diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..090f4c42 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 7ece1dd4..994cca39 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..1f63f40f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index e1455414..ca8b37fc 100644 --- a/README.md +++ b/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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 97d166e6..40b3cb47 100644 --- a/README.zh-CN.md +++ b/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、部署、兼容适配,或围绕本项目的其它客户端/服务端路径。 diff --git a/cmd/appearancefetch/main.go b/cmd/appearancefetch/main.go new file mode 100644 index 00000000..15515370 --- /dev/null +++ b/cmd/appearancefetch/main.go @@ -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 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 ") + 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 ") + } + 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 +} diff --git a/cmd/botcheck/main.go b/cmd/botcheck/main.go new file mode 100644 index 00000000..a0667b18 --- /dev/null +++ b/cmd/botcheck/main.go @@ -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 ":" # 仅登录自检 +// go run ./cmd/botcheck -token ":" -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 :,默认取 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 +} diff --git a/cmd/botdemo/main.go b/cmd/botdemo/main.go new file mode 100644 index 00000000..1156e404 --- /dev/null +++ b/cmd/botdemo/main.go @@ -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 ":" \ +// -chat -chat-hash \ +// -interval 15s -mode rotate +// +// 其它常用参数: +// +// -to -to-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 :(默认取 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 +} diff --git a/cmd/catalogfetch/main.go b/cmd/catalogfetch/main.go new file mode 100644 index 00000000..6159f4fd --- /dev/null +++ b/cmd/catalogfetch/main.go @@ -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 [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 [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) + } +} diff --git a/cmd/giftcheck/main.go b/cmd/giftcheck/main.go new file mode 100644 index 00000000..6ff8cdf1 --- /dev/null +++ b/cmd/giftcheck/main.go @@ -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) +} diff --git a/cmd/langpackfetch/main.go b/cmd/langpackfetch/main.go new file mode 100644 index 00000000..ff6582eb --- /dev/null +++ b/cmd/langpackfetch/main.go @@ -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 [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 [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 +} diff --git a/cmd/starcheck/main.go b/cmd/starcheck/main.go new file mode 100644 index 00000000..3dabb254 --- /dev/null +++ b/cmd/starcheck/main.go @@ -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 +} diff --git a/cmd/stickerfetch/main.go b/cmd/stickerfetch/main.go new file mode 100644 index 00000000..28304883 --- /dev/null +++ b/cmd/stickerfetch/main.go @@ -0,0 +1,385 @@ +// Command stickerfetch 从官方 Telegram 拉取贴纸集(messages.getStickerSet),写成 telesrv +// SeedMedia 可直接导入的 seed 目录格式://set_info.json + stickers/.tgs。 +// 这样 masks / custom-emoji / 系统集等只需把 seed 文件放进 data/sticker-seed 即可被现有 +// SeedMedia + handler 识别下发,无需改服务端代码。 +// +// 支持的 set spec(命令行参数,可多个): +// short: 按 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 [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 [spec...]\n spec: short: | 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/. + 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/. 并返回其 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 +} diff --git a/cmd/telesrv-admin/main.go b/cmd/telesrv-admin/main.go new file mode 100644 index 00000000..cae58e1e --- /dev/null +++ b/cmd/telesrv-admin/main.go @@ -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[:]) +} diff --git a/cmd/telesrv-admin/readstore.go b/cmd/telesrv-admin/readstore.go new file mode 100644 index 00000000..d7bd5724 --- /dev/null +++ b/cmd/telesrv-admin/readstore.go @@ -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) +} diff --git a/cmd/telesrv-admin/server.go b/cmd/telesrv-admin/server.go new file mode 100644 index 00000000..df6b0da0 --- /dev/null +++ b/cmd/telesrv-admin/server.go @@ -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() +} diff --git a/cmd/telesrv-admin/session.go b/cmd/telesrv-admin/session.go new file mode 100644 index 00000000..d66ef12e --- /dev/null +++ b/cmd/telesrv-admin/session.go @@ -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, + }) +} diff --git a/cmd/telesrv-admin/session_test.go b/cmd/telesrv-admin/session_test.go new file mode 100644 index 00000000..d8ad97fa --- /dev/null +++ b/cmd/telesrv-admin/session_test.go @@ -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(), `
`) { + t.Fatalf("spa body missing root: %s", rec.Body.String()) + } +} diff --git a/cmd/telesrv-admin/web/dist/assets/index-IDEWWIS9.css b/cmd/telesrv-admin/web/dist/assets/index-IDEWWIS9.css new file mode 100644 index 00000000..c6d3e784 --- /dev/null +++ b/cmd/telesrv-admin/web/dist/assets/index-IDEWWIS9.css @@ -0,0 +1 @@ +:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--bg:#f3f5f7;--panel:#fff;--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:#176d6129;--shadow:0 18px 52px #10182824}*{box-sizing:border-box}html,body,#root{min-height:100%}body{color:var(--text);background:var(--bg);margin:0;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{grid-template-columns:232px minmax(0,1fr);min-height:100vh;display:grid}.sidebar{color:#eef2f6;background:var(--sidebar);border-right:1px solid var(--sidebar-line);flex-direction:column;gap:16px;height:100vh;padding:18px 12px;display:flex;position:sticky;top:0}.brand{align-items:center;gap:10px;min-height:42px;padding:0 4px;display:flex}.brand.compact{justify-content:center}.brand-elevated .brand-mark{box-shadow:0 8px 24px #176d6142}.brand-mark{color:#fff;background:var(--brand);border:1px solid #fff3;border-radius:8px;place-items:center;width:34px;height:34px;font-weight:800;display:grid}.brand strong{font-size:14px;line-height:1.1;display:block}.brand small{color:#aeb8c4;margin-top:3px;font-size:11px;display:block}.sidebar-label{color:#8492a6;text-transform:uppercase;padding:0 8px;font-size:11px;font-weight:700}.nav-list,.nav-section{gap:4px;display:grid}.nav-section-toggle{color:#8fa0b4;cursor:pointer;text-align:left;background:0 0;border:1px solid #0000;border-radius:7px;grid-template-columns:18px minmax(0,1fr) 16px;align-items:center;gap:9px;width:100%;min-height:38px;padding:0 10px;font-size:12px;font-weight:800;display:grid}.nav-section-toggle:hover,.nav-section.active .nav-section-toggle{color:#fff;background:var(--sidebar-soft);border-color:#34404d}.nav-section-chevron{color:#8fa0b4;justify-self:end;transition:transform .14s}.nav-section.open .nav-section-chevron{transform:rotate(180deg)}.nav-children{gap:4px;padding:2px 0 2px 18px;display:grid}.nav-item{color:#c6d0dc;border:1px solid #0000;border-radius:7px;grid-template-columns:18px minmax(0,1fr);align-items:center;gap:9px;min-height:38px;padding:0 10px;display:grid}.nav-dot{background:#687789;border-radius:999px;justify-self:center;width:6px;height:6px}.nav-item:hover,.nav-item.active{color:#fff;background:var(--sidebar-soft);border-color:#34404d}.nav-item.active .nav-dot{background:var(--brand)}.sidebar-status{gap:7px;margin-top:auto;display:grid}.runtime-row{color:#cbd5df;background:#171d25;border:1px solid #27313c;border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;min-height:32px;padding:0 8px;display:grid}.runtime-row strong{color:#fff;font-size:11px}.workspace{min-width:0}.topbar{z-index:20;border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);background:#fffffff0;justify-content:space-between;align-items:center;gap:18px;min-height:66px;padding:12px 24px;display:flex;position:sticky;top:0}.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{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.actor-pill{color:#344054;background:var(--panel-subtle);border:1px solid var(--line);border-radius:999px;align-items:center;min-height:30px;padding:0 10px;display:inline-flex}.content{gap:16px;padding:18px 24px 30px;display:grid}.eyebrow{color:var(--muted);text-transform:uppercase;font-size:11px;font-weight:800}.dashboard-layout,.stacked-sections{gap:14px;display:grid}.overview-band,.page-frame{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0}.overview-band{grid-template-columns:minmax(220px,1fr) minmax(420px,.9fr);align-items:center;gap:16px;padding:16px;display:grid}.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{grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;display:grid}.overview-metrics{grid-template-columns:repeat(3,minmax(120px,1fr))}.status-item,.metric,.summary-item{background:var(--panel-subtle);border:1px solid var(--line);border-radius:7px;min-width:0;padding:10px}.status-item span,.metric span,.summary-item span{color:var(--muted);margin-bottom:6px;font-size:11px;display:block}.status-item strong,.metric strong,.summary-item strong{overflow-wrap:anywhere;color:var(--text);font-weight:800;display:block}.status-item.good,.metric.good{border-color:#afd8bf}.status-item.warn,.metric.warn{border-color:#e7c77e}.metric.danger{border-color:#efb4ad}.command-grid{grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px;display:grid}.launcher{background:var(--panel);border:1px solid var(--line);border-radius:8px;grid-template-columns:38px minmax(0,1fr) 18px;align-items:center;gap:12px;min-height:94px;padding:14px;display:grid}.launcher:hover{border-color:var(--brand)}.launcher-icon{width:38px;height:38px;color:var(--brand);background:#edf7f4;border:1px solid #c9e2dc;border-radius:8px;place-items:center;display:grid}.launcher-copy{gap:4px;display:grid}.launcher-copy strong{font-size:15px}.launcher-copy span{color:var(--muted)}.work-strip{grid-template-columns:repeat(4,minmax(160px,1fr));gap:8px;display:grid}.strip-item{color:#344054;background:var(--panel);border:1px solid var(--line);border-radius:8px;align-items:center;gap:8px;min-height:38px;padding:0 10px;display:flex}.page-frame{gap:14px;padding:14px;display:grid}.page-title-row{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:14px;padding-bottom:12px;display:flex}.query-panel{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;padding:10px}.toolbar{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.message-query input{width:150px}.message-selector-grid{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;margin-bottom:10px;display:grid}.message-selector-grid.single{grid-template-columns:minmax(320px,620px)}.entity-picker{border:1px solid var(--line);background:#fff;border-radius:8px;gap:8px;min-width:0;padding:10px;display:grid}.picker-head{color:#344054;justify-content:space-between;align-items:center;gap:8px;min-height:24px;font-weight:800;display:flex}.selected-entity{color:#0f3f38;background:#eef8f5;border:1px solid #b9dcd3;border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:8px;min-height:40px;padding:7px 9px;display:grid}.selected-entity strong,.selected-entity span{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.selected-entity div{gap:2px;min-width:0;display:grid}.selected-entity div span{color:#52606d;font-size:11px}.picker-search{background:var(--panel-subtle);border:1px solid var(--line-strong);border-radius:7px;grid-template-columns:18px minmax(0,1fr) auto;align-items:center;gap:7px;height:34px;padding:0 6px 0 9px;display:grid}.picker-search input{width:100%;height:30px;box-shadow:none;background:0 0;border:0;padding:0}.picker-results{border:1px solid var(--line);border-radius:7px;max-height:236px;display:grid;overflow:auto}.picker-row{min-height:36px;color:var(--text);border:0;border-bottom:1px solid var(--line);cursor:pointer;text-align:left;background:#fff;grid-template-columns:96px minmax(120px,1fr) minmax(120px,1fr) auto;align-items:center;gap:8px;padding:6px 8px;display:grid}.picker-row:last-child{border-bottom:0}.picker-row:hover,.picker-row.selected{background:#f3f8f6}.picker-row strong,.picker-row span{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.picker-empty,.picker-error{color:var(--muted);text-align:center;padding:9px}.picker-error{color:var(--danger);background:#fff2f0;border:1px solid #efb4ad;border-radius:7px}input,textarea{color:var(--text);border:1px solid var(--line-strong);background:#fff;border-radius:7px;outline:none}input{width:190px;height:34px;padding:0 10px}textarea{resize:vertical;width:100%;padding:9px 10px}input:focus,textarea:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--focus)}.small-input{width:88px}.field-inline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.field-inline span{font-size:11px;font-weight:700}.searchbox{border:1px solid var(--line-strong);background:#fff;border-radius:7px;align-items:center;gap:8px;width:min(380px,100%);height:34px;padding:0 10px;display:inline-flex}.searchbox input{width:100%;height:30px;box-shadow:none;border:0;padding:0}.btn{color:#1d2939;border:1px solid var(--line-strong);cursor:pointer;white-space:nowrap;background:#fff;border-radius:7px;justify-content:center;align-items:center;gap:6px;min-height:34px;padding:0 12px;display:inline-flex}.btn:hover:not(:disabled){background:#f7f9fb}.btn:disabled{color:var(--muted-2);cursor:not-allowed}.btn.primary{color:#fff;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);border-color:var(--line);cursor:not-allowed;background:#f3f5f7}.btn.full{width:100%}.icon-text{gap:7px}.compact-btn{min-height:28px;padding:0 8px;font-size:12px}.row-link,.link-button{color:var(--brand-2);cursor:pointer;background:0 0;border:0;align-items:center;gap:4px;padding:0;display:inline-flex}.table-wrap{border:1px solid var(--line);border-radius:8px;width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;font-size:12.5px}.data-table th,.data-table td{border-bottom:1px solid var(--line);text-align:left;vertical-align:middle;white-space:nowrap;height:38px;padding:7px 9px}.data-table th{z-index:0;color:#475467;background:var(--panel-strong);font-weight:800;position:sticky;top:0}.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{text-overflow:ellipsis;max-width:380px;overflow:hidden}.badge{color:#4f5b68;white-space:nowrap;background:#f3f6f8;border:1px solid #d7e0e8;border-radius:999px;align-items:center;min-height:22px;padding:1px 8px;display:inline-flex}.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}.split-layout{grid-template-columns:minmax(0,1fr) 330px;align-items:start;gap:14px;display:grid}.split-main,.split-side{min-width:0}.entity-head{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;justify-content:space-between;align-items:flex-start;gap:14px;padding:14px;display:flex}.entity-title{font-size:20px;font-weight:800;line-height:1.25}.entity-subtitle{color:var(--muted);margin-top:4px}.summary-grid{grid-template-columns:repeat(4,minmax(150px,1fr));gap:8px;display:grid}.about-text{color:#344054;border:1px solid var(--line);background:#fbfcfd;border-radius:8px;margin:0;padding:10px}.section-block,.action-dock,.surface{background:var(--panel);border:1px solid var(--line);border-radius:8px;min-width:0;padding:12px}.section-head{justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px;display:flex}.section-head p{color:var(--muted);margin:5px 0 0}.action-dock{gap:10px;display:grid;position:sticky;top:82px}.dock-title{color:#344054;border-bottom:1px solid var(--line);padding-bottom:4px;font-weight:800}.action-dock>.btn,.action-dock .action-stack .btn{justify-content:center;width:100%}.duration-field{gap:4px;display:grid}.duration-field span{color:var(--muted);font-size:11px;font-weight:800}.duration-field input{width:100%}.action-stack{gap:10px;display:grid}.action-stack .btn,.action-dock>.btn{min-height:42px}.danger-zone{border-top:1px solid var(--line);flex-wrap:wrap;gap:8px;margin-top:10px;padding-top:10px;display:flex}.authorization-block{gap:10px;display:grid}.authorization-table{table-layout:fixed;min-width:720px}.authorization-table th,.authorization-table td{height:46px}.device-text{text-overflow:ellipsis;max-width:260px;overflow:hidden}.device-actions-head{width:190px}.device-actions-cell{width:190px;min-width:190px}.device-actions{white-space:normal;grid-template-columns:repeat(2,minmax(82px,1fr));gap:6px;min-width:178px;display:grid}.device-actions .btn{justify-content:center;width:100%}.operation-row{grid-template-columns:repeat(2,minmax(280px,1fr));gap:10px;display:grid}.operation-box{background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;flex-wrap:wrap;align-items:center;gap:8px;padding:10px;display:flex}.operation-title{align-items:center;gap:6px;width:100%;font-weight:800;display:flex}.checkline{color:var(--muted);align-items:center;gap:6px;display:inline-flex}.checkline input{width:auto;height:auto}.alert{color:#8a251d;background:#fff2f0;border:1px solid #efb4ad;border-radius:8px;align-items:flex-start;gap:8px;padding:9px 10px;display:flex}.json-block{color:#d8e6f0;background:#141a22;border:1px solid #2a3542;border-radius:8px;max-height:520px;margin:0;padding:12px;font-size:12px;overflow:auto}.raw-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.loading-line{min-height:80px;color:var(--muted);place-items:center;display:grid}.empty-panel{min-height:92px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;place-items:center;display:grid}.modal-backdrop{z-index:10000;background:#11182785;place-items:center;padding:24px;display:grid;position:fixed;inset:0}.modal{border:1px solid var(--line);width:min(760px,100%);max-height:min(820px,100vh - 48px);box-shadow:var(--shadow);background:#fff;border-radius:8px;padding:0;overflow:hidden}.command-modal{flex-direction:column;display:flex}.modal-head{border-bottom:1px solid var(--line);justify-content:space-between;align-items:flex-start;gap:12px;padding:16px 18px 12px;display:flex}.icon-btn{background:var(--panel-subtle);border:1px solid var(--line);cursor:pointer;border-radius:7px;place-items:center;width:30px;height:30px;display:grid}.command-steps{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;display:grid}.command-body{gap:12px;padding:14px 18px;display:grid;overflow:auto}.command-step{min-height:38px;color:var(--muted);background:var(--panel-subtle);border:1px solid var(--line);border-radius:8px;align-items:center;gap:8px;padding:0 10px;display:flex}.command-step span{border:1px solid var(--line);background:#fff;border-radius:999px;place-items:center;width:20px;height:20px;font-size:11px;font-weight:800;display:grid}.command-step.active{color:var(--brand);border-color:#a9d8ce}.command-step.done{color:var(--good);border-color:#b9dcc7}.form-field{gap:6px;display:grid}.form-field span,.form-stack span{color:#4b5563;font-weight:800}.command-preview{gap:8px;display:grid}.command-preview .json-block{max-height:150px}.preview-head,.result-title{color:#344054;align-items:center;gap:7px;font-weight:800;display:flex}.result-box{border:1px solid var(--line);background:#fbfcfd;border-radius:8px;gap:8px;padding:10px;display:grid}.result-line{grid-template-columns:92px minmax(0,1fr);gap:8px;display:grid}.result-line span{color:var(--muted)}.result-line strong{overflow-wrap:anywhere}.result-message{color:#344054}.modal-actions{border-top:1px solid var(--line);background:#fff;justify-content:flex-end;padding:12px 18px}.login-page{background:var(--bg);place-items:center;min-height:100vh;padding:24px;display:grid}.login-panel{border:1px solid var(--line);width:min(420px,100%);box-shadow:var(--shadow);background:#fff;border-radius:8px;gap:18px;padding:22px;display:grid}.login-head{justify-content:space-between;align-items:center;gap:12px;display:flex}.login-chip{min-height:24px;color:var(--brand);background:#edf7f4;border:1px solid #c9e2dc;border-radius:999px;align-items:center;padding:0 8px;font-size:12px;display:inline-flex}.login-copy h1{margin:0;font-size:22px}.login-copy p{color:var(--muted);margin:8px 0 0}.form-stack{gap:12px;display:grid}.form-stack label{gap:6px;display:grid}.form-stack input{width:100%}.boot-screen{align-content:center;place-items:center;gap:18px;min-height:100vh;display:grid}.loader-bar{background:#d7dde4;border-radius:999px;width:180px;height:4px;overflow:hidden}.loader-bar:before{content:"";background:var(--brand);width:42%;height:100%;animation:1s ease-in-out infinite load;display:block}.spin{animation:.8s linear infinite spin}@keyframes load{0%{transform:translate(-120%)}to{transform:translate(260%)}}@keyframes spin{to{transform:rotate(360deg)}}@media (width<=1120px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.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 (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{flex-direction:column;align-items:flex-start}input,.searchbox{width:100%}.toolbar{align-items:stretch}.picker-row,.selected-entity{grid-template-columns:1fr}} diff --git a/cmd/telesrv-admin/web/dist/assets/index-m3onrdER.js b/cmd/telesrv-admin/web/dist/assets/index-m3onrdER.js new file mode 100644 index 00000000..317d1df5 --- /dev/null +++ b/cmd/telesrv-admin/web/dist/assets/index-m3onrdER.js @@ -0,0 +1,8 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,ee=Object.prototype.hasOwnProperty,S={current:null},C={key:!0,ref:!0,__self:!0,__source:!0};function te(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)ee.call(n,i)&&!C.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m)if(n(c)!==null)m=!0,se(x);else{var t=n(l);t!==null&&ce(b,t.startTime-e)}}function x(t,i){m=!1,h&&(h=!1,_(C),C=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!ne());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&ce(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var ee=!1,S=null,C=-1,te=5,w=-1;function ne(){return!(e.unstable_now()-we||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(C),C=-1):h=!0,ce(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,se(x))),r},e.unstable_shouldYield=ne,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u(),n=f();function r(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;n`u`||window.document===void 0||window.document.createElement===void 0),l=Object.prototype.hasOwnProperty,d=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,p={},m={};function h(e){return l.call(m,e)?!0:l.call(p,e)?!1:d.test(e)?m[e]=!0:(p[e]=!0,!1)}function g(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case`function`:case`symbol`:return!0;case`boolean`:return r?!1:n===null?(e=e.toLowerCase().slice(0,5),e!==`data-`&&e!==`aria-`):!n.acceptsBooleans;default:return!1}}function _(e,t,n,r){if(t==null||g(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return!1===t;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function v(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var y={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){y[e]=new v(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];y[t]=new v(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){y[e]=new v(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){y[e]=new v(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){y[e]=new v(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){y[e]=new v(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){y[e]=new v(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){y[e]=new v(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){y[e]=new v(e,5,!1,e.toLowerCase(),null,!1,!1)});var b=/[\-:]([a-z])/g;function x(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(b,x);y[t]=new v(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!1,!1)}),y.xlinkHref=new v(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){y[e]=new v(e,1,!1,e.toLowerCase(),null,!0,!0)});function ee(e,t,n,r){var i=y.hasOwnProperty(t)?y[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` +`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{he=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?me(e):``}function _e(e){switch(e.tag){case 5:return me(e.type);case 16:return me(`Lazy`);case 13:return me(`Suspense`);case 19:return me(`SuspenseList`);case 0:case 2:case 15:return e=ge(e.type,!1),e;case 11:return e=ge(e.type.render,!1),e;case 1:return e=ge(e.type,!0),e;default:return``}}function ve(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case w:return`Fragment`;case te:return`Portal`;case re:return`Profiler`;case ne:return`StrictMode`;case se:return`Suspense`;case ce:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case ae:return(e.displayName||`Context`)+`.Consumer`;case ie:return(e._context.displayName||`Context`)+`.Provider`;case oe:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case T:return t=e.displayName||null,t===null?ve(e.type)||`Memo`:t;case le:t=e._payload,e=e._init;try{return ve(e(t))}catch{}}return null}function ye(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return ve(t);case 8:return t===ne?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function D(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function be(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function xe(e){var t=be(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Se(e){e._valueTracker||=xe(e)}function Ce(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=be(e)?e.checked?`true`:`false`:e.value),e=r,e===n?!1:(t.setValue(e),!0)}function we(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function Te(e,t){var n=t.checked;return E({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Ee(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=D(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function De(e,t){t=t.checked,t!=null&&ee(e,`checked`,t,!1)}function Oe(e,t){De(e,t);var n=D(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?O(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&O(e,t.type,D(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ke(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function O(e,t,n){(t!==`number`||we(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var Ae=Array.isArray;function je(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=Re.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Be(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Ve={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},He=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(Ve).forEach(function(e){He.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Ve[t]=Ve[e]})});function Ue(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||Ve.hasOwnProperty(e)&&Ve[e]?(``+t).trim():t+`px`}function We(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Ue(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var Ge=E({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Ke(e,t){if(t){if(Ge[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(r(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(r(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(r(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(r(62))}}function k(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var qe=null;function A(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var j=null,Je=null,Ye=null;function Xe(e){if(e=Ji(e)){if(typeof j!=`function`)throw Error(r(280));var t=e.stateNode;t&&(t=Xi(t),j(e.stateNode,e.type,t))}}function Ze(e){Je?Ye?Ye.push(e):Ye=[e]:Je=e}function Qe(){if(Je){var e=Je,t=Ye;if(Ye=Je=null,Xe(e),t)for(e=0;e>>=0,e===0?32:31-(Pt(e)/Ft|0)|0}var Lt=64,Rt=4194304;function zt(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Bt(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=zt(a))):r=zt(s)}else o=n&~i,o===0?a!==0&&(r=zt(a)):r=zt(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Kt(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Nt(t),e[t]=n}function qt(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=cr),dr=` `,fr=!1;function pr(e,t){switch(e){case`keyup`:return or.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function mr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var hr=!1;function gr(e,t){switch(e){case`compositionend`:return mr(t);case`keypress`:return t.which===32?(fr=!0,dr):null;case`textInput`:return e=t.data,e===dr&&fr?null:e;default:return null}}function _r(e,t){if(hr)return e===`compositionend`||!sr&&pr(e,t)?(e=jn(),An=kn=On=null,hr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=zr(n)}}function Vr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Vr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Hr(){for(var e=window,t=we();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=we(e.document)}return t}function Ur(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Wr(e){var t=Hr(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Vr(n.ownerDocument.documentElement,n)){if(r!==null&&Ur(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=Br(n,a);var o=Br(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,Kr=null,qr=null,Jr=null,Yr=!1;function Xr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Yr||Kr==null||Kr!==we(r)||(r=Kr,`selectionStart`in r&&Ur(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Jr&&Rr(Jr,r)||(Jr=r,r=xi(qr,`onSelect`),0Qi||(e.current=Zi[Qi],Zi[Qi]=null,Qi--)}function I(e,t){Qi++,Zi[Qi]=e.current,e.current=t}var ea={},L=$i(ea),ta=$i(!1),na=ea;function ra(e,t){var n=e.type.contextTypes;if(!n)return ea;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function ia(e){return e=e.childContextTypes,e!=null}function aa(){F(ta),F(L)}function oa(e,t,n){if(L.current!==ea)throw Error(r(168));I(L,t),I(ta,n)}function sa(e,t,n){var i=e.stateNode;if(t=t.childContextTypes,typeof i.getChildContext!=`function`)return n;for(var a in i=i.getChildContext(),i)if(!(a in t))throw Error(r(108,ye(e)||`Unknown`,a));return E({},n,i)}function ca(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||ea,na=L.current,I(L,e),I(ta,ta.current),!0}function la(e,t,n){var i=e.stateNode;if(!i)throw Error(r(169));n?(e=sa(e,t,na),i.__reactInternalMemoizedMergedChildContext=e,F(ta),F(L),I(L,e)):F(ta),I(ta,n)}var ua=null,da=!1,fa=!1;function pa(e){ua===null?ua=[e]:ua.push(e)}function ma(e){da=!0,pa(e)}function ha(){if(!fa&&ua!==null){fa=!0;var e=0,t=N;try{var n=ua;for(N=1;e>=o,i-=o,Ca=1<<32-Nt(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(r,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(r,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(r,d),R&&Ta(r,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),R&&Ta(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return R&&Ta(a,g),u}for(h=i(a,h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),R&&Ta(a,g),u}function _(e,r,i,o){if(typeof i==`object`&&i&&i.type===w&&i.key===null&&(i=i.props.children),typeof i==`object`&&i){switch(i.$$typeof){case C:a:{for(var c=i.key,l=r;l!==null;){if(l.key===c){if(c=i.type,c===w){if(l.tag===7){n(e,l.sibling),r=a(l,i.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===le&&Wa(c)===l.type){n(e,l.sibling),r=a(l,i.props),r.ref=Ha(e,l,i),r.return=e,e=r;break a}n(e,l);break}else t(e,l);l=l.sibling}i.type===w?(r=Zl(i.props.children,e.mode,o,i.key),r.return=e,e=r):(o=Xl(i.type,i.key,i.props,null,e.mode,o),o.ref=Ha(e,r,i),o.return=e,e=o)}return s(e);case te:a:{for(l=i.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===i.containerInfo&&r.stateNode.implementation===i.implementation){n(e,r.sibling),r=a(r,i.children||[]),r.return=e,e=r;break a}else{n(e,r);break}else t(e,r);r=r.sibling}r=eu(i,e.mode,o),r.return=e,e=r}return s(e);case le:return l=i._init,_(e,r,l(i._payload),o)}if(Ae(i))return h(e,r,i,o);if(fe(i))return g(e,r,i,o);Ua(e,i)}return typeof i==`string`&&i!==``||typeof i==`number`?(i=``+i,r!==null&&r.tag===6?(n(e,r.sibling),r=a(r,i),r.return=e,e=r):(n(e,r),r=$l(i,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var Ka=Ga(!0),qa=Ga(!1),Ja=$i(null),Ya=null,Xa=null,Za=null;function Qa(){Za=Xa=Ya=null}function $a(e){var t=Ja.current;F(Ja),e._currentValue=t}function eo(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function to(e,t){Ya=e,Za=Xa=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Bs=!0),e.firstContext=null)}function no(e){var t=e._currentValue;if(Za!==e)if(e={context:e,memoizedValue:t,next:null},Xa===null){if(Ya===null)throw Error(r(308));Xa=e,Ya.dependencies={lanes:0,firstContext:e}}else Xa=Xa.next=e;return t}var ro=null;function io(e){ro===null?ro=[e]:ro.push(e)}function ao(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,io(t)):(n.next=i.next,i.next=n),t.interleaved=n,oo(e,r)}function oo(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var so=!1;function co(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function lo(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function uo(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function fo(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,J&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,oo(e,n)}return i=r.interleaved,i===null?(t.next=t,io(r)):(t.next=i.next,i.next=t),r.interleaved=t,oo(e,n)}function po(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Jt(e,n)}}function mo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function ho(e,t,n,r){var i=e.updateQueue;so=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=E({},d,f);break a;case 2:so=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Yc|=o,e.lanes=o,e.memoizedState=d}}function go(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Ao.transition;Ao.transition={};try{e(!1),t()}finally{N=n,Ao.transition=r}}function ps(){return Bo().memoizedState}function ms(e,t,n){var r=ml(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},gs(e))_s(t,n);else if(n=ao(e,t,n,r),n!==null){var i=pl();hl(n,e,r,i),vs(n,t,r)}}function hs(e,t,n){var r=ml(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(gs(e))_s(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Lr(s,o)){var c=t.interleaved;c===null?(i.next=i,io(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=ao(e,t,i,r),n!==null&&(i=pl(),hl(n,e,r,i),vs(n,t,r))}}function gs(e){var t=e.alternate;return e===B||t!==null&&t===B}function _s(e,t){No=Mo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function vs(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Jt(e,n)}}var ys={readContext:no,useCallback:U,useContext:U,useEffect:U,useImperativeHandle:U,useInsertionEffect:U,useLayoutEffect:U,useMemo:U,useReducer:U,useRef:U,useState:U,useDebugValue:U,useDeferredValue:U,useTransition:U,useMutableSource:U,useSyncExternalStore:U,useId:U,unstable_isNewReconciler:!1},bs={readContext:no,useCallback:function(e,t){return zo().memoizedState=[e,t===void 0?null:t],e},useContext:no,useEffect:ns,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),es(4194308,4,os.bind(null,t,e),n)},useLayoutEffect:function(e,t){return es(4194308,4,e,t)},useInsertionEffect:function(e,t){return es(4,2,e,t)},useMemo:function(e,t){var n=zo();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=zo();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=ms.bind(null,B,e),[r.memoizedState,e]},useRef:function(e){var t=zo();return e={current:e},t.memoizedState=e},useState:Zo,useDebugValue:cs,useDeferredValue:function(e){return zo().memoizedState=e},useTransition:function(){var e=Zo(!1),t=e[0];return e=fs.bind(null,e[1]),zo().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var i=B,a=zo();if(R){if(n===void 0)throw Error(r(407));n=n()}else{if(n=t(),Y===null)throw Error(r(349));jo&30||Ko(i,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ns(Jo.bind(null,i,o,e),[e]),i.flags|=2048,Qo(9,qo.bind(null,i,o,n,t),void 0,null),n},useId:function(){var e=zo(),t=Y.identifierPrefix;if(R){var n=wa,r=Ca;n=(r&~(1<<32-Nt(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=Po++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof i.is==`string`?e=c.createElement(n,{is:i.is}):(e=c.createElement(n),n===`select`&&(c=e,i.multiple?c.multiple=!0:i.size&&(c.size=i.size))):e=c.createElementNS(e,n),e[Vi]=t,e[Hi]=i,uc(e,t,!1,!1),t.stateNode=e;a:{switch(c=k(n,i),n){case`dialog`:P(`cancel`,e),P(`close`,e),o=i;break;case`iframe`:case`object`:case`embed`:P(`load`,e),o=i;break;case`video`:case`audio`:for(o=0;otl&&(t.flags|=128,i=!0,pc(s,!1),t.lanes=4194304)}else{if(!i)if(e=Eo(c),e!==null){if(t.flags|=128,i=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),pc(s,!0),s.tail===null&&s.tailMode===`hidden`&&!c.alternate&&!R)return W(t),null}else 2*M()-s.renderingStartTime>tl&&n!==1073741824&&(t.flags|=128,i=!0,pc(s,!1),t.lanes=4194304);s.isBackwards?(c.sibling=t.child,t.child=c):(n=s.last,n===null?t.child=c:n.sibling=c,s.last=c)}return s.tail===null?(W(t),null):(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=M(),t.sibling=null,n=z.current,I(z,i?n&1|2:n&1),t);case 22:case 23:return Tl(),i=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==i&&(t.flags|=8192),i&&t.mode&1?Kc&1073741824&&(W(t),t.subtreeFlags&6&&(t.flags|=8192)):W(t),null;case 24:return null;case 25:return null}throw Error(r(156,t.tag))}function hc(e,t){switch(Oa(t),t.tag){case 1:return ia(t.type)&&aa(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Co(),F(ta),F(L),Oo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return To(t),null;case 13:if(F(z),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(r(340));za()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return F(z),null;case 4:return Co(),null;case 10:return $a(t.type._context),null;case 22:case 23:return Tl(),null;case 24:return null;default:return null}}var gc=!1,G=!1,_c=typeof WeakSet==`function`?WeakSet:Set,K=null;function vc(e,t){var n=e.ref;if(n!==null)if(typeof n==`function`)try{n(null)}catch(n){$(e,t,n)}else n.current=null}function yc(e,t,n){try{n()}catch(n){$(e,t,n)}}var bc=!1;function xc(e,t){if(ki=xn,e=Hr(),Ur(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var i=n.getSelection&&n.getSelection();if(i&&i.rangeCount!==0){n=i.anchorNode;var a=i.anchorOffset,o=i.focusNode;i=i.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||i!==0&&f.nodeType!==3||(l=s+i),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===i&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Ai={focusedElem:e,selectionRange:n},xn=!1,K=t;K!==null;)if(t=K,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,K=e;else for(;K!==null;){t=K;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:Cs(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(r(163))}}catch(e){$(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,K=e;break}K=t.return}return h=bc,bc=!1,h}function Sc(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&yc(t,n,a)}i=i.next}while(i!==r)}}function Cc(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function wc(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function Tc(e){var t=e.alternate;t!==null&&(e.alternate=null,Tc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Vi],delete t[Hi],delete t[Wi],delete t[Gi],delete t[Ki])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Ec(e){return e.tag===5||e.tag===3||e.tag===4}function Dc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Ec(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Oc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Oi));else if(r!==4&&(e=e.child,e!==null))for(Oc(e,t,n),e=e.sibling;e!==null;)Oc(e,t,n),e=e.sibling}function kc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(kc(e,t,n),e=e.sibling;e!==null;)kc(e,t,n),e=e.sibling}var q=null,Ac=!1;function jc(e,t,n){for(n=n.child;n!==null;)Mc(e,t,n),n=n.sibling}function Mc(e,t,n){if(jt&&typeof jt.onCommitFiberUnmount==`function`)try{jt.onCommitFiberUnmount(At,n)}catch{}switch(n.tag){case 5:G||vc(n,t);case 6:var r=q,i=Ac;q=null,jc(e,t,n),q=r,Ac=i,q!==null&&(Ac?(e=q,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):q.removeChild(n.stateNode));break;case 18:q!==null&&(Ac?(e=q,n=n.stateNode,e.nodeType===8?Li(e.parentNode,n):e.nodeType===1&&Li(e,n),yn(e)):Li(q,n.stateNode));break;case 4:r=q,i=Ac,q=n.stateNode.containerInfo,Ac=!0,jc(e,t,n),q=r,Ac=i;break;case 0:case 11:case 14:case 15:if(!G&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&yc(n,t,o),i=i.next}while(i!==r)}jc(e,t,n);break;case 1:if(!G&&(vc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){$(n,t,e)}jc(e,t,n);break;case 21:jc(e,t,n);break;case 22:n.mode&1?(G=(r=G)||n.memoizedState!==null,jc(e,t,n),G=r):jc(e,t,n);break;default:jc(e,t,n)}}function Nc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new _c),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function Pc(e,t){var n=t.deletions;if(n!==null)for(var i=0;ia&&(a=s),i&=~o}if(i=a,i=M()-i,i=(120>i?120:480>i?480:1080>i?1080:1920>i?1920:3e3>i?3e3:4320>i?4320:1960*Hc(i/1960))-i,10e?16:e,sl===null)var i=!1;else{if(e=sl,sl=null,cl=0,J&6)throw Error(r(331));var a=J;for(J|=4,K=e.current;K!==null;){var o=K,s=o.child;if(K.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lM()-el?El(e,0):Zc|=n),gl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=Rt,Rt<<=1,!(Rt&130023424)&&(Rt=4194304)):t=1);var n=pl();e=oo(e,t),e!==null&&(Kt(e,t,n),gl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var i=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:i=e.stateNode;break;default:throw Error(r(314))}i!==null&&i.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ta.current)Bs=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return Bs=!1,lc(e,t,n);Bs=!!(e.flags&131072)}else Bs=!1,R&&t.flags&1048576&&Ea(t,ya,t.index);switch(t.lanes=0,t.tag){case 2:var i=t.type;sc(e,t),e=t.pendingProps;var a=ra(t,L.current);to(t,n),a=Lo(null,t,i,e,a,n);var o=Ro();return t.flags|=1,typeof a==`object`&&a&&typeof a.render==`function`&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,ia(i)?(o=!0,ca(t)):o=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,co(t),a.updater=Ts,t.stateNode=a,a._reactInternals=t,ks(t,i,e,n),t=Ys(null,t,i,!0,o,n)):(t.tag=0,R&&o&&Da(t),Vs(null,t,a,n),t=t.child),t;case 16:i=t.elementType;a:{switch(sc(e,t),e=t.pendingProps,a=i._init,i=a(i._payload),t.type=i,a=t.tag=Jl(i),e=Cs(i,e),a){case 0:t=qs(null,t,i,e,n);break a;case 1:t=Js(null,t,i,e,n);break a;case 11:t=Hs(null,t,i,e,n);break a;case 14:t=Us(null,t,i,Cs(i.type,e),n);break a}throw Error(r(306,i,``))}return t;case 0:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:Cs(i,a),qs(e,t,i,a,n);case 1:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:Cs(i,a),Js(e,t,i,a,n);case 3:a:{if(Xs(t),e===null)throw Error(r(387));i=t.pendingProps,o=t.memoizedState,a=o.element,lo(e,t),ho(t,i,null,n);var s=t.memoizedState;if(i=s.element,o.isDehydrated)if(o={element:i,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){a=As(Error(r(423)),t),t=Zs(e,t,i,n,a);break a}else if(i!==a){a=As(Error(r(424)),t),t=Zs(e,t,i,n,a);break a}else for(Aa=Ri(t.stateNode.containerInfo.firstChild),ka=t,R=!0,ja=null,n=qa(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(za(),i===a){t=cc(e,t,n);break a}Vs(e,t,i,n)}t=t.child}return t;case 5:return wo(t),e===null&&Fa(t),i=t.type,a=t.pendingProps,o=e===null?null:e.memoizedProps,s=a.children,ji(i,a)?s=null:o!==null&&ji(i,o)&&(t.flags|=32),Ks(e,t),Vs(e,t,s,n),t.child;case 6:return e===null&&Fa(t),null;case 13:return ec(e,t,n);case 4:return So(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=Ka(t,null,i,n):Vs(e,t,i,n),t.child;case 11:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:Cs(i,a),Hs(e,t,i,a,n);case 7:return Vs(e,t,t.pendingProps,n),t.child;case 8:return Vs(e,t,t.pendingProps.children,n),t.child;case 12:return Vs(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(i=t.type._context,a=t.pendingProps,o=t.memoizedProps,s=a.value,I(Ja,i._currentValue),i._currentValue=s,o!==null)if(Lr(o.value,s)){if(o.children===a.children&&!ta.current){t=cc(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===i){if(o.tag===1){l=uo(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),eo(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(r(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),eo(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}Vs(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,i=t.pendingProps.children,to(t,n),a=no(a),i=i(a),t.flags|=1,Vs(e,t,i,n),t.child;case 14:return i=t.type,a=Cs(i,t.pendingProps),a=Cs(i.type,a),Us(e,t,i,a,n);case 15:return Ws(e,t,t.type,t.pendingProps,n);case 17:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:Cs(i,a),sc(e,t),t.tag=1,ia(i)?(e=!0,ca(t)):e=!1,to(t,n),Ds(t,i,a),ks(t,i,a,n),Ys(null,t,i,!0,e,n);case 19:return oc(e,t,n);case 22:return Gs(e,t,n)}throw Error(r(156,t.tag))};function Wl(e,t){return bt(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===oe)return 11;if(e===T)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,i,a,o){var s=2;if(i=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case w:return Zl(n.children,a,o,t);case ne:s=8,a|=8;break;case re:return e=Kl(12,n,t,a|2),e.elementType=re,e.lanes=o,e;case se:return e=Kl(13,n,t,a),e.elementType=se,e.lanes=o,e;case ce:return e=Kl(19,n,t,a),e.elementType=ce,e.lanes=o,e;case ue:return Ql(n,a,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case ie:s=10;break a;case ae:s=9;break a;case oe:s=11;break a;case T:s=14;break a;case le:s=16,i=null;break a}throw Error(r(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,a),t.elementType=e,t.type=i,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=ue,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Gt(0),this.expirationTimes=Gt(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Gt(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},co(a),e}function ru(e,t,n){var r=3{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=m();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),g=c(u()),_=c(h(),1),v=class extends Error{status;constructor(e,t){super(t),this.status=e}};async function y(e,t={}){let n=await fetch(e,{credentials:`same-origin`,headers:{"Content-Type":`application/json`,...t.headers??{}},...t}),r=await n.text(),i=r?JSON.parse(r):null;if(!n.ok){let e=i?.error||i?.Error||i?.message||n.statusText;throw new v(n.status,e)}return i}function b(e){return e instanceof Error?e.message:String(e)}var x={session:()=>y(`/api/session`),login:e=>y(`/api/login`,{method:`POST`,body:JSON.stringify({secret:e})}),logout:()=>y(`/api/logout`,{method:`POST`,body:`{}`}),accounts:e=>y(`/api/accounts?${e.toString()}`),account:e=>y(`/api/accounts/${e}`),channels:e=>y(`/api/channels?${e.toString()}`),channel:e=>y(`/api/channels/${e}`),messages:e=>y(`/api/messages?${e.toString()}`),message:(e,t)=>y(`/api/messages/detail?${new URLSearchParams({owner_user_id:String(e),msg_id:String(t)}).toString()}`),groupMessages:e=>y(`/api/messages/groups?${e.toString()}`),groupMessage:(e,t)=>y(`/api/messages/groups/detail?${new URLSearchParams({channel_id:String(e),msg_id:String(t)}).toString()}`),action:(e,t)=>y(e,{method:`POST`,body:JSON.stringify(t)})},ee=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),S=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),C={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},te=(0,g.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>(0,g.createElement)(`svg`,{ref:c,...C,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:S(`lucide`,i),...s},[...o.map(([e,t])=>(0,g.createElement)(e,t)),...Array.isArray(a)?a:[a]])),w=(e,t)=>{let n=(0,g.forwardRef)(({className:n,...r},i)=>(0,g.createElement)(te,{ref:i,iconNode:t,className:S(`lucide-${ee(e)}`,n),...r}));return n.displayName=`${e}`,n},ne=w(`BadgeCheck`,[[`path`,{d:`M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z`,key:`3c2336`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),re=w(`CircleAlert`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`12`,x2:`12`,y1:`8`,y2:`12`,key:`1pkeuh`}],[`line`,{x1:`12`,x2:`12.01`,y1:`16`,y2:`16`,key:`4dfq90`}]]),ie=w(`CircleCheck`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),ae=w(`LoaderCircle`,[[`path`,{d:`M21 12a9 9 0 1 1-6.219-8.56`,key:`13zald`}]]),oe=w(`Sparkles`,[[`path`,{d:`M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z`,key:`4pj2yx`}],[`path`,{d:`M20 3v4`,key:`1olli1`}],[`path`,{d:`M22 5h-4`,key:`1gvqau`}],[`path`,{d:`M4 17v2`,key:`vumght`}],[`path`,{d:`M5 18H3`,key:`zchphs`}]]),se=w(`ArrowLeft`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),ce=w(`Cable`,[[`path`,{d:`M17 21v-2a1 1 0 0 1-1-1v-1a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v1a1 1 0 0 1-1 1`,key:`10bnsj`}],[`path`,{d:`M19 15V6.5a1 1 0 0 0-7 0v11a1 1 0 0 1-7 0V9`,key:`1eqmu1`}],[`path`,{d:`M21 21v-2h-4`,key:`14zm7j`}],[`path`,{d:`M3 5h4V3`,key:`z442eg`}],[`path`,{d:`M7 5a1 1 0 0 1 1 1v1a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a1 1 0 0 1 1-1V3`,key:`ebdjd7`}]]),T=w(`Check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),le=w(`ChevronDown`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]),ue=w(`ChevronRight`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),de=w(`Clock3`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`polyline`,{points:`12 6 12 12 16.5 12`,key:`1aq6pp`}]]),fe=w(`Database`,[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 21 19V5`,key:`1wlel7`}],[`path`,{d:`M3 12A9 3 0 0 0 21 12`,key:`mv7ke4`}]]),E=w(`FileJson`,[[`path`,{d:`M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z`,key:`1rqfz7`}],[`path`,{d:`M14 2v4a2 2 0 0 0 2 2h4`,key:`tnqrlb`}],[`path`,{d:`M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1`,key:`1oajmo`}],[`path`,{d:`M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1`,key:`mpwhp6`}]]),pe=w(`History`,[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`,key:`1357e3`}],[`path`,{d:`M3 3v5h5`,key:`1xhq8a`}],[`path`,{d:`M12 7v5l4 2`,key:`1fdv2h`}]]),me=w(`KeyRound`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),he=w(`LayoutDashboard`,[[`rect`,{width:`7`,height:`9`,x:`3`,y:`3`,rx:`1`,key:`10lvy0`}],[`rect`,{width:`7`,height:`5`,x:`14`,y:`3`,rx:`1`,key:`16une8`}],[`rect`,{width:`7`,height:`9`,x:`14`,y:`12`,rx:`1`,key:`1hutg5`}],[`rect`,{width:`7`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`ldoo1y`}]]),ge=w(`LogOut`,[[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}],[`polyline`,{points:`16 17 21 12 16 7`,key:`1gabdz`}],[`line`,{x1:`21`,x2:`9`,y1:`12`,y2:`12`,key:`1uyos4`}]]),_e=w(`MessageSquareText`,[[`path`,{d:`M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z`,key:`1lielz`}],[`path`,{d:`M13 8H7`,key:`14i4kc`}],[`path`,{d:`M17 12H7`,key:`16if0g`}]]),ve=w(`Play`,[[`polygon`,{points:`6 3 20 12 6 21 6 3`,key:`1oa8hb`}]]),ye=w(`RefreshCw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]),D=w(`Search`,[[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}],[`path`,{d:`m21 21-4.3-4.3`,key:`1qie3q`}]]),be=w(`Server`,[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]]),xe=w(`ShieldCheck`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),Se=w(`Shield`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]),Ce=w(`Trash2`,[[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6`,key:`4alrt4`}],[`path`,{d:`M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2`,key:`v07s0e`}],[`line`,{x1:`10`,x2:`10`,y1:`11`,y2:`17`,key:`1uufr5`}],[`line`,{x1:`14`,x2:`14`,y1:`11`,y2:`17`,key:`xtxkd`}]]),we=w(`Users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`path`,{d:`M16 3.13a4 4 0 0 1 0 7.75`,key:`1da9ce`}]]),Te=w(`X`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]);function Ee(){return{href:`${window.location.pathname}${window.location.search}`,path:window.location.pathname,search:new URLSearchParams(window.location.search)}}function De(e){return e.startsWith(`/accounts`)?`账号管理`:e.startsWith(`/channels`)?`超级群与频道`:e.startsWith(`/messages`)?`消息审计`:`运维控制台`}function Oe(e){return e.startsWith(`/accounts`)?`控制台 / 账号`:e.startsWith(`/channels`)?`控制台 / 频道`:e.startsWith(`/messages`)?`控制台 / 消息`:`控制台 / 总览`}var ke=o((e=>{var t=u(),n=Symbol.for(`react.element`),r=Symbol.for(`react.fragment`),i=Object.prototype.hasOwnProperty,a=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,o={key:!0,ref:!0,__self:!0,__source:!0};function s(e,t,r){var s,c={},l=null,u=null;for(s in r!==void 0&&(l=``+r),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)i.call(t,s)&&!o.hasOwnProperty(s)&&(c[s]=t[s]);if(e&&e.defaultProps)for(s in t=e.defaultProps,t)c[s]===void 0&&(c[s]=t[s]);return{$$typeof:n,type:e,key:l,ref:u,props:c,_owner:a.current}}e.Fragment=r,e.jsx=s,e.jsxs=s})),O=o(((e,t)=>{t.exports=ke()}))();function Ae({href:e,navigate:t,className:n,children:r}){return(0,O.jsx)(`a`,{className:n,href:e,onClick:n=>{n.preventDefault(),t(e)},children:r})}function je(){return(0,O.jsxs)(`div`,{className:`boot-screen`,children:[(0,O.jsxs)(`div`,{className:`brand compact brand-elevated`,children:[(0,O.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,O.jsxs)(`span`,{children:[(0,O.jsx)(`strong`,{children:`telesrv`}),(0,O.jsx)(`small`,{children:`管理控制台`})]})]}),(0,O.jsx)(`div`,{className:`loader-bar`})]})}function Me({actor:e,route:t,navigate:n,onLogout:r,children:i}){let a=t.path.startsWith(`/messages`),[o,s]=(0,g.useState)(a);(0,g.useEffect)(()=>{a&&s(!0)},[a]);async function c(){await x.logout().catch(()=>void 0),r()}return(0,O.jsxs)(`div`,{className:`shell`,children:[(0,O.jsxs)(`aside`,{className:`sidebar`,children:[(0,O.jsxs)(Ae,{className:`brand`,href:`/`,navigate:n,children:[(0,O.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,O.jsxs)(`span`,{children:[(0,O.jsx)(`strong`,{children:`telesrv`}),(0,O.jsx)(`small`,{children:`管理控制台`})]})]}),(0,O.jsx)(`div`,{className:`sidebar-label`,children:`导航`}),(0,O.jsxs)(`nav`,{className:`nav-list`,"aria-label":`主导航`,children:[(0,O.jsx)(Ne,{icon:(0,O.jsx)(he,{size:16}),href:`/`,route:t,navigate:n,children:`总览`}),(0,O.jsx)(Ne,{icon:(0,O.jsx)(we,{size:16}),href:`/accounts`,route:t,navigate:n,children:`账号`}),(0,O.jsx)(Ne,{icon:(0,O.jsx)(xe,{size:16}),href:`/channels`,route:t,navigate:n,children:`超级群/频道`}),(0,O.jsxs)(`div`,{className:`nav-section ${a?`active`:``} ${o?`open`:``}`,children:[(0,O.jsxs)(`button`,{className:`nav-section-toggle`,type:`button`,"aria-expanded":o,onClick:()=>s(e=>!e),children:[(0,O.jsx)(_e,{size:16}),(0,O.jsx)(`span`,{children:`消息`}),(0,O.jsx)(le,{className:`nav-section-chevron`,size:15})]}),o&&(0,O.jsxs)(`div`,{className:`nav-children`,children:[(0,O.jsx)(Ne,{href:`/messages/private`,route:t,navigate:n,activeWhen:e=>e===`/messages`||e===`/messages/detail`||e.startsWith(`/messages/private`),children:`私聊`}),(0,O.jsx)(Ne,{href:`/messages/groups`,route:t,navigate:n,activeWhen:e=>e.startsWith(`/messages/groups`),children:`群聊`})]})]})]}),(0,O.jsxs)(`div`,{className:`sidebar-status`,children:[(0,O.jsx)(`div`,{className:`sidebar-label`,children:`运行状态`}),(0,O.jsxs)(`div`,{className:`runtime-row`,children:[(0,O.jsx)(be,{size:14}),(0,O.jsx)(`span`,{children:`管理后台`}),(0,O.jsx)(`strong`,{children:`就绪`})]}),(0,O.jsxs)(`div`,{className:`runtime-row`,children:[(0,O.jsx)(fe,{size:14}),(0,O.jsx)(`span`,{children:`PG 读取`}),(0,O.jsx)(`strong`,{children:`只读`})]}),(0,O.jsxs)(`div`,{className:`runtime-row`,children:[(0,O.jsx)(Se,{size:14}),(0,O.jsx)(`span`,{children:`写操作`}),(0,O.jsx)(`strong`,{children:`预演`})]})]})]}),(0,O.jsxs)(`div`,{className:`workspace`,children:[(0,O.jsxs)(`header`,{className:`topbar`,children:[(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`div`,{className:`eyebrow`,children:Oe(t.path)}),(0,O.jsx)(`h1`,{children:De(t.path)})]}),(0,O.jsxs)(`div`,{className:`topbar-actions`,children:[(0,O.jsxs)(`span`,{className:`actor-pill`,children:[`操作者:`,e]}),(0,O.jsxs)(`button`,{className:`btn ghost icon-text`,type:`button`,onClick:c,title:`退出`,children:[(0,O.jsx)(ge,{size:16}),` 退出`]})]})]}),(0,O.jsx)(`main`,{className:`content`,children:i})]})]})}function Ne({href:e,route:t,navigate:n,icon:r,children:i,activeWhen:a}){return(0,O.jsxs)(Ae,{className:`nav-item ${(a?a(t.path):e===`/`?t.path===`/`:t.path.startsWith(e))?`active`:``}`,href:e,navigate:n,children:[r??(0,O.jsx)(`span`,{"aria-hidden":`true`,className:`nav-dot`}),(0,O.jsx)(`span`,{children:i})]})}function Pe(e){let t=e.trim();return!t||t.startsWith(`+`)?t:/^\d+$/.test(t)?`+${t}`:t}function Fe(e){let t=e.trim();return t?t.startsWith(`@`)?t:`@${t}`:``}function Ie(e){return`${e.FirstName||``} ${e.LastName||``}`.trim()||`-`}function Le(e){return e.Broadcast&&!e.Megagroup?`频道`:e.Megagroup&&e.Forum?`超级群/论坛`:e.Megagroup?`超级群`:`频道/群`}function Re(e){if(!e||e.startsWith(`0001-`))return``;let t=new Date(e);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function ze(e){if(!e||e<=0)return``;let t=new Date(e*1e3);return Number.isNaN(t.getTime())?``:t.toLocaleString()}function Be(e){if(!e.trim())return 0;let t=Number.parseInt(e,10);return Number.isFinite(t)?t:0}function Ve(e){let t=e.split(/[\s,]+/).map(e=>e.trim()).filter(Boolean).map(e=>Number.parseInt(e,10));if(t.length===0||t.some(e=>!Number.isFinite(e)||e<=0))throw Error(`msg ids invalid`);return t}function He({title:e,eyebrow:t,children:n,actions:r}){return(0,O.jsxs)(`div`,{className:`page-frame`,children:[(0,O.jsxs)(`div`,{className:`page-title-row`,children:[(0,O.jsxs)(`div`,{children:[t&&(0,O.jsx)(`div`,{className:`eyebrow`,children:t}),(0,O.jsx)(`h2`,{children:e})]}),r&&(0,O.jsx)(`div`,{className:`page-actions`,children:r})]}),n]})}function Ue({children:e}){return(0,O.jsx)(`div`,{className:`query-panel`,children:e})}function We({main:e,side:t}){return(0,O.jsxs)(`div`,{className:`split-layout`,children:[(0,O.jsx)(`div`,{className:`split-main`,children:e}),(0,O.jsx)(`aside`,{className:`split-side`,children:t})]})}function Ge({title:e,text:t,action:n}){return(0,O.jsxs)(`div`,{className:`section-head`,children:[(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`h2`,{children:e}),t&&(0,O.jsx)(`p`,{children:t})]}),n&&(0,O.jsx)(`div`,{className:`section-action`,children:n})]})}function Ke({children:e}){return(0,O.jsxs)(`div`,{className:`alert`,children:[(0,O.jsx)(re,{size:16}),` `,(0,O.jsx)(`span`,{children:e})]})}function k({children:e,tone:t=`neutral`}){return(0,O.jsx)(`span`,{className:`badge ${t}`,children:e})}function qe({label:e,value:t,tone:n}){return(0,O.jsxs)(`div`,{className:`status-item ${n}`,children:[(0,O.jsx)(`span`,{children:e}),(0,O.jsx)(`strong`,{children:t})]})}function A({label:e,value:t,tone:n=`neutral`,mono:r=!1}){return(0,O.jsxs)(`div`,{className:`metric ${n}`,children:[(0,O.jsx)(`span`,{children:e}),(0,O.jsx)(`strong`,{className:r?`mono`:``,children:t})]})}function j({label:e,value:t,mono:n=!1}){return(0,O.jsxs)(`div`,{className:`summary-item`,children:[(0,O.jsx)(`span`,{children:e}),(0,O.jsx)(`strong`,{className:n?`mono`:``,children:t})]})}function Je({rows:e}){return(0,O.jsx)(`div`,{className:`table-wrap`,children:(0,O.jsxs)(`table`,{className:`data-table`,children:[(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`th`,{children:`ID`}),(0,O.jsx)(`th`,{children:`命令 ID`}),(0,O.jsx)(`th`,{children:`动作`}),(0,O.jsx)(`th`,{children:`操作者`}),(0,O.jsx)(`th`,{children:`状态`}),(0,O.jsx)(`th`,{children:`预演`}),(0,O.jsx)(`th`,{children:`原因`}),(0,O.jsx)(`th`,{children:`时间`})]})}),(0,O.jsxs)(`tbody`,{children:[e.map(e=>(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`td`,{children:e.ID}),(0,O.jsx)(`td`,{className:`mono`,children:e.CommandID}),(0,O.jsx)(`td`,{children:e.Action}),(0,O.jsx)(`td`,{children:e.Actor}),(0,O.jsx)(`td`,{children:e.Status}),(0,O.jsx)(`td`,{children:e.DryRun?`是`:`否`}),(0,O.jsx)(`td`,{className:`truncate`,children:e.Reason}),(0,O.jsx)(`td`,{children:Re(e.CreatedAt)})]},e.ID)),e.length===0&&(0,O.jsx)(Ye,{colSpan:8})]})]})})}function Ye({colSpan:e}){return(0,O.jsx)(`tr`,{children:(0,O.jsx)(`td`,{colSpan:e,className:`empty-cell`,children:`无结果`})})}function Xe({label:e}){return(0,O.jsx)(`section`,{className:`surface`,children:(0,O.jsx)(`div`,{className:`loading-line`,children:e})})}function Ze({value:e}){return(0,O.jsx)(`pre`,{className:`json-block`,children:e||`{}`})}function Qe({onLogin:e}){let[t,n]=(0,g.useState)(``),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(!1);async function s(n){n.preventDefault(),o(!0),i(``);try{e((await x.login(t)).actor)}catch(e){i(b(e))}finally{o(!1)}}return(0,O.jsx)(`main`,{className:`login-page`,children:(0,O.jsxs)(`section`,{className:`login-panel`,children:[(0,O.jsxs)(`div`,{className:`login-head`,children:[(0,O.jsxs)(`div`,{className:`brand brand-elevated`,children:[(0,O.jsx)(`span`,{className:`brand-mark`,children:`T`}),(0,O.jsxs)(`span`,{children:[(0,O.jsx)(`strong`,{children:`telesrv`}),(0,O.jsx)(`small`,{children:`管理控制台`})]})]}),(0,O.jsx)(`span`,{className:`login-chip`,children:`本地访问`})]}),(0,O.jsxs)(`div`,{className:`login-copy`,children:[(0,O.jsx)(`h1`,{children:`运维后台`}),(0,O.jsx)(`p`,{children:`输入凭据后进入控制台。`})]}),r&&(0,O.jsx)(Ke,{children:r}),(0,O.jsxs)(`form`,{className:`form-stack`,onSubmit:s,children:[(0,O.jsxs)(`label`,{children:[(0,O.jsx)(`span`,{children:`管理员密码或 token`}),(0,O.jsx)(`input`,{autoFocus:!0,type:`password`,value:t,autoComplete:`current-password`,onChange:e=>n(e.target.value)})]}),(0,O.jsx)(`button`,{className:`btn primary full`,type:`submit`,disabled:a,children:a?`登录中`:`登录`})]})]})})}var $e=m();function et({label:e,path:t,payload:n,icon:r,compact:i=!1,tone:a=`danger`,onDone:o}){let[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(``),[d,f]=(0,g.useState)(null),[p,m]=(0,g.useState)(``),[h,_]=(0,g.useState)(!1);function v(){u(``),f(null),m(``)}async function y(e){if(!l.trim()){m(`请填写操作原因`);return}_(!0),m(``);try{let r={...n(),reason:l,confirm:e};f(await x.action(t,r)),e&&o?.()}catch(e){m(b(e))}finally{_(!1)}}let ee=d?.dry_run&&!d.error,S=`btn ${a===`danger`?`danger`:a===`warn`?`warn`:``} ${i?`compact-btn`:``}`,C=(0,g.useMemo)(()=>{try{return n()}catch(e){return{payload_error:b(e)}}},[s,n]);return(0,O.jsxs)(O.Fragment,{children:[(0,O.jsxs)(`button`,{className:S,type:`button`,onClick:()=>{v(),c(!0)},children:[r,e]}),s&&(0,$e.createPortal)((0,O.jsx)(`div`,{className:`modal-backdrop`,role:`presentation`,children:(0,O.jsxs)(`section`,{className:`modal command-modal`,role:`dialog`,"aria-modal":`true`,"aria-label":e,children:[(0,O.jsxs)(`div`,{className:`modal-head`,children:[(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`div`,{className:`eyebrow`,children:`操作流程`}),(0,O.jsx)(`h2`,{children:e})]}),(0,O.jsx)(`button`,{className:`icon-btn`,type:`button`,onClick:()=>c(!1),"aria-label":`关闭`,children:(0,O.jsx)(Te,{size:15})})]}),(0,O.jsxs)(`div`,{className:`command-body`,children:[(0,O.jsxs)(`div`,{className:`command-steps`,children:[(0,O.jsxs)(`div`,{className:`command-step ${l.trim()?`done`:`active`}`,children:[(0,O.jsx)(`span`,{children:`1`}),(0,O.jsx)(`strong`,{children:`填写原因`})]}),(0,O.jsxs)(`div`,{className:`command-step ${d?.dry_run?`done`:l.trim()?`active`:``}`,children:[(0,O.jsx)(`span`,{children:`2`}),(0,O.jsx)(`strong`,{children:`预演检查`})]}),(0,O.jsxs)(`div`,{className:`command-step ${d&&!d.dry_run&&!d.error?`done`:ee?`active`:``}`,children:[(0,O.jsx)(`span`,{children:`3`}),(0,O.jsx)(`strong`,{children:`确认执行`})]})]}),(0,O.jsxs)(`label`,{className:`form-field`,children:[(0,O.jsx)(`span`,{children:`操作原因`}),(0,O.jsx)(`textarea`,{value:l,onChange:e=>u(e.target.value),rows:3,placeholder:`说明本次操作原因`})]}),(0,O.jsxs)(`div`,{className:`command-preview`,children:[(0,O.jsxs)(`div`,{className:`preview-head`,children:[(0,O.jsx)(E,{size:14}),` 请求预览`]}),(0,O.jsx)(Ze,{value:JSON.stringify(C,null,2)})]}),p&&(0,O.jsx)(Ke,{children:p}),d&&(0,O.jsxs)(`div`,{className:`result-box`,children:[(0,O.jsxs)(`div`,{className:`result-title`,children:[d.error?(0,O.jsx)(re,{size:16}):(0,O.jsx)(ie,{size:16}),(0,O.jsx)(`strong`,{children:d.message||d.error||`操作结果`})]}),(0,O.jsxs)(`div`,{className:`result-line`,children:[(0,O.jsx)(`span`,{children:`命令 ID`}),(0,O.jsx)(`strong`,{children:d.command_id})]}),(0,O.jsxs)(`div`,{className:`result-line`,children:[(0,O.jsx)(`span`,{children:`状态`}),(0,O.jsx)(`strong`,{children:d.status})]}),(0,O.jsxs)(`div`,{className:`result-line`,children:[(0,O.jsx)(`span`,{children:`预演`}),(0,O.jsx)(`strong`,{children:d.dry_run?`是`:`否`})]}),(0,O.jsx)(`div`,{className:`result-message`,children:d.message||d.error}),d.details&&(0,O.jsx)(Ze,{value:JSON.stringify(d.details,null,2)})]})]}),(0,O.jsxs)(`div`,{className:`modal-actions`,children:[(0,O.jsx)(`button`,{className:`btn`,type:`button`,onClick:()=>c(!1),children:`关闭`}),(0,O.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>y(!1),disabled:h,children:[h?(0,O.jsx)(ae,{size:15,className:`spin`}):(0,O.jsx)(ve,{size:15}),d?`重新预演`:`先预演`]}),(0,O.jsxs)(`button`,{className:`btn danger icon-text`,type:`button`,onClick:()=>y(!0),disabled:h||!ee,children:[(0,O.jsx)(ie,{size:15}),`确认执行`]})]})]})}),document.body)]})}function tt({rows:e,userID:t,onDone:n}){let[r,i]=(0,g.useState)(()=>new Set);(0,g.useEffect)(()=>{i(new Set)},[t]);let a=(0,g.useMemo)(()=>e.filter(e=>!r.has(e.Hash)),[e,r]);function o(e){i(t=>e(t)),n()}return(0,O.jsxs)(`div`,{className:`authorization-block`,children:[(0,O.jsx)(`div`,{className:`table-wrap`,children:(0,O.jsxs)(`table`,{className:`data-table authorization-table`,children:[(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`th`,{children:`设备`}),(0,O.jsx)(`th`,{children:`平台`}),(0,O.jsx)(`th`,{children:`IP`}),(0,O.jsx)(`th`,{children:`最近活跃`}),(0,O.jsx)(`th`,{className:`device-actions-head`,children:`操作`})]})}),(0,O.jsxs)(`tbody`,{children:[a.map(n=>(0,O.jsxs)(`tr`,{children:[(0,O.jsxs)(`td`,{className:`device-text`,children:[n.DeviceModel,` `,n.SystemVersion]}),(0,O.jsxs)(`td`,{className:`device-text`,children:[n.Platform,` `,n.AppVersion]}),(0,O.jsx)(`td`,{children:n.IP}),(0,O.jsx)(`td`,{children:Re(n.ActiveAt)}),(0,O.jsx)(`td`,{className:`device-actions-cell`,children:(0,O.jsxs)(`div`,{className:`device-actions`,children:[(0,O.jsx)(et,{label:`撤销当前`,icon:(0,O.jsx)(ge,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,hash:n.Hash}),onDone:()=>o(e=>new Set([...e,n.Hash]))}),(0,O.jsx)(et,{label:`保留当前`,icon:(0,O.jsx)(xe,{size:13}),compact:!0,path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,keep_hash:n.Hash}),onDone:()=>o(()=>new Set(e.filter(e=>e.Hash!==n.Hash).map(e=>e.Hash)))})]})})]},n.Hash)),a.length===0&&(0,O.jsx)(Ye,{colSpan:5})]})]})}),(0,O.jsx)(`div`,{className:`danger-zone`,children:(0,O.jsx)(et,{label:`撤销全部设备`,icon:(0,O.jsx)(ce,{size:15}),path:`/api/actions/revoke-sessions`,payload:()=>({user_id:t,revoke_all:!0}),onDone:()=>o(()=>new Set(e.map(e=>e.Hash)))})})]})}function nt({id:e,navigate:t}){let[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(``),[o,s]=(0,g.useState)(!1),[c,l]=(0,g.useState)(`1`);async function u(){s(!0),a(``);try{r(await x.account(e))}catch(e){a(b(e))}finally{s(!1)}}if((0,g.useEffect)(()=>{u()},[e]),i)return(0,O.jsx)(Ke,{children:i});if(!n)return(0,O.jsx)(Xe,{label:o?`加载账号详情`:`等待数据`});let d=n.Account;return(0,O.jsx)(He,{title:`账号 #${d.ID}`,eyebrow:`账号档案`,actions:(0,O.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/accounts`),children:[(0,O.jsx)(se,{size:15}),` 返回列表`]}),children:(0,O.jsx)(We,{main:(0,O.jsxs)(`div`,{className:`stacked-sections`,children:[(0,O.jsxs)(`section`,{className:`entity-head`,children:[(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`div`,{className:`entity-title`,children:Ie(d)}),(0,O.jsxs)(`div`,{className:`entity-subtitle`,children:[Fe(d.Username)||`无用户名`,` · `,Pe(d.Phone)||`无手机号`]})]}),(0,O.jsxs)(`div`,{className:`entity-badges`,children:[d.PremiumUntil>0?(0,O.jsx)(k,{tone:`good`,children:`会员`}):(0,O.jsx)(k,{children:`非会员`}),n.Verified?(0,O.jsx)(k,{tone:`good`,children:`已认证`}):(0,O.jsx)(k,{children:`未认证`}),d.Frozen?(0,O.jsx)(k,{tone:`danger`,children:`发消息冻结`}):(0,O.jsx)(k,{children:`发送正常`})]})]}),(0,O.jsxs)(`div`,{className:`summary-grid`,children:[(0,O.jsx)(j,{label:`用户 ID`,value:String(d.ID),mono:!0}),(0,O.jsx)(j,{label:`最后在线`,value:ze(n.LastSeenAt)||`-`}),(0,O.jsx)(j,{label:`会员到期`,value:d.PremiumUntil>0?ze(d.PremiumUntil):`无`}),(0,O.jsx)(j,{label:`更新时间`,value:Re(d.UpdatedAt)||`-`}),(0,O.jsx)(j,{label:`授权设备`,value:String(n.Authorizations.length)}),(0,O.jsx)(j,{label:`账号标记`,value:`support=${n.Support} bot=${n.Bot}`}),(0,O.jsx)(j,{label:`限制状态`,value:n.HasRestriction?n.Restriction.Reason||`已限制`:`无`}),(0,O.jsx)(j,{label:`创建时间`,value:Re(d.CreatedAt)||`-`})]}),n.About&&(0,O.jsx)(`p`,{className:`about-text`,children:n.About}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`授权设备`,text:`共 ${n.Authorizations.length} 个授权`}),(0,O.jsx)(tt,{rows:n.Authorizations,userID:d.ID,onDone:u})]}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`最近后台操作`,text:`最近 30 条审计`}),(0,O.jsx)(Je,{rows:n.AuditLogs})]})]}),side:(0,O.jsxs)(`section`,{className:`action-dock`,children:[(0,O.jsx)(`div`,{className:`dock-title`,children:`账号操作`}),(0,O.jsx)(et,{label:d.Frozen?`解冻发消息`:`冻结发消息`,icon:(0,O.jsx)(re,{size:15}),path:`/api/actions/freeze-send`,payload:()=>({user_id:d.ID,frozen:!d.Frozen}),onDone:u}),(0,O.jsxs)(`label`,{className:`duration-field`,children:[(0,O.jsx)(`span`,{children:`会员时长(月)`}),(0,O.jsx)(`input`,{"aria-label":`设置会员时长,单位月`,value:c,onChange:e=>l(e.target.value),type:`number`,min:`1`,max:`120`})]}),(0,O.jsxs)(`div`,{className:`action-stack`,children:[(0,O.jsx)(et,{label:`设置会员`,icon:(0,O.jsx)(oe,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:d.ID,months:Be(c)}),onDone:u}),(0,O.jsx)(et,{label:`取消会员`,icon:(0,O.jsx)(oe,{size:15}),tone:`warn`,path:`/api/actions/grant-premium`,payload:()=>({user_id:d.ID,months:0}),onDone:u}),(0,O.jsx)(et,{label:n.Verified?`取消认证`:`设置认证`,icon:(0,O.jsx)(ne,{size:15}),tone:`warn`,path:`/api/actions/set-verified`,payload:()=>({user_id:d.ID,verified:!n.Verified}),onDone:u})]})]})})})}function rt(e){return e.reduce((e,t)=>(e.devices+=t.DeviceCount,t.PremiumUntil>0&&(e.premium+=1),t.Frozen&&(e.frozen+=1),e),{devices:0,premium:0,frozen:0})}function it(e){return e.reduce((e,t)=>(t.Megagroup&&(e.megagroups+=1),t.Broadcast&&(e.broadcasts+=1),t.Verified&&(e.verified+=1),e),{megagroups:0,broadcasts:0,verified:0})}function at({navigate:e}){let[t,n]=(0,g.useState)(``),[r,i]=(0,g.useState)(`50`),[a,o]=(0,g.useState)(null),[s,c]=(0,g.useState)({beforeID:0,beforeActiveUS:0}),[l,u]=(0,g.useState)(!1),[d,f]=(0,g.useState)(``);async function p(e=!1){u(!0),f(``);let n=new URLSearchParams({limit:r});t.trim()?n.set(`q`,t.trim()):e&&(n.set(`before_id`,String(s.beforeID)),n.set(`before_active_us`,String(s.beforeActiveUS)));try{let e=await x.accounts(n);o(e),c({beforeID:e.next_before_id,beforeActiveUS:e.next_before_active_us})}catch(e){f(b(e))}finally{u(!1)}}(0,g.useEffect)(()=>{p(!1)},[]);let m=rt(a?.rows??[]);return(0,O.jsxs)(He,{title:`账号`,eyebrow:a?.listing===!1?`查询结果`:`最近活跃账号`,actions:(0,O.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>p(!1),disabled:l,children:[(0,O.jsx)(ye,{size:15}),` 刷新`]}),children:[d&&(0,O.jsx)(Ke,{children:d}),(0,O.jsxs)(`div`,{className:`metric-row`,children:[(0,O.jsx)(A,{label:`当前页账号`,value:String(a?.rows.length??0)}),(0,O.jsx)(A,{label:`在线设备记录`,value:String(m.devices)}),(0,O.jsx)(A,{label:`会员`,value:String(m.premium),tone:`good`}),(0,O.jsx)(A,{label:`冻结`,value:String(m.frozen),tone:m.frozen>0?`danger`:`neutral`})]}),(0,O.jsx)(Ue,{children:(0,O.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),p(!1)},children:[(0,O.jsxs)(`label`,{className:`searchbox`,children:[(0,O.jsx)(D,{size:15}),(0,O.jsx)(`input`,{value:t,onChange:e=>n(e.target.value),placeholder:`用户 ID / 手机号 / 用户名`})]}),(0,O.jsxs)(`label`,{className:`field-inline`,children:[(0,O.jsx)(`span`,{children:`条数`}),(0,O.jsx)(`input`,{className:`small-input`,value:r,onChange:e=>i(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,O.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:l,children:[l?(0,O.jsx)(ae,{size:15,className:`spin`}):(0,O.jsx)(D,{size:15}),` 查询`]}),a?.listing&&a.has_more&&(0,O.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>p(!0),disabled:l,children:[(0,O.jsx)(ue,{size:15}),` 下一页`]})]})}),(0,O.jsx)(`div`,{className:`table-wrap`,children:(0,O.jsxs)(`table`,{className:`data-table`,children:[(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`th`,{children:`用户 ID`}),(0,O.jsx)(`th`,{children:`手机号`}),(0,O.jsx)(`th`,{children:`用户名`}),(0,O.jsx)(`th`,{children:`姓名`}),(0,O.jsx)(`th`,{children:`设备`}),(0,O.jsx)(`th`,{children:`最近活跃`}),(0,O.jsx)(`th`,{children:`会员`}),(0,O.jsx)(`th`,{children:`认证`}),(0,O.jsx)(`th`,{children:`冻结`}),(0,O.jsx)(`th`,{children:`更新时间`}),(0,O.jsx)(`th`,{})]})}),(0,O.jsxs)(`tbody`,{children:[a?.rows.map(t=>(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`td`,{className:`mono`,children:t.ID}),(0,O.jsx)(`td`,{children:Pe(t.Phone)}),(0,O.jsx)(`td`,{children:Fe(t.Username)}),(0,O.jsx)(`td`,{children:Ie(t)}),(0,O.jsx)(`td`,{children:t.DeviceCount}),(0,O.jsx)(`td`,{children:Re(t.LastActiveAt)}),(0,O.jsx)(`td`,{children:t.PremiumUntil>0?(0,O.jsxs)(k,{tone:`good`,children:[`会员 `,ze(t.PremiumUntil)]}):(0,O.jsx)(k,{children:`无`})}),(0,O.jsx)(`td`,{children:t.Verified?(0,O.jsx)(k,{tone:`good`,children:`已认证`}):(0,O.jsx)(k,{children:`未认证`})}),(0,O.jsx)(`td`,{children:t.Frozen?(0,O.jsx)(k,{tone:`danger`,children:`冻结`}):(0,O.jsx)(k,{children:`正常`})}),(0,O.jsx)(`td`,{children:Re(t.UpdatedAt)}),(0,O.jsx)(`td`,{children:(0,O.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/accounts/${t.ID}`),children:[`详情 `,(0,O.jsx)(ue,{size:14})]})})]},t.ID)),(!a||a.rows.length===0)&&(0,O.jsx)(Ye,{colSpan:11})]})]})})]})}function ot({id:e,navigate:t}){let[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(``);async function o(){a(``);try{r(await x.channel(e))}catch(e){a(b(e))}}if((0,g.useEffect)(()=>{o()},[e]),i)return(0,O.jsx)(Ke,{children:i});if(!n)return(0,O.jsx)(Xe,{label:`加载频道详情`});let s=n.Channel;return(0,O.jsx)(He,{title:`${Le(s)} #${s.ID}`,eyebrow:`频道档案`,actions:(0,O.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>t(`/channels`),children:[(0,O.jsx)(se,{size:15}),` 返回列表`]}),children:(0,O.jsx)(We,{main:(0,O.jsxs)(`div`,{className:`stacked-sections`,children:[(0,O.jsxs)(`section`,{className:`entity-head`,children:[(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`div`,{className:`entity-title`,children:s.Title||`-`}),(0,O.jsxs)(`div`,{className:`entity-subtitle`,children:[Fe(s.Username)||`无用户名`,` · 创建者 `,s.CreatorUserID]})]}),(0,O.jsxs)(`div`,{className:`entity-badges`,children:[(0,O.jsx)(k,{children:Le(s)}),s.Verified?(0,O.jsx)(k,{tone:`good`,children:`已认证`}):(0,O.jsx)(k,{children:`未认证`}),s.Deleted?(0,O.jsx)(k,{tone:`danger`,children:`已删除`}):(0,O.jsx)(k,{children:`有效`})]})]}),(0,O.jsxs)(`div`,{className:`summary-grid`,children:[(0,O.jsx)(j,{label:`频道 ID`,value:String(s.ID),mono:!0}),(0,O.jsx)(j,{label:`access_hash`,value:String(s.AccessHash),mono:!0}),(0,O.jsx)(j,{label:`成员`,value:`${s.ParticipantsCount} / 管理员 ${s.AdminsCount}`}),(0,O.jsx)(j,{label:`治理状态`,value:`封禁 ${s.BannedCount} / 踢出 ${s.KickedCount}`}),(0,O.jsx)(j,{label:`频道标记`,value:`broadcast=${s.Broadcast} megagroup=${s.Megagroup} forum=${s.Forum}`}),(0,O.jsx)(j,{label:`top / pinned / PTS`,value:`${s.TopMessageID} / ${s.PinnedMessageID} / ${s.PTS}`}),(0,O.jsx)(j,{label:`创建时间`,value:ze(s.Date)||`-`}),(0,O.jsx)(j,{label:`更新时间`,value:Re(s.UpdatedAt)||`-`})]}),s.About&&(0,O.jsx)(`p`,{className:`about-text`,children:s.About}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`最近后台操作`,text:`最近 30 条审计`}),(0,O.jsx)(Je,{rows:n.AuditLogs})]}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`频道原始行`,text:`数据库只读快照`}),(0,O.jsx)(Ze,{value:n.ChannelJSON})]})]}),side:(0,O.jsxs)(`section`,{className:`action-dock`,children:[(0,O.jsx)(`div`,{className:`dock-title`,children:`频道操作`}),(0,O.jsx)(et,{label:s.Verified?`取消认证`:`设置认证`,icon:(0,O.jsx)(ne,{size:15}),tone:`warn`,path:`/api/actions/set-channel-verified`,payload:()=>({channel_id:s.ID,verified:!s.Verified}),onDone:o})]})})})}function st({navigate:e}){let[t,n]=(0,g.useState)(``),[r,i]=(0,g.useState)(`50`),[a,o]=(0,g.useState)(null),[s,c]=(0,g.useState)({beforeID:0,beforeUpdatedUS:0}),[l,u]=(0,g.useState)(!1),[d,f]=(0,g.useState)(``);async function p(e=!1){u(!0),f(``);let n=new URLSearchParams({limit:r});t.trim()?n.set(`q`,t.trim()):e&&(n.set(`before_id`,String(s.beforeID)),n.set(`before_updated_us`,String(s.beforeUpdatedUS)));try{let e=await x.channels(n);o(e),c({beforeID:e.next_before_id,beforeUpdatedUS:e.next_before_updated_us})}catch(e){f(b(e))}finally{u(!1)}}(0,g.useEffect)(()=>{p(!1)},[]);let m=it(a?.rows??[]);return(0,O.jsxs)(He,{title:`超级群与频道`,eyebrow:a?.listing===!1?`查询结果`:`最近更新`,actions:(0,O.jsxs)(`button`,{className:`btn`,type:`button`,onClick:()=>p(!1),disabled:l,children:[(0,O.jsx)(ye,{size:15}),` 刷新`]}),children:[d&&(0,O.jsx)(Ke,{children:d}),(0,O.jsxs)(`div`,{className:`metric-row`,children:[(0,O.jsx)(A,{label:`当前页实体`,value:String(a?.rows.length??0)}),(0,O.jsx)(A,{label:`超级群`,value:String(m.megagroups)}),(0,O.jsx)(A,{label:`频道`,value:String(m.broadcasts)}),(0,O.jsx)(A,{label:`已认证`,value:String(m.verified),tone:`good`})]}),(0,O.jsx)(Ue,{children:(0,O.jsxs)(`form`,{className:`toolbar`,onSubmit:e=>{e.preventDefault(),p(!1)},children:[(0,O.jsxs)(`label`,{className:`searchbox`,children:[(0,O.jsx)(D,{size:15}),(0,O.jsx)(`input`,{value:t,onChange:e=>n(e.target.value),placeholder:`频道 ID / 用户名 / 标题`})]}),(0,O.jsxs)(`label`,{className:`field-inline`,children:[(0,O.jsx)(`span`,{children:`条数`}),(0,O.jsx)(`input`,{className:`small-input`,value:r,onChange:e=>i(e.target.value),type:`number`,min:`1`,max:`100`})]}),(0,O.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,disabled:l,children:[l?(0,O.jsx)(ae,{size:15,className:`spin`}):(0,O.jsx)(D,{size:15}),` 查询`]}),a?.listing&&a.has_more&&(0,O.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>p(!0),disabled:l,children:[(0,O.jsx)(ue,{size:15}),` 下一页`]})]})}),(0,O.jsx)(`div`,{className:`table-wrap`,children:(0,O.jsxs)(`table`,{className:`data-table`,children:[(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`th`,{children:`频道 ID`}),(0,O.jsx)(`th`,{children:`类型`}),(0,O.jsx)(`th`,{children:`用户名`}),(0,O.jsx)(`th`,{children:`标题`}),(0,O.jsx)(`th`,{children:`成员`}),(0,O.jsx)(`th`,{children:`管理员`}),(0,O.jsx)(`th`,{children:`PTS`}),(0,O.jsx)(`th`,{children:`认证`}),(0,O.jsx)(`th`,{children:`更新时间`}),(0,O.jsx)(`th`,{})]})}),(0,O.jsxs)(`tbody`,{children:[a?.rows.map(t=>(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`td`,{className:`mono`,children:t.ID}),(0,O.jsx)(`td`,{children:Le(t)}),(0,O.jsx)(`td`,{children:Fe(t.Username)}),(0,O.jsx)(`td`,{children:t.Title}),(0,O.jsx)(`td`,{children:t.ParticipantsCount}),(0,O.jsx)(`td`,{children:t.AdminsCount}),(0,O.jsx)(`td`,{children:t.PTS}),(0,O.jsx)(`td`,{children:t.Verified?(0,O.jsx)(k,{tone:`good`,children:`已认证`}):(0,O.jsx)(k,{children:`未认证`})}),(0,O.jsx)(`td`,{children:Re(t.UpdatedAt)}),(0,O.jsx)(`td`,{children:(0,O.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/channels/${t.ID}`),children:[`详情 `,(0,O.jsx)(ue,{size:14})]})})]},t.ID)),(!a||a.rows.length===0)&&(0,O.jsx)(Ye,{colSpan:10})]})]})})]})}function ct({navigate:e}){return(0,O.jsxs)(`div`,{className:`dashboard-layout`,children:[(0,O.jsxs)(`section`,{className:`overview-band`,children:[(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`div`,{className:`eyebrow`,children:`运行总览`}),(0,O.jsx)(`h2`,{children:`控制台总览`})]}),(0,O.jsxs)(`div`,{className:`overview-metrics`,children:[(0,O.jsx)(qe,{label:`读路径`,value:`PG 只读`,tone:`neutral`}),(0,O.jsx)(qe,{label:`写路径`,value:`Admin API`,tone:`good`}),(0,O.jsx)(qe,{label:`执行策略`,value:`先预演`,tone:`warn`})]})]}),(0,O.jsxs)(`div`,{className:`command-grid`,children:[(0,O.jsx)(lt,{icon:(0,O.jsx)(we,{}),title:`账号管理`,text:`账号状态、会员、认证、会话。`,href:`/accounts`,navigate:e}),(0,O.jsx)(lt,{icon:(0,O.jsx)(xe,{}),title:`超级群与频道`,text:`公开实体、成员计数、认证状态。`,href:`/channels`,navigate:e}),(0,O.jsx)(lt,{icon:(0,O.jsx)(_e,{}),title:`消息审计`,text:`消息盒、update、outbox 状态。`,href:`/messages`,navigate:e})]}),(0,O.jsxs)(`section`,{className:`work-strip`,children:[(0,O.jsxs)(`div`,{className:`strip-item`,children:[(0,O.jsx)(ie,{size:16}),(0,O.jsx)(`span`,{children:`所有危险操作先预演`})]}),(0,O.jsxs)(`div`,{className:`strip-item`,children:[(0,O.jsx)(me,{size:16}),(0,O.jsx)(`span`,{children:`浏览器不持有内部 token`})]}),(0,O.jsxs)(`div`,{className:`strip-item`,children:[(0,O.jsx)(de,{size:16}),(0,O.jsx)(`span`,{children:`列表使用游标分页`})]}),(0,O.jsxs)(`div`,{className:`strip-item`,children:[(0,O.jsx)(E,{size:16}),(0,O.jsx)(`span`,{children:`详情页保留原始状态快照`})]})]})]})}function lt({icon:e,title:t,text:n,href:r,navigate:i}){return(0,O.jsxs)(Ae,{className:`launcher`,href:r,navigate:i,children:[(0,O.jsx)(`span`,{className:`launcher-icon`,children:e}),(0,O.jsxs)(`span`,{className:`launcher-copy`,children:[(0,O.jsx)(`strong`,{children:t}),(0,O.jsx)(`span`,{children:n})]}),(0,O.jsx)(ue,{size:16})]})}function ut({channelID:e,msgID:t,navigate:n}){let[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``);async function s(){o(``);try{i(await x.groupMessage(e,t))}catch(e){o(b(e))}}if((0,g.useEffect)(()=>{s()},[e,t]),a)return(0,O.jsx)(Ke,{children:a});if(!r)return(0,O.jsx)(Xe,{label:`加载群聊消息详情`});let c=r.Message;return(0,O.jsx)(He,{title:`群聊消息 #${c.ID}`,eyebrow:`消息详情`,actions:(0,O.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/groups`),children:[(0,O.jsx)(se,{size:15}),` 返回群聊消息`]}),children:(0,O.jsxs)(`div`,{className:`stacked-sections`,children:[(0,O.jsxs)(`section`,{className:`entity-head`,children:[(0,O.jsxs)(`div`,{children:[(0,O.jsxs)(`div`,{className:`entity-title`,children:[`频道/群 `,c.ChannelID]}),(0,O.jsxs)(`div`,{className:`entity-subtitle`,children:[`发送方 `,c.SenderUserID,` · `,ze(c.Date)]})]}),(0,O.jsxs)(`div`,{className:`entity-badges`,children:[c.Deleted?(0,O.jsx)(k,{tone:`danger`,children:`已删除`}):(0,O.jsx)(k,{children:`存活`}),c.Pinned&&(0,O.jsx)(k,{tone:`warn`,children:`置顶`}),c.Post&&(0,O.jsx)(k,{children:`频道帖子`}),(0,O.jsxs)(k,{children:[`pts `,c.PTS]})]})]}),(0,O.jsxs)(`div`,{className:`summary-grid`,children:[(0,O.jsx)(j,{label:`消息 ID`,value:String(c.ID),mono:!0}),(0,O.jsx)(j,{label:`频道 / 群`,value:String(c.ChannelID),mono:!0}),(0,O.jsx)(j,{label:`From Peer`,value:`${c.FromPeerType}:${c.FromPeerID}`,mono:!0}),(0,O.jsx)(j,{label:`浏览`,value:String(c.ViewsCount)})]}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`消息行`,text:`channel_messages 只读快照`}),(0,O.jsx)(Ze,{value:r.MessageJSON})]}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`频道行`,text:`channels 只读快照`}),(0,O.jsx)(Ze,{value:r.ChannelJSON})]}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`频道更新事件`,text:`durable channel_update_events`}),(0,O.jsx)(`div`,{className:`table-wrap`,children:(0,O.jsxs)(`table`,{className:`data-table`,children:[(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`th`,{children:`PTS`}),(0,O.jsx)(`th`,{children:`数量`}),(0,O.jsx)(`th`,{children:`类型`}),(0,O.jsx)(`th`,{children:`消息 ID`}),(0,O.jsx)(`th`,{children:`发送方`}),(0,O.jsx)(`th`,{children:`时间`})]})}),(0,O.jsxs)(`tbody`,{children:[r.UpdateEvents.map(e=>(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`td`,{children:e.PTS}),(0,O.jsx)(`td`,{children:e.PTSCount}),(0,O.jsx)(`td`,{children:e.Type}),(0,O.jsx)(`td`,{children:e.MessageID}),(0,O.jsx)(`td`,{children:e.SenderUserID}),(0,O.jsx)(`td`,{children:ze(e.Date)})]},`${e.PTS}-${e.Type}-${e.MessageID}`)),r.UpdateEvents.length===0&&(0,O.jsx)(Ye,{colSpan:6})]})]})})]}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`事件 JSON`}),(0,O.jsxs)(`div`,{className:`raw-grid`,children:[r.UpdateEvents.map(e=>(0,O.jsx)(Ze,{value:e.JSON},`${e.PTS}-${e.Type}-json`)),r.UpdateEvents.length===0&&(0,O.jsx)(`div`,{className:`empty-panel`,children:`无结果`})]})]})]})})}function dt({label:e,value:t,onChange:n}){let[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)([]),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(``);async function d(){c(!0),u(``);let e=new URLSearchParams({limit:`20`});r.trim()&&e.set(`q`,r.trim());try{o((await x.accounts(e)).rows)}catch(e){u(b(e))}finally{c(!1)}}return(0,g.useEffect)(()=>{d()},[]),(0,O.jsxs)(`div`,{className:`entity-picker`,children:[(0,O.jsxs)(`div`,{className:`picker-head`,children:[(0,O.jsx)(`span`,{children:e}),t?(0,O.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,O.jsx)(Te,{size:13}),` 清除`]}):null]}),t?(0,O.jsxs)(`div`,{className:`selected-entity`,children:[(0,O.jsx)(T,{size:15}),(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`strong`,{children:Ie(t)}),(0,O.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,O.jsx)(`span`,{children:Fe(t.Username)||Pe(t.Phone)||`-`})]}):null,(0,O.jsxs)(`div`,{className:`picker-search`,children:[(0,O.jsx)(D,{size:15}),(0,O.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),d())},placeholder:`搜索 user_id / phone / username`}),(0,O.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:d,disabled:s,children:s?(0,O.jsx)(ae,{size:14,className:`spin`}):`搜索`})]}),l&&(0,O.jsx)(`div`,{className:`picker-error`,children:l}),(0,O.jsxs)(`div`,{className:`picker-results`,children:[a.map(e=>(0,O.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,O.jsx)(`span`,{className:`mono`,children:e.ID}),(0,O.jsx)(`strong`,{children:Ie(e)}),(0,O.jsx)(`span`,{children:Fe(e.Username)||Pe(e.Phone)||`-`}),e.Verified?(0,O.jsx)(k,{tone:`good`,children:`认证`}):(0,O.jsx)(k,{children:`普通`})]},e.ID)),a.length===0&&!s?(0,O.jsx)(`div`,{className:`picker-empty`,children:`无结果`}):null]})]})}function ft({label:e,value:t,onChange:n}){let[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)([]),[s,c]=(0,g.useState)(!1),[l,u]=(0,g.useState)(``);async function d(){c(!0),u(``);let e=new URLSearchParams({limit:`20`});r.trim()&&e.set(`q`,r.trim());try{o((await x.channels(e)).rows)}catch(e){u(b(e))}finally{c(!1)}}return(0,g.useEffect)(()=>{d()},[]),(0,O.jsxs)(`div`,{className:`entity-picker`,children:[(0,O.jsxs)(`div`,{className:`picker-head`,children:[(0,O.jsx)(`span`,{children:e}),t?(0,O.jsxs)(`button`,{className:`link-button`,type:`button`,onClick:()=>n(null),children:[(0,O.jsx)(Te,{size:13}),` 清除`]}):null]}),t?(0,O.jsxs)(`div`,{className:`selected-entity`,children:[(0,O.jsx)(T,{size:15}),(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`strong`,{children:t.Title||`-`}),(0,O.jsx)(`span`,{className:`mono`,children:t.ID})]}),(0,O.jsx)(`span`,{children:Fe(t.Username)||Le(t)})]}):null,(0,O.jsxs)(`div`,{className:`picker-search`,children:[(0,O.jsx)(D,{size:15}),(0,O.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),d())},placeholder:`搜索 channel_id / username / title`}),(0,O.jsx)(`button`,{className:`btn compact-btn`,type:`button`,onClick:d,disabled:s,children:s?(0,O.jsx)(ae,{size:14,className:`spin`}):`搜索`})]}),l&&(0,O.jsx)(`div`,{className:`picker-error`,children:l}),(0,O.jsxs)(`div`,{className:`picker-results`,children:[a.map(e=>(0,O.jsxs)(`button`,{className:`picker-row ${t?.ID===e.ID?`selected`:``}`,type:`button`,onClick:()=>n(e),children:[(0,O.jsx)(`span`,{className:`mono`,children:e.ID}),(0,O.jsx)(`strong`,{children:e.Title||`-`}),(0,O.jsx)(`span`,{children:Fe(e.Username)||Le(e)}),e.Verified?(0,O.jsx)(k,{tone:`good`,children:`认证`}):(0,O.jsx)(k,{children:Le(e)})]},e.ID)),a.length===0&&!s?(0,O.jsx)(`div`,{className:`picker-empty`,children:`无结果`}):null]})]})}function pt({navigate:e}){let[t,n]=(0,g.useState)(null),[r,i]=(0,g.useState)(``),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(`100`),[l,u]=(0,g.useState)(null),[d,f]=(0,g.useState)(``);async function p(e=!1){if(f(``),!t){f(`请先搜索并选择超级群或频道`);return}let n=new URLSearchParams({channel_id:String(t.ID),limit:s});if(e&&l?.rows.length){let e=l.rows[l.rows.length-1];n.set(`before_date`,String(e.Date)),n.set(`before_id`,String(e.ID)),i(String(e.Date)),o(String(e.ID))}else r&&n.set(`before_date`,r),a&&n.set(`before_id`,a);try{u(await x.groupMessages(n))}catch(e){f(b(e))}}function m(e){n(e),i(``),o(``),u(null)}let h=l?.rows??[];return(0,O.jsxs)(He,{title:`群聊消息`,eyebrow:`超级群 / 频道消息`,children:[d&&(0,O.jsx)(Ke,{children:d}),(0,O.jsxs)(Ue,{children:[(0,O.jsx)(`div`,{className:`message-selector-grid single`,children:(0,O.jsx)(ft,{label:`超级群 / 频道`,value:t,onChange:m})}),(0,O.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),p(!1)},children:[(0,O.jsx)(`input`,{value:r,onChange:e=>i(e.target.value),placeholder:`before_date 游标`}),(0,O.jsx)(`input`,{value:a,onChange:e=>o(e.target.value),placeholder:`before_msg_id 游标`}),(0,O.jsx)(`input`,{className:`small-input`,value:s,onChange:e=>c(e.target.value),placeholder:`条数 <= 100`}),(0,O.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,O.jsx)(D,{size:15}),` 查询消息`]}),h.length?(0,O.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>p(!0),children:[(0,O.jsx)(ue,{size:15}),` 下一页`]}):null]})]}),(0,O.jsxs)(`div`,{className:`metric-row`,children:[(0,O.jsx)(A,{label:`当前页消息`,value:String(h.length)}),(0,O.jsx)(A,{label:`有媒体`,value:String(h.filter(e=>e.Media&&e.Media!==`{}`).length)}),(0,O.jsx)(A,{label:`频道帖子`,value:String(h.filter(e=>e.Post).length)}),(0,O.jsx)(A,{label:`频道 / 群`,value:t?`${t.Title||Le(t)} (${t.ID})`:`-`})]}),(0,O.jsx)(`div`,{className:`table-wrap`,children:(0,O.jsxs)(`table`,{className:`data-table`,children:[(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`th`,{children:`消息 ID`}),(0,O.jsx)(`th`,{children:`时间`}),(0,O.jsx)(`th`,{children:`发送方`}),(0,O.jsx)(`th`,{children:`From Peer`}),(0,O.jsx)(`th`,{children:`PTS`}),(0,O.jsx)(`th`,{children:`浏览`}),(0,O.jsx)(`th`,{children:`状态`}),(0,O.jsx)(`th`,{children:`正文`}),(0,O.jsx)(`th`,{})]})}),(0,O.jsxs)(`tbody`,{children:[h.map(t=>(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`td`,{className:`mono`,children:t.ID}),(0,O.jsx)(`td`,{children:ze(t.Date)}),(0,O.jsx)(`td`,{className:`mono`,children:t.SenderUserID}),(0,O.jsxs)(`td`,{className:`mono`,children:[t.FromPeerType,`:`,t.FromPeerID]}),(0,O.jsx)(`td`,{children:t.PTS}),(0,O.jsx)(`td`,{children:t.ViewsCount}),(0,O.jsx)(`td`,{children:t.Deleted?(0,O.jsx)(k,{tone:`danger`,children:`已删除`}):t.Pinned?(0,O.jsx)(k,{tone:`warn`,children:`置顶`}):(0,O.jsx)(k,{children:`存活`})}),(0,O.jsx)(`td`,{className:`truncate`,children:t.Body}),(0,O.jsx)(`td`,{children:(0,O.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/groups/detail?channel_id=${t.ChannelID}&msg_id=${t.ID}`),children:[`详情 `,(0,O.jsx)(ue,{size:14})]})})]},`${t.ChannelID}-${t.ID}`)),h.length===0&&(0,O.jsx)(Ye,{colSpan:9})]})]})})]})}function mt({ownerUserID:e,msgID:t,navigate:n}){let[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``);async function s(){o(``);try{i(await x.message(e,t))}catch(e){o(b(e))}}if((0,g.useEffect)(()=>{s()},[e,t]),a)return(0,O.jsx)(Ke,{children:a});if(!r)return(0,O.jsx)(Xe,{label:`加载消息详情`});let c=r.Message;return(0,O.jsx)(He,{title:`消息 #${c.BoxID}`,eyebrow:`消息详情`,actions:(0,O.jsxs)(`button`,{className:`btn icon-text`,onClick:()=>n(`/messages/private`),children:[(0,O.jsx)(se,{size:15}),` 返回私聊消息`]}),children:(0,O.jsx)(We,{main:(0,O.jsxs)(`div`,{className:`stacked-sections`,children:[(0,O.jsxs)(`section`,{className:`entity-head`,children:[(0,O.jsxs)(`div`,{children:[(0,O.jsxs)(`div`,{className:`entity-title`,children:[`所属 `,c.OwnerUserID,` · 对端 `,c.PeerID]}),(0,O.jsxs)(`div`,{className:`entity-subtitle`,children:[`发送方 `,c.FromUserID,` · `,ze(c.Date)]})]}),(0,O.jsxs)(`div`,{className:`entity-badges`,children:[c.Deleted?(0,O.jsx)(k,{tone:`danger`,children:`已删除`}):(0,O.jsx)(k,{children:`存活`}),(0,O.jsxs)(k,{children:[`pts `,c.PTS]}),(0,O.jsx)(k,{children:c.Outgoing?`发出`:`收到`})]})]}),(0,O.jsxs)(`div`,{className:`summary-grid`,children:[(0,O.jsx)(j,{label:`消息盒 ID`,value:String(c.BoxID),mono:!0}),(0,O.jsx)(j,{label:`私聊消息 ID`,value:String(c.PrivateMessageID),mono:!0}),(0,O.jsx)(j,{label:`发送方`,value:String(c.MessageSenderID),mono:!0}),(0,O.jsx)(j,{label:`时间`,value:ze(c.Date)})]}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`消息盒`,text:`message_boxes 只读快照`}),(0,O.jsx)(Ze,{value:r.MessageJSON})]}),(0,O.jsxs)(`div`,{className:`raw-grid`,children:[(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`会话行`,text:`dialogs 只读快照`}),(0,O.jsx)(Ze,{value:r.DialogJSON})]}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`私聊消息行`,text:`private_messages 只读快照`}),(0,O.jsx)(Ze,{value:r.PrivateJSON})]})]}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`更新事件`,text:`durable user_update_events`}),(0,O.jsx)(`div`,{className:`table-wrap`,children:(0,O.jsxs)(`table`,{className:`data-table`,children:[(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`th`,{children:`PTS`}),(0,O.jsx)(`th`,{children:`数量`}),(0,O.jsx)(`th`,{children:`类型`}),(0,O.jsx)(`th`,{children:`时间`})]})}),(0,O.jsxs)(`tbody`,{children:[r.UpdateEvents.map(e=>(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`td`,{children:e.PTS}),(0,O.jsx)(`td`,{children:e.PTSCount}),(0,O.jsx)(`td`,{children:e.Type}),(0,O.jsx)(`td`,{children:ze(e.Date)})]},`${e.PTS}-${e.Type}`)),r.UpdateEvents.length===0&&(0,O.jsx)(Ye,{colSpan:4})]})]})})]}),(0,O.jsxs)(`section`,{className:`section-block`,children:[(0,O.jsx)(Ge,{title:`分发队列`,text:`在线/离线 dispatch_outbox`}),(0,O.jsx)(`div`,{className:`table-wrap`,children:(0,O.jsxs)(`table`,{className:`data-table`,children:[(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`th`,{children:`ID`}),(0,O.jsx)(`th`,{children:`用户`}),(0,O.jsx)(`th`,{children:`PTS`}),(0,O.jsx)(`th`,{children:`类型`}),(0,O.jsx)(`th`,{children:`状态`}),(0,O.jsx)(`th`,{children:`尝试`}),(0,O.jsx)(`th`,{children:`更新时间`})]})}),(0,O.jsxs)(`tbody`,{children:[r.Outbox.map(e=>(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`td`,{children:e.ID}),(0,O.jsx)(`td`,{children:e.TargetUserID}),(0,O.jsx)(`td`,{children:e.PTS}),(0,O.jsx)(`td`,{children:e.EventType}),(0,O.jsx)(`td`,{children:e.Status}),(0,O.jsx)(`td`,{children:e.Attempts}),(0,O.jsx)(`td`,{children:Re(e.UpdatedAt)})]},e.ID)),r.Outbox.length===0&&(0,O.jsx)(Ye,{colSpan:7})]})]})})]})]}),side:(0,O.jsxs)(`section`,{className:`action-dock`,children:[(0,O.jsx)(`div`,{className:`dock-title`,children:`消息操作`}),(0,O.jsx)(et,{label:`删除此消息`,icon:(0,O.jsx)(Ce,{size:15}),path:`/api/actions/delete-messages`,payload:()=>({owner_user_id:c.OwnerUserID,peer_id:c.PeerID,ids:[c.BoxID],revoke:!0}),onDone:s})]})})})}function ht({navigate:e}){let[t,n]=(0,g.useState)(null),[r,i]=(0,g.useState)(null),[a,o]=(0,g.useState)(``),[s,c]=(0,g.useState)(``),[l,u]=(0,g.useState)(`100`),[d,f]=(0,g.useState)(``),[p,m]=(0,g.useState)(!0),[h,_]=(0,g.useState)(!1),[v,y]=(0,g.useState)(``),[ee,S]=(0,g.useState)(`1`),[C,te]=(0,g.useState)(null),[w,ne]=(0,g.useState)(``);async function re(e=!1){if(ne(``),!t||!r){ne(`请先搜索并选择所属用户和对端用户`);return}let n=new URLSearchParams({owner_user_id:String(t.ID),peer_id:String(r.ID),limit:l});if(e&&C?.rows.length){let e=C.rows[C.rows.length-1];n.set(`before_date`,String(e.Date)),n.set(`before_id`,String(e.BoxID)),o(String(e.Date)),c(String(e.BoxID))}else a&&n.set(`before_date`,a),s&&n.set(`before_id`,s);try{te(await x.messages(n))}catch(e){ne(b(e))}}function ie(e){n(e),o(``),c(``),te(null)}function ae(e){i(e),o(``),c(``),te(null)}return(0,O.jsxs)(He,{title:`私聊消息`,eyebrow:`私聊消息盒`,children:[w&&(0,O.jsx)(Ke,{children:w}),(0,O.jsxs)(Ue,{children:[(0,O.jsxs)(`div`,{className:`message-selector-grid`,children:[(0,O.jsx)(dt,{label:`所属用户`,value:t,onChange:ie}),(0,O.jsx)(dt,{label:`对端用户`,value:r,onChange:ae})]}),(0,O.jsxs)(`form`,{className:`toolbar message-query`,onSubmit:e=>{e.preventDefault(),re(!1)},children:[(0,O.jsx)(`input`,{value:a,onChange:e=>o(e.target.value),placeholder:`before_date 游标`}),(0,O.jsx)(`input`,{value:s,onChange:e=>c(e.target.value),placeholder:`before_msg_id 游标`}),(0,O.jsx)(`input`,{className:`small-input`,value:l,onChange:e=>u(e.target.value),placeholder:`条数 <= 100`}),(0,O.jsxs)(`button`,{className:`btn primary icon-text`,type:`submit`,children:[(0,O.jsx)(D,{size:15}),` 查询消息`]}),C?.rows.length?(0,O.jsxs)(`button`,{className:`btn icon-text`,type:`button`,onClick:()=>re(!0),children:[(0,O.jsx)(ue,{size:15}),` 下一页`]}):null]})]}),(0,O.jsxs)(`div`,{className:`metric-row`,children:[(0,O.jsx)(A,{label:`当前页消息`,value:String(C?.rows.length??0)}),(0,O.jsx)(A,{label:`已删除`,value:String((C?.rows??[]).filter(e=>e.Deleted).length),tone:`danger`}),(0,O.jsx)(A,{label:`发出消息`,value:String((C?.rows??[]).filter(e=>e.Outgoing).length)}),(0,O.jsx)(A,{label:`所属 / 对端`,value:t&&r?`${Ie(t)} / ${Ie(r)}`:`-`})]}),(0,O.jsxs)(`div`,{className:`operation-row`,children:[(0,O.jsxs)(`div`,{className:`operation-box`,children:[(0,O.jsxs)(`div`,{className:`operation-title`,children:[(0,O.jsx)(Ce,{size:15}),` 删除指定消息`]}),(0,O.jsx)(`input`,{value:d,onChange:e=>f(e.target.value),placeholder:`消息 ID,逗号分隔`}),(0,O.jsxs)(`label`,{className:`checkline`,children:[(0,O.jsx)(`input`,{type:`checkbox`,checked:p,onChange:e=>m(e.target.checked)}),` 同步撤回`]}),(0,O.jsx)(et,{path:`/api/actions/delete-messages`,label:`预演删除`,payload:()=>({owner_user_id:t?.ID??0,peer_id:r?.ID??0,ids:Ve(d),revoke:p})})]}),(0,O.jsxs)(`div`,{className:`operation-box`,children:[(0,O.jsxs)(`div`,{className:`operation-title`,children:[(0,O.jsx)(pe,{size:15}),` 清空私聊历史`]}),(0,O.jsx)(`input`,{value:v,onChange:e=>y(e.target.value),placeholder:`max_id 截止消息`}),(0,O.jsx)(`input`,{value:ee,onChange:e=>S(e.target.value),placeholder:`max_batches 批次数`}),(0,O.jsxs)(`label`,{className:`checkline`,children:[(0,O.jsx)(`input`,{type:`checkbox`,checked:p,onChange:e=>m(e.target.checked)}),` 同步撤回`]}),(0,O.jsxs)(`label`,{className:`checkline`,children:[(0,O.jsx)(`input`,{type:`checkbox`,checked:h,onChange:e=>_(e.target.checked)}),` 仅清本侧`]}),(0,O.jsx)(et,{path:`/api/actions/delete-history`,label:`预演清历史`,payload:()=>({owner_user_id:t?.ID??0,peer_id:r?.ID??0,max_id:Be(v),max_batches:Be(ee),just_clear:h,revoke:p})})]})]}),(0,O.jsx)(`div`,{className:`table-wrap`,children:(0,O.jsxs)(`table`,{className:`data-table`,children:[(0,O.jsx)(`thead`,{children:(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`th`,{children:`消息 ID`}),(0,O.jsx)(`th`,{children:`时间`}),(0,O.jsx)(`th`,{children:`发送方`}),(0,O.jsx)(`th`,{children:`方向`}),(0,O.jsx)(`th`,{children:`PTS`}),(0,O.jsx)(`th`,{children:`状态`}),(0,O.jsx)(`th`,{children:`正文`}),(0,O.jsx)(`th`,{})]})}),(0,O.jsxs)(`tbody`,{children:[C?.rows.map(t=>(0,O.jsxs)(`tr`,{children:[(0,O.jsx)(`td`,{className:`mono`,children:t.BoxID}),(0,O.jsx)(`td`,{children:ze(t.Date)}),(0,O.jsx)(`td`,{className:`mono`,children:t.FromUserID}),(0,O.jsx)(`td`,{children:t.Outgoing?`发出`:`收到`}),(0,O.jsx)(`td`,{children:t.PTS}),(0,O.jsx)(`td`,{children:t.Deleted?(0,O.jsx)(k,{tone:`danger`,children:`已删除`}):(0,O.jsx)(k,{children:`存活`})}),(0,O.jsx)(`td`,{className:`truncate`,children:t.Body}),(0,O.jsx)(`td`,{children:(0,O.jsxs)(`button`,{className:`row-link`,onClick:()=>e(`/messages/private/detail?owner_user_id=${t.OwnerUserID}&msg_id=${t.BoxID}`),children:[`详情 `,(0,O.jsx)(ue,{size:14})]})})]},`${t.OwnerUserID}-${t.BoxID}`)),(!C||C.rows.length===0)&&(0,O.jsx)(Ye,{colSpan:8})]})]})})]})}function gt({route:e,navigate:t}){let n=e.path.match(/^\/accounts\/(\d+)$/)?.[1],r=e.path.match(/^\/channels\/(\d+)$/)?.[1];return n?(0,O.jsx)(nt,{id:Number(n),navigate:t}):r?(0,O.jsx)(ot,{id:Number(r),navigate:t}):e.path===`/accounts`?(0,O.jsx)(at,{navigate:t}):e.path===`/channels`?(0,O.jsx)(st,{navigate:t}):e.path===`/messages/detail`||e.path===`/messages/private/detail`?(0,O.jsx)(mt,{ownerUserID:Number(e.search.get(`owner_user_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups/detail`?(0,O.jsx)(ut,{channelID:Number(e.search.get(`channel_id`)||`0`),msgID:Number(e.search.get(`msg_id`)||`0`),navigate:t}):e.path===`/messages/groups`?(0,O.jsx)(pt,{navigate:t}):e.path===`/messages`||e.path===`/messages/private`?(0,O.jsx)(ht,{navigate:t}):(0,O.jsx)(ct,{navigate:t})}function _t(){let[e,t]=(0,g.useState)(void 0),[n,r]=(0,g.useState)(()=>Ee());(0,g.useEffect)(()=>{let e=()=>r(Ee());return window.addEventListener(`popstate`,e),()=>window.removeEventListener(`popstate`,e)},[]),(0,g.useEffect)(()=>{x.session().then(e=>t(e.actor)).catch(e=>{if(e instanceof v&&e.status===401){t(null);return}t(null)})},[]);let i=e=>{window.history.pushState(null,``,e),r(Ee())};return e===void 0?(0,O.jsx)(je,{}):e===null?(0,O.jsx)(Qe,{onLogin:t}):(0,O.jsx)(Me,{actor:e,route:n,navigate:i,onLogout:()=>t(null),children:(0,O.jsx)(gt,{route:n,navigate:i})})}_.createRoot(document.getElementById(`root`)).render((0,O.jsx)(g.StrictMode,{children:(0,O.jsx)(_t,{})})); \ No newline at end of file diff --git a/cmd/telesrv-admin/web/dist/index.html b/cmd/telesrv-admin/web/dist/index.html new file mode 100644 index 00000000..c7fa4691 --- /dev/null +++ b/cmd/telesrv-admin/web/dist/index.html @@ -0,0 +1,13 @@ + + + + + + telesrv admin + + + + +
+ + diff --git a/cmd/telesrv-admin/web/index.html b/cmd/telesrv-admin/web/index.html new file mode 100644 index 00000000..bc4cfccc --- /dev/null +++ b/cmd/telesrv-admin/web/index.html @@ -0,0 +1,12 @@ + + + + + + telesrv admin + + +
+ + + diff --git a/cmd/telesrv-admin/web/package-lock.json b/cmd/telesrv-admin/web/package-lock.json new file mode 100644 index 00000000..c905b1f3 --- /dev/null +++ b/cmd/telesrv-admin/web/package-lock.json @@ -0,0 +1,1017 @@ +{ + "name": "telesrv-admin-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "telesrv-admin-ui", + "version": "0.1.0", + "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" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/cmd/telesrv-admin/web/package.json b/cmd/telesrv-admin/web/package.json new file mode 100644 index 00000000..88828dd8 --- /dev/null +++ b/cmd/telesrv-admin/web/package.json @@ -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" + } +} diff --git a/cmd/telesrv-admin/web/src/App.tsx b/cmd/telesrv-admin/web/src/App.tsx new file mode 100644 index 00000000..bbde52f0 --- /dev/null +++ b/cmd/telesrv-admin/web/src/App.tsx @@ -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(undefined); + const [route, setRoute] = useState(() => 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 ; + } + + if (actor === null) { + return ; + } + + return ( + setActor(null)}> + + + ); +} diff --git a/cmd/telesrv-admin/web/src/api.ts b/cmd/telesrv-admin/web/src/api.ts new file mode 100644 index 00000000..61591052 --- /dev/null +++ b/cmd/telesrv-admin/web/src/api.ts @@ -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(url: string, init: RequestInit = {}): Promise { + 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(`/api/accounts?${params.toString()}`), + account: (id: number) => request(`/api/accounts/${id}`), + channels: (params: URLSearchParams) => request(`/api/channels?${params.toString()}`), + channel: (id: number) => request(`/api/channels/${id}`), + messages: (params: URLSearchParams) => request(`/api/messages?${params.toString()}`), + message: (ownerUserID: number, msgID: number) => { + const params = new URLSearchParams({ owner_user_id: String(ownerUserID), msg_id: String(msgID) }); + return request(`/api/messages/detail?${params.toString()}`); + }, + groupMessages: (params: URLSearchParams) => request(`/api/messages/groups?${params.toString()}`), + groupMessage: (channelID: number, msgID: number) => { + const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) }); + return request(`/api/messages/groups/detail?${params.toString()}`); + }, + action: (path: string, payload: Record) => request(path, { + method: "POST", + body: JSON.stringify(payload) + }) +}; diff --git a/cmd/telesrv-admin/web/src/components/ActionButton.tsx b/cmd/telesrv-admin/web/src/components/ActionButton.tsx new file mode 100644 index 00000000..fe6fa6d3 --- /dev/null +++ b/cmd/telesrv-admin/web/src/components/ActionButton.tsx @@ -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; + icon?: ReactNode; + compact?: boolean; + tone?: ActionTone; + onDone?: () => void; +}) { + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(""); + const [result, setResult] = useState(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 ( + <> + + {open && createPortal( +
+
+
+
+
操作流程
+

{label}

+
+ +
+
+
+
+ 1填写原因 +
+
+ 2预演检查 +
+
+ 3确认执行 +
+
+
+
+ + +
+
+
+ +
+

Recent activity

+
    +
    + + +
    +
    +
    +
    + + + + + +` diff --git a/cmd/walletminiapp/main_test.go b/cmd/walletminiapp/main_test.go new file mode 100644 index 00000000..9444cba1 --- /dev/null +++ b/cmd/walletminiapp/main_test.go @@ -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") + } +} diff --git a/data/langpack/android/android_en_v59634849.strings b/data/langpack/android/android_en_v59634849.strings new file mode 100644 index 00000000..b6880948 --- /dev/null +++ b/data/langpack/android/android_en_v59634849.strings @@ -0,0 +1,11655 @@ +"AIEditor" = "AI Editor"; +"AIEditorAddStyle" = "Add Style"; +"AIEditorAfter" = "After"; +"AIEditorAnotherExample" = "Another Example"; +"AIEditorApply" = "Apply"; +"AIEditorBefore" = "Before"; +"AIEditorChooseStyle" = "Choose a style"; +"AIEditorCreatedBy" = "Created by **%s**."; +"AIEditorDeleteStyle" = "Delete Style"; +"AIEditorDeleteStyleText" = "Are you sure you want to delete this style? It will be removed for everyone who installed it."; +"AIEditorEditStyle" = "Edit Style"; +"AIEditorEmojify" = "emojify"; +"AIEditorFrom" = "From %s"; +"AIEditorFromOther" = "From %s"; +"AIEditorHint" = "Rewrite, translate and correct your text using AI."; +"AIEditorLimitButton" = "Increase Limit"; +"AIEditorLimitText" = "Get **Telegram Premium** for **50x** more text edits per day."; +"AIEditorLimitTitle" = "Daily Limit Reached"; +"AIEditorNewStyle" = "New Style"; +"AIEditorOriginal" = "Original"; +"AIEditorOriginalText" = "Original Text"; +"AIEditorRemoveStyle" = "Remove Style"; +"AIEditorResult" = "Result"; +"AIEditorShareStyle" = "Share Style"; +"AIEditorStyleAddLink" = "Add a link to my account"; +"AIEditorStyleCreate" = "Create"; +"AIEditorStyleDone" = "Done"; +"AIEditorStyleEdit" = "Save"; +"AIEditorStyleLimitText" = "You can save up to %1$d styles."; +"AIEditorStyleLimitTextPremium" = "Subscribe to **Telegram Premium** to save up to %2$d styles."; +"AIEditorStyleLimitTitle" = "Limit Reached"; +"AIEditorStyleNewCreate" = "Create"; +"AIEditorStyleNotFound" = "AI Style not found."; +"AIEditorStylePromptHint" = "Instructions (for example, \"Write like a swashbuckling pirate. Use arr, ye, matey, and talk about treasure and the sea\")"; +"AIEditorStyleSharedTo" = "AI Style link sent to **%s**."; +"AIEditorStyleSharedToManyChats#one" = "AI Style link sent to **a chat**."; +"AIEditorStyleSharedToManyChats#other" = "AI Style link sent to **%1$d chats**."; +"AIEditorStyleSharedToSavedMessages" = "AI Style link sent to **Saved Messages**"; +"AIEditorStyleText" = "Add this style to instantly\nrewrite your messages."; +"AIEditorStyleTitleHint" = "Style Name (for example: \"Pirate\")"; +"AIEditorTabFix" = "Fix"; +"AIEditorTabStyle" = "Style"; +"AIEditorTabTranslate" = "Translate"; +"AIEditorTo" = "To %s"; +"AIEditorToOther" = "To %s"; +"AIEditorToneAddedText" = "Tap 'AI' > '%s' when typing your next long message."; +"AIEditorToneAddedTitle" = "Style Added"; +"AIEditorToneCreatedText" = "Press and hold the style to edit or share it."; +"AIEditorToneCreatedTitle" = "%s Style Created"; +"AIEditorToneNeutral" = "Neutral"; +"AIEditorUsedBy#one" = "Used by %d person."; +"AIEditorUsedBy#other" = "Used by %d people."; +"ALongTimeAgo" = "last seen a long time ago"; +"Abort" = "Abort"; +"AbortEmail" = "Abort Recovery Email Setup"; +"AbortPassword" = "Abort two-step verification setup"; +"AbortPasswordMenu" = "Abort Setup"; +"AboutPremiumDescription" = "While the free version of Telegram already gives its users more than any other messaging application, **Telegram Premium** pushes its capabilities even further."; +"AboutPremiumDescription2" = "**Telegram Premium** is a paid option, because most Premium Features require additional expenses from Telegram to third parties such as data center providers and server manufacturers. Contributions from **Telegram Premium** users allow us to cover such costs and also help Telegram stay free for everyone."; +"AboutPremiumTitle" = "About Telegram Premium"; +"AboutRevenueSharingAds" = "About These Ads"; +"AboutTelegramPremium" = "About Telegram Premium"; +"AccActionCancelDownload" = "Cancel download"; +"AccActionChatPreview" = "Chat Preview"; +"AccActionCloseTranscription" = "Close Transcription"; +"AccActionDownload" = "Download"; +"AccActionEnterSelectionMode" = "Enter selection mode"; +"AccActionMessageOptions" = "Message options"; +"AccActionOnceActivate" = "Activate Play Once"; +"AccActionOnceDeactivate" = "Deactivate Play Once"; +"AccActionOpenFile" = "Open file"; +"AccActionOpenForwardedOrigin" = "Forwarded origin"; +"AccActionOpenTranscription" = "Open Voice Transcription"; +"AccActionPause" = "Pause"; +"AccActionPlay" = "Play"; +"AccActionRecordVideo" = "Record video"; +"AccActionResume" = "Resume"; +"AccActionTakePicture" = "Take a picture"; +"AccAutoDeleteTimer" = "Auto-delete timer"; +"AccDescrArchivedChatsHidden" = "Archived chats hidden"; +"AccDescrArchivedChatsShown" = "Archived chats shown"; +"AccDescrAspectRatio" = "Aspect ratio"; +"AccDescrAttachButton" = "Attach media"; +"AccDescrBackspace" = "Backspace"; +"AccDescrBotCommands" = "Bot commands"; +"AccDescrBotKeyboard" = "Bot keyboard"; +"AccDescrBotMenu" = "Bot menu"; +"AccDescrBrushType" = "Brush type"; +"AccDescrCameraFlashAuto" = "Flash, Auto"; +"AccDescrCameraFlashOff" = "Flash, Off"; +"AccDescrCameraFlashOn" = "Flash, On"; +"AccDescrCameraGallery" = "Gallery"; +"AccDescrCancelEdit" = "Cancel editing"; +"AccDescrCancelForward" = "Cancel forward"; +"AccDescrCancelReply" = "Cancel reply"; +"AccDescrCaptureStory" = "Capture story"; +"AccDescrChanSilentOff" = "Send notifications"; +"AccDescrChanSilentOn" = "Send silently"; +"AccDescrChangeProfilePicture" = "Change profile photo"; +"AccDescrChannel" = "Channel"; +"AccDescrCloseMenu" = "Close menu"; +"AccDescrClosePlayer" = "Close audio player"; +"AccDescrCloseTrendingEmoji" = "Close Trending emoji"; +"AccDescrCloseTrendingStickers" = "Close Trending stickers"; +"AccDescrCollage" = "Collage"; +"AccDescrCollageClose" = "Close Collage"; +"AccDescrCollapsePanel" = "Collapse panel"; +"AccDescrContactSorting" = "Change sorting"; +"AccDescrCustomEmoji" = "Custom emoji"; +"AccDescrDocumentType" = ", %s file"; +"AccDescrDownloadProgress" = "Downloaded %1$s of %2$s"; +"AccDescrDualCameraOff" = "Dual Camera, Off"; +"AccDescrDualCameraOn" = "Dual Camera, On"; +"AccDescrEditing" = "Editing"; +"AccDescrEmojiButton" = "Emoji, stickers, and GIFs"; +"AccDescrExpandPanel" = "Expand panel"; +"AccDescrFingerprint" = "Fingerprint"; +"AccDescrForwarding" = "Forwarding from"; +"AccDescrGIFs" = "GIFs"; +"AccDescrGoBack" = "Go back"; +"AccDescrGoToMessage" = "Go to message"; +"AccDescrGroup" = "Group"; +"AccDescrHideAccounts" = "Hide accounts"; +"AccDescrIVAudio" = "Audio"; +"AccDescrIVBlockquote" = "Quote"; +"AccDescrIVCode" = "Code"; +"AccDescrIVCollapsed" = "collapsed"; +"AccDescrIVDetails" = "Details"; +"AccDescrIVEmbed" = "Embedded content"; +"AccDescrIVEmbedPost" = "Embedded post"; +"AccDescrIVExpanded" = "expanded"; +"AccDescrIVFooter" = "Footer"; +"AccDescrIVHeading" = "Heading"; +"AccDescrIVKicker" = "Kicker"; +"AccDescrIVPullquote" = "Pull quote"; +"AccDescrIVRelatedArticle" = "Related article"; +"AccDescrIVRelatedArticles" = "Related articles"; +"AccDescrIVSlideshow" = "Slideshow"; +"AccDescrIVTable" = "Table"; +"AccDescrIVTitle" = "Title"; +"AccDescrInstantCamera" = "Instant camera"; +"AccDescrLike" = "Like"; +"AccDescrLiteBatteryLevelAnnounce" = "%s%%"; +"AccDescrLiteBatteryWhenBelow" = "enabled when below %s%%"; +"AccDescrLivePhoto" = "Live Photo"; +"AccDescrLocationNotify" = "Notify when people sharing location are in range."; +"AccDescrLockRecording" = "Lock recording"; +"AccDescrMentionCount#one" = "%1$d mention"; +"AccDescrMentionCount#other" = "%1$d mentions"; +"AccDescrMentionDown" = "Go to next mention"; +"AccDescrMentionReaction" = "With new reaction"; +"AccDescrMirror" = "Mirror"; +"AccDescrMoreOptions" = "More options"; +"AccDescrMsgNotPlayed" = "Not played"; +"AccDescrMsgPlayed" = "Played"; +"AccDescrMsgRead" = "Seen"; +"AccDescrMsgSending" = "Sending"; +"AccDescrMsgSendingError" = "Sending error"; +"AccDescrMsgUnread" = "Not seen"; +"AccDescrMusicInfo" = "%2$s by %1$s"; +"AccDescrMuteMic" = "Mute microphone"; +"AccDescrMyLocation" = "My location"; +"AccDescrNotificationsMuted" = "Muted"; +"AccDescrNumberOfPeopleReactions#one" = "%1$d person reacted with %2$s"; +"AccDescrNumberOfPeopleReactions#other" = "%1$d people reacted with %2$s"; +"AccDescrNumberOfReplies#one" = "Has %1$d reply"; +"AccDescrNumberOfReplies#other" = "Has %1$d replies"; +"AccDescrNumberOfViews#one" = "Viewed %1$d time"; +"AccDescrNumberOfViews#other" = "Viewed %1$d times"; +"AccDescrOpenChat" = "Open chat"; +"AccDescrOpenInPhotoViewer" = "Open in photo viewer"; +"AccDescrOpenMenu" = "Open navigation menu"; +"AccDescrOpenMenu2" = "Open menu"; +"AccDescrPageDown" = "Go to bottom"; +"AccDescrPaint" = "Paint"; +"AccDescrPasscodeLock" = "Lock application"; +"AccDescrPasscodeUnlock" = "Unlock application"; +"AccDescrPersonHasSeen" = "%1$s has seen"; +"AccDescrPhotoAdjust" = "Adjustments"; +"AccDescrPhotoEditor" = "Photo editor"; +"AccDescrPhotoViewer" = "Photo viewer"; +"AccDescrPipMode" = "Picture-in-Picture mode"; +"AccDescrPlaceText" = "Place text"; +"AccDescrPlayerDuration" = "%1$s of %2$s"; +"AccDescrPlayerSpeed" = "2X playback"; +"AccDescrPollVotesMentionDown" = "Go to next unread poll vote"; +"AccDescrPostedDate" = "Posted %s"; +"AccDescrPremium" = "Premium account"; +"AccDescrPremiumSpeed" = "Increase speed"; +"AccDescrPrevious" = "Previous"; +"AccDescrProfileMusic" = "Profile Music"; +"AccDescrProfilePicture" = "Profile photo"; +"AccDescrProfileRatingLevel" = "Profile Stars Rating Level"; +"AccDescrQuizCorrectAnswer" = "Correct answer"; +"AccDescrQuizExplanation" = "Explanation"; +"AccDescrQuizIncorrectAnswer" = "Incorrect answer"; +"AccDescrRateTranscriptionDown" = "Rate down"; +"AccDescrRateTranscriptionUp" = "Rate up"; +"AccDescrReactedWith" = "%1$s reacted with %2$s"; +"AccDescrReactionMentionDown" = "Go to next unread reactions"; +"AccDescrReceivedDate" = "Received %s"; +"AccDescrRepeatList" = "Repeat, playlist"; +"AccDescrRepeatOff" = "Repeat, off"; +"AccDescrRepeatOne" = "Repeat, one"; +"AccDescrReplying" = "Replying to"; +"AccDescrRotate" = "Rotate"; +"AccDescrRoundVideo" = "Video message"; +"AccDescrScheduledDate" = "Scheduled for %s"; +"AccDescrSearchByUser" = "Filter by user"; +"AccDescrSearchNext" = "Next search result"; +"AccDescrSearchPrev" = "Previous search result"; +"AccDescrSecretChat" = "Secret chat"; +"AccDescrSendAs" = "Send as %1$s"; +"AccDescrSendAsPeer" = "Send as %1$s"; +"AccDescrSendAudio#one" = "Send %1$d audio file"; +"AccDescrSendAudio#other" = "Send %1$d audio files"; +"AccDescrSendFiles#one" = "Send %1$d file"; +"AccDescrSendFiles#other" = "Send %1$d files"; +"AccDescrSendPhotos#one" = "Send %1$d photo"; +"AccDescrSendPhotos#other" = "Send %1$d photos"; +"AccDescrSentDate" = "Sent %s"; +"AccDescrShareInChats#one" = "Share in %1$d chat"; +"AccDescrShareInChats#other" = "Share in %1$d chats"; +"AccDescrShowAccounts" = "Show accounts"; +"AccDescrShowKeyboard" = "Show keyboard"; +"AccDescrShutter" = "Shutter"; +"AccDescrSpeedSlider" = "Choose speed"; +"AccDescrStartRecording" = "Start recording"; +"AccDescrStickerSet" = "Sticker set"; +"AccDescrStickers" = "Stickers"; +"AccDescrStopLiveLocation" = "Stop sharing live location"; +"AccDescrStopRecording" = "Stop recording"; +"AccDescrStoryPinned" = "Pinned"; +"AccDescrSwitchCamera" = "Switch camera"; +"AccDescrSwitchToDayTheme" = "Switch to day theme"; +"AccDescrSwitchToNightTheme" = "Switch to night theme"; +"AccDescrTabs" = "Web tabs %1$s"; +"AccDescrTagMessage" = "Edit Message Tag"; +"AccDescrTakeMorePics" = "Take one more picture"; +"AccDescrTakePhoto" = "Take photo"; +"AccDescrTopic" = "Topic"; +"AccDescrUnreadCount#one" = "%1$d unread chat"; +"AccDescrUnreadCount#other" = "%1$d unread chats"; +"AccDescrUploadProgress" = "Uploaded %1$s of %2$s"; +"AccDescrUserOnline" = "Online"; +"AccDescrUserOptions" = "User options"; +"AccDescrVerified" = "Verified"; +"AccDescrVideoCompressHigh" = "Higher quality"; +"AccDescrVideoCompressLow" = "Smaller size"; +"AccDescrVideoMessage" = "Record video message"; +"AccDescrVideoQuality" = "Video quality"; +"AccDescrVoiceMessage" = "Record voice message"; +"AccDescrVoipCamOff" = "Camera is off"; +"AccDescrVoipCamOn" = "Camera is on"; +"AccDescrVoipCamSwitchedToBack" = "Switched to rear camera"; +"AccDescrVoipCamSwitchedToFront" = "Switched to front camera"; +"AccDescrVoipMicOff" = "Microphone is off"; +"AccDescrVoipMicOn" = "Microphone is on"; +"AccDescrYouReactedWith" = "You reacted with %1$s"; +"AccExitFullscreen" = "Exit fullscreen"; +"AccPinnedMessagesList" = "Pinned message list"; +"AccReleaseForArchive" = "Release for archive"; +"AccReleaseForGeneral" = "Release for #general"; +"AccSwipeForArchive" = "Pull down for archive"; +"AccSwipeForGeneral" = "Pull down for #general"; +"AccSwitchToFullscreen" = "Switch to fullscreen"; +"Accept" = "Accept"; +"AcceptCall" = "Accept"; +"AcceptCalls" = "Accept Calls"; +"AcceptCallsChatsDescription" = "Use this device for Calls"; +"AcceptSecretChats" = "Accept Secret Chats"; +"AcceptSecretChatsDescription" = "Use this device for Secret Chats"; +"AccessError" = "Access error"; +"Account" = "Account"; +"AccountAlreadyLoggedIn" = "This account is already logged in from this app."; +"AccountFrozen1Text" = "Your account was frozen for violating Telegram’s Terms of Service."; +"AccountFrozen1Title" = "Violation of Terms"; +"AccountFrozen2Text" = "You can access your account but can’t send messages or take actions."; +"AccountFrozen2Title" = "Read-Only Mode"; +"AccountFrozen3Text" = "Appeal via **@SpamBot** before %s, or your account will be deleted."; +"AccountFrozen3Title" = "Appeal Before Deactivation"; +"AccountFrozenAlertSubtitle" = "Tap to view details and submit an appeal."; +"AccountFrozenAlertTitle" = "Your account is frozen!"; +"AccountFrozenBottomSubtitle" = "Tap to view details"; +"AccountFrozenBottomTitle" = "Your account is frozen"; +"AccountFrozenButtonAppeal" = "Submit an Appeal"; +"AccountFrozenButtonUnderstood" = "Understood"; +"AccountFrozenTitle" = "Your Account is Frozen"; +"AccountSettings" = "Account Settings"; +"AccountSwitch" = "Switch"; +"AccurateTo" = "Accurate to %1$s"; +"ActionAddUser" = "un1 added un2"; +"ActionAddUserSelf" = "un1 returned to the group"; +"ActionAddUserSelfMega" = "un1 joined the group"; +"ActionAddUserSelfYou" = "You returned to the group"; +"ActionAddUserYou" = "un1 added you"; +"ActionAttachMenuBotAllowed" = "You allowed this bot to message you when you added it to your attachment menu"; +"ActionBotAllowed" = "You allowed this bot to message you when you logged in on %1$s"; +"ActionBotAllowedApp" = "You allowed this bot to message you when you logged in on \"%1$s\" app."; +"ActionBotAllowedWebapp" = "You allowed this bot to message you in its web-app"; +"ActionBotDocumentAddress" = "Address"; +"ActionBotDocumentBankStatement" = "Bank Statement"; +"ActionBotDocumentDriverLicence" = "Driver Licence"; +"ActionBotDocumentEmail" = "Email Address"; +"ActionBotDocumentIdentity" = "Personal details"; +"ActionBotDocumentIdentityCard" = "Identity Card"; +"ActionBotDocumentInternalPassport" = "Internal Passport"; +"ActionBotDocumentPassport" = "Passport"; +"ActionBotDocumentPassportRegistration" = "Passport Registration"; +"ActionBotDocumentPhone" = "Phone Number"; +"ActionBotDocumentRentalAgreement" = "Rental Agreement"; +"ActionBotDocumentTemporaryRegistration" = "Temporary Registration"; +"ActionBotDocumentUtilityBill" = "Utility Bill"; +"ActionBotDocuments" = "%1$s received the following documents: %2$s"; +"ActionBotWebViewData" = "Data from the \"%1$s\" button was transferred to the bot."; +"ActionChangeCreator" = "un1 has transferred ownership of the group to un2."; +"ActionChangedPhoto" = "un1 changed the group photo"; +"ActionChangedTitle" = "un1 changed the group name to un2"; +"ActionChangedVideo" = "un1 changed the group photo"; +"ActionChannelCallEnded" = "Live stream ended (%s)"; +"ActionChannelCallJustStarted" = "Live stream started"; +"ActionChannelCallScheduled" = "Live stream scheduled on %1$s"; +"ActionChannelChangedPhoto" = "Channel photo updated"; +"ActionChannelChangedTitle" = "Channel name changed to un2"; +"ActionChannelChangedVideo" = "Channel photo updated"; +"ActionChannelRemovedPhoto" = "Channel photo removed"; +"ActionCreateChannel" = "Channel created"; +"ActionCreateGroup" = "un1 created the group"; +"ActionCreateMega" = "Group created"; +"ActionCreatedInviteLink" = "un1 created invite link %1$s"; +"ActionCreatedInviteLinkClickable" = "un1 created invite link un2"; +"ActionDeletedInviteLink" = "un1 deleted the invite link %1$s"; +"ActionDeletedInviteLinkClickable" = "un1 deleted invite link un2"; +"ActionEditedInviteLink" = "un1 edited invite link %1$s to %2$s"; +"ActionEditedInviteLinkClickable" = "un1 edited invite link un2 to un3"; +"ActionEditedInviteLinkToSame" = "un1 edited invite link %1$s"; +"ActionEditedInviteLinkToSameClickable" = "un1 edited invite link un2"; +"ActionForwardsEnabledChannel" = "un1 enabled forwarding from this channel"; +"ActionForwardsEnabledGroup" = "un1 enabled forwarding from this group"; +"ActionForwardsRestrictedChannel" = "un1 restricted forwarding from this channel"; +"ActionForwardsRestrictedGroup" = "un1 restricted forwarding from this group"; +"ActionGift2Received" = "You received a gift for **un2**"; +"ActionGiftAuctionSelf" = "You won the auction with a bid of **un2**"; +"ActionGiftChannel#one" = "**un1** sent a gift to **un2** for %d Star"; +"ActionGiftChannel#other" = "**un1** sent a gift to **un2** for %d Stars"; +"ActionGiftInbound" = "un1 sent you a gift for **un2**"; +"ActionGiftOutbound" = "You have sent a gift for **un2**"; +"ActionGiftPremiumSubtitle" = "for %1$s"; +"ActionGiftPremiumText" = "Subscription for exclusive Telegram features."; +"ActionGiftPremiumTitle" = "Telegram Premium"; +"ActionGiftPremiumTitle2#one" = "%d Month Premium"; +"ActionGiftPremiumTitle2#other" = "%d Months Premium"; +"ActionGiftPremiumView" = "View"; +"ActionGiftSelf" = "You bought a gift for **un2**"; +"ActionGiftSomeone" = "Someone sent you a gift!"; +"ActionGiftStarsSubtitle" = "With Stars, **%s** is able to unlock content and services on Telegram."; +"ActionGiftStarsSubtitleYou" = "Use Stars to unlock content and services on Telegram."; +"ActionGiftStarsTitle#one" = "%d Star"; +"ActionGiftStarsTitle#other" = "%d Stars"; +"ActionGiftStarsView" = "View"; +"ActionGiftTonInbound" = "sent you a gift for **un2**"; +"ActionGiftTonTitle" = "Someone"; +"ActionGroupCallEnded" = "Video chat ended (%s)"; +"ActionGroupCallEndedBy" = "un1 ended the video chat (%s)"; +"ActionGroupCallEndedByYou" = "You ended the video chat (%s)"; +"ActionGroupCallInvited" = "un1 invited un2 to the video chat"; +"ActionGroupCallInvitedYou" = "un1 invited you to the video chat"; +"ActionGroupCallJustStarted" = "Video chat started"; +"ActionGroupCallScheduled" = "Video chat scheduled for %1$s"; +"ActionGroupCallStarted" = "un1 started a video chat"; +"ActionGroupCallStartedByYou" = "You started a video chat"; +"ActionGroupCallYouInvited" = "You invited un2 to the video chat"; +"ActionInviteChannelUser" = "un1 joined the channel via invite link"; +"ActionInviteChannelUserFolder" = "un1 joined the channel via folder invite link"; +"ActionInviteUser" = "un1 joined the group via invite link"; +"ActionInviteUserFolder" = "un1 joined the group via folder invite link"; +"ActionInviteYou" = "You joined the group via invite link"; +"ActionKickUser" = "un1 removed un2"; +"ActionKickUserYou" = "un1 removed you"; +"ActionLeftUser" = "un1 left the group"; +"ActionManagedBotCreated" = "You created un1 bot for un2 to manage"; +"ActionMigrateFromGroup" = "This group was upgraded to a supergroup"; +"ActionMigrateFromGroupNotify" = "%1$s was upgraded to a supergroup"; +"ActionNewCreatorPending" = "un1 will become the new owner in 7 days if un2 does not return."; +"ActionPinnedContact" = "un1 pinned a contact"; +"ActionPinnedFile" = "un1 pinned a file"; +"ActionPinnedGame" = "un1 pinned %1$s"; +"ActionPinnedGeo" = "un1 pinned a map"; +"ActionPinnedGeoLive" = "un1 pinned a live location"; +"ActionPinnedGif" = "un1 pinned a GIF"; +"ActionPinnedMusic" = "un1 pinned an audio file"; +"ActionPinnedNoText" = "un1 pinned a message"; +"ActionPinnedPhoto" = "un1 pinned a photo"; +"ActionPinnedPoll" = "un1 pinned a poll"; +"ActionPinnedQuiz" = "un1 pinned a quiz"; +"ActionPinnedRound" = "un1 pinned a video message"; +"ActionPinnedSticker" = "un1 pinned a sticker"; +"ActionPinnedText" = "un1 pinned \"%1$s\""; +"ActionPinnedVideo" = "un1 pinned a video"; +"ActionPinnedVoice" = "un1 pinned a voice message"; +"ActionPrepaidGiftInbound" = "un1 sent an upgrade worth **un2** for your gift"; +"ActionPrepaidGiftOutbound" = "You sent an upgrade worth **un2** for **un1**'s gift"; +"ActionReactionsChanged" = "un1 changed chat reactions\nfrom: %1$s \nto: %2$s"; +"ActionReactionsChangedList" = "un1 changed chat reactions:\n %1$s"; +"ActionRefunded" = "un1 refunded %1$s"; +"ActionRemovedPhoto" = "un1 removed the group photo"; +"ActionRequestedPeer" = "You shared un1 with un2"; +"ActionRequestedPeerChannel" = "You shared a channel with un2"; +"ActionRequestedPeerChannelPlural#one" = "You shared a channel with un2"; +"ActionRequestedPeerChannelPlural#other" = "You shared channels with un2"; +"ActionRequestedPeerChat" = "You shared a chat with un2"; +"ActionRequestedPeerChatPlural#one" = "You shared a chat with un2"; +"ActionRequestedPeerChatPlural#other" = "You shared chats with un2"; +"ActionRequestedPeerUser" = "You shared a user with un2"; +"ActionRequestedPeerUserPlural#one" = "You shared a user with un2"; +"ActionRequestedPeerUserPlural#other" = "You shared users with un2"; +"ActionRevokedInviteLink" = "un1 revoked invite link %1$s"; +"ActionRevokedInviteLinkClickable" = "un1 revoked invite link un2"; +"ActionSetSameWallpaperForThisChat" = "%s set your wallpaper as their own for this chat"; +"ActionSetSameWallpaperForThisChatSelf" = "You set the same wallpaper as your chat partner"; +"ActionSetWallpaperForThisChannel" = "Channel set a new wallpaper"; +"ActionSetWallpaperForThisChat" = "%s set a new wallpaper for this chat"; +"ActionSetWallpaperForThisChatBoth" = "%s set a new wallpaper for this chat"; +"ActionSetWallpaperForThisChatSelf" = "You set a new wallpaper for this chat"; +"ActionSetWallpaperForThisChatSelfBoth" = "You set a new wallpaper for %s and you"; +"ActionSetWallpaperForThisGroup" = "Group set a new wallpaper"; +"ActionSetWallpaperForThisGroupByUser" = "%s set a new wallpaper"; +"ActionSettingWallpaper" = "Setting new wallpaper..."; +"ActionSkip" = "Skip"; +"ActionStarGift" = "Gift"; +"ActionStarGiftInbound" = "%s sent you a gift"; +"ActionStarGiveawayPrize#one" = "You won a prize in a giveaway organized by **un1**.\n\nYour prize is **%d Star**."; +"ActionStarGiveawayPrize#other" = "You won a prize in a giveaway organized by **un1**.\n\nYour prize is **%d Stars**."; +"ActionStarGiveawayPrizeTitle" = "Congratulations!"; +"ActionSuggestBirthday" = "**un1** suggested you add your birthday"; +"ActionSuggestPhoto" = "Suggested Photo"; +"ActionSuggestPhotoFromYouDescription" = "You suggested this photo for %s's Telegram profile"; +"ActionSuggestPhotoShort" = "Suggested profile photo"; +"ActionSuggestPhotoToYouDescription" = "%s suggests this photo for your Telegram profile"; +"ActionSuggestVideo" = "Suggested Video"; +"ActionSuggestVideoFromYouDescription" = "You suggested this photo for %s's Telegram profile"; +"ActionSuggestVideoShort" = "Suggested profile photo"; +"ActionSuggestVideoToYouDescription" = "%s suggests this photo for your Telegram profile"; +"ActionTTLChanged" = "un1 set messages to auto-delete in %1$s"; +"ActionTTLChannelChanged" = "Messages in this channel will be automatically deleted after %1$s"; +"ActionTTLChannelDisabled" = "Messages in this channel will no longer be automatically deleted"; +"ActionTTLDisabled" = "un1 disabled the auto-delete timer"; +"ActionTTLYouChanged" = "You set messages to auto-delete in %1$s"; +"ActionTTLYouDisabled" = "You disabled the auto-delete timer"; +"ActionTakeScreenshoot" = "un1 took a screenshot!"; +"ActionTakeScreenshootYou" = "You took a screenshot!"; +"ActionUniqueGiftCrafted" = "You crafted a new gift!"; +"ActionUniqueGiftResaleInbound#one" = "**un1** bought you a gift for %d Star!"; +"ActionUniqueGiftResaleInbound#other" = "**un1** bought you a gift for %d Stars!"; +"ActionUniqueGiftResaleInboundTON" = "**un1** bought you a gift for %s Grams!"; +"ActionUniqueGiftResaleOutbound#one" = "You bought this gift for %d Star"; +"ActionUniqueGiftResaleOutbound#other" = "You bought this gift for %d Stars"; +"ActionUniqueGiftResaleOutboundTON" = "You bought this gift for %s Grams"; +"ActionUniqueGiftResaleService#one" = "**un1** sent a gift to **un2** for %d Star!"; +"ActionUniqueGiftResaleService#other" = "**un1** sent a gift to **un2** for %d Stars!"; +"ActionUniqueGiftResaleServiceTON" = "**un1** sent a gift to **un2** for %s Grams!"; +"ActionUniqueGiftResaleSoldOutbound#one" = "You sold this gift for %d Star"; +"ActionUniqueGiftResaleSoldOutbound#other" = "You sold this gift for %d Stars"; +"ActionUniqueGiftResaleSoldOutboundTON" = "You sold this gift for %s Grams"; +"ActionUniqueGiftTransferInbound" = "**un1** transferred a gift to you!"; +"ActionUniqueGiftTransferOutbound" = "You transferred a gift to **un1**"; +"ActionUniqueGiftTransferOutboundAssigned" = "You added %s to your profile."; +"ActionUniqueGiftTransferService" = "**un1** transferred a gift to **un2**!"; +"ActionUniqueGiftUpgradeInbound" = "**un1** turned the gift from you into a unique collectible"; +"ActionUniqueGiftUpgradeInboundChannel" = "**un1** turned the gift into a unique collectible"; +"ActionUniqueGiftUpgradeOutbound" = "You turned the gift from **un1** into a unique collectible"; +"ActionUniqueGiftUpgradeSelf" = "You turned the gift into a unique collectible"; +"ActionUserInvitedToChannel" = "un1 invited you to this channel"; +"ActionUserInvitedToGroup" = "un1 invited you to this group"; +"ActionUserScored" = "un1 scored %1$s"; +"ActionUserScoredInGame" = "un1 scored %1$s in un2"; +"ActionUserWithinOtherRadius" = "un1 is now within %1$s from un2"; +"ActionUserWithinRadius" = "un1 is now within %1$s from you"; +"ActionUserWithinYouRadius" = "You are now within %1$s from un1"; +"ActionVolumeChanged" = "un1 changed un2 volume to %1$d%%"; +"ActionYouAddUser" = "You added un2"; +"ActionYouChangedPhoto" = "You changed the group photo"; +"ActionYouChangedTitle" = "You changed the group name to un2"; +"ActionYouChangedVideo" = "You changed the group photo"; +"ActionYouCreateGroup" = "You created the group"; +"ActionYouKickUser" = "You removed un2"; +"ActionYouLeftUser" = "You left the group"; +"ActionYouRemovedPhoto" = "You removed the group photo"; +"ActionYouScored" = "You scored %1$s"; +"ActionYouScoredInGame" = "You scored %1$s in un2"; +"ActionYouSuggestBirthday" = "You suggested to add a birthday"; +"ActionsChartTitle" = "Actions"; +"AdHidden" = "You will no longer see ads from Telegram."; +"AdReported" = "We will review this ad to ensure it matches our **Ad Policies and Guidelines**."; +"Add" = "Add"; +"AddACaption" = "Add Caption"; +"AddAccount" = "Add Account"; +"AddAccountInfo1#one" = "You can add %d additional account."; +"AddAccountInfo1#other" = "You can add %d additional accounts."; +"AddAccountInfo2#one" = "Subscribe to **Telegram Premium** to use up to %d account."; +"AddAccountInfo2#other" = "Subscribe to **Telegram Premium** to use up to %d accounts."; +"AddAdminErrorBlacklisted" = "You can't add this user as an admin because they are in the Removed Users list and you are not allowed to unban them."; +"AddAdminErrorNotAMember" = "You can't add this user as an admin because they are not a member of this group and you are not allowed to add them."; +"AddAnException" = "Add exception"; +"AddAnExplanation" = "Add an explanation"; +"AddAnExplanationHeader" = "Explanation"; +"AddAnExplanationInfo" = "Users will see this text after choosing the wrong answer, good for educational purposes."; +"AddAnOption" = "Add an Option..."; +"AddAnOptionInfo" = "You can add %1$s."; +"AddAnOptionInfoMax" = "You have added the maximum number of options."; +"AddAnotherAccount" = "Add another account"; +"AddAnotherAccountInfo" = "Connect other Telegram accounts and easily switch between them."; +"AddAsAdmin" = "Add as admin"; +"AddBannedErrorAdmin" = "You can't ban this user because they are an admin in this group and you are not allowed to demote them."; +"AddBirthday" = "Add Birthday"; +"AddBot" = "Add Bot"; +"AddBotAdmin" = "Add bot as admin"; +"AddBotAdminAlert" = "Add bot as admin?"; +"AddBotAsAdmin" = "Bots can only be added to channels as admins."; +"AddBotButton" = "Add bot"; +"AddBotButtonAsAdmin" = "as Admin"; +"AddBotButtonAsMember" = "as Member"; +"AddBotMessageAdminChannel" = "Do you want to add this bot as an admin to the channel **%1$s**? It will have access to lists of channel admins and subscribers."; +"AddBotMessageAdminGroup" = "Are you sure you want to add this bot as an admin to the group **%1$s**? It will have access to the list of anonymous admins."; +"AddCaption" = "Add a caption..."; +"AddContact" = "Add to contacts"; +"AddContactByPhone" = "Add %1$s"; +"AddContactChat" = "ADD CONTACT"; +"AddContactChatNoCaps" = "Add Contact"; +"AddContactFullChat" = "ADD %1$s TO CONTACTS"; +"AddContactFullChatNoCaps" = "Add %1$s to Contacts"; +"AddContactPhonebookTitle" = "Add Contact"; +"AddContactQr" = "Add via QR Code"; +"AddContactShareNumber" = "Share My Phone Number"; +"AddContactShareNumberInfo" = "Turn this off to prevent %s from seeing your phone number."; +"AddContactSync" = "Sync Contact to Phone"; +"AddContactTitle" = "Add Contact"; +"AddEffectMessageHint" = "Add an animated effect"; +"AddEmailSubtitle" = "You will receive Telegram login codes via email and not SMS. Please enter an email address to which you have access."; +"AddEmailTitle" = "Choose a login email"; +"AddEmojiInstalled" = "Emoji added."; +"AddEmojiInstalledInfo" = "%1$s has been added to your emoji packs."; +"AddEmojiMultipleInstalledInfo#one" = "%1$d pack has been added to your emoji."; +"AddEmojiMultipleInstalledInfo#other" = "%1$d packs have been added to your emoji."; +"AddEmojiNotFound" = "Emoji pack not found."; +"AddEmojiPackHeader" = "Add emoji pack by link"; +"AddEmojiPackLinkHint" = "emojiset"; +"AddException" = "Add Exception"; +"AddExceptions" = "Add exceptions"; +"AddFactCheck" = "Add Fact Check"; +"AddGroupEmojiPackHint" = "All members will be able to use these emoji in the group, even if they don't have Telegram Premium."; +"AddImage" = "Add Image"; +"AddLocation" = "ADD LOCATION"; +"AddManyEmojiCount#one" = "Add Emoji"; +"AddManyEmojiCount#other" = "Add %1$d Emoji"; +"AddManyEmojiPacksCount#one" = "Add emoji pack"; +"AddManyEmojiPacksCount#other" = "Add %1$d emoji packs"; +"AddManyMasksCount#one" = "Add mask"; +"AddManyMasksCount#other" = "Add %1$d masks"; +"AddManyMembersAlertNamesText#one" = "Are you sure you want to add member to **%2$s**?"; +"AddManyMembersAlertNamesText#other" = "Are you sure you want to add %1$d members to **%2$s**?"; +"AddManyMembersAlertTitle#one" = "Add member"; +"AddManyMembersAlertTitle#other" = "Add %1$d members"; +"AddManyStickersCount#one" = "Add sticker"; +"AddManyStickersCount#other" = "Add %1$d stickers"; +"AddMasks" = "Add Masks"; +"AddMasksInstalled" = "New masks added"; +"AddMasksInstalledInfo" = "%1$s has been added to your masks."; +"AddMember" = "Add Members"; +"AddMemberTag" = "Add Member Tag"; +"AddMembersAlertCountText" = "Are you sure you want to add %1$s to **%2$s**?"; +"AddMembersAlertNamesText" = "Are you sure you want to add %1$s to **%2$s**?"; +"AddMembersAlertTitle" = "Add %1$s"; +"AddMembersForwardMessages" = "Show the last 100 messages to new members"; +"AddMessageMedia" = "Tap to add media"; +"AddMutual" = "Add people to your channel"; +"AddNotes" = "Add Notes..."; +"AddNotesInfo" = "Notes are only visible to you."; +"AddOneMemberAlertTitle" = "Add member"; +"AddOneMemberForwardMessages" = "Show the last 100 messages to **%1$s**"; +"AddPeopleNearby" = "Find People Nearby"; +"AddPhoto" = "Set Profile Photo"; +"AddProxy" = "Add Proxy"; +"AddRecipient" = "Add recipient"; +"AddShortcut" = "Add to Home Screen"; +"AddStickers" = "Add Stickers"; +"AddStickersCount" = "Add %1$s"; +"AddStickersInstalled" = "New stickers added"; +"AddStickersInstalledInfo" = "%1$s has been added to your stickers."; +"AddStickersNotFound" = "Stickers not found"; +"AddStory" = "Add Story"; +"AddSubscriber" = "Add subscribers"; +"AddTag" = "Add Tag"; +"AddTagsToYourSavedMessages1" = "Add Tags"; +"AddTagsToYourSavedMessages2" = "to your Saved Messages"; +"AddTasks" = "Add Tasks"; +"AddToChannel" = "Add to Channel"; +"AddToContacts" = "Add to contacts"; +"AddToExceptions" = "Add to exceptions"; +"AddToExistingContact" = "Add to Existing Contact"; +"AddToFavorites" = "Add to Favorites"; +"AddToGroup" = "Add to Group"; +"AddToGroupOrChannel" = "Add to Group or Channel"; +"AddToMasks" = "Add to Masks"; +"AddToStickerPack" = "Add to Sticker Set"; +"AddToStickers" = "Add to Stickers"; +"AddToTheGroup" = "Add %1$s to the group?"; +"AddToTheGroupAlertText" = "Do you want to add **%1$s** to the group **%2$s**?"; +"AddToTheGroupAlertTitle" = "Add bot"; +"AddToTheGroupForwardCount" = "Number of previous messages to forward:"; +"AddToTheGroupTitle" = "Add %1$s to the chat %2$s?"; +"AddUserErrorBlacklisted" = "Sorry, you can't add this user because they are on the list of Removed Users and you can't unban them."; +"AddUsername" = "Add Username"; +"Added" = "Added"; +"AddedBy" = "added by %1$s %2$s"; +"AddedMembersToGroup#other" = "**%d members** added to the group"; +"AddedSubscribersToChannel#other" = "**%d subscribers** added to the channel"; +"AddedToDoNotTranslate" = "**%s** added to your Do Not Translate list."; +"AddedToDoNotTranslateOther" = "**%s** is added to the Do Not Translate list."; +"AddedToFavorites" = "Sticker added to Favorites."; +"AdditionalReactions" = "Additional Reactions"; +"AdditionalReactionsDescription" = "Unlock a wider range of reactions on messages by subscribing to Telegram Premium."; +"AddressPlaceholder" = "Search %s or type URL"; +"AdminWillBeRemoved" = "%1$s will be removed from admins if you restrict them."; +"AffiliateLinkExpiredText" = "This link was part of an affiliate program that has been terminated by the app owner."; +"AffiliateLinkExpiredTitle" = "Link expired"; +"AffiliateProgramAlert" = "Warning"; +"AffiliateProgramBot" = "Affiliate Program"; +"AffiliateProgramBotOff" = "Off"; +"AffiliateProgramCommission" = "Commission"; +"AffiliateProgramCommissionInfo" = "Define the percentage of star revenue your affiliates earn for referring users to your bot."; +"AffiliateProgramDuration" = "Duration"; +"AffiliateProgramDurationInfo" = "Set the duration for which affiliates will earn commissions from referred users."; +"AffiliateProgramEndedText" = "Participating affiliates have been notified. All referral links will be disabled in **24** hours."; +"AffiliateProgramEndedTitle" = "Affiliate program ended"; +"AffiliateProgramExistingProgramsText" = "Explore what other mini apps offer."; +"AffiliateProgramExistingProgramsTitle" = "View existing programs"; +"AffiliateProgramJoinedText" = "You can now copy the referral link."; +"AffiliateProgramJoinedTitle" = "Joined Program"; +"AffiliateProgramLinkCopiedText" = "Share this link and earn **%1$s** of what people who use it spend in **%2$s**!"; +"AffiliateProgramLinkCopiedTitle" = "Link copied to clipboard"; +"AffiliateProgramStart" = "Start Affiliate Program"; +"AffiliateProgramStartAlertButton" = "Start"; +"AffiliateProgramStartAlertText" = "Once you start the affiliate program, you won't be able to decrease its commission or duration. You can only increase these parameters or end the program, which will disable all previously distributed referral links."; +"AffiliateProgramStartAvailableIn" = "Available in %s"; +"AffiliateProgramStartInfo" = "By creating an affiliate program, you agree to the **Terms and Conditions** of Affiliate Programs."; +"AffiliateProgramStartInfoLink" = "https://telegram.org/tos/affiliate-program"; +"AffiliateProgramStartedText" = "Any Telegram user, channel owner or mini app developer can now join your program."; +"AffiliateProgramStartedTitle" = "Affiliate program started"; +"AffiliateProgramStop" = "End Affiliate Program"; +"AffiliateProgramStopButton" = "End Anyway"; +"AffiliateProgramStopText" = "If you end your affiliate program:"; +"AffiliateProgramStopText1" = "Any referral links already shared will be disabled in **24** hours."; +"AffiliateProgramStopText2" = "All participants affiliates will be notified."; +"AffiliateProgramStopText3" = "You will have to wait **24** hours until you can start a new affiliate program."; +"AffiliateProgramUpdate" = "Update Affiliate Program"; +"AffiliateProgramUpdateAlertButton" = "Update"; +"AffiliateProgramUpdateAlertText" = "This change is irreversible. You won't be able to reduce commission or duration. You can only increase these parameters or end the program, which will disable all previously shared referral links."; +"AffiliateProgramUpdateInfo" = "By updating an affiliate program, you agree to the **Terms and Conditions** of Affiliate Programs."; +"AffiliateProgramUpdateInfoLink" = "https://telegram.org/tos/affiliate-program"; +"AgeVerificationButton" = "Verify My Age"; +"AgeVerificationFailedText" = "Sorry, you can't view 18+ content."; +"AgeVerificationFailedTitle" = "Age Check Failed"; +"AgeVerificationNeedCameraPermission" = "Telegram needs access to your camera so that you can confirm your age."; +"AgeVerificationPassedTitle" = "Age Check Passed"; +"AgeVerificationTextGB" = "To access such content, you must confirm that you are at least **18** years old as required by UK law.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by Telegram."; +"AgeVerificationTitle" = "Age Verification"; +"Agree" = "Agree"; +"Album" = "Album"; +"Alert" = "Show Notifications"; +"AlertClearHistory" = "Clear History"; +"AlertLeaveAppointAnotherOwner" = "Appoint another owner"; +"AlertLeaveAppointAnotherOwnerTitle" = "Appoint Another Owner"; +"AlertLeaveNewOwner" = "If you leave, **%s** will become its new owner in **1 week**."; +"AllAccounts" = "All Accounts"; +"AllChats" = "All Chats"; +"AllChatsShort" = "All"; +"AllMedia" = "All media"; +"AllNMedia#other" = "All %1$d media"; +"AllNPhotos#other" = "All %1$d photos"; +"AllPhotos" = "All photos"; +"AllReactions" = "All reactions"; +"AllSessionsTerminated" = "All other sessions terminated."; +"AllTopicsShort" = "All"; +"AllTopicsSide" = "All"; +"AllVideo" = "All Videos"; +"AllVideos" = "All videos"; +"AllViewers" = "All viewers"; +"AllWebSessionsTerminated" = "All websites disconnected."; +"Allow" = "Allow"; +"AllowBackgroundActivity" = "Allow background activity"; +"AllowBackgroundActivityInfo" = "This will improve reliability for incoming notifications and Telegram calls."; +"AllowBackgroundActivityInfoOneUIAboveS" = "This will improve reliability for incoming notifications and Telegram calls. Go to your device's Settings > Battery and select **Optimized** or **Unrestricted**."; +"AllowBackgroundActivityInfoOneUIBelowS" = "This will improve reliability for incoming notifications and Telegram calls. Go to your device's Settings > Battery and turn on **Allow background activity**."; +"AllowCustomQuickReply" = "Allow Custom"; +"AllowFillNumber" = "Please allow Telegram to receive calls so that we can automatically confirm your phone number."; +"AllowPostSuggestions" = "Allow Direct Messages"; +"AllowPostSuggestionsHint" = "Allow users to send messages to your channel, with the option to charge a fee for each message."; +"AllowPostSuggestionsHint2" = "Allow users to send messages to your channel, with an optional fee per message."; +"AllowReadCall" = "Please allow Telegram to receive calls so that we can automatically enter your code for you."; +"AllowReadCallAndLog" = "Please allow Telegram to receive calls and read the call log so that we can automatically enter your code for you."; +"AllowReadCallAndSms" = "Please allow Telegram to receive calls so that we can automatically confirm your phone number."; +"AllowReadCallLog" = "Please allow Telegram to read the call log so that we can automatically enter your code for you."; +"AllowReadSms" = "Please allow Telegram to receive SMS so that we can automatically enter your code for you."; +"AlmostDone" = "Almost done!"; +"AlsoTerminateChatbot" = "Also terminate %s"; +"AltNavigationDisable" = "Disable alternative navigation"; +"AltNavigationEnable" = "Enable alternative navigation"; +"AlternativeOptions" = "Alternative options"; +"AlwaysAllow" = "Always Allow"; +"AlwaysAllowPlaceholder" = "Always allow..."; +"AlwaysShareWith" = "Always Share With"; +"AlwaysShareWithPlaceholder" = "Always share with users..."; +"AlwaysShareWithTitle" = "Always Share"; +"AlwaysShowPopup" = "Always show"; +"AmongWinners#one" = "among %d winner"; +"AmongWinners#other" = "among %d winners"; +"AndMoreTyping#one" = "and %1$d more are typing"; +"AndMoreTyping#other" = "and %1$d more are typing"; +"AndMoreTypingGroup#one" = "%1$s and %2$d more are typing"; +"AndMoreTypingGroup#other" = "%1$s and %2$d more are typing"; +"AndOther#one" = "and %1$d other"; +"AndOther#other" = "and %1$d others"; +"AnimatedEffectPremium" = "Subscribe to **Telegram Premium** to add this animated effect."; +"AnimatedStickers" = "Animated Stickers"; +"AnonymousForward" = "Author Hidden"; +"AnonymousNumber" = "Anonymous Number"; +"AnonymousNumberNotice" = "This number is not tied to a SIM card and was acquired on *Fragment*."; +"AnonymousPoll" = "Anonymous Poll"; +"AnonymousQuizPoll" = "Anonymous Quiz"; +"Answer#one" = "%1$d answer"; +"Answer#other" = "%1$d answers"; +"AnswerOptions" = "Answer options"; +"AnswerOptions2" = "Options"; +"ApkRestricted" = "APK installation is restricted for this app. You can enable this in system settings."; +"AppBetaUpdateVersion" = "Version %1$s (%2$s)"; +"AppFolderInfo" = "Browse the app's folder"; +"AppIcon" = "App Icon"; +"AppIconAqua" = "Aqua"; +"AppIconChangedTo" = "App icon changed to %1$s."; +"AppIconDefault" = "Default"; +"AppIconNox" = "Nox"; +"AppIconPremium" = "Premium"; +"AppIconTurbo" = "Turbo"; +"AppIconVintage" = "Vintage"; +"AppLocked" = "Telegram Locked"; +"AppName" = "Telegram"; +"AppNameBeta" = "Telegram"; +"AppStore" = "App Store"; +"AppUpdate" = "Update Telegram"; +"AppUpdateBeta" = "Update Telegram Beta"; +"AppUpdateChangelogEmpty" = "**What's new?**\n\n• Bug fixes and improvements."; +"AppUpdateDownloadNow" = "Download Now"; +"AppUpdateDownloading" = "Downloading… %1$d%%"; +"AppUpdateNow" = "Update Now"; +"AppUpdateRemindMeLater" = "Remind me later"; +"AppUpdateVersionAndSize" = "Version %1$s • %2$s"; +"Appearance" = "Appearance"; +"Application" = "Application"; +"ApplyAvatarHint" = "You can change it in **Settings**."; +"ApplyAvatarHintTitle" = "**Photo updated**"; +"ApplyBackgroundForThisChat" = "APPLY FOR THIS CHAT"; +"ApplyChanges" = "Apply Changes"; +"ApplyLocalizationFile" = "Apply localization file"; +"ApplyTheme" = "Apply"; +"ApplyThemeFile" = "Apply theme file"; +"ApplyWallpaper" = "Set Background"; +"ApplyWallpaperForChannel" = "Apply for %s"; +"ApplyWallpaperForMe" = "Apply for Me"; +"ApplyWallpaperForMeAndPeer" = "Apply for Me and %s"; +"AppointNewOwner" = "Appoint Another Owner"; +"ApprovalRequired" = "approval required"; +"ApproveNewMembers" = "Request Admin Approval"; +"ApproveNewMembers2" = "Approve New Members"; +"ApproveNewMembersApplyToLinksApply" = "Apply"; +"ApproveNewMembersApplyToLinksDontApply" = "Don't Apply"; +"ApproveNewMembersApplyToLinksTitle" = "Apply to existing invite links"; +"ApproveNewMembersDescription" = "Turn this on if you want users to join only after they are approved by an admin."; +"ApproveNewMembersDescription2" = "Require admin approval for people joining through this link."; +"ApproveNewMembersDescriptionFrozen" = "You can't enable admin approval for links that require a monthly fee."; +"ApproveNewMembersDisable" = "Disable"; +"ApproveNewMembersDisableForLinks#one" = "Also disable **Approve New Members** for **%1$d existing invite link** in this group?"; +"ApproveNewMembersDisableForLinks#other" = "Also disable **Approve New Members** for **%1$d existing invite links** in this group?"; +"ApproveNewMembersDisableForLinksChannel#one" = "Also disable **Approve New Subscribers** for **%1$d existing invite link** in this channel?"; +"ApproveNewMembersDisableForLinksChannel#other" = "Also disable **Approve New Subscribers** for **%1$d existing invite links** in this channel?"; +"ApproveNewMembersDisabledMessageChannel" = "New subscribers will no longer need approval from **%1$s** before they can join this channel."; +"ApproveNewMembersDisabledMessageGroup" = "New members will no longer need approval from **%1$s** before they can send messages in this group."; +"ApproveNewMembersEnable" = "Enable"; +"ApproveNewMembersEnableForLinks#one" = "Also enable **Approve New Members** for **%1$d existing invite link** in this group?"; +"ApproveNewMembersEnableForLinks#other" = "Also enable **Approve New Members** for **%1$d existing invite links** in this group?"; +"ApproveNewMembersEnableForLinksChannel#one" = "Also enable **Approve New Subscribers** for **%1$d existing invite link** in this channel?"; +"ApproveNewMembersEnableForLinksChannel#other" = "Also enable **Approve New Subscribers** for **%1$d existing invite links** in this channel?"; +"ApproveNewMembersMessageChannel" = "New subscribers will now need approval from **%1$s** before they can join this channel."; +"ApproveNewMembersMessageGroup" = "New members will now need approval from **%1$s** before they can send messages in this group."; +"ApproveNewMembersTitle" = "Approve New Members"; +"ApproveNewMembersUnavailablePublicGroup" = "This option is unavailable because anyone can join and send messages through the public group link."; +"AppsClearSearch" = "App history"; +"AppsClearSearchAlert" = "Do you want to remove %s from your recent app history?"; +"AppsTab" = "Apps"; +"AppsTabInfo" = "Which apps are included here? **Learn \\>**"; +"AppsTabInfoButton" = "Understood"; +"AppsTabInfoText" = "This catalogue ranks mini apps based on their daily revenue, measured in Stars. To be listed, developers must set their main mini apps in @botfather (as described [here](https://core.telegram.org/bots/webapps#launching-the-main-mini-app)), have over **1,000** daily users, and earn a daily revenue above **1,000** Stars, based on the weekly average."; +"AppsTabInfoTitle" = "Top Mini Apps"; +"April" = "April"; +"Archive" = "Archive"; +"ArchiveAndMute" = "Archive and Mute"; +"ArchiveAndMuteInfo" = "Automatically archive and mute new chats, groups and channels from non-contacts."; +"ArchiveHidden" = "Archive hidden"; +"ArchiveHiddenInfo" = "Pull down to see the archive."; +"ArchiveHintHeader1" = "This is your Archive"; +"ArchiveHintHeader2" = "Muted chats"; +"ArchiveHintHeader3" = "Pinned chats"; +"ArchiveHintSection1" = "Archived Chats"; +"ArchiveHintSection1Info" = "Move any chat into your Archive and back by swiping on it."; +"ArchiveHintSection2" = "Hiding Archive"; +"ArchiveHintSection2Info" = "Hide the Archive from your main screen by swiping on it."; +"ArchiveHintSection3" = "Stories"; +"ArchiveHintSection3Info" = "Archive Stories from your contacts separately from chats with them."; +"ArchiveHintSubtitle" = "Archived chats will remain in the Archive when you receive a new message. **Tap to change >**"; +"ArchiveHintSubtitleUnmutedMove" = "When you receive a new message, muted chats will remain in the Archive, while unmuted chats will be moved to Chats. **Tap to change >**"; +"ArchiveHintText1" = "Chats with enabled notifications get\nunarchived when new messages arrive."; +"ArchiveHintText2" = "Chats with disabled notifications stay\narchived when new messages arrive."; +"ArchiveHintText3" = "You can pin an unlimited number of archived chats to the top."; +"ArchivePeerStories" = "Hide Stories"; +"ArchivePinned" = "Archive pinned"; +"ArchivePinnedInfo" = "Swipe left on the archive to hide it."; +"ArchiveSearchFilter" = "Archive"; +"ArchiveSettingUnmutedChats" = "Chats from folders"; +"ArchiveSettingUnmutedChatsCheck" = "Always keep archived"; +"ArchiveSettingUnmutedChatsInfo" = "Keep archived chats from folders in the Archive even if they are unmuted and get a new message."; +"ArchiveSettingUnmutedFolders" = "Unmuted chats"; +"ArchiveSettingUnmutedFoldersCheck" = "Always keep archived"; +"ArchiveSettingUnmutedFoldersInfo" = "Keep archived chats in the Archive even if they are unmuted and get a new message."; +"ArchiveSettings" = "Archive Settings"; +"ArchiveStickerSetsAlertTitle" = "Archive %1$s"; +"ArchiveStickersAlertMessage" = "Are you sure you want to archive the selected sticker sets?"; +"ArchiveStories#one" = "Archive Story"; +"ArchiveStories#other" = "Archive Stories"; +"ArchiveStory" = "Remove from Profile"; +"ArchivedChats" = "Archived Chats"; +"ArchivedEmojiInfo" = "You can add up to 200 emoji packs.\nUnused packs are archived when you add more."; +"ArchivedEmojiPacks" = "Archived Emoji Packs"; +"ArchivedEmojiPacksEmpty" = "No archived emoji packs"; +"ArchivedMasks" = "Archived Masks"; +"ArchivedMasksAlertInfo" = "Some of your older mask sets have been archived. You can reactivate them in Masks Settings."; +"ArchivedMasksAlertTitle" = "Archived masks"; +"ArchivedMasksEmpty" = "No archived masks"; +"ArchivedMasksInfo" = "You can add up to 200 sets of masks.\nUnused sets are archived when you add more."; +"ArchivedStickers" = "Archived Stickers"; +"ArchivedStickersAlertInfo" = "Some of your older sticker sets have been archived. You can reactivate them in the Stickers Settings."; +"ArchivedStickersAlertTitle" = "Archived stickers"; +"ArchivedStickersEmpty" = "No archived stickers"; +"ArchivedStickersInfo" = "You can have 200 active sticker sets.\nUnused sets are archived when you add more."; +"ArchivedStories" = "Archived Stories"; +"AreTypingGroup" = "%1$s are typing..."; +"AreYouSure" = "Are you sure?"; +"AreYouSureBlockContact" = "Are you sure you want to block this contact?"; +"AreYouSureBlockContact2" = "Are you sure you want to block **%1$s**?"; +"AreYouSureClearDrafts" = "Are you sure you want to delete all cloud drafts?"; +"AreYouSureClearDraftsTitle" = "Delete cloud drafts"; +"AreYouSureClearHistory" = "Are you sure you want to delete all messages in this chat?"; +"AreYouSureClearHistoryCacheFewChats" = "Delete all cached text and media from selected chats?"; +"AreYouSureClearHistoryChannel" = "Delete all cached text and media from this channel?"; +"AreYouSureClearHistoryFewChats" = "Are you sure you want to clear history in these chats?"; +"AreYouSureClearHistoryGroup" = "Delete all cached text and media from this group?"; +"AreYouSureClearHistorySavedMessages" = "Are you sure you want to clear **Saved Messages**? This action cannot be undone."; +"AreYouSureClearHistoryWithChannel" = "Do you want to clear the channel history in **%1$s**?"; +"AreYouSureClearHistoryWithChat" = "Are you sure you want to clear the chat history in **%1$s**?"; +"AreYouSureClearHistoryWithSecretUser" = "This will delete all messages for you and for **%1$s**. Are you sure?"; +"AreYouSureClearHistoryWithUser" = "Are you sure you want to clear your chat history with **%1$s**?"; +"AreYouSureDeleteAndExit" = "Are you sure you want to delete and leave the group?"; +"AreYouSureDeleteAndExitChannel" = "Do you want to delete and leave the channel?"; +"AreYouSureDeleteAndExitName" = "Are you sure you want to delete and leave the group **%1$s**?"; +"AreYouSureDeleteContact" = "Are you sure you want to delete this contact?"; +"AreYouSureDeleteFewChats" = "Are you sure you want to delete these chats?"; +"AreYouSureDeleteFewMessages" = "Are you sure you want to delete these messages?"; +"AreYouSureDeleteFewMessagesMega" = "Are you sure you want to delete these messages for everyone?"; +"AreYouSureDeleteGIF" = "Are you sure you want to delete this GIF?"; +"AreYouSureDeleteGIFEveryone" = "Are you sure you want to delete this GIF for everyone?"; +"AreYouSureDeleteGIFTitle" = "Delete GIF"; +"AreYouSureDeleteMessages" = "Are you sure you want to delete %1$s?"; +"AreYouSureDeletePhoto" = "Are you sure you want to delete this photo?"; +"AreYouSureDeletePhotoEveryone" = "Are you sure you want to delete this photo for everyone?"; +"AreYouSureDeletePhotoTitle" = "Delete photo"; +"AreYouSureDeleteSingleMessage" = "Are you sure you want to delete this message?"; +"AreYouSureDeleteSingleMessageMega" = "Are you sure you want to delete this message for everyone?"; +"AreYouSureDeleteThisChat" = "Do you want to permanently delete this chat?"; +"AreYouSureDeleteThisChatSavedMessages" = "Are you sure you want to delete **Saved Messages**?"; +"AreYouSureDeleteThisChatWithBot" = "Are you sure you want to delete the chat with **%1$s** and block it?"; +"AreYouSureDeleteThisChatWithBotWithCheckmark" = "Are you sure you want to delete the chat with **%1$s**?"; +"AreYouSureDeleteThisChatWithGroup" = "Are you sure you want to delete the chat **%1$s**?"; +"AreYouSureDeleteThisChatWithSecretUser" = "Are you sure you want to delete your secret chat with **%1$s**?"; +"AreYouSureDeleteThisChatWithUser" = "Permanently delete the chat with **%1$s**?"; +"AreYouSureDeleteVideo" = "Are you sure you want to delete this video?"; +"AreYouSureDeleteVideoEveryone" = "Are you sure you want to delete this video for everyone?"; +"AreYouSureDeleteVideoTitle" = "Delete video"; +"AreYouSureLogout" = "Are you sure you want to log out?\n\nThis will cancel all your Secret Chats.\n\nYou can use Telegram on all your devices at once and use several accounts in the same app."; +"AreYouSureRegistration" = "Are you sure you want to stop registration?"; +"AreYouSureSecretChat" = "Are you sure you want to start a secret chat?"; +"AreYouSureSecretChatTitle" = "Secret chat"; +"AreYouSureSendChatToBotAdd" = "This will also add **%1$s** to **%2$s**."; +"AreYouSureSendChatToBotAddRights" = "This will also add **%1$s** to **%2$s** with the following rights: %3$s."; +"AreYouSureSendChatToBotMessage" = "Are you sure you want to send **%1$s** to **%2$s**?"; +"AreYouSureSendChatToBotTitle" = "Send %1$s to %2$s?"; +"AreYouSureSessionTitle" = "Terminate session"; +"AreYouSureSessions" = "Are you sure you want to terminate all other sessions?"; +"AreYouSureSessionsTitle" = "Terminate sessions"; +"AreYouSureShareMyContactInfo" = "Are you sure you want to share your phone number?"; +"AreYouSureShareMyContactInfoBot" = "The bot will know your phone number. This can be useful for integration with other services.\n\n⚠️ Warning! Never enter your Telegram login codes in mini apps."; +"AreYouSureShareMyContactInfoBotUnblock" = "This will also unblock the bot."; +"AreYouSureShareMyContactInfoUser" = "Do you want to share your phone number %1$s with **%2$s**?"; +"AreYouSureShareMyContactInfoWebapp" = "**%s** will know your phone number. This can be useful for integration with other services."; +"AreYouSureUnblockContact" = "Are you sure you want to unblock this user?"; +"AreYouSureUnsaveFewMessages" = "Are you sure you want to delete these messages?"; +"AreYouSureUnsaveSingleMessage" = "Are you sure you want to remove this message from Saved Messages?"; +"AreYouSureWebSessions" = "Are you sure you want to disconnect all websites where you logged in with Telegram?"; +"ArticleByAuthor" = "by %1$s"; +"ArticleDateByAuthor" = "%1$s by %2$s"; +"AskAQuestion" = "Ask a Question"; +"AskAQuestionInfo" = "Please note that Telegram Support is done by volunteers. We try to respond as quickly as possible, but it may take a while.\n\nPlease take a look at the Telegram FAQ]]>: it has answers to most questions and important tips for troubleshooting]]>."; +"AskButton" = "Ask a volunteer"; +"AttachAudio" = "Voice message"; +"AttachAudioRestricted" = "The admins of this group have restricted you from sending music here until %1$s."; +"AttachAudioRestrictedForever" = "The admins of this group have restricted you from sending music here."; +"AttachBotsHelp" = "Scroll down for bots"; +"AttachContact" = "Contact"; +"AttachContactsSlowMode" = "Slow Mode is active. You can't send more than 1 contact."; +"AttachDestructingPhoto" = "Self-Destructing Photo"; +"AttachDestructingVideo" = "Self-Destructing Video"; +"AttachDestructingVoice" = "Self-Destructing Voice Message"; +"AttachDocument" = "File"; +"AttachDocumentsRestricted" = "The admins of this group have restricted you from sending documents here until %1$s."; +"AttachDocumentsRestrictedForever" = "The admins of this group have restricted you from sending documents here."; +"AttachGame" = "Game"; +"AttachGif" = "GIF"; +"AttachGifRestricted" = "The admins of this group have restricted your ability to send GIFs until %1$s."; +"AttachGifRestrictedForever" = "The admins of this group have restricted your ability to send GIFs."; +"AttachInlineRestricted" = "The admins of this group have restricted your ability to send inline content until %1$s."; +"AttachInlineRestrictedForever" = "The admins of this group have restricted your ability to send inline content."; +"AttachLiveLocation" = "Live Location"; +"AttachLiveLocationIsSharing" = "%1$s sharing with %2$s"; +"AttachLiveLocationIsSharingChat" = "%1$s sharing with %2$s"; +"AttachLiveLocationIsSharingChats" = "%1$s sharing with %2$s"; +"AttachLivePhoto" = "Motion Photo"; +"AttachLocation" = "Location"; +"AttachMediaDragHint" = "Drag media to reorder"; +"AttachMediaPhotoDeselected" = "Photo unselected."; +"AttachMediaPreview" = "Message preview"; +"AttachMediaPreviewButton" = "Show preview"; +"AttachMediaRestricted" = "The admins of this group have restricted your ability to send media until %1$s."; +"AttachMediaRestrictedForever" = "The admins of this group have restricted your ability to send media."; +"AttachMediaVideoDeselected" = "Video unselected."; +"AttachMenu" = "Attach..."; +"AttachMusic" = "Music"; +"AttachOnceAudio" = "One-time Voice Message"; +"AttachOnceRound" = "One-time Video Message"; +"AttachPaidMedia" = "⭐️ %s"; +"AttachPhoto" = "Photo"; +"AttachPhotoExpired" = "Expired photo"; +"AttachPhotoRestricted" = "The admins of this group have restricted you from sending photos here until %1$s."; +"AttachPhotoRestrictedForever" = "The admins of this group have restricted you from sending photos here."; +"AttachPlainRestricted" = "The admins of this group have restricted you from sending text messages here until %1$s."; +"AttachPlainRestrictedForever" = "The admins of this group have restricted you from sending text messages."; +"AttachQuickReplies" = "Replies"; +"AttachRound" = "Video message"; +"AttachRoundExpired" = "Expired Video Message"; +"AttachRoundRestricted" = "The admins of this group have restricted you from sending video messages here until %1$s."; +"AttachRoundRestrictedForever" = "The admins of this group have restricted you from sending video messages here."; +"AttachSelectedLocation" = "Attach selected location"; +"AttachSticker" = "Sticker"; +"AttachStickersRestricted" = "The admins of this group have restricted your ability to send stickers until %1$s."; +"AttachStickersRestrictedForever" = "The admins of this group have restricted your ability to send stickers."; +"AttachVideo" = "Video"; +"AttachVideoExpired" = "Expired video"; +"AttachVideoRestricted" = "The admins of this group have restricted you from sending videos here until %1$s."; +"AttachVideoRestrictedForever" = "The admins of this group have restricted you from sending videos here."; +"AttachVoiceExpired" = "Voice expired"; +"AttachVoiceRestricted" = "The admins of this group have restricted you from sending voice messages here until %1$s."; +"AttachVoiceRestrictedForever" = "The admins of this group have restricted you from sending voice messages here."; +"AudioAddToProfile" = "Add to Profile"; +"AudioAddedToProfileRemove" = "This audio is visible on your Profile. **Remove >**"; +"AudioAutodownload" = "Voice messages"; +"AudioRemoveFromProfile" = "Remove from Profile"; +"AudioSaveTo" = "Save To..."; +"AudioSaveToInfo" = "Choose where you want this audio to be saved."; +"AudioSaveToMusicFolder" = "To Music Folder"; +"AudioSaveToMyProfile" = "To Profile"; +"AudioSaveToMyProfileSaved" = "Audio added to your Profile."; +"AudioSaveToMyProfileUnsaved" = "Audio removed from your Profile."; +"AudioSaveToSavedMessages" = "To Saved Messages"; +"AudioSaveToSavedMessagesSaved" = "Audio sent to Saved Messages."; +"AudioSavedHint" = "File saved to music."; +"AudioSearchChats" = "Shared Music"; +"AudioSearchGlobal" = "Global Search"; +"AudioSearchLocal" = "Local Music"; +"AudioSearchProfile" = "Profile Music"; +"AudioSpeedCustom" = "Audio will play at %1$sx speed."; +"AudioSpeedFast" = "Audio will play two times faster."; +"AudioSpeedMedium" = "Audio will play at 1.5x speed."; +"AudioSpeedNormal" = "Audio will play at normal speed."; +"AudioTitle" = "Shared Music"; +"AudioUnknownArtist" = "Unknown artist"; +"AudioUnknownTitle" = "Unknown title"; +"AudiosSavedHint#one" = "File saved to music."; +"AudiosSavedHint#other" = "%1$d files saved to music."; +"August" = "August"; +"AuthAnotherClient" = "Scan QR Code"; +"AuthAnotherClientDownloadClientUrl" = "https://desktop.telegram.org/"; +"AuthAnotherClientInfo1" = "Download Telegram on your computer from *desktop.telegram.org*."; +"AuthAnotherClientInfo2" = "Run Telegram Desktop to get the QR code."; +"AuthAnotherClientInfo3" = "Scan the QR code to connect your account."; +"AuthAnotherClientInfo4" = "Log into *Telegram Desktop* or *Telegram Web* by scanning a QR code."; +"AuthAnotherClientInfo5" = "Go to *getdesktop.telegram.org* or *web.telegram.org* to get a QR code"; +"AuthAnotherClientNotFound" = "No auth token found"; +"AuthAnotherClientOk" = "Auth successful"; +"AuthAnotherClientScan" = "Scan QR Code"; +"AuthAnotherClientTokenError" = "Token invalid or already expired."; +"AuthAnotherClientUrl" = "This code can be used to allow someone to log in to your Telegram account.\n\nTo confirm Telegram login, please go to Settings > Devices > Scan QR and scan the code."; +"AuthAnotherWebClientUrl" = "https://web.telegram.org/"; +"AuthorHiddenDescription" = "Senders of these messages restricted to link their name when forwarding."; +"AutoDelete1Day" = "1 day"; +"AutoDelete1Hour" = "1 hour"; +"AutoDelete1Month" = "1 month"; +"AutoDelete24Hours" = "24 hours"; +"AutoDelete7Days" = "1 week"; +"AutoDeleteAfteTitle" = "Auto-delete after..."; +"AutoDeleteAfter" = "auto-delete after %s"; +"AutoDeleteAfter1Day" = "After 1 day"; +"AutoDeleteAfter1Month" = "After 1 month"; +"AutoDeleteAfter1Week" = "After 1 week"; +"AutoDeleteAfter3Weeks" = "After 3 weeks"; +"AutoDeleteAfterShort" = "After %s"; +"AutoDeleteAlertChannelInfo" = "Automatically delete new messages sent in this channel after a certain period of time."; +"AutoDeleteAlertGroupInfo" = "Automatically delete new messages sent in this group after a certain period of time."; +"AutoDeleteAlertTitle" = "Auto-delete messages"; +"AutoDeleteAlertUserInfo" = "Automatically delete new messages for you and %1$s after a certain period of time."; +"AutoDeleteCachedMedia" = "Auto-remove cached media"; +"AutoDeleteConfirm" = "Confirm"; +"AutoDeleteConfirmMessage" = "Are you sure you want all messages in your new private chats and in new groups you create to be automatically deleted for everyone %s after they are sent?"; +"AutoDeleteCustom" = "Custom"; +"AutoDeleteCustom2" = "Set custom time"; +"AutoDeleteDisable" = "Disable"; +"AutoDeleteDisabled" = "auto-delete disabled"; +"AutoDeleteGlobalAction" = "un1 uses a self-destruct timer for all chats.\nAll new messages in this chat will be automatically deleted %1$s after they are sent."; +"AutoDeleteGlobalActionFromYou" = "You set a self-destruct timer for all chats.\nAll new messages in this chat will be automatically deleted %s after they were sent."; +"AutoDeleteGlobalTimerDisabled" = "Self-destruct timer disabled for all new chats."; +"AutoDeleteGlobalTimerEnabled" = "Messages in all new chats you start will be automatically deleted after **%s**."; +"AutoDeleteHeader" = "Auto-delete messages in this chat"; +"AutoDeleteHintOffText" = "Auto-delete is now disabled."; +"AutoDeleteHintOnText" = "New messages in this chat will be automatically deleted after %1$s."; +"AutoDeleteIn" = "Auto-deletes in %1$s"; +"AutoDeleteInfo" = "Automatically delete new messages sent in this chat after a certain period of time."; +"AutoDeleteMediaNever" = "Never"; +"AutoDeleteMessages" = "Auto-Delete Messages"; +"AutoDeleteNever" = "Off"; +"AutoDeletePopupDescription" = "Automatically delete new messages sent in this chat after a certain period of time."; +"AutoDeletePopupDescription2" = "You can also set a default **self-destruct timer** for all chats in Settings."; +"AutoDeletePopupTitle" = "Auto-Delete"; +"AutoDeleteSet" = "Set for this chat"; +"AutoDeleteSetInfo" = "Messages in this chat are automatically\ndeleted %1$s after they are sent."; +"AutoDeleteSetTimer" = "Set auto-delete timer"; +"AutoDeleteSettingsInfo" = "Automatically delete messages for everyone after a period of time in all new chats you start."; +"AutoDownloadAudioInfo" = "Voice messages are tiny, so they're always downloaded automatically."; +"AutoDownloadChannels" = "Channels"; +"AutoDownloadContacts" = "Contacts"; +"AutoDownloadCustom" = "Custom"; +"AutoDownloadDataUsage" = "Data usage"; +"AutoDownloadFiles" = "Files"; +"AutoDownloadFilesOn" = "Files"; +"AutoDownloadFilesTitle" = "Auto-download files and music"; +"AutoDownloadGroups" = "Groups"; +"AutoDownloadHigh" = "High"; +"AutoDownloadLow" = "Low"; +"AutoDownloadMaxFileSize" = "Maximum file size"; +"AutoDownloadMaxVideoSize" = "Maximum video size"; +"AutoDownloadMedia" = "Auto-Download Media"; +"AutoDownloadMedium" = "Medium"; +"AutoDownloadOff" = "Off"; +"AutoDownloadOn" = "On"; +"AutoDownloadOnAllChats" = "On in all chats"; +"AutoDownloadOnFor" = "On for %1$s"; +"AutoDownloadOnMobileData" = "On Mobile Data"; +"AutoDownloadOnMobileDataInfo" = "Turn this ON if you want media to be automatically downloaded on mobile data."; +"AutoDownloadOnRoamingData" = "When Roaming"; +"AutoDownloadOnRoamingDataInfo" = "Turn this ON if you want media to be automatically downloaded when roaming."; +"AutoDownloadOnUpToFor" = "Up to %1$s for %2$s"; +"AutoDownloadOnWiFiData" = "On Wi-Fi"; +"AutoDownloadOnWiFiDataInfo" = "Turn this ON if you want media to be automatically downloaded on Wi-Fi."; +"AutoDownloadPhotos" = "Photos"; +"AutoDownloadPhotosOn" = "Photos"; +"AutoDownloadPhotosTitle" = "Auto-download photos"; +"AutoDownloadPm" = "PM"; +"AutoDownloadPreloadMusic" = "Preload next track"; +"AutoDownloadPreloadMusicInfo" = "Start downloading the next track while you are listening to an audio file."; +"AutoDownloadPreloadVideo" = "Preload Larger Videos"; +"AutoDownloadPreloadVideoInfo" = "Preload the first few seconds (1-2 MB) of videos larger than %1$s for instant playback."; +"AutoDownloadStories" = "Stories"; +"AutoDownloadTypes" = "Types of media"; +"AutoDownloadUpToOnAllChats" = "Up to %1$s in all chats"; +"AutoDownloadVideos" = "Videos"; +"AutoDownloadVideosOn" = "Videos"; +"AutoDownloadVideosTitle" = "Auto-download videos and GIFs"; +"AutoException#one" = "%1$d automatic exception"; +"AutoException#other" = "%1$d automatic exceptions"; +"AutoLock" = "Auto-lock"; +"AutoLockDisabled" = "Disabled"; +"AutoLockInTime" = "in %1$s"; +"AutoLockInfo" = "Require passcode if away for some time."; +"AutoNightAdaptive" = "Adaptive"; +"AutoNightAutomatic" = "Automatic"; +"AutoNightBrightness" = "Brightness threshold"; +"AutoNightBrightnessInfo" = "Switch to your preferred night theme when ambient lighting falls below %1$d%%."; +"AutoNightDisabled" = "Disabled"; +"AutoNightFrom" = "From"; +"AutoNightLocation" = "Use Local Sunset & Sunrise"; +"AutoNightModeOff" = "Auto-Night Mode is off"; +"AutoNightPreferred" = "Preferred night theme"; +"AutoNightSchedule" = "Schedule"; +"AutoNightScheduled" = "Scheduled"; +"AutoNightSystemDefault" = "System Default"; +"AutoNightSystemModeOff" = "System Default theme is now disabled."; +"AutoNightTheme" = "Auto-Night Mode"; +"AutoNightThemeOff" = "Off"; +"AutoNightTo" = "To"; +"AutoNightUpdateLocation" = "Update Location"; +"AutoNightUpdateLocationInfo" = "Calculating sunset & sunrise times requires a one-time check of your approximate location. Note that this location is only stored locally on your device.\n\nSunset: %1$s\nSunrise: %2$s"; +"AutodeleteTimerDisabledForChats" = "Self-destruct timer disabled in %s."; +"AutodeleteTimerEnabledForChats" = "Self-destruct timer for **%1$s** has been enabled in %2$s."; +"AutodownloadChannels" = "Channels"; +"AutodownloadContacts" = "Contacts"; +"AutodownloadGroupChats" = "Group Chats"; +"AutodownloadPrivateChats" = "Private Chats"; +"AutodownloadSizeLimit" = "Limit by Size"; +"AutodownloadSizeLimitUpTo" = "up to %1$s"; +"AutomaticDownloadSettings" = "Auto-Download Settings"; +"AutomaticDownloadSettingsInfoMobile" = "You can change your auto-download settings to reduce mobile data usage."; +"AutomaticDownloadSettingsInfoRoaming" = "You can change your auto-download settings to reduce data usage when roaming."; +"AutomaticDownloadSettingsInfoWiFi" = "You can change your auto-download settings to reduce data usage when connected to Wi-Fi."; +"AutomaticMediaDownload" = "Automatic media download"; +"AutomaticTranslation" = "Automatic Translation"; +"AutoplayGIF" = "GIFs"; +"AutoplayGifs" = "Autoplay GIFs"; +"AutoplayMedia" = "Autoplay media"; +"AutoplayVideo" = "Videos"; +"AutoplayVideoInfo" = "Press the volume buttons to turn sound on."; +"Available" = "Available"; +"AvailableIn" = "Available in %s"; +"AvailableReactions" = "Available reactions"; +"AvatarPreviewSearchMessages" = "Search Messages"; +"AwaitingEncryption" = "Waiting for %s to come online..."; +"AwayTo" = "%1$s away"; +"Back" = "Back"; +"BackEnteringCode" = "< Back to entering the code"; +"BackEnteringPhrase" = "< Back to entering the phrase"; +"BackEnteringWord" = "< Back to entering the word"; +"BackgroundAnimate" = "Animate"; +"BackgroundAnimateInfo" = "Colors will move when you send messages"; +"BackgroundBlurred" = "Blurred"; +"BackgroundBrightness" = "Brightness"; +"BackgroundChoosePattern" = "Choose pattern"; +"BackgroundColor" = "Color"; +"BackgroundColorPreviewLine1" = "Swipe left or right to see more colors."; +"BackgroundColorPreviewLine2" = "Salmon is a fish, not a color."; +"BackgroundColorSinglePreviewLine1" = "Press 'Apply' to set the wallpaper."; +"BackgroundColorSinglePreviewLine2" = "Looks good"; +"BackgroundColorSinglePreviewLine3" = "Apply the wallpaper in this chat"; +"BackgroundColors" = "Colors"; +"BackgroundDimming" = "Wallpaper dimming"; +"BackgroundHexColorCode" = "Hex color code"; +"BackgroundIntensity" = "Intensity"; +"BackgroundMotion" = "Motion"; +"BackgroundPattern" = "Pattern"; +"BackgroundPreview" = "Wallpaper Preview"; +"BackgroundPreviewLine1" = "Hint: some wallpaper images look better with the blur effect."; +"BackgroundPreviewLine2" = "Woo-hoo! Thanks"; +"BackgroundRestricted" = "Background data restricted."; +"BackgroundSearchColor" = "Color:"; +"BackgroundToChats" = "Wallpaper sent to **%1$s**."; +"BackgroundToGroup" = "Wallpaper sent to **%1$s**."; +"BackgroundToSavedMessages" = "Wallpaper sent to **Saved Messages**."; +"BackgroundToUser" = "Wallpaper shared with **%1$s**."; +"BadgeNumber" = "Badge Counter"; +"BadgeNumberMutedChats" = "Include Muted Chats"; +"BadgeNumberShow" = "Show Badge Icon"; +"BadgeNumberUnread" = "Count Unread Messages"; +"Ban" = "Ban"; +"BanFromTheGroup" = "BAN FROM THE GROUP"; +"BanFromTheGroupNoCaps" = "Ban from the group"; +"BanUser" = "Ban user"; +"BanUserMonoforum" = "Ban User From Channel"; +"BannedPhoneNumber" = "This phone number is banned."; +"Bans#one" = "%1$d restriction"; +"Bans#other" = "%1$d restrictions"; +"BioCopied" = "Bio copied to clipboard."; +"BioLimitSubtitle" = "Add more characters and use links in your bio"; +"BioLimitTitle" = "Bio"; +"BirthdayClearMessage" = "Are you sure you want to remove your birthday from your profile?"; +"BirthdayClearTitle" = "Remove birthday"; +"BirthdayCopied" = "Birthday copied to clipboard."; +"BirthdayRemove" = "Remove"; +"BirthdaySetupLater" = "You can always set your birthday in profile settings."; +"BirthdaySetupMessage" = "Let your contacts know when you’re celebrating"; +"BirthdaySetupTitle" = "Add your birthday! 🎂"; +"BirthdayToday" = "🎂 Birthday today"; +"BirthdayTodayMultipleMessage" = "Gift them Telegram Premium."; +"BirthdayTodayMultipleMessage2" = "Send them a Gift."; +"BirthdayTodayMultipleTitle#other" = "%d contacts have **birthdays** today! 🎂"; +"BirthdayTodaySingleMessage" = "Gift them Telegram Premium."; +"BirthdayTodaySingleMessage2" = "Send them a Gift."; +"BirthdayTodaySingleTitle" = "It’s %s’s **birthday** today! 🎂"; +"BirthdayTomorrow" = "Birthday tomorrow"; +"BirthdayYesterday" = "Birthday yesterday"; +"BizBotManage" = "Manage bot"; +"BizBotRemove" = "Remove bot\nfrom this chat"; +"BizBotStart" = "Start"; +"BizBotStatusManages" = "bot manages this chat"; +"BizBotStatusStopped" = "bot paused"; +"BizBotStop" = "Stop"; +"Black" = "Black"; +"Block" = "BLOCK"; +"BlockAndDeleteReplies" = "BLOCK AND DELETE REPLIES"; +"BlockBot" = "Block bot"; +"BlockContact" = "Block user"; +"BlockUser" = "Block user"; +"BlockUserAlert" = "Do you want to block **%1$s** from messaging and calling you on Telegram?"; +"BlockUserChatsTitle" = "CHATS"; +"BlockUserContactsTitle" = "CONTACTS"; +"BlockUserMessage" = "Do you want to block **%1$s** from messaging and calling you on Telegram?"; +"BlockUserMultiTitle" = "Block User"; +"BlockUserReplyAlert" = "Do you want to block messages from **%1$s**?"; +"BlockUserTitle" = "Block %1$s"; +"BlockUsers" = "Block users"; +"BlockUsersMessage" = "Do you want to block **%1$s** from messaging and calling you on Telegram?"; +"BlockedEmpty" = "None"; +"BlockedUsers" = "Blocked Users"; +"BlockedUsersCount#one" = "%1$d blocked user"; +"BlockedUsersCount#other" = "%1$d blocked users"; +"BlockedUsersInfo" = "Blocked users can't send you messages or add you to groups. They will not see your profile photos, stories, online and last seen status."; +"Blue" = "Blue"; +"BlurInChat" = "Blur in chat"; +"BlurLinear" = "Linear"; +"BlurOff" = "Off"; +"BlurRadial" = "Radial"; +"Bold" = "Bold"; +"BoldMonths#one" = "**%1$d** month"; +"BoldMonths#other" = "**%1$d** months"; +"BoldYears#one" = "**%1$d** year"; +"BoldYears#other" = "**%1$d** years"; +"BoostBtn" = "Boost"; +"BoostChannel" = "Boost Channel"; +"BoostDate" = "Date"; +"BoostExpireOn" = "boost expires on %s"; +"BoostFeatureAutotranslation" = "Auto-Translation of Messages"; +"BoostFeatureBackground#one" = "%d Channel Background"; +"BoostFeatureBackground#other" = "%d Channel Backgrounds"; +"BoostFeatureBackgroundGroup#one" = "%d Group Background"; +"BoostFeatureBackgroundGroup#other" = "%d Group Backgrounds"; +"BoostFeatureCustomBackground" = "Custom Channel Background"; +"BoostFeatureCustomBackgroundGroup" = "Custom Group Background"; +"BoostFeatureCustomEmojiPack" = "Custom Emoji Pack"; +"BoostFeatureCustomReaction#one" = "%d Custom Reaction"; +"BoostFeatureCustomReaction#other" = "%d Custom Reactions"; +"BoostFeatureEmojiStatuses" = "%s Emoji Statuses"; +"BoostFeatureNameColor#other" = "%d Channel Name Colors"; +"BoostFeatureNameColorGroup#other" = "%d Group Name Colors"; +"BoostFeatureProfileColor#other" = "%d Colors for Channel Cover"; +"BoostFeatureProfileColorGroup#other" = "%d Colors for Group Cover"; +"BoostFeatureProfileIcon" = "Custom Logo for Channel Cover"; +"BoostFeatureProfileIconGroup" = "Custom Logo for Group Cover"; +"BoostFeatureRealTimeTranslations" = "Real-Time Translations"; +"BoostFeatureReplyColor#one" = "%d Styles for Links and Quotes"; +"BoostFeatureReplyColor#other" = "%d Styles for Links and Quotes"; +"BoostFeatureReplyIcon" = "Custom Logo for Links and Quotes"; +"BoostFeatureStoriesPerDay#one" = "%d Story Per Day"; +"BoostFeatureStoriesPerDay#other" = "%d Stories Per Day"; +"BoostFeatureSwitchOffAds" = "Switch Off Ads"; +"BoostFeatureVoiceToTextConversion" = "Voice-to-Text Conversion"; +"BoostFrom" = "From"; +"BoostGift" = "Gift"; +"BoostGroup" = "Boost Group"; +"BoostLevel#other" = "Level %d"; +"BoostLevelPlus#other" = "Level %d+"; +"BoostLevelRequired#other" = "Level %1$d Required"; +"BoostLevelUnlocks#other" = "Level %d Unlocks:"; +"BoostLinkButton" = "BOOST"; +"BoostReason" = "Reason"; +"BoostReasonGiveaway" = "Giveaway"; +"BoostStars#one" = "%d Star"; +"BoostStars#other" = "%d Stars"; +"BoostStories#one" = "**%d**\u00a0story"; +"BoostStories#other" = "**%d**\u00a0stories"; +"BoostUntil" = "Until"; +"Boosters" = "Boosters"; +"BoostersInfoDescription" = "Your channel is currently boosted by these users."; +"BoostersInfoGroupDescription" = "Your group is currently boosted by these users."; +"BoostingAddChannel" = "Add Channel"; +"BoostingAddChannelOrGroup" = "Add Group or Channel"; +"BoostingAdditionalFeaturesSubtitle" = "By gaining **boosts**, your group reaches higher levels and unlocks more features."; +"BoostingAdditionalFeaturesSubtitleChannel" = "By gaining **boosts**, your channel reaches higher levels and unlocks more features."; +"BoostingAdditionalFeaturesTitle" = "Additional Features"; +"BoostingAllMembers" = "All members"; +"BoostingAllSubscribers" = "All subscribers"; +"BoostingApplyChangesChannels" = "You have changed the list of channels. Apply changes?"; +"BoostingApplyChangesCountries" = "You have changed the list of countries. Apply changes?"; +"BoostingApplyChangesUsers" = "You have changed the list of users. Apply changes?"; +"BoostingAvailableIn" = "available in %1$s"; +"BoostingAwardSpecificUsers" = "Award Specific Users"; +"BoostingAwardsCreated" = "Premium Subscriptions Gifted"; +"BoostingBoostAgain" = "Boost Again"; +"BoostingBoostChannelMenu" = "Boost Channel"; +"BoostingBoostForChannels" = "Boosts for Channels"; +"BoostingBoostForChannelsInfo" = "Subscribers of your channel can **boost** it so that it **levels up** and gets **exclusive features**."; +"BoostingBoostForGroups" = "Boosts for Groups"; +"BoostingBoostForGroupsInfo" = "Members of your group can **boost** it so that it **levels up** and gets **exclusive features**."; +"BoostingBoostGroupMenu" = "Boost Group"; +"BoostingBoostStars#one" = "%d Star"; +"BoostingBoostStars#other" = "%d Stars"; +"BoostingBoostToSendMessages" = "Boost this group to send messages"; +"BoostingBoostsChannelByUserServiceMsg" = "%s boosted the channel"; +"BoostingBoostsChannelByUserServiceMsgCount#other" = "%2$s boosted the channel %1$d times"; +"BoostingBoostsChannelByYouServiceMsg" = "You boosted the channel"; +"BoostingBoostsChannelByYouServiceMsgCount#other" = "You boosted the channel %1$d times"; +"BoostingBoostsCount#one" = "%1$d Boost"; +"BoostingBoostsCount#other" = "%1$d Boosts"; +"BoostingBoostsCountTitle#one" = "%d boost"; +"BoostingBoostsCountTitle#other" = "%d boosts"; +"BoostingBoostsGroupByUserServiceMsg" = "%s boosted the group"; +"BoostingBoostsGroupByUserServiceMsgCount#other" = "%2$s boosted the group %1$d times"; +"BoostingBoostsGroupByYouServiceMsg" = "You boosted the group"; +"BoostingBoostsGroupByYouServiceMsgCount#other" = "You boosted the group %1$d times"; +"BoostingBoostsViaGifts" = "Boosts via Gifts"; +"BoostingChannelWillReceiveBoost#one" = "this channel will receive %d boost"; +"BoostingChannelWillReceiveBoost#other" = "this channel will receive %d boosts"; +"BoostingChannelsGroupsIncludedGiveaway" = "Groups and channels included"; +"BoostingChannelsIncludedGiveaway" = "Channels included in the giveaway"; +"BoostingCheckGiftsStatistic" = "Check your channel's **Statistics** to see how the gifts boosted your channel."; +"BoostingCheckGiftsStatisticGroup" = "Check your groups’ **Statistics** to see how gifts boosted your group."; +"BoostingCheckStatistic" = "Check your channels’ **Statistics** to see how this giveaway boosted your channel."; +"BoostingCheckStatisticGroup" = "Check your groups’ **Statistics** to see how this giveaway boosted your group."; +"BoostingChooseChannelsGroupsNeedToJoin" = "Choose additional groups and channels the users need to join to take part in the giveaway."; +"BoostingChooseChannelsNeedToJoin" = "Choose the channels users need to join to take part in the giveaway."; +"BoostingChooseHowMany" = "Choose how many Premium subscriptions to give away and boosts to receive."; +"BoostingChooseLimitGiveaway" = "Choose if you want to limit the giveaway only to those who joined the channel after the giveaway started or to users from specific countries."; +"BoostingChooseLimitGiveawayGroups" = "Choose if you want to limit the giveaway only to those who joined the group after the giveaway started or to users from specific countries."; +"BoostingChooseRandom#one" = "Choose when %d subscriber of your channel will be randomly selected to receive Telegram Premium."; +"BoostingChooseRandom#other" = "Choose when %d subscribers of your channel will be randomly selected to receive Telegram Premium."; +"BoostingChooseRandomGroup#one" = "Choose when %d member of your group will be randomly selected to receive Telegram Premium."; +"BoostingChooseRandomGroup#other" = "Choose when %d members of your group will be randomly selected to receive Telegram Premium."; +"BoostingConfirm" = "Confirm"; +"BoostingCongratulations" = "Congratulations!"; +"BoostingCreateGiveaway" = "Create Giveaway"; +"BoostingDate" = "Date"; +"BoostingDateAndTime" = "Date and Time"; +"BoostingDateWhenGiveawayEnds" = "Date when giveaway ends"; +"BoostingDurationOfPremium" = "Duration of Premium subscriptions"; +"BoostingEligibleUsers" = "Users eligible for the giveaway"; +"BoostingEnableColor" = "Enable Colors"; +"BoostingEnableEmojiStatus" = "Unlock Emoji Status"; +"BoostingEnableGroupEmojiPack" = "Unlock Group Emoji Pack"; +"BoostingEnableLinkIcon" = "Unlock Link Icons"; +"BoostingEnableProfileColor" = "Unlock Profile Colors"; +"BoostingEnableProfileIcon" = "Unlock Profile Icons"; +"BoostingEnableStories" = "Enable Stories"; +"BoostingEnableStoriesForChannel" = "Enable stories for the channel"; +"BoostingEnableStoriesForChannel2" = "Enable stories for this channel"; +"BoostingEnableStoriesForGroup" = "Enable stories for the group"; +"BoostingEnableWallpaper" = "Unlock Wallpaper"; +"BoostingEnableWearCollectibles" = "Wear Item"; +"BoostingExpires" = "boost expires on %s"; +"BoostingFrom" = "From"; +"BoostingFromAllCountries" = "from all countries"; +"BoostingFromAllCountries1" = "from %1$s"; +"BoostingFromAllCountries2" = "from %1$s, %2$s"; +"BoostingFromAllCountries3" = "from %1$s, %2$s, %3$s"; +"BoostingFromCountriesCount#one" = "from %1$d country"; +"BoostingFromCountriesCount#other" = "from %1$d countries"; +"BoostingFromOtherChannel#one" = "%d other chat"; +"BoostingFromOtherChannel#other" = "%d other chats"; +"BoostingGetBoostsViaGifts" = "Get Boosts via Gifts"; +"BoostingGetMoreBoost" = "Get more boosts for your channel by gifting Premium to your subscribers."; +"BoostingGetMoreBoost2" = "Get more boosts and subscribers for your channel by giving away prizes."; +"BoostingGetMoreBoostByGifting" = "To boost **%1$s**, get more boosts by gifting **Telegram Premium** to a friend."; +"BoostingGetMoreBoostByGiftingCount#one" = "To boost **%2$s**, get **%1$d** more boost by gifting **Telegram Premium** to a friend."; +"BoostingGetMoreBoostByGiftingCount#other" = "To boost **%2$s**, get **%1$d** more boosts by gifting **Telegram Premium** to a friend."; +"BoostingGetMoreBoostGroup" = "Get more boosts for your group by gifting Premium to your members."; +"BoostingGetMoreBoosts" = "Get more boosts for your channel by gifting Premium to your subscribers."; +"BoostingGetMoreBoosts2" = "Get more boosts and subscribers for your channel by giving away prizes."; +"BoostingGetMoreBoostsGroup" = "Get more boosts for your group by gifting Premium to your members."; +"BoostingGift" = "Gift"; +"BoostingGiftLink" = "Gift Link"; +"BoostingGiftLinkForwardedTo" = "Gift link forwarded to **%1$s**."; +"BoostingGiftLinkForwardedToSavedMsg" = "Gift link forwarded to **Saved Messages**."; +"BoostingGiftsCount#one" = "%1$d Gift"; +"BoostingGiftsCount#other" = "%1$d Gifts"; +"BoostingGiveAwayAbout" = "About this giveaway"; +"BoostingGiveAwayFromCountries" = "from %1$s"; +"BoostingGiveaway" = "Giveaway"; +"BoostingGiveawayAdditionPrizeCountHint#one" = "All prizes: **%1$d** Telegram Premium subscriptions for %2$s."; +"BoostingGiveawayAdditionPrizeCountHint#other" = "All prizes: **%1$d** Telegram Premium subscriptions for %2$s."; +"BoostingGiveawayAdditionPrizeCountNameHint#one" = "All prizes: **%1$d** %2$s with Telegram Premium subscriptions for %3$s."; +"BoostingGiveawayAdditionPrizeCountNameHint#other" = "All prizes: **%1$d** %2$s with Telegram Premium subscriptions for %3$s."; +"BoostingGiveawayAdditionPrizeHint" = "Turn this on if you want to give the winners your own prizes in addition to Premium subscriptions."; +"BoostingGiveawayAdditionalPrizes" = "Additional Prizes"; +"BoostingGiveawayCanceledByPayment" = "The channel canceled the prizes by refunding the payment for them."; +"BoostingGiveawayChannelStarted" = "Channel started a giveaway"; +"BoostingGiveawayCreated" = "Giveaway created"; +"BoostingGiveawayDeleteMsgText" = "Deleting this message won't cancel the giveaway — the winners will still be selected on **%s**.\n\nOnce deleted, the Giveaway Announcement cannot be restored."; +"BoostingGiveawayDeleteMsgTitle" = "Delete announcement"; +"BoostingGiveawayEnd" = "Giveaway ended"; +"BoostingGiveawayEnterYourPrize" = "Enter Your Prize"; +"BoostingGiveawayGroupStarted" = "Group started a giveaway"; +"BoostingGiveawayHowItWorksIncludeText#one" = "**%2$s** also included **%1$d** **%3$s** with the prizes. The channel admins are responsible for delivering these prizes."; +"BoostingGiveawayHowItWorksIncludeText#other" = "**%2$s** also included **%1$d** **%3$s** with the prizes. The channel admins are responsible for delivering these prizes."; +"BoostingGiveawayHowItWorksSubText#one" = "On **%2$s**, Telegram will automatically select **%3$s** random user that joined **%4$s**."; +"BoostingGiveawayHowItWorksSubText#other" = "On **%2$s**, Telegram will automatically select **%3$s** random users that joined **%4$s**."; +"BoostingGiveawayHowItWorksSubTextDate#one" = "On **%2$s**, Telegram will automatically select **%3$s** random user that joined **%4$s** after **%5$s** on **%6$s**."; +"BoostingGiveawayHowItWorksSubTextDate#other" = "On **%2$s**, Telegram will automatically select **%3$s** random users that joined **%4$s** after **%5$s** on **%6$s**."; +"BoostingGiveawayHowItWorksSubTextDateEnd#one" = "On **%2$s**, Telegram automatically selected **%3$s** random user that joined **%4$s** after **%5$s** on **%6$s**."; +"BoostingGiveawayHowItWorksSubTextDateEnd#other" = "On **%2$s**, Telegram automatically selected **%3$s** random users that joined **%4$s** after **%5$s** on **%6$s**."; +"BoostingGiveawayHowItWorksSubTextDateSeveral#one" = "On **%2$s**, Telegram will automatically select **%3$s** random user that joined **%4$s** and **%5$s** other listed channels after **%6$s** on **%7$s**."; +"BoostingGiveawayHowItWorksSubTextDateSeveral#other" = "On **%2$s**, Telegram will automatically select **%3$s** random users that joined **%4$s** and **%5$s** other listed channels after **%6$s** on **%7$s**."; +"BoostingGiveawayHowItWorksSubTextDateSeveral1#one" = "On **%2$s**, Telegram will automatically select **%3$s** random user that joined **%4$s** %5$s."; +"BoostingGiveawayHowItWorksSubTextDateSeveral1#other" = "On **%2$s**, Telegram will automatically select **%3$s** random users that joined **%4$s** %5$s."; +"BoostingGiveawayHowItWorksSubTextDateSeveral2#one" = "and **%1$d** other listed channel after **%2$s** on **%3$s**"; +"BoostingGiveawayHowItWorksSubTextDateSeveral2#other" = "and **%1$d** other listed channels after **%2$s** on **%3$s**"; +"BoostingGiveawayHowItWorksSubTextDateSeveralEnd#one" = "On **%2$s**, Telegram automatically selected **%3$s** random user that joined **%4$s** and **%5$s** other listed channels after **%6$s** on **%7$s**."; +"BoostingGiveawayHowItWorksSubTextDateSeveralEnd#other" = "On **%2$s**, Telegram automatically selected **%3$s** random users that joined **%4$s** and **%5$s** other listed channels after **%6$s** on **%7$s**."; +"BoostingGiveawayHowItWorksSubTextDateSeveralEnd1#one" = "On **%2$s**, Telegram automatically selected **%3$s** random user that joined **%4$s** %5$s."; +"BoostingGiveawayHowItWorksSubTextDateSeveralEnd1#other" = "On **%2$s**, Telegram automatically selected **%3$s** random users that joined **%4$s** %5$s."; +"BoostingGiveawayHowItWorksSubTextEnd#one" = "On **%2$s**, Telegram automatically selected **%3$s** random user that joined **%4$s**."; +"BoostingGiveawayHowItWorksSubTextEnd#other" = "On **%2$s**, Telegram automatically selected **%3$s** random users that joined **%4$s**."; +"BoostingGiveawayHowItWorksSubTextSeveral#one" = "On **%2$s**, Telegram will automatically select **%3$s** random user that joined **%4$s** and **%5$s** other listed channels."; +"BoostingGiveawayHowItWorksSubTextSeveral#other" = "On **%2$s**, Telegram will automatically select **%3$s** random users that joined **%4$s** and **%5$s** other listed channels."; +"BoostingGiveawayHowItWorksSubTextSeveral1#one" = "On **%2$s**, Telegram will automatically select **%3$s** random user that joined **%4$s** %5$s."; +"BoostingGiveawayHowItWorksSubTextSeveral1#other" = "On **%2$s**, Telegram will automatically select **%3$s** random users that joined **%4$s** %5$s."; +"BoostingGiveawayHowItWorksSubTextSeveral2#one" = "and **%1$d** other listed channel"; +"BoostingGiveawayHowItWorksSubTextSeveral2#other" = "and **%1$d** other listed channels"; +"BoostingGiveawayHowItWorksSubTextSeveralEnd#one" = "On **%2$s**, Telegram automatically selected **%3$s** random user that joined **%4$s** and **%5$s** other listed channels."; +"BoostingGiveawayHowItWorksSubTextSeveralEnd#other" = "On **%2$s**, Telegram automatically selected **%3$s** random users that joined **%4$s** and **%5$s** other listed channels."; +"BoostingGiveawayHowItWorksSubTextSeveralEnd1#one" = "On **%2$s**, Telegram automatically selected **%3$s** random user that joined **%4$s** %5$s."; +"BoostingGiveawayHowItWorksSubTextSeveralEnd1#other" = "On **%2$s**, Telegram automatically selected **%3$s** random users that joined **%4$s** %5$s."; +"BoostingGiveawayHowItWorksText#one" = "This giveaway is sponsored by the admins of **%2$s**, who acquired **%3$s Telegram Premium** subscription for %4$s for its followers."; +"BoostingGiveawayHowItWorksText#other" = "This giveaway is sponsored by the admins of **%2$s**, who acquired **%3$s Telegram Premium** subscriptions for %4$s for its followers."; +"BoostingGiveawayHowItWorksTextEnd#one" = "This giveaway was sponsored by the admins of **%2$s**, who acquired **%3$s Telegram Premium** subscription for %4$s for its followers."; +"BoostingGiveawayHowItWorksTextEnd#other" = "This giveaway was sponsored by the admins of **%2$s**, who acquired **%3$s Telegram Premium** subscriptions for %4$s for its followers."; +"BoostingGiveawayHowItWorksTextEndGroup#one" = "This giveaway was sponsored by the admins of **%2$s**, who acquired **%3$s Telegram Premium** subscription for %4$s for its members."; +"BoostingGiveawayHowItWorksTextEndGroup#other" = "This giveaway was sponsored by the admins of **%2$s**, who acquired **%3$s Telegram Premium** subscriptions for %4$s for its members."; +"BoostingGiveawayHowItWorksTextGroup#one" = "This giveaway is sponsored by the admins of **%2$s**, who acquired **%3$s Telegram Premium** subscription for %4$s for its members."; +"BoostingGiveawayHowItWorksTextGroup#other" = "This giveaway is sponsored by the admins of **%2$s**, who acquired **%3$s Telegram Premium** subscriptions for %4$s for its members."; +"BoostingGiveawayJustStarted" = "%s just started a giveaway of Telegram Premium subscriptions for its followers."; +"BoostingGiveawayJustStartedGroup" = "%s just started a giveaway of Telegram Premium subscriptions to its members."; +"BoostingGiveawayMsgAllSubs" = "All subscribers of the channel:"; +"BoostingGiveawayMsgAllSubsGroupPlural#one" = "All members of the group:"; +"BoostingGiveawayMsgAllSubsGroupPlural#other" = "All members of the groups:"; +"BoostingGiveawayMsgAllSubsPlural#one" = "All subscribers of the channel:"; +"BoostingGiveawayMsgAllSubsPlural#other" = "All subscribers of the channels:"; +"BoostingGiveawayMsgInfo" = "**%1$d** Telegram Premium\nSubscription for %2$s."; +"BoostingGiveawayMsgInfoPlural1#one" = "**%1$d** Telegram Premium"; +"BoostingGiveawayMsgInfoPlural1#other" = "**%1$d** Telegram Premium"; +"BoostingGiveawayMsgInfoPlural2#one" = "Subscription for %2$s."; +"BoostingGiveawayMsgInfoPlural2#other" = "Subscriptions for %2$s."; +"BoostingGiveawayMsgNewSubs" = "New subscribers of the channel:"; +"BoostingGiveawayMsgNewSubsGroupPlural#one" = "New members of the group:"; +"BoostingGiveawayMsgNewSubsGroupPlural#other" = "New members of the groups:"; +"BoostingGiveawayMsgNewSubsPlural#one" = "All users who joined the channel\nbelow after this announcement:"; +"BoostingGiveawayMsgNewSubsPlural#other" = "All users who joined the channels\nbelow after this announcement:"; +"BoostingGiveawayMsgParticipants" = "**Participants**"; +"BoostingGiveawayMsgPrizes#one" = "**%1$d** %2$s"; +"BoostingGiveawayMsgPrizes#other" = "**%1$d** %2$s"; +"BoostingGiveawayMsgWithDivider" = "with"; +"BoostingGiveawayNotEligible" = "You are not eligible to participate in this giveaway because you joined this channel on **%1$s**, which is before the contest started."; +"BoostingGiveawayNotEligibleAdmin" = "You are not eligible for this giveaway because you are an admin of a participating channel (**%1$s**)."; +"BoostingGiveawayNotEligibleAdminGroup" = "You are not eligible to participate in this giveaway, because you are an admin of participating group (**%1$s**)."; +"BoostingGiveawayNotEligibleCountry" = "You are not eligible to participate in this giveaway because your country is not included in the terms of the giveaway."; +"BoostingGiveawayParticipant" = "You are participating in this giveaway because you joined the channel **%1$s**."; +"BoostingGiveawayParticipantMulti" = "You are participating in this giveaway because you joined the channel **%1$s** (and **%2$d** other listed channels)."; +"BoostingGiveawayParticipantMultiPlural#one" = "You are participating in this giveaway because you joined the channel **%2$s** (and **%1$d** other listed channel)."; +"BoostingGiveawayParticipantMultiPlural#other" = "You are participating in this giveaway because you joined the channel **%2$s** (and **%1$d** other listed channels)."; +"BoostingGiveawayPrivateChannel" = "Channel is Private"; +"BoostingGiveawayPrivateChannelWarning" = "Are you sure you want to add a private channel? Users won't be able to join it without an invite link."; +"BoostingGiveawayPrivateGroup" = "Group is Private"; +"BoostingGiveawayPrivateGroupWarning" = "Are you sure you want to add a private group? Users won't be able to join it without an invite link."; +"BoostingGiveawayPrizes" = "**Giveaway Prizes**"; +"BoostingGiveawayResults" = "Giveaway results"; +"BoostingGiveawayResultsMsgAllAndMoreWinners#one" = "**And %1$d more!**"; +"BoostingGiveawayResultsMsgAllAndMoreWinners#other" = "**And %1$d more!**"; +"BoostingGiveawayResultsMsgAllWinnersReceivedLinks" = "All winners received gift links in private messages."; +"BoostingGiveawayResultsMsgWinners#one" = "**Winner**"; +"BoostingGiveawayResultsMsgWinners#other" = "**Winners**"; +"BoostingGiveawayResultsMsgWinnersSelected" = "**Winners Selected!**"; +"BoostingGiveawayResultsMsgWinnersTitle#one" = "%1$d winner of the **Giveaway** was randomly selected by Telegram."; +"BoostingGiveawayResultsMsgWinnersTitle#other" = "%1$d winners of the **Giveaway** were randomly selected by Telegram."; +"BoostingGiveawayServiceUndistributed#one" = "%d undistributed link code was forwarded to channel administrators."; +"BoostingGiveawayServiceUndistributed#other" = "%d undistributed links codes were forwarded to channel administrators."; +"BoostingGiveawayServiceUndistributedGroup#one" = "%d undistributed link code was forwarded to group administrators."; +"BoostingGiveawayServiceUndistributedGroup#other" = "%d undistributed link codes were forwarded to group administrators."; +"BoostingGiveawayServiceWinnersSelected#one" = "%d winner of the giveaway was randomly selected by Telegram and received their gift link in a private message."; +"BoostingGiveawayServiceWinnersSelected#other" = "%d winners of the giveaway were randomly selected by Telegram and received their gift links in private messages."; +"BoostingGiveawayShortStatusEnded" = "This giveaway has ended."; +"BoostingGiveawayShortStatusNotParticipating" = "You can't participate in this giveaway."; +"BoostingGiveawayShortStatusParticipating" = "You are participating in this giveaway."; +"BoostingGiveawayShowWinners" = "Show Winners"; +"BoostingGiveawayShowWinnersHint" = "Choose whether to make the list of winners public when the giveaway ends."; +"BoostingGiveawayTakePart" = "To take part in this giveaway please join the channel **%1$s** before **%2$s**."; +"BoostingGiveawayTakePartMulti" = "To take part in this giveaway please join channel **%1$s** (and **%2$d** other listed channels) before **%3$s**."; +"BoostingGiveawayTakePartMultiPlural#one" = "To take part in this giveaway please join channel **%2$s** (and **%1$d** other listed channel) before **%3$s**."; +"BoostingGiveawayTakePartMultiPlural#other" = "To take part in this giveaway please join channel **%2$s** (and **%1$d** other listed channels) before **%3$s**."; +"BoostingGiveawayUsedLinks" = "**%1$d** of the winners already used their gift links."; +"BoostingGiveawayUsedLinksPlural#one" = "**%1$d** of the winners already used their gift links."; +"BoostingGiveawayUsedLinksPlural#other" = "**%1$d** of the winners already used their gift links."; +"BoostingGiveawayViewPrize" = "View my prize"; +"BoostingGiveawayYouNotWon" = "You didn't win a prize in this giveaway."; +"BoostingGiveawayYouWon" = "You won a prize in this giveaway 🏆"; +"BoostingGroupBoostCount#one" = "The group has **%d** boost."; +"BoostingGroupBoostCount#other" = "The group has **%d** boosts."; +"BoostingGroupBoostWhatAreBoosts" = "What are boosts?"; +"BoostingGroupBoostWhatAreBoostsDescription#one" = "Every **Telegram Premium** subscriber can boost your group **%d** time and help you unlock more features by using this link:"; +"BoostingGroupBoostWhatAreBoostsDescription#other" = "Every **Telegram Premium** subscriber can boost your group **%d** times and help you unlock more features by using this link:"; +"BoostingGroupWillReceiveBoost#one" = "this group will receive %d boost"; +"BoostingGroupWillReceiveBoost#other" = "this group will receive %d boosts"; +"BoostingHowItWork" = "LEARN MORE"; +"BoostingIncompleteGiveaway" = "Incomplete Giveaway"; +"BoostingIncreaseLevel" = "Increase Story Limit"; +"BoostingLinkAllows" = "This link allows you to activate\na **Telegram Premium** subscription."; +"BoostingLinkAllowsAnyone" = "This link allows anyone to activate\na **Telegram Premium** subscription."; +"BoostingLinkAllowsToUser" = "This link allows **%1$s** to activate a **Telegram Premium** subscription."; +"BoostingLinkNotActivated" = "This link hasn't been activated yet."; +"BoostingLinkUsed" = "This link was used to activate\na **Telegram Premium** subscription."; +"BoostingMoreBoostsNeeded" = "More Boosts Needed"; +"BoostingNewMembers" = "Only new members"; +"BoostingNewSubscribers" = "Only new subscribers"; +"BoostingNoRecipient" = "No recipient"; +"BoostingOnlyGiveawayCreatorSeeLink" = "Only the giveaway creator can see the link."; +"BoostingOnlyRecipientCode" = "Only the recipient can see the link."; +"BoostingPremium" = "Telegram Premium"; +"BoostingPremiumChristmasSubTitle" = "Send limited-edition collectibles to friends!"; +"BoostingPremiumChristmasTitle" = "👑 Snoop Dogg Gift Drop 🎁"; +"BoostingPremiumChristmasToast" = "You can send gifts to friends later from Settings"; +"BoostingPremiumUserCanBoostGroupWithLink" = "**Premium** users can boost your group with this link:"; +"BoostingPreparedGiveawayOne" = "Prepaid giveaway"; +"BoostingPreparedGiveawaySubscriptions" = "%1$d subscriptions for %2$s"; +"BoostingPreparedGiveawaySubscriptionsPlural#one" = "%1$d subscription for %2$s"; +"BoostingPreparedGiveawaySubscriptionsPlural#other" = "%1$d subscriptions for %2$s"; +"BoostingPreparedGiveaways" = "Prepaid giveaways"; +"BoostingQuantityPrizes" = "Quantity of prizes"; +"BoostingReason" = "Reason"; +"BoostingReassignBoost" = "Reassign Boosts"; +"BoostingReassignBoostText" = "To boost **%1$s**, reassign a previous boost or gift **Telegram Premium** to a friend to get **%2$d** additional boosts."; +"BoostingReassignBoostTextLink" = "**gift Telegram Premium**"; +"BoostingReassignBoostTextPlural#one" = "To boost **%2$s**, reassign a previous boost or gift **Telegram Premium** to a friend to get **%1$d** additional boost."; +"BoostingReassignBoostTextPlural#other" = "To boost **%2$s**, reassign a previous boost or gift **Telegram Premium** to a friend to get **%1$d** additional boosts."; +"BoostingReassignBoostTextPluralWithLink#one" = "To boost **%2$s**, reassign a previous boost or %3$s to a friend to get **%1$d** additional boost."; +"BoostingReassignBoostTextPluralWithLink#other" = "To boost **%2$s**, reassign a previous boost or %3$s to a friend to get **%1$d** additional boosts."; +"BoostingReassignBoosts" = "Reassign Boosts"; +"BoostingReassignedFrom" = "%1$s reassigned from %2$s"; +"BoostingReassignedFromPlural#one" = "%1$d boost reassigned from %2$s"; +"BoostingReassignedFromPlural#other" = "%1$d boosts reassigned from %2$s"; +"BoostingReceivedGiftDuration" = "Your gift is a **Telegram Premium**\nsubscription for %s."; +"BoostingReceivedGiftFrom" = "You've received a gift\nfrom **%s**."; +"BoostingReceivedGiftNoName" = "You received a gift."; +"BoostingReceivedGiftOpenBtn" = "Open Gift Link"; +"BoostingReceivedPrizeDuration" = "Your prize is a **Telegram Premium** subscription for %s."; +"BoostingReceivedPrizeFrom" = "You won a prize in a giveaway organized by **%s**."; +"BoostingReceivedStars#one" = "You've received a prize of %1$d Star from %2$s!"; +"BoostingReceivedStars#other" = "You've received a prize of %1$d Stars from %2$s!"; +"BoostingRecipientWillBeSelected" = "The recipient will be selected when the giveaway ends."; +"BoostingReduceQuantity" = "Reduce Quantity"; +"BoostingReduceQuantityText" = "You can’t purchase **%1$d** %2$s subscriptions in the app. Do you want to reduce the quantity to **%3$d**?"; +"BoostingReduceQuantityTextPlural#one" = "You can’t purchase **%1$d** %2$s subscription in the app. Do you want to reduce the prize quantity to **%3$d**?"; +"BoostingReduceQuantityTextPlural#other" = "You can’t purchase **%1$d** %2$s subscriptions in the app. Do you want to reduce the prize quantity to **%3$d**?"; +"BoostingReduceUsersText" = "You can’t purchase **%1$d** subscriptions in the app. Only **%2$s** available."; +"BoostingReduceUsersTextPlural#one" = "You can’t purchase **%1$d** subscription in the app. Only **%2$s** available."; +"BoostingReduceUsersTextPlural#other" = "You can’t purchase **%1$d** subscriptions in the app. Only **%2$s** available."; +"BoostingRemoveBoostFrom" = "Remove your boost from"; +"BoostingRemoveRestrictionsSubtitle#one" = "Boost the group **%1$d** time to remove messaging restrictions. Your boosts will help **%2$s** to unlock new features."; +"BoostingRemoveRestrictionsSubtitle#other" = "Boost the group **%1$d** times to remove messaging restrictions. Your boosts will help **%2$s** to unlock new features."; +"BoostingRemoveRestrictionsSuccessSubTitle" = "Now you can send messages in it."; +"BoostingRemoveRestrictionsSuccessTitle" = "You boosted the group!"; +"BoostingSaveRecipients" = "Save Recipients"; +"BoostingSelectCountry" = "Select Countries"; +"BoostingSelectDateTime" = "Select Date and Time"; +"BoostingSelectPaidGiveaway" = "Select a giveaway you already paid for to set it up."; +"BoostingSelectRecipients" = "select recipients"; +"BoostingSelectUpTo" = "Select up to %1$d channels"; +"BoostingSelectUpToCountries" = "Select up to %1$d countries"; +"BoostingSelectUpToCountriesPlural#one" = "Select up to %1$d country"; +"BoostingSelectUpToCountriesPlural#other" = "Select up to %1$d countries"; +"BoostingSelectUpToGroupChannelPlural#one" = "Select up to %1$d group or channel"; +"BoostingSelectUpToGroupChannelPlural#other" = "Select up to %1$d groups or channels"; +"BoostingSelectUpToPlural#one" = "Select up to %1$d channel"; +"BoostingSelectUpToPlural#other" = "Select up to %1$d channels"; +"BoostingSelectUpToWarningChannels" = "You can select up to %1$d channels."; +"BoostingSelectUpToWarningChannelsGroupsPlural#one" = "You can select up to %1$d group or channel."; +"BoostingSelectUpToWarningChannelsGroupsPlural#other" = "You can select up to %1$d groups or channels."; +"BoostingSelectUpToWarningChannelsPlural#one" = "You can select %1$d channel."; +"BoostingSelectUpToWarningChannelsPlural#other" = "You can select up to %1$d channels."; +"BoostingSelectUpToWarningCountries" = "You can select maximum %1$d countries."; +"BoostingSelectUpToWarningCountriesPlural#one" = "You can select up to %1$d country."; +"BoostingSelectUpToWarningCountriesPlural#other" = "You can select up to %1$d countries."; +"BoostingSelectUpToWarningUsers" = "You can select up to 10 users."; +"BoostingSendLinkToAnyone" = "You can also **send this link** to anyone as a gift."; +"BoostingSendLinkToFriends" = "You can also **send this link** to a friend as a gift."; +"BoostingShareThisLink" = "Share this link with your subsсribers to get more boosts."; +"BoostingShareThisLinkGroup" = "Share this link with your members to get more boosts."; +"BoostingShortMonths" = "%1$dm"; +"BoostingShowMoreBoosts#one" = "Show %1$d More Boost"; +"BoostingShowMoreBoosts#other" = "Show %1$d More Boosts"; +"BoostingShowMoreGifts#one" = "Show %1$d More Gifted Boost"; +"BoostingShowMoreGifts#other" = "Show %1$d More Gifted Boosts"; +"BoostingStarOptionPerUser#one" = "%d per user"; +"BoostingStarOptionPerUser#other" = "%d per user"; +"BoostingStars" = "Telegram Stars"; +"BoostingStarsChooseRandom#one" = "Choose when %1$d subscriber of your channel will be randomly selected to receive %2$s."; +"BoostingStarsChooseRandom#other" = "Choose when %1$d subscribers of your channel will be randomly selected to receive %2$s."; +"BoostingStarsChooseRandomGroup#one" = "Choose when %1$d member of your group will be randomly selected to receive %2$s."; +"BoostingStarsChooseRandomGroup#other" = "Choose when %1$d members of your group will be randomly selected to receive %2$s."; +"BoostingStarsChooseRandomStars#one" = "%1$d Star"; +"BoostingStarsChooseRandomStars#other" = "%1$d Stars"; +"BoostingStarsCountPlural#one" = "%1$d Star"; +"BoostingStarsCountPlural#other" = "%1$d Stars"; +"BoostingStarsExpires" = "expires on %s"; +"BoostingStarsGiveawayAdditionPrizeCountHint#one" = "All prizes: **%1$d Star**."; +"BoostingStarsGiveawayAdditionPrizeCountHint#other" = "All prizes: **%1$d Stars**."; +"BoostingStarsGiveawayAdditionPrizeCountNameHint#one" = "All prizes: **%2$d** %3$s with **%1$d Star**."; +"BoostingStarsGiveawayAdditionPrizeCountNameHint#other" = "All prizes: **%2$d** %3$s with **%1$d Stars**."; +"BoostingStarsGiveawayAdditionPrizeHint" = "Turn this on if you want to give the winners your own prizes in addition to Stars."; +"BoostingStarsGiveawayHowItWorksText#one" = "This giveaway is sponsored by the admins of **%2$s**, who acquired **%1$d Star** for its followers."; +"BoostingStarsGiveawayHowItWorksText#other" = "This giveaway is sponsored by the admins of **%2$s**, who acquired **%1$d Stars** for its followers."; +"BoostingStarsGiveawayHowItWorksTextEnd#one" = "This giveaway was sponsored by the admins of **%2$s**, who acquired **%1$d Star** for its followers."; +"BoostingStarsGiveawayHowItWorksTextEnd#other" = "This giveaway was sponsored by the admins of **%2$s**, who acquired **%1$d Stars** for its followers."; +"BoostingStarsGiveawayHowItWorksTextEndGroup#one" = "This giveaway was sponsored by the admins of **%2$s**, who acquired **%1$d Star** for its members."; +"BoostingStarsGiveawayHowItWorksTextEndGroup#other" = "This giveaway was sponsored by the admins of **%2$s**, who acquired **%1$d Stars** for its members."; +"BoostingStarsGiveawayHowItWorksTextGroup#one" = "This giveaway is sponsored by the admins of **%2$s**, who acquired **%1$d Star** for its members."; +"BoostingStarsGiveawayHowItWorksTextGroup#other" = "This giveaway is sponsored by the admins of **%2$s**, who acquired **%1$d Stars** for its members."; +"BoostingStarsGiveawayJustStarted#one" = "%2$s just started a giveaway of %1$d Star to its followers."; +"BoostingStarsGiveawayJustStarted#other" = "%2$s just started a giveaway of %1$d Stars to its followers."; +"BoostingStarsGiveawayJustStartedGroup#one" = "%2$s just started a giveaway of %1$d Star to its members."; +"BoostingStarsGiveawayJustStartedGroup#other" = "%2$s just started a giveaway of %1$d Stars to its members."; +"BoostingStarsGiveawayMsgInfoPlural1#one" = "**%1$d Star** will be distributed"; +"BoostingStarsGiveawayMsgInfoPlural1#other" = "**%1$d Stars** will be distributed"; +"BoostingStarsGiveawayMsgInfoPlural2#one" = "to the **%1$d** winner."; +"BoostingStarsGiveawayMsgInfoPlural2#other" = "among **%1$d** winners."; +"BoostingStarsGiveawayResultsMsgAllWinnersReceivedLinks#one" = "All winners received a total of %d Star."; +"BoostingStarsGiveawayResultsMsgAllWinnersReceivedLinks#other" = "All winners received a total of %d Stars."; +"BoostingStarsGiveawayServiceUndistributed#one" = "%d undistributed prize was forwarded to channel administrators."; +"BoostingStarsGiveawayServiceUndistributed#other" = "%d undistributed prizes were forwarded to channel administrators."; +"BoostingStarsGiveawayServiceUndistributedGroup#one" = "%d undistributed star was forwarded to group administrators."; +"BoostingStarsGiveawayServiceUndistributedGroup#other" = "%d undistributed stars were forwarded to group administrators."; +"BoostingStarsGiveawayServiceWinnersSelected#one" = "%1$d winner of the giveaway were randomly selected by Telegram."; +"BoostingStarsGiveawayServiceWinnersSelected#other" = "%1$d winners of the giveaway were randomly selected by Telegram."; +"BoostingStarsOptions" = "Stars to distribute"; +"BoostingStarsOptionsInfo" = "Choose how many stars to give away and how many boosts to receive for 1 year."; +"BoostingStarsPrepaidGiveawayTitle" = "Prepaid giveaway"; +"BoostingStarsPreparedGiveawaySubscriptionsPlural#one" = "%1$d star"; +"BoostingStarsPreparedGiveawaySubscriptionsPlural#other" = "%1$d stars"; +"BoostingStarsQuantityPrizes" = "Number of winners"; +"BoostingStarsQuantityPrizesInfo" = "Choose how many winners you want to distribute stars among."; +"BoostingStartGiveaway" = "Start Giveaway"; +"BoostingStartGiveawayConfirmText" = "Are you sure you want to start the giveaway now?"; +"BoostingStartGiveawayConfirmTitle" = "Start Giveaway"; +"BoostingStoriesByGifting" = "Boost your channel by gifting your subscribers\nTelegram Premium."; +"BoostingStoriesByGiftingChannel" = "Boost your channel by gifting your subscribers\n**Telegram Premium**."; +"BoostingStoriesByGiftingChannel2" = "Boost your channel by giving away prizes to your subscribers."; +"BoostingStoriesByGiftingGroup" = "Boost your group by gifting your members\n**Telegram Premium**."; +"BoostingStoriesByGiftingGroup2" = "Boost your group by giving away prizes to your members."; +"BoostingStoriesByGiftingLink" = "Get boosts >"; +"BoostingStoriesFeaturesAndTerms" = "You can review the list of features and more details about Telegram Premium **here**."; +"BoostingSubscriptionsCount#one" = "%d boost"; +"BoostingSubscriptionsCount#other" = "%d boosts"; +"BoostingSubscriptionsCountPlural#one" = "%2$s subscription"; +"BoostingSubscriptionsCountPlural#other" = "%2$s subscriptions"; +"BoostingTelegramPremiumCountPlural#one" = "%1$d Telegram Premium"; +"BoostingTelegramPremiumCountPlural#other" = "%1$d Telegram Premium"; +"BoostingTelegramPremiumFor" = "Telegram Premium for %s"; +"BoostingTo" = "To"; +"BoostingToBeDistributed" = "To be distributed"; +"BoostingUnclaimed" = "Unclaimed"; +"BoostingUnclaimedPrize" = "Unclaimed Prize"; +"BoostingUnclaimedPrizeDuration" = "This prize is a **Telegram Premium** subscription for %s."; +"BoostingUseLink" = "Use Link"; +"BoostingUsedGiftLink" = "Used Gift Link"; +"BoostingUsedLinkDate" = "This link was used on %s."; +"BoostingWaitWarning" = "Wait until the boost is available or get **%1$d** more boosts by gifting a **Telegram Premium** subscription."; +"BoostingWaitWarningPlural#one" = "Wait until the boost is available or get **%1$d** more boost by gifting a **Telegram Premium** subscription."; +"BoostingWaitWarningPlural#other" = "Wait until the boost is available or get **%1$d** more boosts by gifting a **Telegram Premium** subscription."; +"BoostingWinnersDate" = "**Winners Selection Date**"; +"BoostingWinnersRandomly" = "winners are chosen randomly"; +"BoostingYouHaveUnclaimedPrize" = "You have an unclaimed prize from a giveaway by **%s**."; +"BoostingYouWereSelected" = "You were selected by the channel"; +"BoostingYouWereSelectedGroup" = "You were selected by the group"; +"Boosts" = "Boosts"; +"BoostsExisting" = "Active Boosts"; +"BoostsExpireOn" = "boosts expire on %s"; +"BoostsLevel" = "Level %d"; +"BoostsLevel2" = "Level"; +"BoostsMaxLevelReached" = "Maximum Level Reached"; +"BoostsToLevel" = "Boosts to level up"; +"Bot" = "bot"; +"BotAdWhat" = "what's this?"; +"BotAddToGroupOrChannelInfo" = "This bot is able to manage a group or channel."; +"BotAddToMenu" = "Add to Menu"; +"BotAffiliateProgramFeature1" = "Set the commission for revenue generated by users referred to you."; +"BotAffiliateProgramFeature1Title" = "Share revenue with affiliates"; +"BotAffiliateProgramFeature2" = "Telegram will feature your program for millions of potential affiliates."; +"BotAffiliateProgramFeature2Title" = "Launch your affiliate program"; +"BotAffiliateProgramFeature3" = "Affiliates will share your referral link with their audience."; +"BotAffiliateProgramFeature3Title" = "Let affiliates promote you"; +"BotAffiliateProgramRowText" = "Distribute links to mini apps and earn a share of their revenue in Stars."; +"BotAffiliateProgramRowTitle" = "Earn Stars"; +"BotAffiliateProgramText" = "Reward those who help grow your user base."; +"BotAffiliateProgramTitle" = "Affiliate Program"; +"BotAllowBiometryMessage" = "Do you want to allow **%s** to use biometry?"; +"BotAllowBiometryTitle" = "Allow biometry"; +"BotAlreadyAddedToAttachMenu" = "This bot is already added to your attachment menu."; +"BotAttachMenuShortcatAddedAttach" = "**%s** shortcut added in attachment menu."; +"BotAttachMenuShortcatAddedAttachAndSide" = "**%s** shortcuts added in attachment menu and side menu."; +"BotAttachMenuShortcatAddedSide" = "**%s** shortcut added in side menu."; +"BotAuthAllowMessages" = "Allow Messages"; +"BotAuthAllowMessagesInfo" = "This will allow %s to message you."; +"BotAuthAppSubtitle" = "This app will receive your **name**,\n**username** and **profile photo**."; +"BotAuthBasedOnIP" = "Based on the IP (%s)"; +"BotAuthBotSubtitle" = "This bot will receive your **name**,\n**username** and **profile photo**."; +"BotAuthInfo" = "This login attempt came from the device above."; +"BotAuthLoggedInFail" = "Please try logging in to **%s** again."; +"BotAuthLoggedInFailNoDomain" = "Link has expired."; +"BotAuthLoggedInFailTitle" = "Login Failed"; +"BotAuthLoggedInSuccess" = "You've successfully logged into **%s**."; +"BotAuthLoggedInSuccessTitle" = "Login successful"; +"BotAuthLoggedInSuccessWithoutPhoneNumber" = "You're now logged in to **%s**, but you didn't grant access to your phone number."; +"BotAuthLogin" = "Log In"; +"BotAuthLoginRequestFrom" = "Login request from **%s**"; +"BotAuthNotification" = "Login request for %1$s from %2$s"; +"BotAuthNotificationTitle" = "Login Request"; +"BotAuthPhoneNumber" = "Phone Number"; +"BotAuthPhoneNumberAccept" = "Allow"; +"BotAuthPhoneNumberDeny" = "Deny"; +"BotAuthPhoneNumberText" = "**%1$s** wants to access your phone number **%2$s**.\n\nAllow access?"; +"BotAuthSelectCode" = "Tap the code shown\non your login screen"; +"BotAuthSelectEmoji" = "Tap the emoji shown\non your login screen"; +"BotAuthSiteSubtitle" = "This site will receive your **name**,\n**username** and **profile photo**."; +"BotAuthTitle" = "Log in to **%s**"; +"BotBalance" = "Balance"; +"BotBalanceStars" = "Stars"; +"BotBalanceTON" = "Gram"; +"BotCantAddToAttachMenu" = "This bot can't be added to the attachment menu."; +"BotCantJoinGroups" = "This bot can't join groups."; +"BotCantOpenAttachMenuBot" = "This bot can't be opened in chats with bots."; +"BotCantOpenAttachMenuChannel" = "This bot can't be opened in channels."; +"BotCantOpenAttachMenuGroup" = "This bot can't be opened in groups."; +"BotCantOpenAttachMenuSameBot" = "This bot can't be opened in the chat with itself."; +"BotCantOpenAttachMenuUser" = "This bot can't be opened in chats with users."; +"BotCantReadChatTooltip" = "This bot can't read the chat — only the message where it was mentioned."; +"BotChangeSettings" = "Change Bot Settings"; +"BotChooseChatToVerify" = "Choose Chat To Verify"; +"BotCloseAllTabs" = "Close All Tabs"; +"BotDefaultPrivacyPolicy" = "https://telegram.org/privacy-tpa"; +"BotDownloadFileDownload" = "Download"; +"BotDownloadFileText" = "**%s** offers you to download the following file:"; +"BotDownloadFileTitle" = "Download Document"; +"BotDownloads" = "Downloads"; +"BotEditCommands" = "Edit Commands"; +"BotEditIntro" = "Edit Intro"; +"BotEmojiStatusConfirm" = "Confirm"; +"BotEmojiStatusPermissionAllow" = "Allow"; +"BotEmojiStatusPermissionDecline" = "Decline"; +"BotEmojiStatusPermissionRequest" = "**%1$s** requests access to your **emoji status**. You can revoke this access anytime on the profile page of **%2$s**."; +"BotEmojiStatusPermissionRequestGranted" = "**%s** can now set your emoji status at any time."; +"BotEmojiStatusSetRequest" = "Do you want to set this emoji status suggested by **%1$s**?"; +"BotEmojiStatusSetRequestFor" = "Do you want to set this emoji status suggested by **%1$s** for **%2$s**?"; +"BotEmojiStatusSetRequestForDay#one" = "%d day"; +"BotEmojiStatusSetRequestForDay#other" = "%d days"; +"BotEmojiStatusSetRequestForHour#one" = "%d hour"; +"BotEmojiStatusSetRequestForHour#other" = "%d hours"; +"BotEmojiStatusSetRequestForMinute#one" = "%d minute"; +"BotEmojiStatusSetRequestForMinute#other" = "%d minutes"; +"BotEmojiStatusUpdated" = "Emoji status updated."; +"BotFileDownloadCancel" = "Cancel"; +"BotFileDownloadOpen" = "Open"; +"BotFileDownloaded" = "File saved to Downloads"; +"BotFileDownloading" = "Downloading..."; +"BotForumAskForStartNewChat" = "Type any message to create a new thread."; +"BotForumAskForStartNewChatForward" = "Forward here to create a new thread."; +"BotForumAskForStartNewChatTitle" = "New Thread"; +"BotForumAskForStartOffNewChatForward" = "Forward to main chat"; +"BotForumAskForStartOffNewChatTitle" = "Off-topic"; +"BotForumContinueChat" = "Continue last thread"; +"BotForumNewTopic" = "New Chat"; +"BotFullscreenBack" = "Back"; +"BotFullscreenClose" = "Close"; +"BotHelp" = "Help"; +"BotInfoTitle" = "What can this bot do?"; +"BotInvite" = "Add to Group"; +"BotLocationPermissionAllow" = "Allow"; +"BotLocationPermissionDecline" = "Decline"; +"BotLocationPermissionRequest" = "**%1$s** requests access to your location. You can revoke this access anytime on the profile page of **%2$s**."; +"BotLocationPermissionRequestDeniedApp" = "**%s** can no longer access your location since you restricted Telegram from accessing it."; +"BotLocationPermissionRequestDeniedAppSettings" = "Change in Settings \\>"; +"BotLocationPermissionRequestGranted" = "**%s** can now access your location"; +"BotLocationPermissionSettings" = "Settings"; +"BotManageInfo" = "Use @BotFather to manage this bot."; +"BotMessagePreview" = "Message Preview"; +"BotMonetizationBalance" = "Available balance"; +"BotMonetizationBalanceInfo" = "You can collect your reward using Fragment, a third-party platform used by advertisers to pay for ads. **Learn more >**"; +"BotMonetizationBalanceInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"BotMonetizationBalanceInfoNotAvailable" = "In the coming weeks you will be able to collect your reward using Fragment, a third-party platform used by advertisers to pay ads. **Learn more >**"; +"BotMonetizationGraphImpressions" = "Ad impressions"; +"BotMonetizationGraphRevenue" = "Ad revenue"; +"BotMonetizationInfo" = "Telegram shares %1$d%% of the revenue from ads displayed in your bot as rewards. **Learn more >**"; +"BotMonetizationInfoFeature1Name" = "Telegram Ads"; +"BotMonetizationInfoFeature1Text" = "Telegram can display ads in your bot."; +"BotMonetizationInfoFeature2Name" = "50:50 revenue split"; +"BotMonetizationInfoFeature2Text" = "You can receive 50% of the ad revenue as rewards in Grams."; +"BotMonetizationInfoFeature3Name" = "Flexible withdrawals"; +"BotMonetizationInfoFeature3Text" = "You can withdraw your Grams any time."; +"BotMonetizationInfoTONLink" = "https://telegram.org/privacy"; +"BotMonetizationInfoTONText" = "**Gram** is the cryptocurrency of the TON blockchain platform, which Telegram uses for its high speed and low commissions on transactions."; +"BotMonetizationInfoTONTitle" = "What is 💎 Gram?"; +"BotMonetizationInfoTitle" = "Earn From Your Bot"; +"BotMonetizationOverview" = "Proceeds overview"; +"BotMonetizationOverviewAvailable" = "Balance available to withdraw"; +"BotMonetizationOverviewLastWithdrawal" = "Proceeds since last withdrawal"; +"BotMonetizationOverviewTotal" = "Total lifetime proceeds"; +"BotMonetizationProceedsTONInfo" = "Grams from your total balance become available for spending on ads and rewards 3 days after they are earned."; +"BotMonetizationProceedsTONInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"BotMonetizationTransactions" = "Transactions"; +"BotMoreTabs#one" = "%2$s & %1$d Other"; +"BotMoreTabs#other" = "%2$s & %1$d Others"; +"BotMultiContactsSelectorLimit#one" = "You can select maximum %1$d user."; +"BotMultiContactsSelectorLimit#other" = "You can select maximum %1$d users."; +"BotName" = "Bot Name"; +"BotOpen" = "Open"; +"BotOpenPageMessage" = "**%1$s** would like to open its web app to proceed.\n\nIt will be able to access your **IP address** and basic device info."; +"BotOpenPageTitle" = "Open page"; +"BotOwnershipTransfer" = "2-Step Verification"; +"BotOwnershipTransferAlertText" = "You can complete this action only if you have:"; +"BotOwnershipTransferChangeOwner" = "Confirm"; +"BotOwnershipTransferReadyAlertText" = "Please enter your password to confirm this action."; +"BotPermissionGameAlert" = "Allow %1$s to pass your Telegram name and id (not your phone number) to pages you open with this bot?"; +"BotPreviewEmpty" = "no images yet"; +"BotPreviewsDeleted#one" = "%d preview deleted"; +"BotPreviewsDeleted#other" = "%d previews deleted"; +"BotPrivacyPolicy" = "Privacy Policy"; +"BotProfilePermissionBiometry" = "Biometrics"; +"BotProfilePermissionEmojiStatus" = "Emoji Status"; +"BotProfilePermissionLocation" = "Geolocation"; +"BotProfilePermissions" = "Allow access to"; +"BotPublicLink" = "Public Link"; +"BotPublicLinks" = "Public Links"; +"BotPublicLinksCount" = "%1$s/%2$s"; +"BotRemoveFromMenu" = "This will remove **%1$s** shortcuts from all menus."; +"BotRemoveFromMenuTitle" = "Remove bot"; +"BotRemoveInlineFromMenu" = "Remove **%1$s** from suggestions?"; +"BotRemoveVerificationChatText" = "This chat is already verified by you.\nDo you want to revoke verification?"; +"BotRemoveVerificationText" = "This account is already verified by you.\nDo you want to revoke verification?"; +"BotRemoveVerificationTitle" = "Remove verification"; +"BotRequestAttachPermission" = "**%1$s** requests to be added as an option to your attachment menu so you can access it from any chat."; +"BotRestart" = "Restart Bot"; +"BotRestoreStorageButton" = "Import"; +"BotRestoreStorageCreatedAt" = "created on %s"; +"BotRestoreStorageHeader" = "Account to import data from"; +"BotRestoreStorageText" = "**%s** is requesting permission to import data from a previous Telegram account used on device."; +"BotRestoreStorageTitle" = "Import Data"; +"BotRestrictionsCanDo" = "What can this bot do?"; +"BotSentRevokeVerifyRequest" = "**%s**'s verification has been revoked."; +"BotSentVerifyRequest" = "**%s** has been notified and will receive your verification mark and description upon accepting."; +"BotSetAttachLinkNotBot" = "This link does not lead to a bot."; +"BotSetPublicLinkHeader" = "Set public link"; +"BotSettings" = "Settings"; +"BotSettingsChangedAlert" = "You have changed some settings for this bot. Apply changes?"; +"BotShare" = "Share"; +"BotShareMessage" = "Share Message"; +"BotShareMessageInfo" = "%s offers you to send this message to a chat you select."; +"BotShareMessagePreview" = "Message preview"; +"BotShareMessageShare" = "Share with..."; +"BotShareToTopic" = "Share to…"; +"BotSharedToMany#one" = "Sent message to %d chat"; +"BotSharedToMany#other" = "Sent message to %d chats"; +"BotSharedToOne" = "Sent message to **%s**"; +"BotStarsAvailableBalance" = "Available balance"; +"BotStarsButtonWithdraw#one" = "Withdraw ⭐️%d via Fragment"; +"BotStarsButtonWithdraw#other" = "Withdraw ⭐️%d via Fragment"; +"BotStarsButtonWithdrawAll" = "Withdraw all via Fragment"; +"BotStarsButtonWithdrawShort#one" = "Withdraw ⭐️%d"; +"BotStarsButtonWithdrawShort#other" = "Withdraw ⭐️%d"; +"BotStarsButtonWithdrawShortAll" = "Withdraw all"; +"BotStarsButtonWithdrawShortUntil" = "Withdraw"; +"BotStarsButtonWithdrawUntil" = "Withdraw via Fragment"; +"BotStarsChartRevenue" = "Stars received"; +"BotStarsOverview" = "Proceeds overview"; +"BotStarsOverviewAvailableBalance" = "Available balance"; +"BotStarsOverviewInfo" = "Stars from your total balance can be used for ads or withdrawn as rewards 21 days after they are earned."; +"BotStarsOverviewTotalBalance" = "Total balance"; +"BotStarsOverviewTotalProceeds" = "Total lifetime proceeds"; +"BotStarsWithdrawInfo" = "You can collect rewards for Stars using Fragment, or use Stars to advertise your bot. You cannot withdraw less than 1000 stars. **Learn more >**"; +"BotStarsWithdrawInfoLink" = "https://telegram.org/privacy"; +"BotStarsWithdrawMinLimit#one" = "You cannot withdraw less than **%d star**."; +"BotStarsWithdrawMinLimit#other" = "You cannot withdraw less than **%d stars**."; +"BotStarsWithdrawPlaceholder" = "Enter amount to withdraw"; +"BotStarsWithdrawalToast" = "Next withdrawal will be available in **%s**."; +"BotStart" = "START"; +"BotStart2" = "Start Bot"; +"BotStartAppPermission" = "Do you want to start app **%1$s** from bot **%2$s**?\n\nIt will be able to access your **IP address** and basic device info."; +"BotStartButton" = "Start bot"; +"BotStatsStars" = "Stars"; +"BotStatsTON" = "Gram"; +"BotStatusCantRead" = "has no access to messages"; +"BotStatusRead" = "has access to messages"; +"BotStop" = "Block Bot"; +"BotUnblock" = "RESTART"; +"BotUnblockNoCaps" = "Restart"; +"BotUsernameHelp" = "This link cannot be edited. You can acquire additional usernames on *Fragment*."; +"BotUsernamesHelp" = "Drag and drop links to change the order in which they will be displayed on the bot info page."; +"BotUsers#one" = "%d monthly user"; +"BotUsers#other" = "%d monthly users"; +"BotUsersShort#one" = "%d user"; +"BotUsersShort#other" = "%d users"; +"BotVerifierRepresentatives" = "This community is verified as official by the representatives of %s."; +"BotVerifyAccounts" = "Verify Accounts"; +"BotVerifyBotTitle" = "Verify Bot"; +"BotVerifyChannelTitle" = "Verify Channel"; +"BotVerifyDescription" = "Description"; +"BotVerifyDescriptionInfo" = "You can customize your description for each account."; +"BotVerifyDescriptionInfoChat" = "You can customize your description for each chat."; +"BotVerifyGroupTitle" = "Verify Group"; +"BotVerifyText" = "Do you want to verify **%s** with your verification mark and description?"; +"BotVerifyUserTitle" = "Verify User"; +"BotWebAppDisclaimerCheck" = "I agree to the **Terms of Use**"; +"BotWebAppDisclaimerSubtitle" = "You are about to use a mini app operated by an independent party **not affiliated with Telegram**. You must agree to the Terms of Use for mini apps to continue."; +"BotWebAppInstantViewOpen" = "Launch"; +"BotWebViewChangesMayNotBeSaved" = "Changes that you made may not be saved."; +"BotWebViewCloseAnyway" = "Close anyway"; +"BotWebViewDeleteBot" = "Remove from Menu"; +"BotWebViewFileChooserTitle" = "Choose a file"; +"BotWebViewNotAvailablePlaceholder" = "WebView not available. Please update it to use WebView bots."; +"BotWebViewOpenBot" = "Open Bot"; +"BotWebViewReloadPage" = "Reload Page"; +"BotWebViewRequestAllow" = "Allow"; +"BotWebViewRequestCameraMicPermission" = "Allow **%1$s** to access to your camera and microphone?\n\nThe developer of **%1$s** will be able to access your camera and microphone when this web app is open."; +"BotWebViewRequestCameraMicPermissionWithHint" = "Allow **%1$s** to access to your camera and microphone?\n\nThe developer of **%1$s** will be able to access your camera and microphone when this web app is open.\n\nEnable camera access in Settings > Permissions > Camera."; +"BotWebViewRequestCameraPermission" = "Allow **%1$s** to access your camera?\n\nThe developer of **%1$s** will be able to access your camera when this web app is open."; +"BotWebViewRequestCameraPermissionWithHint" = "Allow **%1$s** to access your camera?\n\nThe developer of **%1$s** will be able to access your camera when this web app is open.\n\nEnable camera access in Settings > Permissions > Camera."; +"BotWebViewRequestDontAllow" = "Don't Allow"; +"BotWebViewRequestGeolocationPermission" = "Allow **%1$s** to access your location?\n\nThe developer of **%1$s** will be able to access your location when this web app is open."; +"BotWebViewRequestGeolocationPermissionWithHint" = "Allow **%1$s** to access your location?\n\nThe developer of **%1$s** will be able to access your location when this web app is open.\n\nGo to Settings > Permissions and turn **Location** on to share location data."; +"BotWebViewRequestMicrophonePermission" = "Allow **%1$s** to access your microphone?\n\nThe developer of **%1$s** will be able to access your microphone when this web app is open."; +"BotWebViewRequestMicrophonePermissionWithHint" = "Allow **%1$s** to access to your microphone?\n\nThe developer of **%1$s** will be able to access your microphone when this web app is open.\n\nEnable microphone access in Settings > Permissions > Microphone."; +"BotWebViewRequestWriteMessage" = "Allow this bot to send you messages?"; +"BotWebViewRequestWriteTitle" = "Allow messaging"; +"BotWebViewSettings" = "Settings"; +"BotWebViewStartPermission" = "To launch this web app, you will connect to its website."; +"BotWebViewStartPermission2" = "By launching this mini app, you agree to the **Terms of Service for Mini Apps**."; +"BotWebViewStartPermissionLink" = "https://telegram.org/tos/mini-apps"; +"BotWebViewToS" = "Terms of Use"; +"BotWebViewToSLink" = "https://telegram.org/tos/mini-apps"; +"Bots#one" = "%1$d bot"; +"Bots#other" = "%1$d bots"; +"BotsMenuTitle" = "Menu"; +"BoughtResoldGiftText" = "%s is now yours."; +"BoughtResoldGiftTitle" = "Gift acquired"; +"BoughtResoldGiftToText" = "%s has been notified about your gift."; +"BoughtResoldGiftToTitle" = "Gift sent"; +"BroadcastGroup" = "Broadcast Group"; +"BroadcastGroupConvert" = "Convert to Broadcast Group"; +"BroadcastGroupConvertInfo" = "Broadcast groups can have over 200,000 members, but only admins can send messages in them."; +"BroadcastGroupConvertSuccess" = "Your group can now have more than 200,000 members."; +"BroadcastGroupInfo" = "Only admins can send messages in this group."; +"Brown" = "Brown"; +"BrowseThemes" = "Browse Themes"; +"BrowserAlwaysOpenExternal" = "Always open this site in Browser"; +"BrowserAlwaysOpenInApp" = "Always open this site in-app"; +"BrowserExternalCheck" = "Always open links from **%s** in browser"; +"BrowserExternalRestricted" = "Telegram will now open links from this website in the external browser. You can change this in **Settings**."; +"BrowserExternalText" = "Do you want to open this link in your default browser?"; +"BrowserExternalTitle" = "Open in Browser"; +"BrowserSettingsAddHint" = "Enter URL"; +"BrowserSettingsAddText" = "Enter a domain that you don’t want to be opened in the in-app browser."; +"BrowserSettingsAddTextExternal" = "Enter a domain that you want to be opened in the in-app browser."; +"BrowserSettingsAddTitle" = "Add Website"; +"BrowserSettingsAddTitleExternal" = "Add Website"; +"BrowserSettingsAlwaysOpenInInfo2" = "These sites will still be opened in-app."; +"BrowserSettingsAlwaysOpenInTitle2" = "Open in-app"; +"BrowserSettingsCacheClear" = "Clear Cache"; +"BrowserSettingsCacheClearText" = "Are you sure you want to clear browser cache%s?"; +"BrowserSettingsCookiesClear" = "Clear Cookies"; +"BrowserSettingsCookiesClearText" = "Are you sure you want to clear browser cookies%s?\n\nThis may log you out from some websites."; +"BrowserSettingsCookiesInfo" = "Delete all cookies and cache from the Telegram in-app browser and mini apps."; +"BrowserSettingsCustomTabs" = "New Pages within Telegram"; +"BrowserSettingsCustomTabsTitle" = "Open external links in"; +"BrowserSettingsEnable" = "Enable In-App Browser"; +"BrowserSettingsEnableInfo" = "Open links inside Telegram without using an external browser app."; +"BrowserSettingsHistoryClear" = "Clear History"; +"BrowserSettingsHistoryClearText" = "Are you sure you want to clear all browser history after %s?"; +"BrowserSettingsHistoryPages#one" = "%d page"; +"BrowserSettingsHistoryPages#other" = "%d pages"; +"BrowserSettingsHistoryShow" = "View History"; +"BrowserSettingsNeverOpenInAdd" = "Add Website"; +"BrowserSettingsNeverOpenInClearList" = "Clear List"; +"BrowserSettingsNeverOpenInClearList2" = "Delete All Exceptions"; +"BrowserSettingsNeverOpenInInfo" = "Telegram will always open links from these websites in an external browser app."; +"BrowserSettingsNeverOpenInInfo2" = "These sites will always be opened in external browser."; +"BrowserSettingsNeverOpenInRemove" = "Remove"; +"BrowserSettingsNeverOpenInTitle" = "Never open in the in-app browser"; +"BrowserSettingsNeverOpenInTitle2" = "Don’t open in-app"; +"BrowserSettingsNoCustomTabs" = "New Tabs in External Browser"; +"BrowserSettingsNoCustomTabsInfo" = "You can choose to open external links without leaving Telegram."; +"BrowserSettingsSearchEngine" = "Search Engine"; +"BrowserSettingsSearchEngineInfo" = "Choose a service for auto-complete and search results in the address bar."; +"BrowserSettingsTitle" = "Web Browser"; +"BubbleRadius" = "Message corners"; +"BuildMyOwnTheme" = "Build my own theme"; +"BuiltInThemes" = "Built-in themes"; +"BusinessAway" = "Away Message"; +"BusinessAwayCreate" = "Create an Away Message"; +"BusinessAwayEnter" = "Add Away Message"; +"BusinessAwayInfo" = "Automatically reply with a message when you are away."; +"BusinessAwayIntro" = "Add messages that will be automatically sent when you are off."; +"BusinessAwayIntroTitle" = "New Away Message"; +"BusinessAwayOnlyOffline" = "Only if Offline"; +"BusinessAwayOnlyOfflineInfo" = "Don’t send the away message if you’ve recently been online."; +"BusinessAwaySchedule" = "Schedule"; +"BusinessAwayScheduleAlways" = "Send Always"; +"BusinessAwayScheduleCustom" = "Custom Schedule"; +"BusinessAwayScheduleCustomEnd" = "End Time"; +"BusinessAwayScheduleCustomEndTitle" = "Choose end time"; +"BusinessAwayScheduleCustomSetButton" = "Select"; +"BusinessAwayScheduleCustomStart" = "Start Time"; +"BusinessAwayScheduleCustomStartTitle" = "Choose start time"; +"BusinessAwayScheduleOutsideHours" = "Outside of Business Hours"; +"BusinessAwaySend" = "Send Away Message"; +"BusinessAwayUnsavedChanges" = "You have changed the business away message settings. Apply changes?"; +"BusinessBotChats" = "Chats the bot can access"; +"BusinessBotChats2" = "Chats the bot can access"; +"BusinessBotChatsInfo" = "Select chats or entire chat categories which the bot will have access to."; +"BusinessBotChatsInfo2" = "Select the chats the bot won't access."; +"BusinessBotDone" = "%s now manages your account."; +"BusinessBotLink" = "Bot Username or Link"; +"BusinessBotLinkInfo" = "Choose a bot to manage your chats automatically."; +"BusinessBotLinkInfo2" = "Choose a bot to manage your chats automatically."; +"BusinessBotNoAddedButton" = "Leave"; +"BusinessBotNoAddedText" = "You haven’t added a bot to manage your account. Leave anyway?"; +"BusinessBotNoAddedTitle" = "No Bot Added"; +"BusinessBotNotFound" = "Chatbot not found"; +"BusinessBotNotSupportedMessage" = "This bot doesn’t support **Telegram Business** yet."; +"BusinessBotNotSupportedTitle" = "Oops"; +"BusinessBotPermissions" = "Bot permissions"; +"BusinessBotPermissionsGiftsSection" = "Manage Gifts and Stars"; +"BusinessBotPermissionsGiftsSell" = "Convert Gifts to Stars"; +"BusinessBotPermissionsGiftsSettings" = "Change Gift Settings"; +"BusinessBotPermissionsGiftsTransfer" = "Transfer and Upgrade Gifts"; +"BusinessBotPermissionsGiftsTransferStars" = "Transfer Stars"; +"BusinessBotPermissionsGiftsView" = "View Gifts and Stars"; +"BusinessBotPermissionsGiftsWarningText" = "The bot **@%s** will be able to manage your gifts and stars, including giving them away to other users."; +"BusinessBotPermissionsInfo" = "The bot can only reply on your behalf in chats that were active during the last 24h."; +"BusinessBotPermissionsMessagesDeleteReceived" = "Delete Received Messages"; +"BusinessBotPermissionsMessagesDeleteSent" = "Delete Sent Messages"; +"BusinessBotPermissionsMessagesMarkAsRead" = "Mark Messages As Read"; +"BusinessBotPermissionsMessagesRead" = "Read Messages"; +"BusinessBotPermissionsMessagesReply" = "Reply to Messages"; +"BusinessBotPermissionsMessagesSection" = "Manage Messages"; +"BusinessBotPermissionsProfileBio" = "Edit Bio"; +"BusinessBotPermissionsProfileName" = "Edit Name"; +"BusinessBotPermissionsProfilePicture" = "Edit Profile Photo"; +"BusinessBotPermissionsProfileSection" = "Manage Profile"; +"BusinessBotPermissionsProfileUsername" = "Edit Username"; +"BusinessBotPermissionsReply" = "Reply to Messages"; +"BusinessBotPermissionsStories" = "Manage Stories"; +"BusinessBotPermissionsUsernamesWarningText" = "The bot **@%s** will be able to manage your usernames, including transferring them away to other users."; +"BusinessBotPermissionsWarning" = "Warning"; +"BusinessBotRemove" = "Remove Bot"; +"BusinessBotUnsavedChanges" = "You have changed the business bot settings. Apply changes?"; +"BusinessBotUpdate" = "Update"; +"BusinessBotUpdated" = "Updated %s’s settings."; +"BusinessBots" = "Chat Automation"; +"BusinessBots2" = "Chat Automation"; +"BusinessBots2Info" = "Add a bot to answer messages on your behalf."; +"BusinessBotsInfo" = "Add a bot to reply to messages on your behalf."; +"BusinessBotsInfoLink" = "https://telegram.org/blog/telegram-business#chatbots-for-business"; +"BusinessChatsAllPrivateExcept" = "All Private Chats Except..."; +"BusinessChatsAllPrivateExcept2" = "All private chats except…"; +"BusinessChatsExcluded" = "Excluded chats"; +"BusinessChatsExcludedAdd" = "Exclude chats"; +"BusinessChatsExcludedAdd2" = "Add"; +"BusinessChatsIncluded" = "Included chats"; +"BusinessChatsIncludedAdd" = "Select chats"; +"BusinessChatsIncludedAdd2" = "Add"; +"BusinessChatsOnlySelected" = "Only Selected Chats"; +"BusinessChatsOnlySelected2" = "Only selected chats"; +"BusinessGreet" = "Greeting Message"; +"BusinessGreetCreate" = "Create a Greeting Message"; +"BusinessGreetInfo" = "Greet customers when they message you the first time or after a period of no activity."; +"BusinessGreetPeriod" = "Period of no activity"; +"BusinessGreetPeriodInfo" = "Choose how many days should pass after your last interaction with a recipient to send them a greeting in response to their message."; +"BusinessGreetRecipientsInfo" = "Select chats or entire chat categories for sending greeting messages."; +"BusinessGreetSend" = "Send Greeting Message"; +"BusinessGreetUnsavedChanges" = "You have changed the business greeting message settings. Apply changes?"; +"BusinessGreetingEnter" = "Add a Greeting"; +"BusinessGreetingIntro" = "Create greetings that will be automatically sent to new customers."; +"BusinessGreetingIntroTitle" = "New Greeting Message"; +"BusinessHours" = "Business Hours"; +"BusinessHoursClearMessage" = "Are you sure you want to remove business opening hours?"; +"BusinessHoursClearTitle" = "Remove Hours"; +"BusinessHoursCopied" = "Business hours copied"; +"BusinessHoursCopyFooter" = "In %s timezone"; +"BusinessHoursCopyHeader" = "%s's opening hours:"; +"BusinessHoursDayAdd" = "Add a Set of Hours"; +"BusinessHoursDayCloseHour" = "Closing Time"; +"BusinessHoursDayCloseHourPicker" = "Choose closing time"; +"BusinessHoursDayClosed" = "Closed"; +"BusinessHoursDayFullOpened" = "Open 24 hours"; +"BusinessHoursDayInfo" = "Specify your working hours during the day."; +"BusinessHoursDayOpen" = "Open On This Day"; +"BusinessHoursDayOpenHour" = "Opening Time"; +"BusinessHoursDayOpenHourPicker" = "Choose opening time"; +"BusinessHoursInfo" = "Turn this on to show your opening hours schedule to your customers."; +"BusinessHoursNextDay" = "%s (Next day)"; +"BusinessHoursNextDayPicker" = "Next day, %s"; +"BusinessHoursProfile" = "Business hours"; +"BusinessHoursProfileClose" = "closed"; +"BusinessHoursProfileFullOpen" = "open 24/7"; +"BusinessHoursProfileInYourTime" = "%s in your time"; +"BusinessHoursProfileNowClosed" = "Closed"; +"BusinessHoursProfileNowOpen" = "Open"; +"BusinessHoursProfileOpen" = "open 24 hours"; +"BusinessHoursProfileOpensInDays#one" = "opens tomorrow"; +"BusinessHoursProfileOpensInDays#other" = "opens in %d days"; +"BusinessHoursProfileOpensInHours#one" = "opens in %d hour"; +"BusinessHoursProfileOpensInHours#other" = "opens in %d hours"; +"BusinessHoursProfileOpensInMinutes#one" = "opens in %d minute"; +"BusinessHoursProfileOpensInMinutes#other" = "opens in %d minutes"; +"BusinessHoursProfileSwitchLocal" = "local time"; +"BusinessHoursProfileSwitchMy" = "my time"; +"BusinessHoursProfileTheirTime" = "it's now %s there"; +"BusinessHoursShow" = "Show Business Hours"; +"BusinessHoursTimezone" = "Time Zone"; +"BusinessHoursTimezonePicker" = "Choose time zone"; +"BusinessHoursUnsavedChanges" = "You have changed the business working hours. Apply changes?"; +"BusinessInReplies" = "in %1$s"; +"BusinessIntro" = "Start Page"; +"BusinessIntroHeader" = "Customize your start page"; +"BusinessIntroInfo" = "You can customize the message people see before they start a chat with you."; +"BusinessIntroMessageHint" = "Enter Message"; +"BusinessIntroReset" = "Reset to Default"; +"BusinessIntroSticker" = "Choose Sticker"; +"BusinessIntroStickerRandom" = "Random"; +"BusinessIntroTitleHint" = "Enter Title"; +"BusinessIntroUnsavedChanges" = "You have changed your start page. Apply changes?"; +"BusinessLink" = "Link to Chat"; +"BusinessLinkDeleted" = "Chat link deleted."; +"BusinessLinkDiscardChangesMessage" = "You have unsaved changes to preset message, do you want to discard them?"; +"BusinessLinkDiscardChangesTitle" = "Discard changes"; +"BusinessLinkInvalid" = "Invalid link to chat"; +"BusinessLinkSaved" = "Preset message saved."; +"BusinessLinkShareTitle" = "Share link to chat"; +"BusinessLinks" = "Links to Chat"; +"BusinessLinksAdd" = "Create a Link to Chat"; +"BusinessLinksDeleteMessage" = "Are you sure you want to delete this chat link?"; +"BusinessLinksDeleteTitle" = "Delete chat link"; +"BusinessLinksEnter" = "Add Preset Message"; +"BusinessLinksFooterOneLink" = "You can also use a simple link for a chat with you — %1$s."; +"BusinessLinksFooterTwoLinks" = "You can also use a simple link for a chat with you — %1$s or %2$s."; +"BusinessLinksInfo" = "Give your customers short links that start a chat with you — and suggest the first message from them to you."; +"BusinessLinksIntro" = "Add a message that will be entered in the message field for anyone who starts a chat with you using the link:"; +"BusinessLinksNamePlaceholder" = "Link Name"; +"BusinessLinksRenameMessage" = "Add a name for this link that only you will see."; +"BusinessLinksRenameTitle" = "Link Name"; +"BusinessLocation" = "Location"; +"BusinessLocationAddress" = "Enter Address"; +"BusinessLocationClear" = "Remove Location"; +"BusinessLocationClearMessage" = "Are you sure you want to remove business location?"; +"BusinessLocationClearTitle" = "Remove Location"; +"BusinessLocationCopied" = "Business address copied"; +"BusinessLocationInfo" = "Display the location of your business on your account."; +"BusinessLocationMap" = "Set Location on Map"; +"BusinessLocationUnsavedChanges" = "You have changed the business location. Apply changes?"; +"BusinessProfileLocation" = "Location"; +"BusinessRecipients" = "Recipients"; +"BusinessRecipientsRemoveExcludeMessage" = "Are you sure you want to remove \"%s\" from excluding list?"; +"BusinessRecipientsRemoveExcludeTitle" = "Remove recipient"; +"BusinessRecipientsRemoveIncludeMessage" = "Are you sure you want to remove \"%s\" from including list?"; +"BusinessRecipientsRemoveIncludeTitle" = "Remove recipient"; +"BusinessReplies" = "Quick Replies"; +"BusinessRepliesAdd" = "Add Quick Reply"; +"BusinessRepliesAddInfo" = "Tap and hold on a reply to select or reorder."; +"BusinessRepliesDeleteMessage#one" = "Are you sure you want to remove this quick reply?"; +"BusinessRepliesDeleteMessage#other" = "Are you sure you want to remove %d quick replies?"; +"BusinessRepliesDeleteTitle#one" = "Remove quick reply"; +"BusinessRepliesDeleteTitle#other" = "Remove quick replies"; +"BusinessRepliesEditMessage" = "Change the name of your shortcut."; +"BusinessRepliesEditTitle" = "Edit Shortcut"; +"BusinessRepliesEnter" = "Add Quick Reply"; +"BusinessRepliesHint" = "To use this quick reply in a chat, type / and select the shortcut from the list."; +"BusinessRepliesInfo" = "Set up shortcuts with rich text and media to respond to messages faster."; +"BusinessRepliesIntro1" = "Enter a message below that will be sent in chat when you type \"/**%s**\"."; +"BusinessRepliesIntro2" = "You can access Quick Replies in any chat by typing \"/\" or using the attachment menu."; +"BusinessRepliesIntroTitle" = "New Quick Reply"; +"BusinessRepliesLimit#other" = "Limit of **%d** messages reached."; +"BusinessRepliesMore#other" = "+%d more"; +"BusinessRepliesNameBusy" = "This shortcut already exists."; +"BusinessRepliesNamePlaceholder" = "Shortcut"; +"BusinessRepliesNameReserved" = "This shortcut name is reserved."; +"BusinessRepliesNewMessage" = "Add a shortcut for your reply."; +"BusinessRepliesNewTitle" = "New Quick Reply"; +"BusinessRepliesRemoveMessage" = "You didn't create a quick reply message. Do you want to exit this page and remove the shortcut?"; +"BusinessRepliesRemoveTitle" = "Remove shortcut"; +"BusinessRepliesSearch" = "Search replies"; +"BusinessRepliesToastLimit#other" = "Limit of **%d** messages reached."; +"BuyUsernameOnFragment" = "Buy on Fragment"; +"BytesReceived" = "Data received"; +"BytesSent" = "Data sent"; +"CacheChannels" = "Channels"; +"CacheClear" = "Clear"; +"CacheEmpty" = "Empty"; +"CacheOpenFile" = "Open File"; +"CacheOtherChats" = "Other Chats"; +"CacheStories" = "Stories"; +"CacheWasCleared" = "%s freed on your device!"; +"CachedStory" = "cached story"; +"CalculatingSize" = "Calculating..."; +"Calendar" = "Calendar"; +"CalendarWeekNameShortFriday" = "F"; +"CalendarWeekNameShortMonday" = "M"; +"CalendarWeekNameShortSaturday" = "S"; +"CalendarWeekNameShortSunday" = "S"; +"CalendarWeekNameShortThursday" = "T"; +"CalendarWeekNameShortTuesday" = "T"; +"CalendarWeekNameShortWednesday" = "W"; +"Call" = "Call"; +"CallAgain" = "Call Again"; +"CallAlert" = "Are you sure you want to call **%1$s**?"; +"CallAlertTitle" = "Voice Call"; +"CallAvailableIn" = "You can request a voice call in %1$d:%2$02d"; +"CallAvailableIn2" = "Telegram can call you in %1$d:%2$02d"; +"CallBack" = "Call Back"; +"CallEmojiKeyTooltip" = "If the emoji on %1$s’s screen are the same, this call is 100%% secure."; +"CallForbiddenInviteLinkButton" = "Send Invite Link"; +"CallForbiddenInviteLinkText" = "**%s** restricts who can call them. Send them an invite link to a call instead."; +"CallForbiddenInviteLinkTitle" = "Invite via Link"; +"CallInviteMembersButton" = "Call"; +"CallInviteViaLink" = "Add to Call"; +"CallInviteViaLinkTitle" = "Invite via Link"; +"CallMessageIncoming" = "Incoming Call"; +"CallMessageIncomingConferenceMissed" = "Missed Call"; +"CallMessageIncomingDeclined" = "Declined Call"; +"CallMessageIncomingMissed" = "Missed Call"; +"CallMessageOutgoing" = "Outgoing Call"; +"CallMessageOutgoingMissed" = "Canceled Call"; +"CallMessageReportProblem" = "Rate Call"; +"CallMessageVideoIncoming" = "Incoming Video Call"; +"CallMessageVideoIncomingConferenceMissed" = "Missed Video Call"; +"CallMessageVideoIncomingDeclined" = "Declined Video Call"; +"CallMessageVideoIncomingMissed" = "Missed Video Call"; +"CallMessageVideoOutgoing" = "Outgoing Video Call"; +"CallMessageVideoOutgoingMissed" = "Canceled Video Call"; +"CallMessageWithDuration" = "%1$s (%2$s)"; +"CallNotAvailable" = "Sorry, you cannot call %1$s because of their privacy settings. You can ask them to modify their setting or to call you instead."; +"CallReportHint" = "What went wrong?"; +"CallReportIncludeLogs" = "Include technical information"; +"CallReportLogsExplain" = "Doesn't reveal chat contents and helps us fix the issue sooner."; +"CallReportSent" = "Thank you for helping us make Telegram calls better."; +"CallText" = "Telegram will call you in %1$d:%2$02d"; +"CallViaTelegram" = "Telegram Call"; +"CallVideoPin" = "Pin"; +"CallVideoPreviewTitle" = "Preview"; +"CallVideoUnpin" = "Unpin"; +"Calling" = "Calling you..."; +"Calls" = "Calls"; +"CallsDataUsage" = "Calls"; +"CallsTotalTime" = "Total time"; +"CameraPermissionText" = "Tap to allow access to your Camera"; +"CanJoin#one" = "%1$d can join"; +"CanJoin#other" = "%1$d can join"; +"Cancel" = "Cancel"; +"CancelAccountReset" = "Cancel account reset"; +"CancelAccountResetInfo" = "Somebody with access to **%1$s** has requested to delete your Telegram account and reset your 2-Step Verification password.\n\nIf this wasn't you, please enter the code we've just sent you via SMS. You can also cancel this by **changing your phone number**."; +"CancelAccountResetInfo2" = "Somebody with access to **%1$s** has requested to delete your Telegram account and reset your 2-Step Verification password.\n\nIf this wasn't you, please enter the code we've just sent you via SMS. You can also cancel this by *changing your phone number*."; +"CancelEmailQuestion" = "Are you sure you want to abort recovery email setup?"; +"CancelEmailQuestionTitle" = "Abort setup"; +"CancelForward" = "Cancel Forward"; +"CancelForwardChat" = "What would you like to do with %1$s from **%2$s**?"; +"CancelForwardMessage" = "Do you want to cancel forwarding or to forward messages to a different chat?"; +"CancelForwardPrivate" = "What would you like to do with %1$s from your chat with **%2$s**?"; +"CancelForwarding" = "Cancel Forwarding"; +"CancelLinkExpired" = "Link is invalid or expired."; +"CancelLinkSuccess" = "The deletion process was canceled for your account %1$s. You may close this window now."; +"CancelLinkSuccessTitle" = "Success!"; +"CancelPasswordQuestion" = "Are you sure you want to abort Two-Step Verification setup?"; +"CancelPasswordReset" = "Cancel the password reset process? If you request a new reset later, it will take another 7 days."; +"CancelPasswordResetNo" = "NO"; +"CancelPasswordResetYes" = "YES"; +"CancelPollAlertText" = "Are you sure you want to discard this poll?"; +"CancelPollAlertTitle" = "Discard poll?"; +"CancelRegistration" = "Cancel registration"; +"CancelReset" = "Cancel reset"; +"CancelRound" = "Cancel"; +"CancelSending" = "Cancel sending"; +"CancelTodoAlertText" = "Are you sure you want to discard this Checklist?"; +"CancelTodoAlertTitle" = "Discard Checklist"; +"CantAddBotAsAdmin" = "Bots can only be added to channels as admins."; +"CantBoostTooOften" = "Can't boost too often!"; +"CantBoostTooOftenDescription" = "You can only change the channel you boost once a day. You will be able to boost in **%s.**"; +"CantBoostWithGiftedPremium" = "Can't boost with gifted Premium!"; +"CantBoostWithGiftedPremiumDescription" = "Because your **Telegram Premium** subscription was gifted to you, you can't use it to boost channels."; +"CantEditTagAdmins" = "Only admins can assign member tags in this group."; +"CantPlayVideo" = "App was unable to play this video. Try to play it with external player?"; +"Caption" = "Caption"; +"CaptionAbove" = "Move Caption Up"; +"CaptionBelow" = "Move Caption Down"; +"CaptionPremiumSubtitle#other" = "Increase this limit **%1$dx** to **%2$s** characters by subscribing to __Telegram Premium__."; +"CaptionPremiumTitle" = "Maximum Length Reached"; +"CaptionsLimitSubtitle" = "Use longer descriptions for your photos and videos"; +"CaptionsLimitTitle" = "Captions"; +"CardNumberCopied" = "Card number copied to clipboard."; +"Change" = "Change"; +"ChangeChannelNameColor" = "Channel Color"; +"ChangeChannelNameColor2" = "Appearance"; +"ChangeChatBackground" = "Change Chat Wallpaper"; +"ChangeColorToColor" = "Do you want to use your currently selected wallpaper or reset it to the default for this theme?"; +"ChangeColors" = "Change colors"; +"ChangeEmail" = "Change Email"; +"ChangeEmojiStatus" = "Change Status"; +"ChangeGroupAppearance" = "Appearance"; +"ChangeLanguageLater" = "You can change your language later in Settings."; +"ChangePasscode" = "Change Passcode"; +"ChangePasscodeInfo" = "When a passcode is set, a lock icon appears above your chat list. Tap it to lock your Telegram app.\n\nNote: if you forget your passcode, you'll need to log out or reinstall the app. All secret chats will be lost."; +"ChangePasscodeInfoShort" = "When a passcode is set, a lock icon appears above your chat list. Tap it to lock your Telegram app."; +"ChangePassword" = "Change Password"; +"ChangePermissions" = "Change permissions"; +"ChangePhoneHelp" = "Your new number will receive a confirmation code via call or SMS."; +"ChangePhoneNewNumber" = "New Number"; +"ChangePhoneNumber" = "Change Phone Number"; +"ChangePhoneNumberInfo" = "Move your account, chats and media to a new number."; +"ChangePhoneNumberOccupied" = "The number %1$s is already connected to a Telegram account. Please delete that account before using this number."; +"ChangePhoneNumberSuccess" = "Your phone number has been successfully changed."; +"ChangePhoneNumberSuccessWithPhone" = "Your phone number has been successfully changed to %1$s."; +"ChangePublicLimitReached" = "You have reserved too many public links. Try revoking the link from an older group or channel, or create a private one instead."; +"ChangeRecipient" = "Change Recipient"; +"ChangeRecoveryEmail" = "Change Recovery Email"; +"ChangeUserNameColor" = "Change Name Color"; +"ChangeWallpaperToColor" = "Are you sure you want to change your chat wallpaper to a color?"; +"ChannelAddAdmin" = "Add Admin"; +"ChannelAddException" = "Add Exception"; +"ChannelAddMembers" = "Add members"; +"ChannelAddSubscribers" = "Add Subscribers"; +"ChannelAddTo" = "Add %1$s to the channel?"; +"ChannelAddToChannel" = "Add to channel"; +"ChannelAddToGroup" = "Add to group"; +"ChannelAddedBy" = "un1 added you to this channel"; +"ChannelAddedByNotification" = "%1$s added you to the channel %2$s"; +"ChannelAdmin" = "Admin"; +"ChannelAdminSettings" = "Channel Settings"; +"ChannelAdministrator" = "Admin"; +"ChannelAdministrators" = "Administrators"; +"ChannelAdmins" = "Channel admins"; +"ChannelAdminsInfo" = "You can add admins to help you manage your channel. Press and hold to remove admins."; +"ChannelAffiliateProgramFeature1" = "Receive guaranteed commissions for spending by users you refer."; +"ChannelAffiliateProgramFeature1Title" = "Reliable"; +"ChannelAffiliateProgramFeature2" = "Track your commissions from referred users in real time."; +"ChannelAffiliateProgramFeature2Title" = "Transparent"; +"ChannelAffiliateProgramFeature3" = "Choose a mini app below, get your referral link, and start earning Stars."; +"ChannelAffiliateProgramFeature3Title" = "Simple"; +"ChannelAffiliateProgramJoinButton" = "Join Program"; +"ChannelAffiliateProgramJoinButtonInfo" = "By joining this program, you agree to the **Terms and Conditions** of Affiliate Programs."; +"ChannelAffiliateProgramJoinButtonInfoLink" = "https://telegram.org/tos/affiliate-program"; +"ChannelAffiliateProgramJoinDailyRevenue" = "App's average daily\nrevenue per user"; +"ChannelAffiliateProgramJoinMonthlyUsers" = "Monthly users"; +"ChannelAffiliateProgramJoinRevenue" = "App's daily revenue per user: ⭐️ %s"; +"ChannelAffiliateProgramJoinText" = "**%1$s** will share **%2$s** of the revenue from each user you refer to it for **%3$s**."; +"ChannelAffiliateProgramJoinText_Lifetime" = "lifetime"; +"ChannelAffiliateProgramJoinText_Months#one" = "%d month"; +"ChannelAffiliateProgramJoinText_Months#other" = "%d months"; +"ChannelAffiliateProgramJoinText_Years#one" = "%d year"; +"ChannelAffiliateProgramJoinText_Years#other" = "%d years"; +"ChannelAffiliateProgramJoinTitle" = "Affiliate Program"; +"ChannelAffiliateProgramJoinViewBot" = "View %s"; +"ChannelAffiliateProgramLinkCopy" = "Copy Link"; +"ChannelAffiliateProgramLinkOpened#one" = "%1$d user has opened %2$s through this link."; +"ChannelAffiliateProgramLinkOpened#other" = "%1$d users have opened %2$s through this link."; +"ChannelAffiliateProgramLinkOpenedNone" = "No one has opened %s through this link."; +"ChannelAffiliateProgramLinkRejoin" = "Rejoin Program"; +"ChannelAffiliateProgramLinkSendTo" = "Commissions will be sent to:"; +"ChannelAffiliateProgramLinkTextChannel" = "Share this link with your subscribers to earn a **%1$s** commission on their spending in **%2$s** for **%3$s**."; +"ChannelAffiliateProgramLinkTextRevoked" = "You revoked this referral link."; +"ChannelAffiliateProgramLinkTextUser" = "Share this link with your friends to earn a **%1$s** commission on their spending in **%2$s** for **%3$s**."; +"ChannelAffiliateProgramLinkTitle" = "Referral Link"; +"ChannelAffiliateProgramMyPrograms" = "My Programs"; +"ChannelAffiliateProgramPrograms" = "Programs"; +"ChannelAffiliateProgramProgramsSort" = "Sort by"; +"ChannelAffiliateProgramProgramsSortDate" = "Date"; +"ChannelAffiliateProgramProgramsSortProfitability" = "Profitability"; +"ChannelAffiliateProgramProgramsSortRevenue" = "Revenue"; +"ChannelAffiliateProgramRowText" = "Distribute links to mini apps and earn a share of their revenue in Stars."; +"ChannelAffiliateProgramRowTitle" = "Earn Stars"; +"ChannelAffiliateProgramText" = "Earn a commission each time a user who first accessed a mini app through your referral link spends **Stars** within it."; +"ChannelAffiliateProgramTitle" = "Affiliate Programs"; +"ChannelAffiliatePrograms" = "Affiliate Programs"; +"ChannelAlertCreate" = "CREATE CHANNEL"; +"ChannelAlertCreate2" = "Create Channel"; +"ChannelAlertText" = "Channels are a one-to-many tool for broadcasting your messages to unlimited audiences."; +"ChannelAlertTitle" = "What is a Channel?"; +"ChannelAntiSpam" = "Aggressive Anti-Spam"; +"ChannelAntiSpamFalsePositiveReported" = "Telegram moderators will review your report. Thank you!"; +"ChannelAntiSpamForbidden#one" = "Aggressive filtering can be enabled only in groups with more than **%1$d member**."; +"ChannelAntiSpamForbidden#other" = "Aggressive filtering can be enabled only in groups with more than **%1$d members**."; +"ChannelAntiSpamInfo" = "Telegram will filter more spam, but may occasionally affect ordinary messages. You can report False Positives in Recent Actions."; +"ChannelAntiSpamInfo2" = "You can manage anti-spam settings in %s."; +"ChannelAntiSpamUser" = "Telegram Anti-Spam"; +"ChannelAppearanceUpdated" = "Channel appearance has been updated"; +"ChannelAutotranslation" = "Auto-Translate Messages"; +"ChannelBackgroundHint" = "All subscribers will see this wallpaper"; +"ChannelBackgroundMessagePreview" = "Details to follow shortly.\nStay tuned!"; +"ChannelBackgroundMessageReplyName" = "Du Rove's Channel"; +"ChannelBackgroundMessageReplyText" = "Breaking News"; +"ChannelBlacklist" = "Removed Users"; +"ChannelBlockUser" = "Remove User"; +"ChannelBlockedUsers" = "Removed users"; +"ChannelBoostsJustReachedLevel1" = "This channel reached **Level\u00a01** and can now post stories."; +"ChannelBoostsJustReachedLevelNext" = "This channel reached **Level\u00a0%1$d** and can now post %2$s per day."; +"ChannelBots" = "Bots"; +"ChannelBroadcast" = "Broadcast"; +"ChannelCantOpenBanned" = "Unfortunately, you are banned from participating in public groups."; +"ChannelCantOpenBannedByAdmin" = "Sorry, you can't access this chat because you were banned by an admin."; +"ChannelCantOpenBannedByAdminTitle" = "Can't access the chat"; +"ChannelCantOpenNa" = "Sorry, this chat doesn't seem to exist."; +"ChannelCantOpenPrivate" = "Sorry, this chat is no longer accessible."; +"ChannelCantOpenPrivate2" = "This channel is unavailable."; +"ChannelCantSendMessage" = "Sorry, you can't send messages to this channel."; +"ChannelCaptionLimitPremiumPromo#one" = "Make the caption shorter or subscribe to *Telegram Premium* to double the limit to **%1$s** character."; +"ChannelCaptionLimitPremiumPromo#other" = "Make the caption shorter or subscribe to *Telegram Premium* to double the limit to **%1$s** characters."; +"ChannelColorApplied" = "Your channel color has been updated!"; +"ChannelColorApply" = "Apply Color and Icon"; +"ChannelColorHint" = "This color will be used for your channel's name, the links it sends, and replies to its posts."; +"ChannelColorPreview" = "The name of the channel and replies to its message will be shown in the selected color"; +"ChannelColorPreviewLinkDescription" = "Previews of links you send will also use this color."; +"ChannelColorPreviewLinkTitle" = "Link Preview"; +"ChannelColorPreviewReply" = "Reply to your channel message"; +"ChannelColorPreviewTitle" = "Color Preview"; +"ChannelColorTabName" = "Name"; +"ChannelColorTabProfile" = "Profile"; +"ChannelColorTitle" = "Your Channel Color"; +"ChannelColorTitle2" = "Appearance"; +"ChannelColorUnsaved" = "Unsaved Changes"; +"ChannelColorUnsavedMessage" = "You have changed your color or icon settings. Apply changes?"; +"ChannelContacts" = "Contacts in this channel"; +"ChannelCreator" = "Owner"; +"ChannelDelete" = "Delete Channel"; +"ChannelDeleteAlert" = "Deleting this channel will remove all subscribers and all posts will be lost. Delete the channel anyway?"; +"ChannelDeleteFromList" = "Delete from this list"; +"ChannelDeleteInfo" = "This will delete all messages in the channel for all subscribers."; +"ChannelDeleteMenu" = "Delete channel"; +"ChannelDeletedUndo" = "Channel deleted."; +"ChannelDescriptionChanged" = "Channel description updated."; +"ChannelDiscuss" = "DISCUSS"; +"ChannelEdit" = "Edit"; +"ChannelEditPermissions" = "Edit permissions"; +"ChannelEmojiStatus" = "Channel Emoji Status"; +"ChannelEmojiStatusInfo" = "Choose a status that will be shown next to the channel's name."; +"ChannelEnablePaidReactions" = "Enable Paid Reactions"; +"ChannelEnablePaidReactionsInfo" = "Switch this on to let your subscribers react to posts with Telegram Stars, which you will be able to withdraw as Grams."; +"ChannelEnablePaidReactionsInfoLink" = "https://telegram.org/tos/stars/"; +"ChannelHiddenMembersForbidden#one" = "Only groups with more than **%1$d member** can hide members."; +"ChannelHiddenMembersForbidden#other" = "Only groups with more than **%1$d members** can hide members."; +"ChannelHideMembers" = "Hide Members"; +"ChannelHideMembersInfo" = "Switch on to hide the list of members in this group. Admins will remain visible."; +"ChannelInviteLinkTitle" = "Invite Link"; +"ChannelInviteViaLink" = "Invite via Link"; +"ChannelInviteViaLink2" = "Add to Group"; +"ChannelInviteViaLinkRestricted" = "You can't create a link"; +"ChannelInviteViaLinkRestricted2" = "This user can't be invited"; +"ChannelInviteViaLinkRestricted3" = "These users can't be invited"; +"ChannelJoin" = "JOIN"; +"ChannelJoinNoCaps" = "Join"; +"ChannelJoinRequest" = "APPLY TO JOIN GROUP"; +"ChannelJoinRequestNoCaps" = "Apply to join channel"; +"ChannelJoinRequestSent" = "JOIN REQUEST SENT"; +"ChannelJoinRequestSentNoCaps" = "Join request sent"; +"ChannelJoinTo" = "Do you want to join the channel '%1$s'?"; +"ChannelJoined" = "You joined this channel"; +"ChannelLeaveAlert" = "Are you sure you want to leave this channel?"; +"ChannelLeaveAlertWithName" = "Are you sure you want to leave **%1$s**?"; +"ChannelLeftUndo" = "You left the channel."; +"ChannelLinkDirectMessages" = "Link to direct messages"; +"ChannelLinkInfo" = "Anyone who has Telegram installed will be able to join your channel by following this link."; +"ChannelLinkTitle" = "Public link"; +"ChannelLinksInfo" = "You can create additional invite links that have limited time and number of users."; +"ChannelLinksInfoPaid" = "You can create additional invite links that have limited time, number of users or require a paid subscription."; +"ChannelManageMessages" = "Manage Messages"; +"ChannelManageStories" = "Manage Stories"; +"ChannelMegaJoined" = "You joined this group"; +"ChannelMemberTags" = "Allow Member Tags"; +"ChannelMemberTagsInfo" = "Turn this on to let members of the group add short tags next to their names."; +"ChannelMembers" = "Members"; +"ChannelMembersInfo" = "Only channel admins can see this list."; +"ChannelMessageAlbum" = "%1$s posted an album"; +"ChannelMessageAudio" = "%1$s posted a voice message"; +"ChannelMessageContact" = "%1$s posted a contact"; +"ChannelMessageContact2" = "%1$s posted a contact %2$s"; +"ChannelMessageDocument" = "%1$s posted a file"; +"ChannelMessageFew" = "%1$s posted %2$s"; +"ChannelMessageGIF" = "%1$s posted a GIF"; +"ChannelMessageLiveLocation" = "%1$s posted a live location"; +"ChannelMessageMap" = "%1$s posted a location"; +"ChannelMessageMusic" = "%1$s posted an audio file"; +"ChannelMessageNoText" = "%1$s posted a message"; +"ChannelMessagePhoto" = "%1$s posted a photo"; +"ChannelMessagePoll" = "%1$s posted a poll"; +"ChannelMessagePoll2" = "%1$s posted a poll %2$s"; +"ChannelMessageQuiz2" = "%1$s posted the quiz \"%2$s\""; +"ChannelMessageRound" = "%1$s posted a video message"; +"ChannelMessageSticker" = "%1$s posted a sticker"; +"ChannelMessageStickerEmoji" = "%1$s posted a %2$s sticker"; +"ChannelMessageTodo2" = "%1$s posted the checklist %2$s"; +"ChannelMessageTodoAppend2" = "%1$s added %2$s tasks"; +"ChannelMessageTodoDone2" = "%1$s toggled %2$s tasks"; +"ChannelMessageVideo" = "%1$s posted a video"; +"ChannelModerator" = "Moderator"; +"ChannelMute" = "MUTE"; +"ChannelMuteNoCaps" = "Mute"; +"ChannelNeedBoostsAlreadyBoostedDescriptionLevel1" = "This channel needs %s to enable stories."; +"ChannelNeedBoostsDescription" = "Your channel needs %s to enable posting stories.\n\nAsk your Premium subscribers to boost your channel with this link:"; +"ChannelNeedBoostsDescription2" = "This channel needs %d more boosts to enable posting stories."; +"ChannelNeedBoostsDescriptionForNewFeatures" = "**%1$s** needs %2$s to unlock new features."; +"ChannelNeedBoostsDescriptionLevel1" = "This channel needs %s to enable stories. Help make it possible!"; +"ChannelNeedBoostsDescriptionLevelNext" = "This channel needs %1$s to be able to post %2$s per day. Help make it possible!"; +"ChannelNeedBoostsDescriptionNextLevel" = "Your channel needs %1$s to post %2$s per day.\n\nAsk your Premium subscribers to boost your channel with this link:"; +"ChannelNeedBoostsForAutotranslationDescription" = "Your channel needs to reach **Level %d** to enable auto-translation.\n\nAsk your **Premium** subscribers to boost the channel with this link:"; +"ChannelNeedBoostsForColorDescription" = "Your channel needs to reach **Level %d** to change channel color. **Premium** users can boost your channel with this link:"; +"ChannelNeedBoostsForCustomWallpaperDescription" = "Your channel needs to reach **Level %d** to change channel wallpaper to custom photo. **Premium** users can boost your channel with this link:"; +"ChannelNeedBoostsForEmojiStatusDescription" = "Your channel needs to reach **Level %d** to set channel emoji status. **Premium** users can boost your channel with this link:"; +"ChannelNeedBoostsForProfileColorDescription" = "Your channel needs to reach **Level %d** to change channel profile color to selected. **Premium** users can boost your channel with this link:"; +"ChannelNeedBoostsForProfileIconDescription" = "Your channel needs to reach **Level %d** to change channel profile icon. **Premium** users can boost your channel with this link:"; +"ChannelNeedBoostsForReplyIconDescription" = "Your channel needs to reach **Level %d** to change channel link style icon. **Premium** users can boost your channel with this link:"; +"ChannelNeedBoostsForSwitchOffAdsDescription" = "Your channel needs to reach **Level %d** to switch off ads.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelNeedBoostsForWallpaperDescription" = "Your channel needs to reach **Level %d** to change channel wallpaper. **Premium** users can boost your channel with this link:"; +"ChannelNeedBoostsForWearCollectiblesDescription" = "Your channel needs to reach **Level %d** to wear collectibles. **Premium** users can boost your channel with this link:"; +"ChannelNoWallpaper" = "No Wallpaper"; +"ChannelNotifyMembersInfoOff" = "Subscribers will receive a silent notification."; +"ChannelNotifyMembersInfoOn" = "Subscribers will be notified when you post."; +"ChannelOpenDirect" = "Direct Messages"; +"ChannelOtherMembers" = "Other members"; +"ChannelOtherSubscriberJoined" = "joined %1$s"; +"ChannelOtherSubscribers" = "Other subscribers"; +"ChannelPermissions" = "Permissions"; +"ChannelPermissionsHeader" = "What can members of this group do?"; +"ChannelPhotoEditNotification" = "Channel %1$s updated its photo"; +"ChannelPostDeleted" = "Sorry, this post has been removed from the discussion group."; +"ChannelPrivate" = "Private Channel"; +"ChannelPrivateInfo" = "Private channels can only be joined via invite link."; +"ChannelPrivateLinkHelp" = "People can join your channel by following this link. You can revoke the link at any time."; +"ChannelProfileColorApplied" = "Your channel profile color has been updated!"; +"ChannelProfileColorEmojiApplied" = "Emoji logos added to your channel profile!"; +"ChannelProfileColorReset" = "Reset Profile Color"; +"ChannelProfileColorResetApplied" = "Your channel profile color has been reset!"; +"ChannelProfileHint" = "Choose a color and a logo for your channel's profile"; +"ChannelProfileIcon" = "Add Icons to Profile"; +"ChannelProfileInfo" = "Choose a color and a logo for the channel's profile."; +"ChannelProfileLogo" = "Profile Logo"; +"ChannelPublic" = "Public Channel"; +"ChannelPublicEmptyUsername" = "Please choose a link for your public channel, so that people can find it in search and share with others.\n\nIf you're not interested, we suggest creating a private channel instead."; +"ChannelPublicEmptyUsernameTitle" = "Choose a link"; +"ChannelPublicInfo" = "Public channels can be found in search, anyone can join them."; +"ChannelRemoveUser" = "Remove from channel"; +"ChannelRemoveUserAdmin" = "Dismiss admin"; +"ChannelReplyIcon" = "Add Icons to Replies"; +"ChannelReplyIconHint" = "Select an icon to create a custom pattern for replies to your messages."; +"ChannelReplyIconOff" = "Off"; +"ChannelReplyInfo" = "Choose a color for the name of your channel, the links it sends, and replies to its messages."; +"ChannelReplyLogo" = "Reply Logo"; +"ChannelRestrictedUsers" = "Restricted users"; +"ChannelSearchException" = "Search Exceptions"; +"ChannelSettings" = "Settings"; +"ChannelSettingsChangedAlert" = "You have changed settings in this channel. Apply changes?"; +"ChannelSettingsJoinRequest" = "Approve new members"; +"ChannelSettingsJoinRequestInfo" = "Turn this on if you want users to join only after they are approved by an admin."; +"ChannelSettingsJoinRequestInfo2" = "An admin must approve users who want to join the channel."; +"ChannelSettingsJoinRequestInfoManagedBy" = "An admin must approve users who want to join the channel. Managed by **%s**"; +"ChannelSettingsJoinTitle" = "Who can send messages?"; +"ChannelSettingsJoinToSend" = "Only members"; +"ChannelSettingsJoinToSendInfo" = "Turn this on if you expect users to join your group before being able to send messages."; +"ChannelSettingsTitle" = "Channel Settings"; +"ChannelSignInfo" = "Add the names of admins to messages they post."; +"ChannelSignMessages" = "Sign Messages"; +"ChannelSignMessagesInfo" = "Add names of admins to the messages they post"; +"ChannelSignMessagesWithProfile" = "Show Authors' Profiles"; +"ChannelSignProfilesInfo" = "Allow admins to post as their channel or personal account, linking to their profile."; +"ChannelSilentBroadcast" = "Silent Broadcast"; +"ChannelStars" = "Balance"; +"ChannelSubscribers" = "Subscribers"; +"ChannelSubscribers2" = "Channel subscribers"; +"ChannelThemeChangedTo" = "%1$s changed the channel theme to %2$s"; +"ChannelThemeDisabled" = "%1$s disabled the channel theme"; +"ChannelTitleChanged" = "Channel name updated."; +"ChannelTooMuch" = "Sorry, you are a member of too many groups and channels. Please leave some before creating a new one."; +"ChannelTooMuchJoin" = "Sorry, you are a member of too many groups and channels. Please leave one before joining another."; +"ChannelTooMuchTitle" = "Too many communities"; +"ChannelTopics" = "Topics"; +"ChannelTopicsDiscussionForbidden" = "Topics are not yet available in the discussion groups of channels."; +"ChannelTopicsForbidden#one" = "Only groups with more than **%1$d member** can have topics enabled."; +"ChannelTopicsForbidden#other" = "Only groups with more than **%1$d members** can have topics enabled."; +"ChannelType" = "Channel Type"; +"ChannelTypeHeader" = "Channel type"; +"ChannelUnmute" = "UNMUTE"; +"ChannelUnmuteNoCaps" = "Unmute"; +"ChannelUserAddLimit" = "Sorry, you can only add the first 200 subscribers to a channel. Note that an unlimited number of people may join via the channel's link."; +"ChannelUserCantAdd" = "Sorry, you can't add this user to channels."; +"ChannelUserCantAdmin" = "Sorry, this channel has too many admins."; +"ChannelUserCantBot" = "Sorry, this channel has too many bots."; +"ChannelUserLeftError" = "If a person has left a channel in the past, you need to be in their Telegram contacts to add them back.\n\nThey can still join via an invite link as long as they are not on the Removed Users list."; +"ChannelUsernameHelp" = "If you set a public link, other people will be able to find and join your channel.\n\nYou can use a–z, 0–9 and underscores.\nMinimum length is 5 characters."; +"ChannelUsernamePlaceholder" = "link"; +"ChannelVerifiedWarning" = "Warning"; +"ChannelVideoEditNotification" = "%1$s updated its photo"; +"ChannelWallpaper" = "Channel Wallpaper"; +"ChannelWallpaper2Info" = "Set a wallpaper that will be visible for everyone reading your channel."; +"ChannelWallpaperInfo" = "Upload your own background image for the channel."; +"ChannelWallpaperRemove" = "Remove Wallpaper"; +"ChannelWallpaperUpdated" = "Wallpaper updated"; +"Channels#one" = "%1$d channel"; +"Channels#other" = "%1$d channels"; +"ChannelsTab" = "Channels"; +"Characters#one" = "%1$d character"; +"Characters#other" = "%1$d characters"; +"CharactersPerMessage" = "%s per message"; +"ChartInTON" = "%s in Grams"; +"ChartInUSD" = "%s in USD"; +"ChartInXTR" = "%s in Stars"; +"ChatAdmin" = "admin"; +"ChatApplyTheme" = "Apply Theme"; +"ChatArchived" = "Chat archived."; +"ChatArchivedInfo" = "Swipe left on the archive to hide it."; +"ChatBackground" = "Chat Wallpaper"; +"ChatBackgroundHint" = "%s will be able to apply this wallpaper"; +"ChatCamera" = "Camera"; +"ChatDeletedUndo" = "Chat deleted."; +"ChatDistanceToPeer" = "%1$s is %2$s"; +"ChatDocument" = "File"; +"ChatEmoji" = "Emoji"; +"ChatGallery" = "Gallery"; +"ChatHints" = "People"; +"ChatHintsDelete" = "Remove %1$s from suggestions?"; +"ChatHintsDeleteAlert" = "Are you sure you want to remove **%1$s** from suggestions?"; +"ChatHintsDeleteAlertTitle" = "Remove suggestion"; +"ChatHistory" = "Chat history for new members"; +"ChatHistoryHidden" = "Hidden"; +"ChatHistoryHiddenInfo" = "New members won't see earlier messages."; +"ChatHistoryHiddenInfo2" = "New members won't see more than 100 previous messages."; +"ChatHistoryShort" = "Chat History"; +"ChatHistoryVisible" = "Visible"; +"ChatHistoryVisibleInfo" = "New members will see messages that were sent before they joined."; +"ChatLink" = "Link"; +"ChatList" = "Chat list view"; +"ChatListDefault" = "Two lines"; +"ChatListExpanded" = "Three lines"; +"ChatListSwipeGesture" = "Chat list swipe gesture"; +"ChatListSwipeGestureInfo" = "Choose which action you want to perform when you swipe to the left in the chat list."; +"ChatLocation" = "Location"; +"ChatMessageSuggestions" = "Direct Messages"; +"ChatMultipleReactionsPromo" = "You can send multiple reactions with Telegram Premium."; +"ChatNoTheme" = "No Theme"; +"ChatPerFolderLimitSubtitle" = "Add up to %d chats into each of your folders"; +"ChatPerFolderLimitTitle" = "Chats per Folder"; +"ChatResetTheme" = "Reset Theme"; +"ChatSetLocation" = "Set Location"; +"ChatSetLocationInfo" = "People will be able to find your group in the \"People Nearby\" section."; +"ChatSetNewPhoto" = "Set New Photo"; +"ChatSetPhotoOrVideo" = "Set Photo"; +"ChatSetThisLocation" = "Set this location"; +"ChatSettings" = "Chat Settings"; +"ChatSticker" = "Sticker"; +"ChatTagAdmin" = "Admin"; +"ChatTagOwner" = "Owner"; +"ChatThemeApplyHint" = "Theme will also be applied for %s"; +"ChatThemeChangedTo" = "%1$s changed the chat theme to %2$s"; +"ChatThemeChangedYou" = "You changed the chat theme to %1$s"; +"ChatThemeDayNightSwitchTooltip" = "Tap to view this theme in the night mode."; +"ChatThemeDaySwitchTooltip" = "Tap to view this theme in day mode."; +"ChatThemeDisabled" = "%1$s disabled the chat theme"; +"ChatThemeDisabledYou" = "You disabled the chat theme"; +"ChatThemeNightSwitchTooltip" = "Tap to view this theme in night mode."; +"ChatThemeSaveDialogApply" = "Apply"; +"ChatThemeSaveDialogDiscard" = "Discard"; +"ChatThemeSaveDialogText" = "Do you want to apply the new theme for this chat?"; +"ChatThemeSaveDialogTitle" = "Save changes?"; +"ChatVideo" = "Video"; +"ChatWasMovedToMainList" = "The chat was moved to your main list."; +"ChatWithAdminChannelTitle" = "Response to your join request"; +"ChatWithAdminGroupTitle" = "Response to your join request"; +"ChatWithAdminMessage" = "You received this message because you requested to join **%1$s**, %2$s."; +"ChatWithChannelAdmin" = "**%1$s** is an admin of **%2$s**, a channel you requested to join."; +"ChatWithGroupAdmin" = "**%1$s** is an admin of **%2$s**, a group you requested to join."; +"ChatYourSelf" = "forward here to save"; +"ChatYourSelfDescription1" = "Forward messages here to save them"; +"ChatYourSelfDescription2" = "Send media and files to store them"; +"ChatYourSelfDescription3" = "Access this chat from any device"; +"ChatYourSelfDescription4" = "Use search to quickly find things"; +"ChatYourSelfName" = "You"; +"ChatYourSelfTitle" = "Your cloud storage"; +"Chats#one" = "%1$d chat"; +"Chats#other" = "%1$d chats"; +"ChatsArchived" = "Chats archived."; +"ChatsDeletedUndo" = "Chats deleted."; +"ChatsException#one" = "%1$d chat"; +"ChatsException#other" = "%1$d chats"; +"ChatsLeaveAlert" = ""; +"ChatsMute" = "Mute"; +"ChatsNearbyHeader" = "Groups nearby"; +"ChatsSelected#one" = "%1$d chat"; +"ChatsSelected#other" = "%1$d chats"; +"ChatsSelectedClear#one" = "%1$d chat"; +"ChatsSelectedClear#other" = "%1$d chats"; +"ChatsSelectedClearCache#one" = "%1$d chat"; +"ChatsSelectedClearCache#other" = "%1$d chats"; +"ChatsUnmute" = "Unmute"; +"CheckPassword" = "Check Password"; +"CheckPasswordBackToSettings" = "Back to Settings"; +"CheckPasswordInfo" = "Do you still remember your password?"; +"CheckPasswordPerfect" = "Perfect!"; +"CheckPasswordPerfectInfo" = "You still remember your password."; +"CheckPasswordWrong" = "You entered the wrong password."; +"CheckPhoneNumber" = "Is %1$s still your number?"; +"CheckPhoneNumberInfo" = "Keep your number up to date to ensure you can always log into Telegram. **Learn more**"; +"CheckPhoneNumberLearnMoreUrl" = "https://telegram.org/faq#q-i-have-a-new-phone-number-what-do-i-do"; +"CheckPhoneNumberNo" = "No"; +"CheckPhoneNumberYes" = "Yes"; +"CheckPhoneNumberYes2" = "👍 Yes"; +"CheckPrivacyCallsText" = "You've restricted who can message you, but anyone can still call you. Would you like to review these settings?"; +"CheckPrivacyCallsTitle" = "Call Settings"; +"CheckPrivacyInviteText" = "You've restricted who can message you, but anyone can still invite you to groups and channels. Would you like to review these settings?"; +"CheckPrivacyInviteTitle" = "Invite Settings"; +"CheckPrivacyReview" = "Review"; +"CheckYourEmail" = "Check Your Email"; +"CheckYourEmailSubtitle" = "Please enter the code we have sent to your email %1$s."; +"CheckYourNewEmail" = "Check Your New Email"; +"CheckYourNewEmailSubtitle" = "Please enter the code we have sent to your new email %1$s."; +"Checking" = "Checking..."; +"ChooseANewWallpaper" = "Choose a New Wallpaper"; +"ChooseBackground" = "Choose background"; +"ChooseBackgroundFromGallery" = "Choose Wallpaper from Gallery"; +"ChooseBot" = "Choose a Bot"; +"ChooseBots" = "Choose Bots"; +"ChooseChannel" = "Choose a Channel"; +"ChooseColorAsBackground" = "Set a Color as Wallpaper"; +"ChooseCountry" = "Choose a country"; +"ChooseCover" = "Choose a frame for your profile photo"; +"ChooseDate" = "Choose date"; +"ChooseEmojiOrSticker" = "Choose emoji or sticker"; +"ChooseEmojiPackHeader" = "Choose emoji pack"; +"ChooseEmojiPackMy" = "You can create your own custom emoji packs using the @stickers bot."; +"ChooseFromGallery" = "Upload from Gallery"; +"ChooseFromGallery2" = "Choose from Gallery"; +"ChooseFromSearch" = "Search web"; +"ChooseFromYourStickers" = "Choose from your stickers"; +"ChooseGroup" = "Choose a Group"; +"ChooseLanguages" = "Choose languages"; +"ChooseMapPreviewProvider" = "Choose Map Preview Provider"; +"ChoosePhoto" = "Choose photo"; +"ChoosePhotoForSticker" = "Create a sticker from a photo"; +"ChoosePhotoOrVideo" = "Choose photo or video"; +"ChooseRecordVideo" = "Record video"; +"ChooseStickerMyEmojiPacks" = "My emoji packs"; +"ChooseStickerMyStickerSets" = "My sticker sets"; +"ChooseStickerNoResultsFound" = "No results found for \"%1$s\""; +"ChooseStickerSet" = "CHOOSE STICKER SET"; +"ChooseStickerSetHeader" = "Choose sticker set"; +"ChooseStickerSetMy" = "You can create your own custom sticker sets using the @stickers bot."; +"ChooseStickerSetNotFound" = "No such sticker set found"; +"ChooseStickerSetNotFoundInfo" = "Try again or choose from the list below"; +"ChooseStickerSetPlaceholder" = "stickerset"; +"ChooseTakePhoto" = "Take photo"; +"ChooseTheme" = "Choose theme"; +"ChooseTimeForAutoDelete" = "Choose time for auto-delete"; +"ChooseTimeForMute" = "Choose time"; +"ChooseUser" = "Choose a User"; +"ChooseUsers" = "Choose Users"; +"ChooseYourLanguage" = "Choose your language"; +"ChooseYourLanguageOther" = "Other"; +"ChoosingSticker" = "ch**oo**sing a sticker"; +"ChromeCrashMessage" = "Something went wrong while displaying this webpage.\n\nIf it occurs frequently, consider updating **Android WebView**."; +"ChromeCrashTitle" = "Aw, Snap!"; +"ChromeCustomTabs" = "In-App Browser"; +"ChromeCustomTabsInfo" = "Open external links within the app"; +"ChromecastStarted" = "Started streaming media to Chromecast."; +"ChromecastStartedTo" = "Started streaming media to %s."; +"Clear" = "Clear"; +"ClearAllHistory" = "Clear All History"; +"ClearButton" = "Clear All"; +"ClearCache" = "Clear Cache"; +"ClearCacheFewChatsTitle" = "Clear cache for %1$s"; +"ClearCacheForChat" = "Are you sure you want to delete cached data for selected chat?"; +"ClearCacheForChats" = "Are you sure you want to delete cached data for selected chats?"; +"ClearCacheInfo" = "Free up disk space on your device; your media will stay in the cloud."; +"ClearDownloadsList" = "Clear Download List"; +"ClearFewChatsTitle" = "Clear %1$s"; +"ClearForAll" = "Clear for all"; +"ClearForMe" = "Clear"; +"ClearHistory" = "Clear History"; +"ClearHistoryCache" = "Delete from cache"; +"ClearHistoryForTheseDays" = "Clear History for these days"; +"ClearHistoryForThisDay" = "Clear History for this day"; +"ClearHistoryMessageMultiple#other" = "Are you sure you want to unsave messages from %d chats?"; +"ClearHistoryMessageSingle" = "Are you sure you want to unsave messages from %s?"; +"ClearHistoryMyNotesMessage" = "Are you sure you want to delete all your notes?"; +"ClearHistoryMyNotesTitle" = "Delete all your notes"; +"ClearHistoryOptionAlso" = "Also clear history for %1$s"; +"ClearHistorySavedMessages" = "Are you sure you want to delete all your saved messages?"; +"ClearHistorySavedMessagesTitle" = "Delete all your saved messages"; +"ClearHistoryTitleMultiple#other" = "Unsave messages from %d chats"; +"ClearHistoryTitleSingle" = "Unsave messages from %s"; +"ClearHistoryTitleSingle2" = "Unsave messages"; +"ClearLocalDatabase" = "Clear Local Database"; +"ClearMediaCache" = "Clear Cache"; +"ClearOtherSessionsHelp" = "Logs out all devices except for this one."; +"ClearOtherWebSessionsHelp" = "You can log in on websites that support signing in with Telegram."; +"ClearQuote" = "Clear Quote"; +"ClearRecentEmoji" = "Clear recent emoji?"; +"ClearRecentEmojiStatusesText" = "Do you want to clear your recent emoji statuses and reset suggestions?"; +"ClearRecentEmojiStatusesTitle" = "Clear recent statuses"; +"ClearRecentEmojiText" = "Do you want to clear all your recent emoji?"; +"ClearRecentEmojiTitle" = "Clear recent emoji"; +"ClearRecentHistory" = "Clear History"; +"ClearRecentReactionsAlertMessage" = "Do you want to clear your recent reaction emoji from suggestions?"; +"ClearRecentReactionsAlertTitle" = "Clear recent emoji"; +"ClearRecentStickersAlertMessage" = "Do you want to clear all your recent stickers?"; +"ClearRecentStickersAlertTitle" = "Clear recent stickers"; +"ClearSearch" = "Clear search history?"; +"ClearSearchAlert" = "Do you want to clear your search history?"; +"ClearSearchAlertPartial#one" = "Do you want to remove this chat from your search history?"; +"ClearSearchAlertPartial#other" = "Do you want to remove these %1$d chats from your search history?"; +"ClearSearchAlertPartialTitle" = "Remove from search history"; +"ClearSearchAlertTitle" = "Clear search history"; +"ClearSearchRemove" = "Remove"; +"ClearSearchSingleAlertTitle" = "Search history"; +"ClearSearchSingleChatAlertText" = "Do you want to remove \"%1$s\" from your search history?"; +"ClearSearchSingleHashtagAlertText" = "Do you want to remove %1$s from your search history?"; +"ClearSearchSingleUserAlertText" = "Do you want to remove \"%1$s\" from your search history?"; +"ClearSelectedCache" = "Clear Selected"; +"ClearStorageHintMessage" = "Clear storage space on your phone"; +"ClearStorageHintTitle" = "Free up to **%s**"; +"ClearTelegramCache" = "Clear Telegram Cache"; +"ClearingCache" = "Clearing cache…"; +"ClearingCacheDescription" = "Please keep this window open while Telegram is clearing your cache."; +"Clicks#one" = "%d click"; +"Clicks#other" = "%d clicks"; +"Close" = "Close"; +"CloseEditor" = "MINIMIZE EDITOR"; +"CloseFriends" = "Close Friends"; +"CloseTopic" = "Close topic"; +"CloseTopics" = "Close topics"; +"CloseTranslation" = "Close Translation"; +"CocoonFeature1Text" = "No third party can access any data, such as translations, inside **@cocoon**."; +"CocoonFeature1TextLink" = "https://t.me/cocoon"; +"CocoonFeature1Title" = "Private"; +"CocoonFeature2Text" = "Cocoon has allowed Telegram to reduce translation cost by 6x."; +"CocoonFeature2Title" = "Efficient"; +"CocoonFeature3Text" = "Any developer can use Cocoon for AI features. Learn more at **cocoon.org**."; +"CocoonFeature3TextLink" = "https://cocoon.org/"; +"CocoonFeature3Title" = "For Everyone"; +"CocoonFooter" = "Want to integrate Cocoon into your projects?\nReach out at **t.me/cocoon?direct**"; +"CocoonFooterLink" = "https://t.me/cocoon?direct"; +"CocoonPoweredBy" = "Translations are powered by\n**🥚 Cocoon**."; +"CocoonPoweredByLink" = "**How does it work?**"; +"CocoonSubtitle" = "Cocoon (**Co**nfidential **Co**mpute **O**pen **N**etwork)\nhandles AI tasks safely and efficiently."; +"Code" = "Code"; +"CodeCopied" = "Code copied to clipboard"; +"CodeExpired" = "Code expired, please start over."; +"ColorBlue" = "Blue"; +"ColorCyan" = "Cyan"; +"ColorDark" = "Dark"; +"ColorGreen" = "Green"; +"ColorOrange" = "Orange"; +"ColorPickerBackground" = "Wallpaper"; +"ColorPickerMainColor" = "Accent Color"; +"ColorPickerMyMessages" = "My Messages"; +"ColorPickerReset" = "RESET"; +"ColorPickerResetAll" = "RESET ALL"; +"ColorPink" = "Pink"; +"ColorRed" = "Red"; +"ColorSepia" = "Sepia"; +"ColorTheme" = "Color theme"; +"ColorThemeChanged" = "Color theme changed."; +"ColorThemeChangedInfo" = "You can change it back in *Chat Settings*."; +"ColorThemes" = "Color Themes"; +"ColorViolet" = "Violet"; +"ColorWhite" = "White"; +"ColorYellow" = "Yellow"; +"Comment" = "Comment"; +"CommentFor" = "Comment for ⭐️%s"; +"Comments#one" = "%1$d Comment"; +"Comments#other" = "%1$d Comments"; +"CommentsCount#one" = "%1$d comment"; +"CommentsCount#other" = "%1$d comments"; +"CommentsNoNumber#one" = "comment"; +"CommentsNoNumber#other" = "comments"; +"CommentsTitle" = "Comments"; +"CommonGroups#one" = "%1$d group in common"; +"CommonGroups#other" = "%1$d groups in common"; +"CompatibilityChat" = "%1$s is using an older version of Telegram, so disappearing photos will be shown in compatibility mode.\n\nOnce %2$s updates Telegram, photos with timers for 1 minute or less will start working in 'Press and hold to view' mode, and you will be notified whenever the other party takes a screenshot."; +"ConferenceCallIncoming" = "Incoming Group Call"; +"ConferenceCallMissed" = "Missed Group Call"; +"ConferenceCallOther#one" = "%d person"; +"ConferenceCallOther#other" = "%d people"; +"ConferenceCallOutgoing" = "Outgoing Group Call"; +"ConferenceCallWithVideo" = "Join with camera on"; +"ConferenceCalling" = "calling..."; +"ConferenceChat" = "Group Call"; +"ConferenceClosed" = "Group chat has been ended."; +"ConferenceEncrypted" = "End-to-End Encrypted"; +"ConferenceEncryptedClose" = "Close"; +"ConferenceEncryptedInfo" = "These emoji represent the encryption key. They must match for all participants and will change when someone joins or leaves."; +"ConferenceEncryptedSyncing" = "End-to-End Encrypted"; +"Confirm" = "Confirm"; +"ConfirmCorrectNumber" = "Is this the correct number?"; +"ConfirmCreatePasscode" = "Re-enter your passcode"; +"ConfirmDeleteCallLog" = "Do you want to delete this entry from the call log?"; +"ConfirmSensitiveContentText" = "Confirm that you are over 18 years old and update your settings to see potentially explicit and sensitive content."; +"ConfirmSensitiveContentTitle" = "18+ Content"; +"Connected" = "Connected"; +"ConnectedAccountsLimitSubtitle" = "Connect %d accounts with different phone numbers"; +"ConnectedAccountsLimitTitle" = "Connected Accounts"; +"Connecting" = "Connecting..."; +"ConnectingConnectProxy" = "Connect Proxy"; +"ConnectingToProxy" = "Connecting to proxy"; +"ConnectingToProxyDisable" = "Disable"; +"ConnectingToProxyDisableAlert" = "Are you sure you want to disable proxy server %1$s? You can reenable it later in Settings > Data and Storage."; +"ConnectingToProxyEnable" = "Enable"; +"ConnectingToProxyTapToDisable" = "Tap here to disable proxy..."; +"ConnectingToProxyWithDots" = "Connecting to proxy..."; +"ConnectingYourContacts" = "Connecting your contacts..."; +"ContactBirthday" = "Birthday"; +"ContactInfoCommonGroups" = "Common groups"; +"ContactInfoIsContact" = "Contact"; +"ContactInfoIsNotContact" = "Not a contact"; +"ContactInfoLocation" = "Location"; +"ContactInfoNotVerified" = "Not an official account"; +"ContactInfoPhone" = "Phone number"; +"ContactInfoPhoneFragment" = "Anonymous"; +"ContactInfoRegistration" = "Registration"; +"ContactInfoUserUpdatedName" = "User updated name %s"; +"ContactInfoUserUpdatedPhoto" = "User updated photo %s"; +"ContactJob" = "Job"; +"ContactJobTitle" = "Title"; +"ContactJoined" = "Contact joined Telegram"; +"ContactNotRegistered" = "%1$s is not on Telegram yet, would you like to invite them to join?"; +"ContactNotRegisteredTitle" = "Invite to Telegram"; +"ContactNote" = "Note"; +"ContactShare" = "SHARE CONTACT"; +"ContactShortcutMessage" = "Message %1$s"; +"ContactShortcutVideoCall" = "Video call %1$s"; +"ContactShortcutVoiceCall" = "Voice call %1$s"; +"ContactSupport" = "Contact Support"; +"ContactSupportInfo" = "Tell us about any issues; logging out doesn't usually help."; +"ContactUrl" = "URL"; +"Contacts" = "Contacts"; +"ContactsAndFee" = "Paid"; +"ContactsAndPremium" = "Contacts & Premium"; +"ContactsDeletedUndo#one" = "%d contact deleted."; +"ContactsDeletedUndo#other" = "%d contacts deleted."; +"ContactsPermissionAlert" = "**Telegram** needs access to your contacts so that you can connect with your friends across all your devices. Your contacts will be continuously synced with Telegram's heavily encrypted cloud servers."; +"ContactsPermissionAlertContinue" = "Continue"; +"ContactsPermissionAlertNotNow" = "Not now"; +"Continue" = "Continue"; +"ContinueOnThisLanguage" = "Continue in English"; +"Contrast" = "Contrast"; +"ConvertGroup" = "Convert to Supergroup"; +"ConvertGroupAlert" = "This action is irreversible. It is not possible to downgrade a supergroup to a regular group."; +"ConvertGroupAlertWarning" = "Warning"; +"ConvertGroupInfo" = "**Member limit reached.**\n\nFor more space and additional features, upgrade to a supergroup:\n\n• Supergroups can hold up to %1$s\n• New members can see the full message history\n• Deleted messages will disappear for all members\n• Admins can add a description to the group\n• Owner can set a public link for the group"; +"ConvertGroupInfo2" = "**In supergroups:**\n\n• New members can see the full message history\n• Deleted messages will disappear for all members\n• Admins can add a description to the group\n• Creator can set a public link for the group"; +"ConvertGroupInfo3" = "**Note:** this action can't be undone."; +"ConvertGroupMenu" = "Convert to supergroup"; +"ConvertingVideo" = "Converting video..."; +"Copy" = "Copy"; +"CopyCardNumber" = "Copy Card"; +"CopyCode" = "Copy Code"; +"CopyEmojiPreview" = "Copy Emoji"; +"CopyHashtag" = "Copy Hashtag"; +"CopyLink" = "Copy Link"; +"CopyMail" = "Copy E-Mail"; +"CopyNumber" = "Copy Number"; +"CountOfResults#one" = "%1$d of %2$d results"; +"CountOfResults#other" = "%1$d of %2$d results"; +"CountReceived" = "Received"; +"CountSent" = "Sent"; +"Country" = "Country"; +"Create" = "Create"; +"CreateChannelForThis" = "Create a New Channel for This"; +"CreateContact" = "Create Contact"; +"CreateEncryptedChatError" = "An error occurred."; +"CreateGeneralTopicTitle" = "Choose topic name"; +"CreateGroupError" = "Sorry, you can't create a group with these users because of their privacy settings."; +"CreateGroupForImport" = "Create a New Group to Import"; +"CreateGroupForThis" = "Create a New Group for This"; +"CreateLink" = "Create Link"; +"CreateLinkHeader" = "CREATE"; +"CreateLinkHeaderNoCaps" = "Create"; +"CreateManagedBotButton" = "Create"; +"CreateManagedBotCreatedText" = "**%s** will manage this bot for you."; +"CreateManagedBotCreatedTitle" = "%s created!"; +"CreateManagedBotLimitText" = "You can create up to %d bots, or delete your current ones via @BotFather"; +"CreateManagedBotLimitTextPremium" = "Subscribe to **Premium** to create up to %1$d bots, or delete one of your %2$d bots via @BotFather"; +"CreateManagedBotLimitTextTime" = "You can create another bot in %s."; +"CreateManagedBotLimitTitle" = "Bot Limit Exceeded"; +"CreateManagedBotName" = "Bot Name"; +"CreateManagedBotNameInfo" = "Tap to edit bot name."; +"CreateManagedBotText" = "**%s** would like you to create\nand manage a chatbot on your behalf."; +"CreateManagedBotTitle" = "Create Bot"; +"CreateManagedBotUnsupported" = "**%s** doesn’t support Bot Management mode."; +"CreateManagedBotUsername" = "Username"; +"CreateManagedBotUsernameInfo" = "Tap to edit bot username."; +"CreateMultipleStories" = "Create Multiple Stories"; +"CreateNewContact" = "Create New Contact"; +"CreateNewFilter" = "Create New Folder"; +"CreateNewFilterInfo" = "Create folders for different groups of chats and quickly switch between them."; +"CreateNewInviteLink" = "Create a New Invite Link"; +"CreateNewLink" = "Create a New Link"; +"CreateNewLinkHelp" = "You can generate invite links that expire after they are used."; +"CreateNewTheme" = "Create a New Theme"; +"CreateNewThemeAlert" = "You can create your own theme by changing colors within the app.\n\nYou can always switch back to the default Telegram theme here."; +"CreateNewThemeHelp" = "Tap on the palette icon to view or edit the list of elements on each screen."; +"CreateNewThemeInfo" = "You can create your own theme by changing colors within the app. You can always switch back to the default Telegram theme here."; +"CreateNewThemeMenu" = "Create New Theme"; +"CreatePasscode" = "Create a passcode"; +"CreatePasscodeInfoPIN" = "Please enter any 4 digits that you will use to unlock your Telegram app."; +"CreatePasscodeInfoPassword" = "Please enter the password you will use to unlock Telegram."; +"CreatePassword" = "Create a Password"; +"CreateSticker" = "Create Sticker"; +"CreateTheme" = "Create Theme"; +"CreateTopic" = "Create topic"; +"CreateTopicTitle" = "Choose a topic name and icon"; +"CreateTopicsPermission" = "Create topics"; +"Crop" = "CROP"; +"CropImage" = "Crop image"; +"CropOriginal" = "Original"; +"CropReset" = "Reset"; +"CropSquare" = "Square"; +"CurrentGroupStickers" = "Group Stickers"; +"CurrentSession" = "This device"; +"CurvesAll" = "ALL"; +"CurvesBlue" = "BLUE"; +"CurvesGreen" = "GREEN"; +"CurvesRed" = "RED"; +"CustomAvatarTooltip" = "photo set by you"; +"CustomAvatarTooltipVideo" = "photo set by you"; +"CustomCallInfo" = "These users will or will not be able to call you regardless of the settings above."; +"CustomHelp" = "Unless you are a Premium user, you won't see Last Seen or Online statuses for people with whom you don't share yours. Approximate times will be shown instead (recently, within a week, within a month)."; +"CustomHelpPremium" = "Because you are a Telegram Premium subscriber, you will see the last seen and read time of all users who are sharing it with you – even if you are hiding yours."; +"CustomNotifications" = "Custom Notifications"; +"CustomP2PInfo" = "Peer-to-peer in calls will or will not be used with these users regardless of the settings above."; +"CustomShareGiftsInfo" = "You can add users or entire groups as exceptions that will override the settings above."; +"CustomShareInfo" = "These users will or will not be able to add you to groups and channels regardless of the settings above."; +"CustomShareSettingsHelp" = "You can add users or entire groups as exceptions that will override the settings above."; +"CustomSound" = "Custom Sound"; +"CustomThemes" = "Custom themes"; +"DartInfo" = "Send a **:dart:** emoji to try your luck."; +"DataSettings" = "Data and Storage"; +"DataUsage" = "Disk and network usage"; +"DataUsageByChats" = "Storage usage by chat"; +"DataUsageSectionsInfo" = "Tap on each section for details."; +"DateDay" = "Day"; +"DateMonth" = "Month"; +"DateOfBirth" = "Date Of Birth"; +"DateOfBirthAddToProfile" = "Add to My Profile"; +"DateOfBirthHideYear" = "Hide Year"; +"DateYear" = "Year"; +"DayAgo" = "a day ago"; +"Days#one" = "%1$d day"; +"Days#other" = "%1$d days"; +"DaysAgo#one" = "%d day ago"; +"DaysAgo#other" = "%d days ago"; +"DaysBold#one" = "**%1$d** day"; +"DaysBold#other" = "**%1$d** days"; +"DaysLeft#one" = "%1$d day left"; +"DaysLeft#other" = "%1$d days left"; +"DaysSchedule#one" = "%1$d day"; +"DaysSchedule#other" = "%1$d days"; +"Deactivate" = "DELETE NOW"; +"DebugAltNavigation" = "Alternative navigation"; +"DebugAltNavigationToggleControls" = "Toggle navigation stiffness controls"; +"DebugClearLocalDatabaseSuccess" = "Cleared local database."; +"DebugClearLogs" = "Clear Logs"; +"DebugClearSendMessageAsPeers" = "Clear Send Message As... peers"; +"DebugDialogsActivity" = "Chat Activity"; +"DebugDrawSnowInChat" = "Snow in chat"; +"DebugGeneral" = "General"; +"DebugMenu" = "Debug Menu"; +"DebugMenuCallSettings" = "Call settings"; +"DebugMenuCheckAppUpdate" = "Check app update"; +"DebugMenuClearMediaCache" = "Clear sent media cache"; +"DebugMenuClearWebViewCache" = "Clear WebView cache"; +"DebugMenuClearWebViewCookies" = "Clear WebView cookies"; +"DebugMenuDisableCamera" = "Disable In-App Camera"; +"DebugMenuDisableForceRtmpStreamFlag" = "Disable forced RTMP Stream Flag"; +"DebugMenuDisableLogs" = "Disable Logs"; +"DebugMenuDisablePauseMusic" = "Disable pause music when recording"; +"DebugMenuDisableSmoothKeyboard" = "Disable smooth keyboard"; +"DebugMenuDisableWebViewDebug" = "Disable WebView debug"; +"DebugMenuDontUseSystemBoldFont" = "Don't use Bold Font from System"; +"DebugMenuDualOff" = "Hide Dual Camera"; +"DebugMenuDualOffToast" = "Dual camera button is hidden"; +"DebugMenuDualOn" = "Show Dual Camera"; +"DebugMenuDualOnToast" = "Dual camera is enabled at your peril"; +"DebugMenuEnableCamera" = "Enable in-app camera"; +"DebugMenuEnableForceRtmpStreamFlag" = "Enable forced RTMP Stream Flag"; +"DebugMenuEnableLogs" = "Enable Logs"; +"DebugMenuEnablePauseMusic" = "Enable pause music when recording"; +"DebugMenuEnableSmoothKeyboard" = "Enable smooth keyboard"; +"DebugMenuEnableWebViewDebug" = "Enable WebView debug"; +"DebugMenuImportContacts" = "Import Contacts"; +"DebugMenuLoginToast#one" = "Tap %d more time to open the debug menu."; +"DebugMenuLoginToast#other" = "Tap %d more times to open the debug menu."; +"DebugMenuLongPress" = "Long press again for debug menu"; +"DebugMenuReadAllDialogs" = "Mark all chats as read"; +"DebugMenuReloadContacts" = "Reload Contacts"; +"DebugMenuResetContacts" = "Reset Imported Contacts"; +"DebugMenuResetDialogs" = "Reset Chats"; +"DebugMenuUseSystemBoldFont" = "Use Bold Font from System"; +"DebugMenuWebViewDebugDisabled" = "Debug disabled!"; +"DebugMenuWebViewDebugEnabled" = "Debug enabled!"; +"DebugMessageSkeletons" = "Message Skeletons"; +"DebugMessageSkeletonsLightOverlayAlpha" = "Light overlay alpha"; +"DebugMessageSkeletonsSaturation" = "Saturation"; +"DebugSendLastLogs" = "Send Last Logs"; +"DebugSendLogs" = "Send Logs"; +"DebugShareAlert" = "Share Alert"; +"DebugShareAlertDialogsModeLess" = "Less"; +"DebugShareAlertDialogsModeMore" = "More"; +"DebugShareAlertDialogsModeNormal" = "Normal"; +"DebugShareAlertSwitchDialogsMode" = "Switch sharing alert dialogs mode (%1$s)"; +"DebugShareAlertTopicsSlowMotion" = "Toggle sharing alert topics slow motion"; +"DebugTestBackend" = "Test Backend"; +"December" = "December"; +"Decline" = "Decline"; +"DeclineCall" = "Decline"; +"DeclineDeactivate" = "Decline & Delete"; +"DecreaseSpeed" = "Decrease speed"; +"Default" = "Default"; +"DefaultRingtone" = "Default"; +"Delete" = "Delete"; +"DeleteAccountHelp" = "If you do not come online at least once within this period, your account will be deleted along with all messages and contacts."; +"DeleteAccountIfAwayFor" = "If you're away for"; +"DeleteAccountIfAwayFor2" = "Delete my account if away for"; +"DeleteAccountIfAwayFor3" = "If away for"; +"DeleteAccountTitle" = "Self-Destruct if inactive for..."; +"DeleteAdditionalActions" = "Additional actions"; +"DeleteAlertReaction" = "Are you sure you want to delete the reaction from this user?"; +"DeleteAlertReactionAll" = "Delete all user reactions"; +"DeleteAll" = "Delete All"; +"DeleteAllCalls" = "Delete All Calls"; +"DeleteAllCallsText" = "Do you want to delete all recent calls?"; +"DeleteAllFrom" = "Delete all from %1$s"; +"DeleteAllFromUsers" = "Delete all from users"; +"DeleteAllMessagesAlert" = "Warning! This will **delete all messages** in this chat for **both** participants."; +"DeleteAllMessagesChannelAlert" = "Warning! This will **delete all messages** in this channel."; +"DeleteAllMessagesFrom" = "Delete all messages from %1$s"; +"DeleteAllMessagesFromUsers" = "Delete all their messages"; +"DeleteAllMessagesSavedAlert" = "Warning! This will **delete all messages** in this chat."; +"DeleteAllProxies" = "Delete All Proxies"; +"DeleteAllProxiesConfirm" = "Are you sure you want to delete all proxies?"; +"DeleteAllReactionsFrom" = "Delete all reactions from %1$s"; +"DeleteAllReactionsFromUsers" = "Delete all their reactions"; +"DeleteAllRevokedLinkHelp" = "This will delete all revoked links."; +"DeleteAllRevokedLinks" = "Delete All Revoked Links"; +"DeleteAndBlock" = "Delete and Block"; +"DeleteAndExit" = "Delete and Leave Group"; +"DeleteAndExitButton" = "Delete and leave group"; +"DeleteAndStop" = "Delete and block"; +"DeleteBackground#one" = "Delete wallpaper"; +"DeleteBackground#other" = "Delete %1$d wallpapers"; +"DeleteBan" = "Ban %1$s"; +"DeleteBanUser" = "Ban user"; +"DeleteBanUsers" = "Ban users"; +"DeleteBot" = "Delete Bot"; +"DeleteBotPreviewSubtitle" = "Are you sure you want to delete this preview?"; +"DeleteBotPreviewTitle" = "Delete Preview"; +"DeleteBotPreviewsSubtitle#one" = "Are you sure you want to delete %d preview?"; +"DeleteBotPreviewsSubtitle#other" = "Are you sure you want to delete %d previews?"; +"DeleteBotPreviewsTitle" = "Delete Previews"; +"DeleteCalls" = "Delete calls"; +"DeleteCallsForEveryone" = "Delete for everyone"; +"DeleteChannelForAll" = "Delete for all subscribers"; +"DeleteChannelForAll2" = "Delete Channel For All"; +"DeleteChat" = "Delete and leave"; +"DeleteChatBackgroundsAlert" = "Are you sure you want to delete the selected wallpapers?"; +"DeleteChatUser" = "Delete Chat"; +"DeleteContact" = "Delete Contact"; +"DeleteContactSubtitle" = "Are you sure you want to delete this contact?"; +"DeleteContactTitle" = "Delete contact"; +"DeleteContactsSubtitle" = "Are you sure you want to delete these contacts?"; +"DeleteContactsTitle#one" = "Delete %d contact"; +"DeleteContactsTitle#other" = "Delete %d contacts"; +"DeleteDays#one" = "Delete %1$s day"; +"DeleteDays#other" = "Delete %1$s days"; +"DeleteException" = "Delete Exception"; +"DeleteFewChatsTitle" = "Delete %1$s"; +"DeleteForAll" = "Delete for all members"; +"DeleteForUser" = "Also delete for %1$s"; +"DeleteFromFavorites" = "Delete from Favorites"; +"DeleteFromRecent" = "Remove from Recent"; +"DeleteGif" = "Remove GIF from this section?"; +"DeleteGroupForAll" = "Delete the group for all members"; +"DeleteHistoryByDaysMessage" = "Are you sure you want to delete all messages for the selected days?"; +"DeleteLink" = "Delete Link"; +"DeleteLinkHelp" = "This will delete link."; +"DeleteLocalization" = "Delete localization?"; +"DeleteLocalizationText" = "Are you sure you want to delete **%1$s** language pack?"; +"DeleteLocalizationTitle" = "Delete language pack"; +"DeleteMega" = "Delete Group"; +"DeleteMegaForAll" = "Delete Group For All"; +"DeleteMegaMenu" = "Delete group"; +"DeleteMessagesForBothSidesWherePossible" = "Delete for both sides where possible"; +"DeleteMessagesOption" = "Unsend My Messages"; +"DeleteMessagesOptionAlso" = "Also delete for %1$s"; +"DeleteMessagesOptionAlsoChat" = "Also clear for all members"; +"DeleteMessagesOptionsTitleAll" = "Delete All Messages?"; +"DeleteMessagesText" = "You can also delete the %1$s you sent from **%2$s**'s inbox by checking \"Unsend my messages\"."; +"DeleteMessagesTextGroup" = "You can also delete the %1$s you sent from the inboxes of other group members by checking \"Unsend my messages\"."; +"DeleteMessagesTextGroupPart" = "You can also delete the %1$s from the inboxes of other group members by checking \"Delete for all members\"."; +"DeleteMessagesTitle" = "Delete %1$s"; +"DeleteMyAccount" = "Delete my account"; +"DeleteOptionsTitle#one" = "Delete %1$d Message?"; +"DeleteOptionsTitle#other" = "Delete %1$d Messages?"; +"DeletePhoto" = "Remove Photo"; +"DeleteProceedBtn" = "Proceed"; +"DeleteProxy" = "Delete Proxy?"; +"DeleteProxyConfirm" = "Are you sure you want to delete this proxy?"; +"DeleteProxyMultiConfirm" = "Are you sure you want to delete these proxies?"; +"DeleteProxyTitle" = "Delete Proxy"; +"DeleteReaction" = "Delete Reaction"; +"DeleteReactionMenu" = "Delete Reaction"; +"DeleteReactionOptionsTitle#one" = "Delete %d Reaction?"; +"DeleteReactionOptionsTitle#other" = "Delete %d Reactions?"; +"DeleteReactionOptionsTitleAll" = "Delete All Reactions?"; +"DeleteReportSpam" = "Report spam"; +"DeleteRestrict" = "Restrict %1$s"; +"DeleteRestrictUsers" = "Restrict users"; +"DeleteSelectedCallsText" = "Do you want to delete selected calls from recent?"; +"DeleteSelectedTopic" = "Are you sure you want to delete %s?"; +"DeleteSelectedTopics" = "Are you sure you want to delete these topics?"; +"DeleteSingleMessagesTitle" = "Delete message"; +"DeleteSticker" = "Delete Sticker"; +"DeleteStickerSetsAlertTitle" = "Delete %1$s"; +"DeleteStickersAlertMessage" = "Are you sure you want to delete the selected sticker sets?"; +"DeleteStoriesSubtitle#one" = "Are you sure you want to delete %d story?"; +"DeleteStoriesSubtitle#other" = "Are you sure you want to delete %d stories?"; +"DeleteStoriesTitle" = "Delete Stories"; +"DeleteStorySubtitle" = "Are you sure you want to delete this story?"; +"DeleteStoryTitle" = "Delete Story"; +"DeleteTheme" = "Delete theme"; +"DeleteThemeAlert" = "Are you sure you want to delete this theme?"; +"DeleteThemeTitle" = "Delete theme"; +"DeleteTheseChats" = "Delete chats"; +"DeleteTheseChatsBothSides" = "Delete chats"; +"DeleteThisChat" = "Delete this chat"; +"DeleteThisChatBothSides" = "Delete this chat"; +"DeleteThisGroup" = "Delete group"; +"DeleteToggleBanUser" = "Fully ban this user"; +"DeleteToggleBanUsers" = "Fully ban users"; +"DeleteToggleRestrictUser" = "Partially restrict this user"; +"DeleteToggleRestrictUsers" = "Partially restrict users"; +"DeleteTones#one" = "Delete %d tone"; +"DeleteTones#other" = "Delete %d tones"; +"DeleteTonesMessage#one" = "Do you want to delete this notification tone?"; +"DeleteTonesMessage#other" = "Do you want to delete **%d notification tones**?"; +"DeleteTopics#one" = "Delete topic"; +"DeleteTopics#other" = "Delete topics"; +"DeletedFromYourContacts" = "%s deleted from your contacts"; +"Deletions#one" = "%1$d deletion"; +"Deletions#other" = "%1$d deletions"; +"DescriptionInfo" = "You can provide an optional description for your channel."; +"DescriptionMore" = "more"; +"DescriptionOptionalPlaceholder" = "Description (optional)"; +"DescriptionPlaceholder" = "Description"; +"Deselect" = "Deselect"; +"DeselectAll" = "Deselect All"; +"DetectedLanguage" = "Detected Language"; +"DeviceStorage" = "Device storage"; +"Devices" = "Devices"; +"DialogNotAvailable" = "Not available"; +"DialogPin" = "Pin"; +"DialogUnpin" = "Unpin"; +"DiceEmojiInfo" = "Send a %1$s emoji to try your luck."; +"DiceInfo" = "Send a 🎲 emoji to any chat to get a random number from Telegram."; +"DiceInfo2" = "Send a **:dice:** emoji to any chat to roll a die."; +"DidNotGetTheCode" = "Didn't get the code?"; +"DidNotGetTheCodeEditNumberButton" = "Edit number"; +"DidNotGetTheCodeFragment" = "Get a code via Fragment"; +"DidNotGetTheCodeHelpButton" = "Help"; +"DidNotGetTheCodeInfo" = "If you didn't get the code by SMS or call, please check your **cellular data settings** and phone number:\n\n**%1$s**\n\nYour remaining options are to try another number or to contact Telegram Support. Tap **Help** to send us the technical details so that we can identify the problem."; +"DidNotGetTheCodePhone" = "Get code on this phone"; +"DidNotGetTheCodeSms" = "Tap to get a code via SMS"; +"DirectChargeFee" = "Charge Fee"; +"DirectFee" = "%2$s pays **⭐️%1$d** per message."; +"DirectFeeNone" = "%1$s can send messages for free."; +"DirectRemoveFee" = "Remove Fee"; +"DirectShare" = "Direct Share"; +"DirectShareInfo" = "Show recent chats in Android share menu"; +"Directions" = "Directions"; +"Disable" = "Disable"; +"DisableAutoDeleteTimer" = "Disable Auto-Delete"; +"DisablePasscode" = "Turn Passcode Off"; +"DisablePasscodeConfirmMessage" = "Are you sure you want to disable your passcode?"; +"DisablePasscodeTurnOff" = "Turn Off"; +"DisablePhotoSpoiler" = "Remove spoiler"; +"DisableReactionsInfo" = "Members of the group can’t add any reactions to messages."; +"DisableSharing" = "Disable Sharing"; +"DisableSharingActionOther" = "%s disabled sharing in this chat"; +"DisableSharingActionStillDisabled" = "Sharing in this chat is still disabled"; +"DisableSharingActionStillEnabled" = "Sharing in this chat is still enabled"; +"DisableSharingActionYou" = "You disabled sharing in this chat"; +"DisableSharingInfoButton" = "Disable Sharing"; +"DisableSharingInfoHeader" = "Disable Sharing"; +"DisableSharingInfoHeader1" = "No Screenshots"; +"DisableSharingInfoHeader2" = "No Forwarding"; +"DisableSharingInfoHeader3" = "No Saving"; +"DisableSharingInfoText1" = "Disable screenshots and screen\nrecordings in this chat."; +"DisableSharingInfoText2" = "Disable forwarding messages to other chats."; +"DisableSharingInfoText3" = "Disable copying text and saving photos or videos."; +"DisableSharingOfferAccept" = "Accept"; +"DisableSharingOfferDecline" = "Reject"; +"DisableSharingToastDisabled" = "Sharing disabled for this chat"; +"DisableSharingToastDisabledPending" = "Sharing will be disabled if **%s** approves it"; +"DisableSharingToastEnabled" = "Sharing enabled for this chat"; +"DisableSharingToastEnabledPending" = "Sharing will be enabled if **%s** approves it"; +"DisappearingGif" = "Disappearing GIF"; +"DisappearingPhoto" = "Disappearing Photo"; +"DisappearingVideo" = "Disappearing Video"; +"Discard" = "Discard"; +"DiscardChanges" = "Discard changes"; +"DiscardPollOptionWithMediaAlertTitle" = "Delete Poll Option?"; +"DiscardPollOptionWithMediaMessage" = "This poll option has a media attachment. Do you want to delete it?"; +"DiscardQuizOptionWithMediaAlertTitle" = "Delete Quiz Answer?"; +"DiscardQuizOptionWithMediaMessage" = "This quiz answer has a media attachment. Do you want to delete it?"; +"DiscardSelectionAlertMessage" = "Do you want to close the attachment menu?"; +"DiscardSelectionAlertTitle" = "Discard selection"; +"DiscardTopic" = "Discard topic"; +"DiscardTopicMessage" = "The topic won't be created until you send a message.\n\nDo you want to discard this topic?"; +"DiscardVideoMessageDescription" = "Are you sure you want to stop recording and discard your video message?"; +"DiscardVideoMessageTitle" = "Discard Video Message"; +"DiscardVoiceMessageAction" = "Discard"; +"DiscardVoiceMessageDescription" = "Are you sure you want to stop recording and discard your voice message?"; +"DiscardVoiceMessageTitle" = "Discard Voice Message"; +"Disconnect" = "Disconnect"; +"DiscussChannel" = "channel"; +"Discussion" = "Discussion"; +"DiscussionChannelGroupSetHelp" = "A link to **%1$s** is shown to all subscribers in the bottom panel."; +"DiscussionChannelGroupSetHelp2" = "**%1$s** is selected as the group that hosts comments for your channel."; +"DiscussionChannelHelp" = "Select a group chat for discussion that will be displayed in your channel."; +"DiscussionChannelHelp2" = "Everything you post in the channel will be forwarded to this group."; +"DiscussionChannelHelp3" = "Select a group chat that will host comments from your channel."; +"DiscussionCreateGroup" = "Create a New Group"; +"DiscussionGroupHelp" = "This group is linked as the discussion board for %1$s."; +"DiscussionGroupHelp2" = "All new posts from this channel are forwarded to the group."; +"DiscussionInfo" = "Add a group chat for comments"; +"DiscussionInfoShort" = "Add"; +"DiscussionLinkGroup" = "Link Group"; +"DiscussionLinkGroupAlertHistory" = "\"Chat history for new members\" will be switched to Visible."; +"DiscussionLinkGroupPrivateAlert" = "Make **%1$s** the discussion board for **%2$s**?\n\nAny member of this group will be able to see messages in the channel."; +"DiscussionLinkGroupPublicAlert" = "Make **%1$s** the discussion group for **%2$s**?"; +"DiscussionLinkGroupPublicPrivateAlert" = "Make **%1$s** the discussion board for **%2$s**?\n\nAnyone from the channel will be able to see messages in this group."; +"DiscussionStarted" = "Discussion started"; +"DiscussionUnlink" = "Unlink"; +"DiscussionUnlinkChannel" = "Unlink Channel"; +"DiscussionUnlinkChannelAlert" = "Are you sure you want to unlink **%1$s** from this channel?"; +"DiscussionUnlinkGroup" = "Unlink Group"; +"DiscussionUnlinkGroupAlert" = "Are you sure you want to unlink **%1$s** from this group?"; +"Dismiss" = "Dismiss"; +"DismissRequest" = "Dismiss request"; +"DistanceUnits" = "Distance Units"; +"DistanceUnitsAutomatic" = "Automatic"; +"DistanceUnitsKilometers" = "Kilometers"; +"DistanceUnitsMiles" = "Miles"; +"DistanceUnitsTitle" = "Distance units"; +"DoNoSetTheme" = "Do Not Set Theme"; +"DoNotForward" = "Do Not Forward"; +"DoNotLinkPreview" = "Do Not Preview"; +"DoNotQuote" = "Do Not Quote"; +"DoNotReply" = "Do Not Reply"; +"DoNotTranslate" = "Do Not Translate"; +"DoNotTranslateLanguage" = "Don't translate %s"; +"DoNotTranslateLanguageOther" = "Don't translate %s"; +"DoNotUseSDCard" = "Do not use SD card"; +"DocumentsTitle" = "Shared Files"; +"Done" = "Done"; +"DontAskAgain" = "Don't ask again"; +"DoubleTapPreviewMessage" = "I hope you're enjoying your day as much as I am."; +"DoubleTapPreviewRational" = "Double tap this message for a quick reaction."; +"DoubleTapPreviewSenderName" = "Dino"; +"DoubleTapSetting" = "Quick Reaction"; +"DoubledLimits" = "Doubled Limits"; +"DownloadSpeedLimited" = "Download speed limited"; +"DownloadSpeedLimitedMessage" = "Subscribe to **Telegram Premium** to increase download speeds %d times."; +"DownloadedFiles" = "Downloaded files"; +"DownloadedFilesMessage" = "Telegram lets you store all documents you send and receive in the cloud and save storage space on your device."; +"Downloading" = "Downloading"; +"DownloadsTabs" = "Downloads"; +"Draft" = "Draft"; +"DualErrorMessage" = "Unfortunately, the Dual Camera mode is not supported on your device."; +"DualErrorTitle" = ":("; +"DynamicPackOrder" = "Dynamic Pack Order"; +"DynamicPackOrderInfo" = "Automatically place recently used sticker sets above others."; +"DynamicPackOrderOff" = "Dynamic Order off"; +"DynamicPackOrderOffInfo" = "Sticker sets will no longer be rearranged when you send a sticker."; +"Edit" = "Edit"; +"EditAccountInfo2" = "Account"; +"EditAccountInfoHeader" = "Your Info"; +"EditAdmin" = "Admin Rights"; +"EditAdminAddAdmins" = "Add New Admins"; +"EditAdminAddUsers" = "Add Users"; +"EditAdminAddUsersViaLink" = "Invite Users via Link"; +"EditAdminBanUsers" = "Ban Users"; +"EditAdminCantEdit" = "You can't edit the rights of this admin."; +"EditAdminChangeChannelInfo" = "Change Channel Info"; +"EditAdminChangeGroupInfo" = "Change Group Info"; +"EditAdminChannelTransfer" = "Transfer Channel Ownership"; +"EditAdminDeleteMessages" = "Delete Messages of Others"; +"EditAdminDeleteStories" = "Delete Stories of Others"; +"EditAdminEditMessages" = "Edit Messages of Others"; +"EditAdminEditStories" = "Edit Stories of Others"; +"EditAdminEditTags" = "Edit Member Tags"; +"EditAdminGroupDeleteMessages" = "Delete Messages"; +"EditAdminGroupTransfer" = "Transfer Group Ownership"; +"EditAdminManageDirect" = "Manage Direct Messages"; +"EditAdminPinMessages" = "Pin Messages"; +"EditAdminPostMessages" = "Post Messages"; +"EditAdminPostStories" = "Post Stories"; +"EditAdminProcessJoinRequests" = "Process Join Requests"; +"EditAdminProcessJoinRequestsInfo" = "Allow this bot to approve new members using its own interfaces, such as captchas."; +"EditAdminPromotedBy" = "Promoted by %1$s"; +"EditAdminRank" = "Member tag"; +"EditAdminRankInfo" = "A title that members will see instead of '%1$s'."; +"EditAdminRemoveAdmin" = "Dismiss Admin"; +"EditAdminRights" = "Edit admin rights"; +"EditAdminSendAnonymously" = "Remain Anonymous"; +"EditAdminTag" = "Edit Admin Tag"; +"EditAdminTransferAlertText" = "You can transfer this group to **%1$s** only if you:"; +"EditAdminTransferAlertText1" = "Enabled **Two-Step Verification** more than **7 days** ago."; +"EditAdminTransferAlertText2" = "Logged in on this device more than **24 hours** ago."; +"EditAdminTransferAlertText3" = "Please come back later."; +"EditAdminTransferAlertTitle" = "Security Check"; +"EditAdminTransferChangeOwner" = "Change Owner"; +"EditAdminTransferChannelToast" = "**%1$s** is now the owner of the channel."; +"EditAdminTransferGroupToast" = "**%1$s** is now the owner of the group."; +"EditAdminTransferReadyAlertText" = "This will transfer **full owner rights** for **%1$s** to **%2$s**. The new owner will be free to remove any of your admin privileges or even ban you."; +"EditAdminTransferReadyAlertText2" = "This will transfer full **owner rights** for **%1$s** to **%2$s**."; +"EditAdminTransferSetPassword" = "Set Password"; +"EditAdminWhatCanDo" = "What can this admin do?"; +"EditBotPreview" = "Edit Preview"; +"EditCantEditPermissions" = "You cannot change this permission."; +"EditCantEditPermissionsDiscussion" = "This permission is not available in discussion groups."; +"EditCantEditPermissionsPublic" = "This permission is not available in public groups."; +"EditCaption" = "Edit Caption"; +"EditChannelAdminTransferAlertText" = "You can transfer this channel to **%1$s** only if you have:"; +"EditContact" = "Edit Contact"; +"EditCurrentTheme" = "Edit Current Theme"; +"EditException" = "Edit Exception"; +"EditFactCheck" = "Edit Fact Check"; +"EditInfo" = "Edit Info"; +"EditLink" = "Edit link"; +"EditMemberRankInfo" = "Add short tag next to %1$s’s name."; +"EditMemberTag" = "Edit Member Tag"; +"EditMessage" = "Edit Message"; +"EditMessageEditPhoto" = "Edit this photo"; +"EditMessageEditVideo" = "Edit this video"; +"EditMessageError" = "Sorry, you can't edit this message."; +"EditMessageMedia" = "Tap to edit media"; +"EditMessageReplaceAudio" = "Replace audio file"; +"EditMessageReplaceFile" = "Replace file"; +"EditMessageReplaceGif" = "Replace media"; +"EditMessageReplacePhoto" = "Replace photo"; +"EditMessageReplaceVideo" = "Replace video"; +"EditName" = "Edit Name"; +"EditNumber" = "Edit number"; +"EditNumberInfo" = "Do you want to edit your number?\n\n**%1$s**\n\nIf the number above is correct, please wait for the verification code."; +"EditOfferAdd" = "Add Offer"; +"EditOfferMessage" = "Edit Message"; +"EditOfferPrice" = "Edit Price"; +"EditOfferTime" = "Edit Time"; +"EditPack" = "edit"; +"EditPhoto" = "Open in editor"; +"EditProfileBio" = "Your bio"; +"EditProfileBioHint" = "Write about yourself..."; +"EditProfileBioHint2" = "Bio"; +"EditProfileBioInfo" = "You can add a few lines about yourself. Choose who can see your bio in **Settings**."; +"EditProfileBioInfo2" = "A few words about you."; +"EditProfileBioInfoContacts" = "Your contacts can see your bio. **Change >**"; +"EditProfileBioInfoContactsExtra" = "Your contacts (%s) can see your bio. **Change >**"; +"EditProfileBioInfoEveryone" = "Everyone can see your bio. **Change >**"; +"EditProfileBioInfoEveryoneExcept" = "Everyone (except %d) can see your bio. **Change >**"; +"EditProfileBioInfoNobody" = "Nobody can see your bio. **Change >**"; +"EditProfileBioInfoNobodyExcept" = "Nobody (except %d) can see your bio. **Change >**"; +"EditProfileBioInfoUnknown" = "Choose who can see your bio in **Settings**."; +"EditProfileBirthday" = "Your birthday"; +"EditProfileBirthdayAdd" = "Add"; +"EditProfileBirthdayButton" = "Save"; +"EditProfileBirthdayInfo" = "Choose who can see your birthday in **Settings**."; +"EditProfileBirthdayInfoContacts" = "Only your contacts can see your birthday. **Change >**"; +"EditProfileBirthdayInfoRemove" = "**Remove birthday**."; +"EditProfileBirthdayRemove" = "Remove Birthday"; +"EditProfileBirthdayText" = "Birthday"; +"EditProfileBirthdayTitle" = "Birthday"; +"EditProfileChannel" = "Your channel"; +"EditProfileChannelAdd" = "Add"; +"EditProfileChannelHide" = "Hide Channel"; +"EditProfileChannelSelect" = "Select your channel"; +"EditProfileChannelSet" = "Personal channel added."; +"EditProfileChannelStartNew" = "Start a Channel"; +"EditProfileChannelTitle" = "Personal channel"; +"EditProfileChannelTitleAdd" = "Add Personal channel"; +"EditProfileChatAutomation" = "Chat Automation"; +"EditProfileChatAutomationInfo" = "Add a bot to answer messages on your behalf."; +"EditProfileColor" = "Edit Profile Color"; +"EditProfileFirstName" = "First name"; +"EditProfileHours" = "Change Opening Hours"; +"EditProfileInfo" = "Profile Info"; +"EditProfileLastName" = "Last name"; +"EditProfileLocation" = "Change Location"; +"EditProfileName" = "Your name"; +"EditSticker" = "Edit Sticker"; +"EditStickerPack" = "Edit Sticker Set"; +"EditStickers" = "Edit Stickers"; +"EditStory" = "Edit Story"; +"EditThemeColors" = "Edit colors"; +"EditThemeTitle" = "Edit Theme"; +"EditToDo" = "Edit"; +"EditTopic" = "Edit topic"; +"EditTopicHide" = "Show in Topics"; +"EditTopicHideInfo" = "If the 'General' topic is hidden, group members can pull down in the topic list to view it."; +"EditWidget" = "Edit Widget"; +"EditWidgetChatsInfo" = "Display the latest message from the chats you choose.\n\nIf no specific chats are selected, your most recent chats will be shown in this widget."; +"EditWidgetContactsInfo" = "Display shortcuts to your most important chats for quick access.\n\nIf nobody is selected, your most frequent contacts will be shown in this widget."; +"EditedMessage" = "edited"; +"EditorMuteHint" = "Sound is now muted, so the video will play and loop like a GIF."; +"EditorSetCover" = "Edit Cover"; +"EditorSetCoverGallery" = "Choose from Gallery"; +"EditorSetCoverSave" = "Save Cover"; +"EditorSetCoverTitle" = "Select Frame"; +"EmailAddressInvalid" = "Invalid email address."; +"EmailCopied" = "Email copied to clipboard."; +"EmailLogin" = "Login Email"; +"EmailLoginChangeMessage" = "This email address will be used every time you log in to your Telegram account from a new device."; +"EmailNotAllowed" = "This email domain is not allowed."; +"EmailPasswordConfirmText" = "Please follow these steps to complete Two-Step Verification Setup:\n\n1. Check your email (don't forget the spam folder)\n%1$s\n\n2. Click on the validation link."; +"EmailPasswordConfirmText2" = "To complete Two-Step Verification Setup, check your email (don't forget the spam folder) and enter the code we just sent you."; +"EmailPasswordConfirmText3" = "To complete reсoverу email setup, check %1$s (don't forget the spam folder) and enter the code we just sent you."; +"EmailTokenInvalid" = "Invalid email token."; +"Emoji" = "Emoji"; +"Emoji1" = "Emoji & People"; +"Emoji2" = "Animals and nature"; +"Emoji3" = "Food and drink"; +"Emoji4" = "Activity"; +"Emoji5" = "Travel and places"; +"Emoji6" = "Objects"; +"Emoji7" = "Symbols"; +"Emoji8" = "Flags"; +"EmojiArchived" = "Emoji archived."; +"EmojiArchivedInfo" = "%1$s has been moved to your archive."; +"EmojiBigSize" = "Draw single big emoji"; +"EmojiBotInfo" = "Artists are welcome to add their own emoji packs using our @stickers bot."; +"EmojiCopied" = "Emoji copied to clipboard."; +"EmojiCount#one" = "%1$d emoji"; +"EmojiCount#other" = "%1$d emoji"; +"EmojiCountButton#one" = "%1$d Emoji"; +"EmojiCountButton#other" = "%1$d Emoji"; +"EmojiInteractionTapHint" = "If **%s** had this chat open right now, they would also see the animation."; +"EmojiLongtapHint" = "Hold an emoji to set a timer"; +"EmojiPackCollectibles" = "Collectibles"; +"EmojiPackCount#one" = "%1$d Emoji Pack"; +"EmojiPackCount#other" = "%1$d Emoji Packs"; +"EmojiRemoved" = "Emoji removed."; +"EmojiRemovedInfo" = "%1$s is no longer in your emoji."; +"EmojiRemovedMultipleInfo#one" = "%1$d pack is no longer in your emoji."; +"EmojiRemovedMultipleInfo#other" = "%1$d packs are no longer in your emoji."; +"EmojiSearchBackToSearch" = "Back to search"; +"EmojiStatusExpireHint" = "Your status expires %s"; +"EmojiSuggestions" = "Emoji suggestions"; +"EmojiSuggestionsInfo" = "**Telegram** lets you find emoji by thousands of keywords, but there is always something missing. If you want to suggest a new emoji replacement, you can help us by visiting:"; +"EmojiSuggestionsUrl" = "https://translations.telegram.org/%1$s/emoji"; +"EmojiUseDefault" = "Use system default emoji"; +"EmpryUsersPlaceholder" = "Add Users"; +"EmptyExceptions" = "None"; +"Enable" = "Enable"; +"EnableAllReactionsInfo" = "Members of the group can use any emoji as reactions to messages."; +"EnableAllStreamingInfo" = "When possible, Telegram will start playing videos and music right away, without waiting for the files to fully download."; +"EnableAnimations" = "Enable Animations"; +"EnableAutoDelete" = "Enable Auto-Delete"; +"EnableLightMode" = "Enable lite mode"; +"EnablePasscode" = "Enable Passcode"; +"EnablePhotoSpoiler" = "Hide with spoiler"; +"EnableProxyAlert" = "Are you sure you want to enable this proxy?"; +"EnableProxyAlert2" = "You can change your proxy server later in Settings > Data and Storage."; +"EnableReactions" = "Enable Reactions"; +"EnableReactionsChannelInfo" = "Allow subscribers to react to channel posts."; +"EnableReactionsGroupInfo" = "Allow members to react to group messages."; +"EnableSharing" = "Enable Sharing"; +"EnableSharingActionOther" = "%s enabled sharing in this chat"; +"EnableSharingActionYou" = "You enabled sharing in this chat"; +"EnableSomeReactionsInfo" = "Members of the group can use only certain approved emoji as reactions to messages."; +"EnableStealthMode" = "Enable Stealth Mode"; +"EnableStealthModeAndOpenStory" = "Enable and open the story"; +"EnableStreaming" = "Stream Videos and Audio Files"; +"EnabledNotifications" = "Enabled Notifications"; +"EnabledPasswordText" = "You have enabled Two-Step verification.\nYou'll need the password you set up here to log in to your Telegram account."; +"EncryptedChatStartedIncoming" = "You joined the secret chat"; +"EncryptedChatStartedOutgoing" = "%s joined your secret chat"; +"EncryptedDescription1" = "Use end-to-end encryption"; +"EncryptedDescription2" = "Leave no trace on our servers"; +"EncryptedDescription3" = "Have a self-destruct timer"; +"EncryptedDescription4" = "Do not allow forwarding"; +"EncryptedDescriptionTitle" = "Secret chats:"; +"EncryptedPlaceholderTitleIncoming" = "%s invited you to join a secret chat."; +"EncryptedPlaceholderTitleOutgoing" = "You invited %s to join a secret chat."; +"EncryptionKey" = "Encryption Key"; +"EncryptionKeyDescription" = "This image and text were derived from the encryption key for this secret chat with **%1$s**.\n\nIf they look the same on **%2$s's** device, end-to-end encryption is guaranteed.\n\nLearn more at telegram.org"; +"EncryptionKeyLink" = "https://telegram.org/faq#secret-chats"; +"EncryptionProcessing" = "Exchanging encryption keys..."; +"EncryptionRejected" = "Secret chat canceled"; +"English" = "English"; +"Enhance" = "Enhance"; +"EnjoyngAnimations" = "**oo** watching %1$s"; +"EnterChannelName" = "Channel name"; +"EnterCode" = "Enter code"; +"EnterCurrentPasscode" = "Enter current passcode"; +"EnterGroupNamePlaceholder" = "Enter group name"; +"EnterListName" = "Enter list name"; +"EnterNewEmail" = "Enter New Email"; +"EnterNewFirstPasscode" = "Enter a passcode"; +"EnterNewPasscode" = "Enter new passcode"; +"EnterPIN" = "Enter your PIN"; +"EnterPINorFingerprint" = "Enter your PIN or use a fingerprint"; +"EnterPassword" = "Enter password"; +"EnterThemeName" = "Enter theme name"; +"EnterThemeNameEdit" = "Tap **Create** if you want to share your theme with others or customize each color manually.\n\nPlease enter theme name:"; +"EnterTopicName" = "What do you want to discuss?"; +"EnterYourPasscode" = "Enter your passcode"; +"EnterYourPasscodeInfo" = "Please enter your current Telegram passcode to manage this setting."; +"EnterYourTelegramPasscode" = "Enter your Telegram passcode"; +"ErrorOccurred" = "An error occurred."; +"ErrorRingtoneDurationTooLong" = "Maximum length is %s seconds."; +"ErrorRingtoneInvalidFormat" = "File must be in MP3 or Ogg format."; +"ErrorRingtoneSizeTooBig" = "Size must be under %s KB."; +"ErrorSendRestrictedDocuments" = "Sorry, the admins of this group have restricted you from sending documents."; +"ErrorSendRestrictedDocumentsAll" = "Sorry, sending documents is not allowed in this group."; +"ErrorSendRestrictedMedia" = "The admins of this group have restricted your ability to send media."; +"ErrorSendRestrictedMediaAll" = "Sorry, sending media is not allowed in this group."; +"ErrorSendRestrictedMusic" = "Sorry, the admins of this group have restricted you from sending music."; +"ErrorSendRestrictedMusicAll" = "Sorry, sending music is not allowed in this group."; +"ErrorSendRestrictedPhoto" = "Sorry, the admins of this group have restricted you from sending photos."; +"ErrorSendRestrictedPhotoAll" = "Sorry, sending photos is not allowed in this group."; +"ErrorSendRestrictedPolls" = "The admins of this group have restricted your ability to send polls."; +"ErrorSendRestrictedPollsAll" = "Sorry, sending polls is not allowed in this group."; +"ErrorSendRestrictedPrivacyVideoMessages" = "Sorry, you can't send video messages to this user because of their privacy settings."; +"ErrorSendRestrictedPrivacyVoiceMessages" = "Sorry, you can't send voice messages to this user because of their privacy settings."; +"ErrorSendRestrictedRound" = "Sorry, the admins of this group have restricted you from sending video messages."; +"ErrorSendRestrictedRoundAll" = "Sorry, sending video messages is not allowed in this group."; +"ErrorSendRestrictedStickers" = "The admins of this group have restricted your ability to send stickers."; +"ErrorSendRestrictedStickersAll" = "Sorry, sending stickers is not allowed in this group."; +"ErrorSendRestrictedTodo" = "Sorry, the admins of this group have restricted you from sending checklists."; +"ErrorSendRestrictedTodoAll" = "Sorry, sending checklists is not allowed in this group."; +"ErrorSendRestrictedVideo" = "Sorry, the admins of this group have restricted you from sending videos."; +"ErrorSendRestrictedVideoAll" = "Sorry, sending videos is not allowed in this group."; +"ErrorSendRestrictedVoice" = "Sorry, the admins of this group have restricted you from sending voice messages."; +"ErrorSendRestrictedVoiceAll" = "Sorry, sending voice messages is not allowed in this group."; +"EventLog" = "Recent Actions"; +"EventLogActivatedUsername" = "un1 activated %1$s username"; +"EventLogAdded" = "un1 added un2"; +"EventLogAddedMedia" = "un1 added media:"; +"EventLogAllAdmins" = "All admins"; +"EventLogAllEvents" = "All actions"; +"EventLogChangeUsernames" = "un1 changed group username from %1$s to %2$s"; +"EventLogChangedChannelLink" = "un1 changed the channel link:"; +"EventLogChangedColor" = "un1 changed channel color from %1$s to %2$s"; +"EventLogChangedColorGroup" = "un1 changed group color from %1$s to %2$s"; +"EventLogChangedEmoji" = "un1 changed channel emoji from %1$s to %2$s"; +"EventLogChangedEmojiGroup" = "un1 changed group emoji from %1$s to %2$s"; +"EventLogChangedEmojiPack" = "un1 changed the group emoji pack"; +"EventLogChangedEmojiStatus" = "un1 changed channel emoji status to %2$s"; +"EventLogChangedEmojiStatusFor" = "un1 changed channel emoji status to %2$s (for %3$s)"; +"EventLogChangedEmojiStatusForGroup" = "un1 changed group emoji status to %2$s (for %3$s)"; +"EventLogChangedEmojiStatusFrom" = "un1 changed channel emoji status from %1$s to %2$s"; +"EventLogChangedEmojiStatusFromFor" = "un1 changed channel emoji status from %1$s to %2$s (for %3$s)"; +"EventLogChangedEmojiStatusFromForGroup" = "un1 changed group emoji status from %1$s to %2$s (for %3$s)"; +"EventLogChangedEmojiStatusFromGroup" = "un1 changed group emoji status from %1$s to %2$s"; +"EventLogChangedEmojiStatusGroup" = "un1 changed group emoji status to %2$s"; +"EventLogChangedGroupLink" = "un1 changed the group link:"; +"EventLogChangedLinkedChannel" = "un1 linked this group to un2"; +"EventLogChangedLinkedGroup" = "un1 made un2 the discussion group for this channel"; +"EventLogChangedLocation" = "un1 changed the group location to \"%1$s\""; +"EventLogChangedOwnership" = "transferred ownership to %1$s"; +"EventLogChangedPeerColorIcon" = "un1 changed channel color and icon from %1$s to %2$s"; +"EventLogChangedPeerColorIconGroup" = "un1 changed group color and icon from %1$s to %2$s"; +"EventLogChangedProfileColorIcon" = "un1 changed channel profile color and icon from %1$s to %2$s"; +"EventLogChangedProfileColorIconGroup" = "un1 changed group profile color and icon from %1$s to %2$s"; +"EventLogChangedStickersSet" = "un1 changed the group sticker set"; +"EventLogChangedWallpaper" = "un1 changed channel wallpaper"; +"EventLogChangedWallpaperGroup" = "un1 changed group wallpaper"; +"EventLogChannelJoined" = "un1 joined the channel"; +"EventLogChannelRestricted" = "banned %1$s"; +"EventLogChannelUnrestricted" = "unbanned %1$s"; +"EventLogClosedTopic" = "%s closed topic un2"; +"EventLogCreateTopic" = "un1 created topic un2"; +"EventLogDeactivatedUsername" = "un1 deactivated %1$s username"; +"EventLogDefaultPermissions" = "changed default permissions"; +"EventLogDeleteTopic" = "un1 deleted topic un2"; +"EventLogDeletedMessages" = "un1 deleted this message:"; +"EventLogDeletedMultipleMessages#one" = "un1 deleted message from %2$s:"; +"EventLogDeletedMultipleMessages#other" = "un1 deleted %1$d messages from %2$s:"; +"EventLogDeletedMultipleMessagesHide" = "Hide all"; +"EventLogDeletedMultipleMessagesShow" = "Show all"; +"EventLogDeletedMultipleMessagesToExpand#one" = "un1 deleted message from %2$s"; +"EventLogDeletedMultipleMessagesToExpand#other" = "un1 deleted %1$d messages from %2$s"; +"EventLogDisabledAntiSpam" = "un1 disabled anti-spam"; +"EventLogEditTopic" = "un1 changed topic name from un2 to un3"; +"EventLogEditedCaption" = "un1 edited caption:"; +"EventLogEditedChannelDescription" = "un1 edited the channel description:"; +"EventLogEditedChannelPhoto" = "un1 set a new channel photo"; +"EventLogEditedChannelTheme" = "un1 changed the channel theme:"; +"EventLogEditedChannelTitle" = "un1 renamed the channel to \"%1$s\""; +"EventLogEditedChannelVideo" = "un1 set a new channel photo"; +"EventLogEditedGroupDescription" = "un1 edited the group description:"; +"EventLogEditedGroupPhoto" = "un1 set a new group photo"; +"EventLogEditedGroupTheme" = "un1 changed the group theme:"; +"EventLogEditedGroupTitle" = "un1 renamed the group to \"%1$s\""; +"EventLogEditedGroupVideo" = "un1 set a new group photo"; +"EventLogEditedMedia" = "un1 edited media:"; +"EventLogEditedMediaCaption" = "un1 edited media and caption:"; +"EventLogEditedMessages" = "un1 edited this message:"; +"EventLogEditedYouChannelPhoto" = "You set new channel photo"; +"EventLogEditedYouChannelTitle" = "You renamed the channel to \"%1$s\""; +"EventLogEditedYouGroupPhoto" = "You set new group photo"; +"EventLogEditedYouGroupTitle" = "You renamed the group to \"%1$s\""; +"EventLogEmojiNone" = "none"; +"EventLogEmpty" = "**No recent actions**\n\nNo notable actions taken by the members and admins of this group in the last 48 hours."; +"EventLogEmpty2" = "**No actions here yet**\n\nThe group's members and admins have not taken any service actions in the last **48** hours."; +"EventLogEmptyChannel" = "**No recent actions**\n\nNo notable actions taken\nby the admins of this channel\nin the last 48 hours."; +"EventLogEmptyChannel2" = "**No actions here yet**\n\nThe channel's admins have not taken any service actions in the last **48** hours."; +"EventLogEmptySearch" = "**No actions found**\n\nNo recent actions that match your query\nwere found."; +"EventLogEmptyTextSearch" = "No recent actions that contain '**%1$s**' were found."; +"EventLogEnabledAntiSpam" = "un1 enabled anti-spam"; +"EventLogEndedLiveStream" = "un1 ended the live stream"; +"EventLogEndedVoiceChat" = "un1 ended the video chat"; +"EventLogExpandMore#one" = "Show %d More Message"; +"EventLogExpandMore#other" = "Show %d More Messages"; +"EventLogFilterAll" = "All actions"; +"EventLogFilterApply" = "Apply Filter"; +"EventLogFilterByActions" = "Filter actions by type"; +"EventLogFilterByAdmins" = "Filter actions by admins"; +"EventLogFilterByAdminsAll" = "Show Actions by All Admins"; +"EventLogFilterCalls" = "Video Chats"; +"EventLogFilterChannelInfo" = "Channel info"; +"EventLogFilterChannelSettings" = "Channel settings"; +"EventLogFilterDeletedMessages" = "Deleted messages"; +"EventLogFilterEditedMessages" = "Edited messages"; +"EventLogFilterGroupInfo" = "Group info"; +"EventLogFilterInvites" = "Invite links"; +"EventLogFilterLeavingMembers" = "Members leaving"; +"EventLogFilterLeavingMembers2" = "Members left the Group"; +"EventLogFilterLeavingSubscribers2" = "Subscribers left the Channel"; +"EventLogFilterMembersRank" = "Members Tag Updates"; +"EventLogFilterNewAdmins" = "Admin rights"; +"EventLogFilterNewMembers" = "New members"; +"EventLogFilterNewRestrictions" = "New exceptions"; +"EventLogFilterNewSubscribers" = "New subscribers"; +"EventLogFilterPinnedMessages" = "Pinned messages"; +"EventLogFilterSectionAdmin" = "New Admin Rights"; +"EventLogFilterSectionChannelSettings" = "Channel Settings"; +"EventLogFilterSectionGroupSettings" = "Group Settings"; +"EventLogFilterSectionMembers" = "Members And Admins"; +"EventLogFilterSectionMessages" = "Messages"; +"EventLogFilterSectionSubscribers" = "Subscribers And Admins"; +"EventLogGroupJoined" = "un1 joined the group"; +"EventLogInfoDetail" = "This is a list of notable actions by members and admins in the last 48 hours."; +"EventLogInfoDetailChannel" = "This is a list of all service actions taken by the channel's admins in the last 48 hours."; +"EventLogInfoTitle" = "What are Recent Actions?"; +"EventLogLeft" = "un1 left the group"; +"EventLogLeftChannel" = "un1 left the channel"; +"EventLogLeftGroup" = "un1 left the group"; +"EventLogOriginalCaption" = "Original caption"; +"EventLogOriginalCaptionEmpty" = "Empty"; +"EventLogOriginalMessages" = "Original message"; +"EventLogPinTopic" = "un1 pinned topic un2"; +"EventLogPinnedMessages" = "un1 pinned this message:"; +"EventLogPreviousChannelLink" = "Previous channel link"; +"EventLogPreviousGroupDescription" = "Previous description"; +"EventLogPreviousGroupLink" = "Previous group link"; +"EventLogPreviousGroupTheme" = "Previous theme"; +"EventLogPreviousLink" = "Previous link"; +"EventLogPromoted" = "changed privileges of %1$s"; +"EventLogPromotedAddAdmins" = "Add admins"; +"EventLogPromotedAddUsers" = "Add users"; +"EventLogPromotedBanUsers" = "Ban users"; +"EventLogPromotedChangeChannelInfo" = "Change channel Info"; +"EventLogPromotedChangeGroupInfo" = "Edit group info"; +"EventLogPromotedDeleteMessages" = "Delete messages"; +"EventLogPromotedDeleteStories" = "Delete stories of others"; +"EventLogPromotedEditMessages" = "Edit messages"; +"EventLogPromotedEditRanks" = "Edit tags"; +"EventLogPromotedEditStories" = "Edit stories of others"; +"EventLogPromotedInviteLink" = "Invite via link"; +"EventLogPromotedManageCall" = "Manage video chats"; +"EventLogPromotedManageDirect" = "Manage direct"; +"EventLogPromotedManageTopics" = "Manage topics"; +"EventLogPromotedNoRights" = "promoted %1$s"; +"EventLogPromotedPinMessages" = "Pin messages"; +"EventLogPromotedPostMessages" = "Post messages"; +"EventLogPromotedPostStories" = "Post stories"; +"EventLogPromotedRemovedTitle" = "Title"; +"EventLogPromotedSendAnonymously" = "Remain Anonymous"; +"EventLogPromotedTitle" = "Title: %1$s"; +"EventLogRankAdd" = "un1 set un2’s tag to «%1$s»"; +"EventLogRankEdit" = "un1 changed un2’s tag from «%1$s» to «%2$s»"; +"EventLogRankRemove" = "un1 removed un2’s «%1$s» tag"; +"EventLogRankSelfAdd" = "un1 set their tag to «%1$s»"; +"EventLogRankSelfEdit" = "un1 changed their tag from «%1$s» to «%2$s»"; +"EventLogRankSelfRemove" = "un1 removed their «%1$s» tag"; +"EventLogRemoved" = "un1 removed un2"; +"EventLogRemovedChannelLink" = "un1 removed the channel link"; +"EventLogRemovedChannelPhoto" = "un1 removed the channel photo"; +"EventLogRemovedEmojiPack" = "un1 removed the group emoji pack"; +"EventLogRemovedGroupLink" = "un1 removed the group link"; +"EventLogRemovedLinkedChannel" = "un1 unlinked this group from un2"; +"EventLogRemovedLinkedGroup" = "un1 removed the discussion group un2"; +"EventLogRemovedLocation" = "un1 removed the group location"; +"EventLogRemovedStickersSet" = "un1 removed the group sticker set"; +"EventLogRemovedWGroupPhoto" = "un1 removed the group photo"; +"EventLogRemovedWallpaper" = "un1 removed channel wallpaper"; +"EventLogRemovedWallpaperGroup" = "un1 removed group wallpaper"; +"EventLogRemovedYouChannelPhoto" = "You removed channel photo"; +"EventLogRemovedYouGroupPhoto" = "You removed group photo"; +"EventLogReopenedTopic" = "%s reopened topic un2"; +"EventLogRestricted" = "changed restrictions for %1$s indefinitely"; +"EventLogRestrictedChangeInfo" = "Change info"; +"EventLogRestrictedEditRank" = "Edit Tags"; +"EventLogRestrictedInviteUsers" = "Add users"; +"EventLogRestrictedPinMessages" = "Pin messages"; +"EventLogRestrictedReadMessages" = "Read messages"; +"EventLogRestrictedSendEmbed" = "Embed links"; +"EventLogRestrictedSendMedia" = "Send media"; +"EventLogRestrictedSendMessages" = "Send messages"; +"EventLogRestrictedSendPolls" = "Send polls"; +"EventLogRestrictedSendReactions" = "Send Reactions"; +"EventLogRestrictedSendStickers" = "Send stickers & GIFs"; +"EventLogRestrictedUntil" = "changed permissions for %1$s\n\nDuration: %2$s"; +"EventLogSelectedEvents" = "selected actions"; +"EventLogSendMessages" = "un1 sent this message:"; +"EventLogStartedLiveStream" = "un1 started a live stream"; +"EventLogStartedVoiceChat" = "un1 started a video chat"; +"EventLogStopPoll" = "un1 stopped this poll:"; +"EventLogStopQuiz" = "un1 stopped the quiz:"; +"EventLogSubExtend" = "un1 renewed subscription"; +"EventLogSwitchToForum" = "un1 enabled topics"; +"EventLogSwitchToGroup" = "un1 disabled topics"; +"EventLogToggledAutotranslationOff" = "un1 disabled auto-translation"; +"EventLogToggledAutotranslationOn" = "un1 enabled auto-translation"; +"EventLogToggledInvitesHistoryOff" = "un1 made the group history hidden for new members"; +"EventLogToggledInvitesHistoryOn" = "un1 made the group history visible for new members"; +"EventLogToggledInvitesOff" = "un1 disabled group invites"; +"EventLogToggledInvitesOn" = "un1 enabled group invites"; +"EventLogToggledSignaturesOff" = "un1 disabled signatures"; +"EventLogToggledSignaturesOn" = "un1 enabled signatures"; +"EventLogToggledSignaturesProfilesOff" = "un1 disabled author profiles"; +"EventLogToggledSignaturesProfilesOn" = "un1 enabled author profiles"; +"EventLogToggledSlowmodeOff" = "un1 disabled the slow mode"; +"EventLogToggledSlowmodeOn" = "un1 set the slow mode timer to %1$s"; +"EventLogUnpinTopic" = "un1 unpinned topic un2"; +"EventLogUnpinnedMessages" = "un1 unpinned a message"; +"EventLogVoiceChatAllowedToSpeak" = "un1 allowed new video chat participants to speak"; +"EventLogVoiceChatMuted" = "un1 muted un2 in a video chat"; +"EventLogVoiceChatNotAllowedToSpeak" = "un1 muted new video chat participants"; +"EventLogVoiceChatUnmuted" = "un1 unmuted un2 in a video chat"; +"EventLogYouChannelJoined" = "You joined channel"; +"EventLogYouGroupJoined" = "You joined group"; +"EventLogYouLeft" = "You left group"; +"EventLogYouPromoted" = "You changed privileges of un2 (%1$s)"; +"EventLogYouRestricted" = "You changed restrictions for un2 (%1$s)"; +"Events" = "Events"; +"Everyone" = "Everyone"; +"ExactTextCopied" = "\"%s\" copied to clipboard"; +"Exception#one" = "%1$d exception"; +"Exception#other" = "%1$d exceptions"; +"ExceptionShort#one" = "%d exception"; +"ExceptionShort#other" = "%d exceptions"; +"ExpireAfter" = "Expire after"; +"Expired" = "Expired"; +"ExpiredLink" = "Expired Link"; +"ExpiredStory" = "Expired story"; +"ExpiredStoryMention" = "The story you were mentioned in is no longer available"; +"ExpiredStoryMentioned" = "The story where you mentioned %s is no longer available"; +"ExpiredViewsStub" = "List of viewers becomes unavailable **24 hours** after the story expires."; +"ExpiredViewsStubPremiumDescription" = "To unlock viewer lists for expired and posted stories, subscribe to **Telegram Premium**."; +"ExplainStarsButton" = "Got It"; +"ExplainStarsFeature1Text" = "Give your friends gifts that can be added to their profiles or converted to Stars."; +"ExplainStarsFeature1Title" = "Send Gifts to Friends"; +"ExplainStarsFeature2Text" = "Buy additional content and services in Telegram mini apps. **See Examples >**"; +"ExplainStarsFeature2Title" = "Use Stars in mini apps"; +"ExplainStarsFeature3Text" = "Get access to paid content and services in Telegram channels."; +"ExplainStarsFeature3Title" = "Unlock content in channels"; +"ExplainStarsFeature4Text" = "Support your favorite channels by sending Star reactions to their posts."; +"ExplainStarsFeature4Title" = "Send Star reactions"; +"ExplainStarsTitle" = "What are Stars?"; +"ExplainStarsTitle2" = "Telegram Stars allow users to:"; +"ExportTheme" = "Export"; +"Exposure" = "Exposure"; +"ExternalFolderInfo" = "Browse your external storage"; +"ExternalStorage" = "External Storage"; +"FactCheck" = "Fact Check"; +"FactCheckDeleted" = "Fact check removed."; +"FactCheckDialog" = "Fact Check"; +"FactCheckEdited" = "Fact check updated."; +"FactCheckFooter" = "This clarification was provided by Telegram."; +"FactCheckPlaceholder" = "Add Fact or Context"; +"FactCheckToast" = "This clarification was provided by Telegram."; +"FactCheckWhat" = "what’s this?"; +"Fade" = "Fade"; +"FailedStory" = "Failed"; +"FailedToUploadStory" = "failed to upload"; +"FakeMessage" = "FAKE"; +"FallbackTooltip" = "public photo"; +"FavoriteStickers" = "Favorites"; +"FavoriteStickersLimitSubtitle" = "Save up to %d stickers in your Favorite stickers"; +"FavoriteStickersLimitTitle" = "Favorite Stickers"; +"FavoriteStickersShort" = "Favorites"; +"FeaturePreviewGifts" = "Telegram Gifts"; +"FeaturePreviewGiftsDescription" = "Gifts are collectible items you can trade or showcase on your profile."; +"FeaturedEmojiPacks" = "Trending Emoji Packs"; +"FeaturedGifs" = "Trending GIFs"; +"FeaturedStickers" = "Trending Stickers"; +"FeaturedStickersInfo" = "These stickers are currently trending on Telegram. You can add custom stickers via the @stickers bot."; +"FeaturedStickersPremium" = "Trending Premium Stickers"; +"FeaturedStickersShort" = "Trending"; +"FeaturesBtn" = "Features"; +"February" = "February"; +"FileSavedHint" = "File saved to Downloads."; +"FileSavedHintLinked" = "File saved to **downloads**."; +"FileTooLarge" = "File Too Large"; +"FileUploadLimit" = "File size shouldn't be greater than %1$s"; +"Files#one" = "%1$d file"; +"Files#other" = "%1$d files"; +"FilesDataUsage" = "Files"; +"FilesReceived" = "Files received"; +"FilesReceivedCount#one" = "**%s** file received"; +"FilesReceivedCount#other" = "**%s** files received"; +"FilesSavedHint#one" = "File saved to Downloads."; +"FilesSavedHint#other" = "%1$d files saved to Downloads."; +"FilesSavedHintLinked#one" = "File saved to **downloads**."; +"FilesSavedHintLinked#other" = "%1$d files saved to **downloads**."; +"FilesSent" = "Files sent"; +"FilesSentCount#one" = "**%s** file sent"; +"FilesSentCount#other" = "**%s** files sent"; +"FilterAddChats" = "Add Chats"; +"FilterAddException" = "Add Exception"; +"FilterAddTo" = "Add to folder"; +"FilterAddToAlertFullText" = "Sorry, you can't exclude more than 100 chats from a folder."; +"FilterAddToAlertFullTitle" = "Limit reached"; +"FilterAddingChats" = "Adding chats"; +"FilterAddingChatsInfo" = "Please wait a few moments while\nwe fill this folder for you..."; +"FilterAllChats" = "All Chats"; +"FilterAllChatsShort" = "All"; +"FilterAlwaysShow" = "Include Chats"; +"FilterArchived" = "Archived"; +"FilterAvailableText" = "Open *Settings > Folders* to organize your chats into folders."; +"FilterAvailableTitle" = "Chat folders"; +"FilterBots" = "Bots"; +"FilterChannels" = "Channels"; +"FilterChatAddedToExisting" = "**%1$s** added to **%2$s**."; +"FilterChatRemovedFrom" = "**%1$s** removed from **%2$s**."; +"FilterChatTypes" = "Chat types"; +"FilterChats" = "Chats"; +"FilterChatsAddedToExisting" = "**%1$s** added to **%2$s**."; +"FilterChatsRemovedFrom" = "**%1$s** removed from **%2$s**."; +"FilterChoose" = "Choose a folder"; +"FilterContact" = "Contact"; +"FilterContacts" = "Contacts"; +"FilterDelete" = "Remove Folder"; +"FilterDeleteAlert" = "Are you sure you want to remove this folder? Your chats will not be deleted."; +"FilterDeleteAlertLinks" = "Are you sure you want to delete this folder? This will also deactivate all the invite links created to share this folder."; +"FilterDeleteItem" = "Delete folder"; +"FilterDiscardAlert" = "You have edited this folder. Apply changes?"; +"FilterDiscardNewAlert" = "You have not finished creating the folder yet. Create now?"; +"FilterDiscardNewSave" = "Create"; +"FilterDiscardNewTitle" = "Create Folder"; +"FilterDiscardTitle" = "Unsaved Changes"; +"FilterEdit" = "Edit folder"; +"FilterEditAll" = "Edit folders"; +"FilterEditItem" = "Edit folder"; +"FilterExclude" = "Excluded Chats"; +"FilterExcludeArchived" = "Exclude Archived"; +"FilterExcludeInfo" = "Choose chats or types of chats that will not appear in this folder."; +"FilterExcludeMuted" = "Exclude Muted"; +"FilterExcludeRead" = "Exclude Read"; +"FilterExistingChats" = "Existing chats"; +"FilterFinishCreating" = "Please finish creating this folder to share it."; +"FilterGroups" = "Groups"; +"FilterInclude" = "Included Chats"; +"FilterIncludeInfo" = "Choose chats or types of chats that will appear in this folder."; +"FilterInviteBot" = "chats with bots can’t be shared"; +"FilterInviteBotToast" = "Bots can't be shared via folders."; +"FilterInviteChannel" = "you can’t invite users here"; +"FilterInviteChannelToast" = "You don’t have the admin rights to share invite links to this channel."; +"FilterInviteChats#one" = "Includes %d chat"; +"FilterInviteChats#other" = "Includes %d chats"; +"FilterInviteEditName" = "Name this link"; +"FilterInviteErrorEmpty" = "You can’t share empty folders."; +"FilterInviteErrorEmptyName" = "You can’t share folders without a name."; +"FilterInviteErrorExcluded" = "You can’t share folders that exclude chats."; +"FilterInviteErrorTypes" = "You can’t share folders which include or exclude specific chat types like 'Groups', 'Contacts', etc."; +"FilterInviteErrorTypesExcluded" = "Folders that filter by chat type or exclude chats can't be shared."; +"FilterInviteGroup" = "you can’t invite users here"; +"FilterInviteGroupToast" = "You don’t have the admin rights to share invite links to this group."; +"FilterInviteHeader#one" = "Anyone with this link can add **%2$s** folder and the **%1$d** chat selected below."; +"FilterInviteHeader#other" = "Anyone with this link can add **%2$s** folder and the **%1$d** chats selected below."; +"FilterInviteHeaderChats#one" = "%d chat selected"; +"FilterInviteHeaderChats#other" = "%d chats selected"; +"FilterInviteHeaderChatsEmpty" = "No chats selected"; +"FilterInviteHeaderChatsNo" = "These chats cannot be shared"; +"FilterInviteHeaderNo" = "There are no chats in this folder that you can share with others."; +"FilterInviteHint" = "Everyone who adds the folder via this link will be able to join the chats you select."; +"FilterInviteHintNo" = "You can only share groups and channels in which you are allowed to create invite links."; +"FilterInviteLinks" = "Invite Links"; +"FilterInviteLinksHint" = "Create more links to set up different access levels for different people."; +"FilterInviteLinksHintNew" = "Share access to some of this folder’s groups and channels with others."; +"FilterInviteNameEdited" = "Link Name saved."; +"FilterInvitePrivateChannelToast" = "You don’t have the admin rights to share invite links to this private channel."; +"FilterInvitePrivateGroupToast" = "You don’t have the admin rights to share invite links to this private group."; +"FilterInviteUser" = "chats with users can't be shared"; +"FilterInviteUserToast" = "Users can't be shared via folders."; +"FilterMuteAll" = "Mute All"; +"FilterMuted" = "Muted"; +"FilterNameAnimationsDisable" = "Disable Animations"; +"FilterNameAnimationsEnable" = "Enable Animations"; +"FilterNameHeader" = "Folder name"; +"FilterNameHint" = "Folder name"; +"FilterNameNonMuted" = "Not Muted"; +"FilterNameUnread" = "Unread"; +"FilterNeverShow" = "Exclude Chats"; +"FilterNew" = "New Folder"; +"FilterNewChats" = "New chats"; +"FilterNoChats" = "No Chats"; +"FilterNoChatsToDisplay" = "Folder is empty"; +"FilterNoChatsToDisplayInfo" = "No chats currently belong to this folder."; +"FilterNoChatsToForward" = "No chats found"; +"FilterNoChatsToForwardInfo" = "No chats in this folder to forward to."; +"FilterNonContact" = "Non-Contact"; +"FilterNonContacts" = "Non-Contacts"; +"FilterRead" = "Read"; +"FilterRecommended" = "Recommended Folders"; +"FilterRemoveChats" = "Add Chats to Exclude"; +"FilterRemoveExclusionChatText" = "Are you sure you want to remove \"%1$s\" from the always excluded list?"; +"FilterRemoveExclusionText" = "Are you sure you want to remove \"%1$s\" from the always excluded list?"; +"FilterRemoveExclusionTitle" = "Remove exception"; +"FilterRemoveExclusionUserText" = "Are you sure you want to remove \"%1$s\" from the always excluded list?"; +"FilterRemoveFrom" = "Remove from folder"; +"FilterRemoveFromAlertFullText" = "Sorry, you can't add more than 100 chats to a folder."; +"FilterRemoveInclusionChatText" = "Are you sure you want to remove \"%1$s\" from the always included list?"; +"FilterRemoveInclusionText" = "Are you sure you want to remove \"%1$s\" from the always included list?"; +"FilterRemoveInclusionTitle" = "Remove exception"; +"FilterRemoveInclusionUserText" = "Are you sure you want to remove \"%1$s\" from the always included list?"; +"FilterReorder" = "Reorder"; +"FilterSecretChats" = "Secret Chats"; +"FilterShare" = "Share Folder"; +"FilterShareFolder" = "Share folder"; +"FilterShareFolderButton" = "Create an Invite Link"; +"FilterShowMoreChats#one" = "Show %1$d More Chat"; +"FilterShowMoreChats#other" = "Show %1$d More Chats"; +"FilterStories" = "Stories"; +"FilterUnmuteAll" = "Unmute All"; +"FilterUnread" = "Unread"; +"FilterUserAddedToExisting" = "**%1$s** added to **%2$s**."; +"FilterUserRemovedFrom" = "**%1$s** removed from **%2$s**."; +"Filters" = "Chat Folders"; +"FiltersSetup" = "Setup"; +"FiltersSetupPinAlert" = "Set Up Folders"; +"FinalResults" = "Final Results"; +"FingerprintHelp" = "Touch sensor"; +"FingerprintInfo" = "Confirm fingerprint to continue"; +"FingerprintNotRecognized" = "Fingerprint not recognized, try again."; +"FirstName" = "First name (required)"; +"FirstNameSmall" = "First name"; +"FlashIntensity" = "Intensity"; +"FlashWarmth" = "Warmth"; +"Flip" = "Flip"; +"FloatingDebugDisable" = "Disable floating debug"; +"FloatingDebugEnable" = "Enable floating debug"; +"FloodWait" = "Too many attempts, please try again later."; +"FloodWaitTime" = "Too many attempts, please try again in %1$s"; +"Folder" = "Folder"; +"FolderLinkAddedChats#one" = "Chat added to folder"; +"FolderLinkAddedChats#other" = "%d chats added to folder"; +"FolderLinkAddedSubtitle#one" = "You also joined %d chat."; +"FolderLinkAddedSubtitle#other" = "You also joined %d chats."; +"FolderLinkAddedTitle" = "Folder **%s** Added"; +"FolderLinkAlreadyJoined" = "You are already a member of this group."; +"FolderLinkAlreadySubscribed" = "You are already subscribed to this channel."; +"FolderLinkButtonAdd" = "Add %s"; +"FolderLinkButtonJoin" = "Join chats"; +"FolderLinkButtonJoinPlural#one" = "Join chat"; +"FolderLinkButtonJoinPlural#other" = "Join chats"; +"FolderLinkButtonNone" = "Do not join any chats"; +"FolderLinkButtonRemove" = "Remove Folder"; +"FolderLinkButtonRemoveChats" = "Remove Folder and Chats"; +"FolderLinkChatlistUpdate" = "This will not affect the chat list of the links of this folder."; +"FolderLinkDeleted" = "Folder **%s** deleted."; +"FolderLinkDeletedSubtitle#one" = "You also left %d chat."; +"FolderLinkDeletedSubtitle#other" = "You also left %d chats."; +"FolderLinkDeletedTitle" = "Folder %s Deleted"; +"FolderLinkHeaderAlready" = "Chats you already joined"; +"FolderLinkHeaderChatsJoin#one" = "%d chat to join"; +"FolderLinkHeaderChatsJoin#other" = "%d chats to join"; +"FolderLinkHeaderChatsQuit#one" = "%d chat to leave"; +"FolderLinkHeaderChatsQuit#other" = "%d chats to leave"; +"FolderLinkHint" = "You can deselect the chats you don’t want to join."; +"FolderLinkHintRemove" = "You can select the chats you want to leave."; +"FolderLinkNoChatsError" = "You don’t have access to share any of these chats."; +"FolderLinkOtherAdminLimitError" = "One of the groups in this folder can’t be added because one of its admins has too many groups and channels."; +"FolderLinkPreviewLeft" = "All chats"; +"FolderLinkPreviewRight" = "Personal"; +"FolderLinkRemovedChats#one" = "Chat removed from folder"; +"FolderLinkRemovedChats#other" = "%d chats removed from folder"; +"FolderLinkShareButton" = "Create Invite Link"; +"FolderLinkShareSubtitle" = "You can create more links to set up different access levels for different people."; +"FolderLinkShareSubtitleEmpty" = "Share access to some of this folder's groups and channels with others."; +"FolderLinkShareTitle" = "Share Folder %s"; +"FolderLinkShareTitle2" = "Share Folder %1$s"; +"FolderLinkSubtitle" = "Do you want to add the chat folder **%s** and join its groups and channels?"; +"FolderLinkSubtitleAlready" = "You have already added the folder **%s** and all its chats."; +"FolderLinkSubtitleChats#one" = "Do you want to join **%1$d chat** and add it to the folder **%2$s**?"; +"FolderLinkSubtitleChats#other" = "Do you want to join **%1$d chats** and add them to the folder **%2$s**?"; +"FolderLinkSubtitleRemove" = "Do you also want to leave the chats included in this folder?"; +"FolderLinkTitleAdd" = "Add Folder"; +"FolderLinkTitleAddChats" = "Add Chats to Folder"; +"FolderLinkTitleAlready" = "Folder already added"; +"FolderLinkTitleRemove" = "Remove Folder"; +"FolderLinkUpdatedJoinedSubtitle#one" = "You joined %d new chat."; +"FolderLinkUpdatedJoinedSubtitle#other" = "You joined %d new chats."; +"FolderLinkUpdatedSubtitle#one" = "You have added %d new chat."; +"FolderLinkUpdatedSubtitle#other" = "You have added %d new chats."; +"FolderLinkUpdatedTitle" = "Folder **%s** Updated"; +"FolderShowTags" = "Show Folder Tags"; +"FolderShowTagsInfo" = "Display folder names for each chat in the chat list."; +"FolderShowTagsInfoPremium" = "Subscribe to **Telegram Premium** to display folder names for each chat in the chat list."; +"FolderTagColor" = "Folder color in chat list"; +"FolderTagColorInfo" = "Choose a color for the tag of this folder."; +"FolderTagNoColor" = "No Tag"; +"FolderTagNoColorPremium" = "Premium expired"; +"FolderUpdatesSubtitle#one" = "Tap here to view it"; +"FolderUpdatesSubtitle#other" = "Tap here to view them"; +"FolderUpdatesTitle#one" = "You can join **%d new chat**"; +"FolderUpdatesTitle#other" = "You can join **%d new chats**"; +"FoldersLimitSubtitle" = "Organize your chats into %d folders"; +"FoldersLimitTitle" = "Folders"; +"FollowersChartTitle" = "Followers"; +"FontSize" = "Font Size"; +"FontSizePreviewLine1" = "Do you know what time it is?"; +"FontSizePreviewLine2" = "It's morning in Tokyo 😎"; +"FontSizePreviewName" = "Bob Harris"; +"FontSizePreviewReply" = "Good morning! 👋"; +"FontType" = "Font Type"; +"FootsAway" = "%1$s ft away"; +"FootsFromYou" = "%1$s ft from\u00a0you"; +"FootsShort" = "%1$s ft"; +"ForceSetPasswordAlertMessage#one" = "Proceed without a password? If you do not set a password, you will only be able to log into your account via SMS once every %1$s day."; +"ForceSetPasswordAlertMessage#other" = "Proceed without a password? If you do not set a password, you will only be able to log into your account via SMS once every %1$s days."; +"ForceSetPasswordAlertMessageShort#one" = "If you do not set a password, you will only be able to log into your account via SMS once every %1$s day. Do you want to proceed without a password?"; +"ForceSetPasswordAlertMessageShort#other" = "If you do not set a password, you will only be able to log into your account via SMS once every %1$s days. Do you want to proceed without a password?"; +"ForceSetPasswordCancel" = "Yes, I'm sure"; +"ForceSetPasswordContinue" = "No, let me set a password"; +"ForgotPasscode" = "Forgot passcode?"; +"ForgotPasscodeInfo" = "If you forgot your passcode, please delete and reinstall the app.\n\nAll secret chats will be lost."; +"ForgotPassword" = "Forgot password?"; +"FormattedDate" = "Date"; +"ForumReplyToMessagesInTopic" = "Swipe left on a message to reply"; +"ForumToggleDescription" = "The group chat will be divided into topics created by admins or users."; +"Forward" = "Forward"; +"ForwardAnotherChat" = "Forward to another chat"; +"ForwardDialogYourChannel" = "Your Channel"; +"ForwardGroupMedia" = "Forward media"; +"ForwardGroupMediaMessage" = "Would you like to forward..."; +"ForwardPreviewSendersNameHidden" = "%s won't see they were forwarded"; +"ForwardPreviewSendersNameHiddenChannel" = "Recipients won't see they were forwarded"; +"ForwardPreviewSendersNameHiddenGroup" = "Recipients won't see they were forwarded"; +"ForwardPreviewSendersNameVisible" = "%s will see that it was forwarded"; +"ForwardPreviewSendersNameVisibleChannel" = "Recipients will see that it was forwarded"; +"ForwardPreviewSendersNameVisibleGroup" = "Recipients will see that it was forwarded"; +"ForwardSendMessages" = "Send Messages"; +"ForwardTo" = "Forward to..."; +"ForwardedAudio#one" = "Forwarded voice message"; +"ForwardedAudio#other" = "%1$d forwarded voice messages"; +"ForwardedContact#one" = "Forwarded contact"; +"ForwardedContact#other" = "%1$d forwarded contacts"; +"ForwardedFile#one" = "Forwarded file"; +"ForwardedFile#other" = "%1$d forwarded files"; +"ForwardedFrom" = "Forwarded from"; +"ForwardedLocation#one" = "Forwarded location"; +"ForwardedLocation#other" = "%1$d forwarded locations"; +"ForwardedMessage" = "Forwarded message"; +"ForwardedMessageCount#one" = "Forwarded message"; +"ForwardedMessageCount#other" = "%1$d forwarded messages"; +"ForwardedMusic#one" = "Forwarded audio file"; +"ForwardedMusic#other" = "%1$d forwarded audio files"; +"ForwardedPhoto#one" = "Forwarded photo"; +"ForwardedPhoto#other" = "%1$d forwarded photos"; +"ForwardedPoll#one" = "Forwarded poll"; +"ForwardedPoll#other" = "%1$d forwarded polls"; +"ForwardedRound#one" = "Forwarded video message"; +"ForwardedRound#other" = "%1$d forwarded video messages"; +"ForwardedSticker#one" = "Forwarded sticker"; +"ForwardedSticker#other" = "%1$d forwarded stickers"; +"ForwardedStory" = "Forwarded Story"; +"ForwardedStoryFrom" = "From **%s**"; +"ForwardedVideo#one" = "Forwarded video"; +"ForwardedVideo#other" = "%1$d forwarded videos"; +"ForwardingFromNameAndMessage" = "%s: %s"; +"ForwardingFromNames" = "From %s"; +"ForwardsRestrictedInfoBot" = "Copying and forwarding is not allowed from this chat."; +"ForwardsRestrictedInfoChannel" = "Сopying and forwarding is not allowed in this channel."; +"ForwardsRestrictedInfoGroup" = "Сopying and forwarding is not allowed from this group."; +"ForwardsRestrictedInfoUser" = "Сopying and forwarding is not allowed for this user."; +"ForwardsRestrictedInfoUserBecauseUser" = "**%s** disabled copying and forwarding in this chat."; +"ForwardsRestrictedInfoUserBecauseYou" = "You disabled copying and forwarding in this chat."; +"FoundStories#one" = "%d story"; +"FoundStories#other" = "%d stories"; +"Fragment" = "Fragment"; +"FragmentAddFunds" = "You can add Grams to your balance via the third-party platform Fragment."; +"FragmentChannelUsername" = "The **%1$s** username was acquired on **Fragment** on %2$s for **%3$s** %4$s."; +"FragmentPhoneCopy" = "Copy Phone Number"; +"FragmentPhoneMessage" = "This phone number was bought on **Fragment** on %1$s for **%2$s** %3$s"; +"FragmentPhoneTitle" = "**%s** is a collectible phone number that belongs to"; +"FragmentUsernameCopy" = "Copy Link"; +"FragmentUsernameMessage" = "This username was bought on **Fragment** on %1$s for **%2$s** %3$s"; +"FragmentUsernameOpen" = "Learn More"; +"FragmentUsernameTitle" = "**%s** is a collectible username that belongs to"; +"FreeOfTotal" = "Free %1$s of %2$s"; +"FreshChangePhoneForbidden" = "For security reasons, you can't change your phone number from a device that you've just connected. Please use an earlier connection or wait for a few hours."; +"FreshChangePhoneForbiddenTitle" = "Unable to terminate"; +"From" = "From"; +"FromCamera" = "From Camera"; +"FromChats#one" = "from %1$d chat"; +"FromChats#other" = "from %1$d chats"; +"FromFormatted" = "From %1$s"; +"FromGalley" = "From Gallery"; +"FromYou" = "You"; +"FwdMessageToChats" = "Message forwarded to **%1$s**."; +"FwdMessageToGroup" = "Message forwarded to **%1$s**."; +"FwdMessageToManyChats#one" = "Message forwarded to **a chat**."; +"FwdMessageToManyChats#other" = "Message forwarded to **%1$d chats**."; +"FwdMessageToSavedMessages" = "Message forwarded to **Saved Messages**."; +"FwdMessageToUser" = "Message forwarded to **%1$s**."; +"FwdMessageToUserShort" = "Forwarded to **%1$s**."; +"FwdMessagesToChats" = "Messages forwarded to **%1$s**."; +"FwdMessagesToGroup" = "Messages forwarded to **%1$s**."; +"FwdMessagesToManyChats#one" = "Messages forwarded to **a chat**."; +"FwdMessagesToManyChats#other" = "Messages forwarded to **%1$d chats**."; +"FwdMessagesToSavedMessages" = "Messages forwarded to **Saved Messages**."; +"FwdMessagesToUser" = "Messages forwarded to **%1$s**."; +"FwdMessagesToUserShort" = "Forwarded to **%1$s**."; +"FwdMyStory" = "Repost\nStory"; +"GIFs#one" = "%1$d GIF"; +"GIFs#other" = "%1$d GIFs"; +"Gallery" = "Gallery"; +"GalleryAccessAllowAccess" = "Allow Access to Photos"; +"GalleryAccessAllowAccessButton" = "Allow Access"; +"GalleryAccessAllowAccessCamera" = "Allow Access to Camera"; +"GalleryAccessAllowAccessOpenCamera" = "Open Camera"; +"GalleryAccessAllowAccessTextNonPremium" = "Share an unlimited number of photos\nand videos of up to 2 GB each."; +"GalleryAccessAllowAccessTextPremium" = "Share an unlimited number of photos\nand videos of up to 4 GB each."; +"GalleryInfo" = "To send images without compression"; +"GalleryPermissionText" = "Tap to allow access to your Gallery"; +"GameCantSendSecretChat" = "Sorry, games can’t be sent to secret chats."; +"General" = "General"; +"GetDirections" = "Get Directions"; +"GetQRCode" = "Get QR code"; +"GettingLinkInfo" = "Getting Link Info..."; +"GifCaption" = "GIF Caption"; +"GifSavedHint" = "Added to Favorite GIFs."; +"GifSavedToDownloadsHint" = "GIF saved to Downloads."; +"GifSavedToDownloadsHintLinked" = "GIF saved to **downloads**."; +"GifsTab" = "GIFS"; +"GifsTab2" = "GIFs"; +"Gift2ActionBotInfo" = "You can add this gift to your profile."; +"Gift2ActionBotSavedInfo" = "This gift is now visible on your profile."; +"Gift2ActionConvertRefundedText" = "This gift cannot be converted to Stars because the payment related to it was refunded."; +"Gift2ActionConvertedInfo#one" = "You converted this gift to %d Star."; +"Gift2ActionConvertedInfo#other" = "You converted this gift to %d Stars."; +"Gift2ActionCraftedTitle" = "Crafted Gift"; +"Gift2ActionInfo#one" = "Add this gift to your profile or convert it to %d Star."; +"Gift2ActionInfo#other" = "Add this gift to your profile or convert it to %d Stars."; +"Gift2ActionInfoChannel#one" = "Add this gift to your channel's profile or convert it to **%d** Star."; +"Gift2ActionInfoChannel#other" = "Add this gift to your channel's profile or convert it to **%d** Stars."; +"Gift2ActionInfoChannelNoConvert" = "Add this gift to your channel's profile."; +"Gift2ActionOutInfo#one" = "**%2$s** can add this gift to their profile or convert it to %1$d Star."; +"Gift2ActionOutInfo#other" = "**%2$s** can add this gift to their profile or convert it to %1$d Stars."; +"Gift2ActionOutInfoNoConvert" = "**%s** can display this gift on their page."; +"Gift2ActionOutInfoUpgrade" = "**%s** can display this gift on their page or convert it to unique collectible."; +"Gift2ActionPurchasedTitle" = "Purchased Gift"; +"Gift2ActionReleasedBy" = "Released by **%s**"; +"Gift2ActionResell" = "Sell"; +"Gift2ActionSavedInfo" = "You added this gift to your profile."; +"Gift2ActionSelfInfo#one" = "Add this gift to your profile or convert it to %1$d Star."; +"Gift2ActionSelfInfo#other" = "Add this gift to your profile or convert it to %1$d Stars."; +"Gift2ActionSelfInfoNoConvert" = "Add this gift to your profile."; +"Gift2ActionSelfInfoUpgrade" = "You can add this gift to your profile or upgrade it to a unique collectible to keep or transfer."; +"Gift2ActionSelfTitle" = "Saved Gift"; +"Gift2ActionShare" = "Share"; +"Gift2ActionTitle" = "Gift from "; +"Gift2ActionTitleAnonymous" = "You received a gift!"; +"Gift2ActionTitleInAnonymous" = "%s received a gift!"; +"Gift2ActionTitleTo" = "Gift for "; +"Gift2ActionTransfer" = "Transfer"; +"Gift2ActionUnlist" = "Unlist"; +"Gift2ActionUpgrade" = "Tap \"Unpack\" to turn this gift into a unique collectible."; +"Gift2ActionUpgradeOut" = "%s can turn this gift into a unique collectible."; +"Gift2ActionUpgradeRefundedText" = "This gift was downgraded because a request to refund the payment related to this gift was made, and the money was returned."; +"Gift2ActionUpgradeTitle" = "Upgrade from "; +"Gift2ActionUpgradeUnknownTitle" = "Upgrade from unknown"; +"Gift2ActionUpgradedTitle" = "Upgraded Gift"; +"Gift2ActionWear" = "Wear"; +"Gift2ActionWearDone" = "You put on **%s**"; +"Gift2ActionWearNeededPremium" = "Subscribe to **Telegram Premium** to wear collectibles."; +"Gift2ActionWearOff" = "Take Off"; +"Gift2ActionWearOffDone" = "You took off **%s**"; +"Gift2ActionWonActionText" = "You bought this gift at auction for %s Stars."; +"Gift2ActiveAuctionsActiveAuctionTitle" = "Active Auction"; +"Gift2ActiveAuctionsActiveAuctionsTitle" = "%d Active Auctions"; +"Gift2ActiveAuctionsActiveBidActive" = "Your bid of **%s** is ranked **#%d**"; +"Gift2ActiveAuctionsActiveBidOutbid" = "Your bid of **%s** was outbid"; +"Gift2ActiveAuctionsActiveRaiseBid" = "Raise Bid"; +"Gift2ActiveAuctionsActiveRound" = "Round %s of %s"; +"Gift2ActiveAuctionsActiveStatusEarly" = "You've placed an early bid."; +"Gift2ActiveAuctionsActiveStatusOutbid" = "You've been outbid."; +"Gift2ActiveAuctionsActiveStatusWinning1Place" = "1st place"; +"Gift2ActiveAuctionsActiveStatusWinning2Place" = "2nd place"; +"Gift2ActiveAuctionsActiveStatusWinning3Place" = "3rd place"; +"Gift2ActiveAuctionsActiveStatusWinningAll" = "You are winning all of them."; +"Gift2ActiveAuctionsActiveStatusWinningOne" = "You are winning (%s)."; +"Gift2ActiveAuctionsActiveStatusWinningOtherNd" = "%dnd place"; +"Gift2ActiveAuctionsActiveStatusWinningOtherPlace" = "%d place"; +"Gift2ActiveAuctionsActiveStatusWinningOtherRd" = "%drd place"; +"Gift2ActiveAuctionsActiveStatusWinningOtherSt" = "%dst place"; +"Gift2ActiveAuctionsActiveStatusWinningOtherTh" = "%dth place"; +"Gift2ActiveAuctionsUpcomingAuctionTitle" = "Upcoming Auction"; +"Gift2ActiveAuctionsUpcomingAuctionsTitle" = "%d Upcoming Auctions"; +"Gift2AddMyNameName" = "Add my name to the gift"; +"Gift2AddMyNameNameChannel" = "Add channel name to the gift"; +"Gift2AddSenderName" = "Add sender's name to the gift"; +"Gift2AddSenderNameComment" = "Add sender's name and comment"; +"Gift2AddToCollection" = "Add to Collection"; +"Gift2AddedToCollection" = "**%1$s** added to **%2$s**."; +"Gift2AddedToCollectionMany#one" = "**%1$d gift** added to **%2$s**."; +"Gift2AddedToCollectionMany#other" = "**%1$d gifts** added to **%2$s**."; +"Gift2AttributeBackdrop" = "Backdrop"; +"Gift2AttributeModel" = "Model"; +"Gift2AttributeOriginalDetails" = "Gifted by %1$s to %2$s on %3$s."; +"Gift2AttributeOriginalDetailsComment" = "Gifted by %1$s to %2$s on %3$s with the comment: \"%4$s\""; +"Gift2AttributeOriginalDetailsNoSender" = "Gifted to %1$s on %2$s."; +"Gift2AttributeOriginalDetailsNoSenderComment" = "Gifted to %1$s on %2$s with the comment: \"%3$s\""; +"Gift2AttributeOriginalDetailsSelf" = "Saved by %1$s on %2$s."; +"Gift2AttributeOriginalDetailsSelfComment" = "Gifted by %1$s on %2$s with the comment: \"%3$s\""; +"Gift2AttributeSymbol" = "Symbol"; +"Gift2AuctionAlreadyParticipate" = "You are already participating in an auction for **%s**. You can only participate in one auction. Win the current round or wait until you are removed from the list of participants."; +"Gift2AuctionAlreadyParticipateSelf" = "You are already participating in an auction. You can only participate in one auction. Win the current round or wait until you are removed from the list of participants."; +"Gift2AuctionAlreadyParticipateTitle" = "Auction"; +"Gift2AuctionBidInfoLeft" = "left"; +"Gift2AuctionBidInfoMinimumBid" = "minimum bid"; +"Gift2AuctionBidInfoUntilEndRound" = "last round"; +"Gift2AuctionBidInfoUntilNextRound" = "until next round"; +"Gift2AuctionBidInfoUntilStart" = "until start"; +"Gift2AuctionBidStatusFuture" = "Your bid will be"; +"Gift2AuctionBidStatusFuture2" = "Your bid will be %s"; +"Gift2AuctionBidStatusOutbid" = "You've been outbid"; +"Gift2AuctionBidStatusWinning" = "You're winning"; +"Gift2AuctionBidStatusWinning2" = "You're winning %s"; +"Gift2AuctionCountdownFinished" = "Finished"; +"Gift2AuctionCountdownStartsIn" = "starts in %s"; +"Gift2AuctionEnded" = "Auction ended."; +"Gift2AuctionEndedNoDot" = "Auction ended"; +"Gift2AuctionInfo#one" = "%d gift is dropped at varying intervals to the top %d bidder by bid amount."; +"Gift2AuctionInfo#other" = "%d gifts are dropped at varying intervals to the top %d bidders by bid amount."; +"Gift2AuctionInfo2#one" = "Top **%d** bidder will get the **%s**\ngift this round."; +"Gift2AuctionInfo2#other" = "Top **%d** bidders will get **%s**\ngifts this round."; +"Gift2AuctionInfo3" = "**%1$s** gifts are dropped in **%2$d** rounds\nto the top **%3$d** bidders. %4$s"; +"Gift2AuctionInfoLearnMore" = "**Learn More >**"; +"Gift2AuctionInfoLearnMore2#one" = "%d gift is dropped at varying intervals to the top %d bidder by bid amount. **Learn More >**"; +"Gift2AuctionInfoLearnMore2#other" = "%d gifts are dropped at varying intervals to the top %d bidders by bid amount. **Learn More >**"; +"Gift2AuctionJoin" = "Join Auction"; +"Gift2AuctionLearnMore2" = "Learn more about Telegram Gifts >"; +"Gift2AuctionLearnMore3" = "Learn more about wearing gifts >"; +"Gift2AuctionMinimumBidIncreased#one" = "You must bid at least **%d star**."; +"Gift2AuctionMinimumBidIncreased#other" = "You must bid at least **%d stars**."; +"Gift2AuctionPlaceABid" = "Place a Bid"; +"Gift2AuctionPlaceABidTitle" = "Place a Bid"; +"Gift2AuctionPlaceACustomBid" = "Place a Custom Bid"; +"Gift2AuctionPlaceACustomBidHint" = "If you fall bellow the top %d, your bid will roll over to the next round."; +"Gift2AuctionPlaceACustomBidHint2" = "Custom Bid..."; +"Gift2AuctionPlaceAEarlyBid" = "Place an Early Bid"; +"Gift2AuctionPlaceBid" = "Place a ⭐️%s Bid"; +"Gift2AuctionPlaceBidAdd" = "Add ⭐️%s to Your Bid"; +"Gift2AuctionPriceJoin" = "Join"; +"Gift2AuctionPriceView" = "View"; +"Gift2AuctionStartsIn" = "starts in %s"; +"Gift2AuctionTableCurrentAvailability" = "Availability"; +"Gift2AuctionTableCurrentQuantity" = "Quantity"; +"Gift2AuctionTableCurrentRound" = "Current Round"; +"Gift2AuctionTableCurrentRounds" = "Rounds"; +"Gift2AuctionTableCurrentRoundsOne" = "Round %d"; +"Gift2AuctionTableCurrentRoundsOneDuration" = "%1$s + %2$s for late bids in top %3$d"; +"Gift2AuctionTableCurrentRoundsTwo" = "Rounds %1$d-%2$d"; +"Gift2AuctionTableCurrentRoundsTwoDuration#one" = "%1$d minute each"; +"Gift2AuctionTableCurrentRoundsTwoDuration#other" = "%1$d minutes each"; +"Gift2AuctionTableEnded" = "Ends"; +"Gift2AuctionTableStarted" = "Started"; +"Gift2AuctionTapToBidMore" = "tap to bid more"; +"Gift2AuctionTimeLeft" = "%s left"; +"Gift2AuctionTop3Winners" = "Top 3 Winners"; +"Gift2AuctionUpgradedShort" = "upgraded"; +"Gift2AuctionVariants" = "View %1$s %2$s Variants >"; +"Gift2AuctionYourBid" = "your bid"; +"Gift2AuctionsAcquiredAcceptedBid" = "Accepted Bid"; +"Gift2AuctionsAcquiredDate" = "Date"; +"Gift2AuctionsAcquiredGifts#one" = "%1$d Bought Gift"; +"Gift2AuctionsAcquiredGifts#other" = "%1$d Bought Gifts"; +"Gift2AuctionsAcquiredRecipient" = "Recipient"; +"Gift2AuctionsAcquiredRound" = "Round #%1$d"; +"Gift2AuctionsAcquiredRound2" = "%1$s #%2$d in round %3$d"; +"Gift2AuctionsAcquiredTop" = "TOP %d"; +"Gift2AuctionsBidHasBeenIncreased" = "Your bid has been increased"; +"Gift2AuctionsBidHasBeenPlaced" = "Your bid has been placed"; +"Gift2AuctionsChangeRecipient" = "Change Recipient"; +"Gift2AuctionsChangeRecipient2" = "You’ve already placed a bid on this gift for **%s**. Do you want to raise your bid and change the recipient to **%s**?"; +"Gift2AuctionsItemsBought#one" = "%1$d %2$s item bought >"; +"Gift2AuctionsItemsBought#other" = "%1$d %2$s items bought >"; +"Gift2AuctionsItemsBought2#one" = "%1$d %2$s item bought >"; +"Gift2AuctionsItemsBought2#other" = "%1$d %2$s items bought >"; +"Gift2Availability" = "Availability"; +"Gift2Availability2Value" = "%1$s of %2$s left"; +"Gift2Availability2ValueNone#one" = "0 of %1$d left"; +"Gift2Availability2ValueNone#other" = "0 of %1$d left"; +"Gift2Availability3Value" = "%1$d of %2$s left"; +"Gift2Availability4Value#one" = "%1$d of %2$s left"; +"Gift2Availability4Value#other" = "%1$d of %2$s left"; +"Gift2AvailabilityLeft#one" = "%d left"; +"Gift2AvailabilityLeft#other" = "%d left"; +"Gift2AvailabilitySold#one" = "%d sold"; +"Gift2AvailabilitySold#other" = "%d sold"; +"Gift2AveragePriceHint" = "**%d Stars** is the average sale price for **%s** gifts."; +"Gift2ButtonSell#one" = "sell for %d Star"; +"Gift2ButtonSell#other" = "sell for %d Stars"; +"Gift2ButtonSendGift" = "send a gift"; +"Gift2BuyDoPrice" = "Buy for ⭐️%d"; +"Gift2BuyDoPrice2#one" = "Buy for ⭐️%d"; +"Gift2BuyDoPrice2#other" = "Buy for ⭐️%d"; +"Gift2BuyDoPrice2TON" = "Buy for ⭐️%s"; +"Gift2BuyInStars" = "Pay in Stars"; +"Gift2BuyInTON" = "Pay in Grams"; +"Gift2BuyPriceOnlyTON" = "The seller only accepts Grams as payment."; +"Gift2BuyPricePayHintTON" = "Pay with Grams to skip the 21-day wait before transferring the gift again."; +"Gift2BuyPriceSelfText#one" = "Do you want to buy **%2$s** for **%1$d Star**?"; +"Gift2BuyPriceSelfText#other" = "Do you want to buy **%2$s** for **%1$d Stars**?"; +"Gift2BuyPriceSelfTextTON" = "Do you want to buy **%2$s** for **%1$s Grams**?"; +"Gift2BuyPriceText#one" = "Do you want to buy **%2$s** for **%1$d Star** and gift it to **%3$s**?"; +"Gift2BuyPriceText#other" = "Do you want to buy **%2$s** for **%1$d Stars** and gift it to **%3$s**?"; +"Gift2BuyPriceTextTON" = "Do you want to buy **%2$s** for **%1$s Grams** and gift it to **%3$s**?"; +"Gift2CantDoText" = "Transfer this gift to your Telegram account on Fragment to unlock this action."; +"Gift2CantDoTitle" = "Action Locked"; +"Gift2CaptionMore" = "more"; +"Gift2ChangePrice" = "Change Price"; +"Gift2ChannelDoesntSupportGiftsText" = "This channel can't receive Telegram gifts."; +"Gift2ChannelDoesntSupportGiftsTitle" = "Not Available"; +"Gift2ChannelMadePrivate" = "Gift is now hidden from your channel's profile."; +"Gift2ChannelMadePrivateTitle" = "Gift Hidden"; +"Gift2ChannelMadePublic" = "Gift is now visible on your channel's profile."; +"Gift2ChannelMadePublicTitle" = "Gift Shown"; +"Gift2ChannelNotify" = "Notify About New Gifts"; +"Gift2ChannelNotifyChecked" = "Telegram will send you a message when your channel receives a gift."; +"Gift2ChannelNotifyNotChecked" = "You will not be notified when your channel receives a gift."; +"Gift2ChannelProfileInvisible3" = "This gift is hidden from visitors to your channel. **Show >**"; +"Gift2ChannelProfileVisible3" = "This gift is visible on your channel's profile. **Hide >**"; +"Gift2ChannelSend" = "Send Gift"; +"Gift2ChannelSendHint" = "Tap here to send a gift"; +"Gift2CollectionAddGiftsButton" = "Add Gifts"; +"Gift2CollectionAddGiftsTitle" = "Add Gifts"; +"Gift2CollectionAll" = "All Gifts"; +"Gift2CollectionEmptyButton" = "Add Gifts"; +"Gift2CollectionEmptyText" = "Add some gifts to this collection."; +"Gift2CollectionEmptyTitle" = "Organize Your Gifts"; +"Gift2CollectionNumber#one" = "Collectible #%d"; +"Gift2CollectionNumber#other" = "Collectible #%d"; +"Gift2CollectionNumberBy#one" = "Collectible #%1$d by **%2$s**"; +"Gift2CollectionNumberBy#other" = "Collectible #%1$d by **%2$s**"; +"Gift2CollectionsAdd" = "Add Gifts"; +"Gift2CollectionsDelete" = "Delete Collection"; +"Gift2CollectionsRename" = "Edit Name"; +"Gift2CollectionsReorder" = "Reorder"; +"Gift2CollectionsShare" = "Share Collection"; +"Gift2ConvertButton" = "Convert"; +"Gift2ConvertStars#one" = "%d Star"; +"Gift2ConvertStars#other" = "%d Stars"; +"Gift2ConvertText" = "Do you want to convert this gift from **%1$s** to **%2$s**?\n\nThis action cannot be undone. This will permanently destroy the gift."; +"Gift2ConvertText2#one" = "Do you want to convert this gift from **%2$s** to **%3$s**?\n\nConversion is available for the next **%1$d day**.\n\nThis action cannot be undone. This will permanently destroy the gift."; +"Gift2ConvertText2#other" = "Do you want to convert this gift from **%2$s** to **%3$s**?\n\nConversion is available for the next **%1$d days**.\n\nThis action cannot be undone. This will permanently destroy the gift."; +"Gift2ConvertTitle" = "Convert Gift to Stars"; +"Gift2Converted#one" = "%d Star was sent to your balance"; +"Gift2Converted#other" = "%d Star were sent to your balance"; +"Gift2ConvertedChannel#one" = "%d Star was sent to channel's balance"; +"Gift2ConvertedChannel#other" = "%d Stars were sent to channel's balance"; +"Gift2ConvertedTitle" = "Converted Gift"; +"Gift2EditCollectionNameTitle" = "Edit Name"; +"Gift2ExportTONFragmentOpen" = "Open Fragment"; +"Gift2ExportTONFragmentText" = "You can use Fragment, a third-party service, to transfer **%s** to the TON blockchain. After that, you can manage it as an NFT with any TON wallet application.\n\nYou can also move such NFTs back to your Telegram account via Fragment."; +"Gift2ExportTONFragmentTitle" = "Manage with Fragment"; +"Gift2ExportTONTitle" = "Send via Blockchain"; +"Gift2ExportTONUnlocksAlertText#one" = "In %d day, you'll be able to send this collectible to any TON blockchain address outside Telegram for sale or auction."; +"Gift2ExportTONUnlocksAlertText#other" = "In %d days, you'll be able to send this collectible to any TON blockchain address outside Telegram for sale or auction."; +"Gift2ExportTONUnlocksAlertTitle" = "Unlocking in progress"; +"Gift2ExportTONUnlocksIn#one" = "unlocks in %d day"; +"Gift2ExportTONUnlocksIn#other" = "unlocks in %d days"; +"Gift2ExportTONUpdateRequiredText" = "The Telegram version that enables gift transfers to the blockchain is currently under review by the Play Store. Please wait a few days."; +"Gift2ExportTONUpdateRequiredTitle" = "Update required"; +"Gift2FilterDisplayed" = "Displayed"; +"Gift2FilterHidden" = "Hidden"; +"Gift2FilterLimited" = "Limited"; +"Gift2FilterSortByDate" = "Sort By Date"; +"Gift2FilterSortByValue" = "Sort By Value"; +"Gift2FilterUnique" = "Unique"; +"Gift2FilterUnlimited" = "Unlimited"; +"Gift2FilterUpgradable" = "Upgradable"; +"Gift2From" = "From"; +"Gift2Gift" = "Gift"; +"Gift2GiftAnUpgrade" = "Gift an Upgrade"; +"Gift2Hide" = "Hide My Name"; +"Gift2HideChannelInfo" = "You can hide your name and message from all visitors of this channel except its admins."; +"Gift2HideGift" = "Hide"; +"Gift2HideInfo" = "You can hide your name and message from visitors to %1$s's profile. %1$s will still see your name and message."; +"Gift2HideSelf" = "Hide My Name"; +"Gift2HideSelfInfo" = "Hide my name and message from visitors to my profile"; +"Gift2Host" = "Telegram"; +"Gift2HostAddress" = "Owner"; +"Gift2InBlockchain" = "This gift is on the TON Blockchain. **View >**"; +"Gift2InSenderHidden2" = "Only you can see the senderʼs name."; +"Gift2InSenderMessageHidden2" = "Only you can see the senderʼs name and message."; +"Gift2Info#one" = "You can add this gift to your profile or convert it to **%d** Star."; +"Gift2Info#other" = "You can add this gift to your profile or convert it to **%d** Stars."; +"Gift2Info2#one" = "You can add this gift to your profile or convert it to **%1$d** Star within **%2$s**."; +"Gift2Info2#other" = "You can add this gift to your profile or convert it to **%1$d** Stars within **%2$s**."; +"Gift2Info2BotKeep" = "You can add this gift to your profile or hide it."; +"Gift2Info2BotRemove" = "You can remove this gift from your Profile."; +"Gift2Info2ChannelExpired#one" = "Your channel can add this gift of %d Star to its profile."; +"Gift2Info2ChannelExpired#other" = "Your channel can add this gift of %d Stars to its profile."; +"Gift2Info2ChannelKeep" = "Your channel can add this gift to its profile."; +"Gift2Info2ChannelRemove" = "Your channel can hide this gift from its profile."; +"Gift2Info2Expired#one" = "You can add this gift of %d Star to your profile."; +"Gift2Info2Expired#other" = "You can add this gift of %d Stars to your profile."; +"Gift2Info2KeepUpgrade" = "You can add this gift to your Profile or upgrade it to a unique collectible."; +"Gift2Info2Out#one" = "%2$s can add this gift to their profile or convert it to **%1$d** Star."; +"Gift2Info2Out#other" = "%2$s can add this gift to their profile or convert it to **%1$d** Stars."; +"Gift2Info2OutExpired" = "%1$s can add this gift to their Profile."; +"Gift2Info2OutUpgrade" = "%1$s can add this gift to their Profile or upgrade it to a unique collectible."; +"Gift2Info2RemoveUpgrade" = "You can remove this gift from your Profile or upgrade to unique collectible."; +"Gift2Info3#one" = "You can add this gift to your profile or convert it to **%1$d** Star."; +"Gift2Info3#other" = "You can add this gift to your profile or convert it to **%1$d** Stars."; +"Gift2Info3Channel#one" = "Your channel can add this gift to its profile or convert it to **%1$d** Star."; +"Gift2Info3Channel#other" = "Your channel can add this gift to its profile or convert it to **%1$d** Stars."; +"Gift2InfoChannelConverted#one" = "Your channel converted this gift to **%d** Star."; +"Gift2InfoChannelConverted#other" = "Your channel converted this gift to **%d** Stars."; +"Gift2InfoConverted#one" = "You converted this gift to **%d** Star."; +"Gift2InfoConverted#other" = "You converted this gift to **%d** Stars."; +"Gift2InfoFreeUpgrade" = "%s can turn this gift into a unique collectible."; +"Gift2InfoInFreeUpgrade" = "Upgrade this gift for free to turn it into a unique collectible."; +"Gift2InfoOut#one" = "%2$s can add this gift to their profile or convert it to **%1$d** Star."; +"Gift2InfoOut#other" = "%2$s can add this gift to their profile or convert it to **%1$d** Stars."; +"Gift2InfoOutConverted#one" = "%2$s converted this gift to **%d** Star."; +"Gift2InfoOutConverted#other" = "%2$s converted this gift to **%d** Stars."; +"Gift2InfoOutPinned" = "%1$s added this gift to their profile."; +"Gift2InfoPinned" = "You added this gift to your profile."; +"Gift2Invisible" = "Not visible on your profile"; +"Gift2InvisibleShow" = "show"; +"Gift2LearnMoreText" = "Let %s turn this gift into a unique collectible."; +"Gift2LearnMoreTitle" = "Make Unique"; +"Gift2Limited1OfRibbon" = "1 of %s"; +"Gift2LimitedAuction" = "auction"; +"Gift2LimitedAuctionSoon" = "soon"; +"Gift2LimitedPremium" = "premium"; +"Gift2LimitedRibbon" = "limited"; +"Gift2LimitedSlider" = "Limited"; +"Gift2LinkGiftAuction" = "Gift Auction"; +"Gift2LinkUpcomingAuction" = "Upcoming Auction"; +"Gift2ListingsCount#one" = "%d listing"; +"Gift2ListingsCount#other" = "%d listings"; +"Gift2MadePrivate" = "Gift is now hidden in **your profile**."; +"Gift2MadePrivateTitle" = "Gift Hidden"; +"Gift2MadePublic" = "This gift is now visible on **your profile**."; +"Gift2MadePublicTitle" = "Gift Shown"; +"Gift2Message" = "Enter Message"; +"Gift2MessageOptional" = "Enter Message (Optional)"; +"Gift2MessagePremiumInfo" = "Only %s will see your message."; +"Gift2MessageStars" = "Pay with ⭐️%d"; +"Gift2MessageStarsInfo" = "Your balance is ⭐️%s."; +"Gift2MessageStarsInfoLink" = "**Get More Stars >**"; +"Gift2MessageStarsInfoTON" = "Your balance is ⭐️**%s**"; +"Gift2Months#one" = "%1$d month"; +"Gift2Months#other" = "%1$d months"; +"Gift2More" = "**More about Stars >**"; +"Gift2Myself" = "buy yourself a gift"; +"Gift2MyselfSection" = "This is You"; +"Gift2NewCollection" = "Add Collection"; +"Gift2NewCollectionHint" = "Title"; +"Gift2NewCollectionText" = "Choose a name for your collection and add your gifts."; +"Gift2NewCollectionTitle" = "Create a New Collection"; +"Gift2NoUpgradeChannelForcedInfo" = "%s only accepts limited gifts."; +"Gift2NoUpgradeForcedInfo" = "%s only accepts limited gifts."; +"Gift2Notification#one" = "sent a Gift worth of %d Star"; +"Gift2Notification#other" = "sent a Gift worth of %d Stars"; +"Gift2OnSale" = "on sale"; +"Gift2OutSenderHidden2" = "Only %s can see the senderʼs name."; +"Gift2OutSenderMessageHidden2" = "Only %s can see the senderʼs name and message."; +"Gift2Owner" = "Owner"; +"Gift2OwnerChange" = "change"; +"Gift2PeerColorEmptyButton" = "**Browse gifts available for purchase >**"; +"Gift2PeerColorProfileEmptyTitle" = "You don't have any gifts you can use as a profile cover."; +"Gift2PeerColorReplyEmptyTitle" = "You don't have any gifts you can use as a reply color."; +"Gift2PerUserLimit#one" = "You already sent **%d** of these gifts, which is the limit."; +"Gift2PerUserLimit#other" = "You already sent **%d** of these gifts, which is the limit."; +"Gift2Pin" = "Pin"; +"Gift2PinnedSubtitle" = "Now it will always be shown on top."; +"Gift2PinnedTitle" = "Gift pinned"; +"Gift2Premium" = "Gift Premium"; +"Gift2PremiumInfo" = "Give **%s** access to exclusive features with Telegram Premium."; +"Gift2PremiumInfoLink" = "See Features >"; +"Gift2PremiumSubtitle" = "Subscribe to **Telegram Premium** to send these gifts and unlock access to powerful features."; +"Gift2PremiumSubtitleMany#one" = "Subscribe to **Telegram Premium** to send up to **%d** of these gifts and unlock powerful features."; +"Gift2PremiumSubtitleMany#other" = "Subscribe to **Telegram Premium** to send up to **%d** of these gifts and unlock powerful features."; +"Gift2PremiumTitle" = "Premium Gift"; +"Gift2PrepayUpgradeButton" = "Pay ⭐️%d for Upgrade"; +"Gift2PrepayUpgradeFeature1Text" = "%s will get a unique number, model, backdrop and symbol for the gift."; +"Gift2PrepayUpgradeFeature2Text" = "%s will be able to send the gift to anyone on Telegram."; +"Gift2PrepayUpgradeFeature3Text" = "%s will be able to auction the gift on third-party NFT marketplaces."; +"Gift2PrepayUpgradeText" = "Let %s turn this gift into a unique collectible."; +"Gift2PrepayUpgradeTitle" = "Make Unique"; +"Gift2PreviewRandomTraits" = "Random Traits"; +"Gift2PreviewSelectedTraits" = "Selected Traits"; +"Gift2PriceUnique" = "Unique"; +"Gift2ProfileInvisible" = "This gift is hidden. Only you can see it."; +"Gift2ProfileInvisible3" = "This gift is hidden from your profile. **Show >**"; +"Gift2ProfileInvisible4" = "This gift is hidden. **Show on Profile >**"; +"Gift2ProfileMakeInvisible" = "Hide from my profile"; +"Gift2ProfileMakeVisible" = "Add to my profile"; +"Gift2ProfileVisible" = "This gift is visible on your profile."; +"Gift2ProfileVisible2" = "This gift is visible on your profile. **View >**"; +"Gift2ProfileVisible3" = "This gift is visible on your profile. **Hide >**"; +"Gift2ProfileVisible4" = "This gift is visible. **Hide from profile >**"; +"Gift2Quantity" = "Quantity"; +"Gift2QuantityIssued1#other" = "%d/"; +"Gift2QuantityIssued2#other" = "%d issued"; +"Gift2QuantityIssuedNone#one" = "None of %d left"; +"Gift2QuantityIssuedNone#other" = "None of %d left"; +"Gift2RarityHint" = "Only %s of such collectibles have this attribute"; +"Gift2Refunded" = "This gift was downgraded because a request to refund the payment related to this gift was made, and the money was returned."; +"Gift2ReleasedBy" = "released by **%s**"; +"Gift2ReleasedBy2" = "by **%s**"; +"Gift2RemoveDescriptionButton" = "Remove for ⭐️%d"; +"Gift2RemoveDescriptionText" = "Do you want to permanently remove this description from your gift?"; +"Gift2RemoveDescriptionTitle" = "Remove Description"; +"Gift2RemoveFromCollection" = "Remove From Collection"; +"Gift2RemovedFromCollection" = "**%1$s** removed from **%2$s**."; +"Gift2RemovedFromCollectionMany#one" = "**%1$d gift** removed from **%2$s**."; +"Gift2RemovedFromCollectionMany#other" = "**%1$d gifts** removed from **%2$s**."; +"Gift2Reorder" = "Reorder"; +"Gift2ReplacedPinSubtitle" = "replacing %s"; +"Gift2ReplacedPinTitle" = "%s pinned"; +"Gift2Resale" = "resale"; +"Gift2ResaleCount#one" = "%d listing"; +"Gift2ResaleCount#other" = "%d listings"; +"Gift2ResaleDisable" = "%s removed from sale!"; +"Gift2ResaleEnable" = "%s is now for sale!"; +"Gift2ResaleFilterBackdrop" = "Backdrop"; +"Gift2ResaleFilterBackdrops#one" = "%d Backdrop"; +"Gift2ResaleFilterBackdrops#other" = "%d Backdrops"; +"Gift2ResaleFilterModel" = "Model"; +"Gift2ResaleFilterModels#one" = "%d Model"; +"Gift2ResaleFilterModels#other" = "%d Models"; +"Gift2ResaleFilterSymbol" = "Symbol"; +"Gift2ResaleFilterSymbols#one" = "%d Symbol"; +"Gift2ResaleFilterSymbols#other" = "%d Symbols"; +"Gift2ResaleFiltersBackdropEmpty" = "No backdrops found."; +"Gift2ResaleFiltersClear" = "Clear Filters"; +"Gift2ResaleFiltersEmptyClear" = "Clear Filters"; +"Gift2ResaleFiltersEmptySubtitle" = "Currently, there are no gifts that match this filter."; +"Gift2ResaleFiltersEmptyTitle" = "No gifts found"; +"Gift2ResaleFiltersModelEmpty" = "No models found."; +"Gift2ResaleFiltersSearch" = "Search…"; +"Gift2ResaleFiltersSymbolEmpty" = "No symbols found."; +"Gift2ResaleNoCount" = "no results"; +"Gift2ResellTimeout" = "You will be able to resell this gift in %s."; +"Gift2ResellTimeoutTitle" = "Try Later"; +"Gift2SelfInfo" = "You can add this gift to your profile."; +"Gift2SelfInfoConvert#one" = "Add this gift to your profile or convert it to **%d Star**."; +"Gift2SelfInfoConvert#other" = "Add this gift to your profile or convert it to **%d Stars**."; +"Gift2SelfInfoConverted#one" = "You converted this gift to **%d Star**."; +"Gift2SelfInfoConverted#other" = "You converted this gift to **%d Stars**."; +"Gift2SelfInfoUpgrade" = "You can add this gift to your profile or turn it into a unique transferable collectible."; +"Gift2Send#one" = "Send a Gift for ⭐️%d"; +"Gift2Send#other" = "Send a Gift for ⭐️%d"; +"Gift2SendPremium" = "Send a Gift for %s"; +"Gift2SendPremiumStars" = "Send a Gift for ⭐️%s"; +"Gift2SendSelf#one" = "Buy a Gift for ⭐️%d"; +"Gift2SendSelf#other" = "Buy a Gift for ⭐️%d"; +"Gift2SendYourBalance#one" = "Your balance is %d Star"; +"Gift2SendYourBalance#other" = "Your balance is %d Stars"; +"Gift2SenderHidden" = "Only you can see the sender's name and message."; +"Gift2SentRemainsLimit#one" = "You can send **%d** more."; +"Gift2SentRemainsLimit#other" = "You can send **%d** more."; +"Gift2ShowGift" = "Show"; +"Gift2SoldAuctionPreviewGifts#one" = "%d Gift"; +"Gift2SoldAuctionPreviewGifts#other" = "%d Gifts"; +"Gift2SoldOut" = "sold out"; +"Gift2SoldOutCount#one" = "All %d gifts were already sold."; +"Gift2SoldOutCount#other" = "All %d gifts were already sold."; +"Gift2SoldOutSheetFirstSale" = "First Sale"; +"Gift2SoldOutSheetLastSale" = "Last Sale"; +"Gift2SoldOutSheetSubtitle" = "This gift has been sold out"; +"Gift2SoldOutSheetTitle" = "Unavailable"; +"Gift2SoldOutSheetValue" = "Value"; +"Gift2SoldOutTitle" = "Sold Out!"; +"Gift2Stars" = "Send a Gift"; +"Gift2StarsChannel" = "Send a Gift"; +"Gift2StarsChannelInfo" = "Select a gift to show appreciation for **%s**."; +"Gift2StarsCollectibleInfo" = "Collectible gifts are unique digital items you can exchange or sell."; +"Gift2StarsCount#one" = "%d Star"; +"Gift2StarsCount#other" = "%d Stars"; +"Gift2StarsInfo" = "Give **%s** gifts that can be kept on the profile or converted to Stars."; +"Gift2StarsInfoLink" = "What are Stars >"; +"Gift2StarsInfoProfileLink" = "%s’s Gifts"; +"Gift2StarsSelf" = "Buy a gift"; +"Gift2StarsSelfInfo1" = "Buy yourself a gift to add to your profile or reserve for later."; +"Gift2StarsSelfInfo2" = "Limited-edition gifts upgraded to collectibles can be gifted to others."; +"Gift2Status" = "Status"; +"Gift2StatusNonUnique" = "Non-Unique"; +"Gift2StatusUpgrade" = "upgrade"; +"Gift2TabAll" = "All Gifts"; +"Gift2TabCollectibles" = "Collectibles"; +"Gift2TabInStock" = "In Stock"; +"Gift2TabLimited" = "Limited"; +"Gift2TabMine" = "My Gifts"; +"Gift2TabResale" = "Resale"; +"Gift2Title" = "Send a Gift"; +"Gift2TitleChannel" = "Gift"; +"Gift2TitleProfile" = "Gift"; +"Gift2TitleReceived" = "Received Gift"; +"Gift2TitleSaved" = "Saved Gift"; +"Gift2TitleSelf1" = "Buy a Gift"; +"Gift2TitleSelf2" = "Gift to Myself"; +"Gift2TitleSent" = "Sent Gift"; +"Gift2To" = "To"; +"Gift2ToBalance#one" = "Convert to %d Star"; +"Gift2ToBalance#other" = "Convert to %d Stars"; +"Gift2TransactionAuctionBid" = "Auction Bid"; +"Gift2TransactionConverted" = "Converted Gift"; +"Gift2TransactionPrepaidUpgrade" = "Gift Upgrade"; +"Gift2TransactionRefundedAuctionBid" = "Refunded Auction Bid"; +"Gift2TransactionRefundedConverted" = "Refunded Converted Gift"; +"Gift2TransactionRefundedSent" = "Refunded Sent Gift"; +"Gift2TransactionRefundedUpgrade" = "Refunded Gift Upgrade"; +"Gift2TransactionRemovedDescription" = "Removed Gift Description"; +"Gift2TransactionSent" = "Gift"; +"Gift2TransactionUpgraded" = "Collectible Upgrade"; +"Gift2Transfer" = "Transfer %s"; +"Gift2TransferDo" = "Transfer"; +"Gift2TransferDoPrice" = "Transfer for ⭐️%d"; +"Gift2TransferMine" = "Transfer"; +"Gift2TransferOption" = "Transfer"; +"Gift2TransferPriceText#one" = "Do you want to transfer ownership of **%2$s** to **%3$s** for **%1$d Star**?"; +"Gift2TransferPriceText#other" = "Do you want to transfer ownership of **%2$s** to **%3$s** for **%1$d Stars**?"; +"Gift2TransferShort" = "Transfer Gift"; +"Gift2TransferText" = "Do you want to transfer ownership of **%1$s** to **%2$s**?"; +"Gift2TransferTimeout" = "You will be able to transfer this gift in %s."; +"Gift2TransferTimeoutTitle" = "Try Later"; +"Gift2TransferToTONAlertText" = "You can transfer your gift to TON if you have:"; +"Gift2TransferToTONAlertText1" = "Enabled **Two-Step Verification** more than **7 days** ago."; +"Gift2TransferToTONAlertText2" = "Logged in on this device more than **24 hours** ago."; +"Gift2TransferToTONAlertText3" = "Please come back later."; +"Gift2TransferToTONAlertTitle" = "Security Check"; +"Gift2TransferToTONSetPassword" = "Set Password"; +"Gift2TransferredText" = "You have successfully transferred **%1$s** to **%2$s**."; +"Gift2TransferredTitle" = "Gift Transferred"; +"Gift2UniqueNotification" = "sent a Unique Gift!"; +"Gift2UniquePrepaidUpgradeNotification" = "sent an Upgrade for your Gift"; +"Gift2UniqueRibbon" = "gift"; +"Gift2UniqueRibbonBurned" = "burned"; +"Gift2UniqueTitle" = "Gift from %s"; +"Gift2UniqueTitle2" = "Gift"; +"Gift2UniqueUnpackUpgradeNotification" = "unpacked the Gift"; +"Gift2UniqueUpgradeNotification" = "upgraded your Gift"; +"Gift2UniqueView" = "View"; +"Gift2UnlistText" = "Are you sure you want to unlist your gift?"; +"Gift2UnlistTitle" = "Unlist %s"; +"Gift2Unpack" = "Unpack"; +"Gift2Unpin" = "Unpin"; +"Gift2UnpinAlertButton" = "Unpin"; +"Gift2UnpinAlertSubtitle" = "Select a gift to unpin below"; +"Gift2UnpinAlertTitle" = "Too Many Pinned Gifts"; +"Gift2Unpinned" = "Gift unpinned"; +"Gift2Unwear" = "Take Off"; +"Gift2Upgrade" = "Make Unique for ⭐️%d"; +"Gift2UpgradeButton" = "Upgrade for ⭐️%d"; +"Gift2UpgradeButtonDecreasesIn" = "Price decreases in %s"; +"Gift2UpgradeButtonFree" = "Upgrade for Free"; +"Gift2UpgradeButtonGift" = "Upgrade"; +"Gift2UpgradeChannelForcedInfo" = "%s only accepts unique gifts."; +"Gift2UpgradeChannelInfo" = "Enable this to let the admins of %s convert your gift to a unique collectible. **Learn More >**"; +"Gift2UpgradeCostsFooter" = "Upgrade cost drops every minute."; +"Gift2UpgradeCostsInfo" = "See how this price will decrease >"; +"Gift2UpgradeCostsText" = "Users who upgrade their gift first get collectibles with lower numbers."; +"Gift2UpgradeCostsTitle" = "Upgrade Cost"; +"Gift2UpgradeFeature1Text" = "Get a unique number, model, backdrop and symbol for your gift."; +"Gift2UpgradeFeature1TextLearn" = "The recipient will get a unique number, model, backdrop and symbol for the gift."; +"Gift2UpgradeFeature1Title" = "Unique"; +"Gift2UpgradeFeature2Text" = "Send your upgraded gift to any of your friends on Telegram."; +"Gift2UpgradeFeature2TextLearn" = "The recipient will be able to send the gift to anyone on Telegram."; +"Gift2UpgradeFeature2Title" = "Transferable"; +"Gift2UpgradeFeature3Text" = "Sell or auction your gift on third-party NFT marketplaces."; +"Gift2UpgradeFeature3TextLearn" = "The recipient will be able to auction the gift on third-party NFT marketplaces."; +"Gift2UpgradeFeature3Title" = "Tradable"; +"Gift2UpgradeFeatureSoon" = "SOON"; +"Gift2UpgradeForcedInfo" = "%s only accepts unique gifts."; +"Gift2UpgradeInfo" = "Enable this to let %s turn your gift into a unique collectible. **Learn More >**"; +"Gift2UpgradeNext" = "Upgrade Next Gift"; +"Gift2UpgradeSelf" = "Make Unique for ⭐️%d"; +"Gift2UpgradeSelfInfo" = "Enable this to turn your gift into a unique collectible. **Learn More >**"; +"Gift2UpgradeText" = "Turn your gift into a unique collectible that you can transfer or auction."; +"Gift2UpgradeTitle" = "Upgrade Gift"; +"Gift2UpgradedText" = "Your gift **%s** now has unique attributes and can be transferred to others."; +"Gift2UpgradedTitle" = "Gift Upgraded"; +"Gift2User" = "Gift %s"; +"Gift2Value" = "Value"; +"Gift2ViewInProfile" = "View in Profile"; +"Gift2Visibility" = "Visibility"; +"Gift2Visible" = "Visible on your profile"; +"Gift2VisibleHide" = "hide"; +"Gift2Wear" = "Wear"; +"Gift2WearFeature1Text" = "This gift will be displayed next to your name with a glittering icon."; +"Gift2WearFeature1Title" = "Radiant Badge"; +"Gift2WearFeature2Text" = "Your profile will have the same backdrop and symbol as this gift."; +"Gift2WearFeature2Title" = "Unique Profile Design"; +"Gift2WearFeature3Text" = "Tapping the icon next to your name will show the gift's owner and attributes."; +"Gift2WearFeature3Title" = "Proof of Ownership"; +"Gift2WearStart" = "Start Wearing"; +"Gift2WearSubtitle" = "and get these benefits"; +"Gift2WearTitle" = "Wear %s"; +"Gift2WearingHint" = "You can upgrade your gift for free, sell it to\nother users or set it as your profile cover."; +"GiftAuctionInfo1Header#one" = "Top %d Bidder"; +"GiftAuctionInfo1Header#other" = "Top %d Bidders"; +"GiftAuctionInfo1Text#one" = "%d gift is dropped at varying intervals to the top %d bidder by bid amount."; +"GiftAuctionInfo1Text#other" = "%d gifts are dropped at varying intervals to the top %d bidders by bid amount."; +"GiftAuctionInfo2Header" = "Bid Carryover"; +"GiftAuctionInfo2Text#one" = "If yours is not the top bid, it will automatically join the next round."; +"GiftAuctionInfo2Text#other" = "If your bid leaves the top %d, it will automatically join the next round."; +"GiftAuctionInfo3Header" = "Missed Bidders"; +"GiftAuctionInfo3Text" = "If your bid doesn’t win after the final round, your Stars will be fully refunded."; +"GiftAuctionInfoHeader" = "Auction"; +"GiftAuctionInfoText" = "Join the battle for exclusive gifts."; +"GiftAuctionNotFound" = "Gift auction was not found"; +"GiftAuctionWearInfo1Header" = "Radiant Badge"; +"GiftAuctionWearInfo1Text" = "This gift will be displayed next to your name with a glittering icon."; +"GiftAuctionWearInfo2Header" = "Unique Profile Design"; +"GiftAuctionWearInfo2Text" = "Your profile will use the color and the symbol of this gift."; +"GiftAuctionWearInfo3Header" = "Proof of Ownership"; +"GiftAuctionWearInfo3Text" = "Tapping the icon of this gift next to your name will show its info and owner."; +"GiftAuctionWearInfoHeader" = "Wear %s"; +"GiftAuctionWearInfoOnline" = "online"; +"GiftAuctionWearInfoText" = "and get these benefits:"; +"GiftCollectionSharedTo" = "Gift Collection forwarded to **%s**."; +"GiftCollectionSharedToManyChats#one" = "Gift Collection forwarded to **a chat**."; +"GiftCollectionSharedToManyChats#other" = "Gift Collection forwarded to **%1$d chats**."; +"GiftCollectionSharedToSavedMessages" = "Gift Collection forwarded to **Saved Messages**"; +"GiftCraft" = "Craft"; +"GiftCraftBackdropChance#one" = "**%1$d%%** chance the crafted gift will have **%2$s** backdrop."; +"GiftCraftBackdropChance#other" = "**%1$d%%** chance the crafted gift will have **%2$s** backdrop."; +"GiftCraftButton" = "Craft Gift"; +"GiftCraftButtonEmpty" = "Tap + to add your first gift"; +"GiftCraftButtonFailed" = "Craft New Gift"; +"GiftCraftButtonNext" = "Craft New Gift"; +"GiftCraftCantChooseFirst" = "This gift can't be used as the first one in crafting because it has been recorded on the blockchain."; +"GiftCraftCantChooseFirstTitle" = "Can't Choose as First"; +"GiftCraftFailedText#one" = "The crafting attempt was unsuccessful.\n**%d** gift was lost."; +"GiftCraftFailedText#other" = "The crafting attempt was unsuccessful.\n**%d** gifts were lost."; +"GiftCraftFailedTitle" = "Crafting Failed"; +"GiftCraftInfoButton" = "Start Crafting"; +"GiftCraftInfoFeature1Text" = "Select up to 4 gifts to craft a new exclusive model."; +"GiftCraftInfoFeature1Title" = "Get Rare Models"; +"GiftCraftInfoFeature2Text" = "Combine more gifts to increase your odds of success."; +"GiftCraftInfoFeature2Title" = "Maximize Chances"; +"GiftCraftInfoFeature3Text" = "Use gifts with the same attribute to boost its chance."; +"GiftCraftInfoFeature3Title" = "Affect the Result"; +"GiftCraftInfoText" = "Turn your gifts into rare, epic\nand legendary versions."; +"GiftCraftInfoTitle" = "Gift Crafting"; +"GiftCraftLaterText" = "You will be able to craft this gift on %s."; +"GiftCraftLaterTitle" = "Try Later"; +"GiftCraftProgressSuccessChance" = "%s success chance"; +"GiftCraftProgressText" = "If crafting fails, these gifts\nwill be lost."; +"GiftCraftProgressTitle" = "Crafting"; +"GiftCraftSelect#one" = "You can add up to **%d gift** from\nthe same collection for crafting."; +"GiftCraftSelect#other" = "You can add up to **%d gifts** from\nthe same collection for crafting."; +"GiftCraftSelectResale" = "Suitable Gifts On Sale"; +"GiftCraftSelectTitle" = "Select Gift"; +"GiftCraftSelectYour" = "Your Gifts"; +"GiftCraftSelectYourEmpty" = "You don't have other gifts\nfrom this collection."; +"GiftCraftSuccessChance" = "**%s** Success Chance"; +"GiftCraftSymbolChance#one" = "**%1$d%%** chance the crafted gift will have **%2$s** symbol."; +"GiftCraftSymbolChance#other" = "**%1$d%%** chance the crafted gift will have **%2$s** symbol."; +"GiftCraftText1" = "Add up to **4** gifts to craft a new"; +"GiftCraftText2" = "**%1$s #%2$s**.\n\nIf crafting fails, these gifts\nwill be **lost**."; +"GiftCraftTextEmpty1" = "Add up to **4** gifts to craft a new"; +"GiftCraftTextEmpty2" = "**%s** gift.\n\nIf crafting fails, these gifts\nwill be **lost**."; +"GiftCraftTitle" = "Craft Gift"; +"GiftCraftUnavailableText" = "These gifts currently can't be crafted."; +"GiftCraftUnavailableTextTime" = "This gift will become available for crafting on **%s**."; +"GiftCraftUnavailableTitle" = "Crafting Unavailable"; +"GiftCraftViewAllVariants" = "View all craftable models >"; +"GiftCrafted" = "crafted"; +"GiftLocked" = "Gift Locked"; +"GiftMonths#one" = "%1$d-month"; +"GiftMonths#other" = "%1$d-months"; +"GiftOfferAccept" = "Accept"; +"GiftOfferAmount" = "⭐️ %s"; +"GiftOfferAmountHigher" = "%s higher"; +"GiftOfferAmountHigherHint" = "The value of this gift is **%s higher** than the offer"; +"GiftOfferAmountHigherHint2" = "The price of this offer is **%1$s** higher\nthan the average price for %2$s"; +"GiftOfferAmountLower" = "%s lower"; +"GiftOfferAmountLowerHint" = "The value of this gift is **%s lower** than the offer"; +"GiftOfferAmountLowerHint2" = "The price of this offer is **%1$s** lower\nthan the average price for %2$s"; +"GiftOfferButtonStars" = "Offer ⭐️%s"; +"GiftOfferConfirmSend" = "Confirm Offer"; +"GiftOfferDecline" = "Reject"; +"GiftOfferDuration" = "Offer Duration"; +"GiftOfferDurationInfoStars" = "Choose how long **%s** has to accept your offer. When it expires, your **Stars** will be refunded."; +"GiftOfferDurationInfoTON" = "Choose how long **%s** has to accept your offer. When it expires, your **Grams** will be refunded."; +"GiftOfferHours#one" = "%d hour"; +"GiftOfferHours#other" = "%d hours"; +"GiftOfferOfferedTextStars" = "**%1$s** offered you **%2$s Stars** for **%3$s**."; +"GiftOfferOfferedTextStars2" = "This user offered you **%2$s Stars** for **%3$s**."; +"GiftOfferOfferedTextStarsExpired" = "Your offer to **%1$s** has expired. **%2$s Stars** for **%3$s**."; +"GiftOfferOfferedTextStarsExpiredOut" = "**%1$s**'s offer has expired. **%2$s Stars** for **%3$s**."; +"GiftOfferOfferedTextStarsOut" = "You offered **%1$s** **%2$s Stars** for **%3$s**."; +"GiftOfferOfferedTextStarsRejected" = "**%1$s** rejected your offer of **%2$s Stars** for **%3$s**."; +"GiftOfferOfferedTextStarsRejectedOut" = "You rejected **%1$s's** offer to buy your **%3$s** for **%2$s Stars**."; +"GiftOfferOfferedTextTON" = "**%1$s** offered you **%2$s Grams** for **%3$s**."; +"GiftOfferOfferedTextTON2" = "This user offered you **%2$s Grams** for **%3$s**."; +"GiftOfferOfferedTextTONExpired" = "Your offer to **%1$s** has expired. **%2$s Grams** for **%3$s**."; +"GiftOfferOfferedTextTONExpiredOut" = "**%1$s**'s offer has expired. **%2$s Grams** for **%3$s**."; +"GiftOfferOfferedTextTONOut" = "You offered **%1$s** **%2$s Grams** for **%3$s**."; +"GiftOfferOfferedTextTONRejected" = "**%1$s** rejected your offer of **%2$s Grams** for **%3$s**."; +"GiftOfferOfferedTextTONRejectedOut" = "You rejected **%1$s's** offer to buy your **%3$s** for **%2$s Grams**."; +"GiftOfferPay" = "Pay ⭐️%s"; +"GiftOfferPayMulti" = "Pay %s and %s"; +"GiftOfferPayMultiPart" = "⭐️%s"; +"GiftOfferRejectConfirmConfirm" = "Reject"; +"GiftOfferRejectConfirmText" = "Are you sure you want to reject the offer from **%s**?"; +"GiftOfferRejectConfirmTitle" = "Reject Offer"; +"GiftOfferRowDuration" = "Duration"; +"GiftOfferRowFee" = "Fee"; +"GiftOfferRowOffer" = "Offer"; +"GiftOfferRowValue" = "Value"; +"GiftOfferSellFor" = "Sell for ⭐️%s"; +"GiftOfferSentText" = "You successfully sent an offer for **%1$s** to **%2$s**."; +"GiftOfferSentTitle" = "Gift offer sent"; +"GiftOfferStarsToOffer" = "Stars to offer"; +"GiftOfferStarsToOfferInfo" = "Enter the amount of Stars you'd like to offer for **%s**."; +"GiftOfferStarsToOfferInfoIsHigh" = "This offer is too high. The maximum offer for **%2$s** is **%1$s Stars**."; +"GiftOfferStarsToOfferInfoIsLow" = "This offer is too low. The minimum offer for **%2$s** is **%1$s Stars**."; +"GiftOfferStatusAccepted" = "This offer was accepted."; +"GiftOfferStatusExpired" = "This offer has expired."; +"GiftOfferStatusPending" = "This offer expires in **%s**."; +"GiftOfferStatusRejected" = "This offer was rejected."; +"GiftOfferTONToOffer" = "Grams to offer"; +"GiftOfferTONToOfferInfo" = "Enter the amount of Grams you'd like to offer for **%s**."; +"GiftOfferTONToOfferInfoIsHigh" = "This offer is too high. The maximum offer for **%2$s** is **%1$s Grams**."; +"GiftOfferTONToOfferInfoIsLow" = "This offer is too low. The minimum offer for **%2$s** is **%1$s Grams**."; +"GiftOfferToBuyMenu" = "Offer to buy"; +"GiftOfferToBuyTitle" = "Offer to Buy"; +"GiftOfferTransferInfoTextSellStars" = "Do you want to sell **%3$s** to **%2$s** for **%1$s Stars**?\n\nYou will receive **%4$s Stars** after fees."; +"GiftOfferTransferInfoTextSellTON" = "Do you want to sell **%3$s** to **%2$s** for **%1$s Grams**?\n\nYou will receive **%4$s Grams** after fees."; +"GiftOfferTransferInfoTextStars" = "Do you want to offer **%1$s Stars** to **%2$s** for **%3$s**?"; +"GiftOfferTransferInfoTextTON" = "Do you want to offer **%1$s Grams** to **%2$s** for **%3$s**?"; +"GiftOnSale" = "On sale for %s"; +"GiftPremium" = "Gift Premium"; +"GiftPremiumAboutThisLink" = "This link allows you or **anyone you choose** to activate a %1$s."; +"GiftPremiumAboutThisLinkEnd" = "**Telegram Premium** subscription"; +"GiftPremiumActivateErrorText" = "You can activate this gift code after %1$s or **send the link** to a friend."; +"GiftPremiumActivateErrorTitle" = "You already have Telegram Premium"; +"GiftPremiumActivateForFree" = "Activate For Free"; +"GiftPremiumChooseRecipientsBtn" = "Choose Recipients"; +"GiftPremiumEventAdsTitle" = "👑 Snoop Dogg Gift Drop 🎁"; +"GiftPremiumFrequentContacts" = "Frequent Contacts"; +"GiftPremiumGifting" = "Gift Premium"; +"GiftPremiumGiftsSent#one" = "Gift Sent!"; +"GiftPremiumGiftsSent#other" = "Gifts Sent!"; +"GiftPremiumGiftsSentStatus" = "They now have access to additional features."; +"GiftPremiumGiftsSentStatusForUser" = "**%1$s** now has access to additional features."; +"GiftPremiumListFeaturesAndTerms" = "You can review the list of features and more details about Telegram Premium *here*."; +"GiftPremiumOptionDiscount" = "-%1$d%%"; +"GiftPremiumPrivacyPolicy" = "**Privacy Policy**."; +"GiftPremiumPrivacyPolicyAndTerms" = "By gifting Telegram Premium you agree to the **Telegram Terms of Service** and %1$s"; +"GiftPremiumProceedBtn" = "Proceed"; +"GiftPremiumUseGiftBtn" = "Use Gift"; +"GiftPremiumUsersGiveAccessMany#one" = "Give %2$s and **%1$d** more friend access to exclusive features."; +"GiftPremiumUsersGiveAccessMany#other" = "Give %2$s and **%1$d** more friends access to exclusive features."; +"GiftPremiumUsersGiveAccessManyZero" = "Give %1$s access to exclusive features."; +"GiftPremiumUsersOne" = "**%1$s**"; +"GiftPremiumUsersPurchasedMany#one" = "%2$s and **%1$d** other have been notified about the gifts you purchased."; +"GiftPremiumUsersPurchasedMany#other" = "%2$s and **%1$d** others have been notified about the gifts you purchased."; +"GiftPremiumUsersPurchasedManyZero" = "%1$s have been notified about the gifts you purchased."; +"GiftPremiumUsersSearchHint" = "Search people to gift Premium to..."; +"GiftPremiumUsersThree" = "**%1$s**, **%2$s**, **%3$s**"; +"GiftPremiumUsersTwo" = "**%1$s**, **%2$s**"; +"GiftPremiumWhatsIncluded" = "What's included"; +"GiftPremiumWillReceiveBoosts" = "You will receive ⚡ **%1$d** boosts."; +"GiftPremiumWillReceiveBoostsPlural#one" = "You will receive ⚡ **%1$d** boost."; +"GiftPremiumWillReceiveBoostsPlural#other" = "You will receive ⚡ **%1$d** boosts."; +"GiftPreviewBackdrop" = "backdrop"; +"GiftPreviewBackdrops" = "Backdrops"; +"GiftPreviewCountBackdrops#one" = "This collection features **%d** unique backdrop"; +"GiftPreviewCountBackdrops#other" = "This collection features **%d** unique backdrops"; +"GiftPreviewCountModels#one" = "This collection features **%d** unique model"; +"GiftPreviewCountModels#other" = "This collection features **%d** unique models"; +"GiftPreviewCountModelsCrafting#one" = "This collection features **%d** craftable model."; +"GiftPreviewCountModelsCrafting#other" = "This collection features **%d** craftable models."; +"GiftPreviewCountModelsCrafting2#one" = "This collection features **%d** regular model you can craft from."; +"GiftPreviewCountModelsCrafting2#other" = "This collection features **%d** regular models you can craft from."; +"GiftPreviewCountSymbols#one" = "This collection features **%d** unique symbol"; +"GiftPreviewCountSymbols#other" = "This collection features **%d** unique symbols"; +"GiftPreviewModel" = "model"; +"GiftPreviewModels" = "Models"; +"GiftPreviewSymbol" = "symbol"; +"GiftPreviewSymbols" = "Symbols"; +"GiftRarityEpic" = "epic"; +"GiftRarityLegendary" = "legendary"; +"GiftRarityRare" = "rare"; +"GiftRarityUncommon" = "uncommon"; +"GiftRemovedDescription" = "Removed **%s**'s Description!"; +"GiftRepostedToChannelProfile" = "Gift reposted to **%s**."; +"GiftRepostedToProfile" = "Gift reposted to your profile."; +"GiftResaleFilterAllListings" = "All Listings"; +"GiftResaleFilterForStarsOnly" = "For Stars Only"; +"GiftResaleStarsOnly" = "Show listings for Stars only"; +"GiftSelfTopAction" = "You bought this gift on **%1$s**"; +"GiftSelfTopActionCrafted" = "You crafted this gift on **%1$s**"; +"GiftSkipAnimation" = "Skip"; +"GiftStarsSubtitle" = "With Stars, **%s** will be able to unlock content and services on Telegram."; +"GiftStarsSubtitleLinkName" = "**See Examples >**"; +"GiftStarsTitle" = "Gift Stars"; +"GiftSubscriptionCountFor#one" = "Gift %1$d Subscription for %2$s"; +"GiftSubscriptionCountFor#other" = "Gift %1$d Subscriptions for %2$s"; +"GiftSubscriptionFor" = "Gift Subscription for %1$s"; +"GiftTelegramPremiumDescription" = "Give **%1$s** access to exclusive features with **Telegram Premium**."; +"GiftTelegramPremiumOrStarsTitle" = "Send a Gift"; +"GiftTelegramPremiumTitle" = "Gift Telegram Premium"; +"GiftThemesSetActionView" = "View"; +"GiftThemesSetByOther" = "**%1$s** set **%2$s** as a new theme for this chat."; +"GiftThemesSetByYou" = "**You** set **%s** as a new theme for this chat."; +"GiftThemesSetIn" = "Set as Theme in..."; +"GiftThemesSetInReuseConfirm" = "Yes"; +"GiftThemesSetInReuseInfo" = "This gift is already your theme in the chat with **%s**. Remove it there and use it here instead?"; +"GiftTopAction" = "**%1$s** sent you this gift on **%2$s**"; +"GiftValue2" = "Value"; +"GiftValue2LearnMore" = "learn more"; +"GiftValueAverage" = "The average sale price of **%s** gifts on Telegram and Fragment over the past month."; +"GiftValueAveragePrice" = "Average Price"; +"GiftValueAveragePriceInfo" = "**%1$s** is the average sale price of **%2$s** gifts over the past month."; +"GiftValueInitialPrice" = "Initial Price"; +"GiftValueInitialSale" = "Initial Sale"; +"GiftValueLastFragment" = "This is the price at which **%s** was last sold on Fragment."; +"GiftValueLastPrice" = "Last Price"; +"GiftValueLastSale" = "Last Sale"; +"GiftValueLastTelegram" = "This is the price at which **%s** was last sold on Telegram."; +"GiftValueMinPrice" = "Minimum Price"; +"GiftValueMinPriceInfo" = "**%1$s** is the floor price for **%2$s** gifts listed on Telegram and Fragment."; +"GiftValueOnSaleFragment" = "for sale on Fragment >"; +"GiftValueOnSaleTelegram" = "for sale on Telegram >"; +"GiftsBirthdaySetup" = "Add Your Birthday"; +"GiftsFeature1Text" = "Upgrade your gifts to get a unique number, model, backdrop and symbol."; +"GiftsFeature1Title" = "Unique"; +"GiftsFeature2Text" = "Sell your gift on Telegram or on third-party NFT marketplaces."; +"GiftsFeature2Title" = "Tradable"; +"GiftsFeature3Text" = "Display gifts on your profile and set them as your cover or status."; +"GiftsFeature3Title" = "Wearable"; +"GiftsPinLimit#one" = "You can't pin more than %d gift."; +"GiftsPinLimit#other" = "You can't pin more than %d gifts."; +"GigagroupAlertLearnMore" = "LEARN MORE"; +"GigagroupAlertText" = "Your group has reached the limit of **200,000** members.\n\nYou can increase this limit by converting the group to a **broadcast group** where only admins can post.\nInterested?"; +"GigagroupAlertTitle" = "Limit reached"; +"GigagroupConvertAlertConver" = "Convert"; +"GigagroupConvertAlertText" = "Regular members of the group (non-admins) will **permanently** lose their right to send messages in the group.\n\nThis action **can't** be undone."; +"GigagroupConvertAlertTitle" = "Are you sure?"; +"GigagroupConvertCancelButton" = "Leave as regular group"; +"GigagroupConvertCancelHint" = "If you change your mind, go to your group's Settings > Permissions."; +"GigagroupConvertInfo1" = "No limit on the number of members."; +"GigagroupConvertInfo2" = "Only admins can send messages."; +"GigagroupConvertInfo3" = "Can't be turned back into a regular group."; +"GigagroupConvertProcessButton" = "Convert to Broadcast Group"; +"GigagroupConvertTitle" = "Broadcast Groups"; +"GiveawayBtn" = "Giveaway"; +"GiveawayChooseUsersRandomly" = "Choose Randomly"; +"GiveawayStars#one" = "%d Star"; +"GiveawayStars#other" = "%d Stars"; +"GlobalAttachAudioRestricted" = "Sending music isn't allowed in this group."; +"GlobalAttachDocumentsRestricted" = "Sending documents isn't allowed in this group."; +"GlobalAttachEmojiRestricted" = "Text messages aren't allowed in this group."; +"GlobalAttachGifRestricted" = "Sending GIFs is not allowed in this group."; +"GlobalAttachInlineRestricted" = "Sending inline content isn't allowed in this group."; +"GlobalAttachMediaRestricted" = "Sending media isn't allowed in this group."; +"GlobalAttachPhotoRestricted" = "Sending photos isn't allowed in this group."; +"GlobalAttachPlainRestricted" = "Sending text messages isn't allowed in this group."; +"GlobalAttachRoundRestricted" = "Sending video messages isn't allowed here."; +"GlobalAttachStickersRestricted" = "Stickers aren't allowed in this group."; +"GlobalAttachVideoRestricted" = "Sending videos isn't allowed in this group."; +"GlobalAttachVoiceRestricted" = "Sending voice isn't allowed in this group."; +"GlobalAutoDeleteInfo" = "If enabled, all new messages in chats you start will be automatically deleted for everyone at some point after they are sent. You can also **apply this setting for your existing chats**."; +"GlobalSearch" = "Global search"; +"GlobalSendMessageRestricted" = "Sending messages is not allowed in this group."; +"GoToSettings" = "GO TO SETTINGS"; +"GotAQuestion" = "Have a question\nabout Telegram?"; +"GotIt" = "Got it"; +"GpsDisabledAlert" = "Your GPS seems to be disabled, please enable it to access location-based features."; +"GpsDisabledAlertText" = "Please enable your GPS to access location-based features."; +"GpsDisabledAlertTitle" = "Enable GPS"; +"GraceMessage" = "Don’t lose access to exclusive features."; +"GraceSuggestionButton" = "Restore Subscription"; +"GraceSuggestionMessage" = "⚠️ Unfortunately, your latest payment didn’t come through. To keep your access to exclusive features, please renew the subscription."; +"GraceSuggestionTitle" = "Access to Telegram Premium is expiring!"; +"GraceTitle" = "⚠️ Your Premium subscription is expiring!"; +"Grain" = "Grain"; +"Gray" = "Gray"; +"Green" = "Green"; +"GreetingHow" = "%s added the message above for all empty chats **how?**"; +"GreetingHowSticker" = "%s set the sticker above for all empty chats **how?**"; +"GroupAddMembers" = "Add Members"; +"GroupAdmins" = "Group admins"; +"GroupAppearanceUpdated" = "Group appearance has been updated"; +"GroupBoostedByUserWithDescription" = "Boost **%1$s** to help it unlock new features and get a booster **badge** for your messages."; +"GroupBoostedByUserWithTimes#one" = "**%2$s** boosted the group **%1$d** time."; +"GroupBoostedByUserWithTimes#other" = "**%2$s** boosted the group **%1$d** times."; +"GroupBoostsJustReachedLevel1" = "This group reached **Level\u00a01** and can now post stories."; +"GroupBoostsJustReachedLevelNext" = "This group reached **Level\u00a0%1$d** and can now post **%2$s** per day."; +"GroupCalMessageDecryptionError" = "Unsupported message"; +"GroupCallCreate" = "Start New Call"; +"GroupCallCreate2" = "Create New Call"; +"GroupCallCreateAddMembers" = "Add members"; +"GroupCallCreateInfo#one" = "You can add up to %d participant to a call."; +"GroupCallCreateInfo#other" = "You can add up to %d participants to a call."; +"GroupCallCreateLink" = "Create Call Link"; +"GroupCallCreateText" = "This call is no longer active. You can start a new one."; +"GroupCallCreateTitle" = "Start Group Call"; +"GroupCallCreateVideo" = "Video"; +"GroupCallCreateVoice" = "Voice"; +"GroupCallCreatedLinkCopy" = "Copy"; +"GroupCallCreatedLinkJoinOr" = "or"; +"GroupCallCreatedLinkJoinText" = "Be the first to join the call and add people from there. **Open Call >**"; +"GroupCallCreatedLinkRevoke" = "Revoke link"; +"GroupCallCreatedLinkRevokedText" = "New link has been generated."; +"GroupCallCreatedLinkRevokedTitle" = "Link Revoked"; +"GroupCallCreatedLinkShare" = "Share"; +"GroupCallCreatedLinkText" = "Anyone on Telegram can join your call by following the link below."; +"GroupCallCreatedLinkTitle" = "Call Link"; +"GroupCallDiscardInvite" = "Cancel Invite"; +"GroupCallLinkJoin" = "Join Group Call"; +"GroupCallLinkMicrophone" = "Turn on the microphone"; +"GroupCallLinkText" = "You are invited to join a Telegram call."; +"GroupCallLinkText2Many#one" = "**%2$s**, **%3$s** and **%1$d** more person already joined this call."; +"GroupCallLinkText2Many#other" = "**%2$s**, **%3$s** and **%1$d** more people already joined this call."; +"GroupCallLinkText2One" = "**%1$s** already joined this call."; +"GroupCallLinkText2Two" = "**%1$s** and **%2$s** already joined this call."; +"GroupCallLinkTitle" = "Group Call"; +"GroupCallShowInMainTabs" = "Show Calls Tab"; +"GroupCallStopCallingInvite" = "Stop Calling"; +"GroupCallTabWasHiddenTitle" = "**Calls** tab is now hidden"; +"GroupCallTabWasShownTitle" = "**Calls** tab is now visible"; +"GroupChats" = "Group Chats"; +"GroupContacts" = "Contacts in this group"; +"GroupCreateAutodeleteDescription" = "Automatically delete messages in this group for everyone after a period of time."; +"GroupCreateDiscussionDefaultName" = "%1$s chat"; +"GroupCreateMembersFive" = "%1$s, %2$s, %3$s, %4$s and %5$s"; +"GroupCreateMembersFour" = "%1$s, %2$s, %3$s and %4$s"; +"GroupCreateMembersThree" = "%1$s, %2$s and %3$s"; +"GroupCreateMembersTwo" = "%1$s and %2$s"; +"GroupDeletedUndo" = "Group deleted"; +"GroupDescription1" = "Up to 200,000 members"; +"GroupDescription2" = "Persistent chat history"; +"GroupDescription3" = "Public links such as t.me/title"; +"GroupDescription4" = "Admins with different rights"; +"GroupEmoji" = "Group Emoji"; +"GroupEmojiPack" = "Group Emoji Pack"; +"GroupEmojiPackHint" = "All members of the group can use the %s pack."; +"GroupEmojiPackInfo" = "Choose an emoji pack that will be available to all members within the group."; +"GroupEmojiStatus" = "Group Emoji Status"; +"GroupEmojiStatusInfo" = "Choose a status that will be shown next to the group's name."; +"GroupEmptyTitle1" = "You created a **group**"; +"GroupEmptyTitle2" = "Groups can have:"; +"GroupJoin" = "JOIN"; +"GroupJoinNoCaps" = "Join"; +"GroupJoinRequest" = "APPLY TO JOIN GROUP"; +"GroupJoinRequestNoCaps" = "Apply to join group"; +"GroupJoinRequestSent" = "JOIN REQUEST SENT"; +"GroupJoinRequestSentNoCaps" = "Join request sent"; +"GroupLeftUndo" = "You left the group."; +"GroupMembers" = "Members"; +"GroupMembers2" = "Group members"; +"GroupMembersChartTitle" = "Group members"; +"GroupMessagesChargePrice" = "Charge Stars for Messages"; +"GroupMessagesChargePriceInfo" = "If you turn this on, regular members of the group will have to pay Stars to send messages."; +"GroupMessagesPriceHeader" = "Set price per message"; +"GroupMessagesPriceInfo" = "Your group will receive %1$s of the selected fee (~%2$s$) for each incoming message."; +"GroupName" = "Group name"; +"GroupNeedBoostsAlreadyBoostedDescriptionLevel1" = "This group needs %s more boosts to enable stories."; +"GroupNeedBoostsDescription" = "Your group needs %s to enable posting stories.\n\nAsk your **Premium** members to boost your group with this link:"; +"GroupNeedBoostsDescriptionForNewFeatures" = "**%1$s** needs %2$s to unlock new features."; +"GroupNeedBoostsDescriptionLevel1" = "This group needs %s to enable stories. Help make it possible!"; +"GroupNeedBoostsDescriptionLevelNext" = "This group needs %1$s to be able post **%2$s** per day. Help make it possible!"; +"GroupNeedBoostsDescriptionNextLevel" = "Your group needs %1$s to be able post **%2$s** per day.\n\nAsk your Premium members to boost your group with this link:"; +"GroupNeedBoostsForColorDescription" = "Your group needs to reach **Level %d** to change group color to selected. **Premium** users can boost your group with this link:"; +"GroupNeedBoostsForCustomEmojiPackDescription" = "Your group needs to reach **Level %d** to set group emoji pack. **Premium** users can boost your group with this link:"; +"GroupNeedBoostsForCustomWallpaperDescription" = "Your group needs to reach **Level %d** to change group wallpaper to custom photo. **Premium** users can boost your group with this link:"; +"GroupNeedBoostsForEmojiStatusDescription" = "Your group needs to reach **Level %d** to set group emoji status.\n\n**Premium** users can boost your group with this link:"; +"GroupNeedBoostsForProfileColorDescription" = "Your group needs to reach **Level %d** to change group profile color to selected. **Premium** users can boost your group with this link:"; +"GroupNeedBoostsForProfileIconDescription" = "Your group needs to reach **Level %d** to change group profile icon.\n\n**Premium** users can boost your group with this link:"; +"GroupNeedBoostsForReplyIconDescription" = "Your group needs to reach **Level %d** to change group link style icon.\n\n**Premium** users can boost your group with this link:"; +"GroupNeedBoostsForWallpaperDescription" = "Your group needs to reach **Level %d** to change group wallpaper. **Premium** users can boost your group with this link:"; +"GroupNeedBoostsForWearCollectiblesDescription" = "Your group needs to reach **Level %d** to wear collectibles. **Premium** users can boost your group with this link:"; +"GroupNotRestrictBoosters" = "Do Not Restrict Boosters"; +"GroupNotRestrictBoostersInfo" = "Turn this on to always allow users who boosted your group to send messages and media."; +"GroupNotRestrictBoostersInfo2" = "Choose how many boosts a user must give to the group to bypass restrictions on sending messages."; +"GroupNotifications" = "Group notifications"; +"GroupPhotosHelp" = "Group media into one message"; +"GroupPrivateSettingsJoinRequestInfo2" = "An admin must approve users who want to join the group."; +"GroupPrivateSettingsJoinRequestInfoManagedBy" = "An admin must approve users who want to join the group. Managed by **%s**"; +"GroupProfileInfo" = "Choose a color and a logo for the group's profile."; +"GroupPublicSettingsJoinRequestInfo2" = "An admin must approve users who want to send messages in the group."; +"GroupPublicSettingsJoinRequestInfoManagedBy" = "An admin must approve users who want to send messages in the group. Managed by **%s**"; +"GroupSettingsChangedAlert" = "You have changed settings in this group. Apply changes?"; +"GroupSettingsTitle" = "Group Settings"; +"GroupStickerPack" = "Group Sticker Pack"; +"GroupStickerPackInfo" = "You can choose a sticker set that will be shown to all group members."; +"GroupStickers" = "Group Stickers"; +"GroupStickersInfo" = "You can choose a sticker set that will be available to all group members when they are chatting in this group."; +"GroupThemePreviewSenderName" = "Monika"; +"GroupTooLarge" = "Group Too Large"; +"GroupTooLargeMessage" = "You can select groups with up to 200 members."; +"GroupType" = "Group Type"; +"GroupTypeHeader" = "Group type"; +"GroupUserAddLimit" = "Unable to join this group because it has too many members."; +"GroupUserCantAdd" = "Sorry, you can't add this user to groups."; +"GroupUserCantAdmin" = "Sorry, this group has too many admins."; +"GroupUserCantBot" = "There are too many bots in this group. Please remove some of the bots you're not using first."; +"GroupUserLeftError" = "If a person has left the group in the past, you need to be in their Telegram contacts to add them back.\n\nThey can still join via an invite link as long as they are not on the Removed Users list."; +"GroupWallpaper" = "Group Wallpaper"; +"GroupWallpaper2Info" = "Set a wallpaper that will be visible for everyone viewing your group."; +"GroupWallpaperInfo" = "Upload your own background image for the group."; +"Groups#one" = "%1$d group"; +"Groups#other" = "%1$d groups"; +"GroupsAndChannels" = "Groups & Channels"; +"GroupsAndChannelsHelp" = "Choose who can add you to groups and channels."; +"GroupsAndChannelsLimitSubtitle" = "Join up to %d channels and large groups"; +"GroupsAndChannelsLimitTitle" = "Groups and Channels"; +"GroupsEmojiPackUpdated" = "Group emoji pack updated"; +"GroupsInCommon" = "Groups in Common"; +"GroupsInCommonTitle" = "Groups In Common"; +"GrowthChartTitle" = "Growth"; +"GuardBotJoinRequestApproved" = "Your request to join **%1$s** has been approved"; +"GuardBotJoinRequestDeclined" = "Your request to join **%1$s** has been declined"; +"GuardBotJoinRequestQueued" = "Your request to join **%1$s** has been sent to the admins"; +"GuardBotReplaceKeepCurrent" = "Keep %1$s"; +"GuardBotReplaceMessage" = "**%1$s** is currently processing join requests. Switch to **%2$s** instead?"; +"GuardBotReplaceTitle" = "Replace Bot"; +"GuardBotReplaceUseNew" = "Use %1$s"; +"GuardBotTimeout" = "The admin bot cannot process the join request now. Try again later."; +"GuardBotTimeoutTitle" = "Join Request"; +"GuestBotFor" = " for "; +"HasBeenAddedToChannel" = "%1$s has been added to the channel"; +"HasBeenAddedToGroup" = "%1$s has been added to the group"; +"HashtagCopied" = "Hashtag copied to clipboard."; +"HashtagMessagesFound#one" = "%d message found"; +"HashtagMessagesFound#other" = "%d messages found"; +"HashtagMessagesFoundChannel#one" = "%1$d message in **%2$s**"; +"HashtagMessagesFoundChannel#other" = "%1$d messages in **%2$s**"; +"HashtagMessagesFoundSubtitle" = "View posts with %s"; +"HashtagSearchEmptyViewFilteredSubtitle" = "There were no results for %s.\nTry another hashtag."; +"HashtagSearchPlaceholder" = "Enter a hashtag to find messages that contain it."; +"HashtagStoriesFound#one" = "%d Story found"; +"HashtagStoriesFound#other" = "%d Stories found"; +"HashtagStoriesFoundChannel#one" = "%1$d story in **%2$s**"; +"HashtagStoriesFoundChannel#other" = "%1$d stories in **%2$s**"; +"HashtagStoriesFoundSubtitle" = "View stories with %s"; +"HashtagSuggestion1Text" = "searches posts from all channels"; +"HashtagSuggestion1Title" = "Use %s"; +"HashtagSuggestion2Text" = "searches only posts from this channel"; +"HashtagSuggestion2Title" = "Use %s"; +"Hashtags" = "HASHTAGS"; +"HelpUpgradeChannel" = "Help upgrade this channel"; +"HelpUpgradeGroup" = "Help upgrade group"; +"HidAccount" = "The account was hidden by the user"; +"HiddenName" = "Deleted Account"; +"HiddenSendersNameDescription" = "Sender names removed"; +"HiddenStories" = "Hidden Stories"; +"Hide" = "Hide"; +"HideAboveTheList" = "Hide from Chat List"; +"HideAd" = "Hide"; +"HideCallTab" = "Hide Calls Tab"; +"HideCaption" = "Hide Caption"; +"HideNewChatsAlertText" = "You are receiving many new chats from users who are not in your Contact List. Do you want to have such chats **automatically muted** and **archived**?"; +"HideNewChatsAlertTitle" = "Hide new chats?"; +"HideNextViews" = "Hide Upcoming Views"; +"HideNextViewsDescription" = "Hide my views for the next 25 minutes."; +"HideOnTop" = "Hide"; +"HidePinnedMessages" = "HIDE PINNED MESSAGES"; +"HidePinnedMessagesNoCaps" = "Hide pinned messages"; +"HideReadTime" = "Hide Read Time"; +"HideReadTimeInfo" = "Hide the time when you read messages from people who can’t see your last seen. If you turn this on, their read time will also be hidden from you (unless you are a Premium user).\n\nThis setting does not affect group chats."; +"HideRecentViews" = "Hide Recent Views"; +"HideRecentViewsDescription" = "Hide my views from the past 5 minutes."; +"HideSenderNames" = "Hide Sender Names"; +"HideSendersName" = "Hide Sender Name"; +"HideStoriesFromAlbum#one" = "Remove Story from Album"; +"HideStoriesFromAlbum#other" = "Remove Stories from Album"; +"HideTranslation" = "Hide Translation"; +"Highlights" = "Highlights"; +"HintAddYourPhoto" = "Add your photo! 📸"; +"HintAddYourPhotoText" = "Help your friends spot you easily."; +"HintRead" = "Read"; +"HintSent" = "Delivered"; +"HintViewStoriesMultiple#one" = "Tap here to view your %d story"; +"HintViewStoriesMultiple#other" = "Tap here to view your %d stories"; +"HistoryCleared" = "History was cleared"; +"HistoryClearedUndo" = "History cleared"; +"HoldToAudio" = "Hold to record audio. Tap to switch to video."; +"HoldToAudioOnly" = "Hold to record audio."; +"HoldToVideo" = "Hold to record video. Tap to switch to audio."; +"HourAgo" = "an hour ago"; +"Hours#one" = "%1$d hour"; +"Hours#other" = "%1$d hours"; +"HoursAgo#one" = "%d hour ago"; +"HoursAgo#other" = "%d hours ago"; +"HoursBold#one" = "**%1$d** hour"; +"HoursBold#other" = "**%1$d** hours"; +"HoursSchedule#one" = "%1$d hour"; +"HoursSchedule#other" = "%1$d hours"; +"HowDoesItWork" = "How does it work?"; +"Hybrid" = "Hybrid"; +"IUnderstand" = "I understand"; +"IVInteractionsChartTitle" = "IV Interactions"; +"IfInactiveFor" = "If inactive for"; +"Images#one" = "%d image"; +"Images#other" = "%d images"; +"ImagesTab" = "IMAGES"; +"ImagesTab2" = "Images"; +"ImporImportingService" = "Importing messages and media..."; +"ImporImportingStickersService" = "Importing stickers..."; +"Import" = "Import"; +"ImportContacts" = "Import Contacts"; +"ImportCount" = "%1$s of %2$s"; +"ImportDone" = "Done"; +"ImportDoneInfo" = "Save space on your device by importing more chats to Telegram."; +"ImportDoneTitle" = "Chat imported successfully!"; +"ImportErrorChatInvalidGroup" = "The file format is for a user, but you selected a group."; +"ImportErrorChatInvalidUser" = "The file format is for a group, but you selected a user."; +"ImportErrorFileFormatInvalid" = "Invalid file format."; +"ImportErrorFileLang" = "Unknown file language."; +"ImportErrorNotAdmin" = "You must be an admin in the group to import messages."; +"ImportErrorPeerInvalid" = "Unable to import history to selected chat."; +"ImportErrorTitle" = "Import Error"; +"ImportErrorUserBlocked" = "Unable to import messages due to privacy settings."; +"ImportFailedToUpload" = "Failed to upload file."; +"ImportFileTooLarge" = "File is too large for import."; +"ImportHeader" = "Import messages and media to..."; +"ImportHeaderContacts" = "Or choose one of your contacts"; +"ImportImportingInfo" = "Please keep Telegram open\nuntil the import is completed."; +"ImportImportingTitle" = "Importing chat"; +"ImportMessages" = "Import Messages"; +"ImportMutualError" = "You can only import messages into private chats with users with whom you are mutual contacts."; +"ImportNotAdmin" = "You must be an admin in the group to import messages."; +"ImportStickers" = "IMPORT %1$s"; +"ImportStickersDoneInfo" = "Import more stickers to Telegram and share them with your friends."; +"ImportStickersDoneTitle" = "Stickers imported successfully!"; +"ImportStickersEnterName" = "Enter name"; +"ImportStickersEnterNameInfo" = "Please choose a name for your set."; +"ImportStickersEnterUrlInfo" = "You can use a-z, 0-9 and underscores."; +"ImportStickersImportingTitle" = "Importing stickers"; +"ImportStickersLinkAvailable" = "This link is available."; +"ImportStickersLinkChecking" = "Checking link..."; +"ImportStickersLinkInvalid" = "Sorry, this link is invalid."; +"ImportStickersLinkInvalidLong" = "Link must not exceed 32 characters."; +"ImportStickersLinkInvalidShort" = "Link must have at least 5 characters."; +"ImportStickersLinkTaken" = "Sorry, this link is already taken."; +"ImportStickersProcessing" = "Processing stickers..."; +"ImportStickersRemove" = "REMOVE FROM IMPORT"; +"ImportStickersRemoveMenu" = "Remove from import"; +"ImportToChat" = "Do you want to import messages from **%1$s** to the group **%2$s**?\n\nMessages will be imported into the current day but will also include their original timestamps. All members will see the messages."; +"ImportToChatNoTitle" = "Do you want to import messages to the group **%1$s**?\n\nMessages will be imported into the current day but will also include their original timestamps. All members will see the messages."; +"ImportToUser" = "Do you want to import messages from **%1$s** to the chat with **%2$s**?\n\nMessages will be imported into the current day but will also include their original timestamps. Both sides will see the messages."; +"ImportToUserNoTitle" = "Do you want to import messages to the chat with **%1$s**?\n\nMessages will be imported into the current day but will also include their original timestamps. Both sides will see the messages."; +"ImportUploading" = "Importing messages and media... **%d%%**"; +"ImportedInfo" = "This message was imported from another app. We can't guarantee it's real."; +"ImportedMessage" = "Imported"; +"InAppNotifications" = "In-app notifications"; +"InAppPopup" = "In-App Pop on Screen"; +"InAppPopupInfo" = "Show pop-up notifications while in app."; +"InAppPreview" = "In-App Preview"; +"InAppSounds" = "In-App Sounds"; +"InAppVibrate" = "In-App Vibrate"; +"InChatSound" = "In-Chat Sounds"; +"InactiveChannelSignature" = "channel, inactive for %1$s"; +"InactiveChatSignature" = "%1$s, inactive for %2$s"; +"InactiveChats" = "Inactive chats"; +"InappBrowser" = "In-App Browser"; +"InappBrowserInfo" = "Open external links within the app"; +"IncomingCalls" = "Incoming calls"; +"IncomingCallsCount" = "%s incoming calls"; +"IncomingCallsSystemSetting" = "Internal call notifications"; +"IncomingCallsSystemSettingDescription" = "You can change your ringtone in Telegram > Settings > Notifications and Sounds."; +"IncorrectLocalization" = "Incorrect localization file"; +"IncorrectTheme" = "Sorry, this theme file appears to be corrupted."; +"IncreaseLimit" = "Increase Limit"; +"Infinity" = "∞"; +"Info" = "Info"; +"InstallGoogleMaps" = "Install Google Maps?"; +"InstallHuaweiMaps" = "Install Petal Maps?"; +"InstallOfficialApp" = "Install official app"; +"InstantView" = "INSTANT VIEW"; +"InstantViewNightMode" = "The dark theme will automatically turn on at night"; +"InstantViewReference" = "Reference"; +"InstantViewReferences" = "References"; +"InteractionsChartTitle" = "Interactions"; +"InternalFolderInfo" = "Browse your file system"; +"InternalStorage" = "Internal Storage"; +"InvLinkToChats" = "Invite link sent to **%1$s**."; +"InvLinkToGroup" = "Invite link sent to **%1$s**."; +"InvLinkToSavedMessages" = "Invite link sent to **Saved Messages**."; +"InvLinkToUser" = "Invite link sent to **%1$s**."; +"InvalidCode" = "Invalid code, please try again."; +"InvalidFirstName" = "This first name is not allowed, please try another."; +"InvalidFormatError" = "Invalid format"; +"InvalidLastName" = "Sorry, this last name can't be used"; +"InvalidPhoneNumber" = "Invalid phone number. Please check the number and try again."; +"Invisible" = "invisible"; +"Invitations#one" = "%1$d invitation"; +"Invitations#other" = "%1$d invitations"; +"Invite" = "Invite"; +"InviteBlockedManyMessage" = "You can send these people an invite link to the group in a private message instead."; +"InviteBlockedOneMessage" = "You can send an invite link to the group in a private message instead."; +"InviteBlockedTitle" = "Invite via Link"; +"InviteByQRCode" = "Invite by QR Code"; +"InviteCallMessagePremiumBlockedMany#other" = "**%2$s**, **%3$s** and **%1$d** more people only accept messages and invitations to calls from Contacts and **Premium** users."; +"InviteCallMessagePremiumBlockedOne" = "**%1$s** only accepts messages and invitations to calls from Contacts and **Premium** users."; +"InviteCallMessagePremiumBlockedThree" = "**%1$s**, **%2$s** and **%3$s** only accept invitations to calls from Contacts and **Premium** users."; +"InviteCallMessagePremiumBlockedTwo" = "**%1$s** and **%2$s** only accept messages and invitations to calls from Contacts and **Premium** users."; +"InviteCallRestrictedUsers#one" = "**%d user** restricts calling them. You can send them an invite link to this call instead."; +"InviteCallRestrictedUsers#other" = "**%d users** restrict calling them. You can send them an invite link to this call instead."; +"InviteCallRestrictedUsersOne" = "**%s** restricts calling them. You can send them an invite link to this call instead."; +"InviteChannelRestrictedUsers#one" = "**%d user** restricts adding them to channels. You can send them an invite link instead."; +"InviteChannelRestrictedUsers#other" = "**%d users** restrict adding them to channels. You can send them an invite link instead."; +"InviteChannelRestrictedUsers2#one" = "**%d user** can only be invited via link, but you don't have permission to share invite links to this channel."; +"InviteChannelRestrictedUsers2#other" = "**%d users** can only be invited via link, but you don't have permission to share invite links to this channel."; +"InviteChannelRestrictedUsers2One" = "**%s** can only be invited via link, but you don't have permission to share invite links to this channel."; +"InviteChannelRestrictedUsersOne" = "**%s** restricts adding them to channels. You can send them an invite link instead."; +"InviteExpired" = "This invite link has expired."; +"InviteFriends" = "Invite Friends"; +"InviteFriendsHelp" = "Select contacts to invite them to Telegram"; +"InviteLink" = "Invite Link"; +"InviteLinkCount#one" = "%1$d invite link"; +"InviteLinkCount#other" = "%1$d invite links"; +"InviteLinkPrivate" = "This topic link will only work for group members"; +"InviteLinkSent#one" = "Invite link sent to %d user."; +"InviteLinkSent#other" = "Invite link sent to %d users."; +"InviteLinkSentSingle" = "Invite link sent to **%s**."; +"InviteLinks" = "Invite Links"; +"InviteMessagePremiumBlockedMany#other" = "**%2$s**, **%3$s** and **%1$d** more people only accept messages and invitations to groups from Contacts and **Premium** users."; +"InviteMessagePremiumBlockedOne" = "**%1$s** only accepts messages and invitations to groups from Contacts and **Premium** users."; +"InviteMessagePremiumBlockedThree" = "**%1$s**, **%2$s** and **%3$s** only accept invitations to groups from Contacts and **Premium** users."; +"InviteMessagePremiumBlockedTwo" = "**%1$s** and **%2$s** only accept messages and invitations to groups from Contacts and **Premium** users."; +"InvitePremiumBlockedMany#other" = "**%2$s**, **%3$s** and **%1$d** more people only accept invitations to groups from Contacts and **Premium** users."; +"InvitePremiumBlockedOne" = "**%1$s** only accepts invitations to groups from Contacts and **Premium** users."; +"InvitePremiumBlockedOr" = "or"; +"InvitePremiumBlockedSubscribe" = "Subscribe to Telegram Premium"; +"InvitePremiumBlockedThree" = "**%1$s**, **%2$s** and **%3$s** only accept invitations to groups from Contacts and **Premium** users."; +"InvitePremiumBlockedTitle" = "Upgrade to Premium"; +"InvitePremiumBlockedTwo" = "**%1$s** and **%2$s** only accept invitations to groups from Contacts and **Premium** users."; +"InvitePremiumBlockedUser" = "available only to Premium users"; +"InviteRestrictedUsers#one" = "**%d user** restricts adding them to groups. You can send them an invite link instead."; +"InviteRestrictedUsers#other" = "**%d users** restrict adding them to groups. You can send them an invite link instead."; +"InviteRestrictedUsers2#one" = "**%d user** can only be invited via link, but you don't have permission to share invite links to this group."; +"InviteRestrictedUsers2#other" = "**%d users** can only be invited via link, but you don't have permission to share invite links to this group."; +"InviteRestrictedUsers2One" = "**%s** can only be invited via link, but you don't have permission to share invite links to this group."; +"InviteRestrictedUsersOne" = "**%s** restricts adding them to groups. You can send them an invite link instead."; +"InviteRevokedHint" = "The invite link has been revoked."; +"InviteText2" = "Hey, I'm using Telegram to chat. Join me! Download it here: %1$s"; +"InviteTextNum#one" = "Hey, I'm using Telegram to chat – and so is %1$d of our other contacts. Join us! Download it here: %2$s"; +"InviteTextNum#other" = "Hey, I'm using Telegram to chat – and so are %1$d of our other contacts. Join us! Download it here: %2$s"; +"InviteToChannelError" = "Sorry, you can't add this user to channels because of their privacy settings."; +"InviteToGroupByLink" = "Invite to Group via Link"; +"InviteToGroupError" = "Sorry, you can't add this user to groups because of their privacy settings."; +"InviteToGroupErrorMessageDouble" = "Sorry, you can't add **%1$s** and **%2$s** to groups because of their privacy settings."; +"InviteToGroupErrorMessageMultipleAll" = "Sorry, you can't add these users to groups because of their privacy settings."; +"InviteToGroupErrorMessageMultipleSome" = "Sorry, you can't add some of these users to groups because of their privacy settings."; +"InviteToGroupErrorMessageSingle" = "Sorry, you can't add **%1$s** to groups because of **%1$s**'s privacy settings."; +"InviteToGroupErrorTitleAUser" = "Can't add user"; +"InviteToGroupErrorTitleSomeUsers" = "Can't add some users"; +"InviteToGroupErrorTitleTheseUsers" = "Can't add these users"; +"InviteToGroupErrorTitleThisUser" = "Can't add this user"; +"InviteToTelegram" = "INVITE TO TELEGRAM"; +"InviteToTelegramShort" = "Invite To Telegram"; +"InviteUser" = "This user does not have Telegram yet, send an invitation?"; +"Invited" = "invited"; +"InvoiceCantForwardSecretChat" = "Sorry, invoices can’t be forwarded to secret chats."; +"InvoiceCantSendSecretChat" = "Sorry, invoices can’t be sent to secret chats."; +"IpAddress" = "IP address"; +"IsChoosingSticker" = "%1$s is ch**oo**sing a sticker"; +"IsEnjoyngAnimations" = "%1$s is **oo** watching %2$s"; +"IsRecordingAudio" = "%1$s is recording a voice message..."; +"IsRecordingRound" = "%1$s is recording a video message..."; +"IsRecordingVideo" = "%1$s is recording a video..."; +"IsSelectingContact" = "%1$s is selecting contact to share..."; +"IsSelectingLocation" = "%1$s is selecting location to share..."; +"IsSendingAudio" = "%1$s is sending audio..."; +"IsSendingFile" = "%1$s is sending a file..."; +"IsSendingGame" = "%1$s is playing a game..."; +"IsSendingPhoto" = "%1$s is sending a photo..."; +"IsSendingVideo" = "%1$s is sending a video..."; +"IsTyping" = "is typing..."; +"IsTypingGroup" = "%1$s is typing..."; +"Italic" = "Italic"; +"January" = "January"; +"JoinByPeekChannelText" = "This channel is private. Please join it to continue viewing its content."; +"JoinByPeekChannelTitle" = "Join Channel"; +"JoinByPeekGroupText" = "This group is private. Please join it to continue viewing its content."; +"JoinByPeekGroupTitle" = "Join Group"; +"JoinByPeekJoin" = "JOIN"; +"JoinCall" = "Join Call"; +"JoinGroup" = "JOIN GROUP"; +"JoinRequests#one" = "%1$d join request"; +"JoinRequests#other" = "%1$d join requests"; +"JoinToGroupErrorFull" = "Sorry, this group is already full."; +"JoinToGroupErrorNotExist" = "Sorry, this chat doesn't seem to exist."; +"JoinUsersRequests#one" = "%1$d join request"; +"JoinUsersRequests#other" = "%1$d join requests"; +"JoinedGroup" = "You joined the group."; +"JoinedGroupAddTag" = "Add Tag"; +"JoinedViaFolder" = "joined via a folder invite link"; +"JoinedViaInviteLinkApproved" = "un1 joined via invite link un2, approved by un3"; +"JoinedViaRequestApproved" = "un1 joined, approved by un2"; +"July" = "July"; +"JumpToDate" = "Jump to Date"; +"June" = "June"; +"KMetersAway" = "km away"; +"KMetersAway2" = "%1$s km away"; +"KMetersFromYou2" = "%1$s km from\u00a0you"; +"KMetersShort" = "%1$s km"; +"KeepMedia" = "Keep Media"; +"KeepMediaForever" = "Forever"; +"KeepMediaInfo" = "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space.\n\nAll media will stay in the Telegram cloud and can be re-downloaded if you need it again."; +"KeepMediaInfoPart" = "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space."; +"KeepMediaPopupDescription" = "Choose how often to clear cached media from this chat."; +"KickFromBroadcast" = "Remove from broadcast list"; +"KickFromGroup" = "Remove from group"; +"KickFromSupergroup" = "Restrict user"; +"Km" = "km"; +"Language" = "Language"; +"LanguageAlert" = "You are about to apply a language pack (**%1$s**) that is %2$d%% complete.\n\nThis will translate the entire interface. You can suggest corrections via the [translation platform].\n\nYou can change your language back at any time in Settings."; +"LanguageCode" = "en"; +"LanguageCustom" = "Custom"; +"LanguageCustomAlert" = "You are about to apply a custom language pack (**%1$s**) that is %2$d%% complete.\n\nThis will translate the entire interface. You can suggest corrections via the [translation platform].\n\nYou can change your language back at any time in Settings."; +"LanguageName" = "English"; +"LanguageNameInEnglish" = "English"; +"LanguageSame" = "You are already using this language pack (**%1$s**). You can change your language at any time in Settings."; +"LanguageSuggested" = "Install \"%1$s\" language?"; +"LanguageTitle" = "Change language?"; +"LanguageUnknownCustomAlert" = "Unfortunately, this custom language pack (**%1$s**) doesn't contain data for Telegram Android."; +"LanguageUnknownTitle" = "Insufficient data"; +"LanguageUnofficial" = "Unofficial languages"; +"LanguageUnsupportedError" = "Language not found."; +"Languages#one" = "%1$d Language"; +"Languages#other" = "%1$d Languages"; +"LanguagesChartTitle" = "Languages"; +"LargeEmoji" = "Large Emoji"; +"LastActiveCommunities" = "Least active communities"; +"LastName" = "Last name (optional)"; +"LastNameSmall" = "Last name"; +"LastSeen" = "last seen"; +"LastSeenContacts" = "My Contacts"; +"LastSeenContactsMinus" = "My Contacts (-%1$d)"; +"LastSeenContactsMinusPlus" = "My Contacts (-%1$d, +%2$d)"; +"LastSeenContactsPlus" = "My Contacts (+%1$d)"; +"LastSeenContactsPremium" = "Contacts & Premium"; +"LastSeenContactsPremiumMinus" = "Contacts & Premium (-%1$d)"; +"LastSeenContactsPremiumMinusPlus" = "Contacts & Premium (-%1$d, +%2$d)"; +"LastSeenContactsPremiumPlus" = "Contacts & Premium (+%1$d)"; +"LastSeenDate" = "last seen"; +"LastSeenDateFormatted" = "last seen %1$s"; +"LastSeenEverybody" = "Everybody"; +"LastSeenEverybodyMinus" = "Everybody (-%1$d)"; +"LastSeenFormatted" = "last seen %1$s"; +"LastSeenNobody" = "Nobody"; +"LastSeenNobodyPlus" = "Nobody (+%1$d)"; +"LastSeenNobodyPremium" = "Premium users"; +"LastSeenNobodyPremiumPlus" = "Premium (+%1$d)"; +"LastSeenTitle" = "Who can see my last seen time?"; +"Lately" = "last seen recently"; +"Later" = "LATER"; +"LearnMore" = "Learn More"; +"LearnMoreArrow" = "Learn More >"; +"LeaveAComment" = "Leave a comment"; +"LeaveAffiliateLink" = "Revoke Link"; +"LeaveAffiliateLinkAlert" = "Are you sure you want to revoke the link of **%1$s**?"; +"LeaveAffiliateLinkButton" = "Leave"; +"LeaveChannel" = "Leave Channel"; +"LeaveChannelAndAppoint" = "Appoint %s and Leave Channel"; +"LeaveChannelMenu" = "Leave Channel"; +"LeaveChannelNewOwnerText" = "If you leave, **%1$s** will become the new owner of **%2$s** in **1 week**."; +"LeaveChannelTitle" = "Leave Channel?"; +"LeaveChats" = "Leave %1$s"; +"LeaveCommunities#one" = "Leave community"; +"LeaveCommunities#other" = "Leave %d communities"; +"LeaveConversationMenu" = "Leave Conversation"; +"LeaveGroupAndAppoint" = "Appoint %s and Leave Group"; +"LeaveGroupNewOwnerText" = "If you leave, **%1$s** will become the new owner of **%2$s** in **1 week**."; +"LeaveGroupTitle" = "Leave Group?"; +"LeaveMega" = "Leave Group"; +"LeaveMegaMenu" = "Leave Group"; +"LeaveonversationMenu" = "Leave conversation"; +"LedColor" = "Light Color"; +"LedDisabled" = "Disabled"; +"LessMinuteAgo" = "just now"; +"Lifetime" = "Lifetime"; +"LightMode" = "Lite Mode (Beta)"; +"LightModeInfoRow" = "This mode turns off all visual effects in the app. It is recommended only for those devices that are low on CPU and memory."; +"Likes#one" = "%d like"; +"Likes#other" = "%d likes"; +"LimitByPeriod" = "Limit by time period"; +"LimitFree" = "Free"; +"LimitNumberOfUses" = "Limit by number of users"; +"LimitPremium" = "Premium"; +"LimitReached" = "Limit Reached"; +"LimitReachedAccounts" = "Sorry, you can't add more than **%1$d** accounts. You can increase this limit to **%2$d** by subscribing to **Telegram Premium**."; +"LimitReachedAccountsPremium" = "Sorry, you can't add more than **%1$d** accounts."; +"LimitReachedChatInFolders" = "Sorry, you can't add more than **%1$d** chats to a folder. You can increase this limit to **%2$d** by subscribing to **Telegram Premium**."; +"LimitReachedChatInFoldersLocked" = "Sorry, you can't add more than **%1$d** chats to a folder. Please create a new one. We are working to let you increase this limit in the future."; +"LimitReachedChatInFoldersPremium" = "Sorry, you can't add more than **%1$d** chats to a folder. Please create a new one."; +"LimitReachedCommunities" = "You are a member of **%1$d** groups and channels. Please leave some before joining a new one — or subscribe to **Telegram Premium** to double the limit to **%2$d** groups and channels."; +"LimitReachedCommunitiesLocked" = "You are a member of **%1$d** groups and channels. Please leave some before joining a new one. We are working to let you increase this limit in the future."; +"LimitReachedCommunitiesPremium" = "You are a member of **%1$d** groups and channels. Please leave some before joining a new one."; +"LimitReachedFavoriteGifs" = "Limit of %d GIFs Reached"; +"LimitReachedFavoriteGifsSubtitle" = "An older GIF was replaced with this one. You can **increase the limit** to %d GIFs."; +"LimitReachedFavoriteGifsSubtitlePremium" = "An older GIF was replaced with this one."; +"LimitReachedFavoriteStickers" = "Limit of %d Stickers Reached"; +"LimitReachedFavoriteStickersSubtitle" = "An older sticker was replaced with this one. You can **increase the limit** to %d stickers"; +"LimitReachedFavoriteStickersSubtitlePremium" = "An older sticker was replaced with this one."; +"LimitReachedFileSize" = "The document can’t be sent, because it is larger than **%1$s**. You can double this limit to **%2$s** per document by subscribing to **Telegram Premium**."; +"LimitReachedFileSizeLocked" = "The document can't be sent, because it is larger than **%1$s**. We are working to let you increase this limit in the future."; +"LimitReachedFileSizePremium" = "The document can't be sent, because it is larger than **%1$s**."; +"LimitReachedFolderLinks" = "You can only create %1$d invite links.\nUpgrade to **Telegram Premium** to create up to **%2$d**."; +"LimitReachedFolderLinksLocked" = "You can only create %1$d invite links. This number may increase in the future."; +"LimitReachedFolderLinksPremium" = "You can only create %1$d invite links. Try deleting some folder invite links."; +"LimitReachedFolders" = "You have reached the limit of **%1$d** folders. You can double the limit to **%2$d** folders by subscribing to **Telegram Premium**."; +"LimitReachedFoldersLocked" = "You have reached the limit of **%1$d** folders for this account. We are working to let you increase this limit in the future."; +"LimitReachedFoldersPremium" = "You have reached the limit of **%1$d** folders for this account."; +"LimitReachedPinDialogs" = "You can't pin more than %1$d chats to the top. Unpin some that are currently pinned – or subscribe to **Telegram Premium** to double the limit to **%2$d** chats."; +"LimitReachedPinDialogsLocked" = "Sorry, you can't pin more than %1$d chats to the top. Unpin some that are currently pinned. We are working to let you increase this limit in the future."; +"LimitReachedPinDialogsPremium" = "Sorry, you can't pin more than %1$d chats to the top. Unpin some that are currently pinned."; +"LimitReachedPinSavedDialogs" = "Sorry, you can't pin more than %1$d chats to the top. Unpin some that are currently pinned or subscribe to **Telegram Premium** to increase the limit to **%2$d** chats."; +"LimitReachedPinSavedDialogsLocked" = "Sorry, you can't pin more than %1$d chats to the top. Unpin some that are currently pinned. We are working to let you increase this limit in the future."; +"LimitReachedPinSavedDialogsPremium" = "Sorry, you can't pin more than %1$d chats to the top. Unpin some that are currently pinned."; +"LimitReachedPinnedTopics" = "Sorry, you can't pin more than %d topics to the top."; +"LimitReachedPublicLinks" = "You have reserved too many public links. Try revoking the link from an older group or channel, or subscribe to **Telegram Premium** to double the limit to **%2$d** public links."; +"LimitReachedPublicLinksLocked" = "You have reserved too many public links. Try revoking the link from an older group or channel. We are working to let you increase this limit in the future."; +"LimitReachedPublicLinksPremium" = "You have reserved too many public links. Try revoking the link from an older group or channel."; +"LimitReachedReorderFolder" = "Subscribe to **Telegram Premium** to move the \"%1$s\" folder."; +"LimitReachedSharedFolders" = "You can only have %1$d shareable folders.\nUpgrade to **Telegram Premium** to increase this limit to **%2$d**."; +"LimitReachedSharedFoldersLocked" = "You can only have %1$d shareable folders. This number may increase in the future."; +"LimitReachedSharedFoldersPremium" = "You can only have %1$d shareable folders. Try deleting one of your shareable folders."; +"LimitReachedStoriesCount" = "You can post **%1$d** story in **24** hours.\nSubscribe to **Telegram Premium** to increase this limit to **%2$d**."; +"LimitReachedStoriesCount2First#one" = "You can post **%d** story in **24** hours."; +"LimitReachedStoriesCount2First#other" = "You can post **%d** stories in **24** hours."; +"LimitReachedStoriesCount2Premium#one" = "Sorry, you can’t post more than **%d** story in **24** hours."; +"LimitReachedStoriesCount2Premium#other" = "Sorry, you can’t post more than **%d** stories in **24** hours."; +"LimitReachedStoriesCount2Second#one" = "Subscribe to **Telegram Premium** to increase this limit to **%d**."; +"LimitReachedStoriesCount2Second#other" = "Subscribe to **Telegram Premium** to increase this limit to **%d**."; +"LimitReachedStoriesCountPremium" = "Sorry, you can't post more than **%1$d** stories."; +"LimitReachedStoriesMonthly" = "You can post **%1$d** stories per month.\nSubscribe to **Telegram Premium** to increase this limit to **%2$d**."; +"LimitReachedStoriesMonthlyPremium" = "Sorry, you can't post more than **%1$d** stories per month."; +"LimitReachedStoriesWeekly" = "You can post **%1$d** stories per week.\nSubscribe to **Telegram Premium** to increase this limit to **%2$d**."; +"LimitReachedStoriesWeeklyPremium" = "Sorry, you can't post more than **%1$d** stories per week."; +"LinkAbove" = "Move Up"; +"LinkActionCopy" = "Copy"; +"LinkActionShare" = "Share"; +"LinkAvailable" = "%1$s is available."; +"LinkBelow" = "Move Down"; +"LinkChecking" = "Checking name…"; +"LinkCopied" = "Link copied to clipboard."; +"LinkCopiedPrivate" = "Link copied to clipboard. This link will only work for members of this chat."; +"LinkCopiedPrivateInfo" = "This link will only work for members of this chat."; +"LinkCreatedeBy" = "Link created by"; +"LinkDesktopDevice" = "Add Device"; +"LinkExpiresIn" = "The link expires %s"; +"LinkExpiresInTime" = "The link expires in %s"; +"LinkForBoosting" = "Link for boosting"; +"LinkHashExpired" = "Link is no longer active"; +"LinkInUse" = "Sorry, this link is already taken."; +"LinkInfo" = "Anyone who has Telegram installed will be able to join your group by following this link."; +"LinkInfoChannel" = "Anyone who has Telegram installed will be able to join your channel by following this link."; +"LinkInvalid" = "This public link is invalid."; +"LinkInvalidLong" = "Public links must not exceed 32 characters."; +"LinkInvalidShort" = "Public links must have at least 5 characters."; +"LinkInvalidShortMega" = "Public links must have at least 5 characters."; +"LinkInvalidStartNumber" = "Public links can't start with a number."; +"LinkInvalidStartNumberMega" = "Public links can't start with a number."; +"LinkIsExpired" = "The time limit for this link has expired."; +"LinkIsExpiredLimitReached" = "This link reached its usage limit."; +"LinkIsNoActive" = "This link is no longer active."; +"LinkLimitReached" = "Limit reached"; +"LinkMediaLarger" = "Enlarge Photo"; +"LinkMediaSmaller" = "Shrink Photo"; +"LinkNameHelp" = "Only admins will see this name."; +"LinkNameHint" = "Link Name (Optional)"; +"LinkNotFound" = "Unfortunately, you can't access this message. You aren't a member of the chat where it was posted."; +"LinkPreview" = "Link preview"; +"LinkRevenue" = "Subscription fee"; +"LinkRevenuePrice" = "⭐️%1$d/month"; +"LinkRevenuePriceInfo" = "you get approximately %1$s monthly"; +"LinkSharedTo" = "Link forwarded to **%s**."; +"LinkSharedToManyChats#one" = "Link forwarded to **a chat**."; +"LinkSharedToManyChats#other" = "Link forwarded to **%1$d chats**."; +"LinkSharedToSavedMessages" = "Link forwarded to **Saved Messages**"; +"LinkVideoLarger" = "Enlarge Video"; +"LinkVideoSmaller" = "Shrink Video"; +"LinkedChannel" = "Linked Channel"; +"Links#one" = "%1$d link"; +"Links#other" = "%1$d links"; +"LinksCreatedByOtherAdmins" = "Links created by other admins"; +"LinksCreatedByThisAdmin" = "Invite links created by this admin"; +"LinksTitle" = "Shared Links"; +"Listening" = "listening"; +"LiteBatteryAlwaysDisabled" = "Always Disabled"; +"LiteBatteryAlwaysEnabled" = "Always Enabled"; +"LiteBatteryDisabled" = "Off"; +"LiteBatteryEnabled" = "On"; +"LiteBatteryInfoBelow" = "Automatically reduce power usage and animations when your battery is below %s."; +"LiteBatteryInfoDisabled" = "Never reduce power usage and keep all animations even when your battery is low."; +"LiteBatteryInfoEnabled" = "Always reduce power usage regardless of your battery level."; +"LiteBatteryRestricted" = "Please turn off Power Saving Mode to change these settings."; +"LiteBatteryTitle" = "Power Saving Mode"; +"LiteBatteryWhenBelow" = "When below %s"; +"LiteMode" = "Animations"; +"LiteModeInfo" = "Reduce motion effects to save power"; +"LiteOptionsAutoplayChat" = "Autoplay in chat"; +"LiteOptionsAutoplayGifs" = "Autoplay GIFs"; +"LiteOptionsAutoplayKeyboard" = "Autoplay in keyboard"; +"LiteOptionsAutoplayReactions" = "Autoplay in reaction menu"; +"LiteOptionsAutoplayVideo" = "Autoplay Videos"; +"LiteOptionsBackground" = "Wallpaper rotation"; +"LiteOptionsBlur" = "Night theme blur"; +"LiteOptionsBlur2" = "Blur"; +"LiteOptionsCalls" = "Animations in Calls"; +"LiteOptionsChat" = "Effects in Chats"; +"LiteOptionsEmoji" = "Animated Emoji"; +"LiteOptionsLiquidGlass" = "Liquid Glass"; +"LiteOptionsParticles" = "Particles"; +"LiteOptionsScale" = "Zoom animations"; +"LiteOptionsSpoiler" = "Animated spoiler effect"; +"LiteOptionsStickers" = "Animated Stickers"; +"LiteOptionsThanos" = "Dust-effect deletion"; +"LiteOptionsTitle" = "Power saving options"; +"LiteOptionsTopics" = "Side menu in topics"; +"LitePowerSaver" = "Save Power on Low Battery"; +"LitePowerSaverInfo" = "Automatically switch to low power usage when your system is in Battery Saving Mode."; +"LiteSmoothTransitions" = "Enable Smooth Transitions"; +"LiteSmoothTransitionsInfo" = "You can disable animated transitions between different sections of the app."; +"LiveLocationAlertExpandMessage" = "For how long do you want to extend sharing your location?"; +"LiveLocationAlertExpandTitle" = "Add More Time"; +"LiveLocationAlertGroup" = "Choose for how long people in this chat will see your accurate location, including when the app is closed."; +"LiveLocationAlertPrivate" = "Choose for how long %1$s will see your accurate location, including when the app is closed."; +"LiveLocationContext" = "Live Location"; +"LiveLocations" = "Live locations"; +"LivePhotoOff" = "Motion Photo **Off**"; +"LivePhotoOn" = "Motion Photo **On**"; +"LivePhotoSavedHint" = "Motion Photo saved to gallery"; +"LivePhotosOff" = "Motion Photos turned **Off**"; +"LivePhotosOn" = "Motion Photos turned **On**"; +"LivePhotosSavedHint#one" = "Motion Photo saved to gallery"; +"LivePhotosSavedHint#other" = "%1$d Motion Photos saved to gallery"; +"LiveStoryAdminRole" = "admin"; +"LiveStoryAlreadyStreaming" = "You already have an active live stream."; +"LiveStoryAlreadyStreamingTitle" = "Already Live"; +"LiveStoryBadge" = "LIVE"; +"LiveStoryCommentPlaceholder" = "Comment"; +"LiveStoryCommentsDisabled" = "Comments Disabled"; +"LiveStoryDisconnected" = "No Stream"; +"LiveStoryDisconnectedContinue" = "Continue Streaming"; +"LiveStoryEnd" = "End Live Stream"; +"LiveStoryEndAlertButton" = "Stop"; +"LiveStoryEndAlertText" = "Are you sure you want to stop the live stream?"; +"LiveStoryEndAlertTitle" = "End Live Stream"; +"LiveStoryHighlightFeatureEmoji" = "emoji"; +"LiveStoryHighlightFeatureLength" = "characters"; +"LiveStoryHighlightFeaturePin" = "pin in chat"; +"LiveStoryHighlightHint" = "Tap here to highlight message"; +"LiveStoryHighlightText" = "Highlight and pin your message by adding Stars for **%s**"; +"LiveStoryHighlightTitle" = "Highlight and Pin"; +"LiveStoryMessageEditStars" = "Edit Stars"; +"LiveStoryMessageRemoveStars" = "Remove Stars"; +"LiveStoryMessageSent" = "Sent %s"; +"LiveStoryPeerChange" = "change >"; +"LiveStoryPricePerComment" = "Price per comment"; +"LiveStoryPricePerCommentFree" = "Free"; +"LiveStoryPricePerCommentInfo" = "The price a viewer must pay to send a comment."; +"LiveStoryRTMPDisable" = "Stream from your phone"; +"LiveStoryRTMPEnable" = "Connect Stream"; +"LiveStoryRTMPEnableInfo" = "Stream with a different app."; +"LiveStoryRTMPRevoke" = "Revoke Stream Key"; +"LiveStoryRTMPRevokeText" = "Are you sure you want to regenerate your stream key?"; +"LiveStoryRTMPRevokeTitle" = "Revoke Stream Key"; +"LiveStoryRTMPSettings" = "RTMP Settings"; +"LiveStoryReactAdminCant" = "You can't send Star Reactions to your own Live Story."; +"LiveStoryReactAdminEmptyText" = "Viewers can send you Star Reactions and Top Senders will appear here."; +"LiveStoryReactAdminText" = "Viewers can send you Star Reactions."; +"LiveStoryReactAdminTitle" = "Receive Stars from Viewers"; +"LiveStoryReactText" = "Highlight and pin your message by sending Stars to **%s**"; +"LiveStoryReactTitle" = "React with Stars"; +"LiveStorySettings" = "Live Settings"; +"LiveStorySettingsHeader" = "Live Settings"; +"LiveStoryStart" = "Start Live Stream"; +"LiveStoryTopPanelWatching#one" = "%d watching"; +"LiveStoryTopPanelWatching#other" = "%d watching"; +"LiveStoryWatching#one" = "%d watching"; +"LiveStoryWatching#other" = "%d watching"; +"LiveStreamWillNotNotify" = "You will not be notified when the live stream starts."; +"LiveStreamWillNotify" = "You will be notified when the live stream starts."; +"Loading" = "Loading..."; +"LoadingFullImage" = "loading full image..."; +"LoadingStats" = "Loading stats..."; +"LoadingStatsDescription" = "Please wait a few moments while\nwe generate your stats."; +"LocalAudioCache" = "Voice messages"; +"LocalCache" = "Other files"; +"LocalDatabase" = "Clear Local Database"; +"LocalDatabaseClear" = "Clear cached text messages?"; +"LocalDatabaseClearText" = "Are you sure you want to clear cached text messages?"; +"LocalDatabaseClearText2" = "This operation may take a few minutes to complete.\n\nLocal database size: **%s**"; +"LocalDatabaseClearTextTitle" = "Clear local database"; +"LocalDatabaseInfo" = "Clearing the local database will delete the texts of cached messages and compress the database to save internal disk space. Telegram needs some data to work, so database size will not reach zero.\n\nThis operation can take a few minutes to complete."; +"LocalDatabaseSize" = "%s Telegram Local Database"; +"LocalDocumentCache" = "Documents"; +"LocalGifCache" = "GIFs"; +"LocalLogsCache" = "Logs"; +"LocalMiscellaneousCache" = "Miscellaneous"; +"LocalMusicCache" = "Music"; +"LocalOther" = "Other"; +"LocalPhotoCache" = "Photos"; +"LocalProfilePhotosCache" = "Profile Photos"; +"LocalStickersCache" = "Stickers & Emoji"; +"LocalStoriesCache" = "Stories"; +"LocalVideoCache" = "Videos"; +"LocatedChannelsTooMuch" = "Sorry, the target user owns too many location-based groups. Please ask them to delete or transfer an existing one first."; +"Location" = "Location based on the IP address"; +"LocationNotifiation" = "Proximity alert"; +"LocationNotifiationButton" = "Notify me within %1$s"; +"LocationNotifiationButtonGroup" = "Notify when someone is within %1$s"; +"LocationNotifiationButtonUser" = "Notify when %1$s is within %2$s"; +"LocationNotifiationCloser" = "You are already closer than %1$s"; +"LocationOnMap" = "Location on a map"; +"LocationStories#one" = "%d story from this location"; +"LocationStories#other" = "%d stories from this location"; +"LocationUpdated" = "updated"; +"LocationUpdatedFormatted" = "updated %1$s"; +"LocationUpdatedJustNow" = "updated just now"; +"LocationsShowAll" = "Show All"; +"LogOut" = "Log Out"; +"LogOutInfo" = "Logging out deactivates all Secret Chats."; +"LogOutTitle" = "Log Out"; +"LoginAccessibilityCountryCode" = "Country code"; +"LoginAttempts" = "Incomplete login attempts"; +"LoginAttemptsInfo" = "The devices above have no access to your messages. The code was entered correctly, but no correct password was given."; +"LoginCantAccessThisEmail" = "Can't access this email?"; +"LoginEmailResetButton" = "Reset Login Email"; +"LoginEmailResetInDoublePattern" = "%1$s and %2$s"; +"LoginEmailResetInSinglePattern" = "%1$s"; +"LoginEmailResetInTime" = "Email will be reset in %1$s.\n*Reset now via SMS*"; +"LoginEmailResetMessage" = "You can change your login email if you are logged into Telegram from another device. Otherwise, if you don't have access to email %1$s, you can reset this email with an **SMS** code in **%2$s**."; +"LoginEmailResetPleaseWait" = "Resetting email, please wait..."; +"LoginEmailResetPremiumRequiredMessage" = "Due to the high cost of SMS in your country, you need a **Telegram Premium** account to immediately reset this email via an SMS code.\n\nYou can ask a friend to gift a Premium subscription for your account **%1$s**"; +"LoginEmailResetPremiumRequiredTitle" = "Telegram Premium Required"; +"LoginEmailResetTitle" = "Reset Email"; +"LoginOrSingInWithGoogle" = "or"; +"LoginPassword" = "Password"; +"LoginPasswordText" = "You have Two-Step Verification enabled, so your account is protected with an additional password."; +"LoginPasswordTextShort" = "Two-Step Verification enabled. Your account is protected with an additional password."; +"Long" = "Long"; +"LoopAnimatedStickers" = "Loop Animated Stickers"; +"LoopAnimatedStickersInfo" = "Animated stickers will play continuously in chats."; +"LowDiskSpaceButton" = "Manage Cache"; +"LowDiskSpaceMessage" = "Your device is almost out of disk space. To free some space, you can set up Telegram to cache only recent media."; +"LowDiskSpaceMessage2" = "Your device is almost out of disk space. You can use cache settings to remove unnecessary media — and re-download files if you need them again."; +"LowDiskSpaceNeverRemove" = "Never remove"; +"LowDiskSpaceTitle" = "Attention"; +"LowDiskSpaceTitle2" = "Remove media after"; +"LowPowerEnabledSubtitle" = "%s battery remaining"; +"LowPowerEnabledTitle" = "Power Saving Mode enabled"; +"MainChannelProfilePhotoSetHint" = "This is the main channel photo now."; +"MainChannelProfileVideoSetHint" = "This is the main channel photo now."; +"MainGroupProfilePhotoSetHint" = "This is the main group photo now."; +"MainGroupProfileVideoSetHint" = "This is the main group photo now."; +"MainProfilePhotoSetHint" = "This is your main profile photo now."; +"MainProfileVideoSetHint" = "This is your main profile photo now."; +"MainTabsCalls" = "Calls"; +"MainTabsChats" = "Chats"; +"MainTabsContacts" = "Contacts"; +"MainTabsProfile" = "Profile"; +"MainTabsSettings" = "Settings"; +"MakeAdmin" = "MAKE ADMIN"; +"MakeMyselfVisible" = "Make Myself Visible"; +"MakeMyselfVisibleInfo" = "Users nearby will be able to view your profile and send you messages. This may help you find new friends, but could also attract excessive attention. You can stop sharing your profile at any time.\n\nYour phone number will remain hidden."; +"MakeMyselfVisibleTitle" = "Show Your Profile?"; +"MakeYourFirstCall" = "Make your first call"; +"MakeYourFirstCallHint" = "You can make end-to-end encrypted calls\nwith up to %d friends at once"; +"ManageAllFilesRational" = "Please allow **Telegram** to manage all files to open in-app file browser"; +"ManageAllFilesRational2" = "Telegram needs permission to manage all files to migrate old cache files to Scoped Storage"; +"ManageChannel" = "Manage Channel"; +"ManageChannelMenu" = "Manage channel"; +"ManageDeviceStorage" = "Manage Device Storage"; +"ManageGroup" = "Manage Group"; +"ManageGroupMenu" = "Manage group"; +"ManageInviteLinks" = "Manage Invite Links"; +"ManageLinksInfoHelp" = "You can create additional invite links that have a limited time or number of users."; +"ManageLinksInfoHelpPaid" = "You can create additional invite links that have a limited time, number of uses or require a paid subscription."; +"ManageTopicsPermission" = "Manage topics"; +"ManagedBotChatInfo" = "**%1$s** is ready!\n\nTap **Start** below to test your new chatbot. Its behavior is defined by **%2$s**."; +"Map" = "Map"; +"MapPreviewProvider" = "Map Preview Provider"; +"MapPreviewProviderGoogle" = "Google"; +"MapPreviewProviderNobody" = "No Previews"; +"MapPreviewProviderTelegram" = "Telegram"; +"MapPreviewProviderTitle" = "Map preview provider"; +"MapPreviewProviderYandex" = "Yandex"; +"March" = "March"; +"MarkAllAsRead" = "Mark all as read"; +"MarkAsRead" = "Mark as read"; +"MarkAsUnread" = "Mark as unread"; +"Masks" = "Masks"; +"MasksArchived" = "Masks archived."; +"MasksArchivedInfo" = "%1$s has been moved to your archive."; +"MasksCount#one" = "%1$d mask"; +"MasksCount#other" = "%1$d masks"; +"MasksInfo" = "You can add masks in the media editor when sending photos."; +"MasksRemoved" = "Masks removed."; +"MasksRemovedInfo" = "%1$s is no longer in your masks."; +"MaxCacheSize" = "Maximum cache size"; +"MaxCacheSizeInfo" = "If your cache size exceeds this limit, the oldest unused media will be removed from the device."; +"MaxVideoSize" = "Maximum video size"; +"MaximumReactionsHeader" = "Maximum number of reactions"; +"MaximumReactionsInfo" = "Limit the number of different reactions that can be added to a post, including already published ones."; +"MaximumReactionsValue#one" = "%d reaction per post"; +"MaximumReactionsValue#other" = "%d reactions per post"; +"May" = "May"; +"Media#one" = "%1$d media"; +"Media#other" = "%1$d media"; +"MediaAndSoundSettings" = "Media and Sound"; +"MediaSavedHint#one" = "One item saved to gallery."; +"MediaSavedHint#other" = "%1$d items saved to gallery."; +"MediaSelected#one" = "%1$d media selected"; +"MediaSelected#other" = "%1$d media selected"; +"MediaShowPhotos" = "Show Photos"; +"MediaShowVideos" = "Show Videos"; +"MediaTab" = "Media"; +"MediaZoomIn" = "Zoom In"; +"MediaZoomOut" = "Zoom Out"; +"MegaAddedBy" = "un1 added you to this group"; +"MegaAdminsInfo" = "You can add admins to help you manage your group. Press and hold to remove them."; +"MegaDeleteAlert" = "Deleting this group will remove all members and all messages will be lost. Do you want to delete the group?"; +"MegaDeleteInfo" = "You will lose all messages in this group."; +"MegaLeaveAlert" = "Do you want to leave\u00a0this group?"; +"MegaLeaveAlertWithName" = "Are you sure you want to leave **%1$s**?"; +"MegaLocation" = "Location-based Group"; +"MegaPrivate" = "Private Group"; +"MegaPrivateInfo" = "Private groups can only be joined if you were invited or have an invite link."; +"MegaPrivateLinkHelp" = "People can join your group by following this link. You can revoke the link at any time."; +"MegaPublic" = "Public Group"; +"MegaPublicInfo" = "Public groups can be found in search, chat history is available to everyone and anyone can join."; +"MegaUsernameHelp" = "If you set a public link, other people will be able to find and join your group.\n\nYou can use a–z, 0–9 and underscores.\nMinimum length is 5 characters."; +"MemberRequests" = "Join Requests"; +"MemberTagButtonAdd" = "Add Tag"; +"MemberTagButtonEdit" = "Edit Tag"; +"MemberTagButtonRemove" = "Remove Tag"; +"MemberTagHintAdd" = "Add Member Tag"; +"MemberTagHintEdit" = "Edit Member Tag"; +"MemberTagInfo" = "Share your role, title or how you're known in this group. Your tag is visible to all members."; +"MemberTagSelfInfo" = "Share your role, title or how you’re known in this group. Your tag is visible to all members."; +"MemberTagTheirInfo" = "Add a short tag next to %s’s name."; +"MemberTagTitle" = "Member Tag"; +"Members#one" = "%1$d member"; +"Members#other" = "%1$d members"; +"MembersAreSpeakingToast#one" = "un1 is speaking"; +"MembersAreSpeakingToast#other" = "un1 are speaking"; +"MembersCount" = "%1$d of %2$d selected"; +"MembersCountSelected#one" = "%1$d of %2$d selected"; +"MembersCountSelected#other" = "%1$d of %2$d selected"; +"MembersCountZero" = "up to %1$s"; +"MembersLanguageChartTitle" = "Members' primary language"; +"MembersOverviewTitle" = "Members"; +"MembersSearch" = "Search people"; +"MembersTalking#one" = "%1$s member speaking"; +"MembersTalking#other" = "%1$s members talking"; +"MembersTalkingNobody" = "Tap to join"; +"Mention" = "Mention"; +"MenuProxyConnected" = "Connected"; +"MenuProxyConnecting" = "Connecting"; +"MenuProxyDisabled" = "Disabled"; +"MenuProxyTitle" = "Proxy"; +"MenuTelegramStars" = "My Stars"; +"Message" = "Message"; +"MessageAddFile" = "Add File"; +"MessageAddMusic" = "Add Music"; +"MessageAddPhotoVideo" = "Add Photo or Video"; +"MessageAuthorSentBy" = "Sent by **%s**"; +"MessageBizBot" = "Only you can see that this message is sent by a **bot**."; +"MessageContainsEmojiPack" = "This message contains emoji from %s pack."; +"MessageContainsEmojiPackSingle" = "This message contains **%s** emoji."; +"MessageContainsEmojiPacks#other" = "This message contains emoji from **%d Packs**."; +"MessageContainsReactionPack" = "This reaction is from %s emoji pack."; +"MessageContainsReactionsPack" = "Emoji from %s pack were used as reactions."; +"MessageContainsReactionsPacks#other" = "Emoji from **%d packs** were used as reactions."; +"MessageCopied" = "Message copied to clipboard."; +"MessageGroupTodoList" = "Group Checklist"; +"MessageLifetime" = "Self-Destruct Timer"; +"MessageLifetimeChanged" = "%1$s set the self-destruct timer to %2$s"; +"MessageLifetimeChangedOutgoing" = "You set the self-destruct timer to %1$s"; +"MessageLifetimePhoto" = "If you set a timer, the photo will self-destruct after it is viewed."; +"MessageLifetimeRemoved" = "%1$s disabled the self-destruct timer"; +"MessageLifetimeVideo" = "If you set a timer, the video will self-destruct after it is viewed."; +"MessageLifetimeYouRemoved" = "You disabled the self-destruct timer"; +"MessageLockedPremium" = "Subscribe to **Premium** to message **%s**."; +"MessageLockedPremiumLocked" = "**%s** only accepts messages from contacts and Premium users."; +"MessageLockedStars" = "**%1$s** charges **⭐️ %2$s** for each message."; +"MessageLockedStarsConfirmMessage1#one" = "**%2$s** charges **%1$d** Star per message."; +"MessageLockedStarsConfirmMessage1#other" = "**%2$s** charges **%1$d** Stars per message."; +"MessageLockedStarsConfirmMessage2Many1#one" = "Would you like to pay **%d** Star"; +"MessageLockedStarsConfirmMessage2Many1#other" = "Would you like to pay **%d** Stars"; +"MessageLockedStarsConfirmMessage2Many2#one" = "to send %d message?"; +"MessageLockedStarsConfirmMessage2Many2#other" = "to send %d messages?"; +"MessageLockedStarsConfirmMessage2One#one" = "Would you like to pay **%1$d** Star to send one message?"; +"MessageLockedStarsConfirmMessage2One#other" = "Would you like to pay **%1$d** Stars to send one message?"; +"MessageLockedStarsConfirmMessageDontAsk" = "Don't ask me again"; +"MessageLockedStarsConfirmMessageMulti1#one" = "You selected **%1$d** chat that charges Stars for messages."; +"MessageLockedStarsConfirmMessageMulti1#other" = "You selected **%1$d** chats that charge Stars for messages."; +"MessageLockedStarsConfirmMessageMulti2#one" = "Would you like to pay **%1$d** Star to send %2$s?"; +"MessageLockedStarsConfirmMessageMulti2#other" = "Would you like to pay **%1$d** Stars to send %2$s?"; +"MessageLockedStarsConfirmMessageMulti2Messages#one" = "**%d** message"; +"MessageLockedStarsConfirmMessageMulti2Messages#other" = "**%d** messages"; +"MessageLockedStarsConfirmMessagePay#one" = "Pay for %d Message"; +"MessageLockedStarsConfirmMessagePay#other" = "Pay for %d Messages"; +"MessageLockedStarsConfirmTitle" = "Confirm Payment"; +"MessageLockedStarsRemoveFee" = "%1$s must pay ⭐️ %2$s for each message to you.\n**Remove Fee**"; +"MessageLockedStarsRemoveFeeShort" = "%1$s pays ⭐️ %2$s for each message to you.\n**Remove Fee**"; +"MessageNotFound" = "Message doesn't exist"; +"MessageNotifications" = "Message notifications"; +"MessageOptionsForward" = "Forward"; +"MessageOptionsLink" = "Link"; +"MessageOptionsLinkSubtitle" = "Change how your link looks"; +"MessageOptionsLinkTitle" = "Link Preview"; +"MessageOptionsReply" = "Reply"; +"MessageOptionsReplySubtitle" = "You can select a specific part to quote"; +"MessageOptionsReplyTitle" = "Reply to Message"; +"MessagePinnedHint" = "Message pinned."; +"MessagePlayed#one" = "Played"; +"MessagePlayed#other" = "%1$d Played"; +"MessagePremiumErrorMessage" = "%s only accepts messages from contacts or Premium users."; +"MessagePremiumErrorTitle" = "Unable to send"; +"MessagePremiumUnlock" = "Get Premium"; +"MessagePreview" = "Show Message Previews"; +"MessageReplaceFile" = "To File"; +"MessageReplaceMusic" = "To Music"; +"MessageReplacePhotoVideo" = "To Photo or Video"; +"MessageScheduleEditTime" = "Reschedule"; +"MessageScheduleSend" = "Send Now"; +"MessageScheduleToday" = "Today"; +"MessageScheduledBotAction" = "This action will become available after the message is published."; +"MessageScheduledLimitReached" = "Sorry, you can not schedule more than 100 messages."; +"MessageScheduledOn" = "Scheduled for %1$s"; +"MessageScheduledReminderNotification" = "📅 Reminder"; +"MessageScheduledRepeatBiweekly" = "biweekly"; +"MessageScheduledRepeatDaily" = "daily"; +"MessageScheduledRepeatDeleteAll" = "Delete All Future Messages"; +"MessageScheduledRepeatDeletePostponeDays#one" = "Postpone By %d Day"; +"MessageScheduledRepeatDeletePostponeDays#other" = "Postpone By %d Days"; +"MessageScheduledRepeatDeletePostponeMonths#one" = "Postpone By %d Month"; +"MessageScheduledRepeatDeletePostponeMonths#other" = "Postpone By %d Months"; +"MessageScheduledRepeatDeletePostponeSeconds" = "Postpone By %ds"; +"MessageScheduledRepeatDeletePostponeWeeks#one" = "Postpone By %d Week"; +"MessageScheduledRepeatDeletePostponeWeeks#other" = "Postpone By %d Weeks"; +"MessageScheduledRepeatDeletePostponeYear" = "Postpone By 1 Year"; +"MessageScheduledRepeatDeleteText" = "Are you sure you want to delete this scheduled message? This is a repeating message."; +"MessageScheduledRepeatDeleteTitle" = "Delete Repeating Message"; +"MessageScheduledRepeatMonthly" = "monthly"; +"MessageScheduledRepeatMonthlyMany#one" = "%d-monthly"; +"MessageScheduledRepeatMonthlyMany#other" = "%d-monthly"; +"MessageScheduledRepeatOption" = "Repeat:"; +"MessageScheduledRepeatOption3Monthly" = "Every 3 months"; +"MessageScheduledRepeatOption6Monthly" = "Every 6 months"; +"MessageScheduledRepeatOptionBiweekly" = "Biweekly"; +"MessageScheduledRepeatOptionDaily" = "Daily"; +"MessageScheduledRepeatOptionMonthly" = "Monthly"; +"MessageScheduledRepeatOptionNever" = "Never"; +"MessageScheduledRepeatOptionWeekly" = "Weekly"; +"MessageScheduledRepeatOptionYearly" = "Yearly"; +"MessageScheduledRepeatPremium" = "Subscribe to **Telegram Premium** to schedule repeating messages."; +"MessageScheduledRepeatSeconds" = "every %1$ds"; +"MessageScheduledRepeatWeekly" = "weekly"; +"MessageScheduledRepeatYearly" = "yearly"; +"MessageScheduledTodo" = "Checklist will become available after the message is published."; +"MessageScheduledUntilOnline" = "Scheduled until online"; +"MessageScheduledVote" = "Voting will become available after the message is published."; +"MessageScheduledVoteResults" = "Poll results will become available after the message is published."; +"MessageSeen#one" = "Seen"; +"MessageSeen#other" = "%1$d Seen"; +"MessageSeenTooltipMessage" = "To protect privacy, views are only stored for **7 days**."; +"MessageSensitiveContent" = "18+"; +"MessageSensitiveContentShort" = "18+"; +"MessageSent" = "Message sent."; +"MessageShowSensitiveContentAlways" = "Always show 18+ media"; +"MessageShowSensitiveContentButton" = "View Anyway"; +"MessageShowSensitiveContentChannelText" = "This chat may contain sensitive content suitable only for adults. Do you still want to view it?"; +"MessageShowSensitiveContentChannelTextClosed" = "This chat may contain sensitive content suitable only for adults."; +"MessageShowSensitiveContentChannelTextClosedButton" = "Cancel"; +"MessageShowSensitiveContentChannelTitle" = "18+"; +"MessageShowSensitiveContentMediaText" = "This media may contain sensitive content suitable only for adults. Do you still want to view it?"; +"MessageShowSensitiveContentMediaTextClosed" = "This media may contain sensitive content suitable only for adults."; +"MessageShowSensitiveContentMediaTextClosedButton" = "Cancel"; +"MessageShowSensitiveContentMediaTitle" = "18+"; +"MessageStarsUnlock" = "Buy Stars"; +"MessageSuggestionPublishNow" = "Publish Now"; +"MessageSuggestionsUnsavedChanges" = "You have changed the direct message settings. Apply changes?"; +"MessageTodo" = "Checklist"; +"MessageTodoList" = "Checklist"; +"MessageTodoName" = "%s"; +"MessageUnpinnedHint" = "Message unpinned."; +"MessagesBold#one" = "**%1$d** message"; +"MessagesBold#other" = "**%1$d** messages"; +"MessagesChartTitle" = "Messages"; +"MessagesDataUsage" = "Messages and other data"; +"MessagesDeleted" = "Messages Deleted"; +"MessagesDeletedHint#one" = "Message deleted"; +"MessagesDeletedHint#other" = "%1$d messages deleted"; +"MessagesDeletedUndo#one" = "Messages for %1$s day deleted"; +"MessagesDeletedUndo#other" = "Messages for %1$s days deleted"; +"MessagesOverview" = "Messages"; +"MessagesSelected#one" = "%d Selected"; +"MessagesSelected#other" = "%d Selected"; +"MessagesSettings" = "Messages"; +"MessagesUnpinned#one" = "Message unpinned."; +"MessagesUnpinned#other" = "%1$d messages unpinned."; +"Meters#one" = "%1$d meter"; +"Meters#other" = "%1$d meters"; +"MetersAway" = "m away"; +"MetersAway2" = "%1$s m away"; +"MetersFromYou2" = "%1$s m from\u00a0you"; +"MetersShort" = "%1$s m"; +"Mi" = "mi"; +"MicrophoneForVoiceMessages" = "Microphone for voice messages"; +"MicrophoneForVoiceMessagesBuiltIn" = "Built-In"; +"MicrophoneForVoiceMessagesSco" = "Headset"; +"MicrophoneForVoiceMessagesScoHint" = "Using a headset microphone may reduce background noise, but will slightly decrease audio quality."; +"MicrophoneForVoiceMessagesScoIfConnected" = "Headset (if connected)"; +"MigrateOldFolder" = "Migrate old folder to Scoped Storage"; +"MigrateOldFolderButton" = "Move Files Now"; +"MigrateOldFolderDescription" = "Due to a change made by **Google**, we need to move the files you downloaded using **Telegram** to a new folder.\n\nThis may take a few moments. You can continue using the app while the files are moved."; +"MigrateOldFolderTitle" = "Migrate Files to Scoped Storage"; +"MigratingFiles" = "Migrating files"; +"MigrationNotification" = "Migration"; +"MilesAway" = "%1$s mi away"; +"MilesFromYou" = "%1$s mi from\u00a0you"; +"MilesShort" = "%1$s mi"; +"MinuteAgo" = "a minute ago"; +"Minutes#one" = "%1$d minute"; +"Minutes#other" = "%1$d minutes"; +"MinutesAgo#one" = "%1$d minute ago"; +"MinutesAgo#other" = "%1$d minutes ago"; +"MinutesBold#one" = "**%1$d** minute"; +"MinutesBold#other" = "**%1$d** minutes"; +"MinutesSchedule#one" = "%1$d minute"; +"MinutesSchedule#other" = "%1$d minutes"; +"MissedCallDescriptionSubtitle" = "In a few seconds you should receive a short call from:"; +"MissedCallDescriptionSubtitle2" = "Please enter the last digits of the number that called"; +"MissedCallDescriptionTitle" = "Enter the missing digits"; +"MobileHidden" = "Mobile hidden"; +"MobileHiddenExceptionInfo" = "Phone number will be visible once %1$s adds you as a contact."; +"MobileUsage" = "Mobile Data Usage"; +"MobileVisibleInfo" = "When you tap **DONE**, your phone number will become visible to %1$s."; +"Monetization" = "Monetization"; +"MonetizationBalance" = "Available reward"; +"MonetizationBalanceInfo" = "You can collect your reward using Fragment, a third-party platform used by advertisers to pay for ads. **Learn more >**"; +"MonetizationBalanceInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"MonetizationBalanceInfoNotAvailable" = "In the coming weeks you will be able to collect your reward using Fragment, a third-party platform used by advertisers to pay ads. **Learn more >**"; +"MonetizationChartInTON" = "%s in Grams"; +"MonetizationChartInUSD" = "%s in USD"; +"MonetizationGraphImpressions" = "Ad impressions"; +"MonetizationGraphRevenue" = "Ad rewards"; +"MonetizationGraphStarsRevenue" = "Stars revenue"; +"MonetizationInfo" = "Telegram shares %1$d%% of the revenue from ads displayed in your channel as rewards. **Learn more >**"; +"MonetizationInfoFeature1Name" = "Telegram Ads"; +"MonetizationInfoFeature1Text" = "Telegram can display ads in your channel."; +"MonetizationInfoFeature2Name" = "50:50 revenue split"; +"MonetizationInfoFeature2Text" = "You can receive 50% of the ad revenue as rewards in Grams."; +"MonetizationInfoFeature3Name" = "Flexible withdrawals"; +"MonetizationInfoFeature3Text" = "You can collect your Grams any time."; +"MonetizationInfoTONLink" = "https://telegram.org/blog/monetization-for-channels"; +"MonetizationInfoTONText" = "**Gram** is the cryptocurrency of the TON blockchain platform, which Telegram uses for its high speed and low commissions on transactions."; +"MonetizationInfoTONTitle" = "What is 💎 Gram?"; +"MonetizationInfoTitle" = "Earn From Your Channel"; +"MonetizationMoreTransactions#one" = "Show %d more transaction"; +"MonetizationMoreTransactions#other" = "Show %d more transactions"; +"MonetizationOverview" = "Rewards overview"; +"MonetizationOverviewAvailable" = "Rewards available for collection"; +"MonetizationOverviewLastWithdrawal" = "Rewards since last collection"; +"MonetizationOverviewTotal" = "Total lifetime rewards"; +"MonetizationProceedsInfo" = "Stars from your total balance can be used for ads or withdrawn as rewards 21 days after they are earned."; +"MonetizationProceedsInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"MonetizationProceedsInfoTONLink" = "https://telegram.org/tos/content-creator-rewards"; +"MonetizationProceedsStarsInfo" = "Stars from your total balance can be used for ads or withdrawn as rewards 21 days after they are earned."; +"MonetizationProceedsStarsInfoLink" = "https://telegram.org/tos/stars/"; +"MonetizationProceedsStarsTONInfo" = "Stars and Grams from your total balance can be used for ads or withdrawn as rewards 21 and 3 days respectively after they are earned."; +"MonetizationProceedsStarsTONInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"MonetizationProceedsTONInfo" = "Grams from your total balance can be used for ads or withdrawn as rewards 3 days after they are earned. You cannot withdraw less than 10 Grams."; +"MonetizationProceedsTONInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"MonetizationSelfWithdraw" = "Withdraw via Fragment"; +"MonetizationStarsAds" = "Buy Ads"; +"MonetizationStarsBalance" = "Stars balance"; +"MonetizationStarsInfo" = "You can withdraw Stars using Fragment, or use Stars to advertise your channel. You cannot withdraw less than 1000 stars. **Learn more >**"; +"MonetizationStarsInfoGroup" = "By withdrawing Stars using Fragment, you accept the **Terms of Service**."; +"MonetizationStarsInfoLink" = "https://telegram.org/tos/stars"; +"MonetizationStarsWithdraw#other" = "Withdraw ⭐️%d"; +"MonetizationStarsWithdrawAll" = "Withdraw All"; +"MonetizationStarsWithdrawUntil" = "Withdraw"; +"MonetizationSwitchOff" = "Switch Off Ads"; +"MonetizationSwitchOffInfo" = "You will not be eligible for any rewards if you switch off ads."; +"MonetizationTransactionDetailProceed" = "Rewards from ads displayed in"; +"MonetizationTransactionDetailRefund" = "Refunded back"; +"MonetizationTransactionDetailWithdraw" = "Collecting rewards"; +"MonetizationTransactionDetailWithdrawButton" = "View in Blockchain Explorer"; +"MonetizationTransactionNotCompleted" = "Not Completed"; +"MonetizationTransactionPending" = "Pending"; +"MonetizationTransactionProceed" = "Rewards from ads"; +"MonetizationTransactionRefund" = "Refund"; +"MonetizationTransactionWithdraw" = "Collection via Fragment"; +"MonetizationTransactions" = "Transaction history"; +"MonetizationTransactionsStars" = "Stars Transactions"; +"MonetizationTransactionsTON" = "Gram Transactions"; +"MonetizationWithdraw" = "Collect via Fragment"; +"Mono" = "Mono"; +"MonoforumDeletedUndo" = "Removed from your chat list."; +"MonoforumMessages" = "direct messages"; +"MonoforumSpan" = "DIRECT"; +"MonoforumTitle" = "%1$s Messages"; +"MonthAgo" = "a month ago"; +"Months#one" = "%1$d month"; +"Months#other" = "%1$d months"; +"MonthsAgo#one" = "%d month ago"; +"MonthsAgo#other" = "%d months ago"; +"MonthsShort#one" = "%1$dm"; +"MonthsShort#other" = "%1$dm"; +"MoreAboutThisBot" = "More about this bot"; +"MoreBoosts#one" = "**%d** more boost"; +"MoreBoosts#other" = "**%d** more boosts"; +"MoreInfo" = "Learn more"; +"MoreSimilar" = "More Channels"; +"MoreSimilarBotsButton" = "Show More Bots"; +"MoreSimilarBotsText" = "Subscribe to **Telegram Premium**\nto unlock up to %s similar bots."; +"MoreSimilarButton" = "Show More Channels"; +"MoreSimilarText" = "Subscribe to **Telegram Premium**\nto unlock up to %s similar channels."; +"MoveCaptionDown" = "Move caption down"; +"MoveCaptionUp" = "Move caption up"; +"MovedCaptionDown" = "Caption moved down"; +"MovedCaptionDownText" = "Caption will be shown below the media"; +"MovedCaptionUp" = "Caption moved up"; +"MovedCaptionUpText" = "Caption will be shown above the media"; +"Music" = "Music"; +"MusicFiles#one" = "%1$d music file"; +"MusicFiles#other" = "%1$d music files"; +"MusicInfo" = "To send music files"; +"Mute" = "Mute"; +"MuteDisable" = "Disable"; +"MuteFor" = "Mute for %1$s"; +"MuteFor1h" = "Mute for 1 hour"; +"MuteForAlert" = "Mute notifications for..."; +"MuteForButton" = "Mute for %s"; +"MuteForPopup" = "Mute for..."; +"MuteNever" = "Never"; +"MuteNotifications" = "Mute Forever"; +"MyChannels" = "My Channels"; +"MyGroups" = "My Groups"; +"MyNotes" = "My Notes"; +"MyProfile" = "My Profile"; +"MyStory" = "My Story"; +"MyTON" = "My Grams"; +"NearbyCreateGroup" = "Create a Local Group"; +"NearbyCreateGroupInfo" = "Anyone close to this location (neighbors, coworkers, fellow students, event attendees) will be able to see your group in the People Nearby section."; +"NearbyCreateGroupInfo2" = "If you start an unrelated group at this location, you may lose the ability to create location-based groups."; +"NearbyPeopleGreetingsDescription" = "Send a message or tap on the greeting below to show that you are ready to chat."; +"NearbyPeopleGreetingsMessage" = "%1$s is %2$s"; +"NearbyPlaces" = "Or choose a place"; +"NearbyStartGroup" = "Start Group"; +"NearbyVenue" = "Or choose a venue"; +"NeedAdminRightForSetAutoDeleteTimer" = "You must be an admin to enable auto-delete in this chat."; +"NetworkUsage" = "Data Usage"; +"NetworkUsageAllTab" = "All"; +"NetworkUsageMobile" = "MOBILE"; +"NetworkUsageMobileTab" = "Mobile"; +"NetworkUsageRoaming" = "ROAMING"; +"NetworkUsageRoamingTab" = "Roaming"; +"NetworkUsageSince" = "Network usage since %1$s"; +"NetworkUsageWiFi" = "WI-FI"; +"NetworkUsageWiFiTab" = "Wi-Fi"; +"NeverAllow" = "Never Allow"; +"NeverAllowPlaceholder" = "Never allow..."; +"NeverShareWith" = "Never Share With"; +"NeverShareWithPlaceholder" = "Never share with users..."; +"NeverShareWithTitle" = "Never Share"; +"NewBroadcastList" = "New Broadcast List"; +"NewCall" = "New Call"; +"NewCallSearch" = "Search for contacts or usernames..."; +"NewChannel" = "New Channel"; +"NewChatsFromNonContacts" = "New chats from unknown users"; +"NewChatsFromNonContactsCheck" = "Automatically archive"; +"NewContact" = "New Contact"; +"NewContactAlertButton" = "Add contact"; +"NewContactAlertMessage" = "The phone number **%1$s** is not in your contacts. Do you want to add it?"; +"NewContactAlertTitle" = "New contact"; +"NewContactTitle" = "New Contact"; +"NewConversationShortcut" = "New conversation"; +"NewFollowersBySourceChartTitle" = "New subscribers by source"; +"NewGroup" = "New Group"; +"NewLink" = "New Link"; +"NewMembersBySourceChartTitle" = "New members by source"; +"NewMessageTitle" = "New Message"; +"NewMessages#one" = "%1$d new message"; +"NewMessages#other" = "%1$d new messages"; +"NewPassword" = "New Password"; +"NewPoll" = "New Poll"; +"NewQuiz" = "New Quiz"; +"NewSecretChat" = "New Secret Chat"; +"NewStickerPack" = "New Sticker Set"; +"NewStories#one" = "%d new story"; +"NewStories#other" = "%d new stories"; +"NewTheme" = "New color theme"; +"NewThemePreviewLine1" = "Do you know what time it is?"; +"NewThemePreviewLine2" = "It's morning in Tokyo 😎"; +"NewThemePreviewLine3" = "It's morning in *Tokyo* 😎"; +"NewThemePreviewName" = "Bob Harris"; +"NewThemePreviewReply" = "Good morning! 👋"; +"NewThemePreviewReply2" = "Good morning"; +"NewThemeTitle" = "New Theme"; +"NewTopic" = "New Topic"; +"Next" = "Next"; +"NextMediaTap" = "Tap to show next media"; +"NextMediaTapInfo" = "Tap near the edge of the screen while viewing media to navigate"; +"NoArchivedStoriesSubtitle" = "Upload a new story to view it here."; +"NoArchivedStoriesTitle" = "No stories yet…"; +"NoAudio" = "Add files to the music library on your device to see them here."; +"NoAudioFiles" = "No audio files"; +"NoAudioFilesInfo" = "Your music library is empty."; +"NoAudioFound" = "No results found"; +"NoAudioFoundInfo" = "There are no matches with **%1$s** in your music library."; +"NoAudioFoundInfo2" = "There are no matches with **%1$s**."; +"NoAudioFoundPlayerInfo" = "There are no matches for **%1$s**."; +"NoBlocked" = "No blocked users yet"; +"NoBlockedChannel" = "Banned users are removed from the channel and can only come back if added by an admin. Invite links don't work for them."; +"NoBlockedChannel2" = "Users removed from the channel by admins can't rejoin via invite links."; +"NoBlockedGroup" = "Banned users are removed from the group and can only come back if added by an admin. Invite links don't work for them."; +"NoBlockedGroup2" = "Users removed from the group by admins can't rejoin via invite links."; +"NoBlockedUsers" = "No removed users"; +"NoBoostersGroupHint" = "No users currently boost your group"; +"NoBoostersHint" = "No users currently boost your channel"; +"NoCallLog" = "No calls here yet..."; +"NoCarMusic" = "No music in your chats"; +"NoChannelsMessage" = "You are not subscribed to any channel."; +"NoChannelsTitle" = "No channels yet…"; +"NoChats" = "Welcome to Telegram"; +"NoChatsContactsHelp" = "Start messaging by tapping one of your contacts below."; +"NoChatsHelp" = "Start messaging by tapping the chat bubble icon in the bottom right."; +"NoClicks" = "no clicks"; +"NoComments" = "No comments here yet..."; +"NoContacts" = "No contacts yet"; +"NoContactsViewed" = "None of your contacts viewed this story."; +"NoContactsYet" = "You have no contacts on Telegram yet"; +"NoContactsYet2" = "You have no contacts\non Telegram yet"; +"NoContactsYet2Invite" = "Invite Friends >"; +"NoContactsYet2Sub" = "Invite friends to try Telegram or search\npeople by username."; +"NoContactsYet3" = "Add Contacts"; +"NoContactsYet3Sub" = "You have no contacts on Telegram yet. How about adding them?"; +"NoContactsYetLine1" = "Invite friends to try Telegram"; +"NoContactsYetLine2" = "Find people nearby to chat with"; +"NoContactsYetLine3" = "Search people by username"; +"NoEffectsFound" = "No effects found"; +"NoEmojiFound" = "No emoji found"; +"NoEmojiOrStickersFound" = "No emoji or stickers found"; +"NoExceptions" = "This section will list all chats with non-default notification settings.\n\nYou can customize notifications for a chat by opening its profile and choosing 'Notifications'."; +"NoFilesFound" = "No results found"; +"NoFilesFoundInfo" = "There are no matches with **%1$s** in current directory."; +"NoFilesInfo" = "This folder is empty."; +"NoFilteredMessages" = "No messages under this tag or search found"; +"NoFolderFound" = "No chat folder found."; +"NoGIFs" = "No GIFs"; +"NoGIFsFound" = "No GIFs found"; +"NoGroupsInCommon" = "No common groups"; +"NoHandleAppInstalled" = "You don't have applications that can handle the file type '%1$s', please install one to continue"; +"NoHashtagStoriesTitle" = "No stories found"; +"NoIconsFound" = "No icons found"; +"NoLimit" = "No limit"; +"NoLogFound" = "**No results found**"; +"NoLogFoundFiltered" = "**No results matching your filters**"; +"NoMailInstalled" = "Please send an email describing your problem to reports@stel.com"; +"NoMasks" = "No masks yet"; +"NoMedia" = "Share photos and videos in this chat and access them on any of your devices."; +"NoMediaAutoDownload" = "Disabled"; +"NoMediaSecret" = "Photos and videos from this chat will be shown here."; +"NoMemberRequests" = "No join requests"; +"NoMemberRequestsDescription" = "There are no pending join requests."; +"NoMessages" = "No messages here yet..."; +"NoMessagesForThisDay" = "No messages for this day"; +"NoMessagesGreetingsDescription" = "Send a message or tap the greeting below."; +"NoMonoforumTopicsCreated" = "no received messages"; +"NoNetworkUsageSince" = "No network usage since %1$s"; +"NoNewCarMessages" = "No new messages"; +"NoOneJoined" = "No one joined"; +"NoOneJoinedYet" = "No one joined yet"; +"NoOneSubscribed" = "No one subscribed"; +"NoOtherSessions" = "No other active sessions"; +"NoOtherSessionsInfo" = "You can log in to Telegram from other mobile, tablet and desktop devices, using the same phone number. All your data will be instantly synchronized."; +"NoOtherWebSessions" = "No active logins."; +"NoOtherWebSessionsInfo" = "You can log in on websites that support signing in with Telegram."; +"NoPhoneFound" = "There is no Telegram account with this phone number."; +"NoPhotos" = "No photos yet"; +"NoPlacesFound" = "No places found"; +"NoPlacesFoundInfo" = "There are no matches with **%1$s** near you."; +"NoPlayerInstalled" = "You don't have a video player, please install one to continue"; +"NoPopup" = "No popup"; +"NoPublicStoriesTitle" = "No public stories"; +"NoPublicStoriesTitle2" = "No posts yet…"; +"NoReactions" = "No reactions"; +"NoReactionsFound" = "No reactions found"; +"NoReactionsStub" = "No one has reacted to this story so far."; +"NoRecent" = "No recent"; +"NoRecentCalls" = "No recent calls"; +"NoRecentCallsInfo" = "Your recent voice and video calls\nwill appear here."; +"NoRecentGIFs" = "No recent GIFs"; +"NoRecentPhotos" = "No recent photos"; +"NoRecentSearches" = "No recent searches"; +"NoReplies" = "No replies here yet..."; +"NoResult" = "No results"; +"NoResultFoundFor" = "No results found for \"%s\"."; +"NoResultFoundFor2" = "There were no results for \"%s\".\nTry a new search."; +"NoResultFoundForTag" = "No results found for %s"; +"NoRtmpStreamFromAppOwner" = "**Oops!** Telegram doesn't see any stream coming from your streaming app.\n\nPlease make sure you entered the right Server URL and Stream Key in your app."; +"NoRtmpStreamFromAppViewer" = "%1$s is currently not broadcasting live stream data to Telegram."; +"NoScheduledMessages" = "No scheduled messages yet..."; +"NoSharedAudio" = "Share music in this chat and access it on any of your devices."; +"NoSharedAudioSecret" = "Music from this chat will be shown here."; +"NoSharedFiles" = "Share files and documents in this chat and access them on any of your devices."; +"NoSharedFilesSecret" = "Files and documents from this chat will be shown here."; +"NoSharedGifSecret" = "GIFs from this chat will be shown here."; +"NoSharedLinks" = "Share links in this chat and access them on any of your devices."; +"NoSharedLinksSecret" = "Shared links from this chat will be shown here."; +"NoSharedVoice" = "Share voice messages in this chat and access them on any of your devices."; +"NoSharedVoiceSecret" = "Voice messages from this chat will be shown here."; +"NoSound" = "No sound"; +"NoStickers" = "No stickers yet"; +"NoStickersFound" = "No stickers found"; +"NoStoriesSubtitle" = "Open the 'Archive' tab to select stories you want to be displayed on your profile."; +"NoStoriesSubtitle2" = "Publish photos and videos to display on your profile page"; +"NoStoriesTitle" = "No posted stories"; +"NoSubscribeRequests" = "No join requests"; +"NoSubscribeRequestsDescription" = "There are no pending join requests."; +"NoSuchChannels" = "No such channels"; +"NoSuchChannelsInfo" = "You don't have channels that meet the requirements for this bot"; +"NoSuchGroups" = "No such groups"; +"NoSuchGroupsInfo" = "You don't have groups that meet the requirements for this bot"; +"NoSuchUsers" = "No such users"; +"NoSuchUsersInfo" = "No users found that meet the requirements for this bot"; +"NoTopics" = "No topics here yet"; +"NoTopicsCreated" = "no created topics"; +"NoTopicsDescription" = "You can start the first topic or tap %s\nto view the group as messages."; +"NoTopicsDescriptionUser" = "Older messages from this group have been moved to the **’%s’** topic."; +"NoUsernameFound" = "Username not found."; +"NoUsernameFound2" = "This user doesn’t exist on Telegram"; +"NoViews" = "No views"; +"NoViewsStub" = "No one has viewed this story so far."; +"NoVotes" = "No votes"; +"NoVotesQuiz" = "Nobody answered"; +"NoWordsRecognized" = "No words recognized."; +"NobodyLikesSpam1" = "Sorry, you can only send messages to mutual contacts at the moment."; +"NobodyLikesSpam2" = "Sorry, you can only add mutual contacts to groups at the moment."; +"NobodyLikesSpam3" = "Sorry, you are currently restricted from posting in this group."; +"NobodyViewed" = "Nobody viewed"; +"NobodyViews" = "No views yet"; +"NobodyViewsArchived" = "No views available"; +"NobodyViewsTitle" = "No views yet…"; +"None" = "None"; +"NotMounted" = "Storage not mounted"; +"NotfificationsFrequencyTitle" = "Notification frequency"; +"NotificationActionMissedCallConference" = "You missed a call from %1$s"; +"NotificationActionMissedCallConferenceOther#one" = "You missed a call from %2$s and %1$d other"; +"NotificationActionMissedCallConferenceOther#other" = "You missed a call from %2$s and %1$d others"; +"NotificationActionPinnedContact" = "%1$s pinned a contact in the group %2$s"; +"NotificationActionPinnedContact2" = "%1$s pinned a contact %3$s in the group %2$s"; +"NotificationActionPinnedContactChannel" = "%1$s pinned a contact"; +"NotificationActionPinnedContactChannel2" = "%1$s pinned a contact %2$s"; +"NotificationActionPinnedContactUser" = "%1$s pinned a contact %2$s"; +"NotificationActionPinnedFile" = "%1$s pinned a file in the group %2$s"; +"NotificationActionPinnedFileChannel" = "%1$s pinned a file"; +"NotificationActionPinnedFileUser" = "%1$s pinned a file"; +"NotificationActionPinnedGame" = "%1$s pinned a game in the group %2$s"; +"NotificationActionPinnedGameChannel" = "%1$s pinned a game"; +"NotificationActionPinnedGameScore" = "%1$s pinned a game score in the group %2$s"; +"NotificationActionPinnedGameScoreChannel" = "%1$s pinned a game score"; +"NotificationActionPinnedGameScoreUser" = "%1$s pinned a game score"; +"NotificationActionPinnedGameUser" = "%1$s pinned a game"; +"NotificationActionPinnedGeo" = "%1$s pinned a map in the group %2$s"; +"NotificationActionPinnedGeoChannel" = "%1$s pinned a map"; +"NotificationActionPinnedGeoLive" = "%1$s pinned a live location in the group %2$s"; +"NotificationActionPinnedGeoLiveChannel" = "%1$s pinned a live location"; +"NotificationActionPinnedGeoLiveUser" = "%1$s pinned a live location"; +"NotificationActionPinnedGeoUser" = "%1$s pinned a map"; +"NotificationActionPinnedGif" = "%1$s pinned a GIF in the group %2$s"; +"NotificationActionPinnedGifChannel" = "%1$s pinned a GIF"; +"NotificationActionPinnedGifUser" = "%1$s pinned a GIF"; +"NotificationActionPinnedInvoice" = "%1$s pinned an invoice in the group %2$s"; +"NotificationActionPinnedInvoiceChannel" = "%1$s pinned an invoice"; +"NotificationActionPinnedInvoiceUser" = "%1$s pinned an invoice"; +"NotificationActionPinnedMusic" = "%1$s pinned an audio file in the group %2$s"; +"NotificationActionPinnedMusicChannel" = "%1$s pinned an audio file"; +"NotificationActionPinnedMusicUser" = "%1$s pinned an audio file"; +"NotificationActionPinnedNoText" = "%1$s pinned a message in the group %2$s"; +"NotificationActionPinnedNoTextChannel" = "%1$s pinned a message"; +"NotificationActionPinnedNoTextUser" = "%1$s pinned a message"; +"NotificationActionPinnedPhoto" = "%1$s pinned a photo in the group %2$s"; +"NotificationActionPinnedPhotoChannel" = "%1$s pinned a photo"; +"NotificationActionPinnedPhotoUser" = "%1$s pinned a photo"; +"NotificationActionPinnedPoll" = "%1$s pinned a poll in the group %2$s"; +"NotificationActionPinnedPoll2" = "%1$s pinned a poll %3$s in the group %2$s"; +"NotificationActionPinnedPollChannel" = "%1$s pinned a poll"; +"NotificationActionPinnedPollChannel2" = "%1$s pinned a poll %2$s"; +"NotificationActionPinnedPollUser" = "%1$s pinned the poll %2$s"; +"NotificationActionPinnedQuiz2" = "%1$s pinned the quiz \"%3$s\" in the group %2$s"; +"NotificationActionPinnedQuizChannel2" = "%1$s pinned the quiz \"%2$s\""; +"NotificationActionPinnedQuizUser" = "%1$s pinned the quiz %2$s"; +"NotificationActionPinnedRound" = "%1$s pinned a video message in the group %2$s"; +"NotificationActionPinnedRoundChannel" = "%1$s pinned a video message"; +"NotificationActionPinnedRoundUser" = "%1$s pinned a video message"; +"NotificationActionPinnedSticker" = "%1$s pinned a sticker in the group %2$s"; +"NotificationActionPinnedStickerChannel" = "%1$s pinned a sticker"; +"NotificationActionPinnedStickerEmoji" = "%1$s pinned a %3$s sticker in the group %2$s"; +"NotificationActionPinnedStickerEmojiChannel" = "%1$s pinned a %2$s sticker"; +"NotificationActionPinnedStickerEmojiUser" = "%1$s pinned a %2$s sticker"; +"NotificationActionPinnedStickerUser" = "%1$s pinned a sticker"; +"NotificationActionPinnedText" = "%1$s pinned \"%2$s\" in the group %3$s"; +"NotificationActionPinnedTextChannel" = "%1$s pinned \"%2$s\""; +"NotificationActionPinnedTextUser" = "%1$s pinned \"%2$s\""; +"NotificationActionPinnedTodo2" = "%1$s pinned the checklist %3$s in the group %2$s"; +"NotificationActionPinnedTodoChannel2" = "%1$s pinned the checklist %2$s"; +"NotificationActionPinnedTodoUser" = "%1$s pinned the checklist %2$s"; +"NotificationActionPinnedVideo" = "%1$s pinned a video in the group %2$s"; +"NotificationActionPinnedVideoChannel" = "%1$s pinned a video"; +"NotificationActionPinnedVideoUser" = "%1$s pinned a video"; +"NotificationActionPinnedVoice" = "%1$s pinned a voice message in %2$s"; +"NotificationActionPinnedVoiceChannel" = "%1$s pinned a voice message"; +"NotificationActionPinnedVoiceUser" = "%1$s pinned a voice message"; +"NotificationChannelMessagePaidMedia#one" = "%2$s posted a paid post for %1$d star"; +"NotificationChannelMessagePaidMedia#other" = "%2$s posted a paid post for %1$d stars"; +"NotificationChannelStory" = "%s shared a story"; +"NotificationChatMessagePaidMedia#one" = "%2$s posted a paid post in %3$s group for %1$d star"; +"NotificationChatMessagePaidMedia#other" = "%2$s posted a paid post in %3$s group for %1$d stars"; +"NotificationChatReactGiveaway" = "%1$s reacted %3$s in group %2$s to your giveaway"; +"NotificationChatStory" = "%s shared a story to the group"; +"NotificationContactJoined" = "%1$s joined Telegram!"; +"NotificationContactNewPhoto" = "%1$s updated profile photo"; +"NotificationDisabled" = "Disabled"; +"NotificationEditedGroupName" = "%1$s renamed the group %2$s"; +"NotificationEditedGroupPhoto" = "%1$s changed the group photo for %2$s"; +"NotificationEditedGroupVideo" = "%1$s changed the group photo for %2$s"; +"NotificationEnabled" = "Enabled"; +"NotificationEnabledAutomatically" = "Automatically enabled"; +"NotificationGroupAddMember" = "%1$s invited %3$s to the group %2$s"; +"NotificationGroupAddSelf" = "%1$s returned to the group %2$s"; +"NotificationGroupAddSelfMega" = "%1$s joined the group %2$s"; +"NotificationGroupAlbum" = "%1$s sent an album to the group %2$s"; +"NotificationGroupCreatedCall" = "%1$s started a video chat in %2$s"; +"NotificationGroupEndedCall" = "%1$s ended the video chat in %2$s"; +"NotificationGroupFew" = "%1$s sent %3$s to the group %2$s"; +"NotificationGroupForwardedFew" = "%1$s forwarded %3$s to the group %2$s"; +"NotificationGroupInvitedToCall" = "%1$s invited %3$s to a video chat in %2$s"; +"NotificationGroupInvitedYouToCall" = "%1$s invited you to a video chat in %2$s"; +"NotificationGroupKickMember" = "%1$s removed %3$s from the group %2$s"; +"NotificationGroupKickYou" = "%1$s removed you from the group %2$s"; +"NotificationGroupLeftMember" = "%1$s left the group %2$s"; +"NotificationHiddenChatName" = "Telegram"; +"NotificationHiddenChatUserName" = "User"; +"NotificationHiddenMessage" = "New message"; +"NotificationHiddenName" = "Telegram"; +"NotificationInvitedToGroup" = "%1$s invited you to the group %2$s"; +"NotificationInvitedToGroupByLink" = "%1$s joined to the group %2$s via invite link"; +"NotificationMessageAlbum" = "%1$s sent you an album"; +"NotificationMessageAudio" = "%1$s sent you a voice message"; +"NotificationMessageChannelGiveaway" = "%1$s posted a giveaway of %2$sx %3$sm Premium subscriptions"; +"NotificationMessageChannelStarsGiveaway" = "%1$s posted a giveaway of %3$s stars among %2$s winners"; +"NotificationMessageChannelStarsGiveaway2" = "%1$s posted a giveaway of %3$s stars %2$s"; +"NotificationMessageChatGiveaway" = "%1$s sent a giveaway of %3$sx %4$sm Premium subscriptions to the group %2$s"; +"NotificationMessageChatStarsGiveaway" = "%1$s sent a giveaway of %4$s stars among %3$s winners to the group %2$s"; +"NotificationMessageChatStarsGiveaway2" = "%1$s sent a giveaway of %4$s stars %3$s to the group %2$s"; +"NotificationMessageContact" = "%1$s shared a contact with you"; +"NotificationMessageContact2" = "%1$s shared a contact %2$s with you"; +"NotificationMessageDocument" = "%1$s sent you a file"; +"NotificationMessageFew" = "%1$s sent you %2$s"; +"NotificationMessageForwardFew" = "%1$s forwarded you %2$s"; +"NotificationMessageGame" = "%1$s invited you to play %2$s"; +"NotificationMessageGameScored" = "%1$s scored %3$s in game %2$s"; +"NotificationMessageGif" = "%1$s sent you a GIF"; +"NotificationMessageGiftCode" = "%1$s sent you a Gift Code for %2$s of Telegram Premium"; +"NotificationMessageGiveaway" = "%1$s sent you a giveaway of %2$sx %3$sm Premium subscriptions"; +"NotificationMessageGroupAudio" = "%1$s sent a voice message to %2$s"; +"NotificationMessageGroupContact" = "%1$s shared a contact in the group %2$s"; +"NotificationMessageGroupContact2" = "%1$s shared a contact %3$s in the group %2$s"; +"NotificationMessageGroupDocument" = "%1$s sent a file to the group %2$s"; +"NotificationMessageGroupGame" = "%1$s invited the group %2$s to play %3$s"; +"NotificationMessageGroupGameScored" = "%1$s scored %4$s in game %3$s in the group %2$s"; +"NotificationMessageGroupGif" = "%1$s sent a GIF to the group %2$s"; +"NotificationMessageGroupInvoice" = "%1$s sent an invoice to the group %2$s for %3$s"; +"NotificationMessageGroupLiveLocation" = "%1$s shared a live location with the group %2$s"; +"NotificationMessageGroupMap" = "%1$s sent a location to the group %2$s"; +"NotificationMessageGroupMusic" = "%1$s sent an audio file to the group %2$s"; +"NotificationMessageGroupNoText" = "%1$s sent a message to the group %2$s"; +"NotificationMessageGroupPhoto" = "%1$s sent a photo to the group %2$s"; +"NotificationMessageGroupPoll" = "%1$s sent a poll to %2$s"; +"NotificationMessageGroupPoll2" = "%1$s sent a poll %3$s to the group %2$s"; +"NotificationMessageGroupQuiz2" = "%1$s sent the quiz \"%3$s\" to the group %2$s"; +"NotificationMessageGroupRound" = "%1$s sent a video message to the group %2$s"; +"NotificationMessageGroupSticker" = "%1$s sent a sticker to the group %2$s"; +"NotificationMessageGroupStickerEmoji" = "%1$s sent a %3$s sticker to the group %2$s"; +"NotificationMessageGroupText" = "%1$s @ %2$s: %3$s"; +"NotificationMessageGroupTodo2" = "%1$s sent the checklist %3$s to the group %2$s"; +"NotificationMessageGroupTodoAppend2" = "%1$s added %3$s tasks in the group %2$s"; +"NotificationMessageGroupTodoDone2" = "%1$s toggled %3$s tasks in the group %2$s"; +"NotificationMessageGroupVideo" = "%1$s sent a video to the group %2$s"; +"NotificationMessageInvoice" = "%1$s sent you an invoice for %2$s"; +"NotificationMessageLiveLocation" = "%1$s sent you a live location"; +"NotificationMessageMap" = "%1$s sent you a location"; +"NotificationMessageMusic" = "%1$s sent you an audio file"; +"NotificationMessageNoText" = "%1$s sent you a message"; +"NotificationMessagePaidMedia#one" = "%2$s sent you a paid post for %1$d star"; +"NotificationMessagePaidMedia#other" = "%2$s sent you a paid post for %1$d stars"; +"NotificationMessagePhoto" = "%1$s sent you a photo"; +"NotificationMessagePoll" = "%1$s sent you a poll"; +"NotificationMessagePoll2" = "%1$s sent you a poll %2$s"; +"NotificationMessageQuiz2" = "%1$s sent you the quiz \"%2$s\""; +"NotificationMessageRecurringPay" = "%1$s were charged %2$s"; +"NotificationMessageRound" = "%1$s sent you a video message"; +"NotificationMessageSDPhoto" = "%1$s sent you a self-destructing photo"; +"NotificationMessageSDVideo" = "%1$s sent you a self-destructing video"; +"NotificationMessageScheduled" = "📅 You: %1$s"; +"NotificationMessageScheduledName" = "📅 You"; +"NotificationMessageStarGift#one" = "%2$s sent you a Gift worth of %1$d Star"; +"NotificationMessageStarGift#other" = "%2$s sent you a Gift worth of %1$d Stars"; +"NotificationMessageStarsGiveaway" = "%1$s sent you a giveaway of %3$s stars among %2$s winners"; +"NotificationMessageStarsGiveaway2" = "%1$s sent you a giveaway of %3$s stars %2$s"; +"NotificationMessageSticker" = "%1$s sent you a sticker"; +"NotificationMessageStickerEmoji" = "%1$s sent you a %2$s sticker"; +"NotificationMessageSuggestBirthday" = "%s suggested you add your birthday"; +"NotificationMessageText" = "%1$s: %2$s"; +"NotificationMessageTodo2" = "%1$s sent you the checklist %2$s"; +"NotificationMessageUniqueStarGift" = "%1$s transferred a Gift to you!"; +"NotificationMessageUniqueStarGiftPrepaidUpgrade#one" = "%2$s sent an Upgrade worth %1$d Star for your gift"; +"NotificationMessageUniqueStarGiftPrepaidUpgrade#other" = "%2$s sent an Upgrade worth %1$d Stars for your gift"; +"NotificationMessageUniqueStarGiftUnpackUpgrade" = "%1$s unpacked the Gift that you helped upgrade"; +"NotificationMessageUniqueStarGiftUpgrade" = "%1$s upgraded your Gift"; +"NotificationMessageVideo" = "%1$s sent you a video"; +"NotificationMessagesPeopleDisplayOrder" = "%1$s %2$s"; +"NotificationPaidMedia#one" = "%1$d ⭐️ media"; +"NotificationPaidMedia#other" = "%1$d ⭐️ media"; +"NotificationPinnedGiveaway" = "%1$s pinned a giveaway"; +"NotificationPinnedPaidMedia#one" = "%2$s pinned a paid post for %1$d star"; +"NotificationPinnedPaidMedia#other" = "%2$s pinned a paid post for %1$d stars"; +"NotificationReactGiveaway" = "%1$s reacted %2$s to your giveaway"; +"NotificationReactions" = "Reactions"; +"NotificationReactionsMessages" = "Messages"; +"NotificationReactionsStories" = "Stories"; +"NotificationShowSenderNames" = "Show Sender's Name"; +"NotificationStories" = "Stories"; +"NotificationStory" = "%s shared a story with you"; +"NotificationTopicExceptionsDesctription#one" = "%s, %d exception"; +"NotificationTopicExceptionsDesctription#other" = "%s, %d exceptions"; +"NotificationUnrecognizedDevice" = "%1$s,\nWe detected a login to your account from a new device on %2$s\n\nDevice: %3$s\nLocation: %4$s\n\nIf this wasn't you, you can go to Settings > Devices and terminate that session.\n\nIf you think that somebody logged in to your account against your will, you can enable Two-Step Verification in Privacy and Security settings.\n\nSincerely,\nThe Telegram Team"; +"Notifications" = "Notifications"; +"NotificationsAddAnException" = "Add Exception"; +"NotificationsAndSounds" = "Notifications and Sounds"; +"NotificationsChannels" = "Channels"; +"NotificationsChartTitle" = "Notifications"; +"NotificationsChatInApp" = "%1$s In-App"; +"NotificationsCustom" = "Custom"; +"NotificationsCustomize" = "Customize"; +"NotificationsDefault" = "Default"; +"NotificationsDefaultOff" = "Default (Off)"; +"NotificationsDefaultOn" = "Default (On)"; +"NotificationsDeleteAllException" = "Delete All Exceptions"; +"NotificationsDeleteAllExceptionAlert" = "Are you sure you want to delete all exceptions?"; +"NotificationsDeleteAllExceptionTitle" = "Delete all exceptions"; +"NotificationsEnableCustom" = "Enable Custom Notifications"; +"NotificationsExceptions" = "Exceptions"; +"NotificationsExceptionsAlert" = "Please note that **%1$s** are listed as exceptions and won't be affected by this change."; +"NotificationsExceptionsSingleAlert" = "Please note that **%1$s** is listed as an exception and won't be affected by this change."; +"NotificationsForChannels" = "Notifications for channels"; +"NotificationsForChats" = "Notifications for chats"; +"NotificationsForGroups" = "Notifications for groups"; +"NotificationsForPrivateChats" = "Notifications for private chats"; +"NotificationsForStories" = "Story Notifications"; +"NotificationsFrequencyDivider" = "in"; +"NotificationsGroups" = "Groups"; +"NotificationsImportance" = "Priority"; +"NotificationsInAppDefault" = "In-App Default"; +"NotificationsLed" = "Light"; +"NotificationsLedColor" = "Color"; +"NotificationsLedInfo" = "Blinking light used to indicate new messages on some devices."; +"NotificationsMuted" = "Muted"; +"NotificationsMutedForHint" = "Notifications muted for %1$s."; +"NotificationsMutedHint" = "Notifications muted."; +"NotificationsMutedHintChats#one" = "Notifications muted for %d chat"; +"NotificationsMutedHintChats#other" = "Notifications muted for %d chats"; +"NotificationsNewException" = "New Exception"; +"NotificationsOff" = "Off"; +"NotificationsOffUntil" = "Off until %1$s"; +"NotificationsOn" = "On"; +"NotificationsOther" = "Other"; +"NotificationsPermissionAlert1" = "Get notified about new messages"; +"NotificationsPermissionAlert2" = "Know when you are mentioned in groups"; +"NotificationsPermissionAlert3" = "Mute specific chats and types of chats"; +"NotificationsPermissionAlertSubtitle" = "Please allow Telegram to send you notifications to stay in touch with your friends and family."; +"NotificationsPermissionAlertTitle" = "Turn on notifications"; +"NotificationsPermissionContinue" = "Continue"; +"NotificationsPermissionSettings" = "Open Settings"; +"NotificationsPriority" = "Priority"; +"NotificationsPriorityDefault" = "Default"; +"NotificationsPriorityHigh" = "High"; +"NotificationsPriorityLow" = "Low"; +"NotificationsPriorityMax" = "Max"; +"NotificationsPriorityMedium" = "Medium"; +"NotificationsPrioritySettings" = "Same as in Settings"; +"NotificationsPriorityUrgent" = "Urgent"; +"NotificationsPrivateChats" = "Private Chats"; +"NotificationsReactions" = "Reactions"; +"NotificationsService" = "Keep-Alive Service"; +"NotificationsServiceConnection" = "Background Connection"; +"NotificationsServiceConnectionInfo" = "Keep a low-impact background connection to Telegram for reliable notifications."; +"NotificationsServiceInfo" = "Relaunch app when shut down. Enable for reliable notifications."; +"NotificationsSilent" = "Silent"; +"NotificationsSound" = "Notification Sound"; +"NotificationsSoundChannels" = "Channel Sound"; +"NotificationsSoundGroup" = "Group Sound"; +"NotificationsSoundPrivate" = "Private Chat Sound"; +"NotificationsSoundReactions" = "Reactions Sound"; +"NotificationsSoundStories" = "Story Sound"; +"NotificationsStories" = "Stories"; +"NotificationsStoryMute" = "Do Not Notify"; +"NotificationsStoryMute2" = "Do Not Notify\nAbout Stories"; +"NotificationsStoryMutedHint" = "Disabled notifications for **%s** stories."; +"NotificationsStoryUnmute" = "Notify"; +"NotificationsStoryUnmute2" = "Notify About\nStories"; +"NotificationsStoryUnmutedHint" = "You will receive a notification whenever **%s** posts a new story."; +"NotificationsTurnOff" = "Turn Off"; +"NotificationsTurnOn" = "Turn On"; +"NotificationsUnmuted" = "Unmuted"; +"NotificationsUnmutedHint" = "Notifications unmuted."; +"NotificationsUnmutedHintChats#one" = "Notifications unmuted for %d chat"; +"NotificationsUnmutedHintChats#other" = "Notifications unmuted for %d chats"; +"NotifyAboutReactionsFrom" = "Notify about reactions from"; +"NotifyAboutReactionsFromContacts" = "My Contacts"; +"NotifyAboutReactionsFromEveryone" = "Everyone"; +"NotifyFromContacts" = "From My Contacts"; +"NotifyFromEveryone" = "From Everyone"; +"NotifyFromNobody" = "Off"; +"NotifyLessOptions" = "Fewer Options"; +"NotifyMeAbout" = "Notify me about…"; +"NotifyMeAboutChannels" = "New Messages in Channels"; +"NotifyMeAboutGroups" = "New Messages in Groups"; +"NotifyMeAboutImportantStories" = "Important Stories"; +"NotifyMeAboutMessagesReactions" = "Reactions to My Messages"; +"NotifyMeAboutNewStories" = "New Stories"; +"NotifyMeAboutPrivate" = "New Messages in Private Chats"; +"NotifyMeAboutStoriesReactions" = "Reactions to My Stories"; +"NotifyMoreOptions" = "More Options"; +"November" = "November"; +"NowInContacts" = "%1$s is now in your contacts."; +"NumberNotOnTelegram" = "This number is not on Telegram"; +"NumberUnknown" = "Unknown"; +"OK" = "OK"; +"October" = "October"; +"Of" = "%1$d of %2$d"; +"OfCounted" = "**%1$d** of %2$d"; +"OfS" = "%1$s of %2$s"; +"OneResult" = "1 result"; +"Online" = "online"; +"OnlineCount#one" = "%1$d online"; +"OnlineCount#other" = "%1$d online"; +"OnlyAllowThisReactions" = "Only allow these reactions"; +"OnlyIfSilent" = "Only on silent"; +"OnlyPremiumCanMessage" = "%s only accepts messages from Premium users.\n**Learn more...**"; +"OnlyPremiumCanReply" = "Only Premium users can reply to %s.\n**Learn more...**"; +"OnlyWhenScreenOff" = "Only when screen \"off\""; +"OnlyWhenScreenOn" = "Only when screen \"on\""; +"Open" = "Open"; +"OpenAIStyle" = "VIEW STYLE"; +"OpenAllTopics" = "All Topics"; +"OpenBackground" = "VIEW WALLPAPER"; +"OpenBot" = "OPEN BOT"; +"OpenChannel" = "VIEW CHANNEL"; +"OpenChannel2" = "Open Channel"; +"OpenChannelArchiveStories" = "Archived Stories"; +"OpenChannelDirect" = "CONTACT CHANNEL"; +"OpenChannelPost" = "VIEW POST"; +"OpenEmojiSet" = "VIEW EMOJI"; +"OpenFile" = "OPEN FILE"; +"OpenFragment" = "Open Fragment"; +"OpenGift" = "OPEN"; +"OpenGiftAuctionActive" = "JOIN"; +"OpenGiftAuctionResults" = "VIEW RESULTS"; +"OpenGiftAuctionView" = "VIEW"; +"OpenGroup" = "VIEW GROUP"; +"OpenGroup2" = "Open Group"; +"OpenInBrowser" = "OPEN IN..."; +"OpenInEditor" = "Open in editor"; +"OpenInExternalApp" = "Open in..."; +"OpenInMaps" = "Open in Maps"; +"OpenInSystemBrowser" = "Open in..."; +"OpenInSystemBrowser2" = "Open in Browser"; +"OpenInTelegramBrowser" = "Open in Telegram"; +"OpenInTelegramBrowser2" = "Open In-App"; +"OpenInstantView" = "Instant View"; +"OpenLink" = "Open Link"; +"OpenLocalInstantView" = "Instant View"; +"OpenMessage" = "VIEW MESSAGE"; +"OpenProfile" = "Open Profile"; +"OpenStickerSet" = "VIEW STICKERS"; +"OpenTheme" = "VIEW THEME"; +"OpenUniqueGift" = "VIEW COLLECTIBLE"; +"OpenUrlAlert" = "Open this link: %1$s?"; +"OpenUrlAlert2" = "Do you want to open %1$s?"; +"OpenUrlOption1" = "Log in to %1$s as **%2$s**"; +"OpenUrlOption2" = "Allow **%1$s** to send me messages"; +"OpenUrlTitle" = "Open Link"; +"OptimizingTelegram" = "Optimizing Telegram..."; +"OptimizingTelegramDescription1" = "This may take a while, depending on the size of the database. Please keep the app open until the process is finished."; +"OptimizingTelegramDescription2" = "Sorry for the inconvenience."; +"Option#one" = "%1$d more option"; +"Option#other" = "%1$d more options"; +"OptionHint" = "Option"; +"OptionPremiumRequiredButton" = "Unlock"; +"OptionPremiumRequiredMessage" = "Subscribe to **Telegram Premium** to select this option."; +"OptionPremiumRequiredTitle" = "Premium Required"; +"Orange" = "Orange"; +"OtherLoginCode" = "Your login code is **%1$s**. Enter it in the Telegram app where you are trying to log in.\n\nDo not give this code to anyone."; +"OtherSessions" = "Active sessions"; +"OtherSettings" = "Other Settings"; +"OtherStickers" = "More Stickers"; +"OtherWebSessions" = "Connected websites"; +"OutgoingCalls" = "Outgoing calls"; +"OutgoingCallsCount" = "%s outgoing calls"; +"P2PContacts" = "My Contacts"; +"P2PContactsMinus" = "My Contacts (-%1$d)"; +"P2PContactsMinusPlus" = "My Contacts (-%1$d, +%2$d)"; +"P2PContactsPlus" = "My Contacts (+%1$d)"; +"P2PEnabledWith" = "Use peer-to-peer with"; +"P2PEverybody" = "Everybody"; +"P2PEverybodyMinus" = "Everybody (-%1$d)"; +"P2PNobody" = "Nobody"; +"P2PNobodyPlus" = "Nobody (+%1$d)"; +"Page1Message" = "The world's **fastest** messaging app.\nIt is **free** and **secure**."; +"Page1Title" = "Telegram"; +"Page2Message" = "**Telegram** delivers messages\nfaster than any other application."; +"Page2Title" = "Fast"; +"Page3Message" = "**Telegram** provides free unlimited\ncloud storage for chats and media."; +"Page3Title" = "Free"; +"Page4Message" = "**Telegram** keeps your messages safe\nfrom hacker attacks."; +"Page4Title" = "Secure"; +"Page5Message" = "**Telegram** has no limits on\nthe size of your media and chats."; +"Page5Title" = "Powerful"; +"Page6Message" = "**Telegram** lets you access your\nmessages from multiple devices."; +"Page6Title" = "Cloud-Based"; +"PaidContentButton" = "Make This Media Paid"; +"PaidContentClearButton" = "Keep this media free"; +"PaidContentInfo" = "Users will have to transfer this amount of Stars to your channel in order to view this media. **Learn more >**"; +"PaidContentInfoLink" = "https://telegram.org/tos/stars"; +"PaidContentPriceTitle" = "Enter unlock cost"; +"PaidContentTitle" = "Paid Content"; +"PaidContentUpdateButton" = "Save Price"; +"PaidMediaButton" = "Make this content paid"; +"PaidMediaPrice#one" = "⭐️%d"; +"PaidMediaPrice#other" = "⭐️%d"; +"PaidMediaPriceButton" = "Edit price"; +"PaidMessageAction#one" = "un1 paid %1$d Star to send a message"; +"PaidMessageAction#other" = "un1 paid %1$d Stars to send a message"; +"PaidMessageActionIn#one" = "un1 paid %1$d Star to send a message"; +"PaidMessageActionIn#other" = "un1 paid %1$d Stars to send a message"; +"PaidMessageActionMany#one" = "un1 paid %1$d Star to send %2$s"; +"PaidMessageActionMany#other" = "un1 paid %1$d Stars to send %2$s"; +"PaidMessageActionMessages#one" = "%d message"; +"PaidMessageActionMessages#other" = "%d messages"; +"PaidMessageActionOut#one" = "You paid %d Star to send a message"; +"PaidMessageActionOut#other" = "You paid %d Stars to send a message"; +"PaidMessageActionOutMany#one" = "You paid %1$d Star to send %2$s"; +"PaidMessageActionOutMany#other" = "You paid %1$d Stars to send %2$s"; +"PaidMessageSentSubtitle#one" = "You paid **%d** Star."; +"PaidMessageSentSubtitle#other" = "You paid **%d** Stars."; +"PaidMessageSentTitle#one" = "%d message sent!"; +"PaidMessageSentTitle#other" = "%d messages sent!"; +"PaidMessageSentTitleOne" = "Message sent!"; +"PaidMessagesPriceUpdated#one" = "un1 changed price to %1$d Star per message"; +"PaidMessagesPriceUpdated#other" = "un1 changed price to %1$d Stars per message"; +"PaidMessagesPriceUpdatedOut#one" = "You changed price to %1$d Star per message"; +"PaidMessagesPriceUpdatedOut#other" = "You changed price to %1$d Stars per message"; +"PaidMessagesRefunded#one" = "un1 refunded %1$d Star to you"; +"PaidMessagesRefunded#other" = "un1 refunded %1$d Stars to you"; +"PaidMessagesRefundedOut#one" = "You refunded %1$d Star to un1"; +"PaidMessagesRefundedOut#other" = "You refunded %1$d Stars to un1"; +"PaidMessagesSendErrorState1#one" = "Price went up from ⭐️%d"; +"PaidMessagesSendErrorState1#other" = "Price went up from ⭐️%d"; +"PaidMessagesSendErrorState2#one" = "to ⭐️%d"; +"PaidMessagesSendErrorState2#other" = "to ⭐️%d"; +"PaidMessagesSendErrorToast1#one" = "Price went up from ⭐️%d"; +"PaidMessagesSendErrorToast1#other" = "Price went up from ⭐️%d"; +"PaidMessagesSendErrorToast2#one" = "to ⭐️%d"; +"PaidMessagesSendErrorToast2#other" = "to ⭐️%d"; +"PaintArrow" = "Arrow"; +"PaintBringToFront" = "To Front"; +"PaintBubble" = "Bubble"; +"PaintCircle" = "Circle"; +"PaintDelete" = "Delete"; +"PaintDuplicate" = "Duplicate"; +"PaintEdit" = "Edit"; +"PaintFramed" = "Framed"; +"PaintMarker" = "Marker"; +"PaintNeon" = "Neon"; +"PaintOutlined" = "Outlined"; +"PaintPaletteGrid" = "Grid"; +"PaintPaletteSliders" = "Sliders"; +"PaintPaletteSlidersBlue" = "BLUE"; +"PaintPaletteSlidersGreen" = "GREEN"; +"PaintPaletteSlidersHexColor" = "Hex Color #"; +"PaintPaletteSlidersRed" = "RED"; +"PaintPaletteSpectrum" = "Spectrum"; +"PaintPen" = "Pen"; +"PaintRectangle" = "Rectangle"; +"PaintRegular" = "Regular"; +"PaintStar" = "Star"; +"Participants#one" = "%1$d participant"; +"Participants#other" = "%1$d participants"; +"Passcode" = "Passcode Lock"; +"PasscodeDoNotMatch" = "Passcodes do not match"; +"PasscodeFinish" = "Set Passcode"; +"PasscodePIN" = "PIN"; +"PasscodePassword" = "Password"; +"PasscodeReinstallNotice" = "If you forget your passcode, you'll need to **log out** or **reinstall** the app. All secret chats will be lost."; +"PasscodeScreenHint" = "Tap the lock icon above your chat list to lock the app."; +"PasscodeSwitchToPIN" = "Switch to PIN"; +"PasscodeSwitchToPassword" = "Switch to Password"; +"PasscodesDoNotMatchTryAgain" = "Passcodes don't match. Please try again."; +"Passkey" = "Passkeys"; +"PasskeyAdd" = "Create Passkey"; +"PasskeyAddedText" = "You can now login into your account with %s."; +"PasskeyAddedTitle" = "Passkey Added!"; +"PasskeyCreatedOn" = "Created %s"; +"PasskeyDeleteText" = "Once deleted, this passkey can't be used to log in.\n\nDon't forget to remove it from your password manager too."; +"PasskeyDeleteTitle" = "Delete Passkey"; +"PasskeyFeature1Subtitle" = "Make a passkey to sign in easily and safely."; +"PasskeyFeature1Title" = "Create a Passkey"; +"PasskeyFeature2Subtitle" = "Use your face, fingerprint or screen lock to sign in."; +"PasskeyFeature2Title" = "Log in with face recognition"; +"PasskeyFeature3Subtitle" = "Your passkey is stored safely in the cloud service you choose."; +"PasskeyFeature3Title" = "Store Passkey securely"; +"PasskeyFeatureButton" = "Create Passkey"; +"PasskeyFeatureSubtitle" = "Log in safely and keep your account secure."; +"PasskeyFeatureTitle" = "Protect your account"; +"PasskeyInfo" = "Your passkey is stored securely in your password manager. **Learn more >**"; +"PasskeyLastUsedOn" = "Last used %s"; +"PasskeyNoOptionsText" = "Install app that supports saving passkeys on your phone or physical ledgers."; +"PasskeyNoOptionsTitle" = "No Passkey App"; +"PasskeyPopupText" = "Log in with your face, fingerprint or screen lock"; +"PasskeyPopupTitle" = "🔑 Protect your account"; +"PasskeyTopInfo" = "Manage your passkey, stored safely in the cloud service you choose."; +"PasskeyUnknown" = "Passkey"; +"PassportAddAddressUploadInfo" = "To confirm your address, please upload a scan or photo of the selected document (all pages)."; +"PassportAddAgreement" = "Add Tenancy Agreement"; +"PassportAddAgreementInfo" = "Upload a scan of your tenancy agreement."; +"PassportAddBank" = "Add Bank Statement"; +"PassportAddBankInfo" = "Upload a scan of your bank statement."; +"PassportAddBill" = "Add Utility Bill"; +"PassportAddBillInfo" = "Upload a scan of your utility bill."; +"PassportAddCard" = "Add Identity Card"; +"PassportAddDriverLicenceInfo" = "Upload scans of a certified English translation of your driver's license."; +"PassportAddIdentityCardInfo" = "Upload scans of a certified English translation of your identity card."; +"PassportAddInternalPassport" = "Add Internal Passport"; +"PassportAddInternalPassportInfo" = "Upload scans of a certified English translation of your internal passport."; +"PassportAddLicence" = "Add Driver's License"; +"PassportAddPassport" = "Add Passport"; +"PassportAddPassportInfo" = "Upload scans of a certified English translation of your passport."; +"PassportAddPassportRegistration" = "Passport Registration"; +"PassportAddPassportRegistrationInfo" = "Upload a scan of your passport registration page."; +"PassportAddTemporaryRegistration" = "Temporary Registration"; +"PassportAddTemporaryRegistrationInfo" = "Upload a scan of your temporary registration."; +"PassportAddTranslationAgreementInfo" = "Upload scans of a certified English translation of your tenancy agreement."; +"PassportAddTranslationBankInfo" = "Upload scans of a certified English translation of your bank statement."; +"PassportAddTranslationBillInfo" = "Upload scans of a certified English translation of your utility bill."; +"PassportAddTranslationPassportRegistrationInfo" = "Upload scans of a certified English translation of your passport registration page."; +"PassportAddTranslationTemporaryRegistrationInfo" = "Upload scans of a certified English translation of your temporary registration."; +"PassportAddTranslationUploadInfo" = "Upload scans of a certified English translation of the selected document."; +"PassportAddress" = "Address"; +"PassportAddressHeader" = "Address"; +"PassportAddressInfo" = "Upload proof of your address"; +"PassportAddressNoUploadInfo" = "Provide your address"; +"PassportAuthorize" = "AUTHORIZE"; +"PassportBirthdate" = "Date of Birth"; +"PassportCitizenship" = "Citizenship"; +"PassportCity" = "City"; +"PassportCorrectErrors" = "Tap to correct errors."; +"PassportCountry" = "Country"; +"PassportDeleteAddressAlert" = "Do you want to delete address details?"; +"PassportDeleteDocument" = "Delete Document"; +"PassportDeleteDocumentAddress" = "Delete address details"; +"PassportDeleteDocumentAlert" = "Do you want to delete this document?"; +"PassportDeleteDocumentPersonal" = "Delete personal details"; +"PassportDeleteEmailAlert" = "Delete email?"; +"PassportDeleteInfo" = "Delete"; +"PassportDeletePersonalAlert" = "Are you sure you want to delete personal details?"; +"PassportDeletePhoneAlert" = "Delete phone number?"; +"PassportDeleteScan" = "Delete scan?"; +"PassportDeleteScanAlert" = "Do you want to delete this scan?"; +"PassportDeleteSelfie" = "Delete selfie?"; +"PassportDeleteSelfieAlert" = "Do you want to delete this selfie?"; +"PassportDiscard" = "Discard"; +"PassportDiscardChanges" = "Are you sure you want to discard all information you've entered?"; +"PassportDocument" = "Document Details"; +"PassportDocumentNumber" = "Document Number"; +"PassportDocuments" = "Scans"; +"PassportEmail" = "Email"; +"PassportEmailCode" = "Code"; +"PassportEmailInfo" = "Enter your email address"; +"PassportEmailUploadInfo" = "Note: You will receive a confirmation code on the email address you provide."; +"PassportEmailVerifyInfo" = "Please enter the confirmation code we've just sent to %1$s."; +"PassportExpired" = "Expiry Date"; +"PassportFemale" = "Female"; +"PassportFrontSide" = "Front Side"; +"PassportFrontSideInfo" = "Upload a photo of the front side of the document"; +"PassportGender" = "Gender"; +"PassportIdentityDocument" = "Identity Document"; +"PassportIdentityDocumentInfo" = "Upload a scan of your passport or other ID"; +"PassportIdentityDriverLicence" = "Upload a scan of your driver's license"; +"PassportIdentityID" = "Upload a scan of your identity card"; +"PassportIdentityInternalPassport" = "Upload a scan of your internal passport"; +"PassportIdentityPassport" = "Upload a scan of your passport"; +"PassportInfo" = "With Telegram Passport you can easily sign up for websites and services that require identity verification.\n\nYour information, personal data, and documents are protected by end-to-end encryption. Nobody, including Telegram, can access them without your permission.\n\nYou can visit our FAQ to learn more."; +"PassportInfo2" = "With Telegram Passport you can easily sign up for websites and services that require identity verification.\n\nYour information, personal data, and documents are protected by end-to-end encryption. Nobody, including Telegram, can access them without your permission.\n\nYou can visit our *FAQ* to learn more."; +"PassportInfoTitle" = "What is Telegram Passport?"; +"PassportInfoUrl" = "https://telegram.org/faq#passport"; +"PassportLanguage_AR" = "Arabic"; +"PassportLanguage_AZ" = "Azerbaijani"; +"PassportLanguage_BG" = "Bulgarian"; +"PassportLanguage_BN" = "Bangla"; +"PassportLanguage_CS" = "Czech"; +"PassportLanguage_DA" = "Danish"; +"PassportLanguage_DE" = "German"; +"PassportLanguage_DV" = "Divehi"; +"PassportLanguage_DZ" = "Dzongkha"; +"PassportLanguage_EL" = "Greek"; +"PassportLanguage_ES" = "Spanish"; +"PassportLanguage_ET" = "Estonian"; +"PassportLanguage_FA" = "Persian"; +"PassportLanguage_FR" = "French"; +"PassportLanguage_HE" = "Hebrew"; +"PassportLanguage_HR" = "Croatian"; +"PassportLanguage_HU" = "Hungarian"; +"PassportLanguage_HY" = "Armenian"; +"PassportLanguage_ID" = "Indonesian"; +"PassportLanguage_IS" = "Icelandic"; +"PassportLanguage_IT" = "Italian"; +"PassportLanguage_JA" = "Japanese"; +"PassportLanguage_KA" = "Georgian"; +"PassportLanguage_KM" = "Khmer"; +"PassportLanguage_KO" = "Korean"; +"PassportLanguage_LO" = "Lao"; +"PassportLanguage_LT" = "Lithuanian"; +"PassportLanguage_LV" = "Latvian"; +"PassportLanguage_MK" = "Macedonian"; +"PassportLanguage_MN" = "Mongolian"; +"PassportLanguage_MS" = "Malay"; +"PassportLanguage_MY" = "Burmese"; +"PassportLanguage_NE" = "Nepali"; +"PassportLanguage_NL" = "Dutch"; +"PassportLanguage_PL" = "Polish"; +"PassportLanguage_PT" = "Portuguese"; +"PassportLanguage_RO" = "Romanian"; +"PassportLanguage_RU" = "Russian"; +"PassportLanguage_SK" = "Slovak"; +"PassportLanguage_SL" = "Slovenian"; +"PassportLanguage_TH" = "Thai"; +"PassportLanguage_TK" = "Turkmen"; +"PassportLanguage_TR" = "Turkish"; +"PassportLanguage_UK" = "Ukrainian"; +"PassportLanguage_UZ" = "Uzbek"; +"PassportLanguage_VI" = "Vietnamese"; +"PassportMainPage" = "Main Page"; +"PassportMainPageInfo" = "Upload a photo of the main page of the document"; +"PassportMale" = "Male"; +"PassportMidname" = "Middle name"; +"PassportMidnameCountry" = "Middle name (%1$s)"; +"PassportMidnameLatin" = "Middle name (latin)"; +"PassportName" = "Name"; +"PassportNameCheckAlert" = "Please check if this name is correct:\n\n%1$s %2$s %3$s"; +"PassportNameCountry" = "Name (%1$s)"; +"PassportNameLatin" = "Name (latin)"; +"PassportNativeHeader" = "Your name"; +"PassportNativeHeaderLang" = "Your name in %1$s"; +"PassportNativeInfo" = "Your name in the language of your country of residence (%1$s)."; +"PassportNoDocuments" = "You have no documents yet"; +"PassportNoDocumentsAdd" = "Add a Document"; +"PassportNoDocumentsInfo" = "You can add your phone number, email address, identity document or residential address."; +"PassportNoExpireDate" = "None"; +"PassportNoPolicy" = "You are sending your documents directly to %1$s and allowing their @%2$s to send you messages."; +"PassportPersonal" = "Personal Details"; +"PassportPersonalDetails" = "Personal Details"; +"PassportPersonalDetailsInfo" = "Fill in your personal details"; +"PassportPersonalUploadInfo" = "The document must contain your photograph, first and last name, date of birth, document number, country of issue, and expiry date."; +"PassportPhone" = "Phone Number"; +"PassportPhoneInfo" = "Enter your phone number"; +"PassportPhoneUploadInfo" = "Note: You will receive a confirmation code on the phone number you provide."; +"PassportPhoneUseOther" = "Or enter a new phone number"; +"PassportPhoneUseSame" = "Use %1$s"; +"PassportPhoneUseSameEmailInfo" = "Use the same email as on Telegram."; +"PassportPhoneUseSameInfo" = "Use the same phone number as on Telegram."; +"PassportPolicy" = "You accept the *%1$s Privacy Policy* and allow their @%2$s to send you messages."; +"PassportPostcode" = "Postcode"; +"PassportProvidedInformation" = "Provided Information"; +"PassportRequest" = "**%1$s** requests access to your personal data to sign you up for their services."; +"PassportRequestPasswordInfo" = "Enter your Telegram Password to decrypt your data."; +"PassportRequestedInformation" = "Requested Information"; +"PassportRequiredDocuments" = "Required Documents"; +"PassportResidence" = "Residence"; +"PassportResidentialAddress" = "Residential Address"; +"PassportReverseSide" = "Reverse Side"; +"PassportReverseSideInfo" = "Upload a photo of the reverse side of the document"; +"PassportSDK_Cancel" = "Cancel"; +"PassportSDK_DownloadTelegram" = "You need Telegram to log in to **%s**. Would you like to install it now?"; +"PassportSDK_LogInWithTelegram" = "Log in with Telegram"; +"PassportSDK_OpenGooglePlay" = "Open Google Play"; +"PassportSDK_TelegramPassport" = "Telegram Passport"; +"PassportScanPassport" = "Scan Your Passport"; +"PassportScanPassportInfo" = "Scan your passport or identity card's machine-readable zone to fill in your details automatically."; +"PassportSelectBithdayDate" = "Select Birthday"; +"PassportSelectExpiredDate" = "Select Expiry Date"; +"PassportSelectGender" = "Select Gender"; +"PassportSelectNotExpire" = "Does not Expire"; +"PassportSelfRequest" = "Please enter your password to access your personal data."; +"PassportSelfie" = "Selfie"; +"PassportSelfieInfo" = "Upload a selfie of yourself holding the document"; +"PassportState" = "State/Region"; +"PassportStreet1" = "Street"; +"PassportStreet2" = "Street"; +"PassportSurname" = "Last name"; +"PassportSurnameCountry" = "Last name (%1$s)"; +"PassportSurnameLatin" = "Last name (latin)"; +"PassportTranslation" = "Translation"; +"PassportTwoDocuments" = "%1$s or %2$s"; +"PassportUploadAdditinalDocument" = "Upload Additional Scans"; +"PassportUploadDocument" = "Upload Scans"; +"PassportUploadMaxReached" = "You can't upload more than %1$s."; +"PassportUploadNotImage" = "You can only upload image files."; +"PassportUseLatinOnly" = "Please use latin characters only."; +"PasswordAsHintError" = "Hint must be different from your password"; +"PasswordCode" = "Code"; +"PasswordDoNotMatch" = "Passwords do not match"; +"PasswordEmailInvalid" = "Invalid email address, please try again."; +"PasswordHint" = "Password Hint"; +"PasswordHintDescription" = "You can create an optional hint for your password."; +"PasswordHintPlaceholder" = "Hint"; +"PasswordHintText" = "Create a hint for your password"; +"PasswordHintTextLogin" = "Create a hint for your password."; +"PasswordOff" = "Off"; +"PasswordOn" = "On"; +"PasswordRecovery" = "Password Recovery"; +"PasswordReset" = "Password deactivated"; +"Paste" = "Paste"; +"PasteFromClipboard" = "Paste from clipboard"; +"PauseAll" = "Pause all"; +"PauseMusicOnMedia" = "Pause music while playing media"; +"PauseMusicOnRecord" = "Pause music while recording"; +"PauseMusicOnRecordInfo" = "Pause music when you start recording a video message"; +"PaymentAppNotFoundForDeeplink" = "No app found to handle this deep link."; +"PaymentBillingAddress" = "Billing address"; +"PaymentCardCvv" = "Security Code (CVV)"; +"PaymentCardExpireDate" = "MM/YY"; +"PaymentCardInfo" = "Payment info"; +"PaymentCardName" = "First and Last Name"; +"PaymentCardNumber" = "Card Number"; +"PaymentCardSavePaymentInformation" = "Save Payment Information"; +"PaymentCardSavePaymentInformationInfoLine1" = "You can save your payment info for future use. It will be stored directly with the payment provider. Telegram has no access to your credit card data."; +"PaymentCardSavePaymentInformationInfoLine2" = "Please *turn on Two-Step Verification* to enable this."; +"PaymentCardTitle" = "Payment card"; +"PaymentCheckout" = "Checkout"; +"PaymentCheckoutAcceptRecurrent" = "I accept the *Terms of Service* of %1$s."; +"PaymentCheckoutAcceptRecurrentFee" = "I accept the *Terms of Service*."; +"PaymentCheckoutEmail" = "Contact address"; +"PaymentCheckoutMethod" = "Payment method"; +"PaymentCheckoutMethodNewCard" = "Add Bank Card…"; +"PaymentCheckoutName" = "Name"; +"PaymentCheckoutPay" = "PAY %1$s"; +"PaymentCheckoutPhoneNumber" = "Phone number"; +"PaymentCheckoutProvider" = "Payment provider"; +"PaymentCheckoutShippingMethod" = "Shipping method"; +"PaymentConfirmationError" = "There was an error during payment confirmation."; +"PaymentConfirmationMessage" = "Your card %1$s is on file. To pay with this card, please enter your 2-Step-Verification password."; +"PaymentConfirmationNewCard" = "Choose Another Card"; +"PaymentConnectionFailed" = "Unable to reach payment server. Please check your Internet connection and try again."; +"PaymentEmailToProvider" = "Email address will be passed to %1$s as billing info."; +"PaymentFailed" = "Sorry, the payment was declined."; +"PaymentInfoHint" = "You paid **%1$s** for **%2$s**."; +"PaymentInfoHintStars#other" = "You paid **%2$s** for **%1$d Stars**."; +"PaymentInvoice" = "INVOICE"; +"PaymentInvoiceLinkInvalid" = "Invalid invoice"; +"PaymentInvoiceSubscriptionLinkAlreadyPaid" = "Already subscribed!"; +"PaymentNoShippingMethod" = "Sorry, it is not possible to deliver to your address."; +"PaymentPassword" = "Password & Email"; +"PaymentPasswordEmail" = "Your email address"; +"PaymentPasswordEmailInfo" = "Please enter a valid email address. It is the only way to recover a forgotten password."; +"PaymentPasswordEmailTitle" = "Recovery email"; +"PaymentPasswordEnter" = "Enter a password"; +"PaymentPasswordInfo" = "Please create a password to protect your payment info. You'll be asked to enter it when you log in."; +"PaymentPasswordReEnter" = "Re-enter your password"; +"PaymentPasswordTitle" = "Password"; +"PaymentPhoneEmailToProvider" = "Phone and email address will be passed to %1$s as billing info."; +"PaymentPhoneToProvider" = "Phone will be passed to %1$s as billing info."; +"PaymentPrecheckoutFailed" = "Sorry, the payment was canceled by the bot."; +"PaymentReceipt" = "Receipt"; +"PaymentShippingAddress" = "Shipping address"; +"PaymentShippingAddress1Placeholder" = "Address 1 (Street)"; +"PaymentShippingAddress2Placeholder" = "Address 2 (Street)"; +"PaymentShippingCityPlaceholder" = "City"; +"PaymentShippingCountry" = "Country"; +"PaymentShippingEmailPlaceholder" = "Email"; +"PaymentShippingInfo" = "Shipping Information"; +"PaymentShippingMethod" = "Shipping methods"; +"PaymentShippingName" = "Full Name"; +"PaymentShippingPhoneNumber" = "Phone Number"; +"PaymentShippingReceiver" = "Receiver"; +"PaymentShippingSave" = "Save Shipping Information"; +"PaymentShippingSaveInfo" = "You can save your shipping info for future use."; +"PaymentShippingStatePlaceholder" = "State"; +"PaymentShippingZipPlaceholder" = "Postcode"; +"PaymentSuccessfullyPaid" = "You successfully transferred %1$s to %2$s for %3$s"; +"PaymentSuccessfullyPaidMeNoItemSubscription" = "%1$s has transferred %2$s for subscription until %3$s"; +"PaymentSuccessfullyPaidMeSubscription" = "%1$s has transferred %2$s for %3$s subscription until %4$s"; +"PaymentSuccessfullyPaidNoItem" = "You successfully transferred %1$s to %2$s"; +"PaymentSuccessfullyPaidNoItemRecurrent" = "You successfully transferred %1$s to %2$s and allowed future recurring payments"; +"PaymentSuccessfullyPaidRecurrent" = "You successfully transferred %1$s to %2$s for %3$s and allowed future recurring payments"; +"PaymentSuccessfullyPaidSubscription" = "You have just successfully transferred %1$s to %2$s for %3$s until %4$s"; +"PaymentSuccessfullyPaidSubscriptionNoItem" = "You have just successfully transferred %1$s to %2$s until %3$s"; +"PaymentTestInvoice" = "TEST INVOICE"; +"PaymentTip" = "Tip"; +"PaymentTipOptional" = "Tip (Optional)"; +"PaymentTransactionMessage" = "Are you sure you want to transfer %1$s to the bot %2$s for %3$s?"; +"PaymentTransactionMessage2" = "Transfer **%1$s** to the %2$s bot for **%3$s**?"; +"PaymentTransactionReview" = "Transaction Review"; +"PaymentTransactionTotal" = "Total"; +"PaymentWarning" = "Warning"; +"PaymentWarningText" = "Neither Telegram, nor %1$s will have access to your credit card information. Credit card details will be handled only by the payment system, %2$s.\n\nPayments will go directly to the developer of %1$s. Telegram cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of %1$s or your bank."; +"PeerRequirementChannelBotParticipant" = "The bot must be in the channel."; +"PeerRequirementChannelCreatorFalse" = "You must not be the owner of the channel."; +"PeerRequirementChannelCreatorTrue" = "You must be the owner of the channel."; +"PeerRequirementChannelPublicFalse" = "The channel must be private."; +"PeerRequirementChannelPublicTrue" = "The channel must be public."; +"PeerRequirementForumFalse" = "The group must have topics turned off."; +"PeerRequirementForumTrue" = "The group must have topics turned on."; +"PeerRequirementGroupBotParticipant" = "The bot must be in the group."; +"PeerRequirementGroupCreatorFalse" = "You must not be the owner of the group."; +"PeerRequirementGroupCreatorTrue" = "You must be the owner of the group."; +"PeerRequirementGroupPublicFalse" = "The group must be private."; +"PeerRequirementGroupPublicTrue" = "The group must be public."; +"PeerRequirementPremiumFalse" = "The user must not have a Premium subscription."; +"PeerRequirementPremiumTrue" = "The user must have a Premium subscription."; +"PeerRequirementUserRight" = "You must have admin rights to"; +"PeerRequirementUserRights" = "You must have these admin rights:"; +"PeerRequirements" = "Requirements"; +"PendingEmailText" = "Your recovery email %1$s is not yet active and pending confirmation."; +"PeopleCanJoinViaLink" = "%s people can join via this link"; +"PeopleCanJoinViaLinkCount#one" = "%s person can join via this link."; +"PeopleCanJoinViaLinkCount#other" = "%s people can join via this link."; +"PeopleJoined#one" = "%1$d person joined"; +"PeopleJoined#other" = "%1$d people joined"; +"PeopleJoinedRemaining#one" = "%1$d remaining"; +"PeopleJoinedRemaining#other" = "%1$d remaining"; +"PeopleNearby" = "People Nearby"; +"PeopleNearbyAccessInfo" = "Quickly add people nearby who are viewing this section and discover local group chats.\n\nPlease switch on location access to enable this feature."; +"PeopleNearbyAllowAccess" = "Allow Access"; +"PeopleNearbyEmpty" = "Looking for users around you..."; +"PeopleNearbyGps" = "Turn On"; +"PeopleNearbyGpsInfo" = "Quickly add people nearby who are also viewing this section and discover local group chats.\n\nPlease turn on location services to enable this feature."; +"PeopleNearbyHeader" = "People nearby"; +"PeopleNearbyInfo" = "Ask your friend nearby to open this page to exchange phone numbers."; +"PeopleNearbyInfo2" = "Exchange contact info with people nearby and find new friends."; +"PeopleSubscriptionExpired#one" = "%d expired subscription"; +"PeopleSubscriptionExpired#other" = "%d expired subscriptions"; +"Percent" = "%d%"; +"PeriodDHM" = "%1$sd:%2$sh:%3$sm"; +"Permanent" = "Primary link"; +"PermanentLinkForThisAdmin" = "Invite link for this admin"; +"PermissionBackgroundLocation" = "To share your live location in this chat, Telegram needs access to your location all the time, including while the app is in the background.\n\nWe will access your location only for the duration you choose, and you can stop sharing it any time. We won't use your location for any purpose other than sharing it in this chat."; +"PermissionContacts" = "To let you connect with friends across all your devices, your contacts will be continuously synced with Telegram's heavily encrypted cloud servers."; +"PermissionDrawAboveOtherApps" = "Telegram needs access to draw above other apps to play videos in Picture-in-Picture mode."; +"PermissionDrawAboveOtherAppsGroupCall" = "Overlay mode lets you use push-to-talk and see who is talking even when you are outside of Telegram."; +"PermissionDrawAboveOtherAppsGroupCallTitle" = "Video chat overlay"; +"PermissionDrawAboveOtherAppsTitle" = "Picture-in-Picture"; +"PermissionFSILockscreen" = "Please allow Telegram to be shown on the lock screen so that you can receive calls when your device is locked."; +"PermissionNoAudio" = "Telegram needs microphone access so that you can send voice messages."; +"PermissionNoAudioStorageStory" = "**Telegram** needs access to music files to add them to your story. Tap Settings > Permissions, and turn **Music and Audio** on."; +"PermissionNoAudioVideo" = "Telegram needs access to your microphone so that you can record videos."; +"PermissionNoAudioVideoWithHint" = "**Telegram** needs microphone access so that you can send voice messages. Tap Settings > Permissions, and turn **Microphone** on."; +"PermissionNoAudioWithHint" = "**Telegram** needs microphone access so that you can send voice messages. Tap Settings > Permissions, and turn **Microphone** on."; +"PermissionNoBluetoothWithHint" = "**Telegram** needs bluetooth access to record audio from your headset microphone. Tap Settings > Permissions, and turn **Bluetooth** on."; +"PermissionNoCamera" = "Telegram needs camera access so that you can take photos and videos. Please enable it in Settings."; +"PermissionNoCameraMicVideo" = "**Telegram** needs camera and microphone access so that you can send video messages. Tap Settings > Permissions, and turn **Microphone** and **Camera** on."; +"PermissionNoCameraWithHint" = "**Telegram** needs camera access so that you can take photos and videos. Tap Settings > Permissions, and turn **Camera** on."; +"PermissionNoContactsSaving" = "**Telegram** needs access to contacts to save them on your phone. Tap Settings > Permissions, and turn **Contacts** on."; +"PermissionNoContactsSharing" = "**Telegram** needs access to contacts so that you can share them with your friends. Tap Settings > Permissions, and turn **Contacts** on."; +"PermissionNoLocation" = "Telegram needs access to your location so that you can share it with your friends."; +"PermissionNoLocationFriends" = "**Telegram** needs access to location so that you can share it with your friends. Tap Settings > Permissions, and turn **Location** on."; +"PermissionNoLocationNavigation" = "**Telegram** needs access to location so that you can get directions. Tap Settings > Permissions, and turn **Location** on."; +"PermissionNoLocationPeopleNearby" = "**Telegram** needs access to location so that you can find people nearby. Tap Settings > Permissions, and turn **Location** on."; +"PermissionNoLocationPosition" = "Telegram needs access to your location."; +"PermissionNoLocationStory" = "Telegram needs access to your location so that you can add the weather widget to your stories."; +"PermissionNoStorageAvatar" = "**Telegram** needs storage access so you can upload profile photos. Tap Settings > Permissions, and turn **Files and media** on."; +"PermissionOpenSettings" = "Settings"; +"PermissionStorage" = "Telegram needs access to your storage so that you can send and save photos, videos, music and other documents."; +"PermissionStorageWithHint" = "**Telegram** needs storage access so you can send and save photos, videos, music and other documents. Tap Settings > Permissions, and turn **Files and media** on."; +"PermissionXiaomiLockscreen" = "Please allow Telegram to be shown on the lock screen so that you can receive calls when your device is locked."; +"PersonalChannel" = "personal channel"; +"Phone" = "Phone"; +"PhoneCopied" = "Phone copied to clipboard"; +"PhoneHidden" = "Unknown"; +"PhoneHome" = "Home"; +"PhoneMain" = "Main"; +"PhoneMobile" = "Mobile"; +"PhoneNumber" = "Phone number"; +"PhoneNumberAlert" = "Users will see your new number if they have it in their address book or your privacy settings allow them to see it. You can modify this in Settings > Privacy and Security > Phone number."; +"PhoneNumberChange" = "CHANGE NUMBER"; +"PhoneNumberChange2" = "Change Number"; +"PhoneNumberChangeTitle" = "Change number"; +"PhoneNumberFlood" = "Sorry, you have deleted and re-created your account too many times recently. Please wait for a few days before signing up again."; +"PhoneNumberHelp" = "You can change your Telegram number here. Your account and all your cloud data — messages, media, contacts, etc. will be moved to the new number."; +"PhoneNumberKeepButton" = "Keep %1$s"; +"PhoneNumberSearch" = "Phone number"; +"PhoneOther" = "Other"; +"PhoneWork" = "Work"; +"PhotoCaption" = "Photo Caption"; +"PhotoEditor" = "Photo Editor"; +"PhotoEditorClearAll" = "Clear All"; +"PhotoEditorDiscardAlert" = "Are you sure you want to discard all changes?"; +"PhotoEditorDraw" = "Draw"; +"PhotoEditorSticker" = "Sticker"; +"PhotoEditorText" = "Text"; +"PhotoEditorTypefaceCondensed" = "Condensed"; +"PhotoEditorTypefaceCourierNew" = "Courier New"; +"PhotoEditorTypefaceItalic" = "Italic"; +"PhotoEditorTypefaceMerriweather" = "Merriweather"; +"PhotoEditorTypefaceMono" = "Mono-serif"; +"PhotoEditorTypefaceRoboto" = "Roboto"; +"PhotoEditorTypefaceSerif" = "Serif"; +"PhotoEditorZoomOut" = "Zoom Out"; +"PhotoForRestDescription" = "You can upload a public photo for users who are restricted from seeing your real profile photos."; +"PhotoForRestTooltip" = "This photo will be shown to users who are restricted from seeing your real photos."; +"PhotoSavedHint" = "Photo saved to gallery."; +"PhotoSavedToDownloadsHint" = "Photo saved to Downloads."; +"PhotoSavedToDownloadsHintLinked" = "Photo saved to **downloads**."; +"PhotoShownOnce" = "This photo can only be viewed once."; +"PhotoWillBeSentInHD" = "The photo will be sent in high quality."; +"PhotoWillBeSentInSD" = "The photo will be sent in standard quality."; +"Photos#one" = "%1$d photo"; +"Photos#other" = "%1$d photos"; +"PhotosSavedHint#one" = "Photo saved to gallery."; +"PhotosSavedHint#other" = "%1$d photos saved to gallery."; +"PhotosSelected#one" = "%1$d photo selected"; +"PhotosSelected#other" = "%1$d photos selected"; +"PinAlsoFor" = "Also pin for %1$s"; +"PinChatsLimitSubtitle" = "Pin up to %d chats in your main chat list"; +"PinChatsLimitTitle" = "Pinned Chats"; +"PinFolderLimitReached" = "Sorry, you can't pin any more chats to the top."; +"PinInTheList" = "Pin in Chat List"; +"PinMessage" = "Pin"; +"PinMessageAlert" = "Pin this message in the group?"; +"PinMessageAlertChannel" = "Do you want to pin this message in this channel?"; +"PinMessageAlertChat" = "Do you want to pin this message to the top of the chat?"; +"PinMessageAlertTitle" = "Pin message"; +"PinMessageInTopicAlert" = "Pin this message in the topic?"; +"PinNotify" = "Notify all members"; +"PinOldMessageAlert" = "Do you want to pin an older message while leaving a more recent one pinned?"; +"PinToTop" = "Pin to top"; +"PinToTopLimitReached" = "Sorry, you can only pin %1$s in your main list. You can pin an unlimited number of chats in your Archive and other Folders."; +"PinToTopLimitReached2" = "Sorry, you can only pin %1$s to the top in the main list. More chats can be pinned in Chat Folders and your Archive."; +"Ping" = "Ping: %1$d ms"; +"Ping2" = "%1$d ms ping"; +"Pink" = "Pink"; +"PinnedCall" = "Pinned Call"; +"PinnedDialogsCount#one" = "Chat pinned"; +"PinnedDialogsCount#other" = "%1$d chats pinned"; +"PinnedMessage" = "Pinned Message"; +"PinnedMessages" = "Pinned Messages"; +"PinnedMessagesCount#one" = "Pinned Message"; +"PinnedMessagesCount#other" = "%1$d Pinned Messages"; +"PinnedMessagesHidden" = "Pinned messages hidden"; +"PinnedMessagesHiddenInfo" = "Pinned messages will be shown again if a new message is pinned."; +"PinnedPoll" = "Pinned poll"; +"PipMinimize" = "Minimize"; +"PlacesInThisArea" = "Places in this area"; +"PlainTextRestrictedHint" = "Text not allowed"; +"PlayEmojiInKeyboard" = "Emoji Panel Animations"; +"PlayFile" = "Play File"; +"PlayMarket" = "Google Play"; +"PleaseDownload" = "Please download media first"; +"PleaseEnterCurrentPassword" = "Enter your password"; +"PleaseEnterCurrentPasswordGiftTransfer" = "Please enter your password to send **%s** to Fragment."; +"PleaseEnterCurrentPasswordTransfer" = "Please enter your password to complete the transfer."; +"PleaseEnterCurrentPasswordWithdraw" = "Please enter your password to collect."; +"PleaseEnterFirstPassword" = "Enter a password"; +"PleaseEnterNewFirstPassword" = "Enter a new password"; +"PleaseEnterNewFirstPasswordHint" = "Enter new password"; +"PleaseEnterNewFirstPasswordLogin" = "You can now set a new password that will be used to log into your account."; +"PleaseEnterNewSecondPasswordHint" = "Re-enter new password"; +"PleaseEnterPassword" = "Enter your new password"; +"PleaseLoginPassport" = "Please log in to your Telegram account to use Telegram Passport."; +"PleaseReEnterPassword" = "Re-enter your password"; +"PleaseStreamDownload" = "Please wait for the video to be fully downloaded."; +"PmEditedDateTimeAt" = "edited %1$s at %2$s"; +"PmEditedTodayAt" = "edited at %s"; +"PmEditedYesterdayAt" = "edited yesterday at %s"; +"PmFwdOriginalDateTimeAt" = "original %1$s at %2$s"; +"PmFwdOriginalTodayAt" = "original at %s"; +"PmFwdOriginalYesterdayAt" = "original yesterday at %s"; +"PmRead" = "read"; +"PmReadAt" = "read %s"; +"PmReadDateTimeAt" = "read %1$s at %2$s"; +"PmReadShowWhen" = "show when"; +"PmReadTodayAt" = "read at %s"; +"PmReadUnknown" = "read time unknown"; +"PmReadYesterdayAt" = "yesterday at %s"; +"Points#one" = "%1$d"; +"Points#other" = "%1$d"; +"Poll" = "Poll"; +"PollAddAnOption" = "Add an Option"; +"PollAddAnOptionHint" = "Option..."; +"PollAddTextOrRemoveMedia" = "Add text or remove media first"; +"PollAddedByAtTime" = "Added by **%1$s** %2$s"; +"PollAddingActionOther" = "%1$s added \"%2$s\" to the poll"; +"PollAddingActionYou" = "You added \"%s\" to the poll"; +"PollAnonymous" = "Anonymous Voting"; +"PollButtonSave" = "Save"; +"PollCantForwardSecretChat" = "Sorry, polls can't be forwarded to secret chats."; +"PollCollapse" = "Collapse"; +"PollCustomDeadlineClosesIn" = "Closes in %s"; +"PollCustomDeadlineClosesInFmt" = "%1$s %2$s %3$s"; +"PollEndsIn" = "ends in %s"; +"PollExpand" = "Expand"; +"PollItemQuote" = "Reply to Option"; +"PollMenuHint" = "You are viewing actions for one option.\nYou can switch to actions for the poll."; +"PollMenuTabOption" = "Option"; +"PollMenuTabPoll" = "Poll"; +"PollMultiple" = "Multiple Answers"; +"PollOptions" = "Poll options"; +"PollQuestion" = "Poll question"; +"PollQuestion2" = "Question"; +"PollQuiz" = "Quiz Mode"; +"PollRemovedActionOther" = "%1$s removed \"%2$s\" from the poll"; +"PollRemovedActionYou" = "You removed \"%s\" from the poll"; +"PollResults" = "Poll Results"; +"PollResultsIn" = "results in %s"; +"PollResultsWillLater" = "Results will appear after the poll ends"; +"PollSelectOption" = "Select an option first"; +"PollStatsWillLater" = "Poll stats are not available now"; +"PollSubmitVotes" = "VOTE"; +"PollSubmitVotesNoCaps" = "Vote"; +"PollTapToSelect" = "Tap to choose the correct answer"; +"PollV2AddLinkMessage" = "Attach a link to this option."; +"PollV2AddLinkTitle" = "Add Link"; +"PollV2AddLinkUrlHint" = "URL"; +"PollV2AllowAddingOptions" = "Allow Adding Options"; +"PollV2AllowAddingOptionsInfo" = "Participants can suggest new options"; +"PollV2AllowMultipleAnswers" = "Allow Multiple Answers"; +"PollV2AllowMultipleAnswersInfo" = "Voters can select more than one option"; +"PollV2AllowRevoting" = "Allow Revoting"; +"PollV2AllowRevotingInfo" = "Voters can change their vote"; +"PollV2AllowedCountries" = "Allowed Countries"; +"PollV2AllowedCountriesListMany" = "%d countries"; +"PollV2AllowedCountriesListManyP#one" = "%1$d country"; +"PollV2AllowedCountriesListManyP#other" = "%1$d countries"; +"PollV2AllowedCountriesListOther" = "%1$s and %2$s"; +"PollV2HideResults" = "Hide results"; +"PollV2HideResultsInfo" = "When enabled, results will not be visible until the poll closes."; +"PollV2LimitByCountry" = "Limit by Country"; +"PollV2LimitByCountryInfo" = "Only users from selected\ncountries can vote"; +"PollV2LimitDuration" = "Limit Duration"; +"PollV2LimitDurationInfo" = "Automatically close the poll at a set time"; +"PollV2MenuViewStats" = "View Stats"; +"PollV2PollDuration" = "Poll duration"; +"PollV2PollDurationOptionCustom" = "Custom"; +"PollV2PollEnds" = "Poll ends"; +"PollV2RestrictToSubscribers" = "Restrict to Subscribers"; +"PollV2RestrictToSubscribersInfo" = "Only subscribers who joined\n24+ hours ago can vote"; +"PollV2SearchHint" = "Search Countries"; +"PollV2SetCorrectAnswer" = "Set Correct Answer"; +"PollV2SetCorrectAnswerInfo" = "Mark one option as the right answer"; +"PollV2ShowWhoVoted" = "Show Who Voted"; +"PollV2ShowWhoVotedInfo" = "Display voter name on each option"; +"PollV2ShuffleOptions" = "Shuffle Options"; +"PollV2ShuffleOptionsInfo" = "Answers appear in a random order for each voter"; +"PollV2StatsPollStats" = "Poll Stats"; +"PollV2StatsVoteTimeline" = "Vote Timeline"; +"PollV2ToastOnlySubscribersCanVote" = "Only subscribers of **%1$s** can vote."; +"PollV2ToastOnlySubscribersFromCountriesCanVoteOne" = "Only subscribers from **%1$s** can vote."; +"PollV2ToastOnlySubscribersFromCountriesCanVoteOther" = "Only subscribers from **%1$s** and **%2$s** can vote."; +"PollV2ToastOnlySubscribersJoined24hCanVote" = "Only subscribers who joined more than **24 hours** ago can vote."; +"PollV2ToastOnlyUsersFromCountriesCanVoteOne" = "Only users from **%1$s** can vote."; +"PollV2ToastOnlyUsersFromCountriesCanVoteOther" = "Only users from **%1$s** and **%2$s** can vote."; +"PollV2YouCanAddXCountriesOnly" = "You can only add up to **%d** countries"; +"PollViewBack" = "< Vote"; +"PollViewResults" = "VIEW RESULTS"; +"PollViewResultsNoCaps" = "View results"; +"PollViewVotes" = "View Votes"; +"PollViewVotesAsAdmin#one" = "%d Vote >"; +"PollViewVotesAsAdmin#other" = "%d Votes >"; +"PollViewVotesX" = "View Votes (%d)"; +"PollVotesCount#one" = "%d Vote"; +"PollVotesCount#other" = "%d Votes"; +"PopularReactions" = "Popular"; +"PopupDisabled" = "Disabled"; +"PopupEnabled" = "Enabled"; +"PopupNotification" = "Popup Notification"; +"PostStatistics" = "Post Statistics"; +"PostSuggestions" = "Direct Messages"; +"PostSuggestionsAddTime" = "Edit Time"; +"PostSuggestionsAddTimeHint" = "Select the date and time you want the post to be published."; +"PostSuggestionsAddTimeHint2" = "The post will remain available for at least %d hours from this date."; +"PostSuggestionsAnytime" = "Anytime"; +"PostSuggestionsDisabledUpdated" = "un1 disabled direct messages"; +"PostSuggestionsEnabledUpdated" = "un1 now accepts direct messages"; +"PostSuggestionsInlineAccept" = "Accept"; +"PostSuggestionsInlineDecline" = "Decline"; +"PostSuggestionsInlineEdit" = "Suggest Changes"; +"PostSuggestionsOff" = "Off"; +"PostSuggestionsOffer" = "Offer"; +"PostSuggestionsOfferChangeTitle" = "Suggest Changes"; +"PostSuggestionsOfferChangeUpdateTerms" = "Update Terms"; +"PostSuggestionsOfferForFree" = "Offer for Free"; +"PostSuggestionsOfferStars" = "Offer ⭐️%s"; +"PostSuggestionsOfferSubtitleStars" = "Choose how many Stars to pay to publish this post."; +"PostSuggestionsOfferSubtitleTON" = "Choose how much to pay to publish this post."; +"PostSuggestionsOfferTON" = "Offer %s Grams"; +"PostSuggestionsOfferTitle" = "Suggest a Post"; +"PostSuggestionsOfferTitlePriceStars" = "Enter Price in Stars"; +"PostSuggestionsOfferTitlePriceTON" = "Enter Price in Grams"; +"PostSuggestionsOfferTitleTime" = "Publishing Time"; +"PostSuggestionsPriceInfo" = "Your channel will receive %1$s of the selected fee (~%2$s$) for each incoming message."; +"PostSuggestionsPriceInfo2" = "You will receive %1$s of the selected fee (~%2$s$) for each incoming message."; +"PostSuggestionsPriceUpdated#one" = "un1 now accepts direct messages for %1$d Star"; +"PostSuggestionsPriceUpdated#other" = "un1 now accepts direct messages for %1$d Stars"; +"PostSuggestionsSendWithOffer" = "Send with Offer"; +"PostSuggestionsStars" = "⭐️ %d"; +"PostingMembers" = "Posting Members"; +"PowerUsage" = "Power Saving"; +"PremiumAvatarToast" = "Subscribe to **Telegram Premium** to set premium avatars."; +"PremiumBusinessAwayMessages" = "Away Messages"; +"PremiumBusinessAwayMessagesDescription" = "Define messages that are automatically sent when you are off."; +"PremiumBusinessChatLinks" = "Links to Chat"; +"PremiumBusinessChatLinksDescription" = "Create links that start a chat with you, suggesting the first message."; +"PremiumBusinessChatbots" = "Coming Later"; +"PremiumBusinessChatbots2" = "Chatbots"; +"PremiumBusinessChatbotsDescription" = "Add any third party chatbots that will process customer interactions."; +"PremiumBusinessGreetingMessages" = "Greeting Messages"; +"PremiumBusinessGreetingMessagesDescription" = "Create greetings that will be automatically sent to new customers."; +"PremiumBusinessIntro" = "Start Page"; +"PremiumBusinessIntroDescription" = "Customize the message people see before they start a chat with you."; +"PremiumBusinessLocation" = "Location"; +"PremiumBusinessLocationDescription" = "Display the location of your business on your account."; +"PremiumBusinessOpeningHours" = "Opening Hours"; +"PremiumBusinessOpeningHoursDescription" = "Show to your customers when you are open for business."; +"PremiumBusinessQuickReplies" = "Quick Replies"; +"PremiumBusinessQuickRepliesDescription" = "Set up shortcuts with rich text and media to respond to messages faster."; +"PremiumLastSeenButton1" = "Show My Last Seen"; +"PremiumLastSeenButton2" = "Subscribe to Telegram Premium"; +"PremiumLastSeenHeader1" = "Show Your Last Seen"; +"PremiumLastSeenHeader2" = "Upgrade to Premium"; +"PremiumLastSeenSet" = "Your last seen time is now visible."; +"PremiumLastSeenText1" = "To see **%s** Last Seen time, either start showing your own Last Seen time..."; +"PremiumLastSeenText1Locked" = "To see **%s** Last Seen time, start showing your own Last Seen time."; +"PremiumLastSeenText2" = "Subscribing will let you see **%s’s** Last Seen status without showing yours."; +"PremiumMembers" = "Premium members"; +"PremiumMessageButton" = "Subscribe to Telegram Premium"; +"PremiumMessageHeader" = "Unlock Messaging"; +"PremiumMessageHeaderLocked" = "Restricted Messaging"; +"PremiumMessageText" = "**%1$s** restricted who can message them in Privacy Settings. Subscribe to **Telegram Premium** to message **%2$s**."; +"PremiumMessageTextLocked" = "**%1$s** only accepts messages from contacts and Premium users."; +"PremiumMore" = "More"; +"PremiumNeeded" = "Premium needed"; +"PremiumNeededForBoosting" = "Only **Telegram Premium** subscribers can boost channels. Do you want to subscribe to **Telegram Premium**?"; +"PremiumNeededForBoostingGroup" = "Only **Telegram Premium** subscribers can boost groups. Do you want to subscribe to **Telegram Premium**?"; +"PremiumOr" = "or"; +"PremiumOrStarsPrice" = "or ⭐️ %s"; +"PremiumPreviewAIEditor" = "AI Tools"; +"PremiumPreviewAIEditorDescription" = "Transform your messages and entire chats in your preferred style and language."; +"PremiumPreviewAdvancedChatManagement" = "Advanced Chat Management"; +"PremiumPreviewAdvancedChatManagementDescription" = "Tools to set the default folder, auto-archive and hide new chats from non-contacts."; +"PremiumPreviewAdvancedChatManagementDescription2" = "Subscribers of Telegram Premium can set a default folder, auto-archive and hide new chats from non-contacts."; +"PremiumPreviewAnimatedProfiles" = "Animated Profile Pictures"; +"PremiumPreviewAnimatedProfilesDescription" = "Video avatars animated in chat lists and chats to allow for additional self-expression."; +"PremiumPreviewAppIcon" = "Premium App Icons"; +"PremiumPreviewAppIcon2" = "Additional App Icons"; +"PremiumPreviewAppIconDescription" = "Choose from a selection of Telegram app icons for your homescreen."; +"PremiumPreviewAppIconDescription2" = "Unlock a wider range of app icons by subscribing to Telegram Premium."; +"PremiumPreviewBusinessDescription" = "Upgrade your account with business features such as location, opening hours and quick replies."; +"PremiumPreviewBusinessEmojiStatus" = "Set Emoji Status"; +"PremiumPreviewBusinessEmojiStatusDescription" = "Display the current status of your business next to your name."; +"PremiumPreviewBusinessStories" = "Add a Post"; +"PremiumPreviewBusinessStoriesDescription" = "Publish photos and videos of your goods or services on your page."; +"PremiumPreviewDownloadSpeed" = "Faster Download Speed"; +"PremiumPreviewDownloadSpeedDescription" = "No more limits on the speed with which media and documents are downloaded."; +"PremiumPreviewDownloadSpeedDescription2" = "Subscribe to Telegram Premium to download media and files at the fastest possible speed."; +"PremiumPreviewEffects" = "Message Effects"; +"PremiumPreviewEffectsDescription" = "Add over 500 animated effects to private messages."; +"PremiumPreviewEmoji" = "Animated Emoji"; +"PremiumPreviewEmojiDescription" = "Include animated emoji from different packs in any message you send."; +"PremiumPreviewEmojiPack" = "This emoji pack is available only to subscribers of **Telegram Premium**."; +"PremiumPreviewEmojiStatus" = "Emoji Statuses"; +"PremiumPreviewEmojiStatusDescription" = "Choose from thousands of emoji to display current activity next to your name."; +"PremiumPreviewFolderTags" = "Tag Your Chats"; +"PremiumPreviewFolderTagsDescription" = "Display folder names for each chat in the chat list."; +"PremiumPreviewLastSeen" = "Last Seen Times"; +"PremiumPreviewLastSeenDescription" = "View the last seen and read times of others even if you hide yours."; +"PremiumPreviewLimits" = "Doubled Limits"; +"PremiumPreviewLimitsDescription" = "Up to %1$d channels, %2$d folders, %3$d pins, %4$d public links, %5$d accounts and more."; +"PremiumPreviewMessagePrivacy" = "Message Privacy"; +"PremiumPreviewMessagePrivacyDescription" = "Limit messages from strangers or charge for incoming messages."; +"PremiumPreviewMoreBusinessFeatures" = "More business features"; +"PremiumPreviewMoreBusinessFeaturesInfo" = "Check this section later for new business features."; +"PremiumPreviewNoAds" = "No Ads"; +"PremiumPreviewNoAdsDescription" = "No more ads in public channels where Telegram sometimes shows ads."; +"PremiumPreviewNoAdsDescription2" = "Remove ads such as this one by subscribing to Telegram Premium."; +"PremiumPreviewPaidMessages" = "Paid Messages"; +"PremiumPreviewPaidMessagesDescription" = "Charge a fee for messages from non-contacts or new senders."; +"PremiumPreviewProfileBadge" = "Profile Badge"; +"PremiumPreviewProfileBadgeDescription" = "An exclusive badge next to your name showing that you subscribe to Telegram Premium."; +"PremiumPreviewProfileColor" = "Name and Profile Colors"; +"PremiumPreviewProfileColorDescription" = "Choose a color and logo for your profile and replies to your messages."; +"PremiumPreviewReactions" = "Unique Reactions"; +"PremiumPreviewReactions2" = "Infinite Reactions"; +"PremiumPreviewReactions2Description" = "React with thousands of emoji — using multiple reactions per message."; +"PremiumPreviewReactionsDescription" = "Additional animated reactions on messages, available only to Premium subscribers."; +"PremiumPreviewSharingDisable" = "Disable Sharing"; +"PremiumPreviewSharingDisableDescription" = "Prevent forwarding, saving and copying content in private chats."; +"PremiumPreviewStickers" = "Premium Stickers"; +"PremiumPreviewStickersDescription" = "Exclusive enlarged stickers featuring additional effects, updated regularly."; +"PremiumPreviewStories" = "Stories"; +"PremiumPreviewStoriesDescription" = "Unlimited posting, priority order, stealth mode, permanent view history and more."; +"PremiumPreviewTags" = "Tags for Messages"; +"PremiumPreviewTags2" = "Tags in Saved Messages"; +"PremiumPreviewTagsDescription" = "Organize your Saved Messages with tags\nfor quicker access."; +"PremiumPreviewTagsDescription2" = "Organize your Saved Messages with tags for quicker access."; +"PremiumPreviewTodo" = "Checklists"; +"PremiumPreviewTodoDescription" = "Plan, assign and complete tasks –\nseamlessly and efficiently."; +"PremiumPreviewTranslations" = "Real-Time Translation"; +"PremiumPreviewTranslationsDescription" = "Real-time translation of chats and channels into other languages."; +"PremiumPreviewUploads" = "Unlimited Cloud Storage"; +"PremiumPreviewUploadsDescription" = "4 GB per each document, unlimited storage for your chats and media overall."; +"PremiumPreviewVoiceToText" = "Voice-to-Text Conversion"; +"PremiumPreviewVoiceToTextDescription" = "Ability to read the transcript of any incoming voice message."; +"PremiumPreviewVoiceToTextDescription2" = "Subscribe to Telegram Premium to be able to convert voice and video messages to text."; +"PremiumPreviewWallpaper" = "Wallpaper for Both Sides"; +"PremiumPreviewWallpaperDescription" = "Set custom wallpapers for you and your chat partner."; +"PremiumReadButton1" = "Show My Read Time"; +"PremiumReadButton2" = "Subscribe to Telegram Premium"; +"PremiumReadHeader1" = "Show Your Read Date"; +"PremiumReadHeader2" = "Upgrade to Premium"; +"PremiumReadSet" = "Your read times are now visible."; +"PremiumReadText1" = "To see when **%s** read the message, either start showing your own read time..."; +"PremiumReadText1Locked" = "To see when **%s** read the message, start showing your own read time."; +"PremiumReadText2" = "Subscribing will let you see **%s’s** read time without showing yours."; +"PremiumSpeedPromo" = "Increase Speed"; +"PremiumStickerToolteip" = "This pack contains premium stickers like this one."; +"PremiumStickerTooltip" = "This set contains premium stickers like this one."; +"PremiumStickers" = "Premium Stickers"; +"PremiumStickersShort" = "Premium"; +"PremiumStoriesCaption" = "Longer Captions"; +"PremiumStoriesCaptionDescription" = "Add 10x longer captions to your stories — up to 2048 characters."; +"PremiumStoriesExpiration" = "Expiration Options"; +"PremiumStoriesExpirationDescription" = "Set custom durations like 6 or 48 hours for your stories."; +"PremiumStoriesFormatting" = "Links and Formatting"; +"PremiumStoriesFormattingDescription" = "Add links and formatting to your story captions."; +"PremiumStoriesPriority" = "Priority Order"; +"PremiumStoriesPriorityDescription" = "Get more views as your stories are always displayed first."; +"PremiumStoriesQuality" = "Higher quality"; +"PremiumStoriesQualityDescription" = "View video stories in double the resolution."; +"PremiumStoriesSaveToGallery" = "Save Stories to Gallery"; +"PremiumStoriesSaveToGalleryDescription" = "Save other people's stories that are not protected."; +"PremiumStoriesStealth" = "Stealth Mode"; +"PremiumStoriesStealthDescription" = "Hide the fact that you viewed other people's stories."; +"PremiumStoriesViews" = "Permanent View History"; +"PremiumStoriesViewsDescription" = "Check who opens your stories — even after they expire."; +"PremiumSubscribers" = "Premium subscribers"; +"PremiumTierAnnual" = "Annual"; +"PremiumTierAnnualYears#one" = "%d-Year"; +"PremiumTierAnnualYears#other" = "%d-Year"; +"PremiumTierMonthly" = "Monthly"; +"PremiumTierSemiannual" = "Semiannual"; +"PrepaidGiveawayMonths#one" = "%1$d-month"; +"PrepaidGiveawayMonths#other" = "%1$d-month"; +"PreparingSticker" = "Preparing sticker…"; +"PreparingVideo" = "Preparing video…"; +"PreviewFeedback" = "Report a bug with this preview"; +"PreviewFeedback2" = "Wrong layout?"; +"PreviewFeedbackAuto" = "Automatically generated layout"; +"PreviewForwardAudio#one" = "Forward voice message"; +"PreviewForwardAudio#other" = "Forward %1$d voice messages"; +"PreviewForwardContact#one" = "Forward contact"; +"PreviewForwardContact#other" = "Forward %1$d contacts"; +"PreviewForwardFile#one" = "Forward file"; +"PreviewForwardFile#other" = "Forward %1$d files"; +"PreviewForwardLocation#one" = "Forward location"; +"PreviewForwardLocation#other" = "Forward %1$d locations"; +"PreviewForwardMessagesCount#one" = "Forward message"; +"PreviewForwardMessagesCount#other" = "Forward %1$d messages"; +"PreviewForwardMusic#one" = "Forward audio file"; +"PreviewForwardMusic#other" = "Forward %1$d audio files"; +"PreviewForwardPhoto#one" = "Forward photo"; +"PreviewForwardPhoto#other" = "Forward %1$d photos"; +"PreviewForwardPoll#one" = "Forward poll"; +"PreviewForwardPoll#other" = "Forward %1$d polls"; +"PreviewForwardRound#one" = "Forward video message"; +"PreviewForwardRound#other" = "Forward %1$d video messages"; +"PreviewForwardSticker#one" = "Forward sticker"; +"PreviewForwardSticker#other" = "Forward %1$d stickers"; +"PreviewForwardTodo#one" = "Forward Checklist"; +"PreviewForwardTodo#other" = "Forward %1$d Checklists"; +"PreviewForwardVideo#one" = "Forward video"; +"PreviewForwardVideo#other" = "Forward %1$d videos"; +"PreviewQuoteUpdate" = "Update Quote"; +"PreviewQuoteUpdateSubtitle" = "You can edit or remove the quote"; +"PreviewWallpaperDay" = "Preview this wallpaper in day mode."; +"PreviewWallpaperNight" = "Preview this wallpaper in night mode."; +"PreviousPinnedMessage" = "Previous Message"; +"PriceForEachSuggestion" = "Price per message"; +"PricePerMonth" = "%1$s / month"; +"PricePerMonthMe" = "%1$s/month"; +"PricePerYear" = "%1$s/year"; +"PrimaryLinkHelp" = "Anyone on Telegram will be able to join your group by following this link."; +"PrimaryLinkHelpChannel" = "Anyone on Telegram will be able to join your channel by following this link."; +"PriorityInfo" = "Higher priority notifications will work even in Do Not Disturb mode."; +"PrivacyAddAnException" = "Add Users or Groups"; +"PrivacyAdvanced" = "Advanced"; +"PrivacyBio" = "Bio"; +"PrivacyBioInfo" = "You can restrict who can see the bio on your profile with granular precision."; +"PrivacyBioInfo3" = "You can add users or entire groups that will not see your bio."; +"PrivacyBioTitle" = "Who can see my bio?"; +"PrivacyBiometryBots" = "Bots with biometry"; +"PrivacyBiometryBotsButton" = "Bots with biometry access"; +"PrivacyBiometryBotsInfo" = "Web apps can request permission for fingerprint check."; +"PrivacyBirthday" = "Birthday"; +"PrivacyBirthdayInfo" = "You can restrict who can see your birthday with granular precision."; +"PrivacyBirthdayInfo3" = "You can add users or entire groups as exception that will override the settings above."; +"PrivacyBirthdaySet" = "You haven’t added your birthday yet.\n**Add my birthday >**"; +"PrivacyBirthdaySetDone" = "Birthday added."; +"PrivacyBirthdaySetDoneInfo" = "You can change your birthday in Settings."; +"PrivacyBirthdayTitle" = "Who can see my birthday?"; +"PrivacyBirthdayTooOftenMessage" = "Sorry, you can’t change your birthday so often."; +"PrivacyBirthdayTooOftenTitle" = "Oops"; +"PrivacyBotUsersAnd" = "Mini Apps & %s"; +"PrivacyBots" = "Bots and websites"; +"PrivacyBotsInfo" = "Websites where you've used Telegram to log in."; +"PrivacyCallsP2PHelp" = "Disabling peer-to-peer will relay all calls through Telegram servers to avoid revealing your IP address, but may decrease audio and video quality."; +"PrivacyCallsP2PTitle" = "Peer-to-Peer"; +"PrivacyClearPayment" = "Payment Info"; +"PrivacyClearShipping" = "Shipping info"; +"PrivacyContactsAndBotUsers" = "Contacts & Mini Apps"; +"PrivacyContactsAndBotUsersMinus" = "Contacts & Mini Apps (-%1$d)"; +"PrivacyContactsAndBotUsersMinusPlus" = "Contacts & Mini Apps (-%1$d, +%2$d)"; +"PrivacyContactsAndBotUsersPlus" = "Contacts & Mini Apps (+%1$d)"; +"PrivacyDeleteCloudDrafts" = "Delete All Cloud Drafts"; +"PrivacyExceptions" = "Exceptions"; +"PrivacyExcludeBotUsers" = "Mini Apps"; +"PrivacyFloodControlError" = "Too many requests. Please wait before changing privacy settings again."; +"PrivacyForwards" = "Forwarded Messages"; +"PrivacyForwardsContacts" = "Link if allowed by the settings below"; +"PrivacyForwardsEverybody" = "Link to your account"; +"PrivacyForwardsInfo" = "Messages you send will not link back to your account when forwarded by other users."; +"PrivacyForwardsInfo2" = "You can add users or entire groups as exceptions that will override the settings above."; +"PrivacyForwardsMessageLine" = "Reinhardt, we need to find you some new tunes 🎶."; +"PrivacyForwardsNobody" = "Not a link to your account"; +"PrivacyForwardsTitle" = "Who can add a link to my account when forwarding my messages?"; +"PrivacyGifts" = "Gifts"; +"PrivacyGiftsInfo" = "Choose whether gifts from specific senders need your approval before they’re visible to others on your profile."; +"PrivacyGiftsShowIcon" = "Show Gift Icon in Chats"; +"PrivacyGiftsShowIconInfo" = "Display the %s Gift icon in the message input field for both participants in all chats."; +"PrivacyGiftsTitle" = "Automatically display gifts from"; +"PrivacyGiftsTypeFromChannels" = "From Channels"; +"PrivacyGiftsTypeHeader" = "Accepted gift types"; +"PrivacyGiftsTypeInfo" = "Choose the types of gifts that you allow others to send you."; +"PrivacyGiftsTypeLimited" = "Limited-Edition"; +"PrivacyGiftsTypePremium" = "Premium Subscriptions"; +"PrivacyGiftsTypeUnique" = "Unique"; +"PrivacyGiftsTypeUnlimited" = "Unlimited"; +"PrivacyInvites" = "Invites"; +"PrivacyInvitesInfo" = "You can restrict which users are allowed to add you to groups and channels."; +"PrivacyLastSeen" = "Last Seen & Online"; +"PrivacyLastSeenPremium" = "Subscribe to Telegram Premium"; +"PrivacyLastSeenPremiumForPremium" = "Subscribed to Telegram Premium"; +"PrivacyLastSeenPremiumInfo" = "If you subscribe to Premium, you will see other users’ last seen and read time even if you hid yours from them (unless they specifically restricted it)."; +"PrivacyLastSeenPremiumInfoForPremium" = "Because you are a Telegram Premium subscriber, you will see the last seen and read time of all users who are sharing it with you – even if you are hiding yours."; +"PrivacyMessages" = "Messages"; +"PrivacyMessagesContactsAndPremium" = "My Contacts and Premium users"; +"PrivacyMessagesInfo" = "You can restrict messages from users who are not in your contacts and whom you haven't messaged first.\n\n**What is Telegram Premium?**"; +"PrivacyMessagesTitle" = "Who can send me messages?"; +"PrivacyMiniapps" = "Mini Apps & Bots"; +"PrivacyMiniappsText" = "all bots"; +"PrivacyMusic" = "Saved Music"; +"PrivacyMusicInfo" = "You can restrict who can see your saved music with granular precision."; +"PrivacyMusicInfo3" = "You can add users or entire groups that will not see your saved music."; +"PrivacyMusicTitle" = "Who can see the saved music on my profile?"; +"PrivacyP2P" = "Peer-to-Peer Calls"; +"PrivacyP2P2" = "Use Peer-to-Peer with"; +"PrivacyP2PHeader" = "Peer-to-Peer"; +"PrivacyPayments" = "Payments"; +"PrivacyPaymentsClear" = "Clear Payment and Shipping Info"; +"PrivacyPaymentsClearAlert" = "Delete your shipping info and instruct all payment providers to remove your saved credit cards? Note that Telegram never stores your credit card data."; +"PrivacyPaymentsClearAlertText" = "Are you sure you want to clear your payment and shipping info?"; +"PrivacyPaymentsClearAlertTitle" = "Clear payment info"; +"PrivacyPaymentsClearInfo" = "You can delete your shipping info and instruct all payment providers to remove your saved credit cards. Note that Telegram never stores your credit card data."; +"PrivacyPaymentsPaymentInfoCleared" = "Payment info cleared."; +"PrivacyPaymentsPaymentShippingCleared" = "Payment and shipping info cleared."; +"PrivacyPaymentsShippingInfoCleared" = "Shipping info cleared."; +"PrivacyPhone" = "Phone Number"; +"PrivacyPhoneInfo" = "Users who have your number saved in their contacts will also see it on Telegram."; +"PrivacyPhoneInfo2" = "You can add users or entire groups as exceptions that will override the settings above."; +"PrivacyPhoneInfo3" = "Users who add your number to their contacts will see it on Telegram only if they are your contacts."; +"PrivacyPhoneInfo4" = "This public link opens a chat with you:"; +"PrivacyPhoneTitle" = "Who can see my phone number?"; +"PrivacyPhoneTitle2" = "Who can find me by my number?"; +"PrivacyPolicy" = "Privacy Policy"; +"PrivacyPolicyAndTerms" = "Privacy Policy and Terms of Service"; +"PrivacyPolicyUrl" = "https://telegram.org/privacy"; +"PrivacyPremium" = "Premium Users"; +"PrivacyPremiumAnd" = "Premium & %s"; +"PrivacyPremiumText" = "all Telegram Premium subscribers"; +"PrivacyProfilePhoto" = "Profile Photos"; +"PrivacyProfilePhotoInfo" = "You can restrict who can see your profile photo with granular precision."; +"PrivacyProfilePhotoInfo2" = "You can add users or entire groups as exceptions that will override the settings above."; +"PrivacyProfilePhotoInfo3" = "You can add users or entire groups that will **not** see your profile photo."; +"PrivacyProfilePhotoInfo4" = "Add users or entire groups that will still see your profile photo."; +"PrivacyProfilePhotoInfo5" = "Add users or entire groups as exceptions that will override the settings above."; +"PrivacyProfilePhotoTitle" = "Who can see my profile photos?"; +"PrivacySettings" = "Privacy and Security"; +"PrivacySettingsChangedAlert" = "You have changed some privacy settings. Apply changes?"; +"PrivacyTitle" = "Privacy"; +"PrivacyUserTypes" = "User Types"; +"PrivacyValueBots" = "Mini Apps"; +"PrivacyValueBotsAnd" = "Mini Apps & %s"; +"PrivacyValueChannels" = "Channels"; +"PrivacyValueChannelsAnd" = "Channels & %s"; +"PrivacyValueEveryoneExceptBots" = "Except Mini Apps"; +"PrivacyValueEveryoneExceptBotsMinus" = "Except Mini Apps (-%1$d)"; +"PrivacyValueGiftsNone" = "None"; +"PrivacyValueGiftsOnlyPremium" = "Only Premium"; +"PrivacyValueOnlyBots" = "Mini Apps"; +"PrivacyVoiceMessages" = "Voice Messages"; +"PrivacyVoiceMessagesInfo" = "You can restrict who can send you voice or video messages with granular precision."; +"PrivacyVoiceMessagesInfo2" = "You can add users or entire groups as exceptions that will override the settings above."; +"PrivacyVoiceMessagesPremiumOnly" = "Only subscribers of *Telegram Premium* can restrict receiving voice messages."; +"PrivacyVoiceMessagesTitle" = "Who can send me voice or video messages?"; +"PrivateChats" = "Private Chats"; +"PrivateMessagesChargePremiumLocked" = "Unlock with Telegram Premium"; +"PrivateMessagesChargePrice" = "Charge for Messages"; +"PrivateMessagesChargePriceInfo" = "Charge a fee for messages from people outside your contacts or who you haven't messaged first."; +"PrivateMessagesExceptions" = "Remove Fee"; +"PrivateMessagesExceptionsHeader" = "Exceptions"; +"PrivateMessagesExceptionsInfo" = "Add users or entire groups who won't have to pay to send you messages."; +"PrivateMessagesPriceHeader" = "Set your price per message"; +"PrivateMessagesPriceInfo" = "You will receive %1$s of the selected fee (~%2$s$) for each incoming message."; +"PrivateShares" = "Private Shares"; +"Proceed" = "Proceed"; +"ProfileActionsAddStory" = "Add Story"; +"ProfileActionsCall" = "Call"; +"ProfileActionsDiscuss" = "Discuss"; +"ProfileActionsEditInfo" = "Edit Info"; +"ProfileActionsEditPhoto" = "Set Profile"; +"ProfileActionsEditPhoto2" = "Set Photo"; +"ProfileActionsEditUsername" = "Username"; +"ProfileActionsGift" = "Gift"; +"ProfileActionsJoin" = "Join"; +"ProfileActionsLeave" = "Leave"; +"ProfileActionsLiveStream" = "Live Stream"; +"ProfileActionsMessage" = "Message"; +"ProfileActionsReport" = "Report"; +"ProfileActionsShare" = "Share"; +"ProfileActionsStop" = "Stop"; +"ProfileActionsVideo" = "Video"; +"ProfileActionsVoiceChat" = "Video Chat"; +"ProfileArchivedStories" = "Archived Posts"; +"ProfileBirthday" = "Birthday"; +"ProfileBirthdayChange" = "Change"; +"ProfileBirthdayToday" = "Birthday today"; +"ProfileBirthdayTodayValue" = "🎂 %1$s"; +"ProfileBirthdayTodayValueYear#other" = "🎂 %2$s (%1$d years old)"; +"ProfileBirthdayValue" = "%1$s"; +"ProfileBirthdayValueYear#other" = "%2$s (%1$d years old)"; +"ProfileBotAddPreview" = "Add Preview"; +"ProfileBotAffiliateProgram" = "Affiliate Program"; +"ProfileBotAffiliateProgramInfo" = "Share a link to %1$s with your friends and earn %2$s of their spending there."; +"ProfileBotAffiliateProgramInfoOwner" = "Share a link to %1$s with other users and earn %2$s of their spending there."; +"ProfileBotLanguageAdd" = "Add Translation"; +"ProfileBotLanguageGeneral" = "General"; +"ProfileBotManagedBy" = "Created and managed by **@%s**"; +"ProfileBotOpenApp" = "Open App"; +"ProfileBotOpenAppInfo" = "By launching this mini app, you agree to the **Terms of Service for Mini Apps**."; +"ProfileBotOpenAppInfoLink" = "https://telegram.org/tos/mini-apps"; +"ProfileBotOpenAppInfoOwner" = "By publishing this mini app, you agree to the **Telegram Terms of Service for Developers**."; +"ProfileBotOpenAppInfoOwnerLink" = "https://telegram.org/tos/mini-apps"; +"ProfileBotOr" = "or"; +"ProfileBotPreviewEmptyButton" = "Add preview"; +"ProfileBotPreviewEmptyText#one" = "Upload up to %d screenshot and video demo for your mini app."; +"ProfileBotPreviewEmptyText#other" = "Upload up to %d screenshots and video demos for your mini app."; +"ProfileBotPreviewEmptyTitle" = "No Preview"; +"ProfileBotPreviewFooterCreateTranslation" = "Create a Translation"; +"ProfileBotPreviewFooterDeleteTranslation" = "Delete this Translation"; +"ProfileBotPreviewFooterGeneral" = "This preview will be displayed for all users except those for whom you provide a translated version."; +"ProfileBotPreviewFooterLanguage" = "This preview will be displayed for all users who have %s set as their language."; +"ProfileBotPreviewLanguageChoose" = "Add a Translation"; +"ProfileBotPreviewTab" = "Preview"; +"ProfileBotRemoveLang" = "Delete %s"; +"ProfileBotReorder" = "Reorder"; +"ProfileBotSelect" = "Select"; +"ProfileBotUnSelect" = "Unselect"; +"ProfileButtonMute" = "Mute"; +"ProfileButtonUnmute" = "Unmute"; +"ProfileChannel" = "Channel"; +"ProfileChannelChange" = "Change Channel"; +"ProfileChannelClearMessage" = "Are you sure you want to remove the channel from your profile?"; +"ProfileChannelClearTitle" = "Remove channel"; +"ProfileChannelCopy" = "Copy Link"; +"ProfileColorEdit" = "Change profile color"; +"ProfileCopyLink" = "Copy link to profile"; +"ProfileCopyUsername" = "Copy username"; +"ProfileEditBio" = "Edit bio"; +"ProfileGifts" = "Gifts"; +"ProfileGiftsAdd" = "Add Gifts"; +"ProfileGiftsCount#one" = "%d gift"; +"ProfileGiftsCount#other" = "%d gifts"; +"ProfileGiftsInfo" = "Tap on a gift to convert it to Stars or change its privacy settings."; +"ProfileGiftsNotFoundButton" = "View All Gifts"; +"ProfileGiftsNotFoundTitle" = "No matching gifts"; +"ProfileGiftsSend" = "Send Gifts to Friends"; +"ProfileGiftsSendChannel" = "Send Gifts to Channel"; +"ProfileGiftsSendUser" = "Send a Gift to %s"; +"ProfileHoursCopy" = "Copy hours"; +"ProfileHoursEdit" = "Edit hours"; +"ProfileHoursRemove" = "Remove"; +"ProfileJoinChannel" = "Join Channel"; +"ProfileJoinGroup" = "Join Group"; +"ProfileLevelInfo" = "Profile level reflects the\nuser's payment reliability."; +"ProfileLocationCopy" = "Copy address"; +"ProfileLocationEdit" = "Change location"; +"ProfileLocationMaps" = "Open in Maps"; +"ProfileLocationRemove" = "Remove"; +"ProfileLocationView" = "View location"; +"ProfileMyStories" = "My Stories"; +"ProfileMyStoriesCount#one" = "%d posted story"; +"ProfileMyStoriesCount#other" = "%d posted stories"; +"ProfileMyStoriesTab" = "Posted"; +"ProfileNotes" = "Notes"; +"ProfileNotesInfo" = "only visible to you"; +"ProfileNotesRemoveText" = "Are you sure you want to remove the note?"; +"ProfileNotesRemoveTitle" = "Delete Note"; +"ProfilePhoneEdit" = "Replace number"; +"ProfilePlaylistRemoveFromProfile" = "Remove from Profile"; +"ProfilePlaylistTitle" = "%s's Playlist"; +"ProfilePlaylistTitleMine" = "Your Playlist"; +"ProfilePollsCount#one" = "%d poll"; +"ProfilePollsCount#other" = "%d polls"; +"ProfilePopupNotification" = "Popup notifications"; +"ProfilePopupNotificationInfo" = "New messages from this contact will appear on your screen when you are not using Telegram."; +"ProfileSendAGift" = "Send a Gift"; +"ProfileSendAGiftToChannel" = "Send a Gift"; +"ProfileStories" = "Posts"; +"ProfileStoriesArchive" = "Story Archive"; +"ProfileStoriesArchiveChannelHint" = "Only admins of the channel can see archived stories unless they are posted to the channel page."; +"ProfileStoriesArchiveCount#one" = "%d archived story"; +"ProfileStoriesArchiveCount#other" = "%d archived stories"; +"ProfileStoriesArchiveGroupHint" = "Only admins of the group can see archived stories unless they are posted to the group page."; +"ProfileStoriesArchiveHint" = "Only you can see archived stories unless you choose to post them to your profile."; +"ProfileStoriesArchiveTab" = "Archive"; +"ProfileStoriesCount#one" = "%d story"; +"ProfileStoriesCount#other" = "%d stories"; +"ProfileStoriesCountZero" = "no stories"; +"ProfileTabSetAsMain" = "Set as Main Tab"; +"ProfileUnofficialSecurityRisk" = "%s uses an unofficial Telegram client — messages to this user may be less secure."; +"ProfileUsernameEdit" = "Change username"; +"ProfileUsernameSet" = "Set username"; +"PromoteUrl" = "https://ads.telegram.org"; +"ProximityAlertCancelled" = "Proximity alert canceled"; +"ProximityAlertSet" = "Proximity alert set"; +"ProximityAlertSetInfoGroup2" = "We will notify you once someone is within %1$s from you."; +"ProximityAlertSetInfoUser" = "We will notify you once %1$s is within %2$s from you."; +"ProximityTooltioGroup" = "Alert when other members of the group are close."; +"ProximityTooltioUser" = "Alert when %1$s is close"; +"Proxy" = "Proxy"; +"ProxyAddedSuccess" = "Proxy added."; +"ProxyBottomSheetCheckStatus" = "**Check Status**"; +"ProxyBottomSheetCheckWarning" = "Warning"; +"ProxyBottomSheetCheckWarningText" = "This will expose your IP address to the admin of the proxy server."; +"ProxyBottomSheetChecking" = "Checking"; +"ProxyConnections" = "Connections"; +"ProxyDetails" = "Proxy Details"; +"ProxyRotationTimeoutInfo" = "You can choose how quickly the app should auto-connect to the nearest active proxy if the current one stops working."; +"ProxyRotationTimeoutSeconds" = "%1$d s"; +"ProxySettings" = "Proxy Settings"; +"ProxyStatus" = "Status"; +"PsaHide" = "Hide"; +"PsaHideChatAlertText" = "Are you sure you want to hide this chat?"; +"PsaHideChatAlertTitle" = "Hide chat"; +"PsaInfoDefault" = "This message provides you with a public service announcement."; +"PsaInfo_covid" = "This message provides you with a public service announcement in relation to the ongoing COVID-19 pandemic. Learn more about this initiative at https://telegram.org/blog/coronavirus"; +"PsaInfo_psa" = "This is an official message from the CEO of Telegram regarding the ongoing conflict."; +"PsaMessageDefault" = "Important Notification"; +"PsaMessageInfoDefault" = "This is an important notification."; +"PsaMessageInfo_covid" = "This message provides you with a public service announcement in relation to the ongoing COVID-19 pandemic. Learn more about this initiative at https://telegram.org/blog/coronavirus"; +"PsaMessageInfo_psa" = "This is an official message from the CEO of Telegram regarding the ongoing conflict."; +"PsaMessage_covid" = "COVID-19 Notification"; +"PsaMessage_psa" = "Announcement"; +"PsaTypeDefault" = "PSA"; +"PsaType_covid" = "COVID-19"; +"PsaType_psa" = "Announcement"; +"PublicChannelsTooMuch" = "Sorry, the target user has too many public groups or channels already. Please ask them to make one of their existing groups or channels private first."; +"PublicLink" = "Public link"; +"PublicLinksLimitSubtitle" = "Reserve up to %d t.me/username links"; +"PublicLinksLimitTitle" = "Public Links"; +"PublicPhoto" = "public photo"; +"PublicPoll" = "Poll"; +"PublicPollCantForward" = "Sorry, polls with visible votes can’t be forwarded to channels."; +"PublicPostsMore" = "Show more \\>"; +"PublicPostsSection" = "Public posts"; +"PublicPostsTabs" = "Public Posts"; +"PublicShares" = "Public Shares"; +"PublicSharesCount#one" = "%1$s public share"; +"PublicSharesCount#other" = "%1$s public shares"; +"PublishDayAt" = "'Publish on' MMM d 'at' HH:mm"; +"PublishDayYearAt" = "'Publish on' MMM d yyyy 'at' HH:mm"; +"PublishTodayAt" = "'Publish today at' HH:mm"; +"Purple" = "Purple"; +"PushChatReactAudio" = "%3$s to your voice message"; +"PushChatReactContact" = "%3$s to your contact %4$s"; +"PushChatReactDoc" = "%3$s to your file"; +"PushChatReactGame" = "%3$s to your game"; +"PushChatReactGeo" = "%3$s to your map"; +"PushChatReactGeoLive" = "%3$s to your live location"; +"PushChatReactGif" = "%3$s to your GIF"; +"PushChatReactInvoice" = "%3$s to your invoice"; +"PushChatReactNotext" = "%3$s to your message"; +"PushChatReactPhoto" = "%3$s to your photo"; +"PushChatReactPoll" = "%3$s to your poll %4$s"; +"PushChatReactQuiz" = "%3$s to your quiz %4$s"; +"PushChatReactRound" = "%3$s to your video message"; +"PushChatReactSticker" = "%3$s to your %4$s sticker"; +"PushChatReactText" = "%3$s to your \"%4$s\""; +"PushChatReactTodo" = "%3$s to your checklist %4$s in %2$s"; +"PushChatReactVideo" = "%3$s to your video"; +"PushReactAudio" = "%2$s to your voice message"; +"PushReactContect" = "%2$s to your contact %3$s"; +"PushReactDoc" = "%2$s to your file"; +"PushReactGame" = "%2$s to your game"; +"PushReactGeo" = "%2$s to your map"; +"PushReactGeoLocation" = "%2$s to your live location"; +"PushReactGif" = "%2$s to your GIF"; +"PushReactHidden" = "New reaction to your message"; +"PushReactInvoice" = "%2$s to your invoice"; +"PushReactNoText" = "%2$s to your message"; +"PushReactPhoto" = "%2$s to your photo"; +"PushReactPoll" = "%2$s to your poll %3$s"; +"PushReactQuiz" = "%2$s to your quiz %3$s"; +"PushReactRound" = "%2$s to your video message"; +"PushReactSticker" = "%2$s to your %3$s sticker"; +"PushReactStory" = "%2$s to your story"; +"PushReactStoryHidden" = "New reaction to your story"; +"PushReactStoryHiddenSender" = "Story Reaction"; +"PushReactText" = "%2$s to your \"%3$s\""; +"PushReactTodo" = "%2$s to your checklist %3$s"; +"PushReactVideo" = "%2$s to your video"; +"QRCodeLinkGroupCall" = "Everyone on Telegram can scan this code to join your group call."; +"QRCodeLinkHelpChannel" = "Everyone on Telegram can scan this code to join your channel."; +"QRCodeLinkHelpChannelDirect" = "Everyone on Telegram can scan this code to write to your channel."; +"QRCodeLinkHelpFolder" = "Anyone on Telegram can scan this code to add the folder and join all the chats included in its invite link."; +"QRCodeLinkHelpGroup" = "Everyone on Telegram can scan this code to join your group."; +"QRCodeLinkHelpProxy" = "Your friends can add this proxy by scanning the code with their phone's camera."; +"QRCodePermissionNoCamera" = "Telegram needs access to your camera so that you can scan QR codes."; +"QRCodePermissionNoCameraWithHint" = "**Telegram** needs camera access so that you can scan QR codes. Tap Settings > Permissions, and turn **Camera** on."; +"QrCode" = "QR Code"; +"Quality" = "Quality"; +"Quality1080" = "High"; +"Quality1080Short" = "HD"; +"Quality144" = "Low"; +"Quality1440" = "Ultra High"; +"Quality1440Short" = "2K"; +"Quality2160" = "Ultra High"; +"Quality2160Short" = "4K"; +"Quality240" = "Low"; +"Quality360" = "Low"; +"Quality480" = "Low"; +"Quality720" = "Medium"; +"Quality720Short" = "HD"; +"QualityAuto" = "Auto"; +"QualityCached" = "・cached"; +"QualityList" = "Video Quality"; +"QualityOriginal" = "Original"; +"QualitySaveIn" = "Save in %dp"; +"QualitySource" = "Source"; +"Question" = "Question"; +"QuestionDescriptionHint" = "Add Description (optional)"; +"QuestionHint" = "Ask a question"; +"QuickReplyCustom" = "Custom Message..."; +"QuickReplyDefault1" = "Can't talk now. What's up?"; +"QuickReplyDefault2" = "I'll call you right back."; +"QuickReplyDefault3" = "I'll call you later."; +"QuickReplyDefault4" = "Can't talk now. Call me later?"; +"QuizAnswers" = "Quiz answers"; +"QuizExplanationTitle" = "Explanation"; +"QuizInfo" = "Polls in Quiz Mode have one correct answer. Users can't revoke their answers."; +"QuizPoll" = "Quiz"; +"QuizWellDone" = "Well done!"; +"QuizWellDoneInfo" = "You chose the right answer."; +"QuizWrongAnswer" = "Wrong answer!"; +"QuizWrongAnswerInfo" = "You missed the correct option."; +"Quote" = "Quote"; +"QuoteCollapse" = "Collapse"; +"QuoteExpand" = "Expand"; +"QuoteMaxError" = "Quote too long!"; +"QuoteMaxErrorMessage" = "The selected text is too long to quote."; +"QuoteNotFound" = "Original quote was not found"; +"QuoteOutdated" = "Outdated Quote"; +"QuotePrivate" = "This quote is from a private chat"; +"QuotePrivateChannel" = "This quote is from a private channel"; +"QuotePrivateGroup" = "This quote is from a private group"; +"QuoteSelectedPart" = "Quote Selected Part"; +"QuoteTo" = "Quote to..."; +"QuoteToAnotherChat" = "Reply in Another Chat"; +"RaiseToListen" = "Raise to Listen"; +"RaiseToListenInfo" = "Switch sound to the earpiece by raising the phone to your ear"; +"RaiseToSpeak" = "Raise to Speak"; +"RaiseToSpeakInfo" = "Record voice messages by raising the phone to your ear"; +"RateCallDistorted" = "Speech was distorted"; +"RateCallDropped" = "Call ended unexpectedly"; +"RateCallEcho" = "I heard my own voice"; +"RateCallInterruptions" = "The other side kept disappearing"; +"RateCallNoise" = "I heard background noise"; +"RateCallSilentLocal" = "I couldn't hear the other side"; +"RateCallSilentRemote" = "The other side couldn't hear me"; +"RateCallVideoDistorted" = "Video was distorted"; +"RateCallVideoPixelated" = "Video was pixelated"; +"RateTranscription" = "Rate transcription"; +"ReEnterPassword" = "Re-enter password"; +"ReEnterYourPasscode" = "Re-enter your new passcode"; +"Reacted#one" = "%1$s Reacted"; +"Reacted#other" = "%1$s Reacted"; +"ReactionAddEmojiFromAnyPack" = "You can add emoji from any emoji pack as reactions."; +"ReactionAddReactionsHint" = "Add Reactions..."; +"ReactionApplyChangesDialog" = "You have changed the list of reactions. Apply changes?"; +"ReactionCreateOwnPack" = "You can also **create your own** emoji packs and use them."; +"ReactionCustomReactions" = "Custom Reactions"; +"ReactionDeleteSent" = "Reaction deleted"; +"ReactionInDialog" = "Reacted with %s to your message"; +"ReactionLevelRequiredBtn#one" = "Level %1$d Required"; +"ReactionLevelRequiredBtn#other" = "Level %1$d Required"; +"ReactionLongTapHint" = "Long tap for more reactions."; +"ReactionMaxCountError#one" = "You can select %1$d reaction."; +"ReactionMaxCountError#other" = "You can select up to %1$d reactions."; +"ReactionReachLvlForReaction#one" = "Your channel needs to reach Level **%2$d** to add **%1$d** custom emoji as reactions.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ReactionReachLvlForReaction#other" = "Your channel needs to reach Level **%2$d** to add **%1$d** custom emoji as reactions.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ReactionReachLvlForReactionShort#one" = "Your channel needs to reach Level **%2$d** to add **%1$d** custom emoji as reactions."; +"ReactionReachLvlForReactionShort#other" = "Your channel needs to reach Level **%2$d** to add **%1$d** custom emoji as reactions."; +"ReactionSent" = "Message sent."; +"ReactionUpdateReactionsBtn" = "Update Reactions"; +"Reactions" = "Reactions"; +"ReactionsAll" = "All"; +"ReactionsByEmotionChartTitle" = "Reactions"; +"ReactionsCount#one" = "%1$d reaction"; +"ReactionsCount#other" = "%1$d reactions"; +"ReactionsDeleted" = "Reactions Deleted"; +"ReactionsLongtapHint" = "Hold emoji to send a super reaction"; +"ReactionsOff" = "Off"; +"ReactionsPerPost" = "Reactions Per Post"; +"ReactionsPerStory" = "Reactions Per Story"; +"ReadAllMentions" = "Read all mentions"; +"ReadAllPollVotes" = "Read all votes"; +"ReadAllReactions" = "Read all reactions"; +"ReadMore" = "Read More"; +"Recent" = "Recent"; +"RecentCalls" = "Recent calls"; +"RecentFiles" = "Recent files"; +"RecentFilesAZ" = "Files A-Z"; +"RecentPosts" = "Recent posts"; +"RecentPostsCapitalize" = "Recent Posts"; +"RecentStickers" = "Recently Used"; +"RecentStickersShort" = "Recent"; +"RecentlyDownloaded" = "Recently downloaded"; +"RecentlyUsed" = "Recently Used"; +"RecentlyViewed" = "Recently viewed"; +"RecentlyViewedHide" = "HIDE"; +"Recipient#one" = "%1$d recipient"; +"Recipient#other" = "%1$d recipients"; +"RecorderEditCover" = "Story Cover"; +"RecorderEditStory" = "Edit"; +"RecorderNewStory" = "New Story"; +"RecorderRepost" = "Repost"; +"RecordingAudio" = "recording voice..."; +"RecordingRound" = "recording video..."; +"RecordingTrimText" = "Audio outside that range will be discarded, and recording will start immediately."; +"RecordingTrimTitle" = "Trim to selected range?"; +"RecordingVideoStatus" = "recording a video..."; +"RecoveryEmail" = "Recovery email"; +"RecoveryEmailSubtitle" = "You can set a recovery email to be able to reset your password and restore access to your Telegram account."; +"RecoveryEmailTitle" = "Recovery Email"; +"Red" = "Red"; +"Reduce" = "Reduce"; +"Refresh" = "Refresh"; +"RegisterText" = "Enter your first and last name"; +"RegisterText2" = "Enter your name and add a profile photo."; +"RegistrationProfileInfo" = "Profile info"; +"Regular" = "Regular"; +"RelativeDateAddDate" = "Add Date"; +"RelativeDateCopied" = "Date copied to clipboard"; +"RelativeDateFormatterWeek2" = "%1$s, %2$s"; +"RelativeDateMenuAddToACalendar" = "Add to Calendar"; +"RelativeDateMenuCopy" = "Copy Date"; +"RelativeDateMenuSetAReminder" = "Set a Reminder"; +"ReleaseToGoNextArchive" = "Release to go to archived channels"; +"ReleaseToGoNextChannel" = "Release to go to the next unread channel"; +"ReleaseToGoNextFolder" = "Release to go to the next unread folder"; +"ReleaseToGoNextRecommendedChannel" = "Release to go to next channel"; +"ReleaseToGoNextUnreadTopic" = "Release to go to the next unread topic"; +"RemindDayAt" = "'Remind on' MMM d 'at' HH:mm"; +"RemindDayYearAt" = "'Remind on' MMM d yyyy 'at' HH:mm"; +"RemindTodayAt" = "'Remind today at' HH:mm"; +"Reminders" = "Reminders"; +"Remove" = "Remove"; +"RemoveAds" = "Remove Ads"; +"RemoveDocumentsAlertMessage" = "Removed files will be deleted from your disk, but will remain accessible in the cloud."; +"RemoveDocumentsMessage#one" = "Do you want to remove **this item** from Downloads?"; +"RemoveDocumentsMessage#other" = "Do you want to remove these **%d items** from Downloads?"; +"RemoveDocumentsTitle#one" = "Remove %d download"; +"RemoveDocumentsTitle#other" = "Remove %d downloads"; +"RemoveFromListText" = "Press and hold user to delete."; +"RemoveFromPosts" = "Remove from Posts"; +"RemoveFromRecent" = "Remove from Recent"; +"RemoveGroupEmojiPackSet" = "Remove Group Emoji Pack"; +"RemoveGroupStickerSet" = "Remove Group Sticker Set"; +"RemoveManyEmojiCount#one" = "Remove emoji"; +"RemoveManyEmojiCount#other" = "Remove %1$d emoji"; +"RemoveManyEmojiPacksCount#one" = "Remove emoji pack"; +"RemoveManyEmojiPacksCount#other" = "Remove %1$d emoji packs"; +"RemoveManyMasksCount#one" = "Remove mask"; +"RemoveManyMasksCount#other" = "Remove %1$d masks"; +"RemoveManyStickersCount#one" = "Remove sticker"; +"RemoveManyStickersCount#other" = "Remove %1$d stickers"; +"RemoveMessageFeeMessage" = "Are you sure you want to allow **%s** to message you for free?"; +"RemoveMessageFeeMessageChannel" = "Do you want to allow **%s** to message the channel for free?"; +"RemoveMessageFeeRefund#one" = "Refund the %d Star already paid "; +"RemoveMessageFeeRefund#other" = "Refund the %d Stars already paid "; +"RemoveMessageFeeTitle" = "Remove Fee"; +"RemovePhotoForRestDescription" = "Are you sure you want to delete your public photo?"; +"RemovePublicPhoto" = "Remove Public Photo"; +"RemoveQuote" = "Remove Quote"; +"RemoveReply" = "Remove Reply"; +"RemoveStatus" = "Remove Status"; +"RemoveStatusInfo" = "Emoji status removed."; +"RemoveStickersCount" = "Remove %1$s"; +"RemoveWallpaperAction" = "Remove"; +"RemoveWallpaperMessage" = "Are you sure you want to reset the wallpaper?"; +"RemoveWallpaperTitle" = "Remove wallpaper"; +"RemovedFromFavorites" = "Sticker was removed from Favorites"; +"RemovedFromRecent" = "Sticker was removed from Recent"; +"RemovedUser#one" = "%1$d removed user"; +"RemovedUser#other" = "%1$d removed users"; +"Rename" = "Rename"; +"RepeatDisabled" = "Off"; +"RepeatList" = "Repeat List"; +"RepeatNotifications" = "Repeat Notifications"; +"RepeatNotificationsNever" = "Never"; +"RepeatSong" = "Repeat Track"; +"Replace" = "Replace"; +"ReplaceAttachedPollMedia" = "Replace"; +"ReplaceBoostChannelDescription" = "You currently boost **%1$s**. Do you want to boost **%2$s** instead?"; +"ReplacePhoto" = "Replace photo"; +"Replies#one" = "%1$d Reply"; +"Replies#other" = "%1$d Replies"; +"RepliesChatInfo" = "This chat helps you keep track of replies to your comments in Channels."; +"RepliesTitle" = "Replies"; +"Reply" = "Reply"; +"ReplyDialogMessageAuthor" = "Message Author"; +"ReplyDialogYourChats" = "Your Chats"; +"ReplyPrivate" = "This reply is from a private chat."; +"ReplyPrivateChannel" = "This reply is from a private channel."; +"ReplyPrivateGroup" = "This reply is from a private group."; +"ReplyPrivately" = "Reply privately..."; +"ReplyTo" = "Reply to %s"; +"ReplyToAnotherChat" = "Reply in Another Chat"; +"ReplyToDialog" = "Reply in..."; +"ReplyToGroup" = "Reply"; +"ReplyToGroupStory" = "Comment story..."; +"ReplyToPoll" = "Reply to poll option"; +"ReplyToPollIn" = "Reply to poll option in %s"; +"ReplyToQuote" = "Reply to quote from %s"; +"ReplyToTask" = "Reply to checklist task"; +"ReplyToTaskIn" = "Reply to item in %s"; +"ReplyToUser" = "Reply"; +"Report2" = "Report"; +"Report2Comment" = "Add Comment"; +"Report2CommentInfo" = "Please help us by telling what is wrong with the message you are reporting."; +"Report2CommentInfoChannel" = "Please help us by telling what is wrong with the channel you have selected."; +"Report2CommentInfoGroup" = "Please help us by telling what is wrong with the group you have selected."; +"Report2CommentInfoMany" = "Please help us by telling what is wrong with the message you are reporting."; +"Report2CommentInfoUser" = "Please help us by telling what is wrong with the user you have selected."; +"Report2CommentOptional" = "Add Comment (Optional)"; +"Report2Send" = "Send Report"; +"ReportAd" = "Report Ad"; +"ReportAdLearnMore" = "Learn more about our [Ad Policies and Guidelines](https://ads.telegram.org/guidelines)."; +"ReportAlertReaction" = "Are you sure you want to report reactions from this user?"; +"ReportBot" = "Report"; +"ReportChat" = "Report"; +"ReportChatChild" = "Child Abuse"; +"ReportChatDescription" = "Description"; +"ReportChatFakeAccount" = "Fake Account"; +"ReportChatIllegalDrugs" = "Illegal Drugs"; +"ReportChatOther" = "Other"; +"ReportChatPersonalDetails" = "Personal Details"; +"ReportChatPornography" = "Pornography"; +"ReportChatSent" = "Report sent."; +"ReportChatSpam" = "Spam"; +"ReportChatViolence" = "Violence"; +"ReportFalsePositive" = "Report False Positive"; +"ReportHint" = "Additional details..."; +"ReportInfo" = "Please enter any additional details relevant to your report."; +"ReportMessages" = "REPORT MESSAGES"; +"ReportMessagesCount" = "REPORT %1$s"; +"ReportMessagesCountNoCaps" = "Report %1$s"; +"ReportMessagesNoCaps" = "Report Messages"; +"ReportProfilePhoto" = "Report"; +"ReportReaction" = "Report reaction"; +"ReportReaction2" = "Report Reaction"; +"ReportReactionAndBan" = "Ban and Report"; +"ReportReactionAndBan2" = "Ban and Report Reaction"; +"ReportSelectMessages" = "Select messages"; +"ReportSend" = "Send Report"; +"ReportSentInfo" = "Telegram moderators will review your report.\nThank you for your cooperation!"; +"ReportSpam" = "REPORT SPAM"; +"ReportSpamAlert" = "Are you sure you want to report spam from this user?"; +"ReportSpamAlertChannel" = "Are you sure you want to report this channel for spam?"; +"ReportSpamAlertGroup" = "Are you sure you want to report this group for spam?"; +"ReportSpamAndLeave" = "REPORT SPAM AND LEAVE"; +"ReportSpamAndLeaveNoCaps" = "Report Spam and Leave"; +"ReportSpamLocation" = "GROUP UNRELATED TO LOCATION?"; +"ReportSpamLocationNoCaps" = "Group unrelated to location?"; +"ReportSpamNoCaps" = "Report Spam"; +"ReportSpamTitle" = "Report spam"; +"ReportSpamUser" = "BLOCK USER"; +"ReportSpamUserBotVerificationHint" = "This page is verified by %1$s"; +"ReportSpamUserEmojiStatusHint" = "This account uses %1$s as a custom status next to its name. Such emoji statuses are available to all subscribers of %2$s."; +"ReportSpamUserEmojiStatusHint2" = "%1$s is a mark for **Premium subscribers >**"; +"ReportSpamUserNoCaps" = "Block User"; +"ReportStory" = "Report Story"; +"ReportTitleChild" = "Report Child Abuse"; +"ReportTitleFake" = "Report Fake"; +"ReportTitlePornography" = "Report Pornography"; +"ReportTitleSpam" = "Report Spam"; +"ReportTitleViolence" = "Report Violence"; +"ReportUnrelatedGroup" = "Report unrelated group"; +"ReportUnrelatedGroupText" = "Please let us know if this group is not related to this location:\n\n**%1$s**"; +"ReportUnrelatedGroupTextNoAddress" = "Please let us know if this group is not related to this location."; +"ReportUserLocation" = "Report"; +"Reported2" = "Telegram moderators will review your report. Thank you!"; +"RepostToStory" = "Repost\nto Story"; +"RepostedToChannelProfile" = "Message reposted to **%s**."; +"RepostedToProfile" = "Message reposted to your profile."; +"Reposts#one" = "%d repost"; +"Reposts#other" = "%d reposts"; +"RequestAnotherSMS" = "Request another SMS"; +"RequestCallButton" = "Call me to dictate the code"; +"RequestMissedCall" = "Get the code via phone call"; +"RequestSmsButton" = "Get the code via SMS"; +"RequestToJoinChannel" = "Request to Join Channel"; +"RequestToJoinChannelApproved" = "Your request to join the channel was approved"; +"RequestToJoinChannelDescription" = "This channel accepts new subscribers only after they are approved by its admins."; +"RequestToJoinChannelSentDescription" = "You will be added to the channel once its admins approve your request."; +"RequestToJoinGroup" = "Request to Join Group"; +"RequestToJoinGroupApproved" = "Your request to join the group was approved"; +"RequestToJoinGroupDescription" = "This group accepts new members only after they are approved by its admins."; +"RequestToJoinGroupSentDescription" = "You will be added to the group once an admin approves your request."; +"RequestToJoinMembersAll#one" = "%1$s, %2$s and %3$d other member"; +"RequestToJoinMembersAll#other" = "%1$s, %2$s and %3$d other members"; +"RequestToJoinMembersThree" = "%1$s, %2$s and %3$s"; +"RequestToJoinMembersTwo" = "%1$s, %2$s"; +"RequestToJoinSent" = "Join request sent"; +"RequestedToJoinAt" = "requested to join %1$s"; +"RequireMonthlyFee" = "Require Monthly Fee"; +"RequireMonthlyFeeInfo" = "Charge a subscription fee from people who join your channel via this link."; +"RequireMonthlyFeeInfoFrozen" = "To change the subscription fee, create a new invite link with a different price."; +"RequireMonthlyFeeInfoLink" = "https://telegram.org/tos/stars"; +"RequireMonthlyFeePrice" = "~%s/month"; +"RequireMonthlyFeePriceHint" = "Star amount per month"; +"RequireMonthlyFeePriceHintTest5Minutes" = "Stars Amount per 5 minutes"; +"RequireMonthlyFeePriceTest5Minutes" = "~%s/5min"; +"ResellGiftButton" = "List for Sale"; +"ResellGiftBuy#one" = "Buy for ⭐️%d"; +"ResellGiftBuy#other" = "Buy for ⭐️%d"; +"ResellGiftBuyEq#one" = "Equal to ⭐️%d"; +"ResellGiftBuyEq#other" = "Equal to ⭐️%d"; +"ResellGiftBuyTON" = "Buy for ⭐️%s Grams"; +"ResellGiftFilterSortDate" = "Sort by Date"; +"ResellGiftFilterSortDateShort" = "Date"; +"ResellGiftFilterSortNumber" = "Sort by Number"; +"ResellGiftFilterSortNumberShort" = "Number"; +"ResellGiftFilterSortPrice" = "Sort by Price"; +"ResellGiftFilterSortPriceShort" = "Price"; +"ResellGiftInfo#one" = "You will receive **%d** Star."; +"ResellGiftInfo#other" = "You will receive **%d** Stars."; +"ResellGiftInfoMin#one" = "Minimum price is %d Star."; +"ResellGiftInfoMin#other" = "Minimum price is **%d** Stars."; +"ResellGiftInfoMinTON" = "Minimum price is %s Grams"; +"ResellGiftInfoTON" = "You will receive **%s** Grams."; +"ResellGiftPriceHintOnlyTON" = "If the buyer pays you in Grams there's no risk of refunds, unlike Star payments."; +"ResellGiftPriceOnlyTON" = "Only Accept Grams"; +"ResellGiftPriceTitle" = "Enter price in Stars"; +"ResellGiftPriceTitleTON" = "Enter price in Grams"; +"ResellGiftPriceTooMuch" = "You can request maximum %s"; +"ResellGiftPriceTooSmall" = "You must request minimum %s"; +"ResellGiftTitle" = "Price in Stars"; +"ResellGiftTitleTON" = "Price in Grams"; +"ResendCode" = "Resend Code"; +"ResendCodeInfo" = "The verification code was sent to your email."; +"ResendSmsAvailableIn" = "You can request a new SMS in %1$d:%2$02d"; +"Reset" = "Reset"; +"ResetAccount" = "Reset account"; +"ResetAccountButton" = "RESET ACCOUNT"; +"ResetAccountCancelledAlert" = "Your recent attempts to reset this account have been canceled by its active user. Please try again in 7 days."; +"ResetAccountInfo" = "Since the account **%1$s** is active and protected by a password, it will be deleted in 1 week. This delay is required for security purposes.\n\nYou can cancel this process anytime."; +"ResetAccountStatus" = "You'll be able to reset your account in:"; +"ResetAllNotifications" = "Reset All Notifications"; +"ResetAutomaticMediaDownload" = "Reset Auto-Download Settings"; +"ResetAutomaticMediaDownloadAlert" = "Are you sure you want to reset auto-download settings?"; +"ResetAutomaticMediaDownloadAlertTitle" = "Reset settings"; +"ResetChatBackgrounds" = "Reset Chat Wallpapers"; +"ResetChatBackgroundsAlert" = "Are you sure you want to reset all chat wallpapers?"; +"ResetChatBackgroundsAlertTitle" = "Reset chat wallpapers"; +"ResetChatBackgroundsInfo" = "Remove all uploaded wallpapers and restore the pre-installed ones."; +"ResetCustomNotifications" = "Reset to default settings"; +"ResetCustomNotificationsAlert" = "Are you sure you want to reset notification settings to default?"; +"ResetCustomNotificationsAlertTitle" = "Reset notifications"; +"ResetMyAccount" = "RESET MY ACCOUNT"; +"ResetMyAccountText" = "You will lose all your Telegram chats, messages, media and files if you proceed with resetting your account."; +"ResetMyAccountWarning" = "Warning"; +"ResetMyAccountWarningReset" = "Reset account"; +"ResetMyAccountWarningText" = "This action can't be undone. You will lose all your Telegram chats, messages, media and files if you proceed with resetting your account."; +"ResetNotificationsAlert" = "Are you sure you want to reset all notification settings to default?"; +"ResetNotificationsAlertTitle" = "Reset all notifications"; +"ResetNotificationsText" = "All notification settings reset to default"; +"ResetPassword" = "Reset password"; +"ResetPasswordWait" = "You recently requested a password reset that was canceled. Please wait %1$s before making a new request."; +"ResetStatistics" = "Reset Statistics"; +"ResetStatisticsAlert" = "Do you want to reset your usage statistics?"; +"ResetStatisticsAlertTitle" = "Reset statistics"; +"ResetToOriginalPhoto" = "Reset to Original Photo"; +"ResetToOriginalPhotoMessage" = "Are you sure you want to reset to %s's original photo?"; +"ResetToOriginalPhotoTitle" = "Reset Photo"; +"RestToDefaultBackground" = "Remove Wallpaper"; +"RestartTopic" = "Reopen topic"; +"RestartTopics" = "Reopen topics"; +"Restore" = "Restore"; +"RestoreEmailNoAccess" = "Unable to access %1$s?"; +"RestoreEmailSent" = "We've sent a recovery code to the email you provided:\n\n%1$s"; +"RestoreEmailSentInfo" = "Check your email and enter the 6-digit code we sent there to deactivate your cloud password."; +"RestoreEmailSentTitle" = "Recovery code"; +"RestoreEmailTrouble" = "Unable to access %1$s?"; +"RestoreEmailTroubleNoEmail" = "Having trouble accessing your email?"; +"RestoreEmailTroubleText" = "If you can't restore access to your email, your remaining options are either to remember your password or to reset your account."; +"RestoreEmailTroubleText2" = "If you don’t have access to your recovery email, your remaining options are either to remember your password or wait 7 days until your password is reset."; +"RestorePasswordNoEmailText" = "Since you didn't provide a recovery email when setting up your password, your remaining options are either to remember your password or to reset your account."; +"RestorePasswordNoEmailText2" = "Since you didn’t provide a recovery email when setting up your password, your remaining options are either to remember your password or wait 7 days until your password is reset."; +"RestorePasswordNoEmailTitle" = "Sorry"; +"RestorePasswordResetAccount" = "RESET ACCOUNT"; +"RestorePasswordResetIn" = "You can reset your password in %1$s."; +"RestorePasswordResetPassword" = "RESET PASSWORD"; +"RestorePasswordResetPasswordOk" = "Your 2-Step Verification password was reset."; +"RestorePasswordResetPasswordText" = "Are you sure you want to reset your 2-Step Verification password?"; +"RestorePremiumHintMessage" = "Your Telegram Premium has recently expired. Tap here to extend it."; +"RestorePremiumHintTitle" = "Get Premium back with up to **%1$d%%** off"; +"Restrict" = "Restrict"; +"RestrictSavingContent" = "Restrict saving content"; +"RestrictSavingContentInfoChannel" = "Subscribers won't be able to copy, save or forward content from this channel."; +"RestrictSavingContentInfoGroup" = "Members won't be able to copy, save or forward content from this group."; +"RestrictUserDeleteAllMessages" = "Delete all messages"; +"RestrictUserDeleteAllReactions" = "Delete all reactions"; +"RestrictedParticipantSending" = "Restricted **%s** from sending any messages"; +"Restrictions#one" = "%1$d ban"; +"Restrictions#other" = "%1$d bans"; +"ResumeAll" = "Resume all"; +"Retry" = "Retry"; +"RetryCall" = "Retry"; +"ReturnEnteringPhrase" = "Return to entering the phrase >"; +"ReturnEnteringSMS" = "Return to entering the code >"; +"ReturnEnteringWord" = "Return to entering the word >"; +"ReturnPhoneCall" = "Return to phone call >"; +"ReturnToCall" = "RETURN TO CALL"; +"RevenueSharingAdsAlertButton" = "Understood"; +"RevenueSharingAdsAlertSubtitle" = "Telegram Ads are very different from ads on other platforms. Ads such as this one:"; +"RevenueSharingAdsInfo1Subtitle" = "Ads on Telegram do not use your personal information and are based on the channel in which you see them."; +"RevenueSharingAdsInfo1SubtitleBot" = "Ads on Telegram do not use your personal information and are based on the bot in which you see them."; +"RevenueSharingAdsInfo1Title" = "Respect Your Privacy"; +"RevenueSharingAdsInfo1TitleBot" = "Respect Your Privacy"; +"RevenueSharingAdsInfo2Subtitle" = "50% of the revenue from Telegram Ads goes to the owner of the channel where they are displayed."; +"RevenueSharingAdsInfo2SubtitleBot" = "50% of the revenue from Telegram Ads goes to the owner of the bot where they are displayed."; +"RevenueSharingAdsInfo2Title" = "Help the Channel Creator"; +"RevenueSharingAdsInfo2TitleBot" = "Help the Bot Creator"; +"RevenueSharingAdsInfo3Subtitle" = "You can turn off ads by subscribing to **Telegram Premium**, and Level %1$s channels can remove them for their subscribers."; +"RevenueSharingAdsInfo3SubtitleBot" = "You can turn off ads by subscribing to **Telegram Premium**."; +"RevenueSharingAdsInfo3Title" = "Can Be Removed"; +"RevenueSharingAdsInfo4Subtitle2" = "Anyone can create an ad to display in this channel — with minimal budgets. Check out the **Telegram Ad Platform** for details. %1$s"; +"RevenueSharingAdsInfo4Subtitle2Bot" = "Anyone can create an ad to display in this bot — with minimal budgets. Check out the **Telegram Ad Platform** for details. %1$s"; +"RevenueSharingAdsInfo4SubtitleLearnMore" = "**Learn More >**"; +"RevenueSharingAdsInfo4Title" = "Can I Launch an Ad?"; +"RevenueSharingAdsInfo4TitleBot" = "Can I Launch an Ad?"; +"RevenueSharingAdsUnderstood" = "Understood"; +"ReverseOrder" = "Reverse Order"; +"RevokeAlert" = "Are you sure you want to revoke this link? Once the link is revoked, no one will be able to join using it."; +"RevokeAlertNewLink" = "The previous invite link is now inactive. A new link has been generated."; +"RevokeButton" = "Revoke"; +"RevokeLink" = "Revoke Link"; +"RevokeLinkAlert" = "Are you sure you want to revoke the link **%1$s**?\n\nThe group \"**%2$s**\" will become private."; +"RevokeLinkAlertChannel" = "Are you sure you want to revoke the link **%1$s**?\n\nThe channel \"**%2$s**\" will become private."; +"RevokeLinks#one" = "Revoke link"; +"RevokeLinks#other" = "Revoke %d links"; +"RevokeLinksAlert" = "Are you sure you want to revoke these links?\n\nThe groups will become private."; +"RevokeLinksAlertChannel" = "Are you sure you want to revoke these links?\n\nThe channels will become private."; +"Revoked" = "Revoked"; +"RevokedLink" = "Revoked Link"; +"RevokedLinks" = "Revoked Links"; +"RightNow" = "Just now"; +"RoamingUsage" = "Roaming Data Usage"; +"SETTINGS" = "Settings"; +"SMSFee1Text" = "Telecom providers in your country charge Telegram very high prices for SMS."; +"SMSFee1TextCountry" = "Due to fake accounts and high SMS costs in your country, you’ll need to cover your signup fee."; +"SMSFee1Title" = "High SMS Costs"; +"SMSFee2Text" = "Telegram needs to send you an SMS with a verification code to confirm your phone number."; +"SMSFee2Title" = "Verification Required"; +"SMSFee3Text" = "Sign up for 1-week Telegram Premium subscription to help cover the SMS costs."; +"SMSFee3TextDays#one" = "Sign up for %d day of Telegram Premium to help cover the SMS costs."; +"SMSFee3TextDays#other" = "Sign up for %d days of Telegram Premium to help cover the SMS costs."; +"SMSFee3Title" = "Support via **Telegram Premium >**"; +"SMSFeeContact" = "Having issues? **Contact us**"; +"SMSFeePurchaseText" = "Get Telegram Premium for 1 week"; +"SMSFeePurchaseTextDays#one" = "Get Telegram Premium for %d day"; +"SMSFeePurchaseTextDays#other" = "Get Telegram Premium for %d days"; +"SMSFeePurchaseTitle" = "Sign up for %s"; +"SMSFeeTitle" = "SMS Fee"; +"SMSPhrase" = "Secret phrase"; +"SMSPhraseBeginningError" = "Incorrect word"; +"SMSPhraseBeginningText" = "We’ve sent an SMS with a secret phrase that starts with “**%2$s**” to your phone **%1$s**."; +"SMSPhraseError" = "Incorrect, please try again"; +"SMSPhraseHint" = "Enter Phrase from SMS"; +"SMSPhrasePasteHint" = "You can copy and paste the phrase here."; +"SMSPhraseText" = "We’ve sent an SMS with a secret phrase to your phone **%1$s**."; +"SMSPhraseTitle" = "Enter phrase from SMS"; +"SMSWord" = "Secret word"; +"SMSWordBeginningError" = "Incorrect word"; +"SMSWordBeginningText" = "We’ve sent an SMS with a secret word that starts with “**%2$s**” to your phone **%1$s**."; +"SMSWordError" = "Incorrect, please try again"; +"SMSWordHint" = "Enter Word from SMS"; +"SMSWordPasteHint" = "You can copy and paste the word here."; +"SMSWordText" = "We’ve sent an SMS with a secret word to your phone **%1$s**."; +"SMSWordTitle" = "Enter word from SMS"; +"SafetyNetErrorOccurred" = "An internal error occurred. Please try again later."; +"Satellite" = "Satellite"; +"Saturation" = "Saturation"; +"Save" = "Save"; +"SaveChangesAlertText" = "Do you want to apply the new colors you chose?"; +"SaveChangesAlertTitle" = "Save changes?"; +"SaveException" = "Save exception"; +"SaveForNotifications" = "Save for Notifications"; +"SaveGroupMedia" = "Save media"; +"SaveGroupMediaMessage" = "Would you like to save..."; +"SaveImage" = "Save Image"; +"SaveLink" = "Save Link"; +"SaveLinkHeader" = "SAVE"; +"SaveLinkHeaderNoCaps" = "Save"; +"SaveOnAnnualPremiumMessage" = "Sign up for the annual payment plan for Telegram Premium now to get the discount."; +"SaveOnAnnualPremiumTitle" = "Save up to **%1$d%%** on your subscription"; +"SaveStoryToGalleryPremiumHint" = "**Subscribe to Premium** to save other people's stories that are not protected."; +"SaveTheme" = "SAVE THEME"; +"SaveToDownloads" = "Save to Downloads"; +"SaveToGIFs" = "Add to GIFs"; +"SaveToGallery" = "Save to Gallery"; +"SaveToGalleryChannels" = "Channels"; +"SaveToGalleryException" = "Exception"; +"SaveToGalleryGroups" = "Groups"; +"SaveToGalleryHintChannels" = "Automatically save all new media from channels to your phone's gallery."; +"SaveToGalleryHintCurrent" = "Automatically save all new media from this chat to your phone's gallery."; +"SaveToGalleryHintGroup" = "Automatically save all new media from groups to your phone's gallery."; +"SaveToGalleryHintUser" = "Automatically save all new media from private chats to your phone's gallery."; +"SaveToGalleryOff" = "Off"; +"SaveToGalleryPhotos" = "Photos"; +"SaveToGalleryPrivate" = "Private Chats"; +"SaveToGallerySettings" = "Save to Gallery"; +"SaveToGalleryVideoHintChannels" = "All videos in channels that are smaller than the selected size will be saved to your gallery."; +"SaveToGalleryVideoHintCurrent" = "All videos from this chat that are smaller than the selected size will be saved to your gallery."; +"SaveToGalleryVideoHintGroup" = "All videos in groups that are smaller than the selected size will be saved to your gallery."; +"SaveToGalleryVideoHintUser" = "All videos in private chats that are smaller than the selected size will be saved to your gallery."; +"SaveToGalleryVideos" = "Videos"; +"SaveToGalleryVideosUpTo" = "Videos up to %s"; +"SaveToMusic" = "Save to Music"; +"SaveToPosts" = "Post to Profile"; +"SaveToProfile" = "Post to Profile"; +"SaveVideo" = "Save Video"; +"SavedDialogsTab" = "Chats"; +"SavedDialogsTabCount#one" = "%d chat"; +"SavedDialogsTabCount#other" = "%d chats"; +"SavedGifsLimitSubtitle" = "Save up to %d GIFs in your Favorite GIFs"; +"SavedGifsLimitTitle" = "Saved GIFs"; +"SavedMessages" = "Saved Messages"; +"SavedMessagesCount#one" = "%d saved message"; +"SavedMessagesCount#other" = "%d saved messages"; +"SavedMessagesHint" = "Tap to view your **Saved Messages** organized by type or source."; +"SavedMessagesInfo" = "Forward here to save"; +"SavedMessagesInfoQuote" = "Quote here to save."; +"SavedMessagesProfileHint" = "Messages you sent to Saved Messages"; +"SavedMessagesTab" = "Saved Messages"; +"SavedMessagesTab2" = "Saved"; +"SavedMessagesViewAsChatsHint" = "tap to view as chats >"; +"SavedOpenChannel" = "OPEN CHANNEL"; +"SavedOpenChannelNoCaps" = "Open Channel"; +"SavedOpenChat" = "OPEN CHAT"; +"SavedOpenChatNoCaps" = "Open Chat"; +"SavedOpenGroup" = "OPEN GROUP"; +"SavedOpenGroupNoCaps" = "Open Group"; +"SavedStories" = "Posted Stories"; +"SavedTagFilterByTag" = "Filter by Tag"; +"SavedTagHideOtherMessages" = "Hide Other Messages"; +"SavedTagLabelPlaceholder" = "Name"; +"SavedTagLabelTag" = "Add Name"; +"SavedTagLabelTagText" = "You can label your emoji tag with a text name."; +"SavedTagLongpressHint" = "Tap and hold to add a name to your tag"; +"SavedTagMessageTagged" = "Message tagged with"; +"SavedTagMessagesTagged#other" = "Messages tagged with"; +"SavedTagReactionsHint" = "You can add a tag to the message"; +"SavedTagReactionsHint2" = "Tag the message with an emoji for quick search"; +"SavedTagReactionsPremiumHint" = "Organize your Saved Messages with tags\nfor quicker access. **Learn more…**"; +"SavedTagReactionsSelectedAddHint" = "Add a tag to selected messages"; +"SavedTagReactionsSelectedEditHint" = "Edit tags of selected messages"; +"SavedTagRemoveTag" = "Remove Tag"; +"SavedTagRenameTag" = "Edit Name"; +"SavedTagSearchHint" = "Search messages or tags"; +"SavedTagSearchTooltipHint" = "Tap this icon to search by tags"; +"SavedTagShowOtherMessages" = "Show Other Messages"; +"SavedViewAsChats" = "View as Chats"; +"SavedViewAsMessages" = "View as Messages"; +"SavingContentTitle" = "Content protection"; +"ScamMessage" = "SCAM"; +"ScanQrCode" = "Scan QR Code"; +"Schedule" = "Schedule"; +"ScheduleMessage" = "Schedule Message"; +"ScheduleNotifyOffChannel" = "Subscribers will receive a silent notification"; +"ScheduleNotifyOffChat" = "%s will receive a silent notification"; +"ScheduleNotifyOffGroup" = "Members will receive a silent notification"; +"ScheduleNotifyOffSelf" = "You will receive a silent notification"; +"ScheduleNotifyOnChannel" = "Subscribers will be notified"; +"ScheduleNotifyOnChat" = "%s will be notified"; +"ScheduleNotifyOnGroup" = "Members will be notified"; +"ScheduleNotifyOnSelf" = "You will be notified"; +"ScheduleWhenOnline" = "Send when %1$s comes online"; +"ScheduledHint" = "Long tap to send your message later."; +"ScheduledMessages" = "Scheduled Messages"; +"ScheduledOrNoSoundHint" = "Hold this button to schedule your message or send it without sound."; +"ScheduledTimeApprox" = "appx. %s"; +"ScreenCapture" = "Show App Content in Task Switcher"; +"ScreenCaptureAlert" = "This will hide the contents of your chats or chat list from the task switcher, but you will not be able to take screenshots on Telegram.\n\nYou may need to restart the app for this to take effect."; +"ScreenCaptureHeader" = "App Content in Task Switcher"; +"ScreenCaptureInfo" = "If disabled, chat content will be hidden in the task switcher but you won't be able to take screenshots in the app."; +"ScreenCaptureShowContent" = "Show Content"; +"SdCard" = "SD Card"; +"SdCardAlert" = "Storing on SD card will make Telegram slow. Proceed anyway?"; +"SdCardError" = "SD Card Error"; +"SdCardErrorDescription" = "Telegram is unable to save data on your SD card."; +"Search" = "Search"; +"SearchAd" = "Ad"; +"SearchAdsAbout1Subtitle" = "Ads like this do not use your personal information and are based on the search query you entered."; +"SearchAdsAbout1Title" = "Respect Your Privacy"; +"SearchAdsAbout2Subtitle" = "You can turn off ads by subscribing to Telegram Premium. **Subscribe >**"; +"SearchAdsAbout2SubtitlePremium" = "You can turn off ads by subscribing to Telegram Premium and toggling off Ads. **Remove Ads >**"; +"SearchAdsAbout2Title" = "Can Be Removed"; +"SearchAdsAboutLaunchLearnMore" = "**Learn More >**"; +"SearchAdsAboutLaunchSubtitle" = "Anyone can create an ad to display in search results for any query. Check out the **Telegram Ad Platform** for details. %1$s"; +"SearchAdsAboutLaunchTitle" = "Can I Launch My Ad?"; +"SearchAdsAboutSubtitle" = "Telegram Ads are very different from ads on other platforms. Ads such as this one:"; +"SearchAdsAboutTitle" = "About These Ads"; +"SearchAdsAboutUnderstood" = "Understood"; +"SearchAllChatsShort" = "Chats"; +"SearchApps" = "Bots"; +"SearchAppsExamples" = "Examples"; +"SearchAppsMine" = "Apps you use"; +"SearchAppsPopular" = "Grossing apps"; +"SearchAsChat" = "Show as Chat"; +"SearchAsList" = "Show as List"; +"SearchBackgrounds" = "Search for images"; +"SearchByColor" = "Search by color"; +"SearchChannels" = "Channels"; +"SearchChats" = "Search Chats"; +"SearchContacts" = "Search Contacts"; +"SearchCountriesSelect" = "Select"; +"SearchCountriesTitle" = "Global Search"; +"SearchEmojiHint" = "Search emoji"; +"SearchEmptyViewDownloads" = "No recent downloads"; +"SearchEmptyViewFilteredSubtitle2" = "Try a new search."; +"SearchEmptyViewFilteredSubtitleFiles" = "Files from all your chats will be shown here."; +"SearchEmptyViewFilteredSubtitleLinks" = "Links from all your chats will be shown here."; +"SearchEmptyViewFilteredSubtitleMedia" = "Media from all your chats will be shown here."; +"SearchEmptyViewFilteredSubtitleMusic" = "Music from all your chats will be shown here."; +"SearchEmptyViewFilteredSubtitleVoice" = "Voice from all your chats will be shown here."; +"SearchEmptyViewTitle" = "No Results"; +"SearchEmptyViewTitle2" = "No Results"; +"SearchEngine" = "Search Engine"; +"SearchEngine1Name" = "Google"; +"SearchEngineInfo" = "Search and auto-complete in browser search bar"; +"SearchForChats" = "Search for chats..."; +"SearchForPeople" = "Search for people..."; +"SearchForPeopleAndGroups" = "Search for users and groups..."; +"SearchFriends" = "Search contacts"; +"SearchFrom" = "from:"; +"SearchGifs" = "FIND GIFS"; +"SearchGifsTitle" = "Search GIFs"; +"SearchHashtagsHint" = "Search hashtags"; +"SearchIconsHint" = "Search icons"; +"SearchImages" = "FIND IMAGES"; +"SearchImagesInfo" = "WEB SEARCH"; +"SearchImagesTitle" = "Search web"; +"SearchImagesType" = "Type to search."; +"SearchInSettings" = "Search Settings and FAQ"; +"SearchMemberRequests" = "Search join requests"; +"SearchMembers" = "Search Members"; +"SearchMessages" = "Messages"; +"SearchMessagesFilterAll" = "All Chats"; +"SearchMessagesFilterAllFrom" = "From all chats"; +"SearchMessagesFilterChannels" = "Channels"; +"SearchMessagesFilterChannelsFrom" = "From channels"; +"SearchMessagesFilterEmptySearchAll" = "Search in All Chats"; +"SearchMessagesFilterEmptyText" = "There were no results for \"%s\".\nTry a new search."; +"SearchMessagesFilterEmptyTitle" = "No results"; +"SearchMessagesFilterGroup" = "Group Chats"; +"SearchMessagesFilterGroupFrom" = "From groups"; +"SearchMessagesFilterPrivate" = "Private Chats"; +"SearchMessagesFilterPrivateFrom" = "From private chats"; +"SearchMessagesIn" = "Messages in %s"; +"SearchMessagesResultCount#one" = "%2$s result"; +"SearchMessagesResultCount#other" = "%2$s results"; +"SearchMusic" = "Search music"; +"SearchMyChannels" = "Channels you joined"; +"SearchMyMessages" = "My Chats"; +"SearchPaidStars#one" = "**%d Star** spent on extra search."; +"SearchPaidStars#other" = "**%d Stars** spent on extra search."; +"SearchPeopleByUsername" = "Search people by username"; +"SearchPosts" = "Posts"; +"SearchPostsButton" = "Search"; +"SearchPostsButtonPay#one" = "Search for ⭐️%d"; +"SearchPostsButtonPay#other" = "Search for ⭐️%d"; +"SearchPostsButtonPremium" = "Subscribe to Premium"; +"SearchPostsFreeSearchUnlocksIn" = "free search unlocks in %s"; +"SearchPostsFreeSearches#one" = "%d free search remaining today."; +"SearchPostsFreeSearches#other" = "%d free searches remaining today."; +"SearchPostsHeaderFound" = "Public Posts"; +"SearchPostsHeaderNews" = "Telegram News"; +"SearchPostsLimitReached" = "Limit Reached"; +"SearchPostsLimitReachedText#one" = "You can make up to %d search query per day."; +"SearchPostsLimitReachedText#other" = "You can make up to %d search queries per day."; +"SearchPostsNotFound" = "Not Found"; +"SearchPostsNotFoundText" = "No posts found for \"%s\"."; +"SearchPostsPremium" = "Global search is a Premium feature."; +"SearchPostsText" = "Type a keyword to search for posts from public channels."; +"SearchPostsTitle" = "Global Search"; +"SearchPublicPosts" = "Public Posts"; +"SearchReactionsHint" = "Search reactions"; +"SearchRecentFiles" = "Search Recent Files"; +"SearchRecommendedChannels" = "Similar Channels"; +"SearchStickersHint" = "Search sticker sets"; +"SearchThisChat" = "This Chat"; +"SearchTipToday" = "Today"; +"SearchTipYesterday" = "Yesterday"; +"SearchTopics" = "Search Topics"; +"SearchTrendingStickersHint" = "Search trending stickers"; +"Seconds#one" = "%1$d second"; +"Seconds#other" = "%1$d seconds"; +"SecondsBold#one" = "**%1$d** second"; +"SecondsBold#other" = "**%1$d** seconds"; +"SecondsSchedule#one" = "%1$d second"; +"SecondsSchedule#other" = "%1$d seconds"; +"SecretChat" = "Secret Chats"; +"SecretChatContextBotAlert" = "Please note that inline bots are provided by third-party developers. For the bot to work, the symbols you type after the bot's username are sent to its respective developer."; +"SecretChatName" = "Telegram"; +"SecretChatTimerDays" = "d"; +"SecretChatTimerHours" = "h"; +"SecretChatTimerMinutes" = "m"; +"SecretChatTimerMonths" = "M"; +"SecretChatTimerSeconds" = "s"; +"SecretChatTimerWeeks" = "w"; +"SecretChatTimerYears" = "y"; +"SecretLinkPreviewAlert" = "Enable extended link previews in Secret Chats? Note that link previews are generated on Telegram servers."; +"SecretWebPage" = "Link Previews"; +"SecretWebPageInfo" = "Link previews will be generated on Telegram servers. We do not store any data about the links you send."; +"SecurityTitle" = "Security"; +"SeekSpeedHint" = "Swipe sideways to change speed"; +"SegmentationCutObject" = "Cut Out an Object"; +"SegmentationCutOut" = "Cut Out"; +"SegmentationErase" = "Erase"; +"SegmentationOutline" = "Add Outline"; +"SegmentationRestore" = "Restore"; +"SegmentationTabToCrop" = "Tap on an object to crop it"; +"SegmentationUndo" = "Undo"; +"SegmentationUndoCutOut" = "Undo Cut"; +"Select" = "Select"; +"SelectAccount" = "Select Account"; +"SelectAll" = "Select All"; +"SelectChat" = "Select Chat"; +"SelectChats" = "Select Chats"; +"SelectChatsForAutoDelete" = "to apply the self-destruct timer"; +"SelectChatsForAutoDelete2" = "will have the self-destruct timer"; +"SelectChatsForDisableAutoDelete" = "to disable the self-destruct timer"; +"SelectChatsForDisableAutoDelete2" = "will disable the self-destruct timer"; +"SelectColor" = "Select color"; +"SelectColorTitle" = "Select a Color"; +"SelectContact" = "Select Contact"; +"SelectContacts" = "Select Contacts"; +"SelectDays" = "Select Days"; +"SelectDaysTooltip" = "Please select one or more days first."; +"SelectEmoji" = "Select emoji"; +"SelectFile" = "Select File"; +"SelectFromGallery" = "Select from Gallery"; +"SelectImage" = "Select image"; +"SelectOtherChat" = "Select another chat"; +"SelectSendAsPeerPremiumHint" = "Subscribe to **Telegram Premium** to be able to comment on behalf of your channels in any group chat."; +"SelectSendAsPeerPremiumOpen" = "Open"; +"SelectSpecificQuote" = "Select Specific Quote"; +"SelectSticker" = "Select sticker"; +"SelectTheme" = "Select theme"; +"SelectThisDay" = "Select This Day"; +"SelectTopic" = "Select topic"; +"SelectTopicIconHint" = "Select Topic Icon"; +"SelectingContact" = "selecting a contact to share..."; +"SelectingLocation" = "selecting a location to share..."; +"SelfStarsOverviewInfo" = "Stars from your total balance will become available 21 days after they are earned."; +"SelfStarsWithdrawInfo#one" = "You can withdraw from %d Star using Fragment. **Learn more >**"; +"SelfStarsWithdrawInfo#other" = "You can withdraw from %d Stars using Fragment. **Learn more >**"; +"SelfStoryTitle" = "My Story"; +"Send" = "Send"; +"SendAGift" = "Send a Gift"; +"SendAnonymously" = "Send anonymously"; +"SendAnotherGift" = "Send Another Gift"; +"SendAsFile" = "Send as file"; +"SendAsFiles" = "Send as files"; +"SendAsNewPhoto" = "Send as a new photo"; +"SendBotNoThread" = "Off-topic message"; +"SendByEnter" = "Send with Enter"; +"SendContactTo" = "Send contact to %1$s?"; +"SendContactToGroup" = "Send contact to %1$s?"; +"SendContactToGroupText" = "Do you want to send this contact to **%1$s**?"; +"SendContactToText" = "Do you want to send this contact to **%1$s**?"; +"SendDayAt" = "'Send on' MMM d 'at' HH:mm"; +"SendDayYearAt" = "'Send on' MMM d yyyy 'at' HH:mm"; +"SendDice" = "SEND"; +"SendEmojiPreview" = "Send Emoji"; +"SendGameTo" = "Share game to %1$s?"; +"SendGameToGroup" = "Share game to %1$s?"; +"SendGameToGroupText" = "Do you want to share this game to **%1$s**?"; +"SendGameToText" = "Do you want to share this game to **%1$s**?"; +"SendGifPreview" = "Send GIF"; +"SendGiftTo" = "Send Gift to %s"; +"SendInHighQuality" = "Send in high quality"; +"SendInStandardQuality" = "Send in standard quality"; +"SendInviteLink" = "Send Invite Link"; +"SendItems" = "Send %1$s"; +"SendLiveLocation" = "Share My Live Location for..."; +"SendLiveLocationFor15m" = "for 15 minutes"; +"SendLiveLocationFor1h" = "for 1 hour"; +"SendLiveLocationFor8h" = "for 8 hours"; +"SendLiveLocationForever" = "until I turn it off"; +"SendLiveLocationInfo" = "Updated in real time as you move"; +"SendLiveLocationMenu" = "Share my Live Location"; +"SendLocation" = "Send My Current Location"; +"SendMediaEmbededLinks" = "Embed Links"; +"SendMediaPermissionFiles" = "Files"; +"SendMediaPermissionMusic" = "Music"; +"SendMediaPermissionPhotos" = "Photos"; +"SendMediaPermissionRound" = "Video Messages"; +"SendMediaPermissionStickersGifs" = ""; +"SendMediaPermissionVideos" = "Videos"; +"SendMediaPermissionVoice" = "Voice Messages"; +"SendMediaPolls" = "Polls"; +"SendMessage" = "Send Message"; +"SendMessageAsTitle" = "Send message as..."; +"SendMessageLocation" = "Send Message"; +"SendMessageRestricted" = "The admins of this group have restricted you from messaging until %1$s."; +"SendMessageRestrictedForever" = "The admins of this group have restricted your ability to send messages."; +"SendMessageTitle" = "Send message"; +"SendMessageTo" = "Who would you like to add?"; +"SendMessagesTo" = "Send to %1$s?"; +"SendMessagesToGroup" = "Send to %1$s?"; +"SendMessagesToGroupText" = "Do you want to send this message to **%1$s**?"; +"SendMessagesToText" = "Do you want to send this message to **%1$s**?"; +"SendPlainTextRestrictionHint" = "The admins of this group only allow sending %s."; +"SendReactionsIsRestrictedInThisChat" = "You cannot send reactions in this chat."; +"SendSelectedLocation" = "Send selected location"; +"SendSticker" = "SEND STICKER"; +"SendStickerPreview" = "Send Sticker"; +"SendTodayAt" = "'Send today at' HH:mm"; +"SendWhenOnline" = "Send When Online"; +"SendWithoutCompression" = "Send without compression"; +"SendWithoutGrouping" = "Send without grouping"; +"SendWithoutSound" = "Send Without Sound"; +"SendingAudio" = "sending audio..."; +"SendingFile" = "sending a file..."; +"SendingGame" = "playing a game..."; +"SendingGif" = "Sending GIF..."; +"SendingPhoto" = "sending a photo..."; +"SendingSms" = "Sending SMS..."; +"SendingVideo" = "Sending video..."; +"SendingVideoStatus" = "sending a video..."; +"SensitiveContentSettingsToast" = "You can update the visibility of sensitive media in **Chat Settings > Show 18+ Content**"; +"SentAppCode" = "We've sent the code to the **Telegram** app on your other device."; +"SentAppCodeTitle" = "Check your Telegram messages"; +"SentAppCodeWithPhone" = "We've sent the code to the **Telegram app** for %1$s on your other device."; +"SentCallCode" = "We're calling your phone **%1$s**.\n\nDon't accept the call. Telegram will process everything automatically."; +"SentCallOnly" = "Calling your phone **%1$s** to dictate the code."; +"SentFragmentCode" = "Get the code for %1$s in the Numbers section on Fragment."; +"SentSmsCode" = "We’ve sent an SMS with an activation code to your phone **%1$s**."; +"SentSmsCodeTitle" = "Enter code"; +"September" = "September"; +"ServerErrorViewers" = "Information about viewers wasn't recorded."; +"ServerErrorViewersFull" = "Information about the other viewers wasn't recorded."; +"ServerErrorViewersTitle" = "Oops"; +"ServiceNotifications" = "Service notifications"; +"SessionBot" = "Chat Automation Bot"; +"SessionBotConnectedFrom" = "Connected from"; +"SessionBotConnectedOn" = "connected on %s"; +"SessionBotDate" = "Date"; +"SessionBotDevice" = "Device"; +"SessionBotLocation" = "Location"; +"SessionsInfo" = "Manage your sessions on all your devices."; +"SessionsListInfo" = "The official Telegram app is available for Android, iPhone, iPad, Windows, macOS and Linux."; +"SessionsSelfDestruct" = "Sessions self-destruct"; +"SessionsSettingsInfo" = "Review the list of devices where you are logged in to your Telegram account."; +"SessionsTitle" = "Devices"; +"Set" = "Set"; +"SetAdditionalPassword" = "Set Additional Password"; +"SetAdditionalPasswordInfo" = "You can set a password that will be required when you log in on a new device in addition to the code you get via SMS."; +"SetAdmins" = "Add admins"; +"SetAdminsAll" = "All Members Are Admins"; +"SetAdminsAllInfo" = "All members can add new members, pin messages, edit name and photo of the group."; +"SetAdminsNotAllInfo" = "Only admins can add and remove members, pin messages or edit group info."; +"SetAdminsTitle" = "Chat Admins"; +"SetAsAdmin" = "Promote to admin"; +"SetAsEmojiStatus" = "Set as Status"; +"SetAsEmojiStatusInfo" = "Emoji status set."; +"SetAsGroupEmojiPackSet" = "Set as Group Emoji Pack"; +"SetAsGroupStickerSet" = "Set as Group Sticker Set"; +"SetAsMain" = "Set as Main"; +"SetAsMyPhoto" = "Set as My Photo"; +"SetAutoDeleteTimer" = "Set Auto-Delete"; +"SetBackground" = "SET WALLPAPER"; +"SetBackgroundFromGallery" = "Set Wallpaper From Gallery"; +"SetChannelPhoto" = "Set Channel Photo"; +"SetColor" = "Set a color"; +"SetColorAsBackground" = "Set a Color as Wallpaper"; +"SetCover" = "Choose a frame for this profile photo"; +"SetCustomTime" = "Set Custom Time"; +"SetEmojiStatus" = "Set Emoji Status"; +"SetEmojiStatusUntil1Hour" = "Set for 1 hour"; +"SetEmojiStatusUntil2Days" = "Set for 2 days"; +"SetEmojiStatusUntil2Hours" = "Set for 2 hours"; +"SetEmojiStatusUntil8Hours" = "Set for 8 hours"; +"SetEmojiStatusUntilButton" = "Set Status"; +"SetEmojiStatusUntilOther" = "Other…"; +"SetEmojiStatusUntilTitle" = "Set status until…"; +"SetGroupPhoto" = "Set Group Photo"; +"SetIntroSticker" = "Set Intro Sticker"; +"SetMyProfilePhotoAvatarConstructor" = "Set My Photo"; +"SetNewPassword" = "Set a new password"; +"SetPasscode" = "Set a Passcode"; +"SetPasscodeInfo" = "Lock the app with a passcode so that others can't open it."; +"SetPhoto" = "Set Photo"; +"SetPhotoFor" = "Set photo for %s"; +"SetPhotoForRest" = "Set Public Photo"; +"SetProfilePhoto" = "Set Profile Photo"; +"SetProfilePhotoAvatarConstructor" = "Set Profile Photo"; +"SetProfileVideo" = "Set As Profile Photo"; +"SetRecoveryEmail" = "Set Recovery Email"; +"SetReminder" = "Set a Reminder"; +"SetSuggestedPhotoTooltip" = "%s suggests this photo for your Telegram profile."; +"SetSuggestedVideoTooltip" = "%s suggests this photo for your Telegram profile."; +"SetThisLocation" = "Set This Location"; +"SetTimeLimit" = "Set Time Limit"; +"SetTimer" = "Set self-destruct timer"; +"SetUrlAvailable" = "%1$s is available."; +"SetUrlChecking" = "Checking link..."; +"SetUrlInUse" = "Sorry, this link is already taken."; +"SetUrlInvalid" = "Sorry, this link is invalid."; +"SetUrlInvalidLong" = "The link must not exceed 64 characters."; +"SetUrlInvalidShort" = "A link must have at least 5 characters."; +"SetUrlInvalidStartNumber" = "Sorry, the link can't start with a number."; +"SetUrlPlaceholder" = "Link"; +"SetUserPhotoAlertMessage" = "Do you want to set this photo for **%1$s**? It will replace any photo %2$s sets – only you will see it."; +"SetUserPhotoSelfAlertMessage" = "Do you want to set this as your profile photo?"; +"SetUsernameHeader" = "Set username"; +"SetVideo" = "Set Photo"; +"SetWallpapers" = "Change Wallpaper"; +"Settings" = "Settings"; +"SettingsAccount" = "Account"; +"SettingsAccountInfo" = "Number, Username, Bio"; +"SettingsAccounts" = "Accounts"; +"SettingsBrowseThemes" = "Browse Themes"; +"SettingsChat" = "Chat Settings"; +"SettingsChatInfo" = "Wallpaper, Night Mode, Animations"; +"SettingsData" = "Data and Storage"; +"SettingsDataInfo" = "Media download settings"; +"SettingsDebug" = "Debug"; +"SettingsDevices" = "Devices"; +"SettingsDevicesInfo" = "Manage connected devices"; +"SettingsFaqSearchTitle" = "Frequently asked questions"; +"SettingsFolders" = "Chat Folders"; +"SettingsFoldersInfo" = "Sort chats into folders"; +"SettingsHelp" = "Help"; +"SettingsLanguage" = "Language"; +"SettingsNoRecent" = "No recent searches"; +"SettingsNoResults" = "No results found"; +"SettingsNotifications" = "Notifications"; +"SettingsNotificationsInfo" = "Sounds, Calls, Badges"; +"SettingsPowerSaving" = "Power Saving"; +"SettingsPowerSavingInfo" = "Reduce power usage on low charge"; +"SettingsPrivacySecurity" = "Privacy & Security"; +"SettingsPrivacySecurityInfo" = "Last Seen, Devices, Passkeys"; +"SettingsRecent" = "Recent searches"; +"SettingsSearchFaq" = "FAQ"; +"SettingsSwitchToDayMode" = "Switch to Day Mode"; +"SettingsSwitchToNightMode" = "Switch to Night Mode"; +"Shadows" = "Shadows"; +"ShadyJoining" = "joining..."; +"ShadyLeaving" = "leaving..."; +"ShareComment" = "Add a comment..."; +"ShareContact" = "Share contact"; +"ShareContactTitle" = "Share Contact"; +"ShareFile" = "Share"; +"ShareLink" = "Share Link"; +"ShareLinks" = "Share Links"; +"ShareLocation" = "Location"; +"ShareLocationAlertButton" = "Share"; +"ShareLocationAlertText" = "For the alert to work, please share your live location in this chat."; +"ShareLocationAlertTitle" = "Share Location"; +"ShareMyContactInfo" = "Share My Contact"; +"ShareMyPhone" = "SHARE MY PHONE NUMBER"; +"ShareMyPhoneNoCaps" = "Share my phone number"; +"SharePhoneNumberWith" = "Share my phone number with %1$s"; +"ShareQrCode" = "Share QR Code"; +"ShareSendItems#one" = "Send %1$d media"; +"ShareSendItems#other" = "Send %1$d media"; +"ShareSendPhoto" = "Send photo"; +"ShareSendPhotos#one" = "Send %1$d photo"; +"ShareSendPhotos#other" = "Send %1$d photos"; +"ShareSendTo" = "Send to..."; +"ShareSendToChats" = "To %s"; +"ShareSendToMany#one" = "To %1$d chat"; +"ShareSendToMany#other" = "To %1$d chats"; +"ShareSendToNewTopic" = "New topic"; +"ShareSendToOffTopic" = "Off-topic"; +"ShareSendVideo" = "Send video"; +"ShareSendVideos#one" = "Send %1$d video"; +"ShareSendVideos#other" = "Send %1$d videos"; +"ShareTapToEditMedia" = "Tap here to edit media"; +"ShareTelegram" = "Share Telegram..."; +"ShareTelegram2" = "Share Telegram"; +"ShareTheme" = "Share theme"; +"ShareVideo" = "Share Camera Video"; +"ShareYouLocationInfo" = "This will send your current location to the bot."; +"ShareYouLocationInline" = "This bot would like to know your location each time you send it a request. This can be used to provide location-specific results."; +"ShareYouLocationTitle" = "Share Your Location?"; +"ShareYouLocationUnable" = "App was unable to determine your current location"; +"ShareYouLocationUnableManually" = "Choose manually"; +"ShareYouPhoneNumberTitle" = "Share your phone number?"; +"Shared#one" = "%1$s shared"; +"Shared#other" = "%1$s shared"; +"SharedAudioFiles" = "Audio files"; +"SharedContactAdd" = "ADD"; +"SharedContactMessage" = "MESSAGE"; +"SharedContent" = "Shared content"; +"SharedContentTitle" = "Shared Content"; +"SharedFilesTab" = "FILES"; +"SharedFilesTab2" = "Files"; +"SharedGIFsTab2" = "GIFs"; +"SharedGroupsTab2" = "Groups"; +"SharedLinks" = "Links"; +"SharedLinksTab" = "LINKS"; +"SharedLinksTab2" = "Links"; +"SharedMedia" = "Shared Media"; +"SharedMediaFastScrollHint" = "You can hold and move this bar for faster scrolling."; +"SharedMediaTab" = "MEDIA"; +"SharedMediaTab2" = "Media"; +"SharedMediaTabFull2" = "Shared Media"; +"SharedMediaTitle" = "Shared Media"; +"SharedMusicTab" = "MUSIC"; +"SharedMusicTab2" = "Music"; +"SharedPhotosAndVideos" = "Photos and videos"; +"SharedPlace" = "Place"; +"SharedPollTab" = "Polls"; +"SharedToPrivateMessagesAndGroups" = "to private messages and groups"; +"SharedVoiceTab" = "VOICE"; +"SharedVoiceTab2" = "Voice"; +"Shares#one" = "%1$s share"; +"Shares#other" = "%1$s shares"; +"SharesPerPost" = "Shares Per Post"; +"SharesPerStory" = "Shares Per Story"; +"SharingLiveLocation" = "Sharing Location..."; +"SharingLiveLocationAdd" = "tap to add more time"; +"SharingLiveLocationTitle" = "You are sharing your Live Location with %1$s"; +"SharingOfferCancelYes" = "Yes"; +"SharingOfferDisable1" = "\\> no forwarding messages"; +"SharingOfferDisable2" = "\\> no saving photos and videos"; +"SharingOfferDisable3" = "\\> no copying messages"; +"SharingOfferDisable4" = "\\> no screenshots"; +"SharingOfferDisableCancelText" = "Are you sure you want to keep sharing disabled?"; +"SharingOfferDisableCancelTitle" = "Disable Sharing"; +"SharingOfferDisableConfirmText" = "Are you sure you want to disable sharing?"; +"SharingOfferDisableHeaderOther" = "**%s** would like to disable sharing in this chat, which includes:"; +"SharingOfferDisableHeaderShortOther" = "**%s** would like to disable sharing in this chat"; +"SharingOfferDisableHeaderShortYou" = "You suggested to disable sharing in this chat"; +"SharingOfferDisableHeaderYou" = "You suggested to disable sharing in this chat, which includes:"; +"SharingOfferEnable1" = "\\> forwarding messages"; +"SharingOfferEnable2" = "\\> saving photos and videos"; +"SharingOfferEnable3" = "\\> copying messages"; +"SharingOfferEnable4" = "\\> taking screenshots"; +"SharingOfferEnableCancelText" = "Are you sure you want to keep sharing enabled?"; +"SharingOfferEnableCancelTitle" = "Enable Sharing"; +"SharingOfferEnableConfirmText" = "Are you sure you want to enable sharing?"; +"SharingOfferEnableHeaderOther" = "**%s** would like to enable sharing in this chat, which includes:"; +"SharingOfferEnableHeaderShortOther" = "**%s** would like to enable sharing in this chat"; +"SharingOfferEnableHeaderShortYou" = "You suggested to enable sharing in this chat"; +"SharingOfferEnableHeaderYou" = "You suggested to enable sharing in this chat, which includes:"; +"SharingYouAndOtherName" = "You and %1$s"; +"Sharpen" = "Sharpen"; +"Short" = "Short"; +"ShortHighQuality" = "HD"; +"ShortHoursAgo#one" = "%dh"; +"ShortHoursAgo#other" = "%dh"; +"ShortMessageLifetimeForever" = "Off"; +"ShortMinutesAgo#one" = "%dm"; +"ShortMinutesAgo#other" = "%dm"; +"ShortNow" = "now"; +"ShortNumberInfo" = "The number you provided is too short for **%1$s**:\n\n**%2$s**\n\nPlease enter the correct number including your country code."; +"ShortToday" = "today"; +"ShortYesterday" = "yesterday"; +"Show" = "Show"; +"ShowAds" = "Do Not Hide Ads"; +"ShowAdsInfo" = "As a Premium subscriber, you don’t see any ads on Telegram, but you can turn them on, for example, to view your own ads that you launched on the **Telegram Ad Platform >**"; +"ShowAdsTitle" = "Ads in Channels"; +"ShowAllFiles" = "Show all files"; +"ShowAllGIFs" = "Show all GIFs"; +"ShowAllMedia" = "Show All Media"; +"ShowAllThemes" = "Show all Themes"; +"ShowAsGrid" = "Show as grid"; +"ShowAsList" = "Show as list"; +"ShowCaption" = "Show Caption"; +"ShowForwardingOptions" = "Show forwarding options"; +"ShowInChat" = "Show in chat"; +"ShowInChats" = "Show in Chats"; +"ShowLess" = "Show less"; +"ShowMore" = "Show more"; +"ShowMoreEmojiPacks" = "Show More Emoji"; +"ShowMoreStickers" = "Show More Stickers"; +"ShowNearbyPlaces" = "Pull up to see places nearby"; +"ShowNotificationsFor" = "Show notifications from"; +"ShowNotificationsForInfo" = "Turn this off if you want to receive notifications only from the account you are currently using."; +"ShowOriginalButton" = "Show Original"; +"ShowOriginalButtonLanguage" = "Show Original (%s)"; +"ShowSenderNames" = "Show Sender Names"; +"ShowSendersName" = "Show Sender Name"; +"ShowSensitiveContent" = "Show 18+ Content"; +"ShowSensitiveContentInfo" = "Do not hide media that contains content suitable only for adults"; +"ShowStickers" = "Show stickers"; +"ShowTranslateButton" = "Show Translate Button"; +"ShowTranslateChatButton" = "Translate Entire Chats"; +"ShowTranslateChatButtonLocked" = "Subscribe to **Telegram Premium** to be able to translate all messages in a chat at once."; +"ShowVotes#one" = "Show More (%1$d)"; +"ShowVotes#other" = "Show More (%1$d)"; +"Shuffle" = "Shuffle"; +"ShuffleList" = "Shuffle List"; +"SignInWithGoogle" = "Sign in with Google"; +"SignUp" = "Sign Up"; +"SimilarBots" = "Similar Bots"; +"SimilarBotsTab" = "Similar Bots"; +"SimilarChannels" = "Similar Channels"; +"SimilarChannelsLimitSubtitle" = "View up to %d similar channels"; +"SimilarChannelsLimitTitle" = "Similar Channels"; +"SimilarChannelsTab" = "Similar Channels"; +"SinglePhotosHelp" = "Show media as separate messages"; +"SlideToCancel" = "SLIDE TO CANCEL"; +"SlideToCancel2" = "Slide to cancel"; +"SlideUpToLock" = "Slide up to lock recording"; +"SlowModeHint" = "Slow Mode is active. You can send\nyour next message in %1$s."; +"Slowmode" = "Slow Mode"; +"SlowmodeHours" = "%1$dh"; +"SlowmodeInfoOff" = "Choose how long each member must wait before sending their next message."; +"SlowmodeInfoSelected" = "Members will be able to send only one message every %1$s."; +"SlowmodeMinutes" = "%1$dm"; +"SlowmodeOff" = "Off"; +"SlowmodeSeconds" = "%1$ds"; +"SlowmodeSelectSendError" = "Slow Mode is enabled. You can't select more items."; +"SlowmodeSendError" = "Slow Mode is active. You can't send more than one message at once."; +"SlowmodeSendErrorTooLong" = "This text is too long to send as one message.\n\nSlow Mode is active. You can't send more than one message at once."; +"SmartNotifications" = "Smart Notifications"; +"SmartNotificationsAlert" = "Sound Alert Frequency"; +"SmartNotificationsDetail" = "%1$s within %2$s"; +"SmartNotificationsDisabled" = "Disable"; +"SmartNotificationsInfo" = "%1$d / %2$s"; +"SmsActiveSim" = "Active SIM"; +"SmsAirplaneMode" = "Airplane mode"; +"SmsAllowInternational" = "Also send SMS outside my country"; +"SmsAvailableIn" = "You can request an SMS in %1$d:%2$02d"; +"SmsAvailableIn2" = "You can request another\nSMS in %1$d:%2$02d"; +"SmsCostsInfo" = "Please refer to your mobile plan for any SMS costs that you may incur"; +"SmsDeactivate" = "Deactivate Premium for SMS"; +"SmsDeactivateMessage" = "Are you sure you want to leave the program?"; +"SmsDeactivateTitle" = "Deactivate Premium for SMS"; +"SmsDescrRESULT_ERROR_NO_SERVICE" = "Your device hasn't had contact with cell phone network."; +"SmsDescrRESULT_ERROR_RADIO_OFF" = "Your device has been in airplane mode."; +"SmsDescrRESULT_RIL_NETWORK_ERR" = "Network error. Make sure your SIM card is active."; +"SmsHistory" = "SMS History"; +"SmsHistoryDateCountry" = "Date and Country"; +"SmsHistoryStatus" = "Status"; +"SmsHistoryStatusFailure" = "Failed"; +"SmsHistoryStatusSuccess" = "Sent"; +"SmsHistorySubtitle" = "Here you can see all the SMS automatically sent from your device."; +"SmsHistoryTitle" = "Sent SMS History"; +"SmsJobsErrorHintMessage" = "Review SMS history to troubleshoot"; +"SmsJobsErrorHintTitle" = "Some SMS were not sent successfully"; +"SmsJobsMenu" = "SMS Statistics"; +"SmsJobsPremiumHintMessage" = "Help Telegram send SMS codes and get a free Premium subscription."; +"SmsJobsPremiumHintTitle" = "Telegram Premium **for free**"; +"SmsLastGiftLink" = "Last Gift Link Receipt"; +"SmsNoSimMessage" = "Insert a SIM card into the device and come back to **SMS Statistics**."; +"SmsNoSimTitle" = "No SIM card"; +"SmsOverview" = "Overview"; +"SmsPermissionText" = "To join the **Premium for SMS** program, please allow Telegram to send out SMS from your device"; +"SmsPermissionTextPhoneSettings" = "To join the **Premium for SMS** program, please allow Telegram to read phone SIM card info.\n\nTap Settings > Permissions, and turn **Phone** permission on."; +"SmsPermissionTextSMSPhoneSettings" = "To join the **Premium for SMS** program, please allow Telegram to send out SMS and read phone SIM card info.\n\nTap Settings > Permissions, and turn **SMS** and **Phone** permissions on."; +"SmsPermissionTextSMSSettings" = "To join the **Premium for SMS** program, please allow Telegram to send out SMS from your device.\n\nTap Settings > Permissions, and turn **SMS** on."; +"SmsPremiumActivated" = "Premium for SMS activated!"; +"SmsPremiumActivatedText" = "Telegram will now try to use your device to send SMS codes, words and phrases in your country (**%s**)."; +"SmsPremiumActivatedText2" = "Check **SMS Statistics** in the left-side menu to see how many SMS have been sent or to change your settings for this Program."; +"SmsPremiumActivatedTextUnknown" = "Telegram will now try to use your device to send SMS codes, words and phrases in your country."; +"SmsPremiumBenefits" = "Benefits of Premium"; +"SmsRemaining" = "SMS Remaining"; +"SmsSelectSim" = "Select SIM card"; +"SmsSentSince" = "Sent Since"; +"SmsStartDate" = "Start Date"; +"SmsStatusDone" = "All %d SMS Sent!"; +"SmsStatusDoneSubtitle" = "Telegram Premium gift-code is sent for you to activate."; +"SmsStatusFirst" = "Premium for SMS"; +"SmsStatusFirstSubtitle" = "Help Telegram send SMS login codes, words and phrases and get **Telegram Premium** gift links."; +"SmsStatusNoPermission" = "Access Required"; +"SmsStatusNoPermissionSubtitle" = "To join this program, please allow Telegram to send SMS in your phone's Settings."; +"SmsStatusNoSim" = "Insert a SIM card"; +"SmsStatusNoSimSubtitle" = "To join this program, please insert a SIM card into the device to start sending SMS."; +"SmsStatusSending" = "%1$d of %2$d SMS Sent"; +"SmsStatusSendingSubtitle#other" = "Only **%d** SMS left to send to receive your **Telegram Premium** gift link."; +"SmsSubscribeAccept" = "I accept the **Terms of Service for P2PLP**"; +"SmsSubscribeActivate" = "Activate"; +"SmsSubscribeActivateText" = "Sending SMS can result in charges. Please read **Terms of Service for Peer-to-Peer Login Program**."; +"SmsSubscribeFeature1Message#other" = "Let Telegram use your phone to send %d login SMS per month."; +"SmsSubscribeFeature1Title" = "Help Send Login SMS"; +"SmsSubscribeFeature2Message" = "Receive a 1-month **Premium subscription** each month."; +"SmsSubscribeFeature2Title" = "Get Telegram Premium"; +"SmsSubscribeFeature3Message" = "Send gift links granting Premium subscriptions to your friends."; +"SmsSubscribeFeature3Title" = "Share Premium with Friends"; +"SmsSubscribeMessage" = "You are one of **0.01%** users eligible to take part in Telegram's **Premium for SMS** initiative!"; +"SmsSubscribeTitle" = "Premium for Free"; +"SmsText" = "Sending you an SMS in %1$d:%2$02d"; +"SmsToS" = "Terms of Service"; +"SmsTotalSent" = "Total SMS Sent"; +"SoftUserLimitAlert" = "You will be able to add more members after you create the group."; +"SoftenSkin" = "Soften Skin"; +"SomeReactions" = "Some reactions"; +"SortBy" = "Sort By"; +"SortByReactions" = "Reactions First"; +"SortByReposts" = "Reposts First"; +"SortByTime" = "Newest First"; +"SortFirstName" = "First name"; +"SortLastName" = "Last name"; +"SortedByLastSeen" = "Sorted by last seen time"; +"SortedByName" = "Sorted by name"; +"Sound" = "Sound"; +"SoundAdded" = "Sound added"; +"SoundAddedSubtitle" = "You can use this sound as a notification tone in your **custom notification settings**."; +"SoundDefault" = "Default"; +"SoundMuted" = "Sound muted"; +"SoundNameEmpty" = "Message dated %s"; +"SoundOff" = "Disable sound"; +"SoundOffHint" = "You will receive silent notifications. "; +"SoundOn" = "Enable sound"; +"SoundOnHint" = "You will receive notifications with sound."; +"Speaking" = "speaking"; +"SpeakingWithVolume" = "%1$d%% speaking"; +"Speed" = "Speed"; +"SpeedFast" = "Fast"; +"SpeedHint" = "Long press for more speed options."; +"SpeedMedium" = "Medium"; +"SpeedNormal" = "Normal"; +"SpeedSlow" = "Slow"; +"SpeedSuperFast" = "Super Fast"; +"SpeedVeryFast" = "Very fast"; +"SpeedVerySlow" = "Very slow"; +"Spoiler" = "Spoiler"; +"SponsoredMessage" = "ad"; +"SponsoredMessage2" = "Ad"; +"SponsoredMessage2Recommended" = "Recommended"; +"SponsoredMessageAd" = "Ad"; +"SponsoredMessageAdWhatIsThis" = "what's this?"; +"SponsoredMessageAlertLearnMore" = "LEARN MORE"; +"SponsoredMessageAlertLearnMoreUrl" = "https://ads.telegram.org"; +"SponsoredMessageAlertText" = "Unlike other apps, Telegram never uses your private data to target ads. No user data is mined or analyzed to display ads, and every user viewing a channel on Telegram sees the same sponsored message. We believe that everyone has the right to privacy, and technological platforms should respect that."; +"SponsoredMessageAlertTitle" = "Sponsored messages"; +"SponsoredMessageInfo" = "What are sponsored\nmessages?"; +"SponsoredMessageInfo2Description1" = "Unlike other apps, Telegram never uses your private data to target ads. [Learn more in the Privacy Policy](https://telegram.org/privacy#5-6-no-ads-based-on-user-data)"; +"SponsoredMessageInfo2Description2" = "Unlike other apps, Telegram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can’t spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that."; +"SponsoredMessageInfo2Description3" = "Telegram offers free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Telegram developed a paid tool to promote messages with user privacy in mind. We welcome responsible advertisers at:"; +"SponsoredMessageInfo2Description4" = "Ads should no longer be synonymous with abuse of user privacy. Let us redefine how a tech company should operate – together."; +"SponsoredMessageInfoDescription1" = "Unlike other apps, Telegram never uses your private data to target ads. Sponsored messages on Telegram are based solely on the topic of the public channels in which they are shown. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on Telegram sees the same sponsored messages."; +"SponsoredMessageInfoDescription2" = "Unlike other apps, Telegram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can’t spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that."; +"SponsoredMessageInfoDescription3" = "Telegram offers a free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Telegram developed a paid tool to promote messages with user privacy in mind. We welcome responsible advertisers at:"; +"SponsoredMessageInfoDescription4" = "Sponsored Messages are currently in test mode. Once they are fully launched and allow Telegram to cover its basic costs, we will start sharing ad revenue with the owners of public channels in which sponsored messages are displayed.\n\nOnline ads should no longer be synonymous with abuse of user privacy. Let us redefine how a tech company should operate – together."; +"SponsoredMessageInfoDescriptionLink" = "Unlike other apps, Telegram never uses your private data to target ads. **Learn more in the Privacy Policy**"; +"SponsoredMessageRecommended" = "recommended"; +"SponsoredMessageSponsor" = "Advertiser Info"; +"SponsoredMessageSponsorReportable" = "Sponsor Info"; +"StakeDiceActionLost" = "un1 lost 💎 %s"; +"StakeDiceActionWon" = "un1 won 💎 %s"; +"StakeDiceActionYouLost" = "You lost 💎 %s"; +"StakeDiceActionYouWon" = "You won 💎 %s"; +"StakeDiceButton" = "Save and Roll"; +"StakeDicePlaceholder" = "Your Stake"; +"StakeDiceReturns" = "Results and Returns"; +"StakeDiceReturnsInfo" = "A streak resets after 3 🎲 or a stake change."; +"StakeDiceText" = "An emoji game for users with\u00a0Level 5\u00a0or higher. 💎 earned in the game can only be spent on Telegram gifts and posts."; +"StakeDiceTitle" = "Emoji Stake"; +"StakeDiceTitleBeta" = "Beta"; +"StakeDiceToast" = "Your Stake: 💎 "; +"StakeDiceToastButton" = "Send"; +"StakeDiceToastChange" = "change"; +"StarAffiliate" = "Affiliate"; +"StarAffiliateCommission" = "Commission"; +"StarAffiliateMiniApp" = "Mini App"; +"StarAffiliateReason" = "Reason"; +"StarAffiliateReasonProgram" = "Affiliate Program"; +"StarAffiliateReferredUser" = "Referred User"; +"StarGiftReason" = "Reason"; +"StarGiftReasonOffer" = "Gift Offer"; +"StarGiftReasonOfferRefund" = "Gift Offer Refund"; +"StarGiftReasonPurchase" = "Gift Purchase"; +"StarGiftReasonRemovedDescription" = "Removed Description"; +"StarGiftReasonSale" = "Gift Sale"; +"StarGiftReasonTransfer" = "Gift Transferred"; +"StarGiftReasonUpgrade" = "Gift Upgraded"; +"StarGiftReasonUpgradeView" = "view"; +"StarGiftTransactionGiftOffer" = "Gift Offer"; +"StarGiftTransactionGiftOfferRefund" = "Refunded Gift Offer"; +"StarGiftTransactionGiftPurchase" = "Gift Purchase"; +"StarGiftTransactionGiftPurchaseRefund" = "Refunded Gift Purchase"; +"StarGiftTransactionGiftSale" = "Gift Sale"; +"StarGiftTransactionGiftSaleRefund" = "Refunded Gift Sale"; +"StarGiftTransactionGiftTransfer" = "Gift Transfer"; +"StarGiftTransactionGiftTransferRefund" = "Refunded Gift Transfer"; +"StarGiftUpgradeGiftFrom" = "Gift From"; +"StarGiveawayGift" = "Gift"; +"StarGiveawayPrizeFrom" = "From"; +"StarGiveawayPrizeTo" = "To"; +"StarGiveawayReason" = "Reason"; +"StarGiveawayReasonLink" = "Giveaway"; +"StarMediaPurchase" = "Media Purchase"; +"StarMySubscriptions" = "My subscriptions"; +"StarMySubscriptionsExpand" = "Show More"; +"StarRatingAdded" = "ADDED"; +"StarRatingButtonUnderstood" = "Understood"; +"StarRatingDeduces" = "DEDUCTED"; +"StarRatingDescription" = "This rating reflects **%s's** activity\non Telegram. It is based on:"; +"StarRatingDescription1" = "%s 100% of the Stars spent on gifts purchased from Telegram."; +"StarRatingDescription2" = "%s 20% of the Stars spent on resold gifts, paid messages and channel posts."; +"StarRatingDescription3" = "%s 10x refunded Stars and 85% of gifts converted back to Stars."; +"StarRatingFuture#one" = "This rating will update in %d day."; +"StarRatingFuture#other" = "This rating will update in %d days."; +"StarRatingFuturePendingPoints#one" = "%d point is pending."; +"StarRatingFuturePendingPoints#other" = "%d points are pending."; +"StarRatingFuturePendingPointsAndLevel1#one" = "%d point and"; +"StarRatingFuturePendingPointsAndLevel1#other" = "%d points and"; +"StarRatingFuturePendingPointsAndLevel2#one" = "Level %d are pending."; +"StarRatingFuturePendingPointsAndLevel2#other" = "Level %d are pending."; +"StarRatingFuturePendingPointsPreview" = "**Preview >**"; +"StarRatingFuturePendingPointsPreviewBack" = "**Back >**"; +"StarRatingFuturePreview1#one" = "This will be your rating in %d day, after"; +"StarRatingFuturePreview1#other" = "This will be your rating in %d days, after"; +"StarRatingFuturePreview2#one" = "%d point are added."; +"StarRatingFuturePreview2#other" = "%d points are added."; +"StarRatingLevel" = "Level %d"; +"StarRatingLevelNegative" = "Negative Rating"; +"StarRatingLevelNegativeOther" = "A negative rating indicates that **%1$s’s** payments are unreliable."; +"StarRatingLevelNegativeYou#one" = "A negative rating indicates that your payments are unreliable. Spend **%1$d Star** to fix this issue."; +"StarRatingLevelNegativeYou#other" = "A negative rating indicates that your payments are unreliable. Spend **%1$d Stars** to fix this issue."; +"StarRatingSelfDescription" = "This rating reflects your activity\non Telegram. It is based on:"; +"StarRatingTitle" = "Rating"; +"StarRatingTitle1" = "Gifts from Telegram"; +"StarRatingTitle2" = "Gifts and Posts from Users"; +"StarRatingTitle3" = "Refunds and Conversions"; +"StarSubscriptionPurchase" = "Monthly subscription fee"; +"StarSubscriptionTo" = "To"; +"StarTransactionCommission" = "%s Commission"; +"Stars#one" = "%d Star"; +"Stars#other" = "%d Stars"; +"StarsAcquired" = "Stars Acquired!"; +"StarsAcquiredInfo#other" = "**%d Stars** added to your balance."; +"StarsAddHighlightedMessage" = "Add ⭐️%s"; +"StarsBalance" = "Balance"; +"StarsBotSubscriptionCompleted" = "Subscription purchased!"; +"StarsBotSubscriptionCompletedInfo#one" = "You subscribed to \"**%2$s**\" in **%3$s** for **%1$d Star**/month."; +"StarsBotSubscriptionCompletedInfo#other" = "You subscribed to \"**%2$s**\" in **%3$s** for **%1$d Stars**/month."; +"StarsBuy" = "Buy Stars"; +"StarsBuyMore" = "Buy More Stars"; +"StarsConfirmPurchaseButton#one" = "Confirm and Pay ⭐️ %d Star"; +"StarsConfirmPurchaseButton#other" = "Confirm and Pay ⭐️ %d Stars"; +"StarsConfirmPurchaseMedia1" = "Do you want to unlock %1$s in **%2$s** for **%3$s**?"; +"StarsConfirmPurchaseMedia2" = "Do you want to unlock %1$s and %2$s in **%3$s** for **%4$s**?"; +"StarsConfirmPurchaseMediaBotOne2#one" = "Do you want to unlock %2$s from **%3$s** for **%1$d star**?"; +"StarsConfirmPurchaseMediaBotOne2#other" = "Do you want to unlock %2$s from **%3$s** for **%1$d stars**?"; +"StarsConfirmPurchaseMediaBotTwo2#one" = "Do you want to unlock %2$s and %3$s from **%4$s** for **%1$d star**?"; +"StarsConfirmPurchaseMediaBotTwo2#other" = "Do you want to unlock %2$s and %3$s from **%4$s** for **%1$d stars**?"; +"StarsConfirmPurchaseMediaOne" = "Do you want to unlock %2$s in **%3$s** for **%1$d Stars**?"; +"StarsConfirmPurchaseMediaOne2#one" = "Do you want to unlock %2$s in **%3$s** for **%1$d star**?"; +"StarsConfirmPurchaseMediaOne2#other" = "Do you want to unlock %2$s in **%3$s** for **%1$d stars**?"; +"StarsConfirmPurchaseMediaTwo" = "Do you want to unlock %2$s and %3$s in **%4$s** for **%1$d Stars**?"; +"StarsConfirmPurchaseMediaTwo2#one" = "Do you want to unlock %2$s and %3$s in **%4$s** for **%1$d Star**?"; +"StarsConfirmPurchaseMediaTwo2#other" = "Do you want to unlock %2$s and %3$s in **%4$s** for **%1$d Stars**?"; +"StarsConfirmPurchaseMedia_Photos#one" = "**%d photo**"; +"StarsConfirmPurchaseMedia_Photos#other" = "**%d photos**"; +"StarsConfirmPurchaseMedia_SinglePhoto" = "a **photo**"; +"StarsConfirmPurchaseMedia_SingleVideo" = "a **video**"; +"StarsConfirmPurchaseMedia_Videos#one" = "**%s video**"; +"StarsConfirmPurchaseMedia_Videos#other" = "**%s videos**"; +"StarsConfirmPurchaseTOS" = "By purchasing you agree to the **Terms of Service**."; +"StarsConfirmPurchaseText#one" = "Do you want to buy \"**%2$s**\" in **%3$s** for **%1$d star**?"; +"StarsConfirmPurchaseText#other" = "Do you want to buy \"**%2$s**\" in **%3$s** for **%1$d stars**?"; +"StarsConfirmPurchaseText2#one" = "Do you want to buy **%2$s** in **%3$s** for **%1$d** star?"; +"StarsConfirmPurchaseText2#other" = "Do you want to buy **%2$s** in **%3$s** for **%1$d** stars?"; +"StarsConfirmPurchaseTitle" = "Confirm Your Purchase"; +"StarsConfirmSubscriptionButton#other" = "Subscribe for ⭐️ %d / month"; +"StarsConfirmSubscriptionTOS" = "By subscribing you agree to the **Terms of Service**."; +"StarsConfirmSubscriptionText2#one" = "Do you want to subscribe to **%2$s** in **%3$s** for **%1$d** Star per month?"; +"StarsConfirmSubscriptionText2#other" = "Do you want to subscribe to **%2$s** in **%3$s** for **%1$d** Stars per month?"; +"StarsConfirmSubscriptionTitle" = "Confirm Your Subscription"; +"StarsCount#one" = "%d Star"; +"StarsCount#other" = "%d Stars"; +"StarsCountX" = "%s Stars"; +"StarsFailed" = "Failed"; +"StarsGiftCompleted" = "Gift sent!"; +"StarsGiftCompletedChannelText#one" = "You sent a gift to **%2$s** for **%1$d** Star."; +"StarsGiftCompletedChannelText#other" = "You sent a gift to **%2$s** for **%1$d** Stars."; +"StarsGiftCompletedText#one" = "You spent **%1$d** Star from your balance."; +"StarsGiftCompletedText#other" = "You spent **%1$d** Stars from your balance."; +"StarsGiftReceived" = "Received Gift"; +"StarsGiftSent" = "Sent Gift"; +"StarsGiftSentPopup" = "Stars Gift Sent!"; +"StarsGiftSentPopupInfo#one" = "**%1$d Star** sent as gift to %2$s."; +"StarsGiftSentPopupInfo#other" = "**%1$d Stars** sent as gift to %2$s."; +"StarsGiftUpgradeCompleted" = "Gift Upgrade sent!"; +"StarsGiftUpgradeCompletedMoreButton" = "Gift More"; +"StarsGiftUpgradeCompletedText" = "%s can now upgrade this gift for free."; +"StarsGiveawayPrizeReceived" = "Received Prize"; +"StarsGiveawaySentPopup" = "Giveaway Started!"; +"StarsGiveawaySentPopupInfo#one" = "Winners will receive a total of **%1$d Star**."; +"StarsGiveawaySentPopupInfo#other" = "Winners will receive a total of **%1$d Stars**."; +"StarsMediaPurchaseCompleted" = "Media Unlocked"; +"StarsMediaPurchaseCompletedInfo#one" = "**%1$d Star** transferred to **%2$s**."; +"StarsMediaPurchaseCompletedInfo#other" = "**%1$d Stars** transferred to **%2$s**."; +"StarsNano" = "Stars"; +"StarsNeededBizText" = "Buy **Stars** and use them on **%s** and businesses."; +"StarsNeededLiveComments" = "Buy **Stars** to highlight comments in Live Stories and send Star Reactions."; +"StarsNeededRemoveGiftDescription" = "Buy **Stars** to remove gift descriptions."; +"StarsNeededText" = "Buy **Stars** and use them in **%s** and other mini apps."; +"StarsNeededTextBuySubscription" = "Buy **Stars** and subscribe to **%s** and other channels."; +"StarsNeededTextGift" = "Buy **Stars** to send gifts to **%s** and other contacts."; +"StarsNeededTextGiftBuyResale" = "Buy **Stars** to spend on gifts."; +"StarsNeededTextGiftChannel" = "Buy **Stars** to send gifts to **%s** and other channels."; +"StarsNeededTextGiftTransfer" = "Buy **Stars** to transfer your gifts."; +"StarsNeededTextGiftUpgrade" = "Buy **Stars** to upgrade your gifts."; +"StarsNeededTextKeepBizSubscription" = "Buy **Stars** to keep your subscription to **%s**."; +"StarsNeededTextKeepBotSubscription" = "Buy **Stars** to keep your subscription to **%s**."; +"StarsNeededTextKeepSubscription" = "Buy **Stars** to keep your subscription to **%s**."; +"StarsNeededTextLink" = "Buy **Stars** to keep your channel subscriptions."; +"StarsNeededTextLink_subadvance12" = "Buy **Stars** to keep your subscriptions active for the next 12 months."; +"StarsNeededTextLink_subs" = "Buy **Stars** to keep your channel subscriptions."; +"StarsNeededTextPrivateMessage" = "Buy **Stars** to send messages to **%s** and others."; +"StarsNeededTextReactions" = "Buy **Stars** to send paid reactions to **%s** and other channels."; +"StarsNeededTextSearch" = "Buy **Stars** to search posts."; +"StarsNeededTitle#one" = "%d Star Needed"; +"StarsNeededTitle#other" = "%d Stars Needed"; +"StarsNotAvailableText" = "Sorry, purchasing Stars is not available in your country."; +"StarsNotAvailableTitle" = "Not Available"; +"StarsParticipantSubscription" = "Subscriber"; +"StarsParticipantSubscriptionApproxMonth" = "appx. %1$s per month"; +"StarsParticipantSubscriptionExpired" = "Expired"; +"StarsParticipantSubscriptionPerMonth" = "per month"; +"StarsParticipantSubscriptionRenews" = "Renews"; +"StarsParticipantSubscriptionStart" = "Subscribed"; +"StarsPending" = "Pending"; +"StarsPurchaseCompleted" = "Purchase Completed!"; +"StarsPurchaseCompletedInfo#other" = "You acquired \"**%2$s**\" in **%3$s** for **%1$d Stars**."; +"StarsReactionAnonymous" = "Anonymous"; +"StarsReactionSend" = "Send ⭐️%s"; +"StarsReactionShowMeInTopSenders" = "Show me in Top Senders"; +"StarsReactionTerms" = "By sending Stars you agree to the **Terms of Service**."; +"StarsReactionTermsLink" = "https://telegram.org/tos/stars"; +"StarsReactionText" = "Choose how many Stars you want to send to **%s** to support this post."; +"StarsReactionTextSent#one" = "You sent **%1$d** Star to support this post."; +"StarsReactionTextSent#other" = "You sent **%1$d** Stars to support this post."; +"StarsReactionTitle" = "React with Stars"; +"StarsReactionTitle2" = "Star Reaction"; +"StarsReactionTop" = "TOP"; +"StarsReactionTopSenders" = "Top Senders"; +"StarsReactionTopX#one" = "TOP %d"; +"StarsReactionTopX#other" = "TOP %d"; +"StarsReactionsDisabled" = "Star Reactions were disabled on **%s**."; +"StarsReactionsSent" = "Star Reaction"; +"StarsRefunded" = "Refund"; +"StarsSentAnonymouslyTitle" = "Stars sent anonymously!"; +"StarsSentText#one" = "You reacted with **%d** star."; +"StarsSentText#other" = "You reacted with **%d** stars."; +"StarsSentTitle" = "Stars sent!"; +"StarsSentTitleChannel" = "Stars sent as %s!"; +"StarsSentUndo" = "Undo"; +"StarsStats" = "Stats"; +"StarsSubscribeButton" = "Subscribe"; +"StarsSubscribeInfo" = "By subscribing you agree to the **Terms of Service**."; +"StarsSubscribeInfoLink" = "https://telegram.org/tos/stars"; +"StarsSubscribeText#one" = "Do you want to subscribe to **%2$s** for **%1$d Star** per month?"; +"StarsSubscribeText#other" = "Do you want to subscribe to **%2$s** for **%1$d Stars** per month?"; +"StarsSubscribeTextTest#one" = "Do you want to subscribe to **%2$s** for **%1$d Star** per %3$s?"; +"StarsSubscribeTextTest#other" = "Do you want to subscribe to **%2$s** for **%1$d Stars** per %3$s?"; +"StarsSubscribeTitle" = "Subscribe to the Channel"; +"StarsSubscriptionAgain" = "Subscribe Again"; +"StarsSubscriptionBot" = "Bot"; +"StarsSubscriptionBotCancelledText" = "Bot has cancelled your subscription."; +"StarsSubscriptionBotProduct" = "Subscription"; +"StarsSubscriptionBotRefulfill" = "Renew Subscription"; +"StarsSubscriptionBotRefulfillInfo" = "You can still renew your subscription until %s."; +"StarsSubscriptionBusiness" = "Business"; +"StarsSubscriptionBusinessCancelledText" = "Business has cancelled your subscription."; +"StarsSubscriptionBusinessProduct" = "Subscription"; +"StarsSubscriptionCancel" = "Cancel Subscription"; +"StarsSubscriptionCancelInfo" = "If you cancel now, you will still be able to access your subscription until %s."; +"StarsSubscriptionCancelledBizToastText" = "You will still have access to **%2$s** until **%1$s**."; +"StarsSubscriptionCancelledBotToastText" = "You will still have access to **%2$s** until **%1$s**."; +"StarsSubscriptionCancelledText" = "You have canceled your subscription."; +"StarsSubscriptionCancelledToast" = "Subscription canceled"; +"StarsSubscriptionCancelledToastText" = "You will still have access to the channel until **%s**."; +"StarsSubscriptionChannel" = "Subscription"; +"StarsSubscriptionCompleted" = "Subscription successful!"; +"StarsSubscriptionCompletedText#one" = "%1$d Star transferred to **%2$s**."; +"StarsSubscriptionCompletedText#other" = "%1$d Stars transferred to **%2$s**."; +"StarsSubscriptionExpired" = "expired on %s"; +"StarsSubscriptionExpiredHintText" = "Insufficient funds to cover your subscription."; +"StarsSubscriptionExpiredHintTitle" = "⭐️%1$d Stars needed for %2$s"; +"StarsSubscriptionExpiredHintTitle2#one" = "⭐️%1$d Star needed for %2$s"; +"StarsSubscriptionExpiredHintTitle2#other" = "⭐️%1$d Stars needed for %2$s"; +"StarsSubscriptionExpiredInfo" = "Your subscription expired on %s."; +"StarsSubscriptionExpires" = "expires on %s"; +"StarsSubscriptionPrice" = "⭐️%1$d/month"; +"StarsSubscriptionRefulfill" = "Join Channel"; +"StarsSubscriptionRefulfillInfo" = "You left the channel, but can still return until %s."; +"StarsSubscriptionRenew" = "Renew Subscription"; +"StarsSubscriptionRenewedToast" = "Subscription renewed"; +"StarsSubscriptionRenewedToastText" = "You renewed your subscription to **%s**."; +"StarsSubscriptionRenews" = "renews on %s"; +"StarsSubscriptionSince" = "Subscribed"; +"StarsSubscriptionStatusBizCancelled" = "canceled"; +"StarsSubscriptionStatusBotCancelled" = "canceled"; +"StarsSubscriptionStatusCancelled" = "canceled"; +"StarsSubscriptionStatusExpired" = "expired"; +"StarsSubscriptionTitle" = "Subscription"; +"StarsSubscriptionUntilExpired" = "Expired"; +"StarsSubscriptionUntilExpires" = "Expires"; +"StarsSubscriptionUntilRenews" = "Renews"; +"StarsSuggestionOfferAcceptTerms" = "By publishing suggested post for Stars you agree to the **Terms of Service**."; +"StarsSuggestionOfferAcceptTermsLink" = "https://telegram.org/tos/stars"; +"StarsSuggestionTerms" = "By sending Stars you agree to the **Terms of Service**."; +"StarsSuggestionTermsLink" = "https://telegram.org/tos/stars"; +"StarsTOS" = "By proceeding and purchasing Stars, you agree with the **Terms and Conditions**."; +"StarsTOSLink" = "https://telegram.org/tos/stars"; +"StarsTopUp" = "Top Up"; +"StarsTopupLinkEnough" = "You have enough stars at the moment."; +"StarsTopupLinkTopupAnyway" = "Topup Anyway"; +"StarsTransactionAds" = "Ads"; +"StarsTransactionBot" = "Stars Top-Up Bot"; +"StarsTransactionDate" = "Date"; +"StarsTransactionFloodskip" = "Paid Broadcast"; +"StarsTransactionFloodskipMessages#one" = "%d Message"; +"StarsTransactionFloodskipMessages#other" = "%d Messages"; +"StarsTransactionFloodskipNumber#one" = "%d"; +"StarsTransactionFloodskipNumber#other" = "%d"; +"StarsTransactionFloodskipNumberName" = "Messages"; +"StarsTransactionFragment" = "Purchase from Fragment"; +"StarsTransactionFullPrice" = "Full Price"; +"StarsTransactionHidden" = "Hidden User"; +"StarsTransactionID" = "Transaction ID"; +"StarsTransactionIDCopied" = "Transaction ID copied to clipboard"; +"StarsTransactionInApp" = "In-App Purchase"; +"StarsTransactionLiveStoryMessageFee" = "Fee for Live Story Comment"; +"StarsTransactionLiveStoryReactionFee" = "Fee for Live Story Reaction"; +"StarsTransactionMedia" = "Media"; +"StarsTransactionMessage" = "Post"; +"StarsTransactionMessageFee#one" = "Fee for %d Message"; +"StarsTransactionMessageFee#other" = "Fee for %d Messages"; +"StarsTransactionMessageFeeInfo" = "You receive **%s** of the price that you charge for each incoming message."; +"StarsTransactionMessageFeeInfoLink" = "**Change Fee >**"; +"StarsTransactionPostsSearch" = "Public Post Search"; +"StarsTransactionPremiumGift" = "Telegram Premium"; +"StarsTransactionPremiumGiftDuration" = "Duration"; +"StarsTransactionRecipient" = "From"; +"StarsTransactionRemovedDescription" = "Removed Gift Description"; +"StarsTransactionSource" = "Via"; +"StarsTransactionSubscriptionMonthly" = "Monthly subscription fee"; +"StarsTransactionTONDate" = "Gram Transaction Date"; +"StarsTransactionTONFromFragment" = "Fragment"; +"StarsTransactionTONFromFragmentLink" = "https://fragment.com/ads/topup"; +"StarsTransactionTOS" = "Review the **Terms of Service** for Stars."; +"StarsTransactionUnknown" = "Unknown User"; +"StarsTransactionUnknownLink" = "https://fragment.com/stars"; +"StarsTransactionUnsupported" = "In-App Purchase"; +"StarsTransactionViewInBlockchainExplorer" = "View in Blockchain Explorer"; +"StarsTransactionWithdrawFragment" = "Withdraw to Fragment"; +"StarsTransactionsAll" = "All Transactions"; +"StarsTransactionsIncoming" = "Incoming"; +"StarsTransactionsOutgoing" = "Outgoing"; +"StarsWithdraw" = "Withdraw"; +"Start" = "Start"; +"StartDayAt" = "'Start on' MMM d 'at' HH:mm"; +"StartDayYearAt" = "'Start on' MMM d yyyy 'at' HH:mm"; +"StartEncryptedChat" = "Start Secret Chat"; +"StartMessaging" = "Start Messaging"; +"StartShortDayAt" = "MMM d',' HH:mm"; +"StartShortDayYearAt" = "MMM d yyyy',' HH:mm"; +"StartShortTodayAt" = "'Today,' HH:mm"; +"StartText" = "Please confirm your country code\nand enter your phone number."; +"StartTextPasskey" = "Please enter your phone number\nor **log in using passkey >**"; +"StartTodayAt" = "'Start today at' HH:mm"; +"StartVoipChannel" = "Start Live Stream"; +"StartVoipChannelAlertText" = "Do you want to start a live stream in this channel?"; +"StartVoipChannelTitle" = "Live Stream"; +"StartVoipChat" = "Start Video chat"; +"StartVoipChatAlertText" = "Start a video chat in this group?"; +"StartVoipChatPermission" = "Manage Live Streams"; +"StartVoipChatTitle" = "Video Chat"; +"StartsDayAt" = "'Starts on' MMM d 'at' HH:mm"; +"StartsDayYearAt" = "'Starts on' MMM d yyyy 'at' HH:mm"; +"StartsTodayAt" = "'Starts today at' HH:mm"; +"StatisticOpenProfile" = "Open Profile"; +"StatisticOverview" = "Overview"; +"StatisticSearchUserHistory" = "View Messages"; +"StatisticViews" = "Views"; +"Statistics" = "Statistics"; +"StatisticsAndBoosts" = "Statistics"; +"StatusHiddenShow" = "when?"; +"StealthMode" = "Stealth Mode"; +"StealthModeActiveHint" = "Stealth Mode active – %s"; +"StealthModeActiveHintShort" = "Stealth Mode %s"; +"StealthModeButton" = "Hide My View"; +"StealthModeConfirmMessage" = "If you send a reply or reaction, the creator of the story will see you in the list of viewers."; +"StealthModeConfirmTitle" = "You are in Stealth Mode now"; +"StealthModeCooldownHint" = "Please wait until **Stealth Mode** is ready to use again."; +"StealthModeHint" = "Turn Stealth Mode on to watch stories without appearing in the list of viewers."; +"StealthModeIsActive" = "Stealth Mode Is Active"; +"StealthModeOn" = "Stealth Mode On"; +"StealthModeOnHint" = "The creators of stories you viewed in the past 5 minutes or will view in the next 25 minutes won't see you in the list of viewers."; +"StealthModePremiumHint" = "Subscribe to Telegram Premium to watch stories without appearing in the list of viewers."; +"StealthModeTitle" = "Stealth Mode"; +"StickerCreateEmpty" = "Create sticker"; +"StickerEffects" = "Effects from stickers"; +"StickerOrEmojiGlobalSearchResult" = "Global Search Result"; +"StickerOrEmojiSearchResult" = "Search Results"; +"StickerSets#one" = "%1$s sticker set"; +"StickerSets#other" = "%1$s sticker sets"; +"Stickers#one" = "%1$d sticker"; +"Stickers#other" = "%1$d stickers"; +"StickersAddAnExistingSticker" = "Add an Existing Sticker"; +"StickersAndMasks" = "Stickers and Masks"; +"StickersArchived" = "Stickers archived."; +"StickersArchivedInfo" = "%1$s has been moved to your archive."; +"StickersBotInfo" = "Artists are welcome to add their own sticker sets using our @stickers bot."; +"StickersCheckStickersBotForMoreOptions" = "Check the **@stickers** bot for more options"; +"StickersChooseNameForStickerPack" = "Choose a name for your set."; +"StickersCopy" = "Copy link"; +"StickersCreateNewSticker" = "Create a New Sticker"; +"StickersDeleteForEveryone" = "Delete for everyone"; +"StickersDeleteStickerSetDescription" = "This will delete the sticker set for all users."; +"StickersDeleteStickerSetTitle" = "Delete Sticker Set"; +"StickersHide" = "Archive"; +"StickersName" = "Stickers and Emoji"; +"StickersNameInfo" = "Manage your sticker sets, emoji and reactions."; +"StickersNameInfo2" = "Manage stickers, emoji and reactions"; +"StickersRemove" = "Remove"; +"StickersRemoveForMe" = "Remove for me"; +"StickersRemoved" = "Stickers removed"; +"StickersRemovedInfo" = "%1$s is no longer in your stickers."; +"StickersReorder" = "Reorder"; +"StickersReorderHint" = "Drag stickers to reorder your sets."; +"StickersReplaceSticker" = "Update Sticker"; +"StickersSetEmojiForSticker" = "Choose emojis that match your sticker"; +"StickersSettings" = "Settings"; +"StickersShare" = "Share"; +"StickersStickerAddedToSetToast" = "Sticker added to the %1$s sticker set."; +"StickersStickerEditedInSetToast" = "Sticker edited in %1$s sticker set."; +"Stop" = "Stop"; +"StopAllLocationSharings" = "STOP ALL"; +"StopDownload" = "Stop downloading"; +"StopLiveLocation" = "Stop Sharing Location"; +"StopLiveLocationAlertAll" = "Are you sure you want to stop sharing your live location?"; +"StopLiveLocationAlertAllText" = "Do you want to stop sharing your live location?"; +"StopLiveLocationAlertToGroup" = "Are you sure you want to stop sharing live location with %1$s?"; +"StopLiveLocationAlertToGroupText" = "Do you want to stop sharing your live location with **%1$s**?"; +"StopLiveLocationAlertToTitle" = "Stop location sharing"; +"StopLiveLocationAlertToUser" = "Stop sharing your live location with %1$s?"; +"StopLiveLocationAlertToUserText" = "Do you want to stop sharing your live location with **%1$s**?"; +"StopLoading" = "Do you want to cancel loading?"; +"StopLoadingTitle" = "Stop loading?"; +"StopPoll" = "Stop Poll"; +"StopPollAlertText" = "If you stop this poll now, nobody will be able to vote in it anymore. This action cannot be undone."; +"StopPollAlertTitle" = "Stop poll?"; +"StopPollDeadlineButton" = "Set Deadline"; +"StopPollDeadlineHeader" = "Deadline"; +"StopQuiz" = "Stop Quiz"; +"StopQuizAlertText" = "If you stop this quiz now, nobody will be able to submit answers. This action cannot be undone."; +"StopQuizAlertTitle" = "Stop quiz?"; +"StopShowingMe" = "Stop Showing Me"; +"StopVerification" = "Do you want to stop the verification process?"; +"StorageCleared" = "Storage Cleared"; +"StorageCleared2" = "All media can be re-downloaded from the Telegram cloud if you need them again."; +"StoragePath" = "Storage Path"; +"StoragePathFree" = "%1$s free"; +"StoragePathFreeExternal" = "%1$s free, slow speed"; +"StoragePathFreeInternal" = "%1$s free, high speed"; +"StoragePathFreeValue" = "%1$s free, %2$s"; +"StoragePathFreeValueExternal" = "%1$s free, slow speed %2$s"; +"StorageUsage" = "Storage Usage"; +"StorageUsageCalculating" = "Calculating cache size..."; +"StorageUsageInfo" = "All media will stay in the Telegram cloud and can be re-downloaded if you need them again."; +"StorageUsageTelegram" = "Telegram uses %s of your device storage."; +"StorageUsageTelegramLess" = "Telegram uses %s of your device storage."; +"StoreCollageRetake" = "Retake"; +"Stories#one" = "%d Story"; +"Stories#other" = "%d Stories"; +"StoriesAddPost" = "Add a post"; +"StoriesAlbum" = "Album"; +"StoriesAlbumAddAlbum" = "Add Album"; +"StoriesAlbumAddHint" = "Choose a name for your album and add your stories."; +"StoriesAlbumAddToAlbum" = "Add to Album"; +"StoriesAlbumBottomButtonAddStories" = "Add Stories"; +"StoriesAlbumBottomButtonRemoveStoriesFromAlbum" = "Remove from Album"; +"StoriesAlbumCount#one" = "%d Album"; +"StoriesAlbumCount#other" = "%d Albums"; +"StoriesAlbumCreateNew" = "Create a New Album"; +"StoriesAlbumMenuAddStories" = "Add Stories"; +"StoriesAlbumMenuArchive" = "Archive"; +"StoriesAlbumMenuDeleteAlbum" = "Delete Album"; +"StoriesAlbumMenuDeleteAlbumAsk" = "Are you sure you want to delete the **%s** album?"; +"StoriesAlbumMenuEdit" = "Edit"; +"StoriesAlbumMenuEditName" = "Edit Name"; +"StoriesAlbumMenuPin" = "Pin"; +"StoriesAlbumMenuRemoveFromAlbum" = "Remove from Album"; +"StoriesAlbumMenuReorder" = "Reorder"; +"StoriesAlbumMenuSelect" = "Select"; +"StoriesAlbumMenuShareLink" = "Share Link"; +"StoriesAlbumMenuUnpin" = "Unpin"; +"StoriesAlbumNameAllStories" = "All Stories"; +"StoriesAlbumNewAlbum" = "New Album"; +"StoriesAlbumOrganizeDescription" = "Add some of your stories to this album."; +"StoriesAlbumOrganizeTitle" = "Organize Your Stories"; +"StoriesAlbumRename" = "Rename Album"; +"StoriesAlbumRenameHint" = "Choose a new name for your album."; +"StoriesAlbumTitleInputHint" = "Title"; +"StoriesCollage" = "Combine into Collage"; +"StoriesCreate#one" = "Create %d Story"; +"StoriesCreate#other" = "Create %d Stories"; +"StoriesDeleted#one" = "%d story deleted"; +"StoriesDeleted#other" = "%d stories deleted"; +"StoriesForwardText" = "Forward to Stories"; +"StoriesForwardTitle" = "Your Stories"; +"StoriesIntroDismiss" = "Tap to keep watching"; +"StoriesIntroGoBackHeader" = "Go Back"; +"StoriesIntroGoBackSubHeader" = "Tap the left edge"; +"StoriesIntroGoForwardHeader" = "Go forward"; +"StoriesIntroGoForwardSubHeader" = "Tap the screen"; +"StoriesIntroGoToNextAuthorHeader" = "Go to Next Author"; +"StoriesIntroGoToNextAuthorSubHeader" = "Swipe left or right"; +"StoriesIntroHeader" = "Watching Stories"; +"StoriesIntroPauseAndSeekHeader" = "Pause and Seek"; +"StoriesIntroPauseAndSeekSubHeader" = "Hold and slide left or right"; +"StoriesIntroSubHeader" = "You can use these gestures\nto control playback."; +"StoriesMovedToContacts" = "Stories of **%s** were moved to the **Archive**."; +"StoriesMovedToDialogs" = "Stories of **%s** were moved to **Chats**."; +"StoriesPinLimit#other" = "You can’t pin more than %d posts."; +"StoriesPinned#one" = "Story pinned"; +"StoriesPinned#other" = "%d stories pinned"; +"StoriesPinnedText#one" = "Now it will be always shown on the top."; +"StoriesPinnedText#other" = "Now they will be always shown on the top."; +"StoriesPremiumHint" = "Posting stories is currently available only to subscribers of **Telegram Premium**."; +"StoriesPremiumHint2" = "Posting stories is currently available only to subscribers of **Telegram Premium**."; +"StoriesRemoveFromRecent" = "Remove from Recent"; +"StoriesSelected#one" = "%d Selected"; +"StoriesSelected#other" = "%d Selected"; +"StoriesSoundEnabled" = "Stories"; +"StoriesTooMuch#one" = "You can't post more than **%d story** in **24 hours**."; +"StoriesTooMuch#other" = "You can't post more than **%d stories** in **24 hours**."; +"StoriesUnpinned#one" = "Story unpinned."; +"StoriesUnpinned#other" = "%d stories unpinned."; +"Story" = "Story"; +"StoryAddCaption" = "Add a caption…"; +"StoryAddedToAlbumTitle#one" = "%d story saved to **%s**"; +"StoryAddedToAlbumTitle#other" = "%d stories saved to **%s**"; +"StoryAddedToAlbumX" = "This story has been added to **%s** album."; +"StoryAlbumNotFound" = "This album is no longer available"; +"StoryAllowScreenshots" = "Allow Screenshots"; +"StoryArchived#one" = "%d story removed from your profile"; +"StoryArchived#other" = "%d stories removed from your profile"; +"StoryArchivedFromProfile" = "This story is hidden from your profile."; +"StoryAudioAddToProfile" = "Add to Profile"; +"StoryAudioAddToProfileToast" = "Added to Profile."; +"StoryAudioAddToSavedMessages" = "Add to Saved Messages"; +"StoryAudioAddToSavedMessagesToast" = "Saved in **Saved Messages**."; +"StoryAudioRemove" = "Remove Audio"; +"StoryAutoExceptions" = "Automatic Exceptions"; +"StoryAutoExceptionsInfo" = "Automatically enable story notifications from the 5 users you contact most frequently."; +"StoryBlockList#one" = "**%d person** will never see your stories."; +"StoryBlockList#other" = "**%d people** will never see your stories."; +"StoryBlockListEmpty" = "**Select people** who will never see your stories."; +"StoryCameraDualHint" = "Turn on both cameras at once"; +"StoryCameraHint" = "Tap on %s to post a story for your contacts."; +"StoryCameraHint2" = "Take photos or videos to share with all your contacts or close friends at once."; +"StoryCameraSavedDualBackHint" = "Tap here to disable the second camera"; +"StoryCameraSavedDualFrontHint" = "Tap here to disable the selfie camera"; +"StoryChannelDisableKeep" = "The story will disappear after it expires."; +"StoryChannelEnableKeep" = "Users will see this story on the channel page even after it expires."; +"StoryCloseFriendsHint" = "You are seeing this story because **%s** added you to their list of Close Friends."; +"StoryCollageMenuHint" = "Hold and drag tiles to reorder them."; +"StoryCollageRemoveGrid" = "Tap to remove grid"; +"StoryCollageReorderHint" = "You can drag a tile to reorder"; +"StoryContactsHint" = "Only **%s’s** contacts can view this story."; +"StoryContainsEmoji#one" = "This story contains emoji from **%d pack**."; +"StoryContainsEmoji#other" = "This story contains emoji from **%d packs**."; +"StoryContainsEmojiFrom" = "This story contains emoji from %s."; +"StoryContainsEmojiPackSingle" = "This story contains **%s**"; +"StoryContainsStickers#one" = "This story contains stickers from **%d pack**."; +"StoryContainsStickers#other" = "This story contains stickers from **%d packs**."; +"StoryContainsStickersEmoji#one" = "This story contains emoji and stickers from **%d pack**."; +"StoryContainsStickersEmoji#other" = "This story contains emoji and stickers from **%d packs**."; +"StoryContainsStickersEmojiFrom" = "This story contains emoji and stickers from %s."; +"StoryContainsStickersFrom" = "This story contains stickers from %s."; +"StoryCoverSave" = "Save Cover"; +"StoryCrop" = "Crop"; +"StoryDeleteContact" = "Delete Contact"; +"StoryDeleteDraft" = "Delete Draft"; +"StoryDisableKeep" = "The story will disappear after it expires."; +"StoryDisabledScreenshots" = "Downloading and taking screenshots will be disabled for this story."; +"StoryDisabledScreenshotsShare" = "Downloading, sharing and taking screenshots will be disabled for this story."; +"StoryDraft" = "Draft"; +"StoryDraftSaved" = "Draft saved"; +"StoryDrafts#one" = "%d Draft"; +"StoryDrafts#other" = "%d Drafts"; +"StoryDraftsAlbum" = "Drafts"; +"StoryEditCover" = "Edit Cover"; +"StoryEditCoverInfo" = "Choose a frame from this story to show on your Profile."; +"StoryEditCoverMenu" = "Edit Cover"; +"StoryEditing" = "Uploading edit…"; +"StoryEnableKeep" = "Users allowed to view your story will see it on your profile even after it expires."; +"StoryEnabledScreenshots" = "Downloading and taking screenshots will be enabled for this story."; +"StoryEnabledScreenshotsShare" = "Downloading, sharing and taking screenshots will be enabled for this story."; +"StoryError" = "Couldn’t upload"; +"StoryExpiredSubtitle" = "This story has expired"; +"StoryGroupReactionsHint" = "Send reaction as a message"; +"StoryGroupRepliesLocked" = "Comments restricted"; +"StoryHidAccount" = "This account was hidden by the user"; +"StoryHidFromToast" = "%s will not be able to see your stories."; +"StoryHideFrom" = "Hide My Stories From %s"; +"StoryHintPinchToZoom" = "Pinch to zoom"; +"StoryHintSwipeToZoom" = "Swipe to zoom"; +"StoryInteractionsChartTitle" = "Story Views and Shares"; +"StoryKeep" = "Post to My Profile"; +"StoryKeepChannel" = "Post to Channel Page"; +"StoryKeepChannelInfo#one" = "Keep this story on the channel page even after it expires in %d hour."; +"StoryKeepChannelInfo#other" = "Keep this story on the channel page even after it expires in %d hours."; +"StoryKeepDraft" = "Keep Draft"; +"StoryKeepGroup" = "Post to Group Page"; +"StoryKeepGroupInfo#one" = "Keep this story on the group page even after it expires in %d hour."; +"StoryKeepGroupInfo#other" = "Keep this story on the group page even after it expires in %d hours."; +"StoryKeepInfo#one" = "Keep this story on your profile even after it expires in %d hour. Privacy settings will apply."; +"StoryKeepInfo#other" = "Keep this story on your profile even after it expires in %d hours. Privacy settings will apply."; +"StoryLinkAdd" = "Add Link"; +"StoryLinkCaptionAbove" = "Move Up"; +"StoryLinkCaptionBelow" = "Move Down"; +"StoryLinkCreate" = "Create Link"; +"StoryLinkEdit" = "Update Link"; +"StoryLinkLimitMessage#other" = "You can’t add more than %d links to a story."; +"StoryLinkLimitTitle" = "Limit Reached"; +"StoryLinkNameHeader" = "Customize Link Text"; +"StoryLinkNamePlaceholder" = "Link Text"; +"StoryLinkPremium" = "Subscribe to **Telegram Premium** to add links to stories."; +"StoryLinkPreviewSubtitle" = "You can customize your link"; +"StoryLinkPreviewTitle" = "Link Preview"; +"StoryLinkURLPlaceholder" = "Enter URL"; +"StoryLive" = "Live"; +"StoryLiveAllowComments" = "Allow Comments"; +"StoryLiveBlockList#one" = "**%d person** will never see your Live Stories."; +"StoryLiveBlockList#other" = "**%d people** will never see your Live Stories."; +"StoryLiveBlockListEmpty" = "**Select people** who will never see your Live Stories."; +"StoryLiveKeepChannelInfo" = "Keep this Live Story on the channel's profile."; +"StoryLiveKeepGroupInfo" = "Keep this Live Story on the group's profile."; +"StoryLiveKeepInfo" = "Keep this Live Story on your profile."; +"StoryLiveNotificationSingle" = "started live stream!"; +"StoryLivePrivacyAlertSubtitleProfile" = "Choose who can view your live stream"; +"StoryLivePrivacyAlertTitle" = "Start Live Stream"; +"StoryLivePrivacyButtonPost" = "Start Live Stream"; +"StoryLivePrivacyPublishAs" = "Start Live Story as"; +"StoryLivePrivacyWhoCanView" = "Who can view this Live Story"; +"StoryMentionInDialog" = "Mentioned you in a story"; +"StoryMentionedAction" = "View Story"; +"StoryMentionedTitle" = "**%s** mentioned you in a story."; +"StoryMusicGlobalMusic" = "Global search"; +"StoryMusicLocalMusic" = "Local Music"; +"StoryMusicProfileMusic" = "Saved Music"; +"StoryMusicSearchMusic" = "Shared Audio"; +"StoryMusicSelectFromFiles" = "Select from Files"; +"StoryMusicTitle" = "Add Audiotrack"; +"StoryMusicTitle2" = "Add Audio"; +"StoryNoSound" = "This video has no sound"; +"StoryNotFound" = "This story is no longer available"; +"StoryNotification1#one" = "posted a story"; +"StoryNotification1#other" = "posted %d stories"; +"StoryNotification2" = "posted by %1$s and %2$s"; +"StoryNotification3" = "posted by %1$s, %2$s and %3$s"; +"StoryNotification4#other" = "posted by %2$s, %3$s and %1$d more"; +"StoryNotificationHidden#one" = "%d story was posted"; +"StoryNotificationHidden#other" = "%d stories were posted"; +"StoryNotificationMention" = "mentioned you in a story"; +"StoryNotificationSingle" = "posted a story"; +"StoryOpenLink" = "Open Link >"; +"StoryOriginalSoundMuted" = "Original audio will be removed."; +"StoryOriginalSoundNotMuted" = "Original audio will be preserved."; +"StoryPeriodHint" = "Choose how long the story will be visible."; +"StoryPeriodKeep" = "Keep Always"; +"StoryPeriodPremium#one" = "Subscribe to **Telegram Premium** to make your stories disappear after %d hour."; +"StoryPeriodPremium#other" = "Subscribe to **Telegram Premium** to make your stories disappear after %d hours."; +"StoryPhoto" = "Photo"; +"StoryPinnedToPosts" = "Story posted to the channel page"; +"StoryPinnedToPostsDescription" = "Posted stories can be viewed by others on the channel page until an admin removes them."; +"StoryPinnedToProfile" = "Story posted to your profile."; +"StoryPremiumFormatting" = "Subscribe to **Telegram Premium** to add links and formatting to your story captions."; +"StoryPremiumWidgets" = "Subscribe to **Telegram Premium** to add up to 5 reaction stickers to a story."; +"StoryPremiumWidgets2#other" = "Subscribe to **Telegram Premium** to add up to %d reaction stickers to a story."; +"StoryPrivacyAlertAsMessageSubtitle" = "Choose recipients"; +"StoryPrivacyAlertAsMessageTitle" = "Send as message"; +"StoryPrivacyAlertBlocklistSubtitle" = "Select people"; +"StoryPrivacyAlertBlocklistTitle" = "Hide my stories from"; +"StoryPrivacyAlertCloseFriendsSubtitle" = "Choose close friends"; +"StoryPrivacyAlertCloseFriendsTitle" = "Close Friends"; +"StoryPrivacyAlertEditTitle" = "Edit Privacy Settings"; +"StoryPrivacyAlertExcludeFromEveryoneSubtitle" = "Exclude contacts"; +"StoryPrivacyAlertExcludeFromEveryoneTitle" = "Everyone except..."; +"StoryPrivacyAlertExcludedContactsSubtitle" = "Exclude contacts"; +"StoryPrivacyAlertExcludedContactsTitle" = "Excluded Contacts"; +"StoryPrivacyAlertSelectContactsSubtitle" = "Choose contacts"; +"StoryPrivacyAlertSelectContactsTitle" = "Select Contacts"; +"StoryPrivacyAlertSubtitle#one" = "Choose who can view your story"; +"StoryPrivacyAlertSubtitle#other" = "Choose who can view your story"; +"StoryPrivacyAlertSubtitleProfile" = "Choose who can view your story"; +"StoryPrivacyAlertTitle" = "Share story"; +"StoryPrivacyAllContacts" = "My Contacts"; +"StoryPrivacyButtonExcludeContacts" = "Exclude Contacts"; +"StoryPrivacyButtonPost" = "Post Story"; +"StoryPrivacyButtonPostMultiple#one" = "Post %d Story"; +"StoryPrivacyButtonPostMultiple#other" = "Post %d Stories"; +"StoryPrivacyButtonSave" = "Save Settings"; +"StoryPrivacyButtonSaveCloseFriends" = "Save List"; +"StoryPrivacyCloseFriends" = "Close Friends"; +"StoryPrivacyContacts#one" = "%d contact"; +"StoryPrivacyContacts#other" = "%d contacts"; +"StoryPrivacyContactsExclude#one" = "Contacts (-%d)"; +"StoryPrivacyContactsExclude#other" = "Contacts (-%d)"; +"StoryPrivacyEveryone" = "Everyone"; +"StoryPrivacyEveryoneExclude#one" = "Everyone (-%d)"; +"StoryPrivacyEveryoneExclude#other" = "Everyone (-%d)"; +"StoryPrivacyHint" = "Set who can view your story"; +"StoryPrivacyNone" = "None"; +"StoryPrivacyOptionCloseFriends" = "Close Friends"; +"StoryPrivacyOptionCloseFriendsDetail" = "edit list"; +"StoryPrivacyOptionContacts" = "My Contacts"; +"StoryPrivacyOptionContactsDetail" = "exclude people"; +"StoryPrivacyOptionEveryone" = "Everyone"; +"StoryPrivacyOptionExcludePeople#one" = "except %d person"; +"StoryPrivacyOptionExcludePeople#other" = "except %d people"; +"StoryPrivacyOptionExcludePerson" = "except %s"; +"StoryPrivacyOptionPeople#one" = "%d person"; +"StoryPrivacyOptionPeople#other" = "%d people"; +"StoryPrivacyOptionSelectedContacts" = "Selected Users"; +"StoryPrivacyOptionSelectedContactsDetail" = "edit list"; +"StoryPrivacyPublishAs" = "Post story as"; +"StoryPrivacyPublishLiveAs" = "Stream as"; +"StoryPrivacyRecipients#one" = "%d recipient"; +"StoryPrivacyRecipients#other" = "%d recipients"; +"StoryPrivacyWhoCanView" = "Who can view your story"; +"StoryQualityDecrease" = "Lower Quality"; +"StoryQualityDecreasedMessage" = "Stories will now download faster."; +"StoryQualityDecreasedTitle" = "Quality Lowered"; +"StoryQualityIncrease" = "Increase Quality"; +"StoryQualityIncreasedMessage" = "You can lower the quality later for faster downloads."; +"StoryQualityIncreasedTitle" = "Quality Increased"; +"StoryQualityPremium" = "High-Quality Stories"; +"StoryQualityPremiumText" = "Subscribe to Premium to view stories in higher resolution."; +"StoryReactionsByEmotionChartTitle" = "Story Reactions"; +"StoryReactionsHint" = "Send reaction as a private message"; +"StoryReactionsSortDescription" = "Choose the order for the \nlist of reactions."; +"StoryReactionsWidgetLimit" = "You can't add more than 5 reaction stickers to a story."; +"StoryReactionsWidgetLimit2#other" = "You can’t add more than %d reaction stickers to a story."; +"StoryRemoveRoundMessage" = "Are you sure you want to delete your video message?"; +"StoryRemoveRoundTitle" = "Remove Video"; +"StoryRemovedFromAlbumTitle#one" = "%d story removed from **%s**"; +"StoryRemovedFromAlbumTitle#other" = "%d stories removed from **%s**"; +"StoryRemovedFromAlbumX" = "This story has been removed from **%s** album."; +"StoryRepliesLocked" = "Replies restricted"; +"StoryRepliesLockedButton" = "Unlock"; +"StoryReplyDisabled" = "You can’t reply to this story"; +"StoryRepostCommented" = "commented"; +"StoryRestrictions" = "Privacy Restrictions"; +"StoryRestrictionsInfo" = "The privacy settings of your story will prevent some users you tagged (%s) from viewing it."; +"StoryRoundRemove" = "Remove Video"; +"StorySave" = "Post to profile"; +"StorySaveDraft" = "Save Draft"; +"StorySavedSubtitle" = "Posted stories can be viewed by others on your profile until you remove them."; +"StorySavedTitle#one" = "%d story posted on your profile"; +"StorySavedTitle#other" = "%d stories posted on your profile"; +"StorySeekHelp" = "Slide left or right to seek"; +"StorySelectedContactsHint" = "Only some users **%s** selected can view this story."; +"StorySetWallpaper" = "Select Wallpaper"; +"StoryShared" = "Story shared"; +"StorySharedTo" = "Story forwarded to **%s**."; +"StorySharedToAllContacts" = "This story is now shown to all your contacts."; +"StorySharedToAllContactsExcluded#one" = "This story is now shown to your contacts (excluding %d)."; +"StorySharedToAllContactsExcluded#other" = "This story is now shown to your contacts (excluding %d)."; +"StorySharedToCloseFriends" = "This story is now shown to your close friends."; +"StorySharedToContacts#one" = "This story is now shown to %d contact."; +"StorySharedToContacts#other" = "This story is now shown to %d contacts."; +"StorySharedToEveryone" = "This story is now shown to everyone."; +"StorySharedToManyChats#one" = "Story forwarded to **a chat**."; +"StorySharedToManyChats#other" = "Story forwarded to **%1$d chats**."; +"StorySharedToSavedMessages" = "Story forwarded to **Saved Messages**"; +"StoryShowBackTo" = "Do Not Hide My Stories From %s"; +"StoryShownBackToToast" = "%s will be able to see your stories again."; +"StorySoundMuted" = "This story will have no sound."; +"StorySoundNotMuted" = "This story will have sound."; +"StoryStatistics" = "Story Statistics"; +"StoryTapToSound" = "Tap here to enable sound"; +"StoryTimeline" = "Timeline"; +"StoryUnavailable" = "Story unavailable"; +"StoryUnpinnedFromPosts" = "Story removed from the channel page"; +"StoryUnsupported" = "This story is not supported by your version of Telegram."; +"StoryUploadError" = "Error uploading story"; +"StoryUploading" = "Uploading story…"; +"StoryVideo" = "Video"; +"StoryViewGift" = "Show Gift >"; +"StoryViewLocation" = "View Location >"; +"StoryViewMessage" = "View Message >"; +"StoryViewsPremiumHint" = "To unlock viewer lists for expired and posted stories, subscribe to **Telegram Premium**."; +"StoryViewsSortDescription" = "Choose the order for the \nlist of viewers."; +"StoryWidgetAudio" = "Audio"; +"StoryWidgetLink" = "Link"; +"StoryWidgetLocation" = "Location"; +"StoryWidgetPhoto" = "Photo"; +"StoryYouMentionInDialog" = "You mentioned %s"; +"StoryYouMentionedTitle" = "**You** mentioned %s in a story."; +"Streaming" = "Streaming"; +"Strike" = "Strikethrough"; +"Style" = "Style"; +"SubscribeRequests" = "Join Requests"; +"SubscribeToPremium" = "Subscribe for %s per month"; +"SubscribeToPremiumNoPrice" = "Subscribe"; +"SubscribeToPremiumNotAvailable" = "Subscription not available"; +"SubscribeToPremiumOfficialAppNeeded" = "**Official app needed**"; +"SubscribeToPremiumOfficialAppNeededDescription" = "You need an official Telegram app to subscribe to **Telegram Premium**.\n\nOnce subscribed, you will be able to use the benefits of Telegram Premium in any apps that support it, including unofficial ones."; +"SubscribeToPremiumPerCustom" = "Subscribe for %1$s / %2$s"; +"SubscribeToPremiumPerYear" = "Subscribe for %s per year"; +"Subscribers#one" = "%1$d subscriber"; +"Subscribers#other" = "%1$d subscribers"; +"SubscriptionsCount" = "%1$s subscriptions"; +"Suggest2ChannelSendHint" = "Send a message to the channel's admin"; +"SuggestAPostBelow" = "Suggest a Post Below"; +"SuggestAPostBelowHint" = "Tap here to suggest a post for %s"; +"SuggestAPostBelowSubtitle" = "Tap to offer a price for publishing"; +"SuggestAPostBelowSubtitleStars" = "⭐️ %s for publishing anytime"; +"SuggestAPostBelowSubtitleStarsAndTime" = "⭐️ %s %s"; +"SuggestAPostTooMuch" = "Maximum offer: %s"; +"SuggestAPostTooSmall" = "Minimum offer: %s"; +"SuggestAnimatedEmoji" = "Suggest Animated Emoji"; +"SuggestAnimatedEmojiInfo" = "Each time you enter an emoji you can replace it with an animated emoji."; +"SuggestClearDatabaseMessage" = "Your local database size is %s. It may affect the speed of the app."; +"SuggestClearDatabaseTitle" = "Clear Database"; +"SuggestContacts" = "Suggest Frequent Contacts"; +"SuggestContactsAlert" = "This will delete all data about the people you message frequently as well as the inline bots you are likely to use."; +"SuggestContactsInfo" = "Display people you message frequently at the top of the search section for quick access."; +"SuggestContactsTitle" = "Disable suggestions"; +"SuggestPhoto" = "Suggest Photo"; +"SuggestPhotoAlertMessage" = "Do you want to suggest this photo for **%s's** Telegram profile?"; +"SuggestPhotoFor" = "Suggest photo for %s"; +"SuggestPhotoShort" = "Suggest"; +"SuggestPostForFree" = "Message"; +"SuggestPostForStars" = "Message for ⭐️%s"; +"SuggestStickers" = "Suggest stickers by emoji"; +"SuggestStickersAll" = "All Sets"; +"SuggestStickersInstalled" = "My Sets"; +"SuggestStickersNone" = "None"; +"SuggestUserPhoto" = "Suggest Photo for %s"; +"SuggestVideo" = "Suggest Video"; +"SuggestVideoAlertMessage" = "Do you want to suggest this photo for **%s's** Telegram profile?"; +"SuggestedDateOfBirthView" = "View"; +"SuggestedMessageAcceptInfo" = "Do you want to publish this post from **%1$s**?"; +"SuggestedMessageAcceptInfo3" = "It must remain visible for at least **%d** hours after publication."; +"SuggestedMessageAcceptInfoAdmin2" = "You will receive **%1$s** (%3$s) for publishing this post **%2$s**."; +"SuggestedMessageAcceptInfoAnytimeAdmin2" = "You will receive **%1$s** (%2$s) for publishing this post."; +"SuggestedMessageAcceptInfoAnytimeUser2" = "You will pay **%1$s** for publishing this post."; +"SuggestedMessageAcceptInfoUser2" = "You will pay **%1$s** for publishing this post **%2$s**."; +"SuggestedMessageAcceptStarsDisclaimer" = "Transactions in Stars may be reversed by the payment provider within 21 days. Only accept Stars from people you trust."; +"SuggestedMessageDecline" = "Decline"; +"SuggestedMessageDeclineInfo" = "Do you want to decline this post from **%1$s**?"; +"SuggestedMessageDeclineReasonHint" = "Add a reason (optional)"; +"SuggestedOfferCompleteAmount#one" = "%1$s received ⭐️ %2$s for publishing post"; +"SuggestedOfferCompleteAmount#other" = "%1$s received ⭐️ %2$s for publishing post"; +"SuggestedOfferCompleteAmountF" = "%1$s has received ⭐️ %2$s for publishing post"; +"SuggestedOfferCompleteAmountUnknown" = "%1$s received payment for publishing post"; +"SuggestedOfferPaidUntil" = "Paid until %1$s"; +"SuggestedOfferRefundByAdminAmount#one" = "⭐️ %3$s refunded to %1$s because %2$s deleted the post"; +"SuggestedOfferRefundByAdminAmount#other" = "⭐️ %3$s refunded to %1$s because %2$s deleted the post"; +"SuggestedOfferRefundByAdminAmountF" = "⭐️ %3$s was returned to %1$s because %2$s deleted the message"; +"SuggestedOfferRefundByAdminAmountUnknown" = "Payment was returned to %1$s because %2$s deleted the message"; +"SuggestedOfferRefundByUserAmount#one" = "%2$s will not receive ⭐️ %3$s because %1$s requested a refund"; +"SuggestedOfferRefundByUserAmount#other" = "%2$s will not receive ⭐️ %3$s because %1$s requested a refund"; +"SuggestedOfferRefundByUserAmountF" = "%2$s will not receive ⭐️ %3$s because %1$s requested a refund"; +"SuggestedOfferRefundByUserAmountUnknown" = "%2$s will not receive payment because %1$s requested a refund"; +"SuggestedOfferStars" = "Offer Stars"; +"SuggestedOfferTON" = "Offer Grams"; +"SuggestedPhoto" = "Suggested Photo"; +"SuggestedPostAcceptTitle" = "Accept Terms"; +"SuggestedPostPublish" = "Publish"; +"SuggestedVideo" = "Suggested Photo"; +"SuggestionAgreementDeclinedThis" = "❌ **%s** rejected this message."; +"SuggestionAgreementDeclinedThisComment" = "❌ **%s** rejected this message with the comment:"; +"SuggestionAgreementDeclinedYou" = "❌ **%s** rejected your message."; +"SuggestionAgreementDeclinedYouComment" = "❌ **%s** rejected your message with the comment:"; +"SuggestionAgreementNotEnoughStars" = "⚠️ **Transaction failed** because **%s** didn’t have enough Stars."; +"SuggestionAgreementReached" = "🤝 **Agreement Reached!**"; +"SuggestionAgreementReachedAdmin1" = "📆 This post will be automatically published **%s** **%s**"; +"SuggestionAgreementReachedAdmin1PastSimple" = "📆 This post was automatically published on **%s** **%s**"; +"SuggestionAgreementReachedAdmin1PresentPerfect" = "📆 This post has been published on **%s**"; +"SuggestionAgreementReachedAdmin2" = "💰 **%s** has been charged ⭐️ **%s**."; +"SuggestionAgreementReachedAdmin3" = "⏳ **%s** will receive payment once the post has been live for **%d** hours."; +"SuggestionAgreementReachedAdmin3Stars" = "⏳ **%s** will receive the **Stars** once the post has been live for **%d** hours."; +"SuggestionAgreementReachedAdmin3TON" = "⏳ **%s** will receive the **Grams** once the post has been live for **%d** hours."; +"SuggestionAgreementReachedAdmin4" = "🔁 If **%s** removes the post before it has been live for **%d** hours, payment will be refunded."; +"SuggestionAgreementReachedAdmin4Stars" = "🔁 If **%s** removes the post before it has been live for **%d** hours, **Stars** will be refunded to user."; +"SuggestionAgreementReachedAdmin4TON" = "🔁 If **%s** removes the post before it has been live for **%d** hours, **Grams** will be refunded to user."; +"SuggestionAgreementReachedUser1" = "📆 Your post will be automatically published on **%s** **%s**"; +"SuggestionAgreementReachedUser1PastSimple" = "📆 Your post was automatically published on **%s** **%s**"; +"SuggestionAgreementReachedUser1PresentPerfect" = "📆 Your post has been published on **%s**"; +"SuggestionAgreementReachedUser2" = "💰 You have been charged ⭐️ **%s**."; +"SuggestionAgreementReachedUser3" = "⏳ **%s** will receive payment once the post has been live for **%d** hours."; +"SuggestionAgreementReachedUser3Stars" = "⏳ **%s** will receive your **Stars** once the post has been live for **%d** hours."; +"SuggestionAgreementReachedUser3TON" = "⏳ **%s** will receive your **Grams** once the post has been live for **%d** hours."; +"SuggestionAgreementReachedUser4" = "🔁 If **%s** removes the post before it has been live for **%d** hours, payment will be refunded."; +"SuggestionAgreementReachedUser4Stars" = "🔁 If **%s** removes the post before it has been live for **%d** hours, your **Stars** will be refunded."; +"SuggestionAgreementReachedUser4TON" = "🔁 If **%s** removes the post before it has been live for **%d** hours, your **Grams** will be refunded."; +"SuggestionLockedStars" = "**%1$s** charges **⭐️ %2$s** per message to its admin."; +"SuggestionOfferInfoPrice" = "Price"; +"SuggestionOfferInfoTime" = "Time"; +"SuggestionOfferInfoTitle" = "**%s** suggested a post."; +"SuggestionOfferInfoTitleEditedAnd" = "and"; +"SuggestionOfferInfoTitleEditedFromX" = "**%1$s** suggested a new %2$s for this post."; +"SuggestionOfferInfoTitleEditedFromYou" = "**You** suggested a new %1$s for this post."; +"SuggestionOfferInfoTitleEditedMedia" = "media"; +"SuggestionOfferInfoTitleEditedPrice" = "price"; +"SuggestionOfferInfoTitleEditedText" = "text"; +"SuggestionOfferInfoTitleEditedTime" = "time"; +"SuggestionOfferInfoTitleEditedX" = "**%1$s** suggested a new %3$s for **%2$s** post."; +"SuggestionOfferInfoTitleYou" = "**You** suggested a post."; +"SuggestionStarsWillBeLost" = "Stars will be lost"; +"SuggestionStarsWillBeLostDelete" = "Delete Anyway"; +"SuggestionStarsWillBeLostInfo" = "You won’t receive Stars for this post if you delete it now. The post must remain visible for at least **%d hours** after it was published."; +"SuggestionTONWillBeLost" = "Grams Will Be Lost"; +"SuggestionTONWillBeLostInfo" = "You won’t receive Grams for this post if you delete it now. The post must remain visible for at least **%d hours** after it was published."; +"SuggestionUnlockedStars" = "Send a direct message to the administrator of **%1$s**."; +"SummaryLimit" = "Summarize large messages with AI — unlimited with Telegram Premium."; +"SummaryLimitUpgrade" = "Upgrade"; +"SummarySubtitle" = "Tap to see original text"; +"SummaryTitle" = "AI Summary"; +"Support" = "Support"; +"SupportStatus" = "support"; +"SwipeDeleteChat" = "Delete"; +"SwipeMarkAsRead" = "Read"; +"SwipeMarkAsUnread" = "Unread"; +"SwipeMute" = "Mute"; +"SwipePin" = "Pin"; +"SwipeSettingsArchive" = "Archive"; +"SwipeSettingsDelete" = "Delete"; +"SwipeSettingsFolders" = "Change Folder"; +"SwipeSettingsMute" = "Mute"; +"SwipeSettingsPin" = "Pin"; +"SwipeSettingsRead" = "Read"; +"SwipeToGoNextArchive" = "Pull up to go to archived channels"; +"SwipeToGoNextChannel" = "Pull up to go to the next unread channel"; +"SwipeToGoNextChannelEnd" = "You have no unread channels"; +"SwipeToGoNextFolder" = "Pull up to go to the next unread folder"; +"SwipeToGoNextRecommendedChannel" = "Pull up to go to next channel"; +"SwipeToGoNextTopicEnd" = "No more unread topics in %1$s"; +"SwipeToGoNextUnreadTopic" = "Pull up to go to the next unread topic"; +"SwipeToReplyHint" = "Swipe to Reply"; +"SwipeToReplyHintMessage" = "Swipe left on any message to reply to it."; +"SwipeUnmute" = "Unmute"; +"SwipeUnpin" = "Unpin"; +"SwitchAccountHint" = "Hold down to switch accounts"; +"SwitchThemeToDay" = "Day Mode"; +"SwitchThemeToNight" = "Night Mode"; +"SyncContacts" = "Sync Contacts"; +"SyncContactsAdded" = "Contacts from this device were added to your account."; +"SyncContactsDelete" = "Delete Synced Contacts"; +"SyncContactsDeleteInfo" = "This will remove your contacts from the Telegram servers. If 'Sync Contacts' is enabled, contacts will be re-synced."; +"SyncContactsDeleteText" = "This will remove your contacts from the Telegram servers.\n\nIf '**Sync Contacts**' is enabled, your current phonebook will be re-synced."; +"SyncContactsDeleteTitle" = "Delete contacts"; +"SyncContactsInfoOff" = "Turn on to continuously sync contacts from this device with your account."; +"SyncContactsInfoOn" = "Contacts from this device will be added to your account."; +"SyncContactsOff" = "Contacts from this device will not be added to this account."; +"SyncContactsOn" = "Contacts from this device will be added to this account."; +"SystemRoot" = "System Root"; +"SystemTones" = "System tones"; +"TONBalanceText" = "Use Grams to buy gifts and suggest posts to channels."; +"TONBalanceTitle" = "Gram Balance"; +"TagAdded" = "Tag Added"; +"TagEdited" = "Tag Edited"; +"TagInfoAdminText" = "This green tag un1 is **%1$s**'s admin tag in **%2$s**."; +"TagInfoAdminTitle" = "Admin Tag"; +"TagInfoButtonAddMyTag" = "Add My Tag"; +"TagInfoButtonAddTag" = "Add Tag"; +"TagInfoButtonEditMyTag" = "Edit My Tag"; +"TagInfoButtonEditTag" = "Edit Tag"; +"TagInfoMemberText" = "This gray tag un1 is **%1$s**'s member tag in **%2$s**."; +"TagInfoMemberTitle" = "Member Tag"; +"TagInfoOwnerText" = "This purple tag un1 is **%1$s**'s owner tag in **%2$s**."; +"TagInfoOwnerTitle" = "Owner Tag"; +"TaggedMessages#one" = "%1$d message"; +"TaggedMessages#other" = "%1$d messages"; +"TapAndHoldToDeleteReaction" = "Hold down to delete reaction."; +"TapForForwardingOptions" = "Tap here for forwarding options"; +"TapForReplyOptions" = "Tap here for reply options"; +"TapForVideo" = "Tap for photo, hold for video"; +"TapHereEmoji" = "Tap %1$s to access more emoji."; +"TapHereGifs" = "Tap here to access saved GIFs"; +"TapOnThePencil" = "Tap on the pencil to start a new chat"; +"TapOnThePencilButton" = "Tap on the button to start a new chat"; +"TapToAddBio" = "tap to add a bio"; +"TapToAddDescription" = "tap to add description"; +"TapToAddPhoto" = "tap to add photo"; +"TapToAddPhotoOrBio" = "tap to add photo or bio"; +"TapToAddPhotoOrDescription" = "tap to add a photo or bio"; +"TapToChange" = "Tap to change"; +"TapToChangePhone" = "Tap to change phone number"; +"TapToCreateTopicHint" = "Tap to create a topic"; +"TapToDownload" = "Tap to download"; +"TapToEditWidget" = "tap to edit widget"; +"TapToEditWidgetShort" = "tap to edit"; +"TapToHideCallsTab" = "Tap here to hide **Calls** tab"; +"TapToSendLocation" = "Tap to send this location"; +"TapToTurnCamera" = "Tap here to turn on your camera"; +"TapToViewAsList" = "Tap to view as a list."; +"Teal" = "Teal"; +"TelegramBusiness" = "Telegram Business"; +"TelegramBusinessSubscribedSubtitle" = "You have now unlocked these additional business features."; +"TelegramBusinessSubscribedSubtitleTemp" = "You have now unlocked Telegram Business features. These features are **temporarily included** with your Premium Subscription."; +"TelegramBusinessSubtitle" = "Subscribe to **Telegram Premium** to unlock these additional business features."; +"TelegramBusinessSubtitle2" = "Turn your account to a business page with these additional features."; +"TelegramBusinessSubtitleTemp" = "Turn your account into a **business page** with these additional features.\nThe benefits of Telegram Business are **temporarily included** with Telegram Premium."; +"TelegramCacheSize" = "%s Telegram Cache"; +"TelegramContacts#one" = "%1$d contact on Telegram"; +"TelegramContacts#other" = "%1$d contacts on Telegram"; +"TelegramFAQ" = "Telegram FAQ"; +"TelegramFaq" = "Telegram FAQ"; +"TelegramFaqUrl" = "https://telegram.org/faq"; +"TelegramFeatures" = "Telegram Features"; +"TelegramFeaturesUrl" = "https://t.me/TelegramTips"; +"TelegramPassport" = "Telegram Passport"; +"TelegramPassportCreatePassword" = "CREATE A PASSWORD"; +"TelegramPassportCreatePasswordInfo" = "Please create a password to secure your personal data with end-to-end encryption.\n\nThis password will also be required whenever you log in to Telegram on a new device."; +"TelegramPassportDelete" = "Delete Telegram Passport"; +"TelegramPassportDeleteAlert" = "Are you sure you want to delete your Telegram Passport?"; +"TelegramPassportDeleteTitle" = "Delete Telegram passport"; +"TelegramPremium" = "Telegram Premium"; +"TelegramPremiumCount" = "%1$d Telegram Premium"; +"TelegramPremiumShort" = "Premium"; +"TelegramPremiumSubscribedSubtitle" = "Thank you for subscribing to **Telegram Premium**.\nHere’s what is now unlocked."; +"TelegramPremiumSubscribedTitle" = "You are all set!"; +"TelegramPremiumSubtitle" = "Go **beyond the limits** and **unlock dozens of exclusive** features by subscribing to **Telegram Premium**."; +"TelegramPremiumUserDialogSubtitle" = "Owners of **Telegram Premium** accounts have exclusive access to multiple additional features."; +"TelegramPremiumUserDialogTitle" = "**%s** is a subscriber of Telegram Premium."; +"TelegramPremiumUserGiftedPremiumDialogSubtitle" = "You now have access to additional features."; +"TelegramPremiumUserGiftedPremiumDialogTitle" = "**%1$s** has gifted you a %2$d-month subscription to Telegram Premium."; +"TelegramPremiumUserGiftedPremiumDialogTitleWithPlural" = "**%1$s** has gifted you a %2$s subscription to Telegram Premium."; +"TelegramPremiumUserGiftedPremiumDialogTitleWithPluralSomeone" = "Someone has gifted you a %1$s subscription to Telegram Premium."; +"TelegramPremiumUserGiftedPremiumOutboundDialogSubtitle" = "**%1$s** now has access to additional features."; +"TelegramPremiumUserGiftedPremiumOutboundDialogTitle" = "You gifted **%1$s** a %2$d-month subscription to Telegram Premium."; +"TelegramPremiumUserGiftedPremiumOutboundDialogTitleWithPlural" = "You gifted **%1$s** a %2$s subscription to\nTelegram Premium."; +"TelegramPremiumUserStatusCollectibleDialogTitle" = "%1$s set this emoji from **%2$s** collection as their current status."; +"TelegramPremiumUserStatusDefaultDialogTitle" = "This is %1$s's current status."; +"TelegramPremiumUserStatusDialogSubtitle" = "Emoji statuses are a premium feature. Other features included in **Telegram Premium**:"; +"TelegramPremiumUserStatusDialogTitle" = "%1$s set this emoji from **%2$s** as their current status."; +"TelegramStars" = "Telegram Stars"; +"TelegramStarsChoose" = "Choose package"; +"TelegramStarsGift" = "Gift Stars to Friends"; +"TelegramStarsInfo" = "Buy Stars to unlock content and services\nin mini apps on Telegram."; +"TelegramStarsInfo2" = "Buy Stars to unlock content and services\nin mini apps on Telegram. **More about Stars >**"; +"TelegramTones" = "Telegram tones"; +"TelegramVersion" = "Telegram for Android %1$s"; +"Terminate" = "Terminate"; +"TerminateAllSessions" = "Terminate All Other Sessions"; +"TerminateAllWebSessions" = "Disconnect All Websites"; +"TerminateOldSessionHeader" = "Automatically terminate old sessions"; +"TerminateSession" = "Terminate Session"; +"TerminateSessionInfo" = "Tap on a session to terminate."; +"TerminateSessionQuestion" = "Terminate this session?"; +"TerminateSessionText" = "Are you sure you want to terminate this session?"; +"TerminateWebSessionInfo" = "Tap to disconnect from your Telegram account."; +"TerminateWebSessionQuestion" = "Disconnect %1$s?"; +"TerminateWebSessionStop" = "Block %1$s"; +"TerminateWebSessionText" = "Are you sure you want to disconnect %1$s?"; +"TerminateWebSessionTitle" = "Disconnect website"; +"TerminateWebSessionsTitle" = "Disconnect websites"; +"TermsOfService" = "Terms of Service"; +"TermsOfServiceLogin" = "By signing up, you agree\nto the *Terms of Service*."; +"TermsOfServiceUrl" = "https://telegram.org/tos"; +"TermsOfUse" = "Terms of Use"; +"TextCopied" = "Text copied to clipboard."; +"TextPlaceholder" = "Add text"; +"TextSelectionHint" = "Hold a **word**, then move the cursor to select more text to quote or copy."; +"TextSelectionHit" = "Hold on a **word**, then move the cursor to select more text to copy."; +"TextSize" = "Message Text Size"; +"TextSizeHeader" = "Message text size"; +"Theme" = "Theme"; +"ThemeAlsoAppliedForHint" = "Theme will be also applied for **%s**."; +"ThemeAlsoDisabledForHint" = "Theme will be also disabled for **%s**."; +"ThemeArcticBlue" = "Arctic"; +"ThemeBlue" = "Mono"; +"ThemeClassic" = "Classic"; +"ThemeCreateHelp" = "Anyone can install your theme using this link. When you change the theme, it will be updated for all users who installed it.\n\nThe theme will be based on your currently selected theme and wallpaper."; +"ThemeCreateHelp2" = "You can change the link of your theme.\n\nTheme links must be longer than 5 characters and can use a-z, 0-9 and underscores."; +"ThemeDark" = "Dark"; +"ThemeDarkBlue" = "Dark Blue"; +"ThemeDay" = "Day"; +"ThemeGraphite" = "Graphite"; +"ThemeHelpLink" = "Your theme will be updated for all users each time you change it. Anyone can install it using this link:\n%1$s"; +"ThemeInfo" = "Choose custom color schemes"; +"ThemeInstallCount#one" = "%1$d person is using this theme"; +"ThemeInstallCount#other" = "%1$d people are using this theme"; +"ThemeNameInvalid" = "Sorry, theme name is invalid."; +"ThemeNamePlaceholder" = "Name"; +"ThemeNight" = "Night"; +"ThemeNotFound" = "Theme not found"; +"ThemeNotSupported" = "Sorry, this color theme doesn’t support your device yet."; +"ThemePreview" = "Theme Preview"; +"ThemePreviewDialog1" = "Eva Summer"; +"ThemePreviewDialog2" = "Your inner Competition"; +"ThemePreviewDialog3" = "Mike Apple"; +"ThemePreviewDialog4" = "Paul Newman"; +"ThemePreviewDialog5" = "Old Pirates"; +"ThemePreviewDialog6" = "Kate Bright"; +"ThemePreviewDialog7" = "Nick K"; +"ThemePreviewDialog8" = "Adler Toberg"; +"ThemePreviewDialogMessage1" = "Reminds me of a Chinese prove..."; +"ThemePreviewDialogMessage2" = "hey, I've updated the source code."; +"ThemePreviewDialogMessage3" = "🤷\u200d♂️ Sticker"; +"ThemePreviewDialogMessage4" = "Any ideas?"; +"ThemePreviewDialogMessage5" = "Yo-ho-ho!"; +"ThemePreviewDialogMessage6" = "Hola!"; +"ThemePreviewDialogMessage7" = "These are not the droids you are looking for."; +"ThemePreviewDialogMessage8" = "Did someone say peanut butter?"; +"ThemePreviewLine1" = "Reinhardt, we need to find you some new tunes 🎶."; +"ThemePreviewLine2" = "I can't even take you seriously right now."; +"ThemePreviewLine3" = "Ah, you kids today with techno music! You should enjoy the classics, like Hasselhoff!"; +"ThemePreviewLine3Reply" = "Lucio"; +"ThemePreviewLine4" = "Bring it on! I LIVE for this!"; +"ThemePreviewSongPerformer" = "David Hasselhoff"; +"ThemePreviewSongTitle" = "True Survivor"; +"ThemePreviewTitle" = "Reinhardt"; +"ThemeResetToDefaults" = "Reset to default"; +"ThemeResetToDefaultsText" = "Are you sure you want to reset text size and color settings?"; +"ThemeResetToDefaultsTitle" = "Reset settings"; +"ThemeSetUrl" = "Set link"; +"ThemeSetUrlHelp" = "You can use a-z, 0-9 and underscores.\nMinimum length is 5 characters."; +"ThemeUrl" = "Theme Link"; +"ThisIsYou" = "this is you"; +"ThisMedia" = "This media"; +"ThisPhoto" = "This photo"; +"TimeLimitHelp" = "You can make the link expire after a certain time."; +"TimeLimitHint" = "Set custom duration"; +"TimeToEdit" = "%1$s"; +"TimeToEditExpired" = "Sorry, editing time expired."; +"TimerPeriodDoNotDelete" = "Do Not Delete"; +"TimerPeriodHint" = "Choose how long the media will be kept after opening."; +"TimerPeriodOnce" = "View Once"; +"TimerPeriodPhotoKeep" = "Photo will be kept in chat."; +"TimerPeriodPhotoSetOnce" = "Photo set to view once"; +"TimerPeriodPhotoSetSeconds#one" = "Photo will be deleted **%d** second after opening."; +"TimerPeriodPhotoSetSeconds#other" = "Photo will be deleted **%d** seconds after opening."; +"TimerPeriodVideoKeep" = "Video will be kept."; +"TimerPeriodVideoSetOnce" = "Video set to view once."; +"TimerPeriodVideoSetSeconds#one" = "Video will be deleted in **%d** second after opening."; +"TimerPeriodVideoSetSeconds#other" = "Video will be deleted in **%d** seconds after opening."; +"Times#one" = "%1$d time"; +"Times#other" = "%1$d times"; +"TimezoneDetectAutomatically" = "Detect automatically"; +"TimezoneDetectAutomaticallyInfo" = "The time zone for your business is set to %s."; +"TimezoneHeader" = "Select your time zone"; +"TimezoneNotFound" = "Timezone not found"; +"TimezoneTitle" = "Time Zone"; +"TintHighlights" = "HIGHLIGHTS"; +"TintShadows" = "SHADOWS"; +"TitleSetupProxy" = "set up proxy >"; +"TodayAt" = "at"; +"TodayAtFormatted" = "at %1$s"; +"TodayAtFormattedWithToday" = "today at %1$s"; +"Todo" = "Checklist"; +"TodoAddTaskInfoMax" = "You have added the maximum number of tasks."; +"TodoAddTasksButton" = "ADD"; +"TodoAddTasksTitle" = "New Tasks"; +"TodoAddedMoreTasks#one" = "un1 added “%2$s”, “%3$s”, “%4$s” and %1$d more task to checklist"; +"TodoAddedMoreTasks#other" = "un1 added “%2$s”, “%3$s”, “%4$s” and %1$d more tasks to checklist"; +"TodoAddedMoreTasksOut#one" = "You added “%2$s”, “%3$s”, “%4$s” and %1$d more task to checklist"; +"TodoAddedMoreTasksOut#other" = "You added “%2$s”, “%3$s”, “%4$s” and %1$d more tasks to checklist"; +"TodoAddedOneTask" = "un1 added “%1$s” to checklist"; +"TodoAddedOneTaskOut" = "You added “%1$s” to checklist"; +"TodoAddedThreeTask" = "un1 added “%1$s”, “%2$s” and “%3$s” to checklist"; +"TodoAddedThreeTaskOut" = "You added “%1$s”, “%2$s” and “%3$s” to checklist"; +"TodoAddedTwoTask" = "un1 added “%1$s” and “%2$s” to checklist"; +"TodoAddedTwoTaskOut" = "You added “%1$s” and “%2$s” to checklist"; +"TodoAllowAddingTasks" = "Allow Others to Add Tasks"; +"TodoAllowMarkingDone" = "Allow Others to Mark As Done"; +"TodoCantForward" = "Sorry, checklists can't be forwarded to channels."; +"TodoCantForwardSecretChat" = "Sorry, checklists can't be forwarded to secret chats."; +"TodoCheck" = "Check"; +"TodoCompleteForbidden" = "**%s** has restricted others from editing this checklist."; +"TodoCompleteForbiddenForward" = "You can't edit a forwarded checklist."; +"TodoCompleted#one" = "%2$d of %1$d completed"; +"TodoCompleted#other" = "%2$d of %1$d completed"; +"TodoCompletedBy#one" = "%2$d of %1$d completed by %3$s"; +"TodoCompletedBy#other" = "%2$d of %1$d completed by %3$s"; +"TodoCompletedDateTimeAt" = "Completed %1$s at %2$s"; +"TodoCompletedTodayAt" = "Completed at %s"; +"TodoCompletedYesterdayAt" = "Completed yesterday at %s"; +"TodoDeleteItem" = "Delete Item"; +"TodoEditItem" = "Edit Item"; +"TodoEditTasksButton" = "SAVE"; +"TodoEditTitle" = "Edit Checklist"; +"TodoItemQuote" = "Reply to Item"; +"TodoItemsTitle" = "Checklist"; +"TodoMenuHint" = "You are viewing actions for one task.\nYou can switch to actions for the list."; +"TodoMenuTabList" = "List"; +"TodoMenuTabTask" = "Task"; +"TodoNewTask" = "Add a Task"; +"TodoNewTaskInfo#one" = "You can add %d more task."; +"TodoNewTaskInfo#other" = "You can add %d more tasks."; +"TodoPremiumRequired" = "Only **Telegram Premium** subscribers can mark tasks as done."; +"TodoTaskCompleted" = "un1 marked \"%s\" as done"; +"TodoTaskCompletedOut" = "You marked \"%s\" as done"; +"TodoTaskCompletedOutUnknown" = "You marked a task as done"; +"TodoTaskCompletedUnknown" = "un1 marked task as done"; +"TodoTaskNotCompleted" = "un1 marked \"%s\" as not done"; +"TodoTaskNotCompletedOut" = "You marked \"%s\" as not done"; +"TodoTaskNotCompletedOutUnknown" = "You marked a task as not done"; +"TodoTaskNotCompletedUnknown" = "un1 marked task as not done"; +"TodoTaskPlaceholder" = "Task"; +"TodoTitle" = "New Checklist"; +"TodoTitlePlaceholder" = "Title"; +"TodoUncheck" = "Uncheck"; +"TonCount#one" = "%d Gram"; +"TonCount#other" = "%d Grams"; +"TonCountX" = "%s Grams"; +"TonNeededTextGiftBuyResale" = "Buy **Grams** to spend on gifts."; +"TonNeededTextSuggestMessage" = "Buy **Grams** to suggest posts to **%s** and others."; +"TonNeededTitle" = "%s Grams Needed"; +"TonStats" = "Stats"; +"TonTopUp" = "Top Up"; +"TooLargeError" = "Too large"; +"TooLongError" = "Too long"; +"TooManyCommunities" = "Too Many Communities"; +"TooManyCommunitiesHintCreate" = "Sorry, you are a member of too many groups and channels. Please leave some before creating a new one."; +"TooManyCommunitiesHintEdit" = "Sorry, you are a member of too many groups and channels. For technical reasons, you need to leave one before changing this setting."; +"TooManyCommunitiesHintJoin" = "Sorry, you are a member of too many groups or channels. Please leave one before joining another."; +"TooManyTries" = "Too many tries.\nPlease try again in %1$s."; +"TopAdmins" = "Top admins"; +"TopDaysOfWeekChartTitle" = "Top days of week"; +"TopHoursChartTitle" = "Views by hours (UTC)"; +"TopInviters" = "Top inviters"; +"TopMembers" = "Top members"; +"TopUpViaFragment" = "Top-Up via Fragment"; +"TopUpViaFragmentInfo" = "You can top-up your Gram balance via Fragment."; +"TopUpViaFragmentLink" = "https://fragment.com/ads/topup"; +"TopicChangeIconAndTitleTo" = "%1$s changed the topic name and icon to %2$s"; +"TopicChangeIconAndTitleToInReply" = "changed the topic name and icon to %s"; +"TopicClosed" = "Topic closed"; +"TopicClosed2" = "%s closed the topic"; +"TopicClosedByAdmin" = "The topic is closed"; +"TopicContainsEmojiPackSingle" = "This topic contains **%s**"; +"TopicCreated" = "Topic created"; +"TopicCreator" = "topic creator"; +"TopicEmptyViewDescription" = "Send a message to start the topic."; +"TopicHidden" = "General topic hidden"; +"TopicHidden2" = "%s hid the general topic"; +"TopicIconChanged" = "Topic icon changed"; +"TopicIconChangedTo" = "%1$s changed the topic icon to %2$s"; +"TopicIconChangedToInReply" = "changed topic icon to %s"; +"TopicNotificationsExceptions#one" = "**%d topic** is listed as an exception."; +"TopicNotificationsExceptions#other" = "**%d topics** are listed as exceptions."; +"TopicProfileStatus" = "In %s"; +"TopicRenamed" = "Topic renamed"; +"TopicRenamedTo" = "%1$s renamed the topic to %2$s"; +"TopicRenamedToInReply" = "renamed topic to %s"; +"TopicRestarted" = "Topic reopened"; +"TopicRestarted2" = "%s reopened the topic"; +"TopicShown" = "General topic was unhidden"; +"TopicShown2" = "%s unhid the general topic"; +"TopicViewAsMessages" = "View as messages"; +"TopicViewAsTopics" = "View as topics"; +"TopicWasClosedAction" = "%1$s closed %2$s"; +"TopicWasCreatedAction" = "The topic %s was created"; +"TopicWasIconChangedToAction" = "%1$s changed the topic icon to %2$s"; +"TopicWasRenamedToAction" = "%1$s renamed topic to %2$s"; +"TopicWasRenamedToAction2" = "%1$s changed the topic name and icon to %2$s"; +"TopicWasReopenedAction" = "%1$s reopened %2$s"; +"Topics" = "Topics"; +"TopicsDeleted#one" = "Topic deleted."; +"TopicsDeleted#other" = "Topics deleted."; +"TopicsEnable" = "Enable Topics"; +"TopicsInfo" = "The group chat will be divided into topics created by admins and users."; +"TopicsLayout" = "Topic layout"; +"TopicsLayoutInfo" = "Choose how topics appear for all members."; +"TopicsLayoutList" = "List"; +"TopicsLayoutTabs" = "Tabs"; +"TopicsTitle" = "Topics"; +"TopicsTitleMedia" = "Media"; +"TosAgeText" = "Tap Agree to confirm that you are %1$s or older."; +"TosAgeTitle" = "Age Verification"; +"TosDecline" = "Unfortunately, this means you can't sign up for Telegram.\n\nUnlike other apps, Telegram does not use user data for ad targeting or other commercial purposes. Telegram only stores the information it needs to function as a feature-rich cloud service. You can adjust how your data is used (e.g., delete synced contacts) in Privacy & Security settings.\n\nIf you are not comfortable with Telegram's modest needs, it won't be possible for us to provide you with this service."; +"TosDeclineDeleteAccount" = "Warning, this will irreversibly delete your Telegram account and all the data you store in the Telegram cloud.\n\nImportant: You can Cancel now and export your data first instead of losing it. (To do this, open the latest version of Telegram Desktop and go to Settings > Advanced > Export Telegram data.)"; +"TosUpdateDecline" = "Unfortunately, there are no other options. Unlike other apps, Telegram does not use user data for ad targeting or other commercial purposes. Telegram only stores the information it needs to function as a feature-rich cloud service. You can adjust how your data is used in Privacy and Security settings.\n\nIf you are not comfortable with Telegram's modest needs, it won't be possible for us to provide you with this service."; +"TotalDataUsage" = "Total"; +"TotalDeviceFreeSize" = "%s Free"; +"TotalDeviceSize" = "%s Other Apps"; +"TotalNetworkUsage" = "Total network usage"; +"TranscriptionReportSent" = "Thank you for your feedback."; +"TranscriptionTrialEnd#one" = "You have used your **%1$d** free transcription this week."; +"TranscriptionTrialEnd#other" = "You have used your **%1$d** free transcriptions this week."; +"TranscriptionTrialEndBuy" = "Subscribe to **Telegram Premium** to unlock unlimited transcription."; +"TranscriptionTrialEndWaitOrBuy" = "Wait until %1$s or subscribe to **Telegram Premium** now."; +"TranscriptionTrialLeft#one" = "You have **%1$d** free voice transcription left."; +"TranscriptionTrialLeft#other" = "You have **%1$d** free voice transcriptions left."; +"TranscriptionTrialLeftUntil#one" = "You have **%1$d** free voice transcription left until %2$s."; +"TranscriptionTrialLeftUntil#other" = "You have **%1$d** free voice transcriptions left until %2$s."; +"TranslateCopy" = "Copy Translation"; +"TranslateEntireChat" = "Translate Entire Chat"; +"TranslateLanguageAR" = "Arabic"; +"TranslateLanguageBE" = "Belarusian"; +"TranslateLanguageCA" = "Catalan"; +"TranslateLanguageCS" = "Czech"; +"TranslateLanguageDE" = "German"; +"TranslateLanguageEN" = "English"; +"TranslateLanguageEO" = "Esperanto"; +"TranslateLanguageES" = "Spanish"; +"TranslateLanguageFA" = "Persian"; +"TranslateLanguageFI" = "Finnish"; +"TranslateLanguageFR" = "French"; +"TranslateLanguageGenitiveAR" = "Arabic"; +"TranslateLanguageGenitiveBE" = "Belarusian"; +"TranslateLanguageGenitiveCA" = "Catalan"; +"TranslateLanguageGenitiveDE" = "German"; +"TranslateLanguageGenitiveEN" = "English"; +"TranslateLanguageGenitiveEO" = "Esperanto"; +"TranslateLanguageGenitiveES" = "Spanish"; +"TranslateLanguageGenitiveFA" = "Persian"; +"TranslateLanguageGenitiveFI" = "Finnish"; +"TranslateLanguageGenitiveFR" = "French"; +"TranslateLanguageGenitiveHE" = "Hebrew"; +"TranslateLanguageGenitiveHR" = "Croatian"; +"TranslateLanguageGenitiveHU" = "Hungarian"; +"TranslateLanguageGenitiveID" = "Indonesian"; +"TranslateLanguageGenitiveIT" = "Italian"; +"TranslateLanguageGenitiveJA" = "Japanese"; +"TranslateLanguageGenitiveKO" = "Korean"; +"TranslateLanguageGenitiveKZ" = "Kazakh"; +"TranslateLanguageGenitiveML" = "Malayalam"; +"TranslateLanguageGenitiveMS" = "Malay"; +"TranslateLanguageGenitiveNL" = "Dutch"; +"TranslateLanguageGenitiveNO" = "Norwegian"; +"TranslateLanguageGenitivePO" = "Polish"; +"TranslateLanguageGenitivePT" = "Portuguese"; +"TranslateLanguageGenitiveRO" = "Romanian"; +"TranslateLanguageGenitiveRU" = "Russian"; +"TranslateLanguageGenitiveSK" = "Slovak"; +"TranslateLanguageGenitiveSR" = "Serbian"; +"TranslateLanguageGenitiveSV" = "Swedish"; +"TranslateLanguageGenitiveTA" = "Tamil"; +"TranslateLanguageGenitiveTR" = "Turkish"; +"TranslateLanguageGenitiveUK" = "Ukrainian"; +"TranslateLanguageGenitiveUZ" = "Uzbek"; +"TranslateLanguageGenitiveZH" = "Chinese"; +"TranslateLanguageHE" = "Hebrew"; +"TranslateLanguageHR" = "Croatian"; +"TranslateLanguageHU" = "Hungarian"; +"TranslateLanguageID" = "Indonesian"; +"TranslateLanguageIT" = "Italian"; +"TranslateLanguageJA" = "Japanese"; +"TranslateLanguageKO" = "Korean"; +"TranslateLanguageKZ" = "Kazakh"; +"TranslateLanguageML" = "Malayalam"; +"TranslateLanguageMS" = "Malay"; +"TranslateLanguageNL" = "Dutch"; +"TranslateLanguageNO" = "Norwegian"; +"TranslateLanguagePO" = "Polish"; +"TranslateLanguagePT" = "Portuguese"; +"TranslateLanguageRO" = "Romanian"; +"TranslateLanguageRU" = "Russian"; +"TranslateLanguageSK" = "Slovak"; +"TranslateLanguageSR" = "Serbian"; +"TranslateLanguageSV" = "Swedish"; +"TranslateLanguageTA" = "Tamil"; +"TranslateLanguageTR" = "Turkish"; +"TranslateLanguageUK" = "Ukrainian"; +"TranslateLanguageUZ" = "Uzbek"; +"TranslateLanguageZH" = "Chinese"; +"TranslateMessage" = "Translate"; +"TranslateMessages" = "Translate Messages"; +"TranslateMessagesInfo1" = "The ‘Translate’ button will appear when you make a single tap on a text message."; +"TranslateMessagesInfo2" = "Google may have access to the text of messages you translate."; +"TranslateMore" = "Translate more..."; +"TranslateTo" = "Translate To"; +"TranslateToButton" = "Translate to %s"; +"TranslateToButtonOther" = "Translate to %s"; +"TranslationBarHiddenForChannel" = "Translation bar hidden for this channel."; +"TranslationBarHiddenForChat" = "Translation bar hidden for this chat."; +"TranslationBarHiddenForGroup" = "Translation bar hidden for this group."; +"TranslationFailedAlert1" = "Translation failed. Try again later."; +"TranslationFailedAlert2" = "Translation failed."; +"TrashHintDrag" = "Drag to delete"; +"TrashHintRelease" = "Release to delete"; +"TryAgain" = "Try Again"; +"TurnPasswordOff" = "Disable Password"; +"TurnPasswordOffPassport" = "All data saved in your Telegram Passport will be lost."; +"TurnPasswordOffQuestion" = "Are you sure you want to disable your password?"; +"TurnPasswordOffQuestionTitle" = "Disable password"; +"TwoStepVerification" = "Two-Step Verification"; +"TwoStepVerificationPasswordReturnPassport" = "Return to Passport"; +"TwoStepVerificationPasswordReturnSettings" = "Return to Settings"; +"TwoStepVerificationPasswordSet" = "Password Set!"; +"TwoStepVerificationPasswordSetInfo" = "This password will be required when you log in on a new device in addition to the code you get in the SMS."; +"TwoStepVerificationSetPassword" = "Set Password"; +"TwoStepVerificationShowPassword" = "Show password"; +"TwoStepVerificationTitle" = "Two-Step Verification"; +"TypeLocationGroup" = "Public Link"; +"TypeLocationGroupEdit" = "Tap to add a public link"; +"TypeMessage" = "Message"; +"TypeMessageAskBot" = "Ask %1$s"; +"TypeMessageForStars" = "Message for ⭐️%s"; +"TypeMessageIn" = "Message in %1$s"; +"TypePrivate" = "Private"; +"TypePrivateGroup" = "Private"; +"TypePrivateGroupRestrictedForwards" = "Private restricted"; +"TypePrivateRestrictedForwards" = "Private restricted"; +"TypePublic" = "Public"; +"TypePublicGroup" = "Public"; +"TypeScheduleMessage" = "Schedule message"; +"Typing" = "typing..."; +"URL" = "URL"; +"UnableForward" = "Unable to forward"; +"Unarchive" = "Unarchive"; +"UnarchiveStories" = "Unhide Stories"; +"Unavailable" = "Unavailable"; +"Unban" = "Unban"; +"UnbanText" = "Press and hold user to unban."; +"UnbanUserMonoforum" = "Unban User From Channel"; +"Unblock" = "Unblock user"; +"UnblockText" = "Press and hold user to unblock."; +"UnconfirmedAuthConfirm" = "Yes, it's me"; +"UnconfirmedAuthConfirmed" = "New Login Allowed"; +"UnconfirmedAuthConfirmedBot" = "New Bot Allowed"; +"UnconfirmedAuthConfirmedMessage" = "You can check the list of your active logins in **Settings > Devices**."; +"UnconfirmedAuthDeniedMessageMultiple" = "We have terminated the login attempts from: %s"; +"UnconfirmedAuthDeniedMessageSingle" = "We have terminated the login attempt from %s."; +"UnconfirmedAuthDeniedTitle#one" = "New Login Prevented"; +"UnconfirmedAuthDeniedTitle#other" = "New Logins Prevented"; +"UnconfirmedAuthDeniedWarning" = "Never send your login code to anyone or you can lose your Telegram account!"; +"UnconfirmedAuthDeny" = "No, it’s not me!"; +"UnconfirmedAuthMultiple#one" = "We detected %d new login to your account. Is it you?"; +"UnconfirmedAuthMultiple#other" = "We detected %d new logins to your account. Is it you?"; +"UnconfirmedAuthMultipleFrom#one" = "We detected %d new login to your account from %s. Is it you?"; +"UnconfirmedAuthMultipleFrom#other" = "We detected %d new logins to your account from %s. Is it you?"; +"UnconfirmedAuthSingle" = "We detected a new login to your account from %s. Is it you?"; +"UnconfirmedAuthSingleBot" = "%1$s connected to your account from %2$s. Was this you?"; +"UnconfirmedAuthTitle" = "Someone just got access to your messages!"; +"UnconfirmedAuthTitleBot" = "A bot just got access to your messages!"; +"Underline" = "Underline"; +"Understood" = "👌 Understood"; +"Undo" = "UNDO"; +"UndoAllCustom" = "Undo all custom notification settings for your contacts, groups and channels."; +"UndoNoCaps" = "Undo"; +"Unhide" = "Unhide"; +"UnhideFromTop" = "Pin"; +"UniqueGiftNotFound" = "Gift not found."; +"UniqueGiftNotFoundBurned" = "Gift has already been burned."; +"UnknownError" = "Unknown error"; +"UnknownErrorCode" = "Unknown error: %s"; +"UnknownLocation" = "Unknown Location"; +"Unlock" = "Unlock"; +"UnlockBoostChannelAutotranslation" = "Auto-translation of messages"; +"UnlockBoostChannelFeatures" = "Unlock Features"; +"UnlockFingerprint" = "Unlock with Fingerprint"; +"UnlockPaidContent#one" = "Unlock for ⭐️%d"; +"UnlockPaidContent#other" = "Unlock for ⭐️%d"; +"UnlockPaidContentShort#one" = "⭐️%d"; +"UnlockPaidContentShort#other" = "⭐️%d"; +"UnlockPremium" = "Subscribe to **Telegram Premium** to unlock this and many other features."; +"UnlockPremiumEmoji" = "Unlock Premium Emoji"; +"UnlockPremiumEmojiHint" = "Subscribe to **Telegram Premium** to unlock this emoji."; +"UnlockPremiumEmojiHint2" = "Try sending these emoji in **Saved Messages** for free to test."; +"UnlockPremiumEmojiPack" = "Unlock %1$s"; +"UnlockPremiumEmojiReaction" = "Subscribe to **Telegram Premium** to unlock this reaction."; +"UnlockPremiumIcons" = "Unlock Premium Icons"; +"UnlockPremiumReactions" = "Unlock Premium Reactions"; +"UnlockPremiumReactionsDescription" = "Unlock additional reactions by subscribing to Telegram Premium."; +"UnlockPremiumStickers" = "Unlock Premium Stickers"; +"UnlockPremiumStickersDescription" = "Unlock this sticker and many more by subscribing to Telegram Premium."; +"UnlockPremiumTranscriptionHint" = "Subscribe to **Telegram Premium** to convert voice to text."; +"UnlockSimilar" = "Unlock more channels"; +"UnlockSimilarChannelsPremium#other" = "Subscribe to **Telegram Premium** to unlock up to %d similar channels."; +"UnlockStealthMode" = "Unlock Stealth Mode"; +"UnlockToUse" = "Unlock to use Telegram"; +"Unmute" = "Unmute"; +"UnmuteNotifications" = "Unmute"; +"UnofficialApp" = "unofficial app"; +"UnpinAllMessages" = "UNPIN ALL MESSAGES"; +"UnpinAllMessagesNoCaps" = "Unpin all Messages"; +"UnpinFromTop" = "Unpin from top"; +"UnpinMessage" = "Unpin"; +"UnpinMessageAlert" = "Are you sure you want to unpin this message for everyone?"; +"UnpinMessageAlertTitle" = "Unpin message"; +"UnpinnedDialogsCount#one" = "Chat unpinned"; +"UnpinnedDialogsCount#other" = "%1$d chats unpinned"; +"UnreadMessages" = "Unread Messages"; +"UnsaveMessagesTitle" = "Unsave %1$s"; +"UnsaveSingleMessagesTitle" = "Unsave message"; +"UnsavedChanges" = "Unsaved Changes"; +"UnsavedChangesMessage" = "You have changed the settings of this invite link. Apply changes?"; +"UnsupportedAttachment" = "Unsupported attachment"; +"UnsupportedMedia" = "This message is not supported by your version of Telegram. Update the app to view: https://telegram.org/update"; +"UnsupportedMedia2" = "This message is not supported on your version of Telegram. Please update to the latest version."; +"UnsupportedMediaStandalone" = "This message is not supported on your version of Telegram. Update the app to view: https://telegram.org/dl"; +"UnverifiedApp" = "Unverified App"; +"Unvote" = "Retract Vote"; +"UpToFileSize" = "Up to %s"; +"Update" = "UPDATE"; +"UpdateApp" = "Update app"; +"UpdateAppAlert" = "Sorry, your Telegram app is out of date and can't handle this request. Please update Telegram."; +"UpdateContactsMessage" = "Telegram has detected many unsynced contacts, would you like to sync them now? Choose 'OK' if you're using your own device, SIM card and Google account."; +"UpdateContactsTitle" = "Update contacts?"; +"UpdateNow" = "UPDATE NOW"; +"UpdatePhotoForRest" = "Update Public Photo"; +"UpdateTelegram" = "Update Telegram"; +"UpdatedMinutes#one" = "updated %1$d minute ago"; +"UpdatedMinutes#other" = "updated %1$d minutes ago"; +"UpdatedQuoteMessage" = "**%s** updated the message you are quoting. Edit your quote to make it up-to-date."; +"UpdatedQuoteTitle" = "Quote updated"; +"Updating" = "Updating..."; +"UpgradeGroup" = "Upgrade to Supergroup"; +"UpgradePremiumMessage" = "Upgrade to the annual payment plan for Telegram Premium now to get the discount."; +"UpgradePremiumPerMonth" = "Upgrade for %s per month"; +"UpgradePremiumPerYear" = "Upgrade for %s per year"; +"UpgradePremiumTitle" = "Telegram Premium with a **%1$d%%** discount"; +"UpgradedStories" = "Stories"; +"UploadBotPreview" = "Add"; +"UploadImage" = "Upload image"; +"UploadItems" = "Upload %1$s"; +"UploadSound" = "Upload sound"; +"UploadSpeedLimited" = "Upload speed limited"; +"UploadSpeedLimitedMessage" = "Subscribe to **Telegram Premium** to increase upload speeds %d times."; +"UploadingStory" = "Uploading…"; +"UsbActive" = "USB transfer active"; +"UseDifferentTheme" = "Use a different theme"; +"UseDifferentThemeInfo" = "You can also use an existing custom theme as a foundation for your theme."; +"UseEmoji" = "Use an emoji"; +"UseFileManger" = "Use Another App"; +"UseLessDataAlways" = "Always"; +"UseLessDataNever" = "Never"; +"UseLessDataOnMobile" = "Only on mobile data"; +"UseLessDataOnRoaming" = "Only while roaming"; +"UsePIN" = "Use PIN"; +"UseProxyAddress" = "Server"; +"UseProxyForCalls" = "Use Proxy for Calls"; +"UseProxyForCallsInfo" = "Proxy servers may degrade the quality of your calls."; +"UseProxyInfo" = "SOCKS5 proxy settings."; +"UseProxyPassword" = "Password"; +"UseProxyPort" = "Port"; +"UseProxyRotation" = "Auto-switch proxies"; +"UseProxySecret" = "Secret"; +"UseProxySettings" = "Use Proxy"; +"UseProxySocks5" = "SOCKS5 Proxy"; +"UseProxySponsor" = "Proxy sponsor"; +"UseProxySponsorInfo" = "This channel is shown by your proxy server. To remove this channel from your chat list, disable the proxy in Telegram Settings > Data and Storage."; +"UseProxyTelegram" = "MTProto Proxy"; +"UseProxyTelegramError" = "The proxy you are using is not configured correctly and will be disabled. Please find another one."; +"UseProxyTelegramInfo" = "MTProto proxy settings."; +"UseProxyTelegramInfo2" = "This proxy may display a sponsored channel in your chat list. This doesn't reveal any of your Telegram traffic."; +"UseProxyTitle" = "Proxy Server"; +"UseProxyUsername" = "Username"; +"UserAcceptedToGroupAction" = "un1 was accepted into the group"; +"UserAcceptedToGroupPush" = "%1$s was accepted into the group"; +"UserAcceptedToGroupPushWithGroup" = "%2$s|%1$s was accepted into the group"; +"UserAddedAsAdminHint" = "**%1$s** added as admin"; +"UserAffiliateProgramRowText" = "Distribute links to mini apps and earn a share of their revenue in Stars."; +"UserAffiliateProgramRowTitle" = "Earn Stars"; +"UserBio" = "Bio"; +"UserBioDetail" = "Add a few words about yourself"; +"UserBioEmpty" = "None"; +"UserBioInfo" = "You can add a few lines about yourself. Choose who can see your bio in Settings."; +"UserBirthdayHint" = "🎂 You can send %1$s a gift for their birthday today."; +"UserBlocked" = "User blocked."; +"UserBlockedNonPremium" = "**%s** only accepts messages from contacts and Premium users."; +"UserBlockedNonPremiumButton" = "View"; +"UserBlockedRepliesNonPremium" = "You need a **Premium** Subscription to reply to **%s**’s stories."; +"UserChannelTooMuchJoin" = "Sorry, the target user is a member of too many groups and channels. Please ask them to leave some first."; +"UserColorApplied" = "Your name color has been updated!"; +"UserColorApply" = "Apply Style"; +"UserColorApplyCollectible" = "Wear Collectible"; +"UserColorApplyIcon" = "Apply Color and Icon"; +"UserColorApplyPremium" = "Subscribe to **Telegram Premium** to choose a custom color for your name."; +"UserColorHint" = "This color will be used for your name, the links you send, and replies to your messages."; +"UserColorPreview" = "Your name and replies to your messages will be shown in the selected color."; +"UserColorPreviewLinkDescription" = "Previews of links you send will also use this color."; +"UserColorPreviewLinkTitle" = "Link Preview"; +"UserColorPreviewReply" = "Reply to your message"; +"UserColorPreviewTitle" = "Color Preview"; +"UserColorTabName" = "Name"; +"UserColorTabProfile" = "Profile"; +"UserColorTitle" = "Your Name Color"; +"UserColorUnsaved" = "Unsaved Changes"; +"UserColorUnsavedMessage" = "You have changed your color or icon settings. Apply changes?"; +"UserCustomPhotoSeted" = "You will now always see this photo for **%s's** account."; +"UserDisallowedGifts" = "**%s** disabled receiving gifts."; +"UserProfileCollectibleHeader" = "Use a gift"; +"UserProfileCollectibleInfo" = "Apply your collectible's unique look to your profile."; +"UserProfileColorApplied" = "Your profile color has been updated!"; +"UserProfileColorApplyPremium" = "Subscribe to **Telegram Premium** to choose a custom color for your profile."; +"UserProfileColorEmojiApplied" = "Your profile logo has been set!"; +"UserProfileColorReset" = "Reset Profile Color"; +"UserProfileColorResetApplied" = "Your profile color has been reset!"; +"UserProfileHint" = "Choose a color and a logo for your profile"; +"UserProfileHint2" = "You can change the color of your name and customize replies to you. **Change >**"; +"UserProfileIcon" = "Add Icons to Profile"; +"UserRemovedBy" = "Removed by %1$s"; +"UserRemovedFromChatHint" = "**%1$s** removed from **%2$s**."; +"UserReplyIcon" = "Add Icons to Replies"; +"UserReplyIconHint" = "Make replies to your messages stand out by adding custom icons to them."; +"UserReplyIconOff" = "Off"; +"UserRestricted" = "Sorry, you are not allowed to do this."; +"UserRestrictions" = "User Permissions"; +"UserRestrictionsApplyChanges" = "Unsaved Changes"; +"UserRestrictionsApplyChangesText" = "You have changed this user's rights in **%1$s**. Apply changes?"; +"UserRestrictionsBlock" = "Ban and remove from group"; +"UserRestrictionsBy" = "Restricted by %1$s"; +"UserRestrictionsCanDo" = "What can this user do?"; +"UserRestrictionsCanDoUsers#one" = "What can %1$s selected user do?"; +"UserRestrictionsCanDoUsers#other" = "What can %1$s selected users do?"; +"UserRestrictionsCantModify" = "Locked Option"; +"UserRestrictionsCantModifyDisabled" = "This action is disabled for all members. Please change it in Group Permissions."; +"UserRestrictionsCantModifyEnabled" = "This action is enabled for all members. Please change it in Group Permissions."; +"UserRestrictionsChangeInfo" = "Change Chat Info"; +"UserRestrictionsCustom" = "Custom"; +"UserRestrictionsDisabled" = "This option is disabled for all members in Group Permissions"; +"UserRestrictionsDuration" = "Duration"; +"UserRestrictionsEditTags" = "Edit Own Tags"; +"UserRestrictionsEmbedLinks" = "Embed Links"; +"UserRestrictionsInviteUsers" = "Add Users"; +"UserRestrictionsNoChangeInfo" = "can't change Info"; +"UserRestrictionsNoEditTags" = "no tags"; +"UserRestrictionsNoEmbedLinks" = "no embed links"; +"UserRestrictionsNoInviteUsers" = "can't add users"; +"UserRestrictionsNoPinMessages" = "no pinning"; +"UserRestrictionsNoRead" = "can't read"; +"UserRestrictionsNoSend" = "can't send messages"; +"UserRestrictionsNoSendDocs" = "no files"; +"UserRestrictionsNoSendMedia" = "no media"; +"UserRestrictionsNoSendMusic" = "no music"; +"UserRestrictionsNoSendPhotos" = "no photos"; +"UserRestrictionsNoSendPolls" = "no polls"; +"UserRestrictionsNoSendReactions" = "no reactions"; +"UserRestrictionsNoSendRound" = "no video messages"; +"UserRestrictionsNoSendStickers" = "no stickers & GIFs"; +"UserRestrictionsNoSendText" = "can't send text messages"; +"UserRestrictionsNoSendVideos" = "no videos"; +"UserRestrictionsNoSendVoice" = "no voice"; +"UserRestrictionsPinMessages" = "Pin Messages"; +"UserRestrictionsRead" = "Read Messages"; +"UserRestrictionsSend" = "Send Messages"; +"UserRestrictionsSendFiles" = "Send Files"; +"UserRestrictionsSendMedia" = "Send Media"; +"UserRestrictionsSendMusic" = "Send Music"; +"UserRestrictionsSendPhotos" = "Send Photos"; +"UserRestrictionsSendPolls" = "Send Polls"; +"UserRestrictionsSendPollsShort" = "Send Polls"; +"UserRestrictionsSendReactions" = "Send Reactions"; +"UserRestrictionsSendRound" = "Send Video Messages"; +"UserRestrictionsSendStickers" = "Send Stickers and GIFs"; +"UserRestrictionsSendText" = "Send Text Messages"; +"UserRestrictionsSendVideos" = "Send Videos"; +"UserRestrictionsSendVoices" = "Send Voice Messages"; +"UserRestrictionsUntil" = "Restricted until"; +"UserRestrictionsUntilForever" = "Forever"; +"UserSelectorLimit#one" = "You can select maximum %d user."; +"UserSelectorLimit#other" = "You can select maximum %d users."; +"UserSetAsAdminHint" = "**%1$s** promoted to admin."; +"UserSetPhoto" = "Set photo for %s"; +"UserSuggestBirthday" = "Suggest Date of Birth"; +"UserSuggestBirthdayButton" = "Suggest Date of Birth"; +"UserSuggestBirthdayTitle" = "%s's Birthday"; +"UserUnblocked" = "User unblocked."; +"Username" = "Username"; +"UsernameActivateErrorMessage" = "Sorry, you have too many active public links already. Please hide one of your active public links first."; +"UsernameActivateErrorTitle" = "Too many active links"; +"UsernameActivateLink" = "Activate link"; +"UsernameActivateLinkChannelMessage" = "Do you want to show this link on the channel info page?"; +"UsernameActivateLinkProfileMessage" = "Do you want to show this link on your info page?"; +"UsernameAlso" = "also %1$s"; +"UsernameAvailable" = "%1$s is available."; +"UsernameChecking" = "Checking username..."; +"UsernameCopied" = "Username copied to clipboard."; +"UsernameDeactivateLink" = "Deactivate link"; +"UsernameDeactivateLinkChannelMessage" = "Do you want to hide this link from the channel info page?"; +"UsernameDeactivateLinkProfileMessage" = "Do you want to hide this link from your info page?"; +"UsernameEmpty" = "None"; +"UsernameHelp" = "You can choose a username on **Telegram**. If you do, people will be able to find you by this username and contact you without needing your phone number.\n\nYou can use **a–z**, **0–9** and underscores. Minimum length is **5** characters."; +"UsernameHelpLink" = "This link opens a chat with you:\n%1$s"; +"UsernameInUse" = "This username is already taken."; +"UsernameInUsePurchase" = "This link is already taken.\nHowever, it is currently available for purchase. **Learn more…**"; +"UsernameInvalid" = "This username is invalid."; +"UsernameInvalidLong" = "Username can't exceed 32 characters."; +"UsernameInvalidShort" = "Usernames must have at least 5 characters."; +"UsernameInvalidShortPurchase" = "A basic username must have at least 5 characters.\nHowever, short collectible usernames including this one are available for purchase. **Learn more…**"; +"UsernameInvalidStartNumber" = "Usernames can't start with a number."; +"UsernameLinkActive" = "active"; +"UsernameLinkEditable" = "tap to edit"; +"UsernameLinkInactive" = "inactive"; +"UsernameLinkPlaceholder" = "username"; +"UsernamePlaceholder" = "Your Username"; +"UsernameProfileLinkActive" = "active"; +"UsernameProfileLinkEditable" = "tap to edit"; +"UsernameProfileLinkInactive" = "not active"; +"UsernamesChannelHeader" = "Link order"; +"UsernamesChannelHelp" = "Drag and drop links to change the order in which they will be displayed on the channel info page."; +"UsernamesProfileHeader" = "Username order"; +"UsernamesProfileHelp" = "Drag and drop links to change the order in which they will be displayed on your info page."; +"Users#one" = "%1$d user"; +"Users#other" = "%1$d users"; +"UsersBanned#one" = "%1$d user banned."; +"UsersBanned#other" = "%1$d users banned."; +"UsersCount#one" = "%1$d user"; +"UsersCount#other" = "%1$d users"; +"UsersCountTitle#one" = "%1$d user"; +"UsersCountTitle#other" = "%1$d users"; +"UsersDeselectAll" = "Deselect All"; +"UsersPluralized#one" = "%@ user"; +"UsersPluralized#other" = "%@ users"; +"UsersReported#one" = "%1$d user reported for spam."; +"UsersReported#other" = "%1$d users reported for spam."; +"UsersRestricted#one" = "%1$d user restricted."; +"UsersRestricted#other" = "%1$d users restricted."; +"UsesLimitHelp" = "You can make the link work only for a certain number of users."; +"UsesLimitHint" = "Enter custom limit"; +"VerificationCode" = "Verification Code"; +"VerificationCodeSubtitle" = "Please check your email %1$s (don't forget the spam folder) and enter the code we just sent you."; +"VerifyChatInfo" = "Third-party services, like websites and stores, can send verification codes to your phone number via Telegram instead of SMS. Such codes will appear in this chat.\n\nIf you didn't request any codes —\u00a0don't worry! Most likely, someone made a mistake when entering their number."; +"VerifyCodesNotifications" = "Verification Codes"; +"ViaBot" = "via"; +"Vibrate" = "Vibrate"; +"VibrationDefault" = "Default"; +"VibrationDisabled" = "Disabled"; +"VideoCall" = "Video Call"; +"VideoCallAlert" = "Are you sure you want to video call **%1$s**?"; +"VideoCallAlertTitle" = "Video Call"; +"VideoCallViaTelegram" = "Telegram Video Call"; +"VideoCaption" = "Video Caption"; +"VideoChooseCover" = "Choose cover"; +"VideoConversionAction" = "The video will be published once converted and optimized"; +"VideoConversionDone" = "Video published."; +"VideoConversionDoneView" = "View"; +"VideoConversionInfo" = "The video will be published once converted and optimized."; +"VideoConversionNowSend" = "Send Anyway"; +"VideoConversionNowText" = "This video hasn't been converted and optimized yet. If you send it now, the viewers of the video may experience slow download speed."; +"VideoConversionNowTitle" = "Wait!"; +"VideoConversionText" = "The video will be published after it's optimized for the best viewing experience."; +"VideoConversionTimeInfo" = "Processing video may take a few minutes."; +"VideoConversionTitle" = "Improving Video..."; +"VideoConvertFail" = "Failed to process video file."; +"VideoDoesNotSupportStreaming" = "Video not optimized for streaming. You may need to download it in full to play it."; +"VideoMessagesAutodownload" = "Video messages"; +"VideoMessagesRestrictedByPrivacy" = "**%1$s** doesn't accept video messages."; +"VideoOnceCloseMessage" = "Are you sure you want to stop listening and delete the video message?"; +"VideoOnceCloseTitle" = "Close Video Message"; +"VideoOnceHint" = "This video message can only be played once."; +"VideoOnceOutHint" = "This message will disappear after **%s** plays it once."; +"VideoPlayerChromecast" = "Chromecast"; +"VideoPlayerLoop" = "Loop"; +"VideoPlayerSpeed" = "Speed"; +"VideoPreview" = "Video Preview"; +"VideoPreviewDesrciption" = "Video from your camera will be shared with other members of this video chat"; +"VideoQualityIsTooLow" = "Video quality is too low to change compression settings."; +"VideoSavedHint" = "Video saved to gallery."; +"VideoSavedToDownloadsHint" = "Video saved to Downloads."; +"VideoSavedToDownloadsHintLinked" = "Video saved to **downloads**."; +"VideoSetOnceHint" = "Tap to set this message to **Play Once**."; +"VideoSetOnceHintEnabled" = "The recipient will be able to play it only once."; +"VideoShareAddTimestamp" = "Start at %s"; +"VideoShownOnce" = "This video can only be viewed once."; +"VideoSpeedCustom" = "Custom %s"; +"VideoSpeedFast" = "Fast"; +"VideoSpeedNormal" = "Normal"; +"VideoSpeedSlow" = "Slow"; +"VideoSpeedVeryFast" = "Very fast"; +"VideoSpeedVerySlow" = "Very slow"; +"VideoStartsAt" = "Starts at %s"; +"Videos#one" = "%1$d video"; +"Videos#other" = "%1$d videos"; +"VideosSavedHint#one" = "Video saved to gallery."; +"VideosSavedHint#other" = "%1$d videos saved to gallery."; +"VideosSelected#one" = "%1$d video selected"; +"VideosSelected#other" = "%1$d videos selected"; +"ViewAction" = "View"; +"ViewAnonymously" = "View\nAnonymously"; +"ViewChannel" = "View Channel"; +"ViewChannelProfile" = "View Channel Info >"; +"ViewChannelStats" = "View Channel Stats"; +"ViewChatList" = "View Chat List"; +"ViewCollection" = "VIEW COLLECTION"; +"ViewContact" = "VIEW CONTACT"; +"ViewDetails" = "VIEW CONTACT"; +"ViewDiscussion" = "View Discussion"; +"ViewExceptions" = "View Exceptions"; +"ViewGroupProfile" = "View Group Info >"; +"ViewInChat" = "View in chat"; +"ViewInTopic" = "View in Topic"; +"ViewMessage" = "Open Message"; +"ViewMessageStatistic" = "Message Statistics"; +"ViewPackPreview" = "View Sticker Set"; +"ViewPhotoAction" = "View Photo"; +"ViewProfile" = "View Profile >"; +"ViewReplies#one" = "View %1$d Reply"; +"ViewReplies#other" = "View %1$d Replies"; +"ViewStatistics" = "View Statistics"; +"ViewStats" = "View Stats"; +"ViewStory" = "View Story"; +"ViewThread" = "View Thread"; +"ViewVideoAction" = "View Photo"; +"ViewWallpaperAction" = "View Wallpaper"; +"Viewers" = "Viewers"; +"ViewersWatching#one" = "%1$d viewer"; +"ViewersWatching#other" = "%1$d viewers"; +"ViewersWatchingNobody" = "Nobody watching"; +"ViewingMembers" = "Viewing Members"; +"Views#one" = "%1$s view"; +"Views#other" = "%1$s views"; +"ViewsAndSharesChartTitle" = "Views and Shares"; +"ViewsBySourceChartTitle" = "Views by source"; +"ViewsPerPost" = "Views Per Post"; +"ViewsPerStory" = "Views Per Story"; +"Vignette" = "Vignette"; +"Voice#one" = "%1$d voice message"; +"Voice#other" = "%1$d voice messages"; +"VoiceCallViaCarrier" = "Call via Carrier"; +"VoiceCallViaTelegram" = "Telegram Voice Call"; +"VoiceMessagesRestrictedByPrivacy" = "**%1$s** doesn't accept voice messages."; +"VoiceOnceClose" = "Close"; +"VoiceOnceCloseMessage" = "Are you sure you want to stop listening and delete voice message?"; +"VoiceOnceCloseTitle" = "Close Voice Message"; +"VoiceOnceDeleteClose" = "Delete and Close"; +"VoiceOnceHint" = "This voice message can only be played once."; +"VoiceOnceOutHint" = "This message will disappear after **%s** plays it once."; +"VoiceOnceTurnOnSound" = "Please, turn on the sound first."; +"VoicePauseHint" = "Pause to trim or replay"; +"VoiceSetOnceHint" = "Tap to set this message to **Play Once**."; +"VoiceSetOnceHintEnabled" = "The recipient will be able to listen only once."; +"VoipAddBio" = "Add Bio"; +"VoipAddDescription" = "Add Description"; +"VoipAddPhoto" = "Change Photo"; +"VoipAnswerCall" = "Answer"; +"VoipAnsweringAsAccount" = "answering as %s"; +"VoipAudioRoutingBluetooth" = "Bluetooth"; +"VoipAudioRoutingEarpiece" = "Earpiece"; +"VoipAudioRoutingHeadset" = "Headset"; +"VoipAudioRoutingPhone" = "Phone"; +"VoipAudioRoutingSpeaker" = "Speaker"; +"VoipAudioSwitchedToBluetooth" = "Audio switched to Bluetooth."; +"VoipAudioSwitchedToBluetoothDevice" = "Audio switched to %s."; +"VoipAudioSwitchedToHeadset" = "Audio switched to Headset."; +"VoipAudioSwitchedToPhone" = "Audio switched to Phone."; +"VoipAudioSwitchedToSpeaker" = "Audio switched to Speaker."; +"VoipBackCamera" = "BACK CAMERA"; +"VoipBusy" = "Line busy"; +"VoipCallEncryptionEndToEnd" = "This call is end-to-end encrypted"; +"VoipCallEnded" = "Call ended"; +"VoipCamera" = "Camera"; +"VoipChannelCancelChat" = "Cancel live stream"; +"VoipChannelChatJoined" = "**%1$s** joined the live stream."; +"VoipChannelDisableComments" = "Disable Messages"; +"VoipChannelEditTitle" = "Edit live stream title"; +"VoipChannelEnableComments" = "Enable Messages"; +"VoipChannelEndAlertText" = "Do you want to end this live stream?"; +"VoipChannelEndAlertTitle" = "End live stream"; +"VoipChannelEndChat" = "End live stream"; +"VoipChannelInviteText" = "Hey! Join our live stream: %1$s"; +"VoipChannelInvitedUser" = "You invited **%1$s** to the live stream."; +"VoipChannelJoinAnonymousAdmin" = "Sorry, you can't join live streams as an anonymous administrator."; +"VoipChannelJoinAnonymouseAlert" = "Are sure you want to appear in this live stream as your personal account?"; +"VoipChannelJoinAs" = "Join Live Stream as..."; +"VoipChannelJoinVoiceChatUrl" = "Join Live Stream"; +"VoipChannelLabelLive" = "LIVE"; +"VoipChannelLeaveAlertEndChat" = "End live stream"; +"VoipChannelLeaveAlertText" = "Do you want to leave this live stream?"; +"VoipChannelLeaveAlertTitle" = "Leave live stream"; +"VoipChannelLeaveConferenceAlertEndChat" = "End group call"; +"VoipChannelLeaveConferenceAlertText" = "Do you want to leave this group call?"; +"VoipChannelLeaveConferenceAlertTitle" = "Leave group call"; +"VoipChannelOpenVoiceChat" = "Open live stream"; +"VoipChannelRecordVoiceChat" = "Record live stream"; +"VoipChannelRecording" = "Live stream is being recorded"; +"VoipChannelRemoveMemberAlertText2" = "Do you want to remove %1$s from the live stream and ban them in %2$s?"; +"VoipChannelScheduleInfo" = "Subscribers of the channel will be notified that the live stream starts in %1$s."; +"VoipChannelScheduleVoiceChat" = "Schedule Live Stream"; +"VoipChannelScheduledVoiceChat" = "Scheduled Live Stream"; +"VoipChannelSoundMuted" = "Live stream sound muted."; +"VoipChannelSoundUnmuted" = "Live stream sound unmuted."; +"VoipChannelStart2" = "Subscribers of this channel will be notified once you start the live stream."; +"VoipChannelStartRecordingRtmpVideoText" = "Do you want to start recording this live stream and save the result into a video file?"; +"VoipChannelStartRecordingVideoText" = "Do you want to start recording this live stream and save the result into a video file?\n\nParticipants will see that the chat is being recorded."; +"VoipChannelStartStreaming" = "Start Streaming"; +"VoipChannelStartVoiceChat" = "Start Live Stream"; +"VoipChannelStopRecordingText" = "Do you want to stop recording this live stream?"; +"VoipChannelTitle" = "Live stream title"; +"VoipChannelTooMuch" = "Sorry, this live stream is full."; +"VoipChannelUserChanged" = "Participants of this live stream will now see you as **%1$s**."; +"VoipChannelUserJoined" = "**%1$s** joined the live stream."; +"VoipChannelVideoNotAvailableAdmin" = "The live stream is over %1$s.\nNew participants only have access to the audio stream."; +"VoipChannelViewVoiceChat" = "VIEW LIVE STREAM"; +"VoipChannelVoiceChat" = "Live Stream"; +"VoipChannelWatching" = "watching"; +"VoipChatActiveChats" = "Active video chats"; +"VoipChatChatJoined" = "**%1$s** joined the video chat."; +"VoipChatDisplayedAs" = "You will be displayed as:"; +"VoipChatJoin" = "Join"; +"VoipChatLateBy" = "Late by"; +"VoipChatNotify" = "Notify Me"; +"VoipChatRecentCalls" = "Recent calls"; +"VoipChatReminderHint" = "We will notify you when it starts."; +"VoipChatShare" = "Share"; +"VoipChatStartScreenCapture" = "Share screen"; +"VoipChatStartsIn" = "Starts in"; +"VoipChatStopScreenCapture" = "Stop sharing screen"; +"VoipChatStreamKey" = "Stream Key"; +"VoipChatStreamServerUrl" = "Server URL"; +"VoipChatStreamSettings" = "Stream settings"; +"VoipChatStreamWithAnotherApp" = "You can also stream with **another app >**"; +"VoipChatStreamWithAnotherAppDescription" = "Once you start broadcasting in your streaming app, tap Start Streaming below."; +"VoipChatStreamWithAnotherAppDescriptionStory" = "Once you start broadcasting in your streaming app, tap Connect Stream below."; +"VoipChatUserJoined" = "**%1$s** joined the video chat."; +"VoipConference" = "Ongoing Video Chat"; +"VoipConferenceAddPeople" = "Add Member"; +"VoipConferenceKicked" = "**%1$s** removed from the group chat."; +"VoipConferenceLeave" = "Leave video chat"; +"VoipConferenceRemoveMemberAlertText2" = "Do you want to remove %1$s from this group chat?"; +"VoipConferenceRemoveMemberAlertTitle2" = "Remove participant"; +"VoipConferenceShareLink" = "Invite via Link"; +"VoipConnecting" = "Connecting"; +"VoipDeclineCall" = "Decline"; +"VoipEditBio" = "Edit Bio"; +"VoipEditDescription" = "Edit description"; +"VoipEditName" = "Edit Name"; +"VoipEditTitle" = "Edit name"; +"VoipEditTitleHint" = "Name"; +"VoipEndCall" = "End call"; +"VoipEndCall2" = "End Call"; +"VoipErrorUnknown" = "Can't place a Telegram call at the moment."; +"VoipExchangingKeys" = "Exchanging encryption keys"; +"VoipFailed" = "Failed to connect"; +"VoipFeedbackCommentHint" = "Add an optional comment"; +"VoipFlip" = "Flip"; +"VoipFrontCamera" = "FRONT CAMERA"; +"VoipGroupAdd" = "Add"; +"VoipGroupAddMemberText" = "Do you want to add **%1$s** to **%2$s**?"; +"VoipGroupAddMemberTitle" = "Add Member"; +"VoipGroupAllCanSpeak" = "New participants can speak"; +"VoipGroupAllowToSpeak" = "Allow to speak"; +"VoipGroupAudio" = "Audio"; +"VoipGroupAudioRecordSaved" = "Audio saved to **Saved Messages**."; +"VoipGroupAudioRecordStarted" = "Audio recording started."; +"VoipGroupBioEditAlertText" = "You can add a few lines about yourself. Everyone will see this text."; +"VoipGroupBioEditTitle" = "Bio"; +"VoipGroupCancelChat" = "Cancel video chat"; +"VoipGroupCancelRaiseHand" = "Cancel request to speak"; +"VoipGroupCancelReminder" = "Cancel Reminder"; +"VoipGroupCancelReminderShort" = "Don't Remind"; +"VoipGroupConnecting" = "Connecting..."; +"VoipGroupContinueAs" = "Continue as %1$s"; +"VoipGroupCopyInviteLink" = "Copy Invite Link"; +"VoipGroupCopyInviteLinkCopied" = "Invite link copied to clipboard."; +"VoipGroupCopyListenLink" = "COPY LISTENER LINK"; +"VoipGroupCopyListenLinkNoCaps" = "Copy listener link"; +"VoipGroupCopySpeakerLink" = "COPY SPEAKER LINK"; +"VoipGroupCopySpeakerLinkNoCaps" = "Copy speaker link"; +"VoipGroupDisplayAs" = "Display me as..."; +"VoipGroupEditPermissions" = "Edit permissions"; +"VoipGroupEditTitle" = "Edit video chat title"; +"VoipGroupEnd" = "End"; +"VoipGroupEndAlertText" = "Are you sure you want to end this video chat?"; +"VoipGroupEndAlertTitle" = "End video chat"; +"VoipGroupEndChat" = "End video chat"; +"VoipGroupEndConference" = "End group call"; +"VoipGroupEndedStartNew" = "Video chat ended. Start a new one?"; +"VoipGroupExpandStream" = "Expand"; +"VoipGroupInCallBranding" = "Incoming Group Call"; +"VoipGroupInviteAlreadyParticipant" = "The selected user is already in this video chat."; +"VoipGroupInviteCanSpeak" = "Can speak"; +"VoipGroupInviteListenOnly" = "Listen only"; +"VoipGroupInviteMember" = "Invite Members"; +"VoipGroupInviteText" = "Hey! Join our video chat: %1$s"; +"VoipGroupInvitedToVoiceChat" = "un1 invited un2 to the video chat"; +"VoipGroupInvitedUser" = "You invited **%1$s** to the video chat."; +"VoipGroupJoinAnonymousAdmin" = "Sorry, you can't join video chats as an anonymous administrator."; +"VoipGroupJoinAnonymouseAlert" = "Are you sure you want to appear in this video chat as your personal account?"; +"VoipGroupJoinAs" = "Join Video Chat as..."; +"VoipGroupJoinAsLinstener" = "JOIN AS LISTENER"; +"VoipGroupJoinAsSpeaker" = "JOIN AS SPEAKER"; +"VoipGroupJoinCall" = "Join video chat"; +"VoipGroupJoinVoiceChatUrl" = "Join Video Chat"; +"VoipGroupLeave" = "Leave"; +"VoipGroupLeaveAlertEndChat" = "End video chat"; +"VoipGroupLeaveAlertText" = "Are you sure you want to leave this video chat?"; +"VoipGroupLeaveAlertTitle" = "Leave video chat"; +"VoipGroupMinimizeStream" = "Minimize"; +"VoipGroupMute" = "Mute"; +"VoipGroupMuteForMe" = "Mute for me"; +"VoipGroupMuteMemberAlertText" = "Are you sure you want to mute **%1$s** in this video chat?"; +"VoipGroupMuteMemberAlertTitle" = "Mute microphone"; +"VoipGroupMutedForMe" = "muted for you"; +"VoipGroupOffline" = "You're currently offline. Please connect to the Internet to join video chats."; +"VoipGroupOfflineAirplane" = "You currently have airplane mode enabled. Please turn it off or connect to Wi-Fi to join video chats."; +"VoipGroupOnlyAdminsCanSpeak" = "New participants are muted"; +"VoipGroupOpenChannel" = "Open Channel"; +"VoipGroupOpenChat" = "Open Chat"; +"VoipGroupOpenGroup" = "Open Group"; +"VoipGroupOpenProfile" = "Open Profile"; +"VoipGroupOpenVoiceChat" = "Open video chat"; +"VoipGroupPersonalAccount" = "personal account"; +"VoipGroupRecordCall" = "Start recording"; +"VoipGroupRecording" = "The audio stream is being recorded"; +"VoipGroupRemoveMemberAlertText" = "Do you want to remove %1$s from the group chat?"; +"VoipGroupRemoveMemberAlertText2" = "Do you want to remove %1$s from the video chat and ban them in %2$s?"; +"VoipGroupRemoveMemberAlertTitle" = "Remove member"; +"VoipGroupRemoveMemberAlertTitle2" = "Remove participant"; +"VoipGroupRemovedFromGroup" = "**%1$s** removed from the group."; +"VoipGroupSaveFileHint" = "Recording Title"; +"VoipGroupScheduleInfo" = "Members of the group will be notified that the video chat starts in %1$s."; +"VoipGroupScheduleVoiceChat" = "Schedule Video Chat"; +"VoipGroupScheduledVoiceChat" = "Scheduled Video Chat"; +"VoipGroupSearchMembers" = "Search members to invite..."; +"VoipGroupSetReminder" = "Set Reminder"; +"VoipGroupSetReminderShort" = "Remind me"; +"VoipGroupShareInviteLink" = "Share invite link"; +"VoipGroupShareLink" = "Share Invite Link"; +"VoipGroupSoundMuted" = "Video chat sound muted."; +"VoipGroupSoundUnmuted" = "Video chat sound unmuted."; +"VoipGroupStart2" = "Members of this group will be notified once you start the video chat."; +"VoipGroupStartAs" = "Start Video Chat as..."; +"VoipGroupStartAsInfo" = "Choose whether you want to be displayed as your personal account or as your channel."; +"VoipGroupStartAsInfoGroup" = "Choose whether you want to be displayed as your personal account, this group, or one of your channels."; +"VoipGroupStartNow" = "Start Now"; +"VoipGroupStartNowShort" = "Start Now"; +"VoipGroupStartRecordingRtmpText" = "Do you want to start recording this chat and save the result into an audio file?"; +"VoipGroupStartRecordingRtmpVideoText" = "Do you want to start recording this chat and save the result into a video file?"; +"VoipGroupStartRecordingText" = "Record this chat and save the result into an audio file?\n\nParticipants will see that the chat is being recorded."; +"VoipGroupStartRecordingTitle" = "Start recording"; +"VoipGroupStartRecordingVideoText" = "Do you want to start recording this chat and save it as a file?\n\nOther participants will see that the chat is being recorded."; +"VoipGroupStartVoiceChat" = "Start Video Chat"; +"VoipGroupStopRecordCall" = "Stop recording"; +"VoipGroupStopRecordingText" = "Do you want to stop recording this video chat?"; +"VoipGroupStopRecordingTitle" = "Stop recording"; +"VoipGroupTitle" = "Video chat title"; +"VoipGroupTooMuch" = "Sorry, this video chat is full."; +"VoipGroupUnmute" = "Unmute"; +"VoipGroupUnmuteForMe" = "Unmute for me"; +"VoipGroupUnmuteShort" = "Unmute"; +"VoipGroupUserCanNowSpeak" = "**%1$s** can now speak."; +"VoipGroupUserCanNowSpeakForYou" = "**%1$s** is now unmuted for you."; +"VoipGroupUserCantNowSpeak" = "**%1$s** is now muted in this chat."; +"VoipGroupUserCantNowSpeakForYou" = "**%1$s** is now muted for you in this chat."; +"VoipGroupUserChanged" = "Participants of this video chat will now see you as **%1$s**"; +"VoipGroupUserRemove" = "Remove"; +"VoipGroupVideoChat" = "Video Chat"; +"VoipGroupVideoRecordSaved" = "Video saved to **Saved Messages**."; +"VoipGroupVideoRecordStarted" = "Video recording started."; +"VoipGroupViewVoiceChat" = "VIEW VIDEO CHAT"; +"VoipGroupVoiceChat" = "Video Chat"; +"VoipGroupVolume" = "Volume"; +"VoipGroupYouCanNowSpeak" = "You can now speak."; +"VoipGroupYouCanNowSpeakIn" = "You can now speak in **%1$s**."; +"VoipHangingUp" = "Hanging up"; +"VoipHideEmoji" = "Hide Emoji"; +"VoipHintEncryptionKey" = "Encryption key of this call"; +"VoipHoldAndTalk" = "or hold and speak\n"; +"VoipInCallBranding" = "Telegram Call"; +"VoipInCallBrandingWithName" = "Telegram Call to %s"; +"VoipInConferenceCallBranding" = "Telegram Group Call"; +"VoipInVideoCallBranding" = "Telegram Video Call"; +"VoipInVideoCallBrandingWithName" = "Telegram Video Call to %s"; +"VoipIncoming" = "Incoming call"; +"VoipLiveStream" = "Ongoing Live Stream"; +"VoipMessage" = "Message"; +"VoipMute" = "Mute"; +"VoipMutedByAdmin" = "Muted by admin"; +"VoipMutedByAdminInfo" = "You are in Listen Only Mode"; +"VoipMutedByAdminShort" = "Muted"; +"VoipMutedTapForSpeak" = "tap if you want to speak"; +"VoipMutedTapedForSpeak" = "You asked to speak"; +"VoipMutedTapedForSpeakInfo" = "We let the speakers know"; +"VoipMutedTapedForSpeakShort" = "Wait..."; +"VoipMyMicrophoneState" = "Your microphone is turned off"; +"VoipNeedCameraPermission" = "Telegram needs access to your camera so that you can make video calls.\n\nTap Settings > Permissions, and turn Camera on."; +"VoipNeedMicCameraPermission" = "Telegram needs access to your microphone and camera so that you can make video calls.\n\nTap Settings > Permissions, and turn Microphone and Camera on."; +"VoipNeedMicCameraPermissionWithHint" = "**Telegram** needs camera and microphone access so that you can make video calls. Tap Settings > Permissions, and turn **Microphone** and **Camera** on."; +"VoipNeedMicPermission" = "Telegram needs access to your microphone so that you can make calls.\n\nTap Settings > Permissions, and turn Microphone on."; +"VoipNeedMicPermissionWithHint" = "**Telegram** needs microphone access so that you can make calls. Tap Settings > Permissions, and turn **Microphone** on."; +"VoipNoiseCancellation" = "Noise suppression"; +"VoipNoiseCancellationDisabled" = "Disabled"; +"VoipNoiseCancellationEnabled" = "Enabled"; +"VoipNotificationSettings" = "Calls"; +"VoipOffline" = "You're currently offline. Please connect to the Internet in order to make calls."; +"VoipOfflineAirplane" = "You have airplane mode enabled. Please turn it off or connect to Wi-Fi to make calls."; +"VoipOfflineAirplaneTitle" = "Airplane Mode"; +"VoipOfflineOpenSettings" = "Settings"; +"VoipOfflineTitle" = "Offline"; +"VoipOngoingAlert" = "End call with **%1$s** and start a new one with **%2$s**?"; +"VoipOngoingAlert2" = "End call with **%1$s** and start a video chat in **%2$s**?"; +"VoipOngoingAlertTitle" = "Another call in progress"; +"VoipOngoingChatAlert" = "Leave video chat in **%1$s** and start a new one in **%2$s**?"; +"VoipOngoingChatAlert2" = "Leave video chat in **%1$s** and call **%2$s**?"; +"VoipOngoingChatAlertTitle" = "Another video chat in progress"; +"VoipOngoingConferenceChatAlert" = "Leave group call with **%1$s** and start a new one with **%2$s**?"; +"VoipOngoingConferenceChatAlert2" = "Leave group call with **%1$s** and call **%2$s**?"; +"VoipOutgoingCall" = "Ongoing Telegram call"; +"VoipOutputDevices" = "Output Devices"; +"VoipPeerIncompatible" = "**%1$s**'s app is using an incompatible protocol. They need to update their app before you can call them."; +"VoipPeerOutdated" = "**%1$s**'s app does not support calls. They need to update their app before you can call them."; +"VoipPeerVideoOutdated" = "**%1$s**'s app does not support video calls. They need to update their app before you can call them."; +"VoipPeerVideoOutdatedMakeVoice" = "Make a voice call"; +"VoipPhoneScreen" = "PHONE SCREEN"; +"VoipQuickReplies" = "Respond with Text"; +"VoipQuickRepliesExplain" = "These options can be used as quick responses to incoming calls. Change them to say anything you like."; +"VoipRateCallAlert" = "Please rate the quality of your Telegram call"; +"VoipRateCallDescription" = "Please rate the quality of this call."; +"VoipRateCallTitle" = "Rate this call"; +"VoipReconnecting" = "Reconnecting"; +"VoipRecordAudio" = "AUDIO ONLY"; +"VoipRecordLandscape" = "LANDSCAPE VIDEO"; +"VoipRecordPortrait" = "PORTRAIT VIDEO"; +"VoipRecordStart" = "Start Recording"; +"VoipRecordVoiceChat" = "Record video chat"; +"VoipRecordVoiceChatInfo" = "Choose how to record this chat"; +"VoipRequesting" = "Requesting"; +"VoipRinging" = "Ringing"; +"VoipRingtoneInfo" = "You can customize the ringtone used when this contact calls you on Telegram."; +"VoipSelectAudioOutput" = "Select audio output"; +"VoipSetNewPhoto" = "Add Photo"; +"VoipSettingsRingtone" = "Ringtone"; +"VoipShareVideo" = "Share Video"; +"VoipSpeaker" = "Speaker"; +"VoipStartVideo" = "Start Video"; +"VoipStopVideo" = "Stop Video"; +"VoipStreamStart" = "To stream video with another app,\nenter the server URL and Stream Key\nin your streaming app."; +"VoipSwitch" = "SWITCH"; +"VoipSwitchToVideoCall" = "Switch to video call?"; +"VoipTapToAddBio" = "Tap to add a Bio"; +"VoipTapToMute" = "You're Live"; +"VoipTapToMuteConference" = "You're Live"; +"VoipTapToMuteConferenceShort" = "You're Live"; +"VoipTapToMuteShort" = "You're Live"; +"VoipUnmute" = "Unmute"; +"VoipUseLessData" = "Use Less Data for Calls"; +"VoipUserCameraIsOff" = "%s's camera is off"; +"VoipUserMicrophoneIsOff" = "%s's microphone is off"; +"VoipVideoNotAvailable" = "To watch the video stream, update\nTelegram to the latest version."; +"VoipVideoNotAvailableAdmin" = "The video chat is over %1$s.\nNew participants only have access to the audio stream."; +"VoipVideoOnPause" = "Video on Pause"; +"VoipVideoPrivateScreenSharing" = "Everything on your screen, including notifications, will be shared."; +"VoipVideoScreenSharing" = "You are sharing your screen"; +"VoipVideoScreenSharingTwoLines" = "You are sharing\nyour screen"; +"VoipVideoScreenStopSharing" = "STOP SHARING"; +"VoipVideoUnavailable" = "Video"; +"VoipVoiceChat" = "Ongoing Video Chat"; +"VoipWaiting" = "Waiting"; +"VoipWeakNetwork" = "Weak network signal"; +"Vote#one" = "%1$d vote"; +"Vote#other" = "%1$d votes"; +"WaitMore" = "Wait more"; +"WaitingForNetwork" = "Waiting for network..."; +"WalletAddressCopied" = "Wallet address copied to clipboard"; +"WallpaperDimming" = "Dimming"; +"WallpaperNotification" = "Set a new chat wallpaper"; +"WallpaperPreview" = "Wallpaper"; +"WallpaperSameNotification" = "Set the same chat Wallpaper"; +"WantsToSpeak" = "wants to speak"; +"Warmth" = "Warmth"; +"Warning" = "Warning"; +"WearAuthEmojis" = "Compare emojis on your watch"; +"WearAuthEmojisConfirm" = "Confirm"; +"WearAuthEmojisLogIn" = "Log In"; +"WearAuthEmojisTitle" = "Match on Phone"; +"WearAuthEmojisTitleWaiting" = "Waiting for phone…"; +"WearAuthIntroQrButton" = "Login via QR instead"; +"WearAuthIntroTitle" = "Check on your phone"; +"WearAuthText" = "Connect your account to Wear OS device\nto use it separately from your watch."; +"WearAuthTitle" = "Use Telegram with Wear OS"; +"WearCouldntLoad" = "Couldn't load"; +"WearCouldntSignIn" = "Couldn't sign in"; +"WearDeletedMessage" = "Deleted Message"; +"WearLoginQRHint" = "Scan QR on mobile at\nDevices > Add Device"; +"WearLoginQRTitle" = "Log In to Telegram"; +"WearNewNotification" = "New notification"; +"WearOpenOnPhone" = "Open on Phone"; +"WearVoiceReleaseToStop" = "Release to stop."; +"WearVoiceTapAgainToStop" = "Tap again to stop."; +"WebAppDisclaimerUrl" = "https://telegram.org/tos/mini-apps"; +"WebBookmark" = "Bookmark"; +"WebBookmarkAdd" = "Add Bookmark"; +"WebBookmarkedToast" = "Link added to Bookmarks in **Saved Messages**."; +"WebBookmarks" = "Bookmarks"; +"WebBrowserDeleteAllExceptionsMessage" = "Are you sure you want to delete all website exceptions?"; +"WebBrowserDeleteAllExceptionsTitle" = "Delete All Exceptions"; +"WebBrowserExceptionsLimitMessage" = "You have reached the maximum number of website exceptions. Please remove some existing ones to add new ones."; +"WebBrowserExceptionsLimitTitle" = "Limit Reached"; +"WebBrowserShowCloseButton" = "Show Close Button"; +"WebBrowserShowCloseButtonInfo" = "Add a Close button when viewing websites."; +"WebDownloadAlertInfo" = "Start downloading **%s**?"; +"WebDownloadAlertInfoWithSize" = "Start downloading **%1$s** (%2$s)?"; +"WebDownloadAlertTitle" = "Download file"; +"WebDownloadAlertYes" = "Download"; +"WebDownloading" = "Downloading..."; +"WebDownloadingFile" = "Downloading %s..."; +"WebEmpty" = "Webpage"; +"WebErrorInfo" = "Failed to load page."; +"WebErrorInfoBot" = "Failed to load **%s**."; +"WebErrorInfoDomain" = "Failed to load **%s**."; +"WebErrorTitle" = "Oops..."; +"WebForward" = "Go Forward"; +"WebHistory" = "History"; +"WebNoBookmarks" = "No bookmarks found"; +"WebNoHistory" = "No history yet"; +"WebNoSearchedHistory" = "No search results"; +"WebRecentClear" = "Clear"; +"WebRecentClearText" = "Do you want to clear your recent search terms?"; +"WebRecentClearTitle" = "Clear recent searches?"; +"WebSectionBookmarks" = "Bookmarks"; +"WebSectionRecent" = "Recent searches"; +"WebSessionsTitle" = "Logged In with Telegram"; +"WebViewRequestCameraMicPermission" = "Allow **%1$s** to access to your camera and microphone?\n\n**%1$s** will be able to access your camera and microphone when this website is open."; +"WebViewRequestCameraMicPermissionWithHint" = "Allow **%1$s** to access to your camera and microphone?\n\n**%1$s** will be able to access your camera and microphone when this website is open.\n\nEnable camera access in Settings > Permissions > Camera."; +"WebViewRequestCameraPermission" = "Allow **%1$s** to access to your camera?\n\n**%1$s** will be able to access your camera when this website is open."; +"WebViewRequestCameraPermissionWithHint" = "Allow **%1$s** to access to your camera?\n\n**%1$s** will be able to access your camera when this website is open.\n\nEnable camera access in Settings > Permissions > Camera."; +"WebViewRequestGeolocationPermission" = "Allow **%1$s** to access to your location?\n\n**%1$s** will be able to access your location when this website is open."; +"WebViewRequestGeolocationPermissionWithHint" = "Allow **%1$s** to access to your location?\n\n**%1$s** will be able to access your location when this website is open.\n\nGo to Settings > Permissions and turn **Location** on to share location data."; +"WebViewRequestMicrophonePermission" = "Allow **%1$s** to access to your microphone?\n\n**%1$s** will be able to access your microphone when this website is open."; +"WebViewRequestMicrophonePermissionWithHint" = "Allow **%1$s** to access to your microphone?\n\n**%1$s** will be able to access your microphone when this website is open.\n\nEnable microphone access in Settings > Permissions > Microphone."; +"WebsiteSays" = "%s says"; +"Weeks#one" = "%1$d week"; +"Weeks#other" = "%1$d weeks"; +"WhenConnectedOnWiFi" = "When connected to Wi-Fi"; +"WhenRoaming" = "When roaming"; +"WhenUsingMobileData" = "When using mobile data"; +"White" = "White"; +"WhoCanAddMe" = "Who can add me?"; +"WhoCanAddMeInfo" = "You can restrict who can add you to groups and channels with granular precision."; +"WhoCanAddMembers" = "Who can add members?"; +"WhoCanAddMembersAdmins" = "Only Admins"; +"WhoCanAddMembersAllMembers" = "All Members"; +"WhoCanCallMe" = "Who can call me?"; +"WhoCanCallMeInfo" = "Choose exactly who can and can't call you."; +"WhoCanSee" = "Who can see"; +"WiFiUsage" = "Wi-Fi Data Usage"; +"WidgetChats" = "Chats"; +"WidgetLoggedOff" = "Account was logged off."; +"WidgetPasscode" = "\u200b\n\nNote: Your passcode will be **ignored** for widgets."; +"WidgetPasscode2" = "Warning: Your widgets will show all recent messages, but your passcode will be required to open the app and reply."; +"WidgetPasscodeEnable" = "\u200b\n\nYour passcode **will not affect** widgets added to homescreen."; +"WidgetPasscodeEnable2" = "Your passcode **will not affect** widgets added to your homescreen."; +"WidgetPreview" = "Preview of the widget"; +"WidgetShortcuts" = "Shortcuts"; +"WillUnmuteIn" = "In %1$s"; +"WithdrawChannelAlertText" = "You can collect rewards only if you have:"; +"WithinAMonth" = "last seen within a month"; +"WithinAWeek" = "last seen within a week"; +"WrongCode" = "Wrong code, please try again."; +"WrongCodeTitle" = "Wrong code"; +"WrongCountry" = "Invalid country code"; +"WrongNumber" = "Wrong number?"; +"WrongNumberFormat" = "Wrong format"; +"YearAgo" = "a year ago"; +"Years#one" = "%1$d year"; +"Years#other" = "%1$d years"; +"YearsAgo#one" = "%d year ago"; +"YearsAgo#other" = "%d years ago"; +"YearsShort#one" = "%1$dy"; +"YearsShort#other" = "%1$dy"; +"Yellow" = "Yellow"; +"Yesterday" = "yesterday"; +"YesterdayAt" = "yesterday at"; +"YesterdayAtFormatted" = "yesterday at %1$s"; +"YouBlockedUser" = "Sorry, you can't add this user or bot to groups because you've blocked them. Please unblock to proceed."; +"YouBoostedChannel" = "You boosted the channel"; +"YouBoostedChannel2" = "You boosted %s!"; +"YouBoostedGroup" = "You boosted the group"; +"YouCreatedBroadcastList" = "You created a broadcast list"; +"YouHaveNewMessage" = "You have a new message"; +"YouJoinedChannel" = "You joined channel %s"; +"YouLeft" = "You left the group"; +"YouSenderNameHidden" = "You (sender name hidden)"; +"YouSendersNameHidden" = "You (sender names hidden)"; +"YouTubeVideoErrorHTML" = "An error occurred while trying to play this video."; +"YouTubeVideoErrorInvalid" = "Video URL is invalid."; +"YouTubeVideoErrorNotAvailableInApp" = "This video can't be played inside this app."; +"YouTubeVideoErrorNotFound" = "Video not found."; +"YouTubeVideoErrorOpenExternal" = "Watch on YouTube"; +"YouWereKicked" = "you were removed"; +"YourBioChanged" = "Bio updated."; +"YourCode" = "Phone Verification"; +"YourContacts" = "Your contacts on Telegram"; +"YourContactsToInvite" = "Your contacts"; +"YourCurrentPlan" = "your current plan"; +"YourEmail" = "Your email"; +"YourEmailAlmostThere" = "Almost there!"; +"YourEmailAlmostThereText" = "Please check your email (don't forget the spam folder) to complete Two-Step Verification setup."; +"YourEmailCode" = "Your Email Code"; +"YourEmailCodeInfo" = "Please confirm your email address by entering the code you just received on %1$s."; +"YourEmailInfo" = "Please add your valid email. It is the only way to recover a forgotten password."; +"YourEmailSkip" = "Skip"; +"YourEmailSkipWarning" = "Warning"; +"YourEmailSkipWarningText" = "No, seriously.\n\nIf you forget your password, you will lose access to your Telegram account. There will be no way to restore it."; +"YourEmailSuccessChangedText" = "Your recovery email for Two-Step Verification has been changed."; +"YourEmailSuccessText" = "Your recovery email for Two-Step Verification is now active."; +"YourLocatedChannelsTooMuch" = "Sorry, you have too many location-based groups already. Please delete one of your existing ones first."; +"YourLoginEmailChangedSuccess" = "Your email has been changed."; +"YourName" = "Your Name"; +"YourNameChanged" = "Name updated."; +"YourNetworkUsageSince" = "Your network usage since %1$s"; +"YourNewEmail" = "Your new email"; +"YourNumber" = "Your phone number"; +"YourPassword" = "Your Password"; +"YourPasswordChangedSuccessText" = "Your password for Two-Step Verification has been changed."; +"YourPasswordHeader" = "Your password"; +"YourPasswordRemember" = "Your account is protected by 2-Step Verification.\nDo you still remember your password?"; +"YourPasswordRememberNo" = "Not sure"; +"YourPasswordRememberYes" = "Yes, definitely"; +"YourPasswordReset" = "Your password was successfully deactivated"; +"YourPasswordSuccess" = "Success!"; +"YourPasswordSuccessText" = "Your password for Two-Step Verification is now active."; +"YourPhone" = "Your Phone"; +"YourProfilePhotoDone" = "Your profile photo is now set."; +"YourProfilePhotoUploading" = "Your profile photo is uploading..."; +"YourProfileVideoDone" = "Your profile video is now set."; +"YourProfileVideoUploading" = "Your profile video is uploading..."; +"YourPublicCommunities" = "Your public communities"; +"YourStarsBalance" = "your balance"; +"YourTonBalance" = "your balance"; +"YourVersionIsLatest" = "Your version is up to date."; +"ZoomOut" = "Zoom out"; +"_tg_cancel" = "Cancel"; +"_tg_open_google_play" = "Open Google Play"; +"chatDate" = "MMMM d"; +"chatDateShort" = "MMM d"; +"chatFullDate" = "MMMM d, yyyy"; +"download_telegram_text" = "You need Telegram to log in to **%s**. Would you like to install it now?"; +"formatDateAtTime" = "%1$s at %2$s"; +"formatDateSchedule" = "MMM d"; +"formatDateScheduleDay" = "EEE MMM d"; +"formatDateScheduleYear" = "MMM d yyyy"; +"formatterBannedUntil12H" = "MMM dd yyyy, h:mm a"; +"formatterBannedUntil24H" = "MMM dd yyyy, HH:mm"; +"formatterBannedUntilThisYear12H" = "MMM dd, h:mm a"; +"formatterBannedUntilThisYear24H" = "MMM dd, HH:mm"; +"formatterBoostExpired" = "MMM dd, yyyy"; +"formatterDay12H" = "h:mm a"; +"formatterDay24H" = "HH:mm"; +"formatterDayWithSeconds12H" = "h:mm:ss a"; +"formatterDayWithSeconds24H" = "HH:mm:ss"; +"formatterGiveawayCard" = "dd MMM yyyy"; +"formatterGiveawayMonthDay" = "MMMM dd"; +"formatterGiveawayMonthDayYear" = "MMMM dd, yyyy"; +"formatterMonth" = "MMM dd"; +"formatterMonthName" = "LLLL"; +"formatterMonthOnly" = "MMMM"; +"formatterMonthYear" = "MMMM yyyy"; +"formatterMonthYear2" = "LLLL yyyy"; +"formatterStats12H" = "MMM dd yyyy, h:mm a"; +"formatterStats24H" = "MMM dd yyyy, HH:mm"; +"formatterWeek" = "EEE"; +"formatterWeekLong" = "EEEE"; +"formatterYear" = "dd.MM.yy"; +"formatterYearMax" = "dd.MM.yyyy"; +"items#one" = "%1$d item"; +"items#other" = "%1$d items"; +"login_with_telegram" = "Log in with Telegram"; +"messages#one" = "%1$d message"; +"messages#other" = "%1$d messages"; +"telegram_passport" = "Telegram Passport"; +"FilterByUser" = "Filter by user"; +"Gift" = "Gift"; +"GiftDiscount" = "-%1$d%"; +"Giveaway" = "Giveaway"; +"Password" = "Password"; +"Previous" = "Previous"; +"Volume" = "Volume"; diff --git a/data/langpack/android/android_zh-hans_v59665710.strings b/data/langpack/android/android_zh-hans_v59665710.strings new file mode 100644 index 00000000..3c9874c6 --- /dev/null +++ b/data/langpack/android/android_zh-hans_v59665710.strings @@ -0,0 +1,10973 @@ +"AIEditor" = "AI 编辑器"; +"AIEditorAddStyle" = "添加样式"; +"AIEditorAfter" = "应用样式后"; +"AIEditorAnotherExample" = "另一个示例\u00a0"; +"AIEditorApply" = "应用"; +"AIEditorBefore" = "原文"; +"AIEditorChooseStyle" = "选择风格"; +"AIEditorCreatedBy" = "由 **%s** 建立。"; +"AIEditorDeleteStyle" = "删除样式"; +"AIEditorDeleteStyleText" = "您确定要删除此样式吗?此样式将从所有已安裝的用户端移除。"; +"AIEditorEditStyle" = "编辑样式"; +"AIEditorEmojify" = "表情化"; +"AIEditorFrom" = "从 %s"; +"AIEditorFromOther" = "来自 %s"; +"AIEditorHint" = "使用 AI 来改写、翻译和修正您的文本。"; +"AIEditorLimitButton" = "提升上限"; +"AIEditorLimitText" = "获取 **Telegram 高级版** 即可享有每天 **50 倍** 的文字编辑次数。"; +"AIEditorLimitTitle" = "已达每日上限"; +"AIEditorNewStyle" = "新样式"; +"AIEditorOriginal" = "原始"; +"AIEditorOriginalText" = "原文"; +"AIEditorRemoveStyle" = "移除样式"; +"AIEditorResult" = "结果"; +"AIEditorShareStyle" = "分享样式"; +"AIEditorStyleAddLink" = "添加链接到我的账户"; +"AIEditorStyleCreate" = "创建"; +"AIEditorStyleDone" = "完成"; +"AIEditorStyleEdit" = "保存"; +"AIEditorStyleLimitText" = "您最多可保存 %1$d 种风格。"; +"AIEditorStyleLimitTextPremium" = "订阅 **Telegram 高级版** 以保存最多 %2$d 种风格。"; +"AIEditorStyleLimitTitle" = "已达到上限"; +"AIEditorStyleNewCreate" = "创建"; +"AIEditorStyleNotFound" = "未找到 AI 样式。"; +"AIEditorStylePromptHint" = "提示词(例如:“用豪迈的海盗风格写作,使用‘哟呵’、‘老伙计’等词语,并提及宝藏和大海”)"; +"AIEditorStyleSharedTo" = "AI 风格链接已发送到 **%s**。"; +"AIEditorStyleSharedToManyChats#other" = "AI 风格链接已发送到 ** %1$d 个聊天**。"; +"AIEditorStyleSharedToSavedMessages" = "AI 风格链接已发送到 **我的收藏**。"; +"AIEditorStyleText" = "应用此样式\n即可立即重写您的消息。"; +"AIEditorStyleTitleHint" = "样式名称(例如:“海盗”)"; +"AIEditorTabFix" = "修正"; +"AIEditorTabStyle" = "风格"; +"AIEditorTabTranslate" = "翻译"; +"AIEditorTo" = "到 %s"; +"AIEditorToOther" = "到 %s"; +"AIEditorToneAddedText" = "输入下一条长息时,点击 ’AI‘> ‘%s’。"; +"AIEditorToneAddedTitle" = "样式已添加"; +"AIEditorToneCreatedText" = "按住样式即可编辑或分享它。"; +"AIEditorToneCreatedTitle" = "%s 风格已创建"; +"AIEditorToneNeutral" = "中立"; +"AIEditorUsedBy#other" = "被 %d 人应用。"; +"ALongTimeAgo" = "很久没上线"; +"Abort" = "终止"; +"AbortEmail" = "放弃恢复密码邮箱设置"; +"AbortPassword" = "取消两步验证设置"; +"AbortPasswordMenu" = "终止设置"; +"AboutPremiumDescription" = "Telegram 的免费版本已经为用户提供了远超其它聊天软件的便利,而 **Telegram 高级版**将给您带来更多功能。"; +"AboutPremiumDescription2" = "**Telegram 高级版** 需要付费,因为 Telegram 需要为大多数会员特性付出更多开销,如数据中心和服务器提供商。**Telegram 高级版** 用户能帮助我们支付这些开销,进而保持对所有人免费。"; +"AboutPremiumTitle" = "关于 Telegram 高级版"; +"AboutRevenueSharingAds" = "关于这些广告"; +"AboutTelegramPremium" = "关于 Telegram 高级版"; +"AccActionCancelDownload" = "取消下载"; +"AccActionChatPreview" = "聊天预览"; +"AccActionCloseTranscription" = "关闭转录"; +"AccActionDownload" = "下载"; +"AccActionEnterSelectionMode" = "进入选择模式"; +"AccActionMessageOptions" = "消息选项"; +"AccActionOnceActivate" = "启用播放一次功能"; +"AccActionOnceDeactivate" = "停用播放一次功能"; +"AccActionOpenFile" = "打开文件"; +"AccActionOpenForwardedOrigin" = "转发来源"; +"AccActionOpenTranscription" = "开启语音转录"; +"AccActionPause" = "暂停"; +"AccActionPlay" = "播放"; +"AccActionRecordVideo" = "录制视频"; +"AccActionResume" = "恢复"; +"AccActionTakePicture" = "拍摄照片"; +"AccAutoDeleteTimer" = "自动删除计时器"; +"AccDescrArchivedChatsHidden" = "已隐藏归档聊天"; +"AccDescrArchivedChatsShown" = "已显示归档聊天"; +"AccDescrAspectRatio" = "宽高比"; +"AccDescrAttachButton" = "添加媒体文件"; +"AccDescrBackspace" = "退格"; +"AccDescrBotCommands" = "机器命令"; +"AccDescrBotKeyboard" = "机械键盘"; +"AccDescrBotMenu" = "机器人菜单"; +"AccDescrBrushType" = "笔刷类型"; +"AccDescrCameraFlashAuto" = "闪光灯,自动"; +"AccDescrCameraFlashOff" = "闪光灯,关闭"; +"AccDescrCameraFlashOn" = "闪光灯,开启"; +"AccDescrCameraGallery" = "相册"; +"AccDescrCancelEdit" = "取消编辑"; +"AccDescrCancelForward" = "取消转发"; +"AccDescrCancelReply" = "取消回复"; +"AccDescrCaptureStory" = "拍下动态"; +"AccDescrChanSilentOff" = "发送通知"; +"AccDescrChanSilentOn" = "静默发送"; +"AccDescrChangeProfilePicture" = "更改头像"; +"AccDescrChannel" = "频道"; +"AccDescrCloseMenu" = "关闭菜单"; +"AccDescrClosePlayer" = "关闭音频播放器"; +"AccDescrCloseTrendingEmoji" = "关闭热门表情"; +"AccDescrCloseTrendingStickers" = "关闭热门贴纸"; +"AccDescrCollage" = "拼贴"; +"AccDescrCollageClose" = "退出拼贴模式"; +"AccDescrCollapsePanel" = "收起面板"; +"AccDescrContactSorting" = "更改排序"; +"AccDescrCustomEmoji" = "自定义表情"; +"AccDescrDocumentType" = ",%s 文件"; +"AccDescrDownloadProgress" = "已下载 %2$s 中的 %1$s"; +"AccDescrDualCameraOff" = "双镜头相机,关闭"; +"AccDescrDualCameraOn" = "双镜头相机,打开"; +"AccDescrEditing" = "编辑"; +"AccDescrEmojiButton" = "表情、贴纸和 GIF 动态图"; +"AccDescrExpandPanel" = "展开面板"; +"AccDescrFingerprint" = "指纹"; +"AccDescrForwarding" = "转发自"; +"AccDescrGIFs" = "GIF"; +"AccDescrGoBack" = "返回"; +"AccDescrGoToMessage" = "转到消息"; +"AccDescrGroup" = "群组"; +"AccDescrHideAccounts" = "隐藏账号"; +"AccDescrIVAudio" = "音频"; +"AccDescrIVBlockquote" = "引用"; +"AccDescrIVCode" = "代码"; +"AccDescrIVCollapsed" = "已折叠"; +"AccDescrIVDetails" = "详细"; +"AccDescrIVEmbed" = "嵌入内容"; +"AccDescrIVEmbedPost" = "嵌入帖子"; +"AccDescrIVExpanded" = "已扩展"; +"AccDescrIVFooter" = "页尾"; +"AccDescrIVHeading" = "标题"; +"AccDescrIVKicker" = "语音朗读说明"; +"AccDescrIVPullquote" = "引文"; +"AccDescrIVRelatedArticle" = "相关文章"; +"AccDescrIVRelatedArticles" = "相关文章"; +"AccDescrIVSlideshow" = "幻灯片"; +"AccDescrIVTable" = "表格"; +"AccDescrIVTitle" = "标题"; +"AccDescrInstantCamera" = "切换到摄像模式"; +"AccDescrLike" = "点赞"; +"AccDescrLiteBatteryLevelAnnounce" = "%s%%"; +"AccDescrLiteBatteryWhenBelow" = "当低于 %s%% 时启用"; +"AccDescrLivePhoto" = "实况照片"; +"AccDescrLocationNotify" = "当其他人在一定范围内通知我。"; +"AccDescrLockRecording" = "锁定录制"; +"AccDescrMentionCount#other" = "%1$d 次提及"; +"AccDescrMentionDown" = "下一步"; +"AccDescrMentionReaction" = "有新的表情回应"; +"AccDescrMirror" = "镜像"; +"AccDescrMoreOptions" = "更多选项"; +"AccDescrMsgNotPlayed" = "未播放"; +"AccDescrMsgPlayed" = "已播放"; +"AccDescrMsgRead" = "已读"; +"AccDescrMsgSending" = "正在发送"; +"AccDescrMsgSendingError" = "发送错误"; +"AccDescrMsgUnread" = "未读"; +"AccDescrMusicInfo" = "%2$s 由 %1$s"; +"AccDescrMuteMic" = "麦克风静音"; +"AccDescrMyLocation" = "我的位置"; +"AccDescrNotificationsMuted" = "已静音"; +"AccDescrNumberOfPeopleReactions#other" = "%1$d 个人以 %2$s 回应"; +"AccDescrNumberOfReplies#other" = "有 %1$d 条回复"; +"AccDescrNumberOfViews#other" = "%1$d 次查看"; +"AccDescrOpenChat" = "打开聊天"; +"AccDescrOpenInPhotoViewer" = "在图片浏览器中查看"; +"AccDescrOpenMenu" = "打开导航菜单"; +"AccDescrOpenMenu2" = "打开菜单"; +"AccDescrPageDown" = "转到底部"; +"AccDescrPaint" = "画"; +"AccDescrPasscodeLock" = "锁定应用"; +"AccDescrPasscodeUnlock" = "解锁应用程序"; +"AccDescrPersonHasSeen" = "%1$s 人已读"; +"AccDescrPhotoAdjust" = "调整"; +"AccDescrPhotoEditor" = "图片编辑器"; +"AccDescrPhotoViewer" = "照片浏览器"; +"AccDescrPipMode" = "画中画模式"; +"AccDescrPlaceText" = "放置文字"; +"AccDescrPlayerDuration" = "%2$s 中的 %1$s"; +"AccDescrPlayerSpeed" = "2 倍速播放"; +"AccDescrPollVotesMentionDown" = "前往下一个未读投票"; +"AccDescrPostedDate" = "发布于 %s"; +"AccDescrPremium" = "高级版账号"; +"AccDescrPremiumSpeed" = "增加速度"; +"AccDescrPrevious" = "上一个"; +"AccDescrProfileMusic" = "资料页音乐"; +"AccDescrProfilePicture" = "头像"; +"AccDescrProfileRatingLevel" = "个人资料评级"; +"AccDescrQuizCorrectAnswer" = "正确答案"; +"AccDescrQuizExplanation" = "说明"; +"AccDescrQuizIncorrectAnswer" = "答案错误"; +"AccDescrRateTranscriptionDown" = "差评"; +"AccDescrRateTranscriptionUp" = "好评"; +"AccDescrReactedWith" = "%1$s 以 %2$s 回应"; +"AccDescrReactionMentionDown" = "前往下一个未读表情回应"; +"AccDescrReceivedDate" = "已收到 %s"; +"AccDescrRepeatList" = "重复播放列表"; +"AccDescrRepeatOff" = "重复,关闭"; +"AccDescrRepeatOne" = "重复,一次"; +"AccDescrReplying" = "回复"; +"AccDescrRotate" = "旋转"; +"AccDescrRoundVideo" = "视频消息"; +"AccDescrScheduledDate" = "预定于%s"; +"AccDescrSearchByUser" = "依据用户进行筛选"; +"AccDescrSearchNext" = "下一条的搜索结果"; +"AccDescrSearchPrev" = "上一条的搜索结果"; +"AccDescrSecretChat" = "私密聊天"; +"AccDescrSendAs" = "以 %1$s 发送"; +"AccDescrSendAsPeer" = "以 %1$s 发送"; +"AccDescrSendAudio#other" = "发送 %1$d 个音频文件"; +"AccDescrSendFiles#other" = "发送 %1$d 个文件"; +"AccDescrSendPhotos#other" = "发送 %1$d 张照片"; +"AccDescrSentDate" = "已发送于 %s"; +"AccDescrShareInChats#other" = "分享到 %1$d 个聊天"; +"AccDescrShowAccounts" = "显示账号"; +"AccDescrShowKeyboard" = "显示键盘"; +"AccDescrShutter" = "快门"; +"AccDescrSpeedSlider" = "选择速度"; +"AccDescrStartRecording" = "开始录制"; +"AccDescrStickerSet" = "贴纸设置"; +"AccDescrStickers" = "贴纸"; +"AccDescrStopLiveLocation" = "停止共享实时位置"; +"AccDescrStopRecording" = "停止录制"; +"AccDescrStoryPinned" = "置顶"; +"AccDescrSwitchCamera" = "切换相机"; +"AccDescrSwitchToDayTheme" = "切换日间模式"; +"AccDescrSwitchToNightTheme" = "切换到夜间模式"; +"AccDescrTabs" = "网站标签页 %1$s"; +"AccDescrTagMessage" = "编辑消息标签"; +"AccDescrTakeMorePics" = "再拍一张"; +"AccDescrTakePhoto" = "拍照"; +"AccDescrTopic" = "话题"; +"AccDescrUnreadCount#other" = "%1$d 条未读聊天"; +"AccDescrUploadProgress" = "已上传 %2$s 中的 %1$s"; +"AccDescrUserOnline" = "在线"; +"AccDescrUserOptions" = "用户选项"; +"AccDescrVerified" = "已验证"; +"AccDescrVideoCompressHigh" = "高品质"; +"AccDescrVideoCompressLow" = "小尺寸"; +"AccDescrVideoMessage" = "录制视频消息"; +"AccDescrVideoQuality" = "视频画质"; +"AccDescrVoiceMessage" = "录制语音消息"; +"AccDescrVoipCamOff" = "摄像头已关闭"; +"AccDescrVoipCamOn" = "摄像头已开启"; +"AccDescrVoipCamSwitchedToBack" = "已切换至后置摄像头"; +"AccDescrVoipCamSwitchedToFront" = "已切换至前置摄像头"; +"AccDescrVoipMicOff" = "麦克风已关闭"; +"AccDescrVoipMicOn" = "麦克风已开启"; +"AccDescrYouReactedWith" = "您以 %1$s 回应"; +"AccExitFullscreen" = "退出全屏"; +"AccPinnedMessagesList" = "置顶消息列表"; +"AccReleaseForArchive" = "松开查看归档"; +"AccReleaseForGeneral" = "释放以显示 #通用"; +"AccSwipeForArchive" = "下滑查看归档"; +"AccSwipeForGeneral" = "向下滑动以显示 #通用"; +"AccSwitchToFullscreen" = "切换到全屏"; +"Accept" = "接受"; +"AcceptCall" = "接听"; +"AcceptCalls" = "接受通话"; +"AcceptCallsChatsDescription" = "使用该设备进行通话"; +"AcceptSecretChats" = "接受私密聊天"; +"AcceptSecretChatsDescription" = "使用该设备进行私密聊天"; +"AccessError" = "访问出错"; +"Account" = "账号"; +"AccountAlreadyLoggedIn" = "该帐号已在此应用中登录。"; +"AccountFrozen1Text" = "\u200b您的账号因违反 Telegram 的服务条款而被冻结。"; +"AccountFrozen1Title" = "违反条款"; +"AccountFrozen2Text" = "您可以访问您的账号,但不能发送消息或执行操作。"; +"AccountFrozen2Title" = "只读模式"; +"AccountFrozen3Text" = "在 %s 之前通过 @SpamBot 申诉,否则您的账户将被删除。"; +"AccountFrozen3Title" = "停用前申诉"; +"AccountFrozenAlertSubtitle" = "点击查看详细信息并提交申诉。"; +"AccountFrozenAlertTitle" = "您的账号已被冻结!"; +"AccountFrozenBottomSubtitle" = "点击查看详细信息"; +"AccountFrozenBottomTitle" = "您的账号已被冻结"; +"AccountFrozenButtonAppeal" = "提交申诉"; +"AccountFrozenButtonUnderstood" = "了解"; +"AccountFrozenTitle" = "您的账号已被冻结"; +"AccountSettings" = "账号设置"; +"AccountSwitch" = "切换"; +"AccurateTo" = "精确到 %1$s"; +"ActionAddUser" = "un1 添加了 un2"; +"ActionAddUserSelf" = "un1 已返回群组"; +"ActionAddUserSelfMega" = "un1 已加入群组"; +"ActionAddUserSelfYou" = "您已返回群组"; +"ActionAddUserYou" = "un1 添加了您"; +"ActionAttachMenuBotAllowed" = "当您将此机器人添加到附件菜单中时,您已允许此机器人向您发送消息"; +"ActionBotAllowed" = "您已允许此机器人在您登录 %1$s 时给您发送消息。"; +"ActionBotAllowedApp" = "您允许此机器人在您登录 ”%1$s“ 应用时向您发送消息。"; +"ActionBotAllowedWebapp" = "您已允许该机器人在其网页应用中向您发送消息。"; +"ActionBotDocumentAddress" = "地址"; +"ActionBotDocumentBankStatement" = "银行对账单"; +"ActionBotDocumentDriverLicence" = "驾驶证"; +"ActionBotDocumentEmail" = "电子邮箱地址"; +"ActionBotDocumentIdentity" = "个人信息"; +"ActionBotDocumentIdentityCard" = "身份证"; +"ActionBotDocumentInternalPassport" = "户口本(Internal Passport)"; +"ActionBotDocumentPassport" = "护照"; +"ActionBotDocumentPassportRegistration" = "护照信息"; +"ActionBotDocumentPhone" = "手机号码"; +"ActionBotDocumentRentalAgreement" = "租约"; +"ActionBotDocumentTemporaryRegistration" = "临时证明信息"; +"ActionBotDocumentUtilityBill" = "水电气费账单"; +"ActionBotDocuments" = "%1$s 已收到下列证件:%2$s"; +"ActionBotWebViewData" = "来自 “%1$s” 按钮的数据已发送至机器人。"; +"ActionChangeCreator" = "un1 已将此群组的所有权转让给 un2。"; +"ActionChangedPhoto" = "un1 已更改群组头像"; +"ActionChangedTitle" = "un1 已将群组名称更改为 un2"; +"ActionChangedVideo" = "un1 已更改群组图片"; +"ActionChannelCallEnded" = "直播已结束 (%s)"; +"ActionChannelCallJustStarted" = "直播已开始"; +"ActionChannelCallScheduled" = "计划于 %1$s 直播"; +"ActionChannelChangedPhoto" = "频道头像已更新"; +"ActionChannelChangedTitle" = "频道名称已更改为 un2"; +"ActionChannelChangedVideo" = "频道图片已更新"; +"ActionChannelRemovedPhoto" = "频道头像已被移除"; +"ActionCreateChannel" = "频道已创建"; +"ActionCreateGroup" = "un1 已创建群组"; +"ActionCreateMega" = "群组已创建"; +"ActionCreatedInviteLink" = "un1 已创建邀请链接 %1$s"; +"ActionCreatedInviteLinkClickable" = "un1 已创建邀请链接 un2"; +"ActionDeletedInviteLink" = "un1 已删除邀请链接 %1$s"; +"ActionDeletedInviteLinkClickable" = "un1 删除了邀请链接 un2"; +"ActionEditedInviteLink" = "un1 将邀请链接由 %1$s 改为 %2$s"; +"ActionEditedInviteLinkClickable" = "un1 将邀请链接 un2 编辑为 un3"; +"ActionEditedInviteLinkToSame" = "un1已编辑邀请链接 %1$s"; +"ActionEditedInviteLinkToSameClickable" = "un1已编辑邀请链接un2"; +"ActionForwardsEnabledChannel" = "un1 已允许从此频道转发内容"; +"ActionForwardsEnabledGroup" = "un1 已允许从该群组中转发信息"; +"ActionForwardsRestrictedChannel" = "un1 已限制该频道的消息转发"; +"ActionForwardsRestrictedGroup" = "un1 已限制从此群组中转发消息"; +"ActionGift2Received" = "您收到了一份来自 **un2** 的礼物"; +"ActionGiftAuctionSelf" = "您以 **un2** 的出价赢得了此次拍卖"; +"ActionGiftChannel#other" = "**un1** 向 **un2** 赠送了 %d 颗星星"; +"ActionGiftInbound" = "un1 已花费 **un2** 赠予您一份礼物"; +"ActionGiftOutbound" = "您已花费 **un2** 赠予礼物"; +"ActionGiftPremiumSubtitle" = "%1$s"; +"ActionGiftPremiumText" = "订阅 Telegram 专属功能。"; +"ActionGiftPremiumTitle" = "Telegram 高级版"; +"ActionGiftPremiumTitle2#other" = "%d 个月高级版订阅"; +"ActionGiftPremiumView" = "查看"; +"ActionGiftSelf" = "你给 **un2** 购买了一份礼物"; +"ActionGiftSomeone" = "有人向你赠送了礼物!"; +"ActionGiftStarsSubtitle" = "有了星星,**%s** 就可以解锁 Telegram 上的内容和服务。"; +"ActionGiftStarsSubtitleYou" = "使用星星解锁 Telegram 上的內容和服务。"; +"ActionGiftStarsTitle#other" = "%d 颗星星"; +"ActionGiftStarsView" = "查看"; +"ActionGiftTonInbound" = "赠送了您价值 **un2** 的礼物"; +"ActionGiftTonTitle" = "某人"; +"ActionGroupCallEnded" = "视频聊天已结束 (%s)"; +"ActionGroupCallEndedBy" = "un1 已结束语音聊天(%s)"; +"ActionGroupCallEndedByYou" = "您已结束视频聊天( %s )"; +"ActionGroupCallInvited" = "un1 已邀请 un2 加入视频聊天"; +"ActionGroupCallInvitedYou" = "un1 邀请您加入视频聊天"; +"ActionGroupCallJustStarted" = "视频聊天已开始"; +"ActionGroupCallScheduled" = "视频聊天计划于 %1$s 开始"; +"ActionGroupCallStarted" = "un1 已发起视频聊天"; +"ActionGroupCallStartedByYou" = "您发起了视频聊天"; +"ActionGroupCallYouInvited" = "您已邀请 un2 加入视频聊天"; +"ActionInviteChannelUser" = "un1 已通过邀请链接加入此频道"; +"ActionInviteChannelUserFolder" = "un1 已通过文件夹邀请链接加入频道"; +"ActionInviteUser" = "un1 已通过邀请链接加入群组"; +"ActionInviteUserFolder" = "un1 已通过文件夹邀请链接加入"; +"ActionInviteYou" = "您已通过邀请链接加入群组"; +"ActionKickUser" = "un1 移除了 un2"; +"ActionKickUserYou" = "un1 已将您移除"; +"ActionLeftUser" = "un1 已退出群组"; +"ActionManagedBotCreated" = "您已创建 un1 机器人供 un2 管理"; +"ActionMigrateFromGroup" = "此群组已升级为超级群组"; +"ActionMigrateFromGroupNotify" = "%1$s 已升级为超级群组"; +"ActionNewCreatorPending" = "如果 un2 7 天后仍未返回,则 un1 将成为新的所有者。"; +"ActionPinnedContact" = "un1 已置顶一个联系人信息"; +"ActionPinnedFile" = "un1 置顶了一个文件"; +"ActionPinnedGame" = "un1 已置顶 %1$s"; +"ActionPinnedGeo" = "un1 已置顶一张地图"; +"ActionPinnedGeoLive" = "un1 已置顶一个实时位置"; +"ActionPinnedGif" = "un1 已置顶一张 GIF 动态图"; +"ActionPinnedMusic" = "un1 置顶了一个音频文件"; +"ActionPinnedNoText" = "un1 已置顶一条消息"; +"ActionPinnedPhoto" = "un1 置顶了一张图片"; +"ActionPinnedPoll" = "un1 置顶了一项投票"; +"ActionPinnedQuiz" = "un1 已置顶一个问答"; +"ActionPinnedRound" = "un1 已置顶一条视频消息"; +"ActionPinnedSticker" = "un1 已置顶一张贴纸"; +"ActionPinnedText" = "un1 已置顶 \"%1$s\""; +"ActionPinnedVideo" = "un1 已置顶一段视频"; +"ActionPinnedVoice" = "un1 已置顶一条语音消息"; +"ActionPrepaidGiftInbound" = "un1 为您的礼物赠送了价值 **un2** 的升级"; +"ActionPrepaidGiftOutbound" = "您为 **un1** 的礼物发送了价值 **un2** 的升级"; +"ActionReactionsChanged" = "un1 将聊天中的表情回应\n从 %1$s 修改为 %2$s"; +"ActionReactionsChangedList" = "un1 已更改聊天回应:\n %1$s"; +"ActionRefunded" = "un1 已退款 %1$s"; +"ActionRemovedPhoto" = "un1 已移除群组头像"; +"ActionRequestedPeer" = "您已将 un1 分享给 un2"; +"ActionRequestedPeerChannel" = "您已将一个频道分享给 un2"; +"ActionRequestedPeerChannelPlural#other" = "您已将频道分享给 un2"; +"ActionRequestedPeerChat" = "您已将一个聊天分享给 un2"; +"ActionRequestedPeerChatPlural#other" = "您已将聊天分享给 un2"; +"ActionRequestedPeerUser" = "您已将一位用户分享给 un2"; +"ActionRequestedPeerUserPlural#other" = "您已将用户分享给 un2"; +"ActionRevokedInviteLink" = "un1 撤销邀请链接 %1$s"; +"ActionRevokedInviteLinkClickable" = "un1 已撤销邀请链接 un2"; +"ActionSetSameWallpaperForThisChat" = "%s 在此聊天中已为您设置与其相同的壁纸"; +"ActionSetSameWallpaperForThisChatSelf" = "您已设置与对方相同的壁纸"; +"ActionSetWallpaperForThisChannel" = "频道已设立新壁纸"; +"ActionSetWallpaperForThisChat" = "%s 为此聊天设置了新壁纸"; +"ActionSetWallpaperForThisChatBoth" = "%s 已为此聊天设置新壁纸"; +"ActionSetWallpaperForThisChatSelf" = "您已为此聊天设置新壁纸"; +"ActionSetWallpaperForThisChatSelfBoth" = "您已为自己和 %s 设置新壁纸"; +"ActionSetWallpaperForThisGroup" = "群组已设置新壁纸"; +"ActionSetWallpaperForThisGroupByUser" = "%s 已设置新壁纸"; +"ActionSettingWallpaper" = "正在设置新壁纸..."; +"ActionSkip" = "跳过"; +"ActionStarGift" = "礼物"; +"ActionStarGiftInbound" = "%s 送了您一份礼物"; +"ActionStarGiveawayPrize#other" = "您在 **un1** 组织的抽奖活动中赢得了奖品。\n\n您的奖品是 **%d 颗星星**。"; +"ActionStarGiveawayPrizeTitle" = "恭喜!"; +"ActionSuggestBirthday" = "**un1** 建议您添加您的生日"; +"ActionSuggestPhoto" = "推荐的图片"; +"ActionSuggestPhotoFromYouDescription" = "您已向 %s 推荐此图片作为头像"; +"ActionSuggestPhotoShort" = "已推荐头像"; +"ActionSuggestPhotoToYouDescription" = "%s 建议将此图片用做您的 Telegram 头像"; +"ActionSuggestVideo" = "推荐的视频"; +"ActionSuggestVideoFromYouDescription" = "您已向 %s 推荐此图片作为头像"; +"ActionSuggestVideoShort" = "已推荐头像"; +"ActionSuggestVideoToYouDescription" = "%s 已向您推荐该图片作为您的 Telegram 头像"; +"ActionTTLChanged" = "un1 已设置在 %1$s 之后自动删除消息"; +"ActionTTLChannelChanged" = "此频道中的消息将在 %1$s 后自动删除"; +"ActionTTLChannelDisabled" = "此频道中的消息将不会被自动删除"; +"ActionTTLDisabled" = "un1 已停用自动删除计时器"; +"ActionTTLYouChanged" = "您已设置消息在 %1$s 后自动删除"; +"ActionTTLYouDisabled" = "您关闭了自动删除定时器"; +"ActionTakeScreenshoot" = "un1 截了一张图!"; +"ActionTakeScreenshootYou" = "您截了一张图!"; +"ActionUniqueGiftCrafted" = "您制作了一件新礼物!"; +"ActionUniqueGiftResaleInbound#other" = "**un1** 购买了一份价格为 %d 颗星星的礼物给您!"; +"ActionUniqueGiftResaleInboundTON" = "**un1** 已为您购买一份价值 %s Gram 的礼物!"; +"ActionUniqueGiftResaleOutbound#other" = "您购买此礼物的价格为 %d 颗星星"; +"ActionUniqueGiftResaleOutboundTON" = "您以 %s Gram 购买了这份礼物"; +"ActionUniqueGiftResaleService#other" = "**un1** 已购买价格为 %d 颗星星的礼物给 un2!"; +"ActionUniqueGiftResaleServiceTON" = "**un1** 已向 **un2** 赠送一份价值 %s Gram 的礼物!"; +"ActionUniqueGiftResaleSoldOutbound#other" = "您以 %d 颗星星的价格出售了此礼物"; +"ActionUniqueGiftResaleSoldOutboundTON" = "您以 %s Gram 的价格出售了此礼物"; +"ActionUniqueGiftTransferInbound" = "**un1** 已转让你的礼物!"; +"ActionUniqueGiftTransferOutbound" = "你已将你的礼物转让给 **un1**"; +"ActionUniqueGiftTransferOutboundAssigned" = "您已将 %s 添加至您的个人资料页。"; +"ActionUniqueGiftTransferService" = "**un1** 向 **un2** 转赠了一个礼物!"; +"ActionUniqueGiftUpgradeInbound" = "**un1** 将您的礼物转换为了独家典藏品"; +"ActionUniqueGiftUpgradeInboundChannel" = "**un1** 已将礼物转换为独家典藏品"; +"ActionUniqueGiftUpgradeOutbound" = "你将 **un1** 赠送的礼物转换为了独家典藏品"; +"ActionUniqueGiftUpgradeSelf" = "您已将这份礼物转换为独家典藏品"; +"ActionUserInvitedToChannel" = "un1 邀请了您加入此频道"; +"ActionUserInvitedToGroup" = "un1 已邀请您加入此群组"; +"ActionUserScored" = "un1 获得 %1$s 分"; +"ActionUserScoredInGame" = "un1 在«un2»中获得 %1$s 分"; +"ActionUserWithinOtherRadius" = "现在 un1 距离 un2 %1$s"; +"ActionUserWithinRadius" = "un1 离你 %1$s之内"; +"ActionUserWithinYouRadius" = "现在您距离 un1 约%1$s之内"; +"ActionVolumeChanged" = "un1 已将 un2 的音量调整为 %1$d%%"; +"ActionYouAddUser" = "您已将 un2 加入群组"; +"ActionYouChangedPhoto" = "您已更改群组头像"; +"ActionYouChangedTitle" = "您已将群组名称更改为 un2"; +"ActionYouChangedVideo" = "您已更改群组图片"; +"ActionYouCreateGroup" = "您已创建群组"; +"ActionYouKickUser" = "您已将 un2 移除"; +"ActionYouLeftUser" = "您已退出此群组"; +"ActionYouRemovedPhoto" = "您已移除群组头像"; +"ActionYouScored" = "您获得 %1$s 分"; +"ActionYouScoredInGame" = "您在«un2»中获得 %1$s 分"; +"ActionYouSuggestBirthday" = "您建议添加生日"; +"ActionsChartTitle" = "操作"; +"AdHidden" = "你将不再看到 Telegram 提供的广告。"; +"AdReported" = "我们将审核此广告,以保证其符合**广告政策与准则**。"; +"Add" = "添加"; +"AddACaption" = "添加说明文字"; +"AddAccount" = "添加帐号"; +"AddAccountInfo1#other" = "您还可以添加 %d 个账号。\n"; +"AddAccountInfo2#other" = "订阅 **Telegram 高级版** 即可使用最多 %d 个账号。"; +"AddAdminErrorBlacklisted" = "抱歉,您无法将这位用户设为管理员。因为该用户在群组的已移除用户名单中,并且您也无法解除其封禁。"; +"AddAdminErrorNotAMember" = "您无法将这位用户设为管理员。因为该用户不是此群组的成员,且您也无权添加该用户。"; +"AddAnException" = "添加例外"; +"AddAnExplanation" = "添加注释"; +"AddAnExplanationHeader" = "说明"; +"AddAnExplanationInfo" = "选错的用户将看到这段文字,因此非常适合教育用途。"; +"AddAnOption" = "添加一个选项..."; +"AddAnOptionInfo" = "您还可以添加 %1$s"; +"AddAnOptionInfoMax" = "您添加的选项数量已经达到上限。"; +"AddAnotherAccount" = "添加其他帐号"; +"AddAnotherAccountInfo" = "连接其他 Telegram 帐号并轻松切换。"; +"AddAsAdmin" = "添加为管理员"; +"AddBannedErrorAdmin" = "您无法封禁这位用户,因为该用户是此群组的管理员,并且您也无权移除其管理员权限。"; +"AddBirthday" = "添加生日"; +"AddBot" = "添加机器人"; +"AddBotAdmin" = "添加机器人为管理员"; +"AddBotAdminAlert" = "要添加机器人作为管理员吗?"; +"AddBotAsAdmin" = "机器人只能作为管理员加入频道。"; +"AddBotButton" = "添加机器人"; +"AddBotButtonAsAdmin" = "作为管理员"; +"AddBotButtonAsMember" = "作为成员"; +"AddBotMessageAdminChannel" = "您确定要将此机器人作为管理员添加到频道 **%1$s** 吗?它将有权访问频道管理员和订阅者列表。"; +"AddBotMessageAdminGroup" = "您确定要将此机器人作为管理员添加到群组 **%1$s** 吗?它将有权访问匿名管理员列表。"; +"AddCaption" = "添加说明…"; +"AddContact" = "添加为联系人"; +"AddContactByPhone" = "添加 %1$s"; +"AddContactChat" = "添加联系人"; +"AddContactChatNoCaps" = "添加联系人"; +"AddContactFullChat" = "添加 %1$s 为联系人"; +"AddContactFullChatNoCaps" = "将 %1$s 添加为联系人"; +"AddContactPhonebookTitle" = "添加联系人"; +"AddContactQr" = "通过二维码添加"; +"AddContactShareNumber" = "分享我的电话号码"; +"AddContactShareNumberInfo" = "关闭此选项可防止 %s 看到您的电话号码。"; +"AddContactSync" = "将联系人同步到手机"; +"AddContactTitle" = "添加联系人"; +"AddEffectMessageHint" = "添加动画特效"; +"AddEmailSubtitle" = "您将通过电子邮箱而非短信收取 Telegram 登录码。请输入您可以访问的电子邮箱地址。"; +"AddEmailTitle" = "选择登录邮箱"; +"AddEmojiInstalled" = "表情已添加。"; +"AddEmojiInstalledInfo" = "%1$s 已添加到您的表情包中。"; +"AddEmojiMultipleInstalledInfo#other" = "已向您的表情中添加了 %1$d 个表情包。"; +"AddEmojiNotFound" = "未找到表情符号包。"; +"AddEmojiPackHeader" = "使用链接添加表情包"; +"AddEmojiPackLinkHint" = "emojiset"; +"AddException" = "添加例外"; +"AddExceptions" = "添加例外"; +"AddFactCheck" = "添加事实查核"; +"AddGroupEmojiPackHint" = "所有成员都可以在群组中使用这些表情,即使没有订阅 Telegram 高级版。"; +"AddImage" = "添加图片"; +"AddLocation" = "添加位置"; +"AddManyEmojiCount#other" = "添加 %1$d 个表情"; +"AddManyEmojiPacksCount#other" = "添加 %1$d 个表情包"; +"AddManyMasksCount#other" = "添加 %1$d 个面具装饰"; +"AddManyMembersAlertNamesText#other" = "是否确实要添加 %1$d 名成员到群组 **%2$s**?"; +"AddManyMembersAlertTitle#other" = "添加 %1$d 名成员"; +"AddManyStickersCount#other" = "添加 %1$d 张贴纸"; +"AddMasks" = "添加装饰"; +"AddMasksInstalled" = "新的装饰已添加"; +"AddMasksInstalledInfo" = "%1$s 已添加至您的装饰。"; +"AddMember" = "添加成员"; +"AddMemberTag" = "添加成员标签"; +"AddMembersAlertCountText" = "你确定想要将 %1$s 添加到 **%2$s** 吗?"; +"AddMembersAlertNamesText" = "您确定将 %1$s 添加到 **%2$s** 吗?"; +"AddMembersAlertTitle" = "添加 %1$s"; +"AddMembersForwardMessages" = "向新成员显示最近100条聊天记录"; +"AddMessageMedia" = "点击以添加媒体"; +"AddMutual" = "添加他人到您的频道"; +"AddNotes" = "添加备注…"; +"AddNotesInfo" = "只有您自己才能看到备注。"; +"AddOneMemberAlertTitle" = "添加成员"; +"AddOneMemberForwardMessages" = "向 **%1$s** 显示最近100条聊天记录"; +"AddPeopleNearby" = "查找附近的人"; +"AddPhoto" = "设置头像"; +"AddProxy" = "添加代理"; +"AddRecipient" = "添加接收者"; +"AddShortcut" = "添加到主屏幕"; +"AddStickers" = "添加表情包"; +"AddStickersCount" = "添加 %1$s"; +"AddStickersInstalled" = "新的表情包已添加"; +"AddStickersInstalledInfo" = "%1$s 已经添加到您的贴纸中。"; +"AddStickersNotFound" = "未找到该贴纸"; +"AddStory" = "添加动态"; +"AddSubscriber" = "添加订阅者"; +"AddTag" = "添加标签"; +"AddTagsToYourSavedMessages1" = "添加标签"; +"AddTagsToYourSavedMessages2" = "至我的收藏"; +"AddTasks" = "添加任务"; +"AddToChannel" = "添加至频道"; +"AddToContacts" = "添加为联系人"; +"AddToExceptions" = "添加例外"; +"AddToExistingContact" = "添加到现有的联系人信息"; +"AddToFavorites" = "添加到收藏"; +"AddToGroup" = "添加到群组"; +"AddToGroupOrChannel" = "添加至群组或频道"; +"AddToMasks" = "添加到装饰库"; +"AddToStickerPack" = "添加到贴纸包"; +"AddToStickers" = "添加到表情库"; +"AddToTheGroup" = "是否将 %1$s 添加到此群组?"; +"AddToTheGroupAlertText" = "您要将 **%1$s** 添加到群组 **%2$s** 吗?"; +"AddToTheGroupAlertTitle" = "添加机器人"; +"AddToTheGroupForwardCount" = "将转发的最近消息数:"; +"AddToTheGroupTitle" = "是否将 %1$s 加入聊天 %2$s ?"; +"AddUserErrorBlacklisted" = "抱歉,由于该用户在被移除用户名单上,您无法将其加入频道或解除其封禁。"; +"AddUsername" = "添加用户名"; +"Added" = "已添加"; +"AddedBy" = "已被 %1$s %2$s 加入"; +"AddedMembersToGroup#other" = "已将 **%d 位成员** 加入群组"; +"AddedSubscribersToChannel#other" = "**%d 订阅者** 已添加该频道"; +"AddedToDoNotTranslate" = "**%s** 已添加至无需翻译列表。"; +"AddedToDoNotTranslateOther" = "**%s** 已被添加至无需翻译列表。"; +"AddedToFavorites" = "此贴纸已被添加到收藏。"; +"AdditionalReactions" = "额外的回应"; +"AdditionalReactionsDescription" = "订阅 Telegram 高级版以解锁更多表情回应。"; +"AddressPlaceholder" = "使用 %s 搜索或输入网址"; +"AdminWillBeRemoved" = "如果您限制%1$s,他将被解除管理员权限。"; +"AffiliateLinkExpiredText" = "此链接所属的联盟计划已被应用所有者终止。"; +"AffiliateLinkExpiredTitle" = "链接已过期"; +"AffiliateProgramAlert" = "警告"; +"AffiliateProgramBot" = "联盟计划"; +"AffiliateProgramBotOff" = "关闭"; +"AffiliateProgramCommission" = "佣金"; +"AffiliateProgramCommissionInfo" = "设定您的合作者透过推荐用户使用您的机器人所能赚取的星星收入分成比例。"; +"AffiliateProgramDuration" = "期限"; +"AffiliateProgramDurationInfo" = "设置合作方从推荐用户赚取佣金的期限。"; +"AffiliateProgramEndedText" = "已通知参与联盟计划的成员,所有推荐链接将在 **24** 小时后失效。"; +"AffiliateProgramEndedTitle" = "联盟计划已结束"; +"AffiliateProgramExistingProgramsText" = "检索其他小程序提供的功能。"; +"AffiliateProgramExistingProgramsTitle" = "查看现有计划"; +"AffiliateProgramJoinedText" = "现在你可以复制推荐链接。"; +"AffiliateProgramJoinedTitle" = "加入计划"; +"AffiliateProgramLinkCopiedText" = "分享此链接并赚取推荐用户在 %2$s 中消费的 %1$s!"; +"AffiliateProgramLinkCopiedTitle" = "链接已复制到剪贴板"; +"AffiliateProgramStart" = "启动联盟计划"; +"AffiliateProgramStartAlertButton" = "启动"; +"AffiliateProgramStartAlertText" = "一旦您启动联盟计划,就无法再降低佣金比例或有效期限。您只提高这些参数或终止计划,而终止计划将导致所有之前分发的推荐链接失效。"; +"AffiliateProgramStartAvailableIn" = "%s 之后可用"; +"AffiliateProgramStartInfo" = "创建联盟计划即表示您同意联盟计划的**条款和条件**。"; +"AffiliateProgramStartInfoLink" = "https://telegram.org/tos/affiliate-program"; +"AffiliateProgramStartedText" = "任何 Telegram 用户、频道所有者或小程序开发者现在都可以加入您的计划。"; +"AffiliateProgramStartedTitle" = "已启动联盟计划"; +"AffiliateProgramStop" = "结束联盟计划"; +"AffiliateProgramStopButton" = "仍然结束"; +"AffiliateProgramStopText" = "如果你结束联盟计划:"; +"AffiliateProgramStopText1" = "任何已分享的推荐链接都将在 **24** 小时內停用。"; +"AffiliateProgramStopText2" = "所有合作者将收到通知。"; +"AffiliateProgramStopText3" = "您需要等待 **24** 小时后才能开启新的联盟计划。"; +"AffiliateProgramUpdate" = "更新联盟计划"; +"AffiliateProgramUpdateAlertButton" = "更新"; +"AffiliateProgramUpdateAlertText" = "此更改是不可逆的。您将无法減少佣金或期限。您只能增加这些参数或结束计划,那样将停用所有先前分享的推荐链接。"; +"AffiliateProgramUpdateInfo" = "更新联盟计划即表示您同意联盟计划的**条款和条件**。"; +"AffiliateProgramUpdateInfoLink" = "https://telegram.org/tos/affiliate-program"; +"AgeVerificationButton" = "验证我的年龄"; +"AgeVerificationFailedText" = "抱歉, 您无法查看 18+ 内容。"; +"AgeVerificationFailedTitle" = "年龄验证失败"; +"AgeVerificationNeedCameraPermission" = "Telegram 需要访问您的相机,以便确认您的年龄。"; +"AgeVerificationPassedTitle" = "已经通过年龄验证"; +"AgeVerificationTextGB" = "要访问此类内容,您必须根据英国法律确认您已年满 **18** 岁。 \n\n这是通过使用手机相机的一次性操作。Telegram 不会储存您的自拍照。"; +"AgeVerificationTitle" = "年龄验证"; +"Agree" = "同意"; +"Album" = "相册"; +"Alert" = "显示通知"; +"AlertClearHistory" = "清除历史记录"; +"AlertLeaveAppointAnotherOwner" = "指定另一位所有者"; +"AlertLeaveAppointAnotherOwnerTitle" = "指定另一位所有者"; +"AlertLeaveNewOwner" = "如果您离开,**%s** 将在 **1 周** 后成为其新所有者。"; +"AllAccounts" = "所有账号"; +"AllChats" = "所有聊天"; +"AllChatsShort" = "全部"; +"AllMedia" = "所有媒体"; +"AllNMedia#other" = "所有 %1$d 个媒体"; +"AllNPhotos#other" = "所有 %1$d 张照片"; +"AllPhotos" = "所有图片"; +"AllReactions" = "所有表情回应"; +"AllSessionsTerminated" = "已终止其他会话。"; +"AllTopicsShort" = "全部"; +"AllTopicsSide" = "全部"; +"AllVideos" = "所有视频"; +"AllViewers" = "所有访客"; +"AllWebSessionsTerminated" = "已断开链接所有网页。"; +"Allow" = "允许"; +"AllowBackgroundActivity" = "允许背景活动"; +"AllowBackgroundActivityInfo" = "这将会提高收到的通知和电报呼叫的可靠性"; +"AllowBackgroundActivityInfoOneUIAboveS" = "这将提升接收新消息通知和 Telegram 通话的可靠性。请在 设置 > 电池 中选择 ** 优化 ** 或 ** 不受限制 **。"; +"AllowBackgroundActivityInfoOneUIBelowS" = "这将提升接收通知和 Telegram 通话的可靠性。请在您的设备上打开“设置” > “电池”,然后开启**允许后台活动**。"; +"AllowCustomQuickReply" = "允许自定义"; +"AllowFillNumber" = "请允许 Telegram 接收来电,以便自动确认您的手机号码。"; +"AllowPostSuggestions" = "允许私信"; +"AllowPostSuggestionsHint" = "允许用户向您的频道发送消息,并可选择对每条消息收取费用。"; +"AllowPostSuggestionsHint2" = "允许用户向您的频道发送消息,并可选择为每条消息收取费用。"; +"AllowReadCall" = "请授予 Telegram 电话权限以便我们自动读取验证码。"; +"AllowReadCallAndLog" = "请允许 Telegram 接听来电并读取通话记录,以便为您自动填写验证码。"; +"AllowReadCallAndSms" = "请允许 Telegram 接收来电,以便自动确认您的手机号码。"; +"AllowReadCallLog" = "请允许Telegram读取通话记录,以便为您自动填写验证码。"; +"AllowReadSms" = "请授予 Telegram 短信权限以便我们自动读取验证码。"; +"AlmostDone" = "就要完成了!"; +"AlsoTerminateChatbot" = "同时也终止 %s"; +"AltNavigationDisable" = "停用其它导航"; +"AltNavigationEnable" = "启用其它导航"; +"AlternativeOptions" = "其他选项"; +"AlwaysAllow" = "总是允许"; +"AlwaysAllowPlaceholder" = "总是允许…"; +"AlwaysShareWith" = "总是分享给"; +"AlwaysShareWithPlaceholder" = "选择可以看见您上线时间的用户…"; +"AlwaysShareWithTitle" = "总是共享"; +"AlwaysShowPopup" = "总是显示"; +"AmongWinners#other" = "给 %d 名获奖者"; +"AndMoreTyping#other" = "和其他 %1$d 人正在输入"; +"AndMoreTypingGroup#other" = "%1$s 和其他 %2$d 人正在输入"; +"AndOther#other" = "和其他 %1$d 人"; +"AnimatedEffectPremium" = "订阅 **Telegram 高级版** 添加此动画特效。"; +"AnimatedStickers" = "动态贴纸"; +"AnonymousForward" = "作者已隐藏"; +"AnonymousNumber" = "匿名号码"; +"AnonymousNumberNotice" = "此号码获取自 *Fragment*,并未绑定到 SIM 卡。"; +"AnonymousPoll" = "匿名投票"; +"AnonymousQuizPoll" = "匿名问答"; +"Answer#other" = "%1$d 条答案"; +"AnswerOptions" = "答案选项"; +"AnswerOptions2" = "选项"; +"ApkRestricted" = "此应用已被限制安装 APK,您可以在系统设置中启用它。"; +"AppBetaUpdateVersion" = "版本 %1$s (%2$s)"; +"AppFolderInfo" = "浏览APP的文件夹"; +"AppIcon" = "应用图标"; +"AppIconAqua" = "水色"; +"AppIconChangedTo" = "应用图标已更换为 %1$s。"; +"AppIconDefault" = "默认"; +"AppIconNox" = "夜空"; +"AppIconPremium" = "高级版"; +"AppIconTurbo" = "极速"; +"AppIconVintage" = "经典"; +"AppLocked" = "Telegram 已锁定"; +"AppName" = "Telegram"; +"AppNameBeta" = "Telegram"; +"AppStore" = "App Store"; +"AppUpdate" = "更新Telegram"; +"AppUpdateBeta" = "更新 Telegram 测试版"; +"AppUpdateChangelogEmpty" = "**更新日志**\n\n• 修复错误并提升性能"; +"AppUpdateDownloadNow" = "立即下载"; +"AppUpdateDownloading" = "下载中… %1$d%%"; +"AppUpdateNow" = "立即更新"; +"AppUpdateRemindMeLater" = "请稍后提醒我"; +"AppUpdateVersionAndSize" = "版本号 %1$s • %2$s"; +"Appearance" = "外观"; +"Application" = "应用程序"; +"ApplyAvatarHint" = "您可以在 **设置** 中更改。"; +"ApplyAvatarHintTitle" = "**头像已更新**"; +"ApplyBackgroundForThisChat" = "应用于此聊天"; +"ApplyChanges" = "应用更改"; +"ApplyLocalizationFile" = "应用本地化文件"; +"ApplyTheme" = "应用"; +"ApplyThemeFile" = "应用主题文件"; +"ApplyWallpaper" = "设置背景"; +"ApplyWallpaperForChannel" = "为 %s 应用"; +"ApplyWallpaperForMe" = "为自己应用"; +"ApplyWallpaperForMeAndPeer" = "为自己和 %s 应用"; +"AppointNewOwner" = "指定其他所有者"; +"ApprovalRequired" = "需要批准"; +"ApproveNewMembers" = "需要管理员批准"; +"ApproveNewMembers2" = "批准新成员"; +"ApproveNewMembersApplyToLinksApply" = "应用"; +"ApproveNewMembersApplyToLinksDontApply" = "不应用"; +"ApproveNewMembersApplyToLinksTitle" = "应用于现有邀请链接"; +"ApproveNewMembersDescription" = "启用此选项后,新用户加入前需得到管理员的批准。"; +"ApproveNewMembersDescription2" = "通过此链接加入须经管理员批准。"; +"ApproveNewMembersDescriptionFrozen" = "您无法对需要按月付费的链接启用管理员批准。"; +"ApproveNewMembersDisable" = "禁用"; +"ApproveNewMembersDisableForLinks#other" = "同时禁用此群组中 %1$d 个现有邀请链接的 **批准新成员** 功能?"; +"ApproveNewMembersDisableForLinksChannel#other" = "同时禁用此频道中 %1$d 个现有邀请链接的 **批准新成员** 功能?"; +"ApproveNewMembersDisabledMessageChannel" = "新订阅者加入此频道前,将不再需要获得 %1$s 的批准。"; +"ApproveNewMembersDisabledMessageGroup" = "新成员在群内发消息无需再经 **%1$s** 批准。"; +"ApproveNewMembersEnable" = "启用"; +"ApproveNewMembersEnableForLinks#other" = "同时启用此群组中 %1$d 个现有邀请链接的 **批准新成员** 功能?"; +"ApproveNewMembersEnableForLinksChannel#other" = "同时启用此频道中 **%1$d 个现有邀请链接** 的**批准新订阅者**功能?"; +"ApproveNewMembersMessageChannel" = "新成员加入此频道须经 **%1$s** 批准。"; +"ApproveNewMembersMessageGroup" = "新成员在此群组中发送消息前,现在需要获得 **%1$s** 的批准。"; +"ApproveNewMembersTitle" = "批准新成员"; +"ApproveNewMembersUnavailablePublicGroup" = "此选项不可用,因为任何人都可以通过公开群组链接加入并发送消息。"; +"AppsClearSearch" = "应用记录"; +"AppsClearSearchAlert" = "您是否要从最近应用使用记录中删除 %s?"; +"AppsTab" = "应用"; +"AppsTabInfo" = "这里包括哪些应用? **了解 \\>**"; +"AppsTabInfoButton" = "已知悉"; +"AppsTabInfoText" = "此目录根据小程序的每日收入(以星星数量衡量)进行排名。若想要列入清单,开发人员必须在 [@botfather]() 中设定主要小程序(如 [此处](https://core.telegram.org/bots/webapps#launching-the-main-mini-app) 所述) ,该小程序应拥有超过 **1000** 名每日用户,且每日收入超过 **1000** 颗星星(基于每周平均值)。"; +"AppsTabInfoTitle" = "热门小程序"; +"April" = "四月"; +"Archive" = "归档"; +"ArchiveAndMute" = "归档并静音"; +"ArchiveAndMuteInfo" = "自动归档及静音来自非联系人的新聊天、群组和频道。"; +"ArchiveHidden" = "归档已被隐藏"; +"ArchiveHiddenInfo" = "下拉查看归档。"; +"ArchiveHintHeader1" = "这是你的归档"; +"ArchiveHintHeader2" = "已静音的聊天"; +"ArchiveHintHeader3" = "已置顶聊天"; +"ArchiveHintSection1" = "已归档的动态"; +"ArchiveHintSection1Info" = "滑动任意聊天可以将其移至或移出归档。"; +"ArchiveHintSection2" = "隐藏归档"; +"ArchiveHintSection2Info" = "在主屏幕上滑动可以隐藏归档。"; +"ArchiveHintSection3" = "动态"; +"ArchiveHintSection3Info" = "将联系人的动态和聊天分开归档。"; +"ArchiveHintSubtitle" = "收到一条新消息时,已归档的聊天将继续保留在归档中。**点击更改 >**"; +"ArchiveHintSubtitleUnmutedMove" = "收到一条新消息时,已静音的聊天将保留在归档中,未静音的聊天则会被移动至“聊天”界面。**点击更改 >**"; +"ArchiveHintText1" = "收到新消息时,已开启通知的聊天将自动取消归档。"; +"ArchiveHintText2" = "收到新消息时,停用通知的聊天将被保持归档。"; +"ArchiveHintText3" = "您可以将任意数量的归档聊天置顶。"; +"ArchivePeerStories" = "隐藏动态"; +"ArchivePinned" = "归档已置顶"; +"ArchivePinnedInfo" = "向左滑动以隐藏归档。"; +"ArchiveSearchFilter" = "归档"; +"ArchiveSettingUnmutedChats" = "文件夹中的聊天"; +"ArchiveSettingUnmutedChatsCheck" = "始终保留在归档中"; +"ArchiveSettingUnmutedChatsInfo" = "即使已将聊天取消静音并收到新消息,仍将文件夹中的已归档聊天保留在归档中。"; +"ArchiveSettingUnmutedFolders" = "未静音的聊天"; +"ArchiveSettingUnmutedFoldersCheck" = "始终保留在归档中"; +"ArchiveSettingUnmutedFoldersInfo" = "即使已将聊天取消静音并收到新消息,仍将归档的聊天保留在存档中。"; +"ArchiveSettings" = "归档设置"; +"ArchiveStickerSetsAlertTitle" = "归档%1$s"; +"ArchiveStickersAlertMessage" = "您确定归档选中的贴纸包吗?"; +"ArchiveStories#other" = "归档动态"; +"ArchiveStory" = "从个人资料页移除"; +"ArchivedChats" = "已归档的聊天"; +"ArchivedEmojiInfo" = "您最多可以添加 200 个表情包。\n继续添加更多表情包时,未使用的将被归档。"; +"ArchivedEmojiPacks" = "已归档表情包"; +"ArchivedEmojiPacksEmpty" = "暂无已归档的表情包"; +"ArchivedMasks" = "已归档装饰"; +"ArchivedMasksAlertInfo" = "您的一些旧装饰包已经被归档。您可以在装饰设置里重新激活它们。"; +"ArchivedMasksAlertTitle" = "已归档装饰"; +"ArchivedMasksEmpty" = "暂无已归档装饰"; +"ArchivedMasksInfo" = "您最多能够添加 200 套装饰包。\n未被使用的装饰包将在您添加新装饰包时被归档。"; +"ArchivedStickers" = "已归档贴纸包"; +"ArchivedStickersAlertInfo" = "您的一些旧贴纸包已经被归档。您可以在「贴纸」设置里重新激活它们。"; +"ArchivedStickersAlertTitle" = "已归档贴纸包"; +"ArchivedStickersEmpty" = "暂无已归档的贴纸包"; +"ArchivedStickersInfo" = "您最多能够拥有 200 套贴纸包。\n未被使用的贴纸包将在您添加新贴纸包时被归档。"; +"ArchivedStories" = "已归档的动态"; +"AreTypingGroup" = "%1$s 正在输入…"; +"AreYouSure" = "您确定吗?"; +"AreYouSureBlockContact" = "您确定要屏蔽该联系人吗?"; +"AreYouSureBlockContact2" = "您确定要屏蔽 **%1$s** 吗?"; +"AreYouSureClearDrafts" = "您确定要删除所有云草稿吗?"; +"AreYouSureClearDraftsTitle" = "删除云端草稿"; +"AreYouSureClearHistory" = "您确定要删除此聊天中的所有消息吗?"; +"AreYouSureClearHistoryCacheFewChats" = "是否删除选中聊天的所有缓存文本和媒体文件?"; +"AreYouSureClearHistoryChannel" = "确定删除此频道中所有缓存的消息和媒体文件?"; +"AreYouSureClearHistoryFewChats" = "您确定要清除选定的聊天记录吗?"; +"AreYouSureClearHistoryGroup" = "是否删除该群组中所有缓存的消息记录和媒体文件?"; +"AreYouSureClearHistorySavedMessages" = "您确定要清空 **我的收藏** 吗?此操作无法撤销。"; +"AreYouSureClearHistoryWithChannel" = "您要清除频道 **%1$s** 的历史记录吗?"; +"AreYouSureClearHistoryWithChat" = "您确定要清除 **%1$s** 的聊天记录吗?"; +"AreYouSureClearHistoryWithSecretUser" = "这将会为你和 **%1$s** 删除所有消息,确定要继续吗?"; +"AreYouSureClearHistoryWithUser" = "您确定要清空与 **%1$s** 的聊天记录吗?"; +"AreYouSureDeleteAndExit" = "您确定要删除并退出此群组吗?"; +"AreYouSureDeleteAndExitChannel" = "您确定要删除并退出此频道吗?"; +"AreYouSureDeleteAndExitName" = "您确定要删除并退出 **%1$s** 群组吗?"; +"AreYouSureDeleteContact" = "您确定要删除此联系人吗?"; +"AreYouSureDeleteFewChats" = "您确定删除这些聊天吗?"; +"AreYouSureDeleteFewMessages" = "您确定要清空这些消息吗?"; +"AreYouSureDeleteFewMessagesMega" = "您确定要为所有人删除这些消息吗?"; +"AreYouSureDeleteGIF" = "您确定要删除此 GIF 动态图吗?"; +"AreYouSureDeleteGIFEveryone" = "您确定要为所有人删除此 GIF?"; +"AreYouSureDeleteGIFTitle" = "删除 GIF"; +"AreYouSureDeleteMessages" = "您确定要删除这 %1$s吗?"; +"AreYouSureDeletePhoto" = "您确定要删除此图片吗?"; +"AreYouSureDeletePhotoEveryone" = "您确定要为所有人删除此相片吗?"; +"AreYouSureDeletePhotoTitle" = "删除照片"; +"AreYouSureDeleteSingleMessage" = "您确定要删除这条消息吗?"; +"AreYouSureDeleteSingleMessageMega" = "您确定要为所有人删除此消息吗?"; +"AreYouSureDeleteThisChat" = "您确定要永久删除此聊天吗?"; +"AreYouSureDeleteThisChatSavedMessages" = "您确定要清空收藏夹内的消息吗?"; +"AreYouSureDeleteThisChatWithBot" = "您确定要删除并屏蔽与**%1$s**的聊天吗?"; +"AreYouSureDeleteThisChatWithBotWithCheckmark" = "您确定要删除与 **%1$s** 的聊天吗?"; +"AreYouSureDeleteThisChatWithGroup" = "您确定要删除 **%1$s** 的聊天吗?"; +"AreYouSureDeleteThisChatWithSecretUser" = "您确定要删除与 **%1$s** 的私密聊天吗?"; +"AreYouSureDeleteThisChatWithUser" = "是否永久删除与 **%1$s** 的聊天?"; +"AreYouSureDeleteVideo" = "您确定要删除此视频吗?"; +"AreYouSureDeleteVideoEveryone" = "你确定要为所有人删除此视频吗?"; +"AreYouSureDeleteVideoTitle" = "删除视频"; +"AreYouSureLogout" = "您确定要登出吗?\n\n此操作将取消您的所有私密聊天。\n\n您可以同时在所有设备上使用 Telegram,并在同一个应用中使用多个帐号。"; +"AreYouSureRegistration" = "您确定要取消注册吗?"; +"AreYouSureSecretChat" = "您确定要开始私密对话吗?"; +"AreYouSureSecretChatTitle" = "私密聊天"; +"AreYouSureSendChatToBotAdd" = "这将会把 **%1$s** 添加到 **%2$s**。"; +"AreYouSureSendChatToBotAddRights" = "这将会同时将 **%1$s** 加入 **%2$s**,并具有以下权限:%3$s。"; +"AreYouSureSendChatToBotMessage" = "您确定要将 **%1$s** 发送至 **%2$s** 吗?"; +"AreYouSureSendChatToBotTitle" = "将 %1$s 发送给 %2$s ?"; +"AreYouSureSessionTitle" = "结束会话"; +"AreYouSureSessions" = "您确定要终止所有其它设备上的会话吗?"; +"AreYouSureSessionsTitle" = "结束会话"; +"AreYouSureShareMyContactInfo" = "您确定要分享您的手机号码?"; +"AreYouSureShareMyContactInfoBot" = "机器人将会获取您的手机号码。这可能对机器人与其它服务的集成有用。\n\n⚠ 警告!切勿在小程序中输入您的 Telegram 登录验证码。"; +"AreYouSureShareMyContactInfoBotUnblock" = "此操作也将解锁机器人。"; +"AreYouSureShareMyContactInfoUser" = "您是否确定将您的手机号码(%1$s)分享给 **%2$s** 吗?"; +"AreYouSureShareMyContactInfoWebapp" = "**%s** 将会获取您的手机号码。这可能对机器人与其它服务的集成有用。"; +"AreYouSureUnblockContact" = "您确定要取消屏蔽此用户吗?"; +"AreYouSureUnsaveFewMessages" = "您确定要删除这些消息吗?"; +"AreYouSureUnsaveSingleMessage" = "您确定要从“我的收藏”中删除此消息吗?"; +"AreYouSureWebSessions" = "您确定要断开所有使用 Telegram 登录的网站吗?"; +"ArticleByAuthor" = "作者:%1$s"; +"ArticleDateByAuthor" = "%2$s 作于 %1$s"; +"AskAQuestion" = "向我们提问"; +"AskAQuestionInfo" = "请注意:Telegram 的支持服务来自志愿者。我们会尽快回复,但仍可能需要等待一段时间。\n\n请前往Telegram 常见问题]]>看看:那里有绝大多数问题的答案和重要的故障排除]]>提示。"; +"AskButton" = "向志愿者提问"; +"AttachAudio" = "语音消息"; +"AttachAudioRestricted" = "该群组的管理员已限制您在 %1$s 前发送音乐。"; +"AttachAudioRestrictedForever" = "群管理员已限制您在本群发送音乐消息。"; +"AttachBotsHelp" = "上划查看机器人"; +"AttachContact" = "联系人"; +"AttachContactsSlowMode" = "慢速模式已启用,您不能发送给超过 1 位联系人。"; +"AttachDestructingPhoto" = "阅后即焚图片"; +"AttachDestructingVideo" = "阅后即焚视频"; +"AttachDestructingVoice" = "自毁语音消息"; +"AttachDocument" = "文件"; +"AttachDocumentsRestricted" = "该群组的管理员已限制您在 %1$s 之前发送文件。"; +"AttachDocumentsRestrictedForever" = "该群组的管理员已限制你在此发送文件。"; +"AttachGame" = "游戏"; +"AttachGif" = "GIF"; +"AttachGifRestricted" = "此群组的管理员已限制您发送 GIF 动态图直到 %1$s。"; +"AttachGifRestrictedForever" = "此群组的管理员已限制您发送 GIF 动态图。"; +"AttachInlineRestricted" = "此群组的管理员已限制您在 %1$s 之前发送机器人内容。"; +"AttachInlineRestrictedForever" = "此群组的管理员已限制您发送机器人内容。"; +"AttachLiveLocation" = "实时位置"; +"AttachLiveLocationIsSharing" = "%1$s - 正在与 %2$s 共享"; +"AttachLiveLocationIsSharingChat" = "正在与 %2$s 共享 %1$s"; +"AttachLiveLocationIsSharingChats" = "正在与 %2$s 共享 %1$s"; +"AttachLivePhoto" = "动态照片"; +"AttachLocation" = "位置"; +"AttachMediaDragHint" = "拖动媒体以重新排序"; +"AttachMediaPhotoDeselected" = "已取消选择图片。"; +"AttachMediaPreview" = "消息预览"; +"AttachMediaPreviewButton" = "显示预览"; +"AttachMediaRestricted" = "该群组的管理员限制了您在 %1$s 之前发送媒体文件。"; +"AttachMediaRestrictedForever" = "此群组的管理员已限制您发送媒体文件。"; +"AttachMediaVideoDeselected" = "已取消选择视频。"; +"AttachMenu" = "添加附件..."; +"AttachMusic" = "音乐"; +"AttachOnceAudio" = "阅后即焚视频语音消息"; +"AttachOnceRound" = "阅后即焚视频消息"; +"AttachPaidMedia" = "⭐️ %s"; +"AttachPhoto" = "图片"; +"AttachPhotoExpired" = "图片已过期"; +"AttachPhotoRestricted" = "该群组的管理员已限制您在 %1$s 前发送图片。"; +"AttachPhotoRestrictedForever" = "该群组的管理员已限制您发送图片。"; +"AttachPlainRestricted" = "该群组的管理员已限制您在 %1$s 之前发送文本消息。"; +"AttachPlainRestrictedForever" = "群组管理员已禁止你在群内发送文本消息。"; +"AttachQuickReplies" = "回复"; +"AttachRound" = "视频消息"; +"AttachRoundExpired" = "视频消息已失效"; +"AttachRoundRestricted" = "该群组的管理员已限制您在 %1$s 之前发送视频消息。"; +"AttachRoundRestrictedForever" = "该群组的管理员已限制您在此发送视频消息。"; +"AttachSelectedLocation" = "附加所选位置"; +"AttachSticker" = "贴纸"; +"AttachStickersRestricted" = "该群组的管理员限制了您在 %1$s 之前发送贴纸。"; +"AttachStickersRestrictedForever" = "此群组的管理员已限制您发送贴纸。"; +"AttachVideo" = "视频"; +"AttachVideoExpired" = "视频已自毁"; +"AttachVideoRestricted" = "该群组的管理员已限制您在 %1$s 前发送视频。"; +"AttachVideoRestrictedForever" = "群管理员已限制您在本群发送视频消息。"; +"AttachVoiceExpired" = "语音已过期"; +"AttachVoiceRestricted" = "该群组的管理员已限制您在 %1$s 之前发送语音消息。"; +"AttachVoiceRestrictedForever" = "群组管理员已禁止你在群内发送语音消息。"; +"AudioAddToProfile" = "添加到个人资料页"; +"AudioAddedToProfileRemove" = "此音频在您的个人资料页中可见。**移除 >**"; +"AudioAutodownload" = "语音消息"; +"AudioRemoveFromProfile" = "从个人资料中移除"; +"AudioSaveTo" = "保存到…"; +"AudioSaveToInfo" = "选择您想要保存该音频的位置。"; +"AudioSaveToMusicFolder" = "至音乐文件夹"; +"AudioSaveToMyProfile" = "到个人资料页"; +"AudioSaveToMyProfileSaved" = "音频已添加到您的个人资料页。"; +"AudioSaveToMyProfileUnsaved" = "音频已从您的个人资料页中删除。"; +"AudioSaveToSavedMessages" = "至我的收藏"; +"AudioSaveToSavedMessagesSaved" = "音频已发送到我的收藏。"; +"AudioSavedHint" = "文件已保存至音乐。"; +"AudioSearchChats" = "分享的音乐"; +"AudioSearchGlobal" = "全局搜索"; +"AudioSearchLocal" = "本地音乐"; +"AudioSearchProfile" = "个人音乐"; +"AudioSpeedCustom" = "音频将会以 %1$sx 的速度播放。"; +"AudioSpeedFast" = "音频将以两倍速度播放。"; +"AudioSpeedMedium" = "音频将以 1.5 倍速播放。"; +"AudioSpeedNormal" = "音频将以正常速度播放。"; +"AudioTitle" = "分享的音乐"; +"AudioUnknownArtist" = "未知艺术家"; +"AudioUnknownTitle" = "未知标题"; +"AudiosSavedHint#other" = "已将 %1$d 个文件保存至音乐。"; +"August" = "八月"; +"AuthAnotherClient" = "通过二维码登录"; +"AuthAnotherClientDownloadClientUrl" = "https://desktop.telegram.org/"; +"AuthAnotherClientInfo1" = "从 *desktop.telegram.org* 下载电脑版 Telegram 。"; +"AuthAnotherClientInfo2" = "运行 Telegram 桌面版获取二维码。"; +"AuthAnotherClientInfo3" = "扫描二维码链接您的账号。"; +"AuthAnotherClientInfo4" = "扫描二维码以连接 *Telegram 桌面版* 或 *Telegram 网页版*。"; +"AuthAnotherClientInfo5" = "前往 *getdesktop.telegram.org* 或 *web.telegram.org* 获取二维码"; +"AuthAnotherClientNotFound" = "没有发现认证令牌"; +"AuthAnotherClientOk" = "成功授权"; +"AuthAnotherClientScan" = "通过二维码登录"; +"AuthAnotherClientTokenError" = "令牌无效或已过期。"; +"AuthAnotherClientUrl" = "此二维码可登录到您的 Telegram 帐号。\n\n要确认Telegram登录,请转到 **设置**>**设备**>**扫一扫**。"; +"AuthAnotherWebClientUrl" = "https://web.telegram.org/"; +"AuthorHiddenDescription" = "消息发送者已限制转发消息中显示他们的名字。"; +"AutoDelete1Day" = "1天"; +"AutoDelete1Hour" = "1小时"; +"AutoDelete1Month" = "1个月"; +"AutoDelete24Hours" = "24 小时"; +"AutoDelete7Days" = "1 周"; +"AutoDeleteAfteTitle" = "自动删除…"; +"AutoDeleteAfter" = "%s 后自动删除"; +"AutoDeleteAfter1Day" = "1天后"; +"AutoDeleteAfter1Month" = "1个月后"; +"AutoDeleteAfter1Week" = "1周后"; +"AutoDeleteAfter3Weeks" = "3 周后"; +"AutoDeleteAfterShort" = "%s 后"; +"AutoDeleteAlertChannelInfo" = "在一定时间后自动删除此频道中发送的新消息。"; +"AutoDeleteAlertGroupInfo" = "此群组的新消息将在特定时间后自动删除。"; +"AutoDeleteAlertTitle" = "自动删除消息"; +"AutoDeleteAlertUserInfo" = "指定时间后自动删除您与 %1$s 的消息"; +"AutoDeleteCachedMedia" = "自动删除已缓存媒体"; +"AutoDeleteConfirm" = "确认"; +"AutoDeleteConfirmMessage" = "您确定要于消息发送 %s 后为所有人自动删除您新建的私聊和群组中的所有消息吗?"; +"AutoDeleteCustom" = "自定义"; +"AutoDeleteCustom2" = "设置自定义时间"; +"AutoDeleteDisable" = "停用"; +"AutoDeleteDisabled" = "自动删除已停用"; +"AutoDeleteGlobalAction" = "un1 为所有聊天启用了自毁计时器。\n该聊天中的所有新消息将会在发送 %1$s 后自动删除。"; +"AutoDeleteGlobalActionFromYou" = "您已为所有聊天已设置自毁计时器。\n此聊天中的所有新消息在发送 %s 之后将自动删除。"; +"AutoDeleteGlobalTimerDisabled" = "为所有新聊天禁用自毁计时器。"; +"AutoDeleteGlobalTimerEnabled" = "您发起的所有新聊天中的消息将在**%s**后自动删除。"; +"AutoDeleteHeader" = "自动删除此聊天中的消息"; +"AutoDeleteHintOffText" = "已停用自动删除。"; +"AutoDeleteHintOnText" = "该频道中的新消息将在 %1$s 后自动删除。"; +"AutoDeleteIn" = "在 %1$s 之后自动删除"; +"AutoDeleteInfo" = "在一定时间后自动删除本聊天中发送的新消息。"; +"AutoDeleteMediaNever" = "从不"; +"AutoDeleteMessages" = "自动删除消息"; +"AutoDeleteNever" = "关闭"; +"AutoDeletePopupDescription" = "一段时间后自动删除此聊天中的新消息。"; +"AutoDeletePopupDescription2" = "您还可以在设置中为所有聊天默认设定**自动删除计时器**。"; +"AutoDeletePopupTitle" = "自动删除"; +"AutoDeleteSet" = "为本次聊天设置"; +"AutoDeleteSetInfo" = "此聊天中的消息会在发出后 %1$s 删除。"; +"AutoDeleteSetTimer" = "设置自动删除计时器"; +"AutoDeleteSettingsInfo" = "在你开始的所有新聊天中,一段时间后自动删除所有人的消息。"; +"AutoDownloadAudioInfo" = "由于语音消息占用很小,所以它们始终会被自动下载。"; +"AutoDownloadChannels" = "频道"; +"AutoDownloadContacts" = "联系人"; +"AutoDownloadCustom" = "自定义"; +"AutoDownloadDataUsage" = "流量使用情况"; +"AutoDownloadFiles" = "文件"; +"AutoDownloadFilesOn" = "文件"; +"AutoDownloadFilesTitle" = "自动下载文件和音乐"; +"AutoDownloadGroups" = "群组"; +"AutoDownloadHigh" = "高"; +"AutoDownloadLow" = "低"; +"AutoDownloadMaxFileSize" = "文件大小限制"; +"AutoDownloadMaxVideoSize" = "视频大小限制"; +"AutoDownloadMedia" = "自动下载媒体文件"; +"AutoDownloadMedium" = "中"; +"AutoDownloadOff" = "关闭"; +"AutoDownloadOn" = "开启"; +"AutoDownloadOnAllChats" = "在所有聊天中开启"; +"AutoDownloadOnFor" = "为 %1$s 开启"; +"AutoDownloadOnMobileData" = "使用移动数据时"; +"AutoDownloadOnMobileDataInfo" = "如果您在使用移动数据时想自动下载媒体文件,请将其开启。"; +"AutoDownloadOnRoamingData" = "漫游时"; +"AutoDownloadOnRoamingDataInfo" = "如果您在使用漫游时想自动下载媒体文件,请将其开启。"; +"AutoDownloadOnUpToFor" = "最大 %1$s(%2$s)"; +"AutoDownloadOnWiFiData" = "使用 Wi-Fi 时"; +"AutoDownloadOnWiFiDataInfo" = "打开此开关后将在 Wi-Fi 环境下自动下载媒体文件。"; +"AutoDownloadPhotos" = "图片"; +"AutoDownloadPhotosOn" = "图片"; +"AutoDownloadPhotosTitle" = "自动下载图片"; +"AutoDownloadPm" = "私聊"; +"AutoDownloadPreloadMusic" = "预先载入下一曲"; +"AutoDownloadPreloadMusicInfo" = "当您在听音乐时开始下载下一个音频文件。"; +"AutoDownloadPreloadVideo" = "预先载入较大的视频"; +"AutoDownloadPreloadVideoInfo" = "预加载大于%1$s的视频的前几秒(1-2 MB)以便即时播放。"; +"AutoDownloadStories" = "动态"; +"AutoDownloadTypes" = "媒体文件类型"; +"AutoDownloadUpToOnAllChats" = "在所有聊天中最大 %1$s"; +"AutoDownloadVideos" = "视频"; +"AutoDownloadVideosOn" = "视频"; +"AutoDownloadVideosTitle" = "自动下载视频和 GIF 动态图 "; +"AutoException#other" = "%1$d 项自动排除"; +"AutoLock" = "自动锁定"; +"AutoLockDisabled" = "已停用"; +"AutoLockInTime" = "离开 %1$s 后"; +"AutoLockInfo" = "在离开一段时间后需要重新输入锁定码。"; +"AutoNightAdaptive" = "自动切换"; +"AutoNightAutomatic" = "自动"; +"AutoNightBrightness" = "亮度阈值"; +"AutoNightBrightnessInfo" = "当环境亮度低于 %1$d%% 时,切换为夜间主题。"; +"AutoNightDisabled" = "已停用"; +"AutoNightFrom" = "从"; +"AutoNightLocation" = "使用当地日落/日出时间"; +"AutoNightModeOff" = "自动夜间模式已关闭"; +"AutoNightPreferred" = "首选夜间主题"; +"AutoNightSchedule" = "时间表"; +"AutoNightScheduled" = "计划时段"; +"AutoNightSystemDefault" = "系统默认"; +"AutoNightSystemModeOff" = "系统默认主题已禁用。"; +"AutoNightTheme" = "自动切换夜间模式"; +"AutoNightThemeOff" = "关闭"; +"AutoNightTo" = "到"; +"AutoNightUpdateLocation" = "更新位置"; +"AutoNightUpdateLocationInfo" = "计算日落/日出时间需要对您的粗略位置进行一次性检测。请注意,此位置仅会存储在您目前的设备上。\n\n日落:%1$s\n日出:%2$s"; +"AutodeleteTimerDisabledForChats" = "自焚计时器已在 %s 中禁用。"; +"AutodeleteTimerEnabledForChats" = "时长为 **%1$s** 的自毁计时器已在%2$s中启用。"; +"AutodownloadChannels" = "频道"; +"AutodownloadContacts" = "联系人"; +"AutodownloadGroupChats" = "群聊"; +"AutodownloadPrivateChats" = "私聊"; +"AutodownloadSizeLimit" = "大小限制"; +"AutodownloadSizeLimitUpTo" = "上限为 %1$s"; +"AutomaticDownloadSettings" = "自动下载设置"; +"AutomaticDownloadSettingsInfoMobile" = "您可以修改自动下载设置以减少移动数据的用量。"; +"AutomaticDownloadSettingsInfoRoaming" = "您可以更改自动下载设置,以减少漫游数据的使用量。"; +"AutomaticDownloadSettingsInfoWiFi" = "您可以更改自动下载设置,以减少连接到 Wi-Fi 时的数据用量。"; +"AutomaticMediaDownload" = "自动下载媒体文件"; +"AutomaticTranslation" = "自动翻译"; +"AutoplayGIF" = "GIF"; +"AutoplayGifs" = "自动播放 GIF"; +"AutoplayMedia" = "自动播放媒体"; +"AutoplayVideo" = "视频"; +"AutoplayVideoInfo" = "按音量按钮以打开声音。"; +"Available" = "可用"; +"AvailableIn" = "%s 后可用"; +"AvailableReactions" = "可选择的回应"; +"AvatarPreviewSearchMessages" = "搜索消息"; +"AwaitingEncryption" = "等待 %s 上线…"; +"AwayTo" = "距离此地 %1$s 米"; +"Back" = "返回"; +"BackEnteringCode" = "< 返回输入验证码"; +"BackEnteringPhrase" = "< 返回输入秘密短语"; +"BackEnteringWord" = "< 返回输入秘密单词"; +"BackgroundAnimate" = "视觉效果"; +"BackgroundAnimateInfo" = "发送消息时,背景颜色图案将会移动"; +"BackgroundBlurred" = "模糊"; +"BackgroundBrightness" = "亮度"; +"BackgroundChoosePattern" = "选择图案"; +"BackgroundColor" = "颜色"; +"BackgroundColorPreviewLine1" = "向左或向右滑动可查看更多颜色。"; +"BackgroundColorPreviewLine2" = "三文鱼是一种鱼,而不是一种颜色。"; +"BackgroundColorSinglePreviewLine1" = "点击“应用”以设置壁纸。"; +"BackgroundColorSinglePreviewLine2" = "看起来不错"; +"BackgroundColorSinglePreviewLine3" = "在这个聊天里应用壁纸吧"; +"BackgroundColors" = "颜色"; +"BackgroundDimming" = "背景虚化"; +"BackgroundHexColorCode" = "Hex 颜色代码"; +"BackgroundIntensity" = "强度"; +"BackgroundMotion" = "动态效果"; +"BackgroundPattern" = "图案"; +"BackgroundPreview" = "壁纸预览"; +"BackgroundPreviewLine1" = "提示:一些壁纸图片虚化后更好看。"; +"BackgroundPreviewLine2" = "哇!谢谢!"; +"BackgroundRestricted" = "后台数据已被限制。"; +"BackgroundSearchColor" = "颜色:"; +"BackgroundToChats" = "壁纸已发送给 **%1$s**。"; +"BackgroundToGroup" = "壁纸已发送给 **%1$s**。"; +"BackgroundToSavedMessages" = "壁纸已发送到 **我的收藏**。"; +"BackgroundToUser" = "已与 **%1$s** 共享壁纸。"; +"BadgeNumber" = "未读消息数量提示"; +"BadgeNumberMutedChats" = "包含已静音的聊天"; +"BadgeNumberShow" = "显示角标"; +"BadgeNumberUnread" = "统计未读消息"; +"Ban" = "封禁"; +"BanFromTheGroup" = "在群组中封禁此用户"; +"BanFromTheGroupNoCaps" = "在群组中封禁此用户"; +"BanUser" = "封禁用户"; +"BanUserMonoforum" = "在频道中封闭用户"; +"BannedPhoneNumber" = "此手机号码已被封禁。"; +"Bans#other" = "%1$d 次限制"; +"BioCopied" = "个人简介已复制至剪贴板。"; +"BioLimitSubtitle" = "在个人介绍中添加更多文字,使用链接。"; +"BioLimitTitle" = "个人简介"; +"BirthdayClearMessage" = "您确定要从个人资料页中移除您的生日吗?"; +"BirthdayClearTitle" = "移除生日"; +"BirthdayCopied" = "已将生日复制到剪贴板。"; +"BirthdayRemove" = "移除"; +"BirthdaySetupLater" = "你可以随时在个人资料页面中设置你的生日"; +"BirthdaySetupMessage" = "让您的联系人知道您何时庆祝生日"; +"BirthdaySetupTitle" = "添加您的生日!🎂"; +"BirthdayToday" = "🎂 今天生日"; +"BirthdayTodayMultipleMessage" = "赠送他们 Telegram 高级版。"; +"BirthdayTodayMultipleMessage2" = "赠送礼物给他们。"; +"BirthdayTodayMultipleTitle#other" = "今天 %d 位联系人过 **生日** ! 🎂"; +"BirthdayTodaySingleMessage" = "赠送他们 Telegram 高级版"; +"BirthdayTodaySingleMessage2" = "赠送礼物给他们。"; +"BirthdayTodaySingleTitle" = "今天是 %s 的 **生日**!🎂"; +"BirthdayTomorrow" = "明天过生日"; +"BirthdayYesterday" = "昨天生日"; +"BizBotManage" = "管理机器人"; +"BizBotRemove" = "从此聊天中移除机器人"; +"BizBotStart" = "开始"; +"BizBotStatusManages" = "机器人管理此聊天"; +"BizBotStatusStopped" = "机器人已暂停"; +"BizBotStop" = "停止"; +"Black" = "黑色"; +"Block" = "屏蔽"; +"BlockAndDeleteReplies" = "屏蔽并删除回复"; +"BlockBot" = "屏蔽机器人"; +"BlockContact" = "屏蔽用户"; +"BlockUser" = "屏蔽用户"; +"BlockUserAlert" = "您想要阻止 %1$s 在 Telegram 上给您发消息和打电话吗?"; +"BlockUserChatsTitle" = "聊天"; +"BlockUserContactsTitle" = "联系人"; +"BlockUserMessage" = "您是否想要屏蔽 **%1$s** 在 Telegram 上给的消息和通话?"; +"BlockUserMultiTitle" = "屏蔽用户"; +"BlockUserReplyAlert" = "您是否要屏蔽来自 **%1$s** 的消息?"; +"BlockUserTitle" = "屏蔽 %1$s"; +"BlockUsers" = "屏蔽用户"; +"BlockUsersMessage" = "您是否想要屏蔽 **%1$s** 位用户在 Telegram 上的消息和通话?"; +"BlockedEmpty" = "无"; +"BlockedUsers" = "已屏蔽的用户"; +"BlockedUsersCount#other" = "%1$d 位被屏蔽的用户"; +"BlockedUsersInfo" = "被您屏蔽的用户将无法向您发送消息,或邀请您加入群组。该用户也无法看到您的头像、动态、在线状态及最后上线时间。"; +"Blue" = "蓝色"; +"BlurInChat" = "在聊天中模糊"; +"BlurLinear" = "线性"; +"BlurOff" = "关闭"; +"BlurRadial" = "圆形"; +"Bold" = "粗体"; +"BoldMonths#other" = "**%1$d** 个月"; +"BoldYears#other" = "**%1$d** 年"; +"BoostBtn" = "助推"; +"BoostChannel" = "助推频道"; +"BoostDate" = "日期"; +"BoostExpireOn" = "助推将于 %s 到期"; +"BoostFeatureAutotranslation" = "消息自动翻译"; +"BoostFeatureBackground#other" = "%d 种频道壁纸"; +"BoostFeatureBackgroundGroup#other" = "%d 个群组背景"; +"BoostFeatureCustomBackground" = "自定义频道壁纸"; +"BoostFeatureCustomBackgroundGroup" = "自定义群组背景"; +"BoostFeatureCustomEmojiPack" = "自定义表情包"; +"BoostFeatureCustomReaction#other" = "%d 个自定义表情回应"; +"BoostFeatureEmojiStatuses" = "%s 表情符号状态"; +"BoostFeatureNameColor#other" = "%d 种频道名称颜色"; +"BoostFeatureNameColorGroup#other" = "%d 个群组名称颜色"; +"BoostFeatureProfileColor#other" = "%d 种频道封面颜色"; +"BoostFeatureProfileColorGroup#other" = "%d 种群组封面支持的颜色"; +"BoostFeatureProfileIcon" = "频道封面的自定义标志"; +"BoostFeatureProfileIconGroup" = "为群组的封面自定义一个标志"; +"BoostFeatureRealTimeTranslations" = "即时翻译"; +"BoostFeatureReplyColor#other" = "%d 种链接及引用样式"; +"BoostFeatureReplyIcon" = "链接和引用的自定义标志"; +"BoostFeatureStoriesPerDay#other" = "每天 %d 条状态"; +"BoostFeatureSwitchOffAds" = "关闭广告"; +"BoostFeatureVoiceToTextConversion" = "语音转文字"; +"BoostFrom" = "来自"; +"BoostGift" = "礼物"; +"BoostGroup" = "助推群组"; +"BoostLevel#other" = "等级 %d"; +"BoostLevelPlus#other" = "等级 %d+ "; +"BoostLevelRequired#other" = "需要等级 %1$d"; +"BoostLevelUnlocks#other" = "等级 %d 解锁:"; +"BoostLinkButton" = "助推"; +"BoostReason" = "事由"; +"BoostReasonGiveaway" = "抽奖活动"; +"BoostStars#other" = "%d 颗星星"; +"BoostStories#other" = "**%d**\u00a0条动态"; +"BoostUntil" = "直到"; +"Boosters" = "助推者"; +"BoostersInfoDescription" = "您的频道目前受到这些用户的助推。"; +"BoostersInfoGroupDescription" = "您的群组正在由这些用户助推。"; +"BoostingAddChannel" = "添加频道"; +"BoostingAddChannelOrGroup" = "添加群组或频道"; +"BoostingAdditionalFeaturesSubtitle" = "通过获得 **助推**,您的群组可以达到更高的等级并解锁更多的功能。"; +"BoostingAdditionalFeaturesSubtitleChannel" = "通过获得 **助推**,您的频道可以达到更高等级并解锁更多功能。"; +"BoostingAdditionalFeaturesTitle" = "额外功能"; +"BoostingAllMembers" = "所有成员"; +"BoostingAllSubscribers" = "所有订阅者"; +"BoostingApplyChangesChannels" = "您已更改频道清单。应用更改吗?"; +"BoostingApplyChangesCountries" = "您已更改国家清单。应用更改吗?"; +"BoostingApplyChangesUsers" = "您已更改用户名单。应用更改吗?"; +"BoostingAvailableIn" = "将于 %1$s 可用"; +"BoostingAwardSpecificUsers" = "奖励特定用户"; +"BoostingAwardsCreated" = "已赠送 Telegram 高级版订阅"; +"BoostingBoostAgain" = "再次助推"; +"BoostingBoostChannelMenu" = "助推频道"; +"BoostingBoostForChannels" = "频道助推"; +"BoostingBoostForChannelsInfo" = "您频道的订阅者可以 **助推** 您的频道,使其 **升级** 并获得 **专属功能**。"; +"BoostingBoostForGroups" = "助推群组"; +"BoostingBoostForGroupsInfo" = "您的群组成员可以**助推**群组并使其**升级**以获得**专属功能**。"; +"BoostingBoostGroupMenu" = "助推群组"; +"BoostingBoostStars#other" = "%d 颗星星"; +"BoostingBoostToSendMessages" = "助推此群组以发送消息"; +"BoostingBoostsChannelByUserServiceMsg" = "%s 已助推此频道"; +"BoostingBoostsChannelByUserServiceMsgCount#other" = "%2$s 总共助推了此频道 %1$d 次"; +"BoostingBoostsChannelByYouServiceMsg" = "您已助推频道"; +"BoostingBoostsChannelByYouServiceMsgCount#other" = "您已助推此频道 %1$d 次"; +"BoostingBoostsCount#other" = "%1$d 次助推"; +"BoostingBoostsCountTitle#other" = "%d 次助推"; +"BoostingBoostsGroupByUserServiceMsg" = "%s 已助推群组"; +"BoostingBoostsGroupByUserServiceMsgCount#other" = "%2$s 已助推群组 %1$d 次"; +"BoostingBoostsGroupByYouServiceMsg" = "您已助推此群组"; +"BoostingBoostsGroupByYouServiceMsgCount#other" = "您已助推此群组 %1$d 次"; +"BoostingBoostsViaGifts" = "通过赠品助推"; +"BoostingChannelWillReceiveBoost#other" = "此频道将获得 %d 次助推"; +"BoostingChannelsGroupsIncludedGiveaway" = "包含的群组和频道"; +"BoostingChannelsIncludedGiveaway" = "抽奖活动中包括的频道"; +"BoostingCheckGiftsStatistic" = "检查您频道的**统计信息**,了解礼物将如何助推您的频道。"; +"BoostingCheckGiftsStatisticGroup" = "查看群组的 **统计数据**,了解礼物能如何助推您的群组。"; +"BoostingCheckStatistic" = "在频道的 **统计信息** 页面查看您的频道通过此次抽奖活动获得了多少助推。"; +"BoostingCheckStatisticGroup" = "检查您群组的**资料信息**,了解此抽奖活动如何助推您的群组。"; +"BoostingChooseChannelsGroupsNeedToJoin" = "选择用户参与抽奖活动前额外需要加入的群组或频道。"; +"BoostingChooseChannelsNeedToJoin" = "选择用户需要订阅哪些频道才能参与抽奖活动。"; +"BoostingChooseHowMany" = "选择要赠送的 Telegram 高级版订阅数量以及接收的助推数量。"; +"BoostingChooseLimitGiveaway" = "选择是否要将抽奖活动仅限于活动发起之后加入频道的用户,或来自特定国家/地区的用户。"; +"BoostingChooseLimitGiveawayGroups" = "选择是否要将抽奖活动仅限于活动发起后加入群组或来自特定国家的用户。"; +"BoostingChooseRandom#other" = "选择何时在您的频道中随机选择 %d 名订阅者获得 Telegram 高级版。"; +"BoostingChooseRandomGroup#other" = "选择何时从您的群组中随机选出 %d 名成员接收 Telegram 高级版。"; +"BoostingConfirm" = "确认"; +"BoostingCongratulations" = "恭喜!"; +"BoostingCreateGiveaway" = "创建抽奖活动"; +"BoostingDate" = "日期"; +"BoostingDateAndTime" = "日期和时间"; +"BoostingDateWhenGiveawayEnds" = "抽奖活动结束日期"; +"BoostingDurationOfPremium" = "Telegram 高级版订阅的时长"; +"BoostingEligibleUsers" = "有资格参与抽奖活动的用户"; +"BoostingEnableColor" = "启用颜色"; +"BoostingEnableEmojiStatus" = "解锁表情符号状态"; +"BoostingEnableGroupEmojiPack" = "解锁群组表情包"; +"BoostingEnableLinkIcon" = "解锁链接图标"; +"BoostingEnableProfileColor" = "解锁资料页颜色"; +"BoostingEnableProfileIcon" = "解锁简介图标"; +"BoostingEnableStories" = "启用动态"; +"BoostingEnableStoriesForChannel" = "为此频道启用动态"; +"BoostingEnableStoriesForChannel2" = "为此频道启用动态"; +"BoostingEnableStoriesForGroup" = "为群组启用动态"; +"BoostingEnableWallpaper" = "解锁壁纸功能"; +"BoostingEnableWearCollectibles" = "佩戴礼物"; +"BoostingExpires" = "助推将在 %s 过期"; +"BoostingFrom" = "来自"; +"BoostingFromAllCountries" = "来自所有国家"; +"BoostingFromAllCountries1" = "来自 %1$s"; +"BoostingFromAllCountries2" = "来自 %1$s、 %2$s"; +"BoostingFromAllCountries3" = "来自 %1$s、 %2$s、 %3$s"; +"BoostingFromCountriesCount#other" = "来自 %1$d 个国家"; +"BoostingFromOtherChannel#other" = "%d 个其他聊天"; +"BoostingGetBoostsViaGifts" = "通过赠品助推"; +"BoostingGetMoreBoost" = "通过向您的订阅者赠予 Telegram 高级版来为您的频道获取更多助推。"; +"BoostingGetMoreBoost2" = "通过赠送奖品来为您的频道获得更多的助推和订阅者。"; +"BoostingGetMoreBoostByGifting" = "要助推%1$s,请向好友赠予 **Telegram 高级版** 来获取更多助推 。"; +"BoostingGetMoreBoostByGiftingCount#other" = "要助推 **%2$s**,请通过向朋友赠送 **Telegram 高级版** 以获得 **%1$d** 次额外的助推。"; +"BoostingGetMoreBoostGroup" = "通过向群组成员赠送高级版订阅为您的群组获得更多助推。"; +"BoostingGetMoreBoosts" = "通过向您的订阅者赠予 Telegram 高级版来为您的频道获取更多助推。"; +"BoostingGetMoreBoosts2" = "通过赠送奖品来为您的频道获得更多的助推和订阅者。"; +"BoostingGetMoreBoostsGroup" = "通过向您的成员赠送 Telegram 高级版来为您的群组获得更多助推。"; +"BoostingGift" = "赠礼"; +"BoostingGiftLink" = "礼物链接"; +"BoostingGiftLinkForwardedTo" = "礼物链接已转发到 **%1$s**。"; +"BoostingGiftLinkForwardedToSavedMsg" = "礼物链接已转发到 **我的收藏**。"; +"BoostingGiftsCount#other" = "%1$d 份奖品"; +"BoostingGiveAwayAbout" = "关于此抽奖活动"; +"BoostingGiveAwayFromCountries" = "来自 %1$s"; +"BoostingGiveaway" = "抽奖活动"; +"BoostingGiveawayAdditionPrizeCountHint#other" = "所有奖品:**%1$d** 份为期 %2$s 的 Telegram 高级版订阅。"; +"BoostingGiveawayAdditionPrizeCountNameHint#other" = "所有奖品:**%1$d** 份 %2$s 以及为期 %3$s 的 Telegram Premium 订阅。"; +"BoostingGiveawayAdditionPrizeHint" = "如果您除了 Telegram 高级版订阅之外还想为获奖者提供您自己的奖品,请打开此选项。"; +"BoostingGiveawayAdditionalPrizes" = "额外奖品"; +"BoostingGiveawayCanceledByPayment" = "该频道通过撤销付款的方式取消了奖品。"; +"BoostingGiveawayChannelStarted" = "频道已发起抽奖活动"; +"BoostingGiveawayCreated" = "抽奖活动已创建"; +"BoostingGiveawayDeleteMsgText" = "删除此消息不会取消抽奖活动——仍旧将在 **%s** 选出获奖者。\n\n一旦消息被删除,将无法恢复抽奖活动公告。"; +"BoostingGiveawayDeleteMsgTitle" = "删除公告"; +"BoostingGiveawayEnd" = "抽奖活动已结束"; +"BoostingGiveawayEnterYourPrize" = "输入您的奖品"; +"BoostingGiveawayGroupStarted" = "群组已开始抽奖活动"; +"BoostingGiveawayHowItWorksIncludeText#other" = "**%2$s** 还包括 **%1$d** **%3$s** 等奖品。频道管理员负责发放这些奖品。"; +"BoostingGiveawayHowItWorksSubText#other" = "Telegram 将于 **%2$s** 自动选择加入 **%4$s** 的 **%3$s** 位随机用户。"; +"BoostingGiveawayHowItWorksSubTextDate#other" = "在 **%2$s**,Telegram 将会自动选择在 **%6$s** **%5$s** 之后加入 **%4$s** 的 **%3$s** 位随机用户。"; +"BoostingGiveawayHowItWorksSubTextDateEnd#other" = "在 **%2$s**,Telegram 已自动选择了在 **%6$s** **%5$s** 之后加入 **%4$s** 的 **%3$s** 位随机用户。"; +"BoostingGiveawayHowItWorksSubTextDateSeveral#other" = "Telegram 将于 %2$s 自动挑选 %3$s 位在 %6$s%7$s 之后加入 %4$s 和 %5$s 个其他列出频道的随机用户。"; +"BoostingGiveawayHowItWorksSubTextDateSeveral1#other" = "Telegram 将于 **%2$s** 自动挑选已加入 **%4$s** %5$s 的 **%3$s** 位随机用户。"; +"BoostingGiveawayHowItWorksSubTextDateSeveral2#other" = "以及 **%3$s** **%2$s** 之后的 **%1$d** 列出的其他频道"; +"BoostingGiveawayHowItWorksSubTextDateSeveralEnd#other" = "在 **%2$s**,Telegram 自动选择了在 **%7$s** **%6$s** 之后加入 **%4$s** 以及其他 **%5$s** 个列出频道的 **%3$s** 位随机用户。"; +"BoostingGiveawayHowItWorksSubTextDateSeveralEnd1#other" = "Telegram 于 **%2$s** 已自动挑选已加入 **%4$s** %5$s 的 %3$s 位随机用户。"; +"BoostingGiveawayHowItWorksSubTextEnd#other" = "Telegram 于 **%2$s** 自动选择了已加入 **%4$s** 的 **%3$s** 位随机用户。"; +"BoostingGiveawayHowItWorksSubTextSeveral#other" = "在 **%2$s**,Telegram 将会自动选择加入了 **%4$s** 以及其他 **%5$s** 个列出频道的 **%3$s** 位随机用户。"; +"BoostingGiveawayHowItWorksSubTextSeveral1#other" = "Telegram 将于 **%2$s** 自动挑选已加入 **%4$s** %5$s 的 **%3$s** 位随机用户。"; +"BoostingGiveawayHowItWorksSubTextSeveral2#other" = "和 **%1$d** 个其他列出的频道"; +"BoostingGiveawayHowItWorksSubTextSeveralEnd#other" = "在 **%2$s**,Telegram 已自动选择加入了 **%4$s** 和其他 **%5$s** 个列出频道的 **%3$s** 位随机用户。"; +"BoostingGiveawayHowItWorksSubTextSeveralEnd1#other" = "Telegram 於 **%2$s** 自动挑选了已加入 **%4$s** %5$s 的 **%3$s** 位随机用户。"; +"BoostingGiveawayHowItWorksText#other" = "此赠品活动由 **%2$s** 的管理员赞助,他们为其订阅者以 %4$s 的价格购买了 **%3$s 个 Telegram 高级版** 订阅。"; +"BoostingGiveawayHowItWorksTextEnd#other" = "此抽奖活动由 **%2$s** 的管理员赞助,并为其订阅者购买了时长为 %4$s 的 **%3$s 份 Telegram 高级版** 订阅。"; +"BoostingGiveawayHowItWorksTextEndGroup#other" = "此次抽奖活动由 **%2$s** 的管理员赞助,他们为其会员已准备为期 **%4$s** 的 **%3$s 个高级版订阅**。"; +"BoostingGiveawayHowItWorksTextGroup#other" = "此次抽奖活动由 **%2$s** 的管理员赞助,他们为其成员已准备为期 %4$s 的 **%3$s**。"; +"BoostingGiveawayJustStarted" = "%s 刚刚发起一场抽奖活动,向其订阅者赠送 Telegram 高级版订阅。"; +"BoostingGiveawayJustStartedGroup" = "%s 刚开始为其会员赠送 Telegram 高级版订阅的抽奖活动。"; +"BoostingGiveawayMsgAllSubs" = "所有频道的订阅者:"; +"BoostingGiveawayMsgAllSubsGroupPlural#other" = "群组所有成员:"; +"BoostingGiveawayMsgAllSubsPlural#other" = "频道的所有订阅者:"; +"BoostingGiveawayMsgInfo" = "**%1$d 份** 时长为 %2$s 的 Telegram 高级版订阅。"; +"BoostingGiveawayMsgInfoPlural1#other" = "**%1$d 个** Telegram 高级版"; +"BoostingGiveawayMsgInfoPlural2#other" = "%2$s 的订阅"; +"BoostingGiveawayMsgNewSubs" = "频道的新订阅者:"; +"BoostingGiveawayMsgNewSubsGroupPlural#other" = "群组的新成员:"; +"BoostingGiveawayMsgNewSubsPlural#other" = "本公告发布后\n加入以下频道的所有用户:"; +"BoostingGiveawayMsgParticipants" = "**参与者**"; +"BoostingGiveawayMsgPrizes#other" = "**%1$d** %2$s"; +"BoostingGiveawayMsgWithDivider" = "及"; +"BoostingGiveawayNotEligible" = "您没有资格参加此赠品活动,因为您是在 **%1$s** 即活动开始之前加入此频道的。"; +"BoostingGiveawayNotEligibleAdmin" = "您没有资格参与此赠品活动,因为您是参与频道的管理员 (**%1$s**)。"; +"BoostingGiveawayNotEligibleAdminGroup" = "您没有资格参加此抽奖活动,因为您是主办群组 (**%1$s**) 的管理员。"; +"BoostingGiveawayNotEligibleCountry" = "您没有资格参加此赠品活动,因为您所在的国家未包含在赠品活动条款中。"; +"BoostingGiveawayParticipant" = "您正在参与此抽奖活动,因为您已加入 **%1$s** 频道。"; +"BoostingGiveawayParticipantMulti" = "您正在参与此赠品活动,因为您已加入频道 **%1$s** (和 %2$d 个其他列出的频道)。"; +"BoostingGiveawayParticipantMultiPlural#other" = "您正在参与此次抽奖活动,因为您加入了 **%2$s** 频道(以及其他 **%1$d** 个列出的频道)。"; +"BoostingGiveawayPrivateChannel" = "这是私人频道"; +"BoostingGiveawayPrivateChannelWarning" = "您确定要新增私人频道吗?如果没有邀请链接,用户将无法加入。"; +"BoostingGiveawayPrivateGroup" = "群组是私密的"; +"BoostingGiveawayPrivateGroupWarning" = "您确定要新增私人群组吗?如果没有邀请链接,用户将无法加入。"; +"BoostingGiveawayPrizes" = "**抽奖奖品**"; +"BoostingGiveawayResults" = "赠品活动结果"; +"BoostingGiveawayResultsMsgAllAndMoreWinners#other" = "以及 %1$d 等更多!"; +"BoostingGiveawayResultsMsgAllWinnersReceivedLinks" = "所有获奖者都已在私密消息中收到礼物链接。"; +"BoostingGiveawayResultsMsgWinners#other" = "**获奖者**"; +"BoostingGiveawayResultsMsgWinnersSelected" = "**获奖者已选出!**"; +"BoostingGiveawayResultsMsgWinnersTitle#other" = "Telegram 已随机选出 %1$d 位 **赠品活动** 获奖者。"; +"BoostingGiveawayServiceUndistributed#other" = "已将 %d 个未分发的链接兑换码转发给频道管理员。"; +"BoostingGiveawayServiceUndistributedGroup#other" = "%d 个未分发的链接代码已转发至群组管理员。"; +"BoostingGiveawayServiceWinnersSelected#other" = "Telegram 已随机抽取 %d 位获奖者,并已通过私信发送奖品链接。"; +"BoostingGiveawayShortStatusEnded" = "该抽奖活动已结束。"; +"BoostingGiveawayShortStatusNotParticipating" = "您无法参加此次抽奖活动。"; +"BoostingGiveawayShortStatusParticipating" = "您参加此次赠品活动。"; +"BoostingGiveawayShowWinners" = "显示获奖者"; +"BoostingGiveawayShowWinnersHint" = "选择是否在抽奖活动结束时公开获奖者名单。"; +"BoostingGiveawayTakePart" = "要参加此抽奖活动,请在 **%2$s** 之前加入 **%1$s** 频道。"; +"BoostingGiveawayTakePartMulti" = "要参加此抽奖活动,请在 **%3$s** 之前加入 **%1$s** 频道(以及其他 **%2$d** 个列出的频道)。"; +"BoostingGiveawayTakePartMultiPlural#other" = "要参加此赠品活动,请在 **%3$s** 之前加入频道 **%2$s**(以及 **%1$d** 个列出的其他频道)。"; +"BoostingGiveawayUsedLinks" = "**%1$d** 位获奖者已使用了赠礼链接。"; +"BoostingGiveawayUsedLinksPlural#other" = "**%1$d** 位获奖者已使用了赠礼链接。"; +"BoostingGiveawayViewPrize" = "查看我的奖品"; +"BoostingGiveawayYouNotWon" = "您在此次抽奖活动中没有获奖。"; +"BoostingGiveawayYouWon" = "您在此抽奖活动中赢得了奖品 🏆"; +"BoostingGroupBoostCount#other" = "群组已获得 **%d** 次助推。"; +"BoostingGroupBoostWhatAreBoosts" = "助推是什么?"; +"BoostingGroupBoostWhatAreBoostsDescription#other" = "每个 **Telegram 高级版** 订阅者都可以使用此链接助推您的群组 **%d** 次,并帮助您解鎖更多功能:"; +"BoostingGroupWillReceiveBoost#other" = "此群组将获得 %d 次助推"; +"BoostingHowItWork" = "了解更多"; +"BoostingIncompleteGiveaway" = "未完成的抽奖活动"; +"BoostingIncreaseLevel" = "增加动态上限"; +"BoostingLinkAllows" = "此链接允许您启用 **Telegram 高级版** 订阅。"; +"BoostingLinkAllowsAnyone" = "此链接允许任何人启用 **Telegram 高级版** 订阅。"; +"BoostingLinkAllowsToUser" = "此链接允许 **%1$s** 启用 **Telegram 高级版** 订阅。"; +"BoostingLinkNotActivated" = "此链接尚未启用。"; +"BoostingLinkUsed" = "此链接用于启用 **Telegram 高级版** 订阅。"; +"BoostingMoreBoostsNeeded" = "需要更多助推"; +"BoostingNewMembers" = "仅限新成员"; +"BoostingNewSubscribers" = "仅限新订阅者"; +"BoostingNoRecipient" = "无接收者"; +"BoostingOnlyGiveawayCreatorSeeLink" = "只有赠品活动的发起者才能查看此链接。"; +"BoostingOnlyRecipientCode" = "只有接收者可以看到链接。"; +"BoostingPremium" = "Telegram 高级版"; +"BoostingPremiumChristmasSubTitle" = "赠送限定版典藏品给朋友!"; +"BoostingPremiumChristmasTitle" = "👑 史努比狗狗礼物放送 🎁"; +"BoostingPremiumChristmasToast" = "您可以稍后从“设置”中赠送礼物。"; +"BoostingPremiumUserCanBoostGroupWithLink" = "**高级版** 用户可以通过此链接助推您的群组:"; +"BoostingPreparedGiveawayOne" = "预付抽奖活动"; +"BoostingPreparedGiveawaySubscriptions" = "%1$d 个 %2$s 的订阅"; +"BoostingPreparedGiveawaySubscriptionsPlural#other" = "%1$d 个 %2$s 的订阅"; +"BoostingPreparedGiveaways" = "预付抽奖活动"; +"BoostingQuantityPrizes" = "奖品的数量"; +"BoostingReason" = "事由"; +"BoostingReassignBoost" = "重新分配助推"; +"BoostingReassignBoostText" = "要助推 **%1$s**,请重新分配之前助推或将 **Telegram 高级版** 赠予給朋友,以获得 **%2$d** 次额外的助推。"; +"BoostingReassignBoostTextLink" = "**赠送.Telegram 高级版订阅**"; +"BoostingReassignBoostTextPlural#other" = "要助推 **%2$s**,请重新分配之前的助推或将 **Telegram 高级版** 赠予給朋友,以获得 **%1$d** 次额外的助推。"; +"BoostingReassignBoostTextPluralWithLink#other" = "要助推 **%2$s**,請將先前的助推或重新分配 %3$s 給朋友以获得 **%1$d** 次额外助推。"; +"BoostingReassignBoosts" = "重新分配助推"; +"BoostingReassignedFrom" = "%1$s 次助推从其他 %2$s 个频道重新分配"; +"BoostingReassignedFromPlural#other" = "%2$s 的 %1$d 次助推已重新分配"; +"BoostingReceivedGiftDuration" = "您的奖品是 %s 的 **Telegram 高级版**\n订阅。"; +"BoostingReceivedGiftFrom" = "您已收到来自 %s 的礼物。"; +"BoostingReceivedGiftNoName" = "您已收到礼物。"; +"BoostingReceivedGiftOpenBtn" = "公开礼物链接"; +"BoostingReceivedPrizeDuration" = "你的奖品是 %s 的 **Telegram 高级版** 订阅。"; +"BoostingReceivedPrizeFrom" = "您在 **%s** 发起的赠品活动中赢得了奖品。"; +"BoostingReceivedStars#other" = "您已从 %2$s 获得 %1$d 颗星星的奖品!"; +"BoostingRecipientWillBeSelected" = "抽奖活动结束后将选择接收者。"; +"BoostingReduceQuantity" = "减少数量"; +"BoostingReduceQuantityText" = "您无法在应用程序中购买 **%1$d** 个 %2$s 的订阅。您想将奖品数量減少到 **%3$d** 吗?"; +"BoostingReduceQuantityTextPlural#other" = "您无法在应用程序中购买 **%1$d** 个 %2$s 订阅。您想将奖品数量減少到 **%3$d** 吗?"; +"BoostingReduceUsersText" = "您无法在应用程序中购买 **%1$d** 订阅。仅 **%2$s** 可用。"; +"BoostingReduceUsersTextPlural#other" = "您无法在应用程序中购买 **%1$d** 订阅。仅 **%2$s** 可用。"; +"BoostingRemoveBoostFrom" = "移除您的助推"; +"BoostingRemoveRestrictionsSubtitle#other" = "助推群组 **%1$d** 以移除消息限制。您的助推将有助于 **%2$s** 解锁新功能。"; +"BoostingRemoveRestrictionsSuccessSubTitle" = "现在,您可以在其中发送消息了。"; +"BoostingRemoveRestrictionsSuccessTitle" = "你成功助推了此群组!"; +"BoostingSaveRecipients" = "保存接收者"; +"BoostingSelectCountry" = "选择国家"; +"BoostingSelectDateTime" = "选择日期与时间"; +"BoostingSelectPaidGiveaway" = "请选择您已支付的赠品活动以进行设定。"; +"BoostingSelectRecipients" = "选择接收者"; +"BoostingSelectUpTo" = " 最多可以选择 %1$d 个频道"; +"BoostingSelectUpToCountries" = "最多选择 %1$d 个国家"; +"BoostingSelectUpToCountriesPlural#other" = "最多可以选择 %1$d 个国家"; +"BoostingSelectUpToGroupChannelPlural#other" = "选择最多 %1$d 个群组或频道。"; +"BoostingSelectUpToPlural#other" = "最多选择 %1$d 个频道"; +"BoostingSelectUpToWarningChannels" = "您最多可以选择 %1$d 个频道。"; +"BoostingSelectUpToWarningChannelsGroupsPlural#other" = "你最多可以选择 %1$d 个群组或频道。"; +"BoostingSelectUpToWarningChannelsPlural#other" = "您最多可以 %1$d 个频道。"; +"BoostingSelectUpToWarningCountries" = "您最多可以选择 %1$d 个国家。"; +"BoostingSelectUpToWarningCountriesPlural#other" = "您最多可以选择 %1$d 个国家。"; +"BoostingSelectUpToWarningUsers" = "您最多可以选择 10 位用户。"; +"BoostingSendLinkToAnyone" = "您也可以 **发送此链接** 给任何人作为礼物。"; +"BoostingSendLinkToFriends" = "您也可以 **发送此链接** 给好友作为礼物。"; +"BoostingShareThisLink" = "将此链接分享给您的订阅者,以得到更多助推。"; +"BoostingShareThisLinkGroup" = "与您的成员分享此链接以获得更多助推。"; +"BoostingShortMonths" = "%1$d 个月"; +"BoostingShowMoreBoosts#other" = "显示 %1$d 个助推"; +"BoostingShowMoreGifts#other" = "显示 %1$d 个更多受赠助推"; +"BoostingStarOptionPerUser#other" = "每个用户 %d 颗"; +"BoostingStars" = "Telegram 星星"; +"BoostingStarsChooseRandom#other" = "选择何时从您的频道中随机选出 %1$d 名成员接收 %2$s。"; +"BoostingStarsChooseRandomGroup#other" = "选择何时从您的群组中随机选出 %1$d 名成员接收 %2$s。"; +"BoostingStarsChooseRandomStars#other" = "%1$d 颗星星"; +"BoostingStarsCountPlural#other" = "%1$d 颗星星"; +"BoostingStarsExpires" = "将在 %s 过期"; +"BoostingStarsGiveawayAdditionPrizeCountHint#other" = "所有奖品: **%1$d 颗星星**。"; +"BoostingStarsGiveawayAdditionPrizeCountNameHint#other" = "所有奖品: **%2$d** %3$s 和 **%1$d 颗星星**。"; +"BoostingStarsGiveawayAdditionPrizeHint" = "如果您想在赠送星星的同时,额外为获奖者提供您自己的奖品,请开启此选项。"; +"BoostingStarsGiveawayHowItWorksText#other" = "此次抽奖活动由 **%2$s** 的管理员赞助,他们已为其订阅者准备了 **%1$d 颗星星**。"; +"BoostingStarsGiveawayHowItWorksTextEnd#other" = "此次抽奖活动由 **%2$s** 的管理员赞助,他们已为其订阅者准备了 **%1$d 颗星星**。"; +"BoostingStarsGiveawayHowItWorksTextEndGroup#other" = "此次抽奖活动由 **%2$s** 的管理员赞助,他们已为其成员准备了 **%1$d 颗星星**。"; +"BoostingStarsGiveawayHowItWorksTextGroup#other" = "此次抽奖活动由 **%2$s** 的管理员赞助,他们为其成员已准备 ** %1$d 颗星星**。"; +"BoostingStarsGiveawayJustStarted#other" = "%2$s 刚刚为其关注者发起了一次抽奖活动,奖品为 %1$d 颗星星。"; +"BoostingStarsGiveawayJustStartedGroup#other" = "%2$s 刚刚为其成员发起了一次抽奖活动,奖品为 %1$d 颗星星。"; +"BoostingStarsGiveawayMsgInfoPlural1#other" = "**%1$d 颗星星** 将会分发"; +"BoostingStarsGiveawayMsgInfoPlural2#other" = "给 **%1$d** 名获奖者。"; +"BoostingStarsGiveawayResultsMsgAllWinnersReceivedLinks#other" = "所有获奖者都已总共收到 %d 颗星星。"; +"BoostingStarsGiveawayServiceUndistributed#other" = "%d 个未分发的奖品已转发给频道管理员。"; +"BoostingStarsGiveawayServiceUndistributedGroup#other" = "%d 颗未分发的星星已转发给群组管理员。"; +"BoostingStarsGiveawayServiceWinnersSelected#other" = "抽奖活动的 %1$d 名获奖者由 Telegram 随机选择。"; +"BoostingStarsOptions" = "可分发的星星"; +"BoostingStarsOptionsInfo" = "选择赠送多少颗星星以及每年获得多少次助推。"; +"BoostingStarsPrepaidGiveawayTitle" = "预付抽奖活动"; +"BoostingStarsPreparedGiveawaySubscriptionsPlural#other" = "%1$d 颗星星"; +"BoostingStarsQuantityPrizes" = "获奖者数量"; +"BoostingStarsQuantityPrizesInfo" = "选择您要为多少获奖者发放星星。"; +"BoostingStartGiveaway" = "开始抽奖活动"; +"BoostingStartGiveawayConfirmText" = "您确定要现在开始抽奖活动吗?"; +"BoostingStartGiveawayConfirmTitle" = "开始抽奖活动"; +"BoostingStoriesByGifting" = "通过向订阅者赠予 Telegram 高级版推广您的频道。"; +"BoostingStoriesByGiftingChannel" = "通过向订阅者赠送礼物来助推您的频道\n**Telegram 高级版**。"; +"BoostingStoriesByGiftingChannel2" = "通过向您的订阅者赠送奖品来助推您的频道。"; +"BoostingStoriesByGiftingGroup" = "通过向成员赠送 **Telegram 高级版** 来助推您的群组。"; +"BoostingStoriesByGiftingGroup2" = "通过向您的成员赠送奖品来助推您的群组。\u200b"; +"BoostingStoriesByGiftingLink" = "获取助推 >"; +"BoostingStoriesFeaturesAndTerms" = "您可以在 **这里** 查看 Telegram 高级版的功能列表和更多详情。"; +"BoostingSubscriptionsCount#other" = "%d 次助推"; +"BoostingSubscriptionsCountPlural#other" = "%2$s 个订阅"; +"BoostingTelegramPremiumCountPlural#other" = "%1$d 个 Telegram 高级版订阅"; +"BoostingTelegramPremiumFor" = "%s 的 Telegram 高级版"; +"BoostingTo" = "到"; +"BoostingToBeDistributed" = "待分发"; +"BoostingUnclaimed" = "尚未领取"; +"BoostingUnclaimedPrize" = "尚未领取的奖品"; +"BoostingUnclaimedPrizeDuration" = "此奖品是 %s 的 **Telegram 高级版** 订阅。"; +"BoostingUseLink" = "使用链接"; +"BoostingUsedGiftLink" = "使用过的礼品链接"; +"BoostingUsedLinkDate" = "此链接已于 %s 使用。"; +"BoostingWaitWarning" = "等待助推可用,或通过赠送 **Telegram 高级版** 订阅来获得 **%1$d** 次额外的助推。"; +"BoostingWaitWarningPlural#other" = "等待助推可用,或通过赠予 **Telegram 高级版** 来获得 **%1$d** 次额外的助推。"; +"BoostingWinnersDate" = "**选定获奖者的日期**"; +"BoostingWinnersRandomly" = "将随机选出获奖者"; +"BoostingYouHaveUnclaimedPrize" = "您从 **%s** 已获得一份尚未领取的奖品"; +"BoostingYouWereSelected" = "你已被频道选中"; +"BoostingYouWereSelectedGroup" = "您已被群组选中"; +"Boosts" = "助推"; +"BoostsExisting" = "活跃助推"; +"BoostsExpireOn" = "助推将于 %s 到期"; +"BoostsLevel" = "等级 %d"; +"BoostsLevel2" = "等级"; +"BoostsMaxLevelReached" = "已达到最高等级"; +"BoostsToLevel" = "助推以提升等级"; +"Bot" = "机器人"; +"BotAdWhat" = "这是什么?"; +"BotAddToGroupOrChannelInfo" = "该机器人可以管理群组或频道。"; +"BotAddToMenu" = "添加到菜单"; +"BotAffiliateProgramFeature1" = "设置由您推荐的用户产生的收入佣金。"; +"BotAffiliateProgramFeature1Title" = "与合作者分享收入"; +"BotAffiliateProgramFeature2" = "Telegram 将为数百万潜在合作者展示您的计划。"; +"BotAffiliateProgramFeature2Title" = "启动你的联盟计划"; +"BotAffiliateProgramFeature3" = "合作者将与他们的订阅者分享您的推荐链接。"; +"BotAffiliateProgramFeature3Title" = "通过联盟计划推广你"; +"BotAffiliateProgramRowText" = "分享小程序链接,赚取星星分成收益。"; +"BotAffiliateProgramRowTitle" = "赚取星星"; +"BotAffiliateProgramText" = "奖励那些帮助您扩大用户群的合作者"; +"BotAffiliateProgramTitle" = "联盟计划"; +"BotAllowBiometryMessage" = "你想要允许 **%s** 使用生物识别吗?"; +"BotAllowBiometryTitle" = "允许生物识别"; +"BotAlreadyAddedToAttachMenu" = "此机器人已经在您的附件菜单中。"; +"BotAttachMenuShortcatAddedAttach" = "**%s** 快捷方式已添加到附件菜单中。"; +"BotAttachMenuShortcatAddedAttachAndSide" = "**%s** 快捷方式已添加至附件菜单和侧边栏。"; +"BotAttachMenuShortcatAddedSide" = "**%s** 快捷方式已添加到侧边栏中。"; +"BotAuthAllowMessages" = "允许发送消息"; +"BotAuthAllowMessagesInfo" = "这将允许 %s 发送消息给您。"; +"BotAuthAppSubtitle" = "该应用将获取您的 **姓名**、\n**用户名** 和 **头像**。"; +"BotAuthBasedOnIP" = "基于 IP 地址(%s)"; +"BotAuthBotSubtitle" = "这个机器人会收到您的 **名字**,\n**用户名** 和 **个人资料页头像**。"; +"BotAuthInfo" = "本次登录尝试来自上方设备。"; +"BotAuthLoggedInFail" = "请尝试再次登录 **%s**。"; +"BotAuthLoggedInFailNoDomain" = "链接已失效。"; +"BotAuthLoggedInFailTitle" = "登录失败"; +"BotAuthLoggedInSuccess" = "您已成功登录到 **%s**。"; +"BotAuthLoggedInSuccessTitle" = "登录成功"; +"BotAuthLoggedInSuccessWithoutPhoneNumber" = "您已登录 **%s**,但您未授予存取您手机号码的权限。"; +"BotAuthLogin" = "登录"; +"BotAuthLoginRequestFrom" = "来自 **%s** 的登录请求"; +"BotAuthNotification" = "用户 %1$s 来自 %2$s 的登录请求"; +"BotAuthNotificationTitle" = "登录请求"; +"BotAuthPhoneNumber" = "电话号码"; +"BotAuthPhoneNumberAccept" = "允许"; +"BotAuthPhoneNumberDeny" = "拒绝"; +"BotAuthPhoneNumberText" = "**%1$s** 想要访问您的手机号码 **%2$s**。\n\n允许访问?"; +"BotAuthSelectCode" = "点击登录界面上\n显示的二维码"; +"BotAuthSelectEmoji" = "点击登录界面上\n显示的表情符号"; +"BotAuthSiteSubtitle" = "此网站将接收您的\u00a0**姓名**、**用户名**\u00a0和\u00a0**个人资料页头像**。"; +"BotAuthTitle" = "登录到 **%s**"; +"BotBalance" = "余额"; +"BotBalanceStars" = "星星"; +"BotBalanceTON" = "Gram 币"; +"BotCantAddToAttachMenu" = "无法将此机器人添加到附件菜单。"; +"BotCantJoinGroups" = "此机器人无法加入群组。"; +"BotCantOpenAttachMenuBot" = "无法在与机器人的聊天中打开此机器人。"; +"BotCantOpenAttachMenuChannel" = "无法在频道中打开此机器人。"; +"BotCantOpenAttachMenuGroup" = "无法在群组中打开此机器人。"; +"BotCantOpenAttachMenuSameBot" = "不能在与该机器人的聊天中打开此机器人。"; +"BotCantOpenAttachMenuUser" = "无法在与用户的聊天中打开此机器人。"; +"BotCantReadChatTooltip" = "该机器人无法读取聊天内容,仅能查看被提及的消息。"; +"BotChangeSettings" = "更改机器人设置"; +"BotChooseChatToVerify" = "选择聊天进行验证"; +"BotCloseAllTabs" = "关闭所有标签页"; +"BotDefaultPrivacyPolicy" = "https://telegram.org/privacy-tpa"; +"BotDownloadFileDownload" = "下载"; +"BotDownloadFileText" = "**%s** 邀请您下载以下文件:"; +"BotDownloadFileTitle" = "下载文件"; +"BotDownloads" = "下载"; +"BotEditCommands" = "修改命令"; +"BotEditIntro" = "修改介绍"; +"BotEmojiStatusConfirm" = "确认"; +"BotEmojiStatusPermissionAllow" = "允许"; +"BotEmojiStatusPermissionDecline" = "拒绝"; +"BotEmojiStatusPermissionRequest" = "**%1$s** 请求设置 **表情符号状态** 的权限,您可以随时在 **%2$s** 的资料页面中撤销此权限。"; +"BotEmojiStatusPermissionRequestGranted" = "**%s** 现在可以随时设置您的表情状态了。"; +"BotEmojiStatusSetRequest" = "你想要套用 **%1$s** 建议的表情状态吗?"; +"BotEmojiStatusSetRequestFor" = "是否要按照 %1$s 的建议设置此符号状态,持续 %2$s?"; +"BotEmojiStatusSetRequestForDay#other" = "%d 天"; +"BotEmojiStatusSetRequestForHour#other" = "%d 小时"; +"BotEmojiStatusSetRequestForMinute#other" = "%d 分钟"; +"BotEmojiStatusUpdated" = "表情符号状态已更新。"; +"BotFileDownloadCancel" = "取消"; +"BotFileDownloadOpen" = "打开"; +"BotFileDownloaded" = "文件已保存到「下载」文件夹中。"; +"BotFileDownloading" = "正在下载..."; +"BotForumAskForStartNewChat" = "输入任何消息即可建立新话题。"; +"BotForumAskForStartNewChatForward" = "转发到此处以创建新话题"; +"BotForumAskForStartNewChatTitle" = "新话题\n"; +"BotForumAskForStartOffNewChatForward" = "转发到主聊天"; +"BotForumAskForStartOffNewChatTitle" = "离题"; +"BotForumContinueChat" = "继续上一个话题"; +"BotForumNewTopic" = "新聊天"; +"BotFullscreenBack" = "返回"; +"BotFullscreenClose" = "关闭"; +"BotHelp" = "帮助"; +"BotInfoTitle" = "它能做什么?"; +"BotInvite" = "添加到群组"; +"BotLocationPermissionAllow" = "允许"; +"BotLocationPermissionDecline" = "拒绝"; +"BotLocationPermissionRequest" = "**%1$s** 请求访问您的位置信息,您可以随时在 **%2$s** 的资料页面中撤销此权限。"; +"BotLocationPermissionRequestDeniedApp" = "由于您限制了 Telegram 的位置访问权限,**%s** 已无法访问您的位置信息。"; +"BotLocationPermissionRequestDeniedAppSettings" = "更改设置 \\>"; +"BotLocationPermissionRequestGranted" = "**%s** 现在可以访问您的位置信息了"; +"BotLocationPermissionSettings" = "设置"; +"BotManageInfo" = "使用 @BotFather 管理此机器人。"; +"BotMessagePreview" = "消息预览"; +"BotMonetizationBalance" = "可用余额"; +"BotMonetizationBalanceInfo" = "您可以通过 Fragment 领取奖励,Fragment 是广告商用来支付广告费用的第三方平台。 **了解更多>**"; +"BotMonetizationBalanceInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"BotMonetizationBalanceInfoNotAvailable" = "在接下来的几周内,您将能够通过 Fragment(广告商用来支付广告费用的第三方平台)领取奖励。 **了解更多 >**"; +"BotMonetizationGraphImpressions" = "广告曝光量"; +"BotMonetizationGraphRevenue" = "广告收入"; +"BotMonetizationInfo" = "Telegram 将分享您的机器人中显示的广告所带来收入的 %1$d%% 作为奖励。**了解更多 >**"; +"BotMonetizationInfoFeature1Name" = "Telegram 广告"; +"BotMonetizationInfoFeature1Text" = "Telegram 可以在你的机器人中显示广告。"; +"BotMonetizationInfoFeature2Name" = "50:50 收入分成"; +"BotMonetizationInfoFeature2Text" = "您将以 Gram 收到 50% 的广告收入分成作为奖励。"; +"BotMonetizationInfoFeature3Name" = "灵活提现"; +"BotMonetizationInfoFeature3Text" = "您可以随时提取您的 Gram。"; +"BotMonetizationInfoTONLink" = "https://telegram.org/privacy"; +"BotMonetizationInfoTONText" = "**Gram** 是 Telegram 使用的区块链平台和加密货币,交易速度快且手续费低廉。"; +"BotMonetizationInfoTONTitle" = "什么是 💎 Gram?"; +"BotMonetizationInfoTitle" = "通过你的机器人赚取收益"; +"BotMonetizationOverview" = "收益概览"; +"BotMonetizationOverviewAvailable" = "可提款余额"; +"BotMonetizationOverviewLastWithdrawal" = "自上次提款以来的收益"; +"BotMonetizationOverviewTotal" = "累计收益总额"; +"BotMonetizationProceedsTONInfo" = "总余额中的 Gram 可在获得 3 天后用于广告或作为奖励提取。"; +"BotMonetizationProceedsTONInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"BotMonetizationTransactions" = "交易"; +"BotMoreTabs#other" = "%2$s & 另外 %1$d 个程序"; +"BotMultiContactsSelectorLimit#other" = "您最多可以选择 %1$d 位用户。"; +"BotName" = "机器人名称"; +"BotOpen" = "打开"; +"BotOpenPageMessage" = "**%1$s** 想要打开其网页应用以继续。\n\n它将能够访问您的 **IP 地址** 和基本设备信息。"; +"BotOpenPageTitle" = "打开页面"; +"BotOwnershipTransfer" = "两步验证码"; +"BotOwnershipTransferAlertText" = "您只有满足以下条件才能完成此操作:"; +"BotOwnershipTransferChangeOwner" = "确认"; +"BotOwnershipTransferReadyAlertText" = "请输入密码确认此操作。"; +"BotPermissionGameAlert" = "是否允许 %1$s 传送您的 Telegram 昵称及 ID(不是您的手机号码)到通过机器人打开的网页中呢?"; +"BotPreviewEmpty" = "暂无图片"; +"BotPreviewsDeleted#other" = "已删除 %d 个预览"; +"BotPrivacyPolicy" = "隐私政策"; +"BotProfilePermissionBiometry" = "生物识别"; +"BotProfilePermissionEmojiStatus" = "表情符号状态"; +"BotProfilePermissionLocation" = "地理位置"; +"BotProfilePermissions" = "允许访问"; +"BotPublicLink" = "公开链接"; +"BotPublicLinks" = "公开链接"; +"BotPublicLinksCount" = "%1$s/%2$s"; +"BotRemoveFromMenu" = "这会从所有菜单中移除 **%1$s** 快捷方式。"; +"BotRemoveFromMenuTitle" = "移除机器人"; +"BotRemoveInlineFromMenu" = "从建议中移除 **%1$s** ?"; +"BotRemoveVerificationChatText" = "您已验证此聊天。是否要撤销验证?"; +"BotRemoveVerificationText" = "你已验证过此账号。\n你想要撒销验证吗?"; +"BotRemoveVerificationTitle" = "移除验证"; +"BotRequestAttachPermission" = "**%1$s** 请求作为选项添加到您的附件菜单,以便您可以从任何聊天中访问它。"; +"BotRestart" = "重新启用机器人"; +"BotRestoreStorageButton" = "导入"; +"BotRestoreStorageCreatedAt" = "创建于 %s"; +"BotRestoreStorageHeader" = "导入数据的账户"; +"BotRestoreStorageText" = "**%s** 正在请求导入此设备上先前使用的Telegram账号数据的权限。"; +"BotRestoreStorageTitle" = "导入数据"; +"BotRestrictionsCanDo" = "该机器人能做什么?"; +"BotSentRevokeVerifyRequest" = "**%s** 的验证已撒销。"; +"BotSentVerifyRequest" = "**%s** 已收到通知并将在接受后收到您的验证标记和说明。"; +"BotSetAttachLinkNotBot" = "此链接并不指向机器人。"; +"BotSetPublicLinkHeader" = "设置公开链接"; +"BotSettings" = "设置"; +"BotSettingsChangedAlert" = "您已更改此机器人的一些设置。是否应用更改?"; +"BotShare" = "分享"; +"BotShareMessage" = "分享消息"; +"BotShareMessageInfo" = "%s 迷你应用建议您将此消息发送到您所选的聊天。"; +"BotShareMessagePreview" = "消息预览"; +"BotShareMessageShare" = "分享给..."; +"BotShareToTopic" = "分享到…"; +"BotSharedToMany#other" = "已发送消息至 %d 个聊天"; +"BotSharedToOne" = "已发送消息至 **%s**"; +"BotStarsAvailableBalance" = "可用余额"; +"BotStarsButtonWithdraw#other" = "通过 Fragment 提现 ⭐️%d "; +"BotStarsButtonWithdrawAll" = "通过 Fragment 全部提醒"; +"BotStarsButtonWithdrawShort#other" = "提现 ⭐️%d"; +"BotStarsButtonWithdrawShortAll" = "全部提现"; +"BotStarsButtonWithdrawShortUntil" = "提现"; +"BotStarsButtonWithdrawUntil" = "通过 Fragment 提现"; +"BotStarsChartRevenue" = "收到的星星"; +"BotStarsOverview" = "收益概览"; +"BotStarsOverviewAvailableBalance" = "可用余额"; +"BotStarsOverviewInfo" = "总余额中的星星可用于广告支出,或在获得星星 21 天提取奖励。"; +"BotStarsOverviewTotalBalance" = "总余额"; +"BotStarsOverviewTotalProceeds" = "累计收益总额"; +"BotStarsWithdrawInfo" = "您可以使用 Fragment 领取星星奖励,或使用星星来宣传您的机器人,每次提取金额不得少于 1000 颗星星。**了解更多 >**"; +"BotStarsWithdrawInfoLink" = "https://telegram.org/privacy"; +"BotStarsWithdrawMinLimit#other" = "星星不足 **%d 颗**,无法提取。"; +"BotStarsWithdrawPlaceholder" = "输入提款金额"; +"BotStarsWithdrawalToast" = "**%s** 后可以进行下次提现。"; +"BotStart" = "开始"; +"BotStart2" = "启动机器人"; +"BotStartAppPermission" = "您是否想从机器人 **%2$s** 启动应用 **%1$s**?\n\n它将能够访问您的 **IP 地址**和基本设备信息。"; +"BotStartButton" = "开始机器人"; +"BotStatsStars" = "星星"; +"BotStatsTON" = "Gram 币"; +"BotStatusCantRead" = "无权读取消息"; +"BotStatusRead" = "可读取消息"; +"BotStop" = "屏蔽机器人"; +"BotUnblock" = "重新启用"; +"BotUnblockNoCaps" = "重启"; +"BotUsernameHelp" = "此链接不可编辑,您可以在 *Fragment* 上获取更多用户名。"; +"BotUsernamesHelp" = "拖拽以调整机器人信息页中链接显示的顺序。"; +"BotUsers#other" = "%d 名每月活跃用户"; +"BotUsersShort#other" = "%d 位用户"; +"BotVerifierRepresentatives" = "此社区已由 %s 的代表验证为官方社区。"; +"BotVerifyAccounts" = "验证账号"; +"BotVerifyBotTitle" = "验证机器人"; +"BotVerifyChannelTitle" = "验证频道"; +"BotVerifyDescription" = "描写"; +"BotVerifyDescriptionInfo" = "您可以为每个账户自定义描述。"; +"BotVerifyDescriptionInfoChat" = "您可以为每次聊天自定义描述。"; +"BotVerifyGroupTitle" = "验证群组"; +"BotVerifyText" = "您想用验证标记和描述来验证 **%s** 吗?"; +"BotVerifyUserTitle" = "验证用户"; +"BotWebAppDisclaimerCheck" = "我同意 **使用条款**"; +"BotWebAppDisclaimerSubtitle" = "您即将使用一个 **不属于 Telegram** 的独立第三方小程序。您必须同意小程序使用条款后才能继续。"; +"BotWebAppInstantViewOpen" = "打开应用"; +"BotWebViewChangesMayNotBeSaved" = "所作的更改可能不会保存。"; +"BotWebViewCloseAnyway" = "仍然关闭"; +"BotWebViewDeleteBot" = "从菜单中移除"; +"BotWebViewFileChooserTitle" = "选择一个文件"; +"BotWebViewNotAvailablePlaceholder" = "WebView 不可用。如果要使用 WebView 机器人,请更新 WebView。"; +"BotWebViewOpenBot" = "打开机器人"; +"BotWebViewReloadPage" = "重新加载页面"; +"BotWebViewRequestAllow" = "允许"; +"BotWebViewRequestCameraMicPermission" = "是否允许 **%1$s** 访问您的摄像头和麦克风?\n\n此网页应用打开时,**%1$s** 的开发者将能够访问您的摄像头和麦克风。"; +"BotWebViewRequestCameraMicPermissionWithHint" = "是否允许 **%1$s** 访问您的摄像头和麦克风?\n\n此网页应用打开时,**%1$s** 的开发者将能够访问您的摄像头和麦克风。\n\n在“设置”>“权限”>“相机”中启用相机访问权限。"; +"BotWebViewRequestCameraPermission" = "允许 **%1$s** 访问您的相机?\n\n**%1$s** 的开发者将能够在此网页应用打开时访问你的相机。"; +"BotWebViewRequestCameraPermissionWithHint" = "允许 **%1$s** 访问你的摄像头吗?\n\n**%1$s** 的开发者将能够在此网页应用打开时访问到你的摄像头。\n\n请在设置 > 权限 > 相机中启用对相机的访问。"; +"BotWebViewRequestDontAllow" = "不允许"; +"BotWebViewRequestGeolocationPermission" = "允许 **%1$s** 访问你的位置?\n\n**%1$s** 的开发者将能够在此网页应用打开时访问你的位置。"; +"BotWebViewRequestGeolocationPermissionWithHint" = "是否允许 **%1$s** 访问您的位置吗?\n\n当该网页应用打开时,**%1$s** 的开发者将有权访问到您的位置。\n\n请前往 设置 > 权限 将 **位置** 选项打开,以共享位置数据。"; +"BotWebViewRequestMicrophonePermission" = "是否允许 **%1$s** 访问您的麦克风?\n\n当此网页应用打开时,**%1$s** 的开发者将能够访问您的麦克风。"; +"BotWebViewRequestMicrophonePermissionWithHint" = "是否允许 **%1$s** 访问您的麦克风?\n\n打开此网页应用程序时, **%1$s** 的开发人员将能够访问您的麦克风。\n\n在设置 > 权限 > 麦克风中启用麦克风访问。"; +"BotWebViewRequestWriteMessage" = "允许此机器人向您发送消息?"; +"BotWebViewRequestWriteTitle" = "允许写入"; +"BotWebViewSettings" = "设置"; +"BotWebViewStartPermission" = "您将连接到该机器人的网站以启动其 Web 应用。"; +"BotWebViewStartPermission2" = "此迷你应用即表示您同意 **小程序服务条款**。"; +"BotWebViewStartPermissionLink" = "https://telegram.org/tos/mini-apps"; +"BotWebViewToS" = "使用条款"; +"BotWebViewToSLink" = "https://telegram.org/tos/mini-apps"; +"Bots#other" = "%1$d 个机器人"; +"BotsMenuTitle" = "菜单"; +"BoughtResoldGiftText" = "%s 现在属于你了。"; +"BoughtResoldGiftTitle" = "已获得礼物"; +"BoughtResoldGiftToText" = "%s 已收到关于您礼物的通知。"; +"BoughtResoldGiftToTitle" = "已送出礼物"; +"BroadcastGroup" = "广播群组"; +"BroadcastGroupConvert" = "转换为广播组"; +"BroadcastGroupConvertInfo" = "广播组可以拥有超过200,000个成员,但仅管理员可发消息。"; +"BroadcastGroupConvertSuccess" = "您的群组现在可以拥有超过20万个成员了。"; +"BroadcastGroupInfo" = "只有管理员才能在此群组中发送消息。"; +"Brown" = "棕色"; +"BrowseThemes" = "浏览主题"; +"BrowserAlwaysOpenExternal" = "始终在浏览器中打开此网站"; +"BrowserAlwaysOpenInApp" = "始终在应用内打开此网站"; +"BrowserExternalCheck" = "始终在浏览器中打开来自 **%s** 的链接"; +"BrowserExternalRestricted" = "Telegram 现在将在外部浏览器中打开来自此网站的链接,您可以在 **设置** 中进行更改。"; +"BrowserExternalText" = "您是否要用默认浏览器打开此链接?"; +"BrowserExternalTitle" = "在浏览器中打开"; +"BrowserSettingsAddHint" = "输入网址"; +"BrowserSettingsAddText" = "输入您不希望在应用内浏览器中打开的域名。"; +"BrowserSettingsAddTextExternal" = "输入要在应用内浏览器中打开的域名。"; +"BrowserSettingsAddTitle" = "添加网站"; +"BrowserSettingsAddTitleExternal" = "添加网站"; +"BrowserSettingsAlwaysOpenInInfo2" = "此网站仍将在应用内打开。"; +"BrowserSettingsAlwaysOpenInTitle2" = "在应用内打开"; +"BrowserSettingsCacheClear" = "清空缓存"; +"BrowserSettingsCacheClearText" = "您确定要清除浏览器缓存%s吗?"; +"BrowserSettingsCookiesClear" = "清除 Cookies"; +"BrowserSettingsCookiesClearText" = "您确定要清除浏览器 Cookies%s 吗?\n\n这可能会让您登出某些网站。"; +"BrowserSettingsCookiesInfo" = "从 Telegram 应用内浏览器和小程序删除全部 Cookie 和缓存。"; +"BrowserSettingsCustomTabs" = "在 Telegram 上打开新页面"; +"BrowserSettingsCustomTabsTitle" = "打开外部链接时"; +"BrowserSettingsEnable" = "启用应用内浏览器"; +"BrowserSettingsEnableInfo" = "无需使用外部浏览器应用 Telegram 内打开链接。"; +"BrowserSettingsHistoryClear" = "清除历史记录"; +"BrowserSettingsHistoryClearText" = "您确定要清除自 %s 以来的所有浏览器记录吗?"; +"BrowserSettingsHistoryPages#other" = "%d 页"; +"BrowserSettingsHistoryShow" = "查看记录"; +"BrowserSettingsNeverOpenInAdd" = "添加网站"; +"BrowserSettingsNeverOpenInClearList" = "清除清单"; +"BrowserSettingsNeverOpenInClearList2" = "删除全部例外"; +"BrowserSettingsNeverOpenInInfo" = "Telegram 将始终在外部浏览器中打开这些网站的链接。"; +"BrowserSettingsNeverOpenInInfo2" = "此网站将始终在外部浏览器中打开。"; +"BrowserSettingsNeverOpenInRemove" = "移除"; +"BrowserSettingsNeverOpenInTitle" = "从不在应用内浏览器中打开"; +"BrowserSettingsNeverOpenInTitle2" = "不在应用内打开"; +"BrowserSettingsNoCustomTabs" = "在外部浏览器打开新标签页"; +"BrowserSettingsNoCustomTabsInfo" = "您可以选择在不离开 Telegram 的情况下打开外部链接。"; +"BrowserSettingsSearchEngine" = "搜索引擎"; +"BrowserSettingsSearchEngineInfo" = "选择用于地址栏中的搜索和自动完成的服务。"; +"BrowserSettingsTitle" = "浏览器"; +"BubbleRadius" = "消息圆角"; +"BuildMyOwnTheme" = "制作个性化主题"; +"BuiltInThemes" = "内置主题"; +"BusinessAway" = "离开消息"; +"BusinessAwayCreate" = "创建离开消息"; +"BusinessAwayEnter" = "添加离开消息"; +"BusinessAwayInfo" = "当您离开时将自动回复消息。"; +"BusinessAwayIntro" = "添加在您离线时自动发送的消息。"; +"BusinessAwayIntroTitle" = "新的离开消息"; +"BusinessAwayOnlyOffline" = "仅离线"; +"BusinessAwayOnlyOfflineInfo" = "当您最近在线时不发送离开消息。"; +"BusinessAwaySchedule" = "时间表"; +"BusinessAwayScheduleAlways" = "总是发送"; +"BusinessAwayScheduleCustom" = "自定义时间表"; +"BusinessAwayScheduleCustomEnd" = "结束时间"; +"BusinessAwayScheduleCustomEndTitle" = "选择结束时间"; +"BusinessAwayScheduleCustomSetButton" = "选择"; +"BusinessAwayScheduleCustomStart" = "开始时间"; +"BusinessAwayScheduleCustomStartTitle" = "选择开始时间"; +"BusinessAwayScheduleOutsideHours" = "不在营业时间"; +"BusinessAwaySend" = "发送离开消息"; +"BusinessAwayUnsavedChanges" = "您已更改企业离开留言设置,是否应用更改?"; +"BusinessBotChats" = "机器人可访问的聊天"; +"BusinessBotChats2" = "机器人可以访问的聊天"; +"BusinessBotChatsInfo" = "选择机器人将有权访问的聊天或整个聊天类型。"; +"BusinessBotChatsInfo2" = "选择机器人不会访问的聊天。"; +"BusinessBotDone" = "%s 现正管理您的账号。"; +"BusinessBotLink" = "机器人用户名或链接"; +"BusinessBotLinkInfo" = "输入您希望自动处理聊天的机器人链接。"; +"BusinessBotLinkInfo2" = "选择一个机器人来自动管理您的聊天。"; +"BusinessBotNoAddedButton" = "退出"; +"BusinessBotNoAddedText" = "您还没新增机器人来管理您的账号。仍然退出?"; +"BusinessBotNoAddedTitle" = "未添加机器人"; +"BusinessBotNotFound" = "未找到聊天机器人"; +"BusinessBotNotSupportedMessage" = "此机器人暂不支持 **Telegram 企业版**。"; +"BusinessBotNotSupportedTitle" = "哎呀"; +"BusinessBotPermissions" = "机器人权限"; +"BusinessBotPermissionsGiftsSection" = "管理礼物或星星"; +"BusinessBotPermissionsGiftsSell" = "将礼物兑换成星星"; +"BusinessBotPermissionsGiftsSettings" = "更改礼物设置"; +"BusinessBotPermissionsGiftsTransfer" = "转让和升级礼物"; +"BusinessBotPermissionsGiftsTransferStars" = "转让星星"; +"BusinessBotPermissionsGiftsView" = "查看礼物和星星"; +"BusinessBotPermissionsGiftsWarningText" = "机器人**@%s** 将有权管理您的礼物和星星,包括将它们赠送给其他用户。"; +"BusinessBotPermissionsInfo" = "机器人只能代替您回复最近 24 小时内活跃的聊天。"; +"BusinessBotPermissionsMessagesDeleteReceived" = "删除已收到的消息"; +"BusinessBotPermissionsMessagesDeleteSent" = "删除已发送的消息"; +"BusinessBotPermissionsMessagesMarkAsRead" = "标记消息为已读"; +"BusinessBotPermissionsMessagesRead" = "阅读消息"; +"BusinessBotPermissionsMessagesReply" = "回复消息"; +"BusinessBotPermissionsMessagesSection" = "管理消息"; +"BusinessBotPermissionsProfileBio" = "编辑个人简介"; +"BusinessBotPermissionsProfileName" = "编辑名称"; +"BusinessBotPermissionsProfilePicture" = "编辑头像"; +"BusinessBotPermissionsProfileSection" = "管理个人资料页"; +"BusinessBotPermissionsProfileUsername" = "编辑用户名"; +"BusinessBotPermissionsReply" = "回复消息"; +"BusinessBotPermissionsStories" = "管理动态"; +"BusinessBotPermissionsUsernamesWarningText" = "机器人**@%s** 将有权管理您的用户名,包括将其转让給其他用户。"; +"BusinessBotPermissionsWarning" = "警告"; +"BusinessBotRemove" = "移除机器人"; +"BusinessBotUnsavedChanges" = "您已更改企业机器人设置,是否应用更改?"; +"BusinessBotUpdate" = "更新"; +"BusinessBotUpdated" = "已更新 %s 设置。"; +"BusinessBots" = "自动聊天"; +"BusinessBots2" = "聊天自动化"; +"BusinessBots2Info" = "添加机器人代您回复消息。"; +"BusinessBotsInfo" = "为您的帐号添加机器人,帮助您自动处理和回复收到的消息。"; +"BusinessBotsInfoLink" = "https://telegram.org/blog/telegram-business#chatbots-for-business"; +"BusinessChatsAllPrivateExcept" = "所有私聊,除……"; +"BusinessChatsAllPrivateExcept2" = "所有私聊,除了..."; +"BusinessChatsExcluded" = "不包含的聊天"; +"BusinessChatsExcludedAdd" = "不包含的聊天"; +"BusinessChatsExcludedAdd2" = "添加"; +"BusinessChatsIncluded" = "包含的聊天"; +"BusinessChatsIncludedAdd" = "选择聊天"; +"BusinessChatsIncludedAdd2" = "添加"; +"BusinessChatsOnlySelected" = "仅选出的聊天"; +"BusinessChatsOnlySelected2" = "仅所选的聊天"; +"BusinessGreet" = "问候消息"; +"BusinessGreetCreate" = "创建问候消息"; +"BusinessGreetInfo" = "向首次向你发消息或一段时间没有活跃的客户发送问候。"; +"BusinessGreetPeriod" = "不活跃的时长"; +"BusinessGreetPeriodInfo" = "选择自您上次与对方互动多少天后,将给对方的发送问候消息作为回复。"; +"BusinessGreetRecipientsInfo" = "选择单个聊天或整个聊天分类发送问候消息。"; +"BusinessGreetSend" = "发送问候消息"; +"BusinessGreetUnsavedChanges" = "您已修改了企业问候消息的设置。要应用这些更改吗?"; +"BusinessGreetingEnter" = "添加问候语"; +"BusinessGreetingIntro" = "创建将自动发送给新用户的祝福语。"; +"BusinessGreetingIntroTitle" = "新问候消息"; +"BusinessHours" = "营业时间"; +"BusinessHoursClearMessage" = "您确定要移除营业时间吗?"; +"BusinessHoursClearTitle" = "移除时间"; +"BusinessHoursCopied" = "已复制营业时间"; +"BusinessHoursCopyFooter" = "在时区 %s"; +"BusinessHoursCopyHeader" = "%s 的营业时间:"; +"BusinessHoursDayAdd" = "添加一组时间"; +"BusinessHoursDayCloseHour" = "关闭时间"; +"BusinessHoursDayCloseHourPicker" = "设置结束营业时间"; +"BusinessHoursDayClosed" = "休息"; +"BusinessHoursDayFullOpened" = "24 小时营业"; +"BusinessHoursDayInfo" = "设置您一天中的工作时间。"; +"BusinessHoursDayOpen" = "当天营业"; +"BusinessHoursDayOpenHour" = "营业时间"; +"BusinessHoursDayOpenHourPicker" = "设定营业时间"; +"BusinessHoursInfo" = "启用此功能可以让您的客户了解您的营业时间安排。"; +"BusinessHoursNextDay" = "%s (明天)"; +"BusinessHoursNextDayPicker" = "明天, %s"; +"BusinessHoursProfile" = "营业时间"; +"BusinessHoursProfileClose" = "休息"; +"BusinessHoursProfileFullOpen" = "7 × 24 小时营业"; +"BusinessHoursProfileInYourTime" = "本地时间 %s"; +"BusinessHoursProfileNowClosed" = "已关闭"; +"BusinessHoursProfileNowOpen" = "营业中"; +"BusinessHoursProfileOpen" = "24 小时营业"; +"BusinessHoursProfileOpensInDays#other" = "%d 天后营业 "; +"BusinessHoursProfileOpensInHours#other" = "%d 小时后营业"; +"BusinessHoursProfileOpensInMinutes#other" = "%d 分钟后营业"; +"BusinessHoursProfileSwitchLocal" = "当地时间"; +"BusinessHoursProfileSwitchMy" = "你本地的时间"; +"BusinessHoursProfileTheirTime" = "现在那里是 %s"; +"BusinessHoursShow" = "显示营业时间"; +"BusinessHoursTimezone" = "时区"; +"BusinessHoursTimezonePicker" = "选择时区"; +"BusinessHoursUnsavedChanges" = "您已更改企业营业时间,是否应用更改?"; +"BusinessInReplies" = "在 %1$s"; +"BusinessIntro" = "起始页"; +"BusinessIntroHeader" = "自定义您的商家主页"; +"BusinessIntroInfo" = "你可以自定他人与你聊天前看到的消息。"; +"BusinessIntroMessageHint" = "输入消息"; +"BusinessIntroReset" = "重置为默认"; +"BusinessIntroSticker" = "选择贴纸"; +"BusinessIntroStickerRandom" = "随机"; +"BusinessIntroTitleHint" = "输入标题"; +"BusinessIntroUnsavedChanges" = "你已修改你的主页,是否应用?"; +"BusinessLink" = "聊天链接"; +"BusinessLinkDeleted" = "聊天链接已删除。"; +"BusinessLinkDiscardChangesMessage" = "您对设置消息有未保存更改,是否要放弃?"; +"BusinessLinkDiscardChangesTitle" = "放弃更改"; +"BusinessLinkInvalid" = "无效聊天链接"; +"BusinessLinkSaved" = "已保存预设消息。"; +"BusinessLinkShareTitle" = "分享聊天链接"; +"BusinessLinks" = "聊天链接"; +"BusinessLinksAdd" = "创建聊天链接"; +"BusinessLinksDeleteMessage" = "你确定要删除此聊天链接吗?"; +"BusinessLinksDeleteTitle" = "删除聊天链接"; +"BusinessLinksEnter" = "添加预设消息"; +"BusinessLinksFooterOneLink" = "也可以使用短链接来与你发起聊天 — %1$s."; +"BusinessLinksFooterTwoLinks" = "您也可以使用一个简单的链接来开始与您聊天 — %1$s 或 %2$s。"; +"BusinessLinksInfo" = "为您的客户提供可以开始与您聊天的短链接,并为其推荐与您聊天的第一条消息。"; +"BusinessLinksIntro" = "添加一条消息,所有通过以下链接与您开始聊天的人都将在消息输入框看到此消息:"; +"BusinessLinksNamePlaceholder" = "链接名称"; +"BusinessLinksRenameMessage" = "为此链接添加一个只有您才能看到的名称。"; +"BusinessLinksRenameTitle" = "链接名称"; +"BusinessLocation" = "位置"; +"BusinessLocationAddress" = "输入地址"; +"BusinessLocationClear" = "移除位置信息"; +"BusinessLocationClearMessage" = "您确定要移除企业位置信息吗?"; +"BusinessLocationClearTitle" = "移除位置信息"; +"BusinessLocationCopied" = "已复制企业地址"; +"BusinessLocationInfo" = "在您的帐号上显示企业位置。"; +"BusinessLocationMap" = "在地图上标记位置"; +"BusinessLocationUnsavedChanges" = "您已更改企业位置信息,是否应用更改?"; +"BusinessProfileLocation" = "位置"; +"BusinessRecipients" = "接收者"; +"BusinessRecipientsRemoveExcludeMessage" = "您确定要从排除列表中移除 “%s” 吗?"; +"BusinessRecipientsRemoveExcludeTitle" = "移除收件人"; +"BusinessRecipientsRemoveIncludeMessage" = "您确定要从包含列表中移除 “%s” 吗?"; +"BusinessRecipientsRemoveIncludeTitle" = "移除收件人"; +"BusinessReplies" = "快捷回复"; +"BusinessRepliesAdd" = "添加快速回复"; +"BusinessRepliesAddInfo" = "长按回复以选择或重新排序。"; +"BusinessRepliesDeleteMessage#other" = "您确定要删除 %d 条快速回复吗?"; +"BusinessRepliesDeleteTitle#other" = "移除快速回复"; +"BusinessRepliesEditMessage" = "编辑此指令的名称"; +"BusinessRepliesEditTitle" = "编辑指令"; +"BusinessRepliesEnter" = "添加快速回复"; +"BusinessRepliesHint" = "如要在聊天中使用此快捷回复,请输入 / 并从列表中选择快捷方式。"; +"BusinessRepliesInfo" = "设置包含富文本和媒体的快捷方式,更快回复消息。"; +"BusinessRepliesIntro1" = "请在下面输入一条消息,输入\"/**%s**\"后,将在聊天中发送该消息。"; +"BusinessRepliesIntro2" = "您可以通过键入 “/” 或使用 “附件” 菜单在任何聊天中访问快速回复。"; +"BusinessRepliesIntroTitle" = "新建快速回复"; +"BusinessRepliesLimit#other" = "已达 **%d** 条消息上限。"; +"BusinessRepliesMore#other" = "其他 +%d 个"; +"BusinessRepliesNameBusy" = "该指令已存在。"; +"BusinessRepliesNamePlaceholder" = "指令"; +"BusinessRepliesNameReserved" = "此快捷方式名称已被保留。"; +"BusinessRepliesNewMessage" = "为您快速回复添加快捷方式"; +"BusinessRepliesNewTitle" = "新建快速回复"; +"BusinessRepliesRemoveMessage" = "您尚未创建快速回复信息。是否退出本页面并移除快捷方式?"; +"BusinessRepliesRemoveTitle" = "移除快捷键"; +"BusinessRepliesSearch" = "搜索回复"; +"BusinessRepliesToastLimit#other" = "已达到 **%d** 条消息上限。"; +"BuyUsernameOnFragment" = "在 Fragment 上购买"; +"BytesReceived" = "已接收流量"; +"BytesSent" = "已发送流量"; +"CacheChannels" = "频道"; +"CacheClear" = "清理"; +"CacheEmpty" = "已清空"; +"CacheOpenFile" = "打开文件"; +"CacheOtherChats" = "其他聊天"; +"CacheStories" = "动态"; +"CacheWasCleared" = "已释放了 %s"; +"CachedStory" = "缓存的动态"; +"CalculatingSize" = "正在计算…"; +"Calendar" = "按日期排序"; +"CalendarWeekNameShortFriday" = "五"; +"CalendarWeekNameShortMonday" = "一"; +"CalendarWeekNameShortSaturday" = "六"; +"CalendarWeekNameShortSunday" = "日"; +"CalendarWeekNameShortThursday" = "四"; +"CalendarWeekNameShortTuesday" = "二"; +"CalendarWeekNameShortWednesday" = "三"; +"Call" = "呼叫"; +"CallAgain" = "再次呼叫"; +"CallAlert" = "您确定要呼叫 %1$s 吗?"; +"CallAlertTitle" = "语音通话"; +"CallAvailableIn" = "您可以在 %1$d:%2$02d 之后重新获取语音验证码"; +"CallAvailableIn2" = "Telegram 可以在 %1$d:%2$02d 后给您拨打电话"; +"CallBack" = "回拨"; +"CallEmojiKeyTooltip" = "如果 %1$s 屏幕上的表情和此处一致,那么本次通话就是 100%% 安全的。"; +"CallForbiddenInviteLinkButton" = "发送邀请链接"; +"CallForbiddenInviteLinkText" = "**%s** 已限制可以打电话给他们的用户。请改为向他们发送通话邀请链接。"; +"CallForbiddenInviteLinkTitle" = "通过链接发送"; +"CallInviteMembersButton" = "通话"; +"CallInviteViaLink" = "添加到通话"; +"CallInviteViaLinkTitle" = "通过链接邀请"; +"CallMessageIncoming" = "来电"; +"CallMessageIncomingConferenceMissed" = "未接来电"; +"CallMessageIncomingDeclined" = "拒接"; +"CallMessageIncomingMissed" = "未接"; +"CallMessageOutgoing" = "拨出通话"; +"CallMessageOutgoingMissed" = "通话已取消"; +"CallMessageReportProblem" = "评价通话"; +"CallMessageVideoIncoming" = "视频来电"; +"CallMessageVideoIncomingConferenceMissed" = "错过的视频通话"; +"CallMessageVideoIncomingDeclined" = "拒绝视频通话"; +"CallMessageVideoIncomingMissed" = "未接视频来电"; +"CallMessageVideoOutgoing" = "拨出视频通话"; +"CallMessageVideoOutgoingMissed" = "已取消的视频通话"; +"CallMessageWithDuration" = "%1$s(%2$s)"; +"CallNotAvailable" = "抱歉,由于其隐私设置,您无法呼叫 **%1$s**。您可以让其修改设置或呼叫您。"; +"CallReportHint" = "出了什么问题?"; +"CallReportIncludeLogs" = "附送技术信息"; +"CallReportLogsExplain" = "这不会泄露您的聊天内容,但却能帮助我们尽快修复问题。"; +"CallReportSent" = "感谢您帮助 Telegram 通话变得更好。"; +"CallText" = "Telegram 会在 %1$d:%2$02d 后使用电话呼叫您播放验证码"; +"CallViaTelegram" = "Telegram 通话"; +"CallVideoPin" = "置顶"; +"CallVideoPreviewTitle" = "预览"; +"CallVideoUnpin" = "取消置顶"; +"Calling" = "正在呼叫您…"; +"Calls" = "通话"; +"CallsDataUsage" = "通话"; +"CallsTotalTime" = "总时间"; +"CameraPermissionText" = "点击以允许访问相机"; +"CanJoin#other" = "可加入 %1$d 人"; +"Cancel" = "取消"; +"CancelAccountReset" = "取消账号重置请求"; +"CancelAccountResetInfo" = "有人使用你的手机号码 **%1$s**\u00a0请求删除你的 Telegram 账号并重置两步验证密码。\n\n如果这不是你本人,请输入我们刚刚通过短信发送给你的验证码。你也可以通过 **更改手机号码** 来取消此操作。"; +"CancelAccountResetInfo2" = "有人正在删除您的 **%1$s** 帐号并重设两步验证码。\n\n若不是您的操作,请输入短信验证码。您也可以通过 *修改手机号码* 来取消此操作。"; +"CancelEmailQuestion" = "您确定要放弃设置密码恢复邮箱吗?"; +"CancelEmailQuestionTitle" = "终止设置"; +"CancelForward" = "取消转发"; +"CancelForwardChat" = "您将如何处理来自**%2$s**的%1$s?"; +"CancelForwardMessage" = "您想取消转发还是把消息转发到其他聊天?"; +"CancelForwardPrivate" = "您想对您与 **%2$s** 聊天中的 %1$s 做什么?"; +"CancelForwarding" = "取消转发"; +"CancelLinkExpired" = "链接无效或已过期。"; +"CancelLinkSuccess" = "删除帐号(%1$s)的请求已被取消。您现在可以关闭此窗口。"; +"CancelLinkSuccessTitle" = "成功!"; +"CancelPasswordQuestion" = "您确定要放弃设置两步验证吗?"; +"CancelPasswordReset" = "是否取消重置密码?若要再次申请密码重置,需要再等 7 天。"; +"CancelPasswordResetNo" = "否"; +"CancelPasswordResetYes" = "是"; +"CancelPollAlertText" = "您确定要放弃此次投票吗?"; +"CancelPollAlertTitle" = "放弃投票吗?"; +"CancelRegistration" = "取消注册"; +"CancelReset" = "取消重置"; +"CancelRound" = "取消"; +"CancelSending" = "取消发送"; +"CancelTodoAlertText" = "您确定要放弃此核对清单吗?"; +"CancelTodoAlertTitle" = "放弃核对清单"; +"CantAddBotAsAdmin" = "机器人只能作为管理员加入频道。"; +"CantBoostTooOften" = "不能频繁助推!"; +"CantBoostTooOftenDescription" = "您每天只能更改一次助推的频道。 您可以在 **%s** 后再次助推。"; +"CantBoostWithGiftedPremium" = "无法使用受赠的 Telegram 高级版助推!"; +"CantBoostWithGiftedPremiumDescription" = "由于您的 **Telegram 高级版** 是通过赠送获得的,所以您无法用其助推频道。"; +"CantEditTagAdmins" = "仅管理员可在此群组设置成员标签。"; +"CantPlayVideo" = "应用无法播放此视频,要尝试使用外部播放器播放吗?"; +"Caption" = "说明"; +"CaptionAbove" = "将说明文字上移"; +"CaptionBelow" = "将说明文字下移"; +"CaptionPremiumSubtitle#other" = "订阅 __Telegram 高级版__ 后可将此上限提高 **%1$d 倍** 至 **%2$s** 个字符。"; +"CaptionPremiumTitle" = "已达最大长度上限"; +"CaptionsLimitSubtitle" = "在图片和视频下使用更长的说明文字"; +"CaptionsLimitTitle" = "说明文字"; +"CardNumberCopied" = "卡号已复制到剪切板。"; +"Change" = "更改"; +"ChangeChannelNameColor" = "频道颜色"; +"ChangeChannelNameColor2" = "外观"; +"ChangeChatBackground" = "更改聊天壁纸"; +"ChangeColorToColor" = "您是否想要使用当前选择的壁纸,还是重置为此主题的默认壁纸?"; +"ChangeColors" = "更改背景颜色"; +"ChangeEmail" = "更改邮箱"; +"ChangeEmojiStatus" = "更改状态"; +"ChangeGroupAppearance" = "外观"; +"ChangeLanguageLater" = "您可以稍后在设置中更改语言。"; +"ChangePasscode" = "修改锁定码"; +"ChangePasscodeInfo" = "当您设置好锁定码后,聊天列表上方会出现一个锁形图标。点击它来锁定或解锁 Telegram 应用。\n\n注意:如果您忘记了锁定码,您将需要登出或重新安装应用。所有的私密聊天将会丢失。"; +"ChangePasscodeInfoShort" = "设置锁定码后,一个“锁”的图标将会出现在聊天列表顶部,点击该图标以锁定 Telegram 应用。"; +"ChangePassword" = "更改密码"; +"ChangePermissions" = "更改权限"; +"ChangePhoneHelp" = "您的新号码将通过电话或短信收到确认码。"; +"ChangePhoneNewNumber" = "新手机号码"; +"ChangePhoneNumber" = "更换手机号码"; +"ChangePhoneNumberInfo" = "将您的帐号、聊天以及媒体文件转移到新号码上。"; +"ChangePhoneNumberOccupied" = "手机号码 %1$s 已经绑定到 Telegram。迁移到此号码前,请先解除绑定账号。"; +"ChangePhoneNumberSuccess" = "你的手机号码已成功更改。"; +"ChangePhoneNumberSuccessWithPhone" = "您的手机号码已成功更改为 %1$s。"; +"ChangePublicLimitReached" = "您保留了太多公共用户名。您可以撤消其中一个较旧群组或频道的链接,也可以创建一个私人链接。"; +"ChangeRecipient" = "修改收件人"; +"ChangeRecoveryEmail" = "更改密码恢复电子邮箱"; +"ChangeUserNameColor" = "更改名称颜色"; +"ChangeWallpaperToColor" = "您确定将聊天壁纸换成纯色吗?"; +"ChannelAddAdmin" = "添加管理员"; +"ChannelAddException" = "添加例外"; +"ChannelAddMembers" = "添加成员"; +"ChannelAddSubscribers" = "添加订阅者"; +"ChannelAddTo" = "是否将 %1$s 添加到此频道?"; +"ChannelAddToChannel" = "添加到频道"; +"ChannelAddToGroup" = "添加到群组"; +"ChannelAddedBy" = "un1 已将您加入此频道"; +"ChannelAddedByNotification" = "%1$s 已将您加入 %2$s 频道"; +"ChannelAdmin" = "管理员"; +"ChannelAdminSettings" = "频道设置"; +"ChannelAdministrator" = "管理员"; +"ChannelAdministrators" = "管理员"; +"ChannelAdmins" = "频道管理员"; +"ChannelAdminsInfo" = "您可以添加管理员帮助您管理频道。长按可移除管理员。"; +"ChannelAffiliateProgramFeature1" = "你推荐的用户消费中获得确保的佣金。"; +"ChannelAffiliateProgramFeature1Title" = "可靠"; +"ChannelAffiliateProgramFeature2" = "实时追踪您从推荐用户处获得的佣金。"; +"ChannelAffiliateProgramFeature2Title" = "透明"; +"ChannelAffiliateProgramFeature3" = "从下方选择小程序,获取您的推荐链接,开始赚取星星。"; +"ChannelAffiliateProgramFeature3Title" = "简单"; +"ChannelAffiliateProgramJoinButton" = "加入计划"; +"ChannelAffiliateProgramJoinButtonInfo" = "加入此计划即表示您同意联盟计划的**条款和条件**。"; +"ChannelAffiliateProgramJoinButtonInfoLink" = "https://telegram.org/tos/affiliate-program"; +"ChannelAffiliateProgramJoinDailyRevenue" = "应用的每用户\n日均收入"; +"ChannelAffiliateProgramJoinMonthlyUsers" = "月活跃用户"; +"ChannelAffiliateProgramJoinRevenue" = "应用中每位用户的日收入:⭐️ %s"; +"ChannelAffiliateProgramJoinText" = "**%1$s** 将在 **%3$s** 内与您分享每位您推荐的用户所产生收入的 **%2$s**。"; +"ChannelAffiliateProgramJoinText_Lifetime" = "终身"; +"ChannelAffiliateProgramJoinText_Months#other" = "%d 个月"; +"ChannelAffiliateProgramJoinText_Years#other" = "%d 年"; +"ChannelAffiliateProgramJoinTitle" = "联盟计划"; +"ChannelAffiliateProgramJoinViewBot" = "查看 %s"; +"ChannelAffiliateProgramLinkCopy" = "复制链接"; +"ChannelAffiliateProgramLinkOpened#other" = "%1$d 位用户已通过此链接打开了 %2$s。"; +"ChannelAffiliateProgramLinkOpenedNone" = "尚未有人通过此链接打开 %s。"; +"ChannelAffiliateProgramLinkRejoin" = "重新加入计划"; +"ChannelAffiliateProgramLinkSendTo" = "佣金将会发送至:"; +"ChannelAffiliateProgramLinkTextChannel" = "将此链接分享给您的订阅者,在 **%3$s** 内您可获得其在 **%2$s** 消费金额 **%1$s** 的佣金。"; +"ChannelAffiliateProgramLinkTextRevoked" = "您已撤销此推荐链接。"; +"ChannelAffiliateProgramLinkTextUser" = "分享此链接给好友,在 **%3$s** 内您可获得其在 **%2$s** 消费金额的 **%1$s** 佣金。"; +"ChannelAffiliateProgramLinkTitle" = "推荐链接"; +"ChannelAffiliateProgramMyPrograms" = "我的计划"; +"ChannelAffiliateProgramPrograms" = "计划"; +"ChannelAffiliateProgramProgramsSort" = "排序方式"; +"ChannelAffiliateProgramProgramsSortDate" = "日期"; +"ChannelAffiliateProgramProgramsSortProfitability" = "利润率"; +"ChannelAffiliateProgramProgramsSortRevenue" = "收入"; +"ChannelAffiliateProgramRowText" = "分享小程序链接,赚取星星分成收益。"; +"ChannelAffiliateProgramRowTitle" = "赚取星星"; +"ChannelAffiliateProgramText" = "每次用户通过您的推荐链接首次访问小程序并在其中消费 **星星** 时,您都会获得佣金。"; +"ChannelAffiliateProgramTitle" = "联盟计划"; +"ChannelAffiliatePrograms" = "联盟计划"; +"ChannelAlertCreate" = "创建频道"; +"ChannelAlertCreate2" = "创建频道"; +"ChannelAlertText" = "频道是一种能够将您的消息广播给无数关注者的工具。"; +"ChannelAlertTitle" = "频道是什么?"; +"ChannelAntiSpam" = "积极的反垃圾信息策略"; +"ChannelAntiSpamFalsePositiveReported" = "Telegram 管理员会检查你的报告,感谢!"; +"ChannelAntiSpamForbidden#other" = "只有在超过 **%1$d 名成员** 群组中才能启用主动筛选。"; +"ChannelAntiSpamInfo" = "Telegram 会过滤更多的垃圾信息,但偶尔可能会影响普通消息。您可以在最近的操作中报告误报。"; +"ChannelAntiSpamInfo2" = "您可以在 %s 中管理反垃圾设置。"; +"ChannelAntiSpamUser" = "Telegram 反垃圾"; +"ChannelAppearanceUpdated" = "频道外观已更新"; +"ChannelAutotranslation" = "自动翻译消息"; +"ChannelBackgroundHint" = "所有订阅者都将看到此壁纸"; +"ChannelBackgroundMessagePreview" = "详情稍后公布。\n敬请期待!"; +"ChannelBackgroundMessageReplyName" = "Du Rove 的频道"; +"ChannelBackgroundMessageReplyText" = "突发新闻"; +"ChannelBlacklist" = "被移除的用户"; +"ChannelBlockUser" = "移除用户"; +"ChannelBlockedUsers" = "被移除的用户"; +"ChannelBoostsJustReachedLevel1" = "此频道已达到 **等级 1**,现在可以发布动态了。"; +"ChannelBoostsJustReachedLevelNext" = "此频道已达到 **等级\u00a0%1$d**,现在每天可发布 %2$s。"; +"ChannelBots" = "机器人"; +"ChannelBroadcast" = "广播"; +"ChannelCantOpenBanned" = "很遗憾,您已被禁止参与公开群组。"; +"ChannelCantOpenBannedByAdmin" = "抱歉,由于您被管理员封禁,该聊天已无法访问。"; +"ChannelCantOpenBannedByAdminTitle" = "不能访问该聊天"; +"ChannelCantOpenNa" = "抱歉,此聊天似乎不存在。"; +"ChannelCantOpenPrivate" = "抱歉,此聊天已无法访问。"; +"ChannelCantOpenPrivate2" = "此频道不可用。"; +"ChannelCantSendMessage" = "抱歉,您无法发送消息到此频道。"; +"ChannelCaptionLimitPremiumPromo#other" = "您可以缩短说明文字,或者订阅 **Telegram 高级版**,将此上限翻倍至 **%1$s** 个字符。"; +"ChannelColorApplied" = "您频道颜色已更新!"; +"ChannelColorApply" = "应用颜色与图标"; +"ChannelColorHint" = "您可以选择一种颜色为您的频道名称、发送的链接以及频道贴文的回复着色。"; +"ChannelColorPreview" = "频道名称及回复消息将以所选的颜色显示"; +"ChannelColorPreviewLinkDescription" = "该颜色也会套用于您发送的链接预览。"; +"ChannelColorPreviewLinkTitle" = "链接预览"; +"ChannelColorPreviewReply" = "回复您频道信息"; +"ChannelColorPreviewTitle" = "颜色预览"; +"ChannelColorTabName" = "名字"; +"ChannelColorTabProfile" = "个人资料"; +"ChannelColorTitle" = "您频道颜色"; +"ChannelColorTitle2" = "外观"; +"ChannelColorUnsaved" = "未保存的更改"; +"ChannelColorUnsavedMessage" = "您已更改颜色或图标设置,是否应用更改?"; +"ChannelContacts" = "此频道中的联系人"; +"ChannelCreator" = "所有者"; +"ChannelDelete" = "删除频道"; +"ChannelDeleteAlert" = "删除此频道将会移除所有关注者并且会丢失所有消息。确定要删除此频道?"; +"ChannelDeleteFromList" = "从列表中移除"; +"ChannelDeleteInfo" = "您将为所有订阅者删除频道内的所有消息。"; +"ChannelDeleteMenu" = "删除频道"; +"ChannelDeletedUndo" = "频道已删除。"; +"ChannelDescriptionChanged" = "频道描述已更新。"; +"ChannelDiscuss" = "讨论"; +"ChannelEdit" = "编辑"; +"ChannelEditPermissions" = "修改权限"; +"ChannelEmojiStatus" = "频道表情符号状态"; +"ChannelEmojiStatusInfo" = "选择将显示在频道名称旁边的状态。"; +"ChannelEnablePaidReactions" = "启用付费回应"; +"ChannelEnablePaidReactionsInfo" = "打开此选项,让您的订阅者可以用 Telegram 星星对贴文作出付费回应,您之后可以以 Gram 的形式提取。"; +"ChannelEnablePaidReactionsInfoLink" = "https://telegram.org/tos/stars/"; +"ChannelHiddenMembersForbidden#other" = "只有超过 **%1$d 名成员**的群组允许隐藏成员列表。"; +"ChannelHideMembers" = "隐藏成员"; +"ChannelHideMembersInfo" = "打开此选项可隐藏该群组的成员列表。管理员将仍然可见。"; +"ChannelInviteLinkTitle" = "邀请链接"; +"ChannelInviteViaLink" = "通过链接邀请"; +"ChannelInviteViaLink2" = "添加到群组"; +"ChannelInviteViaLinkRestricted" = "您无法创建链接"; +"ChannelInviteViaLinkRestricted2" = "无法邀请此用户"; +"ChannelInviteViaLinkRestricted3" = "无法邀请下列用户"; +"ChannelJoin" = "加入"; +"ChannelJoinNoCaps" = "加入"; +"ChannelJoinRequest" = "申请加入群组"; +"ChannelJoinRequestNoCaps" = "申请加入频道"; +"ChannelJoinRequestSent" = "加入请求已发送"; +"ChannelJoinRequestSentNoCaps" = "已发送加入请求"; +"ChannelJoinTo" = "您确定要加入频道‘%1$s’吗?"; +"ChannelJoined" = "您已加入此频道"; +"ChannelLeaveAlert" = "您确定要退出此频道吗?"; +"ChannelLeaveAlertWithName" = "您确定要退出 **%1$s** 吗?"; +"ChannelLeftUndo" = "您退出了频道。"; +"ChannelLinkDirectMessages" = "私信链接"; +"ChannelLinkInfo" = "任何已安装 Telegram 的用户都可以通过此链接关注您的频道。"; +"ChannelLinkTitle" = "公开链接"; +"ChannelLinksInfo" = "您可以创建能限制时间和用户数量的附加邀请链接。"; +"ChannelLinksInfoPaid" = "你可以建立限制时间、用户数量或需要付费订阅的附加邀请链接。"; +"ChannelManageMessages" = "管理消息"; +"ChannelManageStories" = "管理动态"; +"ChannelMegaJoined" = "您已加入此群组"; +"ChannelMemberTags" = "允许成员标签"; +"ChannelMemberTagsInfo" = "启用此功能后,群组成员可以在自己的名字旁边添加尖端标签。"; +"ChannelMembers" = "成员"; +"ChannelMembersInfo" = "此列表仅频道管理员可见。"; +"ChannelMessageAlbum" = "%1$s 发布了一个相册"; +"ChannelMessageAudio" = "%1$s 已发布一条语音消息"; +"ChannelMessageContact" = "%1$s 已发布一个联系人信息"; +"ChannelMessageContact2" = "%1$s 已发布一个联系人信息 %2$s"; +"ChannelMessageDocument" = "%1$s 已发布一个文件"; +"ChannelMessageFew" = "%1$s 发送了 %2$s "; +"ChannelMessageGIF" = "%1$s 发送了一张 GIF"; +"ChannelMessageLiveLocation" = "%1$s 已发布共享实时位置"; +"ChannelMessageMap" = "%1$s 发送了一个位置"; +"ChannelMessageMusic" = "%1$s 发送了一个音频文件"; +"ChannelMessageNoText" = "%1$s 已发布一条消息"; +"ChannelMessagePhoto" = "%1$s 已发布一张图片"; +"ChannelMessagePoll" = "%1$s 发布了一项投票"; +"ChannelMessagePoll2" = "%1$s 发布了一个投票 %2$s"; +"ChannelMessageQuiz2" = "%1$s 已发布问答 “%2$s”"; +"ChannelMessageRound" = "%1$s 已发布一条视频消息"; +"ChannelMessageSticker" = "%1$s 发送了一个表情"; +"ChannelMessageStickerEmoji" = "%1$s 发送了一张 %2$s 贴纸"; +"ChannelMessageTodo2" = "%1$s 已发布核对清单 %2$s"; +"ChannelMessageTodoAppend2" = "%1$s 已添加 %2$s 个任务"; +"ChannelMessageTodoDone2" = "%1$s 已切换 %2$s 个任务"; +"ChannelMessageVideo" = "%1$s 发送了一段视频"; +"ChannelMute" = "静音"; +"ChannelMuteNoCaps" = "静音"; +"ChannelNeedBoostsAlreadyBoostedDescriptionLevel1" = "此频道需要 %s 来启用动态功能。"; +"ChannelNeedBoostsDescription" = "您的频道还需要 %s 才能发布动态。\n\n请 Telegram 高级版订阅者通过此链接来助推您的频道:"; +"ChannelNeedBoostsDescription2" = "此频道还需 %d 个助推才能发布动态。"; +"ChannelNeedBoostsDescriptionForNewFeatures" = "**%1$s** 需要 %2$s 来解锁新功能。"; +"ChannelNeedBoostsDescriptionLevel1" = "此频道需要 %s 才能启用动态。请帮帮我们!"; +"ChannelNeedBoostsDescriptionLevelNext" = "该频道还需要 %1$s 才能每天发布 %2$s。帮助实现这个目标吧!"; +"ChannelNeedBoostsDescriptionNextLevel" = "您的频道需要 %1$s 才能每天发布 %2$s。\n\n请 Telegram 高级版订阅者通过此链接来助推您的频道:"; +"ChannelNeedBoostsForAutotranslationDescription" = "您的频道需要达到 **等级 %d** 才能启用自动翻译。\n\n请您的 **高级版** 订阅者通过此链接为频道助推:"; +"ChannelNeedBoostsForColorDescription" = "您的频道需要达到 **等级 %d ** 才能更改频道颜色。请 **Telegram 高级版** 订阅者通过此链接来助推您的频道:"; +"ChannelNeedBoostsForCustomWallpaperDescription" = "您的频道需要达到 **等级 %d** 才能将频道壁纸更改为自定义图片。 **高级版** 订阅者可以通过此链接助推您的频道:"; +"ChannelNeedBoostsForEmojiStatusDescription" = "您的频道需要达到 **等级 %d** 才能设置频道表情状态。 请您的 **高级版** 订阅者通过此链接助推您的频道:"; +"ChannelNeedBoostsForProfileColorDescription" = "您的频道需要达到 **等级 %d** 才能更改为所选的频道资料页颜色。 请您的 **高级版** 订阅者通过此链接助推您的频道:"; +"ChannelNeedBoostsForProfileIconDescription" = "您的频道需要达到 **等级 %d** 才能更改频道资料页图标。 请您的 **高级版** 订阅者通过此链接助推您的频道:"; +"ChannelNeedBoostsForReplyIconDescription" = "您的频道需要达到 **等级 %d** 才能更改频道链接样式图标。 **高级版** 订阅者可以通过此链接助推您的频道:"; +"ChannelNeedBoostsForSwitchOffAdsDescription" = "您的频道需要达到 **等级 %d** 才能关闭广告。\n\n请您的 **高级版** 订阅者通过此链接助推您的频道:"; +"ChannelNeedBoostsForWallpaperDescription" = "您的频道要达到 **等级 %d** 才能修改频道壁纸。请您的 **高级版** 订阅者通过此链接助推您的频道:"; +"ChannelNeedBoostsForWearCollectiblesDescription" = "您的频道需要达到 %d 级才能佩戴收藏品,**高级版** 用户可通过此链接助推频道:"; +"ChannelNoWallpaper" = "无壁纸"; +"ChannelNotifyMembersInfoOff" = "消息发布后订阅者会收到无声通知。"; +"ChannelNotifyMembersInfoOn" = "消息发布后订阅者会收到通知。"; +"ChannelOpenDirect" = "私信"; +"ChannelOtherMembers" = "其他成员"; +"ChannelOtherSubscriberJoined" = "在 %1$s 加入"; +"ChannelOtherSubscribers" = "其他订阅者"; +"ChannelPermissions" = "权限"; +"ChannelPermissionsHeader" = "本群组成员有何权限?"; +"ChannelPhotoEditNotification" = "%1$s 更新了频道头像"; +"ChannelPostDeleted" = "抱歉,该贴文已从讨论群组中移除。"; +"ChannelPrivate" = "私密频道"; +"ChannelPrivateInfo" = "私密频道只能通过邀请链接加入。"; +"ChannelPrivateLinkHelp" = "任何人都可以通过此链接关注您的频道。您可以随时废除此链接。"; +"ChannelProfileColorApplied" = "您的频道资料页颜色已更新!"; +"ChannelProfileColorEmojiApplied" = "已将表情标志添加到您的频道资料页!"; +"ChannelProfileColorReset" = "重置个人资料页颜色"; +"ChannelProfileColorResetApplied" = "您的频道资料页颜色已重置!"; +"ChannelProfileHint" = "为您的频道简介选择颜色和图标"; +"ChannelProfileIcon" = "在个人资料页添加图标"; +"ChannelProfileInfo" = "为频道资料页选择颜色和标志。"; +"ChannelProfileLogo" = "资料页标志"; +"ChannelPublic" = "公开频道"; +"ChannelPublicEmptyUsername" = "请为您的公开频道设置一条链接,以便他人能通过搜索找到此频道并分享给他人。\n\n如果您对此不感兴趣,我们建议您创建一个私密频道。"; +"ChannelPublicEmptyUsernameTitle" = "选择链接"; +"ChannelPublicInfo" = "公开频道可以通过搜索找到,任何人都可以加入。"; +"ChannelRemoveUser" = "从频道中移除"; +"ChannelRemoveUserAdmin" = "解任管理员"; +"ChannelReplyIcon" = "为回复添加图标"; +"ChannelReplyIconHint" = "选择一个图标并为收到的回复创建自定义样式。"; +"ChannelReplyIconOff" = "关闭"; +"ChannelReplyInfo" = "为频道名称、发送的链接和回复的消息选择颜色。"; +"ChannelReplyLogo" = "回复标志"; +"ChannelRestrictedUsers" = "受限用户"; +"ChannelSearchException" = "搜索例外"; +"ChannelSettings" = "设置"; +"ChannelSettingsChangedAlert" = "您已更改频道设置,确定应用更改吗?"; +"ChannelSettingsJoinRequest" = "新成员审核"; +"ChannelSettingsJoinRequestInfo" = "启用此选项后,新成员加入前需得到管理员的批准。"; +"ChannelSettingsJoinRequestInfo2" = "管理员必须批准任何想加入频道的人。"; +"ChannelSettingsJoinRequestInfoManagedBy" = "管理员必须批准想要加入频道的用户。由 **%s** 管理"; +"ChannelSettingsJoinTitle" = "谁可以发送消息?"; +"ChannelSettingsJoinToSend" = "仅成员"; +"ChannelSettingsJoinToSendInfo" = "如果您希望用户在加入群组之后方可发送消息,请开启此功能。"; +"ChannelSettingsTitle" = "频道设置"; +"ChannelSignInfo" = "将管理员的名字添加到他们发布的贴文中。"; +"ChannelSignMessages" = "消息署名"; +"ChannelSignMessagesInfo" = "在管理员发送的消息后附上名字。"; +"ChannelSignMessagesWithProfile" = "显示作者资料"; +"ChannelSignProfilesInfo" = "允许管理员以他们的频道或个人账号身分发布內容,并链接到他们的个人资料。"; +"ChannelSilentBroadcast" = "静默广播"; +"ChannelStars" = "余额"; +"ChannelSubscribers" = "订阅者"; +"ChannelSubscribers2" = "频道订阅者"; +"ChannelThemeChangedTo" = "%1$s 已将频道主题更改为 %2$s"; +"ChannelThemeDisabled" = "%1$s 已停用频道主题"; +"ChannelTitleChanged" = "频道名称已更新。"; +"ChannelTooMuch" = "抱歉,您加入的群组和频道过多,请在创建新的之前退出一些。"; +"ChannelTooMuchJoin" = "抱歉,您已经加入了过多的群组和频道,请在加入新的之前退出一些。"; +"ChannelTooMuchTitle" = "群组过多"; +"ChannelTopics" = "话题"; +"ChannelTopicsDiscussionForbidden" = "目前无法在频道的讨论群组中使用话题。"; +"ChannelTopicsForbidden#other" = "只有拥有超过 **%1$d 名成员** 的群组才能启用该主题。"; +"ChannelType" = "频道类型"; +"ChannelTypeHeader" = "频道类型"; +"ChannelUnmute" = "取消静音"; +"ChannelUnmuteNoCaps" = "取消静音"; +"ChannelUserAddLimit" = "抱歉,您只能在订阅者未满 200 人时手动添加用户。然而通过频道邀请链接关注的用户无此数量限制。"; +"ChannelUserCantAdd" = "抱歉,您无法邀请此用户关注频道。"; +"ChannelUserCantAdmin" = "抱歉,此频道的管理员数量已达上限。"; +"ChannelUserCantBot" = "抱歉,此频道的机器人数量已达上限。"; +"ChannelUserLeftError" = "若用户曾离开群组,您需要成为其联系人才能将其加回群组。\n\n只要其不在已移除用户列表中,该用户仍可通过邀请链接加入群组。"; +"ChannelUsernameHelp" = "设置公开链接后,其他人就可以找到并加入您的频道。\n\n您可以使用 **a-z**、**0-9** 和下划线。\n最小长度为 5 个字符。"; +"ChannelUsernamePlaceholder" = "链接"; +"ChannelVerifiedWarning" = "警告"; +"ChannelVideoEditNotification" = "%1$s 已更新频道图片"; +"ChannelWallpaper" = "频道壁纸"; +"ChannelWallpaper2Info" = "设置每个阅读频道的人都能看到墙纸。"; +"ChannelWallpaperInfo" = "上传您自己的背景图片。"; +"ChannelWallpaperRemove" = "移除壁纸"; +"ChannelWallpaperUpdated" = "壁纸已更新"; +"Channels#other" = "%1$d个频道"; +"ChannelsTab" = "频道"; +"Characters#other" = "%1$d 个字符"; +"CharactersPerMessage" = "每条消息 %s 个字符"; +"ChartInTON" = "%s (Gram)"; +"ChartInUSD" = "%s (美元)"; +"ChartInXTR" = "%s (星星)"; +"ChatAdmin" = "管理员"; +"ChatApplyTheme" = "应用主题"; +"ChatArchived" = "聊天已归档。"; +"ChatArchivedInfo" = "向左滑动隐藏归档。"; +"ChatBackground" = "聊天壁纸"; +"ChatBackgroundHint" = "%s 可以应用此壁纸"; +"ChatCamera" = "相机"; +"ChatDeletedUndo" = "聊天已删除。"; +"ChatDistanceToPeer" = "%1$s 是 %2$s"; +"ChatDocument" = "文件"; +"ChatEmoji" = "表情符号"; +"ChatGallery" = "相册"; +"ChatHints" = "联系人"; +"ChatHintsDelete" = "要从建议列表中移除 %1$s 吗?"; +"ChatHintsDeleteAlert" = "您确定将 **%1$s** 从建议中移除吗?"; +"ChatHintsDeleteAlertTitle" = "移除建议"; +"ChatHistory" = "新成员是否可见聊天记录"; +"ChatHistoryHidden" = "隐藏"; +"ChatHistoryHiddenInfo" = "新成员不能查看他们加入前的消息。"; +"ChatHistoryHiddenInfo2" = "新成员只能看到最近 100 条消息。"; +"ChatHistoryShort" = "聊天记录"; +"ChatHistoryVisible" = "可见"; +"ChatHistoryVisibleInfo" = "新成员将能查看他们加群前的消息记录。"; +"ChatLink" = "链接"; +"ChatList" = "预览聊天列表"; +"ChatListDefault" = "两行"; +"ChatListExpanded" = "三行"; +"ChatListSwipeGesture" = "聊天列表滑动手势"; +"ChatListSwipeGestureInfo" = "选择在聊天列表中向左滑动时执行的操作。"; +"ChatLocation" = "位置"; +"ChatMessageSuggestions" = "私信"; +"ChatMultipleReactionsPromo" = "订阅 Telegram 高级版后,您可以发送多个表情回应。"; +"ChatNoTheme" = "无主题"; +"ChatPerFolderLimitSubtitle" = "在每个文件夹内添加高达 %d 个聊天 "; +"ChatPerFolderLimitTitle" = "每个文件夹内聊天数量"; +"ChatResetTheme" = "重置主题"; +"ChatSetLocation" = "设置位置"; +"ChatSetLocationInfo" = "用户可以在“查找附近的人”功能中发现您的群组。"; +"ChatSetNewPhoto" = "设置新图片"; +"ChatSetPhotoOrVideo" = "设置图片"; +"ChatSetThisLocation" = "选择此位置"; +"ChatSettings" = "聊天设置"; +"ChatSticker" = "贴纸"; +"ChatTagAdmin" = "管理员"; +"ChatTagOwner" = "所有者"; +"ChatThemeApplyHint" = "主题也将应用于 %s"; +"ChatThemeChangedTo" = "%1$s 将聊天主题设为 %2$s"; +"ChatThemeChangedYou" = "您已将聊天主题更改为 %1$s"; +"ChatThemeDayNightSwitchTooltip" = "点击以在夜间模式下查看该主题。"; +"ChatThemeDaySwitchTooltip" = "点击以在日间模式下查看该主题。"; +"ChatThemeDisabled" = "%1$s 已停用聊天主题"; +"ChatThemeDisabledYou" = "您已停用聊天主题"; +"ChatThemeNightSwitchTooltip" = "点击以在夜间模式下查看该主题。"; +"ChatThemeSaveDialogApply" = "应用"; +"ChatThemeSaveDialogDiscard" = "放弃"; +"ChatThemeSaveDialogText" = "您确定要将新主题应用到此聊天中吗?"; +"ChatThemeSaveDialogTitle" = "保存更改吗?"; +"ChatVideo" = "视频"; +"ChatWasMovedToMainList" = "此聊天已移至您的主列表。"; +"ChatWithAdminChannelTitle" = "您的加入请求已被回复"; +"ChatWithAdminGroupTitle" = "对于您加群申请的回复"; +"ChatWithAdminMessage" = "您收到这条消息是因为您曾在 %2$s 申请加入 **%1$s** "; +"ChatWithChannelAdmin" = "**%1$s** 是您请求加入频道 **%2$s** 的管理员。"; +"ChatWithGroupAdmin" = "**%1$s** 是您曾申请加入的 %2$s 群组管理员。"; +"ChatYourSelf" = "转发到此以保存"; +"ChatYourSelfDescription1" = "转发消息到此以保存它们"; +"ChatYourSelfDescription2" = "保存发送至此的媒体和文件"; +"ChatYourSelfDescription3" = "在任意设备上访问此聊天"; +"ChatYourSelfDescription4" = "使用搜索以更快地查找"; +"ChatYourSelfName" = "您"; +"ChatYourSelfTitle" = "您的云存储"; +"Chats#other" = "%1$d 个聊天"; +"ChatsArchived" = "聊天已归档。"; +"ChatsDeletedUndo" = "聊天已删除。"; +"ChatsException#other" = "%1$d 个聊天"; +"ChatsLeaveAlert" = ""; +"ChatsMute" = "静音"; +"ChatsNearbyHeader" = "附近的群组"; +"ChatsSelected#other" = "%1$d 个聊天"; +"ChatsSelectedClear#other" = "%1$d 个聊天"; +"ChatsSelectedClearCache#other" = "%1$d 个聊天"; +"ChatsUnmute" = "开启通知"; +"CheckPassword" = "检查密码"; +"CheckPasswordBackToSettings" = "返回设置"; +"CheckPasswordInfo" = "你还记得你的密码吗? "; +"CheckPasswordPerfect" = "完美!"; +"CheckPasswordPerfectInfo" = "您还记得您的密码。 "; +"CheckPasswordWrong" = "您输入的密码无效."; +"CheckPhoneNumber" = "您还在继续使用%1$s这个号码吗?"; +"CheckPhoneNumberInfo" = "保持您的号码是最新的,以确保您始终可以登录 Telegram。 **了解更多**"; +"CheckPhoneNumberLearnMoreUrl" = "https://telegram.org/faq#q-i-have-a-new-phone-number-what-do-i-do"; +"CheckPhoneNumberNo" = "否"; +"CheckPhoneNumberYes" = "确认"; +"CheckPhoneNumberYes2" = "👍 是的"; +"CheckPrivacyCallsText" = "你已限制谁可以向你发送消息,但是任何人仍然可以打电话给你。你想查看这些设置吗?"; +"CheckPrivacyCallsTitle" = "通话设置"; +"CheckPrivacyInviteText" = "你已限制谁可以向你发送消息,但是任何人仍然可以邀请你加入群组或频道。你想查看这些设置吗?"; +"CheckPrivacyInviteTitle" = "邀请设置"; +"CheckPrivacyReview" = "查看"; +"CheckYourEmail" = "检查您的电子邮箱"; +"CheckYourEmailSubtitle" = "请输入我们向您的邮箱 %1$s 发送的验证码。"; +"CheckYourNewEmail" = "检查你的新邮件"; +"CheckYourNewEmailSubtitle" = "请输入我们发送至你的新邮件%1$s的代码。"; +"Checking" = "正在检查…"; +"ChooseANewWallpaper" = "选择新壁纸"; +"ChooseBackground" = "选择背景"; +"ChooseBackgroundFromGallery" = "从相册中选择壁纸"; +"ChooseBot" = "选择机器人"; +"ChooseBots" = "选择机器人"; +"ChooseChannel" = "选择频道"; +"ChooseColorAsBackground" = "设置颜色作为壁纸"; +"ChooseCountry" = "选择国家/地区"; +"ChooseCover" = "选择一帧作为您的头像"; +"ChooseDate" = "选择日期"; +"ChooseEmojiOrSticker" = "选择表情或贴纸"; +"ChooseEmojiPackHeader" = "选择表情包"; +"ChooseEmojiPackMy" = "您可以使用 @stickers 机器人创建自己的自定义表情包。"; +"ChooseFromGallery" = "自相册中上传"; +"ChooseFromGallery2" = "从相册中选择"; +"ChooseFromSearch" = "在网络中搜索"; +"ChooseFromYourStickers" = "从您的贴纸包中选择"; +"ChooseGroup" = "选择群组"; +"ChooseLanguages" = "选择语言"; +"ChooseMapPreviewProvider" = "选择地图预览提供商"; +"ChoosePhoto" = "选择图片"; +"ChoosePhotoForSticker" = "用图片创建贴纸"; +"ChoosePhotoOrVideo" = "选择图片或视频"; +"ChooseRecordVideo" = "录制视频"; +"ChooseStickerMyEmojiPacks" = "我的表情包"; +"ChooseStickerMyStickerSets" = "我的贴纸包"; +"ChooseStickerNoResultsFound" = "\"%1$s\" 的搜索暂无结果"; +"ChooseStickerSet" = "选择表情包"; +"ChooseStickerSetHeader" = "选择贴纸包"; +"ChooseStickerSetMy" = "您可以通过机器人 @stickers 创建您自己的贴纸包。"; +"ChooseStickerSetNotFound" = "没有符合条件的贴纸包"; +"ChooseStickerSetNotFoundInfo" = "请检查您的输入,或从下方列表中选取一个"; +"ChooseStickerSetPlaceholder" = "stickerset"; +"ChooseTakePhoto" = "拍照"; +"ChooseTheme" = "选择主题"; +"ChooseTimeForAutoDelete" = "选择自动删除的时间"; +"ChooseTimeForMute" = "选择"; +"ChooseUser" = "选择用户"; +"ChooseUsers" = "选择用户"; +"ChooseYourLanguage" = "选择您的语言"; +"ChooseYourLanguageOther" = "其它"; +"ChoosingSticker" = "正在选择表情"; +"ChromeCrashMessage" = "显示此网页时出现了问题。\n\n如果频繁发生,请考虑更新 **Android WebView**。"; +"ChromeCrashTitle" = "喔唷,崩溃啦!"; +"ChromeCustomTabs" = "应用内浏览器"; +"ChromeCustomTabsInfo" = "在应用内打开外部链接"; +"ChromecastStarted" = "已开始将媒体直播到 Chromecast。"; +"ChromecastStartedTo" = "开始将媒体直播到 %s。"; +"Clear" = "清空"; +"ClearAllHistory" = "清楚聊天记录"; +"ClearButton" = "全部清空"; +"ClearCache" = "清空缓存"; +"ClearCacheFewChatsTitle" = "清空 %1$s 的缓存"; +"ClearCacheForChat" = "你确定需要删除所选群组的缓存数据吗?"; +"ClearCacheForChats" = "是否确实要删除所选聊天的缓存数据?"; +"ClearCacheInfo" = "释放设备上的存储空间;你的媒体文件仍将保留在云端。"; +"ClearDownloadsList" = "清除下载列表"; +"ClearFewChatsTitle" = "清除 %1$s"; +"ClearForAll" = "为所有人清除"; +"ClearForMe" = "清空"; +"ClearHistory" = "清空历史记录"; +"ClearHistoryCache" = "删除缓存"; +"ClearHistoryForTheseDays" = "清除这些天的历史记录"; +"ClearHistoryForThisDay" = "清除这天的历史记录"; +"ClearHistoryMessageMultiple#other" = "您确定要取消保存来自 %d 个聊天的消息吗?"; +"ClearHistoryMessageSingle" = "您确定要取消保存来自 %s 的消息吗?"; +"ClearHistoryMyNotesMessage" = "您确定要删除您的所有备注吗?"; +"ClearHistoryMyNotesTitle" = "删除我的所有备注"; +"ClearHistoryOptionAlso" = "同时为 %1$s 清除历史记录"; +"ClearHistorySavedMessages" = "您确定要删除“我的收藏”中的所有消息吗?"; +"ClearHistorySavedMessagesTitle" = "删除所有收藏的消息"; +"ClearHistoryTitleMultiple#other" = "取消保存来自 %d 个聊天的消息"; +"ClearHistoryTitleSingle" = "取消保存来自 %s 的消息"; +"ClearHistoryTitleSingle2" = "取消收藏消息"; +"ClearLocalDatabase" = "清除本地数据库"; +"ClearMediaCache" = "清理缓存"; +"ClearOtherSessionsHelp" = "登出除此设备外的所有设备。"; +"ClearOtherWebSessionsHelp" = "您可以在支持的网站上使用 Telegram 登录。"; +"ClearQuote" = "清除引文"; +"ClearRecentEmoji" = "是否清空最近使用的表情列表?"; +"ClearRecentEmojiStatusesText" = "您确定要清空最近使用的表情符号状态并重置建议吗?"; +"ClearRecentEmojiStatusesTitle" = "清除最近的状态"; +"ClearRecentEmojiText" = "您确定要清空所有最近使用的表情吗?"; +"ClearRecentEmojiTitle" = "清除最近表情"; +"ClearRecentHistory" = "清除历史记录"; +"ClearRecentReactionsAlertMessage" = "您确定要从建议中清空最近使用的表情回应吗?"; +"ClearRecentReactionsAlertTitle" = "清空最近使用的表情"; +"ClearRecentStickersAlertMessage" = "您确定要清空您所有最近使用的贴纸吗?"; +"ClearRecentStickersAlertTitle" = "清空最近使用的贴纸"; +"ClearSearch" = "是否清空搜索历史?"; +"ClearSearchAlert" = "您确定要清空搜索历史记录吗?"; +"ClearSearchAlertPartial#other" = "您确定要在搜索历史记录中移除这 %1$d 个聊天吗?"; +"ClearSearchAlertPartialTitle" = "从搜索历史记录中移除"; +"ClearSearchAlertTitle" = "清空搜索记录"; +"ClearSearchRemove" = "移除"; +"ClearSearchSingleAlertTitle" = "搜索历史记录"; +"ClearSearchSingleChatAlertText" = "您确定要将 “%1$s” 从您的搜索记录中删除吗?"; +"ClearSearchSingleHashtagAlertText" = "您想从搜索历史记录中移除 %1$s 吗?"; +"ClearSearchSingleUserAlertText" = "您确定要将 “%1$s” 从您的搜索历史记录中移除吗?"; +"ClearSelectedCache" = "清除所选"; +"ClearStorageHintMessage" = "清理手机存储空间"; +"ClearStorageHintTitle" = "释放最多 %s 空间"; +"ClearTelegramCache" = "清除 Telegram 缓存"; +"ClearingCache" = "缓存清除中…"; +"ClearingCacheDescription" = "请保持此窗口为打开状态,直到 Telegram 清理完您的缓存。"; +"Clicks#other" = "%d 次点击"; +"Close" = "关闭"; +"CloseEditor" = "关闭编辑器"; +"CloseFriends" = "密友"; +"CloseTopic" = "锁定话题"; +"CloseTopics" = "锁定话题"; +"CloseTranslation" = "关闭翻译"; +"CocoonFeature1Text" = "任何第三方都无法访问 **@cocoon** 内的任何数据,包括翻译。"; +"CocoonFeature1TextLink" = "https://t.me/cocoon"; +"CocoonFeature1Title" = "隐私"; +"CocoonFeature2Text" = "Cocoon 帮助 Telegram 将翻译成本降低了 5/6。"; +"CocoonFeature2Title" = "高效"; +"CocoonFeature3Text" = "任何开发者都能使用 Cocoon 实现 AI 功能。了解更多,请访问\u00a0**cocoon.org**。\n"; +"CocoonFeature3TextLink" = "https://cocoon.org/"; +"CocoonFeature3Title" = "人人都可以用"; +"CocoonFooter" = "想在您的项目中集成 Cocoon 吗?\n请通过\u00a0**t.me/cocoon?direct**\u00a0联系我们。"; +"CocoonFooterLink" = "https://t.me/cocoon?direct"; +"CocoonPoweredBy" = "翻译由 **🥚 Cocoon** 提供技术支持。"; +"CocoonPoweredByLink" = "**这是如何运作的?**"; +"CocoonSubtitle" = "Cocoon (**Co**nfidential **Co**mpute **O**pen **N**etwork) 能安全且高效地处理 AI 任务。"; +"Code" = "验证码"; +"CodeCopied" = "代码已拷贝到剪贴板"; +"CodeExpired" = "验证码已过期,请重新登录"; +"ColorBlue" = "蓝色"; +"ColorCyan" = "青色"; +"ColorDark" = "暗色"; +"ColorGreen" = "绿色"; +"ColorOrange" = "橙色"; +"ColorPickerBackground" = "壁纸"; +"ColorPickerMainColor" = "强调色"; +"ColorPickerMyMessages" = "我的消息"; +"ColorPickerReset" = "重置"; +"ColorPickerResetAll" = "重置所有"; +"ColorPink" = "粉色"; +"ColorRed" = "红色"; +"ColorSepia" = "褐色"; +"ColorTheme" = "主题颜色"; +"ColorThemeChanged" = "多彩主题已修改。"; +"ColorThemeChangedInfo" = "您可以在*聊天设置*撤销修改。"; +"ColorThemes" = "多彩主题"; +"ColorViolet" = "紫色"; +"ColorWhite" = "白色"; +"ColorYellow" = "黄色"; +"Comment" = "评论"; +"CommentFor" = "花费 ⭐️%s 评论"; +"Comments#other" = "%1$d 条评论"; +"CommentsCount#other" = "%1$d 条评论"; +"CommentsNoNumber#other" = "评论"; +"CommentsTitle" = "评论"; +"CommonGroups#other" = "%1$d 个共同群组"; +"CompatibilityChat" = "%1$s 正在使用旧版 Telegram,因此阅后即焚图片会以兼容模式显示。\n\n当 %2$s 更新 Telegram 后,若阅后即焚计时为 1 分钟或以内,对方可“长按以观看”;对方截屏时,您也会收到通知。"; +"ConferenceCallIncoming" = "呼入群组通话"; +"ConferenceCallMissed" = "未接群组通话"; +"ConferenceCallOther#other" = "%d 人"; +"ConferenceCallOutgoing" = "拨出群组通话"; +"ConferenceCallWithVideo" = "打开摄像头加入"; +"ConferenceCalling" = "正在呼叫..."; +"ConferenceChat" = "群组通话"; +"ConferenceClosed" = "群聊已结束。"; +"ConferenceEncrypted" = "端到端加密"; +"ConferenceEncryptedClose" = "关闭"; +"ConferenceEncryptedInfo" = "这些表情符号代表加密密钥。所有参与者的密钥必须匹配,当有人加入或离开时密钥会发生变化。"; +"ConferenceEncryptedSyncing" = "端到端加密"; +"Confirm" = "确认"; +"ConfirmCorrectNumber" = "此手机号码正确吗?"; +"ConfirmCreatePasscode" = "再次输入您的锁定码"; +"ConfirmDeleteCallLog" = "您确定要删除这条通话记录吗?"; +"ConfirmSensitiveContentText" = "确认您已年满 18 岁并更新您的设置以查看潜在露骨或敏感的的內容。"; +"ConfirmSensitiveContentTitle" = "18+ 内容"; +"Connected" = "已连接"; +"ConnectedAccountsLimitSubtitle" = "使用不同的电话号码连接到 %d 个账号"; +"ConnectedAccountsLimitTitle" = "关联账户"; +"Connecting" = "连接中…"; +"ConnectingConnectProxy" = "连接代理"; +"ConnectingToProxy" = "正在连接代理"; +"ConnectingToProxyDisable" = "停用"; +"ConnectingToProxyDisableAlert" = "您确定要停用代理服务器 %1$s 吗?您可以在「设置 - 数据和存储」中重新启用它。"; +"ConnectingToProxyEnable" = "启用"; +"ConnectingToProxyTapToDisable" = "点击此处停用代理…"; +"ConnectingToProxyWithDots" = "正在连接代理"; +"ConnectingYourContacts" = "正在连接您的通讯录..."; +"ContactBirthday" = "生日"; +"ContactInfoCommonGroups" = "共同群组"; +"ContactInfoIsContact" = "联系人"; +"ContactInfoIsNotContact" = "非联系人"; +"ContactInfoLocation" = "位置"; +"ContactInfoNotVerified" = "非官方账号"; +"ContactInfoPhone" = "电话号码"; +"ContactInfoPhoneFragment" = "匿名"; +"ContactInfoRegistration" = "注册于"; +"ContactInfoUserUpdatedName" = "用户 %s 已更新名称"; +"ContactInfoUserUpdatedPhoto" = "用户已更新照片 %s"; +"ContactJob" = "工作"; +"ContactJobTitle" = "职位"; +"ContactJoined" = "联系人已加入 Telegram"; +"ContactNotRegistered" = "%1$s 没有在使用 Telegram,是否邀请其加入?"; +"ContactNotRegisteredTitle" = "邀请使用 Telegram"; +"ContactNote" = "备注"; +"ContactShare" = "分享联系人信息"; +"ContactShortcutMessage" = "给 %1$s 发消息"; +"ContactShortcutVideoCall" = "视频呼叫 %1$s"; +"ContactShortcutVoiceCall" = "语音呼叫 %1$s"; +"ContactSupport" = "联系客服"; +"ContactSupportInfo" = "如遇问题,请告诉我们;登出通常无法解决此问题。"; +"ContactUrl" = "链接"; +"Contacts" = "联系人"; +"ContactsAndFee" = "付费"; +"ContactsAndPremium" = "联系人和高级版用户"; +"ContactsDeletedUndo#other" = "已删除 %d 个联系人。"; +"ContactsPermissionAlert" = "Telegram 需要访问您的通讯录才能让您能够在任何设备上联系朋友。您的联系人信息将会被即时同步到高度加密的 Telegram 云服务器。"; +"ContactsPermissionAlertContinue" = "继续"; +"ContactsPermissionAlertNotNow" = "稍后"; +"Continue" = "继续"; +"ContinueOnThisLanguage" = "使用简体中文继续"; +"Contrast" = "对比"; +"ConvertGroup" = "转换为超级群"; +"ConvertGroupAlert" = "此操作是不可逆的。超级群组不可能降级为普通群组。"; +"ConvertGroupAlertWarning" = "警告"; +"ConvertGroupInfo" = "**成员人数已达上限。**\n\n升级为超级群组即可提升成员数量上限,并获得更多功能:\n\n• 超级群组可以提升成员数量上限到高达 %1$s 人\n• 新成员将可以看到所有历史聊天记录\n• 所有成员将看不到已删除的消息\n• 管理员可以为群组添加描述信息\n• 创建者可以给群组设置一条公开链接"; +"ConvertGroupInfo2" = "**在超级群组中:** \n• 新成员能看到所有历史聊天记录 \n• 所有成员将看不到已删除的消息 \n• 管理员可以为群组添加描述信息 \n• 创建者可以给群组设置一条公开链接"; +"ConvertGroupInfo3" = "**注意:**此操作不可撤消。"; +"ConvertGroupMenu" = "升级为超级群组"; +"ConvertingVideo" = "视频转换中..."; +"Copy" = "复制"; +"CopyCardNumber" = "复制卡号"; +"CopyCode" = "复制代码"; +"CopyEmojiPreview" = "复制表情"; +"CopyHashtag" = "复制标签"; +"CopyLink" = "复制链接"; +"CopyMail" = "复制电子邮箱地址"; +"CopyNumber" = "复制号码"; +"CountOfResults#other" = "%1$d / %2$d 结果"; +"CountReceived" = "已接收"; +"CountSent" = "已发送"; +"Country" = "国家/地区"; +"Create" = "创建"; +"CreateChannelForThis" = "为此创建新频道"; +"CreateContact" = "新建联系人"; +"CreateEncryptedChatError" = "发生错误。"; +"CreateGeneralTopicTitle" = "选择话题名称"; +"CreateGroupError" = "抱歉,根据其中一些用户的隐私设置,您无法与这些人建立群组。"; +"CreateGroupForImport" = "创建新群组以导入"; +"CreateGroupForThis" = "为此创建一个新群组"; +"CreateLink" = "创建链接"; +"CreateLinkHeader" = "创建"; +"CreateLinkHeaderNoCaps" = "创建"; +"CreateManagedBotButton" = "创建"; +"CreateManagedBotCreatedText" = "**%s** 将为您管理此机器人。"; +"CreateManagedBotCreatedTitle" = "%s 已创建!"; +"CreateManagedBotLimitText" = "您最多可以创建 %d 个机器人,或通过 @BotFather 删除您现有的机器人"; +"CreateManagedBotLimitTextPremium" = "订阅 **高级版** 即可创建最多 %1$d 个机器人,或通过 @BotFather 删除一个 %2$d 机器人"; +"CreateManagedBotLimitTextTime" = "您可以在 %s 中创建另一个机器人。"; +"CreateManagedBotLimitTitle" = "机器人数量已超出限制"; +"CreateManagedBotName" = "机器人名称"; +"CreateManagedBotNameInfo" = "点击以编辑机器人名称。"; +"CreateManagedBotText" = "**%s** 想要代表您创建\n并管理一个聊天机器人。"; +"CreateManagedBotTitle" = "创建机器人"; +"CreateManagedBotUnsupported" = "**%s** 不支持机器人管理模式。"; +"CreateManagedBotUsername" = "用户名"; +"CreateManagedBotUsernameInfo" = "点击以编辑机器人名称"; +"CreateMultipleStories" = "创建多条动态"; +"CreateNewContact" = "新建联系人"; +"CreateNewFilter" = "创建新文件夹"; +"CreateNewFilterInfo" = "为不同聊天分组创建文件夹,并在它们之间快速切换。"; +"CreateNewInviteLink" = "新建邀请链接"; +"CreateNewLink" = "新建链接"; +"CreateNewLinkHelp" = "您可以生成使用后自动过期的邀请链接。"; +"CreateNewTheme" = "创建新主题"; +"CreateNewThemeAlert" = "您可以通过更改应用中的颜色来创建自己的主题。\n\n您可以随时切换回默认的Telegram主题。"; +"CreateNewThemeHelp" = "点按调色板图标来查看或编辑每个屏幕上元素的列表。"; +"CreateNewThemeInfo" = "您可以通过更改应用中的颜色来创建自己的主题。您可以随时切换回默认的Telegram主题。"; +"CreateNewThemeMenu" = "创建新主题"; +"CreatePasscode" = "创建锁定码"; +"CreatePasscodeInfoPIN" = "请输入四位数字作为解锁 Telegram 的密码"; +"CreatePasscodeInfoPassword" = "请输入密码以解锁Telegram"; +"CreatePassword" = "创建新密码"; +"CreateSticker" = "创建贴纸"; +"CreateTheme" = "创建主题"; +"CreateTopic" = "创建话题"; +"CreateTopicTitle" = "请选择话题名称与图标"; +"CreateTopicsPermission" = "创建话题"; +"Crop" = "裁剪"; +"CropImage" = "裁剪图片"; +"CropOriginal" = "原图"; +"CropReset" = "重置"; +"CropSquare" = "正方形"; +"CurrentGroupStickers" = "群组贴纸包"; +"CurrentSession" = "此设备"; +"CurvesAll" = "全部颜色"; +"CurvesBlue" = "蓝"; +"CurvesGreen" = "绿"; +"CurvesRed" = "红"; +"CustomAvatarTooltip" = "您设置的头像"; +"CustomAvatarTooltipVideo" = "由您设置的头像"; +"CustomCallInfo" = "上面的设置项将决定这些用户是否能呼叫您。"; +"CustomHelp" = "除非您是高级版订阅用户,对于您不愿意分享最后上线时间的对象,他们最后的上线时间您将也无法看到。取而代之的是大致的上线时间(如:最近、一周内、一个月内)。"; +"CustomHelpPremium" = "因为您是 Telegram 高级版订阅者,所以您将看到启用分享的所有用户的最后上线和已读时间 - 即使您已隐藏自己的。"; +"CustomNotifications" = "自定义通知"; +"CustomP2PInfo" = "无论上述设置如何,通话中的端对端连接都会或者不会对这些用户启用。"; +"CustomShareGiftsInfo" = "您可以将个别用户或整个群组新增为例外,以覆盖上面的设定。"; +"CustomShareInfo" = "上面的设置项将决定这些用户是否能添加您到群组或频道。"; +"CustomShareSettingsHelp" = "您可以将用户或整个群组设为例外,不受以上设置的限制。"; +"CustomSound" = "自定义声音"; +"CustomThemes" = "自定义主题"; +"DartInfo" = "发送 **:dart:** 表情试试运气。"; +"DataSettings" = "数据和存储"; +"DataUsage" = "存储和网络使用情况"; +"DataUsageByChats" = "聊天占用的存储空间"; +"DataUsageSectionsInfo" = "点击各部分查看详细信息。"; +"DateDay" = "日"; +"DateMonth" = "月"; +"DateOfBirth" = "出生日期"; +"DateOfBirthAddToProfile" = "添加到我的个人资料页上"; +"DateOfBirthHideYear" = "隐藏年份"; +"DateYear" = "年\n"; +"DayAgo" = "前一天"; +"Days#other" = "%1$d 天"; +"DaysAgo#other" = "%d 天前"; +"DaysBold#other" = "**%1$d** 天"; +"DaysLeft#other" = "剩余 %1$d 天"; +"DaysSchedule#other" = "%1$d天内"; +"Deactivate" = "立即删除"; +"DebugAltNavigation" = "替代导航"; +"DebugAltNavigationToggleControls" = "切换导航刚度控制"; +"DebugClearLocalDatabaseSuccess" = "已清除本地数据库。"; +"DebugClearLogs" = "清空日志"; +"DebugClearSendMessageAsPeers" = "清除发送消息为...同行"; +"DebugDialogsActivity" = "聊天活动"; +"DebugDrawSnowInChat" = "在聊天中下雪 "; +"DebugGeneral" = "常规"; +"DebugMenu" = "调试菜单"; +"DebugMenuCallSettings" = "通话设置"; +"DebugMenuCheckAppUpdate" = "检查应用更新"; +"DebugMenuClearMediaCache" = "清理已发送的媒体缓存"; +"DebugMenuClearWebViewCache" = "清除网页视图缓存"; +"DebugMenuClearWebViewCookies" = "清除 WebView cookies"; +"DebugMenuDisableCamera" = "停用应用内相机"; +"DebugMenuDisableForceRtmpStreamFlag" = "停用强制 RTMP 流"; +"DebugMenuDisableLogs" = "停用日志"; +"DebugMenuDisablePauseMusic" = "当录音时正常播放音乐"; +"DebugMenuDisableSmoothKeyboard" = "停用滑行输入"; +"DebugMenuDisableWebViewDebug" = "停用 WebView 调试"; +"DebugMenuDontUseSystemBoldFont" = "不要使用系统加粗字体"; +"DebugMenuDualOff" = "隐藏双摄像头"; +"DebugMenuDualOffToast" = "双摄像头按钮已隐藏"; +"DebugMenuDualOn" = "显示双摄像头"; +"DebugMenuDualOnToast" = "已开启双摄像头,请谨慎使用该功能"; +"DebugMenuEnableCamera" = "启用应用内相机"; +"DebugMenuEnableForceRtmpStreamFlag" = "启用强制RTMP流"; +"DebugMenuEnableLogs" = "启用日志"; +"DebugMenuEnablePauseMusic" = "当录音时暂停音乐播放"; +"DebugMenuEnableSmoothKeyboard" = "启用滑行输入"; +"DebugMenuEnableWebViewDebug" = "允许网页视图调试"; +"DebugMenuImportContacts" = "导入通讯录"; +"DebugMenuLoginToast#other" = "再点击 %d 次即可开启侦错选单。"; +"DebugMenuLongPress" = "再次长按以显示调试菜单"; +"DebugMenuReadAllDialogs" = "将所有聊天标记为已读"; +"DebugMenuReloadContacts" = "重载通讯录"; +"DebugMenuResetContacts" = "重置已导入的联系人"; +"DebugMenuResetDialogs" = "重置聊天"; +"DebugMenuUseSystemBoldFont" = "使用系统加粗字体"; +"DebugMenuWebViewDebugDisabled" = "调试已停用!"; +"DebugMenuWebViewDebugEnabled" = "调试成功开启"; +"DebugMessageSkeletons" = "消息框架"; +"DebugMessageSkeletonsLightOverlayAlpha" = "透明浅色叠加 "; +"DebugMessageSkeletonsSaturation" = "饱和度"; +"DebugSendLastLogs" = "发送最近的日志"; +"DebugSendLogs" = "发送日志"; +"DebugShareAlert" = "分享提醒"; +"DebugShareAlertDialogsModeLess" = "更少"; +"DebugShareAlertDialogsModeMore" = "更多"; +"DebugShareAlertDialogsModeNormal" = "正常"; +"DebugShareAlertSwitchDialogsMode" = "切换共享警报对话框模式(%1$s)"; +"DebugShareAlertTopicsSlowMotion" = "切换共享警报话题慢动作"; +"DebugTestBackend" = "测试后台"; +"December" = "十二月"; +"Decline" = "拒绝"; +"DeclineCall" = "挂断"; +"DeclineDeactivate" = "拒绝并删除帐号"; +"DecreaseSpeed" = "降低速度"; +"Default" = "默认"; +"DefaultRingtone" = "默认"; +"Delete" = "删除"; +"DeleteAccountHelp" = "如果您在此期间内您的帐号未曾上线,您帐号的所有资料——包括消息记录、联系人都会被删除。"; +"DeleteAccountIfAwayFor" = "保留"; +"DeleteAccountIfAwayFor2" = "自动删除账号"; +"DeleteAccountIfAwayFor3" = "保留"; +"DeleteAccountTitle" = "自动删除帐号"; +"DeleteAdditionalActions" = "更多选项"; +"DeleteAlertReaction" = "您确定要删除此用户的回应吗?"; +"DeleteAlertReactionAll" = "删除所有该用户的回应"; +"DeleteAll" = "删除所有"; +"DeleteAllCalls" = "删除所有通话"; +"DeleteAllCallsText" = "您确定要删除近期所有通话记录吗?"; +"DeleteAllFrom" = "删除 %1$s 发送的所有消息"; +"DeleteAllFromUsers" = "删除该用户发送的所有消息"; +"DeleteAllMessagesAlert" = "警告!这将为**双方删除**聊天中的**所有消息** 。"; +"DeleteAllMessagesChannelAlert" = "警告!这将 **彻底删除** 该频道内全部消息。"; +"DeleteAllMessagesFrom" = "删除来自 %1$s 的所有消息"; +"DeleteAllMessagesFromUsers" = "删除他们所有的消息"; +"DeleteAllMessagesSavedAlert" = "警告!这将删除聊天中的**所有消息**。"; +"DeleteAllProxies" = "删除所有代理"; +"DeleteAllProxiesConfirm" = "你确定需要删除所有代理设置吗?"; +"DeleteAllReactionsFrom" = "删除来自 %1$s 的所有回应"; +"DeleteAllReactionsFromUsers" = "删除他们所有的回应"; +"DeleteAllRevokedLinkHelp" = "此操作将删除所有已撤销的链接。"; +"DeleteAllRevokedLinks" = "删除所有已废除的链接"; +"DeleteAndBlock" = "删除并屏蔽"; +"DeleteAndExit" = "删除并退出群组"; +"DeleteAndExitButton" = "删除并退出群组"; +"DeleteAndStop" = "删除并屏蔽"; +"DeleteBackground#other" = "删除 %1$d 张壁纸"; +"DeleteBan" = "封禁 %1$s"; +"DeleteBanUser" = "封禁用户"; +"DeleteBanUsers" = "封禁用户"; +"DeleteBot" = "删除机器人"; +"DeleteBotPreviewSubtitle" = "您确定要删除此预览?"; +"DeleteBotPreviewTitle" = "删除预览"; +"DeleteBotPreviewsSubtitle#other" = "您确定要删除 %d 个预览吗?"; +"DeleteBotPreviewsTitle" = "删除预览"; +"DeleteCalls" = "删除通话"; +"DeleteCallsForEveryone" = "为所有人删除"; +"DeleteChannelForAll" = "为所有订阅者删除"; +"DeleteChannelForAll2" = "为所有人删除频道"; +"DeleteChat" = "删除并退出"; +"DeleteChatBackgroundsAlert" = "您确定要删除所选壁纸吗?"; +"DeleteChatUser" = "删除聊天"; +"DeleteContact" = "删除联系人"; +"DeleteContactSubtitle" = "您确定想要删除这些联系人吗?"; +"DeleteContactTitle" = "删除联系人"; +"DeleteContactsSubtitle" = "您确定想要删除这些联系人吗?"; +"DeleteContactsTitle#other" = "删除 %d 个联系人"; +"DeleteDays#other" = "删除 %1$s 天内的聊天记录"; +"DeleteException" = "清除列外"; +"DeleteFewChatsTitle" = "删除 %1$s"; +"DeleteForAll" = "为所有成员删除"; +"DeleteForUser" = "同时为 %1$s 删除"; +"DeleteFromFavorites" = "从收藏中删除"; +"DeleteFromRecent" = "从最近使用文档列表中删除"; +"DeleteGif" = "删除此 GIF?"; +"DeleteGroupForAll" = "为所有成员删除群组"; +"DeleteHistoryByDaysMessage" = "您确定要删除所选日期的所有消息吗?"; +"DeleteLink" = "删除链接"; +"DeleteLinkHelp" = "这将会删除链接。"; +"DeleteLocalization" = "删除本地化文件?"; +"DeleteLocalizationText" = "你确定要删除 **%1$s** 语言包吗?"; +"DeleteLocalizationTitle" = "删除语言包"; +"DeleteMega" = "删除群组"; +"DeleteMegaForAll" = "为所有人删除群组"; +"DeleteMegaMenu" = "删除群组"; +"DeleteMessagesForBothSidesWherePossible" = "尽可能为双方删除"; +"DeleteMessagesOption" = "取消发送消息"; +"DeleteMessagesOptionAlso" = "同时为%1$s删除"; +"DeleteMessagesOptionAlsoChat" = "同时为所有成员删除"; +"DeleteMessagesOptionsTitleAll" = "删除所有消息吗?"; +"DeleteMessagesText" = "你还可以通过选中“取消发送我的消息”来删除您从**%2$s**收件箱发送的%1$s。"; +"DeleteMessagesTextGroup" = "您还可以通过选中“取消发送我的消息”来删除您从其他群组成员的收件箱发送的%1$s。"; +"DeleteMessagesTextGroupPart" = "你还可以通过选中“删除所有成员”,从其他群组成员的收件箱中删除 %1$s。"; +"DeleteMessagesTitle" = "删除 %1$s"; +"DeleteMyAccount" = "删除我的账号"; +"DeleteOptionsTitle#other" = "删除 %1$d 条消息?"; +"DeletePhoto" = "移除头像"; +"DeleteProceedBtn" = "继续"; +"DeleteProxy" = "删除代理?"; +"DeleteProxyConfirm" = "确定要删除此代理服务器吗?"; +"DeleteProxyMultiConfirm" = "确定要删除这些代理服务器吗?"; +"DeleteProxyTitle" = "删除代理服务器"; +"DeleteReaction" = "删除回应"; +"DeleteReactionMenu" = "删除回应"; +"DeleteReactionOptionsTitle#other" = "删除 %d 个回应?"; +"DeleteReactionOptionsTitleAll" = "删除所有反应吗?"; +"DeleteReportSpam" = "举报垃圾信息"; +"DeleteRestrict" = "限制 %1$s"; +"DeleteRestrictUsers" = "限制用户"; +"DeleteSelectedCallsText" = "您确定要删除已选中的最近通话吗?"; +"DeleteSelectedTopic" = "您确定要删除 %s 吗?"; +"DeleteSelectedTopics" = "你确定想要删除这些话题吗?"; +"DeleteSingleMessagesTitle" = "删除消息"; +"DeleteSticker" = "删除贴图"; +"DeleteStickerSetsAlertTitle" = "删除 %1$s"; +"DeleteStickersAlertMessage" = "您确定删除选中的贴纸包吗?"; +"DeleteStoriesSubtitle#other" = "您确定要删除 %d 条动态吗?"; +"DeleteStoriesTitle" = "删除动态"; +"DeleteStorySubtitle" = "您确定想要删除此动态吗?"; +"DeleteStoryTitle" = "删除动态"; +"DeleteTheme" = "删除主题"; +"DeleteThemeAlert" = "您确定要删除此主题吗?"; +"DeleteThemeTitle" = "删除主题"; +"DeleteTheseChats" = "删除聊天"; +"DeleteTheseChatsBothSides" = "删除聊天"; +"DeleteThisChat" = "删除此次聊天"; +"DeleteThisChatBothSides" = "删除此次聊天"; +"DeleteThisGroup" = "删除群组"; +"DeleteToggleBanUser" = "彻底封禁此用户"; +"DeleteToggleBanUsers" = "彻底封禁用户"; +"DeleteToggleRestrictUser" = "部分限制此用户"; +"DeleteToggleRestrictUsers" = "部分限制用户"; +"DeleteTones#other" = "删除 %d 个提示音"; +"DeleteTonesMessage#other" = "你想要删除 **%d 通知铃声吗?"; +"DeleteTopics#other" = "删除话题"; +"DeletedFromYourContacts" = "%s 已从您的联系人中删除"; +"Deletions#other" = "%1$d 次删除"; +"DescriptionInfo" = "您可以给您的频道添加一段简介。"; +"DescriptionMore" = "更多"; +"DescriptionOptionalPlaceholder" = "简介(可选)"; +"DescriptionPlaceholder" = "简介"; +"Deselect" = "取消选择"; +"DeselectAll" = "取消全选"; +"DetectedLanguage" = "检测到的语言"; +"DeviceStorage" = "存储空间"; +"Devices" = "设备"; +"DialogNotAvailable" = "不可用"; +"DialogPin" = "置顶"; +"DialogUnpin" = "取消置顶"; +"DiceEmojiInfo" = "发送 %1$s 表情试试运气。"; +"DiceInfo" = "发送 🎲 表情至任意聊天,Telegram 将为您随机生成点数。"; +"DiceInfo2" = "在任何聊天中发送 **:dice:** 表情即可掷骰子。"; +"DidNotGetTheCode" = "未收到验证码?"; +"DidNotGetTheCodeEditNumberButton" = "编辑手机号码"; +"DidNotGetTheCodeFragment" = "通过 Fragment 获取代码"; +"DidNotGetTheCodeHelpButton" = "帮助"; +"DidNotGetTheCodeInfo" = "如果您没有通过短信或电话收到验证码,请检查您的**蜂窝数据设置**和手机号码:\n\n**%1$s**\n\n另外,您可尝试使用另一个号码或联系 Telegram 客服。 点击 **帮助** 将技术细节发送给我们,以便我们识别问题。"; +"DidNotGetTheCodePhone" = "在此手机上获取验证码"; +"DidNotGetTheCodeSms" = "点击此处通过短信收取验证码"; +"DirectChargeFee" = "费用"; +"DirectFee" = "%2$s 为每条消息支付 **⭐️%1$d**。"; +"DirectFeeNone" = "%1$s 可以免费发送消息。"; +"DirectRemoveFee" = "移除费用"; +"DirectShare" = "直接分享"; +"DirectShareInfo" = "在系统分享菜单中显示最近的聊天"; +"Directions" = "方向"; +"Disable" = "停用"; +"DisableAutoDeleteTimer" = "停用自动删除"; +"DisablePasscode" = "关闭锁定码"; +"DisablePasscodeConfirmMessage" = "您确定要停用锁定码吗?"; +"DisablePasscodeTurnOff" = "关闭"; +"DisablePhotoSpoiler" = "取消遮罩"; +"DisableReactionsInfo" = "群组成员无法对消息使用表情回应。"; +"DisableSharing" = "禁用分享"; +"DisableSharingActionOther" = "%s 已禁用此聊天中的分享功能"; +"DisableSharingActionStillDisabled" = "此聊天仍启用分享功能"; +"DisableSharingActionStillEnabled" = "此聊天仍启用分享功能"; +"DisableSharingActionYou" = "您已禁用此聊天中的分享功能"; +"DisableSharingInfoButton" = "禁用分享"; +"DisableSharingInfoHeader" = "禁用分享"; +"DisableSharingInfoHeader1" = "禁止截屏"; +"DisableSharingInfoHeader2" = "禁止转发"; +"DisableSharingInfoHeader3" = "禁止保存"; +"DisableSharingInfoText1" = "禁用在此聊天中截屏与录屏。"; +"DisableSharingInfoText2" = "禁用将消息转发到其他聊天。"; +"DisableSharingInfoText3" = "禁用复制文本及保存图片或视频。\u00a0"; +"DisableSharingOfferAccept" = "接受"; +"DisableSharingOfferDecline" = "拒绝"; +"DisableSharingToastDisabled" = "此聊天已禁用分享功能"; +"DisableSharingToastDisabledPending" = "若 **%s** 批准,将关闭分享功能"; +"DisableSharingToastEnabled" = "此聊天已启用分享功能"; +"DisableSharingToastEnabledPending" = "若 **%s** 批准,将开启分享功能"; +"DisappearingGif" = "正在消失的 GIF"; +"DisappearingPhoto" = "阅后即焚图片"; +"DisappearingVideo" = "阅后即焚视频"; +"Discard" = "放弃"; +"DiscardChanges" = "放弃更改"; +"DiscardPollOptionWithMediaAlertTitle" = "删除了问答选项?"; +"DiscardPollOptionWithMediaMessage" = "此投票选项包含媒体附件。您是否想要删除?"; +"DiscardQuizOptionWithMediaAlertTitle" = "删除问答答案?"; +"DiscardQuizOptionWithMediaMessage" = "此问答答案包含媒体附件。您是否想要删除?"; +"DiscardSelectionAlertMessage" = "您确定要关闭附件菜单吗?"; +"DiscardSelectionAlertTitle" = "放弃所选内容"; +"DiscardTopic" = "放弃话题"; +"DiscardTopicMessage" = "直到您发送一条消息后,话题才会创建。\n\n您确定要放弃发布话题吗?"; +"DiscardVideoMessageDescription" = "您确定要停止录制并放弃您的视频消息吗?"; +"DiscardVideoMessageTitle" = "放弃视频消息"; +"DiscardVoiceMessageAction" = "放弃"; +"DiscardVoiceMessageDescription" = "您确定要停止录制并放弃您的语音消息吗?"; +"DiscardVoiceMessageTitle" = "放弃语音消息"; +"Disconnect" = "断开连接"; +"DiscussChannel" = "频道"; +"Discussion" = "讨论"; +"DiscussionChannelGroupSetHelp" = "频道底部将向所有订阅者显示**%1$s**的链接。"; +"DiscussionChannelGroupSetHelp2" = "已选择 **%1$s** 为频道的留言群组。"; +"DiscussionChannelHelp" = "选择一个用于讨论的群组,它将显示在您的频道中。"; +"DiscussionChannelHelp2" = "频道内发布的消息将会被转发到与该频道关联的群组"; +"DiscussionChannelHelp3" = "为您的频道选择一个评论群组。"; +"DiscussionCreateGroup" = "创建新群组"; +"DiscussionGroupHelp" = "此群组已作为 %1$s 的关联讨论群组。"; +"DiscussionGroupHelp2" = "频道内的所有新贴文都将转发到该群组。"; +"DiscussionInfo" = "添加一个群组聊天以便讨论"; +"DiscussionInfoShort" = "添加"; +"DiscussionLinkGroup" = "关联群组"; +"DiscussionLinkGroupAlertHistory" = "聊天记录将设置为新成员可见。"; +"DiscussionLinkGroupPrivateAlert" = "您想将 **%1$s** 作为 **%2$s** 的讨论群吗?\n\n该群组的任何成员都可以查看此频道中的消息。"; +"DiscussionLinkGroupPublicAlert" = "您想将 **%1$s** 作为 **%2$s** 的讨论群组吗?"; +"DiscussionLinkGroupPublicPrivateAlert" = "您想将 **%1$s** 作为 **%2$s** 的讨论群吗?\n\n该频道的任何成员都可以查看此群组中的消息。"; +"DiscussionStarted" = "讨论已开始"; +"DiscussionUnlink" = "取消关联"; +"DiscussionUnlinkChannel" = "取消关联频道"; +"DiscussionUnlinkChannelAlert" = "您确定要将 **%1$s** 与本频道取消关联吗?"; +"DiscussionUnlinkGroup" = "取消关联群组"; +"DiscussionUnlinkGroupAlert" = "您确定要取消本群组与**%1$s**频道的链接?"; +"Dismiss" = "拒绝"; +"DismissRequest" = "拒绝请求"; +"DistanceUnits" = "距离单位"; +"DistanceUnitsAutomatic" = "自动"; +"DistanceUnitsKilometers" = "公里"; +"DistanceUnitsMiles" = "英里"; +"DistanceUnitsTitle" = "距离单位"; +"DoNoSetTheme" = "不设置主题"; +"DoNotForward" = "取消转发"; +"DoNotLinkPreview" = "关闭预览"; +"DoNotQuote" = "取消引用"; +"DoNotReply" = "取消回复"; +"DoNotTranslate" = "无需翻译"; +"DoNotTranslateLanguage" = "不用翻译 %s"; +"DoNotTranslateLanguageOther" = "无需翻译 %s"; +"DoNotUseSDCard" = "不要使用 SD 卡"; +"DocumentsTitle" = "分享的文件"; +"Done" = "完成"; +"DontAskAgain" = "不再询问"; +"DoubleTapPreviewMessage" = "希望你今天也玩得很开心。"; +"DoubleTapPreviewRational" = "双击此消息以快速回应"; +"DoubleTapPreviewSenderName" = "迪诺"; +"DoubleTapSetting" = "快速回应"; +"DoubledLimits" = "双倍上限"; +"DownloadSpeedLimited" = "下载速度受限"; +"DownloadSpeedLimitedMessage" = "订阅 **Telegram 高级版** 以将下载速度提高 %d 倍。"; +"DownloadedFiles" = "已下载的文件"; +"DownloadedFilesMessage" = "Telegram 允许您将发送和接收的所有文档存储在云端,以在设备上节省存储空间。"; +"Downloading" = "下载中……"; +"DownloadsTabs" = "下载"; +"Draft" = "草稿"; +"DualErrorMessage" = "很遗憾,您的设备暂不支持双摄像头模式。"; +"DualErrorTitle" = ":("; +"DynamicPackOrder" = "贴纸包动态顺序"; +"DynamicPackOrderInfo" = "将最近使用的贴纸包自动置于前列。"; +"DynamicPackOrderOff" = "关闭动态排序"; +"DynamicPackOrderOffInfo" = "当您发送贴纸时,贴纸包将不会被重新排列。"; +"Edit" = "编辑"; +"EditAccountInfo2" = "账号"; +"EditAccountInfoHeader" = "个人信息"; +"EditAdmin" = "管理员权限"; +"EditAdminAddAdmins" = "添加新的管理员"; +"EditAdminAddUsers" = "添加用户"; +"EditAdminAddUsersViaLink" = "通过链接邀请用户"; +"EditAdminBanUsers" = "封禁用户"; +"EditAdminCantEdit" = "您无法修改此管理员的权限。"; +"EditAdminChangeChannelInfo" = "修改频道信息"; +"EditAdminChangeGroupInfo" = "修改群组信息"; +"EditAdminChannelTransfer" = "转让频道所有权"; +"EditAdminDeleteMessages" = "删除其他用户的消息"; +"EditAdminDeleteStories" = "删除其他用户的动态"; +"EditAdminEditMessages" = "编辑其他用户的消息"; +"EditAdminEditStories" = "编辑其他人的动态"; +"EditAdminEditTags" = "编辑成员标签"; +"EditAdminGroupDeleteMessages" = "删除消息"; +"EditAdminGroupTransfer" = "转让群组所有权"; +"EditAdminManageDirect" = "管理私信"; +"EditAdminPinMessages" = "置顶消息"; +"EditAdminPostMessages" = "发布消息"; +"EditAdminPostStories" = "发布动态"; +"EditAdminProcessJoinRequests" = "处理加入请求"; +"EditAdminProcessJoinRequestsInfo" = "允许此机器人使用其自己的界面(如验证码)批准新成员。"; +"EditAdminPromotedBy" = "由 %1$s 设为管理员"; +"EditAdminRank" = "成员标签"; +"EditAdminRankInfo" = "群组成员将看到此头衔,而不是 “%1$s”。"; +"EditAdminRemoveAdmin" = "解任管理员"; +"EditAdminRights" = "修改管理员权限"; +"EditAdminSendAnonymously" = "保持匿名"; +"EditAdminTag" = "编辑管理员标签"; +"EditAdminTransferAlertText" = "满足以下条件您才可以将群组转让给 **%1$s** :"; +"EditAdminTransferAlertText1" = "已开启 **双重验证** 超过 **7天**。"; +"EditAdminTransferAlertText2" = "在此设备登录时间超过**24小时**。"; +"EditAdminTransferAlertText3" = "请稍后再试。"; +"EditAdminTransferAlertTitle" = "安全检查"; +"EditAdminTransferChangeOwner" = "更改所有者"; +"EditAdminTransferChannelToast" = "**%1$s** 现在是该频道的所有者。"; +"EditAdminTransferGroupToast" = "**%1$s** 现在是该群组的所有者。"; +"EditAdminTransferReadyAlertText" = "这将转让 **%1$s** 的所有权给 **%2$s**。\n\n您将不再被视为群组的创建者。 新的所有者可以自由地删除您的任何管理员权限,甚至封禁您。"; +"EditAdminTransferReadyAlertText2" = "此将会把「**%1$s**」的**完整所有权**转让给 **%2$s**。"; +"EditAdminTransferSetPassword" = "设置密码"; +"EditAdminWhatCanDo" = "这位管理员可以做什么?"; +"EditBotPreview" = "编辑预览"; +"EditCantEditPermissions" = "您不能更改此权限。"; +"EditCantEditPermissionsDiscussion" = "讨论群中无此权限。"; +"EditCantEditPermissionsPublic" = "公开群组不具有此权限。"; +"EditCaption" = "编辑说明"; +"EditChannelAdminTransferAlertText" = "满足以下条件您才可以将频道转让给 **%1$s** :"; +"EditContact" = "编辑联系人信息"; +"EditCurrentTheme" = "编辑当前主题"; +"EditException" = "编辑例外"; +"EditFactCheck" = "编辑事实查核"; +"EditInfo" = "编辑信息"; +"EditLink" = "编辑链接"; +"EditMemberRankInfo" = "在 %1$s 的名称旁添加简短标签。"; +"EditMemberTag" = "编辑成员标签"; +"EditMessage" = "编辑消息"; +"EditMessageEditPhoto" = "编辑此照片"; +"EditMessageEditVideo" = "编辑此视频"; +"EditMessageError" = "抱歉,您无法编辑这条消息。"; +"EditMessageMedia" = "点击以编辑媒体文件"; +"EditMessageReplaceAudio" = "更换音频文件"; +"EditMessageReplaceFile" = "更换文件"; +"EditMessageReplaceGif" = "更换媒体"; +"EditMessageReplacePhoto" = "更换照片"; +"EditMessageReplaceVideo" = "更换视频"; +"EditName" = "编辑名称"; +"EditNumber" = "编辑手机号码"; +"EditNumberInfo" = "确定要编辑号码吗?\n\n**%1$s**\n\n如果上面的号码是正确的,请等待验证码。"; +"EditOfferAdd" = "添加报价"; +"EditOfferMessage" = "编辑消息"; +"EditOfferPrice" = "编辑价格"; +"EditOfferTime" = "编辑时间"; +"EditPack" = "编辑"; +"EditPhoto" = "在编辑器中打开"; +"EditProfileBio" = "你的简介"; +"EditProfileBioHint" = "介绍你自己..."; +"EditProfileBioHint2" = "个人简介"; +"EditProfileBioInfo" = "您可以添加几行关于自己的简介,并在 **设置** 中选择谁可以查看您的个人简介。"; +"EditProfileBioInfo2" = "关于您自己的文字介绍。"; +"EditProfileBioInfoContacts" = "您的联系人能看到您的个人简介。**更改 >**"; +"EditProfileBioInfoContactsExtra" = "您的联系人(%s)能看到您的个人简介。**更改 >**"; +"EditProfileBioInfoEveryone" = "所有人都能看到您的个人简介。**更改 >**"; +"EditProfileBioInfoEveryoneExcept" = "所有人(除了 %d)能看到您的个人简介。**更改 >**"; +"EditProfileBioInfoNobody" = "没有人能看到您的个人简介。**更改 >**"; +"EditProfileBioInfoNobodyExcept" = "没有人(除了 %d)能看到您的个人简介。**更改 >**"; +"EditProfileBioInfoUnknown" = "在 **设置** 中选择谁可以看到你的个人简介。"; +"EditProfileBirthday" = "你的出生日期"; +"EditProfileBirthdayAdd" = "添加"; +"EditProfileBirthdayButton" = "保存"; +"EditProfileBirthdayInfo" = "在 **设置** 中选择谁能看到你的出生日期。"; +"EditProfileBirthdayInfoContacts" = "只有您的联系人才能看到您的生日。 **变更 >**"; +"EditProfileBirthdayInfoRemove" = "**移除出生日期**。"; +"EditProfileBirthdayRemove" = "移除生日"; +"EditProfileBirthdayText" = "生日"; +"EditProfileBirthdayTitle" = "生日"; +"EditProfileChannel" = "你的频道"; +"EditProfileChannelAdd" = "添加"; +"EditProfileChannelHide" = "隐藏频道"; +"EditProfileChannelSelect" = "选择您的频道"; +"EditProfileChannelSet" = "已添加个人频道。"; +"EditProfileChannelStartNew" = "创建频道"; +"EditProfileChannelTitle" = "个人频道"; +"EditProfileChannelTitleAdd" = "添加个人频道"; +"EditProfileChatAutomation" = "聊天自动化"; +"EditProfileChatAutomationInfo" = "添加一个机器人来代表你回复消息。"; +"EditProfileColor" = "更改个人资料页颜色"; +"EditProfileFirstName" = "名"; +"EditProfileHours" = "更改营业时间"; +"EditProfileInfo" = "个人资料"; +"EditProfileLastName" = "姓"; +"EditProfileLocation" = "更改位置信息"; +"EditProfileName" = "你的姓名"; +"EditSticker" = "编辑贴纸"; +"EditStickerPack" = "编辑贴纸包"; +"EditStickers" = "编辑贴纸"; +"EditStory" = "编辑动态"; +"EditThemeColors" = "编辑颜色"; +"EditThemeTitle" = "编辑主题"; +"EditToDo" = "编辑"; +"EditTopic" = "编辑话题"; +"EditTopicHide" = "在话题中显示"; +"EditTopicHideInfo" = "如果“常规”主题处于隐藏状态,则群组成员可以在主题列表中下拉以查看该主题。"; +"EditWidget" = "编辑微件"; +"EditWidgetChatsInfo" = "显示选中聊天的最新信息。\n\n如果没有选择聊天,此微件将显示您最近的聊天。"; +"EditWidgetContactsInfo" = "显示您最重要聊天的快捷方式,以便快速访问。\n\n如果没有选择任何人,您最常用的联系人将显示在此微件中。"; +"EditedMessage" = "已编辑"; +"EditorMuteHint" = "已处于静音状态,视频将像 GIF 动态图一样自动循环播放。"; +"EditorSetCover" = "编辑封面"; +"EditorSetCoverGallery" = "从相册选择"; +"EditorSetCoverSave" = "保存封面"; +"EditorSetCoverTitle" = "选择一帧"; +"EmailAddressInvalid" = "电子邮件地址无效。"; +"EmailCopied" = "已复制邮箱地址到剪贴板。"; +"EmailLogin" = "邮箱登录"; +"EmailLoginChangeMessage" = "你每次从新的设备登入Telegram 时都会使用该电子邮箱。"; +"EmailNotAllowed" = "此电子邮件域名不允许使用。"; +"EmailPasswordConfirmText" = "请根据以下步骤以完成两步验证设置:\n\n1. 查看您的电子邮箱(不要忘记垃圾邮件文件夹)\n%1$s\n\n2. 点击验证链接。"; +"EmailPasswordConfirmText2" = "为完成两步验证设置,请查看您的电子邮箱(不要忘记检查垃圾邮件)并输入我们刚刚向您发送的验证码。"; +"EmailPasswordConfirmText3" = "为完成恢复邮箱设置,请查看 %1$s(不要忘记检查垃圾邮件)并输入我们刚刚向您发送的验证码。"; +"EmailTokenInvalid" = "此邮箱地址无效。"; +"Emoji" = "表情符号"; +"Emoji1" = "笑脸与人像"; +"Emoji2" = "动物与自然"; +"Emoji3" = "食物与饮料"; +"Emoji4" = "活动"; +"Emoji5" = "旅行与地点"; +"Emoji6" = "物品"; +"Emoji7" = "符号"; +"Emoji8" = "旗帜"; +"EmojiArchived" = "表情已归档。"; +"EmojiArchivedInfo" = "%1$s 已移至您的归档。"; +"EmojiBigSize" = "emoji 自适应缩放"; +"EmojiBotInfo" = "欢迎各位画师使用 @stickers 机器人添加自己的表情包。"; +"EmojiCopied" = "表情已拷贝至剪贴板。"; +"EmojiCount#other" = "%1$d 个表情"; +"EmojiCountButton#other" = "%1$d 个表情"; +"EmojiInteractionTapHint" = "如果 **%s** 正在查看此聊天,其也将看到此动画效果。"; +"EmojiLongtapHint" = "按住表情符号来设置定时器"; +"EmojiPackCollectibles" = "典藏品"; +"EmojiPackCount#other" = "%1$d 个表情包"; +"EmojiRemoved" = "表情已移除。"; +"EmojiRemovedInfo" = "%1$s 不再出现于您的表情中。"; +"EmojiRemovedMultipleInfo#other" = "%1$d不再存在于你的表情包中。"; +"EmojiSearchBackToSearch" = "返回搜索"; +"EmojiStatusExpireHint" = "您的状态将在 %s 过期"; +"EmojiSuggestions" = "表情建议"; +"EmojiSuggestionsInfo" = "**Telegram** 让您通过成千上万的关键字查找表情,但总会有所缺失。如果您想建议新的表情替换,可以访问以下链接来帮助我们:"; +"EmojiSuggestionsUrl" = "https://translations.telegram.org/%1$s/emoji"; +"EmojiUseDefault" = "使用系统默认表情"; +"EmpryUsersPlaceholder" = "添加用户"; +"EmptyExceptions" = "无"; +"Enable" = "启用"; +"EnableAllReactionsInfo" = "群组成员可以使用任何表情回应消息。"; +"EnableAllStreamingInfo" = "如果可能,Telegram 将立即开始播放视频和音乐,而无需等待文件下载完成。"; +"EnableAnimations" = "启用动画效果"; +"EnableAutoDelete" = "开启自动删除"; +"EnableLightMode" = "启用精简模式"; +"EnablePasscode" = "启用锁定码"; +"EnablePhotoSpoiler" = "罩住图片"; +"EnableProxyAlert" = "您确定要启用此代理吗?"; +"EnableProxyAlert2" = "您可以稍后在「设置 - 数据和存储」中修改您的代理服务器。"; +"EnableReactions" = "启用表情回应"; +"EnableReactionsChannelInfo" = "允许订阅者对频道消息作出回应。"; +"EnableReactionsGroupInfo" = "允许成员对群组消息作出表情回应。"; +"EnableSharing" = "启用分享"; +"EnableSharingActionOther" = "%s 已启用此聊天中分享功能"; +"EnableSharingActionYou" = "您已启用此聊天中分享功能"; +"EnableSomeReactionsInfo" = "群组成员仅可使用已批准的表情回应消息。"; +"EnableStealthMode" = "启用隐身模式"; +"EnableStealthModeAndOpenStory" = "启用并打开动态"; +"EnableStreaming" = "在线播放音/视频文件"; +"EnabledNotifications" = "启用通知"; +"EnabledPasswordText" = "您已启用两步验证。\n您将需要此处设置的密码以登录到您的 Telegram 帐号。"; +"EncryptedChatStartedIncoming" = "您已加入私密聊天"; +"EncryptedChatStartedOutgoing" = "%s 已加入您的私密聊天"; +"EncryptedDescription1" = "使用端到端加密"; +"EncryptedDescription2" = "不会在我们的服务器上留下任何痕迹"; +"EncryptedDescription3" = "支持阅后即焚"; +"EncryptedDescription4" = "不允许转发消息"; +"EncryptedDescriptionTitle" = "私密聊天:"; +"EncryptedPlaceholderTitleIncoming" = "%s 邀请您加入私密聊天。"; +"EncryptedPlaceholderTitleOutgoing" = "您已邀请 %s 加入私密聊天。"; +"EncryptionKey" = "加密密钥"; +"EncryptionKeyDescription" = "这是您与“**%1$s**”私密对话加密密钥的可视化图片。\n\n如果这张图片看起来和“**%2$s**”设备上的图片完全一致,端到端加密则是安全的。\n\n了解更多请前往 telegram.org"; +"EncryptionKeyLink" = "https://telegram.org/faq#secret-chats"; +"EncryptionProcessing" = "交换加密密钥中…"; +"EncryptionRejected" = "私密聊天已取消"; +"English" = "英语"; +"Enhance" = "增强"; +"EnjoyngAnimations" = "**oo** 正在观看 %1$s"; +"EnterChannelName" = "频道名称"; +"EnterCode" = "输入验证码"; +"EnterCurrentPasscode" = "请输入当前的锁定码"; +"EnterGroupNamePlaceholder" = "输入群组名称"; +"EnterListName" = "输入列表名称"; +"EnterNewEmail" = "输入新的邮箱地址"; +"EnterNewFirstPasscode" = "请输入一个锁定码"; +"EnterNewPasscode" = "请输入新的锁定码"; +"EnterPIN" = "输入您的 PIN 码"; +"EnterPINorFingerprint" = "输入 PIN 码或者使用指纹"; +"EnterPassword" = "输入密码"; +"EnterThemeName" = "输入主题名称"; +"EnterThemeNameEdit" = "如果要与其他人共享主题或手动自定义每种颜色,请点击 **创建**。\n\n请输入主题名称:"; +"EnterTopicName" = "你想要讨论什么?"; +"EnterYourPasscode" = "请输入锁定码"; +"EnterYourPasscodeInfo" = "请输入您当前的 Telegram 锁定码以管理当前选项。"; +"EnterYourTelegramPasscode" = "请输入 Telegram 锁定码"; +"ErrorOccurred" = "发生错误。"; +"ErrorRingtoneDurationTooLong" = "最长时间为 %s 秒。\u200e"; +"ErrorRingtoneInvalidFormat" = "文件必须是 mp3 或 ogg 格式。"; +"ErrorRingtoneSizeTooBig" = "大小必须在 %s KB以下。"; +"ErrorSendRestrictedDocuments" = "群组管理员已禁止你在群内发送文档。"; +"ErrorSendRestrictedDocumentsAll" = "对不起,你不能在本群内发送文档。"; +"ErrorSendRestrictedMedia" = "此群组的管理员限制了您发送媒体文件。"; +"ErrorSendRestrictedMediaAll" = "抱歉,该群组不允许发送媒体文件。"; +"ErrorSendRestrictedMusic" = "群组管理员已禁止你在群内发送音频。"; +"ErrorSendRestrictedMusicAll" = "抱歉,该群组不允许发送音乐。"; +"ErrorSendRestrictedPhoto" = "对不起,群管理员已限制您在本群发送图片消息。"; +"ErrorSendRestrictedPhotoAll" = "对不起,你不能在本群内发送图片消息。"; +"ErrorSendRestrictedPolls" = "此群组的管理员已限制您发送投票。"; +"ErrorSendRestrictedPollsAll" = "抱歉,此群组不允许发起投票。"; +"ErrorSendRestrictedPrivacyVideoMessages" = "抱歉,由于其隐私设置,您无法向该用户发送视频消息。"; +"ErrorSendRestrictedPrivacyVoiceMessages" = "抱歉,由于其隐私设置,您无法向该用户发送语音消息。"; +"ErrorSendRestrictedRound" = "群组管理员已禁止你在群内发送视频消息。"; +"ErrorSendRestrictedRoundAll" = "对不起,你不能在本群内发送视频消息。"; +"ErrorSendRestrictedStickers" = "此群组的管理员已限制您发送贴纸。"; +"ErrorSendRestrictedStickersAll" = "抱歉,该群组不允许发送贴纸。"; +"ErrorSendRestrictedTodo" = "抱歉,此群群组的管理员已限制您发送核对清单。"; +"ErrorSendRestrictedTodoAll" = "抱歉,此群组不允许发送核对清单。"; +"ErrorSendRestrictedVideo" = "对不起,群管理员已限制您在本群发送视频消息。"; +"ErrorSendRestrictedVideoAll" = "对不起,你不能在本群内发送视频消息。"; +"ErrorSendRestrictedVoice" = "群组管理员已禁止你在群内发送语音消息。"; +"ErrorSendRestrictedVoiceAll" = "抱歉,该群组不允许发送语音消息。"; +"EventLog" = "近期操作"; +"EventLogActivatedUsername" = "un1 已激活 %1$s 用户名"; +"EventLogAdded" = "un1 已将 un2 加入群组"; +"EventLogAddedMedia" = "un1 已添加媒体:"; +"EventLogAllAdmins" = "所有管理员"; +"EventLogAllEvents" = "所有操作"; +"EventLogChangeUsernames" = "un1 将群组名称从 %1$s 更改为 %2$s"; +"EventLogChangedChannelLink" = "un1 更换了频道链接:"; +"EventLogChangedColor" = "un1 已将频道颜色从 %1$s 更改为 %2$s"; +"EventLogChangedColorGroup" = "un1 已将群组颜色由 %1$s 更改为 %2$s"; +"EventLogChangedEmoji" = "un1 已将频道的表情符号从 %1$s 更改为 %2$s"; +"EventLogChangedEmojiGroup" = "un1 已将群组表情从 %1$s 更改为 %2$s"; +"EventLogChangedEmojiPack" = "un1 已更改群组表情包"; +"EventLogChangedEmojiStatus" = "un1 已将频道表情符号状态更改为 %2$s"; +"EventLogChangedEmojiStatusFor" = "un1 已将频道表情状态更改为 %2$s (针对 %3$s)"; +"EventLogChangedEmojiStatusForGroup" = "un1 已将群组表情符号状态更改为 %2$s(用于 %3$s)"; +"EventLogChangedEmojiStatusFrom" = "un1 已将频道表情状态从 %1$s 更改为 %2$s"; +"EventLogChangedEmojiStatusFromFor" = "un1 已将频道表情状态由 %1$s 改为 %2$s(持续 %3$s)"; +"EventLogChangedEmojiStatusFromForGroup" = "un1 已将群组表情符号状态从 %1$s 更改为 %2$s (用于 %3$s)"; +"EventLogChangedEmojiStatusFromGroup" = "un1 将群组表情状态从 %1$s 更改为 %2$s"; +"EventLogChangedEmojiStatusGroup" = "un1 已将群组的表情符号状态改为 %2$s"; +"EventLogChangedGroupLink" = "un1 已更改群组链接:"; +"EventLogChangedLinkedChannel" = "un1 已将 un2 关联到本群组"; +"EventLogChangedLinkedGroup" = "un1 已将 un2 设置为频道的讨论群组"; +"EventLogChangedLocation" = "un1 已将群组位置更改为 “%1$s”"; +"EventLogChangedOwnership" = "转让所有权给 %1$s"; +"EventLogChangedPeerColorIcon" = "un1 已将频道颜色及图标从 %1$s 更改为 %2$s"; +"EventLogChangedPeerColorIconGroup" = "un1 已将群组颜色和图标从 %1$s 更改为 %2$s"; +"EventLogChangedProfileColorIcon" = "un1 将频道资料页颜色和图标从 %1$s 更改为 %2$s"; +"EventLogChangedProfileColorIconGroup" = "un1 将群组资料页颜色和图标从 %1$s 更改为 %2$s"; +"EventLogChangedStickersSet" = "un1 已更改群组贴纸包"; +"EventLogChangedWallpaper" = "un1 已更改频道壁纸"; +"EventLogChangedWallpaperGroup" = "un1 已更换群组壁纸"; +"EventLogChannelJoined" = "un1 已加入频道"; +"EventLogChannelRestricted" = "已封禁 %1$s"; +"EventLogChannelUnrestricted" = "已将 %1$s 解除封禁"; +"EventLogClosedTopic" = "%s 已关闭话题 un2"; +"EventLogCreateTopic" = "un1 已创建话题 un2"; +"EventLogDeactivatedUsername" = "un1 已停用 %1$s 用户名"; +"EventLogDefaultPermissions" = "默认权限已修改"; +"EventLogDeleteTopic" = "un1 删除话题 un2"; +"EventLogDeletedMessages" = "un1 已删除这条消息:"; +"EventLogDeletedMultipleMessages#other" = "un1 已删除 %2$s 发送的 %1$d 条消息:"; +"EventLogDeletedMultipleMessagesHide" = "全部隐藏"; +"EventLogDeletedMultipleMessagesShow" = "全部显示"; +"EventLogDeletedMultipleMessagesToExpand#other" = "un1 已删除 %2$s 发送的 %1$d 条消息"; +"EventLogDisabledAntiSpam" = "un1 已停用反垃圾措施"; +"EventLogEditTopic" = "un1 将话题名称从 un2 更改为 un3"; +"EventLogEditedCaption" = "un1 已编辑说明:"; +"EventLogEditedChannelDescription" = "un1 已修改频道简介:"; +"EventLogEditedChannelPhoto" = "un1 设置了新的频道头像"; +"EventLogEditedChannelTheme" = "un1改变了频道的主题:"; +"EventLogEditedChannelTitle" = "un1 将频道名称更改为«%1$s»"; +"EventLogEditedChannelVideo" = "un1 已设置新的频道图片"; +"EventLogEditedGroupDescription" = "un1 已编辑群组简介:"; +"EventLogEditedGroupPhoto" = "un1 已设置新的群组头像"; +"EventLogEditedGroupTheme" = "un1 已更改群组主题:"; +"EventLogEditedGroupTitle" = "un1 已将群组名称更改为 “%1$s”"; +"EventLogEditedGroupVideo" = "un1 已设置新的群组图片"; +"EventLogEditedMedia" = "un1 更换了媒体文件:"; +"EventLogEditedMediaCaption" = "un1 已编辑媒体文件并修改说明:"; +"EventLogEditedMessages" = "un1 已编辑这条消息:"; +"EventLogEmojiNone" = "无"; +"EventLogEmpty" = "**暂无操作记录**\n\n在过去的 48 小时内\n群组成员/管理员没有执行任何操作。"; +"EventLogEmpty2" = "**暂无任何操作**\n\n该群组的成员和管理员在过去 **48** 小时内没有进行任何服务操作。"; +"EventLogEmptyChannel" = "**暂无操作记录**\n\n在过去的 48 小时内\n频道管理员没有执行\n任何值得注意的操作。"; +"EventLogEmptyChannel2" = "**暂无任何操作**\n\n该频道的管理员在过去 **48** 小时内没有进行任何服务操作。"; +"EventLogEmptySearch" = "**未找到操作记录**\n\n近期操作中找不到与您查询请求\n相符的结果。"; +"EventLogEmptyTextSearch" = "找不到包含“**%1$s**”的近期操作。"; +"EventLogEnabledAntiSpam" = "un1 已启用反垃圾措施"; +"EventLogEndedLiveStream" = "un1 已结束直播"; +"EventLogEndedVoiceChat" = "un1 已结束视频聊天"; +"EventLogExpandMore#other" = "显示其他 %d 条消息"; +"EventLogFilterAll" = "所有操作"; +"EventLogFilterApply" = "应用筛选器"; +"EventLogFilterByActions" = "按类型筛选操作"; +"EventLogFilterByAdmins" = "按管理员筛选操作"; +"EventLogFilterByAdminsAll" = "显示所有管理员的操作"; +"EventLogFilterCalls" = "视频聊天"; +"EventLogFilterChannelInfo" = "频道信息"; +"EventLogFilterDeletedMessages" = "已删除消息"; +"EventLogFilterEditedMessages" = "已编辑消息"; +"EventLogFilterGroupInfo" = "群组信息"; +"EventLogFilterInvites" = "邀请链接"; +"EventLogFilterLeavingMembers" = "成员退出"; +"EventLogFilterLeavingMembers2" = "成员离开群组"; +"EventLogFilterLeavingSubscribers2" = "订阅者已离开此频道"; +"EventLogFilterMembersRank" = "成员标签更新"; +"EventLogFilterNewAdmins" = "管理员权限"; +"EventLogFilterNewMembers" = "新成员"; +"EventLogFilterNewRestrictions" = "新建例外"; +"EventLogFilterNewSubscribers" = "新的订阅者"; +"EventLogFilterPinnedMessages" = "置顶消息"; +"EventLogFilterSectionAdmin" = "新增管理员权限"; +"EventLogFilterSectionChannelSettings" = "频道设置"; +"EventLogFilterSectionGroupSettings" = "群组设置"; +"EventLogFilterSectionMembers" = "成员与管理员"; +"EventLogFilterSectionMessages" = "消息"; +"EventLogFilterSectionSubscribers" = "订阅者和管理员"; +"EventLogGroupJoined" = "un1 已加入群组"; +"EventLogInfoDetail" = "此列表包含了在过去 48 小时内群组成员和管理员执行的操作。"; +"EventLogInfoDetailChannel" = "此列表包含了在过去 48 小时内频道管理员执行的所有操作。"; +"EventLogInfoTitle" = "近期操作是什么?"; +"EventLogLeftChannel" = "un1 已退出频道"; +"EventLogLeftGroup" = "un1 已退出群组"; +"EventLogOriginalCaption" = "原说明"; +"EventLogOriginalCaptionEmpty" = "< 空 >"; +"EventLogOriginalMessages" = "原消息"; +"EventLogPinTopic" = "un1 置顶话题 un2"; +"EventLogPinnedMessages" = "un1 已置顶这条消息:"; +"EventLogPreviousGroupDescription" = "之前的简介"; +"EventLogPreviousGroupTheme" = "上一个主题"; +"EventLogPreviousLink" = "之前的链接"; +"EventLogPromoted" = "已修改 %1$s 的权限"; +"EventLogPromotedAddAdmins" = "添加管理员"; +"EventLogPromotedAddUsers" = "添加用户"; +"EventLogPromotedBanUsers" = "封禁用户"; +"EventLogPromotedChangeChannelInfo" = "修改频道信息"; +"EventLogPromotedChangeGroupInfo" = "编辑群组信息"; +"EventLogPromotedDeleteMessages" = "删除消息"; +"EventLogPromotedDeleteStories" = "删除他人的动态"; +"EventLogPromotedEditMessages" = "编辑消息"; +"EventLogPromotedEditRanks" = "编辑标签"; +"EventLogPromotedEditStories" = "编辑他人的动态"; +"EventLogPromotedManageCall" = "管理视频聊天"; +"EventLogPromotedManageDirect" = "管理私信"; +"EventLogPromotedManageTopics" = "管理话题"; +"EventLogPromotedNoRights" = "已将 %1$s 设为管理员"; +"EventLogPromotedPinMessages" = "置顶消息"; +"EventLogPromotedPostMessages" = "发布消息"; +"EventLogPromotedPostStories" = "发布动态"; +"EventLogPromotedRemovedTitle" = "标题"; +"EventLogPromotedSendAnonymously" = "保持匿名"; +"EventLogPromotedTitle" = "标题:%1$s"; +"EventLogRankAdd" = "un1 已将 un2 的标签设置为 “%1$s”"; +"EventLogRankEdit" = "un1 已将 un2 的标签从 “%1$s” 更改为 “%2$s”"; +"EventLogRankRemove" = "un1 删除了 un2 的 “%1$s” 标签"; +"EventLogRankSelfAdd" = "un1 已将自己的标签设置为 “%1$s”"; +"EventLogRankSelfEdit" = "un1 已将自己的标签从 “%1$s” 更改为 “%2$s”"; +"EventLogRankSelfRemove" = "un1 移除了自己的 “%1$s” 标签"; +"EventLogRemovedChannelLink" = "un1 移除了频道链接"; +"EventLogRemovedChannelPhoto" = "un1 移除了频道头像"; +"EventLogRemovedEmojiPack" = "un1 已移除群组的表情包"; +"EventLogRemovedGroupLink" = "un1 已移除群组链接"; +"EventLogRemovedLinkedChannel" = "un1 已将此群组与 un2 取消关联"; +"EventLogRemovedLinkedGroup" = "un1 已移除讨论群组 un2"; +"EventLogRemovedLocation" = "un1 已移除群组位置"; +"EventLogRemovedStickersSet" = "un1 已删除群组贴纸包"; +"EventLogRemovedWGroupPhoto" = "un1 已移除群组头像"; +"EventLogRemovedWallpaper" = "un1 已移除频道壁纸"; +"EventLogRemovedWallpaperGroup" = "un1 已移除群组壁纸"; +"EventLogReopenedTopic" = "%s 已重启话题 un2"; +"EventLogRestrictedChangeInfo" = "修改信息"; +"EventLogRestrictedEditRank" = "编辑标签"; +"EventLogRestrictedInviteUsers" = "添加用户"; +"EventLogRestrictedPinMessages" = "置顶消息"; +"EventLogRestrictedReadMessages" = "查看消息"; +"EventLogRestrictedSendEmbed" = "嵌入链接预览"; +"EventLogRestrictedSendMedia" = "发送媒体文件"; +"EventLogRestrictedSendMessages" = "发送消息"; +"EventLogRestrictedSendPolls" = "发起投票"; +"EventLogRestrictedSendReactions" = "作出回应"; +"EventLogRestrictedSendStickers" = "发送贴纸与 GIF"; +"EventLogRestrictedUntil" = "修改了 %1$s 的权限\n\n持续时间:%2$s"; +"EventLogSelectedEvents" = "选中的操作"; +"EventLogSendMessages" = "un1 已发送该消息:"; +"EventLogStartedLiveStream" = "un1 已开始直播"; +"EventLogStartedVoiceChat" = "un1 已发起视频聊天"; +"EventLogStopPoll" = "un1 已停止此投票:"; +"EventLogStopQuiz" = "un1 结束了问答:"; +"EventLogSubExtend" = "un1 已续订。"; +"EventLogSwitchToForum" = "un1 已启用话题"; +"EventLogSwitchToGroup" = "un1 已停用话题"; +"EventLogToggledAutotranslationOff" = "un1 已禁用自动翻译"; +"EventLogToggledAutotranslationOn" = "un1 已启用自动翻译"; +"EventLogToggledInvitesHistoryOff" = "un1 已设置消息记录对新成员不可见"; +"EventLogToggledInvitesHistoryOn" = "un1 已将消息记录设置为对新成员可见"; +"EventLogToggledInvitesOff" = "un1 已停用群组邀请"; +"EventLogToggledInvitesOn" = "un1 已开启群组成员的邀请权限"; +"EventLogToggledSignaturesOff" = "un1 已停用消息署名"; +"EventLogToggledSignaturesOn" = "un1 开启了消息署名"; +"EventLogToggledSignaturesProfilesOff" = "un1 已停用作者资料"; +"EventLogToggledSignaturesProfilesOn" = "un1 已启用作者资料"; +"EventLogToggledSlowmodeOff" = "un1 已停用慢速模式"; +"EventLogToggledSlowmodeOn" = "un1 将慢速模式的时间改为 %1$s"; +"EventLogUnpinTopic" = "un1 取消置顶话题 un2"; +"EventLogUnpinnedMessages" = "un1 已将一条消息取消置顶"; +"EventLogVoiceChatAllowedToSpeak" = "un1 已允许新视频聊天参与者发言"; +"EventLogVoiceChatMuted" = "un1 已在视频聊天中将 un2 静音"; +"EventLogVoiceChatNotAllowedToSpeak" = "un1 已将新加入视频聊天的参与者静音"; +"EventLogVoiceChatUnmuted" = "un1 在视频聊天中已取消 un2 的静音"; +"Events" = "事件"; +"Everyone" = "所有人"; +"ExactTextCopied" = "\"%s\" 已复制到剪贴板"; +"Exception#other" = "%1$d个例外"; +"ExceptionShort#other" = "%d 条例外"; +"ExpireAfter" = "到期于"; +"Expired" = "已失效"; +"ExpiredLink" = "失效的链接"; +"ExpiredStory" = "过期的动态"; +"ExpiredStoryMention" = "提到您的动态已无法访问"; +"ExpiredStoryMentioned" = "您提到 %s 的动态已无法访问"; +"ExpiredViewsStub" = "访客名单将在动态过期 **24 小时**后不可见。"; +"ExpiredViewsStubPremiumDescription" = "如要解锁已过期和已发布动态的访客列表,请订阅 **Telegram 高级版**。"; +"ExplainStarsButton" = "了解"; +"ExplainStarsFeature1Text" = "为您的朋友赠送礼物,这些礼物可以添加至他们的个人资料页或转换为星星。"; +"ExplainStarsFeature1Title" = "为好友送礼"; +"ExplainStarsFeature2Text" = "在 Telegram 小程序中购买额外的內容和服务。 **请查看示例 >**"; +"ExplainStarsFeature2Title" = "在小程序使用星星"; +"ExplainStarsFeature3Text" = "可以访问 Telegram 频道中的付费內容和服务。"; +"ExplainStarsFeature3Title" = "解锁频道中的内容"; +"ExplainStarsFeature4Text" = "通过向您所喜欢的频道的贴文作出星星回应来支持它们。"; +"ExplainStarsFeature4Title" = "作出星星回应"; +"ExplainStarsTitle" = "什么是星星?"; +"ExplainStarsTitle2" = "Telegram 星星允许用户:"; +"ExportTheme" = "导出"; +"Exposure" = "曝光"; +"ExternalFolderInfo" = "浏览外部存储"; +"ExternalStorage" = "外部存储器"; +"FactCheck" = "事实核查"; +"FactCheckDeleted" = "已移除事实核查。"; +"FactCheckDialog" = "事实核查"; +"FactCheckEdited" = "已更新事实核查。"; +"FactCheckFooter" = "此澄清是由 Telegram 提供的。"; +"FactCheckPlaceholder" = "添加事实或背景"; +"FactCheckToast" = "此澄清是由 Telegram 提供的。"; +"FactCheckWhat" = "这是什么?"; +"Fade" = "褪色"; +"FailedStory" = "上传失败"; +"FailedToUploadStory" = "上传失败"; +"FakeMessage" = "假冒"; +"FallbackTooltip" = "公开图片"; +"FavoriteStickers" = "收藏"; +"FavoriteStickersLimitSubtitle" = "在贴纸收藏中存储高达 %d 张贴纸"; +"FavoriteStickersLimitTitle" = "贴纸收藏"; +"FavoriteStickersShort" = "收藏"; +"FeaturePreviewGifts" = "Telegram 礼物"; +"FeaturePreviewGiftsDescription" = "礼物是可以交易或在个人资料页上展示的典藏品。"; +"FeaturedEmojiPacks" = "热门表情包"; +"FeaturedGifs" = "流行 GIF"; +"FeaturedStickers" = "热门表情"; +"FeaturedStickersInfo" = "这些是当前 Telegram 的热门表情,您可以通过机器人 @stickers 添加自定义表情。"; +"FeaturedStickersPremium" = "热门高级版贴纸"; +"FeaturedStickersShort" = "热门"; +"FeaturesBtn" = "功能"; +"February" = "二月"; +"FileSavedHint" = "文件已保存至下载文件夹。"; +"FileSavedHintLinked" = "已将文件保存至 **下载文件夹**。"; +"FileTooLarge" = "文件过大"; +"FileUploadLimit" = "文件大小不能超过 %1$s"; +"Files#other" = "%1$d 个文件"; +"FilesDataUsage" = "文件"; +"FilesReceived" = "已接收文件"; +"FilesReceivedCount#other" = "**%s** 个文件已接收"; +"FilesSavedHint#other" = "%1$d 个文件已保存至下载文件夹。"; +"FilesSavedHintLinked#other" = "已将 %1$d 个文件保存至 **下载文件夹**。"; +"FilesSent" = "已发送文件"; +"FilesSentCount#other" = "**%s** 个文件已发送"; +"FilterAddChats" = "添加聊天"; +"FilterAddException" = "添加例外"; +"FilterAddTo" = "添加至文件夹"; +"FilterAddToAlertFullText" = "抱歉,您不能从一个文件夹中排除超过 100 个聊天。"; +"FilterAddToAlertFullTitle" = "已达上线"; +"FilterAddingChats" = "正在添加聊天"; +"FilterAddingChatsInfo" = "我们正在为您添加聊天到此文件夹,请稍候…"; +"FilterAllChats" = "所有聊天"; +"FilterAllChatsShort" = "所有"; +"FilterAlwaysShow" = "包含聊天"; +"FilterArchived" = "已归档"; +"FilterAvailableText" = "打开 *设置 > 文件夹* 将您的聊天整理到文件夹中。"; +"FilterAvailableTitle" = "聊天文件夹"; +"FilterBots" = "机器人"; +"FilterChannels" = "频道"; +"FilterChatAddedToExisting" = "已将 **%1$s** 添加到 **%2$s**。"; +"FilterChatRemovedFrom" = "已从 **%2$s** 中移除 **%1$s**。"; +"FilterChatTypes" = "聊天类型"; +"FilterChats" = "聊天"; +"FilterChatsAddedToExisting" = "已将 **%1$s** 添加到 **%2$s**。"; +"FilterChatsRemovedFrom" = "已从 **%2$s** 中移除 **%1$s**。"; +"FilterChoose" = "选择一个文件夹"; +"FilterContact" = "联系人"; +"FilterContacts" = "联系人"; +"FilterDelete" = "删除文件夹"; +"FilterDeleteAlert" = "您确认删除此文件夹吗?您的聊天记录将不会被删除。"; +"FilterDeleteAlertLinks" = "您确定要删除此文件夹吗?这将同时停用所有用来分享此文件夹的邀请链接。"; +"FilterDeleteItem" = "删除文件夹"; +"FilterDiscardAlert" = "您已编辑此文件夹。是否应用更改?"; +"FilterDiscardNewAlert" = "您还没完成创建文件夹,现在创建吗?"; +"FilterDiscardNewSave" = "创建"; +"FilterDiscardNewTitle" = "创建文件夹"; +"FilterDiscardTitle" = "未保存的更改"; +"FilterEdit" = "编辑文件夹"; +"FilterEditAll" = "编辑文件夹"; +"FilterEditItem" = "编辑文件夹"; +"FilterExclude" = "排除的聊天"; +"FilterExcludeArchived" = "排除已归档的"; +"FilterExcludeInfo" = "选择不在此文件夹出现的聊天或聊天类型。"; +"FilterExcludeMuted" = "排除已静音的"; +"FilterExcludeRead" = "排除已读"; +"FilterExistingChats" = "现有聊天"; +"FilterFinishCreating" = "请先创建此文件夹,然后再分享。"; +"FilterGroups" = "群组"; +"FilterInclude" = "包含的聊天"; +"FilterIncludeInfo" = "选择将出现在此文件夹的聊天和聊天类型。"; +"FilterInviteBot" = "无法分享与机器人的聊天"; +"FilterInviteBotToast" = "您无法通过文件夹分享机器人。"; +"FilterInviteChannel" = "您无法邀请用户加入"; +"FilterInviteChannelToast" = "您没有管理员权限,无法分享此频道的邀请链接。"; +"FilterInviteChats#other" = "包括 %d 个聊天"; +"FilterInviteEditName" = "为此链接命名"; +"FilterInviteErrorEmpty" = "您无法分享空文件夹。"; +"FilterInviteErrorEmptyName" = "您无法分享没有名字的文件夹。"; +"FilterInviteErrorExcluded" = "您无法分享排除含有“排除的聊天”的文件夹。"; +"FilterInviteErrorTypes" = "您不能分享包含特定聊天类别(比如“群组”、“联系人”等)的分组。"; +"FilterInviteErrorTypesExcluded" = "您无法分享使用“聊天类型”或“排除的聊天”进行筛选的文件夹"; +"FilterInviteGroup" = "您无法邀请用户加入"; +"FilterInviteGroupToast" = "您没有管理员权限,无法分享此群组的邀请链接。"; +"FilterInviteHeader#other" = "获得此链接的所有人都可以添加 **%2$s** 文件夹并加入以下选择的 **%1$d 个**聊天。"; +"FilterInviteHeaderChats#other" = "已选择 %d 个聊天"; +"FilterInviteHeaderChatsEmpty" = "没有选择任何聊天"; +"FilterInviteHeaderChatsNo" = "无法分享这些聊天"; +"FilterInviteHeaderNo" = "该文件夹中没有您可以与他人分享的聊天。"; +"FilterInviteHint" = "所有通过此链接添加文件夹的人都能加入您选择的聊天。"; +"FilterInviteHintNo" = "您只能分享允许您创建邀请链接的群组和频道。"; +"FilterInviteLinks" = "邀请链接"; +"FilterInviteLinksHint" = "创建更多链接,为不同的人设置不同的访问级别。"; +"FilterInviteLinksHintNew" = "与其他人分享此文件夹中的群组和频道的访问权。"; +"FilterInviteNameEdited" = "链接名称已保存。"; +"FilterInvitePrivateChannelToast" = "您没有管理员权限,无法分享此私密频道的邀请链接。"; +"FilterInvitePrivateGroupToast" = "您没有管理员权限,无法分享此私密群组的邀请链接。"; +"FilterInviteUser" = "您无法分享与用户的聊天"; +"FilterInviteUserToast" = "您无法使用文件夹分享用户。"; +"FilterMuteAll" = "全部静音"; +"FilterMuted" = "已静音"; +"FilterNameAnimationsDisable" = "禁用动画"; +"FilterNameAnimationsEnable" = "启用动画"; +"FilterNameHeader" = "文件夹名称"; +"FilterNameHint" = "文件夹名称"; +"FilterNameNonMuted" = "未静音"; +"FilterNameUnread" = "未读"; +"FilterNeverShow" = "排除聊天"; +"FilterNew" = "新文件夹"; +"FilterNewChats" = "新聊天"; +"FilterNoChats" = "无聊天"; +"FilterNoChatsToDisplay" = "文件夹为空"; +"FilterNoChatsToDisplayInfo" = "该文件夹内暂无聊天。"; +"FilterNoChatsToForward" = "未找到聊天"; +"FilterNoChatsToForwardInfo" = "此文件夹中没有可转发的聊天。"; +"FilterNonContact" = "非联系人"; +"FilterNonContacts" = "非联系人"; +"FilterRead" = "已读"; +"FilterRecommended" = "推荐的文件夹"; +"FilterRemoveChats" = "添加以排除聊天"; +"FilterRemoveExclusionChatText" = "你确定要从排除的聊天中移除 “%1$s” 吗?"; +"FilterRemoveExclusionText" = "您确定要从排除的对话中移除“%1$s ”吗?"; +"FilterRemoveExclusionTitle" = "移除例外"; +"FilterRemoveExclusionUserText" = "你确定要从排除的对话中移除 “%1$s” 吗?"; +"FilterRemoveFrom" = "从文件夹移除"; +"FilterRemoveFromAlertFullText" = "抱歉,您的文件夹不能添加超过 100 个聊天。"; +"FilterRemoveInclusionChatText" = "您确定要从包含的对话中移除“ %1$s ”吗?"; +"FilterRemoveInclusionText" = "您确定要从包含的聊天中移除“ %1$s ”吗?"; +"FilterRemoveInclusionTitle" = "移除例外"; +"FilterRemoveInclusionUserText" = "您确定要从包含的聊天中移除“ %1$s ”吗?"; +"FilterReorder" = "重新排序"; +"FilterSecretChats" = "私密聊天"; +"FilterShare" = "分享文件夹"; +"FilterShareFolder" = "分享文件夹"; +"FilterShareFolderButton" = "创建邀请链接"; +"FilterShowMoreChats#other" = "显示其他 %1$d 个聊天"; +"FilterStories" = "动态"; +"FilterUnmuteAll" = "全部取消静音"; +"FilterUnread" = "未读"; +"FilterUserAddedToExisting" = "已将 **%1$s** 添加到 **%2$s**。"; +"FilterUserRemovedFrom" = "已从 **%2$s** 中移除 **%1$s**。"; +"Filters" = "聊天文件夹"; +"FiltersSetup" = "设定"; +"FiltersSetupPinAlert" = "设置文件夹"; +"FinalResults" = "最终结果"; +"FingerprintHelp" = "请按下指纹"; +"FingerprintInfo" = "请验证指纹"; +"FingerprintNotRecognized" = "未能识别指纹,请重试。"; +"FirstName" = "名字(必填)"; +"FirstNameSmall" = "名字"; +"FlashIntensity" = "图像强度"; +"FlashWarmth" = "色温"; +"Flip" = "翻转"; +"FloatingDebugDisable" = "停用浮动调试"; +"FloatingDebugEnable" = "启用浮动调试"; +"FloodWait" = "尝试次数过多,请稍后再试。"; +"FloodWaitTime" = "尝试次数过多,请在 %1$s 后重试"; +"Folder" = "文件夹"; +"FolderLinkAddedChats#other" = "已将 %d 个聊天添加至文件夹"; +"FolderLinkAddedSubtitle#other" = "您还加入了 %d 个聊天。"; +"FolderLinkAddedTitle" = "已添加 **%s** 文件夹"; +"FolderLinkAlreadyJoined" = "您已经是此群组的成员了。"; +"FolderLinkAlreadySubscribed" = "您已经订阅了此频道。"; +"FolderLinkButtonAdd" = "添加 %s"; +"FolderLinkButtonJoin" = "加入聊天"; +"FolderLinkButtonJoinPlural#other" = "加入聊天"; +"FolderLinkButtonNone" = "不加入任何聊天"; +"FolderLinkButtonRemove" = "移除文件夹"; +"FolderLinkButtonRemoveChats" = "移除文件夹和聊天"; +"FolderLinkChatlistUpdate" = "这不会影响该文件夹链接中的聊天列表。"; +"FolderLinkDeleted" = "已删除 **%s** 文件夹。"; +"FolderLinkDeletedSubtitle#other" = "您还退出了 %d 个聊天。"; +"FolderLinkDeletedTitle" = "已删除 %s 文件夹"; +"FolderLinkHeaderAlready" = "您已加入的聊天"; +"FolderLinkHeaderChatsJoin#other" = "即将加入 %d 个聊天"; +"FolderLinkHeaderChatsQuit#other" = "即将退出 %d 个聊天"; +"FolderLinkHint" = "您可以取消选择不想加入的聊天。"; +"FolderLinkHintRemove" = "您可以选择想要退出的聊天。"; +"FolderLinkNoChatsError" = "你无权共享这些聊天的任何一个"; +"FolderLinkOtherAdminLimitError" = "无法将其中一个群组添加至文件夹中,因为其中一位管理员加入了过多群组和频道。"; +"FolderLinkPreviewLeft" = "所有聊天"; +"FolderLinkPreviewRight" = "个人"; +"FolderLinkRemovedChats#other" = "已从文件夹中移除 %d 个聊天"; +"FolderLinkShareButton" = "创建邀请链接"; +"FolderLinkShareSubtitle" = "您可以创建更多链接,为不同的人设置不同的访问级别。"; +"FolderLinkShareSubtitleEmpty" = "与其他人分享此文件夹中的群组和频道的访问权。"; +"FolderLinkShareTitle" = "分享文件夹 %s"; +"FolderLinkShareTitle2" = "分享文件夹 %1$s"; +"FolderLinkSubtitle" = "您是否要添加聊天文件夹 **%s**, 并加入其中的群组和频道?"; +"FolderLinkSubtitleAlready" = "您已经添加过 **%s** 文件夹以及其中的所有聊天了。"; +"FolderLinkSubtitleChats#other" = "您是否要加入 **%1$d 个聊天**,并将其添加至 **%2$s** 文件夹?"; +"FolderLinkSubtitleRemove" = "您是否要同时退出此文件夹中包括的聊天?"; +"FolderLinkTitleAdd" = "添加文件夹"; +"FolderLinkTitleAddChats" = "将聊天添加至文件夹"; +"FolderLinkTitleAlready" = "文件夹已添加"; +"FolderLinkTitleRemove" = "移除文件夹"; +"FolderLinkUpdatedJoinedSubtitle#other" = "您已加入 %d 个新聊天。"; +"FolderLinkUpdatedSubtitle#other" = "您已添加 %d 个新聊天。"; +"FolderLinkUpdatedTitle" = "已更新 **%s** 文件夹"; +"FolderShowTags" = "显示文件夹标签"; +"FolderShowTagsInfo" = "显示聊天列表中每个聊天文件夹名称。"; +"FolderShowTagsInfoPremium" = "订阅 **Telegram 高级版** 以显示聊天列表中每个聊天的文件夹名称。"; +"FolderTagColor" = "聊天列表中的文件夹颜色"; +"FolderTagColorInfo" = "为此文件夹的标签选择颜色。"; +"FolderTagNoColor" = "无标签"; +"FolderTagNoColorPremium" = "高级版已过期"; +"FolderUpdatesSubtitle#other" = "点击此处查看"; +"FolderUpdatesTitle#other" = "您可以加入 **%d 个新聊天**"; +"FoldersLimitSubtitle" = "将您的聊天整理到 %d 个文件夹中"; +"FoldersLimitTitle" = "文件夹"; +"FollowersChartTitle" = "订阅者"; +"FontSize" = "字体大小"; +"FontSizePreviewLine1" = "你知道现在几点吗?"; +"FontSizePreviewLine2" = "现在是东京的早晨😎"; +"FontSizePreviewName" = "鲍勃·哈里斯"; +"FontSizePreviewReply" = "早上好!👋"; +"FontType" = "字体"; +"FootsAway" = "相距%1$s英尺"; +"FootsFromYou" = "距您 %1$s 英尺"; +"FootsShort" = "%1$s 英尺"; +"ForceSetPasswordAlertMessage#other" = "确定不使用密码吗?如果您不设置密码,您在 %1$s 天内只能使用短信登录一次。"; +"ForceSetPasswordAlertMessageShort#other" = "如果您未设置密码,每%1$s天您只能通过短信登录账户一次。是否继续设置密码?"; +"ForceSetPasswordCancel" = "是的,暂时不设置密码"; +"ForceSetPasswordContinue" = "不,让我设置一个密码。"; +"ForgotPasscode" = "忘记了锁定码?"; +"ForgotPasscodeInfo" = "如果您忘记了锁定码,请删除 Telegram 应用并重新安装。\n\n所有私密聊天将会丢失。"; +"ForgotPassword" = "是否忘记密码?"; +"FormattedDate" = "日期"; +"ForumReplyToMessagesInTopic" = "左划消息以回复"; +"ForumToggleDescription" = "群聊将被划分为由管理员或用户创建的话题"; +"Forward" = "转发"; +"ForwardAnotherChat" = "转发至其他聊天"; +"ForwardDialogYourChannel" = "您的频道"; +"ForwardGroupMedia" = "转发媒体"; +"ForwardGroupMediaMessage" = "您需要转发..."; +"ForwardPreviewSendersNameHidden" = "%s 无法看到被转发记录"; +"ForwardPreviewSendersNameHiddenChannel" = "接收者将不会看到此条为转发消息"; +"ForwardPreviewSendersNameHiddenGroup" = "接收者不会看到这是被转发的消息"; +"ForwardPreviewSendersNameVisible" = "%s 会看到这是一条转发消息"; +"ForwardPreviewSendersNameVisibleChannel" = "接收者将看到此条为转发消息"; +"ForwardPreviewSendersNameVisibleGroup" = "接收者将看到 “转发的消息” 标识"; +"ForwardSendMessages" = "发送消息"; +"ForwardTo" = "转发到…"; +"ForwardedAudio#other" = "%1$d 条转发的语音消息"; +"ForwardedContact#other" = "%1$d 个被转发的联系人信息"; +"ForwardedFile#other" = "%1$d 个转发的文件"; +"ForwardedFrom" = "转发自"; +"ForwardedLocation#other" = "%1$d 个转发的位置"; +"ForwardedMessage" = "转发的消息"; +"ForwardedMessageCount#other" = "%1$d 条转发的消息"; +"ForwardedMusic#other" = "%1$d 个转发的音频文件"; +"ForwardedPhoto#other" = "%1$d 张转发的图片"; +"ForwardedPoll#other" = "%1$d 个转发的投票"; +"ForwardedRound#other" = "%1$d 条转发的视频消息"; +"ForwardedSticker#other" = "%1$d 个转发的表情"; +"ForwardedStory" = "已转发的动态"; +"ForwardedStoryFrom" = "来自 **%s**"; +"ForwardedVideo#other" = "%1$d 段转发的视频"; +"ForwardingFromNameAndMessage" = "%s:%s"; +"ForwardingFromNames" = "来自%s"; +"ForwardsRestrictedInfoBot" = "本聊天不允许复制和转发消息。"; +"ForwardsRestrictedInfoChannel" = "该频道不允许拷贝和转发。"; +"ForwardsRestrictedInfoGroup" = "该群组不允许拷贝和转发。"; +"ForwardsRestrictedInfoUser" = "此用户无权复制和转发。"; +"ForwardsRestrictedInfoUserBecauseUser" = "**%s** 已禁用此聊天中的复制和转发功能。"; +"ForwardsRestrictedInfoUserBecauseYou" = "您已禁用此聊天中的复制和转发功能。"; +"FoundStories#other" = "%d 个动态"; +"Fragment" = "Fragment"; +"FragmentAddFunds" = "您可以通过第三方平台 Fragment 充值 Gram。"; +"FragmentChannelUsername" = "用户名 **%1$s** 已于 %2$s 在**Fragment** 上花费 **%3$s** %4$s 取得。"; +"FragmentPhoneCopy" = "复制电话号码"; +"FragmentPhoneMessage" = "该电话号码于 %1$s 在**Fragment** 上花费 **%2$s** %3$s 获取"; +"FragmentPhoneTitle" = "**%s** 是一个可获取的电话号码,其属于"; +"FragmentUsernameCopy" = "复制链接"; +"FragmentUsernameMessage" = "该用户名于 %1$s 在 **Fragment** 上花费 **%2$s** %3$s 获取。"; +"FragmentUsernameOpen" = "了解更多"; +"FragmentUsernameTitle" = "**%s** 是一个典藏用户名,属于"; +"FreeOfTotal" = "%1$s 可用,共 %2$s"; +"FreshChangePhoneForbidden" = "出于安全考量,您无法从刚刚连接的设备上更改手机号码。 请使用较早一些时候登录的设备或等待几个小时。"; +"FreshChangePhoneForbiddenTitle" = "无法终止"; +"From" = "来自"; +"FromCamera" = "拍照"; +"FromChats#other" = "来自 %1$d 个聊天"; +"FromFormatted" = "来自 %1$s"; +"FromGalley" = "从相册中选择"; +"FromYou" = "您"; +"FwdMessageToChats" = "消息已转发到 **%1$s**。"; +"FwdMessageToGroup" = "消息已转发到 **%1$s**。"; +"FwdMessageToManyChats#other" = "消息已转发至 **%1$d 个聊天**。"; +"FwdMessageToSavedMessages" = "消息已转发到 **我的收藏**。"; +"FwdMessageToUser" = "消息已转发到 **%1$s**。"; +"FwdMessageToUserShort" = "已转发到 **%1$s**."; +"FwdMessagesToChats" = "消息已转发到 **%1$s**。"; +"FwdMessagesToGroup" = "消息已转发到 **%1$s**。"; +"FwdMessagesToManyChats#other" = "消息已转发至 **%1$d 组聊天**。"; +"FwdMessagesToSavedMessages" = "消息已转发到 **我的收藏**。"; +"FwdMessagesToUser" = "消息已转发到 **%1$s**。"; +"FwdMessagesToUserShort" = "已转发到 **%1$s**。"; +"FwdMyStory" = "转发\n状态"; +"GIFs#other" = "%1$d 张 GIF"; +"Gallery" = "相册"; +"GalleryAccessAllowAccess" = "允许访问照片"; +"GalleryAccessAllowAccessButton" = "允许访问"; +"GalleryAccessAllowAccessCamera" = "允许访问摄像头"; +"GalleryAccessAllowAccessOpenCamera" = "打开摄像头"; +"GalleryAccessAllowAccessTextNonPremium" = "分享无限量的照片和视频 \n每个最高 2 GB。"; +"GalleryAccessAllowAccessTextPremium" = "分享不限数量的照片和视频,\n每个文件最大 4 GB。"; +"GalleryInfo" = "发送未经压缩的图片"; +"GalleryPermissionText" = "点击以获取相册访问权限"; +"GameCantSendSecretChat" = "抱歉,游戏无法发送至私密聊天。"; +"General" = "通用"; +"GetDirections" = "获取路线"; +"GetQRCode" = "获取二维码"; +"GettingLinkInfo" = "正在获取链接信息…"; +"GifCaption" = "GIF 说明"; +"GifSavedHint" = "已加入 GIF 动态图收藏。"; +"GifSavedToDownloadsHint" = "GIF 已保存到下载。"; +"GifSavedToDownloadsHintLinked" = "已将 GIF 动态图保存至 **下载文件夹**。"; +"GifsTab" = "GIF"; +"GifsTab2" = "GIF"; +"Gift2ActionBotInfo" = "您可以将此礼物添加至您的个人资料页。"; +"Gift2ActionBotSavedInfo" = "这份礼物现已在您的个人资料页上可见。"; +"Gift2ActionConvertRefundedText" = "此礼物无法转换为星星,因为与其相关的付款已被退回。"; +"Gift2ActionConvertedInfo#other" = "你已将此礼物转换为 %d 颗星星。"; +"Gift2ActionCraftedTitle" = "已制作的礼物"; +"Gift2ActionInfo#other" = "将此礼物添加至您的个人资料页或将其转换为 %d 颗星星。"; +"Gift2ActionInfoChannel#other" = "将此礼物添加到你的频道资料页上或将其转换为 **%d** 颗星星。"; +"Gift2ActionInfoChannelNoConvert" = "将此礼物添加到你的频道资料页上"; +"Gift2ActionOutInfo#other" = "**%2$s** 可将此礼物添加至个人资料页,或将其转换为 %1$d 颗星星。"; +"Gift2ActionOutInfoNoConvert" = "**%s** 可以在他们的个人资料页上显示此礼物。"; +"Gift2ActionOutInfoUpgrade" = "**%s** 可以在他们的个人资料页上显示此礼物或将其转换为独家典藏品。"; +"Gift2ActionPurchasedTitle" = "已购买的礼物"; +"Gift2ActionReleasedBy" = "由 **%s** 发布"; +"Gift2ActionResell" = "出售"; +"Gift2ActionSavedInfo" = "您已将这份礼物添加至您的个人资料页。"; +"Gift2ActionSelfInfo#other" = "将此礼物添加至您的个人资料页或将其转换为 %1$d 颗星星。"; +"Gift2ActionSelfInfoNoConvert" = "将此礼物添加至您的个人资料页。"; +"Gift2ActionSelfInfoUpgrade" = "您可以将此礼物添加至您的个人资料页,或将其转换为独家典藏品以供收藏或转赠。"; +"Gift2ActionSelfTitle" = "已保存的礼物"; +"Gift2ActionShare" = "分享"; +"Gift2ActionTitle" = "赠礼来自"; +"Gift2ActionTitleAnonymous" = "你已收到一份礼物!"; +"Gift2ActionTitleInAnonymous" = "%s 已收到一份礼物!"; +"Gift2ActionTitleTo" = "送礼物给 "; +"Gift2ActionTransfer" = "转让"; +"Gift2ActionUnlist" = "取消列出"; +"Gift2ActionUpgrade" = "点击“开箱”即可将此礼物转换为独家典藏品。"; +"Gift2ActionUpgradeOut" = "%s 可以将此礼物转换为独家典藏品。"; +"Gift2ActionUpgradeRefundedText" = "由于此礼物相关的付款已申请退款并成功退回,该礼物已被降级。"; +"Gift2ActionUpgradeTitle" = "升级来自"; +"Gift2ActionUpgradeUnknownTitle" = "从未知升级"; +"Gift2ActionUpgradedTitle" = "已升级的礼物"; +"Gift2ActionWear" = "佩戴"; +"Gift2ActionWearDone" = "您已佩戴 **%s**"; +"Gift2ActionWearNeededPremium" = "订阅 **Telegram 高级版** 以佩带典藏品。"; +"Gift2ActionWearOff" = "脱下"; +"Gift2ActionWearOffDone" = "您已取下 **%s**"; +"Gift2ActionWonActionText" = "你在拍卖中以 %s 颗星星购了此礼物。"; +"Gift2ActiveAuctionsActiveAuctionTitle" = "进行中的拍卖"; +"Gift2ActiveAuctionsActiveAuctionsTitle" = "%d 进行中的拍卖"; +"Gift2ActiveAuctionsActiveBidActive" = "您的出价 %s 排名第 %d 位"; +"Gift2ActiveAuctionsActiveBidOutbid" = "您对 **%s** 出价已失效"; +"Gift2ActiveAuctionsActiveRaiseBid" = "提高出价"; +"Gift2ActiveAuctionsActiveRound" = "第 %s / %s 轮"; +"Gift2ActiveAuctionsActiveStatusEarly" = "您已提前出价。"; +"Gift2ActiveAuctionsActiveStatusOutbid" = "你的出价已经被超越。"; +"Gift2ActiveAuctionsActiveStatusWinning1Place" = "第一名"; +"Gift2ActiveAuctionsActiveStatusWinning2Place" = "第二名"; +"Gift2ActiveAuctionsActiveStatusWinning3Place" = "第三名"; +"Gift2ActiveAuctionsActiveStatusWinningAll" = "你赢了所有竞拍。"; +"Gift2ActiveAuctionsActiveStatusWinningOne" = "你赢了 (%s)。"; +"Gift2ActiveAuctionsActiveStatusWinningOtherNd" = "第 %d 名"; +"Gift2ActiveAuctionsActiveStatusWinningOtherPlace" = "第 %d 名"; +"Gift2ActiveAuctionsActiveStatusWinningOtherRd" = "第 %d 名"; +"Gift2ActiveAuctionsActiveStatusWinningOtherSt" = "第 %d 名"; +"Gift2ActiveAuctionsActiveStatusWinningOtherTh" = "第 %d 命"; +"Gift2ActiveAuctionsUpcomingAuctionTitle" = "即将开始的拍卖"; +"Gift2ActiveAuctionsUpcomingAuctionsTitle" = "%d 场即将开始的拍卖"; +"Gift2AddMyNameName" = "将我的名字添加到礼物上"; +"Gift2AddMyNameNameChannel" = "将频道礼物添加到礼物中"; +"Gift2AddSenderName" = "将送礼者的名字添加到礼物上"; +"Gift2AddSenderNameComment" = "添加送礼者的名字和留言"; +"Gift2AddToCollection" = "添加到典藏集"; +"Gift2AddedToCollection" = "**%1$s** 已添加至 **%2$s**。"; +"Gift2AddedToCollectionMany#other" = "**%1$d 份礼物** 已添加到 **%2$s**。"; +"Gift2AttributeBackdrop" = "背景"; +"Gift2AttributeModel" = "型号"; +"Gift2AttributeOriginalDetails" = "由 %1$s 于 %3$s 赠送给 %2$s。"; +"Gift2AttributeOriginalDetailsComment" = "由 %1$s 于 %3$s 赠送给 %2$s,留言为:“%4$s”。"; +"Gift2AttributeOriginalDetailsNoSender" = "已于 %2$s 赠送给 %1$s。"; +"Gift2AttributeOriginalDetailsNoSenderComment" = "于 %2$s 赠送给 %1$s,留言为:“%3$s”"; +"Gift2AttributeOriginalDetailsSelf" = "由 %1$s 于 %2$s 保存。"; +"Gift2AttributeOriginalDetailsSelfComment" = "由 %1$s 于 %2$s 赠送,留言为:“%3$s”"; +"Gift2AttributeSymbol" = "符号"; +"Gift2AuctionAlreadyParticipate" = "你已参与 **%s** 的拍卖。你只能参与一场拍卖。请赢得当前轮次,或等待从参与者列表中被移除。"; +"Gift2AuctionAlreadyParticipateSelf" = "你已参与一场拍卖。你只能参与一场拍卖,需赢得当前轮次,或等待从参与者列表中被移除。"; +"Gift2AuctionAlreadyParticipateTitle" = "拍卖"; +"Gift2AuctionBidInfoLeft" = "剩余"; +"Gift2AuctionBidInfoMinimumBid" = "最低出价"; +"Gift2AuctionBidInfoUntilEndRound" = "最后一轮"; +"Gift2AuctionBidInfoUntilNextRound" = "直到下一轮"; +"Gift2AuctionBidInfoUntilStart" = "距离开始"; +"Gift2AuctionBidStatusFuture" = "你的出价是"; +"Gift2AuctionBidStatusFuture2" = "您的出价将为 %s"; +"Gift2AuctionBidStatusOutbid" = "您的出价已失效"; +"Gift2AuctionBidStatusWinning" = "您赢了!"; +"Gift2AuctionBidStatusWinning2" = "您正在赢取 %s"; +"Gift2AuctionCountdownFinished" = "已结束"; +"Gift2AuctionCountdownStartsIn" = "%s 后开始"; +"Gift2AuctionEnded" = "拍卖已结束。"; +"Gift2AuctionEndedNoDot" = "拍卖结束"; +"Gift2AuctionInfo#other" = "将有 %d 份礼物按出价金额,以不同的时间间隔发放给排名前 %d 的竞拍者。"; +"Gift2AuctionInfo2#other" = "本轮排名前 %d 的用户将获得 %s 个礼物。"; +"Gift2AuctionInfo3" = "**%1$s** 份礼物将在 **%2$d** 轮中\n发放给出价最高的 **%3$d** 位竞拍者。%4$s"; +"Gift2AuctionInfoLearnMore" = "**了解更多 >**"; +"Gift2AuctionInfoLearnMore2#other" = "出价最高的 %d 位竞标者将按不同时间间隔获得 %d 份礼物。**了解更多 >**"; +"Gift2AuctionJoin" = "加入拍卖"; +"Gift2AuctionLearnMore2" = "了解更多关于 Telegram 礼物 >"; +"Gift2AuctionLearnMore3" = "了解更多佩戴 Telegram 礼物的信息 >"; +"Gift2AuctionMinimumBidIncreased#other" = "您至少需要出价 **%d 颗星星**。"; +"Gift2AuctionPlaceABid" = "出价"; +"Gift2AuctionPlaceABidTitle" = "出价"; +"Gift2AuctionPlaceACustomBid" = "自定义出价"; +"Gift2AuctionPlaceACustomBidHint" = "如果你的排名跌出前 %d 名,你的出价将顺延到下一轮。"; +"Gift2AuctionPlaceACustomBidHint2" = "自定义出价......"; +"Gift2AuctionPlaceAEarlyBid" = "提前出价"; +"Gift2AuctionPlaceBid" = "出价为 ⭐️%s "; +"Gift2AuctionPlaceBidAdd" = "向您的出价添加 ⭐️%s"; +"Gift2AuctionPriceJoin" = "加入"; +"Gift2AuctionPriceView" = "查看"; +"Gift2AuctionStartsIn" = "%s 后开始"; +"Gift2AuctionTableCurrentAvailability" = "剩余数量"; +"Gift2AuctionTableCurrentQuantity" = "数量"; +"Gift2AuctionTableCurrentRound" = "当前轮次"; +"Gift2AuctionTableCurrentRounds" = "轮次"; +"Gift2AuctionTableCurrentRoundsOne" = "第 %d 轮"; +"Gift2AuctionTableCurrentRoundsOneDuration" = "前 %3$d 名延迟出价时延长 %1$s + %2$s"; +"Gift2AuctionTableCurrentRoundsTwo" = "第 %1$d-%2$d 轮"; +"Gift2AuctionTableCurrentRoundsTwoDuration#other" = "每轮 %1$d 分钟"; +"Gift2AuctionTableEnded" = "结束"; +"Gift2AuctionTableStarted" = "开始"; +"Gift2AuctionTapToBidMore" = "点击加价"; +"Gift2AuctionTimeLeft" = "剩余 %s"; +"Gift2AuctionTop3Winners" = "前三名赢家"; +"Gift2AuctionUpgradedShort" = "升级后"; +"Gift2AuctionVariants" = "查看 %1$s %2$s 款式 >"; +"Gift2AuctionYourBid" = "你的出价"; +"Gift2AuctionsAcquiredAcceptedBid" = "中标价"; +"Gift2AuctionsAcquiredDate" = "日期"; +"Gift2AuctionsAcquiredGifts#other" = "%1$d 已购买礼物"; +"Gift2AuctionsAcquiredRecipient" = "收件人"; +"Gift2AuctionsAcquiredRound" = "第 %1$d 轮"; +"Gift2AuctionsAcquiredRound2" = "%1$s #%2$d 于第 %3$d 轮"; +"Gift2AuctionsAcquiredTop" = "前 %d"; +"Gift2AuctionsBidHasBeenIncreased" = "您的出家已提高"; +"Gift2AuctionsBidHasBeenPlaced" = "你的出价已提交"; +"Gift2AuctionsChangeRecipient" = "更改收件人"; +"Gift2AuctionsChangeRecipient2" = "您已对这份礼物出价 **%s**。您是否想提高出价并将收件人更改为 **%s**?"; +"Gift2AuctionsItemsBought#other" = "已购买 %1$d 份 %2$s 礼物 >"; +"Gift2AuctionsItemsBought2#other" = "已购买 %1$d 个 %2$s 物品 >"; +"Gift2Availability" = "送出情况"; +"Gift2Availability2Value" = "剩余 %1$s / %2$s "; +"Gift2Availability2ValueNone#other" = "剩余 0 / %1$d"; +"Gift2Availability3Value" = "剩余 %1$d/%2$s"; +"Gift2Availability4Value#other" = "剩余 %1$d / %2$s "; +"Gift2AvailabilityLeft#other" = "剩余 %d 个"; +"Gift2AvailabilitySold#other" = "售出 %d 颗"; +"Gift2AveragePriceHint" = "**%d** 颗星星是 **%s ** 份礼物的平均成交价。"; +"Gift2ButtonSell#other" = "以 %d 颗星星的价格售出"; +"Gift2ButtonSendGift" = "赠送礼物"; +"Gift2BuyDoPrice" = "以 ⭐️%d 购买"; +"Gift2BuyDoPrice2#other" = "以 ⭐️%d 购买"; +"Gift2BuyDoPrice2TON" = "以 ⭐️%s 购买"; +"Gift2BuyInStars" = "使用星星付款"; +"Gift2BuyInTON" = "以 Gram 付款"; +"Gift2BuyPriceOnlyTON" = "卖家仅接受 Gram 作为付款方式。"; +"Gift2BuyPricePayHintTON" = "使用 Gram 付款可以避免再次转让礼物的21天等待期。"; +"Gift2BuyPriceSelfText#other" = "您想用 **%1$d** 颗星星购买 **%2$s** 吗?"; +"Gift2BuyPriceSelfTextTON" = "您想用 **%1$s Gram** 购买 **%2$s** 吗?"; +"Gift2BuyPriceText#other" = "您想用 **%1$d 颗星星** 购买 **%2$s** 并赠送给 **%3$s** 吗?"; +"Gift2BuyPriceTextTON" = "您想要以 **%1$s Gram** 购买 **%2$s** 并将其赠送给 **%3$s** 吗?"; +"Gift2CantDoText" = "将此礼物转让到 Fragment 上的 Telegram 账号以解锁此操作。"; +"Gift2CantDoTitle" = "操作已锁定"; +"Gift2CaptionMore" = "更多"; +"Gift2ChangePrice" = "更改价格"; +"Gift2ChannelDoesntSupportGiftsText" = "此频道无法接收任何 Telegram 礼物。"; +"Gift2ChannelDoesntSupportGiftsTitle" = "不可用"; +"Gift2ChannelMadePrivate" = "礼物现已从你的页面上隐藏。"; +"Gift2ChannelMadePrivateTitle" = "礼物已隐藏"; +"Gift2ChannelMadePublic" = "礼物现在可以在你的频道资料页上看到。"; +"Gift2ChannelMadePublicTitle" = "礼物显示"; +"Gift2ChannelNotify" = "关于新礼物的通知"; +"Gift2ChannelNotifyChecked" = "当你的频道收到礼物时,Telegram 将给你发送消息。"; +"Gift2ChannelNotifyNotChecked" = "当你的频道收到礼物时,你不会收到通知。"; +"Gift2ChannelProfileInvisible3" = "此礼物对频道访客不可见 **显示 >**"; +"Gift2ChannelProfileVisible3" = "此礼物在您頻道資料頁上可見。 **隱藏 >**"; +"Gift2ChannelSend" = "发送礼物"; +"Gift2ChannelSendHint" = "点击此处以赠送礼物"; +"Gift2CollectionAddGiftsButton" = "添加礼物"; +"Gift2CollectionAddGiftsTitle" = "添加礼物"; +"Gift2CollectionAll" = "所有礼物"; +"Gift2CollectionEmptyButton" = "添加礼物"; +"Gift2CollectionEmptyText" = "将一些礼物添加到此收藏集中。"; +"Gift2CollectionEmptyTitle" = "整理您的礼物"; +"Gift2CollectionNumber#other" = "典藏 %d"; +"Gift2CollectionNumberBy#other" = "**%2$s** 持有的典藏品 #%1$d"; +"Gift2CollectionsAdd" = "添加礼物"; +"Gift2CollectionsDelete" = "删除收藏集"; +"Gift2CollectionsRename" = "编辑名称"; +"Gift2CollectionsReorder" = "重新排序"; +"Gift2CollectionsShare" = "分享收藏品"; +"Gift2ConvertButton" = "转换"; +"Gift2ConvertStars#other" = "%d 颗星星"; +"Gift2ConvertText" = "您想将此礼物从 **%1$s** 转换为 **%2$s** 吗?\n\n此操作无法撤消。这将永久删除礼物。"; +"Gift2ConvertText2#other" = "您要将来自 **%2$s** 的礼物兑换为 **%3$s** 吗?\n\n兑换可在接下来的 %1$d 天内进行。\n\n此操作无法撤销,将永久销毁该礼物。"; +"Gift2ConvertTitle" = "将礼物转换为星星"; +"Gift2Converted#other" = "%d 颗星星已保存至您的余额"; +"Gift2ConvertedChannel#other" = "%d 颗星星已转入频道余额"; +"Gift2ConvertedTitle" = "礼物已转换"; +"Gift2EditCollectionNameTitle" = "编辑名称"; +"Gift2ExportTONFragmentOpen" = "打开 Fragment"; +"Gift2ExportTONFragmentText" = "你可以使用第三方服务 Fragment 将 **%s** 转移到 TON 区块链。之后你可以用任何 TON 钱包应用将其作为 NFT 管理。\n\n你也可以通过 Fragment 将此类 NFT 转回到你的 Telegram 账号。"; +"Gift2ExportTONFragmentTitle" = "通过 Fragment 管理"; +"Gift2ExportTONTitle" = "通过区块链发送"; +"Gift2ExportTONUnlocksAlertText#other" = "%d 天后,您能够将此典藏品发送到 Telegram 以外的任何 TON 区块链地址进行销售或拍卖。"; +"Gift2ExportTONUnlocksAlertTitle" = "正在解锁中"; +"Gift2ExportTONUnlocksIn#other" = "%d 天后解锁"; +"Gift2ExportTONUpdateRequiredText" = "Telegram 支持将礼物转账至区块链的版本目前正在 Play 商店审核中,请耐心等待几天。"; +"Gift2ExportTONUpdateRequiredTitle" = "需要更新"; +"Gift2FilterDisplayed" = "显示"; +"Gift2FilterHidden" = "隐藏"; +"Gift2FilterLimited" = "限量"; +"Gift2FilterSortByDate" = "按日期排序"; +"Gift2FilterSortByValue" = "按价值排序"; +"Gift2FilterUnique" = "独家"; +"Gift2FilterUnlimited" = "不限量"; +"Gift2FilterUpgradable" = "可升级"; +"Gift2From" = "来自"; +"Gift2Gift" = "礼物"; +"Gift2GiftAnUpgrade" = "赠送升级机会"; +"Gift2Hide" = "隐藏送礼者"; +"Gift2HideChannelInfo" = "你可以对此频道的所有访客(管理员除外)隐藏您的名字和消息。"; +"Gift2HideGift" = "隐藏"; +"Gift2HideInfo" = "向 %1$s 个人资料页的访客隐藏您的名称和消息。 %1$s 仍会看到您的名称和消息。"; +"Gift2HideSelf" = "隐藏我的姓名"; +"Gift2HideSelfInfo" = "向我的个人资料页访客隐藏我的名称和消息"; +"Gift2Host" = "Telegram"; +"Gift2HostAddress" = "所有者"; +"Gift2InBlockchain" = "此礼物位于 TON 区块链上。 **查看 >**"; +"Gift2InSenderHidden2" = "仅您可见发件人名称。"; +"Gift2InSenderMessageHidden2" = "仅您可见发件人名称和消息。"; +"Gift2Info#other" = "您可以将此礼物保留在您的个人资料页上或将其转换为 **%d** 颗星星。"; +"Gift2Info2#other" = "您可以将此礼物添加至您的个人资料页,或在 **%2$s** 内将其转换为 **%1$d** 颗星星。"; +"Gift2Info2BotKeep" = "您可以将此礼物添加至您的个人资料页或将其隐藏。"; +"Gift2Info2BotRemove" = "您可以从个人资料页上移除此礼物。"; +"Gift2Info2ChannelExpired#other" = "你可以将這份价值 %d 颗星星的礼物加入到频道资料页上。"; +"Gift2Info2ChannelKeep" = "你的频道可以把这份礼物添加到其资料页面上。"; +"Gift2Info2ChannelRemove" = "你可以在个人页面上隐藏此礼物。"; +"Gift2Info2Expired#other" = "您可以将这份价值 %d 颗星星的礼物添加至您的个人资料页。"; +"Gift2Info2KeepUpgrade" = "您可以将此礼物添加至您的个人资料页或者升级为独家典藏品。"; +"Gift2Info2Out#other" = "%2$s 可以将此礼物添加至个人资料页或将其转换为 **%1$d** 颗星星。"; +"Gift2Info2OutExpired" = "%1$s 可将此礼物添加至个人资料页。"; +"Gift2Info2OutUpgrade" = "%1$s 可以将此礼物添加至个人资料页或者升级为独家典藏品。"; +"Gift2Info2RemoveUpgrade" = "您可以将此礼物从个人资料页上移除或者升级为独家典藏品。"; +"Gift2Info3#other" = "你可以将此礼物加入到您的个人资料页上或將其转换为 **%1$d** 颗星星。"; +"Gift2Info3Channel#other" = "您的频道可以将此礼物添加到资料页中,或将其转换为 **%1$d** 颗星星。"; +"Gift2InfoChannelConverted#other" = "您的频道已将此礼物转换为 **%d** 颗星星。"; +"Gift2InfoConverted#other" = "你已将此礼物转换为 **%d** 颗星星。"; +"Gift2InfoFreeUpgrade" = "%s 可以将此礼物转换为独家典藏品。"; +"Gift2InfoInFreeUpgrade" = "免费升级此礼物,将其转换为独家典藏品。"; +"Gift2InfoOut#other" = "%2$s 可将此礼物添加至个人资料页,或将其转换为 **%1$d** 颗星星。"; +"Gift2InfoOutConverted#other" = "%2$s 已将此礼物转换为 **%d** 颗星星。"; +"Gift2InfoOutPinned" = "%1$s 已将此礼物添加到他们的个人资料页。"; +"Gift2InfoPinned" = "您已将这份礼物添加至您的个人资料页。"; +"Gift2Invisible" = "在您的个人资料页上不可见"; +"Gift2InvisibleShow" = "显示"; +"Gift2LearnMoreText" = "让 %s 将该礼物转换为独家典藏品。"; +"Gift2LearnMoreTitle" = "独家定制"; +"Gift2Limited1OfRibbon" = "1(共 %s)"; +"Gift2LimitedAuction" = "拍卖"; +"Gift2LimitedAuctionSoon" = "即将开始"; +"Gift2LimitedPremium" = "高级版"; +"Gift2LimitedRibbon" = "限量"; +"Gift2LimitedSlider" = "限量"; +"Gift2LinkGiftAuction" = "礼物拍卖"; +"Gift2LinkUpcomingAuction" = "即将举行的拍卖"; +"Gift2ListingsCount#other" = "%d 件在售"; +"Gift2MadePrivate" = "礼物现已在**您的个人资料页**中隐藏。"; +"Gift2MadePrivateTitle" = "礼物已隐藏"; +"Gift2MadePublic" = "此礼物现在可以在 **您的个人资料页** 上看到。"; +"Gift2MadePublicTitle" = "礼物已显示"; +"Gift2Message" = "输入消息"; +"Gift2MessageOptional" = "输入消息 (可选)"; +"Gift2MessagePremiumInfo" = "只有 %s 能看到你的消息。"; +"Gift2MessageStars" = "支付 ⭐️%d"; +"Gift2MessageStarsInfo" = "您的余额为 ⭐️%s。"; +"Gift2MessageStarsInfoLink" = "**获取更多星星 >**"; +"Gift2MessageStarsInfoTON" = "您的余额为 ⭐️**%s**"; +"Gift2Months#other" = "%1$d 个月"; +"Gift2More" = "**更多关于星星的介绍 >**"; +"Gift2Myself" = "给自己买一份礼物"; +"Gift2MyselfSection" = "这是你"; +"Gift2NewCollection" = "创建新的收藏集"; +"Gift2NewCollectionHint" = "标题"; +"Gift2NewCollectionText" = "为您的收藏集选择一个名称,并添加您的礼物。"; +"Gift2NewCollectionTitle" = "新建收藏集"; +"Gift2NoUpgradeChannelForcedInfo" = "%s 仅接受限量版礼物。"; +"Gift2NoUpgradeForcedInfo" = "%s 仅接受限量版礼物。"; +"Gift2Notification#other" = "已送出以 %d 颗星星为价格的礼物"; +"Gift2OnSale" = "在售"; +"Gift2OutSenderHidden2" = "仅 %s 可见发件人名称。"; +"Gift2OutSenderMessageHidden2" = "仅 %s 可见发件人名称和消息。"; +"Gift2Owner" = "所有者"; +"Gift2OwnerChange" = "转让"; +"Gift2PeerColorEmptyButton" = "**浏览可供购买的礼物 >**"; +"Gift2PeerColorProfileEmptyTitle" = "您没有任何可以用于个人资料封面的礼物。"; +"Gift2PeerColorReplyEmptyTitle" = "您没有任何可以用于回复颜色的礼物。"; +"Gift2PerUserLimit#other" = "您已经发送了 **%d** 份同类礼物,已达上限。"; +"Gift2Pin" = "置顶"; +"Gift2PinnedSubtitle" = "现在它将总是显示在顶部。"; +"Gift2PinnedTitle" = "礼物已置顶"; +"Gift2Premium" = "赠送 Telegram 高级版"; +"Gift2PremiumInfo" = "让 **%s** 享有 Telegram 高级版专属功能."; +"Gift2PremiumInfoLink" = "查看详情 >"; +"Gift2PremiumSubtitle" = "订阅 **Telegram 高级版** 以发送这些礼物并解锁强大功能。"; +"Gift2PremiumSubtitleMany#other" = "订阅 **Telegram 高级版** 即可赠送最多 **%d** 份此类礼物,并解锁强大功能。"; +"Gift2PremiumTitle" = "高级版礼物"; +"Gift2PrepayUpgradeButton" = "支付 ⭐️%d 升级"; +"Gift2PrepayUpgradeFeature1Text" = "%s 将获得专属编号、款式、背景和图标的礼物。"; +"Gift2PrepayUpgradeFeature2Text" = "%s 可以将此礼物发送给 Telegram 上的任何人。"; +"Gift2PrepayUpgradeFeature3Text" = "%s 将能够在第三方 NFT 市场上拍卖该礼物。"; +"Gift2PrepayUpgradeText" = "让 %s 将这份礼物变成一件独特的收藏品。"; +"Gift2PrepayUpgradeTitle" = "独家定制"; +"Gift2PreviewRandomTraits" = "随机特征"; +"Gift2PreviewSelectedTraits" = "选定特征"; +"Gift2PriceUnique" = "独家"; +"Gift2ProfileInvisible" = "此礼物已被隐藏,只有你能看到它。"; +"Gift2ProfileInvisible3" = "此礼物在你的个人资料页上不可见。 **显示 >**"; +"Gift2ProfileInvisible4" = "此礼物在你的个人资料页上不可见。**在个人资料页上显示 >**"; +"Gift2ProfileMakeInvisible" = "从我的个人资料页上隐藏"; +"Gift2ProfileMakeVisible" = "添加至我的资料页"; +"Gift2ProfileVisible" = "此礼物可以在您的个人资料页上看到。"; +"Gift2ProfileVisible2" = "此礼物会显示在您的个人资料页上。 **查看 >**"; +"Gift2ProfileVisible3" = "此礼物在你的个人页面上可见。 **隐藏 >**"; +"Gift2ProfileVisible4" = "此礼物在你的个人页面上可见。 **从个人资料中隐藏 >**"; +"Gift2Quantity" = "数量"; +"Gift2QuantityIssued1#other" = "%d/"; +"Gift2QuantityIssued2#other" = "已送出 %d 个"; +"Gift2QuantityIssuedNone#other" = "剩余 0 / %d"; +"Gift2RarityHint" = "只有 %s 的此类典藏品具有此属性"; +"Gift2Refunded" = "由于此礼物相关的付款已申请退款并成功退回,该礼物已被降级。"; +"Gift2ReleasedBy" = "由 **%s** 发布"; +"Gift2ReleasedBy2" = "来自 **%s**"; +"Gift2RemoveDescriptionButton" = "花费 ⭐️%d 移除"; +"Gift2RemoveDescriptionText" = "您是否要永久移除此礼物的描述?"; +"Gift2RemoveDescriptionTitle" = "移除描述"; +"Gift2RemoveFromCollection" = "从收藏集中移除"; +"Gift2RemovedFromCollection" = "**%1$s** 已从 **%2$s** 移除。"; +"Gift2RemovedFromCollectionMany#other" = "**%1$d 份礼物** 已从 **%2$s** 移除。"; +"Gift2Reorder" = "重新排序"; +"Gift2ReplacedPinSubtitle" = "更换 %s"; +"Gift2ReplacedPinTitle" = "%s 已置顶"; +"Gift2Resale" = "转售"; +"Gift2ResaleCount#other" = "%d 件在售"; +"Gift2ResaleDisable" = "%s 已停止销售!"; +"Gift2ResaleEnable" = "%s 现已出售!"; +"Gift2ResaleFilterBackdrop" = "背景"; +"Gift2ResaleFilterBackdrops#other" = "%d 个背景"; +"Gift2ResaleFilterModel" = "型号"; +"Gift2ResaleFilterModels#other" = "%d 个型号"; +"Gift2ResaleFilterSymbol" = "符号"; +"Gift2ResaleFilterSymbols#other" = "%d 种符号"; +"Gift2ResaleFiltersBackdropEmpty" = "未找到背景。"; +"Gift2ResaleFiltersClear" = "清除筛选"; +"Gift2ResaleFiltersEmptyClear" = "清除筛选条件"; +"Gift2ResaleFiltersEmptySubtitle" = "目前,没有符合该筛选条件的礼物。"; +"Gift2ResaleFiltersEmptyTitle" = "未找到礼物"; +"Gift2ResaleFiltersModelEmpty" = "未找到型号。"; +"Gift2ResaleFiltersSearch" = "搜索…"; +"Gift2ResaleFiltersSymbolEmpty" = "未找到符号。"; +"Gift2ResaleNoCount" = "无结果"; +"Gift2ResellTimeout" = "您可以在 %s 后转售此礼物。"; +"Gift2ResellTimeoutTitle" = "稍后再试"; +"Gift2SelfInfo" = "您可以将此礼物添加至您的个人资料页。"; +"Gift2SelfInfoConvert#other" = "将此礼物添加至您的个人资料页或将其转换为 **%d 颗星星**。"; +"Gift2SelfInfoConverted#other" = "您已将此礼物兑换为 **%d 颗星星**。"; +"Gift2SelfInfoUpgrade" = "您可以将此礼物展示添加至个人资料页,或者将其转换为独一无二的可转赠收藏品。"; +"Gift2Send#other" = "花费 ⭐️%d 赠送礼物"; +"Gift2SendPremium" = "赠送礼物给 %s"; +"Gift2SendPremiumStars" = "花费 ⭐️%s 发送礼物"; +"Gift2SendSelf#other" = "以 ⭐️%d 购买礼物"; +"Gift2SendYourBalance#other" = "你的余额为 %d 颗星星"; +"Gift2SenderHidden" = "只有您可以看到送礼人的名字和消息。"; +"Gift2SentRemainsLimit#other" = "您可以再发送 **%d** 个。"; +"Gift2ShowGift" = "显示"; +"Gift2SoldAuctionPreviewGifts#other" = "%d 份礼物"; +"Gift2SoldOut" = "售罄"; +"Gift2SoldOutCount#other" = "所有 %d 份礼物均已售出。"; +"Gift2SoldOutSheetFirstSale" = "首次销售"; +"Gift2SoldOutSheetLastSale" = "最新成交"; +"Gift2SoldOutSheetSubtitle" = "此礼物已售罄"; +"Gift2SoldOutSheetTitle" = "售罄"; +"Gift2SoldOutSheetValue" = "价值"; +"Gift2SoldOutTitle" = "售罄!"; +"Gift2Stars" = "赠送礼物"; +"Gift2StarsChannel" = "发送礼物"; +"Gift2StarsChannelInfo" = "选择一份礼物来表达对 **%s** 的感谢。"; +"Gift2StarsCollectibleInfo" = "典藏品礼物是您可以交换或出售的独特数位资产。"; +"Gift2StarsCount#other" = "%d 颗星星"; +"Gift2StarsInfo" = "给 **%s** 赠送礼物,可保留在个人资料中或兑换为星星。"; +"Gift2StarsInfoLink" = "星星是什么 >"; +"Gift2StarsInfoProfileLink" = "%s 的礼物"; +"Gift2StarsSelf" = "购买礼物"; +"Gift2StarsSelfInfo1" = "给自己买一份礼物并添加至您的个人资料页或留作日后使用。"; +"Gift2StarsSelfInfo2" = "限量版礼物升级为典藏品后可以赠送给他人。"; +"Gift2Status" = "状态"; +"Gift2StatusNonUnique" = "非独家"; +"Gift2StatusUpgrade" = "升级"; +"Gift2TabAll" = "所有礼物"; +"Gift2TabCollectibles" = "典藏品"; +"Gift2TabInStock" = "现货"; +"Gift2TabLimited" = "限量"; +"Gift2TabMine" = "我的礼物"; +"Gift2TabResale" = "转售"; +"Gift2Title" = "赠送礼物"; +"Gift2TitleChannel" = "礼物"; +"Gift2TitleProfile" = "礼物"; +"Gift2TitleReceived" = "收到的礼物"; +"Gift2TitleSaved" = "已保存的礼物"; +"Gift2TitleSelf1" = "购买礼物"; +"Gift2TitleSelf2" = "送给自己"; +"Gift2TitleSent" = "已送出赠礼"; +"Gift2To" = "赠予"; +"Gift2ToBalance#other" = "转换为 %d 颗星星"; +"Gift2TransactionAuctionBid" = "拍卖出价"; +"Gift2TransactionConverted" = "已转换的礼物"; +"Gift2TransactionPrepaidUpgrade" = "礼物升级"; +"Gift2TransactionRefundedAuctionBid" = "拍卖出价已退款"; +"Gift2TransactionRefundedConverted" = "转换的礼物已退款"; +"Gift2TransactionRefundedSent" = "礼物已退款"; +"Gift2TransactionRefundedUpgrade" = "已退款的礼物升级"; +"Gift2TransactionRemovedDescription" = "已移除礼物描述"; +"Gift2TransactionSent" = "礼物"; +"Gift2TransactionUpgraded" = "升级为典藏品"; +"Gift2Transfer" = "转让 %s"; +"Gift2TransferDo" = "转让"; +"Gift2TransferDoPrice" = "以 ⭐️%d 转让"; +"Gift2TransferMine" = "转让"; +"Gift2TransferOption" = "转让"; +"Gift2TransferPriceText#other" = "你是否要花费 %1$d 将 %2$s 的所有权转让给 %3$s?"; +"Gift2TransferShort" = "转让礼物"; +"Gift2TransferText" = "您想要将 **%1$s** 的所有权转让给 **%2$s** 吗?"; +"Gift2TransferTimeout" = "您可以在 %s 后转让此礼物。"; +"Gift2TransferTimeoutTitle" = "稍后再试"; +"Gift2TransferToTONAlertText" = "转移礼物到 TON 需要满足以下条件:"; +"Gift2TransferToTONAlertText1" = "已启用**两步验证** 超过 **7 天**。"; +"Gift2TransferToTONAlertText2" = "在此设备登录超过 **24 小时**。"; +"Gift2TransferToTONAlertText3" = "请稍后再试"; +"Gift2TransferToTONAlertTitle" = "安全检查"; +"Gift2TransferToTONSetPassword" = "设置密码"; +"Gift2TransferredText" = "您已成功将 **%1$s** 转赠给 **%2$s**。"; +"Gift2TransferredTitle" = "礼物已转让"; +"Gift2UniqueNotification" = "发送独家礼物!"; +"Gift2UniquePrepaidUpgradeNotification" = "一赠送一次礼物升级"; +"Gift2UniqueRibbon" = "礼物"; +"Gift2UniqueRibbonBurned" = "已销毁"; +"Gift2UniqueTitle" = "来自 %s 的礼物"; +"Gift2UniqueTitle2" = "礼物"; +"Gift2UniqueUnpackUpgradeNotification" = "已打开礼物"; +"Gift2UniqueUpgradeNotification" = "升级你的礼物"; +"Gift2UniqueView" = "查看"; +"Gift2UnlistText" = "您确定要取消列出您的礼物吗?"; +"Gift2UnlistTitle" = "取消列出 %s"; +"Gift2Unpack" = "开箱"; +"Gift2Unpin" = "取消置顶"; +"Gift2UnpinAlertButton" = "取消置顶"; +"Gift2UnpinAlertSubtitle" = "选择下方要取消置顶的礼物"; +"Gift2UnpinAlertTitle" = "置顶的礼物过多"; +"Gift2Unpinned" = "礼物已取消置顶"; +"Gift2Unwear" = "脱下"; +"Gift2Upgrade" = "以 ⭐️%d 打造独家典藏品"; +"Gift2UpgradeButton" = "以 ⭐️%d 升级"; +"Gift2UpgradeButtonDecreasesIn" = "价格将在 %s 后下降"; +"Gift2UpgradeButtonFree" = "免费更新"; +"Gift2UpgradeButtonGift" = "升级"; +"Gift2UpgradeChannelForcedInfo" = "%s 仅接受独家礼物。"; +"Gift2UpgradeChannelInfo" = "开启此功能以允许 %s 的管理员将您的礼物转换为独家典藏品。**了解更多 >**"; +"Gift2UpgradeCostsFooter" = "升级成本每分钟下降。"; +"Gift2UpgradeCostsInfo" = "查看价格会如何下降 >"; +"Gift2UpgradeCostsText" = "抢先升级礼物的用户将获得编号更短的典藏品。"; +"Gift2UpgradeCostsTitle" = "升级价格"; +"Gift2UpgradeFeature1Text" = "为您的礼物取得专属编号、模型、背景和符号。"; +"Gift2UpgradeFeature1TextLearn" = "收礼者将获得礼物的独家编号、模型、背景和符号。"; +"Gift2UpgradeFeature1Title" = "独家"; +"Gift2UpgradeFeature2Text" = "将升级后的礼物赠送给 Telegram 上的任何朋友。"; +"Gift2UpgradeFeature2TextLearn" = "收件人能够将礼物发送给 Telegram 上的任何人。"; +"Gift2UpgradeFeature2Title" = "可转让"; +"Gift2UpgradeFeature3Text" = "在第三方 NFT 市场上出售或拍卖你的礼物。"; +"Gift2UpgradeFeature3TextLearn" = "收件人将能够在第三方 NFT 市场上拍卖礼物。"; +"Gift2UpgradeFeature3Title" = "可交易"; +"Gift2UpgradeFeatureSoon" = "即将推出"; +"Gift2UpgradeForcedInfo" = "%s 仅接受独家礼物。"; +"Gift2UpgradeInfo" = "启用此选项可让 %s 将您的礼物转换为独家典藏品。 **了解更多 >**"; +"Gift2UpgradeNext" = "升级下一个礼物"; +"Gift2UpgradeSelf" = "以 %d 创造独家典藏品"; +"Gift2UpgradeSelfInfo" = "启用此选项可将你的礼物改成独家典藏品。 **了解更多 >**"; +"Gift2UpgradeText" = "将你的礼物转换为可以转让或拍卖的独家典藏品。"; +"Gift2UpgradeTitle" = "升级礼物"; +"Gift2UpgradedText" = "你的礼物 **%s** 现在具有独家属性,可以转让给其他人。"; +"Gift2UpgradedTitle" = "礼物已升级"; +"Gift2User" = "礼物 %s"; +"Gift2Value" = "价值"; +"Gift2ViewInProfile" = "在个人资料页上查看"; +"Gift2Visibility" = "可见性"; +"Gift2Visible" = "在您的个人资料页可见"; +"Gift2VisibleHide" = "隐藏"; +"Gift2Wear" = "佩带"; +"Gift2WearFeature1Text" = "此礼物将以闪耀图标的形式显示在你的名字旁边"; +"Gift2WearFeature1Title" = "光芒徽章"; +"Gift2WearFeature2Text" = "您的个人资料页将套用此物品的背景和符号。"; +"Gift2WearFeature2Title" = "独特的个人资料页设计"; +"Gift2WearFeature3Text" = "点击您名字旁的图标将显示礼物的所有者和属性。"; +"Gift2WearFeature3Title" = "所有权证明"; +"Gift2WearStart" = "开始佩戴"; +"Gift2WearSubtitle" = "并获得以下权益"; +"Gift2WearTitle" = "佩戴 %s"; +"Gift2WearingHint" = "您可以免费升级你的礼物、将其出售给\n其他用户或将其设置为个人资料页封面。"; +"GiftAuctionInfo1Header#other" = "前 %d 名竞拍者"; +"GiftAuctionInfo1Text#other" = "按出价金额排名的前 %d 名竞拍者,将以不同时间间隔获得 %d 份礼品。"; +"GiftAuctionInfo2Header" = "出价延续"; +"GiftAuctionInfo2Text#other" = "如果您的出价未进入前 %d 名,将自动进入下一轮竞标。"; +"GiftAuctionInfo3Header" = "未中拍者"; +"GiftAuctionInfo3Text" = "如果您的出价在最后一轮未能胜出,您的星星将全额退还。"; +"GiftAuctionInfoHeader" = "拍卖"; +"GiftAuctionInfoText" = "参与专属礼品争夺战。"; +"GiftAuctionNotFound" = "未找到礼物拍卖"; +"GiftAuctionWearInfo1Header" = "闪耀徽章"; +"GiftAuctionWearInfo1Text" = "此礼物将以闪亮图标的形式显示在您的名字旁边。"; +"GiftAuctionWearInfo2Header" = "独特的个人资料页设计"; +"GiftAuctionWearInfo2Text" = "您的个人资料页将使用此礼物的颜色和符号。"; +"GiftAuctionWearInfo3Header" = "所有权证明"; +"GiftAuctionWearInfo3Text" = "点击您名字旁边的礼物图标将显示其信息和所有者。"; +"GiftAuctionWearInfoHeader" = "佩戴 %s"; +"GiftAuctionWearInfoOnline" = "在线"; +"GiftAuctionWearInfoText" = "并获得以下权益:"; +"GiftCollectionSharedTo" = "礼物收藏集已转发到 **%s**。"; +"GiftCollectionSharedToManyChats#other" = "礼物收藏集已转发至 **%1$d 个聊天**。"; +"GiftCollectionSharedToSavedMessages" = "礼物收藏集已转发至**我的收藏**"; +"GiftCraft" = "制作"; +"GiftCraftBackdropChance#other" = "制作出的礼物将有 **%1$d%%** 的概率带有 **%2$s** 背景。"; +"GiftCraftButton" = "制作礼物"; +"GiftCraftButtonEmpty" = "点击「+」添加您的第一份礼物"; +"GiftCraftButtonFailed" = "制作新的礼物"; +"GiftCraftButtonNext" = "制作新礼物"; +"GiftCraftCantChooseFirst" = "此礼物已记录在区块链上,无法作为制作的第一件礼物使用。"; +"GiftCraftCantChooseFirstTitle" = "无法选为首件"; +"GiftCraftFailedText#other" = "这次制作尝试失败了。 \n**%d 件礼物**已丟失。"; +"GiftCraftFailedTitle" = "制作失败"; +"GiftCraftInfoButton" = "开始制作"; +"GiftCraftInfoFeature1Text" = "选择最多 4 件礼物,制作全新专属型号。"; +"GiftCraftInfoFeature1Title" = "获取稀有型号"; +"GiftCraftInfoFeature2Text" = "组合更多礼物以提高成功率。"; +"GiftCraftInfoFeature2Title" = "最大化成功率"; +"GiftCraftInfoFeature3Text" = "使用相同属性的礼物来提升成功率。"; +"GiftCraftInfoFeature3Title" = "影响结果"; +"GiftCraftInfoText" = "将您的礼物升级为稀有、史诗\n和传奇版本。"; +"GiftCraftInfoTitle" = "礼物制作中"; +"GiftCraftLaterText" = "你将能够于 %s 制作这份礼物。"; +"GiftCraftLaterTitle" = "稍后再试"; +"GiftCraftProgressSuccessChance" = "%s 成功率"; +"GiftCraftProgressText" = "如果制作失败,这些礼物 \n将会丟失。"; +"GiftCraftProgressTitle" = "制作中"; +"GiftCraftSelect#other" = "您可以从同一系列中添加最多 **%d 件礼物**\n用于制作。"; +"GiftCraftSelectResale" = "精选特价礼物"; +"GiftCraftSelectTitle" = "选择礼物"; +"GiftCraftSelectYour" = "您的礼物"; +"GiftCraftSelectYourEmpty" = "您没有其他来自\n此收藏系列的礼物。"; +"GiftCraftSuccessChance" = "**%s** 成功率"; +"GiftCraftSymbolChance#other" = "制作出的礼物将有 **%1$d%%** 的概率带有 **%2$s** 符号。"; +"GiftCraftText1" = "最多可添加 **4** 份礼物来制作全新的"; +"GiftCraftText2" = "**%1$s #%2$s**.\n\n如果制作失败,这些礼物 \n将会 **丟失**。"; +"GiftCraftTextEmpty1" = "最多可添加 **4** 份礼物来制作全新的"; +"GiftCraftTextEmpty2" = "**%s** 件礼物。\n\n如果制作失败,这些礼物 \n将会 **丟失**。"; +"GiftCraftTitle" = "制作礼物"; +"GiftCraftUnavailableText" = "这些礼物目前无法制作。"; +"GiftCraftUnavailableTextTime" = "此礼物将于 **%s** 开放制作。"; +"GiftCraftUnavailableTitle" = "制作功能不可用"; +"GiftCraftViewAllVariants" = "查看所有可制作的型号 >"; +"GiftCrafted" = "已制作"; +"GiftLocked" = "礼物已锁定"; +"GiftMonths#other" = "%1$d 个月"; +"GiftOfferAccept" = "接受"; +"GiftOfferAmount" = "⭐️ %s"; +"GiftOfferAmountHigher" = "高 %s"; +"GiftOfferAmountHigherHint" = "此礼物的价值比出价 **高 %s**"; +"GiftOfferAmountHigherHint2" = "此出价比 %2$s 的平均价格 **高 %1$s**"; +"GiftOfferAmountLower" = "低 %s"; +"GiftOfferAmountLowerHint" = "此礼物的价值比出价 **低 %s**"; +"GiftOfferAmountLowerHint2" = "此出价比 %2$s 的平均价格 **低 %1$s**"; +"GiftOfferButtonStars" = "出价 ⭐️ %s"; +"GiftOfferConfirmSend" = "确认出价"; +"GiftOfferDecline" = "拒绝"; +"GiftOfferDuration" = "出价有效期"; +"GiftOfferDurationInfoStars" = "选择 **%s** 接受您出价的有效期限。过期后,将退还您的 **星星**。"; +"GiftOfferDurationInfoTON" = "选择 **%s** 接受您出价的有效期限。过期后,将退还您的 **Gram**。"; +"GiftOfferHours#other" = "%d 小时"; +"GiftOfferOfferedTextStars" = "**%1$s** 出价 **%2$s 颗星星** 购买您的 **%3$s**。"; +"GiftOfferOfferedTextStars2" = "此用户向您的 **%3$s** 提出了 **%2$s 颗星星** 的报价。"; +"GiftOfferOfferedTextStarsExpired" = "您给 **%1$s** 的报价已失效。 **%2$s 颗星星** 以 **%3$s** 价格。"; +"GiftOfferOfferedTextStarsExpiredOut" = "**%1$s** 的报价已失效。 **%2$s 颗星星** 以 **%3$s**的价格。"; +"GiftOfferOfferedTextStarsOut" = "您已向 **%1$s** 出价 **%2$s 颗星星** 购买 **%3$s**。"; +"GiftOfferOfferedTextStarsRejected" = "**%1$s** 拒绝了您以 **%2$s 颗星星** 购买 **%3$s** 的出价。"; +"GiftOfferOfferedTextStarsRejectedOut" = "您拒绝了 **%1$s** 的出价,以 **%2$s 颗星星** 的价格购买您的 **%3$s**。"; +"GiftOfferOfferedTextTON" = "**%1$s** 出价 **%2$s Gram** 购买您的 **%3$s**。"; +"GiftOfferOfferedTextTON2" = "此用户向您的 **%3$s** 提出了 **%2$s Gram** 的报价。"; +"GiftOfferOfferedTextTONExpired" = "您给 **%1$s** 的报价已失效。 **%2$s Gram** 以 **%3$s** 价格。"; +"GiftOfferOfferedTextTONExpiredOut" = "**%1$s** 的报价已失效。**%2$s Gram** 以 **%3$s**的价格。"; +"GiftOfferOfferedTextTONOut" = "您向 **%1$s** 的 **%3$s** 提出了 **%2$s Gram** 的报价。"; +"GiftOfferOfferedTextTONRejected" = "**%1$s** 拒绝了您以 **%2$s Gram** 购买 **%3$s** 的出价。"; +"GiftOfferOfferedTextTONRejectedOut" = "您拒绝了 **%1$s** 的出价,以 **%2$s Gram** 的价格购买您的 **%3$s**。"; +"GiftOfferPay" = "支付 ⭐️ %s"; +"GiftOfferPayMulti" = "支付 %s 和 %s"; +"GiftOfferPayMultiPart" = "⭐️ %s"; +"GiftOfferRejectConfirmConfirm" = "拒绝"; +"GiftOfferRejectConfirmText" = "您确定要拒绝来自 **%s** 的出价吗?"; +"GiftOfferRejectConfirmTitle" = "拒绝出价"; +"GiftOfferRowDuration" = "有效期"; +"GiftOfferRowFee" = "费用"; +"GiftOfferRowOffer" = "出价"; +"GiftOfferRowValue" = "价值"; +"GiftOfferSellFor" = "以 ⭐️ %s 出售"; +"GiftOfferSentText" = "您已成功向 **%2$s** 发送购买 **%1$s** 的出价。"; +"GiftOfferSentTitle" = "礼物出价已发送"; +"GiftOfferStarsToOffer" = "出价星星数量"; +"GiftOfferStarsToOfferInfo" = "输入您想为 **%s** 出价的星星数量。"; +"GiftOfferStarsToOfferInfoIsHigh" = "此出价过高,**%2$s** 的最高出价为 **%1$s 颗星星**。"; +"GiftOfferStarsToOfferInfoIsLow" = "此出价过低,**%2$s** 的最低出价为 **%1$s 颗星星**。"; +"GiftOfferStatusAccepted" = "此出价已被接受。"; +"GiftOfferStatusExpired" = "此出价已过期。"; +"GiftOfferStatusPending" = "此出价将在 **%s** 后过期。"; +"GiftOfferStatusRejected" = "此出价已被拒绝。"; +"GiftOfferTONToOffer" = "出价 Gram 数量"; +"GiftOfferTONToOfferInfo" = "输入您想为 **%s** 出价的 Gram 数量。"; +"GiftOfferTONToOfferInfoIsHigh" = "此出价过高,**%2$s** 的最高出价为 **%1$s Gram**。"; +"GiftOfferTONToOfferInfoIsLow" = "此出价过低,**%2$s** 的最低出价为 **%1$s Gram**。"; +"GiftOfferToBuyMenu" = "出价购买"; +"GiftOfferToBuyTitle" = "出价购买"; +"GiftOfferTransferInfoTextSellStars" = "您想以 **%1$s 颗星星** 的价格将 **%3$s** 出售给 **%2$s** 吗?\n\n扣除手续费后您将收到 **%4$s 颗星星**。"; +"GiftOfferTransferInfoTextSellTON" = "您想以 **%1$s Gram** 的价格将 **%3$s** 出售给 **%2$s** 吗?\n\n扣除手续费后您将收到 **%4$s Gram**。"; +"GiftOfferTransferInfoTextStars" = "您想向 **%2$s** 出价 **%1$s 颗星星** 购买 **%3$s** 吗?"; +"GiftOfferTransferInfoTextTON" = "您想向 **%2$s** 出价 **%1$s Gram** 购买 **%3$s** 吗?"; +"GiftOnSale" = "特价 %s"; +"GiftPremium" = "赠予高级版订阅"; +"GiftPremiumAboutThisLink" = "此链接允许您或 **您选择的任何人** 启用 %1$s。"; +"GiftPremiumAboutThisLinkEnd" = "**Telegram 高级版** 订阅"; +"GiftPremiumActivateErrorText" = "您可以在 %1$s 之后激活此礼品码,或者 **发送链接** 给朋友。"; +"GiftPremiumActivateErrorTitle" = "您已经拥有 Telegram 高级版"; +"GiftPremiumActivateForFree" = "免费激活"; +"GiftPremiumChooseRecipientsBtn" = "选择接收者"; +"GiftPremiumEventAdsTitle" = "👑 史努比狗狗礼物放送 🎁"; +"GiftPremiumFrequentContacts" = "常用联系人"; +"GiftPremiumGifting" = "赠送 Telegram 高级版"; +"GiftPremiumGiftsSent#other" = "礼物已赠送!"; +"GiftPremiumGiftsSentStatus" = "对方现已可以使用额外功能。"; +"GiftPremiumGiftsSentStatusForUser" = "**%1$s** 现在可以访问其它功能。"; +"GiftPremiumListFeaturesAndTerms" = "您可以在 *这里* 查看 Telegram 高级版的功能列表和更多详情。"; +"GiftPremiumOptionDiscount" = "-%1$d%%"; +"GiftPremiumPrivacyPolicy" = "**隐私政策**。"; +"GiftPremiumPrivacyPolicyAndTerms" = "赠予 Telegram 高级版即表示您同意 **Telegram 服务条款** 和%1$s"; +"GiftPremiumProceedBtn" = "继续"; +"GiftPremiumUseGiftBtn" = "使用礼物"; +"GiftPremiumUsersGiveAccessMany#other" = "让 %2$s 和 **%1$d** 个其他好友使用专属功能。"; +"GiftPremiumUsersGiveAccessManyZero" = "让 %1$s 享有专属功能。"; +"GiftPremiumUsersOne" = "**%1$s**"; +"GiftPremiumUsersPurchasedMany#other" = "%2$s 和其他 **%1$d** 个人已收到您购买的礼物的通知。"; +"GiftPremiumUsersPurchasedManyZero" = "%1$s 已收到您购买礼物的通知。"; +"GiftPremiumUsersSearchHint" = "搜索要赠予高级版的对象..."; +"GiftPremiumUsersThree" = "**%1$s**, **%2$s**, **%3$s**"; +"GiftPremiumUsersTwo" = "**%1$s** 和 **%2$s**"; +"GiftPremiumWhatsIncluded" = "包含了什么"; +"GiftPremiumWillReceiveBoosts" = "您将会获得 ⚡ **%1$d** 个助推。"; +"GiftPremiumWillReceiveBoostsPlural#other" = "您将获得 ⚡ **%1$d** 次助推。"; +"GiftPreviewBackdrop" = "背景"; +"GiftPreviewBackdrops" = "背景"; +"GiftPreviewCountBackdrops#other" = "此收藏系列包含 **%d** 个独家背景"; +"GiftPreviewCountModels#other" = "此收藏系列包含 **%d** 个独特模型"; +"GiftPreviewCountModelsCrafting#other" = "此收藏系列包含\u00a0**%d**\u00a0个可制作模型。"; +"GiftPreviewCountModelsCrafting2#other" = "此典藏品包含 **%d** 个可用于制作的普通模型。"; +"GiftPreviewCountSymbols#other" = "此收藏系列包含 **%d** 个独家符号"; +"GiftPreviewModel" = "模型"; +"GiftPreviewModels" = "模型"; +"GiftPreviewSymbol" = "符号"; +"GiftPreviewSymbols" = "符号"; +"GiftRarityEpic" = "史诗"; +"GiftRarityLegendary" = "传奇"; +"GiftRarityRare" = "稀有"; +"GiftRarityUncommon" = "罕见"; +"GiftRemovedDescription" = "已移除 **%s** 的描述!"; +"GiftRepostedToChannelProfile" = "礼物已转发到 **%s**."; +"GiftRepostedToProfile" = "礼物已转发到你的个人资料页。"; +"GiftResaleFilterAllListings" = "所有礼物"; +"GiftResaleFilterForStarsOnly" = "仅限星星"; +"GiftResaleStarsOnly" = "仅显示星星礼物"; +"GiftSelfTopAction" = "您于 **%1$s** 购买了此礼物"; +"GiftSelfTopActionCrafted" = "您于 **%1$s** 制作了此礼物"; +"GiftSkipAnimation" = "跳过"; +"GiftStarsSubtitle" = "有了星星,**%s** 将可以解锁 Telegram 上的内容和服务。"; +"GiftStarsSubtitleLinkName" = "**请查看示例 >**"; +"GiftStarsTitle" = "赠送星星"; +"GiftSubscriptionCountFor#other" = "以 %2$s 赠予 %1$d 份订阅"; +"GiftSubscriptionFor" = "花费 %1$s 赠予订阅"; +"GiftTelegramPremiumDescription" = "将 **Telegram 高级版** 的专属功能赠予 **%1$s**。"; +"GiftTelegramPremiumOrStarsTitle" = "赠送礼物"; +"GiftTelegramPremiumTitle" = "赠予 Telegram 高级版"; +"GiftThemesSetActionView" = "查看"; +"GiftThemesSetByOther" = "%1$s 已将 **%2$s** 设置为此聊天的新主题。"; +"GiftThemesSetByYou" = "**您**将 **%s** 设置为此聊天的新主题。"; +"GiftThemesSetIn" = "设置为主题..."; +"GiftThemesSetInReuseConfirm" = "确认"; +"GiftThemesSetInReuseInfo" = "您在与 **%s** 的聊天中已设置了这个礼物作为主题,要从那里移除并在这里使用吗?"; +"GiftTopAction" = "**%1$s** 于 **%2$s** 向您赠送了此礼物"; +"GiftValue2" = "价值"; +"GiftValue2LearnMore" = "了解更多"; +"GiftValueAverage" = "过去一个月 **%s** 礼物在 Telegram 和 Fragment 上的平均售价。"; +"GiftValueAveragePrice" = "平均价格"; +"GiftValueAveragePriceInfo" = "**%1$s** 是过去一个月内在 Telegram 和 Fragment 上架的 **%2$s** 礼物的平均售价。"; +"GiftValueInitialPrice" = "初始价格"; +"GiftValueInitialSale" = "首次发售"; +"GiftValueLastFragment" = "这是 **%s** 在 Fragment 上的最新成交价。"; +"GiftValueLastPrice" = "最新价格"; +"GiftValueLastSale" = "最新成交"; +"GiftValueLastTelegram" = "这是 **%s** 在 Telegram 上的最新成交价。"; +"GiftValueMinPrice" = "最低价格"; +"GiftValueMinPriceInfo" = "**%1$s** 是在 Telegram 和 Fragment 上架的 **%2$s** 礼物的最低价。"; +"GiftValueOnSaleFragment" = "在 Fragment 上出售 >"; +"GiftValueOnSaleTelegram" = "在 Telegram 上出售 >"; +"GiftsBirthdaySetup" = "添加您的生日"; +"GiftsFeature1Text" = "升级您的礼物以获得独特的编号、模型、背景和符号。"; +"GiftsFeature1Title" = "独特"; +"GiftsFeature2Text" = "在 Telegram 或第三方 NFT 市场上出售您的礼物。"; +"GiftsFeature2Title" = "可交易"; +"GiftsFeature3Text" = "在您的个人资料页上展示礼物并将其设置为封面或状态。"; +"GiftsFeature3Title" = "可佩戴"; +"GiftsPinLimit#other" = "你不能置顶超过 %d 份礼物。"; +"GigagroupAlertLearnMore" = "了解更多"; +"GigagroupAlertText" = "您的群组已经达到了 **200,000** 名成员的上限。\n\n通过将该群组转换为只有管理员才能发布的消息的**广播群组**可以提升用户容纳上限。\n您感兴趣吗?"; +"GigagroupAlertTitle" = "群成员已达上限"; +"GigagroupConvertAlertConver" = "转换"; +"GigagroupConvertAlertText" = "该群组的普通成员(非管理员)将 **永久** 失去在群组中发送消息的权限。\n\n此操作 **无法** 撤消。"; +"GigagroupConvertAlertTitle" = "您确定吗?"; +"GigagroupConvertCancelButton" = "保持为普通群组"; +"GigagroupConvertCancelHint" = "如果您改变主意,请进入群组设置 > 权限中进行修改。"; +"GigagroupConvertInfo1" = "成员数量没有限制。"; +"GigagroupConvertInfo2" = "只有管理员可以发送消息。"; +"GigagroupConvertInfo3" = "无法转回成常规组。"; +"GigagroupConvertProcessButton" = "转换为广播群组"; +"GigagroupConvertTitle" = "广播群组"; +"GiveawayBtn" = "抽奖活动"; +"GiveawayChooseUsersRandomly" = "随机选出"; +"GiveawayStars#other" = "%d 颗星星"; +"GlobalAttachAudioRestricted" = "此群组不允许发送音频。"; +"GlobalAttachDocumentsRestricted" = "这个群组不允许发送文档。"; +"GlobalAttachEmojiRestricted" = "此群组不允许发送文本消息。"; +"GlobalAttachGifRestricted" = "此群组不允许发送 GIF。"; +"GlobalAttachInlineRestricted" = "此群组不允许发送机器人内容。"; +"GlobalAttachMediaRestricted" = "该群组不允许发送媒体文件。"; +"GlobalAttachPhotoRestricted" = "此群组不允许发送图片。"; +"GlobalAttachPlainRestricted" = "该群组不允许发送文本消息。"; +"GlobalAttachRoundRestricted" = "这里不允许发送视频消息。"; +"GlobalAttachStickersRestricted" = "此群组不允许发送贴纸。"; +"GlobalAttachVideoRestricted" = "此群组不允许发送视频。"; +"GlobalAttachVoiceRestricted" = "此群组不允许发送语音。"; +"GlobalAutoDeleteInfo" = "如果启用,您发起的聊天中的所有新消息将在发送后的某个时间点自动删除。您还可以**将此设置应用于现有聊天**。"; +"GlobalSearch" = "全局搜索"; +"GlobalSendMessageRestricted" = "此群组不允许发送消息。"; +"GoToSettings" = "转到设置"; +"GotAQuestion" = "对 Telegram 有疑问?"; +"GotIt" = "明白了"; +"GpsDisabledAlert" = "看起来您停用了定位服务,若需要使用基于位置的功能,请将其重新开启。"; +"GpsDisabledAlertText" = "请开启您的GPS以便获取当前位置信息"; +"GpsDisabledAlertTitle" = "启用 GPS"; +"GraceMessage" = "不要失去 Telegram 高级版的专属功能。"; +"GraceSuggestionButton" = "修复订阅"; +"GraceSuggestionMessage" = "⚠️ 抱歉,您最近一次付款没有成功。若要继续享有专属功能,请更新订阅。"; +"GraceSuggestionTitle" = "Telegram 高级版的权限即将到期!"; +"GraceTitle" = "⚠️ 您的 Telegram 高级版订阅即将到期!"; +"Grain" = "噪点"; +"Gray" = "灰色"; +"Green" = "绿色"; +"GreetingHow" = "%s 为所有空白聊天添加了上述消息 **如何实现?**"; +"GreetingHowSticker" = "%s 已为所有空聊天设置此贴纸 **如何实现?**"; +"GroupAddMembers" = "添加成员"; +"GroupAdmins" = "群组管理员"; +"GroupAppearanceUpdated" = "群组外观已更新"; +"GroupBoostedByUserWithDescription" = "助推 **%1$s** 以帮助其解锁新功能并使您的消息上展示助推者**徽章**。"; +"GroupBoostedByUserWithTimes#other" = "**%2$s** 已助推群组 **%1$d** 次。"; +"GroupBoostsJustReachedLevel1" = "此群组已达到 ** 等级1 **,现在可以发布故事。"; +"GroupBoostsJustReachedLevelNext" = "此群组已达到 **等级 %1$d**,现在每天可以发布 **%2$s** 个动态。"; +"GroupCalMessageDecryptionError" = "不支持的消息"; +"GroupCallCreate" = "开始新通话"; +"GroupCallCreate2" = "创建新通话"; +"GroupCallCreateAddMembers" = "添加成员"; +"GroupCallCreateInfo#other" = "您最多可以向通话添加 %d 位参与者。"; +"GroupCallCreateLink" = "创建通话链接"; +"GroupCallCreateText" = "此通话不再有效。您可以开始新通话。"; +"GroupCallCreateTitle" = "开始群组通话"; +"GroupCallCreateVideo" = "视频"; +"GroupCallCreateVoice" = "声音"; +"GroupCallCreatedLinkCopy" = "复制"; +"GroupCallCreatedLinkJoinOr" = "或"; +"GroupCallCreatedLinkJoinText" = "率先加入通话并在通话中添加其他人。 **打开通话 >**"; +"GroupCallCreatedLinkRevoke" = "撤销链接"; +"GroupCallCreatedLinkRevokedText" = "已生成新链接。"; +"GroupCallCreatedLinkRevokedTitle" = "链接已撤销"; +"GroupCallCreatedLinkShare" = "分享"; +"GroupCallCreatedLinkText" = "Telegram 上的任何人都可以通过以下链接加入您的通话。"; +"GroupCallCreatedLinkTitle" = "通话链接"; +"GroupCallDiscardInvite" = "取消邀请"; +"GroupCallLinkJoin" = "加入群组通话"; +"GroupCallLinkMicrophone" = "打开麦克风"; +"GroupCallLinkText" = "您已获邀请加入此群组通话。"; +"GroupCallLinkText2Many#other" = "**%2$s**, **%3$s** 和其他 **%1$d** 人已加入本次通话。"; +"GroupCallLinkText2One" = "**%1$s** 已加入此通话。"; +"GroupCallLinkText2Two" = "**%1$s** 和 **%2$s** 已加入此次通话。"; +"GroupCallLinkTitle" = "群组通话"; +"GroupCallShowInMainTabs" = "显示通话标签页"; +"GroupCallStopCallingInvite" = "结束通话"; +"GroupCallTabWasHiddenTitle" = "**通话**标签现已隐藏\n"; +"GroupCallTabWasShownTitle" = "**通话** 标签现已显示"; +"GroupChats" = "群聊"; +"GroupContacts" = "此群组内的联系人"; +"GroupCreateAutodeleteDescription" = "一段时间后,自动为所有人删除此群组中的消息。"; +"GroupCreateDiscussionDefaultName" = "%1$s 聊天"; +"GroupCreateMembersFive" = "%1$s、%2$s、%3$s、%4$s 和 %5$s"; +"GroupCreateMembersFour" = "%1$s、%2$s、%3$s 和 %4$s"; +"GroupCreateMembersThree" = "%1$s、%2$s 和 %3$s"; +"GroupCreateMembersTwo" = "%1$s 和 %2$s"; +"GroupDeletedUndo" = "群组已删除"; +"GroupDescription1" = "成员上限为 20 万人"; +"GroupDescription2" = "永久聊天记录"; +"GroupDescription3" = "公开链接,例如:t.me/title"; +"GroupDescription4" = "具备不同权限的管理员"; +"GroupEmoji" = "群组表情符号"; +"GroupEmojiPack" = "群组表情包"; +"GroupEmojiPackHint" = "此群组的所有成员都可以使用 %s 包。"; +"GroupEmojiPackInfo" = "选择一个可供群组内所有成员使用的表情包。"; +"GroupEmojiStatus" = "群组表情符号状态"; +"GroupEmojiStatusInfo" = "选择将显示在群组名称旁边的状态。"; +"GroupEmptyTitle1" = "您已创建一个 **群组**"; +"GroupEmptyTitle2" = "群组可以拥有:"; +"GroupJoin" = "加入"; +"GroupJoinNoCaps" = "加入"; +"GroupJoinRequest" = "申请加入群组"; +"GroupJoinRequestNoCaps" = "申请加入群组"; +"GroupJoinRequestSent" = "加入请求已发送"; +"GroupJoinRequestSentNoCaps" = "已发送加入请求"; +"GroupLeftUndo" = "您退出了群组。"; +"GroupMembers" = "成员"; +"GroupMembers2" = "群组成员"; +"GroupMembersChartTitle" = "群组成员"; +"GroupMessagesChargePrice" = "为消息收取星星"; +"GroupMessagesChargePriceInfo" = "如果启用此功能,群组的普通成员必须支付星星才能发送消息。"; +"GroupMessagesPriceHeader" = "设置每条消息的价格"; +"GroupMessagesPriceInfo" = "你的群组每有一条消息就会获取 %1$s 设置费用(%2$s)"; +"GroupName" = "群组名称"; +"GroupNeedBoostsAlreadyBoostedDescriptionLevel1" = "本群组还需要 %s 次助推来启用动态。"; +"GroupNeedBoostsDescription" = "您的群组需要 %s 才能发布动态。\n\n请您的 **高级** 会员使用此链接助推2您的群组:"; +"GroupNeedBoostsDescriptionForNewFeatures" = "**%1$s** 需要 %2$s 來解锁新功能。"; +"GroupNeedBoostsDescriptionLevel1" = "本组需要 %s 来启用故事。帮助实现它!"; +"GroupNeedBoostsDescriptionLevelNext" = "本群组需要 %1$s 才能每天发布 **%2$s**。帮助使其成为可能!"; +"GroupNeedBoostsDescriptionNextLevel" = "您的群组需要 %1$s 才能每天发布 **%2$s**。 \n\n请您的高级版会员通过此链接来助推您的群组:"; +"GroupNeedBoostsForColorDescription" = "您的群组需要达到 **等级 %d** 才能更改为所选的群组颜色。 **高级版** 会员可以通过此链接来助推您的群组:"; +"GroupNeedBoostsForCustomEmojiPackDescription" = "您的群组需要达到 **等级 %d** 才能设置群组表情包。 请您的 **高级** 会员通过此链接来助推您的群组:"; +"GroupNeedBoostsForCustomWallpaperDescription" = "您的群组需要达到 **等级 %d** 才能将群组背景更改为自定义照片。 **高级版** 会员可以通过此链接来助推您的群组:"; +"GroupNeedBoostsForEmojiStatusDescription" = "您的群组需要达到 **等级 %d** 才能设置群组表情符号状态。 \n\n**高级版** 会员可以通过此链接来助推您的群组:"; +"GroupNeedBoostsForProfileColorDescription" = "您的群组需要达到 **等级 %d** 才能更改为所选的群组资料页颜色。 **高级版** 会员可以通过此链接来助推您的群组:"; +"GroupNeedBoostsForProfileIconDescription" = "您的群组需要达到 **等级 %d** 才能更改群组资料页图标。 \n\n**高级版** 会员可以通过此链接来助推您的群组:"; +"GroupNeedBoostsForReplyIconDescription" = "您的群组需要达到 **等级 %d** 才能更改群组链接样式图标。 \n\n **Telegram 高级版** 用户可以通过此链接助推您的群组:"; +"GroupNeedBoostsForWallpaperDescription" = "您的群组需要达到 **等级 %d** 才能更改群组背景。 请您的 **高级版** 会员通过此链接来助推您的群组:"; +"GroupNeedBoostsForWearCollectiblesDescription" = "您的群组需要达到 %d 级才能佩戴收藏品,**高级版** 用户可通过此链接助推群组:"; +"GroupNotRestrictBoosters" = "不限制助推者"; +"GroupNotRestrictBoostersInfo" = "启用此功能可始终允许已助推您群组的用户发送消息和媒体。"; +"GroupNotRestrictBoostersInfo2" = "选择用户必须为群组助推几次才能不受发送消息的限制。"; +"GroupNotifications" = "群组通知"; +"GroupPhotosHelp" = "将媒体文件合并至一条消息"; +"GroupPrivateSettingsJoinRequestInfo2" = "管理员必须批准任何想加入群组的人。"; +"GroupPrivateSettingsJoinRequestInfoManagedBy" = "新成员加入前需得到管理员的批准。由 **%s** 管理"; +"GroupProfileInfo" = "为群组资料页选择颜色和图标。"; +"GroupPublicSettingsJoinRequestInfo2" = "管理员必须批准任何想要在群组中发送消息的人。"; +"GroupPublicSettingsJoinRequestInfoManagedBy" = "所有人在群组发消息需管理员批准。由 **%s** 管理"; +"GroupSettingsChangedAlert" = "您已更改群组的一些设置。是否应用更改?"; +"GroupSettingsTitle" = "群组设置"; +"GroupStickerPack" = "群组贴纸包"; +"GroupStickerPackInfo" = "您可以选择一个贴纸包,向所有群组成员显示。"; +"GroupStickers" = "群组表情包"; +"GroupStickersInfo" = "您可以选择一套贴纸包,所有群组成员在聊天时均可使用该贴纸包。"; +"GroupThemePreviewSenderName" = "Monika"; +"GroupTooLarge" = "群组过大"; +"GroupTooLargeMessage" = "您可以选择最多有 200 名成员的群组。"; +"GroupType" = "群组类型"; +"GroupTypeHeader" = "群组类型"; +"GroupUserAddLimit" = "群组成员数量过多,无法加入此群组。"; +"GroupUserCantAdd" = "抱歉,您无法将此用户添加到群组。"; +"GroupUserCantAdmin" = "抱歉,此群组管理员人数已达上限。"; +"GroupUserCantBot" = "抱歉,此群组的机器人数量已达上限。请先删除一些您不使用的机器人。"; +"GroupUserLeftError" = "抱歉,如果某人已不再是群组的成员,您需要成为其 Telegram 联系人才能将对方添加回来。\n\n注意只要对方不在被移除用户列表中,就仍可通过群组的邀请链接加入。"; +"GroupWallpaper" = "群组壁纸"; +"GroupWallpaper2Info" = "设置对所有正在阅读您的群组的用户都可见的壁纸。"; +"GroupWallpaperInfo" = "为群组页面上传您的背景图片。"; +"Groups#other" = "%1$d 个群组"; +"GroupsAndChannels" = "群组与频道"; +"GroupsAndChannelsHelp" = "选择哪些人能将您添加到群组和频道中。"; +"GroupsAndChannelsLimitSubtitle" = "加入多达 %d 个频道和大型群组"; +"GroupsAndChannelsLimitTitle" = "群组和频道"; +"GroupsEmojiPackUpdated" = "群组表情包已更新"; +"GroupsInCommon" = "共同加入的群组"; +"GroupsInCommonTitle" = "共同加入的群组"; +"GrowthChartTitle" = "增长"; +"GuardBotJoinRequestApproved" = "您加入 **%1$s** 的请求已获得批准。"; +"GuardBotJoinRequestDeclined" = "您加入 **%1$s** 的请求已被拒绝。"; +"GuardBotJoinRequestQueued" = "您加入 %1$s 的请求已发送给管理员。"; +"GuardBotReplaceKeepCurrent" = "保持 %1$s"; +"GuardBotReplaceMessage" = "**%1$s** 正在处理加入请求。切换至 **%2$s** ?\n"; +"GuardBotReplaceTitle" = "更换机器人"; +"GuardBotReplaceUseNew" = "使用 %1$s"; +"GuardBotTimeout" = "管理员机器人当前无法处理加入请求。请稍后再试。"; +"GuardBotTimeoutTitle" = "加入请求"; +"GuestBotFor" = " 给 "; +"HasBeenAddedToChannel" = "已将 %1$s 加入频道"; +"HasBeenAddedToGroup" = "%1$s 已添加到群组"; +"HashtagCopied" = "标签已复制至剪贴板。"; +"HashtagMessagesFound#other" = "找到 %d 条消息"; +"HashtagMessagesFoundChannel#other" = "**%2$s** 中有 %1$d 条消息"; +"HashtagMessagesFoundSubtitle" = "查看含有 %s 的贴文"; +"HashtagSearchEmptyViewFilteredSubtitle" = "没有找到与 %s 相关的结果。\n请尝试搜索其他标签。"; +"HashtagSearchPlaceholder" = "输入标签,查找包含该标签的消息。"; +"HashtagStoriesFound#other" = "找到 %d 个动态。"; +"HashtagStoriesFoundChannel#other" = "**%2$s** 中有%1$d 条动态"; +"HashtagStoriesFoundSubtitle" = "查看与 %s 相关的动态"; +"HashtagSuggestion1Text" = "从所有频道搜索贴文"; +"HashtagSuggestion1Title" = "使用 %s"; +"HashtagSuggestion2Text" = "仅搜索此频道中的贴文"; +"HashtagSuggestion2Title" = "使用 %s"; +"Hashtags" = "标签"; +"HelpUpgradeChannel" = "帮助升级此频道"; +"HelpUpgradeGroup" = "帮助此群组升级"; +"HidAccount" = "用户主动隐藏该账户"; +"HiddenName" = "已删除账户"; +"HiddenSendersNameDescription" = "已移除发送者名称"; +"HiddenStories" = "已隐藏的动态"; +"Hide" = "隐藏"; +"HideAboveTheList" = "从列表中隐藏"; +"HideAd" = "隐藏"; +"HideCallTab" = "隐藏通话标签"; +"HideCaption" = "隐藏说明"; +"HideNewChatsAlertText" = "您收到了很多来自陌生用户的新聊天会话。您是否想将这些聊天会话**自动静音**并**归档**?"; +"HideNewChatsAlertTitle" = "隐藏新聊天?"; +"HideNextViews" = "隐藏之后的浏览记录"; +"HideNextViewsDescription" = "隐藏我在接下来 25 分钟内的浏览记录。"; +"HideOnTop" = "隐藏"; +"HidePinnedMessages" = "隐藏置顶消息"; +"HidePinnedMessagesNoCaps" = "隐藏置顶消息"; +"HideReadTime" = "隐藏已读时间"; +"HideReadTimeInfo" = "对无法看到您最后上线时间的用户隐藏您的已读时间。如果您开启此功能,对方的已读时间也会对您隐藏(除非您是高级版订阅用户)。 \n\n此设定不影响群聊。"; +"HideRecentViews" = "隐藏最近的浏览记录"; +"HideRecentViewsDescription" = "隐藏我在过去 5 分钟内的浏览记录。"; +"HideSenderNames" = "隐藏发送者名称"; +"HideSendersName" = "隐藏发送者名称"; +"HideStoriesFromAlbum#other" = "从相册里移除动态"; +"HideTranslation" = "隐藏翻译"; +"Highlights" = "高光"; +"HintAddYourPhoto" = "添加你的头像! 📸"; +"HintAddYourPhotoText" = "帮助您的朋友轻松发现您。"; +"HintRead" = "已读"; +"HintSent" = "已发送"; +"HintViewStoriesMultiple#other" = "点击此处查看您的 %d 个动态"; +"HistoryCleared" = "历史记录已被清空"; +"HistoryClearedUndo" = "历史记录已清空"; +"HoldToAudio" = "长按以录制语音消息,点按切换为视频消息。"; +"HoldToAudioOnly" = "长按录音"; +"HoldToVideo" = "长按以录制视频消息,点按切换为语音消息。"; +"HourAgo" = "一小时前"; +"Hours#other" = "%1$d 小时"; +"HoursAgo#other" = "%d 小时前"; +"HoursBold#other" = "**%1$d** 小时"; +"HoursSchedule#other" = "%1$d 小时"; +"HowDoesItWork" = "这是如何运作的?"; +"Hybrid" = "混合"; +"IUnderstand" = "明白了"; +"IVInteractionsChartTitle" = "即时预览互动"; +"IfInactiveFor" = "如果我不上线"; +"Images#other" = "%d 张图片"; +"ImagesTab" = "图片"; +"ImagesTab2" = "图片"; +"ImporImportingService" = "正在导入消息和媒体文件…"; +"ImporImportingStickersService" = "正在导入表情包..."; +"Import" = "导入"; +"ImportContacts" = "导入通讯录"; +"ImportCount" = "%1$s / %2$s"; +"ImportDone" = "完成"; +"ImportDoneInfo" = "导入更多聊天到 Telegram 以节省设备存储空间。"; +"ImportDoneTitle" = "聊天导入成功!"; +"ImportErrorChatInvalidGroup" = "此文件格式只能导入到个人会话而不是群组。"; +"ImportErrorChatInvalidUser" = "此文件格式使用于用户,但你已选择一个群组。"; +"ImportErrorFileFormatInvalid" = "无效的文件格式。"; +"ImportErrorFileLang" = "文件包含未知语言。"; +"ImportErrorNotAdmin" = "您必须是群组管理员才能导入消息。"; +"ImportErrorPeerInvalid" = "无法将历史记录导入所选对话。"; +"ImportErrorTitle" = "导入错误"; +"ImportErrorUserBlocked" = "由于隐私设置,无法导入消息。"; +"ImportFailedToUpload" = "上传文件失败"; +"ImportFileTooLarge" = "文件过大无法导入"; +"ImportHeader" = " 将消息和媒体文件导入到..."; +"ImportHeaderContacts" = "或选择一个联系人"; +"ImportImportingInfo" = "请保持 Telegram 开启\n直到导入完成。"; +"ImportImportingTitle" = "导入聊天中"; +"ImportMessages" = "导入消息"; +"ImportMutualError" = "您只能将消息导入至与您互为联系人的用户的私聊中。"; +"ImportNotAdmin" = "您必须是群组管理员才能导入消息。"; +"ImportStickers" = "导入表情包: %1$s"; +"ImportStickersDoneInfo" = "在 Telegram 中导入更多贴纸并与好友分享"; +"ImportStickersDoneTitle" = "表情导入成功!"; +"ImportStickersEnterName" = "表情包名称"; +"ImportStickersEnterNameInfo" = "为您的表情包起个名字。"; +"ImportStickersEnterUrlInfo" = "您可以使用**a-z**, **0-9** 和下划线"; +"ImportStickersImportingTitle" = "正在导入表情包..."; +"ImportStickersLinkAvailable" = "此链接可用"; +"ImportStickersLinkChecking" = "正在检查链接..."; +"ImportStickersLinkInvalid" = "抱歉,此链接已失效"; +"ImportStickersLinkInvalidLong" = "链接不可超过**32**个字符"; +"ImportStickersLinkInvalidShort" = "链接不可少于5个字符"; +"ImportStickersLinkTaken" = "抱歉, 此链接已被占用。"; +"ImportStickersProcessing" = "正在处理贴纸..."; +"ImportStickersRemove" = "从导入中删除"; +"ImportStickersRemoveMenu" = "从导入中删除"; +"ImportToChat" = "你确定要将与 **%1$s** 聊天的信息导入到群组 **%2$s** 吗?\n\n截止到当前的所有信息都会被导入,但同时也会包括他们的原始时间戳。所有成员都会看到这些信息。"; +"ImportToChatNoTitle" = "您确定要把消息导入到 **%1$s** 吗?\n\n消息将被导入到当日的记录中,但也会包括原始时间戳。所有成员都会看到这些消息。"; +"ImportToUser" = "您确定要把消息从 **%1$s** 导入到与 **%2$s** 的聊天中吗?\n\n消息将被导入到当日的记录中,但也会包括原始时间戳。双方都会看到这些消息。"; +"ImportToUserNoTitle" = "您确定要把消息导入到与 **%1$s** 的聊天中吗?\n\n消息将被导入到当日的记录中,但也会包括原始时间戳。双方都会看到这些消息。"; +"ImportUploading" = "正在导入消息和媒体文件 **%d%%**"; +"ImportedInfo" = "此信息由其他软件导入,无法保证其真实性。"; +"ImportedMessage" = "已导入"; +"InAppNotifications" = "应用内通知"; +"InAppPopup" = "应用内在屏幕上弹出"; +"InAppPopupInfo" = "在应用内显示弹出通知。"; +"InAppPreview" = "应用内消息预览"; +"InAppSounds" = "应用内提示音"; +"InAppVibrate" = "应用内振动"; +"InChatSound" = "发送提示音"; +"InactiveChannelSignature" = "频道已经不活跃了 %1$s"; +"InactiveChatSignature" = "%1$s,处于非活跃状态已有 %2$s"; +"InactiveChats" = "不活跃的聊天"; +"InappBrowser" = "应用内浏览器"; +"InappBrowserInfo" = "在应用程序内打开外部链接"; +"IncomingCalls" = "呼入"; +"IncomingCallsCount" = "%s 个来电"; +"IncomingCallsSystemSetting" = "内部通话通知"; +"IncomingCallsSystemSettingDescription" = "您可以在 “Telegram” > “设置” > “通知和声音” 中更改铃声。"; +"IncorrectLocalization" = "错误的本地化文件"; +"IncorrectTheme" = "抱歉,该主题文件似乎已损坏。"; +"IncreaseLimit" = "提升上限"; +"Infinity" = "∞"; +"Info" = "个人信息"; +"InstallGoogleMaps" = "要安装 Google 地图吗?"; +"InstallHuaweiMaps" = "是否安装 Petal 地图?"; +"InstallOfficialApp" = "安装官方应用"; +"InstantView" = "即时预览"; +"InstantViewNightMode" = "暗色主题将会在夜间自动启用"; +"InstantViewReference" = "参考"; +"InstantViewReferences" = "参考"; +"InteractionsChartTitle" = "互动"; +"InternalFolderInfo" = "浏览文件系统"; +"InternalStorage" = "内部存储器"; +"InvLinkToChats" = "邀请链接已发送给 %1$s 。"; +"InvLinkToGroup" = "邀请链接发送给 %1$s 。"; +"InvLinkToSavedMessages" = "邀请链接已保存至 **我的收藏**。"; +"InvLinkToUser" = "邀请链接已发送给 %1$s 。"; +"InvalidCode" = "验证码无效,请重试。"; +"InvalidFirstName" = "不符合规范的名字,请重新输入。"; +"InvalidFormatError" = "格式无效"; +"InvalidLastName" = "抱歉,此姓氏不可用"; +"InvalidPhoneNumber" = "手机号码无效。请检查号码后重试。"; +"Invisible" = "隐身"; +"Invitations#other" = "%1$d 次邀请"; +"Invite" = "邀请"; +"InviteBlockedManyMessage" = "你仍可以通过私聊发送邀请链接。"; +"InviteBlockedOneMessage" = "你还可以通过私人消息发送邀请链接来邀请。"; +"InviteBlockedTitle" = "通过链接邀请"; +"InviteByQRCode" = "通过二维码邀请"; +"InviteCallMessagePremiumBlockedMany#other" = "\n**%2$s**、**%3$s** 和 其他 **%1$d** 人只接受来自其联系人和 **高级版** 用户的消息和通话邀请。"; +"InviteCallMessagePremiumBlockedOne" = "**%1$s** 仅接受来自联系人和 **高级版** 用户的消息和通话邀请。"; +"InviteCallMessagePremiumBlockedThree" = "**%1$s**、**%2$s** 和 **%3$s** 只接受来自其联系人和 **高级版** 用户的通话邀请。"; +"InviteCallMessagePremiumBlockedTwo" = "**%1$s** 和 **%2$s** 仅接受来自联系人和 **高级版** 用户的消息和通话邀请。"; +"InviteCallRestrictedUsers#other" = "**%d 位用户** 限制接听通话。您可以向他们发送本次通话的邀请链接。"; +"InviteCallRestrictedUsersOne" = "**%s** 限制接听通话。您可以向他们发送本次通话的邀请链接。"; +"InviteChannelRestrictedUsers#other" = "**%d 位用户**限制将其加入频道,您可以给对方发送邀请链接。"; +"InviteChannelRestrictedUsers2#other" = "**%d 位用户**只能通过邀请链接邀请,但您无权分享此频道的邀请链接。"; +"InviteChannelRestrictedUsers2One" = "**%s**只能通过邀请链接邀请,但您无权分享此频道的邀请链接。"; +"InviteChannelRestrictedUsersOne" = "**%s** 限制将其加入频道,您可以给对方发送邀请链接。"; +"InviteExpired" = "邀请链接已失效。"; +"InviteFriends" = "邀请朋友"; +"InviteFriendsHelp" = "选择要邀请到 Telegram 的联系人"; +"InviteLink" = "邀请链接"; +"InviteLinkCount#other" = "%1$d 条邀请链接"; +"InviteLinkPrivate" = "此话题链接只对群组成员有效"; +"InviteLinkSent#other" = "已将邀请链接发送给 %d 名用户。"; +"InviteLinkSentSingle" = "已将邀请链接发送给 **%s**。"; +"InviteLinks" = "邀请链接"; +"InviteMessagePremiumBlockedMany#other" = "**%2$s**、**%3$s** 和 **%1$d** 个人只允许联系人和 **高级版** 用户发起消息或邀请其加入群组。"; +"InviteMessagePremiumBlockedOne" = "**%1$s** 只允许联系人和 **高级版** 用户发起消息或邀请其加入群组。"; +"InviteMessagePremiumBlockedThree" = "**%1$s**、**%2$s** 和 **%3$s** 只允许联系人和 **高级版** 用户发起邀请其加入群组。"; +"InviteMessagePremiumBlockedTwo" = "**%1$s** 和 **%2$s** 只允许联系人和 **高级版** 用户发起消息或邀请其加入群组。"; +"InvitePremiumBlockedMany#other" = "**%2$s**、**%3$s** 和 **%1$d** 个人只允许联系人和 **高级版** 用户邀请其加入群组。"; +"InvitePremiumBlockedOne" = "**%1$s** 仅能从联系人和 **高级版** 用户中接收邀请以加入群组。"; +"InvitePremiumBlockedOr" = "或"; +"InvitePremiumBlockedSubscribe" = "订阅 Telegram 高级版"; +"InvitePremiumBlockedThree" = "**%1$s**、**%2$s** 和 **%3$s** 只允许联系人和 **高级版** 用户邀请其加入群组。"; +"InvitePremiumBlockedTitle" = "升级为高级版"; +"InvitePremiumBlockedTwo" = "**%1$s** 和 **%2$s** 只允许联系人和 **高级版** 用户邀请其加入群组。"; +"InvitePremiumBlockedUser" = "只允许高级版用户"; +"InviteRestrictedUsers#other" = "**%d 位用户**限制将其加入群组,您可以给对方发送邀请链接。"; +"InviteRestrictedUsers2#other" = "**%d 名用户**只能通过链接邀请,但是您无权分享本群组的邀请链接。 "; +"InviteRestrictedUsers2One" = "**%s** 只能通过邀请链接邀请,但您无权分享此群组的邀请链接。"; +"InviteRestrictedUsersOne" = "**%s** 限制将其加入群组,您可以给对方发送邀请链接。"; +"InviteRevokedHint" = "此邀请链接已被废除。"; +"InviteText2" = "嗨,我正在使用 Telegram 聊天。加入我们吧!从这里下载:%1$s"; +"InviteTextNum#other" = "嗨,我在和另外 %1$d 位联系人使用 Telegram 聊天。加入我们吧!从这里下载:%2$s"; +"InviteToChannelError" = "抱歉,根据此用户的隐私设置,您无法邀请此人关注频道。"; +"InviteToGroupByLink" = "通过链接邀请加入群组"; +"InviteToGroupError" = "抱歉,根据此用户的隐私设置,您无法将其添加到群组。"; +"InviteToGroupErrorMessageDouble" = "对不起,因为群组的隐私设定,您无法添加%1$s和%2$s到群组。"; +"InviteToGroupErrorMessageMultipleAll" = "抱歉,由于这些用户的隐私设置,你无法邀请他们到此群组中。"; +"InviteToGroupErrorMessageMultipleSome" = "抱歉,由于用户的隐私设置您无法添加他们加入群组。"; +"InviteToGroupErrorMessageSingle" = "抱歉,根据 **%1$s** 的隐私设置,您无法将 **%1$s** 添加到群组。"; +"InviteToGroupErrorTitleAUser" = "无法添加此用户"; +"InviteToGroupErrorTitleSomeUsers" = "不能添加这些用户"; +"InviteToGroupErrorTitleTheseUsers" = "无法添加这些用户"; +"InviteToGroupErrorTitleThisUser" = "不能添加此用户"; +"InviteToTelegram" = "邀请使用 TELEGRAM"; +"InviteToTelegramShort" = "邀请到 Telegram"; +"InviteUser" = "此用户还未加入 Telegram ,是否发送邀请?"; +"Invited" = "已邀请"; +"InvoiceCantForwardSecretChat" = "抱歉,账单无法转发至私密聊天。"; +"InvoiceCantSendSecretChat" = "抱歉,账单无法发送至私密聊天。"; +"IpAddress" = "IP 地址"; +"IsChoosingSticker" = "%1$s 正在选择贴纸"; +"IsEnjoyngAnimations" = "%1$s 正在 **oo** 查看 %2$s"; +"IsRecordingAudio" = "%1$s 正在录制语音消息…"; +"IsRecordingRound" = "%1$s 正在录制视频消息…"; +"IsRecordingVideo" = "%1$s 录制视频中……"; +"IsSelectingContact" = "%1$s 分享联系人中……"; +"IsSelectingLocation" = "%1$s 分享位置中……"; +"IsSendingAudio" = "%1$s 正在发送音频…"; +"IsSendingFile" = "%1$s 正在发送文件…"; +"IsSendingGame" = "%1$s 正在玩游戏…"; +"IsSendingPhoto" = "%1$s 正在发送图片…"; +"IsSendingVideo" = "%1$s 正在发送视频…"; +"IsTyping" = "正在输入…"; +"IsTypingGroup" = "%1$s 正在输入…"; +"Italic" = "斜体"; +"January" = "一月"; +"JoinByPeekChannelText" = "该频道为私人频道。请加入后继续浏览。"; +"JoinByPeekChannelTitle" = "加入频道"; +"JoinByPeekGroupText" = "此为私密群组,要继续浏览内容请先加入群组。"; +"JoinByPeekGroupTitle" = "加入群组"; +"JoinByPeekJoin" = "加入"; +"JoinCall" = "加入通话"; +"JoinGroup" = "加入群组"; +"JoinRequests#other" = "%1$d 个加入请求"; +"JoinToGroupErrorFull" = "抱歉,此群组已满员。"; +"JoinToGroupErrorNotExist" = "抱歉,此聊天似乎不存在。"; +"JoinUsersRequests#other" = "%1$d 个加入请求"; +"JoinedGroup" = "您已加入群组。"; +"JoinedGroupAddTag" = "添加标签"; +"JoinedViaFolder" = "已通过文件夹邀请链接加入"; +"JoinedViaInviteLinkApproved" = "un3 已批准 un1 通过邀请链接 un2 加入"; +"JoinedViaRequestApproved" = "un2 已批准 un1 加入"; +"July" = "七月"; +"JumpToDate" = "跳转到此日期"; +"June" = "六月"; +"KMetersAway" = "千米以外"; +"KMetersAway2" = "相距 %1$s 千米"; +"KMetersFromYou2" = "距您 %1$s 公里"; +"KMetersShort" = "%1$s 公里"; +"KeepMedia" = "媒体文件保留时间"; +"KeepMediaForever" = "永久"; +"KeepMediaInfo" = "为节省磁盘空间,此期限内云端聊天中**未访问过**的图片、视频和其他文件将会被移除。\n\n所有媒体文件都会保留在 Telegram 云端,若您需要可再次下载。"; +"KeepMediaInfoPart" = "在此期间你**未曾访问过的**云端聊天中的照片、视频和其他文件将从此设备中删除,以节省磁盘空间。"; +"KeepMediaPopupDescription" = "你想多久清除一次该聊天中缓存的媒体文件?"; +"KickFromBroadcast" = "从群发消息名单中移除"; +"KickFromGroup" = "从群组中移除"; +"KickFromSupergroup" = "限制用户权限"; +"Km" = "公里"; +"Language" = "语言"; +"LanguageAlert" = "您将要使用一个完成度为 %2$d%% 的自定义语言包(**%1$s**)。\n\n此操作将会翻译整个界面。您可以通过 [翻译平台] 提出修改建议。\n\n您可以随时在“设置”中更改语言。"; +"LanguageCode" = "zh_hans"; +"LanguageCustom" = "自定义"; +"LanguageCustomAlert" = "您即将使用一个完成度为(%2$d%%)的自定义语言包(%1$s)。\n\n此操作将会应用到整个界面。您可以通过建议进行更正 [翻译平台]()。\n\n您可以随时在“设置”中更改语言。"; +"LanguageName" = "简体中文"; +"LanguageNameInEnglish" = "Chinese (Simplified)"; +"LanguageSame" = "你已经在使用这个语言包(**%1$s**)。你随时可以在设置更改你的语言包。"; +"LanguageTitle" = "是否更改语言?"; +"LanguageUnknownCustomAlert" = "很遗憾,这个自定义语言包(**%1$s**)不包含 Telegram Android 的数据。"; +"LanguageUnknownTitle" = "数据不足"; +"LanguageUnofficial" = "非官方语言"; +"LanguageUnsupportedError" = "未找到语言。"; +"Languages#other" = "%1$d 种语言"; +"LanguagesChartTitle" = "语言"; +"LargeEmoji" = "大号表情"; +"LastActiveCommunities" = "最不活跃的社群"; +"LastName" = "姓氏(可选)"; +"LastNameSmall" = "姓氏"; +"LastSeen" = "最近上线"; +"LastSeenContacts" = "我的联系人"; +"LastSeenContactsMinus" = "我的联系人 (-%1$d)"; +"LastSeenContactsMinusPlus" = "我的联系人 (-%1$d, +%2$d)"; +"LastSeenContactsPlus" = "我的联系人 (+%1$d)"; +"LastSeenContactsPremium" = "联系人和高级版用户"; +"LastSeenContactsPremiumMinus" = "联系人和高级版用户(-%1$d)"; +"LastSeenContactsPremiumMinusPlus" = "联系人和高级版用户(-%1$d,+%2$d)"; +"LastSeenContactsPremiumPlus" = "联系人和高级版用户(+%1$d)"; +"LastSeenDate" = "最近上线"; +"LastSeenDateFormatted" = "最近上线于 %1$s"; +"LastSeenEverybody" = "所有人"; +"LastSeenEverybodyMinus" = "所有人 (-%1$d)"; +"LastSeenFormatted" = "最近上线于 %1$s"; +"LastSeenNobody" = "不允许任何人"; +"LastSeenNobodyPlus" = "不允许任何人 (+%1$d)"; +"LastSeenNobodyPremium" = "高级版用户"; +"LastSeenNobodyPremiumPlus" = "高级版用户(+%1$d)"; +"LastSeenTitle" = "谁可以看到我的最后上线的时间?"; +"Lately" = "最近曾上线"; +"Later" = "以后再说"; +"LearnMore" = "了解更多"; +"LearnMoreArrow" = "了解更多 >"; +"LeaveAComment" = "留言"; +"LeaveAffiliateLink" = "撒销链接"; +"LeaveAffiliateLinkAlert" = "你确定要撒销 **%1$s** 链接吗?"; +"LeaveAffiliateLinkButton" = "离开"; +"LeaveChannel" = "退出频道"; +"LeaveChannelAndAppoint" = "指定 %s 并离开频道"; +"LeaveChannelMenu" = "退出频道"; +"LeaveChannelNewOwnerText" = "如果您退出,**%1$s** 将在 **1 周** 后成为 **%2$s** 的新所有者。"; +"LeaveChannelTitle" = "退出频道吗?"; +"LeaveChats" = "退出 %1$s"; +"LeaveCommunities#other" = "退出 %d 个社群"; +"LeaveConversationMenu" = "退出对话"; +"LeaveGroupAndAppoint" = "指定 %s 并离开群组"; +"LeaveGroupNewOwnerText" = "如果您退出,**%1$s** 将在 **1 周** 后成为「**%2$s**」的新所有者。"; +"LeaveGroupTitle" = "退出群组吗?"; +"LeaveMega" = "离开群组"; +"LeaveMegaMenu" = "退出群组"; +"LeaveonversationMenu" = "离开聊天"; +"LedColor" = "灯光颜色"; +"LedDisabled" = "停用"; +"LessMinuteAgo" = "刚刚"; +"Lifetime" = "终身"; +"LightMode" = "精简模式(测试)"; +"LightModeInfoRow" = "此模式会关闭应用程序中的所有视觉效果。建议仅将其用于 CPU 和内存不足的设备。"; +"Likes#other" = "%d 个赞"; +"LimitByPeriod" = "时间限制"; +"LimitFree" = "免费"; +"LimitNumberOfUses" = "按用户数量限制"; +"LimitPremium" = "高级版"; +"LimitReached" = "达到上限"; +"LimitReachedAccounts" = "抱歉,你无法添加超过 **%1$d** 个帐号。你可以通过订阅 **Telegram Premium** 来将该限制提高至 %2$d 。"; +"LimitReachedAccountsPremium" = "抱歉,你无法添加超过 **%1$d** 个帐户。"; +"LimitReachedChatInFolders" = "抱歉,您无法在单个文件夹中添加超过 **%1$d** 个聊天。订阅 **Telegram 高级版** 后可将此上限提高至 **%2$d** 个聊天。"; +"LimitReachedChatInFoldersLocked" = "抱歉,您无法在此文件夹中添加超过 **%1$d** 个聊天。请再创建一个新的文件夹。我们在以后将尽力为您提高该限制。"; +"LimitReachedChatInFoldersPremium" = "抱歉,您无法在一个文件夹中添加超过 %1$d 个聊天,请创建新的文件夹。"; +"LimitReachedCommunities" = "您已加入 **%1$d** 个群组或频道。请在加入新的之前先退出一些——或者订阅 **Telegram 高级版**,将此上限翻倍至 **%2$d** 个群组或频道。"; +"LimitReachedCommunitiesLocked" = "您已加入 **%1$d** 个群组或频道。请在加入新的之前先退出一些。我们正在努力能让你在未来提高此上限。"; +"LimitReachedCommunitiesPremium" = "您已经成为 %1$d 个群组和频道的成员了,请在加入新的之前退出一些群组或频道。"; +"LimitReachedFavoriteGifs" = "已达 %d 张 GIF 上限"; +"LimitReachedFavoriteGifsSubtitle" = "一张旧的 GIF 动态图已被这张图替换。您可以 **提升上限** 至 %d 张 GIF 动态图。"; +"LimitReachedFavoriteGifsSubtitlePremium" = "一张旧的 GIF 被这张替代了。"; +"LimitReachedFavoriteStickers" = "已达到 %d 张贴纸的限制"; +"LimitReachedFavoriteStickersSubtitle" = "一张旧的贴纸已被这张贴纸替换。您可以 **提升上限** 至 %d 张贴纸"; +"LimitReachedFavoriteStickersSubtitlePremium" = "一张旧的贴纸已被该贴纸替换。"; +"LimitReachedFileSize" = "此文件已超过 **%1$s** ,无法发送。您可以订阅 **Telegram 高级版**,将此上限翻倍至每个文件 **%2$s**。"; +"LimitReachedFileSizeLocked" = "由于大小超过了 **%1$s**,文档无法发送。我们正在努力能让您在未来提高此上限。"; +"LimitReachedFileSizePremium" = "由于大小超过了 **%1$s**,无法发送此文档。"; +"LimitReachedFolderLinks" = "您只能创建 %1$d 个邀请链接。\n升级至 **Telegram 高级版**即可创建最多 **%2$d 个**。"; +"LimitReachedFolderLinksLocked" = "您只能创建 %1$d 个邀请链接,此上限可能会在未来提高。"; +"LimitReachedFolderLinksPremium" = "您只能创建 %1$d 个邀请链接。请试着删除一些文件夹邀请链接。"; +"LimitReachedFolders" = "您已达到 **%1$d** 个文件夹的限额。 您可以订阅 **Telegram 高级版**,将此上限翻倍至 **%2$d** 个文件夹。"; +"LimitReachedFoldersLocked" = "你已达到 %1$d 文件夹的上限。我们正在努力让你在未来提高此上限。"; +"LimitReachedFoldersPremium" = "您已经达到此帐号 **%1$d** 个文件夹的上限。"; +"LimitReachedPinDialogs" = "您无法置顶超过 %1$d 个聊天。请取消一些置顶的聊天,或者订阅 **Telegram 高级版**,将此上限翻倍至 **%2$d** 个聊天。"; +"LimitReachedPinDialogsLocked" = "抱歉,您最多只能置顶 %1$d 个聊天,请先取消置顶一些当前已置顶的聊天。我们在以后将尽力为您提高该限制。"; +"LimitReachedPinDialogsPremium" = "抱歉,您最多只能置顶 %1$d 个聊天,请先取消置顶一些当前已置顶的聊天。"; +"LimitReachedPinSavedDialogs" = "抱歉,您不能设置超过 %1$d 个置顶聊天。您可以取消置顶或订阅 **Telegram 高级版** 将上限增加到 **%2$d** 个。"; +"LimitReachedPinSavedDialogsLocked" = "抱歉,您不能设置超过 %1$d 个置顶聊天。请取消一些置顶。我们正在努力为您提高此上限。"; +"LimitReachedPinSavedDialogsPremium" = "抱歉,您不能设置超过 %1$d 个置顶聊天。请取消一部分置顶聊天。"; +"LimitReachedPinnedTopics" = "抱歉,您最多只能置顶 %d 个话题。"; +"LimitReachedPublicLinks" = "您保留了太多公开链接。您可以撤消其中一个较旧群组或频道的链接,或者订阅 **Telegram 高级版**,将此上限翻倍至 **%2$d** 个公开链接。"; +"LimitReachedPublicLinksLocked" = "您保留了太多公开链接。您可以撤消其中一个较旧群组或频道的链接。我们正在努力能让您在未来提高此上限。"; +"LimitReachedPublicLinksPremium" = "您保留了太多公开链接,试着从旧的群组或频道中撤销一些。"; +"LimitReachedReorderFolder" = "订阅 **Telegram Premium** 以移动 \"%1$s\" 文件夹。"; +"LimitReachedSharedFolders" = "您只能拥有 %1$d 个可分享文件夹。\n升级至 **Telegram 高级版**并将上限提升至 **%2$d 个**。"; +"LimitReachedSharedFoldersLocked" = "您只能拥有 %1$d 个可分享文件夹,此上限可能会在未来提高。"; +"LimitReachedSharedFoldersPremium" = "您只能拥有 %1$d 个可分享文件夹。请试着删除一些可分享文件夹。"; +"LimitReachedStoriesCount" = "每 **24** 小时您可以发送最多 **%1$d 条**动态。\n订阅 **Telegram 高级版**后可将此上限提升至 **%2$d 条**。"; +"LimitReachedStoriesCount2First#other" = "您在 **24** 小时內最多可以发布 **%d** 个动态。\n34/63"; +"LimitReachedStoriesCount2Premium#other" = "抱歉,您在 **24** 小时內最多可以发布 **%d** 个动态。\n34/63"; +"LimitReachedStoriesCount2Second#other" = "订阅 **Telegram 高级版** 可将此上限增加到 **%d**。"; +"LimitReachedStoriesCountPremium" = "抱歉,您最多可以发送 **%1$d 条** 动态。"; +"LimitReachedStoriesMonthly" = "您每月最多可以发送 **%1$d 条** 动态。\n订阅 **Telegram 高级版** 后可将此上限提升至 **%2$d 条**。"; +"LimitReachedStoriesMonthlyPremium" = "抱歉,您每月最多可以发送 **%1$d 条**动态。"; +"LimitReachedStoriesWeekly" = "您每周最多可以发送 **%1$d 条** 动态。\n订阅 **Telegram 高级版** 后可将此上限提升至 **%2$d 条**。"; +"LimitReachedStoriesWeeklyPremium" = "抱歉,您每周最多可以发送 **%1$d 条** 动态。"; +"LinkAbove" = "上移"; +"LinkActionCopy" = "复制\n"; +"LinkActionShare" = "分享"; +"LinkAvailable" = "%1$s 可用。"; +"LinkBelow" = "下移"; +"LinkChecking" = "正在检查用户名…"; +"LinkCopied" = "链接已复制到剪贴板。"; +"LinkCopiedPrivate" = "链接已复制到剪贴板。\n此链接仅对该聊天中的成员有效。"; +"LinkCopiedPrivateInfo" = "此链接只对该聊天的成员有效。"; +"LinkCreatedeBy" = "链接创建者"; +"LinkDesktopDevice" = "添加设备"; +"LinkExpiresIn" = "链接将于 %s 过期"; +"LinkExpiresInTime" = "链接将于 %s 过期"; +"LinkForBoosting" = "助推链接"; +"LinkHashExpired" = "链接已失效"; +"LinkInUse" = "抱歉,此链接已被占用。"; +"LinkInfo" = "任何已安装 Telegram 的用户都将可以通过此链接加入您的群组。"; +"LinkInfoChannel" = "通过此链接,任何已安装Telegram的人都可以加入您的频道。"; +"LinkInvalid" = "抱歉,此公开链接不符合规范。"; +"LinkInvalidLong" = "公开链接不能超过 32 个字符。"; +"LinkInvalidShort" = "公开链接必须包含至少 5 个字符。"; +"LinkInvalidShortMega" = "公开链接必须包含至少 5 个字符。"; +"LinkInvalidStartNumber" = "公开链接不能以数字开头。"; +"LinkInvalidStartNumberMega" = "公开链接不能以数字开头。"; +"LinkIsExpired" = "此链接已过期。"; +"LinkIsExpiredLimitReached" = "此链接已达到其使用上限。"; +"LinkIsNoActive" = "链接已失效。"; +"LinkLimitReached" = "已达人数上限"; +"LinkMediaLarger" = "放大图片"; +"LinkMediaSmaller" = "缩小图片"; +"LinkNameHelp" = "仅管理员有权查看该名称"; +"LinkNameHint" = "链接名称(可选)"; +"LinkNotFound" = "很遗憾,您无法打开此消息。 您不是消息所在聊天的成员。"; +"LinkPreview" = "链接预览"; +"LinkRevenue" = "订阅费"; +"LinkRevenuePrice" = "⭐️%1$d/月"; +"LinkRevenuePriceInfo" = "您每月约可获得 %1$s "; +"LinkSharedTo" = "链接转发给 **%s**。"; +"LinkSharedToManyChats#other" = "链接已转发到 **%1$d 个聊天**。"; +"LinkSharedToSavedMessages" = "链接转发到 **收藏消息**"; +"LinkVideoLarger" = "放大视频"; +"LinkVideoSmaller" = "缩小视频"; +"LinkedChannel" = "已关联的频道"; +"Links#other" = "%1$d 链接"; +"LinksCreatedByOtherAdmins" = "由其他管理员创建的邀请链接"; +"LinksCreatedByThisAdmin" = "此管理员创建的邀请链接"; +"LinksTitle" = "分享的链接"; +"Listening" = "接听中"; +"LiteBatteryAlwaysDisabled" = "总是关闭"; +"LiteBatteryAlwaysEnabled" = "总是开启"; +"LiteBatteryDisabled" = "关闭"; +"LiteBatteryEnabled" = "开启"; +"LiteBatteryInfoBelow" = "当电池电量低于 %s 时,自动减少功耗和动画。"; +"LiteBatteryInfoDisabled" = "即使处于低电量状态,始终保持动画特效且不启用省电模式。"; +"LiteBatteryInfoEnabled" = "无论电池电量如何,始终禁用所有资源密集型进程。"; +"LiteBatteryRestricted" = "关闭省电模式以更改这些设置。"; +"LiteBatteryTitle" = "省电模式"; +"LiteBatteryWhenBelow" = "低于 %s"; +"LiteMode" = "动画"; +"LiteModeInfo" = "减弱动态效果以节省电量"; +"LiteOptionsAutoplayChat" = "在聊天中自动播放"; +"LiteOptionsAutoplayGifs" = "自动播放 GIF"; +"LiteOptionsAutoplayKeyboard" = "键盘自动播放"; +"LiteOptionsAutoplayReactions" = "在回应菜单中自动播放"; +"LiteOptionsAutoplayVideo" = "自动播放视频"; +"LiteOptionsBackground" = "壁纸旋转"; +"LiteOptionsBlur" = "夜间主题虚化"; +"LiteOptionsBlur2" = "模糊"; +"LiteOptionsCalls" = "通话中的动画"; +"LiteOptionsChat" = "聊天中的特效"; +"LiteOptionsEmoji" = "动态表情符号"; +"LiteOptionsLiquidGlass" = "液态玻璃"; +"LiteOptionsParticles" = "粒子特效"; +"LiteOptionsScale" = "缩放动画"; +"LiteOptionsSpoiler" = "动态遮罩特效"; +"LiteOptionsStickers" = "动态贴纸"; +"LiteOptionsThanos" = "删除时的灰尘特效"; +"LiteOptionsTitle" = "省电选项"; +"LiteOptionsTopics" = "话题中的侧边栏"; +"LitePowerSaver" = "在低电量时节省电量"; +"LitePowerSaverInfo" = "当设备处于低电量时自动开启省电模式。"; +"LiteSmoothTransitions" = "启用平滑过渡"; +"LiteSmoothTransitionsInfo" = "您可以禁用应用不同部分之间的动画过渡。"; +"LiveLocationAlertExpandMessage" = "您希望将共享位置的时间延长多久?"; +"LiveLocationAlertExpandTitle" = "延长时间"; +"LiveLocationAlertGroup" = "选择此聊天的成员在多长时间内可看到您实时位置,包括应用关闭时。"; +"LiveLocationAlertPrivate" = "选择 %1$s 在多长时间内可看到您的精确位置,包括应用关闭时。"; +"LiveLocationContext" = "实时位置"; +"LiveLocations" = "实时位置"; +"LivePhotoOff" = "动态照片已 **关闭**"; +"LivePhotoOn" = "动态照片已 **关闭**"; +"LivePhotoSavedHint" = "动态照片已保存到相册\u00a0"; +"LivePhotosOff" = "动态照片已 **关闭**"; +"LivePhotosOn" = "动态照片已 **开启**"; +"LivePhotosSavedHint#other" = "%1$d 张动态照片已保存到相册\u00a0"; +"LiveStoryAdminRole" = "管理员"; +"LiveStoryAlreadyStreaming" = "您目前已有一场正在进行的直播。"; +"LiveStoryAlreadyStreamingTitle" = "已开播"; +"LiveStoryBadge" = "直播"; +"LiveStoryCommentPlaceholder" = "评论"; +"LiveStoryCommentsDisabled" = "禁止评论"; +"LiveStoryDisconnected" = "无直播"; +"LiveStoryDisconnectedContinue" = "继续直播"; +"LiveStoryEnd" = "结束直播"; +"LiveStoryEndAlertButton" = "停止"; +"LiveStoryEndAlertText" = "你确定想要结束直播吗?"; +"LiveStoryEndAlertTitle" = "结束直播"; +"LiveStoryHighlightFeatureEmoji" = "表情"; +"LiveStoryHighlightFeatureLength" = "字符"; +"LiveStoryHighlightFeaturePin" = "在聊天中置顶"; +"LiveStoryHighlightHint" = "点击此处高亮消息"; +"LiveStoryHighlightText" = "通过向 **%s** 赠送星星,高亮并置顶您的消息。"; +"LiveStoryHighlightTitle" = "高亮并置顶"; +"LiveStoryMessageEditStars" = "编辑星星"; +"LiveStoryMessageRemoveStars" = "移除星星"; +"LiveStoryMessageSent" = "已发送于 %s"; +"LiveStoryPeerChange" = "改变 >"; +"LiveStoryPricePerComment" = "每个评论的价格"; +"LiveStoryPricePerCommentFree" = "免费"; +"LiveStoryPricePerCommentInfo" = "观众发送评论需支付的价格。"; +"LiveStoryRTMPDisable" = "通过手机直播"; +"LiveStoryRTMPEnable" = "连接直播流"; +"LiveStoryRTMPEnableInfo" = "使用其他应用直播。"; +"LiveStoryRTMPRevoke" = "撤销推流密钥"; +"LiveStoryRTMPRevokeText" = "您确定要重新生成串流密钥吗?"; +"LiveStoryRTMPRevokeTitle" = "撤销串流密钥"; +"LiveStoryRTMPSettings" = "RTMP 设置"; +"LiveStoryReactAdminCant" = "您不能对自己的直播动态作出星星回应。"; +"LiveStoryReactAdminEmptyText" = "观众可向你发送星星回应,送礼榜前列用户将展示于此。"; +"LiveStoryReactAdminText" = "观众可以向你发送星星互动。"; +"LiveStoryReactAdminTitle" = "接受观众赠送的星星"; +"LiveStoryReactText" = "通过向 **%s** 赠送星星,高亮并置顶您的消息。"; +"LiveStoryReactTitle" = "以星星回应"; +"LiveStorySettings" = "直播设置"; +"LiveStorySettingsHeader" = "直播设置"; +"LiveStoryStart" = "开始直播"; +"LiveStoryTopPanelWatching#other" = "%d 人观看中"; +"LiveStoryWatching#other" = "%d 人观看中"; +"LiveStreamWillNotNotify" = "您将不会在直播开始时收到通知。"; +"LiveStreamWillNotify" = "您将在直播开始时收到通知。"; +"Loading" = "加载中…"; +"LoadingFullImage" = "正在加载完整图片…"; +"LoadingStats" = "加载数据中..."; +"LoadingStatsDescription" = "正在为您生成统计数据,\n请稍等片刻。"; +"LocalAudioCache" = "语音消息"; +"LocalCache" = "其它文件"; +"LocalDatabase" = "清除本地数据库"; +"LocalDatabaseClear" = "是否清空缓存的消息记录?"; +"LocalDatabaseClearText" = "您确定要清除缓存的文本消息吗?"; +"LocalDatabaseClearText2" = "此操作可能需要几分钟时间才能完成。\n\n本地数据库大小: **%s**"; +"LocalDatabaseClearTextTitle" = "清除本地数据库"; +"LocalDatabaseInfo" = "清空本地数据库将删除缓存在本地的历史聊天记录并压缩数据库以节省存储空间。但由于 Telegram 需要一些数据来维持正常工作,所以数据库大小不会完全归零。\n\n此操作需要一些时间来完成。"; +"LocalDatabaseSize" = "%s Telegram 本地数据库"; +"LocalDocumentCache" = "文档"; +"LocalGifCache" = "GIF"; +"LocalLogsCache" = "日志"; +"LocalMiscellaneousCache" = "杂项"; +"LocalMusicCache" = "音乐"; +"LocalOther" = "其他"; +"LocalPhotoCache" = "图片"; +"LocalProfilePhotosCache" = "头像"; +"LocalStickersCache" = "贴纸和表情"; +"LocalStoriesCache" = "动态"; +"LocalVideoCache" = "视频"; +"LocatedChannelsTooMuch" = "抱歉,目标用户已经有太多基于位置的群组。请让他们先删除或者转移其中一个。"; +"Location" = "基于IP地址的定位"; +"LocationNotifiation" = "靠近提醒"; +"LocationNotifiationButton" = "当距离 %1$s 之内时通知我"; +"LocationNotifiationButtonGroup" = "当其他人在 %1$s 范围内通知我"; +"LocationNotifiationButtonUser" = "当 %1$s 距离 %2$s 时通知我"; +"LocationNotifiationCloser" = "您已经进入了%1$s以内"; +"LocationOnMap" = "地图上的位置"; +"LocationStories#other" = "当前位置动态数:%d "; +"LocationUpdated" = "已更新"; +"LocationUpdatedFormatted" = "上次更新:%1$s"; +"LocationUpdatedJustNow" = "刚刚更新"; +"LocationsShowAll" = "全部显示"; +"LogOut" = "登出"; +"LogOutInfo" = "登出后,所有私密聊天将会失效。"; +"LogOutTitle" = "登出"; +"LoginAccessibilityCountryCode" = "国家代码"; +"LoginAttempts" = "未完成的登录尝试"; +"LoginAttemptsInfo" = "上述设备无法获取您的消息。验证码输入正确,但没有提供正确的密码。"; +"LoginCantAccessThisEmail" = "不能访问这个邮箱?"; +"LoginEmailResetButton" = "重置登录邮箱"; +"LoginEmailResetInDoublePattern" = "%1$s 和 %2$s"; +"LoginEmailResetInSinglePattern" = "%1$s"; +"LoginEmailResetInTime" = "邮箱地址将在 %1$s 后重置。\n*现在通过短信重置*"; +"LoginEmailResetMessage" = "如果您已在其他设备上登录 Telegram,则可以更改登录所使用的电子邮箱。如果您无法访问电子邮箱 %1$s,可以在 **%2$s 后**使用**短信**登录码重置此电子邮箱。"; +"LoginEmailResetPleaseWait" = "正在重置邮箱,请稍候..."; +"LoginEmailResetPremiumRequiredMessage" = "由于您所在国家的短信费用很高,您需要拥有 **Telegram 高级版**帐号才能通过短信登录码重置此电子邮箱。\n\n您可以请朋友给您的帐号赠送高级版订阅 **%1$s**"; +"LoginEmailResetPremiumRequiredTitle" = "需要 Telegram 高级版"; +"LoginEmailResetTitle" = "重置邮箱"; +"LoginOrSingInWithGoogle" = "或者"; +"LoginPassword" = "密码"; +"LoginPasswordText" = "您已启用了两步验证,因此您的帐号已受到额外密码的保护。"; +"LoginPasswordTextShort" = "已启用两步验证,您的账户已受到额外密码的保护。"; +"Long" = "长"; +"LoopAnimatedStickers" = "循环播放动态贴纸"; +"LoopAnimatedStickersInfo" = "动态贴纸将在聊天中重复循环。"; +"LowDiskSpaceButton" = "管理缓存"; +"LowDiskSpaceMessage" = "您的设备存储空间快满了。您可以将 Telegram 设置为仅缓存近期的媒体文件以释放空间。"; +"LowDiskSpaceMessage2" = "您的设备存储空间即将用尽。您可以在缓存设置中删除不必要的媒体文件,并在需要时重新下载。"; +"LowDiskSpaceNeverRemove" = "永不删除"; +"LowDiskSpaceTitle" = "注意"; +"LowDiskSpaceTitle2" = "删除此时间后的媒体文件"; +"LowPowerEnabledSubtitle" = "电量剩余 %s"; +"LowPowerEnabledTitle" = "省电模式已启用"; +"MainChannelProfilePhotoSetHint" = "主频道照片"; +"MainChannelProfileVideoSetHint" = "现在这是频道主头像了。"; +"MainGroupProfilePhotoSetHint" = "现在这是主要群组头像。"; +"MainGroupProfileVideoSetHint" = "现在这是主要群组图片。"; +"MainProfilePhotoSetHint" = "您的主页照片"; +"MainProfileVideoSetHint" = "现在这是您的主头像了。"; +"MainTabsCalls" = "通话"; +"MainTabsChats" = "聊天"; +"MainTabsContacts" = "联系人"; +"MainTabsProfile" = "个人资料"; +"MainTabsSettings" = "设置"; +"MakeAdmin" = "设定为管理员"; +"MakeMyselfVisible" = "使自身可见"; +"MakeMyselfVisibleInfo" = "附近的人可以查看你的个人资料并发送消息给你。这可能帮你找到新伙伴,也可能带来骚扰。您可以随时停止分享个人资料。\n你的手机号码将一直保持隐藏状态。"; +"MakeMyselfVisibleTitle" = "是否显示个人资料?"; +"MakeYourFirstCall" = "进行您的第一次通话"; +"MakeYourFirstCallHint" = "您可以同时与最多 %d 位朋友进行端到端加密通话。"; +"ManageAllFilesRational" = "请允许 **Telegram** 管理所有文件并打开应用内文件浏览器"; +"ManageAllFilesRational2" = "Telegram 需要管理所有文件的权限以合并旧缓存文件,减少存储占用"; +"ManageChannel" = "管理频道"; +"ManageChannelMenu" = "管理频道"; +"ManageDeviceStorage" = "管理设备存储"; +"ManageGroup" = "管理群组"; +"ManageGroupMenu" = "管理群组"; +"ManageInviteLinks" = "管理邀请链接"; +"ManageLinksInfoHelp" = "您可以创建额外的邀请链接,这些链接有一定的时间或使用次数限制。"; +"ManageLinksInfoHelpPaid" = "您可以创建限制时间、用户数量或需要付费订阅的附加邀请链接。"; +"ManageTopicsPermission" = "管理话题"; +"ManagedBotChatInfo" = "%1$s\u00a0已准备就绪!\n\n点击下方的开始按钮即可测试您的新聊天机器人。其行为由\u00a0%2$s\u00a0定义。"; +"Map" = "地图"; +"MapPreviewProvider" = "地图预览提供商"; +"MapPreviewProviderGoogle" = "Google"; +"MapPreviewProviderNobody" = "不使用地图预览"; +"MapPreviewProviderTelegram" = "Telegram"; +"MapPreviewProviderTitle" = "地图预览提供商"; +"MapPreviewProviderYandex" = "Yandex"; +"March" = "三月"; +"MarkAllAsRead" = "全部标记为已读"; +"MarkAsRead" = "标记为已读"; +"MarkAsUnread" = "标记为未读"; +"Masks" = "装饰"; +"MasksArchived" = "装饰已归档。"; +"MasksArchivedInfo" = "%1$s 已移至您的归档。"; +"MasksCount#other" = "%1$d 个装饰"; +"MasksInfo" = "您可以在发送照片时在媒体编辑器中添加装饰。"; +"MasksRemoved" = "已移除装饰。"; +"MasksRemovedInfo" = "%1$s 已不在您的装饰中。"; +"MaxCacheSize" = "缓存大小限制"; +"MaxCacheSizeInfo" = "如果缓存大小超过此限制,将从此设备中删除最早的未曾访问的媒体文件。"; +"MaxVideoSize" = "视频大小限制"; +"MaximumReactionsHeader" = "表情反应最大数量"; +"MaximumReactionsInfo" = "限制可以加入到帖子中的表情回应类型数量,包括已发布的帖子。"; +"MaximumReactionsValue#other" = "每则贴文 %d 个反应"; +"May" = "五月"; +"Media#other" = "%1$d 个媒体文件"; +"MediaAndSoundSettings" = "媒体和声音"; +"MediaSavedHint#other" = "已将 %1$d 个项目保存到相册。"; +"MediaSelected#other" = "已选中 %1$d 条媒体文件"; +"MediaShowPhotos" = "显示图片"; +"MediaShowVideos" = "显示视频"; +"MediaTab" = "媒体"; +"MediaZoomIn" = "放大"; +"MediaZoomOut" = "缩小"; +"MegaAddedBy" = "un1 已将您添加到此群组"; +"MegaAdminsInfo" = "您可以添加管理员帮助您管理群组。长按可删除管理员。"; +"MegaDeleteAlert" = "删除本群组将会移除所有成员并导致所有消息记录丢失,确认要这样做吗?"; +"MegaDeleteInfo" = "您将会丢失此群组的所有消息。"; +"MegaLeaveAlert" = "您确定要退出此群组吗?"; +"MegaLeaveAlertWithName" = "您确定要退出 **%1$s** 吗?"; +"MegaLocation" = "基于位置的群"; +"MegaPrivate" = "私密群组"; +"MegaPrivateInfo" = "私密群组只能通过群成员的邀请,或邀请链接加入。"; +"MegaPrivateLinkHelp" = "任何人都可以通过此链接加入您的群组。您可以随时撤销此链接。"; +"MegaPublic" = "公开群组"; +"MegaPublicInfo" = "任何人都可以搜索、加入公开群组,并查看历史聊天记录。"; +"MegaUsernameHelp" = "如果您设置了公开链接,其他用户可以查找并加入到群组中。\n\n您可以使用 a–z,0–9 和下划线。最小长度为 5 个字符。"; +"MemberRequests" = "加入请求"; +"MemberTagButtonAdd" = "添加标签"; +"MemberTagButtonEdit" = "编辑标签"; +"MemberTagButtonRemove" = "移除标签"; +"MemberTagHintAdd" = "添加成员标签"; +"MemberTagHintEdit" = "编辑成员标签"; +"MemberTagInfo" = "分享您在群组中的角色、头衔或昵称。您的标签会对所有成员可见。"; +"MemberTagSelfInfo" = "分享您在群组中的角色、头衔或昵称。您的标签会对所有成员可见。"; +"MemberTagTheirInfo" = "在 %s 的名称旁添加简短的标签。"; +"MemberTagTitle" = "成员标签"; +"Members#other" = "%1$d 位成员"; +"MembersAreSpeakingToast#other" = "un1 正在发言"; +"MembersCount" = "已选择 %2$d 位成员中的 %1$d 位"; +"MembersCountSelected#other" = "选中 %1$d / %2$d "; +"MembersCountZero" = "最多 %1$s"; +"MembersLanguageChartTitle" = "成员主要语言"; +"MembersOverviewTitle" = "成员"; +"MembersSearch" = "搜索用户"; +"MembersTalking#other" = "%1$s 个成员通话"; +"MembersTalkingNobody" = "点击加入"; +"Mention" = "提及"; +"MenuProxyConnected" = "已连接"; +"MenuProxyConnecting" = "连接中"; +"MenuProxyDisabled" = "已禁用"; +"MenuProxyTitle" = "代理"; +"MenuTelegramStars" = "我的星星"; +"Message" = "消息"; +"MessageAddFile" = "添加文件"; +"MessageAddMusic" = "添加音乐"; +"MessageAddPhotoVideo" = "添加图片或视频"; +"MessageAuthorSentBy" = "由 **%s** 发送"; +"MessageBizBot" = "只有您可以看到此消息是由 **机器人** 发送的。"; +"MessageContainsEmojiPack" = "此消息包含来自 %s 表情包的表情。"; +"MessageContainsEmojiPackSingle" = "这条消息内含有 **%s** 表情。"; +"MessageContainsEmojiPacks#other" = "这条消息包含了来自 **%d 个表情包** 的表情。"; +"MessageContainsReactionPack" = "此表情回应是 %s 表情包中的一个表情。"; +"MessageContainsReactionsPack" = "表情回应包含来自 %s 的表情。"; +"MessageContainsReactionsPacks#other" = "表情回应包含 **%d 个表情包** 中的表情。"; +"MessageCopied" = "消息已拷贝到剪切板。"; +"MessageGroupTodoList" = "群组核对清单"; +"MessageLifetime" = "阅后即焚计时器"; +"MessageLifetimeChanged" = "%1$s 已将阅后即焚计时器设定为 %2$s"; +"MessageLifetimeChangedOutgoing" = "您已将阅后即焚计时器设定为 %1$s"; +"MessageLifetimePhoto" = "设置计时器后,该图片将在阅后定时自毁。"; +"MessageLifetimeRemoved" = "%1$s 已停用阅后即焚计时器"; +"MessageLifetimeVideo" = "设置计时器后,该视频将在阅后一定时间内自毁。"; +"MessageLifetimeYouRemoved" = "您已停用阅后即焚计时器"; +"MessageLockedPremium" = "订阅 **高级版** 即可向 **%s** 发送消息。"; +"MessageLockedPremiumLocked" = "**%s** 仅接受来自联系人和高级版用户的消息。"; +"MessageLockedStars" = "**%1$s** 对每条消息收取 **%2$s**。"; +"MessageLockedStarsConfirmMessage1#other" = "**%2$s** 对每条消息收取 **%1$d** 颗星星。"; +"MessageLockedStarsConfirmMessage2Many1#other" = "你是否愿意支付 **%d** 颗星星"; +"MessageLockedStarsConfirmMessage2Many2#other" = "以发送 %d 条消息?"; +"MessageLockedStarsConfirmMessage2One#other" = "你是否愿意支付 **%1$d** 颗星星以发送一条消息?"; +"MessageLockedStarsConfirmMessageDontAsk" = "不再询问我"; +"MessageLockedStarsConfirmMessageMulti1#other" = "你选出了 **%1$d** 个对消息收取星星的聊天。"; +"MessageLockedStarsConfirmMessageMulti2#other" = "您愿意支付 **%1$d** 颗星星以发送 %2$s 吗?"; +"MessageLockedStarsConfirmMessageMulti2Messages#other" = "**%d** 条消息"; +"MessageLockedStarsConfirmMessagePay#other" = "支付 %d 条消息"; +"MessageLockedStarsConfirmTitle" = "确认支付"; +"MessageLockedStarsRemoveFee" = "%1$s 必须为发送给你的每条消息支付 ⭐️%2$s。 \n**免收费用**"; +"MessageLockedStarsRemoveFeeShort" = "%1$s 为每条发给您的消息支付 ⭐️ %2$s。\n**取消收费**"; +"MessageNotFound" = "消息不存在"; +"MessageNotifications" = "消息通知"; +"MessageOptionsForward" = "转发"; +"MessageOptionsLink" = "链接"; +"MessageOptionsLinkSubtitle" = "更改您发送的链接样式"; +"MessageOptionsLinkTitle" = "链接预览"; +"MessageOptionsReply" = "回复"; +"MessageOptionsReplySubtitle" = "您可以选择特定部分进行引用"; +"MessageOptionsReplyTitle" = "回复消息"; +"MessagePinnedHint" = "消息已置顶。"; +"MessagePlayed#other" = "%1$d 已播放"; +"MessagePremiumErrorMessage" = "%s 只接受来自联系人或高级版订阅用户的消息。"; +"MessagePremiumErrorTitle" = "无法发送"; +"MessagePremiumUnlock" = "获取高级版"; +"MessagePreview" = "显示消息预览"; +"MessageReplaceFile" = "到「文件」"; +"MessageReplaceMusic" = "到「音乐」"; +"MessageReplacePhotoVideo" = "到 “图片” 或 “视频”"; +"MessageScheduleEditTime" = "重设发送时间"; +"MessageScheduleSend" = "立即发送"; +"MessageScheduleToday" = "今天"; +"MessageScheduledBotAction" = "消息发布后,即可进行此操作。"; +"MessageScheduledLimitReached" = "抱歉,您不能设置超过 100 条定时消息。"; +"MessageScheduledOn" = "计划于 %1$s 发送"; +"MessageScheduledReminderNotification" = "📅 日程提醒"; +"MessageScheduledRepeatBiweekly" = "每两周"; +"MessageScheduledRepeatDaily" = "每天"; +"MessageScheduledRepeatDeleteAll" = "删除所有后续消息"; +"MessageScheduledRepeatDeletePostponeDays#other" = "延迟 %d 天"; +"MessageScheduledRepeatDeletePostponeMonths#other" = "延迟 %d 月"; +"MessageScheduledRepeatDeletePostponeSeconds" = "延迟 %d 秒"; +"MessageScheduledRepeatDeletePostponeWeeks#other" = "延迟 %d 周"; +"MessageScheduledRepeatDeletePostponeYear" = "延迟一年"; +"MessageScheduledRepeatDeleteText" = "你确定要删除这条定时消息吗?这是一条重复发送的消息。"; +"MessageScheduledRepeatDeleteTitle" = "删除重复的消息"; +"MessageScheduledRepeatMonthly" = "每月"; +"MessageScheduledRepeatMonthlyMany#other" = "每 %d 个月"; +"MessageScheduledRepeatOption" = "重复:"; +"MessageScheduledRepeatOption3Monthly" = "每三个月"; +"MessageScheduledRepeatOption6Monthly" = "每六个月"; +"MessageScheduledRepeatOptionBiweekly" = "每两周"; +"MessageScheduledRepeatOptionDaily" = "每天"; +"MessageScheduledRepeatOptionMonthly" = "每月"; +"MessageScheduledRepeatOptionNever" = "从不"; +"MessageScheduledRepeatOptionWeekly" = "每周"; +"MessageScheduledRepeatOptionYearly" = "每年"; +"MessageScheduledRepeatPremium" = "订阅 **Telegram 高级版** 以设置定时发送消息。"; +"MessageScheduledRepeatSeconds" = "每 %1$ds"; +"MessageScheduledRepeatWeekly" = "每周"; +"MessageScheduledRepeatYearly" = "每年"; +"MessageScheduledTodo" = "消息发布后,核对清单将会可用。"; +"MessageScheduledUntilOnline" = "在线后发送"; +"MessageScheduledVote" = "消息发布后才能进行投票。"; +"MessageScheduledVoteResults" = "消息发布之后可以查看投票结果。"; +"MessageSeen#other" = "%1$d 人已读"; +"MessageSeenTooltipMessage" = "为保护隐私,已阅数据只会保留 **7 天**。"; +"MessageSensitiveContent" = "18+"; +"MessageSensitiveContentShort" = "18+"; +"MessageSent" = "消息已发送。"; +"MessageShowSensitiveContentAlways" = "总是显示 18+ 媒体"; +"MessageShowSensitiveContentButton" = "仍然查看"; +"MessageShowSensitiveContentChannelText" = "此聊天可能包含仅适合成人的敏感內容。您仍想查看吗?"; +"MessageShowSensitiveContentChannelTextClosed" = "此聊天可能包含仅适合成人的敏感内容。"; +"MessageShowSensitiveContentChannelTextClosedButton" = "取消"; +"MessageShowSensitiveContentChannelTitle" = "18+"; +"MessageShowSensitiveContentMediaText" = "此媒体可能包含仅适合成人的敏感內容。您仍想查看吗?"; +"MessageShowSensitiveContentMediaTextClosed" = "此媒体可能包含仅适合成人的敏感内容。"; +"MessageShowSensitiveContentMediaTextClosedButton" = "取消"; +"MessageShowSensitiveContentMediaTitle" = "18+"; +"MessageStarsUnlock" = "购买星星"; +"MessageSuggestionPublishNow" = "立即发布"; +"MessageSuggestionsUnsavedChanges" = "您已更改私信设置。要套用更改吗?"; +"MessageTodo" = "核对清单"; +"MessageTodoList" = "核对清单"; +"MessageTodoName" = "%s"; +"MessageUnpinnedHint" = "消息已取消置顶。"; +"MessagesBold#other" = "**%1$d** 条消息"; +"MessagesChartTitle" = "消息"; +"MessagesDataUsage" = "消息和其它数据"; +"MessagesDeleted" = "消息已删除"; +"MessagesDeletedHint#other" = "%1$d 消息已删除"; +"MessagesDeletedUndo#other" = "已删除 %1$s 天内的消息记录"; +"MessagesOverview" = "消息"; +"MessagesSelected#other" = "已选择 %d 项"; +"MessagesSettings" = "聊天设置"; +"MessagesUnpinned#other" = "%1$d 条消息已取消置顶。"; +"Meters#other" = "%1$d 米"; +"MetersAway" = "米以外"; +"MetersAway2" = "相距 %1$s 米"; +"MetersFromYou2" = "距您 %1$s 米"; +"MetersShort" = "%1$s 米"; +"Mi" = "英里"; +"MicrophoneForVoiceMessages" = "语音消息使用的麦克风"; +"MicrophoneForVoiceMessagesBuiltIn" = "内置"; +"MicrophoneForVoiceMessagesSco" = "耳机"; +"MicrophoneForVoiceMessagesScoHint" = "使用耳机可以减少背景噪音,但可能会降低音频质量。"; +"MicrophoneForVoiceMessagesScoIfConnected" = "耳机(如果已连接)"; +"MigrateOldFolder" = "将旧的文件夹迁移到分区存储"; +"MigrateOldFolderButton" = "立即移动文件"; +"MigrateOldFolderDescription" = "由于**Google**的变更,我们需要将你在 **Telegram** 中下载的文件迁移到新的文件夹中。\n\n这可能需要一些时间。你可以在文件迁移过程中继续使用本应用。"; +"MigrateOldFolderTitle" = "迁移到分区存储"; +"MigratingFiles" = "文件迁移中"; +"MigrationNotification" = "转移"; +"MilesAway" = "相距%1$s米"; +"MilesFromYou" = "距您 %1$s 英里"; +"MilesShort" = "%1$s 英里"; +"MinuteAgo" = "一分钟前"; +"Minutes#other" = "%1$d 分钟"; +"MinutesAgo#other" = "%1$d 分钟前"; +"MinutesBold#other" = "**%1$d** 分钟"; +"MinutesSchedule#other" = "%1$d分钟内"; +"MissedCallDescriptionSubtitle" = "几秒钟后,您将会收到来自以下号码的简短电话:"; +"MissedCallDescriptionSubtitle2" = "请输入呼叫号码的尾号"; +"MissedCallDescriptionTitle" = "输入剩余的数字"; +"MobileHidden" = "手机号码已隐藏"; +"MobileHiddenExceptionInfo" = "一旦 %1$s 将您添加为联系人,手机号码将对其可见。"; +"MobileUsage" = "移动流量使用情况"; +"MobileVisibleInfo" = "当您点击 **完成** 时,您的手机号码将对 %1$s 可见。"; +"Monetization" = "营利"; +"MonetizationBalance" = "可用奖励"; +"MonetizationBalanceInfo" = "您可以使用 Fragment 领取奖励,Fragment 是广告商用来支付广告费用的第三方平台。 **了解更多 >**"; +"MonetizationBalanceInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"MonetizationBalanceInfoNotAvailable" = "在接下来的几周中,你将能够使用 Fragment 获取奖励,Fragment 是广告商用于支付广告费的第三方平台。 **了解更多 >**"; +"MonetizationChartInTON" = "以 Gram 计的 %s"; +"MonetizationChartInUSD" = "以美元计的 %s"; +"MonetizationGraphImpressions" = "广告显示次数"; +"MonetizationGraphRevenue" = "广告收益"; +"MonetizationGraphStarsRevenue" = "星星收入"; +"MonetizationInfo" = "Telegram 会将您频道中显示的广告收入的 %1$d%% 分享给您作为奖励 。**了解更多 >**"; +"MonetizationInfoFeature1Name" = "Telegram 广告"; +"MonetizationInfoFeature1Text" = "Telegram 能够在你的频道中展示广告。"; +"MonetizationInfoFeature2Name" = "50:50 收入分成"; +"MonetizationInfoFeature2Text" = "您将以 Gram 收到 50% 的广告收入分成。"; +"MonetizationInfoFeature3Name" = "灵活提现"; +"MonetizationInfoFeature3Text" = "您可以随时领取您的 Gram。"; +"MonetizationInfoTONLink" = "https://telegram.org/blog/monetization-for-channels"; +"MonetizationInfoTONText" = "**Gram** 是一个区块链平台和加密货币,因其高性能和低佣金而被 Telegram 采用。"; +"MonetizationInfoTONTitle" = "什么是 💎 Gram?"; +"MonetizationInfoTitle" = "从你的频道中赚取收益"; +"MonetizationMoreTransactions#other" = "显示另外 %d 笔交易"; +"MonetizationOverview" = "收益总览"; +"MonetizationOverviewAvailable" = "可领取的奖励"; +"MonetizationOverviewLastWithdrawal" = "自上次领取后的奖励"; +"MonetizationOverviewTotal" = "累计总收益"; +"MonetizationProceedsInfo" = "总余额中的星星可以在获得 21 天后用于购买广告或作为奖励提取。"; +"MonetizationProceedsInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"MonetizationProceedsInfoTONLink" = "https://telegram.org/tos/content-creator-rewards"; +"MonetizationProceedsStarsInfo" = "总余额中的星星可以在获得 21 天后用于购买广告或作为奖励提取。"; +"MonetizationProceedsStarsInfoLink" = "https://telegram.org/tos/stars/"; +"MonetizationProceedsStarsTONInfo" = "总余额中的星星和 Gram 可分别在获得 21 天和 3 天后用于广告或提取奖励。"; +"MonetizationProceedsStarsTONInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"MonetizationProceedsTONInfo" = "总余额中的 Gram 可在获得 3 天后用于广告或提取奖励,每次提取金额不得少于 10 Gram。"; +"MonetizationProceedsTONInfoLink" = "https://telegram.org/tos/content-creator-rewards"; +"MonetizationSelfWithdraw" = "通过 Fragment 提取"; +"MonetizationStarsAds" = "购买广告"; +"MonetizationStarsBalance" = "星星余额"; +"MonetizationStarsInfo" = "您可以使用 Fragment 提取星星,或使用星星宣传您的频道。星星数量低于1000颗时无法提取。**了解更多 >**"; +"MonetizationStarsInfoGroup" = "通过使用 Fragment 提取星星,即表示您接受**服务条款**。"; +"MonetizationStarsInfoLink" = "https://telegram.org/tos/stars"; +"MonetizationStarsWithdraw#other" = "提取 ⭐️%d"; +"MonetizationStarsWithdrawAll" = "全部提取"; +"MonetizationStarsWithdrawUntil" = "提取"; +"MonetizationSwitchOff" = "关闭广告"; +"MonetizationSwitchOffInfo" = "如果您关闭广告,您将没有资格获得任何奖励。"; +"MonetizationTransactionDetailProceed" = "从此处显示的广告中取得的奖励"; +"MonetizationTransactionDetailRefund" = "已退款"; +"MonetizationTransactionDetailWithdraw" = "领取奖励"; +"MonetizationTransactionDetailWithdrawButton" = "在区块链检索器中查看"; +"MonetizationTransactionNotCompleted" = "已终止"; +"MonetizationTransactionPending" = "处理中"; +"MonetizationTransactionProceed" = "广告收益"; +"MonetizationTransactionRefund" = "退款"; +"MonetizationTransactionWithdraw" = "通过 Fragment 领取"; +"MonetizationTransactions" = "交易历史"; +"MonetizationTransactionsStars" = "星星交易"; +"MonetizationTransactionsTON" = "Gram 交易"; +"MonetizationWithdraw" = "通过 Fragment 领取"; +"Mono" = "等宽"; +"MonoforumDeletedUndo" = "已从您的聊天名单移除。"; +"MonoforumMessages" = "私信"; +"MonoforumSpan" = "私信"; +"MonoforumTitle" = "%1$s 的消息"; +"MonthAgo" = "一个月前"; +"Months#other" = "%1$d 个月"; +"MonthsAgo#other" = "%d 个月前"; +"MonthsShort#other" = "%1$d个月"; +"MoreAboutThisBot" = "本机器人的更多信息"; +"MoreBoosts#other" = "额外 **%d** 次助推"; +"MoreInfo" = "更多信息"; +"MoreSimilar" = "更多频道"; +"MoreSimilarBotsButton" = "显示更多机器人"; +"MoreSimilarBotsText" = "订阅 **Telegram 高级版** \n解锁最多 %s 个相似机器人。"; +"MoreSimilarButton" = "显示更多频道"; +"MoreSimilarText" = "订阅 **Telegram 高级版**\n以解锁最多 %s 个相似频道。"; +"MoveCaptionDown" = "下移说明文字"; +"MoveCaptionUp" = "上移说明文字"; +"MovedCaptionDown" = "说明文字已移至下方"; +"MovedCaptionDownText" = "说明文字将显示在媒体内容下方"; +"MovedCaptionUp" = "说明文字已移至上方"; +"MovedCaptionUpText" = "说明文字将显示在媒体内容上方"; +"Music" = "音乐"; +"MusicFiles#other" = "%1$d 音乐文件"; +"MusicInfo" = "发送音频文件"; +"Mute" = "静音"; +"MuteDisable" = "停用"; +"MuteFor" = "静音 %1$s"; +"MuteFor1h" = "静音1小时"; +"MuteForAlert" = "定时通知静音"; +"MuteForButton" = "静音 %s"; +"MuteForPopup" = "定时静音"; +"MuteNever" = "永久"; +"MuteNotifications" = "永久静音"; +"MyChannels" = "我的频道"; +"MyGroups" = "我的群组"; +"MyNotes" = "我的备注"; +"MyProfile" = "我的资料"; +"MyStory" = "我的动态"; +"MyTON" = "我的 Gram"; +"NearbyCreateGroup" = "创建本地群组"; +"NearbyCreateGroupInfo" = "附近的用户(例如邻居、同事、同学、活动参与者)都可以在“附近的人”页面看到您的群组。"; +"NearbyCreateGroupInfo2" = "如果您在此处创建无关群组,您可能会失去创建基于位置的群组的权限。"; +"NearbyPeopleGreetingsDescription" = "发送消息或点击下方的“打招呼”来表示你已准备好开始对话了。"; +"NearbyPeopleGreetingsMessage" = "%1$s 是 %2$s"; +"NearbyPlaces" = "或者选择一个地点"; +"NearbyStartGroup" = "创建群组"; +"NearbyVenue" = "或选择一个地点"; +"NeedAdminRightForSetAutoDeleteTimer" = "您必须是管理员才能启用自动删除。"; +"NetworkUsage" = "流量使用情况"; +"NetworkUsageAllTab" = "全部"; +"NetworkUsageMobile" = "移动数据"; +"NetworkUsageMobileTab" = "移动数据"; +"NetworkUsageRoaming" = "漫游"; +"NetworkUsageRoamingTab" = "漫游"; +"NetworkUsageSince" = "自 %1$s 的网络使用情况"; +"NetworkUsageWiFi" = "WI-FI"; +"NetworkUsageWiFiTab" = "Wi-Fi"; +"NeverAllow" = "从不允许"; +"NeverAllowPlaceholder" = "永不允许…"; +"NeverShareWith" = "从不共享给"; +"NeverShareWithPlaceholder" = "选择无法看见您上线时间的用户…"; +"NeverShareWithTitle" = "从不分享"; +"NewBroadcastList" = "新建群发列表"; +"NewCall" = "新建通话"; +"NewCallSearch" = "搜索联系人或用户名..."; +"NewChannel" = "新建频道"; +"NewChatsFromNonContacts" = "来自未知用户的新聊天"; +"NewChatsFromNonContactsCheck" = "自动归档"; +"NewContact" = "新联系人"; +"NewContactAlertButton" = "添加联系人"; +"NewContactAlertMessage" = "手机号码 **%1$s** 不在您的联系人列表中,是否要添加该号码?"; +"NewContactAlertTitle" = "新建联系人"; +"NewContactTitle" = "新联系人"; +"NewConversationShortcut" = "新建对话"; +"NewFollowersBySourceChartTitle" = "按来源查看新订阅者"; +"NewGroup" = "新建群组"; +"NewLink" = "新链接"; +"NewMembersBySourceChartTitle" = "新成员的来源"; +"NewMessageTitle" = "新建对话"; +"NewMessages#other" = "%1$d 条新消息"; +"NewPassword" = "新密码"; +"NewPoll" = "发起投票"; +"NewQuiz" = "新问答"; +"NewSecretChat" = "新建私密聊天"; +"NewStickerPack" = "新贴纸包"; +"NewStories#other" = "%d 条新动态"; +"NewTheme" = "新建主题颜色"; +"NewThemePreviewLine1" = "你知道现在几点?"; +"NewThemePreviewLine2" = "现在是东京的早晨😎"; +"NewThemePreviewLine3" = "东京现在是早晨 😎"; +"NewThemePreviewName" = "鲍勃·哈里斯"; +"NewThemePreviewReply" = "早上好!👋"; +"NewThemePreviewReply2" = "早上好"; +"NewThemeTitle" = "新主题"; +"NewTopic" = "新的话题"; +"Next" = "下一步"; +"NextMediaTap" = "点击显示下一个媒体"; +"NextMediaTapInfo" = "在浏览媒体时,点击靠近屏幕边缘的位置,可以进行导航"; +"NoArchivedStoriesSubtitle" = "上传一条新的动态并在这里查看。"; +"NoArchivedStoriesTitle" = "暂无动态…"; +"NoAudio" = "将文件添加到您设备上的音乐库,就可以在此处查看。"; +"NoAudioFiles" = "暂无音频文件"; +"NoAudioFilesInfo" = "您的音乐库为空。"; +"NoAudioFound" = "无结果"; +"NoAudioFoundInfo" = "在你的曲库中没有与 **%1$s** 匹配的内容。"; +"NoAudioFoundInfo2" = "没有符合 **%1$s** 结果。"; +"NoAudioFoundPlayerInfo" = "没有匹配到 **%1$s**。"; +"NoBlocked" = "当前没有被屏蔽的用户"; +"NoBlockedChannel" = "被封禁的用户将会从频道中移除,并且只有被管理员添加才能再次关注。他们将不能使用邀请链接再次关注。"; +"NoBlockedChannel2" = "被管理员移除的用户不能通过邀请链接重新加入频道。"; +"NoBlockedGroup" = "被封禁的用户将会从群组中移除,并且只有被管理员添加才能再次加入。邀请链接将对其无效。"; +"NoBlockedGroup2" = "被管理员移除的用户不能通过邀请链接再次加入群组。"; +"NoBlockedUsers" = "暂无被移除的用户"; +"NoBoostersGroupHint" = "目前没有用户助推您的群组"; +"NoBoostersHint" = "目前没有用户助推您的频道"; +"NoCallLog" = "这里还没有通话记录…"; +"NoCarMusic" = "聊天中没有音乐"; +"NoChannelsMessage" = "您目前没有订阅任何频道。"; +"NoChannelsTitle" = "暂无频道…"; +"NoChats" = "欢迎来到 Telegram"; +"NoChatsContactsHelp" = "点击下方的一位联系人开始发送消息。"; +"NoChatsHelp" = "点击右下角的聊天气泡图标,开始发送消息。"; +"NoClicks" = "无点击"; +"NoComments" = "暂无留言..."; +"NoContacts" = "还没有联系人"; +"NoContactsViewed" = "您的联系人中无人查看过此动态。"; +"NoContactsYet" = "您在 Telegram 中还没有联系人"; +"NoContactsYet2" = "在 Telegram 上\n还没有联系人"; +"NoContactsYet2Invite" = "邀请好友 >"; +"NoContactsYet2Sub" = "邀请朋友使用 Telegram 或\n通过用户名搜索他人。"; +"NoContactsYet3" = "添加联系人"; +"NoContactsYet3Sub" = "您在 Telegram 上还没有任何联系人。 要不要新增?"; +"NoContactsYetLine1" = "邀请朋友来使用Telegram"; +"NoContactsYetLine2" = "查找附近的人聊天"; +"NoContactsYetLine3" = "通过用户名查找人"; +"NoEffectsFound" = "未找到特效"; +"NoEmojiFound" = "未找到表情"; +"NoEmojiOrStickersFound" = "未找到表情或贴纸"; +"NoExceptions" = "此选项列举了所有未遵循默认通知策略的对话。\n\n您可以打开特定对话的资料页,通过「通知」选项为其自定义通知策略。"; +"NoFilesFound" = "暂无结果"; +"NoFilesFoundInfo" = "当前目录中未发现与 **%1$s** 匹配的内容。"; +"NoFilesInfo" = "该文件夹为空。"; +"NoFilteredMessages" = "未找到该标签下的任何消息或搜索结果。"; +"NoFolderFound" = "未找到聊天文件夹。"; +"NoGIFs" = "无 GIF"; +"NoGIFsFound" = "未找到 GIF"; +"NoGroupsInCommon" = "暂无共同群组"; +"NoHandleAppInstalled" = "您没有安装可用于执行\"%1$s\"类型文件的应用,请先安装一个再继续"; +"NoHashtagStoriesTitle" = "未找到动态"; +"NoIconsFound" = "未找到图标"; +"NoLimit" = "无限制"; +"NoLogFound" = "**未找到结果**"; +"NoLogFoundFiltered" = "**未找到符合您筛选条件的结果**"; +"NoMailInstalled" = "请发送邮件到 reports@stel.com 来告诉我们您遇到的问题 。"; +"NoMasks" = "暂无装饰"; +"NoMedia" = "在此聊天中分享图片和视频,并在您所有的设备上访问。"; +"NoMediaAutoDownload" = "已停用"; +"NoMediaSecret" = "此聊天中共享的所有图片和视频都会在这里显示。"; +"NoMemberRequests" = "暂无加入请求"; +"NoMemberRequestsDescription" = "当前没有待处理的入群请求。"; +"NoMessages" = "暂无消息…"; +"NoMessagesForThisDay" = "今天没有消息"; +"NoMessagesGreetingsDescription" = "发送信息或点击下方的问候语。"; +"NoMonoforumTopicsCreated" = "没有收到消息"; +"NoNetworkUsageSince" = "自 %1$s以来没有网络用量"; +"NoNewCarMessages" = "暂无新消息"; +"NoOneJoined" = "无人加入"; +"NoOneJoinedYet" = "无人加入"; +"NoOneSubscribed" = "无人订阅"; +"NoOtherSessions" = "没有其它的活动会话"; +"NoOtherSessionsInfo" = "您可以从其它的手机、平板和桌面设备用同样的手机号码登录到 Telegram。所有数据将被实时同步。"; +"NoOtherWebSessions" = "没有活跃的第三方登录"; +"NoOtherWebSessionsInfo" = "您可以在支持的网站上使用 Telegram 登录。"; +"NoPhoneFound" = "没有绑定此电话号的 Telegram 账号。"; +"NoPhotos" = "暂无图片"; +"NoPlacesFound" = "找不到地点"; +"NoPlacesFoundInfo" = "您的附近没有与 %1$s 相符的地点。"; +"NoPlayerInstalled" = "没有可用的视频播放器,请先安装一个再播放"; +"NoPopup" = "不弹出"; +"NoPublicStoriesTitle" = "没有公开动态"; +"NoPublicStoriesTitle2" = "暂无贴文"; +"NoReactions" = "禁用表情回应"; +"NoReactionsFound" = "未找到表情回应"; +"NoReactionsStub" = "尚未有人对您的动态作出回应。"; +"NoRecent" = "没有最近使用"; +"NoRecentCalls" = "暂无最近通话"; +"NoRecentCallsInfo" = "最近的语音和视频通话记录将在此处显示"; +"NoRecentGIFs" = "没有最近的 GIF 动态图"; +"NoRecentPhotos" = "没有最近的图片"; +"NoRecentSearches" = "暂无最近搜索记录"; +"NoReplies" = "暂无回复..."; +"NoResult" = "未找到相关信息"; +"NoResultFoundFor" = " \"%s\" 的搜索暂无结果。"; +"NoResultFoundFor2" = "没有关于「%s」的结果。 \n请尝试新的搜索。"; +"NoResultFoundForTag" = "%s 的搜索暂无结果"; +"NoRtmpStreamFromAppOwner" = "**糟糕!** Telegram 没有接收到来自您的直播程序的推流。\n\n请确保您在直播程序中输入了正确的服务器 URL 和串流码。"; +"NoRtmpStreamFromAppViewer" = "%1$s 目前没有向 Telegram 推流直播数据。"; +"NoScheduledMessages" = "暂无定时消息…"; +"NoSharedAudio" = "在此聊天中分享音乐,并在您所有的设备上访问。"; +"NoSharedAudioSecret" = "此聊天中的音乐将在这里显示。"; +"NoSharedFiles" = "在此聊天中分享文档,并在您所有的设备上访问。"; +"NoSharedFilesSecret" = "此聊天中共享的所有文件都会在这里显示。"; +"NoSharedGifSecret" = "此聊天中的 GIF 会显示在这。"; +"NoSharedLinks" = "在此对话中分享链接,并在您所有的设备上访问。"; +"NoSharedLinksSecret" = "此聊天中共享的所有链接都会在这里显示。"; +"NoSharedVoice" = "在此聊天中分享语音消息,并在您所有的设备上访问。"; +"NoSharedVoiceSecret" = "此聊天中共享的所有语音消息都会在这里显示。"; +"NoSound" = "无声"; +"NoStickers" = "暂无表情"; +"NoStickersFound" = "没有符合条件的贴纸包"; +"NoStoriesSubtitle" = "打开“归档”页,选择要在您的个人资料上显示的动态。"; +"NoStoriesSubtitle2" = "发布照片和视频并显示在您的个人资料页上"; +"NoStoriesTitle" = "没有发布的动态"; +"NoSubscribeRequests" = "暂无加入请求"; +"NoSubscribeRequestsDescription" = "当前没有待处理的加入频道请求。"; +"NoSuchChannels" = "没有符合条件的频道"; +"NoSuchChannelsInfo" = "您没有符合此机器人要求的频道"; +"NoSuchGroups" = "没有符合条件的群组"; +"NoSuchGroupsInfo" = "您没有符合此机器人要求的群组"; +"NoSuchUsers" = "没有符合条件的用户"; +"NoSuchUsersInfo" = "未找到符合此机器人要求的用户"; +"NoTopics" = "暂无主题"; +"NoTopicsCreated" = "无新建话题"; +"NoTopicsDescription" = "您可以创建您的第一个话题或点击%s\n切换到消息列表。"; +"NoTopicsDescriptionUser" = "来自该群组的较早消息已移至 **'%s'** 话题。"; +"NoUsernameFound" = "未找到用户名。"; +"NoUsernameFound2" = "该用户在 Telegram 上不存在"; +"NoViews" = "无人查看"; +"NoViewsStub" = "目前无人查看过此动态。"; +"NoVotes" = "无人投票"; +"NoVotesQuiz" = "无人回答"; +"NoWordsRecognized" = "没有识别出单词。"; +"NobodyLikesSpam1" = "抱歉,目前您只能给双向联系人发送消息。"; +"NobodyLikesSpam2" = "抱歉,目前您只能将双向联系人添加到群组。"; +"NobodyLikesSpam3" = "抱歉,您目前无法在此群组中发布消息。"; +"NobodyViewed" = "无人已读"; +"NobodyViews" = "还没有人看过"; +"NobodyViewsArchived" = "没有可用的查看记录"; +"NobodyViewsTitle" = "还没有人看过…"; +"None" = "无"; +"NotMounted" = "未挂载存储器"; +"NotfificationsFrequencyTitle" = "通知频率"; +"NotificationActionMissedCallConference" = "您错过了来自 %1$s 的通话"; +"NotificationActionMissedCallConferenceOther#other" = "您有来自 %2$s 和其他 %1$d 人的未接来电"; +"NotificationActionPinnedContact" = "%1$s 在群组 %2$s 中置顶了一个联系人信息"; +"NotificationActionPinnedContact2" = "%1$s 已在群组 %2$s 中置顶联系人信息 %3$s"; +"NotificationActionPinnedContactChannel" = "%1$s 置顶了一位联系人"; +"NotificationActionPinnedContactChannel2" = "%1$s 已置顶联系人信息 %2$s"; +"NotificationActionPinnedContactUser" = "%1$s 已置顶联系人信息 %2$s"; +"NotificationActionPinnedFile" = "%1$s 在群组 %2$s 中置顶了一个文件"; +"NotificationActionPinnedFileChannel" = "%1$s 置顶了一个文件"; +"NotificationActionPinnedFileUser" = "%1$s 已置顶一个文件"; +"NotificationActionPinnedGame" = "%1$s 在群组 %2$s 中置顶了一个游戏"; +"NotificationActionPinnedGameChannel" = "%1$s 置顶了一个游戏"; +"NotificationActionPinnedGameScore" = "%1$s 在群组 %2$s 中已置顶一个游戏分数"; +"NotificationActionPinnedGameScoreChannel" = "%1$s 已置顶游戏得分"; +"NotificationActionPinnedGameScoreUser" = "%1$s 已置顶游戏分数"; +"NotificationActionPinnedGameUser" = "%1$s 已置顶一个游戏"; +"NotificationActionPinnedGeo" = "%1$s 在群组 %2$s 中置顶了一张地图"; +"NotificationActionPinnedGeoChannel" = "%1$s 已置顶一张地图"; +"NotificationActionPinnedGeoLive" = "%1$s 在群组 %2$s 中置顶了一个实时位置"; +"NotificationActionPinnedGeoLiveChannel" = "%1$s 已置顶一个实时位置"; +"NotificationActionPinnedGeoLiveUser" = "%1$s 已置顶一个实时位置"; +"NotificationActionPinnedGeoUser" = "%1$s 已置顶一张地图"; +"NotificationActionPinnedGif" = "%1$s 在群组 %2$s 中置顶了一张 GIF 动态图"; +"NotificationActionPinnedGifChannel" = "%1$s 置顶了一张 GIF"; +"NotificationActionPinnedGifUser" = "%1$s 已置顶一张 GIF"; +"NotificationActionPinnedInvoice" = "%1$s 在群组 %2$s 中置顶了一份账单"; +"NotificationActionPinnedInvoiceChannel" = "%1$s 已置顶一份账单"; +"NotificationActionPinnedInvoiceUser" = "%1$s 已置顶一份账单"; +"NotificationActionPinnedMusic" = "%1$s 在 %2$s 群组中置顶了一个音频文件"; +"NotificationActionPinnedMusicChannel" = "%1$s 置顶了一个音频文件"; +"NotificationActionPinnedMusicUser" = "%1$s 已置顶一个音频文件"; +"NotificationActionPinnedNoText" = "%1$s 在群组 %2$s 中置顶了一条消息"; +"NotificationActionPinnedNoTextChannel" = "%1$s 已置顶一条消息"; +"NotificationActionPinnedNoTextUser" = "%1$s 已置顶一条消息"; +"NotificationActionPinnedPhoto" = "%1$s 在群组 %2$s 中置顶了一张图片"; +"NotificationActionPinnedPhotoChannel" = "%1$s 置顶了一张图片"; +"NotificationActionPinnedPhotoUser" = "%1$s 已置顶一张图片"; +"NotificationActionPinnedPoll" = "%1$s 在群组 %2$s 置顶了一项投票"; +"NotificationActionPinnedPoll2" = "%1$s 在群组 %2$s 中已置顶投票 %3$s"; +"NotificationActionPinnedPollChannel" = "%1$s 置顶了一项投票"; +"NotificationActionPinnedPollChannel2" = "%1$s 已置顶投票 %2$s"; +"NotificationActionPinnedPollUser" = "%1$s 已置顶投票 %2$s"; +"NotificationActionPinnedQuiz2" = "%1$s 已置顶群组 %2$s 中 \"%3$s\" 问卷"; +"NotificationActionPinnedQuizChannel2" = "%1$s 已置顶问答 “%2$s”"; +"NotificationActionPinnedQuizUser" = "%1$s 已置顶问答 %2$s"; +"NotificationActionPinnedRound" = "%1$s 在群组 %2$s 中置顶了一条视频消息"; +"NotificationActionPinnedRoundChannel" = "%1$s 已置顶一条视频消息"; +"NotificationActionPinnedRoundUser" = "%1$s 已置顶一条视频消息"; +"NotificationActionPinnedSticker" = "%1$s 在群组 %2$s 中置顶了一张贴纸"; +"NotificationActionPinnedStickerChannel" = "%1$s 置顶了一个表情"; +"NotificationActionPinnedStickerEmoji" = "%1$s 在群组«%2$s»置顶了一个 %3$s 表情"; +"NotificationActionPinnedStickerEmojiChannel" = "%1$s 置顶了一个 %2$s 表情"; +"NotificationActionPinnedStickerEmojiUser" = "%1$s 置顶了 %2$s 张贴纸"; +"NotificationActionPinnedStickerUser" = "%1$s 已置顶一张贴纸"; +"NotificationActionPinnedText" = "%1$s 在群组 %3$s 中置顶了 “%2$s”"; +"NotificationActionPinnedTextChannel" = "%1$s 置顶了«%2$s»"; +"NotificationActionPinnedTextUser" = "%1$s 已置顶 “%2$s”"; +"NotificationActionPinnedTodo2" = "%1$s 在群组 %2$s 中置顶了核对清单 %3$s"; +"NotificationActionPinnedTodoChannel2" = "%1$s 已置顶核对清单 %2$s"; +"NotificationActionPinnedTodoUser" = "%1$s 已置顶核对清单 %2$s"; +"NotificationActionPinnedVideo" = "%1$s 在群组 %2$s 中置顶了一段视频"; +"NotificationActionPinnedVideoChannel" = "%1$s 置顶了一段视频"; +"NotificationActionPinnedVideoUser" = "%1$s 已置顶一段视频"; +"NotificationActionPinnedVoice" = "%1$s 在群组中 %2$s 已置顶一段语音消息"; +"NotificationActionPinnedVoiceChannel" = "%1$s 已置顶一条语音消息"; +"NotificationActionPinnedVoiceUser" = "%1$s 已置顶一条语音消息"; +"NotificationChannelMessagePaidMedia#other" = "%2$s 已发布一条价格为 %1$d 颗星星的付费帖子"; +"NotificationChannelStory" = "%s 已分享一条动态"; +"NotificationChatMessagePaidMedia#other" = "%2$s 在 %3$s 群组中发布了一条价格为 %1$d 颗星星的付费帖子"; +"NotificationChatReactGiveaway" = "%2$s 群组中的 %1$s 已对您的抽奖活动做出了 %3$s 回应"; +"NotificationChatStory" = "%s 已在群组中分享一条动态"; +"NotificationContactJoined" = "%1$s 已加入 Telegram!"; +"NotificationContactNewPhoto" = "%1$s 更新了头像"; +"NotificationDisabled" = "已停用"; +"NotificationEditedGroupName" = "%1$s 已将群组重命名为 %2$s"; +"NotificationEditedGroupPhoto" = "%1$s 已更改 %2$s 的群组头像"; +"NotificationEditedGroupVideo" = "%1$s 已更改 %2$s 的群组图片"; +"NotificationEnabled" = "已启用"; +"NotificationEnabledAutomatically" = "已自动启用"; +"NotificationGroupAddMember" = "%1$s 已邀请 %3$s 加入群组 %2$s"; +"NotificationGroupAddSelf" = "%1$s 已返回群组 %2$s"; +"NotificationGroupAddSelfMega" = "%1$s 已加入群组 %2$s"; +"NotificationGroupAlbum" = "%1$s 在群组 %2$s 中发送了一个相册"; +"NotificationGroupCreatedCall" = "%1$s 已在 %2$s 开始视频聊天"; +"NotificationGroupEndedCall" = "%1$s 已结束 %2$s 的视频聊天"; +"NotificationGroupFew" = "%1$s 已发送 %3$s 到群组 %2$s"; +"NotificationGroupForwardedFew" = "%1$s 在群组 %2$s 中转发了 %3$s 条消息"; +"NotificationGroupInvitedToCall" = "%1$s 已邀请 %3$s 加入 %2$s 的视频聊天"; +"NotificationGroupInvitedYouToCall" = "%1$s 已邀请您加入 %2$s 的视频聊天"; +"NotificationGroupKickMember" = "%1$s 已将 %3$s 移出群组 %2$s"; +"NotificationGroupKickYou" = "%1$s 已将您移出群组 %2$s"; +"NotificationGroupLeftMember" = "%1$s 已退出群组 %2$s"; +"NotificationHiddenChatName" = "Telegram"; +"NotificationHiddenChatUserName" = "用户"; +"NotificationHiddenMessage" = "新消息"; +"NotificationHiddenName" = "Telegram"; +"NotificationInvitedToGroup" = "%1$s 已邀请您加入群组 %2$s"; +"NotificationInvitedToGroupByLink" = "%1$s 通过邀请链接加入群组«%2$s»"; +"NotificationMessageAlbum" = "%1$s 给你发送了相册"; +"NotificationMessageAudio" = "%1$s 给您发送了一条语音消息"; +"NotificationMessageChannelGiveaway" = "%1$s 发布了一场抽奖活动,奖品为 %2$s 份 %3$s 个月的高级版订阅"; +"NotificationMessageChannelStarsGiveaway" = " %1$s 已为 %2$s 名获奖者发起了一次抽奖活动,奖品为 %3$s 颗星星。"; +"NotificationMessageChannelStarsGiveaway2" = "%1$s 发布了一场向 %2$s 赠送 %3$s 颗星星的抽奖活动"; +"NotificationMessageChatGiveaway" = "%1$s 已在 %2$s 群组中发送了一场抽奖活动,奖品为 %3$s 份 %4$s 个月的高级版订阅"; +"NotificationMessageChatStarsGiveaway" = "%1$s 向群组 %2$s 发送了一次为 %3$s 名获奖者发起的抽奖活动,奖品为 %4$s 颗星星。"; +"NotificationMessageChatStarsGiveaway2" = "\n%1$s 向群组 %4$s 发送了向 %2$s 赠送 %3$s 颗星星的抽奖活动"; +"NotificationMessageContact" = "%1$s 给您分享了一个联系人信息"; +"NotificationMessageContact2" = "%1$s 与您分享了一个联系人信息 %2$s "; +"NotificationMessageDocument" = "%1$s 给您发送了一个文件"; +"NotificationMessageFew" = "%1$s 发送给您 %2$s"; +"NotificationMessageForwardFew" = "%1$s 转发给您 %2$s"; +"NotificationMessageGame" = "%1$s 邀请您玩 %2$s "; +"NotificationMessageGameScored" = "%1$s 在游戏 %2$s 中得到 %3$s 分"; +"NotificationMessageGif" = "%1$s 给您发送了一张 GIF"; +"NotificationMessageGiftCode" = "%1$s 已向您发送 %2$s 个 Telegram 高级版的礼品码"; +"NotificationMessageGiveaway" = "%1$s 已发送给您一场抽奖活动,奖品为 %2$s 份 %3$s 个月的 Telegram 高级版订阅"; +"NotificationMessageGroupAudio" = "%1$s 发送了一条语音消息到 %2$s 群组 "; +"NotificationMessageGroupContact" = "%1$s 在群组 %2$s 中分享了一个联系人信息"; +"NotificationMessageGroupContact2" = "%1$s 已在群组 %2$s 中分享一个联系人信息 %3$s"; +"NotificationMessageGroupDocument" = "%1$s 在群组 %2$s 中发送了一个文件"; +"NotificationMessageGroupGame" = "%1$s 已邀请您加入群组 %2$s 来玩 %3$s"; +"NotificationMessageGroupGameScored" = "%1$s 在群组 %2$s 的游戏 %3$s 中获得 %4$s 分"; +"NotificationMessageGroupGif" = "%1$s 在群组 %2$s 中发送了一张 GIF 动态图"; +"NotificationMessageGroupInvoice" = "%1$s 在群组 %2$s 中发送了一份金额为 %3$s 的账单"; +"NotificationMessageGroupLiveLocation" = "%1$s 已开始在群组 %2$s 中共享实时位置"; +"NotificationMessageGroupMap" = "%1$s 在群组 %2$s 中发送了一个位置信息"; +"NotificationMessageGroupMusic" = "%1$s 发送了一个音频文件到 %2$s 群组"; +"NotificationMessageGroupNoText" = "%1$s 在群组 %2$s 中发送了一条消息"; +"NotificationMessageGroupPhoto" = "%1$s 在群组 %2$s 中发送了一张图片"; +"NotificationMessageGroupPoll" = "%1$s 在 %2$s 中发送了一项投票"; +"NotificationMessageGroupPoll2" = "%1$s 在群组 %2$s 中发送了一项投票 %3$s"; +"NotificationMessageGroupQuiz2" = "%1$s 在群组 %2$s 中发送了一个问答 “%3$s”"; +"NotificationMessageGroupRound" = "%1$s 在群组 %2$s 中发送了一条视频消息"; +"NotificationMessageGroupSticker" = "%1$s 在群组 %2$s 中发送了一张贴纸"; +"NotificationMessageGroupStickerEmoji" = "%1$s 发送了一个 %3$s 表情到群组«%2$s»"; +"NotificationMessageGroupText" = "%1$s @ %2$s:%3$s"; +"NotificationMessageGroupTodo2" = "%1$s 向群组 %2$s 发送了核对清单 %3$s "; +"NotificationMessageGroupTodoAppend2" = "%1$s 在群组 %2$s 中添加了 %3$s 个任务"; +"NotificationMessageGroupTodoDone2" = "%1$s 在群组 %2$s 中切换了 %3$s 个任务"; +"NotificationMessageGroupVideo" = "%1$s 在群组 %2$s 中发送了一段视频"; +"NotificationMessageInvoice" = "%1$s 向您发送了一份金额为 %2$s 的账单"; +"NotificationMessageLiveLocation" = "%1$s 已与您共享实时位置"; +"NotificationMessageMap" = "%1$s 给您发送了一个位置"; +"NotificationMessageMusic" = "%1$s 给您发送了一个音频文件"; +"NotificationMessageNoText" = "%1$s 给您发送了一条消息"; +"NotificationMessagePaidMedia#other" = "%2$s 给您发送了一条价格为 %1$d 颗星星的付费帖子"; +"NotificationMessagePhoto" = "%1$s 给您发送了一张图片"; +"NotificationMessagePoll" = "%1$s 给您发送了一项投票"; +"NotificationMessagePoll2" = "%1$s 给你发送了一个投票活动 %2$s"; +"NotificationMessageQuiz2" = "%1$s 给您发送了一个问答 “%2$s”"; +"NotificationMessageRecurringPay" = "已收取 %1$s %2$s"; +"NotificationMessageRound" = "%1$s 给您发送了一条视频消息"; +"NotificationMessageSDPhoto" = "%1$s 给您发送了一张阅后即焚图片"; +"NotificationMessageSDVideo" = "%1$s 给您发送了一段阅后即焚视频"; +"NotificationMessageScheduled" = "📅 您: %1$s"; +"NotificationMessageScheduledName" = "📅 您"; +"NotificationMessageStarGift#other" = "%2$s 向您赠送了一份价值 %1$d 颗星星的礼物"; +"NotificationMessageStarsGiveaway" = "%1$s 向您发送了一次为 %2$s 名获奖者发起的抽奖活动,奖品为 %3$s 颗星星。\n41/41"; +"NotificationMessageStarsGiveaway2" = "%1$s 给您发送了向 %2$s 赠送 %3$s 颗星星抽奖活动"; +"NotificationMessageSticker" = "%1$s 给您发送了一个表情"; +"NotificationMessageStickerEmoji" = "%1$s 给您发送了一个 %2$s 表情"; +"NotificationMessageSuggestBirthday" = "%s 建议您添加生日"; +"NotificationMessageText" = "%1$s:%2$s"; +"NotificationMessageTodo2" = "%1$s 向您发送了核对清单 %2$s"; +"NotificationMessageUniqueStarGift" = "%1$s 已转让一份礼物给你!"; +"NotificationMessageUniqueStarGiftPrepaidUpgrade#other" = "%2$s 赠送了价值 %1$d 颗星星的礼物升级"; +"NotificationMessageUniqueStarGiftUnpackUpgrade" = "%1$s 打开了你帮助升级的礼物"; +"NotificationMessageUniqueStarGiftUpgrade" = "%1$s 已升级你的礼物"; +"NotificationMessageVideo" = "%1$s 给您发送了一段视频"; +"NotificationMessagesPeopleDisplayOrder" = "%1$s %2$s"; +"NotificationPaidMedia#other" = "%1$d ⭐️ 媒体"; +"NotificationPinnedGiveaway" = "%1$s 已将抽奖活动置顶"; +"NotificationPinnedPaidMedia#other" = "%2$s 已置顶一条价格为 **%1$d 颗星星** 的付费帖子"; +"NotificationReactGiveaway" = "%1$s 已对您的抽奖活动作出 %2$s 回应"; +"NotificationReactions" = "回应"; +"NotificationReactionsMessages" = "消息"; +"NotificationReactionsStories" = "动态"; +"NotificationShowSenderNames" = "显示发送者名称"; +"NotificationStories" = "动态"; +"NotificationStory" = "%s 已将一条动态分享给您"; +"NotificationTopicExceptionsDesctription#other" = "%s, %d 个例外"; +"NotificationUnrecognizedDevice" = "%1$s,\n我们发现您的帐号在 %2$s 从新的设备登录\n\n设备:%3$s\n位置:%4$s\n\n如果这不是您本人,您可以前往「Telegram 设置 > 隐私和安全 > 设备」中将该对话进行注销。\n\n如果您认为其他人在您不知情的情况下登录了您的账号,您可以在「Telegram 设置 > 隐私和安全」中开启「两步验证」。\n\n此致,\nTelegram 团队"; +"Notifications" = "通知"; +"NotificationsAddAnException" = "添加例外"; +"NotificationsAndSounds" = "通知和声音"; +"NotificationsChannels" = "频道"; +"NotificationsChartTitle" = "通知"; +"NotificationsChatInApp" = "应用内 %1$s "; +"NotificationsCustom" = "自定义"; +"NotificationsCustomize" = "自定义"; +"NotificationsDefault" = "默认"; +"NotificationsDefaultOff" = "默认(关闭)"; +"NotificationsDefaultOn" = "默认(开启)"; +"NotificationsDeleteAllException" = "删除所有例外"; +"NotificationsDeleteAllExceptionAlert" = "您确定删除所有例外吗?"; +"NotificationsDeleteAllExceptionTitle" = "删除所有例外"; +"NotificationsEnableCustom" = "启用自定义通知策略"; +"NotificationsExceptions" = "例外"; +"NotificationsExceptionsAlert" = "请注意,有 **%1$s** 是例外,不会受到这次更改的影响。"; +"NotificationsExceptionsSingleAlert" = "请注意,**%1$s**是一个例外,不会受到这次更改的影响。"; +"NotificationsForChannels" = "频道通知"; +"NotificationsForChats" = "聊天通知"; +"NotificationsForGroups" = "群组通知"; +"NotificationsForPrivateChats" = "私聊通知"; +"NotificationsForStories" = "动态通知"; +"NotificationsFrequencyDivider" = "于"; +"NotificationsGroups" = "群组"; +"NotificationsImportance" = "优先级"; +"NotificationsInAppDefault" = "应用内默认"; +"NotificationsLed" = "指示灯"; +"NotificationsLedColor" = "颜色"; +"NotificationsLedInfo" = "在部分设备上能够提示新消息的闪烁小灯。"; +"NotificationsMuted" = "已静音"; +"NotificationsMutedForHint" = "%1$s 内的通知已静音。"; +"NotificationsMutedHint" = "通知已静音。"; +"NotificationsMutedHintChats#other" = "%d 个聊天中的通知已静音"; +"NotificationsNewException" = "新建例外"; +"NotificationsOff" = "关闭"; +"NotificationsOffUntil" = "关闭至 %1$s"; +"NotificationsOn" = "开启"; +"NotificationsOther" = "其它"; +"NotificationsPermissionAlert1" = "接收新消息通知"; +"NotificationsPermissionAlert2" = "了解您何时在群组中被提及"; +"NotificationsPermissionAlert3" = "将特定聊天或聊天类型静音"; +"NotificationsPermissionAlertSubtitle" = "请允许 Telegram 向您发送通知,以便您与亲友保持联系。"; +"NotificationsPermissionAlertTitle" = "打开通知"; +"NotificationsPermissionContinue" = "继续"; +"NotificationsPermissionSettings" = "打开设置"; +"NotificationsPriority" = "优先级"; +"NotificationsPriorityDefault" = "默认"; +"NotificationsPriorityHigh" = "高"; +"NotificationsPriorityLow" = "低"; +"NotificationsPriorityMax" = "最高"; +"NotificationsPriorityMedium" = "中"; +"NotificationsPrioritySettings" = "和全局设置相同"; +"NotificationsPriorityUrgent" = "紧急"; +"NotificationsPrivateChats" = "私聊"; +"NotificationsReactions" = "回应"; +"NotificationsService" = "后台服务常驻"; +"NotificationsServiceConnection" = "后台网络连接"; +"NotificationsServiceConnectionInfo" = "与 Telegram 保持低功耗连接,以获得可靠的通知。"; +"NotificationsServiceInfo" = "关闭时,重新启动应用程序。确保启用可靠通知。"; +"NotificationsSilent" = "静音"; +"NotificationsSound" = "通知声音"; +"NotificationsSoundChannels" = "频道声音"; +"NotificationsSoundGroup" = "群组铃声"; +"NotificationsSoundPrivate" = "私聊的声音"; +"NotificationsSoundReactions" = "表情回应音效"; +"NotificationsSoundStories" = "动态声音"; +"NotificationsStories" = "动态"; +"NotificationsStoryMute" = "不接收通知"; +"NotificationsStoryMute2" = "不接收动态通知"; +"NotificationsStoryMutedHint" = "已停用来自 **%s** 的动态通知。"; +"NotificationsStoryUnmute" = "接收通知"; +"NotificationsStoryUnmute2" = "接收\n动态通知"; +"NotificationsStoryUnmutedHint" = "**%s** 发布动态时您将收到一则通知提醒。"; +"NotificationsTurnOff" = "关闭"; +"NotificationsTurnOn" = "开启"; +"NotificationsUnmuted" = "已启用通知"; +"NotificationsUnmutedHint" = "通知已取消静音。"; +"NotificationsUnmutedHintChats#other" = "%d 个聊天中的通知已取消静音"; +"NotifyAboutReactionsFrom" = "当收到以下来源的表情回应时通知我"; +"NotifyAboutReactionsFromContacts" = "我的联系人"; +"NotifyAboutReactionsFromEveryone" = "所有人"; +"NotifyFromContacts" = "来自我的联系人"; +"NotifyFromEveryone" = "来自所有人"; +"NotifyFromNobody" = "关闭"; +"NotifyLessOptions" = "收起选项"; +"NotifyMeAbout" = "通知我有关…"; +"NotifyMeAboutChannels" = "频道中的新消息"; +"NotifyMeAboutGroups" = "群组中的新消息"; +"NotifyMeAboutImportantStories" = "重要动态"; +"NotifyMeAboutMessagesReactions" = "我的消息收到的回应"; +"NotifyMeAboutNewStories" = "新动态"; +"NotifyMeAboutPrivate" = "私聊中的新消息"; +"NotifyMeAboutStoriesReactions" = "我的动态收到的回应"; +"NotifyMoreOptions" = "更多选项"; +"November" = "十一月"; +"NowInContacts" = "%1$s 已成为您的联系人。"; +"NumberNotOnTelegram" = "此号码未加入 Telegram"; +"NumberUnknown" = "未知"; +"OK" = "确定"; +"October" = "十月"; +"Of" = "%1$d / %2$d"; +"OfCounted" = "**%1$d** / %2$d"; +"OfS" = "%1$s / %2$s"; +"OneResult" = "1 结果"; +"Online" = "在线"; +"OnlineCount#other" = "%1$d 人在线"; +"OnlyAllowThisReactions" = "仅允许这些表情回复"; +"OnlyIfSilent" = "仅静音模式"; +"OnlyPremiumCanMessage" = "%s 只接受来自高级版订阅用户的消息。\n**了解更多...**"; +"OnlyPremiumCanReply" = "只有 Telegram 高级版用户才可以回复 %s\n**了解更多...**"; +"OnlyWhenScreenOff" = "只在屏幕关闭时"; +"OnlyWhenScreenOn" = "只在屏幕打开时"; +"Open" = "打开"; +"OpenAIStyle" = "查看风格"; +"OpenAllTopics" = "所有话题"; +"OpenBackground" = "查看壁纸"; +"OpenBot" = "打开机器人"; +"OpenChannel" = "查看频道"; +"OpenChannel2" = "打开频道"; +"OpenChannelArchiveStories" = "已归档的动态"; +"OpenChannelDirect" = "联系频道"; +"OpenChannelPost" = "查看帖子"; +"OpenEmojiSet" = "查看表情"; +"OpenFile" = "打开文件"; +"OpenFragment" = "打开 Fragment"; +"OpenGift" = "打开"; +"OpenGiftAuctionActive" = "加入"; +"OpenGiftAuctionResults" = "查看结果"; +"OpenGiftAuctionView" = "查看"; +"OpenGroup" = "查看群组"; +"OpenGroup2" = "打开群组"; +"OpenInBrowser" = "使用其它应用打开…"; +"OpenInEditor" = "在编辑器中打开"; +"OpenInExternalApp" = "使用其它应用打开…"; +"OpenInMaps" = "在地图中打开"; +"OpenInSystemBrowser" = "打开于..."; +"OpenInSystemBrowser2" = "在浏览器中打开"; +"OpenInTelegramBrowser" = "在 Telegram 上打开"; +"OpenInTelegramBrowser2" = "在应用内打开"; +"OpenInstantView" = "即时预览"; +"OpenLink" = "打开链接"; +"OpenLocalInstantView" = "即时预览"; +"OpenMessage" = "查看消息"; +"OpenProfile" = "打开个人资料"; +"OpenStickerSet" = "查看贴纸"; +"OpenTheme" = "主题预览"; +"OpenUniqueGift" = "查看典藏品"; +"OpenUrlAlert" = "是否打开链接 %1$s ?"; +"OpenUrlAlert2" = "您想要打开链接%1$s吗?"; +"OpenUrlOption1" = "以 **%2$s** 身份登录 %1$s"; +"OpenUrlOption2" = "允许 **%1$s** 给我发消息"; +"OpenUrlTitle" = "打开链接"; +"OptimizingTelegram" = "正在优化 Telegram…"; +"OptimizingTelegramDescription1" = "这可能需要一定时间,具体取决于你的数据库大小。在此过程完成之前,请保持 Telegram 打开。"; +"OptimizingTelegramDescription2" = "如有不便,深感抱歉。"; +"Option#other" = "%1$d 个选项"; +"OptionHint" = "选项"; +"OptionPremiumRequiredButton" = "解锁"; +"OptionPremiumRequiredMessage" = "订阅 **Telegram 高级版** 以启用此选项。"; +"OptionPremiumRequiredTitle" = "需订阅 Telegram 高级版"; +"Orange" = "橙色"; +"OtherLoginCode" = "您的登录验证码是**%1$s**。在您尝试登录 Telegram 应用程序时输入它。 \n\n不要将此验证码透露给任何人。"; +"OtherSessions" = "活跃会话"; +"OtherSettings" = "其他设置"; +"OtherStickers" = "更多贴纸"; +"OtherWebSessions" = "已授权网站"; +"OutgoingCalls" = "拨出"; +"OutgoingCallsCount" = "%s 个拨出通话"; +"P2PContacts" = "我的联系人"; +"P2PContactsMinus" = "我的联系人 (-%1$d)"; +"P2PContactsMinusPlus" = "我的联系人 (-%1$d, +%2$d)"; +"P2PContactsPlus" = "我的联系人 (+%1$d)"; +"P2PEnabledWith" = "对谁启用端对端连接"; +"P2PEverybody" = "所有人"; +"P2PEverybodyMinus" = "所有人 (-%1$d)"; +"P2PNobody" = "不允许任何人"; +"P2PNobodyPlus" = "不允许任何人 (+%1$d)"; +"Page1Message" = "世界上**最快的**聊天工具\n**免费**又**安全**。"; +"Page1Title" = "Telegram"; +"Page2Message" = "**Telegram** 收发信息\n比任何应用都快。"; +"Page2Title" = "快速"; +"Page3Message" = "**Telegram** 提供免费无限量的聊天与媒体云端存储。"; +"Page3Title" = "免费"; +"Page4Message" = "**Telegram** 保障您的消息安全\n免受黑客攻击。"; +"Page4Title" = "安全"; +"Page5Message" = "**Telegram** 不论消息或是媒体文件\n都无大小限制"; +"Page5Title" = "强大"; +"Page6Message" = "**Telegram** 让您能从\n多个设备收发消息。"; +"Page6Title" = "基于云端"; +"PaidContentButton" = "设为付费媒体"; +"PaidContentClearButton" = "保持此媒体免费"; +"PaidContentInfo" = "用户需将此数量的星星转账至您的频道才能查看此媒体。**了解更多 >**"; +"PaidContentInfoLink" = "https://telegram.org/tos/stars"; +"PaidContentPriceTitle" = "输入解锁价格"; +"PaidContentTitle" = "付费内容"; +"PaidContentUpdateButton" = "保存价格"; +"PaidMediaButton" = "将此内容设置为付费内容"; +"PaidMediaPrice#other" = "⭐️%d"; +"PaidMediaPriceButton" = "编辑价格"; +"PaidMessageAction#other" = "un1 花费了 %1$d 颗星星以发送一条消息。"; +"PaidMessageActionIn#other" = "un1 已支付 %1$d 颗星星来发送消息"; +"PaidMessageActionMany#other" = "un1 已支付 %1$d 颗星星以发送 %2$s"; +"PaidMessageActionMessages#other" = "%d 条消息"; +"PaidMessageActionOut#other" = "你已支付 %d 颗星星以发送消息"; +"PaidMessageActionOutMany#other" = "你已支付 %1$d 以发送 %2$s"; +"PaidMessageSentSubtitle#other" = "您支付了 **%d** 颗星星。"; +"PaidMessageSentTitle#other" = "已发送 %d 条消息!"; +"PaidMessageSentTitleOne" = "已发送消息!"; +"PaidMessagesPriceUpdated#other" = "un1 已将价格更改为每条消息 %1$d 颗星星"; +"PaidMessagesPriceUpdatedOut#other" = "您将价格更改为每条消息 %1$d 颗星星"; +"PaidMessagesRefunded#other" = "un1 已将 %1$d 颗星星退还给您"; +"PaidMessagesRefundedOut#other" = "您已将 %1$d 颗星星退还给 un1"; +"PaidMessagesSendErrorState1#other" = "价格从 ⭐️%d 上涨"; +"PaidMessagesSendErrorState2#other" = "至 ⭐️%d"; +"PaidMessagesSendErrorToast1#other" = "价格从 ⭐️%d 上涨"; +"PaidMessagesSendErrorToast2#other" = "至 ⭐️%d"; +"PaintArrow" = "箭头"; +"PaintBringToFront" = "置于前端"; +"PaintBubble" = "气泡"; +"PaintCircle" = "圆形"; +"PaintDelete" = "删除"; +"PaintDuplicate" = "复制"; +"PaintEdit" = "编辑"; +"PaintFramed" = "阴影"; +"PaintMarker" = "记号笔"; +"PaintNeon" = "荧光笔"; +"PaintOutlined" = "轮廓"; +"PaintPaletteGrid" = "栅格"; +"PaintPaletteSliders" = "滑块式"; +"PaintPaletteSlidersBlue" = "蓝色"; +"PaintPaletteSlidersGreen" = "绿色"; +"PaintPaletteSlidersHexColor" = "十六进制颜色编码 #"; +"PaintPaletteSlidersRed" = "红色"; +"PaintPaletteSpectrum" = "谱系"; +"PaintPen" = "画笔"; +"PaintRectangle" = "长方形"; +"PaintRegular" = "常规"; +"PaintStar" = "星"; +"Participants#other" = "%1$d 名参与者"; +"Passcode" = "密码锁"; +"PasscodeDoNotMatch" = "前后两次输入的锁定码不一致"; +"PasscodeFinish" = "设置锁定码"; +"PasscodePIN" = "PIN 码"; +"PasscodePassword" = "密码"; +"PasscodeReinstallNotice" = "如果您忘记了锁定码,您将只能 **退出登录** 或 **重新安装** Telegram 应用。而所有私密聊天将会丢失。"; +"PasscodeScreenHint" = "点击聊天列表上方的“锁”图标锁定应用。"; +"PasscodeSwitchToPIN" = "切换到PIN码"; +"PasscodeSwitchToPassword" = "切换到密码"; +"PasscodesDoNotMatchTryAgain" = "锁定码不一致,请重试。"; +"Passkey" = "通行密钥"; +"PasskeyAdd" = "创建通行密钥"; +"PasskeyAddedText" = "您现在可以使用 %s 登录账号。"; +"PasskeyAddedTitle" = "已添加通行密钥!"; +"PasskeyCreatedOn" = "创建于 %s"; +"PasskeyDeleteText" = "删除后,此通行密钥将无法用于登录。\n\n也别忘了从您的密码管理器中移除它。"; +"PasskeyDeleteTitle" = "删除通行密钥"; +"PasskeyFeature1Subtitle" = "创建通行密钥,轻松安全地登录。"; +"PasskeyFeature1Title" = "创建通行密钥"; +"PasskeyFeature2Subtitle" = "使用您的面容、指纹或屏幕锁定码登录。"; +"PasskeyFeature2Title" = "使用面容识别登录"; +"PasskeyFeature3Subtitle" = "您的通行密钥安全地存储在您选择的云服务中。"; +"PasskeyFeature3Title" = "安全存储通行密钥"; +"PasskeyFeatureButton" = "创建通行密钥"; +"PasskeyFeatureSubtitle" = "安全登录,保障账号安全。"; +"PasskeyFeatureTitle" = "保护您的账号"; +"PasskeyInfo" = "您的通行密钥已安全存储在密码管理器中。**了解更多 >**"; +"PasskeyLastUsedOn" = "最后使用于 %s"; +"PasskeyNoOptionsText" = "请在您的手机或硬件钱包上安装支持保存通行密钥的应用。"; +"PasskeyNoOptionsTitle" = "无通行密钥应用"; +"PasskeyPopupText" = "使用面容、指纹或屏幕锁登录"; +"PasskeyPopupTitle" = "🔑 保护您的账号"; +"PasskeyTopInfo" = "管理您的通行密钥,它安全地存储在您选择的云服务中。"; +"PasskeyUnknown" = "通行密钥"; +"PassportAddAddressUploadInfo" = "为了确认您的住址,请上传所选证件(全部页面)的扫描件或照片。"; +"PassportAddAgreement" = "添加租约信息"; +"PassportAddAgreementInfo" = "上传一份租约的扫描件。"; +"PassportAddBank" = "添加银行对账单信息"; +"PassportAddBankInfo" = "上传一份银行对账单的扫描件。"; +"PassportAddBill" = "添加水电气费账单信息"; +"PassportAddBillInfo" = "上传一份水电气费的扫描件。"; +"PassportAddCard" = "添加身份证信息"; +"PassportAddDriverLicenceInfo" = "上传一份经过认证的驾照英语翻译扫描件。"; +"PassportAddIdentityCardInfo" = "上传一份经过认证的身份证英语翻译扫描件。"; +"PassportAddInternalPassport" = "添加户口(Internal Passport)信息"; +"PassportAddInternalPassportInfo" = "上传一份经过认证的户口本(Internal Passport)英语翻译扫描件。"; +"PassportAddLicence" = "添加驾照信息"; +"PassportAddPassport" = "添加护照信息"; +"PassportAddPassportInfo" = "上传一份经过认证的护照英语翻译扫描件。"; +"PassportAddPassportRegistration" = "护照信息"; +"PassportAddPassportRegistrationInfo" = "上传一份您护照里关于登记信息那一页的扫描件。"; +"PassportAddTemporaryRegistration" = "临时证明信息"; +"PassportAddTemporaryRegistrationInfo" = "上传一份您临时证明的扫描件。"; +"PassportAddTranslationAgreementInfo" = "上传一份经过认证的租约英语翻译扫描件。"; +"PassportAddTranslationBankInfo" = "上传一份经过认证的银行对账单英语翻译扫描件。"; +"PassportAddTranslationBillInfo" = "上传一份经过认证的水电费账单英语翻译扫描件。"; +"PassportAddTranslationPassportRegistrationInfo" = "上传一份经过认证的护照注册信息英语翻译扫描件。"; +"PassportAddTranslationTemporaryRegistrationInfo" = "上传一份经过认证的临时证明英语翻译扫描件。"; +"PassportAddTranslationUploadInfo" = "上传经过认证的所选证件英语翻译扫描件。"; +"PassportAddress" = "地址信息"; +"PassportAddressHeader" = "地址"; +"PassportAddressInfo" = "上传您的地址证明"; +"PassportAddressNoUploadInfo" = "请提供您的住址"; +"PassportAuthorize" = "授权"; +"PassportBirthdate" = "生日"; +"PassportCitizenship" = "国籍"; +"PassportCity" = "城市"; +"PassportCorrectErrors" = "点击以纠正错误。"; +"PassportCountry" = "国家"; +"PassportDeleteAddressAlert" = "您确定要删除详细地址吗?"; +"PassportDeleteDocument" = "删除证件"; +"PassportDeleteDocumentAddress" = "删除住址信息"; +"PassportDeleteDocumentAlert" = "您确定要删除此证件吗?"; +"PassportDeleteDocumentPersonal" = "删除个人信息"; +"PassportDeleteEmailAlert" = "删除电子邮箱?"; +"PassportDeleteInfo" = "删除"; +"PassportDeletePersonalAlert" = "您确定要删除个人信息吗?"; +"PassportDeletePhoneAlert" = "是否删除手机号码?"; +"PassportDeleteScan" = "删除扫描件?"; +"PassportDeleteScanAlert" = "您确定要删除此扫描件吗?"; +"PassportDeleteSelfie" = "删除自拍照?"; +"PassportDeleteSelfieAlert" = "您确定要删除此自拍照吗?"; +"PassportDiscard" = "放弃"; +"PassportDiscardChanges" = "您确定要放弃所有已输入的内容吗?"; +"PassportDocument" = "证件信息"; +"PassportDocumentNumber" = "证件编号"; +"PassportDocuments" = "扫描件"; +"PassportEmail" = "电子邮箱"; +"PassportEmailCode" = "验证码"; +"PassportEmailInfo" = "输入您的电子邮箱地址"; +"PassportEmailUploadInfo" = "注意:您所提供的电子邮箱将会收到一份验证码。"; +"PassportEmailVerifyInfo" = "请输入我们刚刚向 %1$s 发送的验证码。"; +"PassportExpired" = "失效日期"; +"PassportFemale" = "女"; +"PassportFrontSide" = "正面"; +"PassportFrontSideInfo" = "上传证件正面照片"; +"PassportGender" = "性别"; +"PassportIdentityDocument" = "身份证件"; +"PassportIdentityDocumentInfo" = "上传一份您的护照或其它身份证明的扫描件"; +"PassportIdentityDriverLicence" = "上传一份驾驶证的扫描件"; +"PassportIdentityID" = "上传一份身份证的扫描件"; +"PassportIdentityInternalPassport" = "上传一份户口本(Internal Passport)的扫描件"; +"PassportIdentityPassport" = "上传一份护照的扫描件"; +"PassportInfo" = "有了 Telegram 通行证,您可以轻易地注册需要身份验证的网站和服务。\n\n您的信息、个人数据和证件都将受到端到端加密保护。未经您的允许,任何人(包括 Telegram)都无法读取它们。"; +"PassportInfo2" = "有了 Telegram 通行证,您可以轻易地注册需要身份验证的网站和服务。\n\n您的信息、个人数据和证件都将受到端到端加密保护。未经您的允许,任何人(包括 Telegram)都无法读取它们。\n\n您可以访问我们的 *常见问题* 来了解更多信息。"; +"PassportInfoTitle" = "Telegram 通行证是什么?"; +"PassportInfoUrl" = "https://telegram.org/faq#passport"; +"PassportLanguage_AR" = "阿拉伯语"; +"PassportLanguage_AZ" = "阿塞拜疆语"; +"PassportLanguage_BG" = "保加利亚语"; +"PassportLanguage_BN" = "孟加拉语"; +"PassportLanguage_CS" = "捷克语"; +"PassportLanguage_DA" = "丹麦语"; +"PassportLanguage_DE" = "德语"; +"PassportLanguage_DV" = "迪维希语"; +"PassportLanguage_DZ" = "不丹语"; +"PassportLanguage_EL" = "希腊语"; +"PassportLanguage_ES" = "西班牙语"; +"PassportLanguage_ET" = "爱沙尼亚语"; +"PassportLanguage_FA" = "波斯语"; +"PassportLanguage_FR" = "法语"; +"PassportLanguage_HE" = "希伯来语"; +"PassportLanguage_HR" = "克罗地亚语"; +"PassportLanguage_HU" = "匈牙利语"; +"PassportLanguage_HY" = "亚美尼亚语"; +"PassportLanguage_ID" = "印度尼西亚语"; +"PassportLanguage_IS" = "冰岛语"; +"PassportLanguage_IT" = "意大利语"; +"PassportLanguage_JA" = "日语"; +"PassportLanguage_KA" = "格鲁吉亚语"; +"PassportLanguage_KM" = "高棉语"; +"PassportLanguage_KO" = "韩语"; +"PassportLanguage_LO" = "老挝语"; +"PassportLanguage_LT" = "立陶宛语"; +"PassportLanguage_LV" = "拉脱维亚语"; +"PassportLanguage_MK" = "马其顿语"; +"PassportLanguage_MN" = "蒙古语"; +"PassportLanguage_MS" = "马来西亚语"; +"PassportLanguage_MY" = "缅甸语"; +"PassportLanguage_NE" = "尼泊尔语"; +"PassportLanguage_NL" = "荷兰语"; +"PassportLanguage_PL" = "波兰语"; +"PassportLanguage_PT" = "葡萄牙语"; +"PassportLanguage_RO" = "罗马尼亚语"; +"PassportLanguage_RU" = "俄语"; +"PassportLanguage_SK" = "斯洛伐克语"; +"PassportLanguage_SL" = "斯洛文尼亚语"; +"PassportLanguage_TH" = "泰语"; +"PassportLanguage_TK" = "土库曼语"; +"PassportLanguage_TR" = "土耳其语"; +"PassportLanguage_UK" = "乌克兰语"; +"PassportLanguage_UZ" = "乌兹别克斯坦语"; +"PassportLanguage_VI" = "越南语"; +"PassportMainPage" = "文件主页"; +"PassportMainPageInfo" = "上传证件第一页的照片"; +"PassportMale" = "男"; +"PassportMidname" = "中间名"; +"PassportMidnameCountry" = "中间名(%1$s)"; +"PassportMidnameLatin" = "中间名(拉丁字母)"; +"PassportName" = "名字"; +"PassportNameCheckAlert" = "请检查该姓名是否正确:\n\n%1$s %2$s %3$s"; +"PassportNameCountry" = "名字(%1$s)"; +"PassportNameLatin" = "名字(拉丁字母)"; +"PassportNativeHeader" = "您的姓名"; +"PassportNativeHeaderLang" = "您的%1$s名"; +"PassportNativeInfo" = "以您所居住的国家(%1$s)语言填写您的名字。"; +"PassportNoDocuments" = "您还没有任何证件"; +"PassportNoDocumentsAdd" = "添加证件"; +"PassportNoDocumentsInfo" = "您可以添加自己的手机号码、电子邮箱、身份证件或住址。"; +"PassportNoExpireDate" = "无"; +"PassportNoPolicy" = "您的文件将被直接发送给 %1$s。同时,也将允许 @%2$s 给您发送消息。"; +"PassportPersonal" = "个人信息"; +"PassportPersonalDetails" = "个人信息"; +"PassportPersonalDetailsInfo" = "填写个人信息"; +"PassportPersonalUploadInfo" = "这份证件必须包含您的照片、姓名、生日、证件号、签发国家、以及失效日期。"; +"PassportPhone" = "手机号码"; +"PassportPhoneInfo" = "请输入手机号码"; +"PassportPhoneUploadInfo" = "注意:您将在您提供的手机号码上收到一份验证码。"; +"PassportPhoneUseOther" = "或输入新的手机号码"; +"PassportPhoneUseSame" = "使用 %1$s"; +"PassportPhoneUseSameEmailInfo" = "使用当前 Telegram 帐号中设定的电子邮箱。"; +"PassportPhoneUseSameInfo" = "使用当前 Telegram 帐号绑定的手机号码。"; +"PassportPolicy" = "您同意 *%1$s 隐私政策*并允许 @%2$s 给您发送消息。"; +"PassportPostcode" = "邮政编码"; +"PassportProvidedInformation" = "已提供信息"; +"PassportRequest" = "**%1$s** 请求访问您的个人数据以便为您提供服务。"; +"PassportRequestPasswordInfo" = "请输入 Telegram 密码以解密您的数据。"; +"PassportRequestedInformation" = "所需信息"; +"PassportRequiredDocuments" = "所需证件"; +"PassportResidence" = "居住地"; +"PassportResidentialAddress" = "住址"; +"PassportReverseSide" = "反面"; +"PassportReverseSideInfo" = "上传证件反面照片"; +"PassportSDK_Cancel" = "取消"; +"PassportSDK_DownloadTelegram" = "您需要 Telegram 才能登录到 **%s**。要现在安装吗?"; +"PassportSDK_LogInWithTelegram" = "通过 Telegram 登录"; +"PassportSDK_OpenGooglePlay" = "打开 Google Play"; +"PassportSDK_TelegramPassport" = "Telegram 通行证"; +"PassportScanPassport" = "扫描您的护照"; +"PassportScanPassportInfo" = "扫描您护照或身份证的机读区以自动填写个人信息。"; +"PassportSelectBithdayDate" = "选择出生日期"; +"PassportSelectExpiredDate" = "选择失效日期"; +"PassportSelectGender" = "选择性别"; +"PassportSelectNotExpire" = "永不失效"; +"PassportSelfRequest" = "请输入密码来访问个人数据"; +"PassportSelfie" = "自拍照"; +"PassportSelfieInfo" = "上传您拿着证件的自拍照"; +"PassportState" = "州/地区"; +"PassportStreet1" = "街道"; +"PassportStreet2" = "街道"; +"PassportSurname" = "姓氏"; +"PassportSurnameCountry" = "姓氏(%1$s)"; +"PassportSurnameLatin" = "姓氏(拉丁字母)"; +"PassportTranslation" = "翻译"; +"PassportTwoDocuments" = "%1$s或%2$s"; +"PassportUploadAdditinalDocument" = "上传更多的扫描件"; +"PassportUploadDocument" = "上传扫描件"; +"PassportUploadMaxReached" = "您同时最多只能上传 %1$s。"; +"PassportUploadNotImage" = "您只能上传图片文件。"; +"PassportUseLatinOnly" = "只能使用拉丁字母。"; +"PasswordAsHintError" = "密码提示必须与密码本身不同"; +"PasswordCode" = "验证码"; +"PasswordDoNotMatch" = "前后两次输入的密码不一致"; +"PasswordEmailInvalid" = "邮箱地址无效,请重试。"; +"PasswordHint" = "密码提示"; +"PasswordHintDescription" = "您可以为您的密码创建可选提示。"; +"PasswordHintPlaceholder" = "提示"; +"PasswordHintText" = "为您的密码建立提示"; +"PasswordHintTextLogin" = "为您的密码创建提示。"; +"PasswordOff" = "关闭"; +"PasswordOn" = "打开"; +"PasswordRecovery" = "找回密码"; +"PasswordReset" = "密码已失效"; +"Paste" = "粘贴"; +"PasteFromClipboard" = "粘贴自剪贴板"; +"PauseAll" = "暂停所有"; +"PauseMusicOnMedia" = "当播放媒体时暂停音乐"; +"PauseMusicOnRecord" = "录音时暂停音乐"; +"PauseMusicOnRecordInfo" = "开始录制视频消息时暂停音乐"; +"PaymentAppNotFoundForDeeplink" = "没有找到处理该深度链接的应用。"; +"PaymentBillingAddress" = "账单地址"; +"PaymentCardCvv" = "安全码(CVV)"; +"PaymentCardExpireDate" = "月/年"; +"PaymentCardInfo" = "付款信息"; +"PaymentCardName" = "姓名"; +"PaymentCardNumber" = "卡号"; +"PaymentCardSavePaymentInformation" = "保存付款信息"; +"PaymentCardSavePaymentInformationInfoLine1" = "您可以保存付款信息以便在未来使用。将会直接由支付服务提供商保存。Telegram 无法获取您的信用卡数据。"; +"PaymentCardSavePaymentInformationInfoLine2" = "请先*开启两步验证*后再启用此功能。"; +"PaymentCardTitle" = "付款卡"; +"PaymentCheckout" = "结算"; +"PaymentCheckoutAcceptRecurrent" = "我接受 %1$s 的 *服务条款*。"; +"PaymentCheckoutAcceptRecurrentFee" = "我接受 *服务条款*。"; +"PaymentCheckoutEmail" = "联系地址"; +"PaymentCheckoutMethod" = "支付方式"; +"PaymentCheckoutMethodNewCard" = "添加银行卡…"; +"PaymentCheckoutName" = "姓名"; +"PaymentCheckoutPay" = "支付 %1$s"; +"PaymentCheckoutPhoneNumber" = "电话号码"; +"PaymentCheckoutProvider" = "支付服务提供商"; +"PaymentCheckoutShippingMethod" = "配送方式"; +"PaymentConfirmationError" = "确认付款时发生错误。"; +"PaymentConfirmationMessage" = "您曾保存过您的银行卡(%1$s)的信息。要使用此卡支付,请输入您的两步验证密码。"; +"PaymentConfirmationNewCard" = "选择另一张卡"; +"PaymentConnectionFailed" = "无法连接到支付服务器。请检查您的互联网连接并重试。"; +"PaymentEmailToProvider" = "电子邮箱地址将作为付款信息发送给 %1$s。"; +"PaymentFailed" = "抱歉,付款被拒绝。"; +"PaymentInfoHint" = "您花费 **%1$s** 购买 **%2$s** 。"; +"PaymentInfoHintStars#other" = "您已支付 **%2$s** 购买了 **%1$d 颗星星**。"; +"PaymentInvoice" = "账单"; +"PaymentInvoiceLinkInvalid" = "无效账单"; +"PaymentInvoiceSubscriptionLinkAlreadyPaid" = "您已经订阅!"; +"PaymentNoShippingMethod" = "抱歉,无法配送到您的地址。"; +"PaymentPassword" = "密码和电子邮箱"; +"PaymentPasswordEmail" = "您的电子邮箱地址"; +"PaymentPasswordEmailInfo" = "请输入有效的电子邮箱地址。这是忘记密码时唯一的恢复方法。"; +"PaymentPasswordEmailTitle" = "密码找回电子邮箱"; +"PaymentPasswordEnter" = "请输入密码"; +"PaymentPasswordInfo" = "请创建一个用于保护您的付款信息的密码。您需要在登录时输入此密码。"; +"PaymentPasswordReEnter" = "请再次输入密码"; +"PaymentPasswordTitle" = "密码"; +"PaymentPhoneEmailToProvider" = "电话号码和电子邮箱地址将作为付款信息发送给 %1$s。"; +"PaymentPhoneToProvider" = "电话号码将作为付款信息发送给 %1$s。"; +"PaymentPrecheckoutFailed" = "抱歉,付款已被机器人取消。"; +"PaymentReceipt" = "收据"; +"PaymentShippingAddress" = "配送地址"; +"PaymentShippingAddress1Placeholder" = "地址行 1(街道)"; +"PaymentShippingAddress2Placeholder" = "地址行 2(街道)"; +"PaymentShippingCityPlaceholder" = "城市"; +"PaymentShippingCountry" = "国家"; +"PaymentShippingEmailPlaceholder" = "电子邮箱"; +"PaymentShippingInfo" = "配送信息"; +"PaymentShippingMethod" = "配送方式"; +"PaymentShippingName" = "姓名"; +"PaymentShippingPhoneNumber" = "手机号码"; +"PaymentShippingReceiver" = "收件人"; +"PaymentShippingSave" = "保存配送信息"; +"PaymentShippingSaveInfo" = "您可以保存您的配送信息以便在未来使用。"; +"PaymentShippingStatePlaceholder" = "省份/州"; +"PaymentShippingZipPlaceholder" = "邮政编码"; +"PaymentSuccessfullyPaid" = "您已成功为 %3$s 向 %2$s 转账 %1$s"; +"PaymentSuccessfullyPaidMeNoItemSubscription" = "%1$s 已转让 %2$s 用于订阅,有效期至 %3$s"; +"PaymentSuccessfullyPaidMeSubscription" = "%1$s 已转让 %2$s 用于订阅 %3$s,有效期至 %4$s"; +"PaymentSuccessfullyPaidNoItem" = "您已成功向 %2$s 转账 %1$s"; +"PaymentSuccessfullyPaidNoItemRecurrent" = "您已成功将 %1$s 转账给 %2$s,并已允许后续的定期付款"; +"PaymentSuccessfullyPaidRecurrent" = "您已成功为 %3$s 向 %2$s 转账 %1$s,并已允许后续的定期付款"; +"PaymentSuccessfullyPaidSubscription" = "您已成功将 %1$s 转至 %2$s,用于 %3$s,有效期至 %4$s"; +"PaymentSuccessfullyPaidSubscriptionNoItem" = "您已成功将 %1$s 转至 %2$s,有效期至 %3$s"; +"PaymentTestInvoice" = "测试账单"; +"PaymentTip" = "小费"; +"PaymentTipOptional" = "小费(可选)"; +"PaymentTransactionMessage" = "您确定要为 %3$s 向机器人 %2$s 转账 %1$s 吗?"; +"PaymentTransactionMessage2" = "为 **%3$s** 将 **%1$s** 转移到 %2$s 机器人?"; +"PaymentTransactionReview" = "交易检查"; +"PaymentTransactionTotal" = "总计"; +"PaymentWarning" = "警告"; +"PaymentWarningText" = "无论是 Telegram 还是 %1$s 都无法获取您的信用卡信息。信用卡信息将仅由支付平台 %2$s 处理。\n\n款项将会直接发送给 %1$s 的开发者。Telegram 无法提供任何形式的担保,继续付款所带来的风险将由您自己承担。如果遇到问题,请联系 %1$s 的开发者或者您的银行。"; +"PeerRequirementChannelBotParticipant" = "频道必须有一个机器人。"; +"PeerRequirementChannelCreatorFalse" = "您不能是频道的所有者。"; +"PeerRequirementChannelCreatorTrue" = "你必须是频道的所有者。"; +"PeerRequirementChannelPublicFalse" = "频道必须为私密状态。"; +"PeerRequirementChannelPublicTrue" = "频道必须为公开状态。"; +"PeerRequirementForumFalse" = "群组必须关闭话题功能。"; +"PeerRequirementForumTrue" = "群组必须开启话题功能。"; +"PeerRequirementGroupBotParticipant" = "群组必须有一个机器人。"; +"PeerRequirementGroupCreatorFalse" = "你不可以是群主。"; +"PeerRequirementGroupCreatorTrue" = "您必须是群组的所有者。"; +"PeerRequirementGroupPublicFalse" = "必须是私密群组。"; +"PeerRequirementGroupPublicTrue" = "该群组必须是公开的。"; +"PeerRequirementPremiumFalse" = "用户不能拥有 Telegram 高级版订阅。"; +"PeerRequirementPremiumTrue" = "用户必须拥有 Telegram 高级版订阅。"; +"PeerRequirementUserRight" = "您必须拥有管理员权限才能"; +"PeerRequirementUserRights" = "您必须拥有以下管理员权限:"; +"PeerRequirements" = "要求"; +"PendingEmailText" = "您的密码恢复邮箱(%1$s)正等待验证。"; +"PeopleCanJoinViaLink" = "%s 人可通过此链接加入"; +"PeopleCanJoinViaLinkCount#other" = "该链接还能邀请 %s 人加入。"; +"PeopleJoined#other" = "已加入 %1$d 人"; +"PeopleJoinedRemaining#other" = "剩余 %1$d 次"; +"PeopleNearby" = "附近的人"; +"PeopleNearbyAccessInfo" = "快速添加附近正在查看此页面的人并发现附近的群组。\n\n请开启位置访问权限以启用此功能。"; +"PeopleNearbyAllowAccess" = "允许访问"; +"PeopleNearbyEmpty" = "正在为您搜索附近的人。。。"; +"PeopleNearbyGps" = "开启"; +"PeopleNearbyGpsInfo" = "快速添加附近正在查看此页面的人并发现附近的群组。\n\n请开启位置访问权限以启用此功能。"; +"PeopleNearbyHeader" = "附近的人"; +"PeopleNearbyInfo" = "邀请附近的朋友打开此页面来交换手机号码。"; +"PeopleNearbyInfo2" = "与附近的人交换联系方式并查找新伙伴"; +"PeopleSubscriptionExpired#other" = "%d 已取消订阅"; +"Percent" = "%d%"; +"PeriodDHM" = "%1$s 天:%2$s 小时:%3$s 分钟"; +"Permanent" = "主要链接"; +"PermanentLinkForThisAdmin" = "此管理员的专属邀请链接"; +"PermissionBackgroundLocation" = "为了能够在此聊天共享您的实时位置,Telegram 需要随时访问您的位置权限,包括应用在后台运行时。\n\n我们将会在您设定时间范围内访问您的位置权限,您随时可以终止位置共享。除了位置分享之外,我们不会把您的位置权限用于其它目的。"; +"PermissionContacts" = "为了能让您跨设备联系好友,您的联系人信息将会被即时同步到高度加密的 Telegram 云服务器上。"; +"PermissionDrawAboveOtherApps" = "Telegram 需要使用出现在其它应用上的权限以在画中画模式中播放视频。"; +"PermissionDrawAboveOtherAppsGroupCall" = "悬浮窗模式允许您使用按键通话,即使在Telegram之外,也可以看到谁在通话。"; +"PermissionDrawAboveOtherAppsGroupCallTitle" = "视频聊天悬浮窗"; +"PermissionDrawAboveOtherAppsTitle" = "画中画"; +"PermissionFSILockscreen" = "请授予 Telegram 在锁屏界面显示的权限以确保通话正常工作。"; +"PermissionNoAudio" = "Telegram 需要访问您的麦克风来录制语音消息。"; +"PermissionNoAudioStorageStory" = "**Telegram** 需要访问音乐文件才能将其添加到您的动态中。 点击“设置” > “权限”,然后开启 “**音乐和音频**”。"; +"PermissionNoAudioVideo" = "Telegram 需要访问您的麦克风来录制视频。"; +"PermissionNoAudioVideoWithHint" = "**Telegram** 需要麦克风权限,以便您可以发送语音消息。点击选择 “设置”>“权限”,并将 **麦克风** 开启。"; +"PermissionNoAudioWithHint" = "**Telegram** 需要麦克风权限,以便您可以发送语音消息。点击选择“设置”>“权限”,并将**麦克风**开启。"; +"PermissionNoBluetoothWithHint" = "**Telegram** 需要蓝牙权限以从您的耳机麦克风录制音频。请点击 设置 > 权限,然后开启 **蓝牙**。"; +"PermissionNoCamera" = "Telegram 需要访问您的相机,以便您拍照或录像。请在设置中开启。"; +"PermissionNoCameraMicVideo" = "**Telegram** 需要相机和麦克风的权限,以便你可以发送视频消息。点击选择“设置”>“权限”,并将**麦克风**和**相机**开启。"; +"PermissionNoCameraWithHint" = "**Telegram** 需要相机权限,以便拍摄照片和视频。点击进入“设置”>“权限”,并将**相机**开启。"; +"PermissionNoContactsSaving" = "**Telegram** 需要存取您的联系人以将它们保存到您的手机上。请点击「设置」>「权限」,然后开启「联系人」权限。"; +"PermissionNoContactsSharing" = "**Telegram** 需要访问联系人,以便与朋友分享。点击进入“设置”>“权限”,并将**通讯录访问**开启。"; +"PermissionNoLocation" = "Telegram 需要使用定位服务才能获取您的位置信息并分享给您的朋友。"; +"PermissionNoLocationFriends" = "**Telegram** 需要访问位置,以便你可以与朋友分享。点击选择“设置”>“权限”,并将**位置信息**开启。"; +"PermissionNoLocationNavigation" = "**Telegram** 需要访问位置数据,以便获取路线。点击“设置” > “权限”,然后开启**位置**访问权限。"; +"PermissionNoLocationPeopleNearby" = "**Telegram** 需要访问位置,以便你可以找到附近的人。点击选择“设置”>“权限”,并将**位置信息**开启。"; +"PermissionNoLocationPosition" = "Telegram 需要获取您的定位服务权限。"; +"PermissionNoLocationStory" = "Telegram 需要访问您的位置,以便您可以将天气微件添加到动态中。"; +"PermissionNoStorageAvatar" = "**Telegram** 需要存储访问权限,以便您可以上传头像图片。点击“设置” > “权限”并开启“**文件和媒体**”选项。"; +"PermissionOpenSettings" = "设置"; +"PermissionStorage" = "Telegram 需要访问您的本地存储以便您发送和保存图片,视频,音乐和其他文档。"; +"PermissionStorageWithHint" = "**Telegram** 需要访问本地存储,以便您可以发送和保存图片、视频、音乐和其他文档。点击选择“设置 > 权限”,并将**文件和媒体**开启。"; +"PermissionXiaomiLockscreen" = "请授予 Telegram 在锁屏界面显示的权限,这样您在设备锁定时仍能收到通话。"; +"PersonalChannel" = "个人频道"; +"Phone" = "手机号码"; +"PhoneCopied" = "已复制手机号码到剪贴板"; +"PhoneHidden" = "未知"; +"PhoneHome" = "家庭"; +"PhoneMain" = "主要"; +"PhoneMobile" = "手机"; +"PhoneNumber" = "手机号码"; +"PhoneNumberAlert" = "如果其他用户在其通讯录中存有您的新号码,或者您的隐私设置允许其查看您的号码,该用户将会看到您的新号码。您可以在“设置” > “隐私与安全” > “手机号码”中更改此设置。"; +"PhoneNumberChange" = "更改手机号码"; +"PhoneNumberChange2" = "修改手机号码"; +"PhoneNumberChangeTitle" = "修改手机号码"; +"PhoneNumberFlood" = "抱歉,您最近删除并重建您的帐号次数过多。请稍等几天再来重新注册。"; +"PhoneNumberHelp" = "您可以在此更换当前绑定到 Telegram 的手机号码。您的帐号和所有云端数据(消息、媒体文件、联系人等)都会迁移到新号码下。"; +"PhoneNumberKeepButton" = "保持 %1$s"; +"PhoneNumberSearch" = "手机号码"; +"PhoneOther" = "其它"; +"PhoneWork" = "工作"; +"PhotoCaption" = "图片说明"; +"PhotoEditor" = "照片编辑器"; +"PhotoEditorClearAll" = "清除所有"; +"PhotoEditorDiscardAlert" = "您确定要放弃所有变更吗?"; +"PhotoEditorDraw" = "涂鸦"; +"PhotoEditorSticker" = "贴纸"; +"PhotoEditorText" = "文字"; +"PhotoEditorTypefaceCondensed" = "窄体"; +"PhotoEditorTypefaceCourierNew" = "Courier New"; +"PhotoEditorTypefaceItalic" = "斜体"; +"PhotoEditorTypefaceMerriweather" = "Merriweather"; +"PhotoEditorTypefaceMono" = "等宽衬线体"; +"PhotoEditorTypefaceRoboto" = "Roboto"; +"PhotoEditorTypefaceSerif" = "衬线体"; +"PhotoEditorZoomOut" = "缩小"; +"PhotoForRestDescription" = "您可以为被限制查看真实个人资料照片的用户上传公开照片。"; +"PhotoForRestTooltip" = "这张照片将显示给被限制查看您真实照片的用户。"; +"PhotoSavedHint" = "图片已保存到相册。"; +"PhotoSavedToDownloadsHint" = "图片已保存到下载文件夹。"; +"PhotoSavedToDownloadsHintLinked" = "已将图片保存至 **下载文件夹**。"; +"PhotoShownOnce" = "该图片只能查看一次。"; +"PhotoWillBeSentInHD" = "该照片会以高质量发送。"; +"PhotoWillBeSentInSD" = "该照片会以标准质量发送。"; +"Photos#other" = "%1$d 张图片"; +"PhotosSavedHint#other" = "%1$d 张图片已保存到相册。"; +"PhotosSelected#other" = "已选中 %1$d 张图片"; +"PinAlsoFor" = "同时为 %1$s 置顶"; +"PinChatsLimitSubtitle" = "在主聊天列表中置顶高达 %d 个聊天"; +"PinChatsLimitTitle" = "已置顶聊天"; +"PinFolderLimitReached" = "抱歉,您无法再置顶更多的聊天。"; +"PinInTheList" = "在聊天列表中置顶"; +"PinMessage" = "置顶"; +"PinMessageAlert" = "是否在群组中置顶此消息?"; +"PinMessageAlertChannel" = "您确定要在频道中置顶此消息吗?"; +"PinMessageAlertChat" = "您确定要在聊天中置顶此消息吗?"; +"PinMessageAlertTitle" = "置顶消息"; +"PinMessageInTopicAlert" = "您确定要在主题中置顶此消息吗?"; +"PinNotify" = "通知所有成员"; +"PinOldMessageAlert" = "您想置顶一条较旧的消息,并同时保留一则最新的消息吗?"; +"PinToTop" = "置顶"; +"PinToTopLimitReached" = "抱歉,您只能将 %1$s 个聊天置顶在主列表的顶部。 您可以在聊天文件夹和归档中置顶任意数量的聊天。"; +"PinToTopLimitReached2" = "抱歉,您只能将 %1$s 个聊天置顶在主列表的顶部。 您可以在聊天文件夹和归档中置顶更多聊天。"; +"Ping" = "Ping: %1$d 毫秒"; +"Ping2" = "Ping: %1$d 毫秒"; +"Pink" = "粉色"; +"PinnedCall" = "置顶的通话"; +"PinnedDialogsCount#other" = "%1$d 个聊天已置顶"; +"PinnedMessage" = "置顶消息"; +"PinnedMessages" = "置顶消息"; +"PinnedMessagesCount#other" = "%1$d 条置顶消息"; +"PinnedMessagesHidden" = "置顶消息已隐藏"; +"PinnedMessagesHiddenInfo" = "如有新消息被置顶,则置顶消息会再次显示。"; +"PinnedPoll" = "置顶投票"; +"PipMinimize" = "最小化"; +"PlacesInThisArea" = "此区域内的地点"; +"PlainTextRestrictedHint" = "不允许发送文本"; +"PlayEmojiInKeyboard" = "表情面板动画"; +"PlayFile" = "播放文件"; +"PlayMarket" = "Google Play"; +"PleaseDownload" = "请先下载此媒体文件"; +"PleaseEnterCurrentPassword" = "请输入密码"; +"PleaseEnterCurrentPasswordGiftTransfer" = "请输入密码以将 **%s** 发送至 Fragment。"; +"PleaseEnterCurrentPasswordTransfer" = "请输入密码完成转让。"; +"PleaseEnterCurrentPasswordWithdraw" = "请输入密码以领取。"; +"PleaseEnterFirstPassword" = "请输入密码"; +"PleaseEnterNewFirstPassword" = "请输入新密码"; +"PleaseEnterNewFirstPasswordHint" = "输入新密码"; +"PleaseEnterNewFirstPasswordLogin" = "你现在可以设置一个新密码,用于登录你的帐户。"; +"PleaseEnterNewSecondPasswordHint" = "再次输入新密码"; +"PleaseEnterPassword" = "请输入您的新密码"; +"PleaseLoginPassport" = "请登录到您的 Telegram 帐号以使用 Telegram 通行证。"; +"PleaseReEnterPassword" = "请再次输入您的密码"; +"PleaseStreamDownload" = "请等待视频下载完毕。"; +"PmEditedDateTimeAt" = "已编辑于 %1$s %2$s"; +"PmEditedTodayAt" = "已编辑于 %s"; +"PmEditedYesterdayAt" = "已编辑于昨天 %s"; +"PmFwdOriginalDateTimeAt" = "原始发布于 %1$s %2$s"; +"PmFwdOriginalTodayAt" = "原始发布于 %s"; +"PmFwdOriginalYesterdayAt" = "原始发布于昨天 %s"; +"PmRead" = "已读"; +"PmReadAt" = "%s 已读"; +"PmReadDateTimeAt" = "%1$s %2$s 已读"; +"PmReadShowWhen" = "显示时间"; +"PmReadTodayAt" = "%s 已读"; +"PmReadUnknown" = "未知已读时间"; +"PmReadYesterdayAt" = "昨天 %s 已读"; +"Points#other" = "%1$d"; +"Poll" = "投票"; +"PollAddAnOption" = "添加选项"; +"PollAddAnOptionHint" = "选项..."; +"PollAddTextOrRemoveMedia" = "请添加入文字或移除媒体"; +"PollAddedByAtTime" = "由 **%1$s** 添加 %2$s"; +"PollAddingActionOther" = "%1$s 将 “%2$s” 添加到投票中"; +"PollAddingActionYou" = "您已将 “%s” 添加到投票中"; +"PollAnonymous" = "匿名投票"; +"PollButtonSave" = "保存"; +"PollCantForwardSecretChat" = "抱歉,投票无法转发至私密聊天。"; +"PollCollapse" = "折叠"; +"PollCustomDeadlineClosesIn" = "将在 %s 后结束"; +"PollCustomDeadlineClosesInFmt" = "%1$s %2$s %3$s"; +"PollEndsIn" = "将于 %s 结束"; +"PollExpand" = "展开"; +"PollItemQuote" = "回复选项"; +"PollMenuHint" = "您正在查看某个选项的操作。\n您可以切换至清单的相关操作。"; +"PollMenuTabOption" = "选项"; +"PollMenuTabPoll" = "投票"; +"PollMultiple" = "多项选择"; +"PollOptions" = "投票选项"; +"PollQuestion" = "投票问题"; +"PollQuestion2" = "问题"; +"PollQuiz" = "问答模式"; +"PollRemovedActionOther" = "%1$s 将“%2$s”从投票中移除"; +"PollRemovedActionYou" = "您已将 “%s“ 从投票中移除"; +"PollResults" = "投票结果"; +"PollResultsIn" = "结果将 %s 后发布"; +"PollResultsWillLater" = "投票结束后结果將公布"; +"PollSelectOption" = "请先选一个选项"; +"PollStatsWillLater" = "目前暂无投票统计资料"; +"PollSubmitVotes" = "提交投票"; +"PollSubmitVotesNoCaps" = "投票"; +"PollTapToSelect" = "点击选择正确的答案"; +"PollV2AddLinkMessage" = "为此选项添加链接。"; +"PollV2AddLinkTitle" = "添加链接"; +"PollV2AddLinkUrlHint" = "URL"; +"PollV2AllowAddingOptions" = "允许添加选项"; +"PollV2AllowAddingOptionsInfo" = "参与者可以提出新的选项"; +"PollV2AllowMultipleAnswers" = "允许多选"; +"PollV2AllowMultipleAnswersInfo" = "投票者可以选择多个选项"; +"PollV2AllowRevoting" = "允许重新投票"; +"PollV2AllowRevotingInfo" = "投票者可以更改投票选择"; +"PollV2AllowedCountries" = "允许的国家"; +"PollV2AllowedCountriesListMany" = "%d 个国家"; +"PollV2AllowedCountriesListManyP#other" = "%1$d 个国家"; +"PollV2AllowedCountriesListOther" = "%1$s 和 %2$s"; +"PollV2HideResults" = "隐藏结果"; +"PollV2HideResultsInfo" = "启用后,投票结束后才会显示结果。"; +"PollV2LimitByCountry" = "按国家限制"; +"PollV2LimitByCountryInfo" = "只有所选国家的用户\n才能投票"; +"PollV2LimitDuration" = "限制时长"; +"PollV2LimitDurationInfo" = "在指定时间自动关闭投票"; +"PollV2MenuViewStats" = "查看统计数据"; +"PollV2PollDuration" = "投票时长"; +"PollV2PollDurationOptionCustom" = "自定义"; +"PollV2PollEnds" = "投票结束于"; +"PollV2RestrictToSubscribers" = "仅限订阅者"; +"PollV2RestrictToSubscribersInfo" = "只有在 24 小时前加入的\n订阅者才能投票"; +"PollV2SearchHint" = "搜索国家"; +"PollV2SetCorrectAnswer" = "设置正确答案"; +"PollV2SetCorrectAnswerInfo" = "选择一个选项作为正确答案"; +"PollV2ShowWhoVoted" = "显示投票者名单"; +"PollV2ShowWhoVotedInfo" = "在每个选项上显示投票者名称"; +"PollV2ShuffleOptions" = "随机选项"; +"PollV2ShuffleOptionsInfo" = "每位投票者的答案以随机顺序显示"; +"PollV2StatsPollStats" = "投票统计资料"; +"PollV2StatsVoteTimeline" = "投票时间表"; +"PollV2ToastOnlySubscribersCanVote" = "只有 **%1$s** 的订阅者可以投票。"; +"PollV2ToastOnlySubscribersFromCountriesCanVoteOne" = "仅来自 **%1$s** 的订阅者可投票。"; +"PollV2ToastOnlySubscribersFromCountriesCanVoteOther" = "仅来自 **%1$s** 和 **%2$s** 的订阅者可投票。"; +"PollV2ToastOnlySubscribersJoined24hCanVote" = "仅加入 **24 小时** 以上的订阅用户可投票。"; +"PollV2ToastOnlyUsersFromCountriesCanVoteOne" = "只有来自 **%1$s** 的用户才能投票。"; +"PollV2ToastOnlyUsersFromCountriesCanVoteOther" = "仅来自 **%1$s** 和 **%2$s** 的用户可投票。"; +"PollV2YouCanAddXCountriesOnly" = "您只能添加 **%d** 个国家"; +"PollViewBack" = "< 投票"; +"PollViewResults" = "查看结果"; +"PollViewResultsNoCaps" = "查看结果"; +"PollViewVotes" = "查看投票"; +"PollViewVotesAsAdmin#other" = "%d 人投票 >"; +"PollViewVotesX" = "查看投票 (%d)"; +"PollVotesCount#other" = "%d 人投票"; +"PopularReactions" = "热门"; +"PopupDisabled" = "已停用"; +"PopupEnabled" = "启用"; +"PopupNotification" = "弹出通知"; +"PostStatistics" = "贴文统计数据"; +"PostSuggestions" = "私信"; +"PostSuggestionsAddTime" = "编辑时间"; +"PostSuggestionsAddTimeHint" = "选择您想要发布贴文的日期和具体时间。"; +"PostSuggestionsAddTimeHint2" = "从此日期起,发布的贴文将保留至少 %d 小时。"; +"PostSuggestionsAnytime" = "任何时间"; +"PostSuggestionsDisabledUpdated" = "un1 不再接受私信"; +"PostSuggestionsEnabledUpdated" = "un1 现可接受私信"; +"PostSuggestionsInlineAccept" = "接受"; +"PostSuggestionsInlineDecline" = "拒绝"; +"PostSuggestionsInlineEdit" = "推荐更改"; +"PostSuggestionsOff" = "关闭"; +"PostSuggestionsOffer" = "报价"; +"PostSuggestionsOfferChangeTitle" = "推荐更改"; +"PostSuggestionsOfferChangeUpdateTerms" = "更新条款"; +"PostSuggestionsOfferForFree" = "免费提供"; +"PostSuggestionsOfferStars" = "优惠⭐️%s"; +"PostSuggestionsOfferSubtitleStars" = "选择发布此贴文需要支付多少颗星星。"; +"PostSuggestionsOfferSubtitleTON" = "选择要支付多少来发布此贴文。"; +"PostSuggestionsOfferTON" = "报价 %s Gram"; +"PostSuggestionsOfferTitle" = "推荐帖子"; +"PostSuggestionsOfferTitlePriceStars" = "输入星星价格"; +"PostSuggestionsOfferTitlePriceTON" = "输入 Gram 价格"; +"PostSuggestionsOfferTitleTime" = "发布时间"; +"PostSuggestionsPriceInfo" = "每收到一条消息,您的频道将获取设置费用的 %1$s(约 %2$s$)。"; +"PostSuggestionsPriceInfo2" = "每收到一条消息,您价将收取设置费用的 %1$s(~%2$s$)。"; +"PostSuggestionsPriceUpdated#other" = "un1 现可接收每条 %1$d 颗星星的私信"; +"PostSuggestionsSendWithOffer" = "随报价一起发送"; +"PostSuggestionsStars" = "⭐️ %d"; +"PostingMembers" = "发布消息的成员"; +"PowerUsage" = "省电"; +"PremiumAvatarToast" = "订阅 **Telegram Premium** 以设置专属的高级头像。"; +"PremiumBusinessAwayMessages" = "离开消息"; +"PremiumBusinessAwayMessagesDescription" = "设置在您不在线时自动发送的消息。"; +"PremiumBusinessChatLinks" = "聊天链接"; +"PremiumBusinessChatLinksDescription" = "创建与您开始聊天的链接,并预设第一条消息。"; +"PremiumBusinessChatbots" = "稍后推出"; +"PremiumBusinessChatbots2" = "聊天机器人"; +"PremiumBusinessChatbotsDescription" = "添加任何用于处理与客户互动的第三方聊天机器人。"; +"PremiumBusinessGreetingMessages" = "问候消息"; +"PremiumBusinessGreetingMessagesDescription" = "设置自动发送给新客户的问候信息。"; +"PremiumBusinessIntro" = "商家主页"; +"PremiumBusinessIntroDescription" = "自定义用户在与您开始聊天之前看到的消息。"; +"PremiumBusinessLocation" = "位置"; +"PremiumBusinessLocationDescription" = "在您的账户上显示您企业位置。"; +"PremiumBusinessOpeningHours" = "营业时间"; +"PremiumBusinessOpeningHoursDescription" = "向顾客展示营业时间。"; +"PremiumBusinessQuickReplies" = "快速回复"; +"PremiumBusinessQuickRepliesDescription" = "设置包含富文本和媒体的快捷方式,更快回复消息。"; +"PremiumLastSeenButton1" = "显示我的最近上线的时间"; +"PremiumLastSeenButton2" = "订阅 Telegram 高级版"; +"PremiumLastSeenHeader1" = "显示您的最后上线时间"; +"PremiumLastSeenHeader2" = "升级为高级版"; +"PremiumLastSeenSet" = "您最后上线的时间已设为可见。"; +"PremiumLastSeenText1" = "若要查看 **%s** 的最后上线时间,您需显示您自己的最后上线时间..."; +"PremiumLastSeenText1Locked" = "为查看 **%s** 最后在线时间,请先开始显示您自己的最后在线时间。"; +"PremiumLastSeenText2" = "订阅后您将可以看到 **%s** 的最后上线时间,而无需显示您的最后上线时间。"; +"PremiumMembers" = "高级版成员"; +"PremiumMessageButton" = "订阅 Telegram 高级版"; +"PremiumMessageHeader" = "解锁私信"; +"PremiumMessageHeaderLocked" = "发送消息受限"; +"PremiumMessageText" = "**%1$s** 在隐私设置中限制了谁可以给他发送消息。订阅 **Telegram 高级版** 以给 **%2$s** 发送消息。"; +"PremiumMessageTextLocked" = "**%1$s** 仅接收来自高联系人的消息。"; +"PremiumMore" = "更多"; +"PremiumNeeded" = "需要订阅 Telegram 高级版"; +"PremiumNeededForBoosting" = "只有 **Telegram 高级版** 订阅者可以助推频道。您是否要订阅 **Telegram 高级版**?"; +"PremiumNeededForBoostingGroup" = "只有 **Telegram 高级版** 订阅者可以助推群组。您想订阅 **Telegram 高级版** 吗?"; +"PremiumOr" = "或"; +"PremiumOrStarsPrice" = "或 ⭐️%s"; +"PremiumPreviewAIEditor" = "AI 工具"; +"PremiumPreviewAIEditorDescription" = "将您的消息和整个聊天记录转换为您偏好的风格和语言。"; +"PremiumPreviewAdvancedChatManagement" = "高级聊天管理"; +"PremiumPreviewAdvancedChatManagementDescription" = "可设定默认文件夹、自动归档以及隐藏来自非联系人的新聊天。"; +"PremiumPreviewAdvancedChatManagementDescription2" = "Telegram 高级版的订阅者可以设置默认进入的文件夹、自动归档以及隐藏非联系人的新聊天。"; +"PremiumPreviewAnimatedProfiles" = "动态头像"; +"PremiumPreviewAnimatedProfilesDescription" = "视频头像会在聊天列表和聊天中动起来,为您提供更多自我表达空间。"; +"PremiumPreviewAppIcon" = "高级版应用图标"; +"PremiumPreviewAppIcon2" = "额外的应用图标"; +"PremiumPreviewAppIconDescription" = "从一系列图标中选择您的 Telegram 应用程序的桌面图标。"; +"PremiumPreviewAppIconDescription2" = "通过订阅 Telegram 高级版来解锁更加广泛的应用图标选择。"; +"PremiumPreviewBusinessDescription" = "升级您的账户后可使用企业功能,如位置信息、营业时间和快速回复。"; +"PremiumPreviewBusinessEmojiStatus" = "设置表情符号状态"; +"PremiumPreviewBusinessEmojiStatusDescription" = "在您的名称旁边显示您企业的当前状态。"; +"PremiumPreviewBusinessStories" = "添加帖子"; +"PremiumPreviewBusinessStoriesDescription" = "在您的页面上发布您的商品或服务的照片和视频。"; +"PremiumPreviewDownloadSpeed" = "更快的下载速度"; +"PremiumPreviewDownloadSpeedDescription" = "下载媒体文件和文档时不再受到速度限制。"; +"PremiumPreviewDownloadSpeedDescription2" = "订阅 Telegram 高级版 以最快的速度下载媒体和文件。"; +"PremiumPreviewEffects" = "消息特效"; +"PremiumPreviewEffectsDescription" = "为私密消息添加超过 500 种动画特效。"; +"PremiumPreviewEmoji" = "动态表情"; +"PremiumPreviewEmojiDescription" = "在您发送的任何消息中包含来自不同表情包的动态表情。"; +"PremiumPreviewEmojiPack" = "该表情包仅 **Telegram 高级版** 订阅者可用"; +"PremiumPreviewEmojiStatus" = "表情符号状态"; +"PremiumPreviewEmojiStatusDescription" = "从数千个表情中选择一个展示当前的活动,显示在您的名字旁边。"; +"PremiumPreviewFolderTags" = "标记您的聊天"; +"PremiumPreviewFolderTagsDescription" = "显示聊天列表中每个聊天的文件夹名称。"; +"PremiumPreviewLastSeen" = "最后上线时间"; +"PremiumPreviewLastSeenDescription" = "即使您已将其隐藏,也可查看其它用户的最后上线及已读时间。"; +"PremiumPreviewLimits" = "双倍上限"; +"PremiumPreviewLimitsDescription" = "多达 %1$d 个频道、%2$d 个文件夹、%3$d 个置顶、%4$d 个公开链接、%5$d 个帐号等等。"; +"PremiumPreviewMessagePrivacy" = "消息隐私"; +"PremiumPreviewMessagePrivacyDescription" = "限制来自陌生人的消息或对收到的消息收费。"; +"PremiumPreviewMoreBusinessFeatures" = "更多企业功能"; +"PremiumPreviewMoreBusinessFeaturesInfo" = "稍后查看此部分以了解新的业务功能。"; +"PremiumPreviewNoAds" = "无广告"; +"PremiumPreviewNoAdsDescription" = "Telegram 公开频道中不再有不定期展示的广告。"; +"PremiumPreviewNoAdsDescription2" = "通过订阅 Telegram 高级版来移除像这样的广告。"; +"PremiumPreviewPaidMessages" = "付费消息"; +"PremiumPreviewPaidMessagesDescription" = "对非联系人或新寄信者的消息收取费用。"; +"PremiumPreviewProfileBadge" = "形象勋章"; +"PremiumPreviewProfileBadgeDescription" = "在您的名字后方显示专属徽章,以表示您已订阅 Telegram 高级版。"; +"PremiumPreviewProfileColor" = "名字和个人资料页颜色"; +"PremiumPreviewProfileColorDescription" = "为您的个人资料页以及收到回复的消息设置颜色和图标。"; +"PremiumPreviewReactions" = "专属表情回应"; +"PremiumPreviewReactions2" = "无限表情回应"; +"PremiumPreviewReactions2Description" = "用数千个表情作出回应——可使用多个表情回应单条消息。"; +"PremiumPreviewReactionsDescription" = "使用更多动画表情回应消息,仅高级版订阅者可用。"; +"PremiumPreviewSharingDisable" = "禁用分享"; +"PremiumPreviewSharingDisableDescription" = "禁用私聊中的截屏、收藏和转发消息功能。"; +"PremiumPreviewStickers" = "高级版贴纸"; +"PremiumPreviewStickersDescription" = "具有额外特效的专属放大贴纸,定期更新。"; +"PremiumPreviewStories" = "动态"; +"PremiumPreviewStoriesDescription" = "无限发布数量、优先显示、隐身模式、永久访客历史,以及更多功能。"; +"PremiumPreviewTags" = "消息标签"; +"PremiumPreviewTags2" = "我的收藏的标签"; +"PremiumPreviewTagsDescription" = "使用标签整理收藏的消息\n以便快速访问。"; +"PremiumPreviewTagsDescription2" = "使用标签整理收藏的消息,以便快速访问。"; +"PremiumPreviewTodo" = "核对清单"; +"PremiumPreviewTodoDescription" = "计划,分配和完成任务是无缝且有效率的。"; +"PremiumPreviewTranslations" = "即时翻译"; +"PremiumPreviewTranslationsDescription" = "将频道和聊天内容即时翻译成其他语言。"; +"PremiumPreviewUploads" = "无限云端存储空间"; +"PremiumPreviewUploadsDescription" = "单个文档上传限制为 4 GB ,聊天和媒体的总体存储空间无限量。"; +"PremiumPreviewVoiceToText" = "语音转文字"; +"PremiumPreviewVoiceToTextDescription" = "能够将收到的语音消息转换成文字以方便阅读。"; +"PremiumPreviewVoiceToTextDescription2" = "订阅 Telegram 高级版以将语音或视频消息转换为文字。 "; +"PremiumPreviewWallpaper" = "为双方设置壁纸"; +"PremiumPreviewWallpaperDescription" = "为您和聊天对象设置自定义壁纸。"; +"PremiumReadButton1" = "显示我的已读时间"; +"PremiumReadButton2" = "订阅 Telegram 高级版"; +"PremiumReadHeader1" = "显示您的已读时间"; +"PremiumReadHeader2" = "升级为高级版"; +"PremiumReadSet" = "您的已读时间已设为可见。"; +"PremiumReadText1" = "若要查看 **%s** 的已读时间,您需显示您自己的已读时间..."; +"PremiumReadText1Locked" = "为查看 **%s** 何时已读消息,请先开始显示您自己的已读时间。"; +"PremiumReadText2" = "订阅后您将可以看到 **%s** 的已读时间,而无需显示您的已读时间。"; +"PremiumSpeedPromo" = "增加速度"; +"PremiumStickerToolteip" = "此表情包包含像这样的高级版表情贴纸。"; +"PremiumStickerTooltip" = "该贴纸包包含高级版贴纸,譬如该贴纸。"; +"PremiumStickers" = "高级版贴纸"; +"PremiumStickersShort" = "高级版"; +"PremiumStoriesCaption" = "更长的说明文字"; +"PremiumStoriesCaptionDescription" = "为您的动态添加 10 倍长的说明文字——最多 2048 个字符。"; +"PremiumStoriesExpiration" = "过期选项"; +"PremiumStoriesExpirationDescription" = "为您的动态设置自定义过期时间,比如 6 小时或 48 小时。"; +"PremiumStoriesFormatting" = "链接和格式"; +"PremiumStoriesFormattingDescription" = "在动态说明文字中添加链接和格式。"; +"PremiumStoriesPriority" = "优先显示"; +"PremiumStoriesPriorityDescription" = "您的动态将始终优先展示,因此可以获得更多浏览量。"; +"PremiumStoriesQuality" = "更高画质"; +"PremiumStoriesQualityDescription" = "以双倍分辨率查看视频动态。"; +"PremiumStoriesSaveToGallery" = "将动态保存到相册"; +"PremiumStoriesSaveToGalleryDescription" = "保存其他用户未开启保护的动态。"; +"PremiumStoriesStealth" = "隐身模式"; +"PremiumStoriesStealthDescription" = "隐藏您曾浏览其他用户动态的痕迹。"; +"PremiumStoriesViews" = "永久访客历史"; +"PremiumStoriesViewsDescription" = "查看谁打开过您的动态——即使在动态过期后仍能查看。"; +"PremiumSubscribers" = "Telegram 高级版订阅者"; +"PremiumTierAnnual" = "按年"; +"PremiumTierAnnualYears#other" = "%d 年"; +"PremiumTierMonthly" = "按月"; +"PremiumTierSemiannual" = "半年"; +"PrepaidGiveawayMonths#other" = "%1$d个月"; +"PreparingSticker" = "正在制作贴纸…"; +"PreparingVideo" = "正在准备视频…"; +"PreviewFeedback" = "举报此预览的错误"; +"PreviewFeedback2" = "错误布局?"; +"PreviewFeedbackAuto" = "自动生成的布局"; +"PreviewForwardAudio#other" = "转发 %1$d 条语音消息"; +"PreviewForwardContact#other" = "转发 %1$d 个联系人"; +"PreviewForwardFile#other" = "转发 %1$d 个文件"; +"PreviewForwardLocation#other" = "转发 %1$d 个位置"; +"PreviewForwardMessagesCount#other" = "转发 %1$d 条消息"; +"PreviewForwardMusic#other" = "转发 %1$d 个音频文件"; +"PreviewForwardPhoto#other" = "转发 %1$d 张照片"; +"PreviewForwardPoll#other" = "转发 %1$d 个投票"; +"PreviewForwardRound#other" = "转发 %1$d 条视频消息"; +"PreviewForwardSticker#other" = "转发 %1$d 个表情"; +"PreviewForwardTodo#other" = "转发 %1$d 个核对清单"; +"PreviewForwardVideo#other" = "转发 %1$d 条视频"; +"PreviewQuoteUpdate" = "更新引文"; +"PreviewQuoteUpdateSubtitle" = "您可以编辑或去除引文"; +"PreviewWallpaperDay" = "以日间模式预览此壁纸。"; +"PreviewWallpaperNight" = "在夜间模式下预览此壁纸。"; +"PreviousPinnedMessage" = "之前置顶的消息"; +"PriceForEachSuggestion" = "每条消息的价格"; +"PricePerMonth" = "%1$s / 月"; +"PricePerMonthMe" = "%1$s/月"; +"PricePerYear" = "%1$s/年"; +"PrimaryLinkHelp" = "Telegram上的任何人可以通过此链接加入您的群组。"; +"PrimaryLinkHelpChannel" = "Telegram上的任何人可以通过此链接加入您的群组。"; +"PriorityInfo" = "高优先级通知能够在免打扰模式开启时依然正常提醒。"; +"PrivacyAddAnException" = "添加用户或群组"; +"PrivacyAdvanced" = "高级"; +"PrivacyBio" = "个人简介"; +"PrivacyBioInfo" = "您可以精确地限制谁可以看到您个人资料页上的简介。"; +"PrivacyBioInfo3" = "您可以选择哪些用户或群组不能查看您的个人简介。"; +"PrivacyBioTitle" = "谁可以看到我的个人简介?"; +"PrivacyBiometryBots" = "具有生物识别权限的机器人"; +"PrivacyBiometryBotsButton" = "具有生物识别权限的机器人"; +"PrivacyBiometryBotsInfo" = "Web 应用能够请求你的指纹识别许可。"; +"PrivacyBirthday" = "出生日期"; +"PrivacyBirthdayInfo" = "您可以精确限制谁能看到您的出生日期。"; +"PrivacyBirthdayInfo3" = "你可以将部分用户或群组设为上述设置的例外。"; +"PrivacyBirthdaySet" = "您还未添加生日。\n**添加我的生日 >**"; +"PrivacyBirthdaySetDone" = "已添加出生日期。"; +"PrivacyBirthdaySetDoneInfo" = "您可以在设置中更改您的出生日期。"; +"PrivacyBirthdayTitle" = "谁能看到我的生日?"; +"PrivacyBirthdayTooOftenMessage" = "抱歉,您不能频繁修改您的出生日期。"; +"PrivacyBirthdayTooOftenTitle" = "出错了"; +"PrivacyBotUsersAnd" = "小程序 & %s"; +"PrivacyBots" = "机器人与网站"; +"PrivacyBotsInfo" = "您使用 Telegram 登录过的网站。"; +"PrivacyCallsP2PHelp" = "停用端对端连接后,所有通话流量将使用 Telegram 服务器中转以避免泄露您的 IP 地址,但可能会降低音质及画质。"; +"PrivacyCallsP2PTitle" = "端对端连接"; +"PrivacyClearPayment" = "付款信息"; +"PrivacyClearShipping" = "配送信息"; +"PrivacyContactsAndBotUsers" = "联系人与小程序"; +"PrivacyContactsAndBotUsersMinus" = "联系人与小程序(%1$d)"; +"PrivacyContactsAndBotUsersMinusPlus" = "联系人与小程序(-%1$d,+%2$d)"; +"PrivacyContactsAndBotUsersPlus" = "联系人与小程序(%1$d)"; +"PrivacyDeleteCloudDrafts" = "删除所有的云草稿"; +"PrivacyExceptions" = "例外"; +"PrivacyExcludeBotUsers" = "小程序"; +"PrivacyFloodControlError" = "请求过于频繁。现在暂时无法修改隐私设置,请稍后再试。"; +"PrivacyForwards" = "转发的消息"; +"PrivacyForwardsContacts" = "如果以下设置已允许将会链接至您的帐号"; +"PrivacyForwardsEverybody" = "链接至您的帐号"; +"PrivacyForwardsInfo" = "当消息被转发时,你的消息无法通过链接指向你的帐号。"; +"PrivacyForwardsInfo2" = "您可以将个别用户或群组添加为例外,以覆盖上面的设定。"; +"PrivacyForwardsMessageLine" = "莱因哈特,我们需要给你找一些新歌曲🎶。"; +"PrivacyForwardsNobody" = "无法链接到你的帐号"; +"PrivacyForwardsTitle" = "转发我的消息时,谁可以链接至我的账号?"; +"PrivacyGifts" = "礼物"; +"PrivacyGiftsInfo" = "选择来自特定用户的礼物是否需要经过您的批准才能在您的个人资料页中对他人可见。"; +"PrivacyGiftsShowIcon" = "在聊天中显示礼物图标"; +"PrivacyGiftsShowIconInfo" = "在所有聊天的消息输入框中显示 %s 礼物图标,所有参与者均可见。"; +"PrivacyGiftsTitle" = "自动显示礼物的来源"; +"PrivacyGiftsTypeFromChannels" = "来自频道"; +"PrivacyGiftsTypeHeader" = "接受的礼物类型"; +"PrivacyGiftsTypeInfo" = "选择您允許他人向您赠送的礼物类型。"; +"PrivacyGiftsTypeLimited" = "限量版"; +"PrivacyGiftsTypePremium" = "高级版订阅"; +"PrivacyGiftsTypeUnique" = "独家"; +"PrivacyGiftsTypeUnlimited" = "无限量"; +"PrivacyInvites" = "邀请"; +"PrivacyInvitesInfo" = "您可以限制哪些用户能将您添加到群组和频道。"; +"PrivacyLastSeen" = "最后上线时间和在线状态"; +"PrivacyLastSeenPremium" = "订阅 Telegram 高级版"; +"PrivacyLastSeenPremiumForPremium" = "订阅 Telegram 高级版"; +"PrivacyLastSeenPremiumInfo" = "如果您订阅了 Telegram 高级版,您可以看到其他用户的最后上线时间及已读时间,即使您对其隐藏了自己的最后上线时间及已读时间(除非对方明确限制)。"; +"PrivacyLastSeenPremiumInfoForPremium" = "因为您是 Telegram 高级版订阅者,所以您将看到启用分享的所有用户的最后上线和已读时间 - 即使您已隐藏自己的。"; +"PrivacyMessages" = "消息"; +"PrivacyMessagesContactsAndPremium" = "我的联系人与高级版订阅用户"; +"PrivacyMessagesInfo" = "您可以限制非联系人且您未主动联系的用户给您发送消息。\n\n**什么是 Telegram 高级版?**"; +"PrivacyMessagesTitle" = "谁可以给我发消息?"; +"PrivacyMiniapps" = "小程序与机器人"; +"PrivacyMiniappsText" = "所有机器人"; +"PrivacyMusic" = "保存的音乐"; +"PrivacyMusicInfo" = "您可以精确地限制谁可以看到您保存的音乐。"; +"PrivacyMusicInfo3" = "您可以选择不会看到您的已保存的音乐的用户或整个群组。"; +"PrivacyMusicTitle" = "谁可以看到我个人资料页中保存的音乐?"; +"PrivacyP2P" = "端对端通话"; +"PrivacyP2P2" = "将端对端通话用于"; +"PrivacyP2PHeader" = "端对端"; +"PrivacyPayments" = "付款"; +"PrivacyPaymentsClear" = "清除付款和配送信息"; +"PrivacyPaymentsClearAlert" = "是否删除您的配送信息,并使所有的支付服务提供商清除您保存的信用卡信息?请注意 Telegram 从来不会存储您的信用卡数据。"; +"PrivacyPaymentsClearAlertText" = "您确定要清除付款和物流信息吗?"; +"PrivacyPaymentsClearAlertTitle" = "清除付款信息"; +"PrivacyPaymentsClearInfo" = "您可以删除您的配送信息并通知所有的支付服务提供商清除您已保存的信用卡信息。请注意:Telegram 从来不会存储您的信用卡数据。"; +"PrivacyPaymentsPaymentInfoCleared" = "支付信息已清除。"; +"PrivacyPaymentsPaymentShippingCleared" = "支付与配送信息已清空。"; +"PrivacyPaymentsShippingInfoCleared" = "配送信息已清空。"; +"PrivacyPhone" = "手机号码"; +"PrivacyPhoneInfo" = "已将您的手机号码保存到联系人的用户也将在 Telegram 上看到它。"; +"PrivacyPhoneInfo2" = "您可以将个别用户或群组添加为例外,以覆盖上面的设定。"; +"PrivacyPhoneInfo3" = "对于将您的号码加入联系人列表的用户,只有当您将其添加为联系人后,对方才会看到您的号码。"; +"PrivacyPhoneInfo4" = "此公开链接将打开与您的聊天"; +"PrivacyPhoneTitle" = "谁可以看见我的手机号码?"; +"PrivacyPhoneTitle2" = "谁可以通过手机号码找到我?"; +"PrivacyPolicy" = "隐私政策"; +"PrivacyPolicyAndTerms" = "隐私政策和服务条款"; +"PrivacyPolicyUrl" = "https://telegram.org/privacy"; +"PrivacyPremium" = "高级版用户"; +"PrivacyPremiumAnd" = "高级版用户和其他 %s 位用户"; +"PrivacyPremiumText" = "所有 Telegram 高级版订阅用户"; +"PrivacyProfilePhoto" = "头像"; +"PrivacyProfilePhotoInfo" = "您可以精确地限制某些用户查看您的头像。"; +"PrivacyProfilePhotoInfo2" = "您可以将用户或群组添加为列为,以覆盖上面的设定。"; +"PrivacyProfilePhotoInfo3" = "你可以添加无权查看你的个人资料图片的用户或整个群组。"; +"PrivacyProfilePhotoInfo4" = "添加仍可查看你的个人资料照片的用户或整个群组。"; +"PrivacyProfilePhotoInfo5" = "你可以将用户或整个群组添加为例外,并覆盖上述设置。"; +"PrivacyProfilePhotoTitle" = "谁能看见我的头像?"; +"PrivacySettings" = "隐私和安全"; +"PrivacySettingsChangedAlert" = "您已经修改了隐私设置。 确认修改?"; +"PrivacyTitle" = "隐私"; +"PrivacyUserTypes" = "用户类型"; +"PrivacyValueBots" = "小程序"; +"PrivacyValueBotsAnd" = "小程序与 %s"; +"PrivacyValueChannels" = "频道"; +"PrivacyValueChannelsAnd" = "频道 & %s"; +"PrivacyValueEveryoneExceptBots" = "除小程序"; +"PrivacyValueEveryoneExceptBotsMinus" = "除小程序(%1$d)"; +"PrivacyValueGiftsNone" = "无"; +"PrivacyValueGiftsOnlyPremium" = "仅高级版"; +"PrivacyValueOnlyBots" = "小程序"; +"PrivacyVoiceMessages" = "语音消息"; +"PrivacyVoiceMessagesInfo" = "您可以精确地控制谁可以向您发送语音或视频消息。"; +"PrivacyVoiceMessagesInfo2" = "您可以添加例外用户或群组以覆盖上述设置。"; +"PrivacyVoiceMessagesPremiumOnly" = "只有 *Telegram 高级版* 订阅者才可以限制接受语音消息。"; +"PrivacyVoiceMessagesTitle" = "谁可以给我发送语音或视频消息?"; +"PrivateChats" = "私聊"; +"PrivateMessagesChargePremiumLocked" = "通过 Telegram 高级版解锁"; +"PrivateMessagesChargePrice" = "付费消息"; +"PrivateMessagesChargePriceInfo" = "向非联系人或从未向你发送过消息的人收取消息费用。"; +"PrivateMessagesExceptions" = "免收费用"; +"PrivateMessagesExceptionsHeader" = "例外"; +"PrivateMessagesExceptionsInfo" = "添加无需付费即可向你发送消息的用户或整个群组。"; +"PrivateMessagesPriceHeader" = "设置您每条消息的价格"; +"PrivateMessagesPriceInfo" = "每逢收到一条消息,你将获取设置费用的 %1$s(~%2$s)。"; +"PrivateShares" = "私密分享"; +"Proceed" = "继续"; +"ProfileActionsAddStory" = "添加动态"; +"ProfileActionsCall" = "通话"; +"ProfileActionsDiscuss" = "讨论"; +"ProfileActionsEditInfo" = "编辑信息"; +"ProfileActionsEditPhoto" = "设置个人资料页"; +"ProfileActionsEditPhoto2" = "设置照片"; +"ProfileActionsEditUsername" = "用户名"; +"ProfileActionsGift" = "礼物"; +"ProfileActionsJoin" = "加入"; +"ProfileActionsLeave" = "退出"; +"ProfileActionsLiveStream" = "直播"; +"ProfileActionsMessage" = "消息"; +"ProfileActionsReport" = "举报"; +"ProfileActionsShare" = "分享"; +"ProfileActionsStop" = "停用"; +"ProfileActionsVideo" = "视频"; +"ProfileActionsVoiceChat" = "视频通话"; +"ProfileArchivedStories" = "已归档的动态"; +"ProfileBirthday" = "生日"; +"ProfileBirthdayChange" = "更改"; +"ProfileBirthdayToday" = "今天过生日"; +"ProfileBirthdayTodayValue" = "🎂 %1$s"; +"ProfileBirthdayTodayValueYear#other" = "🎂 %2$s(%1$d 岁)"; +"ProfileBirthdayValue" = "%1$s"; +"ProfileBirthdayValueYear#other" = "%2$s(%1$d 岁)"; +"ProfileBotAddPreview" = "添加预览"; +"ProfileBotAffiliateProgram" = "联盟计划"; +"ProfileBotAffiliateProgramInfo" = "将 %1$s 的链接分享给好友,即可获得其在该应用消费金额的 %2$s。"; +"ProfileBotAffiliateProgramInfoOwner" = "分享 %1$s 的链接给其他用户,并赚取他们消费的 %2$s。"; +"ProfileBotLanguageAdd" = "添加译文"; +"ProfileBotLanguageGeneral" = "一般"; +"ProfileBotManagedBy" = "由 **@%s** 创建并管理"; +"ProfileBotOpenApp" = "打开应用"; +"ProfileBotOpenAppInfo" = "启动此小程序即表示您同意 **小程序服务条款**。"; +"ProfileBotOpenAppInfoLink" = "https://telegram.org/tos/mini-apps"; +"ProfileBotOpenAppInfoOwner" = "发布此小程序即表示您同意 **Telegram 开发人员服务条款**。"; +"ProfileBotOpenAppInfoOwnerLink" = "https://telegram.org/tos/mini-apps"; +"ProfileBotOr" = "或"; +"ProfileBotPreviewEmptyButton" = "添加预览"; +"ProfileBotPreviewEmptyText#other" = "为您的小程序上传最多 %d 个屏幕截图和视频演示。"; +"ProfileBotPreviewEmptyTitle" = "没有预览"; +"ProfileBotPreviewFooterCreateTranslation" = "创建翻译"; +"ProfileBotPreviewFooterDeleteTranslation" = "删除此译文"; +"ProfileBotPreviewFooterGeneral" = "该预览将显示给所有用户,提供翻译版本的用户除外。"; +"ProfileBotPreviewFooterLanguage" = "该预览将显示给所有语言设置为 %s 的用户。"; +"ProfileBotPreviewLanguageChoose" = "添加译文"; +"ProfileBotPreviewTab" = "预览"; +"ProfileBotRemoveLang" = "删除 %s"; +"ProfileBotReorder" = "重新排序"; +"ProfileBotSelect" = "选择"; +"ProfileBotUnSelect" = "取消选择"; +"ProfileButtonMute" = "静音"; +"ProfileButtonUnmute" = "取消静音"; +"ProfileChannel" = "频道"; +"ProfileChannelChange" = "更改频道"; +"ProfileChannelClearMessage" = "您确定要从个人资料页移除频道吗?"; +"ProfileChannelClearTitle" = "移除频道"; +"ProfileChannelCopy" = "复制链接"; +"ProfileColorEdit" = "更改个人资料页颜色"; +"ProfileCopyLink" = "将链接复制到个人资料页"; +"ProfileCopyUsername" = "复制用户名"; +"ProfileEditBio" = "编辑简介"; +"ProfileGifts" = "礼物"; +"ProfileGiftsAdd" = "添加礼物"; +"ProfileGiftsCount#other" = "%d 份礼物"; +"ProfileGiftsInfo" = "点击礼物可将其转换为星星或更改其隐私设置。"; +"ProfileGiftsNotFoundButton" = "查看所有礼物"; +"ProfileGiftsNotFoundTitle" = "没有符合的礼物"; +"ProfileGiftsSend" = "为好友送礼"; +"ProfileGiftsSendChannel" = "向频道发送礼物"; +"ProfileGiftsSendUser" = "给 %s 送个礼物吧!"; +"ProfileHoursCopy" = "复制时间"; +"ProfileHoursEdit" = "更改时间"; +"ProfileHoursRemove" = "移除"; +"ProfileJoinChannel" = "加入频道"; +"ProfileJoinGroup" = "加入群组"; +"ProfileLevelInfo" = "个人资料等级反映\n用户的付款可靠性。"; +"ProfileLocationCopy" = "复制地址"; +"ProfileLocationEdit" = "更改位置"; +"ProfileLocationMaps" = "在地图上打开"; +"ProfileLocationRemove" = "移除"; +"ProfileLocationView" = "查看位置"; +"ProfileMyStories" = "我的动态"; +"ProfileMyStoriesCount#other" = "%d 条已发布的动态"; +"ProfileMyStoriesTab" = "已发布"; +"ProfileNotes" = "备注"; +"ProfileNotesInfo" = "仅您可见"; +"ProfileNotesRemoveText" = "您确定要移除此备注吗?"; +"ProfileNotesRemoveTitle" = "删除注解"; +"ProfilePhoneEdit" = "更换号码"; +"ProfilePlaylistRemoveFromProfile" = "从个人资料页移除"; +"ProfilePlaylistTitle" = "%s 的播放列表"; +"ProfilePlaylistTitleMine" = "您的播放列表"; +"ProfilePollsCount#other" = "%d 个投票活动"; +"ProfilePopupNotification" = "浮动通知"; +"ProfilePopupNotificationInfo" = "当您没有打开 Telegram 时,来自此联系人的新消息也会出现在您的屏幕上。"; +"ProfileSendAGift" = "发送礼物"; +"ProfileSendAGiftToChannel" = "赠送礼物"; +"ProfileStories" = "动态"; +"ProfileStoriesArchive" = "动态归档"; +"ProfileStoriesArchiveChannelHint" = "仅频道管理员可以查看已归档动态,除非动态已保存在频道资料页。"; +"ProfileStoriesArchiveCount#other" = "%d 已归档动态"; +"ProfileStoriesArchiveGroupHint" = "只有群组管理员才能查看已归档的动态,除非这些动态已发布到群组页面上。"; +"ProfileStoriesArchiveHint" = "除非将其发布至个人资料页,否则只有您能查看已归档的动态。"; +"ProfileStoriesArchiveTab" = "归档"; +"ProfileStoriesCount#other" = "%d 条动态"; +"ProfileStoriesCountZero" = "暂无动态"; +"ProfileTabSetAsMain" = "设为主选项卡"; +"ProfileUnofficialSecurityRisk" = "%s 正在使用非官方的 Telegram 客户端—这可能会降低与该用户通信的安全性"; +"ProfileUsernameEdit" = "更改用户名"; +"ProfileUsernameSet" = "设置用户名"; +"PromoteUrl" = "https://ads.telegram.org"; +"ProximityAlertCancelled" = "已取消接近警报"; +"ProximityAlertSet" = "接近通知已开启"; +"ProximityAlertSetInfoGroup2" = "当有人在 %1$s 范围内通知我"; +"ProximityAlertSetInfoUser" = "当 %1$s 在 %2$s 范围内通知我"; +"ProximityTooltioGroup" = "当群组其他成员靠近时通知我。"; +"ProximityTooltioUser" = "当 %1$s 靠近时通知我"; +"Proxy" = "代理"; +"ProxyAddedSuccess" = "代理服务器已添加。"; +"ProxyBottomSheetCheckStatus" = "**检查状态**"; +"ProxyBottomSheetCheckWarning" = "警告"; +"ProxyBottomSheetCheckWarningText" = "这会将您的 IP 地址暴露给代理服务器管理员。"; +"ProxyBottomSheetChecking" = "检查中"; +"ProxyConnections" = "代理连接"; +"ProxyDetails" = "代理信息"; +"ProxyRotationTimeoutInfo" = "如果当前的代理停止工作,您可以选择此应用应该以多快的速度自动连接到最近的活跃代理。"; +"ProxyRotationTimeoutSeconds" = "%1$d 秒"; +"ProxySettings" = "代理设置"; +"ProxyStatus" = "状态"; +"PsaHide" = "隐藏"; +"PsaHideChatAlertText" = "是否确定要隐藏此聊天?"; +"PsaHideChatAlertTitle" = "隐藏聊天"; +"PsaInfoDefault" = "此消息为您提供公共服务通告。"; +"PsaInfo_covid" = "此信息为您提供正在发生的新冠肺炎疫情相关公共服务公告。了解有关此计划的更多咨询:https://telegram.org/blog/coronavirus"; +"PsaInfo_psa" = "这是 Telegram 首席执行官对于目前冲突的正式声明。"; +"PsaMessageDefault" = "重要通知"; +"PsaMessageInfoDefault" = "这是重要通知"; +"PsaMessageInfo_covid" = "此信息为您提供正在发生的新冠肺炎疫情相关公共服务公告。了解有关此计划的更多咨询:https://telegram.org/blog/coronavirus"; +"PsaMessageInfo_psa" = "这是 Telegram 首席执行官对于目前冲突的正式声明。"; +"PsaMessage_covid" = "新冠病毒肺炎通知"; +"PsaMessage_psa" = "公告"; +"PsaTypeDefault" = "公共服务通告"; +"PsaType_covid" = "新型冠状病毒"; +"PsaType_psa" = "公告"; +"PublicChannelsTooMuch" = "抱歉,目标用户已经有太多的公开群组或频道。 请让他们先将他们现有的一个群组或频道设为私有。"; +"PublicLink" = "公开链接"; +"PublicLinksLimitSubtitle" = "保留高达 %d 个 t.me/username 链接"; +"PublicLinksLimitTitle" = "公共链接"; +"PublicPhoto" = "公开图片"; +"PublicPoll" = "投票"; +"PublicPollCantForward" = "抱歉,无法将具有可见投票的问卷转发到频道。"; +"PublicPostsMore" = "显示更多 \\>"; +"PublicPostsSection" = "公开贴文"; +"PublicPostsTabs" = "公开贴文"; +"PublicShares" = "公开分享"; +"PublicSharesCount#other" = "%1$s 公共分享"; +"PublishDayAt" = "'发布于' MMM d 'at' HH:mm"; +"PublishDayYearAt" = "于 MMM d yyyy 'at' HH:mm '发布 "; +"PublishTodayAt" = "'发布于今天' HH:mm"; +"Purple" = "紫色"; +"PushChatReactAudio" = "对您的语音消息 %3$s"; +"PushChatReactContact" = "对您的联系人信息 %4$s %3$s"; +"PushChatReactDoc" = "对您的文件 %3$s"; +"PushChatReactGame" = "添加 %3$s 到你的游戏"; +"PushChatReactGeo" = "对您的地图 %3$s"; +"PushChatReactGeoLive" = "对您的实时位置 %3$s"; +"PushChatReactGif" = "对您的 GIF 动态图 %3$s"; +"PushChatReactInvoice" = "对您的账单 %3$s"; +"PushChatReactNotext" = "对您的消息 %3$s"; +"PushChatReactPhoto" = "%3$s 到你的照片中"; +"PushChatReactPoll" = "添加 %3$s 到你的投票 %4$s"; +"PushChatReactQuiz" = "对您的 %4$s 问答 %3$s"; +"PushChatReactRound" = "对您的视频消息 %3$s"; +"PushChatReactSticker" = "%3$s 对您的 %4$s 标贴。"; +"PushChatReactText" = "对您的 “%4$s” %3$s"; +"PushChatReactTodo" = "%3$s 于 %2$s 对您的核对清单作出 %4$s 回应"; +"PushChatReactVideo" = "%3$s 发给你的视频"; +"PushReactAudio" = "对您的语音消息 %2$s"; +"PushReactContect" = "对您的联系人信息 %3$s %2$s"; +"PushReactDoc" = "对您的文件 %2$s"; +"PushReactGame" = "对您的游戏 %2$s"; +"PushReactGeo" = "对您的地图 %2$s"; +"PushReactGeoLocation" = "对您的实时位置 %2$s"; +"PushReactGif" = "对您的 GIF 动态图 %2$s"; +"PushReactHidden" = "您的消息收到的新回应"; +"PushReactInvoice" = "对您的账单 %2$s"; +"PushReactNoText" = "对您的消息 %2$s"; +"PushReactPhoto" = "对您的图片 %2$s"; +"PushReactPoll" = "对您的 %3$s 投票 %2$s"; +"PushReactQuiz" = "对您的 %3$s 问答 %2$s"; +"PushReactRound" = "对您的视频消息 %2$s"; +"PushReactSticker" = "对您的 %3$s 贴纸 %2$s"; +"PushReactStory" = "您的动态有 %2$s "; +"PushReactStoryHidden" = "您的动态有新回应"; +"PushReactStoryHiddenSender" = "动态回应"; +"PushReactText" = "对您的 “%3$s” %2$s"; +"PushReactTodo" = "用 %2$s 回应了您的核对清单 %3$s"; +"PushReactVideo" = "对您的视频 %2$s"; +"QRCodeLinkGroupCall" = "所有 Telegram 用户都可以扫描此二维码加入您的群组通话。"; +"QRCodeLinkHelpChannel" = "通过扫描此二维码,Telegram上的任何用户都可以加入您的频道"; +"QRCodeLinkHelpChannelDirect" = "Telegram 上的每个人都可以扫描此二维码发送消息到您的频道。"; +"QRCodeLinkHelpFolder" = "Telegram 上的所有人都可以扫描此二维码,添加此文件夹,并加入此邀请链接中包含的所有聊天。"; +"QRCodeLinkHelpGroup" = "Telegram 上的每个人都可以扫描此二维码以加入您的群组。"; +"QRCodeLinkHelpProxy" = "您的朋友可以使用手机相机扫码添加此代理。"; +"QRCodePermissionNoCamera" = "Telegram 需要获取相机权限以便扫码登录。"; +"QRCodePermissionNoCameraWithHint" = "**Telegram** 需要相机权限,以便你可以扫描二维码。点击选择“设置” > “权限”,并将**相机**开启。"; +"QrCode" = "二维码 "; +"Quality" = "质量"; +"Quality1080" = "高画质"; +"Quality1080Short" = "HD"; +"Quality144" = "低画质"; +"Quality1440" = "超高画质"; +"Quality1440Short" = "2K"; +"Quality2160" = "超高画质"; +"Quality2160Short" = "4K"; +"Quality240" = "低画质"; +"Quality360" = "低画质"; +"Quality480" = "低画质"; +"Quality720" = "中画质"; +"Quality720Short" = "HD"; +"QualityAuto" = "自动"; +"QualityCached" = "・已缓存"; +"QualityList" = "视频画质"; +"QualityOriginal" = "原画"; +"QualitySaveIn" = "以 %dp 保存"; +"QualitySource" = "原画"; +"Question" = "问题"; +"QuestionDescriptionHint" = "添加简介 (可选)"; +"QuestionHint" = "请输入问题"; +"QuickReplyCustom" = "自定义消息…"; +"QuickReplyDefault1" = "我现在不方便接电话,有什么事吗?"; +"QuickReplyDefault2" = "我将尽快回电话。"; +"QuickReplyDefault3" = "我将稍后回电话。"; +"QuickReplyDefault4" = "我现在不方便接听,能稍后再打过来吗?"; +"QuizAnswers" = "问答答案"; +"QuizExplanationTitle" = "说明"; +"QuizInfo" = "问答模式下的投票仅有一个正确答案,且用户无法撤回其选择。"; +"QuizPoll" = "问答"; +"QuizWellDone" = "做得好!"; +"QuizWellDoneInfo" = "您选择了正确答案。"; +"QuizWrongAnswer" = "回答错误!"; +"QuizWrongAnswerInfo" = "您错过了正确选项。"; +"Quote" = "引用"; +"QuoteCollapse" = "折叠"; +"QuoteExpand" = "展开"; +"QuoteMaxError" = "引文过长!"; +"QuoteMaxErrorMessage" = "选择的文字过长,无法引用。"; +"QuoteNotFound" = "未找到原始引文"; +"QuoteOutdated" = "引文已过时"; +"QuotePrivate" = "该引文来自私聊"; +"QuotePrivateChannel" = "该引文来自私密频道"; +"QuotePrivateGroup" = "该引文来自私密群组"; +"QuoteSelectedPart" = "引用所选部分"; +"QuoteTo" = "引用至…"; +"QuoteToAnotherChat" = "在另一个聊天中回复"; +"RaiseToListen" = "贴近耳边以接听"; +"RaiseToListenInfo" = "把手机举到耳边后,可以将声音切换至听筒"; +"RaiseToSpeak" = "贴耳语音"; +"RaiseToSpeakInfo" = "把手机举到耳边后,可以录制语音消息"; +"RateCallDistorted" = "声音失真"; +"RateCallDropped" = "通话意外中断"; +"RateCallEcho" = "我听到了自己的回声"; +"RateCallInterruptions" = "对方声音断断续续"; +"RateCallNoise" = "我听到了背景噪音"; +"RateCallSilentLocal" = "我无法听到对方的声音"; +"RateCallSilentRemote" = "对方無法听到我的声音"; +"RateCallVideoDistorted" = "视频失真"; +"RateCallVideoPixelated" = "视频被像素化"; +"RateTranscription" = "评价该转换结果"; +"ReEnterPassword" = "重新输入密码"; +"ReEnterYourPasscode" = "请再次输入新的锁定码"; +"Reacted#other" = "%1$s 人已回应"; +"ReactionAddEmojiFromAnyPack" = "您可以添加任意表情包中的表情作为回应。"; +"ReactionAddReactionsHint" = "添加表情回应..."; +"ReactionApplyChangesDialog" = "您已更改表情回应列表,是否应用更改?"; +"ReactionCreateOwnPack" = "您也可以 **创建自己的** 表情包并使用它们。"; +"ReactionCustomReactions" = "自定义表情回应"; +"ReactionDeleteSent" = "已删除回应"; +"ReactionInDialog" = "已使用 %s 回应您的消息"; +"ReactionLevelRequiredBtn#other" = "需要等级 %1$d"; +"ReactionLongTapHint" = "长按查看更多表情回应。"; +"ReactionMaxCountError#other" = "您最多可以选择 %1$d 个表情回应。"; +"ReactionReachLvlForReaction#other" = "您的频道需要达到 **等级 %2$d** 才能添加 **%1$d** 个自定义表情回应。\n\n邀请您的 **高级版** 订阅者们通过此链接助推频道:"; +"ReactionReachLvlForReactionShort#other" = "您的频道需要达到等级 **%2$d** 才能添加 **%1$d** 个自定义表情回应。"; +"ReactionSent" = "消息已发送。"; +"ReactionUpdateReactionsBtn" = "更新表情回应"; +"Reactions" = "表情回应"; +"ReactionsAll" = "全部"; +"ReactionsByEmotionChartTitle" = "表情回应"; +"ReactionsCount#other" = "%1$d 个表情回应"; +"ReactionsDeleted" = "反应已删除"; +"ReactionsLongtapHint" = "长按表情符号发送超级回应"; +"ReactionsOff" = "已关闭"; +"ReactionsPerPost" = "每个帖文的回应次数"; +"ReactionsPerStory" = "每条动态的表情回应"; +"ReadAllMentions" = "全部提及已读"; +"ReadAllPollVotes" = "查看所有投票"; +"ReadAllReactions" = "将所有表情回应标记为已读"; +"ReadMore" = "阅读更多"; +"Recent" = "最近访问"; +"RecentCalls" = "最近通话"; +"RecentFiles" = "最近文件"; +"RecentFilesAZ" = "文件 A-Z"; +"RecentPosts" = "近期贴文"; +"RecentPostsCapitalize" = "近期贴文"; +"RecentStickers" = "最近使用"; +"RecentStickersShort" = "最近"; +"RecentlyDownloaded" = "最近下载"; +"RecentlyUsed" = "最近使用"; +"RecentlyViewed" = "最近查看过"; +"RecentlyViewedHide" = "隐藏"; +"Recipient#other" = "%1$d 个接收者"; +"RecorderEditCover" = "动态封面"; +"RecorderEditStory" = "编辑"; +"RecorderNewStory" = "新动态"; +"RecorderRepost" = "转发"; +"RecordingAudio" = "正在录音…"; +"RecordingRound" = "正在录制视频…"; +"RecordingTrimText" = "该范围以外的音频将会被丢弃,录制也会随即开始。"; +"RecordingTrimTitle" = "剪辑至选定的范围?"; +"RecordingVideoStatus" = "录制视频中……"; +"RecoveryEmail" = "密码找回电子邮箱"; +"RecoveryEmailSubtitle" = "您可以设置恢复邮箱,以便能够重置密码并恢复对 Telegram 帐号的访问。"; +"RecoveryEmailTitle" = "恢复电子邮箱"; +"Red" = "红色"; +"Reduce" = "减少"; +"Refresh" = "刷新"; +"RegisterText" = "请输入您的姓名"; +"RegisterText2" = "请输入您的全名并上传您的个人头像。"; +"RegistrationProfileInfo" = "个人信息"; +"Regular" = "常规"; +"RelativeDateAddDate" = "添加日期"; +"RelativeDateCopied" = "日期已复制到剪贴板"; +"RelativeDateFormatterWeek2" = "%1$s, %2$s"; +"RelativeDateMenuAddToACalendar" = "添加到日历"; +"RelativeDateMenuCopy" = "复制日期"; +"RelativeDateMenuSetAReminder" = "设置提醒"; +"ReleaseToGoNextArchive" = "松开跳转到已归档频道"; +"ReleaseToGoNextChannel" = "松开跳转到下一个未读频道"; +"ReleaseToGoNextFolder" = "松开跳转到下一个未读分组"; +"ReleaseToGoNextRecommendedChannel" = "释放以转到下一个频道"; +"ReleaseToGoNextUnreadTopic" = "松开进入下一个未读话题"; +"RemindDayAt" = "MMM d HH:mm '提醒我'"; +"RemindDayYearAt" = "yyyy MMM d HH:mm '提醒我'"; +"RemindTodayAt" = "今天 HH:mm 提醒我"; +"Reminders" = "提醒"; +"Remove" = "移除"; +"RemoveAds" = "移除广告"; +"RemoveDocumentsAlertMessage" = "已删除的文件将从您的手机存储中删除,但仍可在云端访问。"; +"RemoveDocumentsMessage#other" = "您确定要将** %d 个**项目从下载列表中移除吗?"; +"RemoveDocumentsTitle#other" = "删除%d项下载"; +"RemoveFromListText" = "长按删除用户。"; +"RemoveFromPosts" = "从动态中删除"; +"RemoveFromRecent" = "从最近使用文档的列表中移除"; +"RemoveGroupEmojiPackSet" = "移除群组表情包"; +"RemoveGroupStickerSet" = "移除群组贴纸包"; +"RemoveManyEmojiCount#other" = "删除 %1$d 个表情符号"; +"RemoveManyEmojiPacksCount#other" = "删除 %1$d 个表情包"; +"RemoveManyMasksCount#other" = "删除 %1$d 面具装饰"; +"RemoveManyStickersCount#other" = "删除 %1$d 张贴纸"; +"RemoveMessageFeeMessage" = "你确定要允许 **%s** 免费给你发送消息吗?"; +"RemoveMessageFeeMessageChannel" = "您是否要允许 **%s** 免费向频道发送消息?"; +"RemoveMessageFeeRefund#other" = "退还已支付的 %d 颗星星"; +"RemoveMessageFeeTitle" = "免收费用"; +"RemovePhotoForRestDescription" = "你确定要删除公开照片吗?"; +"RemovePublicPhoto" = "移除公开照片"; +"RemoveQuote" = "移除引文"; +"RemoveReply" = "移除回复"; +"RemoveStatus" = "移除状态"; +"RemoveStatusInfo" = "表情符号状态已移除。"; +"RemoveStickersCount" = "移除 %1$s"; +"RemoveWallpaperAction" = "移除"; +"RemoveWallpaperMessage" = "您确定要重置壁纸吗?"; +"RemoveWallpaperTitle" = "移除壁纸"; +"RemovedFromFavorites" = "已从收藏中删除贴纸"; +"RemovedFromRecent" = "贴纸已从最近使用移除"; +"RemovedUser#other" = "%1$d 个被移除的用户"; +"Rename" = "重命名"; +"RepeatDisabled" = "关闭"; +"RepeatList" = "列表循环"; +"RepeatNotifications" = "重复通知"; +"RepeatNotificationsNever" = "从不"; +"RepeatSong" = "单曲循环"; +"Replace" = "更换"; +"ReplaceAttachedPollMedia" = "替换"; +"ReplaceBoostChannelDescription" = "您目前正在助推 **%1$s**,您是否想改为助推 **%2$s**?"; +"ReplacePhoto" = "更换图片"; +"Replies#other" = "%1$d 回复"; +"RepliesChatInfo" = "此聊天将帮助您在群组中跟踪对您的评论。"; +"RepliesTitle" = "回复"; +"Reply" = "回复"; +"ReplyDialogMessageAuthor" = "消息作者"; +"ReplyDialogYourChats" = "您的聊天"; +"ReplyPrivate" = "该回复来自私聊。"; +"ReplyPrivateChannel" = "该回复来自私密频道。"; +"ReplyPrivateGroup" = "该回复来自私密群组。"; +"ReplyPrivately" = "私信回复…"; +"ReplyTo" = "回复 %s"; +"ReplyToAnotherChat" = "在另一个聊天中回复"; +"ReplyToDialog" = "回复..."; +"ReplyToGroup" = "回复"; +"ReplyToGroupStory" = "评论动态..."; +"ReplyToPoll" = "回复投票选项"; +"ReplyToPollIn" = "回复 %s 的投票选项"; +"ReplyToQuote" = "回复来自 %s 的引文"; +"ReplyToTask" = "回复核对清单中的任务"; +"ReplyToTaskIn" = "回复 %s 中的选项"; +"ReplyToUser" = "回复"; +"Report2" = "举报"; +"Report2Comment" = "添加评论"; +"Report2CommentInfo" = "请告诉我们您举报的消息有什么问题。"; +"Report2CommentInfoChannel" = "请告诉我们您所选的频道有什么问题。"; +"Report2CommentInfoGroup" = "请帮助我们了解您所选的群组有什么问题。"; +"Report2CommentInfoMany" = "请帮助我们了解您举报的消息有什么问题。"; +"Report2CommentInfoUser" = "请告诉我们您所选的用户有什么问题。"; +"Report2CommentOptional" = "添加评论 (可选)"; +"Report2Send" = "发送举报"; +"ReportAd" = "举报广告"; +"ReportAdLearnMore" = "了解更多有关我们的[广告政策和指南](https://ads.telegram.org/guidelines)。"; +"ReportAlertReaction" = "您确定要举报这个用户的表态吗?"; +"ReportBot" = "举报"; +"ReportChat" = "举报"; +"ReportChatChild" = "虐待儿童"; +"ReportChatDescription" = "描述"; +"ReportChatFakeAccount" = "虚假账号"; +"ReportChatIllegalDrugs" = "违禁药品"; +"ReportChatOther" = "其它"; +"ReportChatPersonalDetails" = "个人信息"; +"ReportChatPornography" = "色情"; +"ReportChatSent" = "举报已发送。"; +"ReportChatSpam" = "垃圾信息"; +"ReportChatViolence" = "暴力"; +"ReportFalsePositive" = "举报误报"; +"ReportHint" = "更多细节……"; +"ReportInfo" = "请输入与您的举报有关的任何其他细节。"; +"ReportMessages" = "举报消息"; +"ReportMessagesCount" = "举报 %1$s"; +"ReportMessagesCountNoCaps" = "举报 %1$s"; +"ReportMessagesNoCaps" = "举报消息"; +"ReportProfilePhoto" = "举报"; +"ReportReaction" = "举报表情回应"; +"ReportReaction2" = "举报回应"; +"ReportReactionAndBan" = "封禁并举报"; +"ReportReactionAndBan2" = "封禁并举报回应"; +"ReportSelectMessages" = "选择消息"; +"ReportSend" = "举报"; +"ReportSentInfo" = "Telegram 管理员将复核您的举报。\n感谢您的协助!"; +"ReportSpam" = "举报垃圾信息"; +"ReportSpamAlert" = "您要举报此用户发布垃圾信息吗?"; +"ReportSpamAlertChannel" = "您确定要举报此频道发布垃圾信息吗?"; +"ReportSpamAlertGroup" = "您要举报此群组发布垃圾信息吗?"; +"ReportSpamAndLeave" = "举报垃圾信息并退出"; +"ReportSpamAndLeaveNoCaps" = "举报垃圾信息并退出"; +"ReportSpamLocation" = "群组与位置不相关?"; +"ReportSpamLocationNoCaps" = "群组与位置不相关?"; +"ReportSpamNoCaps" = "举报垃圾信息"; +"ReportSpamTitle" = "举报垃圾信息"; +"ReportSpamUser" = "屏蔽用户"; +"ReportSpamUserBotVerificationHint" = "此页面已由 %1$s 验证"; +"ReportSpamUserEmojiStatusHint" = "此帐号在其名称旁边使用了自定义状态 %1$s。所有订阅 %2$s 的用户都可以使用这样的表情符号状态。"; +"ReportSpamUserEmojiStatusHint2" = "%1$s 是 **高级版订阅者 >** 的标记"; +"ReportSpamUserNoCaps" = "屏蔽用户"; +"ReportStory" = "举报动态"; +"ReportTitleChild" = "举报虐待儿童"; +"ReportTitleFake" = "举报虚假"; +"ReportTitlePornography" = "举报色情"; +"ReportTitleSpam" = "举报垃圾信息"; +"ReportTitleViolence" = "举报暴力"; +"ReportUnrelatedGroup" = "举报与位置无关的群组"; +"ReportUnrelatedGroupText" = "如果此群组与此位置无关,请告知我们:\n\n**%1$s**"; +"ReportUnrelatedGroupTextNoAddress" = "如果此群组与此位置无关,请告知我们。"; +"ReportUserLocation" = "举报"; +"Reported2" = "Telegram 管理员将会检视您的举报。感谢您!"; +"RepostToStory" = "转发\n到动态"; +"RepostedToChannelProfile" = "此消息转发到 **%s**。"; +"RepostedToProfile" = "信息已转发到您的个人资料页。"; +"Reposts#other" = "%d 次转发"; +"RequestAnotherSMS" = "请求另一个短信"; +"RequestCallButton" = "通过语音获取验证码"; +"RequestMissedCall" = "通过电话获取验证码"; +"RequestSmsButton" = "通过短信获取验证码"; +"RequestToJoinChannel" = "请求加入频道"; +"RequestToJoinChannelApproved" = "您已获批加入群组"; +"RequestToJoinChannelDescription" = "此频道仅在管理员批准后才接受新订阅者。"; +"RequestToJoinChannelSentDescription" = "一旦频道管理员批准您的请求,您将被添加到频道中。"; +"RequestToJoinGroup" = "请求加入群组"; +"RequestToJoinGroupApproved" = "您已获批加入群组"; +"RequestToJoinGroupDescription" = "加入该群需要获得管理员的批准。"; +"RequestToJoinGroupSentDescription" = "待管理员同意后即可加入该群组。"; +"RequestToJoinMembersAll#other" = "%1$s、%2$s 以及 %3$d 位其他成员"; +"RequestToJoinMembersThree" = "%1$s、%2$s 和 %3$s"; +"RequestToJoinMembersTwo" = "%1$s 和 %2$s"; +"RequestToJoinSent" = "加入请求已发送"; +"RequestedToJoinAt" = "在 %1$s 请求加入"; +"RequireMonthlyFee" = "需要月费"; +"RequireMonthlyFeeInfo" = "向通过此链接加入您频道的用户收取订阅费用。"; +"RequireMonthlyFeeInfoFrozen" = "如需更改订阅费用,请创建具有不同价格的新邀请链接。"; +"RequireMonthlyFeeInfoLink" = "https://telegram.org/tos/stars"; +"RequireMonthlyFeePrice" = "约 %s/月"; +"RequireMonthlyFeePriceHint" = "每月所需星星数量"; +"RequireMonthlyFeePriceHintTest5Minutes" = "每 5 分钟的星星数量"; +"RequireMonthlyFeePriceTest5Minutes" = "~%s/5 分钟"; +"ResellGiftButton" = "上架"; +"ResellGiftBuy#other" = "以 ⭐️%d 购买"; +"ResellGiftBuyEq#other" = "相当于 ⭐️%d"; +"ResellGiftBuyTON" = "以 ⭐️%s Gram 购买"; +"ResellGiftFilterSortDate" = "按日期排序"; +"ResellGiftFilterSortDateShort" = "日期"; +"ResellGiftFilterSortNumber" = "按编号排序"; +"ResellGiftFilterSortNumberShort" = "编号"; +"ResellGiftFilterSortPrice" = "按价格排序"; +"ResellGiftFilterSortPriceShort" = "价格"; +"ResellGiftInfo#other" = "您将获得 **%d** 颗星星。"; +"ResellGiftInfoMin#other" = "最低价格为 %d 颗星星。"; +"ResellGiftInfoMinTON" = "最低价格为 %s Gram"; +"ResellGiftInfoTON" = "您将收到 **%s** Gram。"; +"ResellGiftPriceHintOnlyTON" = "如果买家使用 Gram 付款,则不存在买家要求退款的风险,\n这是与星星付款的不同之处。"; +"ResellGiftPriceOnlyTON" = "只接受以 Gram 付款"; +"ResellGiftPriceTitle" = "以星星作为单位输入价格"; +"ResellGiftPriceTitleTON" = "输入 Gram 价格"; +"ResellGiftPriceTooMuch" = "您最高可以报价 %s"; +"ResellGiftPriceTooSmall" = "您至少可以报价 %s"; +"ResellGiftTitle" = "星星价格"; +"ResellGiftTitleTON" = "以 Gram 报价"; +"ResendCode" = "重新发送验证码"; +"ResendCodeInfo" = "验证码已发送到您的电子邮箱"; +"ResendSmsAvailableIn" = "您可以在 %1$d:%2$02d 请求新短信"; +"Reset" = "重置"; +"ResetAccount" = "重置帐号"; +"ResetAccountButton" = "重置帐号"; +"ResetAccountCancelledAlert" = "您最近的重置帐号请求已被此帐号当前的活跃用户撤销,请于 7 天后再试。"; +"ResetAccountInfo" = "因为账号 **%1$s** 处于活跃状态并且被密码保护,出于安全考虑,我们需要等待一周才能删除它。\n\n在此期间,您可以随时取消此过程。"; +"ResetAccountStatus" = "在这段时间后,您将能重置您的帐号:"; +"ResetAllNotifications" = "重置所有通知策略"; +"ResetAutomaticMediaDownload" = "重置自动下载设置"; +"ResetAutomaticMediaDownloadAlert" = "您确定要重置自动下载设置吗?"; +"ResetAutomaticMediaDownloadAlertTitle" = "重置设定"; +"ResetChatBackgrounds" = "重置聊天壁纸"; +"ResetChatBackgroundsAlert" = "您确定要重置所有聊天壁纸吗?"; +"ResetChatBackgroundsAlertTitle" = "重置聊天壁纸"; +"ResetChatBackgroundsInfo" = "删除所有上传的壁纸并恢复默认预装壁纸。"; +"ResetCustomNotifications" = "重置至默认设置"; +"ResetCustomNotificationsAlert" = "您确定您想要将通知设置设为初始状态吗?"; +"ResetCustomNotificationsAlertTitle" = "重置通知设定"; +"ResetMyAccount" = "重置我的帐号"; +"ResetMyAccountText" = "如果您确认重置帐号,您将丢失 Telegram 中所有的聊天、消息、媒体和文件。"; +"ResetMyAccountWarning" = "警告"; +"ResetMyAccountWarningReset" = "重置帐号"; +"ResetMyAccountWarningText" = "此操作无法撤消。如果您重置了帐号,将会丢失所有的聊天、消息、媒体和文件。"; +"ResetNotificationsAlert" = "您确定需要重置所有通知策略吗?"; +"ResetNotificationsAlertTitle" = "重置所有通知"; +"ResetNotificationsText" = "重置所有通知设置为默认值"; +"ResetPassword" = "重置密码"; +"ResetPasswordWait" = "您最近请求的密码重置已被取消。请等待 %1$s 后重新请求\n"; +"ResetStatistics" = "重置统计数据"; +"ResetStatisticsAlert" = "您确定要重置统计数据吗?"; +"ResetStatisticsAlertTitle" = "重置统计信息"; +"ResetToOriginalPhoto" = "重置为原始照片"; +"ResetToOriginalPhotoMessage" = "你确定要重置为 %s 的原始照片吗?"; +"ResetToOriginalPhotoTitle" = "重设头像"; +"RestToDefaultBackground" = "移除壁纸"; +"RestartTopic" = "解锁话题"; +"RestartTopics" = "解锁话题"; +"Restore" = "恢复"; +"RestoreEmailNoAccess" = "无法访问 %1$s?"; +"RestoreEmailSent" = "我们已经将恢复码发送到了您的电子邮箱:\n\n%1$s"; +"RestoreEmailSentInfo" = "请查阅您电子邮箱中的邮件,并输入我们发送的六位数验证码,以停用云端密码。"; +"RestoreEmailSentTitle" = "恢复秘密"; +"RestoreEmailTrouble" = "无法访问 %1$s?"; +"RestoreEmailTroubleNoEmail" = "无法查看邮件?"; +"RestoreEmailTroubleText" = "如果您无法访问您的电子邮箱,您剩下的选择就只有回忆起您的密码或重置您的帐号。"; +"RestoreEmailTroubleText2" = "如果你无法访问辅助邮箱,你剩下的选择只有想起密码,或者等待 7 天后密码重置。"; +"RestorePasswordNoEmailText" = "因为您在设置密码时没有同时提供密码恢复邮箱,您剩下的选择就只有回忆起您的密码或重置您的帐号。"; +"RestorePasswordNoEmailText2" = "由于你在设置密码时没有提供恢复邮箱,你只能尝试回忆密码或等待 7 天直到密码重置。"; +"RestorePasswordNoEmailTitle" = "抱歉"; +"RestorePasswordResetAccount" = "重置帐号"; +"RestorePasswordResetIn" = "您可以在 %1$s之后重置密码"; +"RestorePasswordResetPassword" = "重置密码"; +"RestorePasswordResetPasswordOk" = "你的两步验证密码已重置。"; +"RestorePasswordResetPasswordText" = "你确定要重置你的两步验证密码吗?"; +"RestorePremiumHintMessage" = "您的 Telegram 高级版最近已过期,点击此处以续费。"; +"RestorePremiumHintTitle" = "以最高 **%1$d%%** 的折扣续费高级版"; +"Restrict" = "限制"; +"RestrictSavingContent" = "限制保存内容"; +"RestrictSavingContentInfoChannel" = "订阅者将无法复制、保存和转发此频道的内容。"; +"RestrictSavingContentInfoGroup" = "成员将无法复制、保存和转发此组中的内容。"; +"RestrictUserDeleteAllMessages" = "删除全部消息"; +"RestrictUserDeleteAllReactions" = "删除全部反应"; +"RestrictedParticipantSending" = "限制 **%s** 发送任何消息"; +"Restrictions#other" = "%1$d 次封禁"; +"ResumeAll" = "全部继续"; +"Retry" = "重试"; +"RetryCall" = "重拨"; +"ReturnEnteringPhrase" = "返回输入短语 >"; +"ReturnEnteringSMS" = "返回输入验证码 >"; +"ReturnEnteringWord" = "返回输入秘密单词 >"; +"ReturnPhoneCall" = "返回通话 >"; +"ReturnToCall" = "返回通话"; +"RevenueSharingAdsAlertButton" = "了解"; +"RevenueSharingAdsAlertSubtitle" = "Telegram 广告与其他平台上的广告大有不同。例如此广告:"; +"RevenueSharingAdsInfo1Subtitle" = "Telegram 上的广告不会使用您的个人信息,而是基于您所在的频道来展示的。"; +"RevenueSharingAdsInfo1SubtitleBot" = "Telegram 上的广告不会使用您的个人资料,而是仅基于您看到广告的迷你应用内容。"; +"RevenueSharingAdsInfo1Title" = "尊重您的隐私"; +"RevenueSharingAdsInfo1TitleBot" = "尊重您的隐私"; +"RevenueSharingAdsInfo2Subtitle" = "Telegram 广告收入的 50% 将归广告频道的所有者所有。"; +"RevenueSharingAdsInfo2SubtitleBot" = " 广告收入的 50% 将归广告机器人的所有者所有。"; +"RevenueSharingAdsInfo2Title" = "帮助频道创造者"; +"RevenueSharingAdsInfo2TitleBot" = "帮助机器人开发者"; +"RevenueSharingAdsInfo3Subtitle" = "您可以通过订阅 Telegram 高级版关闭广告,而等级为 %1$s 的频道可以为其订阅者移除这些广告。"; +"RevenueSharingAdsInfo3SubtitleBot" = "您可以通过订阅 **Telegram 高级版** 关闭广告。"; +"RevenueSharingAdsInfo3Title" = "可以移除"; +"RevenueSharingAdsInfo4Subtitle2" = "任何人都可以用极低的预算在这个频道投放广告。详情请查看 **Telegram 广告平台**。%1$s"; +"RevenueSharingAdsInfo4Subtitle2Bot" = "任何人都可以创建广告并在此机器人中显示—只需最少的预算。请查看 Telegram 广告平台了解详情。%1$s"; +"RevenueSharingAdsInfo4SubtitleLearnMore" = "**了解更多 >**"; +"RevenueSharingAdsInfo4Title" = "我可以投放广告吗?"; +"RevenueSharingAdsInfo4TitleBot" = "我可以投放广告吗?"; +"RevenueSharingAdsUnderstood" = "已知悉"; +"ReverseOrder" = "倒序"; +"RevokeAlert" = "您确定要废除此邀请链接吗?一旦废除,其他人将不能通过此链接加入此群组。"; +"RevokeAlertNewLink" = "之前的邀请链接已失效,新的链接已生成。"; +"RevokeButton" = "废除"; +"RevokeLink" = "撤销链接"; +"RevokeLinkAlert" = "您确定要废除此链接(**%1$s**)吗 ?\n\n群组«**%2$s**»将转变成私密群组。"; +"RevokeLinkAlertChannel" = "您确定要废除此链接(**%1$s**)吗?\n\n频道«**%2$s**»将转变成私密频道。"; +"RevokeLinks#other" = "撤销 %d 条链接。"; +"RevokeLinksAlert" = "您确定要撤销这些链接吗?\n\n这些群组将变为私密状态。"; +"RevokeLinksAlertChannel" = "您确定要撤销这些链接吗?\n\n这些频道将变为私密状态。"; +"Revoked" = "已撤销"; +"RevokedLink" = "撤销链接"; +"RevokedLinks" = "已撤销的链接"; +"RightNow" = "刚刚"; +"RoamingUsage" = "漫游流量使用情况"; +"SETTINGS" = "设置"; +"SMSFee1Text" = "您所在国家的电信运营商向 Telegram 收取非常高的短信费用。"; +"SMSFee1TextCountry" = "由于您所在国家/地区存在虚假账户问题且短信费用较高,您需要支付注册费用。"; +"SMSFee1Title" = "短信费高昂"; +"SMSFee2Text" = "Telegram 需要向您发送一条包含验证码的短信来确认您的号码。"; +"SMSFee2Title" = "需要验证"; +"SMSFee3Text" = "注册一周的 Telegram 高级版订阅以帮助支付短信费用。"; +"SMSFee3TextDays#other" = "注册 %d 天的 Telegram Premium 订阅以帮助支付短信费用。"; +"SMSFee3Title" = "通过订阅 **Telegram 高级版 >** 支持"; +"SMSFeeContact" = "有问题吗?**联系我们**"; +"SMSFeePurchaseText" = "获取一周 Telegram 高级版订阅"; +"SMSFeePurchaseTextDays#other" = "获取 %d 天 Telegram 高级版订阅"; +"SMSFeePurchaseTitle" = "注册 %s"; +"SMSFeeTitle" = "短信费"; +"SMSPhrase" = "密码短语"; +"SMSPhraseBeginningError" = "秘密单词不正确"; +"SMSPhraseBeginningText" = "我们已向您的号码 **%1$s** 发送了一条短信,其中包含以 “%2$s” 开头的秘密短语。"; +"SMSPhraseError" = "不正确,请再试一次"; +"SMSPhraseHint" = "输入短信验证码"; +"SMSPhrasePasteHint" = "您可以从短信中复制并将验证码粘贴到输入框中。"; +"SMSPhraseText" = "我们已向您的手机号码 **%1$s** 发送了一个带有秘密短语的短信。"; +"SMSPhraseTitle" = "输入短信验证码"; +"SMSWord" = "秘密单词"; +"SMSWordBeginningError" = "Incorrect word"; +"SMSWordBeginningText" = "我们已向您的手机号码 **%1$s** 已发送一条带有以「**%2$s**」开头的秘密短语的短信。"; +"SMSWordError" = "不正确,请再试一次"; +"SMSWordHint" = "输入短信中的文字"; +"SMSWordPasteHint" = "您可以复制并粘贴此单词。"; +"SMSWordText" = "我们已向您的手机号码 **%1$s** 发送了一个带有秘密单词的消息。"; +"SMSWordTitle" = "输入短信中的单词"; +"SafetyNetErrorOccurred" = "内部错误,请稍候再试。"; +"Satellite" = "卫星图像"; +"Saturation" = "饱和"; +"Save" = "保存"; +"SaveChangesAlertText" = "您是否要应用所选的新颜色?"; +"SaveChangesAlertTitle" = "保存更改?"; +"SaveException" = "保存例外"; +"SaveForNotifications" = "保存为通知铃声"; +"SaveGroupMedia" = "保存媒体"; +"SaveGroupMediaMessage" = "你需要保存…"; +"SaveImage" = "保存图片"; +"SaveLink" = "保存链接"; +"SaveLinkHeader" = "保存"; +"SaveLinkHeaderNoCaps" = "保存"; +"SaveOnAnnualPremiumMessage" = "以年付计划订阅 Telegram 高级版来获得此折扣。"; +"SaveOnAnnualPremiumTitle" = "您的订阅可享受最多 **%1$d%%** 的优惠"; +"SaveStoryToGalleryPremiumHint" = "**订阅高级版** 后可以保存其他用户未开启保护的动态。"; +"SaveTheme" = "保存主题"; +"SaveToDownloads" = "保存到下载文件夹"; +"SaveToGIFs" = "添加到 GIF"; +"SaveToGallery" = "保存到相册"; +"SaveToGalleryChannels" = "频道"; +"SaveToGalleryException" = "例外"; +"SaveToGalleryGroups" = "群组"; +"SaveToGalleryHintChannels" = "自动保存来自频道的新增媒体内容到你手机的相册。"; +"SaveToGalleryHintCurrent" = "自动将此聊天中的所有新媒体内容保存到相册中。 "; +"SaveToGalleryHintGroup" = "自动将群组中的所有新媒体内容保存到手机的相册中。"; +"SaveToGalleryHintUser" = "自动将私聊中的所有新媒体文件保存至手机相册。"; +"SaveToGalleryOff" = "关闭"; +"SaveToGalleryPhotos" = "图片"; +"SaveToGalleryPrivate" = "私聊"; +"SaveToGallerySettings" = "保存到相册"; +"SaveToGalleryVideoHintChannels" = "频道中所有小于所选大小的视频都将保存到您的相册中。"; +"SaveToGalleryVideoHintCurrent" = "此聊天中小于所选大小的所有视频都将保存到相册中。"; +"SaveToGalleryVideoHintGroup" = "群组中所有小于您所选择的大小的视频会自动保存到你的相册中。"; +"SaveToGalleryVideoHintUser" = "私聊中所有小于选定大小的视频都将保存到您的相册中。"; +"SaveToGalleryVideos" = "视频"; +"SaveToGalleryVideosUpTo" = "视频不能超过%s"; +"SaveToMusic" = "保存到音乐库"; +"SaveToPosts" = "发布至个人资料页"; +"SaveToProfile" = "发布至个人资料页"; +"SaveVideo" = "保存视频"; +"SavedDialogsTab" = "聊天"; +"SavedDialogsTabCount#other" = "%d 个聊天"; +"SavedGifsLimitSubtitle" = "在 GIF 动态图收藏中存储高达 %d 张 GIF 动态图"; +"SavedGifsLimitTitle" = "保存 GIF "; +"SavedMessages" = "我的收藏"; +"SavedMessagesCount#other" = "%d 条收藏消息"; +"SavedMessagesHint" = "点击即可查看按类型或来源整理的**收藏消息**."; +"SavedMessagesInfo" = "转发到此以保存"; +"SavedMessagesInfoQuote" = "引用至此以收藏。"; +"SavedMessagesProfileHint" = "您发送至“我的收藏”的消息"; +"SavedMessagesTab" = "我的收藏"; +"SavedMessagesTab2" = "已收藏"; +"SavedMessagesViewAsChatsHint" = "点击查看聊天记录 >"; +"SavedOpenChannel" = "公开频道"; +"SavedOpenChannelNoCaps" = "打开频道"; +"SavedOpenChat" = "公开聊天"; +"SavedOpenChatNoCaps" = "打开聊天"; +"SavedOpenGroup" = "公开群组"; +"SavedOpenGroupNoCaps" = "打开群组"; +"SavedStories" = "已发布的动态"; +"SavedTagFilterByTag" = "依据标签进行筛选"; +"SavedTagHideOtherMessages" = "隐藏其他消息"; +"SavedTagLabelPlaceholder" = "名称"; +"SavedTagLabelTag" = "添加名称"; +"SavedTagLabelTagText" = "您可以使用文本给表情标签起一个名字。"; +"SavedTagLongpressHint" = "长按可为标签添加名称"; +"SavedTagMessageTagged" = "消息已标记为 "; +"SavedTagMessagesTagged#other" = "含有以下标签的消息"; +"SavedTagReactionsHint" = "您可以为消息添加标签"; +"SavedTagReactionsHint2" = "使用表情符号标记消息以便快速搜索"; +"SavedTagReactionsPremiumHint" = "使用标签整理收藏的消息\n以便快速访问。**了解更多...**"; +"SavedTagReactionsSelectedAddHint" = "为选定的消息添加标签"; +"SavedTagReactionsSelectedEditHint" = "编辑所选的消息"; +"SavedTagRemoveTag" = "移除标签"; +"SavedTagRenameTag" = "编辑名称"; +"SavedTagSearchHint" = "搜索消息或标签"; +"SavedTagSearchTooltipHint" = "点击此图标按标签搜索"; +"SavedTagShowOtherMessages" = "显示其他消息"; +"SavedViewAsChats" = "以聊天模式查看"; +"SavedViewAsMessages" = "以消息模式查看"; +"SavingContentTitle" = "内容保护"; +"ScamMessage" = "诈骗"; +"ScanQrCode" = "扫描二维码"; +"Schedule" = "定时"; +"ScheduleMessage" = "定时消息"; +"ScheduleNotifyOffChannel" = "订阅者将收到无声通知"; +"ScheduleNotifyOffChat" = "%s 将收到无声通知"; +"ScheduleNotifyOffGroup" = "群组成员将收到无声通知"; +"ScheduleNotifyOffSelf" = "您将收到无声通知"; +"ScheduleNotifyOnChannel" = "订阅者将收到通知"; +"ScheduleNotifyOnChat" = "%s 将收到通知"; +"ScheduleNotifyOnGroup" = "群组成员将收到通知"; +"ScheduleNotifyOnSelf" = "您将收到通知"; +"ScheduleWhenOnline" = "在线 %1$s 后发送"; +"ScheduledHint" = "长按以稍后发送消息。"; +"ScheduledMessages" = "定时消息"; +"ScheduledOrNoSoundHint" = "长按定时发送或者静音发送"; +"ScheduledTimeApprox" = "约 %s"; +"ScreenCapture" = "在任务切换器中显示应用内容"; +"ScreenCaptureAlert" = "这将从任务切换器中隐藏您的聊天或聊天列表内容,并且您将无法在 Telegram 中截图。\n\n您可能需要重新启动应用程序才能生效。"; +"ScreenCaptureHeader" = "任务切换器中的 Telegram 应用内容"; +"ScreenCaptureInfo" = "停用后,您将无法在应用内截图,并且聊天内容不会在任务切换器中显示。"; +"ScreenCaptureShowContent" = "显示内容"; +"SdCard" = "SD 卡"; +"SdCardAlert" = "存储在 SD 卡上会使 Telegram 变慢。是否继续?"; +"SdCardError" = "SD 卡错误"; +"SdCardErrorDescription" = "Telegram 无法将数据保存至 SD 卡。"; +"Search" = "搜索"; +"SearchAd" = "广告"; +"SearchAdsAbout1Subtitle" = "此类广告不会使用您的个人信息,仅基于您输入的搜索关键词显示。"; +"SearchAdsAbout1Title" = "尊重您的隐私"; +"SearchAdsAbout2Subtitle" = "您可以通过订阅 Telegram 高级版来关闭广告。 **订阅 >**"; +"SearchAdsAbout2SubtitlePremium" = "您可以通过订阅 Telegram 高级版并关闭广告选项来移除广告。**移除广告 >**"; +"SearchAdsAbout2Title" = "可以移除"; +"SearchAdsAboutLaunchLearnMore" = "**了解更多 >**"; +"SearchAdsAboutLaunchSubtitle" = "任何人都可以创建广告并在任何搜索查询的结果中显示,查看 **Telegram 广告平台** 了解详情。%1$s"; +"SearchAdsAboutLaunchTitle" = "我可以发布我的广告吗?"; +"SearchAdsAboutSubtitle" = "Telegram 广告与其他平台上的广告大有不同。例如此广告:"; +"SearchAdsAboutTitle" = "关于这些广告"; +"SearchAdsAboutUnderstood" = "知道了"; +"SearchAllChatsShort" = "聊天"; +"SearchApps" = "机器人"; +"SearchAppsExamples" = "例子"; +"SearchAppsMine" = "您用过的应用"; +"SearchAppsPopular" = "畅销应用"; +"SearchAsChat" = "以聊天形式查看"; +"SearchAsList" = "以列表显示"; +"SearchBackgrounds" = "搜索图片"; +"SearchByColor" = "按颜色搜索"; +"SearchChannels" = "频道"; +"SearchChats" = "搜索聊天"; +"SearchContacts" = "搜索联系人"; +"SearchCountriesSelect" = "选择"; +"SearchCountriesTitle" = "全局搜索"; +"SearchEmojiHint" = "搜索表情"; +"SearchEmptyViewDownloads" = "最近没有下载"; +"SearchEmptyViewFilteredSubtitle2" = "请再试搜索"; +"SearchEmptyViewFilteredSubtitleFiles" = "您所有聊天中的文件都会在这里显示。"; +"SearchEmptyViewFilteredSubtitleLinks" = "您所有聊天中的链接都会在这里显示。"; +"SearchEmptyViewFilteredSubtitleMedia" = "您所有聊天中的媒体文件都会在这里显示。"; +"SearchEmptyViewFilteredSubtitleMusic" = "您所有聊天中的音频都会在这里显示。"; +"SearchEmptyViewFilteredSubtitleVoice" = "您所有聊天中的语音信息都会在这里显示。"; +"SearchEmptyViewTitle" = "无结果"; +"SearchEmptyViewTitle2" = "无结果"; +"SearchEngine" = "搜索引擎"; +"SearchEngine1Name" = "Google"; +"SearchEngineInfo" = "在浏览器搜索框中搜索並自动完成"; +"SearchForChats" = "查找聊天……"; +"SearchForPeople" = "搜索用户"; +"SearchForPeopleAndGroups" = "搜索用户和群组…"; +"SearchFriends" = "搜索联系人"; +"SearchFrom" = "来自:"; +"SearchGifs" = "查找 GIF"; +"SearchGifsTitle" = "搜索 GIF"; +"SearchHashtagsHint" = "搜索标签"; +"SearchIconsHint" = "搜索图标"; +"SearchImages" = "查找图片"; +"SearchImagesInfo" = "通过网络搜索"; +"SearchImagesTitle" = "通过网络搜索"; +"SearchImagesType" = "输入并搜索。"; +"SearchInSettings" = "搜索设置和常见问题"; +"SearchMemberRequests" = "搜索加入请求"; +"SearchMembers" = "搜索成员"; +"SearchMessages" = "消息"; +"SearchMessagesFilterAll" = "所有聊天"; +"SearchMessagesFilterAllFrom" = "所有聊天"; +"SearchMessagesFilterChannels" = "频道"; +"SearchMessagesFilterChannelsFrom" = "频道"; +"SearchMessagesFilterEmptySearchAll" = "在所有聊天中搜索"; +"SearchMessagesFilterEmptyText" = "搜索 \"%s\" 没有结果。\n请重新尝试。"; +"SearchMessagesFilterEmptyTitle" = "无结果"; +"SearchMessagesFilterGroup" = "群聊"; +"SearchMessagesFilterGroupFrom" = "群组"; +"SearchMessagesFilterPrivate" = "私聊"; +"SearchMessagesFilterPrivateFrom" = "私聊"; +"SearchMessagesIn" = "%s 中的消息"; +"SearchMessagesResultCount#other" = "%2$s 个结果"; +"SearchMusic" = "查找音乐"; +"SearchMyChannels" = "您加入的频道"; +"SearchMyMessages" = "我的聊天"; +"SearchPaidStars#other" = "额外搜索将花費 **%d 颗星星**。"; +"SearchPeopleByUsername" = "按用户名搜索人员"; +"SearchPosts" = "帖子"; +"SearchPostsButton" = "搜索"; +"SearchPostsButtonPay#other" = "花费 ⭐️%d 搜索"; +"SearchPostsButtonPremium" = "订阅 Telegram 高级版"; +"SearchPostsFreeSearchUnlocksIn" = "%s 后解锁免费搜索"; +"SearchPostsFreeSearches#other" = "今天还剩下 %d 次免费搜索。"; +"SearchPostsHeaderFound" = "公开帖子"; +"SearchPostsHeaderNews" = "Telegram 新闻"; +"SearchPostsLimitReached" = "已达上线"; +"SearchPostsLimitReachedText#other" = "您每天只可以进行 %d 次搜索"; +"SearchPostsNotFound" = "搜索无结果"; +"SearchPostsNotFoundText" = "未找到有关 \"%s\" 的贴文。"; +"SearchPostsPremium" = "全局搜索是高级版的特权。"; +"SearchPostsText" = "输入关键词即可在所有公开帖子中搜索"; +"SearchPostsTitle" = "全局搜索"; +"SearchPublicPosts" = "公开帖子"; +"SearchReactionsHint" = "搜索表情回应"; +"SearchRecentFiles" = "查找最近文件"; +"SearchRecommendedChannels" = "相似的频道"; +"SearchStickersHint" = "搜索贴纸"; +"SearchThisChat" = "此聊天"; +"SearchTipToday" = "今天"; +"SearchTipYesterday" = "昨天"; +"SearchTopics" = "搜索话题"; +"SearchTrendingStickersHint" = "搜索热门表情包"; +"Seconds#other" = "%1$d 秒"; +"SecondsBold#other" = "**%1$d** 秒"; +"SecondsSchedule#other" = "%1$d 秒"; +"SecretChat" = "私密聊天"; +"SecretChatContextBotAlert" = "请注意,机器号服务都是第三方开发者提供的。 一旦开始工作,您在机器号名字后面输入的字符都将发送给机器号的开发者。"; +"SecretChatName" = "Telegram"; +"SecretChatTimerDays" = "天"; +"SecretChatTimerHours" = "小时"; +"SecretChatTimerMinutes" = "分"; +"SecretChatTimerMonths" = "月"; +"SecretChatTimerSeconds" = "秒"; +"SecretChatTimerWeeks" = "周"; +"SecretChatTimerYears" = "年"; +"SecretLinkPreviewAlert" = "您要启用私密聊天中的链接预览功能吗?注意,链接预览将在 Telegram 的服务器上生成。"; +"SecretWebPage" = "链接预览"; +"SecretWebPageInfo" = "链接预览将在 Telegram 服务器上生成,我们不会储存与您所发送的链接有关的任何数据。"; +"SecurityTitle" = "安全"; +"SeekSpeedHint" = "向侧边滑动即可调整速度。"; +"SegmentationCutObject" = "剪出对象"; +"SegmentationCutOut" = "剪切"; +"SegmentationErase" = "擦除"; +"SegmentationOutline" = "添加轮廓"; +"SegmentationRestore" = "恢复"; +"SegmentationTabToCrop" = "点击对象进行裁剪"; +"SegmentationUndo" = "撤销"; +"SegmentationUndoCutOut" = "剪切"; +"Select" = "选择"; +"SelectAccount" = "选择账号"; +"SelectAll" = "全选"; +"SelectChat" = "选择聊天"; +"SelectChats" = "选择聊天"; +"SelectChatsForAutoDelete" = "应用自毁定时器"; +"SelectChatsForAutoDelete2" = "将具有自毁计时器"; +"SelectChatsForDisableAutoDelete" = "以停用自毁计时器"; +"SelectChatsForDisableAutoDelete2" = "将停用自毁计时器"; +"SelectColor" = "选择颜色"; +"SelectColorTitle" = "选择颜色"; +"SelectContact" = "选择联系人"; +"SelectContacts" = "选择联系人"; +"SelectDays" = "选择日期"; +"SelectDaysTooltip" = "请先选择一天或多天。"; +"SelectEmoji" = "选择表情符号"; +"SelectFile" = "选择文件"; +"SelectFromGallery" = "从相册中选择"; +"SelectImage" = "选择图片"; +"SelectOtherChat" = "选择其他聊天"; +"SelectSendAsPeerPremiumHint" = "订阅** Telegram 高级版**后可以在任何群聊中代表您的频道发布评论。"; +"SelectSendAsPeerPremiumOpen" = "打开"; +"SelectSpecificQuote" = "选择特定引文片段"; +"SelectSticker" = "选择贴纸"; +"SelectTheme" = "选择主题"; +"SelectThisDay" = "选择该日期内的所有消息"; +"SelectTopic" = "选择话题"; +"SelectTopicIconHint" = "选择话题图标"; +"SelectingContact" = "选择要分享的联系人中..."; +"SelectingLocation" = "分享位置中……"; +"SelfStarsOverviewInfo" = "你总余额中的星星将在获得 21 天后可用。"; +"SelfStarsWithdrawInfo#other" = "你可以通过 Fragment 提取 %d 颗星星。 **了解更多 >**"; +"SelfStoryTitle" = "我的动态"; +"Send" = "发送"; +"SendAGift" = "发送礼物"; +"SendAnonymously" = "匿名发送"; +"SendAnotherGift" = "再送一份礼物"; +"SendAsFile" = "作为文件发送"; +"SendAsFiles" = "作为文件发送"; +"SendAsNewPhoto" = "发送一张新图片"; +"SendBotNoThread" = "非话题消息"; +"SendByEnter" = "使用回车键发送消息"; +"SendContactTo" = "是否将联系人信息发送给 %1$s?"; +"SendContactToGroup" = "是否将联系人信息发送至 %1$s?"; +"SendContactToGroupText" = "您要把此联系人信息发送给 **%1$s** 吗?"; +"SendContactToText" = "您要把此联系人信息发送给 **%1$s** 吗?"; +"SendDayAt" = "'在' MMM d HH:mm ‘发送’"; +"SendDayYearAt" = "yyyy'年'MM'月'd'日' HH'时'mm'分' '发送'"; +"SendDice" = "发送"; +"SendEmojiPreview" = "发送表情"; +"SendGameTo" = "分享游戏给“%1$s”?"; +"SendGameToGroup" = "分享游戏到«%1$s»?"; +"SendGameToGroupText" = "你想要将该游戏分享给 **%1$s** 吗?"; +"SendGameToText" = "你要把此游戏分享给**%1$s**吗?"; +"SendGifPreview" = "发送 GIF"; +"SendGiftTo" = "给 %s 送礼物"; +"SendInHighQuality" = "以高清质量发送"; +"SendInStandardQuality" = "以标准质量发送"; +"SendInviteLink" = "发送邀请链接"; +"SendItems" = "发送 %1$s"; +"SendLiveLocation" = "共享我的实时位置,持续时间…"; +"SendLiveLocationFor15m" = "15 分钟"; +"SendLiveLocationFor1h" = "1 小时"; +"SendLiveLocationFor8h" = "8 小时"; +"SendLiveLocationForever" = "直到我关闭"; +"SendLiveLocationInfo" = "在您移动时实时更新"; +"SendLiveLocationMenu" = "分享我的实时位置"; +"SendLocation" = "发送我当前的位置"; +"SendMediaEmbededLinks" = "嵌入链接预览"; +"SendMediaPermissionFiles" = "文件"; +"SendMediaPermissionMusic" = "音乐"; +"SendMediaPermissionPhotos" = "图片"; +"SendMediaPermissionRound" = "视频消息"; +"SendMediaPermissionStickersGifs" = ""; +"SendMediaPermissionVideos" = "视频"; +"SendMediaPermissionVoice" = "语音消息"; +"SendMediaPolls" = "投票"; +"SendMessage" = "发送消息"; +"SendMessageAsTitle" = "以哪个身份发送消息…"; +"SendMessageLocation" = "发送消息"; +"SendMessageRestricted" = "该群组的管理员限制了您在 %1$s 之前发送消息。"; +"SendMessageRestrictedForever" = "此群组的管理员已限制您发送消息。"; +"SendMessageTitle" = "发送消息"; +"SendMessageTo" = "您想将谁加入群组?"; +"SendMessagesTo" = "是否发送给 %1$s?"; +"SendMessagesToGroup" = "是否发送给 %1$s?"; +"SendMessagesToGroupText" = "您要把这条消息发送给 **%1$s** 吗?"; +"SendMessagesToText" = "您要把这条消息发送给 **%1$s** 吗?"; +"SendPlainTextRestrictionHint" = "此群组的管理员仅允许发送 %s。"; +"SendReactionsIsRestrictedInThisChat" = "在此聊天中您无法发送表情回应。"; +"SendSelectedLocation" = "发送选中的位置"; +"SendSticker" = "发送表情"; +"SendStickerPreview" = "发送贴纸"; +"SendTodayAt" = "'今天' HH:mm ‘发送'"; +"SendWhenOnline" = "在线时发送"; +"SendWithoutCompression" = "无压缩发送"; +"SendWithoutGrouping" = "单独发送"; +"SendWithoutSound" = "无声发送"; +"SendingAudio" = "正在发送音频…"; +"SendingFile" = "正在发送文件…"; +"SendingGame" = "正在玩游戏…"; +"SendingGif" = "正在发送 GIF…"; +"SendingPhoto" = "正在发送图片…"; +"SendingSms" = "正在发送短信..."; +"SendingVideo" = "正在发送视频…"; +"SendingVideoStatus" = "正在发送视频…"; +"SensitiveContentSettingsToast" = "您可以在**聊天设置 > 显示 18+ 内容**中更新敏感媒体的可见性"; +"SentAppCode" = "我们已向您其它设备上的 **Telegram** 应用发送了一条验证码消息。"; +"SentAppCodeTitle" = "查看您的 Telegram 消息"; +"SentAppCodeWithPhone" = "我们已经将验证码发送到该帐号(%1$s)其他设备上的 **Telegram应用**。"; +"SentCallCode" = "我们正在呼叫您的手机(**%1$s**)。\n\n请不要接听,Telegram 将会自动处理接下来的事情。"; +"SentCallOnly" = "我们正在呼叫您的手机(**%1$s**)以播放验证码。"; +"SentFragmentCode" = "在 Fragment 的号码板块获取 %1$s 的验证码。"; +"SentSmsCode" = "我们已经向您的手机号码(**%1$s**)发送了一条带有激活验证码的短信。"; +"SentSmsCodeTitle" = "输入验证码"; +"September" = "九月"; +"ServerErrorViewers" = "未记录访客信息。"; +"ServerErrorViewersFull" = "未记录其他访客的信息。"; +"ServerErrorViewersTitle" = "哎呀"; +"ServiceNotifications" = "服务通知"; +"SessionBot" = "自动聊天机器人"; +"SessionBotConnectedFrom" = "来自"; +"SessionBotConnectedOn" = "已连接于 %s"; +"SessionBotDate" = "日期"; +"SessionBotDevice" = "设备"; +"SessionBotLocation" = "位置"; +"SessionsInfo" = "管理您在其它设备上的会话。"; +"SessionsListInfo" = "Telegram 官方应用可用于 iPhone、iPad、Android、macOS、Windows 和 Linux。"; +"SessionsSelfDestruct" = "会话自毁"; +"SessionsSettingsInfo" = "请检查你目前已登录 Telegram 账号的设备列表。"; +"SessionsTitle" = "设备"; +"Set" = "设置"; +"SetAdditionalPassword" = "设置两步验证密码"; +"SetAdditionalPasswordInfo" = "除了通过短信获得的验证码外,您还可以设置登录新设备时需要的密码。"; +"SetAdmins" = "添加管理员"; +"SetAdminsAll" = "将所有成员都设为管理员"; +"SetAdminsAllInfo" = "所有成员都能邀请新成员、置顶消息、编辑群组名称和头像。"; +"SetAdminsNotAllInfo" = "只有管理员有权添加或移除成员、置顶消息、编辑群组信息。"; +"SetAdminsTitle" = "管理员"; +"SetAsAdmin" = "设为管理员"; +"SetAsEmojiStatus" = "设为当前账号状态"; +"SetAsEmojiStatusInfo" = "设置表情符号状态。"; +"SetAsGroupEmojiPackSet" = "设置群组表情包"; +"SetAsGroupStickerSet" = "设置为群组贴纸包"; +"SetAsMain" = "设为主头像"; +"SetAsMyPhoto" = "设为我的头像"; +"SetAutoDeleteTimer" = "设置自动删除"; +"SetBackground" = "设置壁纸"; +"SetBackgroundFromGallery" = "从相册中选择壁纸"; +"SetChannelPhoto" = "设置频道照片"; +"SetColor" = "设置颜色"; +"SetColorAsBackground" = "将颜色设置为壁纸"; +"SetCover" = "为此头像选择封面帧"; +"SetCustomTime" = "设置自定义时间"; +"SetEmojiStatus" = "设置表情符号状态"; +"SetEmojiStatusUntil1Hour" = "设置为 1 小时"; +"SetEmojiStatusUntil2Days" = "设置为 2 天"; +"SetEmojiStatusUntil2Hours" = "设置为 2 小时"; +"SetEmojiStatusUntil8Hours" = "设置为 8 小时"; +"SetEmojiStatusUntilButton" = "设置状态"; +"SetEmojiStatusUntilOther" = "其他…"; +"SetEmojiStatusUntilTitle" = "该状态持续至…"; +"SetGroupPhoto" = "设置群组照片"; +"SetIntroSticker" = "设置简介贴纸"; +"SetMyProfilePhotoAvatarConstructor" = "设置我的头像"; +"SetNewPassword" = "设置新密码"; +"SetPasscode" = "设置锁定码"; +"SetPasscodeInfo" = "使用锁定码锁定应用程序以禁止他人打开。"; +"SetPhoto" = "设为头像"; +"SetPhotoFor" = "为 %s 设置头像"; +"SetPhotoForRest" = "设置公开照片"; +"SetProfilePhoto" = "设置头像"; +"SetProfilePhotoAvatarConstructor" = "设置头像"; +"SetProfileVideo" = "设置为头像"; +"SetRecoveryEmail" = "设置密码恢复电子邮箱"; +"SetReminder" = "设置提醒"; +"SetSuggestedPhotoTooltip" = "%s 推荐将此图片用做您的 Telegram 头像。"; +"SetSuggestedVideoTooltip" = "\n%s 推荐将此图片用做您的 Telegram 头像。"; +"SetThisLocation" = "设置此位置"; +"SetTimeLimit" = "设置时间限制"; +"SetTimer" = "设置阅后即焚计时器"; +"SetUrlAvailable" = "%1$s 可用。"; +"SetUrlChecking" = "正在检查链接……"; +"SetUrlInUse" = "抱歉,此链接被占用了。"; +"SetUrlInvalid" = "抱歉,此链接不符合规范。"; +"SetUrlInvalidLong" = "链接不得超过 64 个字符。"; +"SetUrlInvalidShort" = "链接最短为 **5** 个字符"; +"SetUrlInvalidStartNumber" = "抱歉,链接不能以数字开头。"; +"SetUrlPlaceholder" = "链接"; +"SetUserPhotoAlertMessage" = "是否将此图片设置为 **%1$s** 的头像?该头像将替代 %2$s 设置的其他头像——只有您将会看到该头像。"; +"SetUserPhotoSelfAlertMessage" = "你确定要使用这个头像吗?"; +"SetUsernameHeader" = "设置用户名"; +"SetVideo" = "设置图片"; +"SetWallpapers" = "更改壁纸"; +"Settings" = "设置"; +"SettingsAccount" = "账号"; +"SettingsAccountInfo" = "电话、用户名与简介"; +"SettingsAccounts" = "账号"; +"SettingsBrowseThemes" = "浏览其他主题"; +"SettingsChat" = "聊天设置"; +"SettingsChatInfo" = "壁纸、夜间模式与动画效果"; +"SettingsData" = "数据与存储"; +"SettingsDataInfo" = "媒体下载设置"; +"SettingsDebug" = "调试"; +"SettingsDevices" = "设备"; +"SettingsDevicesInfo" = "管理已连接的设备"; +"SettingsFaqSearchTitle" = "常见问题"; +"SettingsFolders" = "聊天文件夹"; +"SettingsFoldersInfo" = "将聊天分类到文件夹中"; +"SettingsHelp" = "帮助"; +"SettingsLanguage" = "语言"; +"SettingsNoRecent" = "最近没有搜索"; +"SettingsNoResults" = "未找到结果"; +"SettingsNotifications" = "通知"; +"SettingsNotificationsInfo" = "声音、通话、标记"; +"SettingsPowerSaving" = "省电"; +"SettingsPowerSavingInfo" = "低电量时降低功耗"; +"SettingsPrivacySecurity" = "隐私与安全"; +"SettingsPrivacySecurityInfo" = "最后上线时间、登录设备、通行密钥"; +"SettingsRecent" = "最近的搜索"; +"SettingsSearchFaq" = "常见问题"; +"SettingsSwitchToDayMode" = "切换至日间模式"; +"SettingsSwitchToNightMode" = "切换到夜间模式"; +"Shadows" = "阴影"; +"ShadyJoining" = "正在加入..."; +"ShadyLeaving" = "正在退出..."; +"ShareComment" = "添加评论…"; +"ShareContact" = "分享联系人"; +"ShareContactTitle" = "分享联系人"; +"ShareFile" = "分享"; +"ShareLink" = "分享链接"; +"ShareLinks" = "分享链接"; +"ShareLocation" = "位置"; +"ShareLocationAlertButton" = "共享"; +"ShareLocationAlertText" = "请在此聊天中共享实时位置以保证通知生效。"; +"ShareLocationAlertTitle" = "共享位置"; +"ShareMyContactInfo" = "分享我的联系信息"; +"ShareMyPhone" = "分享我的手机号码"; +"ShareMyPhoneNoCaps" = "分享我的手机号码"; +"SharePhoneNumberWith" = "向 %1$s 分享我的手机号码"; +"ShareQrCode" = "分享二维码"; +"ShareSendItems#other" = "发送 %1$d 个媒体"; +"ShareSendPhoto" = "发送照片"; +"ShareSendPhotos#other" = "发送 %1$d 张照片"; +"ShareSendTo" = "发送给…"; +"ShareSendToChats" = "至 %s"; +"ShareSendToMany#other" = "至 %1$d 个聊天"; +"ShareSendToNewTopic" = "新话题"; +"ShareSendToOffTopic" = "离题"; +"ShareSendVideo" = "发送视频"; +"ShareSendVideos#other" = "发送 %1$d 个视频"; +"ShareTapToEditMedia" = "点击此处编辑媒体"; +"ShareTelegram" = "分享 Telegram…"; +"ShareTelegram2" = "分享 Telegram"; +"ShareTheme" = "分享主题"; +"ShareVideo" = "分享摄像头画面"; +"ShareYouLocationInfo" = "此操作会把您当前的位置信息发送给机器人。"; +"ShareYouLocationInline" = "机器人需要在您每次向其发送请求时获取您的位置信息,以便提供基于位置的查询结果。"; +"ShareYouLocationTitle" = "是否共享您的位置信息?"; +"ShareYouLocationUnable" = "应用无法确认您当前的位置信息"; +"ShareYouLocationUnableManually" = "手动选择"; +"ShareYouPhoneNumberTitle" = "是否分享您的手机号码?"; +"Shared#other" = "%1$s 次分享"; +"SharedAudioFiles" = "音频文件"; +"SharedContactAdd" = "添加"; +"SharedContactMessage" = "消息"; +"SharedContent" = "分享的内容"; +"SharedContentTitle" = "共享的内容"; +"SharedFilesTab" = "文件"; +"SharedFilesTab2" = "文件"; +"SharedGIFsTab2" = "GIF"; +"SharedGroupsTab2" = "群组"; +"SharedLinks" = "链接"; +"SharedLinksTab" = "链接"; +"SharedLinksTab2" = "链接"; +"SharedMedia" = "分享的媒体文件"; +"SharedMediaFastScrollHint" = "您可以按住并上下滑动滚动条以快速滚动。"; +"SharedMediaTab" = "媒体"; +"SharedMediaTab2" = "媒体"; +"SharedMediaTabFull2" = "分享的媒体"; +"SharedMediaTitle" = "分享的媒体文件"; +"SharedMusicTab" = "音乐"; +"SharedMusicTab2" = "音频"; +"SharedPhotosAndVideos" = "图片和视频"; +"SharedPlace" = "地点"; +"SharedPollTab" = "投票"; +"SharedToPrivateMessagesAndGroups" = "给私密消息和群组"; +"SharedVoiceTab" = "语音"; +"SharedVoiceTab2" = "语音"; +"Shares#other" = "%1$s 次分享次数"; +"SharesPerPost" = "每则贴文的分享次数"; +"SharesPerStory" = "每条动态的分享次数"; +"SharingLiveLocation" = "正在共享位置..."; +"SharingLiveLocationAdd" = "点击以延长时间"; +"SharingLiveLocationTitle" = "您正在 %1$s 个聊天中共享实时位置"; +"SharingOfferCancelYes" = "是"; +"SharingOfferDisable1" = "\\> 禁止转发消息"; +"SharingOfferDisable2" = "\\> 禁止保存图片或视频"; +"SharingOfferDisable3" = "\\> 禁止复制消息"; +"SharingOfferDisable4" = "\\> 禁止截屏"; +"SharingOfferDisableCancelText" = "您确定要继续禁用分享功能吗?"; +"SharingOfferDisableCancelTitle" = "禁用截屏"; +"SharingOfferDisableConfirmText" = "您确定想要禁用分享吗?"; +"SharingOfferDisableHeaderOther" = "**%s** 想要禁用此聊天中的分享功能,其中包括:"; +"SharingOfferDisableHeaderShortOther" = "**%s** 想要停用此聊天中的分享功能"; +"SharingOfferDisableHeaderShortYou" = "您已建议在此聊天中关闭分享功能"; +"SharingOfferDisableHeaderYou" = "您建议停用此聊天中的分享功能,其中包括:"; +"SharingOfferEnable1" = "\\> 转发消息"; +"SharingOfferEnable2" = "\\> 保存图片或视频"; +"SharingOfferEnable3" = "\\> 复制消息"; +"SharingOfferEnable4" = "\\> 截屏"; +"SharingOfferEnableCancelText" = "您确定要继续启用分享吗?"; +"SharingOfferEnableCancelTitle" = "禁用分享"; +"SharingOfferEnableConfirmText" = "您确定要启用分享功能吗?"; +"SharingOfferEnableHeaderOther" = "**%s** 想要禁用此聊天中的分享功能,其中包括:"; +"SharingOfferEnableHeaderShortOther" = "**%s** 希望在此聊天中开启分享功能"; +"SharingOfferEnableHeaderShortYou" = "您已建议在此聊天中开启分享功能"; +"SharingOfferEnableHeaderYou" = "您建议在此聊天中开启分享功能,其中包括:"; +"SharingYouAndOtherName" = "您和 %1$s"; +"Sharpen" = "锐化"; +"Short" = "短"; +"ShortHighQuality" = "高清"; +"ShortHoursAgo#other" = "%d 小时"; +"ShortMessageLifetimeForever" = "关闭"; +"ShortMinutesAgo#other" = "%d 分钟"; +"ShortNow" = "刚刚"; +"ShortNumberInfo" = "你提供的号码对于 **%1$s** 来说过短:\n\n**%2$s**\n\n请输入正确的号码,包括你的国家代码。"; +"ShortToday" = "今天"; +"ShortYesterday" = "昨天"; +"Show" = "显示"; +"ShowAds" = "不要隐藏广告"; +"ShowAdsInfo" = "作为高级版订阅用户,您在 Telegram 上不会看到任何广告,但您可以选择开启它们,例如查看您自己在 **Telegram 广告平台 >** 上发布的广告。"; +"ShowAdsTitle" = "频道中的广告"; +"ShowAllFiles" = "显示所有文件"; +"ShowAllGIFs" = "显示所有 GIF "; +"ShowAllMedia" = "显示所有媒体"; +"ShowAllThemes" = "显示所有主题"; +"ShowAsGrid" = "以表格显示"; +"ShowAsList" = "以列表显示"; +"ShowCaption" = "显示说明"; +"ShowForwardingOptions" = "显示转发选项"; +"ShowInChat" = "在聊天中查看"; +"ShowInChats" = "在聊天中显示"; +"ShowLess" = "显示更少"; +"ShowMore" = "显示更多"; +"ShowMoreEmojiPacks" = "查看更多表情"; +"ShowMoreStickers" = "查看更多贴纸"; +"ShowNearbyPlaces" = "向上拖动查看周边地点"; +"ShowNotificationsFor" = "显示通知"; +"ShowNotificationsForInfo" = "如果您只想从正在使用的账号接收通知,请将其关闭。"; +"ShowOriginalButton" = "显示原文"; +"ShowOriginalButtonLanguage" = "显示原文 (%s)"; +"ShowSenderNames" = "显示发送者名称"; +"ShowSendersName" = "显示发送者名称"; +"ShowSensitiveContent" = "显示 18+ 内容"; +"ShowSensitiveContentInfo" = "不隐藏包含仅适合成人观看的媒体"; +"ShowStickers" = "显示贴纸"; +"ShowTranslateButton" = "显示“翻译”按钮"; +"ShowTranslateChatButton" = "翻译全部聊天记录"; +"ShowTranslateChatButtonLocked" = "订阅 **Telegram 高级版**即可一次性翻译聊天中的所有消息。"; +"ShowVotes#other" = "查看更多 (%1$d)"; +"Shuffle" = "随机"; +"ShuffleList" = "列表随机播放"; +"SignInWithGoogle" = "使用 Google 登录"; +"SignUp" = "注册"; +"SimilarBots" = "相似机器人"; +"SimilarBotsTab" = "相似机器人"; +"SimilarChannels" = "相似频道"; +"SimilarChannelsLimitSubtitle" = "查看最多 %d 个相似频道"; +"SimilarChannelsLimitTitle" = "相似频道"; +"SimilarChannelsTab" = "相似频道"; +"SinglePhotosHelp" = "将媒体文件分开发送"; +"SlideToCancel" = "滑动以取消"; +"SlideToCancel2" = "滑动以取消"; +"SlideUpToLock" = "往上滑即可锁定录音"; +"SlowModeHint" = "慢速模式已启用,您可以在 %1$s 后发送下一条消息。"; +"Slowmode" = "慢速模式"; +"SlowmodeHours" = "%1$d 小时"; +"SlowmodeInfoOff" = "选择每个成员必须等待多长时间才能发送下一条消息。"; +"SlowmodeInfoSelected" = "成员每 %1$s 只能发送一条消息。"; +"SlowmodeMinutes" = "%1$d 分钟"; +"SlowmodeOff" = "关闭"; +"SlowmodeSeconds" = "%1$d 秒"; +"SlowmodeSelectSendError" = "慢速模式已启用。你不能选择多项。"; +"SlowmodeSendError" = "慢速模式已启用,您每次只能发送一条消息。"; +"SlowmodeSendErrorTooLong" = "由于文本过长,您无法发送此消息。\n\n慢速模式已启用,您每次只能发送一条消息。"; +"SmartNotifications" = "智能通知"; +"SmartNotificationsAlert" = "响铃频率"; +"SmartNotificationsDetail" = "%2$s 内提醒 %1$s"; +"SmartNotificationsDisabled" = "停用"; +"SmartNotificationsInfo" = "%2$s %1$d 次"; +"SmsActiveSim" = "启用的 SIM 卡"; +"SmsAirplaneMode" = "飞行模式"; +"SmsAllowInternational" = "也发送短信至我国之外"; +"SmsAvailableIn" = "你可以在 %1$d:%2$02d 请求发送短信"; +"SmsAvailableIn2" = "您可以在 %1$d:%2$02d 后\n再次请求另一个短信"; +"SmsCostsInfo" = "请参阅您的手机移动套餐,了解可能产生的短信费用"; +"SmsDeactivate" = "停用短信兑换高级版功能"; +"SmsDeactivateMessage" = "您确定要退出该计划吗?"; +"SmsDeactivateTitle" = "停用短信兑换高级版"; +"SmsDescrRESULT_ERROR_NO_SERVICE" = "您的设备尚未与行动网络链接。"; +"SmsDescrRESULT_ERROR_RADIO_OFF" = "您的设备已处于飞行模式。"; +"SmsDescrRESULT_RIL_NETWORK_ERR" = "网络错误,请确保您的 SIM 卡已启用。"; +"SmsHistory" = "SMS 记录"; +"SmsHistoryDateCountry" = "日期与国家"; +"SmsHistoryStatus" = "状态"; +"SmsHistoryStatusFailure" = "失败"; +"SmsHistoryStatusSuccess" = "已发送"; +"SmsHistorySubtitle" = "您可以在这里查看从您的设备自动发送的所有短信。"; +"SmsHistoryTitle" = "短信发送历史记录"; +"SmsJobsErrorHintMessage" = "查看短信历史记录以排除故障"; +"SmsJobsErrorHintTitle" = "部分短信未发送成功"; +"SmsJobsMenu" = "短信统计"; +"SmsJobsPremiumHintMessage" = "帮助 Telegram 发送短信代码并获得免费 Telegram 高级版订阅。"; +"SmsJobsPremiumHintTitle" = "**免费** Telegram 高级版订阅"; +"SmsLastGiftLink" = "上次收到礼物链接"; +"SmsNoSimMessage" = "将 SIM 卡插入设备並返回**短信统计**。"; +"SmsNoSimTitle" = "没有 SIM 卡"; +"SmsOverview" = "概况"; +"SmsPermissionText" = "请允许 Telegram 从您的设备发送短信,以加入 **短信兑换高级版** 计划"; +"SmsPermissionTextPhoneSettings" = "要加入 **短信兑换高级版** 计划,请允许 Telegram 读取手机 SIM 卡信息。\n\n点击 “设置” > “权限”,打开 **手机** 权限。"; +"SmsPermissionTextSMSPhoneSettings" = "要加入 *短信兑换高级版** 计划,请允许 Telegram 发送短信和读取手机 SIM 卡信息。\n\n点击设置 > 权限,打开 **短信** 和 **电话** 权限。"; +"SmsPermissionTextSMSSettings" = "要加入 **Premium for SMS** 计划,请允许 Telegram 从您的设备发送短信。\n\n点击 “设置” > “权限”,打开 **短信** 权限。"; +"SmsPremiumActivated" = "已激活短信兑换高级版!"; +"SmsPremiumActivatedText" = "Telegram 现在将尝试使用您的设备在您的国家(**%s**)自动发送短信验证码及密语。"; +"SmsPremiumActivatedText2" = "查看左侧菜单中的 **短信统计**,了解已发送短信的数量或更改该程序的设置。"; +"SmsPremiumActivatedTextUnknown" = "Telegram 现在将尝试使用您的设备在您的国家自动发送短信验证码及密语。"; +"SmsPremiumBenefits" = "高级版利益"; +"SmsRemaining" = "剩余短信数"; +"SmsSelectSim" = "选择 SIM 卡"; +"SmsSentSince" = "自发送以来"; +"SmsStartDate" = "开始日期\n"; +"SmsStatusDone" = "已发送所有 %d 条短信!"; +"SmsStatusDoneSubtitle" = "Telegram 高级版礼品码已发送給您以供启用。"; +"SmsStatusFirst" = "短信兑换高级版"; +"SmsStatusFirstSubtitle" = "帮助 Telegram 发送登录短信验证码及密语可获取 **Telegram 高级版** 礼物链接。"; +"SmsStatusNoPermission" = "需要访问"; +"SmsStatusNoPermissionSubtitle" = "请在手机设置中允许 Telegram 发送短信,以加入该计划。"; +"SmsStatusNoSim" = "插入一张 SIM 卡"; +"SmsStatusNoSimSubtitle" = "如果要启用这个程序发送短信,请先插入一张 SIM 卡。"; +"SmsStatusSending" = "已发送 %2$d 中的 %1$d 条短信"; +"SmsStatusSendingSubtitle#other" = "只需再发送 **%d** 条短信,即可收到您的 **Telegram 高级版** 赠品链接。"; +"SmsSubscribeAccept" = "我接受 **P2PLP 服务条款**"; +"SmsSubscribeActivate" = "启用"; +"SmsSubscribeActivateText" = "发送短信可能会产生费用。请阅读 **点对点登录项目服务条款**。"; +"SmsSubscribeFeature1Message#other" = "允许 Telegram 使用您的手机每月发送 %d 条登录短信。"; +"SmsSubscribeFeature1Title" = "帮忙发送登录短信"; +"SmsSubscribeFeature2Message" = "每月获取 1 个月的 **高级版订阅**。"; +"SmsSubscribeFeature2Title" = "获取 Telegram 高级版"; +"SmsSubscribeFeature3Message" = "向您的朋友发送高级版订阅的礼物链接。"; +"SmsSubscribeFeature3Title" = "与好友分享 Telegram 高级版"; +"SmsSubscribeMessage" = "仅 **0.01%** 的用户有资格参加 Telegram **短信兑换高级版** 活动,您是其中之一!"; +"SmsSubscribeTitle" = "免费 Telegram 高级版"; +"SmsText" = "我们会在 %1$d:%2$02d 后给您发送短信"; +"SmsToS" = "服务条款"; +"SmsTotalSent" = "已发送消息总数"; +"SoftUserLimitAlert" = "在创建群组后,您将能添加更多成员。"; +"SoftenSkin" = "磨皮"; +"SomeReactions" = "部分表情回应"; +"SortBy" = "排序"; +"SortByReactions" = "表情回应优先"; +"SortByReposts" = "按转发量排序"; +"SortByTime" = "最新优先"; +"SortFirstName" = "名字"; +"SortLastName" = "姓氏"; +"SortedByLastSeen" = "按最后上线时间排序"; +"SortedByName" = "按姓名排序"; +"Sound" = "声音"; +"SoundAdded" = "音频已添加"; +"SoundAddedSubtitle" = "您可以在**自定义通知设置**中将此声音设置为提示音。"; +"SoundDefault" = "默认"; +"SoundMuted" = "静音"; +"SoundNameEmpty" = "日期为 %s 的消息"; +"SoundOff" = "关闭通知音"; +"SoundOffHint" = "您的通知将被静音"; +"SoundOn" = "启用声音"; +"SoundOnHint" = "您将会收到通知音提示"; +"Speaking" = "发言中"; +"SpeakingWithVolume" = "正以 %1$d%% 音量发言"; +"Speed" = "播放速度"; +"SpeedFast" = "快"; +"SpeedHint" = "长按选择其他倍速。"; +"SpeedMedium" = "中速"; +"SpeedNormal" = "正常"; +"SpeedSlow" = "慢"; +"SpeedSuperFast" = "超级快"; +"SpeedVeryFast" = "非常快"; +"SpeedVerySlow" = "非常慢"; +"Spoiler" = "遮罩"; +"SponsoredMessage" = "广告"; +"SponsoredMessage2" = "广告"; +"SponsoredMessage2Recommended" = "推荐"; +"SponsoredMessageAd" = "广告"; +"SponsoredMessageAdWhatIsThis" = "这是什么?"; +"SponsoredMessageAlertLearnMore" = "了解更多"; +"SponsoredMessageAlertLearnMoreUrl" = "https://ads.telegram.org"; +"SponsoredMessageAlertText" = "与其他应用不同,Telegram 不会将您的隐私数据用于投放定向广告。我们不会收集或分析用户数据用于展示广告,所有用户在同一频道中看到的赞助消息都是相同的。我们认为每个人都有隐私权,技术平台应尊重这一权利。"; +"SponsoredMessageAlertTitle" = "赞助消息"; +"SponsoredMessageInfo" = "什么是赞助消息?"; +"SponsoredMessageInfo2Description1" = "与其他应用不同,Telegram 不会将您的隐私数据用于投放定向广告。[查看隐私政策了解更多](https://telegram.org/privacy#5-6-no-ads-based-on-user-data)"; +"SponsoredMessageInfo2Description2" = "与其他应用不同,Telegram 不会追踪您是否点击赞助商消息,也不会基于您的活跃数据生成用户画像。为了避免来自第三方的窥探,我们还禁止在赞助商消息中插入外部链接。我们相信每个人都有保护隐私的权利,而科技平台应该尊重用户的隐私权。"; +"SponsoredMessageInfo2Description3" = "Telegram 向数亿用户提供免费和无限制的服务,这意味着服务器和流量的巨大开销。为了保持独立且始终如一地秉持理念,Telegram 已开发一种付费工具以推广信息,同时保证用户的隐私。我们欢迎负责任的广告商:"; +"SponsoredMessageInfo2Description4" = "广告不应再和滥用用户隐私划等号。让我们共同携手,重新定义科技公司的运营模式。"; +"SponsoredMessageInfoDescription1" = "与其他应用不同,Telegram 不会将您的隐私数据用于投放定向广告。赞助商消息的投放完全基于公开频道内所发布的内容。这意味着 Telegram 不会通过挖掘和分析用户画像来投放广告,查看频道的所有用户都会看到同样的赞助商消息。"; +"SponsoredMessageInfoDescription2" = "与其他应用不同,Telegram 不会追踪您是否点击赞助商消息,也不会基于您的活跃数据生成用户画像。为了避免来自第三方的窥探,我们还禁止在赞助商消息中插入外部链接。我们相信每个人都有保护隐私的权利,而科技平台应该尊重用户的隐私权。"; +"SponsoredMessageInfoDescription3" = "Telegram 向数亿用户提供免费和无限制的服务,这意味着服务器和流量的巨大开销。为了保持独立且始终如一地秉持理念,Telegram 已开发一种付费工具以推广信息,同时保证用户的隐私。我们欢迎负责任的广告商:"; +"SponsoredMessageInfoDescription4" = "赞助商消息目前仍然处于测试阶段。全面启动赞助商消息系统,并且 Telegram 能够平衡运营成本之后,我们将与显示赞助商消息的频道所有者共同分享广告收入。\n\n线上广告不应再和滥用用户隐私划等号。让我们共同携手,重新定义科技公司的运营模式。"; +"SponsoredMessageInfoDescriptionLink" = "与其他应用不同,Telegram 不会将您的隐私数据用于投放定向广告。**查看隐私政策了解更多**"; +"SponsoredMessageRecommended" = "推荐"; +"SponsoredMessageSponsor" = "广告商信息"; +"SponsoredMessageSponsorReportable" = "赞助商信息"; +"StakeDiceActionLost" = "un1 已失去 💎 %s"; +"StakeDiceActionWon" = "un1 已赢得 💎 %s"; +"StakeDiceActionYouLost" = "您已失去 💎 %s"; +"StakeDiceActionYouWon" = "您已赢得 💎 %s"; +"StakeDiceButton" = "\u00a0保存并投掷"; +"StakeDicePlaceholder" = "您的投注"; +"StakeDiceReturns" = "结果与收益"; +"StakeDiceReturnsInfo" = "连续记录将在 3 次 🎲 后或更改投注时重置。"; +"StakeDiceText" = "一款传为等级 5 或以上用户推出的表情符号游戏。在游戏中获得的 💎 仅能用于购买 Telegram 礼物及发布帖子。"; +"StakeDiceTitle" = "表情符号押注"; +"StakeDiceTitleBeta" = "测试版"; +"StakeDiceToast" = "您的投注: 💎 "; +"StakeDiceToastButton" = "发送"; +"StakeDiceToastChange" = "更改"; +"StarAffiliate" = "联盟"; +"StarAffiliateCommission" = "佣金"; +"StarAffiliateMiniApp" = "小程序"; +"StarAffiliateReason" = "事由"; +"StarAffiliateReasonProgram" = "联盟计划"; +"StarAffiliateReferredUser" = "您推荐的用户"; +"StarGiftReason" = "事由"; +"StarGiftReasonOffer" = "礼物报价"; +"StarGiftReasonOfferRefund" = "礼物报价退款"; +"StarGiftReasonPurchase" = "购买礼物"; +"StarGiftReasonRemovedDescription" = "已移除描述"; +"StarGiftReasonSale" = "售出礼物"; +"StarGiftReasonTransfer" = "礼物已转让"; +"StarGiftReasonUpgrade" = "礼物已升级"; +"StarGiftReasonUpgradeView" = "查看"; +"StarGiftTransactionGiftOffer" = "礼物报价"; +"StarGiftTransactionGiftOfferRefund" = "礼物报价退款"; +"StarGiftTransactionGiftPurchase" = "购买礼物"; +"StarGiftTransactionGiftPurchaseRefund" = "购买礼物退款"; +"StarGiftTransactionGiftSale" = "售出礼物"; +"StarGiftTransactionGiftSaleRefund" = "出售礼物退款"; +"StarGiftTransactionGiftTransfer" = "礼物转让"; +"StarGiftTransactionGiftTransferRefund" = "礼物转让已退款"; +"StarGiftUpgradeGiftFrom" = "礼物来自"; +"StarGiveawayGift" = "礼物"; +"StarGiveawayPrizeFrom" = "来自"; +"StarGiveawayPrizeTo" = "到"; +"StarGiveawayReason" = "事由"; +"StarGiveawayReasonLink" = "抽奖活动"; +"StarMediaPurchase" = "媒体购买"; +"StarMySubscriptions" = "我的订阅"; +"StarMySubscriptionsExpand" = "显示更多"; +"StarRatingAdded" = "已享有"; +"StarRatingButtonUnderstood" = "知道了"; +"StarRatingDeduces" = "已扣除"; +"StarRatingDescription" = "此评级反映了 **%s** 在 Telegram 上的活跃度。\n评级基于以下因素:"; +"StarRatingDescription1" = "%s 100% 的星星用于从 Telegram 购买礼物。"; +"StarRatingDescription2" = "%s 20% 的星星用于转售礼物、付费消息和频道帖子。"; +"StarRatingDescription3" = "%s 10 倍的退款星星和 85% 礼物转换为星星的比率。"; +"StarRatingFuture#other" = "此评级将在 %d 天后更新。"; +"StarRatingFuturePendingPoints#other" = "%d 点数待定。"; +"StarRatingFuturePendingPointsAndLevel1#other" = "%d 点数和"; +"StarRatingFuturePendingPointsAndLevel2#other" = "等级 %d 待定。"; +"StarRatingFuturePendingPointsPreview" = "**预览 >**"; +"StarRatingFuturePendingPointsPreviewBack" = "**返回 >**"; +"StarRatingFuturePreview1#other" = "添加 %d 后,\n这是您今天的评级"; +"StarRatingFuturePreview2#other" = "已添加 %d 点数。"; +"StarRatingLevel" = "等级 %d"; +"StarRatingLevelNegative" = "负面评级"; +"StarRatingLevelNegativeOther" = "负面评级表示 **%1$s** 的付款不可靠。"; +"StarRatingLevelNegativeYou#other" = "负面评级表示您的付款不可靠。请花费 **%1$d 颗星星** 来解決此问题。"; +"StarRatingSelfDescription" = "此评级反映了您在 Telegram 上的活跃度。\n评级基于以下因素:"; +"StarRatingTitle" = "评级"; +"StarRatingTitle1" = "来自 Telegram 的礼物"; +"StarRatingTitle2" = "来自用户的礼物和帖子"; +"StarRatingTitle3" = "退款与转换"; +"StarSubscriptionPurchase" = "订阅月费"; +"StarSubscriptionTo" = "到"; +"StarTransactionCommission" = "%s 佣金"; +"Stars#other" = "%d 颗星星"; +"StarsAcquired" = "已获得星星!"; +"StarsAcquiredInfo#other" = "**%d 颗星星** 已添加至您的余额。"; +"StarsAddHighlightedMessage" = "添加 ⭐️%s"; +"StarsBalance" = "余额"; +"StarsBotSubscriptionCompleted" = "已购买订阅!"; +"StarsBotSubscriptionCompletedInfo#other" = "您已在 **%3$s** 订阅 “**%2$s**”,价格为 **%1$d** 颗星星/月。"; +"StarsBuy" = "购买星星"; +"StarsBuyMore" = "购买更多星星"; +"StarsConfirmPurchaseButton#other" = "确认并支付 ⭐️ %d 颗星星"; +"StarsConfirmPurchaseMedia1" = "您是否要花费 **%3$s** 解锁 **%2$s** 中的 %1$s?"; +"StarsConfirmPurchaseMedia2" = "您想以 **%4$s** 解锁 **%3$s** 中的 %1$s 和 %2$s 吗?"; +"StarsConfirmPurchaseMediaBotOne2#other" = "您想以 **%1$d 颗星星**的价格解锁 **%3$s** 中的%2$s吗?"; +"StarsConfirmPurchaseMediaBotTwo2#other" = "您想以 **%1$d 颗星星**的价格解锁 **%4$s** 中的%2$s和%3$s吗?"; +"StarsConfirmPurchaseMediaOne" = "您是否要花费 **%1$d 颗星星** 解锁 **%3$s** 中的 %2$s?"; +"StarsConfirmPurchaseMediaOne2#other" = "您想以 **%1$d 颗星星** 解锁 **%3$s** 中的 %2$s 吗?"; +"StarsConfirmPurchaseMediaTwo" = "您是否要花费 **%1$d 颗星星** 解锁 **%4$s** 中的 %2$s 和 %3$s?"; +"StarsConfirmPurchaseMediaTwo2#other" = "您是否要花费 **%1$d 颗星星** 解锁 **%4$s** 中的 %2$s 和 %3$s?"; +"StarsConfirmPurchaseMedia_Photos#other" = "**%d 张图片**"; +"StarsConfirmPurchaseMedia_SinglePhoto" = "**图片**"; +"StarsConfirmPurchaseMedia_SingleVideo" = "**视频**"; +"StarsConfirmPurchaseMedia_Videos#other" = "**%s 段视频**"; +"StarsConfirmPurchaseTOS" = "购买即表示您同意**服务条款**。"; +"StarsConfirmPurchaseText#other" = "您确定要使用 **%1$d 颗星星** 在 **%3$s** 购买 **%2$s** 吗?"; +"StarsConfirmPurchaseText2#other" = "您想以 **%1$d** 颗星星的价格在 **%3$s** 购买 **%2$s** 吗?"; +"StarsConfirmPurchaseTitle" = "确认购买"; +"StarsConfirmSubscriptionButton#other" = "以每月 ⭐️ %d 颗的价格订阅"; +"StarsConfirmSubscriptionTOS" = "订阅即表示您同意**服务条款**。"; +"StarsConfirmSubscriptionText2#other" = "您是否想以每月 **%1$d** 颗星星的价格订阅 **%3$s** 中的 **%2$s**?"; +"StarsConfirmSubscriptionTitle" = "确认你的订阅"; +"StarsCount#other" = "%d 颗星星"; +"StarsCountX" = "%s 颗星星"; +"StarsFailed" = "失败"; +"StarsGiftCompleted" = "赠礼已送出!"; +"StarsGiftCompletedChannelText#other" = "您向 **%2$s** 赠送了一个价值 **%1$d** 颗星星的礼物。"; +"StarsGiftCompletedText#other" = "您已使用余额中的 **%1$d** 颗星星。"; +"StarsGiftReceived" = "收到礼物"; +"StarsGiftSent" = "送出的礼物"; +"StarsGiftSentPopup" = "已发送星星礼物!"; +"StarsGiftSentPopupInfo#other" = "已将 **%1$d 颗星星** 作为礼物赠送给 %2$s。"; +"StarsGiftUpgradeCompleted" = "礼物升级已发送!"; +"StarsGiftUpgradeCompletedMoreButton" = "赠送更多"; +"StarsGiftUpgradeCompletedText" = "%s 现在可免费升级此礼物。"; +"StarsGiveawayPrizeReceived" = "收到礼物"; +"StarsGiveawaySentPopup" = "抽奖活动已开始!"; +"StarsGiveawaySentPopupInfo#other" = "获奖者将总共收到 **%1$d** 颗星星。"; +"StarsMediaPurchaseCompleted" = "媒体已解锁"; +"StarsMediaPurchaseCompletedInfo#other" = "已向 **%2$s** 转账 **%1$d 颗星星**。"; +"StarsNano" = "星星"; +"StarsNeededBizText" = "购买星星并在 **%s** 和其他商家处使用。"; +"StarsNeededLiveComments" = "购买 **星星** 以在直播动态中高亮留言,并作出星星回应。"; +"StarsNeededRemoveGiftDescription" = "购买 **星星** 来移除礼物的描述。"; +"StarsNeededText" = "购买 **星星** 并在 **%s** 和其他小程序中使用。"; +"StarsNeededTextBuySubscription" = "购买**星星**并订阅**%s**和其他频道。"; +"StarsNeededTextGift" = "购买**星星**以将禮礼物送给 **%s** 和其他联系人。"; +"StarsNeededTextGiftBuyResale" = "充值 **星星** 以购买礼物。"; +"StarsNeededTextGiftChannel" = "购买 **星星** 来向 **%s** 和其他频道赠送礼物。"; +"StarsNeededTextGiftTransfer" = "购买 **星星** 来转让你的礼物。"; +"StarsNeededTextGiftUpgrade" = "购买**星星**来升级你的礼物。"; +"StarsNeededTextKeepBizSubscription" = "购买星星以保留 **%s** 的订阅。"; +"StarsNeededTextKeepBotSubscription" = "购买星星以保留 **%s** 的订阅。"; +"StarsNeededTextKeepSubscription" = "购买**星星**以保持您对 %s 频道的订阅。"; +"StarsNeededTextLink" = "购买**星星**以保持您的频道订阅。"; +"StarsNeededTextLink_subadvance12" = "购买 **星星** 以保持您的订阅在接下来的 12 个月内有效。"; +"StarsNeededTextLink_subs" = "购买**星星**以保持您的频道订阅。"; +"StarsNeededTextPrivateMessage" = "购买**星星**以向 **%s** 以及其他人发送礼物。"; +"StarsNeededTextReactions" = "购买 **星星** 后可在 **%s** 和其他频道中发送付费回应。"; +"StarsNeededTextSearch" = "购买 **星星** 来搜索帖子。"; +"StarsNeededTitle#other" = "需要 %d 颗星星"; +"StarsNotAvailableText" = "抱歉,您所在的国家无法购买星星。"; +"StarsNotAvailableTitle" = "不可用"; +"StarsParticipantSubscription" = "订阅者"; +"StarsParticipantSubscriptionApproxMonth" = "每月约 %1$s "; +"StarsParticipantSubscriptionExpired" = "已到期"; +"StarsParticipantSubscriptionPerMonth" = "每月"; +"StarsParticipantSubscriptionRenews" = "续订"; +"StarsParticipantSubscriptionStart" = "已订阅"; +"StarsPending" = "处理中"; +"StarsPurchaseCompleted" = "购买完成!"; +"StarsPurchaseCompletedInfo#other" = "您已使用 **%1$d 颗星星** 在 **%3$s** 购买了 **%2$s**。"; +"StarsReactionAnonymous" = "匿名"; +"StarsReactionSend" = "发送 ⭐️%s"; +"StarsReactionShowMeInTopSenders" = "在星星打赏榜上显示我"; +"StarsReactionTerms" = "发送星星即表示您同意**服务条款**。"; +"StarsReactionTermsLink" = "https://telegram.org/tos/stars"; +"StarsReactionText" = "选择您想要送出多少颗星星给 **%s** 以支持此贴文。"; +"StarsReactionTextSent#other" = "您已送出 **%1$d** 颗星星以支持此贴文。"; +"StarsReactionTitle" = "以星星做出回应"; +"StarsReactionTitle2" = "星星回应"; +"StarsReactionTop" = "顶部"; +"StarsReactionTopSenders" = "星星打赏榜"; +"StarsReactionTopX#other" = "前 %d"; +"StarsReactionsDisabled" = "已在 **%s** 中停用星星回应。"; +"StarsReactionsSent" = "星星回应"; +"StarsRefunded" = "退款"; +"StarsSentAnonymouslyTitle" = "星星已匿名发送!"; +"StarsSentText#other" = "您以 **%d** 颗星星做出了回应。"; +"StarsSentTitle" = "已发送星星!"; +"StarsSentTitleChannel" = "已以 %s 身分送出星星!"; +"StarsSentUndo" = "撤销"; +"StarsStats" = "统计"; +"StarsSubscribeButton" = "订阅"; +"StarsSubscribeInfo" = "订阅即表示您同意**服务条款**。"; +"StarsSubscribeInfoLink" = "https://telegram.org/tos/stars"; +"StarsSubscribeText#other" = "您想要以每月 **%1$d 颗星星**的价格订阅 **%2$s** 吗?"; +"StarsSubscribeTextTest#other" = "您想要以 **%1$d 颗星星**每%3$s的价格订阅 **%2$s**吗?"; +"StarsSubscribeTitle" = "订阅此频道"; +"StarsSubscriptionAgain" = "再次订阅"; +"StarsSubscriptionBot" = "机器人"; +"StarsSubscriptionBotCancelledText" = "机器人已取消您的订阅。"; +"StarsSubscriptionBotProduct" = "订阅"; +"StarsSubscriptionBotRefulfill" = "续订"; +"StarsSubscriptionBotRefulfillInfo" = "您仍可以在 %s 之前继续订阅。"; +"StarsSubscriptionBusiness" = "商家"; +"StarsSubscriptionBusinessCancelledText" = "商家已取消您的订阅。"; +"StarsSubscriptionBusinessProduct" = "订阅"; +"StarsSubscriptionCancel" = "取消订阅"; +"StarsSubscriptionCancelInfo" = "如果您现在取消订阅,您仍然可以在 %s 之前存取您的订阅内容。"; +"StarsSubscriptionCancelledBizToastText" = "您对 **%2$s** 的访问权限将持续到 **%1$s**。"; +"StarsSubscriptionCancelledBotToastText" = "您对 **%2$s** 的访问权限将持续到 **%1$s**。"; +"StarsSubscriptionCancelledText" = "您已取消您的订阅。"; +"StarsSubscriptionCancelledToast" = "订阅已取消"; +"StarsSubscriptionCancelledToastText" = "在 **%s** 之前,您仍然可以访问此頻道。"; +"StarsSubscriptionChannel" = "订阅"; +"StarsSubscriptionCompleted" = "已成功订阅!"; +"StarsSubscriptionCompletedText#other" = "%1$d 颗星星已转移到 **%2$s**。"; +"StarsSubscriptionExpired" = "已到期于 %s"; +"StarsSubscriptionExpiredHintText" = "资金不足以支付您的订阅费用。"; +"StarsSubscriptionExpiredHintTitle" = "%2$s 需要⭐️%1$d 颗星星"; +"StarsSubscriptionExpiredHintTitle2#other" = "%2$s 需要 ⭐️%1$d 颗星星"; +"StarsSubscriptionExpiredInfo" = "您的订阅已过期于 %s。"; +"StarsSubscriptionExpires" = "已取消 %s"; +"StarsSubscriptionPrice" = "⭐️%1$d/月"; +"StarsSubscriptionRefulfill" = "加入频道"; +"StarsSubscriptionRefulfillInfo" = "您已离开频道,但您仍然可以在 %s 之前返回。"; +"StarsSubscriptionRenew" = "更新订阅"; +"StarsSubscriptionRenewedToast" = "订阅已更新"; +"StarsSubscriptionRenewedToastText" = "你已更新 **%s** 的订阅。"; +"StarsSubscriptionRenews" = "已更新于 %s"; +"StarsSubscriptionSince" = "已订阅"; +"StarsSubscriptionStatusBizCancelled" = "已取消"; +"StarsSubscriptionStatusBotCancelled" = "已取消"; +"StarsSubscriptionStatusCancelled" = "已取消"; +"StarsSubscriptionStatusExpired" = "已过期"; +"StarsSubscriptionTitle" = "订阅"; +"StarsSubscriptionUntilExpired" = "已到期"; +"StarsSubscriptionUntilExpires" = "已到期"; +"StarsSubscriptionUntilRenews" = "更新"; +"StarsSuggestionOfferAcceptTerms" = "通过星星发布建议贴文,即表示您同意**服务条款**。"; +"StarsSuggestionOfferAcceptTermsLink" = "https://telegram.org/tos/stars"; +"StarsSuggestionTerms" = "发送星星即表示您同意 **服务条款**。"; +"StarsSuggestionTermsLink" = "https://telegram.org/tos/stars"; +"StarsTOS" = "购买星星即表示您同意 **条款和条件**。"; +"StarsTOSLink" = "https://telegram.org/tos/stars"; +"StarsTopUp" = "充值"; +"StarsTopupLinkEnough" = "你目前有足够的星星可用。"; +"StarsTopupLinkTopupAnyway" = "仍然充值"; +"StarsTransactionAds" = "广告"; +"StarsTransactionBot" = "星星充值机器人"; +"StarsTransactionDate" = "日期"; +"StarsTransactionFloodskip" = "付费广播消息"; +"StarsTransactionFloodskipMessages#other" = "%d 条消息"; +"StarsTransactionFloodskipNumber#other" = "%d"; +"StarsTransactionFloodskipNumberName" = "消息"; +"StarsTransactionFragment" = "通过 Fragment 购买"; +"StarsTransactionFullPrice" = "原价"; +"StarsTransactionHidden" = "隐藏用户"; +"StarsTransactionID" = "交易号"; +"StarsTransactionIDCopied" = "已将交易号复制到剪贴板"; +"StarsTransactionInApp" = "应用内购买"; +"StarsTransactionLiveStoryMessageFee" = "直播动态评论费用"; +"StarsTransactionLiveStoryReactionFee" = "直播动态回应费用"; +"StarsTransactionMedia" = "媒体"; +"StarsTransactionMessage" = "贴文"; +"StarsTransactionMessageFee#other" = "%d 条消息的费用"; +"StarsTransactionMessageFeeInfo" = "每逢收到一条消息,你将获取设置费用的 %s。"; +"StarsTransactionMessageFeeInfoLink" = "**更改费用 >**"; +"StarsTransactionPostsSearch" = "公开帖子搜索"; +"StarsTransactionPremiumGift" = "Telegram 高级版"; +"StarsTransactionPremiumGiftDuration" = "期限"; +"StarsTransactionRecipient" = "来自"; +"StarsTransactionRemovedDescription" = "已移除礼物描述"; +"StarsTransactionSource" = "通过"; +"StarsTransactionSubscriptionMonthly" = "订阅月费"; +"StarsTransactionTONDate" = "Gram 交易日期"; +"StarsTransactionTONFromFragment" = "Fragment"; +"StarsTransactionTONFromFragmentLink" = "https://fragment.com/ads/topup"; +"StarsTransactionTOS" = "查看星星的 **服务条款**。"; +"StarsTransactionUnknown" = "未知用户"; +"StarsTransactionUnknownLink" = "https://fragment.com/stars"; +"StarsTransactionUnsupported" = "程序内购买"; +"StarsTransactionViewInBlockchainExplorer" = "到区块链浏览器查看"; +"StarsTransactionWithdrawFragment" = "通过 Fragment 提现"; +"StarsTransactionsAll" = "所有交易"; +"StarsTransactionsIncoming" = "进账"; +"StarsTransactionsOutgoing" = "出账"; +"StarsWithdraw" = "提现"; +"Start" = "开始"; +"StartDayAt" = "'开始于'MMM'月'd'日'HH:mm"; +"StartDayYearAt" = "yyyy-MM-dd HH:mm '开始'"; +"StartEncryptedChat" = "开始私密聊天"; +"StartMessaging" = "开始聊天"; +"StartShortDayAt" = "M 月 d 日,HH:mm"; +"StartShortDayYearAt" = "yyyy-MM-dd HH:mm"; +"StartShortTodayAt" = "'今天,'HH:mm"; +"StartText" = "请确认您的国际电话区号并输入您的手机号码。"; +"StartTextPasskey" = "请输入您的手机号码\n或 **使用通行密钥登录 >**"; +"StartTodayAt" = "'今天' HH:mm '开始'"; +"StartVoipChannel" = "开始直播"; +"StartVoipChannelAlertText" = "您要在此频道中开始直播吗?"; +"StartVoipChannelTitle" = "直播"; +"StartVoipChat" = "开始视频聊天"; +"StartVoipChatAlertText" = "您确定要在此群组发起视频聊天吗?"; +"StartVoipChatPermission" = "管理直播"; +"StartVoipChatTitle" = "视频聊天"; +"StartsDayAt" = "'于' M '月' d '日' HH:mm '开始'"; +"StartsDayYearAt" = "yyyy-MM-dd HH:mm '开始'"; +"StartsTodayAt" = "‘今天’ HH:mm ‘开始’"; +"StatisticOpenProfile" = "打开个人资料"; +"StatisticOverview" = "概况"; +"StatisticSearchUserHistory" = "查看消息"; +"StatisticViews" = "浏览次数"; +"Statistics" = "统计信息"; +"StatisticsAndBoosts" = "统计数据"; +"StatusHiddenShow" = "何时?"; +"StealthMode" = "隐身模式"; +"StealthModeActiveHint" = "隐身模式已启用 – %s"; +"StealthModeActiveHintShort" = "隐身模式 %s"; +"StealthModeButton" = "隐藏我的浏览记录"; +"StealthModeConfirmMessage" = "如果您发送回复或表情回应,动态的作者会在访客列表中看到您。"; +"StealthModeConfirmTitle" = "您已开启隐身模式"; +"StealthModeCooldownHint" = "请等待 **隐身模式** 再次可用。"; +"StealthModeHint" = "开启隐身模式浏览动态后,将不会出现在访客列表中。"; +"StealthModeIsActive" = "隐身模式已启用"; +"StealthModeOn" = "隐身模式已开启"; +"StealthModeOnHint" = "对于您在过去 5 分钟内或在接下来 25 分钟内查看的动态,其作者不会在访客列表中看到您。"; +"StealthModePremiumHint" = "订阅 Telegram 高级版,浏览动态后,将不会出现在访客列表中。"; +"StealthModeTitle" = "隐身模式"; +"StickerCreateEmpty" = "创建贴纸"; +"StickerEffects" = "贴纸特效"; +"StickerOrEmojiGlobalSearchResult" = "全局搜索结果"; +"StickerOrEmojiSearchResult" = "搜索结果"; +"StickerSets#other" = "%1$s个贴纸包"; +"Stickers#other" = "%1$d 张表情"; +"StickersAddAnExistingSticker" = "添加现有贴纸"; +"StickersAndMasks" = "表情和装饰"; +"StickersArchived" = "贴纸包已归档。"; +"StickersArchivedInfo" = "%1$s 已移至您的归档。"; +"StickersBotInfo" = "艺术家们可以自由地使用机器人 @stickers 创建自己的贴纸包。"; +"StickersCheckStickersBotForMoreOptions" = "查看 **@stickers** 机器人以获取更多选项"; +"StickersChooseNameForStickerPack" = "为您的贴纸包选择一个名字。"; +"StickersCopy" = "复制链接"; +"StickersCreateNewSticker" = "创建新贴纸"; +"StickersDeleteForEveryone" = "为所有人删除"; +"StickersDeleteStickerSetDescription" = "这将为所有用户删除此贴纸包。"; +"StickersDeleteStickerSetTitle" = "删除贴纸包"; +"StickersHide" = "归档"; +"StickersName" = "贴纸与表情"; +"StickersNameInfo" = "管理您的贴纸包、表情和表情回应。"; +"StickersNameInfo2" = "管理贴纸、表情和表情回应"; +"StickersRemove" = "移除"; +"StickersRemoveForMe" = "为我删除"; +"StickersRemoved" = "表情包已移除"; +"StickersRemovedInfo" = "%1$s 从您的贴纸包中移除。"; +"StickersReorder" = "重新排列"; +"StickersReorderHint" = "拖动贴纸可重新排列。"; +"StickersReplaceSticker" = "更新贴纸"; +"StickersSetEmojiForSticker" = "选择与您的贴纸相匹配的表情"; +"StickersSettings" = "设置"; +"StickersShare" = "分享"; +"StickersStickerAddedToSetToast" = "贴纸添加到 %1$s 贴纸包。"; +"StickersStickerEditedInSetToast" = "已编辑 %1$s 贴纸包中的贴纸。"; +"Stop" = "停止"; +"StopAllLocationSharings" = "全部停止"; +"StopDownload" = "停止下载"; +"StopLiveLocation" = "停止共享位置"; +"StopLiveLocationAlertAll" = "您确定要停止共享您的实时位置吗?"; +"StopLiveLocationAlertAllText" = "您要停止共享实时位置吗?"; +"StopLiveLocationAlertToGroup" = "您确定要停止与 %1$s 共享实时位置吗?"; +"StopLiveLocationAlertToGroupText" = "您要停止与 **%1$s** 共享实时位置吗?"; +"StopLiveLocationAlertToTitle" = "停止位置分享"; +"StopLiveLocationAlertToUser" = "您确定要停止与 %1$s 共享您的实时位置吗?"; +"StopLiveLocationAlertToUserText" = "您要停止与 **%1$s** 共享实时位置吗?"; +"StopLoading" = "是否停止加载?"; +"StopLoadingTitle" = "停止加载?"; +"StopPoll" = "停止投票"; +"StopPollAlertText" = "如果您现在停止这个投票,任何人都不能再进行投票了。 此操作无法撤消。"; +"StopPollAlertTitle" = "停止投票?"; +"StopPollDeadlineButton" = "截止日期"; +"StopPollDeadlineHeader" = "截止日期"; +"StopQuiz" = "结束问答"; +"StopQuizAlertText" = "如果您现在结束此问答,将不再有人能够作答。该操作无法撤销。"; +"StopQuizAlertTitle" = "结束问答吗?"; +"StopShowingMe" = "隐身"; +"StopVerification" = "是否停止验证过程?"; +"StorageCleared" = "存储已清除"; +"StorageCleared2" = "所有媒体都可于需要时从 Telegram 云端下载。"; +"StoragePath" = "存储路径"; +"StoragePathFree" = "%1$s可用"; +"StoragePathFreeExternal" = "%1$s 可用空间,低速"; +"StoragePathFreeInternal" = "%1$s 可用空间,高速"; +"StoragePathFreeValue" = "%1$s 可用空间,%2$s"; +"StoragePathFreeValueExternal" = "%1$s 可用空间,慢速 %2$s"; +"StorageUsage" = "存储使用情况"; +"StorageUsageCalculating" = "计算缓存容量中..."; +"StorageUsageInfo" = "所有媒体文件将保留在 Telegram 云端,需要时可以重新下载。"; +"StorageUsageTelegram" = "Telegram 占用你设备储存空间的 %s。"; +"StorageUsageTelegramLess" = "Telegram 使用 %s 的设备存储空间。"; +"StoreCollageRetake" = "重拍"; +"Stories#other" = "%d 条动态"; +"StoriesAddPost" = "添加贴文"; +"StoriesAlbum" = "相册"; +"StoriesAlbumAddAlbum" = "添加相册"; +"StoriesAlbumAddHint" = "为您的相册起一个标题并添加您的动态。"; +"StoriesAlbumAddToAlbum" = "添加到相册"; +"StoriesAlbumBottomButtonAddStories" = "添加动态"; +"StoriesAlbumBottomButtonRemoveStoriesFromAlbum" = "从相册里移除"; +"StoriesAlbumCount#other" = "%d 个相册"; +"StoriesAlbumCreateNew" = "新建相册"; +"StoriesAlbumMenuAddStories" = "添加动态"; +"StoriesAlbumMenuArchive" = "归档"; +"StoriesAlbumMenuDeleteAlbum" = "删除相册"; +"StoriesAlbumMenuDeleteAlbumAsk" = "您确定要删除 **%s** 相册吗?"; +"StoriesAlbumMenuEdit" = "编辑"; +"StoriesAlbumMenuEditName" = "编辑名称"; +"StoriesAlbumMenuPin" = "置顶"; +"StoriesAlbumMenuRemoveFromAlbum" = "从相册里移除"; +"StoriesAlbumMenuReorder" = "重新排序"; +"StoriesAlbumMenuSelect" = "选取"; +"StoriesAlbumMenuShareLink" = "分享链接"; +"StoriesAlbumMenuUnpin" = "取消置顶"; +"StoriesAlbumNameAllStories" = "所有动态"; +"StoriesAlbumNewAlbum" = "新相册"; +"StoriesAlbumOrganizeDescription" = "将您的一些动态添加到此相册里。"; +"StoriesAlbumOrganizeTitle" = "整理您的动态"; +"StoriesAlbumRename" = "编辑相册标题"; +"StoriesAlbumRenameHint" = "为您的相册选择新标题。"; +"StoriesAlbumTitleInputHint" = "标题"; +"StoriesCollage" = "组合成拼贴画"; +"StoriesCreate#other" = "创建 %d 条动态"; +"StoriesDeleted#other" = "已删除 %d 条动态"; +"StoriesForwardText" = "转发至动态"; +"StoriesForwardTitle" = "您的动态"; +"StoriesIntroDismiss" = "点击以查看"; +"StoriesIntroGoBackHeader" = "返回"; +"StoriesIntroGoBackSubHeader" = "点击左侧边缘"; +"StoriesIntroGoForwardHeader" = "前进"; +"StoriesIntroGoForwardSubHeader" = "点击屏幕"; +"StoriesIntroGoToNextAuthorHeader" = "查看下一个作者"; +"StoriesIntroGoToNextAuthorSubHeader" = "向左或向右滑动"; +"StoriesIntroHeader" = "查看动态"; +"StoriesIntroPauseAndSeekHeader" = "暂停并搜索"; +"StoriesIntroPauseAndSeekSubHeader" = "按住并向左边或右边滑动"; +"StoriesIntroSubHeader" = "您可以使用这些手势\n控制播放。"; +"StoriesMovedToContacts" = "**%s** 的动态已移至 **归档**."; +"StoriesMovedToDialogs" = "**%s** 的动态已移至 **聊天**."; +"StoriesPinLimit#other" = "您最多只能置顶 %d 条动态。"; +"StoriesPinned#other" = "已置顶 %d 条动态"; +"StoriesPinnedText#other" = "现在这些动态将始终显示在顶部。"; +"StoriesPremiumHint" = "目前只有 **Telegram 高级版**的订阅用户可以发布动态。"; +"StoriesPremiumHint2" = "目前只有 **Telegram 高级版** 的订阅者可以发布动态。"; +"StoriesRemoveFromRecent" = "从最近的清单中移除"; +"StoriesSelected#other" = "已选中 %d 个"; +"StoriesSoundEnabled" = "动态"; +"StoriesTooMuch#other" = "您无法在 **24 小时内** 发送超过 **%d 条动态**。"; +"StoriesUnpinned#other" = "已将 %d 条动态取消置顶。"; +"Story" = "动态"; +"StoryAddCaption" = "添加说明文字…"; +"StoryAddedToAlbumTitle#other" = "%d 个动态已保存至 **%s**"; +"StoryAddedToAlbumX" = "此动态已添加到 **%s** 相册里。"; +"StoryAlbumNotFound" = "此相册已不存在"; +"StoryAllowScreenshots" = "允许截图"; +"StoryArchived#other" = "已从您的个人资料页移除 %d 条动态"; +"StoryArchivedFromProfile" = "此动态已从您的个人资料页隐藏。"; +"StoryAudioAddToProfile" = "添加到个人资料页"; +"StoryAudioAddToProfileToast" = "已添加到个人资料页"; +"StoryAudioAddToSavedMessages" = "添加到我的收藏"; +"StoryAudioAddToSavedMessagesToast" = "已保存到 **我的收藏**."; +"StoryAudioRemove" = "移除音频"; +"StoryAutoExceptions" = "自动排除"; +"StoryAutoExceptionsInfo" = "自动启用来自您最常联系的5位用户的动态通知。"; +"StoryBlockList#other" = "**%d 位用户** 将永远无法看到您的动态。"; +"StoryBlockListEmpty" = "**选择用户** 使其永远无法看到您的动态。"; +"StoryCameraDualHint" = "同时打开两个摄像头"; +"StoryCameraHint" = "点击 %s 向您的联系人发布动态。"; +"StoryCameraHint2" = "拍摄照片或视频,并立即与所有联系人或亲朋好友分享。"; +"StoryCameraSavedDualBackHint" = "点击此处停用第二个摄像头"; +"StoryCameraSavedDualFrontHint" = "点击此处停用前置摄像头"; +"StoryChannelDisableKeep" = "动态将在过期后消失。"; +"StoryChannelEnableKeep" = "即使动态过期,用户仍然可以在频道资料页上看到这个动态。"; +"StoryCloseFriendsHint" = "您能看到这条动态是因为 **%s** 将您添加到了密友列表中。"; +"StoryCollageMenuHint" = "按住并拖动滑块以重新排序。"; +"StoryCollageRemoveGrid" = "点击以移除网格"; +"StoryCollageReorderHint" = "您可以拖动图块以重新排序"; +"StoryContactsHint" = "只有 **%s** 的联系人可以查看此动态。"; +"StoryContainsEmoji#other" = "这条动态包含来自 **%d 个表情包**的表情。"; +"StoryContainsEmojiFrom" = "这条动态包含来自 %s 的表情。"; +"StoryContainsEmojiPackSingle" = "这条动态包含 **%s**"; +"StoryContainsStickers#other" = "这条动态包含来自 **%d 个贴纸包**的贴纸。"; +"StoryContainsStickersEmoji#other" = "这条动态包含来自 **%d 个包**的表情和贴纸。"; +"StoryContainsStickersEmojiFrom" = "这条动态包含来自 %s 的表情和贴纸。"; +"StoryContainsStickersFrom" = "这条动态包含来自 %s 的贴纸。"; +"StoryCoverSave" = "保存封面"; +"StoryCrop" = "裁切"; +"StoryDeleteContact" = "删除联系人"; +"StoryDeleteDraft" = "删除草稿"; +"StoryDisableKeep" = "动态将在过期后消失。"; +"StoryDisabledScreenshots" = "此动态将禁止下载及截图。"; +"StoryDisabledScreenshotsShare" = "此动态将禁止下载、分享及截图。"; +"StoryDraft" = "草稿"; +"StoryDraftSaved" = "草稿已保存"; +"StoryDrafts#other" = "%d 份草稿"; +"StoryDraftsAlbum" = "草稿"; +"StoryEditCover" = "编辑封面"; +"StoryEditCoverInfo" = "从该动态中选择一帧显示在您的个人资料页。"; +"StoryEditCoverMenu" = "编辑封面"; +"StoryEditing" = "正在提交编辑…"; +"StoryEnableKeep" = "即使动态过期,获准查看您动态的用户仍然可以在您的个人资料上看到它。"; +"StoryEnabledScreenshots" = "此动态将允许下载及截图。"; +"StoryEnabledScreenshotsShare" = "此动态将允许下载、分享及截图。"; +"StoryError" = "无法上传"; +"StoryExpiredSubtitle" = "此动态已过期"; +"StoryGroupReactionsHint" = "以消息形式作出表情回应"; +"StoryGroupRepliesLocked" = "评论受限"; +"StoryHidAccount" = "该帐号已被用户隐藏"; +"StoryHidFromToast" = "%s 将无法看到您的动态。"; +"StoryHideFrom" = "对 %s 隐藏我的动态"; +"StoryHintPinchToZoom" = "捏合以缩放"; +"StoryHintSwipeToZoom" = "滑动以缩放"; +"StoryInteractionsChartTitle" = "动态浏览和分享次数"; +"StoryKeep" = "发布至我的个人资料页"; +"StoryKeepChannel" = "在频道资料页上发布"; +"StoryKeepChannelInfo#other" = "即使该动态在 %d 小时后过期,仍将其保留在频道资料页上。"; +"StoryKeepDraft" = "保留草稿"; +"StoryKeepGroup" = "发布到群组页面"; +"StoryKeepGroupInfo#other" = "即使动态 %d 小时后到期,仍将其保留在群组资料页上。"; +"StoryKeepInfo#other" = "即便此动态在 %d 小时后过期,也依然将其保留在您的个人资料中,将应用隐私设置。"; +"StoryLinkAdd" = "添加链接"; +"StoryLinkCaptionAbove" = "向上移动"; +"StoryLinkCaptionBelow" = "向下移动"; +"StoryLinkCreate" = "创建链接"; +"StoryLinkEdit" = "更新链接"; +"StoryLinkLimitMessage#other" = "动态最多只能添加 %d 个链接。"; +"StoryLinkLimitTitle" = "到达数量上限。"; +"StoryLinkNameHeader" = "自定义链接文本"; +"StoryLinkNamePlaceholder" = "链接文本"; +"StoryLinkPremium" = "订阅 **Telegram 高级版** 后可在动态中添加链接。"; +"StoryLinkPreviewSubtitle" = "您可以自定义链接"; +"StoryLinkPreviewTitle" = "链接预览"; +"StoryLinkURLPlaceholder" = "输入网址"; +"StoryLive" = "直播"; +"StoryLiveAllowComments" = "允许留言"; +"StoryLiveBlockList#other" = "**%d 人** 将永远不会看到您的直播动态。"; +"StoryLiveBlockListEmpty" = "**选中的对象** 永远不会看到您的直播动态。"; +"StoryLiveKeepChannelInfo" = "将此直播动态保留在频道资料页。"; +"StoryLiveKeepGroupInfo" = "将此直播动态保留在群组资料页。"; +"StoryLiveKeepInfo" = "將将此直播動动态保留在您的个人资料页。"; +"StoryLiveNotificationSingle" = "直播已开始!"; +"StoryLivePrivacyAlertSubtitleProfile" = "选择谁可以观看你的直播"; +"StoryLivePrivacyAlertTitle" = "开始直播"; +"StoryLivePrivacyButtonPost" = "开始直播"; +"StoryLivePrivacyPublishAs" = "以那个身份发起直播"; +"StoryLivePrivacyWhoCanView" = "谁能查看此直播动态"; +"StoryMentionInDialog" = "在动态中提到了您"; +"StoryMentionedAction" = "查看动态"; +"StoryMentionedTitle" = "**%s** 在动态中提到了您。"; +"StoryMusicGlobalMusic" = "全局搜索"; +"StoryMusicLocalMusic" = "本地音乐"; +"StoryMusicProfileMusic" = "已保存的音乐"; +"StoryMusicSearchMusic" = "共享音频"; +"StoryMusicSelectFromFiles" = "从文件中选择"; +"StoryMusicTitle" = "添加音轨"; +"StoryMusicTitle2" = "添加音频"; +"StoryNoSound" = "这段视频没有声音"; +"StoryNotFound" = "此动态已无法访问"; +"StoryNotification1#other" = "已发布 %d 条动态"; +"StoryNotification2" = "由 %1$s 和 %2$s 发布"; +"StoryNotification3" = "由 %1$s、%2$s 和 %3$s 发布"; +"StoryNotification4#other" = "由 %2$s、%3$s 以及其他 %1$d 人发布"; +"StoryNotificationHidden#other" = "%d 条动态已发布"; +"StoryNotificationMention" = "在动态中提到了您"; +"StoryNotificationSingle" = "发布了一条动态"; +"StoryOpenLink" = "打开链接 >"; +"StoryOriginalSoundMuted" = "原始音频将被移除。"; +"StoryOriginalSoundNotMuted" = "原始音频将被保留。"; +"StoryPeriodHint" = "选择动态的可见时长。"; +"StoryPeriodKeep" = "始终保留"; +"StoryPeriodPremium#other" = "订阅 **Telegram 高级版**,让您的动态在 %d 小时后消失。"; +"StoryPhoto" = "图片"; +"StoryPinnedToPosts" = "动态已保存至频道资料页"; +"StoryPinnedToPostsDescription" = "其他用户可以在频道资料页上查看已发布的动态,直到管理员将其移除。"; +"StoryPinnedToProfile" = "动态已发布至您的个人资料页。"; +"StoryPremiumFormatting" = "订阅 **Telegram 高级版** 后可以在动态说明文字中添加链接和格式。"; +"StoryPremiumWidgets" = "订阅 **Telegram 高级版** 既可为一条动态添加多达 5 个回应贴纸。"; +"StoryPremiumWidgets2#other" = "订阅 **Telegram 高级版** 后可为动态添加最多 %d 个回应贴纸。"; +"StoryPrivacyAlertAsMessageSubtitle" = "选择接收者"; +"StoryPrivacyAlertAsMessageTitle" = "以消息发送"; +"StoryPrivacyAlertBlocklistSubtitle" = "选择用户"; +"StoryPrivacyAlertBlocklistTitle" = "对谁隐藏我的动态"; +"StoryPrivacyAlertCloseFriendsSubtitle" = "选择密友"; +"StoryPrivacyAlertCloseFriendsTitle" = "密友"; +"StoryPrivacyAlertEditTitle" = "编辑隐私设置"; +"StoryPrivacyAlertExcludeFromEveryoneSubtitle" = "排除联系人"; +"StoryPrivacyAlertExcludeFromEveryoneTitle" = "所有人除了…"; +"StoryPrivacyAlertExcludedContactsSubtitle" = "排除联系人"; +"StoryPrivacyAlertExcludedContactsTitle" = "排除的联系人"; +"StoryPrivacyAlertSelectContactsSubtitle" = "选择联系人"; +"StoryPrivacyAlertSelectContactsTitle" = "选择联系人"; +"StoryPrivacyAlertSubtitle#other" = "选择谁可以查看您的动态"; +"StoryPrivacyAlertSubtitleProfile" = "选择谁可以查看您的动态"; +"StoryPrivacyAlertTitle" = "分享动态"; +"StoryPrivacyAllContacts" = "我的联系人"; +"StoryPrivacyButtonExcludeContacts" = "排除联系人"; +"StoryPrivacyButtonPost" = "发布动态"; +"StoryPrivacyButtonPostMultiple#other" = "发布 %d 条动态"; +"StoryPrivacyButtonSave" = "保存设置"; +"StoryPrivacyButtonSaveCloseFriends" = "保存列表"; +"StoryPrivacyCloseFriends" = "密友"; +"StoryPrivacyContacts#other" = "%d 位联系人"; +"StoryPrivacyContactsExclude#other" = "联系人(-%d)"; +"StoryPrivacyEveryone" = "所有人"; +"StoryPrivacyEveryoneExclude#other" = "所有人(-%d)"; +"StoryPrivacyHint" = "设置谁可以查看您的动态"; +"StoryPrivacyNone" = "无"; +"StoryPrivacyOptionCloseFriends" = "密友"; +"StoryPrivacyOptionCloseFriendsDetail" = "编辑列表"; +"StoryPrivacyOptionContacts" = "我的联系人"; +"StoryPrivacyOptionContactsDetail" = "排除用户"; +"StoryPrivacyOptionEveryone" = "所有人"; +"StoryPrivacyOptionExcludePeople#other" = "除了 %d 位用户"; +"StoryPrivacyOptionExcludePerson" = "除了 %s"; +"StoryPrivacyOptionPeople#other" = "%d 位用户"; +"StoryPrivacyOptionSelectedContacts" = "选定的用户"; +"StoryPrivacyOptionSelectedContactsDetail" = "编辑列表"; +"StoryPrivacyPublishAs" = "以其他身份发布动态"; +"StoryPrivacyPublishLiveAs" = "以哪个身份直播"; +"StoryPrivacyRecipients#other" = "%d 位接收者"; +"StoryPrivacyWhoCanView" = "谁能查看您的动态"; +"StoryQualityDecrease" = "降低画质"; +"StoryQualityDecreasedMessage" = "现在动态的加载速度将会更快。"; +"StoryQualityDecreasedTitle" = "已降低画质"; +"StoryQualityIncrease" = "提升画质"; +"StoryQualityIncreasedMessage" = "您可以稍后降低画质以加快下载速度。"; +"StoryQualityIncreasedTitle" = "已提升画质"; +"StoryQualityPremium" = "高画质动态"; +"StoryQualityPremiumText" = "订阅高级版以更高分辨率查看动态。"; +"StoryReactionsByEmotionChartTitle" = "动态回应"; +"StoryReactionsHint" = "以私密消息形式发送反应"; +"StoryReactionsSortDescription" = "选择表情符号回应列表的顺序。"; +"StoryReactionsWidgetLimit" = "您不能为一条动态添加超过 5 个回应贴纸。"; +"StoryReactionsWidgetLimit2#other" = "您不能为一个动态添加超过 %d 个回应贴纸。"; +"StoryRemoveRoundMessage" = "确定删除视频消息吗?"; +"StoryRemoveRoundTitle" = "移除视频"; +"StoryRemovedFromAlbumTitle#other" = "从 **%s** 移除了 %d 个动态"; +"StoryRemovedFromAlbumX" = "此动态已经从 **%s** 移除。"; +"StoryRepliesLocked" = "回复受限"; +"StoryRepliesLockedButton" = "解锁"; +"StoryReplyDisabled" = "您无法回复此动态"; +"StoryRepostCommented" = "已评论"; +"StoryRestrictions" = "隐私限制"; +"StoryRestrictionsInfo" = "您的动态隐私设置将导致某些您标记的用户(%s)无法查看该动态。"; +"StoryRoundRemove" = "移除视频"; +"StorySave" = "发布至个人资料页"; +"StorySaveDraft" = "保存草稿"; +"StorySavedSubtitle" = "在您手动移除前,其他人可以在您的个人资料页查看这些已发布的动态。"; +"StorySavedTitle#other" = "已将 %d 条动态发布至您的个人资料页。"; +"StorySeekHelp" = "向左或向右滑动以搜索"; +"StorySelectedContactsHint" = "只有 **%s** 选择的部分用户可以查看该动态。"; +"StorySetWallpaper" = "选择壁纸"; +"StoryShared" = "已分享动态"; +"StorySharedTo" = "动态已转发至 **%s**。"; +"StorySharedToAllContacts" = "这条动态现已展示给您所有的联系人。"; +"StorySharedToAllContactsExcluded#other" = "这条动态现已展示给您的联系人(除了 %d)。"; +"StorySharedToCloseFriends" = "这条动态现已展示给您的密友。"; +"StorySharedToContacts#other" = "这条动态现已展示给 %d 位联系人。"; +"StorySharedToEveryone" = "这条动态现已展示给所有人。"; +"StorySharedToManyChats#other" = "已将动态转发到 **%1$d 个聊天**。"; +"StorySharedToSavedMessages" = "已将动态转发到**我的收藏**"; +"StoryShowBackTo" = "不要对 %s 隐藏我的动态"; +"StoryShownBackToToast" = "%s 将可以重新看到您的动态。"; +"StorySoundMuted" = "此动态将不会播放声音。"; +"StorySoundNotMuted" = "此动态将播放声音。"; +"StoryStatistics" = "动态统计数据"; +"StoryTapToSound" = "点击此处以启用声音"; +"StoryTimeline" = "时间线"; +"StoryUnavailable" = "无法访问动态"; +"StoryUnpinnedFromPosts" = "已从频道资料页移除动态"; +"StoryUnsupported" = "您当前版本的 Telegram 不支持该动态。"; +"StoryUploadError" = "上传动态时出错"; +"StoryUploading" = "正在上传动态…"; +"StoryVideo" = "视频"; +"StoryViewGift" = "显示礼物 >"; +"StoryViewLocation" = "查看位置 >"; +"StoryViewMessage" = "查看消息 >"; +"StoryViewsPremiumHint" = "如要解锁已过期和已发布动态的访客列表,请订阅 **Telegram 高级版**。"; +"StoryViewsSortDescription" = "选择访客名单的\n显示顺序。"; +"StoryWidgetAudio" = "音频"; +"StoryWidgetLink" = "链接"; +"StoryWidgetLocation" = "位置"; +"StoryWidgetPhoto" = "图片"; +"StoryYouMentionInDialog" = "您提到了 %s"; +"StoryYouMentionedTitle" = "**您**在动态中提到了 %s。"; +"Streaming" = "在线播放"; +"Strike" = "删除线"; +"Style" = "风格"; +"SubscribeRequests" = "加入请求"; +"SubscribeToPremium" = "以每月 %s 的价格订阅"; +"SubscribeToPremiumNoPrice" = "订阅"; +"SubscribeToPremiumNotAvailable" = "该订阅不可用"; +"SubscribeToPremiumOfficialAppNeeded" = "需要官方应用"; +"SubscribeToPremiumOfficialAppNeededDescription" = "你需要通过官方应用程序来订阅 **Telegram 高级版**。\n\n一旦订阅成功,立刻可以在所有支持它的应用程序中体验到 Telegram 高级版带来的便利,甚至包含非官方应用!"; +"SubscribeToPremiumPerCustom" = "订阅 %1$s / %2$s"; +"SubscribeToPremiumPerYear" = "以每年 %s 的价格订阅"; +"Subscribers#other" = "%1$d 位订阅者"; +"SubscriptionsCount" = "%1$s 个订阅"; +"Suggest2ChannelSendHint" = "向频道管理员发送消息"; +"SuggestAPostBelow" = "在下方建议一条贴文"; +"SuggestAPostBelowHint" = "点击此处为 %s 推荐贴文"; +"SuggestAPostBelowSubtitle" = "点击提供发布价格"; +"SuggestAPostBelowSubtitleStars" = "花费 ⭐️ %s 可随时发布"; +"SuggestAPostBelowSubtitleStarsAndTime" = "⭐️ %s %s"; +"SuggestAPostTooMuch" = "报价上限: %s"; +"SuggestAPostTooSmall" = "最低报价: %s"; +"SuggestAnimatedEmoji" = "推荐动态表情符号"; +"SuggestAnimatedEmojiInfo" = "每次输入表情时,您都可以将其替换为动画表情。"; +"SuggestClearDatabaseMessage" = "您的本地数据库大小为 %s,可能会影响到应用的响应速度。"; +"SuggestClearDatabaseTitle" = "清空存储"; +"SuggestContacts" = "推荐常用联系人"; +"SuggestContactsAlert" = "这将删除所有您常联系用户与常用消息框内机器人的数据。"; +"SuggestContactsInfo" = "在搜索区域顶部显示常用联系人以便您快速访问。"; +"SuggestContactsTitle" = "停用建议"; +"SuggestPhoto" = "推荐图片"; +"SuggestPhotoAlertMessage" = "您想向 %s 推荐此图片作为头像吗?"; +"SuggestPhotoFor" = "推荐照片给 %s"; +"SuggestPhotoShort" = "推荐"; +"SuggestPostForFree" = "消息"; +"SuggestPostForStars" = "每条消息 ⭐️%s"; +"SuggestStickers" = "根据表情联想贴纸"; +"SuggestStickersAll" = "全部"; +"SuggestStickersInstalled" = "我的表情包"; +"SuggestStickersNone" = "不推荐"; +"SuggestUserPhoto" = "为 %s 建议照片"; +"SuggestVideo" = "推荐视频"; +"SuggestVideoAlertMessage" = "确定要推荐本视频为 %s 作为个人资料的头像吗?"; +"SuggestedDateOfBirthView" = "查看"; +"SuggestedMessageAcceptInfo" = "您想要发布来自 **%1$s** 的此贴文吗?"; +"SuggestedMessageAcceptInfo3" = "发布后,贴文必须至少保持可见 **%d** 小时。"; +"SuggestedMessageAcceptInfoAdmin2" = "您将通过发布此贴文 **%2$s** 而获得 **%1$s**(%3$s)。"; +"SuggestedMessageAcceptInfoAnytimeAdmin2" = "您将通过发布此贴文获得 **%1$s** (%2$s)。"; +"SuggestedMessageAcceptInfoAnytimeUser2" = "您将为发布此贴文复制 **%1$s**。"; +"SuggestedMessageAcceptInfoUser2" = "于 **%2$s** 发布此贴文,您将支付 **%1$s**。"; +"SuggestedMessageAcceptStarsDisclaimer" = "星星交易可能会在 21 天內被支付者撤销。请仅接受您信任的人的星星付款。"; +"SuggestedMessageDecline" = "拒绝"; +"SuggestedMessageDeclineInfo" = "您是否要拒绝发布来自 **%1$s** 的贴文?"; +"SuggestedMessageDeclineReasonHint" = "添加原因 (可选)"; +"SuggestedOfferCompleteAmount#other" = "%1$s 通过发布贴文获得 ⭐️%2$s"; +"SuggestedOfferCompleteAmountF" = "%1$s 通过发布贴文获得 ⭐️ %2$s "; +"SuggestedOfferCompleteAmountUnknown" = "%1$s 已收到发布贴文的付款"; +"SuggestedOfferPaidUntil" = "已支付有效至于 %1$s"; +"SuggestedOfferRefundByAdminAmount#other" = "⭐️ 由于 %2$s 删除了贴文,%3$s 已将款项退款给 %1$s"; +"SuggestedOfferRefundByAdminAmountF" = "⭐️ %3$s 已退还给 %1$s,因为 %2$s 删除了消息"; +"SuggestedOfferRefundByAdminAmountUnknown" = "由于 %2$s 删除了此消息,款项已退还给 %1$s"; +"SuggestedOfferRefundByUserAmount#other" = "%2$s 将不会收到 ⭐️%3$s,因为 %1$s 已申请退款"; +"SuggestedOfferRefundByUserAmountF" = "%2$s 将不会收到⭐️%3$s,因为 %1$s 申请了退款"; +"SuggestedOfferRefundByUserAmountUnknown" = "%2$s 将不会收到款项,因为 %1$s 已申请退款"; +"SuggestedOfferStars" = "星星报价"; +"SuggestedOfferTON" = "Gram 报价"; +"SuggestedPhoto" = "推荐的图片"; +"SuggestedPostAcceptTitle" = "接受条款"; +"SuggestedPostPublish" = "发布"; +"SuggestedVideo" = "已推荐的图片"; +"SuggestionAgreementDeclinedThis" = "❌ **%s** 拒绝了此消息。"; +"SuggestionAgreementDeclinedThisComment" = "❌ **%s** 拒收这条信息,并备注:"; +"SuggestionAgreementDeclinedYou" = "❌ **%s** 拒收您的消息。"; +"SuggestionAgreementDeclinedYouComment" = "❌ **%s** 拒收了您的消息,并备注:"; +"SuggestionAgreementNotEnoughStars" = "⚠️ **交易失败**,因为 **%s** 没有足够的星星。"; +"SuggestionAgreementReached" = "🤝 **达成协议!**"; +"SuggestionAgreementReachedAdmin1" = "📆 此贴文将自动发布在 **%s** 于 **%s**"; +"SuggestionAgreementReachedAdmin1PastSimple" = "📆 此贴文已自动发布在 **%s** 于 **%s**"; +"SuggestionAgreementReachedAdmin1PresentPerfect" = "📆 此贴文已发布于 **%s**"; +"SuggestionAgreementReachedAdmin2" = "💰 **%s** 已收取 ⭐️ **%s**。"; +"SuggestionAgreementReachedAdmin3" = "⏳ 贴文发布 **%d** 小时后,**%s** 将收到款项。"; +"SuggestionAgreementReachedAdmin3Stars" = "⌛️ **%s** 将在贴文发布 **%d** 小时后收到星星。"; +"SuggestionAgreementReachedAdmin3TON" = "⏳ 贴文发布 **%d** 小时后,**%s** 将收到 Gram。"; +"SuggestionAgreementReachedAdmin4" = "🔁 如果 **%s** 在贴文发布 **%d** 小时之前将其删除,款项将退还给用户。"; +"SuggestionAgreementReachedAdmin4Stars" = "🔁 如果 **%s** 在贴文发布 **%d** 小时之内将其删除,**星星** 将原路退还给用户。"; +"SuggestionAgreementReachedAdmin4TON" = "🔁 如果 **%s** 在贴文发布 **%d** 小时之内将其删除,**Gram** 将原路退还给用户。"; +"SuggestionAgreementReachedUser1" = "📆 您的贴文将自动发布在 **%s** 于 **%s**"; +"SuggestionAgreementReachedUser1PastSimple" = "📆 您的帖子已于 **%s** **%s** 自动发布"; +"SuggestionAgreementReachedUser1PresentPerfect" = "📆 您的贴文已发布于 **%s**"; +"SuggestionAgreementReachedUser2" = "💰 您已收取 ⭐️ **%s**."; +"SuggestionAgreementReachedUser3" = "⏳ 贴文发布 **%d** 小时后,**%s** 将收到您的款项。"; +"SuggestionAgreementReachedUser3Stars" = "⏳ 贴文发布 **%d** 小时后,**%s** 将会收到您的星星。"; +"SuggestionAgreementReachedUser3TON" = "⏳ 贴文发布 **%d** 小时后,**%s** 将收到您的 **Gram**。"; +"SuggestionAgreementReachedUser4" = "🔁 如果 **%s** 在贴文发布 **%d** 小时之前将其删除,您的款项将退款。"; +"SuggestionAgreementReachedUser4Stars" = "🔁 如果 **%s** 在贴文发布 **%d** 小时之内将其删除,您的星星将原路退还。"; +"SuggestionAgreementReachedUser4TON" = "如果 **%s** 在贴文发布 **%d** 小时之内将其删除,Gram 将原路退还。"; +"SuggestionLockedStars" = "**%1$s** 的管理员对每条消息收取 **⭐️ %2$s** 费用。"; +"SuggestionOfferInfoPrice" = "价格"; +"SuggestionOfferInfoTime" = "时间"; +"SuggestionOfferInfoTitle" = "**%s** 推荐一条帖子。"; +"SuggestionOfferInfoTitleEditedAnd" = "和"; +"SuggestionOfferInfoTitleEditedFromX" = "**%1$s** 为此贴文建议了新的报价 %2$s。"; +"SuggestionOfferInfoTitleEditedFromYou" = "**您** 为此贴文建议了新报价 %1$s。"; +"SuggestionOfferInfoTitleEditedMedia" = "媒体"; +"SuggestionOfferInfoTitleEditedPrice" = "价格"; +"SuggestionOfferInfoTitleEditedText" = "文字"; +"SuggestionOfferInfoTitleEditedTime" = "时间"; +"SuggestionOfferInfoTitleEditedX" = "**%1$s** 为 **%2$s** 贴文建议了新的价格 %3$s。"; +"SuggestionOfferInfoTitleYou" = "**您** 推荐了一条贴文。"; +"SuggestionStarsWillBeLost" = "星星将会丢失"; +"SuggestionStarsWillBeLostDelete" = "仍然删除"; +"SuggestionStarsWillBeLostInfo" = "如果您现在删除此贴文,您将不会收到星星。此贴文发布后必须保持至少在 **%d 小时** 内可见。"; +"SuggestionTONWillBeLost" = "Gram 将会失去"; +"SuggestionTONWillBeLostInfo" = "如果您现在删除此贴文,您将不会收到 Gram。此贴文发布后必须保持至少在 **%d 小时** 内可见。"; +"SuggestionUnlockedStars" = "向 **%1$s** 的管理员发送私信。"; +"SummaryLimit" = "使用 AI 概括长消息-在 Telegram 高级版中无限量使用。"; +"SummaryLimitUpgrade" = "升级"; +"SummarySubtitle" = "点击查看原文"; +"SummaryTitle" = "AI 摘要\n"; +"Support" = "客服"; +"SupportStatus" = "客服"; +"SwipeDeleteChat" = "删除"; +"SwipeMarkAsRead" = "已读"; +"SwipeMarkAsUnread" = "未读"; +"SwipeMute" = "静音"; +"SwipePin" = "置顶"; +"SwipeSettingsArchive" = "归档"; +"SwipeSettingsDelete" = "删除"; +"SwipeSettingsFolders" = "修改文件夹"; +"SwipeSettingsMute" = "静音"; +"SwipeSettingsPin" = "置顶"; +"SwipeSettingsRead" = "已读"; +"SwipeToGoNextArchive" = "上拉跳转到已归档频道"; +"SwipeToGoNextChannel" = "上滑跳转到下一个未读频道"; +"SwipeToGoNextChannelEnd" = "暂无未读频道"; +"SwipeToGoNextFolder" = "上滑跳转到下一个未读分组"; +"SwipeToGoNextRecommendedChannel" = "上拉可前往下一个频道"; +"SwipeToGoNextTopicEnd" = "%1$s 中没有更多的未读话题"; +"SwipeToGoNextUnreadTopic" = "上滑跳转到下一个话题"; +"SwipeToReplyHint" = "滑动以回复"; +"SwipeToReplyHintMessage" = "在任何消息上左滑以回复。"; +"SwipeUnmute" = "取消静音"; +"SwipeUnpin" = "取消置顶"; +"SwitchAccountHint" = "长按以切换账号"; +"SwitchThemeToDay" = "日间模式"; +"SwitchThemeToNight" = "夜间模式"; +"SyncContacts" = "同步通讯录"; +"SyncContactsAdded" = "此设备上的联系人信息已被添加到您的帐号。"; +"SyncContactsDelete" = "删除已同步的联系人"; +"SyncContactsDeleteInfo" = "这将把您的联系人信息从 Telegram 服务端删除。如果“同步联系人”选项处于启用状态,联系人信息将被重新同步。"; +"SyncContactsDeleteText" = "这将把您的联系人信息从 Telegram 服务端删除。\n\n如果“**同步联系人**”选项处于启用状态,您现在的通讯录将被重新同步。"; +"SyncContactsDeleteTitle" = "删除联系人"; +"SyncContactsInfoOff" = "开启以将此设备上的联系人信息即时同步到您的帐号。"; +"SyncContactsInfoOn" = "此设备上的联系人信息将被添加到您的帐号。"; +"SyncContactsOff" = "此设备上的通讯录将不会被同步到此帐号。"; +"SyncContactsOn" = "此设备上的通讯录将被同步到此帐号。"; +"SystemRoot" = "系统根目录"; +"SystemTones" = "系统提示音"; +"TONBalanceText" = "提供 Gram 来向 Telegram 频道提交帖子建议"; +"TONBalanceTitle" = "Gram 余额"; +"TagAdded" = "已添加标签"; +"TagEdited" = "标签已编辑"; +"TagInfoAdminText" = "此绿色标签 un1 是 **%1$s** 在 **%2$s** 中的管理员标签。"; +"TagInfoAdminTitle" = "管理员标签"; +"TagInfoButtonAddMyTag" = "添加我的标签"; +"TagInfoButtonAddTag" = "添加标签"; +"TagInfoButtonEditMyTag" = "编辑我的标签"; +"TagInfoButtonEditTag" = "编辑标签"; +"TagInfoMemberText" = "此灰色标签 un1 是 **%1$s** 在 **%2$s** 中的成员标签。"; +"TagInfoMemberTitle" = "成员标签"; +"TagInfoOwnerText" = "此紫色标签 un1 是 **%1$s** 在 **%2$s** 中的管理员标签。"; +"TagInfoOwnerTitle" = "所有者标签"; +"TaggedMessages#other" = "%1$d 条消息"; +"TapAndHoldToDeleteReaction" = "长按即可删除回应。"; +"TapForForwardingOptions" = "点此进行转发选项"; +"TapForReplyOptions" = "点击此处以回复"; +"TapForVideo" = "点击拍照,长按录像"; +"TapHereEmoji" = "点击 %1$s 以访问更多的表情。"; +"TapHereGifs" = "点击这里访问保存的 GIF 动态图"; +"TapOnThePencil" = "点击铅笔图标开始新聊天"; +"TapOnThePencilButton" = "点击以开始新聊天"; +"TapToAddBio" = "点击添加简介"; +"TapToAddDescription" = "点击以添加描述"; +"TapToAddPhoto" = "点击以添加照片"; +"TapToAddPhotoOrBio" = "点击添加头像或简介"; +"TapToAddPhotoOrDescription" = "点击以添加头像或简介"; +"TapToChange" = "点击以变更"; +"TapToChangePhone" = "点击以更换手机号码"; +"TapToCreateTopicHint" = "点击以创建话题"; +"TapToDownload" = "点击下载"; +"TapToEditWidget" = "点击以编辑微件"; +"TapToEditWidgetShort" = "点击以进行编辑"; +"TapToHideCallsTab" = "点击此处隐藏**通话**标签"; +"TapToSendLocation" = "点击以发送位置信息"; +"TapToTurnCamera" = "点击这里开启摄像头"; +"TapToViewAsList" = "点击以列表形式查看。"; +"Teal" = "青色"; +"TelegramBusiness" = "Telegram 企业版"; +"TelegramBusinessSubscribedSubtitle" = "您现已经解锁这些额外的企业功能。"; +"TelegramBusinessSubscribedSubtitleTemp" = "您现在已经解锁 Telegram 企业功能。这些功能 **暂时包含** 在您的高级版订阅中。"; +"TelegramBusinessSubtitle" = "订阅 **Telegram 高级版** 以解锁下列企业功能。"; +"TelegramBusinessSubtitle2" = "将您的账户转变为企业页面,并获得以下额外功能。"; +"TelegramBusinessSubtitleTemp" = "将您的帐号转变为 **企业页面**,并使用以下额外功能。\nTelegram 企业版的权益目前 **暂时包含** 在 Telegram 高级版中。"; +"TelegramCacheSize" = "%s Telegram 缓存"; +"TelegramContacts#other" = "%1$d 位 Telegram 用户有此联系人信息"; +"TelegramFAQ" = "Telegram 常见问题"; +"TelegramFaq" = "Telegram 常见问题"; +"TelegramFaqUrl" = "https://telegram.org/faq"; +"TelegramFeatures" = "Telegram 功能"; +"TelegramFeaturesUrl" = "https://t.me/TelegramTips"; +"TelegramPassport" = "Telegram 通行证"; +"TelegramPassportCreatePassword" = "创建密码"; +"TelegramPassportCreatePasswordInfo" = "请创建一个密码使用端到端加密保护个人数据。\n\n每次您在新的设备登录到 Telegram 时也将需要输入这个密码。"; +"TelegramPassportDelete" = "删除 Telegram 通行证"; +"TelegramPassportDeleteAlert" = "您确定要删除 Telegram 通行证吗?"; +"TelegramPassportDeleteTitle" = "删除Telegram 护照"; +"TelegramPremium" = "Telegram 高级版"; +"TelegramPremiumCount" = "%1$d 个 Telegram 高级版订阅"; +"TelegramPremiumShort" = "高级版"; +"TelegramPremiumSubscribedSubtitle" = "欢迎订阅 **Telegram 高级版**。\n您已成功解锁以下特权。"; +"TelegramPremiumSubscribedTitle" = "一切都已就绪!"; +"TelegramPremiumSubtitle" = "订阅 **Telegram Premium** 后,您能 **超越限制** 并 **解锁数十项专属** 功能。"; +"TelegramPremiumUserDialogSubtitle" = "拥有 **Telegram 高级版** 帐号即可专享多项附加功能。"; +"TelegramPremiumUserDialogTitle" = "%s是 Telegram Premium 订阅者"; +"TelegramPremiumUserGiftedPremiumDialogSubtitle" = "您现在可以使用额外的功能了。"; +"TelegramPremiumUserGiftedPremiumDialogTitle" = "**%1$s** 已赠予您 %2$d 个月的 Telegram 高级版订阅。"; +"TelegramPremiumUserGiftedPremiumDialogTitleWithPlural" = "**%1$s** 已赠予您 %2$s 的Telegram 高级版订阅。"; +"TelegramPremiumUserGiftedPremiumDialogTitleWithPluralSomeone" = "有人赠送了您一个 %1$s的 Telegram 高级版的订阅。"; +"TelegramPremiumUserGiftedPremiumOutboundDialogSubtitle" = "**%1$s** 现在可以使用额外的功能了。"; +"TelegramPremiumUserGiftedPremiumOutboundDialogTitle" = "您已赠予 **%1$s** %2$d 个月的 Telegram 高级版订阅。"; +"TelegramPremiumUserGiftedPremiumOutboundDialogTitleWithPlural" = "您已赠予 **%1$s** %2$s 的 Telegram 高级版订阅。"; +"TelegramPremiumUserStatusCollectibleDialogTitle" = "%1$s 将 **%2$s** 表情包中的此表情符号设定为其目前的状态。"; +"TelegramPremiumUserStatusDefaultDialogTitle" = "这是 %1$s 的当前状态。"; +"TelegramPremiumUserStatusDialogSubtitle" = "表情符号状态是一项高级功能。**Telegram 高级版** 包含的其它功能:"; +"TelegramPremiumUserStatusDialogTitle" = "%1$s 已将 **%2$s** 中的表情设置为其当前状态。"; +"TelegramStars" = "Telegram 星星"; +"TelegramStarsChoose" = "选择套餐"; +"TelegramStarsGift" = "赠送星星给朋友"; +"TelegramStarsInfo" = "购买星星,解锁 Telegram\n小程序中的内容和服务。"; +"TelegramStarsInfo2" = "购买星星以解锁 Telegram 小程序中的内容与服务。 **了解星星 >**"; +"TelegramTones" = "Telegram 提示音"; +"TelegramVersion" = "Telegram Android 客户端 %1$s"; +"Terminate" = "结束"; +"TerminateAllSessions" = "注销所有其它设备上的会话"; +"TerminateAllWebSessions" = "取消所有网站的授权"; +"TerminateOldSessionHeader" = "自动终止旧会话"; +"TerminateSession" = "终止会话"; +"TerminateSessionInfo" = "点击一个会话来注销它。"; +"TerminateSessionQuestion" = "是否登出此次会话?"; +"TerminateSessionText" = "您确定要结束此会话吗?"; +"TerminateWebSessionInfo" = "点击以取消授权。"; +"TerminateWebSessionQuestion" = "取消授权“%1$s”?"; +"TerminateWebSessionStop" = "屏蔽 %1$s"; +"TerminateWebSessionText" = "您确定要断开与 **%1$s** 的连接吗?"; +"TerminateWebSessionTitle" = "断开网站连接"; +"TerminateWebSessionsTitle" = "断开网站连接"; +"TermsOfService" = "服务条款"; +"TermsOfServiceLogin" = "注册即代表\n您同意*服务条款*。"; +"TermsOfServiceUrl" = "https://telegram.org/tos"; +"TermsOfUse" = "使用条款"; +"TextCopied" = "已复制文本到剪贴板。"; +"TextPlaceholder" = "添加文字"; +"TextSelectionHint" = "按住 **单词**,然后移动光标,选择更多文字以引用或复制。"; +"TextSelectionHit" = "长按 **文本**,移动光标,选择更多文字以复制。"; +"TextSize" = "消息字号"; +"TextSizeHeader" = "消息字号"; +"Theme" = "主题"; +"ThemeAlsoAppliedForHint" = "主题也将应用到**%s** 那一端。"; +"ThemeAlsoDisabledForHint" = "**%s** 的主题也将被停用。"; +"ThemeArcticBlue" = "北极蓝"; +"ThemeBlue" = "单色"; +"ThemeClassic" = "经典"; +"ThemeCreateHelp" = "任何人都可以使用此链接安装你的主题。当您修改主题后,安装过的用户将获得更新。\n\n该主题将基于你当前选择的主题和壁纸。"; +"ThemeCreateHelp2" = "您可以修改主题链接。\n\n主题链接必须长于 **5** 个字符,可以使用 **a-z**、**0-9** 以及下划线。"; +"ThemeDark" = "深色"; +"ThemeDarkBlue" = "深蓝色"; +"ThemeDay" = "白天"; +"ThemeGraphite" = "石墨主题"; +"ThemeHelpLink" = "您每次修改主题时,应用了您的主题的用户都会获得更新,每个人都可以使用以下链接应用您的主题:\n%1$s"; +"ThemeInfo" = "选择自定义配色方案"; +"ThemeInstallCount#other" = "%1$d 个人正在使用该主题"; +"ThemeNameInvalid" = "很抱歉,主题名称无效。"; +"ThemeNamePlaceholder" = "名称"; +"ThemeNight" = "夜间"; +"ThemeNotFound" = "未找到此主题"; +"ThemeNotSupported" = "很抱歉,此多彩主题还不支持您的设备。"; +"ThemePreview" = "主题预览"; +"ThemePreviewDialog1" = "伊娃·萨默"; +"ThemePreviewDialog2" = "你内心的竞争"; +"ThemePreviewDialog3" = "迈克·艾普"; +"ThemePreviewDialog4" = "保罗·纽曼"; +"ThemePreviewDialog5" = "老海盗"; +"ThemePreviewDialog6" = "凯特·布莱特"; +"ThemePreviewDialog7" = "尼克·凯"; +"ThemePreviewDialog8" = "阿德勒·托伯格"; +"ThemePreviewDialogMessage1" = "这让我想起了一句中国谚语…"; +"ThemePreviewDialogMessage2" = "嘿,我已更新了源代码。"; +"ThemePreviewDialogMessage3" = "🤷\u200d♂️ 贴纸"; +"ThemePreviewDialogMessage4" = "有什么想法吗?"; +"ThemePreviewDialogMessage5" = "哟—吼—吼!"; +"ThemePreviewDialogMessage6" = "你好!"; +"ThemePreviewDialogMessage7" = "这些不是您要找的机器人。"; +"ThemePreviewDialogMessage8" = "有人提到了花生酱吗?"; +"ThemePreviewLine1" = "莱因哈特,我们得给你找些新曲子 🎶。"; +"ThemePreviewLine2" = "我不想理你了。"; +"ThemePreviewLine3" = "你们这群小屁孩就知道电子音乐。你们应该听听经典摇滚,比如哈塞尔霍夫!"; +"ThemePreviewLine3Reply" = "Lucio"; +"ThemePreviewLine4" = "来吧!我为此而活!"; +"ThemePreviewSongPerformer" = "大卫·哈塞尔霍夫"; +"ThemePreviewSongTitle" = "True Survivor"; +"ThemePreviewTitle" = "莱因哈特"; +"ThemeResetToDefaults" = "恢复默认"; +"ThemeResetToDefaultsText" = "您确定要重置文本大小和颜色设置吗?"; +"ThemeResetToDefaultsTitle" = "重置"; +"ThemeSetUrl" = "创建链接"; +"ThemeSetUrlHelp" = "您可以使用 **a-z**、**0-9** 和下划线。最小长度为 5 个字符。"; +"ThemeUrl" = "主题链接"; +"ThisIsYou" = "您本人"; +"ThisMedia" = "该媒体"; +"ThisPhoto" = "这张照片"; +"TimeLimitHelp" = "您可以设置该链接在一定时间后过期。"; +"TimeLimitHint" = "设置自定义时间"; +"TimeToEdit" = "%1$s"; +"TimeToEditExpired" = "抱歉,可编辑时间已过。"; +"TimerPeriodDoNotDelete" = "请勿删除"; +"TimerPeriodHint" = "选择媒体打开后的保留时长。"; +"TimerPeriodOnce" = "查看一次"; +"TimerPeriodPhotoKeep" = "图片将会保留在聊天中。"; +"TimerPeriodPhotoSetOnce" = "图片已设置为仅查看一次"; +"TimerPeriodPhotoSetSeconds#other" = "图片将在打开后 **%d** 秒删除。"; +"TimerPeriodVideoKeep" = "视频将被保留。"; +"TimerPeriodVideoSetOnce" = "视频已设置为观看一次。"; +"TimerPeriodVideoSetSeconds#other" = "视频将在打开后 **%d** 秒删除。"; +"Times#other" = "%1$d 次"; +"TimezoneDetectAutomatically" = "自动检测"; +"TimezoneDetectAutomaticallyInfo" = "您的企业所在时区已设置为 %s。"; +"TimezoneHeader" = "选择您的时区"; +"TimezoneNotFound" = "未找到时区"; +"TimezoneTitle" = "时区"; +"TintHighlights" = "高光"; +"TintShadows" = "阴影"; +"TitleSetupProxy" = "设置代理 >"; +"TodayAt" = "于"; +"TodayAtFormatted" = "%1$s"; +"TodayAtFormattedWithToday" = "今天 %1$s"; +"Todo" = "核对清单"; +"TodoAddTaskInfoMax" = "您已添加最大数量的任务。"; +"TodoAddTasksButton" = "添加"; +"TodoAddTasksTitle" = "新任务"; +"TodoAddedMoreTasks#other" = "un1 已将 “%2$s”, “%3$s”, “%4$s” 和另外 %1$d 个任务添加到核对清单"; +"TodoAddedMoreTasksOut#other" = "您已将 “%2$s”, “%3$s”, “%4$s” 和另外 %1$d 个任务添加至核对清单。"; +"TodoAddedOneTask" = "un1 已将 “%1$s” 添加到核对清单。"; +"TodoAddedOneTaskOut" = "您已将 “%1$s” 添加到核对清单"; +"TodoAddedThreeTask" = "un1 已将 “%1$s”, “%2$s” 和 “%3$s” 添加至核对清单"; +"TodoAddedThreeTaskOut" = "您已将 “%1$s”, “%2$s” 和 “%3$s” 添加至核对清单"; +"TodoAddedTwoTask" = "un1 已将 “%1$s” 和 “%2$s” 添加至核对清单"; +"TodoAddedTwoTaskOut" = "您已将 “%1$s” 和 “%2$s” 添加至核对清单"; +"TodoAllowAddingTasks" = "允许其他人新增任务"; +"TodoAllowMarkingDone" = "允许其他人标记为已完成"; +"TodoCantForward" = "抱歉,核对清单无法转发至频道。"; +"TodoCantForwardSecretChat" = "抱歉,核对清单无法转发到秘密聊天。"; +"TodoCheck" = "选取"; +"TodoCompleteForbidden" = "**%s** 已限制其他人编辑此核对清单。"; +"TodoCompleteForbiddenForward" = "您无法编辑已转发的核对清单。"; +"TodoCompleted#other" = "%2$d / %1$d 已完成"; +"TodoCompletedBy#other" = "%2$d / %1$d 已由 %3$s 完成"; +"TodoCompletedDateTimeAt" = "已于 %2$s 完成 %1$s"; +"TodoCompletedTodayAt" = "于 %s 完成"; +"TodoCompletedYesterdayAt" = "于昨天 %s 完成"; +"TodoDeleteItem" = "删除选项"; +"TodoEditItem" = "编辑选项"; +"TodoEditTasksButton" = "保存"; +"TodoEditTitle" = "编辑核对清单"; +"TodoItemQuote" = "回复选项"; +"TodoItemsTitle" = "核对清单"; +"TodoMenuHint" = "您正在查看特定任务的操作选单。 \n您可以切換到清单的操作选单。"; +"TodoMenuTabList" = "清单"; +"TodoMenuTabTask" = "任务"; +"TodoNewTask" = "添加任务"; +"TodoNewTaskInfo#other" = "您可以新增其他 %d 个任务。"; +"TodoPremiumRequired" = "只有 **Telegram 高级版**订阅者可以将任务标记为已完成。"; +"TodoTaskCompleted" = "un1 将 \"%s\" 标记为已完成"; +"TodoTaskCompletedOut" = "您将 \"%s\" 标记为已完成"; +"TodoTaskCompletedOutUnknown" = "您将任务标记为完成"; +"TodoTaskCompletedUnknown" = "un1 将任务标记为已完成"; +"TodoTaskNotCompleted" = "un1 将 \"%s\" 标记为未完成"; +"TodoTaskNotCompletedOut" = "您将 \"%s\" 标记为未完成"; +"TodoTaskNotCompletedOutUnknown" = "您已将一项任务标示为未完成"; +"TodoTaskNotCompletedUnknown" = "un1 将任务标记为未完成"; +"TodoTaskPlaceholder" = "任务"; +"TodoTitle" = "新核对清单"; +"TodoTitlePlaceholder" = "标题"; +"TodoUncheck" = "取消选取"; +"TonCount#other" = "%d Gram"; +"TonCountX" = "%s Gram"; +"TonNeededTextGiftBuyResale" = "充值 **Gram** 以购买礼物。"; +"TonNeededTextSuggestMessage" = "购买 **Gram** 以向 **%s** 及其他人推荐贴文。"; +"TonNeededTitle" = "需要 %s Gram"; +"TonStats" = "数据统计"; +"TonTopUp" = "充值"; +"TooLargeError" = "\u200e太大\u200e"; +"TooLongError" = "过长\u200e"; +"TooManyCommunities" = "社区太多"; +"TooManyCommunitiesHintCreate" = "抱歉,您加入了太多的群组或频道。请在创建新的之前退出一些。"; +"TooManyCommunitiesHintEdit" = "抱歉,您加入的群组与频道过多。由于技术原因,如果您需要继续修改群组设定,请退出一些群组和频道。"; +"TooManyCommunitiesHintJoin" = "抱歉,您加入了太多群组或频道,请在加入新的之前退出一些。"; +"TooManyTries" = "尝试次数过多,请在 %1$s 后重试。"; +"TopAdmins" = "活跃管理员"; +"TopDaysOfWeekChartTitle" = "本周活跃日"; +"TopHoursChartTitle" = "每小时浏览次数 (UTC)"; +"TopInviters" = "最佳邀请者"; +"TopMembers" = "最活跃的成员"; +"TopUpViaFragment" = "通过 Fragment 充值"; +"TopUpViaFragmentInfo" = "您可以通过 Fragment 充值您的 Gram 余额。"; +"TopUpViaFragmentLink" = "https://fragment.com/ads/topup"; +"TopicChangeIconAndTitleTo" = "%1$s 将话题名称与图标更改为 %2$s"; +"TopicChangeIconAndTitleToInReply" = "已将话题名称与图标更改为%s"; +"TopicClosed" = "话题已锁定"; +"TopicClosed2" = "%s 已关闭话题"; +"TopicClosedByAdmin" = "该话题已禁用"; +"TopicContainsEmojiPackSingle" = "此话题包含 %s"; +"TopicCreated" = "话题已创建"; +"TopicCreator" = "话题创建者"; +"TopicEmptyViewDescription" = "发送消息以开始话题。"; +"TopicHidden" = "通用话题已隐藏"; +"TopicHidden2" = "%s 已隐藏通用话题"; +"TopicIconChanged" = "话题图标已更改"; +"TopicIconChangedTo" = "%1$s 已将话题图标更改为 %2$s"; +"TopicIconChangedToInReply" = "已将话题图标更改为 %s"; +"TopicNotificationsExceptions#other" = "**%d 个话题**被列为例外。"; +"TopicProfileStatus" = "在 %s"; +"TopicRenamed" = "话题名称已更改"; +"TopicRenamedTo" = "%1$s 已将话题名称更改为 %2$s"; +"TopicRenamedToInReply" = "已将话题名称更改为 %s"; +"TopicRestarted" = "话题已解锁"; +"TopicRestarted2" = "%s 已重新打开话题"; +"TopicShown" = "通用话题已取消隐藏"; +"TopicShown2" = "%s 已取消隐藏通用话题"; +"TopicViewAsMessages" = "以消息模式查看"; +"TopicViewAsTopics" = "以话题模式查看"; +"TopicWasClosedAction" = "%1$s 已关闭 %2$s"; +"TopicWasCreatedAction" = "已创建 %s 话题"; +"TopicWasIconChangedToAction" = "%1$s 已将话题图标更改为 %2$s"; +"TopicWasRenamedToAction" = "%1$s 已将话题名称更改为 %2$s"; +"TopicWasRenamedToAction2" = "%1$s 已将话题名称与图标更改为 %2$s"; +"TopicWasReopenedAction" = "%1$s 已重新开启 %2$s"; +"Topics" = "话题"; +"TopicsDeleted#other" = "话题已删除。"; +"TopicsEnable" = "启用话题"; +"TopicsInfo" = "群组聊天将划分成由管理员或成员建立的话题。"; +"TopicsLayout" = "话题布局"; +"TopicsLayoutInfo" = "选择话题向所有成员显示的方式。"; +"TopicsLayoutList" = "列表"; +"TopicsLayoutTabs" = "标签"; +"TopicsTitle" = "话题"; +"TopicsTitleMedia" = "媒体"; +"TosAgeText" = "点击“同意”确认您已满 %1$s 周岁。"; +"TosAgeTitle" = "年龄验证"; +"TosDecline" = "很遗憾,这意味着您无法注册 Telegram。\n\n与其它应用不同,Telegram 不会利用您的个人数据进行广告定位或其它商业目的。Telegram 储存的所有信息都是为了给您创造一个功能丰富的云服务体验。 您可以在「隐私和安全」 里调整我们使用您数据的方式(例如:删除已同步的联系人)。\n\n但若您无法接受 Telegram 的数据收集政策,我们将无法为您提供此服务。"; +"TosDeclineDeleteAccount" = "警告,这将不可逆转地删除您的 Telegram 帐号以及所有储存在 Telegram 云服务器中的数据。\n\n注意:为保留个人数据,您可以先行取消,在导出数据并保存后,再删除您的帐号。(若要导出个人数据,请打开 Telegram 最新桌面版,并进入设置 > 高级 > 导出 Telegram 数据 。)"; +"TosUpdateDecline" = "很遗憾,目前没有其他的选择。与其它应用不同,我们不会利用您的个人数据来进行广告定位或其它商业目的。Telegram 储存的所有信息都是为了给您创造一个功能丰富的云服务体验。您可以在「隐私和安全」设置里调整我们使用您数据的方式(例如:删除已同步的联系人)。\n\n如果您不满意 Telegram 的数据收集政策,我们将无法向您提供该服务。"; +"TotalDataUsage" = "总计"; +"TotalDeviceFreeSize" = "%s 空闲"; +"TotalDeviceSize" = "%s 其他应用"; +"TotalNetworkUsage" = "总计网络用量"; +"TranscriptionReportSent" = "感谢您的反馈。"; +"TranscriptionTrialEnd#other" = "您本周的 **%1$d** 次免费语音转录额度已用尽。"; +"TranscriptionTrialEndBuy" = "订阅 **Telegram 高级版** 以解锁无限量的语音转文字功能。"; +"TranscriptionTrialEndWaitOrBuy" = "请等待至 %1$s 或者现在订阅 **Telegram 高级版**。"; +"TranscriptionTrialLeft#other" = "您还剩余 **%1$d** 次免费语音转录。"; +"TranscriptionTrialLeftUntil#other" = "在 %2$s 前,您还剩余 **%1$d** 次免费的语音转录。"; +"TranslateCopy" = "复制译文"; +"TranslateEntireChat" = "翻译整个聊天"; +"TranslateLanguageAR" = "阿拉伯语 (العربية)"; +"TranslateLanguageBE" = "白俄罗斯语"; +"TranslateLanguageCA" = "加泰罗尼亚语 (Català)"; +"TranslateLanguageCS" = "捷克语 (Čeština)"; +"TranslateLanguageDE" = "德语"; +"TranslateLanguageEN" = "英语 English"; +"TranslateLanguageEO" = "世界语"; +"TranslateLanguageES" = "西班牙语 (Español)"; +"TranslateLanguageFA" = "波斯语 (فارسی)"; +"TranslateLanguageFI" = "芬兰语 (Suomi)"; +"TranslateLanguageFR" = "法语 (Français)"; +"TranslateLanguageGenitiveAR" = "阿拉伯语"; +"TranslateLanguageGenitiveBE" = "白俄罗斯语"; +"TranslateLanguageGenitiveCA" = "加泰罗尼亚语"; +"TranslateLanguageGenitiveDE" = "德语"; +"TranslateLanguageGenitiveEN" = "英语"; +"TranslateLanguageGenitiveEO" = "世界语"; +"TranslateLanguageGenitiveES" = "西班牙语"; +"TranslateLanguageGenitiveFA" = "波斯语"; +"TranslateLanguageGenitiveFI" = "芬兰语"; +"TranslateLanguageGenitiveFR" = "法语"; +"TranslateLanguageGenitiveHE" = "希伯来语"; +"TranslateLanguageGenitiveHR" = "克罗地亚语"; +"TranslateLanguageGenitiveHU" = "匈牙利语"; +"TranslateLanguageGenitiveID" = "印尼语"; +"TranslateLanguageGenitiveIT" = "意大利语"; +"TranslateLanguageGenitiveJA" = "日语"; +"TranslateLanguageGenitiveKO" = "韩语"; +"TranslateLanguageGenitiveKZ" = "哈萨克语"; +"TranslateLanguageGenitiveML" = "马拉雅拉姆语"; +"TranslateLanguageGenitiveMS" = "马来语"; +"TranslateLanguageGenitiveNL" = "荷兰语"; +"TranslateLanguageGenitiveNO" = "挪威语"; +"TranslateLanguageGenitivePO" = "波兰语"; +"TranslateLanguageGenitivePT" = "葡萄牙语"; +"TranslateLanguageGenitiveRO" = "罗马尼亚语"; +"TranslateLanguageGenitiveRU" = "俄语"; +"TranslateLanguageGenitiveSK" = "斯洛伐克语"; +"TranslateLanguageGenitiveSR" = "塞尔维亚语"; +"TranslateLanguageGenitiveSV" = "瑞典语"; +"TranslateLanguageGenitiveTA" = "泰米尔语"; +"TranslateLanguageGenitiveTR" = "土耳其语"; +"TranslateLanguageGenitiveUK" = "乌克兰语"; +"TranslateLanguageGenitiveUZ" = "乌兹别克语"; +"TranslateLanguageGenitiveZH" = "中文"; +"TranslateLanguageHE" = "希伯来语"; +"TranslateLanguageHR" = "克罗地亚语"; +"TranslateLanguageHU" = "匈牙利语 (Magyar)"; +"TranslateLanguageID" = "印度尼西亚语 (Bahasa Indonesia)"; +"TranslateLanguageIT" = "意大利语 (Italiano)"; +"TranslateLanguageJA" = "日语 (日本語)"; +"TranslateLanguageKO" = "韩语 (한국어)"; +"TranslateLanguageKZ" = "哈萨克语"; +"TranslateLanguageML" = "马拉雅拉姆语 (മലയാളം)"; +"TranslateLanguageMS" = "马来语 (Bahasa Melayu)"; +"TranslateLanguageNL" = "荷兰语 (Nederlands)"; +"TranslateLanguageNO" = "挪威语 (Norsk)"; +"TranslateLanguagePO" = "波兰语 (Polski)"; +"TranslateLanguagePT" = "葡萄牙语 (Português)"; +"TranslateLanguageRO" = "罗马尼亚语 (Română)"; +"TranslateLanguageRU" = "俄罗斯语 (Русский)"; +"TranslateLanguageSK" = "斯洛伐克语"; +"TranslateLanguageSR" = "塞尔维亚语"; +"TranslateLanguageSV" = "瑞典语 (Svenska)"; +"TranslateLanguageTA" = "泰米尔语"; +"TranslateLanguageTR" = "土耳其语 (Türkçe)"; +"TranslateLanguageUK" = "乌克兰语 (Українська)"; +"TranslateLanguageUZ" = "乌孜别克语 (Oʻzbekcha)"; +"TranslateLanguageZH" = "中文"; +"TranslateMessage" = "翻译"; +"TranslateMessages" = "翻译消息"; +"TranslateMessagesInfo1" = "当您点击一则文本消息时,“翻译”按钮就会出现。"; +"TranslateMessagesInfo2" = "Google 可能会访问您翻译的消息。"; +"TranslateMore" = "翻译更多…"; +"TranslateTo" = "翻译为"; +"TranslateToButton" = "翻译为%s"; +"TranslateToButtonOther" = "翻译为%s"; +"TranslationBarHiddenForChannel" = "已在此频道中隐藏翻译栏。"; +"TranslationBarHiddenForChat" = "已在此聊天中隐藏翻译栏。"; +"TranslationBarHiddenForGroup" = "已在此群组中隐藏翻译栏。"; +"TranslationFailedAlert1" = "翻译失败,请稍后再试。"; +"TranslationFailedAlert2" = "翻译失败。"; +"TrashHintDrag" = "拖动至此以删除"; +"TrashHintRelease" = "松开以删除"; +"TryAgain" = "请重试"; +"TurnPasswordOff" = "停用密码"; +"TurnPasswordOffPassport" = "所有存储在 Telegram 通行证里的数据都将丢失。"; +"TurnPasswordOffQuestion" = "您确定要停用密码吗?"; +"TurnPasswordOffQuestionTitle" = "停用密码"; +"TwoStepVerification" = "两步验证"; +"TwoStepVerificationPasswordReturnPassport" = "跳转到Telegram 护照"; +"TwoStepVerificationPasswordReturnSettings" = "跳转到设置"; +"TwoStepVerificationPasswordSet" = "密码已设置!"; +"TwoStepVerificationPasswordSetInfo" = "在新设备上登录时除了短信验证码还需要输入此两步验证密码。"; +"TwoStepVerificationSetPassword" = "设置密码"; +"TwoStepVerificationShowPassword" = "显示密码"; +"TwoStepVerificationTitle" = "两步验证"; +"TypeLocationGroup" = "公开链接"; +"TypeLocationGroupEdit" = "点击并添加公开链接"; +"TypeMessage" = "输入消息"; +"TypeMessageAskBot" = "询问 %1$s"; +"TypeMessageForStars" = "每条消息 ⭐️%s"; +"TypeMessageIn" = "%1$s 中的消息"; +"TypePrivate" = "私密"; +"TypePrivateGroup" = "私人群组"; +"TypePrivateGroupRestrictedForwards" = "私人"; +"TypePrivateRestrictedForwards" = "私人"; +"TypePublic" = "公开"; +"TypePublicGroup" = "公共群组"; +"TypeScheduleMessage" = "定时消息"; +"Typing" = "正在输入…"; +"URL" = "链接"; +"UnableForward" = "无法转发"; +"Unarchive" = "取消归档"; +"UnarchiveStories" = "取消隐藏动态"; +"Unavailable" = "不可用"; +"Unban" = "解除封禁"; +"UnbanText" = "长按将用户解除封禁。"; +"UnbanUserMonoforum" = "在频道中解除封锁用户"; +"Unblock" = "取消屏蔽用户"; +"UnblockText" = "长按取消用户屏蔽。"; +"UnconfirmedAuthConfirm" = "是的,这是我"; +"UnconfirmedAuthConfirmed" = "允许新登录"; +"UnconfirmedAuthConfirmedBot" = "允许新机器人"; +"UnconfirmedAuthConfirmedMessage" = "您可以在 **设置 > 设备** 中查看您的活跃登录列表。"; +"UnconfirmedAuthDeniedMessageMultiple" = "我们已终止以下来源的登录尝试:%s"; +"UnconfirmedAuthDeniedMessageSingle" = "我们已终止来自 %s 的登录尝试。"; +"UnconfirmedAuthDeniedTitle#other" = "新登录已被阻止"; +"UnconfirmedAuthDeniedWarning" = "切勿将您的登录代码发送给任何人,否则您可能会失去您的 Telegram 帐户!"; +"UnconfirmedAuthDeny" = "不,这不是我!"; +"UnconfirmedAuthMultiple#other" = "我们检测到您的帐号有 %d 次新登录,是您本人吗?"; +"UnconfirmedAuthMultipleFrom#other" = "我们检测到 %d 次来自 %s 的新登录,这是您本人吗?"; +"UnconfirmedAuthSingle" = "我们检测到您的账号有一次来自 %s 的新登录。这是您本人操作的吗?"; +"UnconfirmedAuthSingleBot" = "%1$s 已从 %2$s 连接到您的账户。这是您吗?"; +"UnconfirmedAuthTitle" = "有人刚刚访问了您的消息!"; +"UnconfirmedAuthTitleBot" = "机器人刚刚获得了访问你消息的权限!"; +"Underline" = "下划线"; +"Understood" = "👌 了解"; +"Undo" = "撤消"; +"UndoAllCustom" = "撤消您对联系人、群组和频道的所有自定义通知设置。"; +"UndoNoCaps" = "撤销"; +"Unhide" = "取消隐藏"; +"UnhideFromTop" = "置顶"; +"UniqueGiftNotFound" = "未找到礼物。"; +"UniqueGiftNotFoundBurned" = "礼物已销毁。"; +"UnknownError" = "未知错误"; +"UnknownErrorCode" = "未知错误:%s"; +"UnknownLocation" = "未知位置"; +"Unlock" = "解锁"; +"UnlockBoostChannelAutotranslation" = "消息自动翻译"; +"UnlockBoostChannelFeatures" = "解锁功能"; +"UnlockFingerprint" = "指纹解锁"; +"UnlockPaidContent#other" = "花费 ⭐️%d 解锁"; +"UnlockPaidContentShort#other" = "⭐️%d"; +"UnlockPremium" = "订阅 **Telegram 高级版**以解锁此功能和许多其他功能。"; +"UnlockPremiumEmoji" = "解锁高级表情"; +"UnlockPremiumEmojiHint" = "订阅 **Telegram 高级版** 解锁该表情。"; +"UnlockPremiumEmojiHint2" = "尝试在 **我的收藏** 中免费发送这些表情来体验。"; +"UnlockPremiumEmojiPack" = "解锁 %1$s"; +"UnlockPremiumEmojiReaction" = "订阅 **Telegram 高级版** 以解锁此表情回应。"; +"UnlockPremiumIcons" = "解锁高级版图标"; +"UnlockPremiumReactions" = "解锁高级版消息反馈"; +"UnlockPremiumReactionsDescription" = "订阅 Telegram 高级版,您就可以解锁额外的回应图标。"; +"UnlockPremiumStickers" = "解锁会员专属贴纸"; +"UnlockPremiumStickersDescription" = "订阅 Telegram 高级版即可解锁这个和更多贴纸。"; +"UnlockPremiumTranscriptionHint" = "订阅 **Telegram 高级版** 以将语音消息转换为文字。 "; +"UnlockSimilar" = "解锁更多频道"; +"UnlockSimilarChannelsPremium#other" = "订阅 **Telegram 高级版** 以解锁最多 %d 个相似频道。"; +"UnlockStealthMode" = "解锁隐身模式"; +"UnlockToUse" = "解锁以使用 Telegram"; +"Unmute" = "取消静音"; +"UnmuteNotifications" = "开启通知"; +"UnofficialApp" = "非官方应用"; +"UnpinAllMessages" = "取消所有消息置顶"; +"UnpinAllMessagesNoCaps" = "取消所有置顶"; +"UnpinFromTop" = "取消置顶"; +"UnpinMessage" = "取消置顶"; +"UnpinMessageAlert" = "您确定要为所有人取消置顶此消息吗?"; +"UnpinMessageAlertTitle" = "取消置顶消息"; +"UnpinnedDialogsCount#other" = "%1$d 个聊天已取消置顶"; +"UnreadMessages" = "未读消息"; +"UnsaveMessagesTitle" = "取消保存 %1$s"; +"UnsaveSingleMessagesTitle" = "未收藏消息"; +"UnsavedChanges" = "未保存的更改"; +"UnsavedChangesMessage" = "您已更改此邀请链接的设置,是否应用?"; +"UnsupportedAttachment" = "不支持的附件类型"; +"UnsupportedMedia" = "您当前的 Telegram 版本无法查看此消息。请前往这里更新到新版:https://telegram.org/update"; +"UnsupportedMedia2" = "您当前的 Telegram 版本不支持此消息。请更新至最新版本。"; +"UnsupportedMediaStandalone" = "您的 Telegram 版本不支持此信息。请更新应用程序以查看:https://telegram.org/dl"; +"UnverifiedApp" = "未经验证的应用"; +"Unvote" = "撤回投票"; +"UpToFileSize" = "不超过%s"; +"Update" = "更新"; +"UpdateApp" = "升级应用"; +"UpdateAppAlert" = "抱歉,您的 Telegram 版本太低,无法处理此请求。 请升级 Telegram。"; +"UpdateContactsMessage" = "Telegram 检测到很多未同步的联系人,您想现在将其同步吗?如果您使用的是自己的设备、SIM 卡和 Google 帐号,请选择“确定”。"; +"UpdateContactsTitle" = "更新联系人?"; +"UpdateNow" = "立刻更新"; +"UpdatePhotoForRest" = "更新公共照片"; +"UpdateTelegram" = "升级 Telegram"; +"UpdatedMinutes#other" = "更新于 %1$d 分钟前"; +"UpdatedQuoteMessage" = "**%s**\u00a0已更新您引用的消息,编辑您的引文使其保持最新状态。"; +"UpdatedQuoteTitle" = "引文已更新"; +"Updating" = "正在刷新…"; +"UpgradeGroup" = "升级为超级群组"; +"UpgradePremiumMessage" = "升级为 Telegram 高级版的年付计划即可享受优惠。"; +"UpgradePremiumPerMonth" = "支付每月 %s 并升级"; +"UpgradePremiumPerYear" = "支付每年 %s 并升级"; +"UpgradePremiumTitle" = "享受 **%1$d%%** 折扣优惠订阅 Telegram 高级版"; +"UpgradedStories" = "升级动态"; +"UploadBotPreview" = "添加"; +"UploadImage" = "上传图片"; +"UploadItems" = "上传 %1$s"; +"UploadSound" = "上传铃声"; +"UploadSpeedLimited" = "上载速度受限"; +"UploadSpeedLimitedMessage" = "订阅 **Telegram 高级版** 可将上传速度提升 %d 倍。"; +"UploadingStory" = "正在上传…"; +"UsbActive" = "已启用 USB 传输"; +"UseDifferentTheme" = "使用其他主题"; +"UseDifferentThemeInfo" = "您还可以使用现有的自定义主题作为主题的基础。"; +"UseEmoji" = "使用表情"; +"UseFileManger" = "转至另外一个应用"; +"UseLessDataAlways" = "总是"; +"UseLessDataNever" = "从不"; +"UseLessDataOnMobile" = "仅在使用移动数据时"; +"UseLessDataOnRoaming" = "仅在漫游时"; +"UsePIN" = "使用 PIN 码"; +"UseProxyAddress" = "服务器"; +"UseProxyForCalls" = "通话时使用代理"; +"UseProxyForCallsInfo" = "使用代理服务器可能降低通话质量。"; +"UseProxyInfo" = "SOCKS5 代理设置"; +"UseProxyPassword" = "密码"; +"UseProxyPort" = "端口"; +"UseProxyRotation" = "自动切换代理"; +"UseProxySecret" = "密钥"; +"UseProxySettings" = "使用代理"; +"UseProxySocks5" = "SOCKS5 代理"; +"UseProxySponsor" = "代理赞助商"; +"UseProxySponsorInfo" = "此频道是由您的代理服务器所展示的。若要从您的对话列表中移除此频道,在 Telegram 设置 > 数据和存储中停用代理即可。"; +"UseProxyTelegram" = "MTProto"; +"UseProxyTelegramError" = "由于配置不正确,您正在使用的代理已被停用。请搜寻其它代理。"; +"UseProxyTelegramInfo" = "MTProto 代理设置。"; +"UseProxyTelegramInfo2" = "这个代理可能会在您的对话列表中显示其赞助频道。代理服务器无法泄露任何您的 Telegram 内容。"; +"UseProxyTitle" = "代理服务器"; +"UseProxyUsername" = "用户名"; +"UserAcceptedToGroupAction" = "un1 已获批加入群组"; +"UserAcceptedToGroupPush" = "%1$s 已获批加入群组"; +"UserAcceptedToGroupPushWithGroup" = "%2$s|%1$s 已获批加入群组"; +"UserAddedAsAdminHint" = "已添加 **%1$s** 为管理员"; +"UserAffiliateProgramRowText" = "分享小程序链接,赚取星星分成收益。"; +"UserAffiliateProgramRowTitle" = "赚取星星"; +"UserBio" = "个人简介"; +"UserBioDetail" = "添加一些您的自我介绍"; +"UserBioEmpty" = "< 空 >"; +"UserBioInfo" = "您可以写点东西介绍一下自己。在“设置”中可以选择谁能看到您的个人简介。"; +"UserBirthdayHint" = "🎂 您今天可以向 %1$s 发送生日礼物。"; +"UserBlocked" = "已屏蔽该用户"; +"UserBlockedNonPremium" = "**%s** 仅接受来自联系人和高级版订阅用户的消息。"; +"UserBlockedNonPremiumButton" = "查看"; +"UserBlockedRepliesNonPremium" = "您需要订阅 **高级版** 才能回复 **%s** 的动态。"; +"UserChannelTooMuchJoin" = "抱歉,对方加入了太多群组和频道。请让其先退出一些。"; +"UserColorApplied" = "您频道颜色已更新!"; +"UserColorApply" = "应用风格"; +"UserColorApplyCollectible" = "佩戴典藏品"; +"UserColorApplyIcon" = "应用颜色与图标"; +"UserColorApplyPremium" = "订阅 **Telegram 高级版** 并为您的名字选择自定义颜色。"; +"UserColorHint" = "您可以选择一种颜色为自己的名字、发送的链接和收到的回复着色。"; +"UserColorPreview" = "你的名称与回复将以所选的颜色显示。"; +"UserColorPreviewLinkDescription" = "该颜色也会套用于您发送的链接预览。"; +"UserColorPreviewLinkTitle" = "链接预览"; +"UserColorPreviewReply" = "回复你的消息"; +"UserColorPreviewTitle" = "颜色预览"; +"UserColorTabName" = "名称"; +"UserColorTabProfile" = "个人资料"; +"UserColorTitle" = "你的名称颜色"; +"UserColorUnsaved" = "未保存的更改"; +"UserColorUnsavedMessage" = "您已更改颜色或图标设置,是否应用更改?"; +"UserCustomPhotoSeted" = "您将会在 **%s** 的帐号中看到这个头像。"; +"UserDisallowedGifts" = "**%s** 已停止接受礼物。"; +"UserProfileCollectibleHeader" = "使用礼物"; +"UserProfileCollectibleInfo" = "将您的典藏品独特外观应用到个人资料页。"; +"UserProfileColorApplied" = "您的个人简介颜色已更新!"; +"UserProfileColorApplyPremium" = "订阅 **Telegram 高级版** 给您的个人资料页选择自定颜色。"; +"UserProfileColorEmojiApplied" = "您已设定个人资料页图标!"; +"UserProfileColorReset" = "重置个人资料页颜色"; +"UserProfileColorResetApplied" = "您的个人简介颜色已重置!"; +"UserProfileHint" = "为您的个人资料页选择颜色和图标"; +"UserProfileHint2" = "您可以更改您名字的颜色并自定义您的回复。**更改 >**"; +"UserProfileIcon" = "为个人资料页添加图标"; +"UserRemovedBy" = "被 %1$s 移除"; +"UserRemovedFromChatHint" = "已将 **%1$s** 从 **%2$s** 中移除。"; +"UserReplyIcon" = "为回复添加图标"; +"UserReplyIconHint" = "通过向消息添加自定图标,使您回复的消息脱颖而出。"; +"UserReplyIconOff" = "关闭"; +"UserRestricted" = "抱歉,您不能执行此操作。"; +"UserRestrictions" = "用户权限"; +"UserRestrictionsApplyChanges" = "未保存的更改"; +"UserRestrictionsApplyChangesText" = "您已在%1$s中更改了此用户的权限 。\n应用此更改吗?"; +"UserRestrictionsBlock" = "封禁并从群组中移除"; +"UserRestrictionsBy" = "被 %1$s 限制"; +"UserRestrictionsCanDo" = "这位用户可以做什么?"; +"UserRestrictionsCanDoUsers#other" = "选中的 %1$s 位用户可以做什么?"; +"UserRestrictionsCantModify" = "已锁定的选项"; +"UserRestrictionsCantModifyDisabled" = "此操作已对所有成员禁用。请在群组权限中更改。"; +"UserRestrictionsCantModifyEnabled" = "已对所有成员启用此操作。请在群组权限中进行更改。"; +"UserRestrictionsChangeInfo" = "修改群组信息"; +"UserRestrictionsCustom" = "自定义"; +"UserRestrictionsDisabled" = "在群组权限中,该项已对所有成员停用"; +"UserRestrictionsDuration" = "时限"; +"UserRestrictionsEditTags" = "编辑自己的标签"; +"UserRestrictionsEmbedLinks" = "嵌入链接预览"; +"UserRestrictionsInviteUsers" = "添加用户"; +"UserRestrictionsNoChangeInfo" = "无权修改群组信息"; +"UserRestrictionsNoEditTags" = "禁止编辑标签"; +"UserRestrictionsNoEmbedLinks" = "无权嵌入链接"; +"UserRestrictionsNoInviteUsers" = "无权邀请新成员"; +"UserRestrictionsNoPinMessages" = "无权置顶"; +"UserRestrictionsNoRead" = "无法读取"; +"UserRestrictionsNoSend" = "无权发送信息"; +"UserRestrictionsNoSendDocs" = "暂无文件"; +"UserRestrictionsNoSendMedia" = "无权发送媒体文件"; +"UserRestrictionsNoSendMusic" = "暂无音频"; +"UserRestrictionsNoSendPhotos" = "暂无照片"; +"UserRestrictionsNoSendPolls" = "无权发起投票"; +"UserRestrictionsNoSendReactions" = "禁用表情回应"; +"UserRestrictionsNoSendRound" = "无权发送视频消息"; +"UserRestrictionsNoSendStickers" = "无权发送贴纸和 GIF 动态图"; +"UserRestrictionsNoSendText" = "无法发送文本消息"; +"UserRestrictionsNoSendVideos" = "暂无视频"; +"UserRestrictionsNoSendVoice" = "无权发送语音"; +"UserRestrictionsPinMessages" = "置顶消息"; +"UserRestrictionsRead" = "查看消息"; +"UserRestrictionsSend" = "发送消息"; +"UserRestrictionsSendFiles" = "发送文件"; +"UserRestrictionsSendMedia" = "发送媒体文件"; +"UserRestrictionsSendMusic" = "发送音频"; +"UserRestrictionsSendPhotos" = "发送图片"; +"UserRestrictionsSendPolls" = "发起投票"; +"UserRestrictionsSendPollsShort" = "发起投票"; +"UserRestrictionsSendReactions" = "作出回应"; +"UserRestrictionsSendRound" = "发送视频消息"; +"UserRestrictionsSendStickers" = "发送贴纸与 GIF"; +"UserRestrictionsSendText" = "发送文本消息"; +"UserRestrictionsSendVideos" = "发送视频"; +"UserRestrictionsSendVoices" = "发送语音消息"; +"UserRestrictionsUntil" = "限制至"; +"UserRestrictionsUntilForever" = "永久"; +"UserSelectorLimit#other" = "您最多可以选择 %d 个用户。"; +"UserSetAsAdminHint" = "已提升 **%1$s** 为管理员。"; +"UserSetPhoto" = "为 %s 设置头像"; +"UserSuggestBirthday" = "建议生日"; +"UserSuggestBirthdayButton" = "建议出生日期"; +"UserSuggestBirthdayTitle" = "%s 的生日"; +"UserUnblocked" = "用户已取消屏蔽。"; +"Username" = "用户名"; +"UsernameActivateErrorMessage" = "抱歉,现有活跃公开链接过多,请先隐藏一个活跃公开链接。"; +"UsernameActivateErrorTitle" = "活跃链接过多"; +"UsernameActivateLink" = "激活链接"; +"UsernameActivateLinkChannelMessage" = "你想要将此链接显示在频道信息页面上吗?"; +"UsernameActivateLinkProfileMessage" = "你想要将此链接显示在你的信息页面上吗?"; +"UsernameAlso" = "同时 %1$s"; +"UsernameAvailable" = "%1$s 可用。"; +"UsernameChecking" = "正在检查用户名…"; +"UsernameCopied" = "用户名已复制到粘贴板"; +"UsernameDeactivateLink" = "链接已失效"; +"UsernameDeactivateLinkChannelMessage" = "是否在频道信息页面上隐藏此链接?"; +"UsernameDeactivateLinkProfileMessage" = "是否在您的信息页面上隐藏此链接?"; +"UsernameEmpty" = "< 空 >"; +"UsernameHelp" = "您可以在 **Telegram** 上设置一个用户名。设置后,别人可以通过此用户名找到您并与您联系,而不需要您的手机号码。\n\n您可以使用 **a-z**、**0-9** 和下划线。最小长度为 **5** 个字符。"; +"UsernameHelpLink" = "通过此链接可以与您聊天:\n%1$s"; +"UsernameInUse" = "此用户名已被使用。"; +"UsernameInUsePurchase" = "此用户名已被占用。\n但是,目前可以购买。**了解更多...**"; +"UsernameInvalid" = "此用户名不符合规范。"; +"UsernameInvalidLong" = "用户名不能超过 32 个字符。"; +"UsernameInvalidShort" = "用户名必须包含至少 5 个字符。"; +"UsernameInvalidShortPurchase" = "一个基本的用户名必须至少有5个字符。\n但是,您可以购买包括这个在内的简短的可收藏用户名。**了解更多...**"; +"UsernameInvalidStartNumber" = "用户名不能以数字开头。"; +"UsernameLinkActive" = "活跃"; +"UsernameLinkEditable" = "点击以编辑"; +"UsernameLinkInactive" = "不可用"; +"UsernameLinkPlaceholder" = "用户名"; +"UsernamePlaceholder" = "您的用户名"; +"UsernameProfileLinkActive" = "活跃"; +"UsernameProfileLinkEditable" = "点击编辑"; +"UsernameProfileLinkInactive" = "不可用"; +"UsernamesChannelHeader" = "链接顺序"; +"UsernamesChannelHelp" = "拖放链接以更改它们在频道信息页面上的显示顺序。"; +"UsernamesProfileHeader" = "用户名顺序"; +"UsernamesProfileHelp" = "拖放链接以更改它们在您的信息页面上的显示顺序。"; +"Users#other" = "%1$d 位用户"; +"UsersBanned#other" = "已封禁 %1$d 位用户。"; +"UsersCount#other" = "%1$d 用户"; +"UsersCountTitle#other" = "%1$d 用户"; +"UsersDeselectAll" = "取消全选"; +"UsersReported#other" = "已举报 %1$d 位用户发送垃圾消息。"; +"UsersRestricted#other" = "已限制 %1$d 位用户。"; +"UsesLimitHelp" = "您可以设定链接在使用一定次数后自动失效。"; +"UsesLimitHint" = "输入自定义限制次数"; +"VerificationCode" = "验证码"; +"VerificationCodeSubtitle" = "请查看你的邮箱 %1$s (不要忘记垃圾信箱)并输入我们发送的验证码。"; +"VerifyChatInfo" = "第三方服务(如网站和商店)可通过 Telegram 向您的手机号码发送验证码,无需使用短信。这些验证码将显示在此聊天中。\n\n如果您没有请求任何验证码 — 无需担心!可能是有人在输入号码时出错了。"; +"VerifyCodesNotifications" = "验证码"; +"ViaBot" = "通过"; +"Vibrate" = "振动"; +"VibrationDefault" = "默认"; +"VibrationDisabled" = "停用"; +"VideoCall" = "视频通话"; +"VideoCallAlert" = "您确定要给 %1$s 进行视频通话吗?"; +"VideoCallAlertTitle" = "视频通话"; +"VideoCallViaTelegram" = "Telegram 视频通话"; +"VideoCaption" = "视频说明"; +"VideoChooseCover" = "选择封面"; +"VideoConversionAction" = "视频将在转换优化后发布"; +"VideoConversionDone" = "视频已发布。"; +"VideoConversionDoneView" = "查看"; +"VideoConversionInfo" = "视频将在转换优化后发布。"; +"VideoConversionNowSend" = "仍然发送"; +"VideoConversionNowText" = "此视频尚未转换和优化。如果您现在发送,视频的观众可能会遇到下载速度缓慢的情況。"; +"VideoConversionNowTitle" = "稍等!"; +"VideoConversionText" = "此视频将在优化后发布,以获得最佳观看体验。"; +"VideoConversionTimeInfo" = "处理视频可能需要几分钟时间。"; +"VideoConversionTitle" = "改善视频中…"; +"VideoConvertFail" = "处理视频文件失败。"; +"VideoDoesNotSupportStreaming" = "此视频没有针对串流进行优化,您可能要完全下载它才能播放。"; +"VideoMessagesAutodownload" = "视频消息"; +"VideoMessagesRestrictedByPrivacy" = "**%1$s** 不接受视频消息。"; +"VideoOnceCloseMessage" = "您确定要停止收听并删除此视频消息吗?"; +"VideoOnceCloseTitle" = "关闭视频消息"; +"VideoOnceHint" = "此视频消息只能播放一次。"; +"VideoOnceOutHint" = "此消息将在 **%s** 播放一次后消失。"; +"VideoPlayerChromecast" = "Chromecast"; +"VideoPlayerLoop" = "循环播放"; +"VideoPlayerSpeed" = "速度"; +"VideoPreview" = "视频预览"; +"VideoPreviewDesrciption" = "即将向其他视频聊天成员共享您的摄像头视频"; +"VideoQualityIsTooLow" = "视频质量不佳,无法更改压缩设置。"; +"VideoSavedHint" = "视频已保存到相册。"; +"VideoSavedToDownloadsHint" = "视频已保存至下载文件夹。"; +"VideoSavedToDownloadsHintLinked" = "已将视频保存至 **下载文件夹**。"; +"VideoSetOnceHint" = "点击以设置此消息只能 **播放一次**。"; +"VideoSetOnceHintEnabled" = "收件人只能播放一次。"; +"VideoShareAddTimestamp" = "从 %s 开始"; +"VideoShownOnce" = "该视频只能观看一次。"; +"VideoSpeedCustom" = "自定义 %s"; +"VideoSpeedFast" = "快"; +"VideoSpeedNormal" = "正常"; +"VideoSpeedSlow" = "慢"; +"VideoSpeedVeryFast" = "非常快"; +"VideoSpeedVerySlow" = "非常慢"; +"VideoStartsAt" = "开始于 %s"; +"Videos#other" = "%1$d 个视频"; +"VideosSavedHint#other" = "%1$d 条视频已保存到相册。"; +"VideosSelected#other" = "已选中 %1$d 段视频"; +"ViewAction" = "查看"; +"ViewAnonymously" = "匿名\n查看"; +"ViewChannel" = "查看频道"; +"ViewChannelProfile" = "查看频道信息 >"; +"ViewChannelStats" = "查看频道数据"; +"ViewChatList" = "查看聊天列表"; +"ViewCollection" = "查看收藏集"; +"ViewContact" = "查看联系人"; +"ViewDetails" = "查看联系人"; +"ViewDiscussion" = "查看讨论"; +"ViewExceptions" = "查看例外"; +"ViewGroupProfile" = "查看群组信息 >"; +"ViewInChat" = "在聊天中查看"; +"ViewInTopic" = "查看话题"; +"ViewMessage" = "打开消息"; +"ViewMessageStatistic" = "消息数据分析"; +"ViewPackPreview" = "查看贴纸包"; +"ViewPhotoAction" = "查看头像"; +"ViewProfile" = "查看个人资料 >"; +"ViewReplies#other" = "查看 %1$d 条回复"; +"ViewStatistics" = "查看统计数据"; +"ViewStats" = "查看数据统计"; +"ViewStory" = "查看动态"; +"ViewThread" = "查看跟评"; +"ViewVideoAction" = "查看图片"; +"ViewWallpaperAction" = "查看壁纸"; +"Viewers" = "访客"; +"ViewersWatching#other" = "%1$d 位访客"; +"ViewersWatchingNobody" = "暂时没人观看"; +"ViewingMembers" = "浏览消息的成员"; +"Views#other" = "%1$s 次浏览"; +"ViewsAndSharesChartTitle" = "浏览和分享次数"; +"ViewsBySourceChartTitle" = "浏览来源"; +"ViewsPerPost" = "每则贴文的查看次数"; +"ViewsPerStory" = "每条动态的浏览量"; +"Vignette" = "晕影"; +"Voice#other" = "%1$d 条语音消息"; +"VoiceCallViaCarrier" = "通过运营商呼叫"; +"VoiceCallViaTelegram" = "Telegram 语音呼叫"; +"VoiceMessagesRestrictedByPrivacy" = "**%1$s** 不接受语音消息。"; +"VoiceOnceClose" = "关闭"; +"VoiceOnceCloseMessage" = "您确定要停止收听并删除语音消息吗?"; +"VoiceOnceCloseTitle" = "关闭语音消息"; +"VoiceOnceDeleteClose" = "删除并关闭"; +"VoiceOnceHint" = "此语音消息只能播放一次。"; +"VoiceOnceOutHint" = "一旦 **%s** 播放一次,此消息就会消失。"; +"VoiceOnceTurnOnSound" = "请先打开声音。"; +"VoicePauseHint" = "暂停以剪辑或重播"; +"VoiceSetOnceHint" = "点击以设置此消息只能 **播放一次**。"; +"VoiceSetOnceHintEnabled" = "接收者只能收听一次。"; +"VoipAddBio" = "添加个人简介"; +"VoipAddDescription" = "添加描述"; +"VoipAddPhoto" = "更换照片"; +"VoipAnswerCall" = "接听"; +"VoipAnsweringAsAccount" = "使用 %s 帐号接听"; +"VoipAudioRoutingBluetooth" = "蓝牙耳机"; +"VoipAudioRoutingEarpiece" = "听筒"; +"VoipAudioRoutingHeadset" = "耳机"; +"VoipAudioRoutingPhone" = "手机"; +"VoipAudioRoutingSpeaker" = "扬声器"; +"VoipAudioSwitchedToBluetooth" = "音频已切换到蓝牙。"; +"VoipAudioSwitchedToBluetoothDevice" = "音频已切换到 %s。"; +"VoipAudioSwitchedToHeadset" = "音频已切换到耳机。"; +"VoipAudioSwitchedToPhone" = "音频已切换到手机。"; +"VoipAudioSwitchedToSpeaker" = "音频已切换到扬声器。"; +"VoipBackCamera" = "后置相机"; +"VoipBusy" = "线路正忙"; +"VoipCallEncryptionEndToEnd" = "此通话为端对端加密"; +"VoipCallEnded" = "通话结束"; +"VoipCamera" = "相机"; +"VoipChannelCancelChat" = "取消直播"; +"VoipChannelChatJoined" = "**%1$s** 已加入直播。"; +"VoipChannelDisableComments" = "停用消息"; +"VoipChannelEditTitle" = "编辑直播标题"; +"VoipChannelEnableComments" = "启用消息"; +"VoipChannelEndAlertText" = "您想结束直播吗?"; +"VoipChannelEndAlertTitle" = "结束直播"; +"VoipChannelEndChat" = "结束直播"; +"VoipChannelInviteText" = "嘿!加入我们的直播:%1$s"; +"VoipChannelInvitedUser" = "您已邀请 **%1$s** 加入直播。"; +"VoipChannelJoinAnonymousAdmin" = "抱歉,您不能以匿名管理员的身份加入直播。"; +"VoipChannelJoinAnonymouseAlert" = "您确定要以个人账号的身份加入直播吗?"; +"VoipChannelJoinAs" = "以哪个身份加入直播…"; +"VoipChannelJoinVoiceChatUrl" = "加入直播"; +"VoipChannelLabelLive" = "直播"; +"VoipChannelLeaveAlertEndChat" = "结束直播"; +"VoipChannelLeaveAlertText" = "您确定要退出此直播吗?"; +"VoipChannelLeaveAlertTitle" = "退出直播"; +"VoipChannelLeaveConferenceAlertEndChat" = "结束群组通话"; +"VoipChannelLeaveConferenceAlertText" = "您要退出此群组通话吗?"; +"VoipChannelLeaveConferenceAlertTitle" = "离开群组通话"; +"VoipChannelOpenVoiceChat" = "开启直播"; +"VoipChannelRecordVoiceChat" = "直播录制"; +"VoipChannelRecording" = "正在录制直播"; +"VoipChannelRemoveMemberAlertText2" = "您想将 %1$s 移出直播并在 %2$s 中将其封禁吗?"; +"VoipChannelScheduleInfo" = "频道订阅者将收到通知,直播将在 %1$s 后开始。"; +"VoipChannelScheduleVoiceChat" = "预约直播"; +"VoipChannelScheduledVoiceChat" = "已预约的直播"; +"VoipChannelSoundMuted" = "直播音频已禁用"; +"VoipChannelSoundUnmuted" = "直播已取消静音。"; +"VoipChannelStart2" = "当您开始直播时将通知此频道的订阅者。"; +"VoipChannelStartRecordingRtmpVideoText" = "\u200e您确定要开始录制此直播并将结果保存为视频文件吗?\u200e"; +"VoipChannelStartRecordingVideoText" = "您想开始录制并将此次直播保存为视频文件吗?\n\n其他参与者将看到聊天正在被录制。"; +"VoipChannelStartStreaming" = "开始直播"; +"VoipChannelStartVoiceChat" = "开始直播"; +"VoipChannelStopRecordingText" = "您确定要结束录制直播吗?"; +"VoipChannelTitle" = "直播标题"; +"VoipChannelTooMuch" = "抱歉,此直播的人数已满。"; +"VoipChannelUserChanged" = "此直播的参与者将会看到您是 **%1$s** 。"; +"VoipChannelUserJoined" = "**%1$s** 已加入直播。"; +"VoipChannelVideoNotAvailableAdmin" = "直播人数已超过 %1$s。\n新的参与者只能通过语音串流。"; +"VoipChannelViewVoiceChat" = "查看直播"; +"VoipChannelVoiceChat" = "直播"; +"VoipChannelWatching" = "正在观看"; +"VoipChatActiveChats" = "正在进行的视频聊天"; +"VoipChatChatJoined" = "**%1$s** 已加入视频聊天。"; +"VoipChatDisplayedAs" = "您将显示为:"; +"VoipChatJoin" = "加入"; +"VoipChatLateBy" = "已过预定时间"; +"VoipChatNotify" = "通知我"; +"VoipChatRecentCalls" = "最近通话"; +"VoipChatReminderHint" = "我们将在开始前通知您。"; +"VoipChatShare" = "分享"; +"VoipChatStartScreenCapture" = "分享屏幕"; +"VoipChatStartsIn" = "开始于"; +"VoipChatStopScreenCapture" = "停止共享屏幕"; +"VoipChatStreamKey" = "直播密钥"; +"VoipChatStreamServerUrl" = "服务器 URL"; +"VoipChatStreamSettings" = "直播设置"; +"VoipChatStreamWithAnotherApp" = "你也可以通过 **另一个应用>** 直播"; +"VoipChatStreamWithAnotherAppDescription" = "请您在直播软件上开启推流后点击下方的「开始直播」。"; +"VoipChatStreamWithAnotherAppDescriptionStory" = "当你开始在直播应用中进行直播时,请点击下方的“连接直播”。"; +"VoipChatUserJoined" = "**%1$s** 已加入视频聊天。"; +"VoipConference" = "正在进行视频通话"; +"VoipConferenceAddPeople" = "添加成员"; +"VoipConferenceKicked" = "**%1$s** 已从群组聊天中移除。"; +"VoipConferenceLeave" = "离开视频通话"; +"VoipConferenceRemoveMemberAlertText2" = "您想要从此群组聊天中移除 %1$s 吗?"; +"VoipConferenceRemoveMemberAlertTitle2" = "移除参与者"; +"VoipConferenceShareLink" = "通过链接邀请"; +"VoipConnecting" = "正在连接"; +"VoipDeclineCall" = "拒绝"; +"VoipEditBio" = "编辑个人简介"; +"VoipEditDescription" = "编辑描述"; +"VoipEditName" = "编辑名称"; +"VoipEditTitle" = "编辑名称"; +"VoipEditTitleHint" = "名称"; +"VoipEndCall" = "结束通话"; +"VoipEndCall2" = "结束通话"; +"VoipErrorUnknown" = "暂时无法呼叫"; +"VoipExchangingKeys" = "正在交换加密密钥"; +"VoipFailed" = "连接失败"; +"VoipFeedbackCommentHint" = "添加评价(可选)"; +"VoipFlip" = "切换摄像头"; +"VoipFrontCamera" = "前置相机"; +"VoipGroupAdd" = "添加"; +"VoipGroupAddMemberText" = "您想要将 %1$s 添加到 %2$s 吗?"; +"VoipGroupAddMemberTitle" = "添加成员"; +"VoipGroupAllCanSpeak" = "新加入者可发言"; +"VoipGroupAllowToSpeak" = "允许讲话"; +"VoipGroupAudio" = "音频"; +"VoipGroupAudioRecordSaved" = "音频已保存至 **我的收藏**。"; +"VoipGroupAudioRecordStarted" = "语音通话录音已开始。"; +"VoipGroupBioEditAlertText" = "您可以添加一些关于你自己的信息。每个人都会看到此文本。"; +"VoipGroupBioEditTitle" = "个人简介"; +"VoipGroupCancelChat" = "取消视频聊天"; +"VoipGroupCancelRaiseHand" = "取消发言请求"; +"VoipGroupCancelReminder" = "取消提醒"; +"VoipGroupCancelReminderShort" = "不提醒"; +"VoipGroupConnecting" = "连接中....."; +"VoipGroupContinueAs" = "继续以 %1$s 作为展示"; +"VoipGroupCopyInviteLink" = "复制邀请链接"; +"VoipGroupCopyInviteLinkCopied" = "邀请链接已复制至剪贴板。"; +"VoipGroupCopyListenLink" = "复制听众邀请链接"; +"VoipGroupCopyListenLinkNoCaps" = "复制收听者链接"; +"VoipGroupCopySpeakerLink" = "复制发言人邀请链接"; +"VoipGroupCopySpeakerLinkNoCaps" = "复制说话人链接"; +"VoipGroupDisplayAs" = "将我显示为..."; +"VoipGroupEditPermissions" = "修改权限"; +"VoipGroupEditTitle" = "修改视频聊天标题"; +"VoipGroupEnd" = "结束"; +"VoipGroupEndAlertText" = "您确定要结束此次视频聊天吗?"; +"VoipGroupEndAlertTitle" = "结束视频聊天"; +"VoipGroupEndChat" = "结束视频聊天"; +"VoipGroupEndConference" = "结束群组通话"; +"VoipGroupEndedStartNew" = "视频聊天已结束。要发起新的视频聊天吗?"; +"VoipGroupExpandStream" = "最大化"; +"VoipGroupInCallBranding" = "呼入群组通话"; +"VoipGroupInviteAlreadyParticipant" = "选中的用户已加入此视频聊天。"; +"VoipGroupInviteCanSpeak" = "允许发言"; +"VoipGroupInviteListenOnly" = "仅限收听"; +"VoipGroupInviteMember" = "邀请成员"; +"VoipGroupInviteText" = "嘿!加入我们的视频聊天:%1$s"; +"VoipGroupInvitedToVoiceChat" = "un1 已邀请 un2 加入视频聊天"; +"VoipGroupInvitedUser" = "您已邀请 **%1$s** 加入视频聊天。"; +"VoipGroupJoinAnonymousAdmin" = "抱歉,您不能以匿名管理员的身份加入视频聊天。"; +"VoipGroupJoinAnonymouseAlert" = "您确定要在此视频聊天中以个人帐号出现吗?"; +"VoipGroupJoinAs" = "以哪个身份加入视频聊天…"; +"VoipGroupJoinAsLinstener" = "以听众身份加入"; +"VoipGroupJoinAsSpeaker" = "作为发言人加入"; +"VoipGroupJoinCall" = "加入视频聊天"; +"VoipGroupJoinVoiceChatUrl" = "加入视频聊天"; +"VoipGroupLeave" = "挂断"; +"VoipGroupLeaveAlertEndChat" = "结束视频聊天"; +"VoipGroupLeaveAlertText" = "您确定要退出当前视频聊天吗?"; +"VoipGroupLeaveAlertTitle" = "退出视频聊天"; +"VoipGroupMinimizeStream" = "最小化"; +"VoipGroupMute" = "静音"; +"VoipGroupMuteForMe" = "为我静音"; +"VoipGroupMuteMemberAlertText" = "您确定要在此视频聊天中将 **%1$s** 静音吗?"; +"VoipGroupMuteMemberAlertTitle" = "静音麦克风"; +"VoipGroupMutedForMe" = "已被静音"; +"VoipGroupOffline" = "您目前处于离线状态。请连接网络以加入视频聊天。"; +"VoipGroupOfflineAirplane" = "飞行模式已开启。请关闭飞行模式或连接无线网络以加入视频聊天。"; +"VoipGroupOnlyAdminsCanSpeak" = "将新加入者静音"; +"VoipGroupOpenChannel" = "打开频道"; +"VoipGroupOpenChat" = "打开聊天\n"; +"VoipGroupOpenGroup" = "打开群组"; +"VoipGroupOpenProfile" = "打开个人资料"; +"VoipGroupOpenVoiceChat" = "开启视频聊天"; +"VoipGroupPersonalAccount" = "个人账号"; +"VoipGroupRecordCall" = "开始录制"; +"VoipGroupRecording" = "正在录制音频流"; +"VoipGroupRemoveMemberAlertText" = "您想将 %1$s 从此群组中移除吗?"; +"VoipGroupRemoveMemberAlertText2" = "您确定要将 %1$s 从视频群聊中移除并在 %2$s 中将其封禁吗?"; +"VoipGroupRemoveMemberAlertTitle" = "移除成员"; +"VoipGroupRemoveMemberAlertTitle2" = "移除参与者"; +"VoipGroupRemovedFromGroup" = "**%1$s** 已被移出群组。"; +"VoipGroupSaveFileHint" = "录音标题"; +"VoipGroupScheduleInfo" = "群组成员将收到通知,视频聊天将在 %1$s 后开始。"; +"VoipGroupScheduleVoiceChat" = "预约视频聊天"; +"VoipGroupScheduledVoiceChat" = "已预约视频聊天"; +"VoipGroupSearchMembers" = "搜索要邀请的成员……"; +"VoipGroupSetReminder" = "设置提醒"; +"VoipGroupSetReminderShort" = "提醒我"; +"VoipGroupShareInviteLink" = "分享邀请链接"; +"VoipGroupShareLink" = "分享邀请链接"; +"VoipGroupSoundMuted" = "视频聊天已静音。"; +"VoipGroupSoundUnmuted" = "已将视频聊天中的声音取消静音。"; +"VoipGroupStart2" = "开始视频聊天后,该群组的成员将会收到通知。"; +"VoipGroupStartAs" = "以哪个身份发起视频聊天…"; +"VoipGroupStartAsInfo" = "选择您希望显示为个人帐号还是频道。"; +"VoipGroupStartAsInfoGroup" = "选择你想要以个人账号,群组,还是频道信息作为展示。"; +"VoipGroupStartNow" = "立即开始"; +"VoipGroupStartNowShort" = "立即开始"; +"VoipGroupStartRecordingRtmpText" = "是否要开始录制此聊天并将结果保存到音频文件中?"; +"VoipGroupStartRecordingRtmpVideoText" = "您确定要开始录制本次聊天,并将结果保存为视频文件吗?"; +"VoipGroupStartRecordingText" = "是否录制此次聊天并保存为音频文件?\n\n其他参与者会看到聊天正在被录音。"; +"VoipGroupStartRecordingTitle" = "开始录制"; +"VoipGroupStartRecordingVideoText" = "您想开始录制并将此次聊天保存为文件吗?\n\n其他参与者会看到聊天正在被录制。"; +"VoipGroupStartVoiceChat" = "开始视频聊天"; +"VoipGroupStopRecordCall" = "停止录制"; +"VoipGroupStopRecordingText" = "您确定要停止录制视频聊天吗?"; +"VoipGroupStopRecordingTitle" = "停止录音"; +"VoipGroupTitle" = "视频聊天标题"; +"VoipGroupTooMuch" = "抱歉,视频聊天人数已满。"; +"VoipGroupUnmute" = "开启话筒"; +"VoipGroupUnmuteForMe" = "取消静音"; +"VoipGroupUnmuteShort" = "取消静音"; +"VoipGroupUserCanNowSpeak" = "**%1$s** 现在能够发言。"; +"VoipGroupUserCanNowSpeakForYou" = "**%1$s** 已取消对您的静音。"; +"VoipGroupUserCantNowSpeak" = "**%1$s** 现已在此聊天中静音。"; +"VoipGroupUserCantNowSpeakForYou" = "当前聊天中您已将 **%1$s** 静音。"; +"VoipGroupUserChanged" = "此视频聊天的参与者将会看到您是 **%1$s**"; +"VoipGroupUserRemove" = "移除"; +"VoipGroupVideoChat" = "视频聊天"; +"VoipGroupVideoRecordSaved" = "视频已保存到 **我的收藏**。"; +"VoipGroupVideoRecordStarted" = "视频录制已开始。"; +"VoipGroupViewVoiceChat" = "查看视频聊天页面"; +"VoipGroupVoiceChat" = "视频聊天"; +"VoipGroupVolume" = "音量"; +"VoipGroupYouCanNowSpeak" = "您现在可以发言了。"; +"VoipGroupYouCanNowSpeakIn" = "您可以在 **%1$s** 中发言了。"; +"VoipHangingUp" = "正在挂断"; +"VoipHideEmoji" = "隐藏表情符号"; +"VoipHintEncryptionKey" = "此通话的加密密钥"; +"VoipHoldAndTalk" = "或按住按钮以通话"; +"VoipInCallBranding" = "Telegram 通话"; +"VoipInCallBrandingWithName" = "有人正在呼叫 %s"; +"VoipInConferenceCallBranding" = "Telegram 群组通话"; +"VoipInVideoCallBranding" = "Telegram 视频通话"; +"VoipInVideoCallBrandingWithName" = "给 %s 打 Telegram 视频电话"; +"VoipIncoming" = "来电"; +"VoipLiveStream" = "直播进行中"; +"VoipMessage" = "消息"; +"VoipMute" = "静音"; +"VoipMutedByAdmin" = "被管理员静音"; +"VoipMutedByAdminInfo" = "您处于只听模式"; +"VoipMutedByAdminShort" = "静音"; +"VoipMutedTapForSpeak" = "想要发言时请点此举手"; +"VoipMutedTapedForSpeak" = "举手发言"; +"VoipMutedTapedForSpeakInfo" = "举手让管理员知道你想要发言"; +"VoipMutedTapedForSpeakShort" = "请稍等..."; +"VoipMyMicrophoneState" = "您的麦克风已关闭"; +"VoipNeedCameraPermission" = "Telegram 需要访问您的相机,以便进行视频通话。\n\n点击“设置” > “权限”,然后开启相机访问权限。"; +"VoipNeedMicCameraPermission" = "Telegram 需要访问您的麦克风与相机,以便您进行视频通话。\n\n点击“设置” > “权限”,然后开启麦克风和相机访问权限。"; +"VoipNeedMicCameraPermissionWithHint" = "**Telegram** 需要摄像头和麦克风权限,以便你可以进行视频通话。点击选择“设置”>“权限”,并将**麦克风**和**摄像头**开启。"; +"VoipNeedMicPermission" = "Telegram 需要访问您的麦克风,以便您拨打电话。\n\n点击“设置” > “权限”,然后开启麦克风访问权限。"; +"VoipNeedMicPermissionWithHint" = "**Telegram** 需要麦克风权限,以便拨打电话。点击选择“设置”>“权限”,并将**麦克风**开启。"; +"VoipNoiseCancellation" = "噪音抑制"; +"VoipNoiseCancellationDisabled" = "已停用"; +"VoipNoiseCancellationEnabled" = "已启用"; +"VoipNotificationSettings" = "通话"; +"VoipOffline" = "您现在正处于离线状态。请连接到互联网后再进行通话。"; +"VoipOfflineAirplane" = "您已开启飞行模式。请将其关闭或者连接到 Wi-Fi 后再进行通话。"; +"VoipOfflineAirplaneTitle" = "飞行模式"; +"VoipOfflineOpenSettings" = "设置"; +"VoipOfflineTitle" = "离线"; +"VoipOngoingAlert" = "您是否要挂断与 **%1$s** 的通话并与 **%2$s** 开始新通话?"; +"VoipOngoingAlert2" = "是否结束与 **%1$s** 的通话并与 **%2$s** 开始视频聊天?"; +"VoipOngoingAlertTitle" = "另一个通话正在进行"; +"VoipOngoingChatAlert" = "是否要退出与 **%1$s** 的视频聊天并发起与 **%2$s** 的视频聊天?"; +"VoipOngoingChatAlert2" = "是否要退出与 %1$s 的视频聊天并呼叫 %2$s?"; +"VoipOngoingChatAlertTitle" = "正在进行其他视频聊天"; +"VoipOngoingConferenceChatAlert" = "要退出与 **%1$s** 的群组通话并与 **%2$s** 开始新的通话吗?"; +"VoipOngoingConferenceChatAlert2" = "退出与 **%1$s** 的群组通话并呼叫 **%2$s**?"; +"VoipOutgoingCall" = "Telegram 通话进行中"; +"VoipOutputDevices" = "输出设备"; +"VoipPeerIncompatible" = "**%1$s** 的应用使用了不兼容的协议。在您能够呼叫他们之前,他们必须先更新他们的应用。"; +"VoipPeerOutdated" = "**%1$s** 的应用不支持通话。在您能够呼叫他们之前,他们必须先更新他们的应用。"; +"VoipPeerVideoOutdated" = "**%1$s** 使用的应用不支持视频通话。在您呼叫前,对方应先更新应用。"; +"VoipPeerVideoOutdatedMakeVoice" = "拨打语音电话"; +"VoipPhoneScreen" = "手机屏幕"; +"VoipQuickReplies" = "来电拒接消息模板"; +"VoipQuickRepliesExplain" = "以上的模板答复将用于你以文字信息回复来电。你可以根据喜好來更改它们。"; +"VoipRateCallAlert" = "请对您这次 Telegram 通话的质量作出评价"; +"VoipRateCallDescription" = "请评价此次通话质量"; +"VoipRateCallTitle" = "评价此次通话"; +"VoipReconnecting" = "正在重新连接"; +"VoipRecordAudio" = "仅音频"; +"VoipRecordLandscape" = "风景视频"; +"VoipRecordPortrait" = "人像模式"; +"VoipRecordStart" = "开始录制"; +"VoipRecordVoiceChat" = "录制视频通话"; +"VoipRecordVoiceChatInfo" = "选择录制方式"; +"VoipRequesting" = "正在请求"; +"VoipRinging" = "正在响铃"; +"VoipRingtoneInfo" = "您可以自定义该联系人在 Telegram 上呼叫您时的铃声。"; +"VoipSelectAudioOutput" = "选择音频输出"; +"VoipSetNewPhoto" = "添加头像"; +"VoipSettingsRingtone" = "铃声"; +"VoipShareVideo" = "分享屏幕"; +"VoipSpeaker" = "扬声器"; +"VoipStartVideo" = "开启摄像头"; +"VoipStopVideo" = "关闭摄像头"; +"VoipStreamStart" = "若要使用其他应用程序直播视频\n请在直播应用中\n输入以下服务器网址和推流密钥。"; +"VoipSwitch" = "切换"; +"VoipSwitchToVideoCall" = "要切换到视频通话吗?"; +"VoipTapToAddBio" = "点击以添加个人简介"; +"VoipTapToMute" = "你正在说话"; +"VoipTapToMuteConference" = "您在线上"; +"VoipTapToMuteConferenceShort" = "您在线上"; +"VoipTapToMuteShort" = "您在线上"; +"VoipUnmute" = "取消静音"; +"VoipUseLessData" = "节省通话时的数据流量"; +"VoipUserCameraIsOff" = "%s 的相机处于关闭状态"; +"VoipUserMicrophoneIsOff" = "%s 的麦克风处于关闭状态"; +"VoipVideoNotAvailable" = "请将 Telegram 更新至最新版本以观看视频直播。"; +"VoipVideoNotAvailableAdmin" = "视频聊天人数已超过 %1$s。\n新的参与者只能通过语音串流。"; +"VoipVideoOnPause" = "暂停视频"; +"VoipVideoPrivateScreenSharing" = "屏幕上的所有内容 (包括通知)将被共享。"; +"VoipVideoScreenSharing" = "你正在共享屏幕"; +"VoipVideoScreenSharingTwoLines" = "您正在共享屏幕"; +"VoipVideoScreenStopSharing" = "停止分享"; +"VoipVideoUnavailable" = "视频"; +"VoipVoiceChat" = "进行中的视频聊天"; +"VoipWaiting" = "正在等待"; +"VoipWeakNetwork" = "网络信号弱"; +"Vote#other" = "%1$d 人投票"; +"WaitMore" = "再等等"; +"WaitingForNetwork" = "等待连接网络…"; +"WalletAddressCopied" = "钱包地址已复制到剪贴板"; +"WallpaperDimming" = "渐暗"; +"WallpaperNotification" = "已设置新的聊天壁纸"; +"WallpaperPreview" = "壁纸"; +"WallpaperSameNotification" = "为此聊天设置相同的壁纸"; +"WantsToSpeak" = "想要发言"; +"Warmth" = "色温"; +"Warning" = "警告"; +"WearAuthEmojis" = "比较你手表上的表情符号"; +"WearAuthEmojisConfirm" = "确认"; +"WearAuthEmojisLogIn" = "登录"; +"WearAuthEmojisTitle" = "在手机上配对"; +"WearAuthEmojisTitleWaiting" = "请在手机上确认"; +"WearAuthIntroQrButton" = "通过二维码登录"; +"WearAuthIntroTitle" = "在您的手机上检查"; +"WearAuthText" = "绑定账户至 Wear OS 设备,\n即可独立于手表使用。"; +"WearAuthTitle" = "在 Wear OS 上使用 Telegram"; +"WearCouldntLoad" = "无法加载"; +"WearCouldntSignIn" = "无法登录"; +"WearDeletedMessage" = "删除消息"; +"WearLoginQRHint" = "在手机上扫描二维码:\n设备 → 添加设备"; +"WearLoginQRTitle" = "登录 Telegram"; +"WearNewNotification" = "新通知"; +"WearOpenOnPhone" = "在手机上打开"; +"WearVoiceReleaseToStop" = "松开以停止。"; +"WearVoiceTapAgainToStop" = "再次点击即可停止。"; +"WebAppDisclaimerUrl" = "https://telegram.org/tos/mini-apps"; +"WebBookmark" = "书签"; +"WebBookmarkAdd" = "添加书签"; +"WebBookmarkedToast" = "已将链接添加至 **我的收藏** 中的书签。"; +"WebBookmarks" = "书签"; +"WebBrowserDeleteAllExceptionsMessage" = "您确定要删除所有网站例外吗?"; +"WebBrowserDeleteAllExceptionsTitle" = "删除全部例外"; +"WebBrowserExceptionsLimitMessage" = "\n网站例外数量已达上限。请先移除部分现有例外,再添加新的。"; +"WebBrowserExceptionsLimitTitle" = "已达上限"; +"WebBrowserShowCloseButton" = "显示关闭按钮"; +"WebBrowserShowCloseButtonInfo" = "浏览网页时添加关闭按钮"; +"WebDownloadAlertInfo" = "开始下载 **%s**?"; +"WebDownloadAlertInfoWithSize" = "开始下载 **%1$s** (%2$s)?"; +"WebDownloadAlertTitle" = "下载文件"; +"WebDownloadAlertYes" = "下载"; +"WebDownloading" = "下载中……"; +"WebDownloadingFile" = "下载中 %s..."; +"WebEmpty" = "网页"; +"WebErrorInfo" = "无法加载页面。"; +"WebErrorInfoBot" = "无法加载 **%s**。"; +"WebErrorInfoDomain" = "**%s** 加载失败。"; +"WebErrorTitle" = "糟糕..."; +"WebForward" = "前进"; +"WebHistory" = "历史记录"; +"WebNoBookmarks" = "未找到书签"; +"WebNoHistory" = "没有历史记录"; +"WebNoSearchedHistory" = "暂无结果"; +"WebRecentClear" = "清除"; +"WebRecentClearText" = "您想要清除最近的搜索关键词吗?"; +"WebRecentClearTitle" = "是否清除最近搜索?"; +"WebSectionBookmarks" = "书签"; +"WebSectionRecent" = "最近搜索"; +"WebSessionsTitle" = "使用 Telegram 账号登录的网站"; +"WebViewRequestCameraMicPermission" = "允许 **%1$s** 存取您的相机和麦克风吗? \n\n当此网站打开时,**%1$s** 将能够存取您的相机和麦克风。"; +"WebViewRequestCameraMicPermissionWithHint" = "是否允许 **%1$s** 访问您的相机和麦克风?\n\n打开此网站时,**%1$s** 将可以访问您的相机和麦克风。\n\n请前往“设置” > “权限” > “相机”,开启相机访问权限。"; +"WebViewRequestCameraPermission" = "是否允许 **%1$s** 访问您的相机?\n\n打开此网站时,**%1$s** 将可以访问您的相机。"; +"WebViewRequestCameraPermissionWithHint" = "是否允许 **%1$s** 访问您的相机?\n\n打开此网站时,**%1$s** 将可以访问您的相机。\n\n请前往“设置” > “权限” > “相机”,开启相机访问权限。"; +"WebViewRequestGeolocationPermission" = "是否允许 **%1$s** 访问您的位置?\n\n打开此网站时,**%1$s** 将可以访问您的位置。"; +"WebViewRequestGeolocationPermissionWithHint" = "是否允许 **%1$s** 访问您的位置信息?\n\n打开此网站时,**%1$s** 将可以访问您的位置信息。\n\n请前往“设置” > “权限”,开启 **位置** 选项以共享位置数据。"; +"WebViewRequestMicrophonePermission" = "是否允许 **%1$s** 访问您的麦克风?\n\n打开此网站时,**%1$s** 将可以访问您的麦克风。"; +"WebViewRequestMicrophonePermissionWithHint" = "是否允许 **%1$s** 访问您的麦克风?\n\n打开此网站时,**%1$s** 将可以访问您的麦克风。\n\n请前往“设置” > “权限” > “麦克风”,开启麦克风访问权限。"; +"WebsiteSays" = "%s 称"; +"Weeks#other" = "%1$d 周"; +"WhenConnectedOnWiFi" = "接入 Wi-Fi 时"; +"WhenRoaming" = "漫游时"; +"WhenUsingMobileData" = "使用移动数据时"; +"White" = "白色"; +"WhoCanAddMe" = "谁可以邀请我?"; +"WhoCanAddMeInfo" = "您可以详细控制哪些人能够将您加入到群组和频道中。"; +"WhoCanAddMembers" = "谁可以邀请成员?"; +"WhoCanAddMembersAdmins" = "仅限管理员"; +"WhoCanAddMembersAllMembers" = "所有成员"; +"WhoCanCallMe" = "谁可以呼叫我?"; +"WhoCanCallMeInfo" = "精确选择谁可以或不可以呼叫您。"; +"WhoCanSee" = "谁能查看"; +"WiFiUsage" = "Wi-Fi 流量使用情况"; +"WidgetChats" = "聊天"; +"WidgetLoggedOff" = "账号已退出登录。"; +"WidgetPasscode" = "\n\n注意:您的锁定码将被微件**忽略**。"; +"WidgetPasscode2" = "警告:您的微件将显示所有最近消息,但需要输入您的锁定码才能打开应用程序并回复。"; +"WidgetPasscodeEnable" = "您的锁定码**不会影响**已添加到主屏幕的微件。"; +"WidgetPasscodeEnable2" = "您的锁定码**不会影响**到已添加到主屏幕的微件。"; +"WidgetPreview" = "微件预览"; +"WidgetShortcuts" = "快捷方式"; +"WillUnmuteIn" = " %1$s 后"; +"WithdrawChannelAlertText" = "只有满足以下条件时,您才可以领取奖励:"; +"WithinAMonth" = "最近一个月内曾上线"; +"WithinAWeek" = "最近一周内曾上线"; +"WrongCode" = "验证码错误,请重试。"; +"WrongCodeTitle" = "密码错误"; +"WrongCountry" = "国际电话区号无效"; +"WrongNumber" = "手机号码有误?"; +"WrongNumberFormat" = "格式错误"; +"YearAgo" = "一年前"; +"Years#other" = "%1$d 年"; +"YearsAgo#other" = "%d 年前"; +"YearsShort#other" = "%1$d年"; +"Yellow" = "黄色"; +"Yesterday" = "昨天"; +"YesterdayAt" = "于昨天"; +"YesterdayAtFormatted" = "昨天 %1$s"; +"YouBlockedUser" = "抱歉,您无法将此用户或机器人添加到群组,因为您已将其屏蔽。请先将其解除屏蔽后再继续。"; +"YouBoostedChannel" = "您助推了频道!"; +"YouBoostedChannel2" = "您助推了 %s!"; +"YouBoostedGroup" = "您已助推群组"; +"YouCreatedBroadcastList" = "您创建了一个群发消息列表"; +"YouHaveNewMessage" = "您有一条新消息"; +"YouJoinedChannel" = "您已加入频道 %s"; +"YouLeft" = "您已经退出群组"; +"YouSenderNameHidden" = "你(已隐藏发送者名称)"; +"YouSendersNameHidden" = "你(已隐藏发送者名称)"; +"YouTubeVideoErrorHTML" = "尝试播放此视频时出错。"; +"YouTubeVideoErrorInvalid" = "视频网址无效。"; +"YouTubeVideoErrorNotAvailableInApp" = "无法在此应用内播放此视频。"; +"YouTubeVideoErrorNotFound" = "未找到视频。"; +"YouTubeVideoErrorOpenExternal" = "在 YouTube 上观看"; +"YouWereKicked" = "您已被移出此群组"; +"YourBioChanged" = "个人简介已更新。"; +"YourCode" = "手机验证"; +"YourContacts" = "您在 Telegram 上的联系人"; +"YourContactsToInvite" = "您的联系人"; +"YourCurrentPlan" = "您当前计划"; +"YourEmail" = "您的电子邮箱"; +"YourEmailAlmostThere" = "快完成了!"; +"YourEmailAlmostThereText" = "请查阅您的电子邮箱(不要忘记垃圾邮件文件夹),并按要求完成两步验证设置。"; +"YourEmailCode" = "您的电子邮箱验证码"; +"YourEmailCodeInfo" = "请输入你在%1$s刚刚收到的验证码来确认你的电子邮箱地址。"; +"YourEmailInfo" = "请添加您的有效电子邮箱。这是忘记密码时唯一的恢复方法。"; +"YourEmailSkip" = "跳过"; +"YourEmailSkipWarning" = "警告"; +"YourEmailSkipWarningText" = "您确定不设置密码恢复邮箱吗?\n\n一旦您忘记了密码,您将无法再访问您的 Telegram 帐号,并且再也没有办法恢复帐号数据。"; +"YourEmailSuccessChangedText" = "两步验证邮箱已修改。"; +"YourEmailSuccessText" = "您用于两步验证的恢复邮箱现在已激活。"; +"YourLocatedChannelsTooMuch" = "抱歉,您已经有过多基于位置的群组,请先删除其中一个。"; +"YourLoginEmailChangedSuccess" = "你的电子邮箱地址已更改。"; +"YourName" = "您的姓名"; +"YourNameChanged" = "名称已更新。"; +"YourNetworkUsageSince" = "自 %1$s 网络使用情况"; +"YourNewEmail" = "新的邮箱地址"; +"YourNumber" = "您的手机号码"; +"YourPassword" = "您的密码"; +"YourPasswordChangedSuccessText" = "两步验证密码已修改。"; +"YourPasswordHeader" = "您的密码"; +"YourPasswordRemember" = "您的帐户受到两步验证的保护。\n还记得你的密码吗?"; +"YourPasswordRememberNo" = "不确定"; +"YourPasswordRememberYes" = "是的,我确定"; +"YourPasswordReset" = "密码停用成功"; +"YourPasswordSuccess" = "成功!"; +"YourPasswordSuccessText" = "您的两步验证密码现已启用。"; +"YourPhone" = "您的手机号码"; +"YourProfilePhotoDone" = "已设置您的个人头像。"; +"YourProfilePhotoUploading" = "正在上传您的个人资料照片..."; +"YourProfileVideoDone" = "已设置您的个人视频。"; +"YourProfileVideoUploading" = "正在上传您的个人资料视频..."; +"YourPublicCommunities" = "您的公开社群"; +"YourStarsBalance" = "您的余额"; +"YourTonBalance" = "您的余额"; +"YourVersionIsLatest" = "您的版本是最新的。"; +"ZoomOut" = "缩小"; +"_tg_cancel" = "取消"; +"_tg_open_google_play" = "打开 Google Play"; +"chatDate" = "M月d日"; +"chatDateShort" = "d MMM"; +"chatFullDate" = "yyyy年M月d日"; +"download_telegram_text" = "您需要 Telegram 才能登录到 **%s**。要现在安装吗?"; +"formatDateAtTime" = "%1$s %2$s"; +"formatDateSchedule" = "MMM d"; +"formatDateScheduleDay" = "MMMd日EEEE"; +"formatDateScheduleYear" = "yyyy'年'MM'月'd'日'"; +"formatterBannedUntil12H" = "yyyy年M月d日 ah:mm"; +"formatterBannedUntil24H" = "yyyy年M月d日 HH:mm"; +"formatterBannedUntilThisYear12H" = "M月d日 ah:mm"; +"formatterBannedUntilThisYear24H" = "M月d日 HH:mm"; +"formatterBoostExpired" = "yyyy MMM dd"; +"formatterDay12H" = "ah:mm"; +"formatterDay24H" = "HH:mm"; +"formatterDayWithSeconds12H" = "h:mm:ss a"; +"formatterDayWithSeconds24H" = "HH:mm:ss"; +"formatterGiveawayCard" = "yyyy MMM dd"; +"formatterGiveawayMonthDay" = "MMMM dd"; +"formatterGiveawayMonthDayYear" = "yyyy MMMM dd"; +"formatterMonth" = "M月d日"; +"formatterMonthName" = "LLLL"; +"formatterMonthOnly" = "MMMM"; +"formatterMonthYear" = "yyyy年M月"; +"formatterMonthYear2" = "LLLL yyyy"; +"formatterStats12H" = "yyyy年M月d日 ah:mm"; +"formatterStats24H" = "yyyy年M月d日 HH:mm"; +"formatterWeek" = "EEE"; +"formatterWeekLong" = "EEEE"; +"formatterYear" = "yy.MM.dd"; +"formatterYearMax" = "yyyy年M月dd日"; +"items#other" = "%1$d 项"; +"login_with_telegram" = "通过 Telegram 登录"; +"messages#other" = "%1$d 条消息"; +"telegram_passport" = "Telegram 通行证"; diff --git a/data/langpack/ios/ios_en_v21268555.strings b/data/langpack/ios/ios_en_v21268555.strings new file mode 100644 index 00000000..a922b81f --- /dev/null +++ b/data/langpack/ios/ios_en_v21268555.strings @@ -0,0 +1,12428 @@ +"AUTH_REGION" = "Login from a new device %1$@, location: %2$@"; +"AUTH_REGION_SEPARATED" = "New login|from unrecognized device %1$@, location: %2$@"; +"AUTH_UNKNOWN_SEPARATED" = "New login|from unrecognized device %1$@"; +"AccentColor.Title" = "Accent Color"; +"AccessDenied.AgeVerificationCamera" = "Telegram needs access to your camera for age verification.\n\nOpen your device's Settings > Privacy > Camera and set Telegram to ON."; +"AccessDenied.CallMicrophone" = "Telegram needs access to your microphone so that you can make calls.\n\nPlease go to your device‘s settings > Privacy > Microphone and set Telegram to ON."; +"AccessDenied.Camera" = "Telegram needs camera access so that you can take photos and videos.\n\nPlease go to your device settings > Privacy > Camera and set Telegram to ON."; +"AccessDenied.CameraDisabled" = "Camera access is globally restricted on your phone.\n\nPlease go to Settings > General > Restrictions and set Camera to ON"; +"AccessDenied.CameraRestricted" = "Camera access is restricted for Telegram.\n\nPlease go to your device‘s settings > General > Restrictions > Camera and set Telegram to ON."; +"AccessDenied.Contacts" = "Telegram messaging is based on your existing contact list.\n\nPlease go to your device‘s settings > Privacy > Contacts and set Telegram to ON."; +"AccessDenied.LocationAlwaysDenied" = "If you'd like to share your Live Location with friends, Telegram needs location access when the app is in the background.\n\nPlease go to your device‘s settings > Privacy > Location Services and set Telegram to Always."; +"AccessDenied.LocationDenied" = "Telegram needs access to your location so that you can share it with your contacts.\n\nPlease go to your device‘s settings > Privacy > Location Services and set Telegram to ON."; +"AccessDenied.LocationDisabled" = "Telegram needs access to your location so that you can share it with your contacts.\n\nPlease go to your device‘s settings > Privacy > Location Services and set it to ON."; +"AccessDenied.LocationPreciseDenied" = "To share your specific location in this chat, please go to Settings > Privacy > Location Services > Telegram and set Precise Location to On."; +"AccessDenied.LocationTracking" = "Telegram needs access to your location so that you can share it with your friends.\n\nPlease go to your device‘s settings > Privacy > Location Services and set it to ON."; +"AccessDenied.LocationWeather" = "Telegram needs access to your location so that you can add weather widgets to your stories.\n\nPlease go to Settings > Privacy > Location Services and set Telegram to ON."; +"AccessDenied.MicrophoneRestricted" = "Microphone access is restricted for Telegram.\n\nPlease go to your device‘s settings > General > Restrictions > Microphone and set Telegram to ON."; +"AccessDenied.PhotosAndVideos" = "Telegram needs access to your photo library so that you can send photos and videos.\n\nPlease go to your device‘s settings > Privacy > Photos and set Telegram to ON."; +"AccessDenied.PhotosRestricted" = "Photo access is restricted for Telegram.\n\nPlease go to your device settings > General > Restrictions > Photos and set Telegram to ON."; +"AccessDenied.QrCamera" = "Telegram needs access to your camera to scan QR codes.\n\nPlease go to your device's settings > Privacy > Camera and set Telegram to ON."; +"AccessDenied.QrCode" = "Telegram needs access to your photo library so that you can scan QR codes.\n\nPlease go to your device's settings > Privacy > Photos and set Telegram to ON."; +"AccessDenied.SaveMedia" = "Telegram needs access to your photo library so that you can save photos and videos.\n\nPlease go to your device‘s settings > Privacy > Photos and set Telegram to ON."; +"AccessDenied.Settings" = "Settings"; +"AccessDenied.Title" = "Please Allow Access"; +"AccessDenied.VideoCallCamera" = "Telegram needs access to your camera so that you can make video calls.\n\nPlease go to your device‘s settings > Privacy > Camera and set Telegram to ON."; +"AccessDenied.VideoMessageCamera" = "Telegram needs access to your camera so that you can send video messages.\n\nPlease go to your device‘s settings > Privacy > Camera and set Telegram to ON."; +"AccessDenied.VideoMessageMicrophone" = "Telegram needs access to your microphone so that you can send video messages.\n\nPlease go to your device‘s settings > Privacy > Microphone and set Telegram to ON."; +"AccessDenied.VideoMicrophone" = "Telegram needs access to your microphone so that you can record sound in videos recording.\n\nPlease go to your device‘s settings > Privacy > Microphone and set Telegram to ON."; +"AccessDenied.VoiceMicrophone" = "Telegram needs access to your microphone so that you can send voice messages.\n\nPlease go to your device‘s settings > Privacy > Microphone and set Telegram to ON."; +"AccessDenied.Wallpapers" = "Telegram needs access to your photo library so that you can set a custom wallpaper.\n\nPlease go to your device's settings > Privacy > Photos and set Telegram to ON."; +"Activity.ChoosingSticker" = "choosing a sticker"; +"Activity.EnjoyingAnimations" = "watching %@"; +"Activity.PlayingGame" = "playing a game"; +"Activity.RecordingAudio" = "recording voice"; +"Activity.RecordingVideoMessage" = "recording video"; +"Activity.TappingInteractiveEmoji" = "tapping on %@"; +"Activity.UploadingDocument" = "sending a file"; +"Activity.UploadingPhoto" = "sending a photo"; +"Activity.UploadingVideo" = "sending a video"; +"Activity.UploadingVideoMessage" = "sending a video"; +"AddContact.AddQR" = "Add via QR Code"; +"AddContact.ContactWillBeSharedAfterMutual" = "Phone number will be visible once %1$@ adds you as a contact."; +"AddContact.NotePlaceholder" = "Add notes only visible to you"; +"AddContact.PhoneNumber.IsContact" = "This phone number is already in your contacts. [View >]()"; +"AddContact.PhoneNumber.NotRegistered" = "This phone number is not on Telegram. [Invite >]()"; +"AddContact.PhoneNumber.Registered" = "This phone number is on Telegram."; +"AddContact.SharedContactException" = "Share My Phone Number"; +"AddContact.SharedContactExceptionInfo" = "You can make your phone visible to %@."; +"AddContact.StatusSuccess" = "%@ is now in your contacts."; +"AddContact.SyncToPhone" = "Sync Contact to Phone"; +"AddContact.Title" = "New Contact"; +"AddGifts.AddGifts#one" = "Add %@ Gift"; +"AddGifts.AddGifts#other" = "Add %@ Gifts"; +"AddGifts.Title" = "Add Gifts"; +"AdsInfo.Ads.Text" = "You can turn off ads by subscribing to [Telegram Premium](), and Level %@ channels can remove them for their subscribers."; +"AdsInfo.Ads.Title" = "Can Be Removed"; +"AdsInfo.Bot.Ads.Text" = "You can turn off ads by subscribing to [Telegram Premium]()."; +"AdsInfo.Bot.Launch.Text" = "Anyone can create ads to display in this bot – with minimal budgets. Check out the Telegram Ad Platform for details. [Learn More >]()"; +"AdsInfo.Bot.Respect.Text" = "Ads on Telegram do not use your personal information and are based on the bot in which you see them."; +"AdsInfo.Bot.Split.Text" = "50% of the revenue from Telegram Ads goes to the owner of the bot where they are displayed."; +"AdsInfo.Bot.Split.Title" = "Help the Bot Creator"; +"AdsInfo.Info" = "Telegram Ads are very different from ads on other platforms. Ads such as this one:"; +"AdsInfo.Launch.Text" = "Anyone can create ads to display in this channel – with minimal budgets. Check out the Telegram Ad Platform for details. [Learn More >]()"; +"AdsInfo.Launch.Text_URL" = "https://ads.telegram.org"; +"AdsInfo.Launch.Title" = "Can I Launch an Ad?"; +"AdsInfo.Respect.Text" = "Ads on Telegram do not use your personal information and are based on the channel in which you see them."; +"AdsInfo.Respect.Title" = "Respect Your Privacy"; +"AdsInfo.Search.Ads.Text" = "You can turn off ads by subscribing to [Telegram Premium]()."; +"AdsInfo.Search.Launch.Text" = "Anyone can create an ad to display in search results for any query. Check out the Telegram Ad Platform for details. [Learn More >]()"; +"AdsInfo.Search.Respect.Text" = "Ads like this do not use your personal information and are based on the search query you entered."; +"AdsInfo.Split.Text" = "50% of the revenue from Telegram Ads goes to the owner of the channel where they are displayed."; +"AdsInfo.Split.Title" = "Help the Channel Creator"; +"AdsInfo.Title" = "About These Ads"; +"AdsInfo.Understood" = "Understood"; +"AffiliateProgram.ActionCopyLink" = "Copy Link"; +"AffiliateProgram.ActionJoin" = "Join Program"; +"AffiliateProgram.CommistionDestinationText" = "Commission will be sent to:"; +"AffiliateProgram.DailyRevenueText" = "App's average daily revenue per user: #**%@**"; +"AffiliateProgram.DurationLifetime" = "Lifetime"; +"AffiliateProgram.JoinSubtitleLifetime" = "**{bot}** will share **{commission}** of the revenue from each user you refer to it **forever**."; +"AffiliateProgram.JoinSubtitleMonths#one" = "**{bot}** will share **{commission}** of the revenue from each user you refer to it for **1 month.**"; +"AffiliateProgram.JoinSubtitleMonths#other" = "**{bot}** will share **{commission}** of the revenue from each user you refer to it for **%d months.**"; +"AffiliateProgram.JoinSubtitleYears#one" = "**{bot}** will share **{commission}** of the revenue from each user you refer to it for **1 year**."; +"AffiliateProgram.JoinSubtitleYears#other" = "**{bot}** will share **{commission}** of the revenue from each user you refer to it for **%d years**."; +"AffiliateProgram.JoinTerms" = "By joining this program, you agree to the [terms and conditions](https://telegram.org/tos/affiliate-program) of Affiliate Programs."; +"AffiliateProgram.JoinTitle" = "Affiliate Program"; +"AffiliateProgram.LinkSubtitleLifetime" = "Share this link with your users to earn a **{commission}** commission on their spending in **{bot}** **forever** after they follow your link."; +"AffiliateProgram.LinkSubtitleMonths#one" = "Share this link with your users to earn a **{commission}** commission on their spending in **{bot}** for **1 month** after they follow your link."; +"AffiliateProgram.LinkSubtitleMonths#other" = "Share this link with your users to earn a **{commission}** commission on their spending in **{bot}** for **%d months** after they follow your link."; +"AffiliateProgram.LinkSubtitleYears#one" = "Share this link with your users to earn a **{commission}** commission on their spending in **{bot}** for **1 year** after they follow your link."; +"AffiliateProgram.LinkSubtitleYears#other" = "Share this link with your users to earn a **{commission}** commission on their spending in **{bot}** for **%d years** after they follow your link."; +"AffiliateProgram.LinkTitle" = "Referral Link"; +"AffiliateProgram.OpenBot" = "View %@"; +"AffiliateProgram.PeerTypeSelf" = "personal account"; +"AffiliateProgram.SortSelectorDate" = "Date"; +"AffiliateProgram.SortSelectorProfitability" = "Profitability"; +"AffiliateProgram.SortSelectorRevenue" = "Revenue"; +"AffiliateProgram.ToastJoined.Text" = "You can now copy the referral link."; +"AffiliateProgram.ToastJoined.Title" = "Program joined"; +"AffiliateProgram.ToastLinkCopied.Text" = "Share this link and earn **%1$@%** of what people who use it spend in **%2$@**!"; +"AffiliateProgram.ToastLinkCopied.Title" = "Link copied to clipboard"; +"AffiliateProgram.UserCountFooter#one" = "1 user opened {bot} through this link."; +"AffiliateProgram.UserCountFooter#other" = "%d users opened {bot} through this link."; +"AffiliateProgram.ValueLongMonths#one" = "%@ MONTH"; +"AffiliateProgram.ValueLongMonths#other" = "%@ MONTHS"; +"AffiliateProgram.ValueLongYears#one" = "%@ YEAR"; +"AffiliateProgram.ValueLongYears#other" = "%@ YEARS"; +"AffiliateProgram.ValueShortMonths#one" = "%@m"; +"AffiliateProgram.ValueShortMonths#other" = "%@m"; +"AffiliateProgram.ValueShortYears#one" = "%@y"; +"AffiliateProgram.ValueShortYears#other" = "%@y"; +"AffiliateSetup.AlertApply.Action" = "Start"; +"AffiliateSetup.AlertApply.SectionCommission" = "Commission"; +"AffiliateSetup.AlertApply.SectionDuration" = "Duration"; +"AffiliateSetup.AlertApply.Text" = "Once you start the affiliate program, you won't be able to decrease its commission or duration. You can only increase these parameters or end the program, which will disable all previously distributed referral links."; +"AffiliateSetup.AlertApply.Title" = "Warning"; +"AffiliateSetup.AlertTerminate.Action" = "End Anyway"; +"AffiliateSetup.AlertTerminate.Text" = "If you end your affiliate program:\n\n• Any referral links already shared will be disabled in 24 hours.\n\n• All participating affiliates will be notified.\n\n• You will not be able to start a new affiliate program for 24 hours."; +"AffiliateSetup.AlertTerminate.Title" = "Warning"; +"AffiliateSetup.ConnectedSectionTitle" = "MY PROGRAMS"; +"AffiliateSetup.EndAction" = "End Affiliate Program"; +"AffiliateSetup.ExistingPrograms.Action" = "View Existing Programs"; +"AffiliateSetup.ExistingPrograms.Footer" = "Explore what other mini apps offer."; +"AffiliateSetup.IntroJoin.Text1" = "Receive guaranteed commissions for spending by users you refer."; +"AffiliateSetup.IntroJoin.Text2" = "Track your commissions from referred users in real time."; +"AffiliateSetup.IntroJoin.Text3" = "Choose a mini app below, get your referral link, and start earning Stars."; +"AffiliateSetup.IntroJoin.Title1" = "Reliable"; +"AffiliateSetup.IntroJoin.Title2" = "Transparent"; +"AffiliateSetup.IntroJoin.Title3" = "Simple"; +"AffiliateSetup.IntroNew.Text1" = "Set the commission for revenue generated by users referred to you."; +"AffiliateSetup.IntroNew.Text2" = "Telegram will feature your program for millions of potential affiliates."; +"AffiliateSetup.IntroNew.Text3" = "Affiliates will share your referral link with their audience."; +"AffiliateSetup.IntroNew.Title1" = "Share revenue with affiliates"; +"AffiliateSetup.IntroNew.Title2" = "Launch your affiliate program"; +"AffiliateSetup.IntroNew.Title3" = "Let affiliates promote you"; +"AffiliateSetup.ProgramLeave" = "Leave"; +"AffiliateSetup.ProgramMenu.CopyLink" = "Copy Link"; +"AffiliateSetup.ProgramMenu.Leave" = "Leave"; +"AffiliateSetup.ProgramMenu.OpenApp" = "Open App"; +"AffiliateSetup.ProgramMenu.OpenBot" = "Open Bot"; +"AffiliateSetup.SectionCommission" = "COMMISSION"; +"AffiliateSetup.SectionCommissionFooter" = "Define the percentage of Star revenue your affiliates earn for referring users to your bot."; +"AffiliateSetup.SectionDuration" = "DURATION"; +"AffiliateSetup.SectionDurationFooter" = "Set the duration for which affiliates will earn commissions from referred users."; +"AffiliateSetup.SortSectionHeader.Date" = "SORT BY [DATE]()"; +"AffiliateSetup.SortSectionHeader.Profitability" = "SORT BY [PROFITABILITY]()"; +"AffiliateSetup.SortSectionHeader.Revenue" = "SORT BY [REVENUE]()"; +"AffiliateSetup.StartTitle" = "Start Affiliate Program"; +"AffiliateSetup.SuggestedSectionEmpty" = "No available programs yet.\nPlease check the page later."; +"AffiliateSetup.SuggestedSectionTitle" = "PROGRAMS"; +"AffiliateSetup.TermsFooter" = "By creating an affiliate program, you agree to the [terms and conditions](https://telegram.org/terms) of Affiliate Programs."; +"AffiliateSetup.TextJoin" = "Earn a commission each time a user who first accessed a mini app through your referral link spends **Stars** within it."; +"AffiliateSetup.TextNew" = "Reward those who help grow your user base."; +"AffiliateSetup.TitleJoin" = "Affiliate Programs"; +"AffiliateSetup.TitleNew" = "Affiliate Program"; +"AffiliateSetup.ToastTerminated.Text" = "Participating affiliates have been notified. All referral links will be disabled in 24 hours."; +"AffiliateSetup.ToastTerminated.Title" = "Affiliate Program Ended"; +"AffiliateSetup.UpdateTitle" = "Update Affiliate Program"; +"AgeVerification.Fail.Text" = "Sorry, you can't view this content."; +"AgeVerification.Fail.Title" = "Age check failed"; +"AgeVerification.Success.Text" = "You can now view this content."; +"AgeVerification.Success.Title" = "Age check passed!"; +"AgeVerification.Text" = "To access this content, you must confirm you are at least **18** years old.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by Telegram."; +"AgeVerification.Text.GB" = "To access this content, you must confirm you are at least **18** years old as required by UK law.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by Telegram."; +"AgeVerification.Title" = "Age Verification"; +"AgeVerification.Unavailable.Text" = "This media may contain sensitive content suitable only for adults."; +"AgeVerification.Unavailable.Title" = "18+"; +"AgeVerification.Verify" = "Verify My Age"; +"Appearance.AccentColor" = "Accent Color"; +"Appearance.Animations" = "ANIMATIONS"; +"Appearance.AppIcon" = "App Icon"; +"Appearance.AppIconBlack" = "Black"; +"Appearance.AppIconClassic" = "Classic"; +"Appearance.AppIconClassicX" = "Classic Black"; +"Appearance.AppIconDefault" = "Default"; +"Appearance.AppIconDefaultX" = "Mono Black"; +"Appearance.AppIconFilled" = "Filled"; +"Appearance.AppIconFilledX" = "Filled Black"; +"Appearance.AppIconNew1" = "Sunset"; +"Appearance.AppIconNew2" = "Aqua"; +"Appearance.AppIconPremium" = "Premium"; +"Appearance.AppIconTurbo" = "Turbo"; +"Appearance.AutoNightTheme" = "Auto-Night Mode"; +"Appearance.AutoNightThemeDisabled" = "Disabled"; +"Appearance.BubbleCorners.AdjustAdjacent" = "Adjust Adjacent Corners"; +"Appearance.BubbleCorners.Apply" = "Set"; +"Appearance.BubbleCorners.Title" = "Message Corners"; +"Appearance.BubbleCornersSetting" = "Message Corners"; +"Appearance.ColorTheme" = "COLOR THEME"; +"Appearance.ColorThemeNight" = "COLOR THEME — AUTO-NIGHT MODE"; +"Appearance.CreateTheme" = "Create New Theme"; +"Appearance.CreateThemeInfo" = "A new theme template has been created from your current theme and was sent to your Saved Messages."; +"Appearance.EditTheme" = "Edit Theme"; +"Appearance.LargeEmoji" = "Large Emoji"; +"Appearance.NameColor" = "Your Name Color"; +"Appearance.NightTheme" = "Night Mode"; +"Appearance.Other" = "Other"; +"Appearance.PickAccentColor" = "Pick an Accent Color"; +"Appearance.Preview" = "CHAT PREVIEW"; +"Appearance.PreviewIncomingText" = "Do you know what time it is?"; +"Appearance.PreviewOutgoingText" = "It's morning in Tokyo 😎"; +"Appearance.PreviewReplyAuthor" = "Bob Harris"; +"Appearance.PreviewReplyText" = "Good morning! 👋"; +"Appearance.Publish" = "Publish"; +"Appearance.ReduceMotion" = "Reduce Motion"; +"Appearance.ReduceMotionInfo" = "Disable animations in message bubbles and in the chat list."; +"Appearance.RemoveTheme" = "Remove"; +"Appearance.RemoveThemeColor" = "Remove"; +"Appearance.RemoveThemeColorConfirmation" = "Remove Color"; +"Appearance.RemoveThemeConfirmation" = "Remove Theme"; +"Appearance.SendWithCmdEnter" = "Send Messages with ⌘+Enter"; +"Appearance.ShareTheme" = "Share"; +"Appearance.ShareThemeColor" = "Share"; +"Appearance.ShowNextMediaOnTap" = "Tap to Show Next Media"; +"Appearance.ShowNextMediaOnTapInfo" = "Tap near the edge of the screen while viewing media to navigate between photos."; +"Appearance.TextSize" = "TEXT SIZE"; +"Appearance.TextSize.Apply" = "Set"; +"Appearance.TextSize.Automatic" = "System"; +"Appearance.TextSize.Title" = "Text Size"; +"Appearance.TextSize.UseSystem" = "Use System Text Size"; +"Appearance.TextSizeSetting" = "Text Size"; +"Appearance.ThemeCarouselClassic" = "Classic"; +"Appearance.ThemeCarouselDay" = "Day"; +"Appearance.ThemeCarouselNewNight" = "Night"; +"Appearance.ThemeCarouselNight" = "Monochrome"; +"Appearance.ThemeCarouselNightBlue" = "Night Blue"; +"Appearance.ThemeCarouselTintedNight" = "Dark"; +"Appearance.ThemeDay" = "Day"; +"Appearance.ThemeDayClassic" = "Day Classic"; +"Appearance.ThemeNight" = "Monochrome"; +"Appearance.ThemeNightBlue" = "Night Blue"; +"Appearance.ThemePreview.Chat.1.Text" = "Does he want me to turn from the right or turn from the left? 🤔"; +"Appearance.ThemePreview.Chat.2.ReplyName" = "Bob Harris"; +"Appearance.ThemePreview.Chat.2.Text" = "Right side. And, uh, with intensity."; +"Appearance.ThemePreview.Chat.3.Text" = "Is that everything? It seemed like he said quite a bit more than that. 😯"; +"Appearance.ThemePreview.Chat.3.TextWithLink" = "Is that everything? It seemed like he said [quite a bit more] than that. 😯"; +"Appearance.ThemePreview.Chat.4.Text" = "For relaxing times, make it Suntory time. 😎"; +"Appearance.ThemePreview.Chat.5.Text" = "He wants you to turn, look in camera. Okay?"; +"Appearance.ThemePreview.Chat.6.Text" = "That’s all he said?"; +"Appearance.ThemePreview.Chat.7.Text" = "Yes, turn to camera."; +"Appearance.ThemePreview.ChatList.1.Name" = "Alicia Torreaux"; +"Appearance.ThemePreview.ChatList.1.Text" = "Bob says hi.\u00a0😊 ❤️ 😱"; +"Appearance.ThemePreview.ChatList.2.Name" = "Roberto"; +"Appearance.ThemePreview.ChatList.2.Text" = "Say hello to Alice 👋"; +"Appearance.ThemePreview.ChatList.3.AuthorName" = "Jennie Alpha"; +"Appearance.ThemePreview.ChatList.3.Name" = "Campus Public Chat"; +"Appearance.ThemePreview.ChatList.3.Text" = "We just reached 2,500 members! WOO!"; +"Appearance.ThemePreview.ChatList.4.Name" = "Veronica"; +"Appearance.ThemePreview.ChatList.4.Text" = "Table for four, 2PM. Be there."; +"Appearance.ThemePreview.ChatList.5.Name" = "Animal Videos"; +"Appearance.ThemePreview.ChatList.5.Text" = "Vote now! Moar cat videos in this channel?"; +"Appearance.ThemePreview.ChatList.6.Name" = "Little Sister"; +"Appearance.ThemePreview.ChatList.6.Text" = "Don't tell mom yet, but I got the job! I'm going to ROME!"; +"Appearance.ThemePreview.ChatList.7.Name" = "Jennie Alpha"; +"Appearance.ThemePreview.ChatList.7.Text" = "🖼 Check these out"; +"Appearance.Title" = "Appearance"; +"Appearance.VoiceOver.Theme" = "%@ Theme"; +"AppearanceSettings.Animations" = "Animations"; +"AppleWatch.ReplyPresets" = "REPLY PRESETS"; +"AppleWatch.ReplyPresetsHelp" = "You can select one of these default replies when you compose or reply to a message, or you can change them to anything you like."; +"AppleWatch.Title" = "Apple Watch"; +"Application.Name" = "Telegram"; +"Application.Update" = "Update"; +"ApplyLanguage.ApplyLanguageAction" = "Apply Language"; +"ApplyLanguage.ApplySuccess" = "Language changed"; +"ApplyLanguage.ChangeLanguageAction" = "Change"; +"ApplyLanguage.ChangeLanguageAlreadyActive" = "This language (%1$@) is already active."; +"ApplyLanguage.ChangeLanguageOfficialText" = "You are about to apply a language pack (**%1$@**).\n\nThis will translate the entire interface. You can suggest corrections via the [translation platform]().\n\nYou can change your language back at any time in Settings."; +"ApplyLanguage.ChangeLanguageTitle" = "Change Language?"; +"ApplyLanguage.ChangeLanguageUnofficialText" = "You are about to apply a custom language pack (**%1$@**) that is %2$@% complete.\n\nThis will translate the entire interface. You can suggest corrections via the [translation platform]().\n\nYou can change your language back at any time in Settings."; +"ApplyLanguage.LanguageNotSupportedError" = "Sorry, this language doesn't seem to exist."; +"ApplyLanguage.UnsufficientDataText" = "Unfortunately, this custom language pack (%1$@) doesn't contain data for Telegram iOS. You can contribute to this language pack using the [translation platform]()."; +"ApplyLanguage.UnsufficientDataTitle" = "Insufficient Data"; +"AppointAnotherOwner.Title" = "Choose New Owner"; +"Appstore.Cloud" = "**Cloud-based**\nUnlimited storage for chats,\nmedia and documents."; +"Appstore.Cloud.Profile" = "**Jennifer**\n23 y.o. designer from San Francisco."; +"Appstore.Creative" = "**Creative**\nColor themes, stickers, GIFs,\nvideo messages and more."; +"Appstore.Creative.Chat" = "**You**\nSend a dice emoji to roll a die!\n**You**\nAdvance to Illinois Ave. If you pass Go, collect coffee\n**Gabriella**\nPassed!\n**You**\nOkay\nWait for me there."; +"Appstore.Creative.Chat.Name" = "**Gabriella**"; +"Appstore.Fast" = "**Fast**\nSimple, reliable and synced\nacross all your devices."; +"Appstore.Fast.Chat1" = "**Alicia Torreaux**\nBob says hi."; +"Appstore.Fast.Chat2" = "**Roberto**\nSay hello to Alice."; +"Appstore.Fast.Chat3" = "**Digital Nomads**\nJennie\nWe just reached 2,500 members! WOO!"; +"Appstore.Fast.Chat4" = "**Veronica**\nTable for four, 2 PM. Be there."; +"Appstore.Fast.Chat5" = "**Animal Videos**\nVote now! Moar cat videos in this channel?"; +"Appstore.Fast.Chat6" = "**Little Sister**\nDon't tell mom yet, but I got the job! I'm going to ROME!"; +"Appstore.Fast.Chat7" = "**James**\nCheck these out"; +"Appstore.Fast.Chat8" = "**Study Group**\nEmma\nSticker"; +"Appstore.Fast.Chat9" = "**Digital Nomads**"; +"Appstore.Free.Chat" = "**Jessica**\nPaper airplane is lyfted by...\nWings\nPropeller\n**You**\nIs this from Monday’s test?\n**Harry**\nOnlinePartyPlan.ppd\nLet's get back to planning!\n**You**\n550 MB keynote file??\n**Helene**\nHe added fireworks videos"; +"Appstore.Free.Chat.Name" = "**Study Group**"; +"Appstore.Open" = "**Open**\nNo ads, no fees. Open source\ncode free for everyone."; +"Appstore.Powerful" = "**Powerful**\nNo limits on the size of\ngroups and broadcasts."; +"Appstore.Powerful.Chat" = "**James**\nGood morning!\n\nDwayne joined the group\n\n**You**\nDo you have any idea what time it is?\n**Roxanne**\nIs it still morning?\nSure!\nNot sure\n**Emma**\nVoice"; +"Appstore.Private" = "**Private**\nYour data is never disclosed.\nOnly you are in control."; +"Appstore.Private.Chat" = "**You**\nNo limits on the size of your cats."; +"Appstore.Private.Chat.Name" = "**Beatrice**"; +"Appstore.Public" = "**Public**\nPublic channels, open groups,\nbots for integrations."; +"Appstore.Public.Chat1" = "**Financial Times**\nTruth is like the sun. You can shut it out for a time, but it ain’t goin’ away."; +"Appstore.Public.Chat2" = "**Bloomberg**\nWe'll be sending you a few big stories daily, which you can expect to start..."; +"Appstore.Public.Chat3" = "**Health and Safety**\nIf you're looking for official news about the Novel Coronavirus and COVID-19"; +"Appstore.Public.IV" = "We now have enough data to measure the relative effectiveness of major climate solutions. This simulator lets you see which ones would work best.\n\nBloomberg\n\nThe Best Way to Slow Global Warming? You Decide in This Climate Simulator\nIt was on Earth Day 2016 when more than 170 nations signed the Paris Agreement calling for limiting global warming \"to well below 2°C\"."; +"Appstore.Secure" = "**Secure**\nAll chats are protected\nwith strong encryption."; +"Appstore.Secure.Chat" = "**Little Sister**\nAny gift ideas for mom?\n**You**A dog!\n**You**I'm serious. Let's get her a puppy. \n**You**\nI saw this!\n**Little Sister**\nI needed proof this was your idea!"; +"Appstore.Secure.Chat.Name" = "**Little Sister**"; +"Appstore.new.Cloud.bio" = "23 y.o. pianist from Vienna"; +"Appstore.new.Cloud.name" = "Roxanne"; +"Appstore.new.Cloud.subtitle" = "Unlimited storage for chats,\nmedia and documents."; +"Appstore.new.Cloud.title" = "Cloud-based"; +"Appstore.new.Creative.message1" = "Send a dice emoji to roll a die!"; +"Appstore.new.Creative.message2" = "Advance to Illinois Ave. If you pass Go, collect coffee"; +"Appstore.new.Creative.message3" = "Passed!"; +"Appstore.new.Creative.message4" = "Okay"; +"Appstore.new.Creative.message5" = "Wait for me there."; +"Appstore.new.Creative.name" = "Emma Torreaux"; +"Appstore.new.Creative.subtitle" = "Color themes, stickers, GIFs,\nvideo messages and more."; +"Appstore.new.Creative.title" = "Creative"; +"Appstore.new.Fast.chat1.message" = "Bob says hi."; +"Appstore.new.Fast.chat1.name" = "Emma Torreaux"; +"Appstore.new.Fast.chat2.message" = "Say hello to Emma."; +"Appstore.new.Fast.chat2.name" = "Roberto"; +"Appstore.new.Fast.chat3.message" = "8Bit Times started a Live Stream"; +"Appstore.new.Fast.chat3.name" = "8Bit Times"; +"Appstore.new.Fast.chat4.group" = "Digital Nomads"; +"Appstore.new.Fast.chat4.message" = "We just reached 2,500 members! WOO!"; +"Appstore.new.Fast.chat4.name" = "Jennie"; +"Appstore.new.Fast.chat5.message" = "Table for four, 2 PM. Be there."; +"Appstore.new.Fast.chat5.name" = "Penelope"; +"Appstore.new.Fast.chat6.message" = "Check these out!"; +"Appstore.new.Fast.chat6.name" = "James"; +"Appstore.new.Fast.subtitle" = "Simple, reliable and synced\nacross all your devices."; +"Appstore.new.Fast.title" = "Fast"; +"Appstore.new.Powerful.chat1.message" = "Good morning!"; +"Appstore.new.Powerful.chat1.name" = "James"; +"Appstore.new.Powerful.chat2.message" = "Do you have any idea what time it is?"; +"Appstore.new.Powerful.chat2.name" = "You"; +"Appstore.new.Powerful.chat3.name" = "Roxanne"; +"Appstore.new.Powerful.chat3.option1" = "Sure!"; +"Appstore.new.Powerful.chat3.option2" = "Not sure"; +"Appstore.new.Powerful.chat3.poll" = "Is it still morning?"; +"Appstore.new.Powerful.chat4.name" = "Emma"; +"Appstore.new.Powerful.group" = "Digital Nomads"; +"Appstore.new.Powerful.join" = "Dwayne joined the group"; +"Appstore.new.Powerful.subtitle" = "No limits on the size of\ngroups and broadcasts."; +"Appstore.new.Powerful.title" = "Powerful"; +"Appstore.new.Private.emojipack" = "Animated Emoji"; +"Appstore.new.Private.message" = "No limits on the size of your cats."; +"Appstore.new.Private.name" = "Penelope"; +"Appstore.new.Private.subtitle" = "Your data is never disclosed.\nOnly you are in control."; +"Appstore.new.Private.title" = "Private"; +"Appstore.new.Public.chat1.message" = "US Commerce Secretary Howard Lutnick says a trade deal with India did not materialize last year because.."; +"Appstore.new.Public.chat1.name" = "Bloomberg"; +"Appstore.new.Public.chat2.message" = "There's nothing better than grouse hunting in Maine with your dogs.."; +"Appstore.new.Public.chat2.name" = "Tucker Carlson"; +"Appstore.new.Public.chat3.message" = "Bounce with it"; +"Appstore.new.Public.chat3.name" = "Snoop Dogg"; +"Appstore.new.Public.iv.link" = "ft.com"; +"Appstore.new.Public.iv.name" = "Financial Times"; +"Appstore.new.Public.iv.text" = "Findings reinforce warnings from policymakers such as Christine Lagarde that internal market has stood still"; +"Appstore.new.Public.iv.title" = "Trade between EU member states is slowing, data shows"; +"Appstore.new.Public.subtitle" = "Public channels, open groups,\nbots for integrations."; +"Appstore.new.Public.title" = "Public"; +"Appstore.new.Secure.chat1.name" = "Little Sister"; +"Appstore.new.Secure.message1" = "Any gift ideas for mom?"; +"Appstore.new.Secure.message2" = "A dog!"; +"Appstore.new.Secure.message3" = "I'm serious. Let's get her a puppy."; +"Appstore.new.Secure.message4" = "I saw this!"; +"Appstore.new.Secure.message5" = "I needed proof this was your idea!"; +"Appstore.new.Secure.subtitle" = "All chats are protected\nwith strong encryption."; +"Appstore.new.Secure.title" = "Secure"; +"ArchiveInfo.ChatsText" = "Move any chat into your Archive and back by swiping on it."; +"ArchiveInfo.ChatsTitle" = "Archived Chats"; +"ArchiveInfo.CloseAction" = "Got it"; +"ArchiveInfo.HideText" = "Hide the Archive from your main screen by swiping on it."; +"ArchiveInfo.HideTitle" = "Hiding Archive"; +"ArchiveInfo.StoriesText" = "Archive Stories from your contacts separately from chats with them."; +"ArchiveInfo.StoriesTitle" = "Stories"; +"ArchiveInfo.TextKeepArchivedDefault" = "When you receive a new message, muted chats will remain in the Archive, while unmuted chats will be moved to Chats. [Tap to change >]()"; +"ArchiveInfo.TextKeepArchivedUnmuted" = "Archived chats will remain in the Archive when you receive a new message. [Tap to change >]()"; +"ArchiveInfo.Title" = "This is Your Archive"; +"ArchiveSettings.AutomaticallyArchive" = "Automatically Archive"; +"ArchiveSettings.FolderChatsFooter" = "Keep archived chats from folders in the Archive even if they are unmuted and get a new message."; +"ArchiveSettings.FolderChatsHeader" = "CHATS FROM FOLDERS"; +"ArchiveSettings.KeepArchived" = "Always Keep Archived"; +"ArchiveSettings.Title" = "Archive Settings"; +"ArchiveSettings.TooltipPremiumRequired" = "This setting is available only to the subscribers of [Telegram Premium]()."; +"ArchiveSettings.UnknownChatsFooter" = "Automatically archive and mute new private chats, groups and channels from non-contacts."; +"ArchiveSettings.UnknownChatsHeader" = "NEW CHATS FROM UNKNOWN USERS"; +"ArchiveSettings.UnmutedChatsFooter" = "Keep archived chats in the Archive even if they are unmuted and get a new message."; +"ArchiveSettings.UnmutedChatsHeader" = "UNMUTED CHATS"; +"ArchivedChats.IntroText1" = "Chats with enabled notifications get\nunarchived when new messages arrive."; +"ArchivedChats.IntroText2" = "Chats with disabled notifications stay\narchived when new messages arrive."; +"ArchivedChats.IntroText3" = "You can pin an unlimited number of archived chats to the top."; +"ArchivedChats.IntroTitle1" = "This is your archive"; +"ArchivedChats.IntroTitle2" = "Muted Chats"; +"ArchivedChats.IntroTitle3" = "Pinned Chats"; +"ArchivedPacksAlert.Title" = "Some of your older sticker sets have been archived. You can reactivate them in the Sticker Settings."; +"Attachment.AllMedia" = "All"; +"Attachment.Audio" = "Audio"; +"Attachment.BoostToUnlock" = "Boost to Unlock"; +"Attachment.Camera" = "Camera"; +"Attachment.CameraAccessText" = "Telegram needs camera access so that you can take photos and videos."; +"Attachment.CancelSelectionAlertNo" = "No"; +"Attachment.CancelSelectionAlertText" = "Cancel selection?"; +"Attachment.CancelSelectionAlertYes" = "Yes"; +"Attachment.ChatsMusic" = "YOUR CHATS"; +"Attachment.Contact" = "Contact"; +"Attachment.Create" = "Create sticker"; +"Attachment.DeselectedItems#one" = "%@ item unselected."; +"Attachment.DeselectedItems#other" = "%@ items unselected."; +"Attachment.DeselectedPhotos#one" = "%@ photo unselected."; +"Attachment.DeselectedPhotos#other" = "%@ photos unselected."; +"Attachment.DeselectedVideos#one" = "%@ video unselected."; +"Attachment.DeselectedVideos#other" = "%@ videos unselected."; +"Attachment.DisableSpoiler" = "Disable Spoiler"; +"Attachment.DiscardPasteboardAlertText" = "Discard pasted items?"; +"Attachment.DiscardTodoAlertText" = "Discard checklist items?"; +"Attachment.DragToReorder" = "Drag media to reorder"; +"Attachment.EnableSpoiler" = "Hide With Spoiler"; +"Attachment.File" = "File"; +"Attachment.File.PollSubtitle.Description" = "Add file to the poll description"; +"Attachment.File.PollSubtitle.Explanation" = "Add file to the quiz explanation"; +"Attachment.FilesIntro" = "Send and receive files of any type, up to 2 GB\nin size each, access them instantly\non your other devices."; +"Attachment.FilesSearchPlaceholder" = "Search sent files"; +"Attachment.Gallery" = "Gallery"; +"Attachment.Gift" = "Gift"; +"Attachment.Grouped" = "Grouped"; +"Attachment.LimitedMediaAccessText" = "You have limited Telegram from accessing your photos."; +"Attachment.Link" = "Link"; +"Attachment.Location" = "Location"; +"Attachment.LocationAccessText" = "Share places or your live location."; +"Attachment.LocationAccessTitle" = "Access Your Location"; +"Attachment.Manage" = "Manage"; +"Attachment.MediaAccessStoryText" = "Share an unlimited number of photos and videos of up to 2 GB each."; +"Attachment.MediaAccessText" = "Share an unlimited number of photos\nand videos of up to 2 GB each."; +"Attachment.MediaAccessTitle" = "Access Your Photos and Videos"; +"Attachment.MediaTypes" = "Media Types"; +"Attachment.MessagePreview" = "Message Preview"; +"Attachment.MessagesPreview" = "Messages Preview"; +"Attachment.MyAlbums" = "My Albums"; +"Attachment.OpenCamera" = "Open Camera"; +"Attachment.OpenSettings" = "Go to Settings"; +"Attachment.Paid.Create" = "Make This Content Paid"; +"Attachment.Paid.EditPrice" = "Edit Price"; +"Attachment.Paid.EditPrice.Stars#one" = "%@ Star"; +"Attachment.Paid.EditPrice.Stars#other" = "%@ Stars"; +"Attachment.Pasteboard" = "Clipboard"; +"Attachment.Poll" = "Poll"; +"Attachment.ProfileMusic" = "PROFILE MUSIC"; +"Attachment.PublicMusic" = "PUBLIC CHATS"; +"Attachment.RecentlySentFiles" = "Recently Sent Files"; +"Attachment.Reply" = "Reply"; +"Attachment.ScanDocument" = "Scan Document"; +"Attachment.SearchWeb" = "Search Web"; +"Attachment.SelectFromFiles" = "Select from Files"; +"Attachment.SelectFromGallery" = "Select from Gallery"; +"Attachment.SelectedMedia#one" = "%@ Selected"; +"Attachment.SelectedMedia#other" = "%@ Selected"; +"Attachment.SendAsFile" = "Send as File"; +"Attachment.SendAsFiles" = "Send as Files"; +"Attachment.SendInHd" = "Send in High Quality"; +"Attachment.SendWithoutGrouping" = "Send Without Grouping"; +"Attachment.SharedAudio" = "SHARED AUDIO"; +"Attachment.Sticker" = "Sticker"; +"Attachment.Todo" = "Checklist"; +"Attachment.Ungrouped" = "Ungrouped"; +"AttachmentMenu.AddDocument" = "Add Document"; +"AttachmentMenu.AddPhotoOrVideo" = "Add Photo or Video"; +"AttachmentMenu.File" = "File"; +"AttachmentMenu.PhotoOrVideo" = "Photo or Video"; +"AttachmentMenu.Poll" = "Poll"; +"AttachmentMenu.SendAsFile" = "Send as File"; +"AttachmentMenu.SendAsFiles" = "Send as Files"; +"AttachmentMenu.SendGif#one" = "Send 1 GIF"; +"AttachmentMenu.SendGif#other" = "Send %@ GIFs"; +"AttachmentMenu.SendItem#one" = "Send 1 Item"; +"AttachmentMenu.SendItem#other" = "Send %@ Items"; +"AttachmentMenu.SendPhoto#one" = "Send 1 Photo"; +"AttachmentMenu.SendPhoto#other" = "Send %@ Photos"; +"AttachmentMenu.SendVideo#one" = "Send 1 Video"; +"AttachmentMenu.SendVideo#other" = "Send %@ Videos"; +"AttachmentMenu.WebSearch" = "Web Search"; +"AuthCode.Alert" = "Your login code is %@. Enter it in the Telegram app where you are trying to log in.\n\nDo not give this code to anyone."; +"AuthConfirmation.AllowMessages" = "Allow Messages"; +"AuthConfirmation.AllowMessagesInfo" = "This will allow %@ to message you."; +"AuthConfirmation.Cancel" = "Cancel"; +"AuthConfirmation.Description" = "This site will receive your **name**,\n**username** and **profile photo**."; +"AuthConfirmation.DescriptionApp" = "This app will receive your **name**,\n**username** and **profile photo**."; +"AuthConfirmation.Device" = "Device"; +"AuthConfirmation.Emoji.Description" = "Telegram wants to make sure it's really you."; +"AuthConfirmation.Emoji.DescriptionFirst" = "Telegram wants to make sure it's really you trying to log in to **%@**."; +"AuthConfirmation.Emoji.Title" = "Tap the emoji shown\non your login screen"; +"AuthConfirmation.Info" = "This login attempt came from the device above."; +"AuthConfirmation.IpAddress" = "IP Address"; +"AuthConfirmation.LogIn" = "Log In"; +"AuthConfirmation.LoginFail.Text" = "Please try logging in to [%@]() again."; +"AuthConfirmation.LoginFail.TextUnknown" = "Please try logging in again."; +"AuthConfirmation.LoginFail.Title" = "Login Failed"; +"AuthConfirmation.LoginSuccess.Text" = "You are now logged in to [%@]()."; +"AuthConfirmation.LoginSuccess.TextNoNumber" = "You are logged in to [%@](), but you didn't grant access to your phone number."; +"AuthConfirmation.LoginSuccess.Title" = "Login Successful"; +"AuthConfirmation.PhoneNumberConfirmation.Allow" = "Allow"; +"AuthConfirmation.PhoneNumberConfirmation.Deny" = "Deny"; +"AuthConfirmation.PhoneNumberConfirmation.Text" = "**%1$@** wants to access your phone number **%2$@**.\n\nAllow access?"; +"AuthConfirmation.PhoneNumberConfirmation.Title" = "Phone Number"; +"AuthConfirmation.Title" = "Log in to **%@**"; +"AuthConfirmation.UnverifiedApp" = "Unverified App"; +"AuthSessions.AddDevice" = "Scan QR Code"; +"AuthSessions.AddDevice.InvalidQRCode" = "Invalid QR Code"; +"AuthSessions.AddDevice.ScanInfo" = "Scan a QR code to log into\nthis account on another device."; +"AuthSessions.AddDevice.ScanInstallInfo" = "Go to [getdesktop.telegram.org](desktop) or [web.telegram.org](web) to get the QR code"; +"AuthSessions.AddDevice.ScanTitle" = "Scan QR Code"; +"AuthSessions.AddDevice.UrlLoginHint" = "This code can be used to allow someone to log in to your Telegram account.\n\nTo confirm Telegram login, please go to Settings > Devices > Add Device and scan the code."; +"AuthSessions.AddDeviceIntro.Action" = "Scan QR Code"; +"AuthSessions.AddDeviceIntro.Text1" = "Download Telegram on your computer from [desktop.telegram.org]()"; +"AuthSessions.AddDeviceIntro.Text2" = "Run Telegram on your computer to get the QR code"; +"AuthSessions.AddDeviceIntro.Text3" = "Scan the QR code to connect your account"; +"AuthSessions.AddDeviceIntro.Title" = "Log in by QR Code"; +"AuthSessions.AddedDeviceTerminate" = "Terminate"; +"AuthSessions.AddedDeviceTitle" = "Login Successful"; +"AuthSessions.AppUnofficial" = "(ID: %@)"; +"AuthSessions.CurrentSession" = "THIS DEVICE"; +"AuthSessions.DevicesTitle" = "Devices"; +"AuthSessions.EmptyText" = "You can log in to Telegram from other mobile, tablet and desktop devices, using the same phone number. All your data will be instantly synchronized."; +"AuthSessions.EmptyTitle" = "No other sessions"; +"AuthSessions.HeaderInfo" = "Log into [Telegram Desktop](desktop) or [Telegram Web](web) by scanning a QR code."; +"AuthSessions.IncompleteAttempts" = "INCOMPLETE LOGIN ATTEMPTS"; +"AuthSessions.IncompleteAttemptsInfo" = "These devices have no access to your account. The code was entered correctly, but no correct password was given."; +"AuthSessions.LinkDesktopDevice" = "Add Device"; +"AuthSessions.LogOut" = "Log Out"; +"AuthSessions.LogOutApplications" = "Disconnect All Websites"; +"AuthSessions.LogOutApplicationsHelp" = "You can log in on websites that support signing in with Telegram."; +"AuthSessions.LoggedIn" = "Websites"; +"AuthSessions.LoggedInWithTelegram" = "LOGGED IN WITH TELEGRAM"; +"AuthSessions.Message" = "You allowed this bot to message you when you logged in on %@."; +"AuthSessions.MessageApp" = "You allowed this bot to message you when you opened %@."; +"AuthSessions.OtherDevices" = "The official Telegram App is available for iPhone, iPad, Android, macOS, Windows and Linux. [Learn More]()"; +"AuthSessions.OtherSessions" = "ACTIVE SESSIONS"; +"AuthSessions.Sessions" = "Sessions"; +"AuthSessions.Terminate" = "Terminate"; +"AuthSessions.TerminateIfAwayFor" = "If Inactive For"; +"AuthSessions.TerminateIfAwayTitle" = "Automatically Terminate Old Sessions"; +"AuthSessions.TerminateOtherSessions" = "Terminate all other sessions"; +"AuthSessions.TerminateOtherSessionsHelp" = "Logs out all devices except for this one."; +"AuthSessions.TerminateOtherSessionsText" = "Are you sure you want to terminate all other sessions?"; +"AuthSessions.TerminateSession" = "Terminate session"; +"AuthSessions.TerminateSessionText" = "Are you sure you want to log out from this device?"; +"AuthSessions.Title" = "Active Sessions"; +"AuthSessions.View.AcceptIncomingCalls" = "Incoming Calls"; +"AuthSessions.View.AcceptSecretChats" = "New Secret Chats"; +"AuthSessions.View.AcceptTitle" = "Accept on This Device"; +"AuthSessions.View.Application" = "Application"; +"AuthSessions.View.Browser" = "Browser"; +"AuthSessions.View.Device" = "Device"; +"AuthSessions.View.IP" = "IP Address"; +"AuthSessions.View.Location" = "Location"; +"AuthSessions.View.LocationInfo" = "This location estimate is based on the IP address and may not always be accurate."; +"AuthSessions.View.Logout" = "Log Out"; +"AuthSessions.View.OS" = "Operating System"; +"AuthSessions.View.TerminateSession" = "Terminate Session"; +"AutoDownloadSettings.AutoDownload" = "Auto-Download Media"; +"AutoDownloadSettings.AutodownloadFiles" = "AUTO-DOWNLOAD FILES AND MUSIC"; +"AutoDownloadSettings.AutodownloadPhotos" = "AUTO-DOWNLOAD PHOTOS"; +"AutoDownloadSettings.AutodownloadVideos" = "AUTO-DOWNLOAD VIDEOS AND GIFS"; +"AutoDownloadSettings.Cellular" = "CELLULAR"; +"AutoDownloadSettings.CellularTitle" = "Using Cellular"; +"AutoDownloadSettings.Channels" = "Channels"; +"AutoDownloadSettings.Contacts" = "Contacts"; +"AutoDownloadSettings.DataUsage" = "DATA USAGE"; +"AutoDownloadSettings.DataUsageCustom" = "Custom"; +"AutoDownloadSettings.DataUsageHigh" = "High"; +"AutoDownloadSettings.DataUsageLow" = "Low"; +"AutoDownloadSettings.DataUsageMedium" = "Medium"; +"AutoDownloadSettings.Delimeter" = ", "; +"AutoDownloadSettings.DocumentsTitle" = "Files"; +"AutoDownloadSettings.Files" = "Files"; +"AutoDownloadSettings.GroupChats" = "Group Chats"; +"AutoDownloadSettings.LastDelimeter" = " and "; +"AutoDownloadSettings.LimitBySize" = "LIMIT BY SIZE"; +"AutoDownloadSettings.MaxFileSize" = "MAXIMUM FILE SIZE"; +"AutoDownloadSettings.MaxVideoSize" = "MAXIMUM VIDEO SIZE"; +"AutoDownloadSettings.MediaTypes" = "TYPES OF MEDIA"; +"AutoDownloadSettings.OffForAll" = "Off for all chats"; +"AutoDownloadSettings.OnFor" = "On for %@"; +"AutoDownloadSettings.OnForAll" = "On for all chats"; +"AutoDownloadSettings.OnForContacts" = "On for contacts"; +"AutoDownloadSettings.Photos" = "Photos"; +"AutoDownloadSettings.PhotosTitle" = "Photos"; +"AutoDownloadSettings.PreloadVideo" = "Preload Larger Videos"; +"AutoDownloadSettings.PreloadVideoInfo" = "Preload the first seconds of videos larger than %@ for instant playback."; +"AutoDownloadSettings.PrivateChats" = "Other Private Chats"; +"AutoDownloadSettings.Reset" = "Reset"; +"AutoDownloadSettings.ResetHelp" = "Undo all custom auto-download settings."; +"AutoDownloadSettings.ResetSettings" = "Reset Auto-Download Settings"; +"AutoDownloadSettings.Stories" = "Stories"; +"AutoDownloadSettings.StoriesArchivedContacts" = "Archived Contacts"; +"AutoDownloadSettings.StoriesSectionHeader" = "AUTO-DOWNLOAD STORIES"; +"AutoDownloadSettings.StoriesTitle" = "Stories"; +"AutoDownloadSettings.Title" = "Auto-Download"; +"AutoDownloadSettings.TypeChannels" = "Channels"; +"AutoDownloadSettings.TypeContacts" = "Contacts"; +"AutoDownloadSettings.TypeGroupChats" = "Groups"; +"AutoDownloadSettings.TypePrivateChats" = "PM"; +"AutoDownloadSettings.Unlimited" = "unlimited"; +"AutoDownloadSettings.UpTo" = "up to %@"; +"AutoDownloadSettings.UpToFor" = "Up to %@ for %@"; +"AutoDownloadSettings.UpToForAll" = "Up to %@ for all chats"; +"AutoDownloadSettings.VideoMessagesTitle" = "Video Messages"; +"AutoDownloadSettings.Videos" = "Videos"; +"AutoDownloadSettings.VideosTitle" = "Videos"; +"AutoDownloadSettings.VoiceMessagesInfo" = "Voice messages are tiny, they are always downloaded automatically."; +"AutoDownloadSettings.VoiceMessagesTitle" = "Voice Messages"; +"AutoDownloadSettings.WiFi" = "WI-FI"; +"AutoDownloadSettings.WifiTitle" = "Using Wi-Fi"; +"AutoNightTheme.Automatic" = "Automatic"; +"AutoNightTheme.AutomaticHelp" = "Switch to night theme when brightness is %@%% or less. Auto-brightness should be enabled for this feature to work correctly."; +"AutoNightTheme.AutomaticSection" = "BRIGHTNESS THRESHOLD"; +"AutoNightTheme.Disabled" = "Disabled"; +"AutoNightTheme.LocationHelp" = "Calculating sunset & sunrise times requires a one-time check of your approximate location. Note that this location is stored locally on your device only.\n\nSunset: %@\nSunrise: %@"; +"AutoNightTheme.NotAvailable" = "N/A"; +"AutoNightTheme.PreferredTheme" = "PREFERRED NIGHT THEME"; +"AutoNightTheme.ScheduleSection" = "SCHEDULE"; +"AutoNightTheme.Scheduled" = "Scheduled"; +"AutoNightTheme.ScheduledFrom" = "From"; +"AutoNightTheme.ScheduledTo" = "To"; +"AutoNightTheme.System" = "System"; +"AutoNightTheme.Title" = "Auto-Night Mode"; +"AutoNightTheme.UpdateLocation" = "Update Location"; +"AutoNightTheme.UseSunsetSunrise" = "Use Local Sunset & Sunrise"; +"Autoremove.OptionOff" = "Off"; +"Autoremove.SetCustomTime" = "Set Custom Time..."; +"AutoremoveSetup.AdditionalGlobalSettingsInfo" = "You can also set a default [self-destruct timer]() for all chats in Settings."; +"AutoremoveSetup.TimeSectionHeader" = "AUTO-DELETE MESSAGES"; +"AutoremoveSetup.TimerInfoChannel" = "Automatically delete new messages sent in this channel after a certain period of time."; +"AutoremoveSetup.TimerInfoChat" = "Automatically delete new messages sent in this chat after a certain period of time."; +"AutoremoveSetup.TimerValueAfter" = "After %@"; +"AutoremoveSetup.TimerValueNever" = "Never"; +"AutoremoveSetup.Title" = "Auto-Deletion"; +"Autosave.AddException" = "Add Exception"; +"Autosave.DeleteAllExceptions" = "Delete All Exceptions"; +"Autosave.Exception" = "Exception"; +"Autosave.ExceptionsSection" = "EXCEPTIONS"; +"Autosave.TypePhoto" = "Photos"; +"Autosave.TypeVideo" = "Videos"; +"Autosave.TypesInfo" = "Automatically save all new media from private chats to your Photos app."; +"Autosave.TypesSection" = "SAVE TO PHOTOS"; +"Autosave.VideoInfo" = "All videos downloaded in private chats that are smaller than %@ will be saved to your Photos app."; +"Autosave.VideoSizeSection" = "MAXIMUM VIDEO SIZE"; +"AvatarEditor.Background" = "BACKGROUND"; +"AvatarEditor.Emoji" = "Emoji"; +"AvatarEditor.EmojiOrSticker" = "EMOJI OR STICKER"; +"AvatarEditor.PremiumNeeded.Background" = "Subscribe to Telegram Premium to use this background."; +"AvatarEditor.PremiumNeeded.Emoji" = "Subscribe to Telegram Premium to choose this emoji."; +"AvatarEditor.PremiumNeeded.Sticker" = "Subscribe to Telegram Premium to choose this sticker."; +"AvatarEditor.Set" = "Set"; +"AvatarEditor.SetChannelPhoto" = "Set as Channel Photo"; +"AvatarEditor.SetGroupPhoto" = "Set as Group Photo"; +"AvatarEditor.SetProfilePhoto" = "Set as Profile Photo"; +"AvatarEditor.Stickers" = "Stickers"; +"AvatarEditor.Suggest" = "Suggest"; +"AvatarEditor.SuggestProfilePhoto" = "Suggest as Profile Photo"; +"AvatarEditor.SwitchToEmoji" = "SWITCH TO EMOJI"; +"AvatarEditor.SwitchToStickers" = "SWITCH TO STICKERS"; +"AvatarUpload.StatusDone" = "Your photo is now set."; +"AvatarUpload.StatusUploading" = "Your photo is uploading."; +"AvatarUpload.ViewAction" = "View"; +"BackgroundTasks.MediaFinished" = "Finished"; +"BackgroundTasks.MediaSubtitle" = "Running..."; +"BackgroundTasks.StoryFinished" = "Finished"; +"BackgroundTasks.StoryOpenAppToContinue" = "Open app to continue"; +"BackgroundTasks.StorySubtitle" = "Running..."; +"BackgroundTasks.UploadingMedia#one" = "Sending Message"; +"BackgroundTasks.UploadingMedia#other" = "Sending %d Messages"; +"BackgroundTasks.UploadingStories#one" = "Uploading Story"; +"BackgroundTasks.UploadingStories#other" = "Uploading %d Stories"; +"BalanceNeeded.FragmentAction" = "Add Funds via Fragment"; +"BalanceNeeded.FragmentSubtitle" = "You can add Grams to your balance via the third-party platform Fragment."; +"BalanceNeeded.FragmentTitle" = "%@ Grams Needed"; +"Birthday.Added" = "Birthday added."; +"Birthday.FloodError" = "Sorry, you can’t change your birthday so often."; +"Birthday.Help" = "Choose who can see your birthday in [Settings]()."; +"Birthday.HelpContacts" = "Only your contacts will see your birthday. [Change >]()"; +"Birthday.Save" = "Save"; +"Birthday.Title" = "Birthday"; +"BlockedUsers.AddNew" = "Add New..."; +"BlockedUsers.BlockTitle" = "Block"; +"BlockedUsers.BlockUser" = "Block User..."; +"BlockedUsers.Info" = "Blocked users can't send you messages or add you to groups. They will not see your profile photos, stories, online and last seen status."; +"BlockedUsers.LeavePrefix" = "Leave "; +"BlockedUsers.SelectUserTitle" = "Block User"; +"BlockedUsers.Title" = "Blocked"; +"BlockedUsers.Unblock" = "Unblock"; +"BoostGift.AddChannel" = "Add Channel"; +"BoostGift.AddChannelOrGroup" = "Add Channel or Group"; +"BoostGift.AddGroupOrChannel" = "Add Group or Channel"; +"BoostGift.AdditionalPrizes" = "Additional Prizes"; +"BoostGift.AdditionalPrizesInfoForMonths#one" = "for **%@** month"; +"BoostGift.AdditionalPrizesInfoForMonths#other" = "for **%@** months"; +"BoostGift.AdditionalPrizesInfoOff" = "Turn this on if you want to give the winners your own prizes in addition to Premium subscriptions."; +"BoostGift.AdditionalPrizesInfoOn" = "All prizes: **%1$@** %2$@ %3$@."; +"BoostGift.AdditionalPrizesInfoStars#one" = "**%@** Star"; +"BoostGift.AdditionalPrizesInfoStars#other" = "**%@** Stars"; +"BoostGift.AdditionalPrizesInfoStarsAndOther" = " and **%1$@** %2$@"; +"BoostGift.AdditionalPrizesInfoStarsOff" = "Turn this on if you want to give the winners your own prizes in addition to Stars."; +"BoostGift.AdditionalPrizesInfoStarsOn" = "All prizes: %1$@%2$@."; +"BoostGift.AdditionalPrizesInfoSubscriptions#one" = "%@ Telegram Premium subscription"; +"BoostGift.AdditionalPrizesInfoSubscriptions#other" = "%@ Telegram Premium subscriptions"; +"BoostGift.AdditionalPrizesInfoWithSubscriptions#one" = "with %@ Telegram Premium subscription"; +"BoostGift.AdditionalPrizesInfoWithSubscriptions#other" = "with %@ Telegram Premium subscriptions"; +"BoostGift.AdditionalPrizesPlaceholder" = "Enter Your Prize"; +"BoostGift.AllSubscribers" = "All subscribers"; +"BoostGift.AwardSpecificUsers" = "Award Specific Users"; +"BoostGift.Channels.MaximumReached" = "You can select up to %@ channels."; +"BoostGift.Channels.PrivateChannel.Add" = "Add"; +"BoostGift.Channels.PrivateChannel.Text" = "Are you sure you want to add a private channel? Users won't be able to join it without an invite link."; +"BoostGift.Channels.PrivateChannel.Title" = "Channel is Private"; +"BoostGift.Channels.Save" = "Save Channels"; +"BoostGift.Channels.Search" = "Search Channels"; +"BoostGift.Channels.SectionTitle" = "CHANNELS"; +"BoostGift.Channels.Subtitle" = "select up to %@ channels"; +"BoostGift.Channels.Title" = "Add Channels"; +"BoostGift.ChannelsAndGroupsInfo" = "Choose additional channels or groups users need to join to take part in the giveaway."; +"BoostGift.ChannelsAndGroupsTitle" = "INCLUDED CHANNELS AND GROUPS"; +"BoostGift.ChannelsBoosts#one" = "this channel will receive %@ boost"; +"BoostGift.ChannelsBoosts#other" = "this channel will receive %@ boosts"; +"BoostGift.ChannelsInfo" = "Choose the channels users need to join to take part in the giveaway."; +"BoostGift.ChannelsOrGroups.MaximumReached" = "You can select maximum %@ channels and groups."; +"BoostGift.ChannelsOrGroups.Save" = "Save Channels and Groups"; +"BoostGift.ChannelsOrGroups.Search" = "Search Channels and Groups"; +"BoostGift.ChannelsOrGroups.SectionTitle" = "CHANNELS AND GROUPS"; +"BoostGift.ChannelsOrGroups.Subtitle" = "select up to %@ channels or groups"; +"BoostGift.ChannelsOrGroups.Title" = "Add Channels or Groups"; +"BoostGift.ChannelsTitle" = "CHANNELS INCLUDED IN THE GIVEAWAY"; +"BoostGift.CreateGiveaway" = "Create Giveaway"; +"BoostGift.CreateGiveawayInfo" = "winners are chosen randomly"; +"BoostGift.DateEnds" = "Ends"; +"BoostGift.DateInfo" = "Choose when %1$@ of your channel will be randomly selected to receive Telegram Premium."; +"BoostGift.DateInfoSubscribers#one" = "%@ subscriber"; +"BoostGift.DateInfoSubscribers#other" = "%@ subscribers"; +"BoostGift.DateTitle" = "DATE WHEN GIVEAWAY ENDS"; +"BoostGift.Description" = "Get more boosts for your channel by gifting\nPremium to your subscribers."; +"BoostGift.DurationTitle" = "DURATION OF PREMIUM SUBSCRIPTIONS"; +"BoostGift.FromAllCountries" = "from all countries"; +"BoostGift.FromCountries#one" = "from %@ country"; +"BoostGift.FromCountries#other" = "from %@ countries"; +"BoostGift.FromOneCountry" = "from %1$@"; +"BoostGift.FromTwoCountries" = "from %1$@ and %2$@"; +"BoostGift.GiftPremium" = "Gift Premium"; +"BoostGift.GiveawayCreated.Text" = "Check your channel's [Statistics]() to see how this giveaway boosted your channel."; +"BoostGift.GiveawayCreated.Title" = "Giveaway Created"; +"BoostGift.Group.AllMembers" = "All members"; +"BoostGift.Group.DateInfo" = "Choose when %1$@ of your group will be randomly selected to receive Telegram Premium."; +"BoostGift.Group.DateInfoMembers#one" = "%@ member"; +"BoostGift.Group.DateInfoMembers#other" = "%@ members"; +"BoostGift.Group.Description" = "Get more boosts for your group by gifting\nPremium to your members."; +"BoostGift.Group.GiveawayCreated.Text" = "Check your group's [Boosts]() to see how this giveaway boosted your group."; +"BoostGift.Group.LimitMembersInfo" = "Choose if you want to limit the giveaway only to those who joined the group after the giveaway started."; +"BoostGift.Group.Members.Joined" = "joined %@"; +"BoostGift.Group.Members.MaximumReached" = "You can select maximum %@ members."; +"BoostGift.Group.Members.Save" = "Save Recipients"; +"BoostGift.Group.Members.Search" = "Search Members"; +"BoostGift.Group.Members.SectionTitle" = "MEMBERS"; +"BoostGift.Group.Members.Subtitle" = "select up to %@ members"; +"BoostGift.Group.Members.Title" = "Gift Premium"; +"BoostGift.Group.OnlyNewMembers" = "Only new members"; +"BoostGift.Group.PremiumGifted.Text" = "Check your groups's [Boosts]() to see how gifts boosted your group."; +"BoostGift.Group.StarsDateInfo" = "Choose when %@ of your group will be randomly selected to receive Stars."; +"BoostGift.Group.StarsGiveawayCreated.Text" = "Check your group's [Boosts]() to see how this giveaway boosted your group."; +"BoostGift.GroupBoosts#one" = "this group will receive %@ boost"; +"BoostGift.GroupBoosts#other" = "this group will receive %@ boosts"; +"BoostGift.Groups.PrivateGroup.Text" = "Are you sure you want to add a private group? Users won't be able to join it without an invite link."; +"BoostGift.Groups.PrivateGroup.Title" = "Group is Private"; +"BoostGift.GroupsAndChannelsInfo" = "Choose additional groups or channels users need to join to take part in the giveaway."; +"BoostGift.GroupsAndChannelsTitle" = "INCLUDED GROUPS AND CHANNELS"; +"BoostGift.GroupsOrChannels.MaximumReached" = "You can select maximum %@ groups and channels."; +"BoostGift.GroupsOrChannels.Save" = "Save Groups and Channels"; +"BoostGift.GroupsOrChannels.Search" = "Search Groups and Channels"; +"BoostGift.GroupsOrChannels.SectionTitle" = "GROUPS AND CHANNELS"; +"BoostGift.GroupsOrChannels.Subtitle" = "select up to %@ groups or channels"; +"BoostGift.GroupsOrChannels.Title" = "Add Groups or Channels"; +"BoostGift.LimitSubscribersInfo" = "Choose if you want to limit the giveaway only to those who joined the channel after the giveaway started or to users from specific countries."; +"BoostGift.Members.MaximumReached" = "You can select maximum %@ members."; +"BoostGift.Members.Search" = "Search Members"; +"BoostGift.Members.SectionTitle" = "MEMBERS"; +"BoostGift.Members.Subtitle" = "select up to %@ members"; +"BoostGift.NewDescription" = "Get more boosts and subscribers for\nyour channel by giving away prizes."; +"BoostGift.NewDescriptionGroup" = "Get more boosts and members for\nyour group by giving away prizes."; +"BoostGift.OnlyNewSubscribers" = "Only new subscribers"; +"BoostGift.PremiumGifted.Text" = "Check your channel's [Statistics]() to see how the gifts boosted your channel."; +"BoostGift.PremiumGifted.Title" = "Premium Subscriptions Gifted"; +"BoostGift.PremiumInfo" = "You can review the list of features and more details about Telegram Premium [here]()."; +"BoostGift.PrepaidGiveaway.StarsCount#one" = "%@ Star"; +"BoostGift.PrepaidGiveaway.StarsCount#other" = "%@ Stars"; +"BoostGift.PrepaidGiveaway.StarsWinners#one" = "for %@ winner"; +"BoostGift.PrepaidGiveaway.StarsWinners#other" = "among %@ winners"; +"BoostGift.PrepaidGiveawayCount#one" = "%@ Telegram Premium"; +"BoostGift.PrepaidGiveawayCount#other" = "%@ Telegram Premium"; +"BoostGift.PrepaidGiveawayMonths" = "%@-month subscriptions"; +"BoostGift.PrepaidGiveawayStarsCount#one" = "%@ Telegram Premium"; +"BoostGift.PrepaidGiveawayStarsCount#other" = "%@ Telegram Premium"; +"BoostGift.PrepaidGiveawayStarsMonths" = "%@-month subscriptions"; +"BoostGift.PrepaidGiveawayTitle" = "PREPAID GIVEAWAY"; +"BoostGift.Prize" = "PRIZE"; +"BoostGift.Prize.Premium" = "Telegram Premium"; +"BoostGift.Prize.Stars" = "Telegram Stars"; +"BoostGift.QuantityBoosts#one" = "%@ BOOST"; +"BoostGift.QuantityBoosts#other" = "%@ BOOSTS"; +"BoostGift.QuantityInfo" = "Choose how many Premium subscriptions to give away and boosts to receive."; +"BoostGift.QuantityTitle" = "QUANTITY OF PRIZES"; +"BoostGift.ReduceQuantity.Reduce" = "Reduce"; +"BoostGift.ReduceQuantity.Text" = "You can't acquire %1$@ %2$@-month subscriptions in the app. Do you want to reduce the quantity to %3$@?"; +"BoostGift.ReduceQuantity.Title" = "Reduce Quantity"; +"BoostGift.SelectRecipients" = "select recipients"; +"BoostGift.Stars.Boosts#one" = "%@ BOOST"; +"BoostGift.Stars.Boosts#other" = "%@ BOOSTS"; +"BoostGift.Stars.Info" = "Choose how many stars to give away and how many boosts to receive for 1 year."; +"BoostGift.Stars.PerUser" = "%@ per user"; +"BoostGift.Stars.ShowMoreOptions" = "Show More Options"; +"BoostGift.Stars.Stars#one" = "%@ Star"; +"BoostGift.Stars.Stars#other" = "%@ Stars"; +"BoostGift.Stars.Title" = "STARS TO DISTRIBUTE"; +"BoostGift.Stars.Winners" = "NUMBER OF WINNERS"; +"BoostGift.Stars.WinnersInfo" = "Choose how many winners you want to distribute stars among."; +"BoostGift.StarsDateInfo" = "Choose when %@ of your channel will be randomly selected to receive Stars."; +"BoostGift.StarsGiveawayCreated.Text" = "Check your channel's [Statistics]() to see how this giveaway boosted your channel."; +"BoostGift.StarsGiveawayCreated.Title" = "Stars Giveaway Created"; +"BoostGift.StartConfirmation.Start" = "Start"; +"BoostGift.StartConfirmation.Text" = "Are you sure you want to start giveaway now?"; +"BoostGift.StartConfirmation.Title" = "Start Giveaway"; +"BoostGift.StartGiveaway" = "Start Giveaway"; +"BoostGift.Subscribers.Joined" = "joined %@"; +"BoostGift.Subscribers.MaximumReached" = "You can select up to %@ subscribers."; +"BoostGift.Subscribers.Save" = "Save Recipients"; +"BoostGift.Subscribers.Search" = "Search Subscribers"; +"BoostGift.Subscribers.SectionTitle" = "SUBSCRIBERS"; +"BoostGift.Subscribers.Subtitle" = "select up to %@ subscribers"; +"BoostGift.Subscribers.Title" = "Gift Premium"; +"BoostGift.Title" = "Boosts via Gifts"; +"BoostGift.UsersTitle" = "USERS ELIGIBLE FOR THE GIVEAWAY"; +"BoostGift.Winners" = "Show Winners"; +"BoostGift.WinnersInfo" = "Choose whether to make the list of winners public when the giveaway ends."; +"Bot.AccepRecurrentInfo" = "I accept the [Terms of Service]() of **%1$@**"; +"Bot.AcceptTermsInfo" = "I accept the [Terms of Service]() of **%1$@**"; +"Bot.Active" = "bot is active now"; +"Bot.AddToChannel.Title" = "Add to Channel"; +"Bot.AddToChat" = "Add to Group or Channel"; +"Bot.AddToChat.Add.AddAsAdmin" = "Add Bot as Admin"; +"Bot.AddToChat.Add.AddAsMember" = "Add Bot as Member"; +"Bot.AddToChat.Add.AdminAlertAdd" = "Add as Admin"; +"Bot.AddToChat.Add.AdminAlertTextChannel" = "Do you want to add the bot as an admin in **%@**? It will have access to lists of channel admins and subscribers."; +"Bot.AddToChat.Add.AdminAlertTextGroup" = "Do you want to add the bot as an admin in **%@**? It will have access to the list of anonymous admins."; +"Bot.AddToChat.Add.AdminAlertTitle" = "Add Bot as Admin?"; +"Bot.AddToChat.Add.AdminRights" = "Admin Rights"; +"Bot.AddToChat.Add.MemberAlertAdd" = "Add as Member"; +"Bot.AddToChat.Add.MemberAlertTextChannel" = "Are you sure you want to add the bot as a subscriber in **%@**?"; +"Bot.AddToChat.Add.MemberAlertTextGroup" = "Are you sure you want to add the bot as a member in **%@**?"; +"Bot.AddToChat.Add.MemberAlertTitle" = "Add Bot as Member?"; +"Bot.AddToChat.Add.Title" = "Add Bot"; +"Bot.AddToChat.MyChannels" = "CHANNELS I MANAGE"; +"Bot.AddToChat.MyGroups" = "GROUPS I MANAGE"; +"Bot.AddToChat.OtherGroups" = "GROUPS"; +"Bot.AddToChat.Title" = "Add to Group or Channel"; +"Bot.AddToChatInfo" = "This bot is able to manage a group or channel."; +"Bot.AddToGroup.Title" = "Add to Group"; +"Bot.AlertCanNotCreateBots" = "%@ can't manage other bots."; +"Bot.DescriptionTitle" = "What can this bot do?"; +"Bot.GenericBotStatus" = "bot"; +"Bot.GenericSupportStatus" = "support"; +"Bot.GroupStatusDoesNotReadHistory" = "has no access to messages"; +"Bot.GroupStatusReadsHistory" = "has access to messages"; +"Bot.Inactive" = "bot is not active"; +"Bot.Settings" = "Bot Settings"; +"Bot.Slow" = "bot replies slowly"; +"Bot.Start" = "Start"; +"Bot.Stop" = "Block Bot"; +"Bot.TapToUse" = "Tap here to use this bot"; +"Bot.Unblock" = "Restart Bot"; +"BotPreview.ViewContextDelete" = "Delete Preview"; +"BotPreviews.AlertTooManyPreviews#one" = "You can add up to 12 previews."; +"BotPreviews.AlertTooManyPreviews#other" = "You can add up to 12 previews."; +"BotPreviews.DefaultFooter.Text" = "This preview will be shown by default. You can also add translations for specific languages."; +"BotPreviews.DeleteTranslationAlert.Text" = "Are you sure you want to delete this translation?"; +"BotPreviews.DeleteTranslationAlert.Title" = "Delete Translation"; +"BotPreviews.Empty.Add" = "Add Preview"; +"BotPreviews.Empty.AddTranslation" = "Create a Translation"; +"BotPreviews.Empty.DeleteTranslation" = "Delete this Translation"; +"BotPreviews.Empty.Separator" = "or"; +"BotPreviews.Empty.Text#one" = "Upload up to 1 screenshot or video demo for your mini app."; +"BotPreviews.Empty.Text#other" = "Upload up to %d screenshots and video demos for your mini app."; +"BotPreviews.Empty.Title" = "No Preview"; +"BotPreviews.LanguageTab.Add" = "+ Add Language"; +"BotPreviews.LanguageTab.Main" = "Main"; +"BotPreviews.MenuAddPreview" = "Add Preview"; +"BotPreviews.MenuDeleteLanguage" = "Delete %@"; +"BotPreviews.MenuReorder" = "Reorder"; +"BotPreviews.SelectLanguage.Title" = "Add a Translation"; +"BotPreviews.SheetDeleteTitle#one" = "Delete 1 Preview?"; +"BotPreviews.SheetDeleteTitle#other" = "Delete %d Previews?"; +"BotPreviews.SubtitleCount#one" = "1 preview"; +"BotPreviews.SubtitleCount#other" = "%d previews"; +"BotPreviews.SubtitleEmpty" = "no preview added"; +"BotPreviews.SubtitleLoading" = "loading"; +"BotPreviews.TranslationFooter.Text" = "This preview will be displayed for all users who have %@ set as their language."; +"BotVerification.Added" = "**%@** has been notified and will receive your verification mark and description upon accepting."; +"BotVerification.ChooseChat" = "Choose Chat to Verify"; +"BotVerification.Remove.Bot.Text" = "This bot is already verified by you. Do you want to remove verification?"; +"BotVerification.Remove.Channel.Text" = "This channel is already verified by you. Do you want to remove verification?"; +"BotVerification.Remove.Group.Text" = "This group is already verified by you. Do you want to remove verification?"; +"BotVerification.Remove.Remove" = "Remove"; +"BotVerification.Remove.Title" = "Remove Verification"; +"BotVerification.Remove.User.Text" = "This user is already verified by you. Do you want to remove verification?"; +"BotVerification.Removed" = "You have removed **%@'s** verification."; +"BotVerification.Verify.Bot.Text" = "Do you want to verify this bot with your verification mark and description?"; +"BotVerification.Verify.Bot.Title" = "Verify Bot"; +"BotVerification.Verify.Channel.Text" = "Do you want to verify this channel with your verification mark and description?"; +"BotVerification.Verify.Channel.Title" = "Verify Channel"; +"BotVerification.Verify.Group.Text" = "Do you want to verify this group with your verification mark and description?"; +"BotVerification.Verify.Group.Title" = "Verify Group"; +"BotVerification.Verify.Placeholder" = "This page is verified by %@"; +"BotVerification.Verify.User.Text" = "Do you want to verify this user with your verification mark and description?"; +"BotVerification.Verify.User.Title" = "Verify User"; +"BotVerification.Verify.Verify" = "Verify"; +"Broadcast.AdminLog.EmptyText" = "No notable actions by channel admins in the last 48 hours."; +"BroadcastGroups.Cancel" = "Leave as regular group"; +"BroadcastGroups.ConfirmationAlert.Convert" = "Convert"; +"BroadcastGroups.ConfirmationAlert.Text" = "Regular members of the group (non-admins) will **permanently** lose their right to send messages in the group.\n\nThis action **cannot** be undone."; +"BroadcastGroups.ConfirmationAlert.Title" = "Are you sure?"; +"BroadcastGroups.Convert" = "Convert to Broadcast Group"; +"BroadcastGroups.IntroText" = "• No limit on the number of members.\n\n• Only admins can post.\n\n• Can't be turned back into a regular group."; +"BroadcastGroups.IntroTitle" = "Broadcast Groups"; +"BroadcastGroups.LimitAlert.LearnMore" = "Learn More"; +"BroadcastGroups.LimitAlert.SettingsTip" = "If you change your mind, go to your group's Settings > Permissions."; +"BroadcastGroups.LimitAlert.Text" = "Your group has reached a limit of **%@** members.\n\nYou can increase this limit by converting the group to a **broadcast group** where only admins can post. Interested?"; +"BroadcastGroups.LimitAlert.Title" = "Limit Reached"; +"BroadcastGroups.Success" = "Your group can now have more than %@ members."; +"Browser.ContextMenu.AddToReadingList" = "Add to Reading List"; +"Browser.ContextMenu.CopyLink" = "Copy Link"; +"Browser.ContextMenu.Open" = "Open"; +"Browser.ContextMenu.OpenInNewTab" = "Open in New Tab"; +"Browser.ContextMenu.Share" = "Share"; +"Browser.ErrorTitle" = "An Error Occurred"; +"Business.AddPost" = "Add a Post"; +"Business.AddPostInfo" = "Publish photos and videos of your goods or services on your page."; +"Business.AdsInfo" = "As a Premium subscriber, you don't see any ads on Telegram, but you can turn them on, for example, to view your own ads that you launched on the [Telegram Ad Platform >]()"; +"Business.AdsInfo_URL" = "https://ads.telegram.org"; +"Business.AdsTitle" = "ADS IN CHANNELS"; +"Business.AwayMessages" = "Away Messages"; +"Business.AwayMessagesInfo" = "Define messages that are automatically sent when you are off."; +"Business.Chatbots" = "Coming Later"; +"Business.ChatbotsInfo" = "Add any third-party chatbots that will process customer interactions."; +"Business.ChatbotsItem" = "Chatbots"; +"Business.Description" = "Turn your account into a **business page** with these additional features by subscribing to **Telegram Premium**."; +"Business.DontHideAds" = "Do Not Hide Ads"; +"Business.GreetingMessages" = "Greeting Messages"; +"Business.GreetingMessagesInfo" = "Create greetings that will be automatically sent to new customers."; +"Business.Intro" = "Start Page"; +"Business.Intro.CustomizeSectionFooter" = "You can customize the message people see before they start a chat with you."; +"Business.Intro.CustomizeSectionHeader" = "CUSTOMIZE YOUR START PAGE"; +"Business.Intro.IntroSticker" = "Choose Sticker"; +"Business.Intro.IntroStickerValueRandom" = "Random"; +"Business.Intro.IntroTextPlaceholder" = "Enter Message"; +"Business.Intro.IntroTitlePlaceholder" = "Title"; +"Business.Intro.ResetToDefault" = "Reset to Default"; +"Business.Intro.Title" = "Start Page"; +"Business.IntroInfo" = "Customize the message people see before they start a chat with you."; +"Business.Links" = "Links to Chat"; +"Business.Links.AlertUnsavedAction" = "Reset"; +"Business.Links.AlertUnsavedText" = "You have unsaved changes. Reset?"; +"Business.Links.CreateAction" = "Create a Link to Chat"; +"Business.Links.DeleteItemConfirmationAction" = "Delete Link"; +"Business.Links.EditLinkTitle" = "Link to Chat"; +"Business.Links.EditLinkToastSaved" = "Preset message saved."; +"Business.Links.ErrorTooManyLinks" = "Sorry, you can't create more links."; +"Business.Links.ItemActionShare" = "Share"; +"Business.Links.ItemClickCount#one" = "1 click"; +"Business.Links.ItemClickCount#other" = "%d clicks"; +"Business.Links.ItemNoClicks" = "no clicks"; +"Business.Links.ItemNoText" = "No text"; +"Business.Links.LinkNameInputPlaceholder" = "Name this link..."; +"Business.Links.LinkNameText" = "Add a name for this link that only you will see."; +"Business.Links.LinkNameTitle" = "Link Name"; +"Business.Links.LinksSectionHeader" = "LINKS TO CHAT"; +"Business.Links.PreviewText" = "Add a message that will be entered in the message input field for anyone who starts a chat with you using this link:"; +"Business.Links.PreviewTitle" = "Link to Chat"; +"Business.Links.SimpleLinkInfoPhone" = "You can also use a simple link for a chat with you — [t.me/\u2060+\u2060%@](phone)."; +"Business.Links.SimpleLinkInfoUsername" = "You can also use a simple link for a chat with you — [t.me/%@](username)."; +"Business.Links.SimpleLinkInfoUsernamePhone" = "You can also use a simple link for a chat with you — [t.me/%1$@](username) or [t.me/\u2060+\u2060%2$@](phone)."; +"Business.Links.Text" = "Give your customers short links that start a chat with you — and suggest the first message from them to you."; +"Business.LinksInfo" = "Create links that start a chat with you, suggesting the first message."; +"Business.Location" = "Location"; +"Business.LocationInfo" = "Display the location of your business on your account."; +"Business.MoreFeaturesInfo" = "Check this section later for new business features."; +"Business.MoreFeaturesTitle" = "MORE BUSINESS FEATURES"; +"Business.OpeningHours" = "Opening Hours"; +"Business.OpeningHoursInfo" = "Show to your customers when you are open for business."; +"Business.QuickReplies" = "Quick Replies"; +"Business.QuickRepliesInfo" = "Set up shortcuts with rich text and media to respond to messages faster."; +"Business.SetEmojiStatus" = "Set Emoji Status"; +"Business.SetEmojiStatusInfo" = "Display the current status of your business next to your name."; +"Business.SubscribedDescription" = "You have now unlocked Telegram Business features. These features are **temporarily included** with your Premium Subscription."; +"Business.TagYourChats" = "Tag Your Chats"; +"Business.TagYourChatsInfo" = "Add colorful labels to chats for faster access in chat list."; +"Business.Title" = "Telegram Business"; +"BusinessHoursSetup.AddAction" = "Add a Set of Hours"; +"BusinessHoursSetup.AddSectionFooter" = "Specify your working hours during the day."; +"BusinessHoursSetup.DayClosed" = "Closed"; +"BusinessHoursSetup.DayIntervalEnd" = "Closing time"; +"BusinessHoursSetup.DayIntervalRemove" = "Remove"; +"BusinessHoursSetup.DayIntervalStart" = "Opening time"; +"BusinessHoursSetup.DayOpen24h" = "Open 24 Hours"; +"BusinessHoursSetup.DaySwitch" = "Open On This Day"; +"BusinessHoursSetup.DaysSectionTitle" = "BUSINESS HOURS"; +"BusinessHoursSetup.ErrorIntersectingDays.ResetAction" = "Reset"; +"BusinessHoursSetup.ErrorIntersectingDays.Text" = "Your business hours overlap. Do you want to reset them?"; +"BusinessHoursSetup.ErrorIntersectingHours.ResetAction" = "Reset"; +"BusinessHoursSetup.ErrorIntersectingHours.Text" = "Your business hours overlap. Do you want to reset them?"; +"BusinessHoursSetup.MainToggle" = "Show Business Hours"; +"BusinessHoursSetup.Text" = "Turn this on to show your opening hours schedule to your customers."; +"BusinessHoursSetup.TimeZone" = "Time Zone"; +"BusinessHoursSetup.Title" = "Business Hours"; +"BusinessLink.AlertTextLimitText" = "The message text limit is 4096 characters"; +"BusinessLink.ErrorExpired" = "Link Expired"; +"BusinessLocationSetup.AddressPlaceholder" = "Enter Address"; +"BusinessLocationSetup.AlertUnsavedChanges.ResetAction" = "Undo"; +"BusinessLocationSetup.AlertUnsavedChanges.Text" = "You have unsaved changes."; +"BusinessLocationSetup.DeleteLocation" = "Delete Location"; +"BusinessLocationSetup.ErrorAddressEmpty.ResetAction" = "Delete"; +"BusinessLocationSetup.ErrorAddressEmpty.Text" = "Address can't be empty."; +"BusinessLocationSetup.SetLocationOnMap" = "Set Location on Map"; +"BusinessLocationSetup.Text" = "Display the location of your business on your account."; +"BusinessLocationSetup.Title" = "Location"; +"BusinessMessageSetup.AwayMessageSectionHeader" = "AWAY MESSAGE"; +"BusinessMessageSetup.CreateAwayMessage" = "Create an Away Message"; +"BusinessMessageSetup.CreateGreetingMessage" = "Create a Greeting Message"; +"BusinessMessageSetup.ErrorNoRecipients.ResetAction" = "Reset"; +"BusinessMessageSetup.ErrorNoRecipients.Text" = "No recipients selected. Reset?"; +"BusinessMessageSetup.ErrorScheduleEndTimeBeforeStartTime.Text" = "Custom schedule end time must be larger than start time."; +"BusinessMessageSetup.ErrorScheduleEndTimeMissing.Text" = "Custom schedule end time is missing."; +"BusinessMessageSetup.ErrorScheduleStartTimeMissing.Text" = "Custom schedule start time is missing."; +"BusinessMessageSetup.ErrorScheduleTime.ResetAction" = "Reset"; +"BusinessMessageSetup.ErrorScheduleTimeMissing.Text" = "Custom schedule time is missing."; +"BusinessMessageSetup.GreetingMessageSectionHeader" = "GREETING MESSAGE"; +"BusinessMessageSetup.InactivitySectionFooter" = "Choose how many days should pass after your last interaction with a recipient to send them the greeting in response to their message."; +"BusinessMessageSetup.InactivitySectionHeader" = "PERIOD OF NO ACTIVITY"; +"BusinessMessageSetup.Recipients.AddExclude" = "Exclude Chats..."; +"BusinessMessageSetup.Recipients.AddInclude" = "Include Chats..."; +"BusinessMessageSetup.Recipients.AwayMessageFooter" = "Choose chats or entire chat categories for sending an away message."; +"BusinessMessageSetup.Recipients.CategoryContacts" = "Contacts"; +"BusinessMessageSetup.Recipients.CategoryExistingChats" = "Existing Chats"; +"BusinessMessageSetup.Recipients.CategoryNewChats" = "New Chats"; +"BusinessMessageSetup.Recipients.CategoryNonContacts" = "Non-Contacts"; +"BusinessMessageSetup.Recipients.ExcludeSearchTitle" = "Exclude Chats"; +"BusinessMessageSetup.Recipients.ExcludedSectionHeader" = "EXCLUDED CHATS"; +"BusinessMessageSetup.Recipients.GreetingMessageFooter" = "Select chats or entire chat categories for sending greeting messages."; +"BusinessMessageSetup.Recipients.IncludeSearchTitle" = "Include Chats"; +"BusinessMessageSetup.Recipients.IncludedSectionHeader" = "INCLUDED CHATS"; +"BusinessMessageSetup.RecipientsOptionAllExcept" = "All Private Chats Except..."; +"BusinessMessageSetup.RecipientsOptionOnly" = "Only Selected Chats"; +"BusinessMessageSetup.RecipientsSectionHeader" = "RECIPIENTS"; +"BusinessMessageSetup.ScheduleAlways" = "Always Send"; +"BusinessMessageSetup.ScheduleCustom" = "Custom Schedule"; +"BusinessMessageSetup.ScheduleEndTime" = "End Time"; +"BusinessMessageSetup.ScheduleOutsideBusinessHours" = "Outside of Business Hours"; +"BusinessMessageSetup.ScheduleSectionHeader" = "SCHEDULE"; +"BusinessMessageSetup.ScheduleStartTime" = "Start Time"; +"BusinessMessageSetup.ScheduleTimePlaceholder" = "Set"; +"BusinessMessageSetup.SendWhenOffline" = "Only if Offline"; +"BusinessMessageSetup.SendWhenOfflineFooter" = "Don't send the away message if you've recently been online."; +"BusinessMessageSetup.TextAwayMessage" = "Automatically reply with a message when you are away."; +"BusinessMessageSetup.TextGreetingMessage" = "Greet customers when they message you the first time or after a period of no activity."; +"BusinessMessageSetup.TitleAwayMessage" = "Away Message"; +"BusinessMessageSetup.TitleGreetingMessage" = "Greeting Message"; +"BusinessMessageSetup.ToggleAwayMessage" = "Send Away Message"; +"BusinessMessageSetup.ToggleGreetingMessage" = "Send Greeting Message"; +"CHANNEL_MESSAGES" = "%1$@ posted %2$@ messages"; +"CHANNEL_MESSAGES_SEPARATED" = "%1$@|posted %2$@ messages"; +"CHANNEL_MESSAGE_AUDIO" = "%1$@ posted a voice message"; +"CHANNEL_MESSAGE_AUDIO_SEPARATED" = "%1$@|posted a voice message"; +"CHANNEL_MESSAGE_CONTACT" = "%1$@ posted a contact"; +"CHANNEL_MESSAGE_CONTACT_SEPARATED" = "%1$@|posted a contact"; +"CHANNEL_MESSAGE_DOC" = "%1$@ posted a file"; +"CHANNEL_MESSAGE_DOC_SEPARATED" = "%1$@|posted a file"; +"CHANNEL_MESSAGE_FWDS_SEPARATED" = "%1$@|posted %2$@ forwarded messages"; +"CHANNEL_MESSAGE_GAME" = "%1$@ invited you to play %2$@"; +"CHANNEL_MESSAGE_GAME_SEPARATED" = "%1$@|invited you to play %2$@"; +"CHANNEL_MESSAGE_GEO" = "%1$@ posted a map"; +"CHANNEL_MESSAGE_GEOLIVE" = "%1$@ posted a live location"; +"CHANNEL_MESSAGE_GEOLIVE_SEPARATED" = "%1$@|posted a live location"; +"CHANNEL_MESSAGE_GEO_SEPARATED" = "%1$@|posted a map"; +"CHANNEL_MESSAGE_GIF" = "%1$@ posted a GIF"; +"CHANNEL_MESSAGE_GIF_SEPARATED" = "%1$@|posted a GIF"; +"CHANNEL_MESSAGE_GIVEAWAY" = "%1$@ posted a giveaway of %2$@x %3$@mo Premium subscriptions"; +"CHANNEL_MESSAGE_NOTEXT" = "%1$@ posted a message"; +"CHANNEL_MESSAGE_NOTEXT_SEPARATED" = "%1$@|posted a message"; +"CHANNEL_MESSAGE_PHOTO" = "%1$@ posted a photo"; +"CHANNEL_MESSAGE_PHOTOS" = "%1$@ posted %2$@ photos"; +"CHANNEL_MESSAGE_PHOTOS_SEPARATED" = "%1$@|posted %2$@ photos"; +"CHANNEL_MESSAGE_PHOTO_SEPARATED" = "%1$@|posted a photo"; +"CHANNEL_MESSAGE_POLL" = "%1$@ posted a poll"; +"CHANNEL_MESSAGE_POLL_SEPARATED" = "%1$@|posted a poll"; +"CHANNEL_MESSAGE_ROUND" = "%1$@ posted a video message"; +"CHANNEL_MESSAGE_ROUND_SEPARATED" = "%1$@|posted a video message"; +"CHANNEL_MESSAGE_STICKER" = "%1$@ posted a %2$@sticker"; +"CHANNEL_MESSAGE_STICKER_SEPARATED" = "%1$@|posted a %2$@ sticker"; +"CHANNEL_MESSAGE_TEXT" = "%1$@: %2$@"; +"CHANNEL_MESSAGE_TEXT_SEPARATED" = "%1$@|%2$@"; +"CHANNEL_MESSAGE_VIDEO" = "%1$@ posted a video"; +"CHANNEL_MESSAGE_VIDEO_SEPARATED" = "%1$@|posted a video"; +"CHAT_ADD_MEMBER" = "%1$@ added %3$@ to the group %2$@"; +"CHAT_ADD_MEMBER_SEPARATED" = "%2$@|%1$@ invited %3$@ to the group"; +"CHAT_ADD_YOU" = "%1$@ invited you to the group %2$@"; +"CHAT_ADD_YOU_SEPARATED" = "%2$@|%1$@ invited you to the group"; +"CHAT_CREATED" = "%1$@ invited you to the group %2$@"; +"CHAT_CREATED_SEPARATED" = "%2$@|%1$@ invited you to the group"; +"CHAT_DELETE_MEMBER" = "%1$@ removed %3$@ from the group %2$@"; +"CHAT_DELETE_MEMBER_SEPARATED" = "%2$@|%1$@ kicked %3$@ from the group"; +"CHAT_DELETE_YOU" = "%1$@ removed you from the group %2$@"; +"CHAT_DELETE_YOU_SEPARATED" = "%2$@|%1$@ kicked you from the group "; +"CHAT_JOINED_SEPARATED" = "%2$@|%1$@ has joined the group"; +"CHAT_LEFT" = "%1$@ left the group %2$@"; +"CHAT_LEFT_SEPARATED" = "%2$@|%1$@ left the group"; +"CHAT_MESSAGES" = "%1$@ sent %3$@ messages to the group %2$@"; +"CHAT_MESSAGES_SEPARATED" = "%2$@|%1$@ sent %3$@ messages"; +"CHAT_MESSAGE_AUDIO" = "%1$@ sent a voice message to %2$@"; +"CHAT_MESSAGE_AUDIO_SEPARATED" = "%2$@|%1$@ sent a voice message"; +"CHAT_MESSAGE_CONTACT" = "%1$@ shared a contact in the group %2$@"; +"CHAT_MESSAGE_CONTACT_SEPARATED" = "%2$@|%1$@ shared a contact"; +"CHAT_MESSAGE_DOC" = "%1$@ sent a file to the group %2$@"; +"CHAT_MESSAGE_DOC_SEPARATED" = "%2$@|%1$@ sent a file"; +"CHAT_MESSAGE_FWDS" = "%1$@ forwarded %3$@ messages to the group %2$@"; +"CHAT_MESSAGE_FWDS_SEPARATED" = "%2$@|%1$@ forwarded %3$@ messages"; +"CHAT_MESSAGE_GAME" = "%1$@ invited the group %2$@ to play %3$@"; +"CHAT_MESSAGE_GAME_SEPARATED" = "%2$@|%1$@ invited the group to play %3$@"; +"CHAT_MESSAGE_GEO" = "%1$@ sent a map to the group %2$@"; +"CHAT_MESSAGE_GEOLIVE" = "%1$@ started sharing their live location with %2$@"; +"CHAT_MESSAGE_GEOLIVE_SEPARATED" = "%2$@|%1$@ started sharing their live location"; +"CHAT_MESSAGE_GEO_SEPARATED" = "%2$@|%1$@ sent a map"; +"CHAT_MESSAGE_GIF" = "%1$@ sent a GIF to the group %2$@"; +"CHAT_MESSAGE_GIF_SEPARATED" = "%2$@|%1$@ sent a GIF"; +"CHAT_MESSAGE_GIVEAWAY" = "%1$@ sent a giveaway of %3$@x %4$@mo Premium subscriptions to the group %2$@"; +"CHAT_MESSAGE_INVOICE" = "%1$@ sent an invoice for %3$@ to the group %2$@"; +"CHAT_MESSAGE_INVOICE_SEPARATED" = "%2$@|%1$@ sent an invoice for %3$@"; +"CHAT_MESSAGE_NOTEXT" = "%1$@ sent a message to the group %2$@"; +"CHAT_MESSAGE_NOTEXT_SEPARATED" = "%2$@|%1$@ sent a message"; +"CHAT_MESSAGE_NOTHEME" = "%1$@ set the default theme in the group %2$@"; +"CHAT_MESSAGE_PHOTO" = "%1$@ sent a photo to the group %2$@"; +"CHAT_MESSAGE_PHOTOS" = "%1$@ sent %3$@ photos to the group %2$@"; +"CHAT_MESSAGE_PHOTOS_SEPARATED" = "%2$@|%1$@ sent %3$@ photos"; +"CHAT_MESSAGE_PHOTO_SEPARATED" = "%2$@|%1$@ sent a photo"; +"CHAT_MESSAGE_POLL" = "%1$@ sent a poll to %2$@"; +"CHAT_MESSAGE_POLL_SEPARATED" = "%2$@|%1$@ sent a poll"; +"CHAT_MESSAGE_RECURRING_PAY" = "%1$@|You were charged %2$@"; +"CHAT_MESSAGE_ROUND" = "%1$@ sent a video message to the group %2$@"; +"CHAT_MESSAGE_ROUND_SEPARATED" = "%2$@|%1$@ sent a video message"; +"CHAT_MESSAGE_STICKER" = "%1$@ sent a %3$@sticker to the group %2$@"; +"CHAT_MESSAGE_STICKER_SEPARATED" = "%2$@|%1$@ sent a %3$@ sticker"; +"CHAT_MESSAGE_TEXT" = "%1$@@%2$@: %3$@"; +"CHAT_MESSAGE_TEXT_SEPARATED" = "%2$@|%1$@:%3$@"; +"CHAT_MESSAGE_VIDEO" = "%1$@ sent a video to the group %2$@"; +"CHAT_MESSAGE_VIDEO_SEPARATED" = "%2$@|%1$@ sent a video "; +"CHAT_PHOTO_EDITED" = "%1$@ edited the photo of the group %2$@ "; +"CHAT_REACT_GIVEAWAY" = "%1$@ reacted %3$@ in group %2$@ to your giveaway"; +"CHAT_RETURNED" = "%1$@ returned to the group %2$@"; +"CHAT_RETURNED_SEPARATED" = "%2$@|%1$@ returned to the group"; +"CHAT_TITLE_EDITED" = "%1$@ edited the name of the group %2$@"; +"CHAT_VOICECHAT_INVITE" = "%1$@ invited %3$@ to a video chat"; +"CHAT_VOICECHAT_INVITE_YOU" = "%1$@ invited you to a video chat"; +"CHAT_VOICECHAT_START" = "%1$@ started a video chat"; +"CONTACT_JOINED" = "%1$@ joined Telegram"; +"CONTACT_JOINED_SEPARATED" = "%1$@|joined Telegram!"; +"Cache.ByPeerHeader" = "CHATS"; +"Cache.Clear" = "Clear (%@)"; +"Cache.ClearCache" = "Clear Cache"; +"Cache.ClearEmpty" = "Empty"; +"Cache.ClearNone" = "Clear"; +"Cache.ClearProgress" = "Please Wait..."; +"Cache.Files" = "Files"; +"Cache.Help" = "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space.\n\nAll media will stay in the Telegram cloud and can be re-downloaded if you need it again."; +"Cache.Indexing" = "Telegram is calculating current cache size.\nThis can take a few minutes."; +"Cache.KeepMedia" = "Keep Media"; +"Cache.KeepMediaHelp" = "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space."; +"Cache.LowDiskSpaceText" = "Your phone has run out of available storage. Please free some space to download or upload media."; +"Cache.MaximumCacheSize" = "Maximum Cache Size"; +"Cache.MaximumCacheSizeHelp" = "If your cache size exceeds this limit, the oldest media will be removed from the device.\n\nAll media will stay in the Telegram cloud and can be re-downloaded if you need it again."; +"Cache.Music" = "Music"; +"Cache.NoLimit" = "No Limit"; +"Cache.Photos" = "Photos"; +"Cache.ServiceFiles" = "Service Files"; +"Cache.Title" = "Storage Usage"; +"Cache.Videos" = "Videos"; +"CacheEvictionMenu.CategoryExceptions#one" = "%@ exception"; +"CacheEvictionMenu.CategoryExceptions#other" = "%@ exceptions"; +"Calendar.ShortFri" = "F"; +"Calendar.ShortFriday" = "F"; +"Calendar.ShortMon" = "M"; +"Calendar.ShortMonday" = "M"; +"Calendar.ShortSat" = "S"; +"Calendar.ShortSaturday" = "S"; +"Calendar.ShortSun" = "S"; +"Calendar.ShortSunday" = "S"; +"Calendar.ShortThu" = "T"; +"Calendar.ShortThursday" = "T"; +"Calendar.ShortTue" = "T"; +"Calendar.ShortTuesday" = "T"; +"Calendar.ShortWed" = "W"; +"Calendar.ShortWednesday" = "W"; +"Call.Accept" = "Accept"; +"Call.AddMemberTitle" = "Add Member"; +"Call.AlertMoveToConference" = "End current call and start a group call?"; +"Call.AnsweringWithAccount" = "Answering as %@"; +"Call.Audio" = "audio"; +"Call.AudioRouteHeadphones" = "Headphones"; +"Call.AudioRouteHide" = "Hide"; +"Call.AudioRouteMute" = "Mute Yourself"; +"Call.AudioRouteSpeaker" = "Speaker"; +"Call.BatteryLow" = "%@'s battery level is low"; +"Call.CallAgain" = "Call Again"; +"Call.CallInProgressLiveStreamMessage" = "End call with %1$@ and start a live stream in %2$@?"; +"Call.CallInProgressMessage" = "End call with %1$@ and start a new one with %2$@?"; +"Call.CallInProgressTitle" = "Call in Progress"; +"Call.CallInProgressVoiceChatMessage" = "End call with %1$@ and start a video chat in %2$@?"; +"Call.Camera" = "camera"; +"Call.CameraConfirmationConfirm" = "Switch"; +"Call.CameraConfirmationText" = "Switch to video call?"; +"Call.CameraOff" = "%@'s camera is off"; +"Call.CameraOrScreenTooltip" = "Turn on your camera or share your screen"; +"Call.CameraTooltip" = "Tap here to turn on your camera"; +"Call.ConnectionErrorMessage" = "Please check your Internet connection and try again."; +"Call.ConnectionErrorTitle" = "Unable to Call"; +"Call.Days#one" = "%@ day"; +"Call.Days#other" = "%@ days"; +"Call.Decline" = "Decline"; +"Call.EmojiDescription" = "If the emoji on %@'s screen are the same, this call is 100%% secure."; +"Call.EncryptedAlertText" = "If the emoji on %@'s screen are the same, this call is 100% secure."; +"Call.EncryptedAlertTitle" = "This call is end-to-end encrypted"; +"Call.EncryptionKey.Title" = "Encryption Key"; +"Call.EncryptionKeyTooltip" = "Encryption key of this call"; +"Call.End" = "end"; +"Call.ExternalCallInProgressMessage" = "Please finish the current call first."; +"Call.Flip" = "flip"; +"Call.GenericGroupCallTitle" = "Group Call"; +"Call.GroupFormat" = "%1$@ (%2$@)"; +"Call.Hours#one" = "%@ hour"; +"Call.Hours#other" = "%@ hours"; +"Call.IncomingGroupCallTitle.Multiple#one" = "{} and 1 other"; +"Call.IncomingGroupCallTitle.Multiple#other" = "{} and %d others"; +"Call.IncomingGroupCallTitle.Single" = "%@"; +"Call.IncomingVideoCall" = "Incoming Video Call"; +"Call.IncomingVoiceCall" = "Incoming Voice Call"; +"Call.LiveStreamInProgressCallMessage" = "Leave live stream in %1$@ and start a call with %2$@?"; +"Call.LiveStreamInProgressConferenceMessage" = "Leave live stream in %1$@ and start a group call?"; +"Call.LiveStreamInProgressMessage" = "Leave live stream in %1$@ and start a new one in %2$@?"; +"Call.LiveStreamInProgressTitle" = "Live Stream in Progress"; +"Call.Message" = "Message"; +"Call.MicrophoneOff" = "%@'s microphone is off"; +"Call.Minutes#one" = "%@ minute"; +"Call.Minutes#other" = "%@ minutes"; +"Call.Mute" = "mute"; +"Call.ParticipantVersionOutdatedError" = "%@'s app does not support calls. They need to update their app before you can call them."; +"Call.ParticipantVideoVersionOutdatedError" = "%@'s app does not support video calls. They need to update their app before you can call them."; +"Call.PhoneCallInProgressMessage" = "You can't place a Telegram call if you're already on a phone call."; +"Call.PrivacyErrorMessage" = "Sorry, you cannot call %@ because of their privacy settings. You can ask them to modify their setting or to call you instead."; +"Call.RateCall" = "Rate This Call"; +"Call.RecordingDisabledMessage" = "Please end your call before recording a voice message."; +"Call.RemoteVideoPaused" = "%@'s video is paused"; +"Call.ReportIncludeLog" = "Send technical information"; +"Call.ReportIncludeLogDescription" = "Doesn't reveal chat contents and helps us fix the issue sooner."; +"Call.ReportPlaceholder" = "What went wrong?"; +"Call.ReportSend" = "Send"; +"Call.ReportSkip" = "Skip"; +"Call.Seconds#one" = "%@ second"; +"Call.Seconds#other" = "%@ seconds"; +"Call.ShareLink" = "Share Call Link"; +"Call.ShareStats" = "Share Statistics"; +"Call.ShortMinutes#one" = "%@ min"; +"Call.ShortMinutes#other" = "%@ min"; +"Call.ShortSeconds#one" = "%@ sec"; +"Call.ShortSeconds#other" = "%@ sec"; +"Call.Speaker" = "speaker"; +"Call.StatusBar" = "Touch to return to call %@"; +"Call.StatusBusy" = "Busy"; +"Call.StatusConnecting" = "Connecting..."; +"Call.StatusDeclined" = "Call Declined"; +"Call.StatusEnded" = "Call Ended"; +"Call.StatusFailed" = "Call Failed"; +"Call.StatusIncoming" = "Telegram Audio..."; +"Call.StatusMissed" = "Call Missed"; +"Call.StatusNoAnswer" = "No Answer"; +"Call.StatusOngoing" = "Telegram Audio %@"; +"Call.StatusRequesting" = "Contacting..."; +"Call.StatusRinging" = "Ringing..."; +"Call.StatusWaiting" = "Waiting..."; +"Call.StatusWeakSignal" = "Weak network signal"; +"Call.Video" = "video"; +"Call.VoiceChatInProgressCallMessage" = "Leave video chat in %1$@ and call %2$@?"; +"Call.VoiceChatInProgressConferenceMessage" = "Leave video chat in %1$@ and start a group call?"; +"Call.VoiceChatInProgressMessage" = "Leave the video chat in %1$@ and start a new one in %2$@?"; +"Call.VoiceChatInProgressMessageCall" = "Leave video chat in %1$@ and call %2$@?"; +"Call.VoiceChatInProgressTitle" = "Video Chat in Progress"; +"Call.VoiceOver.Minimize" = "Minimize Call"; +"Call.VoiceOver.VideoCallCanceled" = "Canceled Video Call"; +"Call.VoiceOver.VideoCallIncoming" = "Incoming Video Call"; +"Call.VoiceOver.VideoCallMissed" = "Missed Video Call"; +"Call.VoiceOver.VideoCallOutgoing" = "Outgoing Video Call"; +"Call.VoiceOver.VoiceCallCanceled" = "Canceled Voice Call"; +"Call.VoiceOver.VoiceCallIncoming" = "Incoming Voice Call"; +"Call.VoiceOver.VoiceCallMissed" = "Missed Voice Call"; +"Call.VoiceOver.VoiceCallOutgoing" = "Outgoing Voice Call"; +"Call.WaitingStatusConnecting" = "Connecting"; +"Call.WaitingStatusReconnecting" = "Reconnecting"; +"Call.WaitingStatusRequesting" = "Requesting"; +"Call.WaitingStatusRinging" = "Ringing"; +"Call.YourCameraOff" = "Your camera is off"; +"Call.YourMicrophoneOff" = "Your microphone is off"; +"CallFeedback.AddComment" = "Add an optional comment"; +"CallFeedback.IncludeLogs" = "Include technical information"; +"CallFeedback.IncludeLogsInfo" = "Doesn't reveal chat contents and helps us fix the issue sooner."; +"CallFeedback.ReasonDistortedSpeech" = "Speech was distorted"; +"CallFeedback.ReasonDropped" = "Call ended unexpectedly"; +"CallFeedback.ReasonEcho" = "I heard my own voice"; +"CallFeedback.ReasonInterruption" = "The other side kept disappearing"; +"CallFeedback.ReasonNoise" = "I heard background noise"; +"CallFeedback.ReasonSilentLocal" = "I couldn't hear the other side"; +"CallFeedback.ReasonSilentRemote" = "The other side couldn't hear me"; +"CallFeedback.Send" = "Send"; +"CallFeedback.Success" = "Thanks for\nyour feedback"; +"CallFeedback.Title" = "Call Feedback"; +"CallFeedback.VideoReasonDistorted" = "Video was distorted"; +"CallFeedback.VideoReasonLowQuality" = "Video was pixelated"; +"CallFeedback.WhatWentWrong" = "WHAT WENT WRONG?"; +"CallList.ActiveVoiceChatsHeader" = "ACTIVE VIDEO CHATS"; +"CallList.DeleteAll" = "Delete All"; +"CallList.DeleteAllForEveryone" = "Delete for me and Others"; +"CallList.DeleteAllForMe" = "Delete for me"; +"CallList.DeleteConfirmation" = "Do you want to delete the information about this call?"; +"CallList.NewCall" = "Start New Call"; +"CallList.NewCallLink" = "Create Call Link"; +"CallList.RecentCallsHeader" = "RECENT CALLS"; +"CallList.ToastCallLinkCopied.Action" = "View Call"; +"CallList.ToastCallLinkCopied.Text" = "Call link copied"; +"CallSettings.Always" = "Always"; +"CallSettings.Never" = "Never"; +"CallSettings.OnMobile" = "On Mobile Network"; +"CallSettings.RecentCalls" = "Recent Calls"; +"CallSettings.TabIcon" = "Show Calls Tab"; +"CallSettings.TabIconDescription" = "A call icon will appear in the tab bar."; +"CallSettings.Title" = "Calls"; +"CallSettings.UseLessData" = "Use Less Data"; +"CallSettings.UseLessDataLongDescription" = "Using less data may improve your experience on bad networks, but will slightly decrease audio and video quality."; +"Calls.AddTab" = "Add Tab"; +"Calls.All" = "All"; +"Calls.CallTabDescription" = "You can add a Calls tab to the tab bar."; +"Calls.CallTabTitle" = "Calls Tab"; +"Calls.HideCallsTab" = "Hide Calls Tab"; +"Calls.Missed" = "Missed"; +"Calls.NewCall" = "New Call"; +"Calls.NoCallsPlaceholder" = "Your recent calls will appear here"; +"Calls.NoMissedCallsPlacehoder" = "You have no missed calls"; +"Calls.NoVoiceAndVideoCallsPlaceholder" = "Your recent voice and video calls will appear here."; +"Calls.NotNow" = "Not Now"; +"Calls.RatingFeedback" = "Write a comment..."; +"Calls.RatingTitle" = "Please rate the quality\nof your Telegram call"; +"Calls.StartNewCall" = "Start New Call"; +"Calls.SubmitRating" = "Submit"; +"Calls.TabTitle" = "Calls"; +"Camera.CollageDelete" = "Delete"; +"Camera.CollageManagementTooltip" = "Tap a tile to delete or reorder it."; +"Camera.CollageReorderingInfo" = "Hold and drag tiles to reorder them."; +"Camera.CollageRetake" = "Retake"; +"Camera.Discard" = "Discard All"; +"Camera.FlashAuto" = "Auto"; +"Camera.FlashOff" = "Off"; +"Camera.FlashOn" = "On"; +"Camera.LiveStream.Change" = "change"; +"Camera.LiveStream.End" = "End"; +"Camera.LiveStream.End.End" = "End"; +"Camera.LiveStream.End.Leave" = "Leave"; +"Camera.LiveStream.End.Text" = "Are you sure you want to end this live stream?"; +"Camera.LiveStream.End.Title" = "End Live Stream"; +"Camera.LiveStream.StartLiveStream" = "Start Live Stream"; +"Camera.OpenChat" = "Open Chat"; +"Camera.PhotoMode" = "PHOTO"; +"Camera.Retake" = "Retake"; +"Camera.SquareMode" = "SQUARE"; +"Camera.TapAndHoldForVideo" = "Press and hold for video"; +"Camera.Title" = "Take Photo or Video"; +"Camera.VideoMode" = "VIDEO"; +"CancelResetAccount.Success" = "The deletion process was canceled for your account %@."; +"CancelResetAccount.TextSMS" = "Somebody with access to your phone number %@ has requested to delete your Telegram account and reset your 2-Step Verification password.\n\nIf it wasn't you, please enter the code we've just sent you via SMS to your number. You can also cancel this by **changing your phone number** to a number you control."; +"CancelResetAccount.Title" = "Cancel Account Reset"; +"ChangePhone.ErrorOccupied" = "The number %@ is already connected to a Telegram account. Please delete that account before using this number."; +"ChangePhoneNumberCode.CallTimer" = "Telegram will call you in %@"; +"ChangePhoneNumberCode.Called" = "Telegram dialed your number"; +"ChangePhoneNumberCode.Code" = "YOUR CODE"; +"ChangePhoneNumberCode.CodePlaceholder" = "Code"; +"ChangePhoneNumberCode.Help" = "We have sent you an SMS with the code"; +"ChangePhoneNumberCode.RequestingACall" = "Requesting a call from Telegram..."; +"ChangePhoneNumberNumber.Help" = "Your new number will receive a confirmation code via call or SMS."; +"ChangePhoneNumberNumber.NewNumber" = "NEW NUMBER"; +"ChangePhoneNumberNumber.NumberPlaceholder" = "Enter your new number"; +"ChangePhoneNumberNumber.Title" = "Change Number"; +"Channel.About.Help" = "You can provide an optional description for your channel."; +"Channel.About.Placeholder" = "Description"; +"Channel.About.Title" = "Description"; +"Channel.AboutItem" = "about"; +"Channel.AddAdminKickedError" = "Sorry, you can't add this user as an admin because they are in the Removed Users list and you can't unban them."; +"Channel.AddBotAsAdmin" = "Make Admin"; +"Channel.AddBotErrorHaveRights" = "Bots can only be added to channels as admins."; +"Channel.AddBotErrorNoRights" = "Bots can only be added to channels as admins."; +"Channel.AddUserKickedError" = "Sorry, you can't add this user because they are on the list of Removed Users and you can't unban them."; +"Channel.AddUserLeftError" = "If a person has left a channel in the past, you need to be in their Telegram contacts to add them back.\n\nThey can still join via an invite link as long as they are not on the Removed Users list."; +"Channel.AdminLog.AddMembers" = "Add Members"; +"Channel.AdminLog.AllowedNewMembersToSpeak" = "%1$@ allowed new video chat participants to speak"; +"Channel.AdminLog.AllowedReactionsUpdated" = "%1$@ updated the list of allowed reactions to: %2$@"; +"Channel.AdminLog.AntiSpamDisabled" = "%1$@ disabled anti-spam"; +"Channel.AdminLog.AntiSpamEnabled" = "%1$@ enabled anti-spam"; +"Channel.AdminLog.BanEmbedLinks" = "Embed Links"; +"Channel.AdminLog.BanReadMessages" = "Read Messages"; +"Channel.AdminLog.BanSendGifs" = "Send GIFs"; +"Channel.AdminLog.BanSendMedia" = "Send Media"; +"Channel.AdminLog.BanSendMessages" = "Send Messages"; +"Channel.AdminLog.BanSendStickers" = "Send Stickers"; +"Channel.AdminLog.BanSendStickersAndGifs" = "Send Stickers & GIFs"; +"Channel.AdminLog.CanAddAdmins" = "Add New Admins"; +"Channel.AdminLog.CanBanUsers" = "Ban Users"; +"Channel.AdminLog.CanBeAnonymous" = "Remain Anonymous"; +"Channel.AdminLog.CanChangeInfo" = "Change Info"; +"Channel.AdminLog.CanChangeInviteLink" = "Invite Users Via Link"; +"Channel.AdminLog.CanDeleteMessages" = "Delete Messages"; +"Channel.AdminLog.CanDeleteMessagesOfOthers" = "Delete Messages of Others"; +"Channel.AdminLog.CanDeleteStoriesOfOthers" = "Delete Stories of Others"; +"Channel.AdminLog.CanEditMessages" = "Edit Messages of Others"; +"Channel.AdminLog.CanEditMessagesOfOthers" = "Edit Messages of Others"; +"Channel.AdminLog.CanEditStoriesOfOthers" = "Edit Stories of Others"; +"Channel.AdminLog.CanInviteUsers" = "Add Users"; +"Channel.AdminLog.CanInviteUsersViaLink" = "Invite Users via Link"; +"Channel.AdminLog.CanManageCalls" = "Manage Video Chats"; +"Channel.AdminLog.CanManageDirect" = "Manage Direct Messages"; +"Channel.AdminLog.CanManageLiveStreams" = "Manage Live Streams"; +"Channel.AdminLog.CanManageTopics" = "Manage Topics"; +"Channel.AdminLog.CanPinMessages" = "Pin Messages"; +"Channel.AdminLog.CanPostStories" = "Post Stories"; +"Channel.AdminLog.CanSendMessages" = "Post Messages"; +"Channel.AdminLog.CaptionEdited" = "%@ edited caption:"; +"Channel.AdminLog.ChangeInfo" = "Change Info"; +"Channel.AdminLog.ChannelChangedNameColor" = "%1$@ changed the channel name color to %2$@"; +"Channel.AdminLog.ChannelChangedNameColorAndIcon" = "%1$@ changed the name color and link logo to %2$@ %3$@"; +"Channel.AdminLog.ChannelChangedNameIcon" = "%1$@ changed the channel link logo to %2$@"; +"Channel.AdminLog.ChannelChangedProfileColor" = "%1$@ changed the channel profile color to %2$@"; +"Channel.AdminLog.ChannelChangedProfileColorAndIcon" = "%1$@ changed the channel profile color and logo to %2$@ %3$@"; +"Channel.AdminLog.ChannelChangedProfileIcon" = "%1$@ changed the profile logo to %2$@"; +"Channel.AdminLog.ChannelChangedWallpaper" = "%1$@ set a new wallpaper"; +"Channel.AdminLog.ChannelEmptyText" = "No service actions were taken by the channel's admins in the last 48 hours."; +"Channel.AdminLog.ChannelRemovedProfileColor" = "%1$@ reset the profile color"; +"Channel.AdminLog.ChannelRemovedProfileColorAndIcon" = "%1$@ reset the profile color and icon"; +"Channel.AdminLog.ChannelRemovedProfileIcon" = "%1$@ removed the profile icon"; +"Channel.AdminLog.ChannelRemovedStatus" = "%1$@ removed the channel status"; +"Channel.AdminLog.ChannelRemovedWallpaper" = "%1$@ removed the wallpaper"; +"Channel.AdminLog.ChannelUpdatedStatus" = "%1$@ changed the channel status to %2$@"; +"Channel.AdminLog.CreatedInviteLink" = "%1$@ created invite link %2$@"; +"Channel.AdminLog.DefaultRestrictionsUpdated" = "changed default permissions"; +"Channel.AdminLog.DeletedInviteLink" = "%1$@ deleted the invite link %2$@"; +"Channel.AdminLog.DisabledSlowmode" = "%@ disabled the slow mode"; +"Channel.AdminLog.EditRank" = "Edit Member Tags"; +"Channel.AdminLog.EditRankOwn" = "Edit Member Tag"; +"Channel.AdminLog.EditedInviteLink" = "%1$@ edited invite link %2$@"; +"Channel.AdminLog.EmptyFilterQueryText" = "No recent actions that contain '%@' have been found."; +"Channel.AdminLog.EmptyFilterText" = "No recent actions that match your query were found."; +"Channel.AdminLog.EmptyFilterTitle" = "No actions found"; +"Channel.AdminLog.EmptyMessageText" = "Empty"; +"Channel.AdminLog.EmptyText" = "No notable actions taken by the members and admins of this channel in the last 48 hours."; +"Channel.AdminLog.EmptyTitle" = "No recent actions"; +"Channel.AdminLog.EndedLiveStream" = "%1$@ ended the live stream"; +"Channel.AdminLog.EndedVoiceChat" = "%1$@ ended the video chat"; +"Channel.AdminLog.InfoPanelAlertText" = "This is a list of all service actions taken by the channel's admins in the last 48 hours."; +"Channel.AdminLog.InfoPanelAlertTitle" = "What is the event log?"; +"Channel.AdminLog.InfoPanelChannelAlertText" = "This is a list of all service actions taken by the channel's admins in the last 48 hours."; +"Channel.AdminLog.InfoPanelTitle" = "What Is This?"; +"Channel.AdminLog.JoinedViaFolderInviteLink" = "%1$@ joined via folder invite link %2$@"; +"Channel.AdminLog.JoinedViaInviteLink" = "%1$@ joined via invite link %2$@"; +"Channel.AdminLog.JoinedViaPublicRequest" = "%1$@ joined via public request, approved by %2$@"; +"Channel.AdminLog.JoinedViaRequest" = "%1$@ joined via invite link %2$@, approved by %3$@"; +"Channel.AdminLog.ManageTopics" = "Manage Topics"; +"Channel.AdminLog.MessageAddedAdminName" = "promoted %1$@"; +"Channel.AdminLog.MessageAddedAdminNameUsername" = "promoted %1$@ (%2$@)"; +"Channel.AdminLog.MessageAdmin" = "%@ changed privileges for %@ (%@)"; +"Channel.AdminLog.MessageChangedAutoremoveTimeoutRemove" = "%1$@ disabled auto-delete timer"; +"Channel.AdminLog.MessageChangedAutoremoveTimeoutSet" = "%1$@ set auto-delete timer to %2$@"; +"Channel.AdminLog.MessageChangedBackgroundEmojiRemoved" = "%1$@ removed background emoji"; +"Channel.AdminLog.MessageChangedBackgroundEmojiSet" = "%1$@ set background emoji to %2$@"; +"Channel.AdminLog.MessageChangedChannelAbout" = "%@ edited channel description"; +"Channel.AdminLog.MessageChangedChannelUsername" = "%@ changed channel link:"; +"Channel.AdminLog.MessageChangedChannelUsernames" = "%@ changed channel links:"; +"Channel.AdminLog.MessageChangedGroupAbout" = "%@ edited the group description:"; +"Channel.AdminLog.MessageChangedGroupEmojiPack" = "%@ changed group emoji pack"; +"Channel.AdminLog.MessageChangedGroupGeoLocation" = "changed group location to \"%@\""; +"Channel.AdminLog.MessageChangedGroupStickerPack" = "%@ changed group sticker set"; +"Channel.AdminLog.MessageChangedGroupUsername" = "%@ changed group link:"; +"Channel.AdminLog.MessageChangedGroupUsernames" = "%@ changed group links:"; +"Channel.AdminLog.MessageChangedLinkedChannel" = "%1$@ linked this group to %2$@"; +"Channel.AdminLog.MessageChangedLinkedGroup" = "%1$@ made %2$@ the discussion group for this channel"; +"Channel.AdminLog.MessageChangedNameColorSet" = "%1$@ set name color to %2$@"; +"Channel.AdminLog.MessageChangedThemeRemove" = "%1$@ disabled the chat theme"; +"Channel.AdminLog.MessageChangedThemeSet" = "%1$@ changed the chat theme to %2$@"; +"Channel.AdminLog.MessageChangedUnlinkedChannel" = "%1$@ unlinked this group from %2$@"; +"Channel.AdminLog.MessageChangedUnlinkedGroup" = "%1$@ removed the discussion group %2$@"; +"Channel.AdminLog.MessageDeleted" = "%@ deleted message:"; +"Channel.AdminLog.MessageEdited" = "%@ edited message:"; +"Channel.AdminLog.MessageGroupPreHistoryHidden" = "%@ made the group history hidden for new members"; +"Channel.AdminLog.MessageGroupPreHistoryVisible" = "%@ made the group history visible for new members"; +"Channel.AdminLog.MessageInvitedName" = "invited %1$@"; +"Channel.AdminLog.MessageInvitedNameUsername" = "invited %1$@ (%2$@)"; +"Channel.AdminLog.MessageKickedName" = "banned %1$@"; +"Channel.AdminLog.MessageKickedNameUsername" = "banned %1$@ (%2$@)"; +"Channel.AdminLog.MessageManyDeleted" = "%1$@ deleted %2$@ from %3$@"; +"Channel.AdminLog.MessageManyDeleted.HideAll" = "hide all"; +"Channel.AdminLog.MessageManyDeleted.Messages#one" = "%@ message"; +"Channel.AdminLog.MessageManyDeleted.Messages#other" = "%@ messages"; +"Channel.AdminLog.MessageManyDeleted.ShowAll" = "show all"; +"Channel.AdminLog.MessageManyDeletedMore" = "%1$@ deleted %2$@ from %3$@ %4$@"; +"Channel.AdminLog.MessageParticipantSubscriptionExtended" = "%@ extended their subscription"; +"Channel.AdminLog.MessagePinned" = "%@ pinned message:"; +"Channel.AdminLog.MessagePreviousCaption" = "Original caption"; +"Channel.AdminLog.MessagePreviousDescription" = "Previous description"; +"Channel.AdminLog.MessagePreviousLink" = "Previous link"; +"Channel.AdminLog.MessagePreviousLinks" = "Previous links"; +"Channel.AdminLog.MessagePreviousMessage" = "Original message"; +"Channel.AdminLog.MessagePromotedName" = "changed privileges for %1$@"; +"Channel.AdminLog.MessagePromotedNameUsername" = "changed privileges for %1$@ (%2$@)"; +"Channel.AdminLog.MessageRank" = "changed custom title:\n%1$@"; +"Channel.AdminLog.MessageRankName" = "changed custom title for %1$@:\n%2$@"; +"Channel.AdminLog.MessageRankNameNew" = "changed member tag for %1$@:\n%2$@"; +"Channel.AdminLog.MessageRankNew" = "changed member tag:\n%1$@"; +"Channel.AdminLog.MessageRankUsername" = "changed custom title for %1$@ (%2$@):\n%3$@"; +"Channel.AdminLog.MessageRankUsernameNew" = "changed member tag for %1$@ (%2$@):\n%3$@"; +"Channel.AdminLog.MessageRemovedAdminName" = "demoted %1$@"; +"Channel.AdminLog.MessageRemovedAdminNameUsername" = "demoted %1$@ (%2$@)"; +"Channel.AdminLog.MessageRemovedChannelUsername" = "%@ removed channel link"; +"Channel.AdminLog.MessageRemovedGroupEmojiPack" = "%@ removed group emoji pack"; +"Channel.AdminLog.MessageRemovedGroupStickerPack" = "%@ removed group sticker set"; +"Channel.AdminLog.MessageRemovedGroupUsername" = "%@ removed group link"; +"Channel.AdminLog.MessageRestricted" = "%@ changed restrictions for %@ (%@)"; +"Channel.AdminLog.MessageRestrictedForever" = "indefinitely"; +"Channel.AdminLog.MessageRestrictedName" = "changed restrictions for %1$@"; +"Channel.AdminLog.MessageRestrictedNameUsername" = "changed restrictions for %1$@ (%2$@)"; +"Channel.AdminLog.MessageRestrictedNewSetting" = "now: %@"; +"Channel.AdminLog.MessageRestrictedUntil" = "until %@"; +"Channel.AdminLog.MessageSent" = "%@ sent message:"; +"Channel.AdminLog.MessageToggleAutoTranslateOff" = "%@ disabled auto-translation"; +"Channel.AdminLog.MessageToggleAutoTranslateOn" = "%@ enabled auto-translation"; +"Channel.AdminLog.MessageToggleInvitesOff" = "%@ disabled group invites"; +"Channel.AdminLog.MessageToggleInvitesOn" = "%@ enabled group invites"; +"Channel.AdminLog.MessageToggleNoForwardsOff" = "%@ allowed message forwarding"; +"Channel.AdminLog.MessageToggleNoForwardsOn" = "%@ restricted message forwarding"; +"Channel.AdminLog.MessageToggleProfileSignaturesOff" = "%@ disabled admin profiles"; +"Channel.AdminLog.MessageToggleProfileSignaturesOn" = "%@ enabled admin profiles"; +"Channel.AdminLog.MessageToggleSignaturesOff" = "%@ disabled signatures"; +"Channel.AdminLog.MessageToggleSignaturesOn" = "%@ enabled signatures"; +"Channel.AdminLog.MessageTransferedName" = "transferred ownership to %1$@"; +"Channel.AdminLog.MessageTransferedNameUsername" = "transferred ownership to %1$@ (%2$@)"; +"Channel.AdminLog.MessageUnkickedName" = "unbanned %1$@"; +"Channel.AdminLog.MessageUnkickedNameUsername" = "unbanned %1$@ (%2$@)"; +"Channel.AdminLog.MessageUnpinned" = "%@ unpinned message"; +"Channel.AdminLog.MessageUnpinnedExtended" = "%@ unpinned message:"; +"Channel.AdminLog.MutedNewMembers" = "%1$@ muted new video chat participants"; +"Channel.AdminLog.MutedParticipant" = "%1$@ muted %2$@ in a video chat"; +"Channel.AdminLog.PinMessages" = "Pin Messages"; +"Channel.AdminLog.PollStopped" = "%@ stopped poll"; +"Channel.AdminLog.ReactionsDisabled" = "%1$@ disabled reactions"; +"Channel.AdminLog.ReactionsEnabled" = "%1$@ enabled all reactions"; +"Channel.AdminLog.RevokedInviteLink" = "%1$@ revoked invite link %2$@"; +"Channel.AdminLog.SendPolls" = "Send Polls"; +"Channel.AdminLog.SendReactions" = "Send Reactions"; +"Channel.AdminLog.SetSlowmode" = "%1$@ set the slow mode timer to %2$@"; +"Channel.AdminLog.Settings" = "Settings"; +"Channel.AdminLog.ShowMoreMessages#one" = "Show %@ More Message"; +"Channel.AdminLog.ShowMoreMessages#other" = "Show %@ More Messages"; +"Channel.AdminLog.StartedLiveStream" = "%1$@ started a live stream"; +"Channel.AdminLog.StartedVoiceChat" = "%1$@ started a video chat"; +"Channel.AdminLog.TitleAllEvents" = "All Actions"; +"Channel.AdminLog.TitleSelectedEvents" = "Selected Actions"; +"Channel.AdminLog.TopicChangedIcon" = "%1$@ changed topic %2$@ icon to %3$@"; +"Channel.AdminLog.TopicClosed" = "%1$@ closed topic %2$@"; +"Channel.AdminLog.TopicCreated" = "%1$@ created topic %2$@"; +"Channel.AdminLog.TopicDeleted" = "%1$@ deleted topic %2$@"; +"Channel.AdminLog.TopicHidden" = "%1$@ hid topic %2$@"; +"Channel.AdminLog.TopicPinned" = "%1$@ pinned topic %2$@"; +"Channel.AdminLog.TopicRemovedIcon" = "%1$@ removed topic %2$@ icon"; +"Channel.AdminLog.TopicRenamed" = "%1$@ renamed topic %2$@ to %3$@"; +"Channel.AdminLog.TopicRenamedWithIcon" = "%1$@ renamed topic %2$@ to %3$@ and changed icon to %4$@"; +"Channel.AdminLog.TopicRenamedWithRemovedIcon" = "%1$@ renamed topic %2$@ to %3$@ and removed icon"; +"Channel.AdminLog.TopicReopened" = "%1$@ reopened topic %2$@"; +"Channel.AdminLog.TopicUnhidden" = "%1$@ unhid topic %2$@"; +"Channel.AdminLog.TopicUnpinned" = "%1$@ unpinned topic %2$@"; +"Channel.AdminLog.TopicsDisabled" = "%1$@ disabled topics"; +"Channel.AdminLog.TopicsEnabled" = "%1$@ enabled topics"; +"Channel.AdminLog.UnmutedMutedParticipant" = "%1$@ unmuted %2$@ in a video chat"; +"Channel.AdminLog.UpdatedParticipantVolume" = "%1$@ changed %2$@ volume to %3$@"; +"Channel.AdminLogFilter.AdminsAll" = "All Admins"; +"Channel.AdminLogFilter.AdminsTitle" = "ADMINS"; +"Channel.AdminLogFilter.ApplyFilter" = "Apply Filter"; +"Channel.AdminLogFilter.ChannelEventsInfo" = "Channel Info"; +"Channel.AdminLogFilter.EventsAdminRights" = "New Admin Rights"; +"Channel.AdminLogFilter.EventsAdmins" = "Admin Rights"; +"Channel.AdminLogFilter.EventsAll" = "All Actions"; +"Channel.AdminLogFilter.EventsCalls" = "Video Chats"; +"Channel.AdminLogFilter.EventsDeletedMessages" = "Deleted Messages"; +"Channel.AdminLogFilter.EventsEditedMessages" = "Edited Messages"; +"Channel.AdminLogFilter.EventsExceptions" = "New Exceptions"; +"Channel.AdminLogFilter.EventsInfo" = "Group Info"; +"Channel.AdminLogFilter.EventsInviteLinks" = "Invite Links"; +"Channel.AdminLogFilter.EventsLeaving" = "Members Leaving"; +"Channel.AdminLogFilter.EventsLeavingChannel" = "Subscribers Removed"; +"Channel.AdminLogFilter.EventsLeavingGroup" = "Members Left the Group"; +"Channel.AdminLogFilter.EventsLeavingSubscribers" = "Subscribers Removed"; +"Channel.AdminLogFilter.EventsLiveStreams" = "Live Streams"; +"Channel.AdminLogFilter.EventsNewMembers" = "New Members"; +"Channel.AdminLogFilter.EventsNewSubscribers" = "New Subscribers"; +"Channel.AdminLogFilter.EventsPinned" = "Pinned Messages"; +"Channel.AdminLogFilter.EventsRestrictions" = "New Restrictions"; +"Channel.AdminLogFilter.EventsSentMessages" = "Sent Messages"; +"Channel.AdminLogFilter.EventsTitle" = "ACTIONS"; +"Channel.AdminLogFilter.FilterActionsAdminsTitle" = "FILTER ACTIONS BY ADMINS"; +"Channel.AdminLogFilter.FilterActionsTypeTitle" = "FILTER ACTIONS BY TYPE"; +"Channel.AdminLogFilter.RecentActionsTitle" = "Recent Actions"; +"Channel.AdminLogFilter.Section.MembersChannel" = "Subscribers and Admins"; +"Channel.AdminLogFilter.Section.MembersGroup" = "Members and Admins"; +"Channel.AdminLogFilter.Section.Messages" = "Messages"; +"Channel.AdminLogFilter.Section.SettingsChannel" = "Channel Settings"; +"Channel.AdminLogFilter.Section.SettingsGroup" = "Group Settings"; +"Channel.AdminLogFilter.ShowAllAdminsActions" = "Show Actions by All Admins"; +"Channel.AdminLogFilter.Title" = "Filter"; +"Channel.Appearance.ApplyButton" = "Apply Changes"; +"Channel.Appearance.BoostInfo" = "Subscribers of your channel can **boost** it so that it levels up and unlocks these features."; +"Channel.Appearance.BoostLevel" = "Level %@"; +"Channel.Appearance.ExampleLinkText" = "This preview will also be tinted."; +"Channel.Appearance.ExampleLinkTitle" = "Link Preview"; +"Channel.Appearance.ExampleLinkWebsite" = "Telegram"; +"Channel.Appearance.ExampleReplyText" = "Reply to your channel"; +"Channel.Appearance.ExampleText" = "The color you select will be used for the channel's name"; +"Channel.Appearance.NameColorFooter" = "Choose a color for the name of your channel, the links it sends, and replies to its messages."; +"Channel.Appearance.NameIcon" = "Replies Logo"; +"Channel.Appearance.ProfileFooter" = "Choose a color and a logo for the channel's profile."; +"Channel.Appearance.ProfileHeader" = "PROFILE PAGE COLOR"; +"Channel.Appearance.ProfileIcon" = "Profile Logo"; +"Channel.Appearance.ResetProfileColor" = "Reset Profile Color"; +"Channel.Appearance.Status" = "Channel Emoji Status"; +"Channel.Appearance.StatusFooter" = "Choose a status that will be shown next to the channel's name."; +"Channel.Appearance.Title" = "Appearance"; +"Channel.Appearance.ToastAppliedText" = "Appearance settings have been updated."; +"Channel.Appearance.UnsavedChangesAlertApply" = "Apply"; +"Channel.Appearance.UnsavedChangesAlertDiscard" = "Discard"; +"Channel.Appearance.UnsavedChangesAlertText" = "You have changed the channel appearance settings. Apply changes?"; +"Channel.Appearance.UnsavedChangesAlertTitle" = "Unsaved Changes"; +"Channel.Appearance.Wallpaper" = "Channel Wallpaper"; +"Channel.Appearance.WallpaperFooter" = "Set a wallpaper that will be visible to everyone reading your channel."; +"Channel.BanList.BlockedTitle" = "BANNED"; +"Channel.BanList.RestrictedTitle" = "RESTRICTED"; +"Channel.BanUser.BlockFor" = "Block For"; +"Channel.BanUser.PermissionAddMembers" = "Add Members"; +"Channel.BanUser.PermissionChangeGroupInfo" = "Change Group Info"; +"Channel.BanUser.PermissionEditOwnRank" = "Edit Own Tags"; +"Channel.BanUser.PermissionEditRank" = "Edit Member Tag"; +"Channel.BanUser.PermissionEmbedLinks" = "Embed Links"; +"Channel.BanUser.PermissionReadMessages" = "Read Messages"; +"Channel.BanUser.PermissionSendFile" = "Send Files"; +"Channel.BanUser.PermissionSendMedia" = "Send Media"; +"Channel.BanUser.PermissionSendMessages" = "Send Messages"; +"Channel.BanUser.PermissionSendMusic" = "Send Music"; +"Channel.BanUser.PermissionSendPhoto" = "Send Photos"; +"Channel.BanUser.PermissionSendPolls" = "Send Polls"; +"Channel.BanUser.PermissionSendReactions" = "Send Reactions"; +"Channel.BanUser.PermissionSendStickersAndGifs" = "Send Stickers & GIFs"; +"Channel.BanUser.PermissionSendVideo" = "Send Videos"; +"Channel.BanUser.PermissionSendVideoMessage" = "Send Video Messages"; +"Channel.BanUser.PermissionSendVoiceMessage" = "Send Voice Messages"; +"Channel.BanUser.PermissionsHeader" = "User Restrictions"; +"Channel.BanUser.Title" = "Ban User"; +"Channel.BanUser.Unban" = "Unban"; +"Channel.BlackList.Title" = "Banned"; +"Channel.BotDoesntSupportGroups" = "Sorry, this bot is telling us it doesn't want to be added to groups. You can't add this bot unless its developers change their mind."; +"Channel.ChannelColor" = "Channel Color"; +"Channel.ChannelSubscribersHeader" = "CHANNEL SUBSCRIBERS"; +"Channel.CommentsGroup.Header" = "Select a group chat that will host comments from your channel."; +"Channel.CommentsGroup.HeaderGroupSet" = "%@ links the group as its discussion board."; +"Channel.CommentsGroup.HeaderSet" = "%@ is selected as the group that hosts comments for your channel."; +"Channel.DiscussionGroup" = "Discussion"; +"Channel.DiscussionGroup.Create" = "Create New Group"; +"Channel.DiscussionGroup.Header" = "Select a group chat for discussion that will be displayed in your channel."; +"Channel.DiscussionGroup.HeaderGroupSet" = "This group is linked as the discussion board for %@."; +"Channel.DiscussionGroup.HeaderLabel" = "Discuss"; +"Channel.DiscussionGroup.HeaderSet" = "A link to %@ is shown to all subscribers in the bottom panel."; +"Channel.DiscussionGroup.Info" = "Everything you post in the channel will be forwarded to this group."; +"Channel.DiscussionGroup.LinkGroup" = "Link Group"; +"Channel.DiscussionGroup.MakeHistoryPublic" = "Warning: If you set this private group as the discussion group for your channel, all channel subscribers will be able to access the group. \"Chat history for new members\" will be switched to Visible."; +"Channel.DiscussionGroup.MakeHistoryPublicProceed" = "Proceed"; +"Channel.DiscussionGroup.PrivateChannel" = "private channel"; +"Channel.DiscussionGroup.PrivateChannelLink" = "Make %1$@ the discussion board for %2$@?\n\nAny member of this group will be able to see messages in the channel."; +"Channel.DiscussionGroup.PrivateGroup" = "private group"; +"Channel.DiscussionGroup.PublicChannelLink" = "Make %1$@ the discussion group for %2$@?"; +"Channel.DiscussionGroup.SearchPlaceholder" = "Search"; +"Channel.DiscussionGroup.UnlinkChannel" = "Unlink Channel"; +"Channel.DiscussionGroup.UnlinkGroup" = "Unlink Group"; +"Channel.DiscussionGroupAdd" = "Add"; +"Channel.DiscussionGroupInfo" = "Add a group chat for comments."; +"Channel.DiscussionMessageUnavailable" = "Sorry, this post has been removed from the discussion group."; +"Channel.Edit.AboutItem" = "Description"; +"Channel.Edit.LinkItem" = "Link"; +"Channel.Edit.PrivatePublicLinkAlert" = "Please note that if you choose a public link for your channel, anyone will be able to find it in search and join.\n\nDo not create this link if you want your channel to stay private."; +"Channel.EditAdmin.CannotEdit" = "You can't edit the rights of this admin."; +"Channel.EditAdmin.GuardBotEnable" = "Enable"; +"Channel.EditAdmin.GuardBotEnableMembersText" = "New members will now need approval from **%@** before they can send messages in this group"; +"Channel.EditAdmin.GuardBotEnableSubscribersText" = "New subscribers will now need approval from **%@** before they can join this channel"; +"Channel.EditAdmin.GuardBotReplaceKeep" = "Keep %@"; +"Channel.EditAdmin.GuardBotReplaceText" = "**%@** is currently processing join requests. Switch to **%@** instead?"; +"Channel.EditAdmin.GuardBotReplaceTitle" = "Replace Bot"; +"Channel.EditAdmin.GuardBotReplaceUse" = "Use %@"; +"Channel.EditAdmin.PermissinAddAdminOff" = "This admin will not be able to add new admins."; +"Channel.EditAdmin.PermissinAddAdminOn" = "This admin will be able to add new admins with equal or fewer rights."; +"Channel.EditAdmin.PermissionAddAdmins" = "Add New Admins"; +"Channel.EditAdmin.PermissionBanUsers" = "Ban Users"; +"Channel.EditAdmin.PermissionChangeInfo" = "Change Channel Info"; +"Channel.EditAdmin.PermissionCreateTopics" = "Create Topics"; +"Channel.EditAdmin.PermissionDeleteMessages" = "Delete Messages"; +"Channel.EditAdmin.PermissionDeleteMessagesOfOthers" = "Delete Messages of Others"; +"Channel.EditAdmin.PermissionDeleteStoriesOfOthers" = "Delete Stories of Others"; +"Channel.EditAdmin.PermissionEditMessages" = "Edit Messages of Others"; +"Channel.EditAdmin.PermissionEditMessagesOfOthers" = "Edit Messages of Others"; +"Channel.EditAdmin.PermissionEditStoriesOfOthers" = "Edit Stories of Others"; +"Channel.EditAdmin.PermissionEnabledByDefault" = "This option is permitted for all members in Group Permissions."; +"Channel.EditAdmin.PermissionInviteMembers" = "Add Members"; +"Channel.EditAdmin.PermissionInviteSubscribers" = "Add Subscribers"; +"Channel.EditAdmin.PermissionInviteUsers" = "Add Users"; +"Channel.EditAdmin.PermissionInviteViaLink" = "Invite Users via Link"; +"Channel.EditAdmin.PermissionManageRanks" = "Edit Member Tags"; +"Channel.EditAdmin.PermissionManageTopics" = "Manage Topics"; +"Channel.EditAdmin.PermissionPinMessages" = "Pin Messages"; +"Channel.EditAdmin.PermissionPostMessages" = "Post Messages"; +"Channel.EditAdmin.PermissionPostStories" = "Post Stories"; +"Channel.EditAdmin.PermissionProcessJoinRequests" = "Process Join Requests"; +"Channel.EditAdmin.PermissionProcessJoinRequestsInfo" = "Allow this bot to approve new members using its own interfaces, such as captchas."; +"Channel.EditAdmin.PermissionsHeader" = "WHAT CAN THIS ADMIN DO?"; +"Channel.EditAdmin.SectionMessages" = "Manage Messages"; +"Channel.EditAdmin.SectionStories" = "Manage Stories"; +"Channel.EditAdmin.TransferOwnership" = "Transfer Channel Ownership"; +"Channel.EditMessageErrorGeneric" = "Sorry, you can't edit this message."; +"Channel.ErrorAccessDenied" = "This channel is unavailable."; +"Channel.ErrorAddBlocked" = "Sorry, you can't add this user to channels because of their privacy settings."; +"Channel.ErrorAddTooMuch" = "Sorry, you can only add the first 200 subscribers to a channel. Note that an unlimited number of people may join via the channel's link."; +"Channel.ErrorAdminsTooMuch" = "Sorry, too many administrators in this channel."; +"Channel.Info.AppearanceItem" = "Appearance"; +"Channel.Info.AutoTranslate" = "Auto-Translate Messages"; +"Channel.Info.Banned" = "Banned"; +"Channel.Info.BlackList" = "Removed Users"; +"Channel.Info.BoostLevelPlusBadge" = "Level %@+"; +"Channel.Info.Description" = "description"; +"Channel.Info.Management" = "Admins"; +"Channel.Info.Members" = "Members"; +"Channel.Info.Settings" = "Channel Settings"; +"Channel.Info.Stats" = "Statistics"; +"Channel.Info.Stickers" = "Group Sticker Set"; +"Channel.Info.Subscribers" = "Subscribers"; +"Channel.JoinChannel" = "Join"; +"Channel.LeaveChannel" = "Leave Channel"; +"Channel.LinkItem" = "share link"; +"Channel.Management.AddAdmin" = "Add Admin"; +"Channel.Management.AddModerator" = "Add Admin"; +"Channel.Management.AddModeratorHelp" = "You can add admins to help you manage your channel."; +"Channel.Management.DismissAdmin" = "Dismiss"; +"Channel.Management.LabelAdministrator" = "admin"; +"Channel.Management.LabelCreator" = "Owner"; +"Channel.Management.LabelEditor" = "admin"; +"Channel.Management.LabelOwner" = "owner"; +"Channel.Management.PromotedBy" = "promoted by %@"; +"Channel.Management.RemovedBy" = "Removed by %@"; +"Channel.Management.RestrictedBy" = "restricted by %@"; +"Channel.Management.SaveChanges" = "Save Changes"; +"Channel.Management.Title" = "Admins"; +"Channel.Members.AddAdminErrorBlacklisted" = "Sorry, you can't add this user as an admin because they are in the Removed Users list and you can't unban them."; +"Channel.Members.AddAdminErrorNotAMember" = "You can't add this user as an admin because they are not a member of this group and you are not allowed to add them."; +"Channel.Members.AddBannedErrorAdmin" = "You can't ban this user because they are an admin in this group and you are not allowed to demote them."; +"Channel.Members.AddMembers" = "Add Subscribers"; +"Channel.Members.AddMembersHelp" = "Only channel admins can see this list."; +"Channel.Members.Contacts" = "CONTACTS IN THIS CHANNEL"; +"Channel.Members.InviteLink" = "Invite via Link"; +"Channel.Members.Other" = "OTHER SUBSCRIBERS"; +"Channel.Members.Title" = "Members"; +"Channel.MessagePhotoRemoved" = "Channel photo removed"; +"Channel.MessagePhotoUpdated" = "Channel photo updated"; +"Channel.MessageTitleUpdated" = "Channel renamed to \"%@\""; +"Channel.MessageVideoUpdated" = "Channel photo updated"; +"Channel.Moderator.AccessLevelRevoke" = "Dismiss Admin"; +"Channel.Moderator.Title" = "Admin"; +"Channel.NotificationLoading" = "Loading..."; +"Channel.Owner.Title" = "Owner"; +"Channel.OwnershipTransfer.ChangeOwner" = "Change Owner"; +"Channel.OwnershipTransfer.DescriptionInfo" = "This will transfer the full **owner rights** for **%1$@** to **%2$@**.\n\nYou will no longer be considered the creator of the channel. The new owner will be free to remove any of your admin privileges or even ban you."; +"Channel.OwnershipTransfer.EnterPassword" = "Enter Password"; +"Channel.OwnershipTransfer.EnterPasswordText" = "Please enter your Two-Step Verification password to complete this action."; +"Channel.OwnershipTransfer.ErrorAdminsTooMuch" = "Sorry, this channel has too many admins and the new owner can't be added. Please remove one of the existing admins first."; +"Channel.OwnershipTransfer.ErrorPrivacyRestricted" = "Sorry, this user is not a member of this channel and their privacy settings prevent you from adding them manually."; +"Channel.OwnershipTransfer.ErrorPublicChannelsTooMuch" = "Sorry, the target user has too many public groups or channels already. Please ask them to make one of their existing groups or channels private first."; +"Channel.OwnershipTransfer.PasswordPlaceholder" = "Password"; +"Channel.OwnershipTransfer.Title" = "Transfer Channel Ownership"; +"Channel.OwnershipTransfer.TransferCompleted" = "**%1$@** is now the owner of **%2$@**."; +"Channel.RemoveFeeAlert.Text" = "Do you want to allow **%@** to message your channel for free?"; +"Channel.Setup.ActivateAlertShow" = "Show"; +"Channel.Setup.ActivateAlertText" = "Do you want to show this link on the channel info page?"; +"Channel.Setup.ActivateAlertTitle" = "Activate Link"; +"Channel.Setup.ActiveLimitReachedError" = "Sorry, you have too many active public links already. Please hide one of your active public links first."; +"Channel.Setup.ApproveNewSubscribers" = "Approve New Subscribers"; +"Channel.Setup.ApproveNewSubscribersInfo" = "Turn this on if you want users to join only after they are approved by an admin."; +"Channel.Setup.DeactivateAlertHide" = "Hide"; +"Channel.Setup.DeactivateAlertText" = "Do you want to hide this link from the channel info page?"; +"Channel.Setup.DeactivateAlertTitle" = "Deactivate Link"; +"Channel.Setup.LinkTypePrivate" = "Private"; +"Channel.Setup.LinkTypePublic" = "Public"; +"Channel.Setup.LinksOrder" = "LINK ORDER"; +"Channel.Setup.LinksOrderInfo" = "Drag and drop links to change the order in which they will be displayed on the channel info page."; +"Channel.Setup.PublicLink" = "PUBLIC LINK"; +"Channel.Setup.PublicNoLink" = "Please choose a link for your public channel, so that people can find it in search and share with others.\n\nIf you're not interested, we suggest creating a private channel instead."; +"Channel.Setup.Title" = "Channel"; +"Channel.Setup.TypeHeader" = "CHANNEL TYPE"; +"Channel.Setup.TypePrivate" = "Private"; +"Channel.Setup.TypePrivateHelp" = "Private channels can only be joined via invite link."; +"Channel.Setup.TypePublic" = "Public"; +"Channel.Setup.TypePublicHelp" = "Public channels can be found in search, anyone can join them."; +"Channel.ShowAuthors" = "Show Authors' Profiles"; +"Channel.ShowAuthorsFooter" = "Add names and photos of admins to the messages they post, linking to their profiles."; +"Channel.SignMessages" = "Sign Messages"; +"Channel.SignMessages.Help" = "Add names of admins to the messages they post."; +"Channel.SimilarChannels.ShowMore" = "Show More Channels"; +"Channel.SimilarChannels.ShowMoreInfo" = "Subscribe to [Telegram Premium]()\nto unlock up to **100** similar channels."; +"Channel.Status" = "channel"; +"Channel.Stickers.CreateYourOwn" = "You can create your own custom sticker set using the @stickers bot."; +"Channel.Stickers.NotFound" = "Sticker set not found"; +"Channel.Stickers.NotFoundHelp" = "Try again or choose from the list below"; +"Channel.Stickers.Placeholder" = "stickerset"; +"Channel.Stickers.Searching" = "Searching..."; +"Channel.Stickers.YourStickers" = "CHOOSE FROM YOUR STICKERS"; +"Channel.Subscribers.Title" = "Subscribers"; +"Channel.TitleInfo" = "Channel Info"; +"Channel.TooMuchBots" = "There are too many bots in this group. Please remove some of the bots you're not using first."; +"Channel.TypeSetup.Title" = "Channel Type"; +"Channel.UpdatePhotoItem" = "Set Channel Photo"; +"Channel.Username.CheckingUsername" = "Checking name..."; +"Channel.Username.CreatePrivateLinkHelp" = "People can join your channel by following this link. You can revoke the link at any time."; +"Channel.Username.CreatePublicLinkHelp" = "If you set a public link, other people will be able to find and join your channel.\n\nYou can use a–z, 0–9 and underscores.\nMinimum length is 5 characters."; +"Channel.Username.Help" = "You can choose a channel name on **Telegram**. If you do, other people will be able to find your channel by this name.\n\nYou can use **a–z**, **0–9** and underscores. Minimum length is **5** characters."; +"Channel.Username.InvalidCharacters" = "Sorry, this name is invalid."; +"Channel.Username.InvalidEndsWithUnderscore" = "Channel names can't end with an underscore."; +"Channel.Username.InvalidStartsWithNumber" = "Public links can't start with a number."; +"Channel.Username.InvalidStartsWithUnderscore" = "Channel names can't start with an underscore."; +"Channel.Username.InvalidTaken" = "Sorry, this link is already taken."; +"Channel.Username.InvalidTooShort" = "Public links must have at least 5 characters."; +"Channel.Username.LinkHint" = "This link opens your channel:[\nhttps://t.me/%@]"; +"Channel.Username.RevokeExistingUsernamesInfo" = "You can revoke the link from one of your older groups or channels, or create a private channel instead."; +"Channel.Username.Title" = "Link"; +"Channel.Username.UsernameIsAvailable" = "%@ is available."; +"Channel.Username.UsernamePurchaseAvailable" = "**This username is already taken.** However, it is currently available for purchase. [Learn more...]()"; +"ChannelBoost.AdditionalFeaturesText" = "By gaining **boosts**, your channel reaches higher levels and unlocks more features."; +"ChannelBoost.AskToBoost" = "Ask your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.AutoTranslate" = "Auto-translation of Messages"; +"ChannelBoost.AutoTranslateLevelText" = "Your channel needs to reach **Level %1$@** to enable auto-translation."; +"ChannelBoost.Boost" = "Boost"; +"ChannelBoost.BoostAgain" = "Boost Again"; +"ChannelBoost.BoostChannel" = "Boost Channel"; +"ChannelBoost.BoostLinkCopied" = "Boost link copied to clipboard"; +"ChannelBoost.BoostLinkForwardTooltip.Chat.One" = "Boost link forwarded to **%@**"; +"ChannelBoost.BoostLinkForwardTooltip.ManyChats.One" = "Boost link forwarded to **%@** and %@ others"; +"ChannelBoost.BoostLinkForwardTooltip.SavedMessages.One" = "Boost link forwarded to **Saved Messages**"; +"ChannelBoost.BoostLinkForwardTooltip.TwoChats.One" = "Boost link forwarded to **%@** and **%@**"; +"ChannelBoost.BoostedChannelMoreRequired" = "%1$@ needed to be able to post %2$@."; +"ChannelBoost.BoostedChannelReachedLevel" = "This channel reached **Level %1$@** and can now post %2$@."; +"ChannelBoost.Copy" = "Copy"; +"ChannelBoost.CopyLink" = "Copy Link"; +"ChannelBoost.CustomReactions" = "Custom Reactions"; +"ChannelBoost.CustomReactionsText" = "Your channel needs to reach **Level %1$@** to add **%2$@** custom emoji as reactions.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.CustomWallpaper" = "Set Custom Channel Wallpaper"; +"ChannelBoost.EmojiStatus" = "Use Emoji Statuses"; +"ChannelBoost.EnableColors" = "Enable Colors"; +"ChannelBoost.EnableColorsLevelText" = "Your channel needs **Level %1$@** to change channel color.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.EnableColorsText" = "Your channel needs **Level 5** to change channel color.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.EnableCustomWallpaperLevelText" = "Your channel needs **Level %1$@** to set a custom wallpaper."; +"ChannelBoost.EnableEmojiStatusLevelText" = "Your channel needs **Level %1$@** to use emoji statuses."; +"ChannelBoost.EnableNameColorLevelText" = "Your channel needs **Level %1$@** to change its color."; +"ChannelBoost.EnableNameIconLevelText" = "Your channel needs **Level %1$@** to change the link logo."; +"ChannelBoost.EnableNoAdsLevelText" = "Your channel needs **Level %1$@** to switch off ads."; +"ChannelBoost.EnableProfileColorLevelText" = "Your channel needs **Level %1$@** to change the cover color."; +"ChannelBoost.EnableProfileIconLevelText" = "Your channel needs **Level %1$@** to change the cover logo."; +"ChannelBoost.EnableStories" = "Enable Stories"; +"ChannelBoost.EnableStoriesForChannel" = "Enable Stories for This Channel"; +"ChannelBoost.EnableStoriesForChannelText" = "**%1$@** needs %2$@ to enable posting stories. Help make it possible!"; +"ChannelBoost.EnableStoriesForOtherChannel" = "Enable Stories for the Channel"; +"ChannelBoost.EnableStoriesMoreRequired" = "This channel needs %1$@ to enable stories."; +"ChannelBoost.EnableStoriesText" = "Your channel needs %1$@ to enable posting stories.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.EnableWallpaperLevelText" = "Your channel needs **Level %1$@** to set a wallpaper."; +"ChannelBoost.EnabledStoriesForChannelText" = "This channel reached **Level 1** and can now post stories."; +"ChannelBoost.Error.BoostTooOftenText" = "You can only change the channel you boost once a day. You will be able to boost in **%@**."; +"ChannelBoost.Error.BoostTooOftenTitle" = "Can't Boost Too Often"; +"ChannelBoost.Error.GiftedPremiumNotAllowedText" = "Because your **Telegram Premium** subscription was gifted to you, you can't use it to boost channels."; +"ChannelBoost.Error.GiftedPremiumNotAllowedTitle" = "Can't Boost with Gifted Premium"; +"ChannelBoost.Error.PremiumNeededText" = "Only **Telegram Premium** subscribers can boost channels. Do you want to subscribe to **Telegram Premium**?"; +"ChannelBoost.Error.PremiumNeededTitle" = "Premium Needed"; +"ChannelBoost.Header.Boost" = "boost"; +"ChannelBoost.Header.Features" = "features"; +"ChannelBoost.Header.Giveaway" = "giveaway"; +"ChannelBoost.HelpUpgradeChannel" = "Help Upgrade This Channel"; +"ChannelBoost.HelpUpgradeChannelText" = "**%1$@** needs %2$@ to be able to post %3$@."; +"ChannelBoost.IncreaseLimit" = "Increase Story Limit"; +"ChannelBoost.IncreaseLimitText" = "Your channel needs %1$@ to post %2$@.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.Info" = "Subscribers of your channel can **boost** it so that it **levels up** and gets **exclusive features**."; +"ChannelBoost.Level" = "Level %@"; +"ChannelBoost.MaxLevelReached" = "Maximum Level Reached"; +"ChannelBoost.MaxLevelReached.Text" = "**%1$@** reached **Level %2$@**."; +"ChannelBoost.MaxLevelReachedText" = "This channel reached **Level %1$@** and can now post %2$@."; +"ChannelBoost.MaxLevelReachedTextAuthor" = "This channel reached **Level %1$@** and can now post %2$@."; +"ChannelBoost.MoreBoosts#one" = "**%@** more boost"; +"ChannelBoost.MoreBoosts#other" = "**%@** more boosts"; +"ChannelBoost.MoreBoosts.Gift" = "Gift Premium"; +"ChannelBoost.MoreBoosts.Text" = "To boost **%1$@**, gift **Telegram Premium** to a friend and get **%2$@** additional boosts."; +"ChannelBoost.MoreBoosts.Title" = "More Boosts Needed"; +"ChannelBoost.MoreBoostsNeeded.Boosted.Level.Text" = "This channel reached **Level %@** and unlocked new features."; +"ChannelBoost.MoreBoostsNeeded.Boosted.Text" = "%@ needed to unlock new features."; +"ChannelBoost.MoreBoostsNeeded.Boosts#one" = "**%@** more boost"; +"ChannelBoost.MoreBoostsNeeded.Boosts#other" = "**%@** more boosts"; +"ChannelBoost.MoreBoostsNeeded.Text" = "**%1$@** needs %2$@ to unlock new features."; +"ChannelBoost.NameColor" = "Enable Colors"; +"ChannelBoost.NameIcon" = "Set Link Logo"; +"ChannelBoost.NoAds" = "Switch Off Ads"; +"ChannelBoost.Or" = "or"; +"ChannelBoost.PremiumUsersCanBoost" = "**Premium** users can boost your channel with this link:"; +"ChannelBoost.ProfileColor" = "Set Cover Color"; +"ChannelBoost.ProfileIcon" = "Set Cover Logo"; +"ChannelBoost.Replace" = "Replace"; +"ChannelBoost.ReplaceBoost" = "You currently boost **%1$@**. Do you want to boost **%2$@** instead?"; +"ChannelBoost.StoriesPerDay#one" = "**%@** story per day"; +"ChannelBoost.StoriesPerDay#other" = "**%@** stories per day"; +"ChannelBoost.Table.AutoTranslate" = "Auto-translation of Messages"; +"ChannelBoost.Table.CustomReactions#one" = "%@ Custom Reaction"; +"ChannelBoost.Table.CustomReactions#other" = "%@ Custom Reactions"; +"ChannelBoost.Table.CustomWallpaper" = "Custom Channel Background"; +"ChannelBoost.Table.EmojiStatus" = "1000+ Emoji Statuses"; +"ChannelBoost.Table.Group.CustomWallpaper" = "Custom Group Background"; +"ChannelBoost.Table.Group.ProfileColor#one" = "%@ Color for Group Cover"; +"ChannelBoost.Table.Group.ProfileColor#other" = "%@ Colors for Group Cover"; +"ChannelBoost.Table.Group.ProfileLogo" = "Custom Logo for Group Cover"; +"ChannelBoost.Table.Group.Wallpaper#one" = "%@ Group Background"; +"ChannelBoost.Table.Group.Wallpaper#other" = "%@ Group Backgrounds"; +"ChannelBoost.Table.HeadersLogo" = "Custom Logo for Links and Quotes"; +"ChannelBoost.Table.Level#one" = "Level %@"; +"ChannelBoost.Table.Level#other" = "Level %@"; +"ChannelBoost.Table.LevelUnlocks#one" = "Level %@ Unlocks:"; +"ChannelBoost.Table.LevelUnlocks#other" = "Level %@ Unlocks:"; +"ChannelBoost.Table.NameColor#one" = "%@ Channel Name Color"; +"ChannelBoost.Table.NameColor#other" = "%@ Channel Name Colors"; +"ChannelBoost.Table.NoAds" = "Switch Off Ads"; +"ChannelBoost.Table.ProfileColor#one" = "%@ Color for Channel Cover"; +"ChannelBoost.Table.ProfileColor#other" = "%@ Colors for Channel Cover"; +"ChannelBoost.Table.ProfileLogo" = "Custom Logo for Channel Cover"; +"ChannelBoost.Table.StoriesPerDay#one" = "%@ Story Per Day"; +"ChannelBoost.Table.StoriesPerDay#other" = "%@ Stories Per Day"; +"ChannelBoost.Table.StyleForHeaders#one" = "%@ Style for Links and Quotes"; +"ChannelBoost.Table.StyleForHeaders#other" = "%@ Styles for Links and Quotes"; +"ChannelBoost.Table.Wallpaper#one" = "%@ Channel Background"; +"ChannelBoost.Table.Wallpaper#other" = "%@ Channel Backgrounds"; +"ChannelBoost.Table.WearGift" = "Wear Unique Collectibles"; +"ChannelBoost.Title" = "Boost Channel"; +"ChannelBoost.Title.Current" = "Boost Channel"; +"ChannelBoost.Title.Other" = "Help Upgrade This Channel"; +"ChannelBoost.Wallpaper" = "Set Channel Wallpaper"; +"ChannelBoost.WearGift" = "Wear Item"; +"ChannelBoost.WearGiftLevelText" = "Your channel needs **Level %1$@** to wear collectibles."; +"ChannelBoost.YouBoostedChannel" = "You Boosted %1$@!"; +"ChannelBoost.YouBoostedChannelText" = "You boosted %1$@!"; +"ChannelBoost.YouBoostedOtherChannel" = "You Boosted The Channel"; +"ChannelBoost.YouBoostedOtherChannelText" = "You boosted this channel"; +"ChannelInfo.AddParticipantConfirmation" = "Add %@ to the channel?"; +"ChannelInfo.ChannelForbidden" = "Sorry, the channel \"%@\" is no longer accessible."; +"ChannelInfo.ConfirmLeave" = "Leave Channel"; +"ChannelInfo.CreateExternalStream" = "Stream With..."; +"ChannelInfo.CreateLiveStream" = "Start Live Stream"; +"ChannelInfo.CreateVoiceChat" = "Start Video Chat"; +"ChannelInfo.DeleteChannel" = "Delete Channel"; +"ChannelInfo.DeleteChannelConfirmation" = "Deleting this channel will remove all subscribers and all posts will be lost. Delete the channel anyway?"; +"ChannelInfo.DeleteGroup" = "Delete Group"; +"ChannelInfo.DeleteGroupConfirmation" = "Deleting this group will remove all members and all messages will be lost. Do you want to delete the group?"; +"ChannelInfo.FakeChannelWarning" = "⚠️ Warning: Many users reported that this account impersonates a famous person or organization."; +"ChannelInfo.InviteLink.RevokeAlert.Text" = "Are you sure you want to revoke this link? Once you do, no one will be able to join the channel using it."; +"ChannelInfo.ScamChannelWarning" = "⚠️ Warning: Many users reported this channel as a scam or a fake account. Please be careful, especially if it asks you for money."; +"ChannelInfo.ScheduleLiveStream" = "Schedule Live Stream"; +"ChannelInfo.ScheduleVoiceChat" = "Schedule Video Chat"; +"ChannelInfo.Stats" = "Statistics"; +"ChannelIntro.CreateChannel" = "Create Channel"; +"ChannelIntro.Text" = "Channels are a one-to-many tool\nfor broadcasting your messages\nto unlimited audiences."; +"ChannelIntro.Title" = "What is a Channel?"; +"ChannelMembers.ChannelAdminsTitle" = "CHANNEL ADMINS"; +"ChannelMembers.GroupAdminsTitle" = "GROUP ADMINS"; +"ChannelMembers.WhoCanAddMembers" = "Who can add members"; +"ChannelMembers.WhoCanAddMembers.Admins" = "Only Admins"; +"ChannelMembers.WhoCanAddMembers.AllMembers" = "All Members"; +"ChannelMembers.WhoCanAddMembersAdminsHelp" = "Only admins can add new members."; +"ChannelMembers.WhoCanAddMembersAllHelp" = "Everybody can add new members."; +"ChannelMessages.Info" = "Allow users to send messages to your channel, with the option to charge a fee for each message."; +"ChannelMessages.LinkTitle" = "LINK TO DIRECT MESSAGES"; +"ChannelMessages.PriceSectionFooter" = "Your channel will receive 85% of the selected fee for each incoming message."; +"ChannelMessages.PriceSectionFooterValue" = "You will receive %1$@% of the selected fee for each incoming message."; +"ChannelMessages.PriceSectionTitle" = "PRICE FOR EACH MESSAGE"; +"ChannelMessages.SwitchTitle" = "Direct Messages"; +"ChannelMessages.Title" = "Direct Messages"; +"ChannelProfile.AboutActionCopy" = "Copy"; +"ChannelProfile.ToastAboutCopied" = "Copied to clipboard."; +"ChannelReactions.GeneralInfoLabel" = "You can add emoji from any emoji pack as reactions."; +"ChannelReactions.InputPlaceholder" = "Add Reactions..."; +"ChannelReactions.LevelRequiredLabel" = "Level %1$@ Required"; +"ChannelReactions.Reactions" = "Reactions"; +"ChannelReactions.ReactionsInfoLabel" = "You can also [create your own]() emoji packs and use them."; +"ChannelReactions.ReactionsSectionTitle" = "AVAILABLE REACTIONS"; +"ChannelReactions.SaveAction" = "Update Reactions"; +"ChannelReactions.ToastLevelBoostRequired" = "Your channel needs to reach **Level %1$@** to add **%2$@** custom emoji as reactions."; +"ChannelReactions.ToastLevelBoostRequiredTemplate" = "Your channel needs to reach **%1$@** to add **%2$@** as reactions."; +"ChannelReactions.ToastLevelBoostRequiredTemplateEmojiCount#one" = "1 custom emoji"; +"ChannelReactions.ToastLevelBoostRequiredTemplateEmojiCount#other" = "%d custom emoji"; +"ChannelReactions.ToastLevelBoostRequiredTemplateLevel#one" = "Level 1"; +"ChannelReactions.ToastLevelBoostRequiredTemplateLevel#other" = "Level %d"; +"ChannelReactions.ToastMaxReactionsReached" = "You can select up to 100 reactions."; +"ChannelReactions.UnsavedChangesAlertApply" = "Apply"; +"ChannelReactions.UnsavedChangesAlertDiscard" = "Discard"; +"ChannelReactions.UnsavedChangesAlertText" = "You have changed the list of reactions. Apply changes?"; +"ChannelReactions.UnsavedChangesAlertTitle" = "Unsaved Changes"; +"ChannelRemoved.RemoveInfo" = "Users removed from the channel by admins can't rejoin via invite links."; +"Chat.AdminAction.ToastBannedText#one" = "**1** user banned."; +"Chat.AdminAction.ToastBannedText#other" = "**%d** users banned."; +"Chat.AdminAction.ToastMessagesAndReactionsDeletedText" = "Messages and reactions deleted."; +"Chat.AdminAction.ToastMessagesDeletedTextMultiple" = "Messages Deleted."; +"Chat.AdminAction.ToastMessagesDeletedTextSingle" = "Message Deleted."; +"Chat.AdminAction.ToastMessagesDeletedTitleMultiple" = "Messages Deleted"; +"Chat.AdminAction.ToastMessagesDeletedTitleSingle" = "Message Deleted"; +"Chat.AdminAction.ToastReactionsDeletedTextMultiple" = "Reactions Deleted."; +"Chat.AdminAction.ToastReactionsDeletedTextSingle" = "Reaction Deleted."; +"Chat.AdminAction.ToastReactionsDeletedTitleSingle" = "Reaction Deleted"; +"Chat.AdminAction.ToastReportedSpamText#one" = "**1** user reported for spam."; +"Chat.AdminAction.ToastReportedSpamText#other" = "**%d** users reported for spam."; +"Chat.AdminAction.ToastRestrictedText#one" = "**1** user restricted."; +"Chat.AdminAction.ToastRestrictedText#other" = "**%d** users restricted."; +"Chat.AdminActionSheet.ActionButton" = "Proceed"; +"Chat.AdminActionSheet.BanFooterMultiple" = "Fully ban users"; +"Chat.AdminActionSheet.BanFooterSingle" = "Fully ban this user"; +"Chat.AdminActionSheet.BanMultiple" = "Ban Users"; +"Chat.AdminActionSheet.BanSingle" = "Ban %@"; +"Chat.AdminActionSheet.DeleteAllMessages" = "Delete All Messages"; +"Chat.AdminActionSheet.DeleteAllMultiple" = "Delete All from Users"; +"Chat.AdminActionSheet.DeleteAllReactions" = "Delete All Reactions"; +"Chat.AdminActionSheet.DeleteAllSingle" = "Delete All from %@"; +"Chat.AdminActionSheet.DeleteReactionTitle" = "Delete 1 Reaction"; +"Chat.AdminActionSheet.DeleteTitle#one" = "Delete 1 Message"; +"Chat.AdminActionSheet.DeleteTitle#other" = "Delete %d Messages"; +"Chat.AdminActionSheet.PermissionsSectionHeader" = "WHAT CAN THIS USER DO?"; +"Chat.AdminActionSheet.PermissionsSectionHeaderMultiple" = "WHAT CAN THESE USERS DO?"; +"Chat.AdminActionSheet.ReportSpam" = "Report Spam"; +"Chat.AdminActionSheet.RestrictFooterMultiple" = "Partially restrict users"; +"Chat.AdminActionSheet.RestrictFooterSingle" = "Partially restrict this user"; +"Chat.AdminActionSheet.RestrictMultiple" = "Restrict Users"; +"Chat.AdminActionSheet.RestrictSectionHeader" = "ADDITIONAL ACTIONS"; +"Chat.AdminActionSheet.RestrictSingle" = "Restrict %@"; +"Chat.AttachmentLimitExceeded" = "Sorry, you can't select more items at once."; +"Chat.AttachmentLimitReached" = "You can't select more items."; +"Chat.AttachmentMultipleFilesDisabled" = "Slow Mode is enabled. You can't send multiple files at once."; +"Chat.AttachmentMultipleForwardDisabled" = "Slow Mode is enabled. You can't forward multiple messages at once."; +"Chat.Auction" = "Gift Auction"; +"Chat.Auction.Finished" = "finished"; +"Chat.Auction.Gifts#one" = "%@ gift"; +"Chat.Auction.Gifts#other" = "%@ gifts"; +"Chat.Auction.Join" = "JOIN"; +"Chat.Auction.StartsIn" = "starts in "; +"Chat.Auction.Upcoming" = "Upcoming Auction"; +"Chat.Auction.View" = "VIEW"; +"Chat.Auction.ViewResults" = "VIEW RESULTS"; +"Chat.AudioTranscriptionFeedbackTip" = "Thank you for your feedback."; +"Chat.AudioTranscriptionRateAction" = "Rate Transcription"; +"Chat.BirthdayTooltip" = "🎂 You can send %1$@ a gift for their birthday today."; +"Chat.BotAd.Title" = "Ad"; +"Chat.BotAd.WhatIsThis" = "what's this?"; +"Chat.BottomSearchPanel.DisplayModeChat" = "Chat"; +"Chat.BottomSearchPanel.DisplayModeFormat" = "Show as %@"; +"Chat.BottomSearchPanel.DisplayModeList" = "List"; +"Chat.BottomSearchPanel.MessageCount#one" = "message"; +"Chat.BottomSearchPanel.MessageCount#other" = "messages"; +"Chat.BottomSearchPanel.MessageCountFormat" = "%1$@ %2$@"; +"Chat.BottomSearchPanel.StoryCount#one" = "story"; +"Chat.BottomSearchPanel.StoryCount#other" = "stories"; +"Chat.BusinessBotMessageTooltip" = "Only you can see that this\nmessage was sent by a bot."; +"Chat.BusinessBotPanel.ActionStart" = "START"; +"Chat.BusinessBotPanel.ActionStop" = "STOP"; +"Chat.BusinessBotPanel.Menu.ManageBot" = "Manage Bot"; +"Chat.BusinessBotPanel.Menu.RemoveBot" = "Remove bot from this chat"; +"Chat.BusinessBotPanel.StatusHasAccess" = "bot has access to this chat"; +"Chat.BusinessBotPanel.StatusManages" = "bot manages this chat"; +"Chat.BusinessBotPanel.StatusPaused" = "bot paused"; +"Chat.CallMessage.CancelledGroupCall" = "Cancelled Group Call"; +"Chat.CallMessage.DeclinedGroupCall" = "Declined Group Call"; +"Chat.CallMessage.GroupCallParticipantCount#one" = "1 person"; +"Chat.CallMessage.GroupCallParticipantCount#other" = "%d people"; +"Chat.CallMessage.IncomingGroupCall" = "Incoming Group Call"; +"Chat.CallMessage.MissedGroupCall" = "Missed Group Call"; +"Chat.CallMessage.OutgoingGroupCall" = "Outgoing Group Call"; +"Chat.ChannelMessagesHint" = "Send a message to the channel's admin"; +"Chat.ChannelMessagesHintBadge" = "NEW"; +"Chat.ChannelRecommendation.PremiumTooltip" = "Subscribe to [Telegram Premium]() to unlock up to **100** similar channels."; +"Chat.ClearReactionsAlertAction" = "Clear Recent Emoji"; +"Chat.ClearReactionsAlertText" = "Do you want to clear your recent reaction emoji from suggestions?"; +"Chat.CommandList.EditQuickReplies" = "Edit Quick Replies"; +"Chat.ConfirmationDeleteFromSavedMessages" = "Delete from Saved Messages"; +"Chat.ConfirmationRemoveFromSavedMessages" = "Remove from Saved Messages"; +"Chat.ContactChannel" = "CONTACT CHANNEL"; +"Chat.Context.Card.Copy" = "Copy Card Number"; +"Chat.Context.Command.Copy" = "Copy Command"; +"Chat.Context.Date.AddToCalendar" = "Add to Calendar"; +"Chat.Context.Date.Copy" = "Copy Date"; +"Chat.Context.Date.SetReminder" = "Set a Reminder"; +"Chat.Context.Hashtag.Copy" = "Copy Hashtag"; +"Chat.Context.Hashtag.Search" = "Search"; +"Chat.Context.Phone.AddToContacts" = "Add to Contacts"; +"Chat.Context.Phone.AddToExistingContact" = "Add to Existing Contact"; +"Chat.Context.Phone.CallViaCarrier" = "Call via Carrier"; +"Chat.Context.Phone.CopyNumber" = "Copy Number"; +"Chat.Context.Phone.CreateNewContact" = "Create New Contact"; +"Chat.Context.Phone.InviteToTelegram" = "Invite to Telegram"; +"Chat.Context.Phone.NotOnTelegram" = "This number is not on Telegram."; +"Chat.Context.Phone.SendMessage" = "Send Message"; +"Chat.Context.Phone.TelegramVideoCall" = "Telegram Video Call"; +"Chat.Context.Phone.TelegramVoiceCall" = "Telegram Voice Call"; +"Chat.Context.Phone.ViewProfile" = "View Profile"; +"Chat.Context.Timecode.Copy" = "Copy Timecode"; +"Chat.Context.Username.Copy" = "Copy Username"; +"Chat.Context.Username.NotOnTelegram" = "This user doesn't exist on Telegram."; +"Chat.Context.Username.OpenChannel" = "Open Channel"; +"Chat.Context.Username.OpenGroup" = "Open Group"; +"Chat.Context.Username.SendMessage" = "Send Message"; +"Chat.ContextMenu.AboutAd" = "About This Ad"; +"Chat.ContextMenu.AdSponsorInfo" = "Advertiser Info"; +"Chat.ContextMenu.AdSponsorInfoCopied" = "Text copied."; +"Chat.ContextMenu.AuthorInfo" = "Sent by %@"; +"Chat.ContextMenu.OpenInApp" = "Open In-App"; +"Chat.ContextMenu.OpenInBrowser" = "Open in Browser"; +"Chat.ContextMenu.RemoveAd" = "Remove Ads"; +"Chat.ContextMenu.ReportAd" = "Report Ad"; +"Chat.ContextMenu.SuggestedPost.Create" = "Suggest Post"; +"Chat.ContextMenu.SuggestedPost.EditMessage" = "Edit Message"; +"Chat.ContextMenu.SuggestedPost.EditPrice" = "Edit Price"; +"Chat.ContextMenu.SuggestedPost.EditTime" = "Edit Time"; +"Chat.ContextMenuReadDate.ReadAvailableBadge" = "show when"; +"Chat.ContextMenuReadDate.ReadAvailablePrefix" = "read"; +"Chat.ContextMenuReadDate.ReadFormat" = "read %@"; +"Chat.ContextMenuTagsTitle" = "Tag the message with an emoji for quick access later"; +"Chat.ContextReactionCount#one" = "1 reaction"; +"Chat.ContextReactionCount#other" = "%@ reactions"; +"Chat.ContextViewAsMessages" = "View as Messages"; +"Chat.ContextViewAsTopics" = "View as Topics"; +"Chat.CreateBotLink" = "Create Bot"; +"Chat.CreateTopic" = "New Topic"; +"Chat.DeleteMessagesConfirmation#one" = "Delete message"; +"Chat.DeleteMessagesConfirmation#other" = "Delete %@ messages"; +"Chat.DeletePaidMessage.Action" = "Delete Anyway"; +"Chat.DeletePaidMessageStars.Text" = "You won't receive **Stars** for this post if you delete it now. The post must remain visible for at least **24 hours** after publication."; +"Chat.DeletePaidMessageStars.Title" = "Stars Will Be Lost"; +"Chat.DeletePaidMessageTon.Text" = "You won't receive **Grams** for this post if you delete it now. The post must remain visible for at least **24 hours** after publication."; +"Chat.DeletePaidMessageTon.Title" = "Grams Will Be Lost"; +"Chat.DeleteReactionInfo" = "Hold down to delete reaction."; +"Chat.EditTagTitle.Placeholder" = "Name"; +"Chat.EditTagTitle.Text" = "You can label your emoji tag with a text name."; +"Chat.EditTagTitle.TitleEdit" = "Edit Name"; +"Chat.EditTagTitle.TitleSet" = "Set Name"; +"Chat.EmptyState.QuickReply.Text1" = "• Enter a message below that will be sent in chats when you type \"**/%@**\"."; +"Chat.EmptyState.QuickReply.Text2" = "• You can access Quick Replies in any chat by typing \"/\" or using the attachment menu."; +"Chat.EmptyState.QuickReply.Title" = "New Quick Reply"; +"Chat.EmptyStateIntroFooter" = "%@ added the message above for all empty chats"; +"Chat.EmptyStateIntroFooterAction" = "how?"; +"Chat.EmptyStateIntroFooterPremiumActionButton" = "OK"; +"Chat.EmptyStateMessagingRestrictedToPremium.Action" = "Get Premium"; +"Chat.EmptyStateMessagingRestrictedToPremium.Text" = "Subscribe to **Premium**\nto message **%@**."; +"Chat.EmptyStateMessagingRestrictedToPremiumDisabled.Text" = "Subscribe to **Premium**\nto message **%@**."; +"Chat.EmptyStateMonoforum.Text" = "Send a direct message to the administrator of **%@**."; +"Chat.EmptyStateMonoforumPaid.Text" = "**%1$@** charges **%2$@**\nper message to its admin."; +"Chat.EmptyStatePaidMessaging.Action" = "Buy Stars"; +"Chat.EmptyStatePaidMessaging.Text" = "**%1$@** charges **%2$@**\nfor each message."; +"Chat.EmptyStatePaidMessagingDisabled.Text" = "**%1$@** charges **%2$@**\nfor each message."; +"Chat.EmptyTopicPlaceholder.Text" = "Send a message\nto start the topic."; +"Chat.EmptyTopicPlaceholder.Title" = "Almost done!"; +"Chat.EnableSharingOffer.Accept" = "Accept"; +"Chat.EnableSharingOffer.AcceptConfirmation.Accept" = "Enable"; +"Chat.EnableSharingOffer.AcceptConfirmation.Text" = "Are you sure you want to enable sharing?"; +"Chat.EnableSharingOffer.AcceptConfirmation.Title" = "Enable Sharing"; +"Chat.EnableSharingOffer.Reject" = "Reject"; +"Chat.EnableSharingOffer.RejectConfirmation.Reject" = "Keep Disabled"; +"Chat.EnableSharingOffer.RejectConfirmation.Text" = "Are you sure you want to keep sharing disabled?"; +"Chat.EnableSharingOffer.RejectConfirmation.Title" = "Disable Sharing"; +"Chat.ErrorCantBoost" = "Sorry, you can't boost this group or channel."; +"Chat.ErrorFolderLinkExpired" = "The folder link has expired."; +"Chat.ErrorInvoiceNotFound" = "Invoice not found."; +"Chat.ErrorQuoteOutdatedActionEdit" = "Edit"; +"Chat.ErrorQuoteOutdatedText" = "**%@** updated the message you are quoting. Edit your quote to make it up-to-date."; +"Chat.ErrorQuoteOutdatedTitle" = "Quote Outdated"; +"Chat.ForwardToSavedMessageTagSelectionTitle" = "You can add a tag to the message"; +"Chat.ForwardToSavedMessagesTagSelectionTitle" = "You can add a tag to messages"; +"Chat.GenericForuThreadStatus" = "thread"; +"Chat.GenericPsaTooltip" = "This is a public service announcement"; +"Chat.Gifs.SavedSectionHeader" = "MY GIFS"; +"Chat.Gifs.TrendingSectionHeader" = "TRENDING GIFS"; +"Chat.GiftPurchaseOffer.Accept" = "Accept"; +"Chat.GiftPurchaseOffer.AcceptConfirmation.BadValue" = "The value of this gift is **%@** higher than the offer."; +"Chat.GiftPurchaseOffer.AcceptConfirmation.Confirm" = "Confirm Sale"; +"Chat.GiftPurchaseOffer.AcceptConfirmation.Text" = "Do you want to sell **%1$@** to %2$@ for **%3$@**? You'll receive **%4$@** after fees."; +"Chat.GiftPurchaseOffer.AcceptConfirmation.Text.Stars#one" = "%@ Star"; +"Chat.GiftPurchaseOffer.AcceptConfirmation.Text.Stars#other" = "%@ Stars"; +"Chat.GiftPurchaseOffer.AcceptConfirmation.Title" = "Confirm Sale"; +"Chat.GiftPurchaseOffer.Reject" = "Reject"; +"Chat.GiftPurchaseOffer.RejectConfirmation.Reject" = "Reject"; +"Chat.GiftPurchaseOffer.RejectConfirmation.Text" = "Are you sure you want to reject the offer from **%@**?"; +"Chat.GiftPurchaseOffer.RejectConfirmation.Title" = "Reject Offer"; +"Chat.Giveaway.DeleteConfirmation.Text" = "Deleting this message won't cancel the giveaway — the winners will still be selected on **%@**.\n\nOnce deleted, the Giveaway Announcement cannot be recovered."; +"Chat.Giveaway.DeleteConfirmation.Title" = "Do you want to delete the Giveaway Announcement?"; +"Chat.Giveaway.Info.ActivatedLinks#one" = "%@ winner already used their gift link."; +"Chat.Giveaway.Info.ActivatedLinks#other" = "%@ of the winners already used their gift links."; +"Chat.Giveaway.Info.AdditionalPrizes" = "**%1$@** also included **%2$@** with the prizes. The channel admins are responsible for delivering these prizes."; +"Chat.Giveaway.Info.AlmostOver" = "The giveaway is almost over."; +"Chat.Giveaway.Info.DidntWin" = "You didn't win a prize in this giveaway."; +"Chat.Giveaway.Info.Ended" = "On **%1$@**, Telegram automatically selected %2$@ of **%3$@**."; +"Chat.Giveaway.Info.EndedIntro" = "The giveaway was sponsored by the admins of **%1$@**, who acquired %2$@ for %3$@ for its followers."; +"Chat.Giveaway.Info.EndedMany" = "On **%1$@**, Telegram automatically selected %2$@ of **%3$@** and other listed communities."; +"Chat.Giveaway.Info.EndedNew" = "On **%1$@**, Telegram automatically selected %2$@ that joined **%3$@** after **%4$@**."; +"Chat.Giveaway.Info.EndedNewMany" = "On **%1$@**, Telegram automatically selected %2$@ that joined **%3$@** and other listed channels after **%4$@**."; +"Chat.Giveaway.Info.EndedTitle" = "Giveaway Ended"; +"Chat.Giveaway.Info.FullDate" = "**%1$@** on **%2$@**"; +"Chat.Giveaway.Info.Group.EndedIntro" = "The giveaway was sponsored by the admins of **%1$@**, who acquired %2$@ for %3$@ for its members."; +"Chat.Giveaway.Info.Group.OngoingIntro" = "The giveaway is sponsored by the admins of **%1$@**, who acquired %2$@ for %3$@ for its members."; +"Chat.Giveaway.Info.Group.RandomMembers#one" = "**%@** random member"; +"Chat.Giveaway.Info.Group.RandomMembers#other" = "**%@** random members"; +"Chat.Giveaway.Info.Months#one" = "**%@** month"; +"Chat.Giveaway.Info.Months#other" = "**%@** months"; +"Chat.Giveaway.Info.NotAllowedAdmin" = "You are not eligible for this giveaway because you are an admin of a participating channel (**%@**)."; +"Chat.Giveaway.Info.NotAllowedAdminGroup" = "You are not eligible to participate in this giveaway, because you are an admin of participating group (**%@**)."; +"Chat.Giveaway.Info.NotAllowedCountry" = "You are not eligible to participate in this giveaway because your country is not included in the terms of the giveaway."; +"Chat.Giveaway.Info.NotAllowedJoinedEarly" = "You are not eligible to participate in this giveaway because you joined this channel on **%@**, which is before the contest started."; +"Chat.Giveaway.Info.NotQualified" = "To take part in this giveaway please join the channel **%1$@** before **%2$@**."; +"Chat.Giveaway.Info.NotQualifiedMany" = "To take part in this giveaway please join the channel **%1$@** (and %2$@) before **%3$@**."; +"Chat.Giveaway.Info.Ongoing" = "On **%1$@**, Telegram will automatically select %2$@ of **%3$@**."; +"Chat.Giveaway.Info.OngoingIntro" = "The giveaway is sponsored by the admins of **%1$@**, who acquired %2$@ for %3$@ for its followers."; +"Chat.Giveaway.Info.OngoingMany" = "On **%1$@**, Telegram will automatically select %2$@ of **%3$@** and %4$@."; +"Chat.Giveaway.Info.OngoingNew" = "On **%1$@**, Telegram will automatically select %2$@ that joined **%3$@** after **%4$@**."; +"Chat.Giveaway.Info.OngoingNewMany" = "On **%1$@**, Telegram will automatically select %2$@ that joined **%3$@** and %4$@ after **%5$@**."; +"Chat.Giveaway.Info.OtherChannels#one" = "**%@** other listed channel"; +"Chat.Giveaway.Info.OtherChannels#other" = "**%@** other listed channels"; +"Chat.Giveaway.Info.OtherChannelsAndGroups#one" = "**%@** other listed channel and group"; +"Chat.Giveaway.Info.OtherChannelsAndGroups#other" = "**%@** other listed channels and groups"; +"Chat.Giveaway.Info.OtherGroups#one" = "**%@** other listed group"; +"Chat.Giveaway.Info.OtherGroups#other" = "**%@** other listed groups"; +"Chat.Giveaway.Info.OtherGroupsAndChannels#one" = "**%@** other listed group and channel"; +"Chat.Giveaway.Info.OtherGroupsAndChannels#other" = "**%@** other listed groups and channels"; +"Chat.Giveaway.Info.Participating" = "You are participating in this giveaway because you joined the channel **%1$@**."; +"Chat.Giveaway.Info.ParticipatingMany" = "You are participating in this giveaway because you joined the channel **%1$@** (and %2$@)."; +"Chat.Giveaway.Info.RandomSubscribers#one" = "**%@** random subscriber"; +"Chat.Giveaway.Info.RandomSubscribers#other" = "**%@** random subscribers"; +"Chat.Giveaway.Info.RandomUsers#one" = "**%@** random user"; +"Chat.Giveaway.Info.RandomUsers#other" = "**%@** random users"; +"Chat.Giveaway.Info.Refunded" = "The channel canceled the prizes by refunding the payment for them."; +"Chat.Giveaway.Info.Stars.EndedIntro" = "The giveaway was sponsored by the admins of **%1$@**, who acquired %2$@ for its followers."; +"Chat.Giveaway.Info.Stars.Group.EndedIntro" = "The giveaway was sponsored by the admins of **%1$@**, who acquired %2$@ for its members."; +"Chat.Giveaway.Info.Stars.Group.OngoingIntro" = "The giveaway is sponsored by the admins of **%1$@**, who acquired %2$@ for its members."; +"Chat.Giveaway.Info.Stars.OngoingIntro" = "The giveaway is sponsored by the admins of **%1$@**, who acquired %2$@ for its followers."; +"Chat.Giveaway.Info.Stars.Stars#one" = "**%@ Star**"; +"Chat.Giveaway.Info.Stars.Stars#other" = "**%@ Stars**"; +"Chat.Giveaway.Info.Subscriptions#one" = "**%@ Telegram Premium** subscription"; +"Chat.Giveaway.Info.Subscriptions#other" = "**%@ Telegram Premium** subscriptions"; +"Chat.Giveaway.Info.Title" = "About This Giveaway"; +"Chat.Giveaway.Info.ViewPrize" = "View My Prize"; +"Chat.Giveaway.Info.Won" = "You won a prize in this giveaway. %@"; +"Chat.Giveaway.Message.CountriesDelimiter" = ", "; +"Chat.Giveaway.Message.CountriesFrom" = "from %@"; +"Chat.Giveaway.Message.CountriesLastDelimiter" = " and "; +"Chat.Giveaway.Message.CustomPrizeQuantity#one" = "%@"; +"Chat.Giveaway.Message.CustomPrizeQuantity#other" = "%@"; +"Chat.Giveaway.Message.DateTitle" = "Winner Selection Date"; +"Chat.Giveaway.Message.Group.Participants" = "All members of this group:"; +"Chat.Giveaway.Message.Group.ParticipantsMany" = "All members of the groups below:"; +"Chat.Giveaway.Message.Group.ParticipantsNew" = "All users who join this group after this date:"; +"Chat.Giveaway.Message.Group.ParticipantsNewMany" = "All users who join the groups below after this date:"; +"Chat.Giveaway.Message.LearnMore" = "LEARN MORE"; +"Chat.Giveaway.Message.Months#one" = "**%@** month"; +"Chat.Giveaway.Message.Months#other" = "**%@** months"; +"Chat.Giveaway.Message.Participants" = "All subscribers of this channel:"; +"Chat.Giveaway.Message.ParticipantsMany" = "All subscribers of the channels below:"; +"Chat.Giveaway.Message.ParticipantsNew" = "All users who join this channel after this announcement:"; +"Chat.Giveaway.Message.ParticipantsNewMany" = "All users who join the channels below after this announcement:"; +"Chat.Giveaway.Message.ParticipantsTitle" = "Participants"; +"Chat.Giveaway.Message.PrizeText" = "%1$@ for %2$@."; +"Chat.Giveaway.Message.PrizeTitle" = "Giveaway Prizes"; +"Chat.Giveaway.Message.Stars.PrizeText" = "%1$@ will be distributed %2$@."; +"Chat.Giveaway.Message.Stars.Stars#one" = "**%@** Stars"; +"Chat.Giveaway.Message.Stars.Stars#other" = "**%@** Stars"; +"Chat.Giveaway.Message.Stars.Winners#one" = "to **%@** winner"; +"Chat.Giveaway.Message.Stars.Winners#other" = "among **%@** winners"; +"Chat.Giveaway.Message.Subscriptions#one" = "**%@** Telegram Premium Subscription"; +"Chat.Giveaway.Message.Subscriptions#other" = "**%@** Telegram Premium Subscriptions"; +"Chat.Giveaway.Message.WinnersInfo.Many" = "All winners received gift links in private messages."; +"Chat.Giveaway.Message.WinnersInfo.One" = "The winner received their gift link in a private message."; +"Chat.Giveaway.Message.WinnersInfo.Stars#one" = "**%@ Star**"; +"Chat.Giveaway.Message.WinnersInfo.Stars#other" = "**%@ Stars**"; +"Chat.Giveaway.Message.WinnersInfo.Stars.Many" = "All winners received a total of %@."; +"Chat.Giveaway.Message.WinnersInfo.Stars.One" = "The winner received %@."; +"Chat.Giveaway.Message.WinnersMore#one" = "and %@ more!"; +"Chat.Giveaway.Message.WinnersMore#other" = "and %@ more!"; +"Chat.Giveaway.Message.WinnersSelectedText#one" = "**%@** winner of the [Giveaway]() was randomly selected by Telegram."; +"Chat.Giveaway.Message.WinnersSelectedText#other" = "**%@** winners of the [Giveaway]() were randomly selected by Telegram."; +"Chat.Giveaway.Message.WinnersSelectedTitle.Many" = "Winners Selected!"; +"Chat.Giveaway.Message.WinnersSelectedTitle.One" = "Winner Selected!"; +"Chat.Giveaway.Message.WinnersTitle.Many" = "Winners"; +"Chat.Giveaway.Message.WinnersTitle.One" = "Winner"; +"Chat.Giveaway.Message.With" = "with"; +"Chat.Giveaway.Message.WithSubscriptions#one" = "**%@** Telegram Premium Subscription"; +"Chat.Giveaway.Message.WithSubscriptions#other" = "**%@** Telegram Premium Subscriptions"; +"Chat.Giveaway.Toast.AlmostOver" = "The giveaway is almost over."; +"Chat.Giveaway.Toast.Ended" = "This giveaway has ended."; +"Chat.Giveaway.Toast.LearnMore" = "Learn More"; +"Chat.Giveaway.Toast.NotAllowed" = "You can't participate in this giveaway."; +"Chat.Giveaway.Toast.NotQualified" = "You are not qualified for this giveaway yet."; +"Chat.Giveaway.Toast.Participating" = "You are participating in this giveaway."; +"Chat.GroupEmojiTooltip" = "All members of this group can\nuse the # **%@** pack"; +"Chat.GuestChatMessageTooltip" = "This bot can't read the chat – only the message where it was mentioned."; +"Chat.HashtagSuggestion.UseGeneric.Text" = "searches posts from all channels"; +"Chat.HashtagSuggestion.UseGeneric.Title" = "Use %@"; +"Chat.HashtagSuggestion.UseLocal.Channel.Text" = "searches only posts from this channel"; +"Chat.HashtagSuggestion.UseLocal.Group.Text" = "searches only posts from this group"; +"Chat.HashtagSuggestion.UseLocal.Title" = "Use %@"; +"Chat.InlineTopicMenu.AllTab" = "All"; +"Chat.InlineTopicMenu.Edit" = "Edit"; +"Chat.InlineTopicMenu.NewForumThreadTab" = "New Chat"; +"Chat.InlineTopicMenu.Reorder" = "Reorder"; +"Chat.InlineTopicMenu.TabAll" = "All"; +"Chat.InputPlaceholderMessageInTopic" = "Message in %1$@"; +"Chat.InputPlaceholderReplyInTopic" = "Reply in %1$@"; +"Chat.InputTextPaidCommentPlaceholder" = "Comment for %@"; +"Chat.InputTextPaidMessagePlaceholder" = "Message for %@"; +"Chat.JoinedGroup.AddTag" = "Add Tag"; +"Chat.JoinedGroup.Text" = "You joined the group"; +"Chat.JumpToDate" = "Jump to Date"; +"Chat.ManagedBot.CreatedText" = "Tap **Start** below to test your new chatbot. Its behavior is defined by **%@**."; +"Chat.ManagedBot.CreatedTitle" = "**%@** is ready!"; +"Chat.Message.TopicAuthorBadge" = "Topic Creator"; +"Chat.MessageContextMenu.NonPremiumTagsTitle" = "Organize your Saved Messages with tags for quicker access. [Learn more...]()"; +"Chat.MessageContextMenu.Remove" = "Remove"; +"Chat.MessageContinueLastThread" = "Continue Last Thread"; +"Chat.MessageEffectMenu.SectionMessageEffects" = "Message Effects"; +"Chat.MessageEffectMenu.TitleAddEffect" = "Add an animated effect"; +"Chat.MessageForwardInfo.ExpiredStoryHeader" = "Expired story from"; +"Chat.MessageForwardInfo.MessageHeader" = "Forwarded from"; +"Chat.MessageForwardInfo.StoryHeader" = "Forwarded story from"; +"Chat.MessageForwardInfo.UnavailableStoryHeader" = "Expired story from"; +"Chat.MessageHeaderBotNewThread" = "New Thread"; +"Chat.MessageRangeDeleted.ForBothSides#one" = "Messages for 1 day deleted for both sides."; +"Chat.MessageRangeDeleted.ForBothSides#other" = "Messages for %@ days deleted for both sides."; +"Chat.MessageRangeDeleted.ForMe#one" = "Messages for 1 day deleted."; +"Chat.MessageRangeDeleted.ForMe#other" = "Messages for %@ days deleted."; +"Chat.MessageSeenTimestamp.Date" = "%@"; +"Chat.MessageSeenTimestamp.TodayAt" = "today at %@"; +"Chat.MessageSeenTimestamp.YesterdayAt" = "yesterday at %@"; +"Chat.MessageTooltipVideoProcessing" = "Processing video may take a few minutes."; +"Chat.MessagesDeletedToast.Text#one" = "Message Deleted"; +"Chat.MessagesDeletedToast.Text#other" = "%d Messages Deleted"; +"Chat.MessagesUnpinned#one" = "Message unpinned."; +"Chat.MessagesUnpinned#other" = "%@ messages unpinned."; +"Chat.MessagingRestrictedPlaceholder" = "%@ only accepts messages from Premium users."; +"Chat.MessagingRestrictedPlaceholderAction" = "Learn more..."; +"Chat.Monoforum.Subtitle" = "Direct messages"; +"Chat.MultipleTextMessagesDisabled" = "Slow Mode is enabled. You can't send multiple messages at once."; +"Chat.MultipleTypingMore" = "%@ and %@ others"; +"Chat.MultipleTypingPair" = "%@ and %@"; +"Chat.NavigationNoChannels" = "You have no unread channels"; +"Chat.NavigationNoTopics" = "You have no unread topics"; +"Chat.NewThreadInfo.Text" = "Type any message to\ncreate a new thread."; +"Chat.NewThreadInfo.Title" = "New Thread"; +"Chat.NextChannelArchivedSwipeAction" = "Release to go to archived channels"; +"Chat.NextChannelArchivedSwipeProgress" = "Pull up to go to archived channels"; +"Chat.NextChannelFolderSwipeAction" = "Release to go to the %@ folder"; +"Chat.NextChannelFolderSwipeActionV2" = "Release to go to the {folder} folder"; +"Chat.NextChannelFolderSwipeProgress" = "Pull up to go to the %@ folder"; +"Chat.NextChannelFolderSwipeProgressV2" = "Swipe up to go to the {folder} folder"; +"Chat.NextChannelSameLocationSwipeAction" = "Release to go to the next unread channel"; +"Chat.NextChannelSameLocationSwipeProgress" = "Pull up to go to the next unread channel"; +"Chat.NextChannelUnarchivedSwipeAction" = "Release to go to unarchived channels"; +"Chat.NextChannelUnarchivedSwipeProgress" = "Pull up to go to unarchived channels"; +"Chat.NextSuggestedChannelSwipeAction" = "Release to go to the next channel"; +"Chat.NextSuggestedChannelSwipeProgress" = "Pull up to go to the next channel"; +"Chat.NextUnreadTopicSwipeAction" = "Release to go to the next topic"; +"Chat.NextUnreadTopicSwipeProgress" = "Pull up to go to the next topic"; +"Chat.NonContactUser.AnonymousNumber" = "Anonymous"; +"Chat.NonContactUser.Disclaimer" = "Not an official account"; +"Chat.NonContactUser.Groups" = "Shared Groups"; +"Chat.NonContactUser.GroupsCount#one" = "%@ group"; +"Chat.NonContactUser.GroupsCount#other" = "%@ groups"; +"Chat.NonContactUser.GroupsNone" = "None"; +"Chat.NonContactUser.PhoneNumber" = "Phone Number"; +"Chat.NonContactUser.Registration" = "Registration"; +"Chat.NonContactUser.Subtitle" = "Not a contact"; +"Chat.NonContactUser.UpdatedDays#one" = "%@ day ago"; +"Chat.NonContactUser.UpdatedDays#other" = "%@ days ago"; +"Chat.NonContactUser.UpdatedHours#one" = "%@ hour ago"; +"Chat.NonContactUser.UpdatedHours#other" = "%@ hours ago"; +"Chat.NonContactUser.UpdatedMinutes#one" = "%@ minute ago"; +"Chat.NonContactUser.UpdatedMinutes#other" = "%@ minutes ago"; +"Chat.NonContactUser.UpdatedName" = "User updated name %1$@"; +"Chat.NonContactUser.UpdatedPhoto" = "User updated photo %1$@"; +"Chat.OpenStory" = "VIEW STORY"; +"Chat.OutgoingContextMixedReactionCount" = "%1$@/%2$@ reacted"; +"Chat.OutgoingContextReactionCount#one" = "1 reacted"; +"Chat.OutgoingContextReactionCount#other" = "%@ reacted"; +"Chat.PaidMedia.Purchased" = "Purchased"; +"Chat.PaidMedia.UnlockMedia" = "Unlock for %@"; +"Chat.PaidMessage.Confirm.DontAskAgain" = "Don't ask again"; +"Chat.PaidMessage.Confirm.Multiple.Text" = "You selected %1$@ who charge Stars for messages. Would you like to pay **%2$@** Stars to send %3$@?"; +"Chat.PaidMessage.Confirm.PayForMessage#one" = "Pay for %@ Message"; +"Chat.PaidMessage.Confirm.PayForMessage#other" = "Pay for %@ Messages"; +"Chat.PaidMessage.Confirm.Single.Text" = "**%1$@** charges **%2$@** per incoming message. Would you like to pay **%3$@** to send %4$@?"; +"Chat.PaidMessage.Confirm.SingleComment.Text" = "**%1$@** charges **%2$@** per comment. Would you like to pay **%3$@** to send %4$@?"; +"Chat.PaidMessage.Confirm.Text.Messages#one" = "**%@** message"; +"Chat.PaidMessage.Confirm.Text.Messages#other" = "**%@** messages"; +"Chat.PaidMessage.Confirm.Text.Stars#one" = "%@ Star"; +"Chat.PaidMessage.Confirm.Text.Stars#other" = "%@ Stars"; +"Chat.PaidMessage.Confirm.Text.Users#one" = "**%@** user"; +"Chat.PaidMessage.Confirm.Text.Users#other" = "**%@** users"; +"Chat.PaidMessage.Confirm.Title" = "Confirm Payment"; +"Chat.PaidMessage.RemoveFee.Refund" = "Refund the **%@** already paid"; +"Chat.PaidMessage.RemoveFee.Refund.Stars#one" = "%@ Star"; +"Chat.PaidMessage.RemoveFee.Refund.Stars#other" = "%@ Stars"; +"Chat.PaidMessage.RemoveFee.Text" = "Are you sure you want to allow **%@** to message you for free?"; +"Chat.PaidMessage.RemoveFee.Title" = "Remove Fee"; +"Chat.PaidMessage.RemoveFee.Yes" = "Yes"; +"Chat.PaidMessage.Sent.Text" = "You paid %@"; +"Chat.PaidMessage.Sent.Text.Stars#one" = "%@ Star"; +"Chat.PaidMessage.Sent.Text.Stars#other" = "%@ Stars"; +"Chat.PaidMessage.Sent.Title#one" = "Message Sent"; +"Chat.PaidMessage.Sent.Title#other" = "%@ Messages Sent"; +"Chat.PaidMessageFee.RemoveFee" = "Remove Fee"; +"Chat.PaidMessageFee.Text" = "%1$@ must pay %2$@ for each message to you."; +"Chat.PanelCustomStatusInfo" = "This account uses %@ as a custom status next to its name. Such emoji statuses are available to all subscribers of Telegram Premium."; +"Chat.PanelCustomStatusShortInfo" = "%@ is a mark for [Premium subscribers >]()"; +"Chat.PanelForumModeReplyText" = "Swipe left on a message to reply"; +"Chat.PanelFrozenAccount.Text" = "Tap to view details"; +"Chat.PanelFrozenAccount.Title" = "Your account is frozen"; +"Chat.PanelHidePinnedMessages" = "Don't Show Pinned Messages"; +"Chat.PanelRestartTopic" = "Reopen Topic"; +"Chat.PanelSetBotPhoto" = "Set Profile Photo"; +"Chat.PanelStatusAuthorHidden" = "Senders of these messages restricted to link\ntheir name when forwarding."; +"Chat.PanelTopicClosedText" = "The topic is closed"; +"Chat.PanelUnpinAllMessages" = "Unpin All Messages"; +"Chat.PauseVideoMessageTooltip" = "Pause to trim or replay."; +"Chat.PauseVoiceMessageTooltip" = "Pause to trim or replay."; +"Chat.PinnedGroupCallTitle" = "Pinned Group Call"; +"Chat.PinnedListPreview.HidePinnedMessages" = "Hide Pinned Messages"; +"Chat.PinnedListPreview.ShowAllMessages" = "Show All Messages"; +"Chat.PinnedListPreview.UnpinAllMessages" = "Unpin All Messages"; +"Chat.PinnedMessagesHiddenText" = "Pinned messages will be shown again if a new message is pinned."; +"Chat.PinnedMessagesHiddenTitle" = "Pinned Messages Hidden"; +"Chat.Placeholder.AwayMessage" = "Add away message..."; +"Chat.Placeholder.BusinessLinkPreset" = "Add a preset message..."; +"Chat.Placeholder.GreetingMessage" = "Add greeting message..."; +"Chat.Placeholder.QuickReply" = "Add quick reply…"; +"Chat.PlaceholderTextNotAllowed" = "Text not allowed"; +"Chat.PlayOnceMesasge.DisableScreenCapture" = "Sorry, you can't play this message while screen recording is active."; +"Chat.PlayOnceMesasgeClose" = "Close"; +"Chat.PlayOnceMesasgeCloseAndDelete" = "Close and Delete"; +"Chat.PlayOnceVideoMessageTooltip" = "This video message can only be played once."; +"Chat.PlayOnceVideoMessageYourTooltip" = "This message will disappear after **%@** plays it once."; +"Chat.PlayOnceVoiceMessageTooltip" = "This voice message can only be played once."; +"Chat.PlayOnceVoiceMessageYourTooltip" = "This message will disappear after **%@** plays it once."; +"Chat.PlayVideoMessageOnceTooltip" = "The recipient will be able to play it only once."; +"Chat.PlayVoiceMessageOnceTooltip" = "The recipient will be able to listen only once."; +"Chat.Poll.ContextMenu.SectionOption" = "Option"; +"Chat.Poll.ContextMenu.SectionPoll" = "Poll"; +"Chat.Poll.ContextMenu.SectionsInfo" = "You're viewing actions for one option.\nYou can switch to actions for the list."; +"Chat.Poll.HiddenResultsTooltip" = "Results will appear after the poll ends"; +"Chat.Poll.RemoveOption" = "Remove"; +"Chat.Poll.ReplyToOption" = "Reply to Option"; +"Chat.Poll.Restriction.Country" = "Only users from %@ can vote."; +"Chat.Poll.Restriction.Country.CountriesDelimiter" = ", "; +"Chat.Poll.Restriction.Country.CountriesLastDelimiter" = " and "; +"Chat.Poll.Restriction.Subscribers" = "Only subscribers of **%@** can vote."; +"Chat.Poll.Restriction.Subscribers.TimeLimit" = "Only subscribers who joined more than **24 hours** ago can vote."; +"Chat.Poll.Restriction.SubscribersCountry" = "Only subscribers of **%@** from %@ can vote."; +"Chat.Poll.RetractOptionVote" = "Retract Vote"; +"Chat.Poll.VoteOption" = "Vote"; +"Chat.PollOptionAddedTimestamp.Date" = "Added by %1$@ %2$@"; +"Chat.PollOptionAddedTimestamp.TodayAt" = "Added by %1$@ today at %2$@"; +"Chat.PollOptionAddedTimestamp.YesterdayAt" = "Added by %1$@ yesterday at %2$@"; +"Chat.PollOptionAddedTimestampYou.Date" = "Added by you %1$@"; +"Chat.PollOptionAddedTimestampYou.TodayAt" = "Added by you today at %1$@"; +"Chat.PollOptionAddedTimestampYou.YesterdayAt" = "Added by you yesterday at %1$@"; +"Chat.PostApproval.DetailStatus.AdminFailedRefunded" = "Suggested post was refunded because the user didn't have enough funds"; +"Chat.PostApproval.DetailStatus.FailedDeleted" = "Suggested post was refunded because the message was deleted"; +"Chat.PostApproval.DetailStatus.PostedPaid" = "**%1$@** received **%2$@** for publishing this post"; +"Chat.PostApproval.DetailStatus.StarsAmount#one" = "# Star"; +"Chat.PostApproval.DetailStatus.StarsAmount#other" = "# Stars"; +"Chat.PostApproval.DetailStatus.TonAmount#one" = "# Gram"; +"Chat.PostApproval.DetailStatus.TonAmount#other" = "# Grams"; +"Chat.PostApproval.DetailStatus.UserFailedRefunded" = "Suggested post was refunded due to insufficient balance"; +"Chat.PostApproval.Message.ActionApprove" = "Approve"; +"Chat.PostApproval.Message.ActionReject" = "Decline"; +"Chat.PostApproval.Message.ActionSuggestChanges" = "Suggest Changes"; +"Chat.PostApproval.Message.AdminAgreementFuture" = "📅 Your post will be automatically published in **%1$@** **%2$@**."; +"Chat.PostApproval.Message.AdminAgreementNoTime" = "📅 Your post was automatically published in **%1$@**."; +"Chat.PostApproval.Message.AdminAgreementPast" = "📅 Your post has been automatically published in **%1$@** **%2$@**."; +"Chat.PostApproval.Message.AdminAgreementPriceStars" = "💰 The user has been charged %1$@.\n\n⌛ **%2$@** will receive the Stars once the post has been live for 24 hours.\n\n🔄 If you remove the post before it has been live for 24 hours, the user's Stars will be refunded."; +"Chat.PostApproval.Message.AdminAgreementPriceTon" = "💰 The user has been charged %1$@.\n\n⌛ **%2$@** will receive the Grams once the post has been live for 24 hours.\n\n🔄 If you remove the post before it has been live for 24 hours, the user's Grams will be refunded."; +"Chat.PostApproval.Message.AdminDeclined" = "You declined the post."; +"Chat.PostApproval.Message.AdminDeclinedComment" = "You declined the post with the comment:\n\n%@"; +"Chat.PostApproval.Message.AdminTitleFailedFunds" = "⚠️ **Transaction failed** because the user didn't have enough Stars."; +"Chat.PostApproval.Message.AdminTitleRejected" = "❌ You rejected the post."; +"Chat.PostApproval.Message.AdminTitleRejectedComment" = "❌ You rejected the message with the comment:"; +"Chat.PostApproval.Message.BuyStars" = "Buy Stars"; +"Chat.PostApproval.Message.TitleApproved" = "🤝 Agreement Reached!"; +"Chat.PostApproval.Message.UserAgreementFuture" = "📅 The post will be automatically published in **%1$@** **%2$@**."; +"Chat.PostApproval.Message.UserAgreementNoTime" = "📅 The post was automatically published in **%1$@**."; +"Chat.PostApproval.Message.UserAgreementPast" = "📅 The post has been automatically published in **%1$@** **%2$@**."; +"Chat.PostApproval.Message.UserAgreementPriceStars" = "💰 You have been charged %1$@.\n\n⌛ **%2$@** will receive the Stars once the post has been live for 24 hours.\n\n🔄 If admins remove the post before it has been live for 24 hours, your Stars will be refunded."; +"Chat.PostApproval.Message.UserAgreementPriceTon" = "💰 You have been charged %1$@.\n\n⌛ **%2$@** will receive the Grams once the post has been live for 24 hours.\n\n🔄 If admins remove the post before it has been live for 24 hours, your Grams will be refunded."; +"Chat.PostApproval.Message.UserTitleFailedFunds" = "⚠️ **Transaction failed** because you didn't have enough Stars."; +"Chat.PostApproval.Message.UserTitleRejected" = "❌ **%@** rejected your post."; +"Chat.PostApproval.Message.UserTitleRejectedComment" = "❌ **%@** rejected your message with the comment:"; +"Chat.PostApproval.Status.AdminApproved" = "The post was approved"; +"Chat.PostApproval.Status.AdminRejected" = "The post was rejected"; +"Chat.PostApproval.Status.UserApproved" = "Your post was approved"; +"Chat.PostApproval.Status.UserRejected" = "Your post was rejected"; +"Chat.PostSuggestion.Approve.Action" = "Publish"; +"Chat.PostSuggestion.Approve.AdminConfirmationPriceStars" = "You will receive %1$@ Stars (%2$@%)\nfor publishing this post. It must remain visible for **24** hours after publication."; +"Chat.PostSuggestion.Approve.AdminConfirmationPriceTon" = "You will receive %1$@ Grams (%2$@%)\nfor publishing this post. It must remain visible for **24** hours after publication."; +"Chat.PostSuggestion.Approve.AdminConfirmationText" = "Do you really want to publish this post from **%@**?"; +"Chat.PostSuggestion.Approve.Title" = "Accept Terms"; +"Chat.PostSuggestion.Approve.UserConfirmationText" = "Do you really want to publish this post?"; +"Chat.PostSuggestion.ApproveTime.AdminConfirmationPriceStars" = "You will receive %1$@ Stars (%2$@%)\nfor publishing this post."; +"Chat.PostSuggestion.ApproveTime.AdminConfirmationPriceTon" = "You will receive %1$@ Grams (%2$@%)\nfor publishing this post."; +"Chat.PostSuggestion.ApproveTime.NoTimeAction" = "Post Now"; +"Chat.PostSuggestion.ApproveTime.Text" = "Set the date and time you want\nthis message to be published."; +"Chat.PostSuggestion.ApproveTime.Title" = "Accept Terms"; +"Chat.PostSuggestion.ChannelChange" = "**%@** suggested changes\nto this post."; +"Chat.PostSuggestion.ChannelChangeP" = "**%@** suggested a new price\nfor this post."; +"Chat.PostSuggestion.ChannelChangePT" = "**%@** suggested a new price and time\nfor this post."; +"Chat.PostSuggestion.ChannelChangePTA" = "**%@** suggested a new price,\ntime and attachments for this post."; +"Chat.PostSuggestion.ChannelChangePTC" = "**%@** suggested a new price,\ntime and contents for this post."; +"Chat.PostSuggestion.ChannelChangePTT" = "**%@** suggested a new price,\ntime and text for this post."; +"Chat.PostSuggestion.ChannelChangeT" = "**%@** suggested a new time\nfor this post."; +"Chat.PostSuggestion.ChannelPost" = "**%@** suggested a post."; +"Chat.PostSuggestion.PriceFree" = "Free"; +"Chat.PostSuggestion.Reject.Placeholder" = "Optional"; +"Chat.PostSuggestion.Reject.Title" = "Comment"; +"Chat.PostSuggestion.SetTime.NoTimeAction" = "Send Anytime"; +"Chat.PostSuggestion.SetTime.Text" = "Set the date and time you want\nyour message to be published."; +"Chat.PostSuggestion.SetTime.Title" = "Time"; +"Chat.PostSuggestion.StarsDisclaimer" = "Transactions in **Stars** may be reversed by the payment provider within **21** days. Only accept Stars from people you trust."; +"Chat.PostSuggestion.Suggest.AdminMinAmountStars.Text" = "You cannot request less than %@ Stars."; +"Chat.PostSuggestion.Suggest.EditDateDescription" = "Select the date and time you want to publish the post."; +"Chat.PostSuggestion.Suggest.InputEditTitle" = "Suggest Changes"; +"Chat.PostSuggestion.Suggest.InputSubtitleAnytime" = "%@ for publishing anytime"; +"Chat.PostSuggestion.Suggest.InputSubtitleEmpty" = "Tap to offer a price for publishing"; +"Chat.PostSuggestion.Suggest.InputTitle" = "Suggest a post below"; +"Chat.PostSuggestion.Suggest.OfferButtonFree" = "Offer for Free"; +"Chat.PostSuggestion.Suggest.OfferButtonPrice" = "Offer %@"; +"Chat.PostSuggestion.Suggest.OfferDateDescription" = "Select the date and time you want the post to be published."; +"Chat.PostSuggestion.Suggest.OfferDescriptionStars" = "Choose how many Stars you want to offer %@ to publish this message."; +"Chat.PostSuggestion.Suggest.OfferDescriptionTon" = "Choose how many Grams you want to offer %@ to publish this message."; +"Chat.PostSuggestion.Suggest.OfferStars" = "Offer Stars"; +"Chat.PostSuggestion.Suggest.OfferTon" = "Offer Grams"; +"Chat.PostSuggestion.Suggest.PricePlaceholder" = "Price"; +"Chat.PostSuggestion.Suggest.PriceSectionStars" = "ENTER A PRICE IN STARS"; +"Chat.PostSuggestion.Suggest.PriceSectionTon" = "ENTER A PRICE IN GRAMS"; +"Chat.PostSuggestion.Suggest.RequestDescriptionStars" = "Choose how many Stars you charge for the message."; +"Chat.PostSuggestion.Suggest.RequestDescriptionTon" = "Choose how many Grams you charge for the message."; +"Chat.PostSuggestion.Suggest.RequestStars" = "Request Stars"; +"Chat.PostSuggestion.Suggest.RequestTon" = "Request Grams"; +"Chat.PostSuggestion.Suggest.TitleCreate" = "Suggest Terms"; +"Chat.PostSuggestion.Suggest.TitleEdit" = "Suggest Changes"; +"Chat.PostSuggestion.Suggest.UpdateButton" = "Update Terms"; +"Chat.PostSuggestion.Suggest.UserMinAmountStars.Text" = "You cannot offer less than %@ Stars."; +"Chat.PostSuggestion.TablePrice" = "Price"; +"Chat.PostSuggestion.TableTime" = "Time"; +"Chat.PostSuggestion.TimeAny" = "Anytime"; +"Chat.PostSuggestion.UserChange" = "You suggested changes\nto this post."; +"Chat.PostSuggestion.UserChangeP" = "You suggested a new price\nfor this post."; +"Chat.PostSuggestion.UserChangePT" = "You suggested a new price and time\nfor this post."; +"Chat.PostSuggestion.UserChangePTA" = "You suggested a new price,\ntime and attachments for this post."; +"Chat.PostSuggestion.UserChangePTC" = "You suggested a new price,\ntime and contents for this post."; +"Chat.PostSuggestion.UserChangePTT" = "You suggested a new price,\ntime and text for this post."; +"Chat.PostSuggestion.UserChangeT" = "You suggested a new time\nfor this post."; +"Chat.PostSuggestion.UserPost" = "You suggested a post."; +"Chat.PremiumReactionToastAction" = "More"; +"Chat.PremiumReactionToastTitle" = "Subscribe to **Telegram Premium** to unlock this reaction."; +"Chat.PrivateMessageEditTimestamp.Date" = "edited %@"; +"Chat.PrivateMessageEditTimestamp.TodayAt" = "edited today at %@"; +"Chat.PrivateMessageEditTimestamp.YesterdayAt" = "edited yesterday at %@"; +"Chat.PrivateMessageSeenTimestamp.Date" = "read %@"; +"Chat.PrivateMessageSeenTimestamp.TodayAt" = "read today at %@"; +"Chat.PrivateMessageSeenTimestamp.YesterdayAt" = "read yesterday at %@"; +"Chat.PrivateMessageSentTimestamp.Date" = "sent %@"; +"Chat.PrivateMessageSentTimestamp.TodayAt" = "sent today at %@"; +"Chat.PrivateMessageSentTimestamp.YesterdayAt" = "sent yesterday at %@"; +"Chat.PsaTooltip.covid" = "This message provides you with a public service announcement in relation to the ongoing COVID-19 pandemic. Learn more about this initiative at https://telegram.org/blog/coronavirus"; +"Chat.PsaTooltip.psa" = "This is an official message from the CEO of Telegram regarding the ongoing conflict."; +"Chat.QuickReply.ServiceHeader1" = "To edit or delete your quick reply, tap and hold on it."; +"Chat.QuickReply.ServiceHeader2" = "To use this quick reply in a chat, type / and select the shortcut from the list."; +"Chat.QuickReplyMediaMessageLimitReachedText#one" = "There can be at most %d message in this chat."; +"Chat.QuickReplyMediaMessageLimitReachedText#other" = "There can be at most %d messages in this chat."; +"Chat.QuickReplyMessageLimitReachedText#one" = "Limit of %d message reached"; +"Chat.QuickReplyMessageLimitReachedText#other" = "Limit of %d messages reached"; +"Chat.ReactionContextMenu.EditTagLabel" = "Edit Name"; +"Chat.ReactionContextMenu.FilterByTag" = "Filter by Tag"; +"Chat.ReactionContextMenu.RemoveTag" = "Remove Tag"; +"Chat.ReactionContextMenu.SetTagLabel" = "Set Name"; +"Chat.ReactionSection.Popular" = "Popular"; +"Chat.ReactionSection.Recent" = "Recently Used"; +"Chat.ReactionSelectionTitleAddTag" = "Tag a message with emojis for quick search"; +"Chat.ReactionSelectionTitleEditTag" = "Edit tags of selected messages"; +"Chat.Reactions.MultiplePremiumTooltip" = "You can send multiple reactions with [Telegram Premium]()."; +"Chat.ReinstatePaidMessages" = "Charge Message Fee"; +"Chat.RemoveWallpaper.Remove" = "Remove"; +"Chat.RemoveWallpaper.Text" = "Are you sure you want to reset the wallpaper?"; +"Chat.RemoveWallpaper.Title" = "Remove Wallpaper"; +"Chat.ReplyExpiredStory" = "Expired story"; +"Chat.ReplyPanel.AccessibilityReplyToMessage" = "Reply to message."; +"Chat.ReplyPanel.AccessibilityReplyToMessageFrom" = "Reply to message. From: %@"; +"Chat.ReplyPanel.AccessibilityReplyToYourMessage" = "Reply to your message."; +"Chat.ReplyPanel.HintReplyOptions" = "Tap here for reply options"; +"Chat.ReplyPanel.HintReplyOptionsShort" = "Tap here for options"; +"Chat.ReplyPanel.ReplyTo" = "Reply to %@"; +"Chat.ReplyPanel.ReplyToPollOption" = "Reply to Poll Option"; +"Chat.ReplyPanel.ReplyToQuoteBy" = "Reply to Quote from %@"; +"Chat.ReplyPanel.ReplyToTodoItem" = "Reply to Checklist Item"; +"Chat.ReplyStory" = "Story"; +"Chat.ReplyStoryPrivateChannel" = "Private story"; +"Chat.RichText.ShowMore" = "Show more"; +"Chat.SaveForNotifications" = "Save for Notifications"; +"Chat.SavedMessagesChatsTooltip" = "Tap to view your Saved Messages organized by type or source"; +"Chat.SavedMessagesModeMenu.ViewAsChats" = "View as Chats"; +"Chat.SavedMessagesModeMenu.ViewAsMessages" = "View as Messages"; +"Chat.SavedMessagesStatusViewAsChats" = "Tap to view as chats"; +"Chat.SavedMessagesTabInfoText" = "Messages you sent to **Saved Messages**"; +"Chat.ScheduledForceSendProcessingVideo.Action" = "Send Anyway"; +"Chat.ScheduledForceSendProcessingVideo.Text" = "This video hasn't been converted and optimized yet. If you send it now, the viewers of the video may experience slow download speed."; +"Chat.ScheduledForceSendProcessingVideo.Title" = "Wait!"; +"Chat.SearchTagsPlaceholder" = "Search messages or tags"; +"Chat.SendAllowedContentPeerText" = "The admins of %1$@ only allow sending %2$@."; +"Chat.SendAllowedContentText" = "The admins of this group only allow sending %1$@."; +"Chat.SendAllowedContentTypeFile" = "files"; +"Chat.SendAllowedContentTypeMusic" = "music"; +"Chat.SendAllowedContentTypePhoto" = "photos"; +"Chat.SendAllowedContentTypeSticker" = "stickers & GIFs"; +"Chat.SendAllowedContentTypeText" = "text messages"; +"Chat.SendAllowedContentTypeVideo" = "videos"; +"Chat.SendAllowedContentTypeVideoMessage" = "video messages"; +"Chat.SendAllowedContentTypeVoiceMessage" = "voice messages"; +"Chat.SendGiftTooltip" = "Tap here to send a gift"; +"Chat.SendMessageMenu.EditMessage" = "Edit Message"; +"Chat.SendMessageMenu.MoveCaptionDown" = "Move Caption Down"; +"Chat.SendMessageMenu.MoveCaptionUp" = "Move Caption Up"; +"Chat.SendMessageMenu.ToastPremiumRequired.Text" = "Subscribe to [Telegram Premium]() to add this animated effect."; +"Chat.SendNotAllowedAudioMessage" = "Sending voice messages is not allowed in this group"; +"Chat.SendNotAllowedFile" = "Sending files is not allowed in this group"; +"Chat.SendNotAllowedMusic" = "Sending music is not allowed in this group"; +"Chat.SendNotAllowedPeerText" = "Sending messages is not allowed in %@"; +"Chat.SendNotAllowedPhoto" = "Sending photos is not allowed in this group"; +"Chat.SendNotAllowedText" = "Sending messages is not allowed in this group."; +"Chat.SendNotAllowedVideo" = "Sending videos is not allowed in this group"; +"Chat.SendNotAllowedVideoMessage" = "Sending video messages is not allowed in this group"; +"Chat.SendReactionRestricted" = "You cannot send reactions in this chat."; +"Chat.SendStarsToBecomeTopInfo" = "Send %@ or more to highlight your profile"; +"Chat.SensitiveContent" = "18+ Content"; +"Chat.SensitiveContentShort" = "18+"; +"Chat.SimilarChannels" = "Similar Channels"; +"Chat.SimilarChannels.Join" = "Join"; +"Chat.SimilarChannels.JoinedChannel" = "You joined channel **%@**."; +"Chat.SimilarChannels.MoreChannels" = "More Channels"; +"Chat.SlowmodeAttachmentLimitReached" = "Slow Mode is enabled. You can't select more items."; +"Chat.SlowmodeSendError" = "Slow Mode is enabled."; +"Chat.SlowmodeTooltip" = "Slow Mode is active. You can send\nyour next message in %@."; +"Chat.SlowmodeTooltipPending" = "Slow Mode is active. You can't send more than one message at once."; +"Chat.SpeedLimitAlert.Download.Text" = "Subscribe to [Telegram Premium]() to increase download speeds %@ times."; +"Chat.SpeedLimitAlert.Download.Title" = "Download speed limited"; +"Chat.SpeedLimitAlert.Upload.Text" = "Subscribe to [Telegram Premium]() to increase upload speeds %@ times."; +"Chat.SpeedLimitAlert.Upload.Title" = "Upload speed limited"; +"Chat.StoryMentionAction" = "View Story"; +"Chat.SubtitleLinkListTip" = "Tap on a link to generate its preview"; +"Chat.SubtitleQuoteSelectionTip" = "You can select a specific part to quote"; +"Chat.TagPlaceholder" = "Tag"; +"Chat.TagSearchHideMessages" = "Hide Other Messages"; +"Chat.TagSearchShowMessages" = "Show Other Messages"; +"Chat.TagUpdated.Added" = "Tag added"; +"Chat.TagUpdated.Edited" = "Tag edited"; +"Chat.TagUpdated.Removed" = "Tag removed"; +"Chat.TagsHeaderPanel.AddTags" = "Add tags"; +"Chat.TagsHeaderPanel.AddTagsSuffix" = "to your Saved Messages"; +"Chat.TagsHeaderPanel.Unlock" = "Unlock"; +"Chat.TapToPlayVideoMessageOnceTooltip" = "Tap to set this message to **Play Once**"; +"Chat.TapToPlayVoiceMessageOnceTooltip" = "Tap to set this message to **Play Once**"; +"Chat.TitleJoinGroupCall" = "Join"; +"Chat.TitleLinkOptions" = "Link Preview Settings"; +"Chat.TitlePinnedMessages#one" = "Pinned Message"; +"Chat.TitlePinnedMessages#other" = "%@ Pinned Messages"; +"Chat.TitleQuoteSelection" = "Reply to Quote"; +"Chat.TitleReply" = "Reply to Message"; +"Chat.TitleVideochatPanel.NotifyScheduledButton" = "Notify Me"; +"Chat.ToastCallLinkExpired.Text" = "This link is no longer active"; +"Chat.ToastImprovingVideo.Text" = "The video will be published after it's optimized for the best viewing experience."; +"Chat.ToastImprovingVideo.Title" = "Improving video..."; +"Chat.ToastMessageTagged.Action" = "View"; +"Chat.ToastMessageTagged.Text" = "Message tagged with %@"; +"Chat.ToastMessagesTagged.Text" = "Messages tagged with %@"; +"Chat.ToastMessagingRestrictedToPremium.Action" = "View"; +"Chat.ToastMessagingRestrictedToPremium.Text" = "**%@** only accepts messages from contacts and **Premium** users."; +"Chat.ToastPhoneNumberCopied" = "Phone number copied to clipboard."; +"Chat.ToastQuoteChatUnavailbalePrivateChannel" = "This quote is from a private channel"; +"Chat.ToastQuoteChatUnavailbalePrivateChat" = "This quote is from a private chat"; +"Chat.ToastQuoteChatUnavailbalePrivateGroup" = "This quote is from a private group"; +"Chat.ToastQuoteNotFound" = "Quote not found"; +"Chat.ToastStarsReactionsDisabled" = "Star Reactions were disabled in %@"; +"Chat.ToastStarsSent.AnonymousTitle#one" = "Star sent anonymously!"; +"Chat.ToastStarsSent.AnonymousTitle#other" = "Stars sent anonymously!"; +"Chat.ToastStarsSent.Text" = "You have reacted with %1$@ %2$@."; +"Chat.ToastStarsSent.TextStarAmount#one" = "star"; +"Chat.ToastStarsSent.TextStarAmount#other" = "stars"; +"Chat.ToastStarsSent.Title#one" = "Star sent!"; +"Chat.ToastStarsSent.Title#other" = "Stars sent!"; +"Chat.ToastStarsSent.TitleChannel#one" = "Star sent as {name}!"; +"Chat.ToastStarsSent.TitleChannel#other" = "Stars sent as {name}!"; +"Chat.ToastSubscribedToScheduledLiveStream.Text" = "You will be notified when the live stream starts."; +"Chat.ToastVideoPublished.Action" = "View"; +"Chat.ToastVideoPublished.Title" = "Video Published"; +"Chat.ToastVoiceMessageDeviceMuted" = "Device is muted."; +"Chat.Todo.CompletionLimited" = "%@ has restricted others from editing this checklist."; +"Chat.Todo.CompletionLimitedForward" = "You can’t make changes to forwarded checklists."; +"Chat.Todo.ContextMenu.AddTask" = "Add a Task"; +"Chat.Todo.ContextMenu.CheckTask" = "Check"; +"Chat.Todo.ContextMenu.DeleteTask" = "Delete Item"; +"Chat.Todo.ContextMenu.EditTask" = "Edit Item"; +"Chat.Todo.ContextMenu.SectionList" = "List"; +"Chat.Todo.ContextMenu.SectionTask" = "Task"; +"Chat.Todo.ContextMenu.SectionsInfo" = "You're viewing actions for one task.\nYou can switch to actions for the list."; +"Chat.Todo.ContextMenu.UncheckTask" = "Uncheck"; +"Chat.Todo.Message.Completed#one" = "%@ of {count} completed"; +"Chat.Todo.Message.Completed#other" = "%@ of {count} completed"; +"Chat.Todo.Message.CompletedBy#one" = "%@ of {count} completed by {name}"; +"Chat.Todo.Message.CompletedBy#other" = "%@ of {count} completed by {name}"; +"Chat.Todo.Message.Title" = "Checklist"; +"Chat.Todo.Message.TitleGroup" = "Group Checklist"; +"Chat.Todo.Message.TitlePersonal" = "%@'s Checklist"; +"Chat.Todo.PremiumRequired" = "Only [Telegram Premium]() subscribers can mark tasks as done."; +"Chat.Todo.ReplyToItem" = "Reply to Item"; +"Chat.TodoItemCompletionTimestamp.Date" = "completed %@"; +"Chat.TodoItemCompletionTimestamp.TodayAt" = "completed today at %@"; +"Chat.TodoItemCompletionTimestamp.YesterdayAt" = "completed yesterday at %@"; +"Chat.TooltipAddTagLabel" = "Tap and hold to add a name to your tag"; +"Chat.TopicIsClosedLabel" = "Topic \"%1$@\" is closed"; +"Chat.TrimVoiceMessageToResume.Proceed" = "Proceed"; +"Chat.TrimVoiceMessageToResume.Text" = "Audio outside that range will be discarded, and recording will start immediately."; +"Chat.TrimVoiceMessageToResume.Title" = "Trim to selected range?"; +"Chat.UnsendMyMessages" = "Unsend My Messages"; +"Chat.UnsendMyMessagesAlertTitle" = "Unsending will also delete messages you sent on %@'s side."; +"Chat.VideoProcessingInfo" = "The video will be published once converted and optimized."; +"Chat.VideoProcessingServiceMessage#one" = "This video will be published once converted and optimized"; +"Chat.VideoProcessingServiceMessage#other" = "These videos will be published once converted and optimized"; +"Chat.ViewAlbum" = "VIEW ALBUM"; +"Chat.ViewCollection" = "VIEW COLLECTION"; +"Chat.ViewGroupCall" = "JOIN GROUP CALL"; +"ChatAdmins.AdminLabel" = "admin"; +"ChatAdmins.AllMembersAreAdmins" = "All Members Are Admins"; +"ChatAdmins.AllMembersAreAdminsOffHelp" = "Only admins can add and remove members, edit name and photo of the group."; +"ChatAdmins.AllMembersAreAdminsOnHelp" = "All members can add new members, edit name and photo of the group."; +"ChatAdmins.Title" = "Chat Admins"; +"ChatContextMenu.EmojiSet#one" = "This message contains emoji from [%@ pack]()."; +"ChatContextMenu.EmojiSet#other" = "This message contains emoji from [%@ packs]()."; +"ChatContextMenu.EmojiSetSingle" = "This message contains\n#[%@]() emoji."; +"ChatContextMenu.MessageViewsPrivacyTip" = "To protect privacy, views are only stored for 7 days."; +"ChatContextMenu.QuoteSelectionTip" = "Hold on a word, then move cursor to select more| text to quote."; +"ChatContextMenu.ReactionEmojiSet#one" = "This message contains reactions from [%@ pack]()."; +"ChatContextMenu.ReactionEmojiSet#other" = "This message contains reactions from [%@ packs]()."; +"ChatContextMenu.ReactionEmojiSetSingle" = "This message contains\n#[%@]() reactions."; +"ChatContextMenu.SingleReactionEmojiSet" = "This reaction is from #[%@]() emoji pack."; +"ChatContextMenu.TextSelectionTip" = "Hold on a word, then move\u00a0the cursor to select more| text to copy."; +"ChatContextMenu.TextSelectionTip2" = "Hold a word, then move cursor to select more| text to copy or quote."; +"ChatImport.CreateGroupAlertImportAction" = "Create and Import"; +"ChatImport.CreateGroupAlertText" = "Do you want to create the group **%@** and import messages from another messaging app?"; +"ChatImport.CreateGroupAlertTitle" = "Create Group and Import Messages"; +"ChatImport.SelectionConfirmationAlertImportAction" = "Import"; +"ChatImport.SelectionConfirmationAlertTitle" = "Import Messages"; +"ChatImport.SelectionConfirmationGroupWithTitle" = "Do you want to import messages from **%1$@** into **%2$@**?\n\nMessages will be imported into the current day but will also include their original timestamps. All members will see the messages."; +"ChatImport.SelectionConfirmationGroupWithoutTitle" = "Do you want to import messages into **%@**?\n\nMessages will be imported into the current day but will also include their original timestamps. All members will see the messages."; +"ChatImport.SelectionConfirmationUserWithTitle" = "Do you want to import messages from **%1$@** into the chat with **%2$@**?\n\nMessages will be imported into the current day but will also include their original timestamps. Both sides will see the messages."; +"ChatImport.SelectionConfirmationUserWithoutTitle" = "Do you want to import messages into the chat with **%@?**\n\nMessages will be imported into the current day but will also include their original timestamps. Both sides will see the messages."; +"ChatImport.SelectionErrorGroupGeneric" = "Sorry, you can't import history to this group."; +"ChatImport.SelectionErrorNotAdmin" = "You must to be an admin in the group to import messages to it."; +"ChatImport.Title" = "Select Chat"; +"ChatImport.UserErrorNotMutual" = "You can only import messages into private chats with users who are mutual contacts."; +"ChatImportActivity.ErrorGeneric" = "An error occurred."; +"ChatImportActivity.ErrorInvalidChatType" = "Wrong type of chat for the messages you are trying to import."; +"ChatImportActivity.ErrorLimitExceeded" = "Daily maximum reached,\nplease come back tomorrow."; +"ChatImportActivity.ErrorNotAdmin" = "You need to be an admin in the group to import messages."; +"ChatImportActivity.ErrorUserBlocked" = "Unable to import messages due to privacy settings."; +"ChatImportActivity.InProgress" = "Please keep this window open\nuntil the import is completed."; +"ChatImportActivity.OpenApp" = "Open Telegram"; +"ChatImportActivity.Retry" = "Retry"; +"ChatImportActivity.Success" = "Chat imported\nsuccessfully."; +"ChatImportActivity.Title" = "Importing Chat"; +"ChatList.AddBirthdayText" = "Let your contacts know when you're celebrating."; +"ChatList.AddBirthdayTitle" = "Add your birthday! 🎂"; +"ChatList.AddChatsToFolder" = "Add Chats"; +"ChatList.AddFolder" = "Add Folder"; +"ChatList.AddPhoto.Text" = "Help your friends spot you easily."; +"ChatList.AddPhoto.Title" = "Add your photo! 📸"; +"ChatList.AddedToFolderTooltip" = "%1$@ has been added to %2$@"; +"ChatList.AddedToFolderTooltipV2" = "{chat} has been added to folder {folder}"; +"ChatList.AlertDeleteFolderText" = "Are you sure you want to delete this folder? This will also deactivate all the invite links created to share this folder."; +"ChatList.AlertDeleteFolderTitle" = "Delete Folder"; +"ChatList.AlertResumeLiveStreamAction" = "Resume"; +"ChatList.AlertResumeLiveStreamText" = "Do you want to resume an existing Live Stream?"; +"ChatList.AlertResumeLiveStreamTitle" = "Resume Live Stream"; +"ChatList.Archive" = "Archive"; +"ChatList.Archive.ContextInfo" = "How Does It Work?"; +"ChatList.Archive.ContextSettings" = "Archive Settings"; +"ChatList.ArchiveAction" = "Archive"; +"ChatList.ArchiveStoryCount#one" = "1 story"; +"ChatList.ArchiveStoryCount#other" = "%d stories"; +"ChatList.ArchivedChatsTitle" = "Archived Chats"; +"ChatList.Auctions.ActiveAuction#one" = "Active Auction"; +"ChatList.Auctions.ActiveAuction#other" = "%@ Active Auctions"; +"ChatList.Auctions.Status.Many.Outbid#one" = "You've been outbid in %@ of them."; +"ChatList.Auctions.Status.Many.Outbid#other" = "You've been outbid in %@ of them."; +"ChatList.Auctions.Status.Many.OutbidAll" = "You've been outbid in all of them."; +"ChatList.Auctions.Status.Many.WinningAll" = "You're winning all of them."; +"ChatList.Auctions.Status.Single.Outbid" = "You've been outbid."; +"ChatList.Auctions.Status.Single.PlaceFirst" = "%@st"; +"ChatList.Auctions.Status.Single.PlaceNTh" = "%@th"; +"ChatList.Auctions.Status.Single.PlaceSecond" = "%@nd"; +"ChatList.Auctions.Status.Single.PlaceThird" = "%@rd"; +"ChatList.Auctions.Status.Single.Winning" = "You're winning (%@ place)."; +"ChatList.Auctions.Status.UpcomingBid" = "You've placed an early bid."; +"ChatList.Auctions.UpcomingAuction" = "Upcoming Auction"; +"ChatList.Auctions.View" = "View"; +"ChatList.AuthorHidden" = "Author Hidden"; +"ChatList.AutoarchiveSuggestion.OpenSettings" = "Go to Settings"; +"ChatList.AutoarchiveSuggestion.Text" = "You are receiving many new chats from users who are not in your Contact List. Do you want to have such chats **automatically muted** and **archived**?"; +"ChatList.AutoarchiveSuggestion.Title" = "Hide new chats?"; +"ChatList.BirthdayInSettingsInfo" = "You can always set your birthday in **Settings**."; +"ChatList.BirthdayMultipleText" = "Send them a Gift."; +"ChatList.BirthdayMultipleTitle#one" = "%@ contact has a **birthday** today! 🎂"; +"ChatList.BirthdayMultipleTitle#other" = "%@ contacts have **birthdays** today! 🎂"; +"ChatList.BirthdaySingleText" = "Send them a Gift."; +"ChatList.BirthdaySingleTitle" = "It's %@'s **birthday** today! 🎂"; +"ChatList.BotConnectionReview.PanelText" = "%1$@ connected to your account from %2$@ (%3$@). Was this you?"; +"ChatList.BotConnectionReview.PanelTitle" = "A bot just got access to your messages!"; +"ChatList.ChannelsSection" = "Channels"; +"ChatList.ChatTypesSection" = "CHAT TYPES"; +"ChatList.ClearChatConfirmation" = "Are you sure you want to delete all\nmessages in the chat with %@?"; +"ChatList.ClearSavedMessagesConfirmation" = "Are you sure you want to permanently delete all your Saved Messages?"; +"ChatList.ClearSearchHistory" = "Do you want to clear your search history?"; +"ChatList.ClearSearchHistory.Confirm" = "Clear"; +"ChatList.CloseAction" = "Close"; +"ChatList.Context.AddToContacts" = "Add to Contacts"; +"ChatList.Context.AddToFolder" = "Add to Folder"; +"ChatList.Context.Archive" = "Archive"; +"ChatList.Context.Back" = "Back"; +"ChatList.Context.CloseTopic" = "Close"; +"ChatList.Context.Delete" = "Delete"; +"ChatList.Context.HideArchive" = "Hide from Chat List"; +"ChatList.Context.JoinChannel" = "Join Channel"; +"ChatList.Context.JoinChat" = "Join Chat"; +"ChatList.Context.MarkAllAsRead" = "Mark All as Read"; +"ChatList.Context.MarkAsRead" = "Mark as Read"; +"ChatList.Context.MarkAsUnread" = "Mark as Unread"; +"ChatList.Context.Mute" = "Mute"; +"ChatList.Context.Pin" = "Pin"; +"ChatList.Context.RemoveFromFolder" = "Remove from Folder"; +"ChatList.Context.RemoveFromRecents" = "Remove from Recent"; +"ChatList.Context.ReopenTopic" = "Reopen"; +"ChatList.Context.Select" = "Select"; +"ChatList.Context.Unarchive" = "Unarchive"; +"ChatList.Context.UnhideArchive" = "Pin in Chat List"; +"ChatList.Context.Unmute" = "Unmute"; +"ChatList.Context.Unpin" = "Unpin"; +"ChatList.ContextMenuBadgeNew" = "NEW"; +"ChatList.ContextMenuShare" = "Share"; +"ChatList.ContextMuteAll" = "Mute All"; +"ChatList.ContextOpenChannel" = "Open Channel"; +"ChatList.ContextOpenGroup" = "Open Group"; +"ChatList.ContextSelectChats" = "Select Chats"; +"ChatList.ContextUnmuteAll" = "Unmute All"; +"ChatList.DeleteAndLeaveGroupConfirmation" = "Are you sure you want to leave and delete %@?"; +"ChatList.DeleteChat" = "Delete Chat"; +"ChatList.DeleteChatConfirmation" = "Permanently delete the chat with %@?"; +"ChatList.DeleteConfirmation#one" = "Delete"; +"ChatList.DeleteConfirmation#other" = "Delete %@ Chats"; +"ChatList.DeleteForAll" = "Delete from both sides"; +"ChatList.DeleteForAllMembers" = "Delete for all members"; +"ChatList.DeleteForAllMembersConfirmationText" = "This will **delete all messages** in this chat for **all participants**."; +"ChatList.DeleteForAllSubscribers" = "Delete for all subscribers"; +"ChatList.DeleteForAllSubscribersConfirmationText" = "This will **delete all messages** in this channel for **all subscribers**."; +"ChatList.DeleteForAllWhenPossible" = "Delete from both sides where possible"; +"ChatList.DeleteForCurrentUser" = "Delete just for me"; +"ChatList.DeleteForEveryone" = "Delete for me and %@"; +"ChatList.DeleteForEveryoneConfirmationAction" = "Delete All"; +"ChatList.DeleteForEveryoneConfirmationText" = "This will **delete all messages** in this chat for **both participants**."; +"ChatList.DeleteForEveryoneConfirmationTitle" = "Warning!"; +"ChatList.DeleteForMe" = "Delete for me"; +"ChatList.DeleteSavedMessagesConfirmation" = "Are you sure you want to delete\nSaved Messages?"; +"ChatList.DeleteSavedMessagesConfirmationAction" = "Delete All"; +"ChatList.DeleteSavedMessagesConfirmationText" = "This will **delete all messages** in this chat."; +"ChatList.DeleteSavedMessagesConfirmationTitle" = "Warning!"; +"ChatList.DeleteSavedPeerConfirmation" = "Are you sure you want to delete saved messages from %@?"; +"ChatList.DeleteSavedPeerMyNotesConfirmation" = "Are you sure you want to delete all messages from %@?"; +"ChatList.DeleteSavedPeerMyNotesConfirmationTitle" = "My Notes"; +"ChatList.DeleteSecretChatConfirmation" = "Are you sure you want to delete the secret chat\nwith %@?"; +"ChatList.DeleteThreadsConfirmation#one" = "Delete Topic"; +"ChatList.DeleteThreadsConfirmation#other" = "Delete %@ Topics"; +"ChatList.DeleteTopicConfirmationAction" = "Delete Topic"; +"ChatList.DeleteTopicConfirmationText" = "This will delete the topic and all its messages"; +"ChatList.DeletedChats#one" = "Deleted 1 chat"; +"ChatList.DeletedChats#other" = "Deleted %@ chats"; +"ChatList.DeletedThreads#one" = "Deleted 1 topic"; +"ChatList.DeletedThreads#other" = "Deleted %@ topics"; +"ChatList.EditFolder" = "Edit Folder"; +"ChatList.EditFolders" = "Edit Folders"; +"ChatList.EmptyChatFilterList" = "This folder is empty."; +"ChatList.EmptyChatList" = "You have no\nconversations yet."; +"ChatList.EmptyChatListEditFilter" = "Edit Folder"; +"ChatList.EmptyChatListFilterText" = "No chats currently belong to this folder."; +"ChatList.EmptyChatListFilterTitle" = "Folder is empty"; +"ChatList.EmptyChatListNewMessage" = "New Message"; +"ChatList.EmptyChatListWithArchive" = "All of your chats are archived."; +"ChatList.EmptyListContactsHeader" = "YOUR CONTACTS ON TELEGRAM"; +"ChatList.EmptyListContactsHeaderHide" = "Hide"; +"ChatList.EmptyListTooltip" = "Send a message or\nstart a group here."; +"ChatList.EmptyResult.SearchInAll" = "Search in All Messages"; +"ChatList.EmptyTopicsCreate" = "Create New Topic"; +"ChatList.EmptyTopicsDescription" = "Older messages from this group have been moved to the \"General\" topic."; +"ChatList.EmptyTopicsShowAsMessages" = "Show as Messages"; +"ChatList.EmptyTopicsTitle" = "No topics here yet"; +"ChatList.FolderAllChats" = "All Chats"; +"ChatList.FrozenAccount.Text" = "Tap to view details and submit an appeal."; +"ChatList.FrozenAccount.Title" = "Your account is frozen"; +"ChatList.GeneralHidden" = "General topic hidden"; +"ChatList.GeneralHiddenInfo" = "Pull down to see the general topic."; +"ChatList.GeneralUnhidden" = "General topic unhidden"; +"ChatList.GeneralUnhiddenInfo" = "Swipe left on the general topic to hide it."; +"ChatList.GenericPsaAlert" = "This provides public service announcements in your chat list."; +"ChatList.GenericPsaLabel" = "PSA"; +"ChatList.GlobalSearch.LimitPlaceholder.Text#one" = "You can make up to\n%@ search query per day."; +"ChatList.GlobalSearch.LimitPlaceholder.Text#other" = "You can make up to\n%@ search queries per day."; +"ChatList.GlobalSearch.LimitPlaceholder.Title" = "Limit Reached"; +"ChatList.GlobalSearch.SearchButton" = "Search"; +"ChatList.GlobalSearch.SearchButtonPaidSubtitle" = "free search unlocks in %@"; +"ChatList.GlobalSearch.SearchButtonPaidTitle" = "Search for * %@"; +"ChatList.GlobalSearch.SearchButtonPremium" = "Subscribe to Premium"; +"ChatList.GlobalSearch.SearchButtonQuery" = "Search {}"; +"ChatList.GlobalSearch.StartPlaceholder.RemainingSubtitle#one" = "%@ free search remaining today."; +"ChatList.GlobalSearch.StartPlaceholder.RemainingSubtitle#other" = "%@ free searches remaining today."; +"ChatList.GlobalSearch.StartPlaceholder.Subtitle" = "Global search is a Premium feature."; +"ChatList.GlobalSearch.StartPlaceholder.Text" = "Type a keyword to search for posts\nfrom public channels."; +"ChatList.GlobalSearch.StartPlaceholder.Title" = "Global Search"; +"ChatList.HeaderImportIntoAnExistingGroup" = "SELECT A CHAT TO IMPORT MESSAGES TO"; +"ChatList.HeaderPublicPosts" = "PUBLIC POSTS"; +"ChatList.HideAction" = "Hide"; +"ChatList.InlineButtonOpenApp" = "OPEN"; +"ChatList.ItemMenuDelete" = "Delete"; +"ChatList.ItemMenuEdit" = "Edit"; +"ChatList.ItemMoreMessagesFormat#one" = "+1 MORE"; +"ChatList.ItemMoreMessagesFormat#other" = "+%d MORE"; +"ChatList.LabelAutodeleteAfter" = "auto-delete after %@"; +"ChatList.LabelAutodeleteDisabled" = "auto-delete disabled"; +"ChatList.LeaveGroupConfirmation" = "Are you sure you want to leave %@?"; +"ChatList.MaxThreadPinsFinalText#one" = "Sorry, you can't pin more than **%@** topic to the top. Unpin some that are currently pinned."; +"ChatList.MaxThreadPinsFinalText#other" = "Sorry, you can't pin more than **%@** topics to the top. Unpin some that are currently pinned."; +"ChatList.MessageFiles#one" = "%@ File"; +"ChatList.MessageFiles#other" = "%@ Files"; +"ChatList.MessageMusic#one" = "%@ Music File"; +"ChatList.MessageMusic#other" = "%@ Music Files"; +"ChatList.MessagePhotos#one" = "1 Photo"; +"ChatList.MessagePhotos#other" = "%@ Photos"; +"ChatList.MessageVideos#one" = "1 Video"; +"ChatList.MessageVideos#other" = "%@ Videos"; +"ChatList.MonoforumEmptyText" = "No messages here yet..."; +"ChatList.MonoforumLabel" = "DIRECT"; +"ChatList.Mute" = "Mute"; +"ChatList.PaidSearchToast.Text#one" = "%@ Star spent on extra search."; +"ChatList.PaidSearchToast.Text#other" = "%@ Stars spent on extra search."; +"ChatList.PanelNewChatsAvailable#one" = "1 New Chat Available"; +"ChatList.PanelNewChatsAvailable#other" = "%d New Chats Available"; +"ChatList.PeerTypeBot" = "bot"; +"ChatList.PeerTypeChannel" = "channel"; +"ChatList.PeerTypeContact" = "contact"; +"ChatList.PeerTypeGroup" = "group"; +"ChatList.PeerTypeNonContact" = "user"; +"ChatList.PeerTypeNonContactUser" = "non-contact"; +"ChatList.PremiumAnnualDiscountText" = "Sign up for the annual payment plan for Telegram Premium now to get the discount."; +"ChatList.PremiumAnnualDiscountTitle" = "Telegram Premium with a %@ discount"; +"ChatList.PremiumAnnualUpgradeText" = "Upgrade to the annual payment plan for Telegram Premium to get the discount."; +"ChatList.PremiumAnnualUpgradeTitle" = "Save up to %@ on your subscription"; +"ChatList.PremiumGiftInSettingsInfo" = "You can send gifts to friends later from **Settings**."; +"ChatList.PremiumGraceText" = "Your exclusive benefits are about to expire."; +"ChatList.PremiumGraceTitle" = "⚠️ Don't lose access to Telegram Premium!"; +"ChatList.PremiumRestoreDiscountText" = "Your Telegram Premium has recently expired. Tap here to extend it."; +"ChatList.PremiumRestoreDiscountTitle" = "Get Premium back with up to %@ off"; +"ChatList.PremiumXmasGiftText" = "Send limited-edition collectibles to friends!"; +"ChatList.PremiumXmasGiftTitle" = "👑 Snoop Dogg Gift Drop 🎁"; +"ChatList.PsaAlert.covid" = "This message provides you with a public service announcement in relation to the ongoing COVID-19 pandemic. Learn more about this initiative at https://telegram.org/blog/coronavirus"; +"ChatList.PsaAlert.psa" = "This is an official message from the CEO of Telegram regarding the ongoing conflict."; +"ChatList.PsaLabel.covid" = "COVID-19"; +"ChatList.PsaLabel.psa" = "Announcement"; +"ChatList.Read" = "Read"; +"ChatList.ReadAll" = "Read All"; +"ChatList.RemoveFolder" = "Remove"; +"ChatList.RemoveFolderAction" = "Remove"; +"ChatList.RemoveFolderConfirmation" = "This will remove the folder, your chats will not be deleted."; +"ChatList.RemoveFolderConfirmationTitle" = "Remove %@?"; +"ChatList.RemovedFromFolderTooltip" = "%1$@ has been removed from %2$@"; +"ChatList.RemovedFromFolderTooltipV2" = "{chat} has been removed from folder {folder}"; +"ChatList.ReorderTabs" = "Reorder Tabs"; +"ChatList.Search.Ad" = "Ad"; +"ChatList.Search.Apps.Empty.Text" = "No Apps Found"; +"ChatList.Search.FilterApps" = "Apps"; +"ChatList.Search.FilterChannels" = "Channels"; +"ChatList.Search.FilterChats" = "Chats"; +"ChatList.Search.FilterDownloads" = "Downloads"; +"ChatList.Search.FilterFiles" = "Files"; +"ChatList.Search.FilterGlobalPosts" = "Posts"; +"ChatList.Search.FilterLinks" = "Links"; +"ChatList.Search.FilterMedia" = "Media"; +"ChatList.Search.FilterMusic" = "Music"; +"ChatList.Search.FilterPublicPosts" = "Public Posts"; +"ChatList.Search.FilterTopics" = "Topics"; +"ChatList.Search.FilterVoice" = "Voice"; +"ChatList.Search.Messages.AllChats" = "From All Chats"; +"ChatList.Search.Messages.Channels" = "From Channels"; +"ChatList.Search.Messages.GroupChats" = "From Group Chats"; +"ChatList.Search.Messages.Menu.AllChats" = "All Chats"; +"ChatList.Search.Messages.Menu.Channels" = "Channels"; +"ChatList.Search.Messages.Menu.GroupChats" = "Group Chats"; +"ChatList.Search.Messages.Menu.PrivateChats" = "Private Chats"; +"ChatList.Search.Messages.PrivateChats" = "From Private Chats"; +"ChatList.Search.NoResults" = "No Results"; +"ChatList.Search.NoResultsDescription" = "There were no results.\nTry a new search."; +"ChatList.Search.NoResultsFilter" = "Nothing Yet"; +"ChatList.Search.NoResultsFitlerFiles" = "Files from all your chats will be shown here."; +"ChatList.Search.NoResultsFitlerLinks" = "Links from all your chats will be shown here."; +"ChatList.Search.NoResultsFitlerMedia" = "Photos and videos from all your chats will be shown here."; +"ChatList.Search.NoResultsFitlerMusic" = "Music from all your chats will be shown here."; +"ChatList.Search.NoResultsFitlerVoice" = "Voice and video messages from all your chats will be shown here."; +"ChatList.Search.NoResultsQueryDescription" = "There were no results for \"%@\".\nTry a new search."; +"ChatList.Search.Open" = "OPEN"; +"ChatList.Search.RecommendedChannelsEmpty.Text" = "You are not currently subscribed to any channel."; +"ChatList.Search.RecommendedChannelsEmpty.Title" = "No Channels Yet..."; +"ChatList.Search.SectionActionShowLess" = "Show Less"; +"ChatList.Search.SectionActionShowMore" = "Show More"; +"ChatList.Search.SectionApps" = "APPS"; +"ChatList.Search.SectionLocalChannels" = "CHANNELS YOU JOINED"; +"ChatList.Search.SectionPopularApps" = "GROSSING APPS"; +"ChatList.Search.SectionRecentApps" = "APPS YOU USE"; +"ChatList.Search.SectionRecommendedChannels" = "SIMILAR CHANNELS"; +"ChatList.Search.ShowLess" = "Show Less"; +"ChatList.Search.ShowMore" = "Show More"; +"ChatList.Search.TopAppsInfo" = "Which apps are included here? [Learn >]()"; +"ChatList.SelectedChats#one" = "%@ Chat Selected"; +"ChatList.SelectedChats#other" = "%@ Chats Selected"; +"ChatList.SelectedTopics#one" = "%@ Topic Selected"; +"ChatList.SelectedTopics#other" = "%@ Topics Selected"; +"ChatList.SessionReview.ConfirmToastText" = "You can check the list of your active logins in [Settings > Devices]()."; +"ChatList.SessionReview.ConfirmToastTitle" = "New Login Allowed"; +"ChatList.SessionReview.PanelConfirm" = "Yes, it's me"; +"ChatList.SessionReview.PanelReject" = "No, it's not me!"; +"ChatList.SessionReview.PanelText" = "We detected a new login to your account from %1$@, %2$@. Is it you?"; +"ChatList.SessionReview.PanelTitle" = "Someone just got access to your messages!"; +"ChatList.StartAction" = "Start"; +"ChatList.StartMessaging" = "Select a chat to start messaging"; +"ChatList.StorageHintText" = "Clear storage space on your iPhone"; +"ChatList.StorageHintTitle" = "Free up to %@"; +"ChatList.StoryFeedTooltip" = "Tap above to view stories\nfrom %@"; +"ChatList.StoryFeedTooltipUsers" = "Tap above to view stories from %@"; +"ChatList.SubscriptionsLowBalance.Multiple.Text" = "Insufficient funds to cover your subscriptions."; +"ChatList.SubscriptionsLowBalance.Multiple.Title" = "%@ for your subscriptions"; +"ChatList.SubscriptionsLowBalance.Single.Text" = "Insufficient funds to cover your subscription."; +"ChatList.SubscriptionsLowBalance.Single.Title" = "%1$@ for %2$@"; +"ChatList.SubscriptionsLowBalance.Stars#one" = "%@ Star needed"; +"ChatList.SubscriptionsLowBalance.Stars#other" = "%@ Stars needed"; +"ChatList.TabIconFoldersTooltipEmptyFolders" = "Hold to organize your chats with folders."; +"ChatList.TabIconFoldersTooltipNonEmptyFolders" = "Hold on 'Chats' to edit folders and switch between views."; +"ChatList.Tabs.All" = "All"; +"ChatList.Tabs.AllChats" = "All Chats"; +"ChatList.ThreadHideAction" = "Hide"; +"ChatList.ThreadUnhideAction" = "Unhide"; +"ChatList.ToastFolderMuted" = "All chats in **%@** are now muted"; +"ChatList.ToastFolderMutedV2" = "All chats in {folder} are now muted"; +"ChatList.ToastFolderUnmuted" = "All chats in **%@** are now unmuted"; +"ChatList.ToastFolderUnmutedV2" = "All chats in {folder} are now unmuted"; +"ChatList.UnarchiveAction" = "Unarchive"; +"ChatList.UndoArchiveHiddenText" = "Pull down to see the archive."; +"ChatList.UndoArchiveHiddenTitle" = "Archive hidden"; +"ChatList.UndoArchiveMultipleTitle" = "Chats archived."; +"ChatList.UndoArchiveRevealedText" = "Swipe left on the archive to hide it."; +"ChatList.UndoArchiveRevealedTitle" = "Archive pinned"; +"ChatList.UndoArchiveText1" = "Swipe left on the archive to hide it."; +"ChatList.UndoArchiveTitle" = "Chat archived."; +"ChatList.UnhideAction" = "Pin"; +"ChatList.Unmute" = "Unmute"; +"ChatList.UserReacted" = "Reacted %@ to your message"; +"ChatListFilter.AddChatsSearchPlaceholder" = "Search Chats"; +"ChatListFilter.AddChatsTitle" = "Add Chats..."; +"ChatListFilter.AlertCreateFolderBeforeSharingText" = "Please finish creating this folder to share it."; +"ChatListFilter.CreateLink" = "Create a new Link"; +"ChatListFilter.CreateLinkErrorSomeoneHasChannelLimit" = "One of the groups in this folder can’t be added because one of its admins has too many groups and channels."; +"ChatListFilter.CreateLinkNew" = "Create an Invite Link"; +"ChatListFilter.CreateLinkUnknownError" = "An error occurred while creating the invite link"; +"ChatListFilter.ErrorShareInvalidFolder" = "You can’t share folders which include or exclude specific chat types like 'Groups', 'Contacts', etc."; +"ChatListFilter.ExcludeChatsAction" = "Add Chats to Exclude"; +"ChatListFilter.LinkActionDelete" = "Delete"; +"ChatListFilter.LinkLabelChatCount#one" = "includes 1 chat"; +"ChatListFilter.LinkLabelChatCount#other" = "includes %d chats"; +"ChatListFilter.LinkListInfo" = "Create more links to set up different access levels for different people."; +"ChatListFilter.LinkListInfoNew" = "Share access to some of this folder's groups and channels with others."; +"ChatListFilter.NameDisableAnimations" = "Disable Animations"; +"ChatListFilter.NameEnableAnimations" = "Enable Animations"; +"ChatListFilter.SaveAlertActionSave" = "Save"; +"ChatListFilter.SectionShare" = "SHARE FOLDER"; +"ChatListFilter.ShowMoreChats#one" = "Show %@ More Chat"; +"ChatListFilter.ShowMoreChats#other" = "Show %@ More Chats"; +"ChatListFilter.TagLabelNoTag" = "NO TAG"; +"ChatListFilter.TagLabelPremiumExpired" = "PREMIUM EXPIRED"; +"ChatListFilter.TagPremiumRequiredTooltipText" = "Subscribe to **Telegram Premium** to select folder color."; +"ChatListFilter.TagSectionFooter" = "This color will be used for the folder's tag in the chat list"; +"ChatListFilter.TagSectionTitle" = "FOLDER COLOR"; +"ChatListFilter.ToastChatsAddedText" = "This will not affect the chat list of the links of this folder."; +"ChatListFilter.ToastChatsAddedTitle#one" = "Сhat added to folder"; +"ChatListFilter.ToastChatsAddedTitle#other" = "%d chats added to folder"; +"ChatListFilter.ToastChatsRemovedText" = "This will not affect the chat list of the links of this folder."; +"ChatListFilter.ToastChatsRemovedTitle#one" = "Chat removed from folder"; +"ChatListFilter.ToastChatsRemovedTitle#other" = "%d chats removed from folder"; +"ChatListFilterList.CreateFolder" = "Create a Folder"; +"ChatListFilterList.ShowTags" = "Show Folder Tags"; +"ChatListFilterList.ShowTagsFooter" = "Display folder names for each chat in the chat list."; +"ChatListFolder.AddChats" = "Add Chats"; +"ChatListFolder.CategoryArchived" = "Archived"; +"ChatListFolder.CategoryBots" = "Bots"; +"ChatListFolder.CategoryChannels" = "Channels"; +"ChatListFolder.CategoryContacts" = "Contacts"; +"ChatListFolder.CategoryGroups" = "Groups"; +"ChatListFolder.CategoryMuted" = "Muted"; +"ChatListFolder.CategoryNonContacts" = "Non-Contacts"; +"ChatListFolder.CategoryRead" = "Read"; +"ChatListFolder.DiscardCancel" = "No"; +"ChatListFolder.DiscardConfirmation" = "You have edited this folder. Discard changes?"; +"ChatListFolder.DiscardDiscard" = "Discard"; +"ChatListFolder.ExcludeChatsTitle" = "Exclude Chats"; +"ChatListFolder.ExcludeSectionInfo" = "Choose chats or types of chats that will not appear in this folder."; +"ChatListFolder.ExcludedSectionHeader" = "EXCLUDED CHATS"; +"ChatListFolder.IncludeChatsTitle" = "Include Chats"; +"ChatListFolder.IncludeSectionInfo" = "Choose chats or types of chats that will appear in this folder."; +"ChatListFolder.IncludedSectionHeader" = "INCLUDED CHATS"; +"ChatListFolder.NameBots" = "Bots"; +"ChatListFolder.NameChannels" = "Channels"; +"ChatListFolder.NameContacts" = "Contacts"; +"ChatListFolder.NameGroups" = "Groups"; +"ChatListFolder.NameNonContacts" = "Non-Contacts"; +"ChatListFolder.NameNonMuted" = "Not Muted"; +"ChatListFolder.NamePlaceholder" = "Folder Name"; +"ChatListFolder.NameSectionHeader" = "FOLDER NAME"; +"ChatListFolder.NameUnread" = "Unread"; +"ChatListFolder.TitleCreate" = "New Folder"; +"ChatListFolder.TitleEdit" = "Edit Folder"; +"ChatListFolderSettings.AddRecommended" = "ADD"; +"ChatListFolderSettings.EditFoldersInfo" = "Tap 'Edit' to change the order or delete folders."; +"ChatListFolderSettings.FoldersSection" = "FOLDERS"; +"ChatListFolderSettings.Info" = "Create folders for different groups of chats and quickly switch between them."; +"ChatListFolderSettings.NewFolder" = "Create New Folder"; +"ChatListFolderSettings.RecommendedFoldersSection" = "RECOMMENDED FOLDERS"; +"ChatListFolderSettings.RecommendedNewFolder" = "Add Custom Folder"; +"ChatListFolderSettings.SubscribeToMoveAll" = "Subscribe to **Telegram Premium** to move the \"All Chats\" folder."; +"ChatListFolderSettings.SubscribeToMoveAllAction" = "More"; +"ChatListFolderSettings.Title" = "Folders"; +"ChatSearch.ResultsTooltip" = "Tap to view as a list."; +"ChatSearch.SearchPlaceholder" = "Search"; +"ChatSettings.Appearance" = "APPEARANCE"; +"ChatSettings.AutoDownloadDocuments" = "Files"; +"ChatSettings.AutoDownloadEnabled" = "Auto-Download Media"; +"ChatSettings.AutoDownloadPhotos" = "Photos"; +"ChatSettings.AutoDownloadReset" = "Reset Auto-Download Settings"; +"ChatSettings.AutoDownloadSettings.Delimeter" = ", "; +"ChatSettings.AutoDownloadSettings.OffForAll" = "Disabled"; +"ChatSettings.AutoDownloadSettings.TypeFile" = "Files (%@)"; +"ChatSettings.AutoDownloadSettings.TypeMedia" = "Media (%@)"; +"ChatSettings.AutoDownloadSettings.TypePhoto" = "Photos"; +"ChatSettings.AutoDownloadSettings.TypeVideo" = "Videos (%@)"; +"ChatSettings.AutoDownloadTitle" = "AUTOMATIC MEDIA DOWNLOAD"; +"ChatSettings.AutoDownloadUsingCellular" = "Using Cellular"; +"ChatSettings.AutoDownloadUsingWiFi" = "Using Wi-Fi"; +"ChatSettings.AutoDownloadVideoMessages" = "Video Messages"; +"ChatSettings.AutoDownloadVideos" = "Videos"; +"ChatSettings.AutoDownloadVoiceMessages" = "Voice Messages"; +"ChatSettings.AutoPlayAnimations" = "Autoplay GIFs"; +"ChatSettings.AutoPlayGifs" = "GIFs"; +"ChatSettings.AutoPlayTitle" = "AUTOPLAY MEDIA"; +"ChatSettings.AutoPlayVideos" = "Videos"; +"ChatSettings.AutomaticPhotoDownload" = "AUTOMATIC PHOTO DOWNLOAD"; +"ChatSettings.AutomaticVideoMessageDownload" = "AUTOMATIC VIDEO MESSAGE DOWNLOAD"; +"ChatSettings.Cache" = "Storage Usage"; +"ChatSettings.ConnectionType.Title" = "CONNECTION TYPE"; +"ChatSettings.ConnectionType.UseProxy" = "Use Proxy"; +"ChatSettings.ConnectionType.UseSocks5" = "SOCKS5"; +"ChatSettings.DownloadInBackground" = "Background Download"; +"ChatSettings.DownloadInBackgroundInfo" = "The app will continue downloading media files for a limited time."; +"ChatSettings.Groups" = "Groups"; +"ChatSettings.IntentsSettings" = "Share Sheet"; +"ChatSettings.OpenLinksIn" = "Open Links in"; +"ChatSettings.Other" = "OTHER"; +"ChatSettings.PrivateChats" = "Private Chats"; +"ChatSettings.Stickers" = "Stickers and Emoji"; +"ChatSettings.StickersAndReactions" = "Stickers and Emoji"; +"ChatSettings.TextSize" = "Text Size"; +"ChatSettings.TextSizeUnits" = "pt"; +"ChatSettings.Title" = "Data and Storage"; +"ChatSettings.UseLessDataForCalls" = "Use Less Data for Calls"; +"ChatState.Connecting" = "connecting..."; +"ChatState.ConnectingToProxy" = "connecting to proxy..."; +"ChatState.Updating" = "updating..."; +"ChatState.WaitingForNetwork" = "waiting for network..."; +"ChatbotSetup.BotAddAction" = "ADD"; +"ChatbotSetup.BotInstalled" = "%@ now manages your account."; +"ChatbotSetup.BotNotFoundStatus" = "Chatbot not found"; +"ChatbotSetup.BotSearchPlaceholder" = "Bot Username or Link"; +"ChatbotSetup.BotSectionFooter" = "Choose a bot to manage your chats automatically."; +"ChatbotSetup.ErrorBotNotBusinessCapable" = "This bot doesn't support Telegram Business yet."; +"ChatbotSetup.Gift.Warning.CombinedText" = "The bot **%@** will be able to **manage your gifts and stars**, including giving them away to other users."; +"ChatbotSetup.Gift.Warning.GiftsText" = "The bot **%@** will be able to **manage your gifts**, including giving them away to other users."; +"ChatbotSetup.Gift.Warning.Proceed" = "Proceed"; +"ChatbotSetup.Gift.Warning.StarsText" = "The bot **%@** will be able to **transfer your stars**."; +"ChatbotSetup.Gift.Warning.Title" = "Warning"; +"ChatbotSetup.Gift.Warning.UsernameText" = "The bot **%@** will be able to **set and remove usernames** for your account, which may result in the loss of your current username."; +"ChatbotSetup.Permission.ReplyToMessages" = "Reply to Messages"; +"ChatbotSetup.PermissionsSectionFooter" = "The bot can only reply on your behalf in chats that were active during the last 24h."; +"ChatbotSetup.PermissionsSectionHeader" = "BOT PERMISSIONS"; +"ChatbotSetup.RecipientSummary.ExcludedChatsItem" = "Excluded Chats"; +"ChatbotSetup.RecipientSummary.IncludedChatsItem" = "Included Chats"; +"ChatbotSetup.RecipientSummary.ValueEmpty" = "Add"; +"ChatbotSetup.RecipientSummary.ValueItems#one" = "1 item"; +"ChatbotSetup.RecipientSummary.ValueItems#other" = "%d items"; +"ChatbotSetup.Recipients.AddUsers" = "Add Users"; +"ChatbotSetup.Recipients.ExcludedListTitle" = "Excluded Chats"; +"ChatbotSetup.Recipients.ExcludedSectionFooter" = "Select chats or entire chat categories which the bot **WILL NOT** have access to."; +"ChatbotSetup.Recipients.IncludedListTitle" = "Included Chats"; +"ChatbotSetup.Recipients.IncludedSectionFooter" = "Select chats or entire chat categories which the bot **WILL** have access to."; +"ChatbotSetup.Recipients.RemoveAll" = "Remove All Exceptions"; +"ChatbotSetup.Recipients.SelectionTitle" = "Add Exception"; +"ChatbotSetup.RecipientsSectionHeader" = "CHATS THE BOT CAN ACCESS"; +"ChatbotSetup.Rights.ChangeGiftSettings" = "Change Gift Settings"; +"ChatbotSetup.Rights.DeleteReceivedMessages" = "Delete Any Message"; +"ChatbotSetup.Rights.DeleteSentMessages" = "Delete Sent Messages"; +"ChatbotSetup.Rights.EditBio" = "Edit Bio"; +"ChatbotSetup.Rights.EditName" = "Edit Name"; +"ChatbotSetup.Rights.EditProfilePhoto" = "Edit Profile Photo"; +"ChatbotSetup.Rights.EditUsername" = "Edit Username"; +"ChatbotSetup.Rights.ManageGiftsAndStars" = "Manage Gifts and Stars"; +"ChatbotSetup.Rights.ManageMessages" = "Manage Messages"; +"ChatbotSetup.Rights.ManageProfile" = "Manage Profile"; +"ChatbotSetup.Rights.ManageStories" = "Manage Stories"; +"ChatbotSetup.Rights.MarkAsRead" = "Mark Messages as Read"; +"ChatbotSetup.Rights.ReadMessages" = "Read Messages"; +"ChatbotSetup.Rights.ReplyToMessages" = "Reply to Messages"; +"ChatbotSetup.Rights.SellGifts" = "Convert Gifts to Stars"; +"ChatbotSetup.Rights.TransferAndUpgradeGifts" = "Transfer and Upgrade Gifts"; +"ChatbotSetup.Rights.TransferStars" = "Transfer Stars"; +"ChatbotSetup.Rights.ViewGifts" = "View Gifts and Stars"; +"ChatbotSetup.SetupNotCompleted.Leave" = "Leave"; +"ChatbotSetup.SetupNotCompleted.Text" = "You haven’t added a bot to manage your account. Leave anyway?"; +"ChatbotSetup.SetupNotCompleted.Title" = "No Bot Added"; +"ChatbotSetup.Text" = "Add a bot to reply to messages on your behalf."; +"ChatbotSetup.TextLink" = "https://telegram.org/blog/telegram-business#chatbots-for-business"; +"ChatbotSetup.Title" = "Coming Later"; +"ChatbotSetup.TitleItem" = "Chat Automation"; +"Checkout.Email" = "Email"; +"Checkout.EnterPassword" = "Enter Password"; +"Checkout.ErrorGeneric" = "An error occurred while processing your payment. Your card has not been billed."; +"Checkout.ErrorInvoiceAlreadyPaid" = "You have already paid for this item."; +"Checkout.ErrorPaymentFailed" = "Payment failed. Your card has not been billed."; +"Checkout.ErrorPrecheckoutFailed" = "The bot couldn't process your payment. Your card has not been billed."; +"Checkout.ErrorProviderAccountInvalid" = "This bot can't accept payments at the moment. Please try again later."; +"Checkout.ErrorProviderAccountTimeout" = "This bot can't process payments at the moment. Please try again later."; +"Checkout.LiabilityAlert" = "Neither Telegram, nor %1$@ will have access to your credit card information. Credit card details will be handled only by the payment system, %2$@.\n\nPayments will go directly to the developer of %1$@. Telegram cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of %1$@ or your bank."; +"Checkout.LiabilityAlertTitle" = "Warning"; +"Checkout.Name" = "Name"; +"Checkout.NewCard.CardholderNamePlaceholder" = "Cardholder Name"; +"Checkout.NewCard.CardholderNameTitle" = "CARDHOLDER"; +"Checkout.NewCard.PaymentCard" = "PAYMENT CARD"; +"Checkout.NewCard.PostcodePlaceholder" = "Zip Code"; +"Checkout.NewCard.PostcodeTitle" = "BILLING ADDRESS"; +"Checkout.NewCard.SaveInfo" = "Save Payment Information"; +"Checkout.NewCard.SaveInfoEnableHelp" = "You can save your payment information for future use.\nPlease [turn on Two-Step Verification] to enable this."; +"Checkout.NewCard.SaveInfoHelp" = "You can save your payment info for future use. It will be stored directly with the payment provider. Telegram has no access to your credit card data."; +"Checkout.NewCard.Title" = "New Card"; +"Checkout.OptionalTipItem" = "Tip (Optional)"; +"Checkout.OptionalTipItemPlaceholder" = "Enter Custom"; +"Checkout.PasswordEntry.Pay" = "Pay"; +"Checkout.PasswordEntry.Text" = "Your card %@ is on file. To pay with this card, please enter your 2-Step-Verification password."; +"Checkout.PasswordEntry.Title" = "Payment Confirmation"; +"Checkout.PayNone" = "Pay"; +"Checkout.PayPrice" = "Pay %@"; +"Checkout.PayWithFaceId" = "Pay with Face ID"; +"Checkout.PayWithTouchId" = "Pay with Touch ID"; +"Checkout.PaymentLiabilityAlert" = "Neither Telegram, nor {target} will have access to your credit card information. Credit card details will be handled only by the payment system, {payment_system}.\n\nPayments will go directly to the developer of {target}. Telegram cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of {target} or your bank."; +"Checkout.PaymentLiabilityBothAlert" = "Telegram will not have access to your credit card information. Credit card details will be handled only by the payment system, {target}.\n\nPayments will go directly to the developer of {target}. Telegram cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of {target} or your bank."; +"Checkout.PaymentMethod" = "Payment Method"; +"Checkout.PaymentMethod.New" = "Add Bank Card..."; +"Checkout.PaymentMethod.Proceed" = "Proceed"; +"Checkout.PaymentMethod.Title" = "Payment Method"; +"Checkout.Phone" = "Phone"; +"Checkout.Receipt.Title" = "Receipt"; +"Checkout.SavePasswordTimeout" = "Would you like to save your password for %@?"; +"Checkout.SavePasswordTimeoutAndFaceId" = "Would you like to save your password for %@ and use Face ID instead?"; +"Checkout.SavePasswordTimeoutAndTouchId" = "Would you like to save your password for %@ and use Touch ID instead?"; +"Checkout.ShippingAddress" = "Shipping Information"; +"Checkout.ShippingMethod" = "Shipping Method"; +"Checkout.ShippingMethod.Proceed" = "Proceed"; +"Checkout.ShippingOption.Title" = "Shipping Method"; +"Checkout.SuccessfulTooltip" = "You paid %1$@ for %2$@."; +"Checkout.TipItem" = "Tip"; +"Checkout.Title" = "Checkout"; +"Checkout.TotalAmount" = "Total"; +"Checkout.TotalPaidAmount" = "Total Paid"; +"Checkout.WebConfirmation.Title" = "Complete Payment"; +"CheckoutInfo.ErrorCityInvalid" = "Please enter a valid city."; +"CheckoutInfo.ErrorEmailInvalid" = "Please enter a valid email address."; +"CheckoutInfo.ErrorNameInvalid" = "Please enter a valid name."; +"CheckoutInfo.ErrorPhoneInvalid" = "Please enter a valid phone number."; +"CheckoutInfo.ErrorPostcodeInvalid" = "Please enter a valid postcode."; +"CheckoutInfo.ErrorShippingNotAvailable" = "Shipping to the selected country is not available."; +"CheckoutInfo.ErrorStateInvalid" = "Please enter a valid state."; +"CheckoutInfo.Pay" = "Pay"; +"CheckoutInfo.ReceiverInfoEmail" = "Email"; +"CheckoutInfo.ReceiverInfoEmailPlaceholder" = "Email"; +"CheckoutInfo.ReceiverInfoName" = "Name"; +"CheckoutInfo.ReceiverInfoNamePlaceholder" = "First and Last Name"; +"CheckoutInfo.ReceiverInfoPhone" = "Phone"; +"CheckoutInfo.ReceiverInfoTitle" = "RECEIVER"; +"CheckoutInfo.SaveInfo" = "Save Info"; +"CheckoutInfo.SaveInfoHelp" = "You can save your shipping information for future use."; +"CheckoutInfo.ShippingInfoAddress1" = "Address 1"; +"CheckoutInfo.ShippingInfoAddress1Placeholder" = "Address"; +"CheckoutInfo.ShippingInfoAddress2" = "Address 2"; +"CheckoutInfo.ShippingInfoAddress2Placeholder" = "Address"; +"CheckoutInfo.ShippingInfoCity" = "City"; +"CheckoutInfo.ShippingInfoCityPlaceholder" = "City"; +"CheckoutInfo.ShippingInfoCountry" = "Country"; +"CheckoutInfo.ShippingInfoCountryPlaceholder" = "Country"; +"CheckoutInfo.ShippingInfoPostcode" = "Postcode"; +"CheckoutInfo.ShippingInfoPostcodePlaceholder" = "Postcode"; +"CheckoutInfo.ShippingInfoState" = "State"; +"CheckoutInfo.ShippingInfoStatePlaceholder" = "State"; +"CheckoutInfo.ShippingInfoTitle" = "SHIPPING ADDRESS"; +"CheckoutInfo.Title" = "Shipping Information"; +"ClearCache.Clear" = "Clear"; +"ClearCache.ClearCache" = "Clear Telegram Cache"; +"ClearCache.ClearDescription" = "All media will stay in the Telegram cloud and can be re-downloaded if you need them again."; +"ClearCache.Description" = "Media files will be cleared from your disk, but will remain available for re-downloading if necessary."; +"ClearCache.Forever" = "Forever"; +"ClearCache.FreeSpace" = "Free Space"; +"ClearCache.FreeSpaceDescription" = "If you want to save space on your device, you don't need to delete anything.\n\nYou can use cache settings to remove unnecessary media — and re-download files if you need them again."; +"ClearCache.KeepOpenedDescription" = "Please keep this window open while Telegram is clearing your cache."; +"ClearCache.Never" = "Never"; +"ClearCache.NoProgress" = "Clearing the Cache"; +"ClearCache.Progress" = "Clearing Cached Data • %d%"; +"ClearCache.StorageCache" = "Telegram Cache"; +"ClearCache.StorageFree" = "Free"; +"ClearCache.StorageOtherApps" = "Other Apps"; +"ClearCache.StorageServiceFiles" = "Telegram Service Files"; +"ClearCache.StorageTitle" = "%@ STORAGE"; +"ClearCache.StorageUsage" = "Storage Usage"; +"ClearCache.Success" = "**%@** freed on your %@!"; +"Clipboard.SendPhoto" = "Send Photo"; +"CloudStorage.Title" = "Cloud Storage"; +"CocoonInfo.Description" = "**Cocoon** (**Co**nfidential **Co**mpute **O**pen **N**etwork) handles AI tasks **safely** and **efficiently**."; +"CocoonInfo.Efficient.Text" = "Cocoon has allowed Telegram to reduce translation costs by 6x."; +"CocoonInfo.Efficient.Title" = "Efficient"; +"CocoonInfo.ForEveryone.Text" = "Any developer can use [Cocoon](telegram) for AI features. Learn more on [cocoon.org](web)."; +"CocoonInfo.ForEveryone.Title" = "For Everyone"; +"CocoonInfo.IntergrateInfo" = "Want to integrate Cocoon into your projects?\nReach out at [t.me/cocoon?direct]()"; +"CocoonInfo.Private.Text" = "No third party can access any data, such as translations, inside Cocoon."; +"CocoonInfo.Private.Title" = "Private"; +"CocoonInfo.Title" = "COCOON"; +"CocoonInfo.Understood" = "Understood"; +"CollectibleItemInfo.ButtonCopyPhone" = "Copy Phone Number"; +"CollectibleItemInfo.ButtonCopyUsername" = "Copy Link"; +"CollectibleItemInfo.ButtonOpenInfo" = "Learn More"; +"CollectibleItemInfo.PhoneText" = "The phone number %1$@ was acquired on %2$@ on %3$@ for %4$@ (%5$@)."; +"CollectibleItemInfo.PhoneTitle" = "%@ is a collectible phone number that belongs to"; +"CollectibleItemInfo.ShareInlineText.LearnMore" = "Learn more >"; +"CollectibleItemInfo.StoreName" = "Fragment"; +"CollectibleItemInfo.UsernameText" = "The username %1$@ was acquired on %2$@ on %3$@ for %4$@ (%5$@)."; +"CollectibleItemInfo.UsernameTitle" = "%@ is a collectible username that belongs to"; +"CommentsGroup.ErrorAccessDenied" = "Sorry, you can't access this chat because you were banned by an admin."; +"Common.ActionNotAllowedError" = "Sorry, you are not allowed to do this."; +"Common.Back" = "Back"; +"Common.Cancel" = "Cancel"; +"Common.ChoosePhoto" = "Choose Photo"; +"Common.Close" = "Close"; +"Common.Create" = "Create"; +"Common.Delete" = "Delete"; +"Common.Done" = "Done"; +"Common.Edit" = "Edit"; +"Common.More" = "More"; +"Common.Next" = "Next"; +"Common.No" = "No"; +"Common.NotNow" = "Not Now"; +"Common.OK" = "OK"; +"Common.Paste" = "Paste"; +"Common.Save" = "Save"; +"Common.Search" = "Search"; +"Common.Select" = "Select"; +"Common.TakePhoto" = "Take Photo"; +"Common.TakePhotoOrVideo" = "Take Photo or Video"; +"Common.Yes" = "Yes"; +"Common.edit" = "edit"; +"Common.of" = "of"; +"Compose.ChannelMembers" = "Members"; +"Compose.ChannelTokenListPlaceholder" = "Search for contacts or usernames"; +"Compose.Create" = "Create"; +"Compose.GroupTokenListPlaceholder" = "Search for contacts or usernames"; +"Compose.NewChannel" = "New Channel"; +"Compose.NewEncryptedChat" = "New Secret Chat"; +"Compose.NewEncryptedChatTitle" = "New Secret Chat"; +"Compose.NewGroup" = "New Group"; +"Compose.NewGroupTitle" = "New Group"; +"Compose.NewMessage" = "New Message"; +"Compose.TokenListPlaceholder" = "Who would you like to add?"; +"ContactInfo.BirthdayLabel" = "birthday"; +"ContactInfo.Job" = "job"; +"ContactInfo.Note" = "note"; +"ContactInfo.PhoneLabelHome" = "home"; +"ContactInfo.PhoneLabelHomeFax" = "home fax"; +"ContactInfo.PhoneLabelMain" = "main"; +"ContactInfo.PhoneLabelMobile" = "mobile"; +"ContactInfo.PhoneLabelOther" = "other"; +"ContactInfo.PhoneLabelPager" = "pager"; +"ContactInfo.PhoneLabelWork" = "work"; +"ContactInfo.PhoneLabelWorkFax" = "work fax"; +"ContactInfo.PhoneNumberHidden" = "Hidden"; +"ContactInfo.Title" = "Contact Info"; +"ContactInfo.URLLabelHomepage" = "homepage"; +"ContactList.Context.Call" = "Call"; +"ContactList.Context.Delete" = "Delete Contact"; +"ContactList.Context.Select" = "Select"; +"ContactList.Context.SendMessage" = "Send Message"; +"ContactList.Context.StartSecretChat" = "Start Secret Chat"; +"ContactList.Context.VideoCall" = "Video Call"; +"ContactList.DeleteConfirmation#one" = "Delete %@ Contact"; +"ContactList.DeleteConfirmation#other" = "Delete %@ Contacts"; +"ContactList.DeleteConfirmationSingle" = "Delete Contact"; +"ContactList.DeletedContacts#one" = "Deleted %@ Contact"; +"ContactList.DeletedContacts#other" = "Deleted %@ Contacts"; +"Contacts.AccessDeniedError" = "Telegram does not have access to your contacts"; +"Contacts.AccessDeniedHelpLandscape" = "Please go to your %@ Settings > Privacy > Contacts.\nThen select ON for Telegram."; +"Contacts.AccessDeniedHelpON" = "ON"; +"Contacts.AccessDeniedHelpPortrait" = "Please go to your %@ Settings > Privacy > Contacts and set Telegram to ON."; +"Contacts.AddContact" = "Add Contact"; +"Contacts.AddPeopleNearby" = "Find People Nearby"; +"Contacts.AddPhoneNumber" = "Add %@"; +"Contacts.DeselectAll" = "Deselect All"; +"Contacts.FailedToSendInvitesMessage" = "An error occurred."; +"Contacts.GlobalSearch" = "Global Search"; +"Contacts.ImportersCount#one" = "1 contact on Telegram"; +"Contacts.ImportersCount#other" = "%@ contacts on Telegram"; +"Contacts.InviteContacts#one" = "Invite %@ Contact"; +"Contacts.InviteContacts#other" = "Invite %@ Contacts"; +"Contacts.InviteFriends" = "Invite Friends"; +"Contacts.InviteSearchLabel" = "Search contacts"; +"Contacts.InviteToTelegram" = "Invite to Telegram"; +"Contacts.LimitedAccess.Manage" = "MANAGE"; +"Contacts.LimitedAccess.Text" = "You have limited Telegram from accessing all of your contacts."; +"Contacts.MemberSearchSectionTitleGroup" = "Group Members"; +"Contacts.NotRegisteredSection" = "Phonebook"; +"Contacts.PermissionsAllow" = "Allow Access"; +"Contacts.PermissionsAllowInSettings" = "Allow in Settings"; +"Contacts.PermissionsEnable" = "Enable"; +"Contacts.PermissionsKeepDisabled" = "Keep Disabled"; +"Contacts.PermissionsSuppressWarningText" = "You won't know when your friends join Telegram and become available to chat. We recommend enabling access to contacts in Settings."; +"Contacts.PermissionsSuppressWarningTitle" = "Keep contacts disabled?"; +"Contacts.PermissionsText" = "Please allow Telegram access to your phonebook to seamlessly find all your friends."; +"Contacts.PermissionsTitle" = "Access to Contacts"; +"Contacts.PhoneNumber" = "Phone Number"; +"Contacts.Search.NoResults" = "No Results"; +"Contacts.Search.NoResultsQueryDescription" = "There were no results for \"%@\".\nTry a new search."; +"Contacts.SearchLabel" = "Search for contacts or usernames"; +"Contacts.SearchUsersAndGroupsLabel" = "Search for users and groups"; +"Contacts.SelectAll" = "Select All"; +"Contacts.SelectedContacts#one" = "%@ Selected"; +"Contacts.SelectedContacts#other" = "%@ Selected"; +"Contacts.ShareTelegram" = "Share Telegram"; +"Contacts.Sort" = "Sort"; +"Contacts.Sort.ByLastSeen" = "by Last Seen"; +"Contacts.Sort.ByName" = "by Name"; +"Contacts.SortBy" = "Sort by:"; +"Contacts.SortByName" = "Name"; +"Contacts.SortByPresence" = "Last Seen Time"; +"Contacts.SortedByName" = "Sorted by Name"; +"Contacts.SortedByPresence" = "Sorted by Last Seen Time"; +"Contacts.TabTitle" = "Contacts"; +"Contacts.Title" = "Contacts"; +"Contacts.TopSection" = "CONTACTS"; +"Contacts.VoiceOver.AddContact" = "Add Contact"; +"Conversation.AddContact" = "Add Contact"; +"Conversation.AddMembers" = "Add Members"; +"Conversation.AddNameToContacts" = "Add %@ to Contacts"; +"Conversation.AddToContacts" = "Add to Contacts"; +"Conversation.AddToContactsLong" = "Add to Contacts"; +"Conversation.AddToReadingList" = "Add to Reading List"; +"Conversation.Admin" = "admin"; +"Conversation.AlsoClearCacheTitle" = "You can use \"clear cache\" to remove unnecessary media — and re-downloaded files if you need them again."; +"Conversation.ApplyLocalization" = "Apply Localization"; +"Conversation.AudioRateOptionsTooltip" = "Long press for more speed options."; +"Conversation.AudioRateTooltip15X" = "Audio will play at 1.5X speed."; +"Conversation.AudioRateTooltipCustom" = "Audio will play at %@x speed."; +"Conversation.AudioRateTooltipNormal" = "Audio will play at normal speed."; +"Conversation.AudioRateTooltipSpeedUp" = "Audio will play two times faster."; +"Conversation.AutoremoveActionEdit" = "Edit Auto-Delete Settings"; +"Conversation.AutoremoveActionEnable" = "Enable Auto-Delete"; +"Conversation.AutoremoveChanged" = "Auto-Delete timer set to %@."; +"Conversation.AutoremoveOff" = "Auto-Delete is now off."; +"Conversation.AutoremoveRemainingDays#one" = "auto-deletes in %@ day"; +"Conversation.AutoremoveRemainingDays#other" = "auto-deletes in %@ days"; +"Conversation.AutoremoveRemainingTime" = "auto-deletes in %@"; +"Conversation.AutoremoveTimerRemovedChannel" = "Messages in this channel will no longer be automatically deleted"; +"Conversation.AutoremoveTimerRemovedGroup" = "A group admin disabled the auto-delete timer"; +"Conversation.AutoremoveTimerRemovedUser" = "%1$@ disabled the auto-delete timer"; +"Conversation.AutoremoveTimerRemovedUserYou" = "You disabled the auto-delete timer"; +"Conversation.AutoremoveTimerSetChannel" = "Messages in this channel will be automatically deleted after %1$@"; +"Conversation.AutoremoveTimerSetGroup" = "A group admin set messages to auto-delete after %1$@"; +"Conversation.AutoremoveTimerSetToastText" = "Messages in this chat are automatically\ndeleted %@ after they are sent."; +"Conversation.AutoremoveTimerSetUser" = "%1$@ set messages to auto-delete in %2$@"; +"Conversation.AutoremoveTimerSetUserGlobal" = "%1$@ uses a self-destruct timer for all chats.\nAll new messages in this chat will be automatically deleted %2$@ after they are sent."; +"Conversation.AutoremoveTimerSetUserGlobalYou" = "You set a self-destruct timer for all chats.\nAll new messages in this chat will be automatically deleted %1$@ after they're sent."; +"Conversation.AutoremoveTimerSetUserYou" = "You set messages to auto-delete after %1$@"; +"Conversation.BlockUser" = "Block User"; +"Conversation.BoostChannel" = "BOOST"; +"Conversation.BoostGroup" = "BOOST"; +"Conversation.BoostToUnrestrictText" = "Boost this group to send messages"; +"Conversation.BotApp" = "Bot Application"; +"Conversation.BotInteractiveUrlAlert" = "Allow %@ to pass your Telegram name and id (not your phone number) to pages you open with this bot?"; +"Conversation.Bytes" = "%d B"; +"Conversation.CalendarSearch.Done" = "Done"; +"Conversation.CalendarSearch.Title" = "Search"; +"Conversation.Call" = "Call"; +"Conversation.CancelForwardCancelForward" = "Cancel Forwarding"; +"Conversation.CancelForwardSelectChat" = "Select Another Chat"; +"Conversation.CancelForwardText" = "Do you want to cancel forwarding or send messages to a different chat?"; +"Conversation.CancelForwardTitle" = "Cancel Forwarding"; +"Conversation.CantPhoneCallAnonymousNumberError" = "You can't call this phone number."; +"Conversation.CardNumberCopied" = "Card number copied to clipboard."; +"Conversation.ChatBackground" = "Chat Wallpaper"; +"Conversation.ChecksTooltip.Delivered" = "Delivered"; +"Conversation.ChecksTooltip.Read" = "Read"; +"Conversation.ClearAll" = "Clear Chat"; +"Conversation.ClearCache" = "Clear Cache"; +"Conversation.ClearChannel" = "Clear Channel"; +"Conversation.ClearChatConfirmation" = "Warning, this will delete your **entire chat history** with %@."; +"Conversation.ClearGroupHistory" = "This will delete all messages and media in this chat from your Telegram cloud. Other members of the group will still have them."; +"Conversation.ClearPrivateHistory" = "This will delete all messages and media in this chat from your Telegram cloud. Your chat partner will still have them."; +"Conversation.ClearSecretHistory" = "This will delete all messages and media in this chat for both you and your chat partner."; +"Conversation.ClearSelfHistory" = "This will delete all messages and media in this chat from your Telegram cloud."; +"Conversation.CloudStorage.ChatStatus" = "chat with yourself"; +"Conversation.CloudStorageInfo.Title" = "Your Cloud Storage"; +"Conversation.ClousStorageInfo.Description1" = "• Forward messages here to save them"; +"Conversation.ClousStorageInfo.Description2" = "• Send media and files to store them"; +"Conversation.ClousStorageInfo.Description3" = "• Access this chat from any device"; +"Conversation.ClousStorageInfo.Description4" = "• Use search to quickly find things"; +"Conversation.CodeCopied" = "Code copied to clipboard"; +"Conversation.Contact" = "Contact"; +"Conversation.ContactAddContact" = "ADD"; +"Conversation.ContactAddContactLong" = "ADD CONTACT"; +"Conversation.ContactMessage" = "MESSAGE"; +"Conversation.ContextMenuAddFactCheck" = "Add Fact Check"; +"Conversation.ContextMenuBan" = "Restrict"; +"Conversation.ContextMenuBanFull" = "Ban"; +"Conversation.ContextMenuBlock" = "Block User"; +"Conversation.ContextMenuBuyGift" = "Buy Yourself a Gift"; +"Conversation.ContextMenuCancelEditing" = "Cancel Editing"; +"Conversation.ContextMenuCancelSending" = "Cancel Sending"; +"Conversation.ContextMenuCopy" = "Copy"; +"Conversation.ContextMenuCopyEmail" = "Copy Email"; +"Conversation.ContextMenuCopyLink" = "Copy Link"; +"Conversation.ContextMenuDelete" = "Delete"; +"Conversation.ContextMenuDiscuss" = "Discuss"; +"Conversation.ContextMenuEditFactCheck" = "Edit Fact Check"; +"Conversation.ContextMenuForward" = "Forward"; +"Conversation.ContextMenuListened#one" = "1 Listened"; +"Conversation.ContextMenuListened#other" = "%@ Listened"; +"Conversation.ContextMenuLookUp" = "Look Up"; +"Conversation.ContextMenuMention" = "Mention"; +"Conversation.ContextMenuMore" = "More..."; +"Conversation.ContextMenuNoViews" = "Nobody Viewed"; +"Conversation.ContextMenuNobodyListened" = "Nobody Listened"; +"Conversation.ContextMenuNobodyWatched" = "Nobody Watched"; +"Conversation.ContextMenuOpenChannel" = "Open Channel"; +"Conversation.ContextMenuOpenChannelProfile" = "Open Profile"; +"Conversation.ContextMenuOpenInfo" = "Open Info"; +"Conversation.ContextMenuOpenProfile" = "Open Profile"; +"Conversation.ContextMenuQuote" = "Quote"; +"Conversation.ContextMenuReply" = "Reply"; +"Conversation.ContextMenuReport" = "Report"; +"Conversation.ContextMenuReportFalsePositive" = "Report False Positive"; +"Conversation.ContextMenuSearchMessages" = "Search Messages"; +"Conversation.ContextMenuSeen#one" = "%@ Seen"; +"Conversation.ContextMenuSeen#other" = "%@ Seen"; +"Conversation.ContextMenuSelect" = "Select"; +"Conversation.ContextMenuSelectAll#one" = "Select All %@ Items"; +"Conversation.ContextMenuSelectAll#other" = "Select All %@ Items"; +"Conversation.ContextMenuSendAnotherGift" = "Send Another Gift"; +"Conversation.ContextMenuSendGiftTo" = "Send Gift to %@"; +"Conversation.ContextMenuSendMessage" = "Send Message"; +"Conversation.ContextMenuShare" = "Share"; +"Conversation.ContextMenuSpeak" = "Speak"; +"Conversation.ContextMenuStickerPackAdd" = "Add Stickers"; +"Conversation.ContextMenuStickerPackInfo" = "Info"; +"Conversation.ContextMenuTranslate" = "Translate"; +"Conversation.ContextMenuWatched#one" = "1 Listened"; +"Conversation.ContextMenuWatched#other" = "%@ Listened"; +"Conversation.ContextViewReplies#one" = "View %@ Reply"; +"Conversation.ContextViewReplies#other" = "View %@ Replies"; +"Conversation.ContextViewStats" = "View Statistics"; +"Conversation.ContextViewThread" = "View Thread"; +"Conversation.CopyProtectionForwardingDisabledBot" = "Forwarding from this chat is restricted"; +"Conversation.CopyProtectionForwardingDisabledChannel" = "Forwards from this channel are restricted"; +"Conversation.CopyProtectionForwardingDisabledGroup" = "Forwards from this group are restricted"; +"Conversation.CopyProtectionForwardingDisabledSecret" = "Forwarding is restricted"; +"Conversation.CopyProtectionInfoChannel" = "Copying and forwarding is not allowed in this channel."; +"Conversation.CopyProtectionInfoGroup" = "Copying and forwarding is not allowed from this group."; +"Conversation.CopyProtectionInfoPrivate" = "**%@** disabled copying and forwarding in this chat."; +"Conversation.CopyProtectionInfoPrivateYou" = "You disabled copying and forwarding in this chat."; +"Conversation.CopyProtectionSavingDisabledBot" = "Saving content from this bot is restricted"; +"Conversation.CopyProtectionSavingDisabledChannel" = "Saving from this channel is restricted"; +"Conversation.CopyProtectionSavingDisabledGroup" = "Saving from this group is restricted"; +"Conversation.CopyProtectionSavingDisabledSecret" = "Saving is restricted"; +"Conversation.DateCopied" = "Date copied to clipboard"; +"Conversation.DateReminderSet" = "You have set a reminder in [Saved Messages]()."; +"Conversation.DefaultRestrictedInline" = "Sending inline content isn't allowed in this group."; +"Conversation.DefaultRestrictedMedia" = "Sending media isn't allowed in this group."; +"Conversation.DefaultRestrictedStickers" = "Stickers aren’t allowed in this group."; +"Conversation.DefaultRestrictedText" = "Sending messages is not allowed in this group."; +"Conversation.DeleteAllMessagesInChat" = "Are you sure you want to delete all messages in %@?"; +"Conversation.DeleteManyMessages" = "Delete Messages"; +"Conversation.DeleteMessagesFor" = "Delete for me and %@"; +"Conversation.DeleteMessagesForEveryone" = "Delete for everyone"; +"Conversation.DeleteMessagesForMe" = "Delete for me"; +"Conversation.DeleteTimer.Apply" = "Set Auto-Delete"; +"Conversation.DeleteTimer.Disable" = "Disable Auto-Delete"; +"Conversation.DeleteTimer.SetupTitle" = "Auto-Delete After..."; +"Conversation.DeletedFromContacts" = "**%@** deleted from your contacts"; +"Conversation.Dice" = "Send a 🎲 emoji to any chat to get a random number from Telegram."; +"Conversation.Dice." = "Send a dice emoji to any chat to roll a die."; +"Conversation.Dice.Change" = "change"; +"Conversation.Dice.Stake" = "Stake:"; +"Conversation.Dice.u1F3AF" = "Send a dart emoji to try your luck."; +"Conversation.Dice.u1F3B0" = "Send a slot machine emoji to try your luck."; +"Conversation.Dice.u1F3B2" = "Send a dice emoji to any chat to roll a die."; +"Conversation.Dice.u1F3B3" = "Send a bowling emoji to try your luck."; +"Conversation.Dice.u1F3C0" = "Send a basketball emoji to try your luck."; +"Conversation.Dice.u26BD" = "Send a football emoji to try your luck."; +"Conversation.DiscardRecordedVoiceMessageAction" = "Discard"; +"Conversation.DiscardRecordedVoiceMessageDescription" = "Are you sure you want to discard your voice message?"; +"Conversation.DiscardVoiceMessageAction" = "Discard"; +"Conversation.DiscardVoiceMessageDescription" = "Are you sure you want to stop and discard\nthe recording?"; +"Conversation.DiscardVoiceMessageTitle" = "Discard Voice Message"; +"Conversation.DiscussionNotStarted" = "No comments here yet..."; +"Conversation.DiscussionStarted" = "Discussion started"; +"Conversation.Edit" = "Edit"; +"Conversation.EditingCaptionPanelTitle" = "Edit Caption"; +"Conversation.EditingMessageMediaChange" = "Change Photo or Video"; +"Conversation.EditingMessageMediaEditCurrentPhoto" = "Edit Current Photo"; +"Conversation.EditingMessageMediaEditCurrentVideo" = "Edit Current Video"; +"Conversation.EditingMessagePanelMedia" = "Tap to edit media"; +"Conversation.EditingMessagePanelTitle" = "Edit Message"; +"Conversation.EditingPhotoPanelTitle" = "Edit Photo"; +"Conversation.EditingQuickReplyPanelTitle" = "Edit Quick Reply"; +"Conversation.EmailCopied" = "Email copied to clipboard."; +"Conversation.EmojiCopied" = "Emoji copied to clipboard"; +"Conversation.EmojiStake.Lost" = "%1$@ lost $%2$@"; +"Conversation.EmojiStake.LostYou" = "You lost $%@"; +"Conversation.EmojiStake.Won" = "%1$@ won $%2$@"; +"Conversation.EmojiStake.WonYou" = "You won $%@"; +"Conversation.EmojiTooltip" = "Tap here to choose more emoji"; +"Conversation.EmptyGifPanelPlaceholder" = "You have no saved GIFs yet.\nEnter @gif to search."; +"Conversation.EmptyPlaceholder" = "No messages here yet..."; +"Conversation.EncryptedDescription1" = "Use end-to-end encryption"; +"Conversation.EncryptedDescription2" = "Leave no trace on our servers"; +"Conversation.EncryptedDescription3" = "Have a self-destruct timer"; +"Conversation.EncryptedDescription4" = "Do not allow forwarding"; +"Conversation.EncryptedDescriptionTitle" = "Secret chats:"; +"Conversation.EncryptedPlaceholderTitleIncoming" = "%@ invited you to join a secret chat."; +"Conversation.EncryptedPlaceholderTitleOutgoing" = "You invited %@ to join a Secret Chat."; +"Conversation.EncryptionCanceled" = "Secret chat canceled"; +"Conversation.EncryptionProcessing" = "Exchanging encryption keys..."; +"Conversation.EncryptionWaiting" = "Waiting for %@ to get online..."; +"Conversation.ErrorInaccessibleMessage" = "Unfortunately, you can't access this message. You aren't a member of the chat where it was posted."; +"Conversation.FactCheck.Description" = "This clarification was provided by Telegram."; +"Conversation.FactCheck.InnerDescription" = "This clarification was provided by Telegram."; +"Conversation.FileDropbox" = "Dropbox"; +"Conversation.FileHowToText" = "To share files of any type, open them on your %@ (e.g. in your browser), tap **Open\u00a0in...** or the action button and choose Telegram."; +"Conversation.FileICloudDrive" = "iCloud Drive"; +"Conversation.FileOpenIn" = "Open in..."; +"Conversation.FilePhotoOrVideo" = "Photo or Video"; +"Conversation.FormatDate.AddDate" = "Add Date"; +"Conversation.FormatDate.EditDate" = "Edit Date"; +"Conversation.FormatDate.RemoveDate" = "Remove Date"; +"Conversation.FormatDate.Title" = "Date"; +"Conversation.ForwardAuthorHiddenTooltip" = "The account was hidden by the user"; +"Conversation.ForwardChats" = "Chats"; +"Conversation.ForwardContacts" = "Contacts"; +"Conversation.ForwardFrom" = "From: %@"; +"Conversation.ForwardOptions.CancelForwarding" = "Cancel Forwarding"; +"Conversation.ForwardOptions.ChangeRecipient" = "Change Recipient"; +"Conversation.ForwardOptions.ChannelMessageForwardHidden" = "Recipients won't see that it was forwarded"; +"Conversation.ForwardOptions.ChannelMessageForwardVisible" = "Recipients will see that it was forwarded"; +"Conversation.ForwardOptions.ChannelMessagesForwardHidden" = "Recipients won't see they were forwarded"; +"Conversation.ForwardOptions.ChannelMessagesForwardVisible" = "Recipients will see they were forwarded"; +"Conversation.ForwardOptions.ForwardTitle#one" = "Forward %@ Message"; +"Conversation.ForwardOptions.ForwardTitle#other" = "Forward %@ Messages"; +"Conversation.ForwardOptions.ForwardTitleSingle" = "Forward Message"; +"Conversation.ForwardOptions.GroupMessageForwardHidden" = "Recipients won't see that it was forwarded"; +"Conversation.ForwardOptions.GroupMessageForwardVisible" = "Recipients will see that it was forwarded"; +"Conversation.ForwardOptions.GroupMessagesForwardHidden" = "Recipients won't see they were forwarded"; +"Conversation.ForwardOptions.GroupMessagesForwardVisible" = "Recipients will see they were forwarded"; +"Conversation.ForwardOptions.HideCaption" = "Hide Captions"; +"Conversation.ForwardOptions.HideSendersName" = "Hide Sender Name"; +"Conversation.ForwardOptions.HideSendersNames" = "Hide Sender Names"; +"Conversation.ForwardOptions.Messages#one" = "%@ message"; +"Conversation.ForwardOptions.Messages#other" = "%@ messages"; +"Conversation.ForwardOptions.RecipientsMessageForwardHidden" = "Recipients won't see that it was forwarded"; +"Conversation.ForwardOptions.RecipientsMessageForwardVisible" = "Recipients will see that it was forwarded"; +"Conversation.ForwardOptions.RecipientsMessagesForwardHidden" = "Recipients won't see they were forwarded"; +"Conversation.ForwardOptions.RecipientsMessagesForwardVisible" = "Recipients will see they were forwarded"; +"Conversation.ForwardOptions.SendMessage" = "Send Message"; +"Conversation.ForwardOptions.SendMessages" = "Send Messages"; +"Conversation.ForwardOptions.SenderNamesRemoved" = "Sender names removed"; +"Conversation.ForwardOptions.ShowCaption" = "Show Captions"; +"Conversation.ForwardOptions.ShowOptions" = "Show Forwarding Options"; +"Conversation.ForwardOptions.ShowSendersName" = "Show Sender Name"; +"Conversation.ForwardOptions.ShowSendersNames" = "Show Sender Names"; +"Conversation.ForwardOptions.TapForOptions" = "Tap here for forwarding options"; +"Conversation.ForwardOptions.TapForOptionsShort" = "Tap here for options"; +"Conversation.ForwardOptions.Text" = "What would you like to do with %1$@ from %2$@?"; +"Conversation.ForwardOptions.TextPersonal" = "What would you like to do with %1$@ from your chat with %2$@?"; +"Conversation.ForwardOptions.TextSaved" = "What would you like to do with %1$@ from your Saved Messages?"; +"Conversation.ForwardOptions.Title#one" = "%@ Message"; +"Conversation.ForwardOptions.Title#other" = "%@ Messages"; +"Conversation.ForwardOptions.UserMessageForwardHidden" = "%@ won't see that it was forwarded"; +"Conversation.ForwardOptions.UserMessageForwardVisible" = "%@ will see that it was forwarded"; +"Conversation.ForwardOptions.UserMessagesForwardHidden" = "%@ won't see they were forwarded"; +"Conversation.ForwardOptions.UserMessagesForwardVisible" = "%@ will see they were forwarded"; +"Conversation.ForwardOptionsCancel" = "Do Not Forward"; +"Conversation.ForwardTitle" = "Forward"; +"Conversation.ForwardTooltip.Chat.Many" = "Messages forwarded to **%@**."; +"Conversation.ForwardTooltip.Chat.One" = "Message forwarded to **%@**."; +"Conversation.ForwardTooltip.ManyChats.Many" = "Messages sent to **%@** and %@ other chats."; +"Conversation.ForwardTooltip.ManyChats.One" = "Message sent to **%@** and %@ other chats."; +"Conversation.ForwardTooltip.SavedMessages.Many" = "Messages forwarded to **Saved Messages**."; +"Conversation.ForwardTooltip.SavedMessages.One" = "Message forwarded to **Saved Messages**."; +"Conversation.ForwardTooltip.TwoChats.Many" = "Messages forwarded to **%@** and **%@**."; +"Conversation.ForwardTooltip.TwoChats.One" = "Message forwarded to **%@** and **%@**."; +"Conversation.FreeTranscriptionCooldownTooltip#one" = "You have used your **%@** free transcription this week."; +"Conversation.FreeTranscriptionCooldownTooltip#other" = "You have used your **%@** free transcriptions this week."; +"Conversation.FreeTranscriptionLimitTooltip#one" = "You have **%@** free voice transcription left this week."; +"Conversation.FreeTranscriptionLimitTooltip#other" = "You have **%@** free voice transcriptions left this week."; +"Conversation.FreeTranscriptionWaitOrSubscribe" = "Wait until **%@** to use it again or subscribe to [Telegram Premium]() now."; +"Conversation.GifTooltip" = "Tap here to access saved GIFs"; +"Conversation.GigagroupDescription" = "Only admins can send messages in this group."; +"Conversation.GreetingText" = "Send a message or tap on the greeting below."; +"Conversation.HashtagCopied" = "Hashtag copied to clipboard."; +"Conversation.HoldForAudio" = "Hold to record audio. Tap to switch to video."; +"Conversation.HoldForAudioOnly" = "Hold to record audio."; +"Conversation.HoldForVideo" = "Hold to record video. Tap to switch to audio."; +"Conversation.HoldForVideoOnly" = "Hold to record video."; +"Conversation.ImageCopied" = "Image copied to clipboard."; +"Conversation.ImportProgress" = "Importing Messages... %@%"; +"Conversation.ImportedMessageHint" = "This message was imported from another app. We can't guarantee it's real."; +"Conversation.IncreaseSpeed" = "Increase Speed"; +"Conversation.Info" = "Info"; +"Conversation.InfoGroup" = "Group"; +"Conversation.InputMenu" = "Menu"; +"Conversation.InputTextAnonymousPlaceholder" = "Send anonymously"; +"Conversation.InputTextBroadcastPlaceholder" = "Broadcast"; +"Conversation.InputTextCaptionPlaceholder" = "Caption"; +"Conversation.InputTextPlaceholder" = "Message"; +"Conversation.InputTextPlaceholderComment" = "Comment"; +"Conversation.InputTextPlaceholderReply" = "Reply"; +"Conversation.InputTextSilentBroadcastPlaceholder" = "Silent Broadcast"; +"Conversation.InstantPagePreview" = "INSTANT VIEW"; +"Conversation.InteractiveEmojiSyncTip" = "If %@ had this chat open right now, they would also see the animation."; +"Conversation.InviteRequestAdminChannel" = "%1$@ is an admin of %2$@, a channel you requested to join."; +"Conversation.InviteRequestAdminGroup" = "%1$@ is an admin of %2$@, a group you requested to join."; +"Conversation.InviteRequestInfo" = "You received this message because you requested to join %1$@ on %2$@."; +"Conversation.InviteRequestInfoConfirm" = "I understand"; +"Conversation.JoinVoiceChat" = "JOIN VIDEO CHAT"; +"Conversation.JoinVoiceChatAsListener" = "JOIN AS LISTENER"; +"Conversation.JoinVoiceChatAsSpeaker" = "JOIN AS SPEAKER"; +"Conversation.JumpToDate" = "Jump To Date"; +"Conversation.Kilobytes" = "%d KB"; +"Conversation.LargeEmojiDisabledInfo" = "You have disabled large emoji, so they appear small and show no effects."; +"Conversation.LargeEmojiEnable" = "Enable Large Emoji"; +"Conversation.LargeEmojiEnabled" = "Large emoji enabled."; +"Conversation.LaunchApp" = "LAUNCH APP"; +"Conversation.LinkCopied" = "Link copied to clipboard."; +"Conversation.LinkDialogCopy" = "Copy"; +"Conversation.LinkDialogOpen" = "Open"; +"Conversation.LinkDialogSave" = "Save"; +"Conversation.LinkOptionsCancel" = "Do Not Preview"; +"Conversation.LinksCopied" = "Links copied to clipboard"; +"Conversation.LiveLocation" = "Live Location"; +"Conversation.LiveLocationMembersCount#one" = "1 member"; +"Conversation.LiveLocationMembersCount#other" = "%@ members"; +"Conversation.LiveLocationYou" = "You"; +"Conversation.LiveLocationYouAnd" = "*You* and %@"; +"Conversation.LiveLocationYouAndOther" = "**You** and %@"; +"Conversation.LiveStream" = "Live Stream"; +"Conversation.LiveStreamMediaRecordingRestricted" = "You can't record voice and video messages during a live stream."; +"Conversation.Location" = "Location"; +"Conversation.Megabytes" = "%.1f MB"; +"Conversation.MessageCopied" = "Message copied to clipboard."; +"Conversation.MessageDeliveryFailed" = "Your message was not sent. Tap \"Resend\" to send this message."; +"Conversation.MessageDialogDelete" = "Delete"; +"Conversation.MessageDialogEdit" = "Edit"; +"Conversation.MessageDialogRetry" = "Resend"; +"Conversation.MessageDialogRetryAll" = "Resend %1$d Messages"; +"Conversation.MessageDoesntExist" = "Message doesn't exist"; +"Conversation.MessageEditedLabel" = "edited"; +"Conversation.MessageGuestChatForUser" = "for %@"; +"Conversation.MessageLeaveComment" = "Leave a Comment"; +"Conversation.MessageLeaveCommentShort" = "Comment"; +"Conversation.MessageOptionsApplyChanges" = "Apply Changes"; +"Conversation.MessageOptionsEnlargeImage" = "Enlarge Photo"; +"Conversation.MessageOptionsEnlargeVideo" = "Enlarge Video"; +"Conversation.MessageOptionsLinkMoveDown" = "Move Down"; +"Conversation.MessageOptionsLinkMoveUp" = "Move Up"; +"Conversation.MessageOptionsQuoteRemove" = "Remove Quote"; +"Conversation.MessageOptionsQuoteSelect" = "Select Specific Quote"; +"Conversation.MessageOptionsQuoteSelectedPart" = "Quote Selected Part"; +"Conversation.MessageOptionsReplyCancel" = "Do Not Reply"; +"Conversation.MessageOptionsReplyInAnotherChat" = "Reply in Another Chat"; +"Conversation.MessageOptionsShrinkImage" = "Shrink Photo"; +"Conversation.MessageOptionsShrinkVideo" = "Shrink Video"; +"Conversation.MessageOptionsTabForward" = "Forward"; +"Conversation.MessageOptionsTabLink" = "Link"; +"Conversation.MessageOptionsTabReply" = "Reply"; +"Conversation.MessageViaUser" = "via %@"; +"Conversation.MessageViewComments#one" = "[%@]Comment"; +"Conversation.MessageViewComments#other" = "[%@]Comments"; +"Conversation.MessageViewCommentsFormat" = "%1$@ %2$@"; +"Conversation.Messages#one" = "%@ message"; +"Conversation.Messages#other" = "%@ messages"; +"Conversation.Moderate.Ban" = "Ban User"; +"Conversation.Moderate.Delete" = "Delete Message"; +"Conversation.Moderate.DeleteAllMessages" = "Delete All From %@"; +"Conversation.Moderate.Report" = "Report Spam"; +"Conversation.MoveReplyToAnotherChatTitle" = "Reply in..."; +"Conversation.Mute" = "Mute"; +"Conversation.Mute.ApplyMuteUntil" = "Mute until %@"; +"Conversation.Mute.SetupTitle" = "Mute Until..."; +"Conversation.MyNotes" = "My Notes"; +"Conversation.NoticeInvitedByInChannel" = "%@ invited you to this channel"; +"Conversation.NoticeInvitedByInGroup" = "%@ invited you to this group"; +"Conversation.OpenBotApp" = "LAUNCH"; +"Conversation.OpenBotLinkAllowMessages" = "Allow **%@** to send me messages"; +"Conversation.OpenBotLinkLogin" = "Log in to **%1$@** as %2$@"; +"Conversation.OpenBotLinkOpen" = "Open"; +"Conversation.OpenBotLinkText" = "Do you want to open\n**%@**?"; +"Conversation.OpenBotLinkTitle" = "Open Link"; +"Conversation.OpenChatFolder" = "VIEW CHAT LIST"; +"Conversation.OpenFile" = "Open File"; +"Conversation.OpenLink" = "OPEN LINK"; +"Conversation.OpenProfile" = "OPEN PROFILE"; +"Conversation.Owner" = "owner"; +"Conversation.PeerNearbyDistance" = "%1$@ is %2$@ away"; +"Conversation.PeerNearbyText" = "Send a message or tap on the greeting below to show that you are ready to chat."; +"Conversation.PeerNearbyTitle" = "%1$@ is %2$@ away"; +"Conversation.PhoneCopied" = "Phone copied to clipboard."; +"Conversation.Pin" = "Pin"; +"Conversation.PinMessageAlert.OnlyPin" = "Only Pin"; +"Conversation.PinMessageAlert.PinAndNotifyMembers" = "Pin and notify all members"; +"Conversation.PinMessageAlertGroup" = "Pin this message and notify all group members?"; +"Conversation.PinMessageAlertPin" = "Pin"; +"Conversation.PinMessagesFor" = "Pin for me and %@"; +"Conversation.PinMessagesForMe" = "Pin for me"; +"Conversation.PinOlderMessageAlertText" = "Do you want to pin an older message while leaving a more recent one pinned?"; +"Conversation.PinOlderMessageAlertTitle" = "Pin Message"; +"Conversation.PinnedGiveaway" = "Giveaway"; +"Conversation.PinnedGiveaway.Finished" = "%1$@ on %2$@"; +"Conversation.PinnedGiveaway.Finished.Winners#one" = "%@ winner was selected"; +"Conversation.PinnedGiveaway.Finished.Winners#other" = "%@ winners were selected"; +"Conversation.PinnedGiveaway.Ongoing" = "%1$@ on %2$@"; +"Conversation.PinnedGiveaway.Ongoing.Winners#one" = "%@ winner to be selected"; +"Conversation.PinnedGiveaway.Ongoing.Winners#other" = "%@ winners to be selected"; +"Conversation.PinnedMessage" = "Pinned Message"; +"Conversation.PinnedPoll" = "Pinned Poll"; +"Conversation.PinnedPreviousMessage" = "Previous Message"; +"Conversation.PinnedQuiz" = "Pinned Quiz"; +"Conversation.PremiumUploadFileTooLarge" = "File could not be sent because it is larger than 4 GB.\n\nYou can send as many files as you like, but each must be smaller than 4 GB."; +"Conversation.PressVolumeButtonForSound" = "Press volume button\nto unmute the video"; +"Conversation.PrivateChannelTimeLimitedAlertJoin" = "Join"; +"Conversation.PrivateChannelTimeLimitedAlertText" = "This channel is private. Please join it to continue viewing its content."; +"Conversation.PrivateChannelTimeLimitedAlertTitle" = "Join Channel"; +"Conversation.PrivateChannelTooltip" = "This channel is private"; +"Conversation.PrivateMessageLinkCopied" = "This link will only work for chat members"; +"Conversation.PrivateMessageLinkCopiedLong" = "Link copied to clipboard. This link will only work for members of this chat."; +"Conversation.Processing" = "Processing..."; +"Conversation.ReadAllPollVotes" = "Read All Votes"; +"Conversation.ReadAllReactions" = "Read All Reactions"; +"Conversation.ReadMore" = "Read More"; +"Conversation.ReplyMessagePanelTitle" = "Reply to %@"; +"Conversation.Report" = "Report Spam"; +"Conversation.ReportGroupLocation" = "Group unrelated to location?"; +"Conversation.ReportMessages" = "Report Messages"; +"Conversation.ReportSpam" = "Report Spam"; +"Conversation.ReportSpamAndLeave" = "Report Spam and Leave"; +"Conversation.ReportSpamChannelConfirmation" = "Are you sure you want to report this channel for spam?"; +"Conversation.ReportSpamConfirmation" = "Are you sure you want to report spam from this user?"; +"Conversation.ReportSpamGroupConfirmation" = "Are you sure you want to report this group for spam?"; +"Conversation.RequestToJoinChannel" = "REQUEST TO JOIN"; +"Conversation.RequestToJoinGroup" = "REQUEST TO JOIN"; +"Conversation.RequestsToJoin#one" = "%@ Join Request"; +"Conversation.RequestsToJoin#other" = "%@ Join Requests"; +"Conversation.RestrictedInline" = "The admins of this group have restricted your ability\u00a0to send inline content."; +"Conversation.RestrictedInlineTimed" = "The admins of this group have restricted your ability to send inline content until %@."; +"Conversation.RestrictedMedia" = "The admins of this group have restricted your ability to send media."; +"Conversation.RestrictedMediaTimed" = "The admins of this group have restricted your ability to send media until %@."; +"Conversation.RestrictedStickers" = "The admins of this group have restricted your ability to send stickers."; +"Conversation.RestrictedStickersTimed" = "The admins of this group have restricted your ability to send stickers until %@."; +"Conversation.RestrictedText" = "The admins of this group have restricted your ability to send messages."; +"Conversation.RestrictedTextTimed" = "The admins of this group have restricted you from messaging until %@."; +"Conversation.SaveGif" = "Add to GIFs"; +"Conversation.SaveToFiles" = "Save to Files"; +"Conversation.SavedMessages" = "Saved Messages"; +"Conversation.ScamWarning" = "⚠️ Warning: Many users reported this account as a scam or a fake account. Please be careful, especially if it asks you for money."; +"Conversation.ScheduleMessage.SendOn" = "Send on %@ at %@"; +"Conversation.ScheduleMessage.SendToday" = "Send today at %@"; +"Conversation.ScheduleMessage.SendTomorrow" = "Send tomorrow at %@"; +"Conversation.ScheduleMessage.SendWhenOnline" = "Send When Online"; +"Conversation.ScheduleMessage.Title" = "Schedule Message"; +"Conversation.ScheduledLiveStream" = "Scheduled Live Stream"; +"Conversation.ScheduledLiveStreamStartsOn" = "Live stream starts on %@"; +"Conversation.ScheduledLiveStreamStartsToday" = "Live stream starts today at %@"; +"Conversation.ScheduledLiveStreamStartsTomorrow" = "Live stream starts tomorrow at %@"; +"Conversation.ScheduledVoiceChat" = "Scheduled Video Chat"; +"Conversation.ScheduledVoiceChatStartsOn" = "Video chat starts %@"; +"Conversation.ScheduledVoiceChatStartsOnShort" = "Starts %@"; +"Conversation.ScheduledVoiceChatStartsToday" = "Video chat starts today at %@"; +"Conversation.ScheduledVoiceChatStartsTodayShort" = "Starts today at %@"; +"Conversation.ScheduledVoiceChatStartsTomorrow" = "Video chat starts tomorrow at %@"; +"Conversation.ScheduledVoiceChatStartsTomorrowShort" = "Starts tomorrow at %@"; +"Conversation.Search" = "Search"; +"Conversation.SearchByName.Placeholder" = "Search Members"; +"Conversation.SearchByName.Prefix" = "from: "; +"Conversation.SearchNoResults" = "No Results"; +"Conversation.SearchPlaceholder" = "Search this chat"; +"Conversation.SecretChatContextBotAlert" = "Please note that inline bots are provided by third-party developers. For the bot to work, the symbols you type after the bot's username are sent to the respective developer."; +"Conversation.SecretLinkPreviewAlert" = "Would you like to enable extended link previews in Secret Chats? Note that link previews are generated on Telegram servers."; +"Conversation.SelectMessages" = "Select Messages"; +"Conversation.SelectedMessages#one" = "%@ Selected"; +"Conversation.SelectedMessages#other" = "%@ Selected"; +"Conversation.SelectedMessagesFormat#one" = "{} Selected"; +"Conversation.SelectedMessagesFormat#other" = "{} Selected"; +"Conversation.SendDice" = "Send"; +"Conversation.SendMesageAs" = "Send Message As..."; +"Conversation.SendMesageAsPremiumInfo" = "Subscribe to **Telegram Premium** to be able to comment on behalf of your channels in any group chat."; +"Conversation.SendMessage" = "Send Message"; +"Conversation.SendMessage.ScheduleMessage" = "Schedule Message"; +"Conversation.SendMessage.SendSilently" = "Send Without Sound"; +"Conversation.SendMessage.SendWhenOnline" = "Send When Online"; +"Conversation.SendMessage.SetReminder" = "Set a Reminder"; +"Conversation.SendMessageErrorFlood" = "Sorry, you can only send messages to mutual contacts at the moment."; +"Conversation.SendMessageErrorGroupRestricted" = "Sorry, you are currently restricted from posting in this group."; +"Conversation.SendMessageErrorNonPremiumForbidden" = "Only Premium users can message %@"; +"Conversation.SendMessageErrorTooFast" = "You are sending messages too fast. Please wait a bit."; +"Conversation.SendMessageErrorTooFastTitle" = "Not so fast"; +"Conversation.SendMessageErrorTooMuchScheduled" = "Sorry, you can not schedule more than 100 messages."; +"Conversation.SendWhenOnlineTooltip" = "Long tap to send your message later."; +"Conversation.SendingOptionsTooltip" = "Hold this button to schedule your message\nor send it without sound."; +"Conversation.SetReminder.RemindOn" = "Remind on %@ at %@"; +"Conversation.SetReminder.RemindToday" = "Remind today at %@"; +"Conversation.SetReminder.RemindTomorrow" = "Remind tomorrow at %@"; +"Conversation.SetReminder.Title" = "Set a Reminder"; +"Conversation.ShareBotContactConfirmation" = "The bot will know your phone number. This can be useful for integration with other services.\n\n⚠️ Warning! Never enter your Telegram login codes in mini apps."; +"Conversation.ShareBotContactConfirmationTitle" = "Share Your Phone Number?"; +"Conversation.ShareBotLocationConfirmation" = "This will send your current location to the bot."; +"Conversation.ShareBotLocationConfirmationTitle" = "Share Your Location?"; +"Conversation.ShareInlineBotLocationConfirmation" = "This bot would like to know your location each time you send it a request. This can be used to provide location-specific results."; +"Conversation.ShareLinkTooltip.Chat.One" = "Link shared with **%@**"; +"Conversation.ShareLinkTooltip.ManyChats.One" = "Link shared with **%@** and %@ others."; +"Conversation.ShareLinkTooltip.TwoChats.One" = "Link shared with **%@** and **%@**."; +"Conversation.ShareMyContactInfo" = "Share My Contact Info"; +"Conversation.ShareMyPhoneNumber" = "Share My Phone Number"; +"Conversation.ShareMyPhoneNumber.StatusSuccess" = "%@ can now see your phone number."; +"Conversation.ShareMyPhoneNumberConfirmation" = "Do you want to share your phone number %1$@ with %2$@?"; +"Conversation.SilentBroadcastTooltipOff" = "Subscribers will receive a silent notification."; +"Conversation.SilentBroadcastTooltipOn" = "Subscribers will be notified when you post."; +"Conversation.SlideToCancel" = "Slide to cancel"; +"Conversation.StatusBotSubscribers#one" = "1 monthly user"; +"Conversation.StatusBotSubscribers#other" = "%d monthly users"; +"Conversation.StatusKickedFromChannel" = "you were removed from the channel"; +"Conversation.StatusKickedFromGroup" = "you were removed"; +"Conversation.StatusLeftGroup" = "you have left the group"; +"Conversation.StatusMembers#one" = "1 member"; +"Conversation.StatusMembers#other" = "%@ members"; +"Conversation.StatusOnline#one" = "1 online"; +"Conversation.StatusOnline#other" = "%@ online"; +"Conversation.StatusSubscribers#one" = "%@ subscriber"; +"Conversation.StatusSubscribers#other" = "%@ subscribers"; +"Conversation.StatusTyping" = "typing"; +"Conversation.StickerAddedToFavorites" = "Sticker added to Favorites."; +"Conversation.StickerRemovedFromFavorites" = "Sticker was removed from Favorites."; +"Conversation.StickerRemovedFromRecent" = "Sticker was removed from Recents."; +"Conversation.StopLiveLocation" = "Stop Sharing"; +"Conversation.StopPoll" = "Stop Poll"; +"Conversation.StopPollConfirmation" = "Stop Poll"; +"Conversation.StopPollConfirmationTitle" = "If you stop this poll now, nobody will be able to vote in it anymore. This action cannot be undone."; +"Conversation.StopQuiz" = "Stop Quiz"; +"Conversation.StopQuizConfirmation" = "Stop Quiz"; +"Conversation.StopQuizConfirmationTitle" = "If you stop this quiz now, nobody will be able to submit answers. This action cannot be undone."; +"Conversation.StopVoiceMessageDescription" = "Are you sure you want to pause recording?"; +"Conversation.StopVoiceMessageDiscardAction" = "Discard"; +"Conversation.StopVoiceMessagePauseAction" = "Pause"; +"Conversation.StopVoiceMessageTitle" = "Audio Recording"; +"Conversation.StoryExpiredMentionTextIncoming" = "The story you were mentioned in\nis no longer available"; +"Conversation.StoryExpiredMentionTextOutgoing" = "The story where you mentioned %@\nis no longer available"; +"Conversation.StoryForwardTooltip.Chat.One" = "Story forwarded to **%@**"; +"Conversation.StoryForwardTooltip.ManyChats.One" = "Story forwarded to **%@** and %@ others"; +"Conversation.StoryForwardTooltip.SavedMessages.One" = "Story forwarded to **Saved Messages**"; +"Conversation.StoryForwardTooltip.TwoChats.One" = "Story forwarded to **%@** and **%@**"; +"Conversation.StoryMentionTextIncoming" = "%@ mentioned you\nin a story"; +"Conversation.StoryMentionTextOutgoing" = "You mentioned %@\nin a story"; +"Conversation.SuggestedBirthdate.Day" = "Day"; +"Conversation.SuggestedBirthdate.Month" = "Month"; +"Conversation.SuggestedBirthdate.View" = "View"; +"Conversation.SuggestedBirthdate.Year" = "Year"; +"Conversation.SuggestedBirthdateText" = "**%@** suggested you add your birthday"; +"Conversation.SuggestedBirthdateTextYou" = "You suggested **%@** to add a birthday"; +"Conversation.SuggestedPhotoSuccess" = "Photo updated"; +"Conversation.SuggestedPhotoSuccessText" = "You can change it in [Settings]()."; +"Conversation.SuggestedPhotoText" = "**%@** suggests this photo for your Telegram profile."; +"Conversation.SuggestedPhotoTextExpanded" = "%@ suggests this photo for your Telegram profile."; +"Conversation.SuggestedPhotoTextYou" = "You suggested this photo for **%@'s** Telegram profile."; +"Conversation.SuggestedPhotoTitle" = "Suggested Photo"; +"Conversation.SuggestedPhotoView" = "View Photo"; +"Conversation.SuggestedVideoSuccess" = "Profile Photo Updated"; +"Conversation.SuggestedVideoSuccessText" = "You can change it in [Settings]()."; +"Conversation.SuggestedVideoText" = "**%@** suggests this photo for your Telegram profile."; +"Conversation.SuggestedVideoTextExpanded" = "%@ suggests this photo for your Telegram profile."; +"Conversation.SuggestedVideoTextYou" = "You suggested this photo for **%@'s** Telegram profile."; +"Conversation.SuggestedVideoTitle" = "Suggested Photo"; +"Conversation.SuggestedVideoView" = "View Photo"; +"Conversation.Summary.Limit.Text" = "Summarize large messages with AI – unlimited with Telegram Premium."; +"Conversation.Summary.Limit.Title" = "AI Summary"; +"Conversation.Summary.Text" = "Tap to see original text"; +"Conversation.Summary.Title" = "AI Summary"; +"Conversation.SwipeToReplyHintText" = "Swipe left on any message to reply to it."; +"Conversation.SwipeToReplyHintTitle" = "Swipe To Reply"; +"Conversation.TapAndHoldToRecord" = "Tap and hold to record"; +"Conversation.TextCopied" = "Text copied to clipboard."; +"Conversation.Theme" = "Color Theme"; +"Conversation.Theme.Apply" = "Apply Theme"; +"Conversation.Theme.ChooseColorTitle" = "Set a Color"; +"Conversation.Theme.ChooseWallpaperTitle" = "Choose Wallpaper"; +"Conversation.Theme.DismissAlert" = "Do you want to apply the selected theme to the chat?"; +"Conversation.Theme.DismissAlertApply" = "Apply"; +"Conversation.Theme.DontSetTheme" = "Do Not Set Theme"; +"Conversation.Theme.GiftTransfer.Proceed" = "Yes"; +"Conversation.Theme.GiftTransfer.Text" = "This gift is already your theme in the chat with **%@**. Remove it there and use it here instead?"; +"Conversation.Theme.NoTheme" = "No\nTheme"; +"Conversation.Theme.PreviewDark" = "Tap to view how %@ will see the chat when using night mode."; +"Conversation.Theme.PreviewDarkShort" = "Tap to view this theme in night mode."; +"Conversation.Theme.PreviewLight" = "Tap to view how %@ will see the chat when using day mode."; +"Conversation.Theme.PreviewLightShort" = "Tap to view this theme in day mode."; +"Conversation.Theme.Reset" = "Reset Theme for This Chat"; +"Conversation.Theme.ResetWallpaper" = "Remove Wallpaper"; +"Conversation.Theme.SetColorWallpaper" = "Set a Color as Wallpaper"; +"Conversation.Theme.SetCustomColor" = "Set Custom"; +"Conversation.Theme.SetNewPhotoWallpaper" = "Choose a New Wallpaper"; +"Conversation.Theme.SetPhotoWallpaper" = "Choose Wallpaper from Photos"; +"Conversation.Theme.Subtitle" = "Theme will be also applied for %@"; +"Conversation.Theme.SwitchToDark" = "Switch to dark appearance"; +"Conversation.Theme.SwitchToLight" = "Switch to light appearance"; +"Conversation.Theme.Title" = "Select Theme"; +"Conversation.Timer.Send" = "Send With Timer"; +"Conversation.Timer.Title" = "Send With Timer"; +"Conversation.TitleComments#one" = "[%@]Comment"; +"Conversation.TitleComments#other" = "[%@]Comments"; +"Conversation.TitleCommentsEmpty" = "Comments"; +"Conversation.TitleCommentsFormat" = "%1$@ %2$@"; +"Conversation.TitleMute" = "Mute"; +"Conversation.TitleNoComments" = "Comments"; +"Conversation.TitleReplies#one" = "[%@]Reply"; +"Conversation.TitleReplies#other" = "[%@]Replies"; +"Conversation.TitleRepliesEmpty" = "Replies"; +"Conversation.TitleRepliesFormat" = "%1$@ %2$@"; +"Conversation.TitleUnmute" = "Unmute"; +"Conversation.Translation.AddedToDoNotTranslateText" = "**%@** added to your Do Not Translate list."; +"Conversation.Translation.ChooseLanguage" = "Choose Another Language"; +"Conversation.Translation.CocoonInfo" = "Translations are powered by\n**#Cocoon**. [How does it work?]()"; +"Conversation.Translation.DoNotTranslate" = "Do Not Translate %@"; +"Conversation.Translation.DoNotTranslateOther" = "Do Not Translate %@"; +"Conversation.Translation.Hide" = "Hide"; +"Conversation.Translation.Settings" = "Settings"; +"Conversation.Translation.ShowOriginal" = "Show Original"; +"Conversation.Translation.TranslateTo" = "Translate to %@"; +"Conversation.Translation.TranslateToOther" = "Translate to %@"; +"Conversation.Translation.TranslationBarHiddenChannelText" = "The translation bar is now hidden for this channel."; +"Conversation.Translation.TranslationBarHiddenChatText" = "Translation bar hidden for this chat."; +"Conversation.Translation.TranslationBarHiddenGroupText" = "The translation bar is now hidden for this group."; +"Conversation.Unarchive" = "Unarchive"; +"Conversation.UnarchiveDone" = "The chat was moved to your main list."; +"Conversation.Unblock" = "Unblock"; +"Conversation.UnblockUser" = "Unblock User"; +"Conversation.Unmute" = "Unmute"; +"Conversation.Unpin" = "Unpin"; +"Conversation.UnpinMessageAlert" = "Would you like to unpin this message?"; +"Conversation.UnreadMessages" = "Unread Messages"; +"Conversation.UnsupportedMedia" = "This message is not supported by your version of Telegram. Update the app to view: https://telegram.org/update"; +"Conversation.UnsupportedMediaPlaceholder" = "This message is not supported by your version of Telegram. Please update to the latest version."; +"Conversation.UnvotePoll" = "Retract Vote"; +"Conversation.UpdateTelegram" = "UPDATE TELEGRAM"; +"Conversation.UploadFileTooLarge" = "File could not be sent, because it is larger than 2 GB.\n\nYou can send as many files as you like, but each must be smaller than 2 GB."; +"Conversation.UserSendMessage" = "SEND MESSAGE"; +"Conversation.UsernameCopied" = "Username copied to clipboard."; +"Conversation.UsersTooMuchError" = "Unable to join this group because it has too many members."; +"Conversation.VideoTimeLinkCopied" = "Link with start time at %@ copied to clipboard."; +"Conversation.ViewBackground" = "VIEW WALLPAPER"; +"Conversation.ViewBot" = "VIEW BOT"; +"Conversation.ViewChannel" = "VIEW CHANNEL"; +"Conversation.ViewContactDetails" = "VIEW CONTACT"; +"Conversation.ViewEmojis" = "VIEW EMOJIS"; +"Conversation.ViewGroup" = "VIEW GROUP"; +"Conversation.ViewInChannel" = "View in Channel"; +"Conversation.ViewMessage" = "VIEW MESSAGE"; +"Conversation.ViewPollStats" = "View Statistics"; +"Conversation.ViewPost" = "VIEW POST"; +"Conversation.ViewReply" = "View in Chat"; +"Conversation.ViewStarGift" = "VIEW COLLECTIBLE"; +"Conversation.ViewStickers" = "VIEW STICKERS"; +"Conversation.ViewTheme" = "VIEW THEME"; +"Conversation.VoiceChat" = "Video Chat"; +"Conversation.VoiceChatMediaRecordingRestricted" = "You can't record voice and video messages during a video chat."; +"Conversation.VoiceMessagesRestricted" = "%@ doesn't accept voice or video messages"; +"Conversation.typing" = "typing"; +"ConversationMedia.Title" = "Media"; +"ConversationProfile.ErrorCreatingConversation" = "An error occurred"; +"ConversationProfile.LeaveDeleteAndExit" = "Delete and Leave"; +"ConversationProfile.UnknownAddMemberError" = "An unexpected error has occurred. Our wizards have been notified and will fix the problem soon. Sorry."; +"ConversationProfile.UsersTooMuchError" = "Sorry, this group is full. You cannot add any more members here."; +"ConvertToSupergroup.HelpText" = "• New members can see the full message history\n• Deleted messages will disappear for all members\n• Admins can add a description to the group\n• Creator can set a public link for the group"; +"ConvertToSupergroup.HelpTitle" = "**In supergroups:**"; +"ConvertToSupergroup.Note" = "**Note**: this action can't be undone."; +"ConvertToSupergroup.Title" = "Supergroup"; +"Core.ServiceUserStatus" = "service notifications"; +"Coub.TapForSound" = "Tap for sound"; +"CountriesList.MaximumReached" = "You can select up to %@."; +"CountriesList.MaximumReached.Countries#one" = "%@ country"; +"CountriesList.MaximumReached.Countries#other" = "%@ countries"; +"CountriesList.SaveCountries" = "Save Countries"; +"CountriesList.Search" = "Search"; +"CountriesList.SelectCountries" = "Select Countries"; +"CountriesList.SelectUpTo#one" = "select up to %@ country"; +"CountriesList.SelectUpTo#other" = "select up to %@ countries"; +"CreateBot.ActionButton" = "Create"; +"CreateBot.InputBadge" = "edit"; +"CreateBot.NamePlaceholder" = "Name"; +"CreateBot.SectionName" = "BOT NAME"; +"CreateBot.SectionUsername" = "BOT USERNAME"; +"CreateBot.Text" = "[%@]() would like to create and manage a chatbot on your behalf."; +"CreateBot.Title" = "Create Bot"; +"CreateBot.UnsavedAlert.Discard" = "Discard"; +"CreateBot.UnsavedAlert.Text" = "You have not finished creating a bot."; +"CreateBot.UnsavedAlert.Title" = "Unsaved Changes"; +"CreateBot.UsernameStatus.Checking" = "Checking..."; +"CreateBot.UsernameStatus.Invalid" = "You can only use **a-z**, **0-9** and underscores."; +"CreateBot.UsernameStatus.LimitExceeded#one" = "You can't create more than %d bot."; +"CreateBot.UsernameStatus.LimitExceeded#other" = "You can't create more than %d bots."; +"CreateBot.UsernameStatus.Link" = "Link: t.me/%@"; +"CreateBot.UsernameStatus.Number" = "A username can't start with a number."; +"CreateBot.UsernameStatus.Short" = "A username must have at least 5 characters."; +"CreateBot.UsernameStatus.Taken" = "This username is already taken."; +"CreateExternalStream.Revoke.Revoke" = "Revoke"; +"CreateExternalStream.Revoke.Text" = "Do you want to revoke the stream key?"; +"CreateExternalStream.RevokeStreamKey" = "Revoke Stream Key"; +"CreateExternalStream.ServerUrl" = "server URL"; +"CreateExternalStream.StartStreaming" = "Start Streaming"; +"CreateExternalStream.StartStreamingInfo" = "Once you start broadcasting in your streaming\napp, tap Start Streaming below."; +"CreateExternalStream.StreamKey" = "stream key"; +"CreateExternalStream.StreamKeyTitle" = "Stream Key"; +"CreateExternalStream.Text" = "To stream video with another app, enter\nthis Server URL and Stream Key in your\nstreaming app."; +"CreateExternalStream.Title" = "Stream With..."; +"CreateGroup.AutoDeleteText" = "Automatically delete messages in this group for everyone after a period of time."; +"CreateGroup.AutoDeleteTitle" = "Auto-Delete Messages"; +"CreateGroup.ChannelsTooMuch" = "Sorry, you are a member of too many groups and channels. Please leave some before creating a new one."; +"CreateGroup.ErrorLocatedGroupsTooMuch" = "Sorry, you have too many location-based groups already. Please delete one of your existing ones first."; +"CreateGroup.PeersTitleDelimeter" = ", "; +"CreateGroup.PeersTitleLastDelimeter" = " and "; +"CreateGroup.PublicLinkInfo" = "You can use **a-z**, **0-9** and underscores. Minimum length is **5** characters."; +"CreateGroup.PublicLinkTitle" = "SET A PUBLIC LINK"; +"CreateGroup.SoftUserLimitAlert" = "You will be able to add more members after you create the group."; +"CreatePoll.AddMoreOptions#one" = "You can add 1 more option."; +"CreatePoll.AddMoreOptions#other" = "You can add %@ more options."; +"CreatePoll.AddOption" = "Add an Option"; +"CreatePoll.AddingOptions" = "Allow Adding Options"; +"CreatePoll.AddingOptionsInfo" = "Participants can suggest new options"; +"CreatePoll.AllOptionsAdded" = "You have added the maximum number of options."; +"CreatePoll.AllowedCountries" = "Allowed Countries"; +"CreatePoll.AllowedCountries.Countries#one" = "%@ country"; +"CreatePoll.AllowedCountries.Countries#other" = "%@ countries"; +"CreatePoll.Anonymous" = "Anonymous Voting"; +"CreatePoll.CancelConfirmation" = "Do you want to discard this poll?"; +"CreatePoll.Create" = "Send"; +"CreatePoll.Deadline.SetDeadline" = "Set Deadline"; +"CreatePoll.Deadline.Title" = "Deadline"; +"CreatePoll.DescriptionPlaceholder" = "Add Description (optional)"; +"CreatePoll.DiscardPoll" = "Discard Poll?"; +"CreatePoll.EnableQuiz" = "Set Correct Answer"; +"CreatePoll.EnableQuizInfo" = "Mark one option as the right answer"; +"CreatePoll.EnableQuizMultiInfo" = "Mark one or more options as the right answer"; +"CreatePoll.Explanation" = "Add an Explanation (Optional)"; +"CreatePoll.ExplanationHeader" = "EXPLANATION"; +"CreatePoll.ExplanationInfo" = "Users will see this comment after choosing a wrong answer, good for educational purposes."; +"CreatePoll.LimitCountry" = "Limit by Country"; +"CreatePoll.LimitCountryInfo" = "Only users from selected countries can vote"; +"CreatePoll.LimitDuration" = "Limit Duration"; +"CreatePoll.LimitDuration.Duration" = "Duration"; +"CreatePoll.LimitDuration.HideResults" = "Hide Results"; +"CreatePoll.LimitDuration.HideResultsInfo" = "When enabled, results will not be visible until the poll closes."; +"CreatePoll.LimitDuration.PollEnds" = "Poll Ends"; +"CreatePoll.LimitDurationInfo" = "Automatically close the poll at a set time"; +"CreatePoll.Link.Description" = "Attach a link to this option."; +"CreatePoll.Media.Delete" = "Delete"; +"CreatePoll.Media.Replace" = "Replace"; +"CreatePoll.MediaUploading.Text" = "Poll media is still uploading..."; +"CreatePoll.MediaUploading.Title" = "Please Wait"; +"CreatePoll.MultiAnswer" = "Allow Multiple Answers"; +"CreatePoll.MultiAnswerInfo" = "Voters can select more than one option"; +"CreatePoll.MultipleChoice" = "Multiple Answers"; +"CreatePoll.MultipleChoiceQuizAlert" = "Polls in quiz mode can have only one correct answer."; +"CreatePoll.OptionCountFooterFormat#one" = "You can add {count} more option."; +"CreatePoll.OptionCountFooterFormat#other" = "You can add {count} more options."; +"CreatePoll.OptionPlaceholder" = "Option"; +"CreatePoll.OptionsHeader" = "POLL OPTIONS"; +"CreatePoll.OptionsNeeded" = "Add at least two options"; +"CreatePoll.OptionsNeededOne" = "Add at least one option"; +"CreatePoll.OptionsTitle" = "OPTIONS"; +"CreatePoll.QuestionNeeded" = "Enter a question"; +"CreatePoll.Quiz" = "Quiz Mode"; +"CreatePoll.QuizCorrectOptionNeeded" = "Mark the correct option"; +"CreatePoll.QuizCorrectOptionNeededMultiple" = "Select at least one correct option"; +"CreatePoll.QuizCountryNeeded" = "Select at least one country"; +"CreatePoll.QuizInfo" = "Polls in Quiz Mode have one correct answer. Users can't revoke their answers."; +"CreatePoll.QuizOptionsHeader" = "QUIZ OPTIONS"; +"CreatePoll.QuizTip" = "Tap to choose the correct answer"; +"CreatePoll.QuizTitle" = "New Quiz"; +"CreatePoll.RestrictToSubscribers" = "Restrict to Subscribers"; +"CreatePoll.RestrictToSubscribersInfo" = "Only subscribers who joined 24+ hours ago can vote"; +"CreatePoll.Revoting" = "Allow Revoting"; +"CreatePoll.RevotingInfo" = "Voters can change their vote"; +"CreatePoll.SettingsTitle" = "SETTINGS"; +"CreatePoll.ShowWhoVoted" = "Show Who Voted"; +"CreatePoll.ShowWhoVotedInfo" = "Display voter names on each option"; +"CreatePoll.ShuffleOptions" = "Shuffle Options"; +"CreatePoll.ShuffleOptionsInfo" = "Answers appear in a random order for each voter"; +"CreatePoll.TextHeader" = "QUESTION"; +"CreatePoll.TextPlaceholder" = "Ask a Question"; +"CreatePoll.TimeLimit.Custom" = "Custom"; +"CreatePoll.Title" = "New Poll"; +"CreateTodo.AddTaskPlaceholder" = "Add a Task"; +"CreateTodo.AddTitle" = "Add a Task"; +"CreateTodo.AllowOthersToAppend" = "Allow Others to Add Tasks"; +"CreateTodo.AllowOthersToComplete" = "Allow Others to Mark as Done"; +"CreateTodo.EditTitle" = "Edit Checklist"; +"CreateTodo.Save" = "Save"; +"CreateTodo.Send" = "Send"; +"CreateTodo.TaskCountFooterFormat#one" = "You can add {count} more task."; +"CreateTodo.TaskCountFooterFormat#other" = "You can add {count} more tasks."; +"CreateTodo.TaskCountLimitReached" = "Maximum number of tasks reached."; +"CreateTodo.TaskPlaceholder" = "Task"; +"CreateTodo.Title" = "New Checklist"; +"CreateTodo.TitlePlaceholder" = "Title"; +"CreateTodo.TodoTitle" = "CHECKLIST"; +"CreateTopic.Create" = "Create"; +"CreateTopic.CreateTitle" = "New Topic"; +"CreateTopic.EditTitle" = "Edit Topic"; +"CreateTopic.EnterTopicTitle" = "ENTER TOPIC NAME"; +"CreateTopic.EnterTopicTitlePlaceholder" = "What do you want to discuss?"; +"CreateTopic.SelectTopicIcon" = "SELECT TOPIC ICON"; +"CreateTopic.ShowGeneral" = "Show in Topics"; +"CreateTopic.ShowGeneralInfo" = "If the \"General\" topic is hidden, group members can pull down in the topic list to view it."; +"Currency.Grams#one" = "%@ Gram"; +"Currency.Grams#other" = "%@ Grams"; +"DataUpgrade.Running" = "Optimizing..."; +"DataUsage.AutoDownloadSettings" = "Auto-Download Settings"; +"DataUsage.Header" = "Data Usage"; +"DataUsage.HeaderNoDataUsed" = "No data used"; +"DataUsage.InfoMobileUsageSinceTime" = "Your mobile data usage since %@"; +"DataUsage.InfoTotalUsageSinceTime" = "Your data usage since %@"; +"DataUsage.InfoWifiUsageSinceTime" = "Your Wi-Fi data usage since %@"; +"DataUsage.MediaDirectionIncoming" = "Incoming"; +"DataUsage.MediaDirectionOutgoing" = "Outgoing"; +"DataUsage.Reset" = "Reset Statistics"; +"DataUsage.SectionTotalIncoming" = "Data Received"; +"DataUsage.SectionTotalOutgoing" = "Data Sent"; +"DataUsage.SectionUsageMobile" = "MOBILE NETWORK USAGE"; +"DataUsage.SectionUsageTotal" = "TOTAL NETWORK USAGE"; +"DataUsage.SectionUsageWifi" = "WI-FI NETWORK USAGE"; +"DataUsage.SectionsInfo" = "Tap on each section for details."; +"DataUsage.SettingsHelpCellular" = "You can change your auto-download settings to reduce mobile data usage."; +"DataUsage.SettingsHelpWifi" = "You can change your auto-download settings for media to reduce data usage when on wifi."; +"DataUsage.TopSectionAll" = "All"; +"DataUsage.TopSectionMobile" = "Mobile"; +"DataUsage.TopSectionWifi" = "Wi-Fi"; +"Date.ChatDateHeader" = "%1$@ %2$@"; +"Date.ChatDateHeaderYear" = "%1$@ %2$@, %3$@"; +"Date.DialogDateFormat" = "{month} {day}"; +"DeleteAccount.AlternativeOptionsTitle" = "Alternative Options"; +"DeleteAccount.CloudStorageText" = "You will lose access to all your Saved Messages as well as all messages, media and files from your chats."; +"DeleteAccount.CloudStorageTitle" = "Your Free Cloud Storage"; +"DeleteAccount.ComeBackLater" = "Come Back Later"; +"DeleteAccount.ConfirmationAlertDelete" = "Delete My Account"; +"DeleteAccount.ConfirmationAlertText" = "Deleting your account will permanently delete your data!\n\nIt is impossible to reverse this action!"; +"DeleteAccount.ConfirmationAlertTitle" = "Proceed to Delete Your Account?"; +"DeleteAccount.Continue" = "Continue"; +"DeleteAccount.DeleteMessagesURL" = "https://telegram.org/faq#q-can-i-delete-my-messages"; +"DeleteAccount.DeleteMyAccount" = "Delete My Account"; +"DeleteAccount.DeleteMyAccountTitle" = "Delete My Account"; +"DeleteAccount.EnterPassword" = "Enter Your Password"; +"DeleteAccount.EnterPhoneNumber" = "Enter Your Phone Number"; +"DeleteAccount.GroupsAndChannelsInfo" = "You can transfer group and channel ownership to other users via Chat Info > Edit > Admins."; +"DeleteAccount.GroupsAndChannelsText" = "The groups and channels you created will either get new admins or become orphaned."; +"DeleteAccount.GroupsAndChannelsTitle" = "Your Groups and Channels"; +"DeleteAccount.InvalidPasswordError" = "Invalid password. Please try again."; +"DeleteAccount.InvalidPhoneNumberError" = "Invalid phone number. Please try again."; +"DeleteAccount.MessageHistoryText" = "Your chat partners will keep their message history with you, including the messages you shared in Secret Chats.\n\nYou can remove any messages for both sides at any time, but this will not be possible if you delete your account."; +"DeleteAccount.MessageHistoryTitle" = "Your Message History"; +"DeleteAccount.Options.AddAccountPremiumText" = "You can use up to 4 accounts in one app at the same time."; +"DeleteAccount.Options.AddAccountText" = "You can use up to 3 accounts in one app at the same time."; +"DeleteAccount.Options.AddAccountTitle" = "Add Another Account"; +"DeleteAccount.Options.ChangePhoneNumberText" = "Move your contacts, chats and media to a new number."; +"DeleteAccount.Options.ChangePhoneNumberTitle" = "Change Phone Number"; +"DeleteAccount.Options.ChangePrivacyText" = "Choose who exactly can see which of your info."; +"DeleteAccount.Options.ChangePrivacyTitle" = "Change Your Privacy Settings"; +"DeleteAccount.Options.ClearCacheText" = "Free up disk space on your device, your media will stay in the cloud."; +"DeleteAccount.Options.ClearCacheTitle" = "Clear Cache"; +"DeleteAccount.Options.ClearSyncedContactsText" = "Remove any unnecessary contacts you may have synced."; +"DeleteAccount.Options.ClearSyncedContactsTitle" = "Clear Synced Contacts"; +"DeleteAccount.Options.ContactSupportText" = "Tell us about any issues; deleting the account doesn't usually help."; +"DeleteAccount.Options.ContactSupportTitle" = "Contact Support"; +"DeleteAccount.Options.DeleteChatsText" = "Learn how to remove any info you don’t need in a few taps."; +"DeleteAccount.Options.DeleteChatsTitle" = "Quickly Delete Your Chats"; +"DeleteAccount.Options.SetPasscodeText" = "Lock the app with a passcode so that others can't open it."; +"DeleteAccount.Options.SetPasscodeTitle" = "Set a Passcode"; +"DeleteAccount.Options.SetTwoStepAuthText" = "Set a password that will be required each time you log in."; +"DeleteAccount.Options.SetTwoStepAuthTitle" = "Enable Two-Step Verification"; +"DeleteAccount.SavedMessages" = "Saved"; +"DeleteAccount.Success" = "The account has been successfully deleted."; +"DialogList.AdLabel" = "Proxy Sponsor"; +"DialogList.AdNoticeAlert" = "This channel is shown by your proxy server. To remove this channel from your chat list, disable the proxy in Telegram Settings > Data and Storage."; +"DialogList.AwaitingEncryption" = "Waiting for %@ to come online..."; +"DialogList.ClearHistoryConfirmation" = "Clear History"; +"DialogList.DeleteBotClearHistory" = "Clear Chat History"; +"DialogList.DeleteBotConfirmation" = "Delete"; +"DialogList.DeleteBotConversationConfirmation" = "Delete and Block"; +"DialogList.DeleteConversationConfirmation" = "Delete and Leave"; +"DialogList.Draft" = "Draft"; +"DialogList.EncryptedChatStartedIncoming" = "%@ created a secret chat."; +"DialogList.EncryptedChatStartedOutgoing" = "%@ joined your secret chat."; +"DialogList.EncryptionProcessing" = "Exchanging encryption keys..."; +"DialogList.EncryptionRejected" = "Secret Chat canceled"; +"DialogList.LanguageTooltip" = "You can change your language later in Settings"; +"DialogList.LiveLocationChatsCount#one" = "sharing with 1 chat"; +"DialogList.LiveLocationChatsCount#other" = "sharing with %@ chats"; +"DialogList.LiveLocationSharingTo" = "sharing with %@"; +"DialogList.MultipleTyping" = "%@ and %@"; +"DialogList.MultipleTypingPair" = "%@ and %@ are typing"; +"DialogList.MultipleTypingSuffix" = "%d are typing"; +"DialogList.MyNotes" = "My Notes"; +"DialogList.NoMessagesText" = "Start messaging by tapping the pencil button in the top right corner or go to the Contacts section."; +"DialogList.NoMessagesTitle" = "You have no conversations yet"; +"DialogList.PasscodeLockHelp" = "Tap to lock Telegram"; +"DialogList.Pin" = "Pin"; +"DialogList.PinLimitError" = "Sorry, you can pin no more than %@ chats to the top."; +"DialogList.ProxyConnectionIssuesTooltip" = "Can't connect to your preferred proxy.\nTap to change settings."; +"DialogList.Read" = "Read"; +"DialogList.RecentTitlePeople" = "People"; +"DialogList.Replies" = "Replies"; +"DialogList.SavedMessages" = "Saved Messages"; +"DialogList.SavedMessagesHelp" = "Forward messages here for quick access"; +"DialogList.SavedMessagesTooltip" = "You can find your Saved Messages in Settings"; +"DialogList.SearchLabel" = "Search chats"; +"DialogList.SearchLabelCompact" = "Search"; +"DialogList.SearchSectionChats" = "Chats"; +"DialogList.SearchSectionDialogs" = "Chats and Contacts"; +"DialogList.SearchSectionGlobal" = "Global Search"; +"DialogList.SearchSectionMessages" = "Messages"; +"DialogList.SearchSectionMessagesIn" = "Messages in %@"; +"DialogList.SearchSectionPublicPosts" = "Public Posts"; +"DialogList.SearchSectionRecent" = "Recent"; +"DialogList.SearchSectionTopics" = "Topics"; +"DialogList.SearchSubtitleFormat" = "%1$@, %2$@"; +"DialogList.SingleChoosingStickerSuffix" = "%@ is choosing a sticker"; +"DialogList.SinglePlayingGameSuffix" = "%@ is playing a game"; +"DialogList.SingleRecordingAudioSuffix" = "%@ is recording"; +"DialogList.SingleRecordingVideoMessageSuffix" = "%@ is recording video"; +"DialogList.SingleTypingSuffix" = "%@ is typing"; +"DialogList.SingleUploadingFileSuffix" = "%@ is sending a file"; +"DialogList.SingleUploadingPhotoSuffix" = "%@ is sending a photo"; +"DialogList.SingleUploadingVideoSuffix" = "%@ is sending video"; +"DialogList.TabTitle" = "Chats"; +"DialogList.Title" = "Chats"; +"DialogList.Typing" = "typing"; +"DialogList.UnknownPinLimitError" = "Sorry, you can't pin any more chats to the top."; +"DialogList.Unpin" = "Unpin"; +"DialogList.Unread" = "Unread"; +"DialogList.You" = "You"; +"DisableSharing.Confirm" = "Disable Sharing"; +"DisableSharing.Forwarding.Text" = "Disable forwarding messages to other chats."; +"DisableSharing.Forwarding.Title" = "No Forwarding"; +"DisableSharing.Saving.Text" = "Disable copying text and saving photos or videos."; +"DisableSharing.Saving.Title" = "No Saving"; +"DisableSharing.Screenshot.Text" = "Disable screenshots and screen recordings in this chat."; +"DisableSharing.Screenshot.Title" = "No Screenshots"; +"DisableSharing.Title" = "Disable Sharing"; +"DoNotTranslate.Title" = "Do Not Translate"; +"Document.TargetConfirmationFormat" = "Send file ({size}) to {target}?"; +"DownloadList.CancelDownloading" = "Cancel Download"; +"DownloadList.Clear" = "Clear"; +"DownloadList.ClearAlertText" = "Telegram allows to store all received and sent\ndocuments in the cloud and save storage\nspace on your device."; +"DownloadList.ClearAlertTitle" = "Downloaded Files"; +"DownloadList.ClearDownloadList" = "Clear Download List"; +"DownloadList.DeleteFromCache" = "Delete from Cache"; +"DownloadList.DownloadedHeader" = "Recently Downloaded"; +"DownloadList.DownloadingHeader" = "Downloading"; +"DownloadList.IncreaseSpeed" = "Increase Speed"; +"DownloadList.OptionManageDeviceStorage" = "Manage Device Storage"; +"DownloadList.PauseAll" = "Pause All"; +"DownloadList.RaisePriority" = "Prioritize Download"; +"DownloadList.RemoveFileAlertRemove" = "Remove"; +"DownloadList.RemoveFileAlertText#one" = "Do you want to remove this\nitem from Downloads?\nIt will be deleted from your disk, but\nwill remain accessible in the cloud."; +"DownloadList.RemoveFileAlertText#other" = "Do you want to remove these\n%@ items from Downloads?\nThey will be deleted from your disk, but\nwill remain accessible in the cloud."; +"DownloadList.RemoveFileAlertTitle#one" = "Remove Download?"; +"DownloadList.RemoveFileAlertTitle#other" = "Remove %@ Downloads?"; +"DownloadList.ResumeAll" = "Resume All"; +"DownloadingStatus" = "downloading %@ of %@"; +"ENCRYPTED_MESSAGE" = "You have a new message%1$@"; +"ENCRYPTION_ACCEPT" = "Your secret chat was accepted%1$@"; +"ENCRYPTION_REQUEST" = "New secret chat request%1$@"; +"EditProfile.NameAndPhotoHelp" = "Enter your name and add an optional profile photo."; +"EditProfile.NameAndPhotoOrVideoHelp" = "Enter your name and add an optional profile photo."; +"EditProfile.Title" = "Edit Profile"; +"EditRank.AddLater" = "Add Later"; +"EditRank.AddTag" = "Add Tag"; +"EditRank.EditTag" = "Edit Tag"; +"EditRank.Info" = "Add a short tag next to %@'s name."; +"EditRank.InfoYou" = "Share your role, title or how you're known in this group. Your tag is visible to all members."; +"EditRank.Placeholder" = "Add Tag"; +"EditRank.RemoveTag" = "Remove Tag"; +"EditRank.Title" = "Member Tag"; +"EditTheme.ChangeColors" = "Change Colors"; +"EditTheme.Create.BottomInfo" = "You can also use a manually edited custom theme file."; +"EditTheme.Create.Preview.IncomingReplyName" = "Bob"; +"EditTheme.Create.Preview.IncomingReplyText" = "How does it work?"; +"EditTheme.Create.Preview.IncomingText" = "Use your current colors"; +"EditTheme.Create.Preview.OutgoingText" = "Or upload a theme file"; +"EditTheme.Create.TopInfo" = "The theme will be based on your currently selected colors and wallpaper."; +"EditTheme.CreateTitle" = "Create Theme"; +"EditTheme.Edit.BottomInfo" = "You can select a new file to update the theme. It will be updated for all users."; +"EditTheme.Edit.Preview.IncomingReplyName" = "Bob"; +"EditTheme.Edit.Preview.IncomingReplyText" = "How does it work?"; +"EditTheme.Edit.Preview.IncomingText" = "Use your current colors"; +"EditTheme.Edit.Preview.OutgoingText" = "Or upload a theme file"; +"EditTheme.Edit.TopInfo" = "Your theme will be updated for all users each time you change it. Anyone can install it using this link.\n\nTheme links must be at least **5** characters long and can use **a–z**, **0–9** and underscores."; +"EditTheme.EditTitle" = "Edit Theme"; +"EditTheme.ErrorInvalidCharacters" = "Sorry, this link is invalid."; +"EditTheme.ErrorLinkTaken" = "Sorry, this link is already taken"; +"EditTheme.Expand.BottomInfo" = "You can also use a manually edited custom theme file."; +"EditTheme.Expand.Preview.IncomingReplyName" = "Bob"; +"EditTheme.Expand.Preview.IncomingReplyText" = "How does it work?"; +"EditTheme.Expand.Preview.IncomingText" = "Use your current colors"; +"EditTheme.Expand.Preview.OutgoingText" = "Or upload a theme file"; +"EditTheme.Expand.TopInfo" = "The theme will be based on your currently selected colors and wallpaper."; +"EditTheme.FileReadError" = "Invalid theme file"; +"EditTheme.Preview" = "CHAT PREVIEW"; +"EditTheme.ShortLink" = "link"; +"EditTheme.ShortLinkInfo" = "Your theme will be updated for all users each time you change it. Anyone can install it using this link.\n\nTheme links must be longer than 5 characters and can use a-z, 0-9 and underscores."; +"EditTheme.ThemeTemplateAlert" = "New Theme Added\n\nPress and hold on your theme to edit it or get a sharing link. Users who install your theme will get automatic updates each time you change it.\n\nFor advanced editing purposes, you can find a file with your theme in Saved Messages."; +"EditTheme.ThemeTemplateAlertText" = "Press and hold on your theme to edit it or get a sharing link. Users who install your theme will get automatic updates each time you change it.\n\nFor advanced editing purposes, you can find a file with your theme in Saved Messages."; +"EditTheme.ThemeTemplateAlertTitle" = "New Theme Added"; +"EditTheme.Title" = "Theme Name"; +"EditTheme.UploadEditedInfo" = "You can update your theme for all users by uploading manual changes from a file."; +"EditTheme.UploadEditedTheme" = "Update from File..."; +"EditTheme.UploadNewInfo" = "The theme will be based on your currently selected colors and wallpaper. Alternatively, you can use a manually edited custom theme file."; +"EditTheme.UploadNewTheme" = "Create from File..."; +"Embed.PlayingInPIP" = "This video is playing in Picture in Picture"; +"Emoji.ClearRecent" = "Clear Recent Emoji"; +"Emoji.FrequentlyUsed" = "Recently Used"; +"Emoji.GroupEmoji" = "GROUP EMOJI"; +"EmojiInput.AddPack" = "Add %@"; +"EmojiInput.PanelTitleEmoji" = "Emoji"; +"EmojiInput.PanelTitlePremium" = "Premium"; +"EmojiInput.PanelTitleRecent" = "Recent"; +"EmojiInput.PremiumEmojiToast.Action" = "More"; +"EmojiInput.PremiumEmojiToast.Text" = "Subscribe to Telegram Premium to unlock premium emoji."; +"EmojiInput.PremiumEmojiToast.TryAction" = "Open"; +"EmojiInput.PremiumEmojiToast.TryText" = "Try sending these emoji in your **Saved Messages** for free to test."; +"EmojiInput.SectionTitleCollectibles" = "COLLECTIBLES"; +"EmojiInput.SectionTitleEmoji" = "Emoji"; +"EmojiInput.SectionTitleFavoriteStickers" = "Favorite Stickers"; +"EmojiInput.SectionTitlePremiumStickers" = "Premium Stickers"; +"EmojiInput.TabEmoji" = "Emoji"; +"EmojiInput.TabGifs" = "GIFs"; +"EmojiInput.TabMasks" = "Masks"; +"EmojiInput.TabStickers" = "Stickers"; +"EmojiInput.TrendingEmoji" = "TRENDING EMOJI"; +"EmojiInput.UnlockPack" = "Unlock %@"; +"EmojiPack.Add" = "Add"; +"EmojiPack.Added" = "Added"; +"EmojiPack.Emoji#one" = "%@ emoji"; +"EmojiPack.Emoji#other" = "%@ emoji"; +"EmojiPack.Title" = "Emoji"; +"EmojiPackActionInfo.AddedText" = "%@ has been added to your emoji."; +"EmojiPackActionInfo.AddedTitle" = "Emoji Added"; +"EmojiPackActionInfo.ArchivedTitle" = "Emoji Archived"; +"EmojiPackActionInfo.MultipleAddedText#one" = "%@ pack has been added to your emoji."; +"EmojiPackActionInfo.MultipleAddedText#other" = "%@ packs have been added to your emoji."; +"EmojiPackActionInfo.MultipleRemovedText#one" = "%@ pack is no longer in your emoji."; +"EmojiPackActionInfo.MultipleRemovedText#other" = "%@ packs are no longer in your emoji."; +"EmojiPackActionInfo.RemovedText" = "%@ is no longer in your emoji."; +"EmojiPackActionInfo.RemovedTitle" = "Emoji Removed"; +"EmojiPacks.UnarchiveEmojiPacksConfirmation#one" = "Unarchive %@ Pack"; +"EmojiPacks.UnarchiveEmojiPacksConfirmation#other" = "Unarchive %@ Packs"; +"EmojiPacksSettings.ArchivedPacks" = "Archived Emoji"; +"EmojiPacksSettings.ArchivedPacks.Info" = "You can have up to 200 emoji sets installed.\nUnused emojis are archived when you add more."; +"EmojiPacksSettings.Title" = "Emoji"; +"EmojiPreview.CopyEmoji" = "Copy Emoji"; +"EmojiPreview.SendEmoji" = "Send Emoji"; +"EmojiPreview.SetAsStatus" = "Set as Status"; +"EmojiSearch.SearchEmojiEmptyResult" = "No emoji found"; +"EmojiSearch.SearchEmojiPlaceholder" = "Search Emoji"; +"EmojiSearch.SearchReactionsEmptyResult" = "No emoji found"; +"EmojiSearch.SearchReactionsPlaceholder" = "Search Reactions"; +"EmojiSearch.SearchStatusesEmptyResult" = "No emoji found"; +"EmojiSearch.SearchStatusesPlaceholder" = "Search Statuses"; +"EmojiSearch.SearchStickersEmptyResult" = "No emoji found"; +"EmojiSearch.SearchTopicIconsEmptyResult" = "No emoji found"; +"EmojiSearch.SearchTopicIconsPlaceholder" = "Search Topic Icons"; +"EmojiStake.Description" = "An emoji game for users with\u00a0**Level 5**\u00a0or higher. 💎 earned in the game can only be spent on Telegram gifts and posts."; +"EmojiStake.ResultsTitle" = "RESULTS AND RETURNS"; +"EmojiStake.Roll" = "Save and Roll"; +"EmojiStake.StakePlaceholder" = "Amount"; +"EmojiStake.StakeTitle" = "STAKE"; +"EmojiStake.StreakInfo" = "A streak resets after 3 # or a stake change."; +"EmojiStake.Title" = "Emoji Stake"; +"EmojiStatus.AppliedText" = "Your emoji status has been updated."; +"EmojiStatusSetup.SetUntil" = "Set Until"; +"EmojiStatusSetup.TimerOther" = "Other"; +"EmojiStickerSettings.Info" = "Artists are welcome to add their own emoji packs with our @stickers bot.\n\nPress and hold a message to view and add the whole pack."; +"EmptyGroupInfo.Line1" = "Up to 200,000 members"; +"EmptyGroupInfo.Line2" = "Persistent chat history"; +"EmptyGroupInfo.Line3" = "Public links such as t.me/title"; +"EmptyGroupInfo.Line4" = "Admins with different rights"; +"EmptyGroupInfo.Subtitle" = "Groups can have:"; +"EmptyGroupInfo.Title" = "You created a group"; +"EmptyState.AwayMessage.Text" = "Add messages that are automatically sent when you are off."; +"EmptyState.AwayMessage.Title" = "New Away Message"; +"EmptyState.GreetingMessage.Text" = "Create greetings that will be automatically sent to new customers"; +"EmptyState.GreetingMessage.Title" = "New Greeting Message"; +"EnableSharing.SendRequest" = "Send Request"; +"EnableSharing.Text" = "You need **%@**'s approval to enable sharing. Send a request?"; +"EnableSharing.Title" = "Enable Sharing"; +"EncryptionKey.Description" = "This image and text were derived from the encryption key for this secret chat with %1$@.\n\nIf they look the same on %2$@'s device, end-to-end encryption is guaranteed.\n\nLearn more at telegram.org"; +"EncryptionKey.Title" = "Encryption Key"; +"EnterPasscode.ChangeTitle" = "Change Passcode"; +"EnterPasscode.EnterCurrentPasscode" = "Enter your current passcode"; +"EnterPasscode.EnterNewPasscodeChange" = "Enter your new passcode"; +"EnterPasscode.EnterNewPasscodeNew" = "Enter a passcode"; +"EnterPasscode.EnterPasscode" = "Enter your Telegram Passcode"; +"EnterPasscode.EnterTitle" = "Enter your Telegram Passcode"; +"EnterPasscode.RepeatNewPasscode" = "Re-enter your new passcode"; +"EnterPasscode.TouchId" = "Unlock Telegram"; +"Exceptions.AddToExceptions" = "ADD TO EXCEPTIONS"; +"ExplicitContent.AlertChannel" = "You can't access this channel because it violates App Store rules."; +"ExplicitContent.AlertTitle" = "Sorry"; +"External.OpenIn" = "Open in %@"; +"FaceScan.Instruction.Position" = "Position your face\nwithin the frame"; +"FaceScan.Instruction.Rotate" = "Move your head slowly to\ncomplete the circle"; +"FactCheck.Placeholder" = "Add Fact Check"; +"FactCheck.Remove" = "Remove"; +"FactCheck.Title" = "Fact Check"; +"FastTwoStepSetup.EmailHelp" = "Please add a valid email address. It is the only way to recover a forgotten password."; +"FastTwoStepSetup.EmailPlaceholder" = "Your email"; +"FastTwoStepSetup.EmailSection" = "RECOVERY EMAIL"; +"FastTwoStepSetup.HintHelp" = "You can create an optional hint for your password."; +"FastTwoStepSetup.HintPlaceholder" = "Add a hint"; +"FastTwoStepSetup.HintSection" = "HINT"; +"FastTwoStepSetup.PasswordConfirmationPlaceholder" = "Re-enter your password"; +"FastTwoStepSetup.PasswordHelp" = "Please create a password to protect your payment info. You'll be asked to enter it when you log in."; +"FastTwoStepSetup.PasswordPlaceholder" = "Enter a password"; +"FastTwoStepSetup.PasswordSection" = "PASSWORD"; +"FastTwoStepSetup.Title" = "Password & Email"; +"FeatureDisabled.Oops" = "Oops"; +"FeaturedStickerPacks.Title" = "Trending Stickers"; +"FeaturedStickers.OtherSection" = "OTHER STICKERS"; +"FileSize.B" = "%@ B"; +"FileSize.GB" = "%@ GB"; +"FileSize.KB" = "%@ KB"; +"FileSize.MB" = "%@ MB"; +"FolderLinkPreview.ButtonAddFolder" = "Add Folder"; +"FolderLinkPreview.ButtonDoNotJoinChats" = "Do Not Join Any Chats"; +"FolderLinkPreview.ButtonJoinChats" = "Join Chats"; +"FolderLinkPreview.ButtonRemoveFolder" = "Remove Folder"; +"FolderLinkPreview.ButtonRemoveFolderAndChats" = "Remove Folder and Chats"; +"FolderLinkPreview.ChatSectionHeader#one" = "1 CHAT IN THIS FOLDER"; +"FolderLinkPreview.ChatSectionHeader#other" = "%d CHATS IN THIS FOLDER"; +"FolderLinkPreview.ChatSectionJoinHeader#one" = "1 CHAT TO JOIN"; +"FolderLinkPreview.ChatSectionJoinHeader#other" = "%d CHATS TO JOIN"; +"FolderLinkPreview.IconTabLeft" = "All Chats"; +"FolderLinkPreview.IconTabRight" = "Personal"; +"FolderLinkPreview.LabelPeerMember" = "You are already a member"; +"FolderLinkPreview.LabelPeerMembers#one" = "%d member"; +"FolderLinkPreview.LabelPeerMembers#other" = "%d members"; +"FolderLinkPreview.LabelPeerSubscriber" = "You are already a subscriber"; +"FolderLinkPreview.LabelPeerSubscribers#one" = "%d subscriber"; +"FolderLinkPreview.LabelPeerSubscribers#other" = "%d subscribers"; +"FolderLinkPreview.LinkSectionHeader" = "INVITE LINKS"; +"FolderLinkPreview.ListSelectionSelectAllDynamicPartDeselect" = "DESELECT"; +"FolderLinkPreview.ListSelectionSelectAllDynamicPartSelect" = "SELECT"; +"FolderLinkPreview.ListSelectionSelectAllFormat" = "{dynamic}{static}"; +"FolderLinkPreview.ListSelectionSelectAllStaticPartDeselect" = "ALL"; +"FolderLinkPreview.ListSelectionSelectAllStaticPartSelect" = "ALL"; +"FolderLinkPreview.RemoveSectionSelectedHeader#one" = "%d CHAT TO \u200cLEAVE"; +"FolderLinkPreview.RemoveSectionSelectedHeader#other" = "%d CHATS TO LEAVE"; +"FolderLinkPreview.TextAddChats" = "Do you want to add **%1$@** to the\nfolder **%2$@**?"; +"FolderLinkPreview.TextAddChatsCount#one" = "%d chat"; +"FolderLinkPreview.TextAddChatsCount#other" = "%d chats"; +"FolderLinkPreview.TextAddChatsV2" = "Do you want to add {chats} to the\nfolder {folder}?"; +"FolderLinkPreview.TextAddFolder" = "Do you want to add a new chat folder\nand join its groups and channels?"; +"FolderLinkPreview.TextAllAdded" = "You have already added this\nfolder and its chats."; +"FolderLinkPreview.TextLinkList" = "Create more links to set up different access\nlevels for different people."; +"FolderLinkPreview.TextRemoveFolder" = "Do you also want to leave the chats included in this folder?"; +"FolderLinkPreview.TitleAddChats#one" = "Add %d Chat"; +"FolderLinkPreview.TitleAddChats#other" = "Add %d Chats"; +"FolderLinkPreview.TitleAddFolder" = "Add Folder"; +"FolderLinkPreview.TitleRemove" = "Remove Folder"; +"FolderLinkPreview.TitleShare" = "Share Folder"; +"FolderLinkPreview.ToastAlreadyMemberChannel" = "You are already subscribed to this channel."; +"FolderLinkPreview.ToastAlreadyMemberGroup" = "You are already a member of this group."; +"FolderLinkPreview.ToastChatsAddedText#one" = "You have joined %d new chat"; +"FolderLinkPreview.ToastChatsAddedText#other" = "You have joined %d new chats"; +"FolderLinkPreview.ToastChatsAddedTitle" = "Folder %@ Updated"; +"FolderLinkPreview.ToastChatsAddedTitleV2" = "Folder {folder} Updated"; +"FolderLinkPreview.ToastFolderAddedText#one" = "You also joined %d chat"; +"FolderLinkPreview.ToastFolderAddedText#other" = "You also joined %d chats"; +"FolderLinkPreview.ToastFolderAddedTitle" = "Folder %@ Added"; +"FolderLinkPreview.ToastFolderAddedTitleV2" = "Folder {folder} Added"; +"FolderLinkPreview.ToastLeftChatsText#one" = "You also left **%d** chat"; +"FolderLinkPreview.ToastLeftChatsText#other" = "You also left **%d** chats"; +"FolderLinkPreview.ToastLeftTitle" = "Folder %@ deleted"; +"FolderLinkPreview.ToastLeftTitleV2" = "Folder {folder} deleted"; +"FolderLinkScreen.AlertTextUnavailableBot" = "You can't share chats with bots"; +"FolderLinkScreen.AlertTextUnavailablePrivateChannel" = "You don't have the admin rights to share invite links to this private channel."; +"FolderLinkScreen.AlertTextUnavailablePrivateGroup" = "You don't have the admin rights to share invite links to this private group."; +"FolderLinkScreen.AlertTextUnavailablePublicChannel" = "You don't have the admin rights to share invite links to this channel."; +"FolderLinkScreen.AlertTextUnavailablePublicGroup" = "You don't have the admin rights to share invite links to this group chat."; +"FolderLinkScreen.AlertTextUnavailableUser" = "You can't share private chats"; +"FolderLinkScreen.ChatCountHeaderUnavailable" = "There are no chats in this folder that you can share with others."; +"FolderLinkScreen.ChatsSectionHeader" = "CHATS"; +"FolderLinkScreen.ChatsSectionHeaderActionDeselectAll" = "DESELECT ALL"; +"FolderLinkScreen.ChatsSectionHeaderActionSelectAll" = "SELECT ALL"; +"FolderLinkScreen.ChatsSectionHeaderSelected#one" = "1 CHAT SELECTED"; +"FolderLinkScreen.ChatsSectionHeaderSelected#other" = "%d CHATS SELECTED"; +"FolderLinkScreen.ChatsSectionHeaderUnavailable" = "THESE CHATS CANNOT BE SHARED"; +"FolderLinkScreen.ContextActionNameLink" = "Name Link"; +"FolderLinkScreen.LabelCanInvite" = "you can invite users here"; +"FolderLinkScreen.LabelUnavailableBot" = "chats with bots can’t be shared"; +"FolderLinkScreen.LabelUnavailableGeneric" = "you can't invite users here"; +"FolderLinkScreen.LabelUnavailableUser" = "chats with users can't be shared"; +"FolderLinkScreen.LinkActionCopy" = "Copy"; +"FolderLinkScreen.LinkActionShare" = "Share"; +"FolderLinkScreen.LinkSectionHeader" = "INVITE LINK"; +"FolderLinkScreen.NameLink.Title" = "Name This Link"; +"FolderLinkScreen.SaveAlertActionApply" = "Apply"; +"FolderLinkScreen.SaveAlertActionContinue" = "Cancel"; +"FolderLinkScreen.SaveAlertActionDiscard" = "Discard"; +"FolderLinkScreen.SaveAlertText" = "You have changed the settings of this folder. Apply changes?"; +"FolderLinkScreen.SaveAlertTitle" = "Unsaved Changes"; +"FolderLinkScreen.SaveUnknownError" = "An error occurred while updating the link"; +"FolderLinkScreen.Title" = "Share Folder"; +"FolderLinkScreen.TitleDescriptionDeselected" = "Anyone with this link can add the folder **%@** and the chats selected below."; +"FolderLinkScreen.TitleDescriptionDeselectedV2" = "Anyone with this link can add {folder} folder and the chats selected below."; +"FolderLinkScreen.TitleDescriptionSelected" = "Anyone with this link can add the folder **%1$@** and %2$@ selected below."; +"FolderLinkScreen.TitleDescriptionSelectedCount#one" = "the chat"; +"FolderLinkScreen.TitleDescriptionSelectedCount#other" = "the %d chats"; +"FolderLinkScreen.TitleDescriptionSelectedV2" = "Anyone with this link can add {folder} folder and {chats} selected below."; +"FolderLinkScreen.TitleDescriptionUnavailable" = "You can only share groups and channels in which you are allowed to create invite links."; +"FolderLinkScreen.ToastLinkUpdated" = "Link updated"; +"FolderLinkScreen.ToastNewChatAdded" = "People who already used the invite link will be able to join newly added chats."; +"ForcedPasswordSetup.Intro.Action" = "Set a Password"; +"ForcedPasswordSetup.Intro.DismissActionCancel" = "No, let me set a password"; +"ForcedPasswordSetup.Intro.DismissActionOK" = "Yes, I'm sure"; +"ForcedPasswordSetup.Intro.DismissText#one" = "Proceed without a password? If you do not set a password, you will only be able to log into your account via SMS once every **day**."; +"ForcedPasswordSetup.Intro.DismissText#other" = "Proceed without a password? If you do not set a password, you will only be able to log into your account via SMS once every **%@ days**."; +"ForcedPasswordSetup.Intro.DismissTitle" = "Warning"; +"ForcedPasswordSetup.Intro.DoneAction" = "Done"; +"ForcedPasswordSetup.Intro.Text" = "If you want to log into your account frequently, please choose a password."; +"ForcedPasswordSetup.Intro.Title" = "Set a Password"; +"FormattedDate.DaysAgo#one" = "%@ day ago"; +"FormattedDate.DaysAgo#other" = "%@ days ago"; +"FormattedDate.HoursAgo#one" = "%@ hour ago"; +"FormattedDate.HoursAgo#other" = "%@ hours ago"; +"FormattedDate.InDays#one" = "in %@ day"; +"FormattedDate.InDays#other" = "in %@ days"; +"FormattedDate.InHours#one" = "in %@ hour"; +"FormattedDate.InHours#other" = "in %@ hours"; +"FormattedDate.InMinutes#one" = "in %@ minute"; +"FormattedDate.InMinutes#other" = "in %@ minutes"; +"FormattedDate.InSeconds#one" = "in %@ second"; +"FormattedDate.InSeconds#other" = "in %@ seconds"; +"FormattedDate.LongDate_m1" = "January %1$@, %2$@"; +"FormattedDate.LongDate_m10" = "October %1$@, %2$@"; +"FormattedDate.LongDate_m11" = "November %1$@, %2$@"; +"FormattedDate.LongDate_m12" = "December %1$@, %2$@"; +"FormattedDate.LongDate_m2" = "February %1$@, %2$@"; +"FormattedDate.LongDate_m3" = "March %1$@, %2$@"; +"FormattedDate.LongDate_m4" = "April %1$@, %2$@"; +"FormattedDate.LongDate_m5" = "May %1$@, %2$@"; +"FormattedDate.LongDate_m6" = "June %1$@, %2$@"; +"FormattedDate.LongDate_m7" = "July %1$@, %2$@"; +"FormattedDate.LongDate_m8" = "August %1$@, %2$@"; +"FormattedDate.LongDate_m9" = "September %1$@, %2$@"; +"FormattedDate.MinutesAgo#one" = "%@ minute ago"; +"FormattedDate.MinutesAgo#other" = "%@ minutes ago"; +"FormattedDate.SecondsAgo#one" = "%@ second ago"; +"FormattedDate.SecondsAgo#other" = "%@ seconds ago"; +"Forward.ChannelReadOnly" = "Sorry, you can't post to this channel."; +"Forward.ConfirmMultipleFiles#one" = "Send 1 file to {target}?"; +"Forward.ConfirmMultipleFiles#other" = "Send %@ files to {target}?"; +"Forward.ErrorDisabledForChat" = "Sorry, you can't forward messages to this chat."; +"Forward.ErrorPublicPollDisabledInChannels" = "Sorry, polls with visible votes can’t be forwarded to channels."; +"Forward.ErrorPublicQuizDisabledInChannels" = "Sorry, polls with visible votes can’t be forwarded to channels."; +"Forward.ErrorTodoDisabledInChannels" = "Sorry, checklists can’t be forwarded to channels."; +"ForwardedAudios#one" = "Forwarded voice message"; +"ForwardedAudios#other" = "%@ forwarded voice messages"; +"ForwardedAuthors2" = "%@, %@"; +"ForwardedAuthorsOthers#one" = "%@ and 1 other"; +"ForwardedAuthorsOthers#other" = "%@ and %@ others"; +"ForwardedContacts#one" = "Forwarded contact"; +"ForwardedContacts#other" = "%@ forwarded contacts"; +"ForwardedDices#one" = "Forwarded dice"; +"ForwardedDices#other" = "%@ forwarded dices"; +"ForwardedFiles#one" = "Forwarded file"; +"ForwardedFiles#other" = "%@ forwarded files"; +"ForwardedGifs#one" = "Forwarded GIF"; +"ForwardedGifs#other" = "%@ forwarded GIFs"; +"ForwardedLocations#one" = "Forwarded location"; +"ForwardedLocations#other" = "%@ forwarded locations"; +"ForwardedMessages#one" = "Forwarded message"; +"ForwardedMessages#other" = "%@ forwarded messages"; +"ForwardedPhotos#one" = "Forwarded photo"; +"ForwardedPhotos#other" = "%@ forwarded photos"; +"ForwardedPolls#one" = "Forwarded poll"; +"ForwardedPolls#other" = "%@ forwarded polls"; +"ForwardedStickers#one" = "Forwarded sticker"; +"ForwardedStickers#other" = "%@ forwarded stickers"; +"ForwardedVideoMessages#one" = "Forwarded video message"; +"ForwardedVideoMessages#other" = "%@ forwarded video messages"; +"ForwardedVideos#one" = "Forwarded video"; +"ForwardedVideos#other" = "%@ forwarded videos"; +"FrozenAccount.Appeal.Text" = "Appeal via [@SpamBot]() before %@, or your account will be deleted."; +"FrozenAccount.Appeal.Title" = "Appeal Before Deactivation"; +"FrozenAccount.ReadOnly.Text" = "You can access your account but can't send messages or take actions."; +"FrozenAccount.ReadOnly.Title" = "Read-Only Mode"; +"FrozenAccount.SubmitAppeal" = "Submit an Appeal"; +"FrozenAccount.Title" = "Your Account is Frozen"; +"FrozenAccount.Understood" = "Understood"; +"FrozenAccount.Violation.Text" = "Your account was frozen for violating Telegram’s Terms of Service."; +"FrozenAccount.Violation.TextNew" = "Your account was frozen for breaking Telegram's [Terms of Service]()."; +"FrozenAccount.Violation.TextNew_URL" = "https://telegram.org/tos"; +"FrozenAccount.Violation.Title" = "Violation of Terms"; +"Gallery.Ad" = "Ad"; +"Gallery.AirPlay" = "AirPlay"; +"Gallery.AirPlayPlaceholder" = "This video is playing on the TV using AirPlay"; +"Gallery.CreateSticker" = "Create Sticker"; +"Gallery.GifSaved" = "Added to Favorite GIFs."; +"Gallery.ImageSaved" = "Image saved to Photos."; +"Gallery.ImagesAndVideosSaved" = "Media saved to Photos."; +"Gallery.Live" = "LIVE"; +"Gallery.MenuSaveToGallery" = "Save to Photos"; +"Gallery.SaveImage" = "Save Image"; +"Gallery.SaveToGallery" = "Save to Gallery"; +"Gallery.SaveToGallery.Original" = "Save Original"; +"Gallery.SaveToGallery.Quality" = "Save in %@p"; +"Gallery.SaveToGallery.cached" = "cached"; +"Gallery.SaveVideo" = "Save Video"; +"Gallery.ToastVideoSpeedSwipe" = "Swipe sideways to adjust speed."; +"Gallery.VideoSaved" = "Video saved to Photos."; +"Gallery.VideoSettings.IconQualityFHD" = "FHD"; +"Gallery.VideoSettings.IconQualityHD" = "HD"; +"Gallery.VideoSettings.IconQualityLow" = "L"; +"Gallery.VideoSettings.IconQualityMedium" = "SD"; +"Gallery.VideoSettings.IconQualityQHD" = "QHD"; +"Gallery.VideoSettings.QualityAuto" = "Auto"; +"Gallery.VideoSettings.QualityFHD" = "Full HD"; +"Gallery.VideoSettings.QualityHD" = "High"; +"Gallery.VideoSettings.QualityLow" = "Low"; +"Gallery.VideoSettings.QualityMedium" = "Medium"; +"Gallery.VideoSettings.QualityQHD" = "Ultra-High"; +"Gallery.VideoSettings.QualitySectionTitle" = "QUALITY"; +"Gallery.VideoSettings.SpeedControlTitle" = "Speed"; +"Gallery.VideoSettings.SpeedSectionTitle" = "PLAYBACK SPEED"; +"Gallery.ViewOncePhotoTooltip" = "This photo can only be viewed once."; +"Gallery.ViewOnceVideoTooltip" = "This video can only be viewed once."; +"Gallery.VoiceOver.Delete" = "Delete"; +"Gallery.VoiceOver.Edit" = "Edit"; +"Gallery.VoiceOver.Fullscreen" = "Fullscreen"; +"Gallery.VoiceOver.PictureInPicture" = "Picture-in-Picture"; +"Gallery.VoiceOver.Share" = "Share"; +"Gallery.VoiceOver.Stickers" = "Stickers"; +"Gallery.WaitForVideoDownoad" = "Please wait until the video is fully downloaded."; +"Generic.ErrorMoreInfo" = "Learn More"; +"Generic.OpenHiddenLinkAlert" = "Open %@?"; +"Gif.Emotion.Angry" = "Angry"; +"Gif.Emotion.Cool" = "Cool"; +"Gif.Emotion.Hearts" = "Hearts"; +"Gif.Emotion.Joy" = "Joy"; +"Gif.Emotion.Kiss" = "Kiss"; +"Gif.Emotion.Party" = "Party"; +"Gif.Emotion.RollEyes" = "Roll-Eyes"; +"Gif.Emotion.Surprised" = "Surprised"; +"Gif.Emotion.ThumbsDown" = "Thumbs Down"; +"Gif.Emotion.ThumbsUp" = "Thumbs Up"; +"Gif.NoGifsFound" = "No GIFs Found"; +"Gif.NoGifsPlaceholder" = "No Saved GIFs"; +"Gif.Search" = "Search GIFs"; +"GifSearch.SearchGifPlaceholder" = "Search GIFs"; +"Gift.Acquired.AcceptedBid" = "Accepted Bid"; +"Gift.Acquired.Date" = "Date"; +"Gift.Acquired.GiftRound" = "%1$@ in round %2$@"; +"Gift.Acquired.Recipient" = "Recipient"; +"Gift.Acquired.Round" = "Round #%@"; +"Gift.Acquired.Title#one" = "%@ Item Bought"; +"Gift.Acquired.Title#other" = "%@ Items Bought"; +"Gift.Acquired.Top" = "TOP %@"; +"Gift.ActiveAuctions.Outbid" = "Your bid of **%@** was outbid"; +"Gift.ActiveAuctions.RaiseBid" = "Raise Bid"; +"Gift.ActiveAuctions.Round" = "Round %@ of %@"; +"Gift.ActiveAuctions.Title#one" = "%@ Active Auction"; +"Gift.ActiveAuctions.Title#other" = "%@ Active Auctions"; +"Gift.ActiveAuctions.UpcomingBid" = "You've placed an early bid"; +"Gift.ActiveAuctions.Winning" = "Your bid of **%@** is ranked **#%@**"; +"Gift.Attribute.Epic" = "epic"; +"Gift.Attribute.Legendary" = "legendary"; +"Gift.Attribute.Rare" = "rare"; +"Gift.Attribute.Uncommon" = "uncommon"; +"Gift.Auction.Auction" = "auction"; +"Gift.Auction.Availability" = "Availability"; +"Gift.Auction.AvailabilityOf" = "%@ of %@ left"; +"Gift.Auction.AveragePrice" = "Average Price"; +"Gift.Auction.AveragePriceInfo" = "**%1$@** is the average sale price for %2$@ gifts."; +"Gift.Auction.Context.About" = "Learn More"; +"Gift.Auction.Context.CopyLink" = "Copy Link"; +"Gift.Auction.Context.Share" = "Share"; +"Gift.Auction.CurrentRound" = "Current Round"; +"Gift.Auction.Description" = "Top %@ bidders will get **%@** gifts this round. [Learn More >]()"; +"Gift.Auction.EarlyBid" = "Place an Early Bid"; +"Gift.Auction.End" = "End"; +"Gift.Auction.Ended" = "Auction Ended"; +"Gift.Auction.Ends" = "Ends"; +"Gift.Auction.Extension" = "+ %1$@ for late bids in top %2$@"; +"Gift.Auction.FirstSale" = "First Sale"; +"Gift.Auction.ForSaleOnFragment" = "for sale on Fragment"; +"Gift.Auction.ForSaleOnTelegram" = "for sale on Telegram"; +"Gift.Auction.GiftAuction" = "Gift Auction"; +"Gift.Auction.Hours#one" = "%@ hour"; +"Gift.Auction.Hours#other" = "%@ hours"; +"Gift.Auction.HoursEach#one" = "%@ hour each"; +"Gift.Auction.HoursEach#other" = "%@ hours each"; +"Gift.Auction.Info.Carryover.Text" = "If your bid leaves the top %@, it will automatically join the next round."; +"Gift.Auction.Info.Carryover.Title" = "Bid Carryover"; +"Gift.Auction.Info.Description" = "Join the battle for exclusive gifts."; +"Gift.Auction.Info.Missed.Text" = "If your bid doesn't win after the final round, your Stars will be fully refunded."; +"Gift.Auction.Info.Missed.Title" = "Missed Bidders"; +"Gift.Auction.Info.Title" = "Auction"; +"Gift.Auction.Info.TopBidders.Bidders#one" = "%@ bidder"; +"Gift.Auction.Info.TopBidders.Bidders#other" = "%@ bidders"; +"Gift.Auction.Info.TopBidders.Gifts#one" = "%@ gift"; +"Gift.Auction.Info.TopBidders.Gifts#other" = "%@ gifts"; +"Gift.Auction.Info.TopBidders.Rounds#one" = "%@ round"; +"Gift.Auction.Info.TopBidders.Rounds#other" = "%@ rounds"; +"Gift.Auction.Info.TopBidders.Text" = "%@ are dropped in %@ to the top %@ by bid amount."; +"Gift.Auction.Info.TopBidders.Title#one" = "Top %@ Bidder"; +"Gift.Auction.Info.TopBidders.Title#other" = "Top %@ Bidders"; +"Gift.Auction.Info.Understood" = "Understood"; +"Gift.Auction.ItemsBought#one" = "item bought"; +"Gift.Auction.ItemsBought#other" = "items bought"; +"Gift.Auction.Join" = "Join Auction"; +"Gift.Auction.LastSale" = "Last Sale"; +"Gift.Auction.LearnMore" = "Learn more about Telegram Gifts >"; +"Gift.Auction.Minutes#one" = "%@ minute"; +"Gift.Auction.Minutes#other" = "%@ minutes"; +"Gift.Auction.MinutesEach#one" = "%@ minute each"; +"Gift.Auction.MinutesEach#other" = "%@ minutes each"; +"Gift.Auction.Quantity" = "Quantity"; +"Gift.Auction.Round" = "%@ of %@"; +"Gift.Auction.Seconds#one" = "%@ second"; +"Gift.Auction.Seconds#other" = "%@ seconds"; +"Gift.Auction.Stars#one" = "%@ Star"; +"Gift.Auction.Stars#other" = "%@ Stars"; +"Gift.Auction.Start" = "Start"; +"Gift.Auction.Started" = "Started"; +"Gift.Auction.StartsInHours" = "auction starts in {h}:{m}:{s}"; +"Gift.Auction.StartsInMinutes" = "auction starts in {m}:{s}"; +"Gift.Auction.TimeLeftHours" = "{h}h {m}m left"; +"Gift.Auction.TimeLeftMinutes" = "{m}:{s} left"; +"Gift.Auction.TimeRound" = "Round %@"; +"Gift.Auction.TimeRounds" = "Rounds %@"; +"Gift.Auction.TotalRounds" = "Rounds"; +"Gift.Auction.UpcomingAuction" = "Upcoming Auction"; +"Gift.Auction.Variants#one" = "%@ Variant"; +"Gift.Auction.Variants#other" = "%@ Variants"; +"Gift.Auction.ViewVariants" = "View"; +"Gift.AuctionBid.AddMoreStars" = "Add at least %@ to increase your bid."; +"Gift.AuctionBid.AddMoreStars.Set" = "Set"; +"Gift.AuctionBid.AddMoreStars.Stars#one" = "%@ Star"; +"Gift.AuctionBid.AddMoreStars.Stars#other" = "%@ Stars"; +"Gift.AuctionBid.AddToBid" = "Add %@ to Your Bid"; +"Gift.AuctionBid.BeforeStart" = "before start"; +"Gift.AuctionBid.BidPreview" = "Your bid will be"; +"Gift.AuctionBid.Custom" = "Custom"; +"Gift.AuctionBid.CustomBid.Done" = "Place a Bid"; +"Gift.AuctionBid.CustomBid.Placeholder" = "Amount"; +"Gift.AuctionBid.CustomBid.Text" = "If you fall below the top %@, your bid will roll over to the next round."; +"Gift.AuctionBid.CustomBid.Title" = "Place a Custom Bid"; +"Gift.AuctionBid.Increased.Text" = "If you fall below the top %@, your bid will roll over to the next round."; +"Gift.AuctionBid.Increased.Title" = "Your bid has been increased"; +"Gift.AuctionBid.Left" = "left"; +"Gift.AuctionBid.MinimumBid" = "minimum bid"; +"Gift.AuctionBid.MinimumBidIncreased" = "The minimum bid increased to **%@**."; +"Gift.AuctionBid.Outbid" = "You've been outbid"; +"Gift.AuctionBid.PlaceBid" = "Place a %@ Bid"; +"Gift.AuctionBid.Placed.Text" = "If you fall below the top %@, your bid will roll over to the next round."; +"Gift.AuctionBid.Placed.Title" = "Your bid has been placed"; +"Gift.AuctionBid.RoundSubtitle" = "Round %@ of %@"; +"Gift.AuctionBid.Subtitle" = "Top %@ bidders will win"; +"Gift.AuctionBid.Title" = "Place a Bid"; +"Gift.AuctionBid.Top" = "TOP %@"; +"Gift.AuctionBid.TopWinners" = "Top 3 Winners"; +"Gift.AuctionBid.TopWinnersTotal" = "Top 3 of %@ Winners"; +"Gift.AuctionBid.UntilEnd" = "until the end"; +"Gift.AuctionBid.UntilNext" = "until next round"; +"Gift.AuctionBid.UpcomingBid" = "You've placed an early bid"; +"Gift.AuctionBid.UpcomingTitle" = "Place an Early Bid"; +"Gift.AuctionBid.Winning" = "You're winning"; +"Gift.AuctionBid.YourBid" = "your bid"; +"Gift.AuctionTransfer.Change" = "Change"; +"Gift.AuctionTransfer.Text" = "The current recipient of this gift is **%@**. Change to **%@**?"; +"Gift.AuctionTransfer.TextFromYourself" = "The current recipient of this gift is you. Change to **%@**?"; +"Gift.AuctionTransfer.TextToYourself" = "The current recipient of this gift is **%@**. Change to yourself?"; +"Gift.AuctionTransfer.Title" = "Change Recipient"; +"Gift.Buy.AcceptsTonOnly" = "The seller only accepts\nGrams as payment."; +"Gift.Buy.Confirm.BuyFor#one" = "Buy for %@ Star"; +"Gift.Buy.Confirm.BuyFor#other" = "Buy for %@ Stars"; +"Gift.Buy.Confirm.BuyForGrams#one" = "Buy for %@ Gram"; +"Gift.Buy.Confirm.BuyForGrams#other" = "Buy for %@ Grams"; +"Gift.Buy.Confirm.BuyForTon" = "Buy for %@ Grams"; +"Gift.Buy.Confirm.GiftText" = "Do you really want to buy **%1$@** for %2$@ and gift it to **%3$@**?"; +"Gift.Buy.Confirm.Text" = "Do you really want to buy **%1$@** for %2$@?"; +"Gift.Buy.Confirm.Text.Stars#one" = "**%@** Star"; +"Gift.Buy.Confirm.Text.Stars#other" = "**%@** Stars"; +"Gift.Buy.Confirm.Title" = "Confirm Payment"; +"Gift.Buy.ErrorPriceChanged.Text" = "The price has increased from **%1$@** to **%2$@**. Buy the gift anyway?"; +"Gift.Buy.ErrorPriceChanged.Text.Stars#one" = "%@ Star"; +"Gift.Buy.ErrorPriceChanged.Text.Stars#other" = "%@ Stars"; +"Gift.Buy.ErrorPriceChanged.Title" = "Warning"; +"Gift.Buy.ErrorTooEarly.Text" = "You will be able to buy this gift on %@."; +"Gift.Buy.ErrorTooEarly.Title" = "Try Later"; +"Gift.Buy.ErrorUnknown" = "An error occurred. Please try again."; +"Gift.Buy.PayInStars" = "Pay in Stars"; +"Gift.Buy.PayInTon" = "Pay in Grams"; +"Gift.Buy.PayInTon.Tooltip" = "Pay with Grams to skip the 21-day wait before transferring the gift again."; +"Gift.Convert.Convert" = "Convert"; +"Gift.Convert.Period.Days#one" = "%@ day"; +"Gift.Convert.Period.Days#other" = "%@ days"; +"Gift.Convert.Period.Stars#one" = "%@ Star"; +"Gift.Convert.Period.Stars#other" = "%@ Stars"; +"Gift.Convert.Period.Text" = "Do you want to convert this gift from **%1$@** to **%2$@**?\n\nConversion is available for the next %3$@.\n\nThis will permanently destroy the gift."; +"Gift.Convert.Period.Unavailable.Days#one" = "%@ day"; +"Gift.Convert.Period.Unavailable.Days#other" = "%@ days"; +"Gift.Convert.Period.Unavailable.Text" = "Sorry, you can't convert this gift.\n\nStars can only be claimed within %@ after receiving a gift."; +"Gift.Convert.Stars#one" = "%@ Star"; +"Gift.Convert.Stars#other" = "%@ Stars"; +"Gift.Convert.Success.ChannelText" = "%1$@ to channel's balance."; +"Gift.Convert.Success.ChannelText.Stars#one" = "**%@ Star** was sent"; +"Gift.Convert.Success.ChannelText.Stars#other" = "**%@ Stars** were sent"; +"Gift.Convert.Success.Text" = "You received **%1$@** instead."; +"Gift.Convert.Success.Text.Stars#one" = "%@ Star"; +"Gift.Convert.Success.Text.Stars#other" = "%@ Stars"; +"Gift.Convert.Success.Title" = "Gift Converted"; +"Gift.Convert.Text" = "Do you want to convert this gift from **%1$@** to **%2$@**?\n\nThis will permanently destroy the gift."; +"Gift.Convert.Title" = "Convert Gift to Stars"; +"Gift.Craft.BackdropTooltip" = "**%1$@%** chance the crafted gift will have the **%2$@** backdrop."; +"Gift.Craft.Craft" = "Craft %@"; +"Gift.Craft.Crafting.Description" = "If crafting fails, these gifts\nwill be lost."; +"Gift.Craft.Crafting.SuccessChance" = "%@% Success Chance"; +"Gift.Craft.Crafting.Title" = "Crafting"; +"Gift.Craft.CraftingFailed.CraftAnotherGift" = "Craft Another Gift"; +"Gift.Craft.CraftingFailed.Text#one" = "This crafting attempt was unsuccessful.\n**%@ gift** was lost."; +"Gift.Craft.CraftingFailed.Text#other" = "This crafting attempt was unsuccessful.\n**%@ gifts** were lost."; +"Gift.Craft.CraftingFailed.Title" = "Crafting Failed"; +"Gift.Craft.Description" = "Add up to **4 gifts** to craft a new\n**$ %@**.\n\nIf crafting fails, these gifts\nwill be lost."; +"Gift.Craft.Error.NotAvailable" = "One of the gifts is not available for crafting."; +"Gift.Craft.Info.Description" = "Turn your gifts into rare, epic and legendary versions."; +"Gift.Craft.Info.Paragraph1.Text" = "Select up to 4 gifts to craft a new exclusive model."; +"Gift.Craft.Info.Paragraph1.Title" = "Get Rare Models"; +"Gift.Craft.Info.Paragraph2.Text" = "Combine more gifts to increase your odds of success."; +"Gift.Craft.Info.Paragraph2.Title" = "Maximize Chances"; +"Gift.Craft.Info.Paragraph3.Text" = "Use gifts with the same attribute to boost its chance."; +"Gift.Craft.Info.Paragraph3.Title" = "Affect the Result"; +"Gift.Craft.Info.Title" = "Gift Crafting"; +"Gift.Craft.Info.Understood" = "Understood"; +"Gift.Craft.Select.NoGiftsFromCollection" = "You don't have other gifts\nfrom this collection"; +"Gift.Craft.Select.SaleGifts" = "Suitable Gifts On Sale"; +"Gift.Craft.Select.SaleGiftsCount#one" = "%@ Suitable Gift On Sale"; +"Gift.Craft.Select.SaleGiftsCount#other" = "%@ Suitable Gifts On Sale"; +"Gift.Craft.Select.Title" = "Select Gifts"; +"Gift.Craft.Select.YourGifts" = "Your Gifts"; +"Gift.Craft.SuccessChanceSuffix" = "% Success Chance"; +"Gift.Craft.SymbolTooltip" = "**%1$@%** chance the crafted gift will have the **%2$@** symbol."; +"Gift.Craft.Title" = "Craft Gift"; +"Gift.Craft.Unavailable.Text" = "You will be able to craft this gift on %@."; +"Gift.Craft.Unavailable.Title" = "Try Later"; +"Gift.Craft.UnavailableBlockchain.Text" = "This gift can't be used as the first one in crafting because it has been recorded on the blockchain."; +"Gift.Craft.UnavailableBlockchain.Title" = "Crafting Unavailable"; +"Gift.Craft.ViewVariants" = "View all craftable models"; +"Gift.Demo.Description" = "Gifts are collectible items you can trade or showcase on your profile."; +"Gift.Demo.Title" = "Telegram Gifts"; +"Gift.Demo.Tradable.Text" = "Sell your gift on Telegram or on third-party NFT marketplaces."; +"Gift.Demo.Tradable.Title" = "Tradable"; +"Gift.Demo.Understood" = "Understood"; +"Gift.Demo.Unique.Text" = "Upgrade your gifts to get a unique number, model, backdrop and symbol."; +"Gift.Demo.Unique.Title" = "Unique"; +"Gift.Demo.Wearable.Text" = "Display gifts on your profile and set them as your cover or status."; +"Gift.Demo.Wearable.Title" = "Wearable"; +"Gift.Displayed.ChannelText" = "The gift is now visible on the channel's profile."; +"Gift.Displayed.NewText" = "The gift is now shown on your profile."; +"Gift.Displayed.Text" = "This gift is now visible on [your profile]()."; +"Gift.Displayed.Title" = "Gift Saved to Profile"; +"Gift.Displayed.View" = "View"; +"Gift.Hidden.ChannelText" = "The gift is now hidden from the channel's profile."; +"Gift.Hidden.NewText" = "Gift was removed from your profile."; +"Gift.Hidden.Text" = "The gift is no longer displayed in [your profile]()."; +"Gift.Hidden.Title" = "Gift Removed from Profile"; +"Gift.Offer.Duration" = "Offer Duration"; +"Gift.Offer.Duration.Hours#one" = "%@ Hour"; +"Gift.Offer.Duration.Hours#other" = "%@ Hours"; +"Gift.Offer.DurationDescriptionStars" = "Choose how long **%@** has to accept your offer. When it expires, your Stars will be automatically refunded."; +"Gift.Offer.DurationDescriptionTon" = "Choose how long **%@** has to accept your offer. When it expires, your Grams will be automatically refunded."; +"Gift.Offer.GiftMinAmountToast.Text" = "You cannot offer less than %@ for this gift"; +"Gift.Offer.Offer" = "Offer"; +"Gift.Offer.OfferStars" = "Offer Stars"; +"Gift.Offer.OfferTon" = "Offer Grams"; +"Gift.Offer.PriceDescriptionStars" = "Enter the amount of Stars you'd like to offer for **%@**."; +"Gift.Offer.PriceDescriptionTon" = "Enter the amount of Grams you'd like to offer for **%@**."; +"Gift.Offer.PricePlaceholder" = "Price"; +"Gift.Offer.PriceSectionStars" = "STARS TO OFFER"; +"Gift.Offer.PriceSectionTon" = "GRAMS TO OFFER"; +"Gift.Offer.Title" = "Offer to Buy"; +"Gift.Options.Collectibles.Text" = "Collectible gifts are unique digital items you can exchange or sell."; +"Gift.Options.Gift.Auction" = "auction"; +"Gift.Options.Gift.BuyLimitReached#one" = "You already sent %@ of these gifts, which is the limit."; +"Gift.Options.Gift.BuyLimitReached#other" = "You already sent %@ of these gifts, which is the limit."; +"Gift.Options.Gift.Filter.AllGifts" = "All Gifts"; +"Gift.Options.Gift.Filter.Collectibles" = "Collectibles"; +"Gift.Options.Gift.Filter.InStock" = "In Stock"; +"Gift.Options.Gift.Filter.Limited" = "Limited"; +"Gift.Options.Gift.Filter.MyGifts" = "My Gifts"; +"Gift.Options.Gift.Filter.Resale" = "Resale"; +"Gift.Options.Gift.JoinAuction" = "Join"; +"Gift.Options.Gift.Limited" = "limited"; +"Gift.Options.Gift.Limited.Left#one" = "%@ left"; +"Gift.Options.Gift.Limited.Left#other" = "%@ left"; +"Gift.Options.Gift.Premium" = "premium"; +"Gift.Options.Gift.Premium.Left#one" = "%@ left"; +"Gift.Options.Gift.Premium.Left#other" = "%@ left"; +"Gift.Options.Gift.Resale" = "resale"; +"Gift.Options.Gift.SoldOut" = "sold out"; +"Gift.Options.Gift.Soon" = "soon"; +"Gift.Options.Gift.Text" = "Give **%@** gifts that can be kept on the profile or converted to Stars. [What are Stars >]()"; +"Gift.Options.Gift.Title" = "Send a Gift"; +"Gift.Options.Gift.Transfer" = "Transfer"; +"Gift.Options.Gift.ViewAuction" = "View"; +"Gift.Options.GiftChannel.Text" = "Select a gift to show appreciation for **%@**."; +"Gift.Options.GiftChannel.Title" = "Send a Gift"; +"Gift.Options.GiftLocked.Title" = "Gift Locked"; +"Gift.Options.GiftSelf.Text" = "Buy yourself a gift to add to your profile or reserve for later.\n\nLimited-edition gifts upgraded to collectibles can be gifted to others."; +"Gift.Options.GiftSelf.Title" = "Buy a Gift"; +"Gift.Options.Premium.Months#one" = "%@ month"; +"Gift.Options.Premium.Months#other" = "%@ months"; +"Gift.Options.Premium.OrStars" = "or %@"; +"Gift.Options.Premium.Premium" = "Premium"; +"Gift.Options.Premium.Text" = "Give **%@** access to exclusive features with Telegram Premium. [See Features >]()"; +"Gift.Options.Premium.Title" = "Gift Premium"; +"Gift.Options.Premium.Years#one" = "%@ year"; +"Gift.Options.Premium.Years#other" = "%@ years"; +"Gift.Options.SoldOut.Text" = "Sorry, this gift is sold out."; +"Gift.PremiumOrStars.Title" = "Send a Gift"; +"Gift.RemoveDetails.Action" = "Remove for %@"; +"Gift.RemoveDetails.Success" = "Removed %@'s description!"; +"Gift.RemoveDetails.Text" = "Do you want to permanently remove this description from your gift?"; +"Gift.RemoveDetails.Title" = "Remove Description"; +"Gift.Resale.Unavailable.Text" = "You will be able to resell this gift on %@."; +"Gift.Resale.Unavailable.Title" = "Try Later"; +"Gift.Sell.Confirm.ListFor#one" = "List for %@ Star"; +"Gift.Sell.Confirm.ListFor#other" = "List for %@ Stars"; +"Gift.Sell.Confirm.ListForGrams#one" = "List for %@ Gram"; +"Gift.Sell.Confirm.ListForGrams#other" = "List for %@ Grams"; +"Gift.Sell.Confirm.ListForTon" = "List for %@ Grams"; +"Gift.Sell.Confirm.Text" = "Do you want to list **%1$@** for sale at %2$@?"; +"Gift.Sell.Confirm.Title" = "Confirm Sale"; +"Gift.Send.AvailableForResale" = "Available for Resale"; +"Gift.Send.Buy" = "Buy a Gift for"; +"Gift.Send.Customize.Info" = "Only %@ will see your message."; +"Gift.Send.Customize.MessagePlaceholder" = "Enter Message (Optional)"; +"Gift.Send.Customize.Title" = "CUSTOMIZE YOUR GIFT"; +"Gift.Send.ErrorDisallowed" = "**%@** doesn't accept this kind of gifts."; +"Gift.Send.ErrorOutOfStock" = "Sorry, this gift is sold out. Please choose another gift."; +"Gift.Send.ErrorUnknown" = "An error occurred. Please try again."; +"Gift.Send.GiftsDisallowed" = "%@ doesn't accept gifts."; +"Gift.Send.HideMyName" = "Hide My Name"; +"Gift.Send.HideMyName.Info" = "Hide my name and message from visitors to %1$@'s profile. %2$@ will still see your name and message."; +"Gift.Send.Limited" = "Limited"; +"Gift.Send.Limited.Success.Text#one" = "You can send **%@** more."; +"Gift.Send.Limited.Success.Text#other" = "You can send **%@** more."; +"Gift.Send.Limited.Success.Text.None" = "You've reached your limit on this gift."; +"Gift.Send.Limited.Success.Title" = "Gift Sent!"; +"Gift.Send.PayWithStars" = "Pay with %@"; +"Gift.Send.PayWithStars.Info" = "Your balance is **%@**. [Get More Stars >]()"; +"Gift.Send.Premium.Confirmation.Confirm" = "Confirm"; +"Gift.Send.Premium.Confirmation.Text" = "Are you sure you want to gift **Telegram Premium** to **%1$@** for **%2$@**?"; +"Gift.Send.Premium.Confirmation.Text.Stars#one" = "%@ Star"; +"Gift.Send.Premium.Confirmation.Text.Stars#other" = "%@ Stars"; +"Gift.Send.Premium.Confirmation.Title" = "Send a Gift"; +"Gift.Send.Remains#one" = "%@ left"; +"Gift.Send.Remains#other" = "%@ left"; +"Gift.Send.Send" = "Send a Gift for"; +"Gift.Send.SendShort" = "Send"; +"Gift.Send.Sold#one" = "%@ sold"; +"Gift.Send.Sold#other" = "%@ sold"; +"Gift.Send.Success" = "You sent a gift to **%1$@** for **%2$@**."; +"Gift.Send.Success.Stars#one" = "%@ Star"; +"Gift.Send.Success.Stars#other" = "%@ Stars"; +"Gift.Send.Title" = "Send a Gift"; +"Gift.Send.TitleTo" = "Gift to %@"; +"Gift.Send.Upgrade" = "Make Unique for %@"; +"Gift.Send.Upgrade.ForcedInfo" = "%1$@ accepts only unique gifts. [Learn More >]()"; +"Gift.Send.Upgrade.Info" = "Enable this to let %1$@ turn your gift into a unique collectible. [Learn More >]()"; +"Gift.SendChannel.HideMyName.Info" = "Hide my name and message from visitors to this channel. The channel admins will still see them."; +"Gift.SendChannel.Title" = "Gift Preview"; +"Gift.SendChannel.Upgrade.Info" = "Enable this to let the admins of %@ turn your gift into a unique collectible. [Learn More >]()"; +"Gift.SendSelf.HideMyName" = "Hide My Name"; +"Gift.SendSelf.HideMyName.Info" = "Hide my name and message from visitors to my profile."; +"Gift.SendSelf.Title" = "Gift to Myself"; +"Gift.Setup.AuctionInfo" = "%@ are dropped to the top %@ by bid amount. [Learn more >]()"; +"Gift.Setup.AuctionInfo.Bidders#one" = "%@ bidder"; +"Gift.Setup.AuctionInfo.Bidders#other" = "%@ bidders"; +"Gift.Setup.AuctionInfo.Gifts#one" = "%@ gift"; +"Gift.Setup.AuctionInfo.Gifts#other" = "%@ gifts"; +"Gift.Setup.PlaceBid" = "Place a Bid"; +"Gift.Store.AllListings" = "All Listings"; +"Gift.Store.Balance.MyStars" = "My Stars"; +"Gift.Store.Balance.MyTon" = "My Grams"; +"Gift.Store.ClearFilters" = "Clear Filters"; +"Gift.Store.EmptyResults" = "No Matching Gifts"; +"Gift.Store.Filter.Backdrop" = "Backdrop"; +"Gift.Store.Filter.Model" = "Model"; +"Gift.Store.Filter.Selected.Backdrop#one" = "%@ Backdrop"; +"Gift.Store.Filter.Selected.Backdrop#other" = "%@ Backdrops"; +"Gift.Store.Filter.Selected.Model#one" = "%@ Model"; +"Gift.Store.Filter.Selected.Model#other" = "%@ Models"; +"Gift.Store.Filter.Selected.Symbol#one" = "%@ Symbol"; +"Gift.Store.Filter.Selected.Symbol#other" = "%@ Symbols"; +"Gift.Store.Filter.Symbol" = "Symbol"; +"Gift.Store.ForResale#one" = "%@ listing"; +"Gift.Store.ForResale#other" = "%@ listings"; +"Gift.Store.ForResaleNoResults" = "no results"; +"Gift.Store.NoResults" = "No Results"; +"Gift.Store.Search" = "Search"; +"Gift.Store.SelectAll" = "Select All"; +"Gift.Store.ShowStarsListings" = "Show listings for Stars only"; +"Gift.Store.Sort.Date" = "Date"; +"Gift.Store.Sort.Number" = "Number"; +"Gift.Store.Sort.Price" = "Price"; +"Gift.Store.SortByDate" = "Sort By Date"; +"Gift.Store.SortByNumber" = "Sort By Number"; +"Gift.Store.SortByPrice" = "Sort By Price"; +"Gift.Store.StarsOnlyListings" = "For Stars Only"; +"Gift.Transfer.Confirmation.Text" = "Do you want to transfer ownership of **%1$@** to **%2$@** for **%3$@**?"; +"Gift.Transfer.Confirmation.Text.Stars#one" = "%@ Star"; +"Gift.Transfer.Confirmation.Text.Stars#other" = "%@ Stars"; +"Gift.Transfer.Confirmation.TextFree" = "Do you want to transfer ownership of **%1$@** to **%2$@**?"; +"Gift.Transfer.Confirmation.Title" = "Confirm Transfer"; +"Gift.Transfer.Confirmation.Transfer" = "Transfer for"; +"Gift.Transfer.Confirmation.TransferFree" = "Transfer"; +"Gift.Transfer.SendUnlocks" = "unlocks in %@"; +"Gift.Transfer.SendUnlocks.Days#one" = "%@ day"; +"Gift.Transfer.SendUnlocks.Days#other" = "%@ days"; +"Gift.Transfer.SendViaBlockchain" = "Send via Blockchain"; +"Gift.Transfer.Success" = "You have successfully gifted **%1$@** to **%2$@**."; +"Gift.Transfer.Title" = "Transfer"; +"Gift.Transfer.Unavailable.Text" = "You will be able to transfer this gift on %@."; +"Gift.Transfer.Unavailable.Title" = "Try Later"; +"Gift.Transfer.UnlockPending.Text" = "In %@, you'll be able to send this collectible to any TON blockchain address outside Telegram for sale or auction."; +"Gift.Transfer.UnlockPending.Text.Days#one" = "%@ day"; +"Gift.Transfer.UnlockPending.Text.Days#other" = "%@ days"; +"Gift.Transfer.UnlockPending.Title" = "Unlocking In Progress"; +"Gift.Transfer.UpdateRequired.Text" = "The Telegram version that enables gift transfers to the blockchain is currently under review by the App Store. Please wait a few days."; +"Gift.Transfer.UpdateRequired.Title" = "Update Required"; +"Gift.UnavailableAction.OpenFragment" = "Open Fragment"; +"Gift.UnavailableAction.Text" = "Transfer this gift to your Telegram account on Fragment to unlock this action."; +"Gift.UnavailableAction.Title" = "Action Locked"; +"Gift.Unique.AttributeDescription" = "Only %@ of such collectibles have this attribute."; +"Gift.Unique.Availability" = "Availability"; +"Gift.Unique.Backdrop" = "Backdrop"; +"Gift.Unique.Collectible" = "Collectible"; +"Gift.Unique.CollectibleBy" = "collectible %1$@ by %2$@"; +"Gift.Unique.Issued" = "%@ issued"; +"Gift.Unique.LearnMore" = "learn more"; +"Gift.Unique.Model" = "Model"; +"Gift.Unique.OriginalInfo" = "Gifted to %1$@ on %2$@."; +"Gift.Unique.OriginalInfoSender" = "Gifted by %1$@ to %2$@ on %3$@."; +"Gift.Unique.OriginalInfoSenderWithText" = "Gifted by %1$@ to %2$@ on %3$@ with comment \"%4$@\"."; +"Gift.Unique.OriginalInfoWithText" = "Gifted to %1$@ on %2$@ with comment \"%3$@\"."; +"Gift.Unique.Owner" = "Owner"; +"Gift.Unique.Symbol" = "Symbol"; +"Gift.Unique.Telegram" = "Telegram"; +"Gift.Unique.Transfer" = "change"; +"Gift.Unique.Value" = "Value"; +"Gift.Unpin.Replace" = "Replace"; +"Gift.Unpin.Subtitle" = "Select a gift to unpin below:"; +"Gift.Unpin.Title" = "Too Many Pinned Gifts"; +"Gift.Unpin.Unpin" = "Unpin"; +"Gift.Upgrade.AddChannelName" = "Add channel name to the gift"; +"Gift.Upgrade.AddMyName" = "Add my name to the gift"; +"Gift.Upgrade.AddName" = "Add sender's name"; +"Gift.Upgrade.AddNameAndComment" = "Add sender's name and comment"; +"Gift.Upgrade.Confirm" = "Confirm"; +"Gift.Upgrade.Description" = "Turn your gift into a unique collectible that you can transfer or auction."; +"Gift.Upgrade.Gift.Sent.GiftMore" = "Gift More"; +"Gift.Upgrade.Gift.Sent.Text" = "Upgrade sent!"; +"Gift.Upgrade.GiftDescription" = "Let %@ turn this gift into a unique collectible."; +"Gift.Upgrade.GiftTitle" = "Make Unique"; +"Gift.Upgrade.GiftUpgrade" = "Pay %@ for Upgrade"; +"Gift.Upgrade.IncludeDescription" = "Let %@ turn this gift into a unique collectible."; +"Gift.Upgrade.IncludeTitle" = "Make Unique"; +"Gift.Upgrade.PriceWillDecrease" = "Price decreases in {m}:{s}"; +"Gift.Upgrade.SeePriceDecrease" = "See how this price will decrease >"; +"Gift.Upgrade.Skip" = "Skip"; +"Gift.Upgrade.Soon" = "SOON"; +"Gift.Upgrade.Succeed.Text" = "You have received a unique number, model, backdrop and symbol for your gift."; +"Gift.Upgrade.Succeed.Title" = "The gift is now collectible"; +"Gift.Upgrade.Title" = "Upgrade Gift"; +"Gift.Upgrade.Tradable.Description" = "Sell or auction your gift on third-party NFT marketplaces."; +"Gift.Upgrade.Tradable.GiftDescription" = "%@ will be able to auction the gift on third-party NFT marketplaces."; +"Gift.Upgrade.Tradable.IncludeDescription" = "The recipient will be able to auction the gift on third-party NFT marketplaces."; +"Gift.Upgrade.Tradable.Title" = "Tradable"; +"Gift.Upgrade.Transferable.Description" = "Send your upgraded gift to any of your friends on Telegram."; +"Gift.Upgrade.Transferable.GiftDescription" = "%@ will be able to send the gift to anyone on Telegram."; +"Gift.Upgrade.Transferable.IncludeDescription" = "The recipient will be able to send the gift to anyone on Telegram."; +"Gift.Upgrade.Transferable.Title" = "Transferable"; +"Gift.Upgrade.Unique.Description" = "Get a unique number, model, backdrop and symbol for your gift."; +"Gift.Upgrade.Unique.GiftDescription" = "%@ will get a unique number, model, backdrop and symbol for the gift."; +"Gift.Upgrade.Unique.IncludeDescription" = "The recipient will get a unique number, model, backdrop and symbol for the gift."; +"Gift.Upgrade.Unique.Title" = "Unique"; +"Gift.Upgrade.Upgrade" = "Upgrade"; +"Gift.Upgrade.UpgradeFor" = "Upgrade for %@"; +"Gift.Upgrade.UpgradeNext" = "Upgrade Next Gift"; +"Gift.Upgrade.ViewAllVariants" = "View all variants"; +"Gift.Upgrade.Wearable.Text" = "Display gifts on your profile and set them as your cover or status."; +"Gift.Upgrade.Wearable.Title" = "Wearable"; +"Gift.UpgradeCost.AdditionalDescription" = "Upgrade cost drops every minute."; +"Gift.UpgradeCost.Description" = "Users who upgrade their gifts first get collectibles with lower numbers."; +"Gift.UpgradeCost.Done" = "Understood"; +"Gift.UpgradeCost.Stars#one" = "%@ Star"; +"Gift.UpgradeCost.Stars#other" = "%@ Stars"; +"Gift.UpgradeCost.Title" = "Upgrade Cost"; +"Gift.Value.AveragePrice" = "Average Price"; +"Gift.Value.AveragePriceInfo" = "**%1$@** is the average sale price for %2$@ gifts listed on Telegram and Fragment over the past month."; +"Gift.Value.DescriptionAveragePrice" = "The average sale price of **%@** gifts on Telegram and Fragment over the past month."; +"Gift.Value.DescriptionLastPriceFragment" = "This is the last price at which **%@** was last sold on Fragment."; +"Gift.Value.DescriptionLastPriceTelegram" = "This is the price at which **%@** was last sold on Telegram."; +"Gift.Value.ForSaleOnFragment" = "for sale on Fragment"; +"Gift.Value.ForSaleOnTelegram" = "for sale on Telegram"; +"Gift.Value.InitialPrice" = "Initial Price"; +"Gift.Value.InitialSale" = "Initial Sale"; +"Gift.Value.LastPrice" = "Last Price"; +"Gift.Value.LastPriceInfo" = "**%1$@** is the last price for %2$@ gifts listed on Telegram and Fragment."; +"Gift.Value.LastSale" = "Last Sale"; +"Gift.Value.MinimumPrice" = "Minimum Price"; +"Gift.Value.MinimumPriceInfo" = "**%1$@** is the floor price for %2$@ gifts listed on Telegram and Fragment."; +"Gift.Variants.Backdrop" = "backdrop"; +"Gift.Variants.Backdrops" = "Backdrops"; +"Gift.Variants.CollectionInfo" = "This collection features %@"; +"Gift.Variants.CollectionInfo.Backdrop#one" = "**%@** unique backdrop"; +"Gift.Variants.CollectionInfo.Backdrop#other" = "**%@** unique backdrops"; +"Gift.Variants.CollectionInfo.CraftableModel#one" = "**%@** craftable model"; +"Gift.Variants.CollectionInfo.CraftableModel#other" = "**%@** craftable models"; +"Gift.Variants.CollectionInfo.Model#one" = "**%@** unique model"; +"Gift.Variants.CollectionInfo.Model#other" = "**%@** unique models"; +"Gift.Variants.CollectionInfo.Symbol#one" = "**%@** unique symbol"; +"Gift.Variants.CollectionInfo.Symbol#other" = "**%@** unique symbols"; +"Gift.Variants.Model" = "model"; +"Gift.Variants.Models" = "Models"; +"Gift.Variants.RandomTraits" = "Random Traits"; +"Gift.Variants.Randomize" = "Randomize Traits"; +"Gift.Variants.SelectedTraits" = "Selected Traits"; +"Gift.Variants.Symbol" = "symbol"; +"Gift.Variants.Symbols" = "Symbols"; +"Gift.Variants.ViewCraftableModels" = "View Craftable Models"; +"Gift.Variants.ViewPrimaryModels" = "View Primary Models"; +"Gift.View.Availability" = "Availability"; +"Gift.View.Availability.NewOf" = "%1$@ of %2$@ left"; +"Gift.View.Availability.Of" = "%1$@ of %2$@"; +"Gift.View.BotDescription" = "You can add this gift to your profile or hide it."; +"Gift.View.BuyFor" = "Buy for"; +"Gift.View.Context.BuyOffer" = "Offer to Buy"; +"Gift.View.Context.ChangePrice" = "Change Price"; +"Gift.View.Context.CopyLink" = "Copy Link"; +"Gift.View.Context.Craft" = "Craft"; +"Gift.View.Context.SetAsTheme" = "Set as Theme in..."; +"Gift.View.Context.Share" = "Share"; +"Gift.View.Context.Transfer" = "Transfer"; +"Gift.View.Context.ViewInProfile" = "View in Profile"; +"Gift.View.Convert" = "Convert to %@"; +"Gift.View.Convert.Stars#one" = "%@ Star"; +"Gift.View.Convert.Stars#other" = "%@ Stars"; +"Gift.View.ConvertedDescription" = "You converted this gift to %@. [More About Stars >]()"; +"Gift.View.ConvertedDescription.Stars#one" = "%@ Star"; +"Gift.View.ConvertedDescription.Stars#other" = "%@ Stars"; +"Gift.View.CopiedAddress" = "TON address copied to clipboard."; +"Gift.View.Date" = "Date"; +"Gift.View.Display" = "Add to My Profile"; +"Gift.View.Display.Channel" = "Add to Profile"; +"Gift.View.DisplayedInfo" = "The gift is visible on your profile. [View >]()"; +"Gift.View.DisplayedInfoChannelNew" = "This gift is visible. [Hide from Profile >]()"; +"Gift.View.DisplayedInfoHide" = "The gift is visible on your profile. [Hide >]()"; +"Gift.View.DisplayedInfoHide.Channel" = "The gift is visible on your channel's profile. [Hide >]()"; +"Gift.View.DisplayedInfoNew" = "This gift is visible. [Hide from Profile >]()"; +"Gift.View.EqualsTo" = "Equal to %@"; +"Gift.View.FirstSale" = "First Sale"; +"Gift.View.FreeUpgradeDescription" = "Upgrade this gift for free to turn it into a unique collectible."; +"Gift.View.FreeUpgradeOtherDescription" = "%1$@ can turn this gift into a unique collectible."; +"Gift.View.From" = "From"; +"Gift.View.GiftUpgrade" = "Gift an Upgrade"; +"Gift.View.Header.Share" = "share"; +"Gift.View.Header.TakeOff" = "take off"; +"Gift.View.Header.Transfer" = "transfer"; +"Gift.View.Header.Wear" = "wear"; +"Gift.View.HiddenInfo" = "This gift is hidden. Only you can see it."; +"Gift.View.HiddenInfo.Channel" = "This item is hidden from visitors to your channel."; +"Gift.View.HiddenInfoChannelNew" = "This gift is hidden. [Show on Profile >]()"; +"Gift.View.HiddenInfoNew" = "This gift is hidden. [Show on Profile >]()"; +"Gift.View.HiddenInfoShow" = "This gift is hidden. Only you can see it. [Show >]()"; +"Gift.View.HiddenInfoShow.Channel" = "This item is hidden from visitors to your channel. [Show >]()"; +"Gift.View.HiddenName" = "Hidden Name"; +"Gift.View.Hide" = "Hide from my profile"; +"Gift.View.KeepOrConvertDescription" = "You can add this gift to your Profile or convert it to %@. [More About Stars >]()"; +"Gift.View.KeepOrConvertDescription.Channel" = "Your channel can add this gift to its profile or convert it to %@. [More About Stars >]()"; +"Gift.View.KeepOrConvertDescription.Stars#one" = "%@ Star"; +"Gift.View.KeepOrConvertDescription.Stars#other" = "%@ Stars"; +"Gift.View.KeepUpgradeOrConvertDescription" = "You can keep this gift, upgrade it, or sell it for %@. [More About Stars >]()"; +"Gift.View.KeepUpgradeOrConvertDescription.Channel" = "Your channel can keep this gift, upgrade it, or sell it for %@. [More About Stars >]()"; +"Gift.View.LastSale" = "Last Sale"; +"Gift.View.NameAndMessageHidden" = "Only you can see the sender's name and message."; +"Gift.View.NameHidden" = "Only you can see the sender's name."; +"Gift.View.NoConvertDescription" = "We will notify you when this gift can be upgraded."; +"Gift.View.OnSale" = "On sale for %@"; +"Gift.View.OpenChatTheme" = "This gift is the chat's theme. [Change Theme >]()"; +"Gift.View.OtherDescription" = "%1$@ can add this gift to their profile or convert it to %2$@. [More About Stars >]()"; +"Gift.View.OtherDescription.Stars#one" = "%@ Star"; +"Gift.View.OtherDescription.Stars#other" = "%@ Stars"; +"Gift.View.OtherNoConvertDescription" = "We will notify %1$@ when this gift can be upgraded."; +"Gift.View.Outgoing.NameAndMessageHidden" = "Only %@ can see your name and message."; +"Gift.View.Outgoing.NameHidden" = "Only %@ can see your name."; +"Gift.View.PutOn" = "You put on %@"; +"Gift.View.ReceivedTitle" = "Received Gift"; +"Gift.View.ReleasedBy" = "released by %@"; +"Gift.View.Resale.List.Success" = "%@ is now for sale!"; +"Gift.View.Resale.Relist.Success" = "%1$@ relisted for %2$@."; +"Gift.View.Resale.Relist.Success.Stars#one" = "%@ Star"; +"Gift.View.Resale.Relist.Success.Stars#other" = "%@ Stars"; +"Gift.View.Resale.Success.Text" = "%@ has been notified about your gift."; +"Gift.View.Resale.Success.Title" = "Gift Sent"; +"Gift.View.Resale.SuccessYou.Text" = "%@ is now yours."; +"Gift.View.Resale.SuccessYou.Title" = "Gift Acquired"; +"Gift.View.Resale.Unlist.Success" = "%@ removed from sale."; +"Gift.View.Resale.Unlist.Text" = "It will no longer be for sale."; +"Gift.View.Resale.Unlist.Title" = "Unlist This Item?"; +"Gift.View.Resale.Unlist.Unlist" = "Unlist"; +"Gift.View.Sale" = "sell for %@"; +"Gift.View.Sale.Stars#one" = "%@ Star"; +"Gift.View.Sale.Stars#other" = "%@ Stars"; +"Gift.View.Self.Description" = "You can add this gift to your profile or turn it into a unique transferable collectible."; +"Gift.View.Self.Title" = "Saved Gift"; +"Gift.View.Sell" = "sell"; +"Gift.View.SellingGiftInfo" = "%@ is selling this gift and you can buy it."; +"Gift.View.Send" = "send a gift"; +"Gift.View.SenderInfo" = "**%@** sent you this gift on **%@**."; +"Gift.View.Status" = "Status"; +"Gift.View.Status.NonUnique" = "Non-Unique"; +"Gift.View.Status.Upgrade" = "upgrade"; +"Gift.View.Title" = "Gift"; +"Gift.View.TonGiftAddressInfo" = "This gift is on the TON Blockchain. [View >]()"; +"Gift.View.TookOff" = "You took off %@"; +"Gift.View.TooltipPremiumWearing" = "Subscribe to [Telegram Premium]() to wear collectibles."; +"Gift.View.UnavailableDescription" = "This gift has sold out"; +"Gift.View.UnavailableTitle" = "Unavailable"; +"Gift.View.UniqueHiddenInfo" = "This gift is not visible on your profile."; +"Gift.View.UniqueHiddenInfo.Channel" = "This gift is not visible on your channel's profile."; +"Gift.View.Unknown.Title" = "Gift"; +"Gift.View.Unlist" = "unlist"; +"Gift.View.Upgrade" = "Upgrade"; +"Gift.View.UpgradeDescription" = "Upgrade this gift to turn it into a unique collectible."; +"Gift.View.UpgradeForFree" = "Upgrade for Free"; +"Gift.View.UpgradeFreeDescription" = "Upgrade this gift for free to turn it into a unique collectible."; +"Gift.View.UpgradedDescription" = "This gift was turned into a unique collectible."; +"Gift.View.Value" = "Value"; +"Gift.View.ViewUpgraded" = "View Upgraded Gift"; +"Gift.View.Visibility" = "Visibility"; +"Gift.View.Visibility.Hide" = "hide"; +"Gift.View.Visibility.Visible" = "Visible on your profile"; +"Gift.Wear.Badge.ChannelText" = "This gift will be displayed next to your channel's name with a glittering icon."; +"Gift.Wear.Badge.Text" = "This gift will be displayed next to your name with a glittering icon."; +"Gift.Wear.Badge.Title" = "Radiant Badge"; +"Gift.Wear.Design.ChannelText" = "Your channel's profile will have the same backdrop and symbol as this gift."; +"Gift.Wear.Design.Text" = "Your profile will have the same backdrop and symbol as this gift."; +"Gift.Wear.Design.Title" = "Unique Profile Design"; +"Gift.Wear.GetBenefits" = "and get these benefits:"; +"Gift.Wear.Proof.ChannelText" = "Tapping the icon next to your channel's name will show the gift's owner and attributes."; +"Gift.Wear.Proof.Text" = "Tapping the icon next to your name will show the gift's owner and attributes."; +"Gift.Wear.Proof.Title" = "Proof of Ownership"; +"Gift.Wear.Start" = "Start Wearing"; +"Gift.Wear.Wear" = "Wear %@"; +"Gift.WearPreview.FreeUpgrade" = "Free\nUpgrade"; +"Gift.WearPreview.LearnMore" = "Learn more about wearing Telegram Gifts >"; +"Gift.WearPreview.Limited" = "limited"; +"Gift.WearPreview.Upgraded" = "upgraded"; +"Gift.Withdraw.ComeBackLater" = "\n\nPlease come back later."; +"Gift.Withdraw.EnterPassword.Done" = "Proceed"; +"Gift.Withdraw.EnterPassword.Text" = "Please enter your Two-Step Verification password to complete this action."; +"Gift.Withdraw.EnterPassword.Title" = "Enter Password"; +"Gift.Withdraw.Proceed" = "Open Fragment"; +"Gift.Withdraw.SecurityCheck" = "Security Check"; +"Gift.Withdraw.SecurityRequirements" = "Gift withdrawals are only available if:\n\n• Two-Step verification was enabled for your account more than **7 days** ago.\n\n• You logged in on this device more than **24 hours** ago."; +"Gift.Withdraw.SetupTwoStepAuth" = "Enable Two-Step Verification"; +"Gift.Withdraw.Text" = "You can use Fragment, a third-party service, to transfer **%@** to the TON blockchain. After that, you can manage it as an NFT with any TON wallet application.\n\nYou can also move such NFTs back to your Telegram account via Fragment."; +"Gift.Withdraw.Title" = "Manage with Fragment"; +"GiftLink.Date" = "Date"; +"GiftLink.Description" = "This link allows you to activate a **Telegram Premium** subscription."; +"GiftLink.Footer" = "You can also [send this link]() to a friend as a gift."; +"GiftLink.From" = "From"; +"GiftLink.Gift" = "Gift"; +"GiftLink.LinkHidden" = "Only the recipient can see the link."; +"GiftLink.LinkSharedToChat" = "Gift link forwarded to **%@**"; +"GiftLink.LinkSharedToSavedMessages" = "Gift link forwarded to **Saved Messages**"; +"GiftLink.NoRecipient" = "No recipient"; +"GiftLink.NotUsedFooter" = "This link hasn't been used yet."; +"GiftLink.PersonalDescription" = "This link allows **%@** to activate a **Telegram Premium** subscription."; +"GiftLink.PersonalUsedDescription" = "This link allowed **%@** to activate a **Telegram Premium** subscription."; +"GiftLink.Reason" = "Reason"; +"GiftLink.Reason.Gift" = "You were selected by the channel"; +"GiftLink.Reason.Giveaway" = "Giveaway"; +"GiftLink.Reason.Unclaimed" = "Incomplete Giveaway"; +"GiftLink.TelegramPremium#one" = "Telegram Premium for %@ month"; +"GiftLink.TelegramPremium#other" = "Telegram Premium for %@ months"; +"GiftLink.Title" = "Gift Link"; +"GiftLink.To" = "To"; +"GiftLink.UnclaimedDescription" = "This link allows anyone to activate a **Telegram Premium** subscription."; +"GiftLink.UseLink" = "Use Link"; +"GiftLink.UsedDescription" = "This link was used to activate a **Telegram Premium** subscription."; +"GiftLink.UsedFooter" = "This link was used on %@."; +"GiftLink.UsedTitle" = "Used Gift Link"; +"GlobalAutodeleteSettings.ApplyChatsPlaceholder" = "Select chats to apply a %@ self-destruct timer"; +"GlobalAutodeleteSettings.ApplyChatsSubject#one" = "%@ chat"; +"GlobalAutodeleteSettings.ApplyChatsSubject#other" = "%@ chats"; +"GlobalAutodeleteSettings.ApplyChatsTitle" = "Select Chats"; +"GlobalAutodeleteSettings.ApplyChatsToast" = "You applied the %1$@ self-destruct timer to %2$@."; +"GlobalAutodeleteSettings.AttemptDisabledChannelSelection" = "You need admin rights in this channel to enable auto-delete."; +"GlobalAutodeleteSettings.AttemptDisabledGenericSelection" = "You can't enable auto-delete in this chat."; +"GlobalAutodeleteSettings.AttemptDisabledGroupSelection" = "You must be an admin to enable auto-delete in this group."; +"GlobalAutodeleteSettings.InfoDisabled" = "If enabled, all new messages in chats you start will be automatically deleted for everyone at some point after they have been sent. This will not affect your existing chats."; +"GlobalAutodeleteSettings.InfoEnabled" = "All new messages in chats you start will be automatically deleted for everyone %@ after they are sent. You can also [apply this setting for your existing chats]()."; +"GlobalAutodeleteSettings.OptionTitle" = "After %@"; +"GlobalAutodeleteSettings.OptionsHeader" = "SELF-DESTRUCT TIMER"; +"GlobalAutodeleteSettings.SetConfirmAction" = "Enable Auto-Deletion"; +"GlobalAutodeleteSettings.SetConfirmText" = "Are you sure you want all messages in your new private chats and in new groups you create to be automatically deleted for everyone %@ after they are sent?"; +"GlobalAutodeleteSettings.SetConfirmTitle" = "Self-Destruct Timer"; +"GlobalAutodeleteSettings.SetConfirmToastDisabled" = "Messages in all new chats you start will not be automatically deleted."; +"GlobalAutodeleteSettings.SetConfirmToastEnabled" = "Messages in all new chats you start will be automatically deleted after %@."; +"GlobalAutodeleteSettings.SetCustomTime" = "Set Custom Time..."; +"GlobalAutodeleteSettings.Title" = "Auto-Delete Messages"; +"Group.About.Help" = "You can provide an optional description for your group."; +"Group.AdminLog.AntiSpamFalsePositiveReportedText" = "Telegram moderators will review your report. Thank you!"; +"Group.AdminLog.AntiSpamText" = "You can manage anti-spam settings in Group Info > [Administrators]()."; +"Group.AdminLog.AntiSpamTitle" = "Telegram Anti-Spam"; +"Group.AdminLog.EmptyText" = "No notable actions taken by the members and admins of this group in the last 48 hours."; +"Group.Appearance.BoostInfo" = "Members of your group can **boost** it so that it levels up and unlocks these features."; +"Group.Appearance.ChooseFromGallery" = "Choose From Gallery"; +"Group.Appearance.EmojiPackUpdated" = "Group emoji pack updated."; +"Group.Appearance.GroupEmoji" = "Group Emoji Pack"; +"Group.Appearance.GroupEmojiFooter" = "Choose an emoji pack that will be available to all members within the group."; +"Group.Appearance.PreviewAuthor" = "Jessica"; +"Group.Appearance.ProfileFooter" = "Choose a color and a logo for the group's profile."; +"Group.Appearance.Status" = "Group Emoji Status"; +"Group.Appearance.StatusFooter" = "Choose a status that will be shown next to the group's name."; +"Group.Appearance.Wallpaper" = "Group Wallpaper"; +"Group.Appearance.WallpaperFooter" = "Set a wallpaper that will be visible for everyone viewing your group."; +"Group.ApplyToJoin" = "Apply to Join Group"; +"Group.DeleteGroup" = "Delete Group"; +"Group.Edit.PrivatePublicLinkAlert" = "Please note that if you choose a public link for your group, anyone will be able to find it in search and join.\n\nDo not create this link if you want your group to stay private."; +"Group.EditAdmin.MemberTagInfo" = "Add short tag next to %@'s name."; +"Group.EditAdmin.MemberTagTitle" = "Member Tag"; +"Group.EditAdmin.PermissionChangeInfo" = "Change Group Info"; +"Group.EditAdmin.RankAdminPlaceholder" = "admin"; +"Group.EditAdmin.RankInfo" = "A title that members will see instead of '%@'."; +"Group.EditAdmin.RankOwnerPlaceholder" = "owner"; +"Group.EditAdmin.RankTitle" = "MEMBER TAG"; +"Group.EditAdmin.TransferOwnership" = "Transfer Group Ownership"; +"Group.Emoji.Info" = "All members will be able to use these emoji in the group, even if they don't have Telegram Premium."; +"Group.Emoji.NotFound" = "Emoji pack not found"; +"Group.Emoji.NotFoundHelp" = "Try again or choose from the list below"; +"Group.Emoji.Placeholder" = "emojiset"; +"Group.Emoji.Title" = "Group Emoji Pack"; +"Group.Emoji.YourEmoji" = "CHOOSE EMOJI PACK"; +"Group.ErrorAccessDenied" = "This group is unavailable."; +"Group.ErrorAddBlocked" = "Sorry, you can't add this user to groups."; +"Group.ErrorAddTooMuchAdmins" = "Sorry, you've reached the maximum number of admins for this group."; +"Group.ErrorAddTooMuchBots" = "Sorry, you've reached the maximum number of bots for this group."; +"Group.ErrorAdminsTooMuch" = "Sorry, too many administrators in this group."; +"Group.ErrorNotMutualContact" = "Sorry, you can only add mutual contacts to groups at the moment."; +"Group.ErrorSendRestrictedMedia" = "The admins of this group have restricted your ability to send media."; +"Group.ErrorSendRestrictedStickers" = "The admins of this group have restricted your ability to send stickers."; +"Group.ErrorSupergroupConversionNotPossible" = "Sorry, you are a member of too many groups and channels. Please leave some before creating a new one."; +"Group.GroupMembersHeader" = "GROUP MEMBERS"; +"Group.Info.AdminLog" = "Recent Actions"; +"Group.Info.Members" = "Members"; +"Group.Info.Settings" = "Group Settings"; +"Group.JoinGroup" = "Join Group"; +"Group.LeaveGroup" = "Leave Group"; +"Group.LinkedChannel" = "Linked Channel"; +"Group.Location.ChangeLocation" = "Change Location"; +"Group.Location.CreateInThisPlace" = "Create a group in this place"; +"Group.Location.Info" = "People can find your group using People Nearby section."; +"Group.Location.Title" = "Location"; +"Group.Management.AddModeratorHelp" = "You can add admins to help you manage your group."; +"Group.Management.AntiSpam" = "Aggressive Anti-Spam"; +"Group.Management.AntiSpamInfo" = "Telegram will filter more spam but may occasionally affect ordinary messages. You can report false positives in Recent Actions."; +"Group.Management.AntiSpamMagic" = "magic"; +"Group.Members.AddMemberBotErrorNotAllowed" = "Sorry, you are not allowed to add bots to this group."; +"Group.Members.AddMembers" = "Add Members"; +"Group.Members.AddMembersHelp" = "Only group admins can see this list."; +"Group.Members.Contacts" = "CONTACTS IN THIS GROUP"; +"Group.Members.Other" = "OTHER MEMBERS"; +"Group.Members.Title" = "Members"; +"Group.MessagePhotoRemoved" = "Group photo removed"; +"Group.MessagePhotoUpdated" = "Group photo updated"; +"Group.MessageVideoUpdated" = "Group photo updated"; +"Group.OwnershipTransfer.DescriptionInfo" = "This will transfer the full **owner rights** for **%1$@** to **%2$@**.\n\nYou will no longer be considered the creator of the group. The new owner will be free to remove any of your admin privileges or even ban you."; +"Group.OwnershipTransfer.DescriptionShortInfo" = "This will transfer full **owner rights** for **%1$@** to **%2$@**."; +"Group.OwnershipTransfer.ErrorAdminsTooMuch" = "Sorry, this group has too many admins and the new owner can't be added. Please remove one of the existing admins first."; +"Group.OwnershipTransfer.ErrorLocatedGroupsTooMuch" = "Sorry, the target user owns too many location-based groups. Please ask them to delete or transfer an existing one first."; +"Group.OwnershipTransfer.ErrorPrivacyRestricted" = "Sorry, this user is not a member of this group and their privacy settings prevent you from adding them manually."; +"Group.OwnershipTransfer.Title" = "Transfer Group Ownership"; +"Group.PublicLink.Info" = "People can share this link with others and find your group using Telegram search.\n\nYou can use **a–z**, **0–9** and underscores. Minimum length is **5** characters."; +"Group.PublicLink.Placeholder" = "link"; +"Group.PublicLink.Title" = "Public Link"; +"Group.RequestToJoinSent" = "Request to join sent"; +"Group.RequestToJoinSentDescriptionGroup" = "You will be able to send messages once the admins approve your request."; +"Group.Setup.ActivateAlertShow" = "Show"; +"Group.Setup.ActivateAlertText" = "Do you want to show this link on the group info page?"; +"Group.Setup.ActivateAlertTitle" = "Activate Link"; +"Group.Setup.ActiveLimitReachedError" = "Sorry, you have too many active public links already. Please hide one of your active public links first."; +"Group.Setup.ApproveNewMembers" = "Approve New Members"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksDisable" = "disable"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksEnable" = "enable"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksPeerChannel" = "channel"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksPeerGroup" = "group"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText" = "Also %@ **%@** for **%@ existing invite links** in this %@?"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.DisableChannel" = "Also disable **Approve New Subscribers** for **%@** in this channel?"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.DisableGroup" = "Also disable **Approve New Members** for **%@** in this group?"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.EnableChannel" = "Also enable **Approve New Subscribers** for **%@** in this channel?"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.EnableGroup" = "Also enable **Approve New Members** for **%@** in this group?"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.Links#one" = "%@ existing invite link"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.Links#other" = "%@ existing invite links"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksTitle" = "Apply to existing invite links?"; +"Group.Setup.ApproveNewMembersInfo" = "Turn this on if you want users to be able to send messages only after they are approved by an admin."; +"Group.Setup.ApproveNewMembersManagedBy" = "Managed by %@"; +"Group.Setup.BasicHistoryHiddenHelp" = "New members won't see more than 100 previous messages."; +"Group.Setup.DeactivateAlertHide" = "Hide"; +"Group.Setup.DeactivateAlertText" = "Do you want to hide this link from the group info page?"; +"Group.Setup.DeactivateAlertTitle" = "Deactivate Link"; +"Group.Setup.ForwardingChannelInfo" = "Subscribers will be able to copy, save and forward content from this channel."; +"Group.Setup.ForwardingChannelInfoDisabled" = "Subscribers won't be able to copy, save or forward content from this channel."; +"Group.Setup.ForwardingChannelTitle" = "Saving And Copying Content"; +"Group.Setup.ForwardingDisabled" = "Restrict Saving Content"; +"Group.Setup.ForwardingEnabled" = "Allow Saving Content"; +"Group.Setup.ForwardingGroupInfo" = "Members will be able to copy, save and forward content from this group."; +"Group.Setup.ForwardingGroupInfoDisabled" = "Members won't be able to copy, save or forward content from this group."; +"Group.Setup.ForwardingGroupTitle" = "Forwarding From This Group"; +"Group.Setup.HistoryHeader" = "CHAT HISTORY FOR NEW MEMBERS"; +"Group.Setup.HistoryHidden" = "Hidden"; +"Group.Setup.HistoryHiddenHelp" = "New members won't see earlier messages."; +"Group.Setup.HistoryTitle" = "Chat History Settings"; +"Group.Setup.HistoryVisible" = "Visible"; +"Group.Setup.HistoryVisibleHelp" = "New members will see messages that were sent before they joined."; +"Group.Setup.LinkActive" = "active"; +"Group.Setup.LinkInactive" = "inactive"; +"Group.Setup.LinksOrder" = "LINKS ORDER"; +"Group.Setup.LinksOrderInfo" = "Drag and drop links to change the order in which they will be displayed on the group info page."; +"Group.Setup.PublicLink" = "PUBLIC LINK"; +"Group.Setup.TypeHeader" = "GROUP TYPE"; +"Group.Setup.TypePrivate" = "Private"; +"Group.Setup.TypePrivateHelp" = "Private groups can only be joined if you were invited or have an invite link."; +"Group.Setup.TypePublic" = "Public"; +"Group.Setup.TypePublicHelp" = "Public groups can be found in search, chat history is available to everyone and anyone can join."; +"Group.Setup.WhoCanSendMessages.Everyone" = "Everyone"; +"Group.Setup.WhoCanSendMessages.OnlyMembers" = "Only Members"; +"Group.Setup.WhoCanSendMessages.Title" = "WHO CAN SEND MESSAGES?"; +"Group.Status" = "group"; +"Group.UpgradeConfirmation" = "Warning: this action is irreversible. It is not possible to downgrade a supergroup to a regular group."; +"Group.UpgradeNoticeHeader" = "MEMBERS LIMIT REACHED"; +"Group.UpgradeNoticeText1" = "To go over the limit and get additional features, upgrade to a supergroup:"; +"Group.UpgradeNoticeText2" = "• Supergroups can get up to {supergroup_member_limit} members\n• New members see the entire chat history\n• Admins delete messages for everyone\n• Notifications are muted by default"; +"Group.Username.CreatePrivateLinkHelp" = "People can join your group by following this link. You can revoke the link at any time."; +"Group.Username.CreatePublicLinkHelp" = "People can share this link with others and find your group using Telegram search."; +"Group.Username.InvalidEndsWithUnderscore" = "Group names can't end with an underscore."; +"Group.Username.InvalidStartsWithNumber" = "Public links can't start with a number."; +"Group.Username.InvalidStartsWithUnderscore" = "Group names can't start with an underscore."; +"Group.Username.InvalidTooShort" = "Group names must have at least 5 characters."; +"Group.Username.RemoveExistingUsernamesFinalInfo" = "You have reserved too many public links. Try revoking the link from an older group or channel, or create a private one instead."; +"Group.Username.RemoveExistingUsernamesInfo" = "You have reserved too many public links. Try revoking the link from an older group or channel, or create a private one instead."; +"Group.Username.RemoveExistingUsernamesNoPremiumInfo" = "You have reserved too many public links. Try revoking the link from an older group or channel. We are working to let you increase this limit in the future."; +"Group.Username.RemoveExistingUsernamesOrExtendInfo" = "You have reserved too many public links. Try revoking a link from an older group or channel, or subscribe to **Telegram Premium** to double the limit to **%@** public links."; +"Group.Username.RevokeExistingUsernamesInfo" = "You can revoke the link from one of your older groups or channels, or create a private group instead."; +"GroupBoost.AdditionalFeatures" = "Additional Features"; +"GroupBoost.AdditionalFeaturesText" = "By gaining **boosts**, your group reaches higher levels and unlocks more features."; +"GroupBoost.AudioTranscription" = "Audio Transcription"; +"GroupBoost.BoostGroup" = "Boost Group"; +"GroupBoost.BoostToUnrestrict" = "Boost the group %1$@ to remove messaging restrictions. Your boosts will help **%2$@** to unlock new features."; +"GroupBoost.BoostToUnrestrict.Times#one" = "**%@** time"; +"GroupBoost.BoostToUnrestrict.Times#other" = "**%@** times"; +"GroupBoost.CustomWallpaper" = "Set Custom Group Wallpaper"; +"GroupBoost.EmojiPack" = "Set Group Emoji Pack"; +"GroupBoost.EmojiStatus" = "Use Emoji Statuses"; +"GroupBoost.EnableCustomWallpaperLevelText" = "Your group needs **Level %1$@** to set custom group wallpaper."; +"GroupBoost.EnableEmojiPackLevelText" = "Your group needs **Level %1$@** to set emoji pack."; +"GroupBoost.EnableEmojiStatusLevelText" = "Your group needs **Level %1$@** to use emoji statuses."; +"GroupBoost.EnableProfileColorLevelText" = "Your group needs **Level %1$@** to change group cover color."; +"GroupBoost.EnableProfileIconLevelText" = "Your group needs **Level %1$@** to change group cover icon."; +"GroupBoost.EnableStoriesText" = "Your group needs %1$@ to enable posting stories."; +"GroupBoost.EnableWallpaperLevelText" = "Your group needs **Level %1$@** to set group wallpaper."; +"GroupBoost.Error.BoostTooOftenText" = "You can change the group you boost only once a day. Next time you can boost is in **%@**."; +"GroupBoost.Error.PremiumNeededText" = "Only **Telegram Premium** subscribers can boost groups. Do you want to subscribe to **Telegram Premium**?"; +"GroupBoost.IncreaseLimitText" = "Your group needs %1$@ to post %2$@."; +"GroupBoost.Info" = "Members of your group can **boost** it so that it **levels up** and gets **exclusive features**."; +"GroupBoost.MemberBoosted" = "**%1$@** boosted the group %2$@."; +"GroupBoost.MemberBoosted.BoostForBadge" = "Boost **%1$@** to help it unlock new features and get a booster **badge** for your messages."; +"GroupBoost.MemberBoosted.Times#one" = "**%@** time"; +"GroupBoost.MemberBoosted.Times#other" = "**%@** times"; +"GroupBoost.MoreBoostsNeeded.Boosted.Level.Text" = "This group reached **Level %@** and unlocked new features."; +"GroupBoost.PremiumUsersCanBoost" = "**Premium** users can boost your group with this link:"; +"GroupBoost.ProfileColor" = "Set Cover Color"; +"GroupBoost.ProfileIcon" = "Set Cover Icon"; +"GroupBoost.Table.Group.EmojiPack" = "Custom Emoji Pack"; +"GroupBoost.Table.Group.VoiceToText" = "Voice-to-Text Conversion"; +"GroupBoost.Title" = "Boost Group"; +"GroupBoost.Title.Current" = "Boost Group"; +"GroupBoost.Title.Other" = "Help Upgrade This Group"; +"GroupBoost.Wallpaper" = "Set Group Wallpaper"; +"GroupCall.RevokeLinkText" = "Are you sure you want to revoke this link? Once you do, no one will be able to join the call using it."; +"GroupInfo.ActionAddRank" = "Add Member Tag"; +"GroupInfo.ActionEditAdmin" = "Edit Admin Rights"; +"GroupInfo.ActionEditAdminRank" = "Edit Admin Tag"; +"GroupInfo.ActionEditRank" = "Edit Member Tag"; +"GroupInfo.ActionPromote" = "Promote"; +"GroupInfo.ActionRemove" = "Remove"; +"GroupInfo.ActionRestrict" = "Restrict"; +"GroupInfo.ActionSend" = "Send Message"; +"GroupInfo.AddParticipant" = "Add Members"; +"GroupInfo.AddParticipantConfirmation" = "Add %@ to the group?"; +"GroupInfo.AddParticipantTitle" = "Contacts"; +"GroupInfo.AddRank" = "Add Tag"; +"GroupInfo.AddUserLeftError" = "Sorry, if a person is no longer part of a group, you need to add each other to your respective contact lists to be able to add them back.\n\nNote that they could still join via the group's invite link as long as they are not in the Removed Users list."; +"GroupInfo.Administrators" = "Administrators"; +"GroupInfo.Administrators.Title" = "Administrators"; +"GroupInfo.ChannelListNamePlaceholder" = "Channel Name"; +"GroupInfo.ChatAdmins" = "Add Admins"; +"GroupInfo.ConvertToSupergroup" = "Convert to Supergroup"; +"GroupInfo.DeactivatedStatus" = "Group Deactivated"; +"GroupInfo.DeleteAndExit" = "Delete and Exit"; +"GroupInfo.DeleteAndExitConfirmation" = "You will not be able to return to this group unless added by another member."; +"GroupInfo.FakeGroupWarning" = "⚠️ Warning: Many users reported that this account impersonates a famous person or organization."; +"GroupInfo.GroupHistory" = "Chat History For New Members"; +"GroupInfo.GroupHistoryHidden" = "Hidden"; +"GroupInfo.GroupHistoryShort" = "Chat History"; +"GroupInfo.GroupHistoryVisible" = "Visible"; +"GroupInfo.GroupNamePlaceholder" = "Group Name"; +"GroupInfo.GroupType" = "Group Type"; +"GroupInfo.InvitationLinkAcceptChannel" = "Do you want to join the channel \"%@\"?"; +"GroupInfo.InvitationLinkDoesNotExist" = "Sorry, this group does not seem to exist."; +"GroupInfo.InvitationLinkGroupFull" = "Sorry, this group is already full."; +"GroupInfo.InviteByLink" = "Invite to Group via Link"; +"GroupInfo.InviteLink.CopyAlert.Success" = "Link copied to clipboard."; +"GroupInfo.InviteLink.CopyLink" = "Copy Link"; +"GroupInfo.InviteLink.Help" = "Anyone who has Telegram installed will be able to join your group by following this link."; +"GroupInfo.InviteLink.LinkSection" = "LINK"; +"GroupInfo.InviteLink.RevokeAlert.Revoke" = "Revoke"; +"GroupInfo.InviteLink.RevokeAlert.Success" = "The previous invite link is now inactive. A new invite link has just been generated."; +"GroupInfo.InviteLink.RevokeAlert.Text" = "Are you sure you want to revoke this link? Once you do, no one will be able to join the group using it."; +"GroupInfo.InviteLink.RevokeLink" = "Revoke Link"; +"GroupInfo.InviteLink.ShareLink" = "Share Link"; +"GroupInfo.InviteLink.Title" = "Invite Link"; +"GroupInfo.InviteLinks" = "Invite Links"; +"GroupInfo.LabelAdmin" = "admin"; +"GroupInfo.LabelOwner" = "owner"; +"GroupInfo.LeftStatus" = "You have left the group"; +"GroupInfo.Location" = "Location"; +"GroupInfo.MemberRequests" = "Join Requests"; +"GroupInfo.Notifications" = "Notifications"; +"GroupInfo.ParticipantCount#one" = "1 member"; +"GroupInfo.ParticipantCount#other" = "%@ members"; +"GroupInfo.Permissions" = "Permissions"; +"GroupInfo.Permissions.AddException" = "Add Exception"; +"GroupInfo.Permissions.BroadcastConvert" = "Convert to Broadcast Group"; +"GroupInfo.Permissions.BroadcastConvertInfo" = "Broadcast groups can have over %@ members, but only admins can send messages in them."; +"GroupInfo.Permissions.BroadcastTitle" = "Broadcast Group"; +"GroupInfo.Permissions.ChargeForMessages" = "Charge Stars for Messages"; +"GroupInfo.Permissions.ChargeForMessagesInfo" = "If you turn this on, regular members of the group will have to pay Stars to send messages."; +"GroupInfo.Permissions.DontRestrictBoosters" = "Do Not Restrict Boosters"; +"GroupInfo.Permissions.DontRestrictBoostersEnableInfo" = "Turn this on to always allow users who boosted your group to send messages and media."; +"GroupInfo.Permissions.DontRestrictBoostersInfo" = "Choose how many boosts a user must give to the group to bypass restrictions on sending messages."; +"GroupInfo.Permissions.EditingDisabled" = "You cannot change this permission."; +"GroupInfo.Permissions.Exceptions" = "EXCEPTIONS"; +"GroupInfo.Permissions.MessagePrice" = "SET YOUR PRICE PER MESSAGE"; +"GroupInfo.Permissions.MessagePriceInfo" = "Your group will receive %1$@% of the selected fee (%2$@) for each incoming message."; +"GroupInfo.Permissions.Removed" = "Removed Users"; +"GroupInfo.Permissions.SearchPlaceholder" = "Search Exceptions"; +"GroupInfo.Permissions.SectionTitle" = "WHAT CAN MEMBERS OF THIS GROUP DO?"; +"GroupInfo.Permissions.SlowmodeHeader" = "SLOW MODE"; +"GroupInfo.Permissions.SlowmodeInfo" = "Choose how long each member must wait before sending their next message."; +"GroupInfo.Permissions.SlowmodeValue.Off" = "Off"; +"GroupInfo.Permissions.Title" = "Permissions"; +"GroupInfo.PublicLink" = "Public Link"; +"GroupInfo.PublicLinkAdd" = "Add"; +"GroupInfo.ScamGroupWarning" = "⚠️ Warning: Many users reported this group as a scam or a fake account. Please be careful, especially if it asks you for money."; +"GroupInfo.SetGroupPhoto" = "Set Group Photo"; +"GroupInfo.SetGroupPhotoDelete" = "Remove Photo"; +"GroupInfo.SetGroupPhotoStop" = "Stop"; +"GroupInfo.SetSound" = "Set Sound"; +"GroupInfo.SharedMedia" = "Shared Media"; +"GroupInfo.SharedMediaNone" = "None"; +"GroupInfo.ShowMoreMembers#one" = "Show %@ More Member"; +"GroupInfo.ShowMoreMembers#other" = "Show %@ More Members"; +"GroupInfo.Sound" = "Sound"; +"GroupInfo.Title" = "Group Info"; +"GroupInfo.TitleMembers#one" = "%@ Member"; +"GroupInfo.TitleMembers#other" = "%@ Members"; +"GroupInfo.UpgradeButton" = "Upgrade to supergroup"; +"GroupMembers.HideMembers" = "Hide Members"; +"GroupMembers.MembersHiddenOff" = "Switch this on to hide the list of members in this group. Admins will remain visible."; +"GroupMembers.MembersHiddenOn" = "Switch this off to show the list of members in this group."; +"GroupPermission.AddException" = "Add Exception"; +"GroupPermission.AddMembersNotAvailable" = "You don't have permission to add members."; +"GroupPermission.AddSuccess" = "Exception Added"; +"GroupPermission.AddedInfo" = "Exception added by %1$@ %2$@"; +"GroupPermission.ApplyAlertAction" = "Apply"; +"GroupPermission.ApplyAlertText" = "You have changed this user's rights in %@.\nApply Changes?"; +"GroupPermission.Delete" = "Delete Exception"; +"GroupPermission.Duration" = "Duration"; +"GroupPermission.EditingDisabled" = "You cannot edit restrictions of this user."; +"GroupPermission.Member" = "Member"; +"GroupPermission.NewTitle" = "New Exception"; +"GroupPermission.NoAddMembers" = "no add"; +"GroupPermission.NoChangeInfo" = "no info"; +"GroupPermission.NoEditRank" = "no tag"; +"GroupPermission.NoManageTopics" = "no topics"; +"GroupPermission.NoPinMessages" = "no pinning"; +"GroupPermission.NoSendFile" = "no files"; +"GroupPermission.NoSendGifs" = "no GIFs"; +"GroupPermission.NoSendLinks" = "no links"; +"GroupPermission.NoSendMedia" = "no media"; +"GroupPermission.NoSendMessages" = "no messages"; +"GroupPermission.NoSendMusic" = "no music"; +"GroupPermission.NoSendPhoto" = "no photos"; +"GroupPermission.NoSendPolls" = "no polls"; +"GroupPermission.NoSendReactions" = "no reactions"; +"GroupPermission.NoSendVideo" = "no videos"; +"GroupPermission.NoSendVideoMessage" = "no video messages"; +"GroupPermission.NoSendVoiceMessage" = "no voice messages"; +"GroupPermission.NotAvailableInDiscussionGroups" = "This permission is not available in discussion groups."; +"GroupPermission.NotAvailableInGeoGroups" = "This permission is not available in location-based groups."; +"GroupPermission.NotAvailableInPublicGroups" = "This permission is not available in public groups."; +"GroupPermission.PermissionDisabledByDefault" = "This option is disabled for all members in Group Permissions."; +"GroupPermission.PermissionGloballyDisabled" = "This permission is disabled in this group."; +"GroupPermission.SaveChanges" = "Save Changes"; +"GroupPermission.SectionTitle" = "WHAT CAN THIS MEMBER DO?"; +"GroupPermission.Title" = "Exception"; +"GroupRemoved.AddToGroup" = "Add To Group"; +"GroupRemoved.DeleteUser" = "Delete User"; +"GroupRemoved.Remove" = "Remove User"; +"GroupRemoved.RemoveInfo" = "Users removed from the group by admins can't rejoin via invite links."; +"GroupRemoved.Title" = "Removed Users"; +"GroupRemoved.UsersSectionTitle" = "REMOVED USERS"; +"GroupRemoved.ViewChannelInfo" = "View Channel"; +"GroupRemoved.ViewUserInfo" = "View User Info"; +"HashtagSearch.AllChats" = "All Chats"; +"HashtagSearch.ClearRecent" = "Clear History"; +"HashtagSearch.FoundInfoFormat" = "View %1$@ with %2$@"; +"HashtagSearch.FoundPosts" = "posts"; +"HashtagSearch.FoundStories" = "stories"; +"HashtagSearch.LocalStoriesFound" = "%1$@ in %2$@"; +"HashtagSearch.MyMessages" = "My Chats"; +"HashtagSearch.NoRecentQueries" = "Enter a hashtag to find messages that contain it."; +"HashtagSearch.NoResults" = "No Results"; +"HashtagSearch.NoResultsQueryCashtagDescription" = "There were no results for %@.\nTry another cashtag."; +"HashtagSearch.NoResultsQueryDescription" = "There were no results for %@.\nTry another hashtag."; +"HashtagSearch.Posts#one" = "%@ Message"; +"HashtagSearch.Posts#other" = "%@ Messages"; +"HashtagSearch.PublicPosts" = "Public Posts"; +"HashtagSearch.SearchPlaceholder" = "Hashtag search"; +"HashtagSearch.Stories#one" = "%@ Story"; +"HashtagSearch.Stories#other" = "%@ Stories"; +"HashtagSearch.StoriesFound#one" = "%@ Story Found"; +"HashtagSearch.StoriesFound#other" = "%@ Stories Found"; +"HashtagSearch.StoriesFoundInfo" = "View stories with %@"; +"HashtagSearch.ThisChat" = "This Chat"; +"ImportStickerPack.AddToExistingStickerSet" = "Add to an Existing Sticker Set"; +"ImportStickerPack.CheckingLink" = "checking availability..."; +"ImportStickerPack.ChooseLink" = "Choose Link"; +"ImportStickerPack.ChooseLinkDescription" = "You can use a-z, 0-9 and underscores."; +"ImportStickerPack.ChooseName" = "Choose Name"; +"ImportStickerPack.ChooseNameDescription" = "Please choose a name for your set."; +"ImportStickerPack.ChooseStickerSet" = "Choose Sticker Set"; +"ImportStickerPack.Create" = "Create"; +"ImportStickerPack.CreateNewEmojiPack" = "Create a New Emoji Pack"; +"ImportStickerPack.CreateNewStickerSet" = "Create a New Sticker Set"; +"ImportStickerPack.CreateStickerSet" = "Create Sticker Set"; +"ImportStickerPack.EmojiCount#one" = "%@ Emoji"; +"ImportStickerPack.EmojiCount#other" = "%@ Emoji"; +"ImportStickerPack.GeneratingLink" = "generating link..."; +"ImportStickerPack.ImportingEmojis" = "Importing Emoji"; +"ImportStickerPack.ImportingStickers" = "Importing Stickers"; +"ImportStickerPack.InProgress" = "Please keep this window open\nuntil the import is completed."; +"ImportStickerPack.LinkAvailable" = "Link is available."; +"ImportStickerPack.LinkTaken" = "Sorry, this link is already taken."; +"ImportStickerPack.NamePlaceholder" = "Name"; +"ImportStickerPack.Of" = "%1$@ of %2$@ Imported"; +"ImportStickerPack.RemoveFromImport" = "Remove From Import"; +"ImportStickerPack.StickerCount#one" = "1 Sticker"; +"ImportStickerPack.StickerCount#other" = "%@ Stickers"; +"InfoPlist.NSCameraUsageDescription" = "We need this so that you can take and share photos and videos, as well as make video calls."; +"InfoPlist.NSContactsUsageDescription" = "Telegram will continuously upload your contacts to its heavily encrypted cloud servers to let you connect with your friends across all your devices."; +"InfoPlist.NSFaceIDUsageDescription" = "You can use Face ID to unlock the app."; +"InfoPlist.NSLocationAlwaysAndWhenInUseUsageDescription" = "When you choose to share your Live Location with friends in a chat, Telegram needs background access to your location to keep them updated for the duration of the live sharing."; +"InfoPlist.NSLocationAlwaysUsageDescription" = "When you choose to share your live location with friends in a chat, Telegram needs background access to your location to keep them updated for the duration of the live sharing. You also need this to send locations from an Apple Watch."; +"InfoPlist.NSLocationWhenInUseUsageDescription" = "When you send your location to your friends, Telegram needs access to show them a map."; +"InfoPlist.NSMicrophoneUsageDescription" = "We need this so that you can record and share voice messages and videos with sound."; +"InfoPlist.NSPhotoLibraryAddUsageDescription" = "This is required in order to share your photos and videos or save new ones to your device."; +"InfoPlist.NSPhotoLibraryUsageDescription" = "This is required in order to share your photos and videos or save new ones to your device."; +"InfoPlist.NSSiriUsageDescription" = "You can use Siri to send messages."; +"InstantPage.AuthorAndDateTitle" = "By %1$@ • %2$@"; +"InstantPage.AutoNightTheme" = "Auto-Night Theme"; +"InstantPage.FeedbackButton" = "Report a bug with this preview"; +"InstantPage.FeedbackButtonShort" = "Wrong layout?"; +"InstantPage.FontNewYork" = "New York"; +"InstantPage.FontSanFrancisco" = "San Francisco"; +"InstantPage.OpenInBrowser" = "Open in %@"; +"InstantPage.Reference" = "Reference"; +"InstantPage.RelatedArticleAuthorAndDateTitle" = "%1$@ • %2$@"; +"InstantPage.Search" = "Search"; +"InstantPage.TapToOpenLink" = "Tap to open the link:"; +"InstantPage.Views#one" = "%@ view"; +"InstantPage.Views#other" = "%@ views"; +"InstantPage.VoiceOver.DecreaseFontSize" = "Decrease Font Size"; +"InstantPage.VoiceOver.IncreaseFontSize" = "Increase Font Size"; +"InstantPage.VoiceOver.ResetFontSize" = "Reset Font Size"; +"Intents.ErrorLockedText" = "Open Telegram and enter passcode to edit widget."; +"Intents.ErrorLockedTitle" = "Locked"; +"IntentsSettings.MainAccount" = "Main Account"; +"IntentsSettings.MainAccountInfo" = "Choose an account for Siri and share suggestions."; +"IntentsSettings.Reset" = "Reset"; +"IntentsSettings.ResetAll" = "Reset All Share Suggestions"; +"IntentsSettings.SuggestBy" = "Suggest By"; +"IntentsSettings.SuggestByAll" = "All Sent Messages"; +"IntentsSettings.SuggestByShare" = "Only Shared Messages"; +"IntentsSettings.SuggestedAndSpotlightChatsInfo" = "Suggestions will appear in the Share Sheet and Spotlight search results. Archived chats will not be suggested."; +"IntentsSettings.SuggestedChats" = "Suggested Chats"; +"IntentsSettings.SuggestedChatsContacts" = "Contacts"; +"IntentsSettings.SuggestedChatsGroups" = "Groups"; +"IntentsSettings.SuggestedChatsInfo" = "Archived chats will not be suggested."; +"IntentsSettings.SuggestedChatsPrivateChats" = "Private Chats"; +"IntentsSettings.SuggestedChatsSavedMessages" = "Saved Messages"; +"IntentsSettings.Title" = "Share Sheet"; +"Invitation.Group.AlreadyJoinedMultiple" = "%@ already joined this group."; +"Invitation.Group.AlreadyJoinedMultipleWithCount#one" = "{} and **%d** other person already joined this group."; +"Invitation.Group.AlreadyJoinedMultipleWithCount#other" = "{} and **%d** other people already joined this group."; +"Invitation.Group.AlreadyJoinedSingle" = "**%@** already joined this group."; +"Invitation.GroupCall" = "Group Call"; +"Invitation.GroupCall.AlreadyJoinedMultiple" = "%@ already joined this call."; +"Invitation.GroupCall.AlreadyJoinedMultipleWithCount#one" = "{} and **%d** other person already joined this call."; +"Invitation.GroupCall.AlreadyJoinedMultipleWithCount#other" = "{} and **%d** other people already joined this call."; +"Invitation.GroupCall.AlreadyJoinedSingle" = "**%@** already joined this call."; +"Invitation.GroupCall.Text" = "You are invited to join a group call."; +"Invitation.JoinGroup" = "Join Group"; +"Invitation.JoinGroupCall" = "Join Group Call"; +"Invitation.JoinGroupCall.EnableMicrophone" = "Turn on the microphone"; +"Invitation.JoinVoiceChat" = "Join Video Chat"; +"Invitation.JoinVoiceChatAsListener" = "Join as Listener"; +"Invitation.JoinVoiceChatAsSpeaker" = "Join as Speaker"; +"Invitation.Members#one" = "1 member:"; +"Invitation.Members#other" = "%@ members:"; +"Invitation.PrivateGroup" = "private group"; +"Invitation.PublicGroup" = "public group"; +"Invite.ChannelsTooMuch" = "Sorry, the target user is a member of too many groups and channels. Please ask them to leave some first."; +"Invite.LargeRecipientsCountWarning" = "Please note that it may take some time for your device to send all of these invitations"; +"InviteLink.AdditionalLinks" = "Additional Links"; +"InviteLink.ApprovalRequired" = "approval required"; +"InviteLink.CallLinkTitle" = "Call Link"; +"InviteLink.ContextCopy" = "Copy"; +"InviteLink.ContextDelete" = "Delete"; +"InviteLink.ContextEdit" = "Edit"; +"InviteLink.ContextGetQRCode" = "Get QR Code"; +"InviteLink.ContextRevoke" = "Revoke"; +"InviteLink.ContextShare" = "Share"; +"InviteLink.CopyShort" = "Copy"; +"InviteLink.Create" = "Create a New Link"; +"InviteLink.Create.EditTitle" = "Edit Link"; +"InviteLink.Create.Fee" = "Require Monthly Fee"; +"InviteLink.Create.FeeEditInfo" = "To change the subscription fee, create a new invite link with a different price."; +"InviteLink.Create.FeeInfo" = "Charge a subscription fee from people who join your channel via this link. [Learn More >]()"; +"InviteLink.Create.FeePerMonth" = "%@ / month"; +"InviteLink.Create.FeePlaceholder" = "Star amount per month"; +"InviteLink.Create.LinkName" = "Link Name (Optional)"; +"InviteLink.Create.LinkNameInfo" = "Only admins will see this name."; +"InviteLink.Create.LinkNameTitle" = "Link Name"; +"InviteLink.Create.RequestApproval" = "Request Admin Approval"; +"InviteLink.Create.RequestApprovalFeeUnavailable" = "You can't enable admin approval for links that require a monthly fee."; +"InviteLink.Create.RequestApprovalInfo" = "Request admin approval for people joining through this link."; +"InviteLink.Create.RequestApprovalOffInfoChannel" = "Turn this on if you want users to join only after they are approved by an admin."; +"InviteLink.Create.RequestApprovalOffInfoGroup" = "Turn this on if you want users to join only after they are approved by an admin."; +"InviteLink.Create.RequestApprovalOnInfoChannel" = "Turn this on if you want users to join only after they are approved by an admin."; +"InviteLink.Create.RequestApprovalOnInfoGroup" = "Turn this on if you want users to join only after they are approved by an admin."; +"InviteLink.Create.RequestApprovalPublicGroupUnavailable" = "This option is unavailable because anyone can join and send messages through the public group link."; +"InviteLink.Create.RequestApprovalUnavailableInfo" = "You can't enable admin approval for links that require a monthly fee."; +"InviteLink.Create.Revoke" = "Revoke Link"; +"InviteLink.Create.Subscription" = "Require Monthly Fee"; +"InviteLink.Create.Subscription.EditInfo" = "To change the subscription fee, create a new invite link with a different price."; +"InviteLink.Create.Subscription.Info" = "Charge a subscription fee from people joining your channel via this link. [Learn More >]()"; +"InviteLink.Create.Subscription.PerMonth" = "%@ / month"; +"InviteLink.Create.Subscription.Placeholder" = "Star amount per month"; +"InviteLink.Create.TimeLimit" = "Limit By Time Period"; +"InviteLink.Create.TimeLimitExpiryDate" = "Expiry Date"; +"InviteLink.Create.TimeLimitExpiryDateNever" = "Never"; +"InviteLink.Create.TimeLimitExpiryTime" = "Time"; +"InviteLink.Create.TimeLimitInfo" = "You can make the link expire after a certain time."; +"InviteLink.Create.TimeLimitNoLimit" = "No Limit"; +"InviteLink.Create.Title" = "New Link"; +"InviteLink.Create.UsersLimit" = "Limit By Number Of Users"; +"InviteLink.Create.UsersLimitInfo" = "You can make the link work only for a certain number of users."; +"InviteLink.Create.UsersLimitNoLimit" = "No Limit"; +"InviteLink.Create.UsersLimitNumberOfUsers" = "Number of Users"; +"InviteLink.Create.UsersLimitNumberOfUsersUnlimited" = "Unlimited"; +"InviteLink.CreateInfo" = "You can create additional invite links that have limited time or number of users."; +"InviteLink.CreateNewInfo" = "You can create additional invite links that are limited by time, number of users, or require a paid subscription."; +"InviteLink.CreatePrivateLinkHelp" = "Anyone on Telegram will be able to join your group by following this link."; +"InviteLink.CreatePrivateLinkHelpChannel" = "Anyone who has Telegram installed will be able to join your channel by following this link."; +"InviteLink.CreatedBy" = "Link Created By"; +"InviteLink.CreatedGroupCallFooter" = "Be the first to join the call and add people from there. [Open Call >](open_call)"; +"InviteLink.DeleteAllRevokedLinks" = "Delete All Revoked Links"; +"InviteLink.DeleteAllRevokedLinksAlert.Action" = "Delete All"; +"InviteLink.DeleteAllRevokedLinksAlert.Text" = "This will delete all revoked links."; +"InviteLink.DeleteLinkAlert.Action" = "Delete"; +"InviteLink.DeleteLinkAlert.Text" = "Are you sure you want to delete this link? It will be completely gone."; +"InviteLink.Expired" = "expired"; +"InviteLink.ExpiredLink" = "Expired Link"; +"InviteLink.ExpiredLinkStatus" = "time limit has expired"; +"InviteLink.ExpiresIn" = "expires in %@"; +"InviteLink.GroupCallLinkHelp" = "Anyone on Telegram can join your call by following the link below."; +"InviteLink.InviteLink" = "Invite Link"; +"InviteLink.InviteLinkCopiedText" = "Invite link copied to clipboard."; +"InviteLink.InviteLinkForwardTooltip.Chat.One" = "Invite link sent to **%@**."; +"InviteLink.InviteLinkForwardTooltip.ManyChats.One" = "Invite link sent to **%@** and %@ other chats."; +"InviteLink.InviteLinkForwardTooltip.SavedMessages.One" = "Invite link sent to **Saved Messages**."; +"InviteLink.InviteLinkForwardTooltip.TwoChats.One" = "Invite link sent to **%@** and **%@**."; +"InviteLink.InviteLinkRevoked" = "The invite link has been revoked."; +"InviteLink.InviteLinks#one" = "%@ invite link"; +"InviteLink.InviteLinks#other" = "%@ invite links"; +"InviteLink.LabelJoinedViaFolder" = "joined via a folder invite link"; +"InviteLink.Manage" = "Manage Invite Links"; +"InviteLink.OtherAdminsLinks" = "Invite Links Created By Other Admins"; +"InviteLink.OtherPermanentLinkInfo" = "**%1$@** can see this link and use it to invite new members to **%2$@**."; +"InviteLink.PeopleCanJoin#one" = "%@ can join"; +"InviteLink.PeopleCanJoin#other" = "%@ can join"; +"InviteLink.PeopleJoined#one" = "%@ person joined"; +"InviteLink.PeopleJoined#other" = "%@ people joined"; +"InviteLink.PeopleJoinedNone" = "no one joined yet"; +"InviteLink.PeopleJoinedShort#one" = "%@ joined"; +"InviteLink.PeopleJoinedShort#other" = "%@ joined"; +"InviteLink.PeopleJoinedShortNone" = "no one joined yet"; +"InviteLink.PeopleJoinedShortNoneExpired" = "no one joined"; +"InviteLink.PeopleRemaining#one" = "%@ remaining"; +"InviteLink.PeopleRemaining#other" = "%@ remaining"; +"InviteLink.PerMonth" = "per month"; +"InviteLink.PermanentLink" = "Invite Link"; +"InviteLink.PublicLink" = "Public Link"; +"InviteLink.QRCode.Info" = "Everyone on Telegram can scan this code to join your group."; +"InviteLink.QRCode.InfoChannel" = "Everyone on Telegram can scan this code to join your channel."; +"InviteLink.QRCode.InfoGroupCall" = "Everyone on Telegram can scan this code to join your group call."; +"InviteLink.QRCode.Share" = "Share QR Code"; +"InviteLink.QRCode.Title" = "Invite by QR Code"; +"InviteLink.QRCodeFolder.Text" = "Anyone on Telegram can scan this code to add the folder and join all the chats included in its invite link."; +"InviteLink.QRCodeFolder.Title" = "Invite by QR Code"; +"InviteLink.ReactivateLink" = "Reactivate Link"; +"InviteLink.Revoked" = "revoked"; +"InviteLink.RevokedLinks" = "Revoked Links"; +"InviteLink.Share" = "Share Link"; +"InviteLink.ShareShort" = "Share"; +"InviteLink.Subscription.Fee" = "Subscription Fee"; +"InviteLink.Subscription.Info" = "You get approximately %@ monthly"; +"InviteLink.SubscriptionFee.ApproximateIncome" = "You get approximately %@ monthly"; +"InviteLink.SubscriptionFee.NoOneJoined" = "No one joined yet"; +"InviteLink.SubscriptionFee.PerMonth" = "⭐️%@ / month"; +"InviteLink.SubscriptionFee.Title" = "SUBSCRIPTION FEE"; +"InviteLink.Title" = "Invite Links"; +"InviteLink.UsageLimitReached" = "limit reached"; +"InviteLinks.InviteLinkExpired" = "This invite link has expired."; +"InviteText.ContactsCount#one" = "Hey, I'm using Telegram to chat. Join me! Download it here: %@"; +"InviteText.ContactsCount#other" = "Hey, I'm using Telegram to chat – and so are %@ of our other contacts. Join us! Download it here: %@"; +"InviteText.ContactsCountText#one" = "Hey, I'm using Telegram to chat. Join me! Download it here: {url}"; +"InviteText.ContactsCountText#other" = "Hey, I'm using Telegram to chat – and so are %@ of our other contacts. Join us! Download it here: {url}"; +"InviteText.SingleContact" = "Hey, I'm using Telegram to chat. Join me! Download it here: %@"; +"InviteText.URL" = "https://telegram.org/dl"; +"Items.NOfM" = "%1$@ of %2$@"; +"Join.ChannelsTooMuch" = "Sorry, you are a member of too many groups and channels. Please leave one before joining another."; +"KeyCommand.ChatInfo" = "Chat Info"; +"KeyCommand.EnterFullscreen" = "Enter Fullscreen"; +"KeyCommand.ExitFullscreen" = "Exit Fullscreen"; +"KeyCommand.Find" = "Search"; +"KeyCommand.FocusOnInputField" = "Write Message"; +"KeyCommand.JumpToNextChat" = "Jump to Next Chat"; +"KeyCommand.JumpToNextUnreadChat" = "Jump to Next Unread Chat"; +"KeyCommand.JumpToPreviousChat" = "Jump to Previous Chat"; +"KeyCommand.JumpToPreviousUnreadChat" = "Jump to Previous Unread Chat"; +"KeyCommand.LockWithPasscode" = "Lock with Passcode"; +"KeyCommand.NewMessage" = "New Message"; +"KeyCommand.Pause" = "Pause Video"; +"KeyCommand.Play" = "Play Video"; +"KeyCommand.ScrollDown" = "Scroll Down"; +"KeyCommand.ScrollUp" = "Scroll Up"; +"KeyCommand.SearchInChat" = "Search in Chat"; +"KeyCommand.SeekBackward" = "Seek Backward"; +"KeyCommand.SeekForward" = "Seek Forward"; +"KeyCommand.SendMessage" = "Send Message"; +"KeyCommand.Share" = "Share"; +"KeyCommand.SwitchToPIP" = "Switch to Picture-in-Picture"; +"LOCKED_MESSAGE" = "You have a new message%1$@"; +"LastSeen.ALongTimeAgo" = "last seen a long time ago"; +"LastSeen.AtDate" = "last seen %@"; +"LastSeen.HoursAgo#one" = "last seen 1 hour ago"; +"LastSeen.HoursAgo#other" = "last seen %@ hours ago"; +"LastSeen.JustNow" = "last seen just now"; +"LastSeen.Lately" = "last seen recently"; +"LastSeen.MinutesAgo#one" = "last seen 1 minute ago"; +"LastSeen.MinutesAgo#other" = "last seen %@ minutes ago"; +"LastSeen.Offline" = "last seen a long time ago"; +"LastSeen.TodayAt" = "last seen today at %@"; +"LastSeen.WithinAMonth" = "last seen within a month"; +"LastSeen.WithinAWeek" = "last seen within a week"; +"LastSeen.YesterdayAt" = "last seen yesterday at %@"; +"LeaveGroup.AppointAnotherOwner" = "Appoint Another Owner"; +"LeaveGroup.LegacyGroupText" = "If you leave, **%1$@** will immediately become the owner of **%2$@**."; +"LeaveGroup.Proceed" = "Leave Group"; +"LeaveGroup.Text" = "If you leave, **%1$@** will become the owner of **%2$@** in **1 week**."; +"LeaveGroup.Title" = "Leave %@"; +"LiveLocation.MenuChatsCount#one" = "You are sharing Live Location with 1 chat."; +"LiveLocation.MenuChatsCount#other" = "You are sharing Live Location with %@ chats."; +"LiveLocation.MenuStopAll" = "Stop All"; +"LiveLocationUpdated.JustNow" = "updated just now"; +"LiveLocationUpdated.MinutesAgo#one" = "updated 1 minute ago"; +"LiveLocationUpdated.MinutesAgo#other" = "updated %@ minutes ago"; +"LiveLocationUpdated.TodayAt" = "updated at %@"; +"LiveStream.AnonymousDisabledAlertText" = "Sorry, you can't join live streams as an anonymous admin."; +"LiveStream.CancelConfirmationText" = "Do you want to abort the scheduled live stream?"; +"LiveStream.CancelConfirmationTitle" = "Abort Live Stream"; +"LiveStream.ChatFullAlertText" = "Sorry, this live stream has too many participants at the moment."; +"LiveStream.CommentsDisabledPlaceholder" = "Comments disabled"; +"LiveStream.CreateNewVoiceChatText" = "Live stream ended. Start a new one?"; +"LiveStream.DisplayAsSuccess" = "Participants of this live stream will now see you as **%@**."; +"LiveStream.EditTitle" = "Edit Live Stream Title"; +"LiveStream.EditTitleRemoveSuccess" = "Live Stream title removed."; +"LiveStream.EditTitleSuccess" = "Live stream title changed to **%@**."; +"LiveStream.EditTitleText" = "Discussion topics, notable guests, etc."; +"LiveStream.EndConfirmationText" = "Are you sure you want to end this live stream?"; +"LiveStream.EndConfirmationTitle" = "End live stream"; +"LiveStream.ErrorMaxAllowedEmoji.Text#one" = "You can send up to %d emoji."; +"LiveStream.ErrorMaxAllowedEmoji.Text#other" = "You can send up to %d emoji."; +"LiveStream.Expand" = "expand"; +"LiveStream.InputContextMenuAddStars" = "Add Stars"; +"LiveStream.InputContextMenuEditStars" = "Edit Stars"; +"LiveStream.InputContextMenuRemoveStars" = "Remove Stars"; +"LiveStream.InputPlaceholder" = "Comment"; +"LiveStream.InvitedPeerText" = "You invited %@ to the live stream"; +"LiveStream.LeaveAndCancelVoiceChat" = "Abort Live Stream"; +"LiveStream.LeaveAndEndVoiceChat" = "End Live Stream"; +"LiveStream.LeaveConfirmation" = "Are you sure you want to leave this live stream?"; +"LiveStream.LeaveVoiceChat" = "Leave Live Stream"; +"LiveStream.Listening.Members#one" = "%@ listening"; +"LiveStream.Listening.Members#other" = "%@ listening"; +"LiveStream.LoadingStatus" = "loading..."; +"LiveStream.NoSignalAdminText" = "Oops! Telegram doesn't see any stream\ncoming from your streaming app.\n\nPlease make sure you entered the right Server\nURL and Stream Key in your app."; +"LiveStream.NoSignalUserText" = "%@ is currently not broadcasting live\nstream data to Telegram."; +"LiveStream.NoViewers" = "No viewers"; +"LiveStream.PeerJoinedText" = "%@ joined the live stream"; +"LiveStream.RecordTitle" = "Record Live Stream"; +"LiveStream.RecordingInProgress" = "Live stream is being recorded"; +"LiveStream.RecordingSaved" = "Live stream recording saved to **Saved Messages**."; +"LiveStream.RecordingStarted" = "Live stream recording started."; +"LiveStream.RemoveAndBanPeerConfirmation" = "Do you want to remove %1$@ from the live stream and ban them in %2$@?"; +"LiveStream.StartRecording" = "Start Recording"; +"LiveStream.StartRecordingText" = "Do you want to start recording this live stream and save the result into an audio file?\n\nParticipants will see that the chat is being recorded."; +"LiveStream.StartRecordingTextVideo" = "Do you want to start recording this live stream and save the result into a video file?\n\nOther participants will see that the chat is being recorded."; +"LiveStream.StartRecordingTitle" = "Start Recording"; +"LiveStream.StopLiveStream" = "Stop Live Stream"; +"LiveStream.StoryViewerCount#one" = "1 watching"; +"LiveStream.StoryViewerCount#other" = "%@ watching"; +"LiveStream.StoryViewerEmpty" = "no viewers"; +"LiveStream.ViewCredentials" = "View Stream Key"; +"LiveStream.ViewerCount#one" = "1 viewer"; +"LiveStream.ViewerCount#other" = "%@ viewers"; +"LiveStream.Watching" = "watching"; +"LiveStream.Watching.Members#one" = "%@ watching"; +"LiveStream.Watching.Members#other" = "%@ watching"; +"LiveStreamSettings.AllowComments" = "Allow Comments"; +"LiveStreamSettings.AllowScreenshots" = "Allow Screenshots"; +"LiveStreamSettings.ConnectStream" = "Connect Stream"; +"LiveStreamSettings.ConnectStreamInfo" = "Stream with a different app."; +"LiveStreamSettings.PricePerComment" = "PRICE PER COMMENT"; +"LiveStreamSettings.PricePerComment.Free" = "Free"; +"LiveStreamSettings.PricePerComment.Stars#one" = "%@ Star"; +"LiveStreamSettings.PricePerComment.Stars#other" = "%@ Stars"; +"LiveStreamSettings.PricePerCommentInfo" = "The price a viewer must pay to send a comment."; +"LiveStreamSettings.SaveSettings" = "Save Settings"; +"LiveStreamSettings.StartLiveAs" = "START LIVE AS"; +"LiveStreamSettings.Title" = "Live Settings"; +"LiveStreamSettings.TitleEdit" = "Live Stream"; +"LiveStreamSettings.WhoCanView" = "WHO CAN VIEW THIS LIVE"; +"LiveStreamSettings.WhoCanViewInfo" = "[Select people]() who will never see your Live Stories."; +"LocalGroup.ButtonTitle" = "Start Group"; +"LocalGroup.IrrelevantWarning" = "If you start an unrelated group at this location, you may get restricted in creating new location-based groups."; +"LocalGroup.Text" = "Anyone close to this location (neighbors, co-workers, fellow students, event attendees, visitors of a venue) will see your group in the People Nearby section."; +"LocalGroup.Title" = "Create a Local Group"; +"Localization.ChooseLanguage" = "Choose Your Language"; +"Localization.DoNotTranslate" = "Do Not Translate"; +"Localization.DoNotTranslateInfo" = "Do not show 'Translate' buttons for this language."; +"Localization.DoNotTranslateManyInfo" = "Do not show 'Translate' buttons for these languages."; +"Localization.EnglishLanguageName" = "English"; +"Localization.InterfaceLanguage" = "Interface Language"; +"Localization.LanguageCustom" = "Custom"; +"Localization.LanguageName" = "English"; +"Localization.LanguageOther" = "Other"; +"Localization.ShowTranslate" = "Show Translate Button"; +"Localization.ShowTranslateInfo" = "Show the 'Translate' button in the message context menu."; +"Localization.ShowTranslateInfoExtended" = "Show a 'Translate' button when holding down on a message."; +"Localization.TranslateEntireChat" = "Translate Entire Chats"; +"Localization.TranslateMessages" = "Translate Messages"; +"Location.AddMyLocation" = "Add My Current Location"; +"Location.AddThisLocation" = "Add This Location"; +"Location.LiveLocationRequired.Description" = "For the alert to work, please share your live location in this chat."; +"Location.LiveLocationRequired.ShareLocation" = "Share Location"; +"Location.LiveLocationRequired.Title" = "Share Location"; +"Location.PollSubtitle.Description" = "Add location to the poll description"; +"Location.PollSubtitle.Explanation" = "Add location to the quiz explanation"; +"Location.PollSubtitle.PollOption" = "Add location to this option"; +"Location.ProximityAlertCancelled" = "Proximity alert canceled"; +"Location.ProximityAlertSetText" = "We will notify you once %1$@ is within %2$@ from you."; +"Location.ProximityAlertSetTextGroup" = "We will notify you once any group member is within %@ from you."; +"Location.ProximityAlertSetTitle" = "Proximity alert set"; +"Location.ProximityGroupTip" = "Alert when any group member is close"; +"Location.ProximityNotification.AlreadyClose" = "You are already closer than %@"; +"Location.ProximityNotification.DistanceKM" = "km"; +"Location.ProximityNotification.DistanceM" = "m"; +"Location.ProximityNotification.DistanceMI" = "mi"; +"Location.ProximityNotification.Notify" = "Notify me within %@"; +"Location.ProximityNotification.NotifyLong" = "Notify when %1$@ is within %2$@"; +"Location.ProximityNotification.Title" = "Proximity Alert"; +"Location.ProximityTip" = "Alert when %@ is close"; +"Location.SendLocation" = "Send Location"; +"Location.TypeCity" = "City"; +"Location.TypeLocation" = "Location"; +"Location.TypeStreet" = "Street"; +"Login.AddEmailPlaceholder" = "Enter your email"; +"Login.AddEmailText" = "You will receive Telegram login codes via email and not SMS. Please enter an email address to which you have access."; +"Login.AddEmailTitle" = "Choose a login email"; +"Login.Announce.DontNotify" = "Do Not Notify"; +"Login.Announce.Info" = "Notify people on Telegram who know my phone number that I signed up."; +"Login.Announce.Notify" = "Notify"; +"Login.AnonymousNumbers" = "Anonymous Numbers"; +"Login.BackToCode" = "Back to entering the code"; +"Login.BackToPhrase" = "Back to entering the phrase"; +"Login.BackToWord" = "Back to entering the word"; +"Login.BannedPhoneBody" = "I'm trying to use my mobile phone number: %@\nBut Telegram says it's banned, please help."; +"Login.BannedPhoneSubject" = "Banned phone number: %@"; +"Login.CallRequestState1" = "Telegram will call you in %d:%.2d"; +"Login.CallRequestState2" = "Requesting a call from Telegram…"; +"Login.CallRequestState3" = "Telegram dialed your number\n[Didn't get the code?]"; +"Login.CancelEmailVerification" = "Do you want to stop the email verification process?"; +"Login.CancelEmailVerificationContinue" = "Continue"; +"Login.CancelEmailVerificationStop" = "Stop"; +"Login.CancelPhoneVerification" = "Do you want to stop the verification process?"; +"Login.CancelPhoneVerificationContinue" = "Continue"; +"Login.CancelPhoneVerificationStop" = "Stop"; +"Login.CancelSignUpConfirmation" = "Do you want to stop the registration process?"; +"Login.CheckOtherSessionMessages" = "Check your Telegram messages"; +"Login.Code" = "Code"; +"Login.CodeExpired" = "Code expired, please login again."; +"Login.CodeExpiredError" = "Code expired, please start over."; +"Login.CodeFloodError" = "Too many attempts, please try again later."; +"Login.CodePhonePatternInfoText" = "Please enter the last digits\nof the number that called."; +"Login.CodeSentCall" = "Calling your phone to dictate the code."; +"Login.CodeSentCallText" = "Calling **%@** to dictate the code."; +"Login.CodeSentInternal" = "We've sent the code to the **Telegram** app on your other device"; +"Login.CodeSentSms" = "We've sent you an SMS with the code"; +"Login.Continue" = "Continue"; +"Login.ContinueWithLocalization" = "Continue in English"; +"Login.CountryCode" = "Country Code"; +"Login.Edit" = "Edit"; +"Login.Email.CantAccess" = "Can't access this email?"; +"Login.Email.ElapsedTime" = "in %@"; +"Login.Email.PremiumRequiredText" = "Due to high cost of SMS in your country, you need to have a **Telegram Premium** account to reset this email via an SMS code.\n\nYou can ask a friend to gift a Premium subscription for your account\n**%@**"; +"Login.Email.PremiumRequiredTitle" = "Telegram Premium Required"; +"Login.Email.Reset" = "Reset"; +"Login.Email.ResetNowViaSMS" = "Reset now via SMS"; +"Login.Email.ResetText" = "You can change your login email if you are logged into Telegram from another device. Otherwise, if you don't have access to email %1$@, you can reset this email with an **SMS** code **%2$@**."; +"Login.Email.ResetTitle" = "Reset Email"; +"Login.Email.ResetingNow" = "Please wait..."; +"Login.Email.WillBeResetIn" = "Email will be reset %@"; +"Login.EmailChanged" = "Your email has been changed."; +"Login.EmailCodeBody" = "My phone number is:\n%@\nI can't get an activation code for Telegram."; +"Login.EmailCodeSubject" = "%@, no code"; +"Login.EmailNotAllowedError" = "Sorry, this email can't be used."; +"Login.EmailNotConfiguredError" = "An email account is required so that you can send us details about the error.\n\nPlease go to your device‘s settings > Mail, then tap Accounts > Add account and set up an email account."; +"Login.EmailPhoneBody" = "I'm trying to use my mobile phone number: %@\nBut Telegram says it's invalid. Please help.\nAdditional Info: %@, %@."; +"Login.EmailPhoneSubject" = "Invalid number %@"; +"Login.EnterCodeEmailText" = "Please enter the code we have sent to your email %@."; +"Login.EnterCodeEmailTitle" = "Check Your Email"; +"Login.EnterCodeFragmentText" = "Get the code for **%@** in the Numbers section on **Fragment**."; +"Login.EnterCodeFragmentTitle" = "Enter Code"; +"Login.EnterCodeNewEmailText" = "Please enter the code we have sent to your new email %@."; +"Login.EnterCodeNewEmailTitle" = "Check Your New Email"; +"Login.EnterCodeSMSText" = "We've sent an SMS with an activation code to your phone **%@**."; +"Login.EnterCodeSMSTitle" = "Enter Code"; +"Login.EnterCodeTelegramText" = "We've sent the code to the **Telegram app** for %@ on your other device."; +"Login.EnterCodeTelegramTitle" = "Enter Code"; +"Login.EnterMissingDigits" = "Enter the missing digits"; +"Login.EnterNewEmailTitle" = "Enter New Email"; +"Login.EnterPhraseBeginningText" = "We've sent you an SMS with a secret phrase that starts with **\"%1$@\"** to your phone **%2$@**."; +"Login.EnterPhraseHint" = "You can copy and paste the phrase here."; +"Login.EnterPhrasePlaceholder" = "Enter Phrase from SMS"; +"Login.EnterPhraseText" = "We've sent you an SMS with a secret phrase to your phone **%@**."; +"Login.EnterPhraseTitle" = "Enter Phrase"; +"Login.EnterWordBeginningText" = "We've sent you an SMS with a secret word that starts with **\"%1$@\"** to your phone **%2$@**."; +"Login.EnterWordPlaceholder" = "Enter Word from SMS"; +"Login.EnterWordText" = "We've sent you an SMS with a secret word to your phone **%@**."; +"Login.EnterWordTitle" = "Enter Word"; +"Login.ErrorAppOutdated" = "Please update Telegram to the latest version to log in."; +"Login.Fee.GetPremiumForAWeek" = "Get Telegram Premium for 1 week"; +"Login.Fee.GetPremiumForDays" = "Get Telegram Premium for %@"; +"Login.Fee.GetPremiumForDays.Days#one" = "%@ day"; +"Login.Fee.GetPremiumForDays.Days#other" = "%@ days"; +"Login.Fee.GetPremiumNone" = "Get Telegram Premium"; +"Login.Fee.SignUp" = "Sign Up for %@"; +"Login.Fee.SmsCost.Text" = "Due to fake accounts and high SMS costs in your country, you’ll need to cover your signup fee."; +"Login.Fee.SmsCost.Title" = "High SMS Costs"; +"Login.Fee.Support.NewText" = "Sign up for %@ of Telegram Premium to help cover the SMS costs."; +"Login.Fee.Support.NewText.Days#one" = "%@ day"; +"Login.Fee.Support.NewText.Days#other" = "%@ days"; +"Login.Fee.Support.NewTextNone" = "Sign up for Telegram Premium subscription to help cover the SMS costs."; +"Login.Fee.Support.Text" = "Sign up for a 1-week Telegram Premium subscription to help cover the SMS costs."; +"Login.Fee.Support.Title" = "Support via [Telegram Premium >]()"; +"Login.Fee.Title" = "SMS Fee"; +"Login.Fee.Verification.Text" = "Telegram needs to send you an SMS with a verification code to confirm your phone number."; +"Login.Fee.Verification.Title" = "Verification Required"; +"Login.GetCodeViaFragment" = "Get a code via Fragment"; +"Login.HaveNotReceivedCodeInternal" = "Didn't get the code?"; +"Login.InfoAvatarAdd" = "add"; +"Login.InfoAvatarPhoto" = "photo"; +"Login.InfoDeletePhoto" = "Remove Photo"; +"Login.InfoFirstNamePlaceholder" = "First Name"; +"Login.InfoHelp" = "Enter your name and add a profile photo."; +"Login.InfoLastNamePlaceholder" = "Last Name"; +"Login.InfoTitle" = "Your Info"; +"Login.InvalidCodeError" = "Invalid code, please try again."; +"Login.InvalidCountryCode" = "Invalid Country Code"; +"Login.InvalidEmailAddressError" = "An error occurred. Please try again."; +"Login.InvalidEmailError" = "Please enter a valid email address."; +"Login.InvalidEmailTokenError" = "An error occurred. Please try again."; +"Login.InvalidFirstNameError" = "This first name is not allowed, please try another."; +"Login.InvalidLastNameError" = "Sorry, this last name can't be used."; +"Login.InvalidPhoneEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut Telegram says it's invalid. Please help.\n\nApp version: %2$@\nOS version: %3$@\nLocale: %4$@\nMNC: %5$@"; +"Login.InvalidPhoneEmailSubject" = "Invalid phone number: %@"; +"Login.InvalidPhoneError" = "Invalid phone number, please try again."; +"Login.NetworkError" = "Please check your Internet connection and try again."; +"Login.NewNumber" = "New Number"; +"Login.OpenFragment" = "Open Fragment"; +"Login.Or" = "or"; +"Login.PRIVACY_URL" = "https://telegram.org/privacy"; +"Login.PadPhoneHelp" = "You can use your main mobile number to log in to Telegram on all devices.\nDon't use your iPad's SIM number here — we’ll need to send you an SMS.\n\nIs this number correct?\n{number}"; +"Login.PadPhoneHelpTitle" = "Your Phone Number"; +"Login.Paste" = "Paste"; +"Login.PhoneAndCountryHelp" = "Please confirm your country code\nand enter your phone number."; +"Login.PhoneBannedEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut Telegram says it's banned. Please help.\n\nApp version: %2$@\nOS version: %3$@\nLocale: %4$@\nMNC: %5$@"; +"Login.PhoneBannedEmailSubject" = "Banned phone number: %@"; +"Login.PhoneBannedError" = "This phone number is banned."; +"Login.PhoneFloodError" = "Sorry, you have deleted and re-created your account too many times recently. Please wait for a few days before signing up again."; +"Login.PhoneGenericEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut Telegram shows an error. Please help.\n\nError: %2$@\nApp version: %3$@\nOS version: %4$@\nLocale: %5$@\nMNC: %6$@"; +"Login.PhoneGenericEmailSubject" = "Telegram iOS error: %@"; +"Login.PhoneNumberAlreadyAuthorized" = "This account is already logged in from this app."; +"Login.PhoneNumberAlreadyAuthorizedSwitch" = "Switch"; +"Login.PhoneNumberConfirmation" = "Is this the correct number?"; +"Login.PhoneNumberHelp" = "Help"; +"Login.PhonePaidEmailSubject" = "Payment Issue"; +"Login.PhonePlaceholder" = "Your phone number"; +"Login.PhoneTitle" = "Your Phone"; +"Login.PhoneWithPasskeySubtitle" = "Enter your phone number\nor [log in using Passkey >](passkey)"; +"Login.ResetAccountProtected.LimitExceeded" = "Your recent attempts to reset this account have been canceled by its active user. Please try again in 7 days."; +"Login.ResetAccountProtected.Reset" = "Reset"; +"Login.ResetAccountProtected.Text" = "Since the account %@ is active and protected by a password, it will be deleted in 1 week. This delay is required for security purposes.\n\nYou can cancel this process anytime."; +"Login.ResetAccountProtected.TimerTitle" = "You'll be able to reset your account in:"; +"Login.ResetAccountProtected.Title" = "Reset Account"; +"Login.ReturnToCode" = "Return to entering the code"; +"Login.ReturnToPhrase" = "Return to entering the phrase"; +"Login.ReturnToWord" = "Return to entering the word"; +"Login.SelectCountry" = "Country"; +"Login.SelectCountry.Title" = "Country"; +"Login.SendCodeAsSMS" = "Send the code as an SMS"; +"Login.SendCodeViaCall" = "Get code on this phone"; +"Login.SendCodeViaFlashCall" = "Get the code via phone call"; +"Login.SendCodeViaSms" = "Tap to get a code via SMS"; +"Login.ShortCallTitle" = "In a few seconds you should\nreceive a short call from:"; +"Login.SmsRequestState1" = "Sending you an SMS in %d:%.2d"; +"Login.SmsRequestState2" = "Requesting an SMS from Telegram…"; +"Login.SmsRequestState3" = "Telegram sent you an SMS\n[Didn't get the code?]"; +"Login.TermsOfService.ProceedBot" = "Agree and proceed to %@."; +"Login.TermsOfServiceAgree" = "Agree & Continue"; +"Login.TermsOfServiceDecline" = "Decline"; +"Login.TermsOfServiceHeader" = "Terms of Service"; +"Login.TermsOfServiceLabel" = "By signing up,\nyou agree to the [Terms of Service]."; +"Login.TermsOfServiceSignupDecline" = "Unfortunately, this means you can't sign up for Telegram.\n\nUnlike other apps, Telegram does not use user data for ad targeting or other commercial purposes. Telegram only stores the information it needs to function as a feature-rich cloud service. You can adjust how your data is used (e.g., delete synced contacts) in Privacy & Security settings.\n\nIf you are not comfortable with Telegram's modest needs, it won't be possible for us to provide you with this service."; +"Login.UnknownError" = "An error occurred, please try again later."; +"Login.VoiceOver.Password" = "Password"; +"Login.VoiceOver.PhoneCountryCode" = "Phone country code"; +"Login.VoiceOver.PhoneNumber" = "Phone number"; +"Login.WillCallYou" = "Telegram will call you in %@"; +"Login.WillSendSms" = "Telegram will send you an SMS in %@"; +"Login.WrongCodeError" = "Wrong code, please try again."; +"Login.WrongPhraseError" = "Incorrect, please try again."; +"Login.Yes" = "Yes"; +"LoginEmail.Description" = "You will receive Telegram login codes via email and not SMS. Please enter an email address to which you have access."; +"LoginEmail.Success.Text" = "Your account is now protected!"; +"LoginEmail.Success.Title" = "Email added"; +"LoginEmail.Title" = "Add Email"; +"LoginPassword.FloodError" = "Too many attempts, please try again later."; +"LoginPassword.ForgotPassword" = "Forgot password?"; +"LoginPassword.InvalidPasswordError" = "Invalid password, please try again."; +"LoginPassword.PasswordHelp" = "Two-Step Verification enabled. Your account is protected with an additional password."; +"LoginPassword.PasswordPlaceholder" = "Password"; +"LoginPassword.ResetAccount" = "Reset Account"; +"LoginPassword.Title" = "Your Password"; +"LogoutOptions.AddAccountText" = "Connect other Telegram accounts and easily switch between them."; +"LogoutOptions.AddAccountTitle" = "Add Another Account"; +"LogoutOptions.AlternativeOptionsSection" = "ALTERNATIVE OPTIONS"; +"LogoutOptions.ChangePhoneNumberText" = "Move your account, chats and media to a new number."; +"LogoutOptions.ChangePhoneNumberTitle" = "Change Phone Number"; +"LogoutOptions.ClearCacheText" = "Free up disk space on your device; your media will stay in the cloud."; +"LogoutOptions.ClearCacheTitle" = "Clear Cache"; +"LogoutOptions.ContactSupportText" = "Tell us about any issues; logging out doesn't usually help."; +"LogoutOptions.ContactSupportTitle" = "Contact Support"; +"LogoutOptions.LogOut" = "Log Out"; +"LogoutOptions.LogOutInfo" = "Logging out deactivates all Secret Chats."; +"LogoutOptions.SetPasscodeText" = "Lock the app with a passcode so that others can't open it."; +"LogoutOptions.SetPasscodeTitle" = "Set a Passcode"; +"LogoutOptions.Title" = "Log out"; +"MESSAGES" = "%1$@ sent you %2$@ messages"; +"MESSAGES_SEPARATED" = "%1$@|sent you %2$@ messages"; +"MESSAGE_AUDIO" = "%1$@ sent you a voice message"; +"MESSAGE_AUDIO_SEPARATED" = "%1$@|sent you a voice message"; +"MESSAGE_CONTACT" = "%1$@ shared a contact with you"; +"MESSAGE_CONTACT_SEPARATED" = "%1$@|shared a contact with you"; +"MESSAGE_DOC" = "%1$@ sent you a file"; +"MESSAGE_DOC_SEPARATED" = "%1$@|sent you a file"; +"MESSAGE_FWDS" = "%1$@ forwarded you %2$@ messages"; +"MESSAGE_FWDS_SEPARATED" = "%1$@|forwarded you %2$@ messages"; +"MESSAGE_GAME" = "%1$@ invited you to play %2$@"; +"MESSAGE_GAME_SEPARATED" = "%1$@|invited you to play %2$@"; +"MESSAGE_GEO" = "%1$@ sent you a map"; +"MESSAGE_GEOLIVE" = "%1$@ started sharing their live location"; +"MESSAGE_GEOLIVE_SEPARATED" = "%1$@|started sharing their live location"; +"MESSAGE_GEO_SEPARATED" = "%1$@|sent you a map"; +"MESSAGE_GIF" = "%1$@ sent you a GIF"; +"MESSAGE_GIFTCODE" = "%1$@ sent you a Gift Code for %2$@ months of Telegram Premium"; +"MESSAGE_GIFT_THEME" = "%1$@ changed theme to %2$@"; +"MESSAGE_GIF_SEPARATED" = "%1$@|sent you a GIF"; +"MESSAGE_GIVEAWAY" = "%1$@ sent you a giveaway of %2$@x %3$@mo Premium subscriptions"; +"MESSAGE_INVOICE" = "%1$@ sent you an invoice for %2$@"; +"MESSAGE_INVOICE_SEPARATED" = "%1$@|sent you an invoice for %2$@"; +"MESSAGE_NOTEXT" = "%1$@ sent you a message"; +"MESSAGE_NOTEXT_SEPARATED" = "%1$@|sent you a message"; +"MESSAGE_NOTHEME" = "%1$@ changed the theme back to default"; +"MESSAGE_PHOTO" = "%1$@ sent you a photo"; +"MESSAGE_PHOTOS" = "%1$@ sent you %2$@ photos"; +"MESSAGE_PHOTOS_SEPARATED" = "%1$@|sent you %2$@ photos"; +"MESSAGE_PHOTO_SECRET" = "%1$@ sent you a self-destructing photo"; +"MESSAGE_PHOTO_SECRET_SEPARATED" = "%1$@|sent you a self-destructing photo"; +"MESSAGE_PHOTO_SEPARATED" = "%1$@|sent you a photo"; +"MESSAGE_POLL" = "%1$@ sent you a poll"; +"MESSAGE_POLL_SEPARATED" = "%1$@|sent you a poll"; +"MESSAGE_ROUND" = "%1$@ sent you a video message"; +"MESSAGE_ROUND_SEPARATED" = "%1$@|sent you a video message"; +"MESSAGE_SCREENSHOT" = "%1$@ took a screenshot!"; +"MESSAGE_SCREENSHOT_SEPARATED" = "%1$@|took a screenshot"; +"MESSAGE_STICKER" = "%1$@ sent you a %2$@sticker"; +"MESSAGE_STICKER_SEPARATED" = "%1$@|sent you a %2$@ sticker"; +"MESSAGE_SUGGEST_BIRTHDAY" = "suggested you add your birthday"; +"MESSAGE_TEXT" = "%1$@: %2$@"; +"MESSAGE_TEXT_SEPARATED" = "%1$@|%2$@"; +"MESSAGE_VIDEO" = "%1$@ sent you a video"; +"MESSAGE_VIDEO_SECRET" = "%1$@ sent you a self-destructing video"; +"MESSAGE_VIDEO_SECRET_SEPARATED" = "%1$@|sent you a self-destructing video"; +"MESSAGE_VIDEO_SEPARATED" = "%1$@|sent you a video"; +"Map.AccurateTo" = "Accurate to %@"; +"Map.AddressOnMap" = "Address On Map"; +"Map.ChooseAPlace" = "Or choose a place"; +"Map.ChooseLocationTitle" = "Location"; +"Map.Directions" = "Directions"; +"Map.DirectionsDriveEta" = "%@ drive"; +"Map.DistanceAway" = "%@ away"; +"Map.ETADays#one" = "%@ day"; +"Map.ETADays#other" = "%@ days"; +"Map.ETAHours#one" = "%@ h"; +"Map.ETAHours#other" = "%@ h"; +"Map.ETAMinutes#one" = "%@ min"; +"Map.ETAMinutes#other" = "%@ min"; +"Map.GetDirections" = "Get Directions"; +"Map.Home" = "Home"; +"Map.HomeAndWorkInfo" = "Telegram uses the Home and Work addresses from your Contact Card.\n\nKeep your Contact Card up to date for quick access to sending Home and Work addresses."; +"Map.HomeAndWorkTitle" = "Home & Work Addresses"; +"Map.Hybrid" = "Hybrid"; +"Map.LiveLocationExtendDescription" = "For how long do you want to extend sharing your location?"; +"Map.LiveLocationFor15Minutes" = "for 15 minutes"; +"Map.LiveLocationFor1Hour" = "for 1 hour"; +"Map.LiveLocationFor8Hours" = "for 8 hours"; +"Map.LiveLocationForHours#one" = "For %@ hour"; +"Map.LiveLocationForHours#other" = "For %@ hours"; +"Map.LiveLocationForMinutes#one" = "For %@ minute"; +"Map.LiveLocationForMinutes#other" = "For %@ minutes"; +"Map.LiveLocationGroupDescription" = "Choose for how long people in this chat will see your accurate location."; +"Map.LiveLocationGroupNewDescription" = "Choose for how long people in this chat will see your accurate location, including when the app is closed."; +"Map.LiveLocationIndefinite" = "Until I turn it off"; +"Map.LiveLocationPrivateDescription" = "Choose for how long %@ will see your accurate location."; +"Map.LiveLocationPrivateNewDescription" = "Choose for how long **%@** will see your accurate location, including when the app is closed."; +"Map.LiveLocationShortHour" = "%@h"; +"Map.LiveLocationShowAll" = "Show All"; +"Map.LiveLocationTitle" = "Live Location"; +"Map.LoadError" = "An error occurred, please try again."; +"Map.Locating" = "Locating..."; +"Map.LocatingError" = "Failed to locate"; +"Map.Location" = "Location"; +"Map.LocationTitle" = "Location"; +"Map.Map" = "Map"; +"Map.NoPlacesNearby" = "There are no known places nearby.\nTry a different location."; +"Map.OpenIn" = "Open In"; +"Map.OpenInGoogleMaps" = "Open in Google Maps"; +"Map.OpenInHereMaps" = "Open in HERE Maps"; +"Map.OpenInMaps" = "Open in Maps"; +"Map.OpenInWaze" = "Open in Waze"; +"Map.OpenInYandexMaps" = "Open in Yandex Maps"; +"Map.OpenInYandexNavigator" = "Open in Yandex Navigator"; +"Map.PlacesInThisArea" = "Places In This Area"; +"Map.PlacesNearby" = "Places Nearby"; +"Map.PullUpForPlaces" = "PULL UP TO SEE PLACES NEARBY"; +"Map.Satellite" = "Satellite"; +"Map.Search" = "Search places nearby"; +"Map.SearchNoResultsDescription" = "There were no results for \"%@\".\nTry a new search."; +"Map.SendMyCurrentLocation" = "Send My Current Location"; +"Map.SendThisLocation" = "Send This Location"; +"Map.SendThisPlace" = "Send This Place"; +"Map.SetThisLocation" = "Set This Location"; +"Map.SetThisPlace" = "Set This Place"; +"Map.ShareLiveLocation" = "Share My Live Location for..."; +"Map.ShareLiveLocationHelp" = "Updated in real time as you move"; +"Map.SharingLocation" = "Sharing Location..."; +"Map.ShowPlaces" = "Show places"; +"Map.StopLiveLocation" = "Stop Sharing Location"; +"Map.TapToAddTime" = "tap to add time"; +"Map.Unknown" = "Unknown location"; +"Map.Work" = "Work"; +"Map.YouAreHere" = "you are here"; +"MaskPackActionInfo.ArchivedTitle" = "Masks Archived"; +"MaskPackActionInfo.RemovedText" = "%@ is no longer in your masks."; +"MaskPackActionInfo.RemovedTitle" = "Masks Removed"; +"MaskStickerSettings.Info" = "You can add masks to photos and videos in the media editor before sending."; +"MaskStickerSettings.Title" = "Masks"; +"Media.ChooseFromGallery" = "Choose From Gallery"; +"Media.EditCover" = "Edit Cover"; +"Media.LimitedAccessChangeSettings" = "Change Settings"; +"Media.LimitedAccessManage" = "Manage"; +"Media.LimitedAccessSelectMore" = "Select More Photos..."; +"Media.LimitedAccessText" = "You have limited Telegram from accessing all of your photos."; +"Media.LimitedAccessTitle" = "Limited Access to Media"; +"Media.PhotoHdOff" = "The photo will be sent in standard quality."; +"Media.PhotoHdOn" = "The photo will be sent in high quality."; +"Media.SaveCover" = "Save Cover"; +"Media.SelectFrame" = "Select Frame"; +"Media.SendWithTimer" = "Send With Timer"; +"Media.SendingOptionsTooltip" = "Hold this button to send your message with a self-destruct timer."; +"Media.ShareItem#one" = "1 Item"; +"Media.ShareItem#other" = "All %@ Items"; +"Media.SharePhoto#one" = "1 Photo"; +"Media.SharePhoto#other" = "All %@ Photos"; +"Media.ShareThisPhoto" = "This Photo"; +"Media.ShareThisVideo" = "This Video"; +"Media.ShareVideo#one" = "1 Video"; +"Media.ShareVideo#other" = "All %@ Videos"; +"MediaEditor.AddAudio" = "Audio"; +"MediaEditor.AddGif" = "Add GIF"; +"MediaEditor.AddLink" = "LINK"; +"MediaEditor.AddLocation" = "Add Location"; +"MediaEditor.AddLocationShort" = "Location"; +"MediaEditor.AddToStickerPack" = "Add to Sticker Set"; +"MediaEditor.Audio" = "Audio"; +"MediaEditor.Audio.SavedMusic" = "SAVED MUSIC"; +"MediaEditor.Audio.ShowMore" = "Show More"; +"MediaEditor.Audio.Title" = "Audio"; +"MediaEditor.CreateNewPack" = "New Sticker Set"; +"MediaEditor.Cutout" = "Cut Out an Object"; +"MediaEditor.CutoutInfo" = "Tap on an object to cut it out"; +"MediaEditor.Draft" = "Draft"; +"MediaEditor.Erase" = "Erase"; +"MediaEditor.EraseInfo" = "Erase parts of this sticker"; +"MediaEditor.HoldToRecordVideo" = "Hold to record video"; +"MediaEditor.Link.CreateTitle" = "Create Link"; +"MediaEditor.Link.EditTitle" = "Edit Link"; +"MediaEditor.Link.LinkName.Placeholder" = "Enter a Name"; +"MediaEditor.Link.LinkName.Title" = "LINK NAME (OPTIONAL)"; +"MediaEditor.Link.LinkTo.Placeholder" = "https://somesite.com"; +"MediaEditor.Link.LinkTo.Title" = "LINK TO"; +"MediaEditor.NewStickerPack.Text" = "Choose a name for your set."; +"MediaEditor.NewStickerPack.Title" = "New Sticker Set"; +"MediaEditor.Outline" = "Add Outline"; +"MediaEditor.RemoveAudio" = "Remove Audio"; +"MediaEditor.RemoveVideo" = "Remove Video"; +"MediaEditor.ReplaceSticker" = "Update Sticker"; +"MediaEditor.Restore" = "Restore"; +"MediaEditor.RestoreInfo" = "Restore parts of this sticker"; +"MediaEditor.SetAsIntroSticker" = "Set as Greeting Sticker"; +"MediaEditor.SetStickerEmoji" = "Choose emojis that match your sticker"; +"MediaEditor.Shortcut.Audio" = "Audio"; +"MediaEditor.Shortcut.Image" = "Image"; +"MediaEditor.Shortcut.Link" = "Link"; +"MediaEditor.Shortcut.Location" = "Location"; +"MediaEditor.Shortcut.Reaction" = "Reaction"; +"MediaEditor.StickersTooMuch" = "Sorry, you've reached the maximum number of stickers in this set. Try a different one."; +"MediaEditor.Timeline" = "Timeline"; +"MediaEditor.Undo" = "Undo"; +"MediaEditor.VideoRemovalConfirmation" = "Are you sure you want to delete your video message?"; +"MediaGallery.ToastVideoPip.Text" = "Swipe down on a video to close it."; +"MediaGallery.ToastVideoPip.Title" = "Video Minimized"; +"MediaPicker.AddCaption" = "Add a caption..."; +"MediaPicker.AddImage" = "Add Image"; +"MediaPicker.CameraRoll" = "Camera Roll"; +"MediaPicker.ChooseCover" = "Choose Cover"; +"MediaPicker.CombineIntoCollage" = "Combine into Collage"; +"MediaPicker.ConvertToJpeg" = "Convert to JPEG"; +"MediaPicker.CreateSticker" = "Create a sticker from a photo"; +"MediaPicker.CreateStory#one" = "Create %@ Story"; +"MediaPicker.CreateStory#other" = "Create %@ Stories"; +"MediaPicker.GroupDescription" = "Group media into one message"; +"MediaPicker.InvertCaption.Updated.Down.Text" = "Text will be shown below the media."; +"MediaPicker.InvertCaption.Updated.Down.Title" = "Caption moved down"; +"MediaPicker.InvertCaption.Updated.Up.Text" = "Text will be shown above the media."; +"MediaPicker.InvertCaption.Updated.Up.Title" = "Caption moved up"; +"MediaPicker.InvertCaptionTooltip" = "Tap here to move caption up."; +"MediaPicker.JpegConversionText" = "Do you want to convert photos to JPEG?"; +"MediaPicker.KeepHeic" = "Keep HEIC"; +"MediaPicker.LivePhoto.Bounce" = "Bounce"; +"MediaPicker.LivePhoto.Live" = "Live"; +"MediaPicker.LivePhoto.LiveOff" = "Live Off"; +"MediaPicker.LivePhoto.Loop" = "Loop"; +"MediaPicker.LivePhoto.Tooltip.Bounce" = "Live Photo with Bounce effect"; +"MediaPicker.LivePhoto.Tooltip.Live" = "Live Photo enabled"; +"MediaPicker.LivePhoto.Tooltip.LiveOff" = "Live Photo turned off"; +"MediaPicker.LivePhoto.Tooltip.Loop" = "Live Photo with Loop effect"; +"MediaPicker.LivePhotoDescription" = "The live photo will autoplay and loop like a GIF."; +"MediaPicker.PollSubtitle.Description" = "Add media to the poll description"; +"MediaPicker.PollSubtitle.Explanation" = "Add media to the quiz explanation"; +"MediaPicker.PollSubtitle.PollOption" = "Add media to this option"; +"MediaPicker.Recents" = "Recents"; +"MediaPicker.RemovePhoto" = "Remove Photo"; +"MediaPicker.Send" = "Send"; +"MediaPicker.SetNewChannelPhoto" = "Set new channel photo"; +"MediaPicker.SetNewGroupPhoto" = "Set new group photo"; +"MediaPicker.SetNewPhoto" = "Set new profile photo"; +"MediaPicker.TapToUngroupDescription" = "Tap to send media separately"; +"MediaPicker.Timer.Description" = "Choose how long the media will be kept after opening."; +"MediaPicker.Timer.DoNotDelete" = "Do Not Delete"; +"MediaPicker.Timer.Photo.KeepTooltip" = "Photo will be kept in chat."; +"MediaPicker.Timer.Photo.TimerTooltip" = "Photo will be deleted\n%@ seconds after opening."; +"MediaPicker.Timer.Photo.ViewOnceTooltip" = "Photo set to view once."; +"MediaPicker.Timer.Seconds#one" = "%d Second"; +"MediaPicker.Timer.Seconds#other" = "%d Seconds"; +"MediaPicker.Timer.Video.KeepTooltip" = "Video will be kept in chat."; +"MediaPicker.Timer.Video.TimerTooltip" = "Video will be deleted in\n%@ seconds after opening."; +"MediaPicker.Timer.Video.ViewOnceTooltip" = "Video set to view once."; +"MediaPicker.Timer.ViewOnce" = "View Once"; +"MediaPicker.TimerTooltip" = "You can now set a self-destruct timer"; +"MediaPicker.UngroupDescription" = "Show media as separate messages"; +"MediaPicker.UseAnEmoji" = "Use an Emoji"; +"MediaPicker.VideoMuteDescription" = "Sound is now muted, so the video will autoplay and loop like a GIF."; +"MediaPicker.Videos" = "Videos"; +"MediaPicker.VoiceOver.Camera" = "Camera"; +"MediaPlayer.AudioForwardedToSavedMesagesTooltip" = "Audio forwarded to Saved Messages."; +"MediaPlayer.ContextMenu.Delete" = "Delete"; +"MediaPlayer.ContextMenu.Forward" = "Forward"; +"MediaPlayer.ContextMenu.Remove" = "Remove"; +"MediaPlayer.ContextMenu.SaveTo" = "Save to..."; +"MediaPlayer.ContextMenu.SaveTo.Files" = "Files"; +"MediaPlayer.ContextMenu.SaveTo.Info" = "Choose where you want this audio to be saved."; +"MediaPlayer.ContextMenu.SaveTo.Profile" = "Profile"; +"MediaPlayer.ContextMenu.SaveTo.SavedMessages" = "Saved Messages"; +"MediaPlayer.ContextMenu.SaveToFiles" = "Save to Files"; +"MediaPlayer.ContextMenu.ShowInChat" = "Show in Chat"; +"MediaPlayer.Playlist.SavedMusic" = "%@'S PLAYLIST"; +"MediaPlayer.Playlist.SavedMusicYou" = "YOUR PLAYLIST"; +"MediaPlayer.Playlist.ThisChat" = "AUDIO IN THIS CHAT"; +"MediaPlayer.PlaylistTitle" = "%@'s Playlist"; +"MediaPlayer.PlaylistYourTitle" = "Your Playlist"; +"MediaPlayer.SavedMusic.AddToProfile" = "Add to Profile"; +"MediaPlayer.SavedMusic.AddedToProfile" = "Audio added to your profile."; +"MediaPlayer.SavedMusic.AddedToProfile.View" = "View"; +"MediaPlayer.SavedMusic.RemoveFromProfile" = "This audio is visible on your profile. [Remove >]()"; +"MediaPlayer.SavedMusic.RemoveFromProfileShort" = "Remove from Profile"; +"MediaPlayer.SavedMusic.RemovedFromProfile" = "Audio removed from your profile."; +"MediaPlayer.UnknownArtist" = "Unknown Artist"; +"MediaPlayer.UnknownTrack" = "Unknown Track"; +"MemberRequests.AddToChannel" = "Add to Channel"; +"MemberRequests.AddToGroup" = "Add to Group"; +"MemberRequests.DescriptionChannel" = "Some [additional links]() are set up to generate requests to join the channel."; +"MemberRequests.DescriptionGroup" = "Some additional links are set up to generate requests to join the group."; +"MemberRequests.Dismiss" = "Dismiss"; +"MemberRequests.NoRequests" = "No Join Requests"; +"MemberRequests.NoRequestsDescriptionChannel" = "There are no pending join requests."; +"MemberRequests.NoRequestsDescriptionGroup" = "There are no pending join requests."; +"MemberRequests.PeopleRequested#one" = "%@ join request"; +"MemberRequests.PeopleRequested#other" = "%@ join requests"; +"MemberRequests.PeopleRequestedShort#one" = "%@ request"; +"MemberRequests.PeopleRequestedShort#other" = "%@ requests"; +"MemberRequests.RequestToJoinChannel" = "Request to Join Channel"; +"MemberRequests.RequestToJoinDescriptionChannel" = "This channel accepts new subscribers only after they are approved by its admins."; +"MemberRequests.RequestToJoinDescriptionGroup" = "This group accepts new members only after they are approved by its admins."; +"MemberRequests.RequestToJoinGroup" = "Request to Join Group"; +"MemberRequests.RequestToJoinSent" = "Join Request Sent"; +"MemberRequests.RequestToJoinSentDescriptionChannel" = "You will be added to the channel once its admins approve your request."; +"MemberRequests.RequestToJoinSentDescriptionGroup" = "You will be added to the group once an admin approves your request."; +"MemberRequests.SearchPlaceholder" = "Search Join Requests"; +"MemberRequests.Title" = "Join Requests"; +"MemberRequests.UserAddedToChannel" = "%@ has been added to the channel."; +"MemberRequests.UserAddedToGroup" = "%@ has been added to the group."; +"MemberSearch.BotSection" = "BOTS"; +"Message.AdRecommendedLabel" = "Recommended"; +"Message.AdSponsoredLabel" = "Ad"; +"Message.AdWhatIsThis" = "what's this?"; +"Message.Animation" = "GIF"; +"Message.Approximate" = "appx. %@"; +"Message.Audio" = "Voice Message"; +"Message.AudioTranscription.ErrorEmpty" = "No words recognized."; +"Message.AudioTranscription.ErrorTooLong" = "This voice message is too long to transcribe"; +"Message.AudioTranscription.SubscribeToPremium" = "Subscribe to **Telegram Premium** to convert voice to text."; +"Message.AudioTranscription.SubscribeToPremiumAction" = "More"; +"Message.AuthorPinnedGame" = "%@ pinned a game"; +"Message.Contact" = "Contact"; +"Message.EditFullDateFormat" = "edited on %1$@, %2$@"; +"Message.EditTodayFullDateFormat" = "edited at %1$@"; +"Message.FactCheck" = "Fact Check"; +"Message.FactCheck.WhatIsThis" = "what's this?"; +"Message.FakeAccount" = "Fake"; +"Message.File" = "File"; +"Message.ForwardedExpiredStoryShort" = "Expired Story\nFrom: %@"; +"Message.ForwardedMessage" = "Forwarded Message\nFrom: %@"; +"Message.ForwardedMessageShort" = "Forwarded From\n%@"; +"Message.ForwardedPsa.covid" = "COVID-19 Notification\nFrom: %@"; +"Message.ForwardedPsa.psa" = "Announcement: %@"; +"Message.ForwardedStoryShort" = "Forwarded Story\nFrom: %@"; +"Message.ForwardedUnavailableStoryShort" = "Private Story\nFrom: %@"; +"Message.FullDateFormat" = "%1$@, %2$@"; +"Message.Game" = "Game"; +"Message.GenericForwardedPsa" = "Public Service Announcement\nFrom: %@"; +"Message.Giveaway" = "Giveaway"; +"Message.GiveawayEndedNoWinners" = "Due to the giveaway terms, no winners could be selected by Telegram"; +"Message.GiveawayEndedWinners#one" = "%@ winner of the giveaway was randomly selected by Telegram"; +"Message.GiveawayEndedWinners#other" = "%@ winners of the giveaway were randomly selected by Telegram"; +"Message.GiveawayFinished" = "Giveaway: %1$@ on %2$@"; +"Message.GiveawayFinished.Winners#one" = "%@ winner was selected"; +"Message.GiveawayFinished.Winners#other" = "%@ winners were selected"; +"Message.GiveawayOngoing" = "Giveaway: %1$@ on %2$@"; +"Message.GiveawayOngoing.Winners#one" = "%@ winner to be selected"; +"Message.GiveawayOngoing.Winners#other" = "%@ winners to be selected"; +"Message.GiveawayStarted" = "Channel started a giveaway"; +"Message.GiveawayStartedGroup" = "Group started a giveaway"; +"Message.GiveawayStartedOther" = "%@ started a giveaway"; +"Message.ImageExpired" = "Expired photo"; +"Message.ImportedDateFormat" = "%1$@, %2$@ Imported %3$@"; +"Message.InvoiceLabel" = "INVOICE"; +"Message.LiveLocation" = "Live Location"; +"Message.LivePhoto" = "Live Photo"; +"Message.Location" = "Location"; +"Message.PaymentSent" = "Payment: %@"; +"Message.Photo" = "Photo"; +"Message.PinnedAnimationMessage" = "pinned GIF"; +"Message.PinnedAudioMessage" = "pinned voice message"; +"Message.PinnedContactMessage" = "pinned contact"; +"Message.PinnedDocumentMessage" = "pinned file"; +"Message.PinnedGame" = "pinned a game"; +"Message.PinnedGenericMessage" = "%@ pinned a message"; +"Message.PinnedInvoice" = "pinned an invoice"; +"Message.PinnedLiveLocationMessage" = "pinned live location"; +"Message.PinnedLocationMessage" = "pinned location"; +"Message.PinnedPhotoMessage" = "pinned photo"; +"Message.PinnedPollMessage" = "pinned poll"; +"Message.PinnedStickerMessage" = "pinned sticker"; +"Message.PinnedTextMessage" = "pinned \"%@\""; +"Message.PinnedVideoMessage" = "pinned video"; +"Message.RecommendedLabel" = "recommended"; +"Message.RepeatAt" = "%1$@ at %2$@"; +"Message.RepeatPeriod.3Months" = "every 3 months"; +"Message.RepeatPeriod.6Months" = "every 6 months"; +"Message.RepeatPeriod.Biweekly" = "biweekly"; +"Message.RepeatPeriod.Daily" = "daily"; +"Message.RepeatPeriod.Monthly" = "monthly"; +"Message.RepeatPeriod.Weekly" = "weekly"; +"Message.RepeatPeriod.Yearly" = "yearly"; +"Message.ReplyActionButtonShowReceipt" = "Show Receipt"; +"Message.ScamAccount" = "Scam"; +"Message.SponsoredLabel" = "ad"; +"Message.Sticker" = "Sticker"; +"Message.StickerText" = "%@ Sticker"; +"Message.Story" = "Story"; +"Message.Theme" = "Color Theme"; +"Message.Video" = "Video"; +"Message.VideoExpired" = "Expired video"; +"Message.VideoMessage" = "Video Message"; +"Message.VideoMessageExpired" = "Expired video message"; +"Message.VoiceMessageExpired" = "Expired voice message"; +"Message.Wallpaper" = "Chat Wallpaper"; +"MessageCalendar.ClearHistoryForTheseDays" = "Clear History For These Days"; +"MessageCalendar.ClearHistoryForThisDay" = "Clear History For This Day"; +"MessageCalendar.DaysSelectedTitle#one" = "1 day selected"; +"MessageCalendar.DaysSelectedTitle#other" = "%@ days selected"; +"MessageCalendar.DeleteAlertText#one" = "Are you sure you want to delete all messages for the selected day?"; +"MessageCalendar.DeleteAlertText#other" = "Are you sure you want to delete all messages for the selected %@ days?"; +"MessageCalendar.DeleteConfirmation#one" = "Are you sure you want to delete all messages for the selected day?"; +"MessageCalendar.EmptySelectionTooltip" = "Please select one or more days first."; +"MessageCalendar.Title" = "Calendar"; +"MessagePoll.BackToVote" = "< Vote"; +"MessagePoll.EndsIn" = "ends in %@"; +"MessagePoll.EndsInDays#one" = "ends in %@ day"; +"MessagePoll.EndsInDays#other" = "ends in %@ days"; +"MessagePoll.Explanation" = "Explanation"; +"MessagePoll.LabelAnonymous" = "Anonymous Poll"; +"MessagePoll.LabelAnonymousQuiz" = "Anonymous Quiz"; +"MessagePoll.LabelClosed" = "Final Results"; +"MessagePoll.LabelPoll" = "Poll"; +"MessagePoll.LabelQuiz" = "Quiz"; +"MessagePoll.NoVotes" = "No votes"; +"MessagePoll.QuizCount#one" = "1 answered"; +"MessagePoll.QuizCount#other" = "%@ answered"; +"MessagePoll.QuizNoUsers" = "Nobody answered yet"; +"MessagePoll.ResultsIn" = "results in %@"; +"MessagePoll.ResultsInDays#one" = "results in %@ day"; +"MessagePoll.ResultsInDays#other" = "results in %@ days"; +"MessagePoll.SubmitVote" = "Vote"; +"MessagePoll.ViewResults" = "View Results"; +"MessagePoll.ViewVote#one" = "%@ vote >"; +"MessagePoll.ViewVote#other" = "%@ votes >"; +"MessagePoll.ViewVotes" = "View Votes (%@)"; +"MessagePoll.VotedCount#one" = "1 vote"; +"MessagePoll.VotedCount#other" = "%@ votes"; +"MessageTimer.Custom" = "Custom"; +"MessageTimer.Days#one" = "%@ day"; +"MessageTimer.Days#other" = "%@ days"; +"MessageTimer.Forever" = "Forever"; +"MessageTimer.Hours#one" = "%@ hour"; +"MessageTimer.Hours#other" = "%@ hours"; +"MessageTimer.LargeShortDays#one" = "%@d"; +"MessageTimer.LargeShortDays#other" = "%@d"; +"MessageTimer.LargeShortHours#one" = "%@h"; +"MessageTimer.LargeShortHours#other" = "%@h"; +"MessageTimer.LargeShortMinutes#one" = "%@m"; +"MessageTimer.LargeShortMinutes#other" = "%@m"; +"MessageTimer.LargeShortMonths#one" = "%@M"; +"MessageTimer.LargeShortMonths#other" = "%@M"; +"MessageTimer.LargeShortSeconds#one" = "%@s"; +"MessageTimer.LargeShortSeconds#other" = "%@s"; +"MessageTimer.LargeShortWeeks#one" = "%@w"; +"MessageTimer.LargeShortWeeks#other" = "%@w"; +"MessageTimer.LargeShortYears#one" = "%@y"; +"MessageTimer.LargeShortYears#other" = "%@y"; +"MessageTimer.Minutes#one" = "%@ minute"; +"MessageTimer.Minutes#other" = "%@ minutes"; +"MessageTimer.Months#one" = "%@ month"; +"MessageTimer.Months#other" = "%@ months"; +"MessageTimer.Seconds#one" = "%@ second"; +"MessageTimer.Seconds#other" = "%@ seconds"; +"MessageTimer.ShortDays#one" = "%@d"; +"MessageTimer.ShortDays#other" = "%@d"; +"MessageTimer.ShortHours#one" = "%@h"; +"MessageTimer.ShortHours#other" = "%@h"; +"MessageTimer.ShortMinutes#one" = "%@m"; +"MessageTimer.ShortMinutes#other" = "%@m"; +"MessageTimer.ShortMonths#one" = "%@mo"; +"MessageTimer.ShortMonths#other" = "%@mo"; +"MessageTimer.ShortSeconds#one" = "%@s"; +"MessageTimer.ShortSeconds#other" = "%@s"; +"MessageTimer.ShortWeeks#one" = "%@w"; +"MessageTimer.ShortWeeks#other" = "%@w"; +"MessageTimer.ShortYears#one" = "%@y"; +"MessageTimer.ShortYears#other" = "%@y"; +"MessageTimer.Weeks#one" = "%@ week"; +"MessageTimer.Weeks#other" = "%@ weeks"; +"MessageTimer.Years#one" = "%@ year"; +"MessageTimer.Years#other" = "%@ years"; +"MiniAppList.ListSectionHeader" = "APPS THAT ACCEPT STARS"; +"MiniAppList.Title" = "Examples"; +"Monetization.AdRevenueTitle" = "AD REWARDS"; +"Monetization.Balance.AvailableInfo" = "You can collect your reward using Fragment, a third-party platform used by advertisers to pay for ads. [Terms & Conditions >]()"; +"Monetization.Balance.ComingLaterInfo" = "In the coming weeks, you will be able to collect rewards using Fragment, a third-party platform used by advertisers to pay for ads. [Learn More >]()"; +"Monetization.Balance.StarsInfo" = "You can collect rewards for Stars using Fragment, or use Stars to advertise your channel. You cannot withdraw less than 1000 stars. [Learn More >]()"; +"Monetization.Balance.StarsInfoGroup" = "You can collect rewards for Stars using Fragment. You cannot withdraw less than 1000 stars. [Learn More >]()"; +"Monetization.Balance.StarsInfo_URL" = "https://telegram.org/tos/stars"; +"Monetization.Balance.ZeroInfo" = "You will be able to collect rewards using Fragment, a third-party platform used by advertisers to pay for ads. [Terms & Conditions >]()"; +"Monetization.BalanceInfo_URL" = "https://telegram.org/tos/content-creator-rewards"; +"Monetization.BalanceStarsBuyAds" = "Buy Ads"; +"Monetization.BalanceStarsWithdraw" = "Withdraw via Fragment"; +"Monetization.BalanceStarsWithdrawShort" = "Withdraw"; +"Monetization.BalanceTitle" = "AVAILABLE REWARD"; +"Monetization.BalanceWithdraw" = "Collect via Fragment"; +"Monetization.Bot.BalanceTitle" = "AVAILABLE BALANCE"; +"Monetization.Bot.Header" = "Telegram shares 50% of the revenue from ads displayed in your bot as rewards. [Learn More >]()"; +"Monetization.EarnStarsInfo.Text" = "Distribute links to mini apps and earn a share of their revenue in Stars."; +"Monetization.EarnStarsInfo.Title" = "Earn Stars"; +"Monetization.Header" = "Telegram shares 50% of the revenue from ads displayed in your channel as rewards. [Learn More >]()"; +"Monetization.ImpressionsTitle" = "AD IMPRESSIONS (BY HOURS)"; +"Monetization.Intro.Ads.Text" = "Telegram can display ads in your channel."; +"Monetization.Intro.Ads.Title" = "Telegram Ads"; +"Monetization.Intro.Bot.Ads.Text" = "Telegram can display ads in your bot."; +"Monetization.Intro.Bot.Title" = "Earn From Your Bot"; +"Monetization.Intro.Info.Text" = "Gram is the cryptocurrency of the TON blockchain platform, which Telegram uses for its high speed and low commissions on transactions. [Learn More >]()"; +"Monetization.Intro.Info.Text_URL" = "https://telegram.org/blog/monetization-for-channels"; +"Monetization.Intro.Info.Title" = "What's #Gram?"; +"Monetization.Intro.Split.Text" = "You receive 50% of the ad revenue as rewards in Grams."; +"Monetization.Intro.Split.Title" = "50:50 Revenue Split"; +"Monetization.Intro.Title" = "Earn From Your Channel"; +"Monetization.Intro.Understood" = "Understood"; +"Monetization.Intro.Withdrawal.Text" = "You can collect your Grams any time."; +"Monetization.Intro.Withdrawal.Title" = "Flexible Withdrawals"; +"Monetization.Overview.Available" = "Rewards Available for Collection"; +"Monetization.Overview.Current" = "Rewards Since Last Collection"; +"Monetization.Overview.Total" = "Total Lifetime Rewards"; +"Monetization.OverviewTitle" = "REWARDS OVERVIEW"; +"Monetization.Proceeds.Stars.Info" = "Stars from your total balance can be used for ads or withdrawn as rewards 21 days after they are earned."; +"Monetization.Proceeds.Ton.Info" = "Grams from your total balance can be used for ads or withdrawn as rewards 3 days after they are earned. You cannot withdraw less than 10 Grams."; +"Monetization.Proceeds.TonAndStars.Info" = "Stars and Grams from your total balance can be used for ads or withdrawn as rewards 21 and 3 days respectively after they are earned."; +"Monetization.StarsBalanceTitle" = "STARS BALANCE"; +"Monetization.StarsProceeds.Available" = "Available Balance"; +"Monetization.StarsProceeds.Current" = "Total Balance"; +"Monetization.StarsProceeds.Info" = ""; +"Monetization.StarsProceeds.Title" = "REWARDS OVERVIEW"; +"Monetization.StarsProceeds.Total" = "Lifetime Proceeds"; +"Monetization.StarsRevenueTitle" = "REVENUE"; +"Monetization.StarsTransactions" = "Stars Transactions"; +"Monetization.SwitchOffAds" = "Switch off Ads"; +"Monetization.SwitchOffAdsInfo" = "You will not be eligible for any rewards if you switch off ads."; +"Monetization.TonBalanceTitle" = "GRAM BALANCE"; +"Monetization.TonTransactions" = "Gram Transactions"; +"Monetization.Transaction.Failed" = "Not Completed"; +"Monetization.Transaction.Pending" = "Pending"; +"Monetization.Transaction.Proceeds" = "Rewards from Ads"; +"Monetization.Transaction.Refund" = "Refund"; +"Monetization.Transaction.ShowMoreTransactions#one" = "Show %@ More Transaction"; +"Monetization.Transaction.ShowMoreTransactions#other" = "Show %@ More Transactions"; +"Monetization.Transaction.Withdrawal" = "Collection via %@"; +"Monetization.TransactionInfo.Failed" = "Not Completed"; +"Monetization.TransactionInfo.Pending" = "Pending"; +"Monetization.TransactionInfo.Proceeds" = "Rewards from Ads displayed in"; +"Monetization.TransactionInfo.Refund" = "Refund"; +"Monetization.TransactionInfo.ViewInExplorer" = "View in Blockchain Explorer"; +"Monetization.TransactionInfo.Withdrawal" = "Collecting Rewards via %@"; +"Monetization.TransactionsTitle" = "TRANSACTION HISTORY"; +"Monetization.Withdraw.ComeBackLater" = "\n\nPlease come back later."; +"Monetization.Withdraw.EnterPassword.Done" = "Proceed"; +"Monetization.Withdraw.EnterPassword.Text" = "Please enter your Two-Step Verification password to complete this action."; +"Monetization.Withdraw.EnterPassword.Title" = "Enter Password"; +"Monetization.Withdraw.SecurityCheck" = "Security Check"; +"Monetization.Withdraw.SecurityRequirements" = "You can only collect rewards if:\n\n• 2-Step Verification was enabled for your account more than **7 days** ago.\n\n• You have logged in on this device more than **24 hours** ago."; +"Monetization.Withdraw.SetupTwoStepAuth" = "Enable 2-Step Verification"; +"Monoforum.DeleteTopic.Title" = "Delete %@"; +"Monoforum.NameFormat" = "%@ Messages"; +"Month.GenApril" = "April"; +"Month.GenAugust" = "August"; +"Month.GenDecember" = "December"; +"Month.GenFebruary" = "February"; +"Month.GenJanuary" = "January"; +"Month.GenJuly" = "July"; +"Month.GenJune" = "June"; +"Month.GenMarch" = "March"; +"Month.GenMay" = "May"; +"Month.GenNovember" = "November"; +"Month.GenOctober" = "October"; +"Month.GenSeptember" = "September"; +"Month.NomApril" = "April"; +"Month.NomAugust" = "August"; +"Month.NomDecember" = "December"; +"Month.NomFebruary" = "February"; +"Month.NomJanuary" = "January"; +"Month.NomJuly" = "July"; +"Month.NomJune" = "June"; +"Month.NomMarch" = "March"; +"Month.NomMay" = "May"; +"Month.NomNovember" = "November"; +"Month.NomOctober" = "October"; +"Month.NomSeptember" = "September"; +"Month.ShortApril" = "Apr"; +"Month.ShortAugust" = "Aug"; +"Month.ShortDecember" = "Dec"; +"Month.ShortFebruary" = "Feb"; +"Month.ShortJanuary" = "Jan"; +"Month.ShortJuly" = "Jul"; +"Month.ShortJune" = "Jun"; +"Month.ShortMarch" = "Mar"; +"Month.ShortMay" = "May"; +"Month.ShortNovember" = "Nov"; +"Month.ShortOctober" = "Oct"; +"Month.ShortSeptember" = "Sep"; +"MusicPlayer.VoiceNote" = "Voice Message"; +"MuteExpires.Days#one" = "in 1 day"; +"MuteExpires.Days#other" = "in %@ days"; +"MuteExpires.Hours#one" = "in 1 hour"; +"MuteExpires.Hours#other" = "in %@ hours"; +"MuteExpires.Minutes#one" = "in 1 minute"; +"MuteExpires.Minutes#other" = "in %@ minutes"; +"MuteFor.Days#one" = "Mute for 1 day"; +"MuteFor.Days#other" = "Mute for %@ days"; +"MuteFor.Forever" = "Mute Forever"; +"MuteFor.Hours#one" = "Mute for 1 hour"; +"MuteFor.Hours#other" = "Mute for %@ hours"; +"MutedForTime.Days#one" = "1 day"; +"MutedForTime.Days#other" = "%@ days"; +"MutedForTime.Hours#one" = "1 hour"; +"MutedForTime.Hours#other" = "%@ hours"; +"MutedForTime.Minutes#one" = "1 minute"; +"MutedForTime.Minutes#other" = "%@ minutes"; +"MyProfile.BioActionCopy" = "Copy Bio"; +"MyProfile.BioActionEdit" = "Edit Bio"; +"MyProfile.BirthdayActionCopy" = "Copy"; +"MyProfile.BirthdayActionEdit" = "Edit Birthday"; +"MyProfile.HoursActionCopy" = "Copy Hours"; +"MyProfile.HoursActionEdit" = "Edit Hours"; +"MyProfile.HoursActionRemove" = "Remove"; +"MyProfile.HoursRemoveConfirmation.Action" = "Remove"; +"MyProfile.HoursRemoveConfirmation.Title" = "Are you sure you want to remove business hours?"; +"MyProfile.LocationActionCopy" = "Copy Address"; +"MyProfile.LocationActionEdit" = "Edit Location"; +"MyProfile.LocationActionOpen" = "Open in Maps"; +"MyProfile.LocationActionRemove" = "Remove"; +"MyProfile.LocationRemoveConfirmation.Action" = "Remove"; +"MyProfile.LocationRemoveConfirmation.Title" = "Are you sure you want to remove location?"; +"MyProfile.PhoneActionCopy" = "Copy Number"; +"MyProfile.PhoneActionEdit" = "Change Number"; +"MyProfile.ToastBioCopied" = "Bio copied to clipboard."; +"MyProfile.ToastBirthdayCopied" = "Birthday copied to clipboard."; +"MyProfile.ToastHoursCopied" = "Hours copied to clipboard."; +"MyProfile.ToastLocationCopied" = "Location copied to clipboard."; +"MyProfile.ToastPhoneCopied" = "Birthday copied to clipboard."; +"MyProfile.UsernameActionCopy" = "Copy Username"; +"MyProfile.UsernameActionEdit" = "Edit Username"; +"NEW_CHANNEL_MESSAGES_SEPARATED#one" = "%1$@|posted a message"; +"NEW_CHANNEL_MESSAGES_SEPARATED#other" = "%1$@|posted %2$d messages"; +"NEW_CHANNEL_MESSAGE_FWDS_SEPARATED#one" = "%1$@|posted a forwarded message"; +"NEW_CHANNEL_MESSAGE_FWDS_SEPARATED#other" = "%1$@|posted %2$d forwarded messages"; +"NEW_CHANNEL_MESSAGE_PHOTOS_SEPARATED#one" = "%1$@|posted a photo"; +"NEW_CHANNEL_MESSAGE_PHOTOS_SEPARATED#other" = "%1$@|posted %2$d photos"; +"NEW_CHAT_MESSAGES_SEPARATED#one" = "%2$@|%1$@ sent a message"; +"NEW_CHAT_MESSAGES_SEPARATED#other" = "%2$@|%1$@ sent %3$d messages"; +"NEW_CHAT_MESSAGE_FWDS_SEPARATED#one" = "%2$@|%1$@ forwarded a message"; +"NEW_CHAT_MESSAGE_FWDS_SEPARATED#other" = "%2$@|%1$@ forwarded %3$d messages"; +"NEW_CHAT_MESSAGE_PHOTOS_SEPARATED#one" = "%2$@|%1$@ sent a photo"; +"NEW_CHAT_MESSAGE_PHOTOS_SEPARATED#other" = "%2$@|%1$@ sent %3$d photos"; +"NEW_MESSAGES_SEPARATED#one" = "%1$@|sent you a message"; +"NEW_MESSAGES_SEPARATED#other" = "%1$@|sent you %2$d messages"; +"NEW_MESSAGE_FWDS_SEPARATED#one" = "%1$@|forwarded you a message"; +"NEW_MESSAGE_FWDS_SEPARATED#other" = "%1$@|forwarded you %2$d messages"; +"NEW_MESSAGE_PHOTOS_SEPARATED#one" = "%1$@|sent you a photo"; +"NEW_MESSAGE_PHOTOS_SEPARATED#other" = "%1$@|sent you %2$d photos"; +"NameColor.AddProfileIcons" = "Add Icons To Profile"; +"NameColor.AddRepliesIcons" = "Add Icons To Replies"; +"NameColor.ApplyColor" = "Apply Color"; +"NameColor.ApplyColorAndBackgroundEmoji" = "Apply Color and Icon"; +"NameColor.BackgroundEmoji.Remove" = "REMOVE ICON"; +"NameColor.BackgroundEmoji.Title" = "ADD ICONS TO REPLIES"; +"NameColor.ChannelColorUpdated" = "Channel name color has been updated."; +"NameColor.ChatPreview.Description.Account" = "This color will be used for your name, the links you send, and replies to your messages."; +"NameColor.ChatPreview.Description.Channel" = "This color will be used for your channel's name, the links it sends, and replies to its posts."; +"NameColor.ChatPreview.LinkSite" = "Telegram"; +"NameColor.ChatPreview.LinkText" = "Previews of links you send will also use this color."; +"NameColor.ChatPreview.LinkTitle" = "Link Preview"; +"NameColor.ChatPreview.MessageText.Account" = "Your name and replies to your messages will be shown in the selected color."; +"NameColor.ChatPreview.MessageText.Channel" = "The name of your channel and replies to its messages will be shown in the selected color."; +"NameColor.ChatPreview.ReplyText.Account" = "Reply to your message"; +"NameColor.ChatPreview.ReplyText.Channel" = "Reply to your channel message"; +"NameColor.ChatPreview.Title" = "COLOR PREVIEW"; +"NameColor.GiftInfo" = "Apply your collectible's unique look to your profile."; +"NameColor.GiftTitle" = "USE A GIFT"; +"NameColor.Title.Account" = "Your Name Color"; +"NameColor.Title.Channel" = "Your Channel Color"; +"NameColor.TooltipPremium.Account" = "Subscribe to [Telegram Premium]() to choose a custom color for your name."; +"NameColor.UnsavedChanges.Apply" = "Apply"; +"NameColor.UnsavedChanges.Discard" = "Discard"; +"NameColor.UnsavedChanges.Text" = "You have changed your color or icon settings. Apply changes?"; +"NameColor.UnsavedChanges.Title" = "Unsaved Changes"; +"NameColor.WearCollectible" = "Wear Collectible"; +"NameColor.YourColorUpdated" = "Your name color has been updated."; +"Navigation.AllChats" = "All Chats"; +"NetworkUsageSettings.BytesReceived" = "Data Received"; +"NetworkUsageSettings.BytesSent" = "Data Sent"; +"NetworkUsageSettings.CallDataSection" = "CALLS"; +"NetworkUsageSettings.Cellular" = "Cellular"; +"NetworkUsageSettings.CellularUsageSince" = "Cellular usage since %@"; +"NetworkUsageSettings.GeneralDataSection" = "MESSAGES"; +"NetworkUsageSettings.MediaAudioDataSection" = "AUDIO"; +"NetworkUsageSettings.MediaDocumentDataSection" = "FILES"; +"NetworkUsageSettings.MediaImageDataSection" = "PHOTOS"; +"NetworkUsageSettings.MediaVideoDataSection" = "VIDEOS"; +"NetworkUsageSettings.ResetStats" = "Reset Statistics"; +"NetworkUsageSettings.ResetStatsConfirmation" = "Do you want to reset your usage statistics?"; +"NetworkUsageSettings.Title" = "Data Usage"; +"NetworkUsageSettings.TotalSection" = "TOTAL BYTES"; +"NetworkUsageSettings.Wifi" = "Wi-Fi"; +"NetworkUsageSettings.WifiUsageSince" = "Wi-Fi usage since %@"; +"NewCall.ActionCallMultiple" = "Call"; +"NewCall.ActionCallSingle" = "Call %@"; +"NewCall.SearchPlaceholder" = "Search for contacts or usernames"; +"NewCall.VideoOption" = "Join with camera on"; +"NewContact.Title" = "New Contact"; +"Notification.Boost.Multiple" = "%1$@ boosted the group %2$@"; +"Notification.Boost.MultipleYou" = "You boosted the group %1$@"; +"Notification.Boost.Single" = "%1$@ boosted the group"; +"Notification.Boost.SingleYou" = "You boosted the group"; +"Notification.Boost.Times#one" = "%@ time"; +"Notification.Boost.Times#other" = "%@ times"; +"Notification.BotWriteAllowed" = "You allowed this bot to message you when you added it to your attachment menu."; +"Notification.BotWriteAllowedMenu" = "You allowed this bot to message you when you added it to your attachment menu."; +"Notification.BotWriteAllowedRequest" = "You allowed this bot to message you in its web-app."; +"Notification.CallBack" = "Call Back"; +"Notification.CallCanceled" = "Canceled Call"; +"Notification.CallCanceledShort" = "Canceled"; +"Notification.CallFormat" = "%1$@, %2$@"; +"Notification.CallIncoming" = "Incoming Call"; +"Notification.CallIncomingShort" = "Incoming"; +"Notification.CallMissed" = "Missed Call"; +"Notification.CallMissedShort" = "Missed"; +"Notification.CallOutgoing" = "Outgoing Call"; +"Notification.CallOutgoingShort" = "Outgoing"; +"Notification.CallTimeFormat" = "%1$@ (%2$@)"; +"Notification.ChangedGroupName" = "%@ changed the group name to \"%@\""; +"Notification.ChangedGroupPhoto" = "%@ changed group photo"; +"Notification.ChangedGroupVideo" = "%@ changed the group photo"; +"Notification.ChangedTheme" = "%1$@ changed the chat theme to %2$@"; +"Notification.ChangedThemeGift" = "%1$@ changed chat theme to %2$@"; +"Notification.ChangedToSameWallpaper" = "%1$@ set the same wallpaper for this chat"; +"Notification.ChangedWallpaper" = "%1$@ set a new wallpaper for this chat"; +"Notification.ChannelChangedTheme" = "Channel theme changed to %1$@"; +"Notification.ChannelChangedWallpaper" = "Channel set a new wallpaper"; +"Notification.ChannelDisabledTheme" = "Channel theme disabled"; +"Notification.ChannelInviter" = "%@ invited you to this channel"; +"Notification.ChannelInviterSelf" = "You joined this channel"; +"Notification.ChannelJoinedByYou" = "You joined the channel"; +"Notification.ChannelMessageDisabled" = "%@ no longer accepts private messages"; +"Notification.ChannelMessagePriceChanged#one" = "{name} set the private message price to %d Star"; +"Notification.ChannelMessagePriceChanged#other" = "{name} set the private message price to %d Stars"; +"Notification.ChannelMessagePriceChangedAndEnabledChannelMessage#one" = "{name} now accepts private messages for %d Star"; +"Notification.ChannelMessagePriceChangedAndEnabledChannelMessage#other" = "{name} now accepts private messages for %d Stars"; +"Notification.ChannelMessagePriceZeroChanged" = "%@ now accepts private messages"; +"Notification.ChannelMessagePriceZeroChangedAndEnabledChannelMessage" = "%@ now accepts private messages"; +"Notification.ChannelMigratedFrom" = "This group was upgraded to a supergroup"; +"Notification.ChatTheme.Text" = "%1$@ set **%2$@** as a new theme for this chat."; +"Notification.ChatTheme.TextYou" = "You set **%@** as a new theme for this chat."; +"Notification.CopyProtection.Disabled" = "%1$@ enabled sharing in this chat"; +"Notification.CopyProtection.DisabledYou" = "You enabled sharing in this chat"; +"Notification.CopyProtection.Enabled" = "%1$@ disabled sharing in this chat"; +"Notification.CopyProtection.EnabledYou" = "You disabled sharing in this chat"; +"Notification.CopyProtection.Request" = "%@ would like to enable sharing in this chat"; +"Notification.CopyProtection.RequestChat" = "%@ would like to enable sharing in this chat, which includes:"; +"Notification.CopyProtection.RequestChatInfo" = "forwarding messages\nsaving photos and videos\ncopying messages\ntaking screenshots"; +"Notification.CopyProtection.RequestChatYou" = "You suggested to enable sharing in this chat, which includes:"; +"Notification.CopyProtection.RequestYou" = "You suggested to enable sharing in this chat"; +"Notification.CopyProtection.StillEnabled" = "Sharing in this chat is still disabled"; +"Notification.CreatedChannel" = "Channel created"; +"Notification.CreatedChat" = "%@ created a group"; +"Notification.CreatedChatWithTitle" = "%@ created the group \"%@\""; +"Notification.CreatedGroup" = "Group created"; +"Notification.DisabledTheme" = "%@ disabled the chat theme"; +"Notification.Exceptions.Add" = "Add"; +"Notification.Exceptions.AddException" = "Add Exception"; +"Notification.Exceptions.AlwaysOff" = "Always Off"; +"Notification.Exceptions.AlwaysOn" = "Always On"; +"Notification.Exceptions.DeleteAll" = "Delete All"; +"Notification.Exceptions.DeleteAllConfirmation" = "Are you sure you want to delete all exceptions?"; +"Notification.Exceptions.MessagePreviewAlwaysOff" = "Always Off"; +"Notification.Exceptions.MessagePreviewAlwaysOn" = "Always On"; +"Notification.Exceptions.MutedUntil" = "Muted until %@"; +"Notification.Exceptions.NewException" = "New Exception"; +"Notification.Exceptions.NewException.MessagePreviewHeader" = "MESSAGE PREVIEW"; +"Notification.Exceptions.NewException.NotificationHeader" = "NOTIFICATIONS"; +"Notification.Exceptions.PreviewAlwaysOff" = "Hide Preview"; +"Notification.Exceptions.PreviewAlwaysOn" = "Show Preview"; +"Notification.Exceptions.RemoveFromExceptions" = "Remove from Exceptions"; +"Notification.Exceptions.Sound" = "Sound: %@"; +"Notification.Exceptions.SoundCustom" = "Sound: Custom"; +"Notification.Exceptions.StoriesDisplayAuthorName" = "DISPLAY AUTHOR NAME"; +"Notification.Exceptions.StoriesHeader" = "STORY NOTIFICATIONS"; +"Notification.ForumTopicClosed" = "Topic closed"; +"Notification.ForumTopicClosedAuthor" = "%1$@ closed topic"; +"Notification.ForumTopicCreated" = "Topic created"; +"Notification.ForumTopicHidden" = "Topic hidden"; +"Notification.ForumTopicHiddenAuthor" = "%1$@ hid the topic"; +"Notification.ForumTopicIconChanged" = "Topic icon changed to %1$@"; +"Notification.ForumTopicIconChangedAuthor" = "%1$@ changed the topic icon to %2$@"; +"Notification.ForumTopicRenamed" = "Topic renamed to \"%1$@\""; +"Notification.ForumTopicRenamedAuthor" = "%1$@ changed the topic name to \"%2$@\""; +"Notification.ForumTopicRenamedIconChangedAuthor" = "%1$@ changed the topic name and icon to %2$@ %3$@"; +"Notification.ForumTopicReopened" = "Topic reopened"; +"Notification.ForumTopicReopenedAuthor" = "%1$@ reopened the topic"; +"Notification.ForumTopicUnhidden" = "Topic unhidden"; +"Notification.ForumTopicUnhiddenAuthor" = "%1$@ unhid the topic"; +"Notification.GameScoreExtended#one" = "scored %@ in {game}"; +"Notification.GameScoreExtended#other" = "scored %@ in {game}"; +"Notification.GameScoreSelfExtended#one" = "scored %@ in {game}"; +"Notification.GameScoreSelfExtended#other" = "scored %@ in {game}"; +"Notification.GameScoreSelfSimple#one" = "scored %@"; +"Notification.GameScoreSelfSimple#other" = "scored %@"; +"Notification.GameScoreSimple#one" = "scored %@"; +"Notification.GameScoreSimple#other" = "scored %@"; +"Notification.Gift" = "Gift"; +"Notification.GiftAuction.Acquired" = "You've successfully bought a gift in the auction for %@."; +"Notification.GiftLink" = "You received a gift"; +"Notification.GiftStars" = "Gift for %@"; +"Notification.GiftStars.Stars#one" = "%@ Star"; +"Notification.GiftStars.Stars#other" = "%@ Stars"; +"Notification.GiveawayResults#one" = "**%@** winner of the giveaway was randomly selected by Telegram and received their gift link in a private message."; +"Notification.GiveawayResults#other" = "**%@** winners of the giveaway were randomly selected by Telegram and received their gift links in private messages."; +"Notification.GiveawayResultsMixedUnclaimed#one" = "**%@** undistributed gift link was forwarded to channel administrators"; +"Notification.GiveawayResultsMixedUnclaimed#other" = "**%@** undistributed gift links were forwarded to channel administrators"; +"Notification.GiveawayResultsMixedUnclaimed.Group#one" = "**%@** undistributed gift link was forwarded to group administrators"; +"Notification.GiveawayResultsMixedUnclaimed.Group#other" = "**%@** undistributed gift links were forwarded to group administrators"; +"Notification.GiveawayResultsMixedWinners#one" = "**%@** winner of the giveaway was randomly selected by Telegram and received their gift link in a private message."; +"Notification.GiveawayResultsMixedWinners#other" = "**%@** winners of the giveaway were randomly selected by Telegram and received their gift links in private messages."; +"Notification.GiveawayResultsNoWinners#one" = "Due to the giveaway terms, no winners could be selected by Telegram, a gift link was forwarded to channel administrators."; +"Notification.GiveawayResultsNoWinners#other" = "Due to the giveaway terms, no winners could be selected by Telegram, all **%@** gift links were forwarded to channel administrators."; +"Notification.GiveawayResultsNoWinners.Group#one" = "Due to the giveaway terms, no winners could be selected by Telegram, a gift link was forwarded to group administrators."; +"Notification.GiveawayResultsNoWinners.Group#other" = "Due to the giveaway terms, no winners could be selected by Telegram, all **%@** gift links were forwarded to group administrators."; +"Notification.GiveawayStarted" = "%1$@ just started a giveaway of Telegram Premium subscriptions for its followers."; +"Notification.GiveawayStartedGroup" = "%1$@ just started a giveaway of Telegram Premium subscriptions for its members."; +"Notification.GiveawayStartedStars" = "%1$@ just started a giveaway of %2$@ Telegram Stars for its followers."; +"Notification.GiveawayStartedStarsGroup" = "%1$@ just started a giveaway of %2$@ Telegram Stars for its members."; +"Notification.GroupChangedWallpaper" = "Group set a new wallpaper"; +"Notification.GroupCreatorChangeApplied" = "%1$@ has transferred ownership of the group to %2$@."; +"Notification.GroupCreatorChangePending" = "%2$@ will become the new owner in 7 days if %1$@ does not return."; +"Notification.GroupInviter" = "%@ invited you to this group"; +"Notification.GroupInviterSelf" = "You joined this group"; +"Notification.Invited" = "%@ invited %@"; +"Notification.InvitedMultiple" = "%@ invited %@"; +"Notification.Joined" = "%@ joined Telegram"; +"Notification.JoinedChannel" = "%@ joined the channel"; +"Notification.JoinedChannelByRequestYou" = "Your request to join the channel was approved"; +"Notification.JoinedChat" = "%@ joined the group"; +"Notification.JoinedGroupByLink" = "%@ joined the group via invite link"; +"Notification.JoinedGroupByLinkYou" = "You joined the group via invite link"; +"Notification.JoinedGroupByRequest" = "%@ was accepted to the group"; +"Notification.JoinedGroupByRequestYou" = "Your request to join the group was approved"; +"Notification.Kicked" = "%@ removed %@"; +"Notification.LeftChannel" = "%@ left the channel"; +"Notification.LeftChat" = "%@ left the group"; +"Notification.LiveStreamEnded" = "Live stream ended (%@)"; +"Notification.LiveStreamScheduled" = "Live stream scheduled for %@"; +"Notification.LiveStreamScheduledToday" = "Live stream scheduled for today at %@"; +"Notification.LiveStreamScheduledTomorrow" = "Live stream scheduled for tomorrow at %@"; +"Notification.LiveStreamStarted" = "Live stream started"; +"Notification.LockScreenReactionPlaceholder" = "Reaction"; +"Notification.LockScreenStoryPlaceholder" = "New Story"; +"Notification.ManagedBotCreated" = "%@ bot created"; +"Notification.MessageLifetime1d" = "1 day"; +"Notification.MessageLifetime1h" = "1 hour"; +"Notification.MessageLifetime1m" = "1 minute"; +"Notification.MessageLifetime1w" = "1 week"; +"Notification.MessageLifetime2s" = "2 seconds"; +"Notification.MessageLifetime5s" = "5 seconds"; +"Notification.MessageLifetimeChanged" = "%1$@ set the self-destruct timer to %2$@"; +"Notification.MessageLifetimeChangedOutgoing" = "You set the self-destruct timer to %1$@"; +"Notification.MessageLifetimeRemoved" = "%1$@ disabled the self-destruct timer"; +"Notification.MessageLifetimeRemovedOutgoing" = "You disabled the self-destruct timer"; +"Notification.Mute1h" = "Mute for 1 hour"; +"Notification.Mute1hMin" = "Mute for 1h"; +"Notification.NewAuthDetected" = "%1$@,\nWe detected a login to your account from a new device on %2$@, %3$@ at %4$@.\n\nDevice: %5$@\nLocation: %6$@\n\nIf this wasn't you, you can go to Settings > Devices and terminate that session.\n\nIf you think that somebody logged in to your account against your will, you can enable Two-Step Verification in Privacy and Security settings.\n\nSincerely,\nThe Telegram Team"; +"Notification.OverviewTopicClosed" = "%1$@ closed %2$@ %3$@"; +"Notification.OverviewTopicCreated" = "%1$@ \"%2$@\" was created"; +"Notification.OverviewTopicHidden" = "%1$@ hid %2$@ %3$@"; +"Notification.OverviewTopicReopened" = "%1$@ reopened %2$@ %3$@"; +"Notification.OverviewTopicUnhidden" = "%1$@ unhid %2$@ %3$@"; +"Notification.PaidMessage" = "%1$@ paid %2$@ to send a message"; +"Notification.PaidMessage.Messages#one" = "%@ message"; +"Notification.PaidMessage.Messages#other" = "%@ messages"; +"Notification.PaidMessage.Stars#one" = "%@ Star"; +"Notification.PaidMessage.Stars#other" = "%@ Stars"; +"Notification.PaidMessageMany" = "%1$@ paid %2$@ to send %3$@"; +"Notification.PaidMessagePriceChanged" = "%1$@ changed price to %2$@ per message"; +"Notification.PaidMessagePriceChanged.Stars#one" = "%@ Star"; +"Notification.PaidMessagePriceChanged.Stars#other" = "%@ Stars"; +"Notification.PaidMessagePriceChangedYou" = "You changed price to %1$@ per message"; +"Notification.PaidMessageRefund" = "%1$@ refunded you %2$@"; +"Notification.PaidMessageRefund.Stars#one" = "%@ Star"; +"Notification.PaidMessageRefund.Stars#other" = "%@ Stars"; +"Notification.PaidMessageRefundYou" = "You refunded %1$@ to %2$@"; +"Notification.PaidMessageYou" = "You paid %1$@ to send a message"; +"Notification.PaidMessageYouMany" = "You paid %1$@ to send %2$@"; +"Notification.PassportValueAddress" = "your address"; +"Notification.PassportValueEmail" = "email address"; +"Notification.PassportValuePersonalDetails" = "personal details"; +"Notification.PassportValuePhone" = "phone number"; +"Notification.PassportValueProofOfAddress" = "proof of address"; +"Notification.PassportValueProofOfIdentity" = "proof of identity"; +"Notification.PassportValuesSentMessage" = "%1$@ received the following documents: %2$@"; +"Notification.PaymentSent" = "You successfully transferred {amount} to {name} for {title}"; +"Notification.PaymentSentNoTitle" = "You successfully transferred {amount} to {name}"; +"Notification.PaymentSentRecurringInit" = "You successfully transferred {amount} to {name} for {title} and allowed future recurring payments"; +"Notification.PaymentSentRecurringInitNoTitle" = "You successfully transferred {amount} to {name} and allowed future recurring payments"; +"Notification.PaymentSentRecurringUsed" = "You have just successfully transferred {amount} to {name} for {title} via recurrent payments"; +"Notification.PaymentSentRecurringUsedNoTitle" = "You have just successfully transferred {amount} to {name} via recurrent payments"; +"Notification.PinnedAnimationMessage" = "%@ pinned a GIF"; +"Notification.PinnedAudioMessage" = "%@ pinned a voice message"; +"Notification.PinnedContactMessage" = "%@ pinned a contact"; +"Notification.PinnedDeletedMessage" = "%@ pinned deleted message"; +"Notification.PinnedDocumentMessage" = "%@ pinned a file"; +"Notification.PinnedLiveLocationMessage" = "%@ pinned a live location"; +"Notification.PinnedLocationMessage" = "%@ pinned a map"; +"Notification.PinnedMessage" = "pinned message"; +"Notification.PinnedPhotoMessage" = "%@ pinned a photo"; +"Notification.PinnedPollMessage" = "%@ pinned a poll"; +"Notification.PinnedQuizMessage" = "%@ pinned a quiz"; +"Notification.PinnedRoundMessage" = "%@ pinned a video message"; +"Notification.PinnedStickerMessage" = "%@ pinned a sticker"; +"Notification.PinnedTextMessage" = "%@ pinned \"%@\""; +"Notification.PinnedVideoMessage" = "%@ pinned a video"; +"Notification.PollAddedOption" = "%1$@ added the option \"%2$@\" to the poll"; +"Notification.PollAddedOptionYou" = "You added the option \"%1$@\" to the poll"; +"Notification.PollDeletedOption" = "%1$@ deleted the option \"%2$@\" from the poll"; +"Notification.PollDeletedOptionYou" = "You deleted the option \"%1$@\" from the poll"; +"Notification.PremiumGift.DaysTitle#one" = "%@ Day Premium"; +"Notification.PremiumGift.DaysTitle#other" = "%@ Days Premium"; +"Notification.PremiumGift.Months#one" = "%@ month"; +"Notification.PremiumGift.Months#other" = "%@ months"; +"Notification.PremiumGift.MonthsTitle#one" = "%@ Month Premium"; +"Notification.PremiumGift.MonthsTitle#other" = "%@ Months Premium"; +"Notification.PremiumGift.More" = "more"; +"Notification.PremiumGift.Sent" = "%1$@ sent you a gift for %2$@"; +"Notification.PremiumGift.SentYou" = "You sent a gift for %@"; +"Notification.PremiumGift.Stars#one" = "%@ Star"; +"Notification.PremiumGift.Stars#other" = "%@ Stars"; +"Notification.PremiumGift.SubscriptionDescription" = "Subscription for exclusive Telegram features."; +"Notification.PremiumGift.Subtitle" = "for %@"; +"Notification.PremiumGift.Title" = "Telegram Premium"; +"Notification.PremiumGift.UseGift" = "Use Gift"; +"Notification.PremiumGift.View" = "View"; +"Notification.PremiumGift.YearsTitle#one" = "%@ Year Premium"; +"Notification.PremiumGift.YearsTitle#other" = "%@ Years Premium"; +"Notification.PremiumPrize.GiftText" = "You've received a gift from **%1$@**.\n\nYour gift is a **Telegram Premium** subscription for %2$@."; +"Notification.PremiumPrize.GiveawayText" = "You won a prize in a giveaway organized by **%1$@**.\n\nYour prize is a **Telegram Premium** subscription for %2$@."; +"Notification.PremiumPrize.Months#one" = "**%@** month"; +"Notification.PremiumPrize.Months#other" = "**%@** months"; +"Notification.PremiumPrize.Title" = "Congratulations!"; +"Notification.PremiumPrize.Unclaimed" = "Unclaimed Prize"; +"Notification.PremiumPrize.UnclaimedText" = "You have an unclaimed prize from a giveaway by **%1$@**.\n\nThis prize is a **Telegram Premium** subscription for %2$@."; +"Notification.PremiumPrize.View" = "Open Gift Link"; +"Notification.PrepaidGiftUpgrade" = "Gift Upgrade for %@"; +"Notification.PrepaidGiftUpgrade.Stars#one" = "%@ Star"; +"Notification.PrepaidGiftUpgrade.Stars#other" = "%@ Stars"; +"Notification.ProximityReached" = "%1$@ is now within %2$@ from %3$@"; +"Notification.ProximityReachedYou" = "%1$@ is now within %2$@ from you"; +"Notification.ProximityYouReached" = "You are now within %1$@ from %2$@"; +"Notification.Refund" = "You received a refund of {amount} from {name}"; +"Notification.RemovedGroupPhoto" = "%@ removed group photo"; +"Notification.RenamedChannel" = "Channel renamed"; +"Notification.RenamedChat" = "%@ renamed group"; +"Notification.RenamedGroup" = "Group renamed"; +"Notification.Reply" = "Reply"; +"Notification.RequestedPeer" = "You shared %1$@ with %2$@."; +"Notification.RequestedPeerMultiple" = "You shared %1$@ with %2$@."; +"Notification.SecretChatMessageScreenshot" = "%@ took a screenshot!"; +"Notification.SecretChatMessageScreenshotSelf" = "You took a screenshot!"; +"Notification.SecretChatScreenshot" = "Screenshot taken!"; +"Notification.StarGift.Backdrop" = "Backdrop"; +"Notification.StarGift.Bot.Subtitle" = "Display this gift on your profile."; +"Notification.StarGift.Bot.Subtitle.Displaying" = "You are displaying this gift on your profile."; +"Notification.StarGift.Burned" = "burned"; +"Notification.StarGift.Collectible" = "Collectible"; +"Notification.StarGift.Crafted.Title" = "Crafted Gift"; +"Notification.StarGift.Gift" = "gift"; +"Notification.StarGift.Model" = "Model"; +"Notification.StarGift.OneOf" = "1 of %@"; +"Notification.StarGift.Purchased.Title" = "Purchased Gift"; +"Notification.StarGift.ReleasedBy" = "released by %@"; +"Notification.StarGift.Self.Title" = "Saved Gift"; +"Notification.StarGift.Sold" = "sold"; +"Notification.StarGift.Subtitle" = "Add this gift to your profile or convert it to %@."; +"Notification.StarGift.Subtitle.Channel" = "Add this gift to your channel's profile or convert it to %@."; +"Notification.StarGift.Subtitle.Converted" = "You converted this gift to %@."; +"Notification.StarGift.Subtitle.Converted.Stars#one" = "%@ Star"; +"Notification.StarGift.Subtitle.Converted.Stars#other" = "%@ Stars"; +"Notification.StarGift.Subtitle.Displaying" = "You added this gift to your profile. You can also convert it to %@."; +"Notification.StarGift.Subtitle.Displaying.Stars#one" = "%@ Star"; +"Notification.StarGift.Subtitle.Displaying.Stars#other" = "%@ Stars"; +"Notification.StarGift.Subtitle.Downgraded" = "This gift was downgraded because a request to refund the payment related to this gift was made, and the money was returned."; +"Notification.StarGift.Subtitle.NoConvert" = "Display this gift on your profile and turn it into a collectible."; +"Notification.StarGift.Subtitle.Other" = "%1$@ can add this gift to their profile or convert it to %2$@."; +"Notification.StarGift.Subtitle.Other.Stars#one" = "%@ Star"; +"Notification.StarGift.Subtitle.Other.Stars#other" = "%@ Stars"; +"Notification.StarGift.Subtitle.OtherNoConvert" = "Display this gift on your profile and turn it into a collectible."; +"Notification.StarGift.Subtitle.Refunded" = "This gift cannot be converted to Stars because the payment related to it was refunded."; +"Notification.StarGift.Subtitle.Stars#one" = "%@ Star"; +"Notification.StarGift.Subtitle.Stars#other" = "%@ Stars"; +"Notification.StarGift.Subtitle.Upgrade" = "Tap \"Unpack\" to turn this gift into a unique collectible."; +"Notification.StarGift.Subtitle.Upgrade.Other" = "%1$@ can turn this gift into a unique collectible."; +"Notification.StarGift.Subtitle.Upgrade.Prepaid" = "%@ can now turn this gift into a unique collectible."; +"Notification.StarGift.Subtitle.Upgraded" = "This gift was upgraded."; +"Notification.StarGift.Symbol" = "Symbol"; +"Notification.StarGift.Title" = "Gift from %@"; +"Notification.StarGift.TitleShort" = "Gift"; +"Notification.StarGift.TitleTo" = "Gift for %@"; +"Notification.StarGift.Unpack" = "Unpack"; +"Notification.StarGift.Upgraded.Title" = "Upgraded Gift"; +"Notification.StarGift.View" = "View"; +"Notification.StarGiftOffer.Expiration.Delimiter" = " "; +"Notification.StarGiftOffer.Expiration.Hours#one" = "%@h"; +"Notification.StarGiftOffer.Expiration.Hours#other" = "%@h"; +"Notification.StarGiftOffer.Expiration.Minutes#one" = "%@m"; +"Notification.StarGiftOffer.Expiration.Minutes#other" = "%@m"; +"Notification.StarGiftOffer.Offer" = "The user offered you **%2$@** for your gift **%3$@**."; +"Notification.StarGiftOffer.Offer.Stars#one" = "%@ Star"; +"Notification.StarGiftOffer.Offer.Stars#other" = "%@ Stars"; +"Notification.StarGiftOffer.OfferYou" = "You offered **%1$@** **%2$@** for their gift **%3$@**."; +"Notification.StarGiftOffer.Status.Accepted" = "This offer was accepted."; +"Notification.StarGiftOffer.Status.Expired" = "This offer has expired."; +"Notification.StarGiftOffer.Status.Expires" = "This offer expires in %@"; +"Notification.StarGiftOffer.Status.Rejected" = "This offer was rejected."; +"Notification.StarsGift.Assigned" = "You added %@ to your profile."; +"Notification.StarsGift.Bought" = "%1$@ gifted you %2$@ for %3$@"; +"Notification.StarsGift.Bought.Stars#one" = "%@ Star"; +"Notification.StarsGift.Bought.Stars#other" = "%@ Stars"; +"Notification.StarsGift.BoughtForYouself" = "You bought this gift for %1$@"; +"Notification.StarsGift.BoughtYou" = "You gifted %1$@ for %2$@"; +"Notification.StarsGift.Channel.Sent" = "%1$@ sent a gift to %2$@ for %3$@"; +"Notification.StarsGift.Crafted" = "You crafted a new gift"; +"Notification.StarsGift.Self.Bought" = "You bought a gift for %@"; +"Notification.StarsGift.Sent" = "%1$@ sent you a gift for %2$@"; +"Notification.StarsGift.SentSomeone" = "Someone sent you a gift"; +"Notification.StarsGift.SentUpgrade" = "%1$@ sent an upgrade worth %2$@ for your gift"; +"Notification.StarsGift.SentUpgradeOther" = "%1$@ sent an upgrade worth %2$@ for the gift you received from %3$@"; +"Notification.StarsGift.SentUpgradeYou" = "You sent an upgrade worth %1$@ for your gift to %2$@"; +"Notification.StarsGift.SentUpgradeYouOther" = "You sent an upgrade worth %1$@ for the gift %2$@ received from %3$@"; +"Notification.StarsGift.SentUpgradeYouUnknown" = "You sent an upgrade worth %1$@ for the gift to %2$@"; +"Notification.StarsGift.SentYou" = "You sent a gift for %@"; +"Notification.StarsGift.Stars#one" = "%@ Star"; +"Notification.StarsGift.Stars#other" = "%@ Stars"; +"Notification.StarsGift.Subtitle" = "Use Stars to unlock content and services on Telegram."; +"Notification.StarsGift.Subtitle.Self" = "You can add this gift to your profile or upgrade it to a unique collectible to keep or transfer."; +"Notification.StarsGift.SubtitleYou" = "With Stars, %@ will be able to unlock content and services on Telegram."; +"Notification.StarsGift.Title#one" = "%@ Star"; +"Notification.StarsGift.Title#other" = "%@ Stars"; +"Notification.StarsGift.Transfer" = "%@ transferred a unique collectible to you"; +"Notification.StarsGift.TransferToChannel" = "%1$@ transferred a unique collectible to %2$@"; +"Notification.StarsGift.TransferToChannelYou" = "You transferred a unique collectible to %@"; +"Notification.StarsGift.TransferYou" = "You transferred a unique collectible"; +"Notification.StarsGift.UnknownUser" = "Unknown user"; +"Notification.StarsGift.Upgrade" = "%@ turned the gift from you into a unique collectible"; +"Notification.StarsGift.UpgradeChannel" = "A gift was turned into a unique collectible"; +"Notification.StarsGift.UpgradePrepaid" = "%@ unboxed the gift that you helped upgrade"; +"Notification.StarsGift.UpgradePrepaidYou" = "You unpacked the gift that %@ helped upgrade"; +"Notification.StarsGift.UpgradeSelf" = "You turned the gift into a unique collectible"; +"Notification.StarsGift.UpgradeYou" = "You turned the gift from %@ into a unique collectible"; +"Notification.StarsGiftOffer.Accepted" = "%1$@ accepted your offer and sold you %2$@ for %3$@"; +"Notification.StarsGiftOffer.Accepted.Stars#one" = "%@ Star"; +"Notification.StarsGiftOffer.Accepted.Stars#other" = "%@ Stars"; +"Notification.StarsGiftOffer.AcceptedYou" = "You sold %1$@ to %2$@ for %3$@"; +"Notification.StarsGiftOffer.AcceptedYou.Stars#one" = "%@ Star"; +"Notification.StarsGiftOffer.AcceptedYou.Stars#other" = "%@ Stars"; +"Notification.StarsGiftOffer.Expired" = "%1$@ didn't respond to your offer for %2$@ – your %4$@ have been refunded"; +"Notification.StarsGiftOffer.Expired.Hours#one" = "%@ hours"; +"Notification.StarsGiftOffer.Expired.Hours#other" = "%@ hours"; +"Notification.StarsGiftOffer.Expired.Stars#one" = "%@ Star"; +"Notification.StarsGiftOffer.Expired.Stars#other" = "%@ Stars"; +"Notification.StarsGiftOffer.ExpiredYou" = "The offer from %1$@ to buy your %2$@ for %3$@ has expired"; +"Notification.StarsGiftOffer.ExpiredYou.Stars#one" = "%@ Star"; +"Notification.StarsGiftOffer.ExpiredYou.Stars#other" = "%@ Stars"; +"Notification.StarsGiftOffer.Offer" = "The user offered you %2$@ for your gift %3$@"; +"Notification.StarsGiftOffer.Offer.Stars#one" = "%@ Star"; +"Notification.StarsGiftOffer.Offer.Stars#other" = "%@ Stars"; +"Notification.StarsGiftOffer.OfferYou" = "You offered %1$@ %2$@ for their gift %3$@"; +"Notification.StarsGiftOffer.OfferYou.Stars#one" = "%@ Star"; +"Notification.StarsGiftOffer.OfferYou.Stars#other" = "%@ Stars"; +"Notification.StarsGiftOffer.Rejected" = "%1$@ rejected your offer for %2$@ – your %3$@ have been refunded"; +"Notification.StarsGiftOffer.Rejected.Stars#one" = "%@ Star"; +"Notification.StarsGiftOffer.Rejected.Stars#other" = "%@ Stars"; +"Notification.StarsGiftOffer.RejectedYou" = "You rejected %1$@'s offer"; +"Notification.StarsGiftOffer.RejectedYou.Stars#one" = "%@ Star"; +"Notification.StarsGiftOffer.RejectedYou.Stars#other" = "%@ Stars"; +"Notification.StarsGiveaway.Subtitle" = "You won a prize in a giveaway organized by **%1$@**.\n\nYour prize is **%2$@**."; +"Notification.StarsGiveaway.Subtitle.Stars#one" = "%@ Star"; +"Notification.StarsGiveaway.Subtitle.Stars#other" = "%@ Stars"; +"Notification.StarsGiveaway.Title" = "Congratulations!"; +"Notification.StarsGiveawayResultsNoWinners" = "Due to the giveaway terms, no winners could be selected by Telegram, all stars were credited to channel administrators."; +"Notification.StarsGiveawayResultsNoWinners.Group" = "Due to the giveaway terms, no winners could be selected by Telegram, all stars were credited to group administrators."; +"Notification.StarsGiveawayStarted" = "%1$@ just started a giveaway of %2$@ for its followers."; +"Notification.StarsGiveawayStarted.Stars#one" = "%@ Telegram Star"; +"Notification.StarsGiveawayStarted.Stars#other" = "%@ Telegram Stars"; +"Notification.StarsGiveawayStartedGroup" = "%1$@ just started a giveaway of %2$@ for its members."; +"Notification.StarsPrize" = "You received a gift"; +"Notification.Story" = "Story"; +"Notification.SuggestBirthdate" = "Suggested Birthday"; +"Notification.SuggestedProfilePhoto" = "Suggested Profile Photo"; +"Notification.SuggestedProfileVideo" = "Suggested profile photo"; +"Notification.TodoAddedMultipleTasks" = "%1$@ added %2$@ to \"%3$@\""; +"Notification.TodoAddedMultipleTasksYou" = "You added %1$@ to \"%2$@\""; +"Notification.TodoAddedTask" = "%1$@ added a new task \"%2$@\" to \"%3$@\"."; +"Notification.TodoAddedTaskYou" = "You added a new task \"%1$@\" to \"%2$@\"."; +"Notification.TodoAddedTasks#one" = "%@ task"; +"Notification.TodoAddedTasks#other" = "%@ tasks"; +"Notification.TodoCompleted" = "%1$@ marked \"%2$@\" as done"; +"Notification.TodoCompletedYou" = "You marked \"%1$@\" as done"; +"Notification.TodoIncompleted" = "%1$@ marked \"%2$@\" as undone"; +"Notification.TodoIncompletedYou" = "You marked \"%1$@\" as not done"; +"Notification.TodoMultiple" = "%1$@ marked %2$@."; +"Notification.TodoMultipleCompleted" = "%1$@ marked %2$@ as done"; +"Notification.TodoMultipleCompletedYou" = "You marked %1$@ as done"; +"Notification.TodoMultipleIncompleted" = "%1$@ marked %2$@ as undone"; +"Notification.TodoMultipleIncompletedYou" = "You marked %1$@ as not done"; +"Notification.TodoMultipleYou" = "You marked %1$@."; +"Notification.TodoTasks#one" = "%@ task"; +"Notification.TodoTasks#other" = "%@ tasks"; +"Notification.TodoTasksDone#one" = "%@ task as done"; +"Notification.TodoTasksDone#other" = "%@ tasks as done"; +"Notification.TodoTasksUndone#one" = "%@ task as not done"; +"Notification.TodoTasksUndone#other" = "%@ tasks as not done"; +"Notification.Ton.Subtitle" = "Use Grams to buy gifts and suggest posts to channels."; +"Notification.Ton.SubtitleYou" = "With Grams, %@ will be able to submit post suggestions to channels."; +"Notification.TopicClosed" = "Topic closed"; +"Notification.TopicClosedAuthor" = "%1$@ closed the topic"; +"Notification.TopicCreated" = "Topic created"; +"Notification.TopicIconChanged" = "Topic icon changed to %1$@"; +"Notification.TopicIconChangedAuthor" = "%1$@ changed the topic icon to %2$@"; +"Notification.TopicIconRemoved" = "Topic icon changed"; +"Notification.TopicIconRemovedAuthor" = "%1$@ changed the topic icon"; +"Notification.TopicRenamed" = "Topic renamed to \"%1$@\""; +"Notification.TopicRenamedAuthor" = "%1$@ changed the topic name to \"%2$@\""; +"Notification.TopicReopened" = "Topic reopened"; +"Notification.TopicReopenedAuthor" = "%1$@ reopened the topic"; +"Notification.VideoCallCanceled" = "Canceled Video Call"; +"Notification.VideoCallIncoming" = "Incoming Video Call"; +"Notification.VideoCallMissed" = "Missed Video Call"; +"Notification.VideoCallOutgoing" = "Outgoing Video Call"; +"Notification.VoiceChatEnded" = "Video chat ended (%@)"; +"Notification.VoiceChatEndedGroup" = "%1$@ ended the video chat (%2$@)"; +"Notification.VoiceChatInvitation" = "%1$@ invited %2$@ to the video chat"; +"Notification.VoiceChatInvitationForYou" = "%1$@ invited you to the video chat"; +"Notification.VoiceChatScheduled" = "%1$@ scheduled a video chat for %2$@"; +"Notification.VoiceChatScheduledChannel" = "Video chat scheduled for %@"; +"Notification.VoiceChatScheduledToday" = "%1$@ scheduled a video chat for today at %2$@"; +"Notification.VoiceChatScheduledTodayChannel" = "Video chat scheduled for today at %@"; +"Notification.VoiceChatScheduledTomorrow" = "%1$@ scheduled a video chat for tomorrow at %2$@"; +"Notification.VoiceChatScheduledTomorrowChannel" = "Video chat scheduled for tomorrow at %@"; +"Notification.VoiceChatStarted" = "%1$@ started a video chat"; +"Notification.VoiceChatStartedChannel" = "Video chat started"; +"Notification.Wallpaper.Remove" = "Remove"; +"Notification.Wallpaper.View" = "View Wallpaper"; +"Notification.WebAppSentData" = "Data from the \"%@\" button was transferred to the bot."; +"Notification.YouChangedGroupWallpaper" = "You set a new wallpaper for this group"; +"Notification.YouChangedTheme" = "You changed the chat theme to %@"; +"Notification.YouChangedThemeGift" = "You changed chat theme to %@"; +"Notification.YouChangedToSameWallpaper" = "You set the same wallpaper as your chat partner.\n"; +"Notification.YouChangedWallpaper" = "You set a new wallpaper for this chat"; +"Notification.YouChangedWallpaperBoth" = "You set a new wallpaper for %@ and you"; +"Notification.YouChangingWallpaper" = "Setting new wallpaper"; +"Notification.YouDisabledTheme" = "You disabled the chat theme"; +"NotificationSettings.ContactJoined" = "New Contacts"; +"NotificationSettings.ContactJoinedInfo" = "Receive push notifications when one of your contacts becomes available on Telegram."; +"NotificationSettings.ShowNotificationsAllAccounts" = "All Accounts"; +"NotificationSettings.ShowNotificationsAllAccountsInfoOff" = "Turn this on if you want to receive notifications from all your accounts."; +"NotificationSettings.ShowNotificationsAllAccountsInfoOn" = "Turn this off if you want to receive notifications only from the account you are currently using."; +"NotificationSettings.ShowNotificationsFromAccountsSection" = "SHOW NOTIFICATIONS FROM"; +"NotificationSettings.Stories.AutomaticValue" = "%@ (automatic)"; +"NotificationSettings.Stories.CompactHideName" = "Hide name"; +"NotificationSettings.Stories.CompactShowName" = "Show name"; +"NotificationSettings.Stories.DisplayAuthorName" = "Display Author Name"; +"NotificationSettings.Stories.ShowAll" = "Show All Notifications"; +"NotificationSettings.Stories.ShowImportant" = "Show Important Notifications"; +"NotificationSettings.Stories.ShowImportantFooter" = "Automatically enable story notifications from the 5 users you contact most frequently."; +"Notifications.AddExceptionTitle" = "Add Exception"; +"Notifications.AlertTones" = "ALERT TONES"; +"Notifications.Badge" = "BADGE COUNTER"; +"Notifications.Badge.CountUnreadMessages" = "Count Unread Messages"; +"Notifications.Badge.CountUnreadMessages.InfoOff" = "Switch on to show the number of unread messages instead of chats."; +"Notifications.Badge.CountUnreadMessages.InfoOn" = "Switch off to show the number of unread chats instead of messages."; +"Notifications.Badge.IncludeChannels" = "Include Channels"; +"Notifications.Badge.IncludeMutedChats" = "Include Muted Chats"; +"Notifications.Badge.IncludePublicGroups" = "Include Public Groups"; +"Notifications.CategoryExceptions#one" = "%@ exception"; +"Notifications.CategoryExceptions#other" = "%@ exceptions"; +"Notifications.ChannelNotifications" = "CHANNEL NOTIFICATIONS"; +"Notifications.ChannelNotificationsAlert" = "Show Notifications"; +"Notifications.ChannelNotificationsExceptionsHelp" = "Set custom notifications for specific channels."; +"Notifications.ChannelNotificationsHelp" = "You can set custom notifications for specific channels on the Channel Info page."; +"Notifications.ChannelNotificationsPreview" = "Message Preview"; +"Notifications.ChannelNotificationsSound" = "Sound"; +"Notifications.Channels" = "Channels"; +"Notifications.ChannelsTitle" = "Channels"; +"Notifications.ClassicTones" = "CLASSIC"; +"Notifications.DeleteAllExceptions" = "Delete All Exceptions"; +"Notifications.DisplayNamesOnLockScreen" = "Names on Lock Screen"; +"Notifications.DisplayNamesOnLockScreenInfo" = "Display names in notifications when the device is locked. To disable, open iOS Settings > Telegram > Notifications, and set \"Show Previews\" to \"When Unlocked\" or \"Never\"."; +"Notifications.DisplayNamesOnLockScreenInfoWithLink" = "Display names in notifications when the device is locked. To disable, make sure that \"Show Previews\" is also set to \"When Unlocked\" or \"Never\" in [iOS Settings > Notifications]."; +"Notifications.ExceptionMuteExpires.Days#one" = "In 1 day"; +"Notifications.ExceptionMuteExpires.Days#other" = "In %@ days"; +"Notifications.ExceptionMuteExpires.Hours#one" = "In 1 hour"; +"Notifications.ExceptionMuteExpires.Hours#other" = "In %@ hours"; +"Notifications.ExceptionMuteExpires.Minutes#one" = "In 1 minute"; +"Notifications.ExceptionMuteExpires.Minutes#other" = "In %@ minutes"; +"Notifications.Exceptions#one" = "%@ chat"; +"Notifications.Exceptions#other" = "%@ chats"; +"Notifications.ExceptionsChangeSound" = "Change Sound (%@)"; +"Notifications.ExceptionsDefaultSound" = "Default"; +"Notifications.ExceptionsGroupPlaceholder" = "This section will list all groups and channels with non-default notification settings"; +"Notifications.ExceptionsMessagePlaceholder" = "This section will list all private chats with non-default notification settings"; +"Notifications.ExceptionsMuted" = "Muted"; +"Notifications.ExceptionsNone" = "None"; +"Notifications.ExceptionsTitle" = "Exceptions"; +"Notifications.ExceptionsUnmuted" = "Unmuted"; +"Notifications.GroupChats" = "Group Chats"; +"Notifications.GroupChatsTitle" = "Group Chats"; +"Notifications.GroupNotifications" = "GROUP NOTIFICATIONS"; +"Notifications.GroupNotificationsAlert" = "Show Notifications"; +"Notifications.GroupNotificationsExceptions" = "Exceptions"; +"Notifications.GroupNotificationsExceptionsHelp" = "Set custom notifications for specific groups."; +"Notifications.GroupNotificationsHelp" = "You can set custom notifications for specific groups on the Group Info page."; +"Notifications.GroupNotificationsPreview" = "Message Preview"; +"Notifications.GroupNotificationsSound" = "Sound"; +"Notifications.InAppNotifications" = "IN-APP NOTIFICATIONS"; +"Notifications.InAppNotificationsPreview" = "In-App Preview"; +"Notifications.InAppNotificationsSounds" = "In-App Sounds"; +"Notifications.InAppNotificationsVibrate" = "In-App Vibrate"; +"Notifications.MessageNotifications" = "MESSAGE NOTIFICATIONS"; +"Notifications.MessageNotificationsAlert" = "Show Notifications"; +"Notifications.MessageNotificationsExceptions" = "Exceptions"; +"Notifications.MessageNotificationsExceptionsHelp" = "Set custom notifications for specific users."; +"Notifications.MessageNotificationsHelp" = "You can set custom notifications for specific users on their profile page."; +"Notifications.MessageNotificationsPreview" = "Message Preview"; +"Notifications.MessageNotificationsSound" = "Sound"; +"Notifications.MessageSoundInfo" = "Press and hold a short voice note or MP3 file in any chat and select \"Save for Notifications\". It will appear here."; +"Notifications.Off" = "Off"; +"Notifications.On" = "On"; +"Notifications.Options" = "Options"; +"Notifications.PermissionsAllow" = "Turn Notifications ON"; +"Notifications.PermissionsAllowInSettings" = "Turn ON in Settings"; +"Notifications.PermissionsEnable" = "Enable"; +"Notifications.PermissionsKeepDisabled" = "Keep Disabled"; +"Notifications.PermissionsOpenSettings" = "Open Settings"; +"Notifications.PermissionsSuppressWarningText" = "You may miss important messages on Telegram due to your current settings.\n\nFor better results, enable alerts or banners and try muting certain chats or chat types in Telegram settings."; +"Notifications.PermissionsSuppressWarningTitle" = "Keep notifications disabled?"; +"Notifications.PermissionsText" = "Don't miss important messages from your friends and coworkers."; +"Notifications.PermissionsTitle" = "Turn ON Notifications"; +"Notifications.PermissionsUnreachableText" = "Please note that you partly disabled message notifications in your Settings."; +"Notifications.PermissionsUnreachableTitle" = "Check Notification Settings"; +"Notifications.PrivateChats" = "Private Chats"; +"Notifications.PrivateChatsTitle" = "Private Chats"; +"Notifications.Reactions" = "Reactions"; +"Notifications.Reactions.ItemMessages" = "Reactions to my Messages"; +"Notifications.Reactions.ItemStories" = "Reactions to my Stories"; +"Notifications.Reactions.SettingsHeader" = "NOTIFY ME ABOUT..."; +"Notifications.Reactions.SheetTitleMessages" = "Notify about reactions to my messages from"; +"Notifications.Reactions.SheetTitleStories" = "Notify about reactions to my stories from"; +"Notifications.Reactions.SheetValueContacts" = "My Contacts"; +"Notifications.Reactions.SheetValueEveryone" = "Everyone"; +"Notifications.Reactions.SubtitleContacts" = "From My Contacts"; +"Notifications.Reactions.SubtitleEveryone" = "From Everyone"; +"Notifications.Reactions.SubtitleMessages" = "Messages"; +"Notifications.Reactions.SubtitleStories" = "Stories"; +"Notifications.Reactions.Title" = "Reactions"; +"Notifications.Reset" = "Reset"; +"Notifications.ResetAllNotifications" = "Reset All Notifications"; +"Notifications.ResetAllNotificationsHelp" = "Undo all custom notification settings for your contacts, groups and channels."; +"Notifications.ResetAllNotificationsText" = "Are you sure you want to reset all notification settings to default?"; +"Notifications.SaveSuccess.Text" = "You can use\u00a0this sound as a notification tone in your [custom notification settings]()."; +"Notifications.Stories" = "Stories"; +"Notifications.Stories.DisplayName" = "Show Sender's Name"; +"Notifications.Stories.GlobalSetting" = "New Stories"; +"Notifications.Stories.Important" = "Important Stories"; +"Notifications.Stories.SettingsHeader" = "NOTIFY ME ABOUT..."; +"Notifications.StoriesTitle" = "Stories"; +"Notifications.SystemTones" = "SYSTEM TONES"; +"Notifications.TelegramTones" = "TELEGRAM TONES"; +"Notifications.TextTone" = "Text Tone"; +"Notifications.Title" = "Notifications"; +"Notifications.TopChats" = "Top 5"; +"Notifications.UploadError.TooLarge.Text" = "File is over %@."; +"Notifications.UploadError.TooLarge.Title" = "Audio is too large"; +"Notifications.UploadError.TooLong.Text" = "Duration must be less than %@."; +"Notifications.UploadError.TooLong.Title" = "Audio is too long"; +"Notifications.UploadSound" = "Upload Sound"; +"Notifications.UploadSuccess.Text" = "The sound **%@** was added to your Telegram tones."; +"Notifications.UploadSuccess.Title" = "Sound Added"; +"NotificationsSound.Alert" = "Alert"; +"NotificationsSound.Antic" = "Antic"; +"NotificationsSound.Aurora" = "Aurora"; +"NotificationsSound.Bamboo" = "Bamboo"; +"NotificationsSound.Bell" = "Bell"; +"NotificationsSound.Calypso" = "Calypso"; +"NotificationsSound.Cheers" = "Cheers"; +"NotificationsSound.Chime" = "Chime"; +"NotificationsSound.Chord" = "Chord"; +"NotificationsSound.Circles" = "Circles"; +"NotificationsSound.Complete" = "Complete"; +"NotificationsSound.Droplet" = "Droplet"; +"NotificationsSound.Glass" = "Glass"; +"NotificationsSound.Handoff" = "Handoff"; +"NotificationsSound.Hello" = "Hello"; +"NotificationsSound.Input" = "Input"; +"NotificationsSound.Keys" = "Keys"; +"NotificationsSound.Milestone" = "Milestone"; +"NotificationsSound.None" = "None"; +"NotificationsSound.Note" = "Note"; +"NotificationsSound.Passage" = "Passage"; +"NotificationsSound.Popcorn" = "Popcorn"; +"NotificationsSound.Portal" = "Portal"; +"NotificationsSound.Pulse" = "Pulse"; +"NotificationsSound.Rattle" = "Rattle"; +"NotificationsSound.Rebound" = "Rebound"; +"NotificationsSound.Slide" = "Slide"; +"NotificationsSound.Synth" = "Synth"; +"NotificationsSound.Telegraph" = "Telegraph"; +"NotificationsSound.Tremolo" = "Tremolo"; +"NotificationsSound.Tritone" = "Tri-tone"; +"NotificationsSound.Welcome" = "Welcome"; +"OAUTH_REQUEST" = "Login request for %1$@, location: %2$@"; +"OldChannels.ChannelFormat" = "channel, "; +"OldChannels.ChannelsHeader" = "LEAST ACTIVE"; +"OldChannels.GroupEmptyFormat" = "group, "; +"OldChannels.GroupFormat#one" = "%@ member "; +"OldChannels.GroupFormat#other" = "%@ members "; +"OldChannels.InactiveMonth#one" = "inactive %@ month"; +"OldChannels.InactiveMonth#other" = "inactive %@ months"; +"OldChannels.InactiveWeek#one" = "inactive %@ week"; +"OldChannels.InactiveWeek#other" = "inactive %@ weeks"; +"OldChannels.InactiveYear#one" = "inactive %@ year"; +"OldChannels.InactiveYear#other" = "inactive %@ years"; +"OldChannels.Leave#one" = "Leave %@ Chat"; +"OldChannels.Leave#other" = "Leave %@ Chats"; +"OldChannels.LeaveCommunities#one" = "Leave %@ Community"; +"OldChannels.LeaveCommunities#other" = "Leave %@ Communities"; +"OldChannels.NoticeCreateText" = "Sorry, you are member of too many groups and channels.\nPlease leave some before creating a new one."; +"OldChannels.NoticeText" = "Sorry, you are a member of too many groups and channels. Please leave one before joining another."; +"OldChannels.NoticeTitle" = "Too Many Groups and Channels"; +"OldChannels.NoticeUpgradeText" = "Sorry, you are a member of too many groups and channels.\nFor technical reasons, you need to leave some first before changing this setting in your groups."; +"OldChannels.Title" = "Limit Reached"; +"OldChannels.TooManyCommunitiesCreateFinalText" = "You are a member of **%@** groups and channels. Please leave some before creating a new one."; +"OldChannels.TooManyCommunitiesCreateNoPremiumText" = "You are a member of **%@** groups and channels. Please leave some before creating a new one. We are working to let you increase this limit in the future."; +"OldChannels.TooManyCommunitiesCreateText" = "You are a member of **%@** groups and channels. Please leave some before creating a new one —\u00a0or subscribe to **Telegram Premium** to double the limit to **%@** groups and channels."; +"OldChannels.TooManyCommunitiesFinalText" = "You are a member of **%@** groups and channels. Please leave some before joining a new one."; +"OldChannels.TooManyCommunitiesNoPremiumText" = "You are a member of **%@** groups and channels. Please leave some before joining a new one. We are working to let you increase this limit in the future."; +"OldChannels.TooManyCommunitiesText" = "You are a member of **%@** groups and channels. Please leave some before joining a new one — or subscribe to **Telegram Premium** to double the limit to **%@** groups and channels."; +"OldChannels.TooManyCommunitiesUpgradeFinalText" = "You are a member of **%@** groups and channels. For technical reasons, you need to leave some first before changing this setting in your groups."; +"OldChannels.TooManyCommunitiesUpgradeNoPremiumText" = "You are a member of **%@** groups and channels. For technical reasons, you need to leave some first before changing this setting in your groups. We are working to let you increase this limit in the future."; +"OldChannels.TooManyCommunitiesUpgradeText" = "You are a member of **%@** groups and channels. For technical reasons, you need to leave some first before changing this setting in your groups or subscribe to **Telegram Premium** to double the limit to **%@** groups and channels."; +"OpenFile.PotentiallyDangerousContentAlert" = "Previewing this file can potentially expose your IP address to its sender. Continue?"; +"OpenFile.Proceed" = "Proceed"; +"OpenLinkConfirmation.Open" = "Open"; +"OpenLinkConfirmation.Title" = "Open link?"; +"OwnershipTransfer.ComeBackLater" = "\n\nPlease come back later."; +"OwnershipTransfer.EnterPassword" = "Enter Password"; +"OwnershipTransfer.EnterPasswordText" = "Please enter your Two-Step Verification password to confirm this action."; +"OwnershipTransfer.SecurityCheck" = "Security Check"; +"OwnershipTransfer.SecurityRequirements" = "You can only confirm this action if:\n\n• 2-Step verification is active and has been enabled for your account for more than **7 days**.\n\n• Your 2-Step Verification password has not been changed the last **7 days**.\n\n• You have been logged in on this device for more than **24 hours**."; +"OwnershipTransfer.SetupTwoStepAuth" = "Enable 2-Step Verification"; +"OwnershipTransfer.Transfer" = "Confirm"; +"PHONE_CALL_MISSED" = "You missed a call from %1$@"; +"PHONE_CALL_MISSED_SEPARATED" = "%1$@|You missed a call"; +"PHONE_CALL_REQUEST" = "%1$@ is calling you"; +"PHONE_CALL_REQUEST_SEPARATED" = "%1$@|is calling you!"; +"PINNED_AUDIO" = "%1$@ pinned a voice message"; +"PINNED_AUDIO_SEPARATED" = "%1$@|pinned a voice message"; +"PINNED_CONTACT" = "%1$@ pinned a contact"; +"PINNED_CONTACT_SEPARATED" = "%1$@|pinned a contact"; +"PINNED_DOC" = "%1$@ pinned a file"; +"PINNED_DOC_SEPARATED" = "%1$@|pinned a file"; +"PINNED_GAME" = "%1$@ pinned a game"; +"PINNED_GAME_SEPARATED" = "%1$@|pinned a game"; +"PINNED_GEO" = "%1$@ pinned a map"; +"PINNED_GEOLIVE" = "%1$@ pinned a live location"; +"PINNED_GEOLIVE_SEPARATED" = "%1$@|pinned a live location"; +"PINNED_GEO_SEPARATED" = "%1$@|pinned a map"; +"PINNED_GIF" = "%1$@ pinned a GIF"; +"PINNED_GIF_SEPARATED" = "%1$@|pinned a GIF"; +"PINNED_GIVEAWAY" = "%1$@ pinned a giveaway"; +"PINNED_INVOICE" = "%1$@ pinned an invoice"; +"PINNED_INVOICE_SEPARATED" = "%1$@|pinned an invoice"; +"PINNED_NOTEXT" = "%1$@ pinned a message"; +"PINNED_NOTEXT_SEPARATED" = "%1$@|pinned a message"; +"PINNED_PHOTO" = "%1$@ pinned a photo"; +"PINNED_PHOTO_SEPARATED" = "%1$@|pinned a photo"; +"PINNED_POLL" = "%1$@ pinned a poll"; +"PINNED_POLL_SEPARATED" = "%1$@|pinned a poll"; +"PINNED_ROUND" = "%1$@ pinned a video message"; +"PINNED_ROUND_SEPARATED" = "%1$@|pinned a video message"; +"PINNED_STICKER" = "%1$@ pinned a %2$@ sticker"; +"PINNED_STICKER_SEPARATED" = "%1$@|pinned a %2$@ sticker"; +"PINNED_TEXT" = "%1$@ pinned \"%2$@\""; +"PINNED_TEXT_SEPARATED" = "%1$@|pinned \"%2$@\""; +"PINNED_VIDEO" = "%1$@ pinned a video"; +"PINNED_VIDEO_SEPARATED" = "%1$@|pinned a video"; +"PUSH_ALBUM" = "%1$@|📷📹 Album"; +"PUSH_AUTH_REGION" = "New login|Device: %1$@. Location: %2$@"; +"PUSH_AUTH_UNKNOWN" = "New login|from %1$@"; +"PUSH_CHANNEL_ALBUM" = "%1$@|📷📹 Album"; +"PUSH_CHANNEL_MESSAGE" = "%1$@|New message"; +"PUSH_CHANNEL_MESSAGES#one" = "%1$@|📷 Album"; +"PUSH_CHANNEL_MESSAGES#other" = "%1$@|📷 Albums"; +"PUSH_CHANNEL_MESSAGES_TEXT#one" = "posted a message"; +"PUSH_CHANNEL_MESSAGES_TEXT#other" = "posted %d messages"; +"PUSH_CHANNEL_MESSAGE_AUDIO" = "%1$@|🎤 Voice message"; +"PUSH_CHANNEL_MESSAGE_CONTACT" = "%1$@|👤 %2$@"; +"PUSH_CHANNEL_MESSAGE_DOC" = "%1$@|📎 Document"; +"PUSH_CHANNEL_MESSAGE_DOCS" = "%1$@|posted %2$d files"; +"PUSH_CHANNEL_MESSAGE_DOCS_TEXT#one" = "posted a file"; +"PUSH_CHANNEL_MESSAGE_DOCS_TEXT#other" = "posted %d files"; +"PUSH_CHANNEL_MESSAGE_FWD" = "%1$@|Forwarded message"; +"PUSH_CHANNEL_MESSAGE_FWDS#one" = "%1$@|Forwarded message"; +"PUSH_CHANNEL_MESSAGE_FWDS#other" = "%1$@|%2$d forwarded messages"; +"PUSH_CHANNEL_MESSAGE_GAME" = "%1$@|🎮 Play %2$@"; +"PUSH_CHANNEL_MESSAGE_GEO" = "%1$@|📍 Location"; +"PUSH_CHANNEL_MESSAGE_GEOLIVE" = "%1$@|Live location"; +"PUSH_CHANNEL_MESSAGE_GIF" = "%1$@|📹 GIF"; +"PUSH_CHANNEL_MESSAGE_NOTEXT" = "%1$@|New message"; +"PUSH_CHANNEL_MESSAGE_PAID_MEDIA" = "%1$@ sent a paid post for %2$@ stars"; +"PUSH_CHANNEL_MESSAGE_PHOTO" = "%1$@|📷 Photo"; +"PUSH_CHANNEL_MESSAGE_PHOTOS#one" = "%1$@|📷 Photo"; +"PUSH_CHANNEL_MESSAGE_PHOTOS#other" = "%1$@|📷 %2$d photos"; +"PUSH_CHANNEL_MESSAGE_PHOTOS_TEXT#one" = "posted a photo"; +"PUSH_CHANNEL_MESSAGE_PHOTOS_TEXT#other" = "posted %d photos"; +"PUSH_CHANNEL_MESSAGE_POLL" = "%1$@|📊 Poll \"%2$@\""; +"PUSH_CHANNEL_MESSAGE_QUIZ" = "%1$@|posted the quiz \"%2$@\""; +"PUSH_CHANNEL_MESSAGE_ROUND" = "%1$@|📺 Video message"; +"PUSH_CHANNEL_MESSAGE_ROUNDS#one" = "%1$@|📺 Video message"; +"PUSH_CHANNEL_MESSAGE_ROUNDS#other" = "%1$@|📺 %2$d video messages"; +"PUSH_CHANNEL_MESSAGE_STICKER" = "%1$@|%2$@ Sticker"; +"PUSH_CHANNEL_MESSAGE_STORY" = "%1$@|shared a story"; +"PUSH_CHANNEL_MESSAGE_TEXT" = "%1$@|%2$@"; +"PUSH_CHANNEL_MESSAGE_TODO" = "%1$@|posted a checklist %2$@"; +"PUSH_CHANNEL_MESSAGE_TODO_APPEND" = "%1$@|added %2$@ tasks"; +"PUSH_CHANNEL_MESSAGE_TODO_DONE" = "%1$@|toggled %2$@ tasks"; +"PUSH_CHANNEL_MESSAGE_VIDEO" = "%1$@|📹 Video"; +"PUSH_CHANNEL_MESSAGE_VIDEOS#one" = "%1$@|📹 Video"; +"PUSH_CHANNEL_MESSAGE_VIDEOS#other" = "%1$@|📹 %2$d videos"; +"PUSH_CHANNEL_MESSAGE_VIDEOS_TEXT#one" = "posted a video"; +"PUSH_CHANNEL_MESSAGE_VIDEOS_TEXT#other" = "posted %d videos"; +"PUSH_CHAT_ADD_MEMBER" = "%2$@|%1$@ invited %3$@ to the group"; +"PUSH_CHAT_ADD_YOU" = "%2$@|%1$@ invited you to the group"; +"PUSH_CHAT_ALBUM" = "%2$@|%1$@: 📷📹 Album"; +"PUSH_CHAT_CREATED" = "%2$@|%1$@ invited you to the group"; +"PUSH_CHAT_DELETE_MEMBER" = "%2$@|%1$@ removed %3$@ from the group"; +"PUSH_CHAT_DELETE_YOU" = "%2$@|%1$@ removed you from the group"; +"PUSH_CHAT_JOINED" = "%2$@|%1$@ joined the group"; +"PUSH_CHAT_LEFT" = "%2$@|%1$@ left the group"; +"PUSH_CHAT_LIVESTREAM_END" = "%2$@|%1$@ ended the live stream"; +"PUSH_CHAT_LIVESTREAM_INVITE" = "%2$@|%1$@ invited %3$@ to the live stream"; +"PUSH_CHAT_LIVESTREAM_INVITE_YOU" = "%2$@|%1$@ invited you to the live stream"; +"PUSH_CHAT_LIVESTREAM_START" = "%2$@|%1$@ started a live stream"; +"PUSH_CHAT_MESSAGE" = "%2$@|%1$@: Message"; +"PUSH_CHAT_MESSAGES#one" = "%2$@|%1$@: 📷 Album"; +"PUSH_CHAT_MESSAGES#other" = "%2$@|%1$@: 📷 Albums"; +"PUSH_CHAT_MESSAGES_TEXT#one" = "{author} sent a message"; +"PUSH_CHAT_MESSAGES_TEXT#other" = "{author} sent %d messages"; +"PUSH_CHAT_MESSAGE_AUDIO" = "%2$@|%1$@: 🎤 Voice message"; +"PUSH_CHAT_MESSAGE_CONTACT" = "%2$@|%1$@: 👤 %3$@"; +"PUSH_CHAT_MESSAGE_DOC" = "%2$@|%1$@: 📎 Document"; +"PUSH_CHAT_MESSAGE_DOCS" = "%2$@|%1$@ sent %3$d files"; +"PUSH_CHAT_MESSAGE_DOCS_FIX1#one" = "%2$@|%1$@ sent a file"; +"PUSH_CHAT_MESSAGE_DOCS_FIX1#other" = "%2$@|%1$@ sent %3$d files"; +"PUSH_CHAT_MESSAGE_DOCS_TEXT#one" = "{author} sent a file"; +"PUSH_CHAT_MESSAGE_DOCS_TEXT#other" = "{author} sent %d files"; +"PUSH_CHAT_MESSAGE_FWD" = "%2$@|%1$@: Forwarded message"; +"PUSH_CHAT_MESSAGE_FWDS#one" = "%2$@|%1$@: Forwarded message"; +"PUSH_CHAT_MESSAGE_FWDS#other" = "%2$@|%1$@: %3$d forwarded messages"; +"PUSH_CHAT_MESSAGE_FWDS_TEXT#one" = "{author} forwarded a message"; +"PUSH_CHAT_MESSAGE_FWDS_TEXT#other" = "{author} forwarded %d messages"; +"PUSH_CHAT_MESSAGE_GAME" = "%2$@|🎮 %1$@ invited the group to play %3$@"; +"PUSH_CHAT_MESSAGE_GAME_SCORE" = "🎮 %1$@ scored %4$@ in %3$@ "; +"PUSH_CHAT_MESSAGE_GEO" = "%2$@|%1$@: 📍 Location"; +"PUSH_CHAT_MESSAGE_GEOLIVE" = "%2$@|%1$@: Live location"; +"PUSH_CHAT_MESSAGE_GIF" = "%2$@|%1$@: 📹 GIF"; +"PUSH_CHAT_MESSAGE_INVOICE" = "%2$@|%1$@: Invoice for %3$@"; +"PUSH_CHAT_MESSAGE_NOTEXT" = " %2$@|%1$@: New message"; +"PUSH_CHAT_MESSAGE_NOTHEME" = "%1$@|disabled the group theme %2$@"; +"PUSH_CHAT_MESSAGE_PAID_MEDIA" = "%1$@ sent a paid post to the group %2$@ for %3$@ stars"; +"PUSH_CHAT_MESSAGE_PHOTO" = "%2$@|%1$@: 📷 Photo"; +"PUSH_CHAT_MESSAGE_PHOTOS#one" = "%2$@|%1$@: 📷 Photo"; +"PUSH_CHAT_MESSAGE_PHOTOS#other" = "%2$@|%1$@: 📷 %3$d photos"; +"PUSH_CHAT_MESSAGE_PHOTOS_TEXT#one" = "{author} sent a photo"; +"PUSH_CHAT_MESSAGE_PHOTOS_TEXT#other" = "{author} sent %d photos"; +"PUSH_CHAT_MESSAGE_POLL" = "%2$@|%1$@: 📊 Poll \"%3$@\""; +"PUSH_CHAT_MESSAGE_QUIZ" = "%2$@|%1$@ sent the quiz \"%3$@\" to the group"; +"PUSH_CHAT_MESSAGE_ROUND" = "%2$@|%1$@: 📺 Video message"; +"PUSH_CHAT_MESSAGE_ROUNDS#one" = "%2$@|%1$@: 📺 Video message"; +"PUSH_CHAT_MESSAGE_ROUNDS#other" = "%2$@|%1$@: 📺 %3$d video messages"; +"PUSH_CHAT_MESSAGE_STICKER" = "%2$@|%1$@: %3$@ Sticker"; +"PUSH_CHAT_MESSAGE_STORY" = "%2$@|%1$@ shared a story to the group"; +"PUSH_CHAT_MESSAGE_TEXT" = "%2$@|%1$@: %3$@"; +"PUSH_CHAT_MESSAGE_THEME" = "%1$@|set theme to %3$@ in the group %2$@"; +"PUSH_CHAT_MESSAGE_TODO" = "%2$@|%1$@ sent a checklist %3$@"; +"PUSH_CHAT_MESSAGE_TODO_APPEND" = "%2$@|%1$@ added %3$@ tasks"; +"PUSH_CHAT_MESSAGE_TODO_DONE" = "%2$@|%1$@ toggled %3$@ tasks"; +"PUSH_CHAT_MESSAGE_VIDEO" = "%2$@|%1$@: 📹 Video"; +"PUSH_CHAT_MESSAGE_VIDEOS#one" = "%2$@|%1$@: 📹 Video"; +"PUSH_CHAT_MESSAGE_VIDEOS#other" = "%2$@|%1$@: 📹 %3$d videos"; +"PUSH_CHAT_MESSAGE_VIDEOS_TEXT#one" = "{author} sent a video"; +"PUSH_CHAT_MESSAGE_VIDEOS_TEXT#other" = "{author} sent %d videos"; +"PUSH_CHAT_PHOTO_EDITED" = "%2$@|%1$@ edited the group photo"; +"PUSH_CHAT_REACT_AUDIO" = "%2$@|%1$@: %3$@ to your voice message"; +"PUSH_CHAT_REACT_CONTACT" = "%2$@|%1$@: %3$@ to your contact %4$@"; +"PUSH_CHAT_REACT_DOC" = "%2$@|%1$@: %3$@ to your file"; +"PUSH_CHAT_REACT_GAME" = "%2$@|%1$@: %3$@ to your game"; +"PUSH_CHAT_REACT_GEO" = "%2$@|%1$@: %3$@ to your map"; +"PUSH_CHAT_REACT_GEOLIVE" = "%2$@|%1$@: %3$@ to your live location"; +"PUSH_CHAT_REACT_GIF" = "%2$@|%1$@: %3$@ to your GIF"; +"PUSH_CHAT_REACT_INVOICE" = "%2$@|%1$@: %3$@ to your invoice"; +"PUSH_CHAT_REACT_NOTEXT" = "%2$@|%1$@: %3$@ to your message"; +"PUSH_CHAT_REACT_PAID_MEDIA" = "%2$@|%1$@ %3$@ to your paid post"; +"PUSH_CHAT_REACT_PHOTO" = "%2$@|%1$@: %3$@ to your photo"; +"PUSH_CHAT_REACT_POLL" = "%2$@|%1$@: %3$@ to your poll \"%4$@\""; +"PUSH_CHAT_REACT_QUIZ" = "%2$@|%1$@: %3$@ to your quiz \"%4$@\""; +"PUSH_CHAT_REACT_ROUND" = "%2$@|%1$@: %3$@ to your video message"; +"PUSH_CHAT_REACT_STICKER" = "%2$@|%1$@: %3$@ to your %4$@sticker"; +"PUSH_CHAT_REACT_STORY" = "%2$@|%1$@ %3$@ to your story"; +"PUSH_CHAT_REACT_TEXT" = "%2$@|%1$@: %3$@ to your \"%4$@\""; +"PUSH_CHAT_REACT_TODO" = "%2$@|%1$@ reacted %3$@ to your todo %4$@"; +"PUSH_CHAT_REACT_VIDEO" = "%2$@|%1$@: %3$@ to your video"; +"PUSH_CHAT_REQ_JOINED" = "%2$@|%1$@ was accepted into the group"; +"PUSH_CHAT_RETURNED" = "%2$@|%1$@ returned to the group"; +"PUSH_CHAT_TITLE_EDITED" = "%2$@|%1$@ edited the group name"; +"PUSH_CHAT_VOICECHAT_END" = "%2$@|%1$@ ended the video chat"; +"PUSH_CHAT_VOICECHAT_INVITE" = "%2$@|%1$@ invited %3$@ to the video chat"; +"PUSH_CHAT_VOICECHAT_INVITE_YOU" = "%2$@|%1$@ invited you to the video chat"; +"PUSH_CHAT_VOICECHAT_START" = "%2$@|%1$@ started a video chat"; +"PUSH_CONTACT_JOINED" = "%1$@|joined Telegram!"; +"PUSH_ENCRYPTED_MESSAGE" = "You have a new message%1$@"; +"PUSH_ENCRYPTION_ACCEPT" = "Your secret chat was accepted%1$@"; +"PUSH_ENCRYPTION_REQUEST" = "New secret chat request%1$@"; +"PUSH_LOCKED_MESSAGE" = "You have a new message%1$@"; +"PUSH_MESSAGE" = "%1$@|New message"; +"PUSH_MESSAGES#one" = "%1$@|📷📹 Album"; +"PUSH_MESSAGES#other" = "%1$@|📷📹 Albums"; +"PUSH_MESSAGES_TEXT#one" = "sent you a message"; +"PUSH_MESSAGES_TEXT#other" = "sent you %d messages"; +"PUSH_MESSAGE_ALBUM" = "%1$@|📷 Album"; +"PUSH_MESSAGE_AUDIO" = "%1$@|🎤 Voice message"; +"PUSH_MESSAGE_CHANNEL_MESSAGE_GAME_SCORE" = "🎮 %1$@ scored %3$@ in %2$@"; +"PUSH_MESSAGE_CONTACT" = "%1$@|👤 %2$@"; +"PUSH_MESSAGE_DOC" = "%1$@|📎 Document"; +"PUSH_MESSAGE_DOCS" = "%1$@|sent you %2$d files"; +"PUSH_MESSAGE_FILES#one" = "%1$@|sent you a file"; +"PUSH_MESSAGE_FILES#other" = "%1$@|sent you %2$d files"; +"PUSH_MESSAGE_FILES_TEXT#one" = "sent you a file"; +"PUSH_MESSAGE_FILES_TEXT#other" = "sent you %d files"; +"PUSH_MESSAGE_FWD" = "%1$@|Forwarded message"; +"PUSH_MESSAGE_FWDS#one" = "%1$@|Forwarded message"; +"PUSH_MESSAGE_FWDS#other" = "%1$@|%2$d forwarded messages"; +"PUSH_MESSAGE_FWDS_TEXT#one" = "forwarded you a message"; +"PUSH_MESSAGE_FWDS_TEXT#other" = "forwarded you %d messages"; +"PUSH_MESSAGE_GAME" = "%1$@|🎮 Play %2$@"; +"PUSH_MESSAGE_GAME_SCORE" = "🎮 %1$@ scored %3$@ in %2$@"; +"PUSH_MESSAGE_GEO" = "%1$@|📍 Location"; +"PUSH_MESSAGE_GEOLIVE" = "%1$@|Live location"; +"PUSH_MESSAGE_GIF" = "%1$@|📹 GIF"; +"PUSH_MESSAGE_INVOICE" = "%1$@|Invoice for %2$@"; +"PUSH_MESSAGE_NOTEXT" = "%1$@|New message"; +"PUSH_MESSAGE_NOTHEME" = "%1$@|disabled the chat theme"; +"PUSH_MESSAGE_PAID_MEDIA" = "%1$@ sent you a paid post for %2$@ stars"; +"PUSH_MESSAGE_PHOTO" = "%1$@|📷 Photo"; +"PUSH_MESSAGE_PHOTOS#one" = "%1$@|📷 Photo"; +"PUSH_MESSAGE_PHOTOS#other" = "%1$@|📷 %2$d photos"; +"PUSH_MESSAGE_PHOTOS_TEXT#one" = "sent you a photo"; +"PUSH_MESSAGE_PHOTOS_TEXT#other" = "sent you %d photos"; +"PUSH_MESSAGE_PHOTO_SECRET" = "%1$@|🔥 Self-destructing photo"; +"PUSH_MESSAGE_POLL" = "%1$@|📊 Poll \"%2$@\""; +"PUSH_MESSAGE_QUIZ" = "%1$@|sent you the quiz \"%2$@\""; +"PUSH_MESSAGE_RECURRING_PAY" = "%1$@|You were charged %2$@"; +"PUSH_MESSAGE_ROUND" = "%1$@|📺 Video message"; +"PUSH_MESSAGE_ROUNDS#one" = "%1$@|📺 Video message"; +"PUSH_MESSAGE_ROUNDS#other" = "%1$@|📺 %2$d video messages"; +"PUSH_MESSAGE_SAME_WALLPAPER" = "%1$@ set the same wallpaper for this chat."; +"PUSH_MESSAGE_SCREENSHOT" = "%1$@|took a screenshot!"; +"PUSH_MESSAGE_STARGIFT" = "%1$@|sent you a Gift worth of %2$@ stars"; +"PUSH_MESSAGE_STARGIFT_UPGRADE" = "%1$@ upgraded your Gift"; +"PUSH_MESSAGE_STICKER" = "%1$@|%2$@ Sticker"; +"PUSH_MESSAGE_STORY" = "%1$@|shared a story with you"; +"PUSH_MESSAGE_STORY_MENTION" = "%1$@|mentioned you in a story"; +"PUSH_MESSAGE_SUGGEST_USERPIC" = "%1$@|suggested a photo for your profile"; +"PUSH_MESSAGE_TEXT" = "%1$@|%2$@"; +"PUSH_MESSAGE_THEME" = "%1$@|changed theme to %2$@"; +"PUSH_MESSAGE_TODO" = "%1$@|sent you a checklist %2$@"; +"PUSH_MESSAGE_UNIQUE_STARGIFT" = "%1$@ sent you a Gift"; +"PUSH_MESSAGE_VIDEO" = "%1$@|📹 Video"; +"PUSH_MESSAGE_VIDEOS#one" = "%1$@|📹 Video"; +"PUSH_MESSAGE_VIDEOS#other" = "%1$@|📹 %2$d videos"; +"PUSH_MESSAGE_VIDEOS_TEXT#one" = "sent you a video"; +"PUSH_MESSAGE_VIDEOS_TEXT#other" = "sent you %d videos"; +"PUSH_MESSAGE_VIDEO_SECRET" = "%1$@|🔥 Self-destructing video"; +"PUSH_MESSAGE_WALLPAPER" = "%1$@ set a new wallpaper for the chat with you"; +"PUSH_PHONE_CALL_MISSED" = "%1$@|☎️ Missed call"; +"PUSH_PHONE_CALL_REQUEST" = "%1$@|Incoming call!"; +"PUSH_PINNED_AUDIO" = "%1$@|📌 Pinned a voice message"; +"PUSH_PINNED_CONTACT" = "%1$@|📌 Pinned contact: %2$@"; +"PUSH_PINNED_DOC" = "%1$@|📌 Pinned a document"; +"PUSH_PINNED_GAME" = "%1$@|📌 Pinned a game"; +"PUSH_PINNED_GAME_SCORE" = "%1$@ pinned a game score"; +"PUSH_PINNED_GEO" = "%1$@|📌 Pinned a location"; +"PUSH_PINNED_GEOLIVE" = "%1$@|📌 Pinned a live location"; +"PUSH_PINNED_GIF" = "%1$@|📌 Pinned a GIF"; +"PUSH_PINNED_INVOICE" = "%1$@|📌 Pinned an invoice"; +"PUSH_PINNED_NOTEXT" = "%1$@|📌 Pinned a message"; +"PUSH_PINNED_PAID_MEDIA" = "%1$@|pinned a paid post for %2$@"; +"PUSH_PINNED_PHOTO" = "%1$@|📌 Pinned a photo"; +"PUSH_PINNED_POLL" = "%1$@|📌 Pinned a poll"; +"PUSH_PINNED_QUIZ" = "%1$@|pinned a quiz"; +"PUSH_PINNED_ROUND" = "%1$@|📌 Pinned a video message"; +"PUSH_PINNED_STICKER" = "%1$@|📌 Pinned a %2$@ sticker"; +"PUSH_PINNED_TEXT" = "%1$@|📌 Pinned \"%2$@\""; +"PUSH_PINNED_TODO" = "%1$@|pinned a checklist %2$@"; +"PUSH_PINNED_VIDEO" = "%1$@|📌 Pinned a video"; +"PUSH_REACT_AUDIO" = "%1$@|%2$@ to your voice message"; +"PUSH_REACT_CONTACT" = "%1$@|%2$@ to your contact %3$@"; +"PUSH_REACT_DOC" = "%1$@|%2$@ to your file"; +"PUSH_REACT_GAME" = "%1$@|%2$@ to your game"; +"PUSH_REACT_GEO" = "%1$@|%2$@ to your map"; +"PUSH_REACT_GEOLIVE" = "%1$@|%2$@ to your live location"; +"PUSH_REACT_GIF" = "%1$@|%2$@ to your GIF"; +"PUSH_REACT_HIDDEN" = "New reaction to your message"; +"PUSH_REACT_INVOICE" = "%1$@|%2$@ to your invoice"; +"PUSH_REACT_NOTEXT" = "%1$@|%2$@ to your message"; +"PUSH_REACT_PHOTO" = "%1$@|%2$@ to your photo"; +"PUSH_REACT_POLL" = "%1$@|%2$@ to your poll \"%3$@\""; +"PUSH_REACT_QUIZ" = "%1$@|%2$@ to your quiz \"%3$@\""; +"PUSH_REACT_ROUND" = "%1$@|%2$@ to your video message"; +"PUSH_REACT_STICKER" = "%1$@|%2$@ to your %3$@sticker"; +"PUSH_REACT_STORY" = "%1$@|%2$@ to your story"; +"PUSH_REACT_STORY_HIDDEN" = "New reaction to your story"; +"PUSH_REACT_STORY_NAME" = "%1$@|%3$@ %2$@ to your story"; +"PUSH_REACT_TEXT" = "%1$@|%2$@ to your \"%3$@\""; +"PUSH_REACT_TODO" = "%1$@|reacted %2$@ to your todo %3$@"; +"PUSH_REACT_VIDEO" = "%1$@|%2$@ to your video"; +"PUSH_REMINDER_TITLE" = "🗓 Reminder"; +"PUSH_SENDER_YOU" = "📅 You"; +"PUSH_STORY_HIDDEN_AUTHOR" = "New story was posted"; +"PUSH_STORY_NOTEXT" = "%1$@|posted a story"; +"PUSH_VIDEO_CALL_MISSED" = "%1$@|You missed a video call"; +"PUSH_VIDEO_CALL_REQUEST" = "%1$@|is calling you!"; +"Paint.Arrow" = "Arrow"; +"Paint.Bubble" = "Bubble"; +"Paint.Clear" = "Clear All"; +"Paint.ClearConfirm" = "Clear Painting"; +"Paint.ColorGrid" = "Grid"; +"Paint.ColorOpacity" = "OPACITY"; +"Paint.ColorSliders" = "Sliders"; +"Paint.ColorSpectrum" = "Spectrum"; +"Paint.ColorTitle" = "Colors"; +"Paint.CutOut" = "Cut Out"; +"Paint.Delete" = "Delete"; +"Paint.Draw" = "Draw"; +"Paint.Duplicate" = "Duplicate"; +"Paint.Edit" = "Edit"; +"Paint.Ellipse" = "Ellipse"; +"Paint.Flip" = "Flip"; +"Paint.Framed" = "Framed"; +"Paint.Marker" = "Marker"; +"Paint.Masks" = "Masks"; +"Paint.MoveForward" = "Move Forward"; +"Paint.Neon" = "Neon"; +"Paint.Outlined" = "Outlined"; +"Paint.Pen" = "Pen"; +"Paint.RecentStickers" = "Recent"; +"Paint.Rectangle" = "Rectangle"; +"Paint.Regular" = "Regular"; +"Paint.Star" = "Star"; +"Paint.Sticker" = "Sticker"; +"Paint.Stickers" = "Stickers"; +"Paint.Text" = "Text"; +"Paint.ZoomOut" = "Zoom Out"; +"Passcode.AppLockedAlert" = "Telegram\nLocked"; +"PasscodeSettings.4DigitCode" = "4-Digit Numeric Code"; +"PasscodeSettings.6DigitCode" = "6-Digit Numeric Code"; +"PasscodeSettings.AlphanumericCode" = "Custom Alphanumeric Code"; +"PasscodeSettings.AutoLock" = "Auto-Lock"; +"PasscodeSettings.AutoLock.Disabled" = "Disabled"; +"PasscodeSettings.AutoLock.IfAwayFor_1hour" = "If away for 1 hour"; +"PasscodeSettings.AutoLock.IfAwayFor_1minute" = "If away for 1 min"; +"PasscodeSettings.AutoLock.IfAwayFor_5hours" = "If away for 5 hours"; +"PasscodeSettings.AutoLock.IfAwayFor_5minutes" = "If away for 5 min"; +"PasscodeSettings.ChangePasscode" = "Change Passcode"; +"PasscodeSettings.DoNotMatch" = "Passcodes don't match. Please try again."; +"PasscodeSettings.EncryptData" = "Encrypt Local Database"; +"PasscodeSettings.EncryptDataHelp" = "Experimental feature, use with caution. Encrypt your local Telegram data, using a derivative of your passcode as the key."; +"PasscodeSettings.FailedAttempts#one" = "%@ Failed Passcode Attempt"; +"PasscodeSettings.FailedAttempts#other" = "%@ Failed Passcode Attempts"; +"PasscodeSettings.Help" = "When a passcode is set, a lock icon appears above your chat list. Tap it to lock your Telegram app.\n\nNote: if you forget your passcode, you'll need to reinstall the app. All your Secret Chats will be lost."; +"PasscodeSettings.HelpBottom" = "Note: if you forget your passcode, you'll need to reinstall the app. All secret chats will be lost."; +"PasscodeSettings.HelpTop" = "When a passcode is set, a lock icon appears above your chat list. Tap it to lock your Telegram app."; +"PasscodeSettings.PasscodeOptions" = "Passcode Options"; +"PasscodeSettings.SimplePasscode" = "Simple Passcode"; +"PasscodeSettings.SimplePasscodeHelp" = "A simple passcode is a 4 digit number."; +"PasscodeSettings.Title" = "Passcode Lock"; +"PasscodeSettings.TryAgainIn1Minute" = "Try again in 1 minute"; +"PasscodeSettings.TurnPasscodeOff" = "Turn Passcode Off"; +"PasscodeSettings.TurnPasscodeOn" = "Turn Passcode On"; +"PasscodeSettings.UnlockWithFaceId" = "Unlock with Face ID"; +"PasscodeSettings.UnlockWithTouchId" = "Unlock with Touch ID"; +"Passkeys.AddPasskey" = "Add Passkey"; +"Passkeys.ButtonCreate" = "Create Passkey"; +"Passkeys.ButtonSkip" = "Skip"; +"Passkeys.DeleteAlert.Action" = "Delete"; +"Passkeys.DeleteAlert.Text" = "Once deleted, this passkey can't be used to log in.\n\nDon't forget to remove it from your password manager too."; +"Passkeys.DeleteAlert.Title" = "Delete Passkey?"; +"Passkeys.EmptyName" = "Passkey"; +"Passkeys.Into.Text0" = "Make a passkey to sign in easily and safely."; +"Passkeys.Into.Text1" = "Use Face ID, Touch ID or your passcode to sign in."; +"Passkeys.Into.Text2" = "Your passkey is safely kept in your iCloud Keychain."; +"Passkeys.Into.Title" = "Protect your account"; +"Passkeys.Into.Title0" = "Create a Passkey"; +"Passkeys.Into.Title1" = "Log in with Face ID"; +"Passkeys.Into.Title2" = "Store Passkey Securely"; +"Passkeys.ListFooter" = "Your passkeys are stored securely in your password manager."; +"Passkeys.PasskeyCreatedAndUsedPattern" = "created %1$@ • used %2$@"; +"Passkeys.PasskeyCreatedPattern" = "created %@"; +"Passkeys.Subtitle" = "Log in safely and keep your account secure."; +"Passkeys.Title" = "Passkeys"; +"Passport.AcceptHelp" = "You are sending your documents directly to %1$@ and allowing their @%2$@ to send you messages."; +"Passport.Address.AddBankStatement" = "Add Bank Statement"; +"Passport.Address.AddPassportRegistration" = "Add Passport Registration"; +"Passport.Address.AddRentalAgreement" = "Add Tenancy Agreement"; +"Passport.Address.AddResidentialAddress" = "Add Residential Address"; +"Passport.Address.AddTemporaryRegistration" = "Add Temporary Registration"; +"Passport.Address.AddUtilityBill" = "Add Utility Bill"; +"Passport.Address.Address" = "ADDRESS"; +"Passport.Address.City" = "City"; +"Passport.Address.CityPlaceholder" = "City"; +"Passport.Address.Country" = "Country"; +"Passport.Address.CountryPlaceholder" = "Country"; +"Passport.Address.EditBankStatement" = "Edit Bank Statement"; +"Passport.Address.EditPassportRegistration" = "Edit Passport Registration"; +"Passport.Address.EditRentalAgreement" = "Edit Tenancy Agreement"; +"Passport.Address.EditResidentialAddress" = "Edit Residential Address"; +"Passport.Address.EditTemporaryRegistration" = "Edit Temporary Registration"; +"Passport.Address.EditUtilityBill" = "Edit Utility Bill"; +"Passport.Address.OneOfTypeBankStatement" = "bank statement"; +"Passport.Address.OneOfTypePassportRegistration" = "passport registration"; +"Passport.Address.OneOfTypeRentalAgreement" = "tenancy agreement"; +"Passport.Address.OneOfTypeTemporaryRegistration" = "temporary registration"; +"Passport.Address.OneOfTypeUtilityBill" = "utility bill"; +"Passport.Address.Postcode" = "Postcode"; +"Passport.Address.PostcodePlaceholder" = "Postcode"; +"Passport.Address.Region" = "Region"; +"Passport.Address.RegionPlaceholder" = "State / Province / Region"; +"Passport.Address.ScansHelp" = "The document must contain your full name and residential address, as well as a stamp/bar code/QR code/logo and the issue date. It must be less than three months old."; +"Passport.Address.Street" = "Street"; +"Passport.Address.Street1Placeholder" = "Street and number, P.O. box"; +"Passport.Address.Street2Placeholder" = "Apt., suite, unit, building, floor"; +"Passport.Address.TypeBankStatement" = "Bank Statement"; +"Passport.Address.TypeBankStatementUploadScan" = "Upload a scan of your bank statement"; +"Passport.Address.TypePassportRegistration" = "Passport Registration"; +"Passport.Address.TypePassportRegistrationUploadScan" = "Upload a scan of your passport registration"; +"Passport.Address.TypeRentalAgreement" = "Rental Agreement"; +"Passport.Address.TypeRentalAgreementUploadScan" = "Upload a scan of your tenancy agreement"; +"Passport.Address.TypeResidentialAddress" = "Residential Address"; +"Passport.Address.TypeTemporaryRegistration" = "Temporary Registration"; +"Passport.Address.TypeTemporaryRegistrationUploadScan" = "Upload a scan of your temporary registration"; +"Passport.Address.TypeUtilityBill" = "Utility Bill"; +"Passport.Address.TypeUtilityBillUploadScan" = "Upload a scan of your utility bill"; +"Passport.Address.UploadOneOfScan" = "Upload a scan of your %@"; +"Passport.Authorize" = "Authorize"; +"Passport.CorrectErrors" = "Tap to correct errors"; +"Passport.DeleteAddress" = "Delete Address"; +"Passport.DeleteAddressConfirmation" = "Are you sure you want to delete your address?"; +"Passport.DeleteDocument" = "Delete Document"; +"Passport.DeleteDocumentConfirmation" = "Are you sure you want to delete this document? All details will be lost."; +"Passport.DeletePassport" = "Delete Telegram Passport"; +"Passport.DeletePassportConfirmation" = "Are you sure you want to delete your Telegram Passport? All data will be lost."; +"Passport.DeletePersonalDetails" = "Delete Personal Details"; +"Passport.DeletePersonalDetailsConfirmation" = "Are you sure you want to delete personal details?"; +"Passport.DiscardMessageAction" = "Discard"; +"Passport.DiscardMessageDescription" = "Are you sure you want to discard all changes?"; +"Passport.DiscardMessageTitle" = "Discard Changes"; +"Passport.Email.CodeHelp" = "Please enter the confirmation code we've just sent to %@"; +"Passport.Email.Delete" = "Delete Email Address"; +"Passport.Email.EmailPlaceholder" = "Enter your email address"; +"Passport.Email.EnterOtherEmail" = "OR ENTER A NEW EMAIL ADDRESS"; +"Passport.Email.Help" = "A confirmation code will be sent to the email you provide."; +"Passport.Email.Title" = "Email"; +"Passport.Email.UseTelegramEmail" = "Use %@"; +"Passport.Email.UseTelegramEmailHelp" = "Use the same address as on Telegram."; +"Passport.FieldAddress" = "Residential Address"; +"Passport.FieldAddressHelp" = "Provide your address"; +"Passport.FieldAddressTranslationHelp" = "Add an English translation of your document"; +"Passport.FieldAddressUploadHelp" = "Upload proof of your address"; +"Passport.FieldEmail" = "Email Address"; +"Passport.FieldEmailHelp" = "Provide your contact email address"; +"Passport.FieldIdentity" = "Identity Document"; +"Passport.FieldIdentityDetailsHelp" = "Fill in your personal details"; +"Passport.FieldIdentitySelfieHelp" = "Take a selfie with your document"; +"Passport.FieldIdentityTranslationHelp" = "Add an English translation of your document"; +"Passport.FieldIdentityUploadHelp" = "Upload proof of your identity"; +"Passport.FieldOneOf.Delimeter" = ", "; +"Passport.FieldOneOf.FinalDelimeter" = " or "; +"Passport.FieldOneOf.Or" = "%1$@ or %2$@"; +"Passport.FieldPhone" = "Phone Number"; +"Passport.FieldPhoneHelp" = "Provide your contact phone number"; +"Passport.FloodError" = "Limit exceeded. Please try again later."; +"Passport.ForgottenPassword" = "Forgotten Password"; +"Passport.Identity.AddDriversLicense" = "Add Driver's License"; +"Passport.Identity.AddIdentityCard" = "Add Identity Card"; +"Passport.Identity.AddInternalPassport" = "Add Internal Passport"; +"Passport.Identity.AddPassport" = "Add Passport"; +"Passport.Identity.AddPersonalDetails" = "Add Personal Details"; +"Passport.Identity.Country" = "Citizenship"; +"Passport.Identity.CountryPlaceholder" = "Citizenship"; +"Passport.Identity.DateOfBirth" = "Date of Birth"; +"Passport.Identity.DateOfBirthPlaceholder" = "Date of Birth"; +"Passport.Identity.DocumentDetails" = "DOCUMENT DETAILS"; +"Passport.Identity.DocumentNumber" = "Document #"; +"Passport.Identity.DocumentNumberPlaceholder" = "Document Number"; +"Passport.Identity.DoesNotExpire" = "Does Not Expire"; +"Passport.Identity.EditDriversLicense" = "Edit Driver's License"; +"Passport.Identity.EditIdentityCard" = "Edit Identity Card"; +"Passport.Identity.EditInternalPassport" = "Edit Internal Passport"; +"Passport.Identity.EditPassport" = "Edit Passport"; +"Passport.Identity.EditPersonalDetails" = "Edit Personal Details"; +"Passport.Identity.ExpiryDate" = "Expiry Date"; +"Passport.Identity.ExpiryDateNone" = "None"; +"Passport.Identity.ExpiryDatePlaceholder" = "Expiry Date"; +"Passport.Identity.FilesTitle" = "REQUESTED FILES"; +"Passport.Identity.FilesUploadNew" = "Upload New"; +"Passport.Identity.FilesView" = "View"; +"Passport.Identity.FrontSide" = "Front Side"; +"Passport.Identity.FrontSideHelp" = "Upload a photo of the front side of the document"; +"Passport.Identity.Gender" = "Gender"; +"Passport.Identity.GenderFemale" = "Female"; +"Passport.Identity.GenderMale" = "Male"; +"Passport.Identity.GenderPlaceholder" = "Gender"; +"Passport.Identity.IssueDate" = "Issue Date"; +"Passport.Identity.IssueDatePlaceholder" = "Issue Date"; +"Passport.Identity.LatinNameHelp" = "Enter your name using Latin characters."; +"Passport.Identity.MainPage" = "Main Page"; +"Passport.Identity.MainPageHelp" = "Upload a photo of the main page of the document"; +"Passport.Identity.MiddleName" = "Middle Name"; +"Passport.Identity.MiddleNamePlaceholder" = "Middle Name"; +"Passport.Identity.Name" = "First Name"; +"Passport.Identity.NamePlaceholder" = "First Name"; +"Passport.Identity.NativeNameGenericHelp" = "Your name in the language of your country of residence (%@)."; +"Passport.Identity.NativeNameGenericTitle" = "NAME IN COUNTRY OF RESIDENCE"; +"Passport.Identity.NativeNameHelp" = "Your name in the language of your country of residence."; +"Passport.Identity.NativeNameTitle" = "YOUR NAME IN %@"; +"Passport.Identity.OneOfTypeDriversLicense" = "driver's license"; +"Passport.Identity.OneOfTypeIdentityCard" = "identity card"; +"Passport.Identity.OneOfTypeInternalPassport" = "internal passport"; +"Passport.Identity.OneOfTypePassport" = "passport"; +"Passport.Identity.ResidenceCountry" = "Residence"; +"Passport.Identity.ResidenceCountryPlaceholder" = "Residence"; +"Passport.Identity.ReverseSide" = "Reverse Side"; +"Passport.Identity.ReverseSideHelp" = "Upload a photo of the reverse side of the document"; +"Passport.Identity.ScansHelp" = "The document must contain your photograph, first and last name, date of birth, document number, country of issue, and expiry date."; +"Passport.Identity.Selfie" = "Selfie"; +"Passport.Identity.SelfieHelp" = "Upload a selfie holding this document"; +"Passport.Identity.Surname" = "Last Name"; +"Passport.Identity.SurnamePlaceholder" = "Last Name"; +"Passport.Identity.Translation" = "Translation"; +"Passport.Identity.TranslationHelp" = "Upload a translation of this document"; +"Passport.Identity.Translations" = "TRANSLATION"; +"Passport.Identity.TranslationsHelp" = "Upload scans of a certified English translation of the document."; +"Passport.Identity.TypeDriversLicense" = "Driver's License"; +"Passport.Identity.TypeDriversLicenseUploadScan" = "Upload a scan of your driver's license"; +"Passport.Identity.TypeIdentityCard" = "Identity Card"; +"Passport.Identity.TypeIdentityCardUploadScan" = "Upload a scan of your identity card"; +"Passport.Identity.TypeInternalPassport" = "Internal Passport"; +"Passport.Identity.TypeInternalPassportUploadScan" = "Upload a scan of your internal passport"; +"Passport.Identity.TypePassport" = "Passport"; +"Passport.Identity.TypePassportUploadScan" = "Upload a scan of your passport"; +"Passport.Identity.TypePersonalDetails" = "Personal Details"; +"Passport.Identity.UploadOneOfScan" = "Upload a scan of your %@"; +"Passport.InfoFAQ_URL" = "https://telegram.org/faq#passport"; +"Passport.InfoLearnMore" = "Learn More"; +"Passport.InfoText" = "With **Telegram Passport** you can easily sign up for websites and services that require identity verification.\n\nYour information, personal data, and documents are protected by end-to-end encryption. Nobody, including Telegram, can access them without your permission."; +"Passport.InfoTitle" = "What is Telegram Passport?"; +"Passport.InvalidPasswordError" = "Invalid password. Please try again."; +"Passport.Language.ar" = "Arabic"; +"Passport.Language.az" = "Azerbaijani"; +"Passport.Language.bg" = "Bulgarian"; +"Passport.Language.bn" = "Bangla"; +"Passport.Language.cs" = "Czech"; +"Passport.Language.da" = "Danish"; +"Passport.Language.de" = "German"; +"Passport.Language.dv" = "Divehi"; +"Passport.Language.dz" = "Dzongkha"; +"Passport.Language.el" = "Greek"; +"Passport.Language.en" = "English"; +"Passport.Language.es" = "Spanish"; +"Passport.Language.et" = "Estonian"; +"Passport.Language.fa" = "Persian"; +"Passport.Language.fr" = "French"; +"Passport.Language.he" = "Hebrew"; +"Passport.Language.hr" = "Croatian"; +"Passport.Language.hu" = "Hungarian"; +"Passport.Language.hy" = "Armenian"; +"Passport.Language.id" = "Indonesian"; +"Passport.Language.is" = "Icelandic"; +"Passport.Language.it" = "Italian"; +"Passport.Language.ja" = "Japanese"; +"Passport.Language.ka" = "Georgian"; +"Passport.Language.km" = "Khmer"; +"Passport.Language.ko" = "Korean"; +"Passport.Language.lo" = "Lao"; +"Passport.Language.lt" = "Lithuanian"; +"Passport.Language.lv" = "Latvian"; +"Passport.Language.mk" = "Macedonian"; +"Passport.Language.mn" = "Mongolian"; +"Passport.Language.ms" = "Malay"; +"Passport.Language.my" = "Burmese"; +"Passport.Language.ne" = "Nepali"; +"Passport.Language.nl" = "Dutch"; +"Passport.Language.pl" = "Polish"; +"Passport.Language.pt" = "Portuguese"; +"Passport.Language.ro" = "Romanian"; +"Passport.Language.ru" = "Russian"; +"Passport.Language.sk" = "Slovak"; +"Passport.Language.sl" = "Slovenian"; +"Passport.Language.th" = "Thai"; +"Passport.Language.tk" = "Turkmen"; +"Passport.Language.tr" = "Turkish"; +"Passport.Language.uk" = "Ukrainian"; +"Passport.Language.uz" = "Uzbek"; +"Passport.Language.vi" = "Vietnamese"; +"Passport.NotLoggedInMessage" = "Please log in to your account to use Telegram Passport"; +"Passport.PassportInformation" = "PASSPORT INFORMATION"; +"Passport.PasswordCompleteSetup" = "Complete Password Setup"; +"Passport.PasswordCreate" = "Create a Password"; +"Passport.PasswordDescription" = "Please create a password to secure your personal data with end-to-end encryption.\n\nThis password will also be required whenever you log in to Telegram on a new device."; +"Passport.PasswordHelp" = "Enter your Telegram Password\nto decrypt your data"; +"Passport.PasswordNext" = "Next"; +"Passport.PasswordPlaceholder" = "Enter your password"; +"Passport.PasswordReset" = "All documents uploaded to your Telegram Passport will be lost. You will be able to upload new documents."; +"Passport.Phone.Delete" = "Delete Phone Number"; +"Passport.Phone.EnterOtherNumber" = "OR ENTER NEW PHONE NUMBER"; +"Passport.Phone.Help" = "Note: You will receive a confirmation code on the phone number you provide."; +"Passport.Phone.Title" = "Phone Number"; +"Passport.Phone.UseTelegramNumber" = "Use %@"; +"Passport.Phone.UseTelegramNumberHelp" = "Use the same phone number as on Telegram."; +"Passport.PrivacyPolicy" = "You accept the [%1$@ Privacy Policy] and allow their @%2$@ to send you messages."; +"Passport.RequestHeader" = "%@ requests access to your personal data to sign you up for their services."; +"Passport.RequestedInformation" = "REQUESTED INFORMATION"; +"Passport.ScanPassport" = "Scan Your Passport"; +"Passport.ScanPassportHelp" = "Scan your passport or identity card with machine-readable zone to fill personal details automatically."; +"Passport.Scans" = "SCANS"; +"Passport.Scans.ScanIndex" = "Scan %@"; +"Passport.Scans.Upload" = "Upload Scan"; +"Passport.Scans.UploadNew" = "Upload Additional Scan"; +"Passport.ScansHeader" = "SCANS"; +"Passport.Title" = "Passport"; +"Passport.UpdateRequiredError" = "Sorry, your Telegram app is out of date and can't handle this request. Please update Telegram."; +"PassportKit.Button.GetTelegramAlertInstall" = "Install"; +"PassportKit.Button.GetTelegramAlertMessage" = "You need to have Telegram Messenger installed to use Telegram Passport"; +"PassportKit.Button.GetTelegramAlertNotNow" = "Not Now"; +"PassportKit.Button.GetTelegramAlertTitle" = "Get Telegram Messenger"; +"PassportKit.Button.Title" = "Log in with Telegram"; +"Peer.DeletedUser" = "Deleted User"; +"PeerInfo.AddNotesInfo" = "Notes are only visible to you."; +"PeerInfo.AddNotesPlaceholder" = "Add Notes"; +"PeerInfo.AddToContacts" = "Add to Contacts"; +"PeerInfo.AdjustAutoDelete" = "Adjust Auto-Delete"; +"PeerInfo.AlertLeaveAction" = "Leave"; +"PeerInfo.AllowChannelMessages" = "Direct Messages"; +"PeerInfo.AllowChannelMessages.Free" = "Free"; +"PeerInfo.AllowChannelMessages.Off" = "Off"; +"PeerInfo.AllowedReactions.AllowAllChannelInfo" = "Allow subscribers to react to channel posts."; +"PeerInfo.AllowedReactions.AllowAllGroupInfo" = "Allow members to react to group messages."; +"PeerInfo.AllowedReactions.AllowAllText" = "Enable Reactions"; +"PeerInfo.AllowedReactions.GroupOptionAllInfo" = "Members of the group can use any emoji as reactions to messages."; +"PeerInfo.AllowedReactions.GroupOptionNoInfo" = "Members of the group can't add any reactions to messages."; +"PeerInfo.AllowedReactions.GroupOptionSomeInfo" = "Members of the group can use only certain approved emoji as reactions to messages."; +"PeerInfo.AllowedReactions.MaxCountSectionFooter" = "Limit the number of different reactions that can be added to a post, including already published posts."; +"PeerInfo.AllowedReactions.MaxCountSectionTitle" = "MAXIMUM REACTIONS PER POST"; +"PeerInfo.AllowedReactions.MaxCountValue#one" = "1 reaction"; +"PeerInfo.AllowedReactions.MaxCountValue#other" = "%d reactions"; +"PeerInfo.AllowedReactions.OptionAllReactions" = "All Reactions"; +"PeerInfo.AllowedReactions.OptionNoReactions" = "No Reactions"; +"PeerInfo.AllowedReactions.OptionSomeReactions" = "Some Reactions"; +"PeerInfo.AllowedReactions.ReactionListHeader" = "AVAILABLE REACTIONS"; +"PeerInfo.AllowedReactions.StarReactions" = "Enable Paid Reactions"; +"PeerInfo.AllowedReactions.StarReactionsFooter" = "Switch this on to let your subscribers react to posts with Telegram Stars, which you will be able to withdraw as Grams. [Learn More >](https://telegram.org/tos/content-creator-rewards#2-3-subscriber-donations)"; +"PeerInfo.AllowedReactions.Title" = "Reactions"; +"PeerInfo.AppFooter" = "By launching this mini app, you agree to the [Terms of Service for Mini Apps](https://telegram.org/tos/mini-apps)."; +"PeerInfo.AppFooterAdmin" = "By publishing this mini app, you agree to the [Telegram Terms of Service for Developers](https://telegram.org/tos/bot-developers)."; +"PeerInfo.AutoDeleteDisable" = "Disable"; +"PeerInfo.AutoDeleteInfo" = "Automatically delete new messages sent in this chat after a certain period of time."; +"PeerInfo.AutoDeleteSettingOther" = "Other..."; +"PeerInfo.AutoremoveMessages" = "Auto-Delete Messages"; +"PeerInfo.AutoremoveMessagesDisabled" = "Never"; +"PeerInfo.BioExpand" = "more"; +"PeerInfo.Bot.Balance" = "Balance"; +"PeerInfo.Bot.Balance.Stars#one" = "%@ Star"; +"PeerInfo.Bot.Balance.Stars#other" = "%@ Stars"; +"PeerInfo.Bot.BotFatherInfo" = "Use [@BotFather]() to manage this bot."; +"PeerInfo.Bot.ChangeSettings" = "Change Bot Settings"; +"PeerInfo.Bot.EditCommands" = "Edit Commands"; +"PeerInfo.Bot.EditIntro" = "Edit Intro"; +"PeerInfo.Bot.Username" = "Username"; +"PeerInfo.BotBalance.Stars" = "Stars"; +"PeerInfo.BotBalance.Title" = "BALANCE"; +"PeerInfo.BotBalance.Ton" = "Grams"; +"PeerInfo.BotBlockedText" = "This bot will not be able to message you."; +"PeerInfo.BotBlockedTitle" = "Bot Blocked"; +"PeerInfo.BotLinks" = "Public Links"; +"PeerInfo.BusinessHours.DayClosed" = "closed"; +"PeerInfo.BusinessHours.DayOpen24h" = "open 24 hours"; +"PeerInfo.BusinessHours.Label" = "business hours"; +"PeerInfo.BusinessHours.StatusClosed" = "Closed"; +"PeerInfo.BusinessHours.StatusOpen" = "Open"; +"PeerInfo.BusinessHours.StatusOpensInDays#one" = "Opens in 1 day"; +"PeerInfo.BusinessHours.StatusOpensInDays#other" = "Opens in %d days"; +"PeerInfo.BusinessHours.StatusOpensInHours#one" = "Opens in one hour"; +"PeerInfo.BusinessHours.StatusOpensInHours#other" = "Opens in %d hours"; +"PeerInfo.BusinessHours.StatusOpensInMinutes#one" = "Opens in one minute"; +"PeerInfo.BusinessHours.StatusOpensInMinutes#other" = "Opens in %d minutes"; +"PeerInfo.BusinessHours.StatusOpensOnDate" = "Opens %@"; +"PeerInfo.BusinessHours.StatusOpensTodayAt" = "Opens today at %@"; +"PeerInfo.BusinessHours.StatusOpensTomorrowAt" = "Opens tomorrow at %@"; +"PeerInfo.BusinessHours.TimezoneSwitchBusiness" = "local time"; +"PeerInfo.BusinessHours.TimezoneSwitchMy" = "my time"; +"PeerInfo.ButtonAddMember" = "Add Members"; +"PeerInfo.ButtonCall" = "Call"; +"PeerInfo.ButtonDiscuss" = "Discuss"; +"PeerInfo.ButtonLeave" = "Leave"; +"PeerInfo.ButtonLiveStream" = "Live Stream"; +"PeerInfo.ButtonMessage" = "Message"; +"PeerInfo.ButtonMore" = "More"; +"PeerInfo.ButtonMute" = "Mute"; +"PeerInfo.ButtonSearch" = "Search"; +"PeerInfo.ButtonStop" = "Block"; +"PeerInfo.ButtonUnmute" = "Unmute"; +"PeerInfo.ButtonVideoCall" = "Video"; +"PeerInfo.ButtonVoiceChat" = "Video Chat"; +"PeerInfo.CancelSelectionAlertNo" = "No"; +"PeerInfo.CancelSelectionAlertText" = "Cancel selection?"; +"PeerInfo.CancelSelectionAlertYes" = "Yes"; +"PeerInfo.ChangeEmojiStatus" = "Change Emoji Status"; +"PeerInfo.ChangeProfileColor" = "Change Profile Color"; +"PeerInfo.Channel.ArchivedStories" = "Archived Stories"; +"PeerInfo.Channel.Boost" = "Boost Channel"; +"PeerInfo.ChannelAutoDeleteInfo" = "Automatically delete messages sent in this channel after a certain period of time."; +"PeerInfo.ChannelMessages" = "Direct Messages"; +"PeerInfo.ClearConfirmationGroup" = "Do you want to delete all messages in %@?"; +"PeerInfo.ClearConfirmationUser" = "Do you want to delete all messages with %@?"; +"PeerInfo.ClearMessages" = "Clear Messages"; +"PeerInfo.CustomizeNotifications" = "Customize"; +"PeerInfo.DeleteChannelText" = "Do you want to delete the channel **%@** and all of its messages for all subscribers?"; +"PeerInfo.DeleteChannelTitle" = "Delete for All"; +"PeerInfo.DeleteGroupText" = "Do you want to delete the group **%@** and all of its messages for all members?"; +"PeerInfo.DeleteGroupTitle" = "Delete for All"; +"PeerInfo.DeleteReaction" = "Delete Reaction"; +"PeerInfo.DeleteToneText" = "Are you sure you want to delete\n**%@** notification sound?"; +"PeerInfo.DeleteToneTitle" = "Delete Tone"; +"PeerInfo.DisableSharing" = "Disable Sharing"; +"PeerInfo.DisableSound" = "Disable Sound"; +"PeerInfo.EnableAutoDelete" = "Enable Auto-Delete"; +"PeerInfo.EnableSharing" = "Enable Sharing"; +"PeerInfo.EnableSound" = "Enable Sound"; +"PeerInfo.GiftPremium" = "Gift Premium"; +"PeerInfo.Gifts.AddCollection" = "Add Collection"; +"PeerInfo.Gifts.AddGifts" = "Add Gifts"; +"PeerInfo.Gifts.AddGiftsButton" = "Add Gifts"; +"PeerInfo.Gifts.AddedToCollection" = "The gift has been added to **%@**."; +"PeerInfo.Gifts.AddedToCollectionUnique" = "**%1$@** has been added to **%2$@**."; +"PeerInfo.Gifts.ChannelNotify" = "Notify About New Gifts"; +"PeerInfo.Gifts.ChannelNotifyDisabledTooltip" = "You will not be notified when your channel receives a gift."; +"PeerInfo.Gifts.ChannelNotifyTooltip" = "Telegram will send you a message when your channel receives a gift."; +"PeerInfo.Gifts.CollectionLimitReached.Text" = "Please remove one of the existing collections to add a new one."; +"PeerInfo.Gifts.CollectionLimitReached.Title" = "Limit Reached"; +"PeerInfo.Gifts.Collections.Add" = "Add Collection"; +"PeerInfo.Gifts.Collections.All" = "All Gifts"; +"PeerInfo.Gifts.Context.AddToCollection" = "Add to Collection"; +"PeerInfo.Gifts.Context.CopyLink" = "Copy Link"; +"PeerInfo.Gifts.Context.Hide" = "Hide"; +"PeerInfo.Gifts.Context.NewCollection" = "New Collection"; +"PeerInfo.Gifts.Context.Pin" = "Pin"; +"PeerInfo.Gifts.Context.RemoveFromCollection" = "Remove from Collection"; +"PeerInfo.Gifts.Context.Reorder" = "Reorder"; +"PeerInfo.Gifts.Context.Share" = "Share"; +"PeerInfo.Gifts.Context.Show" = "Show"; +"PeerInfo.Gifts.Context.Transfer" = "Transfer"; +"PeerInfo.Gifts.Context.Unpin" = "Unpin"; +"PeerInfo.Gifts.Context.Wear" = "Wear"; +"PeerInfo.Gifts.CreateCollection.Placeholder" = "Title"; +"PeerInfo.Gifts.CreateCollection.Text" = "Choose a name for your collection and add your gifts."; +"PeerInfo.Gifts.CreateCollection.Title" = "Create a New Collection"; +"PeerInfo.Gifts.DeleteCollection" = "Delete Collection"; +"PeerInfo.Gifts.Displayed" = "Displayed"; +"PeerInfo.Gifts.EmptyCollection.Action" = "Add Gifts"; +"PeerInfo.Gifts.EmptyCollection.Text" = "Add some gifts to this collection."; +"PeerInfo.Gifts.EmptyCollection.Title" = "Organize Your Gifts"; +"PeerInfo.Gifts.Hidden" = "Hidden"; +"PeerInfo.Gifts.Info" = "Tap on a gift to exchange it for Stars or change its privacy settings."; +"PeerInfo.Gifts.Limited" = "Limited"; +"PeerInfo.Gifts.NoResults" = "No Matching Gifts"; +"PeerInfo.Gifts.NoResults.ViewAll" = "View All Gifts"; +"PeerInfo.Gifts.OneOf" = "1 of %@"; +"PeerInfo.Gifts.RemoveCollectionAction" = "Remove"; +"PeerInfo.Gifts.RemoveCollectionConfirmation" = "This will remove the collection."; +"PeerInfo.Gifts.RemovedFromCollection" = "The gift has been removed from **%@**."; +"PeerInfo.Gifts.RemovedFromCollectionUnique" = "**%1$@** has been removed from **%2$@**."; +"PeerInfo.Gifts.RenameCollection" = "Rename"; +"PeerInfo.Gifts.RenameCollection.Title" = "Edit Collection Name"; +"PeerInfo.Gifts.Reorder" = "Reorder"; +"PeerInfo.Gifts.Sale" = "on sale"; +"PeerInfo.Gifts.Send" = "Send Gifts to Friends"; +"PeerInfo.Gifts.SendGift" = "Send Gift"; +"PeerInfo.Gifts.ShareCollection" = "Share"; +"PeerInfo.Gifts.SortByDate" = "Sort by Date"; +"PeerInfo.Gifts.SortByValue" = "Sort by Value"; +"PeerInfo.Gifts.ToastPinLimit.Text#one" = "You can't pin more than %@ gift."; +"PeerInfo.Gifts.ToastPinLimit.Text#other" = "You can't pin more than %@ gifts."; +"PeerInfo.Gifts.ToastPinned.ReplacingText" = "replacing %@."; +"PeerInfo.Gifts.ToastPinned.Text" = "Now it will always be shown on the top."; +"PeerInfo.Gifts.ToastPinned.Title" = "Gift Pinned"; +"PeerInfo.Gifts.ToastPinned.TitleNew" = "%@ Pinned"; +"PeerInfo.Gifts.ToastUnpinned.Text" = "Gift Unpinned."; +"PeerInfo.Gifts.Unique" = "Unique"; +"PeerInfo.Gifts.Unlimited" = "Unlimited"; +"PeerInfo.Gifts.Upgradable" = "Upgradable"; +"PeerInfo.Group.Boost" = "Boost Group"; +"PeerInfo.GroupAboutItem" = "about"; +"PeerInfo.HiddenStatusBadge" = "when?"; +"PeerInfo.HideMembersLimitedParticipantCountText#one" = "Only groups with more than **%d member** can hide members."; +"PeerInfo.HideMembersLimitedParticipantCountText#other" = "Only groups with more than **%d members** can hide members."; +"PeerInfo.HideMembersLimitedRights" = "You don't have the permissions to change this setting."; +"PeerInfo.ItemAffiliateProgram.Footer" = "Share a link to %1$@ with your friends and earn %2$@% of their spending there."; +"PeerInfo.ItemAffiliateProgram.Title" = "Affiliate Program"; +"PeerInfo.ItemAffiliateProgram.ValueOff" = "Off"; +"PeerInfo.ItemAffiliatePrograms.Title" = "Affiliate Programs"; +"PeerInfo.LabelAllReactions" = "All Reactions"; +"PeerInfo.LeaveChannelText" = "Are you sure you want to leave the channel **%@**?"; +"PeerInfo.LeaveChannelTitle" = "Leave Channel"; +"PeerInfo.LeaveGroupText" = "Do you want to leave the group **%@**?"; +"PeerInfo.LeaveGroupTitle" = "Leave Group"; +"PeerInfo.Location.Label" = "location"; +"PeerInfo.ManagedBotFooter" = "Created and managed by [@%@]()"; +"PeerInfo.MenuAddStories" = "Add Stories"; +"PeerInfo.MuteFor" = "Mute for..."; +"PeerInfo.MuteForCustom" = "Mute until..."; +"PeerInfo.MuteForever" = "Mute Forever"; +"PeerInfo.NoteActionCopy" = "Copy Note"; +"PeerInfo.NoteActionEdit" = "Edit Note"; +"PeerInfo.Notes" = "notes"; +"PeerInfo.NotesInfo" = "only visible to you"; +"PeerInfo.NotificationMemberAdded" = "**%1$@** added to the group."; +"PeerInfo.NotificationMultipleMembersAdded#one" = "**%d** member added to the group."; +"PeerInfo.NotificationMultipleMembersAdded#other" = "**%d** members added to the group."; +"PeerInfo.NotificationsCustomize" = "Customize"; +"PeerInfo.OpenAppButton" = "Open App"; +"PeerInfo.OptionTopics" = "Topics"; +"PeerInfo.OptionTopics.Disabled" = "Disabled"; +"PeerInfo.OptionTopics.Enabled" = "Enabled"; +"PeerInfo.OptionTopicsText" = "The group chat will be divided into topics created by admins or users."; +"PeerInfo.PaneArchivedStories" = "Archived Posts"; +"PeerInfo.PaneAudio" = "Music"; +"PeerInfo.PaneBotPreviews" = "Preview"; +"PeerInfo.PaneFiles" = "Files"; +"PeerInfo.PaneGifs" = "GIFs"; +"PeerInfo.PaneGifts" = "Gifts"; +"PeerInfo.PaneGroups" = "Groups"; +"PeerInfo.PaneLinks" = "Links"; +"PeerInfo.PaneMedia" = "Media"; +"PeerInfo.PaneMembers" = "Members"; +"PeerInfo.PanePolls" = "Polls"; +"PeerInfo.PaneRecommended" = "Similar Channels"; +"PeerInfo.PaneRecommendedBots" = "Similar Bots"; +"PeerInfo.PaneStories" = "Posts"; +"PeerInfo.PaneVoice" = "Voice Messages"; +"PeerInfo.PaneVoiceAndVideo" = "Voice"; +"PeerInfo.Permissions.Biometry" = "Biometrics"; +"PeerInfo.Permissions.EmojiStatus" = "Emoji Status"; +"PeerInfo.Permissions.Geolocation" = "Geolocation"; +"PeerInfo.Permissions.Title" = "ALLOW ACCESS TO"; +"PeerInfo.PrivateShareLinkInfo" = "This link will only work for group members."; +"PeerInfo.QRCode.Scan" = "Scan QR Code"; +"PeerInfo.QRCode.Title" = "QR Code"; +"PeerInfo.Reactions" = "Reactions"; +"PeerInfo.ReactionsDisabled" = "Disabled"; +"PeerInfo.ReportProfilePhoto" = "Report Profile Photo"; +"PeerInfo.ReportProfileVideo" = "Report Profile Photo"; +"PeerInfo.SavedMessagesTabTitle" = "Saved"; +"PeerInfo.SetEmojiStatus" = "Set Emoji Status"; +"PeerInfo.SimilarBots.ShowMore" = "Show More Bots"; +"PeerInfo.SimilarBots.ShowMoreInfo" = "Subscribe to [Telegram Premium]()\nto unlock up to **100** similar bots."; +"PeerInfo.Tabs.SetMainTab" = "Set as Main Tab"; +"PeerInfo.Tabs.SetMainTab.Succeed" = "Tab order changed."; +"PeerInfo.ToastNoteCopied" = "Note copied to clipboard."; +"PeerInfo.TooltipMutedFor" = "Notifications are muted for %@."; +"PeerInfo.TooltipMutedForever" = "Notifications are muted."; +"PeerInfo.TooltipMutedUntil" = "Notifications are muted until %@."; +"PeerInfo.TooltipSoundDisabled" = "You will receive silent notifications."; +"PeerInfo.TooltipSoundEnabled" = "You will receive notifications with sound."; +"PeerInfo.TooltipUnmuted" = "Notifications are unmuted."; +"PeerInfo.TopicHeaderLocation" = "in %1$@"; +"PeerInfo.TopicIconInfoText" = "This topic's icon is from **%@**."; +"PeerInfo.TopicNotificationExceptions#one" = "[1 topic]() is listed as an exception."; +"PeerInfo.TopicNotificationExceptions#other" = "[%d topics]() are listed as exceptions."; +"PeerInfo.Topics.DisplayAs" = "DISPLAY AS"; +"PeerInfo.Topics.DisplayAsInfo" = "Choose how topics appear for all members."; +"PeerInfo.Topics.EnableTopics" = "Enable Topics"; +"PeerInfo.Topics.EnableTopicsInfo" = "The group chat will be divided into topics created by admins or users."; +"PeerInfo.Topics.List" = "List"; +"PeerInfo.Topics.Tabs" = "Tabs"; +"PeerInfo.Topics.Title" = "Topics"; +"PeerInfo.TopicsLimitedDiscussionGroups" = "Topics are not yet available in the discussion groups of channels."; +"PeerInfo.TopicsLimitedParticipantCountText#one" = "Only groups with more than **%d member** can have topics enabled."; +"PeerInfo.TopicsLimitedParticipantCountText#other" = "Only groups with more than **%d members** can have topics enabled."; +"PeerInfo.UnofficialSecurityRisk" = "%@ uses an unofficial Telegram client – messages to this user may be less secure."; +"PeerInfo.VerifyAccounts" = "Verify Accounts"; +"PeerInfo.ViewDiscussion" = "View Discussion"; +"PeerSelection.ImportIntoNewGroup" = "Import to a New Group"; +"PeerStatusExpiration.AtDate" = "Your status expires on %@"; +"PeerStatusExpiration.Hours#one" = "Your status expires in 1 hour"; +"PeerStatusExpiration.Hours#other" = "Your status expires in %@ hours"; +"PeerStatusExpiration.Minutes#one" = "Your status expires in 1 minute"; +"PeerStatusExpiration.Minutes#other" = "Your status expires in %@ minutes"; +"PeerStatusExpiration.TomorrowAt" = "Your status expires tomorrow at %@"; +"PeerStatusSetup.NoTimerTitle" = "Long tap to set a timer"; +"PeopleNearby.CreateGroup" = "Create a Local Group"; +"PeopleNearby.Description" = "Ask your friend nearby to open this page to exchange phone numbers."; +"PeopleNearby.DiscoverDescription" = "Exchange contact info with people nearby\nand find new friends."; +"PeopleNearby.Groups" = "Groups Nearby"; +"PeopleNearby.MakeInvisible" = "Stop Showing Me"; +"PeopleNearby.MakeVisible" = "Make Myself Visible"; +"PeopleNearby.MakeVisibleDescription" = "Users nearby will be able to view your profile and send you messages. This may help you find new friends, but could also attract excessive attention. You can stop sharing your profile at any time.\n\nYour phone number will remain hidden."; +"PeopleNearby.MakeVisibleTitle" = "Show Your Profile"; +"PeopleNearby.NoMembers" = "no members"; +"PeopleNearby.ShowMorePeople#one" = "Show %@ More People"; +"PeopleNearby.ShowMorePeople#other" = "Show %@ More People"; +"PeopleNearby.Title" = "People Nearby"; +"PeopleNearby.Users" = "People Nearby"; +"PeopleNearby.UsersEmpty" = "Looking for users around you..."; +"PeopleNearby.VisibleUntil" = "visible until %@"; +"Permissions.CellularDataAllowInSettings.v0" = "Turn ON in Settings"; +"Permissions.CellularDataText.v0" = "Don't worry, Telegram keeps network usage to a minimum. You can further control this in Settings > Data and Storage."; +"Permissions.CellularDataTitle.v0" = "Enable Cellular Data"; +"Permissions.ContactsAllow.v0" = "Allow Access"; +"Permissions.ContactsAllowInSettings.v0" = "Allow in Settings"; +"Permissions.ContactsText.v0" = "See who's on Telegram and switch seamlessly, without having to \"add\" your friends."; +"Permissions.ContactsTitle.v0" = "Sync Your Contacts"; +"Permissions.NotificationsAllow.v0" = "Turn Notifications ON"; +"Permissions.NotificationsAllowInSettings.v0" = "Turn ON in Settings"; +"Permissions.NotificationsText.v0" = "Don't miss important messages from your friends and coworkers."; +"Permissions.NotificationsTitle.v0" = "Turn ON Notifications"; +"Permissions.NotificationsUnreachableText.v0" = "Please note that you partly disabled message notifications in your Settings."; +"Permissions.PeopleNearbyAllow.v0" = "Allow Access"; +"Permissions.PeopleNearbyAllowInSettings.v0" = "Allow in Settings"; +"Permissions.PeopleNearbyText.v0" = "Quickly add people nearby who are also viewing this section and discover local group chats.\n\nPlease allow on location access to enable this feature."; +"Permissions.PeopleNearbyTitle.v0" = "People Nearby"; +"Permissions.PrivacyPolicy" = "Privacy Policy"; +"Permissions.SiriAllow.v0" = "Turn Siri ON"; +"Permissions.SiriAllowInSettings.v0" = "Turn ON in Settings"; +"Permissions.SiriText.v0" = "Use Siri to send messages and make calls."; +"Permissions.SiriTitle.v0" = "Turn ON Siri"; +"Permissions.Skip" = "Skip"; +"PhoneLabel.Title" = "Label"; +"PhoneNumberHelp.Alert" = "Users will see your new number if they have it in their address book or your privacy settings allow them to see it. You can modify this in Settings > Privacy and Security > Phone number."; +"PhoneNumberHelp.ChangeNumber" = "Change Number"; +"PhoneNumberHelp.Help" = "You can change your Telegram number here. Your account and all your cloud data — messages, media, contacts, etc. will be moved to the new number."; +"PhotoEditor.BlurToolLinear" = "Linear"; +"PhotoEditor.BlurToolOff" = "Off"; +"PhotoEditor.BlurToolPortrait" = "Portrait"; +"PhotoEditor.BlurToolRadial" = "Radial"; +"PhotoEditor.ContrastTool" = "Contrast"; +"PhotoEditor.CropAspectRatioOriginal" = "Original"; +"PhotoEditor.CropAspectRatioSquare" = "Square"; +"PhotoEditor.CropAuto" = "AUTO"; +"PhotoEditor.CropReset" = "RESET"; +"PhotoEditor.CurvesAll" = "All"; +"PhotoEditor.CurvesBlue" = "Blue"; +"PhotoEditor.CurvesGreen" = "Green"; +"PhotoEditor.CurvesRed" = "Red"; +"PhotoEditor.DiscardChanges" = "Discard Changes"; +"PhotoEditor.EnhanceTool" = "Enhance"; +"PhotoEditor.ExposureTool" = "Brightness"; +"PhotoEditor.FadeTool" = "Fade"; +"PhotoEditor.GrainTool" = "Grain"; +"PhotoEditor.HighlightsTint" = "Highlights"; +"PhotoEditor.HighlightsTool" = "Highlights"; +"PhotoEditor.Original" = "Original"; +"PhotoEditor.QualityTool" = "Quality"; +"PhotoEditor.SaturationTool" = "Saturation"; +"PhotoEditor.SelectCoverFrame" = "Choose a frame for your profile photo"; +"PhotoEditor.SelectCoverFrameSuggestion" = "Choose a frame for this profile photo"; +"PhotoEditor.SetAsMyPhoto" = "Set as My Photo"; +"PhotoEditor.SetAsMyVideo" = "Set as My Video"; +"PhotoEditor.ShadowsTint" = "Shadows"; +"PhotoEditor.ShadowsTool" = "Shadows"; +"PhotoEditor.SharpenTool" = "Sharpen"; +"PhotoEditor.SkinTool" = "Soften Skin"; +"PhotoEditor.TiltShift" = "Tilt Shift"; +"PhotoEditor.VignetteTool" = "Vignette"; +"PhotoEditor.WarmthTool" = "Warmth"; +"PlaybackSpeed.Normal" = "Normal"; +"PlaybackSpeed.Title" = "Playback Speed"; +"PollResults.Collapse" = "COLLAPSE"; +"PollResults.ShowMore#one" = "Show More (%@)"; +"PollResults.ShowMore#other" = "Show More (%@)"; +"PollResults.Title" = "Poll Results"; +"PollStats.GraphHeader" = "VOTE TIMELINE"; +"PollStats.Title" = "Poll Stats"; +"PowerSaving.AlertEnabledAction" = "Disable"; +"PowerSaving.AlertEnabledText" = "%@% battery remaining."; +"PowerSaving.AlertEnabledTitle" = "Power Saving Mode Enabled"; +"PowerSaving.AllDescriptionAlways" = "Always disable all resource-intensive processes, regardless of the battery charge level."; +"PowerSaving.AllDescriptionLimit" = "Automatically disable all resource-intensive processes when your battery is below %@%."; +"PowerSaving.AllDescriptionNever" = "Never reduce power usage and keep all animations even when the battery is low."; +"PowerSaving.BatteryLevelLimit.AlwaysOff" = "Always Off"; +"PowerSaving.BatteryLevelLimit.AlwaysOn" = "Always On"; +"PowerSaving.BatteryLevelLimit.Off" = "Off"; +"PowerSaving.BatteryLevelLimit.On" = "On"; +"PowerSaving.BatteryLevelLimit.WhenBelow" = "When Below %@%"; +"PowerSavingScreen.OptionAutoplayEffectsText" = "Various effects and animations that make Telegram look amazing."; +"PowerSavingScreen.OptionAutoplayEffectsTitle" = "Interface Effects"; +"PowerSavingScreen.OptionAutoplayEmojiText" = "Loop animated emoji in messages, reactions, statuses."; +"PowerSavingScreen.OptionAutoplayEmojiTitle" = "Emoji Animations"; +"PowerSavingScreen.OptionAutoplayGifText" = "Autoplay and loop GIFs in chats and in the keyboard."; +"PowerSavingScreen.OptionAutoplayGifTitle" = "Autoplay GIFs"; +"PowerSavingScreen.OptionAutoplayStickersText" = "Loop animated stickers, play full-screen premium effects."; +"PowerSavingScreen.OptionAutoplayStickersTitle" = "Sticker Animations"; +"PowerSavingScreen.OptionAutoplayVideoText" = "Autoplay and loop videos and video messages in chats."; +"PowerSavingScreen.OptionAutoplayVideoTitle" = "Autoplay Videos"; +"PowerSavingScreen.OptionBackgroundText" = "Update chats faster when switching between apps."; +"PowerSavingScreen.OptionBackgroundTitle" = "Background Updates"; +"PowerSavingScreen.OptionChangeAlertAlways" = "Turn off Power Saving Mode to change these settings."; +"PowerSavingScreen.OptionChangeAlertConditional" = "Turn off Power Saving Mode or charge your device to change these settings."; +"PowerSavingScreen.OptionPreloadText" = "Start loading media while in the chat list for faster access."; +"PowerSavingScreen.OptionPreloadTitle" = "Preload Media"; +"PowerSavingScreen.OptionsHeader" = "RESOURCE-INTENSIVE PROCESSES"; +"PowerSavingScreen.Title" = "Power Saving"; +"PowerSavingScreen.ToggleHeaderEmpty" = "POWER SAVING MODE"; +"PowerSavingScreen.ToggleHeaderOff" = "POWER SAVING MODE (OFF)"; +"PowerSavingScreen.ToggleHeaderOn" = "POWER SAVING MODE (ON)"; +"Premium.AboutText" = "While the free version of Telegram already gives its users more than any other messaging application, **Telegram Premium** pushes its capabilities even further.\n\n**Telegram Premium** is a paid option, because most Premium Features require additional expenses from Telegram to third parties such as data center providers and server manufacturers. Contributions from **Telegram Premium** users allow us to cover such costs and also help Telegram stay free for everyone."; +"Premium.AboutTitle" = "ABOUT TELEGRAM PREMIUM"; +"Premium.AiTools" = "AI Tools"; +"Premium.AiToolsInfo" = "Transform your messages and entire chats in your preferred style and language."; +"Premium.AnimatedEmoji" = "Animated Emoji"; +"Premium.AnimatedEmoji.Proceed" = "Unlock Animated Emoji"; +"Premium.AnimatedEmojiInfo" = "Include animated emoji from different packs in any message you send."; +"Premium.AnimatedEmojiStandaloneInfo" = "Include animated emoji from different emoji packs in any message you send."; +"Premium.Annual" = "Annual"; +"Premium.AppIcon" = "Telegram App Icon"; +"Premium.AppIconInfo" = "Choose from a selection of Telegram app icons for your homescreen."; +"Premium.AppIconStandalone" = "Premium App Icons"; +"Premium.AppIconStandaloneInfo" = "Unlock a wider range of app icons by subscribing to **Telegram Premium**."; +"Premium.AppIcons.Proceed" = "Unlock Premium Icons"; +"Premium.Avatar" = "Animated Profile Pictures"; +"Premium.AvatarInfo" = "Video avatars animated in chat lists and chats to allow for additional self-expression."; +"Premium.Badge" = "Profile Badge"; +"Premium.BadgeInfo" = "An exclusive badge next to your name showing that you subscribe to Telegram Premium."; +"Premium.Biannual" = "2 Years"; +"Premium.BoostByGiftDescription" = " "; +"Premium.BoostByGiftDescription2" = "Boost your channel by gifting your subscribers Telegram Premium. [Get boosts >]()"; +"Premium.BoostByGiveawayDescription" = "Get more boosts and subscribers for your channel by giving away prizes. [Get boosts >]()"; +"Premium.Business" = "Telegram Business"; +"Premium.Business.Away.Text" = "Define messages that are automatically sent when you are off."; +"Premium.Business.Away.Title" = "Away Messages"; +"Premium.Business.Chatbots.Text" = "Add any third party chatbots that will process customer interactions."; +"Premium.Business.Chatbots.Title" = "Chatbots"; +"Premium.Business.Description" = "Turn your account to a business page with these additional features."; +"Premium.Business.Greetings.Text" = "Create greetings that will be automatically sent to new customers."; +"Premium.Business.Greetings.Title" = "Greeting Messages"; +"Premium.Business.Hours.Text" = "Show to your customers when you are open for business."; +"Premium.Business.Hours.Title" = "Opening Hours"; +"Premium.Business.Intro.Text" = "Customize the message people see before they start a chat with you."; +"Premium.Business.Intro.Title" = "Start Page"; +"Premium.Business.Links.Text" = "Create links that start a chat with you, suggesting the first message."; +"Premium.Business.Links.Title" = "Links to Chat"; +"Premium.Business.Location.Text" = "Display the location of your business on your account."; +"Premium.Business.Location.Title" = "Location"; +"Premium.Business.Replies.Text" = "Set up shortcuts with rich text and media to respond to messages faster."; +"Premium.Business.Replies.Title" = "Quick Replies"; +"Premium.BusinessInfo" = "Upgrade your account with business features such as location, opening hours and quick replies."; +"Premium.ChatManagement" = "Advanced Chat Management"; +"Premium.ChatManagement.Proceed" = "About Telegram Premium"; +"Premium.ChatManagementInfo" = "Tools to set the default folder, auto-archive and hide new chats from non-contacts."; +"Premium.ChatManagementStandaloneInfo" = "Subscribers of **Telegram Premium** can set a default folder, auto-archive and hide new chats from non-contacts."; +"Premium.Colors" = "Name and Profile Colors"; +"Premium.Colors.Proceed" = "About Telegram Premium"; +"Premium.ColorsInfo" = "Choose a color and logo for your profile and replies to your messages."; +"Premium.CopyProtection" = "Disable Sharing"; +"Premium.CopyProtectionInfo" = "Prevent forwarding, saving and copying content in private chats."; +"Premium.CreateMultipleStories" = "Create Multiple Stories"; +"Premium.CurrentPlan" = "your current plan"; +"Premium.Description" = "Go **beyond the limits** and unlock **dozens of exclusive features** by subscribing to **Telegram Premium**."; +"Premium.DoubledLimits" = "Doubled Limits"; +"Premium.DoubledLimitsInfo" = "Up to 1000 channels, 30 folders, 10 pins, 20 public links, 4 accounts and more."; +"Premium.Emoji.Description" = "Unlock this emoji and many more by subscribing to Telegram Premium."; +"Premium.Emoji.Proceed" = "Unlock Premium Emoji"; +"Premium.EmojiStatus" = "Emoji Statuses"; +"Premium.EmojiStatus.Proceed" = "Unlock Emoji Statuses"; +"Premium.EmojiStatusInfo" = "Choose from thousands of emoji to display current activity next to your name."; +"Premium.EmojiStatusShortTitle" = "This is %1$@'s current status."; +"Premium.EmojiStatusText" = "Emoji statuses are a premium feature.\n Other features included in **Telegram Premium**:"; +"Premium.EmojiStatusTitle" = "This is %1$@'s current status from #[%2$@]()."; +"Premium.FasterSpeed" = "Faster Download Speed"; +"Premium.FasterSpeed.Proceed" = "About Telegram Premium"; +"Premium.FasterSpeedInfo" = "No more limits on the speed with which media and documents are downloaded."; +"Premium.FasterSpeedStandaloneInfo" = "Subscribe to **Telegram Premium** to download media and files at the fastest possible speed."; +"Premium.FileTooLarge" = "File Too Large"; +"Premium.FolderTags" = "Folder Tags"; +"Premium.FolderTags.Proceed" = "About Telegram Premium"; +"Premium.FolderTagsInfo" = "Add colorful labels to chats for faster access with Telegram Premium."; +"Premium.FolderTagsStandaloneInfo" = "Add colorful labels to chats for faster access with Telegram Premium."; +"Premium.Free" = "Free"; +"Premium.Gift.ApplyLink" = "Apply for Free"; +"Premium.Gift.ApplyLink.AlreadyHasPremium.Text" = "You can activate this gift link after **%@** or [send the link]() to a friend."; +"Premium.Gift.ApplyLink.AlreadyHasPremium.Title" = "You already have Telegram Premium"; +"Premium.Gift.ContactSelection.AddBirthday" = "Add Your Birthday"; +"Premium.Gift.ContactSelection.BirthdayToday" = "🎂 BIRTHDAY TODAY"; +"Premium.Gift.ContactSelection.BirthdayTomorrow" = "BIRTHDAY TOMORROW"; +"Premium.Gift.ContactSelection.BirthdayYesterday" = "BIRTHDAY YESTERDAY"; +"Premium.Gift.ContactSelection.BuySelf" = "buy yourself a gift"; +"Premium.Gift.ContactSelection.DeselectAll" = "DESELECT ALL"; +"Premium.Gift.ContactSelection.FrequentContacts" = "FREQUENT CONTACTS"; +"Premium.Gift.ContactSelection.MaximumReached" = "You can select up to %@ users."; +"Premium.Gift.ContactSelection.OpenProfile" = "Open Profile"; +"Premium.Gift.ContactSelection.Placeholder" = "Search people to gift Premium to..."; +"Premium.Gift.ContactSelection.Proceed" = "Proceed"; +"Premium.Gift.ContactSelection.SelectAll" = "SELECT ALL"; +"Premium.Gift.ContactSelection.SendMessage" = "Send Message"; +"Premium.Gift.ContactSelection.ThisIsYou" = "THIS IS YOU"; +"Premium.Gift.ContactSelection.Title" = "Gift Premium"; +"Premium.Gift.ContactSelection.TransferSelf" = "send yourself a gift"; +"Premium.Gift.Description" = "Give **%@** access to exclusive features with **Telegram Premium**."; +"Premium.Gift.GiftMultipleSubscriptions#one" = "Gift %@ Subscription"; +"Premium.Gift.GiftMultipleSubscriptions#other" = "Gift %@ Subscriptions"; +"Premium.Gift.GiftMultipleSubscriptionsFormat" = "%1$@ for %2$@"; +"Premium.Gift.GiftSubscription" = "Gift Subscription for %@"; +"Premium.Gift.Info" = "You can review the list of features and more details about Telegram Premium [here]()."; +"Premium.Gift.Link.Text" = "This link allows you or [anyone you choose]() to activate a **Telegram Premium** subscription."; +"Premium.Gift.Months#one" = "%@ Month"; +"Premium.Gift.Months#other" = "%@ Months"; +"Premium.Gift.MultipleDescription" = "Give %1$@%2$@ access to exclusive features with **Telegram Premium**."; +"Premium.Gift.NamesAndMore#one" = " and %@ more"; +"Premium.Gift.NamesAndMore#other" = " and %@ more"; +"Premium.Gift.PricePerMonth" = "%@ / month"; +"Premium.Gift.Sent.Close" = "Close"; +"Premium.Gift.Sent.Multiple.Text" = "%1$@%2$@ have been notified about the gifts you purchased.\n\nThey now have access to additional features."; +"Premium.Gift.Sent.Multiple.Title" = "Gifts Sent!"; +"Premium.Gift.Sent.One.Text" = "%@ has been notified about the gift you purchased.\n\nThey now have access to additional features."; +"Premium.Gift.Sent.One.Title" = "Gift Sent!"; +"Premium.Gift.Terms" = "By gifting Telegram Premium, you agree to the Telegram [Terms of Service](terms) and [Privacy Policy](privacy)."; +"Premium.Gift.Title" = "Gift Telegram Premium"; +"Premium.Gift.TitleShort" = "Telegram Premium"; +"Premium.Gift.UsedLink.Text" = "This link was used to activate a **Telegram Premium** subscription."; +"Premium.Gift.Years#one" = "%@ Year"; +"Premium.Gift.Years#other" = "%@ Years"; +"Premium.Gift.YouWillReceiveBoosts#one" = "You will receive []() **%@ Boost**!"; +"Premium.Gift.YouWillReceiveBoosts#other" = "You will receive []() **%@ Boosts**!"; +"Premium.GiftedDescription" = "You now have access to additional features."; +"Premium.GiftedDescriptionYou" = "They now have access to additional features."; +"Premium.GiftedTitle" = "Telegram Premium"; +"Premium.GiftedTitle.12Month" = "[%@]() has gifted you a 12-month subscription to Telegram Premium"; +"Premium.GiftedTitle.3Month" = "[%@]() has gifted you a 3-month subscription to Telegram Premium"; +"Premium.GiftedTitle.6Month" = "[%@]() has gifted you a 6-month subscription to Telegram Premium"; +"Premium.GiftedTitle.Someone" = "Someone"; +"Premium.GiftedTitleYou.12Month" = "You gifted [%@]() a 12-month subscription to Telegram Premium"; +"Premium.GiftedTitleYou.3Month" = "You gifted [%@]() a 3-month subscription to Telegram Premium"; +"Premium.GiftedTitleYou.6Month" = "You gifted [%@]() a 6-month subscription to Telegram Premium"; +"Premium.Group.BoostByGiftDescription" = "Boost your group by gifting your members Telegram Premium. [Get boosts >]()"; +"Premium.Group.BoostByGiveawayDescription" = "Get more boosts and members for your group by giving away prizes. [Get boosts >]()"; +"Premium.IncreaseLimit" = "Increase Limit"; +"Premium.InfiniteReactions" = "Infinite Reactions"; +"Premium.InfiniteReactionsInfo" = "React with thousands of emoji — using multiple reactions per message."; +"Premium.LastSeen" = "Last Seen Times"; +"Premium.LastSeen.Proceed" = "About Telegram Premium"; +"Premium.LastSeenInfo" = "View the last seen and read times of others even if you hide yours."; +"Premium.LimitReached" = "Limit Reached"; +"Premium.Limits.Accounts" = "Connected Accounts"; +"Premium.Limits.AccountsInfo" = "Connect 4 accounts with different phone numbers"; +"Premium.Limits.Bio" = "Bio"; +"Premium.Limits.BioInfo" = "Add more characters and use links in your bio"; +"Premium.Limits.Captions" = "Captions"; +"Premium.Limits.CaptionsInfo" = "Use longer descriptions for your photos and videos"; +"Premium.Limits.ChatsPerFolder" = "Chats per Folder"; +"Premium.Limits.ChatsPerFolderInfo" = "Add up to 200 chats into each of your folders"; +"Premium.Limits.FavedStickers" = "Favorite Stickers"; +"Premium.Limits.FavedStickersInfo" = "Save up to 10 stickers in your Favorite Stickers"; +"Premium.Limits.Folders" = "Folders"; +"Premium.Limits.FoldersInfo" = "Organize your chats into 30 folders"; +"Premium.Limits.GroupsAndChannels" = "Groups and Channels"; +"Premium.Limits.GroupsAndChannelsInfo" = "Join up to 1000 channels and large groups"; +"Premium.Limits.PinnedChats" = "Pinned Chats"; +"Premium.Limits.PinnedChatsInfo" = "Pin up to 10 chats in your main chat list"; +"Premium.Limits.PublicLinks" = "Public Links"; +"Premium.Limits.PublicLinksInfo" = "Reserve up to 20 [t.me/username]() links"; +"Premium.Limits.RecommendedChannels" = "Similar Channels"; +"Premium.Limits.RecommendedChannelsInfo" = "View up to 100 similar channels"; +"Premium.Limits.SavedGifs" = "Saved GIFs"; +"Premium.Limits.SavedGifsInfo" = "Save up to 400 GIFs in your Favorite GIFs"; +"Premium.Limits.Title" = "Doubled Limits"; +"Premium.MaxAccountsFinalText" = "You have reached the limit of **%@** connected accounts."; +"Premium.MaxAccountsNoPremiumText" = "You have reached the limit of **%@** connected accounts."; +"Premium.MaxAccountsText" = "You have reached the limit of **%@** connected accounts. You can add more by subscribing to **Telegram Premium**."; +"Premium.MaxChannelsFinalText" = "Sorry, you can only join **%1$@** groups and channels"; +"Premium.MaxChannelsNoPremiumText" = "You can only join **%1$@** groups and channels. This limit may increase in the future."; +"Premium.MaxChannelsText" = "You can only join **%1$@** groups and channels. Upgrade to **Telegram Premium** to join up to **%2$@**."; +"Premium.MaxChatsInFolderFinalText" = "Sorry, you can't add more than **%@** chats to a folder."; +"Premium.MaxChatsInFolderNoPremiumText" = "Sorry, you can't add more than **%1$@** chats to a folder. We are working to let you increase this limit in the future."; +"Premium.MaxChatsInFolderText" = "Sorry, you can't add more than **%1$@** chats to a folder. You can increase this limit to **%2$@** by subscribing to **Telegram Premium**."; +"Premium.MaxExpiringStoriesFinalText" = "You've reached the limit of **%@** story in **24** hours."; +"Premium.MaxExpiringStoriesFinalTextFormat" = "You have reached the limit of %@ per **24** hours."; +"Premium.MaxExpiringStoriesFinalTextNumberFormat#one" = "**%d** story"; +"Premium.MaxExpiringStoriesFinalTextNumberFormat#other" = "**%d** stories"; +"Premium.MaxExpiringStoriesNoPremiumText" = "You've reached the limit of **%@** story in **24** hours."; +"Premium.MaxExpiringStoriesNoPremiumTextFormat" = "You have reached the limit of %@ per **24** hours."; +"Premium.MaxExpiringStoriesNoPremiumTextNumberFormat#one" = "**%d** story"; +"Premium.MaxExpiringStoriesNoPremiumTextNumberFormat#other" = "**%d** stories"; +"Premium.MaxExpiringStoriesText" = "You can post **%@** story in **24** hours. Subscribe to **Telegram Premium** to increase this limit to **%@**."; +"Premium.MaxExpiringStoriesTextFormat" = "You can post %@ in **24** hours. Subscribe to **Telegram Premium** to increase this limit to **%@**."; +"Premium.MaxExpiringStoriesTextNumberFormat#one" = "**%d** story"; +"Premium.MaxExpiringStoriesTextNumberFormat#other" = "**%d** stories"; +"Premium.MaxExpiringStoriesTextPremiumNumberFormat#one" = "**%d** story"; +"Premium.MaxExpiringStoriesTextPremiumNumberFormat#other" = "**%d** stories"; +"Premium.MaxFavedStickersFinalText" = "An older sticker was replaced with this one."; +"Premium.MaxFavedStickersText" = "An older sticker was replaced with this one. You can [increase the limit]() to %@ stickers."; +"Premium.MaxFavedStickersTitle" = "Limit of %@ Stickers Reached"; +"Premium.MaxFileSizeFinalText" = "The document can't be sent, because it is larger than **%@**."; +"Premium.MaxFileSizeNoPremiumText" = "The document can't be sent, because it is larger than **%@**. We are working to let you increase this limit in the future."; +"Premium.MaxFileSizeText" = "Double this limit to %@ per file by subscribing to **Telegram Premium**."; +"Premium.MaxFoldersCountFinalText" = "Sorry, you can't create more than **%1$@** folders."; +"Premium.MaxFoldersCountNoPremiumText" = "You have reached the limit of **%1$@** folders. We are working to let you increase this limit in the future."; +"Premium.MaxFoldersCountText" = "You have reached the limit of **%1$@** folders. You can double the limit to **%2$@** folders by subscribing to **Telegram Premium**."; +"Premium.MaxPinsFinalText" = "Sorry, you can't pin more than **%@** chats to the top. Unpin some that are currently pinned."; +"Premium.MaxPinsNoPremiumText" = "Sorry, you can't pin more than **%@** chats to the top. Unpin some that are currently pinned."; +"Premium.MaxPinsText" = "You can't pin more than **%1$@** chats to the top. Unpin some that are currently pinned –\u00a0or subscribe to **Telegram Premium** to double the limit to **%2$@** chats."; +"Premium.MaxSavedGifsFinalText" = "An older GIF was replaced with this one."; +"Premium.MaxSavedGifsText" = "An older GIF was replaced with this one. You can [increase the limit]() to %@ GIFs."; +"Premium.MaxSavedGifsTitle" = "Limit of %@ GIFs Reached"; +"Premium.MaxSavedPinsFinalText" = "Sorry, you can't pin more than **%@** chats to the top. Unpin some that are currently pinned."; +"Premium.MaxSavedPinsNoPremiumText" = "Sorry, you can't pin more than **%@** chats to the top. Unpin some that are currently pinned."; +"Premium.MaxSavedPinsText" = "Sorry, you can't pin more than **%1$@** chats to the top. Unpin some that are currently pinned or subscribe to **Telegram Premium** to double the limit to **%2$@** chats."; +"Premium.MaxSharedFolderLinksFinalText" = "Sorry, you can only create **%1$@** invite links"; +"Premium.MaxSharedFolderLinksNoPremiumText" = "You can only create **%1$@** invite links. This number may increase in the future."; +"Premium.MaxSharedFolderLinksText" = "You can only create **%1$@** invite links. Upgrade to **Telegram Premium** to create up to **%2$@**."; +"Premium.MaxSharedFolderMembershipFinalText" = "Sorry, you can only add **%1$@** shareable folders."; +"Premium.MaxSharedFolderMembershipNoPremiumText" = "You can only add **%1$@** shareable folders. This limit may increase in the future."; +"Premium.MaxSharedFolderMembershipText" = "You can only add **%1$@** shareable folders. Upgrade to **Telegram Premium** to add up to **%2$@**."; +"Premium.MaxStoriesMonthlyFinalText" = "You have reached the limit of **%@** stories per month."; +"Premium.MaxStoriesMonthlyNoPremiumText" = "You have reached the limit of **%@** stories per month."; +"Premium.MaxStoriesMonthlyText" = "You can post **%@** stories per month. Subscribe to **Telegram Premium** to increase this limit to **%@**."; +"Premium.MaxStoriesWeeklyFinalText" = "You have reached the limit of **%@** stories per week."; +"Premium.MaxStoriesWeeklyNoPremiumText" = "You have reached the limit of **%@** stories per week."; +"Premium.MaxStoriesWeeklyText" = "You can post **%@** stories per week. Subscribe to **Telegram Premium** to increase this limit to **%@**."; +"Premium.MessageEffects" = "Message Effects"; +"Premium.MessageEffectsInfo" = "Add over 500 animated effects to private messages."; +"Premium.MessagePrivacy" = "Message Privacy"; +"Premium.MessagePrivacy.Proceed" = "About Telegram Premium"; +"Premium.MessagePrivacyInfo" = "Limit messages from strangers or charge for incoming messages."; +"Premium.MessageTags" = "Tags for Messages"; +"Premium.MessageTags.Proceed" = "About Telegram Premium"; +"Premium.MessageTagsInfo" = "Organize your Saved Messages with tags for quicker access."; +"Premium.Monthly" = "Monthly"; +"Premium.New" = "NEW"; +"Premium.NoAds" = "No Ads"; +"Premium.NoAds.Proceed" = "About Telegram Premium"; +"Premium.NoAdsInfo" = "No more ads in public channels where Telegram sometimes shows ads."; +"Premium.NoAdsStandaloneInfo" = "Remove ads such as this one by subscribing to **Telegram Premium**."; +"Premium.PaidMessages" = "Paid Messages"; +"Premium.PaidMessages.Proceed" = "About Telegram Premium"; +"Premium.PaidMessagesInfo" = "Charge a fee for messages from non-contacts or new senders."; +"Premium.PersonalDescription" = "Owners of **Telegram Premium** accounts have exclusive access to multiple additional features."; +"Premium.PersonalTitle" = "[%@]() is a subscriber\nof Telegram Premium"; +"Premium.Premium" = "Premium"; +"Premium.PremiumGift.Description" = "Subscribe to **Telegram Premium** to send up to **5** of these gifts and unlock powerful features."; +"Premium.PremiumGift.Title" = "Premium Gift"; +"Premium.PricePer2Years" = "%@/2 years"; +"Premium.PricePerMonth" = "%@/month"; +"Premium.PricePerYear" = "%@/year"; +"Premium.Purchase.ErrorCantMakePayments" = "In-app purchases are not allowed on this device."; +"Premium.Purchase.ErrorNetwork" = "Please check your Internet connection and try again."; +"Premium.Purchase.ErrorNotAllowed" = "The device is not allowed to make the payment."; +"Premium.Purchase.ErrorTryLater" = "An error occurred. Please try again."; +"Premium.Purchase.ErrorUnknown" = "An error occurred. Please try again."; +"Premium.Purchase.OnlyOneSubscriptionAllowed" = "You have already purchased Telegram Premium for another account. You can only have one Telegram Premium subscription on one Apple ID."; +"Premium.Reactions" = "Unique Reactions"; +"Premium.Reactions.Proceed" = "Unlock Premium Reactions"; +"Premium.ReactionsInfo" = "Additional animated reactions on messages, available only to Premium subscribers."; +"Premium.ReactionsStandalone" = "Additional Reactions"; +"Premium.ReactionsStandaloneInfo" = "Unlock a wider range of reactions on messages by subscribing to **Telegram Premium**."; +"Premium.Restore.ErrorUnknown" = "An error occurred. Please try again."; +"Premium.Restore.Success" = "Done"; +"Premium.Semiannual" = "Semiannual"; +"Premium.SignUp.SignUpNewInfo" = "Get Telegram Premium for %@"; +"Premium.SignUp.SignUpNewInfo.Days#one" = "%@ day"; +"Premium.SignUp.SignUpNewInfo.Days#other" = "%@ days"; +"Premium.SignUp.SignUpNewInfoNone" = "Get Telegram Premium"; +"Premium.Stickers" = "Premium Stickers"; +"Premium.Stickers.Description" = "Unlock this sticker and many more by subscribing to Telegram Premium."; +"Premium.Stickers.Proceed" = "Unlock Premium Stickers"; +"Premium.StickersInfo" = "Exclusive enlarged stickers featuring additional effects, updated regularly."; +"Premium.Stories" = "Stories"; +"Premium.Stories.AdditionalTitle" = "Stories"; +"Premium.Stories.Captions.Text" = "Add 10x longer captions to your stories."; +"Premium.Stories.Captions.Title" = "Longer Captions"; +"Premium.Stories.Expiration.Text" = "Set custom durations like 6 or 48 hours for your stories."; +"Premium.Stories.Expiration.Title" = "Expiration Options"; +"Premium.Stories.Format.Text" = "Add links and formatting to your story captions."; +"Premium.Stories.Format.Title" = "Links and Formatting"; +"Premium.Stories.Order.Text" = "Get more views as your stories are always displayed first."; +"Premium.Stories.Order.Title" = "Priority Order"; +"Premium.Stories.Quality.Text" = "View video stories in double the resolution."; +"Premium.Stories.Quality.Title" = "Higher Quality"; +"Premium.Stories.Save.Text" = "Save other people's stories that are not protected."; +"Premium.Stories.Save.Title" = "Save Stories to Photos"; +"Premium.Stories.Stealth.Text" = "Hide the fact that you viewed other people's stories."; +"Premium.Stories.Stealth.Title" = "Stealth Mode"; +"Premium.Stories.Title" = "Stories"; +"Premium.Stories.Views.Text" = "Check who opens your stories — even after they expire."; +"Premium.Stories.Views.Title" = "Permanent View History"; +"Premium.StoriesInfo" = "Unlimited posting, priority order, stealth mode, permanent view history and more."; +"Premium.SubscribeFor" = "Subscribe for %@ / month"; +"Premium.SubscribeForAnnual" = "Subscribe for %@ / year"; +"Premium.SubscribeForBiannual" = "Subscribe for %@ / 2 years"; +"Premium.SubscribedDescription" = "Thank you for subscribing to **Telegram Premium**. Here's what is now unlocked."; +"Premium.SubscribedTitle" = "You are all set!"; +"Premium.Terms" = "By purchasing a Premium subscription, you agree to the Telegram [Terms of Service](terms) and [Privacy Policy](privacy)."; +"Premium.Title" = "Telegram Premium"; +"Premium.Todo" = "Checklists"; +"Premium.TodoInfo" = "Plan, assign and complete tasks – seamlessly and efficiently."; +"Premium.Translation" = "Real-Time Translation"; +"Premium.Translation.Proceed" = "About Telegram Premium"; +"Premium.TranslationInfo" = "Real-time translation of chats and channels into other languages."; +"Premium.TranslationStandaloneInfo" = "Subscribe to **Telegram Premium** to be able to translate all messages in a chat at once."; +"Premium.UpgradeDescription" = "Your current **Telegram Premium** plan can be upgraded at a **discount**."; +"Premium.UpgradeFor" = "Upgrade for %@"; +"Premium.UpgradeForAnnual" = "Upgrade for %@ / year"; +"Premium.UpgradeForBiannual" = "Upgrade for %@ / 2 years"; +"Premium.UploadSize" = "Unlimited Cloud Storage"; +"Premium.UploadSizeInfo" = "4 GB per each document, unlimited storage for your chats and media overall."; +"Premium.VoiceToText" = "Voice-to-Text Conversion"; +"Premium.VoiceToText.Proceed" = "About Telegram Premium"; +"Premium.VoiceToTextInfo" = "Ability to read the transcript of any incoming voice message."; +"Premium.VoiceToTextStandaloneInfo" = "Subscribe to **Telegram Premium** to be able to convert voice and video messages to text."; +"Premium.Wallpaper.Proceed" = "About Telegram Premium"; +"Premium.Wallpapers" = "Wallpapers for Both Sides"; +"Premium.WallpapersInfo" = "Set custom wallpapers for you and your chat partner."; +"Premium.Week.SignUp" = "Sign up for %@"; +"Premium.Week.SignUpInfo" = "Get Telegram Premium for 1 week"; +"Premium.WhatsIncluded" = "WHAT'S INCLUDED"; +"PremiumGift.LabelRecipients#one" = "1 recipient"; +"PremiumGift.LabelRecipients#other" = "%d recipients"; +"PremiumNeeded.Subscribe" = "Subscribe"; +"PremiumNeeded.Title" = "Premium Needed"; +"Presence.online" = "online"; +"Preview.CopyAddress" = "Copy Address"; +"Preview.DeleteGif" = "Delete GIF"; +"Preview.DeletePhoto" = "Delete Photo"; +"Preview.Gif.AddCaption" = "Add Caption"; +"Preview.Gif.Edit" = "Edit"; +"Preview.OpenInInstagram" = "Open in Instagram"; +"Preview.SaveGif" = "Save GIF"; +"Preview.SaveToCameraRoll" = "Add to Photos"; +"Privacy.AddNewPeer" = "Add Users or Groups"; +"Privacy.Bio" = "Bio"; +"Privacy.Bio.AlwaysShareWith.Title" = "Always Share With"; +"Privacy.Bio.CustomHelp" = "You can restrict who can see the bio on your profile with granular precision."; +"Privacy.Bio.NeverShareWith.Title" = "Never Share With"; +"Privacy.Bio.WhoCanSeeMyBio" = "WHO CAN SEE MY BIO"; +"Privacy.Birthday" = "Birthday"; +"Privacy.Birthday.AlwaysShareWith.Title" = "Always Share With"; +"Privacy.Birthday.CustomHelp" = "You can restrict who can see your birthday with granular precision."; +"Privacy.Birthday.NeverShareWith.Title" = "Never Share With"; +"Privacy.Birthday.Setup" = "You haven't added your birthday yet.\n[Add my birthday >]()"; +"Privacy.Birthday.WhoCanSeeMyBio" = "WHO CAN SEE MY BIRTHDAY"; +"Privacy.Calls" = "Calls"; +"Privacy.Calls.AlwaysAllow" = "Always Allow"; +"Privacy.Calls.AlwaysAllow.Placeholder" = "Always allow..."; +"Privacy.Calls.AlwaysAllow.Title" = "Always Allow"; +"Privacy.Calls.CustomHelp" = "Choose exactly who can and can't call you."; +"Privacy.Calls.CustomShareHelp" = "These users will or will not be able to call you regardless of the settings above."; +"Privacy.Calls.Integration" = "iOS Call Integration"; +"Privacy.Calls.IntegrationHelp" = "iOS Call Integration shows Telegram calls on the lock screen and in the system's call history. If iCloud sync is enabled, your call history is shared with Apple."; +"Privacy.Calls.NeverAllow" = "Never Allow"; +"Privacy.Calls.NeverAllow.Placeholder" = "Never allow..."; +"Privacy.Calls.NeverAllow.Title" = "Never Allow"; +"Privacy.Calls.P2P" = "Peer-to-Peer"; +"Privacy.Calls.P2PAlways" = "Always"; +"Privacy.Calls.P2PContacts" = "My Contacts"; +"Privacy.Calls.P2PHelp" = "Disabling peer-to-peer will relay all calls through Telegram servers to avoid revealing your IP address, but may decrease audio and video quality."; +"Privacy.Calls.P2PNever" = "Never"; +"Privacy.Calls.WhoCanCallMe" = "WHO CAN CALL ME"; +"Privacy.ChatsTitle" = "CHATS"; +"Privacy.ContactsReset" = "Delete Synced Contacts"; +"Privacy.ContactsReset.ContactsDeleted" = "All synced contacts deleted."; +"Privacy.ContactsResetConfirmation" = "This will remove your contacts from the Telegram servers.\nIf 'Sync Contacts' is enabled, contacts will be re-synced."; +"Privacy.ContactsSync" = "Sync Contacts"; +"Privacy.ContactsSyncHelp" = "Turn on to continuously sync contacts from this device with your account."; +"Privacy.ContactsTitle" = "CONTACTS"; +"Privacy.DeleteDrafts" = "Delete All Cloud Drafts"; +"Privacy.DeleteDrafts.DraftsDeleted" = "All cloud drafts deleted."; +"Privacy.Exceptions" = "EXCEPTIONS"; +"Privacy.Exceptions.DeleteAll" = "Delete All"; +"Privacy.Exceptions.DeleteAllConfirmation" = "Are you sure you want to delete all exceptions?"; +"Privacy.Exceptions.DeleteAllExceptions" = "Delete All Exceptions"; +"Privacy.ExceptionsCount#one" = "%@ EXCEPTION"; +"Privacy.ExceptionsCount#other" = "%@ EXCEPTIONS"; +"Privacy.Forwards" = "Forwarded Messages"; +"Privacy.Forwards.AlwaysAllow.Title" = "Always Allow"; +"Privacy.Forwards.AlwaysLink" = "Link to your account"; +"Privacy.Forwards.CustomHelp" = "Messages you send will not link back to your account when forwarded by other users."; +"Privacy.Forwards.LinkIfAllowed" = "Link if allowed by settings below"; +"Privacy.Forwards.NeverAllow.Title" = "Never Allow"; +"Privacy.Forwards.NeverLink" = "Not a link to your account"; +"Privacy.Forwards.Preview" = "SAMPLE MESSAGE"; +"Privacy.Forwards.PreviewMessageText" = "Reinhardt, we need to find you some new tunes."; +"Privacy.Forwards.WhoCanForward" = "WHO CAN ADD A LINK TO MY ACCOUNT WHEN FORWARDING MY MESSAGES"; +"Privacy.Gifts" = "Gifts"; +"Privacy.Gifts.AcceptedTypes" = "ACCEPTED GIFT TYPES"; +"Privacy.Gifts.AcceptedTypes.Channel" = "From Channels"; +"Privacy.Gifts.AcceptedTypes.Info" = "Choose the types of gifts that you allow others to send you."; +"Privacy.Gifts.AcceptedTypes.Limited" = "Limited-Edition"; +"Privacy.Gifts.AcceptedTypes.Premium" = "Premium Subscriptions"; +"Privacy.Gifts.AcceptedTypes.Unique" = "Unique"; +"Privacy.Gifts.AcceptedTypes.Unlimited" = "Unlimited"; +"Privacy.Gifts.AlwaysAllow.Title" = "Always Allow"; +"Privacy.Gifts.CustomHelp" = "Choose whether gifts from specific senders need your approval before they’re visible to others on your profile."; +"Privacy.Gifts.NeverAllow.Title" = "Never Allow"; +"Privacy.Gifts.PremiumToast.Action" = "Open"; +"Privacy.Gifts.PremiumToast.Text" = "Subscribe to **Telegram Premium** to use this setting."; +"Privacy.Gifts.ShowGiftButton" = "Show Gift Icon in Chats"; +"Privacy.Gifts.ShowGiftButtonInfo" = "Display the #Gift icon in the message input field for both participants in all chats."; +"Privacy.Gifts.WhoCanSeeMyBio" = "AUTOMATICALLY DISPLAY GIFTS FROM"; +"Privacy.GroupsAndChannels" = "Groups & Channels"; +"Privacy.GroupsAndChannels.AlwaysAllow" = "Always Allow"; +"Privacy.GroupsAndChannels.AlwaysAllow.Placeholder" = "Always allow..."; +"Privacy.GroupsAndChannels.AlwaysAllow.Title" = "Always Allow"; +"Privacy.GroupsAndChannels.CustomHelp" = "You can restrict who can add you to groups and channels with granular precision."; +"Privacy.GroupsAndChannels.CustomShareHelp" = "These users will or will not be able to add you to groups and channels regardless of the settings above."; +"Privacy.GroupsAndChannels.InviteToChannelError" = "Sorry, you cannot add %@ to channels because of %@'s privacy settings."; +"Privacy.GroupsAndChannels.InviteToChannelMultipleError" = "Sorry, you can't create a group with these users due to their privacy settings."; +"Privacy.GroupsAndChannels.InviteToGroupError" = "Sorry, you cannot add %@ to groups because of %@'s privacy settings."; +"Privacy.GroupsAndChannels.NeverAllow" = "Never Allow"; +"Privacy.GroupsAndChannels.NeverAllow.Placeholder" = "Never allow..."; +"Privacy.GroupsAndChannels.NeverAllow.Title" = "Never Allow"; +"Privacy.GroupsAndChannels.WhoCanAddMe" = "WHO CAN ADD ME"; +"Privacy.Messages.ChargeForMessages" = "Charge for Messages"; +"Privacy.Messages.ChargeForMessagesInfo" = "Charge a fee for messages from people outside your contacts or those you haven't messaged first."; +"Privacy.Messages.Exceptions.Title" = "Remove Fee"; +"Privacy.Messages.MessagePrice" = "SET YOUR PRICE PER MESSAGE"; +"Privacy.Messages.MessagePriceInfo" = "You will receive %1$@% of the selected fee (%2$@) for each incoming message."; +"Privacy.Messages.PremiumInfoFooter" = "[What is Telegram Premium?]()"; +"Privacy.Messages.PremiumToast.Action" = "Add"; +"Privacy.Messages.PremiumToast.Text" = "Subscribe to **Telegram Premium** to select this option"; +"Privacy.Messages.PremiumToast.Title" = "Premium Required"; +"Privacy.Messages.RemoveFee" = "Remove Fee"; +"Privacy.Messages.RemoveFeeHeader" = "EXCEPTIONS"; +"Privacy.Messages.RemoveFeeInfo" = "Add users or entire groups who won't have to pay to send you messages."; +"Privacy.Messages.SectionFooter" = "You can restrict messages from users who are not in your contacts and whom you haven't messaged first."; +"Privacy.Messages.SectionTitle" = "WHO CAN SEND ME MESSAGES"; +"Privacy.Messages.Stars#one" = "%@ Star"; +"Privacy.Messages.Stars#other" = "%@ Stars"; +"Privacy.Messages.Title" = "Messages"; +"Privacy.Messages.Unlock" = "Unlock with Telegram Premium"; +"Privacy.Messages.ValueContactsAndPremium" = "My Contacts and Premium Users"; +"Privacy.Messages.ValueEveryone" = "Everybody"; +"Privacy.PaymentsClear.AllInfoCleared" = "Payment and shipping info cleared."; +"Privacy.PaymentsClear.PaymentInfo" = "Payment Info"; +"Privacy.PaymentsClear.PaymentInfoCleared" = "Payment info cleared."; +"Privacy.PaymentsClear.ShippingInfo" = "Shipping Info"; +"Privacy.PaymentsClear.ShippingInfoCleared" = "Shipping info cleared."; +"Privacy.PaymentsClearInfo" = "Clear Payment and Shipping Info"; +"Privacy.PaymentsClearInfoDoneHelp" = "Payment & shipping info cleared."; +"Privacy.PaymentsClearInfoHelp" = "You can delete your shipping info and instruct all payment providers to remove your saved credit cards. Note that Telegram never stores your credit card data."; +"Privacy.PaymentsTitle" = "PAYMENTS"; +"Privacy.PhoneNumber" = "Phone Number"; +"Privacy.ProfilePhoto" = "Profile Photos"; +"Privacy.ProfilePhoto.AlwaysShareWith.Title" = "Always Share With"; +"Privacy.ProfilePhoto.CustomHelp" = "You can restrict who can see your profile photo with granular precision."; +"Privacy.ProfilePhoto.CustomOverrideAddInfo" = "Add users or entire groups that will still see your profile photo."; +"Privacy.ProfilePhoto.CustomOverrideBothInfo" = "You can add users or entire groups as exceptions that will override the settings above."; +"Privacy.ProfilePhoto.CustomOverrideInfo" = "You can add users or entire groups that will not see your profile photo."; +"Privacy.ProfilePhoto.NeverShareWith.Title" = "Never Share With"; +"Privacy.ProfilePhoto.PublicPhotoInfo" = "You can upload a public photo for users who are restricted from seeing your real profile photos."; +"Privacy.ProfilePhoto.PublicPhotoSuccess" = "This photo will be shown to users who are restricted from seeing your real photos."; +"Privacy.ProfilePhoto.PublicVideoSuccess" = "This photo will be shown to users who are restricted from seeing your real photos."; +"Privacy.ProfilePhoto.RemovePublicPhoto" = "Remove Public Photo"; +"Privacy.ProfilePhoto.RemovePublicVideo" = "Remove Public Photo"; +"Privacy.ProfilePhoto.SetPublicPhoto" = "Set Public Photo"; +"Privacy.ProfilePhoto.UpdatePublicPhoto" = "Update Public Photo"; +"Privacy.ProfilePhoto.WhoCanSeeMyPhoto" = "WHO CAN SEE MY PROFILE PHOTO"; +"Privacy.Review.Calls.Text" = "You've restricted who can message you, but anyone can still call you. Would you like to review these settings?"; +"Privacy.Review.Calls.Title" = "Call Settings"; +"Privacy.Review.Invite.Text" = "You've restricted who can message you, but anyone can still invite you to groups and channels. Would you like to review these settings?"; +"Privacy.Review.Invite.Title" = "Invite Settings"; +"Privacy.Review.Review" = "Review"; +"Privacy.SavedMusic" = "Saved Music"; +"Privacy.SavedMusic.AlwaysShareWith.Title" = "Always Share With"; +"Privacy.SavedMusic.CustomHelp" = "You can restrict who can see your saved music with granular precision."; +"Privacy.SavedMusic.NeverShareWith.Title" = "Never Share With"; +"Privacy.SavedMusic.WhoCanSeeMyMusic" = "WHO CAN SEE MY SAVED MUSIC"; +"Privacy.SecretChatsLinkPreviews" = "Link Previews"; +"Privacy.SecretChatsLinkPreviewsHelp" = "Link previews will be generated on Telegram servers. We do not store any data about the links you send."; +"Privacy.SecretChatsTitle" = "SECRET CHATS"; +"Privacy.TopPeers" = "Suggest Frequent Contacts"; +"Privacy.TopPeersDelete" = "Delete"; +"Privacy.TopPeersHelp" = "Display people you message frequently at the top of the search section for quick access."; +"Privacy.TopPeersWarning" = "This will delete all data about the people you message frequently as well as the inline bots you are likely to use."; +"Privacy.VoiceMessages" = "Voice Messages"; +"Privacy.VoiceMessages.AlwaysAllow.Title" = "Always Allow"; +"Privacy.VoiceMessages.CustomHelp" = "You can restrict who can send you voice or video messages with granular precision."; +"Privacy.VoiceMessages.CustomShareHelp" = "These users will or will not be able to send you voice messages regardless of the settings above."; +"Privacy.VoiceMessages.NeverAllow.Title" = "Never Allow"; +"Privacy.VoiceMessages.NonPremiumHelp" = "Subscribe to Telegram Premium to restrict who can send you voice or video messages.\n\n[What is Telegram Premium?]()"; +"Privacy.VoiceMessages.Tooltip" = "Only subscribers of [Telegram Premium]() can restrict receiving voice messages."; +"Privacy.VoiceMessages.WhoCanSend" = "WHO CAN SEND ME VOICE & VIDEO MESSAGES"; +"PrivacyInfo.ShowLastSeen.ButtonTitle" = "Show My Last Seen to Everyone"; +"PrivacyInfo.ShowLastSeen.PremiumInfo" = "Subscribing will let you see **%@'s** Last Seen status without showing yours."; +"PrivacyInfo.ShowLastSeen.Text" = "To see **%@'s** Last Seen time, either start showing your own Last Seen Time..."; +"PrivacyInfo.ShowLastSeen.Title" = "Show Your Last Seen"; +"PrivacyInfo.ShowReadTime.AlwaysToast.Text" = "Set **Last Seen** privacy to 'Nobody' or 'My Contacts.'"; +"PrivacyInfo.ShowReadTime.ButtonTitle" = "Show My Read Time"; +"PrivacyInfo.ShowReadTime.PremiumInfo" = "Subscribing will let you see **%@'s** read time without showing yours."; +"PrivacyInfo.ShowReadTime.Text" = "To see when **%@** read the message, either start showing your own read time:"; +"PrivacyInfo.ShowReadTime.Title" = "Show Your Read Time"; +"PrivacyInfo.UpgradeToPremium.ButtonTitle" = "Subscribe to Telegram Premium"; +"PrivacyInfo.UpgradeToPremium.Title" = "Upgrade to Premium"; +"PrivacyLastSeenSettings.AddUsers#one" = "Add 1 user to this list?"; +"PrivacyLastSeenSettings.AddUsers#other" = "Add %@ users to this list?"; +"PrivacyLastSeenSettings.AlwaysShareWith" = "Always Share With"; +"PrivacyLastSeenSettings.AlwaysShareWith.Placeholder" = "Always share with users..."; +"PrivacyLastSeenSettings.AlwaysShareWith.Title" = "Always Share"; +"PrivacyLastSeenSettings.CustomHelp" = "Unless you are a Premium user, you won't see Last Seen or Online statuses for people with whom you don't share yours. Approximate times will be shown instead (recently, within a week, within a month)."; +"PrivacyLastSeenSettings.CustomShareSettings.Delete" = "Delete"; +"PrivacyLastSeenSettings.CustomShareSettingsHelp" = "You can add users or entire groups as exceptions that will override the settings above."; +"PrivacyLastSeenSettings.EmpryUsersPlaceholder" = "Add Users"; +"PrivacyLastSeenSettings.GroupsAndChannelsHelp" = "Change who can add you to groups and channels."; +"PrivacyLastSeenSettings.NeverShareWith" = "Never Share With"; +"PrivacyLastSeenSettings.NeverShareWith.Placeholder" = "Never share with users..."; +"PrivacyLastSeenSettings.NeverShareWith.Title" = "Never Share"; +"PrivacyLastSeenSettings.Title" = "Last Seen & Online"; +"PrivacyLastSeenSettings.WhoCanSeeMyTimestamp" = "WHO CAN SEE MY LAST SEEN"; +"PrivacyPhoneNumberSettings.CustomDisabledHelp" = "Users who add your number to their contacts will see it on Telegram only if they are your contacts."; +"PrivacyPhoneNumberSettings.CustomHelp" = "Users who have your number saved in their contacts will also see it on Telegram."; +"PrivacyPhoneNumberSettings.CustomPublicLink" = "Users who have your number saved in their contacts will also see it on Telegram.\n\nThis public link opens a chat with you:\n[https://t.me/%@]()"; +"PrivacyPhoneNumberSettings.DiscoveryHeader" = "WHO CAN FIND ME BY MY NUMBER"; +"PrivacyPhoneNumberSettings.WhoCanSeeMyPhoneNumber" = "WHO CAN SEE MY PHONE NUMBER"; +"PrivacyPolicy.Accept" = "Accept & Continue"; +"PrivacyPolicy.AgeVerificationAgree" = "Agree"; +"PrivacyPolicy.AgeVerificationMessage" = "Tap Agree to confirm that you are %@ or older."; +"PrivacyPolicy.AgeVerificationTitle" = "Age Verification"; +"PrivacyPolicy.Decline" = "Decline"; +"PrivacyPolicy.DeclineDeclineAndDelete" = "Decline & Delete"; +"PrivacyPolicy.DeclineDeleteNow" = "Delete Now"; +"PrivacyPolicy.DeclineLastWarning" = "Warning, this will irreversibly delete your Telegram account and all the data you store in the Telegram cloud.\n\nImportant: You can Cancel now and export your data first instead of losing it. (To do this, open the latest version of Telegram Desktop and go to Settings > Advanced > Export Telegram data.)"; +"PrivacyPolicy.DeclineMessage" = "Unfortunately, there are no other options. Unlike other apps, Telegram does not use user data for ad targeting or other commercial purposes. Telegram only stores the information it needs to function as a feature-rich cloud service. You can adjust how your data is used in Privacy and Security settings.\n\nIf you are not comfortable with Telegram's modest needs, it won't be possible for us to provide you with this service."; +"PrivacyPolicy.DeclineTitle" = "Decline"; +"PrivacyPolicy.Title" = "Privacy Policy and Terms of Service"; +"PrivacySettings.AuthSessions" = "Active Sessions"; +"PrivacySettings.AutoArchive" = "Archive and Mute"; +"PrivacySettings.AutoArchiveInfo" = "Automatically archive and mute new chats, groups and channels from non-contacts."; +"PrivacySettings.AutoArchiveTitle" = "NEW CHATS FROM UNKNOWN USERS"; +"PrivacySettings.BlockedPeersEmpty" = "None"; +"PrivacySettings.CategoryBots" = "Mini Apps"; +"PrivacySettings.CategoryPremiumUsers" = "Premium Users"; +"PrivacySettings.DataSettings" = "Data Settings"; +"PrivacySettings.DataSettingsHelp" = "Control which of your data is stored in the cloud and used by Telegram to enable advanced features."; +"PrivacySettings.DeleteAccountHelp" = "If you do not come online at least once within this period, your account will be deleted along with all messages and contacts."; +"PrivacySettings.DeleteAccountIfAwayFor" = "If Away For"; +"PrivacySettings.DeleteAccountNow" = "Delete Account Now"; +"PrivacySettings.DeleteAccountTitle" = "AUTOMATICALLY DELETE MY ACCOUNT"; +"PrivacySettings.InviteItem" = "Invites"; +"PrivacySettings.InviteSectionFooter" = "You can restrict which users are allowed to add you to groups and channels."; +"PrivacySettings.LastSeen" = "Last Seen & Online"; +"PrivacySettings.LastSeenCloseFriends" = "Close Friends"; +"PrivacySettings.LastSeenCloseFriendsPlus" = "Close Friends (+%@)"; +"PrivacySettings.LastSeenContacts" = "My Contacts"; +"PrivacySettings.LastSeenContactsMinus" = "My Contacts (-%@)"; +"PrivacySettings.LastSeenContactsMinusPlus" = "My Contacts (-%@, +%@)"; +"PrivacySettings.LastSeenContactsPlus" = "My Contacts (+%@)"; +"PrivacySettings.LastSeenEverybody" = "Everybody"; +"PrivacySettings.LastSeenEverybodyMinus" = "Everybody (-%@)"; +"PrivacySettings.LastSeenNobody" = "Nobody"; +"PrivacySettings.LastSeenNobodyPlus" = "Nobody (+%@)"; +"PrivacySettings.LastSeenTitle" = "Last Seen & Online"; +"PrivacySettings.LoginEmail" = "Login Email"; +"PrivacySettings.LoginEmailAlertChange" = "Change Email"; +"PrivacySettings.LoginEmailAlertText" = "This email address will be used every time you log in to your Telegram account from a new device."; +"PrivacySettings.LoginEmailInfo" = "Change your email address for Telegram login codes."; +"PrivacySettings.LoginEmailSetupInfo" = "Setup your email address for Telegram login codes."; +"PrivacySettings.Passcode" = "Passcode Lock"; +"PrivacySettings.PasscodeAndFaceId" = "Passcode & Face ID"; +"PrivacySettings.PasscodeAndTouchId" = "Passcode & Touch ID"; +"PrivacySettings.PasscodeOff" = "Off"; +"PrivacySettings.PasscodeOn" = "On"; +"PrivacySettings.Passkey" = "Passkeys"; +"PrivacySettings.PhoneNumber" = "Phone Number"; +"PrivacySettings.PrivacyTitle" = "PRIVACY"; +"PrivacySettings.SearchUserTypesHeader" = "USER TYPES"; +"PrivacySettings.SearchUsersPlaceholder" = "Search users and groups"; +"PrivacySettings.SearchUsersTitle" = "Add Users"; +"PrivacySettings.SecurityTitle" = "SECURITY"; +"PrivacySettings.Title" = "Privacy and Security"; +"PrivacySettings.TwoStepAuth" = "Two-Step Verification"; +"PrivacySettings.ValueBots" = "Mini Apps"; +"PrivacySettings.ValueBotsPlus" = "Mini Apps +%@"; +"PrivacySettings.ValueCloseFriendsAndPremium" = "Close Friends, Premium"; +"PrivacySettings.ValueCloseFriendsAndPremiumPlus" = "Close Friends, Premium +%@"; +"PrivacySettings.ValuePremium" = "Premium Users"; +"PrivacySettings.ValuePremiumAndContacts" = "Contacts & Premium"; +"PrivacySettings.ValuePremiumPlus" = "Premium Users +%@"; +"PrivacySettings.WebSessions" = "Active Websites"; +"PrivateDataSettings.Title" = "Data Settings"; +"Profile.About" = "bio"; +"Profile.AddToExisting" = "Add to Existing Contact"; +"Profile.AdditionalUsernames" = "also %@"; +"Profile.AvatarStoryCount#one" = "1 Story"; +"Profile.AvatarStoryCount#other" = "%d Stories"; +"Profile.BotInfo" = "about"; +"Profile.CreateEncryptedChatError" = "An error occurred."; +"Profile.CreateNewContact" = "Create New Contact"; +"Profile.EncryptionKey" = "Encryption Key"; +"Profile.MessageLifetime1d" = "1d"; +"Profile.MessageLifetime1h" = "1h"; +"Profile.MessageLifetime1m" = "1m"; +"Profile.MessageLifetime1w" = "1w"; +"Profile.MessageLifetime2s" = "2s"; +"Profile.MessageLifetime5s" = "5s"; +"Profile.MessageLifetimeForever" = "Off"; +"Profile.PersonalChannelSectionTitle" = "CHANNEL"; +"Profile.SendGift" = "Send a Gift"; +"Profile.ShareContactButton" = "Share Contact"; +"Profile.Username" = "username"; +"ProfileColorSetup.AccountColorInfoLabel" = "Choose a color for your profile"; +"ProfileColorSetup.ApplyStyle" = "Apply Style"; +"ProfileColorSetup.BrowseGiftsForPurchase" = "Browse gifts available for purchase >"; +"ProfileColorSetup.ChannelColorInfoLabel" = "Choose a color for the channel's profile"; +"ProfileColorSetup.IconSectionTitle" = "ADD ICONS TO PROFILE"; +"ProfileColorSetup.MyGifts" = "My Gifts"; +"ProfileColorSetup.NoNameGiftsPlaceholder" = "You don't have any gifts you can use as styles for your name."; +"ProfileColorSetup.NoProfileGiftsPlaceholder" = "You don't have any gifts you can use as styles for your profile."; +"ProfileColorSetup.ProfileColorPreviewInfo" = "You can change the color of your name and customize replies to you. [Change >]()"; +"ProfileColorSetup.ResetAction" = "Reset Profile Color"; +"ProfileColorSetup.TitleChannelColor" = "Set Channel Color"; +"ProfileColorSetup.TitleName" = "Name"; +"ProfileColorSetup.TitleProfile" = "Profile"; +"ProfileColorSetup.ToastAccountColorUpdated" = "Your color has been updated."; +"ProfileLevelInfo.CloseButton" = "Understood"; +"ProfileLevelInfo.FutureRatingTitle" = "Future Rating"; +"ProfileLevelInfo.Item0.Badge" = "ADDED"; +"ProfileLevelInfo.Item0.Text" = "100% of the Stars spent on gifts purchased from Telegram."; +"ProfileLevelInfo.Item0.Title" = "Gifts from Telegram"; +"ProfileLevelInfo.Item1.Badge" = "ADDED"; +"ProfileLevelInfo.Item1.Text" = "20% of the Stars spent on gifts or posts from users and channels."; +"ProfileLevelInfo.Item1.Title" = "Gifts and Posts from Users"; +"ProfileLevelInfo.Item2.Badge" = "DEDUCTED"; +"ProfileLevelInfo.Item2.Text" = "10x refunded Stars and 85% of gifts converted back to Stars."; +"ProfileLevelInfo.Item2.Title" = "Refunds and Conversions"; +"ProfileLevelInfo.LevelIndex#one" = "Level %@"; +"ProfileLevelInfo.LevelIndex#other" = "Level %@"; +"ProfileLevelInfo.MyDescriptionDays#one" = "%@ day"; +"ProfileLevelInfo.MyDescriptionDays#other" = "%@ days"; +"ProfileLevelInfo.MyDescriptionInPreview" = "The rating will update in %1$@,\nafter %2$@ added. [Back >]()"; +"ProfileLevelInfo.MyDescriptionInPreviewDays#one" = "%@ day"; +"ProfileLevelInfo.MyDescriptionInPreviewDays#other" = "%@ days"; +"ProfileLevelInfo.MyDescriptionInPreviewPoints#one" = "%@ point is"; +"ProfileLevelInfo.MyDescriptionInPreviewPoints#other" = "%@ points are"; +"ProfileLevelInfo.MyDescriptionInPreviewToday#one" = "This will be your rating today,\nafter %@ added. [Back >]()"; +"ProfileLevelInfo.MyDescriptionInPreviewToday#other" = "This will be your rating today,\nafter %@ added. [Back >]()"; +"ProfileLevelInfo.MyDescriptionPoints#one" = "%@ point is"; +"ProfileLevelInfo.MyDescriptionPoints#other" = "%@ points are"; +"ProfileLevelInfo.MyDescriptionPreview" = "The rating will update in %@.\n%@ pending. [Preview >]()"; +"ProfileLevelInfo.MyDescriptionToday#one" = "The rating updates today.\n%@ point is pending."; +"ProfileLevelInfo.MyDescriptionToday#other" = "The rating updates today.\n%@ points are pending."; +"ProfileLevelInfo.MyText" = "This rating reflects your activity\non Telegram. It is based on:"; +"ProfileLevelInfo.NegativeRating" = "Negative rating"; +"ProfileLevelInfo.OtherDescription" = "The rating reflects **%@'s** activity on Telegram. It is based on:"; +"ProfileLevelInfo.RatingTitle" = "Rating"; +"ProfileLevelInfo.Title" = "Rating"; +"ProfilePhoto.MainPhoto" = "Main Photo"; +"ProfilePhoto.MainVideo" = "Main Photo"; +"ProfilePhoto.OpenGallery" = "Open Gallery"; +"ProfilePhoto.OpenInEditor" = "Open in Editor"; +"ProfilePhoto.PublicPhoto" = "public photo"; +"ProfilePhoto.PublicVideo" = "public photo"; +"ProfilePhoto.SearchWeb" = "Search Web"; +"ProfilePhoto.SetEmoji" = "Set Emoji"; +"ProfilePhoto.SetMainPhoto" = "Set as Main Photo"; +"ProfilePhoto.SetMainVideo" = "Set as Main Photo"; +"Proxy.TooltipUnavailable" = "The proxy may be unavailable. Try selecting another one."; +"ProxyServer.VoiceOver.Active" = "Active"; +"QuickReplies.EmptyState.AddButton" = "Add Quick Reply"; +"QuickReplies.EmptyState.Text" = "Set up shortcuts with rich text and media to respond to messages faster."; +"QuickReplies.EmptyState.Title" = "No Quick Replies"; +"QuickReply.ChatRemoveAwayMessage.Text" = "You didn't create an away message. Exiting will remove it."; +"QuickReply.ChatRemoveAwayMessage.Title" = "Remove Away Message"; +"QuickReply.ChatRemoveGeneric.DeleteAction" = "Delete"; +"QuickReply.ChatRemoveGeneric.Text" = "You didn't create a quick reply message. Exiting will remove the shortcut."; +"QuickReply.ChatRemoveGeneric.Title" = "Remove Shortcut"; +"QuickReply.ChatRemoveGreetingMessage.Text" = "You didn't create a greeting message. Exiting will remove it."; +"QuickReply.ChatRemoveGreetingMessage.Title" = "Remove Greeting Message"; +"QuickReply.CreateShortcutText" = "Add a shortcut for your quick reply."; +"QuickReply.CreateShortcutTitle" = "New Quick Reply"; +"QuickReply.DeleteAction#one" = "Delete 1 Quick Reply"; +"QuickReply.DeleteAction#other" = "Delete %d Quick Replies"; +"QuickReply.DeleteConfirmationMultiple" = "Delete Quick Replies"; +"QuickReply.DeleteConfirmationSingle" = "Delete Quick Reply"; +"QuickReply.EditShortcutText" = "Add a new name for your shortcut."; +"QuickReply.EditShortcutTitle" = "Edit Shortcut"; +"QuickReply.InlineCreateAction" = "New Quick Reply"; +"QuickReply.SelectedTitle#one" = "%d Selected"; +"QuickReply.SelectedTitle#other" = "%d Selected"; +"QuickReply.ShortcutExistsInlineError" = "Shortcut with that name already exists"; +"QuickReply.ShortcutPlaceholder" = "Shortcut"; +"QuickReply.Title" = "Quick Replies"; +"QuickReply.TitleAwayMessage" = "Away Message"; +"QuickReply.TitleGreetingMessage" = "Greeting Message"; +"QuickSend.Photos#one" = "Send 1 Photo"; +"QuickSend.Photos#other" = "Send %@ Photos"; +"REACT_GIVEAWAY" = "%1$@ reacted %2$@ to your giveaway"; +"RankInfo.Admin.Text" = "This green tag %1$@ is **%2$@**'s admin tag in **%4$@**."; +"RankInfo.Admin.Title" = "Admin Tag"; +"RankInfo.AdminTag" = "Admin Tag"; +"RankInfo.ChangeInfo" = "Only admins can assign member tags in this group."; +"RankInfo.Member.Text" = "This gray tag %1$@ is **%2$@**'s member tag in **%4$@**."; +"RankInfo.Member.Title" = "Member Tag"; +"RankInfo.MemberTag" = "Member Tag"; +"RankInfo.Owner.Text" = "This purple tag %1$@ is **%2$@**'s owner tag in **%4$@**."; +"RankInfo.Owner.Title" = "Owner Tag"; +"RankInfo.OwnerTag" = "Owner Tag"; +"RankInfo.SetMyTag" = "Set My Tag"; +"ReassignBoost.AvailableIn" = "Available in %@"; +"ReassignBoost.Boosts#one" = "%@ boost is reassigned"; +"ReassignBoost.Boosts#other" = "%@ boosts are reassigned"; +"ReassignBoost.Description" = "To boost **%1$@**, reassign a previous boost or gift **Telegram Premium** to a friend to get **%2$@** additional boosts."; +"ReassignBoost.DescriptionWithLink" = "To boost **%1$@**, reassign a previous boost or [gift Telegram Premium]() to a friend to get **%2$@** additional boosts."; +"ReassignBoost.ExpiresOn" = "Boost expires on %@"; +"ReassignBoost.OtherChannels#one" = "%@ other chat"; +"ReassignBoost.OtherChannels#other" = "%@ other chats"; +"ReassignBoost.OtherGroups#one" = "%@ other group"; +"ReassignBoost.OtherGroups#other" = "%@ other groups"; +"ReassignBoost.OtherGroupsAndChannels#one" = "%@ other group and channel"; +"ReassignBoost.OtherGroupsAndChannels#other" = "%@ other groups and channels"; +"ReassignBoost.ReassignBoosts" = "Reassign Boosts"; +"ReassignBoost.Success" = "%1$@ from %2$@."; +"ReassignBoost.Title" = "Reassign Boosts"; +"ReassignBoost.WaitForCooldown" = "Wait until the boost is available or get **%1$@** more boosts by gifting a **Telegram Premium** subscription."; +"RecentSession.ConnectedBot.ConnectedFrom" = "CONNECTED FROM"; +"RecentSession.ConnectedBot.Date" = "Date"; +"RecentSession.ConnectedBot.Device" = "Device"; +"RecentSession.ConnectedBot.Subtitle" = "Chat Automation Bot"; +"RecentSessions.ConnectedBot.ConnectedTime" = "connected %@"; +"RecentSessions.ConnectedBot.TerminateCheckbox" = "Also terminate %@"; +"Reminders.DeleteRepeatingActionMultiple" = "Delete All Future Reminders"; +"Reminders.DeleteRepeatingActionSingle" = "Delete This Reminder Only"; +"Reminders.DeleteRepeatingText" = "Are you sure you want to delete this reminder? This is a repeating reminder."; +"Reminders.DeleteRepeatingTitle" = "Delete Repeating Reminder"; +"Replies.BlockAndDeleteRepliesActionTitle" = "Block and Delete Replies"; +"RepliesChat.DescriptionText" = "This chat helps you keep track of replies to your comments in Channels."; +"Report.AdditionalDetailsPlaceholder" = "Additional details..."; +"Report.AdditionalDetailsText" = "Please enter any additional details relevant for your report."; +"Report.Comment.Info" = "Please help us by telling what is wrong with the message you are reporting."; +"Report.Comment.Placeholder" = "Add Comment"; +"Report.Comment.Placeholder.Optional" = "Add Comment (Optional)"; +"Report.Report" = "Report"; +"Report.Send" = "Send Report"; +"Report.Succeed" = "Telegram moderators will review your report. Thank you!"; +"Report.Title.Bot" = "Report Bot"; +"Report.Title.Channel" = "Report Channel"; +"Report.Title.Group" = "Report Group"; +"Report.Title.Message" = "Report Message"; +"Report.Title.Story" = "Report Story"; +"Report.Title.User" = "Report User"; +"ReportAd.Help" = "Learn more about [Telegram Ad Policies and Guidelines]()."; +"ReportAd.Help_URL" = "https://ads.telegram.org/guidelines"; +"ReportAd.Hidden" = "You will no longer see ads from Telegram."; +"ReportAd.Reported" = "We will review this ad to ensure it matches our [Ad Policies and Guidelines]()."; +"ReportAd.Title" = "Report Ad"; +"ReportGroupLocation.Report" = "Report"; +"ReportGroupLocation.Text" = "Please tell us if this group is not related to this location."; +"ReportGroupLocation.Title" = "Report Unrelated Group"; +"ReportPeer.AlertSuccess" = "Thank you!\nYour report will be reviewed by our team."; +"ReportPeer.BanAndReport" = "Ban and Report"; +"ReportPeer.ReasonChildAbuse" = "Child Abuse"; +"ReportPeer.ReasonCopyright" = "Copyright"; +"ReportPeer.ReasonFake" = "Fake Account"; +"ReportPeer.ReasonIllegalDrugs" = "Illegal Drugs"; +"ReportPeer.ReasonOther" = "Other"; +"ReportPeer.ReasonOther.Placeholder" = "Description"; +"ReportPeer.ReasonOther.Send" = "Send"; +"ReportPeer.ReasonOther.Title" = "Report"; +"ReportPeer.ReasonPersonalDetails" = "Personal Details"; +"ReportPeer.ReasonPornography" = "Pornography"; +"ReportPeer.ReasonSpam" = "Spam"; +"ReportPeer.ReasonViolence" = "Violence"; +"ReportPeer.Report" = "Report"; +"ReportPeer.ReportReaction" = "Report Reaction"; +"ReportPeer.ReportReaction.BanAndReport" = "Ban and Report"; +"ReportPeer.ReportReaction.Report" = "Report Reaction"; +"ReportPeer.ReportReaction.Text" = "Are you sure you want to report reactions from this user?"; +"ReportSpam.DeleteThisChat" = "Delete this Chat"; +"RequestPeer.BotsAllEmpty" = "You don't have any bots."; +"RequestPeer.ChannelsAllEmpty" = "You don't have any channels."; +"RequestPeer.ChannelsEmpty" = "You don't have channels that meet the following requirements:"; +"RequestPeer.ChooseBotTitle" = "Choose a Bot"; +"RequestPeer.ChooseChannelTitle" = "Choose a Channel"; +"RequestPeer.ChooseGroupTitle" = "Choose a Group"; +"RequestPeer.ChooseUserTitle" = "Choose a User"; +"RequestPeer.CreateNewChannel" = "Create a New Channel for This"; +"RequestPeer.CreateNewGroup" = "Create a New Group for This"; +"RequestPeer.GroupsAllEmpty" = "You don't have any groups."; +"RequestPeer.GroupsEmpty" = "You don't have groups that meet the following requirements:"; +"RequestPeer.ReachedMaximum#one" = "You can select up to %@ user."; +"RequestPeer.ReachedMaximum#other" = "You can select up to %@ users."; +"RequestPeer.Requirement.Channel.CreatorOn" = "You must be the owner of the channel."; +"RequestPeer.Requirement.Channel.HasUsernameOff" = "The channel must be private."; +"RequestPeer.Requirement.Channel.HasUsernameOn" = "The channel must be public."; +"RequestPeer.Requirement.Channel.Rights" = "You must have the admin rights to %@."; +"RequestPeer.Requirement.Channel.Rights.AddAdmins" = "add new admins"; +"RequestPeer.Requirement.Channel.Rights.Anonymous" = "remain anonymous"; +"RequestPeer.Requirement.Channel.Rights.Delete" = "delete messages"; +"RequestPeer.Requirement.Channel.Rights.Edit" = "edit messages"; +"RequestPeer.Requirement.Channel.Rights.Info" = "change channel info"; +"RequestPeer.Requirement.Channel.Rights.Invite" = "invite users via link"; +"RequestPeer.Requirement.Channel.Rights.Pin" = "pin messages"; +"RequestPeer.Requirement.Channel.Rights.Send" = "post messages"; +"RequestPeer.Requirement.Channel.Rights.Topics" = "manage topics"; +"RequestPeer.Requirement.Channel.Rights.VideoChats" = "manage live streams"; +"RequestPeer.Requirement.Group.CreatorOn" = "You must be the owner of the group."; +"RequestPeer.Requirement.Group.ForumOff" = "The group must have topics turned off."; +"RequestPeer.Requirement.Group.ForumOn" = "The group must have topics turned on."; +"RequestPeer.Requirement.Group.HasUsernameOff" = "The group must be private."; +"RequestPeer.Requirement.Group.HasUsernameOn" = "The group must be public."; +"RequestPeer.Requirement.Group.ParticipantOn" = "The bot\u00a0must be in the group."; +"RequestPeer.Requirement.Group.Rights" = "You must have the admin rights to %@."; +"RequestPeer.Requirement.Group.Rights.AddAdmins" = "add new admins"; +"RequestPeer.Requirement.Group.Rights.Anonymous" = "remain anonymous"; +"RequestPeer.Requirement.Group.Rights.Ban" = "ban users"; +"RequestPeer.Requirement.Group.Rights.Delete" = "delete messages"; +"RequestPeer.Requirement.Group.Rights.Edit" = "edit messages"; +"RequestPeer.Requirement.Group.Rights.Info" = "change group info"; +"RequestPeer.Requirement.Group.Rights.Invite" = "add members"; +"RequestPeer.Requirement.Group.Rights.Pin" = "pin messages"; +"RequestPeer.Requirement.Group.Rights.Send" = "post messages"; +"RequestPeer.Requirement.Group.Rights.Topics" = "manage topics"; +"RequestPeer.Requirement.Group.Rights.VideoChats" = "manage video chats"; +"RequestPeer.Requirement.UserPremiumOff" = "The user must not have a Premium subscription."; +"RequestPeer.Requirement.UserPremiumOn" = "The user must have a Premium subscription."; +"RequestPeer.Requirements" = "Requirements:"; +"RequestPeer.SelectUsers" = "Select Users"; +"RequestPeer.SelectUsers.SearchPlaceholder" = "Search"; +"RequestPeer.SelectionConfirmationInviteAdminText" = "This will also add %1$@ to %2$@ as an admin."; +"RequestPeer.SelectionConfirmationInviteText" = "This will also add %1$@ to %2$@."; +"RequestPeer.SelectionConfirmationInviteWithRightsText" = "This will also add %1$@ to %2$@ with the following rights:\n\n%3$@"; +"RequestPeer.SelectionConfirmationSend" = "Send"; +"RequestPeer.SelectionConfirmationTitle" = "Are you sure you want to send %1$@ to %2$@?"; +"RequestPeer.UsersAllEmpty" = "You don't know any users."; +"RequestPeer.UsersEmpty" = "You don't know users who meet the following requirements:"; +"Resolve.ChannelErrorNotFound" = "Sorry, this channel doesn't seem to exist."; +"Resolve.ErrorNotFound" = "Sorry, this user doesn't seem to exist."; +"Resolve.GiftErrorBurned" = "Sorry, this gift has already been burned."; +"Resolve.GiftErrorNotFound" = "Sorry, this gift doesn't seem to exist."; +"RichTextPreview.Formula" = "[formula]"; +"RichTextPreview.Music" = "Music"; +"RichTextPreview.Table" = "[table]"; +"SaveIncomingPhotosSettings.From" = "SAVE INCOMING PHOTOS FROM"; +"SaveIncomingPhotosSettings.Title" = "Save Incoming Photos"; +"SavedMessages.OpenChannel" = "Open Channel"; +"SavedMessages.OpenChat" = "Open Chat"; +"SavedMessages.OpenGroup" = "Open Group"; +"SavedMessages.SubChatDeleted" = "Saved messages deleted."; +"ScheduleLiveStream.ChannelText" = "Subscribers of the channel will be notified that the live stream will start in %@."; +"ScheduleLiveStream.Title" = "Schedule Live Stream"; +"ScheduleMessage.PremiumRequired.Add" = "Add"; +"ScheduleMessage.PremiumRequired.Text" = "Subscribe to **Telegram Premium** to schedule repeating messages."; +"ScheduleMessage.PremiumRequired.Title" = "Premium Required"; +"ScheduleMessage.Repeat" = "Repeat"; +"ScheduleMessage.RepeatPeriod.3Months" = "Every 3 Months"; +"ScheduleMessage.RepeatPeriod.6Months" = "Every 6 Months"; +"ScheduleMessage.RepeatPeriod.Biweekly" = "Biweekly"; +"ScheduleMessage.RepeatPeriod.Daily" = "Daily"; +"ScheduleMessage.RepeatPeriod.Monthly" = "Monthly"; +"ScheduleMessage.RepeatPeriod.Never" = "Never"; +"ScheduleMessage.RepeatPeriod.Weekly" = "Weekly"; +"ScheduleMessage.RepeatPeriod.Yearly" = "Yearly"; +"ScheduleMessage.SilentPosting.ChannelDisabled" = "Subscribers will be notified"; +"ScheduleMessage.SilentPosting.ChannelEnabled" = "Subscribers will receive a silent notification"; +"ScheduleMessage.SilentPosting.GroupDisabled" = "Members will be notified"; +"ScheduleMessage.SilentPosting.GroupEnabled" = "Members will receive a silent notification"; +"ScheduleMessage.SilentPosting.UserDisabled" = "%@ will be notified"; +"ScheduleMessage.SilentPosting.UserEnabled" = "%@ will receive a silent notification"; +"ScheduleMessage.SilentPosting.YouDisabled" = "You will be notified"; +"ScheduleMessage.SilentPosting.YouEnabled" = "You will receive a silent notification"; +"ScheduleMessage.Time" = "Time"; +"ScheduleVoiceChat.ChannelText" = "Subscribers of the channel will be notified that the live stream starts in %@."; +"ScheduleVoiceChat.GroupText" = "Members of the group will be notified that the video chat starts in %@."; +"ScheduleVoiceChat.ScheduleOn" = "Start on %@ at %@"; +"ScheduleVoiceChat.ScheduleToday" = "Start today at %@"; +"ScheduleVoiceChat.ScheduleTomorrow" = "Start tomorrow at %@"; +"ScheduleVoiceChat.Title" = "Schedule Video Chat"; +"ScheduleVoiceChatChannel.Title" = "Schedule Video Chat"; +"ScheduledIn.Days#one" = "%@ day"; +"ScheduledIn.Days#other" = "%@ days"; +"ScheduledIn.Hours#one" = "%@ hour"; +"ScheduledIn.Hours#other" = "%@ hours"; +"ScheduledIn.Minutes#one" = "%@ minute"; +"ScheduledIn.Minutes#other" = "%@ minutes"; +"ScheduledIn.Months#one" = "%@ month"; +"ScheduledIn.Months#other" = "%@ months"; +"ScheduledIn.Seconds#one" = "%@ second"; +"ScheduledIn.Seconds#other" = "%@ seconds"; +"ScheduledIn.Weeks#one" = "%@ week"; +"ScheduledIn.Weeks#other" = "%@ weeks"; +"ScheduledIn.Years#one" = "%@ year"; +"ScheduledIn.Years#other" = "%@ years"; +"ScheduledMessages.BotActionUnavailable" = "This action will become available after the message is published."; +"ScheduledMessages.ClearAll" = "Cancel All"; +"ScheduledMessages.ClearAllConfirmation" = "Clear Scheduled Messages"; +"ScheduledMessages.Delete" = "Delete Scheduled Message"; +"ScheduledMessages.DeleteMany" = "Delete Scheduled Messages"; +"ScheduledMessages.DeleteRepeatingActionMultiple" = "Delete All Future Messages"; +"ScheduledMessages.DeleteRepeatingActionSingle" = "Delete This Message Only"; +"ScheduledMessages.DeleteRepeatingText" = "Are you sure you want to delete this scheduled message? This is a repeating message."; +"ScheduledMessages.DeleteRepeatingTitle" = "Delete Repeating Message"; +"ScheduledMessages.EditTime" = "Reschedule"; +"ScheduledMessages.EmptyPlaceholder" = "No scheduled messages here yet..."; +"ScheduledMessages.PollUnavailable" = "Voting will become available after the message is published."; +"ScheduledMessages.Reminder.Delete" = "Delete Reminder"; +"ScheduledMessages.Reminder.DeleteMany" = "Delete Reminders"; +"ScheduledMessages.ReminderNotification" = "📅 Reminder"; +"ScheduledMessages.RemindersTitle" = "Reminders"; +"ScheduledMessages.ScheduledDate" = "Scheduled for %@"; +"ScheduledMessages.ScheduledOnline" = "Scheduled until online"; +"ScheduledMessages.ScheduledToday" = "Scheduled for today"; +"ScheduledMessages.SendNow" = "Send Now"; +"ScheduledMessages.Title" = "Scheduled Messages"; +"ScheduledMessages.TodoUnavailable" = "Completing tasks will become available after the message is published."; +"SearchImages.NoImagesFound" = "No images found"; +"SearchImages.Title" = "Albums"; +"SecretChat.Title" = "Secret Chat"; +"SecretGIF.NotViewedYet" = "%@ hasn't played this GIF yet"; +"SecretGif.Title" = "Disappearing GIF"; +"SecretImage.NotViewedYet" = "%@ hasn't opened this photo yet"; +"SecretImage.Title" = "Disappearing Photo"; +"SecretImage.ViewOnce.Title" = "Disappearing Photo"; +"SecretTimer.ImageDescription" = "If you set a timer, the photo will self-destruct after it was viewed."; +"SecretTimer.VideoDescription" = "If you set a timer, the video will self-destruct after it is viewed."; +"SecretVideo.NotViewedYet" = "%@ hasn't played this video yet"; +"SecretVideo.Title" = "Disappearing Video"; +"SecretVideo.ViewOnce.Title" = "Disappearing Video"; +"SendInviteLink.ActionClose" = "Close"; +"SendInviteLink.ActionInvite" = "Send Invite Link"; +"SendInviteLink.ActionSkip" = "Skip"; +"SendInviteLink.ChannelTextContactsAndPremiumMultipleUsers#one" = "{user_list}, and **%d** more person only accept invitations to channels from contacts and **Premium** users."; +"SendInviteLink.ChannelTextContactsAndPremiumMultipleUsers#other" = "{user_list}, and **%d** more people only accept invitations to channels from contacts and **Premium** users."; +"SendInviteLink.ChannelTextContactsAndPremiumOneUser" = "**%@** accepts invitations to channels from contacts and **Premium** users."; +"SendInviteLink.InviteTitle" = "Invite via Link"; +"SendInviteLink.LinkUnavailableTitle" = "You can't create a link"; +"SendInviteLink.PremiumOrSendSectionSeparator" = "or"; +"SendInviteLink.SkipAction" = "Skip"; +"SendInviteLink.StatusAvailableToPremiumOnly" = "Available only to Premium users"; +"SendInviteLink.SubscribeToPremiumButton" = "Subscribe to Telegram Premium"; +"SendInviteLink.TextAvailableMultipleUsers#one" = "**%d user** restricts adding them to groups.\nYou can send them an invite link instead."; +"SendInviteLink.TextAvailableMultipleUsers#other" = "**%d users** restrict adding them to groups.\nYou can send them an invite link instead."; +"SendInviteLink.TextAvailableSingleUser" = "**%@** restricts adding them to groups.\nYou can send them an invite link instead."; +"SendInviteLink.TextCallsRestrictedMultipleUsers#one" = "{user_list}, and **%d** more person do not accept calls."; +"SendInviteLink.TextCallsRestrictedMultipleUsers#other" = "{user_list}, and **%d** more people do not accept calls."; +"SendInviteLink.TextCallsRestrictedOneUser" = "%@ does not accept calls."; +"SendInviteLink.TextCallsRestrictedSendInviteLink" = "You can try to send an invite link instead."; +"SendInviteLink.TextCallsRestrictedSendOneInviteLink" = "**%@** restricts calling them. You can send them an invite link to this call instead."; +"SendInviteLink.TextContactsAndPremiumMultipleUsers#one" = "{user_list}, and **%d** more person only accept invitations to groups from contacts and **Premium** users."; +"SendInviteLink.TextContactsAndPremiumMultipleUsers#other" = "{user_list}, and **%d** more people only accept invitations to groups from contacts and **Premium** users."; +"SendInviteLink.TextContactsAndPremiumOneUser" = "**%@** accepts invitations to groups from contacts and **Premium** users."; +"SendInviteLink.TextSendInviteLink" = "You can try to send an invite link instead."; +"SendInviteLink.TextUnavailableMultipleUsers#one" = "**%d user** can only be invited via link, but you don't have permission to share invite links to this group."; +"SendInviteLink.TextUnavailableMultipleUsers#other" = "**%d users** can only be invited via link, but you don't have permission to share invite links to this group."; +"SendInviteLink.TextUnavailableSingleUser" = "**%@** can only be invited via invite link, but you don't have permission to share invite links to this group."; +"SendInviteLink.TitleUpgradeToPremium" = "Upgrade to Premium"; +"SendStarReactions.Balance" = "Balance"; +"SendStarReactions.LiveStreamActionButton" = "Add # %@"; +"SendStarReactions.LiveStreamMessageText" = "Highlight and pin a message\nby adding Stars for **%@**"; +"SendStarReactions.LiveStreamMessageTitle" = "Highlight and Pin"; +"SendStarReactions.LiveStreamPerk1Title" = "pin in chat"; +"SendStarReactions.LiveStreamPerk2Title" = "characters"; +"SendStarReactions.LiveStreamPerk3Title" = "emoji"; +"SendStarReactions.LiveStreamReactionText" = "Highlight and pin a message\nby adding Stars for **%@**"; +"SendStarReactions.OwnLiveStreamCloseButton" = "Close"; +"SendStarReactions.OwnLiveStreamInfoText" = "You can't send Star Reactions to your own Live Story."; +"SendStarReactions.SectionTop" = "Top Senders"; +"SendStarReactions.SendButtonTitle" = "Send # %@"; +"SendStarReactions.ShowMyselfInTop" = "Show me in Top Senders"; +"SendStarReactions.SliderTop" = "TOP"; +"SendStarReactions.SubtitleFrom" = "from %@"; +"SendStarReactions.TermsOfServiceFooter" = "By sending Stars you agree to the [Terms of Service](https://telegram.org/tos/stars)"; +"SendStarReactions.TextGeneric" = "Choose how many Stars you want to send to **%@** to support this post."; +"SendStarReactions.TextSentStars#one" = "You sent **1** star to support this post."; +"SendStarReactions.TextSentStars#other" = "You sent **%d** stars to support this post."; +"SendStarReactions.Title" = "React with Stars"; +"SendStarReactions.UserLabelAnonymous" = "Anonymous"; +"SensitiveContent.Enable.Confirm" = "Confirm"; +"SensitiveContent.Enable.Text" = "Confirm that you are over 18 years old and update settings to see potentially explicit and sensitive content."; +"SensitiveContent.Enable.Title" = "18+ Content"; +"SensitiveContent.SettingsInfo" = "You can update the visibility of sensitive media in [Data and Storage > Show 18+ Content]()."; +"SensitiveContent.ShowAlways" = "Always show 18+ media"; +"SensitiveContent.Text" = "This media may contain sensitive content suitable only for adults.\nDo you still want to view it?"; +"SensitiveContent.Title" = "18+ Content"; +"SensitiveContent.ViewAnyway" = "View Anyway"; +"ServiceMessage.GameScoreExtended#one" = "{name} scored %@ in {game}"; +"ServiceMessage.GameScoreExtended#other" = "{name} scored %@ in {game}"; +"ServiceMessage.GameScoreSelfExtended#one" = "You scored %@ in {game}"; +"ServiceMessage.GameScoreSelfExtended#other" = "You scored %@ in {game}"; +"ServiceMessage.GameScoreSelfSimple#one" = "You scored %@"; +"ServiceMessage.GameScoreSelfSimple#other" = "You scored %@"; +"ServiceMessage.GameScoreSimple#one" = "{name} scored %@"; +"ServiceMessage.GameScoreSimple#other" = "{name} scored %@"; +"SessionReview.NoticeText" = "Never send your login code to anyone or you can lose your Telegram account!"; +"SessionReview.OkAction" = "Got it"; +"SessionReview.Text" = "We have terminated the login attempt from **%1$@**, **%2$@**"; +"SessionReview.Title" = "New Login Prevented"; +"SetTimeoutFor.Days#one" = "Set for 1 day"; +"SetTimeoutFor.Days#other" = "Set for %@ days"; +"SetTimeoutFor.Hours#one" = "Set for 1 hour"; +"SetTimeoutFor.Hours#other" = "Set for %@ hours"; +"SetTimeoutFor.Minutes#one" = "Set for 1 minute"; +"SetTimeoutFor.Minutes#other" = "Set for %@ minutes"; +"Settings.About" = "Bio"; +"Settings.About.Help" = "Any details such as age, occupation or city.\nExample: 23 y.o. designer from San Francisco."; +"Settings.About.PrivacyHelp" = "You can add a few lines about yourself. Choose who can see your bio in [Settings]()."; +"Settings.About.PrivacyHelpContacts" = "Only your contacts can see your bio. [Change >]()"; +"Settings.About.PrivacyHelpEmpty" = "A few words about you."; +"Settings.About.PrivacyHelpEveryone" = "Everyone can see your bio. [Change >]()"; +"Settings.About.PrivacyHelpNobody" = "Nobody can see your bio. [Change >]()"; +"Settings.About.Title" = "Bio"; +"Settings.AboutEmpty" = "Add"; +"Settings.AddAccount" = "Add Account"; +"Settings.AddAnotherAccount" = "Add Another Account"; +"Settings.AddAnotherAccount.Help" = "You can connect multiple accounts with different phone numbers."; +"Settings.AddAnotherAccount.PremiumHelp" = "You can add up to four accounts with different phone numbers."; +"Settings.AddDevice" = "Scan QR"; +"Settings.AppLanguage" = "Language"; +"Settings.AppLanguage.Unofficial" = "UNOFFICIAL"; +"Settings.Appearance" = "Appearance"; +"Settings.AppleWatch" = "Apple Watch"; +"Settings.ApplyProxyAlert" = "Are you sure you want to enable this proxy?\nServer: %1$@\nPort: %2$@\n\nYou can change your proxy server later in Settings > Data and Storage."; +"Settings.ApplyProxyAlertCredentials" = "Are you sure you want to enable this proxy?\nServer: %1$@\nPort: %2$@\nUsername: %3$@\nPassword: %4$@\n\nYou can change your proxy server later in Settings > Data and Storage."; +"Settings.ApplyProxyAlertEnable" = "Enable"; +"Settings.AutoDeleteInfo" = "Automatically delete messages for everyone after a period of time in all new chats you start."; +"Settings.AutoDeleteTitle" = "Auto-Delete Messages"; +"Settings.AutosaveMediaAllMedia" = "All Media (%@)"; +"Settings.AutosaveMediaNoPhoto" = "No Photos"; +"Settings.AutosaveMediaNoVideo" = "No Videos"; +"Settings.AutosaveMediaOff" = "Off"; +"Settings.AutosaveMediaOn" = "On"; +"Settings.AutosaveMediaPhoto" = "Photos"; +"Settings.AutosaveMediaVideo" = "Videos up to %@"; +"Settings.Birthday" = "Birthday"; +"Settings.Birthday.Add" = "Add"; +"Settings.Birthday.ContactsHelp" = "Only your contacts can see your birthday. [Change >]()"; +"Settings.Birthday.Help" = "Choose who can see your birthday in [Settings]()."; +"Settings.Birthday.PrivacyHelpContacts" = "Only your contacts can see your birthday. [Change >]()"; +"Settings.Birthday.PrivacyHelpEveryone" = "Everyone can see your birthday. [Change >]()"; +"Settings.Birthday.PrivacyHelpNobody" = "Nobody can see your birthday. [Change >]()"; +"Settings.Birthday.Remove" = "Remove Birthday"; +"Settings.BlockedUsers" = "Blocked Users"; +"Settings.BotListSettings" = "Bot Settings"; +"Settings.BotSettings.Biometry" = "Biometry"; +"Settings.Business" = "Telegram Business"; +"Settings.CallSettings" = "Calls"; +"Settings.CancelUpload" = "Cancel Upload"; +"Settings.ChangePhoneNumber" = "Change Number"; +"Settings.ChangeProfilePhoto" = "Change Profile Photo"; +"Settings.ChatAutomation" = "Chat Automation"; +"Settings.ChatAutomationInfo" = "Add a bot to reply to messages on your behalf."; +"Settings.ChatAutomationOff" = "Off"; +"Settings.ChatBackground" = "Chat Wallpaper"; +"Settings.ChatFolders" = "Chat Folders"; +"Settings.ChatSettings" = "Data and Storage"; +"Settings.ChatThemes" = "Chat Themes"; +"Settings.CheckPasswordText" = "Your account is protected by 2-Step Verification. Do you still remember your password?"; +"Settings.CheckPasswordTitle" = "Your Password"; +"Settings.CheckPhoneNumberFAQAnchor" = "q-i-have-a-new-phone-number-what-do-i-do"; +"Settings.CheckPhoneNumberText" = "Keep your number up to date to ensure you can always log in to Telegram. [Learn more]()"; +"Settings.CheckPhoneNumberTitle" = "Is %@ still your number?"; +"Settings.Context.Logout" = "Log Out"; +"Settings.CopyPhoneNumber" = "Copy Phone Number"; +"Settings.CopyUsername" = "Copy Username"; +"Settings.Devices" = "Devices"; +"Settings.EditAccount" = "Edit Account"; +"Settings.EditPhoto" = "Edit"; +"Settings.EditProfileMedia" = "Edit"; +"Settings.EditVideo" = "Edit"; +"Settings.FAQ" = "Telegram FAQ"; +"Settings.FAQ_Button" = "FAQ"; +"Settings.FAQ_Intro" = "Please note that Telegram Support is done by volunteers. We try to respond as quickly as possible, but it may take a while.\n\nPlease take a look at the Telegram FAQ: it has important troubleshooting tips and answers to most questions."; +"Settings.FAQ_URL" = "https://telegram.org/faq#general-questions"; +"Settings.FrequentlyAskedQuestions" = "Frequently Asked Questions"; +"Settings.KeepPassword" = "Yes, definitely"; +"Settings.KeepPhoneNumber" = "Keep %@"; +"Settings.Logout" = "Log Out"; +"Settings.LogoutConfirmationText" = "This will cancel all your Secret Chats.\n\nYou can use Telegram on all your devices at once and use several accounts in the same app."; +"Settings.LogoutConfirmationTitle" = "Log out?"; +"Settings.MyProfile" = "My Profile"; +"Settings.MyStories" = "My Stories"; +"Settings.MyTon" = "My Grams"; +"Settings.New" = "NEW"; +"Settings.NotificationsAndSounds" = "Notifications and Sounds"; +"Settings.Passport" = "Telegram Passport"; +"Settings.PauseMusicOnRecording" = "Pause Music While Recording"; +"Settings.PersonalChannelAddedToast" = "Personal channel added."; +"Settings.PersonalChannelEmptyValue" = "Add"; +"Settings.PersonalChannelItem" = "Channel"; +"Settings.PersonalChannelRemove" = "Hide Channel"; +"Settings.PersonalChannelRemovedToast" = "Personal channel removed."; +"Settings.PersonalChannelSelectSubtitle" = "select your channel"; +"Settings.PersonalChannelSelectTitle" = "Channel"; +"Settings.PersonalChannelUpdatedToast" = "Personal channel updated."; +"Settings.PhoneNumber" = "Number"; +"Settings.PowerSaving" = "Power Saving"; +"Settings.PowerSavingOff" = "Off"; +"Settings.PowerSavingOn" = "On"; +"Settings.Premium" = "Telegram Premium"; +"Settings.PremiumGift" = "Gift Premium"; +"Settings.Privacy.LastSeenRevealedToast" = "Your last seen time is now visible."; +"Settings.Privacy.MessageReadTimeRevealedToast" = "Your read times are now visible."; +"Settings.Privacy.Messages" = "Messages"; +"Settings.Privacy.Messages.ValueContactsAndPremium" = "Contacts & Premium"; +"Settings.Privacy.Messages.ValueEveryone" = "Everybody"; +"Settings.Privacy.Messages.ValuePaid" = "Paid"; +"Settings.Privacy.ReadTime" = "Hide Read Time"; +"Settings.Privacy.ReadTimeFooter" = "Hide the time when you read messages from people who can't see your last seen. If you turn this on, their read time will also be hidden from you (unless you are a Premium user).\n\nThis setting does not affect group chats."; +"Settings.Privacy.ReadTimePremium" = "Subscribe to Telegram Premium"; +"Settings.Privacy.ReadTimePremiumActive" = "Subcribed to Telegram Premium"; +"Settings.Privacy.ReadTimePremiumActiveFooter" = "Because you are a Telegram Premium subscriber, you will see the last seen and read time of all users who are sharing it with you – even if you are hiding yours."; +"Settings.Privacy.ReadTimePremiumFooter" = "If you subscribe to Telegram Premium, you will still see other users' last seen and read time even if you hid yours from them (unless they specifically restricted it)."; +"Settings.PrivacyPolicy_URL" = "https://telegram.org/privacy"; +"Settings.PrivacySettings" = "Privacy and Security"; +"Settings.Proxy" = "Proxy"; +"Settings.ProxyConnected" = "Connected"; +"Settings.ProxyConnecting" = "Connecting..."; +"Settings.ProxyDisabled" = "Disabled"; +"Settings.QuickReactionSetup.ChooseQuickReaction" = "Choose Your Quick Reaction"; +"Settings.QuickReactionSetup.ChooseQuickReactionInfo" = "You can set any emoji as your quick reaction."; +"Settings.QuickReactionSetup.DemoHeader" = "DOUBLE TAP ON A MESSAGE TO REACT"; +"Settings.QuickReactionSetup.DemoInfo" = "You can double tap on message for a quick reaction."; +"Settings.QuickReactionSetup.DemoMessageAuthor" = "Dino"; +"Settings.QuickReactionSetup.DemoMessageText" = "I hope you're enjoying your day as much as I am."; +"Settings.QuickReactionSetup.NavigationTitle" = "Quick Reaction"; +"Settings.QuickReactionSetup.ReactionListHeader" = "QUICK REACTION"; +"Settings.QuickReactionSetup.Title" = "Quick Reaction"; +"Settings.RaiseToListen" = "Raise to Listen"; +"Settings.RaiseToListenInfo" = "Raise to Listen allows you to quickly listen and reply to incoming audio messages by raising the phone to your ear."; +"Settings.RemoveConfirmation" = "Remove"; +"Settings.RemoveVideo" = "Remove Photo"; +"Settings.SaveEditedPhotos" = "Save Edited Photos"; +"Settings.SaveIncomingPhotos" = "Save Incoming Photos"; +"Settings.SaveToCameraRollInfo" = "Automatically save all new photos and videos from these chats to your Photos app."; +"Settings.SaveToCameraRollSection" = "SAVE TO PHOTOS"; +"Settings.SavedMessages" = "Saved Messages"; +"Settings.Search" = "Search"; +"Settings.SendGift" = "Send a Gift"; +"Settings.SensitiveContent" = "Show 18+ Content"; +"Settings.SensitiveContentInfo" = "Do not hide media that contains content suitable only for adults."; +"Settings.SetNewProfilePhotoOrVideo" = "Set New Photo"; +"Settings.SetProfilePhoto" = "Set Profile Photo"; +"Settings.SetProfilePhotoOrVideo" = "Set Profile Photo"; +"Settings.SetUsername" = "Set Username"; +"Settings.Stars" = "My Stars"; +"Settings.StoriesArchive" = "Story Archive"; +"Settings.SuggestSetupPasswordAction" = "Set Additional Password"; +"Settings.SuggestSetupPasswordText" = "Set a password that will be required each time you log in with this phone number."; +"Settings.SuggestSetupPasswordTitle" = "Protect Your Account"; +"Settings.Support" = "Ask a Question"; +"Settings.Terms_URL" = "https://telegram.org/tos"; +"Settings.Tips" = "Telegram Features"; +"Settings.TipsUsername" = "TelegramTips"; +"Settings.Title" = "Settings"; +"Settings.Ton.Description" = "Use Grams to buy gifts and suggest posts to channels."; +"Settings.TransactionsTon.Subtitle" = "Use Grams to buy gifts and suggest posts to channels."; +"Settings.TransactionsTon.Title" = "Grams"; +"Settings.TryEnterPassword" = "Not sure, let me try"; +"Settings.Username" = "Username"; +"Settings.UsernameEmpty" = "Add"; +"Settings.ViewPhoto" = "View Photo"; +"Settings.ViewVideo" = "View Video"; +"Settings.YourColor" = "Your Color"; +"SettingsSearch.DeleteAccount.DeleteMyAccount" = " "; +"SettingsSearch.FAQ" = "FAQ"; +"SettingsSearch.Synonyms.AppLanguage" = " "; +"SettingsSearch.Synonyms.Appearance.Animations" = "Animations"; +"SettingsSearch.Synonyms.Appearance.AutoNightTheme" = " "; +"SettingsSearch.Synonyms.Appearance.ChatBackground" = "Wallpaper\nBackground"; +"SettingsSearch.Synonyms.Appearance.ChatBackground.Custom" = " "; +"SettingsSearch.Synonyms.Appearance.ChatBackground.SetColor" = " "; +"SettingsSearch.Synonyms.Appearance.ColorTheme" = " "; +"SettingsSearch.Synonyms.Appearance.LargeEmoji" = " "; +"SettingsSearch.Synonyms.Appearance.TextSize" = " "; +"SettingsSearch.Synonyms.Appearance.Title" = " "; +"SettingsSearch.Synonyms.Calls.CallTab" = " "; +"SettingsSearch.Synonyms.Calls.Title" = " "; +"SettingsSearch.Synonyms.ChatSettings.IntentsSettings" = "Siri Suggestions"; +"SettingsSearch.Synonyms.ChatSettings.OpenLinksIn" = "Browser"; +"SettingsSearch.Synonyms.Data.AutoDownloadReset" = " "; +"SettingsSearch.Synonyms.Data.AutoDownloadUsingCellular" = " "; +"SettingsSearch.Synonyms.Data.AutoDownloadUsingWifi" = " "; +"SettingsSearch.Synonyms.Data.AutoplayGifs" = " "; +"SettingsSearch.Synonyms.Data.AutoplayVideos" = " "; +"SettingsSearch.Synonyms.Data.CallsUseLessData" = " "; +"SettingsSearch.Synonyms.Data.DownloadInBackground" = " "; +"SettingsSearch.Synonyms.Data.NetworkUsage" = " "; +"SettingsSearch.Synonyms.Data.SaveEditedPhotos" = " "; +"SettingsSearch.Synonyms.Data.SaveIncomingPhotos" = " "; +"SettingsSearch.Synonyms.Data.Storage.ClearCache" = " "; +"SettingsSearch.Synonyms.Data.Storage.KeepMedia" = " "; +"SettingsSearch.Synonyms.Data.Storage.Title" = "Cache"; +"SettingsSearch.Synonyms.Data.Title" = " "; +"SettingsSearch.Synonyms.Devices.LinkDesktopDevice" = "Log in\nConnect\nDesktop\nBrowser\nComputer\nLaptop\nPC\nWindows\nmacOS\nLinux\nWeb"; +"SettingsSearch.Synonyms.Devices.TerminateOtherSessions" = " "; +"SettingsSearch.Synonyms.EditProfile.AddAccount" = " "; +"SettingsSearch.Synonyms.EditProfile.Bio" = " "; +"SettingsSearch.Synonyms.EditProfile.Logout" = " "; +"SettingsSearch.Synonyms.EditProfile.PhoneNumber" = " "; +"SettingsSearch.Synonyms.EditProfile.Title" = " "; +"SettingsSearch.Synonyms.EditProfile.Username" = " "; +"SettingsSearch.Synonyms.FAQ" = " "; +"SettingsSearch.Synonyms.Language.DoNotTranslate" = " "; +"SettingsSearch.Synonyms.Language.ShowTranslateButton" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeCountUnreadMessages" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedChannels" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedChats" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedPublicGroups" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.ContactJoined" = " "; +"SettingsSearch.Synonyms.Notifications.DisplayNamesOnLockScreen" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsVibrate" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.ResetAllNotifications" = " "; +"SettingsSearch.Synonyms.Notifications.Title" = " "; +"SettingsSearch.Synonyms.Passport" = " "; +"SettingsSearch.Synonyms.Premium" = " "; +"SettingsSearch.Synonyms.Premium.AnimatedEmoji" = " "; +"SettingsSearch.Synonyms.Premium.AppIcon" = " "; +"SettingsSearch.Synonyms.Premium.Avatar" = "Video Avatar"; +"SettingsSearch.Synonyms.Premium.Badge" = "Star"; +"SettingsSearch.Synonyms.Premium.ChatManagement" = " "; +"SettingsSearch.Synonyms.Premium.DoubledLimits" = " "; +"SettingsSearch.Synonyms.Premium.EmojiStatus" = " "; +"SettingsSearch.Synonyms.Premium.FasterSpeed" = " "; +"SettingsSearch.Synonyms.Premium.NoAds" = " "; +"SettingsSearch.Synonyms.Premium.Reactions" = " "; +"SettingsSearch.Synonyms.Premium.Stickers" = " "; +"SettingsSearch.Synonyms.Premium.UploadSize" = " "; +"SettingsSearch.Synonyms.Premium.VoiceToText" = "Transcribe"; +"SettingsSearch.Synonyms.Privacy.AuthSessions" = " "; +"SettingsSearch.Synonyms.Privacy.BlockedUsers" = " "; +"SettingsSearch.Synonyms.Privacy.Calls" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ClearPaymentsInfo" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ContactsReset" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ContactsSync" = " "; +"SettingsSearch.Synonyms.Privacy.Data.DeleteDrafts" = " "; +"SettingsSearch.Synonyms.Privacy.Data.SecretChatLinkPreview" = " "; +"SettingsSearch.Synonyms.Privacy.Data.Title" = " "; +"SettingsSearch.Synonyms.Privacy.Data.TopPeers" = " "; +"SettingsSearch.Synonyms.Privacy.DeleteAccountIfAwayFor" = " "; +"SettingsSearch.Synonyms.Privacy.Forwards" = " "; +"SettingsSearch.Synonyms.Privacy.GroupsAndChannels" = " "; +"SettingsSearch.Synonyms.Privacy.LastSeen" = " "; +"SettingsSearch.Synonyms.Privacy.Passcode" = " "; +"SettingsSearch.Synonyms.Privacy.PasscodeAndFaceId" = " "; +"SettingsSearch.Synonyms.Privacy.PasscodeAndTouchId" = " "; +"SettingsSearch.Synonyms.Privacy.ProfilePhoto" = " "; +"SettingsSearch.Synonyms.Privacy.Title" = " "; +"SettingsSearch.Synonyms.Privacy.TwoStepAuth" = "Password"; +"SettingsSearch.Synonyms.Proxy.AddProxy" = " "; +"SettingsSearch.Synonyms.Proxy.Title" = "SOCKS5\nMTProto"; +"SettingsSearch.Synonyms.Proxy.UseForCalls" = " "; +"SettingsSearch.Synonyms.SavedMessages" = " "; +"SettingsSearch.Synonyms.Stickers.ArchivedPacks" = " "; +"SettingsSearch.Synonyms.Stickers.FeaturedPacks" = " "; +"SettingsSearch.Synonyms.Stickers.Masks" = " "; +"SettingsSearch.Synonyms.Stickers.SuggestStickers" = " "; +"SettingsSearch.Synonyms.Stickers.Title" = " "; +"SettingsSearch.Synonyms.Support" = "Support"; +"SettingsSearch.Synonyms.Watch" = "Apple Watch"; +"Share.AuthDescription" = "Open Telegram and log in to share."; +"Share.AuthTitle" = "Log in to Telegram"; +"Share.MessagePreview" = "Message Preview"; +"Share.MultipleMessagesDisabled" = "Slow Mode is enabled. You can't send multiple messages at once."; +"Share.PostToStory" = "Post\nto Story"; +"Share.RepostStory" = "Repost\nStory"; +"Share.RepostToStory" = "Repost\nto Story"; +"Share.ShareAsImage" = "Share as Image"; +"Share.ShareAsLink" = "Share as Link"; +"Share.ShareMessage" = "Share Message"; +"Share.ShareToInstagramStories" = "Share to Instagram Stories"; +"Share.Title" = "Share"; +"Share.UploadDone" = "Done"; +"Share.UploadProgress" = "Uploading • %d%"; +"Share.VideoStartAt" = "Start at %@"; +"ShareFileTip.CloseTip" = "Close Tip"; +"ShareFileTip.Text" = "You can share **uncompressed** media files from your Camera Roll here.\n\nTo share files of any other type, open them on your %@ (e.g. in your browser), tap **Open\u00a0in...** or the action button and choose Telegram."; +"ShareFileTip.Title" = "Sharing Files"; +"ShareMenu.Comment" = "Add a comment..."; +"ShareMenu.CopyShareLink" = "Copy Link"; +"ShareMenu.CopyShareLinkGame" = "Copy link to game"; +"ShareMenu.SelectChats" = "Select chats"; +"ShareMenu.SelectTopic" = "Select topic"; +"ShareMenu.Send" = "Send"; +"ShareMenu.ShareTo" = "Share with"; +"SharedMedia.CalendarTooltip" = "Tap on this icon for calendar view"; +"SharedMedia.CategoryDocs" = "Docs"; +"SharedMedia.CategoryLinks" = "Links"; +"SharedMedia.CategoryMedia" = "Media"; +"SharedMedia.CategoryOther" = "Music"; +"SharedMedia.CommonGroupCount#one" = "%@ group in common"; +"SharedMedia.CommonGroupCount#other" = "%@ groups in common"; +"SharedMedia.DeleteItemsConfirmation#one" = "Delete media file?"; +"SharedMedia.DeleteItemsConfirmation#other" = "Delete %@ media files?"; +"SharedMedia.EmptyFilesText" = "You can send and receive\nfiles of any type up to 2\u00a0GB each\nand access them anywhere."; +"SharedMedia.EmptyLinksText" = "All links shared in this chat will appear here"; +"SharedMedia.EmptyMusicText" = "All music shared in this chat will appear here."; +"SharedMedia.EmptyText" = "Share photos and videos in this chat\n— or this paperclip stays unhappy."; +"SharedMedia.EmptyTitle" = "No media files yet"; +"SharedMedia.FastScrollTooltip" = "You can hold and move this bar for faster scrolling."; +"SharedMedia.File#one" = "1 file"; +"SharedMedia.File#other" = "%@ files"; +"SharedMedia.FileCount#one" = "1 file"; +"SharedMedia.FileCount#other" = "%@ files"; +"SharedMedia.Generic#one" = "1 media file"; +"SharedMedia.Generic#other" = "%@ media files"; +"SharedMedia.GifCount#one" = "1 GIF"; +"SharedMedia.GifCount#other" = "%@ GIFs"; +"SharedMedia.GiftCount#one" = "%@ gift"; +"SharedMedia.GiftCount#other" = "%@ gifts"; +"SharedMedia.Link#one" = "1 link"; +"SharedMedia.Link#other" = "%@ links"; +"SharedMedia.LinkCount#one" = "1 link"; +"SharedMedia.LinkCount#other" = "%@ links"; +"SharedMedia.MusicCount#one" = "1 music file"; +"SharedMedia.MusicCount#other" = "%@ music files"; +"SharedMedia.Photo#one" = "1 photo"; +"SharedMedia.Photo#other" = "%@ photos"; +"SharedMedia.PhotoCount#one" = "1 photo"; +"SharedMedia.PhotoCount#other" = "%@ photos"; +"SharedMedia.PollCount#one" = "1 poll"; +"SharedMedia.PollCount#other" = "%@ polls"; +"SharedMedia.SearchNoResults" = "No Results"; +"SharedMedia.SearchNoResultsDescription" = "There were no results for \"%@\".\nTry a new search."; +"SharedMedia.ShowCalendar" = "Show Calendar"; +"SharedMedia.ShowPhotos" = "Show Photos"; +"SharedMedia.ShowVideos" = "Show Videos"; +"SharedMedia.SimilarBot.Users#one" = "%@ monthly user"; +"SharedMedia.SimilarBot.Users#other" = "%@ monthly users"; +"SharedMedia.SimilarBotCount#one" = "%@ bot"; +"SharedMedia.SimilarBotCount#other" = "%@ bots"; +"SharedMedia.SimilarChannel.Subscribers#one" = "%@ subscriber"; +"SharedMedia.SimilarChannel.Subscribers#other" = "%@ subscribers"; +"SharedMedia.SimilarChannelCount#one" = "%@ channel"; +"SharedMedia.SimilarChannelCount#other" = "%@ channels"; +"SharedMedia.TitleAll" = "Shared Media"; +"SharedMedia.Video#one" = "1 video"; +"SharedMedia.Video#other" = "%@ videos"; +"SharedMedia.VideoCount#one" = "1 video"; +"SharedMedia.VideoCount#other" = "%@ videos"; +"SharedMedia.ViewInChat" = "Show in Chat"; +"SharedMedia.VoiceMessageCount#one" = "1 voice message"; +"SharedMedia.VoiceMessageCount#other" = "%@ voice messages"; +"SharedMedia.ZoomIn" = "Zoom In"; +"SharedMedia.ZoomOut" = "Zoom Out"; +"ShortTime.AtDate" = "%@"; +"ShortTime.AtPreciseDate" = "%@ at %@"; +"ShortTime.HoursAgo#one" = "%@h"; +"ShortTime.HoursAgo#other" = "%@h"; +"ShortTime.JustNow" = "now"; +"ShortTime.MinutesAgo#one" = "%@m"; +"ShortTime.MinutesAgo#other" = "%@m"; +"Shortcut.AppIcon" = "Edit App Icon"; +"Shortcut.SwitchAccount" = "Switch Account"; +"SocksProxySetup.AdNoticeHelp" = "This proxy may display a sponsored channel in your chat list. This doesn't reveal your Telegram traffic."; +"SocksProxySetup.AddProxy" = "Add Proxy"; +"SocksProxySetup.AddProxyTitle" = "Add Proxy"; +"SocksProxySetup.CheckStatus" = "Check Status"; +"SocksProxySetup.ConnectAndSave" = "Connect Proxy"; +"SocksProxySetup.Connecting" = "Connecting..."; +"SocksProxySetup.Connection" = "CONNECTION"; +"SocksProxySetup.Credentials" = "CREDENTIALS (OPTIONAL)"; +"SocksProxySetup.FailedToConnect" = "Failed to connect"; +"SocksProxySetup.Hostname" = "Server"; +"SocksProxySetup.HostnamePlaceholder" = "Server"; +"SocksProxySetup.Password" = "Password"; +"SocksProxySetup.PasswordPlaceholder" = "Password"; +"SocksProxySetup.PasteFromClipboard" = "Paste From Clipboard"; +"SocksProxySetup.Port" = "Port"; +"SocksProxySetup.PortPlaceholder" = "Port"; +"SocksProxySetup.ProxyDetailsTitle" = "Proxy Details"; +"SocksProxySetup.ProxyEnabled" = "Proxy\nEnabled"; +"SocksProxySetup.ProxySocks5" = "SOCKS5"; +"SocksProxySetup.ProxyStatusChecking" = "checking..."; +"SocksProxySetup.ProxyStatusConnected" = "connected"; +"SocksProxySetup.ProxyStatusConnecting" = "connecting"; +"SocksProxySetup.ProxyStatusPing" = "%@ ms ping"; +"SocksProxySetup.ProxyStatusUnavailable" = "unavailable"; +"SocksProxySetup.ProxyTelegram" = "MTProto"; +"SocksProxySetup.ProxyType" = "TYPE"; +"SocksProxySetup.QrCode.TMeLink" = "t.me link"; +"SocksProxySetup.QrCode.TgLink" = "tg:// link"; +"SocksProxySetup.RequiredCredentials" = "CREDENTIALS"; +"SocksProxySetup.SaveProxy" = "Save Proxy"; +"SocksProxySetup.SavedProxies" = "SAVED PROXIES"; +"SocksProxySetup.Secret" = "Secret"; +"SocksProxySetup.SecretPlaceholder" = "Secret"; +"SocksProxySetup.ShareLink" = "Share Link"; +"SocksProxySetup.ShareProxyList" = "Share Proxy List"; +"SocksProxySetup.ShareQRCode" = "Share QR Code"; +"SocksProxySetup.ShareQRCodeInfo" = "Your friends can add this proxy by scanning the code with their camera."; +"SocksProxySetup.Status" = "Status"; +"SocksProxySetup.Title" = "Proxy"; +"SocksProxySetup.TypeNone" = "Disabled"; +"SocksProxySetup.TypeSocks" = "SOCKS5"; +"SocksProxySetup.UseForCalls" = "Use for calls"; +"SocksProxySetup.UseForCallsHelp" = "Proxy servers may degrade the quality of your calls."; +"SocksProxySetup.UseProxy" = "Use Proxy"; +"SocksProxySetup.UseSocks5" = "Use SOCKS5 Proxy"; +"SocksProxySetup.Username" = "Username"; +"SocksProxySetup.UsernamePlaceholder" = "Username"; +"SocksProxySetup.Warning.Proceed" = "Proceed"; +"SocksProxySetup.Warning.Text" = "This action will expose your IP address to the admin of the proxy server."; +"SocksProxySetup.Warning.Title" = "Warning"; +"SponsoredMessageInfo.Action" = "Learn More"; +"SponsoredMessageInfo.ActionUrl" = "https://ads.telegram.org"; +"SponsoredMessageInfo.Text" = "See https://telegram.org"; +"SponsoredMessageInfo.Url" = "https://ads.telegram.org"; +"SponsoredMessageInfoScreen.MarkdownText" = "Unlike other apps, Telegram never uses your private data to target ads. [Learn more in the Privacy Policy](https://telegram.org/privacy#5-6-no-ads-based-on-user-data)\nYou are seeing this message only because someone chose this public one-to-many channel as a space to promote their messages. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on Telegram sees the same sponsored message.\n\nUnlike other apps, Telegram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can't spy on our users. We believe that everyone has the right to privacy, and online platforms should respect that.\n\nTelegram offers free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Telegram developed a paid tool to promote messages with user privacy in mind. We welcome responsible advertisers at:\n[url]\nAds should no longer be synonymous with abuse of user privacy. Let us redefine how a tech company should operate — together."; +"SponsoredMessageInfoScreen.Text" = "Unlike other apps, Telegram never uses your private data to target ads. Sponsored messages on Telegram are based solely on the topic of the public channels in which they are shown. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on Telegram sees the same sponsored messages.\n\nUnlike other apps, Telegram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can’t spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that.\n\nTelegram offers a free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Telegram developed a paid tool to promote messages with user privacy in mind. We welcome responsible advertisers at:\n[url]\nSponsored Messages are currently in test mode. Once they are fully launched and allow Telegram to cover its basic costs, we will start sharing ad revenue with the owners of public channels in which sponsored messages are displayed.\n\nOnline ads should no longer be synonymous with abuse of user privacy. Let us redefine how a tech company should operate – together."; +"SponsoredMessageInfoScreen.Title" = "What are sponsored messages?"; +"SponsoredMessageMenu.Hide" = "Hide"; +"SponsoredMessageMenu.Info" = "What are sponsored\nmessages?"; +"Stars.AccountRevenue.Proceeds.Info" = "Stars from your total balance can be withdrawn as rewards 21 days after they are earned."; +"Stars.AccountRevenue.Withdraw.Info" = "You can collect rewards for Stars using Fragment. You cannot withdraw less than 1000 stars. [Learn More >]()"; +"Stars.BotRevenue.Proceeds.Available" = "Available Balance"; +"Stars.BotRevenue.Proceeds.Current" = "Total Balance"; +"Stars.BotRevenue.Proceeds.Info" = "Stars from your total balance can be used for ads or withdrawn as rewards 21 days after they are earned."; +"Stars.BotRevenue.Proceeds.Title" = "Rewards Overview"; +"Stars.BotRevenue.Proceeds.Total" = "Lifetime Proceeds"; +"Stars.BotRevenue.Revenue.Title" = "Stars Received"; +"Stars.BotRevenue.Title" = "Stars Balance"; +"Stars.BotRevenue.Transactions.Title" = "Transaction History"; +"Stars.BotRevenue.Withdraw.Balance" = "Available Balance"; +"Stars.BotRevenue.Withdraw.BuyAds" = "Buy Ads"; +"Stars.BotRevenue.Withdraw.Info" = "You can collect rewards for Stars using Fragment, or use Stars to advertise your bot. You cannot withdraw less than 1000 stars. [Learn More >]()"; +"Stars.BotRevenue.Withdraw.Info_URL" = "https://telegram.org/tos/bot-developers#6-2-2-tpa-balance"; +"Stars.BotRevenue.Withdraw.Withdraw" = "Withdraw via Fragment"; +"Stars.BotRevenue.Withdraw.WithdrawShort" = "Withdraw"; +"Stars.Gift.Received.Text" = "Use Stars to unlock content and services on Telegram. [See Examples >]()"; +"Stars.Gift.Received.Title" = "Received Gift"; +"Stars.Gift.Sent.Text" = "With Stars, %@ will be able to unlock content and services on Telegram. [See Examples >]()"; +"Stars.Gift.Sent.Title" = "Sent Gift"; +"Stars.Gift.Ton.Text" = "Use Grams to buy gifts and suggest posts to channels."; +"Stars.Info.Description" = "Telegram Stars allow users to:"; +"Stars.Info.Done" = "Got It"; +"Stars.Info.Gift.Text" = "Give your friends gifts that can be added to their profiles or converted to Stars."; +"Stars.Info.Gift.Title" = "Send Gifts to Friends"; +"Stars.Info.Media.Text" = "Get access to paid content and services in Telegram channels."; +"Stars.Info.Media.Title" = "Unlock Content in Channels"; +"Stars.Info.Miniapp.Text" = "Buy additional content and services in Telegram mini apps. [See Examples >]()"; +"Stars.Info.Miniapp.Title" = "Use Stars in Mini Apps"; +"Stars.Info.Reaction.Text" = "Support your favorite channels by sending Star reactions to their posts."; +"Stars.Info.Reaction.Title" = "Send Star Reactions"; +"Stars.Info.Title" = "What are Stars?"; +"Stars.Intro.AllTransactions" = "All Transactions"; +"Stars.Intro.Balance" = "Balance"; +"Stars.Intro.Buy" = "Buy More Stars"; +"Stars.Intro.BuyShort" = "Top Up"; +"Stars.Intro.Description" = "Buy Stars to unlock content and services in mini apps on Telegram."; +"Stars.Intro.GiftStars" = "Gift Stars to Friends"; +"Stars.Intro.Incoming" = "Incoming"; +"Stars.Intro.Outgoing" = "Outgoing"; +"Stars.Intro.PurchasedText" = "**%@** added to your balance."; +"Stars.Intro.PurchasedText.Stars#one" = "%@ Star"; +"Stars.Intro.PurchasedText.Stars#other" = "%@ Stars"; +"Stars.Intro.PurchasedTitle" = "Stars Acquired"; +"Stars.Intro.StarsSent#one" = "%@ Star sent."; +"Stars.Intro.StarsSent#other" = "%@ Stars sent."; +"Stars.Intro.StarsSent.ViewChat" = "View Chat"; +"Stars.Intro.Stats" = "Stats"; +"Stars.Intro.Subscriptions.Cancelled" = "canceled"; +"Stars.Intro.Subscriptions.Expired" = "expired on %@"; +"Stars.Intro.Subscriptions.ExpiredStatus" = "expired"; +"Stars.Intro.Subscriptions.Expires" = "expires on %@"; +"Stars.Intro.Subscriptions.PerMonth" = "per month"; +"Stars.Intro.Subscriptions.Renews" = "renews on %@"; +"Stars.Intro.Subscriptions.ShowMore" = "Show More"; +"Stars.Intro.Subscriptions.Title" = "MY SUBSCRIPTIONS"; +"Stars.Intro.Title" = "Telegram Stars"; +"Stars.Intro.Transaction.AppleTopUp.Subtitle" = "via App Store"; +"Stars.Intro.Transaction.AppleTopUp.Title" = "Stars Top-Up"; +"Stars.Intro.Transaction.Commission.Title" = "%@ commission"; +"Stars.Intro.Transaction.ConvertedGift" = "Converted Gift"; +"Stars.Intro.Transaction.FragmentTopUp.Subtitle" = "via Fragment"; +"Stars.Intro.Transaction.FragmentTopUp.Title" = "Stars Top-Up"; +"Stars.Intro.Transaction.FragmentWithdrawal.Subtitle" = "via Fragment"; +"Stars.Intro.Transaction.FragmentWithdrawal.Title" = "Withdrawal"; +"Stars.Intro.Transaction.Gift" = "Gift"; +"Stars.Intro.Transaction.Gift.Title" = "Received Gift"; +"Stars.Intro.Transaction.Gift.UnknownUser" = "Unknown User"; +"Stars.Intro.Transaction.GiftAuctionBid" = "Gift Auction Bid"; +"Stars.Intro.Transaction.GiftDropOriginalDetails" = "Gift Description Removal"; +"Stars.Intro.Transaction.GiftPurchase" = "Gift Purchase"; +"Stars.Intro.Transaction.GiftSale" = "Gift Sale"; +"Stars.Intro.Transaction.GiftTransfer" = "Gift Transfer"; +"Stars.Intro.Transaction.GiftUpgrade" = "Gift Upgrade"; +"Stars.Intro.Transaction.GiveawayPrize" = "Giveaway Prize"; +"Stars.Intro.Transaction.GoogleTopUp.Subtitle" = "via Play Store"; +"Stars.Intro.Transaction.GoogleTopUp.Title" = "Stars Top-Up"; +"Stars.Intro.Transaction.LiveStreamPaidMessage#one" = "Fee for %@ Live Story Message"; +"Stars.Intro.Transaction.LiveStreamPaidMessage#other" = "Fee for %@ Live Story Messages"; +"Stars.Intro.Transaction.LiveStreamReaction" = "Live Stream Reaction"; +"Stars.Intro.Transaction.MediaPurchase" = "Media Purchase"; +"Stars.Intro.Transaction.PaidMessage#one" = "Fee for %@ Message"; +"Stars.Intro.Transaction.PaidMessage#other" = "Fee for %@ Messages"; +"Stars.Intro.Transaction.PremiumBotTopUp.Subtitle" = "via Premium Bot"; +"Stars.Intro.Transaction.PremiumBotTopUp.Title" = "Stars Top-Up"; +"Stars.Intro.Transaction.PrepaidGiftUpgrade" = "Gift Upgrade"; +"Stars.Intro.Transaction.Reaction.Title" = "Star Reaction"; +"Stars.Intro.Transaction.Refund" = "Refund"; +"Stars.Intro.Transaction.SearchFee" = "Public Post Search"; +"Stars.Intro.Transaction.SubscriptionFee.Title" = "Monthly Subscription Fee"; +"Stars.Intro.Transaction.TelegramAds.Subtitle" = "via Telegram Ads"; +"Stars.Intro.Transaction.TelegramAds.Title" = "Withdrawal"; +"Stars.Intro.Transaction.TelegramBotApi.Messages#one" = "%@ Message"; +"Stars.Intro.Transaction.TelegramBotApi.Messages#other" = "%@ Messages"; +"Stars.Intro.Transaction.TelegramBotApi.Title" = "Paid Broadcast"; +"Stars.Intro.Transaction.TelegramPremium#one" = "Premium for %@ Month"; +"Stars.Intro.Transaction.TelegramPremium#other" = "Premium for %@ Months"; +"Stars.Intro.Transaction.Unsupported.Title" = "Unsupported"; +"Stars.Intro.Withdraw" = "Withdraw"; +"Stars.Intro.YourBalance" = "your balance"; +"Stars.PaidContent.AmountInfo" = "Users will have to transfer this amount of Stars to your channel in order to view this media.\n[More about Stars >]()"; +"Stars.PaidContent.AmountInfo_URL" = "https://telegram.org/tos/stars"; +"Stars.PaidContent.AmountPlaceholder" = "Stars to Unlock"; +"Stars.PaidContent.AmountTitle" = "ENTER UNLOCK COST"; +"Stars.PaidContent.Create" = "Make This Media Paid"; +"Stars.PaidContent.Title" = "Paid Content"; +"Stars.Purchase.Balance" = "Balance"; +"Stars.Purchase.BuyAnyway" = "Buy Anyway"; +"Stars.Purchase.BuyStarGiftInfo" = "Buy Stars to acquire a unique collectible."; +"Stars.Purchase.EnoughStars" = "You have enough Stars at the moment."; +"Stars.Purchase.GenericPurchasePurpose" = "Buy Stars to unlock content and services on Telegram."; +"Stars.Purchase.GetStars" = "Get Stars"; +"Stars.Purchase.GetStarsInfo" = "Choose how many Stars you would like to buy."; +"Stars.Purchase.GiftInfo" = "With Stars, **%1$@** will be able to unlock content and services on Telegram. [See Examples >]()"; +"Stars.Purchase.GiftStars" = "Gift Stars"; +"Stars.Purchase.Info" = "By proceeding and purchasing Stars, you agree with [Terms and Conditions]()."; +"Stars.Purchase.PurchasePurpose.subs" = "Buy Stars to keep all your subscriptions."; +"Stars.Purchase.RemoveOriginalDetailsStarGiftInfo" = "Buy Stars to remove the description from your gift."; +"Stars.Purchase.SendGroupMessageInfo" = "Buy Stars to send a message in **%@**."; +"Stars.Purchase.SendMessageInfo" = "Buy Stars to send a message to **%@**."; +"Stars.Purchase.ShowMore" = "Show More Options"; +"Stars.Purchase.StarGiftInfo" = "Buy Stars to send **%@** gifts that can be added to their profile or converted to Stars."; +"Stars.Purchase.StarGiftOfferInfo" = "Buy Stars to make offers to buy gifts."; +"Stars.Purchase.Stars#one" = "%@ Star"; +"Stars.Purchase.Stars#other" = "%@ Stars"; +"Stars.Purchase.StarsNeeded#one" = "%@ Star Needed"; +"Stars.Purchase.StarsNeeded#other" = "%@ Stars Needed"; +"Stars.Purchase.StarsNeededInfo" = "Buy Stars to use them on **%@** and other mini apps."; +"Stars.Purchase.StarsNeededUnlockInfo" = "Buy Stars to unlock media and use them on mini apps."; +"Stars.Purchase.StarsReactionsNeededInfo" = "Buy Stars to send paid reactions to **%@** and other channels."; +"Stars.Purchase.SubscriptionInfo" = "Buy Stars to subscribe to **%@**."; +"Stars.Purchase.SubscriptionRenewInfo" = "Buy Stars to keep your subscription to **%@**."; +"Stars.Purchase.Terms_URL" = "https://telegram.org/tos/stars"; +"Stars.Purchase.TransferStarGiftInfo" = "Buy Stars to transfer ownership of your unique collectible."; +"Stars.Purchase.UpgradeStarGiftInfo" = "Buy Stars to upgrade your gift into a unique collectible."; +"Stars.SellGift.AmountInfo" = "You will receive **%@**."; +"Stars.SellGift.AmountInfo.Stars#one" = "%@ Star"; +"Stars.SellGift.AmountInfo.Stars#other" = "%@ Stars"; +"Stars.SellGift.AmountPlaceholder" = "Enter Price"; +"Stars.SellGift.AmountTitle" = "PRICE IN STARS"; +"Stars.SellGift.EditTitle" = "Edit Price"; +"Stars.SellGift.OnlyTon" = "Only Accept Grams"; +"Stars.SellGift.OnlyTonInfo" = "If the buyer pays you in Grams there's no risk of refunds, unlike Star payments."; +"Stars.SellGift.Sell" = "Sell"; +"Stars.SellGift.SellFor" = "Sell for"; +"Stars.SellGift.Title" = "Sell Gift"; +"Stars.SellGift.TonAmountTitle" = "PRICE IN GRAMS"; +"Stars.SellGiftMinAmountToast.Text" = "You cannot sell gift for less than %@"; +"Stars.SendMessage.AdjustmentAction" = "OK"; +"Stars.SendMessage.AdjustmentPlaceholder" = "Price for each Message"; +"Stars.SendMessage.AdjustmentSectionFooterEmpty" = "You will receive **80%**."; +"Stars.SendMessage.AdjustmentSectionFooterEmptyValue" = "You will receive **%1$@%**."; +"Stars.SendMessage.AdjustmentSectionFooterValue" = "You will receive **%@ Stars**."; +"Stars.SendMessage.AdjustmentSectionHeader" = "PRICE IN STARS"; +"Stars.SendMessage.AdjustmentTitle" = "Price for each Message"; +"Stars.SendMessage.PriceFree" = "Free"; +"Stars.SendStars.AmountInfo" = "Send %@ or more to highlight your profile in the TOP 3 supporters of this message."; +"Stars.SendStars.AmountPlaceholder" = "Star Amount"; +"Stars.SendStars.AmountTitle" = "ENTER AMOUNT"; +"Stars.SendStars.SendStars" = "Confirm and Send"; +"Stars.SendStars.Title" = "Send Stars"; +"Stars.Subscription.Terms" = "By subscribing you agree to the [Terms of Service]()."; +"Stars.Subscription.Terms_URL" = "https://telegram.org/tos/stars"; +"Stars.Ton.Description" = "Use Grams to buy gifts and suggest posts to channels."; +"Stars.Ton.Title" = "Gram Balance"; +"Stars.Transaction.AppleTopUp.Subtitle" = "App Store"; +"Stars.Transaction.AppleTopUp.Title" = "Stars Top-Up"; +"Stars.Transaction.CopiedId" = "Transaction ID copied to clipboard."; +"Stars.Transaction.Date" = "Date"; +"Stars.Transaction.FragmentTopUp.Subtitle" = "Fragment"; +"Stars.Transaction.FragmentTopUp.Title" = "Stars Top-Up"; +"Stars.Transaction.FragmentTopUpTon.Title" = "Gram Top-Up"; +"Stars.Transaction.FragmentUnknown_URL" = "https://fragment.com/stars"; +"Stars.Transaction.FragmentWithdrawal.Subtitle" = "Fragment"; +"Stars.Transaction.FragmentWithdrawal.Title" = "Stars Withdrawal"; +"Stars.Transaction.FragmentWithdrawalTon.Title" = "Gram Withdrawal"; +"Stars.Transaction.From" = "From"; +"Stars.Transaction.Gift.Title" = "Gift"; +"Stars.Transaction.GiftAuctionBid" = "Gift Auction Bid"; +"Stars.Transaction.GiftFrom" = "Gift From"; +"Stars.Transaction.GiftPurchase" = "Gift Purchase"; +"Stars.Transaction.GiftSale" = "Gift Sale"; +"Stars.Transaction.GiftTransfer" = "Gift Transfer"; +"Stars.Transaction.GiftUpgrade" = "Gift Upgrade"; +"Stars.Transaction.Giveaway.Boost.Boosts#one" = "%@ Boost"; +"Stars.Transaction.Giveaway.Boost.Boosts#other" = "%@ Boosts"; +"Stars.Transaction.Giveaway.Boost.Members#one" = "%@ Member"; +"Stars.Transaction.Giveaway.Boost.Members#other" = "%@ Members"; +"Stars.Transaction.Giveaway.Boost.Stars#one" = "%@ Star"; +"Stars.Transaction.Giveaway.Boost.Stars#other" = "%@ Stars"; +"Stars.Transaction.Giveaway.Boost.Subscriber#one" = "%@ Subscriber"; +"Stars.Transaction.Giveaway.Boost.Subscriber#other" = "%@ Subscribers"; +"Stars.Transaction.Giveaway.Giveaway" = "Giveaway"; +"Stars.Transaction.Giveaway.Prize" = "Prize"; +"Stars.Transaction.Giveaway.Reason" = "Reason"; +"Stars.Transaction.Giveaway.Stars#one" = "%@ Star"; +"Stars.Transaction.Giveaway.Stars#other" = "%@ Stars"; +"Stars.Transaction.Giveaway.Title" = "Received Prize"; +"Stars.Transaction.GoogleTopUp.Subtitle" = "Play Store"; +"Stars.Transaction.GoogleTopUp.Title" = "Stars Top-Up"; +"Stars.Transaction.Id" = "Transaction ID"; +"Stars.Transaction.LiveStreamPaidMessage#one" = "Fee for %@ Live Story Message"; +"Stars.Transaction.LiveStreamPaidMessage#other" = "Fee for %@ Live Story Messages"; +"Stars.Transaction.LiveStreamPaidMessage.Text" = "You receive **%@%** of the price that you charge for each incoming message."; +"Stars.Transaction.LiveStreamReaction" = "Live Stream Reaction"; +"Stars.Transaction.LiveStreamReaction.Text" = "You receive **%@%** of the amount from each reaction."; +"Stars.Transaction.Media" = "Media"; +"Stars.Transaction.MediaAnd" = "%1$@ and %2$@"; +"Stars.Transaction.MediaPurchase" = "Media Purchase"; +"Stars.Transaction.Paid" = "Full Price"; +"Stars.Transaction.PaidMessage#one" = "Fee for %@ Message"; +"Stars.Transaction.PaidMessage#other" = "Fee for %@ Messages"; +"Stars.Transaction.PaidMessage.Text" = "You receive **%@%** of the price that you charge for each incoming message. [Change Fee >]()"; +"Stars.Transaction.Photos#one" = "%@ Photo"; +"Stars.Transaction.Photos#other" = "%@ Photos"; +"Stars.Transaction.PremiumBotTopUp.Subtitle" = "Premium Bot"; +"Stars.Transaction.PremiumBotTopUp.Title" = "Stars Top-Up"; +"Stars.Transaction.Reaction.Post" = "Post"; +"Stars.Transaction.Reaction.Title" = "Star reaction"; +"Stars.Transaction.Refund" = "Refund"; +"Stars.Transaction.SearchFee.Title" = "Public Post Search"; +"Stars.Transaction.SinglePhoto" = "Photo"; +"Stars.Transaction.SingleVideo" = "Video"; +"Stars.Transaction.Subscription" = "Subscription"; +"Stars.Transaction.Subscription.Active" = "If you cancel now, you can still access your subscription until %@"; +"Stars.Transaction.Subscription.Bot" = "Bot"; +"Stars.Transaction.Subscription.Business" = "Business"; +"Stars.Transaction.Subscription.Cancel" = "Cancel Subscription"; +"Stars.Transaction.Subscription.Cancelled" = "You have canceled your subscription."; +"Stars.Transaction.Subscription.Cancelled.Text" = "You will still have access to [%1$@]() until %2$@."; +"Stars.Transaction.Subscription.Cancelled.Title" = "Subscription canceled"; +"Stars.Transaction.Subscription.CancelledByBot" = "Your subscription was cancelled by the bot."; +"Stars.Transaction.Subscription.CancelledByBusiness" = "Your subscription was cancelled by the business."; +"Stars.Transaction.Subscription.Expired" = "Your subscription expired on %@."; +"Stars.Transaction.Subscription.JoinAgainChannel" = "Join Channel"; +"Stars.Transaction.Subscription.JoinChannel" = "Join Channel"; +"Stars.Transaction.Subscription.LeftChannel" = "You left the channel, but can still return until %@"; +"Stars.Transaction.Subscription.PerMonth" = "%@ / month"; +"Stars.Transaction.Subscription.PerMonthUsd" = "appx. %@ per month"; +"Stars.Transaction.Subscription.Renew" = "Renew Subscription"; +"Stars.Transaction.Subscription.Renewed.Text" = "You renewed your subscription to [%1$@]()."; +"Stars.Transaction.Subscription.Renewed.Title" = "Subscription renewed"; +"Stars.Transaction.Subscription.Status.Expired" = "Expired"; +"Stars.Transaction.Subscription.Status.Expires" = "Expires"; +"Stars.Transaction.Subscription.Status.Renews" = "Renews"; +"Stars.Transaction.Subscription.Status.Subscribed" = "Subscribed"; +"Stars.Transaction.Subscription.Subscriber" = "Subscriber"; +"Stars.Transaction.Subscription.Subscription" = "Subscription"; +"Stars.Transaction.Subscription.Title" = "Subscription"; +"Stars.Transaction.SubscriptionFee" = "Monthly Subscription Fee"; +"Stars.Transaction.TelegramAds.Subtitle" = "Telegram Ads"; +"Stars.Transaction.TelegramAds.Title" = "Stars Withdrawal"; +"Stars.Transaction.TelegramBotApi.Messages#one" = "%@ Message"; +"Stars.Transaction.TelegramBotApi.Messages#other" = "%@ Messages"; +"Stars.Transaction.TelegramBotApi.Title" = "Paid Broadcast"; +"Stars.Transaction.TelegramPremium#one" = "Premium for %@ Month"; +"Stars.Transaction.TelegramPremium#other" = "Premium for %@ Months"; +"Stars.Transaction.Terms" = "Review the [Terms of Service]() for Stars."; +"Stars.Transaction.Terms_URL" = "https://telegram.org/tos/stars"; +"Stars.Transaction.To" = "To"; +"Stars.Transaction.Unsupported.Title" = "Unsupported"; +"Stars.Transaction.Via" = "Via"; +"Stars.Transaction.Videos#one" = "%@ Video"; +"Stars.Transaction.Videos#other" = "%@ Videos"; +"Stars.Transfer.Balance" = "Balance"; +"Stars.Transfer.BotSubscribeInfo" = "Do you want to subscribe to **%1$@** in **%2$@** for **%3$@** per month?"; +"Stars.Transfer.BotSubscribeInfo.Stars#one" = "%@ Star"; +"Stars.Transfer.BotSubscribeInfo.Stars#other" = "%@ Stars"; +"Stars.Transfer.Info" = "Do you want to buy **%1$@** in **%2$@** for **%3$@**?"; +"Stars.Transfer.Info.Stars#one" = "%@ Star"; +"Stars.Transfer.Info.Stars#other" = "%@ Stars"; +"Stars.Transfer.MediaAnd" = "%1$@ and %2$@"; +"Stars.Transfer.Pay" = "Confirm and Pay"; +"Stars.Transfer.Photos#one" = "%@ photo"; +"Stars.Transfer.Photos#other" = "%@ photos"; +"Stars.Transfer.Purchased.Stars#one" = "%@ Star"; +"Stars.Transfer.Purchased.Stars#other" = "%@ Stars"; +"Stars.Transfer.PurchasedText" = "You acquired **%1$@** in **%2$@** for **%3$@**."; +"Stars.Transfer.PurchasedTitle" = "Purchase Completed"; +"Stars.Transfer.SinglePhoto" = "photo"; +"Stars.Transfer.SingleVideo" = "video"; +"Stars.Transfer.Subscribe" = "Subscribe"; +"Stars.Transfer.Subscribe.Channel.Title" = "Subscribe"; +"Stars.Transfer.Subscribe.Successful.Text" = "%1$@ transferred to %2$@."; +"Stars.Transfer.Subscribe.Successful.Title" = "Subscription successful!"; +"Stars.Transfer.SubscribeFor" = "Subscribe for"; +"Stars.Transfer.SubscribeInfo" = "Do you want to subscribe to **%1$@** for **%2$@** per month?"; +"Stars.Transfer.SubscribePerMonth" = "/ month"; +"Stars.Transfer.Terms" = "By purchasing you agree to the [Terms of Service]()."; +"Stars.Transfer.Terms_URL" = "https://telegram.org/tos/stars"; +"Stars.Transfer.Title" = "Confirm Your Purchase"; +"Stars.Transfer.Unavailable" = "Sorry, purchasing Stars is not available in your country."; +"Stars.Transfer.UnlockBotInfo" = "Do you want to unlock %1$@ from **%2$@** for **%3$@**?"; +"Stars.Transfer.UnlockInfo" = "Do you want to unlock %1$@ in **%2$@** for **%3$@**?"; +"Stars.Transfer.UnlockedText" = "You unlocked media for **%1$@**."; +"Stars.Transfer.Videos#one" = "%@ video"; +"Stars.Transfer.Videos#other" = "%@ videos"; +"Stars.Withdraw.AmountPlaceholder" = "Stars Amount"; +"Stars.Withdraw.AmountTitle" = "ENTER AMOUNT TO WITHDRAW"; +"Stars.Withdraw.Title" = "Withdraw"; +"Stars.Withdraw.Withdraw" = "Withdraw"; +"Stars.Withdraw.Withdraw.ErrorMinimum" = "You cannot withdraw less than [%@]()."; +"Stars.Withdraw.Withdraw.ErrorMinimum.Stars#one" = "%@ Star"; +"Stars.Withdraw.Withdraw.ErrorMinimum.Stars#other" = "%@ Stars"; +"Stars.Withdraw.Withdraw.ErrorTimeout" = "Next withdrawal will be available in **%@**."; +"StarsBalance.ChannelBalance" = "Your channel balance is %@"; +"StarsBalance.GetMoreStars" = "Get More Stars >"; +"StarsBalance.YourBalance" = "Your balance is %@"; +"StarsTransaction.Commission" = "Commission"; +"StarsTransaction.StarRefReason.Affiliate" = "Affiliate"; +"StarsTransaction.StarRefReason.Commission" = "Commission"; +"StarsTransaction.StarRefReason.Miniapp" = "Mini App"; +"StarsTransaction.StarRefReason.Program" = "Affiliate Program"; +"StarsTransaction.StarRefReason.Referred" = "Referred User"; +"StarsTransaction.StarRefReason.Title" = "Reason"; +"StarsTransaction.TitleCommission" = "%@% Commission"; +"State.Connecting" = "Connecting..."; +"State.ConnectingToProxy" = "Connecting to Proxy..."; +"State.ConnectingToProxyInfo" = "tap here for settings"; +"State.Updating" = "Updating..."; +"State.WaitingForNetwork" = "Waiting for Network"; +"State.connecting" = "connecting"; +"Stats.Boosts" = "Boosts"; +"Stats.Boosts.Boosters#one" = "%@ BOOSTER"; +"Stats.Boosts.Boosters#other" = "%@ BOOSTERS"; +"Stats.Boosts.BoostersInfo" = "Your channel is currently boosted by these users."; +"Stats.Boosts.BoostersNone" = "BOOSTERS"; +"Stats.Boosts.Boosts#one" = "%@ BOOST"; +"Stats.Boosts.Boosts#other" = "%@ BOOSTS"; +"Stats.Boosts.BoostsNone" = "BOOSTS"; +"Stats.Boosts.BoostsToLevelUp" = "Boosts to Level Up"; +"Stats.Boosts.ExistingBoosts" = "Active Boosts"; +"Stats.Boosts.ExpiresOn" = "Boost expires on %@"; +"Stats.Boosts.GetBoosts" = "Get Boosts via Gifts"; +"Stats.Boosts.GetBoostsInfo" = "Get more boosts for your channel by gifting Premium to your subscribers."; +"Stats.Boosts.Gift" = "Gift"; +"Stats.Boosts.Giveaway" = "Giveaway"; +"Stats.Boosts.Group.BoostersInfo" = "Your group is currently boosted by these members."; +"Stats.Boosts.Group.GetBoostsInfo" = "Get more boosts for your group by gifting Premium to your members."; +"Stats.Boosts.Group.LinkInfo" = "Share this link with your members to get more boosts."; +"Stats.Boosts.Group.NoBoostersYet" = "No users currently boost your group"; +"Stats.Boosts.Level" = "Level"; +"Stats.Boosts.LinkHeader" = "LINK FOR BOOSTING"; +"Stats.Boosts.LinkInfo" = "Share this link with your subscribers to get more boosts."; +"Stats.Boosts.NoBoostersYet" = "No users currently boost your channel"; +"Stats.Boosts.OverviewHeader" = "OVERVIEW"; +"Stats.Boosts.PremiumMembers" = "Premium Members"; +"Stats.Boosts.PremiumSubscribers" = "Premium Subscribers"; +"Stats.Boosts.PrepaidGiveawayCount#one" = "%@ Telegram Premium"; +"Stats.Boosts.PrepaidGiveawayCount#other" = "%@ Telegram Premiums"; +"Stats.Boosts.PrepaidGiveawayMonths" = "%@-month subscriptions"; +"Stats.Boosts.PrepaidGiveawaysInfo" = "Select a giveaway you already paid for to set it up."; +"Stats.Boosts.PrepaidGiveawaysTitle" = "PREPAID GIVEAWAYS"; +"Stats.Boosts.ShortMonth" = "%@m"; +"Stats.Boosts.ShowMoreBoosts#one" = "Show %@ More Boost"; +"Stats.Boosts.ShowMoreBoosts#other" = "Show %@ More Boosts"; +"Stats.Boosts.Stars#one" = "%@ Star"; +"Stats.Boosts.Stars#other" = "%@ Stars"; +"Stats.Boosts.StarsWinners#one" = "for %@ winner"; +"Stats.Boosts.StarsWinners#other" = "among %@ winners"; +"Stats.Boosts.TabBoosts#one" = "%@ Boost"; +"Stats.Boosts.TabBoosts#other" = "%@ Boosts"; +"Stats.Boosts.TabGifts#one" = "%@ Gift"; +"Stats.Boosts.TabGifts#other" = "%@ Gifts"; +"Stats.Boosts.ToBeDistributed" = "To Be Distributed"; +"Stats.Boosts.TooltipToBeDistributed" = "The recipient will be selected when the giveaway ends."; +"Stats.Boosts.Unclaimed" = "Unclaimed"; +"Stats.EnabledNotifications" = "Enabled Notifications"; +"Stats.Followers" = "Followers"; +"Stats.FollowersBySourceTitle" = "FOLLOWERS BY SOURCE"; +"Stats.FollowersTitle" = "FOLLOWERS"; +"Stats.GroupActionsTitle" = "ACTIONS"; +"Stats.GroupGrowthTitle" = "GROWTH"; +"Stats.GroupLanguagesTitle" = "MEMBERS' PRIMARY LANGUAGE"; +"Stats.GroupMembers" = "Members"; +"Stats.GroupMembersTitle" = "GROUP MEMBERS"; +"Stats.GroupMessages" = "Messages"; +"Stats.GroupMessagesTitle" = "MESSAGES"; +"Stats.GroupNewMembersBySourceTitle" = "NEW MEMBERS BY SOURCE"; +"Stats.GroupOverview" = "OVERVIEW"; +"Stats.GroupPosters" = "Posting Members"; +"Stats.GroupShowMoreTopAdmins#one" = "Show %@ More"; +"Stats.GroupShowMoreTopAdmins#other" = "Show %@ More"; +"Stats.GroupShowMoreTopInviters#one" = "Show %@ More"; +"Stats.GroupShowMoreTopInviters#other" = "Show %@ More"; +"Stats.GroupShowMoreTopPosters#one" = "Show %@ More"; +"Stats.GroupShowMoreTopPosters#other" = "Show %@ More"; +"Stats.GroupTopAdmin.Actions" = "Actions"; +"Stats.GroupTopAdmin.Promote" = "Promote"; +"Stats.GroupTopAdminBans#one" = "%@ restriction"; +"Stats.GroupTopAdminBans#other" = "%@ restrictions"; +"Stats.GroupTopAdminDeletions#one" = "%@ deletion"; +"Stats.GroupTopAdminDeletions#other" = "%@ deletions"; +"Stats.GroupTopAdminKicks#one" = "%@ ban"; +"Stats.GroupTopAdminKicks#other" = "%@ bans"; +"Stats.GroupTopAdminsTitle" = "TOP ADMINS"; +"Stats.GroupTopHoursTitle" = "TOP HOURS"; +"Stats.GroupTopInviter.History" = "History"; +"Stats.GroupTopInviter.Promote" = "Promote"; +"Stats.GroupTopInviterInvites#one" = "%@ invitation"; +"Stats.GroupTopInviterInvites#other" = "%@ invitations"; +"Stats.GroupTopInvitersTitle" = "TOP INVITERS"; +"Stats.GroupTopPoster.History" = "History"; +"Stats.GroupTopPoster.Promote" = "Promote"; +"Stats.GroupTopPosterChars#one" = "%@ character per message"; +"Stats.GroupTopPosterChars#other" = "%@ characters per message"; +"Stats.GroupTopPosterMessages#one" = "%@ message"; +"Stats.GroupTopPosterMessages#other" = "%@ messages"; +"Stats.GroupTopPostersTitle" = "TOP MEMBERS"; +"Stats.GroupTopWeekdaysTitle" = "TOP DAYS OF WEEK"; +"Stats.GroupViewers" = "Viewing Members"; +"Stats.GrowthTitle" = "GROWTH"; +"Stats.InstantViewInteractionsTitle" = "INSTANT VIEW INTERACTIONS"; +"Stats.InteractionsTitle" = "INTERACTIONS"; +"Stats.LanguagesTitle" = "LANGUAGES"; +"Stats.LoadingText" = "Please wait a few moments while\nwe generate your stats."; +"Stats.LoadingTitle" = "Preparing stats"; +"Stats.Message.PrivateShares" = "Private Shares"; +"Stats.Message.PublicShares" = "Public Shares"; +"Stats.Message.Reactions" = "Reactions"; +"Stats.Message.Views" = "Views"; +"Stats.MessageForwards#one" = "%@ shared"; +"Stats.MessageForwards#other" = "%@ shared"; +"Stats.MessageInteractionsTitle" = "Interactions"; +"Stats.MessageOverview" = "Overview"; +"Stats.MessagePublicForwardsTitle" = "Public Shares"; +"Stats.MessageReactionsTitle" = "REACTIONS"; +"Stats.MessageTitle" = "Message Statistics"; +"Stats.MessageViews#one" = "%@ view"; +"Stats.MessageViews#other" = "%@ views"; +"Stats.MessageViews.NoViews" = "No views"; +"Stats.Monetization" = "Monetization"; +"Stats.NotificationsTitle" = "NOTIFICATIONS"; +"Stats.Overview" = "OVERVIEW"; +"Stats.PostsTitle" = "RECENT POSTS"; +"Stats.ReactionsByEmotionTitle" = "REACTIONS BY EMOTION"; +"Stats.ReactionsPerPost" = "Reactions Per Post"; +"Stats.ReactionsPerStory" = "Reactions Per Story"; +"Stats.RevenueInStars" = "Revenue in Stars"; +"Stats.RevenueInTon" = "Revenue in Grams"; +"Stats.RevenueInUsd" = "Revenue in USD"; +"Stats.SharesPerPost" = "Shares Per Post"; +"Stats.SharesPerStory" = "Shares Per Story"; +"Stats.Statistics" = "Statistics"; +"Stats.StoryInteractionsTitle" = "STORY INTERACTIONS"; +"Stats.StoryReactionsByEmotionTitle" = "STORY REACTIONS BY EMOTION"; +"Stats.StoryTitle" = "Story Statistics"; +"Stats.TonBotRevenue.Title" = "Gram Balance"; +"Stats.Total" = "Total"; +"Stats.ViewsByHoursTitle" = "VIEWS BY HOURS (UTC)"; +"Stats.ViewsBySourceTitle" = "VIEWS BY SOURCE"; +"Stats.ViewsPerPost" = "Views Per Post"; +"Stats.ViewsPerStory" = "Views Per Story"; +"Stats.ZoomOut" = "Zoom Out"; +"StickerPack.Add" = "Add"; +"StickerPack.AddEmojiCount#one" = "Add 1 Emoji"; +"StickerPack.AddEmojiCount#other" = "Add %@ Emoji"; +"StickerPack.AddEmojiPacksCount#one" = "Add 1 Emoji Pack"; +"StickerPack.AddEmojiPacksCount#other" = "Add %@ Emoji Packs"; +"StickerPack.AddExisting" = "Add an Existing Sticker"; +"StickerPack.AddMaskCount#one" = "Add 1 Mask"; +"StickerPack.AddMaskCount#other" = "Add %@ Masks"; +"StickerPack.AddSticker" = "Add Sticker"; +"StickerPack.AddStickerCount#one" = "Add 1 Sticker"; +"StickerPack.AddStickerCount#other" = "Add %@ Stickers"; +"StickerPack.BuiltinPackName" = "Great Minds"; +"StickerPack.CopyLink" = "Copy Link"; +"StickerPack.CopyLinks" = "Copy Links"; +"StickerPack.CreateNew" = "Create a New Sticker"; +"StickerPack.Delete" = "Delete"; +"StickerPack.Delete.Delete" = "Delete"; +"StickerPack.Delete.DeleteForEveyone" = "Delete for Everyone"; +"StickerPack.Delete.RemoveForMe" = "Remove for Me"; +"StickerPack.Delete.Text" = "This will delete the sticker set for all users."; +"StickerPack.Delete.Title" = "Delete Sticker Set"; +"StickerPack.EditInfo" = "Check the [@stickers]() bot for more options."; +"StickerPack.EditName" = "Edit Name"; +"StickerPack.EditName.Text" = "Choose a new name for your set."; +"StickerPack.EditName.Title" = "Edit Sticker Set"; +"StickerPack.EditStickers" = "Edit Stickers"; +"StickerPack.EmojiCount#one" = "1 emoji"; +"StickerPack.EmojiCount#other" = "%@ emoji"; +"StickerPack.ErrorNotFound" = "Sorry, this sticker set doesn't seem to exist."; +"StickerPack.HideStickers" = "Hide Stickers"; +"StickerPack.MaskCount#one" = "1 mask"; +"StickerPack.MaskCount#other" = "%@ masks"; +"StickerPack.RemoveEmojiCount#one" = "Remove 1 Emoji"; +"StickerPack.RemoveEmojiCount#other" = "Remove %@ Emoji"; +"StickerPack.RemoveEmojiPacksCount#one" = "Remove 1 Emoji Pack"; +"StickerPack.RemoveEmojiPacksCount#other" = "Remove %@ Emoji Packs"; +"StickerPack.RemoveMaskCount#one" = "Remove 1 Mask"; +"StickerPack.RemoveMaskCount#other" = "Remove %@ Masks"; +"StickerPack.RemoveStickerCount#one" = "Remove 1 Sticker"; +"StickerPack.RemoveStickerCount#other" = "Remove %@ Stickers"; +"StickerPack.RemoveStickerSet" = "Remove Sticker Set"; +"StickerPack.Reorder" = "Reorder"; +"StickerPack.Send" = "Send Sticker"; +"StickerPack.Share" = "Share"; +"StickerPack.ShowStickers" = "Show Stickers"; +"StickerPack.StickerAdded" = "Sticker added to the **%@** sticker set."; +"StickerPack.StickerCount#one" = "1 sticker"; +"StickerPack.StickerCount#other" = "%@ stickers"; +"StickerPack.StickerUpdated" = "Sticker updated."; +"StickerPack.ViewPack" = "View Sticker Set"; +"StickerPackActionInfo.AddedText" = "%@ has been added to your stickers."; +"StickerPackActionInfo.AddedTitle" = "Stickers Added"; +"StickerPackActionInfo.ArchivedTitle" = "Stickers Archived"; +"StickerPackActionInfo.MultipleRemovedText#one" = "%@ pack is no longer in your stickers."; +"StickerPackActionInfo.MultipleRemovedText#other" = "%@ packs are no longer in your stickers."; +"StickerPackActionInfo.RemovedText" = "%@ is no longer in your stickers."; +"StickerPackActionInfo.RemovedTitle" = "Stickers Removed"; +"StickerPacks.ActionArchive" = "Archive"; +"StickerPacks.ActionDelete" = "Delete"; +"StickerPacks.ActionShare" = "Share"; +"StickerPacks.ActionUnarchive" = "Unarchive"; +"StickerPacks.ArchiveStickerPacksConfirmation#one" = "Archive %@ Set"; +"StickerPacks.ArchiveStickerPacksConfirmation#other" = "Archive %@ Sets"; +"StickerPacks.DeleteEmojiPacksConfirmation#one" = "Delete 1 Emoji Pack"; +"StickerPacks.DeleteEmojiPacksConfirmation#other" = "Delete %@ Emoji Packs"; +"StickerPacks.DeleteStickerPacksConfirmation#one" = "Delete %@ Sticker Set"; +"StickerPacks.DeleteStickerPacksConfirmation#other" = "Delete %@ Sticker Sets"; +"StickerPacks.UnarchiveStickerPacksConfirmation#one" = "Unarchive %@ Set"; +"StickerPacks.UnarchiveStickerPacksConfirmation#other" = "Unarchive %@ Sets"; +"StickerPacksSettings.AnimatedStickers" = "Loop Animated Stickers"; +"StickerPacksSettings.AnimatedStickersInfo" = "Animated stickers will play continuously in chats."; +"StickerPacksSettings.ArchivedMasks" = "Archived Masks"; +"StickerPacksSettings.ArchivedMasks.Info" = "You can add up to 200 sets of masks.\nUnused sets are archived when you add more."; +"StickerPacksSettings.ArchivedPacks" = "Archived Stickers"; +"StickerPacksSettings.ArchivedPacks.Info" = "You can have 200 active sticker sets.\nUnused sets are archived when you add more."; +"StickerPacksSettings.DynamicOrder" = "Dynamic Pack Order"; +"StickerPacksSettings.DynamicOrderInfo" = "Recently used sticker sets will be displayed above the older ones."; +"StickerPacksSettings.DynamicOrderOff" = "Dynamic Order Off"; +"StickerPacksSettings.DynamicOrderOffInfo" = "Sticker sets will no longer be rearranged when you send a sticker."; +"StickerPacksSettings.Emoji" = "Emoji"; +"StickerPacksSettings.FeaturedPacks" = "Trending Stickers"; +"StickerPacksSettings.ManagingHelp" = "Artists are welcome to add their own sticker sets using our @stickers bot.\n\nTap on a sticker to view and add the whole set."; +"StickerPacksSettings.MyStickers" = "MY STICKERS"; +"StickerPacksSettings.ShowStickersButton" = "Show Stickers Tab"; +"StickerPacksSettings.ShowStickersButtonHelp" = "A sticker icon will appear in the input field."; +"StickerPacksSettings.StickerPacksSection" = "STICKER SETS"; +"StickerPacksSettings.SuggestAnimatedEmoji" = "Suggest Animated Emoji"; +"StickerPacksSettings.SuggestAnimatedEmojiInfo" = "Each time you enter an emoji you can replace it with an animated emoji."; +"StickerPacksSettings.Title" = "Stickers and Emoji"; +"StickerPacksSettings.TrendingStickers" = "Trending Stickers"; +"StickerPicker.PollSubtitle.PollOption" = "Add sticker to this option"; +"StickerPicker.Title" = "Sticker"; +"StickerSettings.ContextHide" = "Archive"; +"StickerSettings.ContextInfo" = "If you archive a sticker set, you can quickly restore it later from the Archived Stickers section."; +"StickerSettings.EmojiContextInfo" = "If you archive an emoji pack, you can quickly restore it later from the Archived Emoji section."; +"StickerSettings.MaskContextInfo" = "If you archive a set of masks, you can quickly restore it later from the Archived Masks section."; +"Stickers.AddToFavorites" = "Add to Favorites"; +"Stickers.ChooseSticker.Title" = "Choose a Sticker"; +"Stickers.ClearRecent" = "Clear Recent Stickers"; +"Stickers.CreateSticker" = "Create\nSticker"; +"Stickers.Delete" = "Delete"; +"Stickers.Delete.ForEveryone" = "Delete for Everyone"; +"Stickers.Edit" = "EDIT"; +"Stickers.EditSticker" = "Edit Sticker"; +"Stickers.EmojiPackInfoText" = "This message contains **%@** emoji."; +"Stickers.FavoriteStickers" = "Favorite Stickers"; +"Stickers.Favorites" = "Favorites"; +"Stickers.FrequentlyUsed" = "Recently Used"; +"Stickers.Gifs" = "GIFs"; +"Stickers.GroupChooseStickerPack" = "CHOOSE STICKER SET"; +"Stickers.GroupStickers" = "Group Stickers"; +"Stickers.GroupStickersHelp" = "You can choose a set that will be available to all group members when they are chatting in this group."; +"Stickers.Install" = "ADD"; +"Stickers.Installed" = "ADDED"; +"Stickers.NoStickersFound" = "No Stickers Found"; +"Stickers.PremiumPackInfoText" = "This set contains premium stickers like this one."; +"Stickers.PremiumPackView" = "View"; +"Stickers.PremiumStickers" = "Premium Stickers"; +"Stickers.Recent" = "Recent"; +"Stickers.RemoveFromFavorites" = "Remove from Favorites"; +"Stickers.RemoveFromRecent" = "Remove from Recents"; +"Stickers.Reorder" = "Reorder"; +"Stickers.Search" = "Search for stickers"; +"Stickers.Settings" = "Settings"; +"Stickers.ShowMore" = "Show More"; +"Stickers.Stickers" = "Stickers"; +"Stickers.SuggestAdded" = "My Sets"; +"Stickers.SuggestAll" = "All Sets"; +"Stickers.SuggestNone" = "None"; +"Stickers.SuggestStickers" = "Suggest by Emoji"; +"Stickers.Trending" = "Trending"; +"Stickers.TrendingPremiumStickers" = "Trending Premium Stickers"; +"StickersList.ArchivedEmojiItem" = "Archived Emoji"; +"StickersList.EmojiItem" = "Custom Emoji"; +"StickersSearch.SearchStickersPlaceholder" = "Search Stickers"; +"StorageManagement.AutoremoveDescription" = "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space."; +"StorageManagement.AutoremoveHeader" = "AUTO-REMOVE CACHED MEDIA"; +"StorageManagement.AutoremoveSpaceDescription" = "If your cache size exceeds this limit, the oldest unused media will be removed from the device."; +"StorageManagement.ClearAll" = "Clear Entire Cache"; +"StorageManagement.ClearCache" = "Clear Cache"; +"StorageManagement.ClearConfirmationText" = "Media and documents will stay in the cloud and can be re-downloaded if you need them again."; +"StorageManagement.ClearSelected" = "Clear Selected"; +"StorageManagement.ContextDeselect" = "Deselect"; +"StorageManagement.ContextSelect" = "Select"; +"StorageManagement.DescriptionAppUsage" = "Telegram uses %1$@% of your free disk space."; +"StorageManagement.DescriptionChatUsage" = "This chat uses %1$@% of your Telegram cache."; +"StorageManagement.DescriptionCleared" = "All media can be re-downloaded from the Telegram cloud if you need them again."; +"StorageManagement.OpenFile" = "Open File"; +"StorageManagement.OpenPhoto" = "Open Photo"; +"StorageManagement.OpenVideo" = "Open Video"; +"StorageManagement.PeerOpenProfile" = "Open Profile"; +"StorageManagement.PeerShowDetails" = "Show Details"; +"StorageManagement.SectionAvatars" = "Avatars"; +"StorageManagement.SectionCalls" = "Calls"; +"StorageManagement.SectionFiles" = "Files"; +"StorageManagement.SectionMessages" = "Messages"; +"StorageManagement.SectionMiscellaneous" = "Misc"; +"StorageManagement.SectionMusic" = "Music"; +"StorageManagement.SectionOther" = "Other"; +"StorageManagement.SectionPhotos" = "Photos"; +"StorageManagement.SectionStickers" = "Stickers"; +"StorageManagement.SectionStories" = "Stories"; +"StorageManagement.SectionVideos" = "Videos"; +"StorageManagement.SectionVoiceMessages" = "Voice Messages"; +"StorageManagement.SectionsDescription" = "All media will stay in the Telegram cloud and can be re-downloaded if you need them again."; +"StorageManagement.TabChats" = "Chats"; +"StorageManagement.TabFiles" = "Files"; +"StorageManagement.TabMedia" = "Media"; +"StorageManagement.TabMusic" = "Music"; +"StorageManagement.Title" = "Storage Usage"; +"StorageManagement.TitleCleared" = "Storage Cleared"; +"Stories.AddStories#one" = "Add %@ Story"; +"Stories.AddStories#other" = "Add %@ Stories"; +"Stories.AddStoriesEmpty" = "Add Stories"; +"Stories.AddStoriesTitle" = "Add Stories"; +"Stories.AlbumAdd" = "+ Add Album"; +"Stories.AlbumAll" = "All Stories"; +"Stories.AlbumEmptyButton" = "Add to Album"; +"Stories.AlbumEmptyText" = "Add some stories to this album."; +"Stories.AlbumEmptyTitle" = "Organize Your Stories"; +"Stories.CreateAlbum.Placeholder" = "Title"; +"Stories.CreateAlbum.Text" = "Choose a name for your album and add your stories."; +"Stories.CreateAlbum.Title" = "Create a New Album"; +"Stories.DeleteAlbum.Confirmation" = "Delete %@?"; +"Stories.EditAlbum.Text" = "Choose a new name for your album."; +"Stories.EditAlbum.Title" = "Edit Album Name"; +"Stories.MenuAddToAlbum" = "Add to Album"; +"Stories.MenuDeleteAlbum" = "Delete Album"; +"Stories.MenuNewAlbum" = "New Album"; +"Stories.MenuRemoveFromAlbum" = "Remove from Album"; +"Stories.MenuRenameAlbum" = "Rename Album"; +"Stories.Post.Album" = "Album"; +"Stories.Post.AlbumAll" = "All Stories"; +"Stories.Post.AlbumCount#one" = "%@ Album"; +"Stories.Post.AlbumCount#other" = "%@ Albums"; +"Stories.Post.AlbumFooter" = "Choose the albums where you want to share your story."; +"Stories.StatusEmpty" = "no stories"; +"Stories.ToastAddedToFolder#one" = "Story added to album."; +"Stories.ToastAddedToFolder#other" = "%@ stories added to album."; +"Stories.ToastAlbumNotAvailable" = "This album is no longer available."; +"Story.AlertStealthModeActiveAction" = "Proceed"; +"Story.AlertStealthModeActiveText" = "If you send a reply or reaction, the creator of the story will see you in the list of viewers."; +"Story.AlertStealthModeActiveTitle" = "You are in Stealth Mode now"; +"Story.Camera.AccessOpenSettings" = "Open Settings"; +"Story.Camera.AccessPlaceholderText" = "This lets you share photos and record videos."; +"Story.Camera.AccessPlaceholderTitle" = "Allow Telegram to access your camera and microphone"; +"Story.Camera.Live" = "Live"; +"Story.Camera.Photo" = "Photo"; +"Story.Camera.SwipeLeftRelease" = "Release to lock"; +"Story.Camera.SwipeLeftToLock" = "Swipe left to lock"; +"Story.Camera.SwipeRightToFlip" = "Swipe right to flip"; +"Story.Camera.SwipeUpToZoom" = "Swipe up to zoom"; +"Story.Camera.TooltipDisableDual" = "Tap here to disable\nthe selfie camera"; +"Story.Camera.TooltipDraftSaved" = "Draft Saved"; +"Story.Camera.TooltipTakePhotos" = "Take photos or videos to share with all\nyour contacts or close friends at once."; +"Story.Camera.Video" = "Video"; +"Story.CaptionShowMore" = "Show more"; +"Story.Context.CopyLink" = "Copy Link"; +"Story.Context.Edit" = "Edit Story"; +"Story.Context.EditCover" = "Edit Cover"; +"Story.Context.EmbeddedEmojiPack" = "This story contains\n#[%@]() emoji."; +"Story.Context.EmbeddedStickerPack" = "This story contains\n#[%@]() stickers."; +"Story.Context.EmbeddedStickers" = "This story contains stickers from [%@]()."; +"Story.Context.EmbeddedStickersValue#one" = "1 pack"; +"Story.Context.EmbeddedStickersValue#other" = "%d packs"; +"Story.Context.Privacy" = "Who Can See"; +"Story.Context.RemoveFromChannel" = "Remove from Posts"; +"Story.Context.RemoveFromProfile" = "Remove from Profile"; +"Story.Context.Report" = "Report"; +"Story.Context.SaveToChannel" = "Post to Profile"; +"Story.Context.SaveToGallery" = "Save to Gallery"; +"Story.Context.SaveToProfile" = "Post to Profile"; +"Story.Context.Share" = "Share"; +"Story.Context.TooltipPremiumSaveStories" = "Subscribe to [Telegram Premium]()\u00a0to save other people's stories that are not protected."; +"Story.Context.ViewStats" = "View Statistics"; +"Story.ContextDeleteContact" = "Delete Contact"; +"Story.ContextDeleteStory" = "Delete Story"; +"Story.ContextHideStoriesFrom" = "Hide My Stories From %@"; +"Story.ContextMenuHD" = "Increase Quality"; +"Story.ContextMenuLiveSettings" = "Live Settings"; +"Story.ContextMenuPip" = "Minimize"; +"Story.ContextMenuSD" = "Decrease Quality"; +"Story.ContextPrivacy.LabelCloseFriends" = "Close Friends"; +"Story.ContextPrivacy.LabelContacts" = "My Contacts"; +"Story.ContextPrivacy.LabelContactsExcept" = "Contacts (-%@)"; +"Story.ContextPrivacy.LabelEveryone" = "Everyone"; +"Story.ContextPrivacy.LabelOnlyMe" = "Only Me"; +"Story.ContextPrivacy.LabelOnlySelected#one" = "1 Person"; +"Story.ContextPrivacy.LabelOnlySelected#other" = "%d People"; +"Story.ContextShowStoriesTo" = "Do Not Hide My Stories From %@"; +"Story.ContextStealthMode" = "Hide My View"; +"Story.Cover" = "Story Cover"; +"Story.Editor.Add" = "Add"; +"Story.Editor.Blur.Linear" = "Linear"; +"Story.Editor.Blur.Off" = "Off"; +"Story.Editor.Blur.Portrait" = "Portrait"; +"Story.Editor.Blur.Radial" = "Radial"; +"Story.Editor.Blur.Title" = "Blur"; +"Story.Editor.Curves.All" = "All"; +"Story.Editor.Curves.Blue" = "Blue"; +"Story.Editor.Curves.Green" = "Green"; +"Story.Editor.Curves.Red" = "Red"; +"Story.Editor.DiscardText" = "If you go back now, you will lose any changes you made."; +"Story.Editor.Done" = "Done"; +"Story.Editor.DraftDiscaedText" = "If you go back without saving, you will lose any changes you made."; +"Story.Editor.DraftDiscard" = "Discard"; +"Story.Editor.DraftDiscardDraft" = "Discard Draft?"; +"Story.Editor.DraftDiscardMedia" = "Discard Changes?"; +"Story.Editor.DraftKeepDraft" = "Keep Draft"; +"Story.Editor.DraftKeepMedia" = "Save Draft"; +"Story.Editor.ExpirationText" = "Choose how long the story will be visible."; +"Story.Editor.ExpirationValue#one" = "1 Hour"; +"Story.Editor.ExpirationValue#other" = "%d Hours"; +"Story.Editor.InputPlaceholderAddCaption" = "Add a caption..."; +"Story.Editor.Next" = "Next"; +"Story.Editor.PreparingVideo" = "Preparing Video..."; +"Story.Editor.Tint.Highlights" = "Highlights"; +"Story.Editor.Tint.Shadows" = "Shadows"; +"Story.Editor.Tool.Brightness" = "Brightness"; +"Story.Editor.Tool.Contrast" = "Contrast"; +"Story.Editor.Tool.Enhance" = "Enhance"; +"Story.Editor.Tool.Fade" = "Fade"; +"Story.Editor.Tool.Grain" = "Grain"; +"Story.Editor.Tool.Highlights" = "Highlights"; +"Story.Editor.Tool.Saturation" = "Saturation"; +"Story.Editor.Tool.Shadows" = "Shadows"; +"Story.Editor.Tool.Vignette" = "Vignette"; +"Story.Editor.Tool.Warmth" = "Warmth"; +"Story.Editor.TooltipImageSavedToPhotos" = "Image saved to Photos."; +"Story.Editor.TooltipLinkLimitValue#one" = "**%@** link"; +"Story.Editor.TooltipLinkLimitValue#other" = "**%@** links"; +"Story.Editor.TooltipLinkPremium" = "Subscribe to [Telegram Premium]() to add links."; +"Story.Editor.TooltipMuted" = "This story will have no sound."; +"Story.Editor.TooltipMutedWithAudio" = "Original audio will be removed"; +"Story.Editor.TooltipPremiumCaptionEntities" = "Subscribe to [Telegram Premium]() to add links and formatting to your story captions."; +"Story.Editor.TooltipPremiumCaptionLimitText" = "Increase this limit 10x to 2048 characters by subscribing to [Telegram Premium]()."; +"Story.Editor.TooltipPremiumCaptionLimitTitle" = "Maximum Length Reached"; +"Story.Editor.TooltipPremiumExpiration" = "Subscribe to [Telegram Premium]() to make your stories disappear after 6, 12 or 48 hours."; +"Story.Editor.TooltipPremiumMore" = "More"; +"Story.Editor.TooltipPremiumReaction" = "Subscribe to [Telegram Premium]() to use this reaction."; +"Story.Editor.TooltipPremiumReactionLimitText" = "Upgrade to [Telegram Premium]() to add up to %@ to a story."; +"Story.Editor.TooltipPremiumReactionLimitTitle" = "Increase Limit"; +"Story.Editor.TooltipPremiumReactionLimitValue#one" = "**%@** reaction sticker"; +"Story.Editor.TooltipPremiumReactionLimitValue#other" = "**%@** reaction stickers"; +"Story.Editor.TooltipReachedLinkLimitText" = "You can't add more than %@ to a story."; +"Story.Editor.TooltipReachedReactionLimitText" = "You can't add more than %@ to a story."; +"Story.Editor.TooltipReachedReactionLimitTitle" = "Limit Reached"; +"Story.Editor.TooltipSelection#one" = "Tap here to view your %@ story"; +"Story.Editor.TooltipSelection#other" = "Tap here to view your %@ stories"; +"Story.Editor.TooltipUnmuted" = "This story will have sound."; +"Story.Editor.TooltipUnmutedWithAudio" = "Original audio will be preserved"; +"Story.Editor.TooltipVideoSavedToPhotos" = "Video saved to Photos."; +"Story.Editor.TooltipWeatherLimitText" = "You can't add more than one weather sticker to a story."; +"Story.Editor.Uploading" = "Uploading..."; +"Story.Editor.VideoTooShort" = "A video must be at least 1 second long."; +"Story.Footer.NoViews" = "No views yet"; +"Story.Footer.Uploading" = "Uploading..."; +"Story.Footer.ViewCount#one" = "|%d| View"; +"Story.Footer.ViewCount#other" = "|%d| Views"; +"Story.Footer.Views#one" = "1 view"; +"Story.Footer.Views#other" = "%d views"; +"Story.FooterReplyUnavailable" = "You can't reply to this story"; +"Story.ForwardAuthorHiddenTooltip" = "This account was hidden by the user"; +"Story.GroupCommentingRestrictedPlaceholder" = "Comments restricted. Boost the group to unlock."; +"Story.GroupCommentingRestrictedPlaceholderAction" = "Learn More..."; +"Story.Guide.BackDescription" = "Tap the left edge"; +"Story.Guide.BackTitle" = "Go back"; +"Story.Guide.Description" = "You can use these gestures to control playback."; +"Story.Guide.ForwardDescription" = "Tap the screen"; +"Story.Guide.ForwardTitle" = "Go forward"; +"Story.Guide.MoveDescription" = "Swipe left or right"; +"Story.Guide.MoveTitle" = "Move between stories"; +"Story.Guide.PauseDescription" = "Hold and slide left or right"; +"Story.Guide.PauseTitle" = "Pause and Seek"; +"Story.Guide.Proceed" = "Tap to keep watching"; +"Story.Guide.Title" = "Watching Stories"; +"Story.HeaderEdited" = "edited"; +"Story.HeaderYourStory" = "Your story"; +"Story.InputPlaceholderReplyInGroup" = "Comment Story..."; +"Story.InputPlaceholderReplyPrivately" = "Reply Privately..."; +"Story.LiveBadge" = "LIVE"; +"Story.LongTapForMoreReactions" = "Long tap for more reactions."; +"Story.MessageReposted.Channel" = "Message reposted to **%@**."; +"Story.MessageReposted.Personal" = "Message reposted to your stories."; +"Story.MessagingRestrictedPlaceholder" = "%@ only accepts messages from contacts or Premium users."; +"Story.MessagingRestrictedPlaceholderAction" = "Learn more..."; +"Story.PremiumUpgradeStoriesButton" = "Upgrade Stories"; +"Story.Privacy.AllowScreenshots" = "Allow Screenshots"; +"Story.Privacy.CategoryCloseFriends" = "Close Friends"; +"Story.Privacy.CategoryContacts" = "My Contacts"; +"Story.Privacy.CategoryEveryone" = "Everyone"; +"Story.Privacy.CategorySelectedContacts" = "Selected Users"; +"Story.Privacy.Choose" = "choose"; +"Story.Privacy.ChooseCover" = "Choose Story Cover"; +"Story.Privacy.ChooseCoverChannelInfo" = "Choose a frame from this story to show on the channel's profile page."; +"Story.Privacy.ChooseCoverGroupInfo" = "Choose a frame from the story to show on the group's profile page."; +"Story.Privacy.ChooseCoverInfo" = "Choose a frame from this story to show on your Profile."; +"Story.Privacy.ContactsHeader" = "CONTACTS"; +"Story.Privacy.EditList" = "edit list"; +"Story.Privacy.EditStory" = "Edit Story"; +"Story.Privacy.ExcludePeople" = "exclude people"; +"Story.Privacy.ExcludePeopleExcept#one" = "except 1 person"; +"Story.Privacy.ExcludePeopleExcept#other" = "except %@ people"; +"Story.Privacy.ExcludePeopleExceptNames" = "except %@"; +"Story.Privacy.ExcludedPeople" = "Excluded Contacts"; +"Story.Privacy.GrayListPeople#one" = "1 person"; +"Story.Privacy.GrayListPeople#other" = "%@ people"; +"Story.Privacy.GrayListSelect" = "[Select people]() who will never see your stories."; +"Story.Privacy.GrayListSelected" = "[%@]() will never see any of your stories."; +"Story.Privacy.GroupParticipantsLimit" = "You can select groups with up to 200 members."; +"Story.Privacy.GroupTooLarge" = "Group Too Large"; +"Story.Privacy.HideMyStoriesFrom" = "Hide My Stories From"; +"Story.Privacy.KeepOnChannelPage" = "Post to Channel Page"; +"Story.Privacy.KeepOnChannelPageInfo" = "Keep this story on the channel page even after it expires in %@."; +"Story.Privacy.KeepOnChannelPageManyInfo" = "Keep these stories on the channel profile even after they expire in %@."; +"Story.Privacy.KeepOnGroupPage" = "Post to Group Page"; +"Story.Privacy.KeepOnGroupPageInfo" = "Keep this story on group page even after it expires in %@."; +"Story.Privacy.KeepOnGroupPageManyInfo" = "Keep these stories on the group profile even after they expire in %@."; +"Story.Privacy.KeepOnMyPage" = "Post to My Profile"; +"Story.Privacy.KeepOnMyPageHours#one" = "1 hour"; +"Story.Privacy.KeepOnMyPageHours#other" = "%@ hours"; +"Story.Privacy.KeepOnMyPageInfo" = "Keep this story on your profile even after it expires in %@. Privacy settings will apply."; +"Story.Privacy.KeepOnMyPageManyInfo" = "Keep these stories on your profile even after they expire in %@. Privacy settings will apply."; +"Story.Privacy.MentionRestrictedProceed" = "Proceed Anyway"; +"Story.Privacy.MentionRestrictedText" = "The privacy settings of your story will prevent some users you tagged (%@) from viewing it."; +"Story.Privacy.MentionRestrictedTitle" = "Privacy Restrictions"; +"Story.Privacy.People#one" = "1 person"; +"Story.Privacy.People#other" = "%@ people"; +"Story.Privacy.PostStories#one" = "Post %@ Story"; +"Story.Privacy.PostStories#other" = "Post %@ Stories"; +"Story.Privacy.PostStoriesAsHeader" = "POST STORIES AS"; +"Story.Privacy.PostStory" = "Post Story"; +"Story.Privacy.PostStoryAs" = "Post Story As"; +"Story.Privacy.PostStoryAsHeader" = "POST STORY AS"; +"Story.Privacy.SaveList" = "Save List"; +"Story.Privacy.SaveSettings" = "Save Settings"; +"Story.Privacy.SearchChats" = "Search Chats"; +"Story.Privacy.SearchContacts" = "Search Contacts"; +"Story.Privacy.ShareStories" = "Share Stories"; +"Story.Privacy.ShareStory" = "Share Story"; +"Story.Privacy.StartLiveAs" = "Start Live Story As"; +"Story.Privacy.TooltipSharingDisabled" = "Downloading and taking screenshots will be disabled for this story."; +"Story.Privacy.TooltipSharingDisabledPublic" = "Downloading, sharing and taking screenshots will be disabled for this story."; +"Story.Privacy.TooltipSharingEnabled" = "Downloading and taking screenshots will be enabled for this story."; +"Story.Privacy.TooltipSharingEnabledPublic" = "Downloading, sharing and taking screenshots will be enabled for this story."; +"Story.Privacy.TooltipStoryArchived" = "Users allowed to view your story will see it on your profile even after it expires."; +"Story.Privacy.TooltipStoryArchivedChannel" = "Users will see this story on the channel page even after it expires."; +"Story.Privacy.TooltipStoryArchivedGroup" = "Users will see this story on the group page even after it expires."; +"Story.Privacy.TooltipStoryExpires" = "The story will disappear after it expires."; +"Story.Privacy.WhoCanViewHeader" = "WHO CAN VIEW THIS STORY"; +"Story.Privacy.WhoCanViewStoriesHeader" = "WHO CAN VIEW THESE STORIES"; +"Story.PrivacyTooltipCloseFriends" = "This story is now shown to your close friends."; +"Story.PrivacyTooltipContacts" = "This story is now shown to all your contacts."; +"Story.PrivacyTooltipEveryone" = "This story is now shown to everyone."; +"Story.PrivacyTooltipNobody" = "This story is now shown only to you."; +"Story.PrivacyTooltipSelectedContacts" = "This story is shown to selected users."; +"Story.PrivacyTooltipSelectedContacts.Contacts#one" = "1 contact"; +"Story.PrivacyTooltipSelectedContacts.Contacts#other" = "%@ contacts"; +"Story.PrivacyTooltipSelectedContactsCount" = "This story is now shown to %@ contacts."; +"Story.PrivacyTooltipSelectedContactsFull" = "%@ can now see this story."; +"Story.SaveCover" = "Save Cover"; +"Story.ScreenshotBlockedText" = "The story you tried to take\na screenshot of is protected\nfrom copying by its creator."; +"Story.ScreenshotBlockedTitle" = "Screenshot Blocked"; +"Story.SendReactionAsGroupMessage" = "Send reaction as a message"; +"Story.SendReactionAsMessage" = "Send reaction as a private message"; +"Story.SlideToSeek" = "Slide left or right to seek"; +"Story.StealthMode.ControlText" = "Turn Stealth Mode on to watch stories without appearing in the list of viewers."; +"Story.StealthMode.CooldownAction" = "Available in %@"; +"Story.StealthMode.EnableAction" = "Enable Stealth Mode"; +"Story.StealthMode.EnableAndOpenAction" = "Enable and Open the Story"; +"Story.StealthMode.NextText" = "Hide my views for the next **%@**."; +"Story.StealthMode.NextTitle" = "Hide Upcoming Views"; +"Story.StealthMode.RecentText" = "Hide my views from the past **%@**."; +"Story.StealthMode.RecentTitle" = "Hide Recent Views"; +"Story.StealthMode.Title" = "Stealth Mode"; +"Story.StealthMode.ToastCooldownText" = "Please wait until **Stealth Mode** is ready to use again."; +"Story.StealthMode.UpgradeAction" = "Unlock Stealth Mode"; +"Story.StealthMode.UpgradeText" = "Subscribe to Telegram Premium to watch stories without appearing in the list of viewers."; +"Story.StealthModeActivePlaceholder" = "Stealth Mode active – %@"; +"Story.StealthModePlaceholder" = "Stealth Mode active — {m}:{s}"; +"Story.ToastDeletedContact" = "**%@** has been removed from your contacts."; +"Story.ToastHideStoriesFrom" = "**%@** will not be able to see your stories."; +"Story.ToastLinkCopied" = "Link copied."; +"Story.ToastPremiumSaveToGallery" = "Subscribe to [Telegram Premium]() to save other people's stories that are not protected."; +"Story.ToastQualityHD.Text" = "You can lower the quality later for faster downloads."; +"Story.ToastQualityHD.Title" = "Quality Increased"; +"Story.ToastQualitySD.Text" = "Stories will now download faster."; +"Story.ToastQualitySD.Title" = "Quality Lowered"; +"Story.ToastReactionSent" = "Message Sent."; +"Story.ToastRemovedFromChannelText" = "Story removed from the channel page"; +"Story.ToastRemovedFromGroupText" = "Story removed from the group page"; +"Story.ToastRemovedFromProfileText" = "Story removed from your profile"; +"Story.ToastSavedToChannelText" = "Posted stories can be viewed by others on the channel page until an admin removes them."; +"Story.ToastSavedToChannelTitle" = "Story posted to the channel page"; +"Story.ToastSavedToGroupText" = "Posted stories can be viewed by others on the group page until an admin removes them."; +"Story.ToastSavedToGroupTitle" = "Story saved to the group page"; +"Story.ToastSavedToProfileText" = "Posted stories can be viewed by others on your profile until you remove them."; +"Story.ToastSavedToProfileTitle" = "Story posted to your profile"; +"Story.ToastShowStoriesTo" = "**%@** will be able to see your stories again."; +"Story.ToastStealthModeActivatedText" = "The creators of stories you viewed in the past **%1$@** or will view in the next **%2$@** won't see you in the list of viewers."; +"Story.ToastStealthModeActivatedTitle" = "Stealth Mode On"; +"Story.ToastStealthModeActiveText" = "The creators of stories you view in the next **%@** won't see you in their list of viewers."; +"Story.ToastStealthModeActiveTitle" = "You are in Stealth Mode now"; +"Story.ToastUserBlocked" = "**%@** has been blocked."; +"Story.ToastViewInChat" = "View in Chat"; +"Story.TooltipExpired" = "This story is no longer available"; +"Story.TooltipMessageScheduled" = "Message Scheduled"; +"Story.TooltipMessageSent" = "Message Sent."; +"Story.TooltipPrivacyCloseFriends" = "You are seeing this story because %@ added you to their list of Close Friends."; +"Story.TooltipPrivacyCloseFriends2" = "You are seeing this story because **%@** added you to their list of Close Friends."; +"Story.TooltipPrivacyContacts" = "Only %@'s contacts can view this story."; +"Story.TooltipPrivacySelectedContacts" = "Only some users %@ selected can view this story."; +"Story.TooltipSaved" = "Saved"; +"Story.TooltipSaving" = "Saving"; +"Story.TooltipUnmuteVideoSound" = "Tap here to enable sound"; +"Story.TooltipVideoHasNoSound" = "This video has no sound"; +"Story.UnsupportedAction" = "Update Telegram"; +"Story.UnsupportedText" = "This story is not supported by\nyour version of Telegram."; +"Story.UpgradeQuality.Action" = "Increase Quality"; +"Story.UpgradeQuality.ActionSubtitle" = "Premium Required"; +"Story.UpgradeQuality.Text" = "Subscribe to Premium to view stories in higher resolution."; +"Story.UpgradeQuality.Title" = "High-Quality Stories"; +"Story.ViewGift" = "View Gift"; +"Story.ViewLink" = "Open Link"; +"Story.ViewList.ContextSortChannelInfo" = "Choose the order for the list of reactions."; +"Story.ViewList.ContextSortInfo" = "Choose the order for the list of viewers."; +"Story.ViewList.ContextSortReactions" = "Reactions First"; +"Story.ViewList.ContextSortRecent" = "Newest First"; +"Story.ViewList.ContextSortReposts" = "Reposts First"; +"Story.ViewList.EmptyTextContacts" = "None of your contacts viewed this story."; +"Story.ViewList.EmptyTextSearch" = "No views found"; +"Story.ViewList.NotFullyRecorded" = "Information about the other viewers wasn't recorded."; +"Story.ViewList.PremiumUpgradeAction" = "Learn More"; +"Story.ViewList.PremiumUpgradeInlineText" = "To unlock viewer lists for expired and posted stories, subscribe to [Telegram Premium]()."; +"Story.ViewList.PremiumUpgradeText" = "The list of viewers disappears 24 hours after a story expires.\n\nTo unlock viewer lists for expired and posted stories, subscribe to [Telegram Premium]()."; +"Story.ViewList.TabTitleAll" = "All Viewers"; +"Story.ViewList.TabTitleContacts" = "Contacts"; +"Story.ViewList.TitleEmpty" = "No Views"; +"Story.ViewList.TitleReactions" = "Reactions"; +"Story.ViewList.TitleViewers" = "Viewers"; +"Story.ViewList.ViewerCount#one" = "1 Viewer"; +"Story.ViewList.ViewerCount#other" = "%d Viewers"; +"Story.ViewLocation" = "View Location"; +"Story.ViewMessage" = "View Message"; +"Story.Views.Commented" = " • commented"; +"Story.Views.NoReactions" = "Nobody has reacted to\nyour story yet."; +"Story.Views.NoViews" = "Nobody has viewed\nyour story yet."; +"Story.Views.ViewsExpired" = "List of viewers becomes unavailable **24 hours** after the story expires."; +"Story.Views.ViewsNotRecorded" = "Information about viewers wasn't recorded."; +"StoryFeed.AddStory" = "Add Story"; +"StoryFeed.ContextAddStory" = "Add Story"; +"StoryFeed.ContextArchive" = "Hide Stories"; +"StoryFeed.ContextArchivedStories" = "Archived Stories"; +"StoryFeed.ContextNotifyOff" = "Do Not Notify About Stories"; +"StoryFeed.ContextNotifyOn" = "Notify About Stories"; +"StoryFeed.ContextOpenChat" = "Send Message"; +"StoryFeed.ContextOpenProfile" = "Open Profile"; +"StoryFeed.ContextSavedStories" = "Posted Stories"; +"StoryFeed.ContextUnarchive" = "Unhide Stories"; +"StoryFeed.MyStory" = "My Story"; +"StoryFeed.MyUploading" = "Uploading..."; +"StoryFeed.TooltipArchive" = "Stories from **%@** were moved to the Archive."; +"StoryFeed.TooltipNotifyOff" = "You will no longer receive a notification when **%@** posts a story."; +"StoryFeed.TooltipNotifyOn" = "You will receive a notification whenever **%@** posts a story."; +"StoryFeed.TooltipPostingDuringCall" = "You can't post stories during a call."; +"StoryFeed.TooltipPostingDuringGroupCall" = "You can't post stories during a video chat."; +"StoryFeed.TooltipPremiumPosting" = "Posting stories is currently available only\nto subscribers of [Telegram Premium]()."; +"StoryFeed.TooltipPremiumPostingLimited" = "Posting stories is currently only available to subscribers of [Telegram Premium]()."; +"StoryFeed.TooltipStoryLimit" = "You can't post more than **%@** in **24 hours**."; +"StoryFeed.TooltipStoryLimitValue#one" = "1 story"; +"StoryFeed.TooltipStoryLimitValue#other" = "%d stories"; +"StoryFeed.TooltipUnarchive" = "Stories from **%@** were returned from the Archive."; +"StoryFeed.ViewAnonymously" = "View Anonymously"; +"StoryGridScreen.TitleLocationSearch" = "Location"; +"StoryList.ActionPanel.Archive" = "Archive"; +"StoryList.ActionPanel.Delete" = "Delete"; +"StoryList.ActionPanel.Pin" = "Pin"; +"StoryList.ActionPanel.Unarchive" = "Unarchive"; +"StoryList.ActionPanel.Unpin" = "Unpin"; +"StoryList.ArchiveDescription" = "Only you can see archived stories unless you choose to post them to your profile."; +"StoryList.ArchivedEmptyState.Text" = "Upload a new story to view it here"; +"StoryList.ArchivedEmptyState.Title" = "No Archived Stories"; +"StoryList.ContextSaveToGallery" = "Save to Photos"; +"StoryList.ContextShowArchive" = "Show Archive"; +"StoryList.DeleteConfirmation.Action" = "Delete"; +"StoryList.DeleteConfirmation.Title#one" = "Delete 1 story?"; +"StoryList.DeleteConfirmation.Title#other" = "Delete %d stories?"; +"StoryList.GridHeaderLocationSearch#one" = "1 STORY FROM THIS LOCATION"; +"StoryList.GridHeaderLocationSearch#other" = "%d STORIES FROM THIS LOCATION"; +"StoryList.ItemAction.Archive" = "Archive"; +"StoryList.ItemAction.Delete" = "Delete"; +"StoryList.ItemAction.Edit" = "Edit"; +"StoryList.ItemAction.Forward" = "Forward"; +"StoryList.ItemAction.Pin" = "Pin"; +"StoryList.ItemAction.Unarchive" = "Unarchive"; +"StoryList.ItemAction.Unpin" = "Unpin"; +"StoryList.SaveToProfile" = "Post to Profile"; +"StoryList.SavedAddAction" = "Add a Post"; +"StoryList.SavedEmptyAction" = "Open Archive"; +"StoryList.SavedEmptyPosts.Text" = "Publish photos and videos to display on your profile page."; +"StoryList.SavedEmptyPosts.Title" = "No posts yet..."; +"StoryList.SavedEmptyState.Text" = "Open the Archive to select stories you\nwant to be displayed on your profile."; +"StoryList.SavedEmptyState.Title" = "No posted stories"; +"StoryList.SubtitleArchived#one" = "1 archived post"; +"StoryList.SubtitleArchived#other" = "%d archived posts"; +"StoryList.SubtitleCount#one" = "1 story"; +"StoryList.SubtitleCount#other" = "%d stories"; +"StoryList.SubtitleSaved#one" = "1 posted story"; +"StoryList.SubtitleSaved#other" = "%d posted stories"; +"StoryList.SubtitleSelected#one" = "1 story selected"; +"StoryList.SubtitleSelected#other" = "%d stories selected"; +"StoryList.TitleArchive" = "Story Archive"; +"StoryList.TitleSaved" = "My Stories"; +"StoryList.ToastArchived.Text#one" = "Story archived."; +"StoryList.ToastArchived.Text#other" = "%d stories archived."; +"StoryList.ToastPinLimit.Text#one" = "You can't pin more than 1 post."; +"StoryList.ToastPinLimit.Text#other" = "You can't pin more than %d posts."; +"StoryList.ToastPinned.Text#one" = "Now it will always be shown on the top."; +"StoryList.ToastPinned.Text#other" = "Now they will always be shown on the top."; +"StoryList.ToastPinned.Title#one" = "Story pinned"; +"StoryList.ToastPinned.Title#other" = "%d stories pinned"; +"StoryList.ToastUnarchived.Text#one" = "Story unarchived."; +"StoryList.ToastUnarchived.Text#other" = "%d stories unarchived."; +"StoryList.ToastUnpinned.Text#one" = "Story unpinned."; +"StoryList.ToastUnpinned.Text#other" = "%d stories unpinned."; +"StoryList.TooltipStoriesDeleted#one" = "1 story deleted."; +"StoryList.TooltipStoriesDeleted#other" = "%d stories deleted."; +"StoryList.TooltipStoriesSavedToChannel#one" = "Story saved to channel's profile."; +"StoryList.TooltipStoriesSavedToChannel#other" = "%d stories saved to channel's profile."; +"StoryList.TooltipStoriesSavedToGroup#one" = "Story saved to group's profile."; +"StoryList.TooltipStoriesSavedToGroup#other" = "%d stories saved to group's profile."; +"StoryList.TooltipStoriesSavedToProfile#one" = "Story posted on your profile."; +"StoryList.TooltipStoriesSavedToProfile#other" = "%d stories posted on your profile."; +"StoryList.TooltipStoriesSavedToProfileText" = "Posted stories can be viewed by others on your profile until you remove them."; +"SuggestBirthdate.Accept.Action" = "Add to My Profile"; +"SuggestBirthdate.Accept.Added" = "Birthday added. You can change it in **Settings**."; +"SuggestBirthdate.Accept.HideYear" = "Hide the Year"; +"SuggestBirthdate.Accept.Title" = "Date of Birth"; +"SuggestBirthdate.Suggest.Action" = "Suggest Date of Birth"; +"SuggestBirthdate.Suggest.Title" = "%@'s Birthday"; +"SuggestPost.SetTime.Label" = "Time"; +"SuggestPost.SetTimeFormat.Any" = "Anytime"; +"SuggestPost.SetTimeFormat.Date" = "%@"; +"SuggestPost.SetTimeFormat.TodayAt" = "Today at %@"; +"SuggestPost.SetTimeFormat.TomorrowAt" = "Tomorrow at %@"; +"SuggestPost.Time.ProposeOn" = "Post on %@ at %@"; +"SuggestPost.Time.ProposeToday" = "Post today at %@"; +"SuggestPost.Time.ProposeTomorrow" = "Post tomorrow at %@"; +"SuggestPost.Time.SendOn" = "Post on %@ at %@"; +"SuggestPost.Time.SendToday" = "Post today at %@"; +"SuggestPost.Time.SendTomorrow" = "Post tomorrow at %@"; +"Target.InviteToGroupConfirmation" = "Add the bot to \"%@\"?"; +"Target.InviteToGroupErrorAlreadyInvited" = "The bot is already a member of this group."; +"Target.SelectGroup" = "Choose a Group"; +"Target.ShareGameConfirmationGroup" = "Share the game with \"%@\"?"; +"Target.ShareGameConfirmationPrivate" = "Share the game with %@?"; +"TextFormat.AddLinkPlaceholder" = "URL"; +"TextFormat.AddLinkText" = "The link will be displayed as \"%@\"."; +"TextFormat.AddLinkTitle" = "Add Link"; +"TextFormat.Bold" = "Bold"; +"TextFormat.Code" = "Code"; +"TextFormat.Date" = "Date"; +"TextFormat.EditLinkTitle" = "Edit Link"; +"TextFormat.Format" = "Format"; +"TextFormat.Italic" = "Italic"; +"TextFormat.Link" = "Link"; +"TextFormat.Monospace" = "Monospace"; +"TextFormat.Quote" = "Quote"; +"TextFormat.Spoiler" = "Spoiler"; +"TextFormat.Strikethrough" = "Strikethrough"; +"TextFormat.Underline" = "Underline"; +"TextProcessing.ActionApply" = "Apply"; +"TextProcessing.ActionClose" = "Close"; +"TextProcessing.ActionTitleNonPremium" = "Increase Limit"; +"TextProcessing.ActionValueNonPremium" = "X50"; +"TextProcessing.AlertCreatorDeleteStyle.Text" = "Are you sure you want to delete this style? It will be removed for everyone who installed it."; +"TextProcessing.AlertCreatorDeleteStyle.Title" = "Delete Style"; +"TextProcessing.AlertDeleteStyle.Text" = "Are you sure you want to remove this style?"; +"TextProcessing.AlertDeleteStyle.Title" = "Delete Style"; +"TextProcessing.AlertTooManyStyles.Text" = "Please delete some of your saved styles to create a new one."; +"TextProcessing.AlertTooManyStyles.Title" = "Too Many Styles"; +"TextProcessing.EditStyle.ActionCreate" = "Create"; +"TextProcessing.EditStyle.ActionEdit" = "Save"; +"TextProcessing.EditStyle.AddLink" = "Add a link to my account"; +"TextProcessing.EditStyle.Delete" = "Delete Style"; +"TextProcessing.EditStyle.NamePlaceholder" = "Style Name (for example, \"Pirate\")"; +"TextProcessing.EditStyle.TextPlaceholder" = "Instructions (for example, \"Write like a swashbuckling pirate. Use arr, ye, matey, and talk about treasure and the sea\")"; +"TextProcessing.EditStyle.TitleCreate" = "New Style"; +"TextProcessing.EditStyle.TitleEdit" = "Edit Style"; +"TextProcessing.Emojify" = "Emojify"; +"TextProcessing.LimitToast.Text" = "Get **Telegram Premium** for **50x** more text edits per day."; +"TextProcessing.LimitToast.Title" = "Daily limit reached"; +"TextProcessing.OriginalBadge" = "Original:"; +"TextProcessing.OriginalStyleBadge" = "Original"; +"TextProcessing.ResultBadge" = "Result"; +"TextProcessing.StyleFooterAuthor" = "Style by [%@]()"; +"TextProcessing.StyleFooterCreatedBy" = "Created by [%@]()"; +"TextProcessing.StyleFooterCreatedByFormat" = "%1$@. %2$@"; +"TextProcessing.StyleFooterCreatedBySimpleFormat" = "%@."; +"TextProcessing.StyleFooterUserCount#one" = "Used by 1 person"; +"TextProcessing.StyleFooterUserCount#other" = "Used by %d people"; +"TextProcessing.StyleList.Add" = "Create"; +"TextProcessing.StyleMenu.ButtonAdd" = "Add Style"; +"TextProcessing.StyleMenu.ButtonClose" = "Close"; +"TextProcessing.StyleMenu.Delete" = "Delete Style"; +"TextProcessing.StyleMenu.Edit" = "Edit Style"; +"TextProcessing.StyleMenu.Share" = "Share Style"; +"TextProcessing.StylePreview.After" = "After"; +"TextProcessing.StylePreview.Before" = "Before"; +"TextProcessing.StylePreview.ExampleHeader" = "EXAMPLE"; +"TextProcessing.StylePreview.ExampleHeaderRefresh" = "ANOTHER EXAMPLE"; +"TextProcessing.StylePreview.Subtitle" = "Add this style to instantly\nrewrite your messages."; +"TextProcessing.StyleTooltip" = "Select Style"; +"TextProcessing.TabFix" = "Fix"; +"TextProcessing.TabStylize" = "Style"; +"TextProcessing.TabTranslate" = "Translate"; +"TextProcessing.TextCollapse" = "less"; +"TextProcessing.TextExpand" = "more"; +"TextProcessing.TitleEdit" = "AI Editor"; +"TextProcessing.TitleTranslate" = "Translation"; +"TextProcessing.ToastStyleAdded.Text" = "Tap 'AI' → '%@' when typing your next long message."; +"TextProcessing.ToastStyleAdded.Title" = "Style Added"; +"TextProcessing.ToastStyleCreated.Text" = "Press and hold a style to edit or share it."; +"TextProcessing.ToastStyleCreated.Title" = "%@ style created!"; +"TextProcessing.Translate.FromLanguage" = "From {}"; +"TextProcessing.Translate.LanguageStyle" = "%1$@ (%2$@)"; +"TextProcessing.Translate.ToLanguage" = "To {}"; +"TextProcessingStyle.Neutral" = "Neutral"; +"TextSelection.SelectAll" = "Select All"; +"Theme.Colors.Accent" = "Accent"; +"Theme.Colors.Background" = "Background"; +"Theme.Colors.ColorWallpaperWarning" = "Are you sure you want to change your chat wallpaper to a color?"; +"Theme.Colors.ColorWallpaperWarningProceed" = "Proceed"; +"Theme.Colors.Messages" = "Messages"; +"Theme.Colors.Proceed" = "Proceed"; +"Theme.Context.Apply" = "Apply"; +"Theme.Context.ChangeColors" = "Change Colors"; +"Theme.ErrorNotFound" = "Sorry, this color theme doesn't seem to exist."; +"Theme.ThemeChanged" = "Color Theme Changed"; +"Theme.ThemeChangedText" = "You can change it back in\n[Settings > Appearance]()."; +"Theme.Unsupported" = "Sorry, this color theme doesn't support your device yet."; +"Theme.UsersCount#one" = "%@ person is using this theme"; +"Theme.UsersCount#other" = "%@ people are using this theme"; +"Themes.BuildOwn" = "Build Your Own Theme"; +"Themes.CreateNewTheme" = "Create a New Theme"; +"Themes.EditCurrentTheme" = "Edit Current Theme"; +"Themes.SelectTheme" = "Select a Theme"; +"Themes.Title" = "Chat Themes"; +"Time.AfterDays#one" = "%@ day"; +"Time.AfterDays#other" = "%@ days"; +"Time.AfterHours#one" = "%@ hour"; +"Time.AfterHours#other" = "%@ hours"; +"Time.AfterMinutes#one" = "%@ minute"; +"Time.AfterMinutes#other" = "%@ minutes"; +"Time.AfterMonths#one" = "%@ month"; +"Time.AfterMonths#other" = "%@ months"; +"Time.AfterSeconds#one" = "%@ second"; +"Time.AfterSeconds#other" = "%@ seconds"; +"Time.AfterWeeks#one" = "%@ week"; +"Time.AfterWeeks#other" = "%@ weeks"; +"Time.AfterYears#one" = "%@ year"; +"Time.AfterYears#other" = "%@ years"; +"Time.AtDate" = "%@"; +"Time.AtPreciseDate" = "%@ at %@"; +"Time.HoursAgo#one" = "%@ hour ago"; +"Time.HoursAgo#other" = "%@ hours ago"; +"Time.JustNow" = "just now"; +"Time.MediumDate" = "%1$@ at %2$@"; +"Time.MinutesAgo#one" = "%@ minute ago"; +"Time.MinutesAgo#other" = "%@ minutes ago"; +"Time.MonthOfYear_m1" = "January %@"; +"Time.MonthOfYear_m10" = "October %@"; +"Time.MonthOfYear_m11" = "November %@"; +"Time.MonthOfYear_m12" = "December %@"; +"Time.MonthOfYear_m2" = "February %@"; +"Time.MonthOfYear_m3" = "March %@"; +"Time.MonthOfYear_m4" = "April %@"; +"Time.MonthOfYear_m5" = "May %@"; +"Time.MonthOfYear_m6" = "June %@"; +"Time.MonthOfYear_m7" = "July %@"; +"Time.MonthOfYear_m8" = "August %@"; +"Time.MonthOfYear_m9" = "September %@"; +"Time.PreciseDate_m1" = "Jan %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m10" = "Oct %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m11" = "Nov %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m12" = "Dec %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m2" = "Feb %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m3" = "Mar %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m4" = "Apr %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m5" = "May %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m6" = "Jun %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m7" = "Jul %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m8" = "Aug %1$@, %2$@ at %3$@"; +"Time.PreciseDate_m9" = "Sep %1$@, %2$@ at %3$@"; +"Time.TimerDays#one" = "%@-day"; +"Time.TimerDays#other" = "%@-day"; +"Time.TimerHours#one" = "%@-hour"; +"Time.TimerHours#other" = "%@-hour"; +"Time.TimerMinutes#one" = "%@-minute"; +"Time.TimerMinutes#other" = "%@-minute"; +"Time.TimerMonths#one" = "%@-month"; +"Time.TimerMonths#other" = "%@-month"; +"Time.TimerSeconds#one" = "%@-second"; +"Time.TimerSeconds#other" = "%@-second"; +"Time.TimerWeeks#one" = "%@-week"; +"Time.TimerWeeks#other" = "%@-week"; +"Time.TimerYears#one" = "%@-year"; +"Time.TimerYears#other" = "%@-year"; +"Time.TodayAt" = "today at %@"; +"Time.TomorrowAt" = "tomorrow at %@"; +"Time.YesterdayAt" = "yesterday at %@"; +"TimeZoneSelection.Title" = "Time Zone"; +"Ton.AvailableBalance" = "Balance Available to Withdraw"; +"Ton.LifetimeProceeds" = "Total Lifetime Proceeds"; +"Ton.ProceedsOverview" = "PROCEEDS OVERVIEW"; +"Ton.WithdrawViaFragment" = "Withdraw via Fragment"; +"Ton.WithdrawViaFragment.Info" = "Collect your Grams using Fragment. [Learn More >]()"; +"Ton.WithdrawViaFragment.Info_URL" = "https://telegram.org/tos/bot-developers#6-2-2-tpa-balance"; +"TopApps.Info.Done" = "Understood"; +"TopApps.Info.Text" = "This catalogue ranks mini apps based on their daily revenue, measured in Stars. To be listed, developers must set their main mini app in [@botfather]() (as described [here](https://core.telegram.org/bots/webapps#launching-the-main-mini-app)), have over **1,000** daily users, and earn a daily revenue above **1,000** Stars, based on weekly average."; +"TopApps.Info.Title" = "Top Mini Apps"; +"Tour.StartButton" = "Start Messaging"; +"Tour.Text1" = "The world's **fastest** messaging app.\nIt is **free** and **secure**."; +"Tour.Text2" = "**Telegram** delivers messages\nfaster than any other application."; +"Tour.Text3" = "**Telegram** has no limits on\nthe size of your media and chats."; +"Tour.Text4" = "**Telegram** keeps your messages\nsafe from hacker attacks."; +"Tour.Text5" = "**Telegram** lets you access your\nmessages from multiple devices."; +"Tour.Text6" = "**Telegram** provides free unlimited\ncloud storage for chats and media."; +"Tour.Title1" = "Telegram"; +"Tour.Title2" = "Fast"; +"Tour.Title3" = "Powerful"; +"Tour.Title4" = "Secure"; +"Tour.Title5" = "Cloud-Based"; +"Tour.Title6" = "Free"; +"Translate.ChangeLanguage" = "Change Language"; +"Translate.CopyTranslation" = "Copy Translation"; +"Translate.Languages.Original" = "Original"; +"Translate.Languages.Title" = "Languages"; +"Translate.Languages.Translation" = "Translation"; +"Translate.More" = "more"; +"Translate.Title" = "Translate"; +"Translation.Language.ar" = "Arabic"; +"Translation.Language.be" = "Belarusian"; +"Translation.Language.ca" = "Catalan"; +"Translation.Language.cs" = "Czech"; +"Translation.Language.de" = "German"; +"Translation.Language.en" = "English"; +"Translation.Language.eo" = "Esperanto"; +"Translation.Language.es" = "Spanish"; +"Translation.Language.fa" = "Persian"; +"Translation.Language.fi" = "Finnish"; +"Translation.Language.fr" = "French"; +"Translation.Language.he" = "Hebrew"; +"Translation.Language.hr" = "Croatian"; +"Translation.Language.hu" = "Hungarian"; +"Translation.Language.id" = "Indonesian"; +"Translation.Language.it" = "Italian"; +"Translation.Language.ja" = "Japanese"; +"Translation.Language.ko" = "Korean"; +"Translation.Language.kz" = "Kazakh"; +"Translation.Language.ml" = "Malayalam"; +"Translation.Language.ms" = "Malay"; +"Translation.Language.nl" = "Dutch"; +"Translation.Language.no" = "Norwegian"; +"Translation.Language.po" = "Polish"; +"Translation.Language.pt" = "Portuguese"; +"Translation.Language.ro" = "Romanian"; +"Translation.Language.ru" = "Russian"; +"Translation.Language.sk" = "Slovak"; +"Translation.Language.sr" = "Serbian"; +"Translation.Language.sv" = "Swedish"; +"Translation.Language.ta" = "Tamil"; +"Translation.Language.tr" = "Turkish"; +"Translation.Language.uk" = "Ukrainian"; +"Translation.Language.uz" = "Uzbek"; +"Translation.Language.zh" = "Chinese"; +"Translation.LanguageFrom.AR" = "Arabic"; +"Translation.LanguageFrom.BE" = "Belarusian"; +"Translation.LanguageFrom.CA" = "Catalan"; +"Translation.LanguageFrom.DE" = "German"; +"Translation.LanguageFrom.EN" = "English"; +"Translation.LanguageFrom.EO" = "Esperanto"; +"Translation.LanguageFrom.ES" = "Spanish"; +"Translation.LanguageFrom.FA" = "Persian"; +"Translation.LanguageFrom.FI" = "Finnish"; +"Translation.LanguageFrom.FR" = "French"; +"Translation.LanguageFrom.HE" = "Hebrew"; +"Translation.LanguageFrom.HR" = "Croatian"; +"Translation.LanguageFrom.HU" = "Hungarian"; +"Translation.LanguageFrom.ID" = "Indonesian"; +"Translation.LanguageFrom.IT" = "Italian"; +"Translation.LanguageFrom.JA" = "Japanese"; +"Translation.LanguageFrom.KO" = "Korean"; +"Translation.LanguageFrom.KZ" = "Kazakh"; +"Translation.LanguageFrom.ML" = "Malayalam"; +"Translation.LanguageFrom.MS" = "Malay"; +"Translation.LanguageFrom.NL" = "Dutch"; +"Translation.LanguageFrom.NO" = "Norwegian"; +"Translation.LanguageFrom.PO" = "Polish"; +"Translation.LanguageFrom.PT" = "Portuguese"; +"Translation.LanguageFrom.RO" = "Romanian"; +"Translation.LanguageFrom.RU" = "Russian"; +"Translation.LanguageFrom.SK" = "Slovak"; +"Translation.LanguageFrom.SR" = "Serbian"; +"Translation.LanguageFrom.SV" = "Swedish"; +"Translation.LanguageFrom.TA" = "Tamil"; +"Translation.LanguageFrom.TR" = "Turkish"; +"Translation.LanguageFrom.UK" = "Ukrainian"; +"Translation.LanguageFrom.UZ" = "Uzbek"; +"Translation.LanguageFrom.ZH" = "Chinese"; +"Translation.LanguageTo.AR" = "Arabic"; +"Translation.LanguageTo.BE" = "Belarusian"; +"Translation.LanguageTo.CA" = "Catalan"; +"Translation.LanguageTo.DE" = "German"; +"Translation.LanguageTo.EN" = "English"; +"Translation.LanguageTo.EO" = "Esperanto"; +"Translation.LanguageTo.ES" = "Spanish"; +"Translation.LanguageTo.FA" = "Persian"; +"Translation.LanguageTo.FI" = "Finnish"; +"Translation.LanguageTo.FR" = "French"; +"Translation.LanguageTo.HE" = "Hebrew"; +"Translation.LanguageTo.HR" = "Croatian"; +"Translation.LanguageTo.HU" = "Hungarian"; +"Translation.LanguageTo.ID" = "Indonesian"; +"Translation.LanguageTo.IT" = "Italian"; +"Translation.LanguageTo.JA" = "Japanese"; +"Translation.LanguageTo.KO" = "Korean"; +"Translation.LanguageTo.KZ" = "Kazakh"; +"Translation.LanguageTo.ML" = "Malayalam"; +"Translation.LanguageTo.MS" = "Malay"; +"Translation.LanguageTo.NL" = "Dutch"; +"Translation.LanguageTo.NO" = "Norwegian"; +"Translation.LanguageTo.PO" = "Polish"; +"Translation.LanguageTo.PT" = "Portuguese"; +"Translation.LanguageTo.RO" = "Romanian"; +"Translation.LanguageTo.RU" = "Russian"; +"Translation.LanguageTo.SK" = "Slovak"; +"Translation.LanguageTo.SR" = "Serbian"; +"Translation.LanguageTo.SV" = "Swedish"; +"Translation.LanguageTo.TA" = "Tamil"; +"Translation.LanguageTo.TR" = "Turkish"; +"Translation.LanguageTo.UK" = "Ukrainian"; +"Translation.LanguageTo.UZ" = "Uzbek"; +"Translation.LanguageTo.ZH" = "Chinese"; +"TwoFactorRemember.CheckPassword" = "Check Password"; +"TwoFactorRemember.Done.Action" = "Back to Settings"; +"TwoFactorRemember.Done.Text" = "You still remember your password."; +"TwoFactorRemember.Done.Title" = "Perfect!"; +"TwoFactorRemember.Forgot" = "Forgot Password?"; +"TwoFactorRemember.Placeholder" = "Password"; +"TwoFactorRemember.Text" = "Do you still remember your password?"; +"TwoFactorRemember.Title" = "Enter Your Password"; +"TwoFactorRemember.WrongPassword" = "This password is incorrect."; +"TwoFactorSetup.Done.Action" = "Return to Settings"; +"TwoFactorSetup.Done.Text" = "This password will be required when you log in on a new device in addition to the code you get via SMS."; +"TwoFactorSetup.Done.Title" = "Password Set!"; +"TwoFactorSetup.Email.Action" = "Continue"; +"TwoFactorSetup.Email.Placeholder" = "Your email address"; +"TwoFactorSetup.Email.SkipAction" = "Skip setting email"; +"TwoFactorSetup.Email.SkipConfirmationSkip" = "Skip"; +"TwoFactorSetup.Email.SkipConfirmationText" = "If you forget your password, you will lose access to your Telegram account. There will be no way to restore it."; +"TwoFactorSetup.Email.SkipConfirmationTitle" = "No, seriously."; +"TwoFactorSetup.Email.Text" = "You can set a recovery email to be able to reset your password and restore access to your Telegram account."; +"TwoFactorSetup.Email.Title" = "Recovery Email"; +"TwoFactorSetup.EmailVerification.Action" = "Continue"; +"TwoFactorSetup.EmailVerification.ChangeAction" = "Change Email"; +"TwoFactorSetup.EmailVerification.Placeholder" = "Code"; +"TwoFactorSetup.EmailVerification.ResendAction" = "Re-send Code"; +"TwoFactorSetup.EmailVerification.Text" = "Please enter the code we just emailed to %@"; +"TwoFactorSetup.EmailVerification.Title" = "Recovery Email"; +"TwoFactorSetup.Hint.Action" = "Continue"; +"TwoFactorSetup.Hint.Placeholder" = "Hint (optional)"; +"TwoFactorSetup.Hint.SkipAction" = "Skip setting hint"; +"TwoFactorSetup.Hint.Text" = "You can create an optional hint for\nyour password."; +"TwoFactorSetup.Hint.Title" = "Hint"; +"TwoFactorSetup.Intro.Action" = "Set Additional Password"; +"TwoFactorSetup.Intro.Text" = "You can set a password that will be required when you log in on a new device in addition to the code you get via SMS."; +"TwoFactorSetup.Intro.Title" = "Additional Password"; +"TwoFactorSetup.Password.Action" = "Create Password"; +"TwoFactorSetup.Password.PlaceholderConfirmPassword" = "Re-enter Password"; +"TwoFactorSetup.Password.PlaceholderPassword" = "Password"; +"TwoFactorSetup.Password.Title" = "Create Password"; +"TwoFactorSetup.PasswordRecovery.Action" = "Continue"; +"TwoFactorSetup.PasswordRecovery.PlaceholderConfirmPassword" = "Re-enter New Password"; +"TwoFactorSetup.PasswordRecovery.PlaceholderPassword" = "New Password"; +"TwoFactorSetup.PasswordRecovery.Skip" = "Skip"; +"TwoFactorSetup.PasswordRecovery.SkipAlertAction" = "Skip"; +"TwoFactorSetup.PasswordRecovery.SkipAlertText" = "Skipping this step will disable 2-step verification for your account. Are you sure you want to skip?"; +"TwoFactorSetup.PasswordRecovery.SkipAlertTitle" = "Attention!"; +"TwoFactorSetup.PasswordRecovery.Text" = "You can now set a new password that will be used to log into your account."; +"TwoFactorSetup.PasswordRecovery.Title" = "Create New Password"; +"TwoFactorSetup.ResetDone.Action" = "Continue"; +"TwoFactorSetup.ResetDone.Text" = "This password will be required when you log in on a new device in addition to the code you get via SMS."; +"TwoFactorSetup.ResetDone.TextNoPassword" = "You can always set a new password in\n\n\nSettings > Privacy & Security > Two-Step Verification"; +"TwoFactorSetup.ResetDone.Title" = "New Password Set!"; +"TwoFactorSetup.ResetDone.TitleNoPassword" = "Password Removed"; +"TwoFactorSetup.ResetFloodWait" = "You recently requested a password reset that was canceled. Please wait %@ before making a new request."; +"TwoStepAuth.AddHintDescription" = "You can create an optional hint for your password."; +"TwoStepAuth.AddHintTitle" = "Add a Hint"; +"TwoStepAuth.AdditionalPassword" = "Additional Password"; +"TwoStepAuth.CancelResetText" = "Cancel the password reset process? If you request a new reset later, it will take another 7 days."; +"TwoStepAuth.CancelResetTitle" = "Cancel Reset"; +"TwoStepAuth.ChangeEmail" = "Change Recovery Email"; +"TwoStepAuth.ChangePassword" = "Change Password"; +"TwoStepAuth.ChangePasswordDescription" = "Please enter a new password which will be used to protect your data."; +"TwoStepAuth.ConfirmEmailCodePlaceholder" = "Code"; +"TwoStepAuth.ConfirmEmailDescription" = "Please enter the code we've just emailed at %1$@."; +"TwoStepAuth.ConfirmEmailResendCode" = "Resend Code"; +"TwoStepAuth.ConfirmationAbort" = "Abort Two-Step Verification Setup"; +"TwoStepAuth.ConfirmationText" = "Please check your email and click on the validation link to complete Two-Step Verification setup. Be sure to check the spam folder as well."; +"TwoStepAuth.ConfirmationTitle" = "Two-Step Verification"; +"TwoStepAuth.Disable" = "Disable"; +"TwoStepAuth.DisableSuccess" = "Two-Step verification\nis disabled."; +"TwoStepAuth.Email" = "Email"; +"TwoStepAuth.EmailAddSuccess" = "Recovery Email Added"; +"TwoStepAuth.EmailChangeSuccess" = "Your recovery email\nhas been changed."; +"TwoStepAuth.EmailCodeExpired" = "This confirmation code has expired. Please try again."; +"TwoStepAuth.EmailHelp" = "Please add your valid email. It is the only way to recover a forgotten password."; +"TwoStepAuth.EmailInvalid" = "Invalid email address, please try again."; +"TwoStepAuth.EmailPlaceholder" = "Your email"; +"TwoStepAuth.EmailSent" = "We have sent you an email to confirm your address."; +"TwoStepAuth.EmailSkip" = "Skip"; +"TwoStepAuth.EmailSkipAlert" = "No, seriously.\n\nIf you forget your password, you will lose access to your Telegram account. There will be no way to restore it."; +"TwoStepAuth.EmailTitle" = "Recovery Email"; +"TwoStepAuth.EnabledSuccess" = "Two-Step Verification Enabled"; +"TwoStepAuth.EnterEmailCode" = "Enter Code"; +"TwoStepAuth.EnterPasswordForgot" = "Forgot password?"; +"TwoStepAuth.EnterPasswordHelp" = "You have Two-Step Verification enabled, so your account is protected with an additional password."; +"TwoStepAuth.EnterPasswordHint" = "Hint: %@"; +"TwoStepAuth.EnterPasswordInvalid" = "Invalid password, please try again."; +"TwoStepAuth.EnterPasswordPassword" = "Password"; +"TwoStepAuth.EnterPasswordTitle" = "Password"; +"TwoStepAuth.FloodError" = "Too many attempts, please try again later."; +"TwoStepAuth.GenericHelp" = "You have enabled Two-Step verification.\nYou'll need the password you set up here to log in to your Telegram account."; +"TwoStepAuth.HintPlaceholder" = "Hint"; +"TwoStepAuth.PasswordChangeSuccess" = "Your password\nhas been changed."; +"TwoStepAuth.PasswordRemoveConfirmation" = "Are you sure you want to disable your password?"; +"TwoStepAuth.PasswordRemovePassportConfirmation" = "Are you sure you want to disable your password?\n\nAll data saved in your Telegram Passport will be lost."; +"TwoStepAuth.PasswordSet" = "Your password for Two-Step Verification is now active."; +"TwoStepAuth.PendingEmailHelp" = "Your recovery email %@ is not yet active and pending confirmation."; +"TwoStepAuth.ReEnterPasswordDescription" = "Please confirm your password."; +"TwoStepAuth.ReEnterPasswordTitle" = "Re-enter your Password"; +"TwoStepAuth.RecoveryCode" = "Code"; +"TwoStepAuth.RecoveryCodeExpired" = "We've sent you a new 6-digit code."; +"TwoStepAuth.RecoveryCodeHelp" = "Check your email and enter the 6-digit code we sent there to deactivate your cloud password."; +"TwoStepAuth.RecoveryCodeInvalid" = "Invalid code, please try again."; +"TwoStepAuth.RecoveryEmailAddDescription" = "Please add your valid email. It is the only way to recover a forgotten password."; +"TwoStepAuth.RecoveryEmailChangeDescription" = "Please enter your new recovery email. It is the only way to recover a forgotten password."; +"TwoStepAuth.RecoveryEmailResetNoAccess" = "Can't access your email?"; +"TwoStepAuth.RecoveryEmailResetText" = "If you don't have access to your recovery email, your remaining options are either to remember your password or wait 7 days until your password resets."; +"TwoStepAuth.RecoveryEmailTitle" = "Recovery Email"; +"TwoStepAuth.RecoveryEmailUnavailable" = "Unable to access %@?"; +"TwoStepAuth.RecoveryFailed" = "Your remaining options are either to remember your password or to reset your account."; +"TwoStepAuth.RecoveryTitle" = "Email Code"; +"TwoStepAuth.RecoveryUnavailable" = "Since you didn't provide a recovery email when setting up your password, your remaining options are either to remember your password or to reset your account."; +"TwoStepAuth.RecoveryUnavailableResetAction" = "Reset"; +"TwoStepAuth.RecoveryUnavailableResetText" = "Since you didn’t provide a recovery email when setting up your password, your remaining options are either to remember your password or wait 7 days until your password is reset."; +"TwoStepAuth.RecoveryUnavailableResetTitle" = "Reset Password"; +"TwoStepAuth.RemovePassword" = "Disable Password"; +"TwoStepAuth.ResetAccountConfirmation" = "You will lose all your Telegram chats, messages, media and files if you proceed with resetting your account."; +"TwoStepAuth.ResetAccountHelp" = "You will lose all your Telegram chats, messages, media and files if you proceed with resetting your account."; +"TwoStepAuth.ResetAction" = "Reset Password"; +"TwoStepAuth.ResetPendingText" = "You can reset your password in %@."; +"TwoStepAuth.SetPassword" = "Set Additional Password"; +"TwoStepAuth.SetPasswordHelp" = "You can set a password that will be required when you log in on a new device in addition to the code you get in the SMS."; +"TwoStepAuth.SetupEmail" = "Set Recovery Email"; +"TwoStepAuth.SetupHint" = "Please create a hint for your password:"; +"TwoStepAuth.SetupHintTitle" = "Password Hint"; +"TwoStepAuth.SetupPasswordConfirmFailed" = "Passwords don't match. Please try again."; +"TwoStepAuth.SetupPasswordConfirmPassword" = "Please re-enter your password:"; +"TwoStepAuth.SetupPasswordDescription" = "Please create a password which will be used to protect your data."; +"TwoStepAuth.SetupPasswordEnterPasswordChange" = "Please enter your new password:"; +"TwoStepAuth.SetupPasswordEnterPasswordNew" = "Enter a password:"; +"TwoStepAuth.SetupPasswordTitle" = "Your Password"; +"TwoStepAuth.SetupPendingEmail" = "Your recovery email %@ needs to be confirmed and is not yet active.\n\nPlease check your email and enter the confirmation code to complete Two-Step Verification setup. Be sure to check the spam folder as well."; +"TwoStepAuth.SetupResendEmailCode" = "Resend Code"; +"TwoStepAuth.SetupResendEmailCodeAlert" = "The code has been sent. Please check your email. Be sure to check the spam folder as well."; +"TwoStepAuth.Title" = "Two-Step Verification"; +"Undo.ChatCleared" = "Chat cleared."; +"Undo.ChatClearedForBothSides" = "Chat cleared for both sides."; +"Undo.ChatClearedForEveryone" = "Chat cleared for everyone."; +"Undo.ChatDeleted" = "Chat deleted."; +"Undo.ChatDeletedForBothSides" = "Chat deleted for both sides."; +"Undo.DeletedChannel" = "Channel deleted."; +"Undo.DeletedGroup" = "Group deleted."; +"Undo.DeletedMonoforum" = "Removed from your chat list."; +"Undo.DeletedTopic" = "Topic Deleted"; +"Undo.LeftChannel" = "You left the channel."; +"Undo.LeftGroup" = "You left the group."; +"Undo.MessagesDeleted" = "Messages deleted."; +"Undo.ScheduledMessagesCleared" = "Scheduled messages cleared"; +"Undo.SecretChatDeleted" = "Secret Chat deleted."; +"Undo.Undo" = "Undo"; +"Update.AppVersion" = "Telegram %@"; +"Update.Skip" = "Skip"; +"Update.Title" = "Telegram Update"; +"Update.UpdateApp" = "Update Telegram"; +"Updated.AtDate" = "%@"; +"Updated.HoursAgo#one" = "1 hour ago"; +"Updated.HoursAgo#other" = "%@ hours ago"; +"Updated.JustNow" = "just now"; +"Updated.MinutesAgo#one" = "1 minute ago"; +"Updated.MinutesAgo#other" = "%@ minutes ago"; +"Updated.TodayAt" = "today at %@"; +"Updated.YesterdayAt" = "yesterday at %@"; +"User.DeletedAccount" = "Deleted Account"; +"UserCount#one" = "1 user"; +"UserCount#other" = "%@ users"; +"UserInfo.About.Placeholder" = "Bio"; +"UserInfo.AddContact" = "Add to Contacts"; +"UserInfo.AddPhone" = "add phone"; +"UserInfo.AddToExisting" = "Add to Existing"; +"UserInfo.Age#one" = "%@ year old"; +"UserInfo.Age#other" = "%@ years old"; +"UserInfo.AnonymousNumberInfo" = "This number is not tied to a SIM card and was acquired on [Fragment]()."; +"UserInfo.AnonymousNumberLabel" = "anonymous number"; +"UserInfo.Birthday" = "birthday"; +"UserInfo.BirthdayToday" = "birthday today"; +"UserInfo.BlockActionTitle" = "Block %@"; +"UserInfo.BlockConfirmation" = "Block %@?"; +"UserInfo.BlockConfirmationTitle" = "Do you want to block %@ from messaging and calling you on Telegram?"; +"UserInfo.BotHelp" = "Help"; +"UserInfo.BotNamePlaceholder" = "Bot Name"; +"UserInfo.BotPrivacy" = "Privacy Policy"; +"UserInfo.BotSettings" = "Settings"; +"UserInfo.ChangeColors" = "Change Colors"; +"UserInfo.ChangeCustomPhoto" = "Change Photo for %@"; +"UserInfo.ChangeWallpaper" = "Change Wallpaper"; +"UserInfo.ContactForwardTooltip.Chat.One" = "Contact sent to **%@**."; +"UserInfo.ContactForwardTooltip.ManyChats.One" = "Contact sent to **%@** and %@ other chats."; +"UserInfo.ContactForwardTooltip.SavedMessages.One" = "Contact sent to **Saved Messages**."; +"UserInfo.ContactForwardTooltip.TwoChats.One" = "Contact sent to **%@** and **%@**."; +"UserInfo.CreateNewContact" = "Create New Contact"; +"UserInfo.CustomPhoto" = "photo set by you"; +"UserInfo.CustomPhotoInfo" = "You can replace %@’s photo with another photo that only you will see."; +"UserInfo.CustomVideo" = "photo set by you"; +"UserInfo.DeleteContact" = "Delete Contact"; +"UserInfo.FakeBotWarning" = "⚠️ Warning: Many users reported that this account impersonates a famous person or organization."; +"UserInfo.FakeUserWarning" = "⚠️ Warning: Many users reported that this account impersonates a famous person or organization."; +"UserInfo.FirstNamePlaceholder" = "First Name"; +"UserInfo.GenericPhoneLabel" = "mobile"; +"UserInfo.GroupsInCommon" = "Groups In Common"; +"UserInfo.Invite" = "Invite to Telegram"; +"UserInfo.InviteBotToGroup" = "Add To Group"; +"UserInfo.LastNamePlaceholder" = "Last Name"; +"UserInfo.LinkForwardTooltip.Chat.One" = "Link sent to **%@**."; +"UserInfo.LinkForwardTooltip.ManyChats.One" = "Link sent to **%@** and %@ other chats."; +"UserInfo.LinkForwardTooltip.SavedMessages.One" = "Link sent to **Saved Messages**."; +"UserInfo.LinkForwardTooltip.TwoChats.One" = "Link sent to **%@** and **%@**."; +"UserInfo.NotificationsDefault" = "Default"; +"UserInfo.NotificationsDefaultDisabled" = "Default (Disabled)"; +"UserInfo.NotificationsDefaultEnabled" = "Default (Enabled)"; +"UserInfo.NotificationsDefaultSound" = "Default (%@)"; +"UserInfo.NotificationsDisable" = "Disable"; +"UserInfo.NotificationsDisabled" = "Disabled"; +"UserInfo.NotificationsEnable" = "Enable"; +"UserInfo.NotificationsEnabled" = "Enabled"; +"UserInfo.PhoneCall" = "Phone Call"; +"UserInfo.PublicPhoto" = "public photo"; +"UserInfo.PublicVideo" = "public video"; +"UserInfo.RemoveCustomPhoto" = "Remove Photo"; +"UserInfo.RemoveCustomVideo" = "Remove Video"; +"UserInfo.ResetCustomPhoto" = "Reset to Original Photo"; +"UserInfo.ResetCustomVideo" = "Reset to Original Video"; +"UserInfo.ResetToOriginalAlertReset" = "Reset"; +"UserInfo.ResetToOriginalAlertText" = "Are you sure you want to reset to %@'s original photo?"; +"UserInfo.ScamBotWarning" = "⚠️ Warning: Many users reported this account as a scam or a fake account. Please be careful, especially if it asks you for money."; +"UserInfo.ScamUserWarning" = "⚠️ Warning: Many users reported this account as a scam or a fake account. Please be careful, especially if it asks you for money."; +"UserInfo.SendMessage" = "Send Message"; +"UserInfo.SetCustomPhoto" = "Set Photo for %@"; +"UserInfo.SetCustomPhoto.AlertPhotoText" = "Do you want to set this photo for %@? It will replace any photo %@ sets – only you will see it."; +"UserInfo.SetCustomPhoto.AlertSet" = "Set"; +"UserInfo.SetCustomPhoto.AlertVideoText" = "Do you want to set this photo for %@? It will replace any photo %@ sets — only you will see it."; +"UserInfo.SetCustomPhoto.SuccessPhotoText" = "You will now always see this photo for **%@**."; +"UserInfo.SetCustomPhoto.SuccessVideoText" = "You will now always see this photo for **%@**."; +"UserInfo.SetCustomPhotoTitle" = "Do you want to set a custom profile photo for %@?"; +"UserInfo.ShareBot" = "Share"; +"UserInfo.ShareContact" = "Share Contact"; +"UserInfo.ShareMyContactInfo" = "Share My Contact Info"; +"UserInfo.StartSecretChat" = "Start Secret Chat"; +"UserInfo.StartSecretChatConfirmation" = "Are you sure you want to start a secret chat with\n%@?"; +"UserInfo.StartSecretChatStart" = "Start"; +"UserInfo.SuggestBirthdate" = "Suggest Birthday"; +"UserInfo.SuggestPhoto" = "Suggest Photo for %@"; +"UserInfo.SuggestPhoto.AlertPhotoText" = "Do you want to suggest this photo for %@'s Telegram profile?"; +"UserInfo.SuggestPhoto.AlertSuggest" = "Suggest"; +"UserInfo.SuggestPhoto.AlertVideoText" = "Do you want to suggest this photo for %@'s Telegram profile?"; +"UserInfo.SuggestPhotoTitle" = "Do you want to suggest a profile photo for %@?"; +"UserInfo.TapToCall" = "Tap to make an end-to-end encrypted call"; +"UserInfo.TelegramCall" = "Telegram Call"; +"UserInfo.TelegramVideoCall" = "Telegram Video Call"; +"UserInfo.Title" = "Info"; +"UserInfo.UnblockConfirmation" = "Unblock %@?"; +"Username.ActivateAlertShow" = "Show"; +"Username.ActivateAlertText" = "Do you want to show this link on your info page?"; +"Username.ActivateAlertTitle" = "Activate Username"; +"Username.ActiveLimitReachedError" = "Sorry, you have too many active public links already. Please hide one of your active public links first."; +"Username.BotActivateAlertText" = "Do you want to show this link on the bot's info page?"; +"Username.BotActiveLimitReachedError" = "Sorry, you have too many active public links already. Please hide one of the bot's active public links first."; +"Username.BotDeactivateAlertText" = "Do you want to hide this link from the bot's info page?"; +"Username.BotLinkHint" = "This username cannot be edited."; +"Username.BotLinkHintExtended" = "This username cannot be edited. You can acquire additional usernames on [Fragment]()."; +"Username.BotLinksOrderInfo" = "Drag and drop links to change the order in which they will be displayed on the bot info page."; +"Username.BotTitle" = "Public Links"; +"Username.CheckingUsername" = "Checking username..."; +"Username.DeactivateAlertHide" = "Hide"; +"Username.DeactivateAlertText" = "Do you want to hide this link from your info page?"; +"Username.DeactivateAlertTitle" = "Deactivate Username"; +"Username.Help" = "You can choose a username on **Telegram**. If you do, people will be able to find you by this username and contact you without needing your phone number.\n\nYou can use **a–z**, **0–9** and underscores. Minimum length is **5** characters."; +"Username.InvalidCharacters" = "Only a–z, 0–9, and underscores allowed."; +"Username.InvalidEndsWithUnderscore" = "Sorry, a username can't end with an underscore."; +"Username.InvalidStartsWithNumber" = "Usernames can't start with a number."; +"Username.InvalidStartsWithUnderscore" = "Sorry, a username can't start with an underscore."; +"Username.InvalidTaken" = "Sorry, this username is already taken."; +"Username.InvalidTooShort" = "Usernames must have at least 5 characters."; +"Username.InvalidValue" = "Sorry, this username is invalid."; +"Username.LinkCopied" = "Link copied to clipboard."; +"Username.LinkHint" = "This link opens a chat with you:[\nhttps://t.me/%@]"; +"Username.LinksOrder" = "USERNAME ORDER"; +"Username.LinksOrderInfo" = "Drag and drop links to change the order in which they will be displayed on your info page."; +"Username.Placeholder" = "Your Username"; +"Username.Title" = "Username"; +"Username.TooManyPublicUsernamesError" = "Sorry, you have reserved too many public usernames."; +"Username.Username" = "USERNAME"; +"Username.UsernameIsAvailable" = "%@ is available."; +"Username.UsernamePurchaseAvailable" = "**This username is already taken.** However, it is currently available for purchase. [Learn more...]()"; +"VerificationCodes.DescriptionText" = "Third-party services, like websites and stores, can send verification codes to your phone number via Telegram instead of SMS. Such codes will appear in this chat.\n\nIf you didn't request any codes —\u00a0don't worry! Most likely, someone made a mistake when entering their number."; +"VideoChat.EncryptionKeyDone" = "Close"; +"VideoChat.EncryptionKeyLabel" = "End-to-end encrypted"; +"VideoChat.EncryptionKeyLabelShort" = "Encrypted"; +"VideoChat.EncryptionKeyText" = "These emoji represent the encryption key. They must match for all participants and will change when someone joins or leaves."; +"VideoChat.GroupCallTitle" = "Group Call"; +"VideoChat.IncomingVideoQuality.AudioOnly" = "Audio Only"; +"VideoChat.IncomingVideoQuality.Title" = "Receive Video Quality"; +"VideoChat.InviteMember" = "Add Member"; +"VideoChat.RecordingSaved" = "Video chat recording saved to **Saved Messages**."; +"VideoChat.RevokeLink" = "Revoke Link"; +"VideoChat.ScheduleButtonTitle" = "Schedule Video Chat"; +"VideoChat.StatusPeerJoined" = "%@ joined"; +"VideoChat.StatusPeerLeft" = "%@ left"; +"VoiceChat.AddBio" = "Add Bio"; +"VoiceChat.AddPhoto" = "Add Photo"; +"VoiceChat.AnonymousDisabledAlertText" = "Sorry, you can't join video chats as an anonymous admin."; +"VoiceChat.AskedToSpeak" = "You Asked To Speak"; +"VoiceChat.AskedToSpeakHelp" = "We let the speakers know"; +"VoiceChat.Audio" = "audio"; +"VoiceChat.CancelConfirmationEnd" = "Abort"; +"VoiceChat.CancelConfirmationText" = "Do you want to abort the scheduled video chat?"; +"VoiceChat.CancelConfirmationTitle" = "Abort Video Chat"; +"VoiceChat.CancelLiveStream" = "Abort Live Stream"; +"VoiceChat.CancelReminder" = "Cancel Reminder"; +"VoiceChat.CancelSpeakRequest" = "Cancel Request to Speak"; +"VoiceChat.CancelVoiceChat" = "Abort Video Chat"; +"VoiceChat.ChangeName" = "Change Name"; +"VoiceChat.ChangeNameTitle" = "Change Name"; +"VoiceChat.ChangePhoto" = "Change Photo"; +"VoiceChat.ChatFullAlertText" = "Sorry, this video chat has too many participants at the moment."; +"VoiceChat.Connecting" = "Connecting..."; +"VoiceChat.ContextAudio" = "Audio"; +"VoiceChat.ContextDisableMessages" = "Disable Messages"; +"VoiceChat.ContextEnableMessages" = "Enable Messages"; +"VoiceChat.CopyInviteLink" = "Copy Invite Link"; +"VoiceChat.CreateNewVoiceChatSchedule" = "Schedule"; +"VoiceChat.CreateNewVoiceChatStart" = "Start"; +"VoiceChat.CreateNewVoiceChatStartNow" = "Start Now"; +"VoiceChat.CreateNewVoiceChatText" = "Video chat ended. Start a new one?"; +"VoiceChat.DiscussionGroup" = "discussion group"; +"VoiceChat.DisplayAs" = "Display Me As..."; +"VoiceChat.DisplayAsInfo" = "Choose whether you want to be displayed as your personal account or as your channel."; +"VoiceChat.DisplayAsInfoGroup" = "Choose whether you want to be displayed as your personal account or as your group."; +"VoiceChat.DisplayAsSuccess" = "Participants of this video chat will now see you as **%@**."; +"VoiceChat.EditBio" = "Edit Bio"; +"VoiceChat.EditBioPlaceholder" = "Bio"; +"VoiceChat.EditBioSave" = "Save"; +"VoiceChat.EditBioSuccess" = "Your bio was updated."; +"VoiceChat.EditBioText" = "Any details such as age, occupation or city."; +"VoiceChat.EditBioTitle" = "Bio"; +"VoiceChat.EditDescription" = "Edit Description"; +"VoiceChat.EditDescriptionPlaceholder" = "Description"; +"VoiceChat.EditDescriptionSave" = "Save"; +"VoiceChat.EditDescriptionSuccess" = "Description is changed."; +"VoiceChat.EditDescriptionText" = "Any details such as age, occupation or city."; +"VoiceChat.EditDescriptionTitle" = "Description"; +"VoiceChat.EditNameSuccess" = "Your name was updated."; +"VoiceChat.EditPermissions" = "Edit Permissions"; +"VoiceChat.EditTitle" = "Edit Video Chat Title"; +"VoiceChat.EditTitleRemoveSuccess" = "Video chat title removed."; +"VoiceChat.EditTitleSuccess" = "Video chat title changed to **%@**."; +"VoiceChat.EditTitleText" = "Discussion topics, notable guests, etc."; +"VoiceChat.EditTitleTitle" = "Video Chat Title"; +"VoiceChat.EndConfirmationEnd" = "End"; +"VoiceChat.EndConfirmationText" = "Are you sure you want to end this video chat?"; +"VoiceChat.EndConfirmationTitle" = "End video chat"; +"VoiceChat.EndLiveStream" = "End Live Stream"; +"VoiceChat.EndVoiceChat" = "End Video Chat"; +"VoiceChat.ForwardTooltip.Chat" = "Invite link sent to **%@**."; +"VoiceChat.ForwardTooltip.ManyChats" = "Invite link sent to **%@** and %@ other chats."; +"VoiceChat.ForwardTooltip.TwoChats" = "Invite link sent to **%@** and **%@**."; +"VoiceChat.ImproveYourProfileText" = "You can improve your profile by adding missing information."; +"VoiceChat.InviteLink.CopyListenerLink" = "Copy Listener Link"; +"VoiceChat.InviteLink.CopySpeakerLink" = "Copy Speaker Link"; +"VoiceChat.InviteLink.InviteListeners#one" = "[%@] Invite Listener"; +"VoiceChat.InviteLink.InviteListeners#other" = "[%@] Invite Listeners"; +"VoiceChat.InviteLink.InviteSpeakers#one" = "[%@] Invite Speaker"; +"VoiceChat.InviteLink.InviteSpeakers#other" = "[%@] Invite Speakers"; +"VoiceChat.InviteLink.Listener" = "Listener"; +"VoiceChat.InviteLink.Speaker" = "Speaker"; +"VoiceChat.InviteLinkCopiedText" = "Invite link copied to clipboard."; +"VoiceChat.InviteMember" = "Invite Members"; +"VoiceChat.InviteMemberToChannelFirstText" = "%1$@ isn't a subscriber of \"%2$@\" yet. Add them to the channel?"; +"VoiceChat.InviteMemberToGroupFirstAdd" = "Add"; +"VoiceChat.InviteMemberToGroupFirstText" = "%1$@ isn't a member of \"%2$@\". Add them to the group?"; +"VoiceChat.InvitePeer" = "Invite"; +"VoiceChat.InvitedPeerText" = "You invited %@ to the video chat"; +"VoiceChat.LateBy" = "Late by"; +"VoiceChat.Leave" = "leave"; +"VoiceChat.LeaveAndCancelVoiceChat" = "Abort Video Chat"; +"VoiceChat.LeaveAndEndVoiceChat" = "End Video Chat"; +"VoiceChat.LeaveConfirmation" = "Are you sure you want to leave this video chat?"; +"VoiceChat.LeaveVoiceChat" = "Leave Video Chat"; +"VoiceChat.Live" = "You're Live"; +"VoiceChat.Message" = "message"; +"VoiceChat.MessagePlaceholder" = "Message"; +"VoiceChat.MicrophoneModes" = "Microphone Modes"; +"VoiceChat.Mute" = "Mute"; +"VoiceChat.MuteForMe" = "Mute for Me"; +"VoiceChat.MutePeer" = "Mute"; +"VoiceChat.MuteShort" = "mute"; +"VoiceChat.Muted" = "Muted"; +"VoiceChat.MutedByAdmin" = "Muted by Admin"; +"VoiceChat.MutedByAdminHelp" = "Tap if you want to speak"; +"VoiceChat.MutedHelp" = "You are in Listen Only mode"; +"VoiceChat.NoiseSuppression" = "Noise Suppression"; +"VoiceChat.NoiseSuppressionDisabled" = "Disabled"; +"VoiceChat.NoiseSuppressionEnabled" = "Enabled"; +"VoiceChat.OpenChannel" = "Open Channel"; +"VoiceChat.OpenChat" = "Open Chat"; +"VoiceChat.OpenGroup" = "Open Group"; +"VoiceChat.Panel.Members#one" = "%@ participant"; +"VoiceChat.Panel.Members#other" = "%@ participants"; +"VoiceChat.Panel.MembersSpeaking#one" = "%@ participant speaking"; +"VoiceChat.Panel.MembersSpeaking#other" = "%@ participants talking"; +"VoiceChat.Panel.TapToJoin" = "Tap to join"; +"VoiceChat.PanelJoin" = "Join"; +"VoiceChat.ParticipantIsSpeaking" = "%1$@ is speaking"; +"VoiceChat.PeerJoinedText" = "%@ joined the video chat"; +"VoiceChat.PersonalAccount" = "personal account"; +"VoiceChat.PinVideo" = "Pin Video"; +"VoiceChat.Reconnecting" = "Reconnecting..."; +"VoiceChat.RecordLandscape" = "Landscape"; +"VoiceChat.RecordOnlyAudio" = "Only Audio"; +"VoiceChat.RecordPortrait" = "Portrait"; +"VoiceChat.RecordStartRecording" = "Start Recording"; +"VoiceChat.RecordTitle" = "Record Video Chat"; +"VoiceChat.RecordVideoAndAudio" = "Video and Audio"; +"VoiceChat.RecordingInProgress" = "Video chat is being recorded"; +"VoiceChat.RecordingSaved" = "Audio saved to **Saved Messages**."; +"VoiceChat.RecordingStarted" = "Audio recording started."; +"VoiceChat.RecordingTitlePlaceholder" = "Audio Title (Optional)"; +"VoiceChat.RecordingTitlePlaceholderVideo" = "Video Title (Optional)"; +"VoiceChat.ReminderNotify" = "We will notify you when it starts."; +"VoiceChat.RemoveAndBanPeerConfirmation" = "Do you want to remove %1$@ from the video chat and ban them in %2$@?"; +"VoiceChat.RemoveConferencePeerConfirmation" = "Are you sure you want to remove %@ from this call?"; +"VoiceChat.RemovePeer" = "Remove"; +"VoiceChat.RemovePeerConfirmation" = "Are you sure you want to remove %@ from the group?"; +"VoiceChat.RemovePeerConfirmationChannel" = "Are you sure you want to remove %@ from the channel?"; +"VoiceChat.RemovePeerRemove" = "Remove"; +"VoiceChat.RemovedConferencePeerText" = "You removed %@ from this call. They will no longer be able to join using the invite link."; +"VoiceChat.RemovedPeerText" = "You removed %@ from the group"; +"VoiceChat.Rotate" = "rotate"; +"VoiceChat.Scheduled" = "Scheduled"; +"VoiceChat.SelectAccount" = "Select Account"; +"VoiceChat.SendPublicLinkSend" = "Send"; +"VoiceChat.SendPublicLinkText" = "%1$@ isn't a member of \"%2$@\" yet. Send them a public invite link instead?"; +"VoiceChat.SetReminder" = "Set Reminder"; +"VoiceChat.Share" = "Share Invite Link"; +"VoiceChat.ShareButton" = "share"; +"VoiceChat.ShareScreen" = "Share Screen"; +"VoiceChat.ShareShort" = "share"; +"VoiceChat.SpeakPermissionAdmin" = "New Participants Are Muted"; +"VoiceChat.SpeakPermissionEveryone" = "New Participants Can Speak"; +"VoiceChat.StartNow" = "Start Now"; +"VoiceChat.StartRecording" = "Start Recording"; +"VoiceChat.StartRecordingStart" = "Start"; +"VoiceChat.StartRecordingText" = "Record this chat and save the result into an audio file?\n\nParticipants will see that the chat is being recorded."; +"VoiceChat.StartRecordingTextVideo" = "Do you want to start recording this chat and save it as a file?\n\nOther participants will see that the chat is being recorded."; +"VoiceChat.StartRecordingTitle" = "Start Recording"; +"VoiceChat.StartsIn" = "Starts in"; +"VoiceChat.Status.Members#one" = "[%@]participant"; +"VoiceChat.Status.Members#other" = "[%@]participants"; +"VoiceChat.Status.MembersFormat" = "%1$@ %2$@"; +"VoiceChat.StatusInvited" = "invited"; +"VoiceChat.StatusLateBy" = "late by %@"; +"VoiceChat.StatusListening" = "listening"; +"VoiceChat.StatusMutedForYou" = "muted for you"; +"VoiceChat.StatusMutedYou" = "put you on mute"; +"VoiceChat.StatusSpeaking" = "speaking"; +"VoiceChat.StatusSpeakingVolume" = "%@ speaking"; +"VoiceChat.StatusStartsIn" = "starts in %@"; +"VoiceChat.StatusWantsToSpeak" = "wants to speak"; +"VoiceChat.StopRecording" = "Stop Recording"; +"VoiceChat.StopRecordingStop" = "Stop"; +"VoiceChat.StopRecordingTitle" = "Stop Recording?"; +"VoiceChat.StopScreenSharing" = "Stop Sharing Screen"; +"VoiceChat.StopScreenSharingShort" = "Stop Sharing"; +"VoiceChat.SubtitleInvited#one" = " invited"; +"VoiceChat.SubtitleInvited#other" = " invited"; +"VoiceChat.SubtitleParticipants#one" = " participant"; +"VoiceChat.SubtitleParticipants#other" = " participants"; +"VoiceChat.TapToAddBio" = "tap to add a bio"; +"VoiceChat.TapToAddPhoto" = "tap to add photo"; +"VoiceChat.TapToAddPhotoOrBio" = "tap to add photo or bio"; +"VoiceChat.TapToEditTitle" = "Tap to edit title"; +"VoiceChat.TapToViewCameraVideo" = "Tap to view camera video"; +"VoiceChat.TapToViewScreenVideo" = "Tap to view screen sharing"; +"VoiceChat.Title" = "Video Chat"; +"VoiceChat.ToastMessagesDisabled" = "Messages disabled"; +"VoiceChat.ToastMessagesEnabled" = "Messages enabled"; +"VoiceChat.ToastMicrophoneIsMuted" = "Your microphone is muted."; +"VoiceChat.Unmute" = "Unmute"; +"VoiceChat.UnmuteForMe" = "Unmute for Me"; +"VoiceChat.UnmuteHelp" = "or hold and speak"; +"VoiceChat.UnmutePeer" = "Allow to Speak"; +"VoiceChat.UnmuteSuggestion" = "You are on mute. Tap here to speak."; +"VoiceChat.Unpin" = "Unpin"; +"VoiceChat.UnpinVideo" = "Unpin Video"; +"VoiceChat.UserCanNowSpeak" = "**%@** can now speak."; +"VoiceChat.UserInvited" = "You invited **%@** to the video chat"; +"VoiceChat.Video" = "video"; +"VoiceChat.VideoParticipantsLimitExceeded" = "To watch the video stream, update\nTelegram to the latest version."; +"VoiceChat.VideoParticipantsLimitExceededExtended" = "The video chat is over %@ members.\nNew participants only have access to audio stream.\u00a0"; +"VoiceChat.VideoPaused" = "Video is paused"; +"VoiceChat.VideoPreviewBackCamera" = "Back Camera"; +"VoiceChat.VideoPreviewContinue" = "Continue"; +"VoiceChat.VideoPreviewDescription" = "Are you sure you want to share your video?"; +"VoiceChat.VideoPreviewFrontCamera" = "Front Camera"; +"VoiceChat.VideoPreviewPhoneScreen" = "Phone Screen"; +"VoiceChat.VideoPreviewShareCamera" = "Share Camera Video"; +"VoiceChat.VideoPreviewShareScreen" = "Share Screen"; +"VoiceChat.VideoPreviewShareScreenInfo" = "Everything on your screen\nwill be shared"; +"VoiceChat.VideoPreviewStopScreenSharing" = "Stop Screen Sharing"; +"VoiceChat.VideoPreviewTabletScreen" = "iPad Screen"; +"VoiceChat.VideoPreviewTitle" = "Video Preview"; +"VoiceChat.You" = "this is you"; +"VoiceChat.YouAreSharingScreen" = "You are sharing your screen"; +"VoiceChat.YouCanNowSpeak" = "You can now speak."; +"VoiceChat.YouCanNowSpeakIn" = "You can now speak in **%@**."; +"VoiceChatChannel.Title" = "Live Stream"; +"VoiceOver.AttachMedia" = "Send media"; +"VoiceOver.AuthSessions.CurrentSession" = "Current Session"; +"VoiceOver.BotCommands" = "Bot Commands"; +"VoiceOver.BotKeyboard" = "Bot Keyboard"; +"VoiceOver.Chat.AnimatedSticker" = "Animated sticker"; +"VoiceOver.Chat.AnimatedStickerFrom" = "Animated sticker, from: %@"; +"VoiceOver.Chat.AnonymousPoll" = "Anonymous poll"; +"VoiceOver.Chat.AnonymousPollFrom" = "Anonymous poll, from: %@"; +"VoiceOver.Chat.Caption" = "Caption: %@"; +"VoiceOver.Chat.ChannelInfo" = "Channel Info"; +"VoiceOver.Chat.Contact" = "Shared contact"; +"VoiceOver.Chat.ContactEmail" = "Email"; +"VoiceOver.Chat.ContactEmailCount#one" = "%@ email address"; +"VoiceOver.Chat.ContactEmailCount#other" = "%@ email addresses"; +"VoiceOver.Chat.ContactFrom" = "Shared contact, from: %@"; +"VoiceOver.Chat.ContactOrganization" = "Organization: %@"; +"VoiceOver.Chat.ContactPhoneNumber" = "Phone number"; +"VoiceOver.Chat.ContactPhoneNumberCount#one" = "%@ phone number"; +"VoiceOver.Chat.ContactPhoneNumberCount#other" = "%@ phone numbers"; +"VoiceOver.Chat.Duration" = "Duration: %@"; +"VoiceOver.Chat.Failed" = "Failed to send"; +"VoiceOver.Chat.File" = "File"; +"VoiceOver.Chat.FileFrom" = "File, from: %@"; +"VoiceOver.Chat.ForwardedFrom" = "Forwarded from: %@"; +"VoiceOver.Chat.ForwardedFromYou" = "Forwarded from you"; +"VoiceOver.Chat.GoToOriginalMessage" = "Go to message"; +"VoiceOver.Chat.GroupInfo" = "Group Info"; +"VoiceOver.Chat.Message" = "Message"; +"VoiceOver.Chat.MessagesSelected#one" = "%@ message selected"; +"VoiceOver.Chat.MessagesSelected#other" = "%@ messages selected"; +"VoiceOver.Chat.Music" = "Music message"; +"VoiceOver.Chat.MusicFrom" = "Music file, from: %@"; +"VoiceOver.Chat.MusicTitle" = "%1$@, by %2$@"; +"VoiceOver.Chat.NotPlayedByRecipient" = "Not played by recipient"; +"VoiceOver.Chat.NotPlayedByRecipients" = "Not played by recipients"; +"VoiceOver.Chat.OpenHint" = "Double tap to open"; +"VoiceOver.Chat.OpenLinkHint" = "Double tap to open link"; +"VoiceOver.Chat.OptionSelected" = "selected"; +"VoiceOver.Chat.PagePreview" = "Page preview"; +"VoiceOver.Chat.Photo" = "Photo"; +"VoiceOver.Chat.PhotoFrom" = "Photo, from: %@"; +"VoiceOver.Chat.PlayHint" = "Double tap to play"; +"VoiceOver.Chat.PlayedByRecipient" = "Played by recipient"; +"VoiceOver.Chat.PlayedByRecipients" = "Played by recipients"; +"VoiceOver.Chat.PollFinalResults" = "Final results"; +"VoiceOver.Chat.PollNoVotes" = "No votes"; +"VoiceOver.Chat.PollOptionCount#one" = "%@ option:"; +"VoiceOver.Chat.PollOptionCount#other" = "%@ options:"; +"VoiceOver.Chat.PollVotes#one" = "%@ vote"; +"VoiceOver.Chat.PollVotes#other" = "%@ votes"; +"VoiceOver.Chat.Profile" = "Profile"; +"VoiceOver.Chat.RecordModeVideoMessage" = "Video message"; +"VoiceOver.Chat.RecordModeVideoMessageInfo" = "Double tap and hold to record video message. Slide up to lock recording, slide left to cancel. Double tap to switch to voice."; +"VoiceOver.Chat.RecordModeVoiceMessage" = "Voice message"; +"VoiceOver.Chat.RecordModeVoiceMessageInfo" = "Double-tap and hold to record voice message. Slide up to lock recording, slide left to cancel. Double tap to switch to video."; +"VoiceOver.Chat.RecordPreviewVoiceMessage" = "Preview voice message"; +"VoiceOver.Chat.Reply" = "Reply to message"; +"VoiceOver.Chat.ReplyFrom" = "Reply to message from: %@"; +"VoiceOver.Chat.ReplyToYourMessage" = "Reply to your message"; +"VoiceOver.Chat.ReplyingToMessage" = "In reply to message: %@"; +"VoiceOver.Chat.SeenByRecipient" = "Seen by recipient"; +"VoiceOver.Chat.SeenByRecipients" = "Seen by recipients"; +"VoiceOver.Chat.Selected" = "Selected"; +"VoiceOver.Chat.Sending" = "Sending"; +"VoiceOver.Chat.Size" = "Size: %@"; +"VoiceOver.Chat.Sticker" = "Sticker"; +"VoiceOver.Chat.StickerFrom" = "Sticker, from: %@"; +"VoiceOver.Chat.Title" = "Title: %@"; +"VoiceOver.Chat.UnreadMessages#one" = "%@ unread message"; +"VoiceOver.Chat.UnreadMessages#other" = "%@ unread messages"; +"VoiceOver.Chat.Video" = "Video"; +"VoiceOver.Chat.VideoFrom" = "Video, from: %@"; +"VoiceOver.Chat.VideoMessage" = "Video message"; +"VoiceOver.Chat.VideoMessageFrom" = "Video message, from: %@"; +"VoiceOver.Chat.VoiceMessage" = "Voice message"; +"VoiceOver.Chat.VoiceMessageFrom" = "Voice message, from: %@"; +"VoiceOver.Chat.YourAnimatedSticker" = "Your animated sticker"; +"VoiceOver.Chat.YourAnonymousPoll" = "Your Anonymous poll"; +"VoiceOver.Chat.YourContact" = "Your shared contact"; +"VoiceOver.Chat.YourFile" = "Your file"; +"VoiceOver.Chat.YourMessage" = "Your message"; +"VoiceOver.Chat.YourMusic" = "Your music message"; +"VoiceOver.Chat.YourPhoto" = "Your photo"; +"VoiceOver.Chat.YourSticker" = "Your sticker"; +"VoiceOver.Chat.YourVideo" = "Your video"; +"VoiceOver.Chat.YourVideoMessage" = "Your video message"; +"VoiceOver.Chat.YourVoiceMessage" = "Your voice message"; +"VoiceOver.ChatList.Message" = "Message"; +"VoiceOver.ChatList.MessageEmpty" = "Empty"; +"VoiceOver.ChatList.MessageFrom" = "From: %@"; +"VoiceOver.ChatList.MessageRead" = "Read"; +"VoiceOver.ChatList.OutgoingMessage" = "Outgoing Message"; +"VoiceOver.Common.Off" = "Off"; +"VoiceOver.Common.On" = "On"; +"VoiceOver.Common.SwitchHint" = "Double Tap To Toggle"; +"VoiceOver.DiscardPreparedContent" = "Discard"; +"VoiceOver.DismissContextMenu" = "Dismiss Context Menu"; +"VoiceOver.Editing.ClearText" = "Clear text"; +"VoiceOver.GiftPremium" = "Gift Telegram Premium"; +"VoiceOver.Keyboard" = "Keyboard"; +"VoiceOver.Media.PlaybackPause" = "Pause"; +"VoiceOver.Media.PlaybackPlay" = "Play"; +"VoiceOver.Media.PlaybackRate" = "Playback speed"; +"VoiceOver.Media.PlaybackRateChange" = "Double tap to change"; +"VoiceOver.Media.PlaybackRateFast" = "Fast"; +"VoiceOver.Media.PlaybackRateNormal" = "Normal"; +"VoiceOver.Media.PlaybackStop" = "Stop playback"; +"VoiceOver.MessageContextDelete" = "Delete"; +"VoiceOver.MessageContextForward" = "Forward"; +"VoiceOver.MessageContextOpenMessageMenu" = "Open message menu"; +"VoiceOver.MessageContextReply" = "Reply"; +"VoiceOver.MessageContextReport" = "Report"; +"VoiceOver.MessageContextSend" = "Send"; +"VoiceOver.MessageContextShare" = "Share"; +"VoiceOver.MessageSelectionButtonTag" = "Tag"; +"VoiceOver.Navigation.Compose" = "Compose"; +"VoiceOver.Navigation.ProxySettings" = "Proxy settings"; +"VoiceOver.Navigation.Search" = "Search"; +"VoiceOver.Recording.StopAndPreview" = "Stop and preview"; +"VoiceOver.ScheduledMessages" = "Scheduled Messages"; +"VoiceOver.ScrollStatus" = "Row %1$@ of %2$@"; +"VoiceOver.SelfDestructTimerOff" = "Self-destruct Timer Off"; +"VoiceOver.SelfDestructTimerOn" = "Self-destruct Timer: %@"; +"VoiceOver.SilentPostOff" = "Silent Broadcast Off"; +"VoiceOver.SilentPostOn" = "Silent Broadcast On"; +"VoiceOver.Stickers" = "Stickers"; +"VoiceOver.SuggestPost" = "Suggest Post"; +"Wallpaper.ApplyForAll" = "Apply For All Chats"; +"Wallpaper.ApplyForBoth" = "Apply for Me and %@"; +"Wallpaper.ApplyForChannel" = "Apply Wallpaper"; +"Wallpaper.ApplyForChat" = "Apply For This Chat"; +"Wallpaper.ApplyForMe" = "Apply for Me"; +"Wallpaper.ChannelCustomBackgroundInfo" = "Upload your own background image for the channel."; +"Wallpaper.ChannelRemoveBackground" = "Remove Wallpaper"; +"Wallpaper.ChannelTitle" = "Channel Wallpaper"; +"Wallpaper.DeleteConfirmation#one" = "Delete Wallpaper"; +"Wallpaper.DeleteConfirmation#other" = "Delete %@ Wallpapers"; +"Wallpaper.ErrorNotFound" = "Sorry, this chat wallpaper doesn't seem to exist."; +"Wallpaper.NoWallpaper" = "No\nWallpaper"; +"Wallpaper.PhotoLibrary" = "Photo Library"; +"Wallpaper.ResetWallpapers" = "Reset Chat Wallpapers"; +"Wallpaper.ResetWallpapersConfirmation" = "Reset Chat Wallpapers"; +"Wallpaper.ResetWallpapersInfo" = "Remove all uploaded wallpapers and restore pre-installed wallpapers for all themes."; +"Wallpaper.Search" = "Search for images"; +"Wallpaper.SearchShort" = "Search"; +"Wallpaper.Set" = "Set"; +"Wallpaper.SetColor" = "Set a Color"; +"Wallpaper.SetCustomBackground" = "Choose from Photos"; +"Wallpaper.SetCustomBackgroundInfo" = "You can set a custom background image and share it with your friends."; +"Wallpaper.Title" = "Chat Wallpaper"; +"Wallpaper.Wallpaper" = "Wallpaper"; +"WallpaperColors.SetCustomColor" = "Set Custom Color"; +"WallpaperColors.Title" = "Set a Color"; +"WallpaperPreview.Animate" = "Animate"; +"WallpaperPreview.AnimateDescription" = "Colors will move when you send messages"; +"WallpaperPreview.Blurred" = "Blurred"; +"WallpaperPreview.ChannelHeader" = "All subscribers will see this wallpaper"; +"WallpaperPreview.ChannelReplyText" = "Breaking News"; +"WallpaperPreview.ChannelTopText" = "Details to follow shortly.\nStay tuned!"; +"WallpaperPreview.ChatBottomText" = "Looks good."; +"WallpaperPreview.ChatTopText" = "Apply the wallpaper in this chat."; +"WallpaperPreview.CropBottomText" = "Get a sharing link for your wallpaper when you're done"; +"WallpaperPreview.CropTopText" = "Pinch and pan to adjust wallpaper"; +"WallpaperPreview.CustomColorBottomText" = "Something to match your curtains"; +"WallpaperPreview.CustomColorTopText" = "Use sliders to adjust color"; +"WallpaperPreview.GroupHeader" = "All members will see this wallpaper"; +"WallpaperPreview.Motion" = "Motion"; +"WallpaperPreview.NotAppliedInfo" = "**%@** will be able to apply this wallpaper"; +"WallpaperPreview.Pattern" = "Pattern"; +"WallpaperPreview.PatternIntensity" = "Pattern Intensity"; +"WallpaperPreview.PatternPaternApply" = "Apply"; +"WallpaperPreview.PatternPaternDiscard" = "Discard"; +"WallpaperPreview.PatternTitle" = "Choose Pattern"; +"WallpaperPreview.PreviewBottomText" = "Looks good"; +"WallpaperPreview.PreviewBottomTextAnimatable" = "Tap the play button to view the wallpaper animation."; +"WallpaperPreview.PreviewInDayMode" = "Preview this wallpaper in day mode."; +"WallpaperPreview.PreviewInNightMode" = "Preview this wallpaper in night mode."; +"WallpaperPreview.PreviewTopText" = "Tap Set to apply the wallpaper"; +"WallpaperPreview.SwipeBottomText" = "Tap 'Motion' and try tilting your device"; +"WallpaperPreview.SwipeColorsBottomText" = "Salmon is a fish, not a color"; +"WallpaperPreview.SwipeColorsTopText" = "Swipe left or right to see more colors"; +"WallpaperPreview.SwipeTopText" = "Swipe left or right to preview more wallpapers"; +"WallpaperPreview.Title" = "Wallpaper Preview"; +"WallpaperPreview.WallpaperColors" = "Colors"; +"WallpaperSearch.ColorBlack" = "Black"; +"WallpaperSearch.ColorBlue" = "Blue"; +"WallpaperSearch.ColorBrown" = "Brown"; +"WallpaperSearch.ColorGray" = "Gray"; +"WallpaperSearch.ColorGreen" = "Green"; +"WallpaperSearch.ColorOrange" = "Orange"; +"WallpaperSearch.ColorPink" = "Pink"; +"WallpaperSearch.ColorPrefix" = "color: "; +"WallpaperSearch.ColorPurple" = "Purple"; +"WallpaperSearch.ColorRed" = "Red"; +"WallpaperSearch.ColorTeal" = "Teal"; +"WallpaperSearch.ColorTitle" = "SEARCH BY COLOR"; +"WallpaperSearch.ColorWhite" = "White"; +"WallpaperSearch.ColorYellow" = "Yellow"; +"WallpaperSearch.Recent" = "RECENT"; +"Watch.AppName" = "Telegram"; +"Watch.AuthRequired" = "Log in to Telegram on your phone to get started"; +"Watch.Bot.Restart" = "Restart"; +"Watch.ChannelInfo.Title" = "Channel Info"; +"Watch.ChatList.Compose" = "New Message"; +"Watch.ChatList.NoConversationsText" = "To start messaging,\npress firmly, then tap\nNew Message"; +"Watch.ChatList.NoConversationsTitle" = "No Conversations"; +"Watch.Compose.AddContact" = "Choose Contact"; +"Watch.Compose.CreateMessage" = "Create Message"; +"Watch.Compose.CurrentLocation" = "Current Location"; +"Watch.Compose.Send" = "Send"; +"Watch.ConnectionDescription" = "Your Watch needs to be connected for the app to work"; +"Watch.Contacts.NoResults" = "No matching\ncontacts found"; +"Watch.Conversation.GroupInfo" = "Group Info"; +"Watch.Conversation.Reply" = "Reply"; +"Watch.Conversation.Unblock" = "Unblock"; +"Watch.Conversation.UserInfo" = "Info"; +"Watch.GroupInfo.Title" = "Group Info"; +"Watch.LastSeen.ALongTimeAgo" = "a long time ago"; +"Watch.LastSeen.AtDate" = "%@"; +"Watch.LastSeen.HoursAgo#one" = "1 hour ago"; +"Watch.LastSeen.HoursAgo#other" = "%@ hours ago"; +"Watch.LastSeen.JustNow" = "just now"; +"Watch.LastSeen.Lately" = "recently"; +"Watch.LastSeen.MinutesAgo#one" = "1 minute ago"; +"Watch.LastSeen.MinutesAgo#other" = "%@ minutes ago"; +"Watch.LastSeen.WithinAMonth" = "within a month"; +"Watch.LastSeen.WithinAWeek" = "within a week"; +"Watch.LastSeen.YesterdayAt" = "yesterday at %@"; +"Watch.Location.Access" = "Allow Telegram to access location on your phone"; +"Watch.Location.Current" = "Current Location"; +"Watch.Message.Call" = "Call"; +"Watch.Message.ForwardedFrom" = "Forwarded from"; +"Watch.Message.Game" = "Game"; +"Watch.Message.Invoice" = "Invoice"; +"Watch.Message.Poll" = "Poll"; +"Watch.Message.Unsupported" = "Unsupported Message"; +"Watch.MessageView.Forward" = "Forward"; +"Watch.MessageView.Reply" = "Reply"; +"Watch.MessageView.Title" = "Message"; +"Watch.MessageView.ViewOnPhone" = "View On Phone"; +"Watch.Microphone.Access" = "Allow Telegram to access the microphone on your phone"; +"Watch.NoConnection" = "No Connection"; +"Watch.Notification.Joined" = "Joined Telegram"; +"Watch.PhotoView.Title" = "Photo"; +"Watch.Stickers.RecentPlaceholder" = "Your most frequently used stickers will appear here"; +"Watch.Stickers.Recents" = "Recents"; +"Watch.Stickers.StickerPacks" = "Sticker Sets"; +"Watch.Suggestion.BRB" = "BRB"; +"Watch.Suggestion.CantTalk" = "Can't talk now..."; +"Watch.Suggestion.HoldOn" = "Hold on a sec..."; +"Watch.Suggestion.OK" = "OK"; +"Watch.Suggestion.OnMyWay" = "I'm on my way."; +"Watch.Suggestion.TalkLater" = "Talk later?"; +"Watch.Suggestion.Thanks" = "Thanks!"; +"Watch.Suggestion.WhatsUp" = "What's up?"; +"Watch.Time.ShortFullAt" = "%1$@ %2$@"; +"Watch.Time.ShortTodayAt" = "Today %@"; +"Watch.Time.ShortWeekdayAt" = "%1$@ %2$@"; +"Watch.Time.ShortYesterdayAt" = "Yesterday %@"; +"Watch.UserInfo.Block" = "Block"; +"Watch.UserInfo.Mute#one" = "Mute for 1 hour"; +"Watch.UserInfo.Mute#other" = "Mute for %@ hours"; +"Watch.UserInfo.MuteTitle" = "Mute"; +"Watch.UserInfo.Service" = "service notifications"; +"Watch.UserInfo.Title" = "Info"; +"Watch.UserInfo.Unblock" = "Unblock"; +"Watch.UserInfo.Unmute" = "Unmute"; +"WatchRemote.AlertOpen" = "Open"; +"WatchRemote.AlertText" = "Open the message here?"; +"WatchRemote.AlertTitle" = "Message from your Apple Watch"; +"WatchRemote.NotificationText" = "Open this notification on your phone to view the message from your Apple Watch"; +"Web.Error" = "Couldn't load page"; +"Web.OpenExternal" = "Open in Browser"; +"WebApp.AddToAttachmentAdd" = "Add"; +"WebApp.AddToAttachmentAllowMessages" = "Allow **%@** to send me messages"; +"WebApp.AddToAttachmentAlreadyAddedError" = "This bot is already added to your attachment menu."; +"WebApp.AddToAttachmentSucceeded" = "**%@** added to your attachment menu."; +"WebApp.AddToAttachmentText" = "%@ requests your permission to be added as an option to your attachment menu, so you can access it from any chat."; +"WebApp.AddToAttachmentTitle" = "Add to Attachment Menu"; +"WebApp.AddToAttachmentUnavailableError" = "This bot can't be added to the attachment menu."; +"WebApp.AddToHomeScreen" = "Add to Home Screen"; +"WebApp.AlertBiometryAccessText" = "Do you want to allow %@ to use Face ID?"; +"WebApp.AlertBiometryAccessTouchIDText" = "Do you want to allow %@ to use Touch ID?"; +"WebApp.AllowWriteConfirmation" = "This will allow the bot **%@** to message you on Telegram."; +"WebApp.AllowWriteTitle" = "Allow Sending Messages?"; +"WebApp.CloseAnyway" = "Close Anyway"; +"WebApp.CloseConfirmation" = "Changes that you made may not be saved."; +"WebApp.DisclaimerAgree" = "I agree to the [Terms of Use]()"; +"WebApp.DisclaimerContinue" = "Continue"; +"WebApp.DisclaimerText" = "You are about to use a mini app operated by an independent party not affiliated with Telegram. You must agree to the Terms of Use for mini apps to continue."; +"WebApp.DisclaimerTitle" = "Terms of Use"; +"WebApp.Disclaimer_URL" = "https://telegram.org/tos/mini-apps"; +"WebApp.Download.Cancel" = "Cancel"; +"WebApp.Download.Document" = "Download Document"; +"WebApp.Download.Download" = "Download"; +"WebApp.Download.Photo" = "Download Photo"; +"WebApp.Download.SavedToFiles" = "Saved to Files."; +"WebApp.Download.SavedToPhotos" = "Saved to Photos."; +"WebApp.Download.Text" = "**%1$@** offers you to download **%2$@**%3$@."; +"WebApp.Download.Video" = "Download Video"; +"WebApp.Emoji.Confirm" = "Confirm"; +"WebApp.Emoji.DurationSucceed" = "Emoji status set for %@."; +"WebApp.Emoji.DurationText" = "Do you want to set this emoji status suggested by **%1$@** for **%2$@**?"; +"WebApp.Emoji.Succeed" = "Emoji status updated."; +"WebApp.Emoji.Text" = "Do you want to set this emoji status suggested by **%@**?"; +"WebApp.Emoji.Title" = "Set Emoji Status"; +"WebApp.EmojiPermission.Allow" = "Allow"; +"WebApp.EmojiPermission.Decline" = "Decline"; +"WebApp.EmojiPermission.Succeed" = "**%@** can now set your emoji status anytime."; +"WebApp.EmojiPermission.Text" = "**%1$@** requests access your **emoji status**. You can revoke this access anytime on the profile page of **%2$@**."; +"WebApp.EmojiPermission.Undo" = "Undo"; +"WebApp.GuardDisclaimerText" = "**%1$@** uses **%2$@** to welcome new members. You must agree to the Terms of Use of mini apps to continue."; +"WebApp.ImportData.AccountHeader" = "ACCOUNT TO IMPORT DATA FROM"; +"WebApp.ImportData.CreatedOn" = "created on %@"; +"WebApp.ImportData.Description" = "**%@** is requesting permission to import data from a previous Telegram account used on this device."; +"WebApp.ImportData.Import" = "Import"; +"WebApp.ImportData.Title" = "Import Data"; +"WebApp.LaunchConfirmation" = "To launch this web app, you will connect to its website."; +"WebApp.LaunchMoreInfo" = "More about this bot"; +"WebApp.LaunchOpenApp" = "Launch"; +"WebApp.LaunchTermsConfirmation" = "By launching this mini app, you agree to the [Terms of Service for Mini Apps]()."; +"WebApp.LaunchTermsConfirmation_URL" = "https://telegram.org/tos/mini-apps"; +"WebApp.LocationPermission.Allow" = "Allow"; +"WebApp.LocationPermission.Decline" = "Decline"; +"WebApp.LocationPermission.Succeed" = "**%@** can now access your location."; +"WebApp.LocationPermission.Text" = "**%1$@** requests access to your **location**. You can revoke this access anytime on the profile page of **%2$@**."; +"WebApp.LocationPermission.Undo" = "Undo"; +"WebApp.MessagePreview" = "Message Preview"; +"WebApp.Miniapp" = "mini app"; +"WebApp.Minimized.CloseAll#one" = "Close All %@ Tab"; +"WebApp.Minimized.CloseAll#other" = "Close All %@ Tabs"; +"WebApp.Minimized.CloseAllTitle" = "Are you sure you want to close all open tabs?"; +"WebApp.MinimizedTitle.Others#one" = "%@ Other"; +"WebApp.MinimizedTitle.Others#other" = "%@ Others"; +"WebApp.MinimizedTitleFormat" = "%1$@ & %2$@"; +"WebApp.OpenBot" = "Open Bot"; +"WebApp.OpenWebViewAlertText" = "**%@** would like to open its web app to proceed.\n\nIt will be able to access your **IP address** and basic device info."; +"WebApp.OpenWebViewAlertTitle" = "Launch App"; +"WebApp.PrivacyPolicy" = "Privacy Policy"; +"WebApp.PrivacyPolicy_URL" = "https://telegram.org/privacy-tpa"; +"WebApp.ReloadPage" = "Reload Page"; +"WebApp.RemoveAllConfirmationText" = "This will remove **%@** shortcuts from all menus."; +"WebApp.RemoveBot" = "Remove From Menu"; +"WebApp.RemoveConfirmationText" = "Remove **%@** from the attachment menu?"; +"WebApp.RemoveConfirmationTitle" = "Remove Bot"; +"WebApp.SelectChat" = "Select Chat"; +"WebApp.Send" = "Send"; +"WebApp.Settings" = "Settings"; +"WebApp.Share" = "Share"; +"WebApp.ShareMessage.Info" = "%@ mini app suggests you to send this message to a chat you select."; +"WebApp.ShareMessage.PreviewTitle" = "MESSAGE PREVIEW"; +"WebApp.ShareMessage.Share" = "Share With..."; +"WebApp.ShareMessage.Title" = "Share Message"; +"WebApp.ShareMyPhoneNumber" = "Share My Phone Number"; +"WebApp.ShareMyPhoneNumberConfirmation" = "Are you sure you want to share your phone number **%1$@** with **%2$@**?"; +"WebApp.SharePhoneConfirmation" = "**%@** will know your phone number. This can be useful for integration with other services."; +"WebApp.SharePhoneConfirmationUnblock" = "**%@** will know your phone number. This can be useful for integration with other services.\n\nThis will also unblock the bot."; +"WebApp.SharePhoneTitle" = "Share Phone Number?"; +"WebApp.ShortcutsAdded" = "**%@** shortcut added in attachment menu."; +"WebApp.ShortcutsSettingsAdded" = "**%@** shortcuts added in attachment menu and Settings."; +"WebApp.TermsOfUse" = "Terms of Use"; +"WebApp.TermsOfUse_URL" = "https://telegram.org/tos/mini-apps"; +"WebBrowser.AddBookmark" = "Add Bookmark"; +"WebBrowser.AddressBar.Bookmarks" = "BOOKMARKS"; +"WebBrowser.AddressBar.RecentlyVisited" = "RECENTLY VISITED"; +"WebBrowser.AddressBar.RecentlyVisited.Clear" = "Clear"; +"WebBrowser.AddressBar.ShowMore" = "Show More"; +"WebBrowser.AddressPlaceholder" = "Enter URL"; +"WebBrowser.AuthChallenge.Text" = "Your login information will be sent securely."; +"WebBrowser.AuthChallenge.Title" = "Sign in to %@"; +"WebBrowser.AutoLogin" = "Auto-Login via Telegram"; +"WebBrowser.AutoLogin.Info" = "Use your Telegram account to automatically log in to websites opened with the in-app browser."; +"WebBrowser.Bookmarks.BookmarkCurrent" = "Bookmark Current Page"; +"WebBrowser.Bookmarks.Title" = "Bookmarks"; +"WebBrowser.ClearCache" = "Clear Cache"; +"WebBrowser.ClearCache.ClearConfirmation.Clear" = "Clear"; +"WebBrowser.ClearCache.ClearConfirmation.Text" = "Are you sure you want to clear cache?"; +"WebBrowser.ClearCache.Succeed" = "Cache cleared."; +"WebBrowser.ClearCookies" = "Clear Cookies"; +"WebBrowser.ClearCookies.ClearConfirmation.Clear" = "Clear"; +"WebBrowser.ClearCookies.ClearConfirmation.Text" = "Are you sure you want to clear cookies?"; +"WebBrowser.ClearCookies.Info" = "Delete all cookies in the Telegram in-app browser. This action will sign you out of most websites."; +"WebBrowser.ClearCookies.Succeed" = "Cookies cleared."; +"WebBrowser.CopyLink" = "Copy Link"; +"WebBrowser.DefaultBrowser" = "DEFAULT WEB BROWSER"; +"WebBrowser.DeleteBookmark" = "Delete Bookmark"; +"WebBrowser.Done" = "Done"; +"WebBrowser.Download.Confirmation" = "Do you want to download \"%@\"?"; +"WebBrowser.Download.Download" = "Download"; +"WebBrowser.Exception.OpenInApp" = "Always open this site in-app"; +"WebBrowser.Exception.OpenInBrowser" = "Always open this site in browser"; +"WebBrowser.ExceptionAdded.Text" = "This site will always open in-app."; +"WebBrowser.ExceptionAdded.Title" = "Exception Added"; +"WebBrowser.Exceptions.AddException" = "Add Website"; +"WebBrowser.Exceptions.Clear" = "Clear List"; +"WebBrowser.Exceptions.ClearConfirmation.Clear" = "Clear"; +"WebBrowser.Exceptions.ClearConfirmation.Text" = "Are you sure you want to clear this list?"; +"WebBrowser.Exceptions.Create.ExternalText" = "Enter a domain that you want to be opened in the in-app browser."; +"WebBrowser.Exceptions.Create.Placeholder" = "Enter URL"; +"WebBrowser.Exceptions.Create.Text" = "Enter a domain that you don't want to be opened in the in-app browser."; +"WebBrowser.Exceptions.Create.Title" = "Add Website"; +"WebBrowser.Exceptions.DeleteAll" = "Delete All Exceptions"; +"WebBrowser.Exceptions.DontOpenInApp" = "DON'T OPEN IN-APP"; +"WebBrowser.Exceptions.InAppInfo" = "These sites will still be opened in-app."; +"WebBrowser.Exceptions.Info" = "These websites will be always opened in your default browser."; +"WebBrowser.Exceptions.OpenInApp" = "OPEN IN-APP"; +"WebBrowser.Exceptions.Title" = "NEVER OPEN IN THE IN-APP BROWSER"; +"WebBrowser.FileForwardTooltip.Chat.One" = "Document forwarded to **%@**"; +"WebBrowser.FileForwardTooltip.ManyChats.One" = "Document forwarded to **%@** and %@ others"; +"WebBrowser.FileForwardTooltip.SavedMessages.One" = "Document forwarded to **Saved Messages**"; +"WebBrowser.FileForwardTooltip.TwoChats.One" = "Document forwarded to **%@** and **%@**"; +"WebBrowser.HideInstantView" = "Hide Instant View"; +"WebBrowser.InAppSafari" = "In-App Safari"; +"WebBrowser.LinkAddedToBookmarks" = "Link added to [Bookmarks]() and **Saved Messages**."; +"WebBrowser.LinkForwardTooltip.Chat.One" = "Link forwarded to **%@**"; +"WebBrowser.LinkForwardTooltip.ManyChats.One" = "Link forwarded to **%@** and %@ others"; +"WebBrowser.LinkForwardTooltip.SavedMessages.One" = "Link forwarded to **Saved Messages**"; +"WebBrowser.LinkForwardTooltip.TwoChats.One" = "Link forwarded to **%@** and **%@**"; +"WebBrowser.OpenLinksIn.Title" = "OPEN LINKS IN"; +"WebBrowser.OpenLinksInfo" = "Open links inside Telegram instead of your default browser for more privacy."; +"WebBrowser.PassExistsError" = "This pass is already added to Wallet."; +"WebBrowser.Reload" = "Reload"; +"WebBrowser.RemoveRecent" = "Remove from Recent"; +"WebBrowser.Share" = "Share"; +"WebBrowser.ShowInstantView" = "Show Instant View"; +"WebBrowser.Telegram" = "Telegram"; +"WebBrowser.Title" = "Web Browser"; +"WebPreview.GettingLinkInfo" = "Getting Link Info..."; +"WebSearch.GIFs" = "GIFs"; +"WebSearch.Images" = "Images"; +"WebSearch.RecentClearConfirmation" = "Are you sure you want to clear recent images?"; +"WebSearch.RecentSectionClear" = "Clear"; +"WebSearch.RecentSectionTitle" = "Recent"; +"WebSearch.SearchNoResults" = "No Results"; +"WebSearch.SearchNoResultsDescription" = "There were no results for \"%@\".\nTry a new search."; +"Weekday.Friday" = "Friday"; +"Weekday.Monday" = "Monday"; +"Weekday.Saturday" = "Saturday"; +"Weekday.ShortFriday" = "Fri"; +"Weekday.ShortMonday" = "Mon"; +"Weekday.ShortSaturday" = "Sat"; +"Weekday.ShortSunday" = "Sun"; +"Weekday.ShortThursday" = "Thu"; +"Weekday.ShortTuesday" = "Tue"; +"Weekday.ShortWednesday" = "Wed"; +"Weekday.Sunday" = "Sunday"; +"Weekday.Thursday" = "Thursday"; +"Weekday.Today" = "Today"; +"Weekday.Tuesday" = "Tuesday"; +"Weekday.Wednesday" = "Wednesday"; +"Weekday.Yesterday" = "Yesterday"; +"Widget.ApplicationLocked" = "Unlock the app to use the widget"; +"Widget.ApplicationStartRequired" = "Open the app to use the widget"; +"Widget.AuthRequired" = "Log In to Telegram"; +"Widget.ChatsGalleryDescription" = "Display the latest message from the most important chats."; +"Widget.ChatsGalleryTitle" = "Chats"; +"Widget.LongTapToEdit" = "Tap or hold to edit widget."; +"Widget.MessageAutoremoveTimerRemoved" = "Auto-delete timer disabled"; +"Widget.MessageAutoremoveTimerUpdated" = "Auto-delete timer updated"; +"Widget.NoUsers" = "Start messaging to see your friends here"; +"Widget.ShortcutsGalleryDescription" = "Display shortcuts of your most important chats to always have quick access to them."; +"Widget.ShortcutsGalleryTitle" = "Shortcuts"; +"Widget.UpdatedAt" = "Updated {}"; +"Widget.UpdatedTodayAt" = "Updated at {}"; +"Your_card_has_expired" = "Your card has expired."; +"Your_card_was_declined" = "Your card was declined."; +"Your_cards_expiration_month_is_invalid" = "You've entered an invalid expiration month."; +"Your_cards_expiration_year_is_invalid" = "You've entered an invalid expiration year."; +"Your_cards_number_is_invalid" = "You've entered an invalid card number."; +"Your_cards_security_code_is_invalid" = "You've entered an invalid security code."; diff --git a/data/langpack/ios/ios_zh-hans_v21265098.strings b/data/langpack/ios/ios_zh-hans_v21265098.strings new file mode 100644 index 00000000..8c115754 --- /dev/null +++ b/data/langpack/ios/ios_zh-hans_v21265098.strings @@ -0,0 +1,11721 @@ +"AUTH_REGION" = "从新的设备登录 %1$@,位置:%2$@"; +"AUTH_REGION_SEPARATED" = "新登录|来自未知设备 %1$@,位置: %2$@"; +"AUTH_UNKNOWN_SEPARATED" = "新设备登陆|无法识别的设备 %1$@"; +"AccentColor.Title" = "强调色"; +"AccessDenied.AgeVerificationCamera" = "Telegram 需要访问您的相机以进行年龄验证。 \n\n打开您设备的设置> 隐私权 > 相机,并将 Telegram 设置为开启。"; +"AccessDenied.CallMicrophone" = "Telegram 需要访问您的麦克风,以便您进行语音通话。\n\n请在您的设备上打开“设置” > “隐私” > “麦克风”,然后将 Telegram 的权限打开。"; +"AccessDenied.Camera" = "Telegram 需要访问您的相机,以便您拍照或录像。\n\n请在您的设备上打开“设置” > “隐私” > “相机”,然后将 Telegram 的权限打开。"; +"AccessDenied.CameraDisabled" = "此设备已被禁止使用相机。\n\n请前往「设置 - 通用 - 访问限制」中打开「相机」的开关。"; +"AccessDenied.CameraRestricted" = "Telegram 无法访问相机。\n\n请在您的设备上打开“设置” > “通用” > “访问限制” > “相机”,然后将 Telegram 的权限打开。"; +"AccessDenied.Contacts" = "Telegram 基于您已有的联系人列表与他人通讯。\n\n请在您的设备上打开“设置” > “隐私” > “通讯录”,然后将 Telegram 的权限打开。"; +"AccessDenied.LocationAlwaysDenied" = "如果您想要和朋友共享实时位置信息,只有授予 Telegram 定位服务权限才能在应用处于后台时依旧能保持共享。\n\n请前往「设置 - 隐私 - 定位服务」中将 Telegram 设置为“总是”。"; +"AccessDenied.LocationDenied" = "Telegram 需要访问您的位置信息,以便您将其分享给联系人。\n\n请在您的设备上打开“设置” > “隐私” > “定位服务”,然后将 Telegram 的权限打开。"; +"AccessDenied.LocationDisabled" = "Telegram 需要访问您的位置信息,以便您将其分享给联系人。\n\n请在您的设备上打开“设置” > “隐私” > “定位服务”,然后将 Telegram 的权限打开。"; +"AccessDenied.LocationPreciseDenied" = "要在此聊天中分享您的具体位置,请在设置 > 隐私 > 定位服务 > Telegram 中开启精确位置。"; +"AccessDenied.LocationTracking" = "Telegram 需要访问您的位置信息,以便您与朋友分享位置。\n\n请在您的设备上打开“设置” > “隐私” > “定位服务”,然后将 Telegram 的权限打开。"; +"AccessDenied.LocationWeather" = "Telegram 需要访问您的位置,以便您可以将天气微件添加到动态中。\n\n请前往“设置” > “隐私” > “定位服务”,并将 Telegram 设定为开启。"; +"AccessDenied.MicrophoneRestricted" = "Telegram 无法访问麦克风。\n\n请在您的设备上打开“设置” > “通用” > “访问限制” > “麦克风”,然后将 Telegram 的权限打开。"; +"AccessDenied.PhotosAndVideos" = "Telegram 需要访问您的相册,以便您发送图片或视频。\n\n请在您的设备上打开“设置” > “隐私” > “照片”,然后将 Telegram 的权限打开。"; +"AccessDenied.PhotosRestricted" = "Telegram 无法访问照片。\n\n请前往「设置 - 隐私 - 照片」中开启 Telegram 权限。"; +"AccessDenied.QrCamera" = "Telegram 需要摄像头权限以扫描二维码。\n\n请前往设备设置 > 隐私 > 相机,将 Telegram 权限设为开启。"; +"AccessDenied.QrCode" = "Telegram 需要访问您的相册,以便您扫描二维码。\n请在您的设备上打开“设置” > “隐私” > “照片”,然后将 Telegram 的权限打开。"; +"AccessDenied.SaveMedia" = "Telegram 需要访问您的相册,以便您保存图片或视频。\n\n请在您的设备上打开“设置” > “隐私” > “照片”,然后将 Telegram 的权限打开。"; +"AccessDenied.Settings" = "设置"; +"AccessDenied.Title" = "请授权访问"; +"AccessDenied.VideoCallCamera" = "Telegram 需要访问您的相机,以便您进行视频通话。\n\n请在您的设备上打开“设置” > “隐私” > “相机”,然后将 Telegram 的权限打开。"; +"AccessDenied.VideoMessageCamera" = "Telegram 需要访问您的相机,以便您录制视频信息。\n\n请在您的设备上打开“设置” > “隐私” > “相机”,然后将 Telegram 的权限打开。"; +"AccessDenied.VideoMessageMicrophone" = "Telegram 需要访问您的麦克风,以便您发送视频消息。\n\n请在您的设备上打开“设置” > “隐私” > “麦克风”,然后将 Telegram 的权限打开。"; +"AccessDenied.VideoMicrophone" = "Telegram 需要访问您的麦克风,以便您在视频中录制声音。\n\n请在您的设备上打开“设置” > “隐私” > “麦克风”,然后将 Telegram 的权限打开。"; +"AccessDenied.VoiceMicrophone" = "Telegram 需要访问您的麦克风,以便您发送语音消息。\n\n请在您的设备上打开“设置” > “隐私” > “麦克风”,然后将 Telegram 的权限打开。"; +"AccessDenied.Wallpapers" = "Telegram 需要访问您的相册,以便您可以设置自定义壁纸。\n\n请在您的设备上打开“设置” > “隐私” > “照片”,然后将 Telegram 的权限打开。"; +"Activity.ChoosingSticker" = "正在选择表情"; +"Activity.EnjoyingAnimations" = "正在观看 %@"; +"Activity.PlayingGame" = "正在玩游戏"; +"Activity.RecordingAudio" = "正在录音"; +"Activity.RecordingVideoMessage" = "正在录制视频"; +"Activity.TappingInteractiveEmoji" = "正在点击%@"; +"Activity.UploadingDocument" = "正在发送文件"; +"Activity.UploadingPhoto" = "正在发送图片"; +"Activity.UploadingVideo" = "正在发送视频"; +"Activity.UploadingVideoMessage" = "正在发送视频"; +"AddContact.AddQR" = "通过二维码添加"; +"AddContact.ContactWillBeSharedAfterMutual" = "一旦 %1$@ 将您添加为联系人,手机号码将对其可见。"; +"AddContact.NotePlaceholder" = "添加仅您可见的备注"; +"AddContact.PhoneNumber.IsContact" = "此电话号码已保存在您的联系人中。 [查看 >]()"; +"AddContact.PhoneNumber.NotRegistered" = "此号码不在 Telegram 上。[邀请 >]()"; +"AddContact.PhoneNumber.Registered" = "此号码在 Telegram 上。"; +"AddContact.SharedContactException" = "分享我的手机号码"; +"AddContact.SharedContactExceptionInfo" = "您的手机号码将对 %@ 可见。"; +"AddContact.StatusSuccess" = "%@ 已成为您的联系人。"; +"AddContact.SyncToPhone" = "将联系人同步到手机"; +"AddContact.Title" = "新联系人"; +"AddGifts.AddGifts#other" = "添加 %@ 份礼物"; +"AddGifts.Title" = "添加礼物"; +"AdsInfo.Ads.Text" = "您可以通过订阅 [Telegram 高级版]()关闭广告,而等级为 %@ 的频道可以为其订阅者移除这些广告。"; +"AdsInfo.Ads.Title" = "可以移除"; +"AdsInfo.Bot.Ads.Text" = "您可以通过订阅 [Telegram 高级版]() 关闭广告。"; +"AdsInfo.Bot.Launch.Text" = "任何人都可以创建广告并在此机器人中显示—只需最少的预算。请查看 Telegram 广告平台了解详情。[了解更多 >]()"; +"AdsInfo.Bot.Respect.Text" = "Telegram 上的广告不会使用您的个人资料,而是仅基于您看到广告的迷你应用内容。"; +"AdsInfo.Bot.Split.Text" = " 广告收入的 50% 将归广告机器人的所有者所有。"; +"AdsInfo.Bot.Split.Title" = "帮助机器人开发者"; +"AdsInfo.Info" = "Telegram 广告与其他平台上的广告大有不同。这类广告:"; +"AdsInfo.Launch.Text" = "任何人都可以用极低的预算在这个频道投放广告。详情请查看 Telegram 广告平台。 [了解更多 >]()"; +"AdsInfo.Launch.Text_URL" = "https://ads.telegram.org"; +"AdsInfo.Launch.Title" = "我可以投放广告吗?"; +"AdsInfo.Respect.Text" = "Telegram 上的广告不会使用您的个人信息,而是基于您所在的频道来展示的。"; +"AdsInfo.Respect.Title" = "尊重您的隐私"; +"AdsInfo.Search.Ads.Text" = "您可以通过订阅 [Telegram 高级版]() 来关闭广告。"; +"AdsInfo.Search.Launch.Text" = "任何人都可以创建广告并在任何搜索查询的结果中显示,查看 Telegram 广告平台了解详情。 [了解更多 >]()"; +"AdsInfo.Search.Respect.Text" = "此类广告不会使用您的个人信息,仅基于您输入的搜索关键词显示。"; +"AdsInfo.Split.Text" = "Telegram 广告收入的 50% 将归广告频道的所有者所有。"; +"AdsInfo.Split.Title" = "帮助频道创作者"; +"AdsInfo.Title" = "关于这些广告"; +"AdsInfo.Understood" = "已知悉"; +"AffiliateProgram.ActionCopyLink" = "复制链接"; +"AffiliateProgram.ActionJoin" = "加入计划"; +"AffiliateProgram.CommistionDestinationText" = "佣金将会发送至:"; +"AffiliateProgram.DailyRevenueText" = "应用的每用户日均收入:#**%@**"; +"AffiliateProgram.DurationLifetime" = "终身"; +"AffiliateProgram.JoinSubtitleLifetime" = "**{bot}** 将**永久**与您分享每位您推荐的用户所产生收入的 **{commission}**。"; +"AffiliateProgram.JoinSubtitleMonths#other" = "**{bot}** 将与您分享每位您推荐的用户在 **%d 个月内** 产生收入的 **{commission}**。"; +"AffiliateProgram.JoinSubtitleYears#other" = "**{bot}** 将在 **%d 年** 内与您分享每位您推荐的用户所产生收入的 **{commission}**。"; +"AffiliateProgram.JoinTerms" = "加入此计划即表示您同意联盟计划的 [条款和条件](https://telegram.org/tos/affiliate-program)。"; +"AffiliateProgram.JoinTitle" = "联盟计划"; +"AffiliateProgram.LinkSubtitleLifetime" = "与您的用户分享此链接。在他们点击链接后,您将 **永久** 获得他们在 **{bot}** 中消费金额的 **{commission}** 作为佣金。"; +"AffiliateProgram.LinkSubtitleMonths#other" = "与你的用户分享此链接,以便他们在点击你的点击后,你可以在 **%d 个月**内从 **{bot}** 赚取 **{commission}** 佣金。"; +"AffiliateProgram.LinkSubtitleYears#other" = "与您的用户分享此链接,在他们点击链接后的 **%d 年** 内,您可以获得他们在 **{bot}** 中消费金额的 **{commission}** 作为佣金。"; +"AffiliateProgram.LinkTitle" = "推荐链接"; +"AffiliateProgram.OpenBot" = "查看 %@"; +"AffiliateProgram.PeerTypeSelf" = "个人账号"; +"AffiliateProgram.SortSelectorDate" = "日期"; +"AffiliateProgram.SortSelectorProfitability" = "利润率"; +"AffiliateProgram.SortSelectorRevenue" = "收入"; +"AffiliateProgram.ToastJoined.Text" = "现在你可以复制推荐链接。"; +"AffiliateProgram.ToastJoined.Title" = "加入计划"; +"AffiliateProgram.ToastLinkCopied.Text" = "分享此链接并赚取推荐用户在 **%2$@** 中消费的 **%1$@**!"; +"AffiliateProgram.ToastLinkCopied.Title" = "链接已复制到剪贴板"; +"AffiliateProgram.UserCountFooter#other" = "%d 位用户通过此链接打开了 {bot}。"; +"AffiliateProgram.ValueLongMonths#other" = "%@ 个月"; +"AffiliateProgram.ValueLongYears#other" = "%@ 年"; +"AffiliateProgram.ValueShortMonths#other" = "%@个月"; +"AffiliateProgram.ValueShortYears#other" = "%@年"; +"AffiliateSetup.AlertApply.Action" = "启动"; +"AffiliateSetup.AlertApply.SectionCommission" = "佣金"; +"AffiliateSetup.AlertApply.SectionDuration" = "期限"; +"AffiliateSetup.AlertApply.Text" = "一旦您启动联盟计划,就无法再降低佣金比例或有效期限。您只提高这些参数或终止计划,而终止计划将导致所有之前分发的推荐链接失效。"; +"AffiliateSetup.AlertApply.Title" = "警报"; +"AffiliateSetup.AlertTerminate.Action" = "仍然结束"; +"AffiliateSetup.AlertTerminate.Text" = "如果您终止联盟计划:\n\n• 已分享的推广链接将在 24 小时后失效。\n\n• 所有参与的合作者都将收到通知。\n\n• 您需等待 24 小时后才能开启新的联盟计划。"; +"AffiliateSetup.AlertTerminate.Title" = "警告"; +"AffiliateSetup.ConnectedSectionTitle" = "我的计划"; +"AffiliateSetup.EndAction" = "结束联盟计划"; +"AffiliateSetup.ExistingPrograms.Action" = "查看现有计划"; +"AffiliateSetup.ExistingPrograms.Footer" = "检索其他小程序提供的功能。"; +"AffiliateSetup.IntroJoin.Text1" = "你推荐的用户消费中获得确保的佣金。"; +"AffiliateSetup.IntroJoin.Text2" = "实时追踪您从推荐用户处获得的佣金。"; +"AffiliateSetup.IntroJoin.Text3" = "从下方选择小程序,获取您的推荐链接,开始赚取星星。"; +"AffiliateSetup.IntroJoin.Title1" = "可靠"; +"AffiliateSetup.IntroJoin.Title2" = "透明"; +"AffiliateSetup.IntroJoin.Title3" = "简单"; +"AffiliateSetup.IntroNew.Text1" = "设置由您推荐的用户产生的收入佣金。"; +"AffiliateSetup.IntroNew.Text2" = "Telegram 将为数百万潜在合作者展示您的计划。"; +"AffiliateSetup.IntroNew.Text3" = "合作者将与他们的订阅者分享您的推荐链接。"; +"AffiliateSetup.IntroNew.Title1" = "与合作者分享收入"; +"AffiliateSetup.IntroNew.Title2" = "启动你的联盟计划"; +"AffiliateSetup.IntroNew.Title3" = "通过联盟计划推广你"; +"AffiliateSetup.ProgramLeave" = "离开"; +"AffiliateSetup.ProgramMenu.CopyLink" = "复制链接"; +"AffiliateSetup.ProgramMenu.Leave" = "退出"; +"AffiliateSetup.ProgramMenu.OpenApp" = "打开应用"; +"AffiliateSetup.ProgramMenu.OpenBot" = "打开机器人"; +"AffiliateSetup.SectionCommission" = "佣金"; +"AffiliateSetup.SectionCommissionFooter" = "设定您的合作者透过推荐用户使用您的机器人所能赚取的星星收入分成比例。"; +"AffiliateSetup.SectionDuration" = "期限"; +"AffiliateSetup.SectionDurationFooter" = "设置合作方从推荐用户赚取佣金的期限。"; +"AffiliateSetup.SortSectionHeader.Date" = "按 [日期]() 排序"; +"AffiliateSetup.SortSectionHeader.Profitability" = "按 [盈利能力]() 排序"; +"AffiliateSetup.SortSectionHeader.Revenue" = "按 [收入]() 排序"; +"AffiliateSetup.StartTitle" = "启动联盟计划"; +"AffiliateSetup.SuggestedSectionEmpty" = "暂无可用计划。\n请稍后再查看此页面。"; +"AffiliateSetup.SuggestedSectionTitle" = "计划"; +"AffiliateSetup.TermsFooter" = "创建联盟计划即表示您同意联盟计划的 [条款和条件] (https://telegram.org/terms)。"; +"AffiliateSetup.TextJoin" = "每次用户通过您的推荐链接首次访问小程序并在其中消费 **星星** 时,您都会获得佣金。"; +"AffiliateSetup.TextNew" = "奖励那些帮助您扩大用户群的合作者"; +"AffiliateSetup.TitleJoin" = "联盟计划"; +"AffiliateSetup.TitleNew" = "联盟计划"; +"AffiliateSetup.ToastTerminated.Text" = "已通知参与联盟计划的成员,所有推荐链接将在 24 小时后失效。"; +"AffiliateSetup.ToastTerminated.Title" = "联盟计划已结束"; +"AffiliateSetup.UpdateTitle" = "更新联盟计划"; +"AgeVerification.Fail.Text" = "抱歉, 您无法查看此内容。"; +"AgeVerification.Fail.Title" = "年龄验证失败"; +"AgeVerification.Success.Text" = "您现在可以查看此内容。"; +"AgeVerification.Success.Title" = "已经通过年龄验证!"; +"AgeVerification.Text" = "要访问此类内容,您必须确认您已年满**18**岁。 \n\n这是通过使用手机相机的一次性操作。Telegram 不会储存您的自拍照。"; +"AgeVerification.Text.GB" = "要访问此类内容,您必须根据英国法律确认您已年满 **18** 岁。 \n\n这是通过使用手机相机的一次性操作。Telegram 不会储存您的自拍照。"; +"AgeVerification.Title" = "年龄验证"; +"AgeVerification.Unavailable.Text" = "此媒体可能包含仅适合成人的敏感内容。"; +"AgeVerification.Unavailable.Title" = "18+"; +"AgeVerification.Verify" = "验证我的年龄"; +"Appearance.AccentColor" = "主题色"; +"Appearance.Animations" = "动画"; +"Appearance.AppIcon" = "应用图标"; +"Appearance.AppIconBlack" = "暗黑"; +"Appearance.AppIconClassic" = "经典"; +"Appearance.AppIconClassicX" = "经典黑"; +"Appearance.AppIconDefault" = "默认"; +"Appearance.AppIconDefaultX" = "单色黑"; +"Appearance.AppIconFilled" = "填充"; +"Appearance.AppIconFilledX" = "填色黑"; +"Appearance.AppIconNew1" = "夕阳"; +"Appearance.AppIconNew2" = "水色"; +"Appearance.AppIconPremium" = "高级版"; +"Appearance.AppIconTurbo" = "极速"; +"Appearance.AutoNightTheme" = "自动夜间模式"; +"Appearance.AutoNightThemeDisabled" = "已停用"; +"Appearance.BubbleCorners.AdjustAdjacent" = "调整信息框圆角"; +"Appearance.BubbleCorners.Apply" = "设置"; +"Appearance.BubbleCorners.Title" = "信息框圆角"; +"Appearance.BubbleCornersSetting" = "信息框圆角"; +"Appearance.ColorTheme" = "主题颜色"; +"Appearance.ColorThemeNight" = "色彩主题-自动夜间模式"; +"Appearance.CreateTheme" = "创建新主题"; +"Appearance.CreateThemeInfo" = "已从当前主题创建了一个新的主题模板,并已发送到您的收藏夹中。"; +"Appearance.EditTheme" = "编辑主题"; +"Appearance.LargeEmoji" = "大号表情"; +"Appearance.NameColor" = "您名称颜色"; +"Appearance.NightTheme" = "夜间模式"; +"Appearance.Other" = "其他"; +"Appearance.PickAccentColor" = "选取一种主题色"; +"Appearance.Preview" = "聊天预览"; +"Appearance.PreviewIncomingText" = "你知道现在几点吗?"; +"Appearance.PreviewOutgoingText" = "现在是东京的早上 😎"; +"Appearance.PreviewReplyAuthor" = "鲍勃·哈里斯"; +"Appearance.PreviewReplyText" = "早上好!👋"; +"Appearance.Publish" = "发布"; +"Appearance.ReduceMotion" = "减弱动态效果"; +"Appearance.ReduceMotionInfo" = "在对话框与消息列表中停用动画。"; +"Appearance.RemoveTheme" = "移除"; +"Appearance.RemoveThemeColor" = "移除"; +"Appearance.RemoveThemeColorConfirmation" = "移除颜色"; +"Appearance.RemoveThemeConfirmation" = "移除主题"; +"Appearance.SendWithCmdEnter" = "通过 ⌘+Enter 发送消息"; +"Appearance.ShareTheme" = "分享"; +"Appearance.ShareThemeColor" = "分享"; +"Appearance.ShowNextMediaOnTap" = "点击显示下一个媒体"; +"Appearance.ShowNextMediaOnTapInfo" = "在浏览媒体时,点击靠近屏幕边缘的位置,可以在图片之间导航。"; +"Appearance.TextSize" = "字号大小"; +"Appearance.TextSize.Apply" = "设置"; +"Appearance.TextSize.Automatic" = "系统"; +"Appearance.TextSize.Title" = "字体大小"; +"Appearance.TextSize.UseSystem" = "使用系统字体大小"; +"Appearance.TextSizeSetting" = "字号"; +"Appearance.ThemeCarouselClassic" = "经典"; +"Appearance.ThemeCarouselDay" = "白天"; +"Appearance.ThemeCarouselNewNight" = "夜间"; +"Appearance.ThemeCarouselNight" = "单色调"; +"Appearance.ThemeCarouselNightBlue" = "夜间蓝"; +"Appearance.ThemeCarouselTintedNight" = "深色"; +"Appearance.ThemeDay" = "日间"; +"Appearance.ThemeDayClassic" = "经典日间"; +"Appearance.ThemeNight" = "黑白"; +"Appearance.ThemeNightBlue" = "夜间蓝"; +"Appearance.ThemePreview.Chat.1.Text" = "他是想让我从右转还是从左转?🤔"; +"Appearance.ThemePreview.Chat.2.ReplyName" = "Bob Harris"; +"Appearance.ThemePreview.Chat.2.Text" = "右边。还有,呃,要有力度。"; +"Appearance.ThemePreview.Chat.3.Text" = "就这些了吗?好像他说的比这还多不少。 😯"; +"Appearance.ThemePreview.Chat.3.TextWithLink" = "就这些?他好像不止说了这些话。😯"; +"Appearance.ThemePreview.Chat.4.Text" = "下班了,放轻松点 😎"; +"Appearance.ThemePreview.Chat.5.Text" = "转过头来,看看镜头,好不好?"; +"Appearance.ThemePreview.Chat.6.Text" = "他就说了这些?"; +"Appearance.ThemePreview.Chat.7.Text" = "嗯嗯,看镜头。"; +"Appearance.ThemePreview.ChatList.1.Name" = "Alicia Torreaux"; +"Appearance.ThemePreview.ChatList.1.Text" = "鲍勃说你好。\u00a0😊 ❤️ 😱"; +"Appearance.ThemePreview.ChatList.2.Name" = "Roberto"; +"Appearance.ThemePreview.ChatList.2.Text" = "跟爱丽丝打招呼👋"; +"Appearance.ThemePreview.ChatList.3.AuthorName" = "Jennie Alpha"; +"Appearance.ThemePreview.ChatList.3.Name" = "校园公共聊天群"; +"Appearance.ThemePreview.ChatList.3.Text" = "我们有 2500 个成员了!赞!"; +"Appearance.ThemePreview.ChatList.4.Name" = "Veronica"; +"Appearance.ThemePreview.ChatList.4.Text" = "下午两点打麻将三缺一,别迟到。"; +"Appearance.ThemePreview.ChatList.5.Name" = "动物视频"; +"Appearance.ThemePreview.ChatList.5.Text" = "投票吧!这个频道里要不要多发一些猫猫视频?"; +"Appearance.ThemePreview.ChatList.6.Name" = "小姐妹"; +"Appearance.ThemePreview.ChatList.6.Text" = "先别告诉妈妈,但我找到工作啦!我就要去罗马了!"; +"Appearance.ThemePreview.ChatList.7.Name" = "Jennie Alpha"; +"Appearance.ThemePreview.ChatList.7.Text" = "🖼 看看这些"; +"Appearance.Title" = "外观"; +"Appearance.VoiceOver.Theme" = "%@ 主题"; +"AppearanceSettings.Animations" = "动画"; +"AppleWatch.ReplyPresets" = "预设回复"; +"AppleWatch.ReplyPresetsHelp" = "编写或回复信息时,您可以选择其中一条作为回复,也可将它们改成任意您喜欢的样子。"; +"AppleWatch.Title" = "Apple Watch"; +"Application.Name" = "Telegram"; +"Application.Update" = "升级"; +"ApplyLanguage.ApplyLanguageAction" = "应用语言"; +"ApplyLanguage.ApplySuccess" = "语言已更改"; +"ApplyLanguage.ChangeLanguageAction" = "更改"; +"ApplyLanguage.ChangeLanguageAlreadyActive" = "语言 (%1$@) 已激活。"; +"ApplyLanguage.ChangeLanguageOfficialText" = "您将要应用语言包 (**%1$@**)。\n\n此操作将会应用到整个界面。您可以通过建议进行更正 [翻译平台]()。\n\n您可以随时在“设置”中更改语言。"; +"ApplyLanguage.ChangeLanguageTitle" = "更改语言吗?"; +"ApplyLanguage.ChangeLanguageUnofficialText" = "您将要使用一个完成度为 %2$@% 的自定义语言包(**%1$@**)。\n\n此操作将会翻译整个界面。您可以通过 [翻译平台]() 提出修改建议。\n\n您可以随时在“设置”中更改语言。"; +"ApplyLanguage.LanguageNotSupportedError" = "抱歉,这种语言似乎并不存在。"; +"ApplyLanguage.UnsufficientDataText" = "很遗憾,此自定义语言包(%1$@)不包含 iOS Telegram 的数据。您可以使用[翻译平台]()为此语言包贡献。"; +"ApplyLanguage.UnsufficientDataTitle" = "数据不足"; +"AppointAnotherOwner.Title" = "选择新所有者"; +"Appstore.Cloud" = "**云端**\n无限空间存储聊天、媒体和文档。"; +"Appstore.Cloud.Profile" = "**詹妮弗**\n23岁,设计师,来自旧金山。"; +"Appstore.Creative" = "**创意**\n色彩主题、贴纸、GIF动图、视频消息等"; +"Appstore.Creative.Chat" = "**您**\n丢个骰子\n**您**\n如果你扔到六点去吃烤冷面\n**如花**\n没问题!\n**您**\n好\n等我一下。"; +"Appstore.Creative.Chat.Name" = "**加布里埃拉**"; +"Appstore.Fast" = "**快速**\n简单、可靠、所有设备同步。"; +"Appstore.Fast.Chat1" = "**艾丽西娅-托雷奥**\n鲍勃说你好。"; +"Appstore.Fast.Chat2" = "**罗伯托**\n跟爱丽丝打个招呼。"; +"Appstore.Fast.Chat3" = "**数字游民**\n詹妮 \n我们刚刚达到2500名会员了!WOO!"; +"Appstore.Fast.Chat4" = "**Veronica**\nTable for four, 2 PM. Be there."; +"Appstore.Fast.Chat5" = "**Animal Videos**\nVote now! Moar cat videos in this channel?"; +"Appstore.Fast.Chat6" = "**Little Sister**\nDon't tell mom yet, but I got the job! I'm going to ROME!"; +"Appstore.Fast.Chat7" = "**James**\nCheck these out"; +"Appstore.Fast.Chat8" = "**Study Group**\nEmma\nSticker"; +"Appstore.Fast.Chat9" = "**数字游民**"; +"Appstore.Free.Chat" = "**Jessica**\nPaper airplane is lyfted by...\nWings\nPropeller\n**You**\nIs this from Monday’s test?\n**Harry**\nOnlinePartyPlan.ppd\nLet's get back to planning!\n**You**\n550 MB keynote file??\n**Helene**\nHe added fireworks videos"; +"Appstore.Free.Chat.Name" = "**学习小组**"; +"Appstore.Open" = "**开放**\n免费、无广告,源代码向所有人开放。"; +"Appstore.Powerful" = "**强大**\n群聊及广播形式无限制*"; +"Appstore.Powerful.Chat" = "**詹姆斯**\n早上好!\n\n德维恩加入了这个群组\n\n**你**\n你知道现在几点了吗?\n**罗克珊**\n现在还是早上吗?\n是的!\n不确定\n**艾玛**\n语音\n"; +"Appstore.Private" = "**隐私**\n您的数据永远不会泄露。\n它只在您的掌控之中。"; +"Appstore.Private.Chat" = "**你**\n对猫咪的大小没有限制。"; +"Appstore.Private.Chat.Name" = "**Beatrice**"; +"Appstore.Public" = "**公共服务**\n公开频道、公开群组、机器人集成服务。"; +"Appstore.Public.Chat1" = "**金融时报**\n真相就像太阳。你可以让它暂时缺席,却不能让它永远消失。"; +"Appstore.Public.Chat2" = "**彭博社**\n我们将每天推送你所期待的大新闻。"; +"Appstore.Public.Chat3" = "**健康与安全**\n你是否在寻找新型冠状病毒 (COVID-19)的官方新闻"; +"Appstore.Public.IV" = "我们现在有足够的数据来衡量主要气候解决方案的相对有效性。这个模拟器可以让你看到哪些方案最行之有效。\n\n彭博社\n\n减缓全球变暖的最佳方法?在这个气候模拟器中由你来定\n正是在2016年的地球日,170多个国家签署了《巴黎协定》,要求将全球变暖限制在 \"远低于2℃\"。"; +"Appstore.Secure" = "**安全**\n所有对话均受强加密保护。"; +"Appstore.Secure.Chat" = "**老妹儿**\n送啥给咱妈?\n**You**狗!\n**You**我认真的,送条小狗吧。\n**You**\n我看到了!!!\n**老妹儿**\n我需要证据!"; +"Appstore.Secure.Chat.Name" = "**小姐妹**"; +"Appstore.new.Cloud.bio" = "23 y.o. pianist from Vienna"; +"Appstore.new.Cloud.name" = "Roxanne"; +"Appstore.new.Cloud.subtitle" = "无限聊天、媒体和\n文件的储存空间。"; +"Appstore.new.Cloud.title" = "基于云端"; +"Appstore.new.Creative.message1" = "Send a dice emoji to roll a die!"; +"Appstore.new.Creative.message2" = "Advance to Illinois Ave. If you pass Go, collect coffee"; +"Appstore.new.Creative.message3" = "Passed!"; +"Appstore.new.Creative.message4" = "Okay"; +"Appstore.new.Creative.message5" = "Wait for me there."; +"Appstore.new.Creative.name" = "Emma Torreaux"; +"Appstore.new.Creative.subtitle" = "Color themes, stickers, GIFs,\nvideo messages and more."; +"Appstore.new.Creative.title" = "创意"; +"Appstore.new.Fast.chat1.message" = "Bob says hi."; +"Appstore.new.Fast.chat1.name" = "Emma Torreaux"; +"Appstore.new.Fast.chat2.message" = "Say hello to Emma."; +"Appstore.new.Fast.chat2.name" = "Roberto"; +"Appstore.new.Fast.chat3.message" = "8Bit Times started a Live Stream"; +"Appstore.new.Fast.chat3.name" = "8Bit Times"; +"Appstore.new.Fast.chat4.group" = "Digital Nomads"; +"Appstore.new.Fast.chat4.message" = "We just reached 2,500 members! WOO!"; +"Appstore.new.Fast.chat4.name" = "Jennie"; +"Appstore.new.Fast.chat5.message" = "Table for four, 2 PM. Be there."; +"Appstore.new.Fast.chat5.name" = "Penelope"; +"Appstore.new.Fast.chat6.message" = "Check these out!"; +"Appstore.new.Fast.chat6.name" = "James"; +"Appstore.new.Fast.subtitle" = "简单、可靠,并且可\n在所有设备上同步。"; +"Appstore.new.Fast.title" = "快速"; +"Appstore.new.Powerful.chat1.message" = "Good morning!"; +"Appstore.new.Powerful.chat1.name" = "James"; +"Appstore.new.Powerful.chat2.message" = "Do you have any idea what time it is?"; +"Appstore.new.Powerful.chat2.name" = "You"; +"Appstore.new.Powerful.chat3.name" = "Roxanne"; +"Appstore.new.Powerful.chat3.option1" = "Sure!"; +"Appstore.new.Powerful.chat3.option2" = "Not sure"; +"Appstore.new.Powerful.chat3.poll" = "Is it still morning?"; +"Appstore.new.Powerful.chat4.name" = "Emma"; +"Appstore.new.Powerful.group" = "Digital Nomads"; +"Appstore.new.Powerful.join" = "Dwayne joined the group"; +"Appstore.new.Powerful.subtitle" = "No limits on the size of\ngroups and broadcasts."; +"Appstore.new.Powerful.title" = "强大"; +"Appstore.new.Private.emojipack" = "Animated Emoji"; +"Appstore.new.Private.message" = "No limits on the size of your cats."; +"Appstore.new.Private.name" = "Penelope"; +"Appstore.new.Private.subtitle" = "Your data is never disclosed.\nOnly you are in control."; +"Appstore.new.Private.title" = "隐私"; +"Appstore.new.Public.chat1.message" = "US Commerce Secretary Howard Lutnick says a trade deal with India did not materialize last year because.."; +"Appstore.new.Public.chat1.name" = "Bloomberg"; +"Appstore.new.Public.chat2.message" = "There's nothing better than grouse hunting in Maine with your dogs.."; +"Appstore.new.Public.chat2.name" = "Tucker Carlson"; +"Appstore.new.Public.chat3.message" = "Bounce with it"; +"Appstore.new.Public.chat3.name" = "Snoop Dogg"; +"Appstore.new.Public.iv.link" = "ft.com"; +"Appstore.new.Public.iv.name" = "Financial Times"; +"Appstore.new.Public.iv.text" = "Findings reinforce warnings from policymakers such as Christine Lagarde that internal market has stood still"; +"Appstore.new.Public.iv.title" = "Trade between EU member states is slowing, data shows"; +"Appstore.new.Public.subtitle" = "Public channels, open groups,\nbots for integrations."; +"Appstore.new.Public.title" = "公开"; +"Appstore.new.Secure.chat1.name" = "Little Sister"; +"Appstore.new.Secure.message1" = "Any gift ideas for mom?"; +"Appstore.new.Secure.message2" = "A dog!"; +"Appstore.new.Secure.message3" = "I'm serious. Let's get her a puppy."; +"Appstore.new.Secure.message4" = "I saw this!"; +"Appstore.new.Secure.message5" = "I needed proof this was your idea!"; +"Appstore.new.Secure.subtitle" = "All chats are protected\nwith strong encryption."; +"Appstore.new.Secure.title" = "安全"; +"ArchiveInfo.ChatsText" = "滑动任意聊天可以将其移至或移出归档。"; +"ArchiveInfo.ChatsTitle" = "归档的聊天"; +"ArchiveInfo.CloseAction" = "明白了"; +"ArchiveInfo.HideText" = "在主屏幕上滑动可以隐藏归档。"; +"ArchiveInfo.HideTitle" = "隐藏归档"; +"ArchiveInfo.StoriesText" = "将联系人的动态和聊天分开归档。"; +"ArchiveInfo.StoriesTitle" = "动态"; +"ArchiveInfo.TextKeepArchivedDefault" = "收到一条新消息时,已静音的聊天将保留在归档中,未静音的聊天则会被移动至“聊天”界面。[点击更改 >]()"; +"ArchiveInfo.TextKeepArchivedUnmuted" = "收到一条新消息时,已归档的聊天将继续保留在归档中。[点击更改 >]()"; +"ArchiveInfo.Title" = "这是您的归档内容"; +"ArchiveSettings.AutomaticallyArchive" = "自动归档"; +"ArchiveSettings.FolderChatsFooter" = "即使已将聊天取消静音并收到新消息,仍将文件夹中的已归档聊天保留在归档中。"; +"ArchiveSettings.FolderChatsHeader" = "文件夹中的聊天"; +"ArchiveSettings.KeepArchived" = "始终保留在归档中"; +"ArchiveSettings.Title" = "归档设置"; +"ArchiveSettings.TooltipPremiumRequired" = "此设置仅向 [Telegram 高级版]()的订阅者开放。"; +"ArchiveSettings.UnknownChatsFooter" = "自动将非联系人的新私聊、群组和频道归档并静音。"; +"ArchiveSettings.UnknownChatsHeader" = "来自未知用户的新聊天"; +"ArchiveSettings.UnmutedChatsFooter" = "即使已将聊天取消静音并收到新消息,仍将归档的聊天保留在存档中。"; +"ArchiveSettings.UnmutedChatsHeader" = "未静音的聊天"; +"ArchivedChats.IntroText1" = "收到新消息时,已开启通知的聊天将自动取消归档。"; +"ArchivedChats.IntroText2" = "收到新消息时,停用通知的聊天将被保持归档。"; +"ArchivedChats.IntroText3" = "您可以将任意数量的归档聊天置顶。"; +"ArchivedChats.IntroTitle1" = "这是你的归档"; +"ArchivedChats.IntroTitle2" = "已静音的聊天"; +"ArchivedChats.IntroTitle3" = "已置顶聊天"; +"ArchivedPacksAlert.Title" = "您的一些旧贴纸包已经被归档。您可以在「贴纸」设置里重新激活它们。"; +"Attachment.AllMedia" = "全部"; +"Attachment.Audio" = "音频"; +"Attachment.BoostToUnlock" = "助推以解锁"; +"Attachment.Camera" = "相机"; +"Attachment.CameraAccessText" = "Telegram 需要访问相机以拍照和录像。"; +"Attachment.CancelSelectionAlertNo" = "否"; +"Attachment.CancelSelectionAlertText" = "是否要取消选择?"; +"Attachment.CancelSelectionAlertYes" = "是"; +"Attachment.ChatsMusic" = "您的聊天"; +"Attachment.Contact" = "联系人"; +"Attachment.Create" = "创建贴纸"; +"Attachment.DeselectedItems#other" = "已取消选择 %@ 个项目。"; +"Attachment.DeselectedPhotos#other" = "已取消选择 %@ 张图片。"; +"Attachment.DeselectedVideos#other" = "已取消选择 %@ 个视频。"; +"Attachment.DisableSpoiler" = "取消遮罩"; +"Attachment.DiscardPasteboardAlertText" = "清除粘贴的项目?"; +"Attachment.DiscardTodoAlertText" = "放弃清单选项吗?"; +"Attachment.DragToReorder" = "拖动媒体以重新排序"; +"Attachment.EnableSpoiler" = "罩住图片"; +"Attachment.File" = "文件"; +"Attachment.File.PollSubtitle.Description" = "向投票描述添加文件"; +"Attachment.File.PollSubtitle.Explanation" = "向问答说明添加文件"; +"Attachment.FilesIntro" = "收发任意类型的文件,每个可达2GB,在你其他设备上也可快速访问。\n"; +"Attachment.FilesSearchPlaceholder" = "搜索已发送的文件"; +"Attachment.Gallery" = "相册"; +"Attachment.Gift" = "赠礼"; +"Attachment.Grouped" = "合并"; +"Attachment.LimitedMediaAccessText" = "您已限制 Telegram 访问照片。"; +"Attachment.Link" = "链接"; +"Attachment.Location" = "位置"; +"Attachment.LocationAccessText" = "分享地点或实时位置。"; +"Attachment.LocationAccessTitle" = "访问你的位置"; +"Attachment.Manage" = "管理"; +"Attachment.MediaAccessStoryText" = "分享无限数量的图片和视频,每个文件大小限制为 2 GB。"; +"Attachment.MediaAccessText" = "分享无限数目的照片和视频 \n每个高达2 GB。"; +"Attachment.MediaAccessTitle" = "访问你的照片和视频"; +"Attachment.MediaTypes" = "媒体类型"; +"Attachment.MessagePreview" = "消息预览"; +"Attachment.MessagesPreview" = "消息预览"; +"Attachment.MyAlbums" = "我的相册"; +"Attachment.OpenCamera" = "打开摄像头"; +"Attachment.OpenSettings" = "跳转到设置"; +"Attachment.Paid.Create" = "将此内容设置为付费内容"; +"Attachment.Paid.EditPrice" = "编辑价格"; +"Attachment.Paid.EditPrice.Stars#other" = "%@ 颗星星"; +"Attachment.Pasteboard" = "剪贴板"; +"Attachment.Poll" = "投票"; +"Attachment.ProfileMusic" = "个人音乐"; +"Attachment.PublicMusic" = "公开聊天"; +"Attachment.RecentlySentFiles" = "最近发送的文件"; +"Attachment.Reply" = "回复"; +"Attachment.ScanDocument" = "扫描文件"; +"Attachment.SearchWeb" = "通过网络搜索"; +"Attachment.SelectFromFiles" = "从文件中选择"; +"Attachment.SelectFromGallery" = "从相册中选择"; +"Attachment.SelectedMedia#other" = "已选择%@个"; +"Attachment.SendAsFile" = "以文件发送"; +"Attachment.SendAsFiles" = "以文件发送"; +"Attachment.SendInHd" = "以高清质量发送"; +"Attachment.SendWithoutGrouping" = "单独发送"; +"Attachment.SharedAudio" = "分享音频"; +"Attachment.Sticker" = "贴纸"; +"Attachment.Todo" = "核对清单"; +"Attachment.Ungrouped" = "不合并"; +"AttachmentMenu.AddDocument" = "添加文件"; +"AttachmentMenu.AddPhotoOrVideo" = "添加图片或视频"; +"AttachmentMenu.File" = "文件"; +"AttachmentMenu.PhotoOrVideo" = "图片或视频"; +"AttachmentMenu.Poll" = "投票"; +"AttachmentMenu.SendAsFile" = "作为文件发送"; +"AttachmentMenu.SendAsFiles" = "作为文件发送"; +"AttachmentMenu.SendGif#other" = "发送 %@ 张 GIF 动态图"; +"AttachmentMenu.SendItem#other" = "发送这 %@ 项"; +"AttachmentMenu.SendPhoto#other" = "发送 %@ 张图片"; +"AttachmentMenu.SendVideo#other" = "发送 %@ 段视频"; +"AttachmentMenu.WebSearch" = "网络搜索"; +"AuthCode.Alert" = "您的登录验证码是 %@。在您尝试登录 Telegram 时输入它。\n\n不要将此验证码透露给任何人。"; +"AuthConfirmation.AllowMessages" = "允许发送消息"; +"AuthConfirmation.AllowMessagesInfo" = "这将允许 %@发送消息给您。"; +"AuthConfirmation.Cancel" = "取消"; +"AuthConfirmation.Description" = "此网站将接收您的\u00a0**姓名**、**用户名**\u00a0和\u00a0**个人资料页头像**。"; +"AuthConfirmation.DescriptionApp" = "该应用将获取您的 **姓名**、\n**用户名** 和 **头像**。"; +"AuthConfirmation.Device" = "设备"; +"AuthConfirmation.Emoji.Description" = "Telegram 想确认这确实是您本人操作。"; +"AuthConfirmation.Emoji.DescriptionFirst" = "Telegram 想确认是您本人尝试登录 **%@**。"; +"AuthConfirmation.Emoji.Title" = "点击登录界面上\n显示的表情符号"; +"AuthConfirmation.Info" = "本次登录尝试来自上方设备。"; +"AuthConfirmation.IpAddress" = "IP 地址"; +"AuthConfirmation.LogIn" = "登录"; +"AuthConfirmation.LoginFail.Text" = "请尝试再次登录 [%@]()。"; +"AuthConfirmation.LoginFail.TextUnknown" = "请尝试再次登录。"; +"AuthConfirmation.LoginFail.Title" = "登录失败"; +"AuthConfirmation.LoginSuccess.Text" = "您现在已登录 [%@]()."; +"AuthConfirmation.LoginSuccess.TextNoNumber" = "您已登录 [%@](), 但您未授予存取您手机号码的权限。"; +"AuthConfirmation.LoginSuccess.Title" = "登录成功"; +"AuthConfirmation.PhoneNumberConfirmation.Allow" = "允许"; +"AuthConfirmation.PhoneNumberConfirmation.Deny" = "拒绝"; +"AuthConfirmation.PhoneNumberConfirmation.Text" = "**%1$@** 想要访问您的手机号码 **%2$@**。\n\n允许访问?"; +"AuthConfirmation.PhoneNumberConfirmation.Title" = "电话号码"; +"AuthConfirmation.Title" = "登录到 **%@**"; +"AuthConfirmation.UnverifiedApp" = "未经验证的应用"; +"AuthSessions.AddDevice" = "扫码登录"; +"AuthSessions.AddDevice.InvalidQRCode" = "无效的二维码"; +"AuthSessions.AddDevice.ScanInfo" = "扫描二维码即可该帐号在其他设备上登录。"; +"AuthSessions.AddDevice.ScanInstallInfo" = "前往 [getdesktop.telegram.org](desktop) 或 [web.telegram.org](web) 获取二维码"; +"AuthSessions.AddDevice.ScanTitle" = "通过二维码登录"; +"AuthSessions.AddDevice.UrlLoginHint" = "此二维码可用于登录您的 Telegram 账号。\n\n若要确认登录 Telegram,请转到设置 > 设备 > 扫码登录,然后选择扫描二维码。"; +"AuthSessions.AddDeviceIntro.Action" = "扫描二维码"; +"AuthSessions.AddDeviceIntro.Text1" = "从 [desktop.telegram.org]() 下载 Telegram 电脑版"; +"AuthSessions.AddDeviceIntro.Text2" = "启动 Telegram 电脑版获取二维码"; +"AuthSessions.AddDeviceIntro.Text3" = "扫描二维码以便登录您的帐号"; +"AuthSessions.AddDeviceIntro.Title" = "扫描二维码登录"; +"AuthSessions.AddedDeviceTerminate" = "结束"; +"AuthSessions.AddedDeviceTitle" = "登录成功"; +"AuthSessions.AppUnofficial" = "(ID: %@)"; +"AuthSessions.CurrentSession" = "此设备"; +"AuthSessions.DevicesTitle" = "设备"; +"AuthSessions.EmptyText" = "您可以从其它的手机、平板和桌面设备用同样的手机号码登录到 Telegram。所有数据将被实时同步。"; +"AuthSessions.EmptyTitle" = "没有其它的会话"; +"AuthSessions.HeaderInfo" = "通过扫描二维码登录 [Telegram 桌面版](desktop)或 [Telegram 网页版](web)。"; +"AuthSessions.IncompleteAttempts" = "未完成的登录尝试"; +"AuthSessions.IncompleteAttemptsInfo" = "这些设备无法登录你的帐号。他们输入了正确的验证码,但是没有提供正确的密码。"; +"AuthSessions.LinkDesktopDevice" = "添加设备"; +"AuthSessions.LogOut" = "登出"; +"AuthSessions.LogOutApplications" = "取消所有网站的授权"; +"AuthSessions.LogOutApplicationsHelp" = "您可以在支持的网站上使用 Telegram 登录。"; +"AuthSessions.LoggedIn" = "网站"; +"AuthSessions.LoggedInWithTelegram" = "使用 Telegram 账号登录的网站"; +"AuthSessions.Message" = "您已允许此机器人在您登录 %@ 时向您发送消息。"; +"AuthSessions.MessageApp" = "您允许此机器人在您打开 %@ 时向您发送消息。"; +"AuthSessions.OtherDevices" = "Telegram 官方应用可用于 iPhone、iPad、Android、macOS、Windows 和 Linux。[了解更多]()"; +"AuthSessions.OtherSessions" = "活跃会话"; +"AuthSessions.Sessions" = "会话"; +"AuthSessions.Terminate" = "注销"; +"AuthSessions.TerminateIfAwayFor" = "若我不上线"; +"AuthSessions.TerminateIfAwayTitle" = "自动终止旧会话"; +"AuthSessions.TerminateOtherSessions" = "注销所有其它设备上的会话"; +"AuthSessions.TerminateOtherSessionsHelp" = "登出除此设备外的所有设备。"; +"AuthSessions.TerminateOtherSessionsText" = "你确定要注销所有其它设备上的会话吗?"; +"AuthSessions.TerminateSession" = "注销会话"; +"AuthSessions.TerminateSessionText" = "您确定要登出此设备吗?"; +"AuthSessions.Title" = "活跃会话"; +"AuthSessions.View.AcceptIncomingCalls" = "接听来电"; +"AuthSessions.View.AcceptSecretChats" = "新建私密聊天"; +"AuthSessions.View.AcceptTitle" = "允许在此设备上"; +"AuthSessions.View.Application" = "应用程序"; +"AuthSessions.View.Browser" = "浏览器"; +"AuthSessions.View.Device" = "设备"; +"AuthSessions.View.IP" = "IP 地址"; +"AuthSessions.View.Location" = "位置"; +"AuthSessions.View.LocationInfo" = "该位置根据 IP 地址估算,可能并不准确。"; +"AuthSessions.View.Logout" = "登出"; +"AuthSessions.View.OS" = "操作系统"; +"AuthSessions.View.TerminateSession" = "结束会话"; +"AutoDownloadSettings.AutoDownload" = "自动下载媒体文件"; +"AutoDownloadSettings.AutodownloadFiles" = "自动下载文件和音乐"; +"AutoDownloadSettings.AutodownloadPhotos" = "自动下载图片"; +"AutoDownloadSettings.AutodownloadVideos" = "自动下载视频和 GIF"; +"AutoDownloadSettings.Cellular" = "蜂窝网络"; +"AutoDownloadSettings.CellularTitle" = "使用数据流量时"; +"AutoDownloadSettings.Channels" = "频道"; +"AutoDownloadSettings.Contacts" = "联系人"; +"AutoDownloadSettings.DataUsage" = "流量使用情况"; +"AutoDownloadSettings.DataUsageCustom" = "自定义"; +"AutoDownloadSettings.DataUsageHigh" = "高"; +"AutoDownloadSettings.DataUsageLow" = "低"; +"AutoDownloadSettings.DataUsageMedium" = "中"; +"AutoDownloadSettings.Delimeter" = "、"; +"AutoDownloadSettings.DocumentsTitle" = "文件"; +"AutoDownloadSettings.Files" = "文件"; +"AutoDownloadSettings.GroupChats" = "群聊"; +"AutoDownloadSettings.LastDelimeter" = "与"; +"AutoDownloadSettings.LimitBySize" = "大小限制"; +"AutoDownloadSettings.MaxFileSize" = "文件大小限制"; +"AutoDownloadSettings.MaxVideoSize" = "视频大小限制"; +"AutoDownloadSettings.MediaTypes" = "媒体文件类型"; +"AutoDownloadSettings.OffForAll" = "对所有聊天关闭"; +"AutoDownloadSettings.OnFor" = "%@"; +"AutoDownloadSettings.OnForAll" = "对所有聊天启用"; +"AutoDownloadSettings.OnForContacts" = "对联系人开启"; +"AutoDownloadSettings.Photos" = "图片"; +"AutoDownloadSettings.PhotosTitle" = "图片"; +"AutoDownloadSettings.PreloadVideo" = "预先载入较大的视频"; +"AutoDownloadSettings.PreloadVideoInfo" = "预先加载大于 %@ 前几秒的视频以便即时播放。"; +"AutoDownloadSettings.PrivateChats" = "其它私聊"; +"AutoDownloadSettings.Reset" = "重置"; +"AutoDownloadSettings.ResetHelp" = "撤消全部自定义自动下载设置"; +"AutoDownloadSettings.ResetSettings" = "重置自动下载设置"; +"AutoDownloadSettings.Stories" = "动态"; +"AutoDownloadSettings.StoriesArchivedContacts" = "归档的联系人"; +"AutoDownloadSettings.StoriesSectionHeader" = "自动下载动态"; +"AutoDownloadSettings.StoriesTitle" = "动态"; +"AutoDownloadSettings.Title" = "自动下载"; +"AutoDownloadSettings.TypeChannels" = "频道"; +"AutoDownloadSettings.TypeContacts" = "联系人"; +"AutoDownloadSettings.TypeGroupChats" = "群组"; +"AutoDownloadSettings.TypePrivateChats" = "私聊"; +"AutoDownloadSettings.Unlimited" = "不限量"; +"AutoDownloadSettings.UpTo" = "上线为 %@"; +"AutoDownloadSettings.UpToFor" = "不超过 %@ 的 %@"; +"AutoDownloadSettings.UpToForAll" = "所有聊天最大 %@"; +"AutoDownloadSettings.VideoMessagesTitle" = "视频消息"; +"AutoDownloadSettings.Videos" = "视频"; +"AutoDownloadSettings.VideosTitle" = "视频"; +"AutoDownloadSettings.VoiceMessagesInfo" = "语音消息占用小,所以始终自动下载。"; +"AutoDownloadSettings.VoiceMessagesTitle" = "语音消息"; +"AutoDownloadSettings.WiFi" = "WI-FI"; +"AutoDownloadSettings.WifiTitle" = "使用无线网络"; +"AutoNightTheme.Automatic" = "自动"; +"AutoNightTheme.AutomaticHelp" = "当亮度低于等于 %@%% 时自动切换至夜间模式。使用此功能需要您打开“自动亮度调节”功能,否则这项功能将无法正常运作。"; +"AutoNightTheme.AutomaticSection" = "亮度阈值"; +"AutoNightTheme.Disabled" = "停用"; +"AutoNightTheme.LocationHelp" = "计算日落/日出时间需要对您的粗略位置进行一次性检测。请注意,此位置仅会存储在您目前的设备上。\n\n日落:%@\n日出:%@"; +"AutoNightTheme.NotAvailable" = "不可用"; +"AutoNightTheme.PreferredTheme" = "首选夜间主题"; +"AutoNightTheme.ScheduleSection" = "计划时段"; +"AutoNightTheme.Scheduled" = "计划时段"; +"AutoNightTheme.ScheduledFrom" = "从"; +"AutoNightTheme.ScheduledTo" = "到"; +"AutoNightTheme.System" = "系统"; +"AutoNightTheme.Title" = "自动切换夜间模式"; +"AutoNightTheme.UpdateLocation" = "更新位置"; +"AutoNightTheme.UseSunsetSunrise" = "使用当地日落/日出时间"; +"Autoremove.OptionOff" = "关闭"; +"Autoremove.SetCustomTime" = "设置自定义时间..."; +"AutoremoveSetup.AdditionalGlobalSettingsInfo" = "您还可以在设置中为所有聊天默认设定[自动删除计时器]()。"; +"AutoremoveSetup.TimeSectionHeader" = "自动删除消息"; +"AutoremoveSetup.TimerInfoChannel" = "在一定时间后自动删除该频道中发送的信息。"; +"AutoremoveSetup.TimerInfoChat" = "在一定时间后自动删除本聊天中发送的新消息。"; +"AutoremoveSetup.TimerValueAfter" = "%@ 之后"; +"AutoremoveSetup.TimerValueNever" = "从不"; +"AutoremoveSetup.Title" = "自动删除"; +"Autosave.AddException" = "添加例外"; +"Autosave.DeleteAllExceptions" = "删除所有例外"; +"Autosave.Exception" = "例外"; +"Autosave.ExceptionsSection" = "例外"; +"Autosave.TypePhoto" = "图片"; +"Autosave.TypeVideo" = "视频"; +"Autosave.TypesInfo" = "自动将私聊中的所有新媒体文件保存至照片应用。"; +"Autosave.TypesSection" = "保存至照片"; +"Autosave.VideoInfo" = "私聊中下载的所有小于 %@ 的视频都将保存到您的照片应用中。"; +"Autosave.VideoSizeSection" = "视频大小限制"; +"AvatarEditor.Background" = "背景"; +"AvatarEditor.Emoji" = "表情"; +"AvatarEditor.EmojiOrSticker" = "表情或贴纸"; +"AvatarEditor.PremiumNeeded.Background" = "订阅 Telegram 高级版以使用此背景。"; +"AvatarEditor.PremiumNeeded.Emoji" = "订阅 Telegram 高级版以选择此表情符号。"; +"AvatarEditor.PremiumNeeded.Sticker" = "订阅 Telegram 高级版以选择此贴纸。"; +"AvatarEditor.Set" = "设置"; +"AvatarEditor.SetChannelPhoto" = "设置为频道头像"; +"AvatarEditor.SetGroupPhoto" = "设置为群组头像"; +"AvatarEditor.SetProfilePhoto" = "设置为头像"; +"AvatarEditor.Stickers" = "贴纸"; +"AvatarEditor.Suggest" = "建议"; +"AvatarEditor.SuggestProfilePhoto" = "推荐为头像"; +"AvatarEditor.SwitchToEmoji" = "切换为表情"; +"AvatarEditor.SwitchToStickers" = "切换为贴纸"; +"AvatarUpload.StatusDone" = "已设置您的个人头像。"; +"AvatarUpload.StatusUploading" = "正在上传您的个人资料照片..."; +"AvatarUpload.ViewAction" = "查看"; +"BackgroundTasks.MediaFinished" = "已结束"; +"BackgroundTasks.MediaSubtitle" = "运行中…"; +"BackgroundTasks.StoryFinished" = "已结束"; +"BackgroundTasks.StoryOpenAppToContinue" = "打开应用以继续"; +"BackgroundTasks.StorySubtitle" = "运行中…"; +"BackgroundTasks.UploadingMedia#other" = "%d 条消息发送中"; +"BackgroundTasks.UploadingStories#other" = "%d 个动态上传中"; +"BalanceNeeded.FragmentAction" = "通过 Fragment 充值"; +"BalanceNeeded.FragmentSubtitle" = "您可以通过第三方平台 Fragment 充值 Gram。"; +"BalanceNeeded.FragmentTitle" = "需要 %@ Gram "; +"Birthday.Added" = "已添加出生日期。"; +"Birthday.FloodError" = "抱歉,您不能频繁修改您的出生日期。"; +"Birthday.Help" = "在 [设置]() 中选择谁可以看到您的生日。"; +"Birthday.HelpContacts" = "只有您的联系人可以看到您的生日。 [更改 >]()"; +"Birthday.Save" = "保存"; +"Birthday.Title" = "出生日期"; +"BlockedUsers.AddNew" = "添加新的…"; +"BlockedUsers.BlockTitle" = "屏蔽"; +"BlockedUsers.BlockUser" = "屏蔽用户…"; +"BlockedUsers.Info" = "被您屏蔽的用户将无法向您发送消息,或邀请您加入群组。该用户也无法看到您的头像、动态、在线状态及最后上线时间。"; +"BlockedUsers.LeavePrefix" = "退出"; +"BlockedUsers.SelectUserTitle" = "屏蔽用户"; +"BlockedUsers.Title" = "已屏蔽"; +"BlockedUsers.Unblock" = "取消屏蔽"; +"BoostGift.AddChannel" = "添加频道"; +"BoostGift.AddChannelOrGroup" = "添加频道或群组"; +"BoostGift.AddGroupOrChannel" = "添加群组或频道"; +"BoostGift.AdditionalPrizes" = "额外奖品"; +"BoostGift.AdditionalPrizesInfoForMonths#other" = "为期 **%@** 个月"; +"BoostGift.AdditionalPrizesInfoOff" = "如果您除了 Telegram 高级版订阅之外还想给获奖者赠送您自己的奖品,请打开此选项。"; +"BoostGift.AdditionalPrizesInfoOn" = "所有奖品:**%1$@** %3$@ %2$@。"; +"BoostGift.AdditionalPrizesInfoStars#other" = "**%@** 颗星星"; +"BoostGift.AdditionalPrizesInfoStarsAndOther" = "与 **%1$@** %2$@"; +"BoostGift.AdditionalPrizesInfoStarsOff" = "如果您想在赠送星星的同时,额外为获奖者提供您自己的奖品,请开启此选项。"; +"BoostGift.AdditionalPrizesInfoStarsOn" = "所有奖品: %1$@%2$@。"; +"BoostGift.AdditionalPrizesInfoSubscriptions#other" = "%@ 个 Telegram 高级版订阅"; +"BoostGift.AdditionalPrizesInfoWithSubscriptions#other" = "及 %@ 个 Telegram 高级版订阅"; +"BoostGift.AdditionalPrizesPlaceholder" = "输入您的奖品"; +"BoostGift.AllSubscribers" = "所有订阅者"; +"BoostGift.AwardSpecificUsers" = "奖励特定用户"; +"BoostGift.Channels.MaximumReached" = "您最多可以选择 %@ 个频道。"; +"BoostGift.Channels.PrivateChannel.Add" = "添加"; +"BoostGift.Channels.PrivateChannel.Text" = "您确定要新增私人频道吗?如果没有邀请链接,用户将无法加入。"; +"BoostGift.Channels.PrivateChannel.Title" = "这是私人频道"; +"BoostGift.Channels.Save" = "保存频道"; +"BoostGift.Channels.Search" = "搜索频道"; +"BoostGift.Channels.SectionTitle" = "频道"; +"BoostGift.Channels.Subtitle" = " 最多可以选择 %@ 个频道"; +"BoostGift.Channels.Title" = "添加频道"; +"BoostGift.ChannelsAndGroupsInfo" = "选择用户为了加入此抽奖活动需要额外加入的频道或群组。"; +"BoostGift.ChannelsAndGroupsTitle" = "包括的频道和群组"; +"BoostGift.ChannelsBoosts#other" = "此频道将获得 %@ 次助推"; +"BoostGift.ChannelsInfo" = "选择用户需要订阅哪些频道才能参与抽奖活动。"; +"BoostGift.ChannelsOrGroups.MaximumReached" = "你最多只能选择 %@ 个频道和群组。"; +"BoostGift.ChannelsOrGroups.Save" = "保存频道和群组"; +"BoostGift.ChannelsOrGroups.Search" = "搜索频道及群组"; +"BoostGift.ChannelsOrGroups.SectionTitle" = "频道和群组"; +"BoostGift.ChannelsOrGroups.Subtitle" = "最多选择 %@ 个频道或群组"; +"BoostGift.ChannelsOrGroups.Title" = "添加频道或群组"; +"BoostGift.ChannelsTitle" = "抽奖活动中包括的频道"; +"BoostGift.CreateGiveaway" = "创建抽奖活动"; +"BoostGift.CreateGiveawayInfo" = "将随机选出获奖者"; +"BoostGift.DateEnds" = "结束时间"; +"BoostGift.DateInfo" = "选择何时在您的频道中随机选择 %1$@ 名订阅者获得 Telegram 高级版。"; +"BoostGift.DateInfoSubscribers#other" = "%@ 位订阅者"; +"BoostGift.DateTitle" = "抽奖活动结束日期"; +"BoostGift.Description" = "通过向您的订阅者赠予 Telegram 高级版来为您的频道获取更多助推。"; +"BoostGift.DurationTitle" = "Telegram 高级版订阅的时长"; +"BoostGift.FromAllCountries" = "来自所有国家"; +"BoostGift.FromCountries#other" = "来自 %@ 个国家"; +"BoostGift.FromOneCountry" = "来自 %1$@"; +"BoostGift.FromTwoCountries" = "来自 %1$@ 和 %2$@"; +"BoostGift.GiftPremium" = "赠送高级版订阅"; +"BoostGift.GiveawayCreated.Text" = "在频道的 [统计信息]() 页面查看您的频道通过此次抽奖活动获得了多少助推。"; +"BoostGift.GiveawayCreated.Title" = "抽奖活动已创建"; +"BoostGift.Group.AllMembers" = "所有成员"; +"BoostGift.Group.DateInfo" = "选择何时从您的群组中随机选出 %1$@ 名成员接收 Telegram 高级版。"; +"BoostGift.Group.DateInfoMembers#other" = "%@ 个成员"; +"BoostGift.Group.Description" = "通过向群组成员赠送高级版订阅\n为您的群组获得更多助推。"; +"BoostGift.Group.GiveawayCreated.Text" = "检查群组的 [助力列表](),了解此抽奖活动如何助推你的群组。"; +"BoostGift.Group.LimitMembersInfo" = "选择是否要将抽奖活动仅限于活动开始后加入群组的用户。"; +"BoostGift.Group.Members.Joined" = "已加入于 %@"; +"BoostGift.Group.Members.MaximumReached" = "你最多只能选择 %@ 名成员。"; +"BoostGift.Group.Members.Save" = "保存接收者"; +"BoostGift.Group.Members.Search" = "搜索成员"; +"BoostGift.Group.Members.SectionTitle" = "成员"; +"BoostGift.Group.Members.Subtitle" = "最多可选择 %@ 名成员"; +"BoostGift.Group.Members.Title" = "赠送高级版"; +"BoostGift.Group.OnlyNewMembers" = "仅限新成员"; +"BoostGift.Group.PremiumGifted.Text" = "查看群组的 [助推情况](),了解礼物能如何助推您的群组。"; +"BoostGift.Group.StarsDateInfo" = "选择何时从您的群组中随机选出 %@ 名成员接收星星。"; +"BoostGift.Group.StarsGiveawayCreated.Text" = "查看群组的 [助推]() 以了解此抽奖活动能够如何助推您的群组。"; +"BoostGift.GroupBoosts#other" = "此群组将获得 %@ 次助推"; +"BoostGift.Groups.PrivateGroup.Text" = "您确定要新增私人群组吗?如果没有邀请链接,用户将无法加入。"; +"BoostGift.Groups.PrivateGroup.Title" = "该群组是私密的"; +"BoostGift.GroupsAndChannelsInfo" = "选择用户参与抽奖活动前额外需要加入的群组或频道。"; +"BoostGift.GroupsAndChannelsTitle" = "包含的群组和频道"; +"BoostGift.GroupsOrChannels.MaximumReached" = "你最多只能选择 %@ 个群组和频道。"; +"BoostGift.GroupsOrChannels.Save" = "保存群组和频道"; +"BoostGift.GroupsOrChannels.Search" = "搜索群组或频道"; +"BoostGift.GroupsOrChannels.SectionTitle" = "群组和频道"; +"BoostGift.GroupsOrChannels.Subtitle" = "选择最多 %@ 个群组或频道。"; +"BoostGift.GroupsOrChannels.Title" = "添加群组或频道"; +"BoostGift.LimitSubscribersInfo" = "选择是否要将抽奖活动仅限于活动发起之后加入频道的用户,或来自特定国家/地区的用户。"; +"BoostGift.Members.MaximumReached" = "你最多只能选择 %@ 名成员。"; +"BoostGift.Members.Search" = "搜索成员"; +"BoostGift.Members.SectionTitle" = "成员"; +"BoostGift.Members.Subtitle" = "最多选择 %@ 位成员"; +"BoostGift.NewDescription" = "通过赠送奖品来为您的频道\n获得更多助推和订阅者。"; +"BoostGift.NewDescriptionGroup" = "通过赠送奖品来为您的群组\n获得更多助推和成员。"; +"BoostGift.OnlyNewSubscribers" = "仅限新订阅者"; +"BoostGift.PremiumGifted.Text" = "检查您频道的**统计信息**,了解礼物将如何助推您的频道。"; +"BoostGift.PremiumGifted.Title" = "已赠送 Telegram 高级版订阅"; +"BoostGift.PremiumInfo" = "您可以在 [这里]() 查看 Telegram 高级版的功能列表和更多详情。"; +"BoostGift.PrepaidGiveaway.StarsCount#other" = "%@ 颗星星"; +"BoostGift.PrepaidGiveaway.StarsWinners#other" = "给 %@ 名获奖者"; +"BoostGift.PrepaidGiveawayCount#other" = "%@ 个 Telegram 高级版订阅"; +"BoostGift.PrepaidGiveawayMonths" = "%@ 个月的订阅"; +"BoostGift.PrepaidGiveawayStarsCount#other" = "%@ 个 Telegram 高级版"; +"BoostGift.PrepaidGiveawayStarsMonths" = "%@月的订阅"; +"BoostGift.PrepaidGiveawayTitle" = "预付抽奖活动"; +"BoostGift.Prize" = "奖品"; +"BoostGift.Prize.Premium" = "Telegram 高级版"; +"BoostGift.Prize.Stars" = "Telegram 星星"; +"BoostGift.QuantityBoosts#other" = "%@ 次助推"; +"BoostGift.QuantityInfo" = "选择要赠送的 Telegram 高级版订阅数量以及接收的助推数量。"; +"BoostGift.QuantityTitle" = "奖品的数量"; +"BoostGift.ReduceQuantity.Reduce" = "减少"; +"BoostGift.ReduceQuantity.Text" = "您无法在应用程序中购买 %1$@ 个 %2$@个月的订阅。您想将奖品数量減少到 %3$@ 吗?"; +"BoostGift.ReduceQuantity.Title" = "减少数量"; +"BoostGift.SelectRecipients" = "选择接收者"; +"BoostGift.Stars.Boosts#other" = "%@ 次助推"; +"BoostGift.Stars.Info" = "选择赠送多少颗星星以及每年获得多少次助推。"; +"BoostGift.Stars.PerUser" = "每位用户 %@"; +"BoostGift.Stars.ShowMoreOptions" = "显示更多选项"; +"BoostGift.Stars.Stars#other" = "%@ 颗星星"; +"BoostGift.Stars.Title" = "可分发的星星"; +"BoostGift.Stars.Winners" = "获奖者数量"; +"BoostGift.Stars.WinnersInfo" = "选择您要为多少获奖者发放星星。"; +"BoostGift.StarsDateInfo" = "选择何时从您的频道中随机选出 %@ 名订阅者以接收星星。"; +"BoostGift.StarsGiveawayCreated.Text" = "查看频道的 [统计数据]() 以了解此抽奖活动能够如何助推您的频道。"; +"BoostGift.StarsGiveawayCreated.Title" = "已创建星星抽奖活动"; +"BoostGift.StartConfirmation.Start" = "发起"; +"BoostGift.StartConfirmation.Text" = "您确定要现在开始抽奖活动吗?"; +"BoostGift.StartConfirmation.Title" = "开始抽奖活动"; +"BoostGift.StartGiveaway" = "开始抽奖活动"; +"BoostGift.Subscribers.Joined" = "在 %@ 加入"; +"BoostGift.Subscribers.MaximumReached" = "您最多可以选择 %@ 位订阅者。"; +"BoostGift.Subscribers.Save" = "保存接收者"; +"BoostGift.Subscribers.Search" = "搜索订阅者"; +"BoostGift.Subscribers.SectionTitle" = "订阅者"; +"BoostGift.Subscribers.Subtitle" = "最多可以选择 %@ 位订阅者"; +"BoostGift.Subscribers.Title" = "赠送 Telegram 高级版订阅"; +"BoostGift.Title" = "通过赠品助推"; +"BoostGift.UsersTitle" = "有资格参与抽奖活动的用户"; +"BoostGift.Winners" = "显示获奖者"; +"BoostGift.WinnersInfo" = "选择是否在抽奖活动结束时公开获奖者名单。"; +"Bot.AccepRecurrentInfo" = "我接受 **%1$@** 的 [使用条款]() "; +"Bot.AcceptTermsInfo" = "我接受 **%1$@** 的 [服务条款]()"; +"Bot.Active" = "机器人现在处于活动状态"; +"Bot.AddToChannel.Title" = "添加至频道"; +"Bot.AddToChat" = "添加到群组或频道"; +"Bot.AddToChat.Add.AddAsAdmin" = "添加机器人为管理员"; +"Bot.AddToChat.Add.AddAsMember" = "添加机器人为成员"; +"Bot.AddToChat.Add.AdminAlertAdd" = "添加为管理员"; +"Bot.AddToChat.Add.AdminAlertTextChannel" = "您确定要将此机器人添加为 **%@** 的管理员吗?它将有权访问频道管理员和订阅者列表。"; +"Bot.AddToChat.Add.AdminAlertTextGroup" = "您想要将此机器人添加为 **%@** 的管理员吗?它将有权访问匿名管理员列表。"; +"Bot.AddToChat.Add.AdminAlertTitle" = "是否添加机器人为管理员?"; +"Bot.AddToChat.Add.AdminRights" = "管理员权限"; +"Bot.AddToChat.Add.MemberAlertAdd" = "添加为成员"; +"Bot.AddToChat.Add.MemberAlertTextChannel" = "是否将机器人添加为 **%@** 的订阅者?"; +"Bot.AddToChat.Add.MemberAlertTextGroup" = "你确定要将机器人添加为成员吗 **%@**?"; +"Bot.AddToChat.Add.MemberAlertTitle" = "将机器人添加为成员?"; +"Bot.AddToChat.Add.Title" = "添加机器人"; +"Bot.AddToChat.MyChannels" = "我管理的频道"; +"Bot.AddToChat.MyGroups" = "我管理的群组"; +"Bot.AddToChat.OtherGroups" = "群组"; +"Bot.AddToChat.Title" = "添加到群组或频道"; +"Bot.AddToChatInfo" = "该机器人可以管理群组或频道。"; +"Bot.AddToGroup.Title" = "添加至群组"; +"Bot.AlertCanNotCreateBots" = "%@ 无法管理其他机器人。"; +"Bot.DescriptionTitle" = "它能做什么?"; +"Bot.GenericBotStatus" = "机器人"; +"Bot.GenericSupportStatus" = "客服"; +"Bot.GroupStatusDoesNotReadHistory" = "无权读取消息"; +"Bot.GroupStatusReadsHistory" = "可读取消息"; +"Bot.Inactive" = "机器人不活跃"; +"Bot.Settings" = "机器人设置"; +"Bot.Slow" = "机器人回复缓慢"; +"Bot.Start" = "开始"; +"Bot.Stop" = "屏蔽机器人"; +"Bot.TapToUse" = "按下这里以使用此机器人"; +"Bot.Unblock" = "重新启用机器人"; +"BotPreview.ViewContextDelete" = "删除预览"; +"BotPreviews.AlertTooManyPreviews#other" = "您最多可以添加 12 个预览。"; +"BotPreviews.DefaultFooter.Text" = "默认显示此预览,您还可以添加特定语言的翻译。"; +"BotPreviews.DeleteTranslationAlert.Text" = "您确定要删除这个翻译吗?"; +"BotPreviews.DeleteTranslationAlert.Title" = "删除译文"; +"BotPreviews.Empty.Add" = "添加预览"; +"BotPreviews.Empty.AddTranslation" = "创建翻译"; +"BotPreviews.Empty.DeleteTranslation" = "删除此译文"; +"BotPreviews.Empty.Separator" = "或"; +"BotPreviews.Empty.Text#other" = "为您的小程序上传最多 %d 个屏幕截图或视频演示。"; +"BotPreviews.Empty.Title" = "没有预览"; +"BotPreviews.LanguageTab.Add" = "添加语言"; +"BotPreviews.LanguageTab.Main" = "主要"; +"BotPreviews.MenuAddPreview" = "添加预览"; +"BotPreviews.MenuDeleteLanguage" = "删除 %@"; +"BotPreviews.MenuReorder" = "重新排序"; +"BotPreviews.SelectLanguage.Title" = "添加译文"; +"BotPreviews.SheetDeleteTitle#other" = "删除 %d 个预览?"; +"BotPreviews.SubtitleCount#other" = "%d 个预览"; +"BotPreviews.SubtitleEmpty" = "未添加预览"; +"BotPreviews.SubtitleLoading" = "加载中"; +"BotPreviews.TranslationFooter.Text" = "该预览将显示给所有语言设置为 %@ 的用户。"; +"BotVerification.Added" = "**%@** 已收到通知并将在接受后收到您的验证标记和说明。"; +"BotVerification.ChooseChat" = "选择聊天进行验证"; +"BotVerification.Remove.Bot.Text" = "您已认证此机器人,是否要移除认证?"; +"BotVerification.Remove.Channel.Text" = "您已认证此频道,是否要移除认证?"; +"BotVerification.Remove.Group.Text" = "您已认证此群组,是否要移除认证?"; +"BotVerification.Remove.Remove" = "移除"; +"BotVerification.Remove.Title" = "移除验证"; +"BotVerification.Remove.User.Text" = "您已认证此用户,是否要移除认证?"; +"BotVerification.Removed" = "您已移除 **%@** 的认证。"; +"BotVerification.Verify.Bot.Text" = "是否要使用您的认证标识和简介来验证此机器人?"; +"BotVerification.Verify.Bot.Title" = "验证机器人"; +"BotVerification.Verify.Channel.Text" = "你是否想要使用你的验证标记和描述来验证此频道?"; +"BotVerification.Verify.Channel.Title" = "验证频道"; +"BotVerification.Verify.Group.Text" = "是否要使用您的认证标识和简介来验证此群组?"; +"BotVerification.Verify.Group.Title" = "验证群组"; +"BotVerification.Verify.Placeholder" = "此页面已由 %@ 验证"; +"BotVerification.Verify.User.Text" = "您想用验证标记和描述来验证此用户吗?"; +"BotVerification.Verify.User.Title" = "验证用户"; +"BotVerification.Verify.Verify" = "验证"; +"Broadcast.AdminLog.EmptyText" = "在过去的 48 小时内,频道管理员没有执行任何值得注意的操作。"; +"BroadcastGroups.Cancel" = "保持为普通群组"; +"BroadcastGroups.ConfirmationAlert.Convert" = "转换"; +"BroadcastGroups.ConfirmationAlert.Text" = "该群组的普通成员(非管理员)将 **永久** 失去在群组中发送消息的权限。\n\n此操作 **无法** 撤消。"; +"BroadcastGroups.ConfirmationAlert.Title" = "您确定吗?"; +"BroadcastGroups.Convert" = "转换为广播群组"; +"BroadcastGroups.IntroText" = "• 成员数量没有上限。\n\n• 仅管理员可发布消息。\n\n• 无法变回普通群。"; +"BroadcastGroups.IntroTitle" = "广播群组"; +"BroadcastGroups.LimitAlert.LearnMore" = "了解更多"; +"BroadcastGroups.LimitAlert.SettingsTip" = "如果您改变主意,请在 群组设置 > 权限 中更改。"; +"BroadcastGroups.LimitAlert.Text" = "您的群组已经达到了 **%@** 名成员的上限。\n\n通过将该群组转换为只有管理员才能发布的消息的 **广播群组** 可以提升成员上限。\n您感兴趣吗?"; +"BroadcastGroups.LimitAlert.Title" = "群成员已达上限"; +"BroadcastGroups.Success" = "您的群组现在可以拥有超过 %@ 个成员了。"; +"Browser.ContextMenu.AddToReadingList" = "添加至阅读列表"; +"Browser.ContextMenu.CopyLink" = "拷贝链接"; +"Browser.ContextMenu.Open" = "打开"; +"Browser.ContextMenu.OpenInNewTab" = "在新标签页中打开"; +"Browser.ContextMenu.Share" = "分享"; +"Browser.ErrorTitle" = "发生错误"; +"Business.AddPost" = "添加贴文"; +"Business.AddPostInfo" = "在您的页面上发布您的商品或服务的照片和视频。"; +"Business.AdsInfo" = "作为高级版订阅用户,您在 Telegram 上不会看到任何广告,但您可以选择开启它们,例如查看您自己在 [Telegram 广告平台 >]() 上发布的广告。"; +"Business.AdsInfo_URL" = "https://ads.telegram.org"; +"Business.AdsTitle" = "频道中的广告"; +"Business.AwayMessages" = "离开消息"; +"Business.AwayMessagesInfo" = "设置在您不在线时自动发送的消息。"; +"Business.Chatbots" = "聊天机器人"; +"Business.ChatbotsInfo" = "添加任何用于处理与客户互动的第三方聊天机器人。"; +"Business.ChatbotsItem" = "聊天机器人"; +"Business.Description" = "通过订阅 **Telegram 高级版**,将您的帐号转变为 **企业页面**,并使用以下额外功能。"; +"Business.DontHideAds" = "不要隐藏广告"; +"Business.GreetingMessages" = "问候消息"; +"Business.GreetingMessagesInfo" = "设置自动发送给新客户的问候信息。"; +"Business.Intro" = "起始页"; +"Business.Intro.CustomizeSectionFooter" = "你可以自定他人与你聊天前看到的消息。"; +"Business.Intro.CustomizeSectionHeader" = "自定你的起始页"; +"Business.Intro.IntroSticker" = "选择贴纸"; +"Business.Intro.IntroStickerValueRandom" = "随机"; +"Business.Intro.IntroTextPlaceholder" = "输入消息"; +"Business.Intro.IntroTitlePlaceholder" = "输入标题"; +"Business.Intro.ResetToDefault" = "重置为默认"; +"Business.Intro.Title" = "起始页"; +"Business.IntroInfo" = "自定义用户在与您开始聊天之前看到的消息。"; +"Business.Links" = "聊天链接"; +"Business.Links.AlertUnsavedAction" = "重置"; +"Business.Links.AlertUnsavedText" = "有未保存的更改,是否重置?"; +"Business.Links.CreateAction" = "创建聊天链接"; +"Business.Links.DeleteItemConfirmationAction" = "删除链接"; +"Business.Links.EditLinkTitle" = "聊天链接"; +"Business.Links.EditLinkToastSaved" = "已保存预设消息。"; +"Business.Links.ErrorTooManyLinks" = "抱歉,您无法再创建更多链接。"; +"Business.Links.ItemActionShare" = "分享"; +"Business.Links.ItemClickCount#other" = "%d 次点击"; +"Business.Links.ItemNoClicks" = "暂无点击数"; +"Business.Links.ItemNoText" = "没有文字描述"; +"Business.Links.LinkNameInputPlaceholder" = "为此链接命名..."; +"Business.Links.LinkNameText" = "为此链接添加一个只有您才能看到的名称。"; +"Business.Links.LinkNameTitle" = "链接名称"; +"Business.Links.LinksSectionHeader" = "已创建的链接"; +"Business.Links.PreviewText" = "添加一条消息,所有通过以下链接与您开始聊天的人都将在消息输入框看到此消息:"; +"Business.Links.PreviewTitle" = "聊天链接"; +"Business.Links.SimpleLinkInfoPhone" = "您也可以使用短链接向你发起聊天 — [t.me/\u2060+\u2060%@](phone)."; +"Business.Links.SimpleLinkInfoUsername" = "您也可以使用短链接来与你发起聊天 — [t.me/%@](username)."; +"Business.Links.SimpleLinkInfoUsernamePhone" = "您也可以使用一个简单的链接来开始与您聊天 — [t.me/%1$@](username) 或 [t.me/\u2060+\u2060%2$@](phone)。"; +"Business.Links.Text" = "为您的客户提供可以开始与您聊天的短链接,并为其推荐与您聊天的第一条消息。"; +"Business.LinksInfo" = "创建与您开始聊天的链接,并预设第一条消息。"; +"Business.Location" = "位置"; +"Business.LocationInfo" = "在您的帐号上显示企业位置"; +"Business.MoreFeaturesInfo" = "稍后查看此部分以了解新的企业功能。"; +"Business.MoreFeaturesTitle" = "更多企业功能"; +"Business.OpeningHours" = "营业时间"; +"Business.OpeningHoursInfo" = "向顾客展示商户的营业时间。"; +"Business.QuickReplies" = "快速回复"; +"Business.QuickRepliesInfo" = "设置包含富文本和媒体的快捷方式,更快回复消息。"; +"Business.SetEmojiStatus" = "设置表情符号状态"; +"Business.SetEmojiStatusInfo" = "在您的名称旁边显示您企业的当前状态。"; +"Business.SubscribedDescription" = "您现在已经解锁 Telegram 企业功能。这些功能 **暂时包含** 在您的高级版订阅中。"; +"Business.TagYourChats" = "标记您的聊天"; +"Business.TagYourChatsInfo" = "为聊天添加彩色标签,以便在聊天列表中快速访问。 "; +"Business.Title" = "Telegram 企业版"; +"BusinessHoursSetup.AddAction" = "添加一组时间"; +"BusinessHoursSetup.AddSectionFooter" = "设置您一天中的工作时间。"; +"BusinessHoursSetup.DayClosed" = "休息"; +"BusinessHoursSetup.DayIntervalEnd" = "关闭时间"; +"BusinessHoursSetup.DayIntervalRemove" = "删除"; +"BusinessHoursSetup.DayIntervalStart" = "营业时间"; +"BusinessHoursSetup.DayOpen24h" = "24 小时营业"; +"BusinessHoursSetup.DaySwitch" = "当天营业"; +"BusinessHoursSetup.DaysSectionTitle" = "营业时间"; +"BusinessHoursSetup.ErrorIntersectingDays.ResetAction" = "重置"; +"BusinessHoursSetup.ErrorIntersectingDays.Text" = "营业时间有交叉,是否需要重置?"; +"BusinessHoursSetup.ErrorIntersectingHours.ResetAction" = "重置"; +"BusinessHoursSetup.ErrorIntersectingHours.Text" = "设置的营业时间有重叠。是否需要重置?"; +"BusinessHoursSetup.MainToggle" = "显示营业时间"; +"BusinessHoursSetup.Text" = "启用此功能可以让您的客户了解您的营业时间安排。"; +"BusinessHoursSetup.TimeZone" = "时区"; +"BusinessHoursSetup.Title" = "营业时间"; +"BusinessLink.AlertTextLimitText" = "消息文本限制为 4096 个字符"; +"BusinessLink.ErrorExpired" = "链接已失效"; +"BusinessLocationSetup.AddressPlaceholder" = "输入地址"; +"BusinessLocationSetup.AlertUnsavedChanges.ResetAction" = "撤销"; +"BusinessLocationSetup.AlertUnsavedChanges.Text" = "你有未保存的变更。"; +"BusinessLocationSetup.DeleteLocation" = "移除位置信息"; +"BusinessLocationSetup.ErrorAddressEmpty.ResetAction" = "删除"; +"BusinessLocationSetup.ErrorAddressEmpty.Text" = "地址不能为空。"; +"BusinessLocationSetup.SetLocationOnMap" = "在地图上标记位置"; +"BusinessLocationSetup.Text" = "在您的帐号上显示企业位置。"; +"BusinessLocationSetup.Title" = "位置"; +"BusinessMessageSetup.AwayMessageSectionHeader" = "离开消息"; +"BusinessMessageSetup.CreateAwayMessage" = "创建离开消息"; +"BusinessMessageSetup.CreateGreetingMessage" = "创建问候消息"; +"BusinessMessageSetup.ErrorNoRecipients.ResetAction" = "重置"; +"BusinessMessageSetup.ErrorNoRecipients.Text" = "未选择收件人。重置?"; +"BusinessMessageSetup.ErrorScheduleEndTimeBeforeStartTime.Text" = "自定义时间表的结束时间必须晚于开始时间。"; +"BusinessMessageSetup.ErrorScheduleEndTimeMissing.Text" = "自定义表中缺少结束时间。"; +"BusinessMessageSetup.ErrorScheduleStartTimeMissing.Text" = "自定义表中缺少营业时间。"; +"BusinessMessageSetup.ErrorScheduleTime.ResetAction" = "重置"; +"BusinessMessageSetup.ErrorScheduleTimeMissing.Text" = "您没有设置自定义表中的时间。"; +"BusinessMessageSetup.GreetingMessageSectionHeader" = "问候消息"; +"BusinessMessageSetup.InactivitySectionFooter" = "选择自您上次与对方互动多少天后,将给对方的发送问候消息作为回复。"; +"BusinessMessageSetup.InactivitySectionHeader" = "不活跃的时长"; +"BusinessMessageSetup.Recipients.AddExclude" = "排除聊天…"; +"BusinessMessageSetup.Recipients.AddInclude" = "包括聊天内容..."; +"BusinessMessageSetup.Recipients.AwayMessageFooter" = "选择聊天或整个聊天类型来发送离线信息。\n"; +"BusinessMessageSetup.Recipients.CategoryContacts" = "联系人"; +"BusinessMessageSetup.Recipients.CategoryExistingChats" = "现有聊天"; +"BusinessMessageSetup.Recipients.CategoryNewChats" = "新聊天"; +"BusinessMessageSetup.Recipients.CategoryNonContacts" = "非联系人"; +"BusinessMessageSetup.Recipients.ExcludeSearchTitle" = "不包含的聊天"; +"BusinessMessageSetup.Recipients.ExcludedSectionHeader" = "排除的聊天"; +"BusinessMessageSetup.Recipients.GreetingMessageFooter" = "选择单个聊天或整个聊天分类发送问候消息。"; +"BusinessMessageSetup.Recipients.IncludeSearchTitle" = "包含的聊天"; +"BusinessMessageSetup.Recipients.IncludedSectionHeader" = "包含的聊天"; +"BusinessMessageSetup.RecipientsOptionAllExcept" = "所有私聊,除……"; +"BusinessMessageSetup.RecipientsOptionOnly" = "仅选出的聊天"; +"BusinessMessageSetup.RecipientsSectionHeader" = "接收者"; +"BusinessMessageSetup.ScheduleAlways" = "总是发送"; +"BusinessMessageSetup.ScheduleCustom" = "自定义时间表"; +"BusinessMessageSetup.ScheduleEndTime" = "结束时间 "; +"BusinessMessageSetup.ScheduleOutsideBusinessHours" = "不在营业时间"; +"BusinessMessageSetup.ScheduleSectionHeader" = "时间表"; +"BusinessMessageSetup.ScheduleStartTime" = "开始时间"; +"BusinessMessageSetup.ScheduleTimePlaceholder" = "设置 "; +"BusinessMessageSetup.SendWhenOffline" = "仅当离线时"; +"BusinessMessageSetup.SendWhenOfflineFooter" = "当您最近在线时不发送离开消息。"; +"BusinessMessageSetup.TextAwayMessage" = "当您离开时将自动回复消息。"; +"BusinessMessageSetup.TextGreetingMessage" = "向首次向你发消息或一段时间没有活跃的客户发送问候。"; +"BusinessMessageSetup.TitleAwayMessage" = "离开消息"; +"BusinessMessageSetup.TitleGreetingMessage" = "问候消息"; +"BusinessMessageSetup.ToggleAwayMessage" = "发送离开消息"; +"BusinessMessageSetup.ToggleGreetingMessage" = "发送问候消息"; +"CHANNEL_MESSAGES" = "%1$@ 已发布 %2$@ 条消息"; +"CHANNEL_MESSAGES_SEPARATED" = "%1$@|已发布 %2$@ 条消息"; +"CHANNEL_MESSAGE_AUDIO" = "%1$@ 已发布一条语音消息"; +"CHANNEL_MESSAGE_AUDIO_SEPARATED" = "%1$@|已发布一条语音消息"; +"CHANNEL_MESSAGE_CONTACT" = "%1$@ 已发布一个联系人信息"; +"CHANNEL_MESSAGE_CONTACT_SEPARATED" = "%1$@|已发布一个联系人信息"; +"CHANNEL_MESSAGE_DOC" = "%1$@ 已发布一个文件"; +"CHANNEL_MESSAGE_DOC_SEPARATED" = "%1$@|已发布一个文件"; +"CHANNEL_MESSAGE_FWDS_SEPARATED" = "%1$@|已发布 %2$@ 条转发消息"; +"CHANNEL_MESSAGE_GAME" = "%1$@ 邀请您玩 %2$@"; +"CHANNEL_MESSAGE_GAME_SEPARATED" = "%1$@|邀请您玩 %2$@"; +"CHANNEL_MESSAGE_GEO" = "%1$@ 已发布一张地图"; +"CHANNEL_MESSAGE_GEOLIVE" = "%1$@ 已发布共享实时位置"; +"CHANNEL_MESSAGE_GEOLIVE_SEPARATED" = "%1$@|已发布共享实时位置"; +"CHANNEL_MESSAGE_GEO_SEPARATED" = "%1$@|已发布一张地图"; +"CHANNEL_MESSAGE_GIF" = "%1$@ 已发布一张 GIF 动态图"; +"CHANNEL_MESSAGE_GIF_SEPARATED" = "%1$@|已发布一张 GIF 动态图"; +"CHANNEL_MESSAGE_GIVEAWAY" = "%1$@ 发布了一场抽奖活动,奖品为 %2$@ 份 %3$@ 个月的高级版订阅"; +"CHANNEL_MESSAGE_NOTEXT" = "%1$@ 已发布一条消息"; +"CHANNEL_MESSAGE_NOTEXT_SEPARATED" = "%1$@|已发布一条消息"; +"CHANNEL_MESSAGE_PHOTO" = "%1$@ 已发布一张图片"; +"CHANNEL_MESSAGE_PHOTOS" = "%1$@ 发布了 %2$@ 张图片"; +"CHANNEL_MESSAGE_PHOTOS_SEPARATED" = "%1$@|已发布 %2$@ 张图片"; +"CHANNEL_MESSAGE_PHOTO_SEPARATED" = "%1$@|已发布一张图片"; +"CHANNEL_MESSAGE_POLL" = "%1$@ 发布了一项投票"; +"CHANNEL_MESSAGE_POLL_SEPARATED" = "%1$@|发布了一项投票"; +"CHANNEL_MESSAGE_ROUND" = "%1$@ 已发布一条视频消息"; +"CHANNEL_MESSAGE_ROUND_SEPARATED" = "%1$@|已发布一条视频消息"; +"CHANNEL_MESSAGE_STICKER" = "%1$@ 已发布一张 %2$@ 贴纸"; +"CHANNEL_MESSAGE_STICKER_SEPARATED" = "%1$@|已发布 %2$@ 贴纸"; +"CHANNEL_MESSAGE_TEXT" = "%1$@:%2$@"; +"CHANNEL_MESSAGE_TEXT_SEPARATED" = "%1$@|%2$@"; +"CHANNEL_MESSAGE_VIDEO" = "%1$@ 已发布一段视频"; +"CHANNEL_MESSAGE_VIDEO_SEPARATED" = "%1$@|已发布一段视频"; +"CHAT_ADD_MEMBER" = "%1$@ 已将 %3$@ 加入群组 %2$@"; +"CHAT_ADD_MEMBER_SEPARATED" = "%2$@|%1$@ 已邀请 %3$@ 加入群组"; +"CHAT_ADD_YOU" = "%1$@ 已邀请您加入群组 %2$@"; +"CHAT_ADD_YOU_SEPARATED" = "%2$@|%1$@ 已邀请您加入群组"; +"CHAT_CREATED" = "%1$@ 已邀请您加入群组 %2$@"; +"CHAT_CREATED_SEPARATED" = "%2$@|%1$@ 已邀请您加入群组"; +"CHAT_DELETE_MEMBER" = "%1$@ 已将 %3$@ 移出群组 %2$@"; +"CHAT_DELETE_MEMBER_SEPARATED" = "%2$@|%1$@ 已将 %3$@ 踢出群组"; +"CHAT_DELETE_YOU" = "%1$@ 已将您移出群组 %2$@"; +"CHAT_DELETE_YOU_SEPARATED" = "%2$@|%1$@ 已将您踢出群组"; +"CHAT_JOINED_SEPARATED" = "%2$@|%1$@ 已加入群组"; +"CHAT_LEFT" = "%1$@ 已退出群组 %2$@"; +"CHAT_LEFT_SEPARATED" = "%2$@|%1$@ 已退出群组"; +"CHAT_MESSAGES" = "%1$@ 在群组 %2$@ 中发送了 %3$@ 条消息"; +"CHAT_MESSAGES_SEPARATED" = "%2$@|%1$@ 已发送 %3$@ 条消息"; +"CHAT_MESSAGE_AUDIO" = "%1$@ 在群组 %2$@ 中发送了一条语音消息"; +"CHAT_MESSAGE_AUDIO_SEPARATED" = "%2$@|%1$@ 已发送一条语音消息"; +"CHAT_MESSAGE_CONTACT" = "%1$@ 在群组 %2$@ 中分享了一个联系人信息"; +"CHAT_MESSAGE_CONTACT_SEPARATED" = "%2$@|%1$@ 已分享一个联系人信息"; +"CHAT_MESSAGE_DOC" = "%1$@ 在群组 %2$@ 中发送了一个文件"; +"CHAT_MESSAGE_DOC_SEPARATED" = "%2$@|%1$@ 已发送一个文件"; +"CHAT_MESSAGE_FWDS" = "%1$@ 在群组 %2$@ 中转发了 %3$@ 条消息"; +"CHAT_MESSAGE_FWDS_SEPARATED" = "%2$@|%1$@ 已转发 %3$@ 条消息"; +"CHAT_MESSAGE_GAME" = "%1$@ 已邀请您加入群组 %2$@ 来玩 %3$@"; +"CHAT_MESSAGE_GAME_SEPARATED" = "%2$@|%1$@ 已邀请群组游玩 %3$@"; +"CHAT_MESSAGE_GEO" = "%1$@ 在群组 %2$@ 中发送了一张地图"; +"CHAT_MESSAGE_GEOLIVE" = "%1$@ 已开始在 %2$@ 中共享实时位置"; +"CHAT_MESSAGE_GEOLIVE_SEPARATED" = "%2$@|%1$@ 已开始共享其实时位置"; +"CHAT_MESSAGE_GEO_SEPARATED" = "%2$@|%1$@ 已发送一张地图"; +"CHAT_MESSAGE_GIF" = "%1$@ 在群组 %2$@ 中发送了一张 GIF 动态图"; +"CHAT_MESSAGE_GIF_SEPARATED" = "%2$@|%1$@ 已发送一张 GIF 动态图"; +"CHAT_MESSAGE_GIVEAWAY" = "%1$@ 已在 %2$@ 群组中发送了一场抽奖活动,奖品为 %3$@ 份 %4$@ 个月的高级版订阅"; +"CHAT_MESSAGE_INVOICE" = "%1$@ 在群组 %2$@ 中发送了一份金额为 %3$@ 的账单"; +"CHAT_MESSAGE_INVOICE_SEPARATED" = "%2$@|%1$@ 已发送一份金额为 %3$@ 的账单"; +"CHAT_MESSAGE_NOTEXT" = "%1$@ 在群组 %2$@ 中发送了一条消息"; +"CHAT_MESSAGE_NOTEXT_SEPARATED" = "%2$@|%1$@ 已发送一条消息"; +"CHAT_MESSAGE_NOTHEME" = "%1$@ 已设定群组 %2$@ 的默认主题"; +"CHAT_MESSAGE_PHOTO" = "%1$@ 在群组 %2$@ 中发送了一张图片"; +"CHAT_MESSAGE_PHOTOS" = "%1$@ 在群组 %2$@ 中发送了 %3$@ 张图片"; +"CHAT_MESSAGE_PHOTOS_SEPARATED" = "%2$@|%1$@ 已发送 %3$@ 张图片"; +"CHAT_MESSAGE_PHOTO_SEPARATED" = "%2$@|%1$@ 已发送一张图片"; +"CHAT_MESSAGE_POLL" = "%1$@ 在 %2$@ 中发送了一项投票"; +"CHAT_MESSAGE_POLL_SEPARATED" = "%2$@|%1$@ 已发送一项投票"; +"CHAT_MESSAGE_RECURRING_PAY" = "%1$@|已收取您 %2$@"; +"CHAT_MESSAGE_ROUND" = "%1$@ 在群组 %2$@ 中发送了一条视频消息"; +"CHAT_MESSAGE_ROUND_SEPARATED" = "%2$@|%1$@ 已发送一条视频消息"; +"CHAT_MESSAGE_STICKER" = "%1$@ 在群组 %2$@ 中发送了一张 %3$@ 贴纸"; +"CHAT_MESSAGE_STICKER_SEPARATED" = "%2$@|%1$@ 已发送一张 %3$@ 贴纸"; +"CHAT_MESSAGE_TEXT" = "%1$@@%2$@:%3$@"; +"CHAT_MESSAGE_TEXT_SEPARATED" = "%2$@|%1$@:%3$@"; +"CHAT_MESSAGE_VIDEO" = "%1$@ 在群组 %2$@ 中发送了一段视频"; +"CHAT_MESSAGE_VIDEO_SEPARATED" = "%2$@|%1$@ 已发送一段视频"; +"CHAT_PHOTO_EDITED" = "%1$@ 已编辑群组 %2$@ 的头像"; +"CHAT_REACT_GIVEAWAY" = "%2$@ 群组中的 %1$@ 已对您的抽奖活动做出了 %3$@ 回应"; +"CHAT_RETURNED" = "%1$@ 已返回群组 %2$@"; +"CHAT_RETURNED_SEPARATED" = "%2$@|%1$@ 已返回群组"; +"CHAT_TITLE_EDITED" = "%1$@ 已编辑群组 %2$@ 名称"; +"CHAT_VOICECHAT_INVITE" = "%1$@ 已邀请 %3$@ 加入视频聊天"; +"CHAT_VOICECHAT_INVITE_YOU" = "%1$@ 已邀请您加入视频聊天"; +"CHAT_VOICECHAT_START" = "%1$@ 已发起视频聊天"; +"CONTACT_JOINED" = "%1$@ 已加入 Telegram"; +"CONTACT_JOINED_SEPARATED" = "%1$@ 加入了 Telegram!"; +"Cache.ByPeerHeader" = "聊天"; +"Cache.Clear" = "释放 (%@)"; +"Cache.ClearCache" = "清空缓存"; +"Cache.ClearEmpty" = "无"; +"Cache.ClearNone" = "清除"; +"Cache.ClearProgress" = "请稍候…"; +"Cache.Files" = "文件"; +"Cache.Help" = "为节省磁盘空间,此期限内云端聊天中**未访问过**的图片、视频和其他文件将会被移除。\n\n所有媒体文件都会保留在 Telegram 云端,若您需要可再次下载。"; +"Cache.Indexing" = "Telegram 正在计算当前缓存占用空间。\n这可能需要几分钟。"; +"Cache.KeepMedia" = "媒体文件保留时间"; +"Cache.KeepMediaHelp" = "为节省磁盘空间,此期限内云端聊天中**未访问过**的图片、视频和其他文件将会被移除。"; +"Cache.LowDiskSpaceText" = "你的手机可用空间不足。 请释放一些空间来下载或上传媒体文件。"; +"Cache.MaximumCacheSize" = "缓存大小限制"; +"Cache.MaximumCacheSizeHelp" = "如果您的缓存大小超过此限制,最旧的媒体将从设备中删除。\n所有媒体将保留在 Telegram 云端,如果需要可以重新下载。"; +"Cache.Music" = "音乐"; +"Cache.NoLimit" = "无限"; +"Cache.Photos" = "图片"; +"Cache.ServiceFiles" = "服务文件"; +"Cache.Title" = "存储使用情况"; +"Cache.Videos" = "视频"; +"CacheEvictionMenu.CategoryExceptions#other" = "%@ 条例外"; +"Calendar.ShortFri" = "五"; +"Calendar.ShortFriday" = "五"; +"Calendar.ShortMon" = "一"; +"Calendar.ShortMonday" = "一"; +"Calendar.ShortSat" = "六"; +"Calendar.ShortSaturday" = "六"; +"Calendar.ShortSun" = "日"; +"Calendar.ShortSunday" = "日"; +"Calendar.ShortThu" = "四"; +"Calendar.ShortThursday" = "四"; +"Calendar.ShortTue" = "二"; +"Calendar.ShortTuesday" = "二"; +"Calendar.ShortWed" = "三"; +"Calendar.ShortWednesday" = "三"; +"Call.Accept" = "接听"; +"Call.AddMemberTitle" = "添加成员"; +"Call.AlertMoveToConference" = "结束当前通话并启动群组通话?"; +"Call.AnsweringWithAccount" = "使用 %@ 帐号接听"; +"Call.Audio" = "语音通话"; +"Call.AudioRouteHeadphones" = "耳机"; +"Call.AudioRouteHide" = "隐藏"; +"Call.AudioRouteMute" = "将自己静音"; +"Call.AudioRouteSpeaker" = "扬声器"; +"Call.BatteryLow" = "%@ 的电池电量过低"; +"Call.CallAgain" = "再次呼叫"; +"Call.CallInProgressLiveStreamMessage" = "是否结束与 %1$@ 的通话并在 %2$@ 开始直播?"; +"Call.CallInProgressMessage" = "您是否要挂断与 %1$@ 的通话并与 %2$@ 开始新通话?"; +"Call.CallInProgressTitle" = "有通话正在进行"; +"Call.CallInProgressVoiceChatMessage" = "是否结束与 %1$@ 的通话并与 %2$@ 开始视频聊天?"; +"Call.Camera" = "摄像头"; +"Call.CameraConfirmationConfirm" = "切换"; +"Call.CameraConfirmationText" = "要切换到视频通话吗?"; +"Call.CameraOff" = "%@' 的相机处于关闭状态"; +"Call.CameraOrScreenTooltip" = "打开您的相机或共享您的屏幕内容"; +"Call.CameraTooltip" = "点击这里开启摄像头"; +"Call.ConnectionErrorMessage" = "请检查您的互联网连接并重试。"; +"Call.ConnectionErrorTitle" = "无法呼叫"; +"Call.Days#other" = "%@ 天"; +"Call.Decline" = "挂断"; +"Call.EmojiDescription" = "如果 %@ 屏幕上的表情和此处一致,那么本次通话就是 100%% 安全的。"; +"Call.EncryptedAlertText" = "如果 %@ 屏幕上的表情符号相同,則此通话 100% 安全。"; +"Call.EncryptedAlertTitle" = "此通话为端对端加密"; +"Call.EncryptionKey.Title" = "加密密钥"; +"Call.EncryptionKeyTooltip" = "此通话的加密密钥"; +"Call.End" = "结束"; +"Call.ExternalCallInProgressMessage" = "请先结束当前通话。"; +"Call.Flip" = "翻转摄像头"; +"Call.GenericGroupCallTitle" = "群组通话"; +"Call.GroupFormat" = "%1$@(%2$@)"; +"Call.Hours#other" = "%@ 小时"; +"Call.IncomingGroupCallTitle.Multiple#other" = "{} 和其他 %d 人"; +"Call.IncomingGroupCallTitle.Single" = "%@"; +"Call.IncomingVideoCall" = "视频来电"; +"Call.IncomingVoiceCall" = "视频来电"; +"Call.LiveStreamInProgressCallMessage" = "是否退出 %1$@ 中的直播并与 %2$@ 开始通话?"; +"Call.LiveStreamInProgressConferenceMessage" = "离开 %1$@ 的直播并启动群组通话?"; +"Call.LiveStreamInProgressMessage" = "要离开 %1$@ 中的直播,并在 %2$@ 中开始新直播吗?"; +"Call.LiveStreamInProgressTitle" = "直播进行中"; +"Call.Message" = "输入消息"; +"Call.MicrophoneOff" = "%@ 的麦克风处于关闭状态"; +"Call.Minutes#other" = "%@ 分钟"; +"Call.Mute" = "静音"; +"Call.ParticipantVersionOutdatedError" = "%@ 的应用不支持通话。在您能够呼叫他们之前,他们必须先更新他们的应用。"; +"Call.ParticipantVideoVersionOutdatedError" = "%@ 使用的应用不支持视频通话。在您呼叫前,对方应先更新应用。"; +"Call.PhoneCallInProgressMessage" = "如果您正在进行电话通话,将无法拨打 Telegram 电话。"; +"Call.PrivacyErrorMessage" = "抱歉,由于其隐私设置,您无法呼叫 %@。您可以让其修改设置或呼叫您。"; +"Call.RateCall" = "评价此次通话"; +"Call.RecordingDisabledMessage" = "请在录制语音消息前结束通话。"; +"Call.RemoteVideoPaused" = "%@ 视频画面已暂停"; +"Call.ReportIncludeLog" = "发送技术信息"; +"Call.ReportIncludeLogDescription" = "这不会泄露您的聊天内容,但却能帮助我们尽快修复问题。"; +"Call.ReportPlaceholder" = "出了什么问题?"; +"Call.ReportSend" = "发送"; +"Call.ReportSkip" = "跳过"; +"Call.Seconds#other" = "%@ 秒"; +"Call.ShareLink" = "分享通话链接"; +"Call.ShareStats" = "分享统计数据"; +"Call.ShortMinutes#other" = "%@ 分"; +"Call.ShortSeconds#other" = "%@ 秒"; +"Call.Speaker" = "扬声器"; +"Call.StatusBar" = "轻触此处可返回通话 %@"; +"Call.StatusBusy" = "线路正忙"; +"Call.StatusConnecting" = "连接中…"; +"Call.StatusDeclined" = "来电被拒绝"; +"Call.StatusEnded" = "通话结束"; +"Call.StatusFailed" = "通话失败"; +"Call.StatusIncoming" = "Telegram 语音通话…"; +"Call.StatusMissed" = "来电未接"; +"Call.StatusNoAnswer" = "无应答"; +"Call.StatusOngoing" = "Telegram 语音通话 %@"; +"Call.StatusRequesting" = "连接中…"; +"Call.StatusRinging" = "拨号中…"; +"Call.StatusWaiting" = "等待中…"; +"Call.StatusWeakSignal" = "网络信号弱"; +"Call.Video" = "视频"; +"Call.VoiceChatInProgressCallMessage" = "是否要退出 %1$@ 的视频聊天并呼叫 %2$@?"; +"Call.VoiceChatInProgressConferenceMessage" = "离开 %1$@ 的视频聊天并启动群组通话?"; +"Call.VoiceChatInProgressMessage" = "是否要退出与 %1$@ 的视频聊天并发起与 %2$@ 的视频聊天?"; +"Call.VoiceChatInProgressMessageCall" = "是否要退出与 %1$@ 的视频聊天并呼叫 %2$@?"; +"Call.VoiceChatInProgressTitle" = "正在进行视频聊天"; +"Call.VoiceOver.Minimize" = "通话最小化"; +"Call.VoiceOver.VideoCallCanceled" = "已取消的视频通话"; +"Call.VoiceOver.VideoCallIncoming" = "视频来电"; +"Call.VoiceOver.VideoCallMissed" = "未接视频来电"; +"Call.VoiceOver.VideoCallOutgoing" = "拨出语音通话"; +"Call.VoiceOver.VoiceCallCanceled" = "已取消的语音通话"; +"Call.VoiceOver.VoiceCallIncoming" = "视频来电"; +"Call.VoiceOver.VoiceCallMissed" = "未接视频来电"; +"Call.VoiceOver.VoiceCallOutgoing" = "拨出语音通话"; +"Call.WaitingStatusConnecting" = "正在连接"; +"Call.WaitingStatusReconnecting" = "正在重新连接"; +"Call.WaitingStatusRequesting" = "正在请求"; +"Call.WaitingStatusRinging" = "正在呼叫"; +"Call.YourCameraOff" = "您的相机已关闭"; +"Call.YourMicrophoneOff" = "未开启麦克风"; +"CallFeedback.AddComment" = "添加可选评论"; +"CallFeedback.IncludeLogs" = "提供技术信息"; +"CallFeedback.IncludeLogsInfo" = "这不会泄露您的聊天内容,但却能帮助我们尽快修复问题。"; +"CallFeedback.ReasonDistortedSpeech" = "声音失真"; +"CallFeedback.ReasonDropped" = "通话意外中断"; +"CallFeedback.ReasonEcho" = "我听到了我自己的声音"; +"CallFeedback.ReasonInterruption" = "对方声音断断续续"; +"CallFeedback.ReasonNoise" = "我听到了背景噪音"; +"CallFeedback.ReasonSilentLocal" = "我听不到对方声音"; +"CallFeedback.ReasonSilentRemote" = "对方无法听到我的声音"; +"CallFeedback.Send" = "发送"; +"CallFeedback.Success" = "感谢您的反馈"; +"CallFeedback.Title" = "通话质量反馈"; +"CallFeedback.VideoReasonDistorted" = "视频失真"; +"CallFeedback.VideoReasonLowQuality" = "视频被像素化"; +"CallFeedback.WhatWentWrong" = "出了什么问题?"; +"CallList.ActiveVoiceChatsHeader" = "正在进行的视频聊天"; +"CallList.DeleteAll" = "删除所有"; +"CallList.DeleteAllForEveryone" = "为所有人删除"; +"CallList.DeleteAllForMe" = "为我删除"; +"CallList.DeleteConfirmation" = "您想要删除本次通话的信息吗?"; +"CallList.NewCall" = "开始新通话"; +"CallList.NewCallLink" = "创建通话链接"; +"CallList.RecentCallsHeader" = "最近通话"; +"CallList.ToastCallLinkCopied.Action" = "查看通话记录"; +"CallList.ToastCallLinkCopied.Text" = "通话链接已复制"; +"CallSettings.Always" = "总是"; +"CallSettings.Never" = "从不"; +"CallSettings.OnMobile" = "仅限移动网络"; +"CallSettings.RecentCalls" = "最近通话"; +"CallSettings.TabIcon" = "显示「通话」标签页"; +"CallSettings.TabIconDescription" = "开启后,底栏内将出现一个「通话」图标。"; +"CallSettings.Title" = "通话"; +"CallSettings.UseLessData" = "节省数据流量"; +"CallSettings.UseLessDataLongDescription" = "在较差的网络环境下,节省数据流量能在一定程度上提升通话体验,但是音质和画质将有些许降低。"; +"Calls.AddTab" = "添加此标签页"; +"Calls.All" = "全部"; +"Calls.CallTabDescription" = "您可以将「通话」添加到底栏内。"; +"Calls.CallTabTitle" = "「通话」标签页"; +"Calls.HideCallsTab" = "隐藏通话标签"; +"Calls.Missed" = "未接"; +"Calls.NewCall" = "新建通话"; +"Calls.NoCallsPlaceholder" = "您最近的通话会在这里显示。"; +"Calls.NoMissedCallsPlacehoder" = "您没有未接来电"; +"Calls.NoVoiceAndVideoCallsPlaceholder" = "最近的语音和视频通话记录将在此处显示。"; +"Calls.NotNow" = "稍后"; +"Calls.RatingFeedback" = "添加评价…"; +"Calls.RatingTitle" = "请对您这次 Telegram 通话的质量\n作出评价"; +"Calls.StartNewCall" = "创建新的语音通话"; +"Calls.SubmitRating" = "提交"; +"Calls.TabTitle" = "通话"; +"Camera.CollageDelete" = "删除"; +"Camera.CollageManagementTooltip" = "点击方块可删除或重新排序。"; +"Camera.CollageReorderingInfo" = "按住并拖动滑块以重新排序。"; +"Camera.CollageRetake" = "重拍"; +"Camera.Discard" = "放弃全部"; +"Camera.FlashAuto" = "自动"; +"Camera.FlashOff" = "关闭"; +"Camera.FlashOn" = "打开"; +"Camera.LiveStream.Change" = "更改"; +"Camera.LiveStream.End" = "结束"; +"Camera.LiveStream.End.End" = "结束"; +"Camera.LiveStream.End.Leave" = "退出"; +"Camera.LiveStream.End.Text" = "您确定要结束这次直播吗?"; +"Camera.LiveStream.End.Title" = "结束直播"; +"Camera.LiveStream.StartLiveStream" = "开始直播"; +"Camera.OpenChat" = "打开礼物"; +"Camera.PhotoMode" = "拍照"; +"Camera.Retake" = "重拍"; +"Camera.SquareMode" = "正方形"; +"Camera.TapAndHoldForVideo" = "长按拍摄视频"; +"Camera.Title" = "拍摄照片或视频"; +"Camera.VideoMode" = "录像"; +"CancelResetAccount.Success" = "删除帐号(%@)的请求已被取消 。"; +"CancelResetAccount.TextSMS" = "某个人通过你的手机号码 %@ 发送了删除你的 Telegram 帐号并重置你的两步验证密码的请求。\n\n如果那并非你本人,请输入我们刚刚通过短信给你的手机号码发送的验证码。"; +"CancelResetAccount.Title" = "撤销账号重置"; +"ChangePhone.ErrorOccupied" = "手机号码 %@ 已经和一个 Telegram 帐号绑定。请先删除该帐号再迁移到此新号码。"; +"ChangePhoneNumberCode.CallTimer" = "Telegram 会在 %@ 后使用电话语音播放验证码"; +"ChangePhoneNumberCode.Called" = "Telegram 已拨打您的电话"; +"ChangePhoneNumberCode.Code" = "您的验证码"; +"ChangePhoneNumberCode.CodePlaceholder" = "验证码"; +"ChangePhoneNumberCode.Help" = "我们已向此号码发送短信验证码"; +"ChangePhoneNumberCode.RequestingACall" = "发送呼叫请求中…"; +"ChangePhoneNumberNumber.Help" = "您的新号码将通过电话或短信收到确认码。"; +"ChangePhoneNumberNumber.NewNumber" = "新手机号码"; +"ChangePhoneNumberNumber.NumberPlaceholder" = "输入您的新手机号码"; +"ChangePhoneNumberNumber.Title" = "更换手机号码"; +"Channel.About.Help" = "您可以给您的频道添加一段简介。"; +"Channel.About.Placeholder" = "简介"; +"Channel.About.Title" = "简介"; +"Channel.AboutItem" = "关于"; +"Channel.AddAdminKickedError" = "抱歉,由于该用户在被移除的用户列表中,您不能将其添加为管理员或解除其封禁。"; +"Channel.AddBotAsAdmin" = "设为管理员"; +"Channel.AddBotErrorHaveRights" = "机器人只能作为管理员加入频道。"; +"Channel.AddBotErrorNoRights" = "机器人只能作为管理员加入频道。"; +"Channel.AddUserKickedError" = "抱歉,由于该用户在被移除用户名单上,您无法将其加入频道或解除其封禁。"; +"Channel.AddUserLeftError" = "若用户曾离开群组,您需要成为其联系人才能将其加回群组。\n\n只要其不在已移除用户列表中,该用户仍可通过邀请链接加入群组。"; +"Channel.AdminLog.AddMembers" = "添加成员"; +"Channel.AdminLog.AllowedNewMembersToSpeak" = "%1$@ 已允许新视频聊天参与者发言"; +"Channel.AdminLog.AllowedReactionsUpdated" = "%1$@ 更新了可用的回应: %2$@"; +"Channel.AdminLog.AntiSpamDisabled" = "%1$@ 已停用反垃圾措施"; +"Channel.AdminLog.AntiSpamEnabled" = "%1$@ 已启用反垃圾措施"; +"Channel.AdminLog.BanEmbedLinks" = "嵌入链接预览"; +"Channel.AdminLog.BanReadMessages" = "查看消息"; +"Channel.AdminLog.BanSendGifs" = "发送 GIF"; +"Channel.AdminLog.BanSendMedia" = "发送媒体"; +"Channel.AdminLog.BanSendMessages" = "发送消息"; +"Channel.AdminLog.BanSendStickers" = "发送贴纸"; +"Channel.AdminLog.BanSendStickersAndGifs" = "发送贴纸和 GIF 动态图"; +"Channel.AdminLog.CanAddAdmins" = "添加新的管理员"; +"Channel.AdminLog.CanBanUsers" = "封禁用户"; +"Channel.AdminLog.CanBeAnonymous" = "保持匿名"; +"Channel.AdminLog.CanChangeInfo" = "修改信息"; +"Channel.AdminLog.CanChangeInviteLink" = "通过链接邀请用户"; +"Channel.AdminLog.CanDeleteMessages" = "删除消息"; +"Channel.AdminLog.CanDeleteMessagesOfOthers" = "删除其他人的消息"; +"Channel.AdminLog.CanDeleteStoriesOfOthers" = "删除他人的动态"; +"Channel.AdminLog.CanEditMessages" = "编辑其他用户的消息"; +"Channel.AdminLog.CanEditMessagesOfOthers" = "编辑其他用户的消息"; +"Channel.AdminLog.CanEditStoriesOfOthers" = "编辑他人的动态"; +"Channel.AdminLog.CanInviteUsers" = "添加用户"; +"Channel.AdminLog.CanInviteUsersViaLink" = "通过链接邀请用户"; +"Channel.AdminLog.CanManageCalls" = "管理视频聊天"; +"Channel.AdminLog.CanManageDirect" = "管理私信"; +"Channel.AdminLog.CanManageLiveStreams" = "管理直播"; +"Channel.AdminLog.CanManageTopics" = "管理话题"; +"Channel.AdminLog.CanPinMessages" = "置顶消息"; +"Channel.AdminLog.CanPostStories" = "发布动态"; +"Channel.AdminLog.CanSendMessages" = "发布消息"; +"Channel.AdminLog.CaptionEdited" = "%@ 已编辑说明:"; +"Channel.AdminLog.ChangeInfo" = "修改信息"; +"Channel.AdminLog.ChannelChangedNameColor" = "%1$@ 已将频道名称的颜色更改为 %2$@"; +"Channel.AdminLog.ChannelChangedNameColorAndIcon" = "%1$@ 已将名称、颜色和链接图标更改为 %2$@ %3$@"; +"Channel.AdminLog.ChannelChangedNameIcon" = "%1$@ 已将频道链接图标更改为 %2$@"; +"Channel.AdminLog.ChannelChangedProfileColor" = "%1$@ 已将频道资料页的颜色更改为 %2$@"; +"Channel.AdminLog.ChannelChangedProfileColorAndIcon" = "%1$@ 将频道资料页的颜色和图标更改为 %2$@ %3$@"; +"Channel.AdminLog.ChannelChangedProfileIcon" = "%1$@ 将个人资料页标志更改为 %2$@"; +"Channel.AdminLog.ChannelChangedWallpaper" = "%1$@ 已设置新壁纸"; +"Channel.AdminLog.ChannelEmptyText" = "在过去的 48 小时内频道管理员没有执行任何操作。"; +"Channel.AdminLog.ChannelRemovedProfileColor" = "%1$@ 已重置个人资料页的颜色"; +"Channel.AdminLog.ChannelRemovedProfileColorAndIcon" = "%1$@ 已重置个人资料页的颜色和图标"; +"Channel.AdminLog.ChannelRemovedProfileIcon" = "%1$@ 已删除个人资料页的图标"; +"Channel.AdminLog.ChannelRemovedStatus" = "%1$@ 已移除频道状态"; +"Channel.AdminLog.ChannelRemovedWallpaper" = "%1$@ 已移除壁纸"; +"Channel.AdminLog.ChannelUpdatedStatus" = "%1$@ 已将频道状态更改为 %2$@"; +"Channel.AdminLog.CreatedInviteLink" = "%1$@ 已创建邀请链接 %2$@"; +"Channel.AdminLog.DefaultRestrictionsUpdated" = "默认权限已修改"; +"Channel.AdminLog.DeletedInviteLink" = "%1$@ 已删除邀请链接 %2$@"; +"Channel.AdminLog.DisabledSlowmode" = "%@ 已停用慢速模式"; +"Channel.AdminLog.EditRank" = "编辑成员标签"; +"Channel.AdminLog.EditRankOwn" = "编辑成员标签"; +"Channel.AdminLog.EditedInviteLink" = "%1$@已编辑邀请链接 %2$@"; +"Channel.AdminLog.EmptyFilterQueryText" = "找不到包含“%@”的近期操作。"; +"Channel.AdminLog.EmptyFilterText" = "近期操作中找不到与您查询请求相符的结果。"; +"Channel.AdminLog.EmptyFilterTitle" = "没有符合条件的操作记录"; +"Channel.AdminLog.EmptyMessageText" = "无"; +"Channel.AdminLog.EmptyText" = "在过去的 48 小时内频道成员/管理员没有执行任何操作。"; +"Channel.AdminLog.EmptyTitle" = "暂无近期操作记录"; +"Channel.AdminLog.EndedLiveStream" = "%1$@ 已结束直播"; +"Channel.AdminLog.EndedVoiceChat" = "%1$@ 已结束视频聊天"; +"Channel.AdminLog.InfoPanelAlertText" = "此列表包含了在过去 48 小时内频道管理员执行的所有操作。"; +"Channel.AdminLog.InfoPanelAlertTitle" = "什么是事件记录?"; +"Channel.AdminLog.InfoPanelChannelAlertText" = "此列表包含了在过去 48 小时内频道管理员执行的所有操作。"; +"Channel.AdminLog.InfoPanelTitle" = "这是什么?"; +"Channel.AdminLog.JoinedViaFolderInviteLink" = "%1$@ 通过文件夹邀请链接加入 %2$@"; +"Channel.AdminLog.JoinedViaInviteLink" = "%1$@ 已通过邀请链接 %2$@ 加入"; +"Channel.AdminLog.JoinedViaPublicRequest" = "%2$@ 已批准 %1$@ 通过公开请求加入群组"; +"Channel.AdminLog.JoinedViaRequest" = "%3$@ 已批准 %1$@ 通过邀请链接 %2$@ 加入群组"; +"Channel.AdminLog.ManageTopics" = "管理话题"; +"Channel.AdminLog.MessageAddedAdminName" = "提拔 %1$@"; +"Channel.AdminLog.MessageAddedAdminNameUsername" = "已将 %1$@(%2$@)设为管理员"; +"Channel.AdminLog.MessageAdmin" = "%@ 已修改 %@(%@)的权限"; +"Channel.AdminLog.MessageChangedAutoremoveTimeoutRemove" = "%1$@ 已停用自动删除计时器"; +"Channel.AdminLog.MessageChangedAutoremoveTimeoutSet" = "%1$@ 已将自动删除计时器设为 %2$@"; +"Channel.AdminLog.MessageChangedBackgroundEmojiRemoved" = "%1$@ 已移除背景表情符号"; +"Channel.AdminLog.MessageChangedBackgroundEmojiSet" = "%1$@ 将背景表情符号设为 %2$@"; +"Channel.AdminLog.MessageChangedChannelAbout" = "%@ 已修改频道简介"; +"Channel.AdminLog.MessageChangedChannelUsername" = "%@ 已更换频道链接:"; +"Channel.AdminLog.MessageChangedChannelUsernames" = "%@ 已更改频道链接:"; +"Channel.AdminLog.MessageChangedGroupAbout" = "%@ 已编辑群组简介:"; +"Channel.AdminLog.MessageChangedGroupEmojiPack" = "%@ 已更改群组表情包"; +"Channel.AdminLog.MessageChangedGroupGeoLocation" = "已将群组位置更改为 “%@”"; +"Channel.AdminLog.MessageChangedGroupStickerPack" = "%@ 已更改群组贴纸包"; +"Channel.AdminLog.MessageChangedGroupUsername" = "%@ 已更改群组链接:"; +"Channel.AdminLog.MessageChangedGroupUsernames" = "%@ 已更改群组链接:"; +"Channel.AdminLog.MessageChangedLinkedChannel" = "%1$@ 已将 %2$@ 关联到本群组"; +"Channel.AdminLog.MessageChangedLinkedGroup" = "%1$@ 已将 %2$@ 设为频道的讨论群组"; +"Channel.AdminLog.MessageChangedNameColorSet" = "%1$@ 将名称颜色设为 %2$@"; +"Channel.AdminLog.MessageChangedThemeRemove" = "%1$@ 已停用聊天主题"; +"Channel.AdminLog.MessageChangedThemeSet" = "%1$@ 已将聊天主题更改为 %2$@"; +"Channel.AdminLog.MessageChangedUnlinkedChannel" = "%1$@ 已将此群组与 %2$@ 取消关联"; +"Channel.AdminLog.MessageChangedUnlinkedGroup" = "%1$@ 已移除讨论群组 %2$@"; +"Channel.AdminLog.MessageDeleted" = "%@ 已删除消息:"; +"Channel.AdminLog.MessageEdited" = "%@ 已编辑消息:"; +"Channel.AdminLog.MessageGroupPreHistoryHidden" = "%@ 已设置消息记录对新成员不可见"; +"Channel.AdminLog.MessageGroupPreHistoryVisible" = "%@ 已将消息记录设置为对新成员可见"; +"Channel.AdminLog.MessageInvitedName" = "已邀请 %1$@"; +"Channel.AdminLog.MessageInvitedNameUsername" = "已邀请%1$@(%2$@)"; +"Channel.AdminLog.MessageKickedName" = "已封禁 %1$@"; +"Channel.AdminLog.MessageKickedNameUsername" = "已封禁 %1$@(%2$@)"; +"Channel.AdminLog.MessageManyDeleted" = "%1$@ 已删除 %3$@ 发送的 %2$@"; +"Channel.AdminLog.MessageManyDeleted.HideAll" = "全部隐藏"; +"Channel.AdminLog.MessageManyDeleted.Messages#other" = "%@ 条消息"; +"Channel.AdminLog.MessageManyDeleted.ShowAll" = "全部显示"; +"Channel.AdminLog.MessageManyDeletedMore" = "%1$@ 已删除 %3$@ 发送的 %2$@ %4$@"; +"Channel.AdminLog.MessageParticipantSubscriptionExtended" = "%@ 已延长订阅期。"; +"Channel.AdminLog.MessagePinned" = "%@ 已置顶消息:"; +"Channel.AdminLog.MessagePreviousCaption" = "原说明"; +"Channel.AdminLog.MessagePreviousDescription" = "之前的简介"; +"Channel.AdminLog.MessagePreviousLink" = "之前的链接"; +"Channel.AdminLog.MessagePreviousLinks" = "之前的链接"; +"Channel.AdminLog.MessagePreviousMessage" = "原消息"; +"Channel.AdminLog.MessagePromotedName" = "已修改 %1$@ 的权限"; +"Channel.AdminLog.MessagePromotedNameUsername" = "已修改 %1$@(%2$@)的权限"; +"Channel.AdminLog.MessageRank" = "更改自定义头衔:\n%1$@"; +"Channel.AdminLog.MessageRankName" = "将 %1$@ 的自定义头衔改为:%2$@"; +"Channel.AdminLog.MessageRankNameNew" = "已更改 %1$@ 的成员标签:\n%2$@"; +"Channel.AdminLog.MessageRankNew" = "成员标签已更改:\n%1$@"; +"Channel.AdminLog.MessageRankUsername" = "已将 %1$@(%2$@)的自定义头衔改为 %3$@"; +"Channel.AdminLog.MessageRankUsernameNew" = "已更改 %1$@ (%2$@)的成员标签:\n%3$@"; +"Channel.AdminLog.MessageRemovedAdminName" = "已移除管理员 %1$@"; +"Channel.AdminLog.MessageRemovedAdminNameUsername" = "已移除管理员 %1$@(%2$@)"; +"Channel.AdminLog.MessageRemovedChannelUsername" = "%@ 已移除频道链接"; +"Channel.AdminLog.MessageRemovedGroupEmojiPack" = "%@ 已移除群组的表情包"; +"Channel.AdminLog.MessageRemovedGroupStickerPack" = "%@ 已删除群组贴纸包"; +"Channel.AdminLog.MessageRemovedGroupUsername" = "%@ 已移除群组链接"; +"Channel.AdminLog.MessageRestricted" = "%@ 已修改 %@(%@)的限制"; +"Channel.AdminLog.MessageRestrictedForever" = "直到永久"; +"Channel.AdminLog.MessageRestrictedName" = "已修改对 %1$@ 的限制"; +"Channel.AdminLog.MessageRestrictedNameUsername" = "已修改对 %1$@(%2$@)的限制"; +"Channel.AdminLog.MessageRestrictedNewSetting" = "现在是:%@"; +"Channel.AdminLog.MessageRestrictedUntil" = "直到 %@"; +"Channel.AdminLog.MessageSent" = "%@ 发送了这条消息:"; +"Channel.AdminLog.MessageToggleAutoTranslateOff" = "%@ 已禁用自动翻译"; +"Channel.AdminLog.MessageToggleAutoTranslateOn" = "%@ 已启用自动翻译"; +"Channel.AdminLog.MessageToggleInvitesOff" = "%@ 已停用群组邀请"; +"Channel.AdminLog.MessageToggleInvitesOn" = "%@ 已开启群组成员的邀请权限"; +"Channel.AdminLog.MessageToggleNoForwardsOff" = "%@ 已允许消息转发"; +"Channel.AdminLog.MessageToggleNoForwardsOn" = "%@ 已限制消息转发"; +"Channel.AdminLog.MessageToggleProfileSignaturesOff" = "%@ 已停用作者资料"; +"Channel.AdminLog.MessageToggleProfileSignaturesOn" = "%@ 已启用作者资料"; +"Channel.AdminLog.MessageToggleSignaturesOff" = "%@ 已停用消息署名"; +"Channel.AdminLog.MessageToggleSignaturesOn" = "%@ 已开启消息署名"; +"Channel.AdminLog.MessageTransferedName" = "把所有权转让给 %1$@"; +"Channel.AdminLog.MessageTransferedNameUsername" = "已将所有权转让给 %1$@(%2$@)"; +"Channel.AdminLog.MessageUnkickedName" = "已将 %1$@ 解除封禁"; +"Channel.AdminLog.MessageUnkickedNameUsername" = "已解封 %1$@(%2$@)"; +"Channel.AdminLog.MessageUnpinned" = "%@ 已将一条消息取消置顶"; +"Channel.AdminLog.MessageUnpinnedExtended" = "%@ 已取消置顶消息:"; +"Channel.AdminLog.MutedNewMembers" = "%1$@ 已将新加入视频聊天的参与者静音"; +"Channel.AdminLog.MutedParticipant" = "\n%1$@ 已在视频聊天中将 %2$@ 静音"; +"Channel.AdminLog.PinMessages" = "置顶消息"; +"Channel.AdminLog.PollStopped" = "%@ 已停止投票"; +"Channel.AdminLog.ReactionsDisabled" = "%1$@ 已停用表情回应"; +"Channel.AdminLog.ReactionsEnabled" = "%1$@ 已启用所有表情回应"; +"Channel.AdminLog.RevokedInviteLink" = "%1$@ 已撤销邀请链接 %2$@"; +"Channel.AdminLog.SendPolls" = "发起投票"; +"Channel.AdminLog.SendReactions" = "发出回应"; +"Channel.AdminLog.SetSlowmode" = "%1$@ 将慢速模式的时间改为 %2$@"; +"Channel.AdminLog.Settings" = "设置"; +"Channel.AdminLog.ShowMoreMessages#other" = "显示其他 %@ 条消息"; +"Channel.AdminLog.StartedLiveStream" = "%1$@ 已开始直播"; +"Channel.AdminLog.StartedVoiceChat" = "%1$@ 已发起视频聊天"; +"Channel.AdminLog.TitleAllEvents" = "所有操作"; +"Channel.AdminLog.TitleSelectedEvents" = "选中的操作"; +"Channel.AdminLog.TopicChangedIcon" = "%1$@ 已将主题 %2$@ 的图标更改为 %3$@"; +"Channel.AdminLog.TopicClosed" = "%1$@ 已关闭话题 %2$@"; +"Channel.AdminLog.TopicCreated" = "%1$@ 已创建话题 %2$@"; +"Channel.AdminLog.TopicDeleted" = "%1$@ 已删除话题 %2$@"; +"Channel.AdminLog.TopicHidden" = "%1$@ 已隐藏话题 %2$@"; +"Channel.AdminLog.TopicPinned" = "%1$@ 已置顶话题 %2$@"; +"Channel.AdminLog.TopicRemovedIcon" = "%1$@ 已删除的话题 %2$@ 图标"; +"Channel.AdminLog.TopicRenamed" = "%1$@ 已将主题 %2$@ 重命名为 %3$@"; +"Channel.AdminLog.TopicRenamedWithIcon" = "%1$@ 已将话题 %2$@ 重命名为 %3$@ 并将图标更改为 %4$@"; +"Channel.AdminLog.TopicRenamedWithRemovedIcon" = "%1$@ 已将话题 %2$@ 重命名为 %3$@ 并移除图标"; +"Channel.AdminLog.TopicReopened" = "%1$@ 已重启话题 %2$@"; +"Channel.AdminLog.TopicUnhidden" = "%1$@ 已取消隐藏 %2$@"; +"Channel.AdminLog.TopicUnpinned" = "%1$@ 取消置顶话题 %2$@"; +"Channel.AdminLog.TopicsDisabled" = "%1$@ 已停用话题"; +"Channel.AdminLog.TopicsEnabled" = "%1$@ 已启用话题"; +"Channel.AdminLog.UnmutedMutedParticipant" = "%1$@ 在视频聊天中已取消 %2$@ 的静音"; +"Channel.AdminLog.UpdatedParticipantVolume" = "%1$@ 已将 %2$@ 的音量调整为 %3$@"; +"Channel.AdminLogFilter.AdminsAll" = "所有管理员"; +"Channel.AdminLogFilter.AdminsTitle" = "管理员"; +"Channel.AdminLogFilter.ApplyFilter" = "应用筛选器"; +"Channel.AdminLogFilter.ChannelEventsInfo" = "频道信息"; +"Channel.AdminLogFilter.EventsAdminRights" = "新增管理员权限"; +"Channel.AdminLogFilter.EventsAdmins" = "管理员权限"; +"Channel.AdminLogFilter.EventsAll" = "所有操作"; +"Channel.AdminLogFilter.EventsCalls" = "视频聊天"; +"Channel.AdminLogFilter.EventsDeletedMessages" = "已删除消息"; +"Channel.AdminLogFilter.EventsEditedMessages" = "已编辑消息"; +"Channel.AdminLogFilter.EventsExceptions" = "新增例外"; +"Channel.AdminLogFilter.EventsInfo" = "群组信息"; +"Channel.AdminLogFilter.EventsInviteLinks" = "邀请链接"; +"Channel.AdminLogFilter.EventsLeaving" = "成员退出"; +"Channel.AdminLogFilter.EventsLeavingChannel" = "移除订阅者"; +"Channel.AdminLogFilter.EventsLeavingGroup" = "成员离开群组"; +"Channel.AdminLogFilter.EventsLeavingSubscribers" = "被移除的订阅者"; +"Channel.AdminLogFilter.EventsLiveStreams" = "直播"; +"Channel.AdminLogFilter.EventsNewMembers" = "新成员"; +"Channel.AdminLogFilter.EventsNewSubscribers" = "新的订阅者"; +"Channel.AdminLogFilter.EventsPinned" = "已置顶消息"; +"Channel.AdminLogFilter.EventsRestrictions" = "新限制"; +"Channel.AdminLogFilter.EventsSentMessages" = "发送消息"; +"Channel.AdminLogFilter.EventsTitle" = "操作"; +"Channel.AdminLogFilter.FilterActionsAdminsTitle" = "按管理员筛选操作"; +"Channel.AdminLogFilter.FilterActionsTypeTitle" = "按类型筛选操作"; +"Channel.AdminLogFilter.RecentActionsTitle" = "近期操作"; +"Channel.AdminLogFilter.Section.MembersChannel" = "订阅者和管理员"; +"Channel.AdminLogFilter.Section.MembersGroup" = "成员与管理员"; +"Channel.AdminLogFilter.Section.Messages" = "消息"; +"Channel.AdminLogFilter.Section.SettingsChannel" = "频道设置"; +"Channel.AdminLogFilter.Section.SettingsGroup" = "群组设置"; +"Channel.AdminLogFilter.ShowAllAdminsActions" = "显示所有管理员的操作"; +"Channel.AdminLogFilter.Title" = "筛选器"; +"Channel.Appearance.ApplyButton" = "应用更改"; +"Channel.Appearance.BoostInfo" = "您频道的订阅者可以 **助推** 频道,使其升级并解锁这些功能。"; +"Channel.Appearance.BoostLevel" = "等级 %@"; +"Channel.Appearance.ExampleLinkText" = "此预览也将会著色。"; +"Channel.Appearance.ExampleLinkTitle" = "链接预览"; +"Channel.Appearance.ExampleLinkWebsite" = "Telegram"; +"Channel.Appearance.ExampleReplyText" = "回复您的频道"; +"Channel.Appearance.ExampleText" = "您选择的颜色将用于频道名称"; +"Channel.Appearance.NameColorFooter" = "为频道名称、发送的链接和回复的消息选择颜色。"; +"Channel.Appearance.NameIcon" = "回复标志"; +"Channel.Appearance.ProfileFooter" = "为频道资料页选择颜色和标志。"; +"Channel.Appearance.ProfileHeader" = "个人资料页的颜色"; +"Channel.Appearance.ProfileIcon" = "资料页标志"; +"Channel.Appearance.ResetProfileColor" = "重置个人资料页的颜色"; +"Channel.Appearance.Status" = "频道表情状态"; +"Channel.Appearance.StatusFooter" = "选择将显示在频道名称旁边的状态。"; +"Channel.Appearance.Title" = "外观"; +"Channel.Appearance.ToastAppliedText" = "外观设置已更新"; +"Channel.Appearance.UnsavedChangesAlertApply" = "应用"; +"Channel.Appearance.UnsavedChangesAlertDiscard" = "放弃"; +"Channel.Appearance.UnsavedChangesAlertText" = "您已更改频道外观设置。应用更改吗?"; +"Channel.Appearance.UnsavedChangesAlertTitle" = "未保存的更改"; +"Channel.Appearance.Wallpaper" = "频道壁纸"; +"Channel.Appearance.WallpaperFooter" = "设置每个阅读频道的人都能看到墙纸。"; +"Channel.BanList.BlockedTitle" = "已封禁"; +"Channel.BanList.RestrictedTitle" = "受限制"; +"Channel.BanUser.BlockFor" = "封禁时长"; +"Channel.BanUser.PermissionAddMembers" = "添加成员"; +"Channel.BanUser.PermissionChangeGroupInfo" = "修改群组信息"; +"Channel.BanUser.PermissionEditOwnRank" = "编辑自己的标签"; +"Channel.BanUser.PermissionEditRank" = "编辑成员标签"; +"Channel.BanUser.PermissionEmbedLinks" = "嵌入链接预览"; +"Channel.BanUser.PermissionReadMessages" = "查看消息"; +"Channel.BanUser.PermissionSendFile" = "发送文件"; +"Channel.BanUser.PermissionSendMedia" = "发送媒体文件"; +"Channel.BanUser.PermissionSendMessages" = "发送消息"; +"Channel.BanUser.PermissionSendMusic" = "发送音乐"; +"Channel.BanUser.PermissionSendPhoto" = "发送图片"; +"Channel.BanUser.PermissionSendPolls" = "发起投票"; +"Channel.BanUser.PermissionSendReactions" = "作出回应"; +"Channel.BanUser.PermissionSendStickersAndGifs" = "发送贴纸和 GIF 动态图"; +"Channel.BanUser.PermissionSendVideo" = "发送视频"; +"Channel.BanUser.PermissionSendVideoMessage" = "发送视频消息"; +"Channel.BanUser.PermissionSendVoiceMessage" = "发送语音消息"; +"Channel.BanUser.PermissionsHeader" = "用户限制"; +"Channel.BanUser.Title" = "封禁用户"; +"Channel.BanUser.Unban" = "解除封禁"; +"Channel.BlackList.Title" = "已封禁"; +"Channel.BotDoesntSupportGroups" = "抱歉,这个机器人告诉我们它不想被添加到群组中。 除非开发人员改变主意,否则无法添加此机器人。"; +"Channel.ChannelColor" = "频道颜色"; +"Channel.ChannelSubscribersHeader" = "频道订阅者"; +"Channel.CommentsGroup.Header" = "为您的频道选择一个评论群组。"; +"Channel.CommentsGroup.HeaderGroupSet" = "作为 %@ 的讨论组。"; +"Channel.CommentsGroup.HeaderSet" = "已选择 %@ 为频道的留言群组。"; +"Channel.DiscussionGroup" = "讨论"; +"Channel.DiscussionGroup.Create" = "创建新群组"; +"Channel.DiscussionGroup.Header" = "选择一个将显示在您频道中的讨论群组。"; +"Channel.DiscussionGroup.HeaderGroupSet" = "此群组已作为 %@ 的关联讨论群组。"; +"Channel.DiscussionGroup.HeaderLabel" = "讨论"; +"Channel.DiscussionGroup.HeaderSet" = "频道底部将向所有订阅者显示 %@ 的链接。"; +"Channel.DiscussionGroup.Info" = "您发布在频道中的所有消息将被转发到讨论群组。"; +"Channel.DiscussionGroup.LinkGroup" = "关联群组"; +"Channel.DiscussionGroup.MakeHistoryPublic" = "警告:如果您将私密群组设为频道的讨论群,所有的频道订阅者都就可以进入到这个群组中。“新成员是否可见聊天记录”功能将切换为“可见”。"; +"Channel.DiscussionGroup.MakeHistoryPublicProceed" = "继续"; +"Channel.DiscussionGroup.PrivateChannel" = "私密频道"; +"Channel.DiscussionGroup.PrivateChannelLink" = "您想将 %1$@ 作为 %2$@ 的讨论群吗?\n\n该群组的任何成员都可以查看此频道中的消息。"; +"Channel.DiscussionGroup.PrivateGroup" = "私密群组"; +"Channel.DiscussionGroup.PublicChannelLink" = "您想将 %1$@ 作为 %2$@ 的讨论群组吗?"; +"Channel.DiscussionGroup.SearchPlaceholder" = "搜索"; +"Channel.DiscussionGroup.UnlinkChannel" = "取消关联频道"; +"Channel.DiscussionGroup.UnlinkGroup" = "取消关联群组"; +"Channel.DiscussionGroupAdd" = "添加"; +"Channel.DiscussionGroupInfo" = "添加一个群组聊天以便讨论。"; +"Channel.DiscussionMessageUnavailable" = "抱歉,该贴文已从讨论群组中移除。"; +"Channel.Edit.AboutItem" = "简介"; +"Channel.Edit.LinkItem" = "链接"; +"Channel.Edit.PrivatePublicLinkAlert" = "请注意:若您选择给您的频道创建一条公开链接,任何人都能找到您的频道并关注。\n\n如果您想保持频道的私密性,请不要创建此链接。"; +"Channel.EditAdmin.CannotEdit" = "您无法修改此管理员的权限。"; +"Channel.EditAdmin.GuardBotEnable" = "启用"; +"Channel.EditAdmin.GuardBotEnableMembersText" = "新成员在此群组中发送消息前,现在需要获得 **%@** 的批准。"; +"Channel.EditAdmin.GuardBotEnableSubscribersText" = "新成员加入此频道须经 **%@** 批准。"; +"Channel.EditAdmin.GuardBotReplaceKeep" = "保持 %@"; +"Channel.EditAdmin.GuardBotReplaceText" = "**%@** 正在处理加入请求。切换至 **%@** ?"; +"Channel.EditAdmin.GuardBotReplaceTitle" = "更换机器人"; +"Channel.EditAdmin.GuardBotReplaceUse" = "使用 %@"; +"Channel.EditAdmin.PermissinAddAdminOff" = "此管理员将不能添加新的管理员。"; +"Channel.EditAdmin.PermissinAddAdminOn" = "此管理员将能够添加新的管理员,并赋予其与该管理员同等(或更少)的权限。"; +"Channel.EditAdmin.PermissionAddAdmins" = "添加新的管理员"; +"Channel.EditAdmin.PermissionBanUsers" = "封禁用户"; +"Channel.EditAdmin.PermissionChangeInfo" = "修改频道信息"; +"Channel.EditAdmin.PermissionCreateTopics" = "创建话题"; +"Channel.EditAdmin.PermissionDeleteMessages" = "删除消息"; +"Channel.EditAdmin.PermissionDeleteMessagesOfOthers" = "删除其他用户的消息"; +"Channel.EditAdmin.PermissionDeleteStoriesOfOthers" = "删除其他用户的动态"; +"Channel.EditAdmin.PermissionEditMessages" = "编辑其他用户的消息"; +"Channel.EditAdmin.PermissionEditMessagesOfOthers" = "编辑其他用户的消息"; +"Channel.EditAdmin.PermissionEditStoriesOfOthers" = "编辑其他人的动态"; +"Channel.EditAdmin.PermissionEnabledByDefault" = "群组设定中所有群组成员无权做此操作。"; +"Channel.EditAdmin.PermissionInviteMembers" = "添加成员"; +"Channel.EditAdmin.PermissionInviteSubscribers" = "添加订阅者"; +"Channel.EditAdmin.PermissionInviteUsers" = "添加用户"; +"Channel.EditAdmin.PermissionInviteViaLink" = "通过链接邀请用户"; +"Channel.EditAdmin.PermissionManageRanks" = "编辑成员标签"; +"Channel.EditAdmin.PermissionManageTopics" = "管理话题"; +"Channel.EditAdmin.PermissionPinMessages" = "置顶消息"; +"Channel.EditAdmin.PermissionPostMessages" = "发布消息"; +"Channel.EditAdmin.PermissionPostStories" = "发布动态"; +"Channel.EditAdmin.PermissionProcessJoinRequests" = "处理加入请求"; +"Channel.EditAdmin.PermissionProcessJoinRequestsInfo" = "允许此机器人使用其自身的界面批准新成员,例如验证码。"; +"Channel.EditAdmin.PermissionsHeader" = "这位管理员可以做什么?"; +"Channel.EditAdmin.SectionMessages" = "管理消息"; +"Channel.EditAdmin.SectionStories" = "管理动态"; +"Channel.EditAdmin.TransferOwnership" = "转让频道所有权"; +"Channel.EditMessageErrorGeneric" = "抱歉,您无法编辑这条消息。"; +"Channel.ErrorAccessDenied" = "此频道不可用。"; +"Channel.ErrorAddBlocked" = "抱歉,根据此用户的隐私设置,您无法邀请此人关注频道。"; +"Channel.ErrorAddTooMuch" = "抱歉,您只能在订阅者未满 200 人时手动添加用户。然而通过频道邀请链接关注的用户无此数量限制。"; +"Channel.ErrorAdminsTooMuch" = "抱歉,此频道管理员人数已达上限。"; +"Channel.Info.AppearanceItem" = "外观"; +"Channel.Info.AutoTranslate" = "自动翻译消息"; +"Channel.Info.Banned" = "已封禁"; +"Channel.Info.BlackList" = "移除的用户"; +"Channel.Info.BoostLevelPlusBadge" = "等级 %@+ "; +"Channel.Info.Description" = "简介"; +"Channel.Info.Management" = "管理员"; +"Channel.Info.Members" = "成员"; +"Channel.Info.Settings" = "频道设置"; +"Channel.Info.Stats" = "统计数据"; +"Channel.Info.Stickers" = "群组贴纸包"; +"Channel.Info.Subscribers" = "订阅者"; +"Channel.JoinChannel" = "加入"; +"Channel.LeaveChannel" = "退出频道"; +"Channel.LinkItem" = "分享链接"; +"Channel.Management.AddAdmin" = "添加管理员"; +"Channel.Management.AddModerator" = "添加管理员"; +"Channel.Management.AddModeratorHelp" = "您可以添加管理员帮助您管理频道。"; +"Channel.Management.DismissAdmin" = "解任"; +"Channel.Management.LabelAdministrator" = "管理员"; +"Channel.Management.LabelCreator" = "所有者"; +"Channel.Management.LabelEditor" = "管理员"; +"Channel.Management.LabelOwner" = "所有者"; +"Channel.Management.PromotedBy" = "由 %@ 设为管理员"; +"Channel.Management.RemovedBy" = "被 %@ 移除"; +"Channel.Management.RestrictedBy" = "被 %@ 限制"; +"Channel.Management.SaveChanges" = "保存更改"; +"Channel.Management.Title" = "管理员"; +"Channel.Members.AddAdminErrorBlacklisted" = "抱歉,由于该用户在被移除的用户列表中,您不能将其添加为管理员或解除其封禁。"; +"Channel.Members.AddAdminErrorNotAMember" = "您无法将这位用户设为管理员。因为该用户不是此群组的成员,且您也无权添加该用户。"; +"Channel.Members.AddBannedErrorAdmin" = "您无法封禁这位用户,因为该用户是此群组的管理员,并且您也无权移除其管理员权限。"; +"Channel.Members.AddMembers" = "添加订阅者"; +"Channel.Members.AddMembersHelp" = "此列表仅频道管理员可见。"; +"Channel.Members.Contacts" = "此频道中的联系人"; +"Channel.Members.InviteLink" = "通过链接邀请"; +"Channel.Members.Other" = "其他订阅者"; +"Channel.Members.Title" = "成员"; +"Channel.MessagePhotoRemoved" = "频道头像已被移除"; +"Channel.MessagePhotoUpdated" = "频道头像已更新"; +"Channel.MessageTitleUpdated" = "频道名称已被更改为«%@»"; +"Channel.MessageVideoUpdated" = "频道图片已更新"; +"Channel.Moderator.AccessLevelRevoke" = "解任管理员"; +"Channel.Moderator.Title" = "管理员"; +"Channel.NotificationLoading" = "加载中…"; +"Channel.Owner.Title" = "所有者"; +"Channel.OwnershipTransfer.ChangeOwner" = "更改所有者"; +"Channel.OwnershipTransfer.DescriptionInfo" = "此操作会将频道 **%1$@** 的**所有权**全部转让给 **%2$@**。\n\n您将不再是频道的创建者,新的频道所有者有权移除您的管理员特权,甚至可以封禁您。"; +"Channel.OwnershipTransfer.EnterPassword" = "输入密码"; +"Channel.OwnershipTransfer.EnterPasswordText" = "请输入两步验证密码完成此操作。"; +"Channel.OwnershipTransfer.ErrorAdminsTooMuch" = "抱歉,目前本频道的管理员太多,无法添加新所有者,请先删除一个现有管理员。"; +"Channel.OwnershipTransfer.ErrorPrivacyRestricted" = "抱歉,此用户不是该频道的成员,其隐私设置会阻止您手动添加。"; +"Channel.OwnershipTransfer.ErrorPublicChannelsTooMuch" = "抱歉,目标用户已经有太多的公开群组或频道。 请让他们先将他们现有的一个群组或频道设为私有。"; +"Channel.OwnershipTransfer.PasswordPlaceholder" = "密码"; +"Channel.OwnershipTransfer.Title" = "转让频道所有权"; +"Channel.OwnershipTransfer.TransferCompleted" = "**%1$@** 现在是 **%2$@** 的所有者。"; +"Channel.RemoveFeeAlert.Text" = "您是否要允许 **%@** 免费向频道发送消息?"; +"Channel.Setup.ActivateAlertShow" = "显示"; +"Channel.Setup.ActivateAlertText" = "你想要将此链接显示在频道信息页面上吗?"; +"Channel.Setup.ActivateAlertTitle" = "激活链接"; +"Channel.Setup.ActiveLimitReachedError" = "抱歉,您已经有太多活跃的公开链接。请先隐藏一个活跃的公开链接。"; +"Channel.Setup.ApproveNewSubscribers" = "批准新成员"; +"Channel.Setup.ApproveNewSubscribersInfo" = "启用此选项后,新成员加入前需得到管理员的批准。"; +"Channel.Setup.DeactivateAlertHide" = "隐藏"; +"Channel.Setup.DeactivateAlertText" = "是否在频道信息页面上隐藏此链接?"; +"Channel.Setup.DeactivateAlertTitle" = "停用链接"; +"Channel.Setup.LinkTypePrivate" = "私密"; +"Channel.Setup.LinkTypePublic" = "公开"; +"Channel.Setup.LinksOrder" = "链接顺序"; +"Channel.Setup.LinksOrderInfo" = "拖放链接以更改它们在频道信息页面上的显示顺序。"; +"Channel.Setup.PublicLink" = "公开链接"; +"Channel.Setup.PublicNoLink" = "请为您的公开频道设置一条链接,以便他人能通过搜索找到此频道并分享给他人。\n\n如果您对此不感兴趣,我们建议您创建一个私密频道。"; +"Channel.Setup.Title" = "频道"; +"Channel.Setup.TypeHeader" = "频道类型"; +"Channel.Setup.TypePrivate" = "私密"; +"Channel.Setup.TypePrivateHelp" = "私密频道只能通过邀请链接加入。"; +"Channel.Setup.TypePublic" = "公开"; +"Channel.Setup.TypePublicHelp" = "公开频道可以通过搜索找到,任何人都可以加入。"; +"Channel.ShowAuthors" = "显示作者资料"; +"Channel.ShowAuthorsFooter" = "允许管理员以他们的频道或个人账号身分发布內容,并链接到他们的个人资料。"; +"Channel.SignMessages" = "消息署名"; +"Channel.SignMessages.Help" = "在管理员发送的消息后附上名字。"; +"Channel.SimilarChannels.ShowMore" = "显示更多频道"; +"Channel.SimilarChannels.ShowMoreInfo" = "订阅 [Telegram 高级版]()\n以解锁最多 **100** 个相似频道。"; +"Channel.Status" = "频道"; +"Channel.Stickers.CreateYourOwn" = "您可以通过机器人 @stickers 创建您自己的贴纸包。"; +"Channel.Stickers.NotFound" = "没有符合条件的贴纸包"; +"Channel.Stickers.NotFoundHelp" = "请检查您的输入,或从下方列表中选取一个"; +"Channel.Stickers.Placeholder" = "stickerset"; +"Channel.Stickers.Searching" = "搜索中…"; +"Channel.Stickers.YourStickers" = "从您的贴纸包中选择"; +"Channel.Subscribers.Title" = "订阅者"; +"Channel.TitleInfo" = "频道信息"; +"Channel.TooMuchBots" = "抱歉,此群组的机器人数量已达上限。请先删除一些您不使用的机器人。"; +"Channel.TypeSetup.Title" = "频道类型"; +"Channel.UpdatePhotoItem" = "设置频道头像"; +"Channel.Username.CheckingUsername" = "正在检查用户名…"; +"Channel.Username.CreatePrivateLinkHelp" = "任何人都可以通过此链接关注您的频道。您可以随时废除此链接。"; +"Channel.Username.CreatePublicLinkHelp" = "设置公开链接后,其他人就可以找到并加入您的频道。\n\n您可以使用 **a-z**、**0-9** 和下划线。\n最小长度为 5 个字符。"; +"Channel.Username.Help" = "您可以在 **Telegram** 上为频道设置一个名称。设置后,别人将能够通过此名称找到您的频道。\n\n您可以使用 **a-z**、**0-9** 和下划线。最小长度为 **5** 个字符。"; +"Channel.Username.InvalidCharacters" = "抱歉,此用户名不符合规范。"; +"Channel.Username.InvalidEndsWithUnderscore" = "频道名称不能以下划线结尾。"; +"Channel.Username.InvalidStartsWithNumber" = "公开链接不能以数字开头。"; +"Channel.Username.InvalidStartsWithUnderscore" = "频道名称不能以下划线开头"; +"Channel.Username.InvalidTaken" = "抱歉,此链接已被占用。"; +"Channel.Username.InvalidTooShort" = "公开链接必须包含至少 5 个字符。"; +"Channel.Username.LinkHint" = "通过这个链接可以打开您的频道:[\nhttps://t.me/%@]"; +"Channel.Username.RevokeExistingUsernamesInfo" = "您可以撤消其中一个较旧群组或频道的链接,也可以创建私人频道。"; +"Channel.Username.Title" = "链接"; +"Channel.Username.UsernameIsAvailable" = "%@ 可用。"; +"Channel.Username.UsernamePurchaseAvailable" = "**此用户名已被占用。** 但是,它目前可供购买。[了解更多...]()"; +"ChannelBoost.AdditionalFeaturesText" = "通过获得 **助推**,您的频道可以达到更高等级并解锁更多功能。"; +"ChannelBoost.AskToBoost" = "请您的 **高级版** 订阅者通过此链接助推您的频道:"; +"ChannelBoost.AutoTranslate" = "消息自动翻译"; +"ChannelBoost.AutoTranslateLevelText" = "您的频道需要达到 **等级 %1$@** 才能启用自动翻译。"; +"ChannelBoost.Boost" = "助推"; +"ChannelBoost.BoostAgain" = "再次助推"; +"ChannelBoost.BoostChannel" = "助推频道"; +"ChannelBoost.BoostLinkCopied" = "助推链接已复制到剪贴板"; +"ChannelBoost.BoostLinkForwardTooltip.Chat.One" = "助推链接已转发至 **%@**"; +"ChannelBoost.BoostLinkForwardTooltip.ManyChats.One" = "助推链接已转发到 **%@** 和其他 %@ 人"; +"ChannelBoost.BoostLinkForwardTooltip.SavedMessages.One" = "已将助推链接转发至 **我的收藏**"; +"ChannelBoost.BoostLinkForwardTooltip.TwoChats.One" = "已将助推链接转发给 **%@** 和 **%@**"; +"ChannelBoost.BoostedChannelMoreRequired" = "%1$@ 需要能够发布 %2$@ 的权限."; +"ChannelBoost.BoostedChannelReachedLevel" = "此频道已达到 **等级\u00a0%1$@**,现在每天可发布 %2$@。"; +"ChannelBoost.Copy" = "复制"; +"ChannelBoost.CopyLink" = "复制链接"; +"ChannelBoost.CustomReactions" = "自定义表情回应"; +"ChannelBoost.CustomReactionsText" = "您的频道需要达到 **等级 %1$@** 才能添加 **%2$@** 个自定义表情回应。\n\n邀请您的 **高级版** 订阅者们通过此链接助推频道:"; +"ChannelBoost.CustomWallpaper" = "设置自定频道壁纸"; +"ChannelBoost.EmojiStatus" = "使用表情符号状态"; +"ChannelBoost.EnableColors" = "启用颜色"; +"ChannelBoost.EnableColorsLevelText" = "您的频道需要 **等级 %1$@** 才能更改频道颜色。 \n\n请您的 **Telegram 高级版** 订阅者通过此链接助推您的频道:"; +"ChannelBoost.EnableColorsText" = "您的频道需要达到 **等级 5 ** 才能更改频道颜色。\n\n请 **Telegram 高级版** 订阅者通过此链接来助推您的频道:"; +"ChannelBoost.EnableCustomWallpaperLevelText" = "您的频道需要 ** 等级 %1$@** 才能设置自定义壁纸。"; +"ChannelBoost.EnableEmojiStatusLevelText" = "您的频道需要达到 **等级 %1$@** 才能使用表情状态"; +"ChannelBoost.EnableNameColorLevelText" = "你的频道需要达到 **等级 %1$@** 才能更改颜色。"; +"ChannelBoost.EnableNameIconLevelText" = "您的频道需要达到 **等级 %1$@** 才能更改频道链接标志。 "; +"ChannelBoost.EnableNoAdsLevelText" = "您的频道需要达到 **等级 %1$@** 才能关闭广告。"; +"ChannelBoost.EnableProfileColorLevelText" = "您的频道需要达到 **等级 %1$@ ** 才能更改频道颜色。"; +"ChannelBoost.EnableProfileIconLevelText" = "您的频道需要达到 **等级 %1$@** 才能更改封面标志。"; +"ChannelBoost.EnableStories" = "启用动态"; +"ChannelBoost.EnableStoriesForChannel" = "为此频道启用动态"; +"ChannelBoost.EnableStoriesForChannelText" = "**%1$@** 还需要 %2$@ 才能发布动态。帮助实现这个目标吧!"; +"ChannelBoost.EnableStoriesForOtherChannel" = "为此频道启用动态"; +"ChannelBoost.EnableStoriesMoreRequired" = "此频道需要 %1$@ 来启用动态功能。"; +"ChannelBoost.EnableStoriesText" = "您的频道还需要 %1$@ 才能发布动态。\n\n请 **Premium 高级版** 订阅者通过此链接来助推您的频道:"; +"ChannelBoost.EnableWallpaperLevelText" = "您的频道要达到 **等级 %1$@** 才能设置频道壁纸。"; +"ChannelBoost.EnabledStoriesForChannelText" = "此频道已达到 **等级 1**,现在可以发布动态了。"; +"ChannelBoost.Error.BoostTooOftenText" = "您每天只能更改一次助推的频道。 您可以在 **%@** 后再次助推。"; +"ChannelBoost.Error.BoostTooOftenTitle" = "不能频繁助推!"; +"ChannelBoost.Error.GiftedPremiumNotAllowedText" = "由于您的 **Telegram 高级版** 是通过赠送获得的,所以您无法用其助推频道。"; +"ChannelBoost.Error.GiftedPremiumNotAllowedTitle" = "无法使用受赠的 Telegram 高级版助推!"; +"ChannelBoost.Error.PremiumNeededText" = "只有 **Telegram 高级版** 订阅者可以助推频道。您是否要订阅 **Telegram 高级版**?"; +"ChannelBoost.Error.PremiumNeededTitle" = "需要订阅 Telegram 高级版"; +"ChannelBoost.Header.Boost" = "助推"; +"ChannelBoost.Header.Features" = "功能"; +"ChannelBoost.Header.Giveaway" = "抽奖活动"; +"ChannelBoost.HelpUpgradeChannel" = "帮助升级此频道"; +"ChannelBoost.HelpUpgradeChannelText" = "**%1$@** 需要 %2$@ 才能发布 %3$@。"; +"ChannelBoost.IncreaseLimit" = "增加动态上限"; +"ChannelBoost.IncreaseLimitText" = "您的频道需要 %1$@ 才能每天发布 %2$@。\n\n请 **Telegram 高级版** 订阅者通过此链接来助推您的频道:"; +"ChannelBoost.Info" = "您频道的订阅者可以 **助推** 您的频道,使其 **升级** 并获得 **专属功能**。"; +"ChannelBoost.Level" = "等级 %@"; +"ChannelBoost.MaxLevelReached" = "已达到最高等级"; +"ChannelBoost.MaxLevelReached.Text" = "**%1$@** 已达到 **等级 %2$@**."; +"ChannelBoost.MaxLevelReachedText" = "此频道已达到 **等级 %1$@**,现在可发布 %2$@。"; +"ChannelBoost.MaxLevelReachedTextAuthor" = "此频道已达到 **等级 %1$@**,现在可发布 %2$@。"; +"ChannelBoost.MoreBoosts#other" = "额外 **%@** 次助推"; +"ChannelBoost.MoreBoosts.Gift" = "赠品 Telegram 高级版订阅"; +"ChannelBoost.MoreBoosts.Text" = "要再次助推 **%1$@**,请将 **Telegram 高级版** 赠予朋友即可获得 **%2$@** 次额外助推。"; +"ChannelBoost.MoreBoosts.Title" = "需要更多助推"; +"ChannelBoost.MoreBoostsNeeded.Boosted.Level.Text" = "此频道已达到 **%@ 级** 并已解锁新功能。"; +"ChannelBoost.MoreBoostsNeeded.Boosted.Text" = "还需要 %@ 次助推来获得新功能。 "; +"ChannelBoost.MoreBoostsNeeded.Boosts#other" = "更多 **%@** 此助推"; +"ChannelBoost.MoreBoostsNeeded.Text" = "**%1$@** 需要 %2$@ 来解锁新功能。"; +"ChannelBoost.NameColor" = "启用颜色"; +"ChannelBoost.NameIcon" = "设置链接图标"; +"ChannelBoost.NoAds" = "关闭广告"; +"ChannelBoost.Or" = "或"; +"ChannelBoost.PremiumUsersCanBoost" = "**高级版** 用户可通过此链接助推你的频道:"; +"ChannelBoost.ProfileColor" = "设置封面颜色"; +"ChannelBoost.ProfileIcon" = "设置封面图标"; +"ChannelBoost.Replace" = "更换"; +"ChannelBoost.ReplaceBoost" = "您目前正在助推 **%1$@**,您是否想改为助推 **%2$@**?"; +"ChannelBoost.StoriesPerDay#other" = "每天发布 **%@ 条** 动态"; +"ChannelBoost.Table.AutoTranslate" = "消息自动翻译"; +"ChannelBoost.Table.CustomReactions#other" = "%@ 个自定义表情回应"; +"ChannelBoost.Table.CustomWallpaper" = "自定义频道壁纸"; +"ChannelBoost.Table.EmojiStatus" = "1000+ 表情符号状态"; +"ChannelBoost.Table.Group.CustomWallpaper" = "自定义群组背景"; +"ChannelBoost.Table.Group.ProfileColor#other" = "%@ 种群组封面支持的颜色"; +"ChannelBoost.Table.Group.ProfileLogo" = "为群组的封面自定义标志"; +"ChannelBoost.Table.Group.Wallpaper#other" = "%@ 个群组背景"; +"ChannelBoost.Table.HeadersLogo" = "链接和引用的自定义标志"; +"ChannelBoost.Table.Level#other" = "等级 %@"; +"ChannelBoost.Table.LevelUnlocks#other" = "等级 %@ 解锁:"; +"ChannelBoost.Table.NameColor#other" = "%@ 种频道名称颜色"; +"ChannelBoost.Table.NoAds" = "关闭广告"; +"ChannelBoost.Table.ProfileColor#other" = "%@ 种频道封面颜色"; +"ChannelBoost.Table.ProfileLogo" = "频道封面的自定义标志"; +"ChannelBoost.Table.StoriesPerDay#other" = "每天 %@ 条状态"; +"ChannelBoost.Table.StyleForHeaders#other" = "%@ 种链接及引用样式"; +"ChannelBoost.Table.Wallpaper#other" = "%@ 种频道壁纸"; +"ChannelBoost.Table.WearGift" = "佩戴典藏品"; +"ChannelBoost.Title" = "助推频道"; +"ChannelBoost.Title.Current" = "助推频道"; +"ChannelBoost.Title.Other" = "帮助升级此频道"; +"ChannelBoost.Wallpaper" = "设置频道壁纸"; +"ChannelBoost.WearGift" = "佩带礼物"; +"ChannelBoost.WearGiftLevelText" = "你的频道需要达到 **等级 %1$@** 才能佩戴典藏品。"; +"ChannelBoost.YouBoostedChannel" = "您助推了 %1$@!"; +"ChannelBoost.YouBoostedChannelText" = "你已助推 %1$@!"; +"ChannelBoost.YouBoostedOtherChannel" = "您助推了此频道!"; +"ChannelBoost.YouBoostedOtherChannelText" = "您已助推此频道"; +"ChannelInfo.AddParticipantConfirmation" = "是否将 %@ 添加到此频道?"; +"ChannelInfo.ChannelForbidden" = "抱歉,频道«%@»已无法访问。"; +"ChannelInfo.ConfirmLeave" = "退出频道"; +"ChannelInfo.CreateExternalStream" = "以其它方式直播..."; +"ChannelInfo.CreateLiveStream" = "发起直播"; +"ChannelInfo.CreateVoiceChat" = "开始视频聊天"; +"ChannelInfo.DeleteChannel" = "删除频道"; +"ChannelInfo.DeleteChannelConfirmation" = "删除此频道将会移除所有关注者并且会丢失所有消息。确定要删除此频道?"; +"ChannelInfo.DeleteGroup" = "删除群组"; +"ChannelInfo.DeleteGroupConfirmation" = "删除本群组将会移除所有成员并导致所有消息记录丢失,确认要这样做吗?"; +"ChannelInfo.FakeChannelWarning" = "⚠️ 警告:大量用户举报该账号冒名顶替名人或组织。"; +"ChannelInfo.InviteLink.RevokeAlert.Text" = "您确定要撤销此链接吗?一旦撤销,将无法使用该链接加入此频道。"; +"ChannelInfo.ScamChannelWarning" = "⚠️ 警告:该账号被大量用户举报存在欺诈行为。请勿与其进行转账交易,以免上当受骗。"; +"ChannelInfo.ScheduleLiveStream" = "预约直播"; +"ChannelInfo.ScheduleVoiceChat" = "预约视频聊天"; +"ChannelInfo.Stats" = "统计"; +"ChannelIntro.CreateChannel" = "创建频道"; +"ChannelIntro.Text" = "频道是一种\n能够将您的消息\n广播给无数关注者的工具。"; +"ChannelIntro.Title" = "频道是什么?"; +"ChannelMembers.ChannelAdminsTitle" = "频道管理员"; +"ChannelMembers.GroupAdminsTitle" = "群组管理员"; +"ChannelMembers.WhoCanAddMembers" = "谁可以邀请成员"; +"ChannelMembers.WhoCanAddMembers.Admins" = "仅限管理员"; +"ChannelMembers.WhoCanAddMembers.AllMembers" = "所有成员"; +"ChannelMembers.WhoCanAddMembersAdminsHelp" = "只有管理员有权添加新成员。"; +"ChannelMembers.WhoCanAddMembersAllHelp" = "所有人都能邀请成员"; +"ChannelMessages.Info" = "允许用户向您的频道发送消息,并可选择对每条消息收取费用。"; +"ChannelMessages.LinkTitle" = "私信链接"; +"ChannelMessages.PriceSectionFooter" = "每收到一条消息,您的频道将获取设置费用的 85%。"; +"ChannelMessages.PriceSectionFooterValue" = "您每收到一条消息将获得所选费用的 %1$@%。"; +"ChannelMessages.PriceSectionTitle" = "每条消息的价格"; +"ChannelMessages.SwitchTitle" = "私信"; +"ChannelMessages.Title" = "私信"; +"ChannelProfile.AboutActionCopy" = "复制"; +"ChannelProfile.ToastAboutCopied" = "已拷贝至剪贴板。"; +"ChannelReactions.GeneralInfoLabel" = "您可以添加任意表情包中的表情作为回应。"; +"ChannelReactions.InputPlaceholder" = "添加表情回应..."; +"ChannelReactions.LevelRequiredLabel" = "需要等级 %1$@"; +"ChannelReactions.Reactions" = "表情回应"; +"ChannelReactions.ReactionsInfoLabel" = "您也可以 [创建自己的]() 表情包并使用它们。"; +"ChannelReactions.ReactionsSectionTitle" = "可选表情回应"; +"ChannelReactions.SaveAction" = "更新表情回应"; +"ChannelReactions.ToastLevelBoostRequired" = "您的频道需要达到 **等级 %1$@** 才能添加 **%2$@** 个自定义表情回应。"; +"ChannelReactions.ToastLevelBoostRequiredTemplate" = "您的频道需要达到 **%1$@** 添加 **%2$@** 作为回应。"; +"ChannelReactions.ToastLevelBoostRequiredTemplateEmojiCount#other" = "%d 个自定义表情符号"; +"ChannelReactions.ToastLevelBoostRequiredTemplateLevel#other" = "等级 %d"; +"ChannelReactions.ToastMaxReactionsReached" = "您最多可以选择 100 个表情回应。"; +"ChannelReactions.UnsavedChangesAlertApply" = "应用"; +"ChannelReactions.UnsavedChangesAlertDiscard" = "放弃"; +"ChannelReactions.UnsavedChangesAlertText" = "您已更改表情回应列表,是否应用更改?"; +"ChannelReactions.UnsavedChangesAlertTitle" = "未保存的更改"; +"ChannelRemoved.RemoveInfo" = "被管理员移除的用户不能通过邀请链接重新加入频道。"; +"Chat.AdminAction.ToastBannedText#other" = "已封禁 **%d** 位用户。"; +"Chat.AdminAction.ToastMessagesAndReactionsDeletedText" = "消息和回应已删除。"; +"Chat.AdminAction.ToastMessagesDeletedTextMultiple" = "消息已被删除。"; +"Chat.AdminAction.ToastMessagesDeletedTextSingle" = "消息已删除。"; +"Chat.AdminAction.ToastMessagesDeletedTitleMultiple" = "消息已删除"; +"Chat.AdminAction.ToastMessagesDeletedTitleSingle" = "消息已被删除"; +"Chat.AdminAction.ToastReactionsDeletedTextMultiple" = "回应已删除。"; +"Chat.AdminAction.ToastReactionsDeletedTextSingle" = "已删除回应。"; +"Chat.AdminAction.ToastReactionsDeletedTitleSingle" = "回应已删除"; +"Chat.AdminAction.ToastReportedSpamText#other" = "已举报 **%d** 位用户发送垃圾消息。"; +"Chat.AdminAction.ToastRestrictedText#other" = "已限制 **%d** 位用户。"; +"Chat.AdminActionSheet.ActionButton" = "继续"; +"Chat.AdminActionSheet.BanFooterMultiple" = "彻底封禁用户"; +"Chat.AdminActionSheet.BanFooterSingle" = "彻底封禁此用户"; +"Chat.AdminActionSheet.BanMultiple" = "封禁用户"; +"Chat.AdminActionSheet.BanSingle" = "封禁 %@"; +"Chat.AdminActionSheet.DeleteAllMessages" = "删除全部消息"; +"Chat.AdminActionSheet.DeleteAllMultiple" = "删除该用户发送的所有消息"; +"Chat.AdminActionSheet.DeleteAllReactions" = "删除全部反应"; +"Chat.AdminActionSheet.DeleteAllSingle" = "删除来自 %@ 的所有消息"; +"Chat.AdminActionSheet.DeleteReactionTitle" = "删除 1 个回应"; +"Chat.AdminActionSheet.DeleteTitle#other" = "删除 %d 条消息"; +"Chat.AdminActionSheet.PermissionsSectionHeader" = "这位用户可以做什么?"; +"Chat.AdminActionSheet.PermissionsSectionHeaderMultiple" = "这些用户可以做什么?"; +"Chat.AdminActionSheet.ReportSpam" = "举报垃圾消息"; +"Chat.AdminActionSheet.RestrictFooterMultiple" = "部分限制用户"; +"Chat.AdminActionSheet.RestrictFooterSingle" = "部分限制此用户"; +"Chat.AdminActionSheet.RestrictMultiple" = "限制用户"; +"Chat.AdminActionSheet.RestrictSectionHeader" = "更多选项"; +"Chat.AdminActionSheet.RestrictSingle" = "限制 %@"; +"Chat.AttachmentLimitExceeded" = "抱歉,您不能一次选中多个项目。"; +"Chat.AttachmentLimitReached" = "您无法选择更多项目。"; +"Chat.AttachmentMultipleFilesDisabled" = "慢速模式已启用,您不能一次发送多个文件。"; +"Chat.AttachmentMultipleForwardDisabled" = "慢速模式已启用,您不能一次转发多条消息。"; +"Chat.Auction" = "礼物拍卖"; +"Chat.Auction.Finished" = "已结束"; +"Chat.Auction.Gifts#other" = "%@ 个礼物"; +"Chat.Auction.Join" = "加入"; +"Chat.Auction.StartsIn" = "距离开始"; +"Chat.Auction.Upcoming" = "即将开始的拍卖"; +"Chat.Auction.View" = "查看"; +"Chat.Auction.ViewResults" = "查看结果"; +"Chat.AudioTranscriptionFeedbackTip" = "感谢您的反馈。"; +"Chat.AudioTranscriptionRateAction" = "评价该转换结果"; +"Chat.BirthdayTooltip" = "🎂 您今天可以向 %1$@ 发送生日礼物。"; +"Chat.BotAd.Title" = "广告"; +"Chat.BotAd.WhatIsThis" = "这是什么?"; +"Chat.BottomSearchPanel.DisplayModeChat" = "聊天"; +"Chat.BottomSearchPanel.DisplayModeFormat" = "以 %@ 模式显示"; +"Chat.BottomSearchPanel.DisplayModeList" = "列表"; +"Chat.BottomSearchPanel.MessageCount#other" = "条消息"; +"Chat.BottomSearchPanel.MessageCountFormat" = "%1$@ %2$@"; +"Chat.BottomSearchPanel.StoryCount#other" = "动态"; +"Chat.BusinessBotMessageTooltip" = "只有您可以看到\n此消息是由机器人发送的。"; +"Chat.BusinessBotPanel.ActionStart" = "启用"; +"Chat.BusinessBotPanel.ActionStop" = "停用"; +"Chat.BusinessBotPanel.Menu.ManageBot" = "管理机器人"; +"Chat.BusinessBotPanel.Menu.RemoveBot" = "从此聊天中移除机器人"; +"Chat.BusinessBotPanel.StatusHasAccess" = "机器人有权访问此聊天"; +"Chat.BusinessBotPanel.StatusManages" = "机器人管理此聊天"; +"Chat.BusinessBotPanel.StatusPaused" = "机器人已暂停"; +"Chat.CallMessage.CancelledGroupCall" = "已取消的群组通话"; +"Chat.CallMessage.DeclinedGroupCall" = "已拒接群组通话"; +"Chat.CallMessage.GroupCallParticipantCount#other" = "%d 个人"; +"Chat.CallMessage.IncomingGroupCall" = "呼入群组通话"; +"Chat.CallMessage.MissedGroupCall" = "未接群组通话"; +"Chat.CallMessage.OutgoingGroupCall" = "拨出群组通话"; +"Chat.ChannelMessagesHint" = "向频道管理员发送消息"; +"Chat.ChannelMessagesHintBadge" = "新"; +"Chat.ChannelRecommendation.PremiumTooltip" = "订阅 [Telegram 高级版]() 以解锁最多 **100** 个相似频道。"; +"Chat.ClearReactionsAlertAction" = "清空最近使用的表情"; +"Chat.ClearReactionsAlertText" = "您确定要从建议中清空最近使用的表情回应吗?"; +"Chat.CommandList.EditQuickReplies" = "编辑快速回复"; +"Chat.ConfirmationDeleteFromSavedMessages" = "从“我的收藏”中删除"; +"Chat.ConfirmationRemoveFromSavedMessages" = "从我的收藏中删除"; +"Chat.ContactChannel" = "联系频道"; +"Chat.Context.Card.Copy" = "复制卡号"; +"Chat.Context.Command.Copy" = "复制命令"; +"Chat.Context.Date.AddToCalendar" = "添加到日历"; +"Chat.Context.Date.Copy" = "复制日期"; +"Chat.Context.Date.SetReminder" = "设置提醒"; +"Chat.Context.Hashtag.Copy" = "复制标签"; +"Chat.Context.Hashtag.Search" = "搜索"; +"Chat.Context.Phone.AddToContacts" = "添加至联系人"; +"Chat.Context.Phone.AddToExistingContact" = "添加至现有的联系人"; +"Chat.Context.Phone.CallViaCarrier" = "通过运营商呼叫"; +"Chat.Context.Phone.CopyNumber" = "拷贝号码"; +"Chat.Context.Phone.CreateNewContact" = "新建联系人"; +"Chat.Context.Phone.InviteToTelegram" = "邀请使用 Telegram"; +"Chat.Context.Phone.NotOnTelegram" = "此号码未加入 Telegram。"; +"Chat.Context.Phone.SendMessage" = "发送消息"; +"Chat.Context.Phone.TelegramVideoCall" = "Telegram 视频通话"; +"Chat.Context.Phone.TelegramVoiceCall" = "Telegram 语音通话"; +"Chat.Context.Phone.ViewProfile" = "查看个人资料"; +"Chat.Context.Timecode.Copy" = "复制时间码"; +"Chat.Context.Username.Copy" = "复制用户名"; +"Chat.Context.Username.NotOnTelegram" = "该用户在 Telegram 上不存在 。"; +"Chat.Context.Username.OpenChannel" = "打开频道"; +"Chat.Context.Username.OpenGroup" = "打开群组"; +"Chat.Context.Username.SendMessage" = "发送消息"; +"Chat.ContextMenu.AboutAd" = "关于此广告"; +"Chat.ContextMenu.AdSponsorInfo" = "广告商信息"; +"Chat.ContextMenu.AdSponsorInfoCopied" = "文字已复制。"; +"Chat.ContextMenu.AuthorInfo" = "来自 %@"; +"Chat.ContextMenu.OpenInApp" = "在 App 内打开"; +"Chat.ContextMenu.OpenInBrowser" = "在浏览器中打开"; +"Chat.ContextMenu.RemoveAd" = "移除广告"; +"Chat.ContextMenu.ReportAd" = "举报广告"; +"Chat.ContextMenu.SuggestedPost.Create" = "建议贴文"; +"Chat.ContextMenu.SuggestedPost.EditMessage" = "编辑消息"; +"Chat.ContextMenu.SuggestedPost.EditPrice" = "编辑价格"; +"Chat.ContextMenu.SuggestedPost.EditTime" = "编辑时间"; +"Chat.ContextMenuReadDate.ReadAvailableBadge" = "显示时间"; +"Chat.ContextMenuReadDate.ReadAvailablePrefix" = "已读"; +"Chat.ContextMenuReadDate.ReadFormat" = "%@ 已读"; +"Chat.ContextMenuTagsTitle" = "使用表情符号标签消息以便快速搜索"; +"Chat.ContextReactionCount#other" = "%@ 个表情回应"; +"Chat.ContextViewAsMessages" = "以消息模式查看"; +"Chat.ContextViewAsTopics" = "以话题模式查看"; +"Chat.CreateBotLink" = "创建机器人"; +"Chat.CreateTopic" = "新建话题"; +"Chat.DeleteMessagesConfirmation#other" = "删除 %@ 条消息"; +"Chat.DeletePaidMessage.Action" = "仍然删除"; +"Chat.DeletePaidMessageStars.Text" = "如果您现在删除此贴文,您将不会收到 **星星**。此贴文发布后必须保持至少在 **24 小时** 内可见。"; +"Chat.DeletePaidMessageStars.Title" = "星星将会丢失"; +"Chat.DeletePaidMessageTon.Text" = "如果您现在删除此贴文,您将不会收到 **Gram**。此贴文发布后必须保持至少在 **24 小时** 内可见。"; +"Chat.DeletePaidMessageTon.Title" = "Gram 将会失去"; +"Chat.DeleteReactionInfo" = "点击并按住即可删除回应。"; +"Chat.EditTagTitle.Placeholder" = "名称"; +"Chat.EditTagTitle.Text" = "您可以使用文本给表情标签起一个名字。"; +"Chat.EditTagTitle.TitleEdit" = "编辑名称"; +"Chat.EditTagTitle.TitleSet" = "设置名称"; +"Chat.EmptyState.QuickReply.Text1" = "• 请在下面输入一条消息,输入“**/%@*” 后,将在聊天中发送该消息。"; +"Chat.EmptyState.QuickReply.Text2" = "• 您可以通过键入 “/” 或使用 “附件” 菜单在任何聊天中访问快速回复。"; +"Chat.EmptyState.QuickReply.Title" = "新的快速回复"; +"Chat.EmptyStateIntroFooter" = "%@ 为所有空聊天添加了上述消息"; +"Chat.EmptyStateIntroFooterAction" = "怎么做?"; +"Chat.EmptyStateIntroFooterPremiumActionButton" = "好"; +"Chat.EmptyStateMessagingRestrictedToPremium.Action" = "获取高级版"; +"Chat.EmptyStateMessagingRestrictedToPremium.Text" = "订阅 **高级版**\n即可向 **%@** 发送消息。"; +"Chat.EmptyStateMessagingRestrictedToPremiumDisabled.Text" = "订阅 **Telegram 高级版** 以发送信息 **%@**。"; +"Chat.EmptyStateMonoforum.Text" = "向 **%@** 的管理员发送私信。"; +"Chat.EmptyStateMonoforumPaid.Text" = "**%1$@** 对每条发送给管理员的消息\n收取 **%2$@** 颗星星。"; +"Chat.EmptyStatePaidMessaging.Action" = "购买星星"; +"Chat.EmptyStatePaidMessaging.Text" = "**%1$@** 对每条消息收取 **%2$@**。"; +"Chat.EmptyStatePaidMessagingDisabled.Text" = "**%1$@** 对每条消息收取 **%2$@**。"; +"Chat.EmptyTopicPlaceholder.Text" = "发送一条消息\n以开始话题。"; +"Chat.EmptyTopicPlaceholder.Title" = "就要完成了!"; +"Chat.EnableSharingOffer.Accept" = "接受"; +"Chat.EnableSharingOffer.AcceptConfirmation.Accept" = "启用"; +"Chat.EnableSharingOffer.AcceptConfirmation.Text" = "您确定要启用分享功能吗?"; +"Chat.EnableSharingOffer.AcceptConfirmation.Title" = "启用分享"; +"Chat.EnableSharingOffer.Reject" = "拒绝"; +"Chat.EnableSharingOffer.RejectConfirmation.Reject" = "保持禁用"; +"Chat.EnableSharingOffer.RejectConfirmation.Text" = "您确定要继续禁用分享功能吗?"; +"Chat.EnableSharingOffer.RejectConfirmation.Title" = "禁用分享"; +"Chat.ErrorCantBoost" = "抱歉,你无法助推此群组或频道。"; +"Chat.ErrorFolderLinkExpired" = "文件夹链接已失效。"; +"Chat.ErrorInvoiceNotFound" = "没有找到账单。"; +"Chat.ErrorQuoteOutdatedActionEdit" = "编辑"; +"Chat.ErrorQuoteOutdatedText" = "**%@**\u00a0已更新您引用的消息,编辑您的引文使其保持最新状态。"; +"Chat.ErrorQuoteOutdatedTitle" = "引文已过时"; +"Chat.ForwardToSavedMessageTagSelectionTitle" = "您可以为消息添加标签"; +"Chat.ForwardToSavedMessagesTagSelectionTitle" = "您可以为消息添加标签"; +"Chat.GenericForuThreadStatus" = "话题\n"; +"Chat.GenericPsaTooltip" = "这是一则公共服务通告"; +"Chat.Gifs.SavedSectionHeader" = "我的 GIF 动态图"; +"Chat.Gifs.TrendingSectionHeader" = "流行 GIF 动态图"; +"Chat.GiftPurchaseOffer.Accept" = "接受"; +"Chat.GiftPurchaseOffer.AcceptConfirmation.BadValue" = "此礼物的价值比出价高 **%@**。"; +"Chat.GiftPurchaseOffer.AcceptConfirmation.Confirm" = "确认出价"; +"Chat.GiftPurchaseOffer.AcceptConfirmation.Text" = "你想把 **%1$@** 以 **%3$@** 的价格卖给 %2$@ 吗?扣除手续费后,您将收到 **%4$@**。"; +"Chat.GiftPurchaseOffer.AcceptConfirmation.Text.Stars#other" = "%@ 颗星星"; +"Chat.GiftPurchaseOffer.AcceptConfirmation.Title" = "确认出价"; +"Chat.GiftPurchaseOffer.Reject" = "拒绝"; +"Chat.GiftPurchaseOffer.RejectConfirmation.Reject" = "拒绝"; +"Chat.GiftPurchaseOffer.RejectConfirmation.Text" = "您确定要拒绝来自 **%@** 的出价吗?"; +"Chat.GiftPurchaseOffer.RejectConfirmation.Title" = "拒绝出价"; +"Chat.Giveaway.DeleteConfirmation.Text" = "删除此消息不会取消抽奖活动——仍旧将在 **%@** 选出获奖者。\n\n一旦消息被删除,将无法恢复抽奖活动公告。"; +"Chat.Giveaway.DeleteConfirmation.Title" = "您确定要删除抽奖公告吗?"; +"Chat.Giveaway.Info.ActivatedLinks#other" = "**%@** 位获奖者已使用了赠礼链接。"; +"Chat.Giveaway.Info.AdditionalPrizes" = "**%1$@** 还包括 **%2$@** 奖品。频道管理员负责发送这些奖品。"; +"Chat.Giveaway.Info.AlmostOver" = "抽奖活动即将结束。"; +"Chat.Giveaway.Info.DidntWin" = "您在此次抽奖活动中没有获奖。"; +"Chat.Giveaway.Info.Ended" = "在 **%1$@**,Telegram 已自动选出 **%3$@** 中的 %2$@。"; +"Chat.Giveaway.Info.EndedIntro" = "此抽奖活动由 **%1$@** 的管理员赞助,并为其订阅者购买了时长为 %3$@ 的 %2$@。"; +"Chat.Giveaway.Info.EndedMany" = "在 **%1$@**,Telegram 已自动选择加入了 **%3$@** 和其他列出社群的 **%2$@** 位随机用户。"; +"Chat.Giveaway.Info.EndedNew" = "在 **%1$@**,Telegram 已自动选择了在 **%4$@** 之后加入 **%3$@** 的 **%2$@**。"; +"Chat.Giveaway.Info.EndedNewMany" = "在 **%1$@**,Telegram 自动选择了在 **%4$@** 之后加入 **%3$@** 以及其他列出频道的 %2$@。"; +"Chat.Giveaway.Info.EndedTitle" = "抽奖活动已结束"; +"Chat.Giveaway.Info.FullDate" = "**%2$@** **%1$@**"; +"Chat.Giveaway.Info.Group.EndedIntro" = "此次抽奖活动由 **%1$@** 的管理员赞助,他们为其会员已准备为期 %3$@ 的 %2$@。"; +"Chat.Giveaway.Info.Group.OngoingIntro" = "此次抽奖活动由 **%1$@** 的管理员赞助,他们为其成员已准备为期 %3$@ 的 %2$@。"; +"Chat.Giveaway.Info.Group.RandomMembers#other" = "**%@** 个随机抽取的成员"; +"Chat.Giveaway.Info.Months#other" = "**%@** 个月"; +"Chat.Giveaway.Info.NotAllowedAdmin" = "您没有资格参与此赠品活动,因为您是参与频道的管理员 (**%@**)。"; +"Chat.Giveaway.Info.NotAllowedAdminGroup" = "您没有资格参加此抽奖活动,因为您是主办群组 (**%@**) 的管理员。"; +"Chat.Giveaway.Info.NotAllowedCountry" = "您没有资格参加此赠品活动,因为您所在的国家未包含在赠品活动条款中。"; +"Chat.Giveaway.Info.NotAllowedJoinedEarly" = "您没有资格参加此抽奖活动,因为您是在 **%@** 即活动开始之前加入此频道的。"; +"Chat.Giveaway.Info.NotQualified" = "要参加此抽奖活动,请在 **%2$@** 之前加入 **%1$@** 频道。"; +"Chat.Giveaway.Info.NotQualifiedMany" = "要参加此抽奖活动,请在 **%3$@** 之前加入 **%1$@** 频道(以及 **%2$@**)。"; +"Chat.Giveaway.Info.Ongoing" = "在 **%1$@**,Telegram 将会自动选择 **%3$@** 的 %2$@。"; +"Chat.Giveaway.Info.OngoingIntro" = "此抽奖活动由 **%1$@** 的管理员赞助,他们为其订阅者以 %3$@ 的价格购买了 **%2$@ 个 Telegram 高级版** 订阅。"; +"Chat.Giveaway.Info.OngoingMany" = "在 **%1$@**,Telegram 将会自动选择 **%3$@** 以及 %4$@ 的 %2$@。"; +"Chat.Giveaway.Info.OngoingNew" = "在 **%1$@**,Telegram 将会自动选择在 **%4$@** 之后加入 **%3$@** 的 **%2$@** 位随机用户。"; +"Chat.Giveaway.Info.OngoingNewMany" = "Telegram 将于 **%1$@** 自动挑选 **%2$@** 位在 **%5$@** 之后加入 **%3$@** 和 **%4$@** 个其他列出频道的随机用户。"; +"Chat.Giveaway.Info.OtherChannels#other" = "和 **%@** 个其他列出的频道"; +"Chat.Giveaway.Info.OtherChannelsAndGroups#other" = "其它 **%@** 个列出的频道和群组"; +"Chat.Giveaway.Info.OtherGroups#other" = "其它 **%@** 个列出的群组"; +"Chat.Giveaway.Info.OtherGroupsAndChannels#other" = "其它 **%@** 个列出的群组和频道"; +"Chat.Giveaway.Info.Participating" = "您正在参与此抽奖活动,因为您已加入 **%1$@** 频道。"; +"Chat.Giveaway.Info.ParticipatingMany" = "您正在参与此抽奖活动,因为您已加入频道 **%1$@** (和 %2$@ )。"; +"Chat.Giveaway.Info.RandomSubscribers#other" = "**%@** 名随机订阅者"; +"Chat.Giveaway.Info.RandomUsers#other" = "**%@** 位随机用户"; +"Chat.Giveaway.Info.Refunded" = "该频道通过撤销付款的方式取消了奖品。"; +"Chat.Giveaway.Info.Stars.EndedIntro" = "此次抽奖活动由 **%1$@** 的管理员赞助,他们已为其订阅者准备了 **%2$@ 颗星星**。"; +"Chat.Giveaway.Info.Stars.Group.EndedIntro" = "此抽奖活动由 **%1$@** 的管理员赞助,他们已为其成员准备了 %2$@ 颗星星。"; +"Chat.Giveaway.Info.Stars.Group.OngoingIntro" = "抽奖活动由 **%1$@** 的管理员赞助,他们已为其成员准备了 %2$@。"; +"Chat.Giveaway.Info.Stars.OngoingIntro" = "此次抽奖活动由 **%1$@** 的管理员赞助,他们已为其订阅者准备了 %2$@。"; +"Chat.Giveaway.Info.Stars.Stars#other" = "**%@ 颗星星**"; +"Chat.Giveaway.Info.Subscriptions#other" = "**%@ Telegram 高级版** 订阅"; +"Chat.Giveaway.Info.Title" = "关于此抽奖活动"; +"Chat.Giveaway.Info.ViewPrize" = "查看我的奖品"; +"Chat.Giveaway.Info.Won" = "您在此抽奖活动中赢得了奖品。 %@"; +"Chat.Giveaway.Message.CountriesDelimiter" = ","; +"Chat.Giveaway.Message.CountriesFrom" = "来自 %@"; +"Chat.Giveaway.Message.CountriesLastDelimiter" = "和"; +"Chat.Giveaway.Message.CustomPrizeQuantity#other" = "%@"; +"Chat.Giveaway.Message.DateTitle" = "选定获奖者的日期"; +"Chat.Giveaway.Message.Group.Participants" = "此群组的所有成员:"; +"Chat.Giveaway.Message.Group.ParticipantsMany" = "以下群组的所有成员:"; +"Chat.Giveaway.Message.Group.ParticipantsNew" = "在此日期之后加入此群组的所有用户:"; +"Chat.Giveaway.Message.Group.ParticipantsNewMany" = "在此日期后加入以下群组的所有用户:"; +"Chat.Giveaway.Message.LearnMore" = "了解更多"; +"Chat.Giveaway.Message.Months#other" = "**%@** 个月"; +"Chat.Giveaway.Message.Participants" = "所有频道的订阅者:"; +"Chat.Giveaway.Message.ParticipantsMany" = "以下频道的所有订阅者:"; +"Chat.Giveaway.Message.ParticipantsNew" = "本公告发布之后加入频道的所有用户:"; +"Chat.Giveaway.Message.ParticipantsNewMany" = "本公告发布之后加入以下频道的所有用户:"; +"Chat.Giveaway.Message.ParticipantsTitle" = "参与者"; +"Chat.Giveaway.Message.PrizeText" = "%1$@ 时长为 %2$@。"; +"Chat.Giveaway.Message.PrizeTitle" = "抽奖奖品"; +"Chat.Giveaway.Message.Stars.PrizeText" = "%1$@ 将会分发 %2$@。"; +"Chat.Giveaway.Message.Stars.Stars#other" = "**%@** 颗星星"; +"Chat.Giveaway.Message.Stars.Winners#other" = "给 **%@** 名获奖者"; +"Chat.Giveaway.Message.Subscriptions#other" = "**%@** 个 Telegram 高级版订阅"; +"Chat.Giveaway.Message.WinnersInfo.Many" = "所有获奖者都已在私密消息中收到礼物链接。"; +"Chat.Giveaway.Message.WinnersInfo.One" = "获胜者已在私密消息中收到他们的礼物链接。"; +"Chat.Giveaway.Message.WinnersInfo.Stars#other" = "**%@ 颗星星**"; +"Chat.Giveaway.Message.WinnersInfo.Stars.Many" = "所有获奖者已总共收到 %@。"; +"Chat.Giveaway.Message.WinnersInfo.Stars.One" = "获奖者已收到 %@。"; +"Chat.Giveaway.Message.WinnersMore#other" = "以及 %@ 等更多!"; +"Chat.Giveaway.Message.WinnersSelectedText#other" = "Telegram 已随机选出 **%@** 位[赠品活动]()获奖者。"; +"Chat.Giveaway.Message.WinnersSelectedTitle.Many" = "获奖者已选出!"; +"Chat.Giveaway.Message.WinnersSelectedTitle.One" = "获奖者已选出!"; +"Chat.Giveaway.Message.WinnersTitle.Many" = "获奖者"; +"Chat.Giveaway.Message.WinnersTitle.One" = "获奖者"; +"Chat.Giveaway.Message.With" = "及"; +"Chat.Giveaway.Message.WithSubscriptions#other" = "**%@** 个 Telegram 高级版订阅"; +"Chat.Giveaway.Toast.AlmostOver" = "抽奖活动即将结束。"; +"Chat.Giveaway.Toast.Ended" = "该抽奖活动已结束。"; +"Chat.Giveaway.Toast.LearnMore" = "了解更多"; +"Chat.Giveaway.Toast.NotAllowed" = "您无法参加此次抽奖活动。"; +"Chat.Giveaway.Toast.NotQualified" = "您还没有资格参与此次抽奖活动。"; +"Chat.Giveaway.Toast.Participating" = "您正在参加此次赠品活动。"; +"Chat.GroupEmojiTooltip" = "此群组的所有成员都可以使用 # ** %@ ** 包。"; +"Chat.GuestChatMessageTooltip" = "该机器人无法读取聊天内容,仅能查看被提及的消息。"; +"Chat.HashtagSuggestion.UseGeneric.Text" = "从所有频道搜索贴文"; +"Chat.HashtagSuggestion.UseGeneric.Title" = "使用 %@"; +"Chat.HashtagSuggestion.UseLocal.Channel.Text" = "仅搜索此频道的贴文"; +"Chat.HashtagSuggestion.UseLocal.Group.Text" = "仅搜索此群组的贴文"; +"Chat.HashtagSuggestion.UseLocal.Title" = "使用 %@"; +"Chat.InlineTopicMenu.AllTab" = "全部"; +"Chat.InlineTopicMenu.Edit" = "编辑"; +"Chat.InlineTopicMenu.NewForumThreadTab" = "新聊天"; +"Chat.InlineTopicMenu.Reorder" = "重新排序"; +"Chat.InlineTopicMenu.TabAll" = "全部"; +"Chat.InputPlaceholderMessageInTopic" = "%1$@ 中的消息"; +"Chat.InputPlaceholderReplyInTopic" = "在 %1$@ 回复"; +"Chat.InputTextPaidCommentPlaceholder" = "花费 %@ 评论"; +"Chat.InputTextPaidMessagePlaceholder" = "每条消息 %@"; +"Chat.JoinedGroup.AddTag" = "添加标签"; +"Chat.JoinedGroup.Text" = "您已加入群组。"; +"Chat.JumpToDate" = "跳转到日期"; +"Chat.ManagedBot.CreatedText" = "点击下方的开始按钮即可测试您的新聊天机器人。其行为由\u00a0**%@**\u00a0定义。"; +"Chat.ManagedBot.CreatedTitle" = "**%@**\u00a0已准备就绪!\n"; +"Chat.Message.TopicAuthorBadge" = "话题创建者"; +"Chat.MessageContextMenu.NonPremiumTagsTitle" = "使用标签整理收藏的消息,以便快速访问。[了解更多...]()"; +"Chat.MessageContextMenu.Remove" = "移除"; +"Chat.MessageContinueLastThread" = "继续上一个话题"; +"Chat.MessageEffectMenu.SectionMessageEffects" = "消息特效"; +"Chat.MessageEffectMenu.TitleAddEffect" = "添加动画特效"; +"Chat.MessageForwardInfo.ExpiredStoryHeader" = "到期动态来自"; +"Chat.MessageForwardInfo.MessageHeader" = "转发自"; +"Chat.MessageForwardInfo.StoryHeader" = "该动态转发自"; +"Chat.MessageForwardInfo.UnavailableStoryHeader" = "到期动态来自"; +"Chat.MessageHeaderBotNewThread" = "新话题\n"; +"Chat.MessageRangeDeleted.ForBothSides#other" = "已为双方删除 %@ 天内的消息记录。"; +"Chat.MessageRangeDeleted.ForMe#other" = "已删除 %@ 天内的消息记录。"; +"Chat.MessageSeenTimestamp.Date" = "%@"; +"Chat.MessageSeenTimestamp.TodayAt" = "今天 %@"; +"Chat.MessageSeenTimestamp.YesterdayAt" = "昨天 %@"; +"Chat.MessageTooltipVideoProcessing" = "处理视频可能需要几分钟时间。"; +"Chat.MessagesDeletedToast.Text#other" = "已删除 %d 条消息"; +"Chat.MessagesUnpinned#other" = "%@ 条消息已取消置顶。"; +"Chat.MessagingRestrictedPlaceholder" = "%@ 只接受来自高级版订阅用户的消息。"; +"Chat.MessagingRestrictedPlaceholderAction" = "了解更多..."; +"Chat.Monoforum.Subtitle" = "私信"; +"Chat.MultipleTextMessagesDisabled" = "慢速模式已启用,您不能一次发送多条消息。"; +"Chat.MultipleTypingMore" = "%@ 以及 %@ 位用户"; +"Chat.MultipleTypingPair" = "%@ 和 %@"; +"Chat.NavigationNoChannels" = "暂无未读频道"; +"Chat.NavigationNoTopics" = "您没有未读话题"; +"Chat.NewThreadInfo.Text" = "输入任何消息即可建立新话题。"; +"Chat.NewThreadInfo.Title" = "新话题"; +"Chat.NextChannelArchivedSwipeAction" = "松开跳转到已归档频道"; +"Chat.NextChannelArchivedSwipeProgress" = "上拉跳转到已归档频道"; +"Chat.NextChannelFolderSwipeAction" = "松开以进入 %@ 文件夹"; +"Chat.NextChannelFolderSwipeActionV2" = "放开即可进入 {folder} 文件夹"; +"Chat.NextChannelFolderSwipeProgress" = "上拉以进入 %@ 文件夹"; +"Chat.NextChannelFolderSwipeProgressV2" = "向上滑动进入 {folder} 文件夹 "; +"Chat.NextChannelSameLocationSwipeAction" = "松开以进入下一个未读频道"; +"Chat.NextChannelSameLocationSwipeProgress" = "上滑跳转到下一个未读频道"; +"Chat.NextChannelUnarchivedSwipeAction" = "松开转到未归档频道"; +"Chat.NextChannelUnarchivedSwipeProgress" = "向上拖动转到未归档频道"; +"Chat.NextSuggestedChannelSwipeAction" = "松开跳转到下一个频道"; +"Chat.NextSuggestedChannelSwipeProgress" = "上滑跳转到下一个频道"; +"Chat.NextUnreadTopicSwipeAction" = "松开跳转到下一个话题"; +"Chat.NextUnreadTopicSwipeProgress" = "上滑跳转到下一个话题"; +"Chat.NonContactUser.AnonymousNumber" = "匿名"; +"Chat.NonContactUser.Disclaimer" = "非官方账号"; +"Chat.NonContactUser.Groups" = "共同群组"; +"Chat.NonContactUser.GroupsCount#other" = "%@ 个群组"; +"Chat.NonContactUser.GroupsNone" = "无"; +"Chat.NonContactUser.PhoneNumber" = "电话号码"; +"Chat.NonContactUser.Registration" = "注册"; +"Chat.NonContactUser.Subtitle" = "非联系人"; +"Chat.NonContactUser.UpdatedDays#other" = "%@ 天前"; +"Chat.NonContactUser.UpdatedHours#other" = "%@ 小时前"; +"Chat.NonContactUser.UpdatedMinutes#other" = "%@ 分钟前"; +"Chat.NonContactUser.UpdatedName" = "用户 %1$@ 已更新名称"; +"Chat.NonContactUser.UpdatedPhoto" = "用户已更新照片 %1$@"; +"Chat.OpenStory" = "查看动态"; +"Chat.OutgoingContextMixedReactionCount" = "%1$@/%2$@ 人已回应"; +"Chat.OutgoingContextReactionCount#other" = "%@ 人已回应"; +"Chat.PaidMedia.Purchased" = "已购买"; +"Chat.PaidMedia.UnlockMedia" = "花费 %@ 解锁"; +"Chat.PaidMessage.Confirm.DontAskAgain" = "不再询问"; +"Chat.PaidMessage.Confirm.Multiple.Text" = "你选出了 %1$@,他们向消息收取星星。你愿意支付 **%2$@** 颗星星来发送 %3$@ 吗?"; +"Chat.PaidMessage.Confirm.PayForMessage#other" = "支付 %@ 条消息"; +"Chat.PaidMessage.Confirm.Single.Text" = "**%1$@** 对每条消息收取 **%2$@**。您愿意支付 **%3$@** 来发送 %4$@ 吗?"; +"Chat.PaidMessage.Confirm.SingleComment.Text" = "**%1$@** 对每条评论收取 **%2$@**。你愿意支付 **%3$@** 来发送 %4$@ 吗?"; +"Chat.PaidMessage.Confirm.Text.Messages#other" = "**%@** 条消息"; +"Chat.PaidMessage.Confirm.Text.Stars#other" = "%@ 颗星星"; +"Chat.PaidMessage.Confirm.Text.Users#other" = "**%@** 个用户"; +"Chat.PaidMessage.Confirm.Title" = "确认支付"; +"Chat.PaidMessage.RemoveFee.Refund" = "退还已支付的 **%@**"; +"Chat.PaidMessage.RemoveFee.Refund.Stars#other" = "%@ 颗星星"; +"Chat.PaidMessage.RemoveFee.Text" = "你确定要允许 **%@** 免费给你发送消息吗?"; +"Chat.PaidMessage.RemoveFee.Title" = "免收费用"; +"Chat.PaidMessage.RemoveFee.Yes" = "确认"; +"Chat.PaidMessage.Sent.Text" = "您支付了 %@"; +"Chat.PaidMessage.Sent.Text.Stars#other" = "%@ 颗星星"; +"Chat.PaidMessage.Sent.Title#other" = "%@ 条消息已支付"; +"Chat.PaidMessageFee.RemoveFee" = "免收费用"; +"Chat.PaidMessageFee.Text" = "%1$@ 必须为每条发送给你的消息支付 %2$@。"; +"Chat.PanelCustomStatusInfo" = "此帐号在其名称旁边使用了自定义状态 %@。所有订阅 Telegram 高级版的用户都可以使用这样的表情符号状态。"; +"Chat.PanelCustomStatusShortInfo" = "%@ 是 [高级版订阅者 >] 的标记"; +"Chat.PanelForumModeReplyText" = "左划消息以回复"; +"Chat.PanelFrozenAccount.Text" = "点击查看详细信息"; +"Chat.PanelFrozenAccount.Title" = "您的账号已被冻结"; +"Chat.PanelHidePinnedMessages" = "不显示置顶消息"; +"Chat.PanelRestartTopic" = "解锁话题"; +"Chat.PanelSetBotPhoto" = "设置头像"; +"Chat.PanelStatusAuthorHidden" = "这些信息的发送者在转发时限制链接他们的用户名。"; +"Chat.PanelTopicClosedText" = "该话题已锁定"; +"Chat.PanelUnpinAllMessages" = "取消所有置顶"; +"Chat.PauseVideoMessageTooltip" = "暂停以剪辑或重播"; +"Chat.PauseVoiceMessageTooltip" = "暂停以剪辑或重播"; +"Chat.PinnedGroupCallTitle" = "已置顶群组通话"; +"Chat.PinnedListPreview.HidePinnedMessages" = "隐藏置顶消息"; +"Chat.PinnedListPreview.ShowAllMessages" = "显示所有消息"; +"Chat.PinnedListPreview.UnpinAllMessages" = "取消所有置顶"; +"Chat.PinnedMessagesHiddenText" = "如有新消息被置顶,则置顶消息会再次显示。"; +"Chat.PinnedMessagesHiddenTitle" = "置顶消息已隐藏"; +"Chat.Placeholder.AwayMessage" = "添加离开消息……"; +"Chat.Placeholder.BusinessLinkPreset" = "添加预设消息..."; +"Chat.Placeholder.GreetingMessage" = "添加问候语... "; +"Chat.Placeholder.QuickReply" = "添加快速回复.."; +"Chat.PlaceholderTextNotAllowed" = "不允许发送文本"; +"Chat.PlayOnceMesasge.DisableScreenCapture" = "很抱歉,在屏幕录制处于活动状态时,您无法播放此消息。"; +"Chat.PlayOnceMesasgeClose" = "关闭"; +"Chat.PlayOnceMesasgeCloseAndDelete" = "删除并关闭"; +"Chat.PlayOnceVideoMessageTooltip" = "此视频消息只能播放一次。"; +"Chat.PlayOnceVideoMessageYourTooltip" = "此消息将在 **%@** 播放一次后消失。"; +"Chat.PlayOnceVoiceMessageTooltip" = "此语音消息只能播放一次。"; +"Chat.PlayOnceVoiceMessageYourTooltip" = "一旦 **%@** 播放一次,此消息就会消失。"; +"Chat.PlayVideoMessageOnceTooltip" = "收件人只能播放一次。"; +"Chat.PlayVoiceMessageOnceTooltip" = "接收者只能收听一次。"; +"Chat.Poll.ContextMenu.SectionOption" = "选项"; +"Chat.Poll.ContextMenu.SectionPoll" = "投票"; +"Chat.Poll.ContextMenu.SectionsInfo" = "您正在查看某个选项的操作。\n您可以切换至清单的相关操作。"; +"Chat.Poll.HiddenResultsTooltip" = "投票结束后结果將公布"; +"Chat.Poll.RemoveOption" = "移除"; +"Chat.Poll.ReplyToOption" = "回复选项"; +"Chat.Poll.Restriction.Country" = "仅来自 %@ 的用户可以投票。"; +"Chat.Poll.Restriction.Country.CountriesDelimiter" = ","; +"Chat.Poll.Restriction.Country.CountriesLastDelimiter" = " 和 "; +"Chat.Poll.Restriction.Subscribers" = "只有 **%@** 的订阅者可以投票。"; +"Chat.Poll.Restriction.Subscribers.TimeLimit" = "仅加入 **24 小时** 以上的订阅用户可投票。"; +"Chat.Poll.Restriction.SubscribersCountry" = "只有 **%@** 中来自 %@ 的订阅者可以投票。"; +"Chat.Poll.RetractOptionVote" = "收回选票"; +"Chat.Poll.VoteOption" = "投票"; +"Chat.PollOptionAddedTimestamp.Date" = "由 %1$@ 添加 %2$@"; +"Chat.PollOptionAddedTimestamp.TodayAt" = "由 %1$@ 添加于今天 %2$@"; +"Chat.PollOptionAddedTimestamp.YesterdayAt" = "由 %1$@ 添加于昨天 %2$@"; +"Chat.PollOptionAddedTimestampYou.Date" = "由您添加于 %1$@"; +"Chat.PollOptionAddedTimestampYou.TodayAt" = "由您添加于今天 %1$@"; +"Chat.PollOptionAddedTimestampYou.YesterdayAt" = "由您添加于昨天 %1$@"; +"Chat.PostApproval.DetailStatus.AdminFailedRefunded" = "由于用户资金不足,推荐帖文已退款"; +"Chat.PostApproval.DetailStatus.FailedDeleted" = "由于消息已被删除,已退款建议贴文的费用"; +"Chat.PostApproval.DetailStatus.PostedPaid" = "**%1$@** 因发布此帖子收到了 **%2$@**"; +"Chat.PostApproval.DetailStatus.StarsAmount#other" = "# 颗星星"; +"Chat.PostApproval.DetailStatus.TonAmount#other" = "# Gram"; +"Chat.PostApproval.DetailStatus.UserFailedRefunded" = "由于余额不足,已退款推荐贴文的费用"; +"Chat.PostApproval.Message.ActionApprove" = "接受"; +"Chat.PostApproval.Message.ActionReject" = "拒绝"; +"Chat.PostApproval.Message.ActionSuggestChanges" = "推荐更改"; +"Chat.PostApproval.Message.AdminAgreementFuture" = "📆 您的贴文将自动发布于 **%1$@** **%2$@**。"; +"Chat.PostApproval.Message.AdminAgreementNoTime" = "📆 您的帖子已于 **%1$@** 自动发布。"; +"Chat.PostApproval.Message.AdminAgreementPast" = "📅 您的帖子已于 %2$@ 自动发布至 %1$@ 。"; +"Chat.PostApproval.Message.AdminAgreementPriceStars" = "💰 用户已被扣除 %1$@。\n\n⌛ **%2$@** 将在帖子发布满 24 小时后收到星星。\n\n🔄 如果您在帖子发布未满 24 小时前删除帖子,用户的星星将被退还。"; +"Chat.PostApproval.Message.AdminAgreementPriceTon" = "💰 用户已被收取 %1$@。 \n\n⌛ 贴子发布 24 小时后,**%2$@** 将收到 Gram。 \n\n🔄 如果您在贴子发布 24 小时前将其删除,用户的 Gram 將退还。"; +"Chat.PostApproval.Message.AdminDeclined" = "您拒绝了此贴文。"; +"Chat.PostApproval.Message.AdminDeclinedComment" = "您拒绝了此贴文,并备注:\n\n%@"; +"Chat.PostApproval.Message.AdminTitleFailedFunds" = "⚠️ **交易失败**,因为用户没有足够的星星。"; +"Chat.PostApproval.Message.AdminTitleRejected" = "❌ 您已拒收此贴文。"; +"Chat.PostApproval.Message.AdminTitleRejectedComment" = "❌ 您拒绝了该消息并附言:"; +"Chat.PostApproval.Message.BuyStars" = "购买星星"; +"Chat.PostApproval.Message.TitleApproved" = "🤝 达成协议!"; +"Chat.PostApproval.Message.UserAgreementFuture" = "📅 此贴文将自动发布于 **%1$@** **%2$@**."; +"Chat.PostApproval.Message.UserAgreementNoTime" = "📆 此贴文已自动发布于 **%1$@**。"; +"Chat.PostApproval.Message.UserAgreementPast" = "📅 帖子已于 **%2$@** 自动发布至 **%1$@** 。"; +"Chat.PostApproval.Message.UserAgreementPriceStars" = "💰 您已被扣除 %1$@。\n\n⌛ **%2$@** 将在帖子发布满 24 小时后收到星星。\n\n🔄 如果管理员在帖子发布未满 24 小时前删除帖子,您的星星将被退还。"; +"Chat.PostApproval.Message.UserAgreementPriceTon" = "💰 您已被收取 %1$@。 \n\n⌛ 贴文发布 24 小时后,**%2$@** 将收到 Gram。 \n\n🔄 如果管理员在贴文发布 24 小时前将其删除,您的 Gram 将原路退还。"; +"Chat.PostApproval.Message.UserTitleFailedFunds" = "⚠️ **交易失敗**,因為您沒有足夠的星星。"; +"Chat.PostApproval.Message.UserTitleRejected" = "❌ **%@** 已拒收您的消息。"; +"Chat.PostApproval.Message.UserTitleRejectedComment" = "❌ **%@** 拒收了您的消息,并备注:"; +"Chat.PostApproval.Status.AdminApproved" = "贴文已被批准"; +"Chat.PostApproval.Status.AdminRejected" = "贴文已被拒绝"; +"Chat.PostApproval.Status.UserApproved" = "您的贴文已被批准"; +"Chat.PostApproval.Status.UserRejected" = "您的贴文已被拒绝"; +"Chat.PostSuggestion.Approve.Action" = "发布"; +"Chat.PostSuggestion.Approve.AdminConfirmationPriceStars" = "您将通过发布此贴文\n获得 %1$@ 颗星星(%2$@%)。此贴文必须在发布后, **24** 小时内保持可见。"; +"Chat.PostSuggestion.Approve.AdminConfirmationPriceTon" = "您将通过发布此贴文\n获得 %1$@ Gram(%2$@%)。此贴文必须在发布后 **24** 小时内保持可见。"; +"Chat.PostSuggestion.Approve.AdminConfirmationText" = "您想要发布来自 **%@** 的此贴文吗?"; +"Chat.PostSuggestion.Approve.Title" = "接受条款"; +"Chat.PostSuggestion.Approve.UserConfirmationText" = "您确定要发布此贴文吗?"; +"Chat.PostSuggestion.ApproveTime.AdminConfirmationPriceStars" = "您将通过发布此贴文获得 %1$@ 颗星星 (%2$@)。"; +"Chat.PostSuggestion.ApproveTime.AdminConfirmationPriceTon" = "您将通过发布此贴文获得 %1$@ Gram (%2$@%)。"; +"Chat.PostSuggestion.ApproveTime.NoTimeAction" = "立即发布"; +"Chat.PostSuggestion.ApproveTime.Text" = "请设置您希望发布此消息的\n日期和时间。"; +"Chat.PostSuggestion.ApproveTime.Title" = "接受条款"; +"Chat.PostSuggestion.ChannelChange" = "**%@** 建议修改\n此帖子。"; +"Chat.PostSuggestion.ChannelChangeP" = "**%@** 为此贴文建议了新的报价。"; +"Chat.PostSuggestion.ChannelChangePT" = "**%@** 已建议此贴文的\n新价格和时间。"; +"Chat.PostSuggestion.ChannelChangePTA" = "**%@** 为此贴文建议了\n新的报价,时间和附件。"; +"Chat.PostSuggestion.ChannelChangePTC" = "**%@** 为此贴文建议了\n新的报价,时间和内容。"; +"Chat.PostSuggestion.ChannelChangePTT" = "**%@** 为此贴文建议了\n新的价格、时间和文字。"; +"Chat.PostSuggestion.ChannelChangeT" = "**%@** 已建议在新时间\n发送此贴文。"; +"Chat.PostSuggestion.ChannelPost" = "**%@** 推荐一条帖子。"; +"Chat.PostSuggestion.PriceFree" = "免费"; +"Chat.PostSuggestion.Reject.Placeholder" = "可选"; +"Chat.PostSuggestion.Reject.Title" = "评论"; +"Chat.PostSuggestion.SetTime.NoTimeAction" = "随时发送"; +"Chat.PostSuggestion.SetTime.Text" = "请设置您已在发布消息的日期和时间。"; +"Chat.PostSuggestion.SetTime.Title" = "时间"; +"Chat.PostSuggestion.StarsDisclaimer" = "**星星**交易可能会在 **21** 天内被支付者撤销。请仅接受您信任的人的星星付款。"; +"Chat.PostSuggestion.Suggest.AdminMinAmountStars.Text" = "您不能请求少于 %@ 颗星星。"; +"Chat.PostSuggestion.Suggest.EditDateDescription" = "选择您想要发布此贴文的日期和时间。"; +"Chat.PostSuggestion.Suggest.InputEditTitle" = "推荐修改"; +"Chat.PostSuggestion.Suggest.InputSubtitleAnytime" = "%@ 可随时发布"; +"Chat.PostSuggestion.Suggest.InputSubtitleEmpty" = "点击提供发布价格"; +"Chat.PostSuggestion.Suggest.InputTitle" = "在下方建议一条贴文"; +"Chat.PostSuggestion.Suggest.OfferButtonFree" = "免费提供"; +"Chat.PostSuggestion.Suggest.OfferButtonPrice" = "报价 %@"; +"Chat.PostSuggestion.Suggest.OfferDateDescription" = "选择您想要发布贴文的日期和具体时间。"; +"Chat.PostSuggestion.Suggest.OfferDescriptionStars" = "选择想要报价 %@ 多少颗星星来发布此消息。"; +"Chat.PostSuggestion.Suggest.OfferDescriptionTon" = "选择要报价 %@ 多少 Gram 来发布此贴文。"; +"Chat.PostSuggestion.Suggest.OfferStars" = "星星报价"; +"Chat.PostSuggestion.Suggest.OfferTon" = "Gram 报价"; +"Chat.PostSuggestion.Suggest.PricePlaceholder" = "价格"; +"Chat.PostSuggestion.Suggest.PriceSectionStars" = "输入星星价格"; +"Chat.PostSuggestion.Suggest.PriceSectionTon" = "输入 Gram 价格"; +"Chat.PostSuggestion.Suggest.RequestDescriptionStars" = "选择对消息收取多少颗星星。"; +"Chat.PostSuggestion.Suggest.RequestDescriptionTon" = "选择为消息收取多少 Gram 。"; +"Chat.PostSuggestion.Suggest.RequestStars" = "用星星请求"; +"Chat.PostSuggestion.Suggest.RequestTon" = "用 Gram 请求"; +"Chat.PostSuggestion.Suggest.TitleCreate" = "推荐条款"; +"Chat.PostSuggestion.Suggest.TitleEdit" = "推荐更改"; +"Chat.PostSuggestion.Suggest.UpdateButton" = "更新条款"; +"Chat.PostSuggestion.Suggest.UserMinAmountStars.Text" = "您的报价不能少于 %@ 颗星星。"; +"Chat.PostSuggestion.TablePrice" = "价格"; +"Chat.PostSuggestion.TableTime" = "时间"; +"Chat.PostSuggestion.TimeAny" = "任何时间"; +"Chat.PostSuggestion.UserChange" = "您已为此贴文\n建议修改。"; +"Chat.PostSuggestion.UserChangeP" = "您为此帖子建议了\n新价格。"; +"Chat.PostSuggestion.UserChangePT" = "您已为此贴文建议\n新的价格和时间。"; +"Chat.PostSuggestion.UserChangePTA" = "您为此贴文建议了\n新的报价,时间和附件。"; +"Chat.PostSuggestion.UserChangePTC" = "您为此贴文建议了\n新的报价,时间和内容。"; +"Chat.PostSuggestion.UserChangePTT" = "您为此贴文建议了\n新的报价,时间和文字。"; +"Chat.PostSuggestion.UserChangeT" = "您已建议在新时间\n发送此贴文。"; +"Chat.PostSuggestion.UserPost" = "您推荐了一条贴文。"; +"Chat.PremiumReactionToastAction" = "更多"; +"Chat.PremiumReactionToastTitle" = "订阅 **Telegram 高级版** 以解锁此表情回应。"; +"Chat.PrivateMessageEditTimestamp.Date" = "已编辑于 %@"; +"Chat.PrivateMessageEditTimestamp.TodayAt" = "已编辑于今天 %@"; +"Chat.PrivateMessageEditTimestamp.YesterdayAt" = "已编辑于昨天 %@"; +"Chat.PrivateMessageSeenTimestamp.Date" = "%@ 已读"; +"Chat.PrivateMessageSeenTimestamp.TodayAt" = "今天 %@ 已读"; +"Chat.PrivateMessageSeenTimestamp.YesterdayAt" = "昨天 %@ 已读"; +"Chat.PrivateMessageSentTimestamp.Date" = "已发送于 %@"; +"Chat.PrivateMessageSentTimestamp.TodayAt" = "已发送于今天 %@"; +"Chat.PrivateMessageSentTimestamp.YesterdayAt" = "已发送昨天 %@"; +"Chat.PsaTooltip.covid" = "此信息为您提供正在发生的新冠肺炎疫情相关公共服务公告。了解有关此计划的更多咨询:https://telegram.org/blog/coronavirus"; +"Chat.PsaTooltip.psa" = "这是 Telegram 首席执行官对于目前冲突的正式声明。"; +"Chat.QuickReply.ServiceHeader1" = "要编辑或删除快速回复,请点击并按住回复。"; +"Chat.QuickReply.ServiceHeader2" = "如要在聊天中使用此快捷回复,请输入 / 并从列表中选择快捷方式。"; +"Chat.QuickReplyMediaMessageLimitReachedText#other" = "此聊天中最多可以有 %d 条消息。"; +"Chat.QuickReplyMessageLimitReachedText#other" = "已达 %d 条消息上限"; +"Chat.ReactionContextMenu.EditTagLabel" = "编辑名称"; +"Chat.ReactionContextMenu.FilterByTag" = "依据标签进行筛选"; +"Chat.ReactionContextMenu.RemoveTag" = "移除标签"; +"Chat.ReactionContextMenu.SetTagLabel" = "设置名称"; +"Chat.ReactionSection.Popular" = "热门"; +"Chat.ReactionSection.Recent" = "最近使用"; +"Chat.ReactionSelectionTitleAddTag" = "使用标签标记消息以便快速查找"; +"Chat.ReactionSelectionTitleEditTag" = "编辑所选消息的标签"; +"Chat.Reactions.MultiplePremiumTooltip" = "订阅 [Telegram 高级版]() 后,您可以发送多个表情回应。"; +"Chat.ReinstatePaidMessages" = "收取消息费用"; +"Chat.RemoveWallpaper.Remove" = "移除"; +"Chat.RemoveWallpaper.Text" = "您确定要重置壁纸吗?"; +"Chat.RemoveWallpaper.Title" = "删除壁纸"; +"Chat.ReplyExpiredStory" = "过期的动态"; +"Chat.ReplyPanel.AccessibilityReplyToMessage" = "回复消息。"; +"Chat.ReplyPanel.AccessibilityReplyToMessageFrom" = "回消息。来自: %@"; +"Chat.ReplyPanel.AccessibilityReplyToYourMessage" = "回复您的消息。"; +"Chat.ReplyPanel.HintReplyOptions" = "点此处查看回复选项"; +"Chat.ReplyPanel.HintReplyOptionsShort" = "点击此处查看选项"; +"Chat.ReplyPanel.ReplyTo" = "回复 %@"; +"Chat.ReplyPanel.ReplyToPollOption" = "回复投票选项"; +"Chat.ReplyPanel.ReplyToQuoteBy" = "回复来自 %@ 的引文"; +"Chat.ReplyPanel.ReplyToTodoItem" = "回复核对清单中的任务"; +"Chat.ReplyStory" = "动态"; +"Chat.ReplyStoryPrivateChannel" = "私密动态"; +"Chat.RichText.ShowMore" = "显示更多"; +"Chat.SaveForNotifications" = "保存为通知铃声"; +"Chat.SavedMessagesChatsTooltip" = "点击以查看按类型或来源整理的收藏消息"; +"Chat.SavedMessagesModeMenu.ViewAsChats" = "以聊天模式查看"; +"Chat.SavedMessagesModeMenu.ViewAsMessages" = "以消息模式查看"; +"Chat.SavedMessagesStatusViewAsChats" = "点击以用聊天样式查看"; +"Chat.SavedMessagesTabInfoText" = "您发送至“我的收藏”的消息"; +"Chat.ScheduledForceSendProcessingVideo.Action" = "仍然发送"; +"Chat.ScheduledForceSendProcessingVideo.Text" = "此视频尚未转换和优化。如果您现在发送,视频的观众可能会遇到下载速度缓慢的情況。"; +"Chat.ScheduledForceSendProcessingVideo.Title" = "稍等!"; +"Chat.SearchTagsPlaceholder" = "搜索消息或标签"; +"Chat.SendAllowedContentPeerText" = "%1$@ 的管理员只允许发送 %2$@。"; +"Chat.SendAllowedContentText" = "此群组的管理员仅允许发送 %1$@。"; +"Chat.SendAllowedContentTypeFile" = "文件"; +"Chat.SendAllowedContentTypeMusic" = "音乐"; +"Chat.SendAllowedContentTypePhoto" = "图片"; +"Chat.SendAllowedContentTypeSticker" = "贴纸 & GIF "; +"Chat.SendAllowedContentTypeText" = "文本消息"; +"Chat.SendAllowedContentTypeVideo" = "视频"; +"Chat.SendAllowedContentTypeVideoMessage" = "视频消息"; +"Chat.SendAllowedContentTypeVoiceMessage" = "语音消息"; +"Chat.SendGiftTooltip" = "点击此处以赠送礼物"; +"Chat.SendMessageMenu.EditMessage" = "编辑消息"; +"Chat.SendMessageMenu.MoveCaptionDown" = "将说明文字下移"; +"Chat.SendMessageMenu.MoveCaptionUp" = "将说明文字上移"; +"Chat.SendMessageMenu.ToastPremiumRequired.Text" = "订阅 [Telegram 高级版]() 添加此动画特效。"; +"Chat.SendNotAllowedAudioMessage" = "本群组不允许发送语音消息"; +"Chat.SendNotAllowedFile" = "本群不允许发送文件"; +"Chat.SendNotAllowedMusic" = "本群不允许发送音乐"; +"Chat.SendNotAllowedPeerText" = "不允许在 %@ 发送消息"; +"Chat.SendNotAllowedPhoto" = "此群组不允许发送图片"; +"Chat.SendNotAllowedText" = "本群组不允许发送消息。"; +"Chat.SendNotAllowedVideo" = "此群组不允许发送视频。"; +"Chat.SendNotAllowedVideoMessage" = "对不起,你不能在本群内发送视频消息。"; +"Chat.SendReactionRestricted" = "在此聊天中您无法发送表情回应。"; +"Chat.SendStarsToBecomeTopInfo" = "送出 %@ 或更多星星以突出显示您的个人资料"; +"Chat.SensitiveContent" = "18+ 内容"; +"Chat.SensitiveContentShort" = "18+"; +"Chat.SimilarChannels" = "相似频道"; +"Chat.SimilarChannels.Join" = "加入"; +"Chat.SimilarChannels.JoinedChannel" = "您已加入 **%@** 频道。"; +"Chat.SimilarChannels.MoreChannels" = "更多频道"; +"Chat.SlowmodeAttachmentLimitReached" = "慢速模式已启用,您无法选中多个项目。"; +"Chat.SlowmodeSendError" = "慢速模式已启用。"; +"Chat.SlowmodeTooltip" = "慢速模式已启用,您可以在 %@ 后发送下一条消息。"; +"Chat.SlowmodeTooltipPending" = "慢速模式已启用,您每次只能发送一条消息。"; +"Chat.SpeedLimitAlert.Download.Text" = "订阅 [Telegram 高级版]() 以将下载速度提高 %@ 倍。"; +"Chat.SpeedLimitAlert.Download.Title" = "下载速度受限"; +"Chat.SpeedLimitAlert.Upload.Text" = "订阅 [Telegram 高级版]() 可将上传速度提升 %@ 倍。"; +"Chat.SpeedLimitAlert.Upload.Title" = "上传速度受限"; +"Chat.StoryMentionAction" = "查看动态"; +"Chat.SubtitleLinkListTip" = "点击链接即可生成预览"; +"Chat.SubtitleQuoteSelectionTip" = "您可以选择特定部分进行引用"; +"Chat.TagPlaceholder" = "标签"; +"Chat.TagSearchHideMessages" = "隐藏其他消息"; +"Chat.TagSearchShowMessages" = "显示其他消息"; +"Chat.TagUpdated.Added" = "已添加标签"; +"Chat.TagUpdated.Edited" = "标签已编辑"; +"Chat.TagUpdated.Removed" = "已移除标签"; +"Chat.TagsHeaderPanel.AddTags" = "添加标签"; +"Chat.TagsHeaderPanel.AddTagsSuffix" = "至我的收藏"; +"Chat.TagsHeaderPanel.Unlock" = "解锁"; +"Chat.TapToPlayVideoMessageOnceTooltip" = "点击以设置此消息只能 **播放一次**"; +"Chat.TapToPlayVoiceMessageOnceTooltip" = "点击以设置此消息只能 **播放一次**。"; +"Chat.TitleJoinGroupCall" = "加入"; +"Chat.TitleLinkOptions" = "链接预览"; +"Chat.TitlePinnedMessages#other" = "%@ 条置顶消息"; +"Chat.TitleQuoteSelection" = "回复引文"; +"Chat.TitleReply" = "回复消息"; +"Chat.TitleVideochatPanel.NotifyScheduledButton" = "通知我"; +"Chat.ToastCallLinkExpired.Text" = "此链接不再有效"; +"Chat.ToastImprovingVideo.Text" = "此视频将在优化后发布,以获得最佳观看体验。"; +"Chat.ToastImprovingVideo.Title" = "改善视频中…"; +"Chat.ToastMessageTagged.Action" = "查看"; +"Chat.ToastMessageTagged.Text" = "消息已标记为 %@"; +"Chat.ToastMessagesTagged.Text" = "含有 %@ 标签的消息"; +"Chat.ToastMessagingRestrictedToPremium.Action" = "查看"; +"Chat.ToastMessagingRestrictedToPremium.Text" = "**%@** 仅接受来自联系人和 **高级版** 订阅用户的消息。"; +"Chat.ToastPhoneNumberCopied" = "手机号码已复制到剪贴板。"; +"Chat.ToastQuoteChatUnavailbalePrivateChannel" = "该引文来自私密频道"; +"Chat.ToastQuoteChatUnavailbalePrivateChat" = "该引文来自私聊"; +"Chat.ToastQuoteChatUnavailbalePrivateGroup" = "该引文来自私密群组"; +"Chat.ToastQuoteNotFound" = "引文未找到"; +"Chat.ToastStarsReactionsDisabled" = "已在 %@ 中停用星星回应。"; +"Chat.ToastStarsSent.AnonymousTitle#other" = "星星已匿名发送!"; +"Chat.ToastStarsSent.Text" = "您以 %1$@ 颗%2$@做出了回应。"; +"Chat.ToastStarsSent.TextStarAmount#other" = "星星"; +"Chat.ToastStarsSent.Title#other" = "已发送星星!"; +"Chat.ToastStarsSent.TitleChannel#other" = "已以 {name} 身分送出星星!"; +"Chat.ToastSubscribedToScheduledLiveStream.Text" = "您将在直播开始时收到通知。"; +"Chat.ToastVideoPublished.Action" = "查看"; +"Chat.ToastVideoPublished.Title" = "视频已发布"; +"Chat.ToastVoiceMessageDeviceMuted" = "设备已静音。"; +"Chat.Todo.CompletionLimited" = "**%@** 已限制其他人编辑此核对清单。"; +"Chat.Todo.CompletionLimitedForward" = "您无法编辑已转发的核对清单。"; +"Chat.Todo.ContextMenu.AddTask" = "添加任务"; +"Chat.Todo.ContextMenu.CheckTask" = "选取"; +"Chat.Todo.ContextMenu.DeleteTask" = "删除选项"; +"Chat.Todo.ContextMenu.EditTask" = "编辑选项"; +"Chat.Todo.ContextMenu.SectionList" = "清单"; +"Chat.Todo.ContextMenu.SectionTask" = "任务"; +"Chat.Todo.ContextMenu.SectionsInfo" = "您正在查看特定任务的操作选单。 \n您可以切換到清单的操作选单。"; +"Chat.Todo.ContextMenu.UncheckTask" = "取消选取"; +"Chat.Todo.Message.Completed#other" = "%@ / {count} 已完成"; +"Chat.Todo.Message.CompletedBy#other" = "%@ / {count} 已由 {name} 完成"; +"Chat.Todo.Message.Title" = "核对清单"; +"Chat.Todo.Message.TitleGroup" = "群组核对清单"; +"Chat.Todo.Message.TitlePersonal" = "%@ 的核对清单"; +"Chat.Todo.PremiumRequired" = "只有 **Telegram 高级版** 订阅者可以将任务标记为已完成。"; +"Chat.Todo.ReplyToItem" = "回复选项"; +"Chat.TodoItemCompletionTimestamp.Date" = "已完成 %@"; +"Chat.TodoItemCompletionTimestamp.TodayAt" = "已完成于今天 %@"; +"Chat.TodoItemCompletionTimestamp.YesterdayAt" = "于昨天 %@ 完成"; +"Chat.TooltipAddTagLabel" = "长按可为标签添加名称"; +"Chat.TopicIsClosedLabel" = "\"%1$@\" 话题已关闭"; +"Chat.TrimVoiceMessageToResume.Proceed" = "继续"; +"Chat.TrimVoiceMessageToResume.Text" = "该范围以外的音频将会被丢弃,录制也会随即开始。"; +"Chat.TrimVoiceMessageToResume.Title" = "剪辑至选定的范围?"; +"Chat.UnsendMyMessages" = "取消发送消息"; +"Chat.UnsendMyMessagesAlertTitle" = "取消发送消息将会在 %@ 那边删除您发送的消息。"; +"Chat.VideoProcessingInfo" = "视频将在转换优化后发布。"; +"Chat.VideoProcessingServiceMessage#other" = "视频将在转换优化后发布"; +"Chat.ViewAlbum" = "查看相册"; +"Chat.ViewCollection" = "查看收藏集"; +"Chat.ViewGroupCall" = "加入群组通话"; +"ChatAdmins.AdminLabel" = "管理员"; +"ChatAdmins.AllMembersAreAdmins" = "将所有成员都设为管理员"; +"ChatAdmins.AllMembersAreAdminsOffHelp" = "只有管理员有权添加或移除成员、编辑群组名称或头像。"; +"ChatAdmins.AllMembersAreAdminsOnHelp" = "所有成员都能邀请新成员、编辑群组名称和头像。"; +"ChatAdmins.Title" = "聊天管理员"; +"ChatContextMenu.EmojiSet#other" = "这条消息包含了来自 [%@ 个表情包]() 的表情。"; +"ChatContextMenu.EmojiSetSingle" = "这条消息内含有#[%@]() 表情。"; +"ChatContextMenu.MessageViewsPrivacyTip" = "为保护隐私,已阅数据只会保留 7 天。"; +"ChatContextMenu.QuoteSelectionTip" = "长按文字,然后移动光标来选择更多|文字以进行引文。"; +"ChatContextMenu.ReactionEmojiSet#other" = "这条消息包含来自 [%@ 个表情包]() 的表情回应。"; +"ChatContextMenu.ReactionEmojiSetSingle" = "此信息包含\n#[%@]() 的回应"; +"ChatContextMenu.SingleReactionEmojiSet" = "此表情回应来自 #[%@]() 表情包。"; +"ChatContextMenu.TextSelectionTip" = "长按需要拷贝的文字,拖动左右端的光标|可选择拷贝内容。"; +"ChatContextMenu.TextSelectionTip2" = "按住一个单词,然后移动光标,选择更多文字以引用或拷贝。"; +"ChatImport.CreateGroupAlertImportAction" = "创建并导入"; +"ChatImport.CreateGroupAlertText" = "您确定要创建 **%@** 群组,并从其他聊天工具中导入消息吗?"; +"ChatImport.CreateGroupAlertTitle" = "创建群组并导入消息"; +"ChatImport.SelectionConfirmationAlertImportAction" = "导入"; +"ChatImport.SelectionConfirmationAlertTitle" = "导入消息"; +"ChatImport.SelectionConfirmationGroupWithTitle" = "您确定要把 **%1$@** 的消息导入到 **%2$@** 吗?\n\n消息将被导入到当日的记录中,但也会包括原始时间戳。所有成员都会看到这些消息。"; +"ChatImport.SelectionConfirmationGroupWithoutTitle" = "您确定要把消息导入到 **%@** 吗?\n\n消息将被导入到当日的记录中,但也会包括原始时间戳。所有成员都会看到这些消息。"; +"ChatImport.SelectionConfirmationUserWithTitle" = "您确定要把 **%1$@** 的消息导入到与 **%2$@** 的聊天中吗?\n\n消息将被导入到当日的记录中,但也会包括原始时间戳。双方都会看到这些消息。"; +"ChatImport.SelectionConfirmationUserWithoutTitle" = "您确定要把消息导入到与 **%@** 的聊天中吗?\n\n消息将被导入到当日的记录中,但也会包括原始时间戳。双方都会看到这些消息。"; +"ChatImport.SelectionErrorGroupGeneric" = "抱歉,您无法导入历史消息到此群组。"; +"ChatImport.SelectionErrorNotAdmin" = "您必须是该群组的管理员才能导入消息。"; +"ChatImport.Title" = "选择聊天"; +"ChatImport.UserErrorNotMutual" = "您只能将消息导入至与您互为联系人的用户的私聊中。"; +"ChatImportActivity.ErrorGeneric" = "发生错误。"; +"ChatImportActivity.ErrorInvalidChatType" = "您尝试导入的消息类型错误。"; +"ChatImportActivity.ErrorLimitExceeded" = "已达到当日上线,请明天再来。"; +"ChatImportActivity.ErrorNotAdmin" = "您必须是群组管理员才能导入消息。"; +"ChatImportActivity.ErrorUserBlocked" = "由于隐私设置,无法导入消息。"; +"ChatImportActivity.InProgress" = "请保持此窗口开启\n直到导入完成。"; +"ChatImportActivity.OpenApp" = "打开 Telegram"; +"ChatImportActivity.Retry" = "重试"; +"ChatImportActivity.Success" = "聊天导入成功!"; +"ChatImportActivity.Title" = "导入聊天"; +"ChatList.AddBirthdayText" = "让您的联系人知道您正在庆祝生日。"; +"ChatList.AddBirthdayTitle" = "添加您的生日!🎂"; +"ChatList.AddChatsToFolder" = "添加聊天"; +"ChatList.AddFolder" = "添加文件夹"; +"ChatList.AddPhoto.Text" = "帮助您的朋友轻松发现您。"; +"ChatList.AddPhoto.Title" = "添加你的头像! 📸"; +"ChatList.AddedToFolderTooltip" = "%1$@ 被添加到 %2$@"; +"ChatList.AddedToFolderTooltipV2" = "{chat} 已被添加到 {folder} 文件夹"; +"ChatList.AlertDeleteFolderText" = "您确定要删除此文件夹吗?这将同时停用所有用来分享此文件夹的邀请链接。"; +"ChatList.AlertDeleteFolderTitle" = "是否删除文件夹"; +"ChatList.AlertResumeLiveStreamAction" = "恢复\n"; +"ChatList.AlertResumeLiveStreamText" = "您想继续现有的直播吗?"; +"ChatList.AlertResumeLiveStreamTitle" = "继续直播"; +"ChatList.Archive" = "归档"; +"ChatList.Archive.ContextInfo" = "这是如何运作的?"; +"ChatList.Archive.ContextSettings" = "归档设置"; +"ChatList.ArchiveAction" = "归档"; +"ChatList.ArchiveStoryCount#other" = "%d 条动态"; +"ChatList.ArchivedChatsTitle" = "已归档的聊天"; +"ChatList.Auctions.ActiveAuction#other" = "%@ 场进行中的拍卖"; +"ChatList.Auctions.Status.Many.Outbid#other" = "您的 %@ 笔出价已失效。"; +"ChatList.Auctions.Status.Many.OutbidAll" = "您的所有出价均已失效。"; +"ChatList.Auctions.Status.Many.WinningAll" = "你赢了所有竞拍。"; +"ChatList.Auctions.Status.Single.Outbid" = "你您的出价已失效。"; +"ChatList.Auctions.Status.Single.PlaceFirst" = "第 %@ 名"; +"ChatList.Auctions.Status.Single.PlaceNTh" = "第 %@ 名"; +"ChatList.Auctions.Status.Single.PlaceSecond" = "第 %@ 名"; +"ChatList.Auctions.Status.Single.PlaceThird" = "第 %@ 名"; +"ChatList.Auctions.Status.Single.Winning" = "您赢了(第 %@ 名)。"; +"ChatList.Auctions.Status.UpcomingBid" = "您已提前出价。"; +"ChatList.Auctions.UpcomingAuction" = "即将开始的拍卖"; +"ChatList.Auctions.View" = "查看"; +"ChatList.AuthorHidden" = "作者已隐藏"; +"ChatList.AutoarchiveSuggestion.OpenSettings" = "转到设置"; +"ChatList.AutoarchiveSuggestion.Text" = "您收到了很多来自陌生用户的新聊天会话。您是否想将这些聊天会话**自动静音**并**归档**?"; +"ChatList.AutoarchiveSuggestion.Title" = "隐藏新聊天?"; +"ChatList.BirthdayInSettingsInfo" = "您可以稍后在 **设置** 中选择您的出生日期。"; +"ChatList.BirthdayMultipleText" = "为其赠送礼物。"; +"ChatList.BirthdayMultipleTitle#other" = "今天 %@ 位联系人过 **生日**! 🎂"; +"ChatList.BirthdaySingleText" = "为其赠送礼物。"; +"ChatList.BirthdaySingleTitle" = "今天是 %@ 的 **生日**! 🎂"; +"ChatList.BotConnectionReview.PanelText" = "%1$@ 从 %2$@(%3$@)连接到您的帐户。这是您吗?"; +"ChatList.BotConnectionReview.PanelTitle" = "机器人刚刚获取了您消息的访问权限!"; +"ChatList.ChannelsSection" = "频道"; +"ChatList.ChatTypesSection" = "聊天类型"; +"ChatList.ClearChatConfirmation" = "您确定要删除\n与 %@ 聊天的所有消息记录吗?"; +"ChatList.ClearSavedMessagesConfirmation" = "您确定要永久删除“我的收藏”中的所有消息吗?"; +"ChatList.ClearSearchHistory" = "您确定要清空搜索历史记录吗?"; +"ChatList.ClearSearchHistory.Confirm" = "清除"; +"ChatList.CloseAction" = "关闭"; +"ChatList.Context.AddToContacts" = "添加为联系人"; +"ChatList.Context.AddToFolder" = "添加至文件夹"; +"ChatList.Context.Archive" = "归档"; +"ChatList.Context.Back" = "返回"; +"ChatList.Context.CloseTopic" = "关闭"; +"ChatList.Context.Delete" = "删除"; +"ChatList.Context.HideArchive" = "从列表中隐藏"; +"ChatList.Context.JoinChannel" = "加入频道"; +"ChatList.Context.JoinChat" = "加入聊天"; +"ChatList.Context.MarkAllAsRead" = "全部标记为已读"; +"ChatList.Context.MarkAsRead" = "标记为已读"; +"ChatList.Context.MarkAsUnread" = "标记为未读"; +"ChatList.Context.Mute" = "静音"; +"ChatList.Context.Pin" = "置顶"; +"ChatList.Context.RemoveFromFolder" = "从文件夹移除"; +"ChatList.Context.RemoveFromRecents" = "从“最近联络”中移除"; +"ChatList.Context.ReopenTopic" = "重新开启"; +"ChatList.Context.Select" = "选择"; +"ChatList.Context.Unarchive" = "取消归档"; +"ChatList.Context.UnhideArchive" = "在聊天列表中置顶"; +"ChatList.Context.Unmute" = "取消静音"; +"ChatList.Context.Unpin" = "取消置顶"; +"ChatList.ContextMenuBadgeNew" = "新"; +"ChatList.ContextMenuShare" = "分享"; +"ChatList.ContextMuteAll" = "全部静音"; +"ChatList.ContextOpenChannel" = "打开频道"; +"ChatList.ContextOpenGroup" = "打开群组"; +"ChatList.ContextSelectChats" = "选择聊天"; +"ChatList.ContextUnmuteAll" = "全部取消静音"; +"ChatList.DeleteAndLeaveGroupConfirmation" = "您确定要删除 %@ 并退出吗?"; +"ChatList.DeleteChat" = "删除聊天"; +"ChatList.DeleteChatConfirmation" = "是否永久删除与 %@ 的聊天?"; +"ChatList.DeleteConfirmation#other" = "删除 %@ 个聊天"; +"ChatList.DeleteForAll" = "为双方删除"; +"ChatList.DeleteForAllMembers" = "为所有成员删除"; +"ChatList.DeleteForAllMembersConfirmationText" = "这将为聊天中的**所有参与者删除所有消息**。"; +"ChatList.DeleteForAllSubscribers" = "为所有订阅者删除"; +"ChatList.DeleteForAllSubscribersConfirmationText" = "您将为**所有订阅者**删除频道内的**所有消息**。"; +"ChatList.DeleteForAllWhenPossible" = "尽可能为双方删除"; +"ChatList.DeleteForCurrentUser" = "仅在我的设备中删除"; +"ChatList.DeleteForEveryone" = "为我和%@删除"; +"ChatList.DeleteForEveryoneConfirmationAction" = "删除所有"; +"ChatList.DeleteForEveryoneConfirmationText" = "这将为**双方**删除聊天中的**所有消息** 。"; +"ChatList.DeleteForEveryoneConfirmationTitle" = "警告!"; +"ChatList.DeleteForMe" = "为我删除"; +"ChatList.DeleteSavedMessagesConfirmation" = "您确定要清空收藏夹内的消息吗?"; +"ChatList.DeleteSavedMessagesConfirmationAction" = "删除所有"; +"ChatList.DeleteSavedMessagesConfirmationText" = "这将删除聊天中的**所有消息**。"; +"ChatList.DeleteSavedMessagesConfirmationTitle" = "警告!"; +"ChatList.DeleteSavedPeerConfirmation" = "您确定要从%@中删除收藏消息吗?"; +"ChatList.DeleteSavedPeerMyNotesConfirmation" = "您确定要删除所有来自 %@ 的消息吗?"; +"ChatList.DeleteSavedPeerMyNotesConfirmationTitle" = "我的笔记"; +"ChatList.DeleteSecretChatConfirmation" = "您确定要删除与 %@ 的私密聊天吗?"; +"ChatList.DeleteThreadsConfirmation#other" = "删除 %@ 个话题"; +"ChatList.DeleteTopicConfirmationAction" = "删除话题"; +"ChatList.DeleteTopicConfirmationText" = "这将会删除此话题及其中的所有消息"; +"ChatList.DeletedChats#other" = "已删除 %@ 个聊天"; +"ChatList.DeletedThreads#other" = "已删除 %@ 个话题"; +"ChatList.EditFolder" = "编辑文件夹"; +"ChatList.EditFolders" = "编辑文件夹"; +"ChatList.EmptyChatFilterList" = "这个文件夹是空的。"; +"ChatList.EmptyChatList" = "您还没有任何对话。"; +"ChatList.EmptyChatListEditFilter" = "编辑文件夹"; +"ChatList.EmptyChatListFilterText" = "该文件夹内暂无聊天。"; +"ChatList.EmptyChatListFilterTitle" = "文件夹为空"; +"ChatList.EmptyChatListNewMessage" = "新消息"; +"ChatList.EmptyChatListWithArchive" = "您的所有聊天都已归档。"; +"ChatList.EmptyListContactsHeader" = "您在 Telegram 上的联系人"; +"ChatList.EmptyListContactsHeaderHide" = "隐藏"; +"ChatList.EmptyListTooltip" = "发送消息或\n\n在此创建一个群组。"; +"ChatList.EmptyResult.SearchInAll" = "在所有消息中搜索"; +"ChatList.EmptyTopicsCreate" = "创建新话题"; +"ChatList.EmptyTopicsDescription" = "来自该群组的较早消息已移至“通用”话题。"; +"ChatList.EmptyTopicsShowAsMessages" = "以消息模式查看"; +"ChatList.EmptyTopicsTitle" = "暂无话题"; +"ChatList.FolderAllChats" = "所有聊天"; +"ChatList.FrozenAccount.Text" = "点击查看详细信息并提交申诉。"; +"ChatList.FrozenAccount.Title" = "您的账号已被冻结"; +"ChatList.GeneralHidden" = "通用话题已隐藏"; +"ChatList.GeneralHiddenInfo" = "下拉以查看通用话题。"; +"ChatList.GeneralUnhidden" = "通用话题已取消隐藏"; +"ChatList.GeneralUnhiddenInfo" = "向左滑动主话题页面以隐藏。"; +"ChatList.GenericPsaAlert" = "在聊天列表中提供公共服务通知。"; +"ChatList.GenericPsaLabel" = "公共服务通告"; +"ChatList.GlobalSearch.LimitPlaceholder.Text#other" = "您每天只可以进行 %@ 次搜索。"; +"ChatList.GlobalSearch.LimitPlaceholder.Title" = "已达上线"; +"ChatList.GlobalSearch.SearchButton" = "搜索"; +"ChatList.GlobalSearch.SearchButtonPaidSubtitle" = "%@ 后解锁免费搜索"; +"ChatList.GlobalSearch.SearchButtonPaidTitle" = "花费 ⭐️%@ 搜索"; +"ChatList.GlobalSearch.SearchButtonPremium" = "订阅 Telegram 高级版"; +"ChatList.GlobalSearch.SearchButtonQuery" = "搜索 {}"; +"ChatList.GlobalSearch.StartPlaceholder.RemainingSubtitle#other" = "今天还剩下 %@ 次免费搜索。"; +"ChatList.GlobalSearch.StartPlaceholder.Subtitle" = "全局搜索是高级版的特权。"; +"ChatList.GlobalSearch.StartPlaceholder.Text" = "输入关键词即可在所有公开帖子中搜索"; +"ChatList.GlobalSearch.StartPlaceholder.Title" = "全局搜索"; +"ChatList.HeaderImportIntoAnExistingGroup" = "选择一个聊天以导入消息"; +"ChatList.HeaderPublicPosts" = "公开帖子"; +"ChatList.HideAction" = "隐藏"; +"ChatList.InlineButtonOpenApp" = "打开"; +"ChatList.ItemMenuDelete" = "删除"; +"ChatList.ItemMenuEdit" = "编辑"; +"ChatList.ItemMoreMessagesFormat#other" = "其他 +%d 个"; +"ChatList.LabelAutodeleteAfter" = "%@ 后自动删除"; +"ChatList.LabelAutodeleteDisabled" = "自动删除已停用"; +"ChatList.LeaveGroupConfirmation" = "您确定要退出 %@ 吗?"; +"ChatList.MaxThreadPinsFinalText#other" = "抱歉,您最多只能置顶 **%@** 个话题。请先取消置顶一些当前已置顶的话题。"; +"ChatList.MessageFiles#other" = "%@ 个文件"; +"ChatList.MessageMusic#other" = "%@ 个音乐文件"; +"ChatList.MessagePhotos#other" = "%@ 张图片"; +"ChatList.MessageVideos#other" = "%@ 段视频"; +"ChatList.MonoforumEmptyText" = "暂无消息..."; +"ChatList.MonoforumLabel" = "私信"; +"ChatList.Mute" = "静音"; +"ChatList.PaidSearchToast.Text#other" = "额外搜索将花費 %@ 颗星星。"; +"ChatList.PanelNewChatsAvailable#other" = "%d 个可用的新聊天"; +"ChatList.PeerTypeBot" = "机器人"; +"ChatList.PeerTypeChannel" = "频道"; +"ChatList.PeerTypeContact" = "联系人"; +"ChatList.PeerTypeGroup" = "群组"; +"ChatList.PeerTypeNonContact" = "用户"; +"ChatList.PeerTypeNonContactUser" = "非联系人"; +"ChatList.PremiumAnnualDiscountText" = "以年付计划订阅 Telegram 高级版来获得此折扣。"; +"ChatList.PremiumAnnualDiscountTitle" = "享受 %@ 折扣优惠订阅 Telegram 高级版"; +"ChatList.PremiumAnnualUpgradeText" = "升级为 Telegram 高级版的年付计划即可享受优惠。"; +"ChatList.PremiumAnnualUpgradeTitle" = "您的订阅可享受最多 %@ 的优惠"; +"ChatList.PremiumGiftInSettingsInfo" = "您可以稍后从 **设置** 中给朋友赠送礼物。"; +"ChatList.PremiumGraceText" = "您的专属福利即将到期。"; +"ChatList.PremiumGraceTitle" = "⚠️ 不要失去 Telegram 高级版的访问权限!"; +"ChatList.PremiumRestoreDiscountText" = "您的 Telegram 高级版最近已过期,点击此处以续费。"; +"ChatList.PremiumRestoreDiscountTitle" = "以最高 %@ 的折扣续费高级版"; +"ChatList.PremiumXmasGiftText" = "赠送限定版典藏品给朋友!"; +"ChatList.PremiumXmasGiftTitle" = "👑 史努比狗狗礼物放送 🎁"; +"ChatList.PsaAlert.covid" = "此信息为您提供正在发生的新冠肺炎疫情相关公共服务公告。了解有关此计划的更多咨询:https://telegram.org/blog/coronavirus"; +"ChatList.PsaAlert.psa" = "这是 Telegram 首席执行官对于目前冲突的正式声明。"; +"ChatList.PsaLabel.covid" = "新冠病毒肺炎"; +"ChatList.PsaLabel.psa" = "公告"; +"ChatList.Read" = "已读"; +"ChatList.ReadAll" = "全部已读"; +"ChatList.RemoveFolder" = "移除"; +"ChatList.RemoveFolderAction" = "移除"; +"ChatList.RemoveFolderConfirmation" = "即将移除该文件夹,您的聊天记录不会被删除。"; +"ChatList.RemoveFolderConfirmationTitle" = "移除 %@?"; +"ChatList.RemovedFromFolderTooltip" = "%1$@ 已从 %2$@ 中移除"; +"ChatList.RemovedFromFolderTooltipV2" = "{chat} 已从 {folder} 文件夹中移出"; +"ChatList.ReorderTabs" = "重新排序标签"; +"ChatList.Search.Ad" = "广告"; +"ChatList.Search.Apps.Empty.Text" = "未找到应用程序"; +"ChatList.Search.FilterApps" = "应用"; +"ChatList.Search.FilterChannels" = "频道"; +"ChatList.Search.FilterChats" = "聊天"; +"ChatList.Search.FilterDownloads" = "下载"; +"ChatList.Search.FilterFiles" = "文件"; +"ChatList.Search.FilterGlobalPosts" = "帖子"; +"ChatList.Search.FilterLinks" = "链接"; +"ChatList.Search.FilterMedia" = "媒体"; +"ChatList.Search.FilterMusic" = "音乐"; +"ChatList.Search.FilterPublicPosts" = "公开贴文"; +"ChatList.Search.FilterTopics" = "话题"; +"ChatList.Search.FilterVoice" = "语音"; +"ChatList.Search.Messages.AllChats" = "所有聊天"; +"ChatList.Search.Messages.Channels" = "频道"; +"ChatList.Search.Messages.GroupChats" = "群组"; +"ChatList.Search.Messages.Menu.AllChats" = "所有聊天"; +"ChatList.Search.Messages.Menu.Channels" = "频道"; +"ChatList.Search.Messages.Menu.GroupChats" = "群聊"; +"ChatList.Search.Messages.Menu.PrivateChats" = "私聊"; +"ChatList.Search.Messages.PrivateChats" = "私聊"; +"ChatList.Search.NoResults" = "无结果"; +"ChatList.Search.NoResultsDescription" = "找不到结果,请重试。"; +"ChatList.Search.NoResultsFilter" = "暂无结果"; +"ChatList.Search.NoResultsFitlerFiles" = "您所有聊天中的文件都会在这里显示。"; +"ChatList.Search.NoResultsFitlerLinks" = "您所有聊天中的链接都会在这里显示。"; +"ChatList.Search.NoResultsFitlerMedia" = "您所有聊天中的视频和图片都会在这里显示。"; +"ChatList.Search.NoResultsFitlerMusic" = "您所有聊天中的音频都会在这里显示。"; +"ChatList.Search.NoResultsFitlerVoice" = "您所有聊天中的语音和视频消息都会在这里显示。"; +"ChatList.Search.NoResultsQueryDescription" = "没有找到“%@”的相关结果。\n请重新搜索。"; +"ChatList.Search.Open" = "打开"; +"ChatList.Search.RecommendedChannelsEmpty.Text" = "您目前没有订阅任何频道。"; +"ChatList.Search.RecommendedChannelsEmpty.Title" = "暂无频道…"; +"ChatList.Search.SectionActionShowLess" = "显示较少"; +"ChatList.Search.SectionActionShowMore" = "显示更多"; +"ChatList.Search.SectionApps" = "App"; +"ChatList.Search.SectionLocalChannels" = "您加入的频道"; +"ChatList.Search.SectionPopularApps" = "畅销应用"; +"ChatList.Search.SectionRecentApps" = "您用过的应用"; +"ChatList.Search.SectionRecommendedChannels" = "相似的频道"; +"ChatList.Search.ShowLess" = "显示更少"; +"ChatList.Search.ShowMore" = "显示更多"; +"ChatList.Search.TopAppsInfo" = "这里包括哪些应用? [了解 >]()"; +"ChatList.SelectedChats#other" = "已选择 %@ 个聊天"; +"ChatList.SelectedTopics#other" = "已选定 %@ 条话题"; +"ChatList.SessionReview.ConfirmToastText" = "您可以在 [设置 > 设备]() 中查看您的活跃登录列表。"; +"ChatList.SessionReview.ConfirmToastTitle" = "允许新登录"; +"ChatList.SessionReview.PanelConfirm" = "是的,这是我"; +"ChatList.SessionReview.PanelReject" = "不,这不是我!"; +"ChatList.SessionReview.PanelText" = "我们检测到您的账号有一次来自 %1$@,%2$@ 的新登录。这是您本人操作的吗?"; +"ChatList.SessionReview.PanelTitle" = "有人刚刚访问了您的消息!"; +"ChatList.StartAction" = "开始"; +"ChatList.StartMessaging" = "请选择一个会话开始聊天"; +"ChatList.StorageHintText" = "清理 iPhone 存储空间"; +"ChatList.StorageHintTitle" = "释放最多 %@ 空间"; +"ChatList.StoryFeedTooltip" = "点击上面查看\n%@ 的动态"; +"ChatList.StoryFeedTooltipUsers" = "点击上面查看 %@ 的动态"; +"ChatList.SubscriptionsLowBalance.Multiple.Text" = "资金不足以支付您的订阅费用。"; +"ChatList.SubscriptionsLowBalance.Multiple.Title" = "%@ 您的订阅"; +"ChatList.SubscriptionsLowBalance.Single.Text" = "资金不足以支付您的订阅费用。"; +"ChatList.SubscriptionsLowBalance.Single.Title" = "%1$@ 用于 %2$@"; +"ChatList.SubscriptionsLowBalance.Stars#other" = "需要 %@ 颗星星"; +"ChatList.TabIconFoldersTooltipEmptyFolders" = "长按以整理文件夹中的聊天记录。"; +"ChatList.TabIconFoldersTooltipNonEmptyFolders" = "长按“聊天”可编辑文件夹并在视图之间切换。"; +"ChatList.Tabs.All" = "全部"; +"ChatList.Tabs.AllChats" = "所有聊天"; +"ChatList.ThreadHideAction" = "隐藏"; +"ChatList.ThreadUnhideAction" = "取消隐藏"; +"ChatList.ToastFolderMuted" = "在 **%@** 中的聊天已全部静音"; +"ChatList.ToastFolderMutedV2" = "{folder} 中的所有聊天现已关闭通知"; +"ChatList.ToastFolderUnmuted" = "在 **%@** 中的聊天已全部取消静音"; +"ChatList.ToastFolderUnmutedV2" = "{folder} 中的所有聊天现已取消静音"; +"ChatList.UnarchiveAction" = "取消归档"; +"ChatList.UndoArchiveHiddenText" = "下拉查看归档。"; +"ChatList.UndoArchiveHiddenTitle" = "归档已被隐藏"; +"ChatList.UndoArchiveMultipleTitle" = "聊天已归档。"; +"ChatList.UndoArchiveRevealedText" = "向左滑动隐藏归档。"; +"ChatList.UndoArchiveRevealedTitle" = "归档已置顶"; +"ChatList.UndoArchiveText1" = "向左滑动来隐藏归档。"; +"ChatList.UndoArchiveTitle" = "聊天已归档。"; +"ChatList.UnhideAction" = "置顶"; +"ChatList.Unmute" = "开启通知"; +"ChatList.UserReacted" = "对您的消息作出了回应 %@"; +"ChatListFilter.AddChatsSearchPlaceholder" = "搜索聊天"; +"ChatListFilter.AddChatsTitle" = "添加聊天…"; +"ChatListFilter.AlertCreateFolderBeforeSharingText" = "请先创建此文件夹,然后再分享。"; +"ChatListFilter.CreateLink" = "创建新链接"; +"ChatListFilter.CreateLinkErrorSomeoneHasChannelLimit" = "无法将其中一个群组添加至文件夹中,因为其中一位管理员已加入过多群组和频道。"; +"ChatListFilter.CreateLinkNew" = "创建一个邀请链接"; +"ChatListFilter.CreateLinkUnknownError" = "创建邀请链接时发生错误"; +"ChatListFilter.ErrorShareInvalidFolder" = "您不能分享包含特定聊天类别(比如“群组”、“联系人”等)的分组。"; +"ChatListFilter.ExcludeChatsAction" = "添加以排除聊天"; +"ChatListFilter.LinkActionDelete" = "删除"; +"ChatListFilter.LinkLabelChatCount#other" = "包括 %d 个聊天"; +"ChatListFilter.LinkListInfo" = "创建更多链接,为不同的人设置不同的访问级别。"; +"ChatListFilter.LinkListInfoNew" = "与其他人分享此文件夹中的群组和频道的访问权。"; +"ChatListFilter.NameDisableAnimations" = "禁用动画"; +"ChatListFilter.NameEnableAnimations" = "启用动画"; +"ChatListFilter.SaveAlertActionSave" = "保存"; +"ChatListFilter.SectionShare" = "分享文件夹"; +"ChatListFilter.ShowMoreChats#other" = "显示其它 %@ 个聊天"; +"ChatListFilter.TagLabelNoTag" = "无标签"; +"ChatListFilter.TagLabelPremiumExpired" = "高级版已过期"; +"ChatListFilter.TagPremiumRequiredTooltipText" = "订阅 **Telegram 高级版** 以选择文件夹颜色。"; +"ChatListFilter.TagSectionFooter" = "此颜色会被用作消息列表中此文件夹的标签。"; +"ChatListFilter.TagSectionTitle" = "文件夹颜色"; +"ChatListFilter.ToastChatsAddedText" = "这不会影响该文件夹链接中的聊天列表。"; +"ChatListFilter.ToastChatsAddedTitle#other" = "已将 %d 个聊天添加至文件夹"; +"ChatListFilter.ToastChatsRemovedText" = "这不会影响此文件夹链接的聊天列表。"; +"ChatListFilter.ToastChatsRemovedTitle#other" = "已从文件夹中移除 %d 个聊天"; +"ChatListFilterList.CreateFolder" = "创建文件夹"; +"ChatListFilterList.ShowTags" = "显示文件夹标签"; +"ChatListFilterList.ShowTagsFooter" = "显示聊天列表中每个聊天的文件夹名称。"; +"ChatListFolder.AddChats" = "添加聊天"; +"ChatListFolder.CategoryArchived" = "归档"; +"ChatListFolder.CategoryBots" = "机器人"; +"ChatListFolder.CategoryChannels" = "频道"; +"ChatListFolder.CategoryContacts" = "联系人"; +"ChatListFolder.CategoryGroups" = "群组"; +"ChatListFolder.CategoryMuted" = "已静音"; +"ChatListFolder.CategoryNonContacts" = "非联系人"; +"ChatListFolder.CategoryRead" = "已读"; +"ChatListFolder.DiscardCancel" = "否"; +"ChatListFolder.DiscardConfirmation" = "您已编辑此文件夹,是否放弃更改?"; +"ChatListFolder.DiscardDiscard" = "放弃"; +"ChatListFolder.ExcludeChatsTitle" = "排除聊天"; +"ChatListFolder.ExcludeSectionInfo" = "选择不会出现在此文件夹中的聊天或聊天类型。"; +"ChatListFolder.ExcludedSectionHeader" = "排除的聊天"; +"ChatListFolder.IncludeChatsTitle" = "包含聊天"; +"ChatListFolder.IncludeSectionInfo" = "选择将出现在此文件夹中的聊天和聊天类型。"; +"ChatListFolder.IncludedSectionHeader" = "包含的聊天"; +"ChatListFolder.NameBots" = "机器人"; +"ChatListFolder.NameChannels" = "频道"; +"ChatListFolder.NameContacts" = "联系人"; +"ChatListFolder.NameGroups" = "群组"; +"ChatListFolder.NameNonContacts" = "非联系人"; +"ChatListFolder.NameNonMuted" = "未静音"; +"ChatListFolder.NamePlaceholder" = "文件夹名称"; +"ChatListFolder.NameSectionHeader" = "文件夹名称"; +"ChatListFolder.NameUnread" = "未读"; +"ChatListFolder.TitleCreate" = "新文件夹"; +"ChatListFolder.TitleEdit" = "编辑文件夹"; +"ChatListFolderSettings.AddRecommended" = "添加"; +"ChatListFolderSettings.EditFoldersInfo" = "点击“编辑”以更改顺序或删除文件夹"; +"ChatListFolderSettings.FoldersSection" = "文件夹"; +"ChatListFolderSettings.Info" = "为不同聊天分组创建文件夹,并在它们之间快速切换。"; +"ChatListFolderSettings.NewFolder" = "创建新文件夹"; +"ChatListFolderSettings.RecommendedFoldersSection" = "推荐文件夹"; +"ChatListFolderSettings.RecommendedNewFolder" = "添加自定义文件夹"; +"ChatListFolderSettings.SubscribeToMoveAll" = "订阅 **Telegram 高级版** 以移动 \"所有聊天\" 文件夹。"; +"ChatListFolderSettings.SubscribeToMoveAllAction" = "更多"; +"ChatListFolderSettings.Title" = "文件夹"; +"ChatSearch.ResultsTooltip" = "点击以列表形式查看。"; +"ChatSearch.SearchPlaceholder" = "搜索"; +"ChatSettings.Appearance" = "外观"; +"ChatSettings.AutoDownloadDocuments" = "文件"; +"ChatSettings.AutoDownloadEnabled" = "自动下载媒体文件"; +"ChatSettings.AutoDownloadPhotos" = "图片"; +"ChatSettings.AutoDownloadReset" = "重置自动下载设置"; +"ChatSettings.AutoDownloadSettings.Delimeter" = ","; +"ChatSettings.AutoDownloadSettings.OffForAll" = "已停用"; +"ChatSettings.AutoDownloadSettings.TypeFile" = "文件 (%@)"; +"ChatSettings.AutoDownloadSettings.TypeMedia" = "媒体 (%@)"; +"ChatSettings.AutoDownloadSettings.TypePhoto" = "图片"; +"ChatSettings.AutoDownloadSettings.TypeVideo" = "视频 (%@)"; +"ChatSettings.AutoDownloadTitle" = "自动下载媒体文件"; +"ChatSettings.AutoDownloadUsingCellular" = "使用数据流量时"; +"ChatSettings.AutoDownloadUsingWiFi" = "使用Wi-Fi"; +"ChatSettings.AutoDownloadVideoMessages" = "视频消息"; +"ChatSettings.AutoDownloadVideos" = "视频"; +"ChatSettings.AutoDownloadVoiceMessages" = "语音消息"; +"ChatSettings.AutoPlayAnimations" = "自动播放 GIF"; +"ChatSettings.AutoPlayGifs" = "GIF"; +"ChatSettings.AutoPlayTitle" = "自动播放媒体"; +"ChatSettings.AutoPlayVideos" = "视频"; +"ChatSettings.AutomaticPhotoDownload" = "自动下载图片"; +"ChatSettings.AutomaticVideoMessageDownload" = "自动下载视频消息"; +"ChatSettings.Cache" = "存储用量"; +"ChatSettings.ConnectionType.Title" = "连接类型"; +"ChatSettings.ConnectionType.UseProxy" = "使用代理"; +"ChatSettings.ConnectionType.UseSocks5" = "SOCKS5"; +"ChatSettings.DownloadInBackground" = "后台下载"; +"ChatSettings.DownloadInBackgroundInfo" = "本应用将在一段时间内继续下载媒体文件。"; +"ChatSettings.Groups" = "群组"; +"ChatSettings.IntentsSettings" = "分享选单"; +"ChatSettings.OpenLinksIn" = "打开链接"; +"ChatSettings.Other" = "其它"; +"ChatSettings.PrivateChats" = "私聊"; +"ChatSettings.Stickers" = "贴纸与表情"; +"ChatSettings.StickersAndReactions" = "贴纸与表情"; +"ChatSettings.TextSize" = "字号大小"; +"ChatSettings.TextSizeUnits" = "号"; +"ChatSettings.Title" = "数据和存储"; +"ChatSettings.UseLessDataForCalls" = "通话时使用更少流量"; +"ChatState.Connecting" = "连接中..."; +"ChatState.ConnectingToProxy" = "正在连接代理…"; +"ChatState.Updating" = "更新中..."; +"ChatState.WaitingForNetwork" = "等待网络中..."; +"ChatbotSetup.BotAddAction" = "添加"; +"ChatbotSetup.BotInstalled" = "%@ 现正管理您的账号。"; +"ChatbotSetup.BotNotFoundStatus" = "未找到聊天机器人"; +"ChatbotSetup.BotSearchPlaceholder" = "机器人用户名或链接"; +"ChatbotSetup.BotSectionFooter" = "输入您希望自动处理聊天的 Telegram 机器人链接。"; +"ChatbotSetup.ErrorBotNotBusinessCapable" = "此机器人暂不支持 Telegram 企业版。"; +"ChatbotSetup.Gift.Warning.CombinedText" = "机器人**%@** 将有权管理您的礼物和星星,包括将它们赠送给其他用户。"; +"ChatbotSetup.Gift.Warning.GiftsText" = "机器人 **%@** 将有权 **管理您的礼物**,包括將它们赠送给其他用户。"; +"ChatbotSetup.Gift.Warning.Proceed" = "继续"; +"ChatbotSetup.Gift.Warning.StarsText" = "机器人 **%@** 将有权 **转让您的星星**。"; +"ChatbotSetup.Gift.Warning.Title" = "警告"; +"ChatbotSetup.Gift.Warning.UsernameText" = "机器人**%@** 将有权对您的账户 **设置和移除用户名**,这可能会导致您丢失目前的用户名。"; +"ChatbotSetup.Permission.ReplyToMessages" = "回复消息"; +"ChatbotSetup.PermissionsSectionFooter" = "机器人只能代替您回复最近 24 小时内活跃的聊天。"; +"ChatbotSetup.PermissionsSectionHeader" = "机器人权限"; +"ChatbotSetup.RecipientSummary.ExcludedChatsItem" = "不包含的聊天"; +"ChatbotSetup.RecipientSummary.IncludedChatsItem" = "包含的聊天"; +"ChatbotSetup.RecipientSummary.ValueEmpty" = "添加"; +"ChatbotSetup.RecipientSummary.ValueItems#other" = "%d 个"; +"ChatbotSetup.Recipients.AddUsers" = "添加用户"; +"ChatbotSetup.Recipients.ExcludedListTitle" = "排除的聊天"; +"ChatbotSetup.Recipients.ExcludedSectionFooter" = "选择机器人 **不能** 访问的聊天或整个聊天类别。"; +"ChatbotSetup.Recipients.IncludedListTitle" = "包含的聊天"; +"ChatbotSetup.Recipients.IncludedSectionFooter" = "选择机器人将有权访问的聊天或整个聊天类型。"; +"ChatbotSetup.Recipients.RemoveAll" = "删除所有例外"; +"ChatbotSetup.Recipients.SelectionTitle" = "添加例外"; +"ChatbotSetup.RecipientsSectionHeader" = "机器人可访问的聊天"; +"ChatbotSetup.Rights.ChangeGiftSettings" = "更改礼物设置"; +"ChatbotSetup.Rights.DeleteReceivedMessages" = "删除任何消息"; +"ChatbotSetup.Rights.DeleteSentMessages" = "删除已发送的消息"; +"ChatbotSetup.Rights.EditBio" = "编辑个人简介"; +"ChatbotSetup.Rights.EditName" = "编辑名称"; +"ChatbotSetup.Rights.EditProfilePhoto" = "编辑头像"; +"ChatbotSetup.Rights.EditUsername" = "编辑用户名"; +"ChatbotSetup.Rights.ManageGiftsAndStars" = "管理礼物或星星"; +"ChatbotSetup.Rights.ManageMessages" = "管理消息"; +"ChatbotSetup.Rights.ManageProfile" = "管理个人资料页"; +"ChatbotSetup.Rights.ManageStories" = "管理动态"; +"ChatbotSetup.Rights.MarkAsRead" = "标记消息为已读"; +"ChatbotSetup.Rights.ReadMessages" = "查看消息"; +"ChatbotSetup.Rights.ReplyToMessages" = "回复消息"; +"ChatbotSetup.Rights.SellGifts" = "将礼物兑换成星星"; +"ChatbotSetup.Rights.TransferAndUpgradeGifts" = "转让和升级礼物"; +"ChatbotSetup.Rights.TransferStars" = "转让星星"; +"ChatbotSetup.Rights.ViewGifts" = "查看礼物和星星"; +"ChatbotSetup.SetupNotCompleted.Leave" = "退出"; +"ChatbotSetup.SetupNotCompleted.Text" = "您还没新增机器人来管理您的账号。仍然退出?"; +"ChatbotSetup.SetupNotCompleted.Title" = "未添加机器人"; +"ChatbotSetup.Text" = "为您的帐号添加机器人,帮助您自动处理和回复收到的消息。"; +"ChatbotSetup.TextLink" = "https://telegram.org/blog/telegram-business#chatbots-for-business"; +"ChatbotSetup.Title" = "即将推出"; +"ChatbotSetup.TitleItem" = "自动聊天"; +"Checkout.Email" = "电子邮箱"; +"Checkout.EnterPassword" = "输入密码"; +"Checkout.ErrorGeneric" = "处理您的付款时出错,您的银行卡没有被扣款。"; +"Checkout.ErrorInvoiceAlreadyPaid" = "您已经支付过此商品了。"; +"Checkout.ErrorPaymentFailed" = "付款失败。您的银行卡没有被扣款。"; +"Checkout.ErrorPrecheckoutFailed" = "此机器人无法处理您的付款。您的银行卡没有被扣款。"; +"Checkout.ErrorProviderAccountInvalid" = "此机器人目前无法接受支付请求,请稍后再试。"; +"Checkout.ErrorProviderAccountTimeout" = "这个机器人现在无法处理付款,请稍后再试。"; +"Checkout.LiabilityAlert" = "无论是 Telegram 还是 %1$@ 都无法获取您的信用卡信息。信用卡信息将仅由支付平台 %2$@ 处理。\n\n款项将会直接发送给 %1$@ 的开发者。Telegram 无法提供任何形式的担保,继续付款所带来的风险将由您自己承担。如果遇到问题,请联系 %1$@ 的开发者或者您的银行。"; +"Checkout.LiabilityAlertTitle" = "警告"; +"Checkout.Name" = "姓名"; +"Checkout.NewCard.CardholderNamePlaceholder" = "持卡人姓名"; +"Checkout.NewCard.CardholderNameTitle" = "持卡人"; +"Checkout.NewCard.PaymentCard" = "付款卡"; +"Checkout.NewCard.PostcodePlaceholder" = "邮政编码"; +"Checkout.NewCard.PostcodeTitle" = "账单地址"; +"Checkout.NewCard.SaveInfo" = "保存付款信息"; +"Checkout.NewCard.SaveInfoEnableHelp" = "您可以保存您的付款信息以便在未来使用。\n请先[开启两步验证]后再启用此功能。"; +"Checkout.NewCard.SaveInfoHelp" = "您可以保存付款信息以便在未来使用。将会直接由支付服务提供商保存。Telegram 无法获取您的信用卡数据。"; +"Checkout.NewCard.Title" = "添加银行卡"; +"Checkout.OptionalTipItem" = "小费(可选)"; +"Checkout.OptionalTipItemPlaceholder" = "自定义输入"; +"Checkout.PasswordEntry.Pay" = "支付"; +"Checkout.PasswordEntry.Text" = "您曾保存过您的银行卡(%@)的信息。要使用此卡支付,请输入您的两步验证密码。"; +"Checkout.PasswordEntry.Title" = "支付确认"; +"Checkout.PayNone" = "支付"; +"Checkout.PayPrice" = "支付 %@"; +"Checkout.PayWithFaceId" = "使用 Face ID 支付"; +"Checkout.PayWithTouchId" = "使用 Touch ID 支付"; +"Checkout.PaymentLiabilityAlert" = "无论是Telegram,还是 {target} ,都无法获得你的信用卡信息。信用卡信息将只由支付系统 {payment_system} 处理。\n\n付款将直接转给 {target} 的开发者。Telegram不能提供任何保证,所以请自行承担风险。如果出现问题,请联系 {target} 的开发者或你的银行。"; +"Checkout.PaymentLiabilityBothAlert" = "Telegram 无法获取您的信用卡信息。信用卡详细信息仅由支付系统 {target} 处理。\n\n款项将直接发送给 {target} 的开发者。Telegram 无法提供任何形式的担保,故您将自行承担风险。 如有问题,请联系 {target} 的开发者或您的银行。"; +"Checkout.PaymentMethod" = "支付方式"; +"Checkout.PaymentMethod.New" = "添加银行卡…"; +"Checkout.PaymentMethod.Proceed" = "继续"; +"Checkout.PaymentMethod.Title" = "支付方式"; +"Checkout.Phone" = "电话号码"; +"Checkout.Receipt.Title" = "收据"; +"Checkout.SavePasswordTimeout" = "您是否要在 %@ 内保存密码?"; +"Checkout.SavePasswordTimeoutAndFaceId" = "您是否希望保存您的密码 %@并改用 Face ID 支付?"; +"Checkout.SavePasswordTimeoutAndTouchId" = "您是否希望保存您的密码 %@并改用 Touch ID 支付?"; +"Checkout.ShippingAddress" = "配送信息"; +"Checkout.ShippingMethod" = "配送方式"; +"Checkout.ShippingMethod.Proceed" = "继续"; +"Checkout.ShippingOption.Title" = "配送方式"; +"Checkout.SuccessfulTooltip" = "你已花费 %1$@ 购买 %2$@ 。"; +"Checkout.TipItem" = "小费"; +"Checkout.Title" = "结算"; +"Checkout.TotalAmount" = "总计"; +"Checkout.TotalPaidAmount" = "总计支付"; +"Checkout.WebConfirmation.Title" = "支付完成"; +"CheckoutInfo.ErrorCityInvalid" = "请输入有效的城市。"; +"CheckoutInfo.ErrorEmailInvalid" = "请输入有效的电子邮箱地址。"; +"CheckoutInfo.ErrorNameInvalid" = "请输入有效的姓名。"; +"CheckoutInfo.ErrorPhoneInvalid" = "请输入有效的手机号码。"; +"CheckoutInfo.ErrorPostcodeInvalid" = "请输入有效的邮政编码。"; +"CheckoutInfo.ErrorShippingNotAvailable" = "无法配送到您所选择的国家。"; +"CheckoutInfo.ErrorStateInvalid" = "请输入有效的省份/州。"; +"CheckoutInfo.Pay" = "支付"; +"CheckoutInfo.ReceiverInfoEmail" = "电子邮箱"; +"CheckoutInfo.ReceiverInfoEmailPlaceholder" = "电子邮箱"; +"CheckoutInfo.ReceiverInfoName" = "姓名"; +"CheckoutInfo.ReceiverInfoNamePlaceholder" = "姓名"; +"CheckoutInfo.ReceiverInfoPhone" = "电话号码"; +"CheckoutInfo.ReceiverInfoTitle" = "收件人"; +"CheckoutInfo.SaveInfo" = "保存信息"; +"CheckoutInfo.SaveInfoHelp" = "您可以保存您的配送信息以便在未来使用。"; +"CheckoutInfo.ShippingInfoAddress1" = "地址行 1"; +"CheckoutInfo.ShippingInfoAddress1Placeholder" = "地址"; +"CheckoutInfo.ShippingInfoAddress2" = "地址行 2"; +"CheckoutInfo.ShippingInfoAddress2Placeholder" = "地址"; +"CheckoutInfo.ShippingInfoCity" = "城市"; +"CheckoutInfo.ShippingInfoCityPlaceholder" = "城市"; +"CheckoutInfo.ShippingInfoCountry" = "国家/地区"; +"CheckoutInfo.ShippingInfoCountryPlaceholder" = "国家/地区"; +"CheckoutInfo.ShippingInfoPostcode" = "邮政编码"; +"CheckoutInfo.ShippingInfoPostcodePlaceholder" = "邮政编码"; +"CheckoutInfo.ShippingInfoState" = "省份/州"; +"CheckoutInfo.ShippingInfoStatePlaceholder" = "省份/州"; +"CheckoutInfo.ShippingInfoTitle" = "配送地址"; +"CheckoutInfo.Title" = "配送信息"; +"ClearCache.Clear" = "清除"; +"ClearCache.ClearCache" = "清除Telegram缓存"; +"ClearCache.ClearDescription" = "所有的媒体都将存于 Telegram 云端服务器上。若你需要,可以重新下载。"; +"ClearCache.Description" = "媒体文件将从您的磁盘中清除,但如果需要仍可重新下载。"; +"ClearCache.Forever" = "永久"; +"ClearCache.FreeSpace" = "释放空间"; +"ClearCache.FreeSpaceDescription" = "如果您要节省设备空间,并不需要删除任何东西。\n\n您可以使用缓存设置删除不重要的媒体文件,需要时可以重新下载。"; +"ClearCache.KeepOpenedDescription" = "请保持此窗口为打开状态,直到 Telegram 清理完您的缓存。"; +"ClearCache.Never" = "从不"; +"ClearCache.NoProgress" = "缓存清除中"; +"ClearCache.Progress" = "清除缓存数据中 • %d%"; +"ClearCache.StorageCache" = "Telegram 缓存"; +"ClearCache.StorageFree" = "释放"; +"ClearCache.StorageOtherApps" = "其他 App"; +"ClearCache.StorageServiceFiles" = "Telegram 服务文件"; +"ClearCache.StorageTitle" = "%@ 储存"; +"ClearCache.StorageUsage" = "储存用量"; +"ClearCache.Success" = "**%@** 已释放在您的 %@!"; +"Clipboard.SendPhoto" = "发送图片"; +"CloudStorage.Title" = "云存储"; +"CocoonInfo.Description" = "Cocoon (**Co**nfidential **Co**mpute **O**pen **N**etwork) 能安全且高效地处理 AI 任务。"; +"CocoonInfo.Efficient.Text" = "Cocoon 帮助 Telegram 将翻译成本降低了 5/6。"; +"CocoonInfo.Efficient.Title" = "高效"; +"CocoonInfo.ForEveryone.Text" = "任何开发者都能使用 Cocoon 实现 AI 功能。了解更多,请访问\u00a0**cocoon.org**。"; +"CocoonInfo.ForEveryone.Title" = "人人都可以用"; +"CocoonInfo.IntergrateInfo" = "想在您的项目中集成 Cocoon 吗?\n请通过\u00a0**t.me/cocoon?direct**\u00a0联系我们。"; +"CocoonInfo.Private.Text" = "任何第三方都无法访问 **@cocoon** 内的任何数据,包括翻译。"; +"CocoonInfo.Private.Title" = "隐私"; +"CocoonInfo.Title" = "COCOON"; +"CocoonInfo.Understood" = "知道了"; +"CollectibleItemInfo.ButtonCopyPhone" = "复制电话号码"; +"CollectibleItemInfo.ButtonCopyUsername" = "拷贝链接"; +"CollectibleItemInfo.ButtonOpenInfo" = "了解更多"; +"CollectibleItemInfo.PhoneText" = "该电话号码 %1$@ 于 %2$@ 在 %3$@ 上花费 %4$@ (%5$@)购买。"; +"CollectibleItemInfo.PhoneTitle" = "%@ 是一个典藏的电话号码,其属于"; +"CollectibleItemInfo.ShareInlineText.LearnMore" = "了解更多 >"; +"CollectibleItemInfo.StoreName" = "Fragment"; +"CollectibleItemInfo.UsernameText" = "用户名 %1$@ 已于 %2$@ 在 %3$@ 上花费 %4$@ (%5$@) 取得。"; +"CollectibleItemInfo.UsernameTitle" = "%@ 是一个典藏用户名,属于"; +"CommentsGroup.ErrorAccessDenied" = "抱歉,由于您被管理员封禁,该聊天已无法访问。"; +"Common.ActionNotAllowedError" = "抱歉,您不能执行此操作。"; +"Common.Back" = "返回"; +"Common.Cancel" = "取消"; +"Common.ChoosePhoto" = "选择照片"; +"Common.Close" = "关闭"; +"Common.Create" = "创建"; +"Common.Delete" = "删除"; +"Common.Done" = "完成"; +"Common.Edit" = "编辑"; +"Common.More" = "更多"; +"Common.Next" = "下一步"; +"Common.No" = "否"; +"Common.NotNow" = "稍后"; +"Common.OK" = "确定"; +"Common.Paste" = "粘贴"; +"Common.Save" = "保存"; +"Common.Search" = "搜索"; +"Common.Select" = "选择"; +"Common.TakePhoto" = "拍照"; +"Common.TakePhotoOrVideo" = "拍照或录像"; +"Common.Yes" = "是"; +"Common.edit" = "编辑"; +"Common.of" = "/"; +"Compose.ChannelMembers" = "成员"; +"Compose.ChannelTokenListPlaceholder" = "搜索联系人或用户名"; +"Compose.Create" = "创建"; +"Compose.GroupTokenListPlaceholder" = "搜索联系人或用户名"; +"Compose.NewChannel" = "新建频道"; +"Compose.NewEncryptedChat" = "新建私密聊天"; +"Compose.NewEncryptedChatTitle" = "新建私密聊天"; +"Compose.NewGroup" = "新建群组"; +"Compose.NewGroupTitle" = "新建群组"; +"Compose.NewMessage" = "新建对话"; +"Compose.TokenListPlaceholder" = "您想将谁加入群组?"; +"ContactInfo.BirthdayLabel" = "生日"; +"ContactInfo.Job" = "工作"; +"ContactInfo.Note" = "笔记"; +"ContactInfo.PhoneLabelHome" = "家庭"; +"ContactInfo.PhoneLabelHomeFax" = "家庭传真"; +"ContactInfo.PhoneLabelMain" = "主要"; +"ContactInfo.PhoneLabelMobile" = "手机"; +"ContactInfo.PhoneLabelOther" = "其它"; +"ContactInfo.PhoneLabelPager" = "寻呼机"; +"ContactInfo.PhoneLabelWork" = "工作"; +"ContactInfo.PhoneLabelWorkFax" = "工作传真"; +"ContactInfo.PhoneNumberHidden" = "隐藏"; +"ContactInfo.Title" = "联系人信息"; +"ContactInfo.URLLabelHomepage" = "网站"; +"ContactList.Context.Call" = "语音通话"; +"ContactList.Context.Delete" = "删除联系人"; +"ContactList.Context.Select" = "选择"; +"ContactList.Context.SendMessage" = "发送消息"; +"ContactList.Context.StartSecretChat" = "开始私密聊天"; +"ContactList.Context.VideoCall" = "视频通话"; +"ContactList.DeleteConfirmation#other" = "删除 %@ 个联系人"; +"ContactList.DeleteConfirmationSingle" = "删除联系人"; +"ContactList.DeletedContacts#other" = "已删除 %@ 个联系人"; +"Contacts.AccessDeniedError" = "Telegram 无法访问您的通讯录"; +"Contacts.AccessDeniedHelpLandscape" = "请前往您 %@ 的「系统设置 - 隐私 - 通讯录」,\n然后开启 Telegram 的权限即可。"; +"Contacts.AccessDeniedHelpON" = "开启"; +"Contacts.AccessDeniedHelpPortrait" = "请在您的 %@ 上打开“设置” > “隐私” > “通讯录”,然后将 Telegram 的权限打开。"; +"Contacts.AddContact" = "添加联系人"; +"Contacts.AddPeopleNearby" = "查找附近的人"; +"Contacts.AddPhoneNumber" = "添加 %@"; +"Contacts.DeselectAll" = "取消全选"; +"Contacts.FailedToSendInvitesMessage" = "发生错误。"; +"Contacts.GlobalSearch" = "全局搜索"; +"Contacts.ImportersCount#other" = "%@ 位 Telegram 用户有此联系人信息"; +"Contacts.InviteContacts#other" = "邀请 %@ 位联系人"; +"Contacts.InviteFriends" = "邀请朋友"; +"Contacts.InviteSearchLabel" = "搜索联系人"; +"Contacts.InviteToTelegram" = "邀请使用 Telegram"; +"Contacts.LimitedAccess.Manage" = "管理"; +"Contacts.LimitedAccess.Text" = "您已限制 Telegram 访问全部通讯录。"; +"Contacts.MemberSearchSectionTitleGroup" = "群组成员"; +"Contacts.NotRegisteredSection" = "通讯录"; +"Contacts.PermissionsAllow" = "允许访问"; +"Contacts.PermissionsAllowInSettings" = "在设置中允许"; +"Contacts.PermissionsEnable" = "开启"; +"Contacts.PermissionsKeepDisabled" = "保持停用"; +"Contacts.PermissionsSuppressWarningText" = "您将不会知道您的朋友何时加入Telegram 。 我们建议在“设置”中启用通讯录访问。"; +"Contacts.PermissionsSuppressWarningTitle" = "是否保持通讯录停用?"; +"Contacts.PermissionsText" = "请允许 Telegram 访问您的通讯录,以便查找您的朋友。"; +"Contacts.PermissionsTitle" = "访问通讯录"; +"Contacts.PhoneNumber" = "手机号码"; +"Contacts.Search.NoResults" = "无结果"; +"Contacts.Search.NoResultsQueryDescription" = "没有找到“%@”的相关结果。\n请重新搜索。"; +"Contacts.SearchLabel" = "搜索联系人或用户名"; +"Contacts.SearchUsersAndGroupsLabel" = "搜索用户和群组"; +"Contacts.SelectAll" = "全选"; +"Contacts.SelectedContacts#other" = "已选 %@ 个人"; +"Contacts.ShareTelegram" = "分享 Telegram"; +"Contacts.Sort" = "排序"; +"Contacts.Sort.ByLastSeen" = "最后上线时间"; +"Contacts.Sort.ByName" = "按姓名"; +"Contacts.SortBy" = "排序方式:"; +"Contacts.SortByName" = "姓名"; +"Contacts.SortByPresence" = "最后上线时间"; +"Contacts.SortedByName" = "按姓名排序"; +"Contacts.SortedByPresence" = "按最后上线时间排序"; +"Contacts.TabTitle" = "联系人"; +"Contacts.Title" = "联系人"; +"Contacts.TopSection" = "联系人"; +"Contacts.VoiceOver.AddContact" = "添加联系人"; +"Conversation.AddContact" = "添加联系人"; +"Conversation.AddMembers" = "添加成员"; +"Conversation.AddNameToContacts" = "添加 %@ 为联系人"; +"Conversation.AddToContacts" = "添加为联系人"; +"Conversation.AddToContactsLong" = "添加联系人"; +"Conversation.AddToReadingList" = "添加到阅读列表"; +"Conversation.Admin" = "管理员"; +"Conversation.AlsoClearCacheTitle" = "您可以使用“清除缓存”功能删除不必要的媒体——如果您再次需要,还可以重新下载文件。"; +"Conversation.ApplyLocalization" = "执行本地化"; +"Conversation.AudioRateOptionsTooltip" = "长按选择其他倍速。"; +"Conversation.AudioRateTooltip15X" = "音频将以 1.5 倍速播放。"; +"Conversation.AudioRateTooltipCustom" = "音频将会以 %@ 的速度播放。"; +"Conversation.AudioRateTooltipNormal" = "音频将以正常速度播放。"; +"Conversation.AudioRateTooltipSpeedUp" = "音频将以两倍速度播放。"; +"Conversation.AutoremoveActionEdit" = "自动删除设置"; +"Conversation.AutoremoveActionEnable" = "开启自动删除"; +"Conversation.AutoremoveChanged" = "自动删除计时器已设为 %@。"; +"Conversation.AutoremoveOff" = "已停用自动删除。"; +"Conversation.AutoremoveRemainingDays#other" = "%@ 天后自动删除"; +"Conversation.AutoremoveRemainingTime" = "在 %@ 之后自动删除"; +"Conversation.AutoremoveTimerRemovedChannel" = "此频道中的消息将不会被自动删除"; +"Conversation.AutoremoveTimerRemovedGroup" = "群组管理员已停用自动删除计时器"; +"Conversation.AutoremoveTimerRemovedUser" = "%1$@ 已停用自动删除计时器"; +"Conversation.AutoremoveTimerRemovedUserYou" = "您已停用自动删除计时器"; +"Conversation.AutoremoveTimerSetChannel" = "此频道中的消息将在 %1$@ 后自动删除"; +"Conversation.AutoremoveTimerSetGroup" = "群组管理员已设置在 %1$@ 之后自动删除消息"; +"Conversation.AutoremoveTimerSetToastText" = "此聊天中的消息会在发出后 %@ 删除。"; +"Conversation.AutoremoveTimerSetUser" = "%1$@ 已设置在 %2$@ 之后自动删除消息"; +"Conversation.AutoremoveTimerSetUserGlobal" = "%1$@ 为所有聊天启用了自毁计时器。\n该聊天中的所有新消息将会在发送 %2$@ 后自动删除。"; +"Conversation.AutoremoveTimerSetUserGlobalYou" = "您已为所有聊天已设置自毁计时器。\n此聊天中的所有新消息在发送 %1$@ 之后将自动删除。"; +"Conversation.AutoremoveTimerSetUserYou" = "您已设置消息在 %1$@ 后自动删除"; +"Conversation.BlockUser" = "屏蔽此用户"; +"Conversation.BoostChannel" = "助推"; +"Conversation.BoostGroup" = "助推"; +"Conversation.BoostToUnrestrictText" = "助推此群组以发送消息"; +"Conversation.BotApp" = "机器人应用"; +"Conversation.BotInteractiveUrlAlert" = "是否允许 %@ 传送您的 Telegram 昵称及 ID(不是您的手机号码)到通过机器人打开的网页中呢?"; +"Conversation.Bytes" = "%d B"; +"Conversation.CalendarSearch.Done" = "完成"; +"Conversation.CalendarSearch.Title" = "搜索"; +"Conversation.Call" = "呼叫"; +"Conversation.CancelForwardCancelForward" = "取消转发"; +"Conversation.CancelForwardSelectChat" = "选择其他聊天"; +"Conversation.CancelForwardText" = "您想取消转发还是把消息转发到其他聊天?"; +"Conversation.CancelForwardTitle" = "取消转发"; +"Conversation.CantPhoneCallAnonymousNumberError" = "您无法拨打这个电话号码。"; +"Conversation.CardNumberCopied" = "卡号已复制至剪贴板。"; +"Conversation.ChatBackground" = "聊天壁纸"; +"Conversation.ChecksTooltip.Delivered" = "送达"; +"Conversation.ChecksTooltip.Read" = "已读"; +"Conversation.ClearAll" = "清空聊天"; +"Conversation.ClearCache" = "清除缓存"; +"Conversation.ClearChannel" = "清空频道"; +"Conversation.ClearChatConfirmation" = "警告,这将删除您和 %@ 的**全部聊天记录**。"; +"Conversation.ClearGroupHistory" = "此操作将会从您的 Telegram 云端中删除此聊天中的所有消息和媒体,且不会从其他群成员的记录中删除。"; +"Conversation.ClearPrivateHistory" = "此操作将会从您的 Telegram 云端中删除此聊天中的所有消息和媒体,且不会从对方的记录中删除。"; +"Conversation.ClearSecretHistory" = "此操作将会从对话双方的记录里,删除此对话中的所有消息和媒体。"; +"Conversation.ClearSelfHistory" = "此操作将从您的 Telegram 云端中,删除此聊天中的所有的消息和媒体。"; +"Conversation.CloudStorage.ChatStatus" = "与你自己聊天"; +"Conversation.CloudStorageInfo.Title" = "您的云存储"; +"Conversation.ClousStorageInfo.Description1" = "• 转发消息到此以保存它们"; +"Conversation.ClousStorageInfo.Description2" = "• 保存发送至此的媒体和文件"; +"Conversation.ClousStorageInfo.Description3" = "• 在任意设备上访问此聊天"; +"Conversation.ClousStorageInfo.Description4" = "• 使用搜索以更快地查找"; +"Conversation.CodeCopied" = "代码已拷贝到剪贴板"; +"Conversation.Contact" = "联系人信息"; +"Conversation.ContactAddContact" = "添加"; +"Conversation.ContactAddContactLong" = "添加联系人"; +"Conversation.ContactMessage" = "消息"; +"Conversation.ContextMenuAddFactCheck" = "添加事实核查"; +"Conversation.ContextMenuBan" = "限制"; +"Conversation.ContextMenuBanFull" = "封禁"; +"Conversation.ContextMenuBlock" = "屏蔽用户"; +"Conversation.ContextMenuBuyGift" = "给自己买一份礼物"; +"Conversation.ContextMenuCancelEditing" = "取消编辑"; +"Conversation.ContextMenuCancelSending" = "取消发送"; +"Conversation.ContextMenuCopy" = "拷贝"; +"Conversation.ContextMenuCopyEmail" = "复制电子邮箱地址"; +"Conversation.ContextMenuCopyLink" = "拷贝链接"; +"Conversation.ContextMenuDelete" = "删除"; +"Conversation.ContextMenuDiscuss" = "讨论"; +"Conversation.ContextMenuEditFactCheck" = "编辑事实查核"; +"Conversation.ContextMenuForward" = "转发"; +"Conversation.ContextMenuListened#other" = "%@ 已收听"; +"Conversation.ContextMenuLookUp" = "查找"; +"Conversation.ContextMenuMention" = "提及"; +"Conversation.ContextMenuMore" = "更多"; +"Conversation.ContextMenuNoViews" = "无人已读"; +"Conversation.ContextMenuNobodyListened" = "无人收听"; +"Conversation.ContextMenuNobodyWatched" = "无人已播放"; +"Conversation.ContextMenuOpenChannel" = "打开频道"; +"Conversation.ContextMenuOpenChannelProfile" = "打开个人资料"; +"Conversation.ContextMenuOpenInfo" = "打开信息"; +"Conversation.ContextMenuOpenProfile" = "打开个人资料"; +"Conversation.ContextMenuQuote" = "引用"; +"Conversation.ContextMenuReply" = "回复"; +"Conversation.ContextMenuReport" = "举报"; +"Conversation.ContextMenuReportFalsePositive" = "申诉误报"; +"Conversation.ContextMenuSearchMessages" = "搜索消息"; +"Conversation.ContextMenuSeen#other" = "%@ 人已读"; +"Conversation.ContextMenuSelect" = "选择"; +"Conversation.ContextMenuSelectAll#other" = "选择 %@ 项"; +"Conversation.ContextMenuSendAnotherGift" = "再送一份礼物"; +"Conversation.ContextMenuSendGiftTo" = "给 %@ 送礼物"; +"Conversation.ContextMenuSendMessage" = "发送消息"; +"Conversation.ContextMenuShare" = "分享"; +"Conversation.ContextMenuSpeak" = "朗读"; +"Conversation.ContextMenuStickerPackAdd" = "添加表情包"; +"Conversation.ContextMenuStickerPackInfo" = "信息"; +"Conversation.ContextMenuTranslate" = "翻译"; +"Conversation.ContextMenuWatched#other" = "%@ 人已收听"; +"Conversation.ContextViewReplies#other" = "查看 %@ 条回复"; +"Conversation.ContextViewStats" = "查看统计数据"; +"Conversation.ContextViewThread" = "查看跟评"; +"Conversation.CopyProtectionForwardingDisabledBot" = "从此聊天转发受到限制"; +"Conversation.CopyProtectionForwardingDisabledChannel" = "从该频道转发消息已受限"; +"Conversation.CopyProtectionForwardingDisabledGroup" = "从该群组转发消息已受限"; +"Conversation.CopyProtectionForwardingDisabledSecret" = "不可转发消息"; +"Conversation.CopyProtectionInfoChannel" = "该频道不允许拷贝和转发。"; +"Conversation.CopyProtectionInfoGroup" = "该群组不允许拷贝和转发。"; +"Conversation.CopyProtectionInfoPrivate" = "**%@** 已禁用此聊天中的复制和转发功能。"; +"Conversation.CopyProtectionInfoPrivateYou" = "您已禁用此聊天中的复制和转发功能。"; +"Conversation.CopyProtectionSavingDisabledBot" = "不可保存来自此机器人的内容"; +"Conversation.CopyProtectionSavingDisabledChannel" = "保存该频道的内容已被限制"; +"Conversation.CopyProtectionSavingDisabledGroup" = "保存该群组内容已被限制"; +"Conversation.CopyProtectionSavingDisabledSecret" = "不可保存消息"; +"Conversation.DateCopied" = "日期已复制到剪贴板"; +"Conversation.DateReminderSet" = "您已在 [我的收藏]() 中设置了提醒。"; +"Conversation.DefaultRestrictedInline" = "此群组不允许发送机器人内容。"; +"Conversation.DefaultRestrictedMedia" = "该群组不允许发送媒体文件。"; +"Conversation.DefaultRestrictedStickers" = "此群组不允许发送贴纸。"; +"Conversation.DefaultRestrictedText" = "此群组不允许发送消息。"; +"Conversation.DeleteAllMessagesInChat" = "您确定要删除 %@ 的所有消息吗?"; +"Conversation.DeleteManyMessages" = "删除消息"; +"Conversation.DeleteMessagesFor" = "为我和“%@\"删除"; +"Conversation.DeleteMessagesForEveryone" = "为所有人删除"; +"Conversation.DeleteMessagesForMe" = "仅为我删除"; +"Conversation.DeleteTimer.Apply" = "设定自动删除"; +"Conversation.DeleteTimer.Disable" = "停用自动删除"; +"Conversation.DeleteTimer.SetupTitle" = "后续自动删除…"; +"Conversation.DeletedFromContacts" = "已从联系人中删除 **%@** "; +"Conversation.Dice" = "发送 🎲 表情至任意聊天,Telegram 将为您随机生成点数。"; +"Conversation.Dice." = "在任何聊天中发送骰子表情即可掷骰子。"; +"Conversation.Dice.Change" = "更改"; +"Conversation.Dice.Stake" = "投注:"; +"Conversation.Dice.u1F3AF" = "发送飞镖表情试试运气。"; +"Conversation.Dice.u1F3B0" = "发送老虎机表情试试运气。"; +"Conversation.Dice.u1F3B2" = "在任何聊天中发送骰子表情即可掷骰子。"; +"Conversation.Dice.u1F3B3" = "发送保龄球表情试试运气。"; +"Conversation.Dice.u1F3C0" = "发送篮球表情试试运气。"; +"Conversation.Dice.u26BD" = "发送足球表情试试运气。"; +"Conversation.DiscardRecordedVoiceMessageAction" = "放弃"; +"Conversation.DiscardRecordedVoiceMessageDescription" = "您确定要放弃录制语音消息吗?"; +"Conversation.DiscardVoiceMessageAction" = "放弃"; +"Conversation.DiscardVoiceMessageDescription" = "您确定要停止并放弃录音吗?"; +"Conversation.DiscardVoiceMessageTitle" = "放弃语音消息"; +"Conversation.DiscussionNotStarted" = "还没有任何评论..."; +"Conversation.DiscussionStarted" = "讨论已开始"; +"Conversation.Edit" = "编辑"; +"Conversation.EditingCaptionPanelTitle" = "编辑说明"; +"Conversation.EditingMessageMediaChange" = "更换为其它图片或视频"; +"Conversation.EditingMessageMediaEditCurrentPhoto" = "编辑当前照片"; +"Conversation.EditingMessageMediaEditCurrentVideo" = "编辑当前视频"; +"Conversation.EditingMessagePanelMedia" = "点击以编辑媒体文件"; +"Conversation.EditingMessagePanelTitle" = "编辑消息"; +"Conversation.EditingPhotoPanelTitle" = "编辑照片"; +"Conversation.EditingQuickReplyPanelTitle" = "编辑快速回复"; +"Conversation.EmailCopied" = "邮箱已拷贝至剪贴板。"; +"Conversation.EmojiCopied" = "表情已拷贝至剪贴板"; +"Conversation.EmojiStake.Lost" = "%1$@ 已失去 $%2$@"; +"Conversation.EmojiStake.LostYou" = "您已失去 $%@"; +"Conversation.EmojiStake.Won" = "%1$@ 已赢得 $%2$@"; +"Conversation.EmojiStake.WonYou" = "您已赢得 $%@"; +"Conversation.EmojiTooltip" = "点击这里选择更多表情"; +"Conversation.EmptyGifPanelPlaceholder" = "您还没有保存过的 GIF 动态图。\n输入 @gif 搜索一些吧。"; +"Conversation.EmptyPlaceholder" = "暂无消息…"; +"Conversation.EncryptedDescription1" = "使用端到端加密"; +"Conversation.EncryptedDescription2" = "不会在我们的服务器上留下任何痕迹"; +"Conversation.EncryptedDescription3" = "支持阅后即焚"; +"Conversation.EncryptedDescription4" = "不允许转发消息"; +"Conversation.EncryptedDescriptionTitle" = "私密聊天:"; +"Conversation.EncryptedPlaceholderTitleIncoming" = "%@ 邀请您加入私密聊天。"; +"Conversation.EncryptedPlaceholderTitleOutgoing" = "您已邀请 %@ 加入私密聊天。"; +"Conversation.EncryptionCanceled" = "私密聊天已取消"; +"Conversation.EncryptionProcessing" = "交换加密密钥中…"; +"Conversation.EncryptionWaiting" = "等待 %@ 上线…"; +"Conversation.ErrorInaccessibleMessage" = "很遗憾,您无法打开此消息。 您不是消息所在聊天的成员。"; +"Conversation.FactCheck.Description" = "此澄清是由 Telegram 提供的。"; +"Conversation.FactCheck.InnerDescription" = "此澄清是由 Telegram 提供的。"; +"Conversation.FileDropbox" = "Dropbox"; +"Conversation.FileHowToText" = "如需分享其它类型的文件,在您的 %@(例如浏览器)中打开它们,点击 **通过其它应用打开** 或在分享按钮中选择使用 Telegram 打开。"; +"Conversation.FileICloudDrive" = "iCloud Drive"; +"Conversation.FileOpenIn" = "使用其它应用打开…"; +"Conversation.FilePhotoOrVideo" = "相机胶卷"; +"Conversation.FormatDate.AddDate" = "添加日期"; +"Conversation.FormatDate.EditDate" = "编辑日期"; +"Conversation.FormatDate.RemoveDate" = "移除日期"; +"Conversation.FormatDate.Title" = "日期"; +"Conversation.ForwardAuthorHiddenTooltip" = "用户主动隐藏该账户"; +"Conversation.ForwardChats" = "聊天"; +"Conversation.ForwardContacts" = "联系人"; +"Conversation.ForwardFrom" = "来自:%@"; +"Conversation.ForwardOptions.CancelForwarding" = "取消转发"; +"Conversation.ForwardOptions.ChangeRecipient" = "修改收件人"; +"Conversation.ForwardOptions.ChannelMessageForwardHidden" = "接收者将不会看到此条为转发消息"; +"Conversation.ForwardOptions.ChannelMessageForwardVisible" = "接收者将看到此条为转发消息"; +"Conversation.ForwardOptions.ChannelMessagesForwardHidden" = "接收者将不会看到此条为转发消息"; +"Conversation.ForwardOptions.ChannelMessagesForwardVisible" = "接收者将看到此条为转发消息"; +"Conversation.ForwardOptions.ForwardTitle#other" = "转发 %@ 条消息"; +"Conversation.ForwardOptions.ForwardTitleSingle" = "转发消息"; +"Conversation.ForwardOptions.GroupMessageForwardHidden" = "收件人不会看到它已被转发"; +"Conversation.ForwardOptions.GroupMessageForwardVisible" = "收件人将看到它已被转发"; +"Conversation.ForwardOptions.GroupMessagesForwardHidden" = "收件人不会看到他们已被转发"; +"Conversation.ForwardOptions.GroupMessagesForwardVisible" = "收件人将看到他们已被转发"; +"Conversation.ForwardOptions.HideCaption" = "隐藏说明"; +"Conversation.ForwardOptions.HideSendersName" = "隐藏发送者姓名"; +"Conversation.ForwardOptions.HideSendersNames" = "隐藏发送者名称"; +"Conversation.ForwardOptions.Messages#other" = "%@ 条消息"; +"Conversation.ForwardOptions.RecipientsMessageForwardHidden" = "接收者将不会看到此条为转发消息"; +"Conversation.ForwardOptions.RecipientsMessageForwardVisible" = "接收者将会看到此条为转发消息"; +"Conversation.ForwardOptions.RecipientsMessagesForwardHidden" = "接收者将不会看到此条为转发消息"; +"Conversation.ForwardOptions.RecipientsMessagesForwardVisible" = "接收者将不会看到此条为转发消息"; +"Conversation.ForwardOptions.SendMessage" = "发送消息"; +"Conversation.ForwardOptions.SendMessages" = "发送消息"; +"Conversation.ForwardOptions.SenderNamesRemoved" = "已移除发送者名称"; +"Conversation.ForwardOptions.ShowCaption" = "显示说明"; +"Conversation.ForwardOptions.ShowOptions" = "显示转发选项"; +"Conversation.ForwardOptions.ShowSendersName" = "显示发送者名称"; +"Conversation.ForwardOptions.ShowSendersNames" = "显示发送者名称"; +"Conversation.ForwardOptions.TapForOptions" = "点击此处进行转发选项"; +"Conversation.ForwardOptions.TapForOptionsShort" = "点击此获得更多选项"; +"Conversation.ForwardOptions.Text" = "您将如何处理来自%2$@的%1$@?"; +"Conversation.ForwardOptions.TextPersonal" = "您想对您与 %2$@ 聊天中的 %1$@ 做什么?"; +"Conversation.ForwardOptions.TextSaved" = "您想如何处理“我的收藏”中的 %1$@ 条消息?"; +"Conversation.ForwardOptions.Title#other" = "%@ 条消息"; +"Conversation.ForwardOptions.UserMessageForwardHidden" = "%@不会看到它已被转发"; +"Conversation.ForwardOptions.UserMessageForwardVisible" = "%@ 会看到选中的消息已被转发"; +"Conversation.ForwardOptions.UserMessagesForwardHidden" = "%@ 看不到选中的消息已被转发"; +"Conversation.ForwardOptions.UserMessagesForwardVisible" = "%@ 会看到选中的消息已被转发"; +"Conversation.ForwardOptionsCancel" = "取消转发"; +"Conversation.ForwardTitle" = "转发"; +"Conversation.ForwardTooltip.Chat.Many" = "消息已转发至 **%@**。"; +"Conversation.ForwardTooltip.Chat.One" = "已将消息转发给 **%@**。"; +"Conversation.ForwardTooltip.ManyChats.Many" = "消息已发送至 **%@** 及其他 %@ 人。"; +"Conversation.ForwardTooltip.ManyChats.One" = "消息已发送至 %@ 及其他 %@ 个聊天。"; +"Conversation.ForwardTooltip.SavedMessages.Many" = "消息已转发至 **我的收藏** 。"; +"Conversation.ForwardTooltip.SavedMessages.One" = "消息转发至 **我的收藏** 。"; +"Conversation.ForwardTooltip.TwoChats.Many" = "消息已转发给 **%@** 和 **%@**。"; +"Conversation.ForwardTooltip.TwoChats.One" = "消息已转发给 **%@** 与 **%@**。"; +"Conversation.FreeTranscriptionCooldownTooltip#other" = "您本周的 **%@** 次免费语音转录额度已用尽。"; +"Conversation.FreeTranscriptionLimitTooltip#other" = "您本周还剩余 **%@** 次免费的语音转录。"; +"Conversation.FreeTranscriptionWaitOrSubscribe" = "请等待至 %@ 或者现在订阅 [Telegram 高级版]()。"; +"Conversation.GifTooltip" = "点击这里访问已保存的 GIF 动态图"; +"Conversation.GigagroupDescription" = "只有管理员才能在此群组中发送消息。"; +"Conversation.GreetingText" = "发送信息或点击下方的问候语。"; +"Conversation.HashtagCopied" = "标签已拷贝至剪贴板。"; +"Conversation.HoldForAudio" = "长按以录制语音消息,点按切换为视频消息。"; +"Conversation.HoldForAudioOnly" = "长按以录制音频。"; +"Conversation.HoldForVideo" = "长按以录制视频消息,点按切换为语音消息。"; +"Conversation.HoldForVideoOnly" = "长按以录制视频。"; +"Conversation.ImageCopied" = "图片已拷贝至剪贴板。"; +"Conversation.ImportProgress" = "正在导入消息... %@ %"; +"Conversation.ImportedMessageHint" = "此信息由其他软件导入,无法保证其真实性。"; +"Conversation.IncreaseSpeed" = "增加速度"; +"Conversation.Info" = "详细信息"; +"Conversation.InfoGroup" = "群组"; +"Conversation.InputMenu" = "菜单"; +"Conversation.InputTextAnonymousPlaceholder" = "匿名发送"; +"Conversation.InputTextBroadcastPlaceholder" = "广播"; +"Conversation.InputTextCaptionPlaceholder" = "说明"; +"Conversation.InputTextPlaceholder" = "输入消息"; +"Conversation.InputTextPlaceholderComment" = "评论"; +"Conversation.InputTextPlaceholderReply" = "回复"; +"Conversation.InputTextSilentBroadcastPlaceholder" = "静默广播"; +"Conversation.InstantPagePreview" = "即时预览"; +"Conversation.InteractiveEmojiSyncTip" = "如果 %@ 正在查看此聊天,其也将看到此动画效果。"; +"Conversation.InviteRequestAdminChannel" = "**%1$@* 是您请求加入频道 **%2$@** 的管理员。"; +"Conversation.InviteRequestAdminGroup" = "%1$@ 是您曾申请加入的 %2$@ 群组管理员。"; +"Conversation.InviteRequestInfo" = "您收到这条消息是因为您曾在 %2$@ 申请加入 %1$@。"; +"Conversation.InviteRequestInfoConfirm" = "我已知晓"; +"Conversation.JoinVoiceChat" = "加入视频聊天"; +"Conversation.JoinVoiceChatAsListener" = "作为听众加入"; +"Conversation.JoinVoiceChatAsSpeaker" = "作为发言人加入"; +"Conversation.JumpToDate" = "跳转到此日期"; +"Conversation.Kilobytes" = "%d KB"; +"Conversation.LargeEmojiDisabledInfo" = "您已停用大号表情,因此表情看起来很小并且没有特效。"; +"Conversation.LargeEmojiEnable" = "启用大号表情"; +"Conversation.LargeEmojiEnabled" = "已启用大号表情。"; +"Conversation.LaunchApp" = "打开应用"; +"Conversation.LinkCopied" = "链接已拷贝到剪贴板。"; +"Conversation.LinkDialogCopy" = "拷贝"; +"Conversation.LinkDialogOpen" = "打开"; +"Conversation.LinkDialogSave" = "保存"; +"Conversation.LinkOptionsCancel" = "关闭预览"; +"Conversation.LinksCopied" = "链接已复制到剪贴板"; +"Conversation.LiveLocation" = "实时位置"; +"Conversation.LiveLocationMembersCount#other" = "和其他 %@ 人"; +"Conversation.LiveLocationYou" = "您"; +"Conversation.LiveLocationYouAnd" = "*您*和 %@"; +"Conversation.LiveLocationYouAndOther" = "**你** 与 %@"; +"Conversation.LiveStream" = "直播"; +"Conversation.LiveStreamMediaRecordingRestricted" = "您无法在直播时录制语音及视频消息。"; +"Conversation.Location" = "位置"; +"Conversation.Megabytes" = "%.1f MB"; +"Conversation.MessageCopied" = "消息已拷贝到剪贴板。"; +"Conversation.MessageDeliveryFailed" = "您的消息未发送成功,点击“重新发送”可再次发送。"; +"Conversation.MessageDialogDelete" = "删除"; +"Conversation.MessageDialogEdit" = "编辑"; +"Conversation.MessageDialogRetry" = "重新发送"; +"Conversation.MessageDialogRetryAll" = "重新发送 %1$d 条消息"; +"Conversation.MessageDoesntExist" = "消息不存在"; +"Conversation.MessageEditedLabel" = "已编辑"; +"Conversation.MessageGuestChatForUser" = "至 %@"; +"Conversation.MessageLeaveComment" = "留言"; +"Conversation.MessageLeaveCommentShort" = "评论"; +"Conversation.MessageOptionsApplyChanges" = "应用更改"; +"Conversation.MessageOptionsEnlargeImage" = "放大图片"; +"Conversation.MessageOptionsEnlargeVideo" = "放大视频"; +"Conversation.MessageOptionsLinkMoveDown" = "下移"; +"Conversation.MessageOptionsLinkMoveUp" = "上移"; +"Conversation.MessageOptionsQuoteRemove" = "移除引文"; +"Conversation.MessageOptionsQuoteSelect" = "选择特定引文片段"; +"Conversation.MessageOptionsQuoteSelectedPart" = "引用所选部分"; +"Conversation.MessageOptionsReplyCancel" = "取消回复"; +"Conversation.MessageOptionsReplyInAnotherChat" = "在另一个聊天中回复"; +"Conversation.MessageOptionsShrinkImage" = "缩小图片"; +"Conversation.MessageOptionsShrinkVideo" = "缩小视频"; +"Conversation.MessageOptionsTabForward" = "转发"; +"Conversation.MessageOptionsTabLink" = "链接"; +"Conversation.MessageOptionsTabReply" = "回复"; +"Conversation.MessageViaUser" = "通过 %@"; +"Conversation.MessageViewComments#other" = "[%@] 条评论"; +"Conversation.MessageViewCommentsFormat" = "%1$@ %2$@"; +"Conversation.Messages#other" = "%@ 条消息"; +"Conversation.Moderate.Ban" = "封禁用户"; +"Conversation.Moderate.Delete" = "删除消息"; +"Conversation.Moderate.DeleteAllMessages" = "删除 %@ 发送的所有消息"; +"Conversation.Moderate.Report" = "举报垃圾信息"; +"Conversation.MoveReplyToAnotherChatTitle" = "回复给…"; +"Conversation.Mute" = "静音"; +"Conversation.Mute.ApplyMuteUntil" = "静音并持续到 %@"; +"Conversation.Mute.SetupTitle" = "开启静音直到..."; +"Conversation.MyNotes" = "我的笔记"; +"Conversation.NoticeInvitedByInChannel" = "%@ 已邀请您加入此频道"; +"Conversation.NoticeInvitedByInGroup" = "%@ 已邀请您加入此群组"; +"Conversation.OpenBotApp" = "打开应用"; +"Conversation.OpenBotLinkAllowMessages" = "允许 **%@** 向我发消息"; +"Conversation.OpenBotLinkLogin" = "以 %2$@ 身份登录 %1$@"; +"Conversation.OpenBotLinkOpen" = "打开"; +"Conversation.OpenBotLinkText" = "你想打开以下链接吗?\n**%@**"; +"Conversation.OpenBotLinkTitle" = "打开链接"; +"Conversation.OpenChatFolder" = "查看聊天列表"; +"Conversation.OpenFile" = "打开文件"; +"Conversation.OpenLink" = "打开链接"; +"Conversation.OpenProfile" = "打开个人资料页"; +"Conversation.Owner" = "所有者"; +"Conversation.PeerNearbyDistance" = "%1$@ 距离 %2$@"; +"Conversation.PeerNearbyText" = "发送消息或点击下面的问候语表明您已准备好聊天。"; +"Conversation.PeerNearbyTitle" = "%1$@ 距离 %2$@"; +"Conversation.PhoneCopied" = "手机号码已拷贝至剪贴板。"; +"Conversation.Pin" = "置顶"; +"Conversation.PinMessageAlert.OnlyPin" = "仅置顶"; +"Conversation.PinMessageAlert.PinAndNotifyMembers" = "置顶并提醒所有人"; +"Conversation.PinMessageAlertGroup" = "是否置顶此消息并通知所有群组成员?"; +"Conversation.PinMessageAlertPin" = "置顶"; +"Conversation.PinMessagesFor" = "为我和 %@ 置顶"; +"Conversation.PinMessagesForMe" = "仅为我置顶"; +"Conversation.PinOlderMessageAlertText" = "您想置顶一条较旧的消息,并同时保留一则最新的消息吗?"; +"Conversation.PinOlderMessageAlertTitle" = "置顶消息"; +"Conversation.PinnedGiveaway" = "抽奖活动"; +"Conversation.PinnedGiveaway.Finished" = "%2$@ %1$@"; +"Conversation.PinnedGiveaway.Finished.Winners#other" = "已选定 %@ 名获奖者"; +"Conversation.PinnedGiveaway.Ongoing" = "%2$@ %1$@"; +"Conversation.PinnedGiveaway.Ongoing.Winners#other" = "将选定 %@ 名获奖者"; +"Conversation.PinnedMessage" = "置顶消息"; +"Conversation.PinnedPoll" = "置顶投票"; +"Conversation.PinnedPreviousMessage" = "之前置顶的消息"; +"Conversation.PinnedQuiz" = "已置顶问答"; +"Conversation.PremiumUploadFileTooLarge" = "无法发送文件,因为它大于 4 GB。\n\n您可以发送任意数量的文件,但每个文件必须小于 4 GB。"; +"Conversation.PressVolumeButtonForSound" = "按音量键将视频静音"; +"Conversation.PrivateChannelTimeLimitedAlertJoin" = "加入"; +"Conversation.PrivateChannelTimeLimitedAlertText" = "该频道为私人频道。请加入后继续浏览。"; +"Conversation.PrivateChannelTimeLimitedAlertTitle" = "加入频道"; +"Conversation.PrivateChannelTooltip" = "这是私密频道"; +"Conversation.PrivateMessageLinkCopied" = "此链接只对该聊天的成员有效"; +"Conversation.PrivateMessageLinkCopiedLong" = "链接已拷贝至剪贴板。此链接只对本聊天的成员有效。"; +"Conversation.Processing" = "处理中…"; +"Conversation.ReadAllPollVotes" = "查看所有投票"; +"Conversation.ReadAllReactions" = "将所有表情回应标记为已读"; +"Conversation.ReadMore" = "阅读更多"; +"Conversation.ReplyMessagePanelTitle" = "回复 %@"; +"Conversation.Report" = "举报垃圾信息"; +"Conversation.ReportGroupLocation" = "群组与位置不相关?"; +"Conversation.ReportMessages" = "举报消息"; +"Conversation.ReportSpam" = "举报垃圾信息"; +"Conversation.ReportSpamAndLeave" = "举报垃圾信息并退出"; +"Conversation.ReportSpamChannelConfirmation" = "您确定要举报此频道发布垃圾信息吗?"; +"Conversation.ReportSpamConfirmation" = "您要举报此用户发布垃圾信息吗?"; +"Conversation.ReportSpamGroupConfirmation" = "您确定要举报此群组发送垃圾信息吗?"; +"Conversation.RequestToJoinChannel" = "请求加入"; +"Conversation.RequestToJoinGroup" = "请求加入"; +"Conversation.RequestsToJoin#other" = "%@ 个加入请求"; +"Conversation.RestrictedInline" = "此群组的管理员已限制您发送机器人内容。"; +"Conversation.RestrictedInlineTimed" = "此群组的管理员已限制您在 %@ 之前发送机器人内容。"; +"Conversation.RestrictedMedia" = "此群组的管理员已限制您发送媒体文件。"; +"Conversation.RestrictedMediaTimed" = "该群组的管理员限制了您在 %@ 之前发送媒体文件。"; +"Conversation.RestrictedStickers" = "此群组的管理员已限制您发送贴纸。"; +"Conversation.RestrictedStickersTimed" = "该群组的管理员限制了您在 %@ 之前发送贴纸。"; +"Conversation.RestrictedText" = "此群组的管理员已限制您发送消息。"; +"Conversation.RestrictedTextTimed" = "该群组的管理员限制了您在 %@ 之前发送消息。"; +"Conversation.SaveGif" = "添加到 GIF"; +"Conversation.SaveToFiles" = "保存至文件"; +"Conversation.SavedMessages" = "我的收藏"; +"Conversation.ScamWarning" = "⚠️警告:这个账号被大量用户举报欺诈。请小心,特别是当他向你索要钱财时。"; +"Conversation.ScheduleMessage.SendOn" = "在 %@ %@ 发送"; +"Conversation.ScheduleMessage.SendToday" = "今天 %@ 发送"; +"Conversation.ScheduleMessage.SendTomorrow" = "明天 %@ 发送"; +"Conversation.ScheduleMessage.SendWhenOnline" = "在线后发送"; +"Conversation.ScheduleMessage.Title" = "定时消息"; +"Conversation.ScheduledLiveStream" = "已预约的直播"; +"Conversation.ScheduledLiveStreamStartsOn" = "直播将于 %@ 开始"; +"Conversation.ScheduledLiveStreamStartsToday" = "直播将于今天 %@ 开始"; +"Conversation.ScheduledLiveStreamStartsTomorrow" = "直播将于明天 %@ 开始"; +"Conversation.ScheduledVoiceChat" = "已预约视频聊天"; +"Conversation.ScheduledVoiceChatStartsOn" = "视频聊天将于 %@ 开始"; +"Conversation.ScheduledVoiceChatStartsOnShort" = "%@ 开始"; +"Conversation.ScheduledVoiceChatStartsToday" = "视频聊天将在今天 %@ 开始"; +"Conversation.ScheduledVoiceChatStartsTodayShort" = "今天 %@ 开始"; +"Conversation.ScheduledVoiceChatStartsTomorrow" = "视频聊天将在明天 %@ 开始"; +"Conversation.ScheduledVoiceChatStartsTomorrowShort" = "明天 %@ 开始"; +"Conversation.Search" = "搜索"; +"Conversation.SearchByName.Placeholder" = "搜索成员"; +"Conversation.SearchByName.Prefix" = "来自:"; +"Conversation.SearchNoResults" = "未找到相关信息"; +"Conversation.SearchPlaceholder" = "在此聊天中搜索"; +"Conversation.SecretChatContextBotAlert" = "请注意:消息框内机器人是由第三方开发者提供的。为了使机器人工作,您在 @ 这些机器人(后需接空格)之后输入的消息将会传送给相关的开发者。"; +"Conversation.SecretLinkPreviewAlert" = "您要启用私密聊天中的链接预览功能吗?注意,链接预览将在 Telegram 的服务器上生成。"; +"Conversation.SelectMessages" = "选择消息"; +"Conversation.SelectedMessages#other" = "已选中 %@ 条消息"; +"Conversation.SelectedMessagesFormat#other" = "{} 已选取"; +"Conversation.SendDice" = "发送"; +"Conversation.SendMesageAs" = "以哪个身份发送消息…"; +"Conversation.SendMesageAsPremiumInfo" = "订阅** Telegram 高级版**后可以在任何群聊中代表您的频道发布评论。"; +"Conversation.SendMessage" = "发送消息"; +"Conversation.SendMessage.ScheduleMessage" = "定时消息"; +"Conversation.SendMessage.SendSilently" = "无声发送"; +"Conversation.SendMessage.SendWhenOnline" = "在线时发送"; +"Conversation.SendMessage.SetReminder" = "设置提醒"; +"Conversation.SendMessageErrorFlood" = "抱歉,目前您只能给双向联系人发送消息。"; +"Conversation.SendMessageErrorGroupRestricted" = "抱歉,您目前无法在此群组中发布消息。"; +"Conversation.SendMessageErrorNonPremiumForbidden" = "%@ 只允许高级版用户发送消息。"; +"Conversation.SendMessageErrorTooFast" = "您发信息的速度太快了,请稍等一下。"; +"Conversation.SendMessageErrorTooFastTitle" = "你发送消息的速度太快了"; +"Conversation.SendMessageErrorTooMuchScheduled" = "抱歉,您不能设置超过 100 条定时消息。"; +"Conversation.SendWhenOnlineTooltip" = "长按以稍后发送消息。"; +"Conversation.SendingOptionsTooltip" = "长按定时发送或者静音发送。"; +"Conversation.SetReminder.RemindOn" = "在 %@ %@ 提醒我"; +"Conversation.SetReminder.RemindToday" = "今天 %@ 提醒我"; +"Conversation.SetReminder.RemindTomorrow" = "明天 %@ 提醒我"; +"Conversation.SetReminder.Title" = "新建提醒"; +"Conversation.ShareBotContactConfirmation" = "此机器人将会获取您的手机号码,这可以用于集成其他服务。\n\n⚠️ 警告!不要在小程序中输入你的 Telegram 登录验证码。"; +"Conversation.ShareBotContactConfirmationTitle" = "是否分享您的手机号码?"; +"Conversation.ShareBotLocationConfirmation" = "此操作会把您当前的位置信息发送给机器人。"; +"Conversation.ShareBotLocationConfirmationTitle" = "是否分享您的位置信息?"; +"Conversation.ShareInlineBotLocationConfirmation" = "机器人需要在您每次向其发送请求时获取您的位置信息,以便提供基于位置的查询结果。"; +"Conversation.ShareLinkTooltip.Chat.One" = "已与 **%@** 分享链接"; +"Conversation.ShareLinkTooltip.ManyChats.One" = "已将链接分享给 **%@** 以及另外 %@ 人。"; +"Conversation.ShareLinkTooltip.TwoChats.One" = "已与 %@、%@ 共享链接。"; +"Conversation.ShareMyContactInfo" = "分享我的联系信息"; +"Conversation.ShareMyPhoneNumber" = "分享我的手机号码"; +"Conversation.ShareMyPhoneNumber.StatusSuccess" = "%@ 可以看到您的手机号码。"; +"Conversation.ShareMyPhoneNumberConfirmation" = "您确定要将您的手机号码(%1$@)分享给 **%2$@** 吗?"; +"Conversation.SilentBroadcastTooltipOff" = "消息发布后订阅者会收到无声通知。"; +"Conversation.SilentBroadcastTooltipOn" = "消息发布后订阅者会收到通知。"; +"Conversation.SlideToCancel" = "滑动以取消"; +"Conversation.StatusBotSubscribers#other" = "%d 名每月活跃用户"; +"Conversation.StatusKickedFromChannel" = "您已被移出此频道"; +"Conversation.StatusKickedFromGroup" = "您已被移出此群组"; +"Conversation.StatusLeftGroup" = "您已退出此群组"; +"Conversation.StatusMembers#other" = "%@ 位成员"; +"Conversation.StatusOnline#other" = "%@ 人在线"; +"Conversation.StatusSubscribers#other" = "%@ 位订阅者"; +"Conversation.StatusTyping" = "正在输入"; +"Conversation.StickerAddedToFavorites" = "此贴纸已被添加到收藏。"; +"Conversation.StickerRemovedFromFavorites" = "已从收藏中删除贴纸。"; +"Conversation.StickerRemovedFromRecent" = "表情已从最近使用过的清单中移除"; +"Conversation.StopLiveLocation" = "停止共享"; +"Conversation.StopPoll" = "停止投票"; +"Conversation.StopPollConfirmation" = "结束投票"; +"Conversation.StopPollConfirmationTitle" = "如果您现在停止这个投票,任何人都不能再进行投票了。 此操作无法撤消。"; +"Conversation.StopQuiz" = "结束问答"; +"Conversation.StopQuizConfirmation" = "结束问答"; +"Conversation.StopQuizConfirmationTitle" = "如果您现在结束此问答,将不再有人能够作答。该操作无法撤销。"; +"Conversation.StopVoiceMessageDescription" = "您确定要暂停录音吗?"; +"Conversation.StopVoiceMessageDiscardAction" = "放弃"; +"Conversation.StopVoiceMessagePauseAction" = "暂停"; +"Conversation.StopVoiceMessageTitle" = "录音中"; +"Conversation.StoryExpiredMentionTextIncoming" = "提到您的动态\n已无法访问"; +"Conversation.StoryExpiredMentionTextOutgoing" = "您提到 %@ 的动态\n已无法访问"; +"Conversation.StoryForwardTooltip.Chat.One" = "动态已转发至 **%@**"; +"Conversation.StoryForwardTooltip.ManyChats.One" = "动态已转发至 **%@** 和其他 %@ 个聊天"; +"Conversation.StoryForwardTooltip.SavedMessages.One" = "已将动态转发到**我的收藏**"; +"Conversation.StoryForwardTooltip.TwoChats.One" = "动态已转发至 **%@** 和 **%@**"; +"Conversation.StoryMentionTextIncoming" = "%@ 在动态中\n提到了您。"; +"Conversation.StoryMentionTextOutgoing" = "您在动态中\n提到了 %@"; +"Conversation.SuggestedBirthdate.Day" = "日"; +"Conversation.SuggestedBirthdate.Month" = "月"; +"Conversation.SuggestedBirthdate.View" = "查看"; +"Conversation.SuggestedBirthdate.Year" = "年"; +"Conversation.SuggestedBirthdateText" = "**%@** 建议您添加您的生日"; +"Conversation.SuggestedBirthdateTextYou" = "您已建议 **%@** 添加生日"; +"Conversation.SuggestedPhotoSuccess" = "头像已更新"; +"Conversation.SuggestedPhotoSuccessText" = "您可以在 [设置]() 中更改。"; +"Conversation.SuggestedPhotoText" = "**%@** 建议将此图片用做您的 Telegram 头像。"; +"Conversation.SuggestedPhotoTextExpanded" = "%@ 推荐将此图片用做您的 Telegram 头像。"; +"Conversation.SuggestedPhotoTextYou" = "您已向 **%@** 推荐这张图片作为头像。"; +"Conversation.SuggestedPhotoTitle" = "已建议的图片"; +"Conversation.SuggestedPhotoView" = "查看图片"; +"Conversation.SuggestedVideoSuccess" = "头像已更新"; +"Conversation.SuggestedVideoSuccessText" = "您可以在 [设置]() 中更改。"; +"Conversation.SuggestedVideoText" = "**%@** 已向您推荐该图片作为您的 Telegram 头像。"; +"Conversation.SuggestedVideoTextExpanded" = "%@ 推荐将此图片用做您的 Telegram 头像。"; +"Conversation.SuggestedVideoTextYou" = "您已向 **%@** 推荐此图片作为头像。"; +"Conversation.SuggestedVideoTitle" = "已推荐的图片"; +"Conversation.SuggestedVideoView" = "查看图片"; +"Conversation.Summary.Limit.Text" = "使用 AI 概括长消息-在 Telegram 中高级版无限量使用。"; +"Conversation.Summary.Limit.Title" = "AI 摘要"; +"Conversation.Summary.Text" = "点击查看原文"; +"Conversation.Summary.Title" = "AI 摘要"; +"Conversation.SwipeToReplyHintText" = "在任何消息上左滑以回复。"; +"Conversation.SwipeToReplyHintTitle" = "滑动回复"; +"Conversation.TapAndHoldToRecord" = "长按录制"; +"Conversation.TextCopied" = "文本已复制到剪贴板。"; +"Conversation.Theme" = "多彩主题"; +"Conversation.Theme.Apply" = "应用主题"; +"Conversation.Theme.ChooseColorTitle" = "设置颜色"; +"Conversation.Theme.ChooseWallpaperTitle" = "选择壁纸"; +"Conversation.Theme.DismissAlert" = "您确定要将选定主题应用到此聊天中吗?"; +"Conversation.Theme.DismissAlertApply" = "应用"; +"Conversation.Theme.DontSetTheme" = "不设置主题"; +"Conversation.Theme.GiftTransfer.Proceed" = "是"; +"Conversation.Theme.GiftTransfer.Text" = "您在与 **%@** 的聊天中已设置了这个礼物作为主题,要从那里移除并在这里使用吗?"; +"Conversation.Theme.NoTheme" = "无主题"; +"Conversation.Theme.PreviewDark" = "点击以查看使用夜间模式时 %@ 将会看到的主题界面。"; +"Conversation.Theme.PreviewDarkShort" = "点击以在深色模式下查看该主题。"; +"Conversation.Theme.PreviewLight" = "点击以查看使用日间模式时 %@ 看到的主题。"; +"Conversation.Theme.PreviewLightShort" = "点击以在浅色模式下查看该主题。"; +"Conversation.Theme.Reset" = "重置此聊天的主题"; +"Conversation.Theme.ResetWallpaper" = "移除壁纸"; +"Conversation.Theme.SetColorWallpaper" = "将颜色设置为壁纸"; +"Conversation.Theme.SetCustomColor" = "自定义设置"; +"Conversation.Theme.SetNewPhotoWallpaper" = "选择新壁纸"; +"Conversation.Theme.SetPhotoWallpaper" = "从相册中选择壁纸"; +"Conversation.Theme.Subtitle" = "主题也会为 %@ 应用"; +"Conversation.Theme.SwitchToDark" = "切换到深色外观"; +"Conversation.Theme.SwitchToLight" = "切换到浅色外观\n"; +"Conversation.Theme.Title" = "选择主题"; +"Conversation.Timer.Send" = "限时消息"; +"Conversation.Timer.Title" = "限时消息"; +"Conversation.TitleComments#other" = "[%@] 条评论"; +"Conversation.TitleCommentsEmpty" = "留言"; +"Conversation.TitleCommentsFormat" = "%1$@ %2$@"; +"Conversation.TitleMute" = "静音"; +"Conversation.TitleNoComments" = "评论"; +"Conversation.TitleReplies#other" = "[%@]回复"; +"Conversation.TitleRepliesEmpty" = "回复"; +"Conversation.TitleRepliesFormat" = "%1$@ %2$@"; +"Conversation.TitleUnmute" = "开启通知"; +"Conversation.Translation.AddedToDoNotTranslateText" = "**%@** 已添加至无需翻译列表。"; +"Conversation.Translation.ChooseLanguage" = "选择其他语言"; +"Conversation.Translation.CocoonInfo" = "翻译由 **#Cocoon** 提供技术支持。[它是如何运作的?]()"; +"Conversation.Translation.DoNotTranslate" = "无需翻译 %@"; +"Conversation.Translation.DoNotTranslateOther" = "无需翻译 %@"; +"Conversation.Translation.Hide" = "隐藏"; +"Conversation.Translation.Settings" = "设置"; +"Conversation.Translation.ShowOriginal" = "显示原文"; +"Conversation.Translation.TranslateTo" = "翻译为%@"; +"Conversation.Translation.TranslateToOther" = "翻译为%@"; +"Conversation.Translation.TranslationBarHiddenChannelText" = "已在此频道中隐藏翻译栏。"; +"Conversation.Translation.TranslationBarHiddenChatText" = "已在此聊天中隐藏翻译栏。"; +"Conversation.Translation.TranslationBarHiddenGroupText" = "已在此群组中隐藏翻译栏。"; +"Conversation.Unarchive" = "取消归档"; +"Conversation.UnarchiveDone" = "此聊天已移至主列表。"; +"Conversation.Unblock" = "移出黑名单"; +"Conversation.UnblockUser" = "将此用户移出黑名单"; +"Conversation.Unmute" = "开启通知"; +"Conversation.Unpin" = "取消置顶"; +"Conversation.UnpinMessageAlert" = "您确定要取消置顶此消息吗?"; +"Conversation.UnreadMessages" = "未读消息"; +"Conversation.UnsupportedMedia" = "此消息不支持您当前版本的 Telegram。更新应用即可查看:https://telegram.org/update"; +"Conversation.UnsupportedMediaPlaceholder" = "此消息不支持当前版本的 Telegram。请更新至最新版本。"; +"Conversation.UnvotePoll" = "撤回投票"; +"Conversation.UpdateTelegram" = "更新 Telegram"; +"Conversation.UploadFileTooLarge" = "文件无法发送,因为它大于2GB。\n\n您可以发送任意数量的文件,但每个文件必须小于2GB。"; +"Conversation.UserSendMessage" = "发送消息"; +"Conversation.UsernameCopied" = "用户名已拷贝至剪贴板。"; +"Conversation.UsersTooMuchError" = "抱歉,此群组已经满员,无法加入此群组。"; +"Conversation.VideoTimeLinkCopied" = "开始时间为 %@ 的链接已拷贝到剪贴板。"; +"Conversation.ViewBackground" = "查看壁纸"; +"Conversation.ViewBot" = "查看机器人"; +"Conversation.ViewChannel" = "查看频道"; +"Conversation.ViewContactDetails" = "查看联系人"; +"Conversation.ViewEmojis" = "查看表情"; +"Conversation.ViewGroup" = "查看群组"; +"Conversation.ViewInChannel" = "在频道中查看"; +"Conversation.ViewMessage" = "查看消息"; +"Conversation.ViewPollStats" = "查看统计数据"; +"Conversation.ViewPost" = "查看帖子"; +"Conversation.ViewReply" = "在聊天中查看"; +"Conversation.ViewStarGift" = "查看典藏品"; +"Conversation.ViewStickers" = "查看贴纸"; +"Conversation.ViewTheme" = "预览主题"; +"Conversation.VoiceChat" = "视频聊天"; +"Conversation.VoiceChatMediaRecordingRestricted" = "抱歉,在视频聊天时无法录制语音和视频消息。"; +"Conversation.VoiceMessagesRestricted" = "%@ 不接受语音或视频消息"; +"Conversation.typing" = "正在输入"; +"ConversationMedia.Title" = "媒体"; +"ConversationProfile.ErrorCreatingConversation" = "发生错误"; +"ConversationProfile.LeaveDeleteAndExit" = "删除并退出"; +"ConversationProfile.UnknownAddMemberError" = "抱歉,遇到了一个意外问题。我们的魔法师已收到错误报告并将很快吟唱修复咒语。"; +"ConversationProfile.UsersTooMuchError" = "抱歉,此群组已经满员。无法添加更多成员。"; +"ConvertToSupergroup.HelpText" = "• 新成员能看到所有历史聊天记录\n• 所有成员将看不到已删除的消息\n• 管理员可以为群组添加描述\n• 创建者可以给群组设置一条公开链接"; +"ConvertToSupergroup.HelpTitle" = "**在超级群组中:**"; +"ConvertToSupergroup.Note" = "**注意**:此操作不可撤消。"; +"ConvertToSupergroup.Title" = "超级群组"; +"Core.ServiceUserStatus" = "服务通知"; +"Coub.TapForSound" = "点击以播放声音"; +"CountriesList.MaximumReached" = "您最多可以选择 %@ 个国家。"; +"CountriesList.MaximumReached.Countries#other" = "%@ 个国家"; +"CountriesList.SaveCountries" = "保存国家"; +"CountriesList.Search" = "搜索"; +"CountriesList.SelectCountries" = "选择国家"; +"CountriesList.SelectUpTo#other" = "最多可以选择 %@ 个国家"; +"CreateBot.ActionButton" = "创建"; +"CreateBot.InputBadge" = "编辑"; +"CreateBot.NamePlaceholder" = "名称"; +"CreateBot.SectionName" = "机器人名称"; +"CreateBot.SectionUsername" = "机器人用户名"; +"CreateBot.Text" = "[%@]() 想要代表您创建并管理一个聊天机器人。"; +"CreateBot.Title" = "创建机器人"; +"CreateBot.UnsavedAlert.Discard" = "放弃"; +"CreateBot.UnsavedAlert.Text" = "您还没完成机器人的创建。"; +"CreateBot.UnsavedAlert.Title" = "未储存更改"; +"CreateBot.UsernameStatus.Checking" = "检查中..."; +"CreateBot.UsernameStatus.Invalid" = "您只能使用 **a-z**、**0-9** 及底线。"; +"CreateBot.UsernameStatus.LimitExceeded#other" = "您最多只能创建 %d 个机器人。"; +"CreateBot.UsernameStatus.Link" = "链接: t.me/%@"; +"CreateBot.UsernameStatus.Number" = "用户名不能以数字开头。"; +"CreateBot.UsernameStatus.Short" = "用户名必须至少有 5 个字元。"; +"CreateBot.UsernameStatus.Taken" = "此用户名已被占用。"; +"CreateExternalStream.Revoke.Revoke" = "撒销"; +"CreateExternalStream.Revoke.Text" = "您想要确定撒销推流密钥?"; +"CreateExternalStream.RevokeStreamKey" = "撤销推流密钥"; +"CreateExternalStream.ServerUrl" = "服务器 URL"; +"CreateExternalStream.StartStreaming" = "开始直播"; +"CreateExternalStream.StartStreamingInfo" = "一旦你在直播应用程序中开始直播,请点击下方的 “开始直播” 按钮。"; +"CreateExternalStream.StreamKey" = "串流密钥"; +"CreateExternalStream.StreamKeyTitle" = "串流密钥"; +"CreateExternalStream.Text" = "若要使用其他应用程序直播视频\n请在直播应用中\n输入以下服务器网址和推流密钥。"; +"CreateExternalStream.Title" = "以其它方式直播"; +"CreateGroup.AutoDeleteText" = "一段时间后,自动为所有人删除此群组中的消息。"; +"CreateGroup.AutoDeleteTitle" = "自动删除消息"; +"CreateGroup.ChannelsTooMuch" = "抱歉,您加入了太多的群组或频道。请在创建新的之前退出一些。"; +"CreateGroup.ErrorLocatedGroupsTooMuch" = "抱歉,您已经有过多基于位置的群组,请先删除其中一个。"; +"CreateGroup.PeersTitleDelimeter" = ","; +"CreateGroup.PeersTitleLastDelimeter" = "以及"; +"CreateGroup.PublicLinkInfo" = "您可以使用 **a-z**、**0-9** 和下划线。最小长度为 **5** 个字符。"; +"CreateGroup.PublicLinkTitle" = "设置公开链接"; +"CreateGroup.SoftUserLimitAlert" = "在创建群组后,您将能添加更多成员。"; +"CreatePoll.AddMoreOptions#other" = "您还可以添加 %@ 个选项。"; +"CreatePoll.AddOption" = "添加一个选项"; +"CreatePoll.AddingOptions" = "允许添加选项"; +"CreatePoll.AddingOptionsInfo" = "参与者可以提出新的选项"; +"CreatePoll.AllOptionsAdded" = "您添加的选项数量已经达到上限。"; +"CreatePoll.AllowedCountries" = "允许的国家"; +"CreatePoll.AllowedCountries.Countries#other" = "%@ 个国家"; +"CreatePoll.Anonymous" = "匿名投票"; +"CreatePoll.CancelConfirmation" = "您确定要放弃此次投票吗?"; +"CreatePoll.Create" = "发送"; +"CreatePoll.Deadline.SetDeadline" = "截止日期"; +"CreatePoll.Deadline.Title" = "截止日期"; +"CreatePoll.DescriptionPlaceholder" = "添加简介 (可选)"; +"CreatePoll.DiscardPoll" = "放弃投票吗?"; +"CreatePoll.EnableQuiz" = "设置正确答案"; +"CreatePoll.EnableQuizInfo" = "选择一个选项作为正确答案"; +"CreatePoll.EnableQuizMultiInfo" = "选择一个或多个选项作为正确答案"; +"CreatePoll.Explanation" = "添加说明(可选)"; +"CreatePoll.ExplanationHeader" = "注释"; +"CreatePoll.ExplanationInfo" = "选错的用户将看到这段注释,适合教学用途。"; +"CreatePoll.LimitCountry" = "按国家限制"; +"CreatePoll.LimitCountryInfo" = "仅来自特定国家的用户才能投票"; +"CreatePoll.LimitDuration" = "限制时长"; +"CreatePoll.LimitDuration.Duration" = "时长"; +"CreatePoll.LimitDuration.HideResults" = "隐藏结果"; +"CreatePoll.LimitDuration.HideResultsInfo" = "若启用此功能,投票结束后才会显示结果。"; +"CreatePoll.LimitDuration.PollEnds" = "投票结束"; +"CreatePoll.LimitDurationInfo" = "在指定时间自动关闭投票"; +"CreatePoll.Link.Description" = "为此选项添加链接。"; +"CreatePoll.Media.Delete" = "删除"; +"CreatePoll.Media.Replace" = "替换"; +"CreatePoll.MediaUploading.Text" = "投票媒体仍在上传中..."; +"CreatePoll.MediaUploading.Title" = "请稍等"; +"CreatePoll.MultiAnswer" = "允许多选"; +"CreatePoll.MultiAnswerInfo" = "投票者可以选择多个选项"; +"CreatePoll.MultipleChoice" = "可多选"; +"CreatePoll.MultipleChoiceQuizAlert" = "问答模式下的投票只能有一个正确答案。"; +"CreatePoll.OptionCountFooterFormat#other" = "您还可以添加其他 {count} 个选项。"; +"CreatePoll.OptionPlaceholder" = "选项"; +"CreatePoll.OptionsHeader" = "投票选项"; +"CreatePoll.OptionsNeeded" = "至少新增两个选项"; +"CreatePoll.OptionsNeededOne" = "至少新增一个选项"; +"CreatePoll.OptionsTitle" = "选项"; +"CreatePoll.QuestionNeeded" = "请输入一个问题。"; +"CreatePoll.Quiz" = "问答模式"; +"CreatePoll.QuizCorrectOptionNeeded" = "选择正确选项"; +"CreatePoll.QuizCorrectOptionNeededMultiple" = "选择至少一个正确选项"; +"CreatePoll.QuizCountryNeeded" = "至少选择一个国家"; +"CreatePoll.QuizInfo" = "问答模式下的投票仅有一个正确答案,且用户无法撤回其选择。"; +"CreatePoll.QuizOptionsHeader" = "问答选项"; +"CreatePoll.QuizTip" = "点击选择正确的答案"; +"CreatePoll.QuizTitle" = "新问答"; +"CreatePoll.RestrictToSubscribers" = "仅限订阅者"; +"CreatePoll.RestrictToSubscribersInfo" = "只有在 24 小时前加入的订阅者才能投票"; +"CreatePoll.Revoting" = "允许重新投票"; +"CreatePoll.RevotingInfo" = "投票者可以更改投票选择"; +"CreatePoll.SettingsTitle" = "设置"; +"CreatePoll.ShowWhoVoted" = "显示投票者名单"; +"CreatePoll.ShowWhoVotedInfo" = "在每个选项上显示投票者名称"; +"CreatePoll.ShuffleOptions" = "随机选项"; +"CreatePoll.ShuffleOptionsInfo" = "每位投票者的答案以随即顺序显示"; +"CreatePoll.TextHeader" = "问题"; +"CreatePoll.TextPlaceholder" = "请输入问题"; +"CreatePoll.TimeLimit.Custom" = "自定义"; +"CreatePoll.Title" = "发起投票"; +"CreateTodo.AddTaskPlaceholder" = "添加任务"; +"CreateTodo.AddTitle" = "添加一项任务"; +"CreateTodo.AllowOthersToAppend" = "允许其他人新增任务"; +"CreateTodo.AllowOthersToComplete" = "允许其他人标记为已完成"; +"CreateTodo.EditTitle" = "编辑核对清单"; +"CreateTodo.Save" = "保存"; +"CreateTodo.Send" = "发送"; +"CreateTodo.TaskCountFooterFormat#other" = "您可以新增其他 {count} 个任务。"; +"CreateTodo.TaskCountLimitReached" = "您已添加最大数量的任务。"; +"CreateTodo.TaskPlaceholder" = "任务"; +"CreateTodo.Title" = "新核对清单"; +"CreateTodo.TitlePlaceholder" = "标题"; +"CreateTodo.TodoTitle" = "核对清单"; +"CreateTopic.Create" = "创建"; +"CreateTopic.CreateTitle" = "新的话题"; +"CreateTopic.EditTitle" = "编辑话题"; +"CreateTopic.EnterTopicTitle" = "输入话题名称"; +"CreateTopic.EnterTopicTitlePlaceholder" = "你想要讨论什么?"; +"CreateTopic.SelectTopicIcon" = "选择话题图标"; +"CreateTopic.ShowGeneral" = "在话题中显示"; +"CreateTopic.ShowGeneralInfo" = "如果“常规”主题处于隐藏状态,则群组成员可以在主题列表中下拉以查看该主题。"; +"Currency.Grams#other" = "%@ Gram"; +"DataUpgrade.Running" = "优化中..."; +"DataUsage.AutoDownloadSettings" = "自动下载设置"; +"DataUsage.Header" = "数据使用情况"; +"DataUsage.HeaderNoDataUsed" = "没有使用任何数据"; +"DataUsage.InfoMobileUsageSinceTime" = "自 %@ 以来的移动数据用量"; +"DataUsage.InfoTotalUsageSinceTime" = "自 %@ 起的数据用量"; +"DataUsage.InfoWifiUsageSinceTime" = "自 %@ 以来的 Wi-Fi 数据用量"; +"DataUsage.MediaDirectionIncoming" = "接收"; +"DataUsage.MediaDirectionOutgoing" = "发送"; +"DataUsage.Reset" = "重置统计数据"; +"DataUsage.SectionTotalIncoming" = "已接收数据"; +"DataUsage.SectionTotalOutgoing" = "已发出数据"; +"DataUsage.SectionUsageMobile" = "移动网络用量"; +"DataUsage.SectionUsageTotal" = "总计网络用量"; +"DataUsage.SectionUsageWifi" = "WI-FI 网络用量"; +"DataUsage.SectionsInfo" = "点击各部分查看详细信息。"; +"DataUsage.SettingsHelpCellular" = "您可以修改自动下载设置以减少移动数据的用量。"; +"DataUsage.SettingsHelpWifi" = "您可以更改自动下载设置,以减少连接到 Wi-Fi 时的数据用量。"; +"DataUsage.TopSectionAll" = "全部"; +"DataUsage.TopSectionMobile" = "移动数据"; +"DataUsage.TopSectionWifi" = "Wi-Fi"; +"Date.ChatDateHeader" = "%1$@%2$@日"; +"Date.ChatDateHeaderYear" = "%3$@年%1$@%2$@日"; +"Date.DialogDateFormat" = "{month} {day}日"; +"DeleteAccount.AlternativeOptionsTitle" = "其它选项"; +"DeleteAccount.CloudStorageText" = "您将无法访问“我的收藏”以及聊天记录中的所有消息、媒体和文件。"; +"DeleteAccount.CloudStorageTitle" = "你的免费云存储空间"; +"DeleteAccount.ComeBackLater" = "稍后回来"; +"DeleteAccount.ConfirmationAlertDelete" = "删除我的账户"; +"DeleteAccount.ConfirmationAlertText" = "删除账号将永久抹除您的数据!\n\n此动作将不可逆!"; +"DeleteAccount.ConfirmationAlertTitle" = "确定删除你的账户吗?"; +"DeleteAccount.Continue" = "继续"; +"DeleteAccount.DeleteMessagesURL" = "https://telegram.org/faq#q-can-i-delete-my-messages"; +"DeleteAccount.DeleteMyAccount" = "删除我的账户"; +"DeleteAccount.DeleteMyAccountTitle" = "删除我的账户"; +"DeleteAccount.EnterPassword" = "请输入你的密码"; +"DeleteAccount.EnterPhoneNumber" = "请输入您的手机号码"; +"DeleteAccount.GroupsAndChannelsInfo" = "您可以通过 聊天信息>编辑>管理员 转移群组和频道的所有权。"; +"DeleteAccount.GroupsAndChannelsText" = "您创建的群组和频道将获得新的管理员或被遗弃。"; +"DeleteAccount.GroupsAndChannelsTitle" = "您的群组和频道"; +"DeleteAccount.InvalidPasswordError" = "密码错误,请重新输入。"; +"DeleteAccount.InvalidPhoneNumberError" = "手机号码格式不正确,请重新输入。"; +"DeleteAccount.MessageHistoryText" = "您的聊天对象将会保留与您的历史消息记录,包括您在私密聊天中分享的消息。\n\n您可以在任何时候为双方删除任何消息,但如果您删除了帐号,这将无法实现。"; +"DeleteAccount.MessageHistoryTitle" = "您的消息历史记录"; +"DeleteAccount.Options.AddAccountPremiumText" = "你可以在一个应用程序中同时使用多达4个账号。"; +"DeleteAccount.Options.AddAccountText" = "你可以在同一应用程序中同时使用多达3个账号。"; +"DeleteAccount.Options.AddAccountTitle" = "添加另外一个账户"; +"DeleteAccount.Options.ChangePhoneNumberText" = "将您的联系人、聊天和媒体移动到新号码。"; +"DeleteAccount.Options.ChangePhoneNumberTitle" = "修改手机号码"; +"DeleteAccount.Options.ChangePrivacyText" = "选择谁可以看到你资料的哪个部分。"; +"DeleteAccount.Options.ChangePrivacyTitle" = "修改你的隐私设置"; +"DeleteAccount.Options.ClearCacheText" = "从你的设备上释放更多空间;你的媒体将保持在云端。"; +"DeleteAccount.Options.ClearCacheTitle" = "清除缓存"; +"DeleteAccount.Options.ClearSyncedContactsText" = "移除任何不需要的已同步联系人。"; +"DeleteAccount.Options.ClearSyncedContactsTitle" = "清除已同步的联系人"; +"DeleteAccount.Options.ContactSupportText" = "如遇问题,请告诉我们;删除帐号通常无法解决此问题。"; +"DeleteAccount.Options.ContactSupportTitle" = "联系客服"; +"DeleteAccount.Options.DeleteChatsText" = "了解如何快速地删除任何你不需要的信息。"; +"DeleteAccount.Options.DeleteChatsTitle" = "快速删除您的聊天"; +"DeleteAccount.Options.SetPasscodeText" = "使用锁定码锁定该应用程序,其他人不能够打开它。"; +"DeleteAccount.Options.SetPasscodeTitle" = "设置锁定码"; +"DeleteAccount.Options.SetTwoStepAuthText" = "设置一个每次登录时都需要提供的密码。"; +"DeleteAccount.Options.SetTwoStepAuthTitle" = "启用二步验证"; +"DeleteAccount.SavedMessages" = "已保存"; +"DeleteAccount.Success" = "账户已删除。"; +"DialogList.AdLabel" = "代理赞助商"; +"DialogList.AdNoticeAlert" = "此频道是由您的代理服务器所展示的。若要从您的对话列表中移除此频道,在 Telegram 设置 > 数据和存储中停用代理即可。"; +"DialogList.AwaitingEncryption" = "等待 %@ 上线…"; +"DialogList.ClearHistoryConfirmation" = "清空历史记录"; +"DialogList.DeleteBotClearHistory" = "清除聊天记录"; +"DialogList.DeleteBotConfirmation" = "删除"; +"DialogList.DeleteBotConversationConfirmation" = "删除并屏蔽"; +"DialogList.DeleteConversationConfirmation" = "删除并退出"; +"DialogList.Draft" = "草稿"; +"DialogList.EncryptedChatStartedIncoming" = "%@ 已创建私密聊天。"; +"DialogList.EncryptedChatStartedOutgoing" = "%@ 已加入您的私密聊天。"; +"DialogList.EncryptionProcessing" = "交换加密密钥中…"; +"DialogList.EncryptionRejected" = "私密聊天已取消"; +"DialogList.LanguageTooltip" = "您可以稍后在设置中更改您的语言"; +"DialogList.LiveLocationChatsCount#other" = "在 %@ 个聊天中共享"; +"DialogList.LiveLocationSharingTo" = "与 %@ 共享"; +"DialogList.MultipleTyping" = "%@ 和 %@"; +"DialogList.MultipleTypingPair" = "%@ 和 %@ 正在输入"; +"DialogList.MultipleTypingSuffix" = "%d 正在输入"; +"DialogList.MyNotes" = "我的笔记"; +"DialogList.NoMessagesText" = "请点击右上角的铅笔按钮或转至联系人界面开始发送消息。"; +"DialogList.NoMessagesTitle" = "您还没有任何对话"; +"DialogList.PasscodeLockHelp" = "点击以锁定 Telegram"; +"DialogList.Pin" = "置顶"; +"DialogList.PinLimitError" = "抱歉,您最多只能置顶 %@ 个聊天。"; +"DialogList.ProxyConnectionIssuesTooltip" = "无法连接至首选代理。\n点击以改变设置。"; +"DialogList.Read" = "已读"; +"DialogList.RecentTitlePeople" = "用户"; +"DialogList.Replies" = "回复"; +"DialogList.SavedMessages" = "我的收藏"; +"DialogList.SavedMessagesHelp" = "转发消息到这里以便快速访问"; +"DialogList.SavedMessagesTooltip" = "您可以在设置界面找到「我的收藏」"; +"DialogList.SearchLabel" = "搜索聊天"; +"DialogList.SearchLabelCompact" = "搜索"; +"DialogList.SearchSectionChats" = "聊天"; +"DialogList.SearchSectionDialogs" = "聊天和联系人"; +"DialogList.SearchSectionGlobal" = "全局搜索"; +"DialogList.SearchSectionMessages" = "消息"; +"DialogList.SearchSectionMessagesIn" = "%@ 中的消息"; +"DialogList.SearchSectionPublicPosts" = "公开贴文"; +"DialogList.SearchSectionRecent" = "最近"; +"DialogList.SearchSectionTopics" = "话题"; +"DialogList.SearchSubtitleFormat" = "%1$@,%2$@"; +"DialogList.SingleChoosingStickerSuffix" = "%@正在挑选贴纸"; +"DialogList.SinglePlayingGameSuffix" = "%@ 正在玩游戏"; +"DialogList.SingleRecordingAudioSuffix" = "%@ 正在录音"; +"DialogList.SingleRecordingVideoMessageSuffix" = "%@ 正在录制视频"; +"DialogList.SingleTypingSuffix" = "%@ 正在输入"; +"DialogList.SingleUploadingFileSuffix" = "%@ 正在发送文件"; +"DialogList.SingleUploadingPhotoSuffix" = "%@ 正在发送图片"; +"DialogList.SingleUploadingVideoSuffix" = "%@ 正在发送视频"; +"DialogList.TabTitle" = "聊天"; +"DialogList.Title" = "聊天"; +"DialogList.Typing" = "正在输入"; +"DialogList.UnknownPinLimitError" = "抱歉,您无法再置顶更多的聊天。"; +"DialogList.Unpin" = "取消置顶"; +"DialogList.Unread" = "未读"; +"DialogList.You" = "您自己"; +"DisableSharing.Confirm" = "禁用分享"; +"DisableSharing.Forwarding.Text" = "禁用将消息转发到其他聊天。"; +"DisableSharing.Forwarding.Title" = "禁止转发"; +"DisableSharing.Saving.Text" = "禁止复制文本,且禁止将照片和视频保存至相册。"; +"DisableSharing.Saving.Title" = "禁止保存"; +"DisableSharing.Screenshot.Text" = "禁用在此聊天中截屏与录屏。"; +"DisableSharing.Screenshot.Title" = "禁止截屏"; +"DisableSharing.Title" = "禁用分享"; +"DoNotTranslate.Title" = "不进行翻译"; +"Document.TargetConfirmationFormat" = "发送文件({size})给 {target}?"; +"DownloadList.CancelDownloading" = "取消下载"; +"DownloadList.Clear" = "清除"; +"DownloadList.ClearAlertText" = "Telegram 允许您将发送和接收的所有文档存储在云端,以在设备上节省存储空间。"; +"DownloadList.ClearAlertTitle" = "已下载的文件"; +"DownloadList.ClearDownloadList" = "清除下载列表"; +"DownloadList.DeleteFromCache" = "从缓存中删除"; +"DownloadList.DownloadedHeader" = "最近下载"; +"DownloadList.DownloadingHeader" = "正在下载"; +"DownloadList.IncreaseSpeed" = "提升速度"; +"DownloadList.OptionManageDeviceStorage" = "管理设备存储"; +"DownloadList.PauseAll" = "全部暂停"; +"DownloadList.RaisePriority" = "优先下载"; +"DownloadList.RemoveFileAlertRemove" = "移除"; +"DownloadList.RemoveFileAlertText#other" = "您确定要将\n%@ 个项目从下载列表中移除吗?\n这些项目会从您的设备上删除,\n但依然能够从云端访问。"; +"DownloadList.RemoveFileAlertTitle#other" = "移除%@项下载"; +"DownloadList.ResumeAll" = "全部继续"; +"DownloadingStatus" = "下载中:%@ / %@"; +"ENCRYPTED_MESSAGE" = "您有一条新消息 %1$@"; +"ENCRYPTION_ACCEPT" = "您的私密聊天请求已被接受 %1$@"; +"ENCRYPTION_REQUEST" = "新的私密聊天请求 %1$@"; +"EditProfile.NameAndPhotoHelp" = "您可以在此输入您的姓名,也可以在需要时上传头像。"; +"EditProfile.NameAndPhotoOrVideoHelp" = "输入您的姓名,同时可以添加头像。"; +"EditProfile.Title" = "编辑个人资料"; +"EditRank.AddLater" = "稍后增加"; +"EditRank.AddTag" = "添加标签"; +"EditRank.EditTag" = "编辑标签"; +"EditRank.Info" = "在 %@ 的名称旁添加简短的标签。"; +"EditRank.InfoYou" = "分享您在群组中的角色、头衔或昵称。您的标签会对所有成员可见。"; +"EditRank.Placeholder" = "添加标签"; +"EditRank.RemoveTag" = "移除标签"; +"EditRank.Title" = "成员标签"; +"EditTheme.ChangeColors" = "变换颜色"; +"EditTheme.Create.BottomInfo" = "你还可以使用手动编辑的自定义主题文件。"; +"EditTheme.Create.Preview.IncomingReplyName" = "Bob"; +"EditTheme.Create.Preview.IncomingReplyText" = "它是如何生效的?"; +"EditTheme.Create.Preview.IncomingText" = "使用您现在的颜色"; +"EditTheme.Create.Preview.OutgoingText" = "或者上传一个主题文件"; +"EditTheme.Create.TopInfo" = "主题将基于你当前选择的颜色和壁纸。"; +"EditTheme.CreateTitle" = "创建主题"; +"EditTheme.Edit.BottomInfo" = "您可以选择一个新文件更新主题。安装该主题的用户将自动获得更新。"; +"EditTheme.Edit.Preview.IncomingReplyName" = "鲍勃"; +"EditTheme.Edit.Preview.IncomingReplyText" = "它是如何生效的?"; +"EditTheme.Edit.Preview.IncomingText" = "使用您现在的颜色"; +"EditTheme.Edit.Preview.OutgoingText" = "或者上传一个主题文件"; +"EditTheme.Edit.TopInfo" = "当你修改主题时,安装了的用户将获得自动更新。任何人都可以使用此链接安装它。\n\n主题链接必须不小于 **5** 个字符,可以使用 **a-z**、**0-9** 和下划线。"; +"EditTheme.EditTitle" = "编辑主题"; +"EditTheme.ErrorInvalidCharacters" = "抱歉,此链接不符合规范。"; +"EditTheme.ErrorLinkTaken" = "抱歉,此链接被占用了。"; +"EditTheme.Expand.BottomInfo" = "你还可以使用手动编辑的自定义主题文件。"; +"EditTheme.Expand.Preview.IncomingReplyName" = "Bob"; +"EditTheme.Expand.Preview.IncomingReplyText" = "它是如何生效的?"; +"EditTheme.Expand.Preview.IncomingText" = "使用您现在的颜色"; +"EditTheme.Expand.Preview.OutgoingText" = "或者上传一个主题文件"; +"EditTheme.Expand.TopInfo" = "主题将基于你当前选择的颜色和壁纸。"; +"EditTheme.FileReadError" = "无效主题文件"; +"EditTheme.Preview" = "聊天预览"; +"EditTheme.ShortLink" = "链接"; +"EditTheme.ShortLinkInfo" = "当你修改主题时,已安装的用户将获得自动更新。任何人都可以使用此链接安装它。\n\n主题链接必须不小于 **5** 个字符,可以使用 **a-z**、**0-9** 和下划线。"; +"EditTheme.ThemeTemplateAlert" = "已添加新主题\n\n长按以编辑主题或生成分享链接。当您修改主题时,已安装的用户将自动获得更新。\n\n您可以在“收藏夹”中找到主题文件进一步进行编辑。"; +"EditTheme.ThemeTemplateAlertText" = "长按以编辑主题或生成分享链接。当您修改主题时,已安装的用户将自动获得更新。\n\n您可以在“收藏夹”中找到主题文件进一步进行编辑。"; +"EditTheme.ThemeTemplateAlertTitle" = "新主题添加成功"; +"EditTheme.Title" = "主题名称"; +"EditTheme.UploadEditedInfo" = "您可以通过上传来自文件的手动更改为所有用户更新主题。"; +"EditTheme.UploadEditedTheme" = "通过文件上传……"; +"EditTheme.UploadNewInfo" = "主题将基于你当前选择的颜色和壁纸。或者,你可以使用手动编辑的自定义主题文件。"; +"EditTheme.UploadNewTheme" = "从文件创建……"; +"Embed.PlayingInPIP" = "此视频正在以「画中画」模式播放"; +"Emoji.ClearRecent" = "清空最近使用的表情"; +"Emoji.FrequentlyUsed" = "最近使用"; +"Emoji.GroupEmoji" = "群组表情符号"; +"EmojiInput.AddPack" = "添加 %@"; +"EmojiInput.PanelTitleEmoji" = "表情"; +"EmojiInput.PanelTitlePremium" = "高级版"; +"EmojiInput.PanelTitleRecent" = "最近"; +"EmojiInput.PremiumEmojiToast.Action" = "更多"; +"EmojiInput.PremiumEmojiToast.Text" = "订阅 Telegram 高级版解锁高级表情。"; +"EmojiInput.PremiumEmojiToast.TryAction" = "打开"; +"EmojiInput.PremiumEmojiToast.TryText" = "尝试在 **我的收藏** 中免费发送这些表情来体验。"; +"EmojiInput.SectionTitleCollectibles" = "典藏品"; +"EmojiInput.SectionTitleEmoji" = "表情"; +"EmojiInput.SectionTitleFavoriteStickers" = "贴纸收藏"; +"EmojiInput.SectionTitlePremiumStickers" = "高级版贴纸"; +"EmojiInput.TabEmoji" = "表情"; +"EmojiInput.TabGifs" = "GIF"; +"EmojiInput.TabMasks" = "装饰"; +"EmojiInput.TabStickers" = "贴纸"; +"EmojiInput.TrendingEmoji" = "热门表情"; +"EmojiInput.UnlockPack" = "解锁 %@"; +"EmojiPack.Add" = "添加"; +"EmojiPack.Added" = "已添加"; +"EmojiPack.Emoji#other" = "%@ 个表情"; +"EmojiPack.Title" = "表情"; +"EmojiPackActionInfo.AddedText" = "%@ 已添加至您的表情中。"; +"EmojiPackActionInfo.AddedTitle" = "表情已添加"; +"EmojiPackActionInfo.ArchivedTitle" = "表情已归档"; +"EmojiPackActionInfo.MultipleAddedText#other" = "已向您的表情中添加了 %@ 个表情包。"; +"EmojiPackActionInfo.MultipleRemovedText#other" = "%@不再出现于你的表情包中。"; +"EmojiPackActionInfo.RemovedText" = "%@ 已不在您的表情包中。"; +"EmojiPackActionInfo.RemovedTitle" = "表情已移除"; +"EmojiPacks.UnarchiveEmojiPacksConfirmation#other" = "取消归档 %@ 个表情包"; +"EmojiPacksSettings.ArchivedPacks" = "已归档的表情符号"; +"EmojiPacksSettings.ArchivedPacks.Info" = "您最多可以安装 200 套表情包。\n不经常使用的表情将会在您添加更多表情后归档。"; +"EmojiPacksSettings.Title" = "表情"; +"EmojiPreview.CopyEmoji" = "复制表情"; +"EmojiPreview.SendEmoji" = "发送表情"; +"EmojiPreview.SetAsStatus" = "设为当前账号状态"; +"EmojiSearch.SearchEmojiEmptyResult" = "未找到表情"; +"EmojiSearch.SearchEmojiPlaceholder" = "搜索表情"; +"EmojiSearch.SearchReactionsEmptyResult" = "未找到表情"; +"EmojiSearch.SearchReactionsPlaceholder" = "搜索表情回应"; +"EmojiSearch.SearchStatusesEmptyResult" = "未找到表情"; +"EmojiSearch.SearchStatusesPlaceholder" = "搜索状态"; +"EmojiSearch.SearchStickersEmptyResult" = "没有找到表情"; +"EmojiSearch.SearchTopicIconsEmptyResult" = "未找到动态图"; +"EmojiSearch.SearchTopicIconsPlaceholder" = "搜索话题图标"; +"EmojiStake.Description" = "面向 Telegram 高级版用户的实验性功能。"; +"EmojiStake.ResultsTitle" = "结果与收益"; +"EmojiStake.Roll" = "\u00a0保存并投掷"; +"EmojiStake.StakePlaceholder" = "下注金额"; +"EmojiStake.StakeTitle" = "投注"; +"EmojiStake.StreakInfo" = "连续记录将在 3 次 # 后或更改投注时重置。"; +"EmojiStake.Title" = "表情符号投注"; +"EmojiStatus.AppliedText" = "您的表情状态已更新。"; +"EmojiStatusSetup.SetUntil" = "设置至"; +"EmojiStatusSetup.TimerOther" = "其他"; +"EmojiStickerSettings.Info" = "欢迎各位画师使用 @stickers 机器人添加自己的表情包。\n\n点击并按住一条消息来查看并添加整个表情包。"; +"EmptyGroupInfo.Line1" = "成员上限为 20 万人"; +"EmptyGroupInfo.Line2" = "永久聊天记录"; +"EmptyGroupInfo.Line3" = "公开链接,例如:t.me/title"; +"EmptyGroupInfo.Line4" = "具备不同权限的管理员"; +"EmptyGroupInfo.Subtitle" = "群组可以拥有:"; +"EmptyGroupInfo.Title" = "您已创建一个群组"; +"EmptyState.AwayMessage.Text" = "添加在您离线时自动发送的消息。"; +"EmptyState.AwayMessage.Title" = "新的离开消息"; +"EmptyState.GreetingMessage.Text" = "创建将自动发送给新用户的祝福语。"; +"EmptyState.GreetingMessage.Title" = "新问候消息"; +"EnableSharing.SendRequest" = "发送请求"; +"EnableSharing.Text" = "您需要获得 **%@** 的批准才能启用分享。发送请求吗?"; +"EnableSharing.Title" = "启用分享"; +"EncryptionKey.Description" = "这是您与“%1$@”私密对话加密密钥的可视化图片。\n\n如果这张图片看起来和“%2$@”设备上的图片完全一致,端到端加密则是安全的。\n\n了解详情请前往 telegram.org 的 FAQ"; +"EncryptionKey.Title" = "加密密钥"; +"EnterPasscode.ChangeTitle" = "修改锁定码"; +"EnterPasscode.EnterCurrentPasscode" = "请输入当前的锁定码"; +"EnterPasscode.EnterNewPasscodeChange" = "请输入新的锁定码"; +"EnterPasscode.EnterNewPasscodeNew" = "请输入一个锁定码"; +"EnterPasscode.EnterPasscode" = "请输入 Telegram 锁定码"; +"EnterPasscode.EnterTitle" = "请输入 Telegram 锁定码"; +"EnterPasscode.RepeatNewPasscode" = "请再次输入新的锁定码"; +"EnterPasscode.TouchId" = "解锁 Telegram"; +"Exceptions.AddToExceptions" = "添加例外"; +"ExplicitContent.AlertChannel" = "您无法访问这个频道,因为它违反了 App Store 的规定。"; +"ExplicitContent.AlertTitle" = "抱歉"; +"External.OpenIn" = "打开 %@"; +"FaceScan.Instruction.Position" = "将您的脸放置\n在相框内"; +"FaceScan.Instruction.Rotate" = "慢慢地移动你的头\n并环绕整圈"; +"FactCheck.Placeholder" = "添加事实核查"; +"FactCheck.Remove" = "移除"; +"FactCheck.Title" = "事实核查"; +"FastTwoStepSetup.EmailHelp" = "请添加一个有效的电子邮箱地址。这是忘记密码时唯一的恢复方法。"; +"FastTwoStepSetup.EmailPlaceholder" = "您的电子邮箱"; +"FastTwoStepSetup.EmailSection" = "密码恢复电子邮箱"; +"FastTwoStepSetup.HintHelp" = "您可以为您的密码创建可选提示。"; +"FastTwoStepSetup.HintPlaceholder" = "添加提示"; +"FastTwoStepSetup.HintSection" = "提示"; +"FastTwoStepSetup.PasswordConfirmationPlaceholder" = "请再次输入密码"; +"FastTwoStepSetup.PasswordHelp" = "请创建一个用于保护您的付款信息的密码。您需要在登录时输入此密码。"; +"FastTwoStepSetup.PasswordPlaceholder" = "请输入密码"; +"FastTwoStepSetup.PasswordSection" = "密码"; +"FastTwoStepSetup.Title" = "密码和邮箱"; +"FeatureDisabled.Oops" = "哎哟"; +"FeaturedStickerPacks.Title" = "热门表情"; +"FeaturedStickers.OtherSection" = "其他贴纸"; +"FileSize.B" = "%@ B"; +"FileSize.GB" = "%@ GB"; +"FileSize.KB" = "%@ KB"; +"FileSize.MB" = "%@ MB"; +"FolderLinkPreview.ButtonAddFolder" = "添加文件夹"; +"FolderLinkPreview.ButtonDoNotJoinChats" = "不加入任何聊天"; +"FolderLinkPreview.ButtonJoinChats" = "加入聊天"; +"FolderLinkPreview.ButtonRemoveFolder" = "移除文件夹"; +"FolderLinkPreview.ButtonRemoveFolderAndChats" = "移除文件夹和聊天"; +"FolderLinkPreview.ChatSectionHeader#other" = "此文件夹中的 %d 个聊天"; +"FolderLinkPreview.ChatSectionJoinHeader#other" = "即将加入 %d 个聊天"; +"FolderLinkPreview.IconTabLeft" = "所有聊天"; +"FolderLinkPreview.IconTabRight" = "个人"; +"FolderLinkPreview.LabelPeerMember" = "您已经加入此群组"; +"FolderLinkPreview.LabelPeerMembers#other" = "%d 位成员"; +"FolderLinkPreview.LabelPeerSubscriber" = "您已经订阅此频道"; +"FolderLinkPreview.LabelPeerSubscribers#other" = "%d 位订阅者"; +"FolderLinkPreview.LinkSectionHeader" = "邀请链接"; +"FolderLinkPreview.ListSelectionSelectAllDynamicPartDeselect" = "取消选择"; +"FolderLinkPreview.ListSelectionSelectAllDynamicPartSelect" = "选择"; +"FolderLinkPreview.ListSelectionSelectAllFormat" = "{dynamic}{static}"; +"FolderLinkPreview.ListSelectionSelectAllStaticPartDeselect" = "全部"; +"FolderLinkPreview.ListSelectionSelectAllStaticPartSelect" = "全部"; +"FolderLinkPreview.RemoveSectionSelectedHeader#other" = "即将退出 %d 个聊天"; +"FolderLinkPreview.TextAddChats" = "您是否要将 **%1$@** 添加到 **%2$@** 文件夹?"; +"FolderLinkPreview.TextAddChatsCount#other" = "%d 个聊天"; +"FolderLinkPreview.TextAddChatsV2" = "你想把 {chats} 添加到 {folder} 文件夹?"; +"FolderLinkPreview.TextAddFolder" = "您是否要添加新的聊天文件夹\n 并加入其中的群组和频道?"; +"FolderLinkPreview.TextAllAdded" = "您已经添加了此文件夹\n以及其中的聊天。"; +"FolderLinkPreview.TextLinkList" = "创建更多链接\n为不同的人设置不同的访问级别。"; +"FolderLinkPreview.TextRemoveFolder" = "您是否要同时退出此文件夹中包括的聊天?"; +"FolderLinkPreview.TitleAddChats#other" = "添加 %d 个聊天"; +"FolderLinkPreview.TitleAddFolder" = "添加文件夹"; +"FolderLinkPreview.TitleRemove" = "移除文件夹"; +"FolderLinkPreview.TitleShare" = "分享文件夹"; +"FolderLinkPreview.ToastAlreadyMemberChannel" = "您已经订阅了此频道。"; +"FolderLinkPreview.ToastAlreadyMemberGroup" = "您已经是此群组的成员了。"; +"FolderLinkPreview.ToastChatsAddedText#other" = "您已加入 %d 个新聊天"; +"FolderLinkPreview.ToastChatsAddedTitle" = "已更新 %@ 文件夹"; +"FolderLinkPreview.ToastChatsAddedTitleV2" = "文件夹 {folder} 已更新"; +"FolderLinkPreview.ToastFolderAddedText#other" = "您还加入了 %d 个聊天。"; +"FolderLinkPreview.ToastFolderAddedTitle" = "已添加 %@ 文件夹"; +"FolderLinkPreview.ToastFolderAddedTitleV2" = "文件夹 {folder} 已添加"; +"FolderLinkPreview.ToastLeftChatsText#other" = "您还退出了 **%d** 个聊天"; +"FolderLinkPreview.ToastLeftTitle" = "已删除 %@ 文件夹"; +"FolderLinkPreview.ToastLeftTitleV2" = "文件夹 {folder} 已删除"; +"FolderLinkScreen.AlertTextUnavailableBot" = "您不能分享与机器人的聊天"; +"FolderLinkScreen.AlertTextUnavailablePrivateChannel" = "您没有管理员权限,无法分享此私密频道的邀请链接。"; +"FolderLinkScreen.AlertTextUnavailablePrivateGroup" = "您没有管理员权限,无法将邀请链接分享到此私密群组。"; +"FolderLinkScreen.AlertTextUnavailablePublicChannel" = "您没有管理员权限,无法分享此频道的邀请链接。"; +"FolderLinkScreen.AlertTextUnavailablePublicGroup" = "您没有管理员权限,无法分享此群组的邀请链接。"; +"FolderLinkScreen.AlertTextUnavailableUser" = "您无法分享私聊"; +"FolderLinkScreen.ChatCountHeaderUnavailable" = "该文件夹中没有您可以与他人分享的聊天。"; +"FolderLinkScreen.ChatsSectionHeader" = "聊天"; +"FolderLinkScreen.ChatsSectionHeaderActionDeselectAll" = "取消全选"; +"FolderLinkScreen.ChatsSectionHeaderActionSelectAll" = "全选"; +"FolderLinkScreen.ChatsSectionHeaderSelected#other" = "已选择 %d 个聊天"; +"FolderLinkScreen.ChatsSectionHeaderUnavailable" = "无法分享这些聊天"; +"FolderLinkScreen.ContextActionNameLink" = "为此链接命名"; +"FolderLinkScreen.LabelCanInvite" = "您可以邀请其他用户加入"; +"FolderLinkScreen.LabelUnavailableBot" = "无法分享与机器人的聊天"; +"FolderLinkScreen.LabelUnavailableGeneric" = "您无法邀请用户加入"; +"FolderLinkScreen.LabelUnavailableUser" = "您无法分享与用户的聊天"; +"FolderLinkScreen.LinkActionCopy" = "复制"; +"FolderLinkScreen.LinkActionShare" = "分享"; +"FolderLinkScreen.LinkSectionHeader" = "邀请链接"; +"FolderLinkScreen.NameLink.Title" = "为此链接命名"; +"FolderLinkScreen.SaveAlertActionApply" = "应用"; +"FolderLinkScreen.SaveAlertActionContinue" = "取消"; +"FolderLinkScreen.SaveAlertActionDiscard" = "放弃"; +"FolderLinkScreen.SaveAlertText" = "您已更改此文件夹的设置。是否应用?"; +"FolderLinkScreen.SaveAlertTitle" = "未保存的更改"; +"FolderLinkScreen.SaveUnknownError" = "更新链接时出错"; +"FolderLinkScreen.Title" = "分享文件夹"; +"FolderLinkScreen.TitleDescriptionDeselected" = "拥有此链接的任何人都可以添加 **%@** 文件夹和下面选定的聊天。"; +"FolderLinkScreen.TitleDescriptionDeselectedV2" = "任何有此链接的人都可添加 {folder} 文件夹和下列选中的聊天。"; +"FolderLinkScreen.TitleDescriptionSelected" = "获得此链接的所有人都可以添加 **%1$@** 文件夹,并加入以下选择的%2$@。"; +"FolderLinkScreen.TitleDescriptionSelectedCount#other" = " %d 个聊天"; +"FolderLinkScreen.TitleDescriptionSelectedV2" = "任何人通过此链接都可添加 {folder} 文件夹和以下选定的 {chats}。"; +"FolderLinkScreen.TitleDescriptionUnavailable" = "您只能分享允许您创建邀请链接的群组和频道。"; +"FolderLinkScreen.ToastLinkUpdated" = "链接已更新"; +"FolderLinkScreen.ToastNewChatAdded" = "已经使用该邀请链接的用户将能够加入新添加的聊天。"; +"ForcedPasswordSetup.Intro.Action" = "设置密码"; +"ForcedPasswordSetup.Intro.DismissActionCancel" = "不,让我设置一个密码。"; +"ForcedPasswordSetup.Intro.DismissActionOK" = "是的,我确定。"; +"ForcedPasswordSetup.Intro.DismissText#other" = "确定不使用密码吗?如果您不设置密码,您在 **%@ 天**内只能使用短信登录一次。"; +"ForcedPasswordSetup.Intro.DismissTitle" = "警告"; +"ForcedPasswordSetup.Intro.DoneAction" = "完成"; +"ForcedPasswordSetup.Intro.Text" = "若您想要经常登录你的账号,请设置密码。"; +"ForcedPasswordSetup.Intro.Title" = "设置密码"; +"FormattedDate.DaysAgo#other" = "%@ 天前"; +"FormattedDate.HoursAgo#other" = "%@ 小时前"; +"FormattedDate.InDays#other" = "%@ 天后"; +"FormattedDate.InHours#other" = "%@ 小时后"; +"FormattedDate.InMinutes#other" = "%@ 分钟后"; +"FormattedDate.InSeconds#other" = "%@ 秒后"; +"FormattedDate.LongDate_m1" = "一月 %1$@, %2$@"; +"FormattedDate.LongDate_m10" = "十月 %1$@, %2$@"; +"FormattedDate.LongDate_m11" = "十一月 %1$@, %2$@"; +"FormattedDate.LongDate_m12" = "十二月 %1$@, %2$@"; +"FormattedDate.LongDate_m2" = "二月 %1$@, %2$@"; +"FormattedDate.LongDate_m3" = "三月 %1$@, %2$@"; +"FormattedDate.LongDate_m4" = "四月 %1$@, %2$@"; +"FormattedDate.LongDate_m5" = "五月 %1$@,,%2$@"; +"FormattedDate.LongDate_m6" = "六月 %1$@, %2$@"; +"FormattedDate.LongDate_m7" = "七月 %1$@,%2$@"; +"FormattedDate.LongDate_m8" = "八月 %1$@, %2$@"; +"FormattedDate.LongDate_m9" = "九月 %1$@, %2$@"; +"FormattedDate.MinutesAgo#other" = "%@ 分钟前"; +"FormattedDate.SecondsAgo#other" = "%@ 秒前"; +"Forward.ChannelReadOnly" = "抱歉,您无法发布消息到此频道。"; +"Forward.ConfirmMultipleFiles#other" = "发送 %@ 个文件给 {target}?"; +"Forward.ErrorDisabledForChat" = "抱歉,您不能转发消息到此聊天。"; +"Forward.ErrorPublicPollDisabledInChannels" = "抱歉,无法将具有可见投票的民意调查转发到频道。"; +"Forward.ErrorPublicQuizDisabledInChannels" = "抱歉,公开投票不可转发至频道。"; +"Forward.ErrorTodoDisabledInChannels" = "抱歉,核对清单无法转发至频道。"; +"ForwardedAudios#other" = "%@ 条转发的语音消息"; +"ForwardedAuthors2" = "%@, %@"; +"ForwardedAuthorsOthers#other" = "%@ 和其他 %@ 人"; +"ForwardedContacts#other" = "%@ 个被转发的联系人信息"; +"ForwardedDices#other" = "%@ 个转发的骰子"; +"ForwardedFiles#other" = "%@ 个转发的文件"; +"ForwardedGifs#other" = "%@ 张转发的 GIF 动态图 "; +"ForwardedLocations#other" = "%@ 个转发的位置信息"; +"ForwardedMessages#other" = "%@ 条转发的消息"; +"ForwardedPhotos#other" = "%@ 张转发的图片"; +"ForwardedPolls#other" = "转发了投票\n转发了 %@ 个投票"; +"ForwardedStickers#other" = "%@ 个转发的表情"; +"ForwardedVideoMessages#other" = "%@ 条转发的视频消息"; +"ForwardedVideos#other" = "%@ 段转发的视频"; +"FrozenAccount.Appeal.Text" = "在 %@ 之前通过 [@SpamBot]() 申诉,否则您的账户将被删除。"; +"FrozenAccount.Appeal.Title" = "停用前申诉"; +"FrozenAccount.ReadOnly.Text" = "您可以访问您的账号,但不能发送消息或执行操作。"; +"FrozenAccount.ReadOnly.Title" = "只读模式"; +"FrozenAccount.SubmitAppeal" = "提交申诉"; +"FrozenAccount.Title" = "您的账号已被冻结"; +"FrozenAccount.Understood" = "了解"; +"FrozenAccount.Violation.Text" = "\u200b您的账号因违反 Telegram 的服务条款而被冻结。"; +"FrozenAccount.Violation.TextNew" = "您的账号因违反 Telegram 的[使用条款]()而被冻结。"; +"FrozenAccount.Violation.TextNew_URL" = "https://telegram.org/tos"; +"FrozenAccount.Violation.Title" = "违反条款"; +"Gallery.Ad" = "广告"; +"Gallery.AirPlay" = "隔空播放"; +"Gallery.AirPlayPlaceholder" = "此视频正在使用“隔空播放”在电视上播放"; +"Gallery.CreateSticker" = "创建贴纸"; +"Gallery.GifSaved" = "已加入 GIF 动态图收藏。"; +"Gallery.ImageSaved" = "图片已保存至“照片”。"; +"Gallery.ImagesAndVideosSaved" = "媒体文件已保存到相册。"; +"Gallery.Live" = "实况"; +"Gallery.MenuSaveToGallery" = "保存至相册"; +"Gallery.SaveImage" = "保存图片"; +"Gallery.SaveToGallery" = "保存到相册"; +"Gallery.SaveToGallery.Original" = "保存原图"; +"Gallery.SaveToGallery.Quality" = "以 %@p 保存"; +"Gallery.SaveToGallery.cached" = "已缓存"; +"Gallery.SaveVideo" = "保存视频"; +"Gallery.ToastVideoSpeedSwipe" = "向侧边滑动即可调整速度。"; +"Gallery.VideoSaved" = "视频已保存"; +"Gallery.VideoSettings.IconQualityFHD" = "FHD"; +"Gallery.VideoSettings.IconQualityHD" = "HD"; +"Gallery.VideoSettings.IconQualityLow" = "慢速"; +"Gallery.VideoSettings.IconQualityMedium" = "中速"; +"Gallery.VideoSettings.IconQualityQHD" = "QHD"; +"Gallery.VideoSettings.QualityAuto" = "自动"; +"Gallery.VideoSettings.QualityFHD" = "高清"; +"Gallery.VideoSettings.QualityHD" = "高画质"; +"Gallery.VideoSettings.QualityLow" = "低画质"; +"Gallery.VideoSettings.QualityMedium" = "中画质"; +"Gallery.VideoSettings.QualityQHD" = "超高清"; +"Gallery.VideoSettings.QualitySectionTitle" = "质量"; +"Gallery.VideoSettings.SpeedControlTitle" = "速度"; +"Gallery.VideoSettings.SpeedSectionTitle" = "播放速度"; +"Gallery.ViewOncePhotoTooltip" = "该图片只能查看一次。"; +"Gallery.ViewOnceVideoTooltip" = "该视频只能观看一次。"; +"Gallery.VoiceOver.Delete" = "删除"; +"Gallery.VoiceOver.Edit" = "编辑"; +"Gallery.VoiceOver.Fullscreen" = "全屏"; +"Gallery.VoiceOver.PictureInPicture" = "画中画"; +"Gallery.VoiceOver.Share" = "分享"; +"Gallery.VoiceOver.Stickers" = "贴纸"; +"Gallery.WaitForVideoDownoad" = "请等待视频下载完毕。"; +"Generic.ErrorMoreInfo" = "更多信息"; +"Generic.OpenHiddenLinkAlert" = "是否打开 %@ ?"; +"Gif.Emotion.Angry" = "生气"; +"Gif.Emotion.Cool" = "酷"; +"Gif.Emotion.Hearts" = "心动"; +"Gif.Emotion.Joy" = "喜悦"; +"Gif.Emotion.Kiss" = "亲吻"; +"Gif.Emotion.Party" = "派对"; +"Gif.Emotion.RollEyes" = "翻白眼"; +"Gif.Emotion.Surprised" = "惊讶"; +"Gif.Emotion.ThumbsDown" = "不赞成"; +"Gif.Emotion.ThumbsUp" = "赞"; +"Gif.NoGifsFound" = "未找到 GIF 动态图"; +"Gif.NoGifsPlaceholder" = "您还没有保存过 GIF 动态图"; +"Gif.Search" = "搜索 GIF"; +"GifSearch.SearchGifPlaceholder" = "查找 GIF 动图"; +"Gift.Acquired.AcceptedBid" = "中标价"; +"Gift.Acquired.Date" = "日期"; +"Gift.Acquired.GiftRound" = "%1$@ 于第 %2$@ 轮"; +"Gift.Acquired.Recipient" = "收件人"; +"Gift.Acquired.Round" = "第 %@ 轮"; +"Gift.Acquired.Title#other" = "已购买 %@ 份礼物"; +"Gift.Acquired.Top" = "前 %@"; +"Gift.ActiveAuctions.Outbid" = "您对 **%@** 出价已失效"; +"Gift.ActiveAuctions.RaiseBid" = "提高出价"; +"Gift.ActiveAuctions.Round" = "第 %@ / %@ 轮"; +"Gift.ActiveAuctions.Title#other" = "%@ 场进行中的拍卖"; +"Gift.ActiveAuctions.UpcomingBid" = "您已预先出价"; +"Gift.ActiveAuctions.Winning" = "您的出价 %@ 排名第 %@ 位"; +"Gift.Attribute.Epic" = "史诗"; +"Gift.Attribute.Legendary" = "传奇"; +"Gift.Attribute.Rare" = "稀有"; +"Gift.Attribute.Uncommon" = "罕见"; +"Gift.Auction.Auction" = "拍卖"; +"Gift.Auction.Availability" = "剩余数量"; +"Gift.Auction.AvailabilityOf" = "剩余 %@ / %@ "; +"Gift.Auction.AveragePrice" = "平均价格"; +"Gift.Auction.AveragePriceInfo" = "**%1$@** 颗星星是 %2$@ 份礼物的平均成交价。"; +"Gift.Auction.Context.About" = "了解更多"; +"Gift.Auction.Context.CopyLink" = "复制链接"; +"Gift.Auction.Context.Share" = "分享"; +"Gift.Auction.CurrentRound" = "当前轮次"; +"Gift.Auction.Description" = "本轮排名前 %@ 的用户将获得 %@ 个礼物。"; +"Gift.Auction.EarlyBid" = "提前出价"; +"Gift.Auction.End" = "结束"; +"Gift.Auction.Ended" = "拍卖已结束"; +"Gift.Auction.Ends" = "结束"; +"Gift.Auction.Extension" = "前 %2$@ 名延迟出价时延长 + %1$@"; +"Gift.Auction.FirstSale" = "首次销售"; +"Gift.Auction.ForSaleOnFragment" = "在 Fragment 上出售"; +"Gift.Auction.ForSaleOnTelegram" = "在 Telegram 上出售"; +"Gift.Auction.GiftAuction" = "礼物拍卖"; +"Gift.Auction.Hours#other" = "%@ 小时"; +"Gift.Auction.HoursEach#other" = "每轮 %@ 小时"; +"Gift.Auction.Info.Carryover.Text" = "如果您的出价未进入前 %@ 名,将自动进入下一轮竞标。"; +"Gift.Auction.Info.Carryover.Title" = "出价延续"; +"Gift.Auction.Info.Description" = "参与专属礼品争夺战。"; +"Gift.Auction.Info.Missed.Text" = "如果您的出价在最后一轮未能胜出,您的星星将全额退还。"; +"Gift.Auction.Info.Missed.Title" = "未中拍者"; +"Gift.Auction.Info.Title" = "拍卖"; +"Gift.Auction.Info.TopBidders.Bidders#other" = "%@ 名出价者"; +"Gift.Auction.Info.TopBidders.Gifts#other" = "%@ 个礼物"; +"Gift.Auction.Info.TopBidders.Rounds#other" = "第 %@ 轮"; +"Gift.Auction.Info.TopBidders.Text" = "%@ 将在 %@ 中发放给出价最高的前 %@ 名。"; +"Gift.Auction.Info.TopBidders.Title#other" = "前 %@ 名竞拍者"; +"Gift.Auction.Info.Understood" = "了解"; +"Gift.Auction.ItemsBought#other" = "已购买礼物"; +"Gift.Auction.Join" = "加入拍卖"; +"Gift.Auction.LastSale" = "最后销售"; +"Gift.Auction.LearnMore" = "了解更多关于 Telegram 礼物 >"; +"Gift.Auction.Minutes#other" = "%@ 分钟"; +"Gift.Auction.MinutesEach#other" = "每轮 %@ 分钟"; +"Gift.Auction.Quantity" = "数量"; +"Gift.Auction.Round" = "%@ / %@"; +"Gift.Auction.Seconds#other" = "%@ 秒"; +"Gift.Auction.Stars#other" = "%@ 颗星星"; +"Gift.Auction.Start" = "开始"; +"Gift.Auction.Started" = "开始"; +"Gift.Auction.StartsInHours" = "拍卖将于 {h}:{m}:{s} 开始"; +"Gift.Auction.StartsInMinutes" = "拍卖将于 {m}:{s} 开始"; +"Gift.Auction.TimeLeftHours" = "剩余 {h} 小时 {m} 分钟"; +"Gift.Auction.TimeLeftMinutes" = "剩余 {m}:{s} "; +"Gift.Auction.TimeRound" = "第 %@ 轮"; +"Gift.Auction.TimeRounds" = "第 %@ 轮"; +"Gift.Auction.TotalRounds" = "轮次"; +"Gift.Auction.UpcomingAuction" = "即将开始的拍卖"; +"Gift.Auction.Variants#other" = "%@ 种款式"; +"Gift.Auction.ViewVariants" = "查看"; +"Gift.AuctionBid.AddMoreStars" = "出价时至少增加 %@。"; +"Gift.AuctionBid.AddMoreStars.Set" = "设置"; +"Gift.AuctionBid.AddMoreStars.Stars#other" = "%@ 颗星星"; +"Gift.AuctionBid.AddToBid" = "提高出价 %@ "; +"Gift.AuctionBid.BeforeStart" = "距离开始"; +"Gift.AuctionBid.BidPreview" = "你的出价是"; +"Gift.AuctionBid.Custom" = "自定义"; +"Gift.AuctionBid.CustomBid.Done" = "出价"; +"Gift.AuctionBid.CustomBid.Placeholder" = "金额"; +"Gift.AuctionBid.CustomBid.Text" = "如果您的出价低于前 %@ 名,您的出价将自动转入下一轮竞标。"; +"Gift.AuctionBid.CustomBid.Title" = "自定义出价"; +"Gift.AuctionBid.Increased.Text" = "如果你的排名跌出前 %@ 名,你的出价将顺延到下一轮。"; +"Gift.AuctionBid.Increased.Title" = "您的出家已提高"; +"Gift.AuctionBid.Left" = "剩余"; +"Gift.AuctionBid.MinimumBid" = "最低出价"; +"Gift.AuctionBid.MinimumBidIncreased" = "最低出价提高至 **%@**。"; +"Gift.AuctionBid.Outbid" = "你的出价已经被超越"; +"Gift.AuctionBid.PlaceBid" = "出价为 %@ "; +"Gift.AuctionBid.Placed.Text" = "如果您的出价低于前 %@ 名,您的出价将自动转入下一轮竞标。"; +"Gift.AuctionBid.Placed.Title" = "你的出价已提交"; +"Gift.AuctionBid.RoundSubtitle" = "第 %@/%@ 轮"; +"Gift.AuctionBid.Subtitle" = "前 %@ 名出价者将获胜"; +"Gift.AuctionBid.Title" = "出价"; +"Gift.AuctionBid.Top" = "前 %@"; +"Gift.AuctionBid.TopWinners" = "前三名赢家"; +"Gift.AuctionBid.TopWinnersTotal" = "前三名 %@ 得标着"; +"Gift.AuctionBid.UntilEnd" = "直到结束"; +"Gift.AuctionBid.UntilNext" = "直到下一轮"; +"Gift.AuctionBid.UpcomingBid" = "您已预先出价"; +"Gift.AuctionBid.UpcomingTitle" = "预先出价"; +"Gift.AuctionBid.Winning" = "您赢了!"; +"Gift.AuctionBid.YourBid" = "你的出价"; +"Gift.AuctionTransfer.Change" = "更改"; +"Gift.AuctionTransfer.Text" = "目前接收者是 **%@**。是否更改为 **%@**?"; +"Gift.AuctionTransfer.TextFromYourself" = "这份礼物目前接收者是您自己。是否更改为 **%@**?"; +"Gift.AuctionTransfer.TextToYourself" = "这份礼物目前接收者是 **%@**。是否更改为您自己?"; +"Gift.AuctionTransfer.Title" = "更改收件人"; +"Gift.Buy.AcceptsTonOnly" = "卖家仅接受 Gram 作为付款方式。"; +"Gift.Buy.Confirm.BuyFor#other" = "花费 %@ 颗星星购买"; +"Gift.Buy.Confirm.BuyForGrams#other" = "以 %@ Gram 购买"; +"Gift.Buy.Confirm.BuyForTon" = "以 %@ Gram 购买"; +"Gift.Buy.Confirm.GiftText" = "您确定要以 %2$@ 的价格购买 **%1$@** 并赠送给 **%3$@** 吗?"; +"Gift.Buy.Confirm.Text" = "您确定要以 %2$@ 的价格购买 %1$@ 吗?"; +"Gift.Buy.Confirm.Text.Stars#other" = "**%@** 颗星星"; +"Gift.Buy.Confirm.Title" = "确认付款"; +"Gift.Buy.ErrorPriceChanged.Text" = "价格已由 **%1$@** 涨至 **%2$@**。仍要购买该礼物吗?"; +"Gift.Buy.ErrorPriceChanged.Text.Stars#other" = "%@ 颗星星"; +"Gift.Buy.ErrorPriceChanged.Title" = "警告"; +"Gift.Buy.ErrorTooEarly.Text" = "您可以通过 %@ 购买此礼物。"; +"Gift.Buy.ErrorTooEarly.Title" = "稍后再试"; +"Gift.Buy.ErrorUnknown" = "发生错误,请重试。"; +"Gift.Buy.PayInStars" = "使用星星付款"; +"Gift.Buy.PayInTon" = "以 Gram 付款"; +"Gift.Buy.PayInTon.Tooltip" = "使用 Gram 付款可以避免再次转让礼物的21天等待期。"; +"Gift.Convert.Convert" = "转换"; +"Gift.Convert.Period.Days#other" = "%@ 天"; +"Gift.Convert.Period.Stars#other" = "%@ 颗星星"; +"Gift.Convert.Period.Text" = "您要将来自 **%2$@** 的礼物兑换为 **%3$@** 吗?\n\n兑换可在接下来的 %1$@ 天内进行。\n\n此操作无法撤销,将永久销毁该礼物。"; +"Gift.Convert.Period.Unavailable.Days#other" = "%@ 天"; +"Gift.Convert.Period.Unavailable.Text" = "抱歉,您无法兑换此礼物。\n\n星星只能在收到礼物后 %@ 内领取。"; +"Gift.Convert.Stars#other" = "%@ 颗星星"; +"Gift.Convert.Success.ChannelText" = "%1$@ 已转入频道余额。"; +"Gift.Convert.Success.ChannelText.Stars#other" = "已发送 **%@ 颗星星**"; +"Gift.Convert.Success.Text" = "你收到的是 **%1$@**。"; +"Gift.Convert.Success.Text.Stars#other" = "%@ 颗星星"; +"Gift.Convert.Success.Title" = "礼物已转换"; +"Gift.Convert.Text" = "您想将此礼物从 **%1$@** 转换为 **%2$@** 吗?\n\n此操作无法撤消。这将永久删除礼物。"; +"Gift.Convert.Title" = "将礼物转换为星星"; +"Gift.Craft.BackdropTooltip" = "制作出的礼物将有 **%1$@** 的概率带有 **%2$@** 背景。"; +"Gift.Craft.Craft" = "制作 %@"; +"Gift.Craft.Crafting.Description" = "如果制作失败,这些礼物\n将会丟失。"; +"Gift.Craft.Crafting.SuccessChance" = "%@% 成功率"; +"Gift.Craft.Crafting.Title" = "制作中"; +"Gift.Craft.CraftingFailed.CraftAnotherGift" = "制作另一件礼物"; +"Gift.Craft.CraftingFailed.Text#other" = "这次制作尝试失败了。 \n**%@ 件礼物**已丟失。"; +"Gift.Craft.CraftingFailed.Title" = "制作失败"; +"Gift.Craft.Description" = "最多可添加 ** 4 份礼物** 来制作全新的\n**$ %@**。\n\n如果制作失败,这些礼物\n将会丢失。"; +"Gift.Craft.Error.NotAvailable" = "其中一件礼物无法进行制作。"; +"Gift.Craft.Info.Description" = "将您的礼物升级为稀有、史诗和传奇版本。"; +"Gift.Craft.Info.Paragraph1.Text" = "选择最多 4 件礼物,制作全新专属型号。"; +"Gift.Craft.Info.Paragraph1.Title" = "获取稀有型号"; +"Gift.Craft.Info.Paragraph2.Text" = "组合更多礼物以提高成功率。"; +"Gift.Craft.Info.Paragraph2.Title" = "最大化成功率"; +"Gift.Craft.Info.Paragraph3.Text" = "使用相同属性的礼物来提升成功率。"; +"Gift.Craft.Info.Paragraph3.Title" = "影响结果"; +"Gift.Craft.Info.Title" = "礼物制作中"; +"Gift.Craft.Info.Understood" = "了解"; +"Gift.Craft.Select.NoGiftsFromCollection" = "您没有其他来自\n此收藏系列的礼物。"; +"Gift.Craft.Select.SaleGifts" = "精选特价礼物"; +"Gift.Craft.Select.SaleGiftsCount#other" = "%@ 份精选特价礼物"; +"Gift.Craft.Select.Title" = "选择礼物"; +"Gift.Craft.Select.YourGifts" = "您的礼物"; +"Gift.Craft.SuccessChanceSuffix" = "% 成功率"; +"Gift.Craft.SymbolTooltip" = "制作出的礼物将有 **%1$@** 的概率带有 **%2$@** 符号。"; +"Gift.Craft.Title" = "制作礼物"; +"Gift.Craft.Unavailable.Text" = "你将能够于 %@ 制作这份礼物。"; +"Gift.Craft.Unavailable.Title" = "稍后再试"; +"Gift.Craft.UnavailableBlockchain.Text" = "此礼物已记录在区块链上,无法作为制作的第一件礼物使用。"; +"Gift.Craft.UnavailableBlockchain.Title" = "制作功能不可用"; +"Gift.Craft.ViewVariants" = "查看所有可制作的型号 "; +"Gift.Demo.Description" = "礼物是可以交易或在个人资料页上展示的典藏品。"; +"Gift.Demo.Title" = "Telegram 礼物"; +"Gift.Demo.Tradable.Text" = "在 Telegram 或第三方 NFT 市场上出售您的礼物。"; +"Gift.Demo.Tradable.Title" = "可交易"; +"Gift.Demo.Understood" = "知道了"; +"Gift.Demo.Unique.Text" = "升级您的礼物以获得独特的编号、模型、背景和符号。"; +"Gift.Demo.Unique.Title" = "独特"; +"Gift.Demo.Wearable.Text" = "在您的个人资料页上展示礼物并将其设置为封面或状态。"; +"Gift.Demo.Wearable.Title" = "可佩戴"; +"Gift.Displayed.ChannelText" = "礼物现在展示在频道资料页上。"; +"Gift.Displayed.NewText" = "礼物现已在您的个人资料页上显示。"; +"Gift.Displayed.Text" = "此礼物现在可以在 [您的个人资料页]() 上看到。"; +"Gift.Displayed.Title" = "礼物已保存至个人资料"; +"Gift.Displayed.View" = "查看"; +"Gift.Hidden.ChannelText" = "礼物现已从频道资料页上隐藏。"; +"Gift.Hidden.NewText" = "此礼物已从您的个人资料页上移除。"; +"Gift.Hidden.Text" = "礼物现已在 [您的个人资料页]() 中隐藏。"; +"Gift.Hidden.Title" = "礼物已从个人资料页上移除"; +"Gift.Offer.Duration" = "出价有效期"; +"Gift.Offer.Duration.Hours#other" = "%@ 小时"; +"Gift.Offer.DurationDescriptionStars" = "选择 **%@** 接受您出价的有效期限。过期后,将自动退还您的 **星星**。"; +"Gift.Offer.DurationDescriptionTon" = "选择 **%@** 接受您出价的有效期限。过期后,将自动退还您的 **Gram**。"; +"Gift.Offer.GiftMinAmountToast.Text" = "你不能为这份礼物提出低于 %@ 的报价"; +"Gift.Offer.Offer" = "报价"; +"Gift.Offer.OfferStars" = "星星报价"; +"Gift.Offer.OfferTon" = "Gram 报价"; +"Gift.Offer.PriceDescriptionStars" = "输入您想为 **%@** 出价的星星数量。"; +"Gift.Offer.PriceDescriptionTon" = "输入您想为 **%@** 出价的 Gram 数量。"; +"Gift.Offer.PricePlaceholder" = "价格"; +"Gift.Offer.PriceSectionStars" = "星星报价"; +"Gift.Offer.PriceSectionTon" = "Gram 报价"; +"Gift.Offer.Title" = "出价购买"; +"Gift.Options.Collectibles.Text" = "典藏品礼物是您可以交换或出售的独特数位资产。"; +"Gift.Options.Gift.Auction" = "拍卖"; +"Gift.Options.Gift.BuyLimitReached#other" = "您已经发送了 **%@** 份同类礼物,已达上限。"; +"Gift.Options.Gift.Filter.AllGifts" = "所有礼物"; +"Gift.Options.Gift.Filter.Collectibles" = "典藏品"; +"Gift.Options.Gift.Filter.InStock" = "现货"; +"Gift.Options.Gift.Filter.Limited" = "限量"; +"Gift.Options.Gift.Filter.MyGifts" = "我的礼物"; +"Gift.Options.Gift.Filter.Resale" = "转售"; +"Gift.Options.Gift.JoinAuction" = "进入\n"; +"Gift.Options.Gift.Limited" = "限量"; +"Gift.Options.Gift.Limited.Left#other" = "剩余 %@ 个"; +"Gift.Options.Gift.Premium" = "高级版\n"; +"Gift.Options.Gift.Premium.Left#other" = "剩余 %@ 个"; +"Gift.Options.Gift.Resale" = "转售"; +"Gift.Options.Gift.SoldOut" = "售罄"; +"Gift.Options.Gift.Soon" = "即将开始"; +"Gift.Options.Gift.Text" = "给 **%@** 赠送礼物,可保留在个人资料中或兑换为星星。[星星是什么 >]()"; +"Gift.Options.Gift.Title" = "发送礼物"; +"Gift.Options.Gift.Transfer" = "转让"; +"Gift.Options.Gift.ViewAuction" = "查看\n"; +"Gift.Options.GiftChannel.Text" = "选择一份礼物来表达对 **%@** 的感谢。"; +"Gift.Options.GiftChannel.Title" = "发送礼物"; +"Gift.Options.GiftLocked.Title" = "礼物已锁定"; +"Gift.Options.GiftSelf.Text" = "给自己买一份礼物并添加至您的个人资料页或留作日后使用。\n\n限量版礼物升级为典藏品后可以赠送给他人。"; +"Gift.Options.GiftSelf.Title" = "购买礼物"; +"Gift.Options.Premium.Months#other" = "%@ 个月"; +"Gift.Options.Premium.OrStars" = "或 %@"; +"Gift.Options.Premium.Premium" = "高级版"; +"Gift.Options.Premium.Text" = "让 **%@** 享有 Telegram 高级版专属功能。 [查看新功能 >]()"; +"Gift.Options.Premium.Title" = "赠送 Telegram 高级版订阅"; +"Gift.Options.Premium.Years#other" = "%@ 年"; +"Gift.Options.SoldOut.Text" = "抱歉, 此礼物已售罄。"; +"Gift.PremiumOrStars.Title" = "赠送礼物"; +"Gift.RemoveDetails.Action" = "花费 %@ 移除"; +"Gift.RemoveDetails.Success" = "已移除 %@ 的描述!"; +"Gift.RemoveDetails.Text" = "您是否要永久移除此礼物的描述?"; +"Gift.RemoveDetails.Title" = "移除描述"; +"Gift.Resale.Unavailable.Text" = "您可以在 %@ 后转售此礼物。"; +"Gift.Resale.Unavailable.Title" = "稍后再试"; +"Gift.Sell.Confirm.ListFor#other" = "以 %@ 颗星星上架"; +"Gift.Sell.Confirm.ListForGrams#other" = "以 %@ Gram 上架"; +"Gift.Sell.Confirm.ListForTon" = "以 %@ Gram 上架"; +"Gift.Sell.Confirm.Text" = "您想以 %2$@ 的价格上架 **%1$@** 吗?"; +"Gift.Sell.Confirm.Title" = "确认出售"; +"Gift.Send.AvailableForResale" = "可转售"; +"Gift.Send.Buy" = "购买礼物条件"; +"Gift.Send.Customize.Info" = "只有 %@ 能看到你的消息。"; +"Gift.Send.Customize.MessagePlaceholder" = "输入消息(可选)"; +"Gift.Send.Customize.Title" = "自定义您的礼物"; +"Gift.Send.ErrorDisallowed" = "**%@** 不接受此型礼物。"; +"Gift.Send.ErrorOutOfStock" = "抱歉,此礼物已售罄。请选择其他礼物。"; +"Gift.Send.ErrorUnknown" = "发生错误,请重试。"; +"Gift.Send.GiftsDisallowed" = "%@ 不接受礼物。"; +"Gift.Send.HideMyName" = "隐藏送礼者"; +"Gift.Send.HideMyName.Info" = "向 %1$@ 个人资料页的访客隐藏您的名称和消息。%2$@ 仍会看到您的名称和消息。"; +"Gift.Send.Limited" = "限量"; +"Gift.Send.Limited.Success.Text#other" = "您可以再送出 **%@** 份。"; +"Gift.Send.Limited.Success.Text.None" = "您已达到此礼物的限额。"; +"Gift.Send.Limited.Success.Title" = "已送出礼物!"; +"Gift.Send.PayWithStars" = "支付 %@"; +"Gift.Send.PayWithStars.Info" = "你的余额为 **%@**。 [获取更多星星 >]()"; +"Gift.Send.Premium.Confirmation.Confirm" = "确认"; +"Gift.Send.Premium.Confirmation.Text" = "你确定要花费 **%2$@** 将 **Telegram 高级版** 赠送给 **%1$@** 吗?"; +"Gift.Send.Premium.Confirmation.Text.Stars#other" = "%@ 颗星星"; +"Gift.Send.Premium.Confirmation.Title" = "赠送礼物"; +"Gift.Send.Remains#other" = "剩余 %@ 个"; +"Gift.Send.Send" = "赠送礼物给"; +"Gift.Send.SendShort" = "发送"; +"Gift.Send.Sold#other" = "%@ 已售出"; +"Gift.Send.Success" = "您向 **%1$@** 赠送了一个价值** %2$@** 颗星星的礼物。"; +"Gift.Send.Success.Stars#other" = "%@ 颗星星"; +"Gift.Send.Title" = "赠送礼物"; +"Gift.Send.TitleTo" = "送礼物给 %@"; +"Gift.Send.Upgrade" = "以 %@ 创造独家典藏品"; +"Gift.Send.Upgrade.ForcedInfo" = "%1$@ 仅接受独家礼物。 [了解更多 >]()"; +"Gift.Send.Upgrade.Info" = "启用此选项可让 %1$@ 将您的礼物转换为独家典藏品。 **了解更多 >**"; +"Gift.SendChannel.HideMyName.Info" = "你可以对此频道的所有访客(管理员除外)隐藏您的名字和消息。"; +"Gift.SendChannel.Title" = "礼物预览"; +"Gift.SendChannel.Upgrade.Info" = "开启此功能以允许 %@ 的管理员将您的礼物转换为独家典藏品。[了解更多 >]()"; +"Gift.SendSelf.HideMyName" = "隐藏我的名字"; +"Gift.SendSelf.HideMyName.Info" = "向访客隐藏我的个人资料页面中名称和消息。"; +"Gift.SendSelf.Title" = "送给自己"; +"Gift.Setup.AuctionInfo" = "%@ 将发放给出价最高的前 %@。[了解更多 >]()"; +"Gift.Setup.AuctionInfo.Bidders#other" = "%@ 名出价者"; +"Gift.Setup.AuctionInfo.Gifts#other" = "%@ 个礼物"; +"Gift.Setup.PlaceBid" = "出价"; +"Gift.Store.AllListings" = "所有礼物"; +"Gift.Store.Balance.MyStars" = "我的星星"; +"Gift.Store.Balance.MyTon" = "我的 Gram"; +"Gift.Store.ClearFilters" = "清除筛选"; +"Gift.Store.EmptyResults" = "没有符合的礼物"; +"Gift.Store.Filter.Backdrop" = "背景"; +"Gift.Store.Filter.Model" = "型号"; +"Gift.Store.Filter.Selected.Backdrop#other" = "%@ 个背景"; +"Gift.Store.Filter.Selected.Model#other" = "%@ 个型号"; +"Gift.Store.Filter.Selected.Symbol#other" = "%@ 种符号"; +"Gift.Store.Filter.Symbol" = "符号"; +"Gift.Store.ForResale#other" = "共 %@ 件"; +"Gift.Store.ForResaleNoResults" = "无结果"; +"Gift.Store.NoResults" = "无结果"; +"Gift.Store.Search" = "搜索"; +"Gift.Store.SelectAll" = "全部选择"; +"Gift.Store.ShowStarsListings" = "仅显示星星礼物"; +"Gift.Store.Sort.Date" = "日期"; +"Gift.Store.Sort.Number" = "数量"; +"Gift.Store.Sort.Price" = "价格"; +"Gift.Store.SortByDate" = "按日期排序"; +"Gift.Store.SortByNumber" = "按数量排序"; +"Gift.Store.SortByPrice" = "按价格排序"; +"Gift.Store.StarsOnlyListings" = "仅限星星"; +"Gift.Transfer.Confirmation.Text" = "你是否要花费 %3$@ 将 %1$@ 的拥有权转让给 %2$@?"; +"Gift.Transfer.Confirmation.Text.Stars#other" = "%@ 颗星星"; +"Gift.Transfer.Confirmation.TextFree" = "您想要将 **%1$@** 的所有权转让给 **%2$@** 吗?"; +"Gift.Transfer.Confirmation.Title" = "确认转移"; +"Gift.Transfer.Confirmation.Transfer" = "转让条件"; +"Gift.Transfer.Confirmation.TransferFree" = "转让"; +"Gift.Transfer.SendUnlocks" = "%@ 天后解锁"; +"Gift.Transfer.SendUnlocks.Days#other" = "%@ 天"; +"Gift.Transfer.SendViaBlockchain" = "通过区块链发送"; +"Gift.Transfer.Success" = "你已成功将 **%1$@** 赠送给 **%2$@**。"; +"Gift.Transfer.Title" = "转让"; +"Gift.Transfer.Unavailable.Text" = "您可以在 %@ 后转让此礼物。"; +"Gift.Transfer.Unavailable.Title" = "稍后再试"; +"Gift.Transfer.UnlockPending.Text" = "%@ 天后,您能够将此典藏品发送到 Telegram 以外的任何 TON 区块链地址进行销售或拍卖。"; +"Gift.Transfer.UnlockPending.Text.Days#other" = "%@ 天"; +"Gift.Transfer.UnlockPending.Title" = "正在解锁中"; +"Gift.Transfer.UpdateRequired.Text" = "Telegram 支持将礼物转账至区块链的版本目前正在 Play 商店审核中,请耐心等待几天。"; +"Gift.Transfer.UpdateRequired.Title" = "需要更新"; +"Gift.UnavailableAction.OpenFragment" = "打开 Fragment"; +"Gift.UnavailableAction.Text" = "将此礼物转让到 Fragment 上的 Telegram 账号以解锁此操作。"; +"Gift.UnavailableAction.Title" = "操作已锁定"; +"Gift.Unique.AttributeDescription" = "只有 %@ 的此类典藏品具有此属性。"; +"Gift.Unique.Availability" = "剩余数量"; +"Gift.Unique.Backdrop" = "背景"; +"Gift.Unique.Collectible" = "典藏品"; +"Gift.Unique.CollectibleBy" = "%2$@ 持有的典藏品 %1$@"; +"Gift.Unique.Issued" = "已送出 %@ 个"; +"Gift.Unique.LearnMore" = "了解更多"; +"Gift.Unique.Model" = "型号"; +"Gift.Unique.OriginalInfo" = "已于 %2$@ 赠送给 %1$@。"; +"Gift.Unique.OriginalInfoSender" = "由 %1$@ 于 %3$@ 赠送给 %2$@。"; +"Gift.Unique.OriginalInfoSenderWithText" = "由 %1$@ 于 %3$@ 赠送给 %2$@,留言为:“%4$@”。"; +"Gift.Unique.OriginalInfoWithText" = "于 %2$@ 赠送给 %1$@,留言为:“%3$@”。"; +"Gift.Unique.Owner" = "所有者"; +"Gift.Unique.Symbol" = "符号"; +"Gift.Unique.Telegram" = "Telegram"; +"Gift.Unique.Transfer" = "转让"; +"Gift.Unique.Value" = "价值"; +"Gift.Unpin.Replace" = "更换"; +"Gift.Unpin.Subtitle" = "选择下方要取消置顶的礼物:"; +"Gift.Unpin.Title" = "置顶的礼物过多"; +"Gift.Unpin.Unpin" = "取消置顶"; +"Gift.Upgrade.AddChannelName" = "将频道礼物添加到礼物中"; +"Gift.Upgrade.AddMyName" = "将我的名字添加到礼物上"; +"Gift.Upgrade.AddName" = "将送礼者的名字添加到礼物上"; +"Gift.Upgrade.AddNameAndComment" = "添加送礼者的名字和留言"; +"Gift.Upgrade.Confirm" = "确认"; +"Gift.Upgrade.Description" = "将你的礼物转换为可以转让或拍卖的独家典藏品。"; +"Gift.Upgrade.Gift.Sent.GiftMore" = "赠送更多"; +"Gift.Upgrade.Gift.Sent.Text" = "已送出升级!"; +"Gift.Upgrade.GiftDescription" = "让 %@ 将这份礼物变成一件独特的收藏品。"; +"Gift.Upgrade.GiftTitle" = "独家定制"; +"Gift.Upgrade.GiftUpgrade" = "支付 %@ 并升级"; +"Gift.Upgrade.IncludeDescription" = "让 %@ 将此礼物转换为独家典藏品。"; +"Gift.Upgrade.IncludeTitle" = "独家定制"; +"Gift.Upgrade.PriceWillDecrease" = "价格将在 {m}:{s} 后下降"; +"Gift.Upgrade.SeePriceDecrease" = "查看价格会如何下降 >"; +"Gift.Upgrade.Skip" = "跳过"; +"Gift.Upgrade.Soon" = "即将推出"; +"Gift.Upgrade.Succeed.Text" = "你已收到礼物的独家编号,型号,背景和符号。"; +"Gift.Upgrade.Succeed.Title" = "礼物现在可以转换为典藏品"; +"Gift.Upgrade.Title" = "升级礼物"; +"Gift.Upgrade.Tradable.Description" = "在第三方 NFT 市场上出售或拍卖你的礼物。"; +"Gift.Upgrade.Tradable.GiftDescription" = "%@ 将能够在第三方 NFT 市场上拍卖该礼物。"; +"Gift.Upgrade.Tradable.IncludeDescription" = "收件人将能够在第三方 NFT 市场上拍卖礼物。"; +"Gift.Upgrade.Tradable.Title" = "可交易"; +"Gift.Upgrade.Transferable.Description" = "将升级后的礼物赠送给 Telegram 上的任何朋友。"; +"Gift.Upgrade.Transferable.GiftDescription" = "%@ 可以将此礼物发送给 Telegram 上的任何人。"; +"Gift.Upgrade.Transferable.IncludeDescription" = "收件人能够将礼物发送给 Telegram 上的任何人。"; +"Gift.Upgrade.Transferable.Title" = "可转让"; +"Gift.Upgrade.Unique.Description" = "为您的礼物取得专属编号、模型、背景和符号。"; +"Gift.Upgrade.Unique.GiftDescription" = "%@ 将获得专属编号、款式、背景和图标的礼物。"; +"Gift.Upgrade.Unique.IncludeDescription" = "收礼者将获得礼物的独家编号、模型、背景和符号。"; +"Gift.Upgrade.Unique.Title" = "独家"; +"Gift.Upgrade.Upgrade" = "升级"; +"Gift.Upgrade.UpgradeFor" = "花费 %@ 升级"; +"Gift.Upgrade.UpgradeNext" = "升级下一个礼物"; +"Gift.Upgrade.ViewAllVariants" = "查看所有选项"; +"Gift.Upgrade.Wearable.Text" = "在您的个人资料页上展示礼物,并将其设置为个人资料封面或状态。"; +"Gift.Upgrade.Wearable.Title" = "可佩带"; +"Gift.UpgradeCost.AdditionalDescription" = "升级成本每分钟下降。"; +"Gift.UpgradeCost.Description" = "抢先升级礼物的用户将获得编号更短的典藏品。"; +"Gift.UpgradeCost.Done" = "了解"; +"Gift.UpgradeCost.Stars#other" = "%@ 颗星星"; +"Gift.UpgradeCost.Title" = "升级价格"; +"Gift.Value.AveragePrice" = "平均价格"; +"Gift.Value.AveragePriceInfo" = "**%1$@** 是过去一个月内在 Telegram 和 Fragment 上架的 %2$@ 礼物的平均售价。"; +"Gift.Value.DescriptionAveragePrice" = "过去一个月内 **%@** 礼物在 Telegram 和 Fragment 上的平均售价。"; +"Gift.Value.DescriptionLastPriceFragment" = "这是 **%@** 在 Fragment 上的最新成交价。"; +"Gift.Value.DescriptionLastPriceTelegram" = "这是 **%@** 在 Telegram 上的最新成交价。"; +"Gift.Value.ForSaleOnFragment" = "在 Fragment 上出售"; +"Gift.Value.ForSaleOnTelegram" = "在 Telegram 上出售"; +"Gift.Value.InitialPrice" = "初始价格"; +"Gift.Value.InitialSale" = "首次发售"; +"Gift.Value.LastPrice" = "最新价格"; +"Gift.Value.LastPriceInfo" = "**%1$@** 是 %2$@ 礼物在 Telegram 和 Fragment 上的最新价格。"; +"Gift.Value.LastSale" = "最新成交"; +"Gift.Value.MinimumPrice" = "最低价格"; +"Gift.Value.MinimumPriceInfo" = "**%1$@** 是在 Telegram 和 Fragment 上架的 %2$@ 礼物的最低价。"; +"Gift.Variants.Backdrop" = "背景"; +"Gift.Variants.Backdrops" = "背景"; +"Gift.Variants.CollectionInfo" = "此系列包含 %@"; +"Gift.Variants.CollectionInfo.Backdrop#other" = "**%@** 个独特背景"; +"Gift.Variants.CollectionInfo.CraftableModel#other" = "**%@** 种可制造的型号"; +"Gift.Variants.CollectionInfo.Model#other" = "**%@** 个独特型号"; +"Gift.Variants.CollectionInfo.Symbol#other" = "**%@** 个独特符号"; +"Gift.Variants.Model" = "型号"; +"Gift.Variants.Models" = "型号"; +"Gift.Variants.RandomTraits" = "随机特征"; +"Gift.Variants.Randomize" = "随机特征"; +"Gift.Variants.SelectedTraits" = "选定特征"; +"Gift.Variants.Symbol" = "符号"; +"Gift.Variants.Symbols" = "符号"; +"Gift.Variants.ViewCraftableModels" = "查看可制作的型号"; +"Gift.Variants.ViewPrimaryModels" = "查看主要型号"; +"Gift.View.Availability" = "送出情况"; +"Gift.View.Availability.NewOf" = "已售出 %1$@ ,剩余 %2$@ "; +"Gift.View.Availability.Of" = "%1$@ / %2$@"; +"Gift.View.BotDescription" = "您可以将此礼物添加至您的个人资料页或将其隐藏。"; +"Gift.View.BuyFor" = "购买"; +"Gift.View.Context.BuyOffer" = "出价购买"; +"Gift.View.Context.ChangePrice" = "更改价格"; +"Gift.View.Context.CopyLink" = "复制链接"; +"Gift.View.Context.Craft" = "制作"; +"Gift.View.Context.SetAsTheme" = "应用主题到..."; +"Gift.View.Context.Share" = "分享"; +"Gift.View.Context.Transfer" = "转让"; +"Gift.View.Context.ViewInProfile" = "在个人资料页上查看"; +"Gift.View.Convert" = "转换为 %@"; +"Gift.View.Convert.Stars#other" = "%@ 颗星星"; +"Gift.View.ConvertedDescription" = "您已将此礼物转换为 %@。 [更多关于星星的信息 >]()"; +"Gift.View.ConvertedDescription.Stars#other" = "%@ 颗星星"; +"Gift.View.CopiedAddress" = "TON 地址已拷贝到剪贴板。"; +"Gift.View.Date" = "日期"; +"Gift.View.Display" = "添加至我的资料页"; +"Gift.View.Display.Channel" = "添加到资料页上"; +"Gift.View.DisplayedInfo" = "这份礼物在您的个人资料页面可见 [查看 >]()"; +"Gift.View.DisplayedInfoChannelNew" = "此礼物可见。 [在人资料中隐藏 >]()"; +"Gift.View.DisplayedInfoHide" = "此礼物在您的个人资料页上可见。[隐藏 >]()"; +"Gift.View.DisplayedInfoHide.Channel" = "此礼物在您頻道資料頁上可見。 **隱藏 >**"; +"Gift.View.DisplayedInfoNew" = "此礼物在你的个人页面上可见。 [隐藏 >]()"; +"Gift.View.EqualsTo" = "相当于 ⭐️%@"; +"Gift.View.FirstSale" = "首次出售"; +"Gift.View.FreeUpgradeDescription" = "免费升级此礼物,将其转换为独家典藏品。"; +"Gift.View.FreeUpgradeOtherDescription" = "%1$@ 可以将此礼物转换为独家典藏品。"; +"Gift.View.From" = "来自"; +"Gift.View.GiftUpgrade" = "赠送升级"; +"Gift.View.Header.Share" = "分享"; +"Gift.View.Header.TakeOff" = "脱下"; +"Gift.View.Header.Transfer" = "转让"; +"Gift.View.Header.Wear" = "佩戴"; +"Gift.View.HiddenInfo" = "此礼物已被隐藏,只有你能看到它。"; +"Gift.View.HiddenInfo.Channel" = "此礼物已对你频道的访客隐藏。"; +"Gift.View.HiddenInfoChannelNew" = "此礼物已隐藏。[在个人资料中显示>]()"; +"Gift.View.HiddenInfoNew" = "此礼物在你的个人资料页上不可见。 [显示 >]()"; +"Gift.View.HiddenInfoShow" = "此礼物是隐藏的,仅您可见。 [显示 >]()"; +"Gift.View.HiddenInfoShow.Channel" = "此礼物对频道访客不可见。[显示 >]()"; +"Gift.View.HiddenName" = "隐藏用户"; +"Gift.View.Hide" = "从我的个人资料页上隐藏"; +"Gift.View.KeepOrConvertDescription" = "您可以将此礼物保留在您的个人资料页上或将其转换为 %@。[更多关于星星 >]()"; +"Gift.View.KeepOrConvertDescription.Channel" = "您的频道可以将此礼物添加到资料页中,或将其转换为 %@。[了解更多星星 >]()"; +"Gift.View.KeepOrConvertDescription.Stars#other" = "%@ 颗星星"; +"Gift.View.KeepUpgradeOrConvertDescription" = "你可以保留、升级或以 %@ 的价格出售此礼物。 [更多关于星星 >]()"; +"Gift.View.KeepUpgradeOrConvertDescription.Channel" = "你的频道可以保留、升级或以 %@ 的价格出售此礼物。 [更多关于星星 >]()"; +"Gift.View.LastSale" = "最新成交"; +"Gift.View.NameAndMessageHidden" = "只有您可以看到发件人的姓名和信息。"; +"Gift.View.NameHidden" = "只有您可以看到送出礼物者的名称。"; +"Gift.View.NoConvertDescription" = "当此礼物可以升级时,我们将通知您。"; +"Gift.View.OnSale" = "特价 %@ "; +"Gift.View.OpenChatTheme" = "该礼物是聊天主题。[更改主题 >]()"; +"Gift.View.OtherDescription" = "%1$@ 可以将此礼物添加至个人资料页或将其转换为 %2$@。 [更多关于星星的信息 >]()"; +"Gift.View.OtherDescription.Stars#other" = "%@ 颗星星"; +"Gift.View.OtherNoConvertDescription" = "当此礼物可以升级时,我们将通知 %1$@。"; +"Gift.View.Outgoing.NameAndMessageHidden" = "只有 %@ 可以看到你的名称和消息。"; +"Gift.View.Outgoing.NameHidden" = "只有 %@ 可以看到您的名称。"; +"Gift.View.PutOn" = "您已佩戴 %@"; +"Gift.View.ReceivedTitle" = "收到礼物"; +"Gift.View.ReleasedBy" = "由 %@ 发布"; +"Gift.View.Resale.List.Success" = "%@ 现已出售!"; +"Gift.View.Resale.Relist.Success" = "%1$@ 已重新上架,价格为 %2$@。"; +"Gift.View.Resale.Relist.Success.Stars#other" = "%@ 颗星星"; +"Gift.View.Resale.Success.Text" = "%@ 已收到关于您礼物的通知。"; +"Gift.View.Resale.Success.Title" = "已送出礼物"; +"Gift.View.Resale.SuccessYou.Text" = "%@ 现在属于你了。"; +"Gift.View.Resale.SuccessYou.Title" = "已获得礼物"; +"Gift.View.Resale.Unlist.Success" = "%@ 已停止销售!"; +"Gift.View.Resale.Unlist.Text" = "此物品将不再出售。"; +"Gift.View.Resale.Unlist.Title" = "下架此礼物吗?"; +"Gift.View.Resale.Unlist.Unlist" = "下架"; +"Gift.View.Sale" = "以 %@ 颗星星的价格售出"; +"Gift.View.Sale.Stars#other" = "%@ 颗星星"; +"Gift.View.Self.Description" = "您可以将此礼物展示添加至个人资料页,或者将其转换为独一无二的可转赠收藏品。"; +"Gift.View.Self.Title" = "已保存的礼物"; +"Gift.View.Sell" = "出售"; +"Gift.View.SellingGiftInfo" = "%@ 正在出售该礼物,您可以购买。"; +"Gift.View.Send" = "发送礼物"; +"Gift.View.SenderInfo" = "**%@** 于 **%@** 向您赠送了此礼物。"; +"Gift.View.Status" = "状态"; +"Gift.View.Status.NonUnique" = "非独家"; +"Gift.View.Status.Upgrade" = "升级"; +"Gift.View.Title" = "礼物"; +"Gift.View.TonGiftAddressInfo" = "此礼物位于 TON 区块链上。 [查看 >*]()"; +"Gift.View.TookOff" = "您已取下 %@"; +"Gift.View.TooltipPremiumWearing" = "订阅 [Telegram 高级版]() 以佩戴典藏品。"; +"Gift.View.UnavailableDescription" = "此礼物已售罄"; +"Gift.View.UnavailableTitle" = "已售罄"; +"Gift.View.UniqueHiddenInfo" = "此礼物不会显示在你的个人资料页上。"; +"Gift.View.UniqueHiddenInfo.Channel" = "此礼物不会显示在你频道资料页上。"; +"Gift.View.Unknown.Title" = "礼物"; +"Gift.View.Unlist" = "取消列出"; +"Gift.View.Upgrade" = "升级"; +"Gift.View.UpgradeDescription" = "升级此礼物,将其转换为独家典藏品。"; +"Gift.View.UpgradeForFree" = "免费升级"; +"Gift.View.UpgradeFreeDescription" = "免费升级这份礼物,将其变成独特的收藏品。"; +"Gift.View.UpgradedDescription" = "这份礼物已转换为独家典藏品。"; +"Gift.View.Value" = "价值"; +"Gift.View.ViewUpgraded" = "查看已升级的礼物"; +"Gift.View.Visibility" = "可见性"; +"Gift.View.Visibility.Hide" = "隐藏"; +"Gift.View.Visibility.Visible" = "在您的个人资料页可见"; +"Gift.Wear.Badge.ChannelText" = "此礼物将显示在你的频道名称旁边,並带有闪耀图标。"; +"Gift.Wear.Badge.Text" = "此礼物将以闪耀图标的形式显示在你的名字旁边。"; +"Gift.Wear.Badge.Title" = "光芒徽章"; +"Gift.Wear.Design.ChannelText" = "你的频道资料页将使用与此礼物相同的背景和符号。"; +"Gift.Wear.Design.Text" = "您的个人资料页将套用此物品的背景和符号。"; +"Gift.Wear.Design.Title" = "独特的个人资料页设计"; +"Gift.Wear.GetBenefits" = "并获得以下权益:"; +"Gift.Wear.Proof.ChannelText" = "点击频道名称旁边的图标将显示礼物的所有者和属性。"; +"Gift.Wear.Proof.Text" = "点击您名字旁的图标将显示礼物的所有者和属性。"; +"Gift.Wear.Proof.Title" = "所有权证明"; +"Gift.Wear.Start" = "开始佩戴"; +"Gift.Wear.Wear" = "佩戴 %@"; +"Gift.WearPreview.FreeUpgrade" = "免费\n升级"; +"Gift.WearPreview.LearnMore" = "了解更多佩戴 Telegram 礼物的信息 >"; +"Gift.WearPreview.Limited" = "限量"; +"Gift.WearPreview.Upgraded" = "升级后"; +"Gift.Withdraw.ComeBackLater" = "请稍后再试"; +"Gift.Withdraw.EnterPassword.Done" = "继续"; +"Gift.Withdraw.EnterPassword.Text" = "请输入你的兩两步验证密码以完成此操作。"; +"Gift.Withdraw.EnterPassword.Title" = "输入验证码"; +"Gift.Withdraw.Proceed" = "打开 Fragment"; +"Gift.Withdraw.SecurityCheck" = "安全检查"; +"Gift.Withdraw.SecurityRequirements" = "如果满足以下条件,則可以提取礼物: \n\n• 你的账号已在 **7 天** 前启用两步验证。 \n\n• 你已在 **24 小时** 前登录此设备。"; +"Gift.Withdraw.SetupTwoStepAuth" = "启用两步验证"; +"Gift.Withdraw.Text" = "你可以使用第三方服务 Fragment 将 **%@** 转移到 TON 区块链。之后你可以用任何 TON 钱包应用将其作为 NFT 管理。\n\n你也可以通过 Fragment 将此类 NFT 转回到你的 Telegram 账号。"; +"Gift.Withdraw.Title" = "通过 Fragment 管理"; +"GiftLink.Date" = "日期"; +"GiftLink.Description" = "此链接允许您启用 **Telegram 高级版** 订阅。"; +"GiftLink.Footer" = "您也可以 [发送此链接]() 给好友作为礼物。"; +"GiftLink.From" = "来自"; +"GiftLink.Gift" = "赠礼"; +"GiftLink.LinkHidden" = "只有收件人可以看到链接。"; +"GiftLink.LinkSharedToChat" = "礼物链接已转发到 **%@**"; +"GiftLink.LinkSharedToSavedMessages" = "礼物链接已转发到 **我的收藏**。"; +"GiftLink.NoRecipient" = "无接收者"; +"GiftLink.NotUsedFooter" = "此链接尚未启用。"; +"GiftLink.PersonalDescription" = "此链接允许 **%@** 启用 **Telegram 高级版** 订阅。"; +"GiftLink.PersonalUsedDescription" = "此链接允许 **%@** 启用 **Telegram 高级版** 订阅。"; +"GiftLink.Reason" = "事由"; +"GiftLink.Reason.Gift" = "你已被频道选中"; +"GiftLink.Reason.Giveaway" = "抽奖活动"; +"GiftLink.Reason.Unclaimed" = "未完成的抽奖活动"; +"GiftLink.TelegramPremium#other" = "%@ 个月的 Telegram 高级版"; +"GiftLink.Title" = "礼物链接"; +"GiftLink.To" = "给"; +"GiftLink.UnclaimedDescription" = "此链接允许任何人启用 **Telegram 高级版** 订阅。"; +"GiftLink.UseLink" = "使用链接"; +"GiftLink.UsedDescription" = "此链接用于启用 **Telegram 高级版** 订阅。"; +"GiftLink.UsedFooter" = "此链接已于 %@ 使用。"; +"GiftLink.UsedTitle" = "使用过的礼物链接"; +"GlobalAutodeleteSettings.ApplyChatsPlaceholder" = "选择聊天以启用 %@ 自毁计时器"; +"GlobalAutodeleteSettings.ApplyChatsSubject#other" = "%@ 个聊天"; +"GlobalAutodeleteSettings.ApplyChatsTitle" = "选择聊天"; +"GlobalAutodeleteSettings.ApplyChatsToast" = "你将 %1$@ 自毁计时器应用于 %2$@。"; +"GlobalAutodeleteSettings.AttemptDisabledChannelSelection" = "你需要管理员权限来启用自动删除。"; +"GlobalAutodeleteSettings.AttemptDisabledGenericSelection" = "你无法在该聊天中启用自动删除。"; +"GlobalAutodeleteSettings.AttemptDisabledGroupSelection" = "您必须是管理员才能启用自动删除。"; +"GlobalAutodeleteSettings.InfoDisabled" = "若果启用这项设置,你所创建的群聊内的信息在发送之后某个时间点为所有用户会被自动删除。这并不会影响你已经存在的群聊。"; +"GlobalAutodeleteSettings.InfoEnabled" = "您发起的聊天中的所有新消息将在发送后 %@ 为所有人自动删除。您还可以[将此设置应用于现有聊天]()。"; +"GlobalAutodeleteSettings.OptionTitle" = "%@ 后"; +"GlobalAutodeleteSettings.OptionsHeader" = "自毁定时器"; +"GlobalAutodeleteSettings.SetConfirmAction" = "启用自动删除"; +"GlobalAutodeleteSettings.SetConfirmText" = "您确定要于消息发送 %@ 后为所有人自动删除您新建的私聊和群组中的所有消息吗?"; +"GlobalAutodeleteSettings.SetConfirmTitle" = "阅后即焚计时器"; +"GlobalAutodeleteSettings.SetConfirmToastDisabled" = "你开启的新聊天中的消息将不会自动删除。"; +"GlobalAutodeleteSettings.SetConfirmToastEnabled" = "您发起的所有新聊天中的消息将在 %@ 后自动删除。"; +"GlobalAutodeleteSettings.SetCustomTime" = "设置自定义时间"; +"GlobalAutodeleteSettings.Title" = "自动删除信息"; +"Group.About.Help" = "您可以给您的群组添加一段简介。"; +"Group.AdminLog.AntiSpamFalsePositiveReportedText" = "Telegram 管理员会检查你的报告,感谢!"; +"Group.AdminLog.AntiSpamText" = "您可以在群组信息 > [管理员]() 中管理反垃圾设置。"; +"Group.AdminLog.AntiSpamTitle" = "Telegram 反垃圾"; +"Group.AdminLog.EmptyText" = "在过去的 48 小时内,群组成员/管理员没有执行任何操作。"; +"Group.Appearance.BoostInfo" = "群组成员可以 **助推** 群组使其升级并解锁这些功能。"; +"Group.Appearance.ChooseFromGallery" = "从相册中选择"; +"Group.Appearance.EmojiPackUpdated" = "群组表情包已升级。"; +"Group.Appearance.GroupEmoji" = "群组表情包"; +"Group.Appearance.GroupEmojiFooter" = "选择一个可供群组内所有成员使用的表情包。"; +"Group.Appearance.PreviewAuthor" = "Jessica"; +"Group.Appearance.ProfileFooter" = "为群组资料页选择颜色和图标。"; +"Group.Appearance.Status" = "群组表情符号状态"; +"Group.Appearance.StatusFooter" = "选择将显示在群组名称旁边的状态。"; +"Group.Appearance.Wallpaper" = "群组壁纸"; +"Group.Appearance.WallpaperFooter" = "设置对所有正在阅读您的群组的用户都可见的壁纸。"; +"Group.ApplyToJoin" = "申请加入群组"; +"Group.DeleteGroup" = "删除群组"; +"Group.Edit.PrivatePublicLinkAlert" = "请注意:如果您为群组添加了公开链接,所有人均可找到并且加入该群组。\n\n若您的群组需要保持私密,请不要创建该链接。"; +"Group.EditAdmin.MemberTagInfo" = "在 %@ 的名称旁添加简短标签。"; +"Group.EditAdmin.MemberTagTitle" = "成员标签"; +"Group.EditAdmin.PermissionChangeInfo" = "修改群组信息"; +"Group.EditAdmin.RankAdminPlaceholder" = "管理员"; +"Group.EditAdmin.RankInfo" = "群组成员将看到此头衔,而不是 “%@”。"; +"Group.EditAdmin.RankOwnerPlaceholder" = "所有者"; +"Group.EditAdmin.RankTitle" = "成员标签"; +"Group.EditAdmin.TransferOwnership" = "转让群组所有权"; +"Group.Emoji.Info" = "所有成员都可以在群组中使用这些表情,即使没有订阅 Telegram 高级版。"; +"Group.Emoji.NotFound" = "未找到此表情包"; +"Group.Emoji.NotFoundHelp" = "请重试或从下方列表中选择"; +"Group.Emoji.Placeholder" = "emojiset"; +"Group.Emoji.Title" = "群组表情包"; +"Group.Emoji.YourEmoji" = "选择表情包"; +"Group.ErrorAccessDenied" = "此群组不可用。"; +"Group.ErrorAddBlocked" = "抱歉,您无法将此用户添加到群组。"; +"Group.ErrorAddTooMuchAdmins" = "抱歉,此群组的管理员人数已达上限。"; +"Group.ErrorAddTooMuchBots" = "抱歉,此群组的机器人数量已达上限。"; +"Group.ErrorAdminsTooMuch" = "抱歉,此群组管理员人数已达上限。"; +"Group.ErrorNotMutualContact" = "抱歉,目前您只能将双向联系人添加到群组。"; +"Group.ErrorSendRestrictedMedia" = "此群组的管理员限制了您发送媒体文件。"; +"Group.ErrorSendRestrictedStickers" = "此群组的管理员已限制您发送贴纸。"; +"Group.ErrorSupergroupConversionNotPossible" = "抱歉,您加入的群组和频道过多,请在创建新的之前退出一些。"; +"Group.GroupMembersHeader" = "群组成员"; +"Group.Info.AdminLog" = "近期操作"; +"Group.Info.Members" = "成员"; +"Group.Info.Settings" = "群组设置"; +"Group.JoinGroup" = "加入群组"; +"Group.LeaveGroup" = "退出群组"; +"Group.LinkedChannel" = "已关联频道"; +"Group.Location.ChangeLocation" = "变更位置"; +"Group.Location.CreateInThisPlace" = "在此地创建一个群组"; +"Group.Location.Info" = "用户可以在“查找附近的人”功能中发现您的群组。"; +"Group.Location.Title" = "位置"; +"Group.Management.AddModeratorHelp" = "您可以添加管理员帮助您管理群组。"; +"Group.Management.AntiSpam" = "积极的反垃圾信息策略"; +"Group.Management.AntiSpamInfo" = "Telegram 会过滤更多的垃圾信息,但偶尔可能会影响普通消息。您可以在最近的操作中申诉误报。"; +"Group.Management.AntiSpamMagic" = "魔法"; +"Group.Members.AddMemberBotErrorNotAllowed" = "抱歉,您没有向此群组添加机器人的权限。"; +"Group.Members.AddMembers" = "添加成员"; +"Group.Members.AddMembersHelp" = "只有群组管理员才能看到此列表。"; +"Group.Members.Contacts" = "此群组内的联系人"; +"Group.Members.Other" = "其他成员"; +"Group.Members.Title" = "成员"; +"Group.MessagePhotoRemoved" = "群组头像已被移除"; +"Group.MessagePhotoUpdated" = "群组头像已更新"; +"Group.MessageVideoUpdated" = "群组头像已更新"; +"Group.OwnershipTransfer.DescriptionInfo" = "这将转让 **%1$@** 的所有权给 **%2$@**。\n\n您将不再被视为群组的创建者。 新的所有者可以自由地删除您的任何管理员权限,甚至封禁您。"; +"Group.OwnershipTransfer.DescriptionShortInfo" = "此将会把「**%1$@**」的**完整所有权**转让给 **%2$@**。"; +"Group.OwnershipTransfer.ErrorAdminsTooMuch" = "抱歉,目前本群的管理员太多,无法添加新所有者,请先移出一个现有管理员。"; +"Group.OwnershipTransfer.ErrorLocatedGroupsTooMuch" = "抱歉,目标用户已经有过多基于位置的群组了。 请让他们先删除其中一个。"; +"Group.OwnershipTransfer.ErrorPrivacyRestricted" = "抱歉,此用户不是该群组的成员,其隐私设置会阻止您手动添加。"; +"Group.OwnershipTransfer.Title" = "转让群组所有权"; +"Group.PublicLink.Info" = "用户可以与其他人分享此链接,并使用 Telegram 搜索找到您的群组。\n\n您可以使用字母 **a-z**,数字 **0-9** 以及下划线,最小长度为 **5** 个字符。"; +"Group.PublicLink.Placeholder" = "链接"; +"Group.PublicLink.Title" = "公开链接"; +"Group.RequestToJoinSent" = "已发送加入请求"; +"Group.RequestToJoinSentDescriptionGroup" = "管理员批准您的请求后,您将能够发送消息。"; +"Group.Setup.ActivateAlertShow" = "显示"; +"Group.Setup.ActivateAlertText" = "您想在群组信息页上显示此链接吗?"; +"Group.Setup.ActivateAlertTitle" = "激活链接"; +"Group.Setup.ActiveLimitReachedError" = "抱歉,现有活跃公开链接过多,请先隐藏一个活跃公开链接。"; +"Group.Setup.ApproveNewMembers" = "新成员审核"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksDisable" = "禁用"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksEnable" = "启用"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksPeerChannel" = "频道"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksPeerGroup" = "群组"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText" = "同时 %@ **%@** %@ 个现有邀请链接在在此 %@?"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.DisableChannel" = "同时禁用此频道中 **%@** 个现有邀请链接的 **批准新成员** 功能?"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.DisableGroup" = "同时禁用此群组中 **%@** 个现有邀请链接的 **批准新成员** 功能?"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.EnableChannel" = "同时启用此频道中 **%@** 个现有邀请链接的 **批准新成员** 功能?"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.EnableGroup" = "同时启用此群组中 **%@** 个现有邀请链接的 **批准新成员** 功能?"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksText.Links#other" = "%@ 个现有邀请链接"; +"Group.Setup.ApproveNewMembersApplyToExistingInviteLinksTitle" = "应用于现有的邀请链接?"; +"Group.Setup.ApproveNewMembersInfo" = "如果您希望用户只有在得到管理员批准后才能发送消息,请启用此选项。"; +"Group.Setup.ApproveNewMembersManagedBy" = "由 %@ 管理"; +"Group.Setup.BasicHistoryHiddenHelp" = "新成员只能看到最近 100 条消息。"; +"Group.Setup.DeactivateAlertHide" = "隐藏"; +"Group.Setup.DeactivateAlertText" = "是否要将此链接从群组信息页中隐藏?"; +"Group.Setup.DeactivateAlertTitle" = "停用链接"; +"Group.Setup.ForwardingChannelInfo" = "订阅者将可以拷贝、保存及转发该频道的内容。"; +"Group.Setup.ForwardingChannelInfoDisabled" = "订阅者将无法拷贝、保存或转发此频道的内容。"; +"Group.Setup.ForwardingChannelTitle" = "保存及拷贝内容"; +"Group.Setup.ForwardingDisabled" = "限制保存内容"; +"Group.Setup.ForwardingEnabled" = "允许保存内容"; +"Group.Setup.ForwardingGroupInfo" = "成员将可以拷贝、保存及转发此群组中的内容。"; +"Group.Setup.ForwardingGroupInfoDisabled" = "成员将无法拷贝、保存或转发此群组中的内容。"; +"Group.Setup.ForwardingGroupTitle" = "从该群组转发消息"; +"Group.Setup.HistoryHeader" = "新成员是否可见聊天记录"; +"Group.Setup.HistoryHidden" = "隐藏"; +"Group.Setup.HistoryHiddenHelp" = "新成员不能查看他们加入前的消息。"; +"Group.Setup.HistoryTitle" = "历史记录设置"; +"Group.Setup.HistoryVisible" = "可见"; +"Group.Setup.HistoryVisibleHelp" = "新成员将能查看他们加群前的消息记录。"; +"Group.Setup.LinkActive" = "活跃"; +"Group.Setup.LinkInactive" = "失效"; +"Group.Setup.LinksOrder" = "链接顺序"; +"Group.Setup.LinksOrderInfo" = "拖放链接以更改它们在群组信息页面上的显示顺序。"; +"Group.Setup.PublicLink" = "公开链接"; +"Group.Setup.TypeHeader" = "群组类型"; +"Group.Setup.TypePrivate" = "私密"; +"Group.Setup.TypePrivateHelp" = "私密群组只能通过他人的邀请或邀请链接加入。"; +"Group.Setup.TypePublic" = "公开"; +"Group.Setup.TypePublicHelp" = "任何人都可以搜索、加入公开群组,并查看历史聊天记录。"; +"Group.Setup.WhoCanSendMessages.Everyone" = "所有人"; +"Group.Setup.WhoCanSendMessages.OnlyMembers" = "仅成员"; +"Group.Setup.WhoCanSendMessages.Title" = "谁可以发送消息?"; +"Group.Status" = "群组"; +"Group.UpgradeConfirmation" = "请注意:此操作是不可逆的。超级群组不可能降级为普通群组。"; +"Group.UpgradeNoticeHeader" = "成员人数已达上限"; +"Group.UpgradeNoticeText1" = "升级为超级群组即可提升成员数量上限,并获得更多功能:"; +"Group.UpgradeNoticeText2" = "• 超级群组可以拥有 {supergroup_member_limit} 名成员\n• 新成员可以看到所有的历史聊天记录\n• 管理员能为所有人撤回消息\n• 消息通知默认为静音"; +"Group.Username.CreatePrivateLinkHelp" = "任何人都可以通过此链接加入您的群组。您可以随时撤销此链接。"; +"Group.Username.CreatePublicLinkHelp" = "所有人都可以与其他人分享此链接,并使用 Telegram 的搜索功能找到您的群组。"; +"Group.Username.InvalidEndsWithUnderscore" = "群组名称不能以下划线结尾。"; +"Group.Username.InvalidStartsWithNumber" = "公开链接不能以数字开头。"; +"Group.Username.InvalidStartsWithUnderscore" = "群组名称不能以下划线开头。"; +"Group.Username.InvalidTooShort" = "群组名称必须至少包含 5 个字符。"; +"Group.Username.RemoveExistingUsernamesFinalInfo" = "您保留了太多公共用户名。您可以撤消其中一个较旧群组或频道的链接,也可以创建一个私人链接。"; +"Group.Username.RemoveExistingUsernamesInfo" = "您保留了太多公共用户名。您可以撤消其中一个较旧群组或频道的链接,也可以创建一个私人链接。"; +"Group.Username.RemoveExistingUsernamesNoPremiumInfo" = "您保留了太多公开链接。您可以撤消其中一个较旧群组或频道的链接。我们正在努力能让您在未来提高此上限。"; +"Group.Username.RemoveExistingUsernamesOrExtendInfo" = "您保留了太多公开链接。您可以撤消其中一个较旧群组或频道的链接,或者订阅 **Telegram 高级版**,将此上限翻倍至 **%@** 个公开链接。"; +"Group.Username.RevokeExistingUsernamesInfo" = "您可以撤消其中一个较旧群组或频道的链接,也可以创建私人群组。"; +"GroupBoost.AdditionalFeatures" = "额外功能"; +"GroupBoost.AdditionalFeaturesText" = "通过获得 **助推**,您的群组可以达到更高的等级并解锁更多的功能。"; +"GroupBoost.AudioTranscription" = "语音转文字"; +"GroupBoost.BoostGroup" = "助推群组"; +"GroupBoost.BoostToUnrestrict" = "助推群组 **%1$@** 以移除消息限制。您的助推将有助于 **%2$@** 解锁新功能。"; +"GroupBoost.BoostToUnrestrict.Times#other" = "**%@** 次"; +"GroupBoost.CustomWallpaper" = "设置定义群组壁纸"; +"GroupBoost.EmojiPack" = "设置群组表情包"; +"GroupBoost.EmojiStatus" = "使用表情符号状态"; +"GroupBoost.EnableCustomWallpaperLevelText" = "您的群组需要达到 **等级 %1$@** 才能设置自定义群组壁纸。"; +"GroupBoost.EnableEmojiPackLevelText" = "您的群组需要达到 **等级 %1$@** 才能设置表情包。"; +"GroupBoost.EnableEmojiStatusLevelText" = "您的群组需要达到 **等级 %1$@** 才能使用表情状态。"; +"GroupBoost.EnableProfileColorLevelText" = "您的群组需要达到 **等级 %1$@** 才能更改群组封面颜色。"; +"GroupBoost.EnableProfileIconLevelText" = "您的群组需要达到 **等级 %1$@** 才能更改群组封面图标。"; +"GroupBoost.EnableStoriesText" = "您的群组需要 %1$@ 才能发布动态。 "; +"GroupBoost.EnableWallpaperLevelText" = "您的群组需要达到 **等级 %1$@** 才能设置群组壁纸。"; +"GroupBoost.Error.BoostTooOftenText" = "你每天只能改变一次助推的群,下次您可以在 **%@** 后重试。"; +"GroupBoost.Error.PremiumNeededText" = "只有 **Telegram 高级版** 订阅者可以助推群组。您想订阅 **Telegram 高级版** 吗?"; +"GroupBoost.IncreaseLimitText" = "您的群组还需要 %1$@ 才能发布 %2$@。"; +"GroupBoost.Info" = "您的群组成员可以**助推**群组并使其**升级**以获得**专属功能**。"; +"GroupBoost.MemberBoosted" = "**%1$@** 已助推 %2$@ 群组。"; +"GroupBoost.MemberBoosted.BoostForBadge" = "助推 **%1$@** 以帮助其解锁新功能并使您的消息上展示助推者 **徽章**。"; +"GroupBoost.MemberBoosted.Times#other" = "**%@** 次"; +"GroupBoost.MoreBoostsNeeded.Boosted.Level.Text" = "此群组已达到 **%@ 级** 并已解锁新功能。"; +"GroupBoost.PremiumUsersCanBoost" = "**高级版** 用户可以通过此链接助推您的群组:"; +"GroupBoost.ProfileColor" = "设置封面颜色"; +"GroupBoost.ProfileIcon" = "设置封面图标"; +"GroupBoost.Table.Group.EmojiPack" = "自定义表情包"; +"GroupBoost.Table.Group.VoiceToText" = "语音转文字"; +"GroupBoost.Title" = "助推群组"; +"GroupBoost.Title.Current" = "助推群组"; +"GroupBoost.Title.Other" = "帮助此群组升级"; +"GroupBoost.Wallpaper" = "设置群组壁纸"; +"GroupCall.RevokeLinkText" = "您确定要撒销此链接吗?一旦您这样做,没有人能夠使用它加入通话。"; +"GroupInfo.ActionAddRank" = "添加成员标签"; +"GroupInfo.ActionEditAdmin" = "修改管理员权限"; +"GroupInfo.ActionEditAdminRank" = "编辑管理员标签"; +"GroupInfo.ActionEditRank" = "编辑成员标签"; +"GroupInfo.ActionPromote" = "提权"; +"GroupInfo.ActionRemove" = "移除"; +"GroupInfo.ActionRestrict" = "限制"; +"GroupInfo.ActionSend" = "发送消息"; +"GroupInfo.AddParticipant" = "添加成员"; +"GroupInfo.AddParticipantConfirmation" = "是否将 %@ 添加到此群组?"; +"GroupInfo.AddParticipantTitle" = "联系人"; +"GroupInfo.AddRank" = "添加标签"; +"GroupInfo.AddUserLeftError" = "抱歉,若用户退出了群组,只有双向联系人才能将他们加回来(即需双方互相持有对方的手机号码)。\n\n注意只要对方不在被移除用户列表中,就仍可通过群组的邀请链接加入。"; +"GroupInfo.Administrators" = "管理员"; +"GroupInfo.Administrators.Title" = "管理员"; +"GroupInfo.ChannelListNamePlaceholder" = "频道名称"; +"GroupInfo.ChatAdmins" = "添加管理员"; +"GroupInfo.ConvertToSupergroup" = "转换为超级群组"; +"GroupInfo.DeactivatedStatus" = "群组已失效"; +"GroupInfo.DeleteAndExit" = "删除并退出"; +"GroupInfo.DeleteAndExitConfirmation" = "您将无法再次加入此群组,除非由另一位群组成员邀请。"; +"GroupInfo.FakeGroupWarning" = "⚠️ 警告:大量用户举报该账号冒名顶替名人或组织。"; +"GroupInfo.GroupHistory" = "新成员是否可见聊天记录"; +"GroupInfo.GroupHistoryHidden" = "隐藏"; +"GroupInfo.GroupHistoryShort" = "聊天记录"; +"GroupInfo.GroupHistoryVisible" = "可见"; +"GroupInfo.GroupNamePlaceholder" = "群组名称"; +"GroupInfo.GroupType" = "群组类型"; +"GroupInfo.InvitationLinkAcceptChannel" = "您确定要加入频道 “%@” 吗?"; +"GroupInfo.InvitationLinkDoesNotExist" = "抱歉,该群组似乎不存在。"; +"GroupInfo.InvitationLinkGroupFull" = "抱歉,此群组已满员。"; +"GroupInfo.InviteByLink" = "通过链接邀请加入群组"; +"GroupInfo.InviteLink.CopyAlert.Success" = "链接已拷贝到剪贴板。"; +"GroupInfo.InviteLink.CopyLink" = "拷贝链接"; +"GroupInfo.InviteLink.Help" = "任何已安装 Telegram 的用户都将可以通过此链接加入您的群组。"; +"GroupInfo.InviteLink.LinkSection" = "链接"; +"GroupInfo.InviteLink.RevokeAlert.Revoke" = "撤销"; +"GroupInfo.InviteLink.RevokeAlert.Success" = "之前的邀请链接已失效,新的链接已生成。"; +"GroupInfo.InviteLink.RevokeAlert.Text" = "您确定要撤销此邀请链接吗?一旦撤销,其他人将不能通过此链接加入此群组。"; +"GroupInfo.InviteLink.RevokeLink" = "撤销链接"; +"GroupInfo.InviteLink.ShareLink" = "分享链接"; +"GroupInfo.InviteLink.Title" = "邀请链接"; +"GroupInfo.InviteLinks" = "邀请链接"; +"GroupInfo.LabelAdmin" = "管理员"; +"GroupInfo.LabelOwner" = "所有者"; +"GroupInfo.LeftStatus" = "您已退出此群组"; +"GroupInfo.Location" = "位置"; +"GroupInfo.MemberRequests" = "加入请求"; +"GroupInfo.Notifications" = "通知"; +"GroupInfo.ParticipantCount#other" = "%@ 名成员"; +"GroupInfo.Permissions" = "权限"; +"GroupInfo.Permissions.AddException" = "添加例外"; +"GroupInfo.Permissions.BroadcastConvert" = "转换为广播群组"; +"GroupInfo.Permissions.BroadcastConvertInfo" = "广播组可以拥有超过%@名成员,但仅管理员可发消息。"; +"GroupInfo.Permissions.BroadcastTitle" = "广播群组"; +"GroupInfo.Permissions.ChargeForMessages" = "为消息收取星星"; +"GroupInfo.Permissions.ChargeForMessagesInfo" = "如果启用此功能,群组的普通成员必须支付星星才能发送消息。"; +"GroupInfo.Permissions.DontRestrictBoosters" = "不限制助推者"; +"GroupInfo.Permissions.DontRestrictBoostersEnableInfo" = "启用此功能可始终允许已助推您群组的用户发送消息和媒体。"; +"GroupInfo.Permissions.DontRestrictBoostersInfo" = "选择用户必须为群组助推几次才能不受发送消息的限制。"; +"GroupInfo.Permissions.EditingDisabled" = "您不能更改此权限。"; +"GroupInfo.Permissions.Exceptions" = "例外"; +"GroupInfo.Permissions.MessagePrice" = "设置每条消息的价格"; +"GroupInfo.Permissions.MessagePriceInfo" = "你的群组每有一条消息就会获取 %1$@ 设置费用(~%2$@)"; +"GroupInfo.Permissions.Removed" = "被移除的用户"; +"GroupInfo.Permissions.SearchPlaceholder" = "搜索例外"; +"GroupInfo.Permissions.SectionTitle" = "本群组成员有何权限?"; +"GroupInfo.Permissions.SlowmodeHeader" = "慢速模式"; +"GroupInfo.Permissions.SlowmodeInfo" = "选择每个成员必须等多长时间才能发送下一条消息。"; +"GroupInfo.Permissions.SlowmodeValue.Off" = "关"; +"GroupInfo.Permissions.Title" = "权限"; +"GroupInfo.PublicLink" = "公开链接"; +"GroupInfo.PublicLinkAdd" = "添加"; +"GroupInfo.ScamGroupWarning" = "⚠️ 警告:许多用户举报这个账号涉嫌欺诈。请小心,尤其是当它向你要钱的时候。"; +"GroupInfo.SetGroupPhoto" = "设置群组头像"; +"GroupInfo.SetGroupPhotoDelete" = "移除头像"; +"GroupInfo.SetGroupPhotoStop" = "停止"; +"GroupInfo.SetSound" = "设置铃声"; +"GroupInfo.SharedMedia" = "分享的媒体文件"; +"GroupInfo.SharedMediaNone" = "无"; +"GroupInfo.ShowMoreMembers#other" = "显示更多成员(%@)"; +"GroupInfo.Sound" = "铃声"; +"GroupInfo.Title" = "群组信息"; +"GroupInfo.TitleMembers#other" = "%@ 位成员"; +"GroupInfo.UpgradeButton" = "升级为超级群组"; +"GroupMembers.HideMembers" = "隐藏成员"; +"GroupMembers.MembersHiddenOff" = "打开此选项可隐藏该群组的成员列表。管理员将仍然可见。"; +"GroupMembers.MembersHiddenOn" = "禁用以显示本群所有成员"; +"GroupPermission.AddException" = "添加例外"; +"GroupPermission.AddMembersNotAvailable" = "您无权添加成员。"; +"GroupPermission.AddSuccess" = "例外已添加"; +"GroupPermission.AddedInfo" = "例外由 %1$@ %2$@ 添加"; +"GroupPermission.ApplyAlertAction" = "应用"; +"GroupPermission.ApplyAlertText" = "您已在%@中更改了此用户的权限 。\n应用此更改吗?"; +"GroupPermission.Delete" = "删除例外"; +"GroupPermission.Duration" = "时限"; +"GroupPermission.EditingDisabled" = "您不能编辑该用户的权限。"; +"GroupPermission.Member" = "成员"; +"GroupPermission.NewTitle" = "新建例外"; +"GroupPermission.NoAddMembers" = "无权添加"; +"GroupPermission.NoChangeInfo" = "无权更改群信息"; +"GroupPermission.NoEditRank" = "禁止编辑标签"; +"GroupPermission.NoManageTopics" = "无主题"; +"GroupPermission.NoPinMessages" = "无权置顶"; +"GroupPermission.NoSendFile" = "无权发送文件"; +"GroupPermission.NoSendGifs" = "无权发送 GIF "; +"GroupPermission.NoSendLinks" = "无权发送链接"; +"GroupPermission.NoSendMedia" = "无权发送媒体"; +"GroupPermission.NoSendMessages" = "无权发送消息"; +"GroupPermission.NoSendMusic" = "无权发送音频"; +"GroupPermission.NoSendPhoto" = "无权发送照片"; +"GroupPermission.NoSendPolls" = "无权发起投票"; +"GroupPermission.NoSendReactions" = "禁用表情回应"; +"GroupPermission.NoSendVideo" = "无权发送视频"; +"GroupPermission.NoSendVideoMessage" = "无权发送视频消息"; +"GroupPermission.NoSendVoiceMessage" = "无权发送语音消息"; +"GroupPermission.NotAvailableInDiscussionGroups" = "讨论群中无此权限。"; +"GroupPermission.NotAvailableInGeoGroups" = "基于位置的群组中无此权限。"; +"GroupPermission.NotAvailableInPublicGroups" = "公开群组不具有此权限。"; +"GroupPermission.PermissionDisabledByDefault" = "在群组权限中,该项已对所有成员停用。"; +"GroupPermission.PermissionGloballyDisabled" = "该权限已在此群组中停用。"; +"GroupPermission.SaveChanges" = "保存更改"; +"GroupPermission.SectionTitle" = "此成员访问权限管理"; +"GroupPermission.Title" = "例外"; +"GroupRemoved.AddToGroup" = "添加到群组"; +"GroupRemoved.DeleteUser" = "删除用户"; +"GroupRemoved.Remove" = "移除用户"; +"GroupRemoved.RemoveInfo" = "被管理员移除的用户不能通过邀请链接再次加入群组。"; +"GroupRemoved.Title" = "被移除的用户"; +"GroupRemoved.UsersSectionTitle" = "被移除的用户"; +"GroupRemoved.ViewChannelInfo" = "查看频道"; +"GroupRemoved.ViewUserInfo" = "查看用户信息"; +"HashtagSearch.AllChats" = "所有聊天"; +"HashtagSearch.ClearRecent" = "清除历史记录"; +"HashtagSearch.FoundInfoFormat" = "查看带有 %2$@ 的 %1$@"; +"HashtagSearch.FoundPosts" = "贴文"; +"HashtagSearch.FoundStories" = "动态"; +"HashtagSearch.LocalStoriesFound" = "%1$@ / %2$@"; +"HashtagSearch.MyMessages" = "我的聊天"; +"HashtagSearch.NoRecentQueries" = "输入标签,查找包含该标签的消息。"; +"HashtagSearch.NoResults" = "无结果"; +"HashtagSearch.NoResultsQueryCashtagDescription" = "没有相关 %@ 的结果。 \n尝试另一个主题标签。"; +"HashtagSearch.NoResultsQueryDescription" = "没有找到与 %@ 相关的结果。\n请尝试搜索其他标签。"; +"HashtagSearch.Posts#other" = "%@ 条短信"; +"HashtagSearch.PublicPosts" = "公开帖子"; +"HashtagSearch.SearchPlaceholder" = "标签搜索"; +"HashtagSearch.Stories#other" = "%@ 个动态"; +"HashtagSearch.StoriesFound#other" = "找到 %@ 个动态。"; +"HashtagSearch.StoriesFoundInfo" = "查看与 %@ 相关的动态"; +"HashtagSearch.ThisChat" = "此聊天"; +"ImportStickerPack.AddToExistingStickerSet" = "添加至现有的贴纸集"; +"ImportStickerPack.CheckingLink" = "正在检查可用性……"; +"ImportStickerPack.ChooseLink" = "选择链接"; +"ImportStickerPack.ChooseLinkDescription" = "你可以使用 a-z、0-9 和下划线。 "; +"ImportStickerPack.ChooseName" = "设置名称"; +"ImportStickerPack.ChooseNameDescription" = "请为您的表情包起个名称"; +"ImportStickerPack.ChooseStickerSet" = "选择贴纸包"; +"ImportStickerPack.Create" = "创建表情包"; +"ImportStickerPack.CreateNewEmojiPack" = "创建新表情包"; +"ImportStickerPack.CreateNewStickerSet" = "创建新的贴纸包"; +"ImportStickerPack.CreateStickerSet" = "创建贴纸包"; +"ImportStickerPack.EmojiCount#other" = "%@ 个表情"; +"ImportStickerPack.GeneratingLink" = "正在生成链接……"; +"ImportStickerPack.ImportingEmojis" = "导入表情"; +"ImportStickerPack.ImportingStickers" = "正在导入表情包"; +"ImportStickerPack.InProgress" = "导入完成前请勿关闭此窗口。"; +"ImportStickerPack.LinkAvailable" = "链接可用。"; +"ImportStickerPack.LinkTaken" = "抱歉, 此链接已被占用。"; +"ImportStickerPack.NamePlaceholder" = "名称"; +"ImportStickerPack.Of" = "%1$@ / %2$@ 已导入"; +"ImportStickerPack.RemoveFromImport" = "从导入中删除"; +"ImportStickerPack.StickerCount#other" = "%@ 张贴纸"; +"InfoPlist.NSCameraUsageDescription" = "我们需要此权限来拍摄和分享照片和视频,以及进行视频通话。"; +"InfoPlist.NSContactsUsageDescription" = "为了让您在任何设备上联系朋友,Telegram 会将您的联系人信息即时同步到高度加密的 Telegram 云服务器。"; +"InfoPlist.NSFaceIDUsageDescription" = "您可以使用 Face ID 解锁应用。"; +"InfoPlist.NSLocationAlwaysAndWhenInUseUsageDescription" = "当您选择在一个聊天中实时共享您的实时位置时,Telegram 需要后台应用刷新权限以在实时共享期间即时更新位置信息。"; +"InfoPlist.NSLocationAlwaysUsageDescription" = "当您选择在一个聊天中实时共享您的实时位置时,Telegram 需要后台应用刷新权限以在实时共享期间即时更新位置信息。您也需要此权限来从 Apple Watch 发送位置信息。"; +"InfoPlist.NSLocationWhenInUseUsageDescription" = "Telegram 需要此权限以便在您需要时向您的朋友发送位置信息。"; +"InfoPlist.NSMicrophoneUsageDescription" = "我们需要此权限来录制语音消息/有声视频,并分享给他人。"; +"InfoPlist.NSPhotoLibraryAddUsageDescription" = "需要此权限才能分享照片和视频或将新内容保存到您的设备。"; +"InfoPlist.NSPhotoLibraryUsageDescription" = "需要此权限才能分享照片和视频或将新内容保存到您的设备。"; +"InfoPlist.NSSiriUsageDescription" = "您可以使用 Siri 发送消息。"; +"InstantPage.AuthorAndDateTitle" = "作者 %1$@ • %2$@"; +"InstantPage.AutoNightTheme" = "自动启用夜间主题"; +"InstantPage.FeedbackButton" = "举报此预览的错误"; +"InstantPage.FeedbackButtonShort" = "排版错误?"; +"InstantPage.FontNewYork" = "纽约"; +"InstantPage.FontSanFrancisco" = "旧金山"; +"InstantPage.OpenInBrowser" = "在 %@ 中打开"; +"InstantPage.Reference" = "参考"; +"InstantPage.RelatedArticleAuthorAndDateTitle" = "%1$@ • %2$@"; +"InstantPage.Search" = "搜索"; +"InstantPage.TapToOpenLink" = "点击以打开链接:"; +"InstantPage.Views#other" = "%@ 次浏览"; +"InstantPage.VoiceOver.DecreaseFontSize" = "减小字体大小"; +"InstantPage.VoiceOver.IncreaseFontSize" = "增大字体大小"; +"InstantPage.VoiceOver.ResetFontSize" = "重置字体大小"; +"Intents.ErrorLockedText" = "打开 Telegram 并输入密码来编辑小组件。"; +"Intents.ErrorLockedTitle" = "已锁定"; +"IntentsSettings.MainAccount" = "主账号"; +"IntentsSettings.MainAccountInfo" = "选择一个账号,用于 Siri 和分享建议。"; +"IntentsSettings.Reset" = "重置"; +"IntentsSettings.ResetAll" = "重置所有分享建议"; +"IntentsSettings.SuggestBy" = "建议"; +"IntentsSettings.SuggestByAll" = "所有已发送的消息"; +"IntentsSettings.SuggestByShare" = "仅分享的消息"; +"IntentsSettings.SuggestedAndSpotlightChatsInfo" = "建议将显示在“分享菜单”和 Spotlight 搜索结果中。已归档的聊天不会被建议。"; +"IntentsSettings.SuggestedChats" = "已建议的聊天"; +"IntentsSettings.SuggestedChatsContacts" = "联系人"; +"IntentsSettings.SuggestedChatsGroups" = "群组"; +"IntentsSettings.SuggestedChatsInfo" = "不会建议已归档的聊天。"; +"IntentsSettings.SuggestedChatsPrivateChats" = "私聊"; +"IntentsSettings.SuggestedChatsSavedMessages" = "我的收藏"; +"IntentsSettings.Title" = "分享选单"; +"Invitation.Group.AlreadyJoinedMultiple" = "%@ 已经加入该小组。"; +"Invitation.Group.AlreadyJoinedMultipleWithCount#other" = "{} 和其他 **%d** 人已加入该群组。"; +"Invitation.Group.AlreadyJoinedSingle" = "**%@** 已经加入该小组。"; +"Invitation.GroupCall" = "群组通话"; +"Invitation.GroupCall.AlreadyJoinedMultiple" = "%@ 已加入此次通话。"; +"Invitation.GroupCall.AlreadyJoinedMultipleWithCount#other" = "{} 和其他 **%d** 位用户已加入本次通话。"; +"Invitation.GroupCall.AlreadyJoinedSingle" = "**%@** 已加入此通话。"; +"Invitation.GroupCall.Text" = "您已获邀请加入此群组通话。"; +"Invitation.JoinGroup" = "加入群组"; +"Invitation.JoinGroupCall" = "加入群组通话"; +"Invitation.JoinGroupCall.EnableMicrophone" = "打开麦克风"; +"Invitation.JoinVoiceChat" = "加入视频聊天"; +"Invitation.JoinVoiceChatAsListener" = "作为听众加入"; +"Invitation.JoinVoiceChatAsSpeaker" = "作为发言者加入"; +"Invitation.Members#other" = "%@ 位成员:"; +"Invitation.PrivateGroup" = "私人群组"; +"Invitation.PublicGroup" = "公开群组"; +"Invite.ChannelsTooMuch" = "抱歉,对方已经加入了过多的群组和频道,请让对方退出一些。"; +"Invite.LargeRecipientsCountWarning" = "请注意,发送所有的邀请将花费一些时间。"; +"InviteLink.AdditionalLinks" = "额外链接"; +"InviteLink.ApprovalRequired" = "需要批准"; +"InviteLink.CallLinkTitle" = "通话链接"; +"InviteLink.ContextCopy" = "拷贝"; +"InviteLink.ContextDelete" = "删除"; +"InviteLink.ContextEdit" = "编辑"; +"InviteLink.ContextGetQRCode" = "获取二维码"; +"InviteLink.ContextRevoke" = "撤销"; +"InviteLink.ContextShare" = "分享"; +"InviteLink.CopyShort" = "复制"; +"InviteLink.Create" = "新建链接"; +"InviteLink.Create.EditTitle" = "编辑链接"; +"InviteLink.Create.Fee" = "需要月费"; +"InviteLink.Create.FeeEditInfo" = "如需更改订阅费用,请创建具有不同价格的新邀请链接。"; +"InviteLink.Create.FeeInfo" = "向通过此链接加入您频道的用户收取订阅费用。 [了解更多 >]()"; +"InviteLink.Create.FeePerMonth" = "%@ /月"; +"InviteLink.Create.FeePlaceholder" = "每月所需星星数量"; +"InviteLink.Create.LinkName" = "链接名称(可选)"; +"InviteLink.Create.LinkNameInfo" = "仅管理员有权查看该名字。"; +"InviteLink.Create.LinkNameTitle" = "链接名称"; +"InviteLink.Create.RequestApproval" = "需要管理员批准"; +"InviteLink.Create.RequestApprovalFeeUnavailable" = "您无法对需要按月付费的链接启用管理员批准。"; +"InviteLink.Create.RequestApprovalInfo" = "通过此链接加入须经管理员批准。"; +"InviteLink.Create.RequestApprovalOffInfoChannel" = "若想要用户得到管理员批准后加入,请将此选项打开。"; +"InviteLink.Create.RequestApprovalOffInfoGroup" = "打开此选项,用户将会仅在管理员允许的情况下加入."; +"InviteLink.Create.RequestApprovalOnInfoChannel" = "如果想要管理员批准用户加入,请将其打开。"; +"InviteLink.Create.RequestApprovalOnInfoGroup" = "启用此选项后,新用户加入前需得到管理员的批准。"; +"InviteLink.Create.RequestApprovalPublicGroupUnavailable" = "此选项不可用,因为任何人都可以通过公开群组链接加入并发送消息。"; +"InviteLink.Create.RequestApprovalUnavailableInfo" = "您无法对需要按月付费的链接启用管理员批准。"; +"InviteLink.Create.Revoke" = "撤销链接"; +"InviteLink.Create.Subscription" = "需要月费"; +"InviteLink.Create.Subscription.EditInfo" = "如需更改订阅费用,请创建具有不同价格的新邀请链接。"; +"InviteLink.Create.Subscription.Info" = "向通过此链接加入您频道的人收取订阅费用。 [了解更多>]()"; +"InviteLink.Create.Subscription.PerMonth" = "%@ / 月"; +"InviteLink.Create.Subscription.Placeholder" = "每月星星余额"; +"InviteLink.Create.TimeLimit" = "时间限制"; +"InviteLink.Create.TimeLimitExpiryDate" = "过期时间"; +"InviteLink.Create.TimeLimitExpiryDateNever" = "永不过期"; +"InviteLink.Create.TimeLimitExpiryTime" = "时间"; +"InviteLink.Create.TimeLimitInfo" = "您可以设置该链接在一定时间后过期。"; +"InviteLink.Create.TimeLimitNoLimit" = "无限制"; +"InviteLink.Create.Title" = "新链接"; +"InviteLink.Create.UsersLimit" = "次数限制"; +"InviteLink.Create.UsersLimitInfo" = "你可以设定链接在使用一定次数后自动失效。"; +"InviteLink.Create.UsersLimitNoLimit" = "无限制"; +"InviteLink.Create.UsersLimitNumberOfUsers" = "使用人数限制"; +"InviteLink.Create.UsersLimitNumberOfUsersUnlimited" = "不限量"; +"InviteLink.CreateInfo" = "您可以创建额外的邀请链接,这些链接有一定的时间或使用次数限制。"; +"InviteLink.CreateNewInfo" = "你可以建立限制时间、用户数量或需要付费订阅的附加邀请链接。"; +"InviteLink.CreatePrivateLinkHelp" = "Telegram上的任何人可以通过此链接加入您的群组。"; +"InviteLink.CreatePrivateLinkHelpChannel" = "任何已安装 Telegram 的用户都将可以通过此链接加入您的频道。"; +"InviteLink.CreatedBy" = "链接创建者"; +"InviteLink.CreatedGroupCallFooter" = "率先加入通话并在通话中添加其他人。 [打开通话 >](open_call)"; +"InviteLink.DeleteAllRevokedLinks" = "删除所有已撤销的链接"; +"InviteLink.DeleteAllRevokedLinksAlert.Action" = "删除所有"; +"InviteLink.DeleteAllRevokedLinksAlert.Text" = "这将删除所有无效的链接。"; +"InviteLink.DeleteLinkAlert.Action" = "删除"; +"InviteLink.DeleteLinkAlert.Text" = "您确定要删除此链接吗?删除后将无法找回。"; +"InviteLink.Expired" = "已失效"; +"InviteLink.ExpiredLink" = "失效的链接"; +"InviteLink.ExpiredLinkStatus" = "邀请链接已经过期"; +"InviteLink.ExpiresIn" = "%@ 后过期"; +"InviteLink.GroupCallLinkHelp" = "Telegram 上的任何人都可以通过以下链接加入您的通话。"; +"InviteLink.InviteLink" = "邀请链接"; +"InviteLink.InviteLinkCopiedText" = "邀请链接已拷贝至剪贴板。"; +"InviteLink.InviteLinkForwardTooltip.Chat.One" = "邀请链接已发送给 **%@**。"; +"InviteLink.InviteLinkForwardTooltip.ManyChats.One" = "邀请链接已发送给 **%@** 及其他 %@ 个聊天。"; +"InviteLink.InviteLinkForwardTooltip.SavedMessages.One" = "邀请链接已转发至 **我的收藏** 。"; +"InviteLink.InviteLinkForwardTooltip.TwoChats.One" = "邀请链接转发至 **%@** 和 **%@** 。"; +"InviteLink.InviteLinkRevoked" = "此邀请链接已被撤销。"; +"InviteLink.InviteLinks#other" = "%@ 条邀请链接"; +"InviteLink.LabelJoinedViaFolder" = "已通过文件夹邀请链接加入"; +"InviteLink.Manage" = "管理邀请链接"; +"InviteLink.OtherAdminsLinks" = "由其他管理员创建的邀请链接"; +"InviteLink.OtherPermanentLinkInfo" = "**%1$@** 可以看到该链接并使用它邀请新成员加入 **%2$@** 群组。"; +"InviteLink.PeopleCanJoin#other" = "可加入 %@ 人"; +"InviteLink.PeopleJoined#other" = "%@ 人已加入"; +"InviteLink.PeopleJoinedNone" = "无人加入"; +"InviteLink.PeopleJoinedShort#other" = "%@ 人已加入"; +"InviteLink.PeopleJoinedShortNone" = "无人加入"; +"InviteLink.PeopleJoinedShortNoneExpired" = "无人加入"; +"InviteLink.PeopleRemaining#other" = "剩余 %@ 次"; +"InviteLink.PerMonth" = "每月"; +"InviteLink.PermanentLink" = "邀请链接"; +"InviteLink.PublicLink" = "公开链接"; +"InviteLink.QRCode.Info" = "Telegram 上的每个人都可以扫描此二维码以加入您的群组。"; +"InviteLink.QRCode.InfoChannel" = "通过扫描此二维码,Telegram 上的任何用户都可以加入您的频道。"; +"InviteLink.QRCode.InfoGroupCall" = "所有 Telegram 用户都可以扫描此二维码加入您的群组通话。"; +"InviteLink.QRCode.Share" = "分享二维码"; +"InviteLink.QRCode.Title" = "通过二维码邀请"; +"InviteLink.QRCodeFolder.Text" = "Telegram 上的所有人都可以扫描此二维码以添加此文件夹,并加入此邀请链接中包含的所有聊天。"; +"InviteLink.QRCodeFolder.Title" = "通过二维码邀请"; +"InviteLink.ReactivateLink" = "重新激活链接"; +"InviteLink.Revoked" = "已撤销"; +"InviteLink.RevokedLinks" = "已撤销的链接"; +"InviteLink.Share" = "分享链接"; +"InviteLink.ShareShort" = "分享"; +"InviteLink.Subscription.Fee" = "订阅费"; +"InviteLink.Subscription.Info" = "您每月大约可获得 %@ "; +"InviteLink.SubscriptionFee.ApproximateIncome" = "您每月约可获得 %@"; +"InviteLink.SubscriptionFee.NoOneJoined" = "无人加入"; +"InviteLink.SubscriptionFee.PerMonth" = "⭐️ %@ /月"; +"InviteLink.SubscriptionFee.Title" = "订阅费"; +"InviteLink.Title" = "邀请链接"; +"InviteLink.UsageLimitReached" = "已达人数上限"; +"InviteLinks.InviteLinkExpired" = "邀请链接已失效。"; +"InviteText.ContactsCount#other" = "嗨,我在和另外 %@ 位联系人使用 Telegram 聊天。加入我们吧!从这里下载:%@"; +"InviteText.ContactsCountText#other" = "嗨,我在和另外 %@ 位联系人使用 Telegram 聊天。加入我们吧!从这里下载:{url}"; +"InviteText.SingleContact" = "嗨,我在使用 Telegram 聊天。加入我们吧!从这里下载:%@"; +"InviteText.URL" = "https://telegram.org/dl"; +"Items.NOfM" = "%2$@ 的 %1$@"; +"Join.ChannelsTooMuch" = "抱歉,您已经加入了过多的群组和频道,请在加入新的之前退出一些。"; +"KeyCommand.ChatInfo" = "聊天信息"; +"KeyCommand.EnterFullscreen" = "进入全屏模式"; +"KeyCommand.ExitFullscreen" = "退出全屏"; +"KeyCommand.Find" = "搜索"; +"KeyCommand.FocusOnInputField" = "编写消息"; +"KeyCommand.JumpToNextChat" = "跳转到下一个聊天"; +"KeyCommand.JumpToNextUnreadChat" = "跳转到下一个未读聊天"; +"KeyCommand.JumpToPreviousChat" = "跳转到上一个聊天"; +"KeyCommand.JumpToPreviousUnreadChat" = "跳转到上一个未读聊天"; +"KeyCommand.LockWithPasscode" = "用锁定码锁定"; +"KeyCommand.NewMessage" = "新建对话"; +"KeyCommand.Pause" = "暂停播放视频"; +"KeyCommand.Play" = "播放视频"; +"KeyCommand.ScrollDown" = "向下滚动"; +"KeyCommand.ScrollUp" = "向上滚动"; +"KeyCommand.SearchInChat" = "在聊天中搜索"; +"KeyCommand.SeekBackward" = "前进播放"; +"KeyCommand.SeekForward" = "后退播放"; +"KeyCommand.SendMessage" = "发送消息"; +"KeyCommand.Share" = "分享"; +"KeyCommand.SwitchToPIP" = "切换到画中画"; +"LOCKED_MESSAGE" = "您有一条新消息 %1$@"; +"LastSeen.ALongTimeAgo" = "很久没上线"; +"LastSeen.AtDate" = "最近上线于 %@"; +"LastSeen.HoursAgo#other" = "最近上线于 %@ 小时前"; +"LastSeen.JustNow" = "上线于不久前"; +"LastSeen.Lately" = "最近曾上线"; +"LastSeen.MinutesAgo#other" = "最近上线于 %@ 分钟前"; +"LastSeen.Offline" = "很久没上线"; +"LastSeen.TodayAt" = "今日最后上线于 %@"; +"LastSeen.WithinAMonth" = "最近一个月内曾上线"; +"LastSeen.WithinAWeek" = "最近一周内曾上线"; +"LastSeen.YesterdayAt" = "最近上线于昨天 %@"; +"LeaveGroup.AppointAnotherOwner" = "指定另一位所有者"; +"LeaveGroup.LegacyGroupText" = "如果您退出,**%1$@** 将立即成为 **%2$@** 的所有者。"; +"LeaveGroup.Proceed" = "退出群组"; +"LeaveGroup.Text" = "如果您离开,**%1$@** 将在 **1 周** 后成为 **%2$@** 的新所有者。"; +"LeaveGroup.Title" = "退出 %@"; +"LiveLocation.MenuChatsCount#other" = "您正在 %@ 个聊天中共享实时位置。"; +"LiveLocation.MenuStopAll" = "全部停止"; +"LiveLocationUpdated.JustNow" = "刚刚更新"; +"LiveLocationUpdated.MinutesAgo#other" = "更新于 %@ 分钟前"; +"LiveLocationUpdated.TodayAt" = "上次更新:%@"; +"LiveStream.AnonymousDisabledAlertText" = "抱歉,您不能以匿名管理员的身份加入直播。"; +"LiveStream.CancelConfirmationText" = "您想要取消已预定的直播吗?"; +"LiveStream.CancelConfirmationTitle" = "取消直播"; +"LiveStream.ChatFullAlertText" = "抱歉,此直播目前已有过多参与者。"; +"LiveStream.CommentsDisabledPlaceholder" = "禁止评论"; +"LiveStream.CreateNewVoiceChatText" = "直播已结束。是否创建一个新的直播?"; +"LiveStream.DisplayAsSuccess" = "此直播的参与者将会看到您是 **%@** 。"; +"LiveStream.EditTitle" = "编辑直播标题"; +"LiveStream.EditTitleRemoveSuccess" = "直播标题已被移除。"; +"LiveStream.EditTitleSuccess" = "直播标题已修改为 **%@** 。"; +"LiveStream.EditTitleText" = "如聊天话题、特殊与会者等"; +"LiveStream.EndConfirmationText" = "您确定要结束此次直播吗?"; +"LiveStream.EndConfirmationTitle" = "结束直播"; +"LiveStream.ErrorMaxAllowedEmoji.Text#other" = "您最多可以发送 %d 个表情符号。"; +"LiveStream.Expand" = "展开"; +"LiveStream.InputContextMenuAddStars" = "添加星星\n"; +"LiveStream.InputContextMenuEditStars" = "编辑星星"; +"LiveStream.InputContextMenuRemoveStars" = "移除星星"; +"LiveStream.InputPlaceholder" = "评论"; +"LiveStream.InvitedPeerText" = "您已邀请 %@ 加入直播"; +"LiveStream.LeaveAndCancelVoiceChat" = "取消直播"; +"LiveStream.LeaveAndEndVoiceChat" = "结束直播"; +"LiveStream.LeaveConfirmation" = "您确定要退出此直播吗?"; +"LiveStream.LeaveVoiceChat" = "退出直播"; +"LiveStream.Listening.Members#other" = "%@ 正在收听"; +"LiveStream.LoadingStatus" = "加载中......\n"; +"LiveStream.NoSignalAdminText" = "**糟糕!** Telegram 没有接收到来自您的直播程序的推流。\n\n请确保您在直播程序中输入了正确的服务器 URL 和串流码。"; +"LiveStream.NoSignalUserText" = "%@ 目前没有向 Telegram 推流直播数据。"; +"LiveStream.NoViewers" = "无人观看"; +"LiveStream.PeerJoinedText" = "%@ 已加入直播"; +"LiveStream.RecordTitle" = "录制直播"; +"LiveStream.RecordingInProgress" = "正在录制直播"; +"LiveStream.RecordingSaved" = "直播录制已保存到 **我的收藏**。"; +"LiveStream.RecordingStarted" = "直播录制已开始。"; +"LiveStream.RemoveAndBanPeerConfirmation" = "您想将 %1$@ 移出直播并在 %2$@ 中将其封禁吗?"; +"LiveStream.StartRecording" = "开始录音"; +"LiveStream.StartRecordingText" = "您要开始录制此次直播,并将结果保存为音频文件吗?\n\n参与者将看到此直播正在被录制。"; +"LiveStream.StartRecordingTextVideo" = "您要开始录制此直播并将结果保存为视频文件吗?\n\n其他参与者将看到聊天正在被录制。"; +"LiveStream.StartRecordingTitle" = "开始录制"; +"LiveStream.StopLiveStream" = "停止直播"; +"LiveStream.StoryViewerCount#other" = "%@ 人在观看"; +"LiveStream.StoryViewerEmpty" = "无人查看\n"; +"LiveStream.ViewCredentials" = "查看串流密钥"; +"LiveStream.ViewerCount#other" = "%@ 位访客"; +"LiveStream.Watching" = "正在观看"; +"LiveStream.Watching.Members#other" = "%@ 正在观看"; +"LiveStreamSettings.AllowComments" = "允许留言"; +"LiveStreamSettings.AllowScreenshots" = "允许截图"; +"LiveStreamSettings.ConnectStream" = "连接直播流"; +"LiveStreamSettings.ConnectStreamInfo" = "使用其他应用直播。"; +"LiveStreamSettings.PricePerComment" = "每个评论的价格"; +"LiveStreamSettings.PricePerComment.Free" = "免费"; +"LiveStreamSettings.PricePerComment.Stars#other" = "%@ 颗星星"; +"LiveStreamSettings.PricePerCommentInfo" = "观众发送评论需支付的价格。"; +"LiveStreamSettings.SaveSettings" = "保存设置"; +"LiveStreamSettings.StartLiveAs" = "以哪个身份开始直播"; +"LiveStreamSettings.Title" = "直播设置"; +"LiveStreamSettings.TitleEdit" = "直播"; +"LiveStreamSettings.WhoCanView" = "谁可以观看此直播"; +"LiveStreamSettings.WhoCanViewInfo" = "[选中的对象]() 永远不会看到您的直播动态。"; +"LocalGroup.ButtonTitle" = "创建群组"; +"LocalGroup.IrrelevantWarning" = "如果您在此处创建无关的群组,则可能无法创建基于位置的组。"; +"LocalGroup.Text" = "附近的用户(例如邻居、同事、同学、活动参与者,游客)都可以在“附近的人”页面看到您的群组。"; +"LocalGroup.Title" = "创建本地群组"; +"Localization.ChooseLanguage" = "选择您的语言"; +"Localization.DoNotTranslate" = "无需翻译"; +"Localization.DoNotTranslateInfo" = "不要为该语言显示“翻译”按钮。"; +"Localization.DoNotTranslateManyInfo" = "不要为这些语言显示“翻译”按钮。"; +"Localization.EnglishLanguageName" = "英语"; +"Localization.InterfaceLanguage" = "界面语言"; +"Localization.LanguageCustom" = "自定义"; +"Localization.LanguageName" = "简体中文"; +"Localization.LanguageOther" = "其它"; +"Localization.ShowTranslate" = "显示“翻译”按钮"; +"Localization.ShowTranslateInfo" = "在消息菜单中显示“翻译”按钮。"; +"Localization.ShowTranslateInfoExtended" = "长按消息时显示“翻译”按钮。"; +"Localization.TranslateEntireChat" = "翻译全部聊天记录"; +"Localization.TranslateMessages" = "翻译消息"; +"Location.AddMyLocation" = "添加我当前的位置"; +"Location.AddThisLocation" = "添加这个位置"; +"Location.LiveLocationRequired.Description" = "请在此聊天中共享实时位置以保证通知生效。"; +"Location.LiveLocationRequired.ShareLocation" = "共享位置"; +"Location.LiveLocationRequired.Title" = "共享位置"; +"Location.PollSubtitle.Description" = "向投票描述添加位置"; +"Location.PollSubtitle.Explanation" = "向问答说明添加位置"; +"Location.PollSubtitle.PollOption" = "向此选项添加位置"; +"Location.ProximityAlertCancelled" = "已取消接近警报"; +"Location.ProximityAlertSetText" = "当 %1$@ 距离 %2$@ 时通知我。"; +"Location.ProximityAlertSetTextGroup" = "当群组其他成员在%@内将会收到通知"; +"Location.ProximityAlertSetTitle" = "接近通知已开启"; +"Location.ProximityGroupTip" = "当其他群组成员在附近时提醒我"; +"Location.ProximityNotification.AlreadyClose" = "您已接近%@以内"; +"Location.ProximityNotification.DistanceKM" = "公里"; +"Location.ProximityNotification.DistanceM" = "米"; +"Location.ProximityNotification.DistanceMI" = "英里"; +"Location.ProximityNotification.Notify" = "距离 %@ 内通知我"; +"Location.ProximityNotification.NotifyLong" = "当 %1$@ 在 %2$@ 范围内通知我"; +"Location.ProximityNotification.Title" = "靠近提醒"; +"Location.ProximityTip" = "当 %@ 靠近时通知我"; +"Location.SendLocation" = "发送位置"; +"Location.TypeCity" = "城市"; +"Location.TypeLocation" = "位置"; +"Location.TypeStreet" = "街道"; +"Login.AddEmailPlaceholder" = "输入你的邮箱"; +"Login.AddEmailText" = "您将通过电子邮箱而非短信收取 Telegram 登录码。请输入您可以访问的电子邮箱地址。"; +"Login.AddEmailTitle" = "选择登录邮箱"; +"Login.Announce.DontNotify" = "不要通知"; +"Login.Announce.Info" = "通知 Telegram 上知道我电话号码的用户我已注册。"; +"Login.Announce.Notify" = "通知"; +"Login.AnonymousNumbers" = "匿名号码"; +"Login.BackToCode" = "返回输入验证码"; +"Login.BackToPhrase" = "返回输入秘密短语"; +"Login.BackToWord" = "返回输入秘密单词"; +"Login.BannedPhoneBody" = "我正在尝试使用我的手机号码:%@\n但 Telegram 称该号码已被封禁,请帮助我。"; +"Login.BannedPhoneSubject" = "Banned phone number: %@"; +"Login.CallRequestState1" = "Telegram 会在 %d:%.2d 后使用电话呼叫您播放验证码"; +"Login.CallRequestState2" = "正在请求 Telegram 呼叫…"; +"Login.CallRequestState3" = "Telegram 已拨打您的电话\n[仍未收到验证码?]"; +"Login.CancelEmailVerification" = "是否要停止邮箱验证过程?"; +"Login.CancelEmailVerificationContinue" = "继续"; +"Login.CancelEmailVerificationStop" = "停"; +"Login.CancelPhoneVerification" = "是否终止验证过程?"; +"Login.CancelPhoneVerificationContinue" = "继续"; +"Login.CancelPhoneVerificationStop" = "停止"; +"Login.CancelSignUpConfirmation" = "您想终止注册吗?"; +"Login.CheckOtherSessionMessages" = "查看您的 Telegram 消息"; +"Login.Code" = "验证码"; +"Login.CodeExpired" = "验证码已过期,请重新登录。"; +"Login.CodeExpiredError" = "验证码已失效,请重新登录。"; +"Login.CodeFloodError" = "尝试次数过多,请稍后再试。"; +"Login.CodePhonePatternInfoText" = "请输入呼叫号码的尾号。"; +"Login.CodeSentCall" = "我们正在呼叫您的手机以播放验证码。"; +"Login.CodeSentCallText" = "正在呼叫您的手机(**%@**)以播放验证码。"; +"Login.CodeSentInternal" = "我们已向您其它设备上的 **Telegram** 应用发送了一条带有验证码的消息"; +"Login.CodeSentSms" = "我们通过短信向您发送了验证码"; +"Login.Continue" = "继续"; +"Login.ContinueWithLocalization" = "继续使用中文(简体)"; +"Login.CountryCode" = "国际电话区号"; +"Login.Edit" = "编辑"; +"Login.Email.CantAccess" = "不能访问这个邮箱?"; +"Login.Email.ElapsedTime" = "于 %@"; +"Login.Email.PremiumRequiredText" = "由于您所在国家的短信费用很高,您需要拥有 **Telegram 高级版**帐号才能通过短信登录码重置此电子邮箱。\n\n您可以请朋友给您的帐号赠送高级版订阅\n**%@**"; +"Login.Email.PremiumRequiredTitle" = "需要 Telegram 高级版"; +"Login.Email.Reset" = "重置"; +"Login.Email.ResetNowViaSMS" = "现在通过短信重置"; +"Login.Email.ResetText" = "如果您已在其他设备上登录 Telegram,则可以更改登录所使用的电子邮箱。如果您无法访问电子邮箱 %1$@,可以在 **%2$@**后使用**短信**登录码重置此电子邮箱。"; +"Login.Email.ResetTitle" = "重置邮箱"; +"Login.Email.ResetingNow" = "请稍候..."; +"Login.Email.WillBeResetIn" = "邮箱地址将在 %@ 后重置"; +"Login.EmailChanged" = "您的电子邮箱地址已变更。"; +"Login.EmailCodeBody" = "My phone number is:\n%@\nI can't get an activation code for Telegram."; +"Login.EmailCodeSubject" = "%@, no code"; +"Login.EmailNotAllowedError" = "抱歉,无法使用此邮箱。"; +"Login.EmailNotConfiguredError" = "需要设置电子邮箱才能向我们发送详细错误信息。\n\n请前往设备的设置 > 邮件, 然后点击帐号 > 添加帐号并设置电子邮箱。"; +"Login.EmailPhoneBody" = "I'm trying to use my mobile phone number: %@\nBut Telegram says it's invalid. Please help.\nAdditional Info: %@, %@."; +"Login.EmailPhoneSubject" = "无效号码 %@"; +"Login.EnterCodeEmailText" = "请输入我们向你的邮箱 %@ 中发送的验证码。"; +"Login.EnterCodeEmailTitle" = "检查您的电子邮箱"; +"Login.EnterCodeFragmentText" = "在 **Fragment** 的号码板块获取 **%@** 的验证码。"; +"Login.EnterCodeFragmentTitle" = "输入验证码"; +"Login.EnterCodeNewEmailText" = "请输入我们发送至你的新邮件 %@ 的代码。"; +"Login.EnterCodeNewEmailTitle" = "检查你的新邮件"; +"Login.EnterCodeSMSText" = "我们已经向你的手机号码 %@ 已发送验证码短信。"; +"Login.EnterCodeSMSTitle" = "输入验证码"; +"Login.EnterCodeTelegramText" = "我们已经将验证码发送到该帐号(%@)其他设备上的 **Telegram应用**。"; +"Login.EnterCodeTelegramTitle" = "输入密码"; +"Login.EnterMissingDigits" = "输入剩余的数字"; +"Login.EnterNewEmailTitle" = "输入新的邮箱地址"; +"Login.EnterPhraseBeginningText" = "我们已向您的号码 **%2$@** 发送了一条短信,其中包含以 “%1$@” 开头的秘密短语。"; +"Login.EnterPhraseHint" = "您可以从短信中复制并将验证码粘贴到输入框中。"; +"Login.EnterPhrasePlaceholder" = "输入短信验证码"; +"Login.EnterPhraseText" = "我们已向您的手机号码 **%@** 发送了一个带有秘密短语的短信。"; +"Login.EnterPhraseTitle" = "输入密码短语"; +"Login.EnterWordBeginningText" = "我们已向您的手机号码 **“%2$@”** 已发送一条带有以**%1$@** 开头的秘密短语的短信。"; +"Login.EnterWordPlaceholder" = "输入短信中的单词"; +"Login.EnterWordText" = "我们已向您的手机号码 **%@** 发送了一个带有秘密单词的消息。"; +"Login.EnterWordTitle" = "输入单词"; +"Login.ErrorAppOutdated" = "请将 Telegram 更新到最新版本以登录。"; +"Login.Fee.GetPremiumForAWeek" = "获取一周 Telegram 高级版订阅"; +"Login.Fee.GetPremiumForDays" = "为 %@ 获取 Telegram 高级版"; +"Login.Fee.GetPremiumForDays.Days#other" = "%@ 天"; +"Login.Fee.GetPremiumNone" = "获取 Telegram 高级版"; +"Login.Fee.SignUp" = "注册 %@"; +"Login.Fee.SmsCost.Text" = "由于您所在国家/地区存在虚假账户问题且短信费用较高,您需要支付注册费用。"; +"Login.Fee.SmsCost.Title" = "短信费高昂"; +"Login.Fee.Support.NewText" = "注册 %@ 天的 Telegram Premium 订阅以帮助支付短信费用。"; +"Login.Fee.Support.NewText.Days#other" = "%@ 天"; +"Login.Fee.Support.NewTextNone" = "注册 Telegram 高级版订阅以帮助支付短信费用。"; +"Login.Fee.Support.Text" = "注册一周的 Telegram 高级版订阅以帮助支付短信费用。"; +"Login.Fee.Support.Title" = "通过订阅 [Telegram 高级版 >]() 支持"; +"Login.Fee.Title" = "短信费"; +"Login.Fee.Verification.Text" = "Telegram 需向您发送一条包含验证码的短信来确认您的号码。"; +"Login.Fee.Verification.Title" = "需验证"; +"Login.GetCodeViaFragment" = "通过 Fragment 获取代码"; +"Login.HaveNotReceivedCodeInternal" = "未收到验证码?"; +"Login.InfoAvatarAdd" = "上传"; +"Login.InfoAvatarPhoto" = "头像"; +"Login.InfoDeletePhoto" = "移除头像"; +"Login.InfoFirstNamePlaceholder" = "名字"; +"Login.InfoHelp" = "输入您的姓名并添加头像。"; +"Login.InfoLastNamePlaceholder" = "姓氏"; +"Login.InfoTitle" = "个人信息"; +"Login.InvalidCodeError" = "验证码无效,请重试。"; +"Login.InvalidCountryCode" = "国际电话区号无效"; +"Login.InvalidEmailAddressError" = "发生错误。请重试。"; +"Login.InvalidEmailError" = "请输入有效的邮箱地址。"; +"Login.InvalidEmailTokenError" = "发生错误。请重试。"; +"Login.InvalidFirstNameError" = "不符合规范的名字,请重新输入。"; +"Login.InvalidLastNameError" = "抱歉,此姓氏不可用。"; +"Login.InvalidPhoneEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut Telegram says it's invalid. Please help.\n\nApp version: %2$@\nOS version: %3$@\nLocale: %4$@\nMNC: %5$@"; +"Login.InvalidPhoneEmailSubject" = "Invalid phone number: %@"; +"Login.InvalidPhoneError" = "手机号码无效,请重新输入。"; +"Login.NetworkError" = "请检查您的网络连接后重试。"; +"Login.NewNumber" = "新号码"; +"Login.OpenFragment" = "打开 Fragment"; +"Login.Or" = "或者"; +"Login.PRIVACY_URL" = "https://telegram.org/privacy"; +"Login.PadPhoneHelp" = "您可以使用主要手机号码在所有设备上登录到 Telegram。\n请不要使用您 iPad SIM 卡的号码 — 我们需要给您发送短信。\n\n此号码是否正确?\n{number}"; +"Login.PadPhoneHelpTitle" = "您的手机号码"; +"Login.Paste" = "粘贴"; +"Login.PhoneAndCountryHelp" = "请确认您的国际电话区号并输入您的手机号码。"; +"Login.PhoneBannedEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut Telegram says it's banned. Please help.\n\nApp version: %2$@\nOS version: %3$@\nLocale: %4$@\nMNC: %5$@"; +"Login.PhoneBannedEmailSubject" = "被封禁的手机号码: %@"; +"Login.PhoneBannedError" = "此手机号码已被封禁。"; +"Login.PhoneFloodError" = "抱歉,您最近删除重建账号的次数过多。请稍等几天再重新注册。"; +"Login.PhoneGenericEmailBody" = "I'm trying to use my mobile phone number: %1$@\nBut Telegram shows an error. Please help.\n\nError: %2$@\nApp version: %3$@\nOS version: %4$@\nLocale: %5$@\nMNC: %6$@"; +"Login.PhoneGenericEmailSubject" = "Telegram iOS 错误: %@"; +"Login.PhoneNumberAlreadyAuthorized" = "该帐号已在此应用中登录。"; +"Login.PhoneNumberAlreadyAuthorizedSwitch" = "切换"; +"Login.PhoneNumberConfirmation" = "此手机号码正确吗?"; +"Login.PhoneNumberHelp" = "帮助"; +"Login.PhonePaidEmailSubject" = "付款问题"; +"Login.PhonePlaceholder" = "您的手机号码"; +"Login.PhoneTitle" = "您的手机号码"; +"Login.PhoneWithPasskeySubtitle" = "请输入您的手机号码\n或 [使用通行密钥登录 >](passkey)"; +"Login.ResetAccountProtected.LimitExceeded" = "您最近的重置帐号请求已被此帐号当前的活跃用户撤销,请于 7 天后再试。"; +"Login.ResetAccountProtected.Reset" = "重置"; +"Login.ResetAccountProtected.Text" = "由于账号(%@)处于活跃状态且正被两步验证密码保护;出于安全考虑,我们将在一周后删除这个账号。\n\n您可以随时撤销这一进程。"; +"Login.ResetAccountProtected.TimerTitle" = "在这段时间后,您将能重置您的帐号:"; +"Login.ResetAccountProtected.Title" = "重置帐号"; +"Login.ReturnToCode" = "返回输入验证码 "; +"Login.ReturnToPhrase" = "返回输入短语 "; +"Login.ReturnToWord" = "返回输入秘密单词 "; +"Login.SelectCountry" = "国家"; +"Login.SelectCountry.Title" = "国家/地区"; +"Login.SendCodeAsSMS" = "短信发送验证码"; +"Login.SendCodeViaCall" = "在此手机上获取验证码"; +"Login.SendCodeViaFlashCall" = "通过电话获取验证码"; +"Login.SendCodeViaSms" = "点击此处通过短信收取验证码"; +"Login.ShortCallTitle" = "几秒钟后,您将会收到来自以下号码的简短电话:"; +"Login.SmsRequestState1" = "我们会在 %d:%.2d 后给您发送短信"; +"Login.SmsRequestState2" = "正在请求 Telegram 发送短信…"; +"Login.SmsRequestState3" = "Telegram 已向您发送短信\n[仍未收到验证码?]"; +"Login.TermsOfService.ProceedBot" = "同意以继续使用 %@。"; +"Login.TermsOfServiceAgree" = "同意并继续"; +"Login.TermsOfServiceDecline" = "拒绝"; +"Login.TermsOfServiceHeader" = "服务条款"; +"Login.TermsOfServiceLabel" = "注册即代表您同意[服务条款]。"; +"Login.TermsOfServiceSignupDecline" = "很遗憾,这意味着您无法注册 Telegram。\n\n与其它应用不同,Telegram 不会利用您的个人数据进行广告定位或其它商业目的。Telegram 储存的所有信息都是为了给您创造一个功能丰富的云服务体验。 您可以在「隐私和安全」 里调整我们使用您数据的方式(例如:删除已同步的联系人)。\n\n但若您无法接受 Telegram 的数据收集政策,我们将无法为您提供此服务。"; +"Login.UnknownError" = "发生错误,请稍后再试。"; +"Login.VoiceOver.Password" = "密码"; +"Login.VoiceOver.PhoneCountryCode" = "电话区号"; +"Login.VoiceOver.PhoneNumber" = "电话号码"; +"Login.WillCallYou" = "Telegram 会在 %@ 后使用电话语音播放验证码"; +"Login.WillSendSms" = "Telegram 将在 %@ 后给您发送短信"; +"Login.WrongCodeError" = "验证码错误,请重试。"; +"Login.WrongPhraseError" = "不正确,请再试一次。"; +"Login.Yes" = "是"; +"LoginEmail.Description" = "您将通过电子邮件而非短信接收 Telegram 登录验证码,请输入您可以访问的电子邮件地址。"; +"LoginEmail.Success.Text" = "您的账号已受到保护!"; +"LoginEmail.Success.Title" = "已添加电子邮箱地址"; +"LoginEmail.Title" = "添加邮箱"; +"LoginPassword.FloodError" = "尝试次数过多,请稍后再试。"; +"LoginPassword.ForgotPassword" = "是否忘记密码?"; +"LoginPassword.InvalidPasswordError" = "密码错误,请重试。"; +"LoginPassword.PasswordHelp" = "两步验证已开启,已使用额外密码保护您的帐号。"; +"LoginPassword.PasswordPlaceholder" = "密码"; +"LoginPassword.ResetAccount" = "重置帐号"; +"LoginPassword.Title" = "您的密码"; +"LogoutOptions.AddAccountText" = "连接其他 Telegram 帐号并轻松切换。"; +"LogoutOptions.AddAccountTitle" = "添加其他帐号"; +"LogoutOptions.AlternativeOptionsSection" = "其它选项"; +"LogoutOptions.ChangePhoneNumberText" = "将您的帐号、聊天以及媒体文件转移到新号码上。"; +"LogoutOptions.ChangePhoneNumberTitle" = "更换手机号码"; +"LogoutOptions.ClearCacheText" = "释放设备上的存储空间;您的资料将留在云端。"; +"LogoutOptions.ClearCacheTitle" = "清空缓存"; +"LogoutOptions.ContactSupportText" = "如遇问题,请告诉我们;登出通常无法解决此问题。"; +"LogoutOptions.ContactSupportTitle" = "联系客服"; +"LogoutOptions.LogOut" = "登出"; +"LogoutOptions.LogOutInfo" = "登出后,所有私密聊天将会失效。"; +"LogoutOptions.SetPasscodeText" = "使用锁定码锁定应用程序以禁止他人打开。"; +"LogoutOptions.SetPasscodeTitle" = "设置锁定码"; +"LogoutOptions.Title" = "登出"; +"MESSAGES" = "%1$@ 给您发送了 %2$@ 条消息"; +"MESSAGES_SEPARATED" = "%1$@|给您发送了 %2$@ 条消息"; +"MESSAGE_AUDIO" = "%1$@ 给您发送了一条语音消息"; +"MESSAGE_AUDIO_SEPARATED" = "%1$@|发来了一条语音消息"; +"MESSAGE_CONTACT" = "%1$@ 已分享一个联系人信息"; +"MESSAGE_CONTACT_SEPARATED" = "%1$@|与您分享了联系人信息"; +"MESSAGE_DOC" = "%1$@ 给您发送了一个文件"; +"MESSAGE_DOC_SEPARATED" = "%1$@|给您发送了一个文件"; +"MESSAGE_FWDS" = "%1$@ 转发给您 %2$@ 条消息"; +"MESSAGE_FWDS_SEPARATED" = "%1$@|已给你转发 %2$@ 条消息"; +"MESSAGE_GAME" = "%1$@ 邀请您玩 %2$@"; +"MESSAGE_GAME_SEPARATED" = "%1$@|邀请您玩 %2$@ 游戏"; +"MESSAGE_GEO" = "%1$@ 给您发送了一张地图"; +"MESSAGE_GEOLIVE" = "%1$@ 开始共享实时位置"; +"MESSAGE_GEOLIVE_SEPARATED" = "%1$@|开始共享实时位置"; +"MESSAGE_GEO_SEPARATED" = "%1$@|给您发送了一张地图"; +"MESSAGE_GIF" = "%1$@ 给您发送了一张 GIF 动态图"; +"MESSAGE_GIFTCODE" = "%1$@ 已向您发送 %2$@ 个月 Telegram 高级版的礼品码"; +"MESSAGE_GIFT_THEME" = "%1$@ 已将主题更换为 %2$@"; +"MESSAGE_GIF_SEPARATED" = "%1$@|给您发了一张 GIF"; +"MESSAGE_GIVEAWAY" = "%1$@ 已发送给您一场抽奖活动,奖品为 %2$@ 份 %3$@ 个月的 Telegram 高级版订阅"; +"MESSAGE_INVOICE" = "%1$@ 向您发送了一份金额为 %2$@ 的账单"; +"MESSAGE_INVOICE_SEPARATED" = "%1$@|给您发了一份金额为 %2$@ 的账单"; +"MESSAGE_NOTEXT" = "%1$@ 给您发送了一条消息"; +"MESSAGE_NOTEXT_SEPARATED" = "%1$@|给您发送了消息"; +"MESSAGE_NOTHEME" = "%1$@ 重新设为默认主题"; +"MESSAGE_PHOTO" = "%1$@ 给您发送了一张图片"; +"MESSAGE_PHOTOS" = "%1$@ 给您发送了 %2$@ 张图片"; +"MESSAGE_PHOTOS_SEPARATED" = "%1$@|给您发送了 %2$@ 张图片"; +"MESSAGE_PHOTO_SECRET" = "%1$@ 给您发送了一张阅后即焚图片"; +"MESSAGE_PHOTO_SECRET_SEPARATED" = "%1$@|给您发送了一张阅后即焚的图片"; +"MESSAGE_PHOTO_SEPARATED" = "%1$@|给您发送了一张图片"; +"MESSAGE_POLL" = "%1$@ 给您发送了一项投票"; +"MESSAGE_POLL_SEPARATED" = "%1$@|给你发送了一个投票"; +"MESSAGE_ROUND" = "%1$@ 给您发送了一条视频消息"; +"MESSAGE_ROUND_SEPARATED" = "%1$@|发来了一条视频消息"; +"MESSAGE_SCREENSHOT" = "%1$@ 截了一张图!"; +"MESSAGE_SCREENSHOT_SEPARATED" = "%1$@截了一张图"; +"MESSAGE_STICKER" = "%1$@ 给您发送了一张 %2$@ 贴纸"; +"MESSAGE_STICKER_SEPARATED" = "%1$@|给您发送了一张 %2$@ 贴纸"; +"MESSAGE_SUGGEST_BIRTHDAY" = "已建议您添加生日"; +"MESSAGE_TEXT" = "%1$@:%2$@"; +"MESSAGE_TEXT_SEPARATED" = "%1$@|%2$@"; +"MESSAGE_VIDEO" = "%1$@ 给您发送了一段视频"; +"MESSAGE_VIDEO_SECRET" = "%1$@ 给您发送了一段阅后即焚视频"; +"MESSAGE_VIDEO_SECRET_SEPARATED" = "%1$@|给您发送了一段阅后即焚的视频"; +"MESSAGE_VIDEO_SEPARATED" = "%1$@|给您发送了视频"; +"Map.AccurateTo" = "精确到 %@"; +"Map.AddressOnMap" = "地图上的地址"; +"Map.ChooseAPlace" = "或者选择一个地点"; +"Map.ChooseLocationTitle" = "位置"; +"Map.Directions" = "路线"; +"Map.DirectionsDriveEta" = "%@ 车程"; +"Map.DistanceAway" = "距离此地 %@"; +"Map.ETADays#other" = "%@ 天"; +"Map.ETAHours#other" = "%@ 小时"; +"Map.ETAMinutes#other" = "%@ 分"; +"Map.GetDirections" = "获取路线"; +"Map.Home" = "家庭"; +"Map.HomeAndWorkInfo" = "Telegram会使用您的联系卡中的家庭住址和工作地址。\n\n保持您的联系卡为最新状态,以快速访问发送家庭和工作地址。"; +"Map.HomeAndWorkTitle" = "家庭和工作地址"; +"Map.Hybrid" = "混合"; +"Map.LiveLocationExtendDescription" = "您希望将共享位置的时间延长多久?"; +"Map.LiveLocationFor15Minutes" = "15 分钟"; +"Map.LiveLocationFor1Hour" = "1 小时"; +"Map.LiveLocationFor8Hours" = "8 小时"; +"Map.LiveLocationForHours#other" = "%@ 小时"; +"Map.LiveLocationForMinutes#other" = "%@ 分钟"; +"Map.LiveLocationGroupDescription" = "选择此聊天的成员在多长时间内可看到您实时位置。"; +"Map.LiveLocationGroupNewDescription" = "选择此聊天的成员在多长时间内可看到您实时位置,包括应用关闭时。"; +"Map.LiveLocationIndefinite" = "直到我关闭"; +"Map.LiveLocationPrivateDescription" = "选择 %@ 在多长时间内可看到您的精确位置。"; +"Map.LiveLocationPrivateNewDescription" = "选择 **%@** 能够看到您的准确位置的时长,包括应用程序关闭的时间。"; +"Map.LiveLocationShortHour" = "%@时"; +"Map.LiveLocationShowAll" = "显示全部"; +"Map.LiveLocationTitle" = "实时位置"; +"Map.LoadError" = "发生错误,请重试。"; +"Map.Locating" = "定位中…"; +"Map.LocatingError" = "定位失败"; +"Map.Location" = "位置"; +"Map.LocationTitle" = "位置"; +"Map.Map" = "地图"; +"Map.NoPlacesNearby" = "无法获取位置信息,请尝试别的地方"; +"Map.OpenIn" = "使用其它应用打开…"; +"Map.OpenInGoogleMaps" = "在 Google 地图中打开"; +"Map.OpenInHereMaps" = "在 HERE Maps 中显示"; +"Map.OpenInMaps" = "在地图中显示"; +"Map.OpenInWaze" = "在 Waze 中显示"; +"Map.OpenInYandexMaps" = "在 Yandex Maps 中显示"; +"Map.OpenInYandexNavigator" = "在 Yandex Navigator 中显示"; +"Map.PlacesInThisArea" = "此区域内的地点"; +"Map.PlacesNearby" = "附近的地点"; +"Map.PullUpForPlaces" = "向上拖动查看周边地点"; +"Map.Satellite" = "卫星图像"; +"Map.Search" = "搜索周边"; +"Map.SearchNoResultsDescription" = "没有找到“%@”的相关结果。\n请重新搜索。"; +"Map.SendMyCurrentLocation" = "发送我当前的位置"; +"Map.SendThisLocation" = "发送此位置"; +"Map.SendThisPlace" = "发送此位置"; +"Map.SetThisLocation" = "选择此位置"; +"Map.SetThisPlace" = "设置此地点"; +"Map.ShareLiveLocation" = "共享我的实时位置,持续时间…"; +"Map.ShareLiveLocationHelp" = "在您移动时实时更新"; +"Map.SharingLocation" = "正在共享位置..."; +"Map.ShowPlaces" = "显示地点"; +"Map.StopLiveLocation" = "停止共享位置"; +"Map.TapToAddTime" = "点击以延长时间"; +"Map.Unknown" = "未知位置"; +"Map.Work" = "工作"; +"Map.YouAreHere" = "您在这里"; +"MaskPackActionInfo.ArchivedTitle" = "装饰已归档"; +"MaskPackActionInfo.RemovedText" = "%@ 已不在您的装饰中。"; +"MaskPackActionInfo.RemovedTitle" = "已移除装饰"; +"MaskStickerSettings.Info" = "在发送图片和视频前,您可以在媒体编辑器中为其添加装饰。"; +"MaskStickerSettings.Title" = "装饰"; +"Media.ChooseFromGallery" = "从相册选择"; +"Media.EditCover" = "编辑封面"; +"Media.LimitedAccessChangeSettings" = "修改设置"; +"Media.LimitedAccessManage" = "管理"; +"Media.LimitedAccessSelectMore" = "选择更多照片……"; +"Media.LimitedAccessText" = "您没有允许 Telegram 访问全部照片。"; +"Media.LimitedAccessTitle" = "访问相册受限"; +"Media.PhotoHdOff" = "该照片会以标准质量发送。"; +"Media.PhotoHdOn" = "该照片会以高分辨率发送。"; +"Media.SaveCover" = "保存封面"; +"Media.SelectFrame" = "选择一帧"; +"Media.SendWithTimer" = "限时消息"; +"Media.SendingOptionsTooltip" = "长按发送按钮设置阅后即焚时间。"; +"Media.ShareItem#other" = "共 %@ 项"; +"Media.SharePhoto#other" = "全部 %@ 张图片"; +"Media.ShareThisPhoto" = "这张图片"; +"Media.ShareThisVideo" = "这段视频"; +"Media.ShareVideo#other" = "全部 %@ 段视频"; +"MediaEditor.AddAudio" = "音频"; +"MediaEditor.AddGif" = "添加 GIF"; +"MediaEditor.AddLink" = "链接"; +"MediaEditor.AddLocation" = "添加位置"; +"MediaEditor.AddLocationShort" = "位置"; +"MediaEditor.AddToStickerPack" = "添加到贴纸包"; +"MediaEditor.Audio" = "音频"; +"MediaEditor.Audio.SavedMusic" = "保存的音乐"; +"MediaEditor.Audio.ShowMore" = "显示更多"; +"MediaEditor.Audio.Title" = "音频"; +"MediaEditor.CreateNewPack" = "新贴纸包"; +"MediaEditor.Cutout" = "剪出对象"; +"MediaEditor.CutoutInfo" = "点击对象进行裁剪"; +"MediaEditor.Draft" = "草稿"; +"MediaEditor.Erase" = "擦除"; +"MediaEditor.EraseInfo" = "擦除此贴纸的一部分"; +"MediaEditor.HoldToRecordVideo" = "按住以录制视频"; +"MediaEditor.Link.CreateTitle" = "创建链接"; +"MediaEditor.Link.EditTitle" = "编辑链接"; +"MediaEditor.Link.LinkName.Placeholder" = "输入名称"; +"MediaEditor.Link.LinkName.Title" = "链接名称 (可选)"; +"MediaEditor.Link.LinkTo.Placeholder" = "https://somesite.com"; +"MediaEditor.Link.LinkTo.Title" = "链接至"; +"MediaEditor.NewStickerPack.Text" = "为您的贴纸包选择一个名字。"; +"MediaEditor.NewStickerPack.Title" = "新贴纸包"; +"MediaEditor.Outline" = "添加轮廓"; +"MediaEditor.RemoveAudio" = "移除音频"; +"MediaEditor.RemoveVideo" = "移除视频"; +"MediaEditor.ReplaceSticker" = "更新贴纸"; +"MediaEditor.Restore" = "恢复"; +"MediaEditor.RestoreInfo" = "恢复此贴纸的一部分"; +"MediaEditor.SetAsIntroSticker" = "设置为问候贴纸"; +"MediaEditor.SetStickerEmoji" = "选择与您的贴纸相配的表情符号"; +"MediaEditor.Shortcut.Audio" = "音频"; +"MediaEditor.Shortcut.Image" = "图片"; +"MediaEditor.Shortcut.Link" = "链接"; +"MediaEditor.Shortcut.Location" = "位置"; +"MediaEditor.Shortcut.Reaction" = "回应"; +"MediaEditor.StickersTooMuch" = "抱歉,您已达到此贴纸包的最大数量上限。请尝试另一个。"; +"MediaEditor.Timeline" = "时间线"; +"MediaEditor.Undo" = "撤销"; +"MediaEditor.VideoRemovalConfirmation" = "确定删除视频消息吗?"; +"MediaGallery.ToastVideoPip.Text" = "向下滑动视频即可将其关闭。"; +"MediaGallery.ToastVideoPip.Title" = "小窗视频"; +"MediaPicker.AddCaption" = "添加说明…"; +"MediaPicker.AddImage" = "添加图片"; +"MediaPicker.CameraRoll" = "相机胶卷"; +"MediaPicker.ChooseCover" = "选择封面"; +"MediaPicker.CombineIntoCollage" = "组合成拼贴画"; +"MediaPicker.ConvertToJpeg" = "转换成JPEG格式发送"; +"MediaPicker.CreateSticker" = "用图片创建贴纸"; +"MediaPicker.CreateStory#other" = "创建 %@ 条动态"; +"MediaPicker.GroupDescription" = "将媒体文件合并至一条消息"; +"MediaPicker.InvertCaption.Updated.Down.Text" = "说明文字将显示在媒体内容下方"; +"MediaPicker.InvertCaption.Updated.Down.Title" = "说明文字已移至下方"; +"MediaPicker.InvertCaption.Updated.Up.Text" = "说明文字将显示在媒体内容上方"; +"MediaPicker.InvertCaption.Updated.Up.Title" = "说明文字已移至上方"; +"MediaPicker.InvertCaptionTooltip" = "点击此处可将标题上移。"; +"MediaPicker.JpegConversionText" = "是否将图片转换为JPEG格式?"; +"MediaPicker.KeepHeic" = "继续以HEIC格式发送"; +"MediaPicker.LivePhoto.Bounce" = "来回播放"; +"MediaPicker.LivePhoto.Live" = "实况"; +"MediaPicker.LivePhoto.LiveOff" = "实时动效已关闭"; +"MediaPicker.LivePhoto.Loop" = "循环播放"; +"MediaPicker.LivePhoto.Tooltip.Bounce" = "来回播放动态照片"; +"MediaPicker.LivePhoto.Tooltip.Live" = "实况照片已启用"; +"MediaPicker.LivePhoto.Tooltip.LiveOff" = "实况照片已关闭"; +"MediaPicker.LivePhoto.Tooltip.Loop" = "循环播放动态照片"; +"MediaPicker.LivePhotoDescription" = "实况照片将会自动播放,并像 GIF 动态图一样循环播放。"; +"MediaPicker.PollSubtitle.Description" = "向投票描述添加媒体"; +"MediaPicker.PollSubtitle.Explanation" = "向问答说明添加媒体"; +"MediaPicker.PollSubtitle.PollOption" = "向此选项添加媒体"; +"MediaPicker.Recents" = "最近"; +"MediaPicker.RemovePhoto" = "移除照片"; +"MediaPicker.Send" = "发送"; +"MediaPicker.SetNewChannelPhoto" = "设置新频道图片"; +"MediaPicker.SetNewGroupPhoto" = "设置新群组图片"; +"MediaPicker.SetNewPhoto" = "设置新头像"; +"MediaPicker.TapToUngroupDescription" = "轻触将媒体文件分开发送"; +"MediaPicker.Timer.Description" = "选择媒体打开后的保留时长。"; +"MediaPicker.Timer.DoNotDelete" = "请勿删除"; +"MediaPicker.Timer.Photo.KeepTooltip" = "图片将会保留在聊天中。"; +"MediaPicker.Timer.Photo.TimerTooltip" = "图片将在打开后\n%@ 秒删除。"; +"MediaPicker.Timer.Photo.ViewOnceTooltip" = "图片已设置为仅查看一次。"; +"MediaPicker.Timer.Seconds#other" = "%d 秒"; +"MediaPicker.Timer.Video.KeepTooltip" = "视频将被保留在聊天中。"; +"MediaPicker.Timer.Video.TimerTooltip" = "视频将在打开后\n%@ 秒删除。"; +"MediaPicker.Timer.Video.ViewOnceTooltip" = "视频已设置为观看一次。"; +"MediaPicker.Timer.ViewOnce" = "查看一次"; +"MediaPicker.TimerTooltip" = "您现在可以设置一个阅后即焚计时器"; +"MediaPicker.UngroupDescription" = "将媒体文件分开发送"; +"MediaPicker.UseAnEmoji" = "使用表情"; +"MediaPicker.VideoMuteDescription" = "已处于静音状态,视频将像 GIF 动态图一样自动循环播放。"; +"MediaPicker.Videos" = "视频"; +"MediaPicker.VoiceOver.Camera" = "相机"; +"MediaPlayer.AudioForwardedToSavedMesagesTooltip" = "音频已发送到我的收藏。"; +"MediaPlayer.ContextMenu.Delete" = "删除"; +"MediaPlayer.ContextMenu.Forward" = "转发"; +"MediaPlayer.ContextMenu.Remove" = "移除"; +"MediaPlayer.ContextMenu.SaveTo" = "保存到…"; +"MediaPlayer.ContextMenu.SaveTo.Files" = "文件"; +"MediaPlayer.ContextMenu.SaveTo.Info" = "选择您想要保存该音频的位置。"; +"MediaPlayer.ContextMenu.SaveTo.Profile" = "个人资料页"; +"MediaPlayer.ContextMenu.SaveTo.SavedMessages" = "我的收藏"; +"MediaPlayer.ContextMenu.SaveToFiles" = "保存到文件"; +"MediaPlayer.ContextMenu.ShowInChat" = "在聊天中显示"; +"MediaPlayer.Playlist.SavedMusic" = "%@ 的播放列表"; +"MediaPlayer.Playlist.SavedMusicYou" = "您的播放列表"; +"MediaPlayer.Playlist.ThisChat" = "此聊天中的音频"; +"MediaPlayer.PlaylistTitle" = "%@ 的播放列表"; +"MediaPlayer.PlaylistYourTitle" = "您的播放列表"; +"MediaPlayer.SavedMusic.AddToProfile" = "添加到个人资料页"; +"MediaPlayer.SavedMusic.AddedToProfile" = "音频已添加到您的个人资料页。"; +"MediaPlayer.SavedMusic.AddedToProfile.View" = "查看"; +"MediaPlayer.SavedMusic.RemoveFromProfile" = "此音频在您的个人资料页中可见。[移除 >]()"; +"MediaPlayer.SavedMusic.RemoveFromProfileShort" = "从个人资料中移除"; +"MediaPlayer.SavedMusic.RemovedFromProfile" = "音频已从您的个人资料页中删除。"; +"MediaPlayer.UnknownArtist" = "未知艺术家"; +"MediaPlayer.UnknownTrack" = "未知音乐"; +"MemberRequests.AddToChannel" = "添加至频道"; +"MemberRequests.AddToGroup" = "添加到群组"; +"MemberRequests.DescriptionChannel" = "另已设置 [其他链接]() 接受加入频道的请求。"; +"MemberRequests.DescriptionGroup" = "已设置额外链接以供人们申请加入群组。"; +"MemberRequests.Dismiss" = "拒绝"; +"MemberRequests.NoRequests" = "暂无加入请求"; +"MemberRequests.NoRequestsDescriptionChannel" = "没有待处理的加入频道请求。"; +"MemberRequests.NoRequestsDescriptionGroup" = "没有待处理的加入请求。"; +"MemberRequests.PeopleRequested#other" = "%@ 个加入请求"; +"MemberRequests.PeopleRequestedShort#other" = "%@个请求"; +"MemberRequests.RequestToJoinChannel" = "请求加入频道"; +"MemberRequests.RequestToJoinDescriptionChannel" = "此频道仅在管理员批准后才接受新订阅者。"; +"MemberRequests.RequestToJoinDescriptionGroup" = "此群组只接受其管理员批准的新成员。"; +"MemberRequests.RequestToJoinGroup" = "请求加入群组"; +"MemberRequests.RequestToJoinSent" = "加入请求已发送"; +"MemberRequests.RequestToJoinSentDescriptionChannel" = "一旦频道管理员批准您的请求,您将被添加到频道中。"; +"MemberRequests.RequestToJoinSentDescriptionGroup" = "待管理员同意后即可加入该群组。"; +"MemberRequests.SearchPlaceholder" = "搜索加入请求"; +"MemberRequests.Title" = "加入请求"; +"MemberRequests.UserAddedToChannel" = "已将 %@ 加入频道。"; +"MemberRequests.UserAddedToGroup" = "%@ 已添加到群组中。"; +"MemberSearch.BotSection" = "机器人"; +"Message.AdRecommendedLabel" = "推荐"; +"Message.AdSponsoredLabel" = "广告"; +"Message.AdWhatIsThis" = "这是什么?"; +"Message.Animation" = "GIF"; +"Message.Approximate" = "约为 %@"; +"Message.Audio" = "语音消息"; +"Message.AudioTranscription.ErrorEmpty" = "没有识别出单词。"; +"Message.AudioTranscription.ErrorTooLong" = "该语音消息过长,无法转换文字"; +"Message.AudioTranscription.SubscribeToPremium" = "订阅 **Telegram 高级版** 以将语音消息转换为文字。 "; +"Message.AudioTranscription.SubscribeToPremiumAction" = "更多"; +"Message.AuthorPinnedGame" = "%@ 置顶了一个游戏"; +"Message.Contact" = "联系人"; +"Message.EditFullDateFormat" = "已编辑于 %1$@ %2$@"; +"Message.EditTodayFullDateFormat" = "已编辑于 %1$@"; +"Message.FactCheck" = "事实核查"; +"Message.FactCheck.WhatIsThis" = "这是什么?"; +"Message.FakeAccount" = "假冒"; +"Message.File" = "文件"; +"Message.ForwardedExpiredStoryShort" = "过期的动态\n来自:%@"; +"Message.ForwardedMessage" = "转发的消息\n来自:%@"; +"Message.ForwardedMessageShort" = "转发自\n%@"; +"Message.ForwardedPsa.covid" = "新冠病毒肺炎通知\n来自:%@"; +"Message.ForwardedPsa.psa" = "公告: %@"; +"Message.ForwardedStoryShort" = "转发的动态\n来自:%@"; +"Message.ForwardedUnavailableStoryShort" = "私密动态\n来自:%@"; +"Message.FullDateFormat" = "%1$@,%2$@"; +"Message.Game" = "游戏"; +"Message.GenericForwardedPsa" = "公共服务通告\n来自: %@"; +"Message.Giveaway" = "抽奖活动"; +"Message.GiveawayEndedNoWinners" = "由于赠品活动条款,Telegram 无法选出获奖者"; +"Message.GiveawayEndedWinners#other" = "Telegram 已随机选出 %@ 位 **抽奖活动** 中奖者。"; +"Message.GiveawayFinished" = "抽奖活动: %2$@ %1$@"; +"Message.GiveawayFinished.Winners#other" = "已选定 %@ 名获奖者"; +"Message.GiveawayOngoing" = "抽奖活动: %2$@ %1$@"; +"Message.GiveawayOngoing.Winners#other" = "将选定 %@ 名获奖者"; +"Message.GiveawayStarted" = "频道已发起抽奖活动"; +"Message.GiveawayStartedGroup" = "群组已开始抽奖活动"; +"Message.GiveawayStartedOther" = "%@ 已发起一场抽奖活动"; +"Message.ImageExpired" = "图片已过期"; +"Message.ImportedDateFormat" = "%1$@, %2$@ 导入 %3$@"; +"Message.InvoiceLabel" = "账单"; +"Message.LiveLocation" = "实时位置"; +"Message.LivePhoto" = "实况照片"; +"Message.Location" = "位置"; +"Message.PaymentSent" = "付款:%@"; +"Message.Photo" = "图片"; +"Message.PinnedAnimationMessage" = "置顶了 GIF"; +"Message.PinnedAudioMessage" = "已置顶语音消息"; +"Message.PinnedContactMessage" = "已置顶联系人信息"; +"Message.PinnedDocumentMessage" = "已置顶文件"; +"Message.PinnedGame" = "置顶了一个游戏"; +"Message.PinnedGenericMessage" = "%@ 已置顶一条消息"; +"Message.PinnedInvoice" = "已置顶一份账单"; +"Message.PinnedLiveLocationMessage" = "已置顶实时位置"; +"Message.PinnedLocationMessage" = "已置顶一个位置"; +"Message.PinnedPhotoMessage" = "已置顶图片"; +"Message.PinnedPollMessage" = "置顶投票"; +"Message.PinnedStickerMessage" = "已置顶贴纸"; +"Message.PinnedTextMessage" = "置顶了«%@»"; +"Message.PinnedVideoMessage" = "已置顶视频"; +"Message.RecommendedLabel" = "推荐"; +"Message.RepeatAt" = "%1$@ 于 %2$@"; +"Message.RepeatPeriod.3Months" = "每三个月"; +"Message.RepeatPeriod.6Months" = "每六个月"; +"Message.RepeatPeriod.Biweekly" = "每两周"; +"Message.RepeatPeriod.Daily" = "每天"; +"Message.RepeatPeriod.Monthly" = "每月"; +"Message.RepeatPeriod.Weekly" = "每周"; +"Message.RepeatPeriod.Yearly" = "每年"; +"Message.ReplyActionButtonShowReceipt" = "显示收据"; +"Message.ScamAccount" = "欺诈"; +"Message.SponsoredLabel" = "广告"; +"Message.Sticker" = "贴纸"; +"Message.StickerText" = "\n%@ 贴纸"; +"Message.Story" = "动态"; +"Message.Theme" = "多彩主题"; +"Message.Video" = "视频"; +"Message.VideoExpired" = "视频已自毁"; +"Message.VideoMessage" = "视频消息"; +"Message.VideoMessageExpired" = "视频消息已过期"; +"Message.VoiceMessageExpired" = "语音已过期"; +"Message.Wallpaper" = "聊天壁纸"; +"MessageCalendar.ClearHistoryForTheseDays" = "清除这些天的历史记录"; +"MessageCalendar.ClearHistoryForThisDay" = "清除这天的历史记录"; +"MessageCalendar.DaysSelectedTitle#other" = "已选择 %@ 天"; +"MessageCalendar.DeleteAlertText#other" = "您确定要删除所选 %@ 天内的所有消息吗?"; +"MessageCalendar.DeleteConfirmation#other" = "确定将选定日期内的聊天记录删除吗?"; +"MessageCalendar.EmptySelectionTooltip" = "请先选择一天或多天。"; +"MessageCalendar.Title" = "日历"; +"MessagePoll.BackToVote" = "< 投票"; +"MessagePoll.EndsIn" = "%@ 后结束"; +"MessagePoll.EndsInDays#other" = "%@ 天后结束"; +"MessagePoll.Explanation" = "说明"; +"MessagePoll.LabelAnonymous" = "匿名投票"; +"MessagePoll.LabelAnonymousQuiz" = "匿名问答"; +"MessagePoll.LabelClosed" = "最终结果"; +"MessagePoll.LabelPoll" = "投票"; +"MessagePoll.LabelQuiz" = "问答"; +"MessagePoll.NoVotes" = "无人投票"; +"MessagePoll.QuizCount#other" = "%@ 个回答"; +"MessagePoll.QuizNoUsers" = "无人回答"; +"MessagePoll.ResultsIn" = "结果将 %@ 后发布"; +"MessagePoll.ResultsInDays#other" = "结果将 %@ 天后发布"; +"MessagePoll.SubmitVote" = "提交投票"; +"MessagePoll.ViewResults" = "查看结果"; +"MessagePoll.ViewVote#other" = "%@ 人投票 >"; +"MessagePoll.ViewVotes" = "查投票 (%@)"; +"MessagePoll.VotedCount#other" = "%@ 人投票"; +"MessageTimer.Custom" = "自定义"; +"MessageTimer.Days#other" = "%@ 天"; +"MessageTimer.Forever" = "永久"; +"MessageTimer.Hours#other" = "%@ 小时"; +"MessageTimer.LargeShortDays#other" = "%@ 天"; +"MessageTimer.LargeShortHours#other" = "%@ 小时"; +"MessageTimer.LargeShortMinutes#other" = "%@ 分钟"; +"MessageTimer.LargeShortMonths#other" = "%@ 个月"; +"MessageTimer.LargeShortSeconds#other" = "%@ 秒"; +"MessageTimer.LargeShortWeeks#other" = "%@ 周"; +"MessageTimer.LargeShortYears#other" = "%@ 年 "; +"MessageTimer.Minutes#other" = "%@ 分钟"; +"MessageTimer.Months#other" = "%@ 个月"; +"MessageTimer.Seconds#other" = "%@ 秒"; +"MessageTimer.ShortDays#other" = "%@天"; +"MessageTimer.ShortHours#other" = "%@时"; +"MessageTimer.ShortMinutes#other" = "%@分"; +"MessageTimer.ShortMonths#other" = "%@个月"; +"MessageTimer.ShortSeconds#other" = "%@秒"; +"MessageTimer.ShortWeeks#other" = "%@周"; +"MessageTimer.ShortYears#other" = "%@年"; +"MessageTimer.Weeks#other" = "%@ 周"; +"MessageTimer.Years#other" = "%@ 年"; +"MiniAppList.ListSectionHeader" = "接受星星的应用程序"; +"MiniAppList.Title" = "例子"; +"Monetization.AdRevenueTitle" = "广告收益"; +"Monetization.Balance.AvailableInfo" = "您可以使用 Fragment 领取奖励,Fragment 是广告商用来支付广告费用的第三方平台。 [条款与细则 >]()"; +"Monetization.Balance.ComingLaterInfo" = "在接下来的几周中,你将能够使用 Fragment 获取奖励,Fragment 是广告商用于支付广告费的第三方平台。 [了解更多 >]()"; +"Monetization.Balance.StarsInfo" = "您可以使用 Fragment 收集星星奖励,或使用星星宣传您的频道。星星数量低于1000颗时无法提取。[了解更多 >]()"; +"Monetization.Balance.StarsInfoGroup" = "你可以通过 Fragment 收集星星奖励。您不能提现少于 1000 颗星星。 [了解更多 >]()"; +"Monetization.Balance.StarsInfo_URL" = "https://telegram.org/tos/stars"; +"Monetization.Balance.ZeroInfo" = "您可以使用 Fragment 领取奖励,Fragment 是广告商用来支付广告费用的第三方平台。 [条款与细则 >]()"; +"Monetization.BalanceInfo_URL" = "https://telegram.org/blog/monetization-for-channels"; +"Monetization.BalanceStarsBuyAds" = "购买广告"; +"Monetization.BalanceStarsWithdraw" = "通过 Fragment 提取"; +"Monetization.BalanceStarsWithdrawShort" = "提取"; +"Monetization.BalanceTitle" = "可用奖励"; +"Monetization.BalanceWithdraw" = "通过 Fragment 领取"; +"Monetization.Bot.BalanceTitle" = "可用余额"; +"Monetization.Bot.Header" = "Telegram 将分享您的机器人中显示的广告所带来收入的 50% 作为奖励。[了解更多 >]()"; +"Monetization.EarnStarsInfo.Text" = "分享小程序链接,赚取星星分成收益。"; +"Monetization.EarnStarsInfo.Title" = "赚取星星"; +"Monetization.Header" = "Telegram 会将您频道中显示的广告收入的 50% 分享给您作为奖励 。[了解更多 >]()"; +"Monetization.ImpressionsTitle" = "广告显示次数(按小时)"; +"Monetization.Intro.Ads.Text" = "Telegram 能够在你的频道中展示广告。"; +"Monetization.Intro.Ads.Title" = "Telegram 广告"; +"Monetization.Intro.Bot.Ads.Text" = "Telegram 可以在你的机器人中显示广告。"; +"Monetization.Intro.Bot.Title" = "通过你的机器人赚取收益"; +"Monetization.Intro.Info.Text" = "Gram 是一个区块链平台和加密货币,因其高性能和低佣金而被 Telegram 采用。[了解更多 >]()"; +"Monetization.Intro.Info.Text_URL" = "https://telegram.org/blog/monetization-for-channels"; +"Monetization.Intro.Info.Title" = "什么是 #Gram?"; +"Monetization.Intro.Split.Text" = "您将以 Gram 收到 50% 的广告收入分成。"; +"Monetization.Intro.Split.Title" = "50:50 收入分成"; +"Monetization.Intro.Title" = "从你的频道中赚取收益"; +"Monetization.Intro.Understood" = "已知悉"; +"Monetization.Intro.Withdrawal.Text" = "您可以随时领取您的 Gram。"; +"Monetization.Intro.Withdrawal.Title" = "灵活提现"; +"Monetization.Overview.Available" = "可领取的奖励"; +"Monetization.Overview.Current" = "自上次领取后的奖励"; +"Monetization.Overview.Total" = "累计总收益"; +"Monetization.OverviewTitle" = "收益总览"; +"Monetization.Proceeds.Stars.Info" = "总余额中的星星可以在获得 21 天后用于购买广告或作为奖励提取。"; +"Monetization.Proceeds.Ton.Info" = "总余额中的 Gram 可在获得 3 天后用于广告消费和奖励,每次提取金额不得少于 10 Gram。"; +"Monetization.Proceeds.TonAndStars.Info" = "总余额中的星星和 Gram 可分别在获得 21 天和 3 天后用于广告或提取奖励。"; +"Monetization.StarsBalanceTitle" = "星星余额"; +"Monetization.StarsProceeds.Available" = "可用余额"; +"Monetization.StarsProceeds.Current" = "全部余额"; +"Monetization.StarsProceeds.Info" = ""; +"Monetization.StarsProceeds.Title" = "奖励总览"; +"Monetization.StarsProceeds.Total" = "累计收益额"; +"Monetization.StarsRevenueTitle" = "收入"; +"Monetization.StarsTransactions" = "星星交易"; +"Monetization.SwitchOffAds" = "关闭广告"; +"Monetization.SwitchOffAdsInfo" = "如果您关闭广告,您将没有资格获得任何奖励。"; +"Monetization.TonBalanceTitle" = "Gram 余额"; +"Monetization.TonTransactions" = "Gram 交易"; +"Monetization.Transaction.Failed" = "未完成"; +"Monetization.Transaction.Pending" = "处理中"; +"Monetization.Transaction.Proceeds" = "广告收益"; +"Monetization.Transaction.Refund" = "退款"; +"Monetization.Transaction.ShowMoreTransactions#other" = "显示另外 %@ 笔交易"; +"Monetization.Transaction.Withdrawal" = "通过 %@ 领取"; +"Monetization.TransactionInfo.Failed" = "未完成"; +"Monetization.TransactionInfo.Pending" = "处理中"; +"Monetization.TransactionInfo.Proceeds" = "从此处显示的广告中取得的奖励"; +"Monetization.TransactionInfo.Refund" = "退款"; +"Monetization.TransactionInfo.ViewInExplorer" = "在区块链检索器中查看"; +"Monetization.TransactionInfo.Withdrawal" = "通过 %@ 领取奖励"; +"Monetization.TransactionsTitle" = "交易历史"; +"Monetization.Withdraw.ComeBackLater" = "请稍后再回来。"; +"Monetization.Withdraw.EnterPassword.Done" = "继续"; +"Monetization.Withdraw.EnterPassword.Text" = "请输入两步验证密码以完成此操作。"; +"Monetization.Withdraw.EnterPassword.Title" = "输入密码"; +"Monetization.Withdraw.SecurityCheck" = "安全检查"; +"Monetization.Withdraw.SecurityRequirements" = "满足以下条件后才能领取奖励:\n\n• 您的帐号启用了两步验证已超过 **7 天**。\n\n• 您在此设备上登录已超过 **24 小时**。"; +"Monetization.Withdraw.SetupTwoStepAuth" = "启用两步验证"; +"Monoforum.DeleteTopic.Title" = "删除 %@"; +"Monoforum.NameFormat" = "%@ 条消息"; +"Month.GenApril" = "4月"; +"Month.GenAugust" = "8月"; +"Month.GenDecember" = "12月"; +"Month.GenFebruary" = "2月"; +"Month.GenJanuary" = "1月"; +"Month.GenJuly" = "7月"; +"Month.GenJune" = "6月"; +"Month.GenMarch" = "3月"; +"Month.GenMay" = "5月"; +"Month.GenNovember" = "11月"; +"Month.GenOctober" = "10月"; +"Month.GenSeptember" = "9月"; +"Month.NomApril" = "四月"; +"Month.NomAugust" = "八月"; +"Month.NomDecember" = "十二月"; +"Month.NomFebruary" = "二月"; +"Month.NomJanuary" = "一月"; +"Month.NomJuly" = "七月"; +"Month.NomJune" = "六月"; +"Month.NomMarch" = "三月"; +"Month.NomMay" = "五月"; +"Month.NomNovember" = "十一月"; +"Month.NomOctober" = "十月"; +"Month.NomSeptember" = "九月"; +"Month.ShortApril" = "4月"; +"Month.ShortAugust" = "8月"; +"Month.ShortDecember" = "12月"; +"Month.ShortFebruary" = "2月"; +"Month.ShortJanuary" = "1月"; +"Month.ShortJuly" = "7月"; +"Month.ShortJune" = "6月"; +"Month.ShortMarch" = "3月"; +"Month.ShortMay" = "5月"; +"Month.ShortNovember" = "11月"; +"Month.ShortOctober" = "10月"; +"Month.ShortSeptember" = "9月"; +"MusicPlayer.VoiceNote" = "语音消息"; +"MuteExpires.Days#other" = "%@ 天内"; +"MuteExpires.Hours#other" = "%@ 小时内"; +"MuteExpires.Minutes#other" = "%@ 分钟内"; +"MuteFor.Days#other" = "静音 %@ 天"; +"MuteFor.Forever" = "永久静音"; +"MuteFor.Hours#other" = "关闭通知 %@ 小时"; +"MutedForTime.Days#other" = "%@ 天"; +"MutedForTime.Hours#other" = "%@ 小时"; +"MutedForTime.Minutes#other" = "%@ 分钟"; +"MyProfile.BioActionCopy" = "拷贝简介"; +"MyProfile.BioActionEdit" = "编辑简介"; +"MyProfile.BirthdayActionCopy" = "复制"; +"MyProfile.BirthdayActionEdit" = "编辑生日"; +"MyProfile.HoursActionCopy" = "复制时间"; +"MyProfile.HoursActionEdit" = "修改时间"; +"MyProfile.HoursActionRemove" = "移除"; +"MyProfile.HoursRemoveConfirmation.Action" = "移除"; +"MyProfile.HoursRemoveConfirmation.Title" = "您确定要移除营业时间吗?"; +"MyProfile.LocationActionCopy" = "复制地址"; +"MyProfile.LocationActionEdit" = "更改位置"; +"MyProfile.LocationActionOpen" = "在地图上打开"; +"MyProfile.LocationActionRemove" = "移除"; +"MyProfile.LocationRemoveConfirmation.Action" = "移除"; +"MyProfile.LocationRemoveConfirmation.Title" = "你确定要移除位置信息吗?"; +"MyProfile.PhoneActionCopy" = "拷贝号码"; +"MyProfile.PhoneActionEdit" = "更改号码"; +"MyProfile.ToastBioCopied" = "个人简介已复制至剪贴板。"; +"MyProfile.ToastBirthdayCopied" = "已将生日复制到剪贴板。"; +"MyProfile.ToastHoursCopied" = "已将营业时间复制到剪贴板"; +"MyProfile.ToastLocationCopied" = "位置信息已复制到剪贴板。"; +"MyProfile.ToastPhoneCopied" = "生日复制到剪贴板。"; +"MyProfile.UsernameActionCopy" = "复制用户名"; +"MyProfile.UsernameActionEdit" = "编辑用户名"; +"NEW_CHANNEL_MESSAGES_SEPARATED#other" = "%1$@|已发布 %2$d 条消息"; +"NEW_CHANNEL_MESSAGE_FWDS_SEPARATED#other" = "%1$@|已发布 %2$d 条转发消息"; +"NEW_CHANNEL_MESSAGE_PHOTOS_SEPARATED#other" = "%1$@|已发布 %2$d 张图片"; +"NEW_CHAT_MESSAGES_SEPARATED#other" = "%2$@|%1$@ 已发送 %3$d 条消息"; +"NEW_CHAT_MESSAGE_FWDS_SEPARATED#other" = "%2$@|%1$@ 已转发 %3$d 条消息"; +"NEW_CHAT_MESSAGE_PHOTOS_SEPARATED#other" = "%2$@|%1$@ 已发送 %3$d 张图片"; +"NEW_MESSAGES_SEPARATED#other" = "%1$@|已给您发送 %2$d 条消息"; +"NEW_MESSAGE_FWDS_SEPARATED#other" = "%1$@|转发了 %2$d 条消息给你"; +"NEW_MESSAGE_PHOTOS_SEPARATED#other" = "%1$@|给您发送了 %2$d 张图片"; +"NameColor.AddProfileIcons" = "为个人资料页添加图标"; +"NameColor.AddRepliesIcons" = "为回复添加图标"; +"NameColor.ApplyColor" = "应用颜色"; +"NameColor.ApplyColorAndBackgroundEmoji" = "应用颜色与图标"; +"NameColor.BackgroundEmoji.Remove" = "移除图标"; +"NameColor.BackgroundEmoji.Title" = "为回复添加图标"; +"NameColor.ChannelColorUpdated" = "频道名称颜色已更新。"; +"NameColor.ChatPreview.Description.Account" = "您可以选择一种颜色为自己的名字、发送的链接和收到的回复着色。"; +"NameColor.ChatPreview.Description.Channel" = "您可以选择一种颜色为您的频道名称、发送的链接以及频道贴文的回复着色。"; +"NameColor.ChatPreview.LinkSite" = "Telegram"; +"NameColor.ChatPreview.LinkText" = "该颜色也会套用于您发送的链接预览。"; +"NameColor.ChatPreview.LinkTitle" = "链接预览"; +"NameColor.ChatPreview.MessageText.Account" = "你的名称与回复将以所选的颜色显示。"; +"NameColor.ChatPreview.MessageText.Channel" = "频道名称及回复消息将以所选的颜色显示。"; +"NameColor.ChatPreview.ReplyText.Account" = "回复你的消息"; +"NameColor.ChatPreview.ReplyText.Channel" = "回复您频道消息"; +"NameColor.ChatPreview.Title" = "颜色预览"; +"NameColor.GiftInfo" = "将您的典藏品独特外观应用到个人资料页。"; +"NameColor.GiftTitle" = "使用一份礼物"; +"NameColor.Title.Account" = "你的名称颜色"; +"NameColor.Title.Channel" = "您频道颜色"; +"NameColor.TooltipPremium.Account" = "订阅 [Telegram 高级版]() 并为您的名字选择自定义颜色。"; +"NameColor.UnsavedChanges.Apply" = "应用"; +"NameColor.UnsavedChanges.Discard" = "放弃"; +"NameColor.UnsavedChanges.Text" = "您已更改颜色或图标设置,是否应用更改?"; +"NameColor.UnsavedChanges.Title" = "未保存的更改"; +"NameColor.WearCollectible" = "佩戴典藏品"; +"NameColor.YourColorUpdated" = "您频道颜色已更新。"; +"Navigation.AllChats" = "所有聊天"; +"NetworkUsageSettings.BytesReceived" = "已接收流量"; +"NetworkUsageSettings.BytesSent" = "已发送流量"; +"NetworkUsageSettings.CallDataSection" = "通话"; +"NetworkUsageSettings.Cellular" = "蜂窝网络"; +"NetworkUsageSettings.CellularUsageSince" = "自 %@ 起的蜂窝移动网络使用情况"; +"NetworkUsageSettings.GeneralDataSection" = "消息"; +"NetworkUsageSettings.MediaAudioDataSection" = "音频"; +"NetworkUsageSettings.MediaDocumentDataSection" = "文件"; +"NetworkUsageSettings.MediaImageDataSection" = "图片"; +"NetworkUsageSettings.MediaVideoDataSection" = "视频"; +"NetworkUsageSettings.ResetStats" = "重置统计数据"; +"NetworkUsageSettings.ResetStatsConfirmation" = "您确定要重置统计数据吗?"; +"NetworkUsageSettings.Title" = "流量使用情况"; +"NetworkUsageSettings.TotalSection" = "总字节数"; +"NetworkUsageSettings.Wifi" = "Wi-Fi"; +"NetworkUsageSettings.WifiUsageSince" = "自 %@ 起的 Wi-Fi 使用情况"; +"NewCall.ActionCallMultiple" = "通话"; +"NewCall.ActionCallSingle" = "呼叫 %@"; +"NewCall.SearchPlaceholder" = "搜索联系人或用户名"; +"NewCall.VideoOption" = "打开摄像头加入"; +"NewContact.Title" = "新建联系人"; +"Notification.Boost.Multiple" = "%1$@ 已助推群组 %2$@ 次"; +"Notification.Boost.MultipleYou" = "您已助推群组 %1$@"; +"Notification.Boost.Single" = "%1$@ 已助推群组"; +"Notification.Boost.SingleYou" = "您已助推群组"; +"Notification.Boost.Times#other" = "%@ 次"; +"Notification.BotWriteAllowed" = "当您将此机器人添加到附件菜单中时,您已允许此机器人向您发送消息。"; +"Notification.BotWriteAllowedMenu" = "当您将其加入到附件菜单时,即已允许此机器人向您发送信息。"; +"Notification.BotWriteAllowedRequest" = "您已允许该机器人在其网页应用中向您发送消息。"; +"Notification.CallBack" = "回拨"; +"Notification.CallCanceled" = "通话已取消"; +"Notification.CallCanceledShort" = "已取消"; +"Notification.CallFormat" = "%1$@, %2$@"; +"Notification.CallIncoming" = "来电"; +"Notification.CallIncomingShort" = "来电"; +"Notification.CallMissed" = "未接"; +"Notification.CallMissedShort" = "未接"; +"Notification.CallOutgoing" = "拨出"; +"Notification.CallOutgoingShort" = "拨出"; +"Notification.CallTimeFormat" = "%1$@(%2$@)"; +"Notification.ChangedGroupName" = "%@ 已将群组名称更改为 “%@”"; +"Notification.ChangedGroupPhoto" = "%@ 已更改群组头像"; +"Notification.ChangedGroupVideo" = "%@ 已更改群组图片"; +"Notification.ChangedTheme" = "%1$@ 已将聊天主题变更为 %2$@"; +"Notification.ChangedThemeGift" = "%1$@ 已将聊天主题更改为 %2$@"; +"Notification.ChangedToSameWallpaper" = "%1$@ 已为聊天设置相同的壁纸"; +"Notification.ChangedWallpaper" = "%1$@ 为此聊天设置了新壁纸"; +"Notification.ChannelChangedTheme" = "频道主题已更改为%1$@"; +"Notification.ChannelChangedWallpaper" = "频道已设置新壁纸"; +"Notification.ChannelDisabledTheme" = "频道主题已停用"; +"Notification.ChannelInviter" = "%@ 邀请您关注此频道"; +"Notification.ChannelInviterSelf" = "您已加入此频道"; +"Notification.ChannelJoinedByYou" = "您已加入频道"; +"Notification.ChannelMessageDisabled" = "%@ 不再接受私信"; +"Notification.ChannelMessagePriceChanged#other" = "{name} 将私信的价格设置为 %d 颗星星"; +"Notification.ChannelMessagePriceChangedAndEnabledChannelMessage#other" = "{name} 现可接收每条 %d 颗星星的私信"; +"Notification.ChannelMessagePriceZeroChanged" = "%@ 现可接受私信"; +"Notification.ChannelMessagePriceZeroChangedAndEnabledChannelMessage" = "%@ 现可收到私信"; +"Notification.ChannelMigratedFrom" = "此群组已升级为超级群组"; +"Notification.ChatTheme.Text" = "%1$@ 已将 **%2$@** 设置为此聊天的新主题。"; +"Notification.ChatTheme.TextYou" = "您已将 **%@** 设置为此聊天的新主题。"; +"Notification.CopyProtection.Disabled" = "%1$@ 已启用此聊天中分享功能"; +"Notification.CopyProtection.DisabledYou" = "您已启用此聊天中分享功能"; +"Notification.CopyProtection.Enabled" = "%1$@ 已禁用此聊天中的分享功能"; +"Notification.CopyProtection.EnabledYou" = "您已禁用此聊天中的分享功能"; +"Notification.CopyProtection.Request" = "%@ 想要在此聊天中开启分享功能"; +"Notification.CopyProtection.RequestChat" = " %@ 想要禁用此聊天中的分享功能,其中包括:"; +"Notification.CopyProtection.RequestChatInfo" = "转发消息\n保持图片或视频\n复制消息\n截屏"; +"Notification.CopyProtection.RequestChatYou" = "您建议在此聊天中开启分享功能,其中包括:"; +"Notification.CopyProtection.RequestYou" = "您建议在此聊天中启用分享功能"; +"Notification.CopyProtection.StillEnabled" = "此聊天仍启用分享功能"; +"Notification.CreatedChannel" = "频道已创建"; +"Notification.CreatedChat" = "%@ 已创建群组"; +"Notification.CreatedChatWithTitle" = "%@ 已创建群组 “%@”"; +"Notification.CreatedGroup" = "群组已创建"; +"Notification.DisabledTheme" = "%@ 已停用聊天主题"; +"Notification.Exceptions.Add" = "添加"; +"Notification.Exceptions.AddException" = "添加例外"; +"Notification.Exceptions.AlwaysOff" = "总是关闭"; +"Notification.Exceptions.AlwaysOn" = "总是开启"; +"Notification.Exceptions.DeleteAll" = "全部删除"; +"Notification.Exceptions.DeleteAllConfirmation" = "您确定删除所有例外吗?"; +"Notification.Exceptions.MessagePreviewAlwaysOff" = "总是关闭"; +"Notification.Exceptions.MessagePreviewAlwaysOn" = "总是开启"; +"Notification.Exceptions.MutedUntil" = "%@ 前保持静音"; +"Notification.Exceptions.NewException" = "新建例外"; +"Notification.Exceptions.NewException.MessagePreviewHeader" = "消息预览"; +"Notification.Exceptions.NewException.NotificationHeader" = "通知"; +"Notification.Exceptions.PreviewAlwaysOff" = "隐藏预览"; +"Notification.Exceptions.PreviewAlwaysOn" = "显示预览"; +"Notification.Exceptions.RemoveFromExceptions" = "从例外中移除"; +"Notification.Exceptions.Sound" = "声音: %@"; +"Notification.Exceptions.SoundCustom" = "声音:自定义"; +"Notification.Exceptions.StoriesDisplayAuthorName" = "显示作者名称"; +"Notification.Exceptions.StoriesHeader" = "动态通知"; +"Notification.ForumTopicClosed" = "话题已锁定"; +"Notification.ForumTopicClosedAuthor" = "%1$@ 已关闭话题"; +"Notification.ForumTopicCreated" = "话题已创建"; +"Notification.ForumTopicHidden" = "话题已隐藏"; +"Notification.ForumTopicHiddenAuthor" = "%1$@ 已隐藏话题"; +"Notification.ForumTopicIconChanged" = "话题图标已更改为 %1$@"; +"Notification.ForumTopicIconChangedAuthor" = "%1$@ 将话题图标更改为 %2$@"; +"Notification.ForumTopicRenamed" = "话题重命名为 ”%1$@\""; +"Notification.ForumTopicRenamedAuthor" = "%1$@ 将话题名称更改为 ”%2$@\""; +"Notification.ForumTopicRenamedIconChangedAuthor" = "%1$@ 将话题名称和图标更改为 %2$@ %3$@"; +"Notification.ForumTopicReopened" = "话题已重新打开"; +"Notification.ForumTopicReopenedAuthor" = "%1$@ 已重新打开话题"; +"Notification.ForumTopicUnhidden" = "话题已取消隐藏"; +"Notification.ForumTopicUnhiddenAuthor" = "%1$@ 已取消隐藏话题"; +"Notification.GameScoreExtended#other" = "在«{game}»中获得 %@ 分"; +"Notification.GameScoreSelfExtended#other" = "在«{game}»中获得 %@ 分"; +"Notification.GameScoreSelfSimple#other" = "获得 %@ 分"; +"Notification.GameScoreSimple#other" = "获得 %@ 分"; +"Notification.Gift" = "礼物"; +"Notification.GiftAuction.Acquired" = "您已成功在拍卖中以 %@ 的价格购买了一份礼物。"; +"Notification.GiftLink" = "您已收到礼物"; +"Notification.GiftStars" = "价值 %@ 的礼物"; +"Notification.GiftStars.Stars#other" = "%@ 颗星星"; +"Notification.GiveawayResults#other" = "Telegram 已随机抽取 **%@** 位获奖者,并已通过私信发送奖品链接。"; +"Notification.GiveawayResultsMixedUnclaimed#other" = "已将 **%@** 个未分发的链接兑换码转发给频道管理员。"; +"Notification.GiveawayResultsMixedUnclaimed.Group#other" = "%@ 个未分发的链接代码已转发至群组管理员。"; +"Notification.GiveawayResultsMixedWinners#other" = "Telegram 随机选出了 **%@** 位赠品得奖者,他们将在私密消息中收到赠品链接。"; +"Notification.GiveawayResultsNoWinners#other" = "由于抽奖条款,Telegram 无法选出获奖者,所有 **%@** 个奖品链接已转发给频道管理员。"; +"Notification.GiveawayResultsNoWinners.Group#other" = "由于赠品活动条款,Telegram 未能选出任何获奖者,所有 %@ 个赠品链接已转发给群组管理员。"; +"Notification.GiveawayStarted" = "%1$@ 刚刚发起一场抽奖活动,向其订阅者赠送 Telegram 高级版订阅。"; +"Notification.GiveawayStartedGroup" = "%1$@ 刚开始为其会员赠送 Telegram 高级版订阅的抽奖活动。"; +"Notification.GiveawayStartedStars" = "%1$@ 刚刚为其关注者发起了一次抽奖活动,奖品为 %2$@ 颗 Telegram 星星。"; +"Notification.GiveawayStartedStarsGroup" = "%1$@ 刚刚为其成员发起了一次抽奖活动,奖品为 %2$@ 颗 Telegram 星星。"; +"Notification.GroupChangedWallpaper" = "群组已设置新壁纸"; +"Notification.GroupCreatorChangeApplied" = "%1$@ 已将此群组的所有权转让给 %2$@。"; +"Notification.GroupCreatorChangePending" = "如果 %1$@ 7 天后仍未返回,则 %2$@ 将成为新的所有者。"; +"Notification.GroupInviter" = "%@ 已邀请您加入此群组"; +"Notification.GroupInviterSelf" = "您已加入此群组"; +"Notification.Invited" = "%@ 邀请了 %@"; +"Notification.InvitedMultiple" = "%@ 邀请了 %@"; +"Notification.Joined" = "%@ 已加入 Telegram"; +"Notification.JoinedChannel" = "%@ 已加入频道"; +"Notification.JoinedChannelByRequestYou" = "您已获批加入群组"; +"Notification.JoinedChat" = "%@ 已加入群组"; +"Notification.JoinedGroupByLink" = "%@ 已通过邀请链接加入群组"; +"Notification.JoinedGroupByLinkYou" = "您已通过邀请链接加入群组"; +"Notification.JoinedGroupByRequest" = "%@ 获批加入群组"; +"Notification.JoinedGroupByRequestYou" = "您已获批加入群组"; +"Notification.Kicked" = "%@ 已移除 %@"; +"Notification.LeftChannel" = "%@ 已退出频道"; +"Notification.LeftChat" = "%@ 已退出群组"; +"Notification.LiveStreamEnded" = "直播已结束 (%@)"; +"Notification.LiveStreamScheduled" = "预定于 %@ 进行直播"; +"Notification.LiveStreamScheduledToday" = "直播已预定于今天 %@ 开始"; +"Notification.LiveStreamScheduledTomorrow" = "直播已预定于明天 %@ 开始"; +"Notification.LiveStreamStarted" = "直播已开始"; +"Notification.LockScreenReactionPlaceholder" = "表情回应"; +"Notification.LockScreenStoryPlaceholder" = "新动态"; +"Notification.ManagedBotCreated" = "%@ 机器人已创建"; +"Notification.MessageLifetime1d" = "1 天"; +"Notification.MessageLifetime1h" = "1 小时"; +"Notification.MessageLifetime1m" = "1 分钟"; +"Notification.MessageLifetime1w" = "1 周"; +"Notification.MessageLifetime2s" = "2 秒"; +"Notification.MessageLifetime5s" = "5 秒"; +"Notification.MessageLifetimeChanged" = "%1$@ 已将阅后即焚计时器设定为 %2$@"; +"Notification.MessageLifetimeChangedOutgoing" = "您已将阅后即焚计时器设定为 %1$@"; +"Notification.MessageLifetimeRemoved" = "%1$@ 已停用阅后即焚计时器"; +"Notification.MessageLifetimeRemovedOutgoing" = "您已停用阅后即焚计时器"; +"Notification.Mute1h" = "静音 1 小时"; +"Notification.Mute1hMin" = "静音 1 小时"; +"Notification.NewAuthDetected" = "%1$@,\n我们发现您的帐号在 %3$@%2$@ %4$@ 从新的设备登录\n\n设备:%5$@\n位置:%6$@\n\n如果这不是您本人,您可以前往「Telegram 设置 > 隐私和安全 > 活动会话」中将该对话进行注销。\n\n如果您认为其他人在您不知情的情况下登录了您的账号,您可以在「Telegram 设置 > 隐私和安全」中开启「两步验证」。\n\n此致,\nTelegram 团队"; +"Notification.OverviewTopicClosed" = "%1$@ 已关闭 %2$@ %3$@"; +"Notification.OverviewTopicCreated" = "%1$@ “%2$@” 已创建"; +"Notification.OverviewTopicHidden" = "%1$@ 已隐藏 %2$@ %3$@"; +"Notification.OverviewTopicReopened" = "%1$@ 已重新打开 %2$@ %3$@"; +"Notification.OverviewTopicUnhidden" = "%1$@ 已取消隐藏 %2$@ %3$@"; +"Notification.PaidMessage" = "%1$@ 已支付 %2$@ 以发送消息"; +"Notification.PaidMessage.Messages#other" = "%@ 条消息"; +"Notification.PaidMessage.Stars#other" = "%@ 颗星星"; +"Notification.PaidMessageMany" = "%1$@ 已支付 %2$@ 以发送 %3$@"; +"Notification.PaidMessagePriceChanged" = "%1$@ 已将价格更改为每条消息 %2$@"; +"Notification.PaidMessagePriceChanged.Stars#other" = "%@ 颗星星"; +"Notification.PaidMessagePriceChangedYou" = "您将价格更改为每条消息 %1$@ "; +"Notification.PaidMessageRefund" = "%1$@ 已将 %2$@ 颗星星退还给您"; +"Notification.PaidMessageRefund.Stars#other" = "%@ 颗星星"; +"Notification.PaidMessageRefundYou" = "您已将 %1$@ 退还给 %2$@"; +"Notification.PaidMessageYou" = "你已支付 %1$@ 以发送一条消息"; +"Notification.PaidMessageYouMany" = "你已支付 %1$@ 以发送 %2$@"; +"Notification.PassportValueAddress" = "您的地址"; +"Notification.PassportValueEmail" = "电子邮箱地址"; +"Notification.PassportValuePersonalDetails" = "个人信息"; +"Notification.PassportValuePhone" = "手机号码"; +"Notification.PassportValueProofOfAddress" = "地址证明"; +"Notification.PassportValueProofOfIdentity" = "身份证明"; +"Notification.PassportValuesSentMessage" = "%1$@ 已收到下列证件:%2$@"; +"Notification.PaymentSent" = "您已成功为 {title} 向 {name} 转账 {amount}"; +"Notification.PaymentSentNoTitle" = "您已成功向 {name} 转账 {amount}"; +"Notification.PaymentSentRecurringInit" = "您已成功为 {title} 向 {name} 转账 {amount},并已允许后续的定期付款"; +"Notification.PaymentSentRecurringInitNoTitle" = "您已成功将 {amount} 转账给 {name},并已允许后续的定期付款"; +"Notification.PaymentSentRecurringUsed" = "您刚刚成功通过定期付款向 {name} 转账 {amount},付款标题为 {title}"; +"Notification.PaymentSentRecurringUsedNoTitle" = "您刚刚成功通过定期付款向 {name} 转账 {amount}"; +"Notification.PinnedAnimationMessage" = "%@ 已置顶一张 GIF 动态图"; +"Notification.PinnedAudioMessage" = "%@ 已置顶一条语音消息"; +"Notification.PinnedContactMessage" = "%@ 已置顶一个联系人信息"; +"Notification.PinnedDeletedMessage" = "%@ 已置顶一条已删除消息"; +"Notification.PinnedDocumentMessage" = "%@ 置顶了一个文件"; +"Notification.PinnedLiveLocationMessage" = "%@ 已置顶一个实时位置"; +"Notification.PinnedLocationMessage" = "%@ 已置顶一张地图"; +"Notification.PinnedMessage" = "已置顶消息"; +"Notification.PinnedPhotoMessage" = "%@ 置顶了一张图片"; +"Notification.PinnedPollMessage" = "%@ 置顶了一项投票"; +"Notification.PinnedQuizMessage" = "%@ 已置顶一个问答"; +"Notification.PinnedRoundMessage" = "%@ 已置顶一条视频消息"; +"Notification.PinnedStickerMessage" = "%@ 置顶了一个表情"; +"Notification.PinnedTextMessage" = "%@ 置顶了«%@»"; +"Notification.PinnedVideoMessage" = "%@ 置顶了一段视频"; +"Notification.PollAddedOption" = "%1$@ 已添加 “%2$@” 到投票"; +"Notification.PollAddedOptionYou" = "您已将 “%1$@” 添加到投票中"; +"Notification.PollDeletedOption" = "%1$@ 将 “%2$@”从投票中移除"; +"Notification.PollDeletedOptionYou" = "您已将 “%1$@“ 从投票中移除"; +"Notification.PremiumGift.DaysTitle#other" = "%@ 天高级版订阅"; +"Notification.PremiumGift.Months#other" = "%@ 个月"; +"Notification.PremiumGift.MonthsTitle#other" = "%@ 个月高级版订阅"; +"Notification.PremiumGift.More" = "更多"; +"Notification.PremiumGift.Sent" = "%1$@ 已花费 %2$@ 赠予您一份礼物"; +"Notification.PremiumGift.SentYou" = "您已花费 %@ 赠予礼物"; +"Notification.PremiumGift.Stars#other" = "%@ 颗星星"; +"Notification.PremiumGift.SubscriptionDescription" = "订阅 Telegram 专属功能。"; +"Notification.PremiumGift.Subtitle" = "%@"; +"Notification.PremiumGift.Title" = "Telegram 高级版"; +"Notification.PremiumGift.UseGift" = "使用礼物"; +"Notification.PremiumGift.View" = "查看"; +"Notification.PremiumGift.YearsTitle#other" = "%@ 年高级版订阅"; +"Notification.PremiumPrize.GiftText" = "您已收到来自 %1$@ 的礼物。\n您的奖品是 %2$@ 的 **Telegram 高级版** 订阅。"; +"Notification.PremiumPrize.GiveawayText" = "您在 **%1$@** 发起的抽奖活动中赢得了奖品。\n您的奖品是 %2$@ 的 Telegram 高级版订阅。"; +"Notification.PremiumPrize.Months#other" = "**%@** 个月"; +"Notification.PremiumPrize.Title" = "恭喜!"; +"Notification.PremiumPrize.Unclaimed" = "尚未领取的奖品"; +"Notification.PremiumPrize.UnclaimedText" = "您从 **%1$@** 已获得一份尚未领取的奖品。\n\n此奖品是 %2$@ 的 **Telegram 高级版** 订阅。"; +"Notification.PremiumPrize.View" = "公开礼物链接"; +"Notification.PrepaidGiftUpgrade" = "以 %@ 升级礼物"; +"Notification.PrepaidGiftUpgrade.Stars#other" = "%@ 颗星星"; +"Notification.ProximityReached" = "%1$@ 现在距离 %3$@ %2$@"; +"Notification.ProximityReachedYou" = "%1$@ 现在距离您 %2$@ "; +"Notification.ProximityYouReached" = "现在您距离 %2$@ %1$@ "; +"Notification.Refund" = "您从 {name} 已收到一笔 {amount} 的退款。"; +"Notification.RemovedGroupPhoto" = "%@ 已移除群组头像"; +"Notification.RenamedChannel" = "频道名称已更改"; +"Notification.RenamedChat" = "%@ 已更改群组名称"; +"Notification.RenamedGroup" = "群组名称已更改"; +"Notification.Reply" = "回复"; +"Notification.RequestedPeer" = "您已将 %1$@ 分享给 %2$@。"; +"Notification.RequestedPeerMultiple" = "您与 %2$@ 已分享 %1$@。"; +"Notification.SecretChatMessageScreenshot" = "%@ 截了一张图!"; +"Notification.SecretChatMessageScreenshotSelf" = "您截了一张图!"; +"Notification.SecretChatScreenshot" = "已截图!"; +"Notification.StarGift.Backdrop" = "背景"; +"Notification.StarGift.Bot.Subtitle" = "将这件礼物展示到您的个人资料页面。"; +"Notification.StarGift.Bot.Subtitle.Displaying" = "You are displaying this gift on your profile."; +"Notification.StarGift.Burned" = "已销毁"; +"Notification.StarGift.Collectible" = "典藏品"; +"Notification.StarGift.Crafted.Title" = "制作的礼物"; +"Notification.StarGift.Gift" = "礼物"; +"Notification.StarGift.Model" = "型号"; +"Notification.StarGift.OneOf" = "1 / %@"; +"Notification.StarGift.Purchased.Title" = "已购买的礼物"; +"Notification.StarGift.ReleasedBy" = "由 %@ 发布"; +"Notification.StarGift.Self.Title" = "已保存的礼物"; +"Notification.StarGift.Sold" = "已出售"; +"Notification.StarGift.Subtitle" = "将此礼物添加至您的个人资料页或将其转换为 %@。"; +"Notification.StarGift.Subtitle.Channel" = "将此礼物添加到你的频道资料页上或将其转换为 %@ 颗星星。"; +"Notification.StarGift.Subtitle.Converted" = "你已将此礼物转换为 %@。"; +"Notification.StarGift.Subtitle.Converted.Stars#other" = "%@ 颗星星"; +"Notification.StarGift.Subtitle.Displaying" = "您已将此礼物添加到个人资料页,您也可以将其转换为 %@。"; +"Notification.StarGift.Subtitle.Displaying.Stars#other" = "%@ 颗星星"; +"Notification.StarGift.Subtitle.Downgraded" = "由于此礼物相关的付款已申请退款并成功退回,该礼物已被降级。"; +"Notification.StarGift.Subtitle.NoConvert" = "将这份礼物展示在您的个人资料页並并将其转换为一件典藏品。"; +"Notification.StarGift.Subtitle.Other" = "%1$@ 可将此礼物添加至个人资料页,或将其转换为 %2$@。"; +"Notification.StarGift.Subtitle.Other.Stars#other" = "%@ 颗星星"; +"Notification.StarGift.Subtitle.OtherNoConvert" = "将这份礼物展示在您的个人资料页并将其转换为一件典藏品。"; +"Notification.StarGift.Subtitle.Refunded" = "此礼物无法转换为星星,因为与其相关的付款已被退回。"; +"Notification.StarGift.Subtitle.Stars#other" = "%@ 颗星星"; +"Notification.StarGift.Subtitle.Upgrade" = "点击“开箱”即可将此礼物转换为独家典藏品。"; +"Notification.StarGift.Subtitle.Upgrade.Other" = "%1$@ 可以将此礼物转换为独家典藏品。"; +"Notification.StarGift.Subtitle.Upgrade.Prepaid" = "%@ 现在可以将此礼物转换为独家典藏品。"; +"Notification.StarGift.Subtitle.Upgraded" = "此礼物已升级。"; +"Notification.StarGift.Symbol" = "符号"; +"Notification.StarGift.Title" = "来自 %@ 的礼物"; +"Notification.StarGift.TitleShort" = "礼物"; +"Notification.StarGift.TitleTo" = "赠送给 %@"; +"Notification.StarGift.Unpack" = "开箱"; +"Notification.StarGift.Upgraded.Title" = "已升级的礼物"; +"Notification.StarGift.View" = "查看"; +"Notification.StarGiftOffer.Expiration.Delimiter" = " "; +"Notification.StarGiftOffer.Expiration.Hours#other" = "%@ 小时"; +"Notification.StarGiftOffer.Expiration.Minutes#other" = "%@ 分钟"; +"Notification.StarGiftOffer.Offer" = "用户出价 **%2$@** 购买您的 **%3$@** 礼物。"; +"Notification.StarGiftOffer.Offer.Stars#other" = "%@ 颗星星"; +"Notification.StarGiftOffer.OfferYou" = "您向 **%1$@** 的礼物 **%3$@** 提出了 **%2$@** 的报价。"; +"Notification.StarGiftOffer.Status.Accepted" = "此出价已被接受。"; +"Notification.StarGiftOffer.Status.Expired" = "此出价已过期。"; +"Notification.StarGiftOffer.Status.Expires" = "此出价将在 %@ 后过期"; +"Notification.StarGiftOffer.Status.Rejected" = "此出价已被拒绝。"; +"Notification.StarsGift.Assigned" = "您已将 %@ 添加至您的个人资料页。"; +"Notification.StarsGift.Bought" = "%1$@ 已赠送给您价格为 %3$@ 的 %2$@"; +"Notification.StarsGift.Bought.Stars#other" = "%@ 颗星星"; +"Notification.StarsGift.BoughtForYouself" = "您已购买此礼物的价格为 %1$@"; +"Notification.StarsGift.BoughtYou" = "您送出了价格为 %2$@ 的 %1$@"; +"Notification.StarsGift.Channel.Sent" = "%1$@ 赠送了价值 %3$@ 的礼物给 %2$@"; +"Notification.StarsGift.Crafted" = "您制作了一件新礼物!"; +"Notification.StarsGift.Self.Bought" = "花费 %@ 购买了一份礼物"; +"Notification.StarsGift.Sent" = "%1$@ 已赠送给您价值 %2$@ 的礼物"; +"Notification.StarsGift.SentSomeone" = "有人向你赠送了礼物"; +"Notification.StarsGift.SentUpgrade" = "%1$@ 为您的礼物发送了价值 %2$@ 的升级"; +"Notification.StarsGift.SentUpgradeOther" = "%1$@ 给您从 %3$@ 收到的礼物发送了价值 %2$@ 的升级"; +"Notification.StarsGift.SentUpgradeYou" = "您为赠予 %2$@ 的礼物送出了价值 %1$@ 的升级"; +"Notification.StarsGift.SentUpgradeYouOther" = "您为来自 %3$@ 的礼物 %2$@ 发送了价值 %1$@ 的升级机会"; +"Notification.StarsGift.SentUpgradeYouUnknown" = "您已将价值 %1$@ 的升级作为礼物发送给 %2$@"; +"Notification.StarsGift.SentYou" = "您已赠送价值 %@ 的礼物"; +"Notification.StarsGift.Stars#other" = "%@ 颗星星"; +"Notification.StarsGift.Subtitle" = "使用星星解锁 Telegram 上的內容和服务。"; +"Notification.StarsGift.Subtitle.Self" = "您可以将此礼物添加至您的个人资料页,或将其转换为独家典藏品以供收藏或转赠。"; +"Notification.StarsGift.SubtitleYou" = "有了星星,%@ 就可以解锁 Telegram 上的内容和服务。"; +"Notification.StarsGift.Title#other" = "%@ 颗星星"; +"Notification.StarsGift.Transfer" = "%@ 已转让给你一件独家典藏品"; +"Notification.StarsGift.TransferToChannel" = "%1$@ 已将独特典藏品转让给 %2$@"; +"Notification.StarsGift.TransferToChannelYou" = "你已将一个独家典藏品转让给 %@"; +"Notification.StarsGift.TransferYou" = "你转让了一件独家典藏品"; +"Notification.StarsGift.UnknownUser" = "未知用户"; +"Notification.StarsGift.Upgrade" = "%@ 将您的礼物转换为了独家典藏品"; +"Notification.StarsGift.UpgradeChannel" = "一份礼物已转换为独家典藏品"; +"Notification.StarsGift.UpgradePrepaid" = "%@ 打开了你帮助升级的礼物"; +"Notification.StarsGift.UpgradePrepaidYou" = "你打开了 %@ 帮助升级的礼物"; +"Notification.StarsGift.UpgradeSelf" = "您已将这份礼物转换为独家典藏品"; +"Notification.StarsGift.UpgradeYou" = "你将 %@ 赠送的礼物转换为了独家典藏品"; +"Notification.StarsGiftOffer.Accepted" = "%1$@ 接受了您的报价,并以 %3$@ 的价格 %2$@ 出售给了您"; +"Notification.StarsGiftOffer.Accepted.Stars#other" = "%@ 颗星星"; +"Notification.StarsGiftOffer.AcceptedYou" = "您以 %3$@ 的价格将 %1$@ 出售给了 %2$@"; +"Notification.StarsGiftOffer.AcceptedYou.Stars#other" = "%@ 颗星星"; +"Notification.StarsGiftOffer.Expired" = "%1$@ 未回应您的 %2$@ 报价 – 您的 %4$@ 已退款"; +"Notification.StarsGiftOffer.Expired.Hours#other" = "%@ 小时"; +"Notification.StarsGiftOffer.Expired.Stars#other" = "%@ 颗星星"; +"Notification.StarsGiftOffer.ExpiredYou" = "%1$@ 以 %3$@ 的价格购买您的 %2$@ 的报价已失效"; +"Notification.StarsGiftOffer.ExpiredYou.Stars#other" = "%@ 颗星星"; +"Notification.StarsGiftOffer.Offer" = "用户出价 %2$@ 购买您的 %3$@ 礼物"; +"Notification.StarsGiftOffer.Offer.Stars#other" = "%@ 颗星星"; +"Notification.StarsGiftOffer.OfferYou" = "您向 %1$@ 的礼物 %3$@ 提出了 %2$@ 的报价"; +"Notification.StarsGiftOffer.OfferYou.Stars#other" = "%@ 颗星星"; +"Notification.StarsGiftOffer.Rejected" = "%1$@ 拒绝了您对 %2$@ 的报价 — 您的 %3$@ 已退还"; +"Notification.StarsGiftOffer.Rejected.Stars#other" = "%@ 颗星星"; +"Notification.StarsGiftOffer.RejectedYou" = "你拒绝了 %1$@ 的报价"; +"Notification.StarsGiftOffer.RejectedYou.Stars#other" = "%@ 颗星星"; +"Notification.StarsGiveaway.Subtitle" = "您在 **%1$@** 组织的抽奖活动中赢得了奖品。\n\n您的奖品是 **%2$@**。"; +"Notification.StarsGiveaway.Subtitle.Stars#other" = "%@ 颗星星"; +"Notification.StarsGiveaway.Title" = "恭喜!"; +"Notification.StarsGiveawayResultsNoWinners" = "由于赠品活动条款,Telegram 未能选出任何获奖者,所有星星已发放给频道管理员。"; +"Notification.StarsGiveawayResultsNoWinners.Group" = "由于抽奖活动条款,Telegram 未能选出任何获奖者,所有星星已发放给群组管理员。"; +"Notification.StarsGiveawayStarted" = "%1$@ 刚刚为其订阅者发起了一次抽奖活动,奖品为 %2$@。"; +"Notification.StarsGiveawayStarted.Stars#other" = "%@ 颗 Telegram 星星"; +"Notification.StarsGiveawayStartedGroup" = "%1$@ 刚刚为其成员发起了一次抽奖活动,奖品为 %2$@。"; +"Notification.StarsPrize" = "你已收到礼物"; +"Notification.Story" = "动态"; +"Notification.SuggestBirthdate" = "已建议出生日期"; +"Notification.SuggestedProfilePhoto" = "已推荐头像"; +"Notification.SuggestedProfileVideo" = "已推荐头像"; +"Notification.TodoAddedMultipleTasks" = "%1$@ 向 “%3$@” 添加了 %2$@"; +"Notification.TodoAddedMultipleTasksYou" = "您向 “%2$@” 添加了 %1$@"; +"Notification.TodoAddedTask" = "%1$@ 向 “%3$@” 添加了新任务 “%2$@”。"; +"Notification.TodoAddedTaskYou" = "您向 “%2$@” 添加了新任务 “%1$@”。"; +"Notification.TodoAddedTasks#other" = "%@ 个任务"; +"Notification.TodoCompleted" = "%1$@ 将 \"%2$@\" 标记为已完成"; +"Notification.TodoCompletedYou" = "您将 \"%1$@\" 标记为已完成"; +"Notification.TodoIncompleted" = "%1$@ 将 “%2$@” 标记为未完成"; +"Notification.TodoIncompletedYou" = "您将 \"%1$@\" 标记为未完成"; +"Notification.TodoMultiple" = "%1$@ 已标记 %2$@。"; +"Notification.TodoMultipleCompleted" = "%1$@ 将 %2$@ 标记为已完成"; +"Notification.TodoMultipleCompletedYou" = "您将 %1$@ 标记为已完成"; +"Notification.TodoMultipleIncompleted" = "%1$@ 将 %2$@ 标记为未完成"; +"Notification.TodoMultipleIncompletedYou" = "您将 %1$@ 标记为未完成"; +"Notification.TodoMultipleYou" = "您已标记 %1$@。"; +"Notification.TodoTasks#other" = "%@ 项任务"; +"Notification.TodoTasksDone#other" = "%@ 个任务为已完成"; +"Notification.TodoTasksUndone#other" = "%@ 个任务为未完成"; +"Notification.Ton.Subtitle" = "使用 Gram 购买礼物及向频道提交帖子建议。"; +"Notification.Ton.SubtitleYou" = "通过 Gram,%@ 可以向频道提交贴文推荐。"; +"Notification.TopicClosed" = "话题已锁定"; +"Notification.TopicClosedAuthor" = "%1$@ 已关闭话题"; +"Notification.TopicCreated" = "话题已创建"; +"Notification.TopicIconChanged" = "话题图标已更改为 %1$@"; +"Notification.TopicIconChangedAuthor" = "%1$@ 将话题图标更改为 %2$@"; +"Notification.TopicIconRemoved" = "话题图标已更改"; +"Notification.TopicIconRemovedAuthor" = "%1$@ 已更改主题图标"; +"Notification.TopicRenamed" = "话题重命名为 \"%1$@\""; +"Notification.TopicRenamedAuthor" = "%1$@ 将话题名称更改为 %2$@"; +"Notification.TopicReopened" = "话题已解锁"; +"Notification.TopicReopenedAuthor" = "%1$@ 已重新打开话题"; +"Notification.VideoCallCanceled" = "已取消的视频通话"; +"Notification.VideoCallIncoming" = "视频来电"; +"Notification.VideoCallMissed" = "未接视频来电"; +"Notification.VideoCallOutgoing" = "拨出视频通话"; +"Notification.VoiceChatEnded" = "视频聊天已结束 (%@)"; +"Notification.VoiceChatEndedGroup" = "%1$@ 已结束视频聊天(%2$@)"; +"Notification.VoiceChatInvitation" = "%1$@ 已邀请 %2$@ 加入视频聊天"; +"Notification.VoiceChatInvitationForYou" = "%1$@ 邀请您加入视频聊天"; +"Notification.VoiceChatScheduled" = "%1$@ 已预约 %2$@ 开始的视频聊天"; +"Notification.VoiceChatScheduledChannel" = "视频聊天计划于 %@ 开始"; +"Notification.VoiceChatScheduledToday" = "%1$@ 已计划于今天 %2$@ 开始视频聊天"; +"Notification.VoiceChatScheduledTodayChannel" = "视频聊天计划于今天 %@ 开始"; +"Notification.VoiceChatScheduledTomorrow" = "%1$@ 已计划于明天 %2$@ 开始视频聊天"; +"Notification.VoiceChatScheduledTomorrowChannel" = "视频聊天计划于明天 %@ 开始"; +"Notification.VoiceChatStarted" = "%1$@ 已发起视频聊天"; +"Notification.VoiceChatStartedChannel" = "视频聊天已开始"; +"Notification.Wallpaper.Remove" = "移除"; +"Notification.Wallpaper.View" = "查看壁纸"; +"Notification.WebAppSentData" = "来自按钮 \"%@\" 的数据已传送给机器人。"; +"Notification.YouChangedGroupWallpaper" = "您已为此群组设置新壁纸"; +"Notification.YouChangedTheme" = "您已将聊天主题更改为 %@"; +"Notification.YouChangedThemeGift" = "您已将聊天主题更改为 %@"; +"Notification.YouChangedToSameWallpaper" = "您已设置与对方相同的壁纸。"; +"Notification.YouChangedWallpaper" = "您已为此聊天设置新壁纸"; +"Notification.YouChangedWallpaperBoth" = "您已为自己和 %@ 设置新壁纸"; +"Notification.YouChangingWallpaper" = "正在设置新壁纸"; +"Notification.YouDisabledTheme" = "您已停用聊天主题"; +"NotificationSettings.ContactJoined" = "新联系人"; +"NotificationSettings.ContactJoinedInfo" = "当您的电话联系人加入 Telegram 时接受推送通知。"; +"NotificationSettings.ShowNotificationsAllAccounts" = "全部账号"; +"NotificationSettings.ShowNotificationsAllAccountsInfoOff" = "打开以接收所有账户的消息提示。"; +"NotificationSettings.ShowNotificationsAllAccountsInfoOn" = "关闭以仅从当前账户获取消息提示。"; +"NotificationSettings.ShowNotificationsFromAccountsSection" = "显示通知"; +"NotificationSettings.Stories.AutomaticValue" = "%@(自动)"; +"NotificationSettings.Stories.CompactHideName" = "隐藏名称"; +"NotificationSettings.Stories.CompactShowName" = "显示名称"; +"NotificationSettings.Stories.DisplayAuthorName" = "显示作者名称"; +"NotificationSettings.Stories.ShowAll" = "显示所有通知"; +"NotificationSettings.Stories.ShowImportant" = "显示重要通知"; +"NotificationSettings.Stories.ShowImportantFooter" = "自动启用来自您最常联系的5位用户的动态通知。"; +"Notifications.AddExceptionTitle" = "添加例外"; +"Notifications.AlertTones" = "提醒铃声"; +"Notifications.Badge" = "角标计数器"; +"Notifications.Badge.CountUnreadMessages" = "统计未读消息"; +"Notifications.Badge.CountUnreadMessages.InfoOff" = "开启以显示未读消息数而非未读聊天数。"; +"Notifications.Badge.CountUnreadMessages.InfoOn" = "关闭以显示未读聊天数而非未读消息数。"; +"Notifications.Badge.IncludeChannels" = "包括频道"; +"Notifications.Badge.IncludeMutedChats" = "包含已静音的聊天"; +"Notifications.Badge.IncludePublicGroups" = "包括公开群组"; +"Notifications.CategoryExceptions#other" = "%@ 条例外"; +"Notifications.ChannelNotifications" = "频道通知"; +"Notifications.ChannelNotificationsAlert" = "显示通知"; +"Notifications.ChannelNotificationsExceptionsHelp" = "为特定频道设置自定义通知。"; +"Notifications.ChannelNotificationsHelp" = "您可以在特定的频道信息界面中设置针对他们的通知策略。"; +"Notifications.ChannelNotificationsPreview" = "消息预览"; +"Notifications.ChannelNotificationsSound" = "声音"; +"Notifications.Channels" = "频道"; +"Notifications.ChannelsTitle" = "频道"; +"Notifications.ClassicTones" = "经典"; +"Notifications.DeleteAllExceptions" = "删除所有例外"; +"Notifications.DisplayNamesOnLockScreen" = "在锁定屏幕上的名称"; +"Notifications.DisplayNamesOnLockScreenInfo" = "在设备锁定时在通知中显示名称。如要停用,打开 iOS 设置 > Telegram > 通知,并将“消息预览”设置为“解锁时”或“从不”。"; +"Notifications.DisplayNamesOnLockScreenInfoWithLink" = "在设备锁定时在通知中显示名称。如要停用,请确保同时在 [iOS 设置> 通知] 中将“消息预览”设置为“解锁时”或“从不”。"; +"Notifications.ExceptionMuteExpires.Days#other" = "持续 %@ 天"; +"Notifications.ExceptionMuteExpires.Hours#other" = "持续 %@ 小时"; +"Notifications.ExceptionMuteExpires.Minutes#other" = "持续 %@ 分钟"; +"Notifications.Exceptions#other" = "%@ 个聊天"; +"Notifications.ExceptionsChangeSound" = "更换提示音(%@)"; +"Notifications.ExceptionsDefaultSound" = "默认"; +"Notifications.ExceptionsGroupPlaceholder" = "这里列举了所有未遵循默认通知策略的群组和频道。"; +"Notifications.ExceptionsMessagePlaceholder" = "这里列举了所有未遵循默认通知策略的私聊。"; +"Notifications.ExceptionsMuted" = "已静音"; +"Notifications.ExceptionsNone" = "无"; +"Notifications.ExceptionsTitle" = "例外"; +"Notifications.ExceptionsUnmuted" = "已启用通知"; +"Notifications.GroupChats" = "群聊"; +"Notifications.GroupChatsTitle" = "群聊"; +"Notifications.GroupNotifications" = "群组通知"; +"Notifications.GroupNotificationsAlert" = "显示通知"; +"Notifications.GroupNotificationsExceptions" = "例外"; +"Notifications.GroupNotificationsExceptionsHelp" = "为特定群组设置自定义通知。"; +"Notifications.GroupNotificationsHelp" = "您可以在特定的群组信息页中设置自定义通知。"; +"Notifications.GroupNotificationsPreview" = "消息预览"; +"Notifications.GroupNotificationsSound" = "铃声"; +"Notifications.InAppNotifications" = "应用内通知"; +"Notifications.InAppNotificationsPreview" = "应用内消息预览"; +"Notifications.InAppNotificationsSounds" = "应用内提示音"; +"Notifications.InAppNotificationsVibrate" = "应用内振动"; +"Notifications.MessageNotifications" = "消息通知"; +"Notifications.MessageNotificationsAlert" = "显示消息提醒"; +"Notifications.MessageNotificationsExceptions" = "例外"; +"Notifications.MessageNotificationsExceptionsHelp" = "为特定用户设置自定义通知。"; +"Notifications.MessageNotificationsHelp" = "您可以在其个人资料页面上为特定用户设置自定义通知。"; +"Notifications.MessageNotificationsPreview" = "消息预览"; +"Notifications.MessageNotificationsSound" = "铃声"; +"Notifications.MessageSoundInfo" = "在任意聊天中按住简短语音消息图标或 MP3 文件,然后选择 “保存为通知铃声” 后,就会出现在这里。"; +"Notifications.Off" = "关闭"; +"Notifications.On" = "打开"; +"Notifications.Options" = "选项"; +"Notifications.PermissionsAllow" = "打开通知"; +"Notifications.PermissionsAllowInSettings" = "在\"设置\"中开启"; +"Notifications.PermissionsEnable" = "启用"; +"Notifications.PermissionsKeepDisabled" = "保持停用"; +"Notifications.PermissionsOpenSettings" = "打开设置"; +"Notifications.PermissionsSuppressWarningText" = "您的当前设置可能会错过 Telegram 内的重要信息。\n\n您可以启用提醒或横幅功能,并设置仅将特定的聊天或聊天类型静音,以享有更好的体验。"; +"Notifications.PermissionsSuppressWarningTitle" = "确定不开启消息通知吗?"; +"Notifications.PermissionsText" = "别错过来自朋友和同事的重要消息。"; +"Notifications.PermissionsTitle" = "开启通知"; +"Notifications.PermissionsUnreachableText" = "请注意,您已在设置中停用部分消息通知。"; +"Notifications.PermissionsUnreachableTitle" = "请检查是否开启通知权限"; +"Notifications.PrivateChats" = "私聊"; +"Notifications.PrivateChatsTitle" = "私聊"; +"Notifications.Reactions" = "回应"; +"Notifications.Reactions.ItemMessages" = "我的消息收到的回应"; +"Notifications.Reactions.ItemStories" = "我的动态收到的回应"; +"Notifications.Reactions.SettingsHeader" = "通知我有关…"; +"Notifications.Reactions.SheetTitleMessages" = "收到以下用户对我的消息的表情回应时通知我"; +"Notifications.Reactions.SheetTitleStories" = "收到以下用户对我的动态的表情回应时通知我"; +"Notifications.Reactions.SheetValueContacts" = "我的联系人"; +"Notifications.Reactions.SheetValueEveryone" = "所有人"; +"Notifications.Reactions.SubtitleContacts" = "来自我的联系人"; +"Notifications.Reactions.SubtitleEveryone" = "来自所有人"; +"Notifications.Reactions.SubtitleMessages" = "消息"; +"Notifications.Reactions.SubtitleStories" = "动态"; +"Notifications.Reactions.Title" = "回应"; +"Notifications.Reset" = "重置"; +"Notifications.ResetAllNotifications" = "重置所有通知策略"; +"Notifications.ResetAllNotificationsHelp" = "撤消您对联系人、群组和频道的所有自定义通知设置。"; +"Notifications.ResetAllNotificationsText" = "你确定需要重置所有通知策略吗?"; +"Notifications.SaveSuccess.Text" = "你可以在[自定义通知设置]()中将此声音设置为提示音。"; +"Notifications.Stories" = "动态"; +"Notifications.Stories.DisplayName" = "显示发送人名字"; +"Notifications.Stories.GlobalSetting" = "新动态"; +"Notifications.Stories.Important" = "重要动态"; +"Notifications.Stories.SettingsHeader" = "通知我有关…"; +"Notifications.StoriesTitle" = "动态"; +"Notifications.SystemTones" = "系统提示音"; +"Notifications.TelegramTones" = "TELEGRAM 提示音"; +"Notifications.TextTone" = "消息提示音"; +"Notifications.Title" = "通知"; +"Notifications.TopChats" = "前 5 名"; +"Notifications.UploadError.TooLarge.Text" = "文件已超过 %@。"; +"Notifications.UploadError.TooLarge.Title" = "音频体积过大"; +"Notifications.UploadError.TooLong.Text" = "持续时间必须短于 %@。"; +"Notifications.UploadError.TooLong.Title" = "音频过长"; +"Notifications.UploadSound" = "上传音频"; +"Notifications.UploadSuccess.Text" = "声音 **%@** 已添加到你的电报铃声中。"; +"Notifications.UploadSuccess.Title" = "音频已添加"; +"NotificationsSound.Alert" = "提醒"; +"NotificationsSound.Antic" = "怪诞"; +"NotificationsSound.Aurora" = "极光"; +"NotificationsSound.Bamboo" = "竹子"; +"NotificationsSound.Bell" = "铃声"; +"NotificationsSound.Calypso" = "即兴曲调"; +"NotificationsSound.Cheers" = "欢呼"; +"NotificationsSound.Chime" = "风铃"; +"NotificationsSound.Chord" = "和弦"; +"NotificationsSound.Circles" = "圆圈"; +"NotificationsSound.Complete" = "完成"; +"NotificationsSound.Droplet" = "水滴"; +"NotificationsSound.Glass" = "玻璃声"; +"NotificationsSound.Handoff" = "接力"; +"NotificationsSound.Hello" = "哈喽"; +"NotificationsSound.Input" = "输入"; +"NotificationsSound.Keys" = "按键"; +"NotificationsSound.Milestone" = "重要事件"; +"NotificationsSound.None" = "无"; +"NotificationsSound.Note" = "音符"; +"NotificationsSound.Passage" = "通道"; +"NotificationsSound.Popcorn" = "爆米花"; +"NotificationsSound.Portal" = "门户"; +"NotificationsSound.Pulse" = "脉冲"; +"NotificationsSound.Rattle" = "沙锤"; +"NotificationsSound.Rebound" = "反弹"; +"NotificationsSound.Slide" = "滑梯"; +"NotificationsSound.Synth" = "合成"; +"NotificationsSound.Telegraph" = "敲打电报"; +"NotificationsSound.Tremolo" = "颤音"; +"NotificationsSound.Tritone" = "三全音"; +"NotificationsSound.Welcome" = "欢迎"; +"OAUTH_REQUEST" = "用户 %1$@ 来自 %2$@ 的登录请求"; +"OldChannels.ChannelFormat" = "频道,"; +"OldChannels.ChannelsHeader" = "最不活跃"; +"OldChannels.GroupEmptyFormat" = "群组,"; +"OldChannels.GroupFormat#other" = "%@ 位成员 "; +"OldChannels.InactiveMonth#other" = "%@个月不活跃"; +"OldChannels.InactiveWeek#other" = "%@周不活跃"; +"OldChannels.InactiveYear#other" = "处于非活跃状态已有 %@ 年"; +"OldChannels.Leave#other" = "退出 %@ 个聊天"; +"OldChannels.LeaveCommunities#other" = "退出 %@ 个社群"; +"OldChannels.NoticeCreateText" = "抱歉,您加入了太多群组和频道。\n请在创建新的之前退出一些。"; +"OldChannels.NoticeText" = "抱歉,您已加入过多群组和频道。\n请再加入新的之前退出一些。"; +"OldChannels.NoticeTitle" = "群组与频道过多"; +"OldChannels.NoticeUpgradeText" = "抱歉,您已加入过多群组和频道。\n由于技术原因,请修改群组设置之前退出一些。"; +"OldChannels.Title" = "达到上限"; +"OldChannels.TooManyCommunitiesCreateFinalText" = "您已经成为 **%@** 个群组和频道的成员了,请在创建新的之前退出一些群组或频道。"; +"OldChannels.TooManyCommunitiesCreateNoPremiumText" = "您目前已是 %@ 个群组和频道的成员,请在创建新群组或频道之前先退出一些。我们正在努力让您在未来可以增加此限制。"; +"OldChannels.TooManyCommunitiesCreateText" = "您已加入 **%@** 个群组或频道。请在加入新的之前先退出一些——或者订阅 **Telegram 高级版**,将此上限翻倍至 **%@** 个群组或频道。"; +"OldChannels.TooManyCommunitiesFinalText" = "您已经成为 %@ 个群组和频道的成员了,请在加入新的之前退出一些群组或频道。"; +"OldChannels.TooManyCommunitiesNoPremiumText" = "您已加入 **%@** 个群组或频道。请在加入新的之前先退出一些。我们正在努力能让你在未来提高此上限。"; +"OldChannels.TooManyCommunitiesText" = "您已加入 **%@** 个群组或频道。请在加入新的之前先退出一些——或者订阅 **Telegram 高级版**,将此上限翻倍至 **%@** 个群组或频道。"; +"OldChannels.TooManyCommunitiesUpgradeFinalText" = "您已加入** %@ 个**群组和频道。由于技术原因,请在修改群组设置之前退出一些。"; +"OldChannels.TooManyCommunitiesUpgradeNoPremiumText" = "您已加入 **%@** 个群组和频道。由于技术原因,请在修改群组设置之前退出一些。我们正在努力让您在未来提高此上限。"; +"OldChannels.TooManyCommunitiesUpgradeText" = "您已加入 **%@** 个群组和频道。由于技术原因,请在修改群组设置之前退出一些,或者订阅 **Telegram 高级版**,将此上限翻倍至 **%@** 个群组或频道。"; +"OpenFile.PotentiallyDangerousContentAlert" = "\n打开此文件可能会将您的IP地址暴露给发送者。您确定继续吗?"; +"OpenFile.Proceed" = "继续"; +"OpenLinkConfirmation.Open" = "打开"; +"OpenLinkConfirmation.Title" = "打开链接?"; +"OwnershipTransfer.ComeBackLater" = "请稍后再试。"; +"OwnershipTransfer.EnterPassword" = "输入密码"; +"OwnershipTransfer.EnterPasswordText" = "请输入您的两步验证密码以确认操作。"; +"OwnershipTransfer.SecurityCheck" = "安全检查"; +"OwnershipTransfer.SecurityRequirements" = "确认此操作需满足以下条件:\n\n• 您的帐号启用两步验证已超过 **7天** 。\n\n• 最近一次更改两步验证密码已超过 **7天** 。\n\n• 在当前设备登录时长已超过 **24 小时** 。"; +"OwnershipTransfer.SetupTwoStepAuth" = "启用两步验证"; +"OwnershipTransfer.Transfer" = "确认"; +"PHONE_CALL_MISSED" = "您错过了来自 %1$@ 的呼叫"; +"PHONE_CALL_MISSED_SEPARATED" = "%1$@|您有未接来电"; +"PHONE_CALL_REQUEST" = "%1$@ 正在呼叫您…"; +"PHONE_CALL_REQUEST_SEPARATED" = "%1$@|正在呼叫你!"; +"PINNED_AUDIO" = "%1$@ 已置顶一条语音消息"; +"PINNED_AUDIO_SEPARATED" = "%1$@|已置顶一条语音信息"; +"PINNED_CONTACT" = "%1$@ 已置顶一个联系人信息"; +"PINNED_CONTACT_SEPARATED" = "%1$@|已置顶一个联系人信息"; +"PINNED_DOC" = "%1$@ 置顶了一个文件"; +"PINNED_DOC_SEPARATED" = "%1$@|已置顶一个文件"; +"PINNED_GAME" = "%1$@ 置顶了一个游戏"; +"PINNED_GAME_SEPARATED" = "%1$@|已置顶一个游戏"; +"PINNED_GEO" = "%1$@ 已置顶一张地图"; +"PINNED_GEOLIVE" = "%1$@ 已置顶一个实时位置"; +"PINNED_GEOLIVE_SEPARATED" = "%1$@|已置顶一个实时位置"; +"PINNED_GEO_SEPARATED" = "%1$@|已置顶一张地图"; +"PINNED_GIF" = "%1$@ 已置顶一张 GIF 动态图"; +"PINNED_GIF_SEPARATED" = "%1$@|已置顶一张 GIF 动态图"; +"PINNED_GIVEAWAY" = "%1$@ 已将抽奖活动置顶"; +"PINNED_INVOICE" = "%1$@ 已置顶一份账单"; +"PINNED_INVOICE_SEPARATED" = "%1$@|已置顶一份账单"; +"PINNED_NOTEXT" = "%1$@ 已置顶一条消息"; +"PINNED_NOTEXT_SEPARATED" = "%1$@|已置顶一条消息"; +"PINNED_PHOTO" = "%1$@ 置顶了一张图片"; +"PINNED_PHOTO_SEPARATED" = "%1$@|已置顶一张照片"; +"PINNED_POLL" = "%1$@ 置顶了一项投票"; +"PINNED_POLL_SEPARATED" = "%1$@置顶了一项投票"; +"PINNED_ROUND" = "%1$@ 已置顶一条视频消息"; +"PINNED_ROUND_SEPARATED" = "%1$@|已置顶一条视频信息"; +"PINNED_STICKER" = "%1$@ 已置顶一张 %2$@ 贴纸"; +"PINNED_STICKER_SEPARATED" = "%1$@|已置顶一张 %2$@ 贴纸"; +"PINNED_TEXT" = "%1$@ 置顶了«%2$@»"; +"PINNED_TEXT_SEPARATED" = "%1$@|已置顶 \"%2$@\""; +"PINNED_VIDEO" = "%1$@ 置顶了一段视频"; +"PINNED_VIDEO_SEPARATED" = "%1$@|已置顶一个视频"; +"PUSH_ALBUM" = "%1$@|📷📹 相册"; +"PUSH_AUTH_REGION" = "新登录|设备: %1$@,位置: %2$@"; +"PUSH_AUTH_UNKNOWN" = "新登录|来自无法识别的设备 %1$@"; +"PUSH_CHANNEL_ALBUM" = "%1$@|📷📹 相册"; +"PUSH_CHANNEL_MESSAGE" = "%1$@|新消息"; +"PUSH_CHANNEL_MESSAGES#other" = "%1$@|📷📹 相册"; +"PUSH_CHANNEL_MESSAGES_TEXT#other" = "发布了 %d 条消息"; +"PUSH_CHANNEL_MESSAGE_AUDIO" = "%1$@|🎤 语音消息"; +"PUSH_CHANNEL_MESSAGE_CONTACT" = "%1$@|👤 %2$@"; +"PUSH_CHANNEL_MESSAGE_DOC" = "%1$@|📎 文档"; +"PUSH_CHANNEL_MESSAGE_DOCS" = "%1$@|发布了 %2$d 个文件"; +"PUSH_CHANNEL_MESSAGE_DOCS_TEXT#other" = "已发布 %d 个文件"; +"PUSH_CHANNEL_MESSAGE_FWD" = "%1$@|转发了一条消息"; +"PUSH_CHANNEL_MESSAGE_FWDS#other" = "%1$@|%2$d 条转发消息"; +"PUSH_CHANNEL_MESSAGE_GAME" = "%1$@|玩 %2$@"; +"PUSH_CHANNEL_MESSAGE_GEO" = "%1$@|📍 位置"; +"PUSH_CHANNEL_MESSAGE_GEOLIVE" = "%1$@|实时位置"; +"PUSH_CHANNEL_MESSAGE_GIF" = "%1$@|📹 GIF 动态图"; +"PUSH_CHANNEL_MESSAGE_NOTEXT" = "%1$@|新消息"; +"PUSH_CHANNEL_MESSAGE_PAID_MEDIA" = "%1$@ 已发送一条价格为 %2$@ 颗星星的付费帖子"; +"PUSH_CHANNEL_MESSAGE_PHOTO" = "%1$@|📷 照片"; +"PUSH_CHANNEL_MESSAGE_PHOTOS#other" = "%1$@|%2$d 张图片"; +"PUSH_CHANNEL_MESSAGE_PHOTOS_TEXT#other" = "已发布 %d 张图片"; +"PUSH_CHANNEL_MESSAGE_POLL" = "%1$@|📊 投票 \"%2$@\""; +"PUSH_CHANNEL_MESSAGE_QUIZ" = "%1$@|已发布问答 “%2$@”"; +"PUSH_CHANNEL_MESSAGE_ROUND" = "%1$@|📺 视频消息"; +"PUSH_CHANNEL_MESSAGE_ROUNDS#other" = "%1$@|📺 %2$d 条视频消息"; +"PUSH_CHANNEL_MESSAGE_STICKER" = "%1$@|%2$@ 贴纸"; +"PUSH_CHANNEL_MESSAGE_STORY" = "%1$@|已分享一条动态"; +"PUSH_CHANNEL_MESSAGE_TEXT" = "%1$@|%2$@"; +"PUSH_CHANNEL_MESSAGE_TODO" = "%1$@|发布了一个和对清单 %2$@"; +"PUSH_CHANNEL_MESSAGE_TODO_APPEND" = "%1$@|已添加 %2$@ 个任务"; +"PUSH_CHANNEL_MESSAGE_TODO_DONE" = "%1$@| 已切换 %2$@ 个任务"; +"PUSH_CHANNEL_MESSAGE_VIDEO" = "%1$@|📹 视频"; +"PUSH_CHANNEL_MESSAGE_VIDEOS#other" = "%1$@|📹 %2$d 段视频"; +"PUSH_CHANNEL_MESSAGE_VIDEOS_TEXT#other" = "已发布 %d 段视频"; +"PUSH_CHAT_ADD_MEMBER" = "%2$@|%1$@ 已邀请 %3$@ 加入群组"; +"PUSH_CHAT_ADD_YOU" = "%2$@|%1$@ 已邀请您加入群组"; +"PUSH_CHAT_ALBUM" = "%2$@|%1$@: 📷📹 相册"; +"PUSH_CHAT_CREATED" = "%2$@|%1$@ 已邀请您加入群组"; +"PUSH_CHAT_DELETE_MEMBER" = "%2$@|%1$@ 已将 %3$@ 移出群组"; +"PUSH_CHAT_DELETE_YOU" = "%2$@|%1$@ 已将您移出群组"; +"PUSH_CHAT_JOINED" = "%2$@|%1$@ 已加入该群组"; +"PUSH_CHAT_LEFT" = "%2$@|%1$@ 已退出群组"; +"PUSH_CHAT_LIVESTREAM_END" = "%2$@|%1$@ 已结束直播"; +"PUSH_CHAT_LIVESTREAM_INVITE" = "%2$@|%1$@ 已邀请 %3$@ 加入直播"; +"PUSH_CHAT_LIVESTREAM_INVITE_YOU" = "%2$@|%1$@ 已邀请您加入直播"; +"PUSH_CHAT_LIVESTREAM_START" = "%2$@|%1$@ 已开始直播"; +"PUSH_CHAT_MESSAGE" = "%2$@|%1$@:消息"; +"PUSH_CHAT_MESSAGES#other" = "%2$@|%1$@: 📷 相册"; +"PUSH_CHAT_MESSAGES_TEXT#other" = "{author} 已发送 %d 条消息"; +"PUSH_CHAT_MESSAGE_AUDIO" = "%2$@|%1$@:🎤 语音消息"; +"PUSH_CHAT_MESSAGE_CONTACT" = "%2$@|%1$@:👤 %3$@"; +"PUSH_CHAT_MESSAGE_DOC" = "%2$@|%1$@:📎 文档"; +"PUSH_CHAT_MESSAGE_DOCS" = "%2$@|%1$@ 已发送 %3$d 个文件"; +"PUSH_CHAT_MESSAGE_DOCS_FIX1#other" = "%2$@|%1$@ 已发送 %3$d 个文件"; +"PUSH_CHAT_MESSAGE_DOCS_TEXT#other" = "{author} 已发送 %d 个文件"; +"PUSH_CHAT_MESSAGE_FWD" = "%2$@|%1$@:转发消息"; +"PUSH_CHAT_MESSAGE_FWDS#other" = "%2$@|%1$@:%3$d 条转发消息"; +"PUSH_CHAT_MESSAGE_FWDS_TEXT#other" = "{author} 已转发 %d 条消息"; +"PUSH_CHAT_MESSAGE_GAME" = "%2$@|🎮 %1$@ 邀请群组游玩 %3$@"; +"PUSH_CHAT_MESSAGE_GAME_SCORE" = "🎮 %1$@ 在 %3$@ 中获得了 %4$@ 分"; +"PUSH_CHAT_MESSAGE_GEO" = "%2$@|%1$@: 📍 位置"; +"PUSH_CHAT_MESSAGE_GEOLIVE" = "%2$@|%1$@:实时位置"; +"PUSH_CHAT_MESSAGE_GIF" = "%2$@|%1$@:📹 GIF 动态图"; +"PUSH_CHAT_MESSAGE_INVOICE" = "%2$@|%1$@:金额为 %3$@ 的账单"; +"PUSH_CHAT_MESSAGE_NOTEXT" = " %2$@|%1$@:新消息"; +"PUSH_CHAT_MESSAGE_NOTHEME" = "%1$@|已停用群组主题 %2$@"; +"PUSH_CHAT_MESSAGE_PAID_MEDIA" = "%1$@ 在 %2$@ 群组中发送了一条价格为 %3$@ 颗星星的付费帖子"; +"PUSH_CHAT_MESSAGE_PHOTO" = "%2$@|%1$@:📷 图片"; +"PUSH_CHAT_MESSAGE_PHOTOS#other" = "%2$@|%1$@:📷 %3$d 张图片"; +"PUSH_CHAT_MESSAGE_PHOTOS_TEXT#other" = "{author} 已发送 %d 张图片"; +"PUSH_CHAT_MESSAGE_POLL" = "%2$@|%1$@:📊 投票 “%3$@”"; +"PUSH_CHAT_MESSAGE_QUIZ" = "%2$@|%1$@ 在群组中发送了问答 “%3$@”"; +"PUSH_CHAT_MESSAGE_ROUND" = "%2$@|%1$@:📺 视频消息"; +"PUSH_CHAT_MESSAGE_ROUNDS#other" = "%2$@|%1$@:📺 %3$d 条视频消息"; +"PUSH_CHAT_MESSAGE_STICKER" = "%2$@|%1$@:%3$@ 贴纸"; +"PUSH_CHAT_MESSAGE_STORY" = "%2$@|%1$@ 已在群组中分享一条动态"; +"PUSH_CHAT_MESSAGE_TEXT" = "%2$@|%1$@:%3$@"; +"PUSH_CHAT_MESSAGE_THEME" = "%1$@|已将群组 %2$@ 的主题设定为 %3$@"; +"PUSH_CHAT_MESSAGE_TODO" = "%2$@|%1$@ 发送了一个和对清单 %3$@"; +"PUSH_CHAT_MESSAGE_TODO_APPEND" = "%2$@|%1$@ 已添加 %3$@ 个任务"; +"PUSH_CHAT_MESSAGE_TODO_DONE" = "%2$@|%1$@ 已切换 %3$@ 个任务"; +"PUSH_CHAT_MESSAGE_VIDEO" = "%2$@|%1$@:📹 视频"; +"PUSH_CHAT_MESSAGE_VIDEOS#other" = "%2$@|%1$@:📹 %3$d 段视频"; +"PUSH_CHAT_MESSAGE_VIDEOS_TEXT#other" = "{author} 已发送 %d 段视频"; +"PUSH_CHAT_PHOTO_EDITED" = "%2$@|%1$@ 已修改群组头像"; +"PUSH_CHAT_REACT_AUDIO" = "%2$@|%1$@:对您的语音消息 %3$@"; +"PUSH_CHAT_REACT_CONTACT" = "%2$@|%1$@ 对您的联系人%3$@ %4$@"; +"PUSH_CHAT_REACT_DOC" = "%2$@|%1$@:对您的文件作出回应 %3$@"; +"PUSH_CHAT_REACT_GAME" = "%2$@|%1$@:对您的游戏作出了回应 %3$@"; +"PUSH_CHAT_REACT_GEO" = "%2$@|%1$@:对您的地图 %3$@"; +"PUSH_CHAT_REACT_GEOLIVE" = "%2$@|%1$@ 对您的实时位置 %3$@"; +"PUSH_CHAT_REACT_GIF" = "%2$@|%1$@:对您的 GIF 动态图 %3$@"; +"PUSH_CHAT_REACT_INVOICE" = "%2$@|%1$@ 对您的账单 %3$@"; +"PUSH_CHAT_REACT_NOTEXT" = "%2$@|%1$@:对您的消息 %3$@"; +"PUSH_CHAT_REACT_PAID_MEDIA" = "%2$@|%1$@ 对您的付费贴文作出 %3$@"; +"PUSH_CHAT_REACT_PHOTO" = "%2$@|%1$@ 对您的照片 %3$@"; +"PUSH_CHAT_REACT_POLL" = "%2$@|%1$@:对您的投票 %4$@ 作出了回应 %3$@"; +"PUSH_CHAT_REACT_QUIZ" = "%2$@|%1$@:对您的问答“%4$@”作出了回应 %3$@"; +"PUSH_CHAT_REACT_ROUND" = "%2$@|%1$@:对您的视频消息 %3$@"; +"PUSH_CHAT_REACT_STICKER" = "%2$@|%1$@ 对您的 %3$@ 贴纸 %4$@"; +"PUSH_CHAT_REACT_STORY" = "您的动态有 %2$@|%1$@ %3$@ "; +"PUSH_CHAT_REACT_TEXT" = "%2$@|%1$@:对您的“%4$@”作出了回应 %3$@"; +"PUSH_CHAT_REACT_TODO" = "%2$@|%1$@ 已对您的清单 %4$@ 作出 %3$@ 回应"; +"PUSH_CHAT_REACT_VIDEO" = "%2$@|%1$@ 对您的视频 %3$@"; +"PUSH_CHAT_REQ_JOINED" = "%2$@|%1$@ 已获批加入群组"; +"PUSH_CHAT_RETURNED" = "%2$@|%1$@ 已返回群组"; +"PUSH_CHAT_TITLE_EDITED" = "%2$@|%1$@ 已编辑群组名称"; +"PUSH_CHAT_VOICECHAT_END" = "%2$@|%1$@ 已结束视频聊天"; +"PUSH_CHAT_VOICECHAT_INVITE" = "%2$@|%1$@ 已邀请 %3$@ 加入视频聊天"; +"PUSH_CHAT_VOICECHAT_INVITE_YOU" = "%2$@|%1$@ 邀请您加入视频聊天"; +"PUSH_CHAT_VOICECHAT_START" = "%2$@|%1$@ 已发起视频聊天"; +"PUSH_CONTACT_JOINED" = "%1$@|已加入 Telegram !"; +"PUSH_ENCRYPTED_MESSAGE" = "您收到一条新消息 %1$@"; +"PUSH_ENCRYPTION_ACCEPT" = "您的私密聊天请求已被接受%1$@"; +"PUSH_ENCRYPTION_REQUEST" = "新的私密聊天请求 %1$@"; +"PUSH_LOCKED_MESSAGE" = "您有一条新的消息%1$@"; +"PUSH_MESSAGE" = "%1$@|新消息"; +"PUSH_MESSAGES#other" = "%1$@|📷📹 相册"; +"PUSH_MESSAGES_TEXT#other" = "给您发送了 %d 条消息"; +"PUSH_MESSAGE_ALBUM" = "%1$@|📷 相册"; +"PUSH_MESSAGE_AUDIO" = "%1$@|🎤 语音消息"; +"PUSH_MESSAGE_CHANNEL_MESSAGE_GAME_SCORE" = "%1$@在游戏%2$@中获得了%3$@分"; +"PUSH_MESSAGE_CONTACT" = "%1$@|👤 %2$@"; +"PUSH_MESSAGE_DOC" = "%1$@|📎 文档"; +"PUSH_MESSAGE_DOCS" = "%1$@|向您发送了 %2$d 个文件"; +"PUSH_MESSAGE_FILES#other" = "%1$@|给您发送了 %2$d 个文件"; +"PUSH_MESSAGE_FILES_TEXT#other" = "已给您发送 %d 个文件"; +"PUSH_MESSAGE_FWD" = "%1$@|已转发消息"; +"PUSH_MESSAGE_FWDS#other" = "%1$@|%2$d 条转发消息"; +"PUSH_MESSAGE_FWDS_TEXT#other" = "向您转发了 %d 条消息"; +"PUSH_MESSAGE_GAME" = "%1$@|🎮游玩 %2$@"; +"PUSH_MESSAGE_GAME_SCORE" = "🎮 %1$@ 在游戏 %2$@ 中获得了 %3$@ 分"; +"PUSH_MESSAGE_GEO" = "%1$@|📍 位置"; +"PUSH_MESSAGE_GEOLIVE" = "%1$@|实时位置"; +"PUSH_MESSAGE_GIF" = "%1$@|📹 GIF 动态图"; +"PUSH_MESSAGE_INVOICE" = "%1$@|金额为 %2$@ 的账单"; +"PUSH_MESSAGE_NOTEXT" = "%1$@|新消息"; +"PUSH_MESSAGE_NOTHEME" = "%1$@|已停用聊天主题"; +"PUSH_MESSAGE_PAID_MEDIA" = "%1$@ 给您发送了一条价格为 %2$@ 颗星星的付费帖子"; +"PUSH_MESSAGE_PHOTO" = "%1$@|📷图片"; +"PUSH_MESSAGE_PHOTOS#other" = "%1$@|📷 %2$d 张图片"; +"PUSH_MESSAGE_PHOTOS_TEXT#other" = "给您发送了 %d 张图片"; +"PUSH_MESSAGE_PHOTO_SECRET" = "%1$@|🔥 阅后即焚图片"; +"PUSH_MESSAGE_POLL" = "%1$@|📊 投票 \"%2$@\""; +"PUSH_MESSAGE_QUIZ" = "%1$@|给您发送了问答 \"%2$@\""; +"PUSH_MESSAGE_RECURRING_PAY" = "%1$@ |已收取您 %2$@"; +"PUSH_MESSAGE_ROUND" = "%1$@|📺视频消息"; +"PUSH_MESSAGE_ROUNDS#other" = "%1$@|📺 %2$d 条视频消息"; +"PUSH_MESSAGE_SAME_WALLPAPER" = "%1$@ 已为此聊天设置相同的壁纸。"; +"PUSH_MESSAGE_SCREENSHOT" = "%1$@|已截屏!"; +"PUSH_MESSAGE_STARGIFT" = "%1$@|向您赠送了一份价值 %2$@ 颗星星的礼物"; +"PUSH_MESSAGE_STARGIFT_UPGRADE" = "%1$@ 已升级你的礼物"; +"PUSH_MESSAGE_STICKER" = "%1$@|%2$@ 贴纸"; +"PUSH_MESSAGE_STORY" = "%1$@|已将一条动态分享给您"; +"PUSH_MESSAGE_STORY_MENTION" = "%1$@|在一条动态中提到了您"; +"PUSH_MESSAGE_SUGGEST_USERPIC" = "%1$@|已为您推荐一张头像图片"; +"PUSH_MESSAGE_TEXT" = "%1$@|%2$@"; +"PUSH_MESSAGE_THEME" = "%1$@|将主题更改为 %2$@"; +"PUSH_MESSAGE_TODO" = "%1$@|发布了给你一个和对清单 %2$@"; +"PUSH_MESSAGE_UNIQUE_STARGIFT" = "%1$@ 已给你赠送一份礼物\n"; +"PUSH_MESSAGE_VIDEO" = "%1$@|📹视频"; +"PUSH_MESSAGE_VIDEOS#other" = "%1$@|📹 %2$d 段视频"; +"PUSH_MESSAGE_VIDEOS_TEXT#other" = "给您发送了 %d 条视频"; +"PUSH_MESSAGE_VIDEO_SECRET" = "%1$@|🔥 阅后即焚视频"; +"PUSH_MESSAGE_WALLPAPER" = "%1$@ 已为此聊天设置一张新壁纸"; +"PUSH_PHONE_CALL_MISSED" = "%1$@|☎️ 未接来电"; +"PUSH_PHONE_CALL_REQUEST" = "%1$@|正在呼叫!"; +"PUSH_PINNED_AUDIO" = "%1$@|📌 已置顶一条语音消息"; +"PUSH_PINNED_CONTACT" = "%1$@|📌 已置顶联系人信息:%2$@"; +"PUSH_PINNED_DOC" = "%1$@|📌 已置顶文档"; +"PUSH_PINNED_GAME" = "%1$@|📌 置顶了游戏"; +"PUSH_PINNED_GAME_SCORE" = "%1$@ 置顶一个游戏分数"; +"PUSH_PINNED_GEO" = "%1$@|📌 置顶了地图"; +"PUSH_PINNED_GEOLIVE" = "%1$@|📌 已置顶一个实时位置"; +"PUSH_PINNED_GIF" = "%1$@|📌 已置顶一张 GIF 动态图"; +"PUSH_PINNED_INVOICE" = "%1$@|📌 已置顶一份账单"; +"PUSH_PINNED_NOTEXT" = "%1$@|📌 置顶了消息"; +"PUSH_PINNED_PAID_MEDIA" = "%1$@|已置顶一条价格为%2$@ 的付费帖子"; +"PUSH_PINNED_PHOTO" = "%1$@|📌 置顶了图片"; +"PUSH_PINNED_POLL" = "%1$@|📌已置顶一项投票"; +"PUSH_PINNED_QUIZ" = "%1$@| 置顶了一张问卷"; +"PUSH_PINNED_ROUND" = "%1$@|📌 已置顶一条视频消息"; +"PUSH_PINNED_STICKER" = "%1$@|置顶了一张 %2$@ 贴纸"; +"PUSH_PINNED_TEXT" = "%1$@|置顶了 “%2$@”"; +"PUSH_PINNED_TODO" = "%1$@|已2置顶一个核对清单 %2$@"; +"PUSH_PINNED_VIDEO" = "%1$@|📌 置顶了视频"; +"PUSH_REACT_AUDIO" = "%1$@|对您的语音消息 %2$@"; +"PUSH_REACT_CONTACT" = "%1$@|对您的联系人信息 %3$@ %2$@"; +"PUSH_REACT_DOC" = "%1$@ 及 %2$@ 发送给你的文件"; +"PUSH_REACT_GAME" = "%1$@|对您的游戏 %2$@"; +"PUSH_REACT_GEO" = "%1$@|对您的地图 %2$@"; +"PUSH_REACT_GEOLIVE" = "%1$@|对您的实时位置 %2$@"; +"PUSH_REACT_GIF" = "%1$@|对您的 GIF 动态图 %2$@"; +"PUSH_REACT_HIDDEN" = "您的消息有新表情符号回应"; +"PUSH_REACT_INVOICE" = "%1$@|对您的账单 %2$@"; +"PUSH_REACT_NOTEXT" = "%1$@|对您的消息 %2$@"; +"PUSH_REACT_PHOTO" = "%1$@|对您的图片 %2$@"; +"PUSH_REACT_POLL" = "%1$@|对您的 “%3$@” 投票 %2$@"; +"PUSH_REACT_QUIZ" = "%1$@|对您的 “%3$@” 问答 %2$@"; +"PUSH_REACT_ROUND" = "%1$@|对您的视频消息 %2$@"; +"PUSH_REACT_STICKER" = "%1$@|对您的 %3$@ 贴纸 %2$@"; +"PUSH_REACT_STORY" = "您的动态有 %1$@|%2$@"; +"PUSH_REACT_STORY_HIDDEN" = "您的动态有新回应"; +"PUSH_REACT_STORY_NAME" = "您的动态有 %1$@|%3$@ %2$@"; +"PUSH_REACT_TEXT" = "%1$@|对您的 “%3$@” %2$@"; +"PUSH_REACT_TODO" = "%1$@| 已对您的清单 %3$@ 作出 %2$@ 回应"; +"PUSH_REACT_VIDEO" = "%1$@|对您的视频 %2$@"; +"PUSH_REMINDER_TITLE" = "🗓 提醒"; +"PUSH_SENDER_YOU" = "📅 您"; +"PUSH_STORY_HIDDEN_AUTHOR" = "新动态已发布。"; +"PUSH_STORY_NOTEXT" = "%1$@|发布了一条动态"; +"PUSH_VIDEO_CALL_MISSED" = "%1$@| 您错过了一个视频电话"; +"PUSH_VIDEO_CALL_REQUEST" = "%1$@| 正在呼叫您!"; +"Paint.Arrow" = "箭头"; +"Paint.Bubble" = "气泡"; +"Paint.Clear" = "清除全部"; +"Paint.ClearConfirm" = "清理绘画痕迹"; +"Paint.ColorGrid" = "栅格"; +"Paint.ColorOpacity" = "不透明度"; +"Paint.ColorSliders" = "滑块式"; +"Paint.ColorSpectrum" = "谱系"; +"Paint.ColorTitle" = "颜色"; +"Paint.CutOut" = "剪切"; +"Paint.Delete" = "删除"; +"Paint.Draw" = "绘制"; +"Paint.Duplicate" = "拷贝"; +"Paint.Edit" = "编辑"; +"Paint.Ellipse" = "椭圆形"; +"Paint.Flip" = "翻转"; +"Paint.Framed" = "阴影"; +"Paint.Marker" = "记号笔"; +"Paint.Masks" = "装饰"; +"Paint.MoveForward" = "前移"; +"Paint.Neon" = "霓虹灯"; +"Paint.Outlined" = "轮廓"; +"Paint.Pen" = "画笔"; +"Paint.RecentStickers" = "最近"; +"Paint.Rectangle" = "矩形"; +"Paint.Regular" = "常规"; +"Paint.Star" = "星形"; +"Paint.Sticker" = "贴纸"; +"Paint.Stickers" = "表情包"; +"Paint.Text" = "文字"; +"Paint.ZoomOut" = "缩小"; +"Passcode.AppLockedAlert" = "Telegram\n已被锁定"; +"PasscodeSettings.4DigitCode" = "4位数字密码"; +"PasscodeSettings.6DigitCode" = "6位数字密码"; +"PasscodeSettings.AlphanumericCode" = "自定义字母数字密码"; +"PasscodeSettings.AutoLock" = "自动锁定"; +"PasscodeSettings.AutoLock.Disabled" = "停用"; +"PasscodeSettings.AutoLock.IfAwayFor_1hour" = "离开 1 小时后"; +"PasscodeSettings.AutoLock.IfAwayFor_1minute" = "离开 1 分钟后"; +"PasscodeSettings.AutoLock.IfAwayFor_5hours" = "离开 5 小时后"; +"PasscodeSettings.AutoLock.IfAwayFor_5minutes" = "离开 5 分钟后"; +"PasscodeSettings.ChangePasscode" = "修改锁定码"; +"PasscodeSettings.DoNotMatch" = "锁定码不一致,请重试。"; +"PasscodeSettings.EncryptData" = "加密本地数据库"; +"PasscodeSettings.EncryptDataHelp" = "这是一个试验性功能,请谨慎使用。它通过锁定码生成密钥,来加密本地储存的 Telegram 数据。"; +"PasscodeSettings.FailedAttempts#other" = "已输错锁定码 %@ 次"; +"PasscodeSettings.Help" = "当您设置好锁定码后,聊天页面会出现一个锁形图标。点击它来锁定 Telegram 应用。\n\n注意:如果您忘记了锁定码,您将需要重新安装应用。所有的私密聊天将会丢失。"; +"PasscodeSettings.HelpBottom" = "注意:如果您忘记了锁定码,您将只能卸载然后重新安装 Telegram。而此操作将导致您丢失所有的私密对话记录。"; +"PasscodeSettings.HelpTop" = "设置锁定码后,一个“锁”的图标将会出现在聊天列表顶部,点击该图标以锁定 Telegram 应用。"; +"PasscodeSettings.PasscodeOptions" = "锁定码选项"; +"PasscodeSettings.SimplePasscode" = "简易锁定码"; +"PasscodeSettings.SimplePasscodeHelp" = "简易锁定码由四位数字组成。"; +"PasscodeSettings.Title" = "密码锁"; +"PasscodeSettings.TryAgainIn1Minute" = "请于 1 分钟后重试"; +"PasscodeSettings.TurnPasscodeOff" = "关闭锁定码"; +"PasscodeSettings.TurnPasscodeOn" = "开启密码锁"; +"PasscodeSettings.UnlockWithFaceId" = "使用 Face ID 解锁"; +"PasscodeSettings.UnlockWithTouchId" = "使用 Touch ID 解锁"; +"Passkeys.AddPasskey" = "添加通行密钥"; +"Passkeys.ButtonCreate" = "创建通行密钥"; +"Passkeys.ButtonSkip" = "跳过"; +"Passkeys.DeleteAlert.Action" = "删除"; +"Passkeys.DeleteAlert.Text" = "删除后,此通行密钥将无法用于登录。\n\n也别忘了从您的密码管理器中移除它。"; +"Passkeys.DeleteAlert.Title" = "是否删除通行密钥?"; +"Passkeys.EmptyName" = "通行密钥"; +"Passkeys.Into.Text0" = "创建通行密钥,轻松安全地登录。"; +"Passkeys.Into.Text1" = "使用你的面容、指纹或密码登录。"; +"Passkeys.Into.Text2" = "您的 Passkey 已安全保存在您的 iCloud 钥匙圈中。"; +"Passkeys.Into.Title" = "保护您的账号"; +"Passkeys.Into.Title0" = "创建通行密钥"; +"Passkeys.Into.Title1" = "使用 Face ID 登录"; +"Passkeys.Into.Title2" = "安全存储通行密钥"; +"Passkeys.ListFooter" = "您的通行密钥已安全存储在密码管理器中。"; +"Passkeys.PasskeyCreatedAndUsedPattern" = "已创建于 %1$@ • 已使用于 %2$@"; +"Passkeys.PasskeyCreatedPattern" = "已创建于 %@"; +"Passkeys.Subtitle" = "安全登录,保障账号安全。"; +"Passkeys.Title" = "通行密钥"; +"Passport.AcceptHelp" = "您的文件将被直接发送给 %1$@。同时,也将允许 %2$@ 给您发送信息。"; +"Passport.Address.AddBankStatement" = "添加银行对账单信息"; +"Passport.Address.AddPassportRegistration" = "添加护照信息"; +"Passport.Address.AddRentalAgreement" = "添加租约信息"; +"Passport.Address.AddResidentialAddress" = "添加住址信息"; +"Passport.Address.AddTemporaryRegistration" = "添加临时证明信息"; +"Passport.Address.AddUtilityBill" = "添加水电气费账单信息"; +"Passport.Address.Address" = "地址信息"; +"Passport.Address.City" = "城市"; +"Passport.Address.CityPlaceholder" = "城市"; +"Passport.Address.Country" = "国家"; +"Passport.Address.CountryPlaceholder" = "国家"; +"Passport.Address.EditBankStatement" = "编辑银行对账单信息"; +"Passport.Address.EditPassportRegistration" = "编辑护照信息"; +"Passport.Address.EditRentalAgreement" = "编辑租约信息"; +"Passport.Address.EditResidentialAddress" = "编辑住址信息"; +"Passport.Address.EditTemporaryRegistration" = "编辑临时证明信息"; +"Passport.Address.EditUtilityBill" = "编辑水电气费账单信息"; +"Passport.Address.OneOfTypeBankStatement" = "银行对账单"; +"Passport.Address.OneOfTypePassportRegistration" = "护照信息"; +"Passport.Address.OneOfTypeRentalAgreement" = "租约"; +"Passport.Address.OneOfTypeTemporaryRegistration" = "临时证明信息"; +"Passport.Address.OneOfTypeUtilityBill" = "水电气费账单"; +"Passport.Address.Postcode" = "邮政编码"; +"Passport.Address.PostcodePlaceholder" = "邮政编码"; +"Passport.Address.Region" = "地区"; +"Passport.Address.RegionPlaceholder" = "州/省份/地区"; +"Passport.Address.ScansHelp" = "该证件必须包含您的姓名、住址、一份邮票/条形码/二维码/商标,发件日期不能在 3 个月之前。"; +"Passport.Address.Street" = "街道"; +"Passport.Address.Street1Placeholder" = "街道号码和邮局箱"; +"Passport.Address.Street2Placeholder" = "公寓,套间,单元,建筑,楼层"; +"Passport.Address.TypeBankStatement" = "银行对账单"; +"Passport.Address.TypeBankStatementUploadScan" = "上传一份银行对账单的扫描件"; +"Passport.Address.TypePassportRegistration" = "护照信息"; +"Passport.Address.TypePassportRegistrationUploadScan" = "上传一份护照注册信息的扫描件"; +"Passport.Address.TypeRentalAgreement" = "租约"; +"Passport.Address.TypeRentalAgreementUploadScan" = "上传一份租约的扫描件"; +"Passport.Address.TypeResidentialAddress" = "住址"; +"Passport.Address.TypeTemporaryRegistration" = "临时证明信息"; +"Passport.Address.TypeTemporaryRegistrationUploadScan" = "上传一份您临时证明的扫描件"; +"Passport.Address.TypeUtilityBill" = "水电气费账单"; +"Passport.Address.TypeUtilityBillUploadScan" = "上传一份水电气费的扫描件"; +"Passport.Address.UploadOneOfScan" = "上传一份%@的扫描件"; +"Passport.Authorize" = "授权"; +"Passport.CorrectErrors" = "点击以纠正错误"; +"Passport.DeleteAddress" = "删除住址信息"; +"Passport.DeleteAddressConfirmation" = "您确定要删除您的住址信息吗?"; +"Passport.DeleteDocument" = "删除证件"; +"Passport.DeleteDocumentConfirmation" = "您确定要删除此证件吗?所有信息都将丢失。"; +"Passport.DeletePassport" = "删除 Telegram 通行证"; +"Passport.DeletePassportConfirmation" = "您确定想要删除您的 Telegram 通行证吗?所有信息都将丢失。"; +"Passport.DeletePersonalDetails" = "删除个人信息"; +"Passport.DeletePersonalDetailsConfirmation" = "您确定需要删除个人信息吗?"; +"Passport.DiscardMessageAction" = "放弃"; +"Passport.DiscardMessageDescription" = "您确定要放弃所有变更吗?"; +"Passport.DiscardMessageTitle" = "放弃更改"; +"Passport.Email.CodeHelp" = "请输入我们刚刚向 %@ 发送的验证码"; +"Passport.Email.Delete" = "删除电子邮箱地址"; +"Passport.Email.EmailPlaceholder" = "输入您的电子邮箱地址"; +"Passport.Email.EnterOtherEmail" = "或输入一个新的电子邮箱地址"; +"Passport.Email.Help" = "您所提供的电子邮箱将会收到一份验证码。"; +"Passport.Email.Title" = "电子邮箱"; +"Passport.Email.UseTelegramEmail" = "使用 %@"; +"Passport.Email.UseTelegramEmailHelp" = "使用当前 Telegram 帐号中设定的电子邮箱。"; +"Passport.FieldAddress" = "住址"; +"Passport.FieldAddressHelp" = "提供您的住址"; +"Passport.FieldAddressTranslationHelp" = "添加您证件的英语翻译"; +"Passport.FieldAddressUploadHelp" = "上传您的地址证明"; +"Passport.FieldEmail" = "邮箱地址"; +"Passport.FieldEmailHelp" = "提供您的联系邮箱地址"; +"Passport.FieldIdentity" = "身份证件"; +"Passport.FieldIdentityDetailsHelp" = "填写个人信息"; +"Passport.FieldIdentitySelfieHelp" = "拍摄一张您拿着证件的自拍照"; +"Passport.FieldIdentityTranslationHelp" = "添加您证件的英语翻译"; +"Passport.FieldIdentityUploadHelp" = "上传您的身份证明"; +"Passport.FieldOneOf.Delimeter" = "、"; +"Passport.FieldOneOf.FinalDelimeter" = "或"; +"Passport.FieldOneOf.Or" = "%1$@或%2$@"; +"Passport.FieldPhone" = "手机号码"; +"Passport.FieldPhoneHelp" = "提供您的联系手机号码"; +"Passport.FloodError" = "尝试次数过多。请稍后再试。"; +"Passport.ForgottenPassword" = "密码遗失"; +"Passport.Identity.AddDriversLicense" = "添加驾照"; +"Passport.Identity.AddIdentityCard" = "添加身份证"; +"Passport.Identity.AddInternalPassport" = "添加户口本(Internal Passport)"; +"Passport.Identity.AddPassport" = "添加护照"; +"Passport.Identity.AddPersonalDetails" = "添加个人信息"; +"Passport.Identity.Country" = "国籍"; +"Passport.Identity.CountryPlaceholder" = "国籍"; +"Passport.Identity.DateOfBirth" = "生日"; +"Passport.Identity.DateOfBirthPlaceholder" = "生日"; +"Passport.Identity.DocumentDetails" = "证件信息"; +"Passport.Identity.DocumentNumber" = "证件号"; +"Passport.Identity.DocumentNumberPlaceholder" = "证件号"; +"Passport.Identity.DoesNotExpire" = "永不失效"; +"Passport.Identity.EditDriversLicense" = "编辑驾驶证"; +"Passport.Identity.EditIdentityCard" = "编辑身份证信息"; +"Passport.Identity.EditInternalPassport" = "编辑户口(Internal Passport)信息"; +"Passport.Identity.EditPassport" = "编辑护照信息"; +"Passport.Identity.EditPersonalDetails" = "编辑个人信息"; +"Passport.Identity.ExpiryDate" = "失效日期"; +"Passport.Identity.ExpiryDateNone" = "无"; +"Passport.Identity.ExpiryDatePlaceholder" = "失效日期"; +"Passport.Identity.FilesTitle" = "所需文件"; +"Passport.Identity.FilesUploadNew" = "上传一份新的"; +"Passport.Identity.FilesView" = "查看"; +"Passport.Identity.FrontSide" = "正面"; +"Passport.Identity.FrontSideHelp" = "上传证件正面照片"; +"Passport.Identity.Gender" = "性别"; +"Passport.Identity.GenderFemale" = "女"; +"Passport.Identity.GenderMale" = "男"; +"Passport.Identity.GenderPlaceholder" = "性别"; +"Passport.Identity.IssueDate" = "发件日期"; +"Passport.Identity.IssueDatePlaceholder" = "发件日期"; +"Passport.Identity.LatinNameHelp" = "以拉丁字母形式输入您的姓名。"; +"Passport.Identity.MainPage" = "文件主页"; +"Passport.Identity.MainPageHelp" = "上传证件第一页的照片"; +"Passport.Identity.MiddleName" = "中间名"; +"Passport.Identity.MiddleNamePlaceholder" = "中间名"; +"Passport.Identity.Name" = "名字"; +"Passport.Identity.NamePlaceholder" = "名字"; +"Passport.Identity.NativeNameGenericHelp" = "以您所居住的国家(%@)语言填写您的名字。"; +"Passport.Identity.NativeNameGenericTitle" = "以您居住的国家语言填写名字"; +"Passport.Identity.NativeNameHelp" = "以您所居住的国家语言填写您的名字。"; +"Passport.Identity.NativeNameTitle" = "您的%@名"; +"Passport.Identity.OneOfTypeDriversLicense" = "驾驶证"; +"Passport.Identity.OneOfTypeIdentityCard" = "身份证"; +"Passport.Identity.OneOfTypeInternalPassport" = "户口本(Internal Passport)"; +"Passport.Identity.OneOfTypePassport" = "护照"; +"Passport.Identity.ResidenceCountry" = "居住地"; +"Passport.Identity.ResidenceCountryPlaceholder" = "居住地"; +"Passport.Identity.ReverseSide" = "反面"; +"Passport.Identity.ReverseSideHelp" = "上传证件反面照片"; +"Passport.Identity.ScansHelp" = "这份证件必须包含您的照片、姓名、生日、证件号、签发国家、以及失效日期。"; +"Passport.Identity.Selfie" = "自拍照"; +"Passport.Identity.SelfieHelp" = "上传一张您拿着证件的自拍照"; +"Passport.Identity.Surname" = "姓氏"; +"Passport.Identity.SurnamePlaceholder" = "姓氏"; +"Passport.Identity.Translation" = "翻译"; +"Passport.Identity.TranslationHelp" = "上传此证件的翻译"; +"Passport.Identity.Translations" = "翻译"; +"Passport.Identity.TranslationsHelp" = "上传经过认证的证件英语翻译扫描件。"; +"Passport.Identity.TypeDriversLicense" = "驾驶证"; +"Passport.Identity.TypeDriversLicenseUploadScan" = "上传一份驾驶证扫描件"; +"Passport.Identity.TypeIdentityCard" = "身份证"; +"Passport.Identity.TypeIdentityCardUploadScan" = "上传一份身份证扫描件"; +"Passport.Identity.TypeInternalPassport" = "户口本(Internal Passport)"; +"Passport.Identity.TypeInternalPassportUploadScan" = "上传一份户口本(Internal Passport)的扫描件"; +"Passport.Identity.TypePassport" = "护照"; +"Passport.Identity.TypePassportUploadScan" = "上传一份护照扫描件"; +"Passport.Identity.TypePersonalDetails" = "个人信息"; +"Passport.Identity.UploadOneOfScan" = "上传一份%@的扫描件"; +"Passport.InfoFAQ_URL" = "https://telegram.org/faq#passport"; +"Passport.InfoLearnMore" = "了解更多"; +"Passport.InfoText" = "有了 Telegram 通行证,您可以轻易地注册需要身份验证的网站和服务。\n\n您的信息、个人数据和证件都将受到端到端加密保护。未经您的允许,任何人(包括 Telegram)都无法读取它们。"; +"Passport.InfoTitle" = "Telegram 通行证是什么?"; +"Passport.InvalidPasswordError" = "密码错误,请重试。"; +"Passport.Language.ar" = "阿拉伯语"; +"Passport.Language.az" = "阿塞拜疆语"; +"Passport.Language.bg" = "保加利亚语"; +"Passport.Language.bn" = "孟加拉语"; +"Passport.Language.cs" = "捷克语"; +"Passport.Language.da" = "丹麦语"; +"Passport.Language.de" = "德语"; +"Passport.Language.dv" = "迪维希语"; +"Passport.Language.dz" = "不丹语"; +"Passport.Language.el" = "希腊语"; +"Passport.Language.en" = "英语"; +"Passport.Language.es" = "西班牙语"; +"Passport.Language.et" = "爱沙尼亚语"; +"Passport.Language.fa" = "波斯语"; +"Passport.Language.fr" = "法语"; +"Passport.Language.he" = "希伯来语"; +"Passport.Language.hr" = "克罗艾西亚语"; +"Passport.Language.hu" = "匈牙利语"; +"Passport.Language.hy" = "亚美尼亚语"; +"Passport.Language.id" = "印度尼西亚语"; +"Passport.Language.is" = "冰岛语"; +"Passport.Language.it" = "意大利语"; +"Passport.Language.ja" = "日语"; +"Passport.Language.ka" = "佐治亚语"; +"Passport.Language.km" = "高棉语"; +"Passport.Language.ko" = "韩语"; +"Passport.Language.lo" = "老挝语"; +"Passport.Language.lt" = "立陶宛语"; +"Passport.Language.lv" = "拉脱维亚语"; +"Passport.Language.mk" = "马其顿语"; +"Passport.Language.mn" = "蒙古语"; +"Passport.Language.ms" = "马来西亚语"; +"Passport.Language.my" = "缅甸语"; +"Passport.Language.ne" = "尼泊尔语"; +"Passport.Language.nl" = "荷兰语"; +"Passport.Language.pl" = "波兰语"; +"Passport.Language.pt" = "葡萄牙语"; +"Passport.Language.ro" = "罗马尼亚语"; +"Passport.Language.ru" = "俄语"; +"Passport.Language.sk" = "斯洛伐克语"; +"Passport.Language.sl" = "斯洛文尼亚语"; +"Passport.Language.th" = "泰语"; +"Passport.Language.tk" = "土库曼语"; +"Passport.Language.tr" = "土耳其语"; +"Passport.Language.uk" = "乌克兰语"; +"Passport.Language.uz" = "乌兹别克斯坦语"; +"Passport.Language.vi" = "越南语"; +"Passport.NotLoggedInMessage" = "请登录到您的账号以使用 Telegram 通行证。"; +"Passport.PassportInformation" = "通行证信息"; +"Passport.PasswordCompleteSetup" = "完成密码设置"; +"Passport.PasswordCreate" = "创建密码"; +"Passport.PasswordDescription" = "请创建一个密码使用端到端加密保护个人数据。\n\n每次您在新的设备登录到 Telegram 时也将需要输入这个密码。"; +"Passport.PasswordHelp" = "请输入 Telegram 密码\n以解密您的数据"; +"Passport.PasswordNext" = "下一步"; +"Passport.PasswordPlaceholder" = "请输入密码"; +"Passport.PasswordReset" = "所有上传到您 Telegram 通行证的证件都将丢失。之后,您将能够上传新的证件。"; +"Passport.Phone.Delete" = "删除手机号码"; +"Passport.Phone.EnterOtherNumber" = "或输入新的手机号码"; +"Passport.Phone.Help" = "注意:您将在您提供的手机号码上收到一份验证码。"; +"Passport.Phone.Title" = "手机号码"; +"Passport.Phone.UseTelegramNumber" = "使用 %@"; +"Passport.Phone.UseTelegramNumberHelp" = "使用当前 Telegram 帐号绑定的手机号码。"; +"Passport.PrivacyPolicy" = "您同意 [%1$@ 隐私政策]并允许 @%2$@ 给您发送信息。"; +"Passport.RequestHeader" = "%@ 请求访问您的个人数据以便为您提供服务。"; +"Passport.RequestedInformation" = "所需信息"; +"Passport.ScanPassport" = "扫描您的护照"; +"Passport.ScanPassportHelp" = "扫描您护照或身份证的机读区以自动填写个人信息。"; +"Passport.Scans" = "扫描件"; +"Passport.Scans.ScanIndex" = "扫描 %@"; +"Passport.Scans.Upload" = "上传扫描件"; +"Passport.Scans.UploadNew" = "上传更多的扫描件"; +"Passport.ScansHeader" = "扫描"; +"Passport.Title" = "通行证"; +"Passport.UpdateRequiredError" = "抱歉,您的 Telegram 版本太低,无法处理此请求。 请升级 Telegram。"; +"PassportKit.Button.GetTelegramAlertInstall" = "安装"; +"PassportKit.Button.GetTelegramAlertMessage" = "您需要安装 Telegram 来使用 Telegram 通行证"; +"PassportKit.Button.GetTelegramAlertNotNow" = "稍后"; +"PassportKit.Button.GetTelegramAlertTitle" = "下载 Telegram"; +"PassportKit.Button.Title" = "通过 Telegram 登录"; +"Peer.DeletedUser" = "已注销用户"; +"PeerInfo.AddNotesInfo" = "只有您自己才能看到备注。"; +"PeerInfo.AddNotesPlaceholder" = "添加备注"; +"PeerInfo.AddToContacts" = "添加为联系人"; +"PeerInfo.AdjustAutoDelete" = "调整自动删除设置"; +"PeerInfo.AlertLeaveAction" = "退出"; +"PeerInfo.AllowChannelMessages" = "私信"; +"PeerInfo.AllowChannelMessages.Free" = "免费"; +"PeerInfo.AllowChannelMessages.Off" = "关闭"; +"PeerInfo.AllowedReactions.AllowAllChannelInfo" = "允许订阅者对频道消息作出回应。"; +"PeerInfo.AllowedReactions.AllowAllGroupInfo" = "允许成员对群组消息作出表情回应。"; +"PeerInfo.AllowedReactions.AllowAllText" = "启用表情回应"; +"PeerInfo.AllowedReactions.GroupOptionAllInfo" = "群组成员可以使用任何表情回应消息。"; +"PeerInfo.AllowedReactions.GroupOptionNoInfo" = "群组成员无法对消息使用表情回应。"; +"PeerInfo.AllowedReactions.GroupOptionSomeInfo" = "群组成员仅可使用已批准的表情回应消息。"; +"PeerInfo.AllowedReactions.MaxCountSectionFooter" = "限制可以加入到帖子中的表情回应类型数量,包括已发布的帖子。"; +"PeerInfo.AllowedReactions.MaxCountSectionTitle" = "每个帖子的回应数量上限"; +"PeerInfo.AllowedReactions.MaxCountValue#other" = " %d 个回应"; +"PeerInfo.AllowedReactions.OptionAllReactions" = "所有表情回应"; +"PeerInfo.AllowedReactions.OptionNoReactions" = "禁用表情回应"; +"PeerInfo.AllowedReactions.OptionSomeReactions" = "部分表情回应"; +"PeerInfo.AllowedReactions.ReactionListHeader" = "可用的回应"; +"PeerInfo.AllowedReactions.StarReactions" = "启用付费回应"; +"PeerInfo.AllowedReactions.StarReactionsFooter" = "打开此选项,让您的订阅者可以用 Telegram 星星对贴文作出付费回应,您之后可以以 Gram 的形式提取。[了解更多 >](https://telegram.org/tos/content-creator-rewards#2-3-subscriber-donations)"; +"PeerInfo.AllowedReactions.Title" = "表情回应"; +"PeerInfo.AppFooter" = "启动此小程序即表示您同意 [小程序服务条款](https://telegram.org/tos/mini-apps)。"; +"PeerInfo.AppFooterAdmin" = "发布此小程序即表示您同意 [Telegram 开发人员服务条款](https://telegram.org/tos/bot-developers)。"; +"PeerInfo.AutoDeleteDisable" = "停用"; +"PeerInfo.AutoDeleteInfo" = "一段时间后自动删除此聊天中的新消息。"; +"PeerInfo.AutoDeleteSettingOther" = "其他..."; +"PeerInfo.AutoremoveMessages" = "自动删除消息"; +"PeerInfo.AutoremoveMessagesDisabled" = "从不"; +"PeerInfo.BioExpand" = "更多"; +"PeerInfo.Bot.Balance" = "余额"; +"PeerInfo.Bot.Balance.Stars#other" = "%@ 颗星星"; +"PeerInfo.Bot.BotFatherInfo" = "使用 [@BotFather]() 管理此机器人。"; +"PeerInfo.Bot.ChangeSettings" = "更改机器人设置"; +"PeerInfo.Bot.EditCommands" = "修改命令"; +"PeerInfo.Bot.EditIntro" = "修改介绍"; +"PeerInfo.Bot.Username" = "用户名"; +"PeerInfo.BotBalance.Stars" = "星星"; +"PeerInfo.BotBalance.Title" = "余额"; +"PeerInfo.BotBalance.Ton" = "Gram"; +"PeerInfo.BotBlockedText" = "此机器人将无法向您发消息。"; +"PeerInfo.BotBlockedTitle" = "已屏蔽机器人"; +"PeerInfo.BotLinks" = "公开链接"; +"PeerInfo.BusinessHours.DayClosed" = "休息"; +"PeerInfo.BusinessHours.DayOpen24h" = "24 小时营业"; +"PeerInfo.BusinessHours.Label" = "营业时间"; +"PeerInfo.BusinessHours.StatusClosed" = "已关闭"; +"PeerInfo.BusinessHours.StatusOpen" = "营业中"; +"PeerInfo.BusinessHours.StatusOpensInDays#other" = "%d 天后营业"; +"PeerInfo.BusinessHours.StatusOpensInHours#other" = "%d 小时后营业"; +"PeerInfo.BusinessHours.StatusOpensInMinutes#other" = "%d 分钟后营业"; +"PeerInfo.BusinessHours.StatusOpensOnDate" = "%@ 营业"; +"PeerInfo.BusinessHours.StatusOpensTodayAt" = "今天 %@ 营业"; +"PeerInfo.BusinessHours.StatusOpensTomorrowAt" = "明天 %@ 营业 "; +"PeerInfo.BusinessHours.TimezoneSwitchBusiness" = "当地时间"; +"PeerInfo.BusinessHours.TimezoneSwitchMy" = "你本地的时间"; +"PeerInfo.ButtonAddMember" = "添加成员"; +"PeerInfo.ButtonCall" = "呼叫"; +"PeerInfo.ButtonDiscuss" = "讨论"; +"PeerInfo.ButtonLeave" = "退出"; +"PeerInfo.ButtonLiveStream" = "直播"; +"PeerInfo.ButtonMessage" = "消息"; +"PeerInfo.ButtonMore" = "更多"; +"PeerInfo.ButtonMute" = "静音"; +"PeerInfo.ButtonSearch" = "搜索"; +"PeerInfo.ButtonStop" = "屏蔽"; +"PeerInfo.ButtonUnmute" = "取消静音"; +"PeerInfo.ButtonVideoCall" = "视频"; +"PeerInfo.ButtonVoiceChat" = "视频聊天"; +"PeerInfo.CancelSelectionAlertNo" = "否"; +"PeerInfo.CancelSelectionAlertText" = "取消选择?"; +"PeerInfo.CancelSelectionAlertYes" = "是"; +"PeerInfo.ChangeEmojiStatus" = "更改表情符号状态"; +"PeerInfo.ChangeProfileColor" = "更改个人资料页颜色"; +"PeerInfo.Channel.ArchivedStories" = "已归档的动态"; +"PeerInfo.Channel.Boost" = "助推频道"; +"PeerInfo.ChannelAutoDeleteInfo" = "在一定时间后自动删除此频道中发送的消息。"; +"PeerInfo.ChannelMessages" = "私信"; +"PeerInfo.ClearConfirmationGroup" = "您是否想要删除 %@ 中的所有消息?"; +"PeerInfo.ClearConfirmationUser" = "您确定要删除与 %@ 的所有消息记录吗?"; +"PeerInfo.ClearMessages" = "清除消息"; +"PeerInfo.CustomizeNotifications" = "自定义"; +"PeerInfo.DeleteChannelText" = "您想要为所有订阅者删除频道 **%@** 和其中的所有消息吗?"; +"PeerInfo.DeleteChannelTitle" = "为所有人删除"; +"PeerInfo.DeleteGroupText" = "你是否需要为群组 **%@** 的所有成员删除此群组及其中的所有消息?"; +"PeerInfo.DeleteGroupTitle" = "为所有人删除"; +"PeerInfo.DeleteReaction" = "删除回应"; +"PeerInfo.DeleteToneText" = "你确定你要删除\n **%@** 通知声音?"; +"PeerInfo.DeleteToneTitle" = "删除通知音"; +"PeerInfo.DisableSharing" = "禁用分享"; +"PeerInfo.DisableSound" = "关闭通知音"; +"PeerInfo.EnableAutoDelete" = "启用自动删除"; +"PeerInfo.EnableSharing" = "启用分享"; +"PeerInfo.EnableSound" = "启用声音"; +"PeerInfo.GiftPremium" = "赠予高级版订阅"; +"PeerInfo.Gifts.AddCollection" = "添加收藏集"; +"PeerInfo.Gifts.AddGifts" = "添加礼物"; +"PeerInfo.Gifts.AddGiftsButton" = "添加礼物"; +"PeerInfo.Gifts.AddedToCollection" = "此礼物已添加至 **%@**。"; +"PeerInfo.Gifts.AddedToCollectionUnique" = "**%1$@** 已添加到 **%2$@**。"; +"PeerInfo.Gifts.ChannelNotify" = "关于新礼物的通知"; +"PeerInfo.Gifts.ChannelNotifyDisabledTooltip" = "当你的频道收到礼物时,你不会收到通知。"; +"PeerInfo.Gifts.ChannelNotifyTooltip" = "当你的频道收到礼物时,Telegram 将给你发送消息。"; +"PeerInfo.Gifts.CollectionLimitReached.Text" = "请删除一个现有收藏集以建立新的收藏集。"; +"PeerInfo.Gifts.CollectionLimitReached.Title" = "已达上线"; +"PeerInfo.Gifts.Collections.Add" = "添加收藏集"; +"PeerInfo.Gifts.Collections.All" = "所有礼物"; +"PeerInfo.Gifts.Context.AddToCollection" = "添加到典藏集"; +"PeerInfo.Gifts.Context.CopyLink" = "复制链接"; +"PeerInfo.Gifts.Context.Hide" = "隐藏"; +"PeerInfo.Gifts.Context.NewCollection" = "新收藏集"; +"PeerInfo.Gifts.Context.Pin" = "置顶"; +"PeerInfo.Gifts.Context.RemoveFromCollection" = "从收藏集中移除"; +"PeerInfo.Gifts.Context.Reorder" = "重新排序"; +"PeerInfo.Gifts.Context.Share" = "分享"; +"PeerInfo.Gifts.Context.Show" = "显示"; +"PeerInfo.Gifts.Context.Transfer" = "转让"; +"PeerInfo.Gifts.Context.Unpin" = "取消置顶"; +"PeerInfo.Gifts.Context.Wear" = "佩带"; +"PeerInfo.Gifts.CreateCollection.Placeholder" = "标题"; +"PeerInfo.Gifts.CreateCollection.Text" = "为您的收藏集选择一个名称,并添加您的礼物。"; +"PeerInfo.Gifts.CreateCollection.Title" = "新建收藏集"; +"PeerInfo.Gifts.DeleteCollection" = "删除收藏集"; +"PeerInfo.Gifts.Displayed" = "显示"; +"PeerInfo.Gifts.EmptyCollection.Action" = "添加礼物"; +"PeerInfo.Gifts.EmptyCollection.Text" = "将一些礼物添加到此收藏集中。"; +"PeerInfo.Gifts.EmptyCollection.Title" = "整理您的礼物"; +"PeerInfo.Gifts.Hidden" = "隐藏"; +"PeerInfo.Gifts.Info" = "点击礼物可将其转换为星星或更改其隐私设置。"; +"PeerInfo.Gifts.Limited" = "限量"; +"PeerInfo.Gifts.NoResults" = "没有符合的礼物"; +"PeerInfo.Gifts.NoResults.ViewAll" = "查看所有礼物"; +"PeerInfo.Gifts.OneOf" = "1 / %@"; +"PeerInfo.Gifts.RemoveCollectionAction" = "移除"; +"PeerInfo.Gifts.RemoveCollectionConfirmation" = "这将移除此收藏集。"; +"PeerInfo.Gifts.RemovedFromCollection" = "此礼物已从 **%@** 移除。"; +"PeerInfo.Gifts.RemovedFromCollectionUnique" = "*%1$@*** 已从 **%2$@** 移除。"; +"PeerInfo.Gifts.RenameCollection" = "更改名称"; +"PeerInfo.Gifts.RenameCollection.Title" = "编辑收藏集名称"; +"PeerInfo.Gifts.Reorder" = "重新排序"; +"PeerInfo.Gifts.Sale" = "在售"; +"PeerInfo.Gifts.Send" = "为好友送礼"; +"PeerInfo.Gifts.SendGift" = "发送礼物"; +"PeerInfo.Gifts.ShareCollection" = "分享"; +"PeerInfo.Gifts.SortByDate" = "按日期排序"; +"PeerInfo.Gifts.SortByValue" = "按价值排序"; +"PeerInfo.Gifts.ToastPinLimit.Text#other" = "你不能置顶超过 %@ 份礼物。"; +"PeerInfo.Gifts.ToastPinned.ReplacingText" = "更换 %@"; +"PeerInfo.Gifts.ToastPinned.Text" = "现在它将总是显示在顶部。"; +"PeerInfo.Gifts.ToastPinned.Title" = "礼物已置顶"; +"PeerInfo.Gifts.ToastPinned.TitleNew" = "%@ 已置顶"; +"PeerInfo.Gifts.ToastUnpinned.Text" = "礼物已取消置顶。"; +"PeerInfo.Gifts.Unique" = "独家"; +"PeerInfo.Gifts.Unlimited" = "不限量"; +"PeerInfo.Gifts.Upgradable" = "可升级"; +"PeerInfo.Group.Boost" = "助推群组"; +"PeerInfo.GroupAboutItem" = "关于"; +"PeerInfo.HiddenStatusBadge" = "何时?"; +"PeerInfo.HideMembersLimitedParticipantCountText#other" = "只有超过 **%d 名成员**的群组允许隐藏成员列表。"; +"PeerInfo.HideMembersLimitedRights" = "您没有修改此设置的权限。"; +"PeerInfo.ItemAffiliateProgram.Footer" = "将 %1$@ 的链接分享给好友,即可获得其在该应用消费金额的 %2$@%。"; +"PeerInfo.ItemAffiliateProgram.Title" = "联盟计划"; +"PeerInfo.ItemAffiliateProgram.ValueOff" = "关闭"; +"PeerInfo.ItemAffiliatePrograms.Title" = "联盟计划"; +"PeerInfo.LabelAllReactions" = "所有回应"; +"PeerInfo.LeaveChannelText" = "您确定要退出频道 **%@** 吗?"; +"PeerInfo.LeaveChannelTitle" = "退出频道"; +"PeerInfo.LeaveGroupText" = "您确定要退出群组 **%@** 吗?"; +"PeerInfo.LeaveGroupTitle" = "退出群组"; +"PeerInfo.Location.Label" = "位置"; +"PeerInfo.ManagedBotFooter" = "由 [@%@]() 创建并管理"; +"PeerInfo.MenuAddStories" = "添加动态"; +"PeerInfo.MuteFor" = "暂停通知"; +"PeerInfo.MuteForCustom" = "静音直到..."; +"PeerInfo.MuteForever" = "永久静音"; +"PeerInfo.NoteActionCopy" = "复制注解"; +"PeerInfo.NoteActionEdit" = "编辑注解"; +"PeerInfo.Notes" = "备注"; +"PeerInfo.NotesInfo" = "仅您可见"; +"PeerInfo.NotificationMemberAdded" = "**%1$@** 已加入群组。"; +"PeerInfo.NotificationMultipleMembersAdded#other" = "已将 **%d 位成员** 加入群组。"; +"PeerInfo.NotificationsCustomize" = "自定义"; +"PeerInfo.OpenAppButton" = "打开应用"; +"PeerInfo.OptionTopics" = "话题"; +"PeerInfo.OptionTopics.Disabled" = "已禁用"; +"PeerInfo.OptionTopics.Enabled" = "已启用"; +"PeerInfo.OptionTopicsText" = "群聊将被划分为由管理员或用户创建的话题。"; +"PeerInfo.PaneArchivedStories" = "已归档的动态"; +"PeerInfo.PaneAudio" = "音乐"; +"PeerInfo.PaneBotPreviews" = "预览"; +"PeerInfo.PaneFiles" = "文件"; +"PeerInfo.PaneGifs" = "GIF"; +"PeerInfo.PaneGifts" = "礼物"; +"PeerInfo.PaneGroups" = "群组"; +"PeerInfo.PaneLinks" = "链接"; +"PeerInfo.PaneMedia" = "媒体"; +"PeerInfo.PaneMembers" = "成员"; +"PeerInfo.PanePolls" = "投票"; +"PeerInfo.PaneRecommended" = "相似频道"; +"PeerInfo.PaneRecommendedBots" = "相似机器人"; +"PeerInfo.PaneStories" = "动态"; +"PeerInfo.PaneVoice" = "语音消息"; +"PeerInfo.PaneVoiceAndVideo" = "语音"; +"PeerInfo.Permissions.Biometry" = "生物识别"; +"PeerInfo.Permissions.EmojiStatus" = "表情状态"; +"PeerInfo.Permissions.Geolocation" = "地理位置"; +"PeerInfo.Permissions.Title" = "允许访问"; +"PeerInfo.PrivateShareLinkInfo" = "此链接仅对群组成员有效。"; +"PeerInfo.QRCode.Scan" = "扫描二维码"; +"PeerInfo.QRCode.Title" = "二维码"; +"PeerInfo.Reactions" = "表情回应"; +"PeerInfo.ReactionsDisabled" = "已停用"; +"PeerInfo.ReportProfilePhoto" = "举报头像"; +"PeerInfo.ReportProfileVideo" = "举报头像"; +"PeerInfo.SavedMessagesTabTitle" = "已保存"; +"PeerInfo.SetEmojiStatus" = "设置表情符号状态"; +"PeerInfo.SimilarBots.ShowMore" = "显示更多机器人"; +"PeerInfo.SimilarBots.ShowMoreInfo" = "订阅 **Telegram 高级版** \n解锁最多 100 个相似机器人。"; +"PeerInfo.Tabs.SetMainTab" = "设为主选项卡"; +"PeerInfo.Tabs.SetMainTab.Succeed" = "选项卡顺序已改变。"; +"PeerInfo.ToastNoteCopied" = "笔记已拷贝到剪贴板。"; +"PeerInfo.TooltipMutedFor" = "通知已被静音 %@。"; +"PeerInfo.TooltipMutedForever" = "通知已被静音。"; +"PeerInfo.TooltipMutedUntil" = "通知已静音并持续到 %@。"; +"PeerInfo.TooltipSoundDisabled" = "你将会收到无声的通知。"; +"PeerInfo.TooltipSoundEnabled" = "你将会收到有声的通知。"; +"PeerInfo.TooltipUnmuted" = "通知已被静音。"; +"PeerInfo.TopicHeaderLocation" = "在 %1$@"; +"PeerInfo.TopicIconInfoText" = "本主题图标来自**%@**。"; +"PeerInfo.TopicNotificationExceptions#other" = "[%d 个话题]() 被列为例外。"; +"PeerInfo.Topics.DisplayAs" = "显示为"; +"PeerInfo.Topics.DisplayAsInfo" = "选择话题向所有成员显示的方式。"; +"PeerInfo.Topics.EnableTopics" = "启用话题"; +"PeerInfo.Topics.EnableTopicsInfo" = "群组聊天将划分成由管理员或成员建立的话题。"; +"PeerInfo.Topics.List" = "列表"; +"PeerInfo.Topics.Tabs" = "标签"; +"PeerInfo.Topics.Title" = "话题"; +"PeerInfo.TopicsLimitedDiscussionGroups" = "目前无法在频道的讨论群组中使用话题。"; +"PeerInfo.TopicsLimitedParticipantCountText#other" = "只有拥有超过 **%d 名成员** 的群组才能启用话题功能。"; +"PeerInfo.UnofficialSecurityRisk" = "%@ 正在使用非官方的 Telegram 客户端—这可能会降低与该用户通信的安全性"; +"PeerInfo.VerifyAccounts" = "验证账号"; +"PeerInfo.ViewDiscussion" = "查看讨论"; +"PeerSelection.ImportIntoNewGroup" = "导入到新群组"; +"PeerStatusExpiration.AtDate" = "你的状态将于 %@ 过期"; +"PeerStatusExpiration.Hours#other" = "你的状态将在 %@ 小时后过期"; +"PeerStatusExpiration.Minutes#other" = "你的状态将在 %@ 分钟后过期"; +"PeerStatusExpiration.TomorrowAt" = "你的状态将于明日 %@ 过期"; +"PeerStatusSetup.NoTimerTitle" = "长按以设置定时器"; +"PeopleNearby.CreateGroup" = "创建本地群组"; +"PeopleNearby.Description" = "邀请附近的朋友打开此页面来交换手机号码。"; +"PeopleNearby.DiscoverDescription" = "与附近的人交换联系方式并查找新伙伴。"; +"PeopleNearby.Groups" = "附近的群组"; +"PeopleNearby.MakeInvisible" = "隐身"; +"PeopleNearby.MakeVisible" = "使自己可见"; +"PeopleNearby.MakeVisibleDescription" = "附近的用户将能够查看你的个人资料并向你发送消息。 这可能会帮助你找到新朋友,但也可能引起过多关注。 你可以随时停止共享个人资料。\n\n你的电话号码将保持隐藏状态。"; +"PeopleNearby.MakeVisibleTitle" = "显示个人资料"; +"PeopleNearby.NoMembers" = "没有成员"; +"PeopleNearby.ShowMorePeople#other" = "显示 %@ 更多人"; +"PeopleNearby.Title" = "附近的人"; +"PeopleNearby.Users" = "附近的人"; +"PeopleNearby.UsersEmpty" = "正在为您搜寻附近的人。。。"; +"PeopleNearby.VisibleUntil" = "可见直到%@"; +"Permissions.CellularDataAllowInSettings.v0" = "在\"设置\"中开启"; +"Permissions.CellularDataText.v0" = "不用担心,Telegram将网络使用率降至最低。 您可以在“设置”>“数据和存储”中进一步控制它。"; +"Permissions.CellularDataTitle.v0" = "打开蜂窝数据"; +"Permissions.ContactsAllow.v0" = "允许访问"; +"Permissions.ContactsAllowInSettings.v0" = "在设置中允许"; +"Permissions.ContactsText.v0" = "查看您在 Telegram 上的朋友并无缝切换,无需手动添加。"; +"Permissions.ContactsTitle.v0" = "同步您的通讯录"; +"Permissions.NotificationsAllow.v0" = "打开通知"; +"Permissions.NotificationsAllowInSettings.v0" = "在“设置”中开启"; +"Permissions.NotificationsText.v0" = "别错过来自朋友和同事的重要消息。"; +"Permissions.NotificationsTitle.v0" = "开启通知"; +"Permissions.NotificationsUnreachableText.v0" = "请注意,您已在设置中停用部分消息通知。"; +"Permissions.PeopleNearbyAllow.v0" = "允许访问"; +"Permissions.PeopleNearbyAllowInSettings.v0" = "在设置允许"; +"Permissions.PeopleNearbyText.v0" = "快速添加附近正在查看此页面的人并发现附近的群组。\n\n请开启位置访问权限以启用此功能。"; +"Permissions.PeopleNearbyTitle.v0" = "附近的人"; +"Permissions.PrivacyPolicy" = "隐私政策"; +"Permissions.SiriAllow.v0" = "打开 Siri"; +"Permissions.SiriAllowInSettings.v0" = "在\"设置\"中开启"; +"Permissions.SiriText.v0" = "使用Siri发送消息及拨打电话。"; +"Permissions.SiriTitle.v0" = "开启 Siri"; +"Permissions.Skip" = "跳过"; +"PhoneLabel.Title" = "标签"; +"PhoneNumberHelp.Alert" = "如果其他用户在其通讯录中存有您的新号码,或者您的隐私设置允许其查看您的号码,该用户将会看到您的新号码。您可以在“设置” > “隐私与安全” > “手机号码”中更改此设置。"; +"PhoneNumberHelp.ChangeNumber" = "更换手机号码"; +"PhoneNumberHelp.Help" = "您可以在此更换当前绑定到 Telegram 的手机号码。您的帐号和所有云端数据(消息、媒体文件、联系人等)都会迁移到新号码下。"; +"PhotoEditor.BlurToolLinear" = "线性"; +"PhotoEditor.BlurToolOff" = "关闭"; +"PhotoEditor.BlurToolPortrait" = "肖像"; +"PhotoEditor.BlurToolRadial" = "圆形"; +"PhotoEditor.ContrastTool" = "对比"; +"PhotoEditor.CropAspectRatioOriginal" = "原图"; +"PhotoEditor.CropAspectRatioSquare" = "正方形"; +"PhotoEditor.CropAuto" = "自动"; +"PhotoEditor.CropReset" = "重置"; +"PhotoEditor.CurvesAll" = "全部颜色"; +"PhotoEditor.CurvesBlue" = "蓝"; +"PhotoEditor.CurvesGreen" = "绿"; +"PhotoEditor.CurvesRed" = "红"; +"PhotoEditor.DiscardChanges" = "放弃更改"; +"PhotoEditor.EnhanceTool" = "增强"; +"PhotoEditor.ExposureTool" = "亮度"; +"PhotoEditor.FadeTool" = "褪色"; +"PhotoEditor.GrainTool" = "噪点"; +"PhotoEditor.HighlightsTint" = "高光"; +"PhotoEditor.HighlightsTool" = "高光"; +"PhotoEditor.Original" = "原图"; +"PhotoEditor.QualityTool" = "画质"; +"PhotoEditor.SaturationTool" = "饱和"; +"PhotoEditor.SelectCoverFrame" = "选择一帧作为您的头像"; +"PhotoEditor.SelectCoverFrameSuggestion" = "为此头像选择封面帧"; +"PhotoEditor.SetAsMyPhoto" = "设置头像"; +"PhotoEditor.SetAsMyVideo" = "设置为我的视频"; +"PhotoEditor.ShadowsTint" = "阴影"; +"PhotoEditor.ShadowsTool" = "阴影"; +"PhotoEditor.SharpenTool" = "锐化"; +"PhotoEditor.SkinTool" = "磨皮"; +"PhotoEditor.TiltShift" = "倾斜移位"; +"PhotoEditor.VignetteTool" = "晕影"; +"PhotoEditor.WarmthTool" = "色温"; +"PlaybackSpeed.Normal" = "正常"; +"PlaybackSpeed.Title" = "播放速度"; +"PollResults.Collapse" = "折叠"; +"PollResults.ShowMore#other" = "显示更多的 (%@) 个"; +"PollResults.Title" = "投票结果"; +"PollStats.GraphHeader" = "投票时间表"; +"PollStats.Title" = "投票统计数据"; +"PowerSaving.AlertEnabledAction" = "停用"; +"PowerSaving.AlertEnabledText" = "剩余 %@ 电量。"; +"PowerSaving.AlertEnabledTitle" = "省电模式已启用"; +"PowerSaving.AllDescriptionAlways" = "无论电池电量如何,始终禁用所有资源密集型进程。"; +"PowerSaving.AllDescriptionLimit" = "当电池电量低于%@时,自动减少功耗和动画特效。"; +"PowerSaving.AllDescriptionNever" = "即使电量不足,始终保持动画效果并不启用节电模式。"; +"PowerSaving.BatteryLevelLimit.AlwaysOff" = "总是关闭"; +"PowerSaving.BatteryLevelLimit.AlwaysOn" = "始终开启"; +"PowerSaving.BatteryLevelLimit.Off" = "关闭"; +"PowerSaving.BatteryLevelLimit.On" = "开启"; +"PowerSaving.BatteryLevelLimit.WhenBelow" = "低于 %@%"; +"PowerSavingScreen.OptionAutoplayEffectsText" = "使 Telegram 看起来超棒的各种特效和动画。"; +"PowerSavingScreen.OptionAutoplayEffectsTitle" = "界面效果"; +"PowerSavingScreen.OptionAutoplayEmojiText" = "在消息、表情回应和状态中循环播放动画表情。"; +"PowerSavingScreen.OptionAutoplayEmojiTitle" = "表情动画"; +"PowerSavingScreen.OptionAutoplayGifText" = "在聊天和键盘中自动循环播放 GIF。"; +"PowerSavingScreen.OptionAutoplayGifTitle" = "自动播放 GIF "; +"PowerSavingScreen.OptionAutoplayStickersText" = "循环播放动态贴纸,全屏播放高级特效。"; +"PowerSavingScreen.OptionAutoplayStickersTitle" = "贴纸动画"; +"PowerSavingScreen.OptionAutoplayVideoText" = "在聊天中自动并循环播放视频和视频消息。"; +"PowerSavingScreen.OptionAutoplayVideoTitle" = "自动播放视频"; +"PowerSavingScreen.OptionBackgroundText" = "在应用程序之间切换时更快地更新聊天。"; +"PowerSavingScreen.OptionBackgroundTitle" = "后台更新"; +"PowerSavingScreen.OptionChangeAlertAlways" = "关闭省电模式以更改这些设置。"; +"PowerSavingScreen.OptionChangeAlertConditional" = "关闭省电模式或充电后才能更改这些设置。"; +"PowerSavingScreen.OptionPreloadText" = "在聊天列表中开始加载媒体以便快速访问。"; +"PowerSavingScreen.OptionPreloadTitle" = "预加载媒体"; +"PowerSavingScreen.OptionsHeader" = "省电选项"; +"PowerSavingScreen.Title" = "省电设置"; +"PowerSavingScreen.ToggleHeaderEmpty" = "省电模式"; +"PowerSavingScreen.ToggleHeaderOff" = "关闭省电模式 (OFF)"; +"PowerSavingScreen.ToggleHeaderOn" = "省电模式(开)"; +"Premium.AboutText" = "虽然免费版本的 Telegram 已经为用户提供了比其他任何即时通讯应用程序更多的功能,但 **Telegram Premium** 进一步推动了它。\n\n**Telegram Premium** 是付费选项,因为大多数高级功能需要 Telegram 向第三方(如数据中心提供商和服务器制造商)支付额外费用。 **Telegram Premium** 用户的贡献使我们能够支付此类费用,并帮助 Telegram 保持为所有人免费提供。"; +"Premium.AboutTitle" = "关于Telegram 高级版"; +"Premium.AiTools" = "AI 工具"; +"Premium.AiToolsInfo" = "将您的消息和整个聊天记录转换为您偏好的风格和语言。"; +"Premium.AnimatedEmoji" = "动态表情"; +"Premium.AnimatedEmoji.Proceed" = "解锁动态表情"; +"Premium.AnimatedEmojiInfo" = "在您发送的任何消息中包含来自不同表情包的动态表情。"; +"Premium.AnimatedEmojiStandaloneInfo" = "在您发送的任何消息中包含来自不同表情包的动态表情。"; +"Premium.Annual" = "按年"; +"Premium.AppIcon" = "Telegram 应用图标"; +"Premium.AppIconInfo" = "从一系列图标中选择你的 Telegram 应用程序的桌面图标。"; +"Premium.AppIconStandalone" = "高级应用图标"; +"Premium.AppIconStandaloneInfo" = "通过订阅 **Telegram 高级版** 来解锁更加广泛的应用图标选择。"; +"Premium.AppIcons.Proceed" = "解锁高级会员图标"; +"Premium.Avatar" = "动态头像"; +"Premium.AvatarInfo" = "视频头像会在聊天列表和聊天中动起来,为您提供更多自我表达空间。"; +"Premium.Badge" = "形象勋章"; +"Premium.BadgeInfo" = "在您的名字后方显示专属徽章,以表示您已订阅 Telegram 高级版。"; +"Premium.Biannual" = "2 年"; +"Premium.BoostByGiftDescription" = ""; +"Premium.BoostByGiftDescription2" = "通过向订阅者赠送 Telegram 高级版来助推您的频道。 [获得助推 >]()"; +"Premium.BoostByGiveawayDescription" = "通过赠送奖品来为您的频道获得更多的助推和订阅者。 [获取助推 >]()"; +"Premium.Business" = "Telegram 商业版"; +"Premium.Business.Away.Text" = "设置在您离线时自动发送的消息。"; +"Premium.Business.Away.Title" = "离开消息"; +"Premium.Business.Chatbots.Text" = "添加任何将帮助您处理与客户互动的第三方聊天机器人。"; +"Premium.Business.Chatbots.Title" = "聊天机器人"; +"Premium.Business.Description" = "将您的账户转变为企业页面,并获得以下额外功能。"; +"Premium.Business.Greetings.Text" = "设置自动发送给新客户的问候信息。"; +"Premium.Business.Greetings.Title" = "问候消息"; +"Premium.Business.Hours.Text" = "向您的客户显示您何时营业。"; +"Premium.Business.Hours.Title" = "营业时间"; +"Premium.Business.Intro.Text" = "自定义人们与你发起聊天前看到的消息"; +"Premium.Business.Intro.Title" = "起始页"; +"Premium.Business.Links.Text" = "创建与你聊天的链接,并建议第一条消息"; +"Premium.Business.Links.Title" = "聊天链接"; +"Premium.Business.Location.Text" = "在您的帐号上显示您的企业位置。"; +"Premium.Business.Location.Title" = "位置"; +"Premium.Business.Replies.Text" = "设置包含文本和媒体的快捷方式,以更快地回复消息。"; +"Premium.Business.Replies.Title" = "快速回复"; +"Premium.BusinessInfo" = "升级您的账户后可使用企业功能,如位置信息、营业时间和快速回复。"; +"Premium.ChatManagement" = "高级聊天管理"; +"Premium.ChatManagement.Proceed" = "关于 Telegram 高级版"; +"Premium.ChatManagementInfo" = "可设定默认文件夹、自动归档以及隐藏来自非联系人的新聊天。"; +"Premium.ChatManagementStandaloneInfo" = "**Telegram 高级版**的订阅者可以设置默认进入的文件夹、自动归档以及隐藏非联系人的新聊天。"; +"Premium.Colors" = "名字和个人资料页颜色"; +"Premium.Colors.Proceed" = "关于 Telegram 高级版"; +"Premium.ColorsInfo" = "为您的个人资料页以及收到回复的消息设置颜色和图标。"; +"Premium.CopyProtection" = "禁用分享"; +"Premium.CopyProtectionInfo" = "禁用私聊中的截屏、收藏和转发消息功能。"; +"Premium.CreateMultipleStories" = "创建多条动态"; +"Premium.CurrentPlan" = "您的当前计划"; +"Premium.Description" = "订阅 **Telegram Premium** 后,您能 **超越限制** 并 **解锁数十项专属** 功能。"; +"Premium.DoubledLimits" = "上限加倍"; +"Premium.DoubledLimitsInfo" = "多达 1000 个频道、30 个文件夹、10 个置顶、20 个公开链接、4 个帐号等等。"; +"Premium.Emoji.Description" = "订阅 **Telegram 高级版**解锁该表情及更多其他表情。"; +"Premium.Emoji.Proceed" = "解锁高级表情"; +"Premium.EmojiStatus" = "表情符号状态"; +"Premium.EmojiStatus.Proceed" = "解锁表情状态"; +"Premium.EmojiStatusInfo" = "从数千个表情中选择一个展示当前的活动,显示在您的名字旁边。"; +"Premium.EmojiStatusShortTitle" = "这是 %1$@ 的当前状态。"; +"Premium.EmojiStatusText" = "表情符号状态是一项高级功能。**Telegram 高级版** 包含的其它功能:"; +"Premium.EmojiStatusTitle" = "这是 %1$@ 的当前状态,该表情来自#[%2$@]()。"; +"Premium.FasterSpeed" = "更快的下载速度"; +"Premium.FasterSpeed.Proceed" = "关于 Telegram 高级版"; +"Premium.FasterSpeedInfo" = "下载媒体文件和文档时不再受到速度限制。"; +"Premium.FasterSpeedStandaloneInfo" = "订阅 **Telegram 高级版** 以最快的速度下载媒体和文件。"; +"Premium.FileTooLarge" = "文件过大"; +"Premium.FolderTags" = "文件夹标签"; +"Premium.FolderTags.Proceed" = "关于 Telegram 高级版 "; +"Premium.FolderTagsInfo" = "使用 Telegram 高级版为聊天添加彩色标签,以便更快访问。"; +"Premium.FolderTagsStandaloneInfo" = "使用 Telegram 高级版为聊天添加彩色标签,以便更快访问。"; +"Premium.Free" = "免费"; +"Premium.Gift.ApplyLink" = "免费应用"; +"Premium.Gift.ApplyLink.AlreadyHasPremium.Text" = "您可以在 %@ 之后激活此礼品码,或者 [发送链接]() 给朋友。"; +"Premium.Gift.ApplyLink.AlreadyHasPremium.Title" = "您已经拥有 Telegram 高级版"; +"Premium.Gift.ContactSelection.AddBirthday" = "添加您的生日"; +"Premium.Gift.ContactSelection.BirthdayToday" = "🎂 今天生日"; +"Premium.Gift.ContactSelection.BirthdayTomorrow" = "明天过生日"; +"Premium.Gift.ContactSelection.BirthdayYesterday" = "昨天生日"; +"Premium.Gift.ContactSelection.BuySelf" = "给自己买一份礼物"; +"Premium.Gift.ContactSelection.DeselectAll" = "取消全选"; +"Premium.Gift.ContactSelection.FrequentContacts" = "常用联系人"; +"Premium.Gift.ContactSelection.MaximumReached" = "您最多可以选择 %@ 个用户。"; +"Premium.Gift.ContactSelection.OpenProfile" = "打开个人资料"; +"Premium.Gift.ContactSelection.Placeholder" = "搜索要赠予高级版的对象..."; +"Premium.Gift.ContactSelection.Proceed" = "继续"; +"Premium.Gift.ContactSelection.SelectAll" = "全选"; +"Premium.Gift.ContactSelection.SendMessage" = "发送消息"; +"Premium.Gift.ContactSelection.ThisIsYou" = "这是你"; +"Premium.Gift.ContactSelection.Title" = "赠送 Telegram 高级版"; +"Premium.Gift.ContactSelection.TransferSelf" = "发送自己一份礼物"; +"Premium.Gift.Description" = "将 **Telegram 高级版** 的专属功能赠予 **%@**。"; +"Premium.Gift.GiftMultipleSubscriptions#other" = "赠送 %@ 个 Telegram 高级版订阅"; +"Premium.Gift.GiftMultipleSubscriptionsFormat" = "%2$@ 的 %1$@"; +"Premium.Gift.GiftSubscription" = "花费 %@ 赠予订阅"; +"Premium.Gift.Info" = "您可以在 [这里]() 查看 Telegram 高级版的功能列表和更多详情。"; +"Premium.Gift.Link.Text" = "此链接允许您或 [您选择的任何人]()激活 **Telegram 高级版** 订阅。"; +"Premium.Gift.Months#other" = "%@ 个月"; +"Premium.Gift.MultipleDescription" = "将 **Telegram 高级版** 的专属功能赠予 %1$@ 和 %2$@。"; +"Premium.Gift.NamesAndMore#other" = " 等 %@ 更多"; +"Premium.Gift.PricePerMonth" = "%@ / 月"; +"Premium.Gift.Sent.Close" = "关闭"; +"Premium.Gift.Sent.Multiple.Text" = "%1$@%2$@ 已收到有关您购买的礼物的通知。\n\n他们现在可以访问其他功能。"; +"Premium.Gift.Sent.Multiple.Title" = "礼物已赠送!"; +"Premium.Gift.Sent.One.Text" = "%@ 已收到有关您购买的礼物的通知。\n\n他们现在可以访问其他功能。"; +"Premium.Gift.Sent.One.Title" = "礼物已赠送!"; +"Premium.Gift.Terms" = "赠予 Telegram 高级版即表示您同意 Telegram [服务条款](terms) 和 [隐私政策](privacy)。"; +"Premium.Gift.Title" = "赠予 Telegram 高级版订阅"; +"Premium.Gift.TitleShort" = "Telegram 高级版"; +"Premium.Gift.UsedLink.Text" = "此链接用于启用 **Telegram 高级版** 订阅。"; +"Premium.Gift.Years#other" = "%@ 年"; +"Premium.Gift.YouWillReceiveBoosts#other" = "您将获得 []() **%@** 次助推。"; +"Premium.GiftedDescription" = "您现在可以使用额外的功能了。"; +"Premium.GiftedDescriptionYou" = "对方现在可以使用额外的功能了。"; +"Premium.GiftedTitle" = "Telegram 高级版"; +"Premium.GiftedTitle.12Month" = "[%@]() 已赠予您 12 个月的 Telegram 高级版订阅"; +"Premium.GiftedTitle.3Month" = "[%@]() 已赠予您 3 个月的 Telegram 高级版订阅"; +"Premium.GiftedTitle.6Month" = "[%@]() 已赠予您 6 个月的 Telegram 高级版订阅"; +"Premium.GiftedTitle.Someone" = "某人"; +"Premium.GiftedTitleYou.12Month" = "您已赠予 [%@]() 12 个月的 Telegram 高级版订阅。"; +"Premium.GiftedTitleYou.3Month" = "您已赠予 [%@]() 3 个月的 Telegram 高级版订阅。"; +"Premium.GiftedTitleYou.6Month" = "您已赠予 [%@]() 6 个月的 Telegram 高级版订阅。"; +"Premium.Group.BoostByGiftDescription" = "通过向成员赠送 Telegram 高级版来助推您的群组。 [获得助推>]()"; +"Premium.Group.BoostByGiveawayDescription" = "通过赠送奖品来获得更多的助推和群组成员。 [获取助推 >]()"; +"Premium.IncreaseLimit" = "提升上限"; +"Premium.InfiniteReactions" = "无限表情回应"; +"Premium.InfiniteReactionsInfo" = "用数千个表情作出回应——可使用多个表情回应单条消息。"; +"Premium.LastSeen" = "最近上线时间"; +"Premium.LastSeen.Proceed" = "关于 Telegram 高级版"; +"Premium.LastSeenInfo" = "即使您已将其隐藏,也可查看其它用户的最后上线及已读时间。"; +"Premium.LimitReached" = "达到上限"; +"Premium.Limits.Accounts" = "关联账户"; +"Premium.Limits.AccountsInfo" = "连接至4个不同手机号码的账号"; +"Premium.Limits.Bio" = "个人简介"; +"Premium.Limits.BioInfo" = "在个人简介中添加更多文字并使用链接"; +"Premium.Limits.Captions" = "说明文字"; +"Premium.Limits.CaptionsInfo" = "在图片和视频下使用更长的说明文字"; +"Premium.Limits.ChatsPerFolder" = "每个文件夹内聊天数量"; +"Premium.Limits.ChatsPerFolderInfo" = "在每个文件夹内添加高达 200 个聊天 "; +"Premium.Limits.FavedStickers" = "贴纸收藏"; +"Premium.Limits.FavedStickersInfo" = "在贴纸收藏中存储高达10张贴纸"; +"Premium.Limits.Folders" = "文件夹"; +"Premium.Limits.FoldersInfo" = "将您的聊天整理到 30 个文件夹中"; +"Premium.Limits.GroupsAndChannels" = "群组和频道"; +"Premium.Limits.GroupsAndChannelsInfo" = "加入多达 1000 个频道和大型群组"; +"Premium.Limits.PinnedChats" = "已置顶聊天"; +"Premium.Limits.PinnedChatsInfo" = "在主聊天列表中置顶高达10个聊天"; +"Premium.Limits.PublicLinks" = "公共链接"; +"Premium.Limits.PublicLinksInfo" = "保留高达 20 个 [t.me/username]() 链接"; +"Premium.Limits.RecommendedChannels" = "相似频道"; +"Premium.Limits.RecommendedChannelsInfo" = "查看最多 100 个相似频道"; +"Premium.Limits.SavedGifs" = "保存的 GIF"; +"Premium.Limits.SavedGifsInfo" = "在 GIF 动态图收藏中存储高达 400 张 GIF 动态图"; +"Premium.Limits.Title" = "上限双倍"; +"Premium.MaxAccountsFinalText" = "你已经达到 **%@** 个关联账号的上限。"; +"Premium.MaxAccountsNoPremiumText" = "您已达到 **%@** 个关联帐户的限制。"; +"Premium.MaxAccountsText" = "您已达到 **%@** 个关联帐号的限制。 您可以通过订阅 **Telegram 高级版** 添加更多。"; +"Premium.MaxChannelsFinalText" = "抱歉,您只能加入 **%1$@** 个群组和频道。"; +"Premium.MaxChannelsNoPremiumText" = "您只能加入 **%1$@** 个群组和频道,此上限可能会在将来提高。"; +"Premium.MaxChannelsText" = "您只能加入 **%1$@** 个群组和频道。升级到 **Telegram 高级版** 以将链接上限提高到 **%2$@ 个**。"; +"Premium.MaxChatsInFolderFinalText" = "抱歉,您不能向文件夹中添加多于 **%@** 个聊天。"; +"Premium.MaxChatsInFolderNoPremiumText" = "抱歉,您无法在此文件夹中添加超过 **%1$@** 个聊天。请再创建一个新的文件夹。我们在以后将尽力为您提高该限制。"; +"Premium.MaxChatsInFolderText" = "抱歉,您无法在单个文件夹中添加超过 **%1$@** 个聊天。订阅 **Telegram 高级版** 后可将此上限提高至 **%2$@** 个聊天。"; +"Premium.MaxExpiringStoriesFinalText" = "您在 **24** 小时内发布的动态数量已达到上限 **%@** 条。"; +"Premium.MaxExpiringStoriesFinalTextFormat" = "您已达到每 **24** 小时 %@ 的限制。"; +"Premium.MaxExpiringStoriesFinalTextNumberFormat#other" = "**%d** 个动态"; +"Premium.MaxExpiringStoriesNoPremiumText" = "您在 **24** 小时内发布的动态数量已达到上限 **%@** 条。"; +"Premium.MaxExpiringStoriesNoPremiumTextFormat" = "您已达到每 **24** 小时 %@ 的限制。"; +"Premium.MaxExpiringStoriesNoPremiumTextNumberFormat#other" = "**%d** 个动态"; +"Premium.MaxExpiringStoriesText" = "每 **24** 小时您可以发送最多 **%@ 条**动态。\n订阅 **Telegram 高级版**后可将此上限提升至 **%@ 条**。"; +"Premium.MaxExpiringStoriesTextFormat" = "您可以在 **24** 小时后发布 %@。订阅 **Telegram 高级版** 可以将此限制提高到 **%@**。"; +"Premium.MaxExpiringStoriesTextNumberFormat#other" = "**%d** 个动态"; +"Premium.MaxExpiringStoriesTextPremiumNumberFormat#other" = "**%d** 个动态"; +"Premium.MaxFavedStickersFinalText" = "一张旧的贴纸已被该贴纸取代。"; +"Premium.MaxFavedStickersText" = "一张旧的贴纸已被这张贴纸替换。您可以 [提升上限]() 至 %@ 张贴纸。"; +"Premium.MaxFavedStickersTitle" = "已达到 %@ 个贴纸的限制"; +"Premium.MaxFileSizeFinalText" = "由于大小超过了 **%@**,无法发送此文档。"; +"Premium.MaxFileSizeNoPremiumText" = "由于大小超过了 **%@**,文档无法发送。我们正在努力能让您在未来提高此上限。"; +"Premium.MaxFileSizeText" = "您可以订阅 **Telegram 高级版**,将此上限翻倍至每个文件 %@。"; +"Premium.MaxFoldersCountFinalText" = "抱歉,您不能创建多于 **%1$@** 个文件夹。"; +"Premium.MaxFoldersCountNoPremiumText" = "你已达到 ** %1$@** 文件夹的上限。我们正在努力让你在未来提高此上限。"; +"Premium.MaxFoldersCountText" = "您已达到 **%1$@** 个文件夹的限额。 您可以订阅 **Telegram 高级版**,将此上限翻倍至 **%2$@** 个文件夹。"; +"Premium.MaxPinsFinalText" = "抱歉,您最多只能置顶 **%@** 个聊天。请先取消置顶一些当前已置顶的聊天。"; +"Premium.MaxPinsNoPremiumText" = "抱歉,您最多只能置顶 **%@** 个聊天。请先取消置顶一些当前已置顶的聊天。"; +"Premium.MaxPinsText" = "您无法置顶超过 **%1$@** 个聊天。请取消一些置顶的聊天,或者订阅 **Telegram 高级版**,将此上限翻倍至 **%2$@** 个聊天。"; +"Premium.MaxSavedGifsFinalText" = "一张旧的 GIF 被这张替代了。"; +"Premium.MaxSavedGifsText" = "一张旧的 GIF 动态图已被这张图替换。您可以 [提升上限]() 至 %@ 张 GIF 动态图。"; +"Premium.MaxSavedGifsTitle" = "已达 %@ 张 GIF 上限"; +"Premium.MaxSavedPinsFinalText" = "抱歉,您不能将超过 **%@** 个聊天置顶到顶部。取消置顶当前置顶的一些内容。"; +"Premium.MaxSavedPinsNoPremiumText" = "抱歉,您不能将超过 **%@** 个聊天置顶到顶部。取消置顶当前置顶的一些内容。"; +"Premium.MaxSavedPinsText" = "抱歉,你不能置顶超过 **%1$@** 个聊天到顶部。取消置顶一些当前置顶聊天的或订阅 **Telegram 高级版**,将上限增加至 **%2$@** 个聊天。"; +"Premium.MaxSharedFolderLinksFinalText" = "抱歉,你只能创建 **%1$@** 个邀请链接"; +"Premium.MaxSharedFolderLinksNoPremiumText" = "您只能创建 %1$@ 个邀请链接,此上限可能会在未来提高。"; +"Premium.MaxSharedFolderLinksText" = "您只能创建 **%1$@** 个邀请链接。升级至 **Telegram 高级版**即可创建最多 **%2$@ 个**。"; +"Premium.MaxSharedFolderMembershipFinalText" = "抱歉,您只能添加 **%1$@** 个可共享的文件夹。"; +"Premium.MaxSharedFolderMembershipNoPremiumText" = "您只能添加 **%1$@ ** 个可共享的文件夹,此上限可能会在将来提高。"; +"Premium.MaxSharedFolderMembershipText" = "你只能拥有 **%1$@** 个可分享文件夹。\n升级至 **Telegram 高级版**并将上限提升至 **%2$@ 个**。"; +"Premium.MaxStoriesMonthlyFinalText" = "您发布的动态数量已达到每月上限 **%@** 条。"; +"Premium.MaxStoriesMonthlyNoPremiumText" = "您发布的动态数量已达到每月上限 **%@** 条。"; +"Premium.MaxStoriesMonthlyText" = "您每月最多可以发送 **%@ 条** 动态。\n订阅 **Telegram 高级版** 后可将此上限提升至 **%@ 条**。"; +"Premium.MaxStoriesWeeklyFinalText" = "您发布的动态数量已达到每周上限 **%@** 条。"; +"Premium.MaxStoriesWeeklyNoPremiumText" = "您发布的动态数量已达到每周上限 **%@** 条。"; +"Premium.MaxStoriesWeeklyText" = "您每周最多可以发送 **%@ 条** 动态。\n订阅 **Telegram 高级版** 后可将此上限提升至 **%@ 条**。"; +"Premium.MessageEffects" = "消息特效"; +"Premium.MessageEffectsInfo" = "为私密消息添加超过 500 种动画特效。"; +"Premium.MessagePrivacy" = "消息隐私"; +"Premium.MessagePrivacy.Proceed" = "关于 Telegram 高级版"; +"Premium.MessagePrivacyInfo" = "限制来自陌生人的消息或对收到的消息收费。"; +"Premium.MessageTags" = "消息标签"; +"Premium.MessageTags.Proceed" = "关于 Telegram 高级版"; +"Premium.MessageTagsInfo" = "使用标签整理收藏的消息,以便快速访问。"; +"Premium.Monthly" = "按月"; +"Premium.New" = "全新"; +"Premium.NoAds" = "无广告"; +"Premium.NoAds.Proceed" = "关于 Telegram 高级版"; +"Premium.NoAdsInfo" = "Telegram 公开频道中不再有不定期展示的广告。"; +"Premium.NoAdsStandaloneInfo" = "通过订阅 **Telegram 高级版** 来移除像这样的广告。"; +"Premium.PaidMessages" = "付费消息"; +"Premium.PaidMessages.Proceed" = "关于 Telegram 高级版"; +"Premium.PaidMessagesInfo" = "对非联系人或新寄信者的消息收取费用。"; +"Premium.PersonalDescription" = "拥有 **Telegram 高级版** 帐号即可专享多项附加功能。"; +"Premium.PersonalTitle" = "[%@]() 是 Telegram 高级版的订阅者"; +"Premium.Premium" = "高级版"; +"Premium.PremiumGift.Description" = "订阅 **Telegram 高级版** 即可赠送最多 **5** 份此类礼物,并解锁强大功能。"; +"Premium.PremiumGift.Title" = "高级版礼物"; +"Premium.PricePer2Years" = "%@/两年"; +"Premium.PricePerMonth" = "%@/月"; +"Premium.PricePerYear" = "%@/年"; +"Premium.Purchase.ErrorCantMakePayments" = "此设备不允许应用内付款。"; +"Premium.Purchase.ErrorNetwork" = "请检查您的互联网连接并重试。"; +"Premium.Purchase.ErrorNotAllowed" = "此设备不允许进行付款操作。"; +"Premium.Purchase.ErrorTryLater" = "发生错误,请重试。"; +"Premium.Purchase.ErrorUnknown" = "发生了错误,请重试。"; +"Premium.Purchase.OnlyOneSubscriptionAllowed" = "您已经为另一个帐户购买了 Telegram 高级版。一个 Apple ID 只能订阅一个Telegram 高级版。"; +"Premium.Reactions" = "专属表情回应"; +"Premium.Reactions.Proceed" = "解锁高级回应"; +"Premium.ReactionsInfo" = "使用更多动画表情回应消息,仅高级版订阅者可用。"; +"Premium.ReactionsStandalone" = "额外的回应"; +"Premium.ReactionsStandaloneInfo" = "订阅 **Telegram 高级版**以解锁更多表情回应。"; +"Premium.Restore.ErrorUnknown" = "出错了,请重试。"; +"Premium.Restore.Success" = "完成"; +"Premium.Semiannual" = "半年"; +"Premium.SignUp.SignUpNewInfo" = "为 %@ 获取 Telegram 高级版"; +"Premium.SignUp.SignUpNewInfo.Days#other" = "%@ 天"; +"Premium.SignUp.SignUpNewInfoNone" = "获取 Telegram 高级版"; +"Premium.Stickers" = "高级版贴纸"; +"Premium.Stickers.Description" = "订阅 Telegram 高级版即可解锁这个和更多贴纸。"; +"Premium.Stickers.Proceed" = "解锁高级贴纸"; +"Premium.StickersInfo" = "具有额外特效的专属放大贴纸,定期更新。"; +"Premium.Stories" = "动态"; +"Premium.Stories.AdditionalTitle" = "动态"; +"Premium.Stories.Captions.Text" = "为您的动态添加 10 倍长的说明文字。"; +"Premium.Stories.Captions.Title" = "更长的说明文字"; +"Premium.Stories.Expiration.Text" = "为您的动态设置自定义过期时间,比如 6 小时或 48 小时。"; +"Premium.Stories.Expiration.Title" = "过期选项"; +"Premium.Stories.Format.Text" = "在动态说明文字中添加链接和格式。"; +"Premium.Stories.Format.Title" = "链接和格式"; +"Premium.Stories.Order.Text" = "您的动态将始终优先展示,因此可以获得更多浏览量。"; +"Premium.Stories.Order.Title" = "优先显示"; +"Premium.Stories.Quality.Text" = "以双倍分辨率查看视频动态。"; +"Premium.Stories.Quality.Title" = "更高画质"; +"Premium.Stories.Save.Text" = "保存其他用户未开启保护的动态。"; +"Premium.Stories.Save.Title" = "将动态保存到“照片”"; +"Premium.Stories.Stealth.Text" = "隐藏您曾浏览其他用户动态的痕迹。"; +"Premium.Stories.Stealth.Title" = "隐身模式"; +"Premium.Stories.Title" = "动态"; +"Premium.Stories.Views.Text" = "查看谁打开过您的动态——即使在动态过期后仍能查看。"; +"Premium.Stories.Views.Title" = "永久访客历史"; +"Premium.StoriesInfo" = "无限发布数量、优先显示、隐身模式、永久访客历史,以及更多功能。"; +"Premium.SubscribeFor" = "以每月 %@ 的价格订阅"; +"Premium.SubscribeForAnnual" = "以每年 %@ 的价格订阅"; +"Premium.SubscribeForBiannual" = "以 %@ / 两年订阅"; +"Premium.SubscribedDescription" = "欢迎订阅 **Telegram Premium**。你已成功解锁以下特权。"; +"Premium.SubscribedTitle" = "一切都已就绪!"; +"Premium.Terms" = "购买高级版订阅,即表示你同意 Telegram 的 [服务条款](terms) 和 [隐私政策](privacy)。"; +"Premium.Title" = "Telegram 高级版"; +"Premium.Todo" = "核对清单"; +"Premium.TodoInfo" = "计划,分配和完成任务是无缝且有效率的。"; +"Premium.Translation" = "即时翻译"; +"Premium.Translation.Proceed" = "关于 Telegram 高级版"; +"Premium.TranslationInfo" = "将频道和聊天内容即时翻译成其他语言。"; +"Premium.TranslationStandaloneInfo" = "订阅 **Telegram 高级版**即可一次性翻译聊天中的所有消息。"; +"Premium.UpgradeDescription" = "您可以使用**优惠价格**升级当前的 **Telegram 高级版**订阅计划。"; +"Premium.UpgradeFor" = "支付 %@ 并升级"; +"Premium.UpgradeForAnnual" = "支付每年 %@ 并升级"; +"Premium.UpgradeForBiannual" = "以 %@ 每两年升级"; +"Premium.UploadSize" = "无限云端存储空间"; +"Premium.UploadSizeInfo" = "单个文档上传限制为 4 GB ,聊天和媒体的总体存储空间无限量。"; +"Premium.VoiceToText" = "语音转文字"; +"Premium.VoiceToText.Proceed" = "关于 Telegram 高级版"; +"Premium.VoiceToTextInfo" = "能够将收到的语音消息转换成文字以方便阅读。"; +"Premium.VoiceToTextStandaloneInfo" = "订阅 **Telegram 高级版** 以将语音或视频消息转换为文字。 "; +"Premium.Wallpaper.Proceed" = "关于 Telegram 高级版"; +"Premium.Wallpapers" = "为双方设置壁纸"; +"Premium.WallpapersInfo" = "为您和聊天对象设置自定义壁纸。"; +"Premium.Week.SignUp" = "注册 %@"; +"Premium.Week.SignUpInfo" = "获取 1 周 Telegram 高级版订阅"; +"Premium.WhatsIncluded" = "包含了什么"; +"PremiumGift.LabelRecipients#other" = "%d 个收件人"; +"PremiumNeeded.Subscribe" = "订阅"; +"PremiumNeeded.Title" = "需要高级版订阅"; +"Presence.online" = "在线"; +"Preview.CopyAddress" = "拷贝地址"; +"Preview.DeleteGif" = "删除 GIF"; +"Preview.DeletePhoto" = "删除图片"; +"Preview.Gif.AddCaption" = "添加说明"; +"Preview.Gif.Edit" = "编辑"; +"Preview.OpenInInstagram" = "在 Instagram 中打开"; +"Preview.SaveGif" = "保存 GIF"; +"Preview.SaveToCameraRoll" = "保存到照片"; +"Privacy.AddNewPeer" = "添加用户或群组"; +"Privacy.Bio" = "个人简介"; +"Privacy.Bio.AlwaysShareWith.Title" = "总是分享给"; +"Privacy.Bio.CustomHelp" = "您可以精确地限制谁可以看到您个人资料页上的简介。"; +"Privacy.Bio.NeverShareWith.Title" = "永不与谁分享"; +"Privacy.Bio.WhoCanSeeMyBio" = "谁可以看到我的个人简介?"; +"Privacy.Birthday" = "出生日期"; +"Privacy.Birthday.AlwaysShareWith.Title" = "始终分享"; +"Privacy.Birthday.CustomHelp" = "您可以精确限制谁能看到您的出生日期。"; +"Privacy.Birthday.NeverShareWith.Title" = "永不分享"; +"Privacy.Birthday.Setup" = "您还未添加生日。\n[添加我的生日 >]()"; +"Privacy.Birthday.WhoCanSeeMyBio" = "谁能看到我的生日"; +"Privacy.Calls" = "通话"; +"Privacy.Calls.AlwaysAllow" = "总是允许"; +"Privacy.Calls.AlwaysAllow.Placeholder" = "总是允许…"; +"Privacy.Calls.AlwaysAllow.Title" = "总是允许"; +"Privacy.Calls.CustomHelp" = "精确限制谁能给您打电话。"; +"Privacy.Calls.CustomShareHelp" = "上面的设置项将决定这些用户是否能呼叫您。"; +"Privacy.Calls.Integration" = "iOS 通话集成"; +"Privacy.Calls.IntegrationHelp" = "iOS 通话集成能在锁屏界面上和系统的通话记录里显示 Telegram 通话的提示/信息。如果启用了 iCloud 同步,您的通话记录将被共享给 Apple。"; +"Privacy.Calls.NeverAllow" = "从不允许"; +"Privacy.Calls.NeverAllow.Placeholder" = "永不允许…"; +"Privacy.Calls.NeverAllow.Title" = "从不允许"; +"Privacy.Calls.P2P" = "端对端连接"; +"Privacy.Calls.P2PAlways" = "总是"; +"Privacy.Calls.P2PContacts" = "我的联系人"; +"Privacy.Calls.P2PHelp" = "停用端对端连接后,所有通话流量将使用 Telegram 服务器中转以避免泄露您的 IP 地址,但可能会降低音质及画质。"; +"Privacy.Calls.P2PNever" = "从不"; +"Privacy.Calls.WhoCanCallMe" = "谁可以给我打电话"; +"Privacy.ChatsTitle" = "聊天"; +"Privacy.ContactsReset" = "删除已同步的联系人"; +"Privacy.ContactsReset.ContactsDeleted" = "所有同步联系人信息已删除。"; +"Privacy.ContactsResetConfirmation" = "这将把您的联系人信息从 Telegram 服务端删除。\n如果“同步联系人”选项处于启用状态,联系人信息将被重新同步。"; +"Privacy.ContactsSync" = "同步通讯录"; +"Privacy.ContactsSyncHelp" = "开启以持续将此设备上的联系人同步到您的账号。"; +"Privacy.ContactsTitle" = "联系人"; +"Privacy.DeleteDrafts" = "删除所有的云草稿"; +"Privacy.DeleteDrafts.DraftsDeleted" = "所有云端草稿已删除。"; +"Privacy.Exceptions" = "其他原因"; +"Privacy.Exceptions.DeleteAll" = "删除所有"; +"Privacy.Exceptions.DeleteAllConfirmation" = "您确定要删除所有例外吗?"; +"Privacy.Exceptions.DeleteAllExceptions" = "删除所有例外"; +"Privacy.ExceptionsCount#other" = "%@ 条例外"; +"Privacy.Forwards" = "已转发的消息"; +"Privacy.Forwards.AlwaysAllow.Title" = "总是允许"; +"Privacy.Forwards.AlwaysLink" = "链接至您的帐号"; +"Privacy.Forwards.CustomHelp" = "当消息被转发时,你的消息无法通过链接指向你的账号。"; +"Privacy.Forwards.LinkIfAllowed" = "如果以下设置已允许将会链接至您的帐号"; +"Privacy.Forwards.NeverAllow.Title" = "从不允许"; +"Privacy.Forwards.NeverLink" = "无法链接到你的帐号"; +"Privacy.Forwards.Preview" = "示例消息"; +"Privacy.Forwards.PreviewMessageText" = "Reinhardt,我们需要找到您一些新的曲调。"; +"Privacy.Forwards.WhoCanForward" = "转发我的消息时,谁可以链接至我的账号"; +"Privacy.Gifts" = "礼物"; +"Privacy.Gifts.AcceptedTypes" = "接受的礼物类型"; +"Privacy.Gifts.AcceptedTypes.Channel" = "来自频道"; +"Privacy.Gifts.AcceptedTypes.Info" = "选择您允許他人向您赠送的礼物类型。"; +"Privacy.Gifts.AcceptedTypes.Limited" = "限量版"; +"Privacy.Gifts.AcceptedTypes.Premium" = "高级版订阅"; +"Privacy.Gifts.AcceptedTypes.Unique" = "独家"; +"Privacy.Gifts.AcceptedTypes.Unlimited" = "无限量"; +"Privacy.Gifts.AlwaysAllow.Title" = "总是允许"; +"Privacy.Gifts.CustomHelp" = "选择来自特定用户的礼物是否需要经过您的批准才能在您的个人资料页中对他人可见。"; +"Privacy.Gifts.NeverAllow.Title" = "永不允许"; +"Privacy.Gifts.PremiumToast.Action" = "打开"; +"Privacy.Gifts.PremiumToast.Text" = "订阅 **Telegram 高级版** 以使用此设置。"; +"Privacy.Gifts.ShowGiftButton" = "在聊天中显示礼物图标"; +"Privacy.Gifts.ShowGiftButtonInfo" = "在所有聊天的消息输入框中显示#礼物图标,所有参与者均可见。"; +"Privacy.Gifts.WhoCanSeeMyBio" = "自动显示礼物的来源"; +"Privacy.GroupsAndChannels" = "群组与频道"; +"Privacy.GroupsAndChannels.AlwaysAllow" = "总是允许"; +"Privacy.GroupsAndChannels.AlwaysAllow.Placeholder" = "总是允许…"; +"Privacy.GroupsAndChannels.AlwaysAllow.Title" = "总是允许"; +"Privacy.GroupsAndChannels.CustomHelp" = "您可以详细控制哪些人能够将您加入到群组和频道中。"; +"Privacy.GroupsAndChannels.CustomShareHelp" = "上面的设置项将决定这些用户是否能添加您到群组或频道。"; +"Privacy.GroupsAndChannels.InviteToChannelError" = "抱歉,根据 %@ 的隐私设置,您无法将 %@ 加入频道。"; +"Privacy.GroupsAndChannels.InviteToChannelMultipleError" = "抱歉,根据其中一些用户的隐私设置,您无法与这些人建立群组。"; +"Privacy.GroupsAndChannels.InviteToGroupError" = "抱歉,根据“%@”的隐私设置,您无法将“%@”添加到群组。"; +"Privacy.GroupsAndChannels.NeverAllow" = "从不允许"; +"Privacy.GroupsAndChannels.NeverAllow.Placeholder" = "永不允许…"; +"Privacy.GroupsAndChannels.NeverAllow.Title" = "绝不允许"; +"Privacy.GroupsAndChannels.WhoCanAddMe" = "谁可以邀请我"; +"Privacy.Messages.ChargeForMessages" = "付费消息"; +"Privacy.Messages.ChargeForMessagesInfo" = "向非联系人或从未向你发送消息的人收取消息费用。"; +"Privacy.Messages.Exceptions.Title" = "免收费用"; +"Privacy.Messages.MessagePrice" = "设置您每条消息的价格"; +"Privacy.Messages.MessagePriceInfo" = "每收到一条消息,您将获得所选费用的 %1$@(%2$@)。"; +"Privacy.Messages.PremiumInfoFooter" = "[什么是 Telegram 高级版?]()"; +"Privacy.Messages.PremiumToast.Action" = "订阅"; +"Privacy.Messages.PremiumToast.Text" = "订阅 **Telegram 高级版** 以启用此选项"; +"Privacy.Messages.PremiumToast.Title" = "需订阅 Telegram 高级版"; +"Privacy.Messages.RemoveFee" = "免收费用"; +"Privacy.Messages.RemoveFeeHeader" = "例外"; +"Privacy.Messages.RemoveFeeInfo" = "添加无需付费即可向你发送消息的用户或整个群组。"; +"Privacy.Messages.SectionFooter" = "您可以限制非联系人且您未主动联系的用户给您发送消息。"; +"Privacy.Messages.SectionTitle" = "谁可以给我发消息"; +"Privacy.Messages.Stars#other" = "%@ 颗星星"; +"Privacy.Messages.Title" = "消息"; +"Privacy.Messages.Unlock" = "通过 Telegram 高级版解锁"; +"Privacy.Messages.ValueContactsAndPremium" = "我的联系人与高级版订阅用户"; +"Privacy.Messages.ValueEveryone" = "所有人"; +"Privacy.PaymentsClear.AllInfoCleared" = "支付与配送信息已清空。"; +"Privacy.PaymentsClear.PaymentInfo" = "付款信息"; +"Privacy.PaymentsClear.PaymentInfoCleared" = "支付信息已清除。"; +"Privacy.PaymentsClear.ShippingInfo" = "配送信息"; +"Privacy.PaymentsClear.ShippingInfoCleared" = "配送信息已清空。"; +"Privacy.PaymentsClearInfo" = "清除付款和配送信息"; +"Privacy.PaymentsClearInfoDoneHelp" = "已清除付款信息"; +"Privacy.PaymentsClearInfoHelp" = "您可以删除您的配送信息并通知所有的支付服务提供商清除您已保存的信用卡信息。请注意:Telegram 从来不会存储您的信用卡数据。"; +"Privacy.PaymentsTitle" = "付款"; +"Privacy.PhoneNumber" = "手机号码"; +"Privacy.ProfilePhoto" = "头像"; +"Privacy.ProfilePhoto.AlwaysShareWith.Title" = "总是共享给"; +"Privacy.ProfilePhoto.CustomHelp" = "您可以精确地限制某些用户查看您的头像。"; +"Privacy.ProfilePhoto.CustomOverrideAddInfo" = "添加仍可查看你的个人资料照片的用户或整个群组。"; +"Privacy.ProfilePhoto.CustomOverrideBothInfo" = "你可以将用户或整个群组添加为例外,并覆盖上述设置。"; +"Privacy.ProfilePhoto.CustomOverrideInfo" = "你可以添加无权查看你的个人资料图片的用户或整个群组。"; +"Privacy.ProfilePhoto.NeverShareWith.Title" = "从不分享给"; +"Privacy.ProfilePhoto.PublicPhotoInfo" = "您可以为被限制看到真实个人资料照片的用户上传公开照片。"; +"Privacy.ProfilePhoto.PublicPhotoSuccess" = "这张照片将显示给被限制查看您真实照片的用户。"; +"Privacy.ProfilePhoto.PublicVideoSuccess" = "该图片将显示给无权查看您的真实照片的用户。"; +"Privacy.ProfilePhoto.RemovePublicPhoto" = "移除公共照片"; +"Privacy.ProfilePhoto.RemovePublicVideo" = "删除公开图片"; +"Privacy.ProfilePhoto.SetPublicPhoto" = "设置公共照片"; +"Privacy.ProfilePhoto.UpdatePublicPhoto" = "更新公共照片"; +"Privacy.ProfilePhoto.WhoCanSeeMyPhoto" = "谁能看到我的头像照片"; +"Privacy.Review.Calls.Text" = "你已限制谁可以向你发送消息,但是任何人仍然可以打电话给你。你想查看这些设置吗?"; +"Privacy.Review.Calls.Title" = "通话设置"; +"Privacy.Review.Invite.Text" = "你已限制谁可以向你发送消息,但是任何人仍然可以邀请你加入群组或频道。你想查看这些设置吗?"; +"Privacy.Review.Invite.Title" = "邀请设置"; +"Privacy.Review.Review" = "查看"; +"Privacy.SavedMusic" = "保存的音乐"; +"Privacy.SavedMusic.AlwaysShareWith.Title" = "总是分享给"; +"Privacy.SavedMusic.CustomHelp" = "您可以精确地限制谁可以看到您保存的音乐。"; +"Privacy.SavedMusic.NeverShareWith.Title" = "从不分享给"; +"Privacy.SavedMusic.WhoCanSeeMyMusic" = "谁可以看到我保存的音乐"; +"Privacy.SecretChatsLinkPreviews" = "链接预览"; +"Privacy.SecretChatsLinkPreviewsHelp" = "链接预览将在 Telegram 服务器上生成,我们不会储存与您所发送的链接有关的任何数据。"; +"Privacy.SecretChatsTitle" = "私密聊天"; +"Privacy.TopPeers" = "推荐常用联系人"; +"Privacy.TopPeersDelete" = "删除"; +"Privacy.TopPeersHelp" = "在搜索区域顶部显示常用联系人以便您快速访问。"; +"Privacy.TopPeersWarning" = "这将删除所有您常联系用户与常用消息框内机器人的数据。"; +"Privacy.VoiceMessages" = "语音消息"; +"Privacy.VoiceMessages.AlwaysAllow.Title" = "总是允许"; +"Privacy.VoiceMessages.CustomHelp" = "您可以精确地控制谁可以向您发送语音或视频消息。"; +"Privacy.VoiceMessages.CustomShareHelp" = "无论上述设置如何,这些用户都将可以或无法给您发送语音消息。"; +"Privacy.VoiceMessages.NeverAllow.Title" = "从不允许"; +"Privacy.VoiceMessages.NonPremiumHelp" = "订阅 Telegram 高级版以限制向您可以发送语音或视频消息的用户。\n\n[Telegram 高级版是什么?]()"; +"Privacy.VoiceMessages.Tooltip" = "只有 [Telegram 高级版]() 订阅者才可以限制接受语音消息。"; +"Privacy.VoiceMessages.WhoCanSend" = "谁可以给我发送语音或视频消息?"; +"PrivacyInfo.ShowLastSeen.ButtonTitle" = "显示我的最后上线的时间"; +"PrivacyInfo.ShowLastSeen.PremiumInfo" = "订阅后您将可以看到 **%@** 的最后上线时间,而无需显示您的最后上线时间。"; +"PrivacyInfo.ShowLastSeen.Text" = "若要查看 **%@** 的最后上线时间,您需显示您自己的最后上线时间..."; +"PrivacyInfo.ShowLastSeen.Title" = "显示您的最后上线时间"; +"PrivacyInfo.ShowReadTime.AlwaysToast.Text" = "将 **最后上线时间** 隐私设置为“不允许任何人”或“我的联系人”。"; +"PrivacyInfo.ShowReadTime.ButtonTitle" = "显示我的已读时间"; +"PrivacyInfo.ShowReadTime.PremiumInfo" = "订阅后您将可以看到 **%@** 的已读时间,而无需显示您的已读时间。"; +"PrivacyInfo.ShowReadTime.Text" = "若要查看 **%@** 的已读时间,您需显示您自己的已读时间..."; +"PrivacyInfo.ShowReadTime.Title" = "显示您的已读时间"; +"PrivacyInfo.UpgradeToPremium.ButtonTitle" = "订阅 Telegram 高级版"; +"PrivacyInfo.UpgradeToPremium.Title" = "升级为高级版"; +"PrivacyLastSeenSettings.AddUsers#other" = "添加 %@ 位用户到此列表?"; +"PrivacyLastSeenSettings.AlwaysShareWith" = "总是共享给"; +"PrivacyLastSeenSettings.AlwaysShareWith.Placeholder" = "选择可以看见您上线时间的用户…"; +"PrivacyLastSeenSettings.AlwaysShareWith.Title" = "总是共享"; +"PrivacyLastSeenSettings.CustomHelp" = "除非您是高级版订阅用户,对于您不愿意分享最后上线时间的对象,他们最后的上线时间您将也无法看到。取而代之的是大致的上线时间(如:最近、一周内、一个月内)。"; +"PrivacyLastSeenSettings.CustomShareSettings.Delete" = "删除"; +"PrivacyLastSeenSettings.CustomShareSettingsHelp" = "您可以将用户或整个群组设为例外,不受以上设置的限制。"; +"PrivacyLastSeenSettings.EmpryUsersPlaceholder" = "添加用户"; +"PrivacyLastSeenSettings.GroupsAndChannelsHelp" = "上面的选项可以决定谁能将您添加到群组/频道。"; +"PrivacyLastSeenSettings.NeverShareWith" = "从不分享给"; +"PrivacyLastSeenSettings.NeverShareWith.Placeholder" = "选择无法看见您上线时间的用户…"; +"PrivacyLastSeenSettings.NeverShareWith.Title" = "从不分享"; +"PrivacyLastSeenSettings.Title" = "最后上线时间和在线状态"; +"PrivacyLastSeenSettings.WhoCanSeeMyTimestamp" = "谁可以看到我最后上线的时间"; +"PrivacyPhoneNumberSettings.CustomDisabledHelp" = "对于将您的号码加入联系人列表的用户,只有当您将其添加为联系人后,对方才会看到您的号码。"; +"PrivacyPhoneNumberSettings.CustomHelp" = "已将您的手机号码保存到联系人的用户也将在 Telegram 上看到它。"; +"PrivacyPhoneNumberSettings.CustomPublicLink" = "将您的手机号码保存为联系人的用户也将在 Telegram 上看到您的手机号码。\n\n 此公开链接将打开与您的聊天:\n [https://t.me/%@]()"; +"PrivacyPhoneNumberSettings.DiscoveryHeader" = "谁可以通过手机号码找到我"; +"PrivacyPhoneNumberSettings.WhoCanSeeMyPhoneNumber" = "谁可以看到我的手机号码"; +"PrivacyPolicy.Accept" = "接受并继续"; +"PrivacyPolicy.AgeVerificationAgree" = "同意"; +"PrivacyPolicy.AgeVerificationMessage" = "点击“同意”确认您已满 %@ 周岁。"; +"PrivacyPolicy.AgeVerificationTitle" = "年龄验证"; +"PrivacyPolicy.Decline" = "拒绝"; +"PrivacyPolicy.DeclineDeclineAndDelete" = "拒绝并删除帐号"; +"PrivacyPolicy.DeclineDeleteNow" = "立即删除"; +"PrivacyPolicy.DeclineLastWarning" = "警告,这将不可逆转地删除您的 Telegram 帐号以及所有储存在 Telegram 云服务器中的数据。\n\n注意:为保留个人数据,您可以先行取消,在导出数据并保存后,再删除您的帐号。(若要导出个人数据,请打开 Telegram 最新桌面版,并进入设置 > 高级 > 导出 Telegram 数据 。)"; +"PrivacyPolicy.DeclineMessage" = "很遗憾,目前没有其他的选择。与其它应用不同,我们不会利用您的个人数据来进行广告定位或其它商业目的。Telegram 储存的所有信息都是为了给您创造一个功能丰富的云服务体验。您可以在「隐私和安全」设置里调整我们使用您数据的方式(例如:删除已同步的联系人)。\n\n如果您不满意 Telegram 的数据收集政策,我们将无法向您提供该服务。"; +"PrivacyPolicy.DeclineTitle" = "拒绝"; +"PrivacyPolicy.Title" = "隐私政策和服务条款"; +"PrivacySettings.AuthSessions" = "活跃会话"; +"PrivacySettings.AutoArchive" = "归档并静音"; +"PrivacySettings.AutoArchiveInfo" = "自动归档及静音来自非联系人的新聊天、群组和频道。"; +"PrivacySettings.AutoArchiveTitle" = "来自未知用户的新聊天"; +"PrivacySettings.BlockedPeersEmpty" = "无"; +"PrivacySettings.CategoryBots" = "小程序"; +"PrivacySettings.CategoryPremiumUsers" = "高级版用户"; +"PrivacySettings.DataSettings" = "数据设置"; +"PrivacySettings.DataSettingsHelp" = "控制您存储在 Telegram 云端的数据,这些数据能让 Telegram 提供更好的服务。"; +"PrivacySettings.DeleteAccountHelp" = "如果您在此期间内您的帐号未曾上线,您帐号的所有资料——包括消息记录、联系人都会被删除。"; +"PrivacySettings.DeleteAccountIfAwayFor" = "若离线达"; +"PrivacySettings.DeleteAccountNow" = "立即删除账户"; +"PrivacySettings.DeleteAccountTitle" = "自动删除我的帐号"; +"PrivacySettings.InviteItem" = "邀请"; +"PrivacySettings.InviteSectionFooter" = "您可以限制哪些用户能将您添加到群组和频道。"; +"PrivacySettings.LastSeen" = "最后上线时间和在线状态"; +"PrivacySettings.LastSeenCloseFriends" = "密友"; +"PrivacySettings.LastSeenCloseFriendsPlus" = "密友(+%@)"; +"PrivacySettings.LastSeenContacts" = "我的联系人"; +"PrivacySettings.LastSeenContactsMinus" = "我的联系人 (-%@)"; +"PrivacySettings.LastSeenContactsMinusPlus" = "我的联系人 (-%@, +%@)"; +"PrivacySettings.LastSeenContactsPlus" = "我的联系人 (+%@)"; +"PrivacySettings.LastSeenEverybody" = "所有人"; +"PrivacySettings.LastSeenEverybodyMinus" = "所有人 (-%@)"; +"PrivacySettings.LastSeenNobody" = "不允许任何人"; +"PrivacySettings.LastSeenNobodyPlus" = "不允许任何人 (+%@)"; +"PrivacySettings.LastSeenTitle" = "最近上线"; +"PrivacySettings.LoginEmail" = "邮箱登录"; +"PrivacySettings.LoginEmailAlertChange" = "更改邮箱"; +"PrivacySettings.LoginEmailAlertText" = "你每次从新的设备登入Telegram 时都会使用该电子邮箱。"; +"PrivacySettings.LoginEmailInfo" = "为获取 Telegram 登录验证码,请修改你的邮箱地址。"; +"PrivacySettings.LoginEmailSetupInfo" = "设置您的电子邮件地址以接收 Telegram 登录验证码。"; +"PrivacySettings.Passcode" = "密码锁"; +"PrivacySettings.PasscodeAndFaceId" = "锁定码 & Face ID"; +"PrivacySettings.PasscodeAndTouchId" = "锁定码 & Touch ID"; +"PrivacySettings.PasscodeOff" = "关闭"; +"PrivacySettings.PasscodeOn" = "开启"; +"PrivacySettings.Passkey" = "通行密钥\n"; +"PrivacySettings.PhoneNumber" = "手机号码"; +"PrivacySettings.PrivacyTitle" = "隐私"; +"PrivacySettings.SearchUserTypesHeader" = "用户类型"; +"PrivacySettings.SearchUsersPlaceholder" = "搜索用户和群组"; +"PrivacySettings.SearchUsersTitle" = "添加用户"; +"PrivacySettings.SecurityTitle" = "安全"; +"PrivacySettings.Title" = "隐私和安全"; +"PrivacySettings.TwoStepAuth" = "两步验证"; +"PrivacySettings.ValueBots" = "小程序"; +"PrivacySettings.ValueBotsPlus" = "小程序 +%@"; +"PrivacySettings.ValueCloseFriendsAndPremium" = "密友和高级版用户"; +"PrivacySettings.ValueCloseFriendsAndPremiumPlus" = "密友、高级版用户 和 %@ 位"; +"PrivacySettings.ValuePremium" = "高级版用户"; +"PrivacySettings.ValuePremiumAndContacts" = "联系人 & 高级版"; +"PrivacySettings.ValuePremiumPlus" = "高级版用户和 %@ 个用户"; +"PrivacySettings.WebSessions" = "活跃网站"; +"PrivateDataSettings.Title" = "数据设置"; +"Profile.About" = "个人简介"; +"Profile.AddToExisting" = "添加到现有的联系人信息"; +"Profile.AdditionalUsernames" = "同时 %@"; +"Profile.AvatarStoryCount#other" = "%d 条动态"; +"Profile.BotInfo" = "关于"; +"Profile.CreateEncryptedChatError" = "发生错误。"; +"Profile.CreateNewContact" = "新建联系人"; +"Profile.EncryptionKey" = "加密密钥"; +"Profile.MessageLifetime1d" = "1 天"; +"Profile.MessageLifetime1h" = "1 小时"; +"Profile.MessageLifetime1m" = "1 分钟"; +"Profile.MessageLifetime1w" = "1 周"; +"Profile.MessageLifetime2s" = "2 秒"; +"Profile.MessageLifetime5s" = "5 秒"; +"Profile.MessageLifetimeForever" = "关闭"; +"Profile.PersonalChannelSectionTitle" = "频道"; +"Profile.SendGift" = "发送礼物"; +"Profile.ShareContactButton" = "分享联系人信息"; +"Profile.Username" = "用户名"; +"ProfileColorSetup.AccountColorInfoLabel" = "为您的个人简介页面选择颜色"; +"ProfileColorSetup.ApplyStyle" = "应用风格"; +"ProfileColorSetup.BrowseGiftsForPurchase" = "浏览可供购买的礼物 >"; +"ProfileColorSetup.ChannelColorInfoLabel" = "为频道资料页选择颜色"; +"ProfileColorSetup.IconSectionTitle" = "在个人资料页添加图标"; +"ProfileColorSetup.MyGifts" = "我的礼物"; +"ProfileColorSetup.NoNameGiftsPlaceholder" = "您没有任何可以用于您名称样式的礼物。"; +"ProfileColorSetup.NoProfileGiftsPlaceholder" = "您没有任何可以用于个人资料封面的礼物。"; +"ProfileColorSetup.ProfileColorPreviewInfo" = "您可以更改您名字的颜色并自定义您的回复。[更改 >]()"; +"ProfileColorSetup.ResetAction" = "重置个人资料页颜色"; +"ProfileColorSetup.TitleChannelColor" = "设置频道颜色"; +"ProfileColorSetup.TitleName" = "名称"; +"ProfileColorSetup.TitleProfile" = "个人简介"; +"ProfileColorSetup.ToastAccountColorUpdated" = "已更新您的颜色。"; +"ProfileLevelInfo.CloseButton" = "了解"; +"ProfileLevelInfo.FutureRatingTitle" = "未来评级"; +"ProfileLevelInfo.Item0.Badge" = "已享有"; +"ProfileLevelInfo.Item0.Text" = "100% 的星星用于从 Telegram 购买礼物。"; +"ProfileLevelInfo.Item0.Title" = "来自 Telegram 的礼物"; +"ProfileLevelInfo.Item1.Badge" = "已享有"; +"ProfileLevelInfo.Item1.Text" = "20% 的星星用于转售礼物、付费消息和频道帖子。"; +"ProfileLevelInfo.Item1.Title" = "来自用户的礼物和帖子"; +"ProfileLevelInfo.Item2.Badge" = "已扣除"; +"ProfileLevelInfo.Item2.Text" = "10 倍的退款星星和 85% 礼物转换为星星的比率。"; +"ProfileLevelInfo.Item2.Title" = "退款与转换"; +"ProfileLevelInfo.LevelIndex#other" = "等级 %@"; +"ProfileLevelInfo.MyDescriptionDays#other" = "%@ 天"; +"ProfileLevelInfo.MyDescriptionInPreview" = "一旦 %2$@ 加入,\n评级将在 %1$@ 天后更新。[返回 >]()"; +"ProfileLevelInfo.MyDescriptionInPreviewDays#other" = "%@ 天\n"; +"ProfileLevelInfo.MyDescriptionInPreviewPoints#other" = "%@ 点数为"; +"ProfileLevelInfo.MyDescriptionInPreviewToday#other" = "添加 %@ 后,\n这是您今天的评级。[返回 >]()"; +"ProfileLevelInfo.MyDescriptionPoints#other" = "%@ 点数为"; +"ProfileLevelInfo.MyDescriptionPreview" = "评级将在 %@ 后更新。\n%@ 待定。[预览 >]()"; +"ProfileLevelInfo.MyDescriptionToday#other" = "评级将在今天更新。\n%@ 分待定。"; +"ProfileLevelInfo.MyText" = "此评级反映了您在 Telegram 上的活跃度。\n评级基于以下因素:"; +"ProfileLevelInfo.NegativeRating" = "负面评级"; +"ProfileLevelInfo.OtherDescription" = "此评级反映了 **%@** 在 Telegram 上的活跃度。\n评级基于以下因素:"; +"ProfileLevelInfo.RatingTitle" = "评级"; +"ProfileLevelInfo.Title" = "评级"; +"ProfilePhoto.MainPhoto" = "主头像"; +"ProfilePhoto.MainVideo" = "主头像"; +"ProfilePhoto.OpenGallery" = "打开相册"; +"ProfilePhoto.OpenInEditor" = "在编辑器中打开"; +"ProfilePhoto.PublicPhoto" = "公开图片"; +"ProfilePhoto.PublicVideo" = "公开图片"; +"ProfilePhoto.SearchWeb" = "网络搜索"; +"ProfilePhoto.SetEmoji" = "设置表情"; +"ProfilePhoto.SetMainPhoto" = "设为主要照片"; +"ProfilePhoto.SetMainVideo" = "设为主头像"; +"Proxy.TooltipUnavailable" = "此代理可能无法使用,请尝试其他代理。"; +"ProxyServer.VoiceOver.Active" = "激活"; +"QuickReplies.EmptyState.AddButton" = "添加快速回复"; +"QuickReplies.EmptyState.Text" = "设置包含富文本和媒体的快捷方式,更快回复消息。"; +"QuickReplies.EmptyState.Title" = "无快速回复"; +"QuickReply.ChatRemoveAwayMessage.Text" = "您没有创建离开信息。退出即可删除。"; +"QuickReply.ChatRemoveAwayMessage.Title" = "删除离开消息"; +"QuickReply.ChatRemoveGeneric.DeleteAction" = "删除"; +"QuickReply.ChatRemoveGeneric.Text" = "你未添加快速回复消息。退出将会清除该指令。"; +"QuickReply.ChatRemoveGeneric.Title" = "移除快捷键"; +"QuickReply.ChatRemoveGreetingMessage.Text" = "您没有创建问候消息。退出将删除它。"; +"QuickReply.ChatRemoveGreetingMessage.Title" = "删除问候语"; +"QuickReply.CreateShortcutText" = "为您快速回复添加快捷方式"; +"QuickReply.CreateShortcutTitle" = "新增快速回复"; +"QuickReply.DeleteAction#other" = "删除 %d 个快速回复"; +"QuickReply.DeleteConfirmationMultiple" = "删除快速回复"; +"QuickReply.DeleteConfirmationSingle" = "删除快速回复"; +"QuickReply.EditShortcutText" = "编辑此指令的名称"; +"QuickReply.EditShortcutTitle" = "编辑快捷方式"; +"QuickReply.InlineCreateAction" = "新增快速回复"; +"QuickReply.SelectedTitle#other" = "已选择 %d 项"; +"QuickReply.ShortcutExistsInlineError" = "该指令已存在"; +"QuickReply.ShortcutPlaceholder" = "指令"; +"QuickReply.Title" = "快速回复"; +"QuickReply.TitleAwayMessage" = "离开消息"; +"QuickReply.TitleGreetingMessage" = "问候消息"; +"QuickSend.Photos#other" = "发送 %@ 张图片"; +"REACT_GIVEAWAY" = "%1$@ 已对您的抽奖活动作出 %2$@ 回应"; +"RankInfo.Admin.Text" = "此绿色标签 %1$@ 是 **%2$@** 在 **%4$@** 中的管理员标签。"; +"RankInfo.Admin.Title" = "管理员标签"; +"RankInfo.AdminTag" = "管理员标签"; +"RankInfo.ChangeInfo" = "仅管理员可在此群组设置成员标签。"; +"RankInfo.Member.Text" = "此灰色标签 %1$@ 是 **%2$@** 在 **%4$@** 中的成员标签。"; +"RankInfo.Member.Title" = "成员标签"; +"RankInfo.MemberTag" = "成员标签"; +"RankInfo.Owner.Text" = "此紫色标签 %1$@ 是 **%2$@** 在 **%4$@** 中的所有者标签。"; +"RankInfo.Owner.Title" = "所有者标签"; +"RankInfo.OwnerTag" = "所有者标签"; +"RankInfo.SetMyTag" = "设置我的标签"; +"ReassignBoost.AvailableIn" = "将于 %@ 可用"; +"ReassignBoost.Boosts#other" = "已重新分配 %@ 次助推"; +"ReassignBoost.Description" = "要助推 **%1$@**,请重新分配之前助推或将 **Telegram 高级版** 赠予給朋友,以获得 **%2$@** 次额外的助推。"; +"ReassignBoost.DescriptionWithLink" = "要助推 **%1$@**,请将先前的助推或[赠送 Telegram 高级版]()给朋友以获得 **%2$@** 次额外助推。"; +"ReassignBoost.ExpiresOn" = "助推将于 %@ 到期"; +"ReassignBoost.OtherChannels#other" = "%@ 个其他聊天"; +"ReassignBoost.OtherGroups#other" = "其它 %@ 个群聊"; +"ReassignBoost.OtherGroupsAndChannels#other" = "其它 %@ 个群聊和频道"; +"ReassignBoost.ReassignBoosts" = "重新分配助推"; +"ReassignBoost.Success" = "来自 %2$@ 的 %1$@ 次助推"; +"ReassignBoost.Title" = "重新分配助推"; +"ReassignBoost.WaitForCooldown" = "等待助推可用,或通过赠送 **Telegram 高级版** 订阅来获得 **%1$@** 次额外的助推。"; +"RecentSession.ConnectedBot.ConnectedFrom" = "连接自"; +"RecentSession.ConnectedBot.Date" = "日期"; +"RecentSession.ConnectedBot.Device" = "设备"; +"RecentSession.ConnectedBot.Subtitle" = "自动聊天机器人"; +"RecentSessions.ConnectedBot.ConnectedTime" = "已连接 %@"; +"RecentSessions.ConnectedBot.TerminateCheckbox" = "同时终止 %@"; +"Reminders.DeleteRepeatingActionMultiple" = "删除所有未来提醒"; +"Reminders.DeleteRepeatingActionSingle" = "仅删除此提醒"; +"Reminders.DeleteRepeatingText" = "您确定要删除此提醒嗎?这是一个重复提醒。"; +"Reminders.DeleteRepeatingTitle" = "删除重复提醒"; +"Replies.BlockAndDeleteRepliesActionTitle" = "屏蔽并删除回复"; +"RepliesChat.DescriptionText" = "此聊天将帮助您在群组中跟踪对您的评论。"; +"Report.AdditionalDetailsPlaceholder" = "更多细节……"; +"Report.AdditionalDetailsText" = "请输入与您的举报有关的任何其他细节。"; +"Report.Comment.Info" = "请告诉我们您举报的消息有什么问题?"; +"Report.Comment.Placeholder" = "添加评论"; +"Report.Comment.Placeholder.Optional" = "添加评论 (可选)"; +"Report.Report" = "举报"; +"Report.Send" = "发送举报"; +"Report.Succeed" = "Telegram 管理员将复核您的举报。谢谢!"; +"Report.Title.Bot" = "举报机器人"; +"Report.Title.Channel" = "举报频道"; +"Report.Title.Group" = "举报群组"; +"Report.Title.Message" = "举报消息"; +"Report.Title.Story" = "举报动态"; +"Report.Title.User" = "举报用户"; +"ReportAd.Help" = "详细了解我们的 [广告政策和指南]()。"; +"ReportAd.Help_URL" = "https://ads.telegram.org/guidelines"; +"ReportAd.Hidden" = "广告已隐藏。"; +"ReportAd.Reported" = "我们将审核此广告,以保证其符合 [广告政策与准则]()。"; +"ReportAd.Title" = "举报广告"; +"ReportGroupLocation.Report" = "举报"; +"ReportGroupLocation.Text" = "如果该群组与位置无关请向 Telegram 官方报告。"; +"ReportGroupLocation.Title" = "举报与位置无关的群组"; +"ReportPeer.AlertSuccess" = "非常感谢!\n我们的团队将会核查您的举报。"; +"ReportPeer.BanAndReport" = "封闭并举报"; +"ReportPeer.ReasonChildAbuse" = "虐待儿童"; +"ReportPeer.ReasonCopyright" = "版权"; +"ReportPeer.ReasonFake" = "虚假账号"; +"ReportPeer.ReasonIllegalDrugs" = "违禁药品"; +"ReportPeer.ReasonOther" = "其它"; +"ReportPeer.ReasonOther.Placeholder" = "描述"; +"ReportPeer.ReasonOther.Send" = "发送"; +"ReportPeer.ReasonOther.Title" = "举报"; +"ReportPeer.ReasonPersonalDetails" = "个人信息"; +"ReportPeer.ReasonPornography" = "色情"; +"ReportPeer.ReasonSpam" = "垃圾信息"; +"ReportPeer.ReasonViolence" = "暴力"; +"ReportPeer.Report" = "举报"; +"ReportPeer.ReportReaction" = "举报表情回应"; +"ReportPeer.ReportReaction.BanAndReport" = "封禁并举报"; +"ReportPeer.ReportReaction.Report" = "举报表态"; +"ReportPeer.ReportReaction.Text" = "您确定要举报这个用户的表态吗?"; +"ReportSpam.DeleteThisChat" = "删除该次聊天"; +"RequestPeer.BotsAllEmpty" = "您还没有机器人账号。"; +"RequestPeer.ChannelsAllEmpty" = "您还没有频道。"; +"RequestPeer.ChannelsEmpty" = "您没有满足以下要求的频道:"; +"RequestPeer.ChooseBotTitle" = "选择机器人"; +"RequestPeer.ChooseChannelTitle" = "选择频道"; +"RequestPeer.ChooseGroupTitle" = "选择群组"; +"RequestPeer.ChooseUserTitle" = "选择用户"; +"RequestPeer.CreateNewChannel" = "为此创建新频道"; +"RequestPeer.CreateNewGroup" = "为此创建一个新群组"; +"RequestPeer.GroupsAllEmpty" = "您还没有群组。"; +"RequestPeer.GroupsEmpty" = "您没有满足以下要求的群组:"; +"RequestPeer.ReachedMaximum#other" = "您最多可以选择 %@ 位用户。"; +"RequestPeer.Requirement.Channel.CreatorOn" = "你必须是频道的所有者。"; +"RequestPeer.Requirement.Channel.HasUsernameOff" = "频道必须为私密状态。"; +"RequestPeer.Requirement.Channel.HasUsernameOn" = "频道必须为公开状态。"; +"RequestPeer.Requirement.Channel.Rights" = "您必须拥有管理员权限才能 %@。"; +"RequestPeer.Requirement.Channel.Rights.AddAdmins" = "添加管理员"; +"RequestPeer.Requirement.Channel.Rights.Anonymous" = "保持匿名"; +"RequestPeer.Requirement.Channel.Rights.Delete" = "删除消息"; +"RequestPeer.Requirement.Channel.Rights.Edit" = "编辑消息"; +"RequestPeer.Requirement.Channel.Rights.Info" = "更改频道信息"; +"RequestPeer.Requirement.Channel.Rights.Invite" = "通过链接邀请用户"; +"RequestPeer.Requirement.Channel.Rights.Pin" = "置顶消息"; +"RequestPeer.Requirement.Channel.Rights.Send" = "发布消息"; +"RequestPeer.Requirement.Channel.Rights.Topics" = "管理话题"; +"RequestPeer.Requirement.Channel.Rights.VideoChats" = "管理直播"; +"RequestPeer.Requirement.Group.CreatorOn" = "您必须是群组的所有者。"; +"RequestPeer.Requirement.Group.ForumOff" = "群组必须关闭话题功能。"; +"RequestPeer.Requirement.Group.ForumOn" = "群组必须开启话题功能。"; +"RequestPeer.Requirement.Group.HasUsernameOff" = "必须是私密群组。"; +"RequestPeer.Requirement.Group.HasUsernameOn" = "该群组必须是公开的。"; +"RequestPeer.Requirement.Group.ParticipantOn" = "群组必须有一个机器人。"; +"RequestPeer.Requirement.Group.Rights" = "您必须拥有管理员权限才能 %@。"; +"RequestPeer.Requirement.Group.Rights.AddAdmins" = "添加管理员"; +"RequestPeer.Requirement.Group.Rights.Anonymous" = "保持匿名"; +"RequestPeer.Requirement.Group.Rights.Ban" = "封禁用户"; +"RequestPeer.Requirement.Group.Rights.Delete" = "删除消息"; +"RequestPeer.Requirement.Group.Rights.Edit" = "编辑消息"; +"RequestPeer.Requirement.Group.Rights.Info" = "更改群组信息"; +"RequestPeer.Requirement.Group.Rights.Invite" = "添加成员"; +"RequestPeer.Requirement.Group.Rights.Pin" = "置顶消息"; +"RequestPeer.Requirement.Group.Rights.Send" = "发布消息"; +"RequestPeer.Requirement.Group.Rights.Topics" = "管理话题"; +"RequestPeer.Requirement.Group.Rights.VideoChats" = "管理视频聊天"; +"RequestPeer.Requirement.UserPremiumOff" = "用户不能拥有 Telegram 高级版订阅。"; +"RequestPeer.Requirement.UserPremiumOn" = "用户必须拥有 Telegram 高级版订阅。"; +"RequestPeer.Requirements" = "要求"; +"RequestPeer.SelectUsers" = "选择用户"; +"RequestPeer.SelectUsers.SearchPlaceholder" = "搜索"; +"RequestPeer.SelectionConfirmationInviteAdminText" = "这将会同时将 %1$@ 作为管理员加入 %2$@。"; +"RequestPeer.SelectionConfirmationInviteText" = "这将会把 %1$@ 添加到 %2$@。"; +"RequestPeer.SelectionConfirmationInviteWithRightsText" = "这将会同时将 %1$@ 加入 %2$@,并具有以下权限:\n\n%3$@"; +"RequestPeer.SelectionConfirmationSend" = "发送"; +"RequestPeer.SelectionConfirmationTitle" = "您确定要将 %1$@ 发送至 %2$@ 吗?"; +"RequestPeer.UsersAllEmpty" = "您不认识任何用户。"; +"RequestPeer.UsersEmpty" = "您不认识满足以下要求的用户:"; +"Resolve.ChannelErrorNotFound" = "抱歉,此聊天似乎不存在。"; +"Resolve.ErrorNotFound" = "抱歉,此用户似乎不存在。"; +"Resolve.GiftErrorBurned" = "抱歉,此礼物已销毁。"; +"Resolve.GiftErrorNotFound" = "抱歉,此礼物似乎不存在。"; +"RichTextPreview.Formula" = "[formula]"; +"RichTextPreview.Music" = "音乐"; +"RichTextPreview.Table" = "[table]"; +"SaveIncomingPhotosSettings.From" = "保存如下来源的图片"; +"SaveIncomingPhotosSettings.Title" = "自动保存图片"; +"SavedMessages.OpenChannel" = "打开频道"; +"SavedMessages.OpenChat" = "打开聊天"; +"SavedMessages.OpenGroup" = "打开群组"; +"SavedMessages.SubChatDeleted" = "收藏的邮件已删除。"; +"ScheduleLiveStream.ChannelText" = "频道订阅者将收到通知,直播将在 %@ 后开始。"; +"ScheduleLiveStream.Title" = "预约直播"; +"ScheduleMessage.PremiumRequired.Add" = "添加"; +"ScheduleMessage.PremiumRequired.Text" = "订阅 **Telegram 高级版** 以设置定时发送消息。"; +"ScheduleMessage.PremiumRequired.Title" = "需要订阅 Premium"; +"ScheduleMessage.Repeat" = "重复"; +"ScheduleMessage.RepeatPeriod.3Months" = "每三个月"; +"ScheduleMessage.RepeatPeriod.6Months" = "每六个月"; +"ScheduleMessage.RepeatPeriod.Biweekly" = "每两周"; +"ScheduleMessage.RepeatPeriod.Daily" = "每天"; +"ScheduleMessage.RepeatPeriod.Monthly" = "每月"; +"ScheduleMessage.RepeatPeriod.Never" = "从不"; +"ScheduleMessage.RepeatPeriod.Weekly" = "每周"; +"ScheduleMessage.RepeatPeriod.Yearly" = "每年"; +"ScheduleMessage.SilentPosting.ChannelDisabled" = "订阅者将收到通知"; +"ScheduleMessage.SilentPosting.ChannelEnabled" = "订阅者将收到无声通知"; +"ScheduleMessage.SilentPosting.GroupDisabled" = "群组成员将收到通知"; +"ScheduleMessage.SilentPosting.GroupEnabled" = "群组成员将收到无声通知"; +"ScheduleMessage.SilentPosting.UserDisabled" = "%@ 将收到通知"; +"ScheduleMessage.SilentPosting.UserEnabled" = "%@ 将收到无声通知"; +"ScheduleMessage.SilentPosting.YouDisabled" = "您将收到通知"; +"ScheduleMessage.SilentPosting.YouEnabled" = "您将收到无声通知"; +"ScheduleMessage.Time" = "时间\n"; +"ScheduleVoiceChat.ChannelText" = "频道订阅者将收到通知,直播将在 %@ 后开始。"; +"ScheduleVoiceChat.GroupText" = "群组成员将收到通知,视频聊天将在 %@ 开始。"; +"ScheduleVoiceChat.ScheduleOn" = "开始于 %@ %@"; +"ScheduleVoiceChat.ScheduleToday" = "今天 %@ 开始"; +"ScheduleVoiceChat.ScheduleTomorrow" = "明天 %@ 开始"; +"ScheduleVoiceChat.Title" = "预约视频聊天"; +"ScheduleVoiceChatChannel.Title" = "预约视频聊天"; +"ScheduledIn.Days#other" = "%@ 天"; +"ScheduledIn.Hours#other" = "%@ 小时"; +"ScheduledIn.Minutes#other" = "%@ 分钟"; +"ScheduledIn.Months#other" = "%@ 个月"; +"ScheduledIn.Seconds#other" = "%@ 秒"; +"ScheduledIn.Weeks#other" = "%@ 周"; +"ScheduledIn.Years#other" = "%@ 年"; +"ScheduledMessages.BotActionUnavailable" = "消息发布后才能执行此操作。"; +"ScheduledMessages.ClearAll" = "取消所有"; +"ScheduledMessages.ClearAllConfirmation" = "清除定时消息"; +"ScheduledMessages.Delete" = "删除定时消息"; +"ScheduledMessages.DeleteMany" = "删除定时消息"; +"ScheduledMessages.DeleteRepeatingActionMultiple" = "删除所有后续消息"; +"ScheduledMessages.DeleteRepeatingActionSingle" = "仅删除此消息"; +"ScheduledMessages.DeleteRepeatingText" = "你确定要删除这条定时消息吗?这是一条重复发送的消息。"; +"ScheduledMessages.DeleteRepeatingTitle" = "删除重复的消息"; +"ScheduledMessages.EditTime" = "重设发送时间"; +"ScheduledMessages.EmptyPlaceholder" = "暂无定时消息…"; +"ScheduledMessages.PollUnavailable" = "消息发布后才能进行投票。"; +"ScheduledMessages.Reminder.Delete" = "删除提醒"; +"ScheduledMessages.Reminder.DeleteMany" = "删除提醒"; +"ScheduledMessages.ReminderNotification" = "📅 日程提醒"; +"ScheduledMessages.RemindersTitle" = "提醒"; +"ScheduledMessages.ScheduledDate" = "计划于 %@发送"; +"ScheduledMessages.ScheduledOnline" = "在线后发送"; +"ScheduledMessages.ScheduledToday" = "今日日程"; +"ScheduledMessages.SendNow" = "立即发送"; +"ScheduledMessages.Title" = "定时消息"; +"ScheduledMessages.TodoUnavailable" = "消息发布后,即可标记核对清单。"; +"SearchImages.NoImagesFound" = "没有符合条件的图片"; +"SearchImages.Title" = "相册"; +"SecretChat.Title" = "私密聊天"; +"SecretGIF.NotViewedYet" = "%@ 尚未播放此 GIF 动态图"; +"SecretGif.Title" = "正在消失的 GIF"; +"SecretImage.NotViewedYet" = "%@ 还没有打开这张图片"; +"SecretImage.Title" = "阅后即焚图片"; +"SecretImage.ViewOnce.Title" = "阅后即焚图片"; +"SecretTimer.ImageDescription" = "设置计时器后,该图片将在阅后定时自毁。"; +"SecretTimer.VideoDescription" = "设置计时器后,该视频将在阅后一定时间内自毁。"; +"SecretVideo.NotViewedYet" = "%@ 还没有播放这段视频"; +"SecretVideo.Title" = "阅后即焚视频"; +"SecretVideo.ViewOnce.Title" = "阅后即焚视频"; +"SendInviteLink.ActionClose" = "关闭"; +"SendInviteLink.ActionInvite" = "发送邀请链接"; +"SendInviteLink.ActionSkip" = "跳过"; +"SendInviteLink.ChannelTextContactsAndPremiumMultipleUsers#other" = "{user_list},及更多 **%d** 个人只接受联系人和 **Premium** 用户的频道邀请。"; +"SendInviteLink.ChannelTextContactsAndPremiumOneUser" = "**%@** 只允许联系人和 **高级版** 用户邀请其加入频道。"; +"SendInviteLink.InviteTitle" = "通过链接邀请"; +"SendInviteLink.LinkUnavailableTitle" = "您无法创建链接"; +"SendInviteLink.PremiumOrSendSectionSeparator" = "或者"; +"SendInviteLink.SkipAction" = "跳过"; +"SendInviteLink.StatusAvailableToPremiumOnly" = "只适用于高级版用户"; +"SendInviteLink.SubscribeToPremiumButton" = "订阅 Telegram 高级版"; +"SendInviteLink.TextAvailableMultipleUsers#other" = "**%d 位用户**限制将其加入群组。\n您可以给对方发送邀请链接。"; +"SendInviteLink.TextAvailableSingleUser" = "**%@** 限制将其加入群组。\n您可以给对方发送邀请链接。"; +"SendInviteLink.TextCallsRestrictedMultipleUsers#other" = "{user_list} 和其他 **%d** 人不接受通话。"; +"SendInviteLink.TextCallsRestrictedOneUser" = "%@ 不接受通话。"; +"SendInviteLink.TextCallsRestrictedSendInviteLink" = "您可以尝试发送邀请链接。"; +"SendInviteLink.TextCallsRestrictedSendOneInviteLink" = "**%@** 限制接听通话。您可以向他们发送本次通话的邀请链接。"; +"SendInviteLink.TextContactsAndPremiumMultipleUsers#other" = "{user_list} 和其它 **%d** 个人只允许联系人和 **高级版** 用户邀请其加入群组。"; +"SendInviteLink.TextContactsAndPremiumOneUser" = "**%@** 只接受联系人和 **高级版** 用户的群组邀请。"; +"SendInviteLink.TextSendInviteLink" = "你也可以尝试发送邀请链接。"; +"SendInviteLink.TextUnavailableMultipleUsers#other" = "**%d 名用户**只能通过链接邀请,但是您无权分享本群组的邀请链接。 "; +"SendInviteLink.TextUnavailableSingleUser" = "**%@** 只能通过邀请链接邀请,但您无权分享此群组的邀请链接。"; +"SendInviteLink.TitleUpgradeToPremium" = "升级为高级版"; +"SendStarReactions.Balance" = "余额"; +"SendStarReactions.LiveStreamActionButton" = "赠送 # %@"; +"SendStarReactions.LiveStreamMessageText" = "通过向 **%@** 赠送星星,高亮并置顶您的消息。"; +"SendStarReactions.LiveStreamMessageTitle" = "高亮并置顶"; +"SendStarReactions.LiveStreamPerk1Title" = "在聊天中置顶"; +"SendStarReactions.LiveStreamPerk2Title" = "字符"; +"SendStarReactions.LiveStreamPerk3Title" = "表情"; +"SendStarReactions.LiveStreamReactionText" = "通过向 **%@** 赠送星星,高亮并置顶您的消息。"; +"SendStarReactions.OwnLiveStreamCloseButton" = "关闭"; +"SendStarReactions.OwnLiveStreamInfoText" = "您不能对自己的直播动态作出星星回应。"; +"SendStarReactions.SectionTop" = "星星打赏榜"; +"SendStarReactions.SendButtonTitle" = "发送 # %@"; +"SendStarReactions.ShowMyselfInTop" = "在星星打赏榜上显示我"; +"SendStarReactions.SliderTop" = "顶部"; +"SendStarReactions.SubtitleFrom" = "来自 %@"; +"SendStarReactions.TermsOfServiceFooter" = "发送星星即表示您同意 [服务条款](https://telegram.org/tos/stars)"; +"SendStarReactions.TextGeneric" = "选择您想要送出多少颗星星给 **%@** 以支持此贴文。"; +"SendStarReactions.TextSentStars#other" = "您已送出 **%d** 颗星星以支持此贴文。"; +"SendStarReactions.Title" = "以星星做出回应"; +"SendStarReactions.UserLabelAnonymous" = "匿名"; +"SensitiveContent.Enable.Confirm" = "确认"; +"SensitiveContent.Enable.Text" = "确认您已年满 18 岁并更新您的设置以查看潜在露骨或敏感的的內容。"; +"SensitiveContent.Enable.Title" = "18+ 内容"; +"SensitiveContent.SettingsInfo" = "您可以在[数据和存储 > 显示 18+ 内容]()中更新敏感媒体的可见性。"; +"SensitiveContent.ShowAlways" = "总是显示 18+ 媒体"; +"SensitiveContent.Text" = "此媒体可能包含仅适合成人的敏感內容。您仍想查看吗?"; +"SensitiveContent.Title" = "18+ 内容"; +"SensitiveContent.ViewAnyway" = "仍然查看"; +"ServiceMessage.GameScoreExtended#other" = "{name} 在«{game}»中获得 %@ 分"; +"ServiceMessage.GameScoreSelfExtended#other" = "您在«{game}»中获得 %@ 分"; +"ServiceMessage.GameScoreSelfSimple#other" = "您获得 %@ 分"; +"ServiceMessage.GameScoreSimple#other" = "{name} 获得 %@ 分"; +"SessionReview.NoticeText" = "切勿将您的登录代码发送给任何人,否则您可能会失去您的 Telegram 账号!"; +"SessionReview.OkAction" = "明白了"; +"SessionReview.Text" = "我们已终止来自 **%2$@**,**%1$@** 的登录尝试。"; +"SessionReview.Title" = "新登录已被阻止"; +"SetTimeoutFor.Days#other" = "设置为 %@ 天"; +"SetTimeoutFor.Hours#other" = "设置为 %@ 小时"; +"SetTimeoutFor.Minutes#other" = "设置为 %@ 分钟"; +"Settings.About" = "个人简介"; +"Settings.About.Help" = "您可以在此输入任何有关您个人的信息,诸如:年龄、工作或居住城市。\n示例:23岁,来自旧金山的设计师。"; +"Settings.About.PrivacyHelp" = "您可以添加几行关于自己的简介,并在 [设置]() 中选择谁可以查看您的个人简介。"; +"Settings.About.PrivacyHelpContacts" = "只有您的联系人能看到您的生日。[更改 >]()"; +"Settings.About.PrivacyHelpEmpty" = "关于您自己的文字介绍。"; +"Settings.About.PrivacyHelpEveryone" = "所有人都能看到你的生日。[更改 >]()"; +"Settings.About.PrivacyHelpNobody" = "没人能看到您的生日。[更改 >]()"; +"Settings.About.Title" = "个人简介"; +"Settings.AboutEmpty" = "添加"; +"Settings.AddAccount" = "添加帐号"; +"Settings.AddAnotherAccount" = "添加新账号"; +"Settings.AddAnotherAccount.Help" = "您可以连接多个使用不同手机号码的帐号。"; +"Settings.AddAnotherAccount.PremiumHelp" = "您最多可以添加四个具有不同手机号码的帐号。"; +"Settings.AddDevice" = "扫描二维码"; +"Settings.AppLanguage" = "语言"; +"Settings.AppLanguage.Unofficial" = "非官方"; +"Settings.Appearance" = "外观"; +"Settings.AppleWatch" = "Apple Watch"; +"Settings.ApplyProxyAlert" = "您确定要启用此代理吗?\n服务器:%1$@\n端口:%2$@\n\n您可以稍后在「设置 - 数据和存储」中修改您的代理服务器。"; +"Settings.ApplyProxyAlertCredentials" = "您确定要启用此代理吗?\n服务器:%1$@\n端口:%2$@\n用户名:%3$@\n密码:%4$@\n\n您可以稍后在「设置 - 数据和存储」中修改您的代理服务器。"; +"Settings.ApplyProxyAlertEnable" = "启用"; +"Settings.AutoDeleteInfo" = "在开始的所有新聊天中,一段时间后自动删除所有消息。"; +"Settings.AutoDeleteTitle" = "自动删除消息"; +"Settings.AutosaveMediaAllMedia" = "所有媒体文件(%@)"; +"Settings.AutosaveMediaNoPhoto" = "无权发送照片"; +"Settings.AutosaveMediaNoVideo" = "无权发送视频"; +"Settings.AutosaveMediaOff" = "关闭"; +"Settings.AutosaveMediaOn" = "开启"; +"Settings.AutosaveMediaPhoto" = "图片"; +"Settings.AutosaveMediaVideo" = "视频不能超过 %@"; +"Settings.Birthday" = "生日"; +"Settings.Birthday.Add" = "添加"; +"Settings.Birthday.ContactsHelp" = "只有您的联系人才能看到您的生日。 [变更 >]()"; +"Settings.Birthday.Help" = "在 [设置]() 中选择谁能看到你的出生日期。"; +"Settings.Birthday.PrivacyHelpContacts" = "只有您的联系人能看到您的生日。[更改 >]()"; +"Settings.Birthday.PrivacyHelpEveryone" = "所有人都能看到你的生日。[更改 >]()"; +"Settings.Birthday.PrivacyHelpNobody" = "没人能看到您的生日。[更改 >]()"; +"Settings.Birthday.Remove" = "移除生日"; +"Settings.BlockedUsers" = "已屏蔽的用户"; +"Settings.BotListSettings" = "机器人设置"; +"Settings.BotSettings.Biometry" = "面容 ID"; +"Settings.Business" = "Telegram 企业版"; +"Settings.CallSettings" = "通话"; +"Settings.CancelUpload" = "取消上传"; +"Settings.ChangePhoneNumber" = "修改号码"; +"Settings.ChangeProfilePhoto" = "更改头像"; +"Settings.ChatAutomation" = "聊天自动化"; +"Settings.ChatAutomationInfo" = "添加一个机器人来代表你回复消息。"; +"Settings.ChatAutomationOff" = "关"; +"Settings.ChatBackground" = "聊天壁纸"; +"Settings.ChatFolders" = "聊天文件夹"; +"Settings.ChatSettings" = "数据和存储"; +"Settings.ChatThemes" = "聊天主题"; +"Settings.CheckPasswordText" = "你的帐户受两步验证保护。你是否还记得密码?"; +"Settings.CheckPasswordTitle" = "密码"; +"Settings.CheckPhoneNumberFAQAnchor" = "查看更多"; +"Settings.CheckPhoneNumberText" = "让你的电话号码保持最新,来确保你可以随时登录Telegram。 [了解更多]()"; +"Settings.CheckPhoneNumberTitle" = "您的手机号码仍是%@吗?"; +"Settings.Context.Logout" = "登出"; +"Settings.CopyPhoneNumber" = "拷贝手机号码"; +"Settings.CopyUsername" = "拷贝用户名"; +"Settings.Devices" = "设备"; +"Settings.EditAccount" = "编辑账号信息"; +"Settings.EditPhoto" = "编辑"; +"Settings.EditProfileMedia" = "编辑"; +"Settings.EditVideo" = "编辑"; +"Settings.FAQ" = "Telegram 常见问题"; +"Settings.FAQ_Button" = "常见问题"; +"Settings.FAQ_Intro" = "请注意:Telegram 的技术支持由志愿者提供。我们会尽快回复,但仍可能需要等待一段时间。\n\n请前往“常见问题”看看:那里有各种常见问题和故障排除指南。"; +"Settings.FAQ_URL" = "https://telegram.org/faq#general-questions"; +"Settings.FrequentlyAskedQuestions" = "常见问题"; +"Settings.KeepPassword" = "是的,我确定"; +"Settings.KeepPhoneNumber" = "继续使用 %@"; +"Settings.Logout" = "登出"; +"Settings.LogoutConfirmationText" = "此操作将取消您的所有私密聊天。\n\n您可以同时在所有设备上使用 Telegram,并在同一个应用中使用多个帐号。"; +"Settings.LogoutConfirmationTitle" = "您确定要登出吗?"; +"Settings.MyProfile" = "我的资料"; +"Settings.MyStories" = "我的动态"; +"Settings.MyTon" = "我的 Gram"; +"Settings.New" = "全新"; +"Settings.NotificationsAndSounds" = "通知和声音"; +"Settings.Passport" = "Telegram 通行证"; +"Settings.PauseMusicOnRecording" = "录音时暂停音乐"; +"Settings.PersonalChannelAddedToast" = "已添加个人频道。"; +"Settings.PersonalChannelEmptyValue" = "添加"; +"Settings.PersonalChannelItem" = "频道"; +"Settings.PersonalChannelRemove" = "隐藏频道"; +"Settings.PersonalChannelRemovedToast" = "个人频道已移除。"; +"Settings.PersonalChannelSelectSubtitle" = "选择您的频道"; +"Settings.PersonalChannelSelectTitle" = "频道"; +"Settings.PersonalChannelUpdatedToast" = "个人频道已更新。"; +"Settings.PhoneNumber" = "更改号码"; +"Settings.PowerSaving" = "省电"; +"Settings.PowerSavingOff" = "关闭"; +"Settings.PowerSavingOn" = "开启"; +"Settings.Premium" = "Telegram 高级版"; +"Settings.PremiumGift" = "赠送 Telegram 高级版"; +"Settings.Privacy.LastSeenRevealedToast" = "您最后上线的时间已设为可见。"; +"Settings.Privacy.MessageReadTimeRevealedToast" = "您的已读时间已设为可见。"; +"Settings.Privacy.Messages" = "消息"; +"Settings.Privacy.Messages.ValueContactsAndPremium" = "联系人和高级版用户"; +"Settings.Privacy.Messages.ValueEveryone" = "所有人"; +"Settings.Privacy.Messages.ValuePaid" = "付费"; +"Settings.Privacy.ReadTime" = "隐藏已读时间"; +"Settings.Privacy.ReadTimeFooter" = "对无法看到您最后上线时间的用户隐藏您的已读时间。如果您开启此功能,对方的已读时间也会对您隐藏(除非您是高级版订阅用户)。 \n\n此设定不影响群聊。"; +"Settings.Privacy.ReadTimePremium" = "订阅 Telegram 高级版"; +"Settings.Privacy.ReadTimePremiumActive" = "订阅 Telegram 高级版"; +"Settings.Privacy.ReadTimePremiumActiveFooter" = "因为您是 Telegram 高级版订阅者,所以您将看到启用分享的所有用户的最后上线和已读时间 - 即使您已隐藏自己的。"; +"Settings.Privacy.ReadTimePremiumFooter" = "如果您订阅了 Telegram 高级版,您可以看到其他用户的最后上线时间及已读时间,即使您对其隐藏了自己的最后上线时间及已读时间(除非对方明确限制)。"; +"Settings.PrivacyPolicy_URL" = "https://telegram.org/privacy"; +"Settings.PrivacySettings" = "隐私和安全"; +"Settings.Proxy" = "代理"; +"Settings.ProxyConnected" = "已连接"; +"Settings.ProxyConnecting" = "连接中…"; +"Settings.ProxyDisabled" = "已停用"; +"Settings.QuickReactionSetup.ChooseQuickReaction" = "选择你的快速回应"; +"Settings.QuickReactionSetup.ChooseQuickReactionInfo" = "您可以将任何表情设置为快速表情回应。"; +"Settings.QuickReactionSetup.DemoHeader" = "双击信息以回应"; +"Settings.QuickReactionSetup.DemoInfo" = "双击此消息以快速回应"; +"Settings.QuickReactionSetup.DemoMessageAuthor" = "恐龙"; +"Settings.QuickReactionSetup.DemoMessageText" = "我希望你和我一样享受这一天。"; +"Settings.QuickReactionSetup.NavigationTitle" = "快速回应"; +"Settings.QuickReactionSetup.ReactionListHeader" = "快速回应"; +"Settings.QuickReactionSetup.Title" = "快速回应"; +"Settings.RaiseToListen" = "贴近耳边听取"; +"Settings.RaiseToListenInfo" = "开启“贴近耳边听取”功能后,将手机举到耳边就能快速收听和回复您收到的语音消息。"; +"Settings.RemoveConfirmation" = "移除"; +"Settings.RemoveVideo" = "移除图片"; +"Settings.SaveEditedPhotos" = "保存经编辑器修改的图片"; +"Settings.SaveIncomingPhotos" = "自动保存图片"; +"Settings.SaveToCameraRollInfo" = "自动将这些聊天中的所有新图片和视频保存至照片应用中。"; +"Settings.SaveToCameraRollSection" = "保存到照片"; +"Settings.SavedMessages" = "我的收藏"; +"Settings.Search" = "搜索"; +"Settings.SendGift" = "发送礼物"; +"Settings.SensitiveContent" = "显示 18+ 内容"; +"Settings.SensitiveContentInfo" = "不隐藏包含仅适合成人观看的媒体。"; +"Settings.SetNewProfilePhotoOrVideo" = "设置新头像"; +"Settings.SetProfilePhoto" = "设置头像"; +"Settings.SetProfilePhotoOrVideo" = "设置头像"; +"Settings.SetUsername" = "设置用户名"; +"Settings.Stars" = "我的星星"; +"Settings.StoriesArchive" = "动态归档"; +"Settings.SuggestSetupPasswordAction" = "设置额外密码"; +"Settings.SuggestSetupPasswordText" = "设置密码,每次使用此手机号码登录时都需要输入该密码。"; +"Settings.SuggestSetupPasswordTitle" = "保护你的账户"; +"Settings.Support" = "向我们提问"; +"Settings.Terms_URL" = "https://telegram.org/tos"; +"Settings.Tips" = "Telegram 功能"; +"Settings.TipsUsername" = "TelegramTips"; +"Settings.Title" = "设置"; +"Settings.Ton.Description" = "使用 Gram 购买礼物及向频道提交帖子建议。"; +"Settings.TransactionsTon.Subtitle" = "使用 Gram 购买礼物及向频道提交帖子建议。"; +"Settings.TransactionsTon.Title" = "Gram"; +"Settings.TryEnterPassword" = "不确定,让我试试"; +"Settings.Username" = "用户名"; +"Settings.UsernameEmpty" = "添加"; +"Settings.ViewPhoto" = "查看头像"; +"Settings.ViewVideo" = "查看视频"; +"Settings.YourColor" = "您的颜色"; +"SettingsSearch.DeleteAccount.DeleteMyAccount" = "\"删除帐号\" \"删除账号\" \"删除帐户\""; +"SettingsSearch.FAQ" = "常见问题"; +"SettingsSearch.Synonyms.AppLanguage" = " "; +"SettingsSearch.Synonyms.Appearance.Animations" = "动画"; +"SettingsSearch.Synonyms.Appearance.AutoNightTheme" = " "; +"SettingsSearch.Synonyms.Appearance.ChatBackground" = "壁纸\n背景"; +"SettingsSearch.Synonyms.Appearance.ChatBackground.Custom" = " "; +"SettingsSearch.Synonyms.Appearance.ChatBackground.SetColor" = " "; +"SettingsSearch.Synonyms.Appearance.ColorTheme" = " "; +"SettingsSearch.Synonyms.Appearance.LargeEmoji" = " "; +"SettingsSearch.Synonyms.Appearance.TextSize" = " "; +"SettingsSearch.Synonyms.Appearance.Title" = " "; +"SettingsSearch.Synonyms.Calls.CallTab" = " "; +"SettingsSearch.Synonyms.Calls.Title" = " "; +"SettingsSearch.Synonyms.ChatSettings.IntentsSettings" = "Siri 建议"; +"SettingsSearch.Synonyms.ChatSettings.OpenLinksIn" = "浏览器"; +"SettingsSearch.Synonyms.Data.AutoDownloadReset" = " "; +"SettingsSearch.Synonyms.Data.AutoDownloadUsingCellular" = " "; +"SettingsSearch.Synonyms.Data.AutoDownloadUsingWifi" = " "; +"SettingsSearch.Synonyms.Data.AutoplayGifs" = " "; +"SettingsSearch.Synonyms.Data.AutoplayVideos" = " "; +"SettingsSearch.Synonyms.Data.CallsUseLessData" = " "; +"SettingsSearch.Synonyms.Data.DownloadInBackground" = " "; +"SettingsSearch.Synonyms.Data.NetworkUsage" = " "; +"SettingsSearch.Synonyms.Data.SaveEditedPhotos" = " "; +"SettingsSearch.Synonyms.Data.SaveIncomingPhotos" = " "; +"SettingsSearch.Synonyms.Data.Storage.ClearCache" = " "; +"SettingsSearch.Synonyms.Data.Storage.KeepMedia" = " "; +"SettingsSearch.Synonyms.Data.Storage.Title" = "缓存"; +"SettingsSearch.Synonyms.Data.Title" = " "; +"SettingsSearch.Synonyms.Devices.LinkDesktopDevice" = "登录\n连接\n桌面\n浏览器\n电脑\n笔记本电脑\n个人电脑\nWindows\nmacOS\nLinux\n网络"; +"SettingsSearch.Synonyms.Devices.TerminateOtherSessions" = " "; +"SettingsSearch.Synonyms.EditProfile.AddAccount" = "在账号设置中添加搜索关键字,以帮助用户在应用内搜索中找到相应的部分。 请使用换行符作为分隔符。"; +"SettingsSearch.Synonyms.EditProfile.Bio" = " "; +"SettingsSearch.Synonyms.EditProfile.Logout" = " "; +"SettingsSearch.Synonyms.EditProfile.PhoneNumber" = " "; +"SettingsSearch.Synonyms.EditProfile.Title" = " "; +"SettingsSearch.Synonyms.EditProfile.Username" = " "; +"SettingsSearch.Synonyms.FAQ" = " "; +"SettingsSearch.Synonyms.Language.DoNotTranslate" = " "; +"SettingsSearch.Synonyms.Language.ShowTranslateButton" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeCountUnreadMessages" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedChannels" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedChats" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedPublicGroups" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsSound" = "在通知中添加搜索关键字,以帮助用户在应用内搜索中找到相应的部分。 请使用换行符作为分隔符。"; +"SettingsSearch.Synonyms.Notifications.ContactJoined" = " "; +"SettingsSearch.Synonyms.Notifications.DisplayNamesOnLockScreen" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsVibrate" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.ResetAllNotifications" = " "; +"SettingsSearch.Synonyms.Notifications.Title" = ""; +"SettingsSearch.Synonyms.Passport" = " "; +"SettingsSearch.Synonyms.Premium" = " "; +"SettingsSearch.Synonyms.Premium.AnimatedEmoji" = " "; +"SettingsSearch.Synonyms.Premium.AppIcon" = " "; +"SettingsSearch.Synonyms.Premium.Avatar" = "视频头像"; +"SettingsSearch.Synonyms.Premium.Badge" = "星标"; +"SettingsSearch.Synonyms.Premium.ChatManagement" = " "; +"SettingsSearch.Synonyms.Premium.DoubledLimits" = " "; +"SettingsSearch.Synonyms.Premium.EmojiStatus" = " "; +"SettingsSearch.Synonyms.Premium.FasterSpeed" = " "; +"SettingsSearch.Synonyms.Premium.NoAds" = " "; +"SettingsSearch.Synonyms.Premium.Reactions" = " "; +"SettingsSearch.Synonyms.Premium.Stickers" = " "; +"SettingsSearch.Synonyms.Premium.UploadSize" = " "; +"SettingsSearch.Synonyms.Premium.VoiceToText" = "转录"; +"SettingsSearch.Synonyms.Privacy.AuthSessions" = " "; +"SettingsSearch.Synonyms.Privacy.BlockedUsers" = " "; +"SettingsSearch.Synonyms.Privacy.Calls" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ClearPaymentsInfo" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ContactsReset" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ContactsSync" = " "; +"SettingsSearch.Synonyms.Privacy.Data.DeleteDrafts" = " "; +"SettingsSearch.Synonyms.Privacy.Data.SecretChatLinkPreview" = " "; +"SettingsSearch.Synonyms.Privacy.Data.Title" = " "; +"SettingsSearch.Synonyms.Privacy.Data.TopPeers" = " "; +"SettingsSearch.Synonyms.Privacy.DeleteAccountIfAwayFor" = " "; +"SettingsSearch.Synonyms.Privacy.Forwards" = " "; +"SettingsSearch.Synonyms.Privacy.GroupsAndChannels" = " "; +"SettingsSearch.Synonyms.Privacy.LastSeen" = " "; +"SettingsSearch.Synonyms.Privacy.Passcode" = " "; +"SettingsSearch.Synonyms.Privacy.PasscodeAndFaceId" = " "; +"SettingsSearch.Synonyms.Privacy.PasscodeAndTouchId" = " "; +"SettingsSearch.Synonyms.Privacy.ProfilePhoto" = " "; +"SettingsSearch.Synonyms.Privacy.Title" = " "; +"SettingsSearch.Synonyms.Privacy.TwoStepAuth" = "密码"; +"SettingsSearch.Synonyms.Proxy.AddProxy" = " "; +"SettingsSearch.Synonyms.Proxy.Title" = "SOCKS5\nMTProto"; +"SettingsSearch.Synonyms.Proxy.UseForCalls" = " "; +"SettingsSearch.Synonyms.SavedMessages" = " "; +"SettingsSearch.Synonyms.Stickers.ArchivedPacks" = " "; +"SettingsSearch.Synonyms.Stickers.FeaturedPacks" = " "; +"SettingsSearch.Synonyms.Stickers.Masks" = " "; +"SettingsSearch.Synonyms.Stickers.SuggestStickers" = " "; +"SettingsSearch.Synonyms.Stickers.Title" = " "; +"SettingsSearch.Synonyms.Support" = "支持"; +"SettingsSearch.Synonyms.Watch" = "Apple Watch"; +"Share.AuthDescription" = "打开并登录到 Telegram 以分享。"; +"Share.AuthTitle" = "登录到 Telegram"; +"Share.MessagePreview" = "消息预览"; +"Share.MultipleMessagesDisabled" = "慢速模式已启用,您不能一次发送多条消息。"; +"Share.PostToStory" = "发布\n至动态"; +"Share.RepostStory" = "转发\n状态"; +"Share.RepostToStory" = "转发\n到动态"; +"Share.ShareAsImage" = "以图片分享"; +"Share.ShareAsLink" = "以链接分享"; +"Share.ShareMessage" = "分享消息"; +"Share.ShareToInstagramStories" = "分享至 Instagram Stories"; +"Share.Title" = "分享"; +"Share.UploadDone" = "完成"; +"Share.UploadProgress" = "正在上传 • %d%"; +"Share.VideoStartAt" = "开始于 %@"; +"ShareFileTip.CloseTip" = "关闭提示"; +"ShareFileTip.Text" = "您可以在此处从您的相机胶卷中分享 **无损的** 媒体文件。\n\n如要分享其它类型的文件,请在 %@(例如浏览器)中打开它们,点击 **通过其它应用打开** 或者点击分享按钮并选择「Telegram」。"; +"ShareFileTip.Title" = "分享文件"; +"ShareMenu.Comment" = "补充评论…"; +"ShareMenu.CopyShareLink" = "拷贝链接"; +"ShareMenu.CopyShareLinkGame" = "拷贝游戏链接"; +"ShareMenu.SelectChats" = "选择聊天"; +"ShareMenu.SelectTopic" = "选择话题"; +"ShareMenu.Send" = "发送"; +"ShareMenu.ShareTo" = "分享给"; +"SharedMedia.CalendarTooltip" = "点击该图标以日期形式查看"; +"SharedMedia.CategoryDocs" = "文件"; +"SharedMedia.CategoryLinks" = "链接"; +"SharedMedia.CategoryMedia" = "媒体"; +"SharedMedia.CategoryOther" = "音乐"; +"SharedMedia.CommonGroupCount#other" = "%@ 个共同群组"; +"SharedMedia.DeleteItemsConfirmation#other" = "删除 %@ 个媒体文件?"; +"SharedMedia.EmptyFilesText" = "您可以发送或收取最大为1.5G的任意格式的文件,\n并在所有设备上查看。"; +"SharedMedia.EmptyLinksText" = "此聊天中分享的所有链接都会显示在这里"; +"SharedMedia.EmptyMusicText" = "此聊天中共享的所有音乐都会在这里显示。"; +"SharedMedia.EmptyText" = "快在这个对话里分享一些图片或者视频吧\n不然这个回形针会一直不开心的。"; +"SharedMedia.EmptyTitle" = "暂无媒体文件"; +"SharedMedia.FastScrollTooltip" = "按住并上下滑动滚动条以快速浏览。"; +"SharedMedia.File#other" = "%@ 个文件"; +"SharedMedia.FileCount#other" = "%@ 个文件"; +"SharedMedia.Generic#other" = "%@ 个媒体文件"; +"SharedMedia.GifCount#other" = "%@ 张 GIF"; +"SharedMedia.GiftCount#other" = "%@ 份礼物"; +"SharedMedia.Link#other" = "%@ 条链接"; +"SharedMedia.LinkCount#other" = "%@ 个链接"; +"SharedMedia.MusicCount#other" = "%@ 个音乐文件"; +"SharedMedia.Photo#other" = "%@ 张图片"; +"SharedMedia.PhotoCount#other" = "%@ 张图片"; +"SharedMedia.PollCount#other" = "%@ 个投票活动"; +"SharedMedia.SearchNoResults" = "暂无结果"; +"SharedMedia.SearchNoResultsDescription" = "没有找到“%@”的相关结果。\n请重新搜索。"; +"SharedMedia.ShowCalendar" = "显示日历"; +"SharedMedia.ShowPhotos" = "显示图片"; +"SharedMedia.ShowVideos" = "显示视频"; +"SharedMedia.SimilarBot.Users#other" = "%@ 名月活跃用户"; +"SharedMedia.SimilarBotCount#other" = "%@ 个机器人"; +"SharedMedia.SimilarChannel.Subscribers#other" = "%@ 个订阅者"; +"SharedMedia.SimilarChannelCount#other" = "%@ 个频道"; +"SharedMedia.TitleAll" = "分享的媒体文件"; +"SharedMedia.Video#other" = "%@ 段视频"; +"SharedMedia.VideoCount#other" = "%@ 个视频"; +"SharedMedia.ViewInChat" = "在聊天中查看"; +"SharedMedia.VoiceMessageCount#other" = "%@ 条语音消息"; +"SharedMedia.ZoomIn" = "放大"; +"SharedMedia.ZoomOut" = "缩小"; +"ShortTime.AtDate" = "%@"; +"ShortTime.AtPreciseDate" = "%@ 在 %@"; +"ShortTime.HoursAgo#other" = "%@ 小时"; +"ShortTime.JustNow" = "刚刚"; +"ShortTime.MinutesAgo#other" = "%@ 分钟"; +"Shortcut.AppIcon" = "编辑应用图标"; +"Shortcut.SwitchAccount" = "切换账号"; +"SocksProxySetup.AdNoticeHelp" = "此代理可能会在您的聊天列表中显示其赞助频道。这不会泄露您的 Telegram 流量。"; +"SocksProxySetup.AddProxy" = "添加代理"; +"SocksProxySetup.AddProxyTitle" = "添加代理"; +"SocksProxySetup.CheckStatus" = "检查状态"; +"SocksProxySetup.ConnectAndSave" = "连接代理"; +"SocksProxySetup.Connecting" = "连接中…"; +"SocksProxySetup.Connection" = "连接信息"; +"SocksProxySetup.Credentials" = "认证信息(可选)"; +"SocksProxySetup.FailedToConnect" = "连接失败"; +"SocksProxySetup.Hostname" = "服务器"; +"SocksProxySetup.HostnamePlaceholder" = "服务器"; +"SocksProxySetup.Password" = "密码"; +"SocksProxySetup.PasswordPlaceholder" = "密码"; +"SocksProxySetup.PasteFromClipboard" = "粘贴自剪贴板"; +"SocksProxySetup.Port" = "端口"; +"SocksProxySetup.PortPlaceholder" = "端口"; +"SocksProxySetup.ProxyDetailsTitle" = "代理信息"; +"SocksProxySetup.ProxyEnabled" = "代理\n已启用"; +"SocksProxySetup.ProxySocks5" = "SOCKS5"; +"SocksProxySetup.ProxyStatusChecking" = "正在检查…"; +"SocksProxySetup.ProxyStatusConnected" = "已连接"; +"SocksProxySetup.ProxyStatusConnecting" = "连接中"; +"SocksProxySetup.ProxyStatusPing" = "Ping: %@ 毫秒"; +"SocksProxySetup.ProxyStatusUnavailable" = "不可用"; +"SocksProxySetup.ProxyTelegram" = "MTProto"; +"SocksProxySetup.ProxyType" = "类型"; +"SocksProxySetup.QrCode.TMeLink" = "t.me 链接"; +"SocksProxySetup.QrCode.TgLink" = "tg:// 链接"; +"SocksProxySetup.RequiredCredentials" = "认证信息"; +"SocksProxySetup.SaveProxy" = "保存代理"; +"SocksProxySetup.SavedProxies" = "已存代理"; +"SocksProxySetup.Secret" = "密钥"; +"SocksProxySetup.SecretPlaceholder" = "密钥"; +"SocksProxySetup.ShareLink" = "分享链接"; +"SocksProxySetup.ShareProxyList" = "分享代理列表"; +"SocksProxySetup.ShareQRCode" = "分享二维码"; +"SocksProxySetup.ShareQRCodeInfo" = "您的朋友可以使用相机扫码添加此代理。"; +"SocksProxySetup.Status" = "状态"; +"SocksProxySetup.Title" = "代理"; +"SocksProxySetup.TypeNone" = "已停用"; +"SocksProxySetup.TypeSocks" = "SOCKS5"; +"SocksProxySetup.UseForCalls" = "对通话启用代理"; +"SocksProxySetup.UseForCallsHelp" = "使用代理服务器可能降低通话质量。"; +"SocksProxySetup.UseProxy" = "使用代理"; +"SocksProxySetup.UseSocks5" = "使用 SOCKS5 代理"; +"SocksProxySetup.Username" = "用户名"; +"SocksProxySetup.UsernamePlaceholder" = "用户名"; +"SocksProxySetup.Warning.Proceed" = "继续"; +"SocksProxySetup.Warning.Text" = "这会将您的 IP 地址暴露给代理服务器管理员。"; +"SocksProxySetup.Warning.Title" = "警告"; +"SponsoredMessageInfo.Action" = "了解更多"; +"SponsoredMessageInfo.ActionUrl" = "https://ads.telegram.org"; +"SponsoredMessageInfo.Text" = "查看 https://telegram.org"; +"SponsoredMessageInfo.Url" = "https://ads.telegram.org"; +"SponsoredMessageInfoScreen.MarkdownText" = "与其他应用不同,Telegram 不会将您的隐私数据用于投放定向广告。[查看隐私政策了解更多](https://telegram.org/privacy#5-6-no-ads-based-on-user-data)\n您之所以看到这条消息,仅是因为有人选择使用此公开的一对多频道推广其信息。这意味着 Telegram 不会通过挖掘和分析用户画像来投放广告,查看频道的所有用户都会看到同样的赞助商消息。\n\n与其他应用不同,Telegram 不会追踪您是否点击赞助商消息,也不会基于您的活跃数据生成用户画像。为了避免来自第三方的窥探,我们还禁止在赞助商消息中插入外部链接。我们相信每个人都有保护隐私的权利,而线上平台应该尊重这一点。\n\nTelegram 向数亿用户提供免费和无限制的服务,这意味着服务器和流量的巨大开销。为了保持独立且始终如一地秉持理念,Telegram 已开发一种付费工具以推广信息,同时保证用户的隐私。我们欢迎负责任的广告商访问:[url]\n\n广告不应再是滥用用户隐私的代名词。让我们共同携手,重新定义科技公司的运营模式。"; +"SponsoredMessageInfoScreen.Text" = "与其他应用不同,Telegram 不会将您的隐私数据用于投放定向广告。赞助商消息的投放完全基于公开频道内所发布的内容。这意味着 Telegram 不会通过挖掘和分析用户画像来投放广告,查看频道的所有用户都会看到同样的赞助商消息。\n\n与其他应用不同,Telegram 不会追踪您是否点击赞助商消息,也不会基于您的活跃数据生成用户画像。为了避免来自第三方的窥探,我们还禁止在赞助商消息中插入外部链接。我们相信每个人都有保护隐私的权利,而科技平台应该尊重用户的隐私权。\n\nTelegram 向数以亿计的用户提供免费服务的同时,也需要承担由此产生的高昂运营成本,包括服务器和流量开销。为了保持独立并延续其价值观,Telegram 将用户隐私权谨记于心,开发了这套付费工具。欢迎有责任的广告商与我们取得联系:\n[url]\n赞助商消息目前仍然处于测试阶段。全面启动赞助商消息系统,并且 Telegram 能够平衡运营成本之后,我们将与显示赞助商消息的频道所有者共同分享广告收入。\n\n线上广告不应再是滥用用户隐私的代名词。让我们共同携手,重新定义科技公司的运营模式。"; +"SponsoredMessageInfoScreen.Title" = "什么是赞助消息?"; +"SponsoredMessageMenu.Hide" = "隐藏"; +"SponsoredMessageMenu.Info" = "什么是赞助消息?"; +"Stars.AccountRevenue.Proceeds.Info" = "您总余额中的星星可以在赚取后的21天后作为奖励提取。"; +"Stars.AccountRevenue.Withdraw.Info" = "您可以使用 Fragment 收集星星的奖励。每次提取的星星不得少于1000个。 [了解更多 >]()"; +"Stars.BotRevenue.Proceeds.Available" = "可用余额"; +"Stars.BotRevenue.Proceeds.Current" = "总余额"; +"Stars.BotRevenue.Proceeds.Info" = "总余额中的星星可用于广告支出,或在获得星星 21 天提取奖励。"; +"Stars.BotRevenue.Proceeds.Title" = "收益总览"; +"Stars.BotRevenue.Proceeds.Total" = "累计收益"; +"Stars.BotRevenue.Revenue.Title" = "收到的星星"; +"Stars.BotRevenue.Title" = "星星数量"; +"Stars.BotRevenue.Transactions.Title" = "交易历史"; +"Stars.BotRevenue.Withdraw.Balance" = "可用余额"; +"Stars.BotRevenue.Withdraw.BuyAds" = "购买广告"; +"Stars.BotRevenue.Withdraw.Info" = "您可以使用 Fragment 领取星星奖励,或使用星星来宣传您的机器人,每次提取金额不得少于 1000 颗星星。[了解更多 >]()"; +"Stars.BotRevenue.Withdraw.Info_URL" = "https://telegram.org/tos/bot-developers#6-2-2-tpa-balance"; +"Stars.BotRevenue.Withdraw.Withdraw" = "通过 Fragment 提现"; +"Stars.BotRevenue.Withdraw.WithdrawShort" = "提现"; +"Stars.Gift.Received.Text" = "使用星星解锁 Telegram 上的内容和服务。[查看示例 >]()"; +"Stars.Gift.Received.Title" = "收到礼物"; +"Stars.Gift.Sent.Text" = "有了星星,%@ 就可以解锁 Telegram 上的内容和服务。[查看示例 >]()"; +"Stars.Gift.Sent.Title" = "送出的礼物"; +"Stars.Gift.Ton.Text" = "使用 Gram 购买礼物及向频道提交帖子建议。"; +"Stars.Info.Description" = "Telegram 星星允许用户:"; +"Stars.Info.Done" = "了解"; +"Stars.Info.Gift.Text" = "为您的朋友赠送礼物,这些礼物可以添加至他们的个人资料页或转换为星星。"; +"Stars.Info.Gift.Title" = "为好友送礼"; +"Stars.Info.Media.Text" = "可以访问 Telegram 频道中的付费內容和服务。"; +"Stars.Info.Media.Title" = "解锁频道中的内容"; +"Stars.Info.Miniapp.Text" = "在 Telegram 小程序中购买额外的内容和服务。 [请查看示例 >]()"; +"Stars.Info.Miniapp.Title" = "在小程序中使用星星"; +"Stars.Info.Reaction.Text" = "通过向您所喜欢的频道的贴文作出星星回应来支持它们。"; +"Stars.Info.Reaction.Title" = "发出星星回应"; +"Stars.Info.Title" = "什么是星星?"; +"Stars.Intro.AllTransactions" = "所有交易"; +"Stars.Intro.Balance" = "余额"; +"Stars.Intro.Buy" = "购买更多星星"; +"Stars.Intro.BuyShort" = "充值"; +"Stars.Intro.Description" = "购买星星,解锁 Telegram 小程序中的内容和服务。"; +"Stars.Intro.GiftStars" = "赠送星星给朋友"; +"Stars.Intro.Incoming" = "进账"; +"Stars.Intro.Outgoing" = "出账"; +"Stars.Intro.PurchasedText" = "**%@ 颗星星** 已添加至您的余额。"; +"Stars.Intro.PurchasedText.Stars#other" = "%@ 颗星星"; +"Stars.Intro.PurchasedTitle" = "已获得星星!"; +"Stars.Intro.StarsSent#other" = "已发送 %@ 颗星星"; +"Stars.Intro.StarsSent.ViewChat" = "查看聊天"; +"Stars.Intro.Stats" = "统计"; +"Stars.Intro.Subscriptions.Cancelled" = "已取消"; +"Stars.Intro.Subscriptions.Expired" = "已过期于 %@"; +"Stars.Intro.Subscriptions.ExpiredStatus" = "已过期"; +"Stars.Intro.Subscriptions.Expires" = "将于 %@ 过期"; +"Stars.Intro.Subscriptions.PerMonth" = "每月"; +"Stars.Intro.Subscriptions.Renews" = "将于 %@ 续订"; +"Stars.Intro.Subscriptions.ShowMore" = "显示更多"; +"Stars.Intro.Subscriptions.Title" = "我的订阅"; +"Stars.Intro.Title" = "Telegram 星星"; +"Stars.Intro.Transaction.AppleTopUp.Subtitle" = "通过 App Store"; +"Stars.Intro.Transaction.AppleTopUp.Title" = "星星充值"; +"Stars.Intro.Transaction.Commission.Title" = "%@ 佣金"; +"Stars.Intro.Transaction.ConvertedGift" = "已转换的礼物"; +"Stars.Intro.Transaction.FragmentTopUp.Subtitle" = "通过 Fragment"; +"Stars.Intro.Transaction.FragmentTopUp.Title" = "星星充值"; +"Stars.Intro.Transaction.FragmentWithdrawal.Subtitle" = "通过 Fragment"; +"Stars.Intro.Transaction.FragmentWithdrawal.Title" = "提现"; +"Stars.Intro.Transaction.Gift" = "礼物"; +"Stars.Intro.Transaction.Gift.Title" = "收到礼物"; +"Stars.Intro.Transaction.Gift.UnknownUser" = "未知用户"; +"Stars.Intro.Transaction.GiftAuctionBid" = "礼物拍卖出价"; +"Stars.Intro.Transaction.GiftDropOriginalDetails" = "已移除礼物描述"; +"Stars.Intro.Transaction.GiftPurchase" = "购买礼物"; +"Stars.Intro.Transaction.GiftSale" = "售出礼物"; +"Stars.Intro.Transaction.GiftTransfer" = "礼物转赠"; +"Stars.Intro.Transaction.GiftUpgrade" = "礼物升级"; +"Stars.Intro.Transaction.GiveawayPrize" = "抽奖活动的奖品"; +"Stars.Intro.Transaction.GoogleTopUp.Subtitle" = "通过 Play 商店"; +"Stars.Intro.Transaction.GoogleTopUp.Title" = "星星充值"; +"Stars.Intro.Transaction.LiveStreamPaidMessage#other" = "%@ 个直播动态留言的费用"; +"Stars.Intro.Transaction.LiveStreamReaction" = "直播回应"; +"Stars.Intro.Transaction.MediaPurchase" = "媒体购买"; +"Stars.Intro.Transaction.PaidMessage#other" = "%@ 条消息的费用"; +"Stars.Intro.Transaction.PremiumBotTopUp.Subtitle" = "通过 Telegram 高级版机器人"; +"Stars.Intro.Transaction.PremiumBotTopUp.Title" = "星星充值"; +"Stars.Intro.Transaction.PrepaidGiftUpgrade" = "礼物升级"; +"Stars.Intro.Transaction.Reaction.Title" = "星星回应"; +"Stars.Intro.Transaction.Refund" = "退款"; +"Stars.Intro.Transaction.SearchFee" = "公开帖子搜索"; +"Stars.Intro.Transaction.SubscriptionFee.Title" = "订阅月费"; +"Stars.Intro.Transaction.TelegramAds.Subtitle" = "通过 Telegram 广告"; +"Stars.Intro.Transaction.TelegramAds.Title" = "提取"; +"Stars.Intro.Transaction.TelegramBotApi.Messages#other" = "%@ 条短信"; +"Stars.Intro.Transaction.TelegramBotApi.Title" = "付费广播消息"; +"Stars.Intro.Transaction.TelegramPremium#other" = "%@ 个月的 Telegram 高级版订阅"; +"Stars.Intro.Transaction.Unsupported.Title" = "不支持"; +"Stars.Intro.Withdraw" = "提现"; +"Stars.Intro.YourBalance" = "您的余额"; +"Stars.PaidContent.AmountInfo" = "用户需将此数量的星星转账至您的频道才能查看此媒体。[关于星星的更多信息 >]()"; +"Stars.PaidContent.AmountInfo_URL" = "https://telegram.org/tos/stars"; +"Stars.PaidContent.AmountPlaceholder" = "解锁所需星星数量"; +"Stars.PaidContent.AmountTitle" = "输入解锁价格"; +"Stars.PaidContent.Create" = "设为付费媒体"; +"Stars.PaidContent.Title" = "付费内容"; +"Stars.Purchase.Balance" = "余额"; +"Stars.Purchase.BuyAnyway" = "仍要购买"; +"Stars.Purchase.BuyStarGiftInfo" = "购买星星以获得独家收藏品。"; +"Stars.Purchase.EnoughStars" = "你目前有足够的星星可用。"; +"Stars.Purchase.GenericPurchasePurpose" = "购买星星以解锁 Telegram 上的内容和服务。"; +"Stars.Purchase.GetStars" = "获得星星"; +"Stars.Purchase.GetStarsInfo" = "选择您想要购买多少颗星星"; +"Stars.Purchase.GiftInfo" = "有了星星,**%1$@** 就可以解锁 Telegram 上的内容和服务。[查看示例 >]()"; +"Stars.Purchase.GiftStars" = "赠送星星"; +"Stars.Purchase.Info" = "购买星星即表示您同意 [条款和条件]()。"; +"Stars.Purchase.PurchasePurpose.subs" = "购买星星以保持您所有订阅。"; +"Stars.Purchase.RemoveOriginalDetailsStarGiftInfo" = "购买星星来移除礼物的描述。"; +"Stars.Purchase.SendGroupMessageInfo" = "购买星星以在 **%@** 中发送消息。"; +"Stars.Purchase.SendMessageInfo" = "购买星星以给 **%@** 赠送礼物。"; +"Stars.Purchase.ShowMore" = "显示更多选项"; +"Stars.Purchase.StarGiftInfo" = "购买星星给 **%@** 赠送的礼物可被他收藏在个人资料页或被转换为星星。"; +"Stars.Purchase.StarGiftOfferInfo" = "购买星星即可对这份礼物出价。"; +"Stars.Purchase.Stars#other" = "%@ 颗星星"; +"Stars.Purchase.StarsNeeded#other" = "需要 %@ 颗星星"; +"Stars.Purchase.StarsNeededInfo" = "购买 **星星** 并在 **%@** 和其他小程序中使用。"; +"Stars.Purchase.StarsNeededUnlockInfo" = "购买星星以解锁媒体并在小程序中使用。"; +"Stars.Purchase.StarsReactionsNeededInfo" = "购买星星后可在 **%@** 和其他频道中发送付费回应。"; +"Stars.Purchase.SubscriptionInfo" = "购买星星以订阅 **%@**."; +"Stars.Purchase.SubscriptionRenewInfo" = "购买星星以保持您对 **%@** 频道的订阅。"; +"Stars.Purchase.Terms_URL" = "https://telegram.org/tos/stars"; +"Stars.Purchase.TransferStarGiftInfo" = "购买星星来转让您独家典藏品的所有权。"; +"Stars.Purchase.UpgradeStarGiftInfo" = "购买星星将您的礼物升级为独家典藏品。"; +"Stars.SellGift.AmountInfo" = "您将获得 **%@** 颗星星。"; +"Stars.SellGift.AmountInfo.Stars#other" = "%@ 颗星星"; +"Stars.SellGift.AmountPlaceholder" = "输入价格"; +"Stars.SellGift.AmountTitle" = "星星价格"; +"Stars.SellGift.EditTitle" = "编辑价格"; +"Stars.SellGift.OnlyTon" = "只接受以 Gram 付款"; +"Stars.SellGift.OnlyTonInfo" = "如果买家使用 Gram 付款,则不存在买家要求退款的风险,\n这是与星星付款的不同之处。"; +"Stars.SellGift.Sell" = "出售"; +"Stars.SellGift.SellFor" = "出售价"; +"Stars.SellGift.Title" = "出售礼物"; +"Stars.SellGift.TonAmountTitle" = "以 Gram 报价"; +"Stars.SellGiftMinAmountToast.Text" = "礼物售价不能低于 %@"; +"Stars.SendMessage.AdjustmentAction" = "确定"; +"Stars.SendMessage.AdjustmentPlaceholder" = "每条消息的价格"; +"Stars.SendMessage.AdjustmentSectionFooterEmpty" = "你将获得 **80%**。"; +"Stars.SendMessage.AdjustmentSectionFooterEmptyValue" = "您将获得 **%1$@%**。"; +"Stars.SendMessage.AdjustmentSectionFooterValue" = "您将获得 **%@ 颗星星**。"; +"Stars.SendMessage.AdjustmentSectionHeader" = "星星价格"; +"Stars.SendMessage.AdjustmentTitle" = "每条消息的价格"; +"Stars.SendMessage.PriceFree" = "免费"; +"Stars.SendStars.AmountInfo" = "发送 %@ 或更多星星以在此消息下作为前 3 位支持者的身分突出显示您的个人资料。"; +"Stars.SendStars.AmountPlaceholder" = "星星数量"; +"Stars.SendStars.AmountTitle" = "输入金额"; +"Stars.SendStars.SendStars" = "确认并发送"; +"Stars.SendStars.Title" = "发送星星"; +"Stars.Subscription.Terms" = "订阅即表示您同意 [服务条款]()。"; +"Stars.Subscription.Terms_URL" = "https://telegram.org/tos/stars"; +"Stars.Ton.Description" = "使用 Gram 购买礼物及向频道提交帖子建议。"; +"Stars.Ton.Title" = "Gram"; +"Stars.Transaction.AppleTopUp.Subtitle" = "App Store"; +"Stars.Transaction.AppleTopUp.Title" = "星星充值"; +"Stars.Transaction.CopiedId" = "已将交易号拷贝到剪贴板"; +"Stars.Transaction.Date" = "日期"; +"Stars.Transaction.FragmentTopUp.Subtitle" = "Fragment"; +"Stars.Transaction.FragmentTopUp.Title" = "星星充值"; +"Stars.Transaction.FragmentTopUpTon.Title" = "充值 Gram"; +"Stars.Transaction.FragmentUnknown_URL" = "https://fragment.com/stars"; +"Stars.Transaction.FragmentWithdrawal.Subtitle" = "Fragment"; +"Stars.Transaction.FragmentWithdrawal.Title" = "提现星星"; +"Stars.Transaction.FragmentWithdrawalTon.Title" = "提取 Gram"; +"Stars.Transaction.From" = "来自"; +"Stars.Transaction.Gift.Title" = "礼物"; +"Stars.Transaction.GiftAuctionBid" = "礼物拍卖出价"; +"Stars.Transaction.GiftFrom" = "礼物来自"; +"Stars.Transaction.GiftPurchase" = "购买礼物"; +"Stars.Transaction.GiftSale" = "售出礼物"; +"Stars.Transaction.GiftTransfer" = "礼物转赠"; +"Stars.Transaction.GiftUpgrade" = "礼物升级"; +"Stars.Transaction.Giveaway.Boost.Boosts#other" = "%@ 次助推"; +"Stars.Transaction.Giveaway.Boost.Members#other" = "%@ 名成员"; +"Stars.Transaction.Giveaway.Boost.Stars#other" = "%@ 颗星星"; +"Stars.Transaction.Giveaway.Boost.Subscriber#other" = "%@ 名订阅者"; +"Stars.Transaction.Giveaway.Giveaway" = "抽奖活动"; +"Stars.Transaction.Giveaway.Prize" = "奖品"; +"Stars.Transaction.Giveaway.Reason" = "事由"; +"Stars.Transaction.Giveaway.Stars#other" = "%@ 颗星星"; +"Stars.Transaction.Giveaway.Title" = "收到奖品"; +"Stars.Transaction.GoogleTopUp.Subtitle" = "Play 商店"; +"Stars.Transaction.GoogleTopUp.Title" = "星星充值"; +"Stars.Transaction.Id" = "交易号"; +"Stars.Transaction.LiveStreamPaidMessage#other" = "%@ 个直播动态留言的费用"; +"Stars.Transaction.LiveStreamPaidMessage.Text" = "每收到一条消息,您将获取设置费用的 **%@%**。"; +"Stars.Transaction.LiveStreamReaction" = "直播回应"; +"Stars.Transaction.LiveStreamReaction.Text" = "每收到一次回应,您将获取设置费用的 **%@%**。"; +"Stars.Transaction.Media" = "媒体"; +"Stars.Transaction.MediaAnd" = "%1$@ 和 %2$@"; +"Stars.Transaction.MediaPurchase" = "媒体购买"; +"Stars.Transaction.Paid" = "原价"; +"Stars.Transaction.PaidMessage#other" = "%@ 条消息的费用"; +"Stars.Transaction.PaidMessage.Text" = "每逢收到一条消息,你将获取设置费用的 **%@**。[更改费用 >]()"; +"Stars.Transaction.Photos#other" = "%@ 张图片"; +"Stars.Transaction.PremiumBotTopUp.Subtitle" = "高级版机器人"; +"Stars.Transaction.PremiumBotTopUp.Title" = "星星充值"; +"Stars.Transaction.Reaction.Post" = "贴文"; +"Stars.Transaction.Reaction.Title" = "星星回应"; +"Stars.Transaction.Refund" = "退款"; +"Stars.Transaction.SearchFee.Title" = "公共帖子搜索"; +"Stars.Transaction.SinglePhoto" = "图片"; +"Stars.Transaction.SingleVideo" = "视频"; +"Stars.Transaction.Subscription" = "订阅"; +"Stars.Transaction.Subscription.Active" = "如果您现在取消订阅,您仍然可以在 %@ 之前存取您的订阅内容。"; +"Stars.Transaction.Subscription.Bot" = "机器人"; +"Stars.Transaction.Subscription.Business" = "商家"; +"Stars.Transaction.Subscription.Cancel" = "取消订阅"; +"Stars.Transaction.Subscription.Cancelled" = "您已取消您的订阅。"; +"Stars.Transaction.Subscription.Cancelled.Text" = "在 **%2$@** 之前,您仍然可以访问 [%1$@]()。"; +"Stars.Transaction.Subscription.Cancelled.Title" = "订阅已取消"; +"Stars.Transaction.Subscription.CancelledByBot" = "您的订阅已经被机器人取消。"; +"Stars.Transaction.Subscription.CancelledByBusiness" = "您的订阅已被商家取消。"; +"Stars.Transaction.Subscription.Expired" = "您的订阅已过期于 %@。"; +"Stars.Transaction.Subscription.JoinAgainChannel" = "加入频道"; +"Stars.Transaction.Subscription.JoinChannel" = "加入频道"; +"Stars.Transaction.Subscription.LeftChannel" = "您已离开频道,但您仍然可以在 %@ 之前返回。"; +"Stars.Transaction.Subscription.PerMonth" = "%@ / 月"; +"Stars.Transaction.Subscription.PerMonthUsd" = "每月约 %@"; +"Stars.Transaction.Subscription.Renew" = "续期订阅"; +"Stars.Transaction.Subscription.Renewed.Text" = "你已续期 [%1$@]() 的订阅。"; +"Stars.Transaction.Subscription.Renewed.Title" = "订阅已续期"; +"Stars.Transaction.Subscription.Status.Expired" = "已过期"; +"Stars.Transaction.Subscription.Status.Expires" = "过期"; +"Stars.Transaction.Subscription.Status.Renews" = "续订"; +"Stars.Transaction.Subscription.Status.Subscribed" = "已订阅"; +"Stars.Transaction.Subscription.Subscriber" = "订阅者"; +"Stars.Transaction.Subscription.Subscription" = "订阅"; +"Stars.Transaction.Subscription.Title" = "订阅"; +"Stars.Transaction.SubscriptionFee" = "订阅月费"; +"Stars.Transaction.TelegramAds.Subtitle" = "Telegram 广告"; +"Stars.Transaction.TelegramAds.Title" = "提取星星"; +"Stars.Transaction.TelegramBotApi.Messages#other" = "%@ 条短信"; +"Stars.Transaction.TelegramBotApi.Title" = "付费广播消息"; +"Stars.Transaction.TelegramPremium#other" = " %@ 个月的高级版订阅"; +"Stars.Transaction.Terms" = "查看星星的 [服务条款]()。"; +"Stars.Transaction.Terms_URL" = "https://telegram.org/tos/stars"; +"Stars.Transaction.To" = "至"; +"Stars.Transaction.Unsupported.Title" = "不支持"; +"Stars.Transaction.Via" = "通过"; +"Stars.Transaction.Videos#other" = "%@ 个视频"; +"Stars.Transfer.Balance" = "余额"; +"Stars.Transfer.BotSubscribeInfo" = "您是否想以每月 **%3$@** 颗星星的价格订阅 **%2$@** 中的 **%1$@**?"; +"Stars.Transfer.BotSubscribeInfo.Stars#other" = "%@ 颗星星"; +"Stars.Transfer.Info" = "您确定要使用 **%3$@** 在 **%2$@** 购买 **%1$@** 吗?"; +"Stars.Transfer.Info.Stars#other" = "%@ 颗星星"; +"Stars.Transfer.MediaAnd" = "%1$@ 和 %2$@"; +"Stars.Transfer.Pay" = "确认并支付"; +"Stars.Transfer.Photos#other" = "%@ 张图片"; +"Stars.Transfer.Purchased.Stars#other" = "%@ 颗星星"; +"Stars.Transfer.PurchasedText" = "您已使用 **%3$@** 在 **%2$@** 购买了 **%1$@**。"; +"Stars.Transfer.PurchasedTitle" = "购买完成"; +"Stars.Transfer.SinglePhoto" = "**图片**"; +"Stars.Transfer.SingleVideo" = "视频"; +"Stars.Transfer.Subscribe" = "订阅"; +"Stars.Transfer.Subscribe.Channel.Title" = "订阅"; +"Stars.Transfer.Subscribe.Successful.Text" = "%1$@ 已转移到 **%2$@**。"; +"Stars.Transfer.Subscribe.Successful.Title" = "已成功订阅!"; +"Stars.Transfer.SubscribeFor" = "订阅价格"; +"Stars.Transfer.SubscribeInfo" = "您想以每月 **%1$@ 颗星星**的价格订阅 **%2$@** 吗?"; +"Stars.Transfer.SubscribePerMonth" = "/ 月"; +"Stars.Transfer.Terms" = "购买即表示您同意 [服务条款]()。"; +"Stars.Transfer.Terms_URL" = "https://telegram.org/tos/stars"; +"Stars.Transfer.Title" = "确认您的购买"; +"Stars.Transfer.Unavailable" = "抱歉,您所在的国家无法购买星星。"; +"Stars.Transfer.UnlockBotInfo" = "您想以 **%1$@**的价格解锁 **%3$@** 中的%2$@吗?"; +"Stars.Transfer.UnlockInfo" = "您是否要花费 **%3$@** 解锁 **%2$@** 中的 %1$@?"; +"Stars.Transfer.UnlockedText" = "您已花费 **%1$@** 解锁媒体。"; +"Stars.Transfer.Videos#other" = "%@ 段视频"; +"Stars.Withdraw.AmountPlaceholder" = "星星数量"; +"Stars.Withdraw.AmountTitle" = "输入提款金额"; +"Stars.Withdraw.Title" = "提现"; +"Stars.Withdraw.Withdraw" = "提现"; +"Stars.Withdraw.Withdraw.ErrorMinimum" = "星星不足 [%@ 颗](),无法提取。"; +"Stars.Withdraw.Withdraw.ErrorMinimum.Stars#other" = "%@ 颗星星"; +"Stars.Withdraw.Withdraw.ErrorTimeout" = "**%@** 后可以进行下次提现。"; +"StarsBalance.ChannelBalance" = "您的频道余额为 %@"; +"StarsBalance.GetMoreStars" = "获取更多星星 >"; +"StarsBalance.YourBalance" = "你的余额为 %@"; +"StarsTransaction.Commission" = "佣金"; +"StarsTransaction.StarRefReason.Affiliate" = "联盟"; +"StarsTransaction.StarRefReason.Commission" = "佣金"; +"StarsTransaction.StarRefReason.Miniapp" = "小程序"; +"StarsTransaction.StarRefReason.Program" = "联盟计划"; +"StarsTransaction.StarRefReason.Referred" = "您推荐的用户"; +"StarsTransaction.StarRefReason.Title" = "事由"; +"StarsTransaction.TitleCommission" = "%@ 佣金"; +"State.Connecting" = "连接中…"; +"State.ConnectingToProxy" = "正在连接代理…"; +"State.ConnectingToProxyInfo" = "点击此处以设置"; +"State.Updating" = "正在刷新…"; +"State.WaitingForNetwork" = "等待连接网络"; +"State.connecting" = "正在连接"; +"Stats.Boosts" = "助推"; +"Stats.Boosts.Boosters#other" = "%@ 位助推者"; +"Stats.Boosts.BoostersInfo" = "您的频道目前受到这些用户的助推。"; +"Stats.Boosts.BoostersNone" = "助推者"; +"Stats.Boosts.Boosts#other" = "%@ 次助推"; +"Stats.Boosts.BoostsNone" = "助推"; +"Stats.Boosts.BoostsToLevelUp" = "助推以提升等级"; +"Stats.Boosts.ExistingBoosts" = "活跃助推"; +"Stats.Boosts.ExpiresOn" = "助推将于 %@ 到期"; +"Stats.Boosts.GetBoosts" = "通过赠品助推"; +"Stats.Boosts.GetBoostsInfo" = "通过向您的订阅者赠予 Telegram 高级版来为您的频道获取更多助推。"; +"Stats.Boosts.Gift" = "礼物"; +"Stats.Boosts.Giveaway" = "抽奖活动"; +"Stats.Boosts.Group.BoostersInfo" = "您的群组正在由这些用户助推。"; +"Stats.Boosts.Group.GetBoostsInfo" = "通过向您的成员赠送 Telegram 高级版来为您的群组获得更多助推。"; +"Stats.Boosts.Group.LinkInfo" = "与您的成员分享此链接以获得更多助推。"; +"Stats.Boosts.Group.NoBoostersYet" = "目前没有用户助推您的群组"; +"Stats.Boosts.Level" = "等级"; +"Stats.Boosts.LinkHeader" = "助推链接"; +"Stats.Boosts.LinkInfo" = "将此链接分享给您的订阅者,以得到更多助推。"; +"Stats.Boosts.NoBoostersYet" = "目前没有用户助推您的频道"; +"Stats.Boosts.OverviewHeader" = "概况"; +"Stats.Boosts.PremiumMembers" = "高级版会员"; +"Stats.Boosts.PremiumSubscribers" = "Telegram 高级版订阅者"; +"Stats.Boosts.PrepaidGiveawayCount#other" = "%@ 个 Telegram 高级版订阅"; +"Stats.Boosts.PrepaidGiveawayMonths" = "%@ 个月的订阅"; +"Stats.Boosts.PrepaidGiveawaysInfo" = "请选择您已支付的赠品活动以进行设定。"; +"Stats.Boosts.PrepaidGiveawaysTitle" = "预付抽奖活动"; +"Stats.Boosts.ShortMonth" = "%@ 个月"; +"Stats.Boosts.ShowMoreBoosts#other" = "显示 %@ 个其他助推"; +"Stats.Boosts.Stars#other" = "%@ 颗星星"; +"Stats.Boosts.StarsWinners#other" = "给 %@ 名获奖者"; +"Stats.Boosts.TabBoosts#other" = "%@ 次助推"; +"Stats.Boosts.TabGifts#other" = "%@ 份奖品"; +"Stats.Boosts.ToBeDistributed" = "待分发"; +"Stats.Boosts.TooltipToBeDistributed" = "抽奖活动结束后将选择接收者。"; +"Stats.Boosts.Unclaimed" = "尚未领取"; +"Stats.EnabledNotifications" = "启用通知"; +"Stats.Followers" = "订阅者"; +"Stats.FollowersBySourceTitle" = "新订阅者的来源"; +"Stats.FollowersTitle" = "订阅者"; +"Stats.GroupActionsTitle" = "操作"; +"Stats.GroupGrowthTitle" = "增长"; +"Stats.GroupLanguagesTitle" = "成员主要语言"; +"Stats.GroupMembers" = "成员"; +"Stats.GroupMembersTitle" = "群组成员"; +"Stats.GroupMessages" = "消息"; +"Stats.GroupMessagesTitle" = "消息"; +"Stats.GroupNewMembersBySourceTitle" = "新成员的来源"; +"Stats.GroupOverview" = "概况"; +"Stats.GroupPosters" = "发布消息的成员"; +"Stats.GroupShowMoreTopAdmins#other" = "显示 %@ 条更多消息"; +"Stats.GroupShowMoreTopInviters#other" = "显示 %@ 条更多信息"; +"Stats.GroupShowMoreTopPosters#other" = "显示 %@ 条更多信息"; +"Stats.GroupTopAdmin.Actions" = "操作"; +"Stats.GroupTopAdmin.Promote" = "提权"; +"Stats.GroupTopAdminBans#other" = "%@ 次限制"; +"Stats.GroupTopAdminDeletions#other" = "%@ 次删除"; +"Stats.GroupTopAdminKicks#other" = "%@ 次封禁"; +"Stats.GroupTopAdminsTitle" = "活跃管理员"; +"Stats.GroupTopHoursTitle" = "活跃时间"; +"Stats.GroupTopInviter.History" = "历史"; +"Stats.GroupTopInviter.Promote" = "提权"; +"Stats.GroupTopInviterInvites#other" = "%@ 次邀请"; +"Stats.GroupTopInvitersTitle" = "活跃邀请者"; +"Stats.GroupTopPoster.History" = "历史记录"; +"Stats.GroupTopPoster.Promote" = "提权"; +"Stats.GroupTopPosterChars#other" = "每条消息 %@ 个字符"; +"Stats.GroupTopPosterMessages#other" = "%@ 条消息"; +"Stats.GroupTopPostersTitle" = "活跃成员"; +"Stats.GroupTopWeekdaysTitle" = "本周活跃日"; +"Stats.GroupViewers" = "浏览消息的成员"; +"Stats.GrowthTitle" = "增长"; +"Stats.InstantViewInteractionsTitle" = "即时预览互动"; +"Stats.InteractionsTitle" = "互动"; +"Stats.LanguagesTitle" = "语言"; +"Stats.LoadingText" = "生成数据中,请稍等。"; +"Stats.LoadingTitle" = "图表准备中"; +"Stats.Message.PrivateShares" = "私密分享"; +"Stats.Message.PublicShares" = "公开分享"; +"Stats.Message.Reactions" = "表情回应"; +"Stats.Message.Views" = "浏览次数"; +"Stats.MessageForwards#other" = "%@ 次分享"; +"Stats.MessageInteractionsTitle" = "互动"; +"Stats.MessageOverview" = "概况"; +"Stats.MessagePublicForwardsTitle" = "公开分享"; +"Stats.MessageReactionsTitle" = "表情回应"; +"Stats.MessageTitle" = "消息数据分析"; +"Stats.MessageViews#other" = "%@ 次浏览"; +"Stats.MessageViews.NoViews" = "无人查看"; +"Stats.Monetization" = "营利"; +"Stats.NotificationsTitle" = "通知"; +"Stats.Overview" = "概况"; +"Stats.PostsTitle" = "** 最近文章 **"; +"Stats.ReactionsByEmotionTitle" = "按情绪分类的回应"; +"Stats.ReactionsPerPost" = "每则帖文的回应次数"; +"Stats.ReactionsPerStory" = "每条动态的表情回应"; +"Stats.RevenueInStars" = "收入(以星星计算)"; +"Stats.RevenueInTon" = "收入(以 Gram 计算)"; +"Stats.RevenueInUsd" = "收入(以美元计算)"; +"Stats.SharesPerPost" = "每则贴文的分享次数"; +"Stats.SharesPerStory" = "每条动态的分享次数"; +"Stats.Statistics" = "统计"; +"Stats.StoryInteractionsTitle" = "动态互动"; +"Stats.StoryReactionsByEmotionTitle" = "按情绪分类的动态回应"; +"Stats.StoryTitle" = "动态统计数据"; +"Stats.TonBotRevenue.Title" = "Gram 余额"; +"Stats.Total" = "总计"; +"Stats.ViewsByHoursTitle" = "每小时浏览次数 (UTC)"; +"Stats.ViewsBySourceTitle" = "浏览来源"; +"Stats.ViewsPerPost" = "每则贴文的查看次数"; +"Stats.ViewsPerStory" = "每条动态的查看次数"; +"Stats.ZoomOut" = "缩小"; +"StickerPack.Add" = "添加"; +"StickerPack.AddEmojiCount#other" = "添加 %@ 个表情"; +"StickerPack.AddEmojiPacksCount#other" = "添加 %@ 个表情包"; +"StickerPack.AddExisting" = "添加现有贴纸"; +"StickerPack.AddMaskCount#other" = "添加 %@ 个装饰"; +"StickerPack.AddSticker" = "添加贴纸"; +"StickerPack.AddStickerCount#other" = "添加 %@ 个表情"; +"StickerPack.BuiltinPackName" = "伟大的思想"; +"StickerPack.CopyLink" = "拷贝链接"; +"StickerPack.CopyLinks" = "拷贝链接"; +"StickerPack.CreateNew" = "创建新贴纸"; +"StickerPack.Delete" = "删除"; +"StickerPack.Delete.Delete" = "删除"; +"StickerPack.Delete.DeleteForEveyone" = "为所有人删除"; +"StickerPack.Delete.RemoveForMe" = "为我删除"; +"StickerPack.Delete.Text" = "这将为所有用户删除此贴纸包。"; +"StickerPack.Delete.Title" = "删除贴纸包"; +"StickerPack.EditInfo" = "查看 [@stickers**]() 机器人以获取更多选项。"; +"StickerPack.EditName" = "编辑名称"; +"StickerPack.EditName.Text" = "为您的贴纸包选择新名字。"; +"StickerPack.EditName.Title" = "编辑贴纸包"; +"StickerPack.EditStickers" = "编辑贴纸"; +"StickerPack.EmojiCount#other" = "%@ 个表情"; +"StickerPack.ErrorNotFound" = "抱歉,此表情包似乎不存在。"; +"StickerPack.HideStickers" = "隐藏这些表情"; +"StickerPack.MaskCount#other" = "%@ 个面具"; +"StickerPack.RemoveEmojiCount#other" = "移除 %@ 个表情"; +"StickerPack.RemoveEmojiPacksCount#other" = "移除 %@ 个表情包"; +"StickerPack.RemoveMaskCount#other" = "移除 %@ 个装饰"; +"StickerPack.RemoveStickerCount#other" = "移除 %@ 个表情"; +"StickerPack.RemoveStickerSet" = "移除贴纸包"; +"StickerPack.Reorder" = "重新排序"; +"StickerPack.Send" = "发送表情"; +"StickerPack.Share" = "分享"; +"StickerPack.ShowStickers" = "显示这些表情"; +"StickerPack.StickerAdded" = "贴纸添加到 %@ 贴纸包。"; +"StickerPack.StickerCount#other" = "%@ 张贴图"; +"StickerPack.StickerUpdated" = "贴纸已更新。"; +"StickerPack.ViewPack" = "查看贴纸包"; +"StickerPackActionInfo.AddedText" = "%@ 已添加到您的贴纸包。"; +"StickerPackActionInfo.AddedTitle" = "已添加表情"; +"StickerPackActionInfo.ArchivedTitle" = "贴纸包已归档"; +"StickerPackActionInfo.MultipleRemovedText#other" = "%@ 包已不在你的贴纸中。"; +"StickerPackActionInfo.RemovedText" = "%@已不在你的贴纸包里"; +"StickerPackActionInfo.RemovedTitle" = "贴纸已移除"; +"StickerPacks.ActionArchive" = "归档"; +"StickerPacks.ActionDelete" = "删除"; +"StickerPacks.ActionShare" = "分享"; +"StickerPacks.ActionUnarchive" = "取消归档"; +"StickerPacks.ArchiveStickerPacksConfirmation#other" = "归档 %@ 个贴纸包"; +"StickerPacks.DeleteEmojiPacksConfirmation#other" = "删除 %@ 个表情包"; +"StickerPacks.DeleteStickerPacksConfirmation#other" = "删除 %@ 个表情包"; +"StickerPacks.UnarchiveStickerPacksConfirmation#other" = "取消归档 %@ 个贴纸包"; +"StickerPacksSettings.AnimatedStickers" = "循环播放动态贴纸"; +"StickerPacksSettings.AnimatedStickersInfo" = "动态贴纸将在聊天中重复循环。"; +"StickerPacksSettings.ArchivedMasks" = "已归档装饰"; +"StickerPacksSettings.ArchivedMasks.Info" = "您最多能够添加 200 套装饰包。\n未被使用的装饰包将在您添加新装饰包时被归档。"; +"StickerPacksSettings.ArchivedPacks" = "已归档贴纸包"; +"StickerPacksSettings.ArchivedPacks.Info" = "您最多可以添加 200 套贴图包。\n未被使用的贴图包将在您添加新贴图包时被归档。"; +"StickerPacksSettings.DynamicOrder" = "贴纸包动态顺序"; +"StickerPacksSettings.DynamicOrderInfo" = "最近使用的贴纸包将显示在旧的贴纸包上方。"; +"StickerPacksSettings.DynamicOrderOff" = "关闭动态排序"; +"StickerPacksSettings.DynamicOrderOffInfo" = "当您发送贴纸时,贴纸包将不会被重新排列。"; +"StickerPacksSettings.Emoji" = "表情"; +"StickerPacksSettings.FeaturedPacks" = "热门表情"; +"StickerPacksSettings.ManagingHelp" = "艺术家可以自由地使用机器人 @stickers 创建自己的表情包。\n\n点击一个表情可以对其进行预览,并添加其所属的整套表情包。"; +"StickerPacksSettings.MyStickers" = "我的贴纸"; +"StickerPacksSettings.ShowStickersButton" = "显示「表情」标签页"; +"StickerPacksSettings.ShowStickersButtonHelp" = "开启后,输入框内将出现一个「表情」图标。"; +"StickerPacksSettings.StickerPacksSection" = "表情包"; +"StickerPacksSettings.SuggestAnimatedEmoji" = "推荐动态表情"; +"StickerPacksSettings.SuggestAnimatedEmojiInfo" = "每次输入表情时,您都可以将其替换为动画表情。"; +"StickerPacksSettings.Title" = "贴纸与表情"; +"StickerPacksSettings.TrendingStickers" = "热门贴纸"; +"StickerPicker.PollSubtitle.PollOption" = "向此选项添加贴纸"; +"StickerPicker.Title" = "贴纸"; +"StickerSettings.ContextHide" = "归档"; +"StickerSettings.ContextInfo" = "如果您归档了一套贴纸包,您可以在「已归档贴纸包」选项中快速将其恢复。"; +"StickerSettings.EmojiContextInfo" = "如果您将一个表情包归档,您可以在以后从归档的表情包里快速恢复它。"; +"StickerSettings.MaskContextInfo" = "如果您归档了一套装饰包,您可以从「已归档装饰包」选项中快速将其恢复。"; +"Stickers.AddToFavorites" = "添加到收藏"; +"Stickers.ChooseSticker.Title" = "选择一个贴纸"; +"Stickers.ClearRecent" = "清除最近的表情"; +"Stickers.CreateSticker" = "创建\n贴纸"; +"Stickers.Delete" = "删除"; +"Stickers.Delete.ForEveryone" = "为所有人删除"; +"Stickers.Edit" = "编辑"; +"Stickers.EditSticker" = "编辑贴纸"; +"Stickers.EmojiPackInfoText" = "该消息包含%@ 表情符号"; +"Stickers.FavoriteStickers" = "贴纸收藏"; +"Stickers.Favorites" = "收藏"; +"Stickers.FrequentlyUsed" = "最近使用"; +"Stickers.Gifs" = "GIF"; +"Stickers.GroupChooseStickerPack" = "选择贴纸包"; +"Stickers.GroupStickers" = "群组贴纸包"; +"Stickers.GroupStickersHelp" = "您可以选择一套贴纸包,所有群组成员在聊天时均可使用该贴纸包。"; +"Stickers.Install" = "添加"; +"Stickers.Installed" = "添加"; +"Stickers.NoStickersFound" = "没有符合条件的贴纸包"; +"Stickers.PremiumPackInfoText" = "该贴纸包包含高级版贴纸,譬如该贴纸。"; +"Stickers.PremiumPackView" = "查看"; +"Stickers.PremiumStickers" = "高级版贴纸"; +"Stickers.Recent" = "最近"; +"Stickers.RemoveFromFavorites" = "从收藏中移除"; +"Stickers.RemoveFromRecent" = "从最近使用过的清单中移除"; +"Stickers.Reorder" = "重新排序"; +"Stickers.Search" = "搜索贴纸"; +"Stickers.Settings" = "设置"; +"Stickers.ShowMore" = "显示更多"; +"Stickers.Stickers" = "贴纸"; +"Stickers.SuggestAdded" = "我的表情包"; +"Stickers.SuggestAll" = "全部"; +"Stickers.SuggestNone" = "不推荐"; +"Stickers.SuggestStickers" = "根据表情联想"; +"Stickers.Trending" = "热门"; +"Stickers.TrendingPremiumStickers" = "热门高级版贴纸"; +"StickersList.ArchivedEmojiItem" = "已归档表情包"; +"StickersList.EmojiItem" = "自定义表情"; +"StickersSearch.SearchStickersPlaceholder" = "搜索贴纸"; +"StorageManagement.AutoremoveDescription" = "在此期间你**未曾访问过的**云端聊天中的照片、视频和其他文件将从此设备中删除,以节省磁盘空间。"; +"StorageManagement.AutoremoveHeader" = "自动删除已缓存媒体"; +"StorageManagement.AutoremoveSpaceDescription" = "如果缓存大小超过此限制,将从此设备中删除最早的未曾访问的媒体文件。"; +"StorageManagement.ClearAll" = "清除缓存"; +"StorageManagement.ClearCache" = "清除缓存"; +"StorageManagement.ClearConfirmationText" = "媒体和文件将保留在云端中,你需要时,可以重新下载。"; +"StorageManagement.ClearSelected" = "清除所选"; +"StorageManagement.ContextDeselect" = "取消选中"; +"StorageManagement.ContextSelect" = "选择"; +"StorageManagement.DescriptionAppUsage" = "Telegram 占用了你 %1$@% 的可用设备储存空间。"; +"StorageManagement.DescriptionChatUsage" = "此聊天使用 Telegram 缓存的 %1$@%。"; +"StorageManagement.DescriptionCleared" = "所有媒体都可在你再次需要时重新从 Telegram 云端下載。"; +"StorageManagement.OpenFile" = "打开文件"; +"StorageManagement.OpenPhoto" = "打开图片"; +"StorageManagement.OpenVideo" = "打开视频"; +"StorageManagement.PeerOpenProfile" = "打开个人资料"; +"StorageManagement.PeerShowDetails" = "显示详情"; +"StorageManagement.SectionAvatars" = "头像"; +"StorageManagement.SectionCalls" = "通话"; +"StorageManagement.SectionFiles" = "文件"; +"StorageManagement.SectionMessages" = "消息"; +"StorageManagement.SectionMiscellaneous" = "杂项"; +"StorageManagement.SectionMusic" = "音乐"; +"StorageManagement.SectionOther" = "其他"; +"StorageManagement.SectionPhotos" = "图片"; +"StorageManagement.SectionStickers" = "贴纸"; +"StorageManagement.SectionStories" = "动态"; +"StorageManagement.SectionVideos" = "视频"; +"StorageManagement.SectionVoiceMessages" = "语音消息"; +"StorageManagement.SectionsDescription" = "所有媒体文件将保留在 Telegram 云端,需要时可以重新下载。"; +"StorageManagement.TabChats" = "聊天"; +"StorageManagement.TabFiles" = "文件"; +"StorageManagement.TabMedia" = "媒体"; +"StorageManagement.TabMusic" = "音乐"; +"StorageManagement.Title" = "存储使用"; +"StorageManagement.TitleCleared" = "存储已清除"; +"Stories.AddStories#other" = "添加 %@ 个动态"; +"Stories.AddStoriesEmpty" = "添加动态"; +"Stories.AddStoriesTitle" = "添加动态"; +"Stories.AlbumAdd" = "添加相册"; +"Stories.AlbumAll" = "所有动态"; +"Stories.AlbumEmptyButton" = "添加到相册里"; +"Stories.AlbumEmptyText" = "将您的一些动态添加到此相册里。"; +"Stories.AlbumEmptyTitle" = "整理您的动态"; +"Stories.CreateAlbum.Placeholder" = "标题"; +"Stories.CreateAlbum.Text" = "为您的相册起一个标题并添加您的动态。"; +"Stories.CreateAlbum.Title" = "新建相册"; +"Stories.DeleteAlbum.Confirmation" = "是否删除 %@?"; +"Stories.EditAlbum.Text" = "为您的相册选出标题。"; +"Stories.EditAlbum.Title" = "编辑相册标题"; +"Stories.MenuAddToAlbum" = "添加到相册里"; +"Stories.MenuDeleteAlbum" = "删除相册"; +"Stories.MenuNewAlbum" = "新相册"; +"Stories.MenuRemoveFromAlbum" = "从相册里移除"; +"Stories.MenuRenameAlbum" = "编辑相册标题"; +"Stories.Post.Album" = "相册"; +"Stories.Post.AlbumAll" = "所有动态"; +"Stories.Post.AlbumCount#other" = "%@ 个相册"; +"Stories.Post.AlbumFooter" = "选择您想要分享动态的相册。"; +"Stories.StatusEmpty" = "暂无动态"; +"Stories.ToastAddedToFolder#other" = "%@ 个动态一添加至相册里。"; +"Stories.ToastAlbumNotAvailable" = "相册已不存在。"; +"Story.AlertStealthModeActiveAction" = "继续"; +"Story.AlertStealthModeActiveText" = "如果您发送回复或表情回应,动态的作者会在访客列表中看到您。"; +"Story.AlertStealthModeActiveTitle" = "您已开启隐身模式"; +"Story.Camera.AccessOpenSettings" = "打开设置"; +"Story.Camera.AccessPlaceholderText" = "这能让您分享照片并录制视频。"; +"Story.Camera.AccessPlaceholderTitle" = "允许 Telegram 访问您的摄像头和麦克风"; +"Story.Camera.Live" = "直播"; +"Story.Camera.Photo" = "照片"; +"Story.Camera.SwipeLeftRelease" = "松开以锁定"; +"Story.Camera.SwipeLeftToLock" = "向左滑动以锁定"; +"Story.Camera.SwipeRightToFlip" = "向右滑动以翻转"; +"Story.Camera.SwipeUpToZoom" = "向上滑动以缩放"; +"Story.Camera.TooltipDisableDual" = "点击此处\n停用前置摄像头"; +"Story.Camera.TooltipDraftSaved" = "已保存草稿"; +"Story.Camera.TooltipTakePhotos" = "拍摄照片或视频,并立即与\n所有联系人或亲朋好友分享。"; +"Story.Camera.Video" = "视频"; +"Story.CaptionShowMore" = "显示更多"; +"Story.Context.CopyLink" = "拷贝链接"; +"Story.Context.Edit" = "编辑动态"; +"Story.Context.EditCover" = "编辑封面"; +"Story.Context.EmbeddedEmojiPack" = "这条动态包含\n#[%@]() 的表情。"; +"Story.Context.EmbeddedStickerPack" = "这条动态包含 #[%@]() 的贴纸。"; +"Story.Context.EmbeddedStickers" = "这条动态包含来自 [%@]() 的贴纸。"; +"Story.Context.EmbeddedStickersValue#other" = "%d 个贴纸包"; +"Story.Context.Privacy" = "谁能查看"; +"Story.Context.RemoveFromChannel" = "从动态中删除"; +"Story.Context.RemoveFromProfile" = "从个人资料页移除"; +"Story.Context.Report" = "举报"; +"Story.Context.SaveToChannel" = "发布至个人资料页"; +"Story.Context.SaveToGallery" = "保存到相册"; +"Story.Context.SaveToProfile" = "发布至个人资料页"; +"Story.Context.Share" = "分享"; +"Story.Context.TooltipPremiumSaveStories" = "订阅 [Telegram 高级版]() 后可以保存其他用户未开启保护的动态。"; +"Story.Context.ViewStats" = "查看统计数据"; +"Story.ContextDeleteContact" = "删除联系人"; +"Story.ContextDeleteStory" = "删除动态"; +"Story.ContextHideStoriesFrom" = "对 %@ 隐藏我的动态"; +"Story.ContextMenuHD" = "提升画质"; +"Story.ContextMenuLiveSettings" = "直播设置"; +"Story.ContextMenuPip" = "最小化\n"; +"Story.ContextMenuSD" = "降低画质"; +"Story.ContextPrivacy.LabelCloseFriends" = "密友"; +"Story.ContextPrivacy.LabelContacts" = "我的联系人"; +"Story.ContextPrivacy.LabelContactsExcept" = "联系人(-%@)"; +"Story.ContextPrivacy.LabelEveryone" = "所有人"; +"Story.ContextPrivacy.LabelOnlyMe" = "仅自己"; +"Story.ContextPrivacy.LabelOnlySelected#other" = "%d 位用户"; +"Story.ContextShowStoriesTo" = "不要对 %@ 隐藏我的动态"; +"Story.ContextStealthMode" = "隐藏我的浏览记录"; +"Story.Cover" = "动态封面"; +"Story.Editor.Add" = "添加"; +"Story.Editor.Blur.Linear" = "线性"; +"Story.Editor.Blur.Off" = "关闭"; +"Story.Editor.Blur.Portrait" = "肖像"; +"Story.Editor.Blur.Radial" = "圆形"; +"Story.Editor.Blur.Title" = "模糊"; +"Story.Editor.Curves.All" = "全部"; +"Story.Editor.Curves.Blue" = "蓝"; +"Story.Editor.Curves.Green" = "绿"; +"Story.Editor.Curves.Red" = "红"; +"Story.Editor.DiscardText" = "如果您现在返回,您将丢失所做的所有更改。"; +"Story.Editor.Done" = "完成"; +"Story.Editor.DraftDiscaedText" = "如果没有保存就返回,您将丢失所做的任何更改。"; +"Story.Editor.DraftDiscard" = "放弃"; +"Story.Editor.DraftDiscardDraft" = "是否放弃草稿?"; +"Story.Editor.DraftDiscardMedia" = "是否放弃更改?"; +"Story.Editor.DraftKeepDraft" = "保留草稿"; +"Story.Editor.DraftKeepMedia" = "保存草稿"; +"Story.Editor.ExpirationText" = "选择动态的可见时长。"; +"Story.Editor.ExpirationValue#other" = "%d 小时"; +"Story.Editor.InputPlaceholderAddCaption" = "添加说明文字…"; +"Story.Editor.Next" = "下一步"; +"Story.Editor.PreparingVideo" = "正在准备视频…"; +"Story.Editor.Tint.Highlights" = "高光"; +"Story.Editor.Tint.Shadows" = "阴影"; +"Story.Editor.Tool.Brightness" = "亮度"; +"Story.Editor.Tool.Contrast" = "对比度"; +"Story.Editor.Tool.Enhance" = "增强"; +"Story.Editor.Tool.Fade" = "褪色"; +"Story.Editor.Tool.Grain" = "噪点"; +"Story.Editor.Tool.Highlights" = "高光"; +"Story.Editor.Tool.Saturation" = "饱和度"; +"Story.Editor.Tool.Shadows" = "阴影"; +"Story.Editor.Tool.Vignette" = "晕影"; +"Story.Editor.Tool.Warmth" = "色温"; +"Story.Editor.TooltipImageSavedToPhotos" = "图片已保存至“照片”。"; +"Story.Editor.TooltipLinkLimitValue#other" = "**%@** 个链接"; +"Story.Editor.TooltipLinkPremium" = "订阅 [Telegram 高级版]() 后可在动态中添加链接。"; +"Story.Editor.TooltipMuted" = "此动态将不会播放声音。"; +"Story.Editor.TooltipMutedWithAudio" = "原始音频将被移除"; +"Story.Editor.TooltipPremiumCaptionEntities" = "订阅 [Telegram 高级版]() 后可以在动态说明文字中添加链接和格式。"; +"Story.Editor.TooltipPremiumCaptionLimitText" = "订阅 [Telegram 高级版]() 后可将此上限提高 10 倍至 2048 个字符。"; +"Story.Editor.TooltipPremiumCaptionLimitTitle" = "已达最大长度上限"; +"Story.Editor.TooltipPremiumExpiration" = "订阅 [Telegram 高级版]() 后,可以让您的动态在 6、12 或 48 小时后消失。"; +"Story.Editor.TooltipPremiumMore" = "更多"; +"Story.Editor.TooltipPremiumReaction" = "订阅 [Telegram 高级版]() 即可使用此表情回应。"; +"Story.Editor.TooltipPremiumReactionLimitText" = "订阅 **Telegram 高级版** 既可为一条动态添加多达 %@ 个回应贴纸。"; +"Story.Editor.TooltipPremiumReactionLimitTitle" = "提高上限"; +"Story.Editor.TooltipPremiumReactionLimitValue#other" = "**%@** 个回应贴纸"; +"Story.Editor.TooltipReachedLinkLimitText" = "动态最多只能添加其他 %@ 个链接。"; +"Story.Editor.TooltipReachedReactionLimitText" = "您不能为一条动态添加超过 %@。"; +"Story.Editor.TooltipReachedReactionLimitTitle" = "已达上限"; +"Story.Editor.TooltipSelection#other" = "点击此处查看您的 %@ 个动态"; +"Story.Editor.TooltipUnmuted" = "此动态将播放声音。"; +"Story.Editor.TooltipUnmutedWithAudio" = "原始音频将被保留"; +"Story.Editor.TooltipVideoSavedToPhotos" = "视频已保存至“照片”。"; +"Story.Editor.TooltipWeatherLimitText" = "一条动态中不能添加多个天气贴纸。"; +"Story.Editor.Uploading" = "正在上传…"; +"Story.Editor.VideoTooShort" = "视频时长必须至少 1 秒。"; +"Story.Footer.NoViews" = "还没有人看过"; +"Story.Footer.Uploading" = "正在上传…"; +"Story.Footer.ViewCount#other" = "|%d| 次浏览"; +"Story.Footer.Views#other" = "%d 次浏览"; +"Story.FooterReplyUnavailable" = "您无法回复此动态"; +"Story.ForwardAuthorHiddenTooltip" = "该帐号已被用户隐藏"; +"Story.GroupCommentingRestrictedPlaceholder" = "评论受到限制,助推此群组可解锁。"; +"Story.GroupCommentingRestrictedPlaceholderAction" = "了解更多..."; +"Story.Guide.BackDescription" = "点击左侧边缘"; +"Story.Guide.BackTitle" = "返回"; +"Story.Guide.Description" = "您可以使用这些手势控制播放。"; +"Story.Guide.ForwardDescription" = "点击屏幕"; +"Story.Guide.ForwardTitle" = "向前"; +"Story.Guide.MoveDescription" = "向左或向右滑动"; +"Story.Guide.MoveTitle" = "在动态之间切换"; +"Story.Guide.PauseDescription" = "按住并向左边或右边滑动"; +"Story.Guide.PauseTitle" = "暂停并搜索"; +"Story.Guide.Proceed" = "点击以继续查看"; +"Story.Guide.Title" = "查看动态"; +"Story.HeaderEdited" = "已编辑"; +"Story.HeaderYourStory" = "您的动态"; +"Story.InputPlaceholderReplyInGroup" = "评论动态..."; +"Story.InputPlaceholderReplyPrivately" = "私信回复…"; +"Story.LiveBadge" = "直播"; +"Story.LongTapForMoreReactions" = "长按查看更多表情回应。"; +"Story.MessageReposted.Channel" = "此消息已转发到 %@。"; +"Story.MessageReposted.Personal" = "信息已转发到您的动态。"; +"Story.MessagingRestrictedPlaceholder" = "%@ 只接受来自联系人或高级版订阅用户的消息。"; +"Story.MessagingRestrictedPlaceholderAction" = "了解更多..."; +"Story.PremiumUpgradeStoriesButton" = "升级动态"; +"Story.Privacy.AllowScreenshots" = "允许截图"; +"Story.Privacy.CategoryCloseFriends" = "密友"; +"Story.Privacy.CategoryContacts" = "我的联系人"; +"Story.Privacy.CategoryEveryone" = "所有人"; +"Story.Privacy.CategorySelectedContacts" = "选定的用户"; +"Story.Privacy.Choose" = "选择"; +"Story.Privacy.ChooseCover" = "更改动态封面"; +"Story.Privacy.ChooseCoverChannelInfo" = "从动态中选择一帧显示在频道资料页。"; +"Story.Privacy.ChooseCoverGroupInfo" = "从动态中选择一帧显示在群组资料页。"; +"Story.Privacy.ChooseCoverInfo" = "从该动态中选择一帧显示在您的个人资料页。"; +"Story.Privacy.ContactsHeader" = "联系人"; +"Story.Privacy.EditList" = "编辑列表"; +"Story.Privacy.EditStory" = "编辑动态"; +"Story.Privacy.ExcludePeople" = "排除用户"; +"Story.Privacy.ExcludePeopleExcept#other" = "除了 %@ 位用户"; +"Story.Privacy.ExcludePeopleExceptNames" = "除了 %@"; +"Story.Privacy.ExcludedPeople" = "排除的联系人"; +"Story.Privacy.GrayListPeople#other" = "%@ 人"; +"Story.Privacy.GrayListSelect" = "[选择用户]() 使其永远无法看到您的动态。"; +"Story.Privacy.GrayListSelected" = "[%@]() 将永远无法看到您的任何动态。"; +"Story.Privacy.GroupParticipantsLimit" = "您可以选择最多有 200 名成员的群组。"; +"Story.Privacy.GroupTooLarge" = "群组过大"; +"Story.Privacy.HideMyStoriesFrom" = "对谁隐藏我的动态"; +"Story.Privacy.KeepOnChannelPage" = "在频道资料页上发布"; +"Story.Privacy.KeepOnChannelPageInfo" = "即使该动态在 %@ 小时后过期,仍将其保留在频道资料页上。"; +"Story.Privacy.KeepOnChannelPageManyInfo" = "即使在 %@ 后过期,这些动态仍会保留在频道资料页上。"; +"Story.Privacy.KeepOnGroupPage" = "发布到群组页面"; +"Story.Privacy.KeepOnGroupPageInfo" = "即使动态 %@ 后到期,仍将其保留在群组资料页上。"; +"Story.Privacy.KeepOnGroupPageManyInfo" = "即使在 %@ 后过期,这些动态仍会保留在群组资料页上。"; +"Story.Privacy.KeepOnMyPage" = "发布至我的个人资料页"; +"Story.Privacy.KeepOnMyPageHours#other" = "%@ 小时"; +"Story.Privacy.KeepOnMyPageInfo" = "即便此动态在 %@ 小时后过期,也依然将其保留在您的个人资料中,将应用隐私设置。"; +"Story.Privacy.KeepOnMyPageManyInfo" = "即使在 %@ 后过期,这些动态仍会保留在您的个人资料页上,隐私设置将继续适用。"; +"Story.Privacy.MentionRestrictedProceed" = "仍然继续"; +"Story.Privacy.MentionRestrictedText" = "您的动态隐私设置将导致某些您标记的用户(%@)无法查看该动态。"; +"Story.Privacy.MentionRestrictedTitle" = "隐私限制"; +"Story.Privacy.People#other" = "%@ 位用户"; +"Story.Privacy.PostStories#other" = "发布 %@ 条动态"; +"Story.Privacy.PostStoriesAsHeader" = "以哪个身份发布动态"; +"Story.Privacy.PostStory" = "发布动态"; +"Story.Privacy.PostStoryAs" = "以其他身份发布动态"; +"Story.Privacy.PostStoryAsHeader" = "已哪个身份发布动态"; +"Story.Privacy.SaveList" = "保存列表"; +"Story.Privacy.SaveSettings" = "保存设置"; +"Story.Privacy.SearchChats" = "搜索聊天"; +"Story.Privacy.SearchContacts" = "搜索联系人"; +"Story.Privacy.ShareStories" = "分享动态"; +"Story.Privacy.ShareStory" = "分享动态"; +"Story.Privacy.StartLiveAs" = "以哪个身份发起直播"; +"Story.Privacy.TooltipSharingDisabled" = "此动态将禁止下载及截图。"; +"Story.Privacy.TooltipSharingDisabledPublic" = "此动态将禁止下载、分享及截图。"; +"Story.Privacy.TooltipSharingEnabled" = "此动态将允许下载及截图。"; +"Story.Privacy.TooltipSharingEnabledPublic" = "此动态将允许下载、分享及截图。"; +"Story.Privacy.TooltipStoryArchived" = "即使动态过期,获准查看您动态的用户仍然可以在您的个人资料上看到它。"; +"Story.Privacy.TooltipStoryArchivedChannel" = "即使动态过期,用户仍然可以在频道资料页上看到这个动态。"; +"Story.Privacy.TooltipStoryArchivedGroup" = "即使过期,用户也会在群组页面上看到这条动态。"; +"Story.Privacy.TooltipStoryExpires" = "动态将在过期后消失。"; +"Story.Privacy.WhoCanViewHeader" = "谁能看到这条动态"; +"Story.Privacy.WhoCanViewStoriesHeader" = "谁可以查看这条动态"; +"Story.PrivacyTooltipCloseFriends" = "这条动态现已展示给您的密友。"; +"Story.PrivacyTooltipContacts" = "这条动态现已展示给您所有的联系人。"; +"Story.PrivacyTooltipEveryone" = "这条动态现已展示给所有人。"; +"Story.PrivacyTooltipNobody" = "这条动态现已仅向您展示。"; +"Story.PrivacyTooltipSelectedContacts" = "此动态只向选择的用户展示。"; +"Story.PrivacyTooltipSelectedContacts.Contacts#other" = "%@ 位联系人"; +"Story.PrivacyTooltipSelectedContactsCount" = "此动态显示给 %@ 个联系人。"; +"Story.PrivacyTooltipSelectedContactsFull" = "%@ 现在可以看到此条动态。"; +"Story.SaveCover" = "保存封面"; +"Story.ScreenshotBlockedText" = "您想要截图的动态\n受到其作者的保护\n不允许复制。"; +"Story.ScreenshotBlockedTitle" = "已阻止截图"; +"Story.SendReactionAsGroupMessage" = "以消息形式发送表情回应"; +"Story.SendReactionAsMessage" = "以私密消息发送回应"; +"Story.SlideToSeek" = "向左或向右滑动以搜索"; +"Story.StealthMode.ControlText" = "开启隐身模式浏览动态后,将不会出现在访客列表中。"; +"Story.StealthMode.CooldownAction" = "%@ 后可用"; +"Story.StealthMode.EnableAction" = "启用隐身模式"; +"Story.StealthMode.EnableAndOpenAction" = "启用并打开动态"; +"Story.StealthMode.NextText" = "隐藏我在接下来 ** %@** 内的浏览记录。"; +"Story.StealthMode.NextTitle" = "隐藏之后的浏览记录"; +"Story.StealthMode.RecentText" = "隐藏我在过去 **%@** 内的浏览记录。"; +"Story.StealthMode.RecentTitle" = "隐藏最近的浏览记录"; +"Story.StealthMode.Title" = "隐身模式"; +"Story.StealthMode.ToastCooldownText" = "请等待 **隐身模式** 再次可用。"; +"Story.StealthMode.UpgradeAction" = "解锁隐身模式"; +"Story.StealthMode.UpgradeText" = "订阅 Telegram 高级版,浏览动态后,将不会出现在访客列表中。"; +"Story.StealthModeActivePlaceholder" = "隐身模式已启用 – %@"; +"Story.StealthModePlaceholder" = "隐身模式已启用 — {m}:{s}"; +"Story.ToastDeletedContact" = "**%@**\u00a0已从您的联系人中移除。"; +"Story.ToastHideStoriesFrom" = "**%@** 将无法看到您的动态。"; +"Story.ToastLinkCopied" = "链接已拷贝。"; +"Story.ToastPremiumSaveToGallery" = "订阅 [Telegram 高级版]() 后可以保存其他用户未开启保护的动态。"; +"Story.ToastQualityHD.Text" = "您可以稍后降低画质以加快下载速度。"; +"Story.ToastQualityHD.Title" = "已提升画质"; +"Story.ToastQualitySD.Text" = "现在动态的加载速度将会更快。"; +"Story.ToastQualitySD.Title" = "已降低画质"; +"Story.ToastReactionSent" = "消息已发送。"; +"Story.ToastRemovedFromChannelText" = "已从频道资料页移除动态"; +"Story.ToastRemovedFromGroupText" = "动态已从群组页面移除"; +"Story.ToastRemovedFromProfileText" = "已将动态从您的个人资料中移除"; +"Story.ToastSavedToChannelText" = "其他用户可以在频道资料页上查看已发布的动态,直到管理员将其移除。"; +"Story.ToastSavedToChannelTitle" = "动态已保存至频道资料页"; +"Story.ToastSavedToGroupText" = "发布的动态始终可以在群组页面中查看,除非管理员将其移除。"; +"Story.ToastSavedToGroupTitle" = "动态保存至群组页面上"; +"Story.ToastSavedToProfileText" = "在您手动移除前,其他人可以在您的个人资料页查看这些已发布的动态。"; +"Story.ToastSavedToProfileTitle" = "动态已发布至您的个人资料页"; +"Story.ToastShowStoriesTo" = "**%@** 将可以重新看到您的动态。"; +"Story.ToastStealthModeActivatedText" = "对于您在过去 **%1$@** 内或在接下来 **%2$@** 内查看的动态,其作者不会在访客列表中看到您。"; +"Story.ToastStealthModeActivatedTitle" = "隐身模式已开启"; +"Story.ToastStealthModeActiveText" = "在接下来的 %@ 内,您观看的动态创作者将无法在观看者列表中看到您。"; +"Story.ToastStealthModeActiveTitle" = "您已开启隐身模式"; +"Story.ToastUserBlocked" = "**%@** 已被屏蔽。"; +"Story.ToastViewInChat" = "在聊天中查看"; +"Story.TooltipExpired" = "此动态已无法访问"; +"Story.TooltipMessageScheduled" = "已设置定时消息"; +"Story.TooltipMessageSent" = "消息已发送。"; +"Story.TooltipPrivacyCloseFriends" = "您能看到这条动态是因为 %@ 将您添加到了密友列表中。"; +"Story.TooltipPrivacyCloseFriends2" = "您能看到这条动态是因为 **%@** 将您添加到了密友列表中。"; +"Story.TooltipPrivacyContacts" = "只有 %@ 的联系人可以查看此动态。"; +"Story.TooltipPrivacySelectedContacts" = "只有 %@ 选择的部分用户可以查看该动态。"; +"Story.TooltipSaved" = "已保存"; +"Story.TooltipSaving" = "正在保持"; +"Story.TooltipUnmuteVideoSound" = "点击此处以启用声音"; +"Story.TooltipVideoHasNoSound" = "这段视频没有声音"; +"Story.UnsupportedAction" = "更新 Telegram"; +"Story.UnsupportedText" = "您当前版本的 Telegram\n不支持该动态。"; +"Story.UpgradeQuality.Action" = "提升画质"; +"Story.UpgradeQuality.ActionSubtitle" = "需要高级版订阅"; +"Story.UpgradeQuality.Text" = "订阅高级版以更高分辨率查看动态。"; +"Story.UpgradeQuality.Title" = "高画质动态"; +"Story.ViewGift" = "查看礼物 "; +"Story.ViewLink" = "打开链接 "; +"Story.ViewList.ContextSortChannelInfo" = "选择表情符号回应列表的顺序。"; +"Story.ViewList.ContextSortInfo" = "选择访客名单的显示顺序。"; +"Story.ViewList.ContextSortReactions" = "表情回应优先"; +"Story.ViewList.ContextSortRecent" = "最新优先"; +"Story.ViewList.ContextSortReposts" = "按转发量排序"; +"Story.ViewList.EmptyTextContacts" = "您的联系人中无人查看过此动态。"; +"Story.ViewList.EmptyTextSearch" = "未找到浏览记录"; +"Story.ViewList.NotFullyRecorded" = "未记录其他访客的信息。"; +"Story.ViewList.PremiumUpgradeAction" = "了解更多"; +"Story.ViewList.PremiumUpgradeInlineText" = "如要解锁已过期和已发布动态的访客列表,请订阅 [Telegram 高级版]()。"; +"Story.ViewList.PremiumUpgradeText" = "动态过期后,访客列表将在 24 小时后消失。\n\n如要解锁已过期和已发布动态的访客列表,请订阅 [Telegram 高级版]()。"; +"Story.ViewList.TabTitleAll" = "所有访客"; +"Story.ViewList.TabTitleContacts" = "联系人"; +"Story.ViewList.TitleEmpty" = "无人查看"; +"Story.ViewList.TitleReactions" = "回应"; +"Story.ViewList.TitleViewers" = "访客"; +"Story.ViewList.ViewerCount#other" = "%d 人已查看"; +"Story.ViewLocation" = "查看位置"; +"Story.ViewMessage" = "查看消息"; +"Story.Views.Commented" = "•已评论"; +"Story.Views.NoReactions" = "尚未有人对您的动态作出回应。"; +"Story.Views.NoViews" = "还没有人看过\n您的动态。"; +"Story.Views.ViewsExpired" = "访客名单将在动态过期 **24 小时**后不可见。"; +"Story.Views.ViewsNotRecorded" = "未记录访客信息。"; +"StoryFeed.AddStory" = "添加动态"; +"StoryFeed.ContextAddStory" = "添加动态"; +"StoryFeed.ContextArchive" = "隐藏动态"; +"StoryFeed.ContextArchivedStories" = "已归档的动态"; +"StoryFeed.ContextNotifyOff" = "不接收动态通知"; +"StoryFeed.ContextNotifyOn" = "接收动态通知"; +"StoryFeed.ContextOpenChat" = "发送消息"; +"StoryFeed.ContextOpenProfile" = "打开个人资料"; +"StoryFeed.ContextSavedStories" = "已发布的动态"; +"StoryFeed.ContextUnarchive" = "取消隐藏动态"; +"StoryFeed.MyStory" = "我的动态"; +"StoryFeed.MyUploading" = "正在上传…"; +"StoryFeed.TooltipArchive" = "**%@**\u00a0的动态已移至归档。"; +"StoryFeed.TooltipNotifyOff" = "当\u00a0**%@**\u00a0发布动态时,您将不再收到通知。"; +"StoryFeed.TooltipNotifyOn" = "%@ 发布动态时您将收到一则通知提醒。"; +"StoryFeed.TooltipPostingDuringCall" = "您无法在通话时发布动态。"; +"StoryFeed.TooltipPostingDuringGroupCall" = "您无法在视频聊天时发布动态。"; +"StoryFeed.TooltipPremiumPosting" = "目前只有 [Telegram 高级版]() 的订阅用户可以发布动态。"; +"StoryFeed.TooltipPremiumPostingLimited" = "目前,只有 [Telegram 高级版]() 的订阅用户可以发布动态。"; +"StoryFeed.TooltipStoryLimit" = "您无法在 **24 小时内**发送超过 **%@ 条动态**。"; +"StoryFeed.TooltipStoryLimitValue#other" = "%d 条动态"; +"StoryFeed.TooltipUnarchive" = "已将 **%@** 的动态从归档中恢复。"; +"StoryFeed.ViewAnonymously" = "匿名查看"; +"StoryGridScreen.TitleLocationSearch" = "位置"; +"StoryList.ActionPanel.Archive" = "归档"; +"StoryList.ActionPanel.Delete" = "删除"; +"StoryList.ActionPanel.Pin" = "置顶"; +"StoryList.ActionPanel.Unarchive" = "取消归档"; +"StoryList.ActionPanel.Unpin" = "取消置顶"; +"StoryList.ArchiveDescription" = "除非将其发布至个人资料页,否则只有您能查看已归档的动态。"; +"StoryList.ArchivedEmptyState.Text" = "上传一条新的动态并在这里查看。"; +"StoryList.ArchivedEmptyState.Title" = "没有归档的动态"; +"StoryList.ContextSaveToGallery" = "保存至“照片”"; +"StoryList.ContextShowArchive" = "显示归档"; +"StoryList.DeleteConfirmation.Action" = "删除"; +"StoryList.DeleteConfirmation.Title#other" = "删除 %d 个动态?"; +"StoryList.GridHeaderLocationSearch#other" = "当前位置动态数:%d "; +"StoryList.ItemAction.Archive" = "归档"; +"StoryList.ItemAction.Delete" = "删除"; +"StoryList.ItemAction.Edit" = "编辑"; +"StoryList.ItemAction.Forward" = "转发"; +"StoryList.ItemAction.Pin" = "置顶"; +"StoryList.ItemAction.Unarchive" = "取消归档"; +"StoryList.ItemAction.Unpin" = "取消置顶"; +"StoryList.SaveToProfile" = "发布至个人资料页"; +"StoryList.SavedAddAction" = "添加贴文"; +"StoryList.SavedEmptyAction" = "打开归档"; +"StoryList.SavedEmptyPosts.Text" = "发布照片和视频并显示在您的个人资料页上。"; +"StoryList.SavedEmptyPosts.Title" = "暂无贴文"; +"StoryList.SavedEmptyState.Text" = "打开“归档”页,\n选择要在您的个人资料上显示的动态。"; +"StoryList.SavedEmptyState.Title" = "没有发布的动态"; +"StoryList.SubtitleArchived#other" = "%d 个贴文已归档。"; +"StoryList.SubtitleCount#other" = "%d 条动态"; +"StoryList.SubtitleSaved#other" = "%d 条已发布的动态"; +"StoryList.SubtitleSelected#other" = "已选择 %d 条动态"; +"StoryList.TitleArchive" = "动态归档"; +"StoryList.TitleSaved" = "我的动态"; +"StoryList.ToastArchived.Text#other" = "%d 个动态已归档。"; +"StoryList.ToastPinLimit.Text#other" = "您最多只能置顶 %d 条动态。"; +"StoryList.ToastPinned.Text#other" = "现在这些动态将始终显示在顶部。"; +"StoryList.ToastPinned.Title#other" = "已置顶 %d 条动态"; +"StoryList.ToastUnarchived.Text#other" = "%d 个动态已取消归档。"; +"StoryList.ToastUnpinned.Text#other" = "已将 %d 条动态取消置顶。"; +"StoryList.TooltipStoriesDeleted#other" = "已删除 %d 条动态。"; +"StoryList.TooltipStoriesSavedToChannel#other" = "%d 条动态已发布到频道资料页。"; +"StoryList.TooltipStoriesSavedToGroup#other" = "已向群组资料中添加 %d 条动态。"; +"StoryList.TooltipStoriesSavedToProfile#other" = "已将 %d 条动态发布至您的个人资料页。"; +"StoryList.TooltipStoriesSavedToProfileText" = "在您手动移除前,其他人可以在您的个人资料页查看这些已发布的动态。"; +"SuggestBirthdate.Accept.Action" = "添加到我的个人资料页上"; +"SuggestBirthdate.Accept.Added" = "已添加出生日期。您可以在 **设置** 中更改。"; +"SuggestBirthdate.Accept.HideYear" = "隐藏年份"; +"SuggestBirthdate.Accept.Title" = "生日"; +"SuggestBirthdate.Suggest.Action" = "建议出生日期"; +"SuggestBirthdate.Suggest.Title" = "%@ 的生日"; +"SuggestPost.SetTime.Label" = "时间"; +"SuggestPost.SetTimeFormat.Any" = "任何时间"; +"SuggestPost.SetTimeFormat.Date" = "%@"; +"SuggestPost.SetTimeFormat.TodayAt" = "今天 %@"; +"SuggestPost.SetTimeFormat.TomorrowAt" = "于明天 %@"; +"SuggestPost.Time.ProposeOn" = "发布于 %@ %@"; +"SuggestPost.Time.ProposeToday" = "发布于今天 %@"; +"SuggestPost.Time.ProposeTomorrow" = "发布于明天 %@"; +"SuggestPost.Time.SendOn" = "发布于 %@ %@"; +"SuggestPost.Time.SendToday" = "发布于今天 %@"; +"SuggestPost.Time.SendTomorrow" = "发布于明天 %@"; +"Target.InviteToGroupConfirmation" = "是否将机器人添加到 “%@”?"; +"Target.InviteToGroupErrorAlreadyInvited" = "该机器人已在此群组中。"; +"Target.SelectGroup" = "选择群组"; +"Target.ShareGameConfirmationGroup" = "向«%@»分享此游戏?"; +"Target.ShareGameConfirmationPrivate" = "与“%@”分享此游戏?"; +"TextFormat.AddLinkPlaceholder" = "链接"; +"TextFormat.AddLinkText" = "该链接将显示为“%@”"; +"TextFormat.AddLinkTitle" = "添加链接"; +"TextFormat.Bold" = "粗体"; +"TextFormat.Code" = "代码"; +"TextFormat.Date" = "日期"; +"TextFormat.EditLinkTitle" = "编辑链接"; +"TextFormat.Format" = "格式"; +"TextFormat.Italic" = "斜体"; +"TextFormat.Link" = "链接"; +"TextFormat.Monospace" = "等宽字体"; +"TextFormat.Quote" = "引用"; +"TextFormat.Spoiler" = "遮罩"; +"TextFormat.Strikethrough" = "删除线"; +"TextFormat.Underline" = "下划线"; +"TextProcessing.ActionApply" = "应用"; +"TextProcessing.ActionClose" = "关闭"; +"TextProcessing.ActionTitleNonPremium" = "提升上限"; +"TextProcessing.ActionValueNonPremium" = "X50"; +"TextProcessing.AlertCreatorDeleteStyle.Text" = "您确定要删除此样式吗?此样式将从所有已安裝的用户端移除。"; +"TextProcessing.AlertCreatorDeleteStyle.Title" = "删除样式"; +"TextProcessing.AlertDeleteStyle.Text" = "您确定想要移除此样式吗?"; +"TextProcessing.AlertDeleteStyle.Title" = "删除样式"; +"TextProcessing.AlertTooManyStyles.Text" = "请删除一些已保存的风格以创建新风格。"; +"TextProcessing.AlertTooManyStyles.Title" = "过多风格"; +"TextProcessing.EditStyle.ActionCreate" = "创建"; +"TextProcessing.EditStyle.ActionEdit" = "保存"; +"TextProcessing.EditStyle.AddLink" = "添加链接到我的账户"; +"TextProcessing.EditStyle.Delete" = "删除样式"; +"TextProcessing.EditStyle.NamePlaceholder" = "风格名称(例如:“海盗”)"; +"TextProcessing.EditStyle.TextPlaceholder" = "提示词(例如:“用豪迈的海盗风格写作,使用‘哟呵’、‘老伙计’等词语,并提及宝藏和大海”)"; +"TextProcessing.EditStyle.TitleCreate" = "新样式"; +"TextProcessing.EditStyle.TitleEdit" = "编辑样式"; +"TextProcessing.Emojify" = "表情化"; +"TextProcessing.LimitToast.Text" = "获取 **Telegram 高级版** 即可享有每天 **50 倍** 的文字编辑次数。"; +"TextProcessing.LimitToast.Title" = "已达每日上限"; +"TextProcessing.OriginalBadge" = "原风格:"; +"TextProcessing.OriginalStyleBadge" = "原始"; +"TextProcessing.ResultBadge" = "成果"; +"TextProcessing.StyleFooterAuthor" = "风格设计:[%@]()"; +"TextProcessing.StyleFooterCreatedBy" = "由 [%@]() 建立。"; +"TextProcessing.StyleFooterCreatedByFormat" = "%1$@。 %2$@"; +"TextProcessing.StyleFooterCreatedBySimpleFormat" = "%@."; +"TextProcessing.StyleFooterUserCount#other" = "被 %d 人使用。"; +"TextProcessing.StyleList.Add" = "创建"; +"TextProcessing.StyleMenu.ButtonAdd" = "添加样式"; +"TextProcessing.StyleMenu.ButtonClose" = "关闭"; +"TextProcessing.StyleMenu.Delete" = "删除样式"; +"TextProcessing.StyleMenu.Edit" = "编辑样式"; +"TextProcessing.StyleMenu.Share" = "分享样式"; +"TextProcessing.StylePreview.After" = "应用样式后"; +"TextProcessing.StylePreview.Before" = "原文"; +"TextProcessing.StylePreview.ExampleHeader" = "示例"; +"TextProcessing.StylePreview.ExampleHeaderRefresh" = "另一个示例\u00a0"; +"TextProcessing.StylePreview.Subtitle" = "应用此样式\n即可立即重写您的消息。"; +"TextProcessing.StyleTooltip" = "选择风格"; +"TextProcessing.TabFix" = "修正"; +"TextProcessing.TabStylize" = "风格"; +"TextProcessing.TabTranslate" = "翻译"; +"TextProcessing.TextCollapse" = "折叠"; +"TextProcessing.TextExpand" = "更多"; +"TextProcessing.TitleEdit" = "AI 编辑器"; +"TextProcessing.TitleTranslate" = "翻译"; +"TextProcessing.ToastStyleAdded.Text" = "输入下一条长息时,点击 ’AI‘> ‘%@’。"; +"TextProcessing.ToastStyleAdded.Title" = "样式已添加"; +"TextProcessing.ToastStyleCreated.Text" = "按住样式即可编辑或分享它。"; +"TextProcessing.ToastStyleCreated.Title" = "%@ 样式已创建"; +"TextProcessing.Translate.FromLanguage" = "从 {}"; +"TextProcessing.Translate.LanguageStyle" = "%1$@ (%2$@)"; +"TextProcessing.Translate.ToLanguage" = "到 {}"; +"TextProcessingStyle.Neutral" = "中立"; +"TextSelection.SelectAll" = "全选"; +"Theme.Colors.Accent" = "强调色"; +"Theme.Colors.Background" = "背景"; +"Theme.Colors.ColorWallpaperWarning" = "您确定将聊天壁纸换成纯色吗?"; +"Theme.Colors.ColorWallpaperWarningProceed" = "继续"; +"Theme.Colors.Messages" = "消息"; +"Theme.Colors.Proceed" = "下一步"; +"Theme.Context.Apply" = "应用"; +"Theme.Context.ChangeColors" = "变换颜色"; +"Theme.ErrorNotFound" = "抱歉,此多彩主题似乎不存在。"; +"Theme.ThemeChanged" = "多彩主题已修改。"; +"Theme.ThemeChangedText" = "您可以在 [设置 > 外观]() 中修改回来。"; +"Theme.Unsupported" = "抱歉,此多彩主题还不支持您的设备。"; +"Theme.UsersCount#other" = "%@ 个人正在使用该色彩主题"; +"Themes.BuildOwn" = "制作您个人的主题"; +"Themes.CreateNewTheme" = "新建主题"; +"Themes.EditCurrentTheme" = "编辑当前主题"; +"Themes.SelectTheme" = "选择一个主题"; +"Themes.Title" = "聊天主题"; +"Time.AfterDays#other" = "%@ 天"; +"Time.AfterHours#other" = "%@ 小时"; +"Time.AfterMinutes#other" = "%@ 分钟"; +"Time.AfterMonths#other" = "%@ 个月"; +"Time.AfterSeconds#other" = "%@ 秒"; +"Time.AfterWeeks#other" = "%@ 周"; +"Time.AfterYears#other" = "%@ 年"; +"Time.AtDate" = "%@"; +"Time.AtPreciseDate" = "%@ %@"; +"Time.HoursAgo#other" = "%@ 小时之前"; +"Time.JustNow" = "刚才"; +"Time.MediumDate" = "%1$@ %2$@"; +"Time.MinutesAgo#other" = "%@ 分钟之前"; +"Time.MonthOfYear_m1" = "%@ 年 1 月"; +"Time.MonthOfYear_m10" = "%@ 年 10 月"; +"Time.MonthOfYear_m11" = "%@ 年 11 月"; +"Time.MonthOfYear_m12" = "%@ 年 12 月"; +"Time.MonthOfYear_m2" = "%@ 年 2 月"; +"Time.MonthOfYear_m3" = "%@ 年 3 月"; +"Time.MonthOfYear_m4" = "%@ 年 4 月"; +"Time.MonthOfYear_m5" = "%@ 年 5 月"; +"Time.MonthOfYear_m6" = "%@ 年 6 月"; +"Time.MonthOfYear_m7" = "%@ 年 7 月"; +"Time.MonthOfYear_m8" = "%@ 年 8 月"; +"Time.MonthOfYear_m9" = "%@ 年 9 月"; +"Time.PreciseDate_m1" = "%2$@年1月%1$@日 %3$@"; +"Time.PreciseDate_m10" = "%2$@年10月%1$@日 %3$@"; +"Time.PreciseDate_m11" = "%2$@年11月%1$@日 %3$@"; +"Time.PreciseDate_m12" = "%2$@年12月%1$@日 %3$@"; +"Time.PreciseDate_m2" = "%2$@年2月%1$@日 %3$@"; +"Time.PreciseDate_m3" = "%2$@年3月%1$@日 %3$@"; +"Time.PreciseDate_m4" = "%2$@年4月%1$@日 %3$@"; +"Time.PreciseDate_m5" = "%2$@年5月%1$@日 %3$@"; +"Time.PreciseDate_m6" = "%2$@年6月%1$@日 %3$@"; +"Time.PreciseDate_m7" = "%2$@年7月%1$@日 %3$@"; +"Time.PreciseDate_m8" = "%2$@年8月%1$@日 %3$@"; +"Time.PreciseDate_m9" = "%2$@年9月%1$@日 %3$@"; +"Time.TimerDays#other" = "%@-天"; +"Time.TimerHours#other" = "%@-小时"; +"Time.TimerMinutes#other" = "%@-分钟"; +"Time.TimerMonths#other" = "%@-个月"; +"Time.TimerSeconds#other" = "%@-秒"; +"Time.TimerWeeks#other" = "%@周"; +"Time.TimerYears#other" = "%@-年"; +"Time.TodayAt" = "今天 %@"; +"Time.TomorrowAt" = "明天%@"; +"Time.YesterdayAt" = "昨天 %@"; +"TimeZoneSelection.Title" = "时区"; +"Ton.AvailableBalance" = "可提款余额"; +"Ton.LifetimeProceeds" = "累计总收益"; +"Ton.ProceedsOverview" = "收益概览"; +"Ton.WithdrawViaFragment" = "通过 Fragment 提取"; +"Ton.WithdrawViaFragment.Info" = "通过 Fragment 领取您的 Gram。 [了解更多 >]()"; +"Ton.WithdrawViaFragment.Info_URL" = "https://telegram.org/tos/bot-developers#6-2-2-tpa-balance"; +"TopApps.Info.Done" = "已知悉"; +"TopApps.Info.Text" = "此目录根据小程序的每日收入(以星星数量衡量)进行排名。若想要列入清单,开发人员必须在 [@botfather]() 中设定主要小程序(如 [此处](https://core.telegram.org/bots/webapps#launching-the-main-mini-app) 所述) ,该小程序应拥有超过 **1000** 名每日用户,且每日收入超过 **1000** 颗星星(基于每周平均值)。"; +"TopApps.Info.Title" = "热门小程序"; +"Tour.StartButton" = "开始聊天"; +"Tour.Text1" = "世界上**最快的**聊天工具\n**免费**又**安全**"; +"Tour.Text2" = "**Telegram** 收发信息\n比任何应用都快"; +"Tour.Text3" = "**Telegram** 不论消息或是媒体文件\n都无大小限制"; +"Tour.Text4" = "**Telegram** 保障您的消息安全\n免受黑客攻击"; +"Tour.Text5" = "**Telegram** 让您能从\n多个设备收发消息。"; +"Tour.Text6" = "**Telegram** 提供免费无限量的聊天与媒体的云端存储。"; +"Tour.Title1" = "Telegram"; +"Tour.Title2" = "快速"; +"Tour.Title3" = "强大"; +"Tour.Title4" = "安全"; +"Tour.Title5" = "云端"; +"Tour.Title6" = "免费"; +"Translate.ChangeLanguage" = "更改语言"; +"Translate.CopyTranslation" = "拷贝译文"; +"Translate.Languages.Original" = "原文"; +"Translate.Languages.Title" = "语言"; +"Translate.Languages.Translation" = "译文"; +"Translate.More" = "更多"; +"Translate.Title" = "翻译"; +"Translation.Language.ar" = "阿拉伯语"; +"Translation.Language.be" = "白俄罗斯语"; +"Translation.Language.ca" = "加泰罗尼亚语"; +"Translation.Language.cs" = "捷克语"; +"Translation.Language.de" = "德语"; +"Translation.Language.en" = "英语"; +"Translation.Language.eo" = "世界语"; +"Translation.Language.es" = "西班牙语"; +"Translation.Language.fa" = "波斯语"; +"Translation.Language.fi" = "芬兰语"; +"Translation.Language.fr" = "法语"; +"Translation.Language.he" = "希伯来语"; +"Translation.Language.hr" = "克罗地亚语"; +"Translation.Language.hu" = "匈牙利语"; +"Translation.Language.id" = "印度尼西亚语"; +"Translation.Language.it" = "意大利语"; +"Translation.Language.ja" = "日语"; +"Translation.Language.ko" = "韩语"; +"Translation.Language.kz" = "哈薩克文"; +"Translation.Language.ml" = "马拉雅拉姆语"; +"Translation.Language.ms" = "马来语"; +"Translation.Language.nl" = "荷兰语"; +"Translation.Language.no" = "挪威语"; +"Translation.Language.po" = "波兰语"; +"Translation.Language.pt" = "葡萄牙语"; +"Translation.Language.ro" = "罗马尼亚语"; +"Translation.Language.ru" = "俄罗斯语"; +"Translation.Language.sk" = "斯洛伐克语"; +"Translation.Language.sr" = "塞尔维亚语"; +"Translation.Language.sv" = "瑞典语"; +"Translation.Language.ta" = "泰米尔语"; +"Translation.Language.tr" = "土耳其语"; +"Translation.Language.uk" = "乌克兰语"; +"Translation.Language.uz" = "乌孜别克语"; +"Translation.Language.zh" = "中文"; +"Translation.LanguageFrom.AR" = "阿拉伯文"; +"Translation.LanguageFrom.BE" = "白俄罗斯文"; +"Translation.LanguageFrom.CA" = "加泰罗尼亚文"; +"Translation.LanguageFrom.DE" = "德文"; +"Translation.LanguageFrom.EN" = "英文"; +"Translation.LanguageFrom.EO" = "世界文"; +"Translation.LanguageFrom.ES" = "西班牙文"; +"Translation.LanguageFrom.FA" = "波斯文"; +"Translation.LanguageFrom.FI" = "芬兰文"; +"Translation.LanguageFrom.FR" = "法文"; +"Translation.LanguageFrom.HE" = "希伯来文"; +"Translation.LanguageFrom.HR" = "克罗地亚文"; +"Translation.LanguageFrom.HU" = "匈牙利文"; +"Translation.LanguageFrom.ID" = "印尼文"; +"Translation.LanguageFrom.IT" = "意大利文"; +"Translation.LanguageFrom.JA" = "日文"; +"Translation.LanguageFrom.KO" = "韩文"; +"Translation.LanguageFrom.KZ" = "哈萨克文"; +"Translation.LanguageFrom.ML" = "马来亚拉姆文"; +"Translation.LanguageFrom.MS" = "马来文"; +"Translation.LanguageFrom.NL" = "荷兰文"; +"Translation.LanguageFrom.NO" = "挪威文"; +"Translation.LanguageFrom.PO" = "波兰文"; +"Translation.LanguageFrom.PT" = "葡萄牙文"; +"Translation.LanguageFrom.RO" = "罗马尼亚文"; +"Translation.LanguageFrom.RU" = "俄文"; +"Translation.LanguageFrom.SK" = "斯洛伐克文"; +"Translation.LanguageFrom.SR" = "萨尔维亚文"; +"Translation.LanguageFrom.SV" = "瑞典文"; +"Translation.LanguageFrom.TA" = "塔米尔文"; +"Translation.LanguageFrom.TR" = "土耳其文"; +"Translation.LanguageFrom.UK" = "乌克兰文"; +"Translation.LanguageFrom.UZ" = "乌兹别克文"; +"Translation.LanguageFrom.ZH" = "中文"; +"Translation.LanguageTo.AR" = "阿拉伯文"; +"Translation.LanguageTo.BE" = "白俄罗斯文"; +"Translation.LanguageTo.CA" = "加泰罗尼亚文"; +"Translation.LanguageTo.DE" = "德文"; +"Translation.LanguageTo.EN" = "英文"; +"Translation.LanguageTo.EO" = "世界文"; +"Translation.LanguageTo.ES" = "西班牙文"; +"Translation.LanguageTo.FA" = "波斯文"; +"Translation.LanguageTo.FI" = "芬兰文"; +"Translation.LanguageTo.FR" = "法语"; +"Translation.LanguageTo.HE" = "希伯来文"; +"Translation.LanguageTo.HR" = "\u00a0克罗地亚文"; +"Translation.LanguageTo.HU" = "匈牙利文"; +"Translation.LanguageTo.ID" = "印尼文"; +"Translation.LanguageTo.IT" = "意大利文"; +"Translation.LanguageTo.JA" = "日文"; +"Translation.LanguageTo.KO" = "韩文"; +"Translation.LanguageTo.KZ" = "哈萨克文"; +"Translation.LanguageTo.ML" = "马来亚拉姆文"; +"Translation.LanguageTo.MS" = "马来文"; +"Translation.LanguageTo.NL" = "荷兰文"; +"Translation.LanguageTo.NO" = "挪威文"; +"Translation.LanguageTo.PO" = "波兰文"; +"Translation.LanguageTo.PT" = "葡萄牙文"; +"Translation.LanguageTo.RO" = "罗马尼亚文"; +"Translation.LanguageTo.RU" = "俄文"; +"Translation.LanguageTo.SK" = "斯洛伐克文"; +"Translation.LanguageTo.SR" = "萨尔维亚文"; +"Translation.LanguageTo.SV" = "瑞典文"; +"Translation.LanguageTo.TA" = "坦米尔文"; +"Translation.LanguageTo.TR" = "土耳其文"; +"Translation.LanguageTo.UK" = "乌克兰文"; +"Translation.LanguageTo.UZ" = "乌兹别克文"; +"Translation.LanguageTo.ZH" = "中文"; +"TwoFactorRemember.CheckPassword" = "检查密码"; +"TwoFactorRemember.Done.Action" = "返回设置"; +"TwoFactorRemember.Done.Text" = "您还记得您的密码。 "; +"TwoFactorRemember.Done.Title" = "真棒!"; +"TwoFactorRemember.Forgot" = "忘记密码?"; +"TwoFactorRemember.Placeholder" = "密码"; +"TwoFactorRemember.Text" = "你还记得你的密码吗? "; +"TwoFactorRemember.Title" = "请输入密码"; +"TwoFactorRemember.WrongPassword" = "此密码不正确"; +"TwoFactorSetup.Done.Action" = "跳转到设置"; +"TwoFactorSetup.Done.Text" = "当您登录到新设备时,除了通过短信获得的验证码外,还需要此密码。"; +"TwoFactorSetup.Done.Title" = "密码已设置!"; +"TwoFactorSetup.Email.Action" = "继续"; +"TwoFactorSetup.Email.Placeholder" = "您的邮件地址"; +"TwoFactorSetup.Email.SkipAction" = "跳过设置邮箱"; +"TwoFactorSetup.Email.SkipConfirmationSkip" = "跳过"; +"TwoFactorSetup.Email.SkipConfirmationText" = "如果您忘记密码,则将无法访问Telegram帐户。无法恢复。"; +"TwoFactorSetup.Email.SkipConfirmationTitle" = "不,我是认真的。"; +"TwoFactorSetup.Email.Text" = "您可以设置恢复邮箱,以便能够重置密码并恢复对 Telegram 帐号的访问。"; +"TwoFactorSetup.Email.Title" = "恢复邮箱"; +"TwoFactorSetup.EmailVerification.Action" = "继续"; +"TwoFactorSetup.EmailVerification.ChangeAction" = "更换邮箱"; +"TwoFactorSetup.EmailVerification.Placeholder" = "验证码"; +"TwoFactorSetup.EmailVerification.ResendAction" = "重发验证码"; +"TwoFactorSetup.EmailVerification.Text" = "请输入刚刚发邮件给 %@ 的验证码"; +"TwoFactorSetup.EmailVerification.Title" = "恢复邮箱"; +"TwoFactorSetup.Hint.Action" = "继续"; +"TwoFactorSetup.Hint.Placeholder" = "提示(可选)"; +"TwoFactorSetup.Hint.SkipAction" = "跳过设置提示"; +"TwoFactorSetup.Hint.Text" = "您可以为您的密码创建可选提示。"; +"TwoFactorSetup.Hint.Title" = "提示"; +"TwoFactorSetup.Intro.Action" = "设置附加密码"; +"TwoFactorSetup.Intro.Text" = "当您登录到新设备时,除了通过短信获得的验证码外,您可以设置额外的密码。"; +"TwoFactorSetup.Intro.Title" = "附加密码"; +"TwoFactorSetup.Password.Action" = "创建密码"; +"TwoFactorSetup.Password.PlaceholderConfirmPassword" = "确认密码"; +"TwoFactorSetup.Password.PlaceholderPassword" = "密码"; +"TwoFactorSetup.Password.Title" = "新建密码"; +"TwoFactorSetup.PasswordRecovery.Action" = "继续"; +"TwoFactorSetup.PasswordRecovery.PlaceholderConfirmPassword" = "再次输入新密码"; +"TwoFactorSetup.PasswordRecovery.PlaceholderPassword" = "新的密码"; +"TwoFactorSetup.PasswordRecovery.Skip" = "跳过"; +"TwoFactorSetup.PasswordRecovery.SkipAlertAction" = "跳过"; +"TwoFactorSetup.PasswordRecovery.SkipAlertText" = "跳过此步骤将停用您帐户的两步验证。您确定要跳过吗? "; +"TwoFactorSetup.PasswordRecovery.SkipAlertTitle" = "请注意!"; +"TwoFactorSetup.PasswordRecovery.Text" = "您现在可以设置一个新密码,用于登录您的帐户。"; +"TwoFactorSetup.PasswordRecovery.Title" = "创建新的密码"; +"TwoFactorSetup.ResetDone.Action" = "继续"; +"TwoFactorSetup.ResetDone.Text" = "当你登录新设备时,除了需要短信发来的代码之外,还将需要此密码。"; +"TwoFactorSetup.ResetDone.TextNoPassword" = "你可以通过以下方式设置新密码\n\n设置 > 隐私与安全 > 两步验证"; +"TwoFactorSetup.ResetDone.Title" = "已设置新密码!"; +"TwoFactorSetup.ResetDone.TitleNoPassword" = "密码已移除"; +"TwoFactorSetup.ResetFloodWait" = "你最近请求的密码重置已被取消。请等待 %@ 后重新请求。"; +"TwoStepAuth.AddHintDescription" = "您可以为您的密码创建可选提示。"; +"TwoStepAuth.AddHintTitle" = "添加提示"; +"TwoStepAuth.AdditionalPassword" = "两步验证密码"; +"TwoStepAuth.CancelResetText" = "是否取消重置密码?若要再次申请密码重置,需要再等 7 天。"; +"TwoStepAuth.CancelResetTitle" = "取消重置"; +"TwoStepAuth.ChangeEmail" = "更改密码恢复电子邮箱"; +"TwoStepAuth.ChangePassword" = "更改密码"; +"TwoStepAuth.ChangePasswordDescription" = "请输入用于保护数据的新密码。"; +"TwoStepAuth.ConfirmEmailCodePlaceholder" = "验证码"; +"TwoStepAuth.ConfirmEmailDescription" = "请输入我们刚刚发送到%1$@电子邮件的验证码"; +"TwoStepAuth.ConfirmEmailResendCode" = "重新发送验证码"; +"TwoStepAuth.ConfirmationAbort" = "取消两步验证设置"; +"TwoStepAuth.ConfirmationText" = "请检查你的电子邮箱,并点击验证链接来完成两步验证的设置。请务必也检查垃圾邮件文件夹。"; +"TwoStepAuth.ConfirmationTitle" = "两步验证"; +"TwoStepAuth.Disable" = "停用"; +"TwoStepAuth.DisableSuccess" = "两步验证已停用。"; +"TwoStepAuth.Email" = "电子邮箱"; +"TwoStepAuth.EmailAddSuccess" = "已添加恢复邮箱"; +"TwoStepAuth.EmailChangeSuccess" = "您的恢复邮箱已变更。"; +"TwoStepAuth.EmailCodeExpired" = "此验证码已失效,请再试一次。"; +"TwoStepAuth.EmailHelp" = "请添加您的有效电子邮箱。这是忘记密码时唯一的恢复方法。"; +"TwoStepAuth.EmailInvalid" = "邮箱地址无效,请重试。"; +"TwoStepAuth.EmailPlaceholder" = "您的电子邮箱"; +"TwoStepAuth.EmailSent" = "我们已向您发送了一封邮件来验证此电子邮箱地址。"; +"TwoStepAuth.EmailSkip" = "跳过"; +"TwoStepAuth.EmailSkipAlert" = "您确定不设置密码恢复邮箱吗?\n\n一旦您忘记了密码,您将无法再访问您的 Telegram 帐号,并且再也没有办法恢复帐号数据。"; +"TwoStepAuth.EmailTitle" = "密码恢复电子邮箱"; +"TwoStepAuth.EnabledSuccess" = "两步验证已启用"; +"TwoStepAuth.EnterEmailCode" = "输入验证码"; +"TwoStepAuth.EnterPasswordForgot" = "是否忘记密码?"; +"TwoStepAuth.EnterPasswordHelp" = "您已启用了两步验证,因此您的帐号已受到额外密码的保护。"; +"TwoStepAuth.EnterPasswordHint" = "提示:%@"; +"TwoStepAuth.EnterPasswordInvalid" = "密码错误,请重试。"; +"TwoStepAuth.EnterPasswordPassword" = "密码"; +"TwoStepAuth.EnterPasswordTitle" = "密码"; +"TwoStepAuth.FloodError" = "尝试次数过多,请稍后再试。"; +"TwoStepAuth.GenericHelp" = "您已启用两步验证。\n您将需要此处设置的密码以登录到您的 Telegram 帐号。"; +"TwoStepAuth.HintPlaceholder" = "提示"; +"TwoStepAuth.PasswordChangeSuccess" = "您的密码已变更。"; +"TwoStepAuth.PasswordRemoveConfirmation" = "您确定要停用密码吗?"; +"TwoStepAuth.PasswordRemovePassportConfirmation" = "您确定要停用密码吗?\n\n所有存储在 Telegram 通行证里的数据都将丢失。"; +"TwoStepAuth.PasswordSet" = "您的两步验证密码现已启用。"; +"TwoStepAuth.PendingEmailHelp" = "您的密码恢复电子邮箱 %@ 正在等待验证,在完成验证之前它将不会启用。"; +"TwoStepAuth.ReEnterPasswordDescription" = "请确认您的密码"; +"TwoStepAuth.ReEnterPasswordTitle" = "重新输入您的密码"; +"TwoStepAuth.RecoveryCode" = "验证码"; +"TwoStepAuth.RecoveryCodeExpired" = "我们重新向您发送了六位验证码。"; +"TwoStepAuth.RecoveryCodeHelp" = "请查阅您电子邮箱中的邮件,并输入我们发送的六位数验证码,以停用云端密码。"; +"TwoStepAuth.RecoveryCodeInvalid" = "验证码无效,请重试。"; +"TwoStepAuth.RecoveryEmailAddDescription" = "请添加您的有效电子邮箱。这是忘记密码时唯一的恢复方法。"; +"TwoStepAuth.RecoveryEmailChangeDescription" = "请输入您的备用电子邮箱。 这是忘记密码时唯一的恢复方法。"; +"TwoStepAuth.RecoveryEmailResetNoAccess" = "无法访问邮箱?"; +"TwoStepAuth.RecoveryEmailResetText" = "如果您无法使用恢复邮箱,您只能试着回忆起密码,或等待 7 天后密码被重置。"; +"TwoStepAuth.RecoveryEmailTitle" = "恢复邮箱"; +"TwoStepAuth.RecoveryEmailUnavailable" = "无法访问 %@ ?"; +"TwoStepAuth.RecoveryFailed" = "您剩下的选择只有回忆起您的密码或重置您的帐号。"; +"TwoStepAuth.RecoveryTitle" = "电子邮箱验证码"; +"TwoStepAuth.RecoveryUnavailable" = "因为您在设置密码时没有同时提供密码恢复电子邮箱,您剩下的选择就只有回忆起您的密码或重置您的帐号。"; +"TwoStepAuth.RecoveryUnavailableResetAction" = "重置"; +"TwoStepAuth.RecoveryUnavailableResetText" = "由于您在设置密码时没有提供恢复电子邮箱,您只能尝试回忆密码或等 7 天直到密码被重置。"; +"TwoStepAuth.RecoveryUnavailableResetTitle" = "重置密码"; +"TwoStepAuth.RemovePassword" = "停用密码"; +"TwoStepAuth.ResetAccountConfirmation" = "如果您重置了帐号,将会丢失所有的聊天、消息、媒体和文件。"; +"TwoStepAuth.ResetAccountHelp" = "如果您确认重置帐号,您将丢失 Telegram 中所有的聊天、消息、媒体和文件。"; +"TwoStepAuth.ResetAction" = "重置密码"; +"TwoStepAuth.ResetPendingText" = "你可以在 %@之后重置密码。"; +"TwoStepAuth.SetPassword" = "设置两步验证密码"; +"TwoStepAuth.SetPasswordHelp" = "您可以设置一个密码。当登陆到新设备时,它将作为输入短信验证码后的附加验证。"; +"TwoStepAuth.SetupEmail" = "设置恢复电子邮箱"; +"TwoStepAuth.SetupHint" = "请为您的密码建立提示:"; +"TwoStepAuth.SetupHintTitle" = "密码提示"; +"TwoStepAuth.SetupPasswordConfirmFailed" = "密码不一致,请重新输入。"; +"TwoStepAuth.SetupPasswordConfirmPassword" = "请再次输入密码:"; +"TwoStepAuth.SetupPasswordDescription" = "请设置一个用于保护您数据的密码。"; +"TwoStepAuth.SetupPasswordEnterPasswordChange" = "请输入新密码:"; +"TwoStepAuth.SetupPasswordEnterPasswordNew" = "请输入密码:"; +"TwoStepAuth.SetupPasswordTitle" = "您的密码"; +"TwoStepAuth.SetupPendingEmail" = "您的恢复邮箱 %@ 需要确认并且现在尚未生效。\n\n请检查您的电子邮箱,并输入验证码以完成两步验证的设置。请务必也检查垃圾邮件文件夹。"; +"TwoStepAuth.SetupResendEmailCode" = "重新发送验证码"; +"TwoStepAuth.SetupResendEmailCodeAlert" = "验证码已发送。 请检查您的电子邮件。为了确保能正常收到,请务必也检查垃圾邮件文件夹。"; +"TwoStepAuth.Title" = "两步验证"; +"Undo.ChatCleared" = "聊天记录已清空。"; +"Undo.ChatClearedForBothSides" = "已为双方清空聊天内容。"; +"Undo.ChatClearedForEveryone" = "已为所有人清除聊天。"; +"Undo.ChatDeleted" = "聊天已删除。"; +"Undo.ChatDeletedForBothSides" = "已为双方删除聊天内容。"; +"Undo.DeletedChannel" = "频道已删除。"; +"Undo.DeletedGroup" = "群组已删除。"; +"Undo.DeletedMonoforum" = "已从您的聊天名单移除。"; +"Undo.DeletedTopic" = "话题已删除"; +"Undo.LeftChannel" = "您已退出频道。"; +"Undo.LeftGroup" = "您已退出此群组。"; +"Undo.MessagesDeleted" = "消息已删除。"; +"Undo.ScheduledMessagesCleared" = "已清空定时消息"; +"Undo.SecretChatDeleted" = "私密聊天已删除。"; +"Undo.Undo" = "撤消"; +"Update.AppVersion" = "Telegram %@"; +"Update.Skip" = "跳过"; +"Update.Title" = "Telegram 更新"; +"Update.UpdateApp" = "升级 Telegram"; +"Updated.AtDate" = "%@"; +"Updated.HoursAgo#other" = "%@ 小时前"; +"Updated.JustNow" = "刚才"; +"Updated.MinutesAgo#other" = "%@ 分钟前"; +"Updated.TodayAt" = "今天 %@"; +"Updated.YesterdayAt" = "昨天 %@"; +"User.DeletedAccount" = "已销号用户"; +"UserCount#other" = "%@ 位用户"; +"UserInfo.About.Placeholder" = "个人简介"; +"UserInfo.AddContact" = "添加为联系人"; +"UserInfo.AddPhone" = "添加电话号码"; +"UserInfo.AddToExisting" = "添加到现有的联系人信息"; +"UserInfo.Age#other" = "%@ 岁"; +"UserInfo.AnonymousNumberInfo" = "此号码获取自 [Fragment](),并未绑定到 SIM 卡。"; +"UserInfo.AnonymousNumberLabel" = "匿名号码"; +"UserInfo.Birthday" = "生日"; +"UserInfo.BirthdayToday" = "今天过生日"; +"UserInfo.BlockActionTitle" = "屏蔽%@"; +"UserInfo.BlockConfirmation" = "是否屏蔽 %@?"; +"UserInfo.BlockConfirmationTitle" = "您想要阻止 %@ 在 Telegram 上给您发消息及打电话吗?"; +"UserInfo.BotHelp" = "帮助"; +"UserInfo.BotNamePlaceholder" = "机器人名称"; +"UserInfo.BotPrivacy" = "隐私政策\n"; +"UserInfo.BotSettings" = "设置"; +"UserInfo.ChangeColors" = "更换颜色"; +"UserInfo.ChangeCustomPhoto" = "为 %@ 更改照片"; +"UserInfo.ChangeWallpaper" = "更改壁纸"; +"UserInfo.ContactForwardTooltip.Chat.One" = "已将联系人信息发送给 **%@**。"; +"UserInfo.ContactForwardTooltip.ManyChats.One" = "联系人信息已发送至 **%@** 及其他 %@ 个聊天。"; +"UserInfo.ContactForwardTooltip.SavedMessages.One" = "联系人信息已转发到 **我的收藏**。"; +"UserInfo.ContactForwardTooltip.TwoChats.One" = "联系人信息已转发给 **%@** 和 **%@**。"; +"UserInfo.CreateNewContact" = "新建联系人"; +"UserInfo.CustomPhoto" = "您设置的头像"; +"UserInfo.CustomPhotoInfo" = "你可以用另一张只有你才能查看的照片替换 %@ 的照片。"; +"UserInfo.CustomVideo" = "由您设置的头像"; +"UserInfo.DeleteContact" = "删除联系人信息"; +"UserInfo.FakeBotWarning" = "⚠️ 警告:大量用户举报该账号冒名顶替名人或组织。"; +"UserInfo.FakeUserWarning" = "⚠️ 警告:大量用户举报该账号冒名顶替名人或组织。"; +"UserInfo.FirstNamePlaceholder" = "名字"; +"UserInfo.GenericPhoneLabel" = "手机"; +"UserInfo.GroupsInCommon" = "共同加入的群组"; +"UserInfo.Invite" = "邀请使用 Telegram"; +"UserInfo.InviteBotToGroup" = "添加到群组"; +"UserInfo.LastNamePlaceholder" = "姓氏"; +"UserInfo.LinkForwardTooltip.Chat.One" = "已将链接发送给 **%@**。"; +"UserInfo.LinkForwardTooltip.ManyChats.One" = "已将链接发送给 **%@** 和另外 %@ 个聊天。"; +"UserInfo.LinkForwardTooltip.SavedMessages.One" = "链接已保存至 **我的收藏**。"; +"UserInfo.LinkForwardTooltip.TwoChats.One" = "链接已转发给 **%@** 和 **%@**。"; +"UserInfo.NotificationsDefault" = "默认"; +"UserInfo.NotificationsDefaultDisabled" = "默认(停用)"; +"UserInfo.NotificationsDefaultEnabled" = "默认(开启)"; +"UserInfo.NotificationsDefaultSound" = "默认(%@)"; +"UserInfo.NotificationsDisable" = "停用"; +"UserInfo.NotificationsDisabled" = "已停用"; +"UserInfo.NotificationsEnable" = "启用"; +"UserInfo.NotificationsEnabled" = "开启"; +"UserInfo.PhoneCall" = "电话呼叫"; +"UserInfo.PublicPhoto" = "公开照片"; +"UserInfo.PublicVideo" = "公共视频"; +"UserInfo.RemoveCustomPhoto" = "移除图片"; +"UserInfo.RemoveCustomVideo" = "移除视频"; +"UserInfo.ResetCustomPhoto" = "重置为原始照片"; +"UserInfo.ResetCustomVideo" = "重置为原始视频"; +"UserInfo.ResetToOriginalAlertReset" = "重置"; +"UserInfo.ResetToOriginalAlertText" = "你确定要重置为 %@ 的原始照片吗?"; +"UserInfo.ScamBotWarning" = "⚠️ 警告:该账号被大量用户举报存在欺诈行为。请勿与其进行转账交易,以防上当受骗。"; +"UserInfo.ScamUserWarning" = "⚠️ 警告:该账号被大量用户举报存在欺诈行为。请勿与其进行转账交易,以防上当受骗。"; +"UserInfo.SendMessage" = "发送消息"; +"UserInfo.SetCustomPhoto" = "为 %@ 设置照片"; +"UserInfo.SetCustomPhoto.AlertPhotoText" = "是否将此图片设置为 %@ 的头像?该头像将替代 %@ 设置的其他头像——只有您将会看到该头像。"; +"UserInfo.SetCustomPhoto.AlertSet" = "设置"; +"UserInfo.SetCustomPhoto.AlertVideoText" = "您想为 %@ 设置这个头像吗? 它将取代 %@ 设置的所有头像 — 只有您自己看得到这个头像。"; +"UserInfo.SetCustomPhoto.SuccessPhotoText" = "您将会在 **%@** 的帐号中始终看到该头像。"; +"UserInfo.SetCustomPhoto.SuccessVideoText" = "您现在将一直看到 **%@** 的这个头像。"; +"UserInfo.SetCustomPhotoTitle" = "想为 %@ 设置自定义头像吗?"; +"UserInfo.ShareBot" = "分享"; +"UserInfo.ShareContact" = "分享联系人信息"; +"UserInfo.ShareMyContactInfo" = "分享我的联系信息"; +"UserInfo.StartSecretChat" = "开始私密聊天"; +"UserInfo.StartSecretChatConfirmation" = "您确定要与 %@ 开始私密聊天吗?"; +"UserInfo.StartSecretChatStart" = "确定"; +"UserInfo.SuggestBirthdate" = "建议生日"; +"UserInfo.SuggestPhoto" = "为 %@ 建议照片"; +"UserInfo.SuggestPhoto.AlertPhotoText" = "您想向 %@ 推荐此图片作为头像吗?"; +"UserInfo.SuggestPhoto.AlertSuggest" = "推荐"; +"UserInfo.SuggestPhoto.AlertVideoText" = "确定要推荐本视频给 %@ 作为对方个人资料的头像吗?"; +"UserInfo.SuggestPhotoTitle" = "您想为 %@ 推荐一张头像吗?"; +"UserInfo.TapToCall" = "点击以发起端到端加密通话"; +"UserInfo.TelegramCall" = "Telegram 通话"; +"UserInfo.TelegramVideoCall" = "Telegram 视频通话"; +"UserInfo.Title" = "个人信息"; +"UserInfo.UnblockConfirmation" = "要将“%@”移出黑名单吗?"; +"Username.ActivateAlertShow" = "显示"; +"Username.ActivateAlertText" = "你想要将此链接显示在你的信息页面上吗?"; +"Username.ActivateAlertTitle" = "激活用户名"; +"Username.ActiveLimitReachedError" = "抱歉,您已经有太多已启用的公开链接。 请先隐藏其一个。"; +"Username.BotActivateAlertText" = "您想要在机器人的信息页面上显示此链接吗?"; +"Username.BotActiveLimitReachedError" = "抱歉,现有活跃公开链接过多,请先隐藏一个机器人的活跃公开链接。"; +"Username.BotDeactivateAlertText" = "您想要在机器人的信息页面中隐藏此链接吗?"; +"Username.BotLinkHint" = "此用户名无法编辑。"; +"Username.BotLinkHintExtended" = "此用户名不可编辑,您可以在 [Fragment]() 上获取更多用户名。"; +"Username.BotLinksOrderInfo" = "拖拽以调整机器人信息页中链接显示的顺序。"; +"Username.BotTitle" = "公开链接"; +"Username.CheckingUsername" = "正在检查用户名…"; +"Username.DeactivateAlertHide" = "隐藏"; +"Username.DeactivateAlertText" = "是否在您的信息页面上隐藏此链接?"; +"Username.DeactivateAlertTitle" = "停用用户名"; +"Username.Help" = "您可以在 **Telegram** 上设置一个用户名。设置后,别人可以通过此用户名找到您并与您联系,而不需要您的手机号码。\n\n您可以使用 **a-z**、**0-9** 和下划线。最小长度为 **5** 个字符。"; +"Username.InvalidCharacters" = "您只能使用 a-z、0-9 和下划线。"; +"Username.InvalidEndsWithUnderscore" = "抱歉,用户名不可以以下划线结尾。"; +"Username.InvalidStartsWithNumber" = "用户名不能以数字开头。"; +"Username.InvalidStartsWithUnderscore" = "抱歉,用户名不允许以下划线开头。"; +"Username.InvalidTaken" = "抱歉,此用户名已被使用。"; +"Username.InvalidTooShort" = "用户名必须包含至少 5 个字符。"; +"Username.InvalidValue" = "抱歉,此用户名无效。"; +"Username.LinkCopied" = "链接已复制到剪贴板。"; +"Username.LinkHint" = "通过此链接可以与您聊天:[\nhttps://t.me/%@]"; +"Username.LinksOrder" = "用户名顺序"; +"Username.LinksOrderInfo" = "拖放链接以更改它们在您的信息页面上的显示顺序。"; +"Username.Placeholder" = "您的用户名"; +"Username.Title" = "用户名"; +"Username.TooManyPublicUsernamesError" = "抱歉,您保留了太多公共用户名。"; +"Username.Username" = "用户名"; +"Username.UsernameIsAvailable" = "%@ 可用。"; +"Username.UsernamePurchaseAvailable" = "**此用户名已被占用。** 但是,它目前可供购买。[了解更多...]()"; +"VerificationCodes.DescriptionText" = "第三方服务(如网站和商店)可通过 Telegram 向您的手机号码发送验证码,无需使用短信。这些验证码将显示在此聊天中。\n\n如果您没有请求任何验证码 — 无需担心!可能是有人在输入号码时出错了。"; +"VideoChat.EncryptionKeyDone" = "关闭"; +"VideoChat.EncryptionKeyLabel" = "端到端加密"; +"VideoChat.EncryptionKeyLabelShort" = "已加密"; +"VideoChat.EncryptionKeyText" = "这些表情符号代表加密密钥。所有参与者的密钥必须匹配,当有人加入或离开时密钥会发生变化。"; +"VideoChat.GroupCallTitle" = "群组通话"; +"VideoChat.IncomingVideoQuality.AudioOnly" = "仅音频"; +"VideoChat.IncomingVideoQuality.Title" = "接收视频质量"; +"VideoChat.InviteMember" = "添加成员"; +"VideoChat.RecordingSaved" = "视频聊天记录已保存到 **我的收藏**。"; +"VideoChat.RevokeLink" = "撒销链接"; +"VideoChat.ScheduleButtonTitle" = "预约视频聊天"; +"VideoChat.StatusPeerJoined" = "%@ 已加入"; +"VideoChat.StatusPeerLeft" = "%@ 已退出"; +"VoiceChat.AddBio" = "添加简介"; +"VoiceChat.AddPhoto" = "添加头像"; +"VoiceChat.AnonymousDisabledAlertText" = "抱歉,您不能以匿名管理员的身份加入视频聊天。"; +"VoiceChat.AskedToSpeak" = "举手发言"; +"VoiceChat.AskedToSpeakHelp" = "举手让管理员知道你想要发言"; +"VoiceChat.Audio" = "语音通话"; +"VoiceChat.CancelConfirmationEnd" = "取消"; +"VoiceChat.CancelConfirmationText" = "您确定要取消计划的视频聊天吗?"; +"VoiceChat.CancelConfirmationTitle" = "取消视频聊天"; +"VoiceChat.CancelLiveStream" = "取消直播"; +"VoiceChat.CancelReminder" = "取消提醒"; +"VoiceChat.CancelSpeakRequest" = "取消发言请求"; +"VoiceChat.CancelVoiceChat" = "取消视频聊天"; +"VoiceChat.ChangeName" = "更改名称"; +"VoiceChat.ChangeNameTitle" = "更换用户名"; +"VoiceChat.ChangePhoto" = "更换图片"; +"VoiceChat.ChatFullAlertText" = "抱歉,此视频聊天目前已有过多参与者。"; +"VoiceChat.Connecting" = "正在连接……"; +"VoiceChat.ContextAudio" = "音频"; +"VoiceChat.ContextDisableMessages" = "停用消息"; +"VoiceChat.ContextEnableMessages" = "启用消息"; +"VoiceChat.CopyInviteLink" = "拷贝邀请链接"; +"VoiceChat.CreateNewVoiceChatSchedule" = "预约"; +"VoiceChat.CreateNewVoiceChatStart" = "开始"; +"VoiceChat.CreateNewVoiceChatStartNow" = "立即开始"; +"VoiceChat.CreateNewVoiceChatText" = "视频聊天已结束。要发起新的视频聊天吗?"; +"VoiceChat.DiscussionGroup" = "讨论群组"; +"VoiceChat.DisplayAs" = "将我显示为..."; +"VoiceChat.DisplayAsInfo" = "选择您希望显示为个人帐号还是频道。"; +"VoiceChat.DisplayAsInfoGroup" = "选择是以个人账户还是以团体账户显示。"; +"VoiceChat.DisplayAsSuccess" = "此视频聊天的参与者将会看到您是 **%@**。"; +"VoiceChat.EditBio" = "编辑个人简介"; +"VoiceChat.EditBioPlaceholder" = "个人简介"; +"VoiceChat.EditBioSave" = "保存"; +"VoiceChat.EditBioSuccess" = "个人简介已更新。"; +"VoiceChat.EditBioText" = "任何细节,如年龄、职业或城市。"; +"VoiceChat.EditBioTitle" = "个人简介"; +"VoiceChat.EditDescription" = "编辑描述"; +"VoiceChat.EditDescriptionPlaceholder" = "描述"; +"VoiceChat.EditDescriptionSave" = "保存"; +"VoiceChat.EditDescriptionSuccess" = "描述已更改。"; +"VoiceChat.EditDescriptionText" = "任何细节,例如年龄、职业或居住城市。"; +"VoiceChat.EditDescriptionTitle" = "描述"; +"VoiceChat.EditNameSuccess" = "你的名称已修改。"; +"VoiceChat.EditPermissions" = "编辑权限"; +"VoiceChat.EditTitle" = "修改视频聊天标题"; +"VoiceChat.EditTitleRemoveSuccess" = "已移除视频聊天标题。"; +"VoiceChat.EditTitleSuccess" = "已将视频聊天标题更改为 **%@**。"; +"VoiceChat.EditTitleText" = "修改语音聊天标题"; +"VoiceChat.EditTitleTitle" = "视频聊天标题"; +"VoiceChat.EndConfirmationEnd" = "结束"; +"VoiceChat.EndConfirmationText" = "您确定要结束此次视频聊天吗?"; +"VoiceChat.EndConfirmationTitle" = "结束视频聊天"; +"VoiceChat.EndLiveStream" = "结束直播"; +"VoiceChat.EndVoiceChat" = "结束视频聊天"; +"VoiceChat.ForwardTooltip.Chat" = "已将邀请链接发送给 **%@**。"; +"VoiceChat.ForwardTooltip.ManyChats" = "邀请链接已发送给 **%@** 及其他 %@ 个聊天。"; +"VoiceChat.ForwardTooltip.TwoChats" = "邀请链接已转发给 **%@** 与 **%@**。"; +"VoiceChat.ImproveYourProfileText" = "你可以通过添加缺失的信息来完善你的资料。"; +"VoiceChat.InviteLink.CopyListenerLink" = "拷贝收听者邀请链接"; +"VoiceChat.InviteLink.CopySpeakerLink" = "拷贝发言人邀请链接"; +"VoiceChat.InviteLink.InviteListeners#other" = "[%@] 邀请听众"; +"VoiceChat.InviteLink.InviteSpeakers#other" = "[%@] 邀请发言人"; +"VoiceChat.InviteLink.Listener" = "仅限收听"; +"VoiceChat.InviteLink.Speaker" = "发言人"; +"VoiceChat.InviteLinkCopiedText" = "邀请链接已拷贝到剪贴板。"; +"VoiceChat.InviteMember" = "邀请成员"; +"VoiceChat.InviteMemberToChannelFirstText" = "%1$@ 还不是 “%2$@” 的订阅者,要将其加入频道吗?"; +"VoiceChat.InviteMemberToGroupFirstAdd" = "添加"; +"VoiceChat.InviteMemberToGroupFirstText" = "%1$@ 不是 “%2$@” 的成员。要将其添加进群组吗?"; +"VoiceChat.InvitePeer" = "邀请"; +"VoiceChat.InvitedPeerText" = "您已邀请 %@ 加入视频聊天"; +"VoiceChat.LateBy" = "已过预定时间"; +"VoiceChat.Leave" = "挂断"; +"VoiceChat.LeaveAndCancelVoiceChat" = "取消视频聊天"; +"VoiceChat.LeaveAndEndVoiceChat" = "结束视频聊天"; +"VoiceChat.LeaveConfirmation" = "您确定要退出视频聊天吗?"; +"VoiceChat.LeaveVoiceChat" = "退出视频聊天"; +"VoiceChat.Live" = "你正在说话"; +"VoiceChat.Message" = "消息"; +"VoiceChat.MessagePlaceholder" = "消息"; +"VoiceChat.MicrophoneModes" = "麦克风模式"; +"VoiceChat.Mute" = "静音"; +"VoiceChat.MuteForMe" = "为我静音"; +"VoiceChat.MutePeer" = "静音"; +"VoiceChat.MuteShort" = "静音"; +"VoiceChat.Muted" = "麦克风已禁用"; +"VoiceChat.MutedByAdmin" = "被管理员静音"; +"VoiceChat.MutedByAdminHelp" = "想要发言时请点此举手"; +"VoiceChat.MutedHelp" = "您处于只听模式"; +"VoiceChat.NoiseSuppression" = "启用噪音抑制"; +"VoiceChat.NoiseSuppressionDisabled" = "已停用"; +"VoiceChat.NoiseSuppressionEnabled" = "开启"; +"VoiceChat.OpenChannel" = "打开频道"; +"VoiceChat.OpenChat" = "公开聊天"; +"VoiceChat.OpenGroup" = "打开群组"; +"VoiceChat.Panel.Members#other" = "%@ 名参与者"; +"VoiceChat.Panel.MembersSpeaking#other" = "%@ 个成员通话"; +"VoiceChat.Panel.TapToJoin" = "点击以加入"; +"VoiceChat.PanelJoin" = "加入"; +"VoiceChat.ParticipantIsSpeaking" = "%1$@正在发言"; +"VoiceChat.PeerJoinedText" = "%@ 已加入视频聊天"; +"VoiceChat.PersonalAccount" = "个人账号"; +"VoiceChat.PinVideo" = "置顶视频"; +"VoiceChat.Reconnecting" = "重新连接中……"; +"VoiceChat.RecordLandscape" = "横屏"; +"VoiceChat.RecordOnlyAudio" = "仅音频"; +"VoiceChat.RecordPortrait" = "竖屏"; +"VoiceChat.RecordStartRecording" = "开始录制"; +"VoiceChat.RecordTitle" = "录制视频聊天"; +"VoiceChat.RecordVideoAndAudio" = "视频与音频"; +"VoiceChat.RecordingInProgress" = "视频聊天录制中"; +"VoiceChat.RecordingSaved" = "音频已保存至 **我的收藏**。"; +"VoiceChat.RecordingStarted" = "语音通话录音已开始。"; +"VoiceChat.RecordingTitlePlaceholder" = "音频标题(可选)"; +"VoiceChat.RecordingTitlePlaceholderVideo" = "视频标题(可选)"; +"VoiceChat.ReminderNotify" = "我们将在开始前通知您。"; +"VoiceChat.RemoveAndBanPeerConfirmation" = "您确定要将 %1$@ 从视频聊天中移除并在 %2$@ 中将其封禁吗?"; +"VoiceChat.RemoveConferencePeerConfirmation" = "你确定要从这个通话中移除 %@ 吗?"; +"VoiceChat.RemovePeer" = "移除"; +"VoiceChat.RemovePeerConfirmation" = "您确定要从群组中移除%@吗?"; +"VoiceChat.RemovePeerConfirmationChannel" = "您确定要将 %@ 从频道移除吗?"; +"VoiceChat.RemovePeerRemove" = "移除"; +"VoiceChat.RemovedConferencePeerText" = "您已将 %@ 从本次通话中移除。他们将无法再使用邀请链接加入。"; +"VoiceChat.RemovedPeerText" = "您已将 %@ 移出群组"; +"VoiceChat.Rotate" = "旋转"; +"VoiceChat.Scheduled" = "已预定"; +"VoiceChat.SelectAccount" = "选择账号"; +"VoiceChat.SendPublicLinkSend" = "发送"; +"VoiceChat.SendPublicLinkText" = "%1$@ 还不是 %2$@ 的一员,转而发送公开链接吗?"; +"VoiceChat.SetReminder" = "设置提醒"; +"VoiceChat.Share" = "分享邀请链接"; +"VoiceChat.ShareButton" = "分享"; +"VoiceChat.ShareScreen" = "分享屏幕"; +"VoiceChat.ShareShort" = "分享"; +"VoiceChat.SpeakPermissionAdmin" = "将新加入者静音"; +"VoiceChat.SpeakPermissionEveryone" = "新加入者可发言"; +"VoiceChat.StartNow" = "立即开始"; +"VoiceChat.StartRecording" = "开始录音"; +"VoiceChat.StartRecordingStart" = "开始"; +"VoiceChat.StartRecordingText" = "是否录制此次聊天并保存为音频文件?\n\n其他参与者会看到聊天正在被录音。"; +"VoiceChat.StartRecordingTextVideo" = "您想开始录制并将此次聊天保存为文件吗?\n\n其他参与者会看到聊天正在被录制。"; +"VoiceChat.StartRecordingTitle" = "开始录制"; +"VoiceChat.StartsIn" = "开始于"; +"VoiceChat.Status.Members#other" = "[%@] 名参与者"; +"VoiceChat.Status.MembersFormat" = "%1$@ %2$@"; +"VoiceChat.StatusInvited" = "已发送邀请"; +"VoiceChat.StatusLateBy" = "已推迟%@"; +"VoiceChat.StatusListening" = "接听中"; +"VoiceChat.StatusMutedForYou" = "已被静音"; +"VoiceChat.StatusMutedYou" = "静音"; +"VoiceChat.StatusSpeaking" = "发言中"; +"VoiceChat.StatusSpeakingVolume" = "正以 %@ 音量发言"; +"VoiceChat.StatusStartsIn" = "开始于 %@"; +"VoiceChat.StatusWantsToSpeak" = "想要发言"; +"VoiceChat.StopRecording" = "停止录制"; +"VoiceChat.StopRecordingStop" = "停止"; +"VoiceChat.StopRecordingTitle" = "是否停止录制?"; +"VoiceChat.StopScreenSharing" = "停止共享屏幕"; +"VoiceChat.StopScreenSharingShort" = "停止共享"; +"VoiceChat.SubtitleInvited#other" = "已邀请"; +"VoiceChat.SubtitleParticipants#other" = "参与者"; +"VoiceChat.TapToAddBio" = "点击以添加简介"; +"VoiceChat.TapToAddPhoto" = "点击添加照片"; +"VoiceChat.TapToAddPhotoOrBio" = "点击以添加图片或简介"; +"VoiceChat.TapToEditTitle" = "点击修改主题"; +"VoiceChat.TapToViewCameraVideo" = "点击查看相机视频"; +"VoiceChat.TapToViewScreenVideo" = "点击查看屏幕共享"; +"VoiceChat.Title" = "视频聊天"; +"VoiceChat.ToastMessagesDisabled" = "已停用消息"; +"VoiceChat.ToastMessagesEnabled" = "已启用消息"; +"VoiceChat.ToastMicrophoneIsMuted" = "您的麦克风已静音。"; +"VoiceChat.Unmute" = "取消静音"; +"VoiceChat.UnmuteForMe" = "为我取消静音"; +"VoiceChat.UnmuteHelp" = "或按住按钮以通话"; +"VoiceChat.UnmutePeer" = "允许发言"; +"VoiceChat.UnmuteSuggestion" = "您处于静音状态。点此发言。"; +"VoiceChat.Unpin" = "取消置顶"; +"VoiceChat.UnpinVideo" = "取消置顶视频"; +"VoiceChat.UserCanNowSpeak" = "**%@** 现在能够发言。"; +"VoiceChat.UserInvited" = "您已邀请 **%@** 加入视频聊天"; +"VoiceChat.Video" = "视频"; +"VoiceChat.VideoParticipantsLimitExceeded" = "请将 Telegram 更新至最新版本\n以观看视频直播。"; +"VoiceChat.VideoParticipantsLimitExceededExtended" = "视频聊天人数已超过 %@。\n新的参与者只能通过语音串流。"; +"VoiceChat.VideoPaused" = "视频已暂停"; +"VoiceChat.VideoPreviewBackCamera" = "后置相机"; +"VoiceChat.VideoPreviewContinue" = "继续"; +"VoiceChat.VideoPreviewDescription" = "您确定想要分享您的摄像头吗?"; +"VoiceChat.VideoPreviewFrontCamera" = "前置相机"; +"VoiceChat.VideoPreviewPhoneScreen" = "手机屏幕"; +"VoiceChat.VideoPreviewShareCamera" = "分享摄像头画面"; +"VoiceChat.VideoPreviewShareScreen" = "共享屏幕"; +"VoiceChat.VideoPreviewShareScreenInfo" = "屏幕上的所有内容\n都将被分享"; +"VoiceChat.VideoPreviewStopScreenSharing" = "停止屏幕共享"; +"VoiceChat.VideoPreviewTabletScreen" = "iPad 屏幕"; +"VoiceChat.VideoPreviewTitle" = "视频预览"; +"VoiceChat.You" = "我"; +"VoiceChat.YouAreSharingScreen" = "你正在共享屏幕"; +"VoiceChat.YouCanNowSpeak" = "您现在可以发言了。"; +"VoiceChat.YouCanNowSpeakIn" = "您可以在 **%@** 中发言了。"; +"VoiceChatChannel.Title" = "直播"; +"VoiceOver.AttachMedia" = "发送媒体文件"; +"VoiceOver.AuthSessions.CurrentSession" = "当前会话"; +"VoiceOver.BotCommands" = "机器人命令"; +"VoiceOver.BotKeyboard" = "机器人键盘"; +"VoiceOver.Chat.AnimatedSticker" = "动态贴纸"; +"VoiceOver.Chat.AnimatedStickerFrom" = "来自 %@ 的动态贴纸"; +"VoiceOver.Chat.AnonymousPoll" = "匿名投票"; +"VoiceOver.Chat.AnonymousPollFrom" = "%@ 的匿名投票"; +"VoiceOver.Chat.Caption" = "说明: %@"; +"VoiceOver.Chat.ChannelInfo" = "频道信息"; +"VoiceOver.Chat.Contact" = "已分享的联系人"; +"VoiceOver.Chat.ContactEmail" = "邮件"; +"VoiceOver.Chat.ContactEmailCount#other" = "%@ 个邮件地址"; +"VoiceOver.Chat.ContactFrom" = "%@ 分享的联系人"; +"VoiceOver.Chat.ContactOrganization" = "组织: %@"; +"VoiceOver.Chat.ContactPhoneNumber" = "手机号码"; +"VoiceOver.Chat.ContactPhoneNumberCount#other" = "%@ 个手机号码"; +"VoiceOver.Chat.Duration" = "时长: %@"; +"VoiceOver.Chat.Failed" = "发送失败"; +"VoiceOver.Chat.File" = "文件"; +"VoiceOver.Chat.FileFrom" = "%@ 的文件"; +"VoiceOver.Chat.ForwardedFrom" = "转发自 %@"; +"VoiceOver.Chat.ForwardedFromYou" = "您转发的"; +"VoiceOver.Chat.GoToOriginalMessage" = "跳转到消息"; +"VoiceOver.Chat.GroupInfo" = "群组信息"; +"VoiceOver.Chat.Message" = "消息"; +"VoiceOver.Chat.MessagesSelected#other" = "已选择 %@ 条消息"; +"VoiceOver.Chat.Music" = "音乐消息"; +"VoiceOver.Chat.MusicFrom" = "%@ 的音乐消息"; +"VoiceOver.Chat.MusicTitle" = "%1$@ ,%2$@"; +"VoiceOver.Chat.NotPlayedByRecipient" = "接收者未播放"; +"VoiceOver.Chat.NotPlayedByRecipients" = "接收者未播放"; +"VoiceOver.Chat.OpenHint" = "双击打开"; +"VoiceOver.Chat.OpenLinkHint" = "双击打开链接"; +"VoiceOver.Chat.OptionSelected" = "已选中"; +"VoiceOver.Chat.PagePreview" = "页面预览"; +"VoiceOver.Chat.Photo" = "照片"; +"VoiceOver.Chat.PhotoFrom" = "%@ 的图片"; +"VoiceOver.Chat.PlayHint" = "双击播放"; +"VoiceOver.Chat.PlayedByRecipient" = "接收者已播放"; +"VoiceOver.Chat.PlayedByRecipients" = "接收者已播放"; +"VoiceOver.Chat.PollFinalResults" = "最终结果"; +"VoiceOver.Chat.PollNoVotes" = "无人投票"; +"VoiceOver.Chat.PollOptionCount#other" = "%@ 选项:"; +"VoiceOver.Chat.PollVotes#other" = "%@ 人投票"; +"VoiceOver.Chat.Profile" = "个人资料"; +"VoiceOver.Chat.RecordModeVideoMessage" = "视频消息"; +"VoiceOver.Chat.RecordModeVideoMessageInfo" = "双击并按住以录制视频消息。 向上滑动保持录制,向左滑动取消。 双击切换到语音录制。"; +"VoiceOver.Chat.RecordModeVoiceMessage" = "语音消息"; +"VoiceOver.Chat.RecordModeVoiceMessageInfo" = "双击并按住以录制语音消息。 向上滑动锁定录制,向左滑动取消。 双击切换到视频录制。"; +"VoiceOver.Chat.RecordPreviewVoiceMessage" = "预览语音消息"; +"VoiceOver.Chat.Reply" = "回复消息"; +"VoiceOver.Chat.ReplyFrom" = "回复 %@ 的消息"; +"VoiceOver.Chat.ReplyToYourMessage" = "回复你的消息"; +"VoiceOver.Chat.ReplyingToMessage" = "回复消息:%@"; +"VoiceOver.Chat.SeenByRecipient" = "接收者已读"; +"VoiceOver.Chat.SeenByRecipients" = "接收人已读"; +"VoiceOver.Chat.Selected" = "已选中"; +"VoiceOver.Chat.Sending" = "正在发送"; +"VoiceOver.Chat.Size" = "大小: %@"; +"VoiceOver.Chat.Sticker" = "贴纸"; +"VoiceOver.Chat.StickerFrom" = "来自 %@ 的贴纸"; +"VoiceOver.Chat.Title" = "标题: %@"; +"VoiceOver.Chat.UnreadMessages#other" = "%@ 条未读消息"; +"VoiceOver.Chat.Video" = "视频"; +"VoiceOver.Chat.VideoFrom" = "%@ 的视频"; +"VoiceOver.Chat.VideoMessage" = "视频消息"; +"VoiceOver.Chat.VideoMessageFrom" = "%@ 的视频消息"; +"VoiceOver.Chat.VoiceMessage" = "语音消息"; +"VoiceOver.Chat.VoiceMessageFrom" = "%@ 的语音消息"; +"VoiceOver.Chat.YourAnimatedSticker" = "您的动态贴纸"; +"VoiceOver.Chat.YourAnonymousPoll" = "您的匿名投票"; +"VoiceOver.Chat.YourContact" = "您分享的联系人"; +"VoiceOver.Chat.YourFile" = "您的文件"; +"VoiceOver.Chat.YourMessage" = "您的消息"; +"VoiceOver.Chat.YourMusic" = "您的音乐消息"; +"VoiceOver.Chat.YourPhoto" = "您的图片"; +"VoiceOver.Chat.YourSticker" = "您的贴纸"; +"VoiceOver.Chat.YourVideo" = "您的视频"; +"VoiceOver.Chat.YourVideoMessage" = "您的视频消息"; +"VoiceOver.Chat.YourVoiceMessage" = "您的语音消息"; +"VoiceOver.ChatList.Message" = "消息"; +"VoiceOver.ChatList.MessageEmpty" = "空"; +"VoiceOver.ChatList.MessageFrom" = "来自: %@"; +"VoiceOver.ChatList.MessageRead" = "已读"; +"VoiceOver.ChatList.OutgoingMessage" = "传出消息"; +"VoiceOver.Common.Off" = "关"; +"VoiceOver.Common.On" = "开"; +"VoiceOver.Common.SwitchHint" = "双击切换"; +"VoiceOver.DiscardPreparedContent" = "放弃"; +"VoiceOver.DismissContextMenu" = "隐藏菜单"; +"VoiceOver.Editing.ClearText" = "清除文本"; +"VoiceOver.GiftPremium" = "赠送 Telegram 高级版"; +"VoiceOver.Keyboard" = "键盘"; +"VoiceOver.Media.PlaybackPause" = "暂停"; +"VoiceOver.Media.PlaybackPlay" = "播放"; +"VoiceOver.Media.PlaybackRate" = "播放速度"; +"VoiceOver.Media.PlaybackRateChange" = "双击修改"; +"VoiceOver.Media.PlaybackRateFast" = "快速"; +"VoiceOver.Media.PlaybackRateNormal" = "正常"; +"VoiceOver.Media.PlaybackStop" = "取消循环播放"; +"VoiceOver.MessageContextDelete" = "删除"; +"VoiceOver.MessageContextForward" = "转发"; +"VoiceOver.MessageContextOpenMessageMenu" = "打开消息菜单"; +"VoiceOver.MessageContextReply" = "回复"; +"VoiceOver.MessageContextReport" = "举报"; +"VoiceOver.MessageContextSend" = "发送"; +"VoiceOver.MessageContextShare" = "分享"; +"VoiceOver.MessageSelectionButtonTag" = "标签"; +"VoiceOver.Navigation.Compose" = "组成"; +"VoiceOver.Navigation.ProxySettings" = "代理设置"; +"VoiceOver.Navigation.Search" = "搜索"; +"VoiceOver.Recording.StopAndPreview" = "停止并预览"; +"VoiceOver.ScheduledMessages" = "定时消息"; +"VoiceOver.ScrollStatus" = "第 %1$@ 行,共 %2$@ 行"; +"VoiceOver.SelfDestructTimerOff" = "自毁计时器已关闭"; +"VoiceOver.SelfDestructTimerOn" = "自毁时间: %@"; +"VoiceOver.SilentPostOff" = "安静广播关闭"; +"VoiceOver.SilentPostOn" = "安静广播打开"; +"VoiceOver.Stickers" = "表情"; +"VoiceOver.SuggestPost" = "建议贴文"; +"Wallpaper.ApplyForAll" = "应用于所有聊天"; +"Wallpaper.ApplyForBoth" = "为自己和 %@ 应用"; +"Wallpaper.ApplyForChannel" = "应用壁纸"; +"Wallpaper.ApplyForChat" = "应用于此聊天"; +"Wallpaper.ApplyForMe" = "为自己应用"; +"Wallpaper.ChannelCustomBackgroundInfo" = "上传您自己的背景图片。"; +"Wallpaper.ChannelRemoveBackground" = "移除壁纸"; +"Wallpaper.ChannelTitle" = "频道壁纸"; +"Wallpaper.DeleteConfirmation#other" = "删除 %@ 张壁纸"; +"Wallpaper.ErrorNotFound" = "抱歉,此聊天壁纸似乎不存在。"; +"Wallpaper.NoWallpaper" = "无壁纸"; +"Wallpaper.PhotoLibrary" = "照片图库"; +"Wallpaper.ResetWallpapers" = "重置聊天壁纸"; +"Wallpaper.ResetWallpapersConfirmation" = "重置聊天壁纸"; +"Wallpaper.ResetWallpapersInfo" = "删除所有上传的壁纸,并恢复所有主题的默认预装壁纸。"; +"Wallpaper.Search" = "搜索图片"; +"Wallpaper.SearchShort" = "搜索"; +"Wallpaper.Set" = "设置"; +"Wallpaper.SetColor" = "设置颜色"; +"Wallpaper.SetCustomBackground" = "从图库中选择"; +"Wallpaper.SetCustomBackgroundInfo" = "你可以设置自定义背景图像并与朋友分享。"; +"Wallpaper.Title" = "聊天壁纸"; +"Wallpaper.Wallpaper" = "壁纸"; +"WallpaperColors.SetCustomColor" = "设置自定义颜色"; +"WallpaperColors.Title" = "设置颜色"; +"WallpaperPreview.Animate" = "视觉效果"; +"WallpaperPreview.AnimateDescription" = "发送消息时,背景颜色图案将会移动"; +"WallpaperPreview.Blurred" = "模糊"; +"WallpaperPreview.ChannelHeader" = "所有订阅者都将看到此壁纸"; +"WallpaperPreview.ChannelReplyText" = "突发新闻"; +"WallpaperPreview.ChannelTopText" = "详情稍后公布。\n敬请期待!"; +"WallpaperPreview.ChatBottomText" = "看起来不错。"; +"WallpaperPreview.ChatTopText" = "在这个聊天里应用壁纸吧。"; +"WallpaperPreview.CropBottomText" = "完成后,获得壁纸的分享连接"; +"WallpaperPreview.CropTopText" = "缩放并平移来调整壁纸"; +"WallpaperPreview.CustomColorBottomText" = "选些和背景幕相配的颜色吧"; +"WallpaperPreview.CustomColorTopText" = "使用滑块来调整颜色"; +"WallpaperPreview.GroupHeader" = "所有成员能够查看该壁纸"; +"WallpaperPreview.Motion" = "动态效果"; +"WallpaperPreview.NotAppliedInfo" = "**%@** 可以应用此壁纸"; +"WallpaperPreview.Pattern" = "图案"; +"WallpaperPreview.PatternIntensity" = "图案强度"; +"WallpaperPreview.PatternPaternApply" = "应用"; +"WallpaperPreview.PatternPaternDiscard" = "放弃"; +"WallpaperPreview.PatternTitle" = "选择样式"; +"WallpaperPreview.PreviewBottomText" = "看起来不错"; +"WallpaperPreview.PreviewBottomTextAnimatable" = "点击播放按钮以查看动态壁纸。"; +"WallpaperPreview.PreviewInDayMode" = "以浅色模式预览此壁纸。"; +"WallpaperPreview.PreviewInNightMode" = "在深色模式下预览此壁纸。"; +"WallpaperPreview.PreviewTopText" = "点击“设置”以应用壁纸"; +"WallpaperPreview.SwipeBottomText" = "点击“减弱动态效果”然后试着倾斜您的设备"; +"WallpaperPreview.SwipeColorsBottomText" = "三文鱼是一种鱼,而不是一种颜色"; +"WallpaperPreview.SwipeColorsTopText" = "向左或向右滑动查看更多颜色"; +"WallpaperPreview.SwipeTopText" = "向左或向右滑动以预览更多壁纸"; +"WallpaperPreview.Title" = "壁纸预览"; +"WallpaperPreview.WallpaperColors" = "颜色"; +"WallpaperSearch.ColorBlack" = "黑色"; +"WallpaperSearch.ColorBlue" = "蓝色"; +"WallpaperSearch.ColorBrown" = "棕色"; +"WallpaperSearch.ColorGray" = "灰色"; +"WallpaperSearch.ColorGreen" = "绿色"; +"WallpaperSearch.ColorOrange" = "橙色"; +"WallpaperSearch.ColorPink" = "粉色"; +"WallpaperSearch.ColorPrefix" = "颜色:"; +"WallpaperSearch.ColorPurple" = "紫色"; +"WallpaperSearch.ColorRed" = "红色"; +"WallpaperSearch.ColorTeal" = "青色"; +"WallpaperSearch.ColorTitle" = "按颜色搜索"; +"WallpaperSearch.ColorWhite" = "白色"; +"WallpaperSearch.ColorYellow" = "黄色"; +"WallpaperSearch.Recent" = "最近"; +"Watch.AppName" = "Telegram"; +"Watch.AuthRequired" = "若要开始,请先在手机上登录到 Telegram"; +"Watch.Bot.Restart" = "重新启用"; +"Watch.ChannelInfo.Title" = "频道信息"; +"Watch.ChatList.Compose" = "新建对话"; +"Watch.ChatList.NoConversationsText" = "重按一下,\n再点击「新消息」\n来开始发送信息"; +"Watch.ChatList.NoConversationsTitle" = "尚无对话"; +"Watch.Compose.AddContact" = "选择联系人"; +"Watch.Compose.CreateMessage" = "创建消息"; +"Watch.Compose.CurrentLocation" = "当前位置"; +"Watch.Compose.Send" = "发送"; +"Watch.ConnectionDescription" = "您的手表需要在连接后才能运行应用"; +"Watch.Contacts.NoResults" = "未找到\n符合条件的\n联系人"; +"Watch.Conversation.GroupInfo" = "群组信息"; +"Watch.Conversation.Reply" = "回复"; +"Watch.Conversation.Unblock" = "移出黑名单"; +"Watch.Conversation.UserInfo" = "个人信息"; +"Watch.GroupInfo.Title" = "群组信息"; +"Watch.LastSeen.ALongTimeAgo" = "很久之前"; +"Watch.LastSeen.AtDate" = "%@"; +"Watch.LastSeen.HoursAgo#other" = "%@ 小时前"; +"Watch.LastSeen.JustNow" = "刚刚"; +"Watch.LastSeen.Lately" = "最近"; +"Watch.LastSeen.MinutesAgo#other" = "%@ 分钟前"; +"Watch.LastSeen.WithinAMonth" = "一个月内"; +"Watch.LastSeen.WithinAWeek" = "一周内"; +"Watch.LastSeen.YesterdayAt" = "昨天 %@"; +"Watch.Location.Access" = "允许 Telegram 获取您的位置"; +"Watch.Location.Current" = "当前位置信息"; +"Watch.Message.Call" = "通话"; +"Watch.Message.ForwardedFrom" = "转发自"; +"Watch.Message.Game" = "游戏"; +"Watch.Message.Invoice" = "账单"; +"Watch.Message.Poll" = "投票"; +"Watch.Message.Unsupported" = "不支持的消息"; +"Watch.MessageView.Forward" = "转发"; +"Watch.MessageView.Reply" = "回复"; +"Watch.MessageView.Title" = "消息"; +"Watch.MessageView.ViewOnPhone" = "在手机上查看"; +"Watch.Microphone.Access" = "允许 Telegram 获取您手机上的麦克风权限"; +"Watch.NoConnection" = "无连接"; +"Watch.Notification.Joined" = "已加入 Telegram"; +"Watch.PhotoView.Title" = "图片"; +"Watch.Stickers.RecentPlaceholder" = "您常用的表情会在这里显示。"; +"Watch.Stickers.Recents" = "最近"; +"Watch.Stickers.StickerPacks" = "贴纸包"; +"Watch.Suggestion.BRB" = "马上回来"; +"Watch.Suggestion.CantTalk" = "我现在没空…"; +"Watch.Suggestion.HoldOn" = "等一下…"; +"Watch.Suggestion.OK" = "确定"; +"Watch.Suggestion.OnMyWay" = "在路上。"; +"Watch.Suggestion.TalkLater" = "待会儿再聊?"; +"Watch.Suggestion.Thanks" = "谢谢!"; +"Watch.Suggestion.WhatsUp" = "什么事?"; +"Watch.Time.ShortFullAt" = "%1$@ %2$@"; +"Watch.Time.ShortTodayAt" = "今天 %@"; +"Watch.Time.ShortWeekdayAt" = "%1$@ %2$@"; +"Watch.Time.ShortYesterdayAt" = "昨天 %@"; +"Watch.UserInfo.Block" = "屏蔽"; +"Watch.UserInfo.Mute#other" = "静音 %@ 小时"; +"Watch.UserInfo.MuteTitle" = "静音"; +"Watch.UserInfo.Service" = "服务通知"; +"Watch.UserInfo.Title" = "个人信息"; +"Watch.UserInfo.Unblock" = "移出黑名单"; +"Watch.UserInfo.Unmute" = "开启通知"; +"WatchRemote.AlertOpen" = "查看"; +"WatchRemote.AlertText" = "是否在此查看消息?"; +"WatchRemote.AlertTitle" = "来自 Apple Watch 的消息"; +"WatchRemote.NotificationText" = "在您的手机上打开此通知以查看来自您 Apple Watch 的消息。"; +"Web.Error" = "无法加载页面"; +"Web.OpenExternal" = "在浏览器中打开"; +"WebApp.AddToAttachmentAdd" = "添加"; +"WebApp.AddToAttachmentAllowMessages" = "允许 **%@** 给我发送消息"; +"WebApp.AddToAttachmentAlreadyAddedError" = "此机器人已经在您的附件菜单中。"; +"WebApp.AddToAttachmentSucceeded" = "**%@** 已添加到您的附件菜单中。"; +"WebApp.AddToAttachmentText" = "%@ 请求作为选项添加到您的附件菜单,以便您可以从任何聊天中访问它。"; +"WebApp.AddToAttachmentTitle" = "\u00a0加入附件菜单"; +"WebApp.AddToAttachmentUnavailableError" = "无法将此机器人添加到附件菜单。"; +"WebApp.AddToHomeScreen" = "添加到主屏幕"; +"WebApp.AlertBiometryAccessText" = "你想要允许 %@ 使用面容 ID 吗?"; +"WebApp.AlertBiometryAccessTouchIDText" = "你想要允许 %@ 使用 Touch ID 吗?"; +"WebApp.AllowWriteConfirmation" = "这将允许机器人 **%@** 在 Telegram 上给您发送消息。"; +"WebApp.AllowWriteTitle" = "允许发送消息?"; +"WebApp.CloseAnyway" = "仍然关闭"; +"WebApp.CloseConfirmation" = "所作的更改可能不会保存。"; +"WebApp.DisclaimerAgree" = "我同意 [使用条款]()"; +"WebApp.DisclaimerContinue" = "继续"; +"WebApp.DisclaimerText" = "您即将使用一个 **不属于 Telegram** 的独立第三方小程序。您必须同意小程序使用条款后才能继续。"; +"WebApp.DisclaimerTitle" = "使用条款"; +"WebApp.Disclaimer_URL" = "https://telegram.org/tos/mini-apps"; +"WebApp.Download.Cancel" = "取消"; +"WebApp.Download.Document" = "下载文件"; +"WebApp.Download.Download" = "下载"; +"WebApp.Download.Photo" = "下载照片"; +"WebApp.Download.SavedToFiles" = "已保存为文件。"; +"WebApp.Download.SavedToPhotos" = "已保存为图片。"; +"WebApp.Download.Text" = "**%1$@** 邀请您下载 **%2$@**%3$@。"; +"WebApp.Download.Video" = "下载视频"; +"WebApp.Emoji.Confirm" = "确认"; +"WebApp.Emoji.DurationSucceed" = "您的表情状态会设为 %@。"; +"WebApp.Emoji.DurationText" = "是否要按照 **%1$@** 的建议设置此表情符号状态,持续 **%2$@**?"; +"WebApp.Emoji.Succeed" = "表情符号状态已更新。"; +"WebApp.Emoji.Text" = "你想要设置成 %@ 建议的表情符号状态吗?"; +"WebApp.Emoji.Title" = "设置表情符号状态"; +"WebApp.EmojiPermission.Allow" = "允许"; +"WebApp.EmojiPermission.Decline" = "拒绝"; +"WebApp.EmojiPermission.Succeed" = "**%@** 现在可以随时设置您的表情符号状态了。"; +"WebApp.EmojiPermission.Text" = "**%1$@** 请求设置 **表情符号状态** 的权限,您可以随时在 **%2$@** 的资料页面中撤销此权限。"; +"WebApp.EmojiPermission.Undo" = "撤销"; +"WebApp.GuardDisclaimerText" = "**%1$@** 通过 **%2$@** 欢迎新成员加入。继续操作前,您需同意小程序的使用条款。"; +"WebApp.ImportData.AccountHeader" = "导入数据的账户"; +"WebApp.ImportData.CreatedOn" = "创建于 %@"; +"WebApp.ImportData.Description" = "**%@** 正在请求导入此设备上先前使用的Telegram账号数据的权限。"; +"WebApp.ImportData.Import" = "导入"; +"WebApp.ImportData.Title" = "导入数据"; +"WebApp.LaunchConfirmation" = "您将连接到该机器人的网站以启动其 Web 应用。"; +"WebApp.LaunchMoreInfo" = "本机器人的更多信息"; +"WebApp.LaunchOpenApp" = "打开应用"; +"WebApp.LaunchTermsConfirmation" = "此小程序即表示您同意 [小程序服务条款]()。"; +"WebApp.LaunchTermsConfirmation_URL" = "https://telegram.org/tos/mini-apps"; +"WebApp.LocationPermission.Allow" = "允许"; +"WebApp.LocationPermission.Decline" = "拒绝"; +"WebApp.LocationPermission.Succeed" = "**%@** 现在可以访问您的位置信息了。"; +"WebApp.LocationPermission.Text" = "**%1$@** 请求访问您的位置信息,您可以随时在 **%2$@** 的资料页面中撤销此权限。"; +"WebApp.LocationPermission.Undo" = "撤销"; +"WebApp.MessagePreview" = "消息预览"; +"WebApp.Miniapp" = "小程序"; +"WebApp.Minimized.CloseAll#other" = "关闭所有 %@ 个标签页"; +"WebApp.Minimized.CloseAllTitle" = "您确定要关闭所有打开的标签页吗?"; +"WebApp.MinimizedTitle.Others#other" = "其它 %@ 个"; +"WebApp.MinimizedTitleFormat" = "%1$@ 及 %2$@"; +"WebApp.OpenBot" = "打开机器人"; +"WebApp.OpenWebViewAlertText" = "**%@** 想要打开其网页应用以继续。\n\n它将可以访问到您的 **IP 地址** 和基本设备信息。"; +"WebApp.OpenWebViewAlertTitle" = "打开应用"; +"WebApp.PrivacyPolicy" = "隐私权政策"; +"WebApp.PrivacyPolicy_URL" = "https://telegram.org/privacy-tpa"; +"WebApp.ReloadPage" = "重新加载页面"; +"WebApp.RemoveAllConfirmationText" = "这会从所有菜单中移除 **%@** 快捷方式。"; +"WebApp.RemoveBot" = "从菜单中移除"; +"WebApp.RemoveConfirmationText" = "要从附件菜单中删除 **%@** 吗?"; +"WebApp.RemoveConfirmationTitle" = "移除机器人"; +"WebApp.SelectChat" = "选择聊天"; +"WebApp.Send" = "发送"; +"WebApp.Settings" = "设置"; +"WebApp.Share" = "分享"; +"WebApp.ShareMessage.Info" = "%@ 小程序建议您将此消息发送到您所选的聊天。"; +"WebApp.ShareMessage.PreviewTitle" = "消息预览"; +"WebApp.ShareMessage.Share" = "分享给..."; +"WebApp.ShareMessage.Title" = "分享消息"; +"WebApp.ShareMyPhoneNumber" = "分享我的手机号码"; +"WebApp.ShareMyPhoneNumberConfirmation" = "您确定要与 **%2$@** 共享您的手机号码 **%1$@** 吗?"; +"WebApp.SharePhoneConfirmation" = "**%@** 将会获取您的手机号码。这可能对机器人与其它服务的集成有用。"; +"WebApp.SharePhoneConfirmationUnblock" = "**%@** 将会知道您的手机号码,这有助于整合其他服务。\n\n同时也将解除对该机器人的屏蔽。"; +"WebApp.SharePhoneTitle" = "分享手机号码?"; +"WebApp.ShortcutsAdded" = "**%@** 快捷方式已添加到附件菜单中。"; +"WebApp.ShortcutsSettingsAdded" = "**%@** 快捷方式已添加至附件菜单和设置中。"; +"WebApp.TermsOfUse" = "使用条款"; +"WebApp.TermsOfUse_URL" = "https://telegram.org/tos/mini-apps"; +"WebBrowser.AddBookmark" = "添加书签"; +"WebBrowser.AddressBar.Bookmarks" = "书签"; +"WebBrowser.AddressBar.RecentlyVisited" = "最近访问"; +"WebBrowser.AddressBar.RecentlyVisited.Clear" = "清除"; +"WebBrowser.AddressBar.ShowMore" = "显示更多"; +"WebBrowser.AddressPlaceholder" = "输入网址"; +"WebBrowser.AuthChallenge.Text" = "您的登录信息将会安全地发送。"; +"WebBrowser.AuthChallenge.Title" = "登录 %@"; +"WebBrowser.AutoLogin" = "通过 Telegram 自动登录"; +"WebBrowser.AutoLogin.Info" = "使用您的 Telegram 账号自动登录应用内浏览器中打开的网站。"; +"WebBrowser.Bookmarks.BookmarkCurrent" = "在目前页面添加书签"; +"WebBrowser.Bookmarks.Title" = "书签"; +"WebBrowser.ClearCache" = "清除缓存"; +"WebBrowser.ClearCache.ClearConfirmation.Clear" = "清除"; +"WebBrowser.ClearCache.ClearConfirmation.Text" = "您确定要清除缓存吗?"; +"WebBrowser.ClearCache.Succeed" = "已清除缓存。"; +"WebBrowser.ClearCookies" = "清除 Cookie"; +"WebBrowser.ClearCookies.ClearConfirmation.Clear" = "清除"; +"WebBrowser.ClearCookies.ClearConfirmation.Text" = "您确定要清除 Cookie?"; +"WebBrowser.ClearCookies.Info" = "在 Telegram 应用内浏览器删除全部 Cookie。此操作将使您从大多数网站登出。"; +"WebBrowser.ClearCookies.Succeed" = "Cookie 已清除。"; +"WebBrowser.CopyLink" = "拷贝链接"; +"WebBrowser.DefaultBrowser" = "默认浏览器"; +"WebBrowser.DeleteBookmark" = "删除书签"; +"WebBrowser.Done" = "完成"; +"WebBrowser.Download.Confirmation" = "您想要下载 \"%@\" 吗?"; +"WebBrowser.Download.Download" = "下载"; +"WebBrowser.Exception.OpenInApp" = "始终在应用内打开此网站"; +"WebBrowser.Exception.OpenInBrowser" = "始终在浏览器中打开此网站"; +"WebBrowser.ExceptionAdded.Text" = "本网站将始终在应用内打开。"; +"WebBrowser.ExceptionAdded.Title" = "例外已添加"; +"WebBrowser.Exceptions.AddException" = "添加网站"; +"WebBrowser.Exceptions.Clear" = "清除清单"; +"WebBrowser.Exceptions.ClearConfirmation.Clear" = "清除"; +"WebBrowser.Exceptions.ClearConfirmation.Text" = "您确定要清除此清单吗?"; +"WebBrowser.Exceptions.Create.ExternalText" = "请输入要在内置浏览器中打开的域名。"; +"WebBrowser.Exceptions.Create.Placeholder" = "输入网址"; +"WebBrowser.Exceptions.Create.Text" = "输入您不希望在应用内浏览器中打开的域名。"; +"WebBrowser.Exceptions.Create.Title" = "添加网站"; +"WebBrowser.Exceptions.DeleteAll" = "删除全部例外"; +"WebBrowser.Exceptions.DontOpenInApp" = "不在 App 内打开"; +"WebBrowser.Exceptions.InAppInfo" = "此网站仍将在 App 内打开。"; +"WebBrowser.Exceptions.Info" = "这些网站将始终在您的默认浏览器中打开。"; +"WebBrowser.Exceptions.OpenInApp" = "在 App 内打开"; +"WebBrowser.Exceptions.Title" = "从不在应用内浏览器中打开"; +"WebBrowser.FileForwardTooltip.Chat.One" = "文件已转发到 **%@**"; +"WebBrowser.FileForwardTooltip.ManyChats.One" = "文件已转发到 **%@** 和其他 %@ 人"; +"WebBrowser.FileForwardTooltip.SavedMessages.One" = "文件已转发到 **我的收藏**"; +"WebBrowser.FileForwardTooltip.TwoChats.One" = "文件已转发到 **%@** 和 **%@**"; +"WebBrowser.HideInstantView" = "隐藏即时预览"; +"WebBrowser.InAppSafari" = "应用内 Safari"; +"WebBrowser.LinkAddedToBookmarks" = "已将链接添加至 **我的收藏** 中的书签。"; +"WebBrowser.LinkForwardTooltip.Chat.One" = "链接转发到 **%@**"; +"WebBrowser.LinkForwardTooltip.ManyChats.One" = "已将链接转发至 **%@** 和其它 %@ 人"; +"WebBrowser.LinkForwardTooltip.SavedMessages.One" = "链接转发到 **我的人收藏**"; +"WebBrowser.LinkForwardTooltip.TwoChats.One" = "已将链接转发至 **%@** 和 **%@**"; +"WebBrowser.OpenLinksIn.Title" = "打开链接于"; +"WebBrowser.OpenLinksInfo" = "无需使用外部浏览器应用 Telegram 内打开链接。"; +"WebBrowser.PassExistsError" = "此通票已添加到钱包中。"; +"WebBrowser.Reload" = "刷新"; +"WebBrowser.RemoveRecent" = "从最近的清单中移除"; +"WebBrowser.Share" = "分享"; +"WebBrowser.ShowInstantView" = "显示即时预览"; +"WebBrowser.Telegram" = "Telegram"; +"WebBrowser.Title" = "浏览器"; +"WebPreview.GettingLinkInfo" = "正在获取链接信息…"; +"WebSearch.GIFs" = "GIF"; +"WebSearch.Images" = "图片"; +"WebSearch.RecentClearConfirmation" = "您确定要清空“近期图片”列表吗?"; +"WebSearch.RecentSectionClear" = "清空"; +"WebSearch.RecentSectionTitle" = "最近"; +"WebSearch.SearchNoResults" = "暂无结果"; +"WebSearch.SearchNoResultsDescription" = "没有找到“%@”的相关结果。\n请重新搜索。"; +"Weekday.Friday" = "星期五"; +"Weekday.Monday" = "星期一"; +"Weekday.Saturday" = "星期六"; +"Weekday.ShortFriday" = "周五"; +"Weekday.ShortMonday" = "周一"; +"Weekday.ShortSaturday" = "周六"; +"Weekday.ShortSunday" = "周日"; +"Weekday.ShortThursday" = "周四"; +"Weekday.ShortTuesday" = "周二"; +"Weekday.ShortWednesday" = "周三"; +"Weekday.Sunday" = "星期天"; +"Weekday.Thursday" = "星期四"; +"Weekday.Today" = "今天"; +"Weekday.Tuesday" = "星期二"; +"Weekday.Wednesday" = "星期三"; +"Weekday.Yesterday" = "昨天"; +"Widget.ApplicationLocked" = "解锁 App 以使用小组件"; +"Widget.ApplicationStartRequired" = "打开 App 以使用小组件"; +"Widget.AuthRequired" = "登录到 Telegram"; +"Widget.ChatsGalleryDescription" = "显示来自最重要聊天的最新消息。"; +"Widget.ChatsGalleryTitle" = "聊天"; +"Widget.LongTapToEdit" = "点击或按住以编辑微件。"; +"Widget.MessageAutoremoveTimerRemoved" = "已停用自动删除计时器"; +"Widget.MessageAutoremoveTimerUpdated" = "自动删除计时器已更新"; +"Widget.NoUsers" = "发送消息后,就能在这里看到您的朋友"; +"Widget.ShortcutsGalleryDescription" = "显示您最重要聊天的快捷方式,以便随时快速访问它们。"; +"Widget.ShortcutsGalleryTitle" = "快捷方式"; +"Widget.UpdatedAt" = "已更新 {}"; +"Widget.UpdatedTodayAt" = "更新于 {}"; +"Your_card_has_expired" = "您的银行卡已过期。"; +"Your_card_was_declined" = "您的银行卡被拒绝。"; +"Your_cards_expiration_month_is_invalid" = "您输入的过期月份无效。"; +"Your_cards_expiration_year_is_invalid" = "您输入的过期年份无效。"; +"Your_cards_number_is_invalid" = "您输入的卡号无效。"; +"Your_cards_security_code_is_invalid" = "您输入的安全码无效。"; diff --git a/data/langpack/macos/macos_en_v2224214.strings b/data/langpack/macos/macos_en_v2224214.strings new file mode 100644 index 00000000..918a7f75 --- /dev/null +++ b/data/langpack/macos/macos_en_v2224214.strings @@ -0,0 +1,8366 @@ +"1000.title" = "Check for Updates"; +"1Xt-HY-uBw.title" = "Telegram"; +"2oI-Rn-ZJC.title" = "Transformations"; +"4J7-dP-txa.title" = "Enter Full Screen"; +"4sb-4s-VLi.title" = "Quit Telegram"; +"5QF-Oa-p0T.title" = "Edit"; +"5kV-Vb-QxS.title" = "About Telegram"; +"6dh-zS-Vam.title" = "Redo"; +"78Y-hA-62v.title" = "Correct Spelling Automatically"; +"9ic-FL-obx.title" = "Substitutions"; +"9yt-4B-nSM.title" = "Smart Copy/Paste"; +"AIStyleModal.AddAuthorLink" = "Add a link to my account"; +"AIStyleModal.Create" = "Create"; +"AIStyleModal.DeleteStyle" = "Delete Style"; +"AIStyleModal.DeleteStyleConfirm" = "Are you sure you want to remove this style?"; +"AIStyleModal.DeleteStyleForAll" = "Delete for All"; +"AIStyleModal.DeleteStyleHeader" = "Delete Style"; +"AIStyleModal.EditStyleTitle" = "Edit Style"; +"AIStyleModal.InstructionsPlaceholder" = "Instructions (for example, \"Write like a swashbuckling pirate. Use arr, ye, matey, and talk about treasure and the sea\")"; +"AIStyleModal.NamePlaceholder" = "Style Name (for example, \"Pirate\")"; +"AIStyleModal.NewStyleTitle" = "New Style"; +"AIStyleModal.Save" = "Save"; +"AIStyleModal.SavedLimitPremium#other" = "You can save up to %1$d styles. Subscribe to Telegram Premium to increase this limit to %2$d."; +"AIStyleModal.SavedLimitReached#other" = "You have reached the limit of %1$d saved styles."; +"AISummary.ClickToSeeOriginal" = "Click to see original text"; +"AISummary.Title" = "AI Summary"; +"About.Description" = "Free messaging app for macOS based on MTProto for speed and security."; +"AboutCocoon.Description" = "Cocoon (**Co**nfidential **Co**mpute **O**pen **N**etwork) handles AI tasks **safely** and **efficiently**."; +"AboutCocoon.Feature.Efficient.Description" = "Cocoon has allowed Telegram to reduce translation costs by 6x."; +"AboutCocoon.Feature.Efficient.Title" = "Efficient"; +"AboutCocoon.Feature.ForEveryone.Description" = "Any developer can use Cocoon for AI features. Learn more at [@cocoon](@cocoon) or [cocoon.org](cocoon.org)."; +"AboutCocoon.Feature.ForEveryone.Title" = "For Everyone"; +"AboutCocoon.Feature.Private.Description" = "No third party can access any data, such as translations, inside Cocoon."; +"AboutCocoon.Feature.Private.Title" = "Private"; +"AboutCocoon.Footer" = "Want to integrate Cocoon into your projects?\nReach out at [t.me/cocoon](t.me/cocoon)"; +"AboutCocoon.Title" = "COCOON"; +"AboutFeature.DisableSharing.DisableButton" = "Disable Sharing"; +"AboutFeature.DisableSharing.NoForwarding" = "No Forwarding"; +"AboutFeature.DisableSharing.NoForwardingDesc" = "Disable message forwarding to other chats."; +"AboutFeature.DisableSharing.NoSaving" = "No Saving"; +"AboutFeature.DisableSharing.NoSavingDesc" = "Disable copying text and saving photos or videos."; +"AboutFeature.DisableSharing.NoScreenshots" = "No Screenshots"; +"AboutFeature.DisableSharing.NoScreenshotsDesc" = "Disable screenshots and screen recordings in this chat."; +"AboutFeature.DisableSharing.Title" = "Disable Sharing"; +"AboutFeature.DisableSharing.UnlockButton" = "Unlock with Premium"; +"AccentColors.Tinted" = "Tinted"; +"Account.Confirm.AskQuestion" = "Please note that Telegram Support is done by volunteers. We try to respond as quickly as possible, but it may take some time.\n\nPlease take a look at the Telegram FAQ: it has important troubleshooting tips and answers to most questions."; +"Account.Confirm.GoToFaq" = "Open FAQ"; +"Account.Confirm.Logout" = "Log out?"; +"Account.Confirm.LogoutText" = "Remember, logging out cancels all your Secret Chats."; +"Account.OpenInWindow" = "Open In Window"; +"AccountSettings.About" = "About"; +"AccountSettings.AddAccount" = "Add Account"; +"AccountSettings.AskQuestion" = "Ask a Question"; +"AccountSettings.Bio" = "Bio"; +"AccountSettings.ChangeStatus" = "Change Emoji Status"; +"AccountSettings.CurrentLanguage" = "English"; +"AccountSettings.DataAndStorage" = "Data and Storage"; +"AccountSettings.DeleteAccount" = "Remove Account"; +"AccountSettings.FAQ" = "Telegram FAQ"; +"AccountSettings.Filters" = "Chat Folders"; +"AccountSettings.General" = "General"; +"AccountSettings.GiftPremium" = "Premium Gifting"; +"AccountSettings.Language" = "Language"; +"AccountSettings.Logout" = "Log Out"; +"AccountSettings.MyProfile" = "My Profile"; +"AccountSettings.MyStories" = "My Stories"; +"AccountSettings.Notifications" = "Notifications and Sounds"; +"AccountSettings.Passport" = "Telegram Passport"; +"AccountSettings.Premium" = "Telegram Premium"; +"AccountSettings.PrivacyAndSecurity" = "Privacy and Security"; +"AccountSettings.ProtectAccount.Info" = "Set a password that will be required each time you log in with this phone number."; +"AccountSettings.ProtectAccount.Set" = "Set Additional Password"; +"AccountSettings.ProtectAccount.Title" = "Protect Your Account"; +"AccountSettings.Proxy" = "Proxy"; +"AccountSettings.Proxy.Connected" = "Connected"; +"AccountSettings.Proxy.Connecting" = "Connecting"; +"AccountSettings.Proxy.Disabled" = "Disabled"; +"AccountSettings.ReadArticles" = "Read Articles"; +"AccountSettings.SendGift" = "Send a Gift"; +"AccountSettings.SetBio" = "Set a Bio"; +"AccountSettings.SetProfileColor" = "Set Profile Color"; +"AccountSettings.SetProfilePhoto" = "Set Profile Photo"; +"AccountSettings.SetUsername" = "Set a Username"; +"AccountSettings.Stars" = "My Stars"; +"AccountSettings.Stickers" = "Stickers and Emoji"; +"AccountSettings.StickersAndEmoji" = "Stickers and Emoji"; +"AccountSettings.TelegramBusiness" = "Telegram Business"; +"AccountSettings.Theme" = "Appearance"; +"AccountSettings.Ton" = "My Grams"; +"AccountSettings.UpdateStatus" = "Update Status"; +"AccountSettings.Username" = "Username"; +"AccountSettings.Wallet" = "Gram Wallet"; +"AccountViewController.Desc.Failed" = "failed"; +"AccountViewController.Desc.Updated" = "updated"; +"AccountViewController.Title" = "Settings"; +"AccountViewController.Update" = "Update"; +"AccountsController.NewAccount" = "New Account"; +"AddContact.AddNotesPlaceholder" = "Add notes only visible to you"; +"Admins.AddAdmin" = "Add Admin"; +"Admins.Admin" = "Admin"; +"Admins.ChannelAdmins" = "CHANNEL ADMINS"; +"Admins.ChannelDescription" = "You can add admins to help you manage your channel"; +"Admins.Creator" = "Owner"; +"Admins.EverbodyCanAddMembers" = "Any member can add new members"; +"Admins.GroupAdmins" = "GROUP ADMINS"; +"Admins.GroupDescription" = "You can add admins to help you manage your group."; +"Admins.OnlyAdminsCanAddMembers" = "Only admins can add new members."; +"Admins.Owner" = "Owner"; +"Admins.SelectNewAdminTitle" = "Contacts"; +"Admins.WhoCanInvite.Admins" = "Only Admins"; +"Admins.WhoCanInvite.Everyone" = "All Members"; +"Admins.WhoCanInvite.Text" = "Who can add members"; +"AffilateProgram.Earn" = "Earn Stars"; +"AffilateProgram.Earn.Info" = "Distribute links to mini apps and earn a share of their revenue in Stars"; +"Affiliate.Duration" = "DURATION"; +"Affiliate.DurationOneYear" = "1 YEAR"; +"Affiliate.Program.StartDelay" = "You will be able to start a new affiliate program only in:\n\n%@"; +"Affiliate.Start" = "Start"; +"Affiliate.Update" = "Update"; +"AffiliateProgram.ActionCopyLink" = "Copy Link"; +"AffiliateProgram.ActionJoin" = "Join Program"; +"AffiliateProgram.Alert.Connected" = "You have successfully connected to the affiliate program"; +"AffiliateProgram.CommistionDestinationText" = "Commission will be sent to:"; +"AffiliateProgram.DailyRevenueText" = "App's average daily revenue per user: #**%@**"; +"AffiliateProgram.DurationLifetime" = "Lifetime"; +"AffiliateProgram.JoinSubtitle" = "**%1$@** will share **%2$@** of the revenue from each user you refer to it for **%3$@**."; +"AffiliateProgram.JoinSubtitleLifetime" = "**{bot}** will share **{commission}** of the revenue from each user you refer to it **forever**."; +"AffiliateProgram.JoinSubtitleMonths_1" = "**{bot}** will share **{commission}** of the revenue from each user you refer to it for **1 month.**"; +"AffiliateProgram.JoinSubtitleMonths_any" = "**{bot}** will share **{commission}** of the revenue from each user you refer to it for **%d months**."; +"AffiliateProgram.JoinSubtitleYears_1" = "**{bot}** will share **{commission}** of the revenue from each user you refer to it for **1 year**."; +"AffiliateProgram.JoinSubtitleYears_any" = "**{bot}** will share **{commission}** of the revenue from each user you refer to it for **%d years**."; +"AffiliateProgram.JoinTerms" = "By joining this program, you agree to the [terms and conditions](https://telegram.org/tos/affiliate-program) of Affiliate Programs."; +"AffiliateProgram.JoinTitle" = "Affiliate Program"; +"AffiliateProgram.LinkSubtitle" = "Share this link with your users to earn a **%1$d** commission on their spending in **%2$@** for **%3$@** after they follow your link."; +"AffiliateProgram.LinkSubtitleLifetime" = "Share this link with your users to earn a **{commission}** commission on their spending in **{bot}** **forever** after they follow your link."; +"AffiliateProgram.LinkSubtitleMonths_1" = "Share this link with your users to earn a **{commission}** commission on their spending in **{bot}** for **1 month** after they follow your link."; +"AffiliateProgram.LinkSubtitleMonths_any" = "Share this link with your users to earn a **{commission}** commission on their spending in **{bot}** for **%d months** after they follow your link."; +"AffiliateProgram.LinkSubtitleYears_1" = "Share this link with your users to earn a **{commission}** commission on their spending in **{bot}** for **1 year** after they follow your link."; +"AffiliateProgram.LinkSubtitleYears_any" = "Share this link with your users to earn a **{commission}** commission on their spending in **{bot}** for **%d years** after they follow your link."; +"AffiliateProgram.LinkTitle" = "Referral Link"; +"AffiliateProgram.MonthlyText" = "Monthly users"; +"AffiliateProgram.Off" = "Off"; +"AffiliateProgram.PeerTypeSelf" = "personal account"; +"AffiliateProgram.SortSelectorDate" = "Date"; +"AffiliateProgram.SortSelectorProfitability" = "Profitability"; +"AffiliateProgram.SortSelectorRevenue" = "Revenue"; +"AffiliateProgram.ToastJoined.Text" = "You can now copy the referral link."; +"AffiliateProgram.ToastJoined.Title" = "Program joined"; +"AffiliateProgram.ToastLinkCopied.Text" = "Share this link and earn **%1$@** of what people who use it spend in **%2$@**!"; +"AffiliateProgram.ToastLinkCopied.Title" = "Link copied to clipboard"; +"AffiliateProgram.UserCountFooter#one" = "%d user opened %@ through this link."; +"AffiliateProgram.UserCountFooter#other" = "%d users opened %@ through this link."; +"AffiliateProgram.UserCountFooter_0" = "No one opened {bot} through this link yet."; +"AffiliateProgram.UserCountFooter_1" = "1 user opened {bot} through this link."; +"AffiliateProgram.UserCountFooter_ZeroValueHolder" = "No one opened %@ through this link yet."; +"AffiliateProgram.UserCountFooter_any" = "%d users opened {bot} through this link."; +"AffiliateProgram.UserCountFooter_countable" = "%d %@"; +"AffiliatePrograms.Confirm.Alert" = "Are you sure you want to leave this program?"; +"AffiliatePrograms.Confirm.Alert.OK" = "Leave"; +"AffiliatePrograms.Empty" = "No available programs yet. Please check this page later."; +"AffiliatePrograms.ExistingPrograms" = "Existing Affiliate Programs"; +"AffiliateSetup.AlertApply.Action" = "Start"; +"AffiliateSetup.AlertApply.SectionCommission" = "Commission"; +"AffiliateSetup.AlertApply.SectionDuration" = "Duration"; +"AffiliateSetup.AlertApply.Text" = "Once you start the affiliate program, you won't be able to decrease its commission or duration. You can only increase these parameters or end the program, which will disable all previously distributed referral links."; +"AffiliateSetup.AlertApply.Title" = "Warning"; +"AffiliateSetup.AlertTerminate.Action" = "End Anyway"; +"AffiliateSetup.AlertTerminate.Text" = "If you end your affiliate program:\n\n• Any referral links already shared will be disabled in 24 hours.\n\n• All participating affiliates will be notified.\n\n• You will not be able to start a new affiliate program for 24 hours."; +"AffiliateSetup.AlertTerminate.Title" = "Warning"; +"AffiliateSetup.ConnectedSectionTitle" = "MY PROGRAMS"; +"AffiliateSetup.EndAction" = "End Affiliate Program"; +"AffiliateSetup.ExistingPrograms.Action" = "View Existing Programs"; +"AffiliateSetup.ExistingPrograms.Footer" = "Explore what other mini apps offer."; +"AffiliateSetup.Header.Info" = "Reward those who help grow your user base"; +"AffiliateSetup.IntroJoin.Text1" = "Receive guaranteed commissions for spending by users you refer."; +"AffiliateSetup.IntroJoin.Text2" = "Track your commissions from referred users in real time."; +"AffiliateSetup.IntroJoin.Text3" = "Choose a mini app below, get your referral link, and start earning Stars."; +"AffiliateSetup.IntroJoin.Title1" = "Reliable"; +"AffiliateSetup.IntroJoin.Title2" = "Transparent"; +"AffiliateSetup.IntroJoin.Title3" = "Simple"; +"AffiliateSetup.IntroNew.Text1" = "Set the commission for revenue generated by users referred to you."; +"AffiliateSetup.IntroNew.Text2" = "Telegram will feature your program for millions of potential affiliates."; +"AffiliateSetup.IntroNew.Text3" = "Affiliates will share your referral link with their audience."; +"AffiliateSetup.IntroNew.Title1" = "Share revenue with affiliates"; +"AffiliateSetup.IntroNew.Title2" = "Launch your affiliate program"; +"AffiliateSetup.IntroNew.Title3" = "Let affiliates promote you"; +"AffiliateSetup.ProgramLeave" = "Leave"; +"AffiliateSetup.ProgramMenu.CopyLink" = "Copy Link"; +"AffiliateSetup.ProgramMenu.Leave" = "Leave"; +"AffiliateSetup.ProgramMenu.OpenApp" = "Open App"; +"AffiliateSetup.ProgramMenu.OpenBot" = "Open Bot"; +"AffiliateSetup.SectionCommission" = "COMMISSION"; +"AffiliateSetup.SectionCommissionFooter" = "Define the percentage of star revenue your affiliates earn for referring users to your bot."; +"AffiliateSetup.SectionDuration" = "DURATION"; +"AffiliateSetup.SectionDurationFooter" = "Set the duration for which affiliates will earn commissions from referred users."; +"AffiliateSetup.SortSectionHeader" = "SORT BY"; +"AffiliateSetup.Start" = "Start"; +"AffiliateSetup.StartTitle" = "Start Affiliate Program"; +"AffiliateSetup.SuggestedSectionEmpty" = "No available programs yet.\nPlease check the page later."; +"AffiliateSetup.SuggestedSectionTitle" = "PROGRAMS"; +"AffiliateSetup.TermsFooter" = "By creating an affiliate program, you agree to the [terms and conditions](https://telegram.org/terms) of Affiliate Programs."; +"AffiliateSetup.TextJoin" = "Earn a commission each time a user who first accessed a mini app through your referral link spends **Stars** within it."; +"AffiliateSetup.TextNew" = "Reward those who help grow your user base."; +"AffiliateSetup.TitleJoin" = "Affiliate Programs"; +"AffiliateSetup.TitleNew" = "Affiliate Program"; +"AffiliateSetup.ToastStarted.Text" = "Affiliate Program Started"; +"AffiliateSetup.ToastTerminated.Text" = "Participating affiliates have been notified. All referral links will be disabled in 24 hours."; +"AffiliateSetup.ToastTerminated.Title" = "Affiliate Program Ended"; +"AffiliateSetup.Update" = "Update"; +"AffiliateSetup.UpdateTitle" = "Update Affiliate Program"; +"AgeVerification.Text" = "To access this content, you must confirm you are at least **18** years old.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by Telegram."; +"AgeVerification.Text.GB" = "To access this content, you must confirm you are at least **18** years old as required by UK law.\n\nThis is a one-time process using your phone's camera. Your selfie will not be stored by Telegram."; +"AgeVerification.Title" = "Age Verification"; +"AgeVerification.Verify" = "Verify My Age"; +"AiStylePreview.AddByAuthor" = "Add this style by [%1$@](%1$@) to transform your messages in the AI editor."; +"AiStylePreview.AddNoAuthor" = "Add this style to transform your messages in the AI editor."; +"AiStylePreview.AddStyle" = "Add Style"; +"AiStylePreview.AnotherExample" = "↻ ANOTHER EXAMPLE"; +"AiStylePreview.DefaultDescription" = "A style you can add to the AI Editor to rewrite your messages."; +"AiStylePreview.ExampleHeader" = "EXAMPLE"; +"AiStylePreview.LabelAfter" = "After"; +"AiStylePreview.LabelBefore" = "Before"; +"AiStylePreview.OpenLinkOnTelegram" = "Open the link on Telegram to add this style."; +"Alert.ButtonOK.UpdateApp" = "Update App"; +"Alert.Cancel" = "Cancel"; +"Alert.Confirm.Discard" = "Discard"; +"Alert.Confirm.Stop" = "Stop"; +"Alert.Discard" = "Discard"; +"Alert.Forward.Error" = "Sorry, you can't forward messages to this conversation."; +"Alert.HideNewChats.Cancel" = "Cancel"; +"Alert.HideNewChats.Header" = "Hide new chats?"; +"Alert.HideNewChats.OK" = "Go to Settings"; +"Alert.HideNewChats.Text" = "You are receiving lots of new chats from users who are not in your Contact List. Do you want to have such chats automatically muted and archived?"; +"Alert.LearnMore" = "Learn More..."; +"Alert.NO" = "No"; +"Alert.OK" = "OK"; +"Alert.PrivateChannel.AccessError" = "Unfortunately, you can't access this message. You aren't a member of the chat where it was posted."; +"Alert.SendError.Delete" = "Delete"; +"Alert.SendError.Header" = "Your message could not be sent"; +"Alert.SendError.Ignore" = "Ignore"; +"Alert.SendError.Resend" = "Resend"; +"Alert.SendError.ResendItems#one" = "Resend %d message"; +"Alert.SendError.ResendItems#other" = "Resend %d messages"; +"Alert.SendError.ResendItems_countable" = "%d"; +"Alert.SendError.Text" = "An error occurred while sending the previous message. Would you like to try resending it?"; +"Alert.UserDoesntExists" = "Sorry, this user doesn't seem to exist."; +"Alert.View" = "View"; +"Alert.Yes" = "Yes"; +"App.MaxFileSize" = "Maximum file size is 2 GB"; +"App.MaxFileSize1" = "Maximum file size is 2 GB"; +"App.MaxFileSizeNew" = "Maximum file size is %@"; +"App.Tooltip.VideoRecord" = "Hold to record video. Click to switch to voice."; +"App.Tooltip.VoiceRecord" = "Hold to record voice. Click to switch to video."; +"AppUpdate.CheckForUpdates" = "Check for Updates"; +"AppUpdate.DownloadUpdate" = "Download Update"; +"AppUpdate.Downloading" = "Downloading..."; +"AppUpdate.Error.FailedDownload" = "Failed to download archive. Please try again."; +"AppUpdate.Error.FailedUpdating" = "Failed to download updating info. Please try again."; +"AppUpdate.Error.NoInternal" = "No internal link for this version. Please try again."; +"AppUpdate.Error.WrongInternal" = "Wrong internal link. Please try again."; +"AppUpdate.NewestAvailable" = "Please update the app to get the latest features and improvements."; +"AppUpdate.RetrievingInfo" = "Retrieving Information..."; +"AppUpdate.Title" = "Updates"; +"AppUpdate.Title.New" = "NEW VERSION (your version: %@)"; +"AppUpdate.Title.Previous" = "PREVIOUS VERSIONS"; +"AppUpdate.Unarchiving" = "Unarchiving..."; +"AppUpdate.Uptodate" = "You have the latest version of Telegram."; +"Appearance.CloudTheme.Unsupported" = "No macOS version, click to edit"; +"Appearance.CloudThemes" = "CLOUD THEMES"; +"Appearance.Confirm.RemoveOK" = "Remove"; +"Appearance.Confirm.RemoveText" = "Are you sure you want to remove this theme?"; +"Appearance.Confirm.RemoveTitle" = "Theme"; +"Appearance.CustomBackground" = "Custom Wallpaper"; +"Appearance.CustomBackground.FileError" = "The file size must not exceed 10MB and the image dimensions must not exceed 500x500px."; +"Appearance.DockIcon.Premium" = "Subscribe to **Telegram Premium** to unlock a wider range of app Icons."; +"Appearance.ExportTheme" = "Export Theme"; +"Appearance.NewTheme" = "New Theme"; +"Appearance.Reset" = "Reset to Defaults"; +"Appearance.Settings.AutoNight" = "Auto-Night Mode"; +"Appearance.Settings.AutoNightHeader" = "AUTO-NIGHT MODE"; +"Appearance.Settings.BubblesMode" = "Bubble Mode"; +"Appearance.Settings.DarkMode" = "Dark Mode"; +"Appearance.Settings.ShowLess" = "Show Less"; +"Appearance.Settings.ShowMore" = "Show More"; +"Appearance.Theme.Accent" = "Accent"; +"Appearance.Theme.Accent.Messages" = "Messages"; +"Appearance.Theme.Edit" = "Edit"; +"Appearance.Theme.Remove" = "Remove"; +"Appearance.Theme.Share" = "Share"; +"Appearance.YourNameColor" = "Your Name Color"; +"AppearanceSettings.ChatPreview.1" = "Good morning! 👋"; +"AppearanceSettings.ChatPreview.2" = "Do you know what time it is?"; +"AppearanceSettings.ChatPreview.3" = "It's morning in Tokyo 😎"; +"AppearanceSettings.ChatPreview.FirstText" = "Ah, you kids today with techno music! You should enjoy the classics, like Hasselhoff!"; +"AppearanceSettings.ChatPreview.Header" = "CHAT PREVIEW"; +"AppearanceSettings.ChatPreview.SecondText" = "I can't even take you seriously right now."; +"AppearanceSettings.ChatPreview.UserName1" = "Lucio"; +"AppearanceSettings.ChatPreview.UserName2" = "Reinhardt"; +"AppearanceSettings.ChatPreview.ZeroText" = "Reinhardt, we need to find you some new tunes 🎶."; +"AppearanceSettings.ChatView.Bubbles" = "Bubbles"; +"AppearanceSettings.ChatView.Classic" = "Minimalist"; +"AppearanceSettings.ChatView.Header" = "CHAT VIEW"; +"AppearanceSettings.ColorTheme.Header" = "COLOR THEME"; +"AppearanceSettings.ColorTheme.dark" = "Dark"; +"AppearanceSettings.ColorTheme.day" = "Day"; +"AppearanceSettings.ColorTheme.dayClassic" = "Day Classic"; +"AppearanceSettings.ColorTheme.day_classic" = "Day Classic"; +"AppearanceSettings.ColorTheme.mojave" = "Dark Mojave"; +"AppearanceSettings.ColorTheme.nightAccent" = "Night Accent"; +"AppearanceSettings.ColorTheme.night_blue" = "Night Blue"; +"AppearanceSettings.ColorTheme.system" = "System"; +"AppearanceSettings.DockIcon" = "APP ICON"; +"AppearanceSettings.FollowSystemAppearance" = "Follow System Appearance"; +"AppearanceSettings.FollowSystemAppearance.DefaultDark" = "Select the default dark theme which will be used for the system dark mode."; +"AppearanceSettings.FollowSystemAppearance.DefaultDay" = "Select the default light theme which will be used for the system light mode."; +"AppearanceSettings.FollowSystemAppearance.DefaultHeader" = "DEFAULT PALETTES"; +"AppearanceSettings.TextSize.Header" = "TEXT SIZE"; +"ApplyLanguage.ApplyLanguageAction" = "Apply Language"; +"ApplyLanguage.ChangeLanguageAction" = "Change"; +"ApplyLanguage.ChangeLanguageAlreadyActive" = "The language %@ is already active."; +"ApplyLanguage.ChangeLanguageOfficialText" = "You are about to apply the language pack **%@**.\n\nThis will translate the entire interface. You can suggest corrections in the [translation panel]().\n\nYou can change your language back at any time in Settings."; +"ApplyLanguage.ChangeLanguageTitle" = "Change Language?"; +"ApplyLanguage.ChangeLanguageUnofficialText" = "You are about to apply the custom language pack **%@** that is %@% complete.\n\nThis will translate the entire interface. You can suggest corrections on the [translation platform]().\n\nYou can change your language back at any time in Settings."; +"ApplyLanguage.ChangeLanguageUnofficialText1" = "You are about to apply the custom language pack **%1$@** that is %2$@% complete.\n\nThis will translate the entire interface. You can suggest corrections on the [translation platform]().\n\nYou can change your language back at any time in Settings."; +"ApplyLanguage.UnsufficientDataOpenPlatform" = "Translation Platform"; +"ApplyLanguage.UnsufficientDataText" = "Unfortunately, this custom language pack (%@) doesn't contain data for Telegram macOS. You can contribute to this language pack using the translations platform."; +"ApplyLanguage.UnsufficientDataTitle" = "Insufficient Data"; +"Archive.Folders.Desc" = "Keep archived chats from folders in the Archive even if they are unmuted and get a new message."; +"Archive.Folders.Text" = "Always Keep Archived"; +"Archive.Folders.Title" = "CHATS FROM FOLDERS"; +"Archive.NewChats.Desc" = "Automatically archive and mute new private chats, groups and channels from non-contacts."; +"Archive.NewChats.Premium" = "This setting is available only to the subscribers of [Telegram Premium]()"; +"Archive.NewChats.Text" = "Automatically Archive"; +"Archive.NewChats.Title" = "NEW CHATS FROM UNKNOWN USERS"; +"Archive.Title" = "Archive Settings"; +"Archive.Tooltip.First.Text" = "unmuted chats will get unarchived if new messages arrive."; +"Archive.Tooltip.First.Title" = "Chat Archived"; +"Archive.Tooltip.JustArchive.Title" = "Chat archived."; +"Archive.Tooltip.Second.Text" = "muted chats will stay archived after new messages arrive."; +"Archive.Tooltip.Second.Title" = "Chat Archived"; +"Archive.Tooltip.Third.Text" = "you can pin an unlimited number of archived chats on the top."; +"Archive.Tooltip.Third.Title" = "Chat Archived"; +"Archive.UnmutedChats.Desc" = "Keep archived chats in the Archive even if they are unmuted and get a new message."; +"Archive.UnmutedChats.Text" = "Always Keep Archived"; +"Archive.UnmutedChats.Title" = "UNMUTED CHATS"; +"ArchivedStickers.Description" = "You can have up to 200 sticker sets installed. Unused stickers are archived when you add more."; +"ArchivedStickers.Empty" = "Your archived sticker sets will appear here"; +"Article.MarkAsRead" = "Mark As Read"; +"Article.MarkAsUnread" = "Mark As Unread"; +"Article.Read" = "READ"; +"Article.ReadAll" = "Read All"; +"Article.Remove" = "Remove"; +"Article.RemoveAll" = "Remove All"; +"Auction.Error.NotExists" = "Auction doesn't exist"; +"Audio.Playlist.Alert.AddedToProfile" = "Audio added to your profile"; +"Audio.Playlist.Alert.RemovedFromProfile" = "Audio removed from your profile"; +"Audio.Playlist.Alert.SavedSuccess" = "Success"; +"Audio.Playlist.Button.AddToProfile" = "Add To Profile"; +"Audio.Playlist.Context.RemoveFromProfile" = "Remove from Profile"; +"Audio.Playlist.Context.SaveTo" = "Save to..."; +"Audio.Playlist.Context.SaveToProfile" = "Profile"; +"Audio.Playlist.Context.SaveToSavedMessages" = "Saved Messages"; +"Audio.Playlist.Empty" = "Nothing to play"; +"Audio.Playlist.ProfileNotice" = "This audio is visible on your profile. [Remove >]()"; +"Audio.Playlist.Separator.Chat" = "AUDIO IN THIS CHAT"; +"Audio.Playlist.Separator.User" = "%@'S PLAYLIST"; +"Audio.Playlist.Separator.Your" = "YOUR PLAYLIST"; +"Audio.UnknownArtist" = "Unknown Artist"; +"Audio.UntitledSong" = "Untitled"; +"AudioController.videoMessage" = "video message"; +"AudioController.voiceMessage" = "voice message"; +"AudioRecord.Help.Fixed" = "Click outside of circle to cancel"; +"AudioRecord.Help.Plain" = "Release outside of circle to cancel"; +"Auth.Login.ConfirmPhone" = "Is %@ your phone number?"; +"Auth.LoginWithPasskey" = "Log in with Passkey"; +"AuthSessions.MessageApp" = "You allowed this bot to message you when you opened %@."; +"AutoDownloadSettings.Delimeter" = ", "; +"AutoDownloadSettings.LastDelimeter" = " and "; +"AutoDownloadSettings.OffForAll" = "Off for all chats"; +"AutoDownloadSettings.OnFor" = "On for %@"; +"AutoDownloadSettings.OnForAll" = "On for all chats"; +"AutoDownloadSettings.TypeChannels" = "Channels"; +"AutoDownloadSettings.TypeGroupChats" = "Groups"; +"AutoDownloadSettings.TypePrivateChats" = "Private Chats"; +"AutoDownloadSettings.UpToFor" = "Up to %@ for %@"; +"AutoDownloadSettings.UpToForAll" = "Up to %@ for all chats"; +"AutoNight.Settings.Disabled" = "Disabled"; +"AutoNight.Settings.From" = "From"; +"AutoNight.Settings.PreferredTheme" = "PREFERRED DARK THEME"; +"AutoNight.Settings.Scheduled" = "Scheduled"; +"AutoNight.Settings.Sunrise.Desc" = "Calculating sunset & sunrise times requires a one-time check of your approximate location. Note that this location is only stored locally on your device.\n\nSunset: %@\nSunrise: %@"; +"AutoNight.Settings.Sunrise.Desc.NA" = "Calculating sunset & sunrise times requires a one-time check of your approximate location. Note that this location is only stored locally on your device.\n\nSunset: N/A\nSunrise: N/A"; +"AutoNight.Settings.SunsetAndSunrise" = "Use Local Sunset & Sunrise"; +"AutoNight.Settings.SystemBased" = "System"; +"AutoNight.Settings.SystemBasedDesc" = "App interface will match the system appearance settings."; +"AutoNight.Settings.Title" = "Auto-Night Mode"; +"AutoNight.Settings.To" = "To"; +"AutoNight.Settings.UpdateLocation" = "Update Location"; +"AutoNight.Settings.UpdateLocation.Error" = "Can't determine your location. Please check your system settings and try again."; +"AutoremoveMessages.Day" = "After 1 Day"; +"AutoremoveMessages.Day1" = "1 Day"; +"AutoremoveMessages.Desc" = "Automatically delete new messages sent in this chat after a certain period of time."; +"AutoremoveMessages.Header" = "AUTO-DELETE MESSAGES"; +"AutoremoveMessages.Month1" = "1 Month"; +"AutoremoveMessages.Never" = "Never"; +"AutoremoveMessages.Title" = "Clear Chat History"; +"AutoremoveMessages.Title.DeleteOnly" = "Auto-Deletion"; +"AutoremoveMessages.Week" = "After 1 Week"; +"AutoremoveMessages.Week1" = "1 Week"; +"Avatar.Background.Patters" = "GRADIENT WITH PATTERN"; +"Avatar.Background.PlainGradient" = "PLAIN GRADIENT"; +"Avatar.Preview" = "Preview"; +"BOF-NM-1cW.title" = "Settings…"; +"Badge.New" = "NEW"; +"Bio.Description" = "Any details such as age, occupation or city.\nExample: 23 y.o. designer from San Francisco"; +"Bio.Header" = "BIO"; +"Bio.Placeholder" = "A few words about you"; +"Bio.Save" = "Save"; +"Birthday.AddYourBirthday" = "Add Your Birthday"; +"Birthday.Alert.Added" = "Birthday added."; +"Birthday.DayTitle" = "Day"; +"Birthday.MonthTitle" = "Month"; +"Birthday.Separator.Today" = "🎂 BIRTHDAY TODAY"; +"Birthday.Separator.Tomorrow" = "BIRTHDAY TOMORROW"; +"Birthday.Separator.Yesterday" = "BIRTHDAY YESTERDAY"; +"Birthday.YearsOld#one" = "%d year old"; +"Birthday.YearsOld#other" = "%d years old"; +"Birthday.YearsOld_countable" = "%d"; +"BlockContact.Options.Action" = "Block %@"; +"BlockContact.Options.DeleteChat" = "Delete this Chat"; +"BlockContact.Options.Report" = "Report Spam"; +"BlockContact.Options.Title" = "Manage User"; +"BlockContact.Title" = "Do you want to block %@ from messaging and calling you on Telegram?"; +"BlockedPeers.EmptyDescrpition" = "Blocked users can't send you messages or add you to groups. They will not see your profile photos, stories, online and last seen status."; +"Boost.Badge.Level" = "Level %d"; +"Boost.Badge.LevelPLus" = "Level %d+"; +"Boost.GetBoosts" = "Boost your channel by gifting your subscribers Telegram Premium. [Get boosts]()"; +"Boost.GiftToGetMore.Info" = "To boost **%@**, get more boosts by gifting **Telegram Premium** to a friend."; +"Boost.GiftToGetMore.Title" = "More Boosts Needed"; +"BoostGift.AdditionalPrizes" = "Additional Prizes"; +"BoostGift.AdditionalPrizesInfoForMonths#one" = "for **%d** month"; +"BoostGift.AdditionalPrizesInfoForMonths#other" = "for **%d** months"; +"BoostGift.AdditionalPrizesInfoForMonths_countable" = "%d"; +"BoostGift.AdditionalPrizesInfoOff" = "Turn this on if you want to give the winners your own prizes in addition to Premium subscriptions."; +"BoostGift.AdditionalPrizesInfoOn" = "All prizes: **%1$@** %2$@ %3$@."; +"BoostGift.AdditionalPrizesInfoSubscriptions#one" = "%d Telegram Premium subscription"; +"BoostGift.AdditionalPrizesInfoSubscriptions#other" = "%d Telegram Premium subscriptions"; +"BoostGift.AdditionalPrizesInfoSubscriptions_countable" = "%d"; +"BoostGift.AdditionalPrizesInfoWithSubscriptions#one" = "with %d Telegram Premium subscription"; +"BoostGift.AdditionalPrizesInfoWithSubscriptions#other" = "with %d Telegram Premium subscriptions"; +"BoostGift.AdditionalPrizesInfoWithSubscriptions_countable" = "%d"; +"BoostGift.AdditionalPrizesPlaceholder" = "Enter Your Prize"; +"BoostGift.StartConfirmation.Start" = "Start"; +"BoostGift.StartConfirmation.Text" = "Are you sure you want to start giveaway now?"; +"BoostGift.StartConfirmation.Title" = "Start Giveaway"; +"BoostGift.Winners" = "Show Winners"; +"BoostGift.WinnersInfo" = "Choose whether to make the list of winners public when the giveaway ends."; +"BoostGroup.ChatInput.Action" = "Boost this group to send messages"; +"BoostGroup.ChatInput.Boost" = "Boost"; +"BoostGroup.ChatInput.SendMedia" = "Boost this group to send media."; +"BoostReassign.Info" = "To boost **%1$@**, reassign a previous boost or gift **Telegram Premium** to a friend to get **%2$@** additional boosts"; +"BoostReassign.OK" = "Reassign Boosts"; +"BoostReassign.Status.AvailableIn" = "Available in %@"; +"BoostReassign.Status.ExpiresOn" = "Boost expires on %@"; +"BoostReassign.Success#one" = "%d boost is reassigned from %d other channel."; +"BoostReassign.Success#other" = "%d boosts are reassigned from %d other channels."; +"BoostReassign.Success_countable" = "%d %d"; +"BoostReassign.Title" = "Reassign Boosts"; +"Bot.AllowSendMessages" = "Allow **%@** to send me messages."; +"Bot.Grossing.Disclaimer" = "Which apps are included here? [Learn >](grossing)"; +"Bot.Grossing.Disclaimer.Alert.Header" = "Top Mini Apps"; +"Bot.Grossing.Disclaimer.Alert.Text" = "This catalogue ranks mini apps based on their daily revenue, measured in Stars. To be listed, developers must set their main mini app in [@botfather](https://t.me/botfather) (as described [here](https://core.telegram.org/api/bots/webapps)), have over **1,000** daily users, and earn a daily revenue above **1,000** Stars, based on the weekly average."; +"Bot.Grossing.Disclaimer.Alert.ok" = "Understood"; +"Bot.InlineAuth.Header" = "Open Link"; +"Bot.InlineAuth.Open" = "Open"; +"Bot.InlineAuth.Option.AllowSendMessages" = "Allow %@ to send me messages"; +"Bot.InlineAuth.Option.Login" = "Log in to %@ as %@"; +"Bot.InlineAuth.Title" = "Do you want to open\n%@?"; +"Bot.LaunchApp" = "Launch"; +"Bot.MoreAbout" = "[More about this bot >](info)"; +"Bot.TransferOwner.Error.Enable2FA" = "Enable 2-Step Verification."; +"Bot.TransferOwner.Error.Text" = "Ownership transfers are only available if:\n\n• 2-Step Verification was enabled for your account more than 7 days ago.\n\n• You have logged in on this device more than 24 hours ago.\n\nPlease come back later."; +"Bot.TransferOwner.Error.Title" = "Security Check"; +"Bot.TransferOwnership.Password.Desc" = "Please enter your 2-Step verification password to complete this action."; +"Bot.TransferOwnership.Password.Title" = "Two-Step Verification"; +"BotInfo.LaunchInfo" = "By publishing this mini app, you agree to the [Telegram Terms of Service for Developers](https://telegram.org/tos/bot-developers)."; +"BotInfo.LaunchInfo.PrivacyUrl" = "https://telegram.org/privacy-tpa"; +"BotInfo.LaunchInfo.User" = "By launching this mini app, you agree to the [Telegram Terms of Service Mini Apps.](%@)"; +"BotInfo.OpenApp" = "Open App"; +"BotUsername.Activate.Info" = "Do you want to show this link on bot?"; +"BotUsername.Activate.Ok" = "Show"; +"BotUsername.Activate.Title" = "Activate Username"; +"BotUsername.Active" = "active"; +"BotUsername.Deactivate.Info" = "Do you want to hide this link from bot?"; +"BotUsername.Deactivate.Ok" = "Hide"; +"BotUsername.Deactivate.Title" = "Deactivate Username"; +"BotUsername.Header" = "USERNAME"; +"BotUsername.Info" = "This username cannot be edited. You can aquire additional usernames on [Fragment]()."; +"BotUsername.List.Info" = "Drag and drop links to change order in which they will be displayed on the bot info page."; +"BotUsername.List.Title" = "USERNAME ORDER"; +"BotUsername.NotActive" = "not active"; +"BotUsername.Title" = "Username"; +"BotVerification.Added" = "**%@** has been notified and will receive your verification mark and description upon accepting."; +"BotVerification.Remove.Bot.Text" = "This bot is already verified by you. Do you want to remove verification?"; +"BotVerification.Remove.Channel.Text" = "This channel is already verified by you. Do you want to remove verification?"; +"BotVerification.Remove.Group.Text" = "This group is already verified by you. Do you want to remove verification?"; +"BotVerification.Remove.Remove" = "Remove"; +"BotVerification.Remove.Title" = "Remove Verification"; +"BotVerification.Remove.User.Text" = "This user is already verified by you. Do you want to remove verification?"; +"BotVerification.Removed" = "You have removed **%@'s** verification."; +"BotVerification.Verify.Bot.Text" = "Do you want to verify this bot with your verification mark and description?"; +"BotVerification.Verify.Bot.Title" = "Verify Bot"; +"BotVerification.Verify.Channel.Text" = "Do you want to verify this channel with your verification mark and description?"; +"BotVerification.Verify.Channel.Title" = "Verify Channel"; +"BotVerification.Verify.Group.Text" = "Do you want to verify this group with your verification mark and description?"; +"BotVerification.Verify.Group.Title" = "Verify Group"; +"BotVerification.Verify.Placeholder" = "Description..."; +"BotVerification.Verify.User.Text" = "Do you want to verify this user with your verification mark and description?"; +"BotVerification.Verify.User.Title" = "Verify User"; +"BotVerification.Verify.Verify" = "Verify"; +"BroadcastGroups.Cancel" = "Leave as regular group"; +"BroadcastGroups.ConfirmationAlert.Convert" = "Convert"; +"BroadcastGroups.ConfirmationAlert.Text" = "Regular members of the group (non-admins) will permanently lose their right to send messages in the group.\n\nThis action cannot be undone."; +"BroadcastGroups.ConfirmationAlert.Title" = "Are you sure?"; +"BroadcastGroups.Convert" = "Convert to Broadcast Group"; +"BroadcastGroups.IntroText" = "• No limit on the number of members.\n\n• Only admins can post.\n\n• Can't be turned back into a regular group."; +"BroadcastGroups.IntroTitle" = "Broadcast Groups"; +"BroadcastGroups.LimitAlert.LearnMore" = "Learn More"; +"BroadcastGroups.LimitAlert.SettingsTip" = "If you change your mind, go to your group's Settings > Permissions."; +"BroadcastGroups.LimitAlert.Text" = "Your group has reached a limit of %@ members.\n\nYou can increase this limit by converting the group to a broadcast group where only admins can post. Interested?"; +"BroadcastGroups.LimitAlert.Title" = "Limit Reached"; +"BroadcastGroups.Success" = "Success! Now your group can have unlimited members."; +"Business.Away.OnlyOffline" = "Only if Offline"; +"Business.Away.OnlyOffline.Info" = "Don't send the away message if you've recently been online."; +"Business.AwayMessage.CreateMessage" = "Create an Away Message"; +"Business.AwayMessage.Enable" = "Send Away Message"; +"Business.AwayMessage.Header" = "Automatically reply with a message when you are away."; +"Business.AwayMessage.Title" = "Away Message"; +"Business.ChatBot.ManageGifts" = "Manage Gifts and Stars"; +"Business.ChatBot.ManageGifts.Sell" = "Convert Gifts to Stars"; +"Business.ChatBot.ManageGifts.Settings" = "Change Gift Settings"; +"Business.ChatBot.ManageGifts.Transfer" = "Transfer and Upgrade Gifts"; +"Business.ChatBot.ManageGifts.TransferStars" = "Transfer Stars"; +"Business.ChatBot.ManageGifts.View" = "View Gifts and Stars"; +"Business.ChatBot.ManageMessages" = "Manage Messages"; +"Business.ChatBot.ManageMessages.DeleteReceived" = "Delete Received Messages"; +"Business.ChatBot.ManageMessages.DeleteSent" = "Delete Sent Messages"; +"Business.ChatBot.ManageMessages.Mark" = "Mark Messages as Read"; +"Business.ChatBot.ManageMessages.Read" = "Read Messages"; +"Business.ChatBot.ManageMessages.Reply" = "Reply to Messages"; +"Business.ChatBot.ManageProfile" = "Manage Profile"; +"Business.ChatBot.ManageProfile.Avatar" = "Edit Profile Photo"; +"Business.ChatBot.ManageProfile.Bio" = "Edit Bio"; +"Business.ChatBot.ManageProfile.Name" = "Edit Name"; +"Business.ChatBot.ManageProfile.Username" = "Edit Username"; +"Business.ChatBot.ManageStories" = "Manage Stories"; +"Business.ChatBots.BotNotSupported" = "This bot doesn't support Telegram Business yet."; +"Business.ChatBots.Footer" = "Choose a bot to manage your chats automatically."; +"Business.Chatbots.Add" = "ADD"; +"Business.Chatbots.ChatTypes" = "CHATS THE BOT CAN ACCESS"; +"Business.Chatbots.Chats" = "CHATS"; +"Business.Chatbots.Header" = "Add a bot to reply to messages on your behalf."; +"Business.Chatbots.NotFound" = "No bots found"; +"Business.Chatbots.Permission" = "Reply to Messages"; +"Business.Chatbots.Permission.Header" = "BOT PERMISSION"; +"Business.Chatbots.Permission.Info" = "The bot can only reply on your behalf in chats that were active during the last 24h."; +"Business.Chatbots.Permission.RemoveBot" = "Remove Bot"; +"Business.Chatbots.Placeholder" = "Bot Username or Link"; +"Business.Chatbots.Title" = "Chat Automation"; +"Business.GreetingMessage.CreateMessage" = "Create a Greeting Message"; +"Business.GreetingMessage.Enable" = "Send Greeting Message"; +"Business.GreetingMessage.Header" = "Greet customers when they message you the first time or after a period of no activity."; +"Business.GreetingMessage.NoActivity.Days" = "%d Days"; +"Business.GreetingMessage.NoActivity.Days_countable" = "%d"; +"Business.GreetingMessage.NoActivity.Info" = "Choose how many days should pass after your last interaction with a recipient to send them the greeting in response to their message."; +"Business.GreetingMessage.NoActivity.Title" = "PERIOD OF NO ACTIVITY"; +"Business.GreetingMessage.Title" = "Greeting Message"; +"Business.Hours.24Hours" = "24 hours"; +"Business.Hours.AddSet" = "Add a Set of Hours"; +"Business.Hours.AddSet.Info" = "Specify your working hours during the day."; +"Business.Hours.BusinessHours" = "BUSINESS HOURS"; +"Business.Hours.Closed" = "Closed"; +"Business.Hours.Header" = "Turn this on to show your opening hours schedule to your customers."; +"Business.Hours.Set.ClosingTime" = "Closing Time"; +"Business.Hours.Set.OpeningTime" = "Opening Time"; +"Business.Hours.Set.Remove" = "Remove"; +"Business.Hours.Set.Title" = "Open On This Day"; +"Business.Hours.ShowHours" = "Show Business Hours"; +"Business.Hours.Timezone" = "Timezone"; +"Business.Hours.Title" = "Business Hours"; +"Business.Hours.UTC" = "UTC"; +"Business.Intro.Empty.Text" = "Send a message or click on the greeting below"; +"Business.Intro.Empty.Title" = "No Messages here yet..."; +"Business.Intro.Info" = "CUSTOMIZE YOUR START PAGE"; +"Business.Intro.Placeholder.Text" = "Enter Message"; +"Business.Intro.Placeholder.Title" = "Enter Title"; +"Business.Intro.Reset" = "Reset To Default"; +"Business.Intro.Sticker.Info" = "You can customize the message people see before they start a chat with you."; +"Business.Intro.Sticker.Random" = "Random"; +"Business.Intro.Sticker.Text" = "Custom Sticker"; +"Business.Intro.Title" = "Start Page"; +"Business.Links.Block" = "LINKS TO CHAT"; +"Business.Links.ConfirmRemove" = "Are you sure you want to delete this link?"; +"Business.Links.Create" = "Create a Link to Chat"; +"Business.Links.CreateInfo.Full" = "You can also use a simple link for a chat with you — %1$@ or %2$@."; +"Business.Links.CreateInfo.Phone" = "You can also use a simple link for a chat with you — %1$@."; +"Business.Links.Info" = "Give your customers short links that start a chat with you - and suggest the first message from them to you."; +"Business.Links.Item.Clicks#one" = "%d click"; +"Business.Links.Item.Clicks#other" = "%d clicks"; +"Business.Links.Item.Clicks_ZeroValueHolder" = "no clicks"; +"Business.Links.Item.Clicks_countable" = "%d"; +"Business.Links.Item.Copy" = "Copy"; +"Business.Links.Item.Delete" = "Delete"; +"Business.Links.Item.EditName" = "Edit Name"; +"Business.Links.Item.NoText" = "no message"; +"Business.Links.Item.Share" = "Share"; +"Business.Links.Name.Info" = "Add a name for this link that only you will see."; +"Business.Links.Name.Placeholder" = "Name this link..."; +"Business.Links.Name.Title" = "Link Name"; +"Business.Links.Title" = "Links to Chat"; +"Business.Links.Tooltip.Saved" = "Preset message saved."; +"Business.Location.EnterAddress" = "Enter Address"; +"Business.Location.Header" = "Display the location of your business on your account."; +"Business.Location.Remove" = "Remove Location"; +"Business.Location.SetOnMap" = "Set Location on Map"; +"Business.Location.Title" = "Location"; +"Business.Message.Contact" = "contact"; +"Business.Message.NonContact" = "non contact"; +"Business.Message.Recepients.All" = "All Private Chats Except..."; +"Business.Message.Recepients.Exclude" = "Exclude Chats..."; +"Business.Message.Recepients.Exclude.Info" = "Select chats or entire chat categories which the bot **WILL NOT** have access to."; +"Business.Message.Recepients.Exclude.Title" = "EXCLUDE CHATS"; +"Business.Message.Recepients.ExcludeUsers" = "Exclude Users..."; +"Business.Message.Recepients.ExcludeUsers.Info" = "Select users which the bot **WILL NOT** have access to."; +"Business.Message.Recepients.Include" = "Select Chats..."; +"Business.Message.Recepients.Include.Info" = "Select chats or entire chat categories which the bot **WILL** have access to."; +"Business.Message.Recepients.Include.Title" = "INCLUDE CHATS"; +"Business.Message.Recepients.Selected" = "Only Selected Chats"; +"Business.Message.Recepients.Title" = "RECIPIENTS"; +"Business.Message.Schedule.AlwaysSend" = "Always Send"; +"Business.Message.Schedule.Custom" = "Custom Schedule"; +"Business.Message.Schedule.Custom.EndTime" = "End Time"; +"Business.Message.Schedule.Custom.StartTime" = "Start Time"; +"Business.Message.Schedule.OutsideHours" = "Outside of Business Hours"; +"Business.Message.Schedule.Title" = "SCHEDULE"; +"Business.Message.SelectPeers.ChatTypes" = "CHAT TYPES"; +"Business.Message.SelectPeers.Chats" = "CHAT"; +"Business.QuickReply.Add" = "Add Quick Reply"; +"Business.QuickReply.Add.Error" = "Shortcut with that name already exists."; +"Business.QuickReply.Add.Info" = "Add a shortcut for your quick reply."; +"Business.QuickReply.Add.Placeholder" = "Enter name..."; +"Business.QuickReply.Add.Title" = "New Quick Reply"; +"Business.QuickReply.ConfirmDelete" = "Are you sure you want to delete quick reply?"; +"Business.QuickReply.Edit.Info" = "Add a shortcut for your quick reply."; +"Business.QuickReply.Edit.Title" = "Edit Quick Reply"; +"Business.QuickReply.EditName" = "Edit Name"; +"Business.QuickReply.Header" = "Set up shortcuts with rich text and media to respond to messages faster."; +"Business.QuickReply.More" = "+%d MORE"; +"Business.QuickReply.More1#one" = "+%d MORE"; +"Business.QuickReply.More1#other" = "+%d MORE"; +"Business.QuickReply.More1_countable" = "%d"; +"Business.QuickReply.Title" = "Quick Replies"; +"Business.ScheduleEnd" = "Schedule End"; +"Business.ScheduleStart" = "Schedule Start"; +"Business.SelectPeers.Limit" = "Limit Reached"; +"Business.SwitchAd" = "Do Not Hide Ads"; +"Business.SwitchAd.Info" = "As a Premium subscriber, you don't see any ads on Telegram, but you can turn them on, for example, to view your own ads that you launched on the [Telegram Ad Platform >](https://ads.telegram.org)"; +"Business.SwitchAd.Title" = "ADS IN CHANNELS"; +"Business.Updated" = "Your Telegram Business has been updated"; +"CHAT_MESSAGE_INVOICE" = "%1$@ sent an invoice for %3$@ to the group %2$@"; +"Cag-YX-WT6.title" = "Hide Telegram"; +"Calendar.WeekDays.Friday" = "F"; +"Calendar.WeekDays.Monday" = "M"; +"Calendar.WeekDays.Saturday" = "S"; +"Calendar.WeekDays.Sunday" = "S"; +"Calendar.WeekDays.Thrusday" = "T"; +"Calendar.WeekDays.Tuesday" = "T"; +"Calendar.WeekDays.Wednesday" = "W"; +"Call.Accept" = "Accept"; +"Call.Camera" = "Camera"; +"Call.Camera.Error" = "Telegram needs access to camera for Video Call."; +"Call.CameraUnavailable" = "Camera is unavailable\n[settings]()"; +"Call.Close" = "Close"; +"Call.Confirm.Discard.Call.Header" = "Call in Progress"; +"Call.Confirm.Discard.Call.ToCall.Text" = "End call with \"%1$@\" and start a video chat in \"%2$@\"?"; +"Call.Confirm.Discard.Call.ToVoice.Text" = "End call with \"%1$@\" and start a video chat in \"%2$@\"?"; +"Call.Confirm.Discard.Voice.Header" = "Video Chat in Progress"; +"Call.Confirm.Discard.Voice.ToCall.Text" = "Leave video chat in \"%1$@\" and start a call with \"%2$@\"?"; +"Call.Confirm.Discard.Voice.ToVoice.Text" = "Leave video chat in \"%1$@\" and start a new one in \"%2$@\"?"; +"Call.Confirm.DiscardCurrent.Description" = "Finish the call with %@ and start a new one with %@?"; +"Call.Confirm.DiscardCurrent.Header" = "Call in Progress"; +"Call.Context.Rate" = "Rate This Call"; +"Call.Decline" = "Decline"; +"Call.End" = "End"; +"Call.GroupCall" = "Group Call"; +"Call.Mute" = "Mute"; +"Call.ParticipantVersionOutdatedError" = "%@'s app does not support calls. They need to update their app before you can call them."; +"Call.PrivacyErrorMessage" = "Sorry, you cannot call %@ because of their privacy settings. You can ask them to modify their setting or to call you instead."; +"Call.RatingModal.NotNow" = "Not Now"; +"Call.RatingModal.Placeholder" = "Leave comment..."; +"Call.RatingModal.Text" = "Please rate the quality of your Telegram call"; +"Call.Recall" = "Redial"; +"Call.Recent.Incoming" = "Incoming"; +"Call.Recent.Missed" = "Missed"; +"Call.Recent.Outgoing" = "Outgoing"; +"Call.SameDevice.Error" = "Sorry, you can’t make a call between two accounts on the same device."; +"Call.Screen" = "Screen"; +"Call.Screen.Error" = "Telegram needs access for Screen Sharing."; +"Call.ShortMinutes#one" = "%d min"; +"Call.ShortMinutes#other" = "%d min"; +"Call.ShortMinutes_countable" = "%d"; +"Call.ShortSeconds#one" = "%d sec"; +"Call.ShortSeconds#other" = "%d sec"; +"Call.ShortSeconds_countable" = "%d"; +"Call.Status.GroupCall" = "Group Call"; +"Call.Status.GroupCall.Account" = "Group Call → %@"; +"Call.StatusBusy" = "Busy"; +"Call.StatusCalling" = "is calling you..."; +"Call.StatusCallingAccount" = "calling → %@..."; +"Call.StatusConnecting" = "Connecting..."; +"Call.StatusEnded" = "Call Ended"; +"Call.StatusFailed" = "Call Failed"; +"Call.StatusRequesting" = "Contacting..."; +"Call.StatusRinging" = "Ringing..."; +"Call.StatusSwitchingToConference" = "Switching to group call..."; +"Call.Toast.CameraOff" = "%@'s camera is off"; +"Call.Toast.LowBattery" = "%@'s battery is low"; +"Call.Toast.MicroOff" = "%@'s microphone is off"; +"Call.Toast.MicroOff.Your" = "Your microphone is off"; +"Call.Toast.WeakNetwork" = "Weak network signal"; +"Call.UndefinedError" = "Undefined error, please try later."; +"Call.Video" = "Video"; +"Call.VideoPaused" = "%@'s paused video"; +"CallFeedback.AddComment" = "Add an optional comment"; +"CallFeedback.IncludeLogs" = "Include technical information"; +"CallFeedback.IncludeLogsInfo" = "This won't reveal the contents of your conversation, but will help us fix the issue sooner."; +"CallFeedback.ReasonDistortedSpeech" = "Speech was distorted"; +"CallFeedback.ReasonDropped" = "Call ended unexpectedly"; +"CallFeedback.ReasonEcho" = "I heard my own voice"; +"CallFeedback.ReasonInterruption" = "The other side kept disappearing"; +"CallFeedback.ReasonNoise" = "I heard background noise"; +"CallFeedback.ReasonSilentLocal" = "I couldn't hear the other side"; +"CallFeedback.ReasonSilentRemote" = "The other side couldn't hear me"; +"CallFeedback.Send" = "Send"; +"CallFeedback.Success" = "Thanks for\nyour feedback"; +"CallFeedback.Title" = "Call Feedback"; +"CallFeedback.VideoReasonDistorted" = "Video was distorted"; +"CallFeedback.VideoReasonLowQuality" = "Video was pixelated"; +"CallFeedback.WhatWentWrong" = "WHAT WENT WRONG?"; +"CallHeader.EndCall" = "End Call"; +"CallSettings.Camera.Title" = "CAMERA"; +"CallSettings.Device.Default" = "Default"; +"CallSettings.Input.Text" = "Input device"; +"CallSettings.Input.Title" = "MICROPHONE"; +"CallSettings.InputLevel" = "Input Level"; +"CallSettings.MuteSound" = "Mute other sounds during calls"; +"CallSettings.OpenSystemPreferences" = "Open System Preferences"; +"CallSettings.OtherSettings.Title" = "OTHER SETTINGS"; +"CallSettings.Output.Text" = "Output device"; +"CallSettings.Output.Title" = "SOUND"; +"CallSettings.Title" = "Call Settings"; +"CancelResetAccount.Success" = "The deletion process was canceled for your account %@."; +"CancelResetAccount.TextSMS" = "Somebody with access to your phone number **%@** has requested to delete your Telegram account and reset your 2-Step Verification password.\n\nIf it wasn't you, please enter the code we've just sent you via SMS to your number. You can also cancel this by **changing your phone number** to a number you control."; +"CancelResetAccount.Title" = "Cancel Account Reset"; +"Canvas.Clear" = "E"; +"Canvas.Draw" = "L - Line\nA - Arrow"; +"Canvas.Redo" = "⌘⇧Z"; +"Canvas.Undo" = "⌘Z"; +"Chanel.EventFilter.AllAdmins" = "All Admins"; +"Chanel.EventFilter.AllAdmins.New" = "Show Actions by All Admins"; +"Chanel.EventFilter.AllEvents" = "All Events"; +"Chanel.EventFilter.Settings" = "Settings"; +"ChangeNumber.ConfirmCode.Error.Generic" = "An error occurred."; +"ChangeNumber.ConfirmCode.Error.codeExpired" = "Code expired."; +"ChangeNumber.ConfirmCode.Error.invalidCode" = "Invalid code. Please try again."; +"ChangeNumber.ConfirmCode.Error.limitExceeded" = "You have entered invalid code too many times. Please try again later."; +"ChangeNumber.ConfirmCode.Success" = "You have changed your phone number to %@."; +"ChangeNumber.SendData.Error.Generic" = "An error occurred. Please try again later."; +"ChangeNumber.SendData.Error.InvalidPhoneNumber" = "The phone number you entered is not valid. Please enter the correct number along with your country code."; +"ChangeNumber.SendData.Error.LimitExceeded" = "You have requested for an authorization code too many times. Please try again later."; +"ChangeNumber.SendData.Error.PhoneNumberOccupied" = "The number %@ is already connected to a Telegram account. Please delete that account before using this number."; +"ChangePhone.UseMobile" = "You can only change your phone number using mobile Telegram apps. Please use an official Telegram app on your phone to update your number."; +"ChangePhoneNumber.Intro.Alert" = "Users will see your new number if they have it in their address book or your privacy settings allow them to see it. You can modify this in Settings > Privacy and Security > Phone number."; +"ChangePhoneNumber.Intro.Description" = "You can change your Telegram number here. Your account and all your cloud data — messages, media, contacts, etc. will be moved to the new number."; +"Channel.AddAdminKickedError" = "Sorry, you can't add this user as an admin because they are in the Removed Users list and you can't unban them."; +"Channel.AddBot.AdminRights" = "Admin Rights"; +"Channel.AddBot.Button.Admin" = "Add Bot as Admin"; +"Channel.AddBot.Button.Member" = "Add Bot as Member"; +"Channel.AddBot.Confirm.Info" = "Are you sure you want to add this bot as an admin to the group %@? It will have access to the list of anonymous admins."; +"Channel.AddBot.Confirm.Ok.Admin" = "Add as Admin"; +"Channel.AddBot.Confirm.Ok.Member" = "Add as Member"; +"Channel.AddBot.Confirm.Title.Admin" = "Add Bot as Admin?"; +"Channel.AddBot.Confirm.Title.Member" = "Add Bot as Member?"; +"Channel.AddBot.CustomTitle" = "Admin"; +"Channel.AddBot.Success.Admin" = "You added \"%@\" to \"%@\" as an admin."; +"Channel.AddBot.Success.Member" = "You added \"%@\" to \"%@\" as a member."; +"Channel.AddBot.Title" = "Add Bot"; +"Channel.AddBotAsAdmin" = "Make Admin"; +"Channel.AddBotErrorHaveRights" = "Bots can only be added to channels as admins."; +"Channel.AddBotErrorNoRights" = "Bots can only be added to channels as admins."; +"Channel.AddUserKickedError" = "Sorry, you can't add this user because they are on the list of Removed Users and you can't unban them."; +"Channel.Admin.AdminAccess" = "This admin will be able to add new admins with equal or fewer rights."; +"Channel.Admin.AdminRestricted" = "This admin will not be able to add new admins."; +"Channel.Admin.CantEdit" = "You are not allowed to edit the rights of this admin."; +"Channel.Admin.CantEditRights" = "You can't edit the rights of this admin."; +"Channel.Admin.Dismiss" = "Dismiss Admin"; +"Channel.Admin.Role.Admin.Desc" = "A title that members will see instead of 'admin'."; +"Channel.Admin.Role.Header" = "MEMBER TAG"; +"Channel.Admin.Role.Owner.Desc" = "A title that will be shown instead of 'owner'."; +"Channel.Admin.Role.Placeholder.Admin" = "admin"; +"Channel.Admin.Role.Placeholder.Owner" = "owner"; +"Channel.Admin.TransferOwnership.Channel" = "Transfer Channel Ownership"; +"Channel.Admin.TransferOwnership.Confirm.Channel.Text" = "This will transfer the full owner rights for %@ to %@.\n\nYou will no longer be considered the creator of the channel. The new owner will be free to remove any of your admin privileges or even ban you."; +"Channel.Admin.TransferOwnership.Confirm.Channel.Title" = "Transfer Channel Ownership"; +"Channel.Admin.TransferOwnership.Confirm.Group.Text" = "This will transfer the full owner rights for %@ to %@.\n\nYou will no longer be considered the creator of the group. The new owner will be free to remove any of your admin privileges or even ban you."; +"Channel.Admin.TransferOwnership.Confirm.Group.Title" = "Transfer Group Ownership"; +"Channel.Admin.TransferOwnership.Confirm.OK" = "Change Owner"; +"Channel.Admin.TransferOwnership.Group" = "Transfer Group Ownership"; +"Channel.Admin.TransferOwnership.Password.Desc" = "Please enter your 2-Step verification password to complete the transfer."; +"Channel.Admin.TransferOwnership.Password.Title" = "Two-Step Verification"; +"Channel.Admin.WhatCanAdminDo" = "WHAT CAN THIS ADMIN DO?"; +"Channel.AdminLog.AllowedNewMembersToSpeak" = "%1$@ allowed new video chat participants to speak"; +"Channel.AdminLog.AllowedReactionsUpdated" = "%1$@ updated the list of allowed reactions to: %2$@"; +"Channel.AdminLog.CanInviteUsersViaLink" = "Invite Users via Link"; +"Channel.AdminLog.CanManageCalls" = "Manage Video Chats"; +"Channel.AdminLog.CanManageTopics" = "Manage Topics"; +"Channel.AdminLog.CreatedInviteLink" = "%1$@ created invite link %2$@"; +"Channel.AdminLog.DeletedInviteLink" = "%1$@ deleted the invite link %2$@"; +"Channel.AdminLog.EditedInviteLink" = "%1$@ edited invite link %2$@"; +"Channel.AdminLog.EndedVoiceChat" = "%1$@ ended the video chat"; +"Channel.AdminLog.JoinedViaInviteLink" = "%1$@ joined via invite link %2$@"; +"Channel.AdminLog.JoinedViaInviteLink.ViaFolder" = "%1$@ joined via folder invite link %2$@"; +"Channel.AdminLog.JoinedViaRequest" = "%1$@ joined via invite link %2$@, approved by %3$@"; +"Channel.AdminLog.MessageChangedAutoremoveTimeoutRemove" = "%1$@ disabled auto-delete timer"; +"Channel.AdminLog.MessageChangedAutoremoveTimeoutSet" = "%1$@ set auto-delete timer to %2$@"; +"Channel.AdminLog.MutedNewMembers" = "%1$@ muted new video chat participants"; +"Channel.AdminLog.MutedParticipant" = "%1$@ muted %2$@ in a video chat"; +"Channel.AdminLog.ReactionsDisabled" = "%1$@ disabled reactions"; +"Channel.AdminLog.ReactionsEnabled" = "%1$@ enabled all reactions"; +"Channel.AdminLog.RevokedInviteLink" = "%1$@ revoked invite link %2$@"; +"Channel.AdminLog.StartedVoiceChat" = "%1$@ started a video chat"; +"Channel.AdminLog.UnmutedMutedParticipant" = "%1$@ unmuted %2$@ in a video chat"; +"Channel.AdminLog.UpdatedParticipantVolume" = "%1$@ changed %2$@ volume to %3$@"; +"Channel.Admins.AddAdminError" = "Sorry, you're not allowed to promote this user to become an admin."; +"Channel.Admins.Promote.BannedAdminError" = "Sorry, you can't add this user as an admin because they are in the Removed Users list and you can't unban them."; +"Channel.Admins.Promote.UnmemberAdminError" = "You can't add this user as an admin because they are not a member of this group and you are not allowed to add them."; +"Channel.Admins.PromotedBy" = "promoted by %@"; +"Channel.BanForever" = "Forever"; +"Channel.BanUser.PermissionAddMembers" = "Add Members"; +"Channel.BanUser.PermissionChangeGroupInfo" = "Change Group Info"; +"Channel.BanUser.PermissionEmbedLinks" = "Embed Links"; +"Channel.BanUser.PermissionReadMessages" = "Read Messages"; +"Channel.BanUser.PermissionSendMedia" = "Send Media"; +"Channel.BanUser.PermissionSendMessages" = "Send Messages"; +"Channel.BanUser.PermissionSendPolls" = "Send Polls"; +"Channel.BanUser.PermissionSendStickersAndGifs" = "Send Stickers & GIFs"; +"Channel.BanUser.PermissionsHeader" = "User Restrictions"; +"Channel.BanUser.Title" = "Ban User"; +"Channel.BanUser.Unban" = "Unban"; +"Channel.Blacklist.BlockedBy" = "blocked by %@"; +"Channel.Blacklist.Context.AddToGroup" = "Add To Group"; +"Channel.Blacklist.Context.Remove" = "Remove"; +"Channel.Blacklist.DemoteAdminError" = "You can't ban this user because they are an admin of this group and you are not allowed to demote them."; +"Channel.Blacklist.DescChannel" = "Users removed from the channel by admins can't rejoin via invite links."; +"Channel.Blacklist.DescGroup" = "Users removed from the group by admins can't rejoin via invite links."; +"Channel.Blacklist.RemoveUser" = "Remove User"; +"Channel.Blacklist.RestrictedBy" = "restricted by %@"; +"Channel.Blacklist.SelectNewUserTitle" = "Members"; +"Channel.Blacklist.Unban" = "Unban"; +"Channel.BlockUser.BlockFor" = "Block For"; +"Channel.BlockUser.CanEmbedLinks" = "Embed Links"; +"Channel.BlockUser.CanReadMessages" = "Can Read Messages"; +"Channel.BlockUser.CanSendMedia" = "Send Media"; +"Channel.BlockUser.CanSendMessages" = "Send Messages"; +"Channel.BlockUser.CanSendStickers" = "Send Stickers & GIFs"; +"Channel.Boost.PrivateError" = "This channel is private."; +"Channel.BoostsStats.GetBoostsViaGifts" = "Get Boosts Via Gifts"; +"Channel.BoostsStats.GetBoostsViaGifts.Info" = "Get more boosts for your channel by gifting Premium to your subscribers."; +"Channel.BoostsStats.GetBoostsViaGifts.Info.Group" = "Get more boosts for your group by gifting Premium to your members."; +"Channel.BoostsStats.Prepaid.Info" = "Select a giveaway you already paid for to set it up."; +"Channel.BoostsStats.Prepaid.Months" = "%d-months subscriptions"; +"Channel.BoostsStats.Prepaid.Quantity" = "%d Telegram Premium"; +"Channel.BoostsStats.Prepaid.Title" = "PREPAID GIVEAWAYS"; +"Channel.BotDoesntSupportGroups" = "Sorry, this bot is telling us it doesn't want to be added to groups. You can't add this bot unless its developers change their mind."; +"Channel.ChannelNameHolder" = "Channel Name"; +"Channel.Comments.Count#one" = "%d Comment"; +"Channel.Comments.Count#other" = "%d Comments"; +"Channel.Comments.Count_countable" = "%d"; +"Channel.Comments.LeaveComment" = "Leave a Comment"; +"Channel.Comments.Short.Count#one" = "%d"; +"Channel.Comments.Short.Count#other" = "%d"; +"Channel.Comments.Short.Count_countable" = "%d"; +"Channel.Comments.Short.LeaveComment" = "Comment"; +"Channel.Create" = "Create"; +"Channel.DescHeader" = "DESCRIPTION"; +"Channel.DescriptionHolder" = "Description"; +"Channel.DescriptionHolderDescrpiton" = "You can provide an optional description for your channel."; +"Channel.EditAdmin.DeleteStories" = "Delete Stories of Others"; +"Channel.EditAdmin.EditStories" = "Edit Stories of Others"; +"Channel.EditAdmin.ManageCalls" = "Manage Video Chats"; +"Channel.EditAdmin.ManageTopics" = "Manage Topics"; +"Channel.EditAdmin.Permission.AddNewAdmins" = "Add New Admins"; +"Channel.EditAdmin.Permission.BanUsers" = "Ban Users"; +"Channel.EditAdmin.Permission.ChangeInfo" = "Change Channel Info"; +"Channel.EditAdmin.Permission.CreateTopics" = "Create Topics"; +"Channel.EditAdmin.Permission.DeleteMessages" = "Delete Messages"; +"Channel.EditAdmin.Permission.EditMessages" = "Edit Messages"; +"Channel.EditAdmin.Permission.InviteUsers" = "Invite Users"; +"Channel.EditAdmin.Permission.ManageDirect" = "Manage Direct Messages"; +"Channel.EditAdmin.Permission.ManageMessages" = "Manage Messages"; +"Channel.EditAdmin.Permission.ManageStories" = "Manage Stories"; +"Channel.EditAdmin.Permission.PinMessages" = "Pin Messages"; +"Channel.EditAdmin.Permission.PostMessages" = "Post Messages"; +"Channel.EditAdmin.Permission.SendFiles" = "Send Files"; +"Channel.EditAdmin.Permission.SendInstantVideo" = "Send Video Messages"; +"Channel.EditAdmin.Permission.SendMusic" = "Send Music"; +"Channel.EditAdmin.Permission.SendPhotos" = "Send Photos"; +"Channel.EditAdmin.Permission.SendVideos" = "Send Videos"; +"Channel.EditAdmin.Permission.SendVoice" = "Send Voice Messages"; +"Channel.EditAdmin.PermissionAnonymous" = "Remain Anonymous"; +"Channel.EditAdmin.PermissionInviteMembers" = "Add Members"; +"Channel.EditAdmin.PermissionInviteSubscribers" = "Add Subscribers"; +"Channel.EditAdmin.PermissionInviteViaLink" = "Invite Users via Link"; +"Channel.EditAdmin.PostStories" = "Post Stories"; +"Channel.Error.DontHavePermissions" = "Sorry, you don't have the necessary permissions for this action."; +"Channel.ErrorAddBlocked" = "Sorry, you can't add this user to channels."; +"Channel.ErrorAddTooMuch" = "Sorry, you can only add the first 200 members to a channel. Note that an unlimited number of people may join via the channel's link."; +"Channel.EventFilter.AdminsHeader" = "ADMINS"; +"Channel.EventFilter.EventsHeader" = "EVENTS"; +"Channel.EventLog.Alert.Header" = "What is the event log?"; +"Channel.EventLog.Alert.Info" = "This is a list of all notable actions by group members and admins in the last 48 hours."; +"Channel.EventLog.Empty" = "Empty"; +"Channel.EventLog.EmptySearch" = "**No actions found**\n\nNo recent actions that match your query were found."; +"Channel.EventLog.EmptyText" = "**No recent actions**\n\nNo notable actions taken\nby the admins of this channel\nin the last 48 hours."; +"Channel.EventLog.MessageChangedBackgroundEmojiSet" = "%1$@ set background emoji to %2$@"; +"Channel.EventLog.MessageChangedBackgroundEmojiSet.New" = "%1$@ updated channel background emoji"; +"Channel.EventLog.MessageChangedLinkedChannel" = "%@ linked this group to %@"; +"Channel.EventLog.MessageChangedLinkedGroup" = "%@ linked %@ as the discussion group"; +"Channel.EventLog.MessageChangedNameAndBackground" = "%1$@ updated channel color and background emoji"; +"Channel.EventLog.MessageChangedNameColorSet" = "%1$@ set name color to %2$@"; +"Channel.EventLog.MessageChangedNameColorSet.New" = "%1$@ updated channel color"; +"Channel.EventLog.MessageChangedUnlinkedChannel" = "%@ unlinked this group from %@"; +"Channel.EventLog.MessageChangedUnlinkedGroup" = "%@ removed discussion group"; +"Channel.EventLog.MessageChangedWallpaper" = "%1$@ updated channel wallpaper"; +"Channel.EventLog.MessageRankName" = "changed custom title for %@: %@"; +"Channel.EventLog.MessageToggleNoForwardsOff" = "%@ allowed message forwarding"; +"Channel.EventLog.MessageToggleNoForwardsOn" = "%@ restricted message forwarding"; +"Channel.EventLog.MessageTransfered" = "transferred ownership"; +"Channel.EventLog.MessageTransferedName" = "transferred ownership to %@ %@"; +"Channel.EventLog.MessageTransferedName1" = "transferred ownership to %1$@ %2$@"; +"Channel.EventLog.OriginalMessage" = "Original message"; +"Channel.EventLog.Service.AboutRemoved" = "%@ removed this channel's description:"; +"Channel.EventLog.Service.AboutUpdated" = "%@ edited this channel's description:"; +"Channel.EventLog.Service.AntispamDisabled" = "%@ disabled anti-spam"; +"Channel.EventLog.Service.AntispamEnabled" = "%@ enabled anti-spam"; +"Channel.EventLog.Service.DisableSignatures" = "%@ disabled channel signatures"; +"Channel.EventLog.Service.DisabledSlowMode" = "%@ disabled slow mode"; +"Channel.EventLog.Service.DisabledTopics" = "%@ disabled topics"; +"Channel.EventLog.Service.EnableSignatures" = "%@ enabled channel signatures"; +"Channel.EventLog.Service.EnabledTopics" = "%@ enabled topics"; +"Channel.EventLog.Service.LinkRemoved" = "%@ removed channel link:"; +"Channel.EventLog.Service.LinkUpdated" = "%@ edited this channel's link:"; +"Channel.EventLog.Service.MessageGroupPreHistoryHidden" = "%@ made the group history hidden from new members"; +"Channel.EventLog.Service.MessageGroupPreHistoryVisible" = "%@ made the group history visible for new members"; +"Channel.EventLog.Service.MinusTitle" = "- Title"; +"Channel.EventLog.Service.PhotoRemoved" = "%@ removed channel photo"; +"Channel.EventLog.Service.PhotoUpdated" = "%@ updated this channel's photo"; +"Channel.EventLog.Service.PlusTitle" = "+ Title: %@"; +"Channel.EventLog.Service.SetSlowMode" = "%@ set slow mode to %@"; +"Channel.EventLog.Service.SetSlowMode1" = "%1$@ set the slow mode timer to %2$@"; +"Channel.EventLog.Service.TitleUpdated" = "%@ changed the channel name:"; +"Channel.EventLog.Service.TopicCreated" = "%1$@ created topic \"%2$@\""; +"Channel.EventLog.Service.TopicEdited" = "%1$@ changed the topic name to \"%2$@\""; +"Channel.EventLog.Service.UpdateJoin" = "%@ joined the channel"; +"Channel.EventLog.Service.UpdateLeft" = "%@ left the channel"; +"Channel.EventLog.What" = "What Is This?"; +"Channel.EventLogs.Filter.Title" = "Chat Admins"; +"Channel.EventLogs.TitleFull" = "All Actions"; +"Channel.EventLogs.TitleSelected" = "Selected Actions"; +"Channel.Exception.DisabledOption.Channel" = "This option is disabled in channel Permissions for all members."; +"Channel.Exception.DisabledOption.Group" = "This option is disabled for all members in the group permissions."; +"Channel.ExportLinkAboutChannel" = "People can join your channel by following this link. You can revoke the link at any time."; +"Channel.ExportLinkAboutGroup" = "People can join your group by following this link. You can revoke the link at any time."; +"Channel.Intro.CreateChannel" = "Create Channel"; +"Channel.IntroDescription" = "Channels are a one-to-many tool for broadcasting your messages to unlimited audiences."; +"Channel.IntroDescriptionHeader" = "What is a Channel?"; +"Channel.Member.EditTag" = "Edit Tag"; +"Channel.Member.Promote" = "Promote"; +"Channel.Member.Restrict" = "Restrict"; +"Channel.NameHeader" = "CHANNEL NAME"; +"Channel.NewChannel" = "New Channel"; +"Channel.Permissions.SlowMode.Header" = "SLOW MODE"; +"Channel.Permissions.SlowMode.Text.Off" = "Choose how long each member must wait before sending their next message."; +"Channel.Permissions.SlowMode.Text.Selected" = "Members will be able to send only one message every %@"; +"Channel.Permissions.SlowMode.Timeout.10s" = "10s"; +"Channel.Permissions.SlowMode.Timeout.15m" = "15m"; +"Channel.Permissions.SlowMode.Timeout.1h" = "1h"; +"Channel.Permissions.SlowMode.Timeout.1m" = "1m"; +"Channel.Permissions.SlowMode.Timeout.30s" = "30s"; +"Channel.Permissions.SlowMode.Timeout.5m" = "5m"; +"Channel.Permissions.SlowMode.Timeout.Off" = "Off"; +"Channel.Persmission.Denied.SendFile.DefaultRestrictedText" = "Sending files isn't allowed in this group."; +"Channel.Persmission.Denied.SendFile.Forever" = "The admins of this group have restricted you from sending files here."; +"Channel.Persmission.Denied.SendFile.Until" = "The admins of this group have restricted you from sending files here until %@."; +"Channel.Persmission.Denied.SendGifs.DefaultRestrictedText" = "Sending GIFs isn't allowed in this group."; +"Channel.Persmission.Denied.SendGifs.Forever" = "The admins of this group have restricted your ability to send GIFs."; +"Channel.Persmission.Denied.SendGifs.Until" = "The admins of this group have restricted your ability to send GIFs until %@."; +"Channel.Persmission.Denied.SendInline.DefaultRestrictedText" = "Sending inline content isn't allowed in this group."; +"Channel.Persmission.Denied.SendInline.Forever" = "The admins of this group have restricted your ability to send inline content."; +"Channel.Persmission.Denied.SendInline.Until" = "The admins of this group have restricted your ability to send inline content until %@."; +"Channel.Persmission.Denied.SendInstantVideo.DefaultRestrictedText" = "Sending video messages isn't allowed in this group."; +"Channel.Persmission.Denied.SendInstantVideo.Forever" = "The admins of this group have restricted you from sending video messages here."; +"Channel.Persmission.Denied.SendInstantVideo.Until" = "The admins of this group have restricted you from sending video messages here until %@."; +"Channel.Persmission.Denied.SendMedia.DefaultRestrictedText" = "Sending media isn't allowed in this group."; +"Channel.Persmission.Denied.SendMedia.Forever" = "The admins of this group have restricted your ability to send media."; +"Channel.Persmission.Denied.SendMedia.Until" = "The admins of this group have restricted your ability to send media until %@."; +"Channel.Persmission.Denied.SendMessages.DefaultRestrictedText" = "Sending messages is not allowed in this group."; +"Channel.Persmission.Denied.SendMessages.Forever" = "The admins of this group have restricted your ability to send messages."; +"Channel.Persmission.Denied.SendMessages.Until" = "The admins of this group have restricted you from messaging until %@."; +"Channel.Persmission.Denied.SendMusic.DefaultRestrictedText" = "Sending music isn't allowed in this group."; +"Channel.Persmission.Denied.SendMusic.Forever" = "The admins of this group have restricted you from sending music here."; +"Channel.Persmission.Denied.SendMusic.Until" = "The admins of this group have restricted you from sending music here until %@."; +"Channel.Persmission.Denied.SendPhoto.DefaultRestrictedText" = "Sending photos isn't allowed in this group."; +"Channel.Persmission.Denied.SendPhoto.Forever" = "The admins of this group have restricted you from sending photos here."; +"Channel.Persmission.Denied.SendPhoto.Until" = "The admins of this group have restricted you from sending photos here until %@."; +"Channel.Persmission.Denied.SendPoll.DefaultRestrictedText" = "Posting polls isn't allowed in this group."; +"Channel.Persmission.Denied.SendPoll.Forever" = "The admins of this group have restricted your ability to send polls."; +"Channel.Persmission.Denied.SendPoll.Until" = "The admins of this group have restricted your ability to send polls until %@."; +"Channel.Persmission.Denied.SendStickers.DefaultRestrictedText" = "Sending stickers isn't allowed in this group."; +"Channel.Persmission.Denied.SendStickers.Forever" = "The admins of this group have restricted your ability to send stickers."; +"Channel.Persmission.Denied.SendStickers.Until" = "The admins of this group have restricted your ability to send stickers until %@."; +"Channel.Persmission.Denied.SendVideo.DefaultRestrictedText" = "Sending videos isn't allowed in this group."; +"Channel.Persmission.Denied.SendVideo.Forever" = "The admins of this group have restricted you from sending video here."; +"Channel.Persmission.Denied.SendVideo.Until" = "The admins of this group have restricted you from sending video here until %@."; +"Channel.Persmission.Denied.SendVoice.DefaultRestrictedText" = "Sending voice messages isn't allowed in this group."; +"Channel.Persmission.Denied.SendVoice.Forever" = "The admins of this group have restricted you from sending voice messages here."; +"Channel.Persmission.Denied.SendVoice.Until" = "The admins of this group have restricted you from sending voice messages here until %@."; +"Channel.Persmission.MessageBlock" = "Text is not Allowed"; +"Channel.Private" = "Private"; +"Channel.PrivateAboutChannel" = "Private channels can only be joined via invite link."; +"Channel.PrivateAboutGroup" = "Private groups can only be joined if you were invited or have an invite link."; +"Channel.Public" = "Public"; +"Channel.PublicAboutChannel" = "Public channels can be found in search, anyone can join them."; +"Channel.PublicAboutGroup" = "Public groups can be found in search, their chat history is available to everyone and anyone can join."; +"Channel.PublicNamesLimitError" = "You have reserved too many public links. Try revoking the link from an older group or channel, or create a private one instead."; +"Channel.RevokeLink" = "Revoke Link"; +"Channel.RevokeLink.Confirm.Header" = "Revoke Link"; +"Channel.RevokeLink.Confirm.OK" = "Revoke"; +"Channel.RevokeLink.Confirm.Text" = "Are you sure you want to revoke this link? Once this is done, nobody will be able to use it to join the group."; +"Channel.SelectPeers.Contacts" = "contacts"; +"Channel.SelectPeers.Global" = "global"; +"Channel.Similar.Premium" = "Subscribe to **[Telegram Premium]()** to unlock up to **%d** similar channels"; +"Channel.SlowMode.Off" = "Off"; +"Channel.SlowMode.ToolTip" = "Slow Mode is active.\nYou can send your next message in %@:%@."; +"Channel.Stats.Loading" = "**Preparing stats**\nPlease wait a few moments while\nwe generate your stats."; +"Channel.Stats.Overview.Reactions" = "Reactions"; +"Channel.Stats.Overview.Story.PrivateShares" = "Private Shares"; +"Channel.Stats.Overview.Views" = "Views"; +"Channel.TooMuchBots" = "There are too many bots in this group. Please remove some of the bots you're not using first."; +"Channel.TransferOwner.Error.Enable2FA" = "Enable Two-Step Verification"; +"Channel.TransferOwner.Error.Text" = "You can only confirm this action if:\n\n• 2-Step verification is active and has been enabled for your account for more than **7 days**.\n\n• Your 2-Step Verification password has not been changed the last **7 days**.\n\n• You have been logged in on this device for more than **24 hours**."; +"Channel.TransferOwner.Error.Title" = "Security Check"; +"Channel.TransferOwner.ErrorAdminsTooMuch" = "Sorry, this channel has too many admins and the new owner can't be added. Please remove one of the existing admins first."; +"Channel.TransferOwner.ErrorPrivacyRestricted" = "Sorry, this user is not a member of this channel and their privacy settings prevent you from adding them manually."; +"Channel.TransferOwner.ErrorPublicChannelsTooMuch" = "Sorry, the target user has too many public groups or channels already. Please ask them to make one of their existing groups or channels private first."; +"Channel.TypeHeaderChannel" = "CHANNEL TYPE"; +"Channel.TypeHeaderGroup" = "GROUP TYPE"; +"Channel.UserRestriction" = "USER RESTRICTIONS"; +"Channel.Username.Activate.Info.Channel" = "Do you want to show this link on channel info page?"; +"Channel.Username.Activate.Info.Group" = "Do you want to show this link on the group info page?"; +"Channel.Username.Activate.Ok.Channel" = "Show"; +"Channel.Username.Activate.Ok.Group" = "Show"; +"Channel.Username.Activate.Title.Channel" = "Activate Username"; +"Channel.Username.Activate.Title.Group" = "Activate Username"; +"Channel.Username.Deactivate.Info.Channel" = "Do you want to hide this link from the channel info page?"; +"Channel.Username.Deactivate.Info.Group" = "Do you want to hide this link from the group info page?"; +"Channel.Username.Deactivate.Ok.Channel" = "Hide"; +"Channel.Username.Deactivate.Ok.Group" = "Hide"; +"Channel.Username.Deactivate.Title.Channel" = "Deactivate Username"; +"Channel.Username.Deactivate.Title.Group" = "Deactivate Username"; +"Channel.Username.List.Info.Channel" = "Drag and drop links to change the order in which they will be displayed on the channel info page."; +"Channel.Username.List.Info.Group" = "Drag and drop links to change order in which they will be displayed on the group info page."; +"Channel.Username.List.Title" = "LINK ORDER"; +"Channel.Username.UsernamePurchaseAvailable" = "This link is already taken."; +"Channel.UsernameAboutChannel" = "If you set a public link, other people will be able to find and join your channel.\n\nYou can use a–z, 0–9 and underscores.\nMinimum length is 5 characters."; +"Channel.UsernameAboutGroup" = "People can share this link with others and find your group using Telegram search."; +"Channel.Wallpaper.ApplyBoth" = "Apply for Me and %@"; +"ChannelAdmins.AggressiveAntiSpam" = "Aggressive Anti-Spam"; +"ChannelAdmins.AggressiveAntiSpam.Info" = "Telegram will filter more spam but may occasionally affect ordinary messages. You can report false positives in Recent Actions."; +"ChannelAdmins.RecentActions" = "Recent Actions"; +"ChannelBlacklist.AddMember" = "Restrict Member"; +"ChannelBlacklist.Blocked" = "BLOCKED"; +"ChannelBlacklist.EmptyDescrpition" = "Banned users are removed from the group and can only come back if added by an admin. Invite links don't work for them."; +"ChannelBlacklist.Restricted" = "RESTRICTED"; +"ChannelBoost.AdditionalFeatures.Text" = "By gaining boosts, your group reaches higher levels and unlocks more features."; +"ChannelBoost.AdditionalFeatures.Title" = "Additional Features"; +"ChannelBoost.Autotranslation" = "Auto-translation of Messages"; +"ChannelBoost.BoostChannel" = "Boost Channel"; +"ChannelBoost.BoostGroup" = "Boost Group"; +"ChannelBoost.BoostedChannelMoreRequired" = "%1$@ needed to be able to post %2$@."; +"ChannelBoost.BoostedChannelMoreRequired.New" = "%1$@ needed to unlock new features."; +"ChannelBoost.BoostedChannelReachedLevel" = "This channel reached **Level %1$@** and can now post %2$@."; +"ChannelBoost.BoostedChannelReachedLevel.Channel" = "This channel reached **Level %1$@**."; +"ChannelBoost.BoostedChannelReachedLevel.Group" = "This group reached **Level %1$@**."; +"ChannelBoost.Booster.Duration" = "%dm"; +"ChannelBoost.Booster.Stars#one" = "%d Star"; +"ChannelBoost.Booster.Stars#other" = "%d Stars"; +"ChannelBoost.Booster.Stars_countable" = "%d"; +"ChannelBoost.Booster.ToBeDistributed" = "To Be Distributed"; +"ChannelBoost.Booster.Unclaimed" = "Unclaimed"; +"ChannelBoost.EmojiPack" = "Use Emoji Pack"; +"ChannelBoost.EmojiStatus" = "Use Emoji Statuses"; +"ChannelBoost.EnableAutoTranslationText" = "Your channel needs to reach **Level %1$@** to enable auto-translation.\n\nAsk your **Premium** subscribers to boost the channel with this link:"; +"ChannelBoost.EnableColors" = "Enable Colors"; +"ChannelBoost.EnableColorsText" = "Your channel needs **%1$@ level** to change channel color.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.EnableEmojiStatusLevelText" = "Your channel needs **Level %1$@** to use emoji statuses.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.EnableEmojiStatusLevelText.Group" = "Your group needs **Level %1$@** to use emoji statuses.\n\nAsk your **Premium** members to boost your group with this link:"; +"ChannelBoost.EnableNameIconLevelText" = "Your channel needs **Level %1$@** to change link icon.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.EnableNoAdsLevelText" = "Your channel needs **Level %1$@** to switch off ads."; +"ChannelBoost.EnableProfileColorLevelText" = "Your channel needs **Level %1$@** to change channel cover color.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.EnableProfileColorLevelText.Group" = "Your group needs **Level %1$@** to change group cover color.\n\nAsk your **Premium** members to boost your group with this link:"; +"ChannelBoost.EnableProfileIconLevelText" = "Your channel needs **Level %1$@** to change channel cover icon.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.EnableProfileIconLevelText.Group" = "Your group needs **Level %1$@** to change group cover icon.\n\nAsk your **Premium** members to boost your group with this link:"; +"ChannelBoost.EnableReactions" = "Custom Reactions"; +"ChannelBoost.EnableReactionsText" = "Your channel needs to reach **Level %1$@** to add %2$@ custom emoji as reactions.\n\nAsk your **Premium** subscribers to boost the channel with this link:"; +"ChannelBoost.EnableReactionsText.Group" = "Your group needs to reach **Level%1$@** to add %2$@ custom emoji as reactions.\n\nAsk your **Premium** members to boost the group with this link:"; +"ChannelBoost.EnableStories" = "Enable Stories"; +"ChannelBoost.EnableStoriesForChannel" = "Enable Stories for This Channel"; +"ChannelBoost.EnableStoriesForChannelText" = "**%1$@** needs %2$@ to enable posting stories. Help make it possible!"; +"ChannelBoost.EnableStoriesForOtherChannel" = "Enable Stories for the Channel"; +"ChannelBoost.EnableStoriesMoreRequired" = "This channel needs **%1$@** to enable stories."; +"ChannelBoost.EnableStoriesText" = "Your channel needs %1$@ to enable posting stories.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.EnableWallpapers" = "Enable Wallpapers"; +"ChannelBoost.EnableWallpapersText" = "Your channel needs **%1$@ level** to change wallpaper.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.EnableWallpapersText.Group" = "Your group needs **Level %1$@** to change wallpaper.\n\nAsk your **Premium** members to boost your group with this link:"; +"ChannelBoost.EnabledStoriesForChannelText" = "This channel reached **Level 1** and can now post stories."; +"ChannelBoost.Error.BoostTooOftenText" = "You can only change the channel you boost once a day. You will be able to boost in **%@**."; +"ChannelBoost.Error.BoostTooOftenTitle" = "Can't Boost Too Often"; +"ChannelBoost.Error.GiftedPremiumNotAllowedText" = "Because your **Telegram Premium** subscription was gifted to you, you can't use it to boost channels."; +"ChannelBoost.Error.GiftedPremiumNotAllowedTitle" = "Can't Boost with Gifted Premium"; +"ChannelBoost.Error.PremiumNeededText" = "Only **Telegram Premium** subscribers can boost channels. Do you want to subscribe to **Telegram Premium**?"; +"ChannelBoost.Error.PremiumNeededText.OK" = "Read More"; +"ChannelBoost.Error.PremiumNeededTitle" = "Premium Needed"; +"ChannelBoost.HelpUpgradeChannel" = "Help Upgrade This Channel"; +"ChannelBoost.HelpUpgradeChannelText" = "**%1$@** needs %2$@ to be able to post %3$@."; +"ChannelBoost.HelpUpgradeGroup" = "Help Upgrade This Group"; +"ChannelBoost.IncreaseLimit" = "Increase Story Limit"; +"ChannelBoost.IncreaseLimitText" = "Your channel needs %1$@ to post %2$@.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.IncreaseLimitText.Channel" = "Your channel needs %1$@ to get access to features.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.IncreaseLimitText.Group" = "Your group needs %1$@ to get access to features.\n\nAsk your **Premium** members to boost your group with this link:"; +"ChannelBoost.Level" = "Level %@"; +"ChannelBoost.MaxLevelReached" = "Maximum Level Reached"; +"ChannelBoost.MaxLevelReachedText" = "This channel reached **Level %1$@** and can now post %2$@."; +"ChannelBoost.MaxLevelReachedText.Channel" = "This channel reached **Level %1$@** and has access to all features."; +"ChannelBoost.MaxLevelReachedText.Group" = "This group reached **Level %1$@** and has access to all features."; +"ChannelBoost.MaxLevelReachedTextAuthor" = "%1$@ reached **Level %2$@** and can now post %3$@."; +"ChannelBoost.MoreBoosts#one" = "**%d** more boost"; +"ChannelBoost.MoreBoosts#other" = "**%d** more boosts"; +"ChannelBoost.MoreBoosts_countable" = "%d"; +"ChannelBoost.NameIcon" = "Set Link Icon"; +"ChannelBoost.NoAds" = "Switch Off Ads"; +"ChannelBoost.ProfileColor" = "Set Cover Color"; +"ChannelBoost.ProfileIcon" = "Set Cover Icon"; +"ChannelBoost.Replace" = "Replace"; +"ChannelBoost.ReplaceBoost" = "You currently boost **%1$@**. Do you want to boost **%2$@** instead?"; +"ChannelBoost.StoriesPerDay#one" = "**%d** story per day"; +"ChannelBoost.StoriesPerDay#other" = "**%d** stories per day"; +"ChannelBoost.StoriesPerDay_countable" = "%d"; +"ChannelBoost.Table.AudioTranscription" = "Voice-To-Text Conversion"; +"ChannelBoost.Table.CustomReactions#one" = "%d Custom Reaction"; +"ChannelBoost.Table.CustomReactions#other" = "%d Custom Reactions"; +"ChannelBoost.Table.CustomReactions_countable" = "%d"; +"ChannelBoost.Table.CustomWallpaper" = "Custom Channel Background"; +"ChannelBoost.Table.CustomWallpaper.Group" = "Custom Group Background"; +"ChannelBoost.Table.EmojiPack" = "Custom Emoji Pack"; +"ChannelBoost.Table.EmojiStatus" = "1000+ Emoji Statuses"; +"ChannelBoost.Table.HeadersLogo" = "Custom Logo for Links and Quotes"; +"ChannelBoost.Table.LevelUnlocks#one" = "Level %d Unlock:"; +"ChannelBoost.Table.LevelUnlocks#other" = "Level %d Unlocks:"; +"ChannelBoost.Table.LevelUnlocks_countable" = "%d"; +"ChannelBoost.Table.NameColor#one" = "%d Channel Name Color"; +"ChannelBoost.Table.NameColor#other" = "%d Channel Name Colors"; +"ChannelBoost.Table.NameColor_countable" = "%d"; +"ChannelBoost.Table.NoAds" = "Switch Off Ads"; +"ChannelBoost.Table.ProfileColor#one" = "%d Color for Channel Cover"; +"ChannelBoost.Table.ProfileColor#other" = "%d Color for Channel Covers"; +"ChannelBoost.Table.ProfileColor.Group#one" = "%d Color for Group Cover"; +"ChannelBoost.Table.ProfileColor.Group#other" = "%d Color for Group Covers"; +"ChannelBoost.Table.ProfileColor.Group_countable" = "%d"; +"ChannelBoost.Table.ProfileColor_countable" = "%d"; +"ChannelBoost.Table.ProfileLogo" = "Custom Logo for Channel Cover"; +"ChannelBoost.Table.ProfileLogo.Group" = "Custom Logo for Group Cover"; +"ChannelBoost.Table.StoriesPerDay#one" = "%d Story Per Day"; +"ChannelBoost.Table.StoriesPerDay#other" = "%d Stories Per Day"; +"ChannelBoost.Table.StoriesPerDay_countable" = "%d"; +"ChannelBoost.Table.StyleForHeaders#one" = "%d Style for Links and Quotes"; +"ChannelBoost.Table.StyleForHeaders#other" = "%d Style for Links and Quotes"; +"ChannelBoost.Table.StyleForHeaders_countable" = "%d"; +"ChannelBoost.Table.Wallpaper#one" = "%d Channel Background"; +"ChannelBoost.Table.Wallpaper#other" = "%d Channel Backgrounds"; +"ChannelBoost.Table.Wallpaper.Group#one" = "%d Group Background"; +"ChannelBoost.Table.Wallpaper.Group#other" = "%d Group Backgrounds"; +"ChannelBoost.Table.Wallpaper.Group_countable" = "%d"; +"ChannelBoost.Table.Wallpaper_countable" = "%d"; +"ChannelBoost.Title.Channel" = "Boost Channel"; +"ChannelBoost.Title.Group" = "Boost Group"; +"ChannelBoost.UnblockSlowmode.Group" = "Boost the group **%1$@** times to remove slow mode restrictions. Your boosts will help **%2$@** to unlock new features."; +"ChannelBoost.UnblockSlowmode.Group.Full" = "Boost the group **%1$@** times to remove slow mode restrictions."; +"ChannelBoost.UnblockSlowmode.Group.Full_countable" = "%d"; +"ChannelBoost.UnblockSlowmode.Group_countable" = "%d %@"; +"ChannelBoost.UnblockText.Group" = "Boost the group **%1$@** times to remove messaging restrictions. Your boosts will help **%2$@** to unlock new features."; +"ChannelBoost.UnblockText.Group.Full" = "Boost the group **%1$@** times to remove messaging restrictions."; +"ChannelBoost.UnblockText.Group.Full_countable" = "%d"; +"ChannelBoost.UnblockText.Group_countable" = "%d %@"; +"ChannelBoost.YouBoostedChannel" = "You Boosted %1$@!"; +"ChannelBoost.YouBoostedOtherChannel" = "You Boosted The Channel"; +"ChannelBoost.ZeroLevelText.Channel" = "Your channel needs %1$@ to get access to features.\n\nAsk your **Premium** subscribers to boost your channel with this link:"; +"ChannelBoost.ZeroLevelText.Group" = "Your group needs %1$@ to get access to features.\n\nAsk your **Premium** members to boost your group with this link:"; +"ChannelEventFilter.Calls" = "Calls"; +"ChannelEventFilter.ChannelInfo" = "Channel Info"; +"ChannelEventFilter.ChannelSettings" = "Channel Settings"; +"ChannelEventFilter.DeletedMessages" = "Deleted Messages"; +"ChannelEventFilter.EditedMessages" = "Edited Messages"; +"ChannelEventFilter.GroupInfo" = "Group Info"; +"ChannelEventFilter.GroupSettings" = "Group Settings"; +"ChannelEventFilter.Invites" = "Invite Links"; +"ChannelEventFilter.LeavingMembers" = "Members Leaving"; +"ChannelEventFilter.MembersAndAdmins" = "Members and Admins"; +"ChannelEventFilter.Messages" = "Messages"; +"ChannelEventFilter.NewAdmins" = "Admin Rights"; +"ChannelEventFilter.NewMembers" = "New Members"; +"ChannelEventFilter.NewRestrictions" = "New Restrictions"; +"ChannelEventFilter.PinnedMessages" = "Pinned Messages"; +"ChannelEventFilter.SendMessages" = "Send Messages"; +"ChannelEventFilter.VoiceChats" = "Video Chats"; +"ChannelInfo.AddUserLeftError" = "If a person has left a channel in the past, you need to be in their Telegram contacts to add them back.\n\nThey can still join via an invite link as long as they are not on the Removed Users list."; +"ChannelInfo.FakeWarning" = "⚠️ Warning: Many users reported that this channel impersonates a famous person or organization."; +"ChannelInfo.ScamWarning" = "⚠️ Warning: Many users reported this channel as a scam or a fake account. Please be careful, especially if it asks you for money."; +"ChannelMembers.AddMembers" = "Add Members"; +"ChannelMembers.AddSubscribers" = "Add Subscribers"; +"ChannelMembers.Contacts" = "CONTACTS IN THIS CHANNEL"; +"ChannelMembers.Group.Contacts" = "CONTACTS IN THIS GROUP"; +"ChannelMembers.Group.HideMembers" = "Hide Members"; +"ChannelMembers.Group.HideMembers.Info" = "Switch this on to hide the list of members in this group. Admins will remain visible."; +"ChannelMembers.Group.OtherMembers" = "OTHER MEMBERS"; +"ChannelMembers.InviteLink" = "Invite via Link"; +"ChannelMembers.MembersListDesc" = "Only channel admins can see this list."; +"ChannelMembers.OtherMembers" = "OTHER SUBSCRIBERS"; +"ChannelMembers.Select.Title" = "Add Members"; +"ChannelMessages.Header" = "Allow users to send messages to your channel, with option to charge a fee for each message."; +"ChannelMessages.Paid.Free" = "Free"; +"ChannelMessages.Paid.Header" = "PRICE FOR EACH MESSAGE"; +"ChannelMessages.Paid.Info" = "You will receive %1$@ of the selected fee (~%2$@$) for each incoming message."; +"ChannelMessages.Paid.Info.Zero" = "Your channel will receive %1$@ of the selected fee for each incoming message."; +"ChannelMessages.Text" = "Direct Messages"; +"ChannelMessages.Title" = "Direct Messages"; +"ChannelParticipant.AddTag" = "Add tag"; +"ChannelParticipant.EditTag" = "Edit Tag"; +"ChannelParticipant.Member" = "Member"; +"ChannelParticipant.Permissions" = "Permissions"; +"ChannelParticipant.PreviewMessage" = "Good morning 👋"; +"ChannelParticipant.PromoteToAdmin" = "Promote to Admin"; +"ChannelParticipant.RemoveMember" = "Remove Member"; +"ChannelParticipant.RemoveTag" = "Remove Tag"; +"ChannelParticipant.SaveChanges" = "Save Changes"; +"ChannelParticipant.TagDescOther" = "Add short tag next to %1$@'s name."; +"ChannelParticipant.TagDescSelf" = "Share your role, title or how you're known in this group. Your tag is visible to all members."; +"ChannelParticipant.TagUpdateError" = "An error occurred while updating the tag."; +"ChannelParticipant.TagUpdated" = "Tag has been updated."; +"ChannelPermission.PaidMessages.ChargeStars" = "Charge Stars for Messages"; +"ChannelPermission.PaidMessages.ChargeStars.Info" = "If you turn this on, regular members of the group will have to pay Stars to send messages."; +"ChannelPermission.PaidMessages.Setup.Header" = "SET YOUR PRICE PER MESSAGE"; +"ChannelPermission.PaidMessages.Setup.Info" = "Your group will receive %1$@% of the selected fee (~%2$@$) for each incoming message."; +"ChannelPermission.RestrictBoosters" = "Do Not Restrict Boosters"; +"ChannelPermission.RestrictBoosters.Active" = "Choose how many boosts a user must give to the group to bypass restrictions on sending messages."; +"ChannelPermission.RestrictBoosters.NonActive" = "Turn this on to always allow users who boosted your group to send messages and media."; +"ChannelPermissions.EditOwnTags" = "Edit Own Tags"; +"ChannelPermissions.NoEditTags" = "no edit tags"; +"ChannelPermissions.NoReactions" = "no reactions"; +"ChannelPermissions.SendReactions" = "Send Reactions"; +"ChannelReactions.Add" = "Add Reactions"; +"ChannelReactions.Add.Info" = "You can also [create your own]() emoji packs and use them."; +"ChannelReactions.EnableReactions" = "Enable Reaction"; +"ChannelReactions.EnableReactions.Info" = "You can add emoji from any emoji pack as reactions."; +"ChannelReactions.EnableStars" = "Enable Paid Reactions"; +"ChannelReactions.EnableStars.Info" = "Switch this on to let your subscribers react to posts with Telegram Stars, which you will be able to withdraw as Grams. [Learn More >](https://telegram.org/tos/content-creator-rewards#2-3-subscriber-donations)"; +"ChannelReactions.LevelRequired" = "%d Boost Level Required"; +"ChannelReactions.MaxCount.Info" = "Limit the number of different reactions that can be added to a post, including already published posts."; +"ChannelReactions.MaxCount.Title" = "MAXIMUM REACTIONS PER POST"; +"ChannelReactions.OK" = "Update Reactions"; +"ChannelReactions.Placeholder" = "Add Reactions..."; +"ChannelReactions.Success" = "Reactions successfully updated"; +"ChannelReactions.Title" = "Reactions"; +"ChannelStats.Graph.Followers" = "FOLLOWERS"; +"ChannelStats.Graph.Growth" = "GROWTH"; +"ChannelStats.Graph.Interactions" = "INTERACTIONS"; +"ChannelStats.Graph.Language" = "LANGUAGES"; +"ChannelStats.Graph.NewFollowersBySource" = "FOLLOWERS BY SOURCE"; +"ChannelStats.Graph.Notifications" = "NOTIFICATIONS"; +"ChannelStats.Graph.Reactions" = "REACTIONS BY EMOTICON"; +"ChannelStats.Graph.Stories" = "STORY INTERACTIONS"; +"ChannelStats.Graph.StoriesReactions" = "STORIES REACTIONS"; +"ChannelStats.Graph.ViewsByHours" = "VIEWS BY HOURS (UTC)"; +"ChannelStats.Graph.ViewsBySource" = "VIEWS BY SOURCE"; +"ChannelStats.Overview" = "OVERVIEW"; +"ChannelStats.Overview.EnabledNotifications" = "Enabled Notifications"; +"ChannelStats.Overview.Followers" = "Followers"; +"ChannelStats.Overview.ReactionsPerPost" = "Reactions Per Post"; +"ChannelStats.Overview.ReactionsPerStory" = "Reactions Per Story"; +"ChannelStats.Overview.SharesPerPost" = "Shares Per Post"; +"ChannelStats.Overview.SharesPerStory" = "Shares Per Story"; +"ChannelStats.Overview.ViewsPerPost" = "Views Per Post"; +"ChannelStats.Overview.ViewsPerStory" = "Views Per Story"; +"ChannelStats.Recent.Header" = "TOP RECENT POSTS"; +"ChannelStats.SharesCount#one" = "%d shares"; +"ChannelStats.SharesCount#other" = "%d shares"; +"ChannelStats.SharesCount_ZeroValueHolder" = "No shares"; +"ChannelStats.SharesCount_countable" = "%d"; +"ChannelStats.Title" = "Channel Statistics"; +"ChannelStats.ViewsCount#one" = "%d view"; +"ChannelStats.ViewsCount#other" = "%d views"; +"ChannelStats.ViewsCount_ZeroValueHolder" = "No views"; +"ChannelStats.ViewsCount_countable" = "%d"; +"ChannelVisibility.Checking" = "Checking..."; +"ChannelVisibility.Confirm.MakePrivate.Channel" = "If you make this channel private, the name @%@ will be removed. Anyone else will be able to take it for their public groups or channels."; +"ChannelVisibility.Confirm.MakePrivate.Group" = "If you make this group private, the name @%@ will be removed. Anyone else will be able to take it for their public groups or channels."; +"ChannelVisibility.Confirm.Revoke" = "Are you sure you want to make this channel private and remove its username?"; +"ChannelVisibility.Forwarding.ChannelInfo" = "Subscribers will be able to copy, save and forward content from this channel."; +"ChannelVisibility.Forwarding.ChannelInfo1" = "Subscribers will be able to copy, save or forward content from this channel."; +"ChannelVisibility.Forwarding.ChannelInfo1.Restrict" = "Subscribers won't be able to copy, save or forward content from this channel."; +"ChannelVisibility.Forwarding.ChannelTitle" = "Forwarding From This Channel"; +"ChannelVisibility.Forwarding.Disabled" = "Restrict Saving Content"; +"ChannelVisibility.Forwarding.Enabled" = "Allow Saving Content"; +"ChannelVisibility.Forwarding.GroupInfo" = "Members will be able to copy, save and forward content from this group."; +"ChannelVisibility.Forwarding.GroupInfo1" = "Members will be able to copy, save and forward content from this group."; +"ChannelVisibility.Forwarding.GroupInfo1.Restrict" = "Members won't be able to copy, save or forward content from this group."; +"ChannelVisibility.Forwarding.GroupTitle" = "Forwarding From This Group"; +"ChannelVisibility.Forwarding.Restrict" = "Restrict Saving Content"; +"ChannelVisibility.Loading" = "Loading..."; +"ChannelVisibility.Messages.Approve" = "Approve New Members"; +"ChannelVisibility.Messages.Approve.Info" = "Turn this on if you want users to be able to send messages only after they are approved by an admin."; +"ChannelVisibility.Messages.Everyone" = "Everyone"; +"ChannelVisibility.Messages.Members" = "Only Members"; +"ChannelVisibility.Messages.Who" = "WHO CAN SEND MESSAGES?"; +"ChannelVisibiltiy.Context.Copy" = "Copy"; +"ChannelVisibiltiy.Context.Revoke" = "Revoke"; +"ChannelVisibiltiy.ManageLinks" = "Manage Links"; +"ChannelVisibiltiy.PermanentLink" = "PERMANENT LINK"; +"ChannelWallpaper.ChooseFromFile" = "Choose from File"; +"ChannelWallpaper.NoWallpaper" = "No\nWallpaper"; +"ChannelWallpaper.RemoveWallpaper" = "Remove Wallpaper"; +"ChannelWallpaper.Title" = "Channel Wallpaper"; +"ChannelWallpaper.Title.Group" = "Group Wallpaper"; +"Chat.Accessory.Forward#one" = "Forward Message"; +"Chat.Accessory.Forward#other" = "Forward %d Messages"; +"Chat.Accessory.Forward.From" = "From"; +"Chat.Accessory.Forward.You" = "You"; +"Chat.Accessory.Forward_ZeroValueHolder" = "Forward Messages"; +"Chat.Accessory.Forward_countable" = "%d"; +"Chat.Accessory.Hidden#one" = "Forward Message (sender's name hidden)"; +"Chat.Accessory.Hidden#other" = "Forward %d Messages (senders' names hidden)"; +"Chat.Accessory.Hidden_ZeroValueHolder" = "Forward Messages (senders' names hidden)"; +"Chat.Accessory.Hidden_countable" = "%d"; +"Chat.Action.ViewTheme" = "VIEW THEME"; +"Chat.Ad.WhatThis" = "what's this?"; +"Chat.AdminBadge" = "admin"; +"Chat.Alert.Forward.Action.Another" = "Forward to Another Chat"; +"Chat.Alert.Forward.Action.Cancel" = "Cancel Forwarding"; +"Chat.Alert.Forward.Action.Hide" = "Hide Sender Names"; +"Chat.Alert.Forward.Action.Hide1#one" = "Hide Sender's Name"; +"Chat.Alert.Forward.Action.Hide1#other" = "Hide Senders' Names"; +"Chat.Alert.Forward.Action.Hide1_countable" = "%d"; +"Chat.Alert.Forward.Action.HideCaption#one" = "Hide Caption"; +"Chat.Alert.Forward.Action.HideCaption#other" = "Hide Captions"; +"Chat.Alert.Forward.Action.HideCaption_countable" = "%d"; +"Chat.Alert.Forward.Action.Show" = "Show Sender Names"; +"Chat.Alert.Forward.Action.Show1#one" = "Show Sender's Name"; +"Chat.Alert.Forward.Action.Show1#other" = "Show Senders' Names"; +"Chat.Alert.Forward.Action.Show1_countable" = "%d"; +"Chat.Alert.Forward.Action.ShowCaption#one" = "Show Caption"; +"Chat.Alert.Forward.Action.ShowCaption#other" = "Show Captions"; +"Chat.Alert.Forward.Action.ShowCaption_countable" = "%d"; +"Chat.Alert.Forward.Header#one" = "%d Message"; +"Chat.Alert.Forward.Header#other" = "%d Messages"; +"Chat.Alert.Forward.Header_countable" = "%d"; +"Chat.Alert.Forward.Text" = "What would you like to do with %1$@ from %2$@?"; +"Chat.Alert.Forward.Text.Inner#one" = "%d message"; +"Chat.Alert.Forward.Text.Inner#other" = "%d messages"; +"Chat.Alert.Forward.Text.Inner_countable" = "%d"; +"Chat.Alert.Unsaved" = "You have unsaved changes.\nReset?"; +"Chat.Alert.Unsaved.Reset" = "Reset"; +"Chat.ApplyProxy" = "ADD PROXY"; +"Chat.AutoremoveTimerSetUserGlobal" = "%1$@ uses a self-destruct timer for all chats.\nAll new messages in this chat will be automatically deleted after %2$@ they are sent."; +"Chat.AutoremoveTimerSetUserGlobalYou" = "You set a self-destruct timer for all chats.\nAll new messages in this chat will be automatically deleted %1$@ after they're sent."; +"Chat.Bot.Ad" = "Ad"; +"Chat.Bot.Remove" = "remove"; +"Chat.BotAd.Ad" = "Ad"; +"Chat.BotManager.Context.Manage" = "Manage Bot"; +"Chat.BotManager.Context.Revoke" = "Revoke access"; +"Chat.BotManager.FullAccess" = "bot manages this chat"; +"Chat.BotManager.Paused" = "bot paused"; +"Chat.BotManager.ReadOnly" = "bot reads this chat"; +"Chat.BotManager.Start" = "START"; +"Chat.BotManager.Stop" = "STOP"; +"Chat.Bubbles.ForwardedFrom" = "Forwarded from: [%@]()"; +"Chat.Bubbles.ForwardedFrom_New" = "Forwarded from: %@"; +"Chat.Bubbles.ForwardedFrom_WithPhoto" = "Forwarded from:## %@"; +"Chat.Bubbles.ForwardedStory" = "Forwarded Story From: [%@]()"; +"Chat.Call.Incoming" = "Incoming Call"; +"Chat.Call.Outgoing" = "Outgoing Call"; +"Chat.Cancel" = "Cancel"; +"Chat.Channel.Unaccessible" = "Sorry, this channel is not accessible."; +"Chat.ChannelBadge" = "channel"; +"Chat.ChatTheme.ApplyTheme" = "Apply Theme"; +"Chat.ChatTheme.Cancel" = "Cancel"; +"Chat.ChatTheme.NoTheme" = "No\nTheme"; +"Chat.ChatTheme.ResetToDefault" = "Remove Wallpaper"; +"Chat.ChatTheme.SelectBackground" = "Choose a Wallpaper"; +"Chat.Comments.Kicked" = "You have been blocked from posting comments."; +"Chat.CommentsHeader.Empty" = "No comments here yet..."; +"Chat.CommentsHeader.Full" = "Discussion started"; +"Chat.Confirm.ActionUndonable" = "This action can't be undone"; +"Chat.Confirm.DeleteForEveryone#one" = "Are you sure you want to delete this message for everyone?"; +"Chat.Confirm.DeleteForEveryone#other" = "Are you sure you want to delete these messages for everyone?"; +"Chat.Confirm.DeleteForEveryone_countable" = "%d"; +"Chat.Confirm.DeleteMessages" = "Delete selected messages?"; +"Chat.Confirm.DeleteMessages1#one" = "Delete selected message?"; +"Chat.Confirm.DeleteMessages1#other" = "Delete selected messages?"; +"Chat.Confirm.DeleteMessages1_countable" = "%d"; +"Chat.Confirm.DeleteMessagesForEveryone" = "Delete for Everyone"; +"Chat.Confirm.DeleteMessages_countable" = "%d"; +"Chat.Confirm.PinFor" = "Pin for me and %@"; +"Chat.Confirm.PinOld" = "Do you want to pin an older message while leaving a more recent one pinned?"; +"Chat.Confirm.ReportSpam" = "Report Spam?"; +"Chat.Confirm.ReportSpam.Channel" = "Report Spam and Leave Channel?"; +"Chat.Confirm.ReportSpam.Group" = "Report Spam and Leave Group?"; +"Chat.Confirm.ReportSpam.Header" = "Report Spam"; +"Chat.Confirm.ReportSpamUser" = "Are you sure you want to report spam from this user?"; +"Chat.Confirm.Unpin" = "Would you like to unpin this message?"; +"Chat.Confirm.Unpin.Header" = "Unpin message"; +"Chat.Confirm.Unpin.OK" = "Unpin"; +"Chat.ConnectingStatus.connecting" = "Connecting"; +"Chat.ConnectingStatus.connectingToProxy" = "Connecting to proxy"; +"Chat.ConnectingStatus.updating" = "Updating"; +"Chat.ConnectingStatus.waitingNetwork" = "Waiting for network"; +"Chat.Contact.SendMessage" = "Send Message"; +"Chat.Context.AddFavoriteSticker" = "Add to Favorites"; +"Chat.Context.Archive" = "Archive"; +"Chat.Context.AutoDelete" = "Auto-Delete Messages"; +"Chat.Context.Ban" = "Ban"; +"Chat.Context.BlockGroup" = "Block Group"; +"Chat.Context.BlockGroup.Cancel" = "Cancel"; +"Chat.Context.BlockGroup.Header" = "Block Group"; +"Chat.Context.BlockGroup.Info" = "Do you want to block messages from %@?"; +"Chat.Context.BlockGroup.OK" = "Block"; +"Chat.Context.BlockGroup.Third" = "Report Spam"; +"Chat.Context.BlockUser" = "Block User"; +"Chat.Context.BlockUser.Cancel" = "Cancel"; +"Chat.Context.BlockUser.Header" = "Block User"; +"Chat.Context.BlockUser.Info" = "Do you want to block messages from %@?"; +"Chat.Context.BlockUser.OK" = "Block"; +"Chat.Context.BlockUser.Third" = "Report Spam"; +"Chat.Context.BusinessLink.EditName" = "Edit Link Name"; +"Chat.Context.CancelEditing" = "Cancel Editing"; +"Chat.Context.ClearHistory" = "Clear Chat History"; +"Chat.Context.ClearScheduled" = "Cancel All"; +"Chat.Context.ClearScheduled.Confirm.Header" = "Scheduled Messages"; +"Chat.Context.ClearScheduled.Confirm.Info" = "Are you sure you want to delete all scheduled messages?"; +"Chat.Context.ClearScheduled.Confirm.OK" = "Clear All"; +"Chat.Context.Copy" = "Copy"; +"Chat.Context.CopyBlock" = "Copy Preformatted Block"; +"Chat.Context.CopyMedia" = "Copy Media"; +"Chat.Context.CopyText" = "Copy Text"; +"Chat.Context.CreateGroup" = "Create Group"; +"Chat.Context.DeleteSaved" = "Are you sure you want to delete saved messages?"; +"Chat.Context.DeleteTopic" = "Are you sure you want to delete this topic?"; +"Chat.Context.DisableNotifications" = "Unmute"; +"Chat.Context.Edit1" = "Edit"; +"Chat.Context.EditHelp" = "click on date"; +"Chat.Context.EditTopic" = "Edit Topic"; +"Chat.Context.EditedAt" = "edited %@"; +"Chat.Context.EnableNotifications" = "Mute"; +"Chat.Context.FavoriteGifs.Limit.Info" = "An older GIF was replaced with this one. You can [increase the limit](premium) to %@ GIFs."; +"Chat.Context.FavoriteGifs.Limit.Title" = "The Limit of Saved GIFs Reached"; +"Chat.Context.FavoriteStickers.Limit.Info" = "An older sticker was replaced with this one. You can [increase the limit](premium) to %@ stickers."; +"Chat.Context.FavoriteStickers.Limit.Title" = "Limit of Favorite Stickers Reached"; +"Chat.Context.FeedInfo" = "Channels Info"; +"Chat.Context.Folders" = "Folders"; +"Chat.Context.Forward.More" = "More..."; +"Chat.Context.Gif.Added" = "GIF Saved"; +"Chat.Context.Gif.Removed" = "GIF Removed"; +"Chat.Context.HideAd" = "Hide Ad"; +"Chat.Context.Info" = "Info"; +"Chat.Context.Message.ContainsEmoji#one" = "This message contains\nemoji from [%d pack]()"; +"Chat.Context.Message.ContainsEmoji#other" = "This message contains\nemoji from [%d packs]()"; +"Chat.Context.Message.ContainsEmoji_countable" = "%d"; +"Chat.Context.MessageEffect.Add" = "Add an animation effect"; +"Chat.Context.Peer.Mention" = "Mention"; +"Chat.Context.Peer.OpenInfo" = "Open Profile"; +"Chat.Context.Peer.OpenStory" = "Open Stories"; +"Chat.Context.Peer.SendMessage" = "Send Message"; +"Chat.Context.Phone.NotTelegram" = "This number is not on Telegram"; +"Chat.Context.Pinned.Hide" = "Hide pinned message"; +"Chat.Context.Reacted" = "%1$@/%2$@ Reacted"; +"Chat.Context.ReactedFast#one" = "%d Reacted"; +"Chat.Context.ReactedFast#other" = "%d Reacted"; +"Chat.Context.ReactedFast_countable" = "%d"; +"Chat.Context.Reaction.Quick" = "Set As Quick"; +"Chat.Context.RemoveFavoriteSticker" = "Remove from Favorites"; +"Chat.Context.ReportFalsePositive" = "Report False Positive"; +"Chat.Context.ReportFalsePositive.Thanks" = "Telegram moderators will review your report. Thank you!"; +"Chat.Context.Restrict" = "Restrict"; +"Chat.Context.ReturnMessageFee" = "Return Messages Fee"; +"Chat.Context.SaveMedia" = "Save As..."; +"Chat.Context.SaveRingtone.Add" = "Save for Notifications"; +"Chat.Context.SaveRingtone.AddSuccess" = "Tone saved to your sound library"; +"Chat.Context.SaveRingtone.Remove" = "Remove Tone"; +"Chat.Context.SaveRingtone.RemoveSuccess" = "Tone successfully removed"; +"Chat.Context.Scheduled.Reschedule" = "Re-schedule"; +"Chat.Context.Scheduled.SendNow" = "Send Now"; +"Chat.Context.SharedMedia" = "Shared Media"; +"Chat.Context.Sticker.AddedToFavorites" = "Sticker Added to Favorites"; +"Chat.Context.Sticker.RemovedFromFavorites" = "Sticker Removed from Favorites"; +"Chat.Context.Summarize" = "Summarize"; +"Chat.Context.Todo.Check" = "Check"; +"Chat.Context.Todo.Delete" = "Delete Item"; +"Chat.Context.Todo.Delete.Confirm" = "Are you sure you want to delete this item?"; +"Chat.Context.Todo.Edit" = "Edit Item"; +"Chat.Context.Todo.TaskFallback" = "Task..."; +"Chat.Context.Todo.Uncheck" = "Uncheck"; +"Chat.Context.Translate" = "Translate"; +"Chat.Context.Unarchive" = "Unarchive"; +"Chat.Context.ViewEmojiSet" = "View Emoji Set"; +"Chat.Context.ViewEmojiSetNew#one" = "View Emoji Set"; +"Chat.Context.ViewEmojiSetNew#other" = "View Emoji Sets"; +"Chat.Context.ViewEmojiSetNew_countable" = "%d"; +"Chat.Context.ViewStatistics" = "View Statistics"; +"Chat.ContextMenu.AddTask" = "Add a Task"; +"Chat.ContextMenu.AutoDelete" = "Auto-Deletes in %@"; +"Chat.Copy.ProxyConfiguration" = "Copy Link to Proxy"; +"Chat.CopySelectedText" = "Copy Selected Text"; +"Chat.Date.ScheduledFor" = "Scheduled for %@"; +"Chat.Date.ScheduledForToday" = "Scheduled for today"; +"Chat.Date.ScheduledUntilOnline" = "Scheduled until online"; +"Chat.DeleteMessage.SuggestedPost.Action.DeleteAnyway" = "Delete Anyway"; +"Chat.DeleteMessage.SuggestedPost.Header.Stars" = "Stars Will Be Lost"; +"Chat.DeleteMessage.SuggestedPost.Header.Ton" = "Grams Will Be Lost"; +"Chat.DeleteMessage.SuggestedPost.Info.Stars" = "You won't receive Stars for this post if you delete it now. The post must remain visible for at least 24 hours after publication."; +"Chat.DeleteMessage.SuggestedPost.Info.Ton" = "You won't receive Grams for this post if you delete it now. The post must remain visible for at least 24 hours after publication."; +"Chat.Dice.Result" = "Send a 🎲 emoji to any chat to get a random number from Telegram."; +"Chat.Dice.ResultNew" = "Send a %@ emoji to any chat to get a random number from Telegram."; +"Chat.DisableAd.Tooltip" = "You will no longer see ads from Telegram."; +"Chat.Discussion.MessageDeleted" = "Sorry, this post has been removed from the discussion group."; +"Chat.DownloadLimit.Text#one" = "Subscribe to [Telegram Premium]() to increase download speeds %d time."; +"Chat.DownloadLimit.Text#other" = "Subscribe to [Telegram Premium]() to increase download speeds %d times."; +"Chat.DownloadLimit.Text_countable" = "%d"; +"Chat.DownloadLimit.Title" = "Download speed limited"; +"Chat.DropAsFilesDesc" = "without compression"; +"Chat.DropEditDesc" = "Edit Media"; +"Chat.DropEditTitle" = "Drop file here to edit media"; +"Chat.DropFolder.Desc" = "as an archive"; +"Chat.DropFolder.Title" = "Drop the folder here to send it"; +"Chat.DropQuickDesc" = "in a quick way"; +"Chat.DropTitle" = "Drop files here to send them"; +"Chat.Edit.Attach.Error" = "Sorry, you can't attach new media while editing a message."; +"Chat.Edit.Cancel.Text" = "Are you sure you want to discard all changes?"; +"Chat.EditMessage.Media" = "Click to edit Media"; +"Chat.Emoji.Dart.ResultNew" = "Send a dart emoji to try your luck."; +"Chat.Emoji.Def.ResultNew" = "Send a %@ emoji to try your luck."; +"Chat.Emoji.Dice.ResultNew" = "Send a dice emoji to roll a die."; +"Chat.Emoji.Send" = "Send"; +"Chat.Empty.Business.AwayMessage" = "New Away Message"; +"Chat.Empty.Business.AwayMessage.Info" = "Add messages that are automatically sent when you are off."; +"Chat.Empty.Business.GreetingMessage" = "New Greeting Message"; +"Chat.Empty.Business.GreetingMessage.Info" = "Create greetings that will be automatically sent to new customers."; +"Chat.Empty.Business.QuickReply" = "New Quick Reply"; +"Chat.Empty.Business.QuickReply.Info1" = "• Enter a message below that\nwill be sent in chats when you type **\"/%@\"**"; +"Chat.Empty.Business.QuickReply.Info2" = "• You can access Quick Replies\nin any chat by typing \"/\" or using the Attachment menu."; +"Chat.Empty.Comments" = "No comments here yet"; +"Chat.Empty.LinkPreview" = "Link Preview"; +"Chat.Empty.PaidMessage" = "%1$@ charges %2$@ for each message"; +"Chat.Empty.PremiumRequired.Action" = "Add Premium"; +"Chat.Empty.PremiumRequiredState" = "Subscribe to **Premium**\nto message **%@**."; +"Chat.Empty.Replies" = "No replies here yet"; +"Chat.Empty.searchHashtag" = "No Messages Found"; +"Chat.EmptyChat" = "No messages here yet"; +"Chat.EmptyChat.Info" = "Send a message or click on the greeting below"; +"Chat.File.QuickLook.Malf" = "Previewing this file can potentially expose your IP address to its sender or harm your device."; +"Chat.File.QuickLook.Svg" = "Previewing this file can potentially expose your IP address to its creator."; +"Chat.ForwardActionHeader" = "Forward Messages"; +"Chat.Gigagroup.Help" = "Only admins can send messages in this group."; +"Chat.Giveaway.DeleteConfirmation.Text" = "Deleting this message won't cancel the giveaway — the winners will still be selected on **%@**.\n\nOnce deleted, the Giveaway Announcement cannot be recovered."; +"Chat.Giveaway.DeleteConfirmation.Title" = "Do you want to delete the Giveaway Announcement?"; +"Chat.Giveaway.Info.ActivatedLinks#one" = "%d winner already used their gift link."; +"Chat.Giveaway.Info.ActivatedLinks#other" = "%d winners already used their gift link."; +"Chat.Giveaway.Info.ActivatedLinks_countable" = "%d"; +"Chat.Giveaway.Info.AlmostOver" = "The giveaway is almost over."; +"Chat.Giveaway.Info.DidntWin" = "You didn't win a prize in this giveaway."; +"Chat.Giveaway.Info.Ended" = "On **%1$@**, Telegram automatically selected %2$@ of **%3$@**."; +"Chat.Giveaway.Info.EndedIntro" = "The giveaway was sponsored by the admins of **%1$@**, who acquired %2$@ for %3$@ for its followers."; +"Chat.Giveaway.Info.EndedIntro.Stars" = "The giveaway was sponsored by the admins of **%1$@**, who acquired %2$@ for its followers."; +"Chat.Giveaway.Info.EndedMany" = "On **%1$@**, Telegram automatically selected %2$@ of **%3$@** and other listed channels."; +"Chat.Giveaway.Info.EndedNew" = "On **%1$@**, Telegram automatically selected %2$@ that joined **%3$@** after **%4$@**."; +"Chat.Giveaway.Info.EndedNewMany" = "On **%1$@**, Telegram automatically selected %2$@ that joined **%3$@** and other listed channels after **%4$@**."; +"Chat.Giveaway.Info.EndedTitle" = "Giveaway Ended"; +"Chat.Giveaway.Info.Months#one" = "**%d** month"; +"Chat.Giveaway.Info.Months#other" = "**%d** months"; +"Chat.Giveaway.Info.Months_countable" = "%d"; +"Chat.Giveaway.Info.NotAllowedAdmin" = "You are not eligible to participate in this giveaway because you are an admin of participating channel (**%@**)."; +"Chat.Giveaway.Info.NotAllowedCountry" = "You are not eligible to participate in this giveaway because your country is not included in the terms of the giveaway."; +"Chat.Giveaway.Info.NotAllowedJoinedEarly" = "You are not eligible to participate in this giveaway because you joined this channel on **%@**, which is before the contest started."; +"Chat.Giveaway.Info.NotQualified" = "To take part in this giveaway please join the channel **%1$@** before **%2$@**."; +"Chat.Giveaway.Info.NotQualifiedMany" = "To take part in this giveaway please join the channel **%1$@** (and %2$@) before **%3$@**."; +"Chat.Giveaway.Info.Ongoing" = "On **%1$@**, Telegram will automatically select %2$@ of **%3$@**."; +"Chat.Giveaway.Info.OngoingIntro" = "The giveaway is sponsored by the admins of **%1$@**, who acquired %2$@ for %3$@ for its followers."; +"Chat.Giveaway.Info.OngoingIntro.Stars" = "The giveaway is sponsored by the admins of **%1$@**, who acquired %2$@ for its followers."; +"Chat.Giveaway.Info.OngoingMany" = "On **%1$@**, Telegram will automatically select %2$@ of **%3$@** and %4$@."; +"Chat.Giveaway.Info.OngoingNew" = "On **%1$@**, Telegram will automatically select %2$@ that joined **%3$@** after **%4$@**."; +"Chat.Giveaway.Info.OngoingNewMany" = "On **%1$@**, Telegram will automatically select %2$@ that joined **%3$@** and %4$@ after **%5$@**."; +"Chat.Giveaway.Info.OtherChannels#one" = "**%d** other listed channel"; +"Chat.Giveaway.Info.OtherChannels#other" = "**%d** other listed channels"; +"Chat.Giveaway.Info.OtherChannels_countable" = "%d"; +"Chat.Giveaway.Info.Participating" = "You are participating in this giveaway because you joined the channel **%1$@**."; +"Chat.Giveaway.Info.ParticipatingMany" = "You are participating in this giveaway because you joined the channel **%1$@** (and %2$@)."; +"Chat.Giveaway.Info.RandomSubscribers#one" = "**%d** random subscriber"; +"Chat.Giveaway.Info.RandomSubscribers#other" = "**%d** random subscribers"; +"Chat.Giveaway.Info.RandomSubscribers_countable" = "%d"; +"Chat.Giveaway.Info.RandomUsers#one" = "**%d** random user"; +"Chat.Giveaway.Info.RandomUsers#other" = "**%d** random users"; +"Chat.Giveaway.Info.RandomUsers_countable" = "%d"; +"Chat.Giveaway.Info.Refunded" = "The channel canceled the prizes by refunding the payment for them."; +"Chat.Giveaway.Info.Subscriptions#one" = "**%d Telegram Premium** subscription"; +"Chat.Giveaway.Info.Subscriptions#other" = "**%d Telegram Premium** subscriptions"; +"Chat.Giveaway.Info.Subscriptions_countable" = "%d"; +"Chat.Giveaway.Info.Title" = "About This Giveaway"; +"Chat.Giveaway.Info.ViewPrize" = "View My Prize"; +"Chat.Giveaway.Info.Won" = "You won a prize in this giveaway. %@"; +"Chat.Giveaway.Message.CountriesDelimiter" = ", "; +"Chat.Giveaway.Message.CountriesFrom" = "from %@"; +"Chat.Giveaway.Message.CountriesLastDelimiter" = " and "; +"Chat.Giveaway.Message.DateTitle" = "Winners Selection Date"; +"Chat.Giveaway.Message.Months#one" = "**%d** month"; +"Chat.Giveaway.Message.Months#other" = "**%d** months"; +"Chat.Giveaway.Message.Months_countable" = "%d"; +"Chat.Giveaway.Message.Participants" = "All subscribers of this channel:"; +"Chat.Giveaway.Message.ParticipantsMany" = "All subscribers of the channels below:"; +"Chat.Giveaway.Message.ParticipantsNew" = "All users who join this channel after this announcement:"; +"Chat.Giveaway.Message.ParticipantsNewMany" = "All users who join the channels below after this announcement:"; +"Chat.Giveaway.Message.ParticipantsTitle" = "Participants"; +"Chat.Giveaway.Message.PrizeText" = "%1$@ for %2$@."; +"Chat.Giveaway.Message.PrizeTitle" = "Giveaway Prizes"; +"Chat.Giveaway.Message.StarWinners#one" = "among **%d** winner"; +"Chat.Giveaway.Message.StarWinners#other" = "among **%d** winners"; +"Chat.Giveaway.Message.StarWinners_countable" = "%d"; +"Chat.Giveaway.Message.Stars#one" = "**%d** Star"; +"Chat.Giveaway.Message.Stars#other" = "**%d** Stars"; +"Chat.Giveaway.Message.Stars.PrizeText" = "%1$@ will be distributed\n%2$@."; +"Chat.Giveaway.Message.Stars_countable" = "%d"; +"Chat.Giveaway.Message.Subscriptions#one" = "**%d** Telegram Premium Subscription"; +"Chat.Giveaway.Message.Subscriptions#other" = "**%d** Telegram Premium Subscriptions"; +"Chat.Giveaway.Message.Subscriptions_countable" = "%d"; +"Chat.Giveaway.Message.WinnersInfo#one" = "The winner received gift link in private messages."; +"Chat.Giveaway.Message.WinnersInfo#other" = "All winners received gift links in private messages."; +"Chat.Giveaway.Message.WinnersInfo.Star#one" = "The winner received a total of %2$@."; +"Chat.Giveaway.Message.WinnersInfo.Star#other" = "All winners received a total of %2$@."; +"Chat.Giveaway.Message.WinnersInfo.Star_countable" = "%d %@"; +"Chat.Giveaway.Message.WinnersInfo_countable" = "%d"; +"Chat.Giveaway.Message.WinnersSelectedText#one" = "**%d** winner of the [Giveaway]() was randomly selected by Telegram."; +"Chat.Giveaway.Message.WinnersSelectedText#other" = "**%d** winners of the [Giveaway]() were randomly selected by Telegram."; +"Chat.Giveaway.Message.WinnersSelectedText_countable" = "%d"; +"Chat.Giveaway.Message.WinnersSelectedTitle#one" = "Winner Selected!"; +"Chat.Giveaway.Message.WinnersSelectedTitle#other" = "Winners Selected!"; +"Chat.Giveaway.Message.WinnersSelectedTitle_countable" = "%d"; +"Chat.Group.Unaccessible" = "Sorry, this group is not accessible."; +"Chat.GroupCall.Join" = "JOIN"; +"Chat.GroupCall.Live.Title" = "Live Stream"; +"Chat.GroupCall.Members#one" = "%d participant"; +"Chat.GroupCall.Members#other" = "%d participants"; +"Chat.GroupCall.Members_ZeroValueHolder" = "Click to join"; +"Chat.GroupCall.Members_countable" = "%d"; +"Chat.GroupCall.Scheduled.Status" = "Starts %@"; +"Chat.GroupCall.Scheduled.Title" = "Scheduled Video Chat"; +"Chat.GroupCall.Speakers#one" = "%d participant speaking"; +"Chat.GroupCall.Speakers#other" = "%d participants speaking"; +"Chat.GroupCall.Speakers_ZeroValueHolder" = "nobody speaking"; +"Chat.GroupCall.Speakers_countable" = "%d"; +"Chat.GroupCall.Title" = "Video Chat"; +"Chat.Hashtag.MyMessages" = "My Messages"; +"Chat.Hashtag.PublicPosts" = "Public Posts"; +"Chat.Hashtag.ThisChat" = "This Chat"; +"Chat.Header.PinnedMessage" = "Pinned message"; +"Chat.Header.PinnedMessage_Numer" = "Pinned message #%d"; +"Chat.Header.PinnedPrevious" = "Previous message"; +"Chat.Header.RemoveFee" = "Remove Fee"; +"Chat.Header.RemoveFee.Confirm.Header" = "Remove Fee"; +"Chat.Header.RemoveFee.Confirm.Info" = "Are you sure you want to allow **%@** to message you for free?"; +"Chat.Header.RemoveFee.Confirm.OK" = "Yes"; +"Chat.Header.RemoveFee.Confirm.Option" = "Refund already paid **%@**?"; +"Chat.Header.RemoveFee.Monoforum.Confirm.Info" = "Are you sure you want to allow **%1$@** to message your channel for free?"; +"Chat.Header.RemoveFee.Monoforum.Text" = "%1$@ must pay %2$@ for each message to your channel."; +"Chat.Header.RemoveFee.Text" = "%1$@ must pay %2$@ for each message to you."; +"Chat.Header.ReportSpam" = "Report Spam"; +"Chat.Header.RequestToJoin#one" = "%d Join Request"; +"Chat.Header.RequestToJoin#other" = "%d Join Requests"; +"Chat.Header.RequestToJoin_countable" = "%d"; +"Chat.Header.VoiceChat.StartsIn" = "Starts in %@"; +"Chat.InlineRequest.Loading" = "Loading..."; +"Chat.Input.Accessory.EditMessage" = "Edit Message"; +"Chat.Input.Attach.QuickReply" = "Quick Reply"; +"Chat.Input.AutoDelete.1Day" = "Messages in this chat are automatically deleted 1 day after they have been sent."; +"Chat.Input.AutoDelete.7Days" = "Messages in this chat are automatically deleted 1 week after they have been sent."; +"Chat.Input.Close" = "Close"; +"Chat.Input.Delete" = "Delete and Leave"; +"Chat.Input.Discuss" = "Discuss"; +"Chat.Input.Error.MessageTooLong#one" = "Your message is too long to be saved. Please remove %d character."; +"Chat.Input.Error.MessageTooLong#other" = "Your message is too long to be saved. Please remove %d characters."; +"Chat.Input.Error.MessageTooLong_countable" = "%d"; +"Chat.Input.Forward.Hidden" = "You (senders' names hidden)"; +"Chat.Input.Join" = "Join"; +"Chat.Input.Mute" = "Mute"; +"Chat.Input.PremiumRequiredState" = "Only Premium users can message **%@**.\n[Learn More...](premium)"; +"Chat.Input.ReplyToAnswer" = "Reply to message in topics"; +"Chat.Input.ReplyToAnswer.Monoforum" = "Reply to message to answer"; +"Chat.Input.ReplyToAnswer.Monoforum_New" = "Select \"Reply\" in context menu to answer"; +"Chat.Input.Restart" = "Restart"; +"Chat.Input.Return" = "Return to the group"; +"Chat.Input.SecretChat.WaitingToOnline" = "Waiting for the other user to come online..."; +"Chat.Input.SecretChat.WaitingToUserOnline" = "Waiting for %@ to get online..."; +"Chat.Input.StartBot" = "Start"; +"Chat.Input.TopicClosed" = "The topic is closed"; +"Chat.Input.Unblock" = "Unblock user"; +"Chat.Input.Unmute" = "Unmute"; +"Chat.InputContext.EditQuickReply" = "Edit Quick Replies"; +"Chat.InstantView" = "INSTANT VIEW"; +"Chat.InviteRequestAdminChannel" = "**%1$@** is an admin of **%2$@**, a channel you requested to join."; +"Chat.InviteRequestAdminGroup" = "**%1$@** is an admin of **%2$@**, a group you requested to join."; +"Chat.InviteRequestInfo" = "You received this message because you requested to join %1$@ on %2$@."; +"Chat.JoinAuction" = "JOIN"; +"Chat.JoinGroupCall" = "JOIN CALL"; +"Chat.Link.Preview.Auction.Finished" = "finished"; +"Chat.Link.Preview.Auction.SoldOut" = "sold out"; +"Chat.List.Contact" = "Contact"; +"Chat.List.GIF" = "GIF"; +"Chat.List.InstantVideo" = "Video message"; +"Chat.List.Map" = "Location"; +"Chat.List.Photo1#one" = "Photo"; +"Chat.List.Photo1#other" = "%d Photos"; +"Chat.List.Photo1_countable" = "%d"; +"Chat.List.Photo_countable" = "%d"; +"Chat.List.Service.PaymentSent" = "Payment: %@"; +"Chat.List.Sticker" = "%@ Sticker"; +"Chat.List.Video" = "Video"; +"Chat.List.Video1#one" = "Video"; +"Chat.List.Video1#other" = "%d Videos"; +"Chat.List.Video1_countable" = "%d"; +"Chat.List.Video_countable" = "%d"; +"Chat.List.Voice" = "Voice Message"; +"Chat.LiveLocation" = "Live Location"; +"Chat.LiveLocation.Updated#one" = "Updated %d minute ago"; +"Chat.LiveLocation.Updated#other" = "Updated %d minutes ago"; +"Chat.LiveLocation.UpdatedNow" = "Updated just now"; +"Chat.LiveLocation.Updated_countable" = "%d"; +"Chat.ManageMessages.RestrictFully#one" = "[Fully ban this user](full)"; +"Chat.ManageMessages.RestrictFully#other" = "[Fully ban selected users](full)"; +"Chat.ManageMessages.RestrictFully_countable" = "%d"; +"Chat.ManageMessages.RestrictPartially#one" = "[Partially restrict this user](part)"; +"Chat.ManageMessages.RestrictPartially#other" = "[Partially restrict selected users](part)"; +"Chat.ManageMessages.RestrictPartially_countable" = "%d"; +"Chat.ManageMessages.What#one" = "WHAT CAN %d SELECTED USER DO?"; +"Chat.ManageMessages.What#other" = "WHAT CAN %d SELECTED USERS DO?"; +"Chat.ManageMessages.What_countable" = "%d"; +"Chat.Message.Ad.ReadMore" = "Read More"; +"Chat.Message.Ad.Text" = "Unlike other apps, Telegram never uses your private data to target ads. Sponsored messages on Telegram are based solely on the topic of the public channels in which they are shown. This means that no user data is mined or analyzed to display ads, and every user viewing a channel on Telegram sees the same sponsored messages.\n\nUnlike other apps, Telegram doesn't track whether you tapped on a sponsored message and doesn't profile you based on your activity. We also prevent external links in sponsored messages to ensure that third parties can’t spy on our users. We believe that everyone has the right to privacy, and technological platforms should respect that.\n\nTelegram offers a free and unlimited service to hundreds of millions of users, which involves significant server and traffic costs. In order to remain independent and stay true to its values, Telegram developed a paid tool to promote messages with user privacy in mind. We welcome responsible advertisers at:\n\n%@\n\nSponsored Messages are currently in test mode. Once they are fully launched and allow Telegram to cover its basic costs, we will start sharing ad revenue with the owners of public channels in which sponsored messages are displayed.\n\nOnline ads should no longer be synonymous with abuse of user privacy. Let us redefine how a tech company should operate – together."; +"Chat.Message.Ad.Url" = "https://ads.telegram.org"; +"Chat.Message.Context.Quote" = "Quote"; +"Chat.Message.Context.Quote.LimitExceed" = "Too Long."; +"Chat.Message.DeleteForAll" = "Delete for All"; +"Chat.Message.DeleteForEveryone" = "Delete for Everyone"; +"Chat.Message.DeleteForMe" = "Delete for Me"; +"Chat.Message.DeleteForMeAndPerson" = "Delete for Me and %@"; +"Chat.Message.Giveaway.LearnMore" = "LEARN MORE"; +"Chat.Message.Imported" = "%@ imported"; +"Chat.Message.Imported.Text" = "This message was imported from another app. We can't guarantee it's real."; +"Chat.Message.ImportedShort" = "imported"; +"Chat.Message.JoinVoiceChat.AsListener" = "JOIN AS LISTENER"; +"Chat.Message.JoinVoiceChat.AsSpeaker" = "JOIN AS SPEAKER"; +"Chat.Message.LaunchBot" = "Launch App"; +"Chat.Message.MTProxy.Config" = "MTProxy Configuration"; +"Chat.Message.OpenGiftLink" = "Open Gift Link"; +"Chat.Message.OpenGiftStars" = "Open"; +"Chat.Message.OpenLink" = "Open Link"; +"Chat.Message.OpenStory" = "VIEW STORY"; +"Chat.Message.ReadStats.EmptyListens" = "Nobody Listened"; +"Chat.Message.ReadStats.EmptyViews" = "Nobody Viewed"; +"Chat.Message.ReadStats.EmptyWatches" = "Nobody Listened"; +"Chat.Message.ReadStats.Listened#one" = "%d Listened"; +"Chat.Message.ReadStats.Listened#other" = "%d Listened"; +"Chat.Message.ReadStats.Listened_countable" = "%d"; +"Chat.Message.ReadStats.Seen#one" = "%d Seen"; +"Chat.Message.ReadStats.Seen#other" = "%d Seen"; +"Chat.Message.ReadStats.Seen_countable" = "%d"; +"Chat.Message.ReadStats.ShowDate" = "Show Read Date"; +"Chat.Message.ReadStats.Watched#one" = "%d Viewed"; +"Chat.Message.ReadStats.Watched#other" = "%d Viewed"; +"Chat.Message.ReadStats.Watched_countable" = "%d"; +"Chat.Message.Recommended" = "recommended"; +"Chat.Message.Repeat.1Min" = "1 min"; +"Chat.Message.Repeat.5Min" = "5 min"; +"Chat.Message.Repeat.Biweekly" = "biweekly"; +"Chat.Message.Repeat.Daily" = "daily"; +"Chat.Message.Repeat.Every3Months" = "every 3 months"; +"Chat.Message.Repeat.Every6Months" = "every 6 months"; +"Chat.Message.Repeat.Monthly" = "monthly"; +"Chat.Message.Repeat.Weekly" = "weekly"; +"Chat.Message.Repeat.Yearly" = "yearly"; +"Chat.Message.SendMessage" = "SEND MESSAGE"; +"Chat.Message.Socks5.Config" = "SOCKS5 Configuration"; +"Chat.Message.Sponsored" = "ad"; +"Chat.Message.Sponsored.Advertiser" = "Advertiser info"; +"Chat.Message.Sponsored.Link" = "https://ads.telegram.org"; +"Chat.Message.Sponsored.What" = "What are sponsored messages?"; +"Chat.Message.Suggest.Markup.Approve" = "Approve"; +"Chat.Message.Suggest.Markup.Decline" = "Decline"; +"Chat.Message.Suggest.Markup.Edit" = "Edit"; +"Chat.Message.Suggest.Markup.SuggestChanges" = "Suggest Changes"; +"Chat.Message.Todo.CompletePremium" = "Completing checklists is available to Premium users."; +"Chat.Message.Todo.EditForwardedError" = "You can't edit forwarded checklist."; +"Chat.Message.Todo.EditRestrictedError" = "%1$@ has restricted others from editing this checklist."; +"Chat.Message.Todo.Title.Group" = "Group Checklist"; +"Chat.Message.Todo.Title.Single" = "Checklist"; +"Chat.Message.Todo.Total" = "%1$@ of %2$@ completed"; +"Chat.Message.UnsendMessages" = "Unsend my messages"; +"Chat.Message.UnsendMessages_countable" = "%d"; +"Chat.Message.Unsupported" = "This message is not supported by your version of Telegram. Please update to the latest version from the AppStore or install it from https://macos.telegram.org"; +"Chat.Message.UnsupportedNew" = "This message is not supported in your version of Telegram. Please update to the latest version."; +"Chat.Message.Via" = "via"; +"Chat.Message.ViewBot" = "VIEW BOT"; +"Chat.Message.ViewChannel" = "VIEW CHANNEL"; +"Chat.Message.ViewGiftLink" = "View"; +"Chat.Message.ViewGroup" = "VIEW GROUP"; +"Chat.Message.edited" = "edited"; +"Chat.MessageAction.RequestToJoin" = "REQUEST TO JOIN"; +"Chat.MessageAction.ShowMessage" = "SHOW MESSAGE"; +"Chat.MonoforumUI.AllTab" = "All"; +"Chat.OffThreadMessage" = "Off-topic message"; +"Chat.Open.MessageNotExist" = "Message doesn't exist"; +"Chat.OwnerBadge" = "owner"; +"Chat.PayStars.Confirm.Checkbox" = "Don't ask again"; +"Chat.PayStars.Confirm.Medias#one" = "%d media"; +"Chat.PayStars.Confirm.Medias#other" = "%d medias"; +"Chat.PayStars.Confirm.Medias_countable" = "%d"; +"Chat.PayStars.Confirm.Messages#one" = "%d message"; +"Chat.PayStars.Confirm.Messages#other" = "%d messages"; +"Chat.PayStars.Confirm.Messages_countable" = "%d"; +"Chat.PayStars.Confirm.Pay#one" = "Pay for %d Message"; +"Chat.PayStars.Confirm.Pay#other" = "Pay for %d Messages"; +"Chat.PayStars.Confirm.Pay.Media#one" = "Pay for %d Media"; +"Chat.PayStars.Confirm.Pay.Media#other" = "Pay for %d Medias"; +"Chat.PayStars.Confirm.Pay.Media_countable" = "%d"; +"Chat.PayStars.Confirm.Pay_countable" = "%d"; +"Chat.PayStars.Confirm.Text" = "**%1$@** charges **%2$@** per incoming message. Would you like to pay **%3$@** to send **%4$@**?"; +"Chat.PayStars.Confirm.Title" = "Confirm Payment"; +"Chat.Pinned.DontShow" = "Don't Show Pinned Messages"; +"Chat.Pinned.UnpinAll#one" = "Unpin %d Message"; +"Chat.Pinned.UnpinAll#other" = "Unpin All %d Messages"; +"Chat.Pinned.UnpinAll_countable" = "%d"; +"Chat.Poll.Result" = "%@%"; +"Chat.Poll.Stop" = "Stop Poll"; +"Chat.Poll.Stop.Confirm.Header" = "Stop Poll?"; +"Chat.Poll.Stop.Confirm.Text" = "If you stop this poll now, nobody will be able to vote in it anymore. This action cannot be undone."; +"Chat.Poll.SubmitVote" = "Vote"; +"Chat.Poll.Tooltip.NoVotes" = "no votes"; +"Chat.Poll.Tooltip.Votes#one" = "%d vote"; +"Chat.Poll.Tooltip.Votes#other" = "%d votes"; +"Chat.Poll.Tooltip.Votes_countable" = "%d"; +"Chat.Poll.TotalVotes#one" = "%d user voted"; +"Chat.Poll.TotalVotes#other" = "%d users voted"; +"Chat.Poll.TotalVotes1#one" = "%d vote"; +"Chat.Poll.TotalVotes1#other" = "%d votes"; +"Chat.Poll.TotalVotes1_countable" = "%d"; +"Chat.Poll.TotalVotesEmpty" = "No votes yet"; +"Chat.Poll.TotalVotesResultEmpty" = "No votes"; +"Chat.Poll.TotalVotes_countable" = "%d"; +"Chat.Poll.Type.Anonymous" = "Anonymous Poll"; +"Chat.Poll.Type.AnonymousQuiz" = "Anonymous Quiz"; +"Chat.Poll.Type.Closed" = "Final Results"; +"Chat.Poll.Type.Public" = "Poll"; +"Chat.Poll.Type.Quiz" = "Quiz"; +"Chat.Poll.Unvote" = "Retract Vote"; +"Chat.Poll.ViewResults" = "View Results"; +"Chat.ProxySponsored.AlertHeader" = "Proxy Sponsor"; +"Chat.ProxySponsored.AlertSettings" = "Settings"; +"Chat.ProxySponsored.AlertText" = "This channel is shown by your proxy server. To remove this channel from your chat list, disable the proxy in Telegram Settings > Data and Storage."; +"Chat.ProxySponsored.CapDesc" = "This channel is shown by your proxy server"; +"Chat.ProxySponsored.CapTitle" = "Proxy Sponsor"; +"Chat.QuickReplyMessageLimitReachedText#one" = "Limit of %d message reached"; +"Chat.QuickReplyMessageLimitReachedText#other" = "Limit of %d messages reached"; +"Chat.QuickReplyMessageLimitReachedText_countable" = "%d"; +"Chat.Quiz.Stop" = "Stop Quiz"; +"Chat.Quiz.Stop.Confirm.Header" = "Stop Quiz?"; +"Chat.Quiz.Stop.Confirm.Text" = "If you stop this quiz now, nobody will be able to answer in it anymore. This action cannot be undone."; +"Chat.Quiz.TextType" = "Quiz"; +"Chat.Quiz.Tooltip.NoVotes" = "no answers"; +"Chat.Quiz.Tooltip.Votes#one" = "%d answer"; +"Chat.Quiz.Tooltip.Votes#other" = "%d answers"; +"Chat.Quiz.Tooltip.Votes_countable" = "%d"; +"Chat.Quiz.TotalVotes#one" = "%d answer"; +"Chat.Quiz.TotalVotes#other" = "%d answers"; +"Chat.Quiz.TotalVotesEmpty" = "No answers yet"; +"Chat.Quiz.TotalVotesResultEmpty" = "No answers"; +"Chat.Quiz.TotalVotes_countable" = "%d"; +"Chat.Reaction.Context.AddLabel" = "Add Name"; +"Chat.Reaction.Context.EditTag" = "Edit Name"; +"Chat.Reaction.Context.EditTag.Info" = "You can label your emoji tag with a text name."; +"Chat.Reaction.Context.FilterByTag" = "Filter by Tag"; +"Chat.Reaction.Context.RemoveTag" = "Remove Tag"; +"Chat.Reaction.EditTag.Placeholder" = "Label..."; +"Chat.Recording.Cancel" = "Are you sure you want to stop recording and discard\nyour voice message?"; +"Chat.Replies.Desc" = "This chat helps you keep track of replies to your comments in Channels."; +"Chat.Reply.ExpiredStory" = "Expired Story"; +"Chat.Reply.ExpiredStory.Error" = "This story is no longer available."; +"Chat.ReplyMarkup.PayXtr" = "Pay %@"; +"Chat.ReplyMessagePanelTitle" = "Reply to %@"; +"Chat.ReplyQuotePanelTitle" = "Reply to quote from %@"; +"Chat.Right.Context.Reminder" = "Reminder"; +"Chat.Right.Context.ScheduledMessages" = "Scheduled Messages"; +"Chat.SavedMessages.ViewAsChats" = "View as Chats"; +"Chat.SavedMessages.ViewAsMessages" = "View as Messages"; +"Chat.Scheduled.InlineButton.Error" = "The buttons will become active as soon as the message is sent."; +"Chat.SearchCount" = "%d of %d"; +"Chat.SearchFrom" = "from:"; +"Chat.SecretChat.1Feature" = " - Use end-to-end encryption"; +"Chat.SecretChat.2Feature" = " - Leave no trace on our servers"; +"Chat.SecretChat.3Feature" = " - Have a self-destruct timer"; +"Chat.SecretChat.4Feature" = " - Do not allow forwarding"; +"Chat.SecretChat.EmptyHeader" = "Secret chats:"; +"Chat.SecretChat.PremiumRequired" = "%@ only accepts secret chats from contacts and Premium users."; +"Chat.SecretChat.Preview.Header" = "Secret Chat"; +"Chat.SecretChat.Preview.NO" = "NO"; +"Chat.SecretChat.Preview.OK" = "YES"; +"Chat.SecretChat.Preview.Text" = "Would you like to enable extended link previews in Secret Chats? Note that link previews are generated on Telegram servers."; +"Chat.Send.AddToCalendar" = "Add to Calendar"; +"Chat.Send.EventAddedToCalendar" = "Event added to Calendar"; +"Chat.Send.ScheduledMessage" = "Schedule Message"; +"Chat.Send.SendWhenOnline" = "Send When Online"; +"Chat.Send.SetReminder" = "Set a Reminder"; +"Chat.Send.WithoutSound" = "Send Without Sound"; +"Chat.SendAs.Channel#one" = "%d subscriber"; +"Chat.SendAs.Channel#other" = "%d subscribers"; +"Chat.SendAs.Channel_countable" = "%d"; +"Chat.SendAs.Group#one" = "%d member"; +"Chat.SendAs.Group#other" = "%d members"; +"Chat.SendAs.Group_countable" = "%d"; +"Chat.SendAs.Header" = "SEND MESSAGE AS..."; +"Chat.SendAs.PersonalAccount" = "personal account"; +"Chat.SendJoinRequest.Info" = "You will be able to send messages once the admins approve your request."; +"Chat.SendJoinRequest.Title" = "Request to join sent"; +"Chat.SendMessage.Error.TooFast" = "You are sending messages too fast. Please wait a bit."; +"Chat.SendMessageError.TooMuchScheduled" = "Sorry, you can only send 100 scheduled messages."; +"Chat.SendMessageErrorFlood" = "Sorry, you can only send messages to mutual contacts at the moment."; +"Chat.SendMessageErrorGroupRestricted" = "Sorry, you are currently restricted from posting in this group."; +"Chat.SendMessageErrorNonPremiumForbidden" = "Only Premium users can message %@"; +"Chat.SendMessageSlowmodeError" = "Slow Mode is active."; +"Chat.SendVoice.PrivacyError" = "%@ doesn't accept voice and video messages"; +"Chat.SensitiveContent" = "18+"; +"Chat.SensitiveContent.Confirm" = "This media may contain sensitive content suitable only for adults. Do you still want to view it?"; +"Chat.SensitiveContent.Confirm.Ok" = "View Anyway"; +"Chat.SensitiveContent.Confirm.Third" = "Always show 18+ media"; +"Chat.Service.Birthday.SuggestedToOther" = "You suggested %@ to add a birthday"; +"Chat.Service.Birthday.SuggestedToYou" = "%@ suggested you add your birthday"; +"Chat.Service.BirthdayAddToProfile" = "Add to My Profile"; +"Chat.Service.BirthdayHideYear" = "Hide the Year"; +"Chat.Service.BotPermissionAllowed" = "You allowed this bot to message you when you logged in on %@"; +"Chat.Service.BotWriteAllowed" = "You allowed this bot to message you when you added it to your attachment menu."; +"Chat.Service.Call.Cancelled" = "Canceled"; +"Chat.Service.Call.Missed" = "Missed"; +"Chat.Service.CancelledGroupCall" = "Cancelled Group Call"; +"Chat.Service.Channel.DisabledTimer" = "Messages in this channel will no longer be automatically deleted"; +"Chat.Service.Channel.RemovedPhoto" = "Channel photo removed"; +"Chat.Service.Channel.SetTimer" = "Messages in this channel will be automatically deleted after %@"; +"Chat.Service.Channel.UpdatedPhoto" = "Channel photo updated"; +"Chat.Service.Channel.UpdatedTitle" = "Channel renamed to \"%@\""; +"Chat.Service.Channel.UpdatedVideo" = "Channel photo updated"; +"Chat.Service.DeclinedGroupCall" = "Declined Group Call"; +"Chat.Service.DisabledTheme" = "%1$@ disabled the chat theme"; +"Chat.Service.DisabledTheme.You" = "You disabled the chat theme"; +"Chat.Service.Gift.Burned" = "%1$@ burned the gift by crafting new unique collectible"; +"Chat.Service.Gift.BurnedHeader" = "Burned Gift"; +"Chat.Service.Gift.Ribbon.Burned" = "burned"; +"Chat.Service.Gift.Ribbon.Gift" = "gift"; +"Chat.Service.Gift.Ribbon.Sold" = "sold"; +"Chat.Service.Gift.View" = "View"; +"Chat.Service.GiftLink" = "You received a gift"; +"Chat.Service.GiftLink.Sent" = "You sent a gift"; +"Chat.Service.GiftUnpack.Incoming" = "Click \"Unpack\" to turn this gift into a unique collectible."; +"Chat.Service.GiftUnpack.Outgoing" = "%@ can now turn this gift into a unique collectible."; +"Chat.Service.GiveawayStarted" = "%1$@ just started a giveaway of Telegram Premium subscriptions for its followers."; +"Chat.Service.GiveawayStarted.Stars#one" = "%1$@ just started a giveaway of %2$d Telegram Star for its followers."; +"Chat.Service.GiveawayStarted.Stars#other" = "%1$@ just started a giveaway of %2$d Telegram Stars for its followers."; +"Chat.Service.GiveawayStarted.Stars_countable" = "%1$@ %2$d"; +"Chat.Service.Group.AddedMembers" = "%@ added %@"; +"Chat.Service.Group.AddedMembers1" = "%1$@ invited %2$@"; +"Chat.Service.Group.AddedSelf" = "%@ joined the group"; +"Chat.Service.Group.Created" = "%@ created the group \"%@\""; +"Chat.Service.Group.Created1" = "%1$@ created the group \"%2$@\""; +"Chat.Service.Group.DisabledTimer" = "A group admin disabled the auto-delete timer"; +"Chat.Service.Group.JoinedByLink" = "%@ joined the group via invite link"; +"Chat.Service.Group.MigratedToSupergroup" = "This group was upgraded to a supergroup"; +"Chat.Service.Group.RemovedMembers" = "%@ removed %@"; +"Chat.Service.Group.RemovedMembers1" = "%1$@ removed %2$@"; +"Chat.Service.Group.RemovedPhoto" = "%@ removed group photo"; +"Chat.Service.Group.RemovedSelf" = "%@ left the group"; +"Chat.Service.Group.SetTimer" = "A group admin set messages to auto-delete after %@"; +"Chat.Service.Group.TookScreenshot" = "%@ took a screenshot!"; +"Chat.Service.Group.TopicCreated" = "The topic \"%2$@\" was created"; +"Chat.Service.Group.TopicCreated.Icon" = "%1$@ \"%2$@\" was created"; +"Chat.Service.Group.TopicEdited.Hided" = "%1$@ hid the general topic"; +"Chat.Service.Group.TopicEdited.Icon" = "%1$@ changed topic icon to %2$@"; +"Chat.Service.Group.TopicEdited.Icon.Removed" = "%1$@ removed the icon"; +"Chat.Service.Group.TopicEdited.Mixed" = "%1$@ renamed topic to \"%3$@\" and changed icon to %2$@"; +"Chat.Service.Group.TopicEdited.Paused" = "%1$@ closed the topic"; +"Chat.Service.Group.TopicEdited.Resumed" = "%1$@ reopened the topic"; +"Chat.Service.Group.TopicEdited.Title" = "%1$@ changed the topic name to \"%2$@\""; +"Chat.Service.Group.TopicEdited.Unhided" = "%1$@ unhid the general topic"; +"Chat.Service.Group.TopicEdited.You.Hided" = "You hid the general topic"; +"Chat.Service.Group.TopicEdited.You.Icon" = "You changed topic icon to %1$@"; +"Chat.Service.Group.TopicEdited.You.Icon.Removed" = "You removed the icon"; +"Chat.Service.Group.TopicEdited.You.Mixed" = "You changed topic icon to %1$@ and name to \"%2$@\""; +"Chat.Service.Group.TopicEdited.You.Paused" = "You closed the topic"; +"Chat.Service.Group.TopicEdited.You.Resumed" = "You reopened the topic"; +"Chat.Service.Group.TopicEdited.You.Title" = "You changed topic name to \"%1$@\""; +"Chat.Service.Group.TopicEdited.You.Unhided" = "You unhid the general topic"; +"Chat.Service.Group.UpdatedPhoto" = "%@ updated group photo"; +"Chat.Service.Group.UpdatedPinnedMessage" = "%@ pinned \"%@\""; +"Chat.Service.Group.UpdatedPinnedMessage1" = "%1$@ pinned \"%2$@\""; +"Chat.Service.Group.UpdatedTitle" = "%@ changed the group name to \"%@\""; +"Chat.Service.Group.UpdatedTitle1" = "%1$@ changed the group name to \"%2$@\""; +"Chat.Service.Group.UpdatedVideo" = "%@ updated the group photo"; +"Chat.Service.GroupsInCommon#one" = "%d group >"; +"Chat.Service.GroupsInCommon#other" = "%d groups >"; +"Chat.Service.GroupsInCommon_ZeroValueHolder" = "no groups"; +"Chat.Service.GroupsInCommon_countable" = "%d"; +"Chat.Service.IncomingGroupCall" = "Incoming Group Call"; +"Chat.Service.MissedGroupCall" = "Missed Group Call"; +"Chat.Service.NotOfficial" = "Not an official account"; +"Chat.Service.Offer.Accept" = "Accept"; +"Chat.Service.Offer.Accepted" = "This offer was accepted."; +"Chat.Service.Offer.BoughtGift" = "You bought **%1$@** from %2$@ for %3$@"; +"Chat.Service.Offer.ConfirmSale" = "Do you want to sell **%1$@** to **%2$@** for **%3$@**? You'll receive **%4$@** after fees."; +"Chat.Service.Offer.ConfirmSaleOk" = "Confirm Sale"; +"Chat.Service.Offer.Expired" = "This offer has expired."; +"Chat.Service.Offer.ExpiredOther" = "**%1$@** didn't respond to your offer for **%2$@** - your %3$@ have been refunded."; +"Chat.Service.Offer.ExpiredYou" = "The offer from **%1$@** to buy your **%2$@** for %3$@ has expired."; +"Chat.Service.Offer.ExpiresIn" = "This offer expires in %@."; +"Chat.Service.Offer.ReceivedOffer" = "%1$@ offered you **%2$@** for your gift **%3$@**."; +"Chat.Service.Offer.Reject" = "Reject"; +"Chat.Service.Offer.RejectConfirm" = "Are you sure you want to reject this offer?"; +"Chat.Service.Offer.Rejected" = "This offer was rejected."; +"Chat.Service.Offer.RejectedOther" = "**%1$@** rejected your offer for **%2$@** — your %3$@ have been refunded."; +"Chat.Service.Offer.RejectedYou" = "The offer from **%1$@** to buy your **%2$@** for %3$@ has been rejected."; +"Chat.Service.Offer.SentOffer" = "You offered **%1$@** for **%2$@'s** gift **%3$@**."; +"Chat.Service.Offer.SoldGift" = "You sold **%1$@** to %2$@ for %3$@"; +"Chat.Service.OutgoingGroupCall" = "Outgoing Group Call"; +"Chat.Service.PaymentSent" = "You successfully transferred **%@** to **%@** for **%@**"; +"Chat.Service.PaymentSent1" = "You have successfully transferred **%1$@** to **%2$@** for **%3$@**"; +"Chat.Service.PaymentSent1NoTitle" = "You have successfully transferred **%1$@** to **%2$@**"; +"Chat.Service.PaymentSentRecurringInit" = "You successfully transferred **%1$@** to **%2$@** for **%3$@** and allowed future recurring payments"; +"Chat.Service.PaymentSentRecurringInitNoTitle" = "You successfully transferred **%1$@** to **%2$@** and allowed future recurring payments"; +"Chat.Service.PaymentSentRecurringUsed" = "You have just successfully transferred **%1$@** to **%2$@** for **%3$@** via recurrent payments"; +"Chat.Service.PaymentSentRecurringUsedNoTitle" = "You have just successfully transferred **%1$@** to **%2$@** via recurrent payments"; +"Chat.Service.PeerJoinedTelegram" = "%@ joined Telegram"; +"Chat.Service.PeerRequest.Confirm.Ok" = "Send"; +"Chat.Service.PeerRequest.Confirm.Permission" = "Are you sure you want to send %1$@ to %2$@? This will also add %3$@ to %4$@ with the following rights: %5$@"; +"Chat.Service.PeerRequest.Confirm.Plain" = "Are you sure you want to send %1$@ to %2$@?"; +"Chat.Service.PeerRequested" = "You shared %1$@ with %2$@."; +"Chat.Service.PeerRequested.Multiple" = "You shared %1$@ with %2$@."; +"Chat.Service.PinnedMessage" = "pinned message"; +"Chat.Service.Premium.Unknown" = "Unknown user"; +"Chat.Service.PremiumGift.Info#one" = "**Telegram Premium**\nfor %d month"; +"Chat.Service.PremiumGift.Info#other" = "**Telegram Premium**\nfor %d months"; +"Chat.Service.PremiumGift.Info_countable" = "%d"; +"Chat.Service.PremiumGift.Sent" = "%1$@ sent you a gift for %2$@"; +"Chat.Service.PremiumGift.Sent.Channel" = "%1$@ sent a gift to %2$@ for %3$@"; +"Chat.Service.PremiumGift.SentYou" = "You sent a gift for %@"; +"Chat.Service.Refund" = "%@ refunded %@"; +"Chat.Service.RefundedBack#one" = "%@ refunded back %@%d"; +"Chat.Service.RefundedBack#other" = "%@ refunded back %@%d"; +"Chat.Service.RefundedBack_countable" = "%@ %@%d"; +"Chat.Service.Resale" = "%1$@ bought **%2$@** for **%3$@** and gifted it to you"; +"Chat.Service.Resale.You" = "You bought **%1$@** for **%2$@** and gifted it to %3$@"; +"Chat.Service.Resale.You.Self" = "You bought **%1$@** for **%2$@**"; +"Chat.Service.SearchAllMessages" = "Search messages by %@"; +"Chat.Service.SecretChat.DisabledTimer" = "%@ disabled the self-destruct timer"; +"Chat.Service.SecretChat.DisabledTimer.Self" = "You disabled the self-destruct timer"; +"Chat.Service.SecretChat.DisabledTimer.Self1" = "You disabled the auto-delete timer"; +"Chat.Service.SecretChat.DisabledTimer1" = "%@ disabled the auto-delete timer"; +"Chat.Service.SecretChat.SetTimer" = "%@ set the self-destruct timer to %@"; +"Chat.Service.SecretChat.SetTimer.Self" = "You set the self-destruct timer to %@"; +"Chat.Service.SecretChat.SetTimer.Self1" = "You set messages to auto-delete after %@"; +"Chat.Service.SecretChat.SetTimer1" = "%@ set messages to auto-delete after %@"; +"Chat.Service.SecureId.AccessGranted" = "%@ received the following documents: %@"; +"Chat.Service.SendAnother" = "Send Another Gift"; +"Chat.Service.SendGift" = "Send Gift to %@"; +"Chat.Service.SetTheme.Button.View" = "View"; +"Chat.Service.StarGift.ChannelSelt" = "%1$@ sent a gift to %2$@ for %3$@"; +"Chat.Service.StarGift.For" = "Gift for %@"; +"Chat.Service.StarGift.From" = "Gift from %@"; +"Chat.Service.StarGift.Sent" = "%1$@ sent you a gift for %2$@"; +"Chat.Service.StarGift.SentYou" = "You sent a gift for %@"; +"Chat.Service.StoryExpiredMentionTextIncoming" = "The story you were mentioned in\nis no longer available"; +"Chat.Service.StoryExpiredMentionTextOutgoing" = "The story where you mentioned %@\n is no longer available"; +"Chat.Service.StoryMentioned" = "%@ mentioned you in a story"; +"Chat.Service.StoryMentioned.You" = "You mentioned %@ in a story"; +"Chat.Service.SuggestPhoto.Info" = "%@ suggests this photo for your Telegram profile."; +"Chat.Service.SuggestPhoto.Info.You" = "You suggested %@ to use this photo for their Telegram profile."; +"Chat.Service.SuggestPhoto.Title" = "Suggested Photo"; +"Chat.Service.SuggestPost.Header.Label.Price" = "Price"; +"Chat.Service.SuggestPost.Header.Label.Time" = "Time"; +"Chat.Service.SuggestPost.Header.Price.Free" = "Free"; +"Chat.Service.SuggestPost.Header.Time.Anytime" = "Anytime"; +"Chat.Service.SuggestPost.Header.Title" = "You suggest to post this message."; +"Chat.Service.SuggestPost.Header.Title.You" = "You suggest to post this message."; +"Chat.Service.SuggestPost.Refunded.Deleted" = "Suggested post was refunded because the message was deleted"; +"Chat.Service.SuggestPost.Refunded.Other" = "Suggested post was refunded because the user didn't have enough funds"; +"Chat.Service.SuggestPost.Refunded.User" = "Suggested post was refunded due to insufficient balance"; +"Chat.Service.SuggestPost.Status.Approved" = "🤝 Post approved."; +"Chat.Service.SuggestPost.Status.Charge.Incoming" = "💰 You have been charged %1$@."; +"Chat.Service.SuggestPost.Status.Charge.Outgoing" = "💰 The user has been charged %1$@."; +"Chat.Service.SuggestPost.Status.Publish.Incoming" = "📅 Your post will be automatically published on %1$@ %2$@."; +"Chat.Service.SuggestPost.Status.Publish.Outgoing" = "📅 The post will be automatically published on %1$@ %2$@."; +"Chat.Service.SuggestPost.Status.RefundCondition" = "🔁 If %1$@ removes the post before it has been live for 24 hours, your Stars will be refunded."; +"Chat.Service.SuggestPost.Status.Rejected" = "❌ Post rejected."; +"Chat.Service.SuggestPost.Status.Rejected.Incoming.LowBalance" = "**%1$@** was unable to post your message, because you did not have enough Stars."; +"Chat.Service.SuggestPost.Status.Rejected.Incoming.NoComment" = "**%1$@** rejected your post."; +"Chat.Service.SuggestPost.Status.Rejected.Incoming.WithComment" = "**%1$@** rejected your message with the comment:\n\n%2$@"; +"Chat.Service.SuggestPost.Status.Rejected.LowBalanceTitle" = "⚠️ Transaction failed"; +"Chat.Service.SuggestPost.Status.Rejected.Outgoing.LowBalance" = "**%1$@** was unable to post the message, because the user did not have enough Stars."; +"Chat.Service.SuggestPost.Status.Rejected.Outgoing.NoComment" = "You rejected the post."; +"Chat.Service.SuggestPost.Status.Rejected.Outgoing.WithComment" = "You rejected the post with the following comment:\n\n%1$@"; +"Chat.Service.SuggestPost.Status.Rejected.Title" = "❌ Rejected"; +"Chat.Service.SuggestPost.Status.Release.Incoming" = "⌛️ %1$@ will receive your Stars once the post has been live for 24 hours."; +"Chat.Service.SuggestPost.Status.Release.Outgoing" = "⌛️ %1$@ will receive Stars once the post has been live for 24 hours."; +"Chat.Service.SuggestPost.Status.Title" = "🤝 Agreement reached!"; +"Chat.Service.SuggestPost.Success.Received" = "**%1$@** received **%2$@** for publishing this post"; +"Chat.Service.SuggestedPhoto" = "%@ suggests this photo for your Telegram profile"; +"Chat.Service.Todo.Added" = "{author} added a new task {values} to {task}."; +"Chat.Service.Todo.Added.Multi" = "{author} added new tasks {values} to {task}."; +"Chat.Service.Todo.Added.Multi.You" = "You added new tasks {values} to {task}."; +"Chat.Service.Todo.Added.You" = "You added a new task {values} to {task}."; +"Chat.Service.Todo.Complete.Premium" = "Subscribe to Telegram Premium to unlock Checklists. [Read More]()"; +"Chat.Service.Todo.Marked.Done" = "{author} marked {values} as done."; +"Chat.Service.Todo.Marked.Done.You" = "You marked {values} as done."; +"Chat.Service.Todo.Marked.Undone" = "{author} marked {values} as not done."; +"Chat.Service.Todo.Marked.Undone.You" = "You marked {values} as not done."; +"Chat.Service.TonGift.Info" = "Use Grams to buy gifts and suggest posts to channels."; +"Chat.Service.UpdateTheme" = "%1$@ changed the chat theme to %2$@"; +"Chat.Service.UpdateTheme.You" = "You changed the chat theme to %@"; +"Chat.Service.VoiceChatFinished" = "%1$@ ended the video chat (%2$@)"; +"Chat.Service.VoiceChatFinished.Channel" = "Live Stream ended (%1$@)"; +"Chat.Service.VoiceChatFinished.Channel1" = "Live stream ended (%1$@)"; +"Chat.Service.VoiceChatFinishedYou" = "You ended the video chat (%@)"; +"Chat.Service.VoiceChatInvitation" = "%1$@ invited %2$@ to the [video chat](open)"; +"Chat.Service.VoiceChatInvitationByYou" = "You invited %1$@ to the [video chat](open)"; +"Chat.Service.VoiceChatInvitationForYou" = "%1$@ invited you to the [video chat](open)"; +"Chat.Service.VoiceChatScheduled" = "%1$@ scheduled a [video chat](open) for %2$@"; +"Chat.Service.VoiceChatScheduled.Channel" = "[Video Chat](open) scheduled for %@"; +"Chat.Service.VoiceChatScheduled.Channel1" = "[Live Stream](open) scheduled for %@"; +"Chat.Service.VoiceChatScheduledYou" = "You scheduled a [video chat](open) for %1$@"; +"Chat.Service.VoiceChatStarted" = "%1$@ started a [video chat](open)"; +"Chat.Service.VoiceChatStarted.Channel" = "[Live Stream](open) started"; +"Chat.Service.VoiceChatStarted.Channel1" = "[Live stream](open) started"; +"Chat.Service.VoiceChatStartedYou" = "You started a [video chat](open)"; +"Chat.Service.WebData" = "Data from the \"%@\" button was transferred to the bot."; +"Chat.Service.You" = "You"; +"Chat.Service.YouSuggestedPhoto" = "You suggested a profile photo"; +"Chat.ShareInlineResultActionHeader" = "Share"; +"Chat.StarsGift.Button.Unpack" = "Unpack"; +"Chat.StarsGift.SentBy" = "%@ sent %@"; +"Chat.StarsGift.SentInChannel" = "%@ sent %@ to %@"; +"Chat.StarsGift.SentYou" = "%@ gift sent"; +"Chat.StarsGift.Upgrade.Other" = "**%@** sent an upgrade worth **%@** for the gift you received from **%@**"; +"Chat.StarsGift.Upgrade.Self" = "**%@** sent an upgrade worth **%@** for the gift"; +"Chat.StarsGift.UpgradeSent" = "You sent an upgrade worth %@ for the gift"; +"Chat.StarsGift.UpgradeSentBy" = "%@ sent an upgrade worth %@ for the gift"; +"Chat.State.HideOtherMessages" = "Hide Other Messages"; +"Chat.State.ShowOtherMessages" = "Show Other Messages"; +"Chat.SuggestPost.Publish.AcceptTitle" = "Accept Terms"; +"Chat.SuggestPost.Publish.ActionNow" = "Post Now"; +"Chat.SuggestPost.Publish.ConfirmButton" = "Publish"; +"Chat.SuggestPost.Publish.ConfirmHeader" = "Accept Terms"; +"Chat.SuggestPost.Publish.ConfirmInfo" = "Do you really want to publish this post from %1$@? You will receive %2$@ (%3$@) for publishing this post. It must remain visible for at least 24 hours after publication."; +"Chat.SuggestPost.Publish.ConfirmInfoFree" = "Do you really want to publish this post from %1$@?"; +"Chat.SuggestPost.Publish.DateConfirm" = "Post at %1$@"; +"Chat.SuggestPost.Publish.Info" = "You will receive %1$@ (%2$@) for publishing this post."; +"Chat.SuggestPost.Publish.InfoFree" = "Select date and time to publish this post."; +"Chat.Theme.OtherSet" = "%1$@ set %2$@ as a new theme for this chat."; +"Chat.Theme.SelfSet" = "You set %1$@ as a new theme for this chat."; +"Chat.Title.Chats#one" = "%d Chat"; +"Chat.Title.Chats#other" = "%d Chats"; +"Chat.Title.Chats_countable" = "%d"; +"Chat.Title.Comments#one" = "%d Comment"; +"Chat.Title.Comments#other" = "%d Comments"; +"Chat.Title.Comments_ZeroValueHolder" = "Comments"; +"Chat.Title.Comments_countable" = "%d"; +"Chat.Title.Discussion" = "Discussion"; +"Chat.Title.PinnedMessages#one" = "%d Pinned Message"; +"Chat.Title.PinnedMessages#other" = "%d Pinned Messages"; +"Chat.Title.PinnedMessages_countable" = "%d"; +"Chat.Title.Reminder" = "Reminder"; +"Chat.Title.Replies#one" = "%d Reply"; +"Chat.Title.Replies#other" = "%d Replies"; +"Chat.Title.Replies_ZeroValueHolder" = "Replies"; +"Chat.Title.Replies_countable" = "%d"; +"Chat.Title.ScheduledMessages" = "Scheduled Messages"; +"Chat.Title.Topic#one" = "%d Message"; +"Chat.Title.Topic#other" = "%d Messages"; +"Chat.Title.Topic_ZeroValueHolder" = "Messages"; +"Chat.Title.Topic_countable" = "%d"; +"Chat.Title.self" = "Your cloud storage"; +"Chat.TitleFeed" = "Feed"; +"Chat.Toast.ReportSuccess" = "Telegram moderators will review your report. Thank you!"; +"Chat.ToastQuoteChatUnavailbalePrivateChannel" = "This quote is from a private channel"; +"Chat.ToastQuoteChatUnavailbalePrivateChat" = "This quote is from a private chat"; +"Chat.ToastQuoteChatUnavailbalePrivateGroup" = "This quote is from a private group"; +"Chat.Tooltip.HiddenForwardName" = "The account was hidden by the user"; +"Chat.TopicBadge" = "topic creator"; +"Chat.TopicHeader.Empty" = "No messages here yet..."; +"Chat.TopicHeader.Full" = "Topic started"; +"Chat.Translate.Menu.DoNotTranslate" = "Do Not Translate %@"; +"Chat.Translate.Menu.Hide" = "Hide"; +"Chat.Translate.Menu.Hide.Bot.Tooltip" = "Translation bar hidden.\nYou can re-enable it in bot profile."; +"Chat.Translate.Menu.Hide.Channel.Tooltip" = "Translation bar is now hidden for this channel. You can enable it in profile."; +"Chat.Translate.Menu.Hide.Group.Tooltip" = "Translation bar hidden.\nYou can re-enable it in group info."; +"Chat.Translate.Menu.Hide.Tooltip" = "Translation bar hidden.\nYou can re-enable it in channel info."; +"Chat.Translate.Menu.Hide.User.Tooltip" = "Translation bar hidden.\nYou can re-enable it on the info page."; +"Chat.Translate.Menu.HidePaywall.Tooltip" = "You can always enable it in Language Settings."; +"Chat.Translate.Menu.To" = "Choose Another Language"; +"Chat.Translate.ShowOriginal" = "Show Original"; +"Chat.Translate.To" = "Translate to %1$@"; +"Chat.TranslationPoweredByCocoon" = "Translations are powered by Cocoon. [How does it work?]()"; +"Chat.UndoManager.ChannelDeleted#one" = "Channel Deleted."; +"Chat.UndoManager.ChannelDeleted#other" = "%d Channels Deleted."; +"Chat.UndoManager.ChannelDeleted_countable" = "%d"; +"Chat.UndoManager.ChannelLeft#one" = "Channel Left"; +"Chat.UndoManager.ChannelLeft#other" = "%d Channels Left"; +"Chat.UndoManager.ChannelLeft_countable" = "%d"; +"Chat.UndoManager.ChatLeft#one" = "Chat Left"; +"Chat.UndoManager.ChatLeft#other" = "%d Chats Left"; +"Chat.UndoManager.ChatLeft_countable" = "%d"; +"Chat.UndoManager.ChatsArchived#one" = "Chat Archived"; +"Chat.UndoManager.ChatsArchived#other" = "%d Chats Archived"; +"Chat.UndoManager.ChatsArchived_countable" = "%d"; +"Chat.UndoManager.ChatsDeleted#one" = "Chat Deleted"; +"Chat.UndoManager.ChatsDeleted#other" = "%d Chats Deleted"; +"Chat.UndoManager.ChatsDeleted_countable" = "%d"; +"Chat.UndoManager.ChatsHistoryCleared#one" = "Chat History Cleared"; +"Chat.UndoManager.ChatsHistoryCleared#other" = "%d Chat History Cleared"; +"Chat.UndoManager.ChatsHistoryCleared_countable" = "%d"; +"Chat.UndoManager.Undo" = "Undo"; +"Chat.UnpinAllMessagesConfirmation#one" = "Do you want to unpin %d message in this chat?"; +"Chat.UnpinAllMessagesConfirmation#other" = "Do you want to unpin all %d messages in this chat?"; +"Chat.UnpinAllMessagesConfirmation_countable" = "%d"; +"Chat.Unsupported.UpdatedApp" = "UPDATE"; +"Chat.UploadLimit.Text#one" = "Subscribe to [Telegram Premium]() to increase upload speeds %d time."; +"Chat.UploadLimit.Text#other" = "Subscribe to [Telegram Premium]() to increase upload speeds %d times."; +"Chat.UploadLimit.Text_countable" = "%d"; +"Chat.UploadLimit.Title" = "Upload speed limited"; +"Chat.Video.Processing" = "processing..."; +"Chat.VideoCall.Incoming" = "Incoming Video Call"; +"Chat.VideoCall.Outgoing" = "Outgoing Video Call"; +"Chat.VideoProccessing.Context" = "The video will be published once converted and optimized."; +"Chat.VideoProccessing.Improving" = "Improving Video..."; +"Chat.VideoProccessing.Improving.Info" = "The video will be published after it's optimized for the best viewing experience."; +"Chat.VideoProccessing.Improving.Info.Forward" = "The forwarded video will be published after it's optimized for the best viewing experience."; +"Chat.VideoProccessing.MessageTime" = "appx. %@"; +"Chat.VideoProccessing.Published" = "Video published."; +"Chat.VideoProccessing.Published.View" = "View"; +"Chat.VideoProccessing.SendNow.Header" = "Wait!"; +"Chat.VideoProccessing.SendNow.Info" = "This video hasn't been converted and optimized yet. If you send it now, the viewers of the video may experience slow download speed."; +"Chat.VideoProccessing.SendNow.OK" = "Send Anyway"; +"Chat.VideoProccessing.Service" = "The video will be published once converted and optimized"; +"Chat.VideoProccessing.Tooltip" = "Processing video may take a few minutes."; +"Chat.ViewBackground" = "VIEW WALLPAPER"; +"Chat.ViewCollectible" = "VIEW COLLECTIBLE"; +"Chat.ViewContact" = "VIEW CONTACT"; +"Chat.Voice.Single.Close" = "Close"; +"Chat.Voice.Single.DeleteAndClose" = "Delete and Close"; +"Chat.Voice.Transribe.Error" = "No speech detected"; +"Chat.VoiceChat.JoinLink.OK" = "Join"; +"Chat.VoiceChat.JoinLink.Participants#one" = "%d participant"; +"Chat.VoiceChat.JoinLink.Participants#other" = "%d participants"; +"Chat.VoiceChat.JoinLink.Participants_ZeroValueHolder" = "no one joined yet"; +"Chat.VoiceChat.JoinLink.Participants_countable" = "%d"; +"Chat.VoiceChat.JoinLink.Text" = "Are you sure you want to join voice chat?"; +"Chat.VoiceChat.JoinLink.Title" = "Video Chat"; +"Chat.VoiceChat.JoinLink.Unavailable" = "Video Chat no longer available"; +"Chat.WP.Auction" = "Gift Auction"; +"Chat.WP.Auction.Upcoming" = "Upcoming Auction"; +"Chat.WP.BackgroundTitle" = "Chat Wallpaper"; +"Chat.WP.Color" = "Color"; +"Chat.WP.Color.FirstMessage" = "Click Apply to set the wallpaper"; +"Chat.WP.Color.SecondMessage" = "Looks good"; +"Chat.WP.FirstMessage" = "Pinch, swipe or double tap to select a custom area for the wallpaper."; +"Chat.WP.Intensity" = "Pattern Intensity"; +"Chat.WP.Pattern" = "Pattern"; +"Chat.WP.Pattern.None" = "None"; +"Chat.WP.SecondMessage" = "Pinch me, I'm dreaming!"; +"Chat.WP.SelectFromFile" = "Select From File"; +"Chat.WP.VoiceChatTitle" = "Video Chat"; +"Chat.Webpage.MediaCount" = "%d of %d"; +"Chat.Webpage.MediaCount1" = "%1$d of %2$d"; +"ChatEmpty.BusinessIntro.How" = "**%@** added the message above for all empty chats. [How?]()"; +"ChatEmpty.BusinessLink.Text" = "Add a message that will be entered in the message input field for anyone who starts a chat with you using the link:"; +"ChatEmpty.BusinessLink.Title" = "Link to Chat"; +"ChatEmpty.ManagedBot.EditInfo" = "You can edit your bot's info "; +"ChatEmpty.ManagedBot.Here" = "here >"; +"ChatEmpty.ManagedBot.IsReady" = "is ready!"; +"ChatEmpty.ManagedBot.Start" = "Start"; +"ChatEmpty.ManagedBot.Tap" = "Click"; +"ChatEmpty.ManagedBot.TestInfo" = " below to test your new chatbot. Its behavior is defined by "; +"ChatEmpty.SavedMessages" = "No Saved Messages"; +"ChatEmpty.Topic" = "Send first message to start this topic."; +"ChatEmpty.UserInfo.CommonGroups" = "Common Groups"; +"ChatEmpty.UserInfo.PhoneNumber" = "Phone Number"; +"ChatEmpty.UserInfo.Registration" = "Registration"; +"ChatGift.Info.FromGiveAway" = "You won a prize in a giveaway organized by **%1$@**.\n\nYour prize is a **Telegram Premium** subscription for **%2$@** months."; +"ChatGift.Info.GiveawayStars#one" = "You won a prize in a giveaway organized by **%1$@**.\n\nYour prize is **%2$@ Star**."; +"ChatGift.Info.GiveawayStars#other" = "You won a prize in a giveaway organized by **%1$@**.\n\nYour prize is **%2$@ Stars**."; +"ChatGift.Info.GiveawayStars_countable" = "%1$@ %2$d"; +"ChatGift.Info.Normal" = "You've received a gift from **%1$@**.\n\nYour gift is a **Telegram Premium** subscription for **%2$@** months."; +"ChatGift.Info.Unclaimed" = "You have an unclaimed prize from a giveaway by **%1$@**.\n\nThis prize is a **Telegram Premium** subscription for **%2$@** months."; +"ChatGift.Info.Unclaimed.Gift" = "You gifted **Telegram Premium** subscription for **%1$@** months."; +"ChatGift.Info.Unclaimed.Gift.You" = "You received **Telegram Premium** subscription for **%1$@** months."; +"ChatGift.Title.Claimed" = "**Congratulations!**"; +"ChatGift.Title.Gift" = "**Gift**"; +"ChatGift.Title.Unclaimed" = "**Unclaimed Prize**"; +"ChatHeader.RestartTopic" = "Reopen Topic"; +"ChatHeader.Search.AddTags" = "Add Tags"; +"ChatHeader.Search.AddTags.Second" = "to your Saved Messages"; +"ChatHeader.Tone" = "Tone"; +"ChatHeader.Tone.Casual" = "Casual"; +"ChatHeader.Tone.Formal" = "Formal"; +"ChatHeader.Tone.Neutral" = "Neutral"; +"ChatHistory.DiceWonOther" = "%1$@ Won 💎%2$@"; +"ChatHistory.DiceWonYou" = "You Won 💎%1$@"; +"ChatInput.BotMenu" = "Menu"; +"ChatInput.Business.Away" = "Away Message..."; +"ChatInput.Business.Greeting" = "Greeting Message..."; +"ChatInput.Business.Link" = "Add a preset message..."; +"ChatInput.Business.QuickReply" = "Quick Reply Message..."; +"ChatInput.EditLink.AboveTheMessage" = "Above the Message"; +"ChatInput.EditLink.BelowTheMessage" = "Below the Message"; +"ChatInput.EditLink.LargerMedia" = "Larger Media"; +"ChatInput.EditLink.RemovePreview" = "Remove Link Preview"; +"ChatInput.EditLink.SmallerMedia" = "Smaller Media"; +"ChatInput.OpenChat" = "Open Chat"; +"ChatInput.Reply.ReplyToAnother" = "Reply in Another Chat"; +"ChatInput.ShowNext" = "Show Next"; +"ChatLink.Unavailable" = "Sorry, this link doesn't seem to exist."; +"ChatList.ActiveAuction.Title#one" = "%d Active Auction"; +"ChatList.ActiveAuction.Title#other" = "%d Active Auctions"; +"ChatList.ActiveAuction.Title_ZeroValueHolder" = "Active Auction"; +"ChatList.ActiveAuction.Title_countable" = "%d Active Auctions"; +"ChatList.Add.BottomSeparator" = "CHATS"; +"ChatList.Add.Placeholder" = "Select chats..."; +"ChatList.Add.Save" = "Add"; +"ChatList.Add.TopSeparator" = "CHAT TYPES"; +"ChatList.Apps.ClearRecent" = "Clear Recent Apps"; +"ChatList.Apps.CloseAll" = "Close All"; +"ChatList.Apps.Popular" = "GROSSING APPS"; +"ChatList.AppsSearch.Empty.Info" = "You are not currently using any app."; +"ChatList.AppsSearch.Empty.Title" = "No apps yet..."; +"ChatList.AppsTag" = "Apps"; +"ChatList.Archive.Back" = "Chats"; +"ChatList.ArchiveStory.Count#one" = "%d Story"; +"ChatList.ArchiveStory.Count#other" = "%d Stories"; +"ChatList.ArchiveStory.Count_countable" = "%d"; +"ChatList.ArchivedChats" = "Archived Chats"; +"ChatList.AuthorHidden" = "Author Hidden"; +"ChatList.Birthday.Add.Info" = "Let your contacts know when you're celebrating."; +"ChatList.Birthday.Add.Title" = "Add your birthday! 🎂"; +"ChatList.BirthdayInfo#one" = "Gift them Telegram Premium."; +"ChatList.BirthdayInfo#other" = "Gift them Telegram Premium."; +"ChatList.BirthdayInfoNew#one" = "Send them a Gift."; +"ChatList.BirthdayInfoNew#other" = "Send them a Gift."; +"ChatList.BirthdayInfoNew_countable" = "%d"; +"ChatList.BirthdayInfo_countable" = "%d"; +"ChatList.BirthdayMultipleTitle#one" = "%d contact has a **birthday** today! 🎂"; +"ChatList.BirthdayMultipleTitle#other" = "%d contacts have **birthdays** today! 🎂"; +"ChatList.BirthdayMultipleTitle_countable" = "%d"; +"ChatList.BirthdaySingleTitle" = "It's %@ **birthday** today! 🎂"; +"ChatList.ChannelSearch.Empty.Info" = "You are not currently subscribed to any channel."; +"ChatList.ChannelSearch.Empty.Title" = "No channels yet..."; +"ChatList.ChannelsTag" = "Channels"; +"ChatList.ChatsTag" = "Chats"; +"ChatList.CloseFilter" = "Show All"; +"ChatList.CloseFilterShort" = "All"; +"ChatList.Context.Call" = "Call"; +"ChatList.Context.ClearHistory" = "Clear History"; +"ChatList.Context.Delete" = "Delete"; +"ChatList.Context.DeleteAndExit" = "Delete and Leave"; +"ChatList.Context.DeleteChat" = "Delete Chat"; +"ChatList.Context.HideGeneral" = "Hide"; +"ChatList.Context.HidePromo" = "Hide"; +"ChatList.Context.LeaveChannel" = "Leave Channel"; +"ChatList.Context.LeaveGroup" = "Leave Group"; +"ChatList.Context.MaskAsRead" = "Mark As Read"; +"ChatList.Context.MaskAsUnread" = "Mark As Unread"; +"ChatList.Context.Mute" = "Mute"; +"ChatList.Context.Pause" = "Close"; +"ChatList.Context.Pin" = "Pin"; +"ChatList.Context.PinError" = "Sorry, you can pin no more than 5 chats to the top."; +"ChatList.Context.PinError.Topics#one" = "Sorry, you can only pin %d topics to the top. Unpin some that are currently pinned."; +"ChatList.Context.PinError.Topics#other" = "Sorry, you can only pin %d topics to the top. Unpin some that are currently pinned."; +"ChatList.Context.PinError.Topics_countable" = "%d"; +"ChatList.Context.PinErrorNew" = "Sorry, you can only pin 5 chats to the top."; +"ChatList.Context.PinErrorNew.SetupFolders" = "Set Up Folders"; +"ChatList.Context.PinErrorNew2" = "Sorry, you can only pin 5 chats to the top in the main list. More chats can be pinned in Chat Folders and your Archive."; +"ChatList.Context.Preview" = "Preview"; +"ChatList.Context.ReturnGroup" = "Return to Group"; +"ChatList.Context.Sound" = "Sound"; +"ChatList.Context.Start" = "Reopen"; +"ChatList.Context.UnhideGeneral" = "Unhide"; +"ChatList.Context.Unmute" = "Unmute"; +"ChatList.Context.Unpin" = "Unpin"; +"ChatList.DownloadsTag" = "Downloads"; +"ChatList.Draft" = "Draft:"; +"ChatList.Empty.CreateTopic" = "Create New Topic"; +"ChatList.Empty.DisableForum" = "Show as Messages"; +"ChatList.Empty.Loading" = "Loading"; +"ChatList.EmptyForum" = "No Topics here yet"; +"ChatList.EmptyText" = "**You have no chats yet**\nStart messaging by clicking the pencil button in the top right corner or go to the Contacts section."; +"ChatList.Feeds" = "Channels"; +"ChatList.Filter.Add.Done" = "Done"; +"ChatList.Filter.AddChats" = "Add Chats"; +"ChatList.Filter.AddToFolder" = "Add to folder..."; +"ChatList.Filter.All" = "All"; +"ChatList.Filter.AllChats" = "All Chats"; +"ChatList.Filter.Archive" = "Archived"; +"ChatList.Filter.Back" = "Chats"; +"ChatList.Filter.Bots" = "Bots"; +"ChatList.Filter.Categories.Header" = "INCLUDE CHAT TYPES"; +"ChatList.Filter.Channels" = "Channels"; +"ChatList.Filter.Confirm.Remove.Header" = "Remove Folder"; +"ChatList.Filter.Confirm.Remove.OK" = "Delete"; +"ChatList.Filter.Confirm.Remove.Text" = "Are you sure you want to remove this folder? Your chats will not be deleted."; +"ChatList.Filter.Contacts" = "Contacts"; +"ChatList.Filter.Delete" = "Delete"; +"ChatList.Filter.Discard.Cancel" = "Cancel"; +"ChatList.Filter.Discard.Header" = "Discard Changes"; +"ChatList.Filter.Discard.OK" = "Discard"; +"ChatList.Filter.Discard.Text" = "Are you sure you want to discard all changes?"; +"ChatList.Filter.Done" = "Create"; +"ChatList.Filter.Edit" = "Edit Folder"; +"ChatList.Filter.EditFilters" = "Edit Folders"; +"ChatList.Filter.Empty" = "**No chats currently match this folder.**\n\n[Edit Folder](filter)"; +"ChatList.Filter.Error.Empty" = "Please add some chats or chat types to the folder."; +"ChatList.Filter.Error.LikeChats" = "Can’t create a folder that includes all your chats."; +"ChatList.Filter.Exclude.AddChat" = "Add Chats"; +"ChatList.Filter.Exclude.Desc" = "Choose chats or types of chats that will not appear in this folder."; +"ChatList.Filter.Exclude.Header" = "EXCLUDED CHATS"; +"ChatList.Filter.Exclude.LimitReached" = "Sorry, you can only add up to 100 individual chats. Try using chat types."; +"ChatList.Filter.Exclude.LimitReached.New" = "Sorry, you can only add up to %d chats."; +"ChatList.Filter.Exclude.RemoveChat" = "Remove"; +"ChatList.Filter.ExcludeMuted" = "Exclude Muted"; +"ChatList.Filter.ExcludeRead" = "Exclude Read"; +"ChatList.Filter.ExistingChats" = "Existing Chats"; +"ChatList.Filter.Groups" = "Groups"; +"ChatList.Filter.Header" = "Create folders for different groups of chats and quickly switch between them."; +"ChatList.Filter.Hide#one" = "Hide %d Chat"; +"ChatList.Filter.Hide#other" = "Hide %d Chats"; +"ChatList.Filter.Hide_countable" = "%d"; +"ChatList.Filter.Include.AddChat" = "Add Chats"; +"ChatList.Filter.Include.Desc" = "Choose chats or types of chats that will appear in this folder."; +"ChatList.Filter.Include.Header" = "INCLUDED CHATS"; +"ChatList.Filter.Include.LimitReached" = "Sorry, you can only add up to 100 individual chats. Try using chat types."; +"ChatList.Filter.Include.LimitReached.New" = "Sorry, you can only add up to %d chats."; +"ChatList.Filter.Include.RemoveChat" = "Remove"; +"ChatList.Filter.InviteLink" = "Create a New Link"; +"ChatList.Filter.InviteLink.Delete" = "Delete"; +"ChatList.Filter.InviteLink.Delete.Confirm" = "Are you sure you want to revoke and delete this invite link?"; +"ChatList.Filter.InviteLink.Desc" = "includes %d chats"; +"ChatList.Filter.InviteLink.Desc_countable" = "%d"; +"ChatList.Filter.InviteLink.IncludeExcludeError" = "You can’t share folders which include or exclude specific chat types like 'Groups', 'Contacts', etc."; +"ChatList.Filter.InviteLink.Info" = "Share access to some of this folder's groups and channels with others."; +"ChatList.Filter.InviteLink.Share" = "Share Folder"; +"ChatList.Filter.InviteLink.header" = "INVITE LINK"; +"ChatList.Filter.List.AddNew" = "Add a Custom Folder"; +"ChatList.Filter.List.Desc" = "Drag and drop folders to change order. Right click to remove."; +"ChatList.Filter.List.Header" = "FOLDERS"; +"ChatList.Filter.List.Remove" = "Remove"; +"ChatList.Filter.List.Title" = "Chat Folders"; +"ChatList.Filter.Loading" = "**Adding Chats**\nPlease wait a few moments while\nwe fill this folder for you..."; +"ChatList.Filter.MiniApps" = "Mini-Apps"; +"ChatList.Filter.MuteAll" = "Mute All"; +"ChatList.Filter.MutedChats" = "Muted"; +"ChatList.Filter.Name.Header" = "FOLDER NAME"; +"ChatList.Filter.Name.Placeholder" = "Folder Name"; +"ChatList.Filter.NewChats" = "New Chats"; +"ChatList.Filter.NewTitle" = "Create Folder"; +"ChatList.Filter.NonContacts" = "Non-Contacts"; +"ChatList.Filter.PremiumUsers" = "Premium Users"; +"ChatList.Filter.ReadAll" = "Read All"; +"ChatList.Filter.ReadChats" = "Read"; +"ChatList.Filter.Recommended.Add" = "Add"; +"ChatList.Filter.Recommended.Header" = "RECOMMENDED"; +"ChatList.Filter.RemoveFromFolder" = "Remove From Folder"; +"ChatList.Filter.SecretChat" = "Secret Chats"; +"ChatList.Filter.Setup" = "Edit Folders"; +"ChatList.Filter.SetupEmpty" = "Add Folder"; +"ChatList.Filter.Share" = "Share"; +"ChatList.Filter.ShowMore#one" = "Show %d More Chat"; +"ChatList.Filter.ShowMore#other" = "Show %d More Chats"; +"ChatList.Filter.ShowMore_countable" = "%d"; +"ChatList.Filter.SmallGroups" = "Small Groups"; +"ChatList.Filter.TabBar.Desc" = "If you have many folders, try moving tabs to the left."; +"ChatList.Filter.TabBar.Header" = "TABS VIEW"; +"ChatList.Filter.TabBar.OnTheLeft" = "Tabs on the left"; +"ChatList.Filter.TabBar.OnTheTop" = "Tabs at the top"; +"ChatList.Filter.Tilte.Default.Bots" = "Bots"; +"ChatList.Filter.Tilte.Default.Channels" = "Channels"; +"ChatList.Filter.Tilte.Default.Contacts" = "Contacts"; +"ChatList.Filter.Tilte.Default.Groups" = "Groups"; +"ChatList.Filter.Tilte.Default.NonContacts" = "Non-Contacts"; +"ChatList.Filter.Tilte.Default.Unmuted" = "Unmuted"; +"ChatList.Filter.Tilte.Default.Unread" = "Unread"; +"ChatList.Filter.Title" = "Folder"; +"ChatList.Filter.Tooltip" = "You can organize your chats by right click."; +"ChatList.Filter.UnmuteAll" = "Unmute All"; +"ChatList.Folder.ColorInfo" = "This color will be used for the folder's tag in the chat list."; +"ChatList.Folder.ColorReset" = "Reset Color"; +"ChatList.Folder.ColorTitle" = "FOLDER COLOR"; +"ChatList.Folder.DisableAnimations" = "disable animations"; +"ChatList.Folder.EnableAnimations" = "enable animations"; +"ChatList.Folder.PremiumAlert" = "Subscribe to Telegram Premium to add colors for your folders."; +"ChatList.Folder.Tags" = "Show Folder Tags"; +"ChatList.Folder.Tags.Info" = "Display folder names for each chat in the chat list."; +"ChatList.FolderUpdates.Info" = "Click here to view them"; +"ChatList.FolderUpdates.Title#one" = "%d New Chats Available"; +"ChatList.FolderUpdates.Title#other" = "%d New Chats Available"; +"ChatList.FolderUpdates.Title_countable" = "%d"; +"ChatList.FreezeAccount" = "Your account has been frozen for a serious violation of Telegram's [Terms and Conditions](https://telegram.org/tos). Your account is now in read-only mode (since %1$@). You can appeal via [@SpamBot](%2$@) if you believe this was a mistake. If we don't hear from you until %3$@, your account will be fully deactivated and terminated."; +"ChatList.FreezeAccount.Alert" = "Your account has been frozen for a serious violation of Telegram's [Terms and Conditions](https://telegram.org/tos). Your account is now in read-only mode (since %1$@). You can appeal via [@SpamBot](%2$@) if you believe this was a mistake. If we don't hear from you until %3$@, your account will be fully deactivated and terminated."; +"ChatList.FreezeAccount.Alert.Appeal" = "Appeal"; +"ChatList.FreezeAccount.Alert.Title" = "Your account was frozen"; +"ChatList.FreezeAccount.Title" = "Your account was frozen"; +"ChatList.Giveaway" = "Giveaway"; +"ChatList.GracePeriod.Info" = "Don't lose access to exclusive features."; +"ChatList.GracePeriod.Title" = "⚠️ Your Premium subscription is expiring!"; +"ChatList.GroupChannel" = "Group Channel"; +"ChatList.HideMuted" = "Hide Muted"; +"ChatList.MessagesTag" = "Messages"; +"ChatList.Monoforum.Holder" = "DIRECT"; +"ChatList.Monoforum.TabBar.List" = "List"; +"ChatList.Monoforum.TabBar.Tabs" = "Tabs"; +"ChatList.Mute.1Day" = "For 1 Day"; +"ChatList.Mute.1Hour" = "For 1 Hour"; +"ChatList.Mute.3Days" = "For 3 Days"; +"ChatList.Mute.4Hours" = "For 4 Hours"; +"ChatList.Mute.8Hours" = "For 8 Hours"; +"ChatList.Mute.Forever" = "Forever"; +"ChatList.Mute.Until" = "Mute Until..."; +"ChatList.Mute.Until.OK" = "Mute"; +"ChatList.Mute.Until.Title" = "Mute Until…"; +"ChatList.MyNotes" = "My Notes"; +"ChatList.OpenMiniApp" = "OPEN"; +"ChatList.Popover.Confirm" = "Are you sure you want to mark all chats as read?"; +"ChatList.Popover.ReadAll" = "Read All"; +"ChatList.Posts.Tag" = "Posts"; +"ChatList.RevealAction.Collapse" = "Collapse"; +"ChatList.RevealAction.Expand" = "Expand"; +"ChatList.RevealAction.Hide" = "Hide"; +"ChatList.RevealAction.Pin" = "Pin"; +"ChatList.Search.Placeholder" = "Search (⌘K)"; +"ChatList.SecretChat.Created" = "%@ created a secret chat."; +"ChatList.SecretChat.ExKeys" = "Waiting to come online"; +"ChatList.SecretChat.Joined" = "%@ joined your secret chat."; +"ChatList.SecretChat.Terminated" = "Secret chat canceled"; +"ChatList.Service.Call.Cancelled" = "Canceled Call"; +"ChatList.Service.Call.Missed" = "Missed Call"; +"ChatList.Service.Call.incoming" = "Incoming Call (%@)"; +"ChatList.Service.Call.outgoing" = "Outgoing Call (%@)"; +"ChatList.Service.DestructingPhoto" = "self-destructing photo"; +"ChatList.Service.DestructingVideo" = "self-destructing video"; +"ChatList.Service.GameScored1#one" = "scored %d in %@"; +"ChatList.Service.GameScored1#other" = "scored %d in %@"; +"ChatList.Service.GameScored1_countable" = "%d %@"; +"ChatList.Service.VideoCall.Cancelled" = "Canceled Video Call"; +"ChatList.Service.VideoCall.Missed" = "Missed Video Call"; +"ChatList.Service.VideoCall.incoming" = "Incoming Video Call (%@)"; +"ChatList.Service.VideoCall.outgoing" = "Outgoing Video Call (%@)"; +"ChatList.Service.VoiceChatFinished.Channel" = "Video chat ended (%1$@)"; +"ChatList.Service.VoiceChatFinished.Channel1" = "Live stream ended (%1$@)"; +"ChatList.Service.VoiceChatInvitation" = "%1$@ invited %2$@ to the video chat"; +"ChatList.Service.VoiceChatInvitationByYou" = "You invited %1$@ to the video chat"; +"ChatList.Service.VoiceChatInvitationForYou" = "%1$@ invited you to the video chat"; +"ChatList.Service.VoiceChatScheduled" = "%1$@ scheduled a video chat for %2$@"; +"ChatList.Service.VoiceChatScheduled.Channel" = "Video chat scheduled for %@"; +"ChatList.Service.VoiceChatScheduled.Channel1" = "live stream scheduled for %@"; +"ChatList.Service.VoiceChatScheduledYou" = "You scheduled a video chat for %2$@"; +"ChatList.Service.VoiceChatStarted" = "%1$@ started a video chat"; +"ChatList.Service.VoiceChatStarted.Channel" = "Video chat started"; +"ChatList.Service.VoiceChatStarted.Channel1" = "Live stream started"; +"ChatList.Service.VoiceChatStartedYou" = "You started a video chat"; +"ChatList.SponsoredChannel" = "Proxy Sponsor"; +"ChatList.Story" = "Story"; +"ChatList.Story.Expired" = "Expired Story"; +"ChatList.Story.Unsupported" = "Unsupported Story"; +"ChatList.StoryMentioned" = "mentioned you in a story"; +"ChatList.SubscriptionsLowBalance#one" = "%d Stars needed"; +"ChatList.SubscriptionsLowBalance#other" = "%d Stars needed"; +"ChatList.SubscriptionsLowBalance.Multiple.Text" = "Insufficient funds to cover your subscriptions."; +"ChatList.SubscriptionsLowBalance.Multiple.Title" = "%@ for your subscriptions"; +"ChatList.SubscriptionsLowBalance.Single.Text" = "Insufficient funds to cover your subscription."; +"ChatList.SubscriptionsLowBalance.Single.Title" = "%1$@ for %2$@"; +"ChatList.SubscriptionsLowBalance_countable" = "%d"; +"ChatList.Swiping.Archive" = "Archive"; +"ChatList.Swiping.Delete" = "Delete"; +"ChatList.Swiping.Mute" = "Mute"; +"ChatList.Swiping.Pin" = "Pin"; +"ChatList.Swiping.Read" = "Read"; +"ChatList.Swiping.Unarchive" = "Unarchive"; +"ChatList.Swiping.Unmute" = "Unmute"; +"ChatList.Swiping.Unpin" = "Unpin"; +"ChatList.Swiping.Unread" = "Unread"; +"ChatList.Text.UniqueGift" = "Gift %@"; +"ChatList.TitleFeed" = "Feed"; +"ChatList.TopicsTag" = "Topics"; +"ChatList.UnhideMuted" = "Unhide Muted"; +"ChatList.UnsupportedMessage" = "Message is not supported"; +"ChatList.UpcomingAuction" = "Upcoming Auction"; +"ChatList.You" = "You"; +"ChatListApps.Clear" = "Clear"; +"ChatListApps.RecentUsed.Header" = "RECENT USED APPS"; +"ChatMenu.AddedBy" = "Added by %1$@\non %2$@"; +"ChatMenu.AddedByYou" = "You"; +"ChatMenu.DeleteOption" = "Delete Option"; +"ChatMenu.ReplyToOption" = "Reply to Option"; +"ChatMessage.BoostChannel" = "BOOST"; +"ChatMessage.Folder.Expired" = "The folder link has expired"; +"ChatMessage.OpenApp" = "Launch"; +"ChatMessage.Recommended.Title" = "Recommended"; +"ChatMessage.RelativeDate.Ago" = "%1$@ ago"; +"ChatMessage.RelativeDate.Days#other" = "%1$d days"; +"ChatMessage.RelativeDate.Hours#other" = "%1$d hours"; +"ChatMessage.RelativeDate.InFuture" = "in %1$@"; +"ChatMessage.RelativeDate.Minutes#other" = "%1$d minutes"; +"ChatMessage.RelativeDate.Months#other" = "%1$d months"; +"ChatMessage.RelativeDate.Seconds#other" = "%1$d seconds"; +"ChatMessage.RelativeDate.Weeks#other" = "%1$d weeks"; +"ChatMessage.RelativeDate.Years#other" = "%1$d years"; +"ChatMessage.Sponsored.About" = "About This Ad"; +"ChatMessage.Sponsored.Report" = "Report Ad"; +"ChatMessage.Sponsored.Report.Aready" = "This Ad is already reported"; +"ChatMessage.Sponsored.Report.LearnMore" = "Learn more about [Telegram Ad Policies and Guidelines](https://ads.telegram.org/guidelines)."; +"ChatMessage.Sponsored.Report.OptionTitle" = "WHAT IS WRONG WITH THIS AD?"; +"ChatMessage.Sponsored.Report.Success" = "Telegram moderators will review your report. Thank you."; +"ChatMessage.Sponsored.Title" = "Ad"; +"ChatMessage.Tooltip.Views" = "views"; +"ChatMessage.ViewChatList" = "VIEW CHAT LIST"; +"ChatMessageMenuItems.AndJoiner" = " and "; +"ChatMessageMenuItems.OnlyOldSubscribersCanVote" = "Only subscribers who joined more than 24 hours ago can vote."; +"ChatMessageMenuItems.OnlySubscribersOfCanVote" = "Only subscribers of %1$@ can vote."; +"ChatMessageMenuItems.OnlyUsersFromCountriesCanVote" = "Only users from %1$@ can vote."; +"ChatMessageMenuItems.ViewStats" = "View Stats"; +"ChatMonoforum.NewChat" = "New Chat"; +"ChatPoll.AddOption" = "Add an Option"; +"ChatPoll.Explanation" = "Explanation"; +"ChatPoll.ResultsAfterEnd" = "Results will appear after the poll ends"; +"ChatPoll.ResultsIn" = "results in %1$@"; +"ChatPoll.ViewVotes#other" = "View Votes (%1$d)"; +"ChatReaction.StarsDisabled" = "Star reactions have been disabled for this channel."; +"ChatReaction.Undo" = "Undo"; +"ChatReaction.Undo.Reacted#one" = "You have reacted with %d star."; +"ChatReaction.Undo.Reacted#other" = "You have reacted with %d stars."; +"ChatReaction.Undo.Reacted_countable" = "%d"; +"ChatReaction.Undo.StarsSent" = "Stars Sent!"; +"ChatReactions.TagMessage" = "Tag the message with an emoji for quick search"; +"ChatReactions.TagMessage.Premium" = "Organize your Saved Messages with tags\nfor quicker access. [Learn More...]()"; +"ChatRow.BotForConnector" = " for "; +"ChatService.ChangedToSameWallpaper" = "%1$@ set the same wallpaper for this chat"; +"ChatService.ChangedWallpaper" = "%1$@ set a new wallpaper for this chat"; +"ChatService.ChannelCreated" = "Channel created"; +"ChatService.CopyProtection.Accept" = "Accept"; +"ChatService.CopyProtection.CopyingMessages" = "Copying messages"; +"ChatService.CopyProtection.ForwardingMessages" = "Forwarding messages"; +"ChatService.CopyProtection.Reject" = "Reject"; +"ChatService.CopyProtection.RequestExpired" = "Sharing request has expired"; +"ChatService.CopyProtection.SavingPhotosVideos" = "Saving photos and videos"; +"ChatService.CopyProtection.SharingDisabled" = "%1$@ disabled sharing in this chat"; +"ChatService.CopyProtection.SharingDisabledToast" = "Sharing disabled for this chat."; +"ChatService.CopyProtection.SharingEnabled" = "%1$@ enabled sharing in this chat"; +"ChatService.CopyProtection.SharingEnabledToast" = "Sharing enabled for this chat."; +"ChatService.CopyProtection.SharingWillBeDisabled" = "Sharing will be disabled once approved."; +"ChatService.CopyProtection.SharingWillBeEnabled" = "Sharing will be enabled once approved."; +"ChatService.CopyProtection.TakingScreenshots" = "Taking screenshots"; +"ChatService.CopyProtection.WouldLikeDisable" = "%1$@ would like to disable sharing"; +"ChatService.CopyProtection.WouldLikeDisableIncludes" = "%1$@ would like to disable sharing in this chat, which includes:"; +"ChatService.CopyProtection.WouldLikeEnable" = "%1$@ would like to enable sharing"; +"ChatService.CopyProtection.WouldLikeEnableIncludes" = "%1$@ would like to enable sharing in this chat, which includes:"; +"ChatService.CraftedGift" = "Crafted Gift"; +"ChatService.CraftedUniqueCollectible" = "You crafted a new unique collectible"; +"ChatService.GiftOffer.ExpiredIncoming" = "**%1$@** didn't respond to your offer for **%2$@** — your %3$@ have been refunded."; +"ChatService.GiftOffer.ExpiredOutgoing" = "The offer from **%1$@** to buy your **%2$@** for %3$@ has expired."; +"ChatService.GiftOffer.RejectedIncoming" = "**%1$@** rejected your offer for %2$@ — your **%3$@** have been refunded."; +"ChatService.GiftOffer.RejectedOutgoing" = "The offer from **%1$@** to buy your **%2$@** for %3$@ has been rejected."; +"ChatService.GiftOffer.ValueHigher" = "The value of this gift is **%1$@** higher than the offer."; +"ChatService.GiftOffer.ValueLower" = "The value of this gift is **%1$@** lower than the offer."; +"ChatService.GiftUsedForCrafting" = "This gift was used for crafting and is no longer available."; +"ChatService.GiveawayResults#one" = "%d winner of the giveaway was randomly selected by Telegram and received their gift link in a private message."; +"ChatService.GiveawayResults#other" = "%d winners of the giveaway were randomly selected by Telegram and received their gift links in private messages."; +"ChatService.GiveawayResults.Stars#one" = "%d winner of the giveaway was randomly selected by Telegram and received their prize in a private message."; +"ChatService.GiveawayResults.Stars#other" = "%d winners of the giveaway were randomly selected by Telegram and received their prize in a private message."; +"ChatService.GiveawayResults.Stars_countable" = "%d winner of the giveaway was randomly selected by Telegram and received their prize in a private message."; +"ChatService.GiveawayResultsMixedUnclaimed#one" = "%d undistributed gift code was forwarded to channel administrators"; +"ChatService.GiveawayResultsMixedUnclaimed#other" = "%d undistributed gift codes was forwarded to channel administrators"; +"ChatService.GiveawayResultsMixedUnclaimed.Stars#one" = "%d undistributed prize code was forwarded to channel administrators"; +"ChatService.GiveawayResultsMixedUnclaimed.Stars#other" = "%d undistributed prize codes were forwarded to channel administrators"; +"ChatService.GiveawayResultsMixedUnclaimed.Stars_countable" = "%d"; +"ChatService.GiveawayResultsMixedUnclaimed_countable" = "%d"; +"ChatService.GiveawayResultsMixedWinners#one" = "**%d** winner of the giveaway was randomly selected by Telegram and received their gift link in a private message."; +"ChatService.GiveawayResultsMixedWinners#other" = "**%d** winners of the giveaway were randomly selected by Telegram and received their gift links in private messages."; +"ChatService.GiveawayResultsMixedWinners.Stars#one" = "**%d** winner of the giveaway was randomly selected by Telegram and received a private message with prize."; +"ChatService.GiveawayResultsMixedWinners.Stars#other" = "**%d** winners of the giveaway were randomly selected by Telegram and received a private message with prize."; +"ChatService.GiveawayResultsMixedWinners.Stars_countable" = "%d"; +"ChatService.GiveawayResultsMixedWinners_countable" = "%d"; +"ChatService.GiveawayResultsNoWinners#one" = "Due to the giveaway terms, no winner could be selected by Telegram, a gift code was forwarded to channel administrators."; +"ChatService.GiveawayResultsNoWinners#other" = "Due to the giveaway terms, no winners could be selected by Telegram, a gift code was forwarded to channel administrators."; +"ChatService.GiveawayResultsNoWinners.Stars#one" = "Due to the giveaway terms, no winner could be selected by Telegram, a prize code was forwarded to channel administrators."; +"ChatService.GiveawayResultsNoWinners.Stars#other" = "Due to the giveaway terms, no winners could be selected by Telegram, prize codes were forwarded to channel administrators."; +"ChatService.GiveawayResultsNoWinners.Stars_countable" = "%d"; +"ChatService.GiveawayResultsNoWinners_countable" = "%d"; +"ChatService.GiveawayResults_countable" = "%d winner of the giveaway was randomly selected by Telegram and received private message with giftcode."; +"ChatService.JoinedChannel" = "You joined the channel"; +"ChatService.JoinedChannelByRequest" = "Your request to join the channel was approved"; +"ChatService.JoinedGroupByRequest" = "Your request to join the group was approved"; +"ChatService.ManagedBotCreated" = "%1$@ created %2$@"; +"ChatService.PhoneNumberRequest" = "phone number request"; +"ChatService.PollOptionAppended" = "%1$@ added a new option: %2$@"; +"ChatService.PollOptionDeleted" = "%1$@ deleted an option: %2$@"; +"ChatService.RemoveBackground" = "Remove"; +"ChatService.StarsPrize" = "You won a prize in a giveaway organized by %1$@.\n\nYour prize is %2$@."; +"ChatService.Suggest.Success" = "You successfully updated your profile photo"; +"ChatService.Suggest.View" = "View Photo"; +"ChatService.UpdateBackground" = "Update Wallpaper"; +"ChatService.UploadingWallpaper" = "Setting new wallpaper...\n**%1$@%**"; +"ChatService.UserJoinedChannelByRequest" = "%@ joined the channel by request"; +"ChatService.UserJoinedGroupByRequest" = "%@ was accepted to the group"; +"ChatService.ViewBackground" = "View Wallpaper"; +"ChatService.YouChangedToSameWallpaper" = "You set the same wallpaper as your chat partner"; +"ChatService.YouChangedWallpaper" = "You set a new wallpaper for this chat"; +"ChatService.YouChangedWallpaperBoth" = "You set a new wallpaper for %@ and you"; +"ChatTheme.Colorful" = "Colorful"; +"ChatTheme.Theme" = "Select Theme"; +"ChatTheme.Theme.Info" = "Theme will be also applied for %@"; +"ChatTheme.UniqueGift.Replace.Confirm" = "This gift is already your theme in the chat with **%@**. Remove it there and use it here instead?"; +"ChatTheme.UniqueGift.Replace.Title" = "Set Theme"; +"ChatTitle.Business.AwayMessages" = "Away Messages"; +"ChatTitle.Business.GreetingMessages" = "Greeting Messages"; +"ChatTitle.ReportMessages" = "Report Messages"; +"ChatWallpaper.Empty" = "Default"; +"ChatbotSetup.Gift.Warning.CombinedText" = "The bot **%@** will be able to **manage your gifts and stars**, including giving them away to other users."; +"ChatbotSetup.Gift.Warning.GiftsText" = "The bot **%@** will be able to **manage your gifts**, including giving them away to other users."; +"ChatbotSetup.Gift.Warning.Proceed" = "Proceed"; +"ChatbotSetup.Gift.Warning.StarsText" = "The bot **%@** will be able to **transfer your stars**."; +"ChatbotSetup.Gift.Warning.Title" = "Warning"; +"ChatbotSetup.Gift.Warning.UsernameText" = "The bot **%@** will be able to **set and remove usernames** for your account, which may result in the loss of your current username."; +"Checkout.2FA.Text" = "Saving payment details is only available with 2-Step Verification."; +"Checkout.Email" = "Email"; +"Checkout.EnterPassword" = "Enter Password"; +"Checkout.ErrorGeneric" = "An error occurred while processing your payment. Your card has not been billed."; +"Checkout.ErrorInvoiceAlreadyPaid" = "You have already paid for this item."; +"Checkout.ErrorPaymentFailed" = "Payment failed. Your card has not been billed."; +"Checkout.ErrorPrecheckoutFailed" = "The bot couldn't process your payment. Your card has not been billed."; +"Checkout.ErrorProviderAccountInvalid" = "This bot can't accept payments at the moment. Please try again later."; +"Checkout.ErrorProviderAccountTimeout" = "This bot can't process payments at the moment. Please try again later."; +"Checkout.Name" = "Name"; +"Checkout.NewCard.CardholderNamePlaceholder" = "Cardholder Name"; +"Checkout.NewCard.CardholderNameTitle" = "CARDHOLDER"; +"Checkout.NewCard.PaymentCard" = "PAYMENT CARD"; +"Checkout.NewCard.PostcodePlaceholder" = "Zip Code"; +"Checkout.NewCard.PostcodeTitle" = "BILLING ADDRESS"; +"Checkout.NewCard.SaveInfo" = "Save Payment Information"; +"Checkout.NewCard.SaveInfoEnableHelp" = "You can save your payment information for future use.\nPlease [turn on Two-Step Verification] to enable this."; +"Checkout.NewCard.SaveInfoHelp" = "You can save your payment info for future use. It will be stored directly with the payment provider. Telegram has no access to your credit card data."; +"Checkout.NewCard.Title" = "New Card"; +"Checkout.PasswordEntry.Pay" = "Pay"; +"Checkout.PasswordEntry.Text" = "Your card %@ is on file. To pay with this card, please enter your 2-Step-Verification password."; +"Checkout.PasswordEntry.Title" = "Payment Confirmation"; +"Checkout.PayNone" = "Pay"; +"Checkout.PayPrice" = "Pay %@"; +"Checkout.PaymentMethod" = "Payment Method"; +"Checkout.PaymentMethod.New" = "New Card..."; +"Checkout.PaymentMethod.Title" = "Payment Method"; +"Checkout.Phone" = "Phone"; +"Checkout.PriceHeader" = "PRICE"; +"Checkout.Receipt.Title" = "Receipt"; +"Checkout.SavePasswordTimeout" = "Would you like to save your password for %@?"; +"Checkout.ShippingAddress" = "Shipping Information"; +"Checkout.ShippingMethod" = "Shipping Method"; +"Checkout.ShippingOption.Title" = "Shipping Method"; +"Checkout.Success" = "Your payment was processed successfully!"; +"Checkout.Title" = "Checkout"; +"Checkout.TotalAmount" = "Total"; +"Checkout.TotalPaidAmount" = "Total Paid"; +"Checkout.WebConfirmation.Title" = "Complete Payment"; +"CheckoutInfo.ErrorCityInvalid" = "Please enter a valid city."; +"CheckoutInfo.ErrorEmailInvalid" = "Please enter a valid email address."; +"CheckoutInfo.ErrorNameInvalid" = "Please enter a valid name."; +"CheckoutInfo.ErrorPhoneInvalid" = "Please enter a valid phone number."; +"CheckoutInfo.ErrorPostcodeInvalid" = "Please enter a valid postcode."; +"CheckoutInfo.ErrorShippingNotAvailable" = "Shipping to the selected country is not available."; +"CheckoutInfo.ErrorStateInvalid" = "Please enter a valid state."; +"CheckoutInfo.Pay" = "Pay"; +"CheckoutInfo.ReceiverInfoEmail" = "Email"; +"CheckoutInfo.ReceiverInfoEmailPlaceholder" = "Email"; +"CheckoutInfo.ReceiverInfoName" = "Name"; +"CheckoutInfo.ReceiverInfoNamePlaceholder" = "Name Surname"; +"CheckoutInfo.ReceiverInfoPhone" = "Phone"; +"CheckoutInfo.ReceiverInfoTitle" = "RECEIVER"; +"CheckoutInfo.SaveInfo" = "Save Info"; +"CheckoutInfo.SaveInfoHelp" = "You can save your shipping information for future use."; +"CheckoutInfo.ShippingInfoAddress1" = "Address 1"; +"CheckoutInfo.ShippingInfoAddress1Placeholder" = "Address"; +"CheckoutInfo.ShippingInfoAddress2" = "Address 2"; +"CheckoutInfo.ShippingInfoAddress2Placeholder" = "Address"; +"CheckoutInfo.ShippingInfoCity" = "City"; +"CheckoutInfo.ShippingInfoCityPlaceholder" = "City"; +"CheckoutInfo.ShippingInfoCountry" = "Country"; +"CheckoutInfo.ShippingInfoCountryPlaceholder" = "Country"; +"CheckoutInfo.ShippingInfoPostcode" = "Postcode"; +"CheckoutInfo.ShippingInfoPostcodePlaceholder" = "Postcode"; +"CheckoutInfo.ShippingInfoState" = "State"; +"CheckoutInfo.ShippingInfoStatePlaceholder" = "State"; +"CheckoutInfo.ShippingInfoTitle" = "SHIPPING ADDRESS"; +"CheckoutInfo.Title" = "Shipping Information"; +"ChoosePeer.Requirements.Channel.AdminRights" = "• You must have the admin rights to %1$@"; +"ChoosePeer.Requirements.Channel.Create" = "Create a New Channel for This"; +"ChoosePeer.Requirements.Channel.Empty" = "You don't have channels that meet the following requirements:"; +"ChoosePeer.Requirements.Channel.NonOwner" = "• You must not be the owner of the channel."; +"ChoosePeer.Requirements.Channel.Owner" = "• You must be the owner of the channel."; +"ChoosePeer.Requirements.Channel.Private" = "• The channel must be private."; +"ChoosePeer.Requirements.Channel.Public" = "• The channel must be public."; +"ChoosePeer.Requirements.Group.AdminRights" = "• You must have the admin rights to %1$@"; +"ChoosePeer.Requirements.Group.Create" = "Create a New Group for This"; +"ChoosePeer.Requirements.Group.Empty" = "You don't have groups that meet the following requirements:"; +"ChoosePeer.Requirements.Group.Forum" = "• The group must have topics turned on."; +"ChoosePeer.Requirements.Group.NonForum" = "• The group must have topics turned off."; +"ChoosePeer.Requirements.Group.NonOwner" = "• You must not be the owner of the group."; +"ChoosePeer.Requirements.Group.Owner" = "• You must be the owner of the group."; +"ChoosePeer.Requirements.Group.Private" = "• The group must be private."; +"ChoosePeer.Requirements.Group.Public" = "• The group must be public."; +"ChoosePeer.Requirements.Title" = "Requirements:"; +"ChoosePeer.Requirements.User.NonPremium" = "• The user must not have a Premium subscription."; +"ChoosePeer.Requirements.User.Premium" = "• The user must have a Premium subscription."; +"ChoosePeer.Title.Bot" = "Choose a Bot"; +"ChoosePeer.Title.Bot.Multiple" = "Choose Bots"; +"ChoosePeer.Title.Channel" = "Choose a Channel"; +"ChoosePeer.Title.Group" = "Choose a Group"; +"ChoosePeer.Title.User" = "Choose a User"; +"ChoosePeer.Title.User.Multiple" = "Choose Users"; +"CollectibleItemInfo.ButtonOpenInfo" = "Learn More"; +"CollectibleItemInfo.PhoneText" = "The phone number **%1$@** was acquired on\nFragment on %2$@ for %3$@ **%4$@** (~%5$@).\n\n[Copy Link]()"; +"CollectibleItemInfo.PhoneTitle" = "[%@]() is a collectible phone number\nthat belongs to"; +"CollectibleItemInfo.UsernameText" = "The **@%1$@** username was acquired on\nFragment on %2$@ for %3$@ **%4$@** (~%5$@).\n\n[Copy Link]()"; +"CollectibleItemInfo.UsernameTitle" = "[%@]() is a collectible username\nthat belongs to"; +"Common.Restart" = "Restart"; +"Compose.Confirm.StartSecretChat" = "Start a secret chat with %@?"; +"Compose.Create" = "Create"; +"Compose.CreateGroup.LimitError" = "You will be able to add more members after you create the group."; +"Compose.Next" = "Next"; +"Compose.Popover.NewChannel" = "New Channel"; +"Compose.Popover.NewGroup" = "New Group"; +"Compose.Popover.NewSecretChat" = "New Secret Chat"; +"Compose.Select.SecretChat" = "Secret Chat"; +"Compose.SelectGroupUsers.Placeholder" = "Who would you like to add?"; +"Compose.SelectUsers" = "Select users"; +"Confirm.AddBotToGroup" = "Add the bot to \"%@\"?"; +"Confirm.Delete" = "Delete"; +"Confirm.DeleteAdminedChannel" = "Deleting this channel will remove all subscribers and all posts will be lost. \n\nDelete the channel anyway?"; +"Confirm.DeleteChatUser" = "Are you sure you want to delete all message history?\n\nThis action cannot be undone."; +"Confirm.Desc.PermissionInlineBotContact" = "The bot will know your phone number. This can be useful for integration with other services.\n\n⚠️ Warning! Never enter your Telegram login codes in mini apps."; +"Confirm.Header.PermissionInlineBotContact" = "Share Your Phone Number?"; +"Confirm.LeaveGroup" = "Are you sure you want to leave this group?"; +"ConnectingStatus.DisableProxy" = "click here to disable proxy"; +"ConnectingStatus.connecting" = "connecting"; +"ConnectingStatus.connectingToProxy" = "connecting to proxy"; +"ConnectingStatus.online" = "online"; +"ConnectingStatus.updating" = "updating"; +"ConnectingStatus.waitingNetwork" = "waiting for network"; +"ConnectionStatus.Connected" = "Connected"; +"ConnectionStatus.Connecting" = "Connecting..."; +"ConnectionStatus.ConnectingToProxy" = "Connecting To Proxy..."; +"ConnectionStatus.Updated" = "Updated"; +"ConnectionStatus.Updating" = "Updating..."; +"ConnectionStatus.WaitingForNetwork" = "Waiting For Network..."; +"ContactInfo.BirthdayLabel" = "birthday"; +"ContactInfo.ContactInfo" = "Contact Info"; +"ContactInfo.Job" = "job"; +"ContactInfo.PhoneLabelHome" = "home"; +"ContactInfo.PhoneLabelHomeFax" = "home fax"; +"ContactInfo.PhoneLabelMain" = "main"; +"ContactInfo.PhoneLabelMobile" = "mobile"; +"ContactInfo.PhoneLabelOther" = "other"; +"ContactInfo.PhoneLabelPager" = "pager"; +"ContactInfo.PhoneLabelWork" = "work"; +"ContactInfo.PhoneLabelWorkFax" = "work fax"; +"ContactInfo.Title" = "title"; +"ContactInfo.URLLabelHomepage" = "homepage"; +"Contacts.AddContact" = "Add Contact"; +"Contacts.ContacsSeparator" = "Contacts"; +"Contacts.FirstName.Placeholder" = "First Name"; +"Contacts.LastName.Placeholder" = "Last Name"; +"Contacts.NotRegistredDescription" = "This person is not registered on Telegram yet.\n\nYou will be able to send them a Telegram message as soon as they sign up."; +"Contacts.NotRegistredTitle" = "Not a Telegram User"; +"Contacts.PhoneNumber.Invalid" = "phone number can't be empty"; +"Contacts.PhoneNumber.NotRegistred" = "The person with this phone number is not registered on Telegram yet."; +"Contacts.PhoneNumber.Placeholder" = "Phone Number"; +"Contacts.Sort.ByLastSeen" = "by Last Seen"; +"Contacts.Sort.ByName" = "by Name"; +"Contacts.Sort.Title" = "Sort"; +"Context.Alert.Copied" = "Copied to Clipboard"; +"Context.Alert.CopyPrivate" = "This link will only work for members of this chat"; +"Context.Copy" = "Copy"; +"Context.CopyMedia" = "Save as..."; +"Context.CopyPaidMedia.Restricted" = "Sorry, you can't copy paid media"; +"Context.CopyToClipboard" = "Copy to Clipboard"; +"Context.Menu.Search" = "Search"; +"Context.OpenInQuickLook" = "Open in Quick Look"; +"Context.RecentGifRemove" = "Remove"; +"Context.Remove" = "Remove"; +"Context.RemoveFaveSticker" = "Remove"; +"Context.SaveMedia" = "Save As..."; +"Context.ShowInFinder" = "Show In Finder"; +"Context.ViewEmojiSet" = "View Emoji Set"; +"Context.ViewStickerSet" = "View Sticker Set"; +"Conversation.FreeTranscriptionCooldownTooltip#one" = "You have used all your **%d** free transcription this week."; +"Conversation.FreeTranscriptionCooldownTooltip#other" = "You have used all your **%d** free transcriptions this week."; +"Conversation.FreeTranscriptionCooldownTooltip_countable" = "%d"; +"Conversation.FreeTranscriptionLimitTooltip#one" = "You have **%d** free voice transcription left this week."; +"Conversation.FreeTranscriptionLimitTooltip#other" = "You have **%d** free voice transcriptions left this week."; +"Conversation.FreeTranscriptionLimitTooltip_countable" = "%d"; +"Conversation.FreeTranscriptionWaitOrSubscribe" = "Wait until **%@** to use it again or subscribe to [Telegram Premium]() now."; +"Conversation.PinnedGiveaway" = "Giveaway"; +"Conversation.PinnedGiveaway.Finished" = "%1$@ on %2$@"; +"Conversation.PinnedGiveaway.Finished.Winners#one" = "%@ winner was selected"; +"Conversation.PinnedGiveaway.Finished.Winners#other" = "%@ winners were selected"; +"Conversation.PinnedGiveaway.Finished.Winners_countable" = "%d"; +"Conversation.PinnedGiveaway.Ongoing" = "%1$@ on %2$@"; +"Conversation.PinnedGiveaway.Ongoing.Winners#one" = "%d winner to be selected"; +"Conversation.PinnedGiveaway.Ongoing.Winners#other" = "%d winners to be selected"; +"Conversation.PinnedGiveaway.Ongoing.Winners_countable" = "%d"; +"Conversation.ShareBotContactConfirmation" = "%@ will know your phone number. This can be useful for integration with other services."; +"Conversation.ShareBotContactConfirmation.Unblock" = "%@ will know your phone number. This can be useful for integration with other services.\n\nThis will also unblock the bot."; +"Conversation.ShareBotContactConfirmationOK" = "Share"; +"Conversation.ShareBotContactConfirmationTitle" = "Share Your Phone Number?"; +"ConvertToSuperGroup.Confirm" = "Are you sure? This action cannot be undone."; +"ConvertToSupergroup.Alert.Error" = "Something went wrong, sorry. Please try again later."; +"CopyRestricted.Channel" = "Sorry, copying from this channel is disabled by its admins."; +"CopyRestricted.Chat" = "Sorry, copying from this chat is disabled."; +"CopyRestricted.Group" = "Sorry, copying from this group is disabled by its admins."; +"CrashOnLaunch.Cancel" = "Cancel"; +"CrashOnLaunch.Information" = "If Telegram keeps crashing immediately after you open it, click OK to log out of the app. This should solve this issue."; +"CrashOnLaunch.Message" = "Something's not right."; +"CrashOnLaunch.OK" = "Log out"; +"Create.ChannelsTooMuch" = "Sorry, you are a member of too many groups and channels. Please leave some before creating a new one."; +"CreateBot.BotNameHeader" = "BOT NAME"; +"CreateBot.BotNamePlaceholder" = "Bot Name"; +"CreateBot.BotUsernameHeader" = "BOT USERNAME"; +"CreateBot.BotUsernamePlaceholder" = "@username"; +"CreateBot.Checking" = "Checking..."; +"CreateBot.Create" = "Create"; +"CreateBot.Description" = "**%1$@** would like to create and manage a chatbot on your behalf."; +"CreateBot.LimitReached" = "You have reached the limit of %1$d bots. Subscribe to Telegram Premium to create up to %2$d bots."; +"CreateBot.LimitReachedPremium" = "You have reached the maximum limit of %1$d bots."; +"CreateBot.SuccessInfo" = "%1$@ will manage this bot for you."; +"CreateBot.SuccessTitle" = "%1$@ created!"; +"CreateBot.Title" = "Create Bot"; +"CreateBot.UsernameEndBot" = "Sorry, the username must end in 'bot'."; +"CreateBot.UsernameHint" = "Choose a username for your bot. It must end in 'bot'. Like this, for example: TetrisBot or tetris_bot."; +"CreateChannel.Forum.Error" = "Topics must be enabled."; +"CreateChannel.TooManyError.Title" = "Sorry, you have reserved too many public usernames. You can revoke the link from one of your older groups or channels."; +"CreateChannel.Username.Error" = "The channel must be public. Please choose a username."; +"CreateGroup.AddMember.Error" = "You can't create a group without members."; +"CreateGroup.NameHolder" = "Group Name"; +"CreateGroup.Requires.Username.Header" = "SET A PUBLIC LINK"; +"CreateGroup.Requires.Username.Info" = "You can use a-z, 0-9 and underscores. Minimum length is 5 characters."; +"CreateGroup.Requires.Username.Placeholder" = "link"; +"CreateGroup.Username.Error" = "The group must be public. Please choose a username."; +"CustomAvatar.EnterSymbol" = "Enter Symbols"; +"CustomAvatar.EnterSymbol.Info" = "Maximum length is 2 symbols"; +"CustomAvatar.Title.Background" = "Background"; +"CustomAvatar.Title.Emoji" = "Emoji"; +"CustomAvatar.Title.Monogram" = "Monogram"; +"CustomAvatar.Title.Sticker" = "Sticker"; +"CustomAvatar.Title.Text" = "Text"; +"CustomEmoji.Info" = "Artists are welcome to add their own emoji sets using our [@stickers]() bot."; +"CustomEmoji.PremiumAlert" = "Subscribe to Telegram Premium to unlock this emoji. [Read More]()"; +"CustomEmoji.Suggest" = "Suggest Animated Emoji"; +"CustomReaction.PremiumAlert" = "Subscribe to Telegram Premium to unlock this reaction. [Read More]()"; +"CustomStatus.Expires" = "YOUR STATUS EXPIRES IN %@"; +"CustomStatus.Expires.Promo" = "RIGHT CLICK TO SET A TIMER"; +"CustomStatus.Menu.Timer" = "Set for %@"; +"CustomStatus.ReportSpam" = "This account uses 🤡 as a custom status next to its name. Such emoji statuses are available to all subscribers of [Telegram Premium]()."; +"Customize.Name.Title" = "Name"; +"Customize.Profile.Title" = "Profile"; +"DarkMode.Confirm.NightMode.Header" = "Night Mode"; +"DarkMode.Confirm.NightMode.OK" = "Disable"; +"DarkMode.Confirm.NightMode.Text" = "You have enabled auto-night mode. If you want to change dark mode settings, you'll need to disable it first."; +"DataAndStorage.AutomaticAudioDownload.Header" = "AUTOMATIC AUDIO DOWNLOAD"; +"DataAndStorage.AutomaticDownload" = "Auto-Download Media"; +"DataAndStorage.AutomaticDownload.Files" = "Files"; +"DataAndStorage.AutomaticDownload.GIFs" = "GIFs"; +"DataAndStorage.AutomaticDownload.GroupsChannels" = "Groups and Channels"; +"DataAndStorage.AutomaticDownload.Header" = "AUTOMATIC MEDIA DOWNLOAD"; +"DataAndStorage.AutomaticDownload.InstantVideo" = "Video Messages"; +"DataAndStorage.AutomaticDownload.Photo" = "Photos"; +"DataAndStorage.AutomaticDownload.Reset" = "Reset Auto-Download Settings"; +"DataAndStorage.AutomaticDownload.Video" = "Videos"; +"DataAndStorage.AutomaticDownload.Voice" = "Voice Messages"; +"DataAndStorage.AutomaticPhotoDownload.Header" = "AUTOMATIC PHOTO DOWNLOAD"; +"DataAndStorage.AutomaticVideoDownload.Header" = "AUTOMATIC VIDEO DOWNLOAD"; +"DataAndStorage.Autoplay.GIFs" = "GIFs"; +"DataAndStorage.Autoplay.Header" = "AUTOPLAY MEDIA"; +"DataAndStorage.Autoplay.SoundOnHover" = "Sound on Hover"; +"DataAndStorage.Autoplay.SoundOnHover.Desc" = "Sound will start playing when you move your cursor over a video."; +"DataAndStorage.Autoplay.Videos" = "Videos"; +"DataAndStorage.Category.PreloadLargeVideos" = "Preload Larger Videos"; +"DataAndStorage.Category.PreloadLargeVideosDesc" = "Preload first few seconds (1-2 MB) of videos large than %@ MB for instant playback."; +"DataAndStorage.CategorySettings.Channels" = "Channels"; +"DataAndStorage.CategorySettings.GroupChats" = "Group Chats"; +"DataAndStorage.CategorySettings.PrivateChats" = "Private Chats"; +"DataAndStorage.CateroryFileSize.Unlimited" = "Unlimited"; +"DataAndStorage.CateroryFileSizeLimit.Header" = "LIMIT BY SIZE"; +"DataAndStorage.Confirm.ResetSettings" = "Undo all custom auto-download settings."; +"DataAndStorage.DownloadFolder" = "Download Folder"; +"DataAndStorage.NetworkUsage" = "Data Usage"; +"DataAndStorage.SensitiveContent" = "Show 18+ Content"; +"DataAndStorage.SensitiveContent.Confirm.Header" = "18+ Content"; +"DataAndStorage.SensitiveContent.Confirm.Ok" = "Confirm"; +"DataAndStorage.SensitiveContent.Confirm.Text" = "Confirm that you are over 18 years old and update your settings to see potentially explicit and sensitive content."; +"DataAndStorage.SensitiveContent.Info" = "Do not hide media that contains content suitable only for adults."; +"DataAndStorage.StorageUsage" = "Storage Usage"; +"DataAndStorage.VerifyAgain.Error" = "You will be able to verify yourself again %@"; +"Date.Today" = "Today"; +"DateSelector.Schedule.Repeat" = "Repeat"; +"DateSelector.Schedule.Repeat.EveryDay" = "Every Day"; +"DateSelector.Schedule.Repeat.EveryMonth" = "Every Month"; +"DateSelector.Schedule.Repeat.EverySixMonths" = "Every Six Months"; +"DateSelector.Schedule.Repeat.EveryThreeMonths" = "Every Three Months"; +"DateSelector.Schedule.Repeat.EveryTwoWeeks" = "Every Two Weeks"; +"DateSelector.Schedule.Repeat.EveryWeek" = "Every Week"; +"DateSelector.Schedule.Repeat.EveryYear" = "Every Year"; +"DateSelector.Schedule.Repeat.Never" = "Never"; +"DateSelector.Schedule.Repeat.PremiumInfo" = "Subscribe to Telegram Premium to schedule repeating messages. [Read More]()"; +"DeclineSuggestPost.Action.Reject" = "Reject"; +"DeclineSuggestPost.Description" = "You can leave an optional comment"; +"DeclineSuggestPost.Placeholder.Comment" = "Comment (Optional)"; +"DeclineSuggestPost.Title" = "Reject Suggested Post"; +"DefaultSoundName" = "Default"; +"DeleteChat.DeleteGroupForAll" = "Delete for all members"; +"DeleteGroupMessages.DeleteAllFromPeer" = "Delete all from %1$@"; +"DeleteGroupMessages.DeleteAllReactions" = "Delete All Reactions"; +"DeleteGroupMessages.DeleteReaction" = "Delete Reaction"; +"DeleteGroupMessages.ReactionTitle" = "Delete 1 Reaction"; +"Deprecated.Alert.Text" = "We are no longer updating the app for your system version. Please update your system to receive the latest Telegram updates."; +"Deprecated.Alert.Third" = "Hide for a month"; +"Deprecated.Alert.Title" = "Update Your System"; +"Deprecated.Text" = "Your version of macOS is no longer supported. Please update your Mac."; +"Deprecated.Title" = "Update macOS"; +"DiceRoll.EmojiStake" = "Emoji Stake"; +"DiceRoll.EmojiStakeDescription" = "An emoji game for users with\u00a0Level 5\u00a0or higher. 💎 earned in the game can only be spent on Telegram gifts and posts."; +"DiceRoll.EnterStakePlaceholder" = "Enter your stake"; +"DiceRoll.MaxStake" = "Maximum stake is %1$@ Grams"; +"DiceRoll.MinStake" = "Minimum stake is %1$@ Grams"; +"DiceRoll.ResultsAndReturns" = "RESULTS AND RETURNS"; +"DiceRoll.SaveAndRoll" = "Save and Roll"; +"DiceRoll.StreakResetInfo" = "A streak resets after 3 %1$@ or a stake change."; +"DiceRoll.YourStake" = "Your Stake: 💎%1$@ [change]()"; +"Discussion.Set.Modal.OK" = "Link Group"; +"Discussion.Set.Modal.Text.ChannelPrivateGroup" = "Do you want to make **%@** the discussion board for **%@**?\n\nAny member of this group will be able to see messages in the channel."; +"Discussion.Set.Modal.Text.Private" = "Make **%@** the discussion group for **%@**?\n\nAll group members will be able to see all messages from the channel."; +"Discussion.Set.Modal.Text.PrivateChannelPublicGroup" = "Do you want to make **%@** the discussion board for **%@**?\n\nAny member of this group will be able to see all messages in the channel."; +"Discussion.Set.Modal.Text.Public" = "Make **%@** the discussion board for **%@**?"; +"Discussion.Set.Modal.Text.PublicChannelPublicGroup" = "Do you want to make **%@** the discussion board for **%@**?"; +"DiscussionController.Channel.Empty.CreateGroup" = "Create a New Group"; +"DiscussionController.Channel.Empty.Description" = "Everything you post in the channel will be forwarded to this group."; +"DiscussionController.Channel.Empty.Header" = "Select a group chat for discussion that will be displayed in your channel."; +"DiscussionController.Channel.Empty.Header1" = "Select a group chat that will host comments from your channel."; +"DiscussionController.Channel.Set.Description" = "Everything you post in the channel will be forwarded to this group."; +"DiscussionController.Channel.Set.Header" = "A link to **%@** is shown to all subscribers in the bottom panel."; +"DiscussionController.Channel.Set.Header1" = "**%@** is selected as the group that hosts comments for your channel."; +"DiscussionController.Channel.Set.UnlinkGroup" = "Unlink Group"; +"DiscussionController.Channel.Title" = "Discussion Group"; +"DiscussionController.Confrim.UnlinkChannel" = "Are you sure you want to unlink the channel from this group?"; +"DiscussionController.Confrim.UnlinkGroup" = "Are you sure you want to unlink the group from this channel?"; +"DiscussionController.Error.OK" = "Proceed"; +"DiscussionController.Error.PreHistory" = "Warning: If you set this private group as the discussion group for your channel, all channel subscribers will be able to access the group. \"Chat history for new members\" will be switched to Visible"; +"DiscussionController.Group.Set.Description" = "All new messages posted in this channel are forwarded to this group."; +"DiscussionController.Group.Set.Header" = "This group is linked as the discussion board for %@."; +"DiscussionController.Group.Set.UnlinkChannel" = "Unlink Channel"; +"DiscussionController.Group.Title" = "Linked Channel"; +"DiscussionController.Group.Unset.Description" = "Channel successfully unlinked."; +"DiscussionController.IconText" = "Discuss"; +"DiscussionController.PrivateChannel" = "private channel"; +"DiscussionController.PrivateGroup" = "private group"; +"DisplayMeAs.Alone" = "You will be displayed as your personal account."; +"DisplayMeAs.Alone.Desc" = "You can also create a public channel to participate in video chats as a channel."; +"DisplayMeAs.ContinueAs" = "Continue as %@"; +"DisplayMeAs.Modern.Schedule" = "Schedule Video Chat"; +"DisplayMeAs.Modern.Start" = "Start Video Chat"; +"DisplayMeAs.New.ScheduleAs" = "Schedule Video Chat as %@"; +"DisplayMeAs.New.StartAs" = "Start Video Chat as %@"; +"DisplayMeAs.New.Title" = "New Video Chat"; +"DisplayMeAs.PersonalAccount" = "personal account"; +"DisplayMeAs.Scheduled" = "Scheduled Video Chat"; +"DisplayMeAs.Scheduled.Desc" = "Subscribers of the channel will be notified that the live stream starts in %@."; +"DisplayMeAs.StartWith" = "Start With..."; +"DisplayMeAs.Text" = "Choose whether you want to be displayed as your personal account or as your channel."; +"DisplayMeAs.Text.Group" = "Choose whether you want to be displayed as your personal account or as your group."; +"DisplayMeAs.Title" = "Display Me As"; +"DownloadsManager.Control.Navigate" = "%@ - [View in Chat]()"; +"DownloadsManager.Control.Title#one" = "%d File"; +"DownloadsManager.Control.Title#other" = "%d Files"; +"DownloadsManager.Control.Title_countable" = "%d"; +"DownloadsManager.Recently" = "RECENTLY DOWNLOADED"; +"DownloadsManager.Recently.Clear" = "clear"; +"Dv1-io-Yv7.title" = "Spelling and Grammar"; +"Edit.Message.EditCurrentPhoto" = "Edit"; +"Edit.PostSuggestion.Action.Free" = "Offer for Free"; +"Edit.PostSuggestion.Action.Offer" = "Offer %1$@"; +"Edit.PostSuggestion.Action.Update" = "Update Terms"; +"Edit.PostSuggestion.Currency.Stars" = "Stars"; +"Edit.PostSuggestion.Currency.Ton" = "Grams"; +"Edit.PostSuggestion.Date.Done" = "OK"; +"Edit.PostSuggestion.Date.Reset" = "Reset to Anytime"; +"Edit.PostSuggestion.Date.Title" = "Select Date"; +"Edit.PostSuggestion.Input.Placeholder" = "Enter amount"; +"Edit.PostSuggestion.Price.Description" = "Choose how many Stars you want to offer %1$@ to publish this post."; +"Edit.PostSuggestion.Price.Label" = "ENTER PRICE IN STARS"; +"Edit.PostSuggestion.Price.Ton.Description" = "Choose how many Grams you want to offer %1$@ to publish this message."; +"Edit.PostSuggestion.Price.Ton.Label" = "ENTER PRICE IN GRAMS"; +"Edit.PostSuggestion.Time.Anytime" = "Anytime"; +"Edit.PostSuggestion.Time.Description" = "Select the date and time you want the post to be published."; +"Edit.PostSuggestion.Time.Label" = "Time"; +"Edit.PostSuggestion.Title.Edit" = "Edit Terms"; +"Edit.PostSuggestion.Title.New" = "Suggest Terms"; +"Edit.PostSuggestion.Title.Suggest" = "Suggest Changes"; +"EditAccount.AddAccount" = "Add Account"; +"EditAccount.Birthday.Add" = "Add"; +"EditAccount.Birthday.Date" = "Birthday"; +"EditAccount.Birthday.Info.Everyone" = "Everyone will see your birthday. [Change >]()"; +"EditAccount.Birthday.Info.Nobody" = "Nobody will see your birthday. [Change >]()"; +"EditAccount.Birthday.Info.OnlyContacts" = "Only your contacts will see your birthday. [Change >]()"; +"EditAccount.Birthday.Remove" = "Remove"; +"EditAccount.ChangeNumber" = "Change Number"; +"EditAccount.Logout" = "Log Out"; +"EditAccount.NameDesc" = "Enter your name and add a profile photo."; +"EditAccount.PersonalChannel" = "Personal Channel"; +"EditAccount.PersonalChannel.Add" = "Add"; +"EditAccount.Title" = "Edit Profile"; +"EditAccount.Username" = "Username"; +"EditAccountInfo.ChatAutomation" = "Chat Automation"; +"EditAvatar.Customize" = "Sticker or Emoji"; +"EditAvatar.PhotoOrVideo" = "Photo or Video"; +"EditAvatar.StickerOrGif" = "Sticker or GIF"; +"EditImageControl.Confirm.Discard" = "Are you sure you want to discard all changes?"; +"EditImageControl.Reset" = "RESET"; +"EditInvitation.EditTitle" = "Edit Link"; +"EditInvitation.EnterNumber" = "Enter Number"; +"EditInvitation.ExpiryDate" = "Expiry Date"; +"EditInvitation.ExpiryDesc" = "You can make the link expire after a certain time."; +"EditInvitation.LimitDesc" = "You can make the link work only for a certain number of users."; +"EditInvitation.LimitedByCount" = "LIMIT BY NUMBER OF USERS"; +"EditInvitation.LimitedByPeriod" = "LIMIT BY TIME PERIOD"; +"EditInvitation.Never" = "Never"; +"EditInvitation.NewTitle" = "New Link"; +"EditInvitation.NumberOfUsers" = "Number of Users"; +"EditInvitation.OK.Create" = "Create"; +"EditInvitation.OK.Save" = "Save"; +"EditInvitation.RequestApproval" = "Request Admin Approval"; +"EditInvitation.RequestApproval.Channel.Off" = "Turn this on if you want users to join only after they are approved by an admin."; +"EditInvitation.RequestApproval.Channel.On" = "Turn this on if you want users to join only after they are approved by an admin."; +"EditInvitation.RequestApproval.Group.Off" = "Turn this on if you want users to join only after they are approved by an admin."; +"EditInvitation.RequestApproval.Group.On" = "Turn this on if you want users to join only after they are approved by an admin."; +"EditInvitation.Save" = "Save"; +"EditInvitation.TitleDesc" = "Only admins will see this name."; +"EditInvitation.TitlePlaceholder" = "Link Name (Optional)"; +"EditInvitation.Unlimited" = "Unlimited"; +"EditThame.Name.AlreadyTaken" = "This link is already taken."; +"EditTheme.Edit" = "Save"; +"EditTheme.NamePlaceholder" = "Theme Name"; +"EditTheme.SelectFile" = "Create from File..."; +"EditTheme.SelectFileDesc" = "This theme will be based on your current theme and wallpaper. Otherwise, you can use a custom theme file if you already have one."; +"EditTheme.SelectUpdatedFile" = "Update from File..."; +"EditTheme.SelectUpdatedFileDesc" = "You can update your theme for all users by uploading manual changes from a file."; +"EditTheme.SlugDesc" = "Your theme will be updated for all users each time you change it. Anyone can install it using this link.\n\nTheme links must be longer than 5 characters and can use a-z, 0-9 and underscores."; +"EditTheme.SlugError.AlreadyExists" = "This link is already taken. Please try a different one."; +"EditTheme.SlugError.Format" = "Sorry, invalid format"; +"EditTheme.SlugPlaceholder" = "short link"; +"EditTheme.Title" = "Edit Theme"; +"Email.CodeExpiredError" = "Code expired, please start over."; +"Email.CodeFloodError" = "Too many attempts, please try again later."; +"Email.EmailNotAllowedError" = "Sorry, this email can't be used."; +"Email.Enter.Placeholder" = "Enter your email"; +"Email.InvalidCodeError" = "Invalid code, please try again."; +"Email.InvalidEmailAddressError" = "An error occurred. Please try again."; +"Email.InvalidEmailError" = "Please enter a valid email address."; +"Email.InvalidEmailTokenError" = "An error occurred. Please try again."; +"Email.NetworkError" = "Please check your internet connection and try again."; +"Email.UnknownError" = "An error occurred, please try again later."; +"Emoji.ActivityAndSport" = "Activity & Sport"; +"Emoji.AnimalsAndNature" = "Animals & Nature"; +"Emoji.Collectibles" = "COLLECTIBLES"; +"Emoji.Context.Remove" = "Remove"; +"Emoji.Context.SetStatus" = "Set as Status"; +"Emoji.Context.SetStatus.Success" = "Your emoji status has been updated."; +"Emoji.Flags" = "Flags"; +"Emoji.FoodAndDrink" = "Food & Drink"; +"Emoji.Objects" = "Objects"; +"Emoji.Recent" = "Frequently Used"; +"Emoji.RecentNew" = "Frequently Used Emoji"; +"Emoji.Search.AnimatedEmoji" = "ANIMATED EMOJI"; +"Emoji.Search.Emoji" = "EMOJI"; +"Emoji.SmilesAndPeople" = "Emoji & People"; +"Emoji.Symbols" = "Symbols"; +"Emoji.TravelAndPlaces" = "Travel & Places"; +"EmojiPack.Add#one" = "Add %d Emoji"; +"EmojiPack.Add#other" = "Add %d Emoji"; +"EmojiPack.Add_countable" = "%d"; +"EmojiPack.Added" = "ADDED"; +"EmojiPack.Added.Info" = "%@ has been added to your emoji."; +"EmojiPack.Added.Title" = "Emoji Added"; +"EmojiPack.Counts#one" = "%d Emoji"; +"EmojiPack.Counts#other" = "%d Emoji"; +"EmojiPack.Counts_countable" = "%d"; +"EmojiPack.MoreEmoji" = "Hover mouse here to choose more emoji"; +"EmojiPack.PremiumAlert" = "Subscribe to Telegram Premium to unlock this emoji. [Read More]()"; +"EmojiPack.Removed.Info" = "%@ is no longer in your emoji."; +"EmojiPack.Removed.Title" = "Emoji Removed"; +"EmojiPack.Restore" = "RESTORE"; +"EmojiPack.Sets.Add#one" = "Add %d Emoji Pack"; +"EmojiPack.Sets.Add#other" = "Add %d Emoji Pack"; +"EmojiPack.Sets.Add_countable" = "%d"; +"EmojiPack.Unlock" = "UNLOCK"; +"EmojiSection.GroupEmoji" = "GROUP EMOJI"; +"Emojies.MessageEffects.Title" = "MESSAGE EFFECTS"; +"EmptyChat.AppIcon" = "Dock Icon"; +"EmptyChat.AppIcon.Desc" = "Find more app icons and theme options in Settings > [Appearance](appearance)."; +"EmptyChat.AppIcon.Shuffle" = "Application Icons"; +"EmptyChat.Appearance" = "Appearance"; +"EmptyChat.Appearance.ChatMode" = "Chat Mode"; +"EmptyChat.Appearance.Colorful" = "Colorful"; +"EmptyChat.Appearance.Dark" = "Dark"; +"EmptyChat.Appearance.Desc" = "You can change these parameters and many others in Settings > [Appearance](appearance)."; +"EmptyChat.Appearance.Light" = "Light"; +"EmptyChat.Appearance.Min" = "Minimalist"; +"EmptyChat.Appearance.System" = "System"; +"EmptyChat.Navigation.Next" = "Next Tip"; +"EmptyChat.Navigation.Prev" = "Previous Tip"; +"EmptyChat.Stickers" = "Suggest Stickers By Emoji"; +"EmptyChat.Stickers.AllSets" = "All Sets"; +"EmptyChat.Stickers.MySets" = "My Sets"; +"EmptyChat.Stickers.None" = "None"; +"EmptyChat.Stickers.Trending" = "Trending Stickers"; +"EmptyChat.StorageUsage" = "Storage Usage"; +"EmptyChat.StorageUsage.CacheDesc" = "Telegram uses **%@** of your storage."; +"EmptyChat.StorageUsage.CacheDescEmpty" = "Telegram cache is empty."; +"EmptyChat.StorageUsage.Clear" = "Clear Cache"; +"EmptyChat.StorageUsage.Clearing" = "Clearing..."; +"EmptyChat.StorageUsage.Data" = "Maximum Cache Size"; +"EmptyChat.StorageUsage.Loading" = "Calculating..."; +"EmptyChat.StorageUsage.Low" = "8 GB"; +"EmptyChat.StorageUsage.Medium" = "32 GB"; +"EmptyChat.StorageUsage.NoLimit" = "No Limit"; +"EmptyChat.StorageUsage.Tooltip.App" = "Telegram\n%@"; +"EmptyChat.StorageUsage.Tooltip.System" = "System\n%@"; +"EmptyGroupInfo.Line1" = "• Up to %@ members"; +"EmptyGroupInfo.Line2" = "• Persistent chat history"; +"EmptyGroupInfo.Line3" = "• Public links such as t.me/title"; +"EmptyGroupInfo.Line4" = "• Admins with different rights"; +"EmptyGroupInfo.Subtitle" = "Groups can have:"; +"EmptyGroupInfo.Title" = "You created a group"; +"EmptyPeer.Description" = "Select a chat to start messaging"; +"EncryptionKey.Description" = "This image and text were derived from the encryption key for this secret chat with **%@**.\n\nIf they look the same on **%@**'s device, end-to-end encryption is guaranteed."; +"Entertainment.Emoji" = "EMOJI"; +"Entertainment.Emoji.New" = "Emoji"; +"Entertainment.GIF" = "GIFs"; +"Entertainment.GIF.New" = "GIFs"; +"Entertainment.Stickers" = "STICKERS"; +"Entertainment.Stickers.New" = "Stickers"; +"Entertainment.Switch.Emoji" = "Emoji"; +"Entertainment.Switch.GifAndStickers" = "Stickers & GIFs"; +"Error.AnError" = "An error occurred. Please try again later."; +"Error.Username.AlreadyTaken" = "This username is already taken."; +"Error.Username.Invalid" = "This username is invalid."; +"Error.Username.MinimumLength" = "Usernames must have at least 5 characters."; +"Error.Username.NumberStart" = "Usernames can't start with a number."; +"Error.Username.UnderscopeEnd" = "A username can't end with an underscore."; +"Error.Username.UnderscopeStart" = "A username can't start with an underscore."; +"EventLog.Service.AddedMedia" = "%@ added media:"; +"EventLog.Service.And" = "%1$@ and %2$@"; +"EventLog.Service.Banned" = "Banned %@ %@"; +"EventLog.Service.Banned1" = "Banned %1$@ %2$@"; +"EventLog.Service.ChangedDefaultsRights" = "changed default permissions"; +"EventLog.Service.ChangedStickerSet" = "%@ changed group sticker set"; +"EventLog.Service.DeletedMessage" = "%@ deleted message:"; +"EventLog.Service.DeletedMessageMultiple.Hide#one" = "%1$@ deleted %2$d message from %3$@ [hide all](s)"; +"EventLog.Service.DeletedMessageMultiple.Hide#other" = "%1$@ deleted %2$d messages from %3$@ [hide all](s)"; +"EventLog.Service.DeletedMessageMultiple.Hide_countable" = "%1$@ %2$d %3$@"; +"EventLog.Service.DeletedMessageMultiple.Show#one" = "%1$@ deleted %2$d message from %3$@ [show all](s)"; +"EventLog.Service.DeletedMessageMultiple.Show#other" = "%1$@ deleted %2$d messages from %3$@ [show all](s)"; +"EventLog.Service.DeletedMessageMultiple.Show_countable" = "%1$@ %2$d %3$@"; +"EventLog.Service.Demote.AddMembers" = "Add Members"; +"EventLog.Service.Demote.ChangeInfo" = "Change Info"; +"EventLog.Service.Demote.EmbedLinks" = "Embed Links"; +"EventLog.Service.Demote.PinMessages" = "Pin Messages"; +"EventLog.Service.Demote.PostPolls" = "Post Polls"; +"EventLog.Service.Demote.SendGifs" = "Send GIFs"; +"EventLog.Service.Demote.SendInline" = "Send Inline"; +"EventLog.Service.Demote.SendMedia" = "Send Media"; +"EventLog.Service.Demote.SendMessages" = "Send Messages"; +"EventLog.Service.Demote.SendStickers" = "Send Stickers"; +"EventLog.Service.Demoted" = "restricted %@ %@ indefinitely"; +"EventLog.Service.Demoted.Changed" = "changed the restrictions for %@ %@ indefinitely"; +"EventLog.Service.Demoted.Changed.Until" = "changed restrictions for %@ %@ until %@"; +"EventLog.Service.Demoted.Changed.Until1" = "changed restrictions for %1$@ %2$@ until %3$@"; +"EventLog.Service.Demoted.Changed1" = "changed the restrictions for %1$@ %2$@ indefinitely"; +"EventLog.Service.Demoted.Until" = "restricted %@ %@ until %@"; +"EventLog.Service.Demoted.Until1" = "restricted %1$@ %2$@ until %3$@"; +"EventLog.Service.Demoted1" = "restricted %1$@ %2$@ indefinitely"; +"EventLog.Service.EditedCaption" = "%@ edited caption:"; +"EventLog.Service.EditedMedia" = "%@ edited media:"; +"EventLog.Service.EditedMessage" = "%@ edited message:"; +"EventLog.Service.PostMessage" = "%@ sent message:"; +"EventLog.Service.PreviousDesc" = "Previous Description"; +"EventLog.Service.PreviousLink" = "Previous Link"; +"EventLog.Service.PreviousTitle" = "Previous Title"; +"EventLog.Service.Promote.AddNewAdmins" = "Add New Admins"; +"EventLog.Service.Promote.AddUsers" = "Add Users"; +"EventLog.Service.Promote.BanUsers" = "Ban Users"; +"EventLog.Service.Promote.ChangeInfo" = "Change Info"; +"EventLog.Service.Promote.DeleteMessages" = "Delete Messages"; +"EventLog.Service.Promote.EditMessages" = "Edit Messages"; +"EventLog.Service.Promote.InviteViaLink" = "Invite Users Via Link"; +"EventLog.Service.Promote.ManageDirect" = "Manage Direct Messages"; +"EventLog.Service.Promote.PinMessages" = "Pin Messages"; +"EventLog.Service.Promote.PostMessages" = "Post Messages"; +"EventLog.Service.Promote.RemainAnonymous" = "Remain Anonymous"; +"EventLog.Service.Promoted" = "promoted %@ %@:"; +"EventLog.Service.Promoted.Changed" = "changed privileges for %@ %@:"; +"EventLog.Service.Promoted.Changed1" = "changed privileges for %1$@ %2$@:"; +"EventLog.Service.Promoted1" = "promoted %1$@ %2$@:"; +"EventLog.Service.RemovePinned" = "%@ unpinned message"; +"EventLog.Service.RemovedStickerSet" = "%@ removed group sticker set"; +"EventLog.Service.ShowMore#one" = "Show %d More Message"; +"EventLog.Service.ShowMore#other" = "Show %d More Messages"; +"EventLog.Service.ShowMore_countable" = "%d"; +"EventLog.Service.UpdatePinned" = "%@ pinned message:"; +"ExampleApps.Title" = "Examples"; +"ExportedInvitation.Context.Copy" = "Copy"; +"ExportedInvitation.Context.OpenProfile" = "Open Profile"; +"ExportedInvitation.Done" = "Done"; +"ExportedInvitation.JoinedViaFolder" = "joined via folder invite link"; +"ExportedInvitation.LinkCreatedBy" = "LINK CREATED BY"; +"ExportedInvitation.PeopleJoined#one" = "%d PERSON JOINED"; +"ExportedInvitation.PeopleJoined#other" = "%d PEOPLE JOINED"; +"ExportedInvitation.PeopleJoined_countable" = "%d"; +"ExportedInvitation.PeopleRequested#one" = "%d JOIN REQUEST"; +"ExportedInvitation.PeopleRequested#other" = "%d JOIN REQUESTS"; +"ExportedInvitation.PeopleRequested_countable" = "%d"; +"ExportedInvitation.Status.Expired" = "expired"; +"ExportedInvitation.Status.ExpiresIn" = "expires in %@"; +"ExportedInvitation.Status.Revoked" = "revoked"; +"ExportedInvitation.Title" = "Invite Link"; +"FactCheck.Context.Edit" = "Edit Fact Check"; +"FactCheck.Info" = "This clarification was provided by Telegram."; +"FactCheck.Info.Second" = "This clarification was provided by Telegram."; +"FactCheck.Link.OnlyAllowed" = "Only %@ links are allowed"; +"FactCheck.Placeholder" = "Add Fact Check"; +"FactCheck.Save.Delete" = "Fact check is removed"; +"FactCheck.Save.Updated" = "Fact check is updated"; +"FactCheck.ShowFull" = "show"; +"FactCheck.Title" = "Fact Check"; +"FactCheck.WhatThis" = "what's this?"; +"FastSettings.DisableDarkMode" = "Disable Dark Mode"; +"FastSettings.EnableDarkMode" = "Enable Dark Mode"; +"FastSettings.LockTelegram" = "Lock Telegram"; +"FastSettings.Mute2Hours" = "Mute For 2 Hours"; +"FastSettings.SetPasscode" = "Set a Passcode"; +"FastSettings.Unmute" = "Unmute"; +"FileSize.B" = "%@ B"; +"FileSize.GB" = "%@ GB"; +"FileSize.KB" = "%@ KB"; +"FileSize.MB" = "%@ MB"; +"Format.TimeoutShort.day" = "d"; +"Format.TimeoutShort.hour" = "h"; +"Format.TimeoutShort.minute" = "m"; +"FormattedDate.DaysAgo#other" = "%1$d days ago"; +"FormattedDate.HoursAgo#other" = "%1$d hours ago"; +"FormattedDate.InDays#other" = "in %1$d days"; +"FormattedDate.InHours#other" = "in %1$d hours"; +"FormattedDate.InMinutes#other" = "in %1$d minutes"; +"FormattedDate.InSeconds#other" = "in %1$d seconds"; +"FormattedDate.LongDate.m1" = "January %1$@, %2$@"; +"FormattedDate.LongDate.m10" = "October %1$@, %2$@"; +"FormattedDate.LongDate.m11" = "November %1$@, %2$@"; +"FormattedDate.LongDate.m12" = "December %1$@, %2$@"; +"FormattedDate.LongDate.m2" = "February %1$@, %2$@"; +"FormattedDate.LongDate.m3" = "March %1$@, %2$@"; +"FormattedDate.LongDate.m4" = "April %1$@, %2$@"; +"FormattedDate.LongDate.m5" = "May %1$@, %2$@"; +"FormattedDate.LongDate.m6" = "June %1$@, %2$@"; +"FormattedDate.LongDate.m7" = "July %1$@, %2$@"; +"FormattedDate.LongDate.m8" = "August %1$@, %2$@"; +"FormattedDate.LongDate.m9" = "September %1$@, %2$@"; +"FormattedDate.MinutesAgo#other" = "%1$d minutes ago"; +"FormattedDate.SecondsAgo#other" = "%1$d seconds ago"; +"FormattedDate.ShortDate.Separator" = "/"; +"FormattedDate.ShortDate.m1" = "Jan %1$@, %2$@"; +"FormattedDate.ShortDate.m10" = "Oct %1$@, %2$@"; +"FormattedDate.ShortDate.m11" = "Nov %1$@, %2$@"; +"FormattedDate.ShortDate.m12" = "Dec %1$@, %2$@"; +"FormattedDate.ShortDate.m2" = "Feb %1$@, %2$@"; +"FormattedDate.ShortDate.m3" = "Mar %1$@, %2$@"; +"FormattedDate.ShortDate.m4" = "Apr %1$@, %2$@"; +"FormattedDate.ShortDate.m5" = "May %1$@, %2$@"; +"FormattedDate.ShortDate.m6" = "Jun %1$@, %2$@"; +"FormattedDate.ShortDate.m7" = "Jul %1$@, %2$@"; +"FormattedDate.ShortDate.m8" = "Aug %1$@, %2$@"; +"FormattedDate.ShortDate.m9" = "Sep %1$@, %2$@"; +"ForumSettings.DisplayAs" = "DISPLAY AS"; +"ForumSettings.DisplayAs.Info" = "Choose how topics appear for all members."; +"ForumSettings.Enable" = "Enable"; +"ForumSettings.Title" = "Topics"; +"ForumSettings.TopicInfo" = "The group chat will be divided into topics created by admins or users."; +"ForumTopic.Context.AddMember" = "Add Members"; +"ForumTopic.Context.Info" = "Group Info"; +"ForumTopic.Context.New" = "Create Topic"; +"ForumTopic.Context.ShowAsMessages" = "View as Messages"; +"ForumTopic.Done.Edit" = "Done"; +"ForumTopic.Done.New" = "Create"; +"ForumTopic.Edit.General.Info" = "If the 'General' topic is hidden, group members can pull down in the topic list to view it."; +"ForumTopic.Edit.General.Show" = "Show in Topics"; +"ForumTopic.Edit.TopicIcon" = "TOPIC ICON"; +"ForumTopic.Edit.TopicName" = "TOPIC NAME"; +"ForumTopic.Name.Placeholder" = "Topic Name"; +"ForumTopic.New.TopicIcon" = "SELECT TOPIC ICON"; +"ForumTopic.New.TopicName" = "ENTER TOPIC NAME"; +"ForumTopic.Title.Create" = "New Topic"; +"ForumTopic.Title.Edit" = "Edit Topic"; +"Forward.ToSavedMessages" = "Forward messages here for quick access"; +"ForwardModalAction.Title#one" = "Forwarding message"; +"ForwardModalAction.Title#other" = "Forwarding messages"; +"ForwardModalAction.Title_countable" = "%d"; +"ForwardModalAction.description#one" = "Select a user or chat to forward message from %@"; +"ForwardModalAction.description#other" = "Select a user or chat to forward messages from %@"; +"ForwardModalAction.description_countable" = "%d %@"; +"Fragment.AdsInfo.Block.Text" = "Anyone can create an ad to display in this channel — with minimal budgets. Check out the Telegram Ad Platform for details. [Learn More >](https://ads.telegram.org)"; +"Fragment.AdsInfo.Block.Title" = "Can I Launch an Ad?"; +"Fragment.AdsInfo.OK" = "Understood"; +"Fragment.AdsInfo.Option1.Info" = "Ads on Telegram do not use your personal information and are based on the channel in which you see them."; +"Fragment.AdsInfo.Option1.Title" = "Respect Your Privacy"; +"Fragment.AdsInfo.Option2.Info" = "50% of the revenue from Telegram Ads goes to the owner of the channel where they are displayed."; +"Fragment.AdsInfo.Option2.Title" = "Help the Channel Creator"; +"Fragment.AdsInfo.Option3.Info" = "You can turn off ads by subscribing to Telegram Premium, and Level 50 channels can remove them for their subscribers."; +"Fragment.AdsInfo.Option3.Title" = "Can Be Removed"; +"Fragment.AdsInfo.Text" = "Telegram Ads are very different from ads on other platforms. Ads such as this one:"; +"Fragment.AdsInfo.Title" = "About These Ads"; +"Fragment.Star.AmountPlaceholder" = "Stars Amount"; +"Fragment.Star.Withdraw" = "Withdraw"; +"Fragment.Star.WithdrawInput" = "Withdraw %@"; +"Fragment.Star.WithdrawPlaceholder" = "ENTER AMOUNT TO WITHDRAW"; +"Fragment.Stars.AvailableBalance" = "Available Balance"; +"Fragment.Stars.BalanceTitle" = "AVAILABLE BALANCE"; +"Fragment.Stars.MaxWithdraw" = "You cannot withdraw more than %d Stars at once."; +"Fragment.Stars.MinWithdraw" = "You cannot withdraw less than %d Stars."; +"Fragment.Stars.Overvew" = "PROCEEDS OVERVIEW"; +"Fragment.Stars.RevenueTitle" = "REVENUE"; +"Fragment.Stars.ShowMore" = "Show More"; +"Fragment.Stars.Title" = "Stars Balance"; +"Fragment.Stars.TotalCurrent" = "Total Balance"; +"Fragment.Stars.TotalLifetime" = "Lifetime Proceeds"; +"Fragment.Stars.WithdrawInfo" = "Stars from your total balance can be used for ads or withdrawn as rewards 21 days after they are earned."; +"Fragment.Ton.AddFunds.Action" = "Add funds via Fragment"; +"Fragment.Ton.AddFunds.Header.Amount" = "%1$@ Grams Needed"; +"Fragment.Ton.AddFunds.Header.Info" = "You can add funds to your balance via the third-party platform Fragment."; +"Fragment.Ton.AddFunds.Link" = "https://fragment.com/ads/topup"; +"Fragment.Ton.AddFunds.Title" = "Add funds"; +"Fragment.Ton.MaxWithdraw" = "You cannot withdraw more than %d Grams at once."; +"Fragment.Ton.MinWithdraw" = "You cannot withdraw less than %d Grams."; +"FreezeAccount.ClickDetails" = "Click to view details"; +"FreezeAccount.OK" = "Submit an Appeal"; +"FreezeAccount.Option1.Text" = "Your account was frozen for violating Telegram’s Terms of Service."; +"FreezeAccount.Option1.Title" = "Violation of Terms"; +"FreezeAccount.Option2.Text" = "You can access your account but can't send messages or take actions."; +"FreezeAccount.Option2.Title" = "Read-Only Mode"; +"FreezeAccount.Option3.Text" = "Appeal via [@SpamBot](appeal) before %@, or your account will be deleted."; +"FreezeAccount.Option3.Title" = "Appeal Before Deactivation"; +"FreezeAccount.Title" = "Your Account is Frozen"; +"Gallery.Contact.PhotoByYou" = "Only you can see this photo."; +"Gallery.Context.Alert.Downloaded" = "Downloaded. [Show in Finder](finder)"; +"Gallery.Context.Alert.Downloading" = "Downloading. [Show in Downloads](manager)"; +"Gallery.Context.CopyToClipboard" = "Copy Media"; +"Gallery.Context.MainPhoto" = "Set As Main Photo"; +"Gallery.Context.Original" = "Original"; +"Gallery.Context.SaveAs" = "Save As..."; +"Gallery.Context.SaveVideo" = "Save Video"; +"Gallery.Context.Share.AllItems#one" = "All %d Items"; +"Gallery.Context.Share.AllItems#other" = "All %d Items"; +"Gallery.Context.Share.AllItems_countable" = "%d"; +"Gallery.Context.Share.AllPhotos#one" = "All %d photo"; +"Gallery.Context.Share.AllPhotos#other" = "All %d photos"; +"Gallery.Context.Share.AllPhotos_countable" = "%d"; +"Gallery.Context.Share.AllVideos#one" = "All %d Videos"; +"Gallery.Context.Share.AllVideos#other" = "All %d Videos"; +"Gallery.Context.Share.AllVideos_countable" = "%d"; +"Gallery.Context.Share.ThisFile" = "This File"; +"Gallery.Context.Share.ThisPhoto" = "This Photo"; +"Gallery.Context.Share.ThisVideo" = "This Video"; +"Gallery.Context.ShowGallery" = "Shared Media"; +"Gallery.Context.ShowMessage" = "Show Message"; +"Gallery.ContextDeletePhoto" = "Delete"; +"Gallery.HideTranslation" = "Hide Translation"; +"Gallery.PublicPhoto" = "public photo"; +"Gallery.RemoveGif" = "Remove GIF"; +"Gallery.SaveGif" = "Save GIF"; +"Gallery.SendHere" = "Edit & Send"; +"Gallery.Translate" = "Translate"; +"Gallery.WaitDownload.Photo" = "Please wait for the photo to be fully downloaded."; +"Gallery.WaitDownload.Video" = "Please wait for the video to be fully downloaded."; +"GalleryView.FastSave.Gif" = "GIF saved to your [Download]() folder"; +"GalleryView.FastSave.Gif1" = "GIF saved to\n[Downloads]()"; +"GalleryView.FastSave.Image" = "Image saved to your [Download]() folder"; +"GalleryView.FastSave.Image1" = "Image saved to\n[Downloads]()"; +"GalleryView.FastSave.Video" = "Video saved to your [Download]() folder"; +"GalleryView.FastSave.Video1" = "Video saved to\n[Downloads]()"; +"GeneralSettings.AccentColor" = "Accent Color"; +"GeneralSettings.AcceptSecretChats" = "Accept Secret Chats"; +"GeneralSettings.AdvancedHeader" = "ADVANCED"; +"GeneralSettings.AppearanceSettings" = "APPEARANCE SETTINGS"; +"GeneralSettings.AutoplayGifs" = "Autoplay GIFs"; +"GeneralSettings.BigEmoji" = "Large Emoji"; +"GeneralSettings.CallSettings.Header" = "CALL SETTINGS"; +"GeneralSettings.CallSettings.Text" = "Call Settings"; +"GeneralSettings.ChatBackground" = "Chat Wallpaper"; +"GeneralSettings.CopyRTF" = "Copy Text Formatting"; +"GeneralSettings.DarkMode" = "Dark Mode"; +"GeneralSettings.DarkMode.Description" = "A color scheme for nighttime and dark desktops"; +"GeneralSettings.Emoji" = "EMOJI"; +"GeneralSettings.EmojiAndStickers" = "EMOJI & STICKERS"; +"GeneralSettings.EmojiPrediction" = "Suggest Emoji"; +"GeneralSettings.EmojiPrediction.Disable" = "Disable"; +"GeneralSettings.EmojiPrediction.DisableText" = "Disable emoji suggestions? You can re-enable them in Settings at any time."; +"GeneralSettings.EmojiReplacements" = "Replace Emoji Automatically"; +"GeneralSettings.EnableSidebar" = "Show Sticker Sidebar"; +"GeneralSettings.EnergySaving" = "POWER SAVING"; +"GeneralSettings.Font.Description" = "Use large font for messages"; +"GeneralSettings.ForceTouch.Edit" = "Edit Message"; +"GeneralSettings.ForceTouch.Forward" = "Forward Message"; +"GeneralSettings.ForceTouch.PreviewMedia" = "Preview Media"; +"GeneralSettings.ForceTouch.React" = "Add Reaction"; +"GeneralSettings.ForceTouch.Reply" = "Reply to Message"; +"GeneralSettings.ForceTouchHeader" = "FORCE TOUCH ACTION"; +"GeneralSettings.GeneralSettings" = "GENERAL SETTINGS"; +"GeneralSettings.InAppSounds" = "In-App Sounds"; +"GeneralSettings.InputSettings" = "INPUT SETTINGS"; +"GeneralSettings.InstantView.ScrollBySpace" = "Scroll With Spacebar"; +"GeneralSettings.InstantViewHeader" = "INSTANT VIEW"; +"GeneralSettings.InterfaceHeader" = "INTERFACE"; +"GeneralSettings.LargeFonts" = "Large Font"; +"GeneralSettings.LiteMode" = "Power Saving Mode"; +"GeneralSettings.MediaKeysForInAppPlayer" = "Handle media keys for in-app player"; +"GeneralSettings.OpenLatestChatOnLaunch" = "Reopen Last Chat On Launch"; +"GeneralSettings.PreviewChats.Info" = "Click on the unread badge to preview chats without marking them as read."; +"GeneralSettings.PreviewChats.Text" = "Preview Chats"; +"GeneralSettings.SendByCmdEnter" = "Send with ⌘ + Enter"; +"GeneralSettings.SendByEnter" = "Send with Enter"; +"GeneralSettings.SendLargePhotos" = "Send Large Photos"; +"GeneralSettings.SendLargePhotos.Tooltip.Disabled" = "Photos will be sent in standard quality"; +"GeneralSettings.SendLargePhotos.Tooltip.Enabled" = "Photos will be sent in high quality"; +"GeneralSettings.Shortcuts" = "Keyboard Shortcuts"; +"GeneralSettings.ShortcutsHeader" = "SHORTCUTS"; +"GeneralSettings.ShowArticlesInSearch" = "Suggest Articles in Search"; +"GeneralSettings.ShowCallsTab" = "Show Calls Tab"; +"GeneralSettings.ShowProfileId.Text" = "Show Peer IDs in Profile"; +"GeneralSettings.Spelling.CheckGrammarSpelling" = "Check Grammar With Spelling"; +"GeneralSettings.Spelling.CheckSpellingAutomatically" = "Correct Spelling Automatically"; +"GeneralSettings.Spelling.CheckWhileTyping" = "Check Spelling While Typing"; +"GeneralSettings.Spelling.Title" = "SPELLING AND GRAMMAR"; +"GeneralSettings.StatusBarItem" = "Show Icon in Menu Bar"; +"Generic.ErrorMoreInfo" = "Learn More"; +"Gif.Keyboard.AddCaption" = "Add Caption"; +"GifsPane.Reactions" = "REACTIONS"; +"GifsPane.Trending" = "TRENDING GIFS"; +"Gift.Context.Hide" = "Hide"; +"Gift.Context.Show" = "Show"; +"Gift.Context.TakeOff" = "Take Off"; +"Gift.Context.Transfer" = "Transfer"; +"Gift.Context.Wear" = "Wear"; +"Gift.ContextMenu.SetAsTheme" = "Set as Theme in..."; +"Gift.Marketplace.Attr.Backdrop#one" = "%d Backdrop"; +"Gift.Marketplace.Attr.Backdrop#other" = "%d Backdrops"; +"Gift.Marketplace.Attr.Backdrop_ZeroValueHolder" = "Backdrop"; +"Gift.Marketplace.Attr.Backdrop_countable" = "%d"; +"Gift.Marketplace.Attr.Date" = "Date"; +"Gift.Marketplace.Attr.Model#one" = "%d Model"; +"Gift.Marketplace.Attr.Model#other" = "%d Models"; +"Gift.Marketplace.Attr.Model_ZeroValueHolder" = "Model"; +"Gift.Marketplace.Attr.Model_countable" = "%d"; +"Gift.Marketplace.Attr.Number" = "Number"; +"Gift.Marketplace.Attr.Price" = "Price"; +"Gift.Marketplace.Attr.Symbol#one" = "%d Symbol"; +"Gift.Marketplace.Attr.Symbol#other" = "%d Symbols"; +"Gift.Marketplace.Attr.Symbol_ZeroValueHolder" = "Symbol"; +"Gift.Marketplace.Attr.Symbol_countable" = "%d"; +"Gift.Marketplace.EmptyFilters" = "No Matching Gifts"; +"Gift.Marketplace.EmptyFilters.Clear" = "Clear Filters"; +"Gift.Marketplace.SelectAll" = "Select All"; +"Gift.Marketplace.Sort.Date" = "Sort by Date"; +"Gift.Marketplace.Sort.Number" = "Sort by Number"; +"Gift.Marketplace.Sort.Price" = "Sort by Price"; +"Gift.NotAccepting" = "Sorry, %@ is not accepting gifts."; +"Gift.Options.Gift.BuyLimitReached" = "You've already reached limit to send this gift."; +"Gift.Options.GiftSelf.Text" = "Buy yourself a gift to add to your profile or reserve for later.\n\nLimited-edition gifts upgraded to collectibles can be gifted to others."; +"Gift.Options.GiftSelf.Title" = "Buy a Gift"; +"Gift.Premium.Header" = "%@ Premium"; +"Gift.Premium.PayOrStars" = "or %@"; +"Gift.Premium.PayWith" = "Pay with %@"; +"Gift.Premium.PreviewInfo" = "Only %@ will see your message."; +"Gift.Premium.Text" = "Subscription for exclusive Telegram features"; +"Gift.Premium.YourBalance" = "Your balance is %@. [Get More Stars >]()"; +"Gift.Resale" = "resale"; +"Gift.Resale.PriceUpdate" = "Price successfully updated."; +"Gift.Resale.SetSuccess" = "Gift successfully set on sale."; +"Gift.Resale.Unavailable.Text" = "You will be able to resell this gift on %@."; +"Gift.Resale.Unavailable.Title" = "Too Early"; +"Gift.Sale" = "on sale"; +"Gift.Sell.Confirm.Text" = "Are you sure you want to sell **%1$@** for **%2$@**?"; +"Gift.Sell.Confirm.Title" = "Sell Gift"; +"Gift.Send.Disallow.Error" = "User is not accepting gifts"; +"Gift.Send.Error.LimitReached" = "You already sent %d of these gifts, which is the limit."; +"Gift.Send.Error.LimitReached_countable" = "%d"; +"Gift.Send.Upgrade" = "Make Unique for %@"; +"Gift.Send.Upgrade.Info" = "Enable this to let %1$@ turn your gift into a unique collectible. [Learn More >]()"; +"Gift.SendSelf.HideMyName.Info" = "Hide my name and message from visitors to my profile."; +"Gift.SoldOut" = "sold out"; +"Gift.SoldOut.Error" = "This gift has sold out"; +"Gift.Tooltip.Pinned" = "Gift Pinned"; +"Gift.Tooltip.Unpinned" = "Gift Unpinned"; +"Gift.Transfer.Confirmation.Text" = "Do you want to transfer ownership of **%1$@** to **%2$@** for **%3$@**?"; +"Gift.Transfer.Confirmation.TextFree" = "Do you want to transfer ownership of **%1$@** to **%2$@**?"; +"Gift.Transfer.Confirmation.Title" = "Confirm Transfer"; +"Gift.Transfer.Confirmation.Transfer" = "Transfer for"; +"Gift.Transfer.Confirmation.TransferFree" = "Transfer"; +"Gift.Transfer.SendUnlocks" = "unlocks in %@"; +"Gift.Transfer.SendViaBlockchain" = "Send via Blockchain"; +"Gift.Transfer.Title" = "Transfer"; +"Gift.Transfer.Unavailable.Text" = "You will be able to transfer this gift on %@."; +"Gift.Transfer.Unavailable.Title" = "Too Early"; +"Gift.Transfer.UnlockPending.Text" = "In %@, you'll be able to send this collectible to any TON blockchain address outside Telegram for sale or auction."; +"Gift.Transfer.UnlockPending.Title" = "Unlocking In Progress"; +"Gift.Transfer.UpdateRequired.Text" = "Please update your Telegram application to the latest version."; +"Gift.Transfer.UpdateRequired.Title" = "Update Required"; +"Gift.TransferTon.OK" = "Proceed"; +"Gift.Unavailable" = "Unavailable"; +"Gift.Unique.AttributeDescription" = "Only %@ of such collectibles have this attribute."; +"Gift.Unique.Availability" = "Availability"; +"Gift.Unique.Backdrop" = "Backdrop"; +"Gift.Unique.Collectible" = "Collectible"; +"Gift.Unique.Issued" = "%@ issued"; +"Gift.Unique.Model" = "Model"; +"Gift.Unique.Needs.Premium" = "Subscribe to [Telegram Premium](premium) to wear this collectible."; +"Gift.Unique.OriginalInfo" = "Gifted to %1$@ on %2$@."; +"Gift.Unique.OriginalInfoSender" = "Gifted by %1$@ to %2$@ on %3$@."; +"Gift.Unique.OriginalInfoSenderWithText" = "Gifted by %1$@ to %2$@ on %3$@ with comment \"%4$@\""; +"Gift.Unique.OriginalInfoWithText" = "Gifted to %1$@ on %2$@ with comment \"%3$@\""; +"Gift.Unique.Owner" = "Owner"; +"Gift.Unique.Rarity.Epic" = "epic"; +"Gift.Unique.Rarity.Legendary" = "legendary"; +"Gift.Unique.Rarity.Rare" = "rare"; +"Gift.Unique.Rarity.Uncommon" = "uncommon"; +"Gift.Unique.Symbol" = "Symbol"; +"Gift.Unique.Telegram" = "Telegram"; +"Gift.Unique.Transfer" = "change"; +"Gift.Unlist.Confirm.Ok" = "Unlist"; +"Gift.Unlist.Confirm.Text" = "It will no longer be for sale."; +"Gift.Unlist.Confirm.Title" = "Unlist this gift?"; +"Gift.Upgrade.AddMyName" = "Add my name to the gift"; +"Gift.Upgrade.AddName" = "Add sender's name"; +"Gift.Upgrade.AddNameAndComment" = "Add sender's name and comment"; +"Gift.Upgrade.Confirm" = "Confirm"; +"Gift.Upgrade.Description" = "Turn your gift into a unique collectible that you can transfer or auction."; +"Gift.Upgrade.IncludeDescription" = "Let %@ turn this gift into a unique collectible."; +"Gift.Upgrade.IncludeTitle" = "Make Unique"; +"Gift.Upgrade.Pay" = "Pay %@"; +"Gift.Upgrade.Refunded" = "This gift was downgraded because a request to refund the payment related to this gift was made, and the money was returned."; +"Gift.Upgrade.Succeed.Text" = "You have received a unique number, model, backdrop and symbol for your gift."; +"Gift.Upgrade.Succeed.Title" = "The gift is now collectible"; +"Gift.Upgrade.Title" = "Upgrade Gift"; +"Gift.Upgrade.Tradable.Description" = "Sell or auction your gift on third-party NFT marketplaces."; +"Gift.Upgrade.Tradable.IncludeDescription" = "The recipient will be able to auction the gift on third-party NFT marketplaces."; +"Gift.Upgrade.Tradable.Title" = "Tradable"; +"Gift.Upgrade.Transferable.Description" = "Send your upgraded gift to any of your friends on Telegram."; +"Gift.Upgrade.Transferable.IncludeDescription" = "The recipient will be able to send the gift to anyone Telegram."; +"Gift.Upgrade.Transferable.Title" = "Transferable"; +"Gift.Upgrade.Unique.Description" = "Get a unique number, model, backdrop and symbol for your gift."; +"Gift.Upgrade.Unique.IncludeDescription" = "The recipient will get a unique number, model, backdrop and symbol for the gift."; +"Gift.Upgrade.Unique.Title" = "Unique"; +"Gift.Upgrade.Upgrade" = "Upgrade"; +"Gift.View.DisplayedInfoHide" = "The gift is visible on your profile. [Hide >]()"; +"Gift.View.KeepOrConvertDescription.Channel" = "Your channel can add this gift to its profile or convert it to %@. [More About Stars >](https://telegram.org)"; +"Gift.View.KeepUpgradeOrConvertDescription" = "You can keep this gift, upgrade it, or sell it for %@. [More About Stars >]()"; +"Gift.View.KeepUpgradeOrConvertDescription.Channel" = "Your channel can keep this gift, upgrade it, or sell it for %@. [More About Stars >](https://telegram.org)"; +"Gift.View.Status" = "Status"; +"Gift.View.Status.NonUnique" = "Non-Unique"; +"Gift.View.Status.Upgrade" = "upgrade"; +"Gift.Wear.Badge.ChannelText" = "This gift will be displayed next to your channel's name with a glittering icon."; +"Gift.Wear.Badge.Text" = "This gift will be displayed next to your name with a glittering icon."; +"Gift.Wear.Badge.Title" = "Radiant Badge"; +"Gift.Wear.Design.ChannelText" = "Your channel's profile will have the same backdrop and symbol as this gift."; +"Gift.Wear.Design.Text" = "Your profile will have the same backdrop and symbol as this gift."; +"Gift.Wear.Design.Title" = "Unique Profile Design"; +"Gift.Wear.Proof.ChannelText" = "Clicking the icon next to your channel's name will show the gift's owner and attributes."; +"Gift.Wear.Proof.Text" = "Clicking the icon next to your name will show the gift's owner and attributes."; +"Gift.Wear.Proof.Title" = "Proof of Ownership"; +"Gift.Wear.Start" = "Start Wearing"; +"Gift.Withdraw.Proceed" = "Open Fragment"; +"Gift.Withdraw.Text" = "You can use Fragment, a third-party service, to transfer **%@** to the TON blockchain. After that, you can manage it as an NFT with any TON wallet application.\n\nYou can also move such NFTs back to your Telegram account via Fragment."; +"Gift.Withdraw.Title" = "Manage with Fragment"; +"GiftLink.Info.NotUsed" = "This link allows you to activate a **Telegram Premium** subscription."; +"GiftLink.Info.NotUsed.Info" = "You can also send this [link](share) to a friend as a gift."; +"GiftLink.Info.Used" = "This link was used to activate a **Telegram Premium** subscription."; +"GiftLink.Info.Used.Info" = "This link was used on %@."; +"GiftLink.NoRecipient" = "No Recipient"; +"GiftLink.PremiumDuration.Months" = "Telegram Premium for %d months"; +"GiftLink.PremiumDuration.Year" = "Telegram Premium 1 Year"; +"GiftLink.Row.Date" = "Date"; +"GiftLink.Row.From" = "From"; +"GiftLink.Row.Gift" = "Gift"; +"GiftLink.Row.Reason" = "Reason"; +"GiftLink.Row.Reason.Gift" = "You were selected by the channel"; +"GiftLink.Row.Reason.Gift.JustGift" = "Gift"; +"GiftLink.Row.Reason.Giveaway" = "Giveaway"; +"GiftLink.Row.Reason.Giveaway.Incomplete" = "Incomplete Giveaway"; +"GiftLink.Row.To" = "To"; +"GiftLink.Title" = "Gift Link"; +"GiftLink.UseLink" = "Use Link"; +"GiftLink.UseSuccess" = "You successfully activated gift link."; +"Gifting.MyGifts" = "My Gifts"; +"Gifting.PremWithStars.Confirm.Info" = "Are you sure you want to gift **Telegram Premium** to **%1$@** for **%2$@**?"; +"Gifting.PremWithStars.Confirm.Title" = "Send a Gift"; +"Gifting.Premium.Info" = "Give %@ access to exclusive features with Telegram Premium. [See Features >](premium)"; +"Gifting.Premium.Title" = "Gift Premium"; +"Gifting.StarGift.All" = "All"; +"Gifting.StarGift.Badge.Remaining#one" = "1 left"; +"Gifting.StarGift.Badge.Remaining#other" = "%d left"; +"Gifting.StarGift.Badge.Remaining_countable" = "%d left"; +"Gifting.StarGift.InStock" = "In Stock"; +"Gifting.StarGift.Info" = "Give %@ gifts that can be kept on the profile or converted to Stars. [What are Stars >](stars)"; +"Gifting.StarGift.Info.Collectibles" = "Collectible gifts are unique digital items you can exchange or sell."; +"Gifting.StarGift.Label.Collectibles" = "Collectibles"; +"Gifting.StarGift.Limited" = "Limited"; +"Gifting.StarGift.Resale" = "Resale"; +"Gifting.StarGift.Title" = "Gift Stars"; +"Gifting.StarGift.Title.Buy" = "Buy a Gift"; +"Gifting.StarGift.Title.Send" = "Send a Gift"; +"Gifting.Title" = "Send a Gift"; +"Gifting.UserChargeStars" = "**%1$@** charges **%2$@** for each message. That price has been added to the cost of the gift."; +"Giveaway.AdditionalPrize" = "**%1$@** also included **%2$@** in the prizes. Admins of the channel are responsible for delivering these prizes."; +"Giveaway.Alert.Created" = "Giveaway created"; +"Giveaway.Booster.Reason.Gift" = "🎁 Gift"; +"Giveaway.Booster.Reason.Giveaway" = "🏆 Giveaway"; +"Giveaway.Channels.Add" = "Add Channel"; +"Giveaway.Channels.Add.Group" = "Add Group Or Channel"; +"Giveaway.Channels.Add.Private.Header" = "Private Channel"; +"Giveaway.Channels.Add.Private.Header.Group" = "Private Group"; +"Giveaway.Channels.Add.Private.Ok" = "Add"; +"Giveaway.Channels.Add.Private.Ok.Group" = "Add"; +"Giveaway.Channels.Add.Private.Text" = "Are you sure you want to add a private channel? Users won't be able to join it without an invite link."; +"Giveaway.Channels.Add.Private.Text.Group" = "Are you sure you want to add a private group? Users won't be able to join it without an invite link."; +"Giveaway.Channels.Add.SelectChannel" = "Select Channel"; +"Giveaway.Channels.Add.SelectChannel.Group" = "Select Group"; +"Giveaway.Channels.BoostReceive#one" = "this channel will receive %d boost"; +"Giveaway.Channels.BoostReceive#other" = "this channel will receive %d boosts"; +"Giveaway.Channels.BoostReceive.Group#one" = "this group will receive %d boost"; +"Giveaway.Channels.BoostReceive.Group#other" = "this group will receive %d boosts"; +"Giveaway.Channels.BoostReceive.Group_countable" = "%d"; +"Giveaway.Channels.BoostReceive_countable" = "%d"; +"Giveaway.Channels.Context.Remove" = "Remove"; +"Giveaway.Channels.Header" = "CHANNELS INCLUDED IN THE GIVEAWAY"; +"Giveaway.Channels.Header.Group" = "GROUPS AND CHANNELS INCLUDED IN THE GIVEAWAY"; +"Giveaway.Date.Ends" = "Ends"; +"Giveaway.Date.Info" = "Choose when subscribers of your channel will be randomly selected to receive Telegram Premium."; +"Giveaway.Date.Info.Group" = "Choose when members of your group will be randomly selected to receive Telegram Premium."; +"Giveaway.Date.Info_Countable" = "%d"; +"Giveaway.Date.SelectDate" = "Giveaway"; +"Giveaway.Date.SelectDate.OK" = "OK"; +"Giveaway.Date.Title" = "DATE WHEN GIVEAWAY ENDS"; +"Giveaway.Header.Text" = "Get more boosts for your channel by gifting"; +"Giveaway.Header.Text.Group" = "Get more boosts for your group by gifting"; +"Giveaway.Header.Title" = "**Gift Telegram Premium**"; +"Giveaway.PaymentOptions.Info" = "You can review the list of features and more details about Telegram Premium [here](premium)."; +"Giveaway.PaymentOptions.Months" = "%d Months"; +"Giveaway.PaymentOptions.Reduce.OK" = "Reduce"; +"Giveaway.PaymentOptions.Reduce.Text" = "You can't acquire %1$@ %2$@-months subscriptions in the app. Do you want to reduce the quantity to 25?"; +"Giveaway.PaymentOptions.Reduce.Title" = "Reduce Quantity"; +"Giveaway.PaymentOptions.Title" = "DURATION OF PREMIUM SUBSCRIPTIONS"; +"Giveaway.PaymentOptions.Year" = "1 Year"; +"Giveaway.Quantity.Header" = "QUANTITY OF PRIZES / BOOSTS"; +"Giveaway.Quantity.Info" = "Choose how many Premium subscriptions to give away and boosts to receive."; +"Giveaway.Quantity.Right#one" = "%d BOOST"; +"Giveaway.Quantity.Right#other" = "%d BOOSTS"; +"Giveaway.Quantity.Right_countable" = "%d"; +"Giveaway.ReceiverType.All" = "All subscribers"; +"Giveaway.ReceiverType.Countries#one" = "from %d country"; +"Giveaway.ReceiverType.Countries#other" = "from %d countries"; +"Giveaway.ReceiverType.Countries_ZeroValueHolder" = "from all countries"; +"Giveaway.ReceiverType.Countries_countable" = "%d"; +"Giveaway.ReceiverType.Info" = "Choose if you want to limit the giveaway only to those who joined the channel after the giveaway started or to users from specific countries."; +"Giveaway.ReceiverType.New" = "Only new subscribers"; +"Giveaway.ReceiverType.Title" = "USERS ELIGIBLE FOR THE GIVEAWAY"; +"Giveaway.Star.AmountPer#one" = "%d per user"; +"Giveaway.Star.AmountPer#other" = "%d per user"; +"Giveaway.Star.AmountPer_countable" = "%d"; +"Giveaway.Star.ChooseRandomly" = "Choose Randomly"; +"Giveaway.Star.Options.Header" = "STARS TO DISTRIBUTE"; +"Giveaway.Star.OptionsInfo" = "Choose how many stars to give away and how many boosts to receive for 1 year."; +"Giveaway.Star.Quantity.Header" = "NUMBER OF WINNERS"; +"Giveaway.Star.Quantity.Info" = "Choose how many winners you want to distribute stars among."; +"Giveaway.Stars.Prepaid.Desc#one" = "for %d user"; +"Giveaway.Stars.Prepaid.Desc#other" = "for %d users"; +"Giveaway.Stars.Prepaid.Desc_countable" = "%d"; +"Giveaway.Stars.Prepaid.Title" = "%d Telegram Stars"; +"Giveaway.StartGiveaway" = "Start Giveaway"; +"Giveaway.Title" = "Boosts via Gifts"; +"Giveaway.TooLongDate" = "Date limit reached."; +"Giveaway.Type.Premium.Random.Text" = "winners are chosen randomly"; +"Giveaway.Type.Premium.Title" = "Telegram Premium"; +"Giveaway.Type.Prepaid.Desc" = "%d-months subscriptions"; +"Giveaway.Type.Prepaid.Title" = "%d Telegram Premium"; +"Giveaway.Type.Random.Text" = "winners are chosen randomly"; +"Giveaway.Type.Random.Title" = "Create Giveaway"; +"Giveaway.Type.Specific.Modal.SelectUsers" = "Select Users"; +"Giveaway.Type.Specific.Text" = "select recipients"; +"Giveaway.Type.Specific.Title" = "Award Specific Users"; +"Giveaway.Type.Stars.Text" = "winners are chosen randomly"; +"Giveaway.Type.Stars.Title" = "Telegram Stars"; +"Giveaway.with" = "with"; +"Giveway.Channels.Info" = "Choose the channels users need to join to take part in the giveaway."; +"Giveway.Channels.Info.Group" = "Choose additional groups and channels users need to join to take part in the giveaway."; +"GlobalSearch.Empty.Desc.PremiumRequired" = "Global search is a Premium feature."; +"GlobalSearch.Empty.Desc.Remaining" = "%d free searches remaining today"; +"GlobalSearch.Empty.Desc.Remaining_countable" = "%d"; +"GlobalSearch.Empty.Info.LimitReached" = "You can make up to %d search queries per day."; +"GlobalSearch.Empty.Info.LimitReached_countable" = "%d"; +"GlobalSearch.Empty.Info.Premium" = "Type a keyword to search for posts from public channels."; +"GlobalSearch.Empty.Title.FreeUnlocksIn" = "free search unlocks in %@"; +"GlobalSearch.Empty.Title.LimitReached" = "Limit Reached"; +"GlobalSearch.Empty.Title.Premium" = "Global Search"; +"GlobalSearch.Empty.Title.PremiumRequired" = "Premium Required"; +"GlobalSearch.Empty.Title.SearchPrefix" = "Search"; +"GlobalSearch.Empty.Title.SearchWithPrice" = "Search for %@ %@"; +"GlobalSearch.Pay.Alert.StarsSpent" = "%@ spent on extra search."; +"GlobalTimer.Block.Enabled.Info" = "All new messages in chats you start will be automatically deleted for everyone %@ after they are sent. You can also [apply this setting for your existing chats]()."; +"GlobalTimer.Block.Header" = "SELF-DESTRUCT TIMER"; +"GlobalTimer.Block.Info" = "If enabled, all new messages in chats you start will be automatically deleted for everyone at some point after they have been sent. This will not affect your existing chats."; +"GlobalTimer.Confirm.Ok" = "Enable Auto-Deletion"; +"GlobalTimer.Confirm.Text" = "Are you sure you want all messages in your new private chats and in new groups you create to be automatically deleted for everyone %1$@ after they are sent?"; +"GlobalTimer.Confirm.Title" = "Self-Destruct Timer"; +"GlobalTimer.Off" = "Off"; +"GlobalTimer.Select.Apply" = "Apply a %@ self-destruct timer"; +"GlobalTimer.Select.StatusDisabled" = "auto-delete disabled"; +"GlobalTimer.Select.StatusEnabled" = "auto-delete after %@"; +"GlobalTimer.SetCustomTime" = "Set Custom Time"; +"GlobalTimer.Title" = "Auto-Delete Messages"; +"Graph.Total" = "Total"; +"Graph.ZoomOut" = "Zoom Out"; +"Group.AppointAnotherAdmin" = "Appoint Another Admin"; +"Group.AppointAnotherOwner" = "Appoint Another Owner"; +"Group.CreateGroup" = "New Group"; +"Group.EditAdmin.Permission.ChangeInfo" = "Change Group Info"; +"Group.EmojiPack.CreateInfo" = "All members will be able to use these emoji in the group, even if they don't have Telegram Premium.\n\nYou can create your own custom emoji set using the [@stickers](stickers) bot."; +"Group.EmojiPack.Header" = "CHOOSE EMOJI PACK"; +"Group.EmojiPack.Title" = "Group Emoji Pack"; +"Group.ErrorAddBlocked" = "Sorry, you can't add this user to groups."; +"Group.EventLog.EmptyText" = "**No events here yet**\n\nNo notable actions taken by the members and admins of this group in the last 48 hours."; +"Group.EventLog.Service.AboutRemoved" = "%@ removed the group description:"; +"Group.EventLog.Service.AboutUpdated" = "%@ edited the group description:"; +"Group.EventLog.Service.DisableInvites" = "%@ disabled group invites"; +"Group.EventLog.Service.EnableInvites" = "%@ enabled group invites"; +"Group.EventLog.Service.LinkRemoved" = "%@ removed the group link:"; +"Group.EventLog.Service.LinkUpdated" = "%@ changed the group link:"; +"Group.EventLog.Service.PhotoRemoved" = "%@ removed group photo"; +"Group.EventLog.Service.PhotoUpdated" = "%@ updated the group photo"; +"Group.EventLog.Service.TitleUpdated" = "%@ changed the group name"; +"Group.EventLog.Service.UpdateJoin" = "%@ joined the group"; +"Group.EventLog.Service.UpdateLeft" = "%@ left the group"; +"Group.LeaveButton" = "Leave Group"; +"Group.LeaveInfoAdmin" = "If you leave, %1$@ will become the new admin of %2$@ in 1 week."; +"Group.LeaveInfoOwner" = "If you leave, %1$@ will become the new owner of %2$@ in 1 week."; +"Group.LeaveTitle" = "Leave %1$@?"; +"Group.NewGroup" = "New Group"; +"Group.OwnershipTransfer.ErrorLocatedGroupsTooMuch" = "Sorry, the target user has too many location-based groups already. Please ask them to delete or transfer one of their existing ones first."; +"Group.TransferOwner.ErrorAdminsTooMuch" = "Sorry, this group has too many admins and the new owner can't be added. Please remove one of the existing admins first."; +"Group.TransferOwner.ErrorPrivacyRestricted" = "Sorry, this user is not a member of this group and their privacy settings prevent you from adding them manually."; +"Group.Unavailable" = "Sorry, this group doesn't seem to exist."; +"Group.UsersTooMuchError" = "Sorry, this group is full. You cannot add any more members here."; +"GroupAdmins.AllMembersAdmins" = "All Members Are Admins"; +"GroupAdmins.Desc.AdminInvites" = "Only admins can add and remove members, pin messages or edit group details."; +"GroupAdmins.Desc.AllInvites" = "Group members can add new members, and can edit the name or photo of the group."; +"GroupCall.EndToEnd.Title" = "End-to-end Encrypted"; +"GroupCall.EndToEnd.TitleShort" = "Encrypted"; +"GroupCall.Input.Placeholder" = "Message..."; +"GroupCall.Invite.NotAvailable" = "Group call is no longer available"; +"GroupCall.InviteLink.Basic" = "Anyone on Telegram will be able to join your call by following this link."; +"GroupCall.InviteLink.Expired" = "This link is no longer active"; +"GroupCall.InviteLink.Revoked" = "The invite link has been revoked"; +"GroupCall.InviteLink.Share" = "Anyone on Telegram can join your call by following the link below."; +"GroupCall.InviteLink.Title" = "Call Link"; +"GroupCall.InviteLinks.BeFirst" = "Be first to join the call and add people from there.\n[Open Call >](open)"; +"GroupCall.InviteLinks.CopyLink" = "Copy Link"; +"GroupCall.InviteLinks.Invited" = "You are invited to join a group call."; +"GroupCall.InviteLinks.ShareLink" = "Share Link"; +"GroupCall.Join.Double" = "**%1$@** and **%2$@** already joined this call."; +"GroupCall.Join.Multiple" = "**%1$@**, **%2$@** and %3$@ already joined this call."; +"GroupCall.Join.Multiple.Others#one" = "%d other"; +"GroupCall.Join.Multiple.Others#other" = "%d others"; +"GroupCall.Join.Multiple.Others_countable" = "%d"; +"GroupCall.Join.None" = "No one has joined this call yet."; +"GroupCall.Join.Single" = "**%1$@** already joined this call."; +"GroupCall.Join.Title" = "Join Group Call"; +"GroupCall.Settings.EnableMessages" = "Enable Messages"; +"GroupCall.StatusBar.StartScreen" = "Share Screen"; +"GroupCall.StatusBar.StartVideo" = "Share Video"; +"GroupCall.StatusBar.StopScreen" = "Stop Screen"; +"GroupCall.StatusBar.StopVideo" = "Stop Video"; +"GroupInfo.AddUserLeftError" = "If a person has left the group in the past, you need to be in their Telegram contacts to add them back.\n\nThey can still join via an invite link as long as they are not on the Removed Users list."; +"GroupInfo.Administrators" = "Administrators"; +"GroupInfo.Administrators.Title" = "Administrators"; +"GroupInfo.FakeWarning" = "⚠️ Warning: Many users reported that this group impersonates a famous person or organization."; +"GroupInfo.Permissions.AddException" = "Add Exception"; +"GroupInfo.Permissions.BroadcastConvert" = "Convert to Broadcast Group"; +"GroupInfo.Permissions.BroadcastConvertInfo" = "Broadcast groups can have over %@ members, but only admins can send messages in them."; +"GroupInfo.Permissions.BroadcastTitle" = "Broadcast Group"; +"GroupInfo.Permissions.Exceptions" = "EXCEPTIONS"; +"GroupInfo.Permissions.Removed" = "Removed Users"; +"GroupInfo.Permissions.SearchPlaceholder" = "Search Exceptions"; +"GroupInfo.Permissions.SectionTitle" = "WHAT CAN MEMBERS OF THIS GROUP DO?"; +"GroupInfo.ScamWarning" = "⚠️ Warning: Many users reported this group as a scam or a fake account. Please be careful, especially if it asks you for money."; +"GroupInvation.ChannelDescription" = "Anyone who has Telegram installed will be able to join your channel by following this link"; +"GroupInvation.CopyLink" = "Copy Link"; +"GroupInvation.GroupDescription" = "Anyone who has Telegram installed will be able to join your group by opening this link."; +"GroupInvation.Revoke" = "Revoke"; +"GroupInvation.Share" = "Share Link"; +"GroupPermission.AddSuccess" = "Exception Added"; +"GroupPermission.AddedInfo" = "Exception added by %@ %@"; +"GroupPermission.ApplyAlertAction" = "Apply"; +"GroupPermission.ApplyAlertText" = "You have changed this user's rights in %@.\nApply Changes?"; +"GroupPermission.Delete" = "Delete Exception"; +"GroupPermission.Duration" = "Duration"; +"GroupPermission.NewTitle" = "New Exception"; +"GroupPermission.NoAddMembers" = "no add"; +"GroupPermission.NoChangeInfo" = "no info"; +"GroupPermission.NoFiles" = "no files"; +"GroupPermission.NoMusic" = "no music"; +"GroupPermission.NoPhotos" = "no photos"; +"GroupPermission.NoPinMessages" = "no pinning"; +"GroupPermission.NoSendGifs" = "no GIFs"; +"GroupPermission.NoSendLinks" = "no links"; +"GroupPermission.NoSendMedia" = "no media"; +"GroupPermission.NoSendMessages" = "no messages"; +"GroupPermission.NoSendPolls" = "no polls"; +"GroupPermission.NoTopics" = "no topics"; +"GroupPermission.NoVideoMessages" = "no video messages"; +"GroupPermission.NoVideos" = "no videos"; +"GroupPermission.NoVoiceMessages" = "no voice messages"; +"GroupPermission.NotAvailableInPublicGroups" = "This permission is not available in public groups."; +"GroupPermission.SectionTitle" = "WHAT CAN THIS MEMBER DO?"; +"GroupPermission.Title" = "Exception"; +"GroupStats.Title" = "Group Statistics"; +"GroupStickers.ChooseHeader" = "CHOOSE FROM YOUR STICKERS"; +"GroupStickers.CreateDescription" = "You can create your own custom sticker set using the @stickers bot."; +"GroupStickers.EmptyDesc" = "Try again or choose from the list below"; +"GroupStickers.EmptyDesc.Emoji" = "Try again or choose from the list below"; +"GroupStickers.EmptyHeader" = "No such sticker set found"; +"GroupStickers.EmptyHeader.Emoji" = "No such emoji set found"; +"GroupsInCommon.Empty" = "No groups in common"; +"H8h-7b-M4v.title" = "View"; +"HFQ-gK-NFA.title" = "Text Replacement"; +"HFo-cy-zxI.title" = "Show Spelling and Grammar"; +"HashtagSearch.StoriesFound#one" = "%d Story Found"; +"HashtagSearch.StoriesFound#other" = "%d Stories Found"; +"HashtagSearch.StoriesFoundInfo" = "View stories with %@"; +"HashtagSearch.StoriesFound_countable" = "%d"; +"HyV-fh-RgO.title" = "View"; +"IV.Channel.Join" = "Join"; +"InAppLinks.AuthFailed" = "Authorization failed."; +"InAppLinks.BotManagementDisabled" = "@%1$@ doesn't have Bot Management Mode enabled."; +"InAppLinks.Confirm.OpenExternal" = "open %@?"; +"InAppLinks.Confirm.OpenExternal.Header" = "Open Link"; +"InAppLinks.Confirm.OpenExternal.OK" = "Open"; +"InAppLinks.Confirm.OpenExternalNew" = "Do you want to open \"%@\"?"; +"InAppLinks.IDNWarning" = "This link may be deceptive.\nDisplayed: %1$@\nActual: %2$@"; +"InAppLinks.LoginExpired" = "The login link has expired."; +"InAppLinks.LoginSuccess" = "You have successfully logged in."; +"InAppLinks.StyleNotFound" = "This style doesn't exist."; +"InactiveChannels.BlockHeader" = "Too Many Groups and Channels"; +"InactiveChannels.Header" = "LEAST ACTIVE"; +"InactiveChannels.InactiveMonth#one" = "inactive for %d month"; +"InactiveChannels.InactiveMonth#other" = "inactive for %d months"; +"InactiveChannels.InactiveMonth_countable" = "%d"; +"InactiveChannels.InactiveWeek#one" = "inactive for %d week"; +"InactiveChannels.InactiveWeek#other" = "inactive for %d weeks"; +"InactiveChannels.InactiveWeek_countable" = "%d"; +"InactiveChannels.InactiveYear#one" = "inactive for %d year"; +"InactiveChannels.InactiveYear#other" = "inactive for %d years"; +"InactiveChannels.InactiveYear_countable" = "%d"; +"InactiveChannels.OK" = "Leave"; +"InactiveChannels.Title" = "Limit Reached"; +"InlineModalAction.Desc" = "Select a user or chat to share content via %@"; +"InlineModalAction.Title" = "Share bot content"; +"Input.Quote.Collapsed" = "Quote text will be collapsed"; +"Input.Quote.Expanded" = "Quote text will be fully visible"; +"InputAttach.Audio" = "Audio"; +"InputAttach.Popover.Checklist" = "Checklist"; +"InputAttach.Popover.File" = "File"; +"InputAttach.Popover.Gift" = "Gift Premium"; +"InputAttach.Popover.Location" = "Location"; +"InputAttach.Popover.Music" = "Audio File"; +"InputAttach.Popover.Photo" = "Photo"; +"InputAttach.Popover.PhotoOrVideo" = "Photo Or Video"; +"InputAttach.Popover.Picture" = "Camera"; +"InputAttach.Popover.Poll" = "Poll"; +"InputAttach.Popover.Video" = "Video"; +"InputContext.HashtashHelp.Basic" = "searches posts from all channels"; +"InputContext.HashtashHelp.Channel" = "searches only posts from this channel"; +"InputContext.HashtashHelp.Use" = "Use %@"; +"InputData.Date.Day.Placeholder" = "Day:"; +"InputData.Date.Day.Placeholder1" = "Day"; +"InputData.Date.Month.Placeholder" = "Month:"; +"InputData.Date.Month.Placeholder1" = "Month"; +"InputData.Date.Year.Placeholder" = "Year:"; +"InputData.Date.Year.Placeholder1" = "Year"; +"InputFormatter.Text.Header" = "TEXT"; +"InputFormatter.Text.Placeholder" = "Text"; +"InputFormatter.URL.Header" = "URL"; +"InputFormatter.URL.Placeholder" = "URL"; +"InputPasswordController.Error.WrongPassword" = "Invalid password. Please try again"; +"InputPasswordController.Placeholder" = "Password"; +"InputView.Settings" = "Settings"; +"InputView.Settings.AutoBulletList" = "Automatic Bullet Lists"; +"InputView.Settings.AutoDetectDates" = "Auto-Detect Dates"; +"InputView.Settings.AutoNumberedList" = "Automatic Numbered Lists"; +"InputView.Settings.DashConversion" = "Dash Conversion"; +"InstalledStickers.Archived" = "Archived Stickers"; +"InstalledStickers.CustomEmoji" = "Custom Emoji"; +"InstalledStickers.Descrpiption" = "Create your own sticker sets using the @stickers bot.\n\nClick on a sticker to view and add the whole set."; +"InstalledStickers.DynamicPackOrder" = "Dynamic Pack Order"; +"InstalledStickers.DynamicPackOrderInfo" = "Recently used sticker sets will be displayed above the older ones."; +"InstalledStickers.LoopAnimated" = "Loop Animated Stickers"; +"InstalledStickers.PacksTitle" = "STICKER SETS"; +"InstalledStickers.QuickReaction" = "Quick Reaction"; +"InstalledStickers.QuickReaction1" = "Quick Reaction"; +"InstalledStickers.Remove.Delete" = "Delete"; +"InstalledStickers.Remove.Description" = "This sticker set will be archived. You can quickly restore it later from the Archived Stickers section."; +"InstalledStickers.Tranding" = "Trending Stickers"; +"InstantPage.AuthorAndDateTitle" = "By %1$@ • %2$@"; +"InstantPage.RelatedArticleAuthorAndDateTitle" = "%@ • %@"; +"Invite.ChannelsTooMuch" = "Sorry, the target user is a member of too many groups and channels. Please ask them to leave some first."; +"InviteFailed.OK" = "Send Invite Link"; +"InviteFailed.Premium.Action" = "Subscribe to Telegram Premium"; +"InviteFailed.Premium.Text.Multiple" = "**%@** only accepts invitations to groups from contacts and **Premium** users"; +"InviteFailed.Premium.Text.MultipleAnd#one" = "**%1$@** and %2$d more person accept invitations to groups from contacts and **Premium** users"; +"InviteFailed.Premium.Text.MultipleAnd#other" = "**%1$@** and %2$d more people accept invitations to groups from contacts and **Premium** users"; +"InviteFailed.Premium.Text.MultipleAnd_countable" = "%@ %d"; +"InviteFailed.Premium.Text.Single" = "**%@** accepts invitations to groups from contacts and **Premium** users"; +"InviteFailed.Premium.Title" = "Upgrade to Premium"; +"InviteFailed.Skip" = "Skip"; +"InviteFailed.Text.Cant.Multiple#one" = "%d user can only be invited via link, but you don't have permission to share invite links to this group."; +"InviteFailed.Text.Cant.Multiple#other" = "%d users can only be invited via link, but you don't have permission to share invite links to this group."; +"InviteFailed.Text.Cant.Multiple_countable" = "%d"; +"InviteFailed.Text.Cant.Single" = "%@ can only be invited via link, but you don't have permission to share invite links to this group."; +"InviteFailed.Text.Multiple#one" = "%d user restricts adding them to groups. You can send them an invite link as message instead."; +"InviteFailed.Text.Multiple#other" = "%d users restrict adding them to groups. You can send them an invite link as message instead."; +"InviteFailed.Text.Multiple_countable" = "%d"; +"InviteFailed.Text.Single" = "%@ restricts adding them to groups. You can send them an invite link as message instead."; +"InviteFailed.Title" = "Invite Via Link"; +"InviteLink.CanJoin#one" = "%d can join"; +"InviteLink.CanJoin#other" = "%d can join"; +"InviteLink.CanJoin_countable" = "%d"; +"InviteLink.Create.Copied" = "Link created and copied to clipboard."; +"InviteLink.EmptyJoinDesc#one" = "%d person can join via this link"; +"InviteLink.EmptyJoinDesc#other" = "%d people can join via this link"; +"InviteLink.EmptyJoinDesc_countable" = "%d"; +"InviteLink.Joined#one" = "%d joined"; +"InviteLink.Joined#other" = "%d joined"; +"InviteLink.JoinedNew#one" = "%d joined"; +"InviteLink.JoinedNew#other" = "%d joined"; +"InviteLink.JoinedNew_ZeroValueHolder" = "nobody joined yet"; +"InviteLink.JoinedNew_countable" = "%d"; +"InviteLink.JoinedRevoked" = "no one joined"; +"InviteLink.Joined_countable" = "%d"; +"InviteLink.PeopleJoined#one" = "%d person joined"; +"InviteLink.PeopleJoined#other" = "%d people joined"; +"InviteLink.PeopleJoined_countable" = "%d"; +"InviteLink.Remaining#one" = "• %d remaining"; +"InviteLink.Remaining#other" = "• %d remaining"; +"InviteLink.Remaining_countable" = "%d"; +"InviteLink.Requested#one" = "%d request"; +"InviteLink.Requested#other" = "%d requests"; +"InviteLink.Requested_ZeroValueHolder" = "no one requested yet"; +"InviteLink.Requested_countable" = "%d"; +"InviteLink.ShareLink" = "Share Link"; +"InviteLink.Sticker.Expired" = " • expired"; +"InviteLink.Sticker.Limit" = " • limit reached"; +"InviteLink.Sticker.Revoked" = "• revoked"; +"InviteLink.Sticker.TimeLeft" = "expires in %@"; +"InviteLink.Sub.Amount.Placeholder" = "Stars Amount per month"; +"InviteLink.Sub.Info" = "Charge a subscription fee from people who join your channel via this link. [Learn More >](https://telegram.org/tos/stars)"; +"InviteLink.Sub.Info.Editing" = "To change the subscription fee, create a new invite link with a different price."; +"InviteLink.Sub.ReqApproval" = "You can't enable admin approval for links that require a monthly fee."; +"InviteLink.Sub.Text" = "Require Monthly Fee"; +"InviteLink.SubFee.Header" = "SUBSCRIPTION FEE"; +"Join.ChannelsTooMuch" = "Sorry, you are a member of too many groups and channels. Please leave some before joining one."; +"Join.InactiveChannels" = "Inactive Chats"; +"JoinLink.FloodError" = "Limit exceeded. Please try again later."; +"JoinLink.Join" = "Join"; +"Kd2-mp-pUS.title" = "Show All"; +"LE2-aR-0XJ.title" = "Bring All to Front"; +"Language.OfficialTransationsHeader" = "OFFICIAL TRANSLATIONS"; +"Language.RemovePack" = "Are you sure you want to remove this language pack?"; +"Language.TranslateMessages.Channel" = "Translate Entire Chats"; +"Language.TranslateMessages.Channel.Info" = "Translate all messages in chat. This is available only for [Premium Users]()."; +"Language.TranslateMessages.Channel.Premium" = "Subscribe to [Telegram Premium](premium) to translate entire chats."; +"Language.TranslateMessages.DoNotTranslate" = "Do Not Translate"; +"Language.TranslateMessages.Header" = "TRANSLATE MESSAGES"; +"LastSeen.HoursAgo#one" = "last seen %d hour ago"; +"LastSeen.HoursAgo#other" = "last seen %d hours ago"; +"LastSeen.HoursAgo_countable" = "%d"; +"Legacy.Intro.Description1" = "Welcome to the new super-fast and stable Telegram for macOS, fully rewritten in Swift 3.0."; +"Legacy.Intro.Description2" = "Please note that your existing secret chats will be available in read-only mode. You can of course create new ones to continue chatting."; +"Legacy.Intro.Next" = "Start Messaging"; +"Link.Expired" = "Sorry, this link has expired."; +"LinkInvation.Channel.Confirm.Revoke" = "Are you sure you want to revoke this link? Once you do, no one will be able to join the channel using it."; +"LinkInvation.Confirm.Ok" = "Revoke"; +"LinkInvation.Group.Confirm.Revoke" = "Are you sure you want to revoke this link? Once the link is revoked, no one will be able to join using it."; +"LiteMode.DisableAlert" = "Disable Power Saving Mode"; +"LiteMode.Disabled" = "Disabled"; +"LiteMode.EnableText" = "Power Saving Mode"; +"LiteMode.Enabled" = "Enabled"; +"LiteMode.Info" = "Reduce all power-intensive animations and improve performance."; +"LiteMode.Key.animations.Info" = "Other animations that make Telegram look amazing."; +"LiteMode.Key.animations.Title" = "Interface Animations"; +"LiteMode.Key.blur.Info" = "Blur transparent interface elements."; +"LiteMode.Key.blur.Title" = "Blur Effects"; +"LiteMode.Key.emoji.Info" = "Loop animated emoji in messages, reactions and statuses."; +"LiteMode.Key.emoji.Title" = "Emoji Animations"; +"LiteMode.Key.emoji_effects.Info" = "Show animated effects for reactions and premium stickers."; +"LiteMode.Key.emoji_effects.Title" = "Emoji Effects"; +"LiteMode.Key.gif.Info" = "Autoplay and loop GIFs in chats and in the keyboard."; +"LiteMode.Key.gif.Title" = "Autoplay GIFs"; +"LiteMode.Key.liquidGlass.Info" = "Render UI surfaces with the macOS Tahoe liquid-glass effect. Turn off to fall back to a lightweight system blur and save GPU."; +"LiteMode.Key.liquidGlass.Title" = "Enable Liquid Glass"; +"LiteMode.Key.menu_animations.Info" = "Animate icons in context menus."; +"LiteMode.Key.menu_animations.Title" = "Menu Animations"; +"LiteMode.Key.stickers.Info" = "Loop animated stickers, in chats and in the keyboard."; +"LiteMode.Key.stickers.Title" = "Sticker Animations"; +"LiteMode.Key.video.Info" = "Autoplay and loop videos and video messages in chats."; +"LiteMode.Key.video.Title" = "Autoplay Videos"; +"LiteMode.LowPower" = "LOW POWER"; +"LiteMode.LowPower.Info" = "Switch Power Saving mode on when your battery is below %1$@%."; +"LiteMode.LowPower.InfoFull" = "Power saving mode can be turned on automatically based on battery level."; +"LiteMode.Title" = "Power Saving"; +"LiveStream.AllowComments" = "Allow Comments"; +"LiveStream.AllowScreenshots" = "Allow Screenshots"; +"LiveStream.PostToChannelInfo" = "Keep this stream on your channel profile."; +"LiveStream.PostToChannelProfile" = "Post to Channel Profile"; +"LiveStream.PricePerComment" = "PRICE PER COMMENT"; +"LiveStream.PricePerCommentInfo" = "The price a viewer must pay to send a comment."; +"LiveStream.Report" = "Report"; +"LiveStream.Save" = "Save"; +"LiveStream.Title" = "Live Stream"; +"Localization.Preview.Error.Generic" = "Sorry, this language doesn't seem to exist."; +"Location.SelectMyLocation" = "Select My Location"; +"Location.SelectThisLocation" = "Select This Location"; +"Location.Send.AccurateTo" = "Accurate to %@"; +"Location.Send.HideNearby" = "Hide nearby places"; +"Location.Send.Locating" = "Locating..."; +"Location.Send.MyLocation" = "Send My Current Location"; +"Location.Send.ShowNearby" = "Show nearby places"; +"Location.Send.ThisLocation" = "Send This Location"; +"Location.Send.ThisLocation.Unknown" = "Unknown Location"; +"Location.Send.Title" = "Location"; +"Location.Set.Location" = "Set This Location"; +"LocationPreview.DistanceAway" = "%@ away"; +"LocationPreview.Location" = "Location"; +"LocationPreview.OpenInMaps" = "Open in Google Maps"; +"LocationPreview.Title" = "Location Preview"; +"Log.Restart.Info" = "Restart your app to start collecting logs"; +"Login.ConnectionError.Header" = "Can't reach server"; +"Login.ConnectionError.Info" = "Please check your Internet connection and try again."; +"Login.ConnectionError.TryAgain" = "Try Again"; +"Login.ConnectionError.UseProxy" = "Use Proxy"; +"Login.ContinueOnLanguage" = "Continue in English"; +"Login.EmailChanged" = "Your email has been changed."; +"Login.EnterCodeFromApp" = "We've sent the code to the Telegram app on your other devices."; +"Login.EnterCodeNewEmailText" = "Please enter the code we have sent to your new email %@."; +"Login.EnterCodeNewEmailTitle" = "Check Your New Email"; +"Login.EnterNewEmailTitle" = "Enter New Email"; +"Login.EnterPasswordDescription" = "You have Two-Step Verification enabled, so your account is protected with an additional password."; +"Login.EnterPhraseBeginningText" = "We've sent you an SMS with a secret phrase that starts with **\"%1$@\"** to your phone **%2$@**."; +"Login.EnterPhrasePlaceholder" = "Enter Phrase from SMS"; +"Login.EnterPhraseText" = "We've sent you an SMS with a secret phrase to your phone **%@**."; +"Login.EnterPhraseTitle" = "Enter Phrase"; +"Login.EnterWordBeginningText" = "We've sent you an SMS with a secret word that starts with **\"%1$@\"** to your phone **%2$@**."; +"Login.EnterWordPlaceholder" = "Enter Word from SMS"; +"Login.EnterWordText" = "We've sent you an SMS with a secret word to your phone **%@**."; +"Login.EnterWordTitle" = "Enter Word"; +"Login.FloodWait" = "Attempts exceeded, please try again later."; +"Login.Header.Code" = "Enter Code"; +"Login.Header.Password" = "Enter Password"; +"Login.Header.SignUp" = "Sign Up"; +"Login.InvalidCountryCode" = "Invalid Country Code"; +"Login.InvalidFirstNameError" = "This first name is not allowed, please try another."; +"Login.InvalidLastNameError" = "Invalid last name. Please try again."; +"Login.JustSentSms" = "We've sent you a code via SMS. Please enter it above."; +"Login.New.Cancel.Confirm" = "Cancel logging in and start over?"; +"Login.New.Code.CallInfo" = "We’ve just called\nyour phone **%@** · [Edit]()"; +"Login.New.Code.CodeInfo" = "We’ve sent the code to the Telegram app\nfor **%@** on your device · [Edit]()"; +"Login.New.Code.Email" = "We’ve sent an e-mail with an activation code\nto %@"; +"Login.New.Code.EnterCode" = "Check your Telegram messages"; +"Login.New.Code.EnterSms" = "Enter Code"; +"Login.New.Code.FragmentInfo" = "Get the code for **%1$@** in the Numbers section on **Fragment** · [Edit]()"; +"Login.New.Code.SmsInfo" = "We’ve sent an SMS with an activation code\nto your phone **%@** · [Edit]()"; +"Login.New.Email.Alert" = "Your remaining options are either to remember your password or to reset your account."; +"Login.New.Email.Alert.Reset" = "Reset Account"; +"Login.New.Email.Footer" = "Unable to access [%@]()?"; +"Login.New.Email.Header" = "Email code"; +"Login.New.Email.Info" = "Check your email and enter the 6-digit code we sent there to deactivate your cloud password."; +"Login.New.Password.Info" = "You have two-step verification enabled, so your account is protected with an additional password."; +"Login.New.Password.Label" = "Your Password"; +"Login.New.Password.Placeholder" = "Enter Password"; +"Login.New.PhoneBannedError" = "This phone number is banned."; +"Login.New.PhoneNumber" = "Please confirm your country code\nand enter your phone number."; +"Login.New.Register.Footer" = "By signing up, you agree to the [Terms of Service]()"; +"Login.New.Register.Header" = "Profile Info"; +"Login.New.Register.Info" = "Enter your name and add a profile photo"; +"Login.New.Register.Next" = "Sign Up"; +"Login.New.Register.Remove" = "Remove"; +"Login.New.Register.Select" = "Select"; +"Login.New.Reset.Able" = "You can reset your account right now."; +"Login.New.Reset.Button" = "Reset"; +"Login.New.Reset.CancelReset" = "Cancel Reset"; +"Login.New.Reset.Header" = "Reset Account"; +"Login.New.Reset.Info" = "Since the account **%@** is active and protected by a password, it will be deleted in 1 week. This delay is required for security purposes.\n\n%@"; +"Login.New.Reset.When" = "You'll be able to reset your account in:"; +"Login.New.Reset.WillAble" = "You'll be able to reset your account in:"; +"Login.Next" = "Next"; +"Login.Next.Fragment" = "Open Fragment"; +"Login.PasswordForgot" = "Forgot password?"; +"Login.PhoneCalledCode" = "We've just called your number. Please enter the code above."; +"Login.PhoneDialed" = "Telegram dialed your number"; +"Login.PhoneNumberAlreadyAuthorized" = "This account is already logged in from this app."; +"Login.PhoneNumberAlreadyAuthorized.Switch" = "Switch"; +"Login.PhoneNumberNotRegistred" = "This phone number isn't registered. If you don't have a Telegram account yet, please sign up on your mobile device."; +"Login.QR.Cancel" = "Log in by phone number"; +"Login.QR.Help1" = "Open Telegram on your phone"; +"Login.QR.Help2" = "Go to **Settings** > **Devices** > **Add Device**"; +"Login.QR.Help3" = "Point your phone at this screen to confirm login"; +"Login.QR.Login" = "Log in by QR Code"; +"Login.QR.Title" = "Log in to Telegram by QR Code"; +"Login.QR1.Help1" = "Open Telegram on your phone"; +"Login.QR1.Help3" = "Point your phone at this screen to confirm login"; +"Login.QR2.Help2" = "Go to Settings > Devices > Add Device"; +"Login.RecoveryMailFailed" = "Since you didn't provide a recovery email when setting up your password, your remaining options are either to remember your password or to reset your account."; +"Login.Register.AddPhoto.Placeholder" = "add\nphoto"; +"Login.Register.Desc" = "Enter your name and add a profile photo."; +"Login.Register.FirstName.Placeholder" = "First Name"; +"Login.Register.LastName.Placeholder" = "Last Name"; +"Login.ResetAccount" = "RESET MY ACCOUNT"; +"Login.ResetAccountDescription" = "You will lose all your Telegram chats, messages, media and files if you proceed with resetting your account."; +"Login.ResetAccountText" = "Reset Account"; +"Login.SendSmsIfNotReceivedAppCode" = "Didn't get the code?"; +"Login.Sms.AppErr" = "If you already signed up for Telegram, please enter the code which was sent to your mobile app via Telegram.\n\nIf you haven’t signed up yet, please register from your phone or tablet first."; +"Login.Sms.AppErr.GotoSite" = "Open Site"; +"Login.WelcomeDescription" = "Welcome to the macOS application"; +"Login.WrongPhraseError" = "Incorrect, please try again."; +"Login.YourCodeLabel" = "your code"; +"Login.YourPasswordLabel" = "your password"; +"Login.YourPhoneLabel" = "your phone"; +"Login.codePlaceholder" = "code"; +"Login.countryLabel" = "country"; +"Login.passwordPlaceholder" = "password"; +"Login.phoneFieldPlaceholder" = "phone number"; +"Login.willCall" = "Telegram will call you in %d:%@"; +"Login.willSendSms" = "Sending you an SMS in %d:%@"; +"LoginEmail.Description" = "Please add your email address to keep access to your account."; +"LoginEmail.Success.Text" = "Your account is now protected!"; +"LoginEmail.Success.Title" = "Email added"; +"LoginEmail.Title" = "Add Email"; +"LoginWidget.Allow" = "Allow"; +"LoginWidget.AllowMessages" = "Allow messages"; +"LoginWidget.Cancel" = "Cancel"; +"LoginWidget.Confirmation.TapNumber" = "Tap the emoji shown on your login screen"; +"LoginWidget.Confirmation.VerifyIdentity" = "Telegram wants to make sure it's really you."; +"LoginWidget.Deny" = "Deny"; +"LoginWidget.Device" = "Device"; +"LoginWidget.IPAddress" = "IP Address"; +"LoginWidget.LogIn" = "Log in"; +"LoginWidget.LogInTo" = "Log in to %1$@"; +"LoginWidget.LogInToApp" = "Log in to app %1$@"; +"LoginWidget.LoggedInNoPhone" = "You're logged in to %1$@, but you didn't grant access to your phone number."; +"LoginWidget.LoggedInSuccess" = "You're now logged in to %1$@."; +"LoginWidget.LoginAttemptInfo" = "This login attempt came from the device above."; +"LoginWidget.PermissionApplyTo" = "This permission will apply to [%1$@]()."; +"LoginWidget.PermissionInfo" = "The site will receive your **name**, **username** and **profile photo**."; +"LoginWidget.PermissionPhone" = "The site will receive your **phone number**."; +"LoginWidget.PhoneAccessInfo" = "**%1$@** wants to access your phone number **%2$@**.\n\nAllow access?"; +"LoginWidget.PhoneNumber" = "Phone Number"; +"LoginWidget.TryAgain" = "Please try logging in to %1$@ again."; +"LoginWidget.UnverifiedApp" = "Unverified App"; +"LogoutOptions.AddAccountText" = "Connect other Telegram accounts and easily switch between them."; +"LogoutOptions.AddAccountTitle" = "Add another account"; +"LogoutOptions.AlternativeOptionsSection" = "ALTERNATIVE OPTIONS"; +"LogoutOptions.ChangePhoneNumberText" = "Move your contacts, groups, messages and media to a new number."; +"LogoutOptions.ChangePhoneNumberTitle" = "Change Phone Number"; +"LogoutOptions.ClearCacheText" = "Free up disk space on your device; your media will stay in the cloud."; +"LogoutOptions.ClearCacheTitle" = "Clear Cache"; +"LogoutOptions.ContactSupportText" = "Tell us about any issues; logging out doesn't usually help."; +"LogoutOptions.ContactSupportTitle" = "Contact Support"; +"LogoutOptions.LogOut" = "Log Out"; +"LogoutOptions.LogOutInfo" = "Remember, logging out kills all your Secret Chats."; +"LogoutOptions.SetPasscodeText" = "Lock the app with a passcode so that others can't open it."; +"LogoutOptions.SetPasscodeTitle" = "Set a Passcode"; +"LogoutOptions.Title" = "Log out"; +"Logs.Enabled" = "Logs are enabled"; +"MESSAGE_INVOICE" = "%1$@ sent you an invoice for %2$@"; +"ManageLinks.AdditionLinks" = "ADDITIONAL LINKS"; +"ManageLinks.Admin.Permanent.Desc" = "**%1$@** can see this link and use it to invite new members to **%2$@**."; +"ManageLinks.Context.Copy" = "Copy Link"; +"ManageLinks.Context.Edit" = "Edit Link"; +"ManageLinks.Context.Revoke" = "Revoke Link"; +"ManageLinks.Context.Share" = "Share Link"; +"ManageLinks.CreateNew" = "Create a New Link"; +"ManageLinks.Delete" = "Delete"; +"ManageLinks.DeleteAll" = "Delete All Revoked Links"; +"ManageLinks.DeleteAll.Confirm" = "Are you sure you want to delete all revoked links?"; +"ManageLinks.EmptyDesc" = "You can create additional invite links that have limited time or number of users."; +"ManageLinks.Header.Channel.Desc" = "Anyone who has Telegram installed will be able to join your channel by following this link."; +"ManageLinks.Header.Desc" = "Anyone who has Telegram installed will be able to join your group by following this link."; +"ManageLinks.Header.Group.Desc" = "Anyone who has Telegram installed will be able to join your group by following this link."; +"ManageLinks.InviteLink" = "INVITE LINK"; +"ManageLinks.OtherAdmins" = "INVITE LINKS CREATED BY OTHER ADMINS"; +"ManageLinks.Permanent" = "PERMANENT LINK"; +"ManageLinks.RevokedLinks" = "REVOKED LINKS"; +"ManageLinks.Title" = "Title"; +"ManageLinks.TitleCount#one" = "%d invite link"; +"ManageLinks.TitleCount#other" = "%d invite links"; +"ManageLinks.TitleCount_countable" = "%d"; +"ManageLinks.TitleNew" = "Invite Links"; +"Mark.Fake" = "FAKE"; +"Mark.Scam" = "SCAM"; +"MediaCell.Unsupported" = "Unsupported"; +"MediaSender.DiscardChanges.Header" = "Discard Changes"; +"MediaSender.DiscardChanges.OK" = "Discard"; +"MediaSender.DiscardChanges.Text" = "Are you sure you want to discard all changes?"; +"Message.AccessoryPanel.Forwarded#one" = "%d forwarded message"; +"Message.AccessoryPanel.Forwarded#other" = "%d forwarded messages"; +"Message.AccessoryPanel.Forwarded_countable" = "%d"; +"Message.ActionsPanel.Delete" = "Delete"; +"Message.ActionsPanel.EmptySelected" = "Select messages"; +"Message.ActionsPanel.Forward" = "Forward"; +"Message.ActionsPanel.SelectedCount#one" = "%d message selected"; +"Message.ActionsPanel.SelectedCount#other" = "%d messages selected"; +"Message.ActionsPanel.SelectedCount_countable" = "%d"; +"Message.Context.Confirm.NotifyPin" = "Notify all members"; +"Message.Context.Confirm.Pin1" = "Would you like to pin this message?"; +"Message.Context.Delete" = "Delete"; +"Message.Context.Edit" = "Edit"; +"Message.Context.Forward" = "Forward"; +"Message.Context.ForwardToCloud" = "Forward to Saved Messages"; +"Message.Context.Goto" = "Show Message"; +"Message.Context.OpenWith" = "Open With..."; +"Message.Context.Pin" = "Pin"; +"Message.Context.RemoveGif" = "Remove GIF"; +"Message.Context.Reply1" = "Reply"; +"Message.Context.ReplyHelp" = "double click"; +"Message.Context.Report" = "Report"; +"Message.Context.Report.AlertOK" = "Thank you! Your report will be reviewed by our team."; +"Message.Context.SaveGif" = "Add GIF"; +"Message.Context.Select" = "Select"; +"Message.Context.Share" = "Share"; +"Message.Context.Unpin" = "Unpin"; +"Message.Context.ViewComments#one" = "View %d Comment"; +"Message.Context.ViewComments#other" = "View %d Comments"; +"Message.Context.ViewComments_countable" = "%d"; +"Message.Context.ViewReplies#one" = "View %d Reply"; +"Message.Context.ViewReplies#other" = "View %d Replies"; +"Message.Context.ViewReplies_countable" = "%d"; +"Message.Context.ViewThread" = "View Thread"; +"Message.GiveawayFinished" = "Giveaway: %1$@ on %2$@"; +"Message.GiveawayOngoing" = "Giveaway: %1$@ on %2$@"; +"Message.GiveawayOngoing.Winners#one" = "%d winner to be selected"; +"Message.GiveawayOngoing.Winners#other" = "%d winners to be selected"; +"Message.GiveawayOngoing.Winners_countable" = "%d"; +"Message.GiveawayResult" = "Giveaway Result"; +"Message.GiveawayStarted" = "Channel started a giveaway"; +"Message.GiveawayStartedOther" = "%@ started a giveaway"; +"Message.InvoiceLabel" = "INVOICE"; +"Message.PaymentSent" = "Payment: %@"; +"Message.PinnedInvoice" = "pinned an invoice"; +"Message.Placeholder.Edit" = "Edit Message..."; +"Message.Placeholder.MessageInGeneral" = "Message in General..."; +"Message.Placeholder.PaidMessage" = "Message for %@..."; +"Message.Placeholder.ReplyToTopic" = "Reply to %@..."; +"Message.ReplyActionButtonShowReceipt" = "Show Receipt"; +"Message.Status.Archive.Failed" = "failed to create archive"; +"Message.Status.Archive.FailedSizeLimit" = "file size limit exceeded"; +"Message.Status.ArchivePreparing" = "preparing archive"; +"Message.Status.Archived" = "archived folder"; +"Message.Status.Archiving" = "%d% archived"; +"Message.Status.Archiving1" = "%@ archived"; +"Message.Text.CopyMusicTitle" = "Copy Track Name"; +"MessageContext.CopyMessageLink1" = "Copy Message Link"; +"MessagePollStats.Title" = "Poll Stats"; +"MessagePollStats.VoteTimeline" = "VOTE TIMELINE"; +"MessageTimer.ShortDays" = "%@d"; +"MessageTimer.ShortHours" = "%@h"; +"MessageTimer.ShortMinutes" = "%@m"; +"MessageTimer.ShortMonths" = "%@M"; +"MessageTimer.ShortSeconds" = "%@s"; +"MessageTimer.ShortWeeks" = "%@w"; +"Messages.DeletedMessage" = "Deleted message"; +"Messages.File.State.FetchingIn_1" = "%d% downloaded"; +"Messages.File.State.FetchingIn_2" = "%@ downloaded"; +"Messages.File.State.FetchingOut_1" = "%d% uploaded"; +"Messages.File.State.FetchingOut_2" = "%@ uploaded"; +"Messages.File.State.Local" = "Show in Finder"; +"Messages.File.State.Remote" = "Download"; +"Messages.ForwardHeader" = "Forwarded messages"; +"Messages.Placeholder.Anonymous" = "Send Anonymously..."; +"Messages.Placeholder.Broadcast" = "Broadcast a message…"; +"Messages.Placeholder.Broadcast.Small" = "Broadcast..."; +"Messages.Placeholder.Comment" = "Comment..."; +"Messages.Placeholder.Reply" = "Reply..."; +"Messages.Placeholder.Scheduled" = "Schedule a message..."; +"Messages.Placeholder.SentMessage" = "Write a message..."; +"Messages.Placeholder.SentMessage.Small" = "Message..."; +"Messages.Placeholder.SilentBroadcast" = "Silent Broadcast..."; +"Messages.ReplyLoading.Header" = "Reply"; +"Messages.ReplyLoading.Loading" = "Loading..."; +"Messages.Silent.Tooltip" = "subscribers will be notified when you post."; +"Messages.Silent.Tooltip.Silent" = "subscribers will receive a silent notification."; +"Messages.UnreadMark" = "Unread messages"; +"MessagesPrivacy.ChargeforMessages" = "Charge for Messages"; +"MessagesPrivacy.ChargeforMessages.Exceptions" = "EXCEPTIONS"; +"MessagesPrivacy.ChargeforMessages.Exceptions.Info" = "Add users or entire groups who won't be charged for sending messages to you"; +"MessagesPrivacy.ChargeforMessages.Exceptions.RemoveFee" = "Remove Fee"; +"MessagesPrivacy.ChargeforMessages.Info" = "Charge a fee for messages from people outside your contacts or those you haven't messaged first."; +"MessagesPrivacy.ChargeforMessages.Select.Header" = "SET YOUR PRICE PER MESSAGE"; +"MessagesPrivacy.ChargeforMessages.Select.Info" = "You will receive %1$@ of the selected fee (~%2$@$) for each incoming message."; +"Modal.Apply" = "Apply"; +"Modal.Cancel" = "Cancel"; +"Modal.CopyLink" = "Copy Link"; +"Modal.Create" = "Create"; +"Modal.Delete" = "Delete"; +"Modal.Done" = "Done"; +"Modal.NotNow" = "Not Now"; +"Modal.OK" = "OK"; +"Modal.Remove" = "Remove"; +"Modal.Report" = "Report"; +"Modal.Reset" = "Reset"; +"Modal.Save" = "Save"; +"Modal.Send" = "Send"; +"Modal.Set" = "Set"; +"Modal.Share" = "Share"; +"Modal.Yes" = "YES"; +"Monetization.AdRevenueTitle" = "AD REWARDS"; +"Monetization.Balance.ComingLaterInfo" = "You will be able to collect rewards using Fragment, a third-party platform used by advertisers to pay for ads. [Terms & Conditions >](https://telegram.org/tos/content-creator-rewards)"; +"Monetization.BalanceInfo" = "You will be able to collect rewards using Fragment, a third-party platform used by advertisers to pay for ads. [Terms & Conditions >](https://telegram.org/tos/content-creator-rewards)"; +"Monetization.BalanceInfo_URL" = "https://telegram.org/tos/content-creator-rewards"; +"Monetization.BalanceTitle" = "AVAILABLE REWARD"; +"Monetization.BalanceWithdraw" = "Collect via Fragment"; +"Monetization.BalanceWithdraw.Stars" = "Withdraw via Fragment"; +"Monetization.BalanceWithdraw.Ton" = "Withdraw via Fragment"; +"Monetization.Header" = "Telegram shares 50%@ of the revenue from ads displayed in your channel as rewards. [Learn More >]()"; +"Monetization.Header.Bot" = "Telegram shares 50%@ of the revenue from ads displayed in your bot as rewards. [Learn More >]()"; +"Monetization.ImpressionsTitle" = "AD IMPRESSIONS (BY HOURS)"; +"Monetization.Intro.Ads.Text" = "Telegram can display ads in your channel."; +"Monetization.Intro.Ads.Title" = "Telegram Ads"; +"Monetization.Intro.Info.Text" = "Gram is the cryptocurrency of the TON blockchain platform, which Telegram uses for its high speed and low commissions on transactions. [Learn More >](https://telegram.org/blog/monetization-for-channels)"; +"Monetization.Intro.Info.Title" = "What's %@ Gram?"; +"Monetization.Intro.Split.Text" = "You receive 50%@ of the ad revenue as rewards in Grams."; +"Monetization.Intro.Split.Title" = "50:50 Revenue Split"; +"Monetization.Intro.Title" = "Earn From Your Channel"; +"Monetization.Intro.Understood" = "Understood"; +"Monetization.Intro.Withdrawal.Text" = "You can collect your Grams any time."; +"Monetization.Intro.Withdrawal.Title" = "Flexible Withdrawals"; +"Monetization.IntroBot.Ads.Text" = "Telegram can display ads in your bot."; +"Monetization.IntroBot.Title" = "Earn From Your Bot"; +"Monetization.Overview.Available" = "Rewards Available for Collection"; +"Monetization.Overview.Current" = "Rewards Since Last Collection"; +"Monetization.Overview.Total" = "Total Lifetime Rewards"; +"Monetization.OverviewTitle" = "REWARDS OVERVIEW"; +"Monetization.Stars.WithdrawInfo" = "You can collect rewards for Stars using Fragment, or use Stars to advertise your bot. [Learn More >](https://telegram.org/tos/bot-developers#6-2-2-tpa-balance)"; +"Monetization.Stars.WithdrawTitle" = "STARS BALANCE"; +"Monetization.SwitchOffAds" = "Switch off Ads"; +"Monetization.SwitchOffAdsInfo" = "You will not be eligible for any rewards if you switch off ads."; +"Monetization.Transaction.Failed" = "Not Completed"; +"Monetization.Transaction.Pending" = "Pending"; +"Monetization.Transaction.Proceeds" = "Rewards from Ads"; +"Monetization.Transaction.Refund" = "Refund"; +"Monetization.Transaction.ShowMoreTransactions#one" = "Show %d More Transaction"; +"Monetization.Transaction.ShowMoreTransactions#other" = "Show %d More Transactions"; +"Monetization.Transaction.ShowMoreTransactions_countable" = "Show %d More Transaction"; +"Monetization.Transaction.Withdrawal" = "Collection via %@"; +"Monetization.TransactionInfo.Failed" = "Not Completed"; +"Monetization.TransactionInfo.Pending" = "Pending"; +"Monetization.TransactionInfo.Proceeds" = "Rewards from Ads displayed in"; +"Monetization.TransactionInfo.Refund" = "Refund"; +"Monetization.TransactionInfo.ViewInExplorer" = "View in Blockchain Explorer"; +"Monetization.TransactionInfo.Withdrawal" = "Collecting Rewards via %@"; +"Monetization.Transactions.Stars" = "Stars Transactions"; +"Monetization.Transactions.TON" = "Gram Transactions"; +"Monetization.TransactionsTitle" = "TRANSACTION HISTORY"; +"Monetization.Withdraw.EnterPassword.Done" = "Proceed"; +"Monetization.Withdraw.EnterPassword.Text" = "Please enter your Two-Step Verification password to complete this action."; +"Monetization.Withdraw.EnterPassword.Title" = "Enter Password"; +"Monetization.Withdraw.Error.Text" = "You can only collect rewards if:\n\n• 2-Step Verification was enabled for your account more than 7 days ago.\n\n• You have logged in on this device more than 24 hours ago.\n\nPlease come back later."; +"Monoforum.SentBy" = "Sent by [%@]()"; +"Navigation.Add" = "Add"; +"Navigation.Cancel" = "Cancel"; +"Navigation.Close" = "Close"; +"Navigation.Done" = "Done"; +"Navigation.Edit" = "Edit"; +"Navigation.Next" = "Next"; +"Navigation.back" = "Back"; +"NetworkUsage.BytesReceived" = "Data Received"; +"NetworkUsage.BytesSent" = "Data Sent"; +"NetworkUsage.Header.Audio" = "AUDIO"; +"NetworkUsage.Header.Files" = "FILES"; +"NetworkUsage.Header.Generic" = "MESSAGES"; +"NetworkUsage.Header.Images" = "PHOTOS"; +"NetworkUsage.Header.Videos" = "VIDEOS"; +"NetworkUsage.NetworkUsage" = "Network Usage"; +"NetworkUsage.NetworkUsage.Reset" = "Reset Network Usage"; +"NetworkUsage.NetworkUsageSince" = "Network usage since %@"; +"NetworkUsage.NetworkUsageSinceNever" = "Network usage since you've logged in on this device."; +"NetworkUsage.Reset" = "Reset Statistics"; +"NetworkUsage.ResetConfirm.Info" = "Are you sure you want to reset statistics?"; +"NetworkUsage.ResetConfirm.Ok" = "Reset"; +"NetworkUsage.Section.Info" = "Click on each section for details."; +"NetworkUsage.Title.Audio" = "Music"; +"NetworkUsage.Title.Files" = "Files"; +"NetworkUsage.Title.Generic" = "Messages"; +"NetworkUsage.Title.Images" = "Photos"; +"NetworkUsage.Title.Videos" = "Videos"; +"NewContact.Exception.ShareMyPhoneNumber" = "Share My Phone Number"; +"NewContact.Exception.ShareMyPhoneNumber.Desc" = "You can make your phone visible to %@."; +"NewContact.Phone" = "phone number"; +"NewContact.Phone.Hidden" = "Hidden"; +"NewContact.Phone.Hidden.Text" = "Phone number will be **visible** once %@ adds you as a contact."; +"NewContact.Title" = "New Contact"; +"NewPoll.Add" = "Add"; +"NewPoll.AddOptionTitle" = "Add an Option"; +"NewPoll.AllowAddingDesc" = "Participants can suggest new options"; +"NewPoll.AllowAddingOptions" = "Allow Adding Options"; +"NewPoll.AllowMultipleAnswers" = "Allow Multiple Answers"; +"NewPoll.AllowMultipleDesc" = "Voters can select more than one option"; +"NewPoll.AllowRevoting" = "Allow Revoting"; +"NewPoll.AllowRevotingDesc" = "Voters can change their vote"; +"NewPoll.AllowedCountries" = "Allowed Countries"; +"NewPoll.Anonymous" = "Anonymous Voting"; +"NewPoll.AttachDelete" = "Delete"; +"NewPoll.AttachFile" = "File"; +"NewPoll.AttachLocation" = "Location"; +"NewPoll.AttachPhotoVideo" = "Photo or Video"; +"NewPoll.AttachSticker" = "Sticker"; +"NewPoll.CorrectAnswerDescMultiple" = "Mark one or more options as the right answer"; +"NewPoll.CorrectAnswerDescSingle" = "Mark one option as the right answer"; +"NewPoll.CountriesCount#other" = "%1$d countries"; +"NewPoll.CountriesNone" = "None"; +"NewPoll.Custom" = "Custom"; +"NewPoll.DateFormat" = "d MMM 'at' HH:mm"; +"NewPoll.Deadline" = "Deadline"; +"NewPoll.DescriptionPlaceholder" = "Add Description (optional)"; +"NewPoll.DisacardConfirm" = "Are you sure you want to discard this poll?"; +"NewPoll.DisacardConfirm.No" = "No"; +"NewPoll.DisacardConfirm.Yes" = "Discard"; +"NewPoll.DisacardConfirmHeader" = "Poll"; +"NewPoll.Duration" = "Duration"; +"NewPoll.Duration.1Hour" = "1 hour"; +"NewPoll.Duration.24Hours" = "24 hours"; +"NewPoll.Duration.3Hours" = "3 hours"; +"NewPoll.Duration.72Hours" = "72 hours"; +"NewPoll.Duration.8Hours" = "8 hours"; +"NewPoll.Duration.Hours#other" = "%1$d hours"; +"NewPoll.Explanation.Desc" = "Users will see this comment after choosing a wrong answer, good for educational purposes."; +"NewPoll.Explanation.Header" = "EXPLANATION"; +"NewPoll.Explanation.Placeholder" = "Add an Explanation (Optional)"; +"NewPoll.HideResults" = "Hide Results"; +"NewPoll.HideResultsDesc" = "When enabled, results will not be visible until the poll closes."; +"NewPoll.LimitByCountry" = "Limit by Country"; +"NewPoll.LimitByCountryDesc" = "Only users from selected countries can vote"; +"NewPoll.LimitDuration" = "Limit Duration"; +"NewPoll.LimitDurationDesc" = "Automatically close the poll at a set time"; +"NewPoll.MediaUploading" = "Please wait until the media has finished uploading."; +"NewPoll.MultipleChoice" = "Multiple Answers"; +"NewPoll.OptionsAddOption" = "Add an Option"; +"NewPoll.OptionsDescription#one" = "You can add %d more options"; +"NewPoll.OptionsDescription#other" = "You can add %d more options"; +"NewPoll.OptionsDescriptionLimitReached" = "You have added the maximum number of options."; +"NewPoll.OptionsDescriptionMinimum#one" = "Minimum %d option"; +"NewPoll.OptionsDescriptionMinimum#other" = "Minimum %d options"; +"NewPoll.OptionsDescriptionMinimum_countable" = "%d"; +"NewPoll.OptionsDescription_countable" = "%d"; +"NewPoll.OptionsHeader" = "POLL OPTIONS"; +"NewPoll.OptionsPlaceholder" = "Option"; +"NewPoll.PollEnds" = "Poll Ends"; +"NewPoll.QuestionHeader" = "QUESTION"; +"NewPoll.QuestionHeaderLimit" = "QUESTION (%d)"; +"NewPoll.QuestionPlaceholder" = "Ask a question"; +"NewPoll.Quiz" = "Quiz Mode"; +"NewPoll.QuizDesc" = "Polls in Quiz Mode have one correct answer. Users can't revoke their answers."; +"NewPoll.QuizMultiple.Error" = "Polls in quiz mode can have only one correct answer."; +"NewPoll.QuizTooltip" = "Click to choose the correct answer"; +"NewPoll.RestrictToSubscribers" = "Restrict to Subscribers"; +"NewPoll.RestrictToSubscribersDesc" = "Only subscribers who joined 24+ hours ago can vote"; +"NewPoll.SetCorrectAnswer" = "Set Correct Answer"; +"NewPoll.SetDeadline" = "Set Deadline"; +"NewPoll.SettingsHeader" = "SETTINGS"; +"NewPoll.ShowWhoVoted" = "Show Who Voted"; +"NewPoll.ShowWhoVotedDesc" = "Display voter name on each option"; +"NewPoll.ShuffleDesc" = "Answers appear in random order for each voter"; +"NewPoll.ShuffleOptions" = "Shuffle Options"; +"NewPoll.Title" = "New Poll"; +"NewPoll.Title.Quiz" = "New Quiz"; +"NewSession.IsntMe" = "No, it's not me!"; +"NewSession.ItsMe" = "Yes, it's me"; +"NewSessionReview.Accept.Text" = "You can check the list of your active logins in\n[Settings > Active Sessions]()."; +"NewSessionReview.Accept.Title" = "New Login Allowed"; +"NewSessionReview.Item.Text" = "We detected a new login to your account from %1$@, %2$@. Is it you?"; +"NewSessionReview.Item.Title" = "Someone just got access to your messages!"; +"NewSessionReview.Modal.Button" = "Got It"; +"NewSessionReview.Modal.Button.Timeout" = "Got It (%@)"; +"NewSessionReview.Modal.Header" = "Never send your login code to anyone or you can lose your Telegram account!"; +"NewSessionReview.Modal.Text" = "We have terminated the login attempt from %1$@, %2$@."; +"NewSessionReview.Modal.Title" = "New Login Prevented"; +"NewTheme.Create" = "Create"; +"NewTheme.Desc" = "This theme will be based on your current theme."; +"NewTheme.EmptyTextError" = "name can't be empty."; +"NewTheme.Placeholder" = "Theme name"; +"NewTheme.Title" = "New Theme"; +"NewTodo.Action.Add" = "Add"; +"NewTodo.Action.Edit" = "Edit"; +"NewTodo.Action.Send" = "Send"; +"NewTodo.Input.Task.Placeholder" = "Task"; +"NewTodo.Input.Title.Placeholder" = "Title..."; +"NewTodo.Option.Add" = "Add Task"; +"NewTodo.Section.Checklist" = "Checklist"; +"NewTodo.Title.AddTask" = "Add Task"; +"NewTodo.Title.Create" = "New Checklist"; +"NewTodo.Title.Edit" = "Edit Checklist"; +"NewTodo.Toggle.OthersCanAppend" = "Allow Others to Add Tasks"; +"NewTodo.Toggle.OthersCanComplete" = "Allow Others to Mark as Done"; +"Notification.Boost.Multiple" = "%1$@ boosted the group %2$@"; +"Notification.Boost.MultipleYou" = "You boosted the group %1$@"; +"Notification.Boost.Single" = "%1$@ boosted the group"; +"Notification.Boost.SingleYou" = "You boosted the group"; +"Notification.Boost.Times#one" = "%@ time"; +"Notification.Boost.Times#other" = "%@ times"; +"Notification.Boost.Times_countable" = "%d"; +"Notification.Contact.Reacted" = "%1$@ to your \"%2$@\""; +"Notification.Group.Reacted" = "%1$@: %2$@ to your \"%3$@\""; +"Notification.GroupCall.Incoming" = "Incoming Group Call"; +"Notification.GroupCall.Outgoing" = "Outgoing Group Call"; +"Notification.GroupCreatorChangeApplied" = "%1$@ has transferred ownership of the group to %2$@."; +"Notification.GroupCreatorChangePending" = "%2$@ will become the new owner in 7 days if %1$@ does not return."; +"Notification.Input.Reply" = "Type a message..."; +"Notification.LockedPreview" = "You have a new message"; +"Notification.MarkAsRead" = "Mark as Read"; +"Notification.PaidMessage" = "%1$@ paid %2$@ to send a message"; +"Notification.PaidMessageMany" = "%1$@ paid %2$@ to send %3$@"; +"Notification.PaidMessagePriceChanged" = "%1$@ changed price to %2$@ per message"; +"Notification.PaidMessagePriceChangedYou" = "You changed price to %1$@ per message"; +"Notification.PaidMessageRefund" = "%1$@ refunded you %2$@"; +"Notification.PaidMessageRefundYou" = "You refunded %1$@ to %2$@"; +"Notification.PaidMessageYou" = "You paid %1$@ to send a message"; +"Notification.PaidMessageYouMany" = "You paid %1$@ to send %2$@"; +"Notification.PaidMessages#one" = "%d message"; +"Notification.PaidMessages#other" = "%d messages"; +"Notification.PaidMessages_countable" = "%d"; +"Notification.ProximityReached" = "%@ is now within %@ from %@"; +"Notification.ProximityReachedYou" = "%@ is now within %@ from you"; +"Notification.ProximityReachedYou_1" = "%1$@ is now within %2$@ from you"; +"Notification.ProximityReached_1" = "%1$@ is now within %2$@ from %3$@"; +"Notification.ProximityYouReached_1" = "You are now within %1$@ from %2$@"; +"Notification.Reminder" = "📆 Reminder"; +"Notification.Reply" = "Reply"; +"Notification.ScheduledTitle" = "📆 You"; +"Notification.StarGift.Self.Title" = "Saved Gift"; +"Notification.StarsGift.Self.AuctionBought" = "You've successfully bought a gift in the auction for %@"; +"Notification.StarsGift.Self.Bought" = "You bought a gift for %@"; +"Notification.StarsGift.Subtitle.Self" = "You can add this gift to your profile or turn it into a unique transferable collectible."; +"Notification.StarsGift.Transfer" = "%@ transferred a unique collectible to you"; +"Notification.StarsGift.TransferYou" = "You transferred a unique collectible"; +"Notification.StarsGift.Upgrade" = "%@ turned the gift from you into a unique collectible"; +"Notification.StarsGift.Upgrade.Channel" = "%@ turned the gift into a unique collectible"; +"Notification.StarsGift.UpgradeFromOther" = "You sent an upgrade worth **%@** for the gift **%@** received from **%@**"; +"Notification.StarsGift.UpgradeGeneric" = "You sent an upgrade worth **%@** for the gift to **%@**"; +"Notification.StarsGift.UpgradePrepaid" = "%@ unboxed the gift that you helped upgrade"; +"Notification.StarsGift.UpgradePrepaidYou" = "You unpacked the gift that %@ helped upgrade"; +"Notification.StarsGift.UpgradeToSelf" = "You sent an upgrade worth **%@** for the gift **%@** received from you"; +"Notification.StarsGift.UpgradeYou" = "You turned the gift from %@ into a unique collectible"; +"Notification.StoryReply" = "Replied to your story: %@"; +"Notification.Title.Reply" = "Reply"; +"NotificationSettings.AllAccounts" = "All Accounts"; +"NotificationSettings.BadgeDesc" = "Switch off to show the number of unread chats instead of messages."; +"NotificationSettings.BadgeEnabled" = "Enabled"; +"NotificationSettings.BadgeHeader" = "BADGE COUNTER"; +"NotificationSettings.BounceDockIcon" = "Bounce Dock Icon"; +"NotificationSettings.Confirm.Reset" = "Reset notifications"; +"NotificationSettings.ContactJoined" = "New Contacts"; +"NotificationSettings.ContactJoinedInfo" = "Receive notifications when one of your contacts becomes available on Telegram."; +"NotificationSettings.CountUnreadMessages" = "Count Unread Messages"; +"NotificationSettings.IncludeChannels" = "Include Channels"; +"NotificationSettings.IncludeGroups" = "Include Groups"; +"NotificationSettings.IncludeMutedChats" = "Include Muted Chats"; +"NotificationSettings.IncludePublicGroups" = "Include Public Groups"; +"NotificationSettings.MessagesPreview" = "Message Preview"; +"NotificationSettings.NotificationTone" = "Notification Tone"; +"NotificationSettings.ResetNotifications" = "Reset Notifications"; +"NotificationSettings.ResetNotificationsText" = "You can set custom notifications for specific chats below."; +"NotificationSettings.SendMessageEffect" = "Sent Message"; +"NotificationSettings.ShowNotificationsFrom" = "SHOW NOTIFICATIONS FROM"; +"NotificationSettings.ShowNotificationsFrom.Off" = "Turn this on if you want to receive notifications from all your accounts."; +"NotificationSettings.ShowNotificationsFrom.On" = "Turn this off if you want to receive notifications only from the account you are currently using."; +"NotificationSettings.Snoof" = "Show Notifications"; +"NotificationSettings.Snoof.Off" = "Turn this on if you want to always receive notifications."; +"NotificationSettings.Snoof.On" = "Turn this off if you want to receive notifications only when application is not in focus."; +"NotificationSettings.SnoofHeader" = "WHEN APP IS IN FOCUS"; +"NotificationSettings.SoundEffects" = "SOUND EFFECTS"; +"NotificationSettings.ToggleNotifications" = "Notifications"; +"NotificationSettings.ToggleNotifications.Header" = "NOTIFICATIONS"; +"NotificationSettings.Tone.Default" = "Default"; +"NotificationSettings.TurnOn" = "Allow in System Settings"; +"NotificationSettings.TurnOn.Text.Text" = "Don't miss important messages from your family and friends."; +"NotificationSettings.TurnOn.Text.Title" = "Allow Notifications"; +"NotificationSettingsToneNone" = "None"; +"NotificationSound.Title" = "Notification Sound"; +"NotificationSound.Tone.Voice" = "Voice Message"; +"NotificationSound.Tones.Info" = "Open the context menu of any short voice note or MP3 file in any chat and select \"Save for Notifications\". It will appear here."; +"NotificationSound.Tones.SizeError" = "The file should be less or equal to %@"; +"NotificationSound.Tones.Title" = "TELEGRAM TONES"; +"NotificationSound.Tones.Upload" = "Upload Sound"; +"Notifications.Snooze" = "Mute"; +"NotificationsSound.Alert" = "Alert"; +"NotificationsSound.Aurora" = "Aurora"; +"NotificationsSound.Bamboo" = "Bamboo"; +"NotificationsSound.Bell" = "Bell"; +"NotificationsSound.Calypso" = "Calypso"; +"NotificationsSound.Chime" = "Chime"; +"NotificationsSound.Chord" = "Chord"; +"NotificationsSound.Circles" = "Circles"; +"NotificationsSound.Complete" = "Complete"; +"NotificationsSound.Glass" = "Glass"; +"NotificationsSound.Hello" = "Hello"; +"NotificationsSound.Input" = "Input"; +"NotificationsSound.Keys" = "Keys"; +"NotificationsSound.None" = "None"; +"NotificationsSound.Note" = "Note"; +"NotificationsSound.Popcorn" = "Popcorn"; +"NotificationsSound.Pulse" = "Pulse"; +"NotificationsSound.Synth" = "Synth"; +"NotificationsSound.Telegraph" = "Telegraph"; +"NotificationsSound.Tremolo" = "Tremolo"; +"NotificationsSound.Tritone" = "Tri-tone"; +"OY7-WF-poV.title" = "Minimize"; +"Olw-nP-bQN.title" = "Hide"; +"PASSWORD_HASH_INVALID" = "Incorrect password"; +"PHONE_CODE_EXPIRED" = "Code expired, please start over."; +"PHONE_CODE_INVALID" = "Invalid code"; +"PHONE_NUMBER_INVALID" = "Invalid phone number"; +"PINNED_INVOICE" = "%1$@ pinned an invoice"; +"PaidMedia.Header" = "ENTER UNLOCK COST"; +"PaidMedia.Info" = "Users will have to transfer this amount of Stars to your channel in order to view this media. [More about stars >](https://telegram.org/blog/telegram-stars)"; +"PaidMedia.Ok" = "Make This Media Paid"; +"PaidMedia.Placeholder" = "Stars to Unlock"; +"PaidMedia.Status.Purchased" = "Purchased"; +"PaidMedia.Title" = "Paid Content"; +"PaidMedia.UnlockFor#one" = "Unlock for # %d"; +"PaidMedia.UnlockFor#other" = "Unlock for # %d"; +"PaidMedia.UnlockFor_countable" = "%d"; +"Passcode.AutoLock.Disabled" = "Disabled"; +"Passcode.AutoLock.IfAway" = "If away for %@"; +"Passcode.Autolock" = "Auto-Lock"; +"Passcode.Change" = "Change passcode"; +"Passcode.EnterCurrentPlaceholder" = "Enter your current passcode"; +"Passcode.EnterNewPlaceholder" = "Enter the new passcode"; +"Passcode.EnterPasscodePlaceholder" = "Enter your passcode"; +"Passcode.Lost.Description" = "If you don't remember your passcode, you can [log out]()"; +"Passcode.Next" = "Next"; +"Passcode.Or" = "or"; +"Passcode.ReEnterPlaceholder" = "Re-enter the passcode"; +"Passcode.TurnOff" = "Turn Passcode Off"; +"Passcode.TurnOn" = "Turn Passcode On"; +"Passcode.TurnOnDescription" = "When you set up an additional passcode, you can use ⌘ + L to lock Telegram.\n\nNote: if you forget your passcode, you'll need to log out or reinstall the app. All secret chats will be lost."; +"Passcode.UnlockTouchIdReason" = "unlock itself"; +"Passcode.UseTouchId" = "Unlock with Touch ID"; +"PasscodeController.Change.Title" = "Change Passcode"; +"PasscodeController.Current.Placeholder" = "Enter current passcode"; +"PasscodeController.Disable.Title" = "Disable Passcode"; +"PasscodeController.EnterPasscode.Placeholder" = "Enter a passcode"; +"PasscodeController.Error.Current" = "invalid passcode"; +"PasscodeController.Error.Different" = "passcodes are different"; +"PasscodeController.Header.Current" = "CURRENT PASSCODE"; +"PasscodeController.Header.New" = "NEW PASSCODE"; +"PasscodeController.Install.Title" = "Passcode"; +"PasscodeController.ReEnterPasscode.Placeholder" = "Re-enter new passcode"; +"PasscodeController.Text" = "To lock, hover over the Settings icon in the tab bar and choose \"Lock Telegram\" or use ⌘ + L.\n\nNote: if you forget your local passcode, you'll need to log out of Telegram for macOS and log in again."; +"Passkeys.CreatePasskey" = "Create Passkey"; +"Passkeys.Delete" = "Delete"; +"Passkeys.DeleteConfirmation" = "Are you sure you want to delete this passkey?"; +"Passkeys.DeleteTitle" = "Delete Passkey"; +"Passkeys.Description" = "Log in safely and keep your account secure."; +"Passkeys.MaxLimitReached" = "Sorry, you can create only %1$d at the moment."; +"Passkeys.Title" = "Passkeys"; +"Passlock.EnterYourPasscode" = "Enter Your Passcode"; +"Passport.Language.ar" = "Arabic"; +"Passport.Language.az" = "Azerbaijani"; +"Passport.Language.bg" = "Bulgarian"; +"Passport.Language.bn" = "Bangla"; +"Passport.Language.cs" = "Czech"; +"Passport.Language.da" = "Danish"; +"Passport.Language.de" = "German"; +"Passport.Language.dv" = "Divehi"; +"Passport.Language.dz" = "Dzongkha"; +"Passport.Language.el" = "Greek"; +"Passport.Language.en" = "English"; +"Passport.Language.es" = "Spanish"; +"Passport.Language.et" = "Estonian"; +"Passport.Language.fa" = "Persian"; +"Passport.Language.fr" = "French"; +"Passport.Language.he" = "Hebrew"; +"Passport.Language.hr" = "Croatian"; +"Passport.Language.hu" = "Hungarian"; +"Passport.Language.hy" = "Armenian"; +"Passport.Language.id" = "Indonesian"; +"Passport.Language.is" = "Icelandic"; +"Passport.Language.it" = "Italian"; +"Passport.Language.ja" = "Japanese"; +"Passport.Language.ka" = "Georgian"; +"Passport.Language.km" = "Khmer"; +"Passport.Language.ko" = "Korean"; +"Passport.Language.lo" = "Lao"; +"Passport.Language.lt" = "Lithuanian"; +"Passport.Language.lv" = "Latvian"; +"Passport.Language.mk" = "Macedonian"; +"Passport.Language.mn" = "Mongolian"; +"Passport.Language.ms" = "Malay"; +"Passport.Language.my" = "Burmese"; +"Passport.Language.ne" = "Nepali"; +"Passport.Language.nl" = "Dutch"; +"Passport.Language.pl" = "Polish"; +"Passport.Language.pt" = "Portuguese"; +"Passport.Language.ro" = "Romanian"; +"Passport.Language.ru" = "Russian"; +"Passport.Language.sk" = "Slovak"; +"Passport.Language.sl" = "Slovenian"; +"Passport.Language.th" = "Thai"; +"Passport.Language.tk" = "Turkmen"; +"Passport.Language.tr" = "Turkish"; +"Passport.Language.uk" = "Ukrainian"; +"Passport.Language.uz" = "Uzbek"; +"Passport.Language.vi" = "Vietnamese"; +"Passport.ResetPassword.Confirm.Header" = "Forgotten Password"; +"Passport.ResetPassword.Confirm.OK" = "Reset"; +"Passport.ResetPassword.Confirm.Text" = "All documents uploaded to your Telegram Passport will be lost. You will be able to upload new documents."; +"Payments.Invoice.AlreadyActive" = "Subscription is already active"; +"Payments.Invoice.NotExists" = "Invoice doesn't exist"; +"Payments.NonRecurrent.Accept" = "I accept the [Terms of Service]() of **%1$@**."; +"Payments.Paid" = "You paid %1$@ for %2$@"; +"Payments.Receipt.Tip" = "Tip"; +"Payments.Recurrent.Accept" = "I accept [Terms of Service]() of **%@**."; +"Payments.TipLabel" = "Tip (Optional)"; +"Payments.Unsupported" = "Sorry, Telegram for macOS doesn't support payments yet. \nPlease try again from the iOS or Android app."; +"Payments.WarninTitle" = "Warning"; +"Payments.WarningText" = "Neither Telegram, nor %1$@ will have access to your credit card information. Credit card details will be handled only by the payments system, %2$@.\n\nPayments will go directly to the developer of %3$@. Telegram cannot provide any guarantees, so proceed at your own risk. In case of problems, please contact the developer of %4$@ or your bank."; +"Peer.Activity.Chat.ChoosingSticker" = "%@ is choosing a sticker"; +"Peer.Activity.Chat.EnjoyingAnimations" = "%@ is watching %@"; +"Peer.Activity.Chat.Multi.ChoosingSticker1" = "%@ and %d others are choosing stickers"; +"Peer.Activity.Chat.Multi.PlayingGame1" = "%@ and %d others are playing a game"; +"Peer.Activity.Chat.Multi.RecordingAudio1" = "%@ and %d others are recording voice"; +"Peer.Activity.Chat.Multi.RecordingVideo1" = "%@ and %d others are recording video"; +"Peer.Activity.Chat.Multi.SendingAudio1" = "%@ and %d others are sending audio"; +"Peer.Activity.Chat.Multi.SendingFile1" = "%@ and %d others are sending files"; +"Peer.Activity.Chat.Multi.SendingPhoto1" = "%@ and %d others are sending photos"; +"Peer.Activity.Chat.Multi.SendingVideo1" = "%@ and %d others are sending videos"; +"Peer.Activity.Chat.Multi.TypingText1" = "%@ and %d others are typing"; +"Peer.Activity.Chat.PlayingGame" = "%@ is playing a game"; +"Peer.Activity.Chat.RecordingAudio" = "%@ is recording voice"; +"Peer.Activity.Chat.RecordingVideo" = "%@ is recording video"; +"Peer.Activity.Chat.SendingFile" = "%@ is sending a file"; +"Peer.Activity.Chat.SendingPhoto" = "%@ is sending a photo"; +"Peer.Activity.Chat.SendingVideo" = "%@ is sending a video"; +"Peer.Activity.Chat.TypingText" = "%@ is typing"; +"Peer.Activity.User.ChoosingSticker" = "choosing a sticker"; +"Peer.Activity.User.EnjoyingAnimations" = "watching %@"; +"Peer.Activity.User.PlayingGame" = "playing a game"; +"Peer.Activity.User.RecordingAudio" = "recording voice"; +"Peer.Activity.User.RecordingVideo" = "recording video"; +"Peer.Activity.User.SendingFile" = "sending file"; +"Peer.Activity.User.SendingPhoto" = "sending a photo"; +"Peer.Activity.User.SendingVideo" = "sending a video"; +"Peer.Activity.User.TypingText" = "typing"; +"Peer.CreatePeer.Context.RemovePhoto" = "Remove photo"; +"Peer.CreatePeer.Context.UpdatePhoto" = "Update photo"; +"Peer.DeletedUser" = "Deleted Account"; +"Peer.Forward.PremiumRequired" = "%@ only accepts messages from contacts and Premium users."; +"Peer.Media.Calendar.Media#one" = "%d media"; +"Peer.Media.Calendar.Media#other" = "%d media"; +"Peer.Media.Calendar.Media_countable" = "%d"; +"Peer.Media.Calendar.Title" = "Calendar"; +"Peer.Media.ShareMedia.SavedMessages" = "Media will appear here."; +"Peer.Media.SharedFilesEmptyList" = "You can send and receive files of any type up to 2 GB each and access them anywhere."; +"Peer.Media.SharedFilesEmptyList1" = "You can send and receive files of any type up to 2 GB each and access them anywhere."; +"Peer.Media.SharedLinksEmptyList" = "All links shared in this chat will appear here."; +"Peer.Media.SharedMediaEmptyList" = "Share photos and videos in this chat\n— or this paperclip stays unhappy."; +"Peer.Media.SharedMusicEmptyList" = "All music shared in this chat will appear here."; +"Peer.Media.SharedVoiceEmptyList" = "All voice and video messages shared in this chat will appear here."; +"Peer.MyNotes" = "My Notes"; +"Peer.Rating.Negative.Other" = "A negative rating indicates that %@'s payments are unreliable."; +"Peer.Rating.Negative.Self" = "A negative rating indicates that your payments are unreliable. Spend **%@** to fix this issue."; +"Peer.RatingPreview.Accept" = "Understood"; +"Peer.RatingPreview.Badge.Added" = "ADDED"; +"Peer.RatingPreview.Badge.Deducted" = "DEDUCTED"; +"Peer.RatingPreview.Info.Header" = "This rating reflects **%@'s** activity on Telegram. It is based on:"; +"Peer.RatingPreview.Info.Header.You" = "This rating reflects your activity\non Telegram. It is based on:"; +"Peer.RatingPreview.Info.Pending#one" = "Rating updates 21 days after purchases. %d point is pending."; +"Peer.RatingPreview.Info.Pending#other" = "Rating updates 21 days after purchases. %d points are pending."; +"Peer.RatingPreview.Info.Pending_countable" = "%d"; +"Peer.RatingPreview.Level" = "Level %d"; +"Peer.RatingPreview.NextLevel" = "Level %d"; +"Peer.RatingPreview.Refunds.Header" = "Refunds and Conversions"; +"Peer.RatingPreview.Refunds.Text" = "10x refunded Stars and %@ of gifts converted back to Stars."; +"Peer.RatingPreview.Telegram.Header" = "Gifts from Telegram"; +"Peer.RatingPreview.Telegram.Text" = "%@ of the Stars spent on gifts purchased from Telegram."; +"Peer.RatingPreview.Title" = "Rating"; +"Peer.RatingPreview.Users.Header" = "Gifts and Posts from Users"; +"Peer.RatingPreview.Users.Text" = "%@ of the Stars spent on resold gifts, paid messages and channel posts."; +"Peer.RepliesNotifications" = "Reply Notifications"; +"Peer.ReportStory" = "Repost Story"; +"Peer.SavedMessages" = "Saved Messages"; +"Peer.ServiceNotifications" = "service notifications"; +"Peer.Status.LastSeenAt" = "last seen %@ at %@"; +"Peer.Status.Member#one" = "%d member"; +"Peer.Status.Member#other" = "%d members"; +"Peer.Status.Member.Online#one" = "%d online"; +"Peer.Status.Member.Online#other" = "%d online"; +"Peer.Status.Member.Online_countable" = "%d"; +"Peer.Status.Member_countable" = "%d"; +"Peer.Status.Subscribers#one" = "%d subscriber"; +"Peer.Status.Subscribers#other" = "%d subscribers"; +"Peer.Status.Subscribers_countable" = "%d"; +"Peer.Status.Today" = "today"; +"Peer.Status.Users#one" = "%d monthly user"; +"Peer.Status.Users#other" = "%d monthly users"; +"Peer.Status.Users_countable" = "%d"; +"Peer.Status.Yesterday" = "yesterday"; +"Peer.Status.channel" = "channel"; +"Peer.Status.forum" = "forum"; +"Peer.Status.group" = "group"; +"Peer.Status.hidden" = "last seen hidden"; +"Peer.Status.justNow" = "last seen just now"; +"Peer.Status.lastMonth" = "last seen within a month"; +"Peer.Status.lastWeek" = "last seen within a week"; +"Peer.Status.longTimeAgo" = "last seen a long time ago"; +"Peer.Status.minAgo#one" = "last seen %d minute ago"; +"Peer.Status.minAgo#other" = "last seen %d minutes ago"; +"Peer.Status.minAgo_countable" = "%d"; +"Peer.Status.online" = "online"; +"Peer.Status.recently" = "last seen recently"; +"Peer.Status.show" = "when?"; +"PeerCall.Screen.E2E" = "This call is end-to-end encrypted"; +"PeerCall.Screen.E2E.Info" = "If emoji on **%1$@** screen are the same, this call is %2$@ secure."; +"PeerInfo.About.Placeholder" = "Description"; +"PeerInfo.Action.AcrhivedStories" = "Archived Stories"; +"PeerInfo.Action.AddMembers" = "Add"; +"PeerInfo.Action.BoostChannel" = "Boost Channel"; +"PeerInfo.Action.BoostGroup" = "Boost Group"; +"PeerInfo.Action.Call" = "Call"; +"PeerInfo.Action.DeleteGroup" = "Delete"; +"PeerInfo.Action.Discussion" = "Discuss"; +"PeerInfo.Action.GiftPremium" = "Gift Premium"; +"PeerInfo.Action.JoinChannel" = "Join"; +"PeerInfo.Action.Leave" = "Leave"; +"PeerInfo.Action.LiveStream" = "Live Stream"; +"PeerInfo.Action.Message" = "Message"; +"PeerInfo.Action.More" = "More"; +"PeerInfo.Action.Mute" = "Mute"; +"PeerInfo.Action.OpenChannel" = "Open"; +"PeerInfo.Action.Report" = "Report"; +"PeerInfo.Action.SecretChat" = "Secret"; +"PeerInfo.Action.SendGift" = "Send a Gift"; +"PeerInfo.Action.Share" = "Share"; +"PeerInfo.Action.Statistics" = "Statistics"; +"PeerInfo.Action.Unmute" = "Unmute"; +"PeerInfo.Action.VideoCall" = "Video"; +"PeerInfo.Action.VoiceChat" = "Video Chat"; +"PeerInfo.AddContact" = "Add Contact"; +"PeerInfo.AddMember" = "Add Members"; +"PeerInfo.AddUserToContact" = "Add %@ to Contacts"; +"PeerInfo.AdminLabel" = "admin"; +"PeerInfo.Administrators" = "Administrators"; +"PeerInfo.Admins" = "Admins"; +"PeerInfo.AnonymousPhone" = "anonymous phone"; +"PeerInfo.AutoTranslateMessages" = "Auto-Translate Messages"; +"PeerInfo.BirthdaySuggested" = "Birthday suggested"; +"PeerInfo.BlackList" = "Blacklist"; +"PeerInfo.Block.Header" = "Block User"; +"PeerInfo.Block.OK" = "Block"; +"PeerInfo.Block.Text" = "Do you want to block %@ from messaging and calling you on Telegram?"; +"PeerInfo.BlockUser" = "Block User"; +"PeerInfo.Bot.AddToGroup" = "Add To Group"; +"PeerInfo.Bot.Help" = "Help"; +"PeerInfo.Bot.Privacy" = "Privacy"; +"PeerInfo.Bot.Settings" = "Settings"; +"PeerInfo.Bot.Share" = "Share"; +"PeerInfo.BotAddTo" = "Add To Group or Channel"; +"PeerInfo.BotAddTo.Info" = "This bot is able to manage a group or channel."; +"PeerInfo.BotAffiliate" = "Affiliate Program"; +"PeerInfo.BotEdit.Balance" = "Balance"; +"PeerInfo.BotEdit.Commands" = "Edit Commands"; +"PeerInfo.BotEdit.Info" = "Use [@BotFather]() to manage this bot."; +"PeerInfo.BotEdit.Intro" = "Edit Intro"; +"PeerInfo.BotEdit.Settings" = "Change Bot Settings"; +"PeerInfo.BotEdit.StarsBalance" = "Balance"; +"PeerInfo.BotEdit.StarsBalance_New" = "Stars Balance"; +"PeerInfo.BotEdit.StarsCount#one" = "%d Star"; +"PeerInfo.BotEdit.StarsCount#other" = "%d Stars"; +"PeerInfo.BotEdit.StarsCount_countable" = "%d"; +"PeerInfo.BotEdit.TonBalance" = "TON Balance"; +"PeerInfo.BotEdit.Username" = "Username"; +"PeerInfo.BotPermissions.Geo" = "Geolocation"; +"PeerInfo.BotPermissions.Header" = "ALLOW ACCESS TO"; +"PeerInfo.BotPermissions.Status" = "Emoji Status"; +"PeerInfo.BotStatus.HasAccess" = "has access to messages"; +"PeerInfo.BotStatus.HasNoAccess" = "has no access to messages"; +"PeerInfo.Business.Hours" = "business hours"; +"PeerInfo.Business.Hours.Closed" = "closed"; +"PeerInfo.Business.Hours.Current.Closed" = "Closed"; +"PeerInfo.Business.Hours.Current.Open" = "Open"; +"PeerInfo.Business.Hours.Current.OpenIn" = "Opens %@"; +"PeerInfo.Business.Hours.Current.OpenInHours#one" = "Opens in %d hour"; +"PeerInfo.Business.Hours.Current.OpenInHours#other" = "Opens in %d hours"; +"PeerInfo.Business.Hours.Current.OpenInHours_countable" = "%d"; +"PeerInfo.Business.Hours.Current.OpenInMinutes#one" = "Opens in %d minute"; +"PeerInfo.Business.Hours.Current.OpenInMinutes#other" = "Opens in %d minutes"; +"PeerInfo.Business.Hours.Current.OpenInMinutes_countable" = "%d"; +"PeerInfo.Business.Hours.Opens24Hours" = "open 24 hours"; +"PeerInfo.Business.Hours.local" = "local time"; +"PeerInfo.Business.Hours.my" = "my time"; +"PeerInfo.Business.Location" = "location"; +"PeerInfo.ChannelAppearance" = "Appearance"; +"PeerInfo.ChannelColor" = "Channel Color"; +"PeerInfo.ChannelName.Placeholder" = "Channel Name"; +"PeerInfo.ChannelReported" = "Thank you! Your report will be reviewed by our team soon."; +"PeerInfo.ChannelTitle.Pleceholder" = "Channel Name"; +"PeerInfo.ChannelType" = "Channel Type"; +"PeerInfo.ChatBackground" = "Change Wallpaper"; +"PeerInfo.ChatColors" = "Change Colors"; +"PeerInfo.Confirm.Add" = "Add"; +"PeerInfo.Confirm.AddMember" = "Add \"%@\" to the group?"; +"PeerInfo.Confirm.AddMembers1#one" = "Add %d user to the group?"; +"PeerInfo.Confirm.AddMembers1#other" = "Add %d users to the group?"; +"PeerInfo.Confirm.AddMembers1_countable" = "%d"; +"PeerInfo.Confirm.Clear" = "Clear"; +"PeerInfo.Confirm.DeleteChat" = "Are you sure you want to delete all message history and leave \"%@\"?\n\nThis action cannot be undone."; +"PeerInfo.Confirm.DeleteContact" = "Are you sure you want to delete this contact?"; +"PeerInfo.Confirm.DeleteGroupConfirmation" = "Deleting this group will remove all members and all messages will be lost. Do you want to delete the group?"; +"PeerInfo.Confirm.DeleteUserChat" = "Are you sure you want to delete the chat?"; +"PeerInfo.Confirm.LeaveChannel" = "Are you sure you want to leave this channel?"; +"PeerInfo.Confirm.LeaveGroup" = "Are you sure you want to leave this group?\n\nThis action cannot be undone."; +"PeerInfo.Confirm.RemovePeer" = "Remove %@ from the group?"; +"PeerInfo.Confirm.SecretChat.Header" = "Secret Chat"; +"PeerInfo.Confirm.SecretChat.OK" = "Start"; +"PeerInfo.Confirm.ShareInfo" = "Are you sure you want to share your phone number with \"%@\"?"; +"PeerInfo.Confirm.StartSecretChat" = "Are you sure you want to start a secret chat with \"%@\"?"; +"PeerInfo.Context.OpenPhoto" = "Open Photo"; +"PeerInfo.ConvertToSupergroup" = "Convert To Supergroup"; +"PeerInfo.CopyProtection.DisableSharing" = "Disable Sharing"; +"PeerInfo.CopyProtection.EnableSharing" = "Enable Sharing"; +"PeerInfo.CopyProtection.EnableSharing.Confirm.Header" = "Enable Sharing"; +"PeerInfo.CopyProtection.EnableSharing.Confirm.OK" = "Send Request"; +"PeerInfo.CopyProtection.EnableSharing.Confirm.Text" = "You need %1$@'s approval to enable sharing. Send a request?"; +"PeerInfo.CopyProtection.Info" = "Users will be able to save and forward content from your profile."; +"PeerInfo.CopyProtection.InfoRestrict" = "Users will not be able to forward messages from your personal chat, save media from it and take screenshots. This will also restrict users from copying text."; +"PeerInfo.CopyProtection.RequiresPremium" = "This feature requires Telegram Premium."; +"PeerInfo.CopyProtection.Restrict" = "Restrict Forwarding"; +"PeerInfo.DeleteAndExit" = "Delete and Leave"; +"PeerInfo.DeleteChannel" = "Delete Channel"; +"PeerInfo.DeleteContact" = "Delete Contact"; +"PeerInfo.DeleteGroup" = "Delete Group"; +"PeerInfo.DeleteSecretChat" = "Delete Secret Chat"; +"PeerInfo.Description.Placeholder" = "About"; +"PeerInfo.DirectMessages.Off" = "Off"; +"PeerInfo.DirectMessages.On" = "On"; +"PeerInfo.DirectMessages.Text" = "Direct Messages"; +"PeerInfo.Discussion" = "Discussion"; +"PeerInfo.Discussion.Add" = "Add"; +"PeerInfo.Discussion.Desc" = "Add a group chat for comments."; +"PeerInfo.EncryptionKey" = "Encryption Key"; +"PeerInfo.FakeWarning" = "⚠️ Warning: Many users reported that this account impersonates a famous person or organization."; +"PeerInfo.FirstName.Placeholder" = "First Name"; +"PeerInfo.Forum" = "Topics"; +"PeerInfo.Forum.Info" = "The group chat will be divided into topics created by admins or users."; +"PeerInfo.Gifts.ChannelNotify" = "Notify About New Gifts"; +"PeerInfo.Gifts.ChannelNotifyDisabledTooltip" = "You will not be notified when your channel receives a gift."; +"PeerInfo.Gifts.ChannelNotifyTooltip" = "Telegram will send you a message when your channel receives a gift."; +"PeerInfo.Gifts.Displayed" = "Displayed"; +"PeerInfo.Gifts.Hidden" = "Hidden"; +"PeerInfo.Gifts.Limited" = "Limited"; +"PeerInfo.Gifts.SendGift" = "Send Gift"; +"PeerInfo.Gifts.SortByDate" = "Sort by Date"; +"PeerInfo.Gifts.SortByValue" = "Sort by Value"; +"PeerInfo.Gifts.Unique" = "Unique"; +"PeerInfo.Gifts.Unlimited" = "Unlimited"; +"PeerInfo.Group.AutoDeleteMessages" = "Auto-Delete Messages"; +"PeerInfo.Group.Menu.Delete" = "Delete"; +"PeerInfo.Group.Menu.Promote" = "Promote"; +"PeerInfo.Group.Menu.Restrict" = "Restrict"; +"PeerInfo.Group.Timer.Never" = "Never"; +"PeerInfo.GroupName.Placeholder" = "Group Name"; +"PeerInfo.GroupTitle.Pleceholder" = "Group Name"; +"PeerInfo.GroupType" = "Group Type"; +"PeerInfo.GroupType.Private" = "Private"; +"PeerInfo.GroupType.Public" = "Public"; +"PeerInfo.GroupsInCommon" = "Groups In Common"; +"PeerInfo.InviteError.ContactNeeded" = "Sorry, you must be in this user's Telegram contacts to add them to this group.\n\nThey can also join on their own if you send them an invite link."; +"PeerInfo.InviteLink" = "Invite Link"; +"PeerInfo.InviteLinks" = "Invite Links"; +"PeerInfo.LastName.Placeholder" = "Last Name"; +"PeerInfo.LeaveChannel" = "Leave Channel"; +"PeerInfo.LeaveGroup" = "Leave Group"; +"PeerInfo.LinkedChannel" = "Linked Channel"; +"PeerInfo.Members" = "Members"; +"PeerInfo.MembersHeader#one" = "%d MEMBER"; +"PeerInfo.MembersHeader#other" = "%d MEMBERS"; +"PeerInfo.MembersHeader_countable" = "%d"; +"PeerInfo.MembersRequest" = "Join Requests"; +"PeerInfo.NotesCopy" = "Copy Notes"; +"PeerInfo.NotesDescription" = "only visible to you"; +"PeerInfo.NotesLabel" = "notes"; +"PeerInfo.NotesPlaceholder" = "Add Notes"; +"PeerInfo.Notifications" = "Notifications"; +"PeerInfo.NotificationsDefault" = "Default"; +"PeerInfo.NotificationsDefaultSound" = "Default (%@)"; +"PeerInfo.Permissions" = "Permissions"; +"PeerInfo.PersonalChannel.Title" = "CHANNEL"; +"PeerInfo.Phone" = "phone"; +"PeerInfo.Phone.AnonymousInfo" = "This number is not tied to a SIM card and was acquired on [Fragment]()."; +"PeerInfo.PreHistory" = "Chat History For New Members"; +"PeerInfo.PreHistory.Hidden" = "Hidden"; +"PeerInfo.PreHistory.Visible" = "Visible"; +"PeerInfo.Premium.Tooltip" = "Premium Account. [Read More](premium)"; +"PeerInfo.Rating.Button" = "Learn More"; +"PeerInfo.Rating.Level" = "Level"; +"PeerInfo.Rating.Text" = "Profile level reflects the user's payment reliability."; +"PeerInfo.Reactions" = "Reactions"; +"PeerInfo.Reactions.All" = "All"; +"PeerInfo.Reactions.Disabled" = "Disabled"; +"PeerInfo.Reactions.Part" = "%1$@/%2$@"; +"PeerInfo.RecentActions" = "Recent Actions"; +"PeerInfo.RemovedUsers" = "Removed Users"; +"PeerInfo.Report" = "Report"; +"PeerInfo.Report.SelectMessages" = "Select Messages"; +"PeerInfo.ReportBot" = "Report Bot"; +"PeerInfo.ReportReaction" = "Block and Report"; +"PeerInfo.ReportReaction.Success" = "Thank you for your report"; +"PeerInfo.RestartBot" = "Restart Bot"; +"PeerInfo.ScamWarning" = "⚠️ Warning: Many users reported this account as a scam or a fake account. Please be careful, especially if it asks you for money."; +"PeerInfo.SendMessage" = "Send Message"; +"PeerInfo.SetAboutDescription" = "You can provide an optional description for your group."; +"PeerInfo.SetAdmins" = "Set Admins"; +"PeerInfo.SetChannelPhoto" = "Set Channel Photo"; +"PeerInfo.SetGroupPhoto" = "Set Group Photo"; +"PeerInfo.SetGroupStickersSet" = "Group Sticker Set"; +"PeerInfo.ShareContact" = "Share Contact"; +"PeerInfo.ShareMyInfo" = "Share My Contact Info"; +"PeerInfo.SharedMedia" = "Shared Media"; +"PeerInfo.ShowAuthorProfiles" = "Show Authors' Profile"; +"PeerInfo.ShowMore" = "Show More"; +"PeerInfo.ShowMoreText" = "[more]()"; +"PeerInfo.SignMessages" = "Sign Messages"; +"PeerInfo.SignMessages.Desc" = "Append names of the admins to the messages they post."; +"PeerInfo.SignMessagesAndShowAuthor.Desc" = "Add names and photos of admins to the messages they post, linking to their profiles."; +"PeerInfo.StartSecretChat" = "Start Secret Chat"; +"PeerInfo.StatAndBoosts" = "Statistics"; +"PeerInfo.Statistics" = "Statistics"; +"PeerInfo.StopBot" = "Block Bot"; +"PeerInfo.Subscribers" = "Subscribers"; +"PeerInfo.SuggestBirthday" = "Suggest Birthday"; +"PeerInfo.TopicStatus.In" = "in %1$@"; +"PeerInfo.TopicsLimitedDiscussionGroups" = "Topics are not yet available in the discussion groups of channels."; +"PeerInfo.TopicsLimitedParticipantCountText#one" = "Only groups with more than %d member can have topics enabled."; +"PeerInfo.TopicsLimitedParticipantCountText#other" = "Only groups with more than %d members can have topics enabled."; +"PeerInfo.TopicsLimitedParticipantCountText_countable" = "%d"; +"PeerInfo.Translate" = "Translate"; +"PeerInfo.Unarchive" = "Unarchive"; +"PeerInfo.UnblockUser" = "Unblock"; +"PeerInfo.UnofficialClient.Text" = "%1$@ uses an unofficial Telegram client — messages to this user may be less secure."; +"PeerInfo.UnofficialClient.Title" = "⚠️ Unofficial Client"; +"PeerInfo.Usernames.List" = "%1$@\nalso %2$@"; +"PeerInfo.VerificationInfo.Bot" = "This bot was verified by Telegram."; +"PeerInfo.VerificationInfo.Channel" = "This channel was verified by Telegram."; +"PeerInfo.VerificationInfo.Group" = "This group was verified by Telegram."; +"PeerInfo.VerificationInfo.URL" = "https://telegram.org/verify"; +"PeerInfo.Verified" = "%@ This bot is verified as official by the representatives of Telegram."; +"PeerInfo.Verified.Tooltip" = "Verified Account"; +"PeerInfo.VerifyAccounts" = "Verify Accounts"; +"PeerInfo.about" = "about"; +"PeerInfo.bio" = "bio"; +"PeerInfo.birthday" = "birthday"; +"PeerInfo.fake" = "fake"; +"PeerInfo.info" = "info"; +"PeerInfo.scam" = "scam"; +"PeerInfo.sharelink" = "share link"; +"PeerInfo.username" = "username"; +"PeerList.Archive.Settings" = "Archive Settings"; +"PeerList.Title.Archive" = "Archive"; +"PeerList.Title.Chats" = "Chats"; +"PeerList.Title.Contacts" = "Contacts"; +"PeerList.Title.Forum" = "Forum"; +"PeerList.Title.SavedMessages" = "Saved Messages"; +"PeerMedia.ArchivePosts" = "Archived Posts"; +"PeerMedia.Audio" = "Audio"; +"PeerMedia.CommonGroups" = "Groups"; +"PeerMedia.Files" = "Files"; +"PeerMedia.Gifs" = "GIFs"; +"PeerMedia.Gifts" = "Gifts"; +"PeerMedia.Gifts.Collection.Add" = "Add Collection"; +"PeerMedia.Gifts.Collection.Alert.Added" = "Gift added to **%@**"; +"PeerMedia.Gifts.Collection.Alert.Removed" = "Gift removed from **%@**"; +"PeerMedia.Gifts.Collection.All" = "All Gifts"; +"PeerMedia.Gifts.Collection.Context.AddCollection" = "Add Collection"; +"PeerMedia.Gifts.Collection.Context.AddGifts" = "Add Gifts"; +"PeerMedia.Gifts.Collection.Context.AddToCollection" = "Add to Collection"; +"PeerMedia.Gifts.Collection.Context.Delete" = "Delete"; +"PeerMedia.Gifts.Collection.Context.Delete.Confirm" = "Are you sure you want to delete **%@**?"; +"PeerMedia.Gifts.Collection.Context.NewCollection" = "New Collection"; +"PeerMedia.Gifts.Collection.Context.Rename" = "Rename"; +"PeerMedia.Gifts.Collection.Empty.Action" = "Add Gifts"; +"PeerMedia.Gifts.Collection.Empty.Header" = "Organize Your Gifts"; +"PeerMedia.Gifts.Collection.Empty.Text" = "Add some gifts to this collection."; +"PeerMedia.Gifts.Collection.New.Info" = "Choose a name for your collection and add your gifts."; +"PeerMedia.Gifts.Collection.New.Ok" = "Create"; +"PeerMedia.Gifts.Collection.New.Placeholder" = "Title..."; +"PeerMedia.Gifts.Collection.New.Title" = "Create a New Collection"; +"PeerMedia.Gifts.Collection.Panel.AddGifts" = "Add Gifts"; +"PeerMedia.Gifts.Collection.Update.Info" = "Update a name for your collection."; +"PeerMedia.Gifts.Collection.Update.Ok" = "Update"; +"PeerMedia.Gifts.Collection.Update.Placeholder" = "Title..."; +"PeerMedia.Gifts.Collection.Update.Title" = "Update Name"; +"PeerMedia.Links" = "Links"; +"PeerMedia.Media" = "Media"; +"PeerMedia.Members" = "Members"; +"PeerMedia.Music" = "Music"; +"PeerMedia.Polls" = "Polls"; +"PeerMedia.Popover.SharedAudio" = "Shared Audio"; +"PeerMedia.Popover.SharedFiles" = "Shared Files"; +"PeerMedia.Popover.SharedLinks" = "Shared Links"; +"PeerMedia.Popover.SharedMedia" = "Shared Media"; +"PeerMedia.Posts" = "Posts"; +"PeerMedia.Preview" = "Preview"; +"PeerMedia.Saved" = "Saved"; +"PeerMedia.SavedMessages" = "Chats"; +"PeerMedia.SharedMedia" = "Shared Media"; +"PeerMedia.SimilarBots" = "Similar Bots"; +"PeerMedia.SimilarChannels" = "Similar Channels"; +"PeerMedia.SimilarChannels.MoreChannels" = "More Channels"; +"PeerMedia.Status.Subscribers#one" = "%d Subscriber"; +"PeerMedia.Status.Subscribers#other" = "%d Subscribers"; +"PeerMedia.Status.Subscribers_countable" = "%d"; +"PeerMedia.Stories" = "Posts"; +"PeerMedia.Title.Search.Files#one" = "%d File"; +"PeerMedia.Title.Search.Files#other" = "%d Files"; +"PeerMedia.Title.Search.Files_countable" = "%d"; +"PeerMedia.Title.Search.GIFs#one" = "%d GIF"; +"PeerMedia.Title.Search.GIFs#other" = "%d GIFs"; +"PeerMedia.Title.Search.GIFs_countable" = "%d"; +"PeerMedia.Title.Search.Links#one" = "%d Link"; +"PeerMedia.Title.Search.Links#other" = "%d Links"; +"PeerMedia.Title.Search.Links_countable" = "%d"; +"PeerMedia.Title.Search.Media#one" = "%d Media"; +"PeerMedia.Title.Search.Media#other" = "%d Media"; +"PeerMedia.Title.Search.Media_countable" = "%d"; +"PeerMedia.Title.Search.Music#one" = "%d Audio"; +"PeerMedia.Title.Search.Music#other" = "%d Audios"; +"PeerMedia.Title.Search.Music_countable" = "%d"; +"PeerMedia.Title.Search.Photos#one" = "%d Photo"; +"PeerMedia.Title.Search.Photos#other" = "%d Photos"; +"PeerMedia.Title.Search.Photos_countable" = "%d"; +"PeerMedia.Title.Search.Videos#one" = "%d Video"; +"PeerMedia.Title.Search.Videos#other" = "%d Videos"; +"PeerMedia.Title.Search.Videos_countable" = "%d"; +"PeerMedia.Voice" = "Voice"; +"PeerRating.Preview.Negative" = "Negative Rating"; +"PeerSelect.InviteViaLink" = "Invite to Group via Link"; +"PersonalChannel.Empty" = "You don't have public channels. [Create >]()"; +"PersonalChannel.Hide" = "Hide Channel"; +"PersonalChannel.Info" = "select your channel"; +"PersonalChannel.Title" = "Personal Channel"; +"PersonalChannel.Tooltip.Updated" = "Personal Channel Updated"; +"PhoneNumber.ChangePhoneNumber" = "Change Phone Number"; +"PhoneNumber.Code.Fragment.Info" = "Get the code in the Numbers section on [Fragment](%1$@)"; +"PhoneNumber.Code.Info" = "We have sent you an SMS with a confirmation code, please enter it to finish the process."; +"PhoneNumber.Code.Title" = "Enter Code"; +"PhoneNumber.Info" = "Your new number will receive a confirmation code via call or SMS."; +"PhoneNumber.SendCode" = "Send Code"; +"PhoneNumber.Title" = "NEW PHONE NUMBER"; +"PlaybackSpeed.SetToDefault" = "Set to Default"; +"Poll.Forward.Error" = "Sorry, polls with visible votes can’t be forwarded to channels."; +"PollResults.Collapse" = "[Collapse]()"; +"PollResults.LoadMore#one" = "Show More (%d)"; +"PollResults.LoadMore#other" = "Show More (%d)"; +"PollResults.LoadMore_countable" = "%d"; +"PollResults.Title.Poll" = "Poll Results"; +"PollResults.Title.Quiz" = "Quiz Results"; +"PreHistory.Confirm.Unlink" = "Warning, this will unlink the group from \"%@\""; +"PreHistorySettings.Description.Group.Hidden" = "New members won't see more than 100 previous messages."; +"PreHistorySettings.Description.Hidden" = "New members won't see earlier messages."; +"PreHistorySettings.Description.Visible" = "New Members will see messages that were sent before they joined."; +"PreHistorySettings.Header" = "CHAT HISTORY FOR NEW MEMBERS"; +"Premium.Boarding.About.Text" = "While the free version of Telegram already gives its users more than any other messaging application, **Telegram Premium** pushes its capabilities even further.\n\n**Telegram Premium** is a paid option, because most Premium Features require additional expenses from Telegram to third parties such as data center providers and server manufacturers. Contributions from **Telegram Premium** users allow us to cover such costs and also help Telegram stay free for everyone."; +"Premium.Boarding.About.Title" = "About Telegram Premium"; +"Premium.Boarding.About.Tos" = "By purchasing a Premium subscription, you agree to the Telegram [Terms of Service](terms) and [Privacy Policy](privacy)."; +"Premium.Boarding.AppStore.Cancelled" = "Purchase was canceled or failed."; +"Premium.Boarding.AppStore.Success" = "You successfully subscribed for Telegram Premium."; +"Premium.Boarding.Avatar.Info" = "Video avatars animated in chat lists and chats to allow for additional self-expression."; +"Premium.Boarding.Avatar.Title" = "Animated Profile Pictures"; +"Premium.Boarding.Badge.Info" = "An exclusive badge next to your name showing that you subscribe to Telegram Premium."; +"Premium.Boarding.Badge.Title" = "Profile Badge"; +"Premium.Boarding.Chats.Info" = "Tools to set default folder, auto-archive and hide new chats."; +"Premium.Boarding.Chats.Title" = "Advanced Chat Management"; +"Premium.Boarding.Colors.Info" = "Choose a color and logo for your profile and replies to your messages."; +"Premium.Boarding.Colors.Title" = "Name and Profile Colors"; +"Premium.Boarding.Double.Accounts" = "Connected Accounts"; +"Premium.Boarding.Double.Accounts.Info" = "Connect %@ accounts with different mobile numbers"; +"Premium.Boarding.Double.Bio" = "Bio"; +"Premium.Boarding.Double.Bio.Info" = "Add more symbols and use links in your bio"; +"Premium.Boarding.Double.Captions" = "Captions"; +"Premium.Boarding.Double.Captions.Info" = "Use longer descriptions for your photos and videos"; +"Premium.Boarding.Double.ChatsPerFolder" = "Chats per Folder"; +"Premium.Boarding.Double.ChatsPerFolder.Info" = "Add up to %@ chats into each of your folders"; +"Premium.Boarding.Double.FavedStickers" = "Favorite Stickers"; +"Premium.Boarding.Double.FavedStickers.Info" = "Save up to %@ stickers in your Favorite stickers"; +"Premium.Boarding.Double.Folders" = "Folders"; +"Premium.Boarding.Double.Folders.Info" = "Organize your chats into %@ folders"; +"Premium.Boarding.Double.GroupsAndChannels" = "Groups and Channels"; +"Premium.Boarding.Double.GroupsAndChannels.Info" = "Join up to %@ channels and large groups"; +"Premium.Boarding.Double.Info" = "Up to %1$@ channels, %2$@ folders, %3$@ pins, %4$@ public links and more."; +"Premium.Boarding.Double.PinnedChats" = "Pinned Chats"; +"Premium.Boarding.Double.PinnedChats.Info" = "Pin up to %@ chats in your main chat list"; +"Premium.Boarding.Double.PublicLinks" = "Public Links"; +"Premium.Boarding.Double.PublicLinks.Info" = "Reserve up to %@ t.me/username links"; +"Premium.Boarding.Double.SavedGifs" = "Saved GIFs"; +"Premium.Boarding.Double.SavedGifs.Info" = "Save up to %@ GIFs in your Favorite GIFs"; +"Premium.Boarding.Double.Title" = "Doubled Limits"; +"Premium.Boarding.Download.Info" = "No more limits on the speed with which media and documents are downloaded."; +"Premium.Boarding.Download.Title" = "Faster Download Speed"; +"Premium.Boarding.Emoji.Info" = "Include animated emoji from different packs in any message you send."; +"Premium.Boarding.Emoji.Title" = "Animated Emoji"; +"Premium.Boarding.FileSize.Info" = "%2$@ per each document, unlimited storage for your chats and media overall."; +"Premium.Boarding.FileSize.Title" = "Unlimited Cloud Storage"; +"Premium.Boarding.Got.Info" = "Thank you for subscribing to Telegram Premium. Here's what is now unlocked."; +"Premium.Boarding.Got.Title" = "You are all set!"; +"Premium.Boarding.GraceText" = "Unfortunately, your latest payment didn't come through. To keep your access to **exclusive features**, please renew the subscription."; +"Premium.Boarding.GraceTitle" = "Your benefits will expire soon!"; +"Premium.Boarding.Info" = "Go **beyond the limits** and **unlock dozens of exclusive** features by subscribing to **Telegram Premium**."; +"Premium.Boarding.LastSeen.Info" = "View the last seen and read times of others even if you hide yours."; +"Premium.Boarding.LastSeen.Title" = "Last Seen Times"; +"Premium.Boarding.MessagePrivacy.Info" = "Limit messages from strangers or charge for incoming messages."; +"Premium.Boarding.MessagePrivacy.Title" = "Message Privacy"; +"Premium.Boarding.NoAds.Info" = "No more ads in public channels where Telegram sometimes shows ads."; +"Premium.Boarding.NoAds.Title" = "No Ads"; +"Premium.Boarding.Payment.NotAvailalbe" = "Not Available"; +"Premium.Boarding.Peer.Gift.Info" = "You now have access to additional features."; +"Premium.Boarding.Peer.Gift.LinkInfo" = " This link allows you or anyone you choose to activate a **Telegram Premium** subscription."; +"Premium.Boarding.Peer.Gift.Title" = "[%1$@](profile) has gifted you a %2$@-month subscription to Telegram Premium"; +"Premium.Boarding.Peer.Gift.Title.Updated" = "[%1$@](profile) has gifted you a %2$@ subscription for Telegram Premium"; +"Premium.Boarding.Peer.Gift.You.Info" = "They now have access to additional features."; +"Premium.Boarding.Peer.Gift.You.Title" = "You gifted [%1$@](profile) a %2$@-month subscription to Telegram Premium"; +"Premium.Boarding.Peer.Gift.You.Title.Updated" = "You gifted [%1$@](profile) a %2$@ subscription for Telegram Premium"; +"Premium.Boarding.Peer.Info" = "Owners of Telegram Premium accounts have exclusive access to multiple additional features."; +"Premium.Boarding.Peer.Title" = "[%@](profile) is a subscriber of\nTelegram Premium"; +"Premium.Boarding.PeerStatus.Custom.Title" = "This is %@'s current status from\n🤡 [%@]()."; +"Premium.Boarding.PeerStatus.Default.Title" = "This is %@'s current status."; +"Premium.Boarding.PeerStatus.Info" = "Emoji status is a premium feature.\nOther features included in **Telegram Premium**:"; +"Premium.Boarding.Reactions.Info" = "Additional animated reactions on messages, available only to Premium subscribers."; +"Premium.Boarding.Reactions.Title" = "Unique Reactions"; +"Premium.Boarding.ReactionsNew.Info" = "React with thousands of emoji — with multiple reactions per message."; +"Premium.Boarding.ReactionsNew.Title" = "Infinite Reactions"; +"Premium.Boarding.Renew.Month" = "Renew for %@ / month"; +"Premium.Boarding.Renew.SixMonth" = "Renew for %@ / six month"; +"Premium.Boarding.Renew.TwoYears" = "Renew for %@ / 2-years"; +"Premium.Boarding.Renew.Year" = "Renew for %@ / year"; +"Premium.Boarding.SavedTags.Info" = "Organize your Saved Messages with tags for quicker access."; +"Premium.Boarding.SavedTags.Title" = "Tags for Messages"; +"Premium.Boarding.Status.Info" = "Choose from thousands of emoji to display current activity next to your name."; +"Premium.Boarding.Status.Title" = "Emoji Statuses"; +"Premium.Boarding.Stickers.Info" = "Exclusive enlarged stickers featuring additional effects, updated regularly."; +"Premium.Boarding.Stickers.Title" = "Premium Stickers"; +"Premium.Boarding.Stories.Info" = "Unlimited posting, priority order, stealth mode, permanent view history and more."; +"Premium.Boarding.Stories.Title" = "Stories"; +"Premium.Boarding.Subscribe" = "Subscribe for %@ per month"; +"Premium.Boarding.Subscribe.Month" = "Subscribe for %@ / month"; +"Premium.Boarding.Subscribe.SixMonth" = "Subscribe for %@ / six months"; +"Premium.Boarding.Subscribe.TwoYears" = "Subscribe for %@ / 2-years"; +"Premium.Boarding.Subscribe.Year" = "Subscribe for %@ / year"; +"Premium.Boarding.Title" = "Telegram Premium"; +"Premium.Boarding.Translate.Info" = "Real-time translation of chats and channels into other languages."; +"Premium.Boarding.Translate.Title" = "Real-Time Translation"; +"Premium.Boarding.Voice.Info" = "Ability to read the transcript of any incoming voice message."; +"Premium.Boarding.Voice.Title" = "Voice-to-Text Conversion"; +"Premium.Boarding.Wallpapers.Info" = "Set custom wallpapers for you and your chat partner."; +"Premium.Boarding.Wallpapers.Title" = "Wallpapers for Both Sides"; +"Premium.GetPremium.Double" = "Double the Limit"; +"Premium.Gift.ContactSelection.BuySelf" = "buy yourself a gift"; +"Premium.Gift.ContactSelection.ThisIsYou" = "THIS IS YOU"; +"Premium.Gift.GetAccess.MoreThanThree" = "Get **%1$@** and **%2$@** more access to exclusive features with **Telegram Premium**."; +"Premium.Gift.GetAccess.OneToThree" = "Get **%1$@** access to exclusive features with **Telegram Premium**."; +"Premium.Gift.ReceiveBoost" = "You will receive %@ boosts"; +"Premium.Gift.Sent.Close" = "Close"; +"Premium.Gift.Sent.Multiple.MoreThanThree" = "**%1$@** and %2$@ more have been notified about the gift you purchased.\n\nThey now have access to additional features."; +"Premium.Gift.Sent.Multiple.OneToThree" = "**%1$@** have been notified about the gift you purchased.\n\nThey now have access to additional features."; +"Premium.Gift.Sent.Multiple.Title" = "Gifts Sent!"; +"Premium.Gift.Sent.One.Text" = "**%@** has been notified about the gift you purchased.\n\nThey now have access to additional features."; +"Premium.Gift.Sent.One.Title" = "Gift Sent!"; +"Premium.Gift.Terms" = "By gifting Telegram Premium, you agree to the Telegram [Terms of Service](terms) and [Privacy Policy](privacy)."; +"Premium.Gift.Text" = "Give **%@** access to exclusive features with **Telegram Premium**."; +"Premium.Gift.Title" = "Gift Telegram Premium"; +"Premium.Limit.About.Info" = "The 'Bio' section can't have more than **%1$@** characters. You can double this limit to **%2$@** characters by subscribing to **Telegram Premium**."; +"Premium.Limit.Accounts.Info" = "You have reached the limit of %@ connected accounts. You can free one more place by subscribing to Telegram Premium with one of these connected accounts."; +"Premium.Limit.Caption.Info" = "Captions can't have more than **%1$@** characters. You can double this limit to **%2$@** characters by subscribing to **Telegram Premium**."; +"Premium.Limit.Channels.Info" = "You are a member of **%1$@** groups and channels. Please leave some before joining a new one or upgrade to **Telegram Premium** to double the limit to **%2$@** groups and channels."; +"Premium.Limit.ChatInFolders.Info" = "Sorry, you can't add more than **%1$@** chats to a folder. You can increase this limit to **%2$@** by upgrading to **Telegram Premium**."; +"Premium.Limit.FaveStickers.Info" = "Sorry, you can't add more than **%1$@** stickers to favorites. Replace an older saved sticker or subscribe to **Telegram Premium** to double the limit to **%2$@** favorite stickers."; +"Premium.Limit.FileSize.Info" = "The document can't be sent, because it is larger than **%1$@**. You can double this limit to **%2$@** per document by subscribing to **Telegram Premium**."; +"Premium.Limit.Folders.Info" = "You have reached the limit of **%1$@** folders. You can double the limit to **%2$@** folders by subscribing to **Telegram Premium**."; +"Premium.Limit.Free" = "Free"; +"Premium.Limit.Increase" = "Increase Limit"; +"Premium.Limit.Locked.About.Info" = "You can't use more than **%1$@** characters in the bio. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.Accounts.Info" = "You have reached the limit of %@ connected accounts. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.Caption.Info" = "Captions can't have more than **%1$@** characters. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.Channels.Info" = "You are a member of **%1$@** groups and channels. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.ChatInFolders.Info" = "Sorry, you can't add more than **%1$@** chats to a folder. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.FaveStickers.Info" = "Sorry, you can't add more than **%1$@** stickers to favorites. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.FileSize.Info" = "The document can't be sent, because it is larger than **%1$@**. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.Folders.Info" = "You have reached the limit of **%1$@** folders. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.Pin.Info" = "Sorry, you can't pin more than **%1$@** chats to the top. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.PinInArchive.Info" = "Sorry, you can't pin more than **%1$@** chats in archive to the top. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.PinInFolders.Info" = "Sorry, you can't pin more than **%1$@** chats to the top. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.PublicLink.Info" = "You have reserved too many public links. Try revoking the link from an older group or channel. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.SavedGifs.Info" = "Sorry, you can't save more than **%1$@** GIFs. We are working to let you increase this limit in the future."; +"Premium.Limit.Locked.SharedFolders.Info" = "You can only add **%1$@** shareable folders. This limit may increase in the future."; +"Premium.Limit.Locked.SharedFoldersInvites.Info" = "You can only create **%1$@** invite links. This number may increase in the future."; +"Premium.Limit.Pin.Info" = "Sorry, you can't pin more than **%1$@** chats to the top. Unpin some of the currently pinned ones or subscribe to **Telegram Premium** to double the limit to **%2$@** chats."; +"Premium.Limit.PinInArchive.Info" = "Sorry, you can't pin more than **%1$@** chats in archive to the top. Unpin some of the currently pinned ones or subscribe to **Telegram Premium** to double the limit to **%2$@** chats."; +"Premium.Limit.PinInFolders.Info" = "Sorry, you can't pin more than **%1$@** chats to the top. Unpin some of the currently pinned ones or subscribe to **Telegram Premium** to double the limit to **%2$@** chats."; +"Premium.Limit.Premium" = "Premium"; +"Premium.Limit.PublicLink.Info" = "You have reserved too many public links. Try revoking the link from an older group or channel, or upgrade to **Telegram Premium** to double the limit to **%1$@** public links."; +"Premium.Limit.Reached" = "Limit Reached"; +"Premium.Limit.SavedGifs.Info" = "Sorry, you can't save more than **%1$@** GIFs. Replace an older GIF or subscribe to **Telegram Premium** to double the limit to **%2$@** GIFs."; +"Premium.Limit.SharedFolders.Info" = "You can only add **%1$@** shareable folders. Upgrade to **Telegram Premium** to increase this limit to **%2$@**."; +"Premium.Limit.SharedFoldersInvites.Info" = "You can only create **%1$@** invite links. Upgrade to **Telegram Premium** to create up to **%2$@**."; +"Premium.Offset.AlreadyHave" = "You already have Telegram Premium."; +"Premium.Period.Annual" = "Annual"; +"Premium.Period.Monthly" = "Monthly"; +"Premium.Period.Price" = "%@/month"; +"Premium.Period.Price.TwoYear" = "%@/2-years"; +"Premium.Period.Price.Year" = "%@/year"; +"Premium.Period.SixMonth" = "Six Months"; +"Premium.Period.TwoYears" = "2-Years"; +"Premium.Purchase.ErrorCantMakePayments" = "In-app purchases are not allowed on this device."; +"Premium.Purchase.ErrorNetwork" = "Please check your Internet connection and try again."; +"Premium.Purchase.ErrorNotAllowed" = "The device is not allowed to make the payment."; +"Premium.Purchase.ErrorUnknown" = "An error occurred. Please try again."; +"Premium.Restore.ErrorUnknown" = "An error occurred. Please try again."; +"Premium.Restore.Success" = "Done"; +"Premium.ShowStatus.Or" = "or"; +"Premium.ShowStatus.ShowMyReadTime" = "Show my Read Time to Everyone"; +"Premium.ShowStatus.ShowMyStatus" = "Show my Last Seen to Everyone"; +"Premium.ShowStatus.ShowYourLastSeen" = "Show Your Last Seen"; +"Premium.ShowStatus.ShowYourLastSeen.Info" = "To see **%@'s** Last Seen time, either start showing your own Last Seen time..."; +"Premium.ShowStatus.ShowYourReadDate" = "Show Your Read Time"; +"Premium.ShowStatus.ShowYourReadDate.Info" = "To see when **%@** read the message, either start showing your own read time..."; +"Premium.ShowStatus.Subscribe" = "Subscribe to Telegram Premium"; +"Premium.ShowStatus.Success.LastSeen" = "Your last seen time is now visible."; +"Premium.ShowStatus.Success.ReadTime" = "Your read time is now visible."; +"Premium.ShowStatus.UpgradeToPremium" = "Upgrade to Premium"; +"Premium.ShowStatus.UpgradeToPremium.LastSeen.Info" = "Subscribing will let you see **%@'s** Last Seen status without showing yours."; +"Premium.ShowStatus.UpgradeToPremium.Read.Info" = "Subscribing will let you see **%@'s** read time without showing yours."; +"Premium.StarGift.Info" = "Subscribe to **Telegram Premium** to send up to **%d** of these gifts and unlock powerful features."; +"Premium.StarGift.Info_countable" = "%d"; +"Premium.StarGift.Title" = "Premium Gift"; +"Premium.WhatsIncluded" = "WHAT'S INCLUDED"; +"PremiumBoarding.AIToolsInfo" = "Transform your messages and entire chats in your preferred style and language."; +"PremiumBoarding.AIToolsTitle" = "AI Tools"; +"PremiumBoarding.ActivateForFree" = "Activate For Free"; +"PremiumBoarding.ActivateForFree.Already" = "You are already Telegram Premium subscriber."; +"PremiumBoarding.Business.AwayMessages" = "Away Messages"; +"PremiumBoarding.Business.AwayMessages.Info" = "Define messages that are automatically sent when you are off."; +"PremiumBoarding.Business.ChatBots" = "ChatBots"; +"PremiumBoarding.Business.ChatBots.Info" = "Add any third party chatbots that will process customer interactions."; +"PremiumBoarding.Business.GreetingMessages" = "Greeting Messages"; +"PremiumBoarding.Business.GreetingMessages.Info" = "Create greetings that will be automatically sent to new customers."; +"PremiumBoarding.Business.Intro" = "Start Page"; +"PremiumBoarding.Business.Intro.Info" = "Customize the message people see before they start a chat with you."; +"PremiumBoarding.Business.Links" = "Links to Chat"; +"PremiumBoarding.Business.Links.Info" = "Create links that start a chat with you, suggesting the first message."; +"PremiumBoarding.Business.Location" = "Location"; +"PremiumBoarding.Business.Location.Info" = "Display the location of your business on your account."; +"PremiumBoarding.Business.OpeningHours" = "Opening Hours"; +"PremiumBoarding.Business.OpeningHours.Info" = "Show to your customers when you are open for business."; +"PremiumBoarding.Business.QuickReplies" = "Quick Replies"; +"PremiumBoarding.Business.QuickReplies.Info" = "Set up shortcuts with rich text and media to respond to messages faster."; +"PremiumBoarding.Business.TelegramBusiness" = "Telegram Business"; +"PremiumBoarding.Business.TelegramBusiness.HeaderInfo1" = "You have now unlocked these additional business features. These features are temporarily included with your Premium Subscription."; +"PremiumBoarding.Business.TelegramBusiness.HeaderInfo2" = "Turn your account into a business page with these additional features. The benefits of Telegram Business are temporarily included with Telegram Premium."; +"PremiumBoarding.Business.TelegramBusiness.Info" = "Upgrade your account with business features such as location, opening hours and quick replies."; +"PremiumBoarding.DisableSharing" = "Disable Sharing"; +"PremiumBoarding.DisableSharingInfo" = "Prevent forwarding, saving and copying content in private chats."; +"PremiumBoarding.MoreBusinessHeader#one" = "+%d MORE TELEGRAM PREMIUM FEATURE"; +"PremiumBoarding.MoreBusinessHeader#other" = "+%d MORE TELEGRAM PREMIUM FEATURES"; +"PremiumBoarding.MoreBusinessHeader_countable" = "%d"; +"PremiumBoarding.Stories.ExpirationDuration.Info" = "Set custom durations like 6 or 48 hours for your stories."; +"PremiumBoarding.Stories.ExpirationDuration.Title" = "Expiration Options"; +"PremiumBoarding.Stories.HighQuality.Info" = "View video stories in double the resolution."; +"PremiumBoarding.Stories.HighQuality.Title" = "Higher Quality"; +"PremiumBoarding.Stories.LinkFormatting.Info" = "Add links and formatting to your story captions."; +"PremiumBoarding.Stories.LinkFormatting.Title" = "Links and Formatting"; +"PremiumBoarding.Stories.LongerCaption.Info" = "Add 10x longer captions to your stories."; +"PremiumBoarding.Stories.LongerCaption.Title" = "Longer Captions"; +"PremiumBoarding.Stories.PermanentHistoryView.Info" = "Check who opens your stories — even after they expire."; +"PremiumBoarding.Stories.PermanentHistoryView.Title" = "Permanent View History"; +"PremiumBoarding.Stories.PriorityOrder.Info" = "Get more views as your stories are always displayed first."; +"PremiumBoarding.Stories.PriorityOrder.Title" = "Priority Order"; +"PremiumBoarding.Stories.SaveToGallery.Info" = "Save other people's stories that are not protected."; +"PremiumBoarding.Stories.SaveToGallery.Title" = "Save Stories to Photos"; +"PremiumBoarding.Stories.StealthMode.Info" = "Hide the fact that you viewed other people's stories."; +"PremiumBoarding.Stories.StealthMode.Title" = "Stealth Mode"; +"PremiumBoarding.TagFolders" = "Tag Your Chats"; +"PremiumBoarding.TagFolders.Info" = "Add colorful labels to chats for faster access in chat list."; +"PremiumBoarding.Todo" = "Checklists"; +"PremiumBoarding.Todo.Info" = "Plan, assign, and complete tasks - seamlessly and efficiently."; +"PremiumGift.ButtonText" = "Gift subscription for %@"; +"PremiumGift.Month" = "%@ / Month"; +"Presence.Support" = "support"; +"Presence.bot" = "bot"; +"Presence.miniapp" = "mini app"; +"Preview.Dragging.AddItems#one" = "Add Item"; +"Preview.Dragging.AddItems#other" = "Add Items"; +"Preview.Dragging.AddItems_countable" = "%d"; +"PreviewSender.Archive" = "Send as ZIP Archive"; +"PreviewSender.ArchiveTooltip" = "Archive all media in one zip file"; +"PreviewSender.CaptionPlaceholder" = "Add a caption..."; +"PreviewSender.ChangeVideoCover" = "Change Video Cover"; +"PreviewSender.CollageTooltip" = "Group all media into one message"; +"PreviewSender.CommentPlaceholder" = "Add a comment..."; +"PreviewSender.CompressFile" = "Send compressed"; +"PreviewSender.FileTooltip" = "Send without compression"; +"PreviewSender.Grouped" = "Grouped"; +"PreviewSender.MakePaid" = "Make This Content Paid"; +"PreviewSender.MediaTooltip" = "Send in a quick way"; +"PreviewSender.MoveTextDown" = "Move Caption Down"; +"PreviewSender.MoveTextUp" = "Move Caption Up"; +"PreviewSender.RemoveMedia" = "Remove Media"; +"PreviewSender.RemovePaid" = "Remove Paid Content"; +"PreviewSender.RemoveVideoCover" = "Remove Video Cover"; +"PreviewSender.SendAsFile" = "Without Compression"; +"PreviewSender.SendAsMedia" = "With Compression"; +"PreviewSender.SendAsSpoiler" = "Send With Spoiler"; +"PreviewSender.SendAudio#one" = "Send Audio"; +"PreviewSender.SendAudio#other" = "Send %d Audios"; +"PreviewSender.SendAudio_countable" = "%d"; +"PreviewSender.SendFile#one" = "Send File"; +"PreviewSender.SendFile#other" = "Send %d Files"; +"PreviewSender.SendFile_countable" = "%d"; +"PreviewSender.SendGif#one" = "Send GIF"; +"PreviewSender.SendGif#other" = "Send %d GIFs"; +"PreviewSender.SendGif_countable" = "%d"; +"PreviewSender.SendMedia#one" = "Send Media"; +"PreviewSender.SendMedia#other" = "Send %d Media"; +"PreviewSender.SendMedia_countable" = "%d"; +"PreviewSender.SendPhoto#one" = "Send Photo"; +"PreviewSender.SendPhoto#other" = "Send %d Photos"; +"PreviewSender.SendPhoto_countable" = "%d"; +"PreviewSender.SendVideo#one" = "Send Video"; +"PreviewSender.SendVideo#other" = "Send %d Videos"; +"PreviewSender.SendVideo_countable" = "%d"; +"PreviewSender.SpoilerTooltip.Disable" = "Disable Spoiler"; +"PreviewSender.SpoilerTooltip.Enable" = "Hide with Spoiler"; +"PreviewSender.Title.Archive" = "ZIP Archive"; +"PreviewSender.Ungrouped" = "Ungrouped"; +"PreviewSender.UploadVideoCover" = "Upload Video Cover"; +"Privacy.Gifts.AcceptedGiftTypes" = "Accepted Gift Types"; +"Privacy.Gifts.Info" = "Choose the types of gifts that you allow others to send you."; +"Privacy.Gifts.LimitedEdition" = "Limited-Edition"; +"Privacy.Gifts.Premium" = "Premium Subscriptions"; +"Privacy.Gifts.Unique" = "Unique"; +"Privacy.Gifts.Unlimited" = "Unlimited"; +"Privacy.GroupsAndChannels.InviteToChannelError" = "Sorry, you cannot add %@ to channels because of %@'s privacy settings."; +"Privacy.GroupsAndChannels.InviteToChannelMultipleError" = "Sorry, you can't create a group with these users due to their privacy settings."; +"Privacy.GroupsAndChannels.InviteToGroupError" = "Sorry, you cannot add %@ to groups because of %@'s privacy settings."; +"Privacy.ResetPhoto.Confirm" = "Are you sure you want to remove your public photo?"; +"PrivacyAndSecurity.AutoArchiveDesc" = "Automatically archive and mute new chats, groups and channels from non-contacts."; +"PrivacyAndSecurity.AutoArchiveHeader" = "NEW CHATS FROM UNKNOWN USERS"; +"PrivacyAndSecurity.AutoArchiveText" = "Archive and Mute"; +"PrivacyAndSecurity.BlockedUsers" = "%@"; +"PrivacyAndSecurity.ClearCloudDrafts" = "Delete All Cloud Drafts"; +"PrivacyAndSecurity.ClearCloudDraftsHeader" = "CHATS"; +"PrivacyAndSecurity.Confirm.ClearCloudDrafts" = "Are you sure you want to delete all cloud drafts?"; +"PrivacyAndSecurity.Item.Off" = "Off"; +"PrivacyAndSecurity.Item.On" = "On"; +"PrivacyAndSecurity.SecretChatWebPreview.Desc" = "Link previews will be generated on Telegram servers. We do not store any data about the links you send."; +"PrivacyAndSecurity.SecretChatWebPreview.Header" = "SECRET CHAT"; +"PrivacyAndSecurity.SecretChatWebPreview.Text" = "Link Previews"; +"PrivacyAndSecurity.SensitiveDesc" = "Display sensitive media in public channels on all your Telegram devices."; +"PrivacyAndSecurity.SensitiveHeader" = "SENSITIVE CONTENT"; +"PrivacyAndSecurity.SensitiveText" = "Disable filtering"; +"PrivacyAndSecurity.WebAuthorizationHeader" = "CONNECTED WEBSITES"; +"PrivacyPhoneNumberSettings.CustomDisabledHelp" = "Users who add your number to their contacts will see it on Telegram only if they are your contacts."; +"PrivacyPhoneNumberSettings.DiscoveryHeader" = "WHO CAN FIND ME BY MY NUMBER"; +"PrivacySettings.ActiveSessions" = "Active Sessions"; +"PrivacySettings.Bio" = "Bio"; +"PrivacySettings.Birthday" = "Birthday"; +"PrivacySettings.BlockedUsers" = "Blocked Users"; +"PrivacySettings.DeleteAccount" = "If Away For"; +"PrivacySettings.DeleteAccountDescription" = "If you do not come online at least once within this period, your account will be deleted along with all messages and contacts."; +"PrivacySettings.DeleteAccountHeader" = "DELETE MY ACCOUNT"; +"PrivacySettings.Forwards" = "Forwarded Messages"; +"PrivacySettings.Gifts" = "Gifts"; +"PrivacySettings.Gifts.EnableForBots" = "Mini-apps"; +"PrivacySettings.GlobalTimer" = "Auto-Delete Messages"; +"PrivacySettings.GlobalTimer.Disable" = "Disable"; +"PrivacySettings.GlobalTimer.Group" = "Automatically delete messages in this group for everyone after a period of time."; +"PrivacySettings.GlobalTimer.Info" = "Automatically delete messages for everyone after a period of time in all new chats you start."; +"PrivacySettings.GlobalTimer.Never" = "Off"; +"PrivacySettings.GroupMembersCount#one" = "%d member"; +"PrivacySettings.GroupMembersCount#other" = "%d members"; +"PrivacySettings.GroupMembersCount_countable" = "%d"; +"PrivacySettings.Groups" = "Groups & Channels"; +"PrivacySettings.HideReadTime" = "Hide Read Time"; +"PrivacySettings.HideReadTime.Info" = "Hide the time when you read messages from people who can’t see your last seen. "; +"PrivacySettings.LastSeen" = "Last Seen & Online"; +"PrivacySettings.LastSeenContactsMinus" = "My Contacts (-%@)"; +"PrivacySettings.LastSeenContactsMinusPlus" = "My Contacts (-%@, +%@)"; +"PrivacySettings.LastSeenContactsPlus" = "My Contacts (+%@)"; +"PrivacySettings.LastSeenEverybodyMinus" = "Everybody (-%@)"; +"PrivacySettings.LastSeenNobodyPlus" = "Nobody (+%@)"; +"PrivacySettings.LoginEmail" = "Login Email"; +"PrivacySettings.LoginEmailAlertChange" = "Change Email"; +"PrivacySettings.LoginEmailAlertText" = "This email address will be used every time you login to your Telegram account from a new device."; +"PrivacySettings.LoginEmailInfo" = "Change your email address for Telegram login codes."; +"PrivacySettings.LoginEmailSetupInfo" = "Setup your email address for Telegram login codes."; +"PrivacySettings.Messages" = "Messages"; +"PrivacySettings.Messages.All" = "Everybody"; +"PrivacySettings.Messages.Contacts" = "My Contacts and Premium Users"; +"PrivacySettings.Messages.Everyone" = "Everyone"; +"PrivacySettings.Messages.Header" = "WHO CAN SEND ME MESSAGES?"; +"PrivacySettings.Messages.Info" = "You can restrict messages from users who are not in your contacts and whom you haven't messaged first.\n\n[What is Telegram Premium?]()"; +"PrivacySettings.Messages.MyContacts" = "Contacts And Premium"; +"PrivacySettings.Messages.PremiumError" = "Only subscribers of [Telegram Premium]() can restrict receiving Private Messages."; +"PrivacySettings.Passcode" = "Passcode Lock"; +"PrivacySettings.Passkey" = "Passkey"; +"PrivacySettings.PeerSelect.AddNew" = "Add New"; +"PrivacySettings.PeerSelect.AddUserOrGroup" = "Add Users or Groups"; +"PrivacySettings.PhoneNumber" = "Phone Number"; +"PrivacySettings.Premium" = "Subscribe to Telegram Premium"; +"PrivacySettings.Premium.Info" = "If you subscribe to Telegram Premium, you will still see other users' last seen and read time even if you hid yours from them (unless they specifically restricted it)."; +"PrivacySettings.PremiumUsers" = "Premium"; +"PrivacySettings.PrivacyHeader" = "PRIVACY"; +"PrivacySettings.ProfilePhoto" = "Profile Photos"; +"PrivacySettings.ProxyHeader" = "CONNECTION TYPE"; +"PrivacySettings.SavedMusic" = "Saved Music"; +"PrivacySettings.SecurityHeader" = "SECURITY"; +"PrivacySettings.TwoStepVerification" = "Two-Step Verification"; +"PrivacySettings.UseProxy" = "Use Proxy"; +"PrivacySettings.Voice.PremiumError" = "Only subscribers of [Telegram Premium]() can restrict receiving Voice and Video Messages."; +"PrivacySettings.VoiceCalls" = "Calls"; +"PrivacySettings.VoiceMessages" = "Voice Messages"; +"PrivacySettingsController.AddUsers" = "Add Users"; +"PrivacySettingsController.AlwaysAllow" = "Always Allow"; +"PrivacySettingsController.AlwaysShare" = "Always Share With"; +"PrivacySettingsController.AlwaysShareWith" = "Always Share With…"; +"PrivacySettingsController.Bio.AlwaysShareWith.Title" = "Always Share With"; +"PrivacySettingsController.Bio.CustomHelp" = "You can restrict who can see the bio on your profile with granular precision."; +"PrivacySettingsController.Bio.NeverShareWith.Title" = "Never Share With"; +"PrivacySettingsController.Bio.WhoCanSee" = "WHO CAN SEE MY BIO"; +"PrivacySettingsController.Birthday.AddHeader" = "You haven't added your birthday yet.\n[Add my birthday >]()"; +"PrivacySettingsController.Birthday.AlwaysShareWith.Title" = "Always Share With"; +"PrivacySettingsController.Birthday.CustomHelp" = "You can restrict who can see your birthday with granular precision."; +"PrivacySettingsController.Birthday.NeverShareWith.Title" = "Never Share With"; +"PrivacySettingsController.Birthday.WhoCanSee" = "WHO CAN SEE MY BIRTHDAY"; +"PrivacySettingsController.Everbody" = "Everybody"; +"PrivacySettingsController.Forwards.AlwaysAllow.Title" = "Always Allow"; +"PrivacySettingsController.Forwards.CustomHelp" = "You can restrict who can add a link to your account when forwarding your messages."; +"PrivacySettingsController.Forwards.NeverAllow.Title" = "Never Allow"; +"PrivacySettingsController.Forwards.WhoCanForward" = "WHO CAN ADD A LINK TO MY ACCOUNT WHEN FORWARDING MY MESSAGES"; +"PrivacySettingsController.Gift.ShowGiftIcon" = "Show Gift Icon in Chats"; +"PrivacySettingsController.Gift.ShowGiftIcon.Info" = "Display the Gift Icon in the message input field for both participants in all chats."; +"PrivacySettingsController.Gifts.CustomHelp" = "Choose whether gifts from specific senders need your approval before they're visible to others on your profile."; +"PrivacySettingsController.Gifts.WhoCanSee" = "WHO CAN DISPLAY GIFTS ON MY PROFILE"; +"PrivacySettingsController.GroupDescription" = "You can restrict who can add you to groups and channels with granular precision."; +"PrivacySettingsController.GroupHeader" = "WHO CAN ADD ME TO GROUP CHATS"; +"PrivacySettingsController.Header" = "Last Seen & Online"; +"PrivacySettingsController.LastSeenDescription" = "Unless you are a Premium user, you won't see Last Seen or Online statuses for people with whom you don't share yours. Approximate times will be shown instead (recently, within a week, within a month)."; +"PrivacySettingsController.LastSeenHeader" = "WHO CAN SEE MY LAST SEEN"; +"PrivacySettingsController.MyContacts" = "My Contacts"; +"PrivacySettingsController.NeverAllow" = "Never Allow"; +"PrivacySettingsController.NeverShare" = "Never Share With"; +"PrivacySettingsController.NeverShareWith" = "Never Share With"; +"PrivacySettingsController.Nobody" = "Nobody"; +"PrivacySettingsController.P2p.Always" = "Always"; +"PrivacySettingsController.P2p.Contacts" = "My Contacts"; +"PrivacySettingsController.P2p.Desc" = "Disabling peer-to-peer will relay all calls through Telegram servers to avoid revealing your IP address, but may decrease audio and video quality."; +"PrivacySettingsController.P2p.Header" = "PEER TO PEER"; +"PrivacySettingsController.P2p.Never" = "Never"; +"PrivacySettingsController.PeerInfo" = "You can add users or entire groups as exceptions that will override the settings above."; +"PrivacySettingsController.PhoneCallDescription" = "Choose exactly who can and can't call you."; +"PrivacySettingsController.PhoneCallHeader" = "WHO CAN CALL ME"; +"PrivacySettingsController.PhoneNumber.AlwaysAllow.Title" = "Always Share With…"; +"PrivacySettingsController.PhoneNumber.CustomHelp" = "Users who have your number saved in their contacts will also see it on Telegram."; +"PrivacySettingsController.PhoneNumber.NeverAllow.Title" = "Never Share"; +"PrivacySettingsController.PhoneNumber.WhoCanSeePhoneNumber" = "WHO CAN SEE MY PHONE NUMBER"; +"PrivacySettingsController.ProfilePhoto.AlwaysShareWith.Title" = "Always Share With…"; +"PrivacySettingsController.ProfilePhoto.CustomHelp" = "You can restrict who can see your profile photo with granular precision."; +"PrivacySettingsController.ProfilePhoto.NeverShareWith.Title" = "Never Share With…"; +"PrivacySettingsController.ProfilePhoto.WhoCanSeeMyPhoto" = "WHO CAN SEE MY PROFILE PHOTO"; +"PrivacySettingsController.RemoveFee" = "Remove Fee"; +"PrivacySettingsController.RemovePublicPhoto" = "Remove Public Photo"; +"PrivacySettingsController.SavedMusic.AlwaysShareWith.Title" = "Always Share With"; +"PrivacySettingsController.SavedMusic.CustomHelp" = "You can restrict who can see your saved music with granular precision."; +"PrivacySettingsController.SavedMusic.NeverShareWith.Title" = "Never Share With"; +"PrivacySettingsController.SavedMusic.WhoCanSee" = "WHO CAN SEE MY SAVED MUSIC"; +"PrivacySettingsController.SetPublicPhoto" = "Set Public Photo"; +"PrivacySettingsController.SetPublicPhoto.Info" = "You can upload a public photo for users who are restricted from seeing your real profile photos."; +"PrivacySettingsController.UpdatePublicPhoto" = "Update Public Photo"; +"PrivacySettingsController.UserCount#one" = "%d user"; +"PrivacySettingsController.UserCount#other" = "%d users"; +"PrivacySettingsController.UserCount_countable" = "%d"; +"PrivacySettingsController.VoiceMessagesDescription" = "You can restrict who can send you voice or video messages."; +"PrivacySettingsController.VoiceMessagesDescription.NonPremium" = "Subscribe to Telegram Premium to restrict who can send you voice or video messages.\n\n[What is Telegram Premium?]()"; +"PrivacySettingsController.VoiceMessagesHeader" = "WHO CAN SEND ME VOICE AND VIDEO MESSAGES"; +"PrivateChannel.Peek.Cancel" = "Cancel"; +"PrivateChannel.Peek.Header" = "Join Channel"; +"PrivateChannel.Peek.OK" = "Join Channel"; +"PrivateChannel.Peek.Text" = "This channel is private. Please join it to continue viewing its content."; +"ProfileTab.ContextMenu.SetAsMain" = "Set as Main"; +"Proxy.CheckStatus" = "Check Status"; +"Proxy.ForceDisable" = "Are you sure you want to disable proxy server %@?"; +"Proxy.ForceEnable.Connect" = "Connect"; +"Proxy.ForceEnable.Enable" = "Enable Proxy"; +"Proxy.ForceEnable.Header1" = "Do you want to add this proxy?"; +"Proxy.ForceEnable.MTPDesc" = "This proxy may display a sponsored channel in your chat list. This doesn't reveal any of your Telegram traffic."; +"Proxy.ForceEnable.OK" = "Add Proxy"; +"Proxy.ForceEnable.Text" = "You can change your proxy server later in Settings > Privacy and Security."; +"Proxy.ForceEnable.Text.IP" = "Server: %@"; +"Proxy.ForceEnable.Text.Password" = "Password: %@"; +"Proxy.ForceEnable.Text.Port" = "Port: %d"; +"Proxy.ForceEnable.Text.Secret" = "Secret: %@"; +"Proxy.ForceEnable.Text.Username" = "Username: %@"; +"Proxy.Password" = "Password"; +"Proxy.Port" = "Port"; +"Proxy.Proceed" = "Proceed"; +"Proxy.Server" = "Server"; +"Proxy.Status" = "Status"; +"Proxy.Username" = "Username"; +"Proxy.Warning" = "Warning"; +"Proxy.WarningInfo" = "This action will expose your IP address to the admin of the proxy server."; +"ProxySettings.AddProxy" = "Add Proxy"; +"ProxySettings.ConnectionHeader" = "Connection"; +"ProxySettings.CopyLink" = "Share proxy link"; +"ProxySettings.CredentialsHeader" = "CREDENTIALS (OPTIONAL)"; +"ProxySettings.Disabled" = "Disabled"; +"ProxySettings.Enable" = "Proxy"; +"ProxySettings.ExportDescription" = "If your clipboard contains socks5-link (**t.me/socks?server=127.0.0.1&port=80**) it will apply immediately"; +"ProxySettings.ExportLink" = "Export link from clipboard"; +"ProxySettings.IncorrectSecret" = "Incorrect secret. Please try again."; +"ProxySettings.Item.Available" = "available (ping: %@ ms)"; +"ProxySettings.Item.Checking" = "checking"; +"ProxySettings.Item.Connected" = "connected"; +"ProxySettings.Item.ConnectedPing" = "connected (ping: %@ ms)"; +"ProxySettings.Item.LastConnection" = "last connection %@"; +"ProxySettings.Item.NeverConnected" = "unavailable"; +"ProxySettings.MTP" = "MTPROTO"; +"ProxySettings.Mtp.Sponsor" = "The proxy may display a sponsored channel in your chat list. This doesn't reveal any of your Telegram traffic."; +"ProxySettings.Password" = "Password"; +"ProxySettings.Port" = "Port"; +"ProxySettings.ProxyNotFound" = "Proxy settings not found in clipboard."; +"ProxySettings.QR.Text" = "Your friends can add this proxy by scanning the code with their camera."; +"ProxySettings.Save" = "Save"; +"ProxySettings.Secret" = "Secret"; +"ProxySettings.Server" = "Server"; +"ProxySettings.Share" = "Share this link with friends to circumvent censorship in your country"; +"ProxySettings.ShareProxyList" = "Share Proxy List"; +"ProxySettings.Socks5" = "SOCKS5"; +"ProxySettings.Title" = "Proxy Settings"; +"ProxySettings.Type" = "Proxy Type"; +"ProxySettings.UseForCalls" = "Use for Calls"; +"ProxySettings.Username" = "Username"; +"QuickLook.Preview" = "Preview"; +"QuickReply.ChatRemoveAwayMessage.Text" = "You didn't create an away message. Exiting will remove it."; +"QuickReply.ChatRemoveAwayMessage.Title" = "Remove Away Message"; +"QuickReply.ChatRemoveGeneric.DeleteAction" = "Delete"; +"QuickReply.ChatRemoveGeneric.Text" = "You didn't create a quick reply message. Exiting will remove the shortcut."; +"QuickReply.ChatRemoveGeneric.Title" = "Remove Shortcut"; +"QuickReply.ChatRemoveGreetingMessage.Text" = "You didn't create a greeting message. Exiting will remove it."; +"QuickReply.ChatRemoveGreetingMessage.Title" = "Remove Greeting Message"; +"QuickSwitcher.Description" = "**TAB** or **↑ ↓** to navigate, **⮐** to select, **ESC** to dismiss"; +"QuickSwitcher.Popular" = "Popular"; +"QuickSwitcher.Recently" = "Recent"; +"R4o-n2-Eq4.title" = "Zoom"; +"Reaction.Settings.Allow" = "Enable Reactions"; +"Reaction.Settings.Allow.Channel.Info" = "Allow subscribers to react to channel posts."; +"Reaction.Settings.Allow.Group.Info" = "Allow members to react to group messages."; +"Reaction.Settings.Available.Info" = "AVAILABLE REACTIONS"; +"Reaction.Settings.Group.All" = "All Reactions"; +"Reaction.Settings.Group.None" = "No Reactions"; +"Reaction.Settings.Group.Some" = "Some Reactions"; +"Reaction.Settings.Legacy" = "Hover Reactions"; +"Reaction.Settings.Legacy.Info" = "Reaction button will be shown when you hover next to a message."; +"Reaction.Settings.Quick.Info" = "QUICK REACTION"; +"Reaction.Settings.Quick.Title" = "Quick Reaction"; +"Reaction.Settings.Title" = "Reactions"; +"Reactions.Popular" = "Popular"; +"Reactions.Preview.Premium" = "Unlock additional reactions by subscribing to Telegram Premium"; +"Reactions.Preview.Unlock" = "Unlock Premium Reactions"; +"Reactions.RecentlyUsed" = "Recently Used"; +"RecentCalls.Delete" = "Delete"; +"RecentCalls.DeleteCalls" = "Are you sure you want to delete this call?"; +"RecentCalls.DeleteForMeAnd" = "Delete for me and %@"; +"RecentCalls.DeleteHeader" = "Delete"; +"RecentCalls.Empty" = "Your recent calls will appear here"; +"RecentCalls.NewCall" = "Create New Call"; +"RecentCalls.NewCall.OK" = "Call"; +"RecentCalls.NewCallLink" = "New Call Link"; +"RecentCalls.RecentCalls" = "Recent Calls"; +"RecentSessions.Confirm.Revoke" = "Do you want to terminate this session?"; +"RecentSessions.Confirm.TerminateOthers" = "Are you sure you want to terminate all other sessions?"; +"RecentSessions.Error.FreshReset" = "For security reasons, you can't terminate older sessions from a device that you've just connected. Please use an earlier connection or wait for a few hours."; +"RecentSessions.IncompleteAttemptDesc" = "These devices have no access to your account. The code was entered correctly, but no correct password was given."; +"RecentSessions.IncompleteAttemptHeader" = "INCOMPLETE LOGIN ATTEMPTS"; +"RecentSessions.Revoke" = "Revoke"; +"RecentSessions.TTL.Header" = "AUTOMATICALLY TERMINATE OLD SESSIONS"; +"RecentSessions.TTL.Text" = "If Inactive For"; +"Report.AdditionText" = "Please enter any additional details relevant for your report."; +"Report.AdditionText.Button" = "Report"; +"Report.AdditionText.Placeholder" = "Additional details..."; +"Report.Complicated.Message.Title" = "Report Message"; +"Report.Complicated.Messages.Title" = "Report Messages"; +"Report.Complicated.Peer.Title" = "Report %@"; +"Report.Complicated.SendReport" = "Send Report"; +"Report.Complicated.Story.Title" = "Report Story"; +"ReportReason.ChildAbuse" = "Child Abuse"; +"ReportReason.Copyright" = "Copyright"; +"ReportReason.Drugs" = "Illegal Drugs"; +"ReportReason.Fake" = "Fake"; +"ReportReason.Other" = "Other"; +"ReportReason.Other.Placeholder" = "Description"; +"ReportReason.PersonalDetails" = "Personal Details"; +"ReportReason.Porno" = "Pornography"; +"ReportReason.Report" = "Report"; +"ReportReason.Spam" = "Spam"; +"ReportReason.Violence" = "Violence"; +"RequestAcces.Error.Conirm.Settings" = "Settings"; +"RequestAcces.Error.HaveNotAccess.Call" = "Telegram needs microphone access so that you can make calls."; +"RequestAcces.Error.HaveNotAccess.VideoMessages" = "Telegram needs access to your microphone and camera to record video messages."; +"RequestAcces.Error.HaveNotAccess.VoiceMessages" = "Telegram needs access to your microphone to record voice messages."; +"RequestJoin.Button" = "Request to Join"; +"RequestJoin.Desc.Channel" = "This channel accepts new subscribers only after they are approved by its admins."; +"RequestJoin.Desc.Group" = "This group accepts new members only after they are approved by its admins."; +"RequestJoin.Error.AlreadySent.Channel" = "You have already requested to join this channel"; +"RequestJoin.Error.AlreadySent.Group" = "You have already requested to join this group"; +"RequestJoin.List.Approve.All" = "Approve All"; +"RequestJoin.List.Approve.Channel" = "Add to Channel"; +"RequestJoin.List.Approve.Dismiss" = "Dismiss"; +"RequestJoin.List.Approve.Group" = "Add to Group"; +"RequestJoin.List.Description" = "Some [addition links]() are set up to accept requests to join the channel."; +"RequestJoin.List.Dismiss.All" = "Dismiss All"; +"RequestJoin.List.Empty1" = "No Join Requests"; +"RequestJoin.List.Empty2.Channel" = "There are no pending join requests."; +"RequestJoin.List.Empty2.Group" = "There are no pending join requests."; +"RequestJoin.List.ListHeader#one" = "%d JOIN REQUEST"; +"RequestJoin.List.ListHeader#other" = "%d JOIN REQUESTS"; +"RequestJoin.List.ListHeader_countable" = "%d"; +"RequestJoin.List.SearchEmpty" = "There were no results for \"%@\".\nTry a new search."; +"RequestJoin.List.SearchEmpty.Header" = "No Results Found"; +"RequestJoin.List.Status.Approved" = "Approved"; +"RequestJoin.List.Status.Dismissed" = "Dismissed"; +"RequestJoin.List.Title" = "Join Requests"; +"RequestJoin.List.Tooltip.Approved.Channel" = "**%@** has been added to the channel."; +"RequestJoin.List.Tooltip.Approved.Group" = "**%@** has been added to the group."; +"RequestJoin.Sent" = "Join request sent"; +"Ruw-6m-B2m.title" = "Select All"; +"SavedAs.ModalOk" = "Saved!\n[Show In Finder]()"; +"SavedMessages.Tags.Unlock" = "Unlock"; +"Schedule.SendDate" = "Send on %@ at %@"; +"Schedule.SendToday" = "Send today at %@"; +"Schedule.SendWhenOnline" = "Send When Online"; +"ScheduleController.Title" = "Schedule Message"; +"ScheduleController.at" = "at"; +"Search.Ad" = "Ad"; +"Search.Ad.About.Remove" = "You can turn off ads by subscribing to Telegram Premium."; +"Search.Ad.RemoveAd" = "Remove Ad"; +"Search.Ad.SponsorInfo" = "Sponsor Info"; +"Search.Confirm.ClearHistory" = "Do you want to clear your search history?"; +"Search.Filter.AllChats" = "All Chats"; +"Search.Filter.Channels" = "Channels"; +"Search.Filter.ClearFilter" = "Clear Filter"; +"Search.Filter.Files" = "Files"; +"Search.Filter.From" = "From %@"; +"Search.Filter.GIFs" = "GIFs"; +"Search.Filter.GroupChats" = "Group Chats"; +"Search.Filter.Links" = "Links"; +"Search.Filter.Music" = "Music"; +"Search.Filter.Photos" = "Photos"; +"Search.Filter.PrivateChats" = "Private Chats"; +"Search.Filter.Videos" = "Videos"; +"Search.Filter.Voice" = "Voice"; +"Search.Global.Channel1#one" = "%@, %d subscriber"; +"Search.Global.Channel1#other" = "%@, %d subscribers"; +"Search.Global.Channel1_countable" = "%@ %d"; +"Search.Global.Group1#one" = "%@, %d member"; +"Search.Global.Group1#other" = "%@, %d members"; +"Search.Global.Group1_countable" = "%@ %d"; +"Search.Popular.Articles" = "Articles"; +"Search.Popular.Delete" = "Delete"; +"Search.Popular.SavedMessages" = "Saved"; +"Search.RemoveFromRecent" = "Remove From Recent"; +"Search.Separator.Apps.Popular" = "Grossing Apps"; +"Search.Separator.Apps.YouUse" = "APPS YOU USE"; +"Search.Separator.ChannelsJoined" = "CHANNELS YOU JOINED"; +"Search.Separator.ChatsAndContacts" = "contacts and chats"; +"Search.Separator.GlobalPeers" = "global search"; +"Search.Separator.Messages" = "messages"; +"Search.Separator.Popular" = "People"; +"Search.Separator.Recent" = "Recent"; +"Search.Separator.Recommended" = "SIMILAR CHANNELS"; +"Search.Separator.ShowAsMessages" = "show as messages"; +"Search.Separator.Topics" = "Topics"; +"SearchField.Search" = "Search"; +"SecretTimer.Off" = "Off"; +"SecureId.Accept.Help" = "You are sending your documents directly to **%@** and allowing their **%@** to send you messages."; +"SecureId.Accept.Policy" = "You accept the [Login Widget Example Privacy Policy](_applyPolicy_) and allow their **%@** to send you messages."; +"SecureId.Add.BankStatement" = "Add Bank Statement"; +"SecureId.Add.DriverLicense" = "Add Driver's License"; +"SecureId.Add.ID" = "Add Identity Card"; +"SecureId.Add.InternalPassport" = "Add Internal Passport"; +"SecureId.Add.Passport" = "Add Passport"; +"SecureId.Add.PassportRegistration" = "Add Passport Registration"; +"SecureId.Add.PersonalDetails" = "Add Personal Details"; +"SecureId.Add.RentalAgreement" = "Add Rental Agreement"; +"SecureId.Add.ResidentialAddress" = "Add Residential Address"; +"SecureId.Add.TemporaryRegistration" = "Add Temporary Registration"; +"SecureId.Add.TenancyAgreement" = "Add Tenancy Agreement"; +"SecureId.Add.UtilityBill" = "Add Utility Bill"; +"SecureId.Address.City.InputPlaceholder" = "City"; +"SecureId.Address.City.Placeholder" = "City"; +"SecureId.Address.Country.Placeholder" = "Country"; +"SecureId.Address.Header" = "ADDRESS"; +"SecureId.Address.Postcode.InputPlaceholder" = "Postcode"; +"SecureId.Address.Postcode.Placeholder" = "Postcode"; +"SecureId.Address.Region.InputPlaceholder" = "State / Republic / Region"; +"SecureId.Address.Region.Placeholder" = "Region"; +"SecureId.Address.Scans#one" = "%d scan"; +"SecureId.Address.Scans#other" = "%d scans"; +"SecureId.Address.Scans_countable" = "%d"; +"SecureId.Address.Street.InputPlaceholder" = "Street and Number, PO Box"; +"SecureId.Address.Street.Placeholder" = "Street"; +"SecureId.Address.Street1.InputPlaceholder" = "Apt, suite, unit, building, floor"; +"SecureId.AppVersionOutdated" = "Sorry, your Telegram app is out of date and can't handle this request. Please update Telegram."; +"SecureId.Confirm.Cancel" = "Are you sure you want to stop the authorization process?"; +"SecureId.Confirm.DeleteAddress" = "Delete Address"; +"SecureId.Confirm.DeleteDocument" = "Are you sure you want to delete this document?"; +"SecureId.CorrectErrors" = "Please correct errors"; +"SecureId.CreatePassword.Description" = "Please create a password to protect your passport info. You will also be asked to enter it when you log in to Telegram."; +"SecureId.CreatePassword.Email.Description" = "Please add your valid email. It is the only way to recover a forgotten password."; +"SecureId.CreatePassword.Email.Header" = "RECOVERY EMAIL"; +"SecureId.CreatePassword.Email.InputPlaceholder" = "Your email"; +"SecureId.CreatePassword.Email.Placeholder" = "Email"; +"SecureId.CreatePassword.Header" = "PASSWORD"; +"SecureId.CreatePassword.Hint.Header" = "HINT"; +"SecureId.CreatePassword.Hint.InputPlaceholder" = "Hint for your password"; +"SecureId.CreatePassword.Hint.Placeholder" = "Hint"; +"SecureId.CreatePassword.Intro" = "Please create a password which will be used to encrypt your personal data.\n\nThis password will also be required whenever you log in to Telegram on a new device."; +"SecureId.CreatePassword.Intro.Header" = "**%@ requests access to your personal data**\nto sign you up for their services"; +"SecureId.CreatePassword.PasswordInputPlaceholder" = "Enter your password"; +"SecureId.CreatePassword.PasswordPlaceholder" = "Password"; +"SecureId.CreatePassword.RePasswordInputPlaceholder" = "Re-Enter your password"; +"SecureId.CreatePassword.Title" = "Password & Email"; +"SecureId.Delete.Confirm.PersonalDetails" = "Are you sure you want to delete personal details?"; +"SecureId.Delete.PersonalDetails" = "Delete Personal Details"; +"SecureId.DeleteAddress" = "Delete Address"; +"SecureId.DeleteIdentity" = "Delete Document"; +"SecureId.DeletePassport" = "Delete Telegram Passport"; +"SecureId.DiscardChanges.Header" = "Discard Changes"; +"SecureId.DiscardChanges.Text" = "Are you sure you want to discard all changes?"; +"SecureId.Edit.BankStatement" = "Edit Bank Statement"; +"SecureId.Edit.DriverLicense" = "Edit Driver's License"; +"SecureId.Edit.ID" = "Edit Identity Card"; +"SecureId.Edit.InternalPassport" = "Edit Internal Passport"; +"SecureId.Edit.Passport" = "Edit Passport"; +"SecureId.Edit.PassportRegistration" = "Edit Passport Registration"; +"SecureId.Edit.PersonalDetails" = "Edit Personal Details"; +"SecureId.Edit.RentalAgreement" = "Edit Rental Agreement"; +"SecureId.Edit.ResidentialAddress" = "Edit Residential Address"; +"SecureId.Edit.TemporaryRegistration" = "Edit Temporary Registration"; +"SecureId.Edit.TenancyAgreement" = "Edit Tenancy Agreement"; +"SecureId.Edit.UtilityBill" = "Edit Utility Bill"; +"SecureId.Email" = "Email Address"; +"SecureId.Email.Email.InputPlaceholder" = "Enter your email address"; +"SecureId.Email.Email.Placeholder" = "Email"; +"SecureId.Email.UseSame" = "Use %@"; +"SecureId.Email.UseSame.Desc" = "Note: You will receive a confirmation code to the email address you provide."; +"SecureId.EmailActivate.Code.InputPlaceholder" = "Enter code"; +"SecureId.EmailActivate.Code.Placeholder" = "Code"; +"SecureId.EmailActivate.Description" = "Please enter the confirmation code we've just sent to %@."; +"SecureId.EmptyDescription.Address" = "Provide your address"; +"SecureId.EmptyDescription.BankStatement" = "Upload a scan of your bank statement"; +"SecureId.EmptyDescription.DriversLicense" = "Upload a scan of your driver's license"; +"SecureId.EmptyDescription.IdentityCard" = "Upload a scan of your identity card"; +"SecureId.EmptyDescription.InternalPassport" = "Upload a scan of your internal passport"; +"SecureId.EmptyDescription.Passport" = "Upload a scan of your passport"; +"SecureId.EmptyDescription.PassportRegistration" = "Upload a scan of your passport registration"; +"SecureId.EmptyDescription.PersonalDetails" = "Fill in your personal details"; +"SecureId.EmptyDescription.TemporaryRegistration" = "Upload a scan of your temporary registration"; +"SecureId.EmptyDescription.TenancyAgreement" = "Upload a scan of your tenancy agreement"; +"SecureId.EmptyDescription.UtilityBill" = "Upload a scan of your utility bill"; +"SecureId.Error.ScansLimit" = "You can't upload more than 20 files"; +"SecureId.FileUpload.Progress" = "%@% Uploaded"; +"SecureId.Gender.Female" = "Female"; +"SecureId.Gender.Male" = "Male"; +"SecureId.Identity.BankStatement" = "Bank Statement"; +"SecureId.Identity.CardId.InputPlaceholder" = "Card ID"; +"SecureId.Identity.CardId.Placeholder" = "Card ID"; +"SecureId.Identity.DocumentDetailsHeader" = "DOCUMENT DETAILS"; +"SecureId.Identity.DriverLicense" = "Driver's License"; +"SecureId.Identity.Id" = "Identity Card"; +"SecureId.Identity.InputPlaceholder.FirstName" = "Name"; +"SecureId.Identity.InputPlaceholder.LastName" = "Surname"; +"SecureId.Identity.InputPlaceholder.MiddleName" = "Middle Name"; +"SecureId.Identity.License.InputPlaceholder" = "Number"; +"SecureId.Identity.License.Placeholder" = "License ID"; +"SecureId.Identity.NameInLatine" = "Enter your name using the Latin alphabet"; +"SecureId.Identity.Passport" = "Passport"; +"SecureId.Identity.Passport.InputPlaceholder" = "Document Number"; +"SecureId.Identity.Passport.Placeholder" = "Document №"; +"SecureId.Identity.PassportRegistration" = "Passport Registration"; +"SecureId.Identity.Placeholder.Birthday" = "Birthday"; +"SecureId.Identity.Placeholder.Citizenship" = "Citizenship"; +"SecureId.Identity.Placeholder.Country" = "Country"; +"SecureId.Identity.Placeholder.ExpiryDate" = "Expiry Date"; +"SecureId.Identity.Placeholder.FirstName" = "Name"; +"SecureId.Identity.Placeholder.Gender" = "Gender"; +"SecureId.Identity.Placeholder.IssuedDate" = "Issue Date"; +"SecureId.Identity.Placeholder.LastName" = "Surname"; +"SecureId.Identity.Placeholder.MiddleName" = "Middle Name"; +"SecureId.Identity.Placeholder.Residence" = "Residence"; +"SecureId.Identity.Selfie" = "Selfie"; +"SecureId.Identity.SelfieHelp" = "Upload a photo of yourself holding your document. Make sure the ID and your face are clearly visible."; +"SecureId.Identity.SelfieTitle" = "SELFIE VERIFICATION"; +"SecureId.Identity.SelfieUpload" = "Add Selfie"; +"SecureId.Identity.SelfieUploadNew" = "Retake Selfie"; +"SecureId.Identity.TenancyAgreement" = "Tenancy Agreement"; +"SecureId.Identity.UtilityBill" = "Utility Bill"; +"SecureId.IdentityDocument" = "Identity Document"; +"SecureId.IdentityScan.Description" = "The document must contain your full name and residential address, as well as a stamp/bar code/QR code/logo and the issue date. It must be less than three months old."; +"SecureId.Info" = "With Telegram Passport you can easily sign up for websites and services that require identity verification.\n\nYour information, personal data, and documents are protected by end-to-end encryption. Nobody, including Telegram, can access them without your permission."; +"SecureId.Info.DeletePassport" = "Are you sure you want to delete your Telegram Passport? All details will be lost."; +"SecureId.Info.More" = "More Info"; +"SecureId.Info.Title" = "What is Telegram Passport?"; +"SecureId.InputError.LatinOnly" = "Please use latin characters only"; +"SecureId.InsertPassword.Description" = "Please enter your password to access your personal data"; +"SecureId.InsertPassword.Next" = "Next"; +"SecureId.InsertPassword.Password" = "Enter your password"; +"SecureId.InsertPassword.Settings.Description" = "Enter your Telegram password to decrypt your data"; +"SecureId.InstallEmail.Title" = "Email"; +"SecureId.InstallPhone.Title" = "Phone Number"; +"SecureId.LoginText" = "Please log in to your account to use Telegram Passport"; +"SecureId.NameNative.Desc.Empty" = "Your name in the language of your country of residence"; +"SecureId.NameNative.Desc.Language" = "Your name in the language of your country of residence (%@)."; +"SecureId.NameNative.Header" = "YOUR NAME IN %@"; +"SecureId.NameNative.HeaderEmpty" = "NAME IN COUNTRY OF RESIDENCE"; +"SecureId.Password.Error.Invalid" = "Invalid password, please try again."; +"SecureId.Password.Error.Limit" = "Too many attempts, please try again later."; +"SecureId.PhoneNumber" = "Phone Number"; +"SecureId.PhoneNumber.ConfirmCode.Desc" = "Please enter the confirmation code we've just sent to %@ via SMS"; +"SecureId.PhoneNumber.ConfirmCode.InputPlaceholder" = "Enter the code"; +"SecureId.PhoneNumber.ConfirmCode.Placeholder" = "Code"; +"SecureId.PhoneNumber.Header" = "OR ENTER ANOTHER PHONE NUMBER"; +"SecureId.PhoneNumber.Note" = "Note: You will receive a confirmation code on the phone number you provide."; +"SecureId.PhoneNumber.UseSame" = "Use %@"; +"SecureId.PhoneNumber.UseSame.Desc" = "Use the phone number you use for Telegram"; +"SecureId.RecoverPassword" = "Password Recovery"; +"SecureId.RecoverPassword.SentEmailCode" = "Code was sent to %@"; +"SecureId.RemoveEmail" = "Remove this email address?"; +"SecureId.RemovePhoneNumber" = "Remove this phone number?"; +"SecureId.Request.Accept" = "Authorize"; +"SecureId.Request.CreatePassword" = "Create a Password"; +"SecureId.Request.Header1" = "**%@** requests access to your personal data to sign you up for their services."; +"SecureId.Request.Permission.Address.Empty" = "Upload proof of your address"; +"SecureId.Request.Permission.BankStatement" = "Bank Statement"; +"SecureId.Request.Permission.DriversLicense" = "Driver's License"; +"SecureId.Request.Permission.Email" = "Email Address"; +"SecureId.Request.Permission.Email.Empty" = "Provide your contact email address"; +"SecureId.Request.Permission.IDCard" = "Identity Card"; +"SecureId.Request.Permission.Identity.Empty" = "Upload a scan of your passport or other ID"; +"SecureId.Request.Permission.IdentityDocument" = "Identity Document"; +"SecureId.Request.Permission.InternalPassport" = "Internal Passport"; +"SecureId.Request.Permission.Passport" = "Passport"; +"SecureId.Request.Permission.PassportRegistration" = "Passport Registration"; +"SecureId.Request.Permission.PersonalDetails" = "Personal Details"; +"SecureId.Request.Permission.Phone" = "Phone Number"; +"SecureId.Request.Permission.Phone.Empty" = "Provide your contact phone number"; +"SecureId.Request.Permission.ResidentialAddress" = "Residential Address"; +"SecureId.Request.Permission.TemporaryRegistration" = "Temporary Registration"; +"SecureId.Request.Permission.TenancyAgreement" = "Tenancy Agreement"; +"SecureId.Request.Permission.UtilityBill" = "Utility Bill"; +"SecureId.Request.TwoDocuments.Title" = "%@ or %@"; +"SecureId.Request.Upload.Selfie" = "Upload a selfie with your document"; +"SecureId.Request.Upload.Translation" = "Upload a translation of your document"; +"SecureId.RequestedInformation.Header" = "REQUESTED INFORMATION"; +"SecureId.ResidentialAddress" = "Residential Address"; +"SecureId.ScanNumber" = "Scan %d"; +"SecureId.Scans.Header" = "SCANS"; +"SecureId.Translation.Desc" = "Upload scans of a certified English translation of the document."; +"SecureId.Translation.Header" = "TRANSLATION"; +"SecureId.Upload.Front" = "Upload a photo of the front side of the document"; +"SecureId.Upload.Main" = "Upload the main page of the document"; +"SecureId.Upload.Reverse" = "Upload a photo of the reverse side of the document"; +"SecureId.Upload.Selfie" = "Upload a selfie of yourself holding the document"; +"SecureId.Upload.Title.FrontSide" = "Front Side"; +"SecureId.Upload.Title.MainPage" = "Main Page"; +"SecureId.Upload.Title.ReverseSide" = "Reverse Side"; +"SecureId.UploadAdditionalScan" = "Upload Additional Scan"; +"SecureId.UploadScan" = "Upload Scan"; +"SecureId.UploadScan.Multi" = "Upload a scan of %@ or %@"; +"SecureId.UploadScan.Single" = "Upload a scan of %@"; +"SecureId.Warning.DataLost" = "**Warning**: All data saved in your Telegram Passport will be lost."; +"SecureId.forgotPassword.NoEmail" = "Since you didn't provide a recovery email when setting up your password, your remaining options are either to remember your password or to reset your account."; +"Select.Gifts.Accept" = "Add Gifts"; +"Select.Gifts.LimitReached" = "Limit Reached"; +"Select.Gifts.Title" = "Add Gifts"; +"SelectAreaControl.Dimension.None" = "None"; +"SelectAreaControl.Dimension.Original" = "Original"; +"SelectAreaControl.Dimension.Square" = "Square"; +"SelectColor.Apply" = "Apply"; +"SelectColor.ChannelWallpaper" = "Channel Wallpaper"; +"SelectColor.ChannelWallpaper.Info" = "Set a wallpaper that will be visible to everyone reading your channel."; +"SelectColor.EmojiPack.Group" = "Group Emoji Pack"; +"SelectColor.EmojiPack.Info.Group" = "Choose an emoji pack that will be available to all members within the group."; +"SelectColor.EmojiStatus.Channel" = "Channel Emoji Status"; +"SelectColor.EmojiStatus.Group" = "Group Emoji Status"; +"SelectColor.EmojiStatus.Info.Channel" = "Choose status that will be shown next to the channel's name."; +"SelectColor.EmojiStatus.Info.Group" = "Choose status that will be shown next to the group's name."; +"SelectColor.EmojiStatus.Info.User" = "Choose status that will be shown next to your profile."; +"SelectColor.EmojiStatus.User" = "Emoji Status"; +"SelectColor.Empty" = "There’s nothing for sale right now."; +"SelectColor.Group.BlockInfo" = "Members of your group can **boost** it so that it levels up and unlocks these features."; +"SelectColor.GroupWallpaper" = "Group Wallpaper"; +"SelectColor.GroupWallpaper.Info" = "Set a wallpaper that will be visible to everyone reading your group."; +"SelectColor.Icon.Info.Channel" = "Make replies to your messages stand out by adding custom icons to them."; +"SelectColor.Icon.Info.Channel.Profile" = "Make your channel profile stand out by adding custom icons to background."; +"SelectColor.Icon.Info.Group.Profile" = "Make your group profile stand out by adding custom icons to background."; +"SelectColor.Icon.Info.User" = "Make replies to your messages stand out by adding custom icons to them."; +"SelectColor.Icon.Info.User.Profile" = "Make your profile stand out by adding custom icons to background."; +"SelectColor.Icon.RemoveIcon" = "REMOVE ICON"; +"SelectColor.Icon.Select" = "Icon"; +"SelectColor.Icon.Select.Off" = "Not Selected"; +"SelectColor.Icon.Select.Profile" = "Profile Logo"; +"SelectColor.Icon.Select.Replies" = "Replies Logo"; +"SelectColor.Icon.Title" = "ADD ICON TO REPLIES"; +"SelectColor.Icon.Title.Profile" = "ADD ICON TO PROFILE BACKGROUND"; +"SelectColor.Info.Channel" = "This color will be used for your channel's name, the links it sends, and replies to its posts."; +"SelectColor.Info.Channel.Profile" = "This color will be used for your channel profile background."; +"SelectColor.Info.User" = "This color will be used for your name, the links you send, and replies to your messages."; +"SelectColor.Info.User.Profile" = "This color will be used for your profile background."; +"SelectColor.Message1" = "Reply to your message"; +"SelectColor.Message2" = "Your name and replies to your messages will be shown in the selected color."; +"SelectColor.Message2.Preview.Text" = "Previews of links you send will also use this color."; +"SelectColor.Message2.Preview.Title" = "Link Preview"; +"SelectColor.MyGifts" = "My Gifts"; +"SelectColor.NoGifts" = "You don't have any gifts you can use as styles for your profile.\n\n[Browse gifts available for purchase >](browse)"; +"SelectColor.NoGifts.Name" = "You don't have any gifts you can use as styles for your name.\n\n[Browse gifts available for purchase >](browse)"; +"SelectColor.NoGifts.Profile" = "You don't have any gifts you can use as styles for your profile.\n\n[Browse gifts available for purchase >](browse)"; +"SelectColor.Premium" = "Subscribe to [Telegram Premium]() to choose a custom color for your name."; +"SelectColor.Preview" = "COLOR PREVIEW"; +"SelectColor.Preview.Profile" = "PROFILE PREVIEW"; +"SelectColor.ProfilePage.Title" = "PROFILE PAGE COLOR"; +"SelectColor.ResetColor" = "Reset Color"; +"SelectColor.ResetColor.Name" = "Reset Name Color"; +"SelectColor.ResetColor.Profile" = "Reset Profile Color"; +"SelectColor.Success.Appearance.Channel" = "Your channel appearance has been updated!"; +"SelectColor.Success.Appearance.Group" = "Your channel appearance has been updated!"; +"SelectColor.Success.Appearance.User" = "Your profile appearance has been updated!"; +"SelectColor.Success.Channel" = "Your channel name color has been updated!"; +"SelectColor.Success.User" = "Your name color has been updated!"; +"SelectColor.Tab.Name" = "Name"; +"SelectColor.Tab.Profile" = "Profile"; +"SelectColor.Title.Channel" = "Channel Color"; +"SelectColor.Title.User" = "Your Name Color"; +"SelectColor.UseGift.Info" = "Apply your collectible's unique look to your profile."; +"SelectColor.UseGift.Title" = "USE A GIFT"; +"SelectCountries.OK" = "Save Countries"; +"SelectCountries.Title" = "Select Countries"; +"SelectGifts.Filter.Upgradable" = "Upgradable"; +"SelectPeers.Chats" = "CHATS"; +"SelectPeers.Contacts" = "CONTACTS"; +"SelectPeers.Frequent" = "FREQUENT CONTACTS"; +"SelectPeers.LimitReached" = "Sorry, you can't select more than %@ users"; +"SelectPeers.Title.SearchMembers" = "Search Members"; +"SelectPeers.Title.SelectChannel" = "Add to Channel"; +"SelectPeers.Title.SelectChat" = "Select Chat"; +"SelectPeers.Title.SelectGroup" = "Add to Group"; +"SelectPeers.Title.SelectGroupOrChannel" = "Add to Group or Channel"; +"SelectPeers.UserTypes" = "USER TYPES"; +"SelectStory.Accept" = "Add Stories"; +"SelectStory.LimitReached" = "Limit Reached"; +"SelectStory.Title" = "Add Stories"; +"SelectivePrivacy.GiftChannels.FromChannels" = "From Channels"; +"SelectivePrivacy.SavedMusic" = "Saved Music"; +"SendAudio.SavedMusic" = "SAVED MUSIC"; +"SendAudio.SelectFromFiles" = "Select from Files"; +"SendAudio.Send" = "Send"; +"SendAudio.SharedAudio" = "SHARED AUDIO"; +"SendAudio.Title" = "Audio"; +"Separator.Clear" = "clear"; +"Separator.ShowLess" = "show less"; +"Separator.ShowMore" = "show more"; +"ServiceMessage.DesturctingPhoto" = "%@ sent you a self-destructing photo. Please view it on your mobile."; +"ServiceMessage.DesturctingPhoto.You" = "%@ sent a self-destructing photo."; +"ServiceMessage.DesturctingVideo" = "%@ sent you a self-destructing video. Please view it on your mobile device."; +"ServiceMessage.DesturctingVideo.You" = "%@ sent a self-destructing video."; +"ServiceMessage.ExpiredFile" = "file has expired"; +"ServiceMessage.ExpiredPhoto" = "Expired photo"; +"ServiceMessage.ExpiredVideo" = "Expired video"; +"ServiceMessage.ExpiredVideoMessage" = "video message has expired"; +"ServiceMessage.ExpiredVoiceMessage" = "voice message has expired"; +"SessionPreview.Accept.Calls" = "Incoming Calls"; +"SessionPreview.Accept.Secret" = "New Secret Chats"; +"SessionPreview.AcceptHeader" = "ACCEPT ON THIS DEVICE"; +"SessionPreview.App" = "Application"; +"SessionPreview.Ip" = "IP Address"; +"SessionPreview.IpDesc" = "This location estimate is based on the IP address and may not always be accurate."; +"SessionPreview.Location" = "Location"; +"SessionPreview.TerminateSession" = "Terminate Session"; +"SessionPreview.Title" = "Session"; +"Sessions.ActiveSessionsHeader" = "ACTIVE SESSIONS"; +"Sessions.CurrentSessionHeader" = "THIS DEVICE"; +"Sessions.TerminateDescription" = "Logs out all devices except for this one."; +"Sessions.TerminateOthers" = "Terminate all other sessions"; +"SettingsSearch.EmptyItem" = "Search results from Settings and the Telegram FAQ will appear here."; +"SettingsSearch.Recent" = "RECENT"; +"SettingsSearch.Recent.Clear" = "Clear"; +"SettingsSearch.Synonyms.AppLanguage" = " "; +"SettingsSearch.Synonyms.Appearance.AutoNightTheme" = " "; +"SettingsSearch.Synonyms.Appearance.ChatBackground" = "Wallpaper\nBackground"; +"SettingsSearch.Synonyms.Appearance.ChatBackground.Custom" = " "; +"SettingsSearch.Synonyms.Appearance.ChatBackground.SetColor" = " "; +"SettingsSearch.Synonyms.Appearance.ChatMode" = "bubbles"; +"SettingsSearch.Synonyms.Appearance.ColorTheme" = " "; +"SettingsSearch.Synonyms.Appearance.TextSize" = "font"; +"SettingsSearch.Synonyms.Appearance.Title" = " "; +"SettingsSearch.Synonyms.Calls.CallTab" = " "; +"SettingsSearch.Synonyms.Calls.Title" = " "; +"SettingsSearch.Synonyms.Data.AutoDownloadReset" = " "; +"SettingsSearch.Synonyms.Data.AutoDownloadUsingCellular" = " "; +"SettingsSearch.Synonyms.Data.AutoDownloadUsingWifi" = " "; +"SettingsSearch.Synonyms.Data.AutoplayGifs" = " "; +"SettingsSearch.Synonyms.Data.AutoplayVideos" = " "; +"SettingsSearch.Synonyms.Data.CallsUseLessData" = " "; +"SettingsSearch.Synonyms.Data.DownloadInBackground" = " "; +"SettingsSearch.Synonyms.Data.NetworkUsage" = " "; +"SettingsSearch.Synonyms.Data.SaveEditedPhotos" = " "; +"SettingsSearch.Synonyms.Data.SaveIncomingPhotos" = " "; +"SettingsSearch.Synonyms.Data.Storage.ClearCache" = " "; +"SettingsSearch.Synonyms.Data.Storage.KeepMedia" = " "; +"SettingsSearch.Synonyms.Data.Storage.Title" = "Cache"; +"SettingsSearch.Synonyms.Data.Title" = " "; +"SettingsSearch.Synonyms.EditProfile.AddAccount" = " "; +"SettingsSearch.Synonyms.EditProfile.Bio" = " "; +"SettingsSearch.Synonyms.EditProfile.Logout" = " "; +"SettingsSearch.Synonyms.EditProfile.PhoneNumber" = " "; +"SettingsSearch.Synonyms.EditProfile.Title" = " "; +"SettingsSearch.Synonyms.EditProfile.Username" = "nickname"; +"SettingsSearch.Synonyms.FAQ" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeCountUnreadMessages" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedChannels" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedChats" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedPublicGroups" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.ContactJoined" = " "; +"SettingsSearch.Synonyms.Notifications.DisplayNamesOnLockScreen" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsVibrate" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.ResetAllNotifications" = " "; +"SettingsSearch.Synonyms.Notifications.Title" = " "; +"SettingsSearch.Synonyms.Passport" = " "; +"SettingsSearch.Synonyms.Privacy.AuthSessions" = " "; +"SettingsSearch.Synonyms.Privacy.BlockedUsers" = " "; +"SettingsSearch.Synonyms.Privacy.Calls" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ClearPaymentsInfo" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ContactsReset" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ContactsSync" = " "; +"SettingsSearch.Synonyms.Privacy.Data.DeleteDrafts" = " "; +"SettingsSearch.Synonyms.Privacy.Data.SecretChatLinkPreview" = " "; +"SettingsSearch.Synonyms.Privacy.Data.Title" = " "; +"SettingsSearch.Synonyms.Privacy.Data.TopPeers" = " "; +"SettingsSearch.Synonyms.Privacy.DeleteAccountIfAwayFor" = " "; +"SettingsSearch.Synonyms.Privacy.Forwards" = " "; +"SettingsSearch.Synonyms.Privacy.GroupsAndChannels" = " "; +"SettingsSearch.Synonyms.Privacy.LastSeen" = " "; +"SettingsSearch.Synonyms.Privacy.Passcode" = " "; +"SettingsSearch.Synonyms.Privacy.PasscodeAndFaceId" = " "; +"SettingsSearch.Synonyms.Privacy.PasscodeAndTouchId" = " "; +"SettingsSearch.Synonyms.Privacy.ProfilePhoto" = " "; +"SettingsSearch.Synonyms.Privacy.Title" = " "; +"SettingsSearch.Synonyms.Privacy.TwoStepAuth" = "Password"; +"SettingsSearch.Synonyms.Proxy.AddProxy" = " "; +"SettingsSearch.Synonyms.Proxy.Title" = "SOCKS5\nMTProto"; +"SettingsSearch.Synonyms.Proxy.UseForCalls" = " "; +"SettingsSearch.Synonyms.SavedMessages" = " "; +"SettingsSearch.Synonyms.Stickers.ArchivedPacks" = " "; +"SettingsSearch.Synonyms.Stickers.FeaturedPacks" = " "; +"SettingsSearch.Synonyms.Stickers.Masks" = " "; +"SettingsSearch.Synonyms.Stickers.SuggestStickers" = " "; +"SettingsSearch.Synonyms.Stickers.Title" = " "; +"SettingsSearch.Synonyms.Support" = "Support"; +"SettingsSearch.Synonyms.Watch" = "Apple Watch"; +"Share.Link.Copied" = "Copied to Clipboard"; +"Share.Pay.Confirm.Text" = "You selected %1$@ who charge Stars for messages. Would you like to pay %2$@ to send %3$@?"; +"Share.Pay.Confirm.UserCount#one" = "%d user"; +"Share.Pay.Confirm.UserCount#other" = "%d users"; +"Share.Pay.Confirm.UserCount_countable" = "%d"; +"ShareCallLogs" = "Share Call Logs"; +"ShareExtension.Cancel" = "Cancel"; +"ShareExtension.Passcode.Next" = "Next"; +"ShareExtension.Passcode.Placeholder" = "passcode"; +"ShareExtension.Search" = "Search"; +"ShareExtension.Share" = "Share"; +"ShareExtension.Unauthorized.Description" = "To share via Telegram, please open the Telegram app and log in."; +"ShareExtension.Unauthorized.OK" = "OK"; +"ShareExtension.Uploading" = "Uploading %@"; +"ShareFolder.Context.Delete" = "Delete"; +"ShareFolder.Context.NameLink" = "Name Link"; +"ShareFolder.DeselectAll" = "DESELECT ALL"; +"ShareFolder.Discard.Ok" = "Discard"; +"ShareFolder.Discard.Text" = "You have changed the settings of this folder. Discard changes?"; +"ShareFolder.Done.Delete" = "Delete"; +"ShareFolder.Done.Done" = "Done"; +"ShareFolder.Header.Empty.Info" = "You can only share groups and channels in which you are allowed to create invite links."; +"ShareFolder.Header.Empty.Title" = "THESE CHATS CANNOT BE SHARED"; +"ShareFolder.Header.Info" = "Select groups and channels that you want everyone who adds the folder via invite link to join."; +"ShareFolder.Header.Title#one" = "%d CHAT SELECTED"; +"ShareFolder.Header.Title#other" = "%d CHAT SELECTED"; +"ShareFolder.Header.Title_ZeroValueHolder" = "CHATS"; +"ShareFolder.Header.Title_countable" = "%d"; +"ShareFolder.InviteLink.Title" = "INVITE LINK"; +"ShareFolder.NameLink.Placeholder" = "Link name"; +"ShareFolder.NameLink.Title" = "Name Link"; +"ShareFolder.SelectAll" = "SELECT ALL"; +"ShareFolder.Status.Allowed" = "you can invite users here"; +"ShareFolder.Status.Disallowed.Alert.Bot" = "You can't share chats with bots."; +"ShareFolder.Status.Disallowed.Alert.Channel" = "You don't have the admin rights to share invite links to this channel."; +"ShareFolder.Status.Disallowed.Alert.Group" = "You don't have the admin rights to share invite links to this group chat."; +"ShareFolder.Status.Disallowed.Alert.NoLonger" = "This chat is no longer in this folder. Please add it to the folder first."; +"ShareFolder.Status.Disallowed.Alert.PrivateChats" = "You can't share private chats."; +"ShareFolder.Status.Disallowed.Bot" = "you can't share bots"; +"ShareFolder.Status.Disallowed.NoLonger" = "this chat is no longer in the folder"; +"ShareFolder.Status.Disallowed.Others" = "you can't invite users here"; +"ShareFolder.Status.Disallowed.PrivateChats" = "you can't share private chats"; +"ShareFolder.Title" = "Share Folder"; +"ShareFolder.Title.Text#one" = "Anyone with this link can add **%1$@** folder and the %2$d chat selected below."; +"ShareFolder.Title.Text#other" = "Anyone with this link can add **%1$@** folder and the %2$d chats selected below."; +"ShareFolder.Title.Text.Empty" = "There are no chats in this folder that you can share with others"; +"ShareFolder.Title.Text_ZeroValueHolder" = "Please select chats below to share folder."; +"ShareFolder.Title.Text_countable" = "%@ %d"; +"ShareModal.Search.ForwardPlaceholder" = "Forward to..."; +"ShareModal.Search.Placeholder" = "Share to..."; +"ShareModal.Select" = "Select"; +"SharedFolder.Alert.AlreadyMember.Channel" = "You are already subscribed to this channel."; +"SharedFolder.Alert.AlreadyMember.Group" = "You are already a member of this group."; +"SharedFolder.Confirm.Delete" = "Delete Folder?"; +"SharedFolder.Confirm.Delete.Text" = "Are you sure you want to delete this folder? This will also deactivate all the invite links created to share this folder."; +"SharedFolder.CreateLink" = "Create Invite Link"; +"SharedFolder.DeselectAll" = "DESELECT ALL"; +"SharedFolder.Done.AddFolder" = "Add Folder"; +"SharedFolder.Done.DonNotJoinChats" = "Do Not Join Any Chats"; +"SharedFolder.Done.JoinChats" = "Join Chats"; +"SharedFolder.Done.RemoveFolder" = "Remove Folder"; +"SharedFolder.Done.RemoveFolderAndChats" = "Remove Folder and Chats"; +"SharedFolder.Error.SomeUserTooMany" = "One of the groups in this folder can’t be added because one of its admins has too many groups and channels."; +"SharedFolder.ListHeader.Already#one" = "%d CHAT ALREADY JOINED"; +"SharedFolder.ListHeader.Already#other" = "%d CHATS ALREADY JOINED"; +"SharedFolder.ListHeader.Already_countable" = "%d"; +"SharedFolder.ListHeader.ChatsToJoin#one" = "%d CHATS TO JOIN"; +"SharedFolder.ListHeader.ChatsToJoin#other" = "%d CHATS TO JOIN"; +"SharedFolder.ListHeader.ChatsToJoin_countable" = "%d"; +"SharedFolder.ListHeader.InviteLinks" = "INVITE LINKS"; +"SharedFolder.ListHeader.Quit#one" = "%d CHATS TO LEAVE"; +"SharedFolder.ListHeader.Quit#other" = "%d CHATS TO LEAVE"; +"SharedFolder.ListHeader.Quit_countable" = "%d"; +"SharedFolder.ListHeader.Updates#one" = "%d CHAT TO JOIN"; +"SharedFolder.ListHeader.Updates#other" = "%d CHATS TO JOIN"; +"SharedFolder.ListHeader.Updates_countable" = "%d"; +"SharedFolder.SelectAll" = "SELECT ALL"; +"SharedFolder.Status.AddChats#one" = "Do you want to add **%1$d** chat to your folder **%2$@**?"; +"SharedFolder.Status.AddChats#other" = "Do you want to add **%1$d** chats to your folder **%2$@**?"; +"SharedFolder.Status.AddChats_ZeroValueHolder" = "Select chats to add them to your folder"; +"SharedFolder.Status.AddChats_countable" = "%d %@"; +"SharedFolder.Status.AddNew" = "Do you want to add a new chat folder and join its groups and channels?"; +"SharedFolder.Status.FullyAdded" = "You have already added this folder and its chats."; +"SharedFolder.Status.Links" = "Create more links to set up different access levels for different people."; +"SharedFolder.Status.Remove" = "Do you want to leave the chats you joined when adding the folder **%1$@**?"; +"SharedFolder.Title.AddChats#one" = "Add %d chat"; +"SharedFolder.Title.AddChats#other" = "Add %d chats"; +"SharedFolder.Title.AddChats_countable" = "%d"; +"SharedFolder.Title.AddFolder" = "Add Folder"; +"SharedFolder.Title.AllChats" = "All Chats"; +"SharedFolder.Title.Personal" = "Personal"; +"SharedFolder.Title.RemoveFolder" = "Remove Folder"; +"SharedFolder.Title.ShareFolder" = "Share Folder"; +"SharedFolder.Tooltip.Added.Text#one" = "You also joined %d new chat"; +"SharedFolder.Tooltip.Added.Text#other" = "You also joined %d new chats"; +"SharedFolder.Tooltip.Added.Text_countable" = "%d"; +"SharedFolder.Tooltip.Added.Title" = "Folder %@ Added"; +"SharedFolder.Tooltip.Updated.Text#one" = "You also joined %d new chat"; +"SharedFolder.Tooltip.Updated.Text#other" = "You also joined %d new chats"; +"SharedFolder.Tooltip.Updated.Text_countable" = "%d"; +"SharedFolder.Tooltip.Updated.Title" = "Folder %@ Updated"; +"SharedMedia.ArchiveStoryCount#one" = "%d archived post"; +"SharedMedia.ArchiveStoryCount#other" = "%d archived posts"; +"SharedMedia.ArchiveStoryCount_countable" = "%d"; +"SharedMedia.BotPreviewCount#one" = "%d preview"; +"SharedMedia.BotPreviewCount#other" = "%d previews"; +"SharedMedia.BotPreviewCount_countable" = "%d"; +"SharedMedia.CommonGroupsCount#one" = "%d group in common"; +"SharedMedia.CommonGroupsCount#other" = "%d groups in common"; +"SharedMedia.CommonGroupsCount_countable" = "%d"; +"SharedMedia.FileCount#one" = "%d file"; +"SharedMedia.FileCount#other" = "%d files"; +"SharedMedia.FileCount_countable" = "%d"; +"SharedMedia.GifCount#one" = "%d GIF"; +"SharedMedia.GifCount#other" = "%d GIFs"; +"SharedMedia.GifCount_countable" = "%d"; +"SharedMedia.LinkCount#one" = "%d link"; +"SharedMedia.LinkCount#other" = "%d links"; +"SharedMedia.LinkCount_countable" = "%d"; +"SharedMedia.MediaCount#one" = "%d media"; +"SharedMedia.MediaCount#other" = "%d media"; +"SharedMedia.MediaCount_countable" = "%d"; +"SharedMedia.MusicCount#one" = "%d music file"; +"SharedMedia.MusicCount#other" = "%d music files"; +"SharedMedia.MusicCount_countable" = "%d"; +"SharedMedia.SavedCount#one" = "%d message"; +"SharedMedia.SavedCount#other" = "%d messages"; +"SharedMedia.SavedCount_countable" = "%d"; +"SharedMedia.SavedMessagesCount#one" = "%d chat"; +"SharedMedia.SavedMessagesCount#other" = "%d chats"; +"SharedMedia.SavedMessagesCount_countable" = "%d"; +"SharedMedia.SimilarBotsCount#one" = "%d similar bots"; +"SharedMedia.SimilarBotsCount#other" = "%d similar bots"; +"SharedMedia.SimilarBotsCount_countable" = "%d"; +"SharedMedia.SimilarCount#one" = "%d similar channel"; +"SharedMedia.SimilarCount#other" = "%d similar channels"; +"SharedMedia.SimilarCount_countable" = "%d"; +"SharedMedia.StarGifts#one" = "%d gift"; +"SharedMedia.StarGifts#other" = "%d gifts"; +"SharedMedia.StarGifts_countable" = "%d"; +"SharedMedia.StoryCount#one" = "%d post"; +"SharedMedia.StoryCount#other" = "%d posts"; +"SharedMedia.StoryCount_countable" = "%d"; +"SharedMedia.VoiceCount#one" = "%d voice message"; +"SharedMedia.VoiceCount#other" = "%d voice messages"; +"SharedMedia.VoiceCount_countable" = "%d"; +"ShortcutsController.Chat" = "CHAT"; +"ShortcutsController.Chat.EditLastMessage" = "Edit Last Message"; +"ShortcutsController.Chat.OpenInfo" = "Open Info"; +"ShortcutsController.Chat.RecordVoiceMessage" = "Record Voice/Video Message"; +"ShortcutsController.Chat.SearchMessages" = "Search Messages"; +"ShortcutsController.Chat.SelectMessageToReply" = "Select Message To Reply"; +"ShortcutsController.Gestures" = "GESTURES"; +"ShortcutsController.Gestures.ChatAction" = "Chat List Actions"; +"ShortcutsController.Gestures.ChatAction.Value" = "Swipe Left or Right"; +"ShortcutsController.Gestures.Navigation" = "Navigation Back"; +"ShortcutsController.Gestures.Navigations.Value" = "Swipe From Left To Right"; +"ShortcutsController.Gestures.Reply" = "Reply"; +"ShortcutsController.Gestures.Reply.Value" = "Swipe From Right To Left"; +"ShortcutsController.Gestures.Stickers" = "Sticker/Emoji/GIF Panel"; +"ShortcutsController.Gestures.Stickers.Value" = "Swipe Left or Right"; +"ShortcutsController.Markdown" = "MARKDOWN"; +"ShortcutsController.Markdown.Bold" = "Bold"; +"ShortcutsController.Markdown.Hyperlink" = "Hyperlink"; +"ShortcutsController.Markdown.Italic" = "Italic"; +"ShortcutsController.Markdown.Monospace" = "Monospace"; +"ShortcutsController.Markdown.Strikethrough" = "Strikethrough"; +"ShortcutsController.Mouse" = "MOUSE"; +"ShortcutsController.Mouse.FastReply" = "Fast Reply"; +"ShortcutsController.Mouse.FastReply.Value" = "Double Click"; +"ShortcutsController.Mouse.ScheduleMessage" = "Schedule message"; +"ShortcutsController.Mouse.ScheduleMessage.Value" = "Option click on 'Send Message'"; +"ShortcutsController.Others" = "OTHERS"; +"ShortcutsController.Others.LockByPasscode" = "Lock by Passcode"; +"ShortcutsController.Search" = "SEARCH"; +"ShortcutsController.Search.GlobalSearch" = "Global Search"; +"ShortcutsController.Search.QuickSearch" = "Quick Search"; +"ShortcutsController.Search.SwitchAccount" = "Switch Account"; +"ShortcutsController.Title" = "Shortcuts"; +"ShortcutsController.VideoChat" = "VIDEO CHAT"; +"ShortcutsController.VideoChat.ToggleCamera" = "Toggle Camera"; +"ShortcutsController.VideoChat.ToggleScreencast" = "Toggle Screen Sharing"; +"Sidebar.Avalability" = "Your stickers, GIFs and emoji will appear in this panel when you open any chat."; +"Sidebar.Hide" = "Hide Panel"; +"Sidebar.Peer.Restricted" = "Sidebar is not available in this chat"; +"SimilarChannel.Alert.Text" = "Subscribe to [Telegram Premium]() to unlock up to %d similar channels"; +"SlowMode.ForwardComment.Error" = "Slow Mode is active. You can't forward a message with a comment."; +"SlowMode.Multiple.Error" = "Slow Mode is active. You can't send more than one message at once."; +"SlowMode.PreviewSender.Comment" = "Slow Mode is active.\nYou can't add a comment."; +"SlowMode.PreviewSender.FileTooltip" = "Slow Mode is active.\nYou can't send multiple files at once."; +"SlowMode.PreviewSender.MediaTooltip" = "Slow mode is enabled.\nYou can't send multiple media at once."; +"SlowMode.TooLong.Error" = "Slow Mode is active. This text is too long to send as one message."; +"Star.Auction.Acquired.AcceptedBid" = "Accepted Bid"; +"Star.Auction.Acquired.BidAtLeast" = "Bid at least %@ to participate in this round."; +"Star.Auction.Acquired.GiftNumber" = "%1$@ #%2$@ in round %3$@"; +"Star.Auction.Acquired.ItemsBought#one" = "%d Item Bought"; +"Star.Auction.Acquired.ItemsBought#other" = "%d Items Bought"; +"Star.Auction.Acquired.ItemsBought_countable" = "%d Item Bought"; +"Star.Auction.Acquired.Recipient" = "Recipient"; +"Star.Auction.Acquired.RoundNumber" = "Round #%d"; +"Star.Auction.Acquired.TopRank" = "TOP %d"; +"Star.Auction.ActiveList.DropsLeft" = "rounds left"; +"Star.Auction.ActiveList.Left" = "left"; +"Star.Auction.ActiveList.Minimum" = "minimum"; +"Star.Auction.ActiveList.Outbid" = "Your bid of **%@** was outbid"; +"Star.Auction.ActiveList.RaiseBid" = "Raise Bid"; +"Star.Auction.ActiveList.Ranked" = "Your bid of **%@** is ranked **#%d**"; +"Star.Auction.ActiveList.RoundOf" = "Round %1$d of %2$d"; +"Star.Auction.ActiveList.UntilNextDrop" = "until next round"; +"Star.Auction.Bid.AddStars" = "Add at least %@ to increase your bid."; +"Star.Auction.Bid.BelowTop" = "If you fall below the top %d, your bid will roll over to the next round."; +"Star.Auction.Bid.Custom" = "Custom"; +"Star.Auction.Bid.CustomDescription" = "If you fall below the top %d, your bid will roll over to the next drop."; +"Star.Auction.Bid.EnterBid" = "Enter bid"; +"Star.Auction.Bid.Max" = "Max"; +"Star.Auction.Bid.PlaceCustomBid" = "Place a Custom Bid"; +"Star.Auction.Bid.PlaceEarlyBid" = "Place an Early Bid"; +"Star.Auction.Bid.Placed.Title" = "Your bid has been placed"; +"Star.Auction.Bid.PlacedTitle" = "Your bid has been placed"; +"Star.Auction.Bid.SubTitle.Bidders#one" = "Top %d bidder will win"; +"Star.Auction.Bid.SubTitle.Bidders#other" = "Top %d bidders will win"; +"Star.Auction.Bid.SubTitle.Bidders_countable" = "Top %d bidders will win"; +"Star.Auction.Bid.Top3Winners" = "TOP 3 WINNERS"; +"Star.Auction.Bid.YouAreWinning" = "YOU ARE WINNING"; +"Star.Auction.Bid.YouHaveBeenOutbid" = "YOU'VE BEEN OUTBID"; +"Star.Auction.Bid.YourBid" = "YOUR BID"; +"Star.Auction.Bid.YourBidWillBe" = "YOUR BID WILL BE"; +"Star.Auction.ChangePeer.Info" = "The current recipient of this gift is **%@**. Change to **%@**?"; +"Star.Auction.ChangePeer.Ok" = "Change"; +"Star.Auction.ChangePeer.Title" = "Change Recipient"; +"Star.Auction.ContextMenu.About" = "Learn more"; +"Star.Auction.Info.BidCarryover" = "Bid Carryover"; +"Star.Auction.Info.BidCarryoverDescription" = "If your bid leaves the top %d, it will automatically join the next drop."; +"Star.Auction.Info.MissedBidders" = "Missed Bidders"; +"Star.Auction.Info.MissedBiddersDescription" = "If your bid doesn't win after the final round, your Stars will be fully refunded."; +"Star.Auction.Info.Subtitle" = "Join the battle for exclusive gifts."; +"Star.Auction.Info.Title" = "Auction"; +"Star.Auction.Info.TopBidders" = "Top %d Bidders"; +"Star.Auction.Info.TopBiddersDescription" = "%1$d gifts are dropped at varying intervals to the top %2$d bidders by bid amount."; +"Star.Auction.ItemsBought#one" = "%d item bought"; +"Star.Auction.ItemsBought#other" = "%d items bought"; +"Star.Auction.ItemsBought_countable" = "%d items bought"; +"Star.Auction.List.ActiveAuctions#one" = "%d Active Auction"; +"Star.Auction.List.ActiveAuctions#other" = "%d Active Auctions"; +"Star.Auction.List.ActiveAuctions_countable" = "%d Active Auction"; +"Star.Auction.Option.Info.Outbid" = "You've been outbid"; +"Star.Auction.Option.Info.OutbidCountable#one" = "You've been outbid in %d of them"; +"Star.Auction.Option.Info.OutbidCountable#other" = "You've been outbid in %d of them"; +"Star.Auction.Option.Info.OutbidCountable_countable" = "You've been outbid in %d of them"; +"Star.Auction.Option.Info.YouAreWinning#one" = "You're winning (%dst place)."; +"Star.Auction.Option.Info.YouAreWinning#other" = "You're winning (%dth place)."; +"Star.Auction.Option.Info.YouAreWinningInAll" = "You're winning in all of them."; +"Star.Auction.Option.Info.YouAreWinning_countable" = "You're winning (%dth place)."; +"Star.Auction.Option.Info.YouHaveBeenOutbidInAll" = "You've been outbid in all of them."; +"Star.Auction.Place" = "Place %@"; +"Star.Auction.PlaceBid" = "Place a bid"; +"Star.Auction.PlaceEarlyBid" = "Place an Early Bid"; +"Star.Auction.Preview.Auction" = "Auction"; +"Star.Auction.Preview.AuctionStartsIn" = "Auction starts in %@"; +"Star.Auction.Preview.Availability" = "Availability"; +"Star.Auction.Preview.AveragePrice" = "Average Price"; +"Star.Auction.Preview.Bidders#one" = "%d bidder"; +"Star.Auction.Preview.Bidders#other" = "%d bidders"; +"Star.Auction.Preview.Bidders_countable" = "%d bidders"; +"Star.Auction.Preview.CurrentRound" = "Current Round"; +"Star.Auction.Preview.DurationEach" = "%@ each"; +"Star.Auction.Preview.Ended" = "Auction ended"; +"Star.Auction.Preview.Ends" = "Ends"; +"Star.Auction.Preview.GiftsDroppedInfo#one" = "%1$d gift is dropped at varying intervals to the %2$@ by bid amount."; +"Star.Auction.Preview.GiftsDroppedInfo#other" = "%1$d gifts are dropped at varying intervals to the %2$@ by bid amount."; +"Star.Auction.Preview.GiftsDroppedInfo_countable" = "%1$d gifts are dropped at varying intervals to the %2$@ by bid amount."; +"Star.Auction.Preview.Join" = "Join Auction"; +"Star.Auction.Preview.LearnMore" = "Top %1$@ will get %2$@ gifts this round.\n[Learn more >]()"; +"Star.Auction.Preview.LearnMoreAboutGifts" = "Learn more about Telegram Gifts >"; +"Star.Auction.Preview.Left" = "%@ left"; +"Star.Auction.Preview.Of" = "%1$@ of %2$@"; +"Star.Auction.Preview.PlaceEarlyBid" = "Place an Early Bid"; +"Star.Auction.Preview.Quantity" = "Quantity"; +"Star.Auction.Preview.RoundSingle" = "Round %@"; +"Star.Auction.Preview.Rounds" = "Rounds"; +"Star.Auction.Preview.RoundsRange" = "Rounds %1$@-%2$@"; +"Star.Auction.Preview.Started" = "Started"; +"Star.Auction.Preview.Starts" = "Starts"; +"Star.Auction.Preview.TopBidders#one" = "top %d bidder"; +"Star.Auction.Preview.TopBidders#other" = "top %d bidders"; +"Star.Auction.Preview.TopBidders_countable" = "top %d bidders"; +"Star.Auction.Preview.ViewVariants" = "View %1$@ variants >"; +"Star.Auction.PreviewSend.LearnMore" = "Learn more >"; +"Star.Auction.SendPreview.Left#one" = "%d left"; +"Star.Auction.SendPreview.Left#other" = "%d left"; +"Star.Auction.SendPreview.Left_countable" = "%d left"; +"Star.Auction.SendPreview.Sold#one" = "%d sold"; +"Star.Auction.SendPreview.Sold#other" = "%d sold"; +"Star.Auction.SendPreview.Sold_countable" = "%d sold"; +"Star.Buy.Custom_subs" = "Buy stars for your expiring subscriptions."; +"Star.BuyScreen.Gift" = "With Stars, **%@** will be able to unlock content and services on Telegram. [See Examples >](examples)"; +"Star.BuyScreen.MySubs" = "MY SUBSCRIPTIONS"; +"Star.BuyScreen.ProlongSubs" = "Buy Stars to keep your subscription to **%@**."; +"Star.BuyScreen.PurschaseSub" = "Buy Stars to subscribe to **%@**."; +"Star.Gift.Limited" = "limited"; +"Star.Gift.Preview.AvailableForResale" = "Available for Resale"; +"Star.Gift.Preview.NextDropIn" = "next round in %@"; +"Star.Gift.Preview.PlaceBid" = "Place a Bid"; +"Star.List.Buy.Success#one" = "%d Star Added to Your Balance"; +"Star.List.Buy.Success#other" = "%d Stars Added to Your Balance"; +"Star.List.Buy.Success_countable" = "%d"; +"Star.List.BuyAndUse" = "Buy Stars and use them in **%@** and other mini apps."; +"Star.List.BuyAndUser.Nobot" = "Buy Stars to unlock content and services in mini apps on Telegram."; +"Star.List.BuyMoreStars" = "Buy More Stars"; +"Star.List.GetStars" = "Get Stars"; +"Star.List.HowMany" = "Choose how many Stars you'd like to buy."; +"Star.List.Item.Count#one" = "%d Star"; +"Star.List.Item.Count#other" = "%d Stars"; +"Star.List.Item.Count_countable" = "%d"; +"Star.List.Item.ShowMore" = "Show More Options"; +"Star.List.MyBalance" = "Balance\n%1$@**%2$@**"; +"Star.List.Refund" = "Refund"; +"Star.List.StarsNeeded#one" = "%d Star Needed"; +"Star.List.StarsNeeded#other" = "%d Stars Needed"; +"Star.List.StarsNeeded_countable" = "%d"; +"Star.List.TelegramStars" = "Telegram Stars"; +"Star.List.TopUp" = "Top Up"; +"Star.List.Tos" = "By proceeding and purchasing Stars, you agree with the [Terms and Conditions](https://telegram.org/tos/stars)."; +"Star.List.Transaction.Ads" = "Ads Platform"; +"Star.List.Transaction.AppStore" = "App Store"; +"Star.List.Transaction.Fragment" = "Fragment"; +"Star.List.Transaction.PlayMarket" = "Play Market"; +"Star.List.Transaction.PremiumBot" = "Premium Bot"; +"Star.List.Transaction.Unknown" = "Unknown"; +"Star.List.Transactions.All" = "All Transactions"; +"Star.List.Transactions.Empty.All" = "No Transactions"; +"Star.List.Transactions.Empty.Incoming" = "No Incoming Transactions"; +"Star.List.Transactions.Empty.Outgoing" = "No Outgoing Transactions"; +"Star.List.Transactions.Header" = "TRANSACTION HISTORY"; +"Star.List.Transactions.Incoming" = "Incoming"; +"Star.List.Transactions.Outgoing" = "Outgoing"; +"Star.List.Transactions.ShowMore" = "Show More"; +"Star.List.Withdraw" = "Stats"; +"Star.List.YourBalance" = "your balance"; +"Star.Marketplace.ResaleAvailable#one" = "%d listing"; +"Star.Marketplace.ResaleAvailable#other" = "%d listings"; +"Star.Marketplace.ResaleAvailable_countable" = "%d for resale"; +"Star.Nft.BuyFor" = "Buy for %@"; +"Star.Nft.Gift.Buy.Confirm" = "Are you sure you want to buy **%1$@** for **%2$@** and gift it to **%3$@**?"; +"Star.Nft.Gift.Buy.Confirm.Self" = "Are you sure you want to buy **%1$@** for **%2$@**?"; +"Star.Nft.Gift.Buy.Success" = "You successfully bought **%1$@** for **%2$@** and gifted it to **%3$@**"; +"Star.Nft.Gift.Buy.Success.Self" = "You successfully bought **%1$@** for **%2$@**"; +"Star.Nft.Gift.Confirm.Ok" = "Buy for %@"; +"Star.Nft.Gift.Confirm.Title" = "Buy Gift"; +"Star.Nft.OnSaleFor" = "On sale for %@"; +"Star.Nft.Price.Edit" = "edit"; +"Star.Nft.Price.Sale" = "Sale"; +"Star.Nft.Sell" = "Sell"; +"Star.Nft.Sell.Button.Sell" = "Sell"; +"Star.Nft.Sell.Button.Update" = "Update"; +"Star.Nft.Sell.Floor" = "The lowest price for gifts from this collection is currently **%@**"; +"Star.Nft.Sell.Header" = "PRICE IN STARS"; +"Star.Nft.Sell.Info" = "You will receive **%@**."; +"Star.Nft.Sell.Title" = "Sell Gift"; +"Star.Nft.Share" = "Share"; +"Star.Nft.TakeOff" = "Take off"; +"Star.Nft.Tooltip.Worn" = "You put on %@"; +"Star.Nft.Transfer" = "Transfer"; +"Star.Nft.Unlist" = "Unlist"; +"Star.Nft.Wear" = "Wear"; +"Star.Nft.Wear.Info" = "and get these benefits:"; +"Star.Nft.Wear.Title" = "Wear %@"; +"Star.Purchase.Balance" = "Balance\n%@"; +"Star.Purchase.Confirm" = "Confirm Your Purchase"; +"Star.Purchase.PaidMedia.Success" = "You unlocked media for %3$@."; +"Star.Purchase.PaidMedia.Text" = "Do you want to unlock %1$@ in **%2$@** **%3$@**?"; +"Star.Purchase.Pay" = "Confirm And Pay %@"; +"Star.Purchase.Success" = "You acquired \"%1$@\" in %2$@ %3$@."; +"Star.Purchase.Text" = "Do you want to buy **\"%1$@\"** in **%2$@** **%3$@**?"; +"Star.Purchase.Text.In#one" = "for %d Star"; +"Star.Purchase.Text.In#other" = "for %d Stars"; +"Star.Purchase.Text.In_countable" = "%d"; +"Star.Sell.Ton.Description" = "If the buyer pays you in Grams there's no risk of refunds, unlike Star payments."; +"Star.Sell.Ton.OnlyAccept" = "Only Accept Grams"; +"Star.Sell.Ton.Tooltip" = "Pay with Grams to skip the 21-day wait\nbefore transferring the gift again."; +"Star.SubScreen.Action.Bot" = "Open Bot"; +"Star.SubScreen.Action.Join" = "Join Channel"; +"Star.SubScreen.Action.Open" = "Open Channel"; +"Star.SubScreen.Action.Renew" = "Renew Subscription"; +"Star.SubScreen.Cancelled.Alert" = "Subscription canceled"; +"Star.SubScreen.Price" = "%@ / month"; +"Star.SubScreen.Row.Renew" = "Renews"; +"Star.SubScreen.Row.Sub" = "Subscription"; +"Star.SubScreen.Row.SubBot" = "Bot"; +"Star.SubScreen.Row.Subd" = "Subscribed"; +"Star.SubScreen.Status.Active" = "If you cancel now, you can still access your subscription until %@."; +"Star.SubScreen.Status.Active.Cancel" = "Cancel Subscription"; +"Star.SubScreen.Status.Cancelled" = "You have canceled your subscription."; +"Star.SubScreen.Status.Expired" = "Your subscription has expired."; +"Star.SubScreen.Title" = "Subscription"; +"Star.Subscription.Status.Active" = "per month"; +"Star.Subscription.Status.Active.Info" = "renews on %@"; +"Star.Subscription.Status.Cancelled" = "canceled"; +"Star.Subscription.Status.Cancelled.Info" = "expires on %@"; +"Star.Subscription.Status.Expired" = "expired"; +"Star.Subscription.Status.Expired.Info" = "expired on %@"; +"Star.Transaciton.ReferredUser" = "Referred User"; +"Star.Transaction.Availability" = "Availability"; +"Star.Transaction.Availability.Of" = "%1$d of %2$@"; +"Star.Transaction.Availability.OfLeft" = "%1$@ of %2$@ left"; +"Star.Transaction.Availability.OfText" = "%1$@ of %2$@"; +"Star.Transaction.BotTos" = "By subscribing you agree to the [Terms of Service](https://telegram.org)"; +"Star.Transaction.BroadcastMessages#one" = "%d Message"; +"Star.Transaction.BroadcastMessages#other" = "%d Messages"; +"Star.Transaction.BroadcastMessages_countable" = "%d"; +"Star.Transaction.Button.GiftUpgrade" = "Gift an Upgrade"; +"Star.Transaction.Button.Upgrade" = "Upgrade"; +"Star.Transaction.Commission" = "Commission"; +"Star.Transaction.ConvertAlert.Header" = "Convert Gift to Stars"; +"Star.Transaction.ConvertAlert.Info" = "Do you want to convert this gift from **%1$@** to **%2$@**?\n\nThis action cannot be undone."; +"Star.Transaction.ConvertAlert.Info.Until" = "Do you want to convert this gift from **%1$@** to **%2$@**?\n\nConversion option available until %3$@.\n\nThis action cannot be undone."; +"Star.Transaction.ConvertAlert.OK" = "Convert"; +"Star.Transaction.ConvertAlert.TooLate" = "Sorry, you can't convert this gift to stars anymore."; +"Star.Transaction.Copied" = "Transaction ID copied to clipboard."; +"Star.Transaction.Date" = "Date"; +"Star.Transaction.FirstSale" = "First Sale"; +"Star.Transaction.From" = "From"; +"Star.Transaction.Gift" = "Gift"; +"Star.Transaction.Gift.Collectible" = "Collectible %@"; +"Star.Transaction.GiftPurchase" = "Gift Purchase"; +"Star.Transaction.GiftSale" = "Gift Sale"; +"Star.Transaction.GiftUpgrade.Issued" = "%1$@/%2$@ issued"; +"Star.Transaction.Id" = "Transaction ID"; +"Star.Transaction.LastSale" = "Last Sale"; +"Star.Transaction.MessageFee#one" = "Message Fee"; +"Star.Transaction.MessageFee#other" = "Fee for %d Messages"; +"Star.Transaction.MessageFee.Info" = "You receive **%@** of the price that you charge for each incoming message. [Change Fee >](changeFee)"; +"Star.Transaction.MessageFee_countable" = "%d"; +"Star.Transaction.MessageId" = "Media"; +"Star.Transaction.MiniApp" = "Mini App"; +"Star.Transaction.Owner" = "Owner"; +"Star.Transaction.ReactionId" = "Message"; +"Star.Transaction.Reason" = "Reason"; +"Star.Transaction.Reason.Giveaway" = "Giveaway"; +"Star.Transaction.ReasonAffiliate" = "[Affiliate Program](affiliate)"; +"Star.Transaction.Released.By" = "released by @%@"; +"Star.Transaction.SaleFor#one" = "sell for %d star"; +"Star.Transaction.SaleFor#other" = "sell for %d stars"; +"Star.Transaction.SaleFor_countable" = "%d"; +"Star.Transaction.SendGift" = "send a gift"; +"Star.Transaction.StarGift.Anonymous" = "Only you can see the sender's name and message."; +"Star.Transaction.StarGift.Channel.DisplayOnMyPage" = "Display in Gifts"; +"Star.Transaction.StarGift.Channel.HideFromMyPage" = "Hide from Gifts"; +"Star.Transaction.StarGift.ConvertTo" = "[Convert to %@](convert)"; +"Star.Transaction.StarGift.ConvertToStars.Alert" = "You Successfully converted Gift to Stars"; +"Star.Transaction.StarGift.DisplayOnMyPage" = "Add to My Profile"; +"Star.Transaction.StarGift.DisplayOnPage.Alert" = "This gift is now visible on your profile."; +"Star.Transaction.StarGift.HidInProfile" = "The gift is not visible on your profile. [Show >](show)"; +"Star.Transaction.StarGift.HideFromMyPage" = "Hide from my profile"; +"Star.Transaction.StarGift.HideFromMyPage.Alert" = "The gift is no longer displayed in your profile."; +"Star.Transaction.StarGift.SeeInProfile" = "The gift is visible on your profile. [View >](view)"; +"Star.Transaction.StarGift.ShownInProfile" = "The gift is visible on your profile. [Hide >](hide)"; +"Star.Transaction.To" = "To"; +"Star.Transaction.Ton.Description" = "Use Grams to buy gifts and suggest posts to channels."; +"Star.Transaction.Tos" = "Review the [Terms of Services](https://telegram.org/tos/stars) for Stars."; +"Star.Transaction.TransactionUrl" = "Transaction URL"; +"Star.Transaction.UnknwonUser" = "[Unknown User](https://fragment.com/stars)"; +"Star.Transaction.Value" = "Value"; +"Star.Transaction.Visibility" = "Visibility"; +"Star.Transaction.Visibility.Hide" = "hide"; +"Star.Transaction.Visibility.Info" = "Visible on your profile"; +"StarGift.ActionLocked.URL" = "https://fragment.com/my/gifts"; +"StarGift.ActionLockedOpen" = "Open Fragment"; +"StarGift.ActionLockedText" = "Transfer this gift to your Telegram account on Fragment to unlock this action."; +"StarGift.ActionLockedTitle" = "Action Locked"; +"StarGift.Auction.StartsIn" = "starts in %@"; +"StarGift.Badge.Auction" = "auction"; +"StarGift.Badge.Soon" = "soon"; +"StarGift.Craft.AnotherGift" = "Craft Another Gift"; +"StarGift.Craft.BackdropChanceHint" = "%1$@ chance the crafted gift will have **%2$@** backdrop."; +"StarGift.Craft.CraftWithName" = "Craft %1$@"; +"StarGift.Craft.Crafted" = " (crafted)"; +"StarGift.Craft.Description" = "Add up to 4 gifts to craft new\n%1$@**%2$@**.\n\nIf crafting fails, these gifts will be lost."; +"StarGift.Craft.Failed" = "Crafting Failed"; +"StarGift.Craft.FailedDescription" = "This crafting attempt was unsuccessful."; +"StarGift.Craft.FeatureAffectResult" = "Affect the Result"; +"StarGift.Craft.FeatureAffectResultDesc" = "Use gifts with the same attribute to boost its chance."; +"StarGift.Craft.FeatureMaximizeChances" = "Maximize Chances"; +"StarGift.Craft.FeatureMaximizeChancesDesc" = "Combine more gifts to increase your odds of success."; +"StarGift.Craft.FeatureRareModels" = "Get Rare Models"; +"StarGift.Craft.FeatureRareModelsDesc" = "Select up to 4 gifts to craft a new exclusive model."; +"StarGift.Craft.GiftLost" = "1 gift was lost"; +"StarGift.Craft.GiftTooEarly" = "Crafting from this gift will be available in %1$@."; +"StarGift.Craft.GiftsLost#other" = "%1$d gifts were lost"; +"StarGift.Craft.HeaderInfo" = "Turn your gifts into rare, epic and legendary versions."; +"StarGift.Craft.HeaderTitle" = "Gift Crafting"; +"StarGift.Craft.MenuItem" = "Craft Gift"; +"StarGift.Craft.PatternChanceHint" = "%1$@ chance the crafted gift will have **%2$@** symbol."; +"StarGift.Craft.StartCrafting" = "Start Crafting"; +"StarGift.Craft.SuccessChance" = "%1$d%% Success Chance"; +"StarGift.Craft.Title" = "Craft Gift"; +"StarGift.Craft.TooEarly" = "Crafting from selected gifts will be available in %1$@."; +"StarGift.Craft.Unavailable" = "One of the gifts is not suitable for crafting."; +"StarGift.Craft.ViewAllModels" = "%1$@View all craftable models"; +"StarGift.LearnAbout.Description" = "Gifts are collectible items you can trade or showcase on your profile."; +"StarGift.LearnAbout.Title" = "Telegram Gifts"; +"StarGift.Marketplace.NoOtherGifts" = "You don't have other gifts from this collection."; +"StarGift.Marketplace.ShowStarsOnly" = "Show listings for Stars only"; +"StarGift.Marketplace.SuitableGiftsOnSale" = "%1$d SUITABLE GIFTS ON SALE"; +"StarGift.Marketplace.YourGifts" = "YOUR GIFTS"; +"StarGift.ModelBuilder.Backdrops" = "Backdrops"; +"StarGift.ModelBuilder.CollectionFeatures#one" = "This collection features %d unique model."; +"StarGift.ModelBuilder.CollectionFeatures#other" = "This collection features %d unique models."; +"StarGift.ModelBuilder.CollectionFeatures_countable" = "This collection features %d unique models."; +"StarGift.ModelBuilder.Craftable" = "craftable"; +"StarGift.ModelBuilder.Models" = "Models"; +"StarGift.ModelBuilder.Primary" = "primary"; +"StarGift.ModelBuilder.RandomizeTraits" = "Randomize Traits"; +"StarGift.ModelBuilder.SelectedTraits" = "Selected Traits"; +"StarGift.ModelBuilder.SubtextBackdrop" = "backdrop"; +"StarGift.ModelBuilder.SubtextModel" = "model"; +"StarGift.ModelBuilder.SubtextSymbol" = "symbol"; +"StarGift.ModelBuilder.Symbols" = "Symbols"; +"StarGift.ModelBuilder.ViewOtherModels" = "View %1$@ models ›"; +"StarGift.Nft.CantChooseFirst" = "Can't Choose First"; +"StarGift.Nft.CantChooseFirstInfo" = "This gift can't be used as the first one in crafting because it has been recorded on the blockchain."; +"StarGift.Nft.ConfirmSale" = "Confirm Sale"; +"StarGift.Nft.OfferToBuy" = "Offer to Buy"; +"StarGift.Offer.ConfirmInfo" = "Do you want to offer **%1$@** to %2$@ for **%3$@**?"; +"StarGift.Offer.ConfirmTitle" = "Confirm Offer"; +"StarGift.Offer.CurrencyStars" = "Stars"; +"StarGift.Offer.CurrencyTon" = "Grams"; +"StarGift.Offer.Duration" = "Offer Duration"; +"StarGift.Offer.DurationDescription" = "Choose how long **%1$@** has to accept your offer. When it expires, your %2$@ will be automatically refunded."; +"StarGift.Offer.OfferLabel" = "Offer"; +"StarGift.Offer.PayButton" = "Pay %@"; +"StarGift.Offer.Title" = "Offer to Buy"; +"StarGift.OnBlockchain.Info" = "This gift is on TON blockchain. [View >]()"; +"StarGift.Option.Join" = "Join"; +"StarGift.Preview.Title" = "Send a Gift"; +"StarGift.Preview.Title.Buy" = "Buy a Gift"; +"StarGift.PriceDecrease.Hint" = "Users who upgrade their gifts first get collectibles with lower numbers."; +"StarGift.PriceDecrease.Preview" = "See how this price will decrease >"; +"StarGift.PriceDecreaseTimer" = "Price decreases in %@"; +"StarGift.RemoveDescriptionConfirm" = "Remove for %@"; +"StarGift.RemoveDescriptionInfo" = "Do you want to permanently remove this description from your gift?"; +"StarGift.RemoveDescriptionTitle" = "Remove Description"; +"StarGift.SelfAuction.LearnMore" = "Learn more about wearing Telegram Gifts >"; +"StarGift.SelfAuction.Limited" = "limited"; +"StarGift.SelfAuction.Upgraded" = "upgraded"; +"StarGift.Sell.Alert.OnlyTon" = "The seller only accepts Grams as payment."; +"StarGift.Sell.Alert.PayInStars" = "Pay in Stars"; +"StarGift.Sell.Alert.PayInTon" = "Pay in Grams"; +"StarGift.Sell.Alert.Title.ConfirmPayment" = "Confirm Payment"; +"StarGift.Unique.Make" = "Make Unique"; +"StarGift.Unique.UpgradeNext" = "Upgrade Next Gift"; +"StarGift.UpgradeCost.Title" = "Upgrade Cost"; +"StarGift.Value.Description.Average" = "The average sale price of **%@** gifts on Telegram and Fragment over the past month."; +"StarGift.Value.Description.LastSale" = "This is the price at which **%@** was last sold on %@."; +"StarGift.Value.LetUserMakeUnique" = "Let %@ turn this gift into a unique collectible."; +"StarGift.Value.Listing.Fragment" = "%d %@ for sale on Fragment >"; +"StarGift.Value.Listing.Telegram" = "%d %@ for sale on Telegram >"; +"StarGift.Value.PayForUpgrade" = "Pay %@ for Upgrade"; +"StarGift.Value.Row.Badge" = "learn more"; +"StarGift.Value.Row.Label" = "Value"; +"StarGift.Value.Source.Fragment" = "Fragment"; +"StarGift.Value.Source.Telegram" = "Telegram"; +"StarGift.ViewAllVariants" = "%1$@ View all variants >"; +"StarList.Ton" = "Grams"; +"StarList.Ton.Withdraw.Info" = "Collect your Grams using Fragment. [Learn More >](telegram.org)"; +"StarList.TonInfo" = "Use Grams to buy gifts and suggest posts to channels."; +"StarTransaction.GiftRefundedInfo" = "This gift was downgraded because a request to refund the payment related to this gift was made, and the money was returned."; +"Stars.Balance.BuyAds" = "Buy Ads"; +"Stars.ExampleApps.Text" = "Use Stars to unlock content and services on Telegram. [See Examples >](apps)"; +"Stars.Gift.ServiceInfo" = "Use Stars to unlock content and services on Telegram."; +"Stars.Gift.Title" = "Gift Stars"; +"Stars.GiftPreview.Buy" = "Buy a Gift for %@"; +"Stars.GiftPreview.BuyMulti#one" = "Buy %d Gift for %@"; +"Stars.GiftPreview.BuyMulti#other" = "Buy %d Gifts for %@"; +"Stars.GiftPreview.BuyMulti_countable" = "%d %@"; +"Stars.GiftPreview.Channel.Display" = "Add this gift to your channel's profile or convert it to %@"; +"Stars.GiftPreview.Customize" = "CUSTOMIZE YOUR GIFT"; +"Stars.GiftPreview.Display" = "Add this gift to your profile or convert it to %@"; +"Stars.GiftPreview.HideMyName" = "Hide My Name"; +"Stars.GiftPreview.HideMyName.Info" = "Hide my name and message from visitors to %1$@'s profile. %2$@ will still see your name and message."; +"Stars.GiftPreview.MessagePlaceholder" = "Enter Message"; +"Stars.GiftPreview.Send" = "Send a Gift for %@"; +"Stars.GiftPreview.SendMulti#one" = "Send %d Gift for %@"; +"Stars.GiftPreview.SendMulti#other" = "Send %d Gifts for %@"; +"Stars.GiftPreview.SendMulti_countable" = "%d %@"; +"Stars.GiftToFriends" = "%@ Gift Stars to Friends"; +"Stars.Intro.Transaction.TelegramBotApi.Subtitle" = "Bot API"; +"Stars.Intro.Transaction.TelegramBotApi.Title" = "Paid Broadcast"; +"Stars.Promo.Header" = "What are Stars?"; +"Stars.Promo.HeaderInfo" = "Telegram Stars allow users to:"; +"Stars.Promo.Option1.Info" = "Give your friends gifts that can be added to their profiles or converted to Stars."; +"Stars.Promo.Option1.Title" = "Send Gifts to Friends"; +"Stars.Promo.Option2.Info" = "Buy additional content and services in Telegram mini apps. [See Examples >](examples)"; +"Stars.Promo.Option2.Title" = "Use Stars in mini apps"; +"Stars.Promo.Option3.Info" = "Get access to paid content and services in Telegram channels."; +"Stars.Promo.Option3.Title" = "Unlock content in channels"; +"Stars.Promo.Option4.Info" = "Support your favorite channels by sending Star reactions to their posts."; +"Stars.Promo.Option4.Title" = "Send Star reactions"; +"Stars.Promo.Option5.Info" = "Get new subscribers and boosts for your channels by giving away Stars."; +"Stars.Promo.Option5.Title" = "Create giveaways in channels"; +"Stars.Purchase.BotSubscribe" = "Subscribe to the Bot"; +"Stars.Purchase.BotSubscribe.Accept.Month" = "Subscribe for %@ / month"; +"Stars.Purchase.BotSubscribe.Accept.SixMonth" = "Subscribe for %@ / six month"; +"Stars.Purchase.BotSubscribe.Accept.Year" = "Subscribe for %@ / year"; +"Stars.Purchase.BotSubscribe.Info#one" = "Do you want to subscribe to **%1$@** in **%2$@** for **%3$d** Stars per month?"; +"Stars.Purchase.BotSubscribe.Info#other" = "Do you want to subscribe to **%1$@** in **%2$@** for **%3$d** Stars per month?"; +"Stars.Purchase.BotSubscribe.Info_countable" = "%1$@ %2$@ %3$d"; +"Stars.Purchase.BotSubscribe.Success" = "You have successfully subscribed to **%@** in **%@**"; +"Stars.Purchase.Subscribe" = "Subscribe to the Channel"; +"Stars.Purchase.Subscribe.Action" = "Subscribe"; +"Stars.Purchase.Subscribe.Info#one" = "Do you want to subscribe for **%@** for **%d** Star per month?"; +"Stars.Purchase.Subscribe.Info#other" = "Do you want to subscribe for **%@** for **%d** Stars per month?"; +"Stars.Purchase.Subscribe.Info_countable" = "%@ %d"; +"Stars.Purchase.Subscribe.Success" = "You have successfully subscribed to **%@**"; +"Stars.Purchase.UpgradeStarGiftInfo" = "Buy Stars to upgrade your gift into a unique collectible."; +"Stars.ReactScreen.Anonymous" = "Anonymous"; +"Stars.ReactScreen.Footer" = "By sending Stars you agree to the [Terms of Service](https://telegram.org/tos/stars)."; +"Stars.ReactScreen.Info" = "Choose how many stars you want to send to **%@** to support this post."; +"Stars.ReactScreen.Send#one" = "Send %@%d"; +"Stars.ReactScreen.Send#other" = "Send %@%d"; +"Stars.ReactScreen.Send_countable" = "%@%d"; +"Stars.ReactScreen.SentValue#one" = "You sent %d star to support this post."; +"Stars.ReactScreen.SentValue#other" = "You sent %d stars to support this post."; +"Stars.ReactScreen.SentValue_countable" = "%d"; +"Stars.ReactScreen.ShowMeInTop" = "Show Me in Top Senders"; +"Stars.ReactScreen.Title" = "React with Stars"; +"Stars.ReactScreen.TopSenders" = "Top Senders"; +"Stars.StarGift.Text.Converted.Incoming" = "You converted this gift to %1$@."; +"Stars.StarGift.Text.Converted.Outgoing" = "%1$@ converted this gift to %2$@."; +"Stars.StarGift.Text.Incoming" = "You can keep this gift on your Profile or convert it to %1$@."; +"Stars.StarGift.Text.Incoming.Channel" = "Display this gift in channel's Gifts or convert it to %@."; +"Stars.StarGift.Text.KeepOrHide" = "You can keep this gift on your profile or hide it."; +"Stars.StarGift.Text.KeptOnPage.Incoming" = "You added this gift to your profile."; +"Stars.StarGift.Text.KeptOnPage.Incoming.Gifts" = "You kept this gift in channel Gifts."; +"Stars.StarGift.Text.KeptOnPage.Outgoing" = "%1$@ added this gift to their profile."; +"Stars.StarGift.Text.Link" = "[More about Stars >](stars)"; +"Stars.StarGift.Text.Outgoing" = "%1$@ can add this gift to their profile or convert it to %2$@."; +"Stars.Subscription.Period.Daily" = "Daily subscription fee"; +"Stars.Subscription.Period.Monthly" = "Monthly subscription fee"; +"Stars.Subscription.Period.Unknown" = "Subscription fee"; +"Stars.Subscription.Period.Weekly" = "Weekly subscription fee"; +"Stars.Transaction.Commission" = "%@ commission"; +"Stars.Transaction.Gift" = "Gift"; +"Stars.Transaction.GiftAuctionBid" = "Gift Auction Bid"; +"Stars.Transaction.GiftSimple" = "Gift"; +"Stars.Transaction.MediaPurchase" = "Media Purchase"; +"Stars.Transaction.PaidReaction" = "Paid Reaction"; +"Stars.Transaction.PremiumFor" = "Premium for %d Months"; +"Stars.Transaction.ReceivedGift" = "Received Gift"; +"Stars.Transaction.ReceivedPrize" = "Received Prize"; +"Stars.Transaction.SentGift" = "Sent Gift"; +"Stars.Transaction.TopUp" = "Stars Top-Up"; +"Stars.Transfer.MediaAnd" = "%1$@ and %2$@"; +"Stars.Transfer.Photos#one" = "%d photo"; +"Stars.Transfer.Photos#other" = "%d photos"; +"Stars.Transfer.Photos_countable" = "%d"; +"Stars.Transfer.SinglePhoto" = "photo"; +"Stars.Transfer.SingleVideo" = "video"; +"Stars.Transfer.Videos#one" = "%d video"; +"Stars.Transfer.Videos#other" = "%d videos"; +"Stars.Transfer.Videos_countable" = "%d"; +"Stars.YouHaveEnough" = "You have enough stars. [Buy More]()"; +"StarsGift.Preview.Limited.Text" = "This gift is limited and available only for **Telegram Premium** Users. You can send it **%d** more times."; +"Stats.Boosts" = "Boosts"; +"Stats.Boosts.Action.Boost" = "Boost"; +"Stats.Boosts.Action.Giveaway" = "Giveaway"; +"Stats.Boosts.Action.Info" = "Features"; +"Stats.Boosts.Boosters#one" = "%d BOOSTER"; +"Stats.Boosts.Boosters#other" = "%d BOOSTERS"; +"Stats.Boosts.BoostersInfo" = "Your channel is currently boosted by these users."; +"Stats.Boosts.BoostersInfo.Group" = "Your group is currently boosted by these users."; +"Stats.Boosts.BoostersNone" = "BOOSTERS"; +"Stats.Boosts.Boosters_ZeroValueHolder" = "NO BOOSTERS"; +"Stats.Boosts.Boosters_countable" = "%d"; +"Stats.Boosts.BoostsToLevelUp" = "Boosts to Level Up"; +"Stats.Boosts.ExistingBoosts" = "Active Boosts"; +"Stats.Boosts.ExpiresOn" = "Boost expires on %@"; +"Stats.Boosts.GroupInfo" = "Members of your group can **boost** it so that it **levels up** and gets **exclusive features**."; +"Stats.Boosts.Level" = "Level"; +"Stats.Boosts.LinkHeader" = "LINK FOR BOOSTING"; +"Stats.Boosts.LinkInfo" = "Share this link with your subscribers to get more boosts."; +"Stats.Boosts.LinkInfo.Group" = "Share this link with your group members to get more boosts."; +"Stats.Boosts.NoBoostersYet" = "No users currently boost your channel"; +"Stats.Boosts.NoBoostersYet.Group" = "No users currently boost your group"; +"Stats.Boosts.OverviewHeader" = "OVERVIEW"; +"Stats.Boosts.PremiumSubscribers" = "Premium Subscribers"; +"Stats.Boosts.ShowMore" = "Show More"; +"Stats.GroupActionsTitle" = "ACTIONS"; +"Stats.GroupGrowthTitle" = "GROWTH"; +"Stats.GroupLanguagesTitle" = "MEMBERS' PRIMARY LANGUAGE"; +"Stats.GroupMembers" = "Members"; +"Stats.GroupMembersTitle" = "GROUP MEMBERS"; +"Stats.GroupMessages" = "Messages"; +"Stats.GroupMessagesTitle" = "MESSAGES"; +"Stats.GroupNewMembersBySourceTitle" = "NEW MEMBERS BY SOURCE"; +"Stats.GroupOverview" = "OVERVIEW"; +"Stats.GroupPosters" = "Posting Members"; +"Stats.GroupTopAdmin.Actions" = "Actions"; +"Stats.GroupTopAdmin.Promote" = "Promote"; +"Stats.GroupTopAdminBans#one" = "%d restriction"; +"Stats.GroupTopAdminBans#other" = "%d restrictions"; +"Stats.GroupTopAdminBans_countable" = "%d"; +"Stats.GroupTopAdminDeletions#one" = "%d deletion"; +"Stats.GroupTopAdminDeletions#other" = "%d deletions"; +"Stats.GroupTopAdminDeletions_countable" = "%d"; +"Stats.GroupTopAdminKicks#one" = "%d ban"; +"Stats.GroupTopAdminKicks#other" = "%d bans"; +"Stats.GroupTopAdminKicks_countable" = "%d"; +"Stats.GroupTopAdminsTitle" = "TOP ADMINS"; +"Stats.GroupTopHoursTitle" = "TOP HOURS"; +"Stats.GroupTopInviter.History" = "History"; +"Stats.GroupTopInviter.Promote" = "Promote"; +"Stats.GroupTopInviterInvites#one" = "%d invitation"; +"Stats.GroupTopInviterInvites#other" = "%d invitations"; +"Stats.GroupTopInviterInvites_countable" = "%d"; +"Stats.GroupTopInvitersTitle" = "TOP INVITERS"; +"Stats.GroupTopPoster.History" = "History"; +"Stats.GroupTopPoster.Promote" = "Promote"; +"Stats.GroupTopPosterChars#one" = "%d character per message"; +"Stats.GroupTopPosterChars#other" = "%d characters per message"; +"Stats.GroupTopPosterChars_countable" = "%d"; +"Stats.GroupTopPosterMessages#one" = "%d message"; +"Stats.GroupTopPosterMessages#other" = "%d messages"; +"Stats.GroupTopPosterMessages_countable" = "%d"; +"Stats.GroupTopPostersTitle" = "TOP MEMBERS"; +"Stats.GroupTopWeekdaysTitle" = "TOP DAYS OF WEEK"; +"Stats.GroupViewers" = "Viewing Members"; +"Stats.MessageInteractionsTitle" = "INTERACTIONS"; +"Stats.MessageOverview" = "OVERVIEW"; +"Stats.MessagePrivateForwardsTitle" = "Private Shares"; +"Stats.MessagePublicForwardsTitle" = "Public Shares"; +"Stats.MessagePublicForwardsTitle.Header" = "PUBLIC SHARES"; +"Stats.MessageReactionsTitle" = "REACTIONS"; +"Stats.MessageStats.StoryTitle" = "Story Statistics"; +"Stats.MessageTitle" = "Message Statistics"; +"Stats.Monetization" = "Monetization"; +"Stats.RevenueInStars" = "Revenue in Stars"; +"Stats.RevenueInTon" = "Revenue in Grams"; +"Stats.RevenueInUsd" = "Revenue in USD"; +"Stats.ShowMore#one" = "Show %d More"; +"Stats.ShowMore#other" = "Show %d More"; +"Stats.ShowMore_countable" = "%d"; +"Stats.Stars" = "Stars"; +"Stats.Statistics" = "Statistics"; +"Stats.Story.Title" = "Story"; +"Stats.Ton" = "Grams"; +"StatusBar.Activate" = "Show Telegram"; +"StatusBar.Hide" = "Hide"; +"StatusBar.Quit" = "Quit"; +"Sticker.Premium.Click.Info" = "This set contains premium stickers like this one. [View Set]()"; +"StickerPack.Add1#one" = "Add %d Sticker"; +"StickerPack.Add1#other" = "Add %d Stickers"; +"StickerPack.Add1_countable" = "%d"; +"StickerPack.Added.Info" = "%@ has been added to your stickers."; +"StickerPack.Added.Title" = "Stickers Added"; +"StickerPack.Emoji" = "Emoji"; +"StickerPack.Featured.Title" = "Trending Stickers"; +"StickerPack.Preview.RemoveGroup" = "Remove Group Emoji Pack"; +"StickerPack.Preview.SetGroup" = "Set as Group Emoji Pack"; +"StickerPack.Removed.Info" = "%@ is no longer in your stickers."; +"StickerPack.Removed.Title" = "Stickers Removed"; +"StickerPack.Sets.Add1#one" = "Add %d Sticker Pack"; +"StickerPack.Sets.Add1#other" = "Add %d Stickers Pack"; +"StickerPack.Sets.Add1_countable" = "%d"; +"StickerPack.ShowMore" = "Discover More"; +"StickerPack.Stickers" = "Stickers"; +"StickerSet.DontExist" = "Sorry, this sticker set doesn't seem to exist."; +"StickerSet.Remove" = "Remove"; +"Stickers.Confirm.ClearRecentHeader" = "Clear Recent Stickers"; +"Stickers.Confirm.ClearRecentOK" = "Clear"; +"Stickers.Confirm.ClearRecentText" = "Are you sure you want to clear recent stickers?"; +"Stickers.Context.Archive" = "Archive"; +"Stickers.Count#one" = "%d sticker"; +"Stickers.Count#other" = "%d stickers"; +"Stickers.Count_countable" = "%d"; +"Stickers.Favorite" = "Favorite"; +"Stickers.GroupStickers" = "GROUP STICKERS"; +"Stickers.Premium" = "Premium"; +"Stickers.Premium.Unlock" = "Unlock Premium Stickers"; +"Stickers.Preview.Premium" = "Unlock this sticker and many more by subscribing to Telegram Premium."; +"Stickers.Recent" = "Recent"; +"Stickers.SearchAdd" = "Add"; +"Stickers.SearchAdded" = "Added"; +"Stickers.Set.Count1#one" = "%d sticker"; +"Stickers.Set.Count1#other" = "%d stickers"; +"Stickers.Set.Count1_countable" = "%d"; +"Stickers.SuggestAdded" = "My Sets"; +"Stickers.SuggestAll" = "All Sets"; +"Stickers.SuggestNone" = "None"; +"Stickers.SuggestStickers" = "Suggest Stickers by Emoji"; +"Stickers.Trending" = "Trending Stickers"; +"Storage.Clear" = "Clear %@"; +"Storage.Clear.Audio" = "Audio"; +"Storage.Clear.Documents" = "Documents"; +"Storage.Clear.Photos" = "Photos"; +"Storage.Clear.Videos" = "Videos"; +"Storage.ClearAll" = "Clear All"; +"Storage.ClearAll.Confirm.Description" = "Are you sure you want to clear all cached data?"; +"StorageExceptions.Add.Channel" = "Add Channel"; +"StorageExceptions.Add.Group" = "Add Group"; +"StorageExceptions.Add.Private" = "Add Private Chat"; +"StorageExceptions.Header#one" = "%d EXCEPTION"; +"StorageExceptions.Header#other" = "%d EXCEPTIONS"; +"StorageExceptions.Header_countable" = "%d"; +"StorageExceptions.Remove" = "Remove"; +"StorageExceptions.RemoveAll" = "Delete All Exceptions"; +"StorageExceptions.RemoveAll.Confirm" = "Are you sure you want to remove all exceptions?"; +"StorageExceptions.Title.Channel" = "Channels"; +"StorageExceptions.Title.Group" = "Group Chats"; +"StorageExceptions.Title.Private" = "Private Chats"; +"StorageExceptions.Title.Stories" = "Stories"; +"StorageUsage.Calculating" = "Telegram is calculating the current cache size.\nThis can take a few minutes."; +"StorageUsage.Category.Avatars" = "Avatars"; +"StorageUsage.Category.Files" = "Files"; +"StorageUsage.Category.Miscellaneous" = "Miscellaneous"; +"StorageUsage.Category.Music" = "Music"; +"StorageUsage.Category.Other" = "Other"; +"StorageUsage.Category.Photos" = "Photos"; +"StorageUsage.Category.Stickers" = "Stickers"; +"StorageUsage.Category.Stories" = "Stories"; +"StorageUsage.Category.Videos" = "Videos"; +"StorageUsage.CategoryText" = "All media will stay in the Telegram cloud and can be re-downloaded if you need them again."; +"StorageUsage.ChatsHeader" = "CHATS"; +"StorageUsage.CleaningProcess" = "Freeing up space by clearing your local cache..."; +"StorageUsage.Clear" = "Clear"; +"StorageUsage.Clear.Confirm.Info" = "Media and documents will stay in the cloud and can be re-downloaded if you need them again."; +"StorageUsage.Clear.Confirm.OK.All" = "Clear Entire Cache"; +"StorageUsage.Clear.Confirm.OK.Part" = "Clear Selected"; +"StorageUsage.ClearChat.Info" = "Media files will be cleared from your disk, but will remain available for re-downloading if necessary."; +"StorageUsage.ClearDisabled" = "Clear"; +"StorageUsage.ClearFull" = "Clear Entire Cache %@"; +"StorageUsage.ClearPart" = "Clear Selected %@"; +"StorageUsage.Cleared" = "Storage Cleared"; +"StorageUsage.Cleared.Info" = "All media can be re-downloaded from the Telegram cloud if you need them again."; +"StorageUsage.ClearedText" = "%@ freed on your Device!"; +"StorageUsage.Header" = "Storage Usage"; +"StorageUsage.KeepMedia" = "Keep Media"; +"StorageUsage.KeepMedia.Channels" = "Channels"; +"StorageUsage.KeepMedia.Description" = "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space.\n\nAll media will stay in the Telegram cloud and can be re-downloaded if you need it again."; +"StorageUsage.KeepMedia.Description1" = "Photos, videos and other files from cloud chats that you have **not accessed** during this period will be removed from this device to save disk space."; +"StorageUsage.KeepMedia.Exceptions#one" = "%d exception"; +"StorageUsage.KeepMedia.Exceptions#other" = "%d exceptions"; +"StorageUsage.KeepMedia.Exceptions_ZeroValueHolder" = "Add Exception"; +"StorageUsage.KeepMedia.Exceptions_countable" = "%d"; +"StorageUsage.KeepMedia.Groups" = "Group Chats"; +"StorageUsage.KeepMedia.Header" = "AUTO-REMOVE CACHED MEDIA"; +"StorageUsage.KeepMedia.Private" = "Private Chats"; +"StorageUsage.KeepMedia.Stories" = "Stories"; +"StorageUsage.Limit.Desc" = "If your cache size exceeds this limit, the oldest media will be removed from the device.\n\nAll media will stay in the Telegram cloud and can be re-downloaded if you need it again."; +"StorageUsage.Limit.Header" = "MAXIMUM CACHE SIZE"; +"StorageUsage.Limit.InfoUpdated" = "If your cache size exceeds this limit, the oldest unused media will be removed from your device."; +"StorageUsage.Limit.NoLimit" = "No Limit"; +"StorageUsage.Media.File" = "File"; +"StorageUsage.Media.Photo" = "Photo"; +"StorageUsage.Media.Video" = "Video"; +"StorageUsage.Media.VideoMessage" = "Video Message"; +"StorageUsage.Media.Voice" = "Voice Message"; +"StorageUsage.Message.Context.Clear" = "Clear"; +"StorageUsage.Message.Context.Delete" = "Delete"; +"StorageUsage.Message.Context.Preview" = "Preview"; +"StorageUsage.Message.Context.Select" = "Select"; +"StorageUsage.Message.Context.ShowInChat" = "Show Message"; +"StorageUsage.Segment.Chats" = "Chats"; +"StorageUsage.Segment.Files" = "Files"; +"StorageUsage.Segment.Media" = "Media"; +"StorageUsage.Segment.Music" = "Music"; +"StorageUsage.Segment.Voice" = "Voice"; +"StorageUsage.Selected.ClearDisabled" = "Clear Selected"; +"StorageUsage.Selected.ClearPart" = "Clear Selected %@"; +"StorageUsage.SelectedMedia.Empty" = "Empty"; +"StorageUsage.TelegramUsage.Empty" = "Telegram uses less than 1% of your free disk space."; +"StorageUsage.TelegramUsage.Empty.Peer" = "This chat uses less than 1% of your Telegram Cache."; +"StorageUsage.TelegramUsage.Text" = "Telegram uses **%@** of your free disk space."; +"StorageUsage.TelegramUsage.Text.Peer" = "This chat uses **%2$@** of your Telegram Cache."; +"Stories.FoundList.FromLocation#one" = "%d STORY FROM THIS LOCATION"; +"Stories.FoundList.FromLocation#other" = "%d STORIES FROM THIS LOCATION"; +"Stories.FoundList.FromLocation_ZeroValueHolder" = "NO STORIES FROM THIS LOCATION"; +"Stories.FoundList.FromLocation_countable" = "%d"; +"Stories.FoundList.NotFound" = "No Stories Found"; +"Story.Alert.NoReactions.Channels" = "Nobody has reacted or reposted your story."; +"Story.Alert.NoViews" = "Nobody has viewed your story yet."; +"Story.Alert.ViewsExpired" = "List of viewers becomes unavailable 24 hours after the story expires."; +"Story.AvatarContext.OpenChannel" = "Open Channel"; +"Story.AvatarContext.SendMessage" = "Send Message"; +"Story.ChannelInput.RemoveFromProfile" = "Remove From Posts"; +"Story.ChannelInput.SaveToProfile" = "Post to Profile"; +"Story.Confirm.Delete" = "Are you sure you want to delete this story?"; +"Story.Context.IncreaseQuality" = "Increase Quality"; +"Story.Context.IncreaseQuality.Tooltip.Text" = "You can lower the quality later for faster downloads."; +"Story.Context.IncreaseQuality.Tooltip.Title" = "Quality Increased"; +"Story.Context.LowerQuality" = "Lower Quality"; +"Story.Context.LowerQuality.Tooltip.Text" = "Stories will now download faster."; +"Story.Context.LowerQuality.Tooltip.Title" = "Quality Lowered"; +"Story.Controls.Edited" = "edited"; +"Story.Controls.LiveStream.Loading" = "loading..."; +"Story.Controls.LiveStream.Watching#one" = "%d watching"; +"Story.Controls.LiveStream.Watching#other" = "%d watching"; +"Story.Controls.LiveStream.Watching_countable" = "%d watching"; +"Story.Controls.Menu.Archive" = "Hide Stories"; +"Story.Controls.Menu.CopyLink" = "Copy Link"; +"Story.Controls.Menu.Report" = "Report"; +"Story.Controls.Menu.Share" = "Share"; +"Story.Controls.Menu.Statistics" = "Statistics"; +"Story.Controls.Menu.StealtMode" = "Hide My View"; +"Story.Controls.Menu.Unarchive" = "Unhide Stories"; +"Story.Controls.VideoNoSound" = "This video has no sound"; +"Story.Controls.YourStory" = "Your Story"; +"Story.Error.NotExist" = "This story is no longer available"; +"Story.Input.Attach" = "Photo or Video"; +"Story.Input.CantShare" = "You can't share this story"; +"Story.Input.EmojiLimitReached" = "Emoji limit reached. Add Stars to increase limit."; +"Story.Input.File" = "File"; +"Story.Input.Group.Placeholder" = "Comment Story..."; +"Story.Input.Placeholder" = "Reply Privately..."; +"Story.ListView.CannotBeShared" = "This story can't be shared"; +"Story.LiveStream.AdditionalActions" = "ADDITIONAL ACTIONS"; +"Story.LiveStream.DeleteMessage" = "Delete Message"; +"Story.LiveStream.Input.CommentsDisabled" = "Comments are disabled."; +"Story.LiveStream.Input.Edit" = "Edit"; +"Story.LiveStream.Input.EmojiLimitReached" = "Emoji limit reached. Add Stars to increase limit."; +"Story.LiveStream.Input.LimitReached" = "Limit reached. Add Stars to increase limit."; +"Story.LiveStream.Input.Reset" = "Reset"; +"Story.LiveStream.Input.Send" = "Send"; +"Story.LiveStream.Message.Admin" = "admin"; +"Story.LiveStream.Proceed" = "Proceed"; +"Story.LiveStream.StarMessage.Add" = "Add %@"; +"Story.LiveStream.StarMessage.Characters" = "characters"; +"Story.LiveStream.StarMessage.Emoji" = "emoji"; +"Story.LiveStream.StarMessage.HighlightAndPin" = "Highlight and Pin"; +"Story.LiveStream.StarMessage.HighlightInfo" = "Highlight and pin a message by adding Stars for **%@**."; +"Story.LiveStream.StarMessage.PinInChat" = "pin in chat"; +"Story.Location.Title" = "Location"; +"Story.Media.Unsupported" = "This story is not supported by your version of Telegram. Please update the app to the latest version."; +"Story.MyInput.CopyLink" = "Copy Link"; +"Story.MyInput.Likes#one" = "%d"; +"Story.MyInput.Likes#other" = "%d"; +"Story.MyInput.Likes_countable" = "%d"; +"Story.MyInput.RemoveFromProfile" = "Remove From Profile"; +"Story.MyInput.SaveMedia" = "Save Media..."; +"Story.MyInput.SaveToProfile" = "Post to Profile"; +"Story.MyInput.Share" = "Share"; +"Story.MyInput.Views#one" = "%d view"; +"Story.MyInput.Views#other" = "%d views"; +"Story.MyInput.Views_ZeroValueHolder" = "No Views yet"; +"Story.MyInput.Views_countable" = "%d"; +"Story.NoReplyInput.NoReply" = "You can't reply to this story"; +"Story.Report.SuccessText" = "Telegram moderators will review your report. Thank you!"; +"Story.RepostToMy" = "repost to my stories"; +"Story.SlowMode.Placeholder" = "Slow Mode - %@"; +"Story.StealthMode.Button.Available" = "Available in %@"; +"Story.StealthMode.Button.Enable" = "Enable Stealth Mode"; +"Story.StealthMode.Button.Unlock" = "Unlock Stealth Mode"; +"Story.StealthMode.First.Info" = "Hide my views from the past 5 minutes."; +"Story.StealthMode.First.Title" = "Hide Recent Views"; +"Story.StealthMode.Info.Subscribe" = "Subscribe to Telegram Premium to watch stories without appearing in the list of viewers."; +"Story.StealthMode.Info.Text" = "Turn Stealth Mode on to watch stories without appearing in the list of viewers."; +"Story.StealthMode.Placeholder" = "Stealth Mode - %@"; +"Story.StealthMode.Second.Info" = "Hide my views for the next 25 minutes."; +"Story.StealthMode.Second.Title" = "Hide Upcoming Views"; +"Story.StealthMode.Title" = "Stealth Mode"; +"Story.StealthMode.Tooltip.Wait" = "Please wait until Stealth Mode is ready to use again."; +"Story.Tooltip.Archive" = "Stories from **%@** were moved to the Archive."; +"Story.Tooltip.AudioSent" = "Audio Sent."; +"Story.Tooltip.Button.ViewInChat" = "View in Chat"; +"Story.Tooltip.CloseFriends" = "You are seeing this story because %@ added you\nto their list of Close Friends."; +"Story.Tooltip.Contacts" = "Only %@'s contacts can view this story."; +"Story.Tooltip.GifSent" = "GIF Sent."; +"Story.Tooltip.LinkCopied" = "Copied to clipboard."; +"Story.Tooltip.MediaSent" = "Media Sent."; +"Story.Tooltip.MessageSent" = "Message Sent."; +"Story.Tooltip.PhotoSent" = "Photo Sent."; +"Story.Tooltip.ReactionSent" = "Message Sent."; +"Story.Tooltip.Removed.Title" = "Stories Removed"; +"Story.Tooltip.RemovedFromProfile" = "Story removed from your profile."; +"Story.Tooltip.RemovedFromProfile.Channel" = "Story removed from the channel page."; +"Story.Tooltip.SaveToGallery.Premium" = "Subscribe to Telegram Premium to save other people's stories that are not protected."; +"Story.Tooltip.Saved.Title" = "Stories Saved"; +"Story.Tooltip.SavedToProfile" = "Posted stories can be viewed by others on your profile until you remove them."; +"Story.Tooltip.SavedToProfile.Channel" = "Posted stories can be viewed by others on the channel page until an admin removes them."; +"Story.Tooltip.SelectedContacts" = "Only some users %@ selected can view this story."; +"Story.Tooltip.StealthMode.Activate" = "The creators of stories you viewed in the past %1$@ or will view in the next %2$@ won't see you in the list of viewers."; +"Story.Tooltip.StealthMode.Active" = "The creators of stories you view in the next %1$@ won't see you in the list of viewers."; +"Story.Tooltip.StickerSent" = "Sticker Sent."; +"Story.Tooltip.Unarchive" = "Stories from **%@** were returned from the Archive."; +"Story.Tooltip.VideoSent" = "Video Sent."; +"Story.Widget.OpenGift" = "Open Gift"; +"StoryFeed.TooltipArchive" = "Stories from %@ were moved to the Archive."; +"StoryFeed.TooltipUnarchive" = "Stories from %@ were returned from the Archive."; +"StoryInput.PremiumRequiredState" = "Only Premium users can reply to **%@** stories. [Learn More...](premium)"; +"StoryItem.Text.ShowMore" = "Show More"; +"StoryList.Context.Archive" = "Hide Stories"; +"StoryList.Context.ArchivedStories" = "Archived Stories"; +"StoryList.Context.OpenChannel" = "Open Channel"; +"StoryList.Context.SavedStories" = "Posted Stories"; +"StoryList.Context.SendMessage" = "Send Message"; +"StoryList.Context.Unarchive" = "Unhide Stories"; +"StoryList.Context.ViewProfile" = "View Profile"; +"StoryList.MyStory" = "My Story"; +"StoryList.TooltipArchive" = "Stories from %@ were moved to the Archive."; +"StoryList.TooltipUnarchive" = "Stories from %@ were returned from the Archive."; +"StoryListView.AddToProfile" = "Add to Profile"; +"StoryListView.AddToSavedMessages" = "Add to Saved Messages"; +"StoryMedia.Album.Add" = "Add Album"; +"StoryMedia.Album.Alert.Added" = "Story added to **%@**"; +"StoryMedia.Album.Alert.Removed" = "Story removed from **%@**"; +"StoryMedia.Album.All" = "All Stories"; +"StoryMedia.Album.Button.AddStories" = "Add Stories"; +"StoryMedia.Album.ContextMenu.AddFolder" = "Add Album"; +"StoryMedia.Album.ContextMenu.AddStories" = "Add Stories"; +"StoryMedia.Album.ContextMenu.AddToAlbum" = "Add To Album"; +"StoryMedia.Album.ContextMenu.Delete" = "Delete"; +"StoryMedia.Album.ContextMenu.Delete.Confirm" = "Are you sure you want to delete **%@**?"; +"StoryMedia.Album.ContextMenu.NewAlbum" = "New Album"; +"StoryMedia.Album.ContextMenu.Rename" = "Rename"; +"StoryMedia.Album.Empty.Action" = "Add to Album"; +"StoryMedia.Album.Empty.Header" = "Organize Your Stories"; +"StoryMedia.Album.Empty.Text" = "Add some stories to this album."; +"StoryMedia.Album.New.Info" = "Choose a name for your album and add your stories."; +"StoryMedia.Album.New.Ok" = "Create"; +"StoryMedia.Album.New.Placeholder" = "Title..."; +"StoryMedia.Album.New.Title" = "Create a New Album"; +"StoryMedia.Album.Update.Info" = "Update the name for your album."; +"StoryMedia.Album.Update.Ok" = "Update"; +"StoryMedia.Album.Update.Placeholder" = "Title..."; +"StoryMedia.Album.Update.Success" = "Name Successfully Updated"; +"StoryMedia.Album.Update.Title" = "Update Name"; +"StoryMedia.Archive" = "Archive"; +"StoryMedia.Archive.Empty.Text" = "Upload a new story to view it here"; +"StoryMedia.Archive.Empty.Title" = "No Archived Stories"; +"StoryMedia.ArchiveText" = "Only you can see archived stories unless you choose to post them to your profile."; +"StoryMedia.Context.Archive" = "Archive"; +"StoryMedia.Delete" = "Delete"; +"StoryMedia.DeleteConfirm#one" = "Are you sure you want to delete %d story?"; +"StoryMedia.DeleteConfirm#other" = "Are you sure you want to delete %d stories?"; +"StoryMedia.DeleteConfirm_countable" = "%d"; +"StoryMedia.Done" = "Done"; +"StoryMedia.Empty.Open" = "Open Archive"; +"StoryMedia.Empty.Text" = "Open the Archive to select stories you want to be displayed on your profile."; +"StoryMedia.Empty.Title" = "No Posted Stories"; +"StoryMedia.Pin" = "Pin"; +"StoryMedia.Select" = "Select"; +"StoryMedia.Title.Archive" = "Archive"; +"StoryMedia.Title.MyStories" = "My Stories"; +"StoryMedia.Tooltip.Limit#one" = "You can't pin more than %d post"; +"StoryMedia.Tooltip.Limit#other" = "You can't pin more than %d posts"; +"StoryMedia.Tooltip.Limit_countable" = "%d"; +"StoryMedia.Tooltip.Pinned#one" = "Story Pinned"; +"StoryMedia.Tooltip.Pinned#other" = "Stories Pinned"; +"StoryMedia.Tooltip.Pinned_countable" = "%d"; +"StoryMedia.Tooltip.Unpinned#one" = "Story Unpinned"; +"StoryMedia.Tooltip.Unpinned#other" = "Stories Unpinned"; +"StoryMedia.Tooltip.Unpinned_countable" = "%d"; +"StoryMedia.Unarchive" = "Unarchive"; +"StoryMedia.Unpin" = "Unpin"; +"StoryPrivacy.AllowScreenshot" = "Allow Screenshots"; +"StoryPrivacy.BasicInfo" = "Keep this story on your profile even after it expires in 24 hours. Privacy settings will apply."; +"StoryPrivacy.Choose" = "choose"; +"StoryPrivacy.CloseFriends" = "Close Friends"; +"StoryPrivacy.Contacts" = "Contacts"; +"StoryPrivacy.Context.PostAs" = "SHARE AS"; +"StoryPrivacy.EditList" = "edit list"; +"StoryPrivacy.Everyone" = "Everyone"; +"StoryPrivacy.ExceptPeople" = "except %@"; +"StoryPrivacy.ExcludePeople" = "exclude people"; +"StoryPrivacy.GrayListPerson#one" = "%d person"; +"StoryPrivacy.GrayListPerson#other" = "%d people"; +"StoryPrivacy.GrayListPerson_countable" = "%d"; +"StoryPrivacy.GrayList_Part1" = "Select people"; +"StoryPrivacy.PeopleList#one" = "%d person"; +"StoryPrivacy.PeopleList#other" = "%d people"; +"StoryPrivacy.PeopleList_countable" = "%d"; +"StoryPrivacy.PersonalAccount" = "personal account"; +"StoryPrivacy.PostStoryAs" = "POST STORY AS"; +"StoryPrivacy.PostToMyProfile" = "Post to My Profile"; +"StoryPrivacy.Reveal.Info" = "Choose who can see your reposted story."; +"StoryPrivacy.Reveal.Title" = "Privacy"; +"StoryPrivacy.Save.Privacy" = "Saved!"; +"StoryPrivacy.Save.Repost" = "Success!"; +"StoryPrivacy.Select.HideFrom" = "Hide Your Stories From"; +"StoryPrivacy.Select.Title.CloseFriends" = "Close Friends"; +"StoryPrivacy.Select.Title.Contacts" = "Excluded Contacts"; +"StoryPrivacy.Select.Title.Nobody" = "Select Users"; +"StoryPrivacy.SelectUsers" = "Select Users"; +"StoryPrivacy.Title.Privacy" = "Story Privacy"; +"StoryPrivacy.Title.Repost" = "Share Story"; +"StoryPrivacy.WhoCanViewStory" = "WHO CAN VIEW THIS STORY"; +"StorySearch.Subtitle#one" = "%d story"; +"StorySearch.Subtitle#other" = "%d stories"; +"StorySearch.Subtitle_ZeroValueHolder" = "no stories"; +"StorySearch.Subtitle_countable" = "%d"; +"StoryView.ContextMenu.Block" = "Block User"; +"StoryView.ContextMenu.DeleteContact" = "Delete Contact"; +"StoryView.ContextMenu.HideMyStories" = "Hide My Stories From %@"; +"StoryView.ContextMenu.ShowMyStories" = "Do Not Hide My Stories From %@"; +"StoryView.ContextMenu.Unblock" = "Unblock"; +"StoryView.MediaArea.OpenUrl" = "Open Link"; +"StoryView.MediaArea.ViewLocation" = "View Location"; +"StoryView.MediaArea.ViewMessage" = "View Message"; +"StoryView.Tooltip.Block" = "%@ was blocked."; +"StoryView.Tooltip.DeleteContact" = "%@ was deleted from your contacts."; +"StoryView.Tooltip.HideMyStories" = "%@ will not be able to see your stories."; +"StoryView.Tooltip.ShowMyStories" = "%@ will be able to see your stories again."; +"StoryView.Tooltip.Unblock" = "%@ was unblocked."; +"StoryViewers.All" = "All Viewers"; +"StoryViewers.Contacts" = "Contacts"; +"StoryViewers.NotRecorded" = "Information about the other viewers wasn't recorded."; +"StoryViewers.Premium.LearnMore" = "Learn More"; +"StoryViewers.Premium.Unlock" = "To unlock viewer lists for expired and posted stories, subscribe to [Telegram Premium]()."; +"StoryViewers.ReactionsFirst" = "Reactions first"; +"StoryViewers.RecentFirst" = "Newest first"; +"StoryViewers.RepostFirst" = "Repost first"; +"StoryViewers.Viewers" = "Viewers"; +"StoryViews.Title#one" = "%d view"; +"StoryViews.Title#other" = "%d views"; +"StoryViews.Title.Channel" = "Reactions"; +"StoryViews.Title_ZeroValueHolder" = "No Views Yet"; +"StoryViews.Title_countable" = "%d"; +"Suggest.Frequent.Contacts" = "Suggest Frequent Contacts"; +"Suggest.Frequent.Contacts.Alert" = "This will delete all data about the people you message frequently as well as the inline bots you are likely to use."; +"Suggest.Frequent.Contacts.Desc" = "Display people you message frequently at the top of the search section for quick access."; +"Suggest.Localization.Header" = "Choose your language"; +"Suggest.Localization.Other" = "Other"; +"SuggestPostPanel.Text.Anytime" = "Anytime"; +"SuggestPostPanel.Text.FreeAndDate" = "Free 📅 %1$@"; +"SuggestPostPanel.Text.OfferPrice" = "Click to offer a price for publishing"; +"SuggestPostPanel.Text.PriceAndDate" = "%1$@ 📅 %2$@"; +"SuggestPostPanel.Title.Edit" = "Edit the Post Below"; +"SuggestPostPanel.Title.New" = "Suggest a Post Below"; +"SuggestPostPanel.Title.Suggest" = "Suggest Changes"; +"Supergroup.Convert.Button" = "Convert to Supergroup"; +"Supergroup.Convert.Description" = "**In supergroups:**\n\n• New members can see the full message history\n• Deleted messages will disappear for all members\n• Admins can add a description to the group\n• Creator can set a public link for the group"; +"Supergroup.Convert.Undone" = "**Note**: This action cannot be undone."; +"Supergroup.DeleteRestriction.Ban" = "Ban"; +"Supergroup.DeleteRestriction.BanUser" = "Ban User"; +"Supergroup.DeleteRestriction.BanUser.Multi" = "Ban All Users"; +"Supergroup.DeleteRestriction.Delete" = "Delete"; +"Supergroup.DeleteRestriction.DeleteAllMessages" = "Delete All Messages"; +"Supergroup.DeleteRestriction.DeleteAllMessages.Multi" = "Delete All From Users"; +"Supergroup.DeleteRestriction.DeleteMessage" = "Delete Message"; +"Supergroup.DeleteRestriction.DeleteMessage.Multi" = "Delete Messages"; +"Supergroup.DeleteRestriction.MultiTitle#one" = "Delete %d Message"; +"Supergroup.DeleteRestriction.MultiTitle#other" = "Delete %d Messages"; +"Supergroup.DeleteRestriction.MultiTitle_countable" = "%d"; +"Supergroup.DeleteRestriction.Proceed" = "Done"; +"Supergroup.DeleteRestriction.Report" = "Report"; +"Supergroup.DeleteRestriction.ReportSpam" = "Report Spam"; +"Supergroup.DeleteRestriction.Restrict" = "Restrict"; +"Supergroup.DeleteRestriction.RestrictUser" = "Restrict User"; +"Supergroup.DeleteRestriction.RestrictUser.Multi" = "Restrict All Users"; +"Supergroup.DeleteRestriction.Title" = "Manage Messages"; +"SuspiciousSession.Me" = "Yes, it's me"; +"SuspiciousSession.NotMe" = "No, it's not me!"; +"System.MemoryWarning.DataAndStorage" = "App Data Storage"; +"System.MemoryWarning.FreeSpace" = "%d GB"; +"System.MemoryWarning.Header" = "Warning!"; +"System.MemoryWarning.LessThen1GB" = "Less than 1 GB"; +"System.MemoryWarning.OK" = "OK"; +"System.MemoryWarning.Text" = "Your Mac is running low on disk space. Please free up some space by removing unnecessary files or changing your cache settings.\n\nFree space available: ~%@"; +"Td7-aD-5lo.title" = "Window"; +"Telegram.AppearanceViewController" = "Appearance"; +"Telegram.ArchivedStickerPacksController" = "Archived Stickers"; +"Telegram.BioViewController" = "Bio"; +"Telegram.BlockedPeersViewController" = "Blocked Users"; +"Telegram.ChannelAdminsViewController" = "Admins"; +"Telegram.ChannelBlacklistViewController" = "Removed Users"; +"Telegram.ChannelEventLogController" = "All Actions"; +"Telegram.ChannelIntroViewController" = "Channel"; +"Telegram.ChannelMembersViewController" = "Channel Members"; +"Telegram.ChannelPermissionsController" = "Permissions"; +"Telegram.ChannelStatisticsController" = "Channel Stats"; +"Telegram.ChannelVisibilityController" = "Channel"; +"Telegram.ChannelVisibilityController.Channel" = "Channel"; +"Telegram.ChannelVisibilityController.Group" = "Group"; +"Telegram.ConvertGroupViewController" = "Supergroup"; +"Telegram.DataAndStorageViewController" = "Data and Storage"; +"Telegram.FeaturedStickerPacksController" = "Trending Stickers"; +"Telegram.ForwardChatListController" = "Forward Messages"; +"Telegram.GeneralSettingsViewController" = "General Settings"; +"Telegram.GroupAdminsController" = "Admins"; +"Telegram.GroupStickerSetController" = "Group Sticker Set"; +"Telegram.GroupsInCommonViewController" = "Groups In Common"; +"Telegram.InstalledStickerPacksController" = "Stickers and Emoji"; +"Telegram.LanguageViewController" = "Language"; +"Telegram.LayoutAccountController" = "Settings"; +"Telegram.LayoutRecentCallsViewController" = "Recent Calls"; +"Telegram.LinkInvationController" = "Invite Link"; +"Telegram.NotificationSettingsViewController" = "Notifications"; +"Telegram.PasscodeSettingsViewController" = "Passcode"; +"Telegram.PassportController" = "Passport"; +"Telegram.PeerInfoController" = "Info"; +"Telegram.PeerMediaController" = "Shared Media"; +"Telegram.PhoneNumberConfirmController" = "Change Number"; +"Telegram.PreHistorySettingsController" = "Chat History Settings"; +"Telegram.PrivacyAndSecurityViewController" = "Privacy and Security"; +"Telegram.ProxySettingsViewController" = "Proxy"; +"Telegram.RecentSessionsController" = "Active Sessions"; +"Telegram.SecretChatKeyViewController" = "Encryption Key"; +"Telegram.SelectPeersController" = "Select Users"; +"Telegram.StorageUsageController" = "Storage Usage"; +"Telegram.TwoStepVerificationUnlockController" = "Two-Step Verification"; +"Telegram.UpgradeDatabase.Text" = "Telegram needs to optimize its database after this update. This may take a few minutes, sorry for the inconvenience."; +"Telegram.UpgradeDatabase.Title" = "Optimizing Database"; +"Telegram.UsernameSettingsViewController" = "Username"; +"Telegram.WebSessionsController" = "Logged In with Telegram"; +"TermsOfService.Accept" = "Agree & Continue"; +"TermsOfService.Accept.ConfirmAge" = "Confirm"; +"TermsOfService.ConfirmAge" = "I confirm that I am %@ or over."; +"TermsOfService.Disagree" = "Decline"; +"TermsOfService.Disagree.OK" = "Decline & Delete"; +"TermsOfService.Disagree.Text" = "Unfortunately, there are no other options. Unlike other apps, Telegram does not use user data for ad targeting or other commercial purposes. Telegram only stores the information it needs to function as a feature-rich cloud service. You can adjust how your data is used in Privacy and Security settings.\n\nIf you are not comfortable with Telegram's modest needs, it won't be possible for us to provide you with this service."; +"TermsOfService.Disagree.Text.Last" = "Warning, this will irreversibly delete your Telegram account and all the data you store in the Telegram cloud.\n\nImportant: You can Cancel now and export your data first instead of losing it. (To do this, open the latest version of Telegram Desktop and go to Settings > Advanced > Export Telegram data.)"; +"TermsOfService.Disagree.Text.Last.OK" = "Delete Now"; +"TermsOfService.ProceedBot" = "Agree and proceed to %@."; +"TermsOfService.Title" = "Terms of Service"; +"Text.Arrow" = "%1$@ → %2$@"; +"Text.Context.Copy.Code" = "Copy Code"; +"Text.Context.Copy.Command" = "Copy Command"; +"Text.Context.Copy.Email" = "Copy Email"; +"Text.Context.Copy.EmojiPack" = "Copy Emoji Pack"; +"Text.Context.Copy.Hashtag" = "Copy Hashtag"; +"Text.Context.Copy.InviteLink" = "Copy Invite Link"; +"Text.Context.Copy.Link" = "Copy Link"; +"Text.Context.Copy.StickerPack" = "Copy Sticker Set Link"; +"Text.Context.Copy.Username" = "Copy Username"; +"Text.Copy" = "Copy Selected Text"; +"Text.CopyLabel" = "Copy %@"; +"Text.CopyLabel_About" = "Copy About"; +"Text.CopyLabel_Bio" = "Copy Bio"; +"Text.CopyLabel_PhoneNumber" = "Copy Phone Number"; +"Text.CopyLabel_ShareLink" = "Copy Share Link"; +"Text.CopyLabel_Username" = "Copy Username"; +"Text.CopyText" = "Copy Text"; +"Text.View.Transformations" = "Formatting"; +"TextUtils.ManagedBotCreated" = "%1$@ created %2$@"; +"TextUtils.PollOptionAppended" = "%1$@ added a new option: %2$@"; +"TextUtils.PollOptionDeleted" = "%1$@ deleted an option: %2$@"; +"TextView.Transform.Bold" = "Bold"; +"TextView.Transform.Code" = "Monospace"; +"TextView.Transform.Date" = "Date"; +"TextView.Transform.Italic" = "Italic"; +"TextView.Transform.Quote" = "Quote"; +"TextView.Transform.RemoveAll" = "Clear Formatting"; +"TextView.Transform.Spoiler" = "Spoiler"; +"TextView.Transform.Strikethrough" = "Strikethrough"; +"TextView.Transform.Translate" = "Translate"; +"TextView.Transform.URL" = "Make URL"; +"TextView.Transform.URL1" = "Make Link"; +"TextView.Transform.Underline" = "Underline"; +"Theme.GetTheme.Error" = "Sorry, this theme doesn't seem to exist for macOS."; +"ThemePreview.Title" = "Theme Preview"; +"ThemePreview.UsesCount#one" = "%d person is using this theme"; +"ThemePreview.UsesCount#other" = "%d people are using this theme"; +"ThemePreview.UsesCount_countable" = "%d"; +"Time.AtDate" = "{date}"; +"Time.AtPreciseDate" = "%1$@ at %2$@"; +"Time.PreciseDate_m1" = "Jan %@, %@ at %@"; +"Time.PreciseDate_m10" = "Oct %@, %@ at %@"; +"Time.PreciseDate_m11" = "Nov %@, %@ at %@"; +"Time.PreciseDate_m12" = "Dec %@, %@ at %@"; +"Time.PreciseDate_m2" = "Feb %@, %@ at %@"; +"Time.PreciseDate_m3" = "Mar %@, %@ at %@"; +"Time.PreciseDate_m4" = "Apr %@, %@ at %@"; +"Time.PreciseDate_m5" = "May %@, %@ at %@"; +"Time.PreciseDate_m6" = "Jun %@, %@ at %@"; +"Time.PreciseDate_m7" = "Jul %@, %@ at %@"; +"Time.PreciseDate_m8" = "Aug %@, %@ at %@"; +"Time.PreciseDate_m9" = "Sep %@, %@ at %@"; +"Time.PreciseMediumDate_m1" = "Jan %@ at %@"; +"Time.PreciseMediumDate_m10" = "Oct %@ at %@"; +"Time.PreciseMediumDate_m11" = "Nov %@ at %@"; +"Time.PreciseMediumDate_m12" = "Dec %@ at %@"; +"Time.PreciseMediumDate_m2" = "Feb %@ at %@"; +"Time.PreciseMediumDate_m3" = "Mar %@ at %@"; +"Time.PreciseMediumDate_m4" = "Apr %@ at %@"; +"Time.PreciseMediumDate_m5" = "May %@ at %@"; +"Time.PreciseMediumDate_m6" = "Jun %@ at %@"; +"Time.PreciseMediumDate_m7" = "Jul %@ at %@"; +"Time.PreciseMediumDate_m8" = "Aug %@ at %@"; +"Time.PreciseMediumDate_m9" = "Sep %@ at %@"; +"Time.TodayAt" = "today at %@"; +"Time.TomorrowAt" = "tomorrow at %@"; +"Time.YesterdayAt" = "yesterday at %@"; +"Time.agoHour" = "{date} hour ago"; +"Time.agoHours" = "{date} hours ago"; +"Time.agoMinute" = "{date} minute ago"; +"Time.agoMinutes" = "{date} minutes ago"; +"Time.at" = "at"; +"Time.justnow" = "just now"; +"Time.last_seen" = "last seen"; +"Time.today" = "today"; +"Time.yesterday" = "yesterday"; +"Timer.Days#one" = "%d day"; +"Timer.Days#other" = "%d days"; +"Timer.Days_countable" = "%d"; +"Timer.Forever" = "Forever"; +"Timer.Hours#one" = "%d hour"; +"Timer.Hours#other" = "%d hours"; +"Timer.Hours_countable" = "%d"; +"Timer.Minutes#one" = "%d minute"; +"Timer.Minutes#other" = "%d minutes"; +"Timer.Minutes_countable" = "%d"; +"Timer.Months#one" = "%d month"; +"Timer.Months#other" = "%d months"; +"Timer.Months_countable" = "%d"; +"Timer.Never" = "Never"; +"Timer.Seconds#one" = "%d second"; +"Timer.Seconds#other" = "%d seconds"; +"Timer.Seconds_countable" = "%d"; +"Timer.Weeks#one" = "%d week"; +"Timer.Weeks#other" = "%d weeks"; +"Timer.Weeks_countable" = "%d"; +"Timer.Years#one" = "%d year"; +"Timer.Years#other" = "%d years"; +"Timer.Years_countable" = "%d"; +"Tip.AutoDelete.TimerSetForDay" = "Auto-delete timer set to 1 day."; +"Tip.AutoDelete.TimerSetForWeek" = "Auto-delete timer set to 1 week."; +"Tip.AutoDelete.TimerSetOff" = "Auto-delete timer is now disabled."; +"Toast.Muted" = "Muted"; +"Toast.Unmuted" = "Unmuted"; +"TonViewer.Url" = "https://tonviewer.com/%@"; +"TouchBar.Attach" = "Attach"; +"TouchBar.Call" = "Call"; +"TouchBar.EditMessage.ReplaceWithFile" = "Replace with File"; +"TouchBar.EditMessage.ReplaceWithMedia" = "Replace with Media"; +"TouchBar.Favorite" = "Favorite"; +"TouchBar.Recent" = "Recent"; +"TouchBar.RecentlyUsed" = "Recently Used"; +"TouchBar.SearchUsersOrMessages" = "Search chats"; +"TouchBar.StartSecretChat" = "Start Secret Chat"; +"TouchBarLabel.ChatActions" = "Chat Actions"; +"TouchBarLabel.EmojiAndStickers" = "Emoji & Stickers"; +"TouchBarLabel.NewChat" = "New Chat"; +"Translate.AIEditor" = "AI Editor"; +"Translate.Apply" = "Apply"; +"Translate.CopyToClipboard" = "Copy to Clipboard"; +"Translate.DailyLimitInfo" = "Get Telegram Premium for 50x more text edits per day."; +"Translate.DailyLimitReached" = "Daily limit reached"; +"Translate.Emojify" = "Emojify"; +"Translate.From" = "FROM: %@"; +"Translate.Language.Afrikaans" = "Afrikaans"; +"Translate.Language.Albanian" = "Albanian"; +"Translate.Language.Amharic" = "Amharic"; +"Translate.Language.Arabic" = "Arabic"; +"Translate.Language.Armenian" = "Armenian"; +"Translate.Language.Azerbaijani" = "Azerbaijani"; +"Translate.Language.Basque" = "Basque"; +"Translate.Language.Belarusian" = "Belarusian"; +"Translate.Language.Bengali" = "Bengali"; +"Translate.Language.Bosnian" = "Bosnian"; +"Translate.Language.Bulgarian" = "Bulgarian"; +"Translate.Language.Catalan" = "Catalan"; +"Translate.Language.Cebuano" = "Cebuano"; +"Translate.Language.Chinese_Simplified" = "Chinese (Simplified)"; +"Translate.Language.Chinese_Traditional" = "Chinese (Traditional)"; +"Translate.Language.Corsican" = "Corsican"; +"Translate.Language.Croatian" = "Croatian"; +"Translate.Language.Czech" = "Czech"; +"Translate.Language.Danish" = "Danish"; +"Translate.Language.Dutch" = "Dutch"; +"Translate.Language.English" = "English"; +"Translate.Language.Esperanto" = "Esperanto"; +"Translate.Language.Estonian" = "Estonian"; +"Translate.Language.Finnish" = "Finnish"; +"Translate.Language.French" = "French"; +"Translate.Language.Frisian" = "Frisian"; +"Translate.Language.Galician" = "Galician"; +"Translate.Language.Georgian" = "Georgian"; +"Translate.Language.German" = "German"; +"Translate.Language.Greek" = "Greek"; +"Translate.Language.Gujarati" = "Gujarati"; +"Translate.Language.Haitian_Creole" = "Haitian Creole"; +"Translate.Language.Hausa" = "Hausa"; +"Translate.Language.Hawaiian" = "Hawaiian"; +"Translate.Language.Hebrew" = "Hebrew"; +"Translate.Language.Hindi" = "Hindi"; +"Translate.Language.Hmong" = "Hmong"; +"Translate.Language.Hungarian" = "Hungarian"; +"Translate.Language.Icelandic" = "Icelandic"; +"Translate.Language.Igbo" = "Igbo"; +"Translate.Language.Indonesian" = "Indonesian"; +"Translate.Language.Irish" = "Irish"; +"Translate.Language.Italian" = "Italian"; +"Translate.Language.Japanese" = "Japanese"; +"Translate.Language.Javanese" = "Javanese"; +"Translate.Language.Kannada" = "Kannada"; +"Translate.Language.Kazakh" = "Kazakh"; +"Translate.Language.Khmer" = "Khmer"; +"Translate.Language.Kinyarwanda" = "Kinyarwanda"; +"Translate.Language.Korean" = "Korean"; +"Translate.Language.Kurdish" = "Kurdish"; +"Translate.Language.Kyrgyz" = "Kyrgyz"; +"Translate.Language.Lao" = "Lao"; +"Translate.Language.Latvian" = "Latvian"; +"Translate.Language.Lithuanian" = "Lithuanian"; +"Translate.Language.Luxembourgish" = "Luxembourgish"; +"Translate.Language.Macedonian" = "Macedonian"; +"Translate.Language.Malagasy" = "Malagasy"; +"Translate.Language.Malay" = "Malay"; +"Translate.Language.Malayalam" = "Malayalam"; +"Translate.Language.Maltese" = "Maltese"; +"Translate.Language.Maori" = "Maori"; +"Translate.Language.Marathi" = "Marathi"; +"Translate.Language.Mongolian" = "Mongolian"; +"Translate.Language.Myanmar_Burmese" = "Myanmar (Burmese)"; +"Translate.Language.Nepali" = "Nepali"; +"Translate.Language.Norwegian" = "Norwegian"; +"Translate.Language.Nyanja_Chichewa" = "Nyanja (Chichewa)"; +"Translate.Language.Odia_Oriya" = "Odia (Oriya)"; +"Translate.Language.Pashto" = "Pashto"; +"Translate.Language.Persian" = "Persian"; +"Translate.Language.Polish" = "Polish"; +"Translate.Language.Portuguese_Portugal_Brazil" = "Portuguese (Portugal, Brazil)"; +"Translate.Language.Punjabi" = "Punjabi"; +"Translate.Language.Romanian" = "Romanian"; +"Translate.Language.Russian" = "Russian"; +"Translate.Language.Samoan" = "Samoan"; +"Translate.Language.Scots_Gaelic" = "Scots Gaelic"; +"Translate.Language.Serbian" = "Serbian"; +"Translate.Language.Sesotho" = "Sesotho"; +"Translate.Language.Shona" = "Shona"; +"Translate.Language.Sindhi" = "Sindhi"; +"Translate.Language.Sinhala_Sinhalese" = "Sinhala (Sinhalese)"; +"Translate.Language.Slovak" = "Slovak"; +"Translate.Language.Slovenian" = "Slovenian"; +"Translate.Language.Somali" = "Somali"; +"Translate.Language.Spanish" = "Spanish"; +"Translate.Language.Sundanese" = "Sundanese"; +"Translate.Language.Swahili" = "Swahili"; +"Translate.Language.Swedish" = "Swedish"; +"Translate.Language.Tagalog_Filipino" = "Tagalog (Filipino)"; +"Translate.Language.Tajik" = "Tajik"; +"Translate.Language.Tamil" = "Tamil"; +"Translate.Language.Tatar" = "Tatar"; +"Translate.Language.Telugu" = "Telugu"; +"Translate.Language.Thai" = "Thai"; +"Translate.Language.Turkish" = "Turkish"; +"Translate.Language.Turkmen" = "Turkmen"; +"Translate.Language.Ukrainian" = "Ukrainian"; +"Translate.Language.Urdu" = "Urdu"; +"Translate.Language.Uyghur" = "Uyghur"; +"Translate.Language.Uzbek" = "Uzbek"; +"Translate.Language.Vietnamese" = "Vietnamese"; +"Translate.Language.Welsh" = "Welsh"; +"Translate.Language.Xhosa" = "Xhosa"; +"Translate.Language.Yiddish" = "Yiddish"; +"Translate.Language.Yoruba" = "Yoruba"; +"Translate.Language.Zulu" = "Zulu"; +"Translate.Language.ar" = "Arabic"; +"Translate.Language.auto" = "Auto"; +"Translate.Language.de" = "German"; +"Translate.Language.en" = "English"; +"Translate.Language.es" = "Spanish"; +"Translate.Language.fr" = "French"; +"Translate.Language.it" = "Italian"; +"Translate.Language.jp" = "Japanese"; +"Translate.Language.ko" = "Korean"; +"Translate.Language.pt" = "Portuguese"; +"Translate.Language.ru" = "Russian"; +"Translate.Language.zh" = "Mandarin Chinese"; +"Translate.Original" = "Original"; +"Translate.Result" = "Result"; +"Translate.SelectStyle" = "Select a style"; +"Translate.ShowMore" = "more"; +"Translate.TabFix" = "Fix"; +"Translate.TabStyle" = "Style"; +"Translate.TabTranslate" = "Translate"; +"Translate.Title" = "Translate"; +"Translate.To" = "TO: %@"; +"Translate.Tone" = "Tone"; +"Translate.TranslateEntireChat" = "Translate Entire Chat"; +"TranslateModal.Create" = "Create"; +"TranslateModal.DeleteStyle" = "Delete Style"; +"TranslateModal.DeleteStyleConfirm" = "Are you sure you want to delete this style?"; +"TranslateModal.DeleteStyleForAll" = "Delete for All"; +"TranslateModal.DeleteStyleHeader" = "Delete Style"; +"TranslateModal.EditStyle" = "Edit Style"; +"TranslateModal.ShareStyle" = "Share Style"; +"TranslateModal.StyleByAuthor" = "Style by [@%1$@](https://t.me/%1$@)"; +"TwoStep.AuthEmailSkip" = "Skip"; +"TwoStepAuth.AnError" = "An error occurred. Please try again later."; +"TwoStepAuth.CancelReset" = "Cancel Reset"; +"TwoStepAuth.CancelReset.Confirm" = "Cancel Reset"; +"TwoStepAuth.CancelReset.Text" = "Cancel the password resetting process? If you proceed, the expired part of the 7-day delay will be lost."; +"TwoStepAuth.ChangeEmail" = "Change Recovery Email"; +"TwoStepAuth.ChangePassword" = "Change Password"; +"TwoStepAuth.ChangePasswordDesc" = "Please enter a new password which will be used to protect your data."; +"TwoStepAuth.Confirm.DisablePassword" = "Are you sure you want to disable your password?"; +"TwoStepAuth.ConfirmEmailCodeDesc" = "Please enter the code we've just emailed to %@."; +"TwoStepAuth.ConfirmationAbort" = "Cancel Two-Step Verification Setup"; +"TwoStepAuth.ConfirmationText" = "Please check your email and click on the validation link to complete Two-Step Verification setup. Be sure to check the spam folder as well."; +"TwoStepAuth.ConfirmationTextNew" = "Please check your email and enter the confirmation code to complete Two-Step Verification setup. Be sure to check the spam folder as well."; +"TwoStepAuth.Email" = "Email"; +"TwoStepAuth.EmailCodeExpired" = "This confirmation code has expired. Please try again."; +"TwoStepAuth.EmailCodeInvalid" = "You have entered an invalid code. Please try again."; +"TwoStepAuth.EmailHelp" = "Please add your valid email. It is the only way to recover a forgotten password."; +"TwoStepAuth.EmailHelpChange" = "Please enter your new recovery email. It is the only way to recover a forgotten password."; +"TwoStepAuth.EmailInvalid" = "Invalid email address, please try again."; +"TwoStepAuth.EmailSent" = "We have sent you an email to confirm your address."; +"TwoStepAuth.EmailSkipAlert" = "No, seriously.\n\nIf you forget your password, you will lose access to your Telegram account. There will be no way to restore it."; +"TwoStepAuth.EnterEmailCode" = "Enter Code"; +"TwoStepAuth.EnterPasswordForgot" = "Forgot password?"; +"TwoStepAuth.EnterPasswordHelp" = "You have Two-Step Verification enabled, so your account is protected with an additional password."; +"TwoStepAuth.EnterPasswordHint" = "Hint: %@"; +"TwoStepAuth.EnterPasswordPassword" = "Password"; +"TwoStepAuth.Error.Generic" = "An error occurred. Please try again later."; +"TwoStepAuth.Error.HaventEmail" = "Since you didn't provide a recovery email when setting up your password, your remaining options are either to remember your password or to reset your account."; +"TwoStepAuth.Error.HaventEmail.Reset" = "Reset"; +"TwoStepAuth.Error.HaventEmail.ResetHeader" = "Reset Password"; +"TwoStepAuth.Error.HaventEmailNew" = "Since you didn’t provide a recovery email when setting up your password, your remaining options are either to remember your password or wait 7 days until your password is reset."; +"TwoStepAuth.Error.InvalidEmail" = "Please enter a valid email address."; +"TwoStepAuth.Error.LimitExceeded" = "You have entered an invalid password too many times. Please try again later."; +"TwoStepAuth.Error.PasswordsDontMatch" = "Passwords don't match.\nPlease try again."; +"TwoStepAuth.FloodError" = "Too many attempts, please try again later."; +"TwoStepAuth.GenericError" = "An error occurred. Please try again later."; +"TwoStepAuth.GenericHelp" = "You have enabled Two-Step verification.\nYou'll need the password you set up here to log in to your Telegram account."; +"TwoStepAuth.InvalidPasswordError" = "Invalid password. Please try again."; +"TwoStepAuth.PasswordTitle" = "Password"; +"TwoStepAuth.PendingEmailHelp" = "Your recovery email %@ is not yet active and pending confirmation."; +"TwoStepAuth.RecoveryCode" = "Code"; +"TwoStepAuth.RecoveryCodeExpired" = "Code Expired"; +"TwoStepAuth.RecoveryCodeHelp" = "Please check your email and enter the 6-digit code we've sent there to deactivate your cloud password."; +"TwoStepAuth.RecoveryCodeInvalid" = "Invalid code. Please try again."; +"TwoStepAuth.RecoveryEmailResetText" = "If you don't have access to your recovery email, your remaining options are either to remember your password or wait 7 days until your password resets."; +"TwoStepAuth.RecoveryEmailUnavailable" = "Having trouble accessing your email %@?"; +"TwoStepAuth.RecoveryEmailUnavailableNew" = "Unable to access your email\n[%@]()?"; +"TwoStepAuth.RecoveryFailed" = "Your remaining options are either to remember your password or to reset your account."; +"TwoStepAuth.RecoverySent" = "We have sent a recovery code to the email you provided:\n\n%@"; +"TwoStepAuth.RecoveryTitle" = "Email Code"; +"TwoStepAuth.RecoveryUnavailable" = "Since you didn't provide a recovery email when setting up your password, your remaining options are either to remember your password or to reset your account."; +"TwoStepAuth.RemovePassword" = "Disable Password"; +"TwoStepAuth.Reset" = "Reset Password"; +"TwoStepAuth.ResetDescription" = "Since the account **%@** is active and protected by a password, it will be deleted in 1 week. This delay is required for security purposes.\n\nYou can cancel this process anytime.\n\nYou'll be able to reset your account in:\n%@"; +"TwoStepAuth.ResetPending" = "You can reset your password in %@."; +"TwoStepAuth.ResetSuccess" = "You have successfully reset your password. Do you want to create a new one?"; +"TwoStepAuth.ResetSuccess.Header" = "Reset Password"; +"TwoStepAuth.SetPassword" = "Set Additional Password"; +"TwoStepAuth.SetPasswordHelp" = "You can set a password that will be required when you log in on a new device in addition to the code you get via SMS."; +"TwoStepAuth.SetupEmail" = "Set Recovery Email"; +"TwoStepAuth.SetupEmailTitle" = "Recovery Email"; +"TwoStepAuth.SetupHint" = "Please create a hint for your password"; +"TwoStepAuth.SetupHintDesc" = "You can create an optional hint for your password."; +"TwoStepAuth.SetupHintPlaceholder" = "Hint"; +"TwoStepAuth.SetupHintTitle" = "Password Hint"; +"TwoStepAuth.SetupPasswordConfirmFailed" = "Passwords don't match. Please try again."; +"TwoStepAuth.SetupPasswordConfirmPassword" = "Please re-enter your password"; +"TwoStepAuth.SetupPasswordConfirmPasswordDesc" = "Please confirm your password."; +"TwoStepAuth.SetupPasswordDesc" = "Please create a password which will be used to protect your data."; +"TwoStepAuth.SetupPasswordEnterPassword" = "Enter your cloud password"; +"TwoStepAuth.SetupPasswordEnterPasswordNew" = "Enter your new password"; +"TwoStepAuth.SetupPasswordTitle" = "Your Password"; +"TwoStepAuth.UnableToReset" = "Unable to reset password, please try again at %@"; +"UEZ-Bs-lqG.title" = "Capitalize"; +"URL.Link.OnlyAllowed" = "Only %@ links are allowed"; +"UnknownError" = "An error occurred, please try again later."; +"Update.UpdateTelegram" = "Update Telegram"; +"UpdateApp.TelegramUpdate" = "Telegram Update"; +"UpdateApp.UpdateTelegram" = "Update Telegram"; +"Upgrade.ChannelsTooMuch" = "Sorry, you are a member of too many groups and channels. For technical reasons, you need to leave some first before changing this setting in your groups."; +"UserInfo.ManagedBotCreatedBy" = "Created and managed by "; +"UserInfo.ResetPhoto" = "Reset to Original Photo"; +"UserInfo.ResetPhoto.Confirm" = "Are you sure you want to reset to %@'s original photo?"; +"UserInfo.ResetPhoto.Confirm.OK" = "Reset"; +"UserInfo.SetByYou" = "photo set by you"; +"UserInfo.SetPhoto" = "Set Photo for %@"; +"UserInfo.SetPhoto.BlockInfo" = "You can replace %@'s photo with another photo that only you will see."; +"UserInfo.SetPhoto.Confirm" = "Do you want to set this photo for %@? It will replace any photo %@ sets — only you will see it."; +"UserInfo.SetPhoto.Confirm.OK" = "SET"; +"UserInfo.SetPhoto.Info" = "Do you want to suggest a profile photo for %@?"; +"UserInfo.SetPhoto.Title" = "Set Photo"; +"UserInfo.SetPhoto.Tooltip" = "You will now always see this photo for %@'s account."; +"UserInfo.Suggest.Confirm" = "Do you want to suggest this photo for %@'s Telegram profile?"; +"UserInfo.Suggest.Confirm.OK" = "SUGGEST"; +"UserInfo.Suggest.Title" = "Suggest Photo"; +"UserInfo.Suggest.Tooltip" = "You suggested a new photo for %@'s Telegram profile"; +"UserInfo.SuggestPhoto" = "Suggest Photo for %@"; +"UserTag.Info.Admin" = "Admin"; +"UserTag.Info.AdminTag" = "Admin Tag"; +"UserTag.Info.AdminTagDesc" = " is %1$@'s admin tag in %2$@."; +"UserTag.Info.CustomTagDesc" = " is %1$@'s custom tag in %2$@."; +"UserTag.Info.Member" = "Member"; +"UserTag.Info.MemberTag" = "Member Tag"; +"UserTag.Info.Owner" = "Owner"; +"UserTag.Info.OwnerTag" = "Owner Tag"; +"UserTag.Info.OwnerTagDesc" = " is %1$@'s owner tag in %2$@."; +"UserTag.Info.SetOwnTag" = "You can set your own member tag in this group."; +"UserTag.Info.TheTag" = "The tag "; +"UserTag.Info.ThisGreenTag" = "This green tag "; +"UserTag.Info.ThisRedTag" = "This purple tag "; +"UserTag.Info.Understood" = "Understood"; +"Username.Activate.Info" = "Do you want to show this link on your profile page?"; +"Username.Activate.Ok" = "Show"; +"Username.Activate.Title" = "Activate Username"; +"Username.Active" = "active"; +"Username.Deactivate.Info" = "Do you want to hide this link from your profile page?"; +"Username.Deactivate.Ok" = "Hide"; +"Username.Deactivate.Title" = "Deactivate Username"; +"Username.List.Info" = "Drag and drop links to change order in which they will be displayed on your info page."; +"Username.List.Title" = "USERNAME ORDER"; +"Username.NotActive" = "inactive"; +"Username.UsernamePurchaseAvailable" = "This username is already taken. However, it is currently available for purchase. [Learn more...](fragment.com)"; +"UsernameSettings.ChangeDescription" = "You can choose a username on Telegram. If you do, people will be able to find you by this username and contact you without needing your phone number.\n\n\nYou can use a-z, 0-9 and underscores. Minimum length is 5 characters."; +"UsernameSettings.Done" = "Done"; +"UsernameSettings.InputPlaceholder" = "Enter your username"; +"UsernameSettings.available" = "%@ is available"; +"Vdr-fp-XzO.title" = "Hide Others"; +"VerifyAge.Alert.Failed.Header" = "Age check failed"; +"VerifyAge.Alert.Failed.Info" = "Sorry, you can't view sensitive content."; +"VerifyAge.Alert.Passed.Header" = "Age check passed!"; +"VerifyAge.Alert.Passed.Info" = "You can now view sensitive content."; +"Video.Quality.Auto" = "Auto"; +"VideoAvatar.Button.Cancel" = "Cancel"; +"VideoAvatar.Button.Set" = "Set"; +"VideoAvatar.ChooseDesc.Channel" = "Choose a cover for channel photo"; +"VideoAvatar.ChooseDesc.Group" = "Choose a cover for group photo"; +"VideoAvatar.ChooseDesc.Profile" = "Choose a frame for your profile photo."; +"VoiceChat.Alert.Recording" = "The audio stream is being recorded."; +"VoiceChat.AnonymousDisabledAlertText" = "Sorry, you can't join video chats as an anonymous admin."; +"VoiceChat.Block.Listening" = "listening"; +"VoiceChat.Block.RecentActive" = "recently active"; +"VoiceChat.Chat.Ended" = "Video chat ended."; +"VoiceChat.Chat.StartNew" = "Video chat ended. Start a new one?"; +"VoiceChat.Chat.StartNew.OK" = "Start"; +"VoiceChat.ChatFullAlertText" = "Sorry, this video chat has too many participants at the moment."; +"VoiceChat.ClickToRaiseHand" = "click if you want to speak"; +"VoiceChat.ClickToUnmute" = "Click to Unmute"; +"VoiceChat.ClickToUnmute.Secondary.Hold" = "hold ⎵ or %@"; +"VoiceChat.ClickToUnmute.Secondary.HoldDefault" = "hold ⎵"; +"VoiceChat.ClickToUnmute.Secondary.Press" = "press ⎵ or %@"; +"VoiceChat.ClickToUnmute.Secondary.PressDefault" = "press ⎵"; +"VoiceChat.Conference.DiscardInvite" = "Discard Invite"; +"VoiceChat.Conference.StopCalling" = "Stop Calling"; +"VoiceChat.Connecting" = "Connecting..."; +"VoiceChat.DownHand" = "Cancel request to speak"; +"VoiceChat.End.OK" = "Leave"; +"VoiceChat.End.Text" = "Are you sure you want to leave this video chat?"; +"VoiceChat.End.Third" = "End Video Chat"; +"VoiceChat.End.Title" = "Leave video chat"; +"VoiceChat.GroupMembers" = "group members"; +"VoiceChat.Info.JoinChannel" = "Join Channel"; +"VoiceChat.Info.LeaveChannel" = "Leave Channel"; +"VoiceChat.Info.OpenChannel" = "Open Channel"; +"VoiceChat.Info.OpenProfile" = "Open Profile"; +"VoiceChat.Info.SendMessage" = "Send Message"; +"VoiceChat.Invite.AddMember" = "Add Member"; +"VoiceChat.Invite.Channels.Title" = "Invite Members"; +"VoiceChat.Invite.Chats" = "CHATS"; +"VoiceChat.Invite.Conference.Title" = "Invite Members"; +"VoiceChat.Invite.Confirm.Header" = "Video Chat"; +"VoiceChat.Invite.Confirm.OK" = "Send"; +"VoiceChat.Invite.Confirm.Text" = "Send the Invite Link to selected chats?"; +"VoiceChat.Invite.Confirm.Third" = "Send Speaker Link"; +"VoiceChat.Invite.Contacts" = "contacts"; +"VoiceChat.Invite.CopyInviteLink" = "Copy Invite Link"; +"VoiceChat.Invite.CopyListenersLink" = "Copy Listener Link"; +"VoiceChat.Invite.CopySpeakersLink" = "Copy Speaker Link"; +"VoiceChat.Invite.GlobalSearch" = "global search"; +"VoiceChat.Invite.GroupMembers" = "group members"; +"VoiceChat.Invite.Invite" = "Send"; +"VoiceChat.Invite.InviteMembers" = "Invite members"; +"VoiceChat.Invite.Title" = "Add Members"; +"VoiceChat.InviteMemberToGroupFirstAdd" = "Add"; +"VoiceChat.InviteMemberToGroupFirstText" = "%1$@ isn't a member of \"%2$@\". Add them to the group?"; +"VoiceChat.Join.Error.TooMany" = "Sorry, there are too many participants in this video chat. Please try again later."; +"VoiceChat.JoinAs.Channel#one" = "%d subscriber"; +"VoiceChat.JoinAs.Channel#other" = "%d subscribers"; +"VoiceChat.JoinAs.Channel_countable" = "%d"; +"VoiceChat.JoinAs.Group#one" = "%d participant"; +"VoiceChat.JoinAs.Group#other" = "%d participants"; +"VoiceChat.JoinAs.Group_countable" = "%d"; +"VoiceChat.Leave" = "Leave"; +"VoiceChat.LeaveCall" = "Leave"; +"VoiceChat.LeaveGrupCall" = "leave"; +"VoiceChat.ListenMode" = "You are in Listen Only mode"; +"VoiceChat.Listening" = "listening"; +"VoiceChat.MuteForMe" = "Mute For Me"; +"VoiceChat.MutePeer" = "Mute"; +"VoiceChat.MutedByAdmin" = "Muted By Admin"; +"VoiceChat.OpenProfile" = "Open Profile"; +"VoiceChat.PinScreencast" = "Pin Screencast"; +"VoiceChat.PinVideo" = "Pin Video"; +"VoiceChat.RTMP.Desc" = "Software encoding recommended (x264 in OBS)."; +"VoiceChat.RTMP.Error" = "Oops! Telegram doesn't see any stream coming from your streaming app. Please make sure you entered the right Server URL and Stream Key in your app."; +"VoiceChat.RTMP.Info" = "To stream video with another app, enter this Server URL and Stream Key in your streaming app."; +"VoiceChat.RTMP.OK" = "Start Streaming"; +"VoiceChat.RTMP.Revoke" = "Revoke Stream Key"; +"VoiceChat.RTMP.Revoke.Info" = "A new key will be generated. You will need to update it in your streaming app."; +"VoiceChat.RTMP.ServerURL" = "server URL"; +"VoiceChat.RTMP.StreamKey" = "stream key"; +"VoiceChat.RTMP.Title" = "Stream With..."; +"VoiceChat.RTMP.ViewerError" = "Telegram couldn’t detect any live stream from %@"; +"VoiceChat.RaisedHand.Text" = "we let the speakers know"; +"VoiceChat.RaisedHand.Title" = "You asked to speak"; +"VoiceChat.Recording.Start.OK" = "Start"; +"VoiceChat.Recording.Start.Text" = "Record this chat and save the result into an audio file?\n\nParticipants will see that the chat is being recorded."; +"VoiceChat.Recording.Start.Text1" = "Do you want to start recording this chat and save the result as a file?\n\nOther participants will see that the chat is being recorded."; +"VoiceChat.Recording.Start.Title" = "Start Recording"; +"VoiceChat.Recording.Stop.OK" = "Stop"; +"VoiceChat.Recording.Stop.Text" = "Stop recording the audio stream?"; +"VoiceChat.Recording.Stop.Title" = "Stop Recording"; +"VoiceChat.RemovePeer" = "Remove"; +"VoiceChat.RemovePeer.Confirm" = "Are you sure you want to remove %1$@ from the group?"; +"VoiceChat.RemovePeer.Confirm.Cancel" = "Cancel"; +"VoiceChat.RemovePeer.Confirm.Channel" = "Do you want to remove %1$@ from the channel?"; +"VoiceChat.RemovePeer.Confirm.OK" = "Remove"; +"VoiceChat.RemovePeerRemove" = "Remove"; +"VoiceChat.RemoveReminder" = "Cancel Reminder"; +"VoiceChat.RequestAccess" = "Telegram needs access to your microphone so that you can speak."; +"VoiceChat.Scheduled.Header" = "Starts In"; +"VoiceChat.Scheduled.HeaderLate" = "Late by"; +"VoiceChat.ScreenShare.Unavailable" = "Unable to share your screen, please grant access in [System Settings](screen)."; +"VoiceChat.Screencast.Confirm.Header" = "Video Chat"; +"VoiceChat.Screencast.Confirm.OK" = "Continue"; +"VoiceChat.Screencast.Confirm.Text" = "%@ is sharing their screen. This action will pin your screen for all participants instead."; +"VoiceChat.Screencast.Paused" = "Screen Sharing is Paused"; +"VoiceChat.SetReminder" = "Set Reminder"; +"VoiceChat.Settings" = "Settings"; +"VoiceChat.Settings.AllMembers" = "New participants can speak"; +"VoiceChat.Settings.DisplayAs.PersonalAccount" = "personal account"; +"VoiceChat.Settings.DisplayAs.Title" = "DISPLAY ME AS"; +"VoiceChat.Settings.End" = "End Video Chat"; +"VoiceChat.Settings.End.Confirm" = "Are you sure you want to end this video chat?"; +"VoiceChat.Settings.End.Confirm.OK" = "End"; +"VoiceChat.Settings.End.Confirm.Title" = "End video chat"; +"VoiceChat.Settings.InputMode" = "PUSH-TO-TALK MODE"; +"VoiceChat.Settings.InputMode.Always" = "Press and Release"; +"VoiceChat.Settings.InputMode.PushToTalk" = "Press and Hold"; +"VoiceChat.Settings.InputMode.SoundEffects" = "Sound Effects"; +"VoiceChat.Settings.NoiseSuppression" = "Noise Suppression"; +"VoiceChat.Settings.NoiseText" = "Noise Suppression"; +"VoiceChat.Settings.NoiseTitle" = "BACKGROUND NOISE"; +"VoiceChat.Settings.OnlyAdmins" = "New participants are muted"; +"VoiceChat.Settings.Output" = "OUTPUT"; +"VoiceChat.Settings.Output.Device" = "Output Device"; +"VoiceChat.Settings.Perfomance.Header" = "PERFORMANCE"; +"VoiceChat.Settings.Performance.Desc" = "Disabling noise suppression can increase performance."; +"VoiceChat.Settings.Performance.Header" = "PERFORMANCE"; +"VoiceChat.Settings.Permissions.Title" = "PERMISSIONS"; +"VoiceChat.Settings.PushToTalk" = "SHORTCUT"; +"VoiceChat.Settings.PushToTalk.Access" = "If you want this shortcut to work even when Telegram is not in focus\nPlease grant Telegram access to [Input Monitor](input)"; +"VoiceChat.Settings.PushToTalk.Access.Old" = "Please allow Accessibility for Telegram in [Privacy Settings.](access)\n\nYou may need to restart the app."; +"VoiceChat.Settings.PushToTalk.Desc" = "When the Video Chat window is in focus, you can also use ⎵ regardless of this setting."; +"VoiceChat.Settings.PushToTalk.EditKeybind" = "Change Key"; +"VoiceChat.Settings.PushToTalk.Enabled" = "Push to Talk"; +"VoiceChat.Settings.PushToTalk.StopRecording" = "Cancel"; +"VoiceChat.Settings.PushToTalk.Title" = "MIC CONTROL"; +"VoiceChat.Settings.PushToTalk.Undefined" = "Undefined"; +"VoiceChat.Settings.RTMP" = "STREAM DATA"; +"VoiceChat.Settings.Record.IncludeVideo" = "Include Video"; +"VoiceChat.Settings.Record.Orientation.Landscape" = "Landscape"; +"VoiceChat.Settings.Record.Orientation.Portrait" = "Portrait"; +"VoiceChat.Settings.ReduceMotion" = "Reduce Motion"; +"VoiceChat.Settings.ResetLink" = "Revoke Speaker Link"; +"VoiceChat.Settings.ResetLink.Success" = "Speaker Link has been revoked."; +"VoiceChat.Settings.Title" = "Video Chat Settings"; +"VoiceChat.Settings.Title.Placeholder" = "Title..."; +"VoiceChat.ShareInviteLink" = "Share Invite Link"; +"VoiceChat.ShareScreen.MutedError" = "You can't share your screen right now. Please ask to speak first."; +"VoiceChat.ShareVideo.MutedError" = "You can't share your video right now. Please ask to speak first."; +"VoiceChat.Sharing.Placeholder" = "You are sharing your screen"; +"VoiceChat.Sharing.Stop" = "Stop"; +"VoiceChat.ShowInfo" = "Show Info"; +"VoiceChat.Speaking" = "speaking"; +"VoiceChat.StartNow" = "Start Now"; +"VoiceChat.StartRecording" = "Start Recording"; +"VoiceChat.Status.Calling" = "calling..."; +"VoiceChat.Status.Connecting" = "Connecting..."; +"VoiceChat.Status.Invited" = "invited"; +"VoiceChat.Status.Listening" = "listening"; +"VoiceChat.Status.Loading" = "connecting..."; +"VoiceChat.Status.Members#one" = "%d participant"; +"VoiceChat.Status.Members#other" = "%d participants"; +"VoiceChat.Status.Members_ZeroValueHolder" = "no participants"; +"VoiceChat.Status.Members_countable" = "%d"; +"VoiceChat.Status.Muted" = "muted"; +"VoiceChat.Status.MutedForYou" = "muted for you"; +"VoiceChat.Status.Screensharing" = "sharing screen"; +"VoiceChat.Status.Speaking" = "speaking"; +"VoiceChat.Status.Stream" = "Live Stream"; +"VoiceChat.Status.Viewers#one" = "%d viewer"; +"VoiceChat.Status.Viewers#other" = "%d viewers"; +"VoiceChat.Status.Viewers_ZeroValueHolder" = "no viewers"; +"VoiceChat.Status.Viewers_countable" = "%d"; +"VoiceChat.Status.WantsSpeak" = "wants to speak"; +"VoiceChat.Status.You" = "This is you"; +"VoiceChat.StopRecording" = "Stop Recording"; +"VoiceChat.Title.End" = "Leave"; +"VoiceChat.Title.InviteMembers" = "Invite Members"; +"VoiceChat.Title.Invited" = "invited"; +"VoiceChat.Title.Scheduled" = "Video Chat"; +"VoiceChat.Title.Scheduled.Soon" = "scheduled"; +"VoiceChat.Toast.Stop" = "Recording of the audio stream added to your Saved Messages."; +"VoiceChat.Toast.YouCanSpeak" = "You can now speak."; +"VoiceChat.Tooltip.EnableCamera" = "Your camera is off. Click here to turn on camera."; +"VoiceChat.Tooltip.EnableMicro" = "You are on mute. Click here to speak."; +"VoiceChat.Tooltip.Error.ScreenUnavailable" = "Screen sharing and video is currently available for the first %d participants."; +"VoiceChat.Tooltip.Error.VideoUnavailable" = "Video and screen sharing is currently only available for the first %d participants."; +"VoiceChat.Tooltip.IsSpeaking" = "**%@** is speaking"; +"VoiceChat.Tooltip.NoCameraFound" = "No active and connected camera was found."; +"VoiceChat.Tooltip.PinWindow" = "Window is pinned."; +"VoiceChat.Tooltip.ScreenPinned" = "%@'s screencast is pinned"; +"VoiceChat.Tooltip.ScreenUnpinned" = "%@'s screencast is unpinned"; +"VoiceChat.Tooltip.ScreencastFailed" = "An error occurred. Screen sharing was stopped."; +"VoiceChat.Tooltip.ShareScreen" = "Your are sharing your screen."; +"VoiceChat.Tooltip.ShareVideo" = "You are sharing your video."; +"VoiceChat.Tooltip.StopScreen" = "You have stopped broadcasting your screen."; +"VoiceChat.Tooltip.StopVideo" = "You have stopped broadcasting video."; +"VoiceChat.Tooltip.Subscribe" = "We will notify you when it starts."; +"VoiceChat.Tooltip.UnpinWindow" = "Window is unpinned."; +"VoiceChat.Tooltip.VideoFailed" = "An error occurred. Video stream was stopped."; +"VoiceChat.Tooltip.VideoPinned" = "%@'s video is pinned"; +"VoiceChat.Tooltip.VideoUnpinned" = "%@'s video is unpinned"; +"VoiceChat.Tooltip.YourScreenPinned" = "Your screencast is pinned"; +"VoiceChat.Tooltip.YourScreenUnpinned" = "Your screencast is unpinned"; +"VoiceChat.Tooltip.YourVideoPinned" = "Your video is pinned"; +"VoiceChat.Tooltip.YourVideoUnpinned" = "Your video is unpinned"; +"VoiceChat.UnmuteForMe" = "Unmute For Me"; +"VoiceChat.UnmutePeer" = "Allow To Speak"; +"VoiceChat.UnpinScreencast" = "Unpin Screencast"; +"VoiceChat.UnpinVideo" = "Unpin Video"; +"VoiceChat.UserInvited" = "You invited **%@** to the video chat"; +"VoiceChat.Video.Paused" = "Video is Paused"; +"VoiceChat.Video.ShortPin" = "Pin"; +"VoiceChat.Video.ShortUnpin" = "Unpin"; +"VoiceChat.Video.Stream" = "stream"; +"VoiceChat.Video.Stream.Message" = "message"; +"VoiceChat.Video.Stream.More" = "more"; +"VoiceChat.Video.Stream.Screencast" = "screen"; +"VoiceChat.Video.Stream.Share" = "share"; +"VoiceChat.Video.Stream.Video" = "video"; +"VoiceChat.Video.VideoSource" = "Video Source"; +"VoiceChat.Video.VideoSource.Cancel" = "Cancel"; +"VoiceChat.Video.VideoSource.Share" = "Share"; +"VoiceChat.VideoShare.Unavailable" = "Unable to share your camera, please grant access in [System Settings](camera)."; +"VoiceChat.YouLive" = "You're Live"; +"VoiecChat.ReduceMotion.Desc" = "You can reduce motion for better performance."; +"VoiecChat.ReduceMotion.Header" = "REDUCE MOTION"; +"VoiecChat.ReduceMotion.Text" = "Reduce Motion"; +"VoiecChat.Settings.Record.Live.Title" = "RECORD LIVE STREAM"; +"VoiecChat.Settings.Record.Placeholder" = "Audio Title (Optional)"; +"VoiecChat.Settings.Record.Placeholder1" = "Title (Optional)"; +"VoiecChat.Settings.Record.Title" = "RECORD VIDEO CHAT"; +"VoiecChat.Settings.Record.Video.Title" = "RECORD VIDEO CHAT"; +"VoiecChat.Settings.ReduceMotion.Desc" = "Reducing motion and disabling noise suppression can increase performance."; +"W48-6f-4Dl.title" = "Edit"; +"Wallpaper.Background.NoApplyToPeer" = "%1$@ will be able to apply this wallpaper"; +"WallpaperPreview.Apply" = "Apply"; +"WallpaperPreview.Blurred" = "Blurred"; +"WallpaperPreview.DoesntExists" = "Sorry, this wallpaper doesn't seem to exist."; +"WallpaperPreview.Header" = "Wallpaper Preview"; +"WallpaperPreview.Plain" = "Plain"; +"WeT-3V-zwk.title" = "Paste and Match Style"; +"WebApp.Access.Allow" = "Allow"; +"WebApp.Access.Audio" = "Are you sure you want to grant %@ access to your microphone?"; +"WebApp.Access.AudioVideo" = "Are you sure you want to grant %@ access to your microphone and camera?"; +"WebApp.Access.Deny" = "Deny"; +"WebApp.Access.Video" = "Are you sure you want to grant %@ access to your camera?"; +"WebApp.AddToAttachmentAllowMessages" = "Allow %@ to send me messages"; +"WebApp.Attach.Confirm" = "\"%@\" asks your permission to be added as an option to your attachment menu so you access it from any chat."; +"WebApp.Attach.Confirm.OK" = "Add"; +"WebApp.Attach.DoenstExist" = "Sorry, %@ doesn't seem to exist."; +"WebApp.Attach.NotSupport" = "Sorry, %@ can't be used from the attachment menu."; +"WebApp.Attach.Success" = "\"%@\" added to your attachment menu."; +"WebApp.AttachRemove.Success" = "\"%@\" removed from your attachment menu."; +"WebApp.Biometry.Confirm.FaceId" = "Do you want to allow %@ to use Touch ID?"; +"WebApp.Biometry.Confirm.TouchId" = "Do you want to allow %@ to use Touch ID?"; +"WebApp.Close" = "Close"; +"WebApp.Confirm.OpenWebapp" = "Are you sure you want to open %@ in Telegram?"; +"WebApp.ExitFullscreen" = "Exit Fullscreen"; +"WebApp.FirstOpen.AllowWrite" = "Allow Messaging"; +"WebApp.FirstOpen.Info" = "%@ would like to open its web app to proceed.\n\nIt will be able to access your IP address and basic device info."; +"WebApp.FirstOpen.Terms" = "By launching this mini app, you agree to the [Terms of Service for Mini Apps.](%@)"; +"WebApp.FirstOpen.Title" = "Launch App"; +"WebApp.Fullscreen" = "Fullscreen"; +"WebApp.InstallBot" = "Install Bot"; +"WebApp.LocationPermission.Allow" = "Allow"; +"WebApp.LocationPermission.Decline" = "Decline"; +"WebApp.LocationPermission.DeniedError" = "Permission Denied"; +"WebApp.LocationPermission.DisabledError" = "Location service is not available"; +"WebApp.LocationPermission.Succeed" = "**%@** can now access your location."; +"WebApp.LocationPermission.Text" = "**%1$@** requests access to your **location**. You will be able to revoke this access in the profile page of **%2$@**."; +"WebApp.LocationPermission.WifiError" = "Wi-Fi required for location service"; +"WebApp.QrIsNotSupported" = "QR Codes for bots are not supported on Desktop. Please use one of Telegram’s mobile apps."; +"WebApp.Reload" = "Reload Page"; +"WebApp.RemoveBot" = "Remove From Menu"; +"WebApp.Settings" = "Settings"; +"WebApp.Title" = "Webpage"; +"WebApp.TransferData.Import" = "Import"; +"WebApp.TransferData.ImportData" = "Import Data"; +"WebApp.TransferData.Transfered" = "Data successfully imported"; +"WebAuthorizations.Confirm.Revoke" = "Do you want to disconnect this website?"; +"WebAuthorizations.Confirm.RevokeAll" = "Are you sure you want to disconnect all websites?"; +"WebAuthorizations.LoggedIn.Descrpiption" = "CONNECTED WEBSITES"; +"WebAuthorizations.Logout" = "Disconnect"; +"WebAuthorizations.LogoutAll" = "Disconnect All Websites"; +"WebAuthorizations.LogoutAll.Description" = "You can log in on websites that support signing in with Telegram."; +"WebBot.Account.Desclaimer.Desc" = "**%@** shortcuts will be added in your attachment menu and Settings."; +"WebBot.Account.Disclaimer.OK" = "Continue"; +"WebBot.Account.Disclaimer.Text" = "You are about to use a mini app operated by an independent party **not affiliated with Telegram**. You must agree to the Terms of Use\u00a0for mini apps to continue."; +"WebBot.Account.Disclaimer.Third" = "I agree to the [Terms of Use](https://telegram.org/tos/mini-apps)"; +"WebBot.Account.Disclaimer.Title" = "Terms of Use"; +"WebBrowser.Error" = "**Something went wrong, sorry.**\nPlease try again later."; +"WebBrowser.InstantView" = "Instant View"; +"WebBrowser.OpenMainPage" = "Open Main Page"; +"Webapp.AllowMessages.OK" = "Allow"; +"Webapp.AllowMessages.Text" = "This will allow the bot %@ to message you on Telegram."; +"Webapp.AllowMessages.Title" = "Allow Sending Messages?"; +"Webapp.Document.Download" = "Download Document"; +"Webapp.Document.Download.Document" = "Document saved\n[Show In Finder]()"; +"Webapp.Document.Download.Image" = "Photo saved\n[Show In Finder]()"; +"Webapp.Document.Download.Info" = "%1$@ offers you to download %2$@."; +"Webapp.Document.Download.OK" = "Download"; +"Webapp.Document.Download.Video" = "Video saved\n[Show In Finder]()"; +"Webapp.EmojiStatus.Allowed" = "%@ can now set your emoji status anytime."; +"Webapp.EmojiStatus.NotExists" = "Emoji Status Not Found"; +"Webapp.EmojiStatus.Requested" = "**%1$@** requests access to set your **emoji status**. You can revoke this access anytime on the profile page of **%2$@**."; +"Webapp.EmojiStatus.Requested.Allow" = "Allow"; +"Webapp.EmojiStatus.Requested.Decline" = "Decline"; +"Webapp.SetEmoji.Header" = "Set Emoji Status"; +"Webapp.SetEmoji.Info" = "Do you want to set this emoji status suggested by %@?"; +"Webapp.ShareMessage.BotInfo" = "%@ mini app suggests you to send this message to a chat you select."; +"Webapp.ShareMessage.Preview" = "MESSAGE PREVIEW"; +"Webapp.ShareMessage.Share" = "Share Message"; +"Webpage.Confirm.Close" = "Changes that you made may not be saved."; +"Webpage.Confirm.Ok" = "Close Anyway"; +"Weekday.Friday" = "Friday"; +"Weekday.Monday" = "Monday"; +"Weekday.Saturday" = "Saturday"; +"Weekday.ShortFriday" = "Fri"; +"Weekday.ShortMonday" = "Mon"; +"Weekday.ShortSaturday" = "Sat"; +"Weekday.ShortSunday" = "Sun"; +"Weekday.ShortThursday" = "Thu"; +"Weekday.ShortTuesday" = "Tue"; +"Weekday.ShortWednesday" = "Wed"; +"Weekday.Sunday" = "Sunday"; +"Weekday.Thursday" = "Thursday"; +"Weekday.Tuesday" = "Tuesday"; +"Weekday.Wednesday" = "Wednesday"; +"Widged.Stickers.InfoText" = "More trending stickers are available in\nSettings > Stickers > [Trending Stickers](trending)."; +"Widged.Storage.InfoText" = "More data and storage settings are available in\nSettings > [Data And Storage](storage)."; +"Widget.Recent.Desc" = "Use ⌘+K or ESC to enter [search](search) mode."; +"Widget.Recent.Mixed" = "Both"; +"Widget.Recent.Popular" = "Popular"; +"Widget.Recent.Recent" = "Recent"; +"Widget.Recent.Title" = "Chats"; +"You" = "You"; +"Your_card_has_expired" = "Your card has expired."; +"Your_card_was_declined" = "Your card was declined."; +"Your_cards_expiration_month_is_invalid" = "You've entered an invalid expiration month."; +"Your_cards_expiration_year_is_invalid" = "You've entered an invalid expiration year."; +"Your_cards_number_is_invalid" = "You've entered an invalid card number."; +"Your_cards_security_code_is_invalid" = "You've entered an invalid security code."; +"Your_cards_zip_code_is_invalid" = "You've entered an invalid zip code."; +"_NS103.title" = "Edit"; +"_NS167.title" = "Window"; +"_NS70.title" = "View"; +"_NS81.title" = "View"; +"_NS88.title" = "Edit"; +"_NS:103.title" = "Edit"; +"_NS:104.title" = "Edit"; +"_NS:163.title" = "Window"; +"_NS:167.title" = "Window"; +"_NS:168.title" = "Window"; +"_NS:77.title" = "View"; +"_NS:81.title" = "View"; +"_NS:82.title" = "View"; +"_NS:99.title" = "Edit"; +"aMa-rb-kjV.title" = "Global Search"; +"aUF-d1-5bR.title" = "Window"; +"bullet" = "•"; +"c8a-y6-VQd.title" = "Formatting"; +"cwL-P1-jid.title" = "Smart Links"; +"d9M-CD-aMd.title" = "Make Lower Case"; +"dRJ-4n-Yzg.title" = "Undo"; +"gVA-U4-sdL.title" = "Paste"; +"hQb-2v-fYv.title" = "Smart Quotes"; +"hz2-CU-CR7.title" = "Check Document Now"; +"mK6-2p-4JG.title" = "Check Grammar With Spelling"; +"pa3-QI-u2k.title" = "Delete"; +"peerInfo.Confirm.ClearHistory.Group" = "This will delete all messages and media in this chat from your Telegram cloud. Other members of the group will still have them."; +"peerInfo.Confirm.ClearHistory.SavedMesssages" = "This will delete all messages and media in this chat from your Telegram cloud."; +"peerInfo.Confirm.ClearHistory.User" = "This will delete all messages and media in this chat from your Telegram cloud. Your chat partner will still have them."; +"peerInfo.Confirm.ClearHistory.UserBothSides" = "Are you sure you want to delete all messages in the chat?"; +"peerInfo.Confirm.Leave" = "Leave"; +"psa.chat.alert.learnmore" = "Learn More"; +"psa.chat.alert.learnmore.covid" = "Learn More"; +"psa.chat.alert.learnmore.psa" = "Learn More"; +"psa.chat.text.covid" = "This message provides you with a public service announcement in relation to the ongoing COVID-19 pandemic. Learn more about this initiative at https://telegram.org/blog/coronavirus"; +"psa.chat.text.psa" = "This is an official message from the CEO of Telegram regarding the ongoing conflict."; +"psa.chat.title" = "Public Service Announcement"; +"psa.chatlist" = "PSA"; +"psa.chatlist.covid" = "COVID-19"; +"psa.chatlist.psa" = "Announcement"; +"psa.text" = "This message provides you with a public service announcement in your chat list"; +"psa.text.covid" = "This message provides you with a public service announcement in relation to the ongoing COVID-19 pandemic. Learn more about this initiative at https://telegram.org/blog/coronavirus"; +"psa.text.psa" = "This is an official message from the CEO of Telegram regarding the ongoing conflict."; +"psa.title" = "PSA Notification"; +"psa.title.bubbles" = "PSA Notification\nfrom: [%@]()"; +"psa.title.bubbles.covid" = "COVID-19 Notification\nfrom: [%@]()"; +"psa.title.bubbles.psa" = "Announcement: [%@]()"; +"psa.title.bubbles_new" = "PSA Notification\nfrom: %@"; +"psa.title.covid" = "COVID-19 Notification"; +"psa.title.psa" = "Announcement"; +"rbD-Rh-wIN.title" = "Check Spelling While Typing"; +"rgM-f4-ycn.title" = "Smart Dashes"; +"sZh-ct-GQS.title" = "Quick Search"; +"tRr-pd-1PS.title" = "Data Detectors"; +"uQy-DD-JDr.title" = "Telegram"; +"uRl-iY-unG.title" = "Cut"; +"vmV-6d-7jI.title" = "Make Upper Case"; +"x3v-GG-iWU.title" = "Copy"; +"z6F-FW-3nz.title" = "Show Substitutions"; diff --git a/data/langpack/macos/macos_zh-hans_v2221980.strings b/data/langpack/macos/macos_zh-hans_v2221980.strings new file mode 100644 index 00000000..a09c889d --- /dev/null +++ b/data/langpack/macos/macos_zh-hans_v2221980.strings @@ -0,0 +1,8060 @@ +"1000.title" = "检查更新"; +"1Xt-HY-uBw.title" = "Telegram"; +"2oI-Rn-ZJC.title" = "转换"; +"4J7-dP-txa.title" = "进入全屏幕"; +"4sb-4s-VLi.title" = "退出 Telegram"; +"5QF-Oa-p0T.title" = "编辑"; +"5kV-Vb-QxS.title" = "关于 Telegram"; +"6dh-zS-Vam.title" = "重做"; +"78Y-hA-62v.title" = "自动纠正拼写"; +"9ic-FL-obx.title" = "替换"; +"9yt-4B-nSM.title" = "智能拷贝/粘贴"; +"AIStyleModal.AddAuthorLink" = "添加链接到我的账号"; +"AIStyleModal.Create" = "创建"; +"AIStyleModal.DeleteStyle" = "删除风格"; +"AIStyleModal.DeleteStyleConfirm" = "确定删除该样式吗?"; +"AIStyleModal.DeleteStyleForAll" = "为所有人删除"; +"AIStyleModal.DeleteStyleHeader" = "删除风格"; +"AIStyleModal.EditStyleTitle" = "编辑风格"; +"AIStyleModal.InstructionsPlaceholder" = "指令(例如:“像个传奇海盗一样写作。使用 arr、ye、matey 并谈论宝藏、大海和朗姆酒”)"; +"AIStyleModal.NamePlaceholder" = "风格名称(例如:“海盗”)"; +"AIStyleModal.NewStyleTitle" = "新建风格"; +"AIStyleModal.Save" = "保存"; +"AIStyleModal.SavedLimitPremium#other" = "您最多可以保存 %1$d 种风格。订阅 **Telegram 高级版** 即可将此上限增加到 %2$d 种。"; +"AIStyleModal.SavedLimitReached#other" = "您已达到 %1$d 种保存风格的上限。"; +"AISummary.ClickToSeeOriginal" = "点击查看原文"; +"AISummary.Title" = "AI 摘要"; +"About.Description" = "macOS 下基于 MTProto 的速度和安全性的免费聊天程序。"; +"AboutCocoon.Description" = "Cocoon (**Co**nfidential **Co**mpute **O**pen **N**etwork) 能安全且高效地处理 AI 任务。"; +"AboutCocoon.Feature.Efficient.Description" = "Cocoon 帮助 Telegram 将翻译成本降低了 5/6。"; +"AboutCocoon.Feature.Efficient.Title" = "高效"; +"AboutCocoon.Feature.ForEveryone.Description" = "任何开发者都能使用 Cocoon 实现 AI 功能。了解更多,请访问\u00a0[@cocoon](@cocoon) 或 [cocoon.org](cocoon.org)。"; +"AboutCocoon.Feature.ForEveryone.Title" = "人人都可以用"; +"AboutCocoon.Feature.Private.Description" = "任何第三方都无法访问 Cocoon 内的任何数据,包括翻译。"; +"AboutCocoon.Feature.Private.Title" = "隐私"; +"AboutCocoon.Footer" = "想在您的项目中集成 Cocoon 吗?\n请通过\u00a0 [t.me/cocoon](t.me/cocoon)\u00a0联系我们。"; +"AboutCocoon.Title" = "COCOON"; +"AboutFeature.DisableSharing.DisableButton" = "禁用分享"; +"AboutFeature.DisableSharing.NoForwarding" = "禁止转发"; +"AboutFeature.DisableSharing.NoForwardingDesc" = "禁用将消息转发到其他聊天。"; +"AboutFeature.DisableSharing.NoSaving" = "禁止保存"; +"AboutFeature.DisableSharing.NoSavingDesc" = "禁用复制文本及保存图片或视频。\u00a0"; +"AboutFeature.DisableSharing.NoScreenshots" = "禁止截屏"; +"AboutFeature.DisableSharing.NoScreenshotsDesc" = "禁用在此聊天中截屏与录屏。"; +"AboutFeature.DisableSharing.Title" = "禁用分享"; +"AboutFeature.DisableSharing.UnlockButton" = "通过 Telegram 高级版解锁"; +"AccentColors.Tinted" = "深色"; +"Account.Confirm.AskQuestion" = "请注意:Telegram 的技术支持由志愿者提供,反馈时请使用英文。我们会尽快回复您,但是仍可能需要等待一段时间。\n\n请前往“常见问题” 看看:那里有各种常见问题和故障排除指南。"; +"Account.Confirm.GoToFaq" = "打开常见问题"; +"Account.Confirm.Logout" = "您确定要登出吗?"; +"Account.Confirm.LogoutText" = "请注意,退出会清空您所有的私密聊天记录。"; +"Account.OpenInWindow" = "在窗口中打开"; +"AccountSettings.About" = "关于"; +"AccountSettings.AddAccount" = "添加帐号"; +"AccountSettings.AskQuestion" = "向我们提问"; +"AccountSettings.Bio" = "个人简介"; +"AccountSettings.ChangeStatus" = "更改表情符号状态"; +"AccountSettings.CurrentLanguage" = "简体中文"; +"AccountSettings.DataAndStorage" = "数据和存储"; +"AccountSettings.DeleteAccount" = "移除帐号"; +"AccountSettings.FAQ" = "Telegram 常见问题"; +"AccountSettings.Filters" = "聊天文件夹"; +"AccountSettings.General" = "通用"; +"AccountSettings.GiftPremium" = "赠送 Telegram 高级版"; +"AccountSettings.Language" = "语言"; +"AccountSettings.Logout" = "登出"; +"AccountSettings.MyProfile" = "我的资料"; +"AccountSettings.MyStories" = "我的动态"; +"AccountSettings.Notifications" = "通知"; +"AccountSettings.Passport" = "Telegram 通行证"; +"AccountSettings.Premium" = "Telegram 高级版"; +"AccountSettings.PrivacyAndSecurity" = "隐私和安全"; +"AccountSettings.ProtectAccount.Info" = "设置密码,每次使用此手机号码登录时都需要输入该密码。"; +"AccountSettings.ProtectAccount.Set" = "设置附加密码"; +"AccountSettings.ProtectAccount.Title" = "保护你的账户"; +"AccountSettings.Proxy" = "代理"; +"AccountSettings.Proxy.Connected" = "已连接"; +"AccountSettings.Proxy.Connecting" = "连接中"; +"AccountSettings.Proxy.Disabled" = "已停用"; +"AccountSettings.ReadArticles" = "阅读文章"; +"AccountSettings.SendGift" = "发送礼物"; +"AccountSettings.SetBio" = "设置个人简介"; +"AccountSettings.SetProfileColor" = "更改个人资料页颜色"; +"AccountSettings.SetProfilePhoto" = "设置头像"; +"AccountSettings.SetUsername" = "设置用户名"; +"AccountSettings.Stars" = "我的星星"; +"AccountSettings.Stickers" = "贴纸与表情"; +"AccountSettings.StickersAndEmoji" = "贴纸和表情"; +"AccountSettings.TelegramBusiness" = "Telegram 企业版"; +"AccountSettings.Theme" = "外观"; +"AccountSettings.Ton" = "我的 TON"; +"AccountSettings.UpdateStatus" = "更新状态"; +"AccountSettings.Username" = "用户名"; +"AccountSettings.Wallet" = "Gram 钱包"; +"AccountViewController.Desc.Failed" = "失败"; +"AccountViewController.Desc.Updated" = "更新"; +"AccountViewController.Title" = "设置"; +"AccountViewController.Update" = "更新"; +"AccountsController.NewAccount" = "添加帐号"; +"AddContact.AddNotesPlaceholder" = "添加仅您可见的注解"; +"Admins.AddAdmin" = "添加管理员"; +"Admins.Admin" = "管理员"; +"Admins.ChannelAdmins" = "频道管理员"; +"Admins.ChannelDescription" = "您可以添加管理员帮助您管理频道。"; +"Admins.Creator" = "所有者"; +"Admins.EverbodyCanAddMembers" = "所有人都能添加成员。"; +"Admins.GroupAdmins" = "群组管理员"; +"Admins.GroupDescription" = "您可以添加管理员帮助您管理群组。"; +"Admins.OnlyAdminsCanAddMembers" = "只有管理员有权添加成员。"; +"Admins.Owner" = "所有者"; +"Admins.SelectNewAdminTitle" = "联系人"; +"Admins.WhoCanInvite.Admins" = "仅管理员"; +"Admins.WhoCanInvite.Everyone" = "所有成员"; +"Admins.WhoCanInvite.Text" = "谁可以邀请成员"; +"AffilateProgram.Earn" = "赚取星星"; +"AffilateProgram.Earn.Info" = "分享小程序链接,赚取星星分成收益。"; +"Affiliate.Duration" = "期限"; +"Affiliate.DurationOneYear" = "一年"; +"Affiliate.Program.StartDelay" = "您只能在此时间之后启动新的联盟计划:\n\n%@"; +"Affiliate.Start" = "开始"; +"Affiliate.Update" = "更新"; +"AffiliateProgram.ActionCopyLink" = "复制链接"; +"AffiliateProgram.ActionJoin" = "加入计划"; +"AffiliateProgram.Alert.Connected" = "您已成功加入联盟计划"; +"AffiliateProgram.CommistionDestinationText" = "佣金将会发送至:"; +"AffiliateProgram.DailyRevenueText" = "应用的每用户日均收入:#**%@**"; +"AffiliateProgram.DurationLifetime" = "终身"; +"AffiliateProgram.JoinSubtitle" = "**%1$@** 将分享您推荐的每位用户在 **%3$@**内获得的 **%2$@** 收入。"; +"AffiliateProgram.JoinSubtitleLifetime" = "**{bot}** 将永久**分享您推荐的每个用户的收入的 **{commission}**。"; +"AffiliateProgram.JoinSubtitleMonths_1" = "**{bot}** 将分享您推荐的每位用户 **1 個月**内收入的 **{commission}**。"; +"AffiliateProgram.JoinSubtitleMonths_any" = "**{bot}** 将与您分享每位您推荐的用户在 **%d 个月内** 产生收入的 **{commission}**。"; +"AffiliateProgram.JoinSubtitleYears_1" = "**{bot}** 将分享您推荐的每位用户 **1 年**内收入的 **{commission}**。"; +"AffiliateProgram.JoinSubtitleYears_any" = "**{bot}** 将在 **%d 年** 内与您分享每位您推荐的用户所产生收入的 **{commission}**。"; +"AffiliateProgram.JoinTerms" = "加入此计划即表示您同意联盟计划的 [条款和条件](https://telegram.org/tos/affiliate-program)。"; +"AffiliateProgram.JoinTitle" = "联盟计划"; +"AffiliateProgram.LinkSubtitle" = "与您的用户分享此链接,以便他们在点击您的链接后,您可以在 **%2$@** 内从 **%3$@** 赚取 **%1$d** 佣金。"; +"AffiliateProgram.LinkSubtitleLifetime" = "与您的用户分享此链接。在他们点击链接后,您将 **永久** 获得他们在 **{bot}** 中消费金额的 **{commission}** 作为佣金。"; +"AffiliateProgram.LinkSubtitleMonths_1" = "与您的用户分享此链接,以便他们在点击您的链接后,您可以在 ** 1 个月内** 从 **{bot}** 赚取 **{commission}** 佣金。"; +"AffiliateProgram.LinkSubtitleMonths_any" = "与你的用户分享此链接,以便他们在点击你的点击后,你可以在 **%d 个月**内从 **{bot}** 赚取 **{commission}** 佣金。"; +"AffiliateProgram.LinkSubtitleYears_1" = "与您的用户分享此链接,以便他们在点击您的链接后,您可以在** 1 年内** 从 **{bot}** 争取 **{commission}** 佣金。"; +"AffiliateProgram.LinkSubtitleYears_any" = "与您的用户分享此链接,以便他们在点击您的链接后,您可以在 **%d 年**内从 **{bot}** 赚取 **{commission}** 佣金。"; +"AffiliateProgram.LinkTitle" = "推荐链接"; +"AffiliateProgram.MonthlyText" = "月活跃用户"; +"AffiliateProgram.Off" = "关闭"; +"AffiliateProgram.PeerTypeSelf" = "个人账号"; +"AffiliateProgram.SortSelectorDate" = "日期"; +"AffiliateProgram.SortSelectorProfitability" = "利润率"; +"AffiliateProgram.SortSelectorRevenue" = "收入"; +"AffiliateProgram.ToastJoined.Text" = "现在你可以复制推荐链接。"; +"AffiliateProgram.ToastJoined.Title" = "已加入计划"; +"AffiliateProgram.ToastLinkCopied.Text" = "分享此链接并赚取推荐用户在 %2$@ 中消费的 %1$@!"; +"AffiliateProgram.ToastLinkCopied.Title" = "链接已复制到剪贴板"; +"AffiliateProgram.UserCountFooter#other" = "%d 位用户已通过此链接打开了 %@。"; +"AffiliateProgram.UserCountFooter_0" = "尚未有人通过此链接打开 {bot}。"; +"AffiliateProgram.UserCountFooter_1" = "1名用户通过此链接已打开 {bot}。"; +"AffiliateProgram.UserCountFooter_ZeroValueHolder" = "尚未有人通过此链接打开 %@。"; +"AffiliateProgram.UserCountFooter_any" = "%d 位用户通过此链接打开了 {bot}。"; +"AffiliateProgram.UserCountFooter_countable" = "%d %@"; +"AffiliatePrograms.Confirm.Alert" = "您确定要退出此计划吗?"; +"AffiliatePrograms.Confirm.Alert.OK" = "退出"; +"AffiliatePrograms.Empty" = "还没有可用的计划。请稍后查看此页面。"; +"AffiliatePrograms.ExistingPrograms" = "现有的联盟计划"; +"AffiliateSetup.AlertApply.Action" = "启动"; +"AffiliateSetup.AlertApply.SectionCommission" = "佣金"; +"AffiliateSetup.AlertApply.SectionDuration" = "期限"; +"AffiliateSetup.AlertApply.Text" = "一旦您启动联盟计划,就无法再降低佣金比例或有效期限。您只提高这些参数或终止计划,而终止计划将导致所有之前分发的推荐链接失效。"; +"AffiliateSetup.AlertApply.Title" = "警报"; +"AffiliateSetup.AlertTerminate.Action" = "仍然结束"; +"AffiliateSetup.AlertTerminate.Text" = "如果您终止联盟计划:\n\n• 已分享的推广链接将在 24 小时后失效。\n\n• 所有参与的合作者都将收到通知。\n\n• 您需等待 24 小时后才能开启新的联盟计划。"; +"AffiliateSetup.AlertTerminate.Title" = "警告"; +"AffiliateSetup.ConnectedSectionTitle" = "我的计划"; +"AffiliateSetup.EndAction" = "结束联盟计划"; +"AffiliateSetup.ExistingPrograms.Action" = "查看现有计划"; +"AffiliateSetup.ExistingPrograms.Footer" = "检索其他小程序提供的功能。"; +"AffiliateSetup.Header.Info" = "奖励帮助您扩大用户群的合作者"; +"AffiliateSetup.IntroJoin.Text1" = "你推荐的用户消费中获得确保的佣金。"; +"AffiliateSetup.IntroJoin.Text2" = "实时追踪您从推荐用户处获得的佣金。"; +"AffiliateSetup.IntroJoin.Text3" = "从下方选择小程序,获取您的推荐链接,开始赚取星星。"; +"AffiliateSetup.IntroJoin.Title1" = "可靠"; +"AffiliateSetup.IntroJoin.Title2" = "透明"; +"AffiliateSetup.IntroJoin.Title3" = "简单"; +"AffiliateSetup.IntroNew.Text1" = "设置由您推荐的用户产生的收入佣金。"; +"AffiliateSetup.IntroNew.Text2" = "Telegram 将为数百万潜在合作者展示您的计划。"; +"AffiliateSetup.IntroNew.Text3" = "合作者将与他们的订阅者分享您的推荐链接。"; +"AffiliateSetup.IntroNew.Title1" = "与合作者分享收入"; +"AffiliateSetup.IntroNew.Title2" = "启动你的联盟计划"; +"AffiliateSetup.IntroNew.Title3" = "通过联盟计划推广您"; +"AffiliateSetup.ProgramLeave" = "离开"; +"AffiliateSetup.ProgramMenu.CopyLink" = "复制链接"; +"AffiliateSetup.ProgramMenu.Leave" = "退出"; +"AffiliateSetup.ProgramMenu.OpenApp" = "打开应用"; +"AffiliateSetup.ProgramMenu.OpenBot" = "打开机器人"; +"AffiliateSetup.SectionCommission" = "佣金"; +"AffiliateSetup.SectionCommissionFooter" = "设定您的合作者透过推荐用户使用您的机器人所能赚取的星星收入分成比例。"; +"AffiliateSetup.SectionDuration" = "期限"; +"AffiliateSetup.SectionDurationFooter" = "设置合作方从推荐用户赚取佣金的期限。"; +"AffiliateSetup.SortSectionHeader" = "排序方式"; +"AffiliateSetup.Start" = "开启"; +"AffiliateSetup.StartTitle" = "启动联盟计划"; +"AffiliateSetup.SuggestedSectionEmpty" = "暂无可用计划。\n请稍后再查看此页面。"; +"AffiliateSetup.SuggestedSectionTitle" = "计划"; +"AffiliateSetup.TermsFooter" = "创建联盟计划即表示您同意联盟计划的 [条款和条件] (https://telegram.org/terms) 。"; +"AffiliateSetup.TextJoin" = "每次用户通过您的推荐链接首次访问小程序并在其中消费 **星星** 时,您都会获得佣金。"; +"AffiliateSetup.TextNew" = "奖励那些帮助您扩大用户群的合作者。"; +"AffiliateSetup.TitleJoin" = "联盟计划"; +"AffiliateSetup.TitleNew" = "联盟计划"; +"AffiliateSetup.ToastStarted.Text" = "已启动联盟计划"; +"AffiliateSetup.ToastTerminated.Text" = "已通知参与联盟计划的成员,所有推荐链接将在 24 小时后失效。"; +"AffiliateSetup.ToastTerminated.Title" = "联盟计划已结束"; +"AffiliateSetup.Update" = "更新"; +"AffiliateSetup.UpdateTitle" = "更新联盟计划"; +"AgeVerification.Text" = "要访问此类内容,您必须确认您已年满**18**岁。 \n\n这是通过使用手机相机的一次性操作。Telegram 不会储存您的自拍照。"; +"AgeVerification.Text.GB" = "要访问此类内容,您必须根据英国法律确认您已年满 **18** 岁。 \n\n这是通过使用手机相机的一次性操作。Telegram 不会储存您的自拍照。"; +"AgeVerification.Title" = "年龄验证"; +"AgeVerification.Verify" = "验证我的年龄"; +"AiStylePreview.AddByAuthor" = "通过 [%1$@](%1$@) 添加该风格以在 AI 编辑器中转换您的消息。"; +"AiStylePreview.AddNoAuthor" = "添加该风格以在 AI 编辑器中转换你的消息。"; +"AiStylePreview.AddStyle" = "添加风格"; +"AiStylePreview.AnotherExample" = "↻ 换个示例"; +"AiStylePreview.DefaultDescription" = "一种风格,您可以将其添加至 AI 编辑器以重写您的消息。"; +"AiStylePreview.ExampleHeader" = "示例"; +"AiStylePreview.LabelAfter" = "应用后"; +"AiStylePreview.LabelBefore" = "应用前"; +"AiStylePreview.OpenLinkOnTelegram" = "在 Telegram 打开链接以添加该风格。"; +"Alert.ButtonOK.UpdateApp" = "升级应用"; +"Alert.Cancel" = "取消"; +"Alert.Confirm.Discard" = "舍弃"; +"Alert.Confirm.Stop" = "停止"; +"Alert.Discard" = "放弃"; +"Alert.Forward.Error" = "抱歉,您无法转发消息到此会话。"; +"Alert.HideNewChats.Cancel" = "取消"; +"Alert.HideNewChats.Header" = "隐藏新聊天?"; +"Alert.HideNewChats.OK" = "转到偏好设置"; +"Alert.HideNewChats.Text" = "您收到了很多来自陌生用户的新聊天会话。您是否想将这些聊天会话**自动静音**及**归档**?"; +"Alert.LearnMore" = "了解更多..."; +"Alert.NO" = "否"; +"Alert.OK" = "确定"; +"Alert.PrivateChannel.AccessError" = "很遗憾,您无法打开此消息。 您不是消息所在聊天的成员。"; +"Alert.SendError.Delete" = "删除"; +"Alert.SendError.Header" = "您的信息未发送成功"; +"Alert.SendError.Ignore" = "忽略"; +"Alert.SendError.Resend" = "重新发送"; +"Alert.SendError.ResendItems#other" = "重发 %d 条信息"; +"Alert.SendError.ResendItems_countable" = "%d"; +"Alert.SendError.Text" = "在发送上一条信息时遇到错误。是否重试?"; +"Alert.UserDoesntExists" = "抱歉,这个用户好像不存在。"; +"Alert.View" = "查看"; +"Alert.Yes" = "确认"; +"App.MaxFileSize" = "文件大小上限为 1.5 GB"; +"App.MaxFileSize1" = "最大文件大小为 2GB"; +"App.MaxFileSizeNew" = "最大文件大小为 %@"; +"App.Tooltip.VideoRecord" = "长按以录制视频消息,点按切换为语音消息。"; +"App.Tooltip.VoiceRecord" = "长按以录制语音消息,点击切换为视频消息。"; +"AppUpdate.CheckForUpdates" = "检查更新"; +"AppUpdate.DownloadUpdate" = "下载更新"; +"AppUpdate.Downloading" = "正在下载..."; +"AppUpdate.Error.FailedDownload" = "下载归档失效。请再试一次。"; +"AppUpdate.Error.FailedUpdating" = "下载更新信息失败。请再试一次。"; +"AppUpdate.Error.NoInternal" = "此版本没有内部链接。请再试一次。"; +"AppUpdate.Error.WrongInternal" = "内部链接错误。请再试一次。"; +"AppUpdate.NewestAvailable" = "请更新应用程序以获取最新功能与改进。"; +"AppUpdate.RetrievingInfo" = "正在获取信息…"; +"AppUpdate.Title" = "更新"; +"AppUpdate.Title.New" = "新版本(您当前版本: %@)"; +"AppUpdate.Title.Previous" = "之前的版本"; +"AppUpdate.Unarchiving" = "正在解压…"; +"AppUpdate.Uptodate" = "您目前的 Telegram 版本是最新的。"; +"Appearance.CloudTheme.Unsupported" = "与 macOS 不兼容,单击进行编辑"; +"Appearance.CloudThemes" = "云端主题"; +"Appearance.Confirm.RemoveOK" = "移除"; +"Appearance.Confirm.RemoveText" = "您确定要删除此主题吗?"; +"Appearance.Confirm.RemoveTitle" = "主题"; +"Appearance.CustomBackground" = "自定义壁纸"; +"Appearance.CustomBackground.FileError" = "文件大小不得超过10MB,图像尺寸不得超过500x500像素。"; +"Appearance.DockIcon.Premium" = "订阅 **Telegram Premium** 以解锁更多应用程序图标。"; +"Appearance.ExportTheme" = "导出主题"; +"Appearance.NewTheme" = "新主题"; +"Appearance.Reset" = "恢复默认"; +"Appearance.Settings.AutoNight" = "自动切换夜间模式"; +"Appearance.Settings.AutoNightHeader" = "自动切换夜间模式"; +"Appearance.Settings.BubblesMode" = "气泡模式"; +"Appearance.Settings.DarkMode" = "深色模式"; +"Appearance.Settings.ShowLess" = "显示更少"; +"Appearance.Settings.ShowMore" = "显示更多"; +"Appearance.Theme.Accent" = "强调色"; +"Appearance.Theme.Accent.Messages" = "信息颜色"; +"Appearance.Theme.Edit" = "编辑"; +"Appearance.Theme.Remove" = "移除"; +"Appearance.Theme.Share" = "分享"; +"Appearance.YourNameColor" = "您名称颜色"; +"AppearanceSettings.ChatPreview.1" = "早上好!👋"; +"AppearanceSettings.ChatPreview.2" = "你知道现在几点吗?"; +"AppearanceSettings.ChatPreview.3" = "现在是东京的早晨😎"; +"AppearanceSettings.ChatPreview.FirstText" = "你们这群小屁孩就知道电子音乐。你们应该听听经典摇滚,比如哈塞尔霍夫!"; +"AppearanceSettings.ChatPreview.Header" = "效果预览"; +"AppearanceSettings.ChatPreview.SecondText" = "我现在已经无法直视你了。"; +"AppearanceSettings.ChatPreview.UserName1" = "卢西奥"; +"AppearanceSettings.ChatPreview.UserName2" = "董先生"; +"AppearanceSettings.ChatPreview.ZeroText" = "莱因哈特,我们需要您找一些新的歌曲 🎶。"; +"AppearanceSettings.ChatView.Bubbles" = "对话框"; +"AppearanceSettings.ChatView.Classic" = "简洁"; +"AppearanceSettings.ChatView.Header" = "会话样式"; +"AppearanceSettings.ColorTheme.Header" = "主题颜色"; +"AppearanceSettings.ColorTheme.dark" = "标准暗色调"; +"AppearanceSettings.ColorTheme.day" = "标准亮色调"; +"AppearanceSettings.ColorTheme.dayClassic" = "日间经典"; +"AppearanceSettings.ColorTheme.day_classic" = "传统亮色调"; +"AppearanceSettings.ColorTheme.mojave" = "深色模式"; +"AppearanceSettings.ColorTheme.nightAccent" = "深色模式"; +"AppearanceSettings.ColorTheme.night_blue" = "夜间蓝"; +"AppearanceSettings.ColorTheme.system" = "系统"; +"AppearanceSettings.DockIcon" = "应用图标"; +"AppearanceSettings.FollowSystemAppearance" = "跟随系统样式"; +"AppearanceSettings.FollowSystemAppearance.DefaultDark" = "选择在系统暗色模式中默认的暗色样式。"; +"AppearanceSettings.FollowSystemAppearance.DefaultDay" = "选择将用于系统日间模式的默认浅色主题。"; +"AppearanceSettings.FollowSystemAppearance.DefaultHeader" = "DEFAULT PALETTES"; +"AppearanceSettings.TextSize.Header" = "文字大小"; +"ApplyLanguage.ApplyLanguageAction" = "应用语言"; +"ApplyLanguage.ChangeLanguageAction" = "应用"; +"ApplyLanguage.ChangeLanguageAlreadyActive" = "此种语言 %@ 已激活。"; +"ApplyLanguage.ChangeLanguageOfficialText" = "您将要应用语言包 **%@**。\n\n此操作将会应用到整个界面。您可以通过 [翻译平台]() 提出修改建议。\n\n您可以随时在“偏好设置”中更改语言。"; +"ApplyLanguage.ChangeLanguageTitle" = "是否更改语言?"; +"ApplyLanguage.ChangeLanguageUnofficialText" = "您将要使用一个完成度为 %@% 的自定义语言包(**%@**)。\n\n此操作将会翻译整个界面。您可以通过 [翻译平台]() 提出修改建议。\n\n您可以随时在“偏好设置”中更改语言。"; +"ApplyLanguage.ChangeLanguageUnofficialText1" = "您即将使用一个完成度为 %2$@% 的自定义语言包 **%1$@** 。\n\n此操作将会应用到整个界面。您可以通过 [翻译平台]() 提出修改建议。\n\n您可以随时在“偏好设置”中更改语言。"; +"ApplyLanguage.UnsufficientDataOpenPlatform" = "翻译平台"; +"ApplyLanguage.UnsufficientDataText" = "不幸的是,此自定义语言包(%@)不包含Telegram macOS 的数据。你可以使用翻译平台为这个语言包做出贡献。"; +"ApplyLanguage.UnsufficientDataTitle" = "数据不足"; +"Archive.Folders.Desc" = "即使已将聊天取消静音并收到新消息,仍将文件夹中的已归档聊天保留在归档中。"; +"Archive.Folders.Text" = "始终保留在归档中"; +"Archive.Folders.Title" = "文件夹中的聊天"; +"Archive.NewChats.Desc" = "自动将非联系人的新私聊、群组和频道归档并静音。"; +"Archive.NewChats.Premium" = "此设置仅向 [Telegram 高级版]()的订阅者开放。"; +"Archive.NewChats.Text" = "自动归档"; +"Archive.NewChats.Title" = "未知用户的新聊天"; +"Archive.Title" = "归档设置"; +"Archive.Tooltip.First.Text" = "收到新消息时,已开启通知的聊天将自动取消归档。"; +"Archive.Tooltip.First.Title" = "聊天已归档"; +"Archive.Tooltip.JustArchive.Title" = "聊天已归档。"; +"Archive.Tooltip.Second.Text" = "收到新消息时,静音的聊天将被保持归档。"; +"Archive.Tooltip.Second.Title" = "聊天已归档"; +"Archive.Tooltip.Third.Text" = "您可以将任意数量的归档聊天置顶。"; +"Archive.Tooltip.Third.Title" = "聊天已归档"; +"Archive.UnmutedChats.Desc" = "即使已将聊天取消静音并收到新消息,仍将归档的聊天保留在存档中。"; +"Archive.UnmutedChats.Text" = "始终保留在归档中"; +"Archive.UnmutedChats.Title" = "未静音的聊天"; +"ArchivedStickers.Description" = "您最多可以拥有 200 个贴纸包。\n当您添加更多的贴纸时,未使用的贴纸将会被归档。"; +"ArchivedStickers.Empty" = "您已归档的贴纸包将在此显示"; +"Article.MarkAsRead" = "标记为已读"; +"Article.MarkAsUnread" = "标记为未读"; +"Article.Read" = "已读"; +"Article.ReadAll" = "查阅全部"; +"Article.Remove" = "移除"; +"Article.RemoveAll" = "全部移除"; +"Auction.Error.NotExists" = "拍卖不存在"; +"Audio.Playlist.Alert.AddedToProfile" = "音频已添加到您的个人资料页。"; +"Audio.Playlist.Alert.RemovedFromProfile" = "音频已从您的个人资料页中删除。"; +"Audio.Playlist.Alert.SavedSuccess" = "成功"; +"Audio.Playlist.Button.AddToProfile" = "添加到个人资料页"; +"Audio.Playlist.Context.RemoveFromProfile" = "从个人资料页中移除"; +"Audio.Playlist.Context.SaveTo" = "保存到…"; +"Audio.Playlist.Context.SaveToProfile" = "个人资料页"; +"Audio.Playlist.Context.SaveToSavedMessages" = "我的收藏"; +"Audio.Playlist.Empty" = "播放清单为空"; +"Audio.Playlist.ProfileNotice" = "此音频在您的个人资料页中可见。[移除 >]()"; +"Audio.Playlist.Separator.Chat" = "此聊天中的音频"; +"Audio.Playlist.Separator.User" = "\n%@ 的播放列表"; +"Audio.Playlist.Separator.Your" = "您的播放列表"; +"Audio.UnknownArtist" = "未知艺术家"; +"Audio.UntitledSong" = "未命名"; +"AudioController.videoMessage" = "视频消息"; +"AudioController.voiceMessage" = "语音消息"; +"AudioRecord.Help.Fixed" = "在圈外点击以取消录制"; +"AudioRecord.Help.Plain" = "在圈外释放鼠标以取消录制"; +"Auth.Login.ConfirmPhone" = "您的手机号码是 %@ 吗?"; +"Auth.LoginWithPasskey" = "通过通行密钥登录"; +"AuthSessions.MessageApp" = "您允许此机器人在您打开 %@ 时向您发送信息。"; +"AutoDownloadSettings.Delimeter" = ", "; +"AutoDownloadSettings.LastDelimeter" = "和"; +"AutoDownloadSettings.OffForAll" = "对所有聊天关闭"; +"AutoDownloadSettings.OnFor" = "%@"; +"AutoDownloadSettings.OnForAll" = "对所有聊天启用"; +"AutoDownloadSettings.TypeChannels" = "频道"; +"AutoDownloadSettings.TypeGroupChats" = "群组"; +"AutoDownloadSettings.TypePrivateChats" = "私聊"; +"AutoDownloadSettings.UpToFor" = "不超过 %@ 的 %@"; +"AutoDownloadSettings.UpToForAll" = "所有聊天最大 %@"; +"AutoNight.Settings.Disabled" = "停用"; +"AutoNight.Settings.From" = "从"; +"AutoNight.Settings.PreferredTheme" = "首选的暗色主题"; +"AutoNight.Settings.Scheduled" = "计划时段"; +"AutoNight.Settings.Sunrise.Desc" = "计算日落/日出时间需要对您的粗略位置进行一次性检测。请注意,此位置仅会存储在您目前的设备上。\n\n日落:%@\n日出:%@"; +"AutoNight.Settings.Sunrise.Desc.NA" = "计算日落/日出时间需要对您的粗略位置进行一次性检测。请注意,此位置仅会存储在您目前的设备上。\n\n日落:未知\n日出:未知"; +"AutoNight.Settings.SunsetAndSunrise" = "使用当地日落/日出时间"; +"AutoNight.Settings.SystemBased" = "系统"; +"AutoNight.Settings.SystemBasedDesc" = "App 接口将匹配系统偏好设置。"; +"AutoNight.Settings.Title" = "自动切换夜间模式"; +"AutoNight.Settings.To" = "到"; +"AutoNight.Settings.UpdateLocation" = "更新位置"; +"AutoNight.Settings.UpdateLocation.Error" = "无法获取您的位置,请检查系统设置然后重试。"; +"AutoremoveMessages.Day" = "1 天以后"; +"AutoremoveMessages.Day1" = "1天"; +"AutoremoveMessages.Desc" = "在一定时间后自动删除本聊天中发送的新消息。"; +"AutoremoveMessages.Header" = "自动删除消息"; +"AutoremoveMessages.Month1" = "1个月"; +"AutoremoveMessages.Never" = "从不"; +"AutoremoveMessages.Title" = "清除聊天记录"; +"AutoremoveMessages.Title.DeleteOnly" = "自动删除"; +"AutoremoveMessages.Week" = "1 周以后"; +"AutoremoveMessages.Week1" = "1周"; +"Avatar.Background.Patters" = "带图案的渐变"; +"Avatar.Background.PlainGradient" = "平淡渐变"; +"Avatar.Preview" = "预览"; +"BOF-NM-1cW.title" = "偏好设置"; +"Badge.New" = "新"; +"Bio.Description" = "您可以在此输入任何有关您个人的信息,诸如:年龄、工作或居住城市。\n示例:23岁,来自旧金山的设计师。"; +"Bio.Header" = "个人简介"; +"Bio.Placeholder" = "您的自我介绍"; +"Bio.Save" = "保存"; +"Birthday.AddYourBirthday" = "添加您的生日"; +"Birthday.Alert.Added" = "已添加出生日期。"; +"Birthday.DayTitle" = "日"; +"Birthday.MonthTitle" = "月"; +"Birthday.Separator.Today" = "🎂 今天生日"; +"Birthday.Separator.Tomorrow" = "明天过生日"; +"Birthday.Separator.Yesterday" = "昨天生日"; +"Birthday.YearsOld#other" = "%d 岁"; +"Birthday.YearsOld_countable" = "%d"; +"BlockContact.Options.Action" = "封闭 %@"; +"BlockContact.Options.DeleteChat" = "删除该次聊天"; +"BlockContact.Options.Report" = "举报垃圾信息"; +"BlockContact.Options.Title" = "管理用户"; +"BlockContact.Title" = "您想要阻止%@在 Telegram 上给您发消息及打电话吗?"; +"BlockedPeers.EmptyDescrpition" = "被您屏蔽的用户将无法向您发送消息,或邀请您加入群组。该用户也无法看到您的头像、动态、在线状态及最后上线时间。"; +"Boost.Badge.Level" = "等级 %d"; +"Boost.Badge.LevelPLus" = "等级 %d+ "; +"Boost.GetBoosts" = "通过向订阅者赠予 Telegram 高级版助推您的频道。[获得助推]()"; +"Boost.GiftToGetMore.Info" = "要助推 **%@** ,请向好友赠予 **Telegram 高级版** 来获取更多助推 。"; +"Boost.GiftToGetMore.Title" = "需要更多助推"; +"BoostGift.AdditionalPrizes" = "额外奖品"; +"BoostGift.AdditionalPrizesInfoForMonths#other" = "为期 **%d** 个月"; +"BoostGift.AdditionalPrizesInfoForMonths_countable" = "%d"; +"BoostGift.AdditionalPrizesInfoOff" = "如果您除了 Telegram 高级版订阅之外还想为获奖者提供您自己的奖品,请打开此选项。"; +"BoostGift.AdditionalPrizesInfoOn" = "所有奖品:**%1$@** %3$@%2$@。"; +"BoostGift.AdditionalPrizesInfoSubscriptions#other" = "%d 个 Telegram 高级版订阅"; +"BoostGift.AdditionalPrizesInfoSubscriptions_countable" = "%d"; +"BoostGift.AdditionalPrizesInfoWithSubscriptions#other" = "及 %d 个 Telegram 高级版订阅"; +"BoostGift.AdditionalPrizesInfoWithSubscriptions_countable" = "%d"; +"BoostGift.AdditionalPrizesPlaceholder" = "输入您的奖品"; +"BoostGift.StartConfirmation.Start" = "开始"; +"BoostGift.StartConfirmation.Text" = "您确定要现在开始抽奖活动吗?"; +"BoostGift.StartConfirmation.Title" = "开始抽奖活动"; +"BoostGift.Winners" = "显示获奖者"; +"BoostGift.WinnersInfo" = "选择是否在抽奖活动结束时公开获奖者名单。"; +"BoostGroup.ChatInput.Action" = "助推此群组以发送消息"; +"BoostGroup.ChatInput.Boost" = "助推"; +"BoostGroup.ChatInput.SendMedia" = "助推此群组以发送媒体。"; +"BoostReassign.Info" = "要助推 **%1$@**,请重新分配之前助推或将 **Telegram 高级版** 赠予給朋友,以获得 **%2$@** 次额外的助推。"; +"BoostReassign.OK" = "重新分配助推"; +"BoostReassign.Status.AvailableIn" = "将于 %@ 可用"; +"BoostReassign.Status.ExpiresOn" = "助推将于 %@ 到期"; +"BoostReassign.Success#other" = "%d 次助推从其他 %d 个频道重新分配"; +"BoostReassign.Success_countable" = "%d %d"; +"BoostReassign.Title" = "重新分配助推"; +"Bot.AllowSendMessages" = "允许 **%@** 发送消息给我。"; +"Bot.Grossing.Disclaimer" = "这里包括哪些应用? [了解 >](grossing)"; +"Bot.Grossing.Disclaimer.Alert.Header" = "热门小程序"; +"Bot.Grossing.Disclaimer.Alert.Text" = "此目录根据小程序的每日收入(以星星数量衡量)进行排名。若想要列入清单,开发人员必须在 [@botfather]() 中设定主要小程序(如 [此处](https://core.telegram.org/api/bots/webapps) 所述) ,该小程序应拥有超过 **1000** 名每日用户,且每日收入超过 **1000** 颗星星(基于每周平均值)。"; +"Bot.Grossing.Disclaimer.Alert.ok" = "已知悉"; +"Bot.InlineAuth.Header" = "打开链接"; +"Bot.InlineAuth.Open" = "打开"; +"Bot.InlineAuth.Option.AllowSendMessages" = "允许 %@ 给我发送消息"; +"Bot.InlineAuth.Option.Login" = "以 **%@** 身份登录 %@"; +"Bot.InlineAuth.Title" = "您想打开下面的链接吗?\n%@"; +"Bot.LaunchApp" = "开启"; +"Bot.MoreAbout" = "[相关此机器人的更多信息>](info)"; +"Bot.TransferOwner.Error.Enable2FA" = "开启两步验证"; +"Bot.TransferOwner.Error.Text" = "所有权转移仅在以下情况下可用:\n\n• 您的帐户已启用两步验证超过7天。\n\n• 您在此设备上登录超过24小时。\n\n请稍后再试。"; +"Bot.TransferOwner.Error.Title" = "安全检查"; +"Bot.TransferOwnership.Password.Desc" = "请输入两步验证密码完成转移。"; +"Bot.TransferOwnership.Password.Title" = "两步验证"; +"BotInfo.LaunchInfo" = "发布此小程序即表示您同意 [Telegram 开发人员服务条款](https://telegram.org/tos/bot-developers)。"; +"BotInfo.LaunchInfo.PrivacyUrl" = "https://telegram.org/privacy-tpa"; +"BotInfo.LaunchInfo.User" = "启动此小程序即表示您同意 [小程序服务条款。](%@)"; +"BotInfo.OpenApp" = "打开应用"; +"BotUsername.Activate.Info" = "您想要在机器人的信息页面上显示此链接吗?"; +"BotUsername.Activate.Ok" = "显示"; +"BotUsername.Activate.Title" = "启用用户名"; +"BotUsername.Active" = "已启用"; +"BotUsername.Deactivate.Info" = "您想要在机器人的信息页面中隐藏此链接吗?"; +"BotUsername.Deactivate.Ok" = "隐藏"; +"BotUsername.Deactivate.Title" = "停用用户名"; +"BotUsername.Header" = "用户名"; +"BotUsername.Info" = "此链接不可编辑,你可以在 [Fragment]() 上获取更多用户名。"; +"BotUsername.List.Info" = "拖拽以调整机器人信息页中链接显示的顺序。"; +"BotUsername.List.Title" = "用户名顺序"; +"BotUsername.NotActive" = "不活跃"; +"BotUsername.Title" = "用户名"; +"BotVerification.Added" = "**%@** 已收到通知并将在接受后收到您的验证标记和说明。"; +"BotVerification.Remove.Bot.Text" = "您已认证此机器人,是否要移除认证?"; +"BotVerification.Remove.Channel.Text" = "您已认证此频道,是否要移除认证?"; +"BotVerification.Remove.Group.Text" = "您已认证此群组,是否要移除认证?"; +"BotVerification.Remove.Remove" = "移除"; +"BotVerification.Remove.Title" = "移除验证"; +"BotVerification.Remove.User.Text" = "您已认证此用户,是否要移除认证?"; +"BotVerification.Removed" = "您已移除 **%@** 的认证。"; +"BotVerification.Verify.Bot.Text" = "是否要使用您的认证标识和简介来验证此机器人?"; +"BotVerification.Verify.Bot.Title" = "验证机器人"; +"BotVerification.Verify.Channel.Text" = "你是否想要使用你的验证标记和描述来验证此频道?"; +"BotVerification.Verify.Channel.Title" = "验证频道"; +"BotVerification.Verify.Group.Text" = "是否要使用您的认证标识和简介来验证此群组?"; +"BotVerification.Verify.Group.Title" = "验证群组"; +"BotVerification.Verify.Placeholder" = "描写…"; +"BotVerification.Verify.User.Text" = "您想用验证标记和描述来验证该用户吗?"; +"BotVerification.Verify.User.Title" = "验证用户"; +"BotVerification.Verify.Verify" = "验证"; +"BroadcastGroups.Cancel" = "保持为普通群组"; +"BroadcastGroups.ConfirmationAlert.Convert" = "转换"; +"BroadcastGroups.ConfirmationAlert.Text" = "该群组的普通成员(非管理员)将 **不可恢复地** 失去在该组中发送消息的权限。\n\n此操作 **无法** 撤消。"; +"BroadcastGroups.ConfirmationAlert.Title" = "您确定吗?"; +"BroadcastGroups.Convert" = "转换为广播群组"; +"BroadcastGroups.IntroText" = "- 不限制成员数量。\n\n- 只有管理员可以发帖。\n\n- 不能变回普通群。"; +"BroadcastGroups.IntroTitle" = "广播群组"; +"BroadcastGroups.LimitAlert.LearnMore" = "了解更多"; +"BroadcastGroups.LimitAlert.SettingsTip" = "如果你改变主意,请进入你的组的设置 > 权限。"; +"BroadcastGroups.LimitAlert.Text" = "您的群组已经达到了 **%@** 名成员的上限。\n\n通过将该群组转换为只有管理员才能发布的消息的 **广播群组** 可以提升成员上限。\n您感兴趣吗?"; +"BroadcastGroups.LimitAlert.Title" = "群成员已达上限"; +"BroadcastGroups.Success" = "成功!您的群组现在可以拥有超过20万个成员了。"; +"Business.Away.OnlyOffline" = "仅当离线时"; +"Business.Away.OnlyOffline.Info" = "当您最近在线时不发送离开消息。"; +"Business.AwayMessage.CreateMessage" = "创建离开消息"; +"Business.AwayMessage.Enable" = "发送离开消息"; +"Business.AwayMessage.Header" = "当您离开时将自动回复消息。"; +"Business.AwayMessage.Title" = "离开消息"; +"Business.ChatBot.ManageGifts" = "管理礼物或星星"; +"Business.ChatBot.ManageGifts.Sell" = "将礼物兑换成星星"; +"Business.ChatBot.ManageGifts.Settings" = "更改礼物设置"; +"Business.ChatBot.ManageGifts.Transfer" = "转让和升级礼物"; +"Business.ChatBot.ManageGifts.TransferStars" = "转让星星"; +"Business.ChatBot.ManageGifts.View" = "查看礼物和星星"; +"Business.ChatBot.ManageMessages" = "管理消息"; +"Business.ChatBot.ManageMessages.DeleteReceived" = "删除已收到的消息"; +"Business.ChatBot.ManageMessages.DeleteSent" = "删除已发送的消息"; +"Business.ChatBot.ManageMessages.Mark" = "标记消息为已读"; +"Business.ChatBot.ManageMessages.Read" = "阅读消息"; +"Business.ChatBot.ManageMessages.Reply" = "回复消息"; +"Business.ChatBot.ManageProfile" = "管理个人资料页"; +"Business.ChatBot.ManageProfile.Avatar" = "编辑头像"; +"Business.ChatBot.ManageProfile.Bio" = "编辑个人简介"; +"Business.ChatBot.ManageProfile.Name" = "编辑名称"; +"Business.ChatBot.ManageProfile.Username" = "编辑用户名"; +"Business.ChatBot.ManageStories" = "管理动态"; +"Business.ChatBots.BotNotSupported" = "此机器人暂不支持 “Telegram 企业版”。"; +"Business.ChatBots.Footer" = "输入您希望自动处理聊天的 Telegram 机器人链接。"; +"Business.Chatbots.Add" = "添加"; +"Business.Chatbots.ChatTypes" = "机器人可访问的聊天"; +"Business.Chatbots.Chats" = "聊天"; +"Business.Chatbots.Header" = "添加机器人代您回复消息。"; +"Business.Chatbots.NotFound" = "未找到机器人"; +"Business.Chatbots.Permission" = "回复消息"; +"Business.Chatbots.Permission.Header" = "机器人权限"; +"Business.Chatbots.Permission.Info" = "机器人只能代替您回复最近 24 小时内活跃的聊天。"; +"Business.Chatbots.Permission.RemoveBot" = "移除机器人"; +"Business.Chatbots.Placeholder" = "机器人用户名或链接"; +"Business.Chatbots.Title" = "聊天自动化"; +"Business.GreetingMessage.CreateMessage" = "创建问候消息"; +"Business.GreetingMessage.Enable" = "发送问候消息"; +"Business.GreetingMessage.Header" = "当客户第一次向您发送消息或在一段时间没有活动时向他们问候。"; +"Business.GreetingMessage.NoActivity.Days" = "%d 天"; +"Business.GreetingMessage.NoActivity.Days_countable" = "%d"; +"Business.GreetingMessage.NoActivity.Info" = "选择自您上次与对方互动多少天后,将给对方的发送问候消息作为回复。"; +"Business.GreetingMessage.NoActivity.Title" = "不活跃的时长"; +"Business.GreetingMessage.Title" = "问候消息"; +"Business.Hours.24Hours" = "24 小时"; +"Business.Hours.AddSet" = "添加一组时间"; +"Business.Hours.AddSet.Info" = "设置您一天中的工作时间。"; +"Business.Hours.BusinessHours" = "营业时间"; +"Business.Hours.Closed" = "休息中"; +"Business.Hours.Header" = "启用此功能可以让您的客户了解您的营业时间安排。"; +"Business.Hours.Set.ClosingTime" = "关闭时间"; +"Business.Hours.Set.OpeningTime" = "开始营业时间"; +"Business.Hours.Set.Remove" = "移除"; +"Business.Hours.Set.Title" = "当天营业"; +"Business.Hours.ShowHours" = "显示营业时间"; +"Business.Hours.Timezone" = "时区"; +"Business.Hours.Title" = "营业时间"; +"Business.Hours.UTC" = "UTC"; +"Business.Intro.Empty.Text" = "发送信息或点击下方的问候语。"; +"Business.Intro.Empty.Title" = "暂无消息..."; +"Business.Intro.Info" = "自定你的起始页"; +"Business.Intro.Placeholder.Text" = "输入消息"; +"Business.Intro.Placeholder.Title" = "输入标题"; +"Business.Intro.Reset" = "重置为默认"; +"Business.Intro.Sticker.Info" = "你可以自定他人与你开始聊天之前看到的消息。"; +"Business.Intro.Sticker.Random" = "随机"; +"Business.Intro.Sticker.Text" = "自定义贴纸"; +"Business.Intro.Title" = "起始页"; +"Business.Links.Block" = "聊天链接"; +"Business.Links.ConfirmRemove" = "您是否确定删除此链接?"; +"Business.Links.Create" = "创建聊天链接"; +"Business.Links.CreateInfo.Full" = "也可以使用短链接来与你聊天 — %1$@ 或 %2$@。"; +"Business.Links.CreateInfo.Phone" = "也可以使用短链接来与你发起聊天 — %1$@."; +"Business.Links.Info" = "为您的客户提供可以开始与您聊天的短链接,并为其推荐与您聊天的第一条消息。"; +"Business.Links.Item.Clicks#other" = "%d 次点击"; +"Business.Links.Item.Clicks_ZeroValueHolder" = "暂无点击数"; +"Business.Links.Item.Clicks_countable" = "%d"; +"Business.Links.Item.Copy" = "复制"; +"Business.Links.Item.Delete" = "删除"; +"Business.Links.Item.EditName" = "编辑名称"; +"Business.Links.Item.NoText" = "没有消息"; +"Business.Links.Item.Share" = "分享"; +"Business.Links.Name.Info" = "为此链接添加一个只有您才能看到的名称。"; +"Business.Links.Name.Placeholder" = "为此链接命名"; +"Business.Links.Name.Title" = "链接名称"; +"Business.Links.Title" = "聊天链接"; +"Business.Links.Tooltip.Saved" = "已保存预设消息。"; +"Business.Location.EnterAddress" = "输入地址"; +"Business.Location.Header" = "在您的账号上显示您的企业位置。"; +"Business.Location.Remove" = "删除位置"; +"Business.Location.SetOnMap" = "在地图上设置位置"; +"Business.Location.Title" = "位置"; +"Business.Message.Contact" = "联系人"; +"Business.Message.NonContact" = "非联系人"; +"Business.Message.Recepients.All" = "所有私聊,除了…"; +"Business.Message.Recepients.Exclude" = "排除聊天..."; +"Business.Message.Recepients.Exclude.Info" = "选择机器人 **不能** 访问的聊天或整个聊天类别。"; +"Business.Message.Recepients.Exclude.Title" = "排除聊天"; +"Business.Message.Recepients.ExcludeUsers" = "不包含的用户..."; +"Business.Message.Recepients.ExcludeUsers.Info" = "选择机器人**无权** 存取的用户。"; +"Business.Message.Recepients.Include" = "选择聊天..."; +"Business.Message.Recepients.Include.Info" = "选择机器人 **有权** 访问的聊天或整个聊天类型。"; +"Business.Message.Recepients.Include.Title" = "包含聊天"; +"Business.Message.Recepients.Selected" = "仅选出的聊天"; +"Business.Message.Recepients.Title" = "接收者"; +"Business.Message.Schedule.AlwaysSend" = "总是发送"; +"Business.Message.Schedule.Custom" = "自定义时间表"; +"Business.Message.Schedule.Custom.EndTime" = "结束时间"; +"Business.Message.Schedule.Custom.StartTime" = "开始时间"; +"Business.Message.Schedule.OutsideHours" = "不在营业时间"; +"Business.Message.Schedule.Title" = "时间表"; +"Business.Message.SelectPeers.ChatTypes" = "聊天类型"; +"Business.Message.SelectPeers.Chats" = "聊天"; +"Business.QuickReply.Add" = "添加快速回复"; +"Business.QuickReply.Add.Error" = "该指令已存在。"; +"Business.QuickReply.Add.Info" = "为您快速回复添加快捷方式。"; +"Business.QuickReply.Add.Placeholder" = "输入名称..."; +"Business.QuickReply.Add.Title" = "新建快速回复"; +"Business.QuickReply.ConfirmDelete" = "您确定要删除快速恢复吗?"; +"Business.QuickReply.Edit.Info" = "为您快速回复添加快捷方式"; +"Business.QuickReply.Edit.Title" = "编辑快速回复"; +"Business.QuickReply.EditName" = "编辑名称"; +"Business.QuickReply.Header" = "设置包含文本和媒体的快捷方式,以更快地回复消息。"; +"Business.QuickReply.More" = "其他 +%d 个"; +"Business.QuickReply.More1#other" = "其他 +%d 个"; +"Business.QuickReply.More1_countable" = "%d"; +"Business.QuickReply.Title" = "快速回复"; +"Business.ScheduleEnd" = "打烊时间"; +"Business.ScheduleStart" = "营业时间"; +"Business.SelectPeers.Limit" = "已达到上线"; +"Business.SwitchAd" = "不要隐藏广告"; +"Business.SwitchAd.Info" = "作为高级版订阅用户,您在 Telegram 上不会看到任何广告,但您可以选择开启它们,例如查看您自己在 [Telegram 广告平台 >] 上发布的广告。"; +"Business.SwitchAd.Title" = "频道中的广告"; +"Business.Updated" = "您的 Telegram 企业已更新"; +"CHAT_MESSAGE_INVOICE" = "%1$@ 在群组 %2$@ 中发送了一份金额为 %3$@ 的账单"; +"Cag-YX-WT6.title" = "隐藏 Telegram"; +"Calendar.WeekDays.Friday" = "五"; +"Calendar.WeekDays.Monday" = "一"; +"Calendar.WeekDays.Saturday" = "六"; +"Calendar.WeekDays.Sunday" = "日"; +"Calendar.WeekDays.Thrusday" = "四"; +"Calendar.WeekDays.Tuesday" = "二"; +"Calendar.WeekDays.Wednesday" = "三"; +"Call.Accept" = "接受"; +"Call.Camera" = "相机"; +"Call.Camera.Error" = "Telegram 需要获取相机权限来视频通话。"; +"Call.CameraUnavailable" = "相机不可用 [设置]()"; +"Call.Close" = "挂断"; +"Call.Confirm.Discard.Call.Header" = "通话进行中"; +"Call.Confirm.Discard.Call.ToCall.Text" = "是否结束与“%1$@”的通话并与“%2$@”开始视频聊天?"; +"Call.Confirm.Discard.Call.ToVoice.Text" = "结束与\"%1$@\"的通话,并与\"%2$@\"开始新的通话?"; +"Call.Confirm.Discard.Voice.Header" = "正在进行视频聊天"; +"Call.Confirm.Discard.Voice.ToCall.Text" = "是否离开 %1$@ 的视频聊天并且与记 %2$@ 进行通话?"; +"Call.Confirm.Discard.Voice.ToVoice.Text" = "是否要退出与 “%1$@” 的视频聊天并发起与 “%2$@” 的视频聊天?"; +"Call.Confirm.DiscardCurrent.Description" = "您是否要挂断与 %@ 的通话并与 %@ 开始新通话?"; +"Call.Confirm.DiscardCurrent.Header" = "正在通话"; +"Call.Context.Rate" = "评价此次通话"; +"Call.Decline" = "拒绝"; +"Call.End" = "结束"; +"Call.GroupCall" = "群组通话"; +"Call.Mute" = "静音"; +"Call.ParticipantVersionOutdatedError" = "%@ 的应用不支持通话。在您能够呼叫他们之前,他们必须先更新他们的应用。"; +"Call.PrivacyErrorMessage" = "抱歉,由于其隐私设置,您无法呼叫 %@。您可以让其修改设置或呼叫您。"; +"Call.RatingModal.NotNow" = "不是现在"; +"Call.RatingModal.Placeholder" = "作出评价..."; +"Call.RatingModal.Text" = "请对您这次 Telegram 通话的质量作出评价"; +"Call.Recall" = "重拨"; +"Call.Recent.Incoming" = "来电"; +"Call.Recent.Missed" = "未接"; +"Call.Recent.Outgoing" = "拨出"; +"Call.SameDevice.Error" = "抱歉,您不能拨打同一设备上的另一个账号。"; +"Call.Screen" = "屏幕"; +"Call.Screen.Error" = "Telegram 需要获取屏幕分享权限。"; +"Call.ShortMinutes#other" = "%d 分钟"; +"Call.ShortMinutes_countable" = "%d"; +"Call.ShortSeconds#other" = "%d 秒"; +"Call.ShortSeconds_countable" = "%d"; +"Call.Status.GroupCall" = "群组通话"; +"Call.Status.GroupCall.Account" = "群组通话 → %@"; +"Call.StatusBusy" = "忙线"; +"Call.StatusCalling" = "正在呼叫..."; +"Call.StatusCallingAccount" = "正在呼叫 → %@..."; +"Call.StatusConnecting" = "连接中…"; +"Call.StatusEnded" = "通话结束"; +"Call.StatusFailed" = "通话失败"; +"Call.StatusRequesting" = "连接中…"; +"Call.StatusRinging" = "拨号中…"; +"Call.StatusSwitchingToConference" = "现在切换到会议..."; +"Call.Toast.CameraOff" = "%@ 的相机处于关闭状态"; +"Call.Toast.LowBattery" = "%@ 的电池电量过低"; +"Call.Toast.MicroOff" = "%@'的麦克风处于关闭状态"; +"Call.Toast.MicroOff.Your" = "您的麦克风已关闭"; +"Call.Toast.WeakNetwork" = "网络信号弱"; +"Call.UndefinedError" = "未知错误,请稍后再试。"; +"Call.Video" = "视频"; +"Call.VideoPaused" = "%@ 视频画面已暂停"; +"CallFeedback.AddComment" = "添加评价(可选)"; +"CallFeedback.IncludeLogs" = "包含技术信息"; +"CallFeedback.IncludeLogsInfo" = "这不会泄露您的对话内容,但却能帮助我们尽快修复问题。"; +"CallFeedback.ReasonDistortedSpeech" = "声音失真"; +"CallFeedback.ReasonDropped" = "通话意外中断"; +"CallFeedback.ReasonEcho" = "我听到了自己的回声"; +"CallFeedback.ReasonInterruption" = "对方声音断断续续"; +"CallFeedback.ReasonNoise" = "我听到了背景噪音"; +"CallFeedback.ReasonSilentLocal" = "我无法听到对方的声音"; +"CallFeedback.ReasonSilentRemote" = "对方无法听到我的声音"; +"CallFeedback.Send" = "发送"; +"CallFeedback.Success" = "感谢您的反馈"; +"CallFeedback.Title" = "通话反馈"; +"CallFeedback.VideoReasonDistorted" = "视频失真"; +"CallFeedback.VideoReasonLowQuality" = "视频被像素化"; +"CallFeedback.WhatWentWrong" = "出了什么问题?"; +"CallHeader.EndCall" = "结束通话"; +"CallSettings.Camera.Title" = "相机"; +"CallSettings.Device.Default" = "默认"; +"CallSettings.Input.Text" = "输入设备"; +"CallSettings.Input.Title" = "麦克风"; +"CallSettings.InputLevel" = "输入音量"; +"CallSettings.MuteSound" = "通话时静音其他声音"; +"CallSettings.OpenSystemPreferences" = "打开系统首选项"; +"CallSettings.OtherSettings.Title" = "其他设置"; +"CallSettings.Output.Text" = "输出设备"; +"CallSettings.Output.Title" = "声音"; +"CallSettings.Title" = "通话设置"; +"CancelResetAccount.Success" = "删除账号 %@ 的请求已被取消 。"; +"CancelResetAccount.TextSMS" = "某个人通过你的手机号码 **%@** 发送了删除你的 Telegram 帐号并重置你的两步验证密码的请求。\n\n如果那并非你本人,请输入我们刚刚通过短信给您的手机号码发送的验证码。"; +"CancelResetAccount.Title" = "取消账号重置"; +"Canvas.Clear" = "E"; +"Canvas.Draw" = "L - 线\nA - 箭头"; +"Canvas.Redo" = "⌘⇧Z"; +"Canvas.Undo" = "⌘Z"; +"Chanel.EventFilter.AllAdmins" = "全部管理员"; +"Chanel.EventFilter.AllAdmins.New" = "显示所有管理员的操作"; +"Chanel.EventFilter.AllEvents" = "全选"; +"Chanel.EventFilter.Settings" = "设置"; +"ChangeNumber.ConfirmCode.Error.Generic" = "发生错误"; +"ChangeNumber.ConfirmCode.Error.codeExpired" = "验证码已失效。"; +"ChangeNumber.ConfirmCode.Error.invalidCode" = "验证码无效,请重试。"; +"ChangeNumber.ConfirmCode.Error.limitExceeded" = "您输错验证码的次数过多,请稍后重试。"; +"ChangeNumber.ConfirmCode.Success" = "您已经将您的电话号码更换为 %@。"; +"ChangeNumber.SendData.Error.Generic" = "发生错误,请稍后再试。"; +"ChangeNumber.SendData.Error.InvalidPhoneNumber" = "您输入的电话号码无效。请输入带国际电话区号的正确电话号码。"; +"ChangeNumber.SendData.Error.LimitExceeded" = "您请求验证码的次数过多,请稍后再试。"; +"ChangeNumber.SendData.Error.PhoneNumberOccupied" = "你所输入的号码 %@ 已经注册了 Telegram。请先删除新号码对应的帐号,再进行迁移。"; +"ChangePhone.UseMobile" = "您只能使用 Telegram 移动版应用更改您的电话号码,请使用手机上的官方 Telegram 应用更新号码。"; +"ChangePhoneNumber.Intro.Alert" = "如果您的 Telegram 联系人有您的旧号码,并且您没有在 Telegram 里拉黑他们,您的新号码将被添加到他们的通讯录里。"; +"ChangePhoneNumber.Intro.Description" = "您可以在此更换当前绑定到 Telegram 的手机号码。您的帐号和所有云端数据(消息、媒体文件、联系人等)都会迁移到新号码下。"; +"Channel.AddAdminKickedError" = "抱歉,由于该用户在被移除的用户列表中,您不能将其添加为管理员或解除其封禁。"; +"Channel.AddBot.AdminRights" = "管理员权限"; +"Channel.AddBot.Button.Admin" = "添加此机器人为管理员"; +"Channel.AddBot.Button.Member" = "添加机器人为成员"; +"Channel.AddBot.Confirm.Info" = "你确定要将此机器人设为群组 %@的管理员吗?"; +"Channel.AddBot.Confirm.Ok.Admin" = "添加为管理员"; +"Channel.AddBot.Confirm.Ok.Member" = "是否添加为成员?"; +"Channel.AddBot.Confirm.Title.Admin" = "是否添加机器人为管理员?"; +"Channel.AddBot.Confirm.Title.Member" = "是否添加此机器人为成员?"; +"Channel.AddBot.CustomTitle" = "管理员"; +"Channel.AddBot.Success.Admin" = "您將“%@”作為管理員加入到“%@”。"; +"Channel.AddBot.Success.Member" = "您已将 “”%@“” 作为成员加入%@。"; +"Channel.AddBot.Title" = "添加机器人"; +"Channel.AddBotAsAdmin" = "设定为管理员"; +"Channel.AddBotErrorHaveRights" = "机器人只能作为管理员加入频道。"; +"Channel.AddBotErrorNoRights" = "机器人只能作为管理员加入频道。"; +"Channel.AddUserKickedError" = "抱歉,由于该用户在被移除用户名单上,您无法将其加入频道或解除其封禁。"; +"Channel.Admin.AdminAccess" = "此管理员将能够添加新的管理员,并赋予其与该管理员同等(或更少)的权限。"; +"Channel.Admin.AdminRestricted" = "该管理员不能添加新管理员。"; +"Channel.Admin.CantEdit" = "您无权编辑管理员的权限。"; +"Channel.Admin.CantEditRights" = "您无法修改此管理员的权限。"; +"Channel.Admin.Dismiss" = "解任管理员"; +"Channel.Admin.Role.Admin.Desc" = "群组成员将看到此头衔,而不是“管理员”。"; +"Channel.Admin.Role.Header" = "成员标签"; +"Channel.Admin.Role.Owner.Desc" = "自定义头衔将替换“拥有者”展示给所有成员。"; +"Channel.Admin.Role.Placeholder.Admin" = "管理员"; +"Channel.Admin.Role.Placeholder.Owner" = "所有者"; +"Channel.Admin.TransferOwnership.Channel" = "转让频道所有权"; +"Channel.Admin.TransferOwnership.Confirm.Channel.Text" = "此操作会将频道 %@ 的所有权全部转让给 %@。\n\n您将不再是频道的创建者,新的频道所有者有权移除您的管理员特权,甚至可以封禁您。"; +"Channel.Admin.TransferOwnership.Confirm.Channel.Title" = "转让频道的所有权"; +"Channel.Admin.TransferOwnership.Confirm.Group.Text" = "这将转让 **%@** 的所有权给 **%@**。\n\n您将不再被视为群组的创建者。 新的所有者可以自由地删除您的任何管理员权限,甚至封禁您。"; +"Channel.Admin.TransferOwnership.Confirm.Group.Title" = "转让群组所有权"; +"Channel.Admin.TransferOwnership.Confirm.OK" = "更改所有者"; +"Channel.Admin.TransferOwnership.Group" = "转让群组"; +"Channel.Admin.TransferOwnership.Password.Desc" = "请输入两步验证码以完成转让。"; +"Channel.Admin.TransferOwnership.Password.Title" = "两步验证"; +"Channel.Admin.WhatCanAdminDo" = "管理员权限"; +"Channel.AdminLog.AllowedNewMembersToSpeak" = "%1$@ 已允许新视频聊天参与者发言"; +"Channel.AdminLog.AllowedReactionsUpdated" = "%1$@ 更新了可用的回应: %2$@"; +"Channel.AdminLog.CanInviteUsersViaLink" = "通过链接邀请用户"; +"Channel.AdminLog.CanManageCalls" = "管理视频聊天"; +"Channel.AdminLog.CanManageTopics" = "环境话题"; +"Channel.AdminLog.CreatedInviteLink" = "%1$@ 已创建邀请链接 %2$@"; +"Channel.AdminLog.DeletedInviteLink" = "%1$@ 已删除邀请链接 %2$@"; +"Channel.AdminLog.EditedInviteLink" = "%1$@ 已编辑邀请链接 %2$@"; +"Channel.AdminLog.EndedVoiceChat" = "%1$@ 已结束视频聊天"; +"Channel.AdminLog.JoinedViaInviteLink" = "\n%1$@ 已通过邀请链接 %2$@ 加入"; +"Channel.AdminLog.JoinedViaInviteLink.ViaFolder" = "%1$@ 通过文件夹邀请链接加入 %2$@"; +"Channel.AdminLog.JoinedViaRequest" = "%3$@ 已批准 %1$@ 通过邀请链接 %2$@ 加入群组"; +"Channel.AdminLog.MessageChangedAutoremoveTimeoutRemove" = "%1$@ 已停用自动删除计时器"; +"Channel.AdminLog.MessageChangedAutoremoveTimeoutSet" = "%1$@ 已将自动删除计时器设为 %2$@"; +"Channel.AdminLog.MutedNewMembers" = "%1$@ 已将新加入视频聊天的参与者静音"; +"Channel.AdminLog.MutedParticipant" = "%1$@ 已在视频聊天中将 %2$@ 静音"; +"Channel.AdminLog.ReactionsDisabled" = "%1$@ 已停用表情回应"; +"Channel.AdminLog.ReactionsEnabled" = "%1$@ 已启用所有表情回应"; +"Channel.AdminLog.RevokedInviteLink" = "%1$@ 撤销了邀请链接 %2$@"; +"Channel.AdminLog.StartedVoiceChat" = "%1$@ 已发起视频聊天"; +"Channel.AdminLog.UnmutedMutedParticipant" = "%1$@ 在视频聊天中已取消 %2$@ 的静音"; +"Channel.AdminLog.UpdatedParticipantVolume" = "%1$@ 已将 %2$@ 的音量调整为 %3$@"; +"Channel.Admins.AddAdminError" = "抱歉,您不能将此用户设为管理员。"; +"Channel.Admins.Promote.BannedAdminError" = "抱歉,您无法将这位用户设为管理员。因为该用户在群组的已移除用户名单中,且您也无法解除其封禁。"; +"Channel.Admins.Promote.UnmemberAdminError" = "您无法将这位用户设为管理员,因为该用户不是此群组的成员,且您也无权邀请该用户。"; +"Channel.Admins.PromotedBy" = "被 %@ 设为管理员"; +"Channel.BanForever" = "永久"; +"Channel.BanUser.PermissionAddMembers" = "添加成员"; +"Channel.BanUser.PermissionChangeGroupInfo" = "修改群组信息"; +"Channel.BanUser.PermissionEmbedLinks" = "嵌入链接预览"; +"Channel.BanUser.PermissionReadMessages" = "查看消息"; +"Channel.BanUser.PermissionSendMedia" = "发送媒体文件"; +"Channel.BanUser.PermissionSendMessages" = "发送消息"; +"Channel.BanUser.PermissionSendPolls" = "发起投票"; +"Channel.BanUser.PermissionSendStickersAndGifs" = "发送贴纸和 GIF 动态图"; +"Channel.BanUser.PermissionsHeader" = "用户限制"; +"Channel.BanUser.Title" = "封禁用户"; +"Channel.BanUser.Unban" = "解除封禁"; +"Channel.Blacklist.BlockedBy" = "被 %@ 拉黑"; +"Channel.Blacklist.Context.AddToGroup" = "添加到群组"; +"Channel.Blacklist.Context.Remove" = "移除"; +"Channel.Blacklist.DemoteAdminError" = "您无法封禁这位用户,因为该用户是此群组的管理员,并且您也无权移除其管理员权限。"; +"Channel.Blacklist.DescChannel" = "被管理员移除的用户不能通过邀请链接重新加入频道。"; +"Channel.Blacklist.DescGroup" = "被管理员移除的用户不能通过邀请链接再次加入群组。"; +"Channel.Blacklist.RemoveUser" = "移除用户"; +"Channel.Blacklist.RestrictedBy" = "被 %@ 限制"; +"Channel.Blacklist.SelectNewUserTitle" = "成员"; +"Channel.Blacklist.Unban" = "解除封禁"; +"Channel.BlockUser.BlockFor" = "封禁时长"; +"Channel.BlockUser.CanEmbedLinks" = "嵌入链接"; +"Channel.BlockUser.CanReadMessages" = "能够查看消息"; +"Channel.BlockUser.CanSendMedia" = "发送媒体"; +"Channel.BlockUser.CanSendMessages" = "发送消息"; +"Channel.BlockUser.CanSendStickers" = "发送贴纸和 GIF"; +"Channel.Boost.PrivateError" = "这是私密频道"; +"Channel.BoostsStats.GetBoostsViaGifts" = "通过赠品助推"; +"Channel.BoostsStats.GetBoostsViaGifts.Info" = "通过向您的订阅者赠予 Telegram 高级版来为您的频道获取更多助推。"; +"Channel.BoostsStats.GetBoostsViaGifts.Info.Group" = "通过向您的成员赠送 Telegram 高级版来为您的群组获得更多助推。"; +"Channel.BoostsStats.Prepaid.Info" = "请选择您已支付的赠品活动以进行设定。"; +"Channel.BoostsStats.Prepaid.Months" = "%d 个月的订阅"; +"Channel.BoostsStats.Prepaid.Quantity" = "%d Telegram 高级版"; +"Channel.BoostsStats.Prepaid.Title" = "预付抽奖活动"; +"Channel.BotDoesntSupportGroups" = "抱歉,这个机器人告诉我们它不想被添加到群组中。 除非开发人员改变主意,否则无法添加此机器人。"; +"Channel.ChannelNameHolder" = "频道名称"; +"Channel.Comments.Count#other" = " %d 次留言"; +"Channel.Comments.Count_countable" = "%d"; +"Channel.Comments.LeaveComment" = "留言"; +"Channel.Comments.Short.Count#other" = "%d"; +"Channel.Comments.Short.Count_countable" = "%d"; +"Channel.Comments.Short.LeaveComment" = "评论"; +"Channel.Create" = "创建"; +"Channel.DescHeader" = "描述"; +"Channel.DescriptionHolder" = "简介"; +"Channel.DescriptionHolderDescrpiton" = "您可以给您的频道添加一段简介。"; +"Channel.EditAdmin.DeleteStories" = "删除其他用户的动态"; +"Channel.EditAdmin.EditStories" = "编辑其他人的动态"; +"Channel.EditAdmin.ManageCalls" = "管理语音聊天"; +"Channel.EditAdmin.ManageTopics" = "管理话题"; +"Channel.EditAdmin.Permission.AddNewAdmins" = "添加新管理员"; +"Channel.EditAdmin.Permission.BanUsers" = "封禁用户"; +"Channel.EditAdmin.Permission.ChangeInfo" = "修改频道信息"; +"Channel.EditAdmin.Permission.CreateTopics" = "创建话题"; +"Channel.EditAdmin.Permission.DeleteMessages" = "删除消息"; +"Channel.EditAdmin.Permission.EditMessages" = "编辑消息"; +"Channel.EditAdmin.Permission.InviteUsers" = "邀请用户"; +"Channel.EditAdmin.Permission.ManageDirect" = "管理私信"; +"Channel.EditAdmin.Permission.ManageMessages" = "管理消息"; +"Channel.EditAdmin.Permission.ManageStories" = "管理动态"; +"Channel.EditAdmin.Permission.PinMessages" = "置顶消息"; +"Channel.EditAdmin.Permission.PostMessages" = "发布消息"; +"Channel.EditAdmin.Permission.SendFiles" = "发送文件"; +"Channel.EditAdmin.Permission.SendInstantVideo" = "发送视频消息"; +"Channel.EditAdmin.Permission.SendMusic" = "发送音频"; +"Channel.EditAdmin.Permission.SendPhotos" = "发送图片"; +"Channel.EditAdmin.Permission.SendVideos" = "发送视频"; +"Channel.EditAdmin.Permission.SendVoice" = "发送语音消息"; +"Channel.EditAdmin.PermissionAnonymous" = "保持匿名"; +"Channel.EditAdmin.PermissionInviteMembers" = "添加成员"; +"Channel.EditAdmin.PermissionInviteSubscribers" = "添加订阅者"; +"Channel.EditAdmin.PermissionInviteViaLink" = "通过链接邀请用户"; +"Channel.EditAdmin.PostStories" = "发布动态"; +"Channel.Error.DontHavePermissions" = "抱歉,您不具有进行该操作所需的权限。"; +"Channel.ErrorAddBlocked" = "抱歉,您无法将此用户添加到频道。"; +"Channel.ErrorAddTooMuch" = "抱歉,您只能在订阅者未满 200 人时手动添加用户。然而通过频道邀请链接关注的用户无此数量限制。"; +"Channel.EventFilter.AdminsHeader" = "管理员"; +"Channel.EventFilter.EventsHeader" = "事件"; +"Channel.EventLog.Alert.Header" = "事件日志是什么?"; +"Channel.EventLog.Alert.Info" = "此列表包含了在过去 48 小时内群组成员和管理员执行的所有服务操作。"; +"Channel.EventLog.Empty" = "无"; +"Channel.EventLog.EmptySearch" = "**未找到操作记录**\n\n近期操作中找不到与您查询请求相符的结果。"; +"Channel.EventLog.EmptyText" = "**暂无操作记录**\n\n在过去的 48 小时内\n频道管理员没有执行\n任何值得注意的操作。"; +"Channel.EventLog.MessageChangedBackgroundEmojiSet" = "%1$@ 将背景表情符号设为 %2$@"; +"Channel.EventLog.MessageChangedBackgroundEmojiSet.New" = "%1$@ 更新了频道背景表情符号"; +"Channel.EventLog.MessageChangedLinkedChannel" = "%@ 已将 %@ 关联到本群组"; +"Channel.EventLog.MessageChangedLinkedGroup" = "%@ 已将 %@ 设置为讨论组"; +"Channel.EventLog.MessageChangedNameAndBackground" = "%1$@ 更新了频道颜色和背景表情符号"; +"Channel.EventLog.MessageChangedNameColorSet" = "%1$@ 将名称颜色设为 %2$@"; +"Channel.EventLog.MessageChangedNameColorSet.New" = "%1$@ 已更新频道名称颜色"; +"Channel.EventLog.MessageChangedUnlinkedChannel" = "%@ 已将此群组与 %@ 取消关联"; +"Channel.EventLog.MessageChangedUnlinkedGroup" = " %@ 已移除讨论组"; +"Channel.EventLog.MessageChangedWallpaper" = "%1$@ 更新了频道壁纸"; +"Channel.EventLog.MessageRankName" = "为 %@: %@ 更改自定义头衔"; +"Channel.EventLog.MessageToggleNoForwardsOff" = "%@ 已允许消息转发"; +"Channel.EventLog.MessageToggleNoForwardsOn" = "%@ 已限制消息转发"; +"Channel.EventLog.MessageTransfered" = "转让所有权"; +"Channel.EventLog.MessageTransferedName" = "已将所有权转让给 %@(%@)"; +"Channel.EventLog.MessageTransferedName1" = "将所有权转让给 %1$@ %2$@"; +"Channel.EventLog.OriginalMessage" = "原始消息"; +"Channel.EventLog.Service.AboutRemoved" = "%@ 移除了频道简介:"; +"Channel.EventLog.Service.AboutUpdated" = "%@ 更改了频道简介:"; +"Channel.EventLog.Service.AntispamDisabled" = "%@ 已停用反垃圾措施"; +"Channel.EventLog.Service.AntispamEnabled" = "%@ 已启用反垃圾措施"; +"Channel.EventLog.Service.DisableSignatures" = "%@ 已停用频道消息署名"; +"Channel.EventLog.Service.DisabledSlowMode" = "%@ 关闭了慢速模式"; +"Channel.EventLog.Service.DisabledTopics" = "%@ 已停用话题"; +"Channel.EventLog.Service.EnableSignatures" = "%@ 启用了消息署名"; +"Channel.EventLog.Service.EnabledTopics" = "%@ 已启用话题"; +"Channel.EventLog.Service.LinkRemoved" = "%@ 移除了频道链接:"; +"Channel.EventLog.Service.LinkUpdated" = "%@ 更改了频道链接:"; +"Channel.EventLog.Service.MessageGroupPreHistoryHidden" = "%@ 已设置消息记录对新成员不可见"; +"Channel.EventLog.Service.MessageGroupPreHistoryVisible" = "%@ 已将消息记录设置为对新成员可见"; +"Channel.EventLog.Service.MinusTitle" = "- 标题"; +"Channel.EventLog.Service.PhotoRemoved" = "%@ 移除了频道头像"; +"Channel.EventLog.Service.PhotoUpdated" = "%@ 更换了频道头像"; +"Channel.EventLog.Service.PlusTitle" = "+ 标题: %@"; +"Channel.EventLog.Service.SetSlowMode" = "%@ 将慢速模式的时间改为 %@"; +"Channel.EventLog.Service.SetSlowMode1" = "%1$@ 将慢速模式的时间改为 %2$@"; +"Channel.EventLog.Service.TitleUpdated" = "%@ 更改了频道名称:"; +"Channel.EventLog.Service.TopicCreated" = "%1$@ 已创建话题 ”%2$@\""; +"Channel.EventLog.Service.TopicEdited" = "%1$@ 将话题名称编辑为 ”%2$@\""; +"Channel.EventLog.Service.UpdateJoin" = "%@ 已加入频道"; +"Channel.EventLog.Service.UpdateLeft" = "%@ 已退出频道"; +"Channel.EventLog.What" = "这是什么?"; +"Channel.EventLogs.Filter.Title" = "聊天管理员"; +"Channel.EventLogs.TitleFull" = "所有操作"; +"Channel.EventLogs.TitleSelected" = "所选操作"; +"Channel.Exception.DisabledOption.Channel" = "此选项在频道权限中对所有成员禁用。"; +"Channel.Exception.DisabledOption.Group" = "对群组中的所有成员禁用此选项。"; +"Channel.ExportLinkAboutChannel" = "用户可通过以上链接加入您的频道。您可以随时撤销此链接。"; +"Channel.ExportLinkAboutGroup" = "用户可以通过此链接加入您的群组。您可以随时撤销此链接。"; +"Channel.Intro.CreateChannel" = "创建频道"; +"Channel.IntroDescription" = "频道是一种能够将您的消息广播给无数关注者的工具。"; +"Channel.IntroDescriptionHeader" = "什么是频道?"; +"Channel.Member.EditTag" = "编辑标签"; +"Channel.Member.Promote" = "提权"; +"Channel.Member.Restrict" = "限制"; +"Channel.NameHeader" = "频道名字"; +"Channel.NewChannel" = "新建频道"; +"Channel.Permissions.SlowMode.Header" = "慢速模式"; +"Channel.Permissions.SlowMode.Text.Off" = "选择每个成员必须等多长时间才能发送下一条消息。"; +"Channel.Permissions.SlowMode.Text.Selected" = "成员每 %@ 只能发送一条消息"; +"Channel.Permissions.SlowMode.Timeout.10s" = "10 秒"; +"Channel.Permissions.SlowMode.Timeout.15m" = "15 分钟"; +"Channel.Permissions.SlowMode.Timeout.1h" = "1 小时"; +"Channel.Permissions.SlowMode.Timeout.1m" = "1 分钟"; +"Channel.Permissions.SlowMode.Timeout.30s" = "30 秒"; +"Channel.Permissions.SlowMode.Timeout.5m" = "5 分钟"; +"Channel.Permissions.SlowMode.Timeout.Off" = "关闭"; +"Channel.Persmission.Denied.SendFile.DefaultRestrictedText" = "此群组不允许发送文件。"; +"Channel.Persmission.Denied.SendFile.Forever" = "该群组的管理员已限制您在此发送文件。"; +"Channel.Persmission.Denied.SendFile.Until" = "该群组的管理员已限制您在 %@ 之前在此发送文件。"; +"Channel.Persmission.Denied.SendGifs.DefaultRestrictedText" = "此群组不允许发送 GIF 动态图。"; +"Channel.Persmission.Denied.SendGifs.Forever" = "此群组的管理员已限制您发送 GIF 动态图。"; +"Channel.Persmission.Denied.SendGifs.Until" = "此群组的管理员已限制您发送 GIF 动态图直到 %@。"; +"Channel.Persmission.Denied.SendInline.DefaultRestrictedText" = "此群组不允许发送机器人内容。"; +"Channel.Persmission.Denied.SendInline.Forever" = "此群组的管理员已限制您发送机器人内容。"; +"Channel.Persmission.Denied.SendInline.Until" = "此群组的管理员已限制您在 %@ 之前发送机器人内容。"; +"Channel.Persmission.Denied.SendInstantVideo.DefaultRestrictedText" = "对不起,你不能在本群内发送视频消息。"; +"Channel.Persmission.Denied.SendInstantVideo.Forever" = "该群组的管理员已限制您在此发送视频消息。"; +"Channel.Persmission.Denied.SendInstantVideo.Until" = "该群组的管理员已限制您在 %@ 之前发送视频消息。"; +"Channel.Persmission.Denied.SendMedia.DefaultRestrictedText" = "该群组不允许发送媒体文件。"; +"Channel.Persmission.Denied.SendMedia.Forever" = "此群组的管理员已限制您发送媒体文件。"; +"Channel.Persmission.Denied.SendMedia.Until" = "该群组的管理员限制了您在 %@ 之前发送媒体文件。"; +"Channel.Persmission.Denied.SendMessages.DefaultRestrictedText" = "此群组不允许发送消息。"; +"Channel.Persmission.Denied.SendMessages.Forever" = "此群组的管理员已限制您发送消息。"; +"Channel.Persmission.Denied.SendMessages.Until" = "该群组的管理员限制了您在 %@ 之前发送消息。"; +"Channel.Persmission.Denied.SendMusic.DefaultRestrictedText" = "此群组不允许发送音频。"; +"Channel.Persmission.Denied.SendMusic.Forever" = "群管理员已限制您在本群发送音乐消息。"; +"Channel.Persmission.Denied.SendMusic.Until" = "该群组的管理员已限制您在 %@ 前发送音乐。"; +"Channel.Persmission.Denied.SendPhoto.DefaultRestrictedText" = "此群组不允许发送图片。"; +"Channel.Persmission.Denied.SendPhoto.Forever" = "该群组的管理员已限制您发送图片。"; +"Channel.Persmission.Denied.SendPhoto.Until" = "该群组的管理员已限制您在 %@ 前发送图片。"; +"Channel.Persmission.Denied.SendPoll.DefaultRestrictedText" = "此群组不允许发布投票。"; +"Channel.Persmission.Denied.SendPoll.Forever" = "此群组的管理员已限制您发送投票。"; +"Channel.Persmission.Denied.SendPoll.Until" = "该群租的管理员限制了您在 %@ 之前在此发起投票。"; +"Channel.Persmission.Denied.SendStickers.DefaultRestrictedText" = "本群组禁止发送贴图表情。"; +"Channel.Persmission.Denied.SendStickers.Forever" = "此群组的管理员已限制您发送贴纸。"; +"Channel.Persmission.Denied.SendStickers.Until" = "此群组管理员已禁止您发送贴图,直到 %@。"; +"Channel.Persmission.Denied.SendVideo.DefaultRestrictedText" = "此群组不允许发送视频。"; +"Channel.Persmission.Denied.SendVideo.Forever" = "群管理员已限制您在本群发送视频消息。"; +"Channel.Persmission.Denied.SendVideo.Until" = "该群组的管理员已限制您在 %@ 前发送视频。"; +"Channel.Persmission.Denied.SendVoice.DefaultRestrictedText" = "此群组不允许发送语音消息。"; +"Channel.Persmission.Denied.SendVoice.Forever" = "群组管理员已禁止你在群内发送语音消息。"; +"Channel.Persmission.Denied.SendVoice.Until" = "该群组的管理员已限制您在 %@ 之前发送语音消息。"; +"Channel.Persmission.MessageBlock" = "不允许发送文本"; +"Channel.Private" = "私密"; +"Channel.PrivateAboutChannel" = "私密频道只能通过邀请链接加入。"; +"Channel.PrivateAboutGroup" = "私密群组只能通过他人的邀请或邀请链接加入。"; +"Channel.Public" = "公开"; +"Channel.PublicAboutChannel" = "公开频道可以通过搜索找到,任何人都可以加入。"; +"Channel.PublicAboutGroup" = "任何人都可以搜索、加入公开群组,并查看历史聊天记录。"; +"Channel.PublicNamesLimitError" = "您保留了太多公共用户名。您可以撤消其中一个较旧群组或频道的链接,也可以创建一个私人链接。"; +"Channel.RevokeLink" = "撤销链接"; +"Channel.RevokeLink.Confirm.Header" = "撤销链接"; +"Channel.RevokeLink.Confirm.OK" = "撤销"; +"Channel.RevokeLink.Confirm.Text" = "您确定要撤销此邀请链接吗?一旦撤销,其他人将不能通过此链接加入此群组。"; +"Channel.SelectPeers.Contacts" = "联系人"; +"Channel.SelectPeers.Global" = "全局"; +"Channel.Similar.Premium" = "订阅 **[Telegram Premium]()** 可解锁高达 **%d** 个类似频道"; +"Channel.SlowMode.Off" = "关闭"; +"Channel.SlowMode.ToolTip" = "慢速模式已启用。\n您可以在 %@:%@ 后发送下一条消息。"; +"Channel.Stats.Loading" = "**准备图表中**\n正在生成图表,请稍等"; +"Channel.Stats.Overview.Reactions" = "回应"; +"Channel.Stats.Overview.Story.PrivateShares" = "私下分享"; +"Channel.Stats.Overview.Views" = "浏览次数"; +"Channel.TooMuchBots" = "抱歉,此群组的机器人数量已达上限。请先删除一些您不使用的机器人。"; +"Channel.TransferOwner.Error.Enable2FA" = "启用两步验证"; +"Channel.TransferOwner.Error.Text" = "确认此操作需满足以下条件:\n\n• 您的帐号启用两步验证已超过 **7天** 。\n\n• 最近一次更改两步验证密码已超过 **7天** 。\n\n• 在当前设备登录时长已超过 **24 小时** 。"; +"Channel.TransferOwner.Error.Title" = "安全检查"; +"Channel.TransferOwner.ErrorAdminsTooMuch" = "抱歉,目前本频道的管理员太多,无法添加新所有者,请先删除一个现有管理员。"; +"Channel.TransferOwner.ErrorPrivacyRestricted" = "抱歉,此用户不是该频道的成员,其隐私设置会阻止您手动添加。"; +"Channel.TransferOwner.ErrorPublicChannelsTooMuch" = "抱歉,目标用户已经有太多的公开群组或频道。 请让他们先将他们现有的一个群组或频道设为私有。"; +"Channel.TypeHeaderChannel" = "频道类型"; +"Channel.TypeHeaderGroup" = "群组类型"; +"Channel.UserRestriction" = "用户限制"; +"Channel.Username.Activate.Info.Channel" = "您想要将此链接显示在频道信息页面上吗?"; +"Channel.Username.Activate.Info.Group" = "您想在群组信息页上显示此链接吗?"; +"Channel.Username.Activate.Ok.Channel" = "显示"; +"Channel.Username.Activate.Ok.Group" = "显示"; +"Channel.Username.Activate.Title.Channel" = "启用用户名"; +"Channel.Username.Activate.Title.Group" = "激活用户名"; +"Channel.Username.Deactivate.Info.Channel" = "你想要从频道信息页面上隐藏此链接吗?"; +"Channel.Username.Deactivate.Info.Group" = "是否要将此链接从群组信息页中隐藏?"; +"Channel.Username.Deactivate.Ok.Channel" = "隐藏"; +"Channel.Username.Deactivate.Ok.Group" = "隐藏"; +"Channel.Username.Deactivate.Title.Channel" = "停用用户名"; +"Channel.Username.Deactivate.Title.Group" = "停用用户名"; +"Channel.Username.List.Info.Channel" = "拖放链接以更改它们在频道信息页面上的显示顺序。"; +"Channel.Username.List.Info.Group" = "拖放链接以更改它们在群组信息页面上的显示顺序。"; +"Channel.Username.List.Title" = "链接顺序"; +"Channel.Username.UsernamePurchaseAvailable" = "此用户名已被占用。\n但是,目前可以购买。**了解更多...**"; +"Channel.UsernameAboutChannel" = "人们可以与他人分享这个链接,并使用 Telegram 搜索找到你的频道。"; +"Channel.UsernameAboutGroup" = "用户可以分享此链接给他人,并且能通过搜索找到您的群组。"; +"Channel.Wallpaper.ApplyBoth" = "为自己和 %@ 应用"; +"ChannelAdmins.AggressiveAntiSpam" = "积极的反垃圾信息策略"; +"ChannelAdmins.AggressiveAntiSpam.Info" = "Telegram 会过滤更多的垃圾信息,但偶尔可能会影响普通消息。您可以在最近的操作中申诉误报。"; +"ChannelAdmins.RecentActions" = "近期操作"; +"ChannelBlacklist.AddMember" = "限制成员"; +"ChannelBlacklist.Blocked" = "已屏蔽"; +"ChannelBlacklist.EmptyDescrpition" = "被加入黑名单的用户会从群组中移除,并且不能使用邀请链接重返群组,只能通过管理员的邀请返回。"; +"ChannelBlacklist.Restricted" = "受限制"; +"ChannelBoost.AdditionalFeatures.Text" = "通过获得助推,您的群组可以达到更高的等级并解锁更多的功能。"; +"ChannelBoost.AdditionalFeatures.Title" = "额外功能"; +"ChannelBoost.Autotranslation" = "消息自动翻译"; +"ChannelBoost.BoostChannel" = "助推频道"; +"ChannelBoost.BoostGroup" = "助推群组"; +"ChannelBoost.BoostedChannelMoreRequired" = "%1$@ 需要能够发布 %2$@ 的权限."; +"ChannelBoost.BoostedChannelMoreRequired.New" = "还需要 %1$@ 次助推来获得新功能。 "; +"ChannelBoost.BoostedChannelReachedLevel" = "此频道已达到 **等级 %1$@** ,现在可以发布 %2$@ 。"; +"ChannelBoost.BoostedChannelReachedLevel.Channel" = "此频道已达到 **等级 %1$@**。"; +"ChannelBoost.BoostedChannelReachedLevel.Group" = "此群组已达到 **等级 %1$@**。"; +"ChannelBoost.Booster.Duration" = "%d个月"; +"ChannelBoost.Booster.Stars#other" = "%d 颗星星"; +"ChannelBoost.Booster.Stars_countable" = "%d"; +"ChannelBoost.Booster.ToBeDistributed" = "待分发"; +"ChannelBoost.Booster.Unclaimed" = "尚未领取"; +"ChannelBoost.EmojiPack" = "使用表情包"; +"ChannelBoost.EmojiStatus" = "使用表情状态"; +"ChannelBoost.EnableAutoTranslationText" = "您的频道需要达到 **等级 %1$@ 才能启用自动翻译。\n\n请您的 **高级版** 订阅者通过此链接为频道助推:"; +"ChannelBoost.EnableColors" = "启用颜色"; +"ChannelBoost.EnableColorsText" = "您的频道需要 **等级 %1$@** 才能更改频道颜色。 \n\n请您的 **Telegram 高级版** 订阅者通过此链接助推您的频道:"; +"ChannelBoost.EnableEmojiStatusLevelText" = "您的频道需要达到 **等级 %1$@** 才能使用表情符号状态。\n\n请 **Telegram 高级版** 订阅者通过此链接来助推您的频道:"; +"ChannelBoost.EnableEmojiStatusLevelText.Group" = "您的群组需要 **等级 %1$@** 才能使用表情符号状态。 \n\n请您的 **Premium** 成员通过此链接助推您的群组:"; +"ChannelBoost.EnableNameIconLevelText" = "您的频道需要达到 **等级 %1$@** 才能更改频道链接样式图标。 \n\n请您的 **高级版** 订阅者通过此链接助推您的频道:"; +"ChannelBoost.EnableNoAdsLevelText" = "您的频道需要达到 **等级 %1$@** 才能关闭广告。"; +"ChannelBoost.EnableProfileColorLevelText" = "您的频道需要达到 **等级 %1$@ ** 才能更改频道颜色。\n\n请您的 **Telegram 高级版** 订阅者通过此链接助推您的频道:"; +"ChannelBoost.EnableProfileColorLevelText.Group" = "您的群组需要达到 **等级 %1$@** 才能更改群组封面颜色。"; +"ChannelBoost.EnableProfileIconLevelText" = "您的频道需要达到 **等级 %1$@** 才能更改封面标志。\n\n请 **Telegram 高级版** 订阅者通过此链接来助推您的频道:"; +"ChannelBoost.EnableProfileIconLevelText.Group" = "您的群组需要**等级 %1$@** 才能更改频道封面图标。 \n\n请您的 **Premium** 成员通过此链接助推您的群组:"; +"ChannelBoost.EnableReactions" = "自定义表情回应"; +"ChannelBoost.EnableReactionsText" = "您的频道需要达到 **等级 %1$@** 才能添加 **%2$@** 个自定义表情回应。\n\n邀请您的 **高级版** 订阅者们通过此链接助推频道:"; +"ChannelBoost.EnableReactionsText.Group" = "您的群组需要达到 **等级 %1$@** 才能加入 %2$@ 自订表情符号作为回应。 \n\n请您的 **Premium** 成員通过此链接助推您的群组:"; +"ChannelBoost.EnableStories" = "启用动态"; +"ChannelBoost.EnableStoriesForChannel" = "为此频道启用动态"; +"ChannelBoost.EnableStoriesForChannelText" = "**%1$@** 还需要 %2$@ 才能发布动态。帮助实现这个目标吧!"; +"ChannelBoost.EnableStoriesForOtherChannel" = "为此频道启用动态"; +"ChannelBoost.EnableStoriesMoreRequired" = "此频道需要 **%1$@** 来启用动态功能。"; +"ChannelBoost.EnableStoriesText" = "您的频道还需要 %1$@ 才能发布动态。\n\n请 **Telegram 高级版** 订阅者通过此链接来助推您的频道:"; +"ChannelBoost.EnableWallpapers" = "启用壁纸"; +"ChannelBoost.EnableWallpapersText" = "您的频道需要达到 **等级 %1$@** 才能更改背景。 \n\n请您的 **Premium** 订阅者通过此链接助推您的频道:"; +"ChannelBoost.EnableWallpapersText.Group" = "您的群组需要**等级 %1$@** 才能更改背景。 \n\n请您的 **Premium** 成员通过此链接助推您的群组:"; +"ChannelBoost.EnabledStoriesForChannelText" = "此频道已达到 **等级 1**,现在可以发布动态了。"; +"ChannelBoost.Error.BoostTooOftenText" = "您每天只能更改一次助推的频道。 您可以在 **%@** 后再次助推。"; +"ChannelBoost.Error.BoostTooOftenTitle" = "不能频繁助推!"; +"ChannelBoost.Error.GiftedPremiumNotAllowedText" = "由于您的 **Telegram 高级版** 是通过赠送获得的,所以您无法用其助推频道。"; +"ChannelBoost.Error.GiftedPremiumNotAllowedTitle" = "无法使用受赠的 Telegram 高级版助推!"; +"ChannelBoost.Error.PremiumNeededText" = "只有 **Telegram 高级版** 订阅者可以助推频道。您是否要订阅 **Telegram 高级版**?"; +"ChannelBoost.Error.PremiumNeededText.OK" = "阅读更多"; +"ChannelBoost.Error.PremiumNeededTitle" = "需要订阅 Telegram 高级版"; +"ChannelBoost.HelpUpgradeChannel" = "帮助升级此频道"; +"ChannelBoost.HelpUpgradeChannelText" = "**%1$@** 需要 %2$@ 才能发布 %3$@。"; +"ChannelBoost.HelpUpgradeGroup" = "帮助此群组升级"; +"ChannelBoost.IncreaseLimit" = "增加动态上限"; +"ChannelBoost.IncreaseLimitText" = "您的频道需要 %1$@ 才能每天发布 %2$@。\n\n请 **Telegram 高级版** 订阅者通过此链接来助推您的频道:"; +"ChannelBoost.IncreaseLimitText.Channel" = "您的频道需要 %1$@ 才能存取新功能。 \n\n请您的 **Premium** 订阅者通过此链接助推您的频道:"; +"ChannelBoost.IncreaseLimitText.Group" = "您的群组需要 %1$@ 才能存取新功能。 \n\n请您的 **Premium** 成员通过此链接助推您的群组:"; +"ChannelBoost.Level" = "等级 %@"; +"ChannelBoost.MaxLevelReached" = "已达到最高等级"; +"ChannelBoost.MaxLevelReachedText" = "此频道已达到 **等级\u00a0%1$@**,现在每天可发布 %2$@。"; +"ChannelBoost.MaxLevelReachedText.Channel" = "此频道已达到 **等级 %1$@** 并可以存取所有功能。"; +"ChannelBoost.MaxLevelReachedText.Group" = "此群组已达到 **等级 %1$@** 并可以存取所有功能。"; +"ChannelBoost.MaxLevelReachedTextAuthor" = "%1$@ 已达到 **等级 %2$@**,现在可以发布 %3$@。"; +"ChannelBoost.MoreBoosts#other" = "额外 **%d** 次助推"; +"ChannelBoost.MoreBoosts_countable" = "%d"; +"ChannelBoost.NameIcon" = "设置链接图标"; +"ChannelBoost.NoAds" = "关闭广告"; +"ChannelBoost.ProfileColor" = "设置封面颜色"; +"ChannelBoost.ProfileIcon" = "设置封面图标"; +"ChannelBoost.Replace" = "更换"; +"ChannelBoost.ReplaceBoost" = "您目前正在助推 **%1$@**,您是否想改为助推 **%2$@**?"; +"ChannelBoost.StoriesPerDay#other" = "每天发布 **%d 条** 动态"; +"ChannelBoost.StoriesPerDay_countable" = "%d"; +"ChannelBoost.Table.AudioTranscription" = "语音转文字"; +"ChannelBoost.Table.CustomReactions#other" = "%d 个自定义表情回应"; +"ChannelBoost.Table.CustomReactions_countable" = "%d"; +"ChannelBoost.Table.CustomWallpaper" = "自定义频道壁纸"; +"ChannelBoost.Table.CustomWallpaper.Group" = "自定义群组背景"; +"ChannelBoost.Table.EmojiPack" = "自定义表情包"; +"ChannelBoost.Table.EmojiStatus" = "1000+ 种表情状态"; +"ChannelBoost.Table.HeadersLogo" = "链接和引用的自定义标志"; +"ChannelBoost.Table.LevelUnlocks#other" = "等级 %d 解锁:"; +"ChannelBoost.Table.LevelUnlocks_countable" = "%d"; +"ChannelBoost.Table.NameColor#other" = "%d 种频道名称颜色"; +"ChannelBoost.Table.NameColor_countable" = "%d"; +"ChannelBoost.Table.NoAds" = "关闭广告"; +"ChannelBoost.Table.ProfileColor#other" = "%d 种频道封面颜色"; +"ChannelBoost.Table.ProfileColor.Group#other" = "%d 种群组封面颜色"; +"ChannelBoost.Table.ProfileColor.Group_countable" = "%d"; +"ChannelBoost.Table.ProfileColor_countable" = "%d"; +"ChannelBoost.Table.ProfileLogo" = "频道封面的自定义标志"; +"ChannelBoost.Table.ProfileLogo.Group" = "为群组的封面自定义一个标志"; +"ChannelBoost.Table.StoriesPerDay#other" = "每天 %d 条状态"; +"ChannelBoost.Table.StoriesPerDay_countable" = "%d"; +"ChannelBoost.Table.StyleForHeaders#other" = "%d 种链接及引用样式"; +"ChannelBoost.Table.StyleForHeaders_countable" = "%d"; +"ChannelBoost.Table.Wallpaper#other" = "%d 种频道壁纸"; +"ChannelBoost.Table.Wallpaper.Group#other" = "%d 个群组背景"; +"ChannelBoost.Table.Wallpaper.Group_countable" = "%d"; +"ChannelBoost.Table.Wallpaper_countable" = "%d"; +"ChannelBoost.Title.Channel" = "助推频道"; +"ChannelBoost.Title.Group" = "助推群组"; +"ChannelBoost.UnblockSlowmode.Group" = "助推群组 **%1$@** 次以移除慢速模式限制。您的助推将有助于 **%2$@** 解锁新功能。"; +"ChannelBoost.UnblockSlowmode.Group.Full" = "助推群组 **%1$@** 次以移除慢速模式限制。"; +"ChannelBoost.UnblockSlowmode.Group.Full_countable" = "%d"; +"ChannelBoost.UnblockSlowmode.Group_countable" = "%d %@"; +"ChannelBoost.UnblockText.Group" = "助推群组 **%1$@** 次以移除消息限制。您的助推将有助于 **%2$@** 解锁新功能。"; +"ChannelBoost.UnblockText.Group.Full" = "助推群组 **%1$@** 次以移除消息限制。"; +"ChannelBoost.UnblockText.Group.Full_countable" = "%d"; +"ChannelBoost.UnblockText.Group_countable" = "%d %@"; +"ChannelBoost.YouBoostedChannel" = "您助推了 %1$@!"; +"ChannelBoost.YouBoostedOtherChannel" = "您助推了此频道!"; +"ChannelBoost.ZeroLevelText.Channel" = "您的频道需要 %1$@ 才能存取新功能。 \n\n请您的 **Premium** 成员通过此链接助推您的频道:"; +"ChannelBoost.ZeroLevelText.Group" = "您的群组需要 %1$@ 才能存取新功能。 \n\n请您的 **Premium** 成员通过此链接助推您的群组:"; +"ChannelEventFilter.Calls" = "电话"; +"ChannelEventFilter.ChannelInfo" = "频道信息"; +"ChannelEventFilter.ChannelSettings" = "频道设置"; +"ChannelEventFilter.DeletedMessages" = "已删除消息"; +"ChannelEventFilter.EditedMessages" = "已编辑消息"; +"ChannelEventFilter.GroupInfo" = "群组信息"; +"ChannelEventFilter.GroupSettings" = "群组设置"; +"ChannelEventFilter.Invites" = "邀请链接"; +"ChannelEventFilter.LeavingMembers" = "成员退出"; +"ChannelEventFilter.MembersAndAdmins" = "成员与管理员"; +"ChannelEventFilter.Messages" = "消息"; +"ChannelEventFilter.NewAdmins" = "管理员权限"; +"ChannelEventFilter.NewMembers" = "新成员"; +"ChannelEventFilter.NewRestrictions" = "新限制"; +"ChannelEventFilter.PinnedMessages" = "已置顶的消息"; +"ChannelEventFilter.SendMessages" = "发送消息"; +"ChannelEventFilter.VoiceChats" = "语音聊天"; +"ChannelInfo.AddUserLeftError" = "若用户曾离开群组,您需要成为其联系人才能将其加回群组。\n\n只要其不在已移除用户列表中,该用户仍可通过邀请链接加入群组。"; +"ChannelInfo.FakeWarning" = "⚠️ 警告:大量用户举报该账号冒名顶替名人或组织。"; +"ChannelInfo.ScamWarning" = "⚠️ 警告:许多用户报告了此账户的诈骗行为。若对方谈及金钱交易,请格外小心。"; +"ChannelMembers.AddMembers" = "添加成员"; +"ChannelMembers.AddSubscribers" = "添加订阅者"; +"ChannelMembers.Contacts" = "此频道中的联系人"; +"ChannelMembers.Group.Contacts" = "本群的联系人"; +"ChannelMembers.Group.HideMembers" = "隐藏成员"; +"ChannelMembers.Group.HideMembers.Info" = "打开此选项可隐藏该群组的成员列表。管理员将仍然可见。"; +"ChannelMembers.Group.OtherMembers" = "其他成员"; +"ChannelMembers.InviteLink" = "通过链接邀请"; +"ChannelMembers.MembersListDesc" = "此列表仅频道管理员可见。"; +"ChannelMembers.OtherMembers" = "其他订阅者"; +"ChannelMembers.Select.Title" = "添加成员"; +"ChannelMessages.Header" = "允许用户向您的频道发送消息,并可选择对每条消息收取费用。"; +"ChannelMessages.Paid.Free" = "免费"; +"ChannelMessages.Paid.Header" = "每条消息的价格"; +"ChannelMessages.Paid.Info" = "每逢收到一条消息,您将获得设置费的 %1$@(~%2$@$)。"; +"ChannelMessages.Paid.Info.Zero" = "每收到一条消息,您的频道将获取设置费用的 %1$@。"; +"ChannelMessages.Text" = "私信"; +"ChannelMessages.Title" = "私信"; +"ChannelParticipant.AddTag" = "添加标签"; +"ChannelParticipant.EditTag" = "编辑标签"; +"ChannelParticipant.Member" = "成员"; +"ChannelParticipant.Permissions" = "权限"; +"ChannelParticipant.PreviewMessage" = "早上好 👋"; +"ChannelParticipant.PromoteToAdmin" = "设为管理员"; +"ChannelParticipant.RemoveMember" = "移除成员"; +"ChannelParticipant.RemoveTag" = "移除标签"; +"ChannelParticipant.SaveChanges" = "保存更改"; +"ChannelParticipant.TagDescOther" = "在 %1$@ 的名称旁添加简短标签。"; +"ChannelParticipant.TagDescSelf" = "分享您在群组中的角色、头衔或昵称。您的标签会对所有成员可见。"; +"ChannelParticipant.TagUpdateError" = "更新标签时发生错误。"; +"ChannelParticipant.TagUpdated" = "标签已更新。"; +"ChannelPermission.PaidMessages.ChargeStars" = "为消息花费星星"; +"ChannelPermission.PaidMessages.ChargeStars.Info" = "如果启用此功能,群组的普通成员必须支付星星才能发送消息。"; +"ChannelPermission.PaidMessages.Setup.Header" = "设置每条消息的价格"; +"ChannelPermission.PaidMessages.Setup.Info" = "你的群组每有一条消息就会获取 %1$@ 设置费用(%2$@)"; +"ChannelPermission.RestrictBoosters" = "不限制助推者"; +"ChannelPermission.RestrictBoosters.Active" = "选择用户必须为群组助推几次才能不受发送消息的限制。"; +"ChannelPermission.RestrictBoosters.NonActive" = "启用此功能可始终允许已助推您群组的用户发送消息和媒体。"; +"ChannelPermissions.EditOwnTags" = "编辑自己的标签"; +"ChannelPermissions.NoEditTags" = "禁止编辑标签"; +"ChannelPermissions.NoReactions" = "禁止表情回应"; +"ChannelPermissions.SendReactions" = "发送表情回应"; +"ChannelReactions.Add" = "添加表情回应..."; +"ChannelReactions.Add.Info" = "您也可以 **创建自己的** 表情包并使用它们。"; +"ChannelReactions.EnableReactions" = "启用表情回应"; +"ChannelReactions.EnableReactions.Info" = "您可以添加任意表情包中的表情作为回应。"; +"ChannelReactions.EnableStars" = "启用付费回应"; +"ChannelReactions.EnableStars.Info" = "打开此选项,让您的订阅者可以用 Telegram 星星对贴文作出付费回应,您之后可以以 TON 的形式提取。[了解更多 >](https://telegram.org/tos/content-creator-rewards#2-3-subscriber-donations)"; +"ChannelReactions.LevelRequired" = "需要助推等级 %d"; +"ChannelReactions.MaxCount.Info" = "限制可以加入到帖子中的表情回应类型数量,包括已发布的帖子。"; +"ChannelReactions.MaxCount.Title" = "表情反应最大数量"; +"ChannelReactions.OK" = "更新表情回应"; +"ChannelReactions.Placeholder" = "添加回应…"; +"ChannelReactions.Success" = "表情符号回应已成功更新"; +"ChannelReactions.Title" = "回应"; +"ChannelStats.Graph.Followers" = "订阅者"; +"ChannelStats.Graph.Growth" = "增长"; +"ChannelStats.Graph.Interactions" = "互动"; +"ChannelStats.Graph.Language" = "语言"; +"ChannelStats.Graph.NewFollowersBySource" = "新订阅者的来源"; +"ChannelStats.Graph.Notifications" = "通知"; +"ChannelStats.Graph.Reactions" = "按情绪分类的回应"; +"ChannelStats.Graph.Stories" = "动态互动"; +"ChannelStats.Graph.StoriesReactions" = "动态回应"; +"ChannelStats.Graph.ViewsByHours" = "每小时浏览次数 (UTC)"; +"ChannelStats.Graph.ViewsBySource" = "浏览来源"; +"ChannelStats.Overview" = "概况"; +"ChannelStats.Overview.EnabledNotifications" = "启用通知"; +"ChannelStats.Overview.Followers" = "订阅者"; +"ChannelStats.Overview.ReactionsPerPost" = "每则帖子的回应次数"; +"ChannelStats.Overview.ReactionsPerStory" = "每条动态的表情回应"; +"ChannelStats.Overview.SharesPerPost" = "每则贴文的分享次数"; +"ChannelStats.Overview.SharesPerStory" = "每条动态的分享次数"; +"ChannelStats.Overview.ViewsPerPost" = "每则贴文的查看次数"; +"ChannelStats.Overview.ViewsPerStory" = "每条动态的浏览量"; +"ChannelStats.Recent.Header" = "近期贴文"; +"ChannelStats.SharesCount#other" = "%d 次分享"; +"ChannelStats.SharesCount_ZeroValueHolder" = "无人分享"; +"ChannelStats.SharesCount_countable" = "%d"; +"ChannelStats.Title" = "频道统计"; +"ChannelStats.ViewsCount#other" = "%d 次浏览"; +"ChannelStats.ViewsCount_ZeroValueHolder" = "无人观看"; +"ChannelStats.ViewsCount_countable" = "%d"; +"ChannelVisibility.Checking" = "正在检查…"; +"ChannelVisibility.Confirm.MakePrivate.Channel" = "如果您将频道设为私密频道,@%@ 这一名称将会被移除。任何人都将可以将其用于他们的公开群组或频道。"; +"ChannelVisibility.Confirm.MakePrivate.Group" = "如果您将群组设为私密群组,@%@ 这一名称将会被移除。任何人都将可以将其用于他们的公开群组或频道。"; +"ChannelVisibility.Confirm.Revoke" = "您确定要将此频道设为私密并删除其用户名吗?"; +"ChannelVisibility.Forwarding.ChannelInfo" = "订阅者将可以拷贝、保存及转发该频道的内容。"; +"ChannelVisibility.Forwarding.ChannelInfo1" = "订阅者将可以拷贝、保存及转发此群组中的内容。"; +"ChannelVisibility.Forwarding.ChannelInfo1.Restrict" = "订阅者将无法拷贝、保存或转发此频道的内容。"; +"ChannelVisibility.Forwarding.ChannelTitle" = "从此频道转发"; +"ChannelVisibility.Forwarding.Disabled" = "限制保存内容"; +"ChannelVisibility.Forwarding.Enabled" = "允许保存内容"; +"ChannelVisibility.Forwarding.GroupInfo" = "成员将可以拷贝、保存及转发此群组中的内容。"; +"ChannelVisibility.Forwarding.GroupInfo1" = "成员将可以拷贝、保存及转发此群组中的内容。"; +"ChannelVisibility.Forwarding.GroupInfo1.Restrict" = "成员将无法拷贝、保存或转发此群组中的内容。"; +"ChannelVisibility.Forwarding.GroupTitle" = "从该群组转发消息"; +"ChannelVisibility.Forwarding.Restrict" = "限制保存内容"; +"ChannelVisibility.Loading" = "加载中…"; +"ChannelVisibility.Messages.Approve" = "新成员审核"; +"ChannelVisibility.Messages.Approve.Info" = "如果您希望用户只有在得到管理员批准后才能发送消息,请启用此选项。"; +"ChannelVisibility.Messages.Everyone" = "所有人"; +"ChannelVisibility.Messages.Members" = "仅成员"; +"ChannelVisibility.Messages.Who" = "谁可以发送消息?"; +"ChannelVisibiltiy.Context.Copy" = "拷贝"; +"ChannelVisibiltiy.Context.Revoke" = "撤销"; +"ChannelVisibiltiy.ManageLinks" = "管理链接"; +"ChannelVisibiltiy.PermanentLink" = "永久链接"; +"ChannelWallpaper.ChooseFromFile" = "从文件中选出"; +"ChannelWallpaper.NoWallpaper" = "无壁纸"; +"ChannelWallpaper.RemoveWallpaper" = "移除壁纸"; +"ChannelWallpaper.Title" = "频道壁纸"; +"ChannelWallpaper.Title.Group" = "群组壁纸"; +"Chat.Accessory.Forward#other" = "转发%d条信息"; +"Chat.Accessory.Forward.From" = "来自"; +"Chat.Accessory.Forward.You" = "你"; +"Chat.Accessory.Forward_ZeroValueHolder" = "转发消息"; +"Chat.Accessory.Forward_countable" = "%d"; +"Chat.Accessory.Hidden#other" = "转发的 %d 条消息(发送者姓名已隐藏)"; +"Chat.Accessory.Hidden_ZeroValueHolder" = "转发消息(发件人姓名已隐藏)"; +"Chat.Accessory.Hidden_countable" = "%d"; +"Chat.Action.ViewTheme" = "主题预览"; +"Chat.Ad.WhatThis" = "这是什么?"; +"Chat.AdminBadge" = "管理员"; +"Chat.Alert.Forward.Action.Another" = "转发至其他聊天"; +"Chat.Alert.Forward.Action.Cancel" = "取消转发"; +"Chat.Alert.Forward.Action.Hide" = "隐藏发送者名称"; +"Chat.Alert.Forward.Action.Hide1#other" = "隐藏发送者姓名"; +"Chat.Alert.Forward.Action.Hide1_countable" = "%d"; +"Chat.Alert.Forward.Action.HideCaption#other" = "隐藏说明"; +"Chat.Alert.Forward.Action.HideCaption_countable" = "%d"; +"Chat.Alert.Forward.Action.Show" = "显示发送者名称"; +"Chat.Alert.Forward.Action.Show1#other" = "显示发送者姓名"; +"Chat.Alert.Forward.Action.Show1_countable" = "%d"; +"Chat.Alert.Forward.Action.ShowCaption#other" = "显示说明"; +"Chat.Alert.Forward.Action.ShowCaption_countable" = "%d"; +"Chat.Alert.Forward.Header#other" = "%d 条消息"; +"Chat.Alert.Forward.Header_countable" = "%d"; +"Chat.Alert.Forward.Text" = "您将如何处理 %2$@ 的 %1$@?"; +"Chat.Alert.Forward.Text.Inner#other" = "%d 条消息"; +"Chat.Alert.Forward.Text.Inner_countable" = "%d"; +"Chat.Alert.Unsaved" = "有未保存的更改,是否重置?"; +"Chat.Alert.Unsaved.Reset" = "重置"; +"Chat.ApplyProxy" = "添加代理"; +"Chat.AutoremoveTimerSetUserGlobal" = "%1$@ 为所有聊天设置自毁计时器。\n此聊天中的所有新消息将在 %2$@ 后自动删除。"; +"Chat.AutoremoveTimerSetUserGlobalYou" = "您已为所有聊天已设置自毁计时器。\n此聊天中的所有新消息在发送 %1$@ 之后将自动删除。"; +"Chat.Bot.Ad" = "广告"; +"Chat.Bot.Remove" = "移除"; +"Chat.BotAd.Ad" = "广告"; +"Chat.BotManager.Context.Manage" = "管理机器人"; +"Chat.BotManager.Context.Revoke" = "撒销访问"; +"Chat.BotManager.FullAccess" = "机器人管理此聊天"; +"Chat.BotManager.Paused" = "机器人已暂停"; +"Chat.BotManager.ReadOnly" = "机器人阅读此聊天"; +"Chat.BotManager.Start" = "启用"; +"Chat.BotManager.Stop" = "停用"; +"Chat.Bubbles.ForwardedFrom" = "转发自 [%@]()"; +"Chat.Bubbles.ForwardedFrom_New" = "转发自: %@"; +"Chat.Bubbles.ForwardedFrom_WithPhoto" = "转发自:## %@"; +"Chat.Bubbles.ForwardedStory" = "转发的动态来自:[%@]()\n"; +"Chat.Call.Incoming" = "来电"; +"Chat.Call.Outgoing" = "拨出通话"; +"Chat.Cancel" = "取消"; +"Chat.Channel.Unaccessible" = "抱歉,此频道已无法访问。"; +"Chat.ChannelBadge" = "频道"; +"Chat.ChatTheme.ApplyTheme" = "应用主题"; +"Chat.ChatTheme.Cancel" = "取消"; +"Chat.ChatTheme.NoTheme" = "无主题"; +"Chat.ChatTheme.ResetToDefault" = "移除壁纸"; +"Chat.ChatTheme.SelectBackground" = "选择新壁纸"; +"Chat.Comments.Kicked" = "您已被禁止发评论。"; +"Chat.CommentsHeader.Empty" = "暂无留言..."; +"Chat.CommentsHeader.Full" = "讨论已开始"; +"Chat.Confirm.ActionUndonable" = "此操作无法撤消。"; +"Chat.Confirm.DeleteForEveryone#other" = "是否为所有人删除所选消息?"; +"Chat.Confirm.DeleteForEveryone_countable" = "%d"; +"Chat.Confirm.DeleteMessages" = "删除所选的消息?"; +"Chat.Confirm.DeleteMessages1#other" = "是否删除所选消息?"; +"Chat.Confirm.DeleteMessages1_countable" = "%d"; +"Chat.Confirm.DeleteMessagesForEveryone" = "为所有人删除"; +"Chat.Confirm.DeleteMessages_countable" = "%d"; +"Chat.Confirm.PinFor" = "为我和 %@ 置顶"; +"Chat.Confirm.PinOld" = "已经置顶了最新的消息,您想置顶更早的吗?"; +"Chat.Confirm.ReportSpam" = "举报垃圾行为吗?"; +"Chat.Confirm.ReportSpam.Channel" = "是否举报垃圾信息并退出频道?"; +"Chat.Confirm.ReportSpam.Group" = "举报骚扰行为并退出群组"; +"Chat.Confirm.ReportSpam.Header" = "举报垃圾信息"; +"Chat.Confirm.ReportSpamUser" = "您要举报此用户发布垃圾信息吗?"; +"Chat.Confirm.Unpin" = "您确定要取消置顶此消息吗?"; +"Chat.Confirm.Unpin.Header" = "取消置顶消息"; +"Chat.Confirm.Unpin.OK" = "取消置顶"; +"Chat.ConnectingStatus.connecting" = "正在连接"; +"Chat.ConnectingStatus.connectingToProxy" = "正在连接代理"; +"Chat.ConnectingStatus.updating" = "正在刷新"; +"Chat.ConnectingStatus.waitingNetwork" = "等待网络连接"; +"Chat.Contact.SendMessage" = "发送消息"; +"Chat.Context.AddFavoriteSticker" = "添加到收藏"; +"Chat.Context.Archive" = "归档"; +"Chat.Context.AutoDelete" = "自动删除信息"; +"Chat.Context.Ban" = "封闭"; +"Chat.Context.BlockGroup" = "屏蔽群组"; +"Chat.Context.BlockGroup.Cancel" = "取消"; +"Chat.Context.BlockGroup.Header" = "屏蔽群组"; +"Chat.Context.BlockGroup.Info" = "您要屏蔽 %@ 的消息吗?"; +"Chat.Context.BlockGroup.OK" = "屏蔽"; +"Chat.Context.BlockGroup.Third" = "举报垃圾信息"; +"Chat.Context.BlockUser" = "屏蔽用户"; +"Chat.Context.BlockUser.Cancel" = "取消"; +"Chat.Context.BlockUser.Header" = "屏蔽用户"; +"Chat.Context.BlockUser.Info" = "您要屏蔽 %@ 的消息吗?"; +"Chat.Context.BlockUser.OK" = "屏蔽\n"; +"Chat.Context.BlockUser.Third" = "举报垃圾信息"; +"Chat.Context.BusinessLink.EditName" = "编辑链接名称"; +"Chat.Context.CancelEditing" = "取消编辑"; +"Chat.Context.ClearHistory" = "清空历史记录"; +"Chat.Context.ClearScheduled" = "取消所有"; +"Chat.Context.ClearScheduled.Confirm.Header" = "定时消息"; +"Chat.Context.ClearScheduled.Confirm.Info" = "您确定要删除所有定时消息吗?"; +"Chat.Context.ClearScheduled.Confirm.OK" = "清除所有"; +"Chat.Context.Copy" = "拷贝"; +"Chat.Context.CopyBlock" = "拷贝预格式化文本块"; +"Chat.Context.CopyMedia" = "拷贝媒体"; +"Chat.Context.CopyText" = "拷贝文本"; +"Chat.Context.CreateGroup" = "新建群组"; +"Chat.Context.DeleteSaved" = "您确定要删除收藏消息吗?"; +"Chat.Context.DeleteTopic" = "你确定想要删除该话题吗?"; +"Chat.Context.DisableNotifications" = "取消免打扰"; +"Chat.Context.Edit1" = "编辑"; +"Chat.Context.EditHelp" = "点击日期"; +"Chat.Context.EditTopic" = "编辑话题"; +"Chat.Context.EditedAt" = "已编辑于 %@"; +"Chat.Context.EnableNotifications" = "静音"; +"Chat.Context.FavoriteGifs.Limit.Info" = "一张旧的 GIF 动态图已被这张图替换。您可以 [提升上限](premium) 至 %@ 张 GIF 动态图。"; +"Chat.Context.FavoriteGifs.Limit.Title" = "已达到保存 GIF 动态图的上限"; +"Chat.Context.FavoriteStickers.Limit.Info" = "一张旧的贴纸已被这张贴纸替换。您可以 [提升上限](premium) 至 %@ 张贴纸。"; +"Chat.Context.FavoriteStickers.Limit.Title" = "收藏的贴纸数目已达到上限"; +"Chat.Context.FeedInfo" = "频道信息"; +"Chat.Context.Folders" = "文件夹"; +"Chat.Context.Forward.More" = "更多..."; +"Chat.Context.Gif.Added" = "GIF 已保存"; +"Chat.Context.Gif.Removed" = "已移除 GIF 动态图"; +"Chat.Context.HideAd" = "隐藏广告"; +"Chat.Context.Info" = "简介"; +"Chat.Context.Message.ContainsEmoji#other" = "这条消息包含了\n来自[%d 个表情包]()的表情。"; +"Chat.Context.Message.ContainsEmoji_countable" = "%d"; +"Chat.Context.MessageEffect.Add" = "添加动画特效"; +"Chat.Context.Peer.Mention" = "提及"; +"Chat.Context.Peer.OpenInfo" = "打开个人资料"; +"Chat.Context.Peer.OpenStory" = "打开动态"; +"Chat.Context.Peer.SendMessage" = "发送消息"; +"Chat.Context.Phone.NotTelegram" = "此号码未加入 Telegram"; +"Chat.Context.Pinned.Hide" = "隐藏归档的信息"; +"Chat.Context.Reacted" = "%1$@/%2$@ 人已回应"; +"Chat.Context.ReactedFast#other" = "%d 人已回应"; +"Chat.Context.ReactedFast_countable" = "%d"; +"Chat.Context.Reaction.Quick" = "设置为快捷方式"; +"Chat.Context.RemoveFavoriteSticker" = "从收藏中移除"; +"Chat.Context.ReportFalsePositive" = "申诉误报"; +"Chat.Context.ReportFalsePositive.Thanks" = "Telegram 管理员会检查你的报告,感谢!"; +"Chat.Context.Restrict" = "限制"; +"Chat.Context.ReturnMessageFee" = "退还消息费用"; +"Chat.Context.SaveMedia" = "另存为.……"; +"Chat.Context.SaveRingtone.Add" = "保存为通知铃声"; +"Chat.Context.SaveRingtone.AddSuccess" = "音调保存到声音库"; +"Chat.Context.SaveRingtone.Remove" = "移除音效"; +"Chat.Context.SaveRingtone.RemoveSuccess" = "已成功移除音效"; +"Chat.Context.Scheduled.Reschedule" = "重设发送时间"; +"Chat.Context.Scheduled.SendNow" = "立即发送"; +"Chat.Context.SharedMedia" = "分享的媒体文件"; +"Chat.Context.Sticker.AddedToFavorites" = "贴纸已添加到收藏夹"; +"Chat.Context.Sticker.RemovedFromFavorites" = "已从收藏中删除贴纸"; +"Chat.Context.Summarize" = "摘要"; +"Chat.Context.Todo.Check" = "选取"; +"Chat.Context.Todo.Delete" = "删除选项"; +"Chat.Context.Todo.Delete.Confirm" = "您确定要删除此选项吗?"; +"Chat.Context.Todo.Edit" = "编辑选项"; +"Chat.Context.Todo.TaskFallback" = "任务..."; +"Chat.Context.Todo.Uncheck" = "取消选取"; +"Chat.Context.Translate" = "翻译"; +"Chat.Context.Unarchive" = "取消归档"; +"Chat.Context.ViewEmojiSet" = "查看表情包"; +"Chat.Context.ViewEmojiSetNew#other" = "查看表情包"; +"Chat.Context.ViewEmojiSetNew_countable" = "%d"; +"Chat.Context.ViewStatistics" = "查看统计数据"; +"Chat.ContextMenu.AddTask" = "添加任务"; +"Chat.ContextMenu.AutoDelete" = "在 %@ 之后自动删除"; +"Chat.Copy.ProxyConfiguration" = "拷贝代理链接"; +"Chat.CopySelectedText" = "拷贝所选内容"; +"Chat.Date.ScheduledFor" = "计划于 %@发送"; +"Chat.Date.ScheduledForToday" = "今日日程"; +"Chat.Date.ScheduledUntilOnline" = "在线后发送"; +"Chat.DeleteMessage.SuggestedPost.Action.DeleteAnyway" = "仍然删除"; +"Chat.DeleteMessage.SuggestedPost.Header.Stars" = "星星将会丢失"; +"Chat.DeleteMessage.SuggestedPost.Header.Ton" = "TON 将会失去"; +"Chat.DeleteMessage.SuggestedPost.Info.Stars" = "如果您现在删除此贴文,您将不会收到星星。此贴文发布后必须保持至少在 24 小时内可见。"; +"Chat.DeleteMessage.SuggestedPost.Info.Ton" = "如果您现在删除此贴文,您将不会收到 TON。此贴文发布后必须保持至少在 24 小时内可见。"; +"Chat.Dice.Result" = "发送 🎲 表情至任意聊天,Telegram 将为您随机生成点数。"; +"Chat.Dice.ResultNew" = "发送 %@ emoji 到任意对话获得一个随机数字。"; +"Chat.DisableAd.Tooltip" = "你将不再看到 Telegram 提供的广告。"; +"Chat.Discussion.MessageDeleted" = "抱歉,该贴文已从讨论群组中移除。"; +"Chat.DownloadLimit.Text#other" = "订阅 [Telegram 高级版]() 以将下载速度提高 %d 倍。"; +"Chat.DownloadLimit.Text_countable" = "%d"; +"Chat.DownloadLimit.Title" = "下载速度受限"; +"Chat.DropAsFilesDesc" = "无损发送"; +"Chat.DropEditDesc" = "编辑媒体文件"; +"Chat.DropEditTitle" = "拖动文件至此以编辑"; +"Chat.DropFolder.Desc" = "作为压缩文档"; +"Chat.DropFolder.Title" = "拖拽文件夹到此处以发送"; +"Chat.DropQuickDesc" = "快速发送"; +"Chat.DropTitle" = "拖拽文件到此处"; +"Chat.Edit.Attach.Error" = "抱歉, 编辑消息时不能附加新媒体。"; +"Chat.Edit.Cancel.Text" = "确定放弃所有修改吗?"; +"Chat.EditMessage.Media" = "点击以编辑媒体文件"; +"Chat.Emoji.Dart.ResultNew" = "发送飞镖表情试试运气。"; +"Chat.Emoji.Def.ResultNew" = "发送 %@ 表情试试运气。"; +"Chat.Emoji.Dice.ResultNew" = "发送骰子表情即可掷骰子。"; +"Chat.Emoji.Send" = "发送"; +"Chat.Empty.Business.AwayMessage" = "新的离开消息"; +"Chat.Empty.Business.AwayMessage.Info" = "添加在您离线时自动发送的消息。"; +"Chat.Empty.Business.GreetingMessage" = "新问候消息"; +"Chat.Empty.Business.GreetingMessage.Info" = "创建将自动发送给新用户的问候语。"; +"Chat.Empty.Business.QuickReply" = "新增快速回复"; +"Chat.Empty.Business.QuickReply.Info1" = "• 请在下面输入一条消息\n输入 **“/%@”** 后,将在聊天中发送该消息"; +"Chat.Empty.Business.QuickReply.Info2" = "• 您可以通过键入 “/” 或使用 “附件” 菜单在任何聊天中访问快速回复。"; +"Chat.Empty.Comments" = "暂无评论"; +"Chat.Empty.LinkPreview" = "链接预览"; +"Chat.Empty.PaidMessage" = "%1$@ 对每条消息收取 %2$@。"; +"Chat.Empty.PremiumRequired.Action" = "订阅高级版"; +"Chat.Empty.PremiumRequiredState" = "订阅 **高级版**\n即可向 **%@** 发送消息。"; +"Chat.Empty.Replies" = "暂无回复"; +"Chat.Empty.searchHashtag" = "未找到消息"; +"Chat.EmptyChat" = "暂无消息"; +"Chat.EmptyChat.Info" = "发送消息或点击下方的问候语"; +"Chat.File.QuickLook.Malf" = "预览此文件可能会将您的 IP 地址暴露给发件人或损害您的设备。"; +"Chat.File.QuickLook.Svg" = "预览此文件可能会将您的 IP 地址暴露给创建者。"; +"Chat.ForwardActionHeader" = "转发消息"; +"Chat.Gigagroup.Help" = "只有管理员才能在这个群里发消息。"; +"Chat.Giveaway.DeleteConfirmation.Text" = "删除此消息不会取消抽奖活动——仍旧将在 *%@** 选出获奖者。\n\n一旦消息被删除,将无法恢复抽奖活动公告。"; +"Chat.Giveaway.DeleteConfirmation.Title" = "您确定要删除抽奖公告吗?"; +"Chat.Giveaway.Info.ActivatedLinks#other" = "**%d** 位获奖者已使用了赠礼链接。"; +"Chat.Giveaway.Info.ActivatedLinks_countable" = "%d"; +"Chat.Giveaway.Info.AlmostOver" = "抽奖活动即将结束。"; +"Chat.Giveaway.Info.DidntWin" = "您在此次抽奖活动中没有获奖。"; +"Chat.Giveaway.Info.Ended" = "在 **%1$@**,Telegram 已自动选出 **%3$@** 中的 %2$@。"; +"Chat.Giveaway.Info.EndedIntro" = "此抽奖活动由 **%1$@** 的管理员赞助,并为其订阅者购买了时长为 %3$@ 的 %2$@。"; +"Chat.Giveaway.Info.EndedIntro.Stars" = "此次抽奖活动由 **%1$@** 的管理员赞助,他们已为其订阅者准备了 **%2$@ 颗星星**。"; +"Chat.Giveaway.Info.EndedMany" = "在 **%1$@**,Telegram 已自动选择加入了 **%3$@** 和其他列出频道的 **%2$@** 位随机用户。"; +"Chat.Giveaway.Info.EndedNew" = "在 **%1$@**,Telegram 已自动选择了在 **%4$@**之后加入 **%3$@** 的 **%2$@** 位随机用户。"; +"Chat.Giveaway.Info.EndedNewMany" = "在 **%1$@**,Telegram 自动选择了在 **%4$@** 之后加入 **%3$@** 以及其他列出频道的 %2$@。"; +"Chat.Giveaway.Info.EndedTitle" = "抽奖活动已结束"; +"Chat.Giveaway.Info.Months#other" = "**%d** 个月"; +"Chat.Giveaway.Info.Months_countable" = "%d"; +"Chat.Giveaway.Info.NotAllowedAdmin" = "您没有资格参加此抽奖活动,因为您是主办群组 (**%@**) 的管理员。"; +"Chat.Giveaway.Info.NotAllowedCountry" = "您没有资格参加此赠品活动,因为您所在的国家未包含在赠品活动条款中。"; +"Chat.Giveaway.Info.NotAllowedJoinedEarly" = "您没有资格参加此赠品活动,因为您是在 **%@** 即活动开始之前加入此频道的。"; +"Chat.Giveaway.Info.NotQualified" = "要参加此抽奖活动,请在 **%1$@** 之前加入 **%2$@** 频道。"; +"Chat.Giveaway.Info.NotQualifiedMany" = "要参加此抽奖活动,请在 **%3$@** 之前加入 **%1$@** 频道(以及 %2$@)。"; +"Chat.Giveaway.Info.Ongoing" = "在 **%1$@**,Telegram 将会自动选择 **%3$@** 的 %2$@。"; +"Chat.Giveaway.Info.OngoingIntro" = "此赠品活动由 **%1$@** 的管理员赞助,他们为其订阅者以 %3$@ 的价格购买了 %2$@ 个 Telegram 高级版订阅。"; +"Chat.Giveaway.Info.OngoingIntro.Stars" = "此次抽奖活动由 **%1$@** 的管理员赞助,他们已为其订阅者准备了 %2$@。"; +"Chat.Giveaway.Info.OngoingMany" = "在 **%1$@**,Telegram 将会自动选择 **%3$@** 以及 %4$@ 的 %2$@。"; +"Chat.Giveaway.Info.OngoingNew" = "在 **%1$@**,Telegram 将会自动选择在 **%4$@** 之后加入 **%3$@** 的 **%2$@** 位随机用户。"; +"Chat.Giveaway.Info.OngoingNewMany" = "Telegram 将于 **%1$@** 自动选择在 **%5$@** 之后加入 **%3$@** 和%4$@ 的 %2$@。"; +"Chat.Giveaway.Info.OtherChannels#other" = "和 **%d** 个其他列出的频道"; +"Chat.Giveaway.Info.OtherChannels_countable" = "%d"; +"Chat.Giveaway.Info.Participating" = "您正在参与此抽奖活动,因为您已加入 **%1$@** 频道。"; +"Chat.Giveaway.Info.ParticipatingMany" = "您正在参与此赠品活动,因为您已加入频道 **%1$@** (和 %2$@)。"; +"Chat.Giveaway.Info.RandomSubscribers#other" = "**%d** 名随机订阅者"; +"Chat.Giveaway.Info.RandomSubscribers_countable" = "%d"; +"Chat.Giveaway.Info.RandomUsers#other" = "**%d** 位随机用户"; +"Chat.Giveaway.Info.RandomUsers_countable" = "%d"; +"Chat.Giveaway.Info.Refunded" = "该频道通过撤销付款的方式取消了奖品。"; +"Chat.Giveaway.Info.Subscriptions#other" = "**%d 份 Telegram 高级版** 订阅"; +"Chat.Giveaway.Info.Subscriptions_countable" = "%d"; +"Chat.Giveaway.Info.Title" = "关于此抽奖活动"; +"Chat.Giveaway.Info.ViewPrize" = "查看我的奖品"; +"Chat.Giveaway.Info.Won" = "您在此抽奖活动中赢得了奖品。 %@"; +"Chat.Giveaway.Message.CountriesDelimiter" = ", "; +"Chat.Giveaway.Message.CountriesFrom" = "来自 %@"; +"Chat.Giveaway.Message.CountriesLastDelimiter" = "和"; +"Chat.Giveaway.Message.DateTitle" = "选定获奖者的日期"; +"Chat.Giveaway.Message.Months#other" = "**%d** 个月"; +"Chat.Giveaway.Message.Months_countable" = "%d"; +"Chat.Giveaway.Message.Participants" = "所有频道的订阅者:"; +"Chat.Giveaway.Message.ParticipantsMany" = "以下频道的所有订阅者:"; +"Chat.Giveaway.Message.ParticipantsNew" = "频道的新订阅者:"; +"Chat.Giveaway.Message.ParticipantsNewMany" = "本公告发布后加入以下频道的所有用户:"; +"Chat.Giveaway.Message.ParticipantsTitle" = "参与者"; +"Chat.Giveaway.Message.PrizeText" = "%1$@ 时长为 %2$@。"; +"Chat.Giveaway.Message.PrizeTitle" = "抽奖奖品"; +"Chat.Giveaway.Message.StarWinners#other" = "给 **%d** 名获奖者"; +"Chat.Giveaway.Message.StarWinners_countable" = "%d"; +"Chat.Giveaway.Message.Stars#other" = "**%d** 颗星星"; +"Chat.Giveaway.Message.Stars.PrizeText" = "%1$@ 将会分发 %2$@。"; +"Chat.Giveaway.Message.Stars_countable" = "%d"; +"Chat.Giveaway.Message.Subscriptions#other" = "**%d** 个 Telegram 高级版订阅"; +"Chat.Giveaway.Message.Subscriptions_countable" = "%d"; +"Chat.Giveaway.Message.WinnersInfo#other" = "所有获奖者都已在私密消息中收到礼物链接。"; +"Chat.Giveaway.Message.WinnersInfo.Star#other" = "所有获奖者都已总共收到 %2$@ 颗星星。"; +"Chat.Giveaway.Message.WinnersInfo.Star_countable" = "%d %@"; +"Chat.Giveaway.Message.WinnersInfo_countable" = "%d"; +"Chat.Giveaway.Message.WinnersSelectedText#other" = "Telegram 已随机选出 ** %d 位** 抽奖活动获奖者。"; +"Chat.Giveaway.Message.WinnersSelectedText_countable" = "%d"; +"Chat.Giveaway.Message.WinnersSelectedTitle#other" = "获奖者已选出!"; +"Chat.Giveaway.Message.WinnersSelectedTitle_countable" = "%d"; +"Chat.Group.Unaccessible" = "抱歉,此群组已无法访问。"; +"Chat.GroupCall.Join" = "加入"; +"Chat.GroupCall.Live.Title" = "直播流"; +"Chat.GroupCall.Members#other" = "%d 位成员"; +"Chat.GroupCall.Members_ZeroValueHolder" = "点击以加入"; +"Chat.GroupCall.Members_countable" = "%d"; +"Chat.GroupCall.Scheduled.Status" = "开始于%@"; +"Chat.GroupCall.Scheduled.Title" = "预定的视频聊天"; +"Chat.GroupCall.Speakers#other" = "%d 位成员正在发言"; +"Chat.GroupCall.Speakers_ZeroValueHolder" = "无人说话"; +"Chat.GroupCall.Speakers_countable" = "%d"; +"Chat.GroupCall.Title" = "视频通话"; +"Chat.Hashtag.MyMessages" = "我的消息"; +"Chat.Hashtag.PublicPosts" = "公开帖子"; +"Chat.Hashtag.ThisChat" = "此聊天"; +"Chat.Header.PinnedMessage" = "置顶消息"; +"Chat.Header.PinnedMessage_Numer" = "置顶消息 #%d"; +"Chat.Header.PinnedPrevious" = "之前置顶的消息"; +"Chat.Header.RemoveFee" = "免收费用"; +"Chat.Header.RemoveFee.Confirm.Header" = "免收费用"; +"Chat.Header.RemoveFee.Confirm.Info" = "你确定要允许 **%@** 免费给你发送消息吗?"; +"Chat.Header.RemoveFee.Confirm.OK" = "是"; +"Chat.Header.RemoveFee.Confirm.Option" = "是否退还已支付的 **%@**?"; +"Chat.Header.RemoveFee.Monoforum.Confirm.Info" = "您是否要允许 **%1$@** 免费向频道发送消息?"; +"Chat.Header.RemoveFee.Monoforum.Text" = "%1$@ 必须为发布在您频道中的每条消息支付 %2$@。"; +"Chat.Header.RemoveFee.Text" = "%1$@ 必须为每条发送给你的消息支付 %2$@。"; +"Chat.Header.ReportSpam" = "举报垃圾信息"; +"Chat.Header.RequestToJoin#other" = "%d 个加入请求"; +"Chat.Header.RequestToJoin_countable" = "%d"; +"Chat.Header.VoiceChat.StartsIn" = " 开始于%@"; +"Chat.InlineRequest.Loading" = "加载中…"; +"Chat.Input.Accessory.EditMessage" = "编辑消息"; +"Chat.Input.Attach.QuickReply" = "快速回复"; +"Chat.Input.AutoDelete.1Day" = "此聊天中的消息发出1天后将自动删除"; +"Chat.Input.AutoDelete.7Days" = "此聊天中的消息发出 1 周后将自动删除。"; +"Chat.Input.Close" = "关闭"; +"Chat.Input.Delete" = "删除并离开"; +"Chat.Input.Discuss" = "讨论"; +"Chat.Input.Error.MessageTooLong#other" = "消息太长无法保存,请删除 %d 个字符。"; +"Chat.Input.Error.MessageTooLong_countable" = "%d"; +"Chat.Input.Forward.Hidden" = "您(发送者名称已隐藏)"; +"Chat.Input.Join" = "加入"; +"Chat.Input.Mute" = "静音"; +"Chat.Input.PremiumRequiredState" = "%@ 只接受来自高级版订阅用户的消息。\n[了解更多...](premium)"; +"Chat.Input.ReplyToAnswer" = "回复话题中的消息"; +"Chat.Input.ReplyToAnswer.Monoforum" = "选择要回复的消息"; +"Chat.Input.ReplyToAnswer.Monoforum_New" = "在右键菜单中选择「回复」进行回答"; +"Chat.Input.Restart" = "重新启动"; +"Chat.Input.Return" = "返回群组"; +"Chat.Input.SecretChat.WaitingToOnline" = "等待用户上线..."; +"Chat.Input.SecretChat.WaitingToUserOnline" = "等待 %@ 上线……"; +"Chat.Input.StartBot" = "开始"; +"Chat.Input.TopicClosed" = "该话题已锁定"; +"Chat.Input.Unblock" = "取消屏蔽该用户"; +"Chat.Input.Unmute" = "取消免打扰"; +"Chat.InputContext.EditQuickReply" = "编辑快速回复"; +"Chat.InstantView" = "即时预览"; +"Chat.InviteRequestAdminChannel" = "**%1$@** 是您请求加入频道 **%2$@** 的管理员。"; +"Chat.InviteRequestAdminGroup" = "**%1$@** 是您曾申请加入的 %2$@ 群组管理员。"; +"Chat.InviteRequestInfo" = "您收到这条消息是因为您曾在 %2$@ 申请加入 %1$@。"; +"Chat.JoinAuction" = "加入"; +"Chat.JoinGroupCall" = "加入通话"; +"Chat.Link.Preview.Auction.Finished" = "已结束"; +"Chat.Link.Preview.Auction.SoldOut" = "售罄"; +"Chat.List.Contact" = "联系人"; +"Chat.List.GIF" = "GIF"; +"Chat.List.InstantVideo" = "视频消息"; +"Chat.List.Map" = "位置"; +"Chat.List.Photo1#other" = "%d 张照片"; +"Chat.List.Photo1_countable" = "%d"; +"Chat.List.Photo_countable" = "%d"; +"Chat.List.Service.PaymentSent" = "支付:%@"; +"Chat.List.Sticker" = "%@ 张贴纸"; +"Chat.List.Video" = "视频"; +"Chat.List.Video1#other" = "%d 个视频"; +"Chat.List.Video1_countable" = "%d"; +"Chat.List.Video_countable" = "%d"; +"Chat.List.Voice" = "语音消息"; +"Chat.LiveLocation" = "实时位置"; +"Chat.LiveLocation.Updated#other" = "更新于 %d 分钟前"; +"Chat.LiveLocation.UpdatedNow" = "刚刚更新"; +"Chat.LiveLocation.Updated_countable" = "%d"; +"Chat.ManageMessages.RestrictFully#other" = "[彻底封禁所选用户](full)"; +"Chat.ManageMessages.RestrictFully_countable" = "%d"; +"Chat.ManageMessages.RestrictPartially#other" = "[部分限制所选用户](part)"; +"Chat.ManageMessages.RestrictPartially_countable" = "%d"; +"Chat.ManageMessages.What#other" = "选中的 %d 位用户可以做什么?"; +"Chat.ManageMessages.What_countable" = "%d"; +"Chat.Message.Ad.ReadMore" = "阅读更多"; +"Chat.Message.Ad.Text" = "与其他应用不同,Telegram 不会将您的隐私数据用于投放定向广告。赞助商消息的投放完全基于公开频道内所发布的内容。这意味着 Telegram 不会通过挖掘和分析用户画像来投放广告,查看频道的所有用户都会看到同样的赞助商消息。\n\n与其他应用不同,Telegram 不会追踪您是否点击赞助商消息,也不会基于您的活跃数据生成用户画像。为了避免来自第三方的窥探,我们还禁止在赞助商消息中插入外部链接。我们相信每个人都有保护隐私的权利,而科技平台应该尊重用户的隐私权。\n\nTelegram 向数以亿计的用户提供免费服务的同时,也需要承担由此产生的高昂运营成本,包括服务器和流量开销。为了保持独立并延续其价值观,Telegram 将用户隐私权谨记于心,开发了这套付费工具。欢迎有责任的广告商与我们取得联系:\n\n%@\n\n赞助商消息目前仍然处于测试阶段。全面启动赞助商消息系统,并且 Telegram 能够平衡运营成本之后,我们将与显示赞助商消息的频道所有者共同分享广告收入。\n\n线上广告不应再是滥用用户隐私的代名词。让我们共同携手,重新定义科技公司的运营模式。"; +"Chat.Message.Ad.Url" = "https://ads.telegram.org"; +"Chat.Message.Context.Quote" = "引用"; +"Chat.Message.Context.Quote.LimitExceed" = "太长。"; +"Chat.Message.DeleteForAll" = "为所有人删除"; +"Chat.Message.DeleteForEveryone" = "为所有人删除"; +"Chat.Message.DeleteForMe" = "仅为我删除"; +"Chat.Message.DeleteForMeAndPerson" = "为我和 %@ 删除"; +"Chat.Message.Giveaway.LearnMore" = "了解更多"; +"Chat.Message.Imported" = "导入于 %@"; +"Chat.Message.Imported.Text" = "此信息由其他软件导入,无法保证其真实性。"; +"Chat.Message.ImportedShort" = "已导入"; +"Chat.Message.JoinVoiceChat.AsListener" = "以听众身份加入"; +"Chat.Message.JoinVoiceChat.AsSpeaker" = "作为发言人加入"; +"Chat.Message.LaunchBot" = "打开应用"; +"Chat.Message.MTProxy.Config" = "MTProxy 配置"; +"Chat.Message.OpenGiftLink" = "打开礼物链接"; +"Chat.Message.OpenGiftStars" = "打开"; +"Chat.Message.OpenLink" = "打开链接"; +"Chat.Message.OpenStory" = "查看动态"; +"Chat.Message.ReadStats.EmptyListens" = "无人收听"; +"Chat.Message.ReadStats.EmptyViews" = "无人已读"; +"Chat.Message.ReadStats.EmptyWatches" = "无人收听"; +"Chat.Message.ReadStats.Listened#other" = "%d 人已收听"; +"Chat.Message.ReadStats.Listened_countable" = "%d"; +"Chat.Message.ReadStats.Seen#other" = "%d 人已读"; +"Chat.Message.ReadStats.Seen_countable" = "%d"; +"Chat.Message.ReadStats.ShowDate" = "显示您的已读时间"; +"Chat.Message.ReadStats.Watched#other" = "%d 次查看"; +"Chat.Message.ReadStats.Watched_countable" = "%d"; +"Chat.Message.Recommended" = "推荐"; +"Chat.Message.Repeat.1Min" = "1 分钟"; +"Chat.Message.Repeat.5Min" = "5 分钟"; +"Chat.Message.Repeat.Biweekly" = "每两周"; +"Chat.Message.Repeat.Daily" = "每天"; +"Chat.Message.Repeat.Every3Months" = "每三个月"; +"Chat.Message.Repeat.Every6Months" = "每六个月"; +"Chat.Message.Repeat.Monthly" = "每月"; +"Chat.Message.Repeat.Weekly" = "每周"; +"Chat.Message.Repeat.Yearly" = "每年"; +"Chat.Message.SendMessage" = "发送消息"; +"Chat.Message.Socks5.Config" = "SOCKS5 配置"; +"Chat.Message.Sponsored" = "广告"; +"Chat.Message.Sponsored.Advertiser" = "广告商信息"; +"Chat.Message.Sponsored.Link" = "https://ads.telegram.org"; +"Chat.Message.Sponsored.What" = "什么是赞助消息?"; +"Chat.Message.Suggest.Markup.Approve" = "接受"; +"Chat.Message.Suggest.Markup.Decline" = "拒绝"; +"Chat.Message.Suggest.Markup.Edit" = "编辑"; +"Chat.Message.Suggest.Markup.SuggestChanges" = "推荐更改"; +"Chat.Message.Todo.CompletePremium" = "高级版用户可以表示核对清单。"; +"Chat.Message.Todo.EditForwardedError" = "您无法编辑已转发的核对清单。"; +"Chat.Message.Todo.EditRestrictedError" = "%1$@ 已限制其他人编辑此核对清单。"; +"Chat.Message.Todo.Title.Group" = "群组核对清单"; +"Chat.Message.Todo.Title.Single" = "核对清单"; +"Chat.Message.Todo.Total" = "%1$@ / %2$@ 已完成"; +"Chat.Message.UnsendMessages" = "不发送我的消息"; +"Chat.Message.UnsendMessages_countable" = "%d"; +"Chat.Message.Unsupported" = "当前 Telegram 版本不支持此消息。请在 AppStore 更新本应用到最新版或在这里下载: https://macos.telegram.org"; +"Chat.Message.UnsupportedNew" = "您的 Telegram 版本不支持此消息。 请更新到最新版本。"; +"Chat.Message.Via" = "通过"; +"Chat.Message.ViewBot" = "查看机器人"; +"Chat.Message.ViewChannel" = "查看频道"; +"Chat.Message.ViewGiftLink" = "查看"; +"Chat.Message.ViewGroup" = "查看群组"; +"Chat.Message.edited" = "已编辑"; +"Chat.MessageAction.RequestToJoin" = "请求加入"; +"Chat.MessageAction.ShowMessage" = "显示消息"; +"Chat.MonoforumUI.AllTab" = "全部"; +"Chat.OffThreadMessage" = "非话题消息"; +"Chat.Open.MessageNotExist" = "消息不存在"; +"Chat.OwnerBadge" = "所有者"; +"Chat.PayStars.Confirm.Checkbox" = "不再询问"; +"Chat.PayStars.Confirm.Medias#other" = "%d 个媒体"; +"Chat.PayStars.Confirm.Medias_countable" = "%d"; +"Chat.PayStars.Confirm.Messages#other" = "%d 条消息"; +"Chat.PayStars.Confirm.Messages_countable" = "%d"; +"Chat.PayStars.Confirm.Pay#other" = "支付 %d 条消息"; +"Chat.PayStars.Confirm.Pay.Media#other" = "为 %d 个媒体付款"; +"Chat.PayStars.Confirm.Pay.Media_countable" = "%d"; +"Chat.PayStars.Confirm.Pay_countable" = "%d"; +"Chat.PayStars.Confirm.Text" = "**%1$@** 对每条消息收取 **%2$@**。您愿意支付 **%3$@** 来发送 %4$@ 吗?"; +"Chat.PayStars.Confirm.Title" = "确认支付"; +"Chat.Pinned.DontShow" = "不显示置顶消息"; +"Chat.Pinned.UnpinAll#other" = "取消置顶 %d 条消息"; +"Chat.Pinned.UnpinAll_countable" = "%d"; +"Chat.Poll.Result" = "%@%"; +"Chat.Poll.Stop" = "停止投票"; +"Chat.Poll.Stop.Confirm.Header" = "停止投票?"; +"Chat.Poll.Stop.Confirm.Text" = "如果您现在停止这个投票,任何人都不能再进行投票了。 此操作无法撤消。"; +"Chat.Poll.SubmitVote" = "提交投票"; +"Chat.Poll.Tooltip.NoVotes" = "无人投票"; +"Chat.Poll.Tooltip.Votes#other" = "%d 票"; +"Chat.Poll.Tooltip.Votes_countable" = "%d"; +"Chat.Poll.TotalVotes#other" = "%d 人已投票"; +"Chat.Poll.TotalVotes1#other" = "%d 票"; +"Chat.Poll.TotalVotes1_countable" = "%d"; +"Chat.Poll.TotalVotesEmpty" = "暂时无人投票"; +"Chat.Poll.TotalVotesResultEmpty" = "无人投票"; +"Chat.Poll.TotalVotes_countable" = "%d"; +"Chat.Poll.Type.Anonymous" = "匿名投票"; +"Chat.Poll.Type.AnonymousQuiz" = "匿名问答"; +"Chat.Poll.Type.Closed" = "最终结果"; +"Chat.Poll.Type.Public" = "投票"; +"Chat.Poll.Type.Quiz" = "问答"; +"Chat.Poll.Unvote" = "撤回投票"; +"Chat.Poll.ViewResults" = "查看结果"; +"Chat.ProxySponsored.AlertHeader" = "代理赞助商"; +"Chat.ProxySponsored.AlertSettings" = "设置"; +"Chat.ProxySponsored.AlertText" = "这个频道是由您的代理服务商所展示的。若要从您的会话列表中移除此频道,在 Telegram 设置中禁用代理即可。"; +"Chat.ProxySponsored.CapDesc" = "此频道由您的代理服务器展示"; +"Chat.ProxySponsored.CapTitle" = "代理赞助商"; +"Chat.QuickReplyMessageLimitReachedText#other" = "已达 %d 条消息上限"; +"Chat.QuickReplyMessageLimitReachedText_countable" = "%d"; +"Chat.Quiz.Stop" = "结束问答"; +"Chat.Quiz.Stop.Confirm.Header" = "是否结束问答?"; +"Chat.Quiz.Stop.Confirm.Text" = "如果您现在结束此问答,将不再有人能够作答。该操作无法撤销。"; +"Chat.Quiz.TextType" = "问答"; +"Chat.Quiz.Tooltip.NoVotes" = "暂无回答"; +"Chat.Quiz.Tooltip.Votes#other" = "%d 个回答"; +"Chat.Quiz.Tooltip.Votes_countable" = "%d"; +"Chat.Quiz.TotalVotes#other" = "%d 个回答"; +"Chat.Quiz.TotalVotesEmpty" = "暂无回答"; +"Chat.Quiz.TotalVotesResultEmpty" = "无人回答"; +"Chat.Quiz.TotalVotes_countable" = "%d"; +"Chat.Reaction.Context.AddLabel" = "添加名称"; +"Chat.Reaction.Context.EditTag" = "编辑名称"; +"Chat.Reaction.Context.EditTag.Info" = "您可以使用文本给表情标签起一个名字。"; +"Chat.Reaction.Context.FilterByTag" = "依据标签进行筛选"; +"Chat.Reaction.Context.RemoveTag" = "移除标签"; +"Chat.Reaction.EditTag.Placeholder" = "标签..."; +"Chat.Recording.Cancel" = "您确定要停止录制并放弃您的语音消息吗?"; +"Chat.Replies.Desc" = "此聊天将帮助您在群组中跟踪对您的评论。"; +"Chat.Reply.ExpiredStory" = "过期的动态"; +"Chat.Reply.ExpiredStory.Error" = "这条动态已无法访问。"; +"Chat.ReplyMarkup.PayXtr" = "支付 %@"; +"Chat.ReplyMessagePanelTitle" = "回复 %@"; +"Chat.ReplyQuotePanelTitle" = "回复来自 %@ 的引文"; +"Chat.Right.Context.Reminder" = "提醒事项"; +"Chat.Right.Context.ScheduledMessages" = "定时消息"; +"Chat.SavedMessages.ViewAsChats" = "以聊天模式查看"; +"Chat.SavedMessages.ViewAsMessages" = "以消息模式查看"; +"Chat.Scheduled.InlineButton.Error" = "消息发送后,按钮将立即变为可点击状态。"; +"Chat.SearchCount" = "%d / %d"; +"Chat.SearchFrom" = "来自:"; +"Chat.SecretChat.1Feature" = "- 使用端到端加密"; +"Chat.SecretChat.2Feature" = "- 不会在服务器上留下任何痕迹"; +"Chat.SecretChat.3Feature" = "- 支持阅后即焚"; +"Chat.SecretChat.4Feature" = "- 不允许转发"; +"Chat.SecretChat.EmptyHeader" = "私密对话:"; +"Chat.SecretChat.PremiumRequired" = "%@ 仅接收来自联系人和高级版用户的秘密聊天。"; +"Chat.SecretChat.Preview.Header" = "私密对话"; +"Chat.SecretChat.Preview.NO" = "否"; +"Chat.SecretChat.Preview.OK" = "是"; +"Chat.SecretChat.Preview.Text" = "您要启用私密聊天中的链接预览功能吗?注意,链接预览将在 Telegram 的服务器上生成。"; +"Chat.Send.AddToCalendar" = "添加到日历"; +"Chat.Send.EventAddedToCalendar" = "事件已添加到日历"; +"Chat.Send.ScheduledMessage" = "定时消息"; +"Chat.Send.SendWhenOnline" = "在线时发送"; +"Chat.Send.SetReminder" = "新建提醒事项"; +"Chat.Send.WithoutSound" = "静默发送"; +"Chat.SendAs.Channel#other" = "%d 位订阅者"; +"Chat.SendAs.Channel_countable" = "%d 位"; +"Chat.SendAs.Group#other" = "%d 位成员"; +"Chat.SendAs.Group_countable" = "%d 个"; +"Chat.SendAs.Header" = "以哪个身份发送消息…"; +"Chat.SendAs.PersonalAccount" = "个人账号"; +"Chat.SendJoinRequest.Info" = "管理员批准您的请求后,您将能够发送消息。"; +"Chat.SendJoinRequest.Title" = "已发送加入请求"; +"Chat.SendMessage.Error.TooFast" = "您发信息的速度太快了,请稍等一下。"; +"Chat.SendMessageError.TooMuchScheduled" = "抱歉,您最多只能设置 100 条定时消息。"; +"Chat.SendMessageErrorFlood" = "抱歉,目前您只能给双向联系人发送消息。"; +"Chat.SendMessageErrorGroupRestricted" = "抱歉,目前您被限制在公开群组发送消息。"; +"Chat.SendMessageErrorNonPremiumForbidden" = "%@ 只允许高级版用户发送消息"; +"Chat.SendMessageSlowmodeError" = "慢速模式已启用。"; +"Chat.SendVoice.PrivacyError" = "%@ 不接受语音或视频消息"; +"Chat.SensitiveContent" = "18+"; +"Chat.SensitiveContent.Confirm" = "此媒体可能包含仅适合成人的敏感內容。您仍想查看吗?"; +"Chat.SensitiveContent.Confirm.Ok" = "仍然查看"; +"Chat.SensitiveContent.Confirm.Third" = "总是显示 18+ 媒体"; +"Chat.Service.Birthday.SuggestedToOther" = "您已建议 %@ 添加生日"; +"Chat.Service.Birthday.SuggestedToYou" = "%@ 建议您添加您的生日"; +"Chat.Service.BirthdayAddToProfile" = "添加到我的个人资料页上"; +"Chat.Service.BirthdayHideYear" = "隐藏年份"; +"Chat.Service.BotPermissionAllowed" = "您允许该机器人在您使用 %@ 登录时向您发出消息。"; +"Chat.Service.BotWriteAllowed" = "当您将此机器人添加到附件菜单中时,您已允许此机器人向您发送消息。"; +"Chat.Service.Call.Cancelled" = "已取消"; +"Chat.Service.Call.Missed" = "未接"; +"Chat.Service.CancelledGroupCall" = "已取消的群组通话"; +"Chat.Service.Channel.DisabledTimer" = "此频道中的消息将不会被自动删除"; +"Chat.Service.Channel.RemovedPhoto" = "频道照片已移除"; +"Chat.Service.Channel.SetTimer" = "此频道中的消息将在 %@ 后自动删除"; +"Chat.Service.Channel.UpdatedPhoto" = "频道头像已更新"; +"Chat.Service.Channel.UpdatedTitle" = "频道名称已修改为 \"%@\""; +"Chat.Service.Channel.UpdatedVideo" = "频道图片已更新"; +"Chat.Service.DeclinedGroupCall" = "已拒接群组通话"; +"Chat.Service.DisabledTheme" = "%1$@ 已停用聊天主题"; +"Chat.Service.DisabledTheme.You" = "您已停用聊天主题"; +"Chat.Service.Gift.Burned" = "%1$@ 已在制作新独家典藏品过程中销毁此礼物"; +"Chat.Service.Gift.BurnedHeader" = "已销毁的礼物"; +"Chat.Service.Gift.Ribbon.Burned" = "已销毁"; +"Chat.Service.Gift.Ribbon.Gift" = "礼物"; +"Chat.Service.Gift.Ribbon.Sold" = "已出售"; +"Chat.Service.Gift.View" = "查看"; +"Chat.Service.GiftLink" = "您已收到礼物。"; +"Chat.Service.GiftLink.Sent" = "您已发送礼物"; +"Chat.Service.GiftUnpack.Incoming" = "点击“开箱”即可将此礼物转换为独家典藏品。"; +"Chat.Service.GiftUnpack.Outgoing" = "%@ 现在可以将此礼物转换为独家典藏品。"; +"Chat.Service.GiveawayStarted" = "%1$@ 刚刚发起一场抽奖活动,向其订阅者赠送 Telegram 高级版订阅。"; +"Chat.Service.GiveawayStarted.Stars#other" = "%1$@ 刚刚为其关注者发起了一次抽奖活动,奖品为 %2$d 颗 Telegram 星星。"; +"Chat.Service.GiveawayStarted.Stars_countable" = "%1$@ %2$d"; +"Chat.Service.Group.AddedMembers" = "%@ 邀请了 %@"; +"Chat.Service.Group.AddedMembers1" = "%1%@ 邀请 %2%@"; +"Chat.Service.Group.AddedSelf" = "%@ 已加入群组"; +"Chat.Service.Group.Created" = "%@ 已创建群组 “%@”"; +"Chat.Service.Group.Created1" = "%1%@ 创建了群组 %2%@"; +"Chat.Service.Group.DisabledTimer" = "群组管理员已停用自动删除计时器"; +"Chat.Service.Group.JoinedByLink" = "%@ 已通过邀请链接加入群组"; +"Chat.Service.Group.MigratedToSupergroup" = "此群组已升级为超级群组"; +"Chat.Service.Group.RemovedMembers" = "%@ 移除了 %@"; +"Chat.Service.Group.RemovedMembers1" = "%1%@ 移除 %2%@"; +"Chat.Service.Group.RemovedPhoto" = "%@ 删除了群组头像"; +"Chat.Service.Group.RemovedSelf" = "%@ 退出了群组"; +"Chat.Service.Group.SetTimer" = "群组管理员已设置在 %@ 之后自动删除消息"; +"Chat.Service.Group.TookScreenshot" = "%@ 截了一张图"; +"Chat.Service.Group.TopicCreated" = "话题 ”%2$@“ 已创建"; +"Chat.Service.Group.TopicCreated.Icon" = "%1$@ “%2$@” 已创建"; +"Chat.Service.Group.TopicEdited.Hided" = "%1$@ 已隐藏通用话题"; +"Chat.Service.Group.TopicEdited.Icon" = "%1$@ 已将主题图标更改为 %2$@"; +"Chat.Service.Group.TopicEdited.Icon.Removed" = "%1$@ 已移除图标"; +"Chat.Service.Group.TopicEdited.Mixed" = "%1$@ 已将话题重命名为 “%3$@” 并将图标更改为 %2$@"; +"Chat.Service.Group.TopicEdited.Paused" = "%1$@ 已锁定话题"; +"Chat.Service.Group.TopicEdited.Resumed" = "%1$@ 已重新解锁话题"; +"Chat.Service.Group.TopicEdited.Title" = "%1$@ 已将主题名称更改为 \"%2$@\""; +"Chat.Service.Group.TopicEdited.Unhided" = "%1$@ 已取消隐藏通用话题"; +"Chat.Service.Group.TopicEdited.You.Hided" = "你已隐藏通用话题"; +"Chat.Service.Group.TopicEdited.You.Icon" = "你已将主题图标更改为 %1$@"; +"Chat.Service.Group.TopicEdited.You.Icon.Removed" = "您已移除该图标"; +"Chat.Service.Group.TopicEdited.You.Mixed" = "您已将主题图标更改为%1$@和名称为”%2$@\""; +"Chat.Service.Group.TopicEdited.You.Paused" = "你已关闭话题"; +"Chat.Service.Group.TopicEdited.You.Resumed" = "您已重新启动话题"; +"Chat.Service.Group.TopicEdited.You.Title" = "你已将主题名称更改为 \"%1$@\""; +"Chat.Service.Group.TopicEdited.You.Unhided" = "你取消隐藏通用话题"; +"Chat.Service.Group.UpdatedPhoto" = "%@ 更换了群组头像"; +"Chat.Service.Group.UpdatedPinnedMessage" = "%@ 已置顶 \"%@\""; +"Chat.Service.Group.UpdatedPinnedMessage1" = "%1$@ 已置顶 “%2$@”"; +"Chat.Service.Group.UpdatedTitle" = "%@ 将群组名称更改为 \"%@\""; +"Chat.Service.Group.UpdatedTitle1" = "%1$@ 已将群组名称更改为 “%2$@”"; +"Chat.Service.Group.UpdatedVideo" = "%@ 已更改群组图片"; +"Chat.Service.GroupsInCommon#other" = "%d 个群组"; +"Chat.Service.GroupsInCommon_ZeroValueHolder" = "无群组"; +"Chat.Service.GroupsInCommon_countable" = "%d"; +"Chat.Service.IncomingGroupCall" = "群组通话来电"; +"Chat.Service.MissedGroupCall" = "未接群组通话"; +"Chat.Service.NotOfficial" = "非官方账号"; +"Chat.Service.Offer.Accept" = "接受"; +"Chat.Service.Offer.Accepted" = "此出价已被接受。"; +"Chat.Service.Offer.BoughtGift" = "您从 %2$@ 以 %3$@ 的价格报价了 **%1$@**"; +"Chat.Service.Offer.ConfirmSale" = "你想把 **%1$@** 以 **%3$@** 的价格卖给 %2$@ 吗?扣除手续费后,您将收到 **%4$@**。"; +"Chat.Service.Offer.ConfirmSaleOk" = "确认出售"; +"Chat.Service.Offer.Expired" = "此出价已过期。"; +"Chat.Service.Offer.ExpiredOther" = "**%1$@** 未回应您对 **%2$@** 的报价——您的 %3$@ 已退还。"; +"Chat.Service.Offer.ExpiredYou" = "**%1$@** 以 %3$@ 的价格购买您的 **%2$@** 的报价已失效。"; +"Chat.Service.Offer.ExpiresIn" = "此出价将在 %@ 后过期。"; +"Chat.Service.Offer.ReceivedOffer" = "%1$@ 出价 **%2$@** 购买您的 **%3$@** 礼物。"; +"Chat.Service.Offer.Reject" = "拒绝"; +"Chat.Service.Offer.RejectConfirm" = "您确定要拒绝此报价吗?"; +"Chat.Service.Offer.Rejected" = "此出价已被拒绝。"; +"Chat.Service.Offer.RejectedOther" = "**%1$@** 拒绝了您对 **%2$@** 的报价——您的 %3$@ 已退还。"; +"Chat.Service.Offer.RejectedYou" = "您已拒绝 **%1$@** 以 %3$@ 的价格购买您的 **%2$@** 的报价。"; +"Chat.Service.Offer.SentOffer" = "您对 **%2$@** 的礼物 **%3$@** 提出了 **%1$@** 的报价。"; +"Chat.Service.Offer.SoldGift" = "您以 %3$@ 的价格将 **%1$@** 出售给了 %2$@"; +"Chat.Service.OutgoingGroupCall" = "拨出群组通话"; +"Chat.Service.PaymentSent" = "您已成功为 **%@** 向 **%@** 转账 **%@**"; +"Chat.Service.PaymentSent1" = "您已成功转账 **%1$@** 给 **%2$@**,用途是 **%3$@**"; +"Chat.Service.PaymentSent1NoTitle" = "您已成功向 **%2$@** 转账 **%1$@**"; +"Chat.Service.PaymentSentRecurringInit" = "您已成功为 %3$@ 向 %2$@ 转账 %1$@,并已允许后续的定期付款"; +"Chat.Service.PaymentSentRecurringInitNoTitle" = "您已成功将 **%1$@** 转账给 **%2$@**,并已允许后续的定期付款"; +"Chat.Service.PaymentSentRecurringUsed" = "您刚刚成功转移 **%1$@** 自 **%2$@** 为 **%3$@** 通过经常性付款"; +"Chat.Service.PaymentSentRecurringUsedNoTitle" = "您刚刚成功通过定期付款向 **%2$@** 转账 **%1$@**"; +"Chat.Service.PeerJoinedTelegram" = "%@ 加入了 Telegram"; +"Chat.Service.PeerRequest.Confirm.Ok" = "发送"; +"Chat.Service.PeerRequest.Confirm.Permission" = "您确定要将 %1$@ 发送到 %2$@ 吗?这也会将 %3$@ 添加到 %4$@,并具有以下权限:%5$@"; +"Chat.Service.PeerRequest.Confirm.Plain" = "您确定要将 %1$@ 发送至 %2$@ 吗?"; +"Chat.Service.PeerRequested" = "您已将 %1$@ 分享给 %2$@。"; +"Chat.Service.PeerRequested.Multiple" = "您与 %2$@ 已分享 %1$@。"; +"Chat.Service.PinnedMessage" = "置顶消息"; +"Chat.Service.Premium.Unknown" = "未知用户"; +"Chat.Service.PremiumGift.Info#other" = "%d 个月的 **Telegram 高级版**"; +"Chat.Service.PremiumGift.Info_countable" = "%d 个"; +"Chat.Service.PremiumGift.Sent" = "%1$@ 已花费 %2$@ 赠予您一份礼物"; +"Chat.Service.PremiumGift.Sent.Channel" = "%1$@ 赠送了一份价值 %3$@ 的礼物给 %2$@ "; +"Chat.Service.PremiumGift.SentYou" = "您已花费 %@ 赠予礼物"; +"Chat.Service.Refund" = "%@ 已退款 %@"; +"Chat.Service.RefundedBack#other" = "%@ 已退款 %@%d"; +"Chat.Service.RefundedBack_countable" = "%@ %@%d"; +"Chat.Service.Resale" = "%1$@ 以 **%3$@** 的价格购买了 **%2$@** 并赠送给您"; +"Chat.Service.Resale.You" = "您以 **%2$@** 的价格购买了 **%1$@** 并將其赠送给2了 %3$@"; +"Chat.Service.Resale.You.Self" = "您以 **%2$@** 的价格购买了 **%1$@**"; +"Chat.Service.SearchAllMessages" = "搜索来自 %@ 的聊天记录"; +"Chat.Service.SecretChat.DisabledTimer" = "%@ 取消了阅后即焚计时器"; +"Chat.Service.SecretChat.DisabledTimer.Self" = "您已停用阅后即焚计时器"; +"Chat.Service.SecretChat.DisabledTimer.Self1" = "您已停用自动删除计时器"; +"Chat.Service.SecretChat.DisabledTimer1" = "%@ 已停用自动删除计时器"; +"Chat.Service.SecretChat.SetTimer" = "%@ 已将阅后即焚计时器设定为 %@"; +"Chat.Service.SecretChat.SetTimer.Self" = "您已将阅后即焚计时器设定为 %@"; +"Chat.Service.SecretChat.SetTimer.Self1" = "您已设置消息在 %@ 后自动删除"; +"Chat.Service.SecretChat.SetTimer1" = "%@ 已设置在 %@ 之后自动删除消息"; +"Chat.Service.SecureId.AccessGranted" = "%@ 已收到下列证件:%@"; +"Chat.Service.SendAnother" = "再送一份礼物"; +"Chat.Service.SendGift" = "给 %@ 送礼物"; +"Chat.Service.SetTheme.Button.View" = "查看"; +"Chat.Service.StarGift.ChannelSelt" = "%1$@ 向 %2$@ 赠送了价值 %3$@ 的礼物"; +"Chat.Service.StarGift.For" = "价值 %@ 的礼物"; +"Chat.Service.StarGift.From" = "来自 %@ 的礼物"; +"Chat.Service.StarGift.Sent" = "%1$@ 向您赠送了价值 %2$@ 的礼物"; +"Chat.Service.StarGift.SentYou" = "您已给 %@ 赠送礼物"; +"Chat.Service.StoryExpiredMentionTextIncoming" = "提到您的动态\n已无法访问"; +"Chat.Service.StoryExpiredMentionTextOutgoing" = "您提到 %@ 的动态已无法访问"; +"Chat.Service.StoryMentioned" = "%@ 在动态中提到了您。"; +"Chat.Service.StoryMentioned.You" = "您在动态中提到了 %@"; +"Chat.Service.SuggestPhoto.Info" = "%@ 推荐将此图片用做您的 Telegram 头像。"; +"Chat.Service.SuggestPhoto.Info.You" = "您已向 %@ 推荐此图片作为头像。"; +"Chat.Service.SuggestPhoto.Title" = "已建议的图片"; +"Chat.Service.SuggestPost.Header.Label.Price" = "价格"; +"Chat.Service.SuggestPost.Header.Label.Time" = "时间"; +"Chat.Service.SuggestPost.Header.Price.Free" = "免费"; +"Chat.Service.SuggestPost.Header.Time.Anytime" = "任何时间"; +"Chat.Service.SuggestPost.Header.Title" = "您推荐了一条贴文。"; +"Chat.Service.SuggestPost.Header.Title.You" = "您建议发布此消息。"; +"Chat.Service.SuggestPost.Refunded.Deleted" = "由于消息已被删除,已退款建议贴文的费用"; +"Chat.Service.SuggestPost.Refunded.Other" = "由于用户资金不足,推荐帖文已退款"; +"Chat.Service.SuggestPost.Refunded.User" = "由于余额不足,已退款推荐贴文的费用"; +"Chat.Service.SuggestPost.Status.Approved" = "🤝 贴文已获批。"; +"Chat.Service.SuggestPost.Status.Charge.Incoming" = "💰 您已被收取 %1$@。"; +"Chat.Service.SuggestPost.Status.Charge.Outgoing" = "💰 此用户已收取 %1$@。"; +"Chat.Service.SuggestPost.Status.Publish.Incoming" = "📆 您的贴文将自动发布在 %1$@ 于 %2$@。"; +"Chat.Service.SuggestPost.Status.Publish.Outgoing" = "📅 此贴文将自动发布于 %1$@ %2$@。"; +"Chat.Service.SuggestPost.Status.RefundCondition" = "🔁 如果 %1$@ 在贴文发布 24 小时之内将其删除,您的星星将原路退还。"; +"Chat.Service.SuggestPost.Status.Rejected" = "❌ 贴文已被拒绝。"; +"Chat.Service.SuggestPost.Status.Rejected.Incoming.LowBalance" = "**%1$@** 无法发布您的消息,因为您没有足够的星星。"; +"Chat.Service.SuggestPost.Status.Rejected.Incoming.NoComment" = "**%1$@** 拒收您的消息。"; +"Chat.Service.SuggestPost.Status.Rejected.Incoming.WithComment" = "**%1$@** 拒收了您的消息,并备注:\n\n%2$@"; +"Chat.Service.SuggestPost.Status.Rejected.LowBalanceTitle" = "⚠️ 交易失败"; +"Chat.Service.SuggestPost.Status.Rejected.Outgoing.LowBalance" = "**%1$@** 无法发布消息,因为用户没有足够的星星。"; +"Chat.Service.SuggestPost.Status.Rejected.Outgoing.NoComment" = "您已拒收此贴文。"; +"Chat.Service.SuggestPost.Status.Rejected.Outgoing.WithComment" = "❌ 您拒绝了该消息并附言:\n\n%1$@"; +"Chat.Service.SuggestPost.Status.Rejected.Title" = "❌ 已拒绝"; +"Chat.Service.SuggestPost.Status.Release.Incoming" = "⏳ 贴文发布 24 小时后,%1$@ 将会收到您的星星。"; +"Chat.Service.SuggestPost.Status.Release.Outgoing" = "⌛️ %1$@ 将在贴文发布 24 小时后收到星星。"; +"Chat.Service.SuggestPost.Status.Title" = "🤝 达成协议!"; +"Chat.Service.SuggestPost.Success.Received" = "**%1$@** 因发布此帖子收到了 **%2$@**"; +"Chat.Service.SuggestedPhoto" = "%@ 为您的 Telegram 资料推荐了一张图片"; +"Chat.Service.Todo.Added" = "{author} 向 {task} 添加了新任务 {values}。"; +"Chat.Service.Todo.Added.Multi" = "{author} 已将任务 {values} 添加至 {task}。"; +"Chat.Service.Todo.Added.Multi.You" = "您已将任务 {values} 添加至 {task}。"; +"Chat.Service.Todo.Added.You" = "您向 {task} 添加了新任务 {values}。"; +"Chat.Service.Todo.Complete.Premium" = "订阅 Telegram 高级版即可解锁核对清单。 [阅读更多]()"; +"Chat.Service.Todo.Marked.Done" = "{author} 将 {values} 标记为已完成"; +"Chat.Service.Todo.Marked.Done.You" = "您将 {values} 标记为已完成。"; +"Chat.Service.Todo.Marked.Undone" = "{author} 将 {values} 标记为未完成。"; +"Chat.Service.Todo.Marked.Undone.You" = "您将 {values} 标记为未完成。"; +"Chat.Service.TonGift.Info" = "使用 TON 购买礼物并向频道建议贴文。"; +"Chat.Service.UpdateTheme" = "%1$@ 已将聊天话题变更为 %2$@"; +"Chat.Service.UpdateTheme.You" = "您已将聊天主题更改为 %@"; +"Chat.Service.VoiceChatFinished" = "%1$@ 已结束语音通话 (%2$@)"; +"Chat.Service.VoiceChatFinished.Channel" = "直播已结束(%1$@)"; +"Chat.Service.VoiceChatFinished.Channel1" = "直播结束(%1$@)"; +"Chat.Service.VoiceChatFinishedYou" = "你已结束语音通话 (%@)"; +"Chat.Service.VoiceChatInvitation" = "%1$@ 邀请了 %2$@ 加入 [语音聊天](open)"; +"Chat.Service.VoiceChatInvitationByYou" = "您邀请了 %1$@ 加入 [语音聊天](open)"; +"Chat.Service.VoiceChatInvitationForYou" = "%1$@ 邀请您加入 [视频聊天](open)"; +"Chat.Service.VoiceChatScheduled" = "%1$@于%2$@安排了[视频通话](open)"; +"Chat.Service.VoiceChatScheduled.Channel" = "[视频聊天](open)计划于 %@ 开始"; +"Chat.Service.VoiceChatScheduled.Channel1" = "[直播](open)预定于%@"; +"Chat.Service.VoiceChatScheduledYou" = "你安排了于 %1$@ 的[视频聊天](open)"; +"Chat.Service.VoiceChatStarted" = "%1$@ 已开始 [视频通话](open)"; +"Chat.Service.VoiceChatStarted.Channel" = "[直播](open)已开始"; +"Chat.Service.VoiceChatStarted.Channel1" = "[直播](open)已开始"; +"Chat.Service.VoiceChatStartedYou" = "您开启了一个 [语音聊天](open)"; +"Chat.Service.WebData" = "来自 “%@” 按钮的数据已发送至机器人。"; +"Chat.Service.You" = "你"; +"Chat.Service.YouSuggestedPhoto" = "您推荐了个人资料图片"; +"Chat.ShareInlineResultActionHeader" = "分享"; +"Chat.StarsGift.Button.Unpack" = "开箱"; +"Chat.StarsGift.SentBy" = "%@ 已发送 %@"; +"Chat.StarsGift.SentInChannel" = "%@ 已将 %@ 发送给 %@"; +"Chat.StarsGift.SentYou" = "%@ 已赠送给您礼物"; +"Chat.StarsGift.Upgrade.Other" = "**%@** 给您从**%@** 收到的礼物发送了价值 **%@** 的升级"; +"Chat.StarsGift.Upgrade.Self" = "**%@** 为这份礼物赠送了价值 **%@** 的升级机会"; +"Chat.StarsGift.UpgradeSent" = "您为这份礼物赠送了价值 %@ 的升级机会。"; +"Chat.StarsGift.UpgradeSentBy" = "%@ 为这份礼物赠送了价值 %@ 的升级机会"; +"Chat.State.HideOtherMessages" = "隐藏其他消息"; +"Chat.State.ShowOtherMessages" = "显示其他消息"; +"Chat.SuggestPost.Publish.AcceptTitle" = "接受条款"; +"Chat.SuggestPost.Publish.ActionNow" = "立即发布"; +"Chat.SuggestPost.Publish.ConfirmButton" = "发布"; +"Chat.SuggestPost.Publish.ConfirmHeader" = "接受条款"; +"Chat.SuggestPost.Publish.ConfirmInfo" = "您确定要发布来自 %1$@ 的这个帖子吗?通过发布此帖子,将获得 %2$@(%3$@)。发布后,帖子必须保持可见状态至少 24 小时。"; +"Chat.SuggestPost.Publish.ConfirmInfoFree" = "您想要发布来自 %1$@ 的此贴文吗?"; +"Chat.SuggestPost.Publish.DateConfirm" = "发布于 %1$@"; +"Chat.SuggestPost.Publish.Info" = "您将通过发布此贴文获得 **%1$@** (%2$@)。"; +"Chat.SuggestPost.Publish.InfoFree" = "选择发布此贴文的日期和时间。"; +"Chat.Theme.OtherSet" = "%1$@ 已将 %2$@ 设置为此聊天的新主题。"; +"Chat.Theme.SelfSet" = "您将 %1$@ 设置为此聊天的新主题。"; +"Chat.Title.Chats#other" = "%d 个聊天"; +"Chat.Title.Chats_countable" = "%d"; +"Chat.Title.Comments#other" = "%d 条评论"; +"Chat.Title.Comments_ZeroValueHolder" = "评论"; +"Chat.Title.Comments_countable" = "%d"; +"Chat.Title.Discussion" = "讨论"; +"Chat.Title.PinnedMessages#other" = "%d 条置顶消息"; +"Chat.Title.PinnedMessages_countable" = "%d"; +"Chat.Title.Reminder" = "提醒事项"; +"Chat.Title.Replies#other" = "%d 回复"; +"Chat.Title.Replies_ZeroValueHolder" = "回复"; +"Chat.Title.Replies_countable" = "%d"; +"Chat.Title.ScheduledMessages" = "定时消息"; +"Chat.Title.Topic#other" = "%d 条消息"; +"Chat.Title.Topic_ZeroValueHolder" = "消息"; +"Chat.Title.Topic_countable" = "%d"; +"Chat.Title.self" = "您的云存储"; +"Chat.TitleFeed" = "订阅"; +"Chat.Toast.ReportSuccess" = "Telegram 审阅人团队将复核您的举报。谢谢!"; +"Chat.ToastQuoteChatUnavailbalePrivateChannel" = "该引文来自私密频道"; +"Chat.ToastQuoteChatUnavailbalePrivateChat" = "该引文来自私聊"; +"Chat.ToastQuoteChatUnavailbalePrivateGroup" = "该引文来自私密群组"; +"Chat.Tooltip.HiddenForwardName" = "该账号被用户屏蔽"; +"Chat.TopicBadge" = "话题创建者"; +"Chat.TopicHeader.Empty" = "暂无消息..."; +"Chat.TopicHeader.Full" = "话题已开始"; +"Chat.Translate.Menu.DoNotTranslate" = "无需翻译%@"; +"Chat.Translate.Menu.Hide" = "隐藏"; +"Chat.Translate.Menu.Hide.Bot.Tooltip" = "翻译栏已隐藏。\n您可以在机器人简介中重新启用。"; +"Chat.Translate.Menu.Hide.Channel.Tooltip" = "您已经在此频道隐藏了翻译栏,可以在频道简介里重新启用。"; +"Chat.Translate.Menu.Hide.Group.Tooltip" = "翻译栏已隐藏。\n您可以在群组简介中重新启用。"; +"Chat.Translate.Menu.Hide.Tooltip" = "已在此频道中隐藏翻译栏。"; +"Chat.Translate.Menu.Hide.User.Tooltip" = "在此聊天中已隐藏翻译栏。\n您可以在信息页面上重新启用。"; +"Chat.Translate.Menu.HidePaywall.Tooltip" = "您始终可以在语言设置中启用它。"; +"Chat.Translate.Menu.To" = "选择其他语言"; +"Chat.Translate.ShowOriginal" = "显示原文"; +"Chat.Translate.To" = "翻译为 %1$@"; +"Chat.TranslationPoweredByCocoon" = "翻译由 Cocoon 提供技术支持。[这是如何运作的?]()"; +"Chat.UndoManager.ChannelDeleted#other" = "已删除 %d 个频道。"; +"Chat.UndoManager.ChannelDeleted_countable" = "%d"; +"Chat.UndoManager.ChannelLeft#other" = "已退出%d 个频道"; +"Chat.UndoManager.ChannelLeft_countable" = "%d"; +"Chat.UndoManager.ChatLeft#other" = "已退出 %d 个聊天"; +"Chat.UndoManager.ChatLeft_countable" = "%d"; +"Chat.UndoManager.ChatsArchived#other" = "%d 个聊天已归档"; +"Chat.UndoManager.ChatsArchived_countable" = "%d"; +"Chat.UndoManager.ChatsDeleted#other" = "%d 对话已被删除"; +"Chat.UndoManager.ChatsDeleted_countable" = "%d"; +"Chat.UndoManager.ChatsHistoryCleared#other" = "已清除 %d 聊天记录"; +"Chat.UndoManager.ChatsHistoryCleared_countable" = "%d"; +"Chat.UndoManager.Undo" = "撤消"; +"Chat.UnpinAllMessagesConfirmation#other" = "您想取消置顶聊天中的 %d 条消息吗?"; +"Chat.UnpinAllMessagesConfirmation_countable" = "%d"; +"Chat.Unsupported.UpdatedApp" = "更新"; +"Chat.UploadLimit.Text#other" = "订阅 [Telegram 高级版]() 可将上传速度提升 %d 倍。"; +"Chat.UploadLimit.Text_countable" = "%d"; +"Chat.UploadLimit.Title" = "上传速度受限"; +"Chat.Video.Processing" = "处理中…"; +"Chat.VideoCall.Incoming" = "视频来电"; +"Chat.VideoCall.Outgoing" = "拨出视频通话"; +"Chat.VideoProccessing.Context" = "视频将在转换优化后发布。"; +"Chat.VideoProccessing.Improving" = "改善视频中…"; +"Chat.VideoProccessing.Improving.Info" = "此视频将在优化后发布,以获得最佳观看体验。"; +"Chat.VideoProccessing.Improving.Info.Forward" = "转发的视频将在优化至最佳观看体验后发布。"; +"Chat.VideoProccessing.MessageTime" = "约 %@"; +"Chat.VideoProccessing.Published" = "视频已发布。"; +"Chat.VideoProccessing.Published.View" = "查看"; +"Chat.VideoProccessing.SendNow.Header" = "稍等!"; +"Chat.VideoProccessing.SendNow.Info" = "此视频尚未转换和优化。如果您现在发送,视频的观众可能会遇到下载速度缓慢的情況。"; +"Chat.VideoProccessing.SendNow.OK" = "仍然发送"; +"Chat.VideoProccessing.Service" = "视频将在转换优化后发布"; +"Chat.VideoProccessing.Tooltip" = "处理视频可能需要几分钟时间。"; +"Chat.ViewBackground" = "查看壁纸"; +"Chat.ViewCollectible" = "查看典藏品"; +"Chat.ViewContact" = "查看联系人"; +"Chat.Voice.Single.Close" = "关闭"; +"Chat.Voice.Single.DeleteAndClose" = "删除并关闭"; +"Chat.Voice.Transribe.Error" = "未检测到语音"; +"Chat.VoiceChat.JoinLink.OK" = "加入"; +"Chat.VoiceChat.JoinLink.Participants#other" = "%d个参与者"; +"Chat.VoiceChat.JoinLink.Participants_ZeroValueHolder" = "无人加入"; +"Chat.VoiceChat.JoinLink.Participants_countable" = "%d"; +"Chat.VoiceChat.JoinLink.Title" = "视频聊天"; +"Chat.VoiceChat.JoinLink.Unavailable" = "视频聊天已结束"; +"Chat.WP.Auction" = "礼物拍卖"; +"Chat.WP.Auction.Upcoming" = "即将开始的拍卖"; +"Chat.WP.BackgroundTitle" = "聊天壁纸"; +"Chat.WP.Color" = "颜色"; +"Chat.WP.Color.FirstMessage" = "请按应用设置壁纸"; +"Chat.WP.Color.SecondMessage" = "看起来不错"; +"Chat.WP.FirstMessage" = "捏住、滑动或双击来选择一个自定义的背景区域。"; +"Chat.WP.Intensity" = "图案强度"; +"Chat.WP.Pattern" = "图案"; +"Chat.WP.Pattern.None" = "无"; +"Chat.WP.SecondMessage" = "捏我,我在做梦!"; +"Chat.WP.SelectFromFile" = "从文件中选择"; +"Chat.WP.VoiceChatTitle" = "视频聊天"; +"Chat.Webpage.MediaCount" = "%d / %d"; +"Chat.Webpage.MediaCount1" = " %2$d 的 %1$d"; +"ChatEmpty.BusinessIntro.How" = "%@ 已为所有空聊天添加上述消息。[如何实现?]()"; +"ChatEmpty.BusinessLink.Text" = "添加一条消息,所有通过以下链接与您开始聊天的人都将在消息输入框看到此消息:"; +"ChatEmpty.BusinessLink.Title" = "聊天链接"; +"ChatEmpty.ManagedBot.EditInfo" = "您可以编辑机器人的信息"; +"ChatEmpty.ManagedBot.Here" = "此处 >"; +"ChatEmpty.ManagedBot.IsReady" = "已准备就绪!"; +"ChatEmpty.ManagedBot.Start" = "启用"; +"ChatEmpty.ManagedBot.Tap" = "点击"; +"ChatEmpty.ManagedBot.TestInfo" = "在下方测试您的新聊天机器人。其行为由以下内容定义: "; +"ChatEmpty.SavedMessages" = "没有收藏的消息"; +"ChatEmpty.Topic" = "发送第一条消息以开始话题。"; +"ChatEmpty.UserInfo.CommonGroups" = "共同群组"; +"ChatEmpty.UserInfo.PhoneNumber" = "电话号码"; +"ChatEmpty.UserInfo.Registration" = "注册"; +"ChatGift.Info.FromGiveAway" = "您在 **%1$@** 发起的赠品活动中赢得了奖品。\n\n您的奖品是 **%2$@** 的 Telegram 高级版订阅。"; +"ChatGift.Info.GiveawayStars#other" = "您在 **%1$@** 组织的抽奖活动中赢得了奖品。\n\n您的奖品是 **%2$@ 颗星星**。"; +"ChatGift.Info.GiveawayStars_countable" = "%1$@ %2$d"; +"ChatGift.Info.Normal" = "您已收到来自 %1$@ 的礼物。\n您的礼物是 **%2$@** 个月的 **Telegram 高级版**\n订阅。"; +"ChatGift.Info.Unclaimed" = "您从 **%1$@** 已获得一份尚未领取的奖品。\n\n您的奖品是 **%2$@** 的Telegram 高级版订阅。"; +"ChatGift.Info.Unclaimed.Gift" = "您已赠送为期 %1$@ 个月的 **Telegram Premium** 订阅。"; +"ChatGift.Info.Unclaimed.Gift.You" = "您收到了为期 %1$@ 个月的 **Telegram Premium** 订阅。"; +"ChatGift.Title.Claimed" = "恭喜!"; +"ChatGift.Title.Gift" = "**礼物**"; +"ChatGift.Title.Unclaimed" = "**尚未领取的奖品**"; +"ChatHeader.RestartTopic" = "解锁话题"; +"ChatHeader.Search.AddTags" = "添加标签"; +"ChatHeader.Search.AddTags.Second" = "至我的收藏"; +"ChatHeader.Tone" = "语气"; +"ChatHeader.Tone.Casual" = "随意"; +"ChatHeader.Tone.Formal" = "正式"; +"ChatHeader.Tone.Neutral" = "中立"; +"ChatHistory.DiceWonOther" = "%1$@ 已赢得 💎 %2$@"; +"ChatHistory.DiceWonYou" = "您已赢得 💎 %1$@"; +"ChatInput.BotMenu" = "菜单"; +"ChatInput.Business.Away" = "离开消息..."; +"ChatInput.Business.Greeting" = "问候消息"; +"ChatInput.Business.Link" = "添加预设消息..."; +"ChatInput.Business.QuickReply" = "快速回复消息..."; +"ChatInput.EditLink.AboveTheMessage" = "消息上方"; +"ChatInput.EditLink.BelowTheMessage" = "消息下方"; +"ChatInput.EditLink.LargerMedia" = "较大的媒体"; +"ChatInput.EditLink.RemovePreview" = "取消链接内容预览"; +"ChatInput.EditLink.SmallerMedia" = "较小的媒体"; +"ChatInput.OpenChat" = "打开聊天"; +"ChatInput.Reply.ReplyToAnother" = "在另一个聊天中回复"; +"ChatInput.ShowNext" = "显示下一项"; +"ChatLink.Unavailable" = "抱歉,此链接似乎不存在。"; +"ChatList.ActiveAuction.Title#other" = "%d 场进行中的拍卖"; +"ChatList.ActiveAuction.Title_ZeroValueHolder" = "进行中的拍卖"; +"ChatList.ActiveAuction.Title_countable" = "%d 场进行中的拍卖"; +"ChatList.Add.BottomSeparator" = "对话"; +"ChatList.Add.Placeholder" = "选择对话……"; +"ChatList.Add.Save" = "添加"; +"ChatList.Add.TopSeparator" = "聊天类型"; +"ChatList.Apps.ClearRecent" = "清除最近使用的应用"; +"ChatList.Apps.CloseAll" = "全部关闭"; +"ChatList.Apps.Popular" = "畅销应用"; +"ChatList.AppsSearch.Empty.Info" = "你目前没有使用任何应用。"; +"ChatList.AppsSearch.Empty.Title" = "暂无应用..."; +"ChatList.AppsTag" = "App"; +"ChatList.Archive.Back" = "聊天"; +"ChatList.ArchiveStory.Count#other" = "%d 条动态"; +"ChatList.ArchiveStory.Count_countable" = "%d"; +"ChatList.ArchivedChats" = "已归档的聊天"; +"ChatList.AuthorHidden" = "作者已隐藏"; +"ChatList.Birthday.Add.Info" = "让您的联系人知道您何时庆祝生日。"; +"ChatList.Birthday.Add.Title" = "添加你的生日!🎂"; +"ChatList.BirthdayInfo#other" = "赠送他们 Telegram 高级版。"; +"ChatList.BirthdayInfoNew#other" = "为其赠送礼物。"; +"ChatList.BirthdayInfoNew_countable" = "%d"; +"ChatList.BirthdayInfo_countable" = "%d"; +"ChatList.BirthdayMultipleTitle#other" = "今天 %d 位联系人过 **生日** ! 🎂"; +"ChatList.BirthdayMultipleTitle_countable" = "%d"; +"ChatList.BirthdaySingleTitle" = "今天是 %@ 的 **生日**!🎂"; +"ChatList.ChannelSearch.Empty.Info" = "您目前没有订阅任何频道。"; +"ChatList.ChannelSearch.Empty.Title" = "暂无频道…"; +"ChatList.ChannelsTag" = "频道"; +"ChatList.ChatsTag" = "聊天"; +"ChatList.CloseFilter" = "显示所有"; +"ChatList.CloseFilterShort" = "所有"; +"ChatList.Context.Call" = "通话"; +"ChatList.Context.ClearHistory" = "清空历史记录"; +"ChatList.Context.Delete" = "删除"; +"ChatList.Context.DeleteAndExit" = "删除并退出"; +"ChatList.Context.DeleteChat" = "删除聊天"; +"ChatList.Context.HideGeneral" = "隐藏"; +"ChatList.Context.HidePromo" = "隐藏"; +"ChatList.Context.LeaveChannel" = "退出频道"; +"ChatList.Context.LeaveGroup" = "退出群组"; +"ChatList.Context.MaskAsRead" = "标记为已读"; +"ChatList.Context.MaskAsUnread" = "标记为未读"; +"ChatList.Context.Mute" = "免打扰"; +"ChatList.Context.Pause" = "关闭"; +"ChatList.Context.Pin" = "置顶"; +"ChatList.Context.PinError" = "抱歉,您最多只能置顶 5 个聊天。"; +"ChatList.Context.PinError.Topics#other" = "抱歉,您最多只能置顶 %d 个话题。请先取消置顶一些当前已置顶的话题。"; +"ChatList.Context.PinError.Topics_countable" = "%d"; +"ChatList.Context.PinErrorNew" = "抱歉,您最多只能置顶 5 个聊天。"; +"ChatList.Context.PinErrorNew.SetupFolders" = "设置文件夹"; +"ChatList.Context.PinErrorNew2" = "抱歉,您只能将 5 个聊天置顶在主列表的顶部。 您可以在聊天文件夹和归档中置顶更多聊天。"; +"ChatList.Context.Preview" = "预览"; +"ChatList.Context.ReturnGroup" = "返回群组"; +"ChatList.Context.Sound" = "提示音"; +"ChatList.Context.Start" = "重新打开"; +"ChatList.Context.UnhideGeneral" = "取消隐藏"; +"ChatList.Context.Unmute" = "取消免打扰"; +"ChatList.Context.Unpin" = "取消置顶"; +"ChatList.DownloadsTag" = "下载"; +"ChatList.Draft" = "草稿:"; +"ChatList.Empty.CreateTopic" = "创建新话题"; +"ChatList.Empty.DisableForum" = "显示为消息"; +"ChatList.Empty.Loading" = "正在下载"; +"ChatList.EmptyForum" = "暂无话题"; +"ChatList.EmptyText" = "**您还没有聊天**\n点击右上角的铅笔按钮开始发送消息,或转到“联系人”。"; +"ChatList.Feeds" = "频道"; +"ChatList.Filter.Add.Done" = "完成"; +"ChatList.Filter.AddChats" = "添加聊天"; +"ChatList.Filter.AddToFolder" = "添加至聊天文件夹..."; +"ChatList.Filter.All" = "所有"; +"ChatList.Filter.AllChats" = "所有聊天"; +"ChatList.Filter.Archive" = "归档"; +"ChatList.Filter.Back" = "对话"; +"ChatList.Filter.Bots" = "机器人"; +"ChatList.Filter.Categories.Header" = "包含对话类型"; +"ChatList.Filter.Channels" = "频道"; +"ChatList.Filter.Confirm.Remove.Header" = "移除文件夹"; +"ChatList.Filter.Confirm.Remove.OK" = "删除"; +"ChatList.Filter.Confirm.Remove.Text" = "您确定要删除这个分组吗?分组中的聊天不会被删除。"; +"ChatList.Filter.Contacts" = "联系人"; +"ChatList.Filter.Delete" = "删除"; +"ChatList.Filter.Discard.Cancel" = "取消"; +"ChatList.Filter.Discard.Header" = "放弃修改"; +"ChatList.Filter.Discard.OK" = "放弃"; +"ChatList.Filter.Discard.Text" = "您确定要放弃所有更改吗?"; +"ChatList.Filter.Done" = "创建"; +"ChatList.Filter.Edit" = "编辑文件夹"; +"ChatList.Filter.EditFilters" = "编辑文件夹"; +"ChatList.Filter.Empty" = "**文件夹中没有任何聊天。**\n\n[编辑文件夹](filter)"; +"ChatList.Filter.Error.Empty" = "请添部分聊天或对话类型。"; +"ChatList.Filter.Error.LikeChats" = "无法创建包含所有聊天的分组。"; +"ChatList.Filter.Exclude.AddChat" = "添加对话"; +"ChatList.Filter.Exclude.Desc" = "选择不会出现在此文件夹中的聊天或聊天类型。"; +"ChatList.Filter.Exclude.Header" = "排除的聊天"; +"ChatList.Filter.Exclude.LimitReached" = "抱歉,您只能添加 100 个单独的聊天,请尝试使用聊天类型。"; +"ChatList.Filter.Exclude.LimitReached.New" = "抱歉,你只能加起来最多 %d 个聊天。"; +"ChatList.Filter.Exclude.RemoveChat" = "移除"; +"ChatList.Filter.ExcludeMuted" = "取消静音"; +"ChatList.Filter.ExcludeRead" = "排除已读"; +"ChatList.Filter.ExistingChats" = "现有聊天"; +"ChatList.Filter.Groups" = "群组"; +"ChatList.Filter.Header" = "为不同聊天分组创建文件夹,并在它们之间快速切换。"; +"ChatList.Filter.Hide#other" = "隐藏 %d 个对话"; +"ChatList.Filter.Hide_countable" = "%d"; +"ChatList.Filter.Include.AddChat" = "所有对话"; +"ChatList.Filter.Include.Desc" = "选择将出现在此文件夹中的聊天和聊天类型。"; +"ChatList.Filter.Include.Header" = "包含的聊天"; +"ChatList.Filter.Include.LimitReached" = "抱歉,您只能添加 100 个单独的聊天,请尝试使用聊天类型。"; +"ChatList.Filter.Include.LimitReached.New" = "抱歉,你只能加起来最多 %d 个聊天。"; +"ChatList.Filter.Include.RemoveChat" = "移除"; +"ChatList.Filter.InviteLink" = "创建一个新链接"; +"ChatList.Filter.InviteLink.Delete" = "删除"; +"ChatList.Filter.InviteLink.Delete.Confirm" = "你确定要撤销并删除此邀请链接吗?"; +"ChatList.Filter.InviteLink.Desc" = "包括 %d 个聊天"; +"ChatList.Filter.InviteLink.Desc_countable" = "%d"; +"ChatList.Filter.InviteLink.IncludeExcludeError" = "您不能分享包含特定聊天类别(比如“群组”、“联系人”等)的分组。"; +"ChatList.Filter.InviteLink.Info" = "与其他人分享此文件夹中的群组和频道的访问权。"; +"ChatList.Filter.InviteLink.Share" = "分享文件夹"; +"ChatList.Filter.InviteLink.header" = "邀请链接"; +"ChatList.Filter.List.AddNew" = "创建新定义文件夹"; +"ChatList.Filter.List.Desc" = "拖放分组重新排序,右键单击删除。"; +"ChatList.Filter.List.Header" = "文件夹"; +"ChatList.Filter.List.Remove" = "移除"; +"ChatList.Filter.List.Title" = "聊天文件夹"; +"ChatList.Filter.Loading" = "**添加对话中**\n我们正在为您添加聊天到此文件夹,请稍候…"; +"ChatList.Filter.MiniApps" = "小程序"; +"ChatList.Filter.MuteAll" = "全部静音"; +"ChatList.Filter.MutedChats" = "已静音"; +"ChatList.Filter.Name.Header" = "文件夹名称"; +"ChatList.Filter.Name.Placeholder" = "文件夹名称"; +"ChatList.Filter.NewChats" = "新聊天"; +"ChatList.Filter.NewTitle" = "创建文件夹"; +"ChatList.Filter.NonContacts" = "非联系人"; +"ChatList.Filter.PremiumUsers" = "高级版用户"; +"ChatList.Filter.ReadAll" = "全部标记已读"; +"ChatList.Filter.ReadChats" = "已读"; +"ChatList.Filter.Recommended.Add" = "添加"; +"ChatList.Filter.Recommended.Header" = "推荐"; +"ChatList.Filter.RemoveFromFolder" = "从聊天文件夹移除"; +"ChatList.Filter.SecretChat" = "私密聊天"; +"ChatList.Filter.Setup" = "编辑文件夹"; +"ChatList.Filter.SetupEmpty" = "添加文件夹"; +"ChatList.Filter.Share" = "分享"; +"ChatList.Filter.ShowMore#other" = "显示其他 %d 个聊天"; +"ChatList.Filter.ShowMore_countable" = "%d"; +"ChatList.Filter.SmallGroups" = "小群组"; +"ChatList.Filter.TabBar.Desc" = "如果文件夹过多,可以试试将分组移动到左侧。"; +"ChatList.Filter.TabBar.Header" = "标签视图"; +"ChatList.Filter.TabBar.OnTheLeft" = "左侧标签"; +"ChatList.Filter.TabBar.OnTheTop" = "将分组显示在顶部"; +"ChatList.Filter.Tilte.Default.Bots" = "机器人"; +"ChatList.Filter.Tilte.Default.Channels" = "频道"; +"ChatList.Filter.Tilte.Default.Contacts" = "联系人"; +"ChatList.Filter.Tilte.Default.Groups" = "群组"; +"ChatList.Filter.Tilte.Default.NonContacts" = "非联系人"; +"ChatList.Filter.Tilte.Default.Unmuted" = "未静音"; +"ChatList.Filter.Tilte.Default.Unread" = "未读"; +"ChatList.Filter.Title" = "文件夹"; +"ChatList.Filter.Tooltip" = "右击管理您的聊天。"; +"ChatList.Filter.UnmuteAll" = "全部取消静音"; +"ChatList.Folder.ColorInfo" = "此颜色会被用作消息列表中此文件夹的标签。"; +"ChatList.Folder.ColorReset" = "重置颜色"; +"ChatList.Folder.ColorTitle" = "聊天列表中的文件夹颜色"; +"ChatList.Folder.DisableAnimations" = "禁用动画"; +"ChatList.Folder.EnableAnimations" = "启用动画"; +"ChatList.Folder.PremiumAlert" = "订阅 Telegram 高级版为你的文件夹添加颜色。"; +"ChatList.Folder.Tags" = "显示文件夹标签"; +"ChatList.Folder.Tags.Info" = "显示聊天列表中每个聊天的文件夹名称。"; +"ChatList.FolderUpdates.Info" = "点击此处查看"; +"ChatList.FolderUpdates.Title#other" = "%d 个可用的新聊天"; +"ChatList.FolderUpdates.Title_countable" = "%d"; +"ChatList.FreezeAccount" = "您的账号因严重违反 Telegram 的 [条款和条件](https://telegram.org/tos) 而被冻结。您的账号现已进入只读模式(自 %1$@ 起)。如果您认为这是一个错误,可以通过 [@SpamBot](%2$@) 提出申诉。如果我们在 %3$@ 之前未收到您的回复,您的账号将被完全停用并终止。"; +"ChatList.FreezeAccount.Alert" = "您的帐户因严重违反 Telegram 的 [服务条款](https://telegram.org/tos). 而被冻结。您的帐户现在处于只读模式(自 %1$@ 起)。如果您认为这是误封,请通过 [@SpamBot](%2$@) 申诉。如果我们直到 %3$@ 都没有收到您的申诉,您的帐户将被完全停用并删除。"; +"ChatList.FreezeAccount.Alert.Appeal" = "申诉"; +"ChatList.FreezeAccount.Alert.Title" = "您的账户已被冻结"; +"ChatList.FreezeAccount.Title" = "您的账号已被冻结"; +"ChatList.Giveaway" = "抽奖活动"; +"ChatList.GracePeriod.Info" = "不要失去 Telegram 高级版的专属功能。"; +"ChatList.GracePeriod.Title" = "⚠️ 您的 Telegram 高级版订阅即将到期!"; +"ChatList.GroupChannel" = "群组频道"; +"ChatList.HideMuted" = "隐藏静音"; +"ChatList.MessagesTag" = "消息"; +"ChatList.Monoforum.Holder" = "私信"; +"ChatList.Monoforum.TabBar.List" = "列表"; +"ChatList.Monoforum.TabBar.Tabs" = "标签"; +"ChatList.Mute.1Day" = "1 天"; +"ChatList.Mute.1Hour" = "1 小时"; +"ChatList.Mute.3Days" = "3 天"; +"ChatList.Mute.4Hours" = "4 小时"; +"ChatList.Mute.8Hours" = "8 小时"; +"ChatList.Mute.Forever" = "永久"; +"ChatList.Mute.Until" = "静音直到..."; +"ChatList.Mute.Until.OK" = "静音"; +"ChatList.Mute.Until.Title" = "静音直到……"; +"ChatList.MyNotes" = "我的笔记"; +"ChatList.OpenMiniApp" = "打开"; +"ChatList.Popover.Confirm" = "您确定要将全部聊天标记为已读吗?"; +"ChatList.Popover.ReadAll" = "全部标记为已读"; +"ChatList.Posts.Tag" = "帖子"; +"ChatList.RevealAction.Collapse" = "折叠"; +"ChatList.RevealAction.Expand" = "展开"; +"ChatList.RevealAction.Hide" = "隐藏"; +"ChatList.RevealAction.Pin" = "置顶"; +"ChatList.Search.Placeholder" = "搜索 (⌘K)"; +"ChatList.SecretChat.Created" = "%@ 创建了私密对话"; +"ChatList.SecretChat.ExKeys" = "等待用户上线"; +"ChatList.SecretChat.Joined" = "%@ 已加入您的私密聊天。"; +"ChatList.SecretChat.Terminated" = "私密聊天已取消"; +"ChatList.Service.Call.Cancelled" = "已取消的通话"; +"ChatList.Service.Call.Missed" = "未接电话"; +"ChatList.Service.Call.incoming" = "来电 (%@)"; +"ChatList.Service.Call.outgoing" = "拨出通话 (%@)"; +"ChatList.Service.DestructingPhoto" = "自毁图片"; +"ChatList.Service.DestructingVideo" = "自毁视频"; +"ChatList.Service.GameScored1#other" = "获得 %d 分:%@"; +"ChatList.Service.GameScored1_countable" = "%d %@"; +"ChatList.Service.VideoCall.Cancelled" = "已取消的视频来电"; +"ChatList.Service.VideoCall.Missed" = "未接视频来电"; +"ChatList.Service.VideoCall.incoming" = "呼入视频通话(%@)"; +"ChatList.Service.VideoCall.outgoing" = "拨出视频通话(%@)"; +"ChatList.Service.VoiceChatFinished.Channel" = "视频聊天已结束 (%1$@)"; +"ChatList.Service.VoiceChatFinished.Channel1" = "直播已结束 (%1$@)"; +"ChatList.Service.VoiceChatInvitation" = "%1$@ 已邀请 %2$@ 加入视频聊天"; +"ChatList.Service.VoiceChatInvitationByYou" = "您已邀请 %1$@ 加入视频聊天"; +"ChatList.Service.VoiceChatInvitationForYou" = "%1$@ 邀请您加入视频聊天"; +"ChatList.Service.VoiceChatScheduled" = "%1$@ 已预约 %2$@ 开始的视频聊天"; +"ChatList.Service.VoiceChatScheduled.Channel" = "视频聊天已预约于 %@"; +"ChatList.Service.VoiceChatScheduled.Channel1" = "预定 %@进行直播"; +"ChatList.Service.VoiceChatScheduledYou" = "你预约了%2$@的视频聊天"; +"ChatList.Service.VoiceChatStarted" = "%1$@ 已发起视频聊天"; +"ChatList.Service.VoiceChatStarted.Channel" = "视频聊天已开始"; +"ChatList.Service.VoiceChatStarted.Channel1" = "直播已开始"; +"ChatList.Service.VoiceChatStartedYou" = "您已发起视频聊天"; +"ChatList.SponsoredChannel" = "代理赞助商"; +"ChatList.Story" = "动态"; +"ChatList.Story.Expired" = "已过期的动态"; +"ChatList.Story.Unsupported" = "不支持的动态"; +"ChatList.StoryMentioned" = "在动态中提到了您"; +"ChatList.SubscriptionsLowBalance#other" = "需要 %d 颗星星"; +"ChatList.SubscriptionsLowBalance.Multiple.Text" = "资金不足以支付您的订阅费用。"; +"ChatList.SubscriptionsLowBalance.Multiple.Title" = "%@ 您的订阅"; +"ChatList.SubscriptionsLowBalance.Single.Text" = "资金不足以支付您的订阅费用。"; +"ChatList.SubscriptionsLowBalance.Single.Title" = "%1$@ 用于 %2$@"; +"ChatList.SubscriptionsLowBalance_countable" = "%d"; +"ChatList.Swiping.Archive" = "归档"; +"ChatList.Swiping.Delete" = "删除"; +"ChatList.Swiping.Mute" = "关闭通知"; +"ChatList.Swiping.Pin" = "置顶"; +"ChatList.Swiping.Read" = "已读"; +"ChatList.Swiping.Unarchive" = "取消归档"; +"ChatList.Swiping.Unmute" = "开启通知"; +"ChatList.Swiping.Unpin" = "取消置顶"; +"ChatList.Swiping.Unread" = "未读"; +"ChatList.Text.UniqueGift" = "Gift %@"; +"ChatList.TitleFeed" = "订阅"; +"ChatList.TopicsTag" = "话题"; +"ChatList.UnhideMuted" = "显现静音"; +"ChatList.UnsupportedMessage" = "消息不被支持"; +"ChatList.UpcomingAuction" = "即将开始的拍卖"; +"ChatList.You" = "你"; +"ChatListApps.Clear" = "清除"; +"ChatListApps.RecentUsed.Header" = "最近用过的应用"; +"ChatMenu.AddedBy" = "由 %1$@ 添加\n于 %2$@"; +"ChatMenu.AddedByYou" = "您"; +"ChatMenu.DeleteOption" = "删除选项"; +"ChatMenu.ReplyToOption" = "回复选项"; +"ChatMessage.BoostChannel" = "助推"; +"ChatMessage.Folder.Expired" = "文件夹链接已失效"; +"ChatMessage.OpenApp" = "打开应用"; +"ChatMessage.Recommended.Title" = "推荐"; +"ChatMessage.RelativeDate.Ago" = "%1$@ 前"; +"ChatMessage.RelativeDate.Days#other" = "%1$d 天"; +"ChatMessage.RelativeDate.Hours#other" = "%1$d 小时"; +"ChatMessage.RelativeDate.InFuture" = "%1$@ 后"; +"ChatMessage.RelativeDate.Minutes#other" = "%1$d 分钟"; +"ChatMessage.RelativeDate.Months#other" = "%1$d 个月"; +"ChatMessage.RelativeDate.Seconds#other" = "%1$d 秒"; +"ChatMessage.RelativeDate.Weeks#other" = "%1$d 周"; +"ChatMessage.RelativeDate.Years#other" = "%1$d 年"; +"ChatMessage.Sponsored.About" = "关于此广告"; +"ChatMessage.Sponsored.Report" = "举报广告"; +"ChatMessage.Sponsored.Report.Aready" = "此广告已被举报"; +"ChatMessage.Sponsored.Report.LearnMore" = "详细了解我们的 [广告政策和指南](https://ads.telegram.org/guidelines)。"; +"ChatMessage.Sponsored.Report.OptionTitle" = "此广告有什么问题吗?"; +"ChatMessage.Sponsored.Report.Success" = "Telegram 管理员将会检视您的报告。感谢您!"; +"ChatMessage.Sponsored.Title" = "广告"; +"ChatMessage.Tooltip.Views" = "次浏览"; +"ChatMessage.ViewChatList" = "查看聊天列表"; +"ChatMessageMenuItems.AndJoiner" = " 和 "; +"ChatMessageMenuItems.OnlyOldSubscribersCanVote" = "仅加入 24 小时以上的订阅用户可投票。"; +"ChatMessageMenuItems.OnlySubscribersOfCanVote" = "仅 %1$@ 的订阅用户可以投票。"; +"ChatMessageMenuItems.OnlyUsersFromCountriesCanVote" = "仅来自 %1$@ 的用户可投票。"; +"ChatMessageMenuItems.ViewStats" = "查看统计数据"; +"ChatMonoforum.NewChat" = "新聊天"; +"ChatPoll.AddOption" = "添加选项"; +"ChatPoll.Explanation" = "说明"; +"ChatPoll.ResultsAfterEnd" = "投票结束后结果將公布"; +"ChatPoll.ResultsIn" = "结果将 %1$@ 后发布"; +"ChatPoll.ViewVotes#other" = "查投票 (%1$d)"; +"ChatReaction.StarsDisabled" = "已在此频道中停用星星回应。"; +"ChatReaction.Undo" = "撤消"; +"ChatReaction.Undo.Reacted#other" = "您以 %d 颗星星做出了回应。"; +"ChatReaction.Undo.Reacted_countable" = "%d"; +"ChatReaction.Undo.StarsSent" = "已发送星星!"; +"ChatReactions.TagMessage" = "使用表情符号标签消息以便快速搜索"; +"ChatReactions.TagMessage.Premium" = "使用标签整理收藏的消息\n以便快速访问。[了解更多...]()"; +"ChatRow.BotForConnector" = " 给 "; +"ChatService.ChangedToSameWallpaper" = "%1$@ 已为此聊天设置相同的壁纸。"; +"ChatService.ChangedWallpaper" = "%1$@ 为此聊天已设置新壁纸。"; +"ChatService.ChannelCreated" = "频道已创建"; +"ChatService.CopyProtection.Accept" = "接受"; +"ChatService.CopyProtection.CopyingMessages" = "复制消息"; +"ChatService.CopyProtection.ForwardingMessages" = "转发消息"; +"ChatService.CopyProtection.Reject" = "拒绝"; +"ChatService.CopyProtection.RequestExpired" = "共享请求已过期"; +"ChatService.CopyProtection.SavingPhotosVideos" = "保存图片和视频"; +"ChatService.CopyProtection.SharingDisabled" = "%1$@ 已禁用此聊天中的分享功能"; +"ChatService.CopyProtection.SharingDisabledToast" = "此聊天已禁用分享功能。"; +"ChatService.CopyProtection.SharingEnabled" = "%1$@ 已启用此聊天中分享功能"; +"ChatService.CopyProtection.SharingEnabledToast" = "此聊天已启用分享功能。"; +"ChatService.CopyProtection.SharingWillBeDisabled" = "批准后将禁用分享。"; +"ChatService.CopyProtection.SharingWillBeEnabled" = "批准后即可分享。"; +"ChatService.CopyProtection.TakingScreenshots" = "截屏"; +"ChatService.CopyProtection.WouldLikeDisable" = "%1$@ 想要停用此聊天中的分享功能"; +"ChatService.CopyProtection.WouldLikeDisableIncludes" = "%1$@ 想要禁用此聊天中的分享功能,其中包括:"; +"ChatService.CopyProtection.WouldLikeEnable" = "%1$@ 希望在此聊天中开启分享功能"; +"ChatService.CopyProtection.WouldLikeEnableIncludes" = "%1$@ 想要禁用此聊天中的分享功能,其中包括:"; +"ChatService.CraftedGift" = "制作的礼物"; +"ChatService.CraftedUniqueCollectible" = "您制作了一件新礼物!"; +"ChatService.GiftOffer.ExpiredIncoming" = "**%1$@** 未回应您的 **%2$@** 报价 – 您的 %3$@ 已退款"; +"ChatService.GiftOffer.ExpiredOutgoing" = "%1$@ 以 %3$@ 的价格购买您的 %2$@ 的报价已失效。"; +"ChatService.GiftOffer.RejectedIncoming" = "%1$@ 拒绝了您对 %2$@ 的报价 — 您的 %3$@ 已退还。"; +"ChatService.GiftOffer.RejectedOutgoing" = "您已拒绝 **%1$@** 以 **%3$@** 购买您的 **%2$@** 的报价。"; +"ChatService.GiftOffer.ValueHigher" = "此礼物的价值比出价高 **%1$@**。"; +"ChatService.GiftOffer.ValueLower" = "此礼物的价值比报价低 **%1$@**。"; +"ChatService.GiftUsedForCrafting" = "此礼物已用于制作,现已无法取得。"; +"ChatService.GiveawayResults#other" = "Telegram 已随机抽取 %d 位获奖者,并已通过私信发送奖品链接。"; +"ChatService.GiveawayResults.Stars#other" = "%d 位由 Telegram 隨机选出的抽奖活动获奖者已通过私信收到奖品。"; +"ChatService.GiveawayResults.Stars_countable" = "%d 名获奖者已由 Telegram 选出,并已通过私信收到奖品。"; +"ChatService.GiveawayResultsMixedUnclaimed#other" = "已将 %d 个未分发的链接兑换码转发给频道管理员。"; +"ChatService.GiveawayResultsMixedUnclaimed.Stars#other" = "有 %d 个未分配的奖品代码已转发给频道管理员。"; +"ChatService.GiveawayResultsMixedUnclaimed.Stars_countable" = "%d"; +"ChatService.GiveawayResultsMixedUnclaimed_countable" = "%d"; +"ChatService.GiveawayResultsMixedWinners#other" = "Telegram 随机选出了 **%d** 位赠品得奖者,他们将在私密消息中收到赠品链接。"; +"ChatService.GiveawayResultsMixedWinners.Stars#other" = "**%d** 名由 Telegram 随机选出的抽奖活动获奖者已收到一条包含奖品的消息。"; +"ChatService.GiveawayResultsMixedWinners.Stars_countable" = "%d"; +"ChatService.GiveawayResultsMixedWinners_countable" = "%d"; +"ChatService.GiveawayResultsNoWinners#other" = "由于抽奖条款,Telegram 无法选出获奖者,奖品兑换码已转发给频道管理员。"; +"ChatService.GiveawayResultsNoWinners.Stars#other" = "根据赠品条款,Telegram 无法选出获奖者,奖品代码将转发给频道管理员。"; +"ChatService.GiveawayResultsNoWinners.Stars_countable" = "%d"; +"ChatService.GiveawayResultsNoWinners_countable" = "%d"; +"ChatService.GiveawayResults_countable" = "Telegram 随机选出了 %d 位赠品得奖者,他们将在私信中收到礼品码。"; +"ChatService.JoinedChannel" = "您已加入频道"; +"ChatService.JoinedChannelByRequest" = "您已获批加入群组"; +"ChatService.JoinedGroupByRequest" = "您已获批加入群组"; +"ChatService.ManagedBotCreated" = "%1$@ 已创建 %2$@"; +"ChatService.PhoneNumberRequest" = "需要电话号码"; +"ChatService.PollOptionAppended" = "%1$@ 添加了一个选项:%2$@"; +"ChatService.PollOptionDeleted" = "%1$@ 删除了一个选项:%2$@"; +"ChatService.RemoveBackground" = "移除"; +"ChatService.StarsPrize" = "您在 %1$@ 组织的抽奖活动中赢得了奖品。\n\n您的奖品是 %2$@。"; +"ChatService.Suggest.Success" = "您已成功更新头像"; +"ChatService.Suggest.View" = "查看图片"; +"ChatService.UpdateBackground" = "更新壁纸"; +"ChatService.UploadingWallpaper" = "正在设置新的壁纸...\n**%1$@%**"; +"ChatService.UserJoinedChannelByRequest" = "%@ 通过请求已加入频道"; +"ChatService.UserJoinedGroupByRequest" = "%@ 获批加入群组"; +"ChatService.ViewBackground" = "查看壁纸"; +"ChatService.YouChangedToSameWallpaper" = "您已设置与对方相同的壁纸。"; +"ChatService.YouChangedWallpaper" = "您已为此聊天设置新壁纸。"; +"ChatService.YouChangedWallpaperBoth" = "您已为自己和 %@ 设置新壁纸"; +"ChatTheme.Colorful" = "多彩"; +"ChatTheme.Theme" = "选择主题"; +"ChatTheme.Theme.Info" = "主题也将应用于 %@"; +"ChatTheme.UniqueGift.Replace.Confirm" = "您在与 **%@** 的聊天中已设置了这个礼物作为主题,要从那里移除并在这里使用吗?"; +"ChatTheme.UniqueGift.Replace.Title" = "设置主题"; +"ChatTitle.Business.AwayMessages" = "离开消息"; +"ChatTitle.Business.GreetingMessages" = "问候消息"; +"ChatTitle.ReportMessages" = "举报信息"; +"ChatWallpaper.Empty" = "默认"; +"ChatbotSetup.Gift.Warning.CombinedText" = "机器人**%@** 将有权管理您的礼物和星星,包括将它们赠送给其他用户。"; +"ChatbotSetup.Gift.Warning.GiftsText" = "机器人 **%@** 将有权 **管理您的礼物**,包括將它们赠送给其他用户。"; +"ChatbotSetup.Gift.Warning.Proceed" = "继续"; +"ChatbotSetup.Gift.Warning.StarsText" = "机器人 **%@** 将有权 **转让您的星星**。"; +"ChatbotSetup.Gift.Warning.Title" = "警告"; +"ChatbotSetup.Gift.Warning.UsernameText" = "机器人**%@** 将有权对您的账户 **设置和移除用户名**,这可能会导致您丢失目前的用户名。"; +"Checkout.2FA.Text" = "开启两步验证后才能保存付款详情。"; +"Checkout.Email" = "电子邮箱"; +"Checkout.EnterPassword" = "输入密码"; +"Checkout.ErrorGeneric" = "处理您的付款时出错。您的银行卡没有被扣款。"; +"Checkout.ErrorInvoiceAlreadyPaid" = "您已经支付过此商品。"; +"Checkout.ErrorPaymentFailed" = "付款失败。您的银行卡没有被扣款。"; +"Checkout.ErrorPrecheckoutFailed" = "此机器人无法处理您的付款。您的银行卡没有被扣款。"; +"Checkout.ErrorProviderAccountInvalid" = "此机器人目前无法接受支付请求,请稍后再试。"; +"Checkout.ErrorProviderAccountTimeout" = "这个机器人现在无法处理付款,请稍后再试。"; +"Checkout.Name" = "姓名"; +"Checkout.NewCard.CardholderNamePlaceholder" = "持卡人姓名"; +"Checkout.NewCard.CardholderNameTitle" = "持卡人"; +"Checkout.NewCard.PaymentCard" = "付款卡"; +"Checkout.NewCard.PostcodePlaceholder" = "邮政编码"; +"Checkout.NewCard.PostcodeTitle" = "账单地址"; +"Checkout.NewCard.SaveInfo" = "保存付款信息"; +"Checkout.NewCard.SaveInfoEnableHelp" = "您可以保存您的付款信息以便在未来使用。\n请先[开启两步验证]后再启用此功能。"; +"Checkout.NewCard.SaveInfoHelp" = "您可以保存付款信息以便在未来使用。将会直接由支付服务提供商保存。Telegram 无法获取您的信用卡数据。"; +"Checkout.NewCard.Title" = "添加银行卡"; +"Checkout.PasswordEntry.Pay" = "支付"; +"Checkout.PasswordEntry.Text" = "您曾保存过您的银行卡%@的信息。要使用此卡支付,请输入您的两步验证密码。"; +"Checkout.PasswordEntry.Title" = "支付确认"; +"Checkout.PayNone" = "支付"; +"Checkout.PayPrice" = "支付 %@"; +"Checkout.PaymentMethod" = "付款方式"; +"Checkout.PaymentMethod.New" = "添加银行卡…"; +"Checkout.PaymentMethod.Title" = "付款方式"; +"Checkout.Phone" = "电话"; +"Checkout.PriceHeader" = "价格"; +"Checkout.Receipt.Title" = "收据"; +"Checkout.SavePasswordTimeout" = "您是否要在 %@ 内保存密码?"; +"Checkout.ShippingAddress" = "配送信息"; +"Checkout.ShippingMethod" = "配送方式"; +"Checkout.ShippingOption.Title" = "配送方式"; +"Checkout.Success" = "您的付款已成功进行!"; +"Checkout.Title" = "结算"; +"Checkout.TotalAmount" = "总计"; +"Checkout.TotalPaidAmount" = "总计支付"; +"Checkout.WebConfirmation.Title" = "付款完成"; +"CheckoutInfo.ErrorCityInvalid" = "请输入有效的城市。"; +"CheckoutInfo.ErrorEmailInvalid" = "请输入有效的电子邮箱地址。"; +"CheckoutInfo.ErrorNameInvalid" = "请输入有效的姓名。"; +"CheckoutInfo.ErrorPhoneInvalid" = "请输入有效的手机号码。"; +"CheckoutInfo.ErrorPostcodeInvalid" = "请输入有效的邮政编码。"; +"CheckoutInfo.ErrorShippingNotAvailable" = "无法配送到您所选择的国家。"; +"CheckoutInfo.ErrorStateInvalid" = "请输入有效的省份/州。"; +"CheckoutInfo.Pay" = "支付"; +"CheckoutInfo.ReceiverInfoEmail" = "电子邮箱"; +"CheckoutInfo.ReceiverInfoEmailPlaceholder" = "电子邮箱"; +"CheckoutInfo.ReceiverInfoName" = "姓名"; +"CheckoutInfo.ReceiverInfoNamePlaceholder" = "姓名"; +"CheckoutInfo.ReceiverInfoPhone" = "电话"; +"CheckoutInfo.ReceiverInfoTitle" = "收件人"; +"CheckoutInfo.SaveInfo" = "保存信息"; +"CheckoutInfo.SaveInfoHelp" = "您可以保存您的配送信息以便在未来使用。"; +"CheckoutInfo.ShippingInfoAddress1" = "地址 1"; +"CheckoutInfo.ShippingInfoAddress1Placeholder" = "地址"; +"CheckoutInfo.ShippingInfoAddress2" = "地址 2"; +"CheckoutInfo.ShippingInfoAddress2Placeholder" = "地址"; +"CheckoutInfo.ShippingInfoCity" = "城市"; +"CheckoutInfo.ShippingInfoCityPlaceholder" = "城市"; +"CheckoutInfo.ShippingInfoCountry" = "国家及地区"; +"CheckoutInfo.ShippingInfoCountryPlaceholder" = "国家"; +"CheckoutInfo.ShippingInfoPostcode" = "邮政编码"; +"CheckoutInfo.ShippingInfoPostcodePlaceholder" = "邮政编码"; +"CheckoutInfo.ShippingInfoState" = "省份/州"; +"CheckoutInfo.ShippingInfoStatePlaceholder" = "省份/州"; +"CheckoutInfo.ShippingInfoTitle" = "配送地址"; +"CheckoutInfo.Title" = "配送信息"; +"ChoosePeer.Requirements.Channel.AdminRights" = "• 您必须拥有管理员权限才能 %1$@"; +"ChoosePeer.Requirements.Channel.Create" = "为此创建新频道"; +"ChoosePeer.Requirements.Channel.Empty" = "您没有满足以下要求的频道:"; +"ChoosePeer.Requirements.Channel.NonOwner" = "• 您不能是频道的所有者。"; +"ChoosePeer.Requirements.Channel.Owner" = "你必须是频道的所有者。"; +"ChoosePeer.Requirements.Channel.Private" = "• 频道必须为私密状态。"; +"ChoosePeer.Requirements.Channel.Public" = "• 频道必须为公开状态。"; +"ChoosePeer.Requirements.Group.AdminRights" = "您必须拥有以下管理员权限:%1$@"; +"ChoosePeer.Requirements.Group.Create" = "为此创建一个新群组"; +"ChoosePeer.Requirements.Group.Empty" = "您没有满足以下要求的群组:"; +"ChoosePeer.Requirements.Group.Forum" = "群组必须开启话题功能。"; +"ChoosePeer.Requirements.Group.NonForum" = "群组必须关闭话题功能。"; +"ChoosePeer.Requirements.Group.NonOwner" = "• 你不可以是该群组的所有者。"; +"ChoosePeer.Requirements.Group.Owner" = "• 您必须是群组的所有者。"; +"ChoosePeer.Requirements.Group.Private" = "• 必须是私密群组。"; +"ChoosePeer.Requirements.Group.Public" = "该群组必须是公开的。"; +"ChoosePeer.Requirements.Title" = "要求:"; +"ChoosePeer.Requirements.User.NonPremium" = "• 该用户必须没有订阅高级版。"; +"ChoosePeer.Requirements.User.Premium" = "• 用户必须拥有 Telegram 高级版订阅。"; +"ChoosePeer.Title.Bot" = "选择机器人"; +"ChoosePeer.Title.Bot.Multiple" = "选择机器人"; +"ChoosePeer.Title.Channel" = "选择频道"; +"ChoosePeer.Title.Group" = "选择群组"; +"ChoosePeer.Title.User" = "选择用户"; +"ChoosePeer.Title.User.Multiple" = "选择用户"; +"CollectibleItemInfo.ButtonOpenInfo" = "了解更多"; +"CollectibleItemInfo.PhoneText" = "该电话号码 **%1$@** 于 %2$@ 在 **Fragment** 上花费 %3$@ **%4$@**(约 %5$@)购买。\n\n[复制链接]()"; +"CollectibleItemInfo.PhoneTitle" = "[%@]() 是一个典藏的电话号码,其属于"; +"CollectibleItemInfo.UsernameText" = "用户名 **@%1$@** 已于 %2$@ 在 Fragment 上花费 %3$@ **%4$@** (约 %5$@)购买。"; +"CollectibleItemInfo.UsernameTitle" = "[%@]() 是一个典藏用户名,属于"; +"Common.Restart" = "重启"; +"Compose.Confirm.StartSecretChat" = "要与 %@ 开始私密聊天吗?"; +"Compose.Create" = "创建"; +"Compose.CreateGroup.LimitError" = "您能够在创建群组并升级到超级群组后添加更多的用户。"; +"Compose.Next" = "下一步"; +"Compose.Popover.NewChannel" = "新建频道"; +"Compose.Popover.NewGroup" = "新建群组"; +"Compose.Popover.NewSecretChat" = "新建私密聊天"; +"Compose.Select.SecretChat" = "私密聊天"; +"Compose.SelectGroupUsers.Placeholder" = "您想添加谁?"; +"Compose.SelectUsers" = "选择用户"; +"Confirm.AddBotToGroup" = "是否将机器人添加到 “%@”?"; +"Confirm.Delete" = "删除"; +"Confirm.DeleteAdminedChannel" = "删除此频道将会移除所有关注者并且会丢失所有消息。\n\n确定要删除此频道?"; +"Confirm.DeleteChatUser" = "您确定要删除所有消息记录吗?\n\n此操作无法撤消。"; +"Confirm.Desc.PermissionInlineBotContact" = "机器人将会知道您的电话号码。这对于整合其他的服务可能有帮助。\n\n⚠️警告!切勿在小程序中输入您的 Telegram 登录验证码。"; +"Confirm.Header.PermissionInlineBotContact" = "是否分享您的手机号码?"; +"Confirm.LeaveGroup" = "您确定要退出此群组吗?"; +"ConnectingStatus.DisableProxy" = "点按这里停用代理"; +"ConnectingStatus.connecting" = "连接中"; +"ConnectingStatus.connectingToProxy" = "正在连接代理"; +"ConnectingStatus.online" = "在线"; +"ConnectingStatus.updating" = "获取中..."; +"ConnectingStatus.waitingNetwork" = "等待网络连接"; +"ConnectionStatus.Connected" = "已连接"; +"ConnectionStatus.Connecting" = "正在连接……"; +"ConnectionStatus.ConnectingToProxy" = "正在连接代理"; +"ConnectionStatus.Updated" = "已更新"; +"ConnectionStatus.Updating" = "正在更新..."; +"ConnectionStatus.WaitingForNetwork" = "等待网络…"; +"ContactInfo.BirthdayLabel" = "生日"; +"ContactInfo.ContactInfo" = "联系人信息"; +"ContactInfo.Job" = "工作"; +"ContactInfo.PhoneLabelHome" = "家庭"; +"ContactInfo.PhoneLabelHomeFax" = "家庭传真"; +"ContactInfo.PhoneLabelMain" = "主要"; +"ContactInfo.PhoneLabelMobile" = "手机"; +"ContactInfo.PhoneLabelOther" = "其它"; +"ContactInfo.PhoneLabelPager" = "寻呼机"; +"ContactInfo.PhoneLabelWork" = "工作"; +"ContactInfo.PhoneLabelWorkFax" = "工作传真"; +"ContactInfo.Title" = "标题"; +"ContactInfo.URLLabelHomepage" = "主页"; +"Contacts.AddContact" = "添加联系人"; +"Contacts.ContacsSeparator" = "联系人"; +"Contacts.FirstName.Placeholder" = "名字"; +"Contacts.LastName.Placeholder" = "姓氏"; +"Contacts.NotRegistredDescription" = "该联系人尚未注册 Telegram 。\n\n当其完成注册时,您即可向其发送消息。"; +"Contacts.NotRegistredTitle" = "不是 Telegram 用户"; +"Contacts.PhoneNumber.Invalid" = "电话号码不能为空"; +"Contacts.PhoneNumber.NotRegistred" = "此电话号码尚未在Telegram上注册。"; +"Contacts.PhoneNumber.Placeholder" = "电话号码"; +"Contacts.Sort.ByLastSeen" = "最后上线时间"; +"Contacts.Sort.ByName" = "按姓名"; +"Contacts.Sort.Title" = "排序"; +"Context.Alert.Copied" = "已拷贝到剪贴板"; +"Context.Alert.CopyPrivate" = "此链接只对该聊天的成员有效"; +"Context.Copy" = "拷贝"; +"Context.CopyMedia" = "另存为..."; +"Context.CopyPaidMedia.Restricted" = "抱歉,您不能拷贝付费媒体"; +"Context.CopyToClipboard" = "复制到剪贴板"; +"Context.Menu.Search" = "搜索"; +"Context.OpenInQuickLook" = "通过快速查看打开"; +"Context.RecentGifRemove" = "移除"; +"Context.Remove" = "删除"; +"Context.RemoveFaveSticker" = "移除"; +"Context.SaveMedia" = "另存为……"; +"Context.ShowInFinder" = "在 Finder 中查看"; +"Context.ViewEmojiSet" = "查看表情包"; +"Context.ViewStickerSet" = "预览贴纸包"; +"Conversation.FreeTranscriptionCooldownTooltip#other" = "您本周的 **%d** 次免费语音转录额度已用尽。"; +"Conversation.FreeTranscriptionCooldownTooltip_countable" = "%d"; +"Conversation.FreeTranscriptionLimitTooltip#other" = "您本周还剩余 **%d** 次免费的语音转录。"; +"Conversation.FreeTranscriptionLimitTooltip_countable" = "%d"; +"Conversation.FreeTranscriptionWaitOrSubscribe" = "请等待至 **%@** 或者现在订阅 **Telegram 高级版**。"; +"Conversation.PinnedGiveaway" = "抽奖活动"; +"Conversation.PinnedGiveaway.Finished" = "%1$@ 于 %2$@"; +"Conversation.PinnedGiveaway.Finished.Winners#other" = "已选定 %@ 名获奖者"; +"Conversation.PinnedGiveaway.Finished.Winners_countable" = "%d"; +"Conversation.PinnedGiveaway.Ongoing" = "%2$@ %1$@"; +"Conversation.PinnedGiveaway.Ongoing.Winners#other" = "将选定 %d 名获奖者"; +"Conversation.PinnedGiveaway.Ongoing.Winners_countable" = "%d"; +"Conversation.ShareBotContactConfirmation" = "%@ 将会获取您的手机号码。这可能对机器人与其它服务的集成有用。"; +"Conversation.ShareBotContactConfirmation.Unblock" = "%@ 将会知道您的手机号码,这有助于整合其他服务。\n\n同时也将解除对该机器人的屏蔽。"; +"Conversation.ShareBotContactConfirmationOK" = "分享"; +"Conversation.ShareBotContactConfirmationTitle" = "是否分享您的电话号码?"; +"ConvertToSuperGroup.Confirm" = "您确定要这么做吗?此操作无法撤消!"; +"ConvertToSupergroup.Alert.Error" = "抱歉,出现了一些问题,请重试。"; +"CopyRestricted.Channel" = "抱歉,管理员已禁止拷贝本频道中的内容。"; +"CopyRestricted.Chat" = "抱歉,此聊天中禁止复制。"; +"CopyRestricted.Group" = "抱歉,管理员已禁止从本群拷贝任何内容。"; +"CrashOnLaunch.Cancel" = "取消"; +"CrashOnLaunch.Information" = "如果 Telegram 依然在您打开它时崩溃,点击 OK 按钮来退出帐号。这应该能解决问题。"; +"CrashOnLaunch.Message" = "发生了些问题。"; +"CrashOnLaunch.OK" = "登出"; +"Create.ChannelsTooMuch" = "抱歉,您加入了太多的群组或频道。请在创建新的之前退出一些。"; +"CreateBot.BotNameHeader" = "机器人名称"; +"CreateBot.BotNamePlaceholder" = "机器人名称"; +"CreateBot.BotUsernameHeader" = "机器人用户名"; +"CreateBot.BotUsernamePlaceholder" = "@username"; +"CreateBot.Checking" = "检查中..."; +"CreateBot.Create" = "创建"; +"CreateBot.Description" = "**%1$@** 想要代表您创建并管理一个聊天机器人。"; +"CreateBot.LimitReached" = "您已达到创建 %1$d 个机器人的上限。订阅 Telegram 高级版即可创建最多 %2$d 个机器人。"; +"CreateBot.LimitReachedPremium" = "您已达到 %1$d 个机器人的上限。"; +"CreateBot.SuccessInfo" = "%1$@ 将为您管理此机器人。"; +"CreateBot.SuccessTitle" = "%1$@ 已创建!"; +"CreateBot.Title" = "创建机器人"; +"CreateBot.UsernameEndBot" = "抱歉,用户名称必须以「bot」结尾。"; +"CreateBot.UsernameHint" = "为您的機机器人选择一个用户名称。用户名称必须以「bot」结尾。例如:TetrisBot 或 tetris_bot。"; +"CreateChannel.Forum.Error" = "话题功能必须启用。"; +"CreateChannel.TooManyError.Title" = "抱歉,您保留了太多公共用户名。您可以从较旧的群组或频道中移除链接。"; +"CreateChannel.Username.Error" = "该频道必须是公开的,请选择一个用户名。"; +"CreateGroup.AddMember.Error" = "您无法创建没有成员的群组。"; +"CreateGroup.NameHolder" = "群组名称"; +"CreateGroup.Requires.Username.Header" = "设置公开链接"; +"CreateGroup.Requires.Username.Info" = "您可以使用 a-z、0-9 和下划线。最小长度为 5 个字符。"; +"CreateGroup.Requires.Username.Placeholder" = "链接"; +"CreateGroup.Username.Error" = "该群组必须是公开的,请选择一个用户名。"; +"CustomAvatar.EnterSymbol" = "输入符号"; +"CustomAvatar.EnterSymbol.Info" = "最大长度为 2 个符号"; +"CustomAvatar.Title.Background" = "背景"; +"CustomAvatar.Title.Emoji" = "表情"; +"CustomAvatar.Title.Monogram" = "花押字"; +"CustomAvatar.Title.Sticker" = "贴纸"; +"CustomAvatar.Title.Text" = "文本"; +"CustomEmoji.Info" = "欢迎各位画师使用 [@stickers]() 机器人添加自己的表情包。"; +"CustomEmoji.PremiumAlert" = "订阅 Telegram 高级版以解锁此表情符号。[了解更多]()"; +"CustomEmoji.Suggest" = "推荐动态表情"; +"CustomReaction.PremiumAlert" = "订阅 Telegram 高级版 以解锁此表情回应。[了解更多]()"; +"CustomStatus.Expires" = "你的状态将在 %@ 分钟后过期"; +"CustomStatus.Expires.Promo" = "右键单击以设置计时器"; +"CustomStatus.Menu.Timer" = "设置为 %@ 小时"; +"CustomStatus.ReportSpam" = "此帐号在其名称旁边使用了自定义状态 🤡。所有订阅[Telegram 高级版]()的用户都可以使用这样的表情符号状态。"; +"Customize.Name.Title" = "名称"; +"Customize.Profile.Title" = "个人资料"; +"DarkMode.Confirm.NightMode.Header" = "夜间模式"; +"DarkMode.Confirm.NightMode.OK" = "停用"; +"DarkMode.Confirm.NightMode.Text" = "您已启用跟随系统自动切换主题,你必须停用后才能切换为深色模式。"; +"DataAndStorage.AutomaticAudioDownload.Header" = "自动下载语音"; +"DataAndStorage.AutomaticDownload" = "自动下载媒体文件"; +"DataAndStorage.AutomaticDownload.Files" = "文件"; +"DataAndStorage.AutomaticDownload.GIFs" = "GIF 动态图"; +"DataAndStorage.AutomaticDownload.GroupsChannels" = "群组和频道"; +"DataAndStorage.AutomaticDownload.Header" = "自动下载媒体文件"; +"DataAndStorage.AutomaticDownload.InstantVideo" = "视频消息"; +"DataAndStorage.AutomaticDownload.Photo" = "照片"; +"DataAndStorage.AutomaticDownload.Reset" = "重置自动下载设置"; +"DataAndStorage.AutomaticDownload.Video" = "视频"; +"DataAndStorage.AutomaticDownload.Voice" = "语音消息"; +"DataAndStorage.AutomaticPhotoDownload.Header" = "自动下载图片"; +"DataAndStorage.AutomaticVideoDownload.Header" = "自动下载视频"; +"DataAndStorage.Autoplay.GIFs" = "GIF"; +"DataAndStorage.Autoplay.Header" = "自动播放媒体"; +"DataAndStorage.Autoplay.SoundOnHover" = "声音悬停"; +"DataAndStorage.Autoplay.SoundOnHover.Desc" = "将鼠标移到视频上时,声音将开始播放。"; +"DataAndStorage.Autoplay.Videos" = "视频"; +"DataAndStorage.Category.PreloadLargeVideos" = "预先载入较大的视频"; +"DataAndStorage.Category.PreloadLargeVideosDesc" = "预先加载大于 %@ MB 前几秒(1-2 MB)的视频以便即时播放。"; +"DataAndStorage.CategorySettings.Channels" = "频道"; +"DataAndStorage.CategorySettings.GroupChats" = "群聊"; +"DataAndStorage.CategorySettings.PrivateChats" = "私聊"; +"DataAndStorage.CateroryFileSize.Unlimited" = "无限量"; +"DataAndStorage.CateroryFileSizeLimit.Header" = "大小限制"; +"DataAndStorage.Confirm.ResetSettings" = "撤消全部自定义自动下载设置"; +"DataAndStorage.DownloadFolder" = "下载文件夹"; +"DataAndStorage.NetworkUsage" = "流量使用情况"; +"DataAndStorage.SensitiveContent" = "显示 18+ 内容"; +"DataAndStorage.SensitiveContent.Confirm.Header" = "18+ 内容"; +"DataAndStorage.SensitiveContent.Confirm.Ok" = "确认"; +"DataAndStorage.SensitiveContent.Confirm.Text" = "确认您已年满 18 岁并更新您的设置以查看潜在露骨或敏感的的內容。"; +"DataAndStorage.SensitiveContent.Info" = "不隐藏包含仅适合成人观看的媒体。"; +"DataAndStorage.StorageUsage" = "存储用量"; +"DataAndStorage.VerifyAgain.Error" = "您能于 %@ 再次验证"; +"Date.Today" = "今天"; +"DateSelector.Schedule.Repeat" = "重复"; +"DateSelector.Schedule.Repeat.EveryDay" = "每天"; +"DateSelector.Schedule.Repeat.EveryMonth" = "每月"; +"DateSelector.Schedule.Repeat.EverySixMonths" = "每六个月"; +"DateSelector.Schedule.Repeat.EveryThreeMonths" = "每三个月"; +"DateSelector.Schedule.Repeat.EveryTwoWeeks" = "每两周"; +"DateSelector.Schedule.Repeat.EveryWeek" = "每周"; +"DateSelector.Schedule.Repeat.EveryYear" = "每年"; +"DateSelector.Schedule.Repeat.Never" = "从不"; +"DateSelector.Schedule.Repeat.PremiumInfo" = "订阅 Telegram 高级版以设置定时消息。[了解更多]()"; +"DeclineSuggestPost.Action.Reject" = "拒绝"; +"DeclineSuggestPost.Description" = "您可以留下可选评论"; +"DeclineSuggestPost.Placeholder.Comment" = "评价 (选填)"; +"DeclineSuggestPost.Title" = "拒绝建议贴文"; +"DefaultSoundName" = "默认"; +"DeleteChat.DeleteGroupForAll" = "为所有成员删除"; +"DeleteGroupMessages.DeleteAllFromPeer" = "删除来自 %1$@ 的所有消息"; +"DeleteGroupMessages.DeleteAllReactions" = "删除所有表情回应"; +"DeleteGroupMessages.DeleteReaction" = "删除表情回应"; +"DeleteGroupMessages.ReactionTitle" = "删除 1 个表情回应"; +"Deprecated.Alert.Text" = "我们不再为您的系统版本更新应用程序。请更新您的系统以接收最新的 Telegram 更新。"; +"Deprecated.Alert.Third" = "隐藏一个月"; +"Deprecated.Alert.Title" = "更新您的系统"; +"Deprecated.Text" = "您的 macOS 系统版本不再受支持,请更新您的 Mac。"; +"Deprecated.Title" = "更新 macOS"; +"DiceRoll.EmojiStake" = "表情符号投注"; +"DiceRoll.EmojiStakeDescription" = "一款专为 **等级 5** 或以上用户推出的表情符号游戏,在游戏中获得的 💎 仅能用于购买 Telegram 礼物及发布贴文。"; +"DiceRoll.EnterStakePlaceholder" = "输入您的投注额"; +"DiceRoll.MaxStake" = "最高投注额为 %1$@ TON"; +"DiceRoll.MinStake" = "最低投注额为 %1$@ TON"; +"DiceRoll.ResultsAndReturns" = "结果与收益"; +"DiceRoll.SaveAndRoll" = "\u00a0保存并投掷"; +"DiceRoll.StreakResetInfo" = "连续记录将在 3 %1$@ 后或更改投注时重置。"; +"DiceRoll.YourStake" = "您的投注:💎 %1$@ [更改]()"; +"Discussion.Set.Modal.OK" = "关联群组"; +"Discussion.Set.Modal.Text.ChannelPrivateGroup" = "你想将 **%@** 作为 **%@** 的讨论群吗?\n\n群组成员可以查看频道消息。"; +"Discussion.Set.Modal.Text.Private" = "你想将 **%@** 作为 **%@** 的讨论群吗?\n\n群组成员可以查看频道消息。"; +"Discussion.Set.Modal.Text.PrivateChannelPublicGroup" = "你想将 **%@** 作为 **%@** 的讨论群吗?\n\n群组成员可以查看频道消息。"; +"Discussion.Set.Modal.Text.Public" = "您想将 **%@** 作为 **%@** 的讨论群吗?"; +"Discussion.Set.Modal.Text.PublicChannelPublicGroup" = "您想将 **%@** 作为 **%@** 的讨论群吗?"; +"DiscussionController.Channel.Empty.CreateGroup" = "创建新群组"; +"DiscussionController.Channel.Empty.Description" = "您发布在频道中的所有消息将被转发到该群组。"; +"DiscussionController.Channel.Empty.Header" = "选择要在您的频道上显示的一个讨论群组"; +"DiscussionController.Channel.Empty.Header1" = "为您的频道选择一个评论群组。"; +"DiscussionController.Channel.Set.Description" = "您发布在频道中的所有消息将被转发到该群组。"; +"DiscussionController.Channel.Set.Header" = "频道底部将向所有订阅者显示 **%@** 的链接。"; +"DiscussionController.Channel.Set.Header1" = "已选择 **%@** 为频道的留言群组。"; +"DiscussionController.Channel.Set.UnlinkGroup" = "取消关联群组"; +"DiscussionController.Channel.Title" = "讨论群组"; +"DiscussionController.Confrim.UnlinkChannel" = "您确定要取消该群组的关联频道吗?"; +"DiscussionController.Confrim.UnlinkGroup" = "您确定要取消该频道的关联群组吗?"; +"DiscussionController.Error.OK" = "确认"; +"DiscussionController.Error.PreHistory" = "警告:如果您将私密群组设为您的频道的讨论群,所有的频道订阅者都可以进入到这个群组中。“新成员加入前的聊天记录”将切换为“可见”。"; +"DiscussionController.Group.Set.Description" = "所有发布于此频道中的消息将被转发到该群组。"; +"DiscussionController.Group.Set.Header" = "此群组已和 %@ 关联,成为了它的讨论群组。"; +"DiscussionController.Group.Set.UnlinkChannel" = "取消关联频道"; +"DiscussionController.Group.Title" = "关联的频道"; +"DiscussionController.Group.Unset.Description" = "频道取消关联成功。"; +"DiscussionController.IconText" = "讨论"; +"DiscussionController.PrivateChannel" = "私密频道"; +"DiscussionController.PrivateGroup" = "私密群组"; +"DisplayMeAs.Alone" = "您将显示为您的个人帐户。"; +"DisplayMeAs.Alone.Desc" = "您还可以创建一个公共频道,作为频道参与视频聊天。"; +"DisplayMeAs.ContinueAs" = "以 %@ 的身份继续"; +"DisplayMeAs.Modern.Schedule" = "预约视频聊天"; +"DisplayMeAs.Modern.Start" = "开始视频聊天"; +"DisplayMeAs.New.ScheduleAs" = "将视频聊天安排为 %@"; +"DisplayMeAs.New.StartAs" = "以 %@ 身份开始视频聊天"; +"DisplayMeAs.New.Title" = "新建视频通话"; +"DisplayMeAs.PersonalAccount" = "个人账号"; +"DisplayMeAs.Scheduled" = "已预约视频聊天"; +"DisplayMeAs.Scheduled.Desc" = "频道订阅者将收到通知,直播将在 %@ 后开始。"; +"DisplayMeAs.StartWith" = "以其它方式直播..."; +"DisplayMeAs.Text" = "选择您希望显示为个人帐号还是频道。"; +"DisplayMeAs.Text.Group" = "选择是以个人账户还是以团体账户显示。"; +"DisplayMeAs.Title" = "将我显示为..."; +"DownloadsManager.Control.Navigate" = "%@ - [在聊天中查看]()"; +"DownloadsManager.Control.Title#other" = "%d 个文件"; +"DownloadsManager.Control.Title_countable" = "%d"; +"DownloadsManager.Recently" = "最近下载"; +"DownloadsManager.Recently.Clear" = "清空"; +"Dv1-io-Yv7.title" = "拼写和语法"; +"Edit.Message.EditCurrentPhoto" = "编辑"; +"Edit.PostSuggestion.Action.Free" = "免费提供"; +"Edit.PostSuggestion.Action.Offer" = "报价 %1$@"; +"Edit.PostSuggestion.Action.Update" = "更新条款"; +"Edit.PostSuggestion.Currency.Stars" = "星星"; +"Edit.PostSuggestion.Currency.Ton" = "TON"; +"Edit.PostSuggestion.Date.Done" = "确定"; +"Edit.PostSuggestion.Date.Reset" = "重置为任何时间"; +"Edit.PostSuggestion.Date.Title" = "选择日期"; +"Edit.PostSuggestion.Input.Placeholder" = "输入金额"; +"Edit.PostSuggestion.Price.Description" = "选择您想要报价 %1$@ 需要支付多少颗星星来发布该帖子。"; +"Edit.PostSuggestion.Price.Label" = "输入星星价格"; +"Edit.PostSuggestion.Price.Ton.Description" = "选择要支付多少来建议 %1$@ 发布此贴文。"; +"Edit.PostSuggestion.Price.Ton.Label" = "输入 TON 价格"; +"Edit.PostSuggestion.Time.Anytime" = "随时"; +"Edit.PostSuggestion.Time.Description" = "选择您想要发布贴文的日期和具体时间。"; +"Edit.PostSuggestion.Time.Label" = "时间"; +"Edit.PostSuggestion.Title.Edit" = "编辑条款"; +"Edit.PostSuggestion.Title.New" = "推荐条款"; +"Edit.PostSuggestion.Title.Suggest" = "建议更改"; +"EditAccount.AddAccount" = "添加账号"; +"EditAccount.Birthday.Add" = "添加"; +"EditAccount.Birthday.Date" = "生日"; +"EditAccount.Birthday.Info.Everyone" = "所有人都会看到您的生日。 [更改>]()"; +"EditAccount.Birthday.Info.Nobody" = "没有人可以看到你的生日。 [更改 >]()"; +"EditAccount.Birthday.Info.OnlyContacts" = "只有您的联系人可以看到您的生日。 [更改 >]()"; +"EditAccount.Birthday.Remove" = "移除"; +"EditAccount.ChangeNumber" = "更改号码"; +"EditAccount.Logout" = "登出"; +"EditAccount.NameDesc" = "输入您的名字并添加头像"; +"EditAccount.PersonalChannel" = "个人频道"; +"EditAccount.PersonalChannel.Add" = "添加"; +"EditAccount.Title" = "编辑个人资料"; +"EditAccount.Username" = "用户名"; +"EditAccountInfo.ChatAutomation" = "聊天自动化"; +"EditAvatar.Customize" = "贴纸或表情符号"; +"EditAvatar.PhotoOrVideo" = "图片或视频"; +"EditAvatar.StickerOrGif" = "贴图或 GIF"; +"EditImageControl.Confirm.Discard" = "您确定要关闭并放弃所有变更吗?"; +"EditImageControl.Reset" = "重置"; +"EditInvitation.EditTitle" = "编辑链接"; +"EditInvitation.EnterNumber" = "输入数量"; +"EditInvitation.ExpiryDate" = "有效日期"; +"EditInvitation.ExpiryDesc" = "你可以设置该链接在一定时间后过期。"; +"EditInvitation.LimitDesc" = "您可以设定链接在使用一定次数后自动失效。"; +"EditInvitation.LimitedByCount" = "人数限制"; +"EditInvitation.LimitedByPeriod" = "使用时间限制"; +"EditInvitation.Never" = "永不"; +"EditInvitation.NewTitle" = "新建链接"; +"EditInvitation.NumberOfUsers" = "用户数量"; +"EditInvitation.OK.Create" = "创建"; +"EditInvitation.OK.Save" = "保存"; +"EditInvitation.RequestApproval" = "请求管理员批准"; +"EditInvitation.RequestApproval.Channel.Off" = "如果您希望用户仅在获得管理员批准后加入,请启用此选项"; +"EditInvitation.RequestApproval.Channel.On" = "如果你希望用户仅在获得管理员批准后加入,请启用此选项。"; +"EditInvitation.RequestApproval.Group.Off" = "如果你希望用户仅在获得管理员批准后加入,请启用此选项。"; +"EditInvitation.RequestApproval.Group.On" = "启用此选项后,新成员加入前需得到管理员的批准。"; +"EditInvitation.Save" = "保存"; +"EditInvitation.TitleDesc" = "仅管理员有权查看该名字。"; +"EditInvitation.TitlePlaceholder" = "链接名称(可选)"; +"EditInvitation.Unlimited" = "不限量"; +"EditThame.Name.AlreadyTaken" = "抱歉,此链接已被占用。"; +"EditTheme.Edit" = "保存"; +"EditTheme.NamePlaceholder" = "主题名称"; +"EditTheme.SelectFile" = "从文件创建……"; +"EditTheme.SelectFileDesc" = "主题将基于您当前选择的颜色和壁纸。或者,您可以使用手动编辑的自定义主题文件。"; +"EditTheme.SelectUpdatedFile" = "通过文件上传……"; +"EditTheme.SelectUpdatedFileDesc" = "您可以通过从文件上传手动更改来为所有用户更新主题。"; +"EditTheme.SlugDesc" = "每当您修改主题时,将为所有用户自动更新。任何人都可以使用此链接安装它。\n\n主题链接必须不小于 **5** 个字符,可以使用 **a-z**、**0-9** 和下划线。"; +"EditTheme.SlugError.AlreadyExists" = "链接被占用了,试试别的吧。"; +"EditTheme.SlugError.Format" = "抱歉,格式无效"; +"EditTheme.SlugPlaceholder" = "短链接"; +"EditTheme.Title" = "编辑主题"; +"Email.CodeExpiredError" = "验证码已过期,请重新开始。"; +"Email.CodeFloodError" = "尝试次数过多,请稍后再试。"; +"Email.EmailNotAllowedError" = "抱歉,无法使用此邮箱。"; +"Email.Enter.Placeholder" = "输入你的邮箱"; +"Email.InvalidCodeError" = "验证码无效,请重试。"; +"Email.InvalidEmailAddressError" = "发生错误,请重试。"; +"Email.InvalidEmailError" = "请输入有效的电子邮箱地址。"; +"Email.InvalidEmailTokenError" = "发生错误,请重试。"; +"Email.NetworkError" = "请检查您的互联网连接,然后再重试。"; +"Email.UnknownError" = "发生错误,请重试。"; +"Emoji.ActivityAndSport" = "活动 & 运动"; +"Emoji.AnimalsAndNature" = "动物 & 自然"; +"Emoji.Collectibles" = "典藏品"; +"Emoji.Context.Remove" = "移除"; +"Emoji.Context.SetStatus" = "设为当前账号状态"; +"Emoji.Context.SetStatus.Success" = "您的表情符号状态已更新。"; +"Emoji.Flags" = "旗帜"; +"Emoji.FoodAndDrink" = "食物 & 饮料"; +"Emoji.Objects" = "物品"; +"Emoji.Recent" = "常用"; +"Emoji.RecentNew" = "常用的表情"; +"Emoji.Search.AnimatedEmoji" = "动态表情"; +"Emoji.Search.Emoji" = "表情"; +"Emoji.SmilesAndPeople" = "笑脸与人像"; +"Emoji.Symbols" = "符号"; +"Emoji.TravelAndPlaces" = "旅行 & 地点"; +"EmojiPack.Add#other" = "添加 %d 个表情"; +"EmojiPack.Add_countable" = "%d 个"; +"EmojiPack.Added" = "已添加"; +"EmojiPack.Added.Info" = "%@ 已添加至您的表情中。"; +"EmojiPack.Added.Title" = "表情已添加"; +"EmojiPack.Counts#other" = "%d 个表情"; +"EmojiPack.Counts_countable" = "%d"; +"EmojiPack.MoreEmoji" = "将鼠标悬停在此处以选择更多表情符号"; +"EmojiPack.PremiumAlert" = "订阅 Telegram 高级版解锁该表情。[阅读更多]()"; +"EmojiPack.Removed.Info" = "%@ 不再出现于您的表情包中。"; +"EmojiPack.Removed.Title" = "表情已移除"; +"EmojiPack.Restore" = "恢复"; +"EmojiPack.Sets.Add#other" = "填加 %d 个表情包"; +"EmojiPack.Sets.Add_countable" = "%d"; +"EmojiPack.Unlock" = "解锁"; +"EmojiSection.GroupEmoji" = "群组表情符号"; +"Emojies.MessageEffects.Title" = "消息特效"; +"EmptyChat.AppIcon" = "程序坞图标"; +"EmptyChat.AppIcon.Desc" = "在设置 > [外观](appearance)中取得更多图标和进行其他设置。"; +"EmptyChat.AppIcon.Shuffle" = "应用图标"; +"EmptyChat.Appearance" = "外观"; +"EmptyChat.Appearance.ChatMode" = "聊天模式"; +"EmptyChat.Appearance.Colorful" = "彩色风格"; +"EmptyChat.Appearance.Dark" = "深色"; +"EmptyChat.Appearance.Desc" = "您可以随时在“偏好设置 ”>“[外观](appearance)”中进行个性化设置。"; +"EmptyChat.Appearance.Light" = "日间"; +"EmptyChat.Appearance.Min" = "极简"; +"EmptyChat.Appearance.System" = "跟随系统"; +"EmptyChat.Navigation.Next" = "下一个技巧"; +"EmptyChat.Navigation.Prev" = "上一个技巧"; +"EmptyChat.Stickers" = "根据表情联想贴纸"; +"EmptyChat.Stickers.AllSets" = "所有表情包"; +"EmptyChat.Stickers.MySets" = "我的表情包"; +"EmptyChat.Stickers.None" = "不推荐"; +"EmptyChat.Stickers.Trending" = "热门表情"; +"EmptyChat.StorageUsage" = "存储使用"; +"EmptyChat.StorageUsage.CacheDesc" = "Telegram 使用您存储空间的 %@ 。"; +"EmptyChat.StorageUsage.CacheDescEmpty" = "Telegram 缓存为空。"; +"EmptyChat.StorageUsage.Clear" = "清空缓存"; +"EmptyChat.StorageUsage.Clearing" = "正在清理..."; +"EmptyChat.StorageUsage.Data" = "最大缓存大小"; +"EmptyChat.StorageUsage.Loading" = "计算中..."; +"EmptyChat.StorageUsage.Low" = "8 GB"; +"EmptyChat.StorageUsage.Medium" = "32 GB"; +"EmptyChat.StorageUsage.NoLimit" = "无限制"; +"EmptyChat.StorageUsage.Tooltip.App" = "Telegram\n%@"; +"EmptyChat.StorageUsage.Tooltip.System" = "系统\n%@"; +"EmptyGroupInfo.Line1" = "• 最多 %@ 位成员"; +"EmptyGroupInfo.Line2" = "• 永久聊天记录"; +"EmptyGroupInfo.Line3" = "• 公开链接,例如:t.me/title"; +"EmptyGroupInfo.Line4" = "• 具备不同权限的管理员"; +"EmptyGroupInfo.Subtitle" = "群组可以拥有:"; +"EmptyGroupInfo.Title" = "您已创建一个群组"; +"EmptyPeer.Description" = "选择一个会话开始聊天"; +"EncryptionKey.Description" = "这是您与 **%@** 的本次私密对话中,加密密钥的可视化图片和文本。\n\n如果它们看起来和 **%@** 设备上的完全一致,端到端加密则是安全的。"; +"Entertainment.Emoji" = "表情符号"; +"Entertainment.Emoji.New" = "表情"; +"Entertainment.GIF" = "GIF 动态图"; +"Entertainment.GIF.New" = "GIF"; +"Entertainment.Stickers" = "贴纸"; +"Entertainment.Stickers.New" = "贴纸"; +"Entertainment.Switch.Emoji" = "Emoji"; +"Entertainment.Switch.GifAndStickers" = "贴纸和 GIF 动态图"; +"Error.AnError" = "出错了,请稍后再试。"; +"Error.Username.AlreadyTaken" = "此用户名已被占用。"; +"Error.Username.Invalid" = "此用户名不符合规范。"; +"Error.Username.MinimumLength" = "用户名必须包含至少 5 个字符。"; +"Error.Username.NumberStart" = "用户名不能以数字开头。"; +"Error.Username.UnderscopeEnd" = "用户名不能以下划线结尾。"; +"Error.Username.UnderscopeStart" = "用户名不能以下划线开头。"; +"EventLog.Service.AddedMedia" = "%@ 已添加媒体:"; +"EventLog.Service.And" = "%1$@ 和 %2$@"; +"EventLog.Service.Banned" = "封禁了 %@ %@"; +"EventLog.Service.Banned1" = " %1$@ %2$@ 被封闭。"; +"EventLog.Service.ChangedDefaultsRights" = "默认权限已修改"; +"EventLog.Service.ChangedStickerSet" = "%@ 已更改群组贴纸包"; +"EventLog.Service.DeletedMessage" = "%@ 删除了消息:"; +"EventLog.Service.DeletedMessageMultiple.Hide#other" = "%1$@ 已从 %3$@ 已删除 %2$d 条消息 [隐藏全部](s)"; +"EventLog.Service.DeletedMessageMultiple.Hide_countable" = "%1$@ %2$d %3$@"; +"EventLog.Service.DeletedMessageMultiple.Show#other" = "%1$@ 已删除来自 %3$@ 的 %2$d 条消息 [显示全部](s)"; +"EventLog.Service.DeletedMessageMultiple.Show_countable" = "%1$@ %2$d %3$@"; +"EventLog.Service.Demote.AddMembers" = "添加成员"; +"EventLog.Service.Demote.ChangeInfo" = "修改信息"; +"EventLog.Service.Demote.EmbedLinks" = "显示链接预览"; +"EventLog.Service.Demote.PinMessages" = "置顶消息"; +"EventLog.Service.Demote.PostPolls" = "发起投票"; +"EventLog.Service.Demote.SendGifs" = "发送 GIF"; +"EventLog.Service.Demote.SendInline" = "发送机器人内容"; +"EventLog.Service.Demote.SendMedia" = "发送媒体"; +"EventLog.Service.Demote.SendMessages" = "发送消息"; +"EventLog.Service.Demote.SendStickers" = "发送贴纸"; +"EventLog.Service.Demoted" = "已永久限制 %@ %@ "; +"EventLog.Service.Demoted.Changed" = "更改了对 %@ %@ 的永久性限制"; +"EventLog.Service.Demoted.Changed.Until" = "更改了对 %@ %@ 的限制直到 %@:"; +"EventLog.Service.Demoted.Changed.Until1" = "变更对 %1$@ %2$@ 的限制,直到 %3$@"; +"EventLog.Service.Demoted.Changed1" = "无限期变更了对 %1$@ %2$@ 的限制"; +"EventLog.Service.Demoted.Until" = "限制了 %@ %@ 直到 %@\n"; +"EventLog.Service.Demoted.Until1" = "限制了 %1$@ %2$@ 直到 %3$@"; +"EventLog.Service.Demoted1" = "已永久限制 %1$@ %2$@"; +"EventLog.Service.EditedCaption" = "%@ 已编辑说明:"; +"EventLog.Service.EditedMedia" = "%@ 编辑了媒体文件:"; +"EventLog.Service.EditedMessage" = "%@ 编辑了消息:"; +"EventLog.Service.PostMessage" = "%@ 发送了这条消息:"; +"EventLog.Service.PreviousDesc" = "旧简介"; +"EventLog.Service.PreviousLink" = "旧链接"; +"EventLog.Service.PreviousTitle" = "旧名称"; +"EventLog.Service.Promote.AddNewAdmins" = "添加新管理员"; +"EventLog.Service.Promote.AddUsers" = "添加用户"; +"EventLog.Service.Promote.BanUsers" = "封禁用户"; +"EventLog.Service.Promote.ChangeInfo" = "修改信息"; +"EventLog.Service.Promote.DeleteMessages" = "删除消息"; +"EventLog.Service.Promote.EditMessages" = "编辑消息"; +"EventLog.Service.Promote.InviteViaLink" = "使用链接邀请用户"; +"EventLog.Service.Promote.ManageDirect" = "管理私信"; +"EventLog.Service.Promote.PinMessages" = "置顶消息"; +"EventLog.Service.Promote.PostMessages" = "发布消息"; +"EventLog.Service.Promote.RemainAnonymous" = "保持匿名"; +"EventLog.Service.Promoted" = "将 %@ %@ 设为管理员:"; +"EventLog.Service.Promoted.Changed" = "修改了 %@ %@ 的权限:"; +"EventLog.Service.Promoted.Changed1" = "已修改 %1$@ %2$@ 的权限:"; +"EventLog.Service.Promoted1" = "已将 %1$@ %2$@ 设为:"; +"EventLog.Service.RemovePinned" = "%@ 已将一条消息取消置顶"; +"EventLog.Service.RemovedStickerSet" = "%@ 已删除群组贴纸包"; +"EventLog.Service.ShowMore#other" = "显示其他 %d 条消息"; +"EventLog.Service.ShowMore_countable" = "%d"; +"EventLog.Service.UpdatePinned" = "%@ 置顶了消息:"; +"ExampleApps.Title" = "例如"; +"ExportedInvitation.Context.Copy" = "拷贝"; +"ExportedInvitation.Context.OpenProfile" = "打开个人资料"; +"ExportedInvitation.Done" = "完成"; +"ExportedInvitation.JoinedViaFolder" = "已通过文件夹邀请链接加入"; +"ExportedInvitation.LinkCreatedBy" = "链接由以下人员创建"; +"ExportedInvitation.PeopleJoined#other" = "已邀请 %d 人"; +"ExportedInvitation.PeopleJoined_countable" = "%d"; +"ExportedInvitation.PeopleRequested#other" = "%d份加入请求"; +"ExportedInvitation.PeopleRequested_countable" = "%d"; +"ExportedInvitation.Status.Expired" = "已失效"; +"ExportedInvitation.Status.ExpiresIn" = "%@ 过期"; +"ExportedInvitation.Status.Revoked" = "已撤销"; +"ExportedInvitation.Title" = "邀请链接"; +"FactCheck.Context.Edit" = "编辑事实查核"; +"FactCheck.Info" = "此澄清是由 Telegram 提供的。"; +"FactCheck.Info.Second" = "此澄清是由 Telegram 提供的。"; +"FactCheck.Link.OnlyAllowed" = "仅允许 %@ 链接"; +"FactCheck.Placeholder" = "添加事实核查"; +"FactCheck.Save.Delete" = "已移除事实核查"; +"FactCheck.Save.Updated" = "已更新事实核查。"; +"FactCheck.ShowFull" = "显示"; +"FactCheck.Title" = "事实核查"; +"FactCheck.WhatThis" = "这是什么?"; +"FastSettings.DisableDarkMode" = "关闭深色模式"; +"FastSettings.EnableDarkMode" = "启用深色模式"; +"FastSettings.LockTelegram" = "锁定 Telegram"; +"FastSettings.Mute2Hours" = "免打扰 2 小时"; +"FastSettings.SetPasscode" = "设置锁定码"; +"FastSettings.Unmute" = "取消免打扰"; +"FileSize.B" = "%@ B"; +"FileSize.GB" = "%@ GB"; +"FileSize.KB" = "%@ KB"; +"FileSize.MB" = "%@ MB"; +"Format.TimeoutShort.day" = "天"; +"Format.TimeoutShort.hour" = "小时"; +"Format.TimeoutShort.minute" = "分钟"; +"FormattedDate.DaysAgo#other" = "%1$d 天前"; +"FormattedDate.HoursAgo#other" = "%1$d 小时前"; +"FormattedDate.InDays#other" = "%1$d 天后"; +"FormattedDate.InHours#other" = "%1$d 小时后"; +"FormattedDate.InMinutes#other" = "%1$d 分钟后"; +"FormattedDate.InSeconds#other" = "%1$d 秒后"; +"FormattedDate.LongDate.m1" = "一月 %1$@, %2$@"; +"FormattedDate.LongDate.m10" = "十月 %1$@, %2$@"; +"FormattedDate.LongDate.m11" = "十一月 %1$@, %2$@"; +"FormattedDate.LongDate.m12" = "十二月 %1$@, %2$@"; +"FormattedDate.LongDate.m2" = "二月 %1$@, %2$@"; +"FormattedDate.LongDate.m3" = "三月 %1$@, %2$@"; +"FormattedDate.LongDate.m4" = "四月 %1$@, %2$@"; +"FormattedDate.LongDate.m5" = "五月 %1$@,,%2$@"; +"FormattedDate.LongDate.m6" = "六月 %1$@, %2$@"; +"FormattedDate.LongDate.m7" = "七月 %1$@,%2$@"; +"FormattedDate.LongDate.m8" = "八月 %1$@, %2$@"; +"FormattedDate.LongDate.m9" = "九月 %1$@, %2$@"; +"FormattedDate.MinutesAgo#other" = "%1$d 分钟前"; +"FormattedDate.SecondsAgo#other" = "%1$d 秒前"; +"FormattedDate.ShortDate.Separator" = "/"; +"FormattedDate.ShortDate.m1" = "一月 %1$@,%2$@"; +"FormattedDate.ShortDate.m10" = "十月 %1$@,%2$@"; +"FormattedDate.ShortDate.m11" = "十一月 %1$@,%2$@"; +"FormattedDate.ShortDate.m12" = "十二月 %1$@,%2$@"; +"FormattedDate.ShortDate.m2" = "二月 %1$@,%2$@"; +"FormattedDate.ShortDate.m3" = "三月 %1$@,%2$@"; +"FormattedDate.ShortDate.m4" = "四月 %1$@,%2$@"; +"FormattedDate.ShortDate.m5" = "五月 %1$@,%2$@"; +"FormattedDate.ShortDate.m6" = "六月 %1$@,%2$@"; +"FormattedDate.ShortDate.m7" = "七月 %1$@,%2$@"; +"FormattedDate.ShortDate.m8" = "八月 %1$@, %2$@"; +"FormattedDate.ShortDate.m9" = "九月 %1$@,%2$@"; +"ForumSettings.DisplayAs" = "显示为"; +"ForumSettings.DisplayAs.Info" = "选择话题向所有成员显示的方式。"; +"ForumSettings.Enable" = "启用"; +"ForumSettings.Title" = "话题"; +"ForumSettings.TopicInfo" = "群组聊天将划分成由管理员或成员建立的话题。"; +"ForumTopic.Context.AddMember" = "添加成员"; +"ForumTopic.Context.Info" = "群组信息"; +"ForumTopic.Context.New" = "创建话题"; +"ForumTopic.Context.ShowAsMessages" = "以消息模式查看"; +"ForumTopic.Done.Edit" = "完成"; +"ForumTopic.Done.New" = "创建"; +"ForumTopic.Edit.General.Info" = "如果“常规”主题处于隐藏状态,则群组成员可以在主题列表中下拉以查看该主题。"; +"ForumTopic.Edit.General.Show" = "在话题中显示"; +"ForumTopic.Edit.TopicIcon" = "话题图标"; +"ForumTopic.Edit.TopicName" = "话题名称"; +"ForumTopic.Name.Placeholder" = "话题名称"; +"ForumTopic.New.TopicIcon" = "选择话题图标"; +"ForumTopic.New.TopicName" = "输入话题名称"; +"ForumTopic.Title.Create" = "新的话题"; +"ForumTopic.Title.Edit" = "编辑话题"; +"Forward.ToSavedMessages" = "转发消息到此处以便快速访问"; +"ForwardModalAction.Title#other" = "转发过的消息"; +"ForwardModalAction.Title_countable" = "%d"; +"ForwardModalAction.description#other" = "选择您要将 %@ 的信息转发给 ..."; +"ForwardModalAction.description_countable" = "%d %@"; +"Fragment.AdsInfo.Block.Text" = "任何人都可以用极低的预算在这个频道投放广告。详情请查看 Telegram 广告平台。[了解更多 >](https://telegram.org)"; +"Fragment.AdsInfo.Block.Title" = "我可以投放广告吗?"; +"Fragment.AdsInfo.OK" = "我已了解"; +"Fragment.AdsInfo.Option1.Info" = "Telegram 上的广告不会使用您的个人信息,而是基于您所在的频道来展示的。"; +"Fragment.AdsInfo.Option1.Title" = "尊重您的隐私"; +"Fragment.AdsInfo.Option2.Info" = "Telegram 广告收入的 50%% 将归广告频道的所有者所有。"; +"Fragment.AdsInfo.Option2.Title" = "帮助频道创作者"; +"Fragment.AdsInfo.Option3.Info" = "您可以通过订阅 Telegram 高级版关闭广告,50级的频道可以为其订阅者移除这些广告。"; +"Fragment.AdsInfo.Option3.Title" = "可以移除"; +"Fragment.AdsInfo.Text" = "Telegram 广告与其他平台上的广告大有不同。这类广告:"; +"Fragment.AdsInfo.Title" = "关于这些广告"; +"Fragment.Star.AmountPlaceholder" = "星星数量"; +"Fragment.Star.Withdraw" = "提现"; +"Fragment.Star.WithdrawInput" = "提现 %@"; +"Fragment.Star.WithdrawPlaceholder" = "输入提款金额"; +"Fragment.Stars.AvailableBalance" = "可用余额"; +"Fragment.Stars.BalanceTitle" = "可用余额"; +"Fragment.Stars.MaxWithdraw" = "您一次不能提取超过 %d 颗星星。"; +"Fragment.Stars.MinWithdraw" = "星星不足 %d 颗,无法提取。"; +"Fragment.Stars.Overvew" = "收益概览"; +"Fragment.Stars.RevenueTitle" = "收入"; +"Fragment.Stars.ShowMore" = "显示更多"; +"Fragment.Stars.Title" = "星星余额"; +"Fragment.Stars.TotalCurrent" = "总余额"; +"Fragment.Stars.TotalLifetime" = "终生奖励总额"; +"Fragment.Stars.WithdrawInfo" = "总余额中的星星可用于广告支出,或在获得星星 21 天提取奖励。"; +"Fragment.Ton.AddFunds.Action" = "通过 Fragment 充值"; +"Fragment.Ton.AddFunds.Header.Amount" = "需要 %1$@ TON "; +"Fragment.Ton.AddFunds.Header.Info" = "您可以通过第三方平台 Fragment 充值 TON。"; +"Fragment.Ton.AddFunds.Link" = "https://fragment.com/ads/topup"; +"Fragment.Ton.AddFunds.Title" = "充值"; +"Fragment.Ton.MaxWithdraw" = "您一次无法提取多于 %d TON。"; +"Fragment.Ton.MinWithdraw" = "您无法提取少于 %d TON."; +"FreezeAccount.ClickDetails" = "点击查看详细信息"; +"FreezeAccount.OK" = "提交申诉"; +"FreezeAccount.Option1.Text" = "\u200b您的账号因违反 Telegram 的服务条款而被冻结。"; +"FreezeAccount.Option1.Title" = "违反条款"; +"FreezeAccount.Option2.Text" = "您可以访问您的账号,但不能发送消息或执行操作。"; +"FreezeAccount.Option2.Title" = "只读模式"; +"FreezeAccount.Option3.Text" = "在 %@ 之前通过 [@SpamBot]() 申诉,否则您的账户将被删除。"; +"FreezeAccount.Option3.Title" = "停用前申诉"; +"FreezeAccount.Title" = "您的账号已被冻结"; +"Gallery.Contact.PhotoByYou" = "只有你可以看到该照片。"; +"Gallery.Context.Alert.Downloaded" = "已下载。 [在 Finder 中显示](finder)"; +"Gallery.Context.Alert.Downloading" = "正在下载。 [在下载文件夹中显示](manager)"; +"Gallery.Context.CopyToClipboard" = "拷贝媒体文件"; +"Gallery.Context.MainPhoto" = "设为主要照片"; +"Gallery.Context.Original" = "原图"; +"Gallery.Context.SaveAs" = "另存为..."; +"Gallery.Context.SaveVideo" = "保存视频"; +"Gallery.Context.Share.AllItems#other" = "全部 %d 个项目"; +"Gallery.Context.Share.AllItems_countable" = "%d"; +"Gallery.Context.Share.AllPhotos#other" = "全部 %d 张图片"; +"Gallery.Context.Share.AllPhotos_countable" = "%d"; +"Gallery.Context.Share.AllVideos#other" = "所有%d段视频"; +"Gallery.Context.Share.AllVideos_countable" = "%d"; +"Gallery.Context.Share.ThisFile" = "此文件"; +"Gallery.Context.Share.ThisPhoto" = "这张图片"; +"Gallery.Context.Share.ThisVideo" = "这段视频"; +"Gallery.Context.ShowGallery" = "分享的媒体"; +"Gallery.Context.ShowMessage" = "显示消息"; +"Gallery.ContextDeletePhoto" = "删除"; +"Gallery.HideTranslation" = "隐藏翻译"; +"Gallery.PublicPhoto" = "公开图片"; +"Gallery.RemoveGif" = "移除 GIF"; +"Gallery.SaveGif" = "保存 GIF"; +"Gallery.SendHere" = "编辑并发送"; +"Gallery.Translate" = "翻译"; +"Gallery.WaitDownload.Photo" = "请等待照片下载完毕。"; +"Gallery.WaitDownload.Video" = "请等待视频下载完毕。"; +"GalleryView.FastSave.Gif" = "GIF 动态图已保存到您的 [下载]() 文件夹"; +"GalleryView.FastSave.Gif1" = "GIF 保存到\n[下载]()"; +"GalleryView.FastSave.Image" = "图像已保存到您的 [下载]() 文件夹"; +"GalleryView.FastSave.Image1" = "图片保存至"; +"GalleryView.FastSave.Video" = "视频已保存到您的 [下载]() 文件夹"; +"GalleryView.FastSave.Video1" = "视频保存到\n[下载]()"; +"GeneralSettings.AccentColor" = "强调色"; +"GeneralSettings.AcceptSecretChats" = "接受私密聊天"; +"GeneralSettings.AdvancedHeader" = "高级"; +"GeneralSettings.AppearanceSettings" = "显示设置"; +"GeneralSettings.AutoplayGifs" = "自动播放 GIF"; +"GeneralSettings.BigEmoji" = "大号表情"; +"GeneralSettings.CallSettings.Header" = "通话设置"; +"GeneralSettings.CallSettings.Text" = "通话设置"; +"GeneralSettings.ChatBackground" = "聊天壁纸"; +"GeneralSettings.CopyRTF" = "拷贝文本格式"; +"GeneralSettings.DarkMode" = "深色模式"; +"GeneralSettings.DarkMode.Description" = "一套适用于夜间以及深色桌面的配色方案"; +"GeneralSettings.Emoji" = "表情"; +"GeneralSettings.EmojiAndStickers" = "表情和贴纸"; +"GeneralSettings.EmojiPrediction" = "推荐表情"; +"GeneralSettings.EmojiPrediction.Disable" = "停用"; +"GeneralSettings.EmojiPrediction.DisableText" = "是否禁用表情符号建议?您可以随时在“偏好设置”中重新启用它们。"; +"GeneralSettings.EmojiReplacements" = "自动替换表情符号"; +"GeneralSettings.EnableSidebar" = "显示贴纸侧边栏"; +"GeneralSettings.EnergySaving" = "省电"; +"GeneralSettings.Font.Description" = "信息中使用大字体"; +"GeneralSettings.ForceTouch.Edit" = "编辑消息"; +"GeneralSettings.ForceTouch.Forward" = "转发消息"; +"GeneralSettings.ForceTouch.PreviewMedia" = "预览媒体"; +"GeneralSettings.ForceTouch.React" = "添加表情回应"; +"GeneralSettings.ForceTouch.Reply" = "回复消息"; +"GeneralSettings.ForceTouchHeader" = "用力点按操作"; +"GeneralSettings.GeneralSettings" = "通用设置"; +"GeneralSettings.InAppSounds" = "应用内提示音"; +"GeneralSettings.InputSettings" = "输入设置"; +"GeneralSettings.InstantView.ScrollBySpace" = "使用空格键向下滚动"; +"GeneralSettings.InstantViewHeader" = "即时预览"; +"GeneralSettings.InterfaceHeader" = "接口"; +"GeneralSettings.LargeFonts" = "大字体"; +"GeneralSettings.LiteMode" = "省电模式"; +"GeneralSettings.MediaKeysForInAppPlayer" = "处理应用内播放器中的媒体键"; +"GeneralSettings.OpenLatestChatOnLaunch" = "自动打开上次的聊天"; +"GeneralSettings.PreviewChats.Info" = "点击未读标记以预览聊天,而不将其标记为已读。"; +"GeneralSettings.PreviewChats.Text" = "预览聊天"; +"GeneralSettings.SendByCmdEnter" = "按 ⌘ + 回车键发送消息"; +"GeneralSettings.SendByEnter" = "按回车键发送消息"; +"GeneralSettings.SendLargePhotos" = "发送大照片"; +"GeneralSettings.SendLargePhotos.Tooltip.Disabled" = "该照片会以标准质量发送"; +"GeneralSettings.SendLargePhotos.Tooltip.Enabled" = "该照片会以高质量发送"; +"GeneralSettings.Shortcuts" = "快捷键"; +"GeneralSettings.ShortcutsHeader" = "快捷方式"; +"GeneralSettings.ShowArticlesInSearch" = "在搜索时推荐文章"; +"GeneralSettings.ShowCallsTab" = "显示呼叫面板"; +"GeneralSettings.ShowProfileId.Text" = "在您的资料页中显示 Peer IDs"; +"GeneralSettings.Spelling.CheckGrammarSpelling" = "检查拼写和语法"; +"GeneralSettings.Spelling.CheckSpellingAutomatically" = "自动更正拼写"; +"GeneralSettings.Spelling.CheckWhileTyping" = "键入时检查拼写"; +"GeneralSettings.Spelling.Title" = "拼写和语法"; +"GeneralSettings.StatusBarItem" = "在菜单栏中显示 Telegram"; +"Generic.ErrorMoreInfo" = "更多信息"; +"Gif.Keyboard.AddCaption" = "添加说明文字"; +"GifsPane.Reactions" = "表情回应"; +"GifsPane.Trending" = "流行 GIF 动态图"; +"Gift.Context.Hide" = "隐藏"; +"Gift.Context.Show" = "显示"; +"Gift.Context.TakeOff" = "脱下"; +"Gift.Context.Transfer" = "转让"; +"Gift.Context.Wear" = "佩带"; +"Gift.ContextMenu.SetAsTheme" = "应用主题到..."; +"Gift.Marketplace.Attr.Backdrop#other" = "%d 个背景"; +"Gift.Marketplace.Attr.Backdrop_ZeroValueHolder" = "背景"; +"Gift.Marketplace.Attr.Backdrop_countable" = "%d"; +"Gift.Marketplace.Attr.Date" = "日期"; +"Gift.Marketplace.Attr.Model#other" = "%d 个型号"; +"Gift.Marketplace.Attr.Model_ZeroValueHolder" = "型号"; +"Gift.Marketplace.Attr.Model_countable" = "%d"; +"Gift.Marketplace.Attr.Number" = "编号"; +"Gift.Marketplace.Attr.Price" = "价格"; +"Gift.Marketplace.Attr.Symbol#other" = "%d 种符号"; +"Gift.Marketplace.Attr.Symbol_ZeroValueHolder" = "符号"; +"Gift.Marketplace.Attr.Symbol_countable" = "%d"; +"Gift.Marketplace.EmptyFilters" = "没有符合的礼物"; +"Gift.Marketplace.EmptyFilters.Clear" = "清除筛选"; +"Gift.Marketplace.SelectAll" = "全部选择"; +"Gift.Marketplace.Sort.Date" = "按日期排序"; +"Gift.Marketplace.Sort.Number" = "按编号排序"; +"Gift.Marketplace.Sort.Price" = "按价格排序"; +"Gift.NotAccepting" = "抱歉, %@ 不接受礼物."; +"Gift.Options.Gift.BuyLimitReached" = "您已达到送出此礼物的限额。"; +"Gift.Options.GiftSelf.Text" = "给自己买一份礼物并添加至您的个人资料页或留作日后使用。\n\n限量版礼物升级为典藏品后可以赠送给他人。"; +"Gift.Options.GiftSelf.Title" = "购买礼物"; +"Gift.Premium.Header" = "%@ 高级版"; +"Gift.Premium.PayOrStars" = "或 %@"; +"Gift.Premium.PayWith" = "支付 %@"; +"Gift.Premium.PreviewInfo" = "只有 %@ 能看到你的消息。"; +"Gift.Premium.Text" = "订阅 Telegram 专属功能。"; +"Gift.Premium.YourBalance" = "你的余额为 %@ 颗星星。 [获取更多星星 >]()"; +"Gift.Resale" = "转售"; +"Gift.Resale.PriceUpdate" = "价格更新成功。"; +"Gift.Resale.SetSuccess" = "礼物已成功上架"; +"Gift.Resale.Unavailable.Text" = "您可以在 %@ 后转售此礼物。"; +"Gift.Resale.Unavailable.Title" = "太早"; +"Gift.Sale" = "在售"; +"Gift.Sell.Confirm.Text" = "您确定要以 **%2$@** 的价格出售 **%1$@** 吗?"; +"Gift.Sell.Confirm.Title" = "出售礼物"; +"Gift.Send.Disallow.Error" = "用户不接受礼物"; +"Gift.Send.Error.LimitReached" = "您已经发送了 %d 份同类礼物,已达上限。"; +"Gift.Send.Error.LimitReached_countable" = "%d"; +"Gift.Send.Upgrade" = "以 %@ 创造独家典藏品"; +"Gift.Send.Upgrade.Info" = "启用此选项可让 %1$@ 将您的礼物转换为独家典藏品。 **了解更多 >**"; +"Gift.SendSelf.HideMyName.Info" = "在我的个人资料页中对访客隐藏用户名和消息。"; +"Gift.SoldOut" = "售罄"; +"Gift.SoldOut.Error" = "此礼物已售罄"; +"Gift.Tooltip.Pinned" = "礼物已置顶"; +"Gift.Tooltip.Unpinned" = "礼物已取消置顶"; +"Gift.Transfer.Confirmation.Text" = "你是否要花费 %3$@ 将 %1$@ 的拥有权转让给 %2$@?"; +"Gift.Transfer.Confirmation.TextFree" = "您想要将 **%1$@** 的所有权转让给 **%2$@** 吗?"; +"Gift.Transfer.Confirmation.Title" = "确认转让"; +"Gift.Transfer.Confirmation.Transfer" = "转让条件"; +"Gift.Transfer.Confirmation.TransferFree" = "转让"; +"Gift.Transfer.SendUnlocks" = "%@ 后解锁"; +"Gift.Transfer.SendViaBlockchain" = "通过区块链发送"; +"Gift.Transfer.Title" = "转让"; +"Gift.Transfer.Unavailable.Text" = "您可以在 %@ 后转让此礼物。"; +"Gift.Transfer.Unavailable.Title" = "太早"; +"Gift.Transfer.UnlockPending.Text" = "%@ 后,您能够将此典藏品发送到 Telegram 以外的任何 TON 区块链地址进行销售或拍卖。"; +"Gift.Transfer.UnlockPending.Title" = "正在解锁中"; +"Gift.Transfer.UpdateRequired.Text" = "Telegram 支持将礼物转账至区块链的版本目前正在 Play 商店审核中,请耐心等待几天。"; +"Gift.Transfer.UpdateRequired.Title" = "需要更新"; +"Gift.TransferTon.OK" = "继续"; +"Gift.Unavailable" = "售罄"; +"Gift.Unique.AttributeDescription" = "只有 %@ 的此类典藏品具有此属性。"; +"Gift.Unique.Availability" = "剩余数量"; +"Gift.Unique.Backdrop" = "背景"; +"Gift.Unique.Collectible" = "典藏品"; +"Gift.Unique.Issued" = "已送出 %@"; +"Gift.Unique.Model" = "型号"; +"Gift.Unique.Needs.Premium" = "订阅 [Telegram 高级版](premium) 以佩带典藏品。"; +"Gift.Unique.OriginalInfo" = "已于 %2$@ 赠送给 %1$@。"; +"Gift.Unique.OriginalInfoSender" = "由 %1$@ 于 %3$@ 赠送给 %2$@。"; +"Gift.Unique.OriginalInfoSenderWithText" = "由 %1$@ 于 %3$@ 赠送给 %2$@,留言为:“%4$@”。"; +"Gift.Unique.OriginalInfoWithText" = "于 %2$@ 赠送给 %1$@,留言为:“%3$@”"; +"Gift.Unique.Owner" = "所有者"; +"Gift.Unique.Rarity.Epic" = "史诗"; +"Gift.Unique.Rarity.Legendary" = "传奇"; +"Gift.Unique.Rarity.Rare" = "稀有"; +"Gift.Unique.Rarity.Uncommon" = "罕见"; +"Gift.Unique.Symbol" = "符号"; +"Gift.Unique.Telegram" = "Telegram"; +"Gift.Unique.Transfer" = "修改"; +"Gift.Unlist.Confirm.Ok" = "下架"; +"Gift.Unlist.Confirm.Text" = "此物品将不再出售。"; +"Gift.Unlist.Confirm.Title" = "下架此礼物吗?"; +"Gift.Upgrade.AddMyName" = "将我的名字添加到礼物上"; +"Gift.Upgrade.AddName" = "将送礼者的名称添加到礼物上"; +"Gift.Upgrade.AddNameAndComment" = "添加送礼者的名字和留言"; +"Gift.Upgrade.Confirm" = "确认"; +"Gift.Upgrade.Description" = "将你的礼物转换为可以转让或拍卖的独家典藏品。"; +"Gift.Upgrade.IncludeDescription" = "让 %@ 将您的礼物转换为独家典藏品。"; +"Gift.Upgrade.IncludeTitle" = "独家定制"; +"Gift.Upgrade.Pay" = "支付 %@"; +"Gift.Upgrade.Refunded" = "由于此礼物相关的付款已申请退款并成功退回,该礼物已被降级。"; +"Gift.Upgrade.Succeed.Text" = "你已收到礼物的独家编号,型号,背景和符号。"; +"Gift.Upgrade.Succeed.Title" = "礼物现在可以转换为典藏品"; +"Gift.Upgrade.Title" = "升级礼物"; +"Gift.Upgrade.Tradable.Description" = "在第三方 NFT 市场上出售或拍卖你的礼物。"; +"Gift.Upgrade.Tradable.IncludeDescription" = "收件人将能够在第三方 NFT 市场上拍卖礼物。"; +"Gift.Upgrade.Tradable.Title" = "可交易"; +"Gift.Upgrade.Transferable.Description" = "将升级后的礼物赠送给 Telegram 上的任何朋友。"; +"Gift.Upgrade.Transferable.IncludeDescription" = "收件人能够将礼物发送给 Telegram 上的任何人。"; +"Gift.Upgrade.Transferable.Title" = "可转让"; +"Gift.Upgrade.Unique.Description" = "为您的礼物取得专属编号、模型、背景和符号。"; +"Gift.Upgrade.Unique.IncludeDescription" = "收礼者将获得礼物的独家编号、模型、背景和符号。"; +"Gift.Upgrade.Unique.Title" = "独家"; +"Gift.Upgrade.Upgrade" = "升级"; +"Gift.View.DisplayedInfoHide" = "此礼物在您的个人资料页上可见。[隐藏 >]()"; +"Gift.View.KeepOrConvertDescription.Channel" = "您的频道可以将此礼物添加到资料页中,或将其转换为 %@。 [更多关于星星的信息 >](https://telegram.org)"; +"Gift.View.KeepUpgradeOrConvertDescription" = "你可以保留、升级或以 %@ 的价格出售此礼物。 [更多关于星星 >]()"; +"Gift.View.KeepUpgradeOrConvertDescription.Channel" = "你的频道可以保留、升级或以 %@ 的价格出售此礼物。 [更多关于星星 >](https://telegram.org)"; +"Gift.View.Status" = "状态"; +"Gift.View.Status.NonUnique" = "非独家"; +"Gift.View.Status.Upgrade" = "升级"; +"Gift.Wear.Badge.ChannelText" = "此礼物将显示在你的频道名称旁边,並带有闪耀图标。"; +"Gift.Wear.Badge.Text" = "此礼物将以闪耀图标的形式显示在你的名字旁边。"; +"Gift.Wear.Badge.Title" = "光芒徽章"; +"Gift.Wear.Design.ChannelText" = "你的频道资料页将使用与此礼物相同的背景和符号。"; +"Gift.Wear.Design.Text" = "您的个人资料页将套用此物品的背景和符号。"; +"Gift.Wear.Design.Title" = "独特的个人资料页设计"; +"Gift.Wear.Proof.ChannelText" = "点击频道名称旁边的图标将显示礼物的所有者和属性。"; +"Gift.Wear.Proof.Text" = "点击您名字旁的图标将显示礼物的所有者和属性。"; +"Gift.Wear.Proof.Title" = "所有权证明"; +"Gift.Wear.Start" = "开始佩戴"; +"Gift.Withdraw.Proceed" = "打开 Fragment"; +"Gift.Withdraw.Text" = "您可以使用第三方服务 Fragment 将 %@ 转移到 TON 区块链。之后,您可以通过任何 TON 钱包应用将其作为 NFT 管理。\n\n您还可以通过 Fragment 将此类 NFT 转回您的 Telegram 账户。"; +"Gift.Withdraw.Title" = "通过 Fragment 管理"; +"GiftLink.Info.NotUsed" = "此链接允许您启用 **Telegram 高级版** 订阅。"; +"GiftLink.Info.NotUsed.Info" = "您也可以发送此 [链接](share) 给好友作为礼物。"; +"GiftLink.Info.Used" = "此链接用于启用 **Telegram 高级版** 订阅。"; +"GiftLink.Info.Used.Info" = "此链接已于 %@ 使用。"; +"GiftLink.NoRecipient" = "无接收者"; +"GiftLink.PremiumDuration.Months" = "%d 个月的 Telegram 高级版"; +"GiftLink.PremiumDuration.Year" = "1 年 Telegram 高级版"; +"GiftLink.Row.Date" = "日期"; +"GiftLink.Row.From" = "来自"; +"GiftLink.Row.Gift" = "赠礼"; +"GiftLink.Row.Reason" = "事由"; +"GiftLink.Row.Reason.Gift" = "你已被频道选中"; +"GiftLink.Row.Reason.Gift.JustGift" = "礼物"; +"GiftLink.Row.Reason.Giveaway" = "抽奖活动"; +"GiftLink.Row.Reason.Giveaway.Incomplete" = "未完成的抽奖活动"; +"GiftLink.Row.To" = "到"; +"GiftLink.Title" = "礼物链接"; +"GiftLink.UseLink" = "使用链接"; +"GiftLink.UseSuccess" = "您成功激活了赠品链接。"; +"Gifting.MyGifts" = "我的礼物"; +"Gifting.PremWithStars.Confirm.Info" = "你确定要花费 **%2$@** 将 **Telegram 高级版** 赠送给 **%1$@** 吗?"; +"Gifting.PremWithStars.Confirm.Title" = "赠送礼物"; +"Gifting.Premium.Info" = "让 %@ 享有 Telegram 高级版专属功能. [请查看详细情况 >](premium)"; +"Gifting.Premium.Title" = "赠送 Telegram 高级版订阅"; +"Gifting.StarGift.All" = "所有礼物"; +"Gifting.StarGift.Badge.Remaining#other" = "剩余 %d 个"; +"Gifting.StarGift.Badge.Remaining_countable" = "剩余 %d 个"; +"Gifting.StarGift.InStock" = "现货"; +"Gifting.StarGift.Info" = "给 %@ 赠送礼物,可保留在个人资料中或兑换为星星。[星星是什么 >](stars)"; +"Gifting.StarGift.Info.Collectibles" = "典藏品礼物是您可以交换或出售的独特数位资产。"; +"Gifting.StarGift.Label.Collectibles" = "典藏品"; +"Gifting.StarGift.Limited" = "限量"; +"Gifting.StarGift.Resale" = "转售"; +"Gifting.StarGift.Title" = "赠送礼物"; +"Gifting.StarGift.Title.Buy" = "购买一个礼物"; +"Gifting.StarGift.Title.Send" = "发送一个礼物"; +"Gifting.Title" = "赠送礼物"; +"Gifting.UserChargeStars" = "**%1$@** 对每条消息收取 **%2$@**。此价格已计入礼物成本中。"; +"Giveaway.AdditionalPrize" = "**%1$@** 还包括 **%2$@** 等奖品。频道管理员负责发放这些奖品。"; +"Giveaway.Alert.Created" = "抽奖活动已创建"; +"Giveaway.Booster.Reason.Gift" = "🎁 赠礼"; +"Giveaway.Booster.Reason.Giveaway" = "🏆 抽奖活动"; +"Giveaway.Channels.Add" = "添加频道"; +"Giveaway.Channels.Add.Group" = "添加群组或频道"; +"Giveaway.Channels.Add.Private.Header" = "私密频道"; +"Giveaway.Channels.Add.Private.Header.Group" = "私密群组"; +"Giveaway.Channels.Add.Private.Ok" = "添加"; +"Giveaway.Channels.Add.Private.Ok.Group" = "添加"; +"Giveaway.Channels.Add.Private.Text" = "您确定要新增私人频道吗?如果没有邀请链接,用户将无法加入。"; +"Giveaway.Channels.Add.Private.Text.Group" = "您确定要新增私人群组吗?如果没有邀请链接,用户将无法加入。"; +"Giveaway.Channels.Add.SelectChannel" = "选择频道"; +"Giveaway.Channels.Add.SelectChannel.Group" = "选择群组"; +"Giveaway.Channels.BoostReceive#other" = "此频道将获得 %d 次助推"; +"Giveaway.Channels.BoostReceive.Group#other" = "此群组将获得 %d 次助推"; +"Giveaway.Channels.BoostReceive.Group_countable" = "%d"; +"Giveaway.Channels.BoostReceive_countable" = "%d"; +"Giveaway.Channels.Context.Remove" = "移除"; +"Giveaway.Channels.Header" = "抽奖活动中包括的频道"; +"Giveaway.Channels.Header.Group" = "抽奖活动包含的群组和频道"; +"Giveaway.Date.Ends" = "结束时间"; +"Giveaway.Date.Info" = "选择何时在您的频道中随机选择订阅者获得 Telegram 高级版。"; +"Giveaway.Date.Info.Group" = "选择何时从您的群组中随机选出成员接收 Telegram 高级版。"; +"Giveaway.Date.Info_Countable" = "%d"; +"Giveaway.Date.SelectDate" = "抽奖活动"; +"Giveaway.Date.SelectDate.OK" = "确定"; +"Giveaway.Date.Title" = "抽奖活动结束日期"; +"Giveaway.Header.Text" = "通过赠礼为您的频道获得更多助推"; +"Giveaway.Header.Text.Group" = "通过赠送礼物为您的群组获得更多助推"; +"Giveaway.Header.Title" = "**赠送 Telegram 高级版订阅**"; +"Giveaway.PaymentOptions.Info" = "您可以在 [这里](premium) 查看 Telegram 高级版的功能列表和更多详情。"; +"Giveaway.PaymentOptions.Months" = "%d 个月"; +"Giveaway.PaymentOptions.Reduce.OK" = "减少"; +"Giveaway.PaymentOptions.Reduce.Text" = "您无法在应用程序中购买 %1$@ 个 %2$@个月的订阅。您想将奖品数量減少到 25 个吗?"; +"Giveaway.PaymentOptions.Reduce.Title" = "减少数量"; +"Giveaway.PaymentOptions.Title" = "Telegram 高级版订阅的时长"; +"Giveaway.PaymentOptions.Year" = "1 年"; +"Giveaway.Quantity.Header" = "奖品/助推的数量"; +"Giveaway.Quantity.Info" = "选择要赠送的 Telegram 高级版订阅数量以及接收的助推数量。"; +"Giveaway.Quantity.Right#other" = "%d 次助推"; +"Giveaway.Quantity.Right_countable" = "%d"; +"Giveaway.ReceiverType.All" = "所有订阅者"; +"Giveaway.ReceiverType.Countries#other" = "来自 %d 个国家"; +"Giveaway.ReceiverType.Countries_ZeroValueHolder" = "来自所有国家"; +"Giveaway.ReceiverType.Countries_countable" = "%d"; +"Giveaway.ReceiverType.Info" = "选择是否要将抽奖活动仅限于活动发起之后加入频道的用户,或来自特定国家/地区的用户。"; +"Giveaway.ReceiverType.New" = "仅限新订阅者"; +"Giveaway.ReceiverType.Title" = "有资格参与抽奖活动的用户"; +"Giveaway.Star.AmountPer#other" = "每个用户 %d 颗"; +"Giveaway.Star.AmountPer_countable" = "%d"; +"Giveaway.Star.ChooseRandomly" = "随机选出"; +"Giveaway.Star.Options.Header" = "可分发的星星"; +"Giveaway.Star.OptionsInfo" = "选择赠送多少颗星星以及每年获得多少次助推。"; +"Giveaway.Star.Quantity.Header" = "获奖者数量"; +"Giveaway.Star.Quantity.Info" = "选择您要为多少获奖者发放星星。"; +"Giveaway.Stars.Prepaid.Desc#other" = "给 %d 名用户"; +"Giveaway.Stars.Prepaid.Desc_countable" = "%d"; +"Giveaway.Stars.Prepaid.Title" = "%d 颗 Telegram 星星"; +"Giveaway.StartGiveaway" = "开始抽奖活动"; +"Giveaway.Title" = "通过赠品助推"; +"Giveaway.TooLongDate" = "已达到日期上限。"; +"Giveaway.Type.Premium.Random.Text" = "获奖者是随机选出的"; +"Giveaway.Type.Premium.Title" = "Telegram 高级版"; +"Giveaway.Type.Prepaid.Desc" = "%d 个月的订阅"; +"Giveaway.Type.Prepaid.Title" = "%d Telegram 高级版"; +"Giveaway.Type.Random.Text" = "将随机选出获奖者"; +"Giveaway.Type.Random.Title" = "创建抽奖活动"; +"Giveaway.Type.Specific.Modal.SelectUsers" = "选择用户"; +"Giveaway.Type.Specific.Text" = "选择接收者"; +"Giveaway.Type.Specific.Title" = "奖励特定用户"; +"Giveaway.Type.Stars.Text" = "将随机选出获奖者"; +"Giveaway.Type.Stars.Title" = "Telegram 星星"; +"Giveaway.with" = "及"; +"Giveway.Channels.Info" = "选择用户需要订阅哪些频道才能参与抽奖活动。"; +"Giveway.Channels.Info.Group" = "选择用户参与抽奖活动前额外需要加入的群组或频道。"; +"GlobalSearch.Empty.Desc.PremiumRequired" = "全局搜索是高级版的特权。"; +"GlobalSearch.Empty.Desc.Remaining" = "今天还剩下 %d 次免费搜索。"; +"GlobalSearch.Empty.Desc.Remaining_countable" = "%d"; +"GlobalSearch.Empty.Info.LimitReached" = "您每天只可以进行 %d 次搜索。"; +"GlobalSearch.Empty.Info.LimitReached_countable" = "%d"; +"GlobalSearch.Empty.Info.Premium" = "输入关键词即可在所有公开帖子中搜索。"; +"GlobalSearch.Empty.Title.FreeUnlocksIn" = "%@ 后解锁免费搜索"; +"GlobalSearch.Empty.Title.LimitReached" = "已达上线"; +"GlobalSearch.Empty.Title.Premium" = "全局搜索"; +"GlobalSearch.Empty.Title.PremiumRequired" = "需要订阅高级版"; +"GlobalSearch.Empty.Title.SearchPrefix" = "搜索"; +"GlobalSearch.Empty.Title.SearchWithPrice" = "花费 %@%@ 搜索"; +"GlobalSearch.Pay.Alert.StarsSpent" = "额外搜索将花費 %@。"; +"GlobalTimer.Block.Enabled.Info" = "您发起的聊天中的所有新消息将在发送后 %@ 为所有人自动删除。您还可以[将此设置应用于现有聊天]()。"; +"GlobalTimer.Block.Header" = "自毁定时器"; +"GlobalTimer.Block.Info" = "若果启用这项设置,你所创建的群聊内的信息在发送之后某个时间点为所有用户会被自动删除。这并不会影响你已经存在的聊天记录。"; +"GlobalTimer.Confirm.Ok" = "启用自动删除"; +"GlobalTimer.Confirm.Text" = "您确定要于消息发送 %1$@ 后为所有人自动删除您新建的私聊和群组中的所有消息吗?"; +"GlobalTimer.Confirm.Title" = "自毁计时器"; +"GlobalTimer.Off" = "关闭"; +"GlobalTimer.Select.Apply" = "应用 %@ 自焚计时器"; +"GlobalTimer.Select.StatusDisabled" = "自动删除已停用"; +"GlobalTimer.Select.StatusEnabled" = "%@后自动删除"; +"GlobalTimer.SetCustomTime" = "设置自定义时间"; +"GlobalTimer.Title" = "自动删除消息"; +"Graph.Total" = "合计"; +"Graph.ZoomOut" = "缩小"; +"Group.AppointAnotherAdmin" = "指定新管理员"; +"Group.AppointAnotherOwner" = "指定新所有者"; +"Group.CreateGroup" = "新建群组"; +"Group.EditAdmin.Permission.ChangeInfo" = "修改群组信息"; +"Group.EmojiPack.CreateInfo" = "所有成员都可以在群组中使用这些表情,即使没有订阅 Telegram 高级版。\n\n您可以使用 [@stickers](stickers) 机器人创建自己的自定义表情包。"; +"Group.EmojiPack.Header" = "选择表情包"; +"Group.EmojiPack.Title" = "群组表情包"; +"Group.ErrorAddBlocked" = "抱歉,您无法将此用户加入到群组。"; +"Group.EventLog.EmptyText" = "**暂无事件**\n\n在过去的 48 小时内群组成员和管理员没有执行任何操作。"; +"Group.EventLog.Service.AboutRemoved" = "%@ 移除了群组简介:"; +"Group.EventLog.Service.AboutUpdated" = "%@ 编辑了群组简介:"; +"Group.EventLog.Service.DisableInvites" = "%@ 已停用群组邀请"; +"Group.EventLog.Service.EnableInvites" = "%@ 启用了邀请权限"; +"Group.EventLog.Service.LinkRemoved" = "%@ 移除了群组链接:"; +"Group.EventLog.Service.LinkUpdated" = "%@ 更改了群组链接:"; +"Group.EventLog.Service.PhotoRemoved" = "%@ 移除了群组头像"; +"Group.EventLog.Service.PhotoUpdated" = "%@ 更新了群组图片"; +"Group.EventLog.Service.TitleUpdated" = "%@ 更改了群组名"; +"Group.EventLog.Service.UpdateJoin" = "%@ 加入了群组"; +"Group.EventLog.Service.UpdateLeft" = "%@ 退出了群组"; +"Group.LeaveButton" = "退出群组"; +"Group.LeaveInfoAdmin" = "如果您退出,%1$@ 将在 **1 周** 后成为 %2$@ 的新管理员。"; +"Group.LeaveInfoOwner" = "如果您离开,%1$@ 将在 1 周后成为 %2$@ 的新所有者。"; +"Group.LeaveTitle" = "退出 %1$@ 吗?"; +"Group.NewGroup" = "新建群组"; +"Group.OwnershipTransfer.ErrorLocatedGroupsTooMuch" = "抱歉,目标用户已经有太多基于位置的群组了。 请让他们先删除其中一个。"; +"Group.TransferOwner.ErrorAdminsTooMuch" = "抱歉,由于此群组的管理员数量太多,无法添加新所有者。请先移除一位现有管理员。"; +"Group.TransferOwner.ErrorPrivacyRestricted" = "抱歉,此用户不是该群组的成员,其隐私设置会阻止您手动添加。"; +"Group.Unavailable" = "抱歉,此群组似乎不存在。"; +"Group.UsersTooMuchError" = "抱歉,此群组已经满员。无法添加更多成员。"; +"GroupAdmins.AllMembersAdmins" = "将所有成员设为管理员"; +"GroupAdmins.Desc.AdminInvites" = "只有管理员有权添加或移除成员、编辑群组名称或头像。"; +"GroupAdmins.Desc.AllInvites" = "所有成员可以添加新成员、编辑群组名称及头像。"; +"GroupCall.EndToEnd.Title" = "端到端加密"; +"GroupCall.EndToEnd.TitleShort" = "已加密"; +"GroupCall.Input.Placeholder" = "消息..."; +"GroupCall.Invite.NotAvailable" = "群组通话无法再访问"; +"GroupCall.InviteLink.Basic" = "Telegram 上的任何人都可以通过此链接加入您的通话。"; +"GroupCall.InviteLink.Expired" = "此链接不再有效"; +"GroupCall.InviteLink.Revoked" = "此邀请链接已被废除"; +"GroupCall.InviteLink.Share" = "Telegram 上的任何人都可以通过以下链接加入您的通话。"; +"GroupCall.InviteLink.Title" = "通话链接"; +"GroupCall.InviteLinks.BeFirst" = "率先加入通话并在通话中添加其他人。 [打开通话 >](open)"; +"GroupCall.InviteLinks.CopyLink" = "复制链接"; +"GroupCall.InviteLinks.Invited" = "您已获邀请加入此群组通话。"; +"GroupCall.InviteLinks.ShareLink" = "分享链接"; +"GroupCall.Join.Double" = "**%1$@** 和 **%2$@** 已加入此通话。"; +"GroupCall.Join.Multiple" = "**%1$@**, **%2$@** 和 %3$@ 已加入此通话。"; +"GroupCall.Join.Multiple.Others#other" = "其他 %d 个"; +"GroupCall.Join.Multiple.Others_countable" = "%d"; +"GroupCall.Join.None" = "没有人加入此次通话。"; +"GroupCall.Join.Single" = "**%1$@** 已加入此通话。"; +"GroupCall.Join.Title" = "加入群组通话"; +"GroupCall.Settings.EnableMessages" = "启用消息"; +"GroupCall.StatusBar.StartScreen" = "分享屏幕"; +"GroupCall.StatusBar.StartVideo" = "分享视频"; +"GroupCall.StatusBar.StopScreen" = "停止共享屏幕"; +"GroupCall.StatusBar.StopVideo" = "停止视频"; +"GroupInfo.AddUserLeftError" = "抱歉,如果某人已不再是群组的成员,您需要成为其 Telegram 联系人才能将对方添加回来。\n\n注意只要对方不在被移除用户列表中,就仍可通过群组的邀请链接加入。"; +"GroupInfo.Administrators" = "管理员"; +"GroupInfo.Administrators.Title" = "管理员"; +"GroupInfo.FakeWarning" = "⚠️ 警告:大量用户举报该账号冒名顶替名人或组织。"; +"GroupInfo.Permissions.AddException" = "添加例外"; +"GroupInfo.Permissions.BroadcastConvert" = "转换为广播组"; +"GroupInfo.Permissions.BroadcastConvertInfo" = "广播群组可以拥有超过 %@ 名成员,但仅管理员可以发送消息。"; +"GroupInfo.Permissions.BroadcastTitle" = "广播群组"; +"GroupInfo.Permissions.Exceptions" = "例外名单"; +"GroupInfo.Permissions.Removed" = "被移除的用户"; +"GroupInfo.Permissions.SearchPlaceholder" = "搜索例外"; +"GroupInfo.Permissions.SectionTitle" = "本群组成员有何权限?"; +"GroupInfo.ScamWarning" = "⚠️ 警告:许多用户报告了此账户的诈骗行为。若对方谈及金钱交易,请格外小心。"; +"GroupInvation.ChannelDescription" = "任何已安装 Telegram 的用户都可以通过此链接关注您的频道。"; +"GroupInvation.CopyLink" = "拷贝链接"; +"GroupInvation.GroupDescription" = "任何已安装 Telegram 的用户都将可以通过此链接加入您的群组。"; +"GroupInvation.Revoke" = "撤销"; +"GroupInvation.Share" = "分享链接"; +"GroupPermission.AddSuccess" = "例外已添加"; +"GroupPermission.AddedInfo" = "例外由 %@ %@ 添加"; +"GroupPermission.ApplyAlertAction" = "应用"; +"GroupPermission.ApplyAlertText" = "您已在%@中更改了此用户的权限 。\n应用此更改吗?"; +"GroupPermission.Delete" = "删除例外"; +"GroupPermission.Duration" = "时限"; +"GroupPermission.NewTitle" = "新建例外"; +"GroupPermission.NoAddMembers" = "无权添加"; +"GroupPermission.NoChangeInfo" = "无权更改群信息"; +"GroupPermission.NoFiles" = "无权发送文件"; +"GroupPermission.NoMusic" = "无权发送音频"; +"GroupPermission.NoPhotos" = "无权发送图片"; +"GroupPermission.NoPinMessages" = "无权置顶"; +"GroupPermission.NoSendGifs" = "不能发送 GIF "; +"GroupPermission.NoSendLinks" = "无权发送链接"; +"GroupPermission.NoSendMedia" = "无权发送媒体"; +"GroupPermission.NoSendMessages" = "无权发送消息"; +"GroupPermission.NoSendPolls" = "无权发起投票"; +"GroupPermission.NoTopics" = "无主题"; +"GroupPermission.NoVideoMessages" = "无权发送视频消息"; +"GroupPermission.NoVideos" = "无权发送视频"; +"GroupPermission.NoVoiceMessages" = "无权发送语音消息"; +"GroupPermission.NotAvailableInPublicGroups" = "公开群组不具有此权限。"; +"GroupPermission.SectionTitle" = "此成员访问权限管理"; +"GroupPermission.Title" = "例外"; +"GroupStats.Title" = "群组数据"; +"GroupStickers.ChooseHeader" = "从您的贴纸包中选择"; +"GroupStickers.CreateDescription" = "您可以通过机器人 @stickers 创建您自己的贴纸包。"; +"GroupStickers.EmptyDesc" = "请重试,或从下面列表中选取一个"; +"GroupStickers.EmptyDesc.Emoji" = "请重试或从下方列表中选择"; +"GroupStickers.EmptyHeader" = "贴纸包未找到"; +"GroupStickers.EmptyHeader.Emoji" = "没有找到此表情包"; +"GroupsInCommon.Empty" = "没有共同加入的群组"; +"H8h-7b-M4v.title" = "查看"; +"HFQ-gK-NFA.title" = "文本替换"; +"HFo-cy-zxI.title" = "显示拼写和语法"; +"HashtagSearch.StoriesFound#other" = "找到 %d 个动态。"; +"HashtagSearch.StoriesFoundInfo" = "查看与 %@ 相关的动态"; +"HashtagSearch.StoriesFound_countable" = "%d"; +"HyV-fh-RgO.title" = "查看"; +"IV.Channel.Join" = "加入"; +"InAppLinks.AuthFailed" = "登录失败。"; +"InAppLinks.BotManagementDisabled" = "%1$@ 未启用机器人管理模式。"; +"InAppLinks.Confirm.OpenExternal" = "打开 %@?"; +"InAppLinks.Confirm.OpenExternal.Header" = "打开链接"; +"InAppLinks.Confirm.OpenExternal.OK" = "打开"; +"InAppLinks.Confirm.OpenExternalNew" = "你想打开 %@ ?"; +"InAppLinks.IDNWarning" = "此链接可能具有欺骗性。 \n显示内容:%1$@ \n实际内容:%2$@"; +"InAppLinks.LoginExpired" = "此链接已失效。"; +"InAppLinks.LoginSuccess" = "您已成功登录。"; +"InAppLinks.StyleNotFound" = "该风格不存在。"; +"InactiveChannels.BlockHeader" = "群组与频道过多"; +"InactiveChannels.Header" = "最不活跃"; +"InactiveChannels.InactiveMonth#other" = "%d个月不活跃"; +"InactiveChannels.InactiveMonth_countable" = "%d"; +"InactiveChannels.InactiveWeek#other" = "%d周不活跃"; +"InactiveChannels.InactiveWeek_countable" = "%d"; +"InactiveChannels.InactiveYear#other" = "%d 年不活跃"; +"InactiveChannels.InactiveYear_countable" = "%d"; +"InactiveChannels.OK" = "离开"; +"InactiveChannels.Title" = "达到上限"; +"InlineModalAction.Desc" = "通过 %@ 给用户或会话分享内容"; +"InlineModalAction.Title" = "分享机器人内容"; +"Input.Quote.Collapsed" = "引用文字将会折叠"; +"Input.Quote.Expanded" = "引用文字将会展开"; +"InputAttach.Audio" = "音频"; +"InputAttach.Popover.Checklist" = "核对清单"; +"InputAttach.Popover.File" = "文档"; +"InputAttach.Popover.Gift" = "赠送高级版"; +"InputAttach.Popover.Location" = "位置"; +"InputAttach.Popover.Music" = "音频文件"; +"InputAttach.Popover.Photo" = "图片"; +"InputAttach.Popover.PhotoOrVideo" = "照片或视频"; +"InputAttach.Popover.Picture" = "相机"; +"InputAttach.Popover.Poll" = "投票"; +"InputAttach.Popover.Video" = "视频"; +"InputContext.HashtashHelp.Basic" = "从所有频道搜索贴文"; +"InputContext.HashtashHelp.Channel" = "仅搜索此频道中的贴文"; +"InputContext.HashtashHelp.Use" = "使用 %@"; +"InputData.Date.Day.Placeholder" = "日:"; +"InputData.Date.Day.Placeholder1" = "日"; +"InputData.Date.Month.Placeholder" = "月:"; +"InputData.Date.Month.Placeholder1" = "月"; +"InputData.Date.Year.Placeholder" = "年:"; +"InputData.Date.Year.Placeholder1" = "年"; +"InputFormatter.Text.Header" = "文本"; +"InputFormatter.Text.Placeholder" = "文本"; +"InputFormatter.URL.Header" = "链接"; +"InputFormatter.URL.Placeholder" = "链接"; +"InputPasswordController.Error.WrongPassword" = "密码错误,请重试"; +"InputPasswordController.Placeholder" = "密码"; +"InputView.Settings" = "设置"; +"InputView.Settings.AutoBulletList" = "自动项目符号列表"; +"InputView.Settings.AutoDetectDates" = "自动检测日期"; +"InputView.Settings.AutoNumberedList" = "自动编号列表"; +"InputView.Settings.DashConversion" = "连字符转换"; +"InstalledStickers.Archived" = "已归档贴纸包"; +"InstalledStickers.CustomEmoji" = "自定义表情"; +"InstalledStickers.Descrpiption" = "用户可以通过机器人 @stickers 创建自己的贴纸包。\n\n点击贴纸可以预览及添加贴纸包。"; +"InstalledStickers.DynamicPackOrder" = "贴纸包动态顺序"; +"InstalledStickers.DynamicPackOrderInfo" = "最近使用的贴纸包将显示在旧的贴纸包上方。"; +"InstalledStickers.LoopAnimated" = "循环播放动态贴纸"; +"InstalledStickers.PacksTitle" = "您的贴纸包"; +"InstalledStickers.QuickReaction" = "快速回应"; +"InstalledStickers.QuickReaction1" = "快速回应"; +"InstalledStickers.Remove.Delete" = "删除"; +"InstalledStickers.Remove.Description" = "此贴纸包将被存档,您可以在「已归档贴纸包」选项中快速将其恢复。"; +"InstalledStickers.Tranding" = "热门贴纸"; +"InstantPage.AuthorAndDateTitle" = "%1$@ 于 %2$@"; +"InstantPage.RelatedArticleAuthorAndDateTitle" = "%@ • %@"; +"Invite.ChannelsTooMuch" = "抱歉,对方加入了太多群组和频道。请让其先退出一些。"; +"InviteFailed.OK" = "发送邀请链接"; +"InviteFailed.Premium.Action" = "订阅 Telegram 高级版"; +"InviteFailed.Premium.Text.Multiple" = "**%@** 仅接受联系人和 **Premium** 用户的群组邀请"; +"InviteFailed.Premium.Text.MultipleAnd#other" = "**%1$@** 和 %2$d 个人只允许联系人和 **高级版** 用户邀请其加入群组。"; +"InviteFailed.Premium.Text.MultipleAnd_countable" = "%@ %d"; +"InviteFailed.Premium.Text.Single" = "**%@** 只接受联系人和 **高级版** 用户的群组邀请。"; +"InviteFailed.Premium.Title" = "升级至高级版"; +"InviteFailed.Skip" = "跳过"; +"InviteFailed.Text.Cant.Multiple#other" = "**%d 名用户**只能通过链接邀请,但是您无权分享本群组的邀请链接。 "; +"InviteFailed.Text.Cant.Multiple_countable" = "%d"; +"InviteFailed.Text.Cant.Single" = "%@ 只能通过邀请链接邀请,但您无权分享此群组的邀请链接。"; +"InviteFailed.Text.Multiple#other" = "**%d 位用户**限制将其加入群组,您可以给对方发送邀请链接。"; +"InviteFailed.Text.Multiple_countable" = "%d"; +"InviteFailed.Text.Single" = "**%@** 限制将其加入群组,您可以给对方发送邀请链接。"; +"InviteFailed.Title" = "通过链接邀请"; +"InviteLink.CanJoin#other" = "%d 可以加入"; +"InviteLink.CanJoin_countable" = "%d"; +"InviteLink.Create.Copied" = "创建链接并保存到剪贴板"; +"InviteLink.EmptyJoinDesc#other" = "该链接还能邀请 %d 人加入。"; +"InviteLink.EmptyJoinDesc_countable" = "%d"; +"InviteLink.Joined#other" = "%d 人已加入"; +"InviteLink.JoinedNew#other" = "%d 已加入"; +"InviteLink.JoinedNew_ZeroValueHolder" = "尚无人加入"; +"InviteLink.JoinedNew_countable" = "%d"; +"InviteLink.JoinedRevoked" = "无人加入"; +"InviteLink.Joined_countable" = "%d 人"; +"InviteLink.PeopleJoined#other" = "%d 人已加入"; +"InviteLink.PeopleJoined_countable" = "%d"; +"InviteLink.Remaining#other" = "剩余 %d 次"; +"InviteLink.Remaining_countable" = "%d"; +"InviteLink.Requested#other" = "%d份请求"; +"InviteLink.Requested_ZeroValueHolder" = "暂无人发送请求"; +"InviteLink.Requested_countable" = "%d"; +"InviteLink.ShareLink" = "分享链接"; +"InviteLink.Sticker.Expired" = " • 已失效"; +"InviteLink.Sticker.Limit" = " • 达到上限"; +"InviteLink.Sticker.Revoked" = "• 已撤销"; +"InviteLink.Sticker.TimeLeft" = "将于%@内过期"; +"InviteLink.Sub.Amount.Placeholder" = "每月所需星星数量"; +"InviteLink.Sub.Info" = "向通过此链接加入您频道的用户收取订阅费用。[了解更多 >](https://telegram.org)"; +"InviteLink.Sub.Info.Editing" = "如果您需要更改订阅费用,请创建具有不同价格的新邀请链接。"; +"InviteLink.Sub.ReqApproval" = "您无法对需要按月付费的链接启用管理员批准。"; +"InviteLink.Sub.Text" = "需要月费"; +"InviteLink.SubFee.Header" = "订阅费"; +"Join.ChannelsTooMuch" = "抱歉,您已经加入了太多的群组和频道,请在加入新的之前退出一些。"; +"Join.InactiveChannels" = "不活跃的聊天"; +"JoinLink.FloodError" = "超出限制。请稍后重试。"; +"JoinLink.Join" = "加入"; +"Kd2-mp-pUS.title" = "显示全部窗口"; +"LE2-aR-0XJ.title" = "前置全部窗口"; +"Language.OfficialTransationsHeader" = "官方翻译"; +"Language.RemovePack" = "您确定要移除这个语言包吗?"; +"Language.TranslateMessages.Channel" = "翻译全部聊天记录"; +"Language.TranslateMessages.Channel.Info" = "翻译聊天中的全部记录。只对 [高级版用户]() 开放。"; +"Language.TranslateMessages.Channel.Premium" = "订阅 [Telegram 高级版](premium) 以翻译所有聊天。"; +"Language.TranslateMessages.DoNotTranslate" = "无需翻译"; +"Language.TranslateMessages.Header" = "翻译消息"; +"LastSeen.HoursAgo#other" = "最近上线于 %d 小时前"; +"LastSeen.HoursAgo_countable" = "%d"; +"Legacy.Intro.Description1" = "欢迎使用超快又稳定的 macOS 版 Telegram,现全以 Swift 3.0 重写。"; +"Legacy.Intro.Description2" = "请注意您的现有私密对话将以只读方式显示。您可以创建一个新的私密对话来继续聊天。"; +"Legacy.Intro.Next" = "开始聊天"; +"Link.Expired" = "抱歉,该链接已失效。"; +"LinkInvation.Channel.Confirm.Revoke" = "您确定要撤销这个邀请链接吗?一旦撤销,其他人将无法使用此链接加入这个频道。"; +"LinkInvation.Confirm.Ok" = "撤销"; +"LinkInvation.Group.Confirm.Revoke" = "您确定要撤销这个邀请链接吗?一旦撤销,其他人将无法使用此链接加入小组。"; +"LiteMode.DisableAlert" = "停用省电模式"; +"LiteMode.Disabled" = "已禁用"; +"LiteMode.EnableText" = "省电模式"; +"LiteMode.Enabled" = "已启用"; +"LiteMode.Info" = "减少所有耗电量的动画并提高性能。"; +"LiteMode.Key.animations.Info" = "使 Telegram 看起来超棒的各种特效和动画。"; +"LiteMode.Key.animations.Title" = "界面效果"; +"LiteMode.Key.blur.Info" = "为界面元素添加模糊透明效果。"; +"LiteMode.Key.blur.Title" = "模糊效果"; +"LiteMode.Key.emoji.Info" = "在消息、表情回应和状态中循环播放动画表情。"; +"LiteMode.Key.emoji.Title" = "表情动画"; +"LiteMode.Key.emoji_effects.Info" = "显示表情回应和高级贴纸的动画特效。"; +"LiteMode.Key.emoji_effects.Title" = "表情特效"; +"LiteMode.Key.gif.Info" = "在聊天和键盘中自动播放并循环 GIF 动态图。 "; +"LiteMode.Key.gif.Title" = "自动播放 GIF 动态图"; +"LiteMode.Key.liquidGlass.Info" = "使用 macOS Tahoe Liquid Glass 效果渲染用户界面。关闭以回滚至轻量的系统模糊并节省 GPU 资源。"; +"LiteMode.Key.liquidGlass.Title" = "启用液态玻璃"; +"LiteMode.Key.menu_animations.Info" = "在上下文菜单中为图标添加动画。"; +"LiteMode.Key.menu_animations.Title" = "动画菜单"; +"LiteMode.Key.stickers.Info" = "在聊天和键盘中循环动态贴纸。"; +"LiteMode.Key.stickers.Title" = "贴纸动画"; +"LiteMode.Key.video.Info" = "在聊天中自动并循环播放视频和视频消息。"; +"LiteMode.Key.video.Title" = "自动播放视频"; +"LiteMode.LowPower" = "低电量模式"; +"LiteMode.LowPower.Info" = "当您的电池电量低于 %1$@% 时,打开省电模式。"; +"LiteMode.LowPower.InfoFull" = "可以根据电池电量自动打开省电模式。"; +"LiteMode.Title" = "省电"; +"LiveStream.AllowComments" = "允许留言"; +"LiveStream.AllowScreenshots" = "允许截图"; +"LiveStream.PostToChannelInfo" = "将此直播保留在您的频道资料页。"; +"LiveStream.PostToChannelProfile" = "发布到频道资料页上"; +"LiveStream.PricePerComment" = "每个评论的价格"; +"LiveStream.PricePerCommentInfo" = "观众发送评论需支付的价格。"; +"LiveStream.Report" = "举办"; +"LiveStream.Save" = "保存"; +"LiveStream.Title" = "直播"; +"Localization.Preview.Error.Generic" = "抱歉,这种语言似乎并不存在。"; +"Location.SelectMyLocation" = "选择我的位置"; +"Location.SelectThisLocation" = "选择我的位置"; +"Location.Send.AccurateTo" = "精确到 %@"; +"Location.Send.HideNearby" = "隐藏附近地点"; +"Location.Send.Locating" = "定位中…"; +"Location.Send.MyLocation" = "发送我当前的位置"; +"Location.Send.ShowNearby" = "显示附近地点"; +"Location.Send.ThisLocation" = "发送此位置"; +"Location.Send.ThisLocation.Unknown" = "未知位置"; +"Location.Send.Title" = "位置"; +"Location.Set.Location" = "选择此位置"; +"LocationPreview.DistanceAway" = "%@ 远"; +"LocationPreview.Location" = "位置"; +"LocationPreview.OpenInMaps" = "在 Google 地图中打开"; +"LocationPreview.Title" = "位置预览"; +"Log.Restart.Info" = "重新启动应用程序以开始收集日志"; +"Login.ConnectionError.Header" = "无法连接服务器"; +"Login.ConnectionError.Info" = "请检查您的互联网连接并重试。"; +"Login.ConnectionError.TryAgain" = "重试"; +"Login.ConnectionError.UseProxy" = "使用代理"; +"Login.ContinueOnLanguage" = "使用简体中文继续"; +"Login.EmailChanged" = "你的电子邮箱地址已更改。"; +"Login.EnterCodeFromApp" = "我们已将验证码发送至您其他已登录设备上的 Telegram 应用。"; +"Login.EnterCodeNewEmailText" = "请输入我们发送至你的新邮件 %@ 的代码。"; +"Login.EnterCodeNewEmailTitle" = "检查你的新邮件"; +"Login.EnterNewEmailTitle" = "输入新的邮箱地址"; +"Login.EnterPasswordDescription" = "您已启用两步认证,现在您的账号受附加密码保护。"; +"Login.EnterPhraseBeginningText" = "我们已向您的号码 **%2$@** 发送了一条短信,其中包含以 “%1$@” 开头的秘密短语。"; +"Login.EnterPhrasePlaceholder" = "输入短信验证码"; +"Login.EnterPhraseText" = "我们已向您的手机号码 **%@** 发送了一个带有秘密短语的短信。"; +"Login.EnterPhraseTitle" = "输入密码短语"; +"Login.EnterWordBeginningText" = "我们已向您的手机号码 **%1$@** 已发送一条带有以**%2$@** 开头的秘密短语的短信。"; +"Login.EnterWordPlaceholder" = "输入短信中的文字"; +"Login.EnterWordText" = "我们已向您的手机号码 **%@** 发送了一个带有秘密单词的消息。"; +"Login.EnterWordTitle" = "输入单词"; +"Login.FloodWait" = "已超出尝试次数,请稍后再试。"; +"Login.Header.Code" = "请输入验证码"; +"Login.Header.Password" = "输入密码"; +"Login.Header.SignUp" = "注册"; +"Login.InvalidCountryCode" = "国际电话区号无效"; +"Login.InvalidFirstNameError" = "不符合规范的名字,请重新输入。"; +"Login.InvalidLastNameError" = "姓氏无效。 请再试一次。"; +"Login.JustSentSms" = "我们通过短信向您发送了验证码,请在上方输入。"; +"Login.New.Cancel.Confirm" = "取消登陆并重新开始?"; +"Login.New.Code.CallInfo" = "我们刚刚拨打了您的电话 **%@** · [Edit]()"; +"Login.New.Code.CodeInfo" = "我们已经将验证码发送至\n你账号 **%@** 设备上的 Telegram App · [修改]()"; +"Login.New.Code.Email" = "我们已发送一封包含活跃验证码的电子邮件 \n到 %@"; +"Login.New.Code.EnterCode" = "查看您的 Telegram 消息"; +"Login.New.Code.EnterSms" = "输入验证码"; +"Login.New.Code.FragmentInfo" = "在 **Fragment** 上的“号码”部分获取 **%1$@** 的验证码。[编辑]()"; +"Login.New.Code.SmsInfo" = "我们已发送一则包含激活码的短信到你的手机是 **%@** · [Edit]()"; +"Login.New.Email.Alert" = "您剩余的选择仅有记住密码或者重置账户"; +"Login.New.Email.Alert.Reset" = "重置账号"; +"Login.New.Email.Footer" = "无法访问 [%@]()?"; +"Login.New.Email.Header" = "邮箱代码"; +"Login.New.Email.Info" = "请检查您的邮件并输入我们发送给您的 6 位验证码,以停用云端密码。"; +"Login.New.Password.Info" = "你已启用两步验证,因此您的帐户受到额外密码的保护。"; +"Login.New.Password.Label" = "你的密码"; +"Login.New.Password.Placeholder" = "输入密码"; +"Login.New.PhoneBannedError" = "手机号码已被封禁"; +"Login.New.PhoneNumber" = "请确认你的国际电话区号并输入您的手机号码。"; +"Login.New.Register.Footer" = "注册即代表你同意[服务条款]()。"; +"Login.New.Register.Header" = "个人信息"; +"Login.New.Register.Info" = "您可以在此输入您的姓名,也可以在需要时上传头像。"; +"Login.New.Register.Next" = "注册"; +"Login.New.Register.Remove" = "删除"; +"Login.New.Register.Select" = "选择"; +"Login.New.Reset.Able" = "你可以立刻重置账户"; +"Login.New.Reset.Button" = "重置"; +"Login.New.Reset.CancelReset" = "取消重置"; +"Login.New.Reset.Header" = "重置帐户"; +"Login.New.Reset.Info" = "由于该账号 **%@** 已经激活 并且有密码保护, 它将在一周内被删除。 延期是为了安全考虑。\n\n%@"; +"Login.New.Reset.When" = "您将能够在以下时间后重置您的账号:"; +"Login.New.Reset.WillAble" = "在这段时间后,您将能重置您的账号:"; +"Login.Next" = "下一步"; +"Login.Next.Fragment" = "打开 Fragment"; +"Login.PasswordForgot" = "是否忘记密码?"; +"Login.PhoneCalledCode" = "我们已致电您的号码。请输入您收到的验证码。"; +"Login.PhoneDialed" = "Telegram 已拨打您的电话"; +"Login.PhoneNumberAlreadyAuthorized" = "该帐号已在此应用中登录。"; +"Login.PhoneNumberAlreadyAuthorized.Switch" = "更换帐号"; +"Login.PhoneNumberNotRegistred" = "该电话号码尚未注册。如果您尚未拥有 Telegram 账号,请在您的移动设备上进行注册。"; +"Login.QR.Cancel" = "使用手机号码登录"; +"Login.QR.Help1" = "在您的手机上打开Telegram"; +"Login.QR.Help2" = "请转到**设置** > **设备** > **关联桌面设备**"; +"Login.QR.Help3" = "点击手机屏幕确认登录"; +"Login.QR.Login" = "扫描二维码登录"; +"Login.QR.Title" = "扫码登录 Telegram"; +"Login.QR1.Help1" = "在您的手机上打开 Telegram"; +"Login.QR1.Help3" = "使用手机扫描二维码以确认登录"; +"Login.QR2.Help2" = "请转到“设置” > “设备” > “关联桌面设备”"; +"Login.RecoveryMailFailed" = "因为您在设置密码时没有同时提供密码恢复邮箱,您剩下的选择就只有回忆起您的密码或重置您的帐号。"; +"Login.Register.AddPhoto.Placeholder" = "添加\n照片"; +"Login.Register.Desc" = "请输入您的姓名并添加头像。"; +"Login.Register.FirstName.Placeholder" = "名字"; +"Login.Register.LastName.Placeholder" = "姓氏"; +"Login.ResetAccount" = "重置帐号"; +"Login.ResetAccountDescription" = "如果您确认重置帐号,您将丢失 Telegram 中所有的聊天、消息、媒体和文件。"; +"Login.ResetAccountText" = "重置帐号"; +"Login.SendSmsIfNotReceivedAppCode" = "未收到验证码?"; +"Login.Sms.AppErr" = "如果您已注册Telegram,请输入发送到你的Telegram app的验证码。\n\n如果您尚未注册,请先通过手机或平板电脑注册。"; +"Login.Sms.AppErr.GotoSite" = "打开网站"; +"Login.WelcomeDescription" = "欢迎使用官方 macOS 客户端"; +"Login.WrongPhraseError" = "不正确,请再试一次。"; +"Login.YourCodeLabel" = "验证码"; +"Login.YourPasswordLabel" = "密码"; +"Login.YourPhoneLabel" = "电话号码"; +"Login.codePlaceholder" = "验证码"; +"Login.countryLabel" = "国家/地区"; +"Login.passwordPlaceholder" = "密码"; +"Login.phoneFieldPlaceholder" = "手机号码"; +"Login.willCall" = "Telegram 会在 %d:%@ 后使用电话呼叫您播放验证码"; +"Login.willSendSms" = "我们会在 %d:%@ 后给您发送短信"; +"LoginEmail.Description" = "请新增您的电子邮件地址以保持您对账号的访问权限。"; +"LoginEmail.Success.Text" = "您的账号已受到保护!"; +"LoginEmail.Success.Title" = "已添加电子邮箱地址"; +"LoginEmail.Title" = "添加邮箱"; +"LoginWidget.Allow" = "允许"; +"LoginWidget.AllowMessages" = "允许发送消息"; +"LoginWidget.Cancel" = "取消"; +"LoginWidget.Confirmation.TapNumber" = "点击另一台设备上显示的二维码"; +"LoginWidget.Confirmation.VerifyIdentity" = "Telegram 想确认这确实是您本人操作。"; +"LoginWidget.Deny" = "拒绝"; +"LoginWidget.Device" = "设备"; +"LoginWidget.IPAddress" = "IP 地址"; +"LoginWidget.LogIn" = "登录"; +"LoginWidget.LogInTo" = "登录到 %1$@"; +"LoginWidget.LogInToApp" = "登录到应用 %1$@"; +"LoginWidget.LoggedInNoPhone" = "您已登录 %1$@,但您未授予存取您手机号码的权限。"; +"LoginWidget.LoggedInSuccess" = "您现在已登录 %1$@。"; +"LoginWidget.LoginAttemptInfo" = "本次登录尝试来自上方设备。"; +"LoginWidget.PermissionApplyTo" = "此权限将应用于 [%1$@]()。"; +"LoginWidget.PermissionInfo" = "此网站将接收您的\u00a0**姓名**、**用户名**\u00a0和\u00a0**个人资料页头像**。"; +"LoginWidget.PermissionPhone" = "此网站将收到您的 **手机号码**。"; +"LoginWidget.PhoneAccessInfo" = "**%1$@** 想要访问您的手机号码 **%2$@**。\n\n允许访问?"; +"LoginWidget.PhoneNumber" = "电话号码"; +"LoginWidget.TryAgain" = "请尝试再次登录 %1$@。"; +"LoginWidget.UnverifiedApp" = "未经验证的应用"; +"LogoutOptions.AddAccountText" = "连接其他 Telegram 帐户并轻松切换。"; +"LogoutOptions.AddAccountTitle" = "添加其他帐号"; +"LogoutOptions.AlternativeOptionsSection" = "其他选项"; +"LogoutOptions.ChangePhoneNumberText" = "将您的联系人、群组、消息以及媒体文件转移到新号码上。"; +"LogoutOptions.ChangePhoneNumberTitle" = "更换手机号码"; +"LogoutOptions.ClearCacheText" = "释放设备上的磁盘空间;您的资料将留在云端。"; +"LogoutOptions.ClearCacheTitle" = "清空缓存"; +"LogoutOptions.ContactSupportText" = "如遇问题,请告诉我们;登出通常无法解决此问题。"; +"LogoutOptions.ContactSupportTitle" = "联系客服"; +"LogoutOptions.LogOut" = "登出"; +"LogoutOptions.LogOutInfo" = "请注意,退出会清空您所有的私密聊天记录。"; +"LogoutOptions.SetPasscodeText" = "使用锁定码锁定应用程序以禁止他人打开。"; +"LogoutOptions.SetPasscodeTitle" = "设置锁定码"; +"LogoutOptions.Title" = "登出"; +"Logs.Enabled" = "日志已启用"; +"MESSAGE_INVOICE" = "%1$@ 向您发送了一份金额为 %2$@ 的账单"; +"ManageLinks.AdditionLinks" = "额外链接"; +"ManageLinks.Admin.Permanent.Desc" = "**%1$@** 将可以看到此链接并邀请新成员至 **%2$@**."; +"ManageLinks.Context.Copy" = "拷贝链接"; +"ManageLinks.Context.Edit" = "编辑链接"; +"ManageLinks.Context.Revoke" = "撤销链接"; +"ManageLinks.Context.Share" = "分享链接"; +"ManageLinks.CreateNew" = "新建一个链接"; +"ManageLinks.Delete" = "删除"; +"ManageLinks.DeleteAll" = "删除所有已撤销的链接"; +"ManageLinks.DeleteAll.Confirm" = "您确定要删除所有已撤销的链接吗?"; +"ManageLinks.EmptyDesc" = "您可以创建额外的邀请链接,这些链接有一定的时间限制或使用次数。"; +"ManageLinks.Header.Channel.Desc" = "任何已安装 Telegram 的用户都可以通过此链接加入您的频道。"; +"ManageLinks.Header.Desc" = "任何已安装 Telegram 的人都可以通过点击此链接加入您的群组。"; +"ManageLinks.Header.Group.Desc" = "任何已安装 Telegram 的用户都将可以通过此链接加入您的群组。"; +"ManageLinks.InviteLink" = "邀请链接"; +"ManageLinks.OtherAdmins" = "由其他管理员创建的邀请链接"; +"ManageLinks.Permanent" = "永久链接"; +"ManageLinks.RevokedLinks" = "撤销的链接"; +"ManageLinks.Title" = "标题"; +"ManageLinks.TitleCount#other" = "%d 条邀请链接"; +"ManageLinks.TitleCount_countable" = "%d 个"; +"ManageLinks.TitleNew" = "邀请链接"; +"Mark.Fake" = "假冒"; +"Mark.Scam" = "欺诈"; +"MediaCell.Unsupported" = "不支持"; +"MediaSender.DiscardChanges.Header" = "放弃更改"; +"MediaSender.DiscardChanges.OK" = "放弃"; +"MediaSender.DiscardChanges.Text" = "您确定放弃所有更改?"; +"Message.AccessoryPanel.Forwarded#other" = "%d 条转发的消息"; +"Message.AccessoryPanel.Forwarded_countable" = "%d"; +"Message.ActionsPanel.Delete" = "删除"; +"Message.ActionsPanel.EmptySelected" = "选择消息"; +"Message.ActionsPanel.Forward" = "转发"; +"Message.ActionsPanel.SelectedCount#other" = "已选择 %d 条消息"; +"Message.ActionsPanel.SelectedCount_countable" = "%d"; +"Message.Context.Confirm.NotifyPin" = "通知所有成员"; +"Message.Context.Confirm.Pin1" = "您确定要置顶此消息吗?"; +"Message.Context.Delete" = "删除"; +"Message.Context.Edit" = "编辑"; +"Message.Context.Forward" = "转发"; +"Message.Context.ForwardToCloud" = "转发到我的收藏"; +"Message.Context.Goto" = "显示消息"; +"Message.Context.OpenWith" = "打开方式"; +"Message.Context.Pin" = "置顶"; +"Message.Context.RemoveGif" = "移除 GIF"; +"Message.Context.Reply1" = "回复"; +"Message.Context.ReplyHelp" = "双击"; +"Message.Context.Report" = "举报"; +"Message.Context.Report.AlertOK" = "非常感谢!我们的团队将会核查您的举报。"; +"Message.Context.SaveGif" = "添加 GIF"; +"Message.Context.Select" = "选择"; +"Message.Context.Share" = "分享"; +"Message.Context.Unpin" = "取消置顶"; +"Message.Context.ViewComments#other" = "查看 %d 条评论"; +"Message.Context.ViewComments_countable" = "%d"; +"Message.Context.ViewReplies#other" = "查看 %d 条回复"; +"Message.Context.ViewReplies_countable" = "%d"; +"Message.Context.ViewThread" = "查看跟评"; +"Message.GiveawayFinished" = "抽奖活动: %2$@ %1$@"; +"Message.GiveawayOngoing" = "抽奖活动: %2$@ %1$@"; +"Message.GiveawayOngoing.Winners#other" = "将选定 %d 名获奖者"; +"Message.GiveawayOngoing.Winners_countable" = "%d"; +"Message.GiveawayResult" = "赠品活动结果"; +"Message.GiveawayStarted" = "频道已发起抽奖活动"; +"Message.GiveawayStartedOther" = "%@ 已发起一场抽奖活动"; +"Message.InvoiceLabel" = "账单"; +"Message.PaymentSent" = "付款:%@"; +"Message.PinnedInvoice" = "已置顶一份账单"; +"Message.Placeholder.Edit" = "编辑消息…"; +"Message.Placeholder.MessageInGeneral" = "一般话题中的消息..."; +"Message.Placeholder.PaidMessage" = "每条消息 ⭐️%@..."; +"Message.Placeholder.ReplyToTopic" = "回复 %@…"; +"Message.ReplyActionButtonShowReceipt" = "显示收据"; +"Message.Status.Archive.Failed" = "压缩失败"; +"Message.Status.Archive.FailedSizeLimit" = "超过了文件大小限制"; +"Message.Status.ArchivePreparing" = "准备压缩文件中"; +"Message.Status.Archived" = "压缩的文件夹"; +"Message.Status.Archiving" = "已压缩 %d%"; +"Message.Status.Archiving1" = "%@ 已归档"; +"Message.Text.CopyMusicTitle" = "拷贝音频名称"; +"MessageContext.CopyMessageLink1" = "拷贝消息链接"; +"MessagePollStats.Title" = "投票统计数据"; +"MessagePollStats.VoteTimeline" = "投票时间轴"; +"MessageTimer.ShortDays" = "%@天"; +"MessageTimer.ShortHours" = "%@小时"; +"MessageTimer.ShortMinutes" = "%@分钟"; +"MessageTimer.ShortMonths" = "%@个月"; +"MessageTimer.ShortSeconds" = "%@秒"; +"MessageTimer.ShortWeeks" = "%@周"; +"Messages.DeletedMessage" = "已删除消息"; +"Messages.File.State.FetchingIn_1" = "已下载 %d%%"; +"Messages.File.State.FetchingIn_2" = "%@ 已下载"; +"Messages.File.State.FetchingOut_1" = "已上传 %d%"; +"Messages.File.State.FetchingOut_2" = "%@ 已上传"; +"Messages.File.State.Local" = "在 Finder 中打开"; +"Messages.File.State.Remote" = "下载"; +"Messages.ForwardHeader" = "转发的消息"; +"Messages.Placeholder.Anonymous" = "匿名发送"; +"Messages.Placeholder.Broadcast" = "广播一条消息……"; +"Messages.Placeholder.Broadcast.Small" = "广播信息... "; +"Messages.Placeholder.Comment" = "评论……"; +"Messages.Placeholder.Reply" = "回复..."; +"Messages.Placeholder.Scheduled" = "预约一条定时信息..."; +"Messages.Placeholder.SentMessage" = "输入消息..."; +"Messages.Placeholder.SentMessage.Small" = "新消息..."; +"Messages.Placeholder.SilentBroadcast" = "静默广播..."; +"Messages.ReplyLoading.Header" = "回复"; +"Messages.ReplyLoading.Loading" = "加载中…"; +"Messages.Silent.Tooltip" = "消息发布后订阅者会收到通知。"; +"Messages.Silent.Tooltip.Silent" = "消息发布后订阅者会收到无声通知。"; +"Messages.UnreadMark" = "未读消息"; +"MessagesPrivacy.ChargeforMessages" = "付费消息"; +"MessagesPrivacy.ChargeforMessages.Exceptions" = "例外"; +"MessagesPrivacy.ChargeforMessages.Exceptions.Info" = "添加无需付费即可向你发送消息的用户或整个群组"; +"MessagesPrivacy.ChargeforMessages.Exceptions.RemoveFee" = "免收费用"; +"MessagesPrivacy.ChargeforMessages.Info" = "向非联系人或从未向你发送过消息的人收取消息费用。"; +"MessagesPrivacy.ChargeforMessages.Select.Header" = "设置您每条消息的价格"; +"MessagesPrivacy.ChargeforMessages.Select.Info" = "每逢收到一条消息,你将获取设置费用的 %1$@(~%2$@)。"; +"Modal.Apply" = "应用"; +"Modal.Cancel" = "取消"; +"Modal.CopyLink" = "拷贝链接"; +"Modal.Create" = "创建"; +"Modal.Delete" = "删除"; +"Modal.Done" = "完成"; +"Modal.NotNow" = "暂时不要"; +"Modal.OK" = "确定"; +"Modal.Remove" = "移除"; +"Modal.Report" = "举报"; +"Modal.Reset" = "重置"; +"Modal.Save" = "保存"; +"Modal.Send" = "发送"; +"Modal.Set" = "设置"; +"Modal.Share" = "分享"; +"Modal.Yes" = "确定"; +"Monetization.AdRevenueTitle" = "广告收益"; +"Monetization.Balance.ComingLaterInfo" = "您可以使用 Fragment 领取奖励,Fragment 是广告商用来支付广告费用的第三方平台。 [条款与细则 >](https://telegram.org/tos/content-creator-rewards)"; +"Monetization.BalanceInfo" = "您可以使用 Fragment 领取奖励,Fragment 是广告商用来支付广告费用的第三方平台。 [条款与细则 >](https://telegram.org/tos/content-creator-rewards)"; +"Monetization.BalanceInfo_URL" = "https://telegram.org/blog/monetization-for-channels"; +"Monetization.BalanceTitle" = "可用奖励"; +"Monetization.BalanceWithdraw" = "通过 Fragment 领取"; +"Monetization.BalanceWithdraw.Stars" = "通过 Fragment 提现"; +"Monetization.BalanceWithdraw.Ton" = "通过 Fragment 提现"; +"Monetization.Header" = "Telegram 会将您频道中显示的广告收入的 50%@ 分享给您作为奖励 。 [了解更多 >]()"; +"Monetization.Header.Bot" = "Telegram 将分享您的机器人中显示的广告所带来收入的 50%@ 作为奖励。[了解更多 >]()"; +"Monetization.ImpressionsTitle" = "广告显示次数"; +"Monetization.Intro.Ads.Text" = "Telegram 能够在你的频道中展示广告。"; +"Monetization.Intro.Ads.Title" = "Telegram 广告"; +"Monetization.Intro.Info.Text" = "TON 是一个区块链平台和加密货币,因其高性能和低佣金而被 Telegram 采用。[了解更多 >](ton.org)"; +"Monetization.Intro.Info.Title" = "什么是 %@ TON?"; +"Monetization.Intro.Split.Text" = "您将以 TON 收到 50%@ 的广告收入分成。"; +"Monetization.Intro.Split.Title" = "50:50 收入分成"; +"Monetization.Intro.Title" = "从你的频道中赚取收益"; +"Monetization.Intro.Understood" = "已知悉"; +"Monetization.Intro.Withdrawal.Text" = "您可以随时领取您的 TON。"; +"Monetization.Intro.Withdrawal.Title" = "灵活提现"; +"Monetization.IntroBot.Ads.Text" = "Telegram 可以在你的机器人中显示广告。"; +"Monetization.IntroBot.Title" = "通过你的机器人赚取收益"; +"Monetization.Overview.Available" = "可领取的奖励"; +"Monetization.Overview.Current" = "自上次领取后的奖励"; +"Monetization.Overview.Total" = "累计总收益"; +"Monetization.OverviewTitle" = "收益总览"; +"Monetization.Stars.WithdrawInfo" = "您可以使用 Fragment 领取星星奖励,或使用星星来宣传您的机器人。[了解更多 >](https://telegram.org)"; +"Monetization.Stars.WithdrawTitle" = "星星余额"; +"Monetization.SwitchOffAds" = "关闭广告"; +"Monetization.SwitchOffAdsInfo" = "如果您关闭广告,您将没有资格获得任何奖励。"; +"Monetization.Transaction.Failed" = "未完成"; +"Monetization.Transaction.Pending" = "处理中"; +"Monetization.Transaction.Proceeds" = "广告收益"; +"Monetization.Transaction.Refund" = "退款"; +"Monetization.Transaction.ShowMoreTransactions#other" = "显示另外 %d 笔交易"; +"Monetization.Transaction.ShowMoreTransactions_countable" = "显示另外 %d 笔交易"; +"Monetization.Transaction.Withdrawal" = "通过 %@ 领取"; +"Monetization.TransactionInfo.Failed" = "未完成"; +"Monetization.TransactionInfo.Pending" = "处理中"; +"Monetization.TransactionInfo.Proceeds" = "从此处显示的广告中取得的奖励"; +"Monetization.TransactionInfo.Refund" = "退款"; +"Monetization.TransactionInfo.ViewInExplorer" = "在区块链检索器中查看"; +"Monetization.TransactionInfo.Withdrawal" = "通过 %@ 领取奖励"; +"Monetization.Transactions.Stars" = "星星交易"; +"Monetization.Transactions.TON" = "TON 交易"; +"Monetization.TransactionsTitle" = "交易历史"; +"Monetization.Withdraw.EnterPassword.Done" = "继续"; +"Monetization.Withdraw.EnterPassword.Text" = "请输入两步验证码以完成此操作。"; +"Monetization.Withdraw.EnterPassword.Title" = "输入密码"; +"Monetization.Withdraw.Error.Text" = "满足以下条件后才能领取奖励:\n\n• 您的帐号启用了两步验证已超过 **7 天**。\n\n• 您在此设备上登录已超过 **24 小时**。\n\n请稍后再来。"; +"Monoforum.SentBy" = "由 [%@]() 发送"; +"Navigation.Add" = "添加"; +"Navigation.Cancel" = "取消"; +"Navigation.Close" = "关闭"; +"Navigation.Done" = "完成"; +"Navigation.Edit" = "编辑"; +"Navigation.Next" = "下一步"; +"Navigation.back" = "返回"; +"NetworkUsage.BytesReceived" = "收取字节"; +"NetworkUsage.BytesSent" = "发送字节"; +"NetworkUsage.Header.Audio" = "音频"; +"NetworkUsage.Header.Files" = "文件"; +"NetworkUsage.Header.Generic" = "消息"; +"NetworkUsage.Header.Images" = "照片"; +"NetworkUsage.Header.Videos" = "视频"; +"NetworkUsage.NetworkUsage" = "网络用量"; +"NetworkUsage.NetworkUsage.Reset" = "重置网络使用情况"; +"NetworkUsage.NetworkUsageSince" = "自 %@ 的网络用量"; +"NetworkUsage.NetworkUsageSinceNever" = "自您登录此设备以来的储存空间使用情况。"; +"NetworkUsage.Reset" = "重置统计数据"; +"NetworkUsage.ResetConfirm.Info" = "您确定要重置统计数据吗?"; +"NetworkUsage.ResetConfirm.Ok" = "重置"; +"NetworkUsage.Section.Info" = "点击各部分以查看详细信息。"; +"NetworkUsage.Title.Audio" = "音乐"; +"NetworkUsage.Title.Files" = "文件"; +"NetworkUsage.Title.Generic" = "消息"; +"NetworkUsage.Title.Images" = "照片"; +"NetworkUsage.Title.Videos" = "视频"; +"NewContact.Exception.ShareMyPhoneNumber" = "分享我的手机号码"; +"NewContact.Exception.ShareMyPhoneNumber.Desc" = "您可将手机号设置为对 %@ 可见。"; +"NewContact.Phone" = "手机号码"; +"NewContact.Phone.Hidden" = "隐藏"; +"NewContact.Phone.Hidden.Text" = "一旦 %@ 将您添加为联系人,手机号将对其可见。"; +"NewContact.Title" = "新建联系人"; +"NewPoll.Add" = "添加"; +"NewPoll.AddOptionTitle" = "添加选项"; +"NewPoll.AllowAddingDesc" = "参与者可以提出新的选项"; +"NewPoll.AllowAddingOptions" = "允许添加选项"; +"NewPoll.AllowMultipleAnswers" = "允许多选"; +"NewPoll.AllowMultipleDesc" = "投票者可以选择多个选项"; +"NewPoll.AllowRevoting" = "允许重新投票"; +"NewPoll.AllowRevotingDesc" = "投票者可以更改投票选择"; +"NewPoll.AllowedCountries" = "允许的国家或地区"; +"NewPoll.Anonymous" = "匿名投票"; +"NewPoll.AttachDelete" = "删除"; +"NewPoll.AttachFile" = "文件"; +"NewPoll.AttachLocation" = "位置"; +"NewPoll.AttachPhotoVideo" = "视频或音频"; +"NewPoll.AttachSticker" = "贴纸"; +"NewPoll.CorrectAnswerDescMultiple" = "选择一个或多个选项作为正确答案"; +"NewPoll.CorrectAnswerDescSingle" = "选择一个选项作为正确答案"; +"NewPoll.CountriesCount#other" = "%1$d 个国家或地区"; +"NewPoll.CountriesNone" = "无"; +"NewPoll.Custom" = "自定义"; +"NewPoll.DateFormat" = "MMM d '于' HH:mm"; +"NewPoll.Deadline" = "截止日期"; +"NewPoll.DescriptionPlaceholder" = "添加简介 (可选)"; +"NewPoll.DisacardConfirm" = "您确定要放弃此次投票吗?"; +"NewPoll.DisacardConfirm.No" = "取消"; +"NewPoll.DisacardConfirm.Yes" = "确定"; +"NewPoll.DisacardConfirmHeader" = "投票"; +"NewPoll.Duration" = "时长"; +"NewPoll.Duration.1Hour" = "1 小时"; +"NewPoll.Duration.24Hours" = "24 小时"; +"NewPoll.Duration.3Hours" = "3 小时"; +"NewPoll.Duration.72Hours" = "72 小时"; +"NewPoll.Duration.8Hours" = "8 小时"; +"NewPoll.Duration.Hours#other" = "%1$d 小时"; +"NewPoll.Explanation.Desc" = "选错的用户将看到这段文字,因此非常适合教育用途。"; +"NewPoll.Explanation.Header" = "注释"; +"NewPoll.Explanation.Placeholder" = "添加说明(可选)"; +"NewPoll.HideResults" = "隐藏结果"; +"NewPoll.HideResultsDesc" = "若启用此功能,投票结束后才会显示结果。"; +"NewPoll.LimitByCountry" = "按国家限制"; +"NewPoll.LimitByCountryDesc" = "仅所选国家用户可投票。"; +"NewPoll.LimitDuration" = "限制时长"; +"NewPoll.LimitDurationDesc" = "在指定时间自动关闭投票"; +"NewPoll.MediaUploading" = "投票媒体仍在上传中。"; +"NewPoll.MultipleChoice" = "可多选"; +"NewPoll.OptionsAddOption" = "添加一个选项"; +"NewPoll.OptionsDescription#other" = "您可以添加 %d 个选项。"; +"NewPoll.OptionsDescriptionLimitReached" = "您添加的选项数量已经达到上限。"; +"NewPoll.OptionsDescriptionMinimum#other" = "至少需要 %d 个选项"; +"NewPoll.OptionsDescriptionMinimum_countable" = "%d"; +"NewPoll.OptionsDescription_countable" = "%d"; +"NewPoll.OptionsHeader" = "投票选项"; +"NewPoll.OptionsPlaceholder" = "选项"; +"NewPoll.PollEnds" = "投票结束于"; +"NewPoll.QuestionHeader" = "问题"; +"NewPoll.QuestionHeaderLimit" = "问题 (%d)"; +"NewPoll.QuestionPlaceholder" = "请输入问题"; +"NewPoll.Quiz" = "问答模式"; +"NewPoll.QuizDesc" = "问答模式下的投票仅有一个正确答案,且用户无法撤回其选择。"; +"NewPoll.QuizMultiple.Error" = "问答模式下的投票只能有一个正确答案。"; +"NewPoll.QuizTooltip" = "点击选择正确的答案"; +"NewPoll.RestrictToSubscribers" = "仅限订阅用户"; +"NewPoll.RestrictToSubscribersDesc" = "仅已加入 24 小时以上的订阅用户可投票"; +"NewPoll.SetCorrectAnswer" = "设置正确答案"; +"NewPoll.SetDeadline" = "截止日期"; +"NewPoll.SettingsHeader" = "设置"; +"NewPoll.ShowWhoVoted" = "显示投票者名单"; +"NewPoll.ShowWhoVotedDesc" = "在每个选项上显示投票者名称"; +"NewPoll.ShuffleDesc" = "每位投票者的答案以随即顺序显示"; +"NewPoll.ShuffleOptions" = "随机选项"; +"NewPoll.Title" = "发起投票"; +"NewPoll.Title.Quiz" = "新的问答"; +"NewSession.IsntMe" = "这不是我!"; +"NewSession.ItsMe" = "是的,是我本人"; +"NewSessionReview.Accept.Text" = "您可以在 [设置 > 活跃会话]() 中查看您的活跃登录列表。"; +"NewSessionReview.Accept.Title" = "已允许新登录"; +"NewSessionReview.Item.Text" = "我们检测到您的账号有一次来自 %1$@、%2$@ 的新登录。这是您本人操作的吗?"; +"NewSessionReview.Item.Title" = "有人刚刚访问了您的消息!"; +"NewSessionReview.Modal.Button" = "明白了"; +"NewSessionReview.Modal.Button.Timeout" = "明白了 (%@)"; +"NewSessionReview.Modal.Header" = "切勿将您的登录代码发送给任何人,否则您可能会失去您的 Telegram 帐号!"; +"NewSessionReview.Modal.Text" = "我们已终止来自 %1$@,%2$@ 的登录尝试。"; +"NewSessionReview.Modal.Title" = "新登录已被阻止"; +"NewTheme.Create" = "创建"; +"NewTheme.Desc" = "此主题将基于您当前的主题。"; +"NewTheme.EmptyTextError" = "名称不能为空。"; +"NewTheme.Placeholder" = "主题名称"; +"NewTheme.Title" = "新建主题"; +"NewTodo.Action.Add" = "添加"; +"NewTodo.Action.Edit" = "编辑"; +"NewTodo.Action.Send" = "发送"; +"NewTodo.Input.Task.Placeholder" = "任务"; +"NewTodo.Input.Title.Placeholder" = "标题..."; +"NewTodo.Option.Add" = "添加任务"; +"NewTodo.Section.Checklist" = "核对清单"; +"NewTodo.Title.AddTask" = "添加任务"; +"NewTodo.Title.Create" = "新核对清单"; +"NewTodo.Title.Edit" = "编辑核对清单"; +"NewTodo.Toggle.OthersCanAppend" = "允许其他人新增任务"; +"NewTodo.Toggle.OthersCanComplete" = "允许其他人标记为已完成"; +"Notification.Boost.Multiple" = "%1$@ 已助推群组 %2$@"; +"Notification.Boost.MultipleYou" = "您已助推群组 %1$@"; +"Notification.Boost.Single" = "%1$@ 已助推群组"; +"Notification.Boost.SingleYou" = "您已助推群组"; +"Notification.Boost.Times#other" = "%@ 次"; +"Notification.Boost.Times_countable" = "%d"; +"Notification.Contact.Reacted" = "对您的 “%2$@” %1$@"; +"Notification.Group.Reacted" = "%1$@: %2$@ 到你的 ”%3$@\""; +"Notification.GroupCall.Incoming" = "呼入群组通话"; +"Notification.GroupCall.Outgoing" = "拨出群组通话"; +"Notification.GroupCreatorChangeApplied" = "%1$@ 已将此群组的所有权转让给 %2$@。"; +"Notification.GroupCreatorChangePending" = "如果 %1$@ 7 天后仍未返回,则 %2$@ 将成为新的主管理员。"; +"Notification.Input.Reply" = "输入消息..."; +"Notification.LockedPreview" = "您有一条新消息"; +"Notification.MarkAsRead" = "标记为已读"; +"Notification.PaidMessage" = "%1$@ 已支付 %2$@ 以发送消息"; +"Notification.PaidMessageMany" = "%1$@ 已支付 %2$@ 以发送 %3$@"; +"Notification.PaidMessagePriceChanged" = "%1$@ 已将价格更改为每条消息 %2$@"; +"Notification.PaidMessagePriceChangedYou" = "您将价格更改为每条消息 %1$@ 颗星星"; +"Notification.PaidMessageRefund" = "%1$@ 已将 %2$@ 颗星星退还给您"; +"Notification.PaidMessageRefundYou" = "您已将 %1$@ 退还给 %2$@"; +"Notification.PaidMessageYou" = "你已支付 %1$@ 以发送一条消息"; +"Notification.PaidMessageYouMany" = "你已支付 %1$@ 以发送 %2$@"; +"Notification.PaidMessages#other" = "%d 条消息"; +"Notification.PaidMessages_countable" = "%d"; +"Notification.ProximityReached" = "%@ 距离 %@ %@"; +"Notification.ProximityReachedYou" = "%@ 现在距离您还有 %@"; +"Notification.ProximityReachedYou_1" = "%1$@ 现在距离您有 %2$@"; +"Notification.ProximityReached_1" = "%1$@ 现在距离 %3$@ 还有 %2$@"; +"Notification.ProximityYouReached_1" = "你离 %2$@ 还有 %1$@"; +"Notification.Reminder" = "📆 提醒"; +"Notification.Reply" = "回复"; +"Notification.ScheduledTitle" = "📆 您"; +"Notification.StarGift.Self.Title" = "已保存的礼物"; +"Notification.StarsGift.Self.AuctionBought" = "您已成功在拍卖中以 %@ 的价格购买了一份礼物。"; +"Notification.StarsGift.Self.Bought" = "你以 %@ 购买了一份礼物"; +"Notification.StarsGift.Subtitle.Self" = "您可以将此礼物展示添加至个人资料页,或者将其转换为独一无二的可转赠收藏品。"; +"Notification.StarsGift.Transfer" = "%@ 已转让给你一件独家典藏品"; +"Notification.StarsGift.TransferYou" = "你转让了一件独家典藏品"; +"Notification.StarsGift.Upgrade" = "%@ 将您的礼物转换为了独家典藏品"; +"Notification.StarsGift.Upgrade.Channel" = "%@ 将继续转换成了独家典藏品"; +"Notification.StarsGift.UpgradeFromOther" = "您为来自 **%@** 的礼物 **%@** 发送了价值 **%@** 的升级机会"; +"Notification.StarsGift.UpgradeGeneric" = "您已将价值 **%@** 的升级作为礼物发送给 **%@**"; +"Notification.StarsGift.UpgradePrepaid" = "%@ 打开了您协助升级的礼物"; +"Notification.StarsGift.UpgradePrepaidYou" = "您打开了 %@ 协助升级的礼物"; +"Notification.StarsGift.UpgradeToSelf" = "您赠送了价值 **%@** 的升级机会给您收到的礼物 **%@**"; +"Notification.StarsGift.UpgradeYou" = "你将 %@ 赠送的礼物转换为了独家典藏品"; +"Notification.StoryReply" = "已回复您的动态:%@"; +"Notification.Title.Reply" = "回复"; +"NotificationSettings.AllAccounts" = "全部账号"; +"NotificationSettings.BadgeDesc" = "关闭以显示未读聊天数而非未读消息数。"; +"NotificationSettings.BadgeEnabled" = "已启用"; +"NotificationSettings.BadgeHeader" = "角标计数器"; +"NotificationSettings.BounceDockIcon" = "弹出状态栏图标"; +"NotificationSettings.Confirm.Reset" = "重置通知设置"; +"NotificationSettings.ContactJoined" = "新联系人"; +"NotificationSettings.ContactJoinedInfo" = "当您的某个联系人在Telegram上可用时接收通知。"; +"NotificationSettings.CountUnreadMessages" = "统计未读消息"; +"NotificationSettings.IncludeChannels" = "包括频道"; +"NotificationSettings.IncludeGroups" = "包含群组"; +"NotificationSettings.IncludeMutedChats" = "包含已静音的聊天"; +"NotificationSettings.IncludePublicGroups" = "包括公开群组"; +"NotificationSettings.MessagesPreview" = "消息预览"; +"NotificationSettings.NotificationTone" = "通知声音"; +"NotificationSettings.ResetNotifications" = "重置通知设置"; +"NotificationSettings.ResetNotificationsText" = "您可以在下面为特定对话设置自定义通知。"; +"NotificationSettings.SendMessageEffect" = "发送消息"; +"NotificationSettings.ShowNotificationsFrom" = "显示通知"; +"NotificationSettings.ShowNotificationsFrom.Off" = "打开将从所有账户获取消息提醒。"; +"NotificationSettings.ShowNotificationsFrom.On" = "关闭仅从当前账号获取消息提醒。"; +"NotificationSettings.Snoof" = "显示通知"; +"NotificationSettings.Snoof.Off" = "如果您想要始终收到通知,请启用此选项。"; +"NotificationSettings.Snoof.On" = "如果您只想在应用程序没有获取焦点时接收通知,请将其关闭。"; +"NotificationSettings.SnoofHeader" = "当应用程序获取焦点时"; +"NotificationSettings.SoundEffects" = "声音特效"; +"NotificationSettings.ToggleNotifications" = "通知"; +"NotificationSettings.ToggleNotifications.Header" = "通知"; +"NotificationSettings.Tone.Default" = "默认"; +"NotificationSettings.TurnOn" = "在系统设置允许"; +"NotificationSettings.TurnOn.Text.Text" = "别错过来自家人和朋友的重要消息。"; +"NotificationSettings.TurnOn.Text.Title" = "允许通知"; +"NotificationSettingsToneNone" = "无"; +"NotificationSound.Title" = "通知声音"; +"NotificationSound.Tone.Voice" = "语音消息"; +"NotificationSound.Tones.Info" = "在任意聊天中打开任意简短语音消息或 MP3 文件的快捷菜单,然后选择 “保存为通知铃声” 后,就会出现在这里。"; +"NotificationSound.Tones.SizeError" = "文件大小应小于等于 %@"; +"NotificationSound.Tones.Title" = "Telegram 提示音"; +"NotificationSound.Tones.Upload" = "上传铃声"; +"Notifications.Snooze" = "关闭通知"; +"NotificationsSound.Alert" = "提醒"; +"NotificationsSound.Aurora" = "极光"; +"NotificationsSound.Bamboo" = "竹子"; +"NotificationsSound.Bell" = "铃声"; +"NotificationsSound.Calypso" = "即兴曲调"; +"NotificationsSound.Chime" = "风铃"; +"NotificationsSound.Chord" = "和弦"; +"NotificationsSound.Circles" = "圆圈"; +"NotificationsSound.Complete" = "完成"; +"NotificationsSound.Glass" = "玻璃声"; +"NotificationsSound.Hello" = "哈喽"; +"NotificationsSound.Input" = "输入"; +"NotificationsSound.Keys" = "按键"; +"NotificationsSound.None" = "无"; +"NotificationsSound.Note" = "音符"; +"NotificationsSound.Popcorn" = "爆米花"; +"NotificationsSound.Pulse" = "脉冲"; +"NotificationsSound.Synth" = "合成"; +"NotificationsSound.Telegraph" = "敲打电报"; +"NotificationsSound.Tremolo" = "颤音"; +"NotificationsSound.Tritone" = "三全音"; +"OY7-WF-poV.title" = "最小化"; +"Olw-nP-bQN.title" = "隐藏"; +"PASSWORD_HASH_INVALID" = "密码不正确"; +"PHONE_CODE_EXPIRED" = "验证码已失效,请重新登录。"; +"PHONE_CODE_INVALID" = "验证码无效"; +"PHONE_NUMBER_INVALID" = "无效的手机号码"; +"PINNED_INVOICE" = "%1$@ 已置顶一份账单"; +"PaidMedia.Header" = "输入解锁价格"; +"PaidMedia.Info" = "用户需将此数量的星星转账至您的频道才能查看此媒体。[关于星星的更多信息 >](https://telegram.org/blog/telegram-stars)"; +"PaidMedia.Ok" = "设为付费媒体"; +"PaidMedia.Placeholder" = "解锁所需星星数量"; +"PaidMedia.Status.Purchased" = "已购买"; +"PaidMedia.Title" = "付费内容"; +"PaidMedia.UnlockFor#other" = "花费 # %d 解锁"; +"PaidMedia.UnlockFor_countable" = "%d"; +"Passcode.AutoLock.Disabled" = "停用"; +"Passcode.AutoLock.IfAway" = "离开 %@ 分钟后"; +"Passcode.Autolock" = "自动锁定"; +"Passcode.Change" = "更改锁定码"; +"Passcode.EnterCurrentPlaceholder" = "请输入当前的锁定码"; +"Passcode.EnterNewPlaceholder" = "请输入新的锁定码"; +"Passcode.EnterPasscodePlaceholder" = "请输入锁定码"; +"Passcode.Lost.Description" = "如果您忘记了锁定码,您可以[注销]()"; +"Passcode.Next" = "下一步"; +"Passcode.Or" = "或"; +"Passcode.ReEnterPlaceholder" = "请再次输入锁定码"; +"Passcode.TurnOff" = "关闭密码锁"; +"Passcode.TurnOn" = "开启密码锁"; +"Passcode.TurnOnDescription" = "当您设置好一个附加锁定码后,你可以使用快捷键 ⌘ + L 来上锁。\n\n注意:如果您忘记这个锁定码,你将只能删除然后重新安装Telegram。重装之后您将丢失所有的私密会话。"; +"Passcode.UnlockTouchIdReason" = "解锁应用程序。"; +"Passcode.UseTouchId" = "使用 Touch ID 解锁"; +"PasscodeController.Change.Title" = "更改锁定码"; +"PasscodeController.Current.Placeholder" = "请输入当前锁定码"; +"PasscodeController.Disable.Title" = "停用锁定码"; +"PasscodeController.EnterPasscode.Placeholder" = "请输入一个锁定码"; +"PasscodeController.Error.Current" = "锁定码无效"; +"PasscodeController.Error.Different" = "锁定码不一致"; +"PasscodeController.Header.Current" = "当前锁定吗"; +"PasscodeController.Header.New" = "新密码"; +"PasscodeController.Install.Title" = "锁定码"; +"PasscodeController.ReEnterPasscode.Placeholder" = "重新输入新锁定码"; +"PasscodeController.Text" = "要锁定,请将鼠标悬停在标签栏中的「设置」图标上,然后选择「锁定 Telegram」或使用 ⌘ + L。\n\n注意:如果您忘记了本地密码,您需要登出 macOS版 Telegram , 然后重新登录。"; +"Passkeys.CreatePasskey" = "创建通行密钥"; +"Passkeys.Delete" = "删除"; +"Passkeys.DeleteConfirmation" = "您确定想要删除此处通行密钥吗?"; +"Passkeys.DeleteTitle" = "删除通行密钥"; +"Passkeys.Description" = "安全登录,保障账号安全。"; +"Passkeys.MaxLimitReached" = "抱歉,目前您只能创建 %1$d。"; +"Passkeys.Title" = "通行密钥"; +"Passlock.EnterYourPasscode" = "输入您的锁定码"; +"Passport.Language.ar" = "阿拉伯语"; +"Passport.Language.az" = "阿塞拜疆语"; +"Passport.Language.bg" = "保加利亚语"; +"Passport.Language.bn" = "孟加拉语"; +"Passport.Language.cs" = "捷克语"; +"Passport.Language.da" = "丹麦语"; +"Passport.Language.de" = "德语"; +"Passport.Language.dv" = "迪维希语"; +"Passport.Language.dz" = "不丹语"; +"Passport.Language.el" = "希腊语"; +"Passport.Language.en" = "英语"; +"Passport.Language.es" = "西班牙语"; +"Passport.Language.et" = "爱沙尼亚语"; +"Passport.Language.fa" = "波斯语"; +"Passport.Language.fr" = "法语"; +"Passport.Language.he" = "希伯来语"; +"Passport.Language.hr" = "克罗艾西亚语"; +"Passport.Language.hu" = "匈牙利语"; +"Passport.Language.hy" = "亚美尼亚语"; +"Passport.Language.id" = "印度尼西亚语"; +"Passport.Language.is" = "冰岛语"; +"Passport.Language.it" = "意大利语"; +"Passport.Language.ja" = "日语"; +"Passport.Language.ka" = "佐治亚语"; +"Passport.Language.km" = "高棉语"; +"Passport.Language.ko" = "韩语"; +"Passport.Language.lo" = "老挝语"; +"Passport.Language.lt" = "立陶宛语"; +"Passport.Language.lv" = "拉脱维亚语"; +"Passport.Language.mk" = "马其顿语"; +"Passport.Language.mn" = "蒙古语"; +"Passport.Language.ms" = "马来西亚语"; +"Passport.Language.my" = "缅甸语"; +"Passport.Language.ne" = "尼泊尔语"; +"Passport.Language.nl" = "荷兰语"; +"Passport.Language.pl" = "波兰语"; +"Passport.Language.pt" = "葡萄牙语"; +"Passport.Language.ro" = "罗马尼亚语"; +"Passport.Language.ru" = "俄语"; +"Passport.Language.sk" = "斯洛伐克语"; +"Passport.Language.sl" = "斯洛文尼亚语"; +"Passport.Language.th" = "泰语"; +"Passport.Language.tk" = "土库曼语"; +"Passport.Language.tr" = "土耳其语"; +"Passport.Language.uk" = "乌克兰语"; +"Passport.Language.uz" = "乌兹别克斯坦语"; +"Passport.Language.vi" = "越南语"; +"Passport.ResetPassword.Confirm.Header" = "忘记密码"; +"Passport.ResetPassword.Confirm.OK" = "重置"; +"Passport.ResetPassword.Confirm.Text" = "所有上传到您 Telegram 通行证的证件都将丢失。之后,您将能够上传新的证件。"; +"Payments.Invoice.AlreadyActive" = "订阅已启用"; +"Payments.Invoice.NotExists" = "账单不存在"; +"Payments.NonRecurrent.Accept" = "我接受 **%1$@** 的 [服务条款]()"; +"Payments.Paid" = "您花费 %1$@ 用于购买 %2$@。"; +"Payments.Receipt.Tip" = "小费"; +"Payments.Recurrent.Accept" = "我同意**%@**的 [服务条款]()。"; +"Payments.TipLabel" = "小费(可选)"; +"Payments.Unsupported" = "抱歉,Telegram macOS 客户端暂不支持付款。请使用我们的移动端应用进行此操作。"; +"Payments.WarninTitle" = "警告"; +"Payments.WarningText" = "无论是Telegram还是 %1$@ 都无法访问您的信用卡信息。信用卡详细信息将仅由支付系统 %2$@ 处理。\n\n付款将直接支付给 %3$@ 的开发人员。Telegram 不能提供任何保证,所以请自行承担风险。如有问题,请联系 %4$@ 的开发人员或您的银行。"; +"Peer.Activity.Chat.ChoosingSticker" = "%@正在挑选贴纸"; +"Peer.Activity.Chat.EnjoyingAnimations" = "%@ 正在观看 %@"; +"Peer.Activity.Chat.Multi.ChoosingSticker1" = "%@ 和其他 %d 人正在选择贴纸"; +"Peer.Activity.Chat.Multi.PlayingGame1" = "%@ 和其他 %d 人正在玩游戏"; +"Peer.Activity.Chat.Multi.RecordingAudio1" = "%@ 和其他 %d 人正在录制语音"; +"Peer.Activity.Chat.Multi.RecordingVideo1" = "%@ 和其他 %d 人正在录制视频"; +"Peer.Activity.Chat.Multi.SendingAudio1" = "%@ 和其他 %d 人正在发送音频"; +"Peer.Activity.Chat.Multi.SendingFile1" = "%@ 和其他 %d 人正在发送文件"; +"Peer.Activity.Chat.Multi.SendingPhoto1" = "%@ 和其他 %d 人正在发送图片"; +"Peer.Activity.Chat.Multi.SendingVideo1" = "%@ 和其他 %d 人正在发送视频"; +"Peer.Activity.Chat.Multi.TypingText1" = "%@ 和其他 %d 人正在输入"; +"Peer.Activity.Chat.PlayingGame" = "%@ 正在玩游戏"; +"Peer.Activity.Chat.RecordingAudio" = "%@ 正在录制语音"; +"Peer.Activity.Chat.RecordingVideo" = "%@ 正在录制视频"; +"Peer.Activity.Chat.SendingFile" = "%@ 正在发送文件"; +"Peer.Activity.Chat.SendingPhoto" = "%@ 正在发送图片"; +"Peer.Activity.Chat.SendingVideo" = "%@ 正在发送视频"; +"Peer.Activity.Chat.TypingText" = "%@ 正在输入"; +"Peer.Activity.User.ChoosingSticker" = "正在挑选贴纸"; +"Peer.Activity.User.EnjoyingAnimations" = "正在观看 %@"; +"Peer.Activity.User.PlayingGame" = "玩游戏"; +"Peer.Activity.User.RecordingAudio" = "正在录制语音"; +"Peer.Activity.User.RecordingVideo" = "正在录制视频"; +"Peer.Activity.User.SendingFile" = "正在发送文件"; +"Peer.Activity.User.SendingPhoto" = "正在发送图片"; +"Peer.Activity.User.SendingVideo" = "正在发送视频"; +"Peer.Activity.User.TypingText" = "正在输入"; +"Peer.CreatePeer.Context.RemovePhoto" = "移除群组图片"; +"Peer.CreatePeer.Context.UpdatePhoto" = "上传群组图片"; +"Peer.DeletedUser" = "已销号用户"; +"Peer.Forward.PremiumRequired" = "%@ 仅接受来自联系人和高级版订阅用户的消息。"; +"Peer.Media.Calendar.Media#other" = "%d 则媒体文件"; +"Peer.Media.Calendar.Media_countable" = "%d"; +"Peer.Media.Calendar.Title" = "按日期排序"; +"Peer.Media.ShareMedia.SavedMessages" = "此处将会显示媒体消息。"; +"Peer.Media.SharedFilesEmptyList" = "您可以发送或收取最大为1.5G的任意格式的文件,\n并在所有设备上查看。"; +"Peer.Media.SharedFilesEmptyList1" = "可以发送或收取最大为2G的任意格式的文件,\n并在所有设备上查看。"; +"Peer.Media.SharedLinksEmptyList" = "此聊天中分享的所有链接都会显示在这里。"; +"Peer.Media.SharedMediaEmptyList" = "快在这个对话里分享一些图片或者视频吧\n不然这个回形针会一直不开心的。"; +"Peer.Media.SharedMusicEmptyList" = "此聊天中共享的所有音乐都会在这里显示。"; +"Peer.Media.SharedVoiceEmptyList" = "此对话中共享的所有语音和视频消息都会在这里显示。"; +"Peer.MyNotes" = "我的笔记"; +"Peer.Rating.Negative.Other" = "负面评级表示 %@ 的付款不可靠。"; +"Peer.Rating.Negative.Self" = "负面评级表示您的付款不可靠。请花费 **%@** 来解決此问题。"; +"Peer.RatingPreview.Accept" = "已知悉"; +"Peer.RatingPreview.Badge.Added" = "已享有"; +"Peer.RatingPreview.Badge.Deducted" = "已扣除"; +"Peer.RatingPreview.Info.Header" = "此评级反映了 **%@** 在 Telegram 上的活跃度。\n评级基于以下因素:"; +"Peer.RatingPreview.Info.Header.You" = "此评级反映了您在 Telegram 上的活跃度。\n评级基于以下因素:"; +"Peer.RatingPreview.Info.Pending#other" = "评级将在购买 21 天后更新。%d 点数待定。"; +"Peer.RatingPreview.Info.Pending_countable" = "%d"; +"Peer.RatingPreview.Level" = "等级 %d"; +"Peer.RatingPreview.NextLevel" = "等级 %d"; +"Peer.RatingPreview.Refunds.Header" = "退款与转换"; +"Peer.RatingPreview.Refunds.Text" = "10 倍的退款星星和 %@ 礼物转换为星星的比率。"; +"Peer.RatingPreview.Telegram.Header" = "来自 Telegram 的礼物"; +"Peer.RatingPreview.Telegram.Text" = "%@ 的星星用于从 Telegram 购买礼物。"; +"Peer.RatingPreview.Title" = "评级"; +"Peer.RatingPreview.Users.Header" = "来自用户的礼物和帖子"; +"Peer.RatingPreview.Users.Text" = "%@ 的星星用于转售礼物、付费消息和频道帖子。"; +"Peer.RepliesNotifications" = "回复通知"; +"Peer.ReportStory" = "转发动态"; +"Peer.SavedMessages" = "我的收藏"; +"Peer.ServiceNotifications" = "服务通知"; +"Peer.Status.LastSeenAt" = "最近上线于%@ %@"; +"Peer.Status.Member#other" = "%d 位成员"; +"Peer.Status.Member.Online#other" = "%d 人在线"; +"Peer.Status.Member.Online_countable" = "%d"; +"Peer.Status.Member_countable" = "%d"; +"Peer.Status.Subscribers#other" = "%d 位订阅者"; +"Peer.Status.Subscribers_countable" = "%d"; +"Peer.Status.Today" = "今天"; +"Peer.Status.Users#other" = "%d 名每月活跃用户"; +"Peer.Status.Users_countable" = "%d"; +"Peer.Status.Yesterday" = "昨天"; +"Peer.Status.channel" = "频道"; +"Peer.Status.forum" = "话题讨论"; +"Peer.Status.group" = "群组"; +"Peer.Status.hidden" = "最近上线时间已隐藏"; +"Peer.Status.justNow" = "上线于不久前"; +"Peer.Status.lastMonth" = "最近一个月内有上线"; +"Peer.Status.lastWeek" = "最近一周内曾上线"; +"Peer.Status.longTimeAgo" = "很久没上线"; +"Peer.Status.minAgo#other" = "最近上线于 %d 分钟前"; +"Peer.Status.minAgo_countable" = "%d"; +"Peer.Status.online" = "在线"; +"Peer.Status.recently" = "最近曾上线"; +"Peer.Status.show" = "何时?"; +"PeerCall.Screen.E2E" = "此通话为端对端加密"; +"PeerCall.Screen.E2E.Info" = "如果 **%1$@** 屏幕上的表情和此处一致,那么本次通话就是 %2$@ 安全的。"; +"PeerInfo.About.Placeholder" = "简介"; +"PeerInfo.Action.AcrhivedStories" = "已归档的动态"; +"PeerInfo.Action.AddMembers" = "添加"; +"PeerInfo.Action.BoostChannel" = "助推频道"; +"PeerInfo.Action.BoostGroup" = "助推群组"; +"PeerInfo.Action.Call" = "呼叫"; +"PeerInfo.Action.DeleteGroup" = "删除"; +"PeerInfo.Action.Discussion" = "讨论"; +"PeerInfo.Action.GiftPremium" = "赠予高级版订阅"; +"PeerInfo.Action.JoinChannel" = "加入"; +"PeerInfo.Action.Leave" = "离开"; +"PeerInfo.Action.LiveStream" = "直播"; +"PeerInfo.Action.Message" = "消息"; +"PeerInfo.Action.More" = "更多"; +"PeerInfo.Action.Mute" = "静音"; +"PeerInfo.Action.OpenChannel" = "打开"; +"PeerInfo.Action.Report" = "举报"; +"PeerInfo.Action.SecretChat" = "私密"; +"PeerInfo.Action.SendGift" = "发送礼物"; +"PeerInfo.Action.Share" = "分享"; +"PeerInfo.Action.Statistics" = "统计"; +"PeerInfo.Action.Unmute" = "取消静音"; +"PeerInfo.Action.VideoCall" = "视频"; +"PeerInfo.Action.VoiceChat" = "视频聊天"; +"PeerInfo.AddContact" = "添加为联系人"; +"PeerInfo.AddMember" = "添加成员"; +"PeerInfo.AddUserToContact" = "添加 %@ 为联系人"; +"PeerInfo.AdminLabel" = "管理员"; +"PeerInfo.Administrators" = "管理员"; +"PeerInfo.Admins" = "管理员"; +"PeerInfo.AnonymousPhone" = "匿名电话"; +"PeerInfo.AutoTranslateMessages" = "自动翻译消息"; +"PeerInfo.BirthdaySuggested" = "已建议生日"; +"PeerInfo.BlackList" = "黑名单"; +"PeerInfo.Block.Header" = "屏蔽用户"; +"PeerInfo.Block.OK" = "屏蔽"; +"PeerInfo.Block.Text" = "你想要阻止来自 %@ 的 Telegram 消息和通话吗?"; +"PeerInfo.BlockUser" = "屏蔽此用户"; +"PeerInfo.Bot.AddToGroup" = "添加到群组"; +"PeerInfo.Bot.Help" = "帮助"; +"PeerInfo.Bot.Privacy" = "隐私"; +"PeerInfo.Bot.Settings" = "设置"; +"PeerInfo.Bot.Share" = "分享"; +"PeerInfo.BotAddTo" = "添加到群组或频道"; +"PeerInfo.BotAddTo.Info" = "该机器人可以管理群组或频道。"; +"PeerInfo.BotAffiliate" = "联盟计划"; +"PeerInfo.BotEdit.Balance" = "余额"; +"PeerInfo.BotEdit.Commands" = "修改命令"; +"PeerInfo.BotEdit.Info" = "使用 [@BotFather]() 以管理此机器人。"; +"PeerInfo.BotEdit.Intro" = "修改简介"; +"PeerInfo.BotEdit.Settings" = "更改机器人设置"; +"PeerInfo.BotEdit.StarsBalance" = "余额"; +"PeerInfo.BotEdit.StarsBalance_New" = "星星余额"; +"PeerInfo.BotEdit.StarsCount#other" = "%d 颗星星"; +"PeerInfo.BotEdit.StarsCount_countable" = "%d"; +"PeerInfo.BotEdit.TonBalance" = "TON 余额"; +"PeerInfo.BotEdit.Username" = "用户名"; +"PeerInfo.BotPermissions.Geo" = "地理位置"; +"PeerInfo.BotPermissions.Header" = "允许访问"; +"PeerInfo.BotPermissions.Status" = "表情符号状态"; +"PeerInfo.BotStatus.HasAccess" = "可读取消息"; +"PeerInfo.BotStatus.HasNoAccess" = "无权读取消息"; +"PeerInfo.Business.Hours" = "营业时间"; +"PeerInfo.Business.Hours.Closed" = "关闭"; +"PeerInfo.Business.Hours.Current.Closed" = "关闭"; +"PeerInfo.Business.Hours.Current.Open" = "营业"; +"PeerInfo.Business.Hours.Current.OpenIn" = "%@ 营业"; +"PeerInfo.Business.Hours.Current.OpenInHours#other" = "%d 小时后营业"; +"PeerInfo.Business.Hours.Current.OpenInHours_countable" = "%d"; +"PeerInfo.Business.Hours.Current.OpenInMinutes#other" = "%d 分钟后营业"; +"PeerInfo.Business.Hours.Current.OpenInMinutes_countable" = "%d"; +"PeerInfo.Business.Hours.Opens24Hours" = "24 小时营业"; +"PeerInfo.Business.Hours.local" = "当地时间"; +"PeerInfo.Business.Hours.my" = "你本地的时间"; +"PeerInfo.Business.Location" = "位置"; +"PeerInfo.ChannelAppearance" = "外观"; +"PeerInfo.ChannelColor" = "频道颜色"; +"PeerInfo.ChannelName.Placeholder" = "频道名称"; +"PeerInfo.ChannelReported" = "非常感谢!我们的团队会尽快核查您的举报。"; +"PeerInfo.ChannelTitle.Pleceholder" = "频道名"; +"PeerInfo.ChannelType" = "频道类型"; +"PeerInfo.ChatBackground" = "更改壁纸"; +"PeerInfo.ChatColors" = "变换色彩"; +"PeerInfo.Confirm.Add" = "添加"; +"PeerInfo.Confirm.AddMember" = "添加 \"%@\" 到此群组?"; +"PeerInfo.Confirm.AddMembers1#other" = "添加 %d 人到此群组?"; +"PeerInfo.Confirm.AddMembers1_countable" = "%d"; +"PeerInfo.Confirm.Clear" = "清除"; +"PeerInfo.Confirm.DeleteChat" = "您确定要删除所有历史聊天记录并退出 \"%@\" 吗?\n\n此操作无法撤消。"; +"PeerInfo.Confirm.DeleteContact" = "您确定要删除此联系人吗?"; +"PeerInfo.Confirm.DeleteGroupConfirmation" = "删除本群组将会移除所有成员并导致所有消息记录丢失,确认要这样做吗?"; +"PeerInfo.Confirm.DeleteUserChat" = "您确定要删除此对话吗?"; +"PeerInfo.Confirm.LeaveChannel" = "您确定要退出此频道吗?"; +"PeerInfo.Confirm.LeaveGroup" = "您确定要退出此群组吗?\n\n此操作无法撤消。"; +"PeerInfo.Confirm.RemovePeer" = "从群组移除 %@?"; +"PeerInfo.Confirm.SecretChat.Header" = "私密聊天"; +"PeerInfo.Confirm.SecretChat.OK" = "开始"; +"PeerInfo.Confirm.ShareInfo" = "您确定要与“%@“分享您的手机号码吗?"; +"PeerInfo.Confirm.StartSecretChat" = "您确定要与 “%@” 开始私密聊天吗?"; +"PeerInfo.Context.OpenPhoto" = "打开图片"; +"PeerInfo.ConvertToSupergroup" = "升级为超级群组"; +"PeerInfo.CopyProtection.DisableSharing" = "禁用分享"; +"PeerInfo.CopyProtection.EnableSharing" = "启用分享"; +"PeerInfo.CopyProtection.EnableSharing.Confirm.Header" = "启用分享"; +"PeerInfo.CopyProtection.EnableSharing.Confirm.OK" = "发送请求"; +"PeerInfo.CopyProtection.EnableSharing.Confirm.Text" = "您需要获得 %1$@ 的批准才能启用分享。发送请求吗?"; +"PeerInfo.CopyProtection.Info" = "用户将能够保存并转发您的个人资料内容。"; +"PeerInfo.CopyProtection.InfoRestrict" = "用户将无法转发您私人聊天中的消息,无法保存其中的媒体内容或截图。这也将限制用户复制文本。"; +"PeerInfo.CopyProtection.RequiresPremium" = "此功能需要 Telegram 高级版。"; +"PeerInfo.CopyProtection.Restrict" = "禁止转发"; +"PeerInfo.DeleteAndExit" = "删除并退出"; +"PeerInfo.DeleteChannel" = "删除频道"; +"PeerInfo.DeleteContact" = "删除联系人"; +"PeerInfo.DeleteGroup" = "删除群组"; +"PeerInfo.DeleteSecretChat" = "删除私密聊天"; +"PeerInfo.Description.Placeholder" = "关于"; +"PeerInfo.DirectMessages.Off" = "关闭"; +"PeerInfo.DirectMessages.On" = "开启"; +"PeerInfo.DirectMessages.Text" = "私信"; +"PeerInfo.Discussion" = "讨论"; +"PeerInfo.Discussion.Add" = "添加"; +"PeerInfo.Discussion.Desc" = "添加一个群组聊天以便讨论。"; +"PeerInfo.EncryptionKey" = "加密密钥"; +"PeerInfo.FakeWarning" = "⚠️ 警告:大量用户举报该账号冒名顶替名人或组织。"; +"PeerInfo.FirstName.Placeholder" = "姓氏"; +"PeerInfo.Forum" = "话题"; +"PeerInfo.Forum.Info" = "群聊将被划分为由管理员或用户创建的话题。"; +"PeerInfo.Gifts.ChannelNotify" = "关于新礼物的通知"; +"PeerInfo.Gifts.ChannelNotifyDisabledTooltip" = "当你的频道收到礼物时,你不会收到通知。"; +"PeerInfo.Gifts.ChannelNotifyTooltip" = "当你的频道收到礼物时,Telegram 将给你发送消息。"; +"PeerInfo.Gifts.Displayed" = "显示"; +"PeerInfo.Gifts.Hidden" = "隐藏"; +"PeerInfo.Gifts.Limited" = "限量"; +"PeerInfo.Gifts.SendGift" = "发送礼物"; +"PeerInfo.Gifts.SortByDate" = "按日期排序"; +"PeerInfo.Gifts.SortByValue" = "按价值排序"; +"PeerInfo.Gifts.Unique" = "独家"; +"PeerInfo.Gifts.Unlimited" = "不限量"; +"PeerInfo.Group.AutoDeleteMessages" = "自动删除信息"; +"PeerInfo.Group.Menu.Delete" = "删除"; +"PeerInfo.Group.Menu.Promote" = "提权"; +"PeerInfo.Group.Menu.Restrict" = "限制"; +"PeerInfo.Group.Timer.Never" = "从不"; +"PeerInfo.GroupName.Placeholder" = "群组名称"; +"PeerInfo.GroupTitle.Pleceholder" = "群组名称"; +"PeerInfo.GroupType" = "群组类型"; +"PeerInfo.GroupType.Private" = "私密"; +"PeerInfo.GroupType.Public" = "公开"; +"PeerInfo.GroupsInCommon" = "共同加入的群组"; +"PeerInfo.InviteError.ContactNeeded" = "抱歉,您必须在此用户的 Telegram 联系人列表中才能将其添加到该群组。\n\n如果您向他们发送邀请链接,他们也可以自己加入。"; +"PeerInfo.InviteLink" = "邀请链接"; +"PeerInfo.InviteLinks" = "邀请链接"; +"PeerInfo.LastName.Placeholder" = "姓氏"; +"PeerInfo.LeaveChannel" = "退出频道"; +"PeerInfo.LeaveGroup" = "退出群组"; +"PeerInfo.LinkedChannel" = "已关联频道"; +"PeerInfo.Members" = "成员"; +"PeerInfo.MembersHeader#other" = "%d 位成员"; +"PeerInfo.MembersHeader_countable" = "%d"; +"PeerInfo.MembersRequest" = "加入请求"; +"PeerInfo.NotesCopy" = "复制备注"; +"PeerInfo.NotesDescription" = "仅您可见"; +"PeerInfo.NotesLabel" = "备注"; +"PeerInfo.NotesPlaceholder" = "添加备注"; +"PeerInfo.Notifications" = "通知"; +"PeerInfo.NotificationsDefault" = "默认"; +"PeerInfo.NotificationsDefaultSound" = "默认(%@)"; +"PeerInfo.Permissions" = "权限"; +"PeerInfo.PersonalChannel.Title" = "频道"; +"PeerInfo.Phone" = "电话"; +"PeerInfo.Phone.AnonymousInfo" = "此号码获取自 [Fragment](),并未绑定到 SIM 卡。"; +"PeerInfo.PreHistory" = "历史记录对新成员"; +"PeerInfo.PreHistory.Hidden" = "隐藏"; +"PeerInfo.PreHistory.Visible" = "可见"; +"PeerInfo.Premium.Tooltip" = "高级版会员账户 [了解更多](premium)"; +"PeerInfo.Rating.Button" = "了解更多"; +"PeerInfo.Rating.Level" = "等级"; +"PeerInfo.Rating.Text" = "个人资料等级反映\n用户的付款可靠性。"; +"PeerInfo.Reactions" = "表情回应"; +"PeerInfo.Reactions.All" = "全部"; +"PeerInfo.Reactions.Disabled" = "已停用"; +"PeerInfo.Reactions.Part" = "%1$@/%2$@"; +"PeerInfo.RecentActions" = "最近操作"; +"PeerInfo.RemovedUsers" = "被移除的用户"; +"PeerInfo.Report" = "举报"; +"PeerInfo.Report.SelectMessages" = "选择信息"; +"PeerInfo.ReportBot" = "举报机器人"; +"PeerInfo.ReportReaction" = "屏蔽并举报"; +"PeerInfo.ReportReaction.Success" = "感谢您举报的信息"; +"PeerInfo.RestartBot" = "重新启用机器人"; +"PeerInfo.ScamWarning" = "⚠️警告:此账号被大量用户举报欺诈。请小心,尤其是当该用户向你索要财物时。"; +"PeerInfo.SendMessage" = "发送消息"; +"PeerInfo.SetAboutDescription" = "您可以给您的频道添加一段简介。"; +"PeerInfo.SetAdmins" = "设置管理员"; +"PeerInfo.SetChannelPhoto" = "设置频道头像"; +"PeerInfo.SetGroupPhoto" = "设置群组头像"; +"PeerInfo.SetGroupStickersSet" = "群组贴纸包"; +"PeerInfo.ShareContact" = "分享联系人信息"; +"PeerInfo.ShareMyInfo" = "分享我的联系信息"; +"PeerInfo.SharedMedia" = "分享的媒体"; +"PeerInfo.ShowAuthorProfiles" = "显示作者资料"; +"PeerInfo.ShowMore" = "显示更多"; +"PeerInfo.ShowMoreText" = "[更多]()"; +"PeerInfo.SignMessages" = "消息署名"; +"PeerInfo.SignMessages.Desc" = "在管理员发送的消息后附上他们的名字。"; +"PeerInfo.SignMessagesAndShowAuthor.Desc" = "允许管理员以他们的频道或个人账号身分发布內容,并链接到他们的个人资料。"; +"PeerInfo.StartSecretChat" = "开始私密聊天"; +"PeerInfo.StatAndBoosts" = "统计数据"; +"PeerInfo.Statistics" = "统计信息"; +"PeerInfo.StopBot" = "屏蔽机器人"; +"PeerInfo.Subscribers" = "订阅者"; +"PeerInfo.SuggestBirthday" = "建议生日"; +"PeerInfo.TopicStatus.In" = "在 %1$@"; +"PeerInfo.TopicsLimitedDiscussionGroups" = "目前无法在频道的讨论群组中使用话题。"; +"PeerInfo.TopicsLimitedParticipantCountText#other" = "只有拥有超过 %d 名成员的群组才能启用话题功能。"; +"PeerInfo.TopicsLimitedParticipantCountText_countable" = "%d"; +"PeerInfo.Translate" = "翻译"; +"PeerInfo.Unarchive" = "未归档"; +"PeerInfo.UnblockUser" = "取消阻止"; +"PeerInfo.UnofficialClient.Text" = "%1$@ 使用了非官方 Telegram 客户端,与该用户之间的消息安全性可能降低"; +"PeerInfo.UnofficialClient.Title" = "⚠️ 非官方客户端"; +"PeerInfo.Usernames.List" = "%1$@\n同时 %2$@"; +"PeerInfo.VerificationInfo.Bot" = "此机器人已经通过 Telegram 的验证。"; +"PeerInfo.VerificationInfo.Channel" = "此频道已经通过 Telegram 验证。"; +"PeerInfo.VerificationInfo.Group" = "此群组已经通过 Telegram 验证。"; +"PeerInfo.VerificationInfo.URL" = "https://telegram.org/verify"; +"PeerInfo.Verified" = "%@ 此机器人已由 Telegram 代表验证为官方机器人。."; +"PeerInfo.Verified.Tooltip" = "已验证帐户"; +"PeerInfo.VerifyAccounts" = "验证账号"; +"PeerInfo.about" = "关于"; +"PeerInfo.bio" = "个人简介"; +"PeerInfo.birthday" = "生日"; +"PeerInfo.fake" = "假冒"; +"PeerInfo.info" = "信息"; +"PeerInfo.scam" = "欺诈"; +"PeerInfo.sharelink" = "分享链接"; +"PeerInfo.username" = "用户名"; +"PeerList.Archive.Settings" = "归档设置"; +"PeerList.Title.Archive" = "归档"; +"PeerList.Title.Chats" = "聊天"; +"PeerList.Title.Contacts" = "联系人"; +"PeerList.Title.Forum" = "话题讨论"; +"PeerList.Title.SavedMessages" = "我的收藏"; +"PeerMedia.ArchivePosts" = "已归档的贴文"; +"PeerMedia.Audio" = "音频"; +"PeerMedia.CommonGroups" = "群组"; +"PeerMedia.Files" = "文件"; +"PeerMedia.Gifs" = "GIF 动态图"; +"PeerMedia.Gifts" = "礼物"; +"PeerMedia.Gifts.Collection.Add" = "添加收藏集"; +"PeerMedia.Gifts.Collection.Alert.Added" = "礼物已添加至 **%@**。"; +"PeerMedia.Gifts.Collection.Alert.Removed" = "礼物已从 **%@** 移除"; +"PeerMedia.Gifts.Collection.All" = "所有礼物"; +"PeerMedia.Gifts.Collection.Context.AddCollection" = "添加收藏集"; +"PeerMedia.Gifts.Collection.Context.AddGifts" = "添加礼物"; +"PeerMedia.Gifts.Collection.Context.AddToCollection" = "添加到收藏集"; +"PeerMedia.Gifts.Collection.Context.Delete" = "删除"; +"PeerMedia.Gifts.Collection.Context.Delete.Confirm" = "您确定要删除 **%@** 吗?"; +"PeerMedia.Gifts.Collection.Context.NewCollection" = "新收藏集"; +"PeerMedia.Gifts.Collection.Context.Rename" = "更改名称"; +"PeerMedia.Gifts.Collection.Empty.Action" = "添加礼物"; +"PeerMedia.Gifts.Collection.Empty.Header" = "整理您的礼物"; +"PeerMedia.Gifts.Collection.Empty.Text" = "将一些礼物添加到此收藏集中。"; +"PeerMedia.Gifts.Collection.New.Info" = "为您的收藏集选择一个名称,并添加您的礼物。"; +"PeerMedia.Gifts.Collection.New.Ok" = "创建"; +"PeerMedia.Gifts.Collection.New.Placeholder" = "标题。。。"; +"PeerMedia.Gifts.Collection.New.Title" = "新建收藏集"; +"PeerMedia.Gifts.Collection.Panel.AddGifts" = "添加礼物"; +"PeerMedia.Gifts.Collection.Update.Info" = "更新您收藏集的标题。"; +"PeerMedia.Gifts.Collection.Update.Ok" = "更新"; +"PeerMedia.Gifts.Collection.Update.Placeholder" = "标题..."; +"PeerMedia.Gifts.Collection.Update.Title" = "更新标题"; +"PeerMedia.Links" = "链接"; +"PeerMedia.Media" = "媒体"; +"PeerMedia.Members" = "成员"; +"PeerMedia.Music" = "音乐"; +"PeerMedia.Polls" = "投票"; +"PeerMedia.Popover.SharedAudio" = "转发的音频"; +"PeerMedia.Popover.SharedFiles" = "分享的文件"; +"PeerMedia.Popover.SharedLinks" = "分享的链接"; +"PeerMedia.Popover.SharedMedia" = "分享的媒体"; +"PeerMedia.Posts" = "贴文"; +"PeerMedia.Preview" = "预览"; +"PeerMedia.Saved" = "我的收藏"; +"PeerMedia.SavedMessages" = "聊天"; +"PeerMedia.SharedMedia" = "分享的媒体"; +"PeerMedia.SimilarBots" = "相似机器人"; +"PeerMedia.SimilarChannels" = "相似频道"; +"PeerMedia.SimilarChannels.MoreChannels" = "更多频道"; +"PeerMedia.Status.Subscribers#other" = "%d 名订阅者"; +"PeerMedia.Status.Subscribers_countable" = "%d"; +"PeerMedia.Stories" = "动态"; +"PeerMedia.Title.Search.Files#other" = "%d 个文件"; +"PeerMedia.Title.Search.Files_countable" = "%d"; +"PeerMedia.Title.Search.GIFs#other" = "%d 张 GIF 动态图"; +"PeerMedia.Title.Search.GIFs_countable" = "%d"; +"PeerMedia.Title.Search.Links#other" = "%d 个链接"; +"PeerMedia.Title.Search.Links_countable" = "%d"; +"PeerMedia.Title.Search.Media#other" = "%d 个媒体文件"; +"PeerMedia.Title.Search.Media_countable" = "%d"; +"PeerMedia.Title.Search.Music#other" = "%d 个音频文件"; +"PeerMedia.Title.Search.Music_countable" = "%d"; +"PeerMedia.Title.Search.Photos#other" = "%d 张图片\n"; +"PeerMedia.Title.Search.Photos_countable" = "%d"; +"PeerMedia.Title.Search.Videos#other" = "%d个视频文件"; +"PeerMedia.Title.Search.Videos_countable" = "%d"; +"PeerMedia.Voice" = "语音"; +"PeerRating.Preview.Negative" = "负面评级"; +"PeerSelect.InviteViaLink" = "通过链接邀请加入群组"; +"PersonalChannel.Empty" = "您没有公开频道。 [创建 >]()"; +"PersonalChannel.Hide" = "隐藏频道"; +"PersonalChannel.Info" = "选择您的频道"; +"PersonalChannel.Title" = "私人频道"; +"PersonalChannel.Tooltip.Updated" = "个人频道已更新"; +"PhoneNumber.ChangePhoneNumber" = "修改电话号码"; +"PhoneNumber.Code.Fragment.Info" = "在 [Fragment](%1$@) 的号码板块获取验证码。"; +"PhoneNumber.Code.Info" = "我们已向您发送带有确认码的短信,请输入该短信以完成该过程。"; +"PhoneNumber.Code.Title" = "输入验证码"; +"PhoneNumber.Info" = "您的新号码将通过电话或短信收到一个确认码。"; +"PhoneNumber.SendCode" = "发送代码"; +"PhoneNumber.Title" = "新电话号码"; +"PlaybackSpeed.SetToDefault" = "默认设置"; +"Poll.Forward.Error" = "抱歉,投票不能被转到到此频道。"; +"PollResults.Collapse" = "[折叠]()"; +"PollResults.LoadMore#other" = "显示其他 (%d) 个"; +"PollResults.LoadMore_countable" = "%d"; +"PollResults.Title.Poll" = "投票结果"; +"PollResults.Title.Quiz" = "问答结果"; +"PreHistory.Confirm.Unlink" = "注意,这将从 \"%@\" 中取消与群组的链接"; +"PreHistorySettings.Description.Group.Hidden" = "新成员只能看到最近 100 条消息。"; +"PreHistorySettings.Description.Hidden" = "新成员不能查看他们加入前的消息。"; +"PreHistorySettings.Description.Visible" = "新成员将可以查看所有消息记录。"; +"PreHistorySettings.Header" = "历史记录对新用户"; +"Premium.Boarding.About.Text" = "虽然免费版本的 Telegram 已经为用户提供了比其他任何即时通讯应用程序更多的功能,但 **Telegram 高级版** 进一步推动了它。\n\n**Telegram 高级版** 是付费选项,因为大多数高级功能需要 Telegram 向第三方(如数据中心提供商和服务器制造商)支付额外费用。 **Telegram P\n高级版** 用户的贡献使我们能够支付此类费用,并帮助 Telegram 保持为所有人免费提供。"; +"Premium.Boarding.About.Title" = "关于 Telegram 高级版"; +"Premium.Boarding.About.Tos" = "购买高级版订阅,即表示你同意 Telegram 的 [服务条款](terms) 和 [隐私政策](privacy)。"; +"Premium.Boarding.AppStore.Cancelled" = "购买已取消或失败。"; +"Premium.Boarding.AppStore.Success" = "您已成功订阅 Telegram 高级版。"; +"Premium.Boarding.Avatar.Info" = "视频头像会在聊天列表和聊天中动起来,为您提供更多自我表达空间。"; +"Premium.Boarding.Avatar.Title" = "动态头像"; +"Premium.Boarding.Badge.Info" = "在您的名字后方显示专属徽章,以表示您已订阅 Telegram 高级版。"; +"Premium.Boarding.Badge.Title" = "形象勋章"; +"Premium.Boarding.Chats.Info" = "可设定默认文件夹、自动归档以及隐藏来自非联系人的新聊天。"; +"Premium.Boarding.Chats.Title" = "高级聊天管理"; +"Premium.Boarding.Colors.Info" = "为您的个人资料页以及收到回复的消息设置颜色和图标。"; +"Premium.Boarding.Colors.Title" = "名字和个人资料页颜色"; +"Premium.Boarding.Double.Accounts" = "关联帐户"; +"Premium.Boarding.Double.Accounts.Info" = "使用不同的电话号码连接到 %@ 个账号"; +"Premium.Boarding.Double.Bio" = "个人简介"; +"Premium.Boarding.Double.Bio.Info" = "在个人介绍中添加更多标志并使用链接。"; +"Premium.Boarding.Double.Captions" = "说明文字"; +"Premium.Boarding.Double.Captions.Info" = "在图片和视频下使用更长的说明文字"; +"Premium.Boarding.Double.ChatsPerFolder" = "每个文件夹内聊天数量"; +"Premium.Boarding.Double.ChatsPerFolder.Info" = "在每个文件夹内添加高达 %@ 个聊天 "; +"Premium.Boarding.Double.FavedStickers" = "贴纸收藏"; +"Premium.Boarding.Double.FavedStickers.Info" = "在贴纸收藏中存储高达 %@ 张贴纸"; +"Premium.Boarding.Double.Folders" = "文件夹"; +"Premium.Boarding.Double.Folders.Info" = "将您的聊天整理到 %@ 个文件夹中"; +"Premium.Boarding.Double.GroupsAndChannels" = "群组和频道"; +"Premium.Boarding.Double.GroupsAndChannels.Info" = "加入多达 %@ 个频道和大型群组"; +"Premium.Boarding.Double.Info" = "多达 %1$@ 个频道、%2$@ 个文件夹、%3$@ 个置顶、%4$@ 个公开链接等等。"; +"Premium.Boarding.Double.PinnedChats" = "已置顶聊天"; +"Premium.Boarding.Double.PinnedChats.Info" = "在主聊天列表中置顶高达 %@ 个聊天"; +"Premium.Boarding.Double.PublicLinks" = "公共链接"; +"Premium.Boarding.Double.PublicLinks.Info" = "最多保留 %@ 个t.me/name 链接"; +"Premium.Boarding.Double.SavedGifs" = "保存的 GIF"; +"Premium.Boarding.Double.SavedGifs.Info" = "在 GIF 动态图收藏中存储高达 %@ 张 GIF 动态图"; +"Premium.Boarding.Double.Title" = "双倍上限"; +"Premium.Boarding.Download.Info" = "下载媒体文件和文档时不再受到速度限制。"; +"Premium.Boarding.Download.Title" = "更快的下载速度"; +"Premium.Boarding.Emoji.Info" = "在您发送的任何消息中包含来自不同表情包的动态表情。"; +"Premium.Boarding.Emoji.Title" = "动态表情"; +"Premium.Boarding.FileSize.Info" = "单个文档上传限制为 %2$@,聊天和媒体的总体存储空间无限量。"; +"Premium.Boarding.FileSize.Title" = "无限云端存储空间"; +"Premium.Boarding.Got.Info" = "欢迎订阅 Telegram 高级版。\n您已成功解锁以下特权。"; +"Premium.Boarding.Got.Title" = "一切都已就绪!"; +"Premium.Boarding.GraceText" = "抱歉,您最近一次付款没有成功。若要继续享有 **专属功能**,请更新订阅。"; +"Premium.Boarding.GraceTitle" = "你的即将到期!"; +"Premium.Boarding.Info" = "订阅 **Telegram Premium** 后,您能 **超越限制** 并 **解锁数十项专属** 功能。"; +"Premium.Boarding.LastSeen.Info" = "即使您已将其隐藏,也可查看其他用户的最后上线及已读时间。"; +"Premium.Boarding.LastSeen.Title" = "最后上线时间"; +"Premium.Boarding.MessagePrivacy.Info" = "限制来自陌生人的消息或对收到的消息收费。"; +"Premium.Boarding.MessagePrivacy.Title" = "消息隐私"; +"Premium.Boarding.NoAds.Info" = "Telegram 公开频道中不再有不定期展示的广告。"; +"Premium.Boarding.NoAds.Title" = "无广告"; +"Premium.Boarding.Payment.NotAvailalbe" = "不可用"; +"Premium.Boarding.Peer.Gift.Info" = "您现在可以使用额外的功能了。"; +"Premium.Boarding.Peer.Gift.LinkInfo" = "此链接允许您或您选择的任何人激活 **Telegram 高级版** 订阅。"; +"Premium.Boarding.Peer.Gift.Title" = "[%1$@](profile) 已赠予您 %2$@ 个月的 Telegram 高级版订阅。"; +"Premium.Boarding.Peer.Gift.Title.Updated" = "[%1$@](profile) 赠送了您一份 %2$@ 的 Telegram 高级版订阅。"; +"Premium.Boarding.Peer.Gift.You.Info" = "对方现在可以使用额外的功能了。"; +"Premium.Boarding.Peer.Gift.You.Title" = "您已赠予 [%1$@](profile) %2$@ 个月的 Telegram 高级版订阅。"; +"Premium.Boarding.Peer.Gift.You.Title.Updated" = "您赠送了 [%1$@](profile) 一份 %2$@ 的 Telegram 高级版订阅。"; +"Premium.Boarding.Peer.Info" = "拥有 **Telegram 高级版** 帐号即可专享多项附加功能。"; +"Premium.Boarding.Peer.Title" = "[%@](profile) 是 Telegram 高级版的订阅者"; +"Premium.Boarding.PeerStatus.Custom.Title" = "这是 %@ 的当前状态,该表情来自 🤡 [%@]()。"; +"Premium.Boarding.PeerStatus.Default.Title" = "这是 %@ 的当前状态。"; +"Premium.Boarding.PeerStatus.Info" = "表情符号状态是一项高级功能。**Telegram 高级版** 包含的其它功能:"; +"Premium.Boarding.Reactions.Info" = "使用更多动画表情回应消息,仅高级版订阅者可用。"; +"Premium.Boarding.Reactions.Title" = "专属表情回应"; +"Premium.Boarding.ReactionsNew.Info" = "用数千个表情作出回应——可使用多个表情回应单条消息。"; +"Premium.Boarding.ReactionsNew.Title" = "无限表情回应"; +"Premium.Boarding.Renew.Month" = "以每月 %@ 续订"; +"Premium.Boarding.Renew.SixMonth" = "Renew for %@ / six month"; +"Premium.Boarding.Renew.TwoYears" = "以 %@ 两年续订"; +"Premium.Boarding.Renew.Year" = "以每年 %@ 续订"; +"Premium.Boarding.SavedTags.Info" = "使用标签整理收藏的消息,以便快速访问。"; +"Premium.Boarding.SavedTags.Title" = "消息标签"; +"Premium.Boarding.Status.Info" = "从数千个表情中选择一个展示当前的活动,显示在您的名字旁边。"; +"Premium.Boarding.Status.Title" = "表情状态"; +"Premium.Boarding.Stickers.Info" = "具有额外特效的专属放大贴纸,定期更新。"; +"Premium.Boarding.Stickers.Title" = "高级版贴纸"; +"Premium.Boarding.Stories.Info" = "无限发布数量、优先显示、隐身模式、永久访客历史,以及更多功能。"; +"Premium.Boarding.Stories.Title" = "动态"; +"Premium.Boarding.Subscribe" = "以每月 %@ 的价格订阅"; +"Premium.Boarding.Subscribe.Month" = "以每月 %@ 的价格订阅"; +"Premium.Boarding.Subscribe.SixMonth" = "以每半年 %@ 的价格订阅"; +"Premium.Boarding.Subscribe.TwoYears" = "以 %@ / 两年订阅"; +"Premium.Boarding.Subscribe.Year" = "以每年 %@ 的价格订阅"; +"Premium.Boarding.Title" = "Telegram 高级版"; +"Premium.Boarding.Translate.Info" = "将频道和聊天内容即时翻译成其他语言。"; +"Premium.Boarding.Translate.Title" = "即时翻译"; +"Premium.Boarding.Voice.Info" = "能够将收到的语音消息转换成文字以方便阅读。"; +"Premium.Boarding.Voice.Title" = "语音转文字"; +"Premium.Boarding.Wallpapers.Info" = "为您和聊天对象设置自定义壁纸。"; +"Premium.Boarding.Wallpapers.Title" = "为双方设置壁纸"; +"Premium.GetPremium.Double" = "双倍限制"; +"Premium.Gift.ContactSelection.BuySelf" = "给自己买一份礼物"; +"Premium.Gift.ContactSelection.ThisIsYou" = "这是你"; +"Premium.Gift.GetAccess.MoreThanThree" = "将 **Telegram 高级版** 的更多专属功能赠予 **%1$@** 和 **%2$@**。"; +"Premium.Gift.GetAccess.OneToThree" = "让 **%1$@** 享有 **Telegram 高级版** 的专属功能。"; +"Premium.Gift.ReceiveBoost" = "您将会获得 **%@** 个助推"; +"Premium.Gift.Sent.Close" = "关闭"; +"Premium.Gift.Sent.Multiple.MoreThanThree" = "**%1$@** 和另外 %2$@ 人已收到您购买的礼物的通知。 \n\n他们现在可以使用额外的功能。"; +"Premium.Gift.Sent.Multiple.OneToThree" = "%1$@ 已收到有关您购买的礼物的通知。\n\n他们现在可以访问额外功能。"; +"Premium.Gift.Sent.Multiple.Title" = "礼物已赠送!"; +"Premium.Gift.Sent.One.Text" = "**%@** 已收到有关您购买的礼物的通知。\n\n他们现在可以访问其他功能。"; +"Premium.Gift.Sent.One.Title" = "礼物已赠送!"; +"Premium.Gift.Terms" = "赠予 Telegram 高级版即表示您同意 Telegram [服务条款](terms) 和 [隐私政策](privacy)。"; +"Premium.Gift.Text" = "将 **Telegram 高级版** 的专属功能赠予 **%@**。"; +"Premium.Gift.Title" = "赠予 Telegram 高级版"; +"Premium.Limit.About.Info" = "“个人简介”部分不能超过 **%1$@** 个字符。您可以订阅 **Telegram 高级版**,将此上限翻倍至 **%2$@** 个字符。"; +"Premium.Limit.Accounts.Info" = "您已达到 %@ 个连接账号的限制。您可以通过其中一个账号订阅 Telegram 高级版来添加一个可登录的账号名额。"; +"Premium.Limit.Caption.Info" = "字幕不能超过 **%1$@** 字符。您可以将此限制加倍至 **%2$@** 通过订阅 **Telegram 高级版** 字符。"; +"Premium.Limit.Channels.Info" = "您已加入 **%1$@** 个群组或频道。请在加入新的之前先退出一些——或者订阅 **Telegram 高级版**,将此上限翻倍至 **%2$@** 个群组或频道。"; +"Premium.Limit.ChatInFolders.Info" = "抱歉,您无法在单个文件夹中添加超过 **%1$@** 个聊天。订阅 **Telegram 高级版** 后可将此上限提高至 **%2$@** 个聊天。"; +"Premium.Limit.FaveStickers.Info" = "抱歉,您不能添加超过 **%1$@**贴纸到收藏夹。替换较旧的已保存贴纸,或者订阅 **Telegram 高级版**,将收藏贴纸上限翻倍至 **%2$@** 个。"; +"Premium.Limit.FileSize.Info" = "此文档已超过 **%1$@** ,无法发送。您可以订阅 **Telegram 高级版**,将此上限翻倍至每个文件 **%2$@**。"; +"Premium.Limit.Folders.Info" = "您已达到 **%1$@** 个文件夹的限额。 您可以通过订阅 **Telegram 高级版**,将此上限翻倍至 **%2$@** 个文件夹。"; +"Premium.Limit.Free" = "免费"; +"Premium.Limit.Increase" = "提升上限"; +"Premium.Limit.Locked.About.Info" = "您不能使用超过 **%1$@** 简历中的人物。我们正在努力让您在将来提高此上限。"; +"Premium.Limit.Locked.Accounts.Info" = "您已达到最多登陆 %@ 个账号的限制。我们正在努力让您未来可提高此上限。"; +"Premium.Limit.Locked.Caption.Info" = "字幕不能超过 **%1$@** 字符。我们正在努力让您在将来提高此上限。"; +"Premium.Limit.Locked.Channels.Info" = "您是 **%1$@** 个群组和频道的成员。我们正在努力让您在将来提高此上限。"; +"Premium.Limit.Locked.ChatInFolders.Info" = "抱歉,您无法在此文件夹中添加超过 **%1$@** 个聊天。我们在以后将尽力为您提高该限制。"; +"Premium.Limit.Locked.FaveStickers.Info" = "抱歉,您最多只能将 **%1$@** 张贴纸加入到收藏。我们正在努力让您未来可提高此上限。"; +"Premium.Limit.Locked.FileSize.Info" = "由于大小超过了 **%1$@**,文档无法发送。我们正在努力能让您在未来提高此上限。"; +"Premium.Limit.Locked.Folders.Info" = "你已达到 ** %1$@** 文件夹的上限。我们正在努力让你在未来提高此上限。"; +"Premium.Limit.Locked.Pin.Info" = "抱歉,您最多只能置顶 **%1$@** 个聊天。我们在以后将尽力为您提高该限制。"; +"Premium.Limit.Locked.PinInArchive.Info" = "抱歉,您最多只能在归档中置顶 **%1$@** 个聊天。我们在以后将尽力为您提高该限制。"; +"Premium.Limit.Locked.PinInFolders.Info" = "抱歉,您最多只能置顶 **%1$@** 个聊天。我们在以后将尽力为您提高该限制。"; +"Premium.Limit.Locked.PublicLink.Info" = "您保留了太多公开链接。您可以撤消其中一个较旧群组或频道的链接。我们正在努力能让您在未来提高此上限。"; +"Premium.Limit.Locked.SavedGifs.Info" = "抱歉,您最多只能保存 **%1$@** 个动图。我们正在努力让您未来可提高此上限。"; +"Premium.Limit.Locked.SharedFolders.Info" = "您只能添加 **%1$@ 个**可共享的文件夹,此上限可能会在将来提高。"; +"Premium.Limit.Locked.SharedFoldersInvites.Info" = "抱歉,您只能创建 %1$@ 个邀请链接。我们在以后将尽力为您提高此上限。"; +"Premium.Limit.Pin.Info" = "抱歉, 您无法置顶超过 **%1$@** 个聊天。请取消一些置顶的聊天,或者订阅 **Telegram 高级版**,将此上限翻倍至 **%2$@** 个聊天。"; +"Premium.Limit.PinInArchive.Info" = "抱歉,您最多只能在归档中置顶 **%1$@** 个聊天,请先取消置顶一些当前已置顶的聊天,或者订阅 **Telegram 高级版**,将此上限翻倍至 **%2$@** 个聊天。"; +"Premium.Limit.PinInFolders.Info" = "抱歉,您最多只能置顶 **%1$@** 个聊天,请先取消置顶一些当前已置顶的聊天,或者订阅 **Telegram 高级版**,将此上限翻倍至 **%2$@** 个聊天。"; +"Premium.Limit.Premium" = "高级版"; +"Premium.Limit.PublicLink.Info" = "您保留了太多公开链接。您可以撤消其中一个较旧群组或频道的链接,或者订阅 **Telegram 高级版**,将此上限翻倍至 **%1$@** 个公开链接。"; +"Premium.Limit.Reached" = "达到上限"; +"Premium.Limit.SavedGifs.Info" = "抱歉,您不能保存超过 **%1$@** 个 GIF 动态图。替换较旧的 GIF 动态图,或订阅 **Telegram 高级版**,将此上限翻倍至 **%2$@** 个 GIF 动态图。"; +"Premium.Limit.SharedFolders.Info" = "您只能添加 **%1$@** 个可分享文件夹。升级至 **Telegram 高级版**并将上限提升至 **%2$@ 个**。"; +"Premium.Limit.SharedFoldersInvites.Info" = "您只能创建 “%1$@” 个邀请链接。\n升级至 **Telegram 高级版** 并将上限提升至 **%2$@** 个。"; +"Premium.Offset.AlreadyHave" = "您已经拥有 Telegram 高级版。"; +"Premium.Period.Annual" = "按年"; +"Premium.Period.Monthly" = "按月"; +"Premium.Period.Price" = "%@/月"; +"Premium.Period.Price.TwoYear" = "%@/2年"; +"Premium.Period.Price.Year" = "%@/年"; +"Premium.Period.SixMonth" = "六个月"; +"Premium.Period.TwoYears" = "2 年"; +"Premium.Purchase.ErrorCantMakePayments" = "此设备不允许应用内付款。"; +"Premium.Purchase.ErrorNetwork" = "请检查您的互联网连接并重试。"; +"Premium.Purchase.ErrorNotAllowed" = "此设备不允许进行付款操作。"; +"Premium.Purchase.ErrorUnknown" = "发生了错误,请重试。"; +"Premium.Restore.ErrorUnknown" = "发生了错误,请重试。"; +"Premium.Restore.Success" = "完成"; +"Premium.ShowStatus.Or" = "或"; +"Premium.ShowStatus.ShowMyReadTime" = "向所有人显示我的已读时间"; +"Premium.ShowStatus.ShowMyStatus" = "显示我的最近上线的时间"; +"Premium.ShowStatus.ShowYourLastSeen" = "显示您的最后上线时间"; +"Premium.ShowStatus.ShowYourLastSeen.Info" = "若要查看 **%@** 的最后上线时间,您需显示您自己的最后上线时间..."; +"Premium.ShowStatus.ShowYourReadDate" = "显示您的已读时间"; +"Premium.ShowStatus.ShowYourReadDate.Info" = "若要查看 *%@** 的已读时间,您需显示您自己的已读时间..."; +"Premium.ShowStatus.Subscribe" = "订阅 Telegram 高级版"; +"Premium.ShowStatus.Success.LastSeen" = "您最后上线的时间已设为可见。"; +"Premium.ShowStatus.Success.ReadTime" = "您的已读时间已设为可见。"; +"Premium.ShowStatus.UpgradeToPremium" = "升级为高级版"; +"Premium.ShowStatus.UpgradeToPremium.LastSeen.Info" = "订阅后您将可以看到 **%@** 的最后上线时间,而无需显示您的最后上线时间。"; +"Premium.ShowStatus.UpgradeToPremium.Read.Info" = "订阅后您将可以看到 **%@** 的已读时间,而无需显示您的已读时间。"; +"Premium.StarGift.Info" = "订阅 **Telegram 高级版** 即可赠送最多 **%d** 份此类礼物,并解锁强大功能。"; +"Premium.StarGift.Info_countable" = "%d"; +"Premium.StarGift.Title" = "高级版礼物"; +"Premium.WhatsIncluded" = "包含了什么"; +"PremiumBoarding.AIToolsInfo" = "将您的消息和整个聊天记录转换为您偏好的风格和语言。"; +"PremiumBoarding.AIToolsTitle" = "AI 工具"; +"PremiumBoarding.ActivateForFree" = "免费激活"; +"PremiumBoarding.ActivateForFree.Already" = "您已经是 Telegram Premium 订阅者。"; +"PremiumBoarding.Business.AwayMessages" = "离开消息"; +"PremiumBoarding.Business.AwayMessages.Info" = "设置在您不在线时自动发送的消息。"; +"PremiumBoarding.Business.ChatBots" = "聊天机器人"; +"PremiumBoarding.Business.ChatBots.Info" = "添加任何用于处理与客户互动的第三方聊天机器人。"; +"PremiumBoarding.Business.GreetingMessages" = "问候消息"; +"PremiumBoarding.Business.GreetingMessages.Info" = "设置自动发送给新客户的问候信息。"; +"PremiumBoarding.Business.Intro" = "启动页面"; +"PremiumBoarding.Business.Intro.Info" = "自定义用户在与您开始聊天之前看到的消息。"; +"PremiumBoarding.Business.Links" = "聊天链接"; +"PremiumBoarding.Business.Links.Info" = "创建与您开始聊天的链接,并预设第一条消息。"; +"PremiumBoarding.Business.Location" = "位置"; +"PremiumBoarding.Business.Location.Info" = "在您的帐号上显示企业位置。"; +"PremiumBoarding.Business.OpeningHours" = "营业时间"; +"PremiumBoarding.Business.OpeningHours.Info" = "向顾客展示商户的营业时间。"; +"PremiumBoarding.Business.QuickReplies" = "快速回复"; +"PremiumBoarding.Business.QuickReplies.Info" = "设置包含富文本和媒体的快捷方式,更快回复消息。"; +"PremiumBoarding.Business.TelegramBusiness" = "Telegram 企业版"; +"PremiumBoarding.Business.TelegramBusiness.HeaderInfo1" = "您现已经解锁这些额外的企业功能。这些功能暂时包含在您的高级版订阅中。"; +"PremiumBoarding.Business.TelegramBusiness.HeaderInfo2" = "将您的帐号转变为 **企业页面**,并使用以下额外功能。Telegram 企业版的权益目前 **暂时包含** 在 Telegram 高级版中。"; +"PremiumBoarding.Business.TelegramBusiness.Info" = "升级您的账户后可使用企业功能,如位置信息、营业时间和快速回复。"; +"PremiumBoarding.DisableSharing" = "禁用分享"; +"PremiumBoarding.DisableSharingInfo" = "禁用私聊中的截屏、收藏和转发消息功能。"; +"PremiumBoarding.MoreBusinessHeader#other" = "+%d 项 TELEGRAM 高级版特权"; +"PremiumBoarding.MoreBusinessHeader_countable" = "%d"; +"PremiumBoarding.Stories.ExpirationDuration.Info" = "为您的动态设置自定义过期时间,比如 6 小时或 48 小时。"; +"PremiumBoarding.Stories.ExpirationDuration.Title" = "过期选项"; +"PremiumBoarding.Stories.HighQuality.Info" = "以双倍分辨率查看视频动态。"; +"PremiumBoarding.Stories.HighQuality.Title" = "更高画质"; +"PremiumBoarding.Stories.LinkFormatting.Info" = "在动态说明文字中添加链接和格式。"; +"PremiumBoarding.Stories.LinkFormatting.Title" = "链接和格式"; +"PremiumBoarding.Stories.LongerCaption.Info" = "为您的动态添加 10 倍长的说明文字。"; +"PremiumBoarding.Stories.LongerCaption.Title" = "更长的说明文字"; +"PremiumBoarding.Stories.PermanentHistoryView.Info" = "查看谁打开过您的动态——即使在动态过期后仍能查看。"; +"PremiumBoarding.Stories.PermanentHistoryView.Title" = "永久访客历史"; +"PremiumBoarding.Stories.PriorityOrder.Info" = "您的动态将始终优先展示,因此可以获得更多浏览量。"; +"PremiumBoarding.Stories.PriorityOrder.Title" = "优先显示"; +"PremiumBoarding.Stories.SaveToGallery.Info" = "保存其他用户未开启保护的动态。"; +"PremiumBoarding.Stories.SaveToGallery.Title" = "将动态保存到“照片”"; +"PremiumBoarding.Stories.StealthMode.Info" = "隐藏您曾浏览其他用户动态的痕迹。"; +"PremiumBoarding.Stories.StealthMode.Title" = "隐身模式"; +"PremiumBoarding.TagFolders" = "标记您的聊天"; +"PremiumBoarding.TagFolders.Info" = "为聊天添加彩色标签,以便在聊天列表中快速访问。 "; +"PremiumBoarding.Todo" = "核对清单"; +"PremiumBoarding.Todo.Info" = "规划、分配并完成任务——无缝衔接,简单高效。"; +"PremiumGift.ButtonText" = "花费 %@ 赠予订阅"; +"PremiumGift.Month" = "%@ / 月"; +"Presence.Support" = "支持"; +"Presence.bot" = "机器人"; +"Presence.miniapp" = "小程序"; +"Preview.Dragging.AddItems#other" = "添加多项"; +"Preview.Dragging.AddItems_countable" = "%d"; +"PreviewSender.Archive" = "使用 ZIP 压缩后发送"; +"PreviewSender.ArchiveTooltip" = "将所有媒体压缩至一个文件"; +"PreviewSender.CaptionPlaceholder" = "添加说明..."; +"PreviewSender.ChangeVideoCover" = "更改视频封面"; +"PreviewSender.CollageTooltip" = "将媒体合并至一条消息"; +"PreviewSender.CommentPlaceholder" = "发表评论..."; +"PreviewSender.CompressFile" = "压缩并发送"; +"PreviewSender.FileTooltip" = "发送原图"; +"PreviewSender.Grouped" = "已合并"; +"PreviewSender.MakePaid" = "将此内容设置为付费内容"; +"PreviewSender.MediaTooltip" = "快速发送"; +"PreviewSender.MoveTextDown" = "将说明文字下移"; +"PreviewSender.MoveTextUp" = "将说明文字上移"; +"PreviewSender.RemoveMedia" = "移除媒体"; +"PreviewSender.RemovePaid" = "移除付费内容"; +"PreviewSender.RemoveVideoCover" = "移除视频封面"; +"PreviewSender.SendAsFile" = "无压缩"; +"PreviewSender.SendAsMedia" = "压缩格式"; +"PreviewSender.SendAsSpoiler" = "带遮罩发送"; +"PreviewSender.SendAudio#other" = "发送 %d 个音频"; +"PreviewSender.SendAudio_countable" = "%d"; +"PreviewSender.SendFile#other" = "发送 %d 个文件"; +"PreviewSender.SendFile_countable" = "%d"; +"PreviewSender.SendGif#other" = "发送 %d 张 GIF 动态图"; +"PreviewSender.SendGif_countable" = "%d"; +"PreviewSender.SendMedia#other" = "发送 %d 个媒体文件"; +"PreviewSender.SendMedia_countable" = "%d"; +"PreviewSender.SendPhoto#other" = "发送 %d 张照片"; +"PreviewSender.SendPhoto_countable" = "%d"; +"PreviewSender.SendVideo#other" = "发送 %d 个视频"; +"PreviewSender.SendVideo_countable" = "%d"; +"PreviewSender.SpoilerTooltip.Disable" = "取消遮罩"; +"PreviewSender.SpoilerTooltip.Enable" = "罩住图片"; +"PreviewSender.Title.Archive" = "ZIP 归档"; +"PreviewSender.Ungrouped" = "不合并"; +"PreviewSender.UploadVideoCover" = "上传视频封面"; +"Privacy.Gifts.AcceptedGiftTypes" = "接受的礼物类型"; +"Privacy.Gifts.Info" = "选择您允許他人向您赠送的礼物类型。"; +"Privacy.Gifts.LimitedEdition" = "限量版"; +"Privacy.Gifts.Premium" = "高级版订阅"; +"Privacy.Gifts.Unique" = "独家"; +"Privacy.Gifts.Unlimited" = "无限量"; +"Privacy.GroupsAndChannels.InviteToChannelError" = "抱歉,根据 %@ 的隐私设置,您无法将 %@ 加入频道。"; +"Privacy.GroupsAndChannels.InviteToChannelMultipleError" = "抱歉,根据其中一些用户的隐私设置,您无法与这些人建立群组。"; +"Privacy.GroupsAndChannels.InviteToGroupError" = "抱歉,根据 %@ 的隐私设置,您无法将 %@ 添加到群组。"; +"Privacy.ResetPhoto.Confirm" = "你确定要删除公开照片吗?"; +"PrivacyAndSecurity.AutoArchiveDesc" = "自动归档及静音来自非联系人的新聊天、群组和频道。"; +"PrivacyAndSecurity.AutoArchiveHeader" = "来自未知用户的新对话"; +"PrivacyAndSecurity.AutoArchiveText" = "归档并静音"; +"PrivacyAndSecurity.BlockedUsers" = "%@"; +"PrivacyAndSecurity.ClearCloudDrafts" = "删除所有云端草稿"; +"PrivacyAndSecurity.ClearCloudDraftsHeader" = "聊天"; +"PrivacyAndSecurity.Confirm.ClearCloudDrafts" = "您确定要删除所有云端草稿吗?"; +"PrivacyAndSecurity.Item.Off" = "关闭"; +"PrivacyAndSecurity.Item.On" = "开启"; +"PrivacyAndSecurity.SecretChatWebPreview.Desc" = "链接预览将在 Telegram 服务器上生成,我们不会储存与您所发送的链接有关的任何数据。"; +"PrivacyAndSecurity.SecretChatWebPreview.Header" = "私密聊天"; +"PrivacyAndSecurity.SecretChatWebPreview.Text" = "链接预览"; +"PrivacyAndSecurity.SensitiveDesc" = "在您的所有Telegram设备上公开频道中显示敏感媒体。"; +"PrivacyAndSecurity.SensitiveHeader" = "敏感内容"; +"PrivacyAndSecurity.SensitiveText" = "停用过滤"; +"PrivacyAndSecurity.WebAuthorizationHeader" = "已使用 Telegram 登录的网站"; +"PrivacyPhoneNumberSettings.CustomDisabledHelp" = "对于将您的号码加入联系人列表的用户,只有当您将其添加为联系人后,对方才会看到您的号码。"; +"PrivacyPhoneNumberSettings.DiscoveryHeader" = "谁可以通过手机号码找到我"; +"PrivacySettings.ActiveSessions" = "所有设备"; +"PrivacySettings.Bio" = "个人简介"; +"PrivacySettings.Birthday" = "生日"; +"PrivacySettings.BlockedUsers" = "已屏蔽的用户"; +"PrivacySettings.DeleteAccount" = "如果离开"; +"PrivacySettings.DeleteAccountDescription" = "如果您在此期间内您的帐号未曾上线,您帐号的所有资料——包括消息记录、联系人都会被删除。"; +"PrivacySettings.DeleteAccountHeader" = "删除我的帐号"; +"PrivacySettings.Forwards" = "转发的消息"; +"PrivacySettings.Gifts" = "礼物"; +"PrivacySettings.Gifts.EnableForBots" = "小程序"; +"PrivacySettings.GlobalTimer" = "自动删除消息"; +"PrivacySettings.GlobalTimer.Disable" = "禁用"; +"PrivacySettings.GlobalTimer.Group" = "一段时间后,自动为所有人删除此群组中的消息。"; +"PrivacySettings.GlobalTimer.Info" = "在开始的所有新聊天中,一段时间后自动删除所有消息。"; +"PrivacySettings.GlobalTimer.Never" = "关闭"; +"PrivacySettings.GroupMembersCount#other" = "%d 位成员"; +"PrivacySettings.GroupMembersCount_countable" = "%d"; +"PrivacySettings.Groups" = "群组与频道"; +"PrivacySettings.HideReadTime" = "隐藏已读时间"; +"PrivacySettings.HideReadTime.Info" = "对无法看到您最后上线时间的用户隐藏您的已读时间。如果您开启此功能,对方的已读时间也会对您隐藏(除非您是高级版订阅用户)。 \n\n此设定不影响群聊。"; +"PrivacySettings.LastSeen" = "最后上线时间和在线状态"; +"PrivacySettings.LastSeenContactsMinus" = "我的联系人 (-%@)"; +"PrivacySettings.LastSeenContactsMinusPlus" = "我的联系人 (-%@, +%@)"; +"PrivacySettings.LastSeenContactsPlus" = "我的联系人 (+%@)"; +"PrivacySettings.LastSeenEverybodyMinus" = "所有人 (-%@)"; +"PrivacySettings.LastSeenNobodyPlus" = "不允许任何人 (+%@)"; +"PrivacySettings.LoginEmail" = "邮箱登录"; +"PrivacySettings.LoginEmailAlertChange" = "更改邮箱"; +"PrivacySettings.LoginEmailAlertText" = "你每次从新的设备登入Telegram 时都会使用该电子邮箱。"; +"PrivacySettings.LoginEmailInfo" = "为获取 Telegram 登录验证码,请修改你的邮箱地址。"; +"PrivacySettings.LoginEmailSetupInfo" = "设置您的电子邮件地址以接收 Telegram 登录验证码。"; +"PrivacySettings.Messages" = "消息"; +"PrivacySettings.Messages.All" = "所有人"; +"PrivacySettings.Messages.Contacts" = "我的联系人与高级版订阅用户"; +"PrivacySettings.Messages.Everyone" = "所有人"; +"PrivacySettings.Messages.Header" = "谁可以给我发消息?"; +"PrivacySettings.Messages.Info" = "您可以限制非联系人且您未主动联系的用户给您发送消息。 \n[什么是 Telegram 高级版?]()"; +"PrivacySettings.Messages.MyContacts" = "联系人与高级版订阅用户"; +"PrivacySettings.Messages.PremiumError" = "只有 [Telegram Premium]() 的订阅者可以限制收到私信。"; +"PrivacySettings.Passcode" = "锁定码"; +"PrivacySettings.Passkey" = "通行密钥"; +"PrivacySettings.PeerSelect.AddNew" = "添加..."; +"PrivacySettings.PeerSelect.AddUserOrGroup" = "添加用户或群组"; +"PrivacySettings.PhoneNumber" = "手机号码"; +"PrivacySettings.Premium" = "订阅 Telegram 高级版"; +"PrivacySettings.Premium.Info" = "如果您订阅了 Telegram 高级版,您可以看到其他用户的最后上线时间及已读时间,即使您对其隐藏了自己的最后上线时间及已读时间(除非对方明确限制)。"; +"PrivacySettings.PremiumUsers" = "高级版"; +"PrivacySettings.PrivacyHeader" = "隐私"; +"PrivacySettings.ProfilePhoto" = "头像"; +"PrivacySettings.ProxyHeader" = "连接类型"; +"PrivacySettings.SavedMusic" = "保存的音乐"; +"PrivacySettings.SecurityHeader" = "安全"; +"PrivacySettings.TwoStepVerification" = "两步验证"; +"PrivacySettings.UseProxy" = "使用代理"; +"PrivacySettings.Voice.PremiumError" = "只有 [Telegram 高级版]() 订阅者才可以限制接受语音或视频消息。"; +"PrivacySettings.VoiceCalls" = "通话"; +"PrivacySettings.VoiceMessages" = "语音消息"; +"PrivacySettingsController.AddUsers" = "添加用户"; +"PrivacySettingsController.AlwaysAllow" = "总是允许"; +"PrivacySettingsController.AlwaysShare" = "总是分享给"; +"PrivacySettingsController.AlwaysShareWith" = "总是分享给……"; +"PrivacySettingsController.Bio.AlwaysShareWith.Title" = "总是共享给"; +"PrivacySettingsController.Bio.CustomHelp" = "您可以精确地限制谁可以看到您个人资料页上的简介。"; +"PrivacySettingsController.Bio.NeverShareWith.Title" = "从不分享给"; +"PrivacySettingsController.Bio.WhoCanSee" = "谁可以看到我的个人简介?"; +"PrivacySettingsController.Birthday.AddHeader" = "您还未添加生日。\n[添加我的生日 >]()"; +"PrivacySettingsController.Birthday.AlwaysShareWith.Title" = "始终与谁分享"; +"PrivacySettingsController.Birthday.CustomHelp" = "您可以精确限制谁能看到您的出生日期。"; +"PrivacySettingsController.Birthday.NeverShareWith.Title" = "永不与谁分享"; +"PrivacySettingsController.Birthday.WhoCanSee" = "谁能看到我的生日"; +"PrivacySettingsController.Everbody" = "所有人"; +"PrivacySettingsController.Forwards.AlwaysAllow.Title" = "总是允许"; +"PrivacySettingsController.Forwards.CustomHelp" = "您可以限制谁可以转发您的信息。"; +"PrivacySettingsController.Forwards.NeverAllow.Title" = "从不允许"; +"PrivacySettingsController.Forwards.WhoCanForward" = "转发我的消息时,谁可以链接至我的账号"; +"PrivacySettingsController.Gift.ShowGiftIcon" = "在聊天中显示礼物图标"; +"PrivacySettingsController.Gift.ShowGiftIcon.Info" = "在所有聊天的消息输入框中显示礼物图标,所有参与者均可见。"; +"PrivacySettingsController.Gifts.CustomHelp" = "选择来自特定用户的礼物是否需要经过您的批准才能在您的个人资料页中对他人可见。"; +"PrivacySettingsController.Gifts.WhoCanSee" = "谁可以在我的个人资料页上展示礼物"; +"PrivacySettingsController.GroupDescription" = "您可以详细控制哪些人能够将您加入到群组和频道中。"; +"PrivacySettingsController.GroupHeader" = "谁可以加我到群聊"; +"PrivacySettingsController.Header" = "最近上线"; +"PrivacySettingsController.LastSeenDescription" = "除非您是高级版订阅用户,如果对方没有和你分享他们的最近在线时间,则你只能看到大致的时间段,如最近,一周内,一个月内等。"; +"PrivacySettingsController.LastSeenHeader" = "谁可以看到我最后上线的时间"; +"PrivacySettingsController.MyContacts" = "我的联系人"; +"PrivacySettingsController.NeverAllow" = "从不允许"; +"PrivacySettingsController.NeverShare" = "从不分享给"; +"PrivacySettingsController.NeverShareWith" = "从不分享给"; +"PrivacySettingsController.Nobody" = "不允许任何人"; +"PrivacySettingsController.P2p.Always" = "总是"; +"PrivacySettingsController.P2p.Contacts" = "我的联系人"; +"PrivacySettingsController.P2p.Desc" = "停用端对端连接后,所有通话流量将使用 Telegram 服务器中转以避免泄露您的 IP 地址,但可能会降低音质及画质。"; +"PrivacySettingsController.P2p.Header" = "端对端连接"; +"PrivacySettingsController.P2p.Never" = "从不"; +"PrivacySettingsController.PeerInfo" = "您可以将用户或整个群组设为例外,不受以上设置的限制。"; +"PrivacySettingsController.PhoneCallDescription" = "你可以限制其他人和你语音通话的权限"; +"PrivacySettingsController.PhoneCallHeader" = "谁可以呼叫我"; +"PrivacySettingsController.PhoneNumber.AlwaysAllow.Title" = "总是共享给"; +"PrivacySettingsController.PhoneNumber.CustomHelp" = "已将您的手机号码保存到联系人的用户也将在 Telegram 上看到它。"; +"PrivacySettingsController.PhoneNumber.NeverAllow.Title" = "从不分享"; +"PrivacySettingsController.PhoneNumber.WhoCanSeePhoneNumber" = "谁能看到我的电话号码"; +"PrivacySettingsController.ProfilePhoto.AlwaysShareWith.Title" = "总是分享给"; +"PrivacySettingsController.ProfilePhoto.CustomHelp" = "您可以精确地限制某些用户查看您的头像。"; +"PrivacySettingsController.ProfilePhoto.NeverShareWith.Title" = "从不分享给"; +"PrivacySettingsController.ProfilePhoto.WhoCanSeeMyPhoto" = "谁能查看我的头像"; +"PrivacySettingsController.RemoveFee" = "免收费用"; +"PrivacySettingsController.RemovePublicPhoto" = "移除公共照片"; +"PrivacySettingsController.SavedMusic.AlwaysShareWith.Title" = "总是分享给"; +"PrivacySettingsController.SavedMusic.CustomHelp" = "您可以精确地限制谁可以看到您保存的音乐。"; +"PrivacySettingsController.SavedMusic.NeverShareWith.Title" = "从不分享给"; +"PrivacySettingsController.SavedMusic.WhoCanSee" = "谁可以看到我保存的音乐"; +"PrivacySettingsController.SetPublicPhoto" = "设置公共照片"; +"PrivacySettingsController.SetPublicPhoto.Info" = "您可以为被限制查看真实个人资料照片的用户上传公开照片。"; +"PrivacySettingsController.UpdatePublicPhoto" = "更新公共照片"; +"PrivacySettingsController.UserCount#other" = "%d 位用户"; +"PrivacySettingsController.UserCount_countable" = "%d"; +"PrivacySettingsController.VoiceMessagesDescription" = "你可以精确地控制谁可以向你发送语音或视频消息。"; +"PrivacySettingsController.VoiceMessagesDescription.NonPremium" = "订阅 Telegram 高级版以限制向您可以发送语音或视频消息的用户。\n\n[Telegram 高级版是什么?]()"; +"PrivacySettingsController.VoiceMessagesHeader" = "谁可以给我发送语音或视频消息?"; +"PrivateChannel.Peek.Cancel" = "取消"; +"PrivateChannel.Peek.Header" = "加入频道"; +"PrivateChannel.Peek.OK" = "加入频道"; +"PrivateChannel.Peek.Text" = "该频道为私人频道。请加入后继续浏览。"; +"ProfileTab.ContextMenu.SetAsMain" = "设为主选项卡"; +"Proxy.CheckStatus" = "检查状态"; +"Proxy.ForceDisable" = "您确定要禁用代理服务器 %@ 吗?"; +"Proxy.ForceEnable.Connect" = "连接"; +"Proxy.ForceEnable.Enable" = "启用代理"; +"Proxy.ForceEnable.Header1" = "您想要添加这个代理服务器吗?"; +"Proxy.ForceEnable.MTPDesc" = "这个代理可能会在您的会话列表中显示其赞助频道。代理服务器无法泄露任何您的 Telegram 内容。"; +"Proxy.ForceEnable.OK" = "添加代理"; +"Proxy.ForceEnable.Text" = "您可以稍后在「设置 - 数据和存储」中更改您的代理服务器。"; +"Proxy.ForceEnable.Text.IP" = "服务器: %@"; +"Proxy.ForceEnable.Text.Password" = "密码:%@"; +"Proxy.ForceEnable.Text.Port" = "端口: %d"; +"Proxy.ForceEnable.Text.Secret" = "密钥:%@"; +"Proxy.ForceEnable.Text.Username" = "用户名:%@"; +"Proxy.Password" = "密码"; +"Proxy.Port" = "端口"; +"Proxy.Proceed" = "继续"; +"Proxy.Server" = "服务器"; +"Proxy.Status" = "状态"; +"Proxy.Username" = "用户名"; +"Proxy.Warning" = "警告"; +"Proxy.WarningInfo" = "这会将您的 IP 地址暴露给代理服务器管理员。"; +"ProxySettings.AddProxy" = "添加代理"; +"ProxySettings.ConnectionHeader" = "连接信息"; +"ProxySettings.CopyLink" = "分享代理链接"; +"ProxySettings.CredentialsHeader" = "认证信息(可选)"; +"ProxySettings.Disabled" = "已停用"; +"ProxySettings.Enable" = "代理"; +"ProxySettings.ExportDescription" = "如果你的剪贴板内含有 socks5-link (**t.me/socks?server=127.0.0.1&port=80**) ,它将立即显示"; +"ProxySettings.ExportLink" = "从剪贴板导出链接"; +"ProxySettings.IncorrectSecret" = "密码不正确,请重试。"; +"ProxySettings.Item.Available" = "可用(ping:%@ 毫秒)"; +"ProxySettings.Item.Checking" = "检查中"; +"ProxySettings.Item.Connected" = "已连接"; +"ProxySettings.Item.ConnectedPing" = "已连接(ping:%@ 毫秒)"; +"ProxySettings.Item.LastConnection" = "上次连接 %@"; +"ProxySettings.Item.NeverConnected" = "不可用"; +"ProxySettings.MTP" = "MTPROTO"; +"ProxySettings.Mtp.Sponsor" = "这个代理可能会在您的会话列表中显示其赞助频道。代理服务器无法泄露任何您的 Telegram 内容。"; +"ProxySettings.Password" = "密码"; +"ProxySettings.Port" = "端口"; +"ProxySettings.ProxyNotFound" = "在剪贴板内未找到代理服务器设置。"; +"ProxySettings.QR.Text" = "您的朋友可以使用相机扫码添加此代理。"; +"ProxySettings.Save" = "保存"; +"ProxySettings.Secret" = "密钥"; +"ProxySettings.Server" = "服务器"; +"ProxySettings.Share" = "与朋友分享此链接,规避国家网络审查"; +"ProxySettings.ShareProxyList" = "分享代理列表"; +"ProxySettings.Socks5" = "SOCKS5"; +"ProxySettings.Title" = "代理偏好设置"; +"ProxySettings.Type" = "代理类型"; +"ProxySettings.UseForCalls" = "用于通话"; +"ProxySettings.Username" = "用户名"; +"QuickLook.Preview" = "预览"; +"QuickReply.ChatRemoveAwayMessage.Text" = "您没有创建离开信息。退出即可删除。"; +"QuickReply.ChatRemoveAwayMessage.Title" = "删除离开消息"; +"QuickReply.ChatRemoveGeneric.DeleteAction" = "删除"; +"QuickReply.ChatRemoveGeneric.Text" = "你未添加快速回复消息。退出将会清除该指令。"; +"QuickReply.ChatRemoveGeneric.Title" = "移除快捷键"; +"QuickReply.ChatRemoveGreetingMessage.Text" = "您没有创建问候语。退出将删除它。"; +"QuickReply.ChatRemoveGreetingMessage.Title" = "删除问候语"; +"QuickSwitcher.Description" = "按 **TAB** 或 **↑ ↓** 移动,按 **⮐** 选择,按 **ESC** 关闭"; +"QuickSwitcher.Popular" = "人气"; +"QuickSwitcher.Recently" = "最近"; +"R4o-n2-Eq4.title" = "缩放"; +"Reaction.Settings.Allow" = "允许回应"; +"Reaction.Settings.Allow.Channel.Info" = "允许订阅者对频道消息作出回应。"; +"Reaction.Settings.Allow.Group.Info" = "允许成员对群组消息作出表情回应。"; +"Reaction.Settings.Available.Info" = "可用的回应"; +"Reaction.Settings.Group.All" = "所有表情回应"; +"Reaction.Settings.Group.None" = "禁用表情回应"; +"Reaction.Settings.Group.Some" = "部分表情回应"; +"Reaction.Settings.Legacy" = "悬停反应"; +"Reaction.Settings.Legacy.Info" = "鼠标悬停在消息旁边时,将显示反应按钮。"; +"Reaction.Settings.Quick.Info" = "快速回应"; +"Reaction.Settings.Quick.Title" = "快速回应"; +"Reaction.Settings.Title" = "回应"; +"Reactions.Popular" = "热门"; +"Reactions.Preview.Premium" = "通过订阅 Telegram 高级版来解锁额外的回应"; +"Reactions.Preview.Unlock" = "解锁高级版消息反馈"; +"Reactions.RecentlyUsed" = "最近使用"; +"RecentCalls.Delete" = "删除"; +"RecentCalls.DeleteCalls" = "您确定想要删除此次通话吗?"; +"RecentCalls.DeleteForMeAnd" = "为我和 %@ 删除"; +"RecentCalls.DeleteHeader" = "删除"; +"RecentCalls.Empty" = "您最近的通话将在此显示"; +"RecentCalls.NewCall" = "创建通话链接"; +"RecentCalls.NewCall.OK" = "通话"; +"RecentCalls.NewCallLink" = "新建通话链接"; +"RecentCalls.RecentCalls" = "最近通话"; +"RecentSessions.Confirm.Revoke" = "您确定要注销此设备?"; +"RecentSessions.Confirm.TerminateOthers" = "您确定要终止所有其他的登录会话吗?"; +"RecentSessions.Error.FreshReset" = "出于安全原因,您无法终止刚刚连接的设备上的会话。 请使用之前的设备连接或等待几个小时。"; +"RecentSessions.IncompleteAttemptDesc" = "上述设备无法获取您的消息。验证码输入正确,但没有提供正确的密码。"; +"RecentSessions.IncompleteAttemptHeader" = "未完成的登录请求"; +"RecentSessions.Revoke" = "撤销"; +"RecentSessions.TTL.Header" = "自动终止旧会话"; +"RecentSessions.TTL.Text" = "如果我不上线"; +"Report.AdditionText" = "请输入与您的举报有关的任何其他细节。"; +"Report.AdditionText.Button" = "报告"; +"Report.AdditionText.Placeholder" = "更多细节……"; +"Report.Complicated.Message.Title" = "举报消息"; +"Report.Complicated.Messages.Title" = "举报消息"; +"Report.Complicated.Peer.Title" = "举报 %@"; +"Report.Complicated.SendReport" = "发送举报"; +"Report.Complicated.Story.Title" = "举报动态"; +"ReportReason.ChildAbuse" = "虐待儿童"; +"ReportReason.Copyright" = "版权"; +"ReportReason.Drugs" = "违禁药品"; +"ReportReason.Fake" = "虚假"; +"ReportReason.Other" = "其他"; +"ReportReason.Other.Placeholder" = "描述"; +"ReportReason.PersonalDetails" = "个人信息"; +"ReportReason.Porno" = "色情"; +"ReportReason.Report" = "举报"; +"ReportReason.Spam" = "垃圾信息"; +"ReportReason.Violence" = "暴力"; +"RequestAcces.Error.Conirm.Settings" = "设置"; +"RequestAcces.Error.HaveNotAccess.Call" = "Telegram 需要访问麦克风,以便您拨打电话。"; +"RequestAcces.Error.HaveNotAccess.VideoMessages" = "Telegram 需要获取你的麦克风和相机权限来录制视频。"; +"RequestAcces.Error.HaveNotAccess.VoiceMessages" = "Telegram 需要获取麦克风权限录取语音消息。"; +"RequestJoin.Button" = "请求加入"; +"RequestJoin.Desc.Channel" = "此频道仅在管理员批准后才接受新订阅者。"; +"RequestJoin.Desc.Group" = "此群组仅在其管理员批准后才接受新成员。"; +"RequestJoin.Error.AlreadySent.Channel" = "你已请求加入此频道"; +"RequestJoin.Error.AlreadySent.Group" = "您已请求加入此群组"; +"RequestJoin.List.Approve.All" = "全部审核"; +"RequestJoin.List.Approve.Channel" = "添加至频道"; +"RequestJoin.List.Approve.Dismiss" = "拒绝"; +"RequestJoin.List.Approve.Group" = "添加到群组"; +"RequestJoin.List.Description" = "另已设置 [其他链接]() 接受加入频道的请求。"; +"RequestJoin.List.Dismiss.All" = "全部拒绝"; +"RequestJoin.List.Empty1" = "暂无加入请求"; +"RequestJoin.List.Empty2.Channel" = "没有待处理的加入频道请求"; +"RequestJoin.List.Empty2.Group" = "无加入此群组的请求。"; +"RequestJoin.List.ListHeader#other" = " %d 个加入请求"; +"RequestJoin.List.ListHeader_countable" = "%d"; +"RequestJoin.List.SearchEmpty" = "没有找到“%@”的相关结果。\n请重新搜索。"; +"RequestJoin.List.SearchEmpty.Header" = "无结果"; +"RequestJoin.List.Status.Approved" = "已批准"; +"RequestJoin.List.Status.Dismissed" = "被拒绝"; +"RequestJoin.List.Title" = "加入请求"; +"RequestJoin.List.Tooltip.Approved.Channel" = "已将 %@ 加入频道。"; +"RequestJoin.List.Tooltip.Approved.Group" = "**%@**已添加到群组中。"; +"RequestJoin.Sent" = "加入请求已发送"; +"Ruw-6m-B2m.title" = "全选"; +"SavedAs.ModalOk" = "已保存!\n[在 访达 中查看]()"; +"SavedMessages.Tags.Unlock" = "解锁"; +"Schedule.SendDate" = "在 %@ %@ 发送"; +"Schedule.SendToday" = "今天 %@ 发送"; +"Schedule.SendWhenOnline" = "上线后发送"; +"ScheduleController.Title" = "定时消息"; +"ScheduleController.at" = "在"; +"Search.Ad" = "广告"; +"Search.Ad.About.Remove" = "您可以通过订阅 Telegram 高级版来关闭广告。 "; +"Search.Ad.RemoveAd" = "移除广告"; +"Search.Ad.SponsorInfo" = "赞助商信息"; +"Search.Confirm.ClearHistory" = "您确定要清空搜索历史记录吗?"; +"Search.Filter.AllChats" = "所有聊天"; +"Search.Filter.Channels" = "频道"; +"Search.Filter.ClearFilter" = "清除筛选"; +"Search.Filter.Files" = "文件"; +"Search.Filter.From" = "来自 %@"; +"Search.Filter.GIFs" = "GIF"; +"Search.Filter.GroupChats" = "群聊"; +"Search.Filter.Links" = "链接"; +"Search.Filter.Music" = "音乐"; +"Search.Filter.Photos" = "照片"; +"Search.Filter.PrivateChats" = "私聊"; +"Search.Filter.Videos" = "视频"; +"Search.Filter.Voice" = "语音"; +"Search.Global.Channel1#other" = "%@, %d 位订阅者"; +"Search.Global.Channel1_countable" = "%@ %d"; +"Search.Global.Group1#other" = "%@, %d 位成员"; +"Search.Global.Group1_countable" = "%@ %d"; +"Search.Popular.Articles" = "文章"; +"Search.Popular.Delete" = "删除"; +"Search.Popular.SavedMessages" = "已收藏"; +"Search.RemoveFromRecent" = "从最近使用文档的列表中移除"; +"Search.Separator.Apps.Popular" = "热门应用程序"; +"Search.Separator.Apps.YouUse" = "您用过的应用"; +"Search.Separator.ChannelsJoined" = "您加入的频道"; +"Search.Separator.ChatsAndContacts" = "联系人与聊天"; +"Search.Separator.GlobalPeers" = "全局搜索"; +"Search.Separator.Messages" = "消息"; +"Search.Separator.Popular" = "用户"; +"Search.Separator.Recent" = "最近"; +"Search.Separator.Recommended" = "相似的频道"; +"Search.Separator.ShowAsMessages" = "显示全部消息"; +"Search.Separator.Topics" = "话题"; +"SearchField.Search" = "搜索"; +"SecretTimer.Off" = "关闭"; +"SecureId.Accept.Help" = "您的文件将被直接发送给 **%@**。同时,也将允许 **%@** 给您发送信息。"; +"SecureId.Accept.Policy" = "您同意[登录部件示例隐私政策](_applyPolicy_)并允许他们的 **%@** 给您发送信息。"; +"SecureId.Add.BankStatement" = "添加银行对账单"; +"SecureId.Add.DriverLicense" = "添加驾驶证"; +"SecureId.Add.ID" = "添加身份证"; +"SecureId.Add.InternalPassport" = "添加户口(Internal Passport)"; +"SecureId.Add.Passport" = "添加护照"; +"SecureId.Add.PassportRegistration" = "添加护照证明"; +"SecureId.Add.PersonalDetails" = "添加个人信息"; +"SecureId.Add.RentalAgreement" = "添加租约信息"; +"SecureId.Add.ResidentialAddress" = "添加住址"; +"SecureId.Add.TemporaryRegistration" = "添加临时证明"; +"SecureId.Add.TenancyAgreement" = "添加租约信息"; +"SecureId.Add.UtilityBill" = "添加水电气费账单"; +"SecureId.Address.City.InputPlaceholder" = "城市"; +"SecureId.Address.City.Placeholder" = "城市"; +"SecureId.Address.Country.Placeholder" = "国家/地区"; +"SecureId.Address.Header" = "地址信息"; +"SecureId.Address.Postcode.InputPlaceholder" = "邮政编码"; +"SecureId.Address.Postcode.Placeholder" = "邮政编码"; +"SecureId.Address.Region.InputPlaceholder" = "州/省份/地区"; +"SecureId.Address.Region.Placeholder" = "地区"; +"SecureId.Address.Scans#other" = "%d 份扫描件"; +"SecureId.Address.Scans_countable" = "%d"; +"SecureId.Address.Street.InputPlaceholder" = "街道、号码和邮箱"; +"SecureId.Address.Street.Placeholder" = "街道"; +"SecureId.Address.Street1.InputPlaceholder" = "公寓,套间,单元,建筑,楼层"; +"SecureId.AppVersionOutdated" = "抱歉,您的 Telegram 版本太低,无法处理此请求。 请升级 Telegram。"; +"SecureId.Confirm.Cancel" = "您确定要停止授权吗?"; +"SecureId.Confirm.DeleteAddress" = "删除住址信息"; +"SecureId.Confirm.DeleteDocument" = "您确定要删除此证件吗?"; +"SecureId.CorrectErrors" = "请纠正错误"; +"SecureId.CreatePassword.Description" = "请创建一个用于保护您的通行证信息的密码。您也需要在登录到 Telegram 时输入此密码。"; +"SecureId.CreatePassword.Email.Description" = "请添加您的有效电子邮箱。这是忘记密码时唯一的恢复方法。"; +"SecureId.CreatePassword.Email.Header" = "密码恢复邮箱"; +"SecureId.CreatePassword.Email.InputPlaceholder" = "您的邮箱"; +"SecureId.CreatePassword.Email.Placeholder" = "邮箱"; +"SecureId.CreatePassword.Header" = "密码"; +"SecureId.CreatePassword.Hint.Header" = "提示"; +"SecureId.CreatePassword.Hint.InputPlaceholder" = "密码提示"; +"SecureId.CreatePassword.Hint.Placeholder" = "提示"; +"SecureId.CreatePassword.Intro" = "请创建一个密码来保护您的个人数据。\n\n每次您在新的设备登录到 Telegram 时也将需要输入这个密码。"; +"SecureId.CreatePassword.Intro.Header" = "**%@请求访问您的个人数据** 以便为您提供服务。"; +"SecureId.CreatePassword.PasswordInputPlaceholder" = "请输入您的密码"; +"SecureId.CreatePassword.PasswordPlaceholder" = "密码"; +"SecureId.CreatePassword.RePasswordInputPlaceholder" = "请再次输入密码"; +"SecureId.CreatePassword.Title" = "密码和邮箱"; +"SecureId.Delete.Confirm.PersonalDetails" = "您确定要删除个人信息吗?"; +"SecureId.Delete.PersonalDetails" = "删除个人信息"; +"SecureId.DeleteAddress" = "删除住址信息"; +"SecureId.DeleteIdentity" = "删除证件"; +"SecureId.DeletePassport" = "删除 Telegram 通行证"; +"SecureId.DiscardChanges.Header" = "放弃更改"; +"SecureId.DiscardChanges.Text" = "您确定要放弃所有更改吗?"; +"SecureId.Edit.BankStatement" = "编辑银行对账单信息"; +"SecureId.Edit.DriverLicense" = "编辑驾驶证信息"; +"SecureId.Edit.ID" = "编辑身份证信息"; +"SecureId.Edit.InternalPassport" = "编辑户口(Internal Passport)信息"; +"SecureId.Edit.Passport" = "编辑护照信息"; +"SecureId.Edit.PassportRegistration" = "编辑护照证明信息"; +"SecureId.Edit.PersonalDetails" = "编辑个人信息"; +"SecureId.Edit.RentalAgreement" = "编辑租约信息"; +"SecureId.Edit.ResidentialAddress" = "编辑住址信息"; +"SecureId.Edit.TemporaryRegistration" = "编辑临时证明信息"; +"SecureId.Edit.TenancyAgreement" = "编辑租约信息"; +"SecureId.Edit.UtilityBill" = "编辑水电气费账单信息"; +"SecureId.Email" = "电子邮件地址"; +"SecureId.Email.Email.InputPlaceholder" = "请输入您的邮箱"; +"SecureId.Email.Email.Placeholder" = "邮箱"; +"SecureId.Email.UseSame" = "使用 %@"; +"SecureId.Email.UseSame.Desc" = "注意:您所提供的电子邮箱将会收到一份验证码。"; +"SecureId.EmailActivate.Code.InputPlaceholder" = "输入验证码"; +"SecureId.EmailActivate.Code.Placeholder" = "验证码"; +"SecureId.EmailActivate.Description" = "请输入我们刚刚向 %@ 发送的验证码"; +"SecureId.EmptyDescription.Address" = "提供您的住址"; +"SecureId.EmptyDescription.BankStatement" = "上传一份银行对账单的扫描件"; +"SecureId.EmptyDescription.DriversLicense" = "上传一份驾驶证扫描件"; +"SecureId.EmptyDescription.IdentityCard" = "上传一份身份证的扫描件"; +"SecureId.EmptyDescription.InternalPassport" = "上传一份户口本(Internal Passport)的扫描件"; +"SecureId.EmptyDescription.Passport" = "上传一份护照的扫描件"; +"SecureId.EmptyDescription.PassportRegistration" = "上传您护照注册页的扫描件"; +"SecureId.EmptyDescription.PersonalDetails" = "填写个人信息"; +"SecureId.EmptyDescription.TemporaryRegistration" = "上传一份您临时证明的扫描件"; +"SecureId.EmptyDescription.TenancyAgreement" = "上传一份租约的扫描件"; +"SecureId.EmptyDescription.UtilityBill" = "上传一份水电气费的扫描件"; +"SecureId.Error.ScansLimit" = "您同时最多只能上传二十个文件。"; +"SecureId.FileUpload.Progress" = "已上传 %@%%"; +"SecureId.Gender.Female" = "女"; +"SecureId.Gender.Male" = "男"; +"SecureId.Identity.BankStatement" = "银行对账单"; +"SecureId.Identity.CardId.InputPlaceholder" = "身份证编号"; +"SecureId.Identity.CardId.Placeholder" = "身份证编号"; +"SecureId.Identity.DocumentDetailsHeader" = "证件信息"; +"SecureId.Identity.DriverLicense" = "驾驶证"; +"SecureId.Identity.Id" = "身份证"; +"SecureId.Identity.InputPlaceholder.FirstName" = "名字"; +"SecureId.Identity.InputPlaceholder.LastName" = "姓氏"; +"SecureId.Identity.InputPlaceholder.MiddleName" = "中间名"; +"SecureId.Identity.License.InputPlaceholder" = "号码"; +"SecureId.Identity.License.Placeholder" = "驾照编号"; +"SecureId.Identity.NameInLatine" = "以拉丁字母形式输入您的姓名"; +"SecureId.Identity.Passport" = "护照"; +"SecureId.Identity.Passport.InputPlaceholder" = "证件号"; +"SecureId.Identity.Passport.Placeholder" = "护照编号"; +"SecureId.Identity.PassportRegistration" = "护照信息"; +"SecureId.Identity.Placeholder.Birthday" = "生日"; +"SecureId.Identity.Placeholder.Citizenship" = "国籍"; +"SecureId.Identity.Placeholder.Country" = "国家"; +"SecureId.Identity.Placeholder.ExpiryDate" = "失效日期"; +"SecureId.Identity.Placeholder.FirstName" = "名字"; +"SecureId.Identity.Placeholder.Gender" = "性别"; +"SecureId.Identity.Placeholder.IssuedDate" = "发件日期"; +"SecureId.Identity.Placeholder.LastName" = "姓氏"; +"SecureId.Identity.Placeholder.MiddleName" = "中间名"; +"SecureId.Identity.Placeholder.Residence" = "居住地"; +"SecureId.Identity.Selfie" = "自拍照"; +"SecureId.Identity.SelfieHelp" = "上传一张您手持该文件的自拍。请保证您的 ID 与脸清晰可见。"; +"SecureId.Identity.SelfieTitle" = "自拍认证"; +"SecureId.Identity.SelfieUpload" = "添加自拍照"; +"SecureId.Identity.SelfieUploadNew" = "重新拍摄自拍照"; +"SecureId.Identity.TenancyAgreement" = "租约"; +"SecureId.Identity.UtilityBill" = "水电气费账单"; +"SecureId.IdentityDocument" = "身份证件"; +"SecureId.IdentityScan.Description" = "这份文件必须包含您的姓名、住址、一份邮票/条形码/二维码/logo,发件日期不能在 3 个月之前。"; +"SecureId.Info" = "有了 Telegram 通行证,您可以轻易地注册需要身份验证的网站和服务。\n\n您的信息、个人数据和证件都将受到端到端加密保护。未经您的允许,任何人(包括 Telegram)都无法读取它们。"; +"SecureId.Info.DeletePassport" = "您确定想要删除您的 Telegram 通行证吗?所有信息都将丢失。"; +"SecureId.Info.More" = "更多信息"; +"SecureId.Info.Title" = "Telegram 通行证是什么?"; +"SecureId.InputError.LatinOnly" = "只能使用拉丁字母。"; +"SecureId.InsertPassword.Description" = "请输入密码来访问个人数据"; +"SecureId.InsertPassword.Next" = "下一步"; +"SecureId.InsertPassword.Password" = "请输入您的密码"; +"SecureId.InsertPassword.Settings.Description" = "请输入 Telegram 密码以解密您的数据"; +"SecureId.InstallEmail.Title" = "邮箱"; +"SecureId.InstallPhone.Title" = "手机号码"; +"SecureId.LoginText" = "请登录到您的帐号以使用 Telegram 通行证。"; +"SecureId.NameNative.Desc.Empty" = "以您所居住的国家语言填写您的名字。"; +"SecureId.NameNative.Desc.Language" = "以您所居住的国家(%@)语言填写您的名字。"; +"SecureId.NameNative.Header" = "您的%@名"; +"SecureId.NameNative.HeaderEmpty" = "以您居住的国家语言填写名字"; +"SecureId.Password.Error.Invalid" = "密码错误,请重试。"; +"SecureId.Password.Error.Limit" = "尝试次数过多。请稍后再试。"; +"SecureId.PhoneNumber" = "电话号码"; +"SecureId.PhoneNumber.ConfirmCode.Desc" = "请输入我们刚刚通过短信发送到 %@ 的验证码。"; +"SecureId.PhoneNumber.ConfirmCode.InputPlaceholder" = "请输入验证码"; +"SecureId.PhoneNumber.ConfirmCode.Placeholder" = "验证码"; +"SecureId.PhoneNumber.Header" = "或输入其他的手机号码"; +"SecureId.PhoneNumber.Note" = "注意:您将在您提供的手机号码上收到一份验证码。"; +"SecureId.PhoneNumber.UseSame" = "使用 %@"; +"SecureId.PhoneNumber.UseSame.Desc" = "使用当前 Telegram 帐号绑定的手机号。"; +"SecureId.RecoverPassword" = "找回密码"; +"SecureId.RecoverPassword.SentEmailCode" = "验证码已发送到 %@"; +"SecureId.RemoveEmail" = "移除此邮箱地址?"; +"SecureId.RemovePhoneNumber" = "移除此电话号?"; +"SecureId.Request.Accept" = "授权"; +"SecureId.Request.CreatePassword" = "创建密码"; +"SecureId.Request.Header1" = "**%@** 请求访问您的个人数据以便为您提供服务。"; +"SecureId.Request.Permission.Address.Empty" = "上传您的地址证明"; +"SecureId.Request.Permission.BankStatement" = "银行对账单"; +"SecureId.Request.Permission.DriversLicense" = "驾驶证"; +"SecureId.Request.Permission.Email" = "邮箱地址"; +"SecureId.Request.Permission.Email.Empty" = "提供您的联系邮箱地址"; +"SecureId.Request.Permission.IDCard" = "身份证"; +"SecureId.Request.Permission.Identity.Empty" = "上传一份您的护照或其它身份证明的扫描件"; +"SecureId.Request.Permission.IdentityDocument" = "身份证件"; +"SecureId.Request.Permission.InternalPassport" = "户口本(Internal Passport)"; +"SecureId.Request.Permission.Passport" = "护照"; +"SecureId.Request.Permission.PassportRegistration" = "护照信息"; +"SecureId.Request.Permission.PersonalDetails" = "个人信息"; +"SecureId.Request.Permission.Phone" = "电话号码"; +"SecureId.Request.Permission.Phone.Empty" = "提供您的联系电话号码"; +"SecureId.Request.Permission.ResidentialAddress" = "住址"; +"SecureId.Request.Permission.TemporaryRegistration" = "临时证明信息"; +"SecureId.Request.Permission.TenancyAgreement" = "租约"; +"SecureId.Request.Permission.UtilityBill" = "水电气费账单"; +"SecureId.Request.TwoDocuments.Title" = "%@或%@"; +"SecureId.Request.Upload.Selfie" = "上传一张您拿着证件的自拍照"; +"SecureId.Request.Upload.Translation" = "上传您证件的翻译"; +"SecureId.RequestedInformation.Header" = "所需信息"; +"SecureId.ResidentialAddress" = "住址"; +"SecureId.ScanNumber" = "扫描 %d"; +"SecureId.Scans.Header" = "扫描件"; +"SecureId.Translation.Desc" = "上传经过认证的证件英语翻译扫描件。"; +"SecureId.Translation.Header" = "翻译"; +"SecureId.Upload.Front" = "上传证件正面照片"; +"SecureId.Upload.Main" = "上传证件第一页的照片"; +"SecureId.Upload.Reverse" = "上传证件反面照片"; +"SecureId.Upload.Selfie" = "上传您拿着证件的自拍照"; +"SecureId.Upload.Title.FrontSide" = "正面"; +"SecureId.Upload.Title.MainPage" = "文件主页"; +"SecureId.Upload.Title.ReverseSide" = "反面"; +"SecureId.UploadAdditionalScan" = "上传更多扫描件"; +"SecureId.UploadScan" = "上传扫描件"; +"SecureId.UploadScan.Multi" = "上传一份%@或%@的扫描件"; +"SecureId.UploadScan.Single" = "上传一份%@的扫描件"; +"SecureId.Warning.DataLost" = "**警告**:所有存储在 Telegram 通行证里的数据都将丢失。"; +"SecureId.forgotPassword.NoEmail" = "因为您在设置密码时没有同时提供密码恢复邮箱,您剩下的选择就只有回忆起您的密码或重置您的账号。"; +"Select.Gifts.Accept" = "添加礼物"; +"Select.Gifts.LimitReached" = "已达上线"; +"Select.Gifts.Title" = "添加礼物"; +"SelectAreaControl.Dimension.None" = "无"; +"SelectAreaControl.Dimension.Original" = "原始"; +"SelectAreaControl.Dimension.Square" = "正方形"; +"SelectColor.Apply" = "应用"; +"SelectColor.ChannelWallpaper" = "频道壁纸"; +"SelectColor.ChannelWallpaper.Info" = "设置每个阅读频道的人都能看到墙纸。"; +"SelectColor.EmojiPack.Group" = "群组表情包"; +"SelectColor.EmojiPack.Info.Group" = "选择一个可供群组内所有成员使用的表情包。"; +"SelectColor.EmojiStatus.Channel" = "频道表情状态"; +"SelectColor.EmojiStatus.Group" = "群组表情符号状态"; +"SelectColor.EmojiStatus.Info.Channel" = "选择将显示在频道名称旁边的状态。"; +"SelectColor.EmojiStatus.Info.Group" = "选择将显示在群组名称旁边的状态。"; +"SelectColor.EmojiStatus.Info.User" = "选择将显示在您个人资料页旁的状态。"; +"SelectColor.EmojiStatus.User" = "表情符号状态"; +"SelectColor.Empty" = "目前没有任何东西可供出售。"; +"SelectColor.Group.BlockInfo" = "群组成员可以 **助推** 群组使其升级并解锁这些功能。"; +"SelectColor.GroupWallpaper" = "群组壁纸"; +"SelectColor.GroupWallpaper.Info" = "设置一个所有阅读群组的人都可以看见的背景。"; +"SelectColor.Icon.Info.Channel" = "通过向消息添加自定图标,使您回复的消息脱颖而出。"; +"SelectColor.Icon.Info.Channel.Profile" = "通过向消息添加自定图标,使您回复的消息脱颖而出。"; +"SelectColor.Icon.Info.Group.Profile" = "通过在背景中添加自定图标,让您的群组资料页脱颖而出。"; +"SelectColor.Icon.Info.User" = "通过向消息添加自定图标,使您回复的消息脱颖而出。"; +"SelectColor.Icon.Info.User.Profile" = "通过在背景中添加自定图标,让您的个人资料页脱颖而出。"; +"SelectColor.Icon.RemoveIcon" = "移除图标"; +"SelectColor.Icon.Select" = "图标"; +"SelectColor.Icon.Select.Off" = "未选定"; +"SelectColor.Icon.Select.Profile" = "资料页标志"; +"SelectColor.Icon.Select.Replies" = "回复标志"; +"SelectColor.Icon.Title" = "为回复添加图标"; +"SelectColor.Icon.Title.Profile" = "将图标添加到个人资料背景"; +"SelectColor.Info.Channel" = "您可以选择一种颜色为您的频道名称、发送的链接以及频道贴文的回复着色。"; +"SelectColor.Info.Channel.Profile" = "此颜色将用于您的频道资料页背景。"; +"SelectColor.Info.User" = "您可以选择一种颜色为自己的名字、发送的链接和收到的回复着色。"; +"SelectColor.Info.User.Profile" = "此颜色将用于您的个人资料页背景。"; +"SelectColor.Message1" = "回复你的消息"; +"SelectColor.Message2" = "您的名称与回复将以所选的颜色显示。"; +"SelectColor.Message2.Preview.Text" = "该颜色也会套用于您发送的链接预览。"; +"SelectColor.Message2.Preview.Title" = "链接预览"; +"SelectColor.MyGifts" = "我的礼物"; +"SelectColor.NoGifts" = "您没有任何可以用于您个人资料样式的礼物。\n\n[浏览可供购买的礼物 >](browse)"; +"SelectColor.NoGifts.Name" = "您没有任何可以用于您名称样式的礼物。\n\n[浏览可供购买的礼物 >](browse)"; +"SelectColor.NoGifts.Profile" = "您没有任何可以用于您个人资料样式的礼物。\n\n[浏览可供购买的礼物 >](browse)"; +"SelectColor.Premium" = "订阅 [Telegram 高级版]() 并为您的名字选择自定义颜色。"; +"SelectColor.Preview" = "颜色预览"; +"SelectColor.Preview.Profile" = "个人资料预览"; +"SelectColor.ProfilePage.Title" = "个人资料页颜色"; +"SelectColor.ResetColor" = "重置颜色"; +"SelectColor.ResetColor.Name" = "重置姓名颜色"; +"SelectColor.ResetColor.Profile" = "重置资料页颜色"; +"SelectColor.Success.Appearance.Channel" = "频道外观已更新!"; +"SelectColor.Success.Appearance.Group" = "您的频道外观已更新!"; +"SelectColor.Success.Appearance.User" = "您的个人资料页外观已更新!"; +"SelectColor.Success.Channel" = "您频道颜色已更新!"; +"SelectColor.Success.User" = "您频道颜色已更新!"; +"SelectColor.Tab.Name" = "名称"; +"SelectColor.Tab.Profile" = "个人资料页"; +"SelectColor.Title.Channel" = "频道颜色"; +"SelectColor.Title.User" = "你的名称颜色"; +"SelectColor.UseGift.Info" = "将您的典藏品独特外观应用到个人资料页。"; +"SelectColor.UseGift.Title" = "使用礼物"; +"SelectCountries.OK" = "保存国家"; +"SelectCountries.Title" = "选择国家"; +"SelectGifts.Filter.Upgradable" = "可升级"; +"SelectPeers.Chats" = "聊天"; +"SelectPeers.Contacts" = "联系人"; +"SelectPeers.Frequent" = "常用联系人"; +"SelectPeers.LimitReached" = "抱歉, 您不能选择超过 %@ 名用户"; +"SelectPeers.Title.SearchMembers" = "搜索成员"; +"SelectPeers.Title.SelectChannel" = "添加到频道"; +"SelectPeers.Title.SelectChat" = "选择聊天"; +"SelectPeers.Title.SelectGroup" = "添加到群组"; +"SelectPeers.Title.SelectGroupOrChannel" = "添加到群组或频道"; +"SelectPeers.UserTypes" = "用户类型"; +"SelectStory.Accept" = "添加动态"; +"SelectStory.LimitReached" = "已达上限\n"; +"SelectStory.Title" = "添加动态"; +"SelectivePrivacy.GiftChannels.FromChannels" = "来自频道"; +"SelectivePrivacy.SavedMusic" = "保存的音乐"; +"SendAudio.SavedMusic" = "已保存的音乐"; +"SendAudio.SelectFromFiles" = "从文件中选择"; +"SendAudio.Send" = "发送"; +"SendAudio.SharedAudio" = "共享音频"; +"SendAudio.Title" = "音频"; +"Separator.Clear" = "清除"; +"Separator.ShowLess" = "显示更少"; +"Separator.ShowMore" = "显示更多"; +"ServiceMessage.DesturctingPhoto" = "%@ 发送给您一张自毁照片,请在您的移动设备上查看。"; +"ServiceMessage.DesturctingPhoto.You" = "%@ 发送了一张自毁图片。"; +"ServiceMessage.DesturctingVideo" = "%@ 给您发送了一段阅后即焚视频,请在您的移动设备上查看。"; +"ServiceMessage.DesturctingVideo.You" = "%@ 发送了一张自毁视频。"; +"ServiceMessage.ExpiredFile" = "文件已自毁"; +"ServiceMessage.ExpiredPhoto" = "图片已过期"; +"ServiceMessage.ExpiredVideo" = "视频已自毁"; +"ServiceMessage.ExpiredVideoMessage" = "视频消息已过期"; +"ServiceMessage.ExpiredVoiceMessage" = "语音已过期"; +"SessionPreview.Accept.Calls" = "接听来电"; +"SessionPreview.Accept.Secret" = "新建私密聊天"; +"SessionPreview.AcceptHeader" = "允许在此设备上"; +"SessionPreview.App" = "应用程序"; +"SessionPreview.Ip" = "IP 地址"; +"SessionPreview.IpDesc" = "该位置根据 IP 地址估算,可能并不准确。"; +"SessionPreview.Location" = "位置"; +"SessionPreview.TerminateSession" = "终止会话"; +"SessionPreview.Title" = "设备"; +"Sessions.ActiveSessionsHeader" = "其他设备"; +"Sessions.CurrentSessionHeader" = "此设备"; +"Sessions.TerminateDescription" = "登出除此设备外的所有设备。"; +"Sessions.TerminateOthers" = "终止所有其他会话"; +"SettingsSearch.EmptyItem" = "“偏好设置”和“Telegram 常见问题”的搜索结果都将显示在这里。"; +"SettingsSearch.Recent" = "最近"; +"SettingsSearch.Recent.Clear" = "清除"; +"SettingsSearch.Synonyms.AppLanguage" = " "; +"SettingsSearch.Synonyms.Appearance.AutoNightTheme" = " "; +"SettingsSearch.Synonyms.Appearance.ChatBackground" = "壁纸\n背景"; +"SettingsSearch.Synonyms.Appearance.ChatBackground.Custom" = " "; +"SettingsSearch.Synonyms.Appearance.ChatBackground.SetColor" = " "; +"SettingsSearch.Synonyms.Appearance.ChatMode" = "对话框"; +"SettingsSearch.Synonyms.Appearance.ColorTheme" = " "; +"SettingsSearch.Synonyms.Appearance.TextSize" = "字体"; +"SettingsSearch.Synonyms.Appearance.Title" = " "; +"SettingsSearch.Synonyms.Calls.CallTab" = " "; +"SettingsSearch.Synonyms.Calls.Title" = " "; +"SettingsSearch.Synonyms.Data.AutoDownloadReset" = " "; +"SettingsSearch.Synonyms.Data.AutoDownloadUsingCellular" = " "; +"SettingsSearch.Synonyms.Data.AutoDownloadUsingWifi" = " "; +"SettingsSearch.Synonyms.Data.AutoplayGifs" = " "; +"SettingsSearch.Synonyms.Data.AutoplayVideos" = " "; +"SettingsSearch.Synonyms.Data.CallsUseLessData" = " "; +"SettingsSearch.Synonyms.Data.DownloadInBackground" = " "; +"SettingsSearch.Synonyms.Data.NetworkUsage" = " "; +"SettingsSearch.Synonyms.Data.SaveEditedPhotos" = " "; +"SettingsSearch.Synonyms.Data.SaveIncomingPhotos" = " "; +"SettingsSearch.Synonyms.Data.Storage.ClearCache" = " "; +"SettingsSearch.Synonyms.Data.Storage.KeepMedia" = " "; +"SettingsSearch.Synonyms.Data.Storage.Title" = "缓存"; +"SettingsSearch.Synonyms.Data.Title" = " "; +"SettingsSearch.Synonyms.EditProfile.AddAccount" = ""; +"SettingsSearch.Synonyms.EditProfile.Bio" = " "; +"SettingsSearch.Synonyms.EditProfile.Logout" = " "; +"SettingsSearch.Synonyms.EditProfile.PhoneNumber" = " "; +"SettingsSearch.Synonyms.EditProfile.Title" = " "; +"SettingsSearch.Synonyms.EditProfile.Username" = "用户名"; +"SettingsSearch.Synonyms.FAQ" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeCountUnreadMessages" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedChannels" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedChats" = " "; +"SettingsSearch.Synonyms.Notifications.BadgeIncludeMutedPublicGroups" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.ChannelNotificationsSound" = ""; +"SettingsSearch.Synonyms.Notifications.ContactJoined" = " "; +"SettingsSearch.Synonyms.Notifications.DisplayNamesOnLockScreen" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.GroupNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.InAppNotificationsVibrate" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsAlert" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsExceptions" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsPreview" = " "; +"SettingsSearch.Synonyms.Notifications.MessageNotificationsSound" = " "; +"SettingsSearch.Synonyms.Notifications.ResetAllNotifications" = " "; +"SettingsSearch.Synonyms.Notifications.Title" = " "; +"SettingsSearch.Synonyms.Passport" = " "; +"SettingsSearch.Synonyms.Privacy.AuthSessions" = " "; +"SettingsSearch.Synonyms.Privacy.BlockedUsers" = " "; +"SettingsSearch.Synonyms.Privacy.Calls" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ClearPaymentsInfo" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ContactsReset" = " "; +"SettingsSearch.Synonyms.Privacy.Data.ContactsSync" = " "; +"SettingsSearch.Synonyms.Privacy.Data.DeleteDrafts" = " "; +"SettingsSearch.Synonyms.Privacy.Data.SecretChatLinkPreview" = " "; +"SettingsSearch.Synonyms.Privacy.Data.Title" = " "; +"SettingsSearch.Synonyms.Privacy.Data.TopPeers" = " "; +"SettingsSearch.Synonyms.Privacy.DeleteAccountIfAwayFor" = " "; +"SettingsSearch.Synonyms.Privacy.Forwards" = " "; +"SettingsSearch.Synonyms.Privacy.GroupsAndChannels" = " "; +"SettingsSearch.Synonyms.Privacy.LastSeen" = " "; +"SettingsSearch.Synonyms.Privacy.Passcode" = " "; +"SettingsSearch.Synonyms.Privacy.PasscodeAndFaceId" = " "; +"SettingsSearch.Synonyms.Privacy.PasscodeAndTouchId" = " "; +"SettingsSearch.Synonyms.Privacy.ProfilePhoto" = " "; +"SettingsSearch.Synonyms.Privacy.Title" = " "; +"SettingsSearch.Synonyms.Privacy.TwoStepAuth" = "密码"; +"SettingsSearch.Synonyms.Proxy.AddProxy" = " "; +"SettingsSearch.Synonyms.Proxy.Title" = "SOCKS5\nMTProto"; +"SettingsSearch.Synonyms.Proxy.UseForCalls" = " "; +"SettingsSearch.Synonyms.SavedMessages" = " "; +"SettingsSearch.Synonyms.Stickers.ArchivedPacks" = " "; +"SettingsSearch.Synonyms.Stickers.FeaturedPacks" = " "; +"SettingsSearch.Synonyms.Stickers.Masks" = " "; +"SettingsSearch.Synonyms.Stickers.SuggestStickers" = " "; +"SettingsSearch.Synonyms.Stickers.Title" = " "; +"SettingsSearch.Synonyms.Support" = "支持"; +"SettingsSearch.Synonyms.Watch" = "Apple Watch"; +"Share.Link.Copied" = "复制到剪贴板"; +"Share.Pay.Confirm.Text" = "你选出了 %1$@ 个对消息收取星星的聊天。您确定想要支付 %2$@ 来发送 %3$@ 吗?"; +"Share.Pay.Confirm.UserCount#other" = "%d 名用户"; +"Share.Pay.Confirm.UserCount_countable" = "%d"; +"ShareCallLogs" = "分享通话记录"; +"ShareExtension.Cancel" = "取消"; +"ShareExtension.Passcode.Next" = "下一步"; +"ShareExtension.Passcode.Placeholder" = "锁定码"; +"ShareExtension.Search" = "搜索"; +"ShareExtension.Share" = "分享"; +"ShareExtension.Unauthorized.Description" = "如需通过 Telegram 分享,请打开 Telegram 应用并登录。"; +"ShareExtension.Unauthorized.OK" = "确定"; +"ShareExtension.Uploading" = "正在上传 %@"; +"ShareFolder.Context.Delete" = "删除"; +"ShareFolder.Context.NameLink" = "为此链接命名"; +"ShareFolder.DeselectAll" = "取消全选"; +"ShareFolder.Discard.Ok" = "放弃"; +"ShareFolder.Discard.Text" = "你已更改此文件夹的设置。放弃更改?"; +"ShareFolder.Done.Delete" = "删除"; +"ShareFolder.Done.Done" = "完成"; +"ShareFolder.Header.Empty.Info" = "您只能分享允许您创建邀请链接的群组和频道。"; +"ShareFolder.Header.Empty.Title" = "无法分享这些聊天"; +"ShareFolder.Header.Info" = "选择所有通过邀请链接添加文件夹的用户可以加入的群组和频道。"; +"ShareFolder.Header.Title#other" = "已选择 %d 个聊天"; +"ShareFolder.Header.Title_ZeroValueHolder" = "聊天"; +"ShareFolder.Header.Title_countable" = "%d"; +"ShareFolder.InviteLink.Title" = "邀请链接"; +"ShareFolder.NameLink.Placeholder" = "链接名称"; +"ShareFolder.NameLink.Title" = "为此链接命名"; +"ShareFolder.SelectAll" = "全选"; +"ShareFolder.Status.Allowed" = "您可以邀请其他用户加入"; +"ShareFolder.Status.Disallowed.Alert.Bot" = "您不能分享与机器人的聊天"; +"ShareFolder.Status.Disallowed.Alert.Channel" = "您没有管理员权限,无法分享此频道的邀请链接。"; +"ShareFolder.Status.Disallowed.Alert.Group" = "您没有管理员权限,无法分享此群组的邀请链接。"; +"ShareFolder.Status.Disallowed.Alert.NoLonger" = "此聊天已不在此文件夹中。请先将其添加到文件夹中。"; +"ShareFolder.Status.Disallowed.Alert.PrivateChats" = "您无法分享私聊。"; +"ShareFolder.Status.Disallowed.Bot" = "你不能分享机器人"; +"ShareFolder.Status.Disallowed.NoLonger" = "此聊天已经不在文件夹中"; +"ShareFolder.Status.Disallowed.Others" = "您无法邀请用户加入"; +"ShareFolder.Status.Disallowed.PrivateChats" = "您不能分享私聊"; +"ShareFolder.Title" = "分享文件夹"; +"ShareFolder.Title.Text#other" = "获得此链接的所有人都可以添加 **%1$@** 文件夹并加入以下选择的 %2$d 个聊天。"; +"ShareFolder.Title.Text.Empty" = "该文件夹中没有您可以与他人分享的聊天。"; +"ShareFolder.Title.Text_ZeroValueHolder" = "请在下面选择聊天以共享文件夹。"; +"ShareFolder.Title.Text_countable" = "%@ %d"; +"ShareModal.Search.ForwardPlaceholder" = "转发到…"; +"ShareModal.Search.Placeholder" = "分享到..."; +"ShareModal.Select" = "选择"; +"SharedFolder.Alert.AlreadyMember.Channel" = "您已订阅此频道。"; +"SharedFolder.Alert.AlreadyMember.Group" = "你已经是此群组的成员了。"; +"SharedFolder.Confirm.Delete" = "删除文件夹?"; +"SharedFolder.Confirm.Delete.Text" = "您确定要删除此文件夹吗?这将同时停用所有用来分享此文件夹的邀请链接。"; +"SharedFolder.CreateLink" = "新建链接"; +"SharedFolder.DeselectAll" = "取消全选"; +"SharedFolder.Done.AddFolder" = "添加文件夹"; +"SharedFolder.Done.DonNotJoinChats" = "不加入任何聊天"; +"SharedFolder.Done.JoinChats" = "加入聊天"; +"SharedFolder.Done.RemoveFolder" = "删除文件夹"; +"SharedFolder.Done.RemoveFolderAndChats" = "移除文件夹和聊天"; +"SharedFolder.Error.SomeUserTooMany" = "无法将其中一个群组添加至文件夹中,因为其中一位管理员加入了过多群组和频道。"; +"SharedFolder.ListHeader.Already#other" = "已加入 %d 个聊天"; +"SharedFolder.ListHeader.Already_countable" = "%d"; +"SharedFolder.ListHeader.ChatsToJoin#other" = "即将加入的 %d 个聊天"; +"SharedFolder.ListHeader.ChatsToJoin_countable" = "%d"; +"SharedFolder.ListHeader.InviteLinks" = "邀请链接"; +"SharedFolder.ListHeader.Quit#other" = "即将退出 %d 个聊天"; +"SharedFolder.ListHeader.Quit_countable" = "%d"; +"SharedFolder.ListHeader.Updates#other" = "%d 聊天加入"; +"SharedFolder.ListHeader.Updates_countable" = "%d"; +"SharedFolder.SelectAll" = "选择所有"; +"SharedFolder.Status.AddChats#other" = "你想要将 **%1$d** 个聊天添加到您的文件夹 **%2$@** 吗?"; +"SharedFolder.Status.AddChats_ZeroValueHolder" = "选择聊天以将它们添加到您的文件夹中"; +"SharedFolder.Status.AddChats_countable" = "%d %@"; +"SharedFolder.Status.AddNew" = "你想要添加一个新的聊天文件夹并加入其群组和频道吗?"; +"SharedFolder.Status.FullyAdded" = "您已添加过此文件夹以及其中的所有聊天。"; +"SharedFolder.Status.Links" = "创建更多链接,为不同的人设置不同的访问级别。"; +"SharedFolder.Status.Remove" = "你想要离开添加文件夹 **%1$@** 时加入的聊天吗?"; +"SharedFolder.Title.AddChats#other" = "添加 %d 个聊天"; +"SharedFolder.Title.AddChats_countable" = "%d"; +"SharedFolder.Title.AddFolder" = "添加文件夹"; +"SharedFolder.Title.AllChats" = "所有聊天"; +"SharedFolder.Title.Personal" = "个人"; +"SharedFolder.Title.RemoveFolder" = "移除文件夹"; +"SharedFolder.Title.ShareFolder" = "共享文件夹"; +"SharedFolder.Tooltip.Added.Text#other" = "你已加入 %d 个新聊天"; +"SharedFolder.Tooltip.Added.Text_countable" = "%d"; +"SharedFolder.Tooltip.Added.Title" = "已添加 %@ 文件夹"; +"SharedFolder.Tooltip.Updated.Text#other" = "你已加入 %d 个新聊天"; +"SharedFolder.Tooltip.Updated.Text_countable" = "%d"; +"SharedFolder.Tooltip.Updated.Title" = "已更新 %@ 文件夹"; +"SharedMedia.ArchiveStoryCount#other" = "%d 个已归档的贴文"; +"SharedMedia.ArchiveStoryCount_countable" = "%d"; +"SharedMedia.BotPreviewCount#other" = "%d 个预览"; +"SharedMedia.BotPreviewCount_countable" = "%d"; +"SharedMedia.CommonGroupsCount#other" = "%d 个共同群组"; +"SharedMedia.CommonGroupsCount_countable" = "%d"; +"SharedMedia.FileCount#other" = "%d 个文件"; +"SharedMedia.FileCount_countable" = "%d"; +"SharedMedia.GifCount#other" = "%d 张 GIF 动态图"; +"SharedMedia.GifCount_countable" = "%d"; +"SharedMedia.LinkCount#other" = "%d 个链接"; +"SharedMedia.LinkCount_countable" = "%d"; +"SharedMedia.MediaCount#other" = "%d 个媒体文件"; +"SharedMedia.MediaCount_countable" = "%d"; +"SharedMedia.MusicCount#other" = "%d 个音乐文件"; +"SharedMedia.MusicCount_countable" = "%d"; +"SharedMedia.SavedCount#other" = "%d 条消息"; +"SharedMedia.SavedCount_countable" = "%d"; +"SharedMedia.SavedMessagesCount#other" = "%d 个聊天"; +"SharedMedia.SavedMessagesCount_countable" = "%d"; +"SharedMedia.SimilarBotsCount#other" = "%d 个类似的机器人"; +"SharedMedia.SimilarBotsCount_countable" = "%d"; +"SharedMedia.SimilarCount#other" = "%d 个相似频道"; +"SharedMedia.SimilarCount_countable" = "%d"; +"SharedMedia.StarGifts#other" = "%d 份礼物"; +"SharedMedia.StarGifts_countable" = "%d"; +"SharedMedia.StoryCount#other" = "%d 个贴文"; +"SharedMedia.StoryCount_countable" = "%d"; +"SharedMedia.VoiceCount#other" = "%d 条语音消息"; +"SharedMedia.VoiceCount_countable" = "%d"; +"ShortcutsController.Chat" = "聊天"; +"ShortcutsController.Chat.EditLastMessage" = "编辑上条消息"; +"ShortcutsController.Chat.OpenInfo" = "打开详情"; +"ShortcutsController.Chat.RecordVoiceMessage" = "录制语音或视频消息"; +"ShortcutsController.Chat.SearchMessages" = "搜索消息"; +"ShortcutsController.Chat.SelectMessageToReply" = "选择要回复的消息"; +"ShortcutsController.Gestures" = "手势"; +"ShortcutsController.Gestures.ChatAction" = "聊天列表操作"; +"ShortcutsController.Gestures.ChatAction.Value" = "双向滑动"; +"ShortcutsController.Gestures.Navigation" = "返回导航"; +"ShortcutsController.Gestures.Navigations.Value" = "从左到右滑动"; +"ShortcutsController.Gestures.Reply" = "回复"; +"ShortcutsController.Gestures.Reply.Value" = "从右到左滑动"; +"ShortcutsController.Gestures.Stickers" = "贴纸/表情/GIF 动态图面板"; +"ShortcutsController.Gestures.Stickers.Value" = "双向滑动"; +"ShortcutsController.Markdown" = "MARKDOWN"; +"ShortcutsController.Markdown.Bold" = "加粗"; +"ShortcutsController.Markdown.Hyperlink" = "超链接"; +"ShortcutsController.Markdown.Italic" = "斜体"; +"ShortcutsController.Markdown.Monospace" = "等宽"; +"ShortcutsController.Markdown.Strikethrough" = "删除线"; +"ShortcutsController.Mouse" = "鼠标"; +"ShortcutsController.Mouse.FastReply" = "快捷回复"; +"ShortcutsController.Mouse.FastReply.Value" = "双击"; +"ShortcutsController.Mouse.ScheduleMessage" = "定时发送消息"; +"ShortcutsController.Mouse.ScheduleMessage.Value" = "点击 “发送消息” 选项"; +"ShortcutsController.Others" = "其他"; +"ShortcutsController.Others.LockByPasscode" = "用密码上锁"; +"ShortcutsController.Search" = "搜索"; +"ShortcutsController.Search.GlobalSearch" = "全局搜索"; +"ShortcutsController.Search.QuickSearch" = "快速搜索"; +"ShortcutsController.Search.SwitchAccount" = "切换账号"; +"ShortcutsController.Title" = "快捷方式"; +"ShortcutsController.VideoChat" = "视频聊天"; +"ShortcutsController.VideoChat.ToggleCamera" = "切换摄像头"; +"ShortcutsController.VideoChat.ToggleScreencast" = "切换屏幕共享"; +"Sidebar.Avalability" = "侧边栏仅在聊天时可用"; +"Sidebar.Hide" = "隐藏频道"; +"Sidebar.Peer.Restricted" = "该对话无法使用侧边栏"; +"SimilarChannel.Alert.Text" = "订阅 [Telegram 高级版]() 以解锁最多 %d 个相似频道。"; +"SlowMode.ForwardComment.Error" = "慢速模式已启用,您无法转发带有评论的消息。"; +"SlowMode.Multiple.Error" = "慢速模式已启用,您每次只能发送一条消息。"; +"SlowMode.PreviewSender.Comment" = "慢速模式已启用。\n您无法将评论添加为额外消息。"; +"SlowMode.PreviewSender.FileTooltip" = "慢速模式已启用,您不能一次发送多个文件。"; +"SlowMode.PreviewSender.MediaTooltip" = "慢速模式已启用。\n无法发送多个媒体文件。"; +"SlowMode.TooLong.Error" = "慢速模式已启用。此文本太长,无法作为一条消息发送。"; +"Star.Auction.Acquired.AcceptedBid" = "中标价"; +"Star.Auction.Acquired.BidAtLeast" = "参与本轮拍卖,最低出价为 %@。"; +"Star.Auction.Acquired.GiftNumber" = "%1$@ %2$@ 于第 %3$@ 轮"; +"Star.Auction.Acquired.ItemsBought#other" = "购买 %d 份礼物"; +"Star.Auction.Acquired.ItemsBought_countable" = "购买 %d 份礼物"; +"Star.Auction.Acquired.Recipient" = "收件人"; +"Star.Auction.Acquired.RoundNumber" = "第 %d 轮"; +"Star.Auction.Acquired.TopRank" = "前 %d"; +"Star.Auction.ActiveList.DropsLeft" = "剩余轮数"; +"Star.Auction.ActiveList.Left" = "剩余"; +"Star.Auction.ActiveList.Minimum" = "最低"; +"Star.Auction.ActiveList.Outbid" = "您对 **%@** 出价已失效"; +"Star.Auction.ActiveList.RaiseBid" = "提高出价"; +"Star.Auction.ActiveList.Ranked" = "您的出价 **%@** 排名第 **#%d** 位"; +"Star.Auction.ActiveList.RoundOf" = "第 %1$d/ %2$d 轮"; +"Star.Auction.ActiveList.UntilNextDrop" = "直到下一轮"; +"Star.Auction.Bid.AddStars" = "出价时至少增加 %@。"; +"Star.Auction.Bid.BelowTop" = "如果您的出价低于前 %d 名,您的出价将自动转入下一轮竞标。"; +"Star.Auction.Bid.Custom" = "自定义"; +"Star.Auction.Bid.CustomDescription" = "如果你的排名跌出前 %d 名,你的出价将顺延到下一轮。"; +"Star.Auction.Bid.EnterBid" = "输入出价"; +"Star.Auction.Bid.Max" = "最多"; +"Star.Auction.Bid.PlaceCustomBid" = "自定义出价"; +"Star.Auction.Bid.PlaceEarlyBid" = "预先出价"; +"Star.Auction.Bid.Placed.Title" = "你的出价已提交"; +"Star.Auction.Bid.PlacedTitle" = "您已出价"; +"Star.Auction.Bid.SubTitle.Bidders#other" = "前 %d 名出價者將獲勝"; +"Star.Auction.Bid.SubTitle.Bidders_countable" = "前 %d 名出价者将获胜"; +"Star.Auction.Bid.Top3Winners" = "前三名赢家"; +"Star.Auction.Bid.YouAreWinning" = "您目前领先"; +"Star.Auction.Bid.YouHaveBeenOutbid" = "您的出价已失效"; +"Star.Auction.Bid.YourBid" = "您的出价"; +"Star.Auction.Bid.YourBidWillBe" = "你的出价是"; +"Star.Auction.ChangePeer.Info" = "目前接收者是 **%@**。是否更改为 **%@**?"; +"Star.Auction.ChangePeer.Ok" = "更改"; +"Star.Auction.ChangePeer.Title" = "更改收件人"; +"Star.Auction.ContextMenu.About" = "了解更多"; +"Star.Auction.Info.BidCarryover" = "出价延续"; +"Star.Auction.Info.BidCarryoverDescription" = "如果您的出价未进入前 %d 名,将自动进入下一轮竞标。"; +"Star.Auction.Info.MissedBidders" = "未中拍者"; +"Star.Auction.Info.MissedBiddersDescription" = "如果您的出价在最后一轮未能胜出,您的星星将全额退还。"; +"Star.Auction.Info.Subtitle" = "参与专属礼品争夺战。"; +"Star.Auction.Info.Title" = "拍卖"; +"Star.Auction.Info.TopBidders" = "前 %d 名出价者"; +"Star.Auction.Info.TopBiddersDescription" = "%1$d 份礼物将以不同间隔,按出价金额由高至低,发放给排名前 %2$d 的出价者。"; +"Star.Auction.ItemsBought#other" = "已购买 %d 份礼物"; +"Star.Auction.ItemsBought_countable" = "已购买 %d 份礼物"; +"Star.Auction.List.ActiveAuctions#other" = "%d 场进行中的拍卖"; +"Star.Auction.List.ActiveAuctions_countable" = "%d 场进行中的拍卖"; +"Star.Auction.Option.Info.Outbid" = "你的出价已经被超越。"; +"Star.Auction.Option.Info.OutbidCountable#other" = "您的 %d 笔出价已失效。"; +"Star.Auction.Option.Info.OutbidCountable_countable" = "您的 %d 笔出价已失效。"; +"Star.Auction.Option.Info.YouAreWinning#other" = "您赢了 (第 %d 名)。"; +"Star.Auction.Option.Info.YouAreWinningInAll" = "你赢了所有竞拍。"; +"Star.Auction.Option.Info.YouAreWinning_countable" = "您赢了 (第 %d 名)。"; +"Star.Auction.Option.Info.YouHaveBeenOutbidInAll" = "您的所有出价均已失效。"; +"Star.Auction.Place" = "出价 %@"; +"Star.Auction.PlaceBid" = "出价"; +"Star.Auction.PlaceEarlyBid" = "提前出价"; +"Star.Auction.Preview.Auction" = "拍卖"; +"Star.Auction.Preview.AuctionStartsIn" = "拍卖开始于 %@"; +"Star.Auction.Preview.Availability" = "剩余数量"; +"Star.Auction.Preview.AveragePrice" = "平均价格"; +"Star.Auction.Preview.Bidders#other" = "%d 名出价者"; +"Star.Auction.Preview.Bidders_countable" = "%d 名出价者"; +"Star.Auction.Preview.CurrentRound" = "当前轮次"; +"Star.Auction.Preview.DurationEach" = "每轮 %@ "; +"Star.Auction.Preview.Ended" = "拍卖已结束"; +"Star.Auction.Preview.Ends" = "结束"; +"Star.Auction.Preview.GiftsDroppedInfo#other" = "将有 %1$d 份礼物按出价金额,以不同的时间间隔发放给 %2$@ 的竞拍者。"; +"Star.Auction.Preview.GiftsDroppedInfo_countable" = "%1$d 份礼物将以不同间隔,按出价金额由高至低,发放给 %2$@。"; +"Star.Auction.Preview.Join" = "加入拍卖"; +"Star.Auction.Preview.LearnMore" = "本轮排名前 %1$@ 的用户将获得 %2$@ 份礼物。[了解更多 >]()"; +"Star.Auction.Preview.LearnMoreAboutGifts" = "了解更多关于 Telegram 礼物 >"; +"Star.Auction.Preview.Left" = "剩余 %@ "; +"Star.Auction.Preview.Of" = "%1$@ / %2$@"; +"Star.Auction.Preview.PlaceEarlyBid" = "预先出价"; +"Star.Auction.Preview.Quantity" = "数量"; +"Star.Auction.Preview.RoundSingle" = "第 %@ 轮"; +"Star.Auction.Preview.Rounds" = "轮数"; +"Star.Auction.Preview.RoundsRange" = "第 %1$@-%2$@ 轮"; +"Star.Auction.Preview.Started" = "开始"; +"Star.Auction.Preview.Starts" = "开始"; +"Star.Auction.Preview.TopBidders#other" = "前 %d 名竞拍者"; +"Star.Auction.Preview.TopBidders_countable" = "前 %d 名出价者"; +"Star.Auction.Preview.ViewVariants" = "查看 %1$@ 种款式 >"; +"Star.Auction.PreviewSend.LearnMore" = "了解更多 >"; +"Star.Auction.SendPreview.Left#other" = "剩余 %d "; +"Star.Auction.SendPreview.Left_countable" = "剩余 %d "; +"Star.Auction.SendPreview.Sold#other" = "已出售 %d "; +"Star.Auction.SendPreview.Sold_countable" = "已出售 %d "; +"Star.Buy.Custom_subs" = "为即将到期的订阅购买星星。"; +"Star.BuyScreen.Gift" = "有了星星,**%@** 就可以解锁 Telegram 上的内容和服务。[查看示例 >](examples)"; +"Star.BuyScreen.MySubs" = "我的订阅"; +"Star.BuyScreen.ProlongSubs" = "购买星星以保持您对 **%@** 频道的订阅。"; +"Star.BuyScreen.PurschaseSub" = "购买星星以订阅 **%@**。"; +"Star.Gift.Limited" = "限量"; +"Star.Gift.Preview.AvailableForResale" = "可转售"; +"Star.Gift.Preview.NextDropIn" = "%@ 后进入下一轮"; +"Star.Gift.Preview.PlaceBid" = "出价"; +"Star.List.Buy.Success#other" = "%d 颗星星已添加至您的余额"; +"Star.List.Buy.Success_countable" = "%d"; +"Star.List.BuyAndUse" = "购买 **星星** 并在 **%@** 和其他小程序中使用。"; +"Star.List.BuyAndUser.Nobot" = "购买星星,解锁 Telegram 小程序中的内容和服务。"; +"Star.List.BuyMoreStars" = "购买更多星星"; +"Star.List.GetStars" = "获得星星"; +"Star.List.HowMany" = "选择您想要购买多少颗星星。"; +"Star.List.Item.Count#other" = "%d 颗星星"; +"Star.List.Item.Count_countable" = "%d"; +"Star.List.Item.ShowMore" = "显示更多选项"; +"Star.List.MyBalance" = "余额\n%1$@**%2$@**"; +"Star.List.Refund" = "退款"; +"Star.List.StarsNeeded#other" = "需要 %d 颗星星"; +"Star.List.StarsNeeded_countable" = "%d"; +"Star.List.TelegramStars" = "Telegram 星星"; +"Star.List.TopUp" = "充值"; +"Star.List.Tos" = "购买星星即表示您同意 [条款和条件](https://telegram.org/tos/stars)。"; +"Star.List.Transaction.Ads" = "广告平台"; +"Star.List.Transaction.AppStore" = "App Store"; +"Star.List.Transaction.Fragment" = "Fragment"; +"Star.List.Transaction.PlayMarket" = "Play Market"; +"Star.List.Transaction.PremiumBot" = "高级版机器人"; +"Star.List.Transaction.Unknown" = "未知"; +"Star.List.Transactions.All" = "所有交易"; +"Star.List.Transactions.Empty.All" = "没有交易记录"; +"Star.List.Transactions.Empty.Incoming" = "没有进账交易"; +"Star.List.Transactions.Empty.Outgoing" = "没有出账交易"; +"Star.List.Transactions.Header" = "交易历史"; +"Star.List.Transactions.Incoming" = "进账"; +"Star.List.Transactions.Outgoing" = "出账"; +"Star.List.Transactions.ShowMore" = "显示更多"; +"Star.List.Withdraw" = "统计"; +"Star.List.YourBalance" = "您的余额"; +"Star.Marketplace.ResaleAvailable#other" = "%d 件在售"; +"Star.Marketplace.ResaleAvailable_countable" = "%d 份转售中"; +"Star.Nft.BuyFor" = "以 %@ 价格购买"; +"Star.Nft.Gift.Buy.Confirm" = "您确定要以 **%2$@** 的价格购买 **%1$@** 并赠送给 **%3$@** 吗?"; +"Star.Nft.Gift.Buy.Confirm.Self" = "您确定要以 **%2$@** 的价格购买 **%1$@** 吗?"; +"Star.Nft.Gift.Buy.Success" = "您成功以 **%2$@** 的价格购买了 **%1$@** 并将其赠送给 **%3$@**"; +"Star.Nft.Gift.Buy.Success.Self" = "您成功以 **%2$@** 购买了 **%1$@** "; +"Star.Nft.Gift.Confirm.Ok" = "以 %@ 价格购买"; +"Star.Nft.Gift.Confirm.Title" = "购买礼物"; +"Star.Nft.OnSaleFor" = "特价 %@"; +"Star.Nft.Price.Edit" = "编辑"; +"Star.Nft.Price.Sale" = "售价"; +"Star.Nft.Sell" = "出售"; +"Star.Nft.Sell.Button.Sell" = "出售"; +"Star.Nft.Sell.Button.Update" = "更新"; +"Star.Nft.Sell.Floor" = "此系列礼物的最低价格目前为 **%@**"; +"Star.Nft.Sell.Header" = "星星价格"; +"Star.Nft.Sell.Info" = "您将获得 **%@** 颗星星。"; +"Star.Nft.Sell.Title" = "出售礼物"; +"Star.Nft.Share" = "分享"; +"Star.Nft.TakeOff" = "脱下"; +"Star.Nft.Tooltip.Worn" = "您已佩戴 %@"; +"Star.Nft.Transfer" = "转让"; +"Star.Nft.Unlist" = "下架"; +"Star.Nft.Wear" = "佩戴"; +"Star.Nft.Wear.Info" = "并获得以下权益:"; +"Star.Nft.Wear.Title" = "佩戴 %@"; +"Star.Purchase.Balance" = "余额\n%@"; +"Star.Purchase.Confirm" = "确认购买"; +"Star.Purchase.PaidMedia.Success" = "您已花费 %3$@ 解锁媒体。"; +"Star.Purchase.PaidMedia.Text" = "您是否要花费 **%3$@** 解锁 **%2$@** 中的 %1$@?"; +"Star.Purchase.Pay" = "确认并支付 %@ "; +"Star.Purchase.Success" = "您已使用 “%1$@” 在 %3$@ 购买了 %2$@。"; +"Star.Purchase.Text" = "您确定要使用 **%1$@** 在 **%3$@** 购买 **%2$@** 吗?"; +"Star.Purchase.Text.In#other" = "%d 颗星星"; +"Star.Purchase.Text.In_countable" = "%d"; +"Star.Sell.Ton.Description" = "如果买家使用 TON 付款,则不存在买家要求退款的风险,\n这是与星星付款的不同之处。"; +"Star.Sell.Ton.OnlyAccept" = "只接受以 TON 付款"; +"Star.Sell.Ton.Tooltip" = "使用 TON 付款可以避免再次转让礼物的21天等待期。"; +"Star.SubScreen.Action.Bot" = "打开机器人"; +"Star.SubScreen.Action.Join" = "加入频道"; +"Star.SubScreen.Action.Open" = "打开频道"; +"Star.SubScreen.Action.Renew" = "续订"; +"Star.SubScreen.Cancelled.Alert" = "订阅已取消"; +"Star.SubScreen.Price" = "%@ / 月"; +"Star.SubScreen.Row.Renew" = "续订"; +"Star.SubScreen.Row.Sub" = "订阅"; +"Star.SubScreen.Row.SubBot" = "机器人"; +"Star.SubScreen.Row.Subd" = "已订阅"; +"Star.SubScreen.Status.Active" = "如果您现在取消订阅,您仍然可以在 %@ 之前存取您的订阅内容。"; +"Star.SubScreen.Status.Active.Cancel" = "取消订阅"; +"Star.SubScreen.Status.Cancelled" = "您已取消您的订阅。"; +"Star.SubScreen.Status.Expired" = "你的订阅已到期。"; +"Star.SubScreen.Title" = "订阅"; +"Star.Subscription.Status.Active" = "每月"; +"Star.Subscription.Status.Active.Info" = "已订续于 %@"; +"Star.Subscription.Status.Cancelled" = "已取消"; +"Star.Subscription.Status.Cancelled.Info" = "将于 %@ 到期"; +"Star.Subscription.Status.Expired" = "已到期"; +"Star.Subscription.Status.Expired.Info" = "已到期于 %@"; +"Star.Transaciton.ReferredUser" = "您推荐的用户"; +"Star.Transaction.Availability" = "送出情况"; +"Star.Transaction.Availability.Of" = "%1$d / %2$@"; +"Star.Transaction.Availability.OfLeft" = "剩余 %1$@/%2$@"; +"Star.Transaction.Availability.OfText" = "%1$@ / %2$@"; +"Star.Transaction.BotTos" = "订阅即表示您同意 [服务条款](https://telegram.org)。"; +"Star.Transaction.BroadcastMessages#other" = "%d 条消息"; +"Star.Transaction.BroadcastMessages_countable" = "%d"; +"Star.Transaction.Button.GiftUpgrade" = "赠送升级"; +"Star.Transaction.Button.Upgrade" = "升级"; +"Star.Transaction.Commission" = "佣金"; +"Star.Transaction.ConvertAlert.Header" = "将礼物转换为星星"; +"Star.Transaction.ConvertAlert.Info" = "您想将此礼物从 **%1$@** 转换为 **%2$@** 吗?\n\n此操作无法撤消。这将永久删除礼物。"; +"Star.Transaction.ConvertAlert.Info.Until" = "您要将来自 **%1$@** 的礼物兑换为 **%2$@** 吗?\n\n兑换可在接下来的 %3$@ 天内进行。\n\n此操作无法撤销。"; +"Star.Transaction.ConvertAlert.OK" = "转换"; +"Star.Transaction.ConvertAlert.TooLate" = "抱歉,您无法再将此礼物转换为星星。"; +"Star.Transaction.Copied" = "已将交易号复制到剪贴板。"; +"Star.Transaction.Date" = "日期"; +"Star.Transaction.FirstSale" = "首次销售"; +"Star.Transaction.From" = "来自"; +"Star.Transaction.Gift" = "奖品"; +"Star.Transaction.Gift.Collectible" = "典藏品 %@"; +"Star.Transaction.GiftPurchase" = "购买礼物"; +"Star.Transaction.GiftSale" = "售出礼物"; +"Star.Transaction.GiftUpgrade.Issued" = "已完成 %1$@/%2$@ "; +"Star.Transaction.Id" = "交易号"; +"Star.Transaction.LastSale" = "最新成交"; +"Star.Transaction.MessageFee#other" = "%d 条消息的费用"; +"Star.Transaction.MessageFee.Info" = "每逢收到一条消息,你将获取设置费用的 %@。[更改费用 >](更改费用)"; +"Star.Transaction.MessageFee_countable" = "%d"; +"Star.Transaction.MessageId" = "媒体"; +"Star.Transaction.MiniApp" = "小程序"; +"Star.Transaction.Owner" = "所有者"; +"Star.Transaction.ReactionId" = "消息"; +"Star.Transaction.Reason" = "事由"; +"Star.Transaction.Reason.Giveaway" = "抽奖活动"; +"Star.Transaction.ReasonAffiliate" = "联盟计划(affiliate)"; +"Star.Transaction.Released.By" = "由 @%@ 发布"; +"Star.Transaction.SaleFor#other" = "以 %d 颗星星的价格售出"; +"Star.Transaction.SaleFor_countable" = "%d"; +"Star.Transaction.SendGift" = "赠送礼物"; +"Star.Transaction.StarGift.Anonymous" = "只有您可以看到送出礼物者的名称。"; +"Star.Transaction.StarGift.Channel.DisplayOnMyPage" = "显示的礼物"; +"Star.Transaction.StarGift.Channel.HideFromMyPage" = "隐藏的礼物"; +"Star.Transaction.StarGift.ConvertTo" = "[转换为 %@](convert)"; +"Star.Transaction.StarGift.ConvertToStars.Alert" = "您已成功将礼物转化为星星"; +"Star.Transaction.StarGift.DisplayOnMyPage" = "添加至我的资料页"; +"Star.Transaction.StarGift.DisplayOnPage.Alert" = "此礼物现在可以在您的个人资料页上看到。"; +"Star.Transaction.StarGift.HidInProfile" = "此礼物不会显示在你的个人资料页上。[显示 >](show)"; +"Star.Transaction.StarGift.HideFromMyPage" = "从我的个人资料页上隐藏"; +"Star.Transaction.StarGift.HideFromMyPage.Alert" = "礼物现已在您的个人资料页上隐藏。"; +"Star.Transaction.StarGift.SeeInProfile" = "这份礼物在您的个人资料页面可见 [查看 >](view)"; +"Star.Transaction.StarGift.ShownInProfile" = "此礼物已显示在您的个人资料面上。 [隐藏 >](hide)"; +"Star.Transaction.To" = "至"; +"Star.Transaction.Ton.Description" = "使用 TON 购买礼物及向频道提交帖子建议。"; +"Star.Transaction.Tos" = "查看星星的 **[服务条款] (https://telegram.org/tos)**。"; +"Star.Transaction.TransactionUrl" = "交易链接"; +"Star.Transaction.UnknwonUser" = "[Unknown User](https://fragment.com/stars)"; +"Star.Transaction.Value" = "价值"; +"Star.Transaction.Visibility" = "可见性"; +"Star.Transaction.Visibility.Hide" = "隐藏"; +"Star.Transaction.Visibility.Info" = "在您的个人资料页可见"; +"StarGift.ActionLocked.URL" = "https://fragment.com/my/gifts"; +"StarGift.ActionLockedOpen" = "打开 Fragment"; +"StarGift.ActionLockedText" = "将此礼物转让到 Fragment 上的 Telegram 账号以解锁此操作。"; +"StarGift.ActionLockedTitle" = "操作已锁定"; +"StarGift.Auction.StartsIn" = "开始于 %@"; +"StarGift.Badge.Auction" = "拍卖"; +"StarGift.Badge.Soon" = "即将推出"; +"StarGift.Craft.AnotherGift" = "制作另一件礼物"; +"StarGift.Craft.BackdropChanceHint" = "制作出的礼物将有 %1$@ 的概率带有 **%2$@** 背景。"; +"StarGift.Craft.CraftWithName" = "制作 %1$@"; +"StarGift.Craft.Crafted" = " (已制作)"; +"StarGift.Craft.Description" = "最多可添加 4 份礼物来制作全新的\n%1$@ **%2$@**。\n\n如果制作失败,这些礼物将会丢失。"; +"StarGift.Craft.Failed" = "制作失败"; +"StarGift.Craft.FailedDescription" = "这次制作尝试失败了。 "; +"StarGift.Craft.FeatureAffectResult" = "影响结果"; +"StarGift.Craft.FeatureAffectResultDesc" = "使用相同属性的礼物来提升成功率。"; +"StarGift.Craft.FeatureMaximizeChances" = "最大化成功率"; +"StarGift.Craft.FeatureMaximizeChancesDesc" = "组合更多礼物以提高成功率。"; +"StarGift.Craft.FeatureRareModels" = "获取稀有型号"; +"StarGift.Craft.FeatureRareModelsDesc" = "选择最多 4 件礼物,制作全新专属型号。"; +"StarGift.Craft.GiftLost" = "丢失了 1 份礼物"; +"StarGift.Craft.GiftTooEarly" = "从此礼物制作将在 %1$@ 内可用。"; +"StarGift.Craft.GiftsLost#other" = "丢失了 %1$d 份礼物"; +"StarGift.Craft.HeaderInfo" = "将您的礼物升级为稀有、史诗\n和传奇版本。"; +"StarGift.Craft.HeaderTitle" = "礼物制作中"; +"StarGift.Craft.MenuItem" = "制作礼物"; +"StarGift.Craft.PatternChanceHint" = "制作出的礼物将有 %1$@ 的概率带有 **%2$@** 符号。"; +"StarGift.Craft.StartCrafting" = "开始制作"; +"StarGift.Craft.SuccessChance" = "%1$d%% 成功率"; +"StarGift.Craft.Title" = "制作礼物"; +"StarGift.Craft.TooEarly" = "此礼物将于 %1$@ 开放制作。"; +"StarGift.Craft.Unavailable" = "其中一件礼物无法进行制作。"; +"StarGift.Craft.ViewAllModels" = "%1$@ 查看所有可制作的型号 >"; +"StarGift.LearnAbout.Description" = "礼物是可以交易或在个人资料页上展示的典藏品。"; +"StarGift.LearnAbout.Title" = "Telegram 礼物"; +"StarGift.Marketplace.NoOtherGifts" = "您没有其他来自此收藏系列的礼物。"; +"StarGift.Marketplace.ShowStarsOnly" = "仅显示星星礼物"; +"StarGift.Marketplace.SuitableGiftsOnSale" = "%1$d 份精选特价礼物"; +"StarGift.Marketplace.YourGifts" = "您的礼物"; +"StarGift.ModelBuilder.Backdrops" = "背景"; +"StarGift.ModelBuilder.CollectionFeatures#other" = "此系列包含 %d 个独特模型。"; +"StarGift.ModelBuilder.CollectionFeatures_countable" = "此系列包含 %d 个独特型号。"; +"StarGift.ModelBuilder.Craftable" = "可制作"; +"StarGift.ModelBuilder.Models" = "型号"; +"StarGift.ModelBuilder.Primary" = "主要"; +"StarGift.ModelBuilder.RandomizeTraits" = "随机特征"; +"StarGift.ModelBuilder.SelectedTraits" = "选定特征"; +"StarGift.ModelBuilder.SubtextBackdrop" = "背景"; +"StarGift.ModelBuilder.SubtextModel" = "型号"; +"StarGift.ModelBuilder.SubtextSymbol" = "符号"; +"StarGift.ModelBuilder.Symbols" = "符号"; +"StarGift.ModelBuilder.ViewOtherModels" = "查看 %1$@ 种型号 ›"; +"StarGift.Nft.CantChooseFirst" = "无法选为首件"; +"StarGift.Nft.CantChooseFirstInfo" = "此礼物已记录在区块链上,无法作为制作的第一件礼物使用。"; +"StarGift.Nft.ConfirmSale" = "确认出售"; +"StarGift.Nft.OfferToBuy" = "出价购买"; +"StarGift.Offer.ConfirmInfo" = "您想要向 %2$@ 的 **%3$@** 提出 **%1$@** 的报价吗?"; +"StarGift.Offer.ConfirmTitle" = "确认报价"; +"StarGift.Offer.CurrencyStars" = "星星"; +"StarGift.Offer.CurrencyTon" = "TON"; +"StarGift.Offer.Duration" = "出价有效期"; +"StarGift.Offer.DurationDescription" = "选择 **%1$@** 接受您报价的時时限。时间到期后,您的 %2$@ 将自动退还。"; +"StarGift.Offer.OfferLabel" = "报价"; +"StarGift.Offer.PayButton" = "支付 %@"; +"StarGift.Offer.Title" = "出价购买"; +"StarGift.OnBlockchain.Info" = "此礼物位于 TON 区块链上。 [查看 >]()"; +"StarGift.Option.Join" = "加入"; +"StarGift.Preview.Title" = "赠送礼物"; +"StarGift.Preview.Title.Buy" = "购买礼物"; +"StarGift.PriceDecrease.Hint" = "抢先升级礼物的用户将获得编号更短的典藏品。"; +"StarGift.PriceDecrease.Preview" = "查看价格会如何下降 >"; +"StarGift.PriceDecreaseTimer" = "价格将在 %@ 后下降"; +"StarGift.RemoveDescriptionConfirm" = "花费 %@ 移除"; +"StarGift.RemoveDescriptionInfo" = "您是否要永久移除您礼物的描述?"; +"StarGift.RemoveDescriptionTitle" = "移除描述"; +"StarGift.SelfAuction.LearnMore" = "了解更多佩戴 Telegram 礼物的信息 >"; +"StarGift.SelfAuction.Limited" = "限量"; +"StarGift.SelfAuction.Upgraded" = "已升级"; +"StarGift.Sell.Alert.OnlyTon" = "卖家仅接受 TON 作为付款方式。"; +"StarGift.Sell.Alert.PayInStars" = "以星星付款"; +"StarGift.Sell.Alert.PayInTon" = "以 TON 付款"; +"StarGift.Sell.Alert.Title.ConfirmPayment" = "确认付款\n"; +"StarGift.Unique.Make" = "独家定制"; +"StarGift.Unique.UpgradeNext" = "升级下一个礼物"; +"StarGift.UpgradeCost.Title" = "升级价格"; +"StarGift.Value.Description.Average" = "过去一个月内 **%@** 礼物在 Telegram 和 Fragment 上的平均售价。"; +"StarGift.Value.Description.LastSale" = "这是 **%@** 在 %@ 上的最新成交价。"; +"StarGift.Value.LetUserMakeUnique" = "让 %@ 将这份礼物变成一件独特的收藏品。"; +"StarGift.Value.Listing.Fragment" = "%d %@,在 Fragment 上出售 >"; +"StarGift.Value.Listing.Telegram" = "%d %@,在 Telegram 上出售 >"; +"StarGift.Value.PayForUpgrade" = "支付 %@ 并升级"; +"StarGift.Value.Row.Badge" = "了解更多"; +"StarGift.Value.Row.Label" = "价值"; +"StarGift.Value.Source.Fragment" = "Fragment"; +"StarGift.Value.Source.Telegram" = "Telegram"; +"StarGift.ViewAllVariants" = "%1$@ 查看所有选项 >"; +"StarList.Ton" = "TON"; +"StarList.Ton.Withdraw.Info" = "通过 Fragment 领取您的 TON。 [了解更多 >](telegram.org)"; +"StarList.TonInfo" = "提供 TON 来向 Telegram 频道提交帖子建议"; +"StarTransaction.GiftRefundedInfo" = "由于此礼物相关的付款已申请退款并成功退回,该礼物已被降级。"; +"Stars.Balance.BuyAds" = "购买广告"; +"Stars.ExampleApps.Text" = "使用星星解锁 Telegram 上的内容和服务。[查看示例 >](app)"; +"Stars.Gift.ServiceInfo" = "使用星星解锁 Telegram 上的內容和服务。"; +"Stars.Gift.Title" = "赠送星星"; +"Stars.GiftPreview.Buy" = "以 %@ 价格购买礼物"; +"Stars.GiftPreview.BuyMulti#other" = "以 %@ 的价格购买 %d 份礼物"; +"Stars.GiftPreview.BuyMulti_countable" = "%d %@"; +"Stars.GiftPreview.Channel.Display" = "将此礼物添加到你的频道资料页上或将其转换为 %@。"; +"Stars.GiftPreview.Customize" = "自定义您的礼物"; +"Stars.GiftPreview.Display" = "将此礼物添加至您的个人资料页或将其转换为 %@。"; +"Stars.GiftPreview.HideMyName" = "隐藏送礼者"; +"Stars.GiftPreview.HideMyName.Info" = "向 %1$@ 个人资料页的访客隐藏您的名称和消息。 %2$@ 仍会看到您的名称和消息。"; +"Stars.GiftPreview.MessagePlaceholder" = "输入消息"; +"Stars.GiftPreview.Send" = "赠送礼物给 %@"; +"Stars.GiftPreview.SendMulti#other" = "以 %@ 价格赠送 %d 份礼物"; +"Stars.GiftPreview.SendMulti_countable" = "%d %@"; +"Stars.GiftToFriends" = "%@赠送星星给朋友"; +"Stars.Intro.Transaction.TelegramBotApi.Subtitle" = "机器人 API"; +"Stars.Intro.Transaction.TelegramBotApi.Title" = "付费广播消息"; +"Stars.Promo.Header" = "什么是星星?"; +"Stars.Promo.HeaderInfo" = "Telegram 星星允许用户:"; +"Stars.Promo.Option1.Info" = "为您的朋友赠送礼物,这些礼物可以添加至他们的个人资料页或转换为星星。"; +"Stars.Promo.Option1.Title" = "为好友送礼"; +"Stars.Promo.Option2.Info" = "在 Telegram 小程序中购买额外的內容和服务。[请查看示例 >](examples)"; +"Stars.Promo.Option2.Title" = "在小程序中使用星星"; +"Stars.Promo.Option3.Info" = "可以访问 Telegram 频道中的付费內容和服务。"; +"Stars.Promo.Option3.Title" = "在频道中解锁内容"; +"Stars.Promo.Option4.Info" = "通过向您所喜欢的频道的贴文作出星星回应来支持它们。"; +"Stars.Promo.Option4.Title" = "作出星星回应"; +"Stars.Promo.Option5.Info" = "通过赠送星星吸引新订阅者并助推您的频道。"; +"Stars.Promo.Option5.Title" = "在频道中创建抽奖活动"; +"Stars.Purchase.BotSubscribe" = "订阅机器人"; +"Stars.Purchase.BotSubscribe.Accept.Month" = "以每月 %@ 的价格订阅"; +"Stars.Purchase.BotSubscribe.Accept.SixMonth" = "以每六个月 %@ 的价格订阅"; +"Stars.Purchase.BotSubscribe.Accept.Year" = "以 %@ / 年订阅"; +"Stars.Purchase.BotSubscribe.Info#other" = "您是否想以每月 **%3$d** 颗星星的价格订阅 **%2$@** 中的 **%1$@**?"; +"Stars.Purchase.BotSubscribe.Info_countable" = "%1$@ %2$@ %3$d"; +"Stars.Purchase.BotSubscribe.Success" = "您已在 **%@** 成功订阅 **%@**"; +"Stars.Purchase.Subscribe" = "订阅此频道"; +"Stars.Purchase.Subscribe.Action" = "订阅"; +"Stars.Purchase.Subscribe.Info#other" = "您想以每月 **%d 颗**星星的价格订阅 **%@** 吗?"; +"Stars.Purchase.Subscribe.Info_countable" = "%@ %d"; +"Stars.Purchase.Subscribe.Success" = "你已成功订阅 **%@**"; +"Stars.Purchase.UpgradeStarGiftInfo" = "购买星星将您的礼物升级为独家典藏品。"; +"Stars.ReactScreen.Anonymous" = "匿名"; +"Stars.ReactScreen.Footer" = "发送星星即表示您同意 [服务条款](https://telegram.org)。"; +"Stars.ReactScreen.Info" = "选择您想要送出多少颗星星给 **%@** 以支持此贴文。"; +"Stars.ReactScreen.Send#other" = "发送 %@%d"; +"Stars.ReactScreen.Send_countable" = "%@%d"; +"Stars.ReactScreen.SentValue#other" = "您已送出 %d 颗星星以支持此贴文。"; +"Stars.ReactScreen.SentValue_countable" = "%d"; +"Stars.ReactScreen.ShowMeInTop" = "在星星打赏榜上显示我"; +"Stars.ReactScreen.Title" = "以星星做出回应"; +"Stars.ReactScreen.TopSenders" = "星星打赏榜"; +"Stars.StarGift.Text.Converted.Incoming" = "你已将此礼物转换为 %1$@ 颗星星。"; +"Stars.StarGift.Text.Converted.Outgoing" = "%1$@ 已将此礼物转换为 %2$@。"; +"Stars.StarGift.Text.Incoming" = "您可以将此礼物保留在您的个人资料页上或将其转换为 %1$@ 颗星星。"; +"Stars.StarGift.Text.Incoming.Channel" = "在频道上显示此礼物或将其转换为 %@。"; +"Stars.StarGift.Text.KeepOrHide" = "您可以将此礼物添加至您的个人资料页或将其隐藏。"; +"Stars.StarGift.Text.KeptOnPage.Incoming" = "您已将这份礼物添加至您的个人资料页。"; +"Stars.StarGift.Text.KeptOnPage.Incoming.Gifts" = "您可以将此礼物保留在频道的礼物中。"; +"Stars.StarGift.Text.KeptOnPage.Outgoing" = "%1$@ 已将此礼物添加到他们的个人资料页。"; +"Stars.StarGift.Text.Link" = "[更多关于星星 >](stars)"; +"Stars.StarGift.Text.Outgoing" = "%1$@ 可以将此礼物添加至个人资料页或将其转换为 %2$@ 颗星星。"; +"Stars.Subscription.Period.Daily" = "订阅日费"; +"Stars.Subscription.Period.Monthly" = "订阅月费"; +"Stars.Subscription.Period.Unknown" = "订阅费"; +"Stars.Subscription.Period.Weekly" = "订阅周费"; +"Stars.Transaction.Commission" = "%@ 佣金"; +"Stars.Transaction.Gift" = "礼物"; +"Stars.Transaction.GiftAuctionBid" = "礼物拍卖出价"; +"Stars.Transaction.GiftSimple" = "礼物"; +"Stars.Transaction.MediaPurchase" = "媒体购买"; +"Stars.Transaction.PaidReaction" = "付费回应"; +"Stars.Transaction.PremiumFor" = "%d 个月的高级版订阅"; +"Stars.Transaction.ReceivedGift" = "收到礼物"; +"Stars.Transaction.ReceivedPrize" = "收到礼物"; +"Stars.Transaction.SentGift" = "发送礼物"; +"Stars.Transaction.TopUp" = "星星充值"; +"Stars.Transfer.MediaAnd" = "%1$@ 和 %2$@"; +"Stars.Transfer.Photos#other" = "%d 张图片"; +"Stars.Transfer.Photos_countable" = "%d"; +"Stars.Transfer.SinglePhoto" = "**图片**"; +"Stars.Transfer.SingleVideo" = "视频"; +"Stars.Transfer.Videos#other" = "%d 段视频"; +"Stars.Transfer.Videos_countable" = "%d"; +"Stars.YouHaveEnough" = "你已拥有足够的星星。 [购买更多]()"; +"StarsGift.Preview.Limited.Text" = "这款礼物数量有限,仅限 **Telegram 高级版** 用户领取。您可以再送出这款礼物 **%d** 次。"; +"Stats.Boosts" = "助推"; +"Stats.Boosts.Action.Boost" = "助推"; +"Stats.Boosts.Action.Giveaway" = "抽奖活动"; +"Stats.Boosts.Action.Info" = "功能"; +"Stats.Boosts.Boosters#other" = "%d 位助推者"; +"Stats.Boosts.BoostersInfo" = "您的频道目前受到这些用户的助推。"; +"Stats.Boosts.BoostersInfo.Group" = "您的群组正在由这些用户助推。"; +"Stats.Boosts.BoostersNone" = "助推者"; +"Stats.Boosts.Boosters_ZeroValueHolder" = "暂无助推者"; +"Stats.Boosts.Boosters_countable" = "%d"; +"Stats.Boosts.BoostsToLevelUp" = "助推以提升等级"; +"Stats.Boosts.ExistingBoosts" = "活跃助推"; +"Stats.Boosts.ExpiresOn" = "助推将于 %@ 到期"; +"Stats.Boosts.GroupInfo" = "您的群组成员可以**助推**群组并使其**升级**以获得**专属功能**。"; +"Stats.Boosts.Level" = "等级"; +"Stats.Boosts.LinkHeader" = "助推链接"; +"Stats.Boosts.LinkInfo" = "将此链接分享给您的订阅者,以得到更多助推。"; +"Stats.Boosts.LinkInfo.Group" = "与您的成员分享此链接以获得更多助推。"; +"Stats.Boosts.NoBoostersYet" = "目前没有用户助推您的频道"; +"Stats.Boosts.NoBoostersYet.Group" = "目前没有用户助推您的群组"; +"Stats.Boosts.OverviewHeader" = "概况"; +"Stats.Boosts.PremiumSubscribers" = "Telegram 高级版订阅者"; +"Stats.Boosts.ShowMore" = "显示更多"; +"Stats.GroupActionsTitle" = "操作"; +"Stats.GroupGrowthTitle" = "增长"; +"Stats.GroupLanguagesTitle" = "成员主要语言"; +"Stats.GroupMembers" = "成员"; +"Stats.GroupMembersTitle" = "群组成员"; +"Stats.GroupMessages" = "消息"; +"Stats.GroupMessagesTitle" = "消息"; +"Stats.GroupNewMembersBySourceTitle" = "新成员的来源"; +"Stats.GroupOverview" = "概况"; +"Stats.GroupPosters" = "发布消息的成员"; +"Stats.GroupTopAdmin.Actions" = "操作"; +"Stats.GroupTopAdmin.Promote" = "提权"; +"Stats.GroupTopAdminBans#other" = "%d 次限制"; +"Stats.GroupTopAdminBans_countable" = "%d"; +"Stats.GroupTopAdminDeletions#other" = "%d 次删除"; +"Stats.GroupTopAdminDeletions_countable" = "%d"; +"Stats.GroupTopAdminKicks#other" = "%d 次封禁"; +"Stats.GroupTopAdminKicks_countable" = "%d"; +"Stats.GroupTopAdminsTitle" = "活跃管理员"; +"Stats.GroupTopHoursTitle" = "活跃时间"; +"Stats.GroupTopInviter.History" = "历史"; +"Stats.GroupTopInviter.Promote" = "提权"; +"Stats.GroupTopInviterInvites#other" = "%d 次邀请"; +"Stats.GroupTopInviterInvites_countable" = "%d"; +"Stats.GroupTopInvitersTitle" = "最佳邀请者"; +"Stats.GroupTopPoster.History" = "历史"; +"Stats.GroupTopPoster.Promote" = "提权"; +"Stats.GroupTopPosterChars#other" = "每条消息 %d 个字符"; +"Stats.GroupTopPosterChars_countable" = "%d"; +"Stats.GroupTopPosterMessages#other" = "%d 条消息"; +"Stats.GroupTopPosterMessages_countable" = "%d"; +"Stats.GroupTopPostersTitle" = "活跃成员"; +"Stats.GroupTopWeekdaysTitle" = "本周活跃日"; +"Stats.GroupViewers" = "浏览消息的成员"; +"Stats.MessageInteractionsTitle" = "互动"; +"Stats.MessageOverview" = "概况"; +"Stats.MessagePrivateForwardsTitle" = "私密分享"; +"Stats.MessagePublicForwardsTitle" = "公开分享"; +"Stats.MessagePublicForwardsTitle.Header" = "公开分享"; +"Stats.MessageReactionsTitle" = "表情回应"; +"Stats.MessageStats.StoryTitle" = "动态统计数据"; +"Stats.MessageTitle" = "消息数据分析"; +"Stats.Monetization" = "营利"; +"Stats.RevenueInStars" = "收入(以星星计算)"; +"Stats.RevenueInTon" = "收入(以 TON 计算)"; +"Stats.RevenueInUsd" = "收入(以美元计算)"; +"Stats.ShowMore#other" = "显示 %d 条更多信息"; +"Stats.ShowMore_countable" = "%d"; +"Stats.Stars" = "星星"; +"Stats.Statistics" = "统计信息"; +"Stats.Story.Title" = "动态"; +"Stats.Ton" = "TON"; +"StatusBar.Activate" = "打开Telegram"; +"StatusBar.Hide" = "隐藏"; +"StatusBar.Quit" = "退出"; +"Sticker.Premium.Click.Info" = "此表情包包含像这样的高级版表情贴纸。[查看表情包]()"; +"StickerPack.Add1#other" = "添加 %d 张贴纸"; +"StickerPack.Add1_countable" = "%d"; +"StickerPack.Added.Info" = "%@ 已添加到您的贴纸包。"; +"StickerPack.Added.Title" = "已添加贴纸"; +"StickerPack.Emoji" = "表情符号"; +"StickerPack.Featured.Title" = "热门贴纸"; +"StickerPack.Preview.RemoveGroup" = "移除群组表情包"; +"StickerPack.Preview.SetGroup" = "设置群组表情包"; +"StickerPack.Removed.Info" = "%@ 不再存在于你的表情包中。"; +"StickerPack.Removed.Title" = "贴纸已移除"; +"StickerPack.Sets.Add1#other" = "添加 %d 个贴纸包"; +"StickerPack.Sets.Add1_countable" = "%d"; +"StickerPack.ShowMore" = "探索更多"; +"StickerPack.Stickers" = "贴纸"; +"StickerSet.DontExist" = "抱歉,这款贴纸包似乎不存在"; +"StickerSet.Remove" = "移除"; +"Stickers.Confirm.ClearRecentHeader" = "清空最近使用的表情列表?"; +"Stickers.Confirm.ClearRecentOK" = "清除"; +"Stickers.Confirm.ClearRecentText" = "你是否确定要清除最近的贴纸?"; +"Stickers.Context.Archive" = "归档"; +"Stickers.Count#other" = "%d 个贴纸"; +"Stickers.Count_countable" = "%d"; +"Stickers.Favorite" = "收藏"; +"Stickers.GroupStickers" = "群组贴纸"; +"Stickers.Premium" = "高级版"; +"Stickers.Premium.Unlock" = "解锁高级贴纸"; +"Stickers.Preview.Premium" = "订阅 Telegram 高级版即可解锁这个和更多贴纸。"; +"Stickers.Recent" = "最近"; +"Stickers.SearchAdd" = "添加"; +"Stickers.SearchAdded" = "已添加"; +"Stickers.Set.Count1#other" = "%d 张贴纸"; +"Stickers.Set.Count1_countable" = "%d"; +"Stickers.SuggestAdded" = "我的表情包"; +"Stickers.SuggestAll" = "所有表情包"; +"Stickers.SuggestNone" = "不推荐"; +"Stickers.SuggestStickers" = "根据表情联想贴纸"; +"Stickers.Trending" = "热门贴纸"; +"Storage.Clear" = "清空 %@"; +"Storage.Clear.Audio" = "语音"; +"Storage.Clear.Documents" = "文件"; +"Storage.Clear.Photos" = "照片"; +"Storage.Clear.Videos" = "视频"; +"Storage.ClearAll" = "全部清除"; +"Storage.ClearAll.Confirm.Description" = "您确定要清除缓存吗?"; +"StorageExceptions.Add.Channel" = "添加频道"; +"StorageExceptions.Add.Group" = "添加群组"; +"StorageExceptions.Add.Private" = "添加私人聊天"; +"StorageExceptions.Header#other" = "%d 个例外"; +"StorageExceptions.Header_countable" = "%d"; +"StorageExceptions.Remove" = "移除"; +"StorageExceptions.RemoveAll" = "删除所有例外"; +"StorageExceptions.RemoveAll.Confirm" = "你确定要移除所有例外吗?"; +"StorageExceptions.Title.Channel" = "频道"; +"StorageExceptions.Title.Group" = "群聊"; +"StorageExceptions.Title.Private" = "私聊"; +"StorageExceptions.Title.Stories" = "动态"; +"StorageUsage.Calculating" = "Telegram 正在计算当前缓存占用空间。\n这可能需要几分钟。"; +"StorageUsage.Category.Avatars" = "头像"; +"StorageUsage.Category.Files" = "文件"; +"StorageUsage.Category.Miscellaneous" = "杂项"; +"StorageUsage.Category.Music" = "音乐"; +"StorageUsage.Category.Other" = "其他"; +"StorageUsage.Category.Photos" = "照片"; +"StorageUsage.Category.Stickers" = "贴纸"; +"StorageUsage.Category.Stories" = "动态"; +"StorageUsage.Category.Videos" = "视频"; +"StorageUsage.CategoryText" = "所有媒体都将保留在 Telegram 云端中。如果您需要查看,可以重新下载。"; +"StorageUsage.ChatsHeader" = "对话"; +"StorageUsage.CleaningProcess" = "你的本地缓存正在清空中..."; +"StorageUsage.Clear" = "清理"; +"StorageUsage.Clear.Confirm.Info" = "媒体和文件将保留在云端中,你需要时,可以重新下载。"; +"StorageUsage.Clear.Confirm.OK.All" = "清理全部缓存"; +"StorageUsage.Clear.Confirm.OK.Part" = "清除所选"; +"StorageUsage.ClearChat.Info" = "媒体文件将从您的磁盘中清除,但如果需要仍可重新下载。"; +"StorageUsage.ClearDisabled" = "清除"; +"StorageUsage.ClearFull" = "清除缓存 %@"; +"StorageUsage.ClearPart" = "清除所选的 %@"; +"StorageUsage.Cleared" = "存储已清除"; +"StorageUsage.Cleared.Info" = "所有媒体都可于需要时从 Telegram 云端下载。"; +"StorageUsage.ClearedText" = "已在你的设备上释放 %@ 空间!"; +"StorageUsage.Header" = "存储空间用量"; +"StorageUsage.KeepMedia" = "保留媒体文件"; +"StorageUsage.KeepMedia.Channels" = "频道"; +"StorageUsage.KeepMedia.Description" = "为节省磁盘空间,此期限内云端聊天中**未访问过**的图片、视频和其他文件将会被移除。\n\n所有媒体文件都会保留在 Telegram 云端,若您需要可再次下载。"; +"StorageUsage.KeepMedia.Description1" = "您在此期间**未访问的**云端聊天中的照片、视频和其他文件将从此设备中删除,以节省磁盘空间。"; +"StorageUsage.KeepMedia.Exceptions#other" = "%d 个例外"; +"StorageUsage.KeepMedia.Exceptions_ZeroValueHolder" = "添加例外"; +"StorageUsage.KeepMedia.Exceptions_countable" = "%d"; +"StorageUsage.KeepMedia.Groups" = "群聊"; +"StorageUsage.KeepMedia.Header" = "自动删除已缓存媒体"; +"StorageUsage.KeepMedia.Private" = "私聊"; +"StorageUsage.KeepMedia.Stories" = "动态"; +"StorageUsage.Limit.Desc" = "如果您的缓存大小超过此限制,最旧的媒体将从设备中删除。\n所有媒体将保留在 Telegram 云端,如果需要可以重新下载。"; +"StorageUsage.Limit.Header" = "缓存大小限制"; +"StorageUsage.Limit.InfoUpdated" = "如果缓存大小超过此限制,将从此设备中删除最早的未曾访问的媒体文件。"; +"StorageUsage.Limit.NoLimit" = "无限"; +"StorageUsage.Media.File" = "文件"; +"StorageUsage.Media.Photo" = "图片"; +"StorageUsage.Media.Video" = "视频"; +"StorageUsage.Media.VideoMessage" = "视频消息"; +"StorageUsage.Media.Voice" = "语音消息"; +"StorageUsage.Message.Context.Clear" = "清除"; +"StorageUsage.Message.Context.Delete" = "删除"; +"StorageUsage.Message.Context.Preview" = "预览"; +"StorageUsage.Message.Context.Select" = "选择"; +"StorageUsage.Message.Context.ShowInChat" = "显示消息"; +"StorageUsage.Segment.Chats" = "聊天"; +"StorageUsage.Segment.Files" = "文件"; +"StorageUsage.Segment.Media" = "媒体"; +"StorageUsage.Segment.Music" = "音乐"; +"StorageUsage.Segment.Voice" = "语音"; +"StorageUsage.Selected.ClearDisabled" = "清除所选内容"; +"StorageUsage.Selected.ClearPart" = "清除选定的 %@"; +"StorageUsage.SelectedMedia.Empty" = "空"; +"StorageUsage.TelegramUsage.Empty" = "Telegram 使用低于 1% 的可用设备存储空间。"; +"StorageUsage.TelegramUsage.Empty.Peer" = "该聊天使用了您 Telegram 缓存的不足 1%。"; +"StorageUsage.TelegramUsage.Text" = "Telegram 占用你设备储存空间的 %@。"; +"StorageUsage.TelegramUsage.Text.Peer" = "此聊天使用 Telegram 缓存的 **%2$@%**。"; +"Stories.FoundList.FromLocation#other" = "当前位置动态数:%d "; +"Stories.FoundList.FromLocation_ZeroValueHolder" = "当前位置暂无动态"; +"Stories.FoundList.FromLocation_countable" = "%d"; +"Stories.FoundList.NotFound" = "未找到动态"; +"Story.Alert.NoReactions.Channels" = "没有人转发或对您的动态作出回应。"; +"Story.Alert.NoViews" = "还没有人看过\n您的动态。"; +"Story.Alert.ViewsExpired" = "访客名单将在动态过期 24 小时后不可见。"; +"Story.AvatarContext.OpenChannel" = "打开频道"; +"Story.AvatarContext.SendMessage" = "发送消息"; +"Story.ChannelInput.RemoveFromProfile" = "从动态中删除"; +"Story.ChannelInput.SaveToProfile" = "发布至个人资料页"; +"Story.Confirm.Delete" = "您确定想要删除此动态吗?"; +"Story.Context.IncreaseQuality" = "提升画质"; +"Story.Context.IncreaseQuality.Tooltip.Text" = "您可以稍后降低画质以加快下载速度。"; +"Story.Context.IncreaseQuality.Tooltip.Title" = "已提升画质"; +"Story.Context.LowerQuality" = "降低画质"; +"Story.Context.LowerQuality.Tooltip.Text" = "现在动态的加载速度将会更快。"; +"Story.Context.LowerQuality.Tooltip.Title" = "已降低画质"; +"Story.Controls.Edited" = "已编辑"; +"Story.Controls.LiveStream.Loading" = "加载中..."; +"Story.Controls.LiveStream.Watching#other" = "%d 人在观看"; +"Story.Controls.LiveStream.Watching_countable" = "%d 人观看中"; +"Story.Controls.Menu.Archive" = "隐藏动态"; +"Story.Controls.Menu.CopyLink" = "拷贝链接"; +"Story.Controls.Menu.Report" = "举报"; +"Story.Controls.Menu.Share" = "分享"; +"Story.Controls.Menu.Statistics" = "统计信息"; +"Story.Controls.Menu.StealtMode" = "隐藏我的浏览记录"; +"Story.Controls.Menu.Unarchive" = "取消隐藏动态"; +"Story.Controls.VideoNoSound" = "这段视频没有声音"; +"Story.Controls.YourStory" = "您的动态"; +"Story.Error.NotExist" = "此动态已无法访问"; +"Story.Input.Attach" = "图片或视频"; +"Story.Input.CantShare" = "您无法分享这条动态"; +"Story.Input.EmojiLimitReached" = "表情符号数量已达上限。赠送星星可增加上限。"; +"Story.Input.File" = "文档"; +"Story.Input.Group.Placeholder" = "评论动态..."; +"Story.Input.Placeholder" = "私信回复…"; +"Story.ListView.CannotBeShared" = "无法分享此动态"; +"Story.LiveStream.AdditionalActions" = "额外操作"; +"Story.LiveStream.DeleteMessage" = "删除消息"; +"Story.LiveStream.Input.CommentsDisabled" = "禁止评论"; +"Story.LiveStream.Input.Edit" = "编辑"; +"Story.LiveStream.Input.EmojiLimitReached" = "表情符号数量已达上限。赠送星星可增加上限。"; +"Story.LiveStream.Input.LimitReached" = "已达上限。赠送星星可增加上限。"; +"Story.LiveStream.Input.Reset" = "重置"; +"Story.LiveStream.Input.Send" = "发送"; +"Story.LiveStream.Message.Admin" = "管理员"; +"Story.LiveStream.Proceed" = "继续"; +"Story.LiveStream.StarMessage.Add" = "赠送 %@"; +"Story.LiveStream.StarMessage.Characters" = "字符"; +"Story.LiveStream.StarMessage.Emoji" = "表情"; +"Story.LiveStream.StarMessage.HighlightAndPin" = "高亮并置顶"; +"Story.LiveStream.StarMessage.HighlightInfo" = "通过向 **%@** 赠送星星,高亮并置顶您的消息。"; +"Story.LiveStream.StarMessage.PinInChat" = "在聊天中置顶"; +"Story.Location.Title" = "位置"; +"Story.Media.Unsupported" = "您当前的 Telegram 版本不支持此动态,\n请将应用更新至最新版本。"; +"Story.MyInput.CopyLink" = "拷贝链接"; +"Story.MyInput.Likes#other" = "%d"; +"Story.MyInput.Likes_countable" = "%d"; +"Story.MyInput.RemoveFromProfile" = "从个人资料页移除"; +"Story.MyInput.SaveMedia" = "保存媒体..."; +"Story.MyInput.SaveToProfile" = "发布至个人资料页"; +"Story.MyInput.Share" = "分享"; +"Story.MyInput.Views#other" = "%d 次查看"; +"Story.MyInput.Views_ZeroValueHolder" = "暂无人查看"; +"Story.MyInput.Views_countable" = "%d"; +"Story.NoReplyInput.NoReply" = "您无法回复此动态"; +"Story.Report.SuccessText" = "Telegram 管理员将复核您的举报。谢谢!"; +"Story.RepostToMy" = "转发到我的动态"; +"Story.SlowMode.Placeholder" = "慢速模式 - %@"; +"Story.StealthMode.Button.Available" = "%@ 后可用"; +"Story.StealthMode.Button.Enable" = "启用隐身模式"; +"Story.StealthMode.Button.Unlock" = "解锁隐身模式"; +"Story.StealthMode.First.Info" = "隐藏我在过去 5 分钟内的浏览记录。"; +"Story.StealthMode.First.Title" = "隐藏最近的浏览记录"; +"Story.StealthMode.Info.Subscribe" = "订阅 Telegram 高级版,浏览动态后,将不会出现在访客列表中。"; +"Story.StealthMode.Info.Text" = "开启隐身模式浏览动态后,将不会出现在访客列表中。"; +"Story.StealthMode.Placeholder" = "隐身模式 - %@"; +"Story.StealthMode.Second.Info" = "隐藏我在接下来 25 分钟内的浏览记录。"; +"Story.StealthMode.Second.Title" = "隐藏之后的浏览记录"; +"Story.StealthMode.Title" = "隐身模式"; +"Story.StealthMode.Tooltip.Wait" = "请等待隐身模式再次可用。"; +"Story.Tooltip.Archive" = "**%@** 的动态已移至归档。"; +"Story.Tooltip.AudioSent" = "已发送音频。"; +"Story.Tooltip.Button.ViewInChat" = "在聊天中查看"; +"Story.Tooltip.CloseFriends" = "您能看到这条动态是因为\n%@ 将您添加到了密友列表中。"; +"Story.Tooltip.Contacts" = "只有 %@ 的联系人可以查看此动态。"; +"Story.Tooltip.GifSent" = "已发送 GIF 动态图。"; +"Story.Tooltip.LinkCopied" = "拷贝到剪切板。"; +"Story.Tooltip.MediaSent" = "已发送媒体文件。"; +"Story.Tooltip.MessageSent" = "消息已发送。"; +"Story.Tooltip.PhotoSent" = "已发送图片。"; +"Story.Tooltip.ReactionSent" = "消息已发送。"; +"Story.Tooltip.Removed.Title" = "动态已移除"; +"Story.Tooltip.RemovedFromProfile" = "已将动态从您的个人资料中移除。"; +"Story.Tooltip.RemovedFromProfile.Channel" = "已从频道资料页移除动态。"; +"Story.Tooltip.SaveToGallery.Premium" = "订阅 Telegram 高级版后可以保存其他用户未开启保护的动态。"; +"Story.Tooltip.Saved.Title" = "动态已保存"; +"Story.Tooltip.SavedToProfile" = "在您手动移除前,其他人可以在您的个人资料页查看这些已发布的动态。"; +"Story.Tooltip.SavedToProfile.Channel" = "其他用户可以在频道资料页上查看已发布的动态,直到管理员将其移除。"; +"Story.Tooltip.SelectedContacts" = "只有 %@ 选择的部分用户可以查看该动态。"; +"Story.Tooltip.StealthMode.Activate" = "对于您在过去 %1$@ 分钟内或在接下来 %2$@ 分钟内查看的动态,其作者不会在访客列表中看到您。"; +"Story.Tooltip.StealthMode.Active" = "在接下来的 %1$@ 内,您观看的动态创作者将无法在观看者列表中看到您。"; +"Story.Tooltip.StickerSent" = "已发送贴纸。"; +"Story.Tooltip.Unarchive" = "已将 **%@** 的动态从归档中恢复。"; +"Story.Tooltip.VideoSent" = "已发送视频。"; +"Story.Widget.OpenGift" = "打开礼物"; +"StoryFeed.TooltipArchive" = "%@ 的动态已移至归档。"; +"StoryFeed.TooltipUnarchive" = "%@ 的动态已从归档中移回。"; +"StoryInput.PremiumRequiredState" = "只有 Premium 用户才能回复 **%@** 的动态。 [了解更多...](premium)"; +"StoryItem.Text.ShowMore" = "显示更多"; +"StoryList.Context.Archive" = "隐藏动态"; +"StoryList.Context.ArchivedStories" = "已归档的动态"; +"StoryList.Context.OpenChannel" = "打开频道"; +"StoryList.Context.SavedStories" = "已发布的动态"; +"StoryList.Context.SendMessage" = "发送消息"; +"StoryList.Context.Unarchive" = "取消隐藏动态"; +"StoryList.Context.ViewProfile" = "查看个人资料"; +"StoryList.MyStory" = "我的动态"; +"StoryList.TooltipArchive" = "%@\u00a0的动态已移至归档。"; +"StoryList.TooltipUnarchive" = "%@ 的动态已从归档中移回。"; +"StoryListView.AddToProfile" = "添加至个人资料"; +"StoryListView.AddToSavedMessages" = "添加至我的收藏"; +"StoryMedia.Album.Add" = "添加相册"; +"StoryMedia.Album.Alert.Added" = "动态已添加至 **%@**"; +"StoryMedia.Album.Alert.Removed" = "动态已从 **%@** 中移除"; +"StoryMedia.Album.All" = "所有动态"; +"StoryMedia.Album.Button.AddStories" = "添加动态"; +"StoryMedia.Album.ContextMenu.AddFolder" = "添加相册"; +"StoryMedia.Album.ContextMenu.AddStories" = "添加动态"; +"StoryMedia.Album.ContextMenu.AddToAlbum" = "添加至相册里"; +"StoryMedia.Album.ContextMenu.Delete" = "删除"; +"StoryMedia.Album.ContextMenu.Delete.Confirm" = "您是否确定要删除 **%@**?"; +"StoryMedia.Album.ContextMenu.NewAlbum" = "新相册"; +"StoryMedia.Album.ContextMenu.Rename" = "编辑相册标题"; +"StoryMedia.Album.Empty.Action" = "添加到相册里"; +"StoryMedia.Album.Empty.Header" = "整理您的动态"; +"StoryMedia.Album.Empty.Text" = "将您的一些动态添加到此相册里。"; +"StoryMedia.Album.New.Info" = "为您的相册起一个标题并添加您的动态。"; +"StoryMedia.Album.New.Ok" = "创建"; +"StoryMedia.Album.New.Placeholder" = "标题..."; +"StoryMedia.Album.New.Title" = "新建相册"; +"StoryMedia.Album.Update.Info" = "更新您相册的标题。"; +"StoryMedia.Album.Update.Ok" = "更新"; +"StoryMedia.Album.Update.Placeholder" = "标题..."; +"StoryMedia.Album.Update.Success" = "标题已成功更新\n"; +"StoryMedia.Album.Update.Title" = "更新标题"; +"StoryMedia.Archive" = "归档"; +"StoryMedia.Archive.Empty.Text" = "上传一条新的动态并在这里查看。"; +"StoryMedia.Archive.Empty.Title" = "没有归档的动态"; +"StoryMedia.ArchiveText" = "除非将其发布至个人资料页,否则只有您能查看已归档的动态。"; +"StoryMedia.Context.Archive" = "归档"; +"StoryMedia.Delete" = "删除"; +"StoryMedia.DeleteConfirm#other" = "您确定要删除 %d 条动态吗?"; +"StoryMedia.DeleteConfirm_countable" = "%d"; +"StoryMedia.Done" = "完成"; +"StoryMedia.Empty.Open" = "打开归档"; +"StoryMedia.Empty.Text" = "打开“归档”页,选择要在您的个人资料上显示的动态。"; +"StoryMedia.Empty.Title" = "没有发布的动态"; +"StoryMedia.Pin" = "置顶"; +"StoryMedia.Select" = "选择"; +"StoryMedia.Title.Archive" = "归档"; +"StoryMedia.Title.MyStories" = "我的动态"; +"StoryMedia.Tooltip.Limit#other" = "您最多只能置顶 %d 条动态。"; +"StoryMedia.Tooltip.Limit_countable" = "%d"; +"StoryMedia.Tooltip.Pinned#other" = "已置顶动态"; +"StoryMedia.Tooltip.Pinned_countable" = "%d"; +"StoryMedia.Tooltip.Unpinned#other" = "已将此动态取消置顶"; +"StoryMedia.Tooltip.Unpinned_countable" = "%d"; +"StoryMedia.Unarchive" = "取消归档"; +"StoryMedia.Unpin" = "取消置顶"; +"StoryPrivacy.AllowScreenshot" = "允许截图"; +"StoryPrivacy.BasicInfo" = "即便此动态在 24 小时后过期,也依然将其保留在您的个人资料中,将应用隐私设置。"; +"StoryPrivacy.Choose" = "选择"; +"StoryPrivacy.CloseFriends" = "密友"; +"StoryPrivacy.Contacts" = "联系人"; +"StoryPrivacy.Context.PostAs" = "以哪个身份分享"; +"StoryPrivacy.EditList" = "编辑列表"; +"StoryPrivacy.Everyone" = "所有人"; +"StoryPrivacy.ExceptPeople" = "除了 %@"; +"StoryPrivacy.ExcludePeople" = "排除用户"; +"StoryPrivacy.GrayListPerson#other" = "%d 个人"; +"StoryPrivacy.GrayListPerson_countable" = "%d"; +"StoryPrivacy.GrayList_Part1" = "选择用户"; +"StoryPrivacy.PeopleList#other" = "%d 个人"; +"StoryPrivacy.PeopleList_countable" = "%d"; +"StoryPrivacy.PersonalAccount" = "个人账号"; +"StoryPrivacy.PostStoryAs" = "以其他身份发布动态"; +"StoryPrivacy.PostToMyProfile" = "发布至我的个人资料页"; +"StoryPrivacy.Reveal.Info" = "选择谁能看到您转发的动态。"; +"StoryPrivacy.Reveal.Title" = "隐私"; +"StoryPrivacy.Save.Privacy" = "已保存!"; +"StoryPrivacy.Save.Repost" = "成功!"; +"StoryPrivacy.Select.HideFrom" = "对其隐藏您的动态"; +"StoryPrivacy.Select.Title.CloseFriends" = "密友"; +"StoryPrivacy.Select.Title.Contacts" = "排除的联系人"; +"StoryPrivacy.Select.Title.Nobody" = "选择用户"; +"StoryPrivacy.SelectUsers" = "选择用户"; +"StoryPrivacy.Title.Privacy" = "动态隐私"; +"StoryPrivacy.Title.Repost" = "分享动态"; +"StoryPrivacy.WhoCanViewStory" = "谁能看到这条动态"; +"StorySearch.Subtitle#other" = "%d 个动态"; +"StorySearch.Subtitle_ZeroValueHolder" = "暂无动态"; +"StorySearch.Subtitle_countable" = "%d"; +"StoryView.ContextMenu.Block" = "屏蔽用户"; +"StoryView.ContextMenu.DeleteContact" = "删除联系人"; +"StoryView.ContextMenu.HideMyStories" = "对 %@ 隐藏我的动态"; +"StoryView.ContextMenu.ShowMyStories" = "不要对 %@ 隐藏我的动态"; +"StoryView.ContextMenu.Unblock" = "解除屏蔽"; +"StoryView.MediaArea.OpenUrl" = "打开链接 "; +"StoryView.MediaArea.ViewLocation" = "查看位置"; +"StoryView.MediaArea.ViewMessage" = "查看消息"; +"StoryView.Tooltip.Block" = "已屏蔽 %@。"; +"StoryView.Tooltip.DeleteContact" = "已从联系人中删除 %@。"; +"StoryView.Tooltip.HideMyStories" = "%@ 将无法看到您的动态。"; +"StoryView.Tooltip.ShowMyStories" = "%@ 将可以重新看到您的动态。"; +"StoryView.Tooltip.Unblock" = "已取消屏蔽 %@。"; +"StoryViewers.All" = "所有访客"; +"StoryViewers.Contacts" = "联系人"; +"StoryViewers.NotRecorded" = "未记录其他访客的信息。"; +"StoryViewers.Premium.LearnMore" = "了解更多"; +"StoryViewers.Premium.Unlock" = "如要解锁已过期和已发布动态的访客列表,请订阅 [Telegram 高级版]()。"; +"StoryViewers.ReactionsFirst" = "表情回应优先"; +"StoryViewers.RecentFirst" = "最新优先"; +"StoryViewers.RepostFirst" = "按转发量排序"; +"StoryViewers.Viewers" = "访客"; +"StoryViews.Title#other" = "%d 次查看"; +"StoryViews.Title.Channel" = "回应"; +"StoryViews.Title_ZeroValueHolder" = "还没有人看过"; +"StoryViews.Title_countable" = "%d"; +"Suggest.Frequent.Contacts" = "推荐常用联系人"; +"Suggest.Frequent.Contacts.Alert" = "这将删除所有您常联系用户与常用消息框内机器人的数据。"; +"Suggest.Frequent.Contacts.Desc" = "在搜索区域顶部显示常用联系人以便您快速访问。"; +"Suggest.Localization.Header" = "选择您的语言"; +"Suggest.Localization.Other" = "其它"; +"SuggestPostPanel.Text.Anytime" = "随时"; +"SuggestPostPanel.Text.FreeAndDate" = "免费 📅 %1$@"; +"SuggestPostPanel.Text.OfferPrice" = "点击提供发布价格"; +"SuggestPostPanel.Text.PriceAndDate" = "%1$@ 📅 %2$@"; +"SuggestPostPanel.Title.Edit" = "编辑下方的贴文"; +"SuggestPostPanel.Title.New" = "在下方建议一条贴文"; +"SuggestPostPanel.Title.Suggest" = "推荐更改"; +"Supergroup.Convert.Button" = "转换为超级群组"; +"Supergroup.Convert.Description" = "**在超级群组中**: \n• 新成员可以看到所有历史聊天记录 \n• 所有成员将看不到已删除的消息 \n• 管理员可以置顶重要的消息 \n• 创建者可以给群组设置一个公开链接"; +"Supergroup.Convert.Undone" = "**注意**:此操作无法撤消。"; +"Supergroup.DeleteRestriction.Ban" = "封闭"; +"Supergroup.DeleteRestriction.BanUser" = "封禁用户"; +"Supergroup.DeleteRestriction.BanUser.Multi" = "封闭所有用户"; +"Supergroup.DeleteRestriction.Delete" = "删除"; +"Supergroup.DeleteRestriction.DeleteAllMessages" = "删除该用户的所有消息"; +"Supergroup.DeleteRestriction.DeleteAllMessages.Multi" = "删除该用户发送的所有消息"; +"Supergroup.DeleteRestriction.DeleteMessage" = "删除消息"; +"Supergroup.DeleteRestriction.DeleteMessage.Multi" = "删除信息"; +"Supergroup.DeleteRestriction.MultiTitle#other" = "删除 %d 条消息"; +"Supergroup.DeleteRestriction.MultiTitle_countable" = "%d"; +"Supergroup.DeleteRestriction.Proceed" = "完成"; +"Supergroup.DeleteRestriction.Report" = "举报"; +"Supergroup.DeleteRestriction.ReportSpam" = "举报垃圾信息"; +"Supergroup.DeleteRestriction.Restrict" = "限制"; +"Supergroup.DeleteRestriction.RestrictUser" = "限制用户"; +"Supergroup.DeleteRestriction.RestrictUser.Multi" = "限制所有用户"; +"Supergroup.DeleteRestriction.Title" = "管理消息"; +"SuspiciousSession.Me" = "是的,这是我"; +"SuspiciousSession.NotMe" = "不,这不是我!"; +"System.MemoryWarning.DataAndStorage" = "应用数据存储"; +"System.MemoryWarning.FreeSpace" = "%d GB"; +"System.MemoryWarning.Header" = "警告!"; +"System.MemoryWarning.LessThen1GB" = "不足 1GB"; +"System.MemoryWarning.OK" = "OK"; +"System.MemoryWarning.Text" = "您的可用硬盘空间不足,请清理或修改缓存设置。\n可用空间:~%@"; +"Td7-aD-5lo.title" = "窗口"; +"Telegram.AppearanceViewController" = "外观"; +"Telegram.ArchivedStickerPacksController" = "已归档表情"; +"Telegram.BioViewController" = "个人简介"; +"Telegram.BlockedPeersViewController" = "已拉黑用户"; +"Telegram.ChannelAdminsViewController" = "管理员"; +"Telegram.ChannelBlacklistViewController" = "已移除用户"; +"Telegram.ChannelEventLogController" = "所有操作"; +"Telegram.ChannelIntroViewController" = "频道"; +"Telegram.ChannelMembersViewController" = "频道成员"; +"Telegram.ChannelPermissionsController" = "权限"; +"Telegram.ChannelStatisticsController" = "频道统计"; +"Telegram.ChannelVisibilityController" = "频道"; +"Telegram.ChannelVisibilityController.Channel" = "频道"; +"Telegram.ChannelVisibilityController.Group" = "群组"; +"Telegram.ConvertGroupViewController" = "超级群组"; +"Telegram.DataAndStorageViewController" = "数据和存储"; +"Telegram.FeaturedStickerPacksController" = "热门贴纸"; +"Telegram.ForwardChatListController" = "转发消息"; +"Telegram.GeneralSettingsViewController" = "通用设置"; +"Telegram.GroupAdminsController" = "管理员"; +"Telegram.GroupStickerSetController" = "群组贴纸包"; +"Telegram.GroupsInCommonViewController" = "共同加入的群组"; +"Telegram.InstalledStickerPacksController" = "贴纸与表情"; +"Telegram.LanguageViewController" = "语言"; +"Telegram.LayoutAccountController" = "偏好设置"; +"Telegram.LayoutRecentCallsViewController" = "最近通话"; +"Telegram.LinkInvationController" = "邀请链接"; +"Telegram.NotificationSettingsViewController" = "通知"; +"Telegram.PasscodeSettingsViewController" = "密码锁"; +"Telegram.PassportController" = "通行证"; +"Telegram.PeerInfoController" = "信息"; +"Telegram.PeerMediaController" = "分享的媒体文件"; +"Telegram.PhoneNumberConfirmController" = "更改号码"; +"Telegram.PreHistorySettingsController" = "历史记录设置"; +"Telegram.PrivacyAndSecurityViewController" = "隐私和安全"; +"Telegram.ProxySettingsViewController" = "代理"; +"Telegram.RecentSessionsController" = "活跃会话"; +"Telegram.SecretChatKeyViewController" = "加密密钥"; +"Telegram.SelectPeersController" = "选择用户"; +"Telegram.StorageUsageController" = "存储用量"; +"Telegram.TwoStepVerificationUnlockController" = "两步验证"; +"Telegram.UpgradeDatabase.Text" = "Telegram 更新后需要一些时间来优化数据库。给您带来不便,敬请谅解。"; +"Telegram.UpgradeDatabase.Title" = "数据库优化中"; +"Telegram.UsernameSettingsViewController" = "用户名"; +"Telegram.WebSessionsController" = "使用 Telegram 账号登录的网站"; +"TermsOfService.Accept" = "同意并继续"; +"TermsOfService.Accept.ConfirmAge" = "确认"; +"TermsOfService.ConfirmAge" = "我确认我已满 %@ 周岁。"; +"TermsOfService.Disagree" = "拒绝"; +"TermsOfService.Disagree.OK" = "拒绝并删除帐号"; +"TermsOfService.Disagree.Text" = "我们对此感到非常抱歉,但是这意味着我们必须在此离别了。与其它软件不同,我们不会利用您的个人数据来进行广告定位或其它商业目的。Telegram 储存的所有文件都是为了给您创造一个功能丰富的云服务体验。您可以在「隐私和安全」设置里调整我们使用您数据的方式(例如:删除已同步的联系人)。\n\n但若您完全无法接受 Telegram 的云数据政策,我们将无法为您提供服务。"; +"TermsOfService.Disagree.Text.Last" = "警告,这将不可逆转地删除您的 Telegram 帐号以及所有储存在 Telegram 云服务器中的数据。\n\n注意:为保留个人数据,您可以先行取消,在导出数据并保存后,再删除您的帐号。(若要导出个人数据,请打开 Telegram 最新桌面版,并进入设置 > 高级 > 导出 Telegram 数据 。)"; +"TermsOfService.Disagree.Text.Last.OK" = "立即删除"; +"TermsOfService.ProceedBot" = "请同意以继续使用 %@。"; +"TermsOfService.Title" = "服务条款"; +"Text.Arrow" = "%1$@ → %2$@"; +"Text.Context.Copy.Code" = "拷贝代码"; +"Text.Context.Copy.Command" = "拷贝命令文本"; +"Text.Context.Copy.Email" = "拷贝邮件"; +"Text.Context.Copy.EmojiPack" = "复制表情包"; +"Text.Context.Copy.Hashtag" = "拷贝标签"; +"Text.Context.Copy.InviteLink" = "拷贝邀请链接"; +"Text.Context.Copy.Link" = "拷贝链接"; +"Text.Context.Copy.StickerPack" = "拷贝贴纸包链接"; +"Text.Context.Copy.Username" = "拷贝用户名"; +"Text.Copy" = "拷贝所选内容"; +"Text.CopyLabel" = "拷贝 %@"; +"Text.CopyLabel_About" = "拷贝关于"; +"Text.CopyLabel_Bio" = "拷贝简介"; +"Text.CopyLabel_PhoneNumber" = "拷贝手机号码"; +"Text.CopyLabel_ShareLink" = "拷贝分享链接"; +"Text.CopyLabel_Username" = "拷贝用户名"; +"Text.CopyText" = "拷贝文本"; +"Text.View.Transformations" = "格式设置"; +"TextUtils.ManagedBotCreated" = "%1$@ 已创建 %2$@"; +"TextUtils.PollOptionAppended" = "%1$@ 已新增一个选项: %2$@"; +"TextUtils.PollOptionDeleted" = "%1$@ 已删除一个选项: %2$@"; +"TextView.Transform.Bold" = "粗体"; +"TextView.Transform.Code" = "等宽"; +"TextView.Transform.Date" = "日期"; +"TextView.Transform.Italic" = "斜体"; +"TextView.Transform.Quote" = "引用"; +"TextView.Transform.RemoveAll" = "清除格式"; +"TextView.Transform.Spoiler" = "遮罩"; +"TextView.Transform.Strikethrough" = "删除线"; +"TextView.Transform.Translate" = "翻译"; +"TextView.Transform.URL" = "转换为链接"; +"TextView.Transform.URL1" = "转换为链接"; +"TextView.Transform.Underline" = "下划线"; +"Theme.GetTheme.Error" = "抱歉,这个主题似乎不存在 macOS 版。"; +"ThemePreview.Title" = "主题预览"; +"ThemePreview.UsesCount#other" = "\n%d 个人正在使用该主题"; +"ThemePreview.UsesCount_countable" = "%d"; +"Time.AtDate" = "{date}"; +"Time.AtPreciseDate" = "%1$@ %2$@"; +"Time.PreciseDate_m1" = "%@年1月%@日 %@"; +"Time.PreciseDate_m10" = "%@年10月%@日 %@"; +"Time.PreciseDate_m11" = "%@年11月%@日 %@"; +"Time.PreciseDate_m12" = "%@年12月%@日 %@"; +"Time.PreciseDate_m2" = "%@年2月%@日 %@"; +"Time.PreciseDate_m3" = "%@年3月%@日 %@"; +"Time.PreciseDate_m4" = "%@年4月%@日 %@"; +"Time.PreciseDate_m5" = "%@年5月%@日 %@"; +"Time.PreciseDate_m6" = "%@年6月%@日 %@"; +"Time.PreciseDate_m7" = "%@年7月%@日 %@"; +"Time.PreciseDate_m8" = "%@年8月%@日 %@"; +"Time.PreciseDate_m9" = "%@年9月%@日 %@"; +"Time.PreciseMediumDate_m1" = "一月%@日%@"; +"Time.PreciseMediumDate_m10" = "十月 %@日的%@"; +"Time.PreciseMediumDate_m11" = "十一月%@日%@"; +"Time.PreciseMediumDate_m12" = "十二月%@日%@"; +"Time.PreciseMediumDate_m2" = "二月%@日%@"; +"Time.PreciseMediumDate_m3" = "三月%@日%@"; +"Time.PreciseMediumDate_m4" = "四月 %@日的 %@"; +"Time.PreciseMediumDate_m5" = "五月%@日%@"; +"Time.PreciseMediumDate_m6" = "六月%@日%@"; +"Time.PreciseMediumDate_m7" = "七月%@日%@"; +"Time.PreciseMediumDate_m8" = "八月%@日%@"; +"Time.PreciseMediumDate_m9" = "九月%@日%@"; +"Time.TodayAt" = "今天 %@"; +"Time.TomorrowAt" = "明天%@"; +"Time.YesterdayAt" = "昨天 %@"; +"Time.agoHour" = "{date} 小时前"; +"Time.agoHours" = "{date} 小时前"; +"Time.agoMinute" = "{date} 分钟前"; +"Time.agoMinutes" = "{date} 分钟前"; +"Time.at" = "于"; +"Time.justnow" = "刚刚"; +"Time.last_seen" = "最近上线"; +"Time.today" = "今天"; +"Time.yesterday" = "昨天"; +"Timer.Days#other" = "%d 天"; +"Timer.Days_countable" = "%d"; +"Timer.Forever" = "永久"; +"Timer.Hours#other" = "%d 小时"; +"Timer.Hours_countable" = "%d"; +"Timer.Minutes#other" = "%d 分钟"; +"Timer.Minutes_countable" = "%d"; +"Timer.Months#other" = "%d 个月"; +"Timer.Months_countable" = "%d"; +"Timer.Never" = "永不"; +"Timer.Seconds#other" = "%d 秒"; +"Timer.Seconds_countable" = "%d"; +"Timer.Weeks#other" = "%d 周"; +"Timer.Weeks_countable" = "%d"; +"Timer.Years#other" = "%d 年"; +"Timer.Years_countable" = "%d"; +"Tip.AutoDelete.TimerSetForDay" = "自动删除计时器设置为 1 天。"; +"Tip.AutoDelete.TimerSetForWeek" = "自动删除计时器设置为 1 周。"; +"Tip.AutoDelete.TimerSetOff" = "已停用自动删除计时器。"; +"Toast.Muted" = "静音"; +"Toast.Unmuted" = "取消静音"; +"TonViewer.Url" = "https://tonviewer.com/%@"; +"TouchBar.Attach" = "发送附件"; +"TouchBar.Call" = "通话"; +"TouchBar.EditMessage.ReplaceWithFile" = "替换为文件"; +"TouchBar.EditMessage.ReplaceWithMedia" = "替换为媒体"; +"TouchBar.Favorite" = "收藏"; +"TouchBar.Recent" = "最近"; +"TouchBar.RecentlyUsed" = "最近使用"; +"TouchBar.SearchUsersOrMessages" = "搜索聊天"; +"TouchBar.StartSecretChat" = "开始私密聊天"; +"TouchBarLabel.ChatActions" = "聊天操作"; +"TouchBarLabel.EmojiAndStickers" = "表情符号与贴纸"; +"TouchBarLabel.NewChat" = "新建聊天"; +"Translate.AIEditor" = "AI 编辑器"; +"Translate.Apply" = "应用"; +"Translate.CopyToClipboard" = "复制到剪贴板"; +"Translate.DailyLimitInfo" = "获取 Telegram 高级版即可享有每天 **50 倍** 的文字编辑次数。"; +"Translate.DailyLimitReached" = "已达每日上限"; +"Translate.Emojify" = "表情化"; +"Translate.From" = "从: %@"; +"Translate.Language.Afrikaans" = "南非语"; +"Translate.Language.Albanian" = "阿尔巴尼亚语"; +"Translate.Language.Amharic" = "阿姆哈拉语"; +"Translate.Language.Arabic" = "阿拉伯语"; +"Translate.Language.Armenian" = "亚美尼亚语"; +"Translate.Language.Azerbaijani" = "阿塞拜疆语"; +"Translate.Language.Basque" = "巴士克语"; +"Translate.Language.Belarusian" = "白俄罗斯语"; +"Translate.Language.Bengali" = "孟加拉语"; +"Translate.Language.Bosnian" = "波斯尼亚语"; +"Translate.Language.Bulgarian" = "保加利亚语"; +"Translate.Language.Catalan" = "加泰罗尼亚语"; +"Translate.Language.Cebuano" = "宿务"; +"Translate.Language.Chinese_Simplified" = "简体中文"; +"Translate.Language.Chinese_Traditional" = "繁体中文"; +"Translate.Language.Corsican" = "科西嘉语"; +"Translate.Language.Croatian" = "克罗地亚语"; +"Translate.Language.Czech" = "捷克语"; +"Translate.Language.Danish" = "丹麦语"; +"Translate.Language.Dutch" = "荷兰语"; +"Translate.Language.English" = "英语"; +"Translate.Language.Esperanto" = "世界语"; +"Translate.Language.Estonian" = "爱沙尼亚语"; +"Translate.Language.Finnish" = "芬兰语"; +"Translate.Language.French" = "法语"; +"Translate.Language.Frisian" = "弗里斯兰语"; +"Translate.Language.Galician" = "加利西亚语"; +"Translate.Language.Georgian" = "乔治亚语"; +"Translate.Language.German" = "德语"; +"Translate.Language.Greek" = "希腊语"; +"Translate.Language.Gujarati" = "古吉拉特语"; +"Translate.Language.Haitian_Creole" = "海地克里奥尔语"; +"Translate.Language.Hausa" = "豪撒语"; +"Translate.Language.Hawaiian" = "夏威夷语"; +"Translate.Language.Hebrew" = "希伯来语"; +"Translate.Language.Hindi" = "印地语"; +"Translate.Language.Hmong" = "苗族"; +"Translate.Language.Hungarian" = "匈牙利语"; +"Translate.Language.Icelandic" = "冰岛语"; +"Translate.Language.Igbo" = "伊博语"; +"Translate.Language.Indonesian" = "印度尼西亚语"; +"Translate.Language.Irish" = "爱尔兰语"; +"Translate.Language.Italian" = "意大利语"; +"Translate.Language.Japanese" = "日语"; +"Translate.Language.Javanese" = "爪哇语"; +"Translate.Language.Kannada" = "卡纳拉语"; +"Translate.Language.Kazakh" = "哈萨克语"; +"Translate.Language.Khmer" = "高棉语"; +"Translate.Language.Kinyarwanda" = "基尼亚卢旺达语"; +"Translate.Language.Korean" = "韩语"; +"Translate.Language.Kurdish" = "库尔德语"; +"Translate.Language.Kyrgyz" = "吉尔吉斯语"; +"Translate.Language.Lao" = "老挝语"; +"Translate.Language.Latvian" = "拉脱维亚语"; +"Translate.Language.Lithuanian" = "立陶宛语"; +"Translate.Language.Luxembourgish" = "卢森堡语"; +"Translate.Language.Macedonian" = "马其顿语"; +"Translate.Language.Malagasy" = "马达加斯加语"; +"Translate.Language.Malay" = "马来语"; +"Translate.Language.Malayalam" = "马拉雅拉姆语"; +"Translate.Language.Maltese" = "马耳他语"; +"Translate.Language.Maori" = "毛利语"; +"Translate.Language.Marathi" = "马拉地语"; +"Translate.Language.Mongolian" = "蒙古语"; +"Translate.Language.Myanmar_Burmese" = "缅甸(缅甸语)"; +"Translate.Language.Nepali" = "尼泊尔语"; +"Translate.Language.Norwegian" = "挪威语"; +"Translate.Language.Nyanja_Chichewa" = "尼扬贾(奇切瓦)"; +"Translate.Language.Odia_Oriya" = "奥迪亚(奥里亚)"; +"Translate.Language.Pashto" = "普什图语"; +"Translate.Language.Persian" = "波斯语"; +"Translate.Language.Polish" = "波兰语"; +"Translate.Language.Portuguese_Portugal_Brazil" = "葡萄牙语(葡萄牙、巴西)"; +"Translate.Language.Punjabi" = "旁遮普语"; +"Translate.Language.Romanian" = "罗马尼亚"; +"Translate.Language.Russian" = "俄语"; +"Translate.Language.Samoan" = "萨摩亚语"; +"Translate.Language.Scots_Gaelic" = "苏格兰盖尔语"; +"Translate.Language.Serbian" = "塞尔维亚语"; +"Translate.Language.Sesotho" = "塞索托语"; +"Translate.Language.Shona" = "绍纳语"; +"Translate.Language.Sindhi" = "信德语"; +"Translate.Language.Sinhala_Sinhalese" = "僧伽罗语(僧伽罗语)"; +"Translate.Language.Slovak" = "斯洛伐克语"; +"Translate.Language.Slovenian" = "斯洛文尼亚语"; +"Translate.Language.Somali" = "索马里语"; +"Translate.Language.Spanish" = "西班牙语"; +"Translate.Language.Sundanese" = "巽他语"; +"Translate.Language.Swahili" = "斯瓦希里语"; +"Translate.Language.Swedish" = "瑞典语"; +"Translate.Language.Tagalog_Filipino" = "他加禄语(菲律宾语)"; +"Translate.Language.Tajik" = "塔吉克斯坦"; +"Translate.Language.Tamil" = "泰米尔语"; +"Translate.Language.Tatar" = "鞑靼语"; +"Translate.Language.Telugu" = "泰卢固语"; +"Translate.Language.Thai" = "泰语"; +"Translate.Language.Turkish" = "土耳其语"; +"Translate.Language.Turkmen" = "土库曼语"; +"Translate.Language.Ukrainian" = "乌克兰语"; +"Translate.Language.Urdu" = "乌都语"; +"Translate.Language.Uyghur" = "维吾尔语"; +"Translate.Language.Uzbek" = "乌兹别克语"; +"Translate.Language.Vietnamese" = "越南语"; +"Translate.Language.Welsh" = "威尔士语"; +"Translate.Language.Xhosa" = "班图语"; +"Translate.Language.Yiddish" = "意第绪语"; +"Translate.Language.Yoruba" = "约鲁巴语"; +"Translate.Language.Zulu" = "祖鲁语"; +"Translate.Language.ar" = "阿拉伯语"; +"Translate.Language.auto" = "自动"; +"Translate.Language.de" = "德语"; +"Translate.Language.en" = "英语"; +"Translate.Language.es" = "西班牙语"; +"Translate.Language.fr" = "法语"; +"Translate.Language.it" = "意大利语"; +"Translate.Language.jp" = "日语"; +"Translate.Language.ko" = "韩语"; +"Translate.Language.pt" = "葡萄牙语"; +"Translate.Language.ru" = "俄语"; +"Translate.Language.zh" = "普通话"; +"Translate.Original" = "原始"; +"Translate.Result" = "结果"; +"Translate.SelectStyle" = "选择风格"; +"Translate.ShowMore" = "更多"; +"Translate.TabFix" = "修正"; +"Translate.TabStyle" = "风格"; +"Translate.TabTranslate" = "翻译"; +"Translate.Title" = "翻译"; +"Translate.To" = "至: %@"; +"Translate.Tone" = "语气"; +"Translate.TranslateEntireChat" = "翻译整个聊天记录"; +"TranslateModal.Create" = "创建"; +"TranslateModal.DeleteStyle" = "删除风格"; +"TranslateModal.DeleteStyleConfirm" = "确定删除该风格吗?"; +"TranslateModal.DeleteStyleForAll" = "删除全部"; +"TranslateModal.DeleteStyleHeader" = "删除风格"; +"TranslateModal.EditStyle" = "编辑风格"; +"TranslateModal.ShareStyle" = "分享风格"; +"TranslateModal.StyleByAuthor" = "风格作者:[@%1$@](https://t.me/%1$@)"; +"TwoStep.AuthEmailSkip" = "跳过"; +"TwoStepAuth.AnError" = "发生错误,请稍后再试。"; +"TwoStepAuth.CancelReset" = "取消重置"; +"TwoStepAuth.CancelReset.Confirm" = "取消重置"; +"TwoStepAuth.CancelReset.Text" = "是否取消重置密码?若要再次申请密码重置,需要再等 7 天。"; +"TwoStepAuth.ChangeEmail" = "更改密码恢复邮箱"; +"TwoStepAuth.ChangePassword" = "更改密码"; +"TwoStepAuth.ChangePasswordDesc" = "请输入用于保护数据的新密码。"; +"TwoStepAuth.Confirm.DisablePassword" = "您确定要删除您的密码吗?"; +"TwoStepAuth.ConfirmEmailCodeDesc" = "请输入我们刚刚发送到 %@ 电子邮件的验证码。"; +"TwoStepAuth.ConfirmationAbort" = "取消两步验证设置"; +"TwoStepAuth.ConfirmationText" = "请检查您的电子邮箱,并点击验证链接来完成两步验证的设置。请务必也检查垃圾邮件文件夹。"; +"TwoStepAuth.ConfirmationTextNew" = "请检查你的电子邮件并输入验证码以完成两步验证设置。 请一定要检查邮箱内的垃圾邮件。"; +"TwoStepAuth.Email" = "邮箱"; +"TwoStepAuth.EmailCodeExpired" = "此验证码已失效,请再试一次。"; +"TwoStepAuth.EmailCodeInvalid" = "您输入的验证码无效,请重试。"; +"TwoStepAuth.EmailHelp" = "请添加您的有效电子邮箱。这是忘记密码时唯一的恢复方法。"; +"TwoStepAuth.EmailHelpChange" = "请输入您的备用电子邮箱。 这是忘记密码时唯一的恢复方法。"; +"TwoStepAuth.EmailInvalid" = "邮箱地址无效,请重新输入。"; +"TwoStepAuth.EmailSent" = "我们发送了一封邮件来验证此邮箱地址。"; +"TwoStepAuth.EmailSkipAlert" = "您确定不设置密码恢复邮箱?\n\n一旦您忘记了密码,您将无法再访问您的 Telegram 帐户,并且我们没有任何恢复帐户数据的方法。"; +"TwoStepAuth.EnterEmailCode" = "输入验证码"; +"TwoStepAuth.EnterPasswordForgot" = "是否忘记密码?"; +"TwoStepAuth.EnterPasswordHelp" = "您已启用了两步验证,因此您的帐号已受到额外密码的保护。"; +"TwoStepAuth.EnterPasswordHint" = "提示: %@"; +"TwoStepAuth.EnterPasswordPassword" = "密码"; +"TwoStepAuth.Error.Generic" = "出错了,请稍后再试。"; +"TwoStepAuth.Error.HaventEmail" = "因为您未设置恢复密码的电子邮箱,您现在只能想办法回忆起您的密码或者是对您的帐号进行重置。"; +"TwoStepAuth.Error.HaventEmail.Reset" = "重置"; +"TwoStepAuth.Error.HaventEmail.ResetHeader" = "重置密码"; +"TwoStepAuth.Error.HaventEmailNew" = "由于你在设置密码时没有提供恢复邮箱,你只能尝试回忆密码或等待 7 天直到密码重置。"; +"TwoStepAuth.Error.InvalidEmail" = "请输入有效的电子邮箱地址。"; +"TwoStepAuth.Error.LimitExceeded" = "您输错密码的次数过多,请稍后重试。"; +"TwoStepAuth.Error.PasswordsDontMatch" = "密码不一致。\n请重新输入"; +"TwoStepAuth.FloodError" = "尝试次数过多。请稍后再试。"; +"TwoStepAuth.GenericError" = "出错了,请稍后再试。"; +"TwoStepAuth.GenericHelp" = "您已启用两步验证。\n您将需要此处设置的密码以登录到您的 Telegram 帐号。"; +"TwoStepAuth.InvalidPasswordError" = "密码错误,请重试。"; +"TwoStepAuth.PasswordTitle" = "密码"; +"TwoStepAuth.PendingEmailHelp" = "邮箱 %@ 正在等待验证,在完成验证之前此恢复邮箱不会启用。"; +"TwoStepAuth.RecoveryCode" = "验证码"; +"TwoStepAuth.RecoveryCodeExpired" = "验证码已失效"; +"TwoStepAuth.RecoveryCodeHelp" = "请检查您的邮箱并输入我们发送给您的六位认证码来关闭两步验证密码。"; +"TwoStepAuth.RecoveryCodeInvalid" = "验证码无效,请重试。"; +"TwoStepAuth.RecoveryEmailResetText" = "如果你无法访问辅助邮箱,你剩下的选择只有想起密码,或者等待 7 天后密码重置。"; +"TwoStepAuth.RecoveryEmailUnavailable" = "无法访问电子邮箱(%@)?"; +"TwoStepAuth.RecoveryEmailUnavailableNew" = "无法访问您的电子邮箱\n[%@]()?"; +"TwoStepAuth.RecoveryFailed" = "您剩下的选择只有回忆起您的密码或重置您的帐号。"; +"TwoStepAuth.RecoverySent" = "我们发送了一个认证码到您的密码找回邮箱:\n\n%@"; +"TwoStepAuth.RecoveryTitle" = "邮件验证码"; +"TwoStepAuth.RecoveryUnavailable" = "因为您在设置密码时没有同时提供密码恢复邮箱,您剩下的选择就只有回忆起您的密码或重置您的帐号。"; +"TwoStepAuth.RemovePassword" = "停用密码"; +"TwoStepAuth.Reset" = "重置密码"; +"TwoStepAuth.ResetDescription" = "因由于账号 **%@** 处于活跃状态且正被两步验证密码保护,出于安全考虑,我们将把删除账号的进程推迟一周。\n\n您可以随时撤销这一进程。\n\n在以下时间后,您可以重置账号:\n%@"; +"TwoStepAuth.ResetPending" = "您可以在 %@ 后重置密码。"; +"TwoStepAuth.ResetSuccess" = "您已成功重置密码,是否要创建新密码?"; +"TwoStepAuth.ResetSuccess.Header" = "重置密码"; +"TwoStepAuth.SetPassword" = "设置附加密码"; +"TwoStepAuth.SetPasswordHelp" = "您可以设置一个密码,这将作为在一台新设备登录时输入短信验证码后额外的身份验证。"; +"TwoStepAuth.SetupEmail" = "设置密码恢复邮箱"; +"TwoStepAuth.SetupEmailTitle" = "密码恢复邮箱"; +"TwoStepAuth.SetupHint" = "请为您的密码建立提示"; +"TwoStepAuth.SetupHintDesc" = "您可以为您的密码创建可选提示。"; +"TwoStepAuth.SetupHintPlaceholder" = "提示"; +"TwoStepAuth.SetupHintTitle" = "密码提示"; +"TwoStepAuth.SetupPasswordConfirmFailed" = "密码不一致,请重新输入。"; +"TwoStepAuth.SetupPasswordConfirmPassword" = "请再次输入密码"; +"TwoStepAuth.SetupPasswordConfirmPasswordDesc" = "请确认您的密码。"; +"TwoStepAuth.SetupPasswordDesc" = "请设置一个用于保护您数据的密码。"; +"TwoStepAuth.SetupPasswordEnterPassword" = "请输入两步验证密码"; +"TwoStepAuth.SetupPasswordEnterPasswordNew" = "请输入新密码"; +"TwoStepAuth.SetupPasswordTitle" = "您的密码"; +"TwoStepAuth.UnableToReset" = "无法重置密码,请在 %@ 重试"; +"UEZ-Bs-lqG.title" = "首字母大写"; +"URL.Link.OnlyAllowed" = "仅允许 %@ 链接"; +"UnknownError" = "出现未知错误,请稍后再试"; +"Update.UpdateTelegram" = "升级 Telegram"; +"UpdateApp.TelegramUpdate" = "Telegram 升级"; +"UpdateApp.UpdateTelegram" = "更新 Telegram"; +"Upgrade.ChannelsTooMuch" = "抱歉,您加入的群组与频道过多。由于技术原因,如果您需要继续修改群组设定,请退出一些群组和频道。"; +"UserInfo.ManagedBotCreatedBy" = "由以下创建并管理"; +"UserInfo.ResetPhoto" = "重置为原始照片"; +"UserInfo.ResetPhoto.Confirm" = "您确定要为 %@ 重置原始照片吗?"; +"UserInfo.ResetPhoto.Confirm.OK" = "重置"; +"UserInfo.SetByYou" = "您设置的头像"; +"UserInfo.SetPhoto" = "为 %@ 设置照片"; +"UserInfo.SetPhoto.BlockInfo" = "你可以用另一张只有你才能查看的照片替换 %@ 的照片。"; +"UserInfo.SetPhoto.Confirm" = "是否将此图片设置为 %@ 的头像?该头像将替代 %@ 设置的其他头像——只有您将会看到该头像。"; +"UserInfo.SetPhoto.Confirm.OK" = "设定"; +"UserInfo.SetPhoto.Info" = "您想为 %@ 推荐一张头像吗?"; +"UserInfo.SetPhoto.Title" = "设定照片"; +"UserInfo.SetPhoto.Tooltip" = "您将会在 %@ 的帐号中看到这个头像。"; +"UserInfo.Suggest.Confirm" = "您想向 %@ 推荐此图片作为头像吗?"; +"UserInfo.Suggest.Confirm.OK" = "推荐"; +"UserInfo.Suggest.Title" = "推荐图片"; +"UserInfo.Suggest.Tooltip" = "您为 %@ 的 Telegram 个人资料推荐了一张新图片"; +"UserInfo.SuggestPhoto" = "为 %@ 建议照片"; +"UserTag.Info.Admin" = "管理员"; +"UserTag.Info.AdminTag" = "管理员标签"; +"UserTag.Info.AdminTagDesc" = "是 %1$@ 在 %2$@ 的管理员标签。"; +"UserTag.Info.CustomTagDesc" = "是 %2$@ 中 %1$@ 的自定义标签。"; +"UserTag.Info.Member" = "成员"; +"UserTag.Info.MemberTag" = "成员标签"; +"UserTag.Info.Owner" = "所有者"; +"UserTag.Info.OwnerTag" = "所有者标签"; +"UserTag.Info.OwnerTagDesc" = "是 %2$@ 中 %1$@ 的所有者标签。"; +"UserTag.Info.SetOwnTag" = "您可以为此群组设置自己的成员标签。"; +"UserTag.Info.TheTag" = "标签"; +"UserTag.Info.ThisGreenTag" = "绿色标签"; +"UserTag.Info.ThisRedTag" = "紫色标签"; +"UserTag.Info.Understood" = "了解"; +"Username.Activate.Info" = "你想要将此链接显示在你的信息页面上吗?"; +"Username.Activate.Ok" = "显示"; +"Username.Activate.Title" = "激活用户名"; +"Username.Active" = "活跃"; +"Username.Deactivate.Info" = "是否在您的信息页面上隐藏此链接?"; +"Username.Deactivate.Ok" = "隐藏"; +"Username.Deactivate.Title" = "停用用户名"; +"Username.List.Info" = "拖放链接以更改它们在您的信息页面上的显示顺序。"; +"Username.List.Title" = "用户名顺序"; +"Username.NotActive" = "不可用"; +"Username.UsernamePurchaseAvailable" = "此用户名已被占用。但是目前可以购买。[了解更多](fragment.com)"; +"UsernameSettings.ChangeDescription" = "您可以在 **Telegram** 上设置一个用户名。设置后,别人可以通过此用户名找到您并与您联系,而不需要您的手机号码。\n\n您可以使用 **a-z**、**0-9** 和下划线。最小长度为 **5** 个字符。"; +"UsernameSettings.Done" = "完成"; +"UsernameSettings.InputPlaceholder" = "输入您的用户名"; +"UsernameSettings.available" = "%@ 可用"; +"Vdr-fp-XzO.title" = "隐藏其他窗口"; +"VerifyAge.Alert.Failed.Header" = "年龄验证失败"; +"VerifyAge.Alert.Failed.Info" = "抱歉, 您无法查看此内容。"; +"VerifyAge.Alert.Passed.Header" = "已经通过年龄验证!"; +"VerifyAge.Alert.Passed.Info" = "您现在可以查看此内容。"; +"Video.Quality.Auto" = "自动"; +"VideoAvatar.Button.Cancel" = "取消"; +"VideoAvatar.Button.Set" = "设置"; +"VideoAvatar.ChooseDesc.Channel" = "选择频道视频封面"; +"VideoAvatar.ChooseDesc.Group" = "选择群组视频封面"; +"VideoAvatar.ChooseDesc.Profile" = "选择一帧作为您的头像"; +"VoiceChat.Alert.Recording" = "正在录制音频流。"; +"VoiceChat.AnonymousDisabledAlertText" = "抱歉,您不能以匿名管理员的身份加入视频聊天。"; +"VoiceChat.Block.Listening" = "接听中"; +"VoiceChat.Block.RecentActive" = "最近活跃"; +"VoiceChat.Chat.Ended" = "视频聊天已结束。"; +"VoiceChat.Chat.StartNew" = "视频聊天已结束。要发起新的视频聊天吗?"; +"VoiceChat.Chat.StartNew.OK" = "开始"; +"VoiceChat.ChatFullAlertText" = "抱歉,此视频聊天目前已有过多参与者。"; +"VoiceChat.ClickToRaiseHand" = "想要发言时请点此举手"; +"VoiceChat.ClickToUnmute" = "点击以开启麦克风"; +"VoiceChat.ClickToUnmute.Secondary.Hold" = "按住 ⎵ 或 %@"; +"VoiceChat.ClickToUnmute.Secondary.HoldDefault" = "按住 ⎵"; +"VoiceChat.ClickToUnmute.Secondary.Press" = "按下 ⎵ 或 %@"; +"VoiceChat.ClickToUnmute.Secondary.PressDefault" = "请按 ⎵"; +"VoiceChat.Conference.DiscardInvite" = "撤销邀请"; +"VoiceChat.Conference.StopCalling" = "结束通话"; +"VoiceChat.Connecting" = "连接中..."; +"VoiceChat.DownHand" = "驳回发言请求"; +"VoiceChat.End.OK" = "退出"; +"VoiceChat.End.Text" = "您确定要退出当前视频聊天吗?"; +"VoiceChat.End.Third" = "结束视频聊天"; +"VoiceChat.End.Title" = "退出视频聊天"; +"VoiceChat.GroupMembers" = "组员"; +"VoiceChat.Info.JoinChannel" = "加入频道"; +"VoiceChat.Info.LeaveChannel" = "退出频道"; +"VoiceChat.Info.OpenChannel" = "打开频道"; +"VoiceChat.Info.OpenProfile" = "打开个人资料"; +"VoiceChat.Info.SendMessage" = "发送信息"; +"VoiceChat.Invite.AddMember" = "添加成员"; +"VoiceChat.Invite.Channels.Title" = "邀请成员"; +"VoiceChat.Invite.Chats" = "最近聊天"; +"VoiceChat.Invite.Conference.Title" = "邀请成员"; +"VoiceChat.Invite.Confirm.Header" = "视频聊天"; +"VoiceChat.Invite.Confirm.OK" = "发送"; +"VoiceChat.Invite.Confirm.Text" = "要将邀请链接发送到所选的聊天吗?"; +"VoiceChat.Invite.Confirm.Third" = "发送发言邀请链接"; +"VoiceChat.Invite.Contacts" = "联系人"; +"VoiceChat.Invite.CopyInviteLink" = "拷贝邀请链接"; +"VoiceChat.Invite.CopyListenersLink" = "拷贝收听者邀请链接"; +"VoiceChat.Invite.CopySpeakersLink" = "拷贝发言人邀请链接"; +"VoiceChat.Invite.GlobalSearch" = "全局搜索"; +"VoiceChat.Invite.GroupMembers" = "群组成员"; +"VoiceChat.Invite.Invite" = "发送"; +"VoiceChat.Invite.InviteMembers" = "邀请成员"; +"VoiceChat.Invite.Title" = "添加成员"; +"VoiceChat.InviteMemberToGroupFirstAdd" = "添加"; +"VoiceChat.InviteMemberToGroupFirstText" = "%1$@ 不是 “%2$@” 的一员,邀请他加入吗?"; +"VoiceChat.Join.Error.TooMany" = "抱歉,此语音群聊已经满员。"; +"VoiceChat.JoinAs.Channel#other" = "%d名订阅者"; +"VoiceChat.JoinAs.Channel_countable" = "%d"; +"VoiceChat.JoinAs.Group#other" = "%d 位参与者"; +"VoiceChat.JoinAs.Group_countable" = "%d"; +"VoiceChat.Leave" = "退出"; +"VoiceChat.LeaveCall" = "退出"; +"VoiceChat.LeaveGrupCall" = "推出"; +"VoiceChat.ListenMode" = "你处于收听模式"; +"VoiceChat.Listening" = "收听中"; +"VoiceChat.MuteForMe" = "为我静音"; +"VoiceChat.MutePeer" = "静音"; +"VoiceChat.MutedByAdmin" = "被管理员静音"; +"VoiceChat.OpenProfile" = "打开个人资料"; +"VoiceChat.PinScreencast" = "置顶屏幕共享"; +"VoiceChat.PinVideo" = "置顶视频"; +"VoiceChat.RTMP.Desc" = "建议使用软件编码(OBS 中的 x264)。"; +"VoiceChat.RTMP.Error" = "**糟糕!** Telegram 没有接收到来自您的直播程序的推流。请确保您在直播程序中输入了正确的服务器 URL 和串流码。"; +"VoiceChat.RTMP.Info" = "若要使用其他应用程序直播视频,请在直播应用程序中输入以下服务器网址和推流密钥。"; +"VoiceChat.RTMP.OK" = "开始直播"; +"VoiceChat.RTMP.Revoke" = "撤销推流密钥"; +"VoiceChat.RTMP.Revoke.Info" = "将生成一个新密钥。您需要在流媒体应用程序中更新它。"; +"VoiceChat.RTMP.ServerURL" = "服务器 URL"; +"VoiceChat.RTMP.StreamKey" = "串流密钥"; +"VoiceChat.RTMP.Title" = "以其他方式直播…"; +"VoiceChat.RTMP.ViewerError" = "Telegram无法检测到来自 %@ 的任何实时流"; +"VoiceChat.RaisedHand.Text" = "举手让管理员知道你想要发言"; +"VoiceChat.RaisedHand.Title" = "举手发言"; +"VoiceChat.Recording.Start.OK" = "开始"; +"VoiceChat.Recording.Start.Text" = "是否录制此次聊天并保存为音频文件?\n\n其他参与者会看到聊天正在被录音。"; +"VoiceChat.Recording.Start.Text1" = "您想开始录制并将此次聊天保存为文件吗?\n\n其他参与者会看到聊天正在被录制。"; +"VoiceChat.Recording.Start.Title" = "开始录制"; +"VoiceChat.Recording.Stop.OK" = "停止"; +"VoiceChat.Recording.Stop.Text" = "是否停止录制音频?"; +"VoiceChat.Recording.Stop.Title" = "停止录音"; +"VoiceChat.RemovePeer" = "移除"; +"VoiceChat.RemovePeer.Confirm" = "你确定要将 %1$@ 从该群组中移除吗?"; +"VoiceChat.RemovePeer.Confirm.Cancel" = "取消"; +"VoiceChat.RemovePeer.Confirm.Channel" = "确定将 %1$@ 移除此频道吗?"; +"VoiceChat.RemovePeer.Confirm.OK" = "移除"; +"VoiceChat.RemovePeerRemove" = "移除"; +"VoiceChat.RemoveReminder" = "取消提醒"; +"VoiceChat.RequestAccess" = "Telegram 需要麦克风权限, 以便您可以发言"; +"VoiceChat.Scheduled.Header" = "开始于"; +"VoiceChat.Scheduled.HeaderLate" = "已过预定时间"; +"VoiceChat.ScreenShare.Unavailable" = "无法共享你的屏幕,请在[系统设置](screen)里给予Telegram权限"; +"VoiceChat.Screencast.Confirm.Header" = "视频聊天"; +"VoiceChat.Screencast.Confirm.OK" = "继续"; +"VoiceChat.Screencast.Confirm.Text" = "%@ 正在共享屏幕,此操作会为所有成员置顶您的屏幕共享。"; +"VoiceChat.Screencast.Paused" = "屏幕共享已暂停"; +"VoiceChat.SetReminder" = "设置提醒"; +"VoiceChat.Settings" = "设置"; +"VoiceChat.Settings.AllMembers" = "新加入者可发言"; +"VoiceChat.Settings.DisplayAs.PersonalAccount" = "个人账号"; +"VoiceChat.Settings.DisplayAs.Title" = "显示我的身份为"; +"VoiceChat.Settings.End" = "结束视频通话"; +"VoiceChat.Settings.End.Confirm" = "您确定要结束此次视频聊天吗?"; +"VoiceChat.Settings.End.Confirm.OK" = "结束"; +"VoiceChat.Settings.End.Confirm.Title" = "结束视频聊天"; +"VoiceChat.Settings.InputMode" = "按下说话模式"; +"VoiceChat.Settings.InputMode.Always" = "按下并松开"; +"VoiceChat.Settings.InputMode.PushToTalk" = "按下并保持"; +"VoiceChat.Settings.InputMode.SoundEffects" = "声音特效"; +"VoiceChat.Settings.NoiseSuppression" = "噪音抑制"; +"VoiceChat.Settings.NoiseText" = "噪音抑制"; +"VoiceChat.Settings.NoiseTitle" = "背景噪音"; +"VoiceChat.Settings.OnlyAdmins" = "新参与者被静音"; +"VoiceChat.Settings.Output" = "输出"; +"VoiceChat.Settings.Output.Device" = "输出设备"; +"VoiceChat.Settings.Perfomance.Header" = "性能"; +"VoiceChat.Settings.Performance.Desc" = "停用噪声抑制可以提高性能"; +"VoiceChat.Settings.Performance.Header" = "性能"; +"VoiceChat.Settings.Permissions.Title" = "权限"; +"VoiceChat.Settings.PushToTalk" = "快捷键"; +"VoiceChat.Settings.PushToTalk.Access" = "若要在 Telegram 未处于聚焦状态时使用此快捷键,请授予 Telegram [输入监听](input) 权限。"; +"VoiceChat.Settings.PushToTalk.Access.Old" = "请在 [隐私设置](访问) 中允许访问 Telegram。\n\n您可能需要重新启动应用程序。"; +"VoiceChat.Settings.PushToTalk.Desc" = " 当视频聊天的视窗处于焦点状态时,无论此设置为何,你都可以使用⎵ 键发言。"; +"VoiceChat.Settings.PushToTalk.EditKeybind" = "修改快捷键"; +"VoiceChat.Settings.PushToTalk.Enabled" = "按下说话"; +"VoiceChat.Settings.PushToTalk.StopRecording" = "取消"; +"VoiceChat.Settings.PushToTalk.Title" = "麦克风控制"; +"VoiceChat.Settings.PushToTalk.Undefined" = "未定义"; +"VoiceChat.Settings.RTMP" = "直播数据"; +"VoiceChat.Settings.Record.IncludeVideo" = "同时录制视频"; +"VoiceChat.Settings.Record.Orientation.Landscape" = "横屏"; +"VoiceChat.Settings.Record.Orientation.Portrait" = "竖屏"; +"VoiceChat.Settings.ReduceMotion" = "减弱动态效果"; +"VoiceChat.Settings.ResetLink" = "删除发言人链接"; +"VoiceChat.Settings.ResetLink.Success" = "发言者链接已被删除。"; +"VoiceChat.Settings.Title" = "语音通话设置"; +"VoiceChat.Settings.Title.Placeholder" = "标题…"; +"VoiceChat.ShareInviteLink" = "分享邀请链接"; +"VoiceChat.ShareScreen.MutedError" = "你现在无法共享屏幕画面,请先举手请求发言。"; +"VoiceChat.ShareVideo.MutedError" = "你现在无法分享您的摄像头,请先举手请求发言。"; +"VoiceChat.Sharing.Placeholder" = "你正在共享屏幕"; +"VoiceChat.Sharing.Stop" = "停止"; +"VoiceChat.ShowInfo" = "显示详情"; +"VoiceChat.Speaking" = "发言中"; +"VoiceChat.StartNow" = "立即开始"; +"VoiceChat.StartRecording" = "开始录制"; +"VoiceChat.Status.Calling" = "正在呼叫..."; +"VoiceChat.Status.Connecting" = "连接中..."; +"VoiceChat.Status.Invited" = "已邀请"; +"VoiceChat.Status.Listening" = "接听中"; +"VoiceChat.Status.Loading" = "连接中..."; +"VoiceChat.Status.Members#other" = "%d 名参与者"; +"VoiceChat.Status.Members_ZeroValueHolder" = "暂无参与者"; +"VoiceChat.Status.Members_countable" = "%d"; +"VoiceChat.Status.Muted" = "已静音"; +"VoiceChat.Status.MutedForYou" = "已被静音"; +"VoiceChat.Status.Screensharing" = "屏幕分享"; +"VoiceChat.Status.Speaking" = "在发言中"; +"VoiceChat.Status.Stream" = "直播"; +"VoiceChat.Status.Viewers#other" = "%d 位访客"; +"VoiceChat.Status.Viewers_ZeroValueHolder" = "无人观看"; +"VoiceChat.Status.Viewers_countable" = "%d 个"; +"VoiceChat.Status.WantsSpeak" = "想要发言"; +"VoiceChat.Status.You" = "您"; +"VoiceChat.StopRecording" = "停止录制"; +"VoiceChat.Title.End" = "退出"; +"VoiceChat.Title.InviteMembers" = "邀请成员"; +"VoiceChat.Title.Invited" = "已邀请"; +"VoiceChat.Title.Scheduled" = "视频聊天"; +"VoiceChat.Title.Scheduled.Soon" = "预定"; +"VoiceChat.Toast.Stop" = "录制音频流并添加到我的收藏."; +"VoiceChat.Toast.YouCanSpeak" = "您现在可以发言了。"; +"VoiceChat.Tooltip.EnableCamera" = "您的摄像头未开启,点击此处开启摄像头。"; +"VoiceChat.Tooltip.EnableMicro" = "您处于静音状态。点此说话。"; +"VoiceChat.Tooltip.Error.ScreenUnavailable" = "屏幕共享和视频目前可供前 %d 名参与者使用。"; +"VoiceChat.Tooltip.Error.VideoUnavailable" = "视频和屏幕分享现在仅对前 %d 名参与者可用。"; +"VoiceChat.Tooltip.IsSpeaking" = "**%@** 正在说话"; +"VoiceChat.Tooltip.NoCameraFound" = "未找到活跃且已连接的摄像头。"; +"VoiceChat.Tooltip.PinWindow" = "窗口已置顶"; +"VoiceChat.Tooltip.ScreenPinned" = "已置顶 %@ 的屏幕共享"; +"VoiceChat.Tooltip.ScreenUnpinned" = "%@ 的屏幕共享已取消置顶"; +"VoiceChat.Tooltip.ScreencastFailed" = "发生错误,屏幕共享已停止。"; +"VoiceChat.Tooltip.ShareScreen" = "您正在分享屏幕"; +"VoiceChat.Tooltip.ShareVideo" = "你正在共享视频"; +"VoiceChat.Tooltip.StopScreen" = "您已停止直播您的屏幕画面。"; +"VoiceChat.Tooltip.StopVideo" = "您已停止广播视频。"; +"VoiceChat.Tooltip.Subscribe" = "我们将在开始前通知您。"; +"VoiceChat.Tooltip.UnpinWindow" = "窗口已取消置顶"; +"VoiceChat.Tooltip.VideoFailed" = "发生错误,屏幕共享已停止。"; +"VoiceChat.Tooltip.VideoPinned" = "%@ 的视频已置顶。"; +"VoiceChat.Tooltip.VideoUnpinned" = "%@ 的视频已取消置顶。"; +"VoiceChat.Tooltip.YourScreenPinned" = "您的屏幕共享已置顶"; +"VoiceChat.Tooltip.YourScreenUnpinned" = "您的屏幕共享已取消顶置。"; +"VoiceChat.Tooltip.YourVideoPinned" = "你的视频已置顶。"; +"VoiceChat.Tooltip.YourVideoUnpinned" = "您的视频已取消置顶。"; +"VoiceChat.UnmuteForMe" = "为我取消静音"; +"VoiceChat.UnmutePeer" = "允许发言"; +"VoiceChat.UnpinScreencast" = "取消置顶屏幕共享"; +"VoiceChat.UnpinVideo" = "取消置顶视频"; +"VoiceChat.UserInvited" = "您已邀请 **%@** 加入视频聊天"; +"VoiceChat.Video.Paused" = "视频已暂停"; +"VoiceChat.Video.ShortPin" = "置顶"; +"VoiceChat.Video.ShortUnpin" = "取消顶置"; +"VoiceChat.Video.Stream" = "直播"; +"VoiceChat.Video.Stream.Message" = "消息"; +"VoiceChat.Video.Stream.More" = "更多"; +"VoiceChat.Video.Stream.Screencast" = "屏幕"; +"VoiceChat.Video.Stream.Share" = "分享"; +"VoiceChat.Video.Stream.Video" = "视频"; +"VoiceChat.Video.VideoSource" = "视频资源"; +"VoiceChat.Video.VideoSource.Cancel" = "取消"; +"VoiceChat.Video.VideoSource.Share" = "分享"; +"VoiceChat.VideoShare.Unavailable" = "无法启动你的摄像头,请在[系统设置](camera)里给予Telegram权限"; +"VoiceChat.YouLive" = "你正在说话"; +"VoiecChat.ReduceMotion.Desc" = "您可以减弱动态效果以获得更好的性能。"; +"VoiecChat.ReduceMotion.Header" = "减弱动态效果"; +"VoiecChat.ReduceMotion.Text" = "减弱动态效果"; +"VoiecChat.Settings.Record.Live.Title" = "录制直播流"; +"VoiecChat.Settings.Record.Placeholder" = "音频标题(可选)"; +"VoiecChat.Settings.Record.Placeholder1" = "标题 (可选)"; +"VoiecChat.Settings.Record.Title" = "录制语音聊天"; +"VoiecChat.Settings.Record.Video.Title" = "录制视频聊天"; +"VoiecChat.Settings.ReduceMotion.Desc" = "减弱动态效果并停用噪音抑制可以增强性能。"; +"W48-6f-4Dl.title" = "编辑"; +"Wallpaper.Background.NoApplyToPeer" = "%1$@ 将能够应用此壁纸"; +"WallpaperPreview.Apply" = "应用"; +"WallpaperPreview.Blurred" = "模糊"; +"WallpaperPreview.DoesntExists" = "对不起,该壁纸似乎不存在。"; +"WallpaperPreview.Header" = "壁纸预览"; +"WallpaperPreview.Plain" = "空白"; +"WeT-3V-zwk.title" = "粘贴并匹配样式"; +"WebApp.Access.Allow" = "允许"; +"WebApp.Access.Audio" = "您确定要授予对麦克风的访问权限吗?%@"; +"WebApp.Access.AudioVideo" = "您确定要授予 %@ 存取您的麦克风和相机的权限吗?"; +"WebApp.Access.Deny" = "拒绝"; +"WebApp.Access.Video" = "您确定要授予对相机的访问权限?%@"; +"WebApp.AddToAttachmentAllowMessages" = "允许 %@ 给我发送消息"; +"WebApp.Attach.Confirm" = "“%@” 请求作为选项添加到您的附件菜单,以便您可以从任何聊天中访问它。"; +"WebApp.Attach.Confirm.OK" = "添加"; +"WebApp.Attach.DoenstExist" = "抱歉, %@ 似乎不存在。"; +"WebApp.Attach.NotSupport" = "抱歉, %@ 不能从附件菜单中使用。"; +"WebApp.Attach.Success" = "“%@” 已添加到您的附件菜单中。"; +"WebApp.AttachRemove.Success" = "\"%@\" 已从您的附件菜单中移除。"; +"WebApp.Biometry.Confirm.FaceId" = "你想要允许 %@ 使用触控 ID 吗?"; +"WebApp.Biometry.Confirm.TouchId" = "你想要允许 %@ 使用 Touch ID 吗?"; +"WebApp.Close" = "关闭"; +"WebApp.Confirm.OpenWebapp" = "您确定要在 Telegram 中打开 %@ 吗?"; +"WebApp.ExitFullscreen" = "退出全屏"; +"WebApp.FirstOpen.AllowWrite" = "允许写入"; +"WebApp.FirstOpen.Info" = "%@ 想要打开其网页应用以继续。\n\n它将可以访问到你的 IP 地址以及基本设备信息。"; +"WebApp.FirstOpen.Terms" = "此小程序即表示您同意 [小程序服务条款](%@)。"; +"WebApp.FirstOpen.Title" = "打开应用"; +"WebApp.Fullscreen" = "全屏幕"; +"WebApp.InstallBot" = "安装机器人"; +"WebApp.LocationPermission.Allow" = "允许"; +"WebApp.LocationPermission.Decline" = "拒绝"; +"WebApp.LocationPermission.DeniedError" = "没有权限"; +"WebApp.LocationPermission.DisabledError" = "定位服务不可用"; +"WebApp.LocationPermission.Succeed" = "**%@** 现在可以访问您的位置信息了。"; +"WebApp.LocationPermission.Text" = "**%1$@** 请求访问您的 **位置信息**,您可以随时在 **%2$@** 的资料页面中撤销此权限。"; +"WebApp.LocationPermission.WifiError" = "定位服务需要 Wi-Fi "; +"WebApp.QrIsNotSupported" = "桌面端不支持机器人二维码,请使用 Telegram 移动端应用。"; +"WebApp.Reload" = "重新加载"; +"WebApp.RemoveBot" = "从菜单中移除"; +"WebApp.Settings" = "设置"; +"WebApp.Title" = "网页"; +"WebApp.TransferData.Import" = "导入"; +"WebApp.TransferData.ImportData" = "导入数据"; +"WebApp.TransferData.Transfered" = "数据导入成功"; +"WebAuthorizations.Confirm.Revoke" = "您想要断开与此网站的连接吗?"; +"WebAuthorizations.Confirm.RevokeAll" = "您确定您想要在所有的网站上停止使用您的 Telegram 账户吗?"; +"WebAuthorizations.LoggedIn.Descrpiption" = "已授权网站"; +"WebAuthorizations.Logout" = "断开连接"; +"WebAuthorizations.LogoutAll" = "取消与所有网站的关联"; +"WebAuthorizations.LogoutAll.Description" = "您可以在在支持的网站上使用 Telegram 登录。"; +"WebBot.Account.Desclaimer.Desc" = "**%@** 快捷方式将会添加至附件菜单和设置中。"; +"WebBot.Account.Disclaimer.OK" = "继续"; +"WebBot.Account.Disclaimer.Text" = "您即将使用一个 **不属于 Telegram** 的独立第三方小程序。您必须同意小程序使用条款后才能继续。"; +"WebBot.Account.Disclaimer.Third" = "我同意 [使用条款](https://telegram.org/tos/mini-apps)"; +"WebBot.Account.Disclaimer.Title" = "使用条款"; +"WebBrowser.Error" = "**抱歉,发生错误。**\n请稍后再试。"; +"WebBrowser.InstantView" = "即时浏览"; +"WebBrowser.OpenMainPage" = "打开主页面"; +"Webapp.AllowMessages.OK" = "允许"; +"Webapp.AllowMessages.Text" = "这将允许机器人 %@ 在 Telegram 上给您发送消息。"; +"Webapp.AllowMessages.Title" = "允许发送消息?"; +"Webapp.Document.Download" = "下载文件"; +"Webapp.Document.Download.Document" = "文件已储存\n[在 Finder 中显示]()"; +"Webapp.Document.Download.Image" = "照片已储存\n[在 Finder 中显示]()"; +"Webapp.Document.Download.Info" = "%1$@ 邀请您下载 %2$@。"; +"Webapp.Document.Download.OK" = "下载"; +"Webapp.Document.Download.Video" = "视频已储存\n[在 Finder 中显示]()"; +"Webapp.EmojiStatus.Allowed" = "**%@** 现在可以随时设置您的表情符号状态了。"; +"Webapp.EmojiStatus.NotExists" = "未找到表情状态"; +"Webapp.EmojiStatus.Requested" = "**%1$@** 请求设置 **表情符号状态** 的权限,您可以随时在 **%2$@** 的资料页面中撤销此权限。"; +"Webapp.EmojiStatus.Requested.Allow" = "允许"; +"Webapp.EmojiStatus.Requested.Decline" = "拒绝"; +"Webapp.SetEmoji.Header" = "设置表情符号状态"; +"Webapp.SetEmoji.Info" = "你想要套用 %@ 建议的表情状态吗?"; +"Webapp.ShareMessage.BotInfo" = "%@ 小程序建议您将此消息发送到您所选的聊天。"; +"Webapp.ShareMessage.Preview" = "消息预览"; +"Webapp.ShareMessage.Share" = "分享消息"; +"Webpage.Confirm.Close" = "所作的更改可能不会保存。"; +"Webpage.Confirm.Ok" = "仍然关闭"; +"Weekday.Friday" = "周五"; +"Weekday.Monday" = "周一"; +"Weekday.Saturday" = "周六"; +"Weekday.ShortFriday" = "周五"; +"Weekday.ShortMonday" = "周一"; +"Weekday.ShortSaturday" = "周六"; +"Weekday.ShortSunday" = "周日"; +"Weekday.ShortThursday" = "周四"; +"Weekday.ShortTuesday" = "周二"; +"Weekday.ShortWednesday" = "周三"; +"Weekday.Sunday" = "周日"; +"Weekday.Thursday" = "周四"; +"Weekday.Tuesday" = "周二"; +"Weekday.Wednesday" = "周三"; +"Widged.Stickers.InfoText" = "更多热门贴纸可在\n设置 > 贴纸 > [热门贴纸](热门)。"; +"Widged.Storage.InfoText" = "更多数据和存储设置在\n设置>[数据和存储](存储)。"; +"Widget.Recent.Desc" = "使用 ⌘+K 或 ESC 进入 [搜索](搜索) 模式。"; +"Widget.Recent.Mixed" = "同时满足"; +"Widget.Recent.Popular" = "流行"; +"Widget.Recent.Recent" = "最近"; +"Widget.Recent.Title" = "聊天"; +"You" = "您"; +"Your_card_has_expired" = "您的银行卡已过期。"; +"Your_card_was_declined" = "您的银行卡被拒绝。"; +"Your_cards_expiration_month_is_invalid" = "您输入的过期月份无效。"; +"Your_cards_expiration_year_is_invalid" = "您输入的过期年份无效。"; +"Your_cards_number_is_invalid" = "您输入的卡号无效。"; +"Your_cards_security_code_is_invalid" = "您输入的安全码无效。"; +"Your_cards_zip_code_is_invalid" = "您输入的邮政编码无效。"; +"_NS103.title" = "编辑"; +"_NS167.title" = "窗口"; +"_NS70.title" = "显示"; +"_NS81.title" = "显示"; +"_NS88.title" = "编辑"; +"_NS:103.title" = "编辑"; +"_NS:104.title" = "编辑"; +"_NS:163.title" = "窗口"; +"_NS:167.title" = "窗口"; +"_NS:168.title" = "窗口"; +"_NS:77.title" = "预览"; +"_NS:81.title" = "View"; +"_NS:82.title" = "查看"; +"_NS:99.title" = "编辑"; +"aMa-rb-kjV.title" = "全局搜索"; +"aUF-d1-5bR.title" = "窗口"; +"bullet" = "。"; +"c8a-y6-VQd.title" = "格式设置"; +"cwL-P1-jid.title" = "智能链接"; +"d9M-CD-aMd.title" = "变为小写"; +"dRJ-4n-Yzg.title" = "撤销"; +"gVA-U4-sdL.title" = "粘贴"; +"hQb-2v-fYv.title" = "智能引号"; +"hz2-CU-CR7.title" = "立即检查文稿"; +"mK6-2p-4JG.title" = "检查拼写和语法"; +"pa3-QI-u2k.title" = "删除"; +"peerInfo.Confirm.ClearHistory.Group" = "此操作将会从你的 Telegram 云端中删除此群组中的所有消息和媒体,但不会从其他群成员的记录中删除。"; +"peerInfo.Confirm.ClearHistory.SavedMesssages" = "此操作将从您的 Telegram 云端中,删除此对话中的所有的消息和媒体。"; +"peerInfo.Confirm.ClearHistory.User" = "此操作将会从你的 Telegram 云端中删除此聊天中的所有消息和媒体,但无法从对方的记录中删除。"; +"peerInfo.Confirm.ClearHistory.UserBothSides" = "您确定要删除聊天中的所有消息吗?"; +"peerInfo.Confirm.Leave" = "退出"; +"psa.chat.alert.learnmore" = "了解更多"; +"psa.chat.alert.learnmore.covid" = "了解更多"; +"psa.chat.alert.learnmore.psa" = "了解更多"; +"psa.chat.text.covid" = "此信息为您提供正在发生的新冠肺炎疫情相关公共服务公告。了解有关此计划的更多咨询:https://telegram.org/blog/coronavirus"; +"psa.chat.text.psa" = "这是 Telegram 首席执行官对于目前冲突的正式声明。"; +"psa.chat.title" = "公共服务通知"; +"psa.chatlist" = "公共服务通告"; +"psa.chatlist.covid" = "新冠病毒肺炎"; +"psa.chatlist.psa" = "公告"; +"psa.text" = "此信息为您在列表中提供公共服务公告"; +"psa.text.covid" = "此信息为您提供正在发生的新冠肺炎疫情相关公共服务公告。了解有关此计划的更多咨询:https://telegram.org/blog/coronavirus"; +"psa.text.psa" = "这是 Telegram 首席执行官对于目前冲突的正式声明。"; +"psa.title" = "公共服务通知"; +"psa.title.bubbles" = "公共服务公告通知\n来自:[%@]()"; +"psa.title.bubbles.covid" = "新冠病毒肺炎通知\n来自:%@"; +"psa.title.bubbles.psa" = "公告:[%@]()"; +"psa.title.bubbles_new" = "PSA 通知\n来自: %@"; +"psa.title.covid" = "新冠病毒肺炎通知"; +"psa.title.psa" = "公告"; +"rbD-Rh-wIN.title" = "键入时检查拼写"; +"rgM-f4-ycn.title" = "智能破折号"; +"sZh-ct-GQS.title" = "快速搜索"; +"tRr-pd-1PS.title" = "数据检测器"; +"uQy-DD-JDr.title" = "Telegram"; +"uRl-iY-unG.title" = "剪切"; +"vmV-6d-7jI.title" = "变为大写"; +"x3v-GG-iWU.title" = "拷贝"; +"z6F-FW-3nz.title" = "显示替换"; diff --git a/data/langpack/weba/weba_en_v12000001.strings b/data/langpack/weba/weba_en_v12000001.strings new file mode 100644 index 00000000..7f49fe87 --- /dev/null +++ b/data/langpack/weba/weba_en_v12000001.strings @@ -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"; diff --git a/data/langpack/weba/weba_zh-hans_v263556.strings b/data/langpack/weba/weba_zh-hans_v263556.strings new file mode 100644 index 00000000..4653c780 --- /dev/null +++ b/data/langpack/weba/weba_zh-hans_v263556.strings @@ -0,0 +1,1391 @@ +"ALongTimeAgo" = "很久没上线"; +"AboutPremiumDescription" = "Telegram 的免费版本已经为用户提供了远超其它聊天软件的便利,而 **Telegram 高级版**将给您带来更多功能。"; +"AboutPremiumDescription2" = "**Telegram 高级版** 需要付费,因为 Telegram 需要为大多数会员特性付出更多开销,如数据中心和服务器提供商。**Telegram 高级版** 用户能帮助我们支付这些开销,进而保持对所有人免费。"; +"AccActionDownload" = "下载"; +"AccActionPlay" = "播放"; +"AccDescrChannel" = "频道"; +"AccDescrGoBack" = "返回"; +"AccDescrGroup" = "群组"; +"AccDescrMentionDown" = "下一步"; +"AccDescrMoreOptions" = "更多选项"; +"AccDescrPageDown" = "转到底部"; +"AccDescrPrevious" = "上一个"; +"AccDescrReactionMentionDown" = "前往下一个未读表情回应"; +"AccDescrStickerSet" = "贴纸设置"; +"AccDescrStickers" = "贴纸"; +"AccDescrVoiceMessage" = "录制语音消息"; +"AccExitFullscreen" = "退出全屏"; +"AccSwitchToFullscreen" = "切换到全屏"; +"AccountSettingsLogout" = "退出"; +"ActionChangeCreator" = "{from} 已将此群组的所有权转让给 {user}。"; +"ActionGiftPremiumText" = "订阅 Telegram 专属功能。"; +"ActionGiftTextCost" = "{from} 已送出以 {cost} 价格的礼物"; +"ActionGiftTextCostAnonymous" = "有人已送出以 {cost} 价格的礼物"; +"ActionGiftTextCostYou" = "你已送出以 {cost} 价格的礼物"; +"ActionGiftUniqueSent" = "您发送了一件独特的典藏品"; +"ActionGroupCallFinishedChannel" = "直播已终止 ({duration})"; +"ActionGroupCallFinishedGroup" = "{from} 已结束语音聊天({duration})"; +"ActionGroupCallStartedChannel" = "直播已开始"; +"ActionOpenGiftButton" = "打开礼物"; +"ActionPaymentRefunded" = "{peer} 已退款 {amount}"; +"ActionPinnedMediaGif" = "一张 GIF"; +"ActionPinnedMediaGiveaway" = "抽奖活动"; +"ActionPinnedMediaGiveawayResults" = "抽奖活动结果"; +"ActionPinnedMediaLocation" = "一个位置信息"; +"ActionStarGiftCrafted" = "已制作的礼物"; +"ActionStarGiftDisplaying" = "您已将这份礼物添加至您的个人资料页。"; +"ActionStarGiftLimitedRibbon" = "1 / {total}"; +"ActionStarGiftSelf" = "已保存的礼物"; +"ActionStarGiftSent" = "你已发送以 {cost} 价格的礼物"; +"ActionStarGiftTo" = "送给 {peer}"; +"ActionStarGiftTransferredSelf" = "你转让了一件独家典藏品"; +"ActionStarGiftUniqueRibbon" = "礼物"; +"ActionStarGiftUpgradeText" = "{peer} 可以将此礼物转换为独家典藏品。"; +"ActionStarGiftUpgraded" = "此礼物已升级。"; +"ActionSuggestedVideoTitle" = "推荐的视频"; +"ActionTopicPlaceholder" = "话题"; +"ActionUserLeft" = "{from} 已退出群组"; +"ActionUserLeftYou" = "您已退出此群组"; +"Add" = "添加"; +"AddContact" = "添加联系人"; +"AddContactSharedContactExceptionInfo" = "你可以令你的电话号码对 {user} 可见。"; +"AddPhoto" = "设置头像"; +"AddToFavorites" = "添加到收藏"; +"AllGiftsCategory" = "所有礼物"; +"AlwaysAllow" = "总是允许"; +"AlwaysAllowPlaceholder" = "总是允许…"; +"AlwaysShareWith" = "总是分享给"; +"AnonymousPoll" = "匿名投票"; +"AnonymousQuizPoll" = "匿名问答"; +"Answer#other" = "{count} 条答案"; +"ApproveNewMembers" = "自动批准新成员"; +"ApproveNewMembersDescription" = "打开该选后,新成员加入前需要获得管理员的批准。"; +"Apps" = "应用"; +"Archive" = "归档"; +"ArchiveAndMute" = "归档及静音"; +"ArchiveAndMuteInfo" = "自动归档及静音非联系人的新聊天、群组和频道。"; +"ArchivedChats" = "已归档对话"; +"AreYouSureCloseMiniApps" = "您确定要关闭所有小程序吗?"; +"AreYouSureDeleteAndExit" = "您确定要删除并退出此群组吗?"; +"AreYouSureDeleteContact" = "您确定要删除此联系人吗?"; +"AreYouSureDeleteFewMessages" = "您确定要清空这些消息吗?"; +"AreYouSureDeleteSingleMessage" = "你确认是否删除这条消息?"; +"AreYouSureDeleteThisChatWithGroup" = "你确定要删除聊天 **{chat}** 吗?"; +"AreYouSureSessions" = "您确定要注销所有其它设备上的会话吗?"; +"AreYouSureShareMyContactInfoBot" = "机器人将会获取您的手机号码。这可能对机器人与其它服务的集成有用。"; +"AreYouSureWebSessions" = "是否取消所有网站的授权?"; +"AriaAddAttachment" = "添加附件"; +"AriaBackToChatList" = "返回至聊天列表"; +"AriaCancelPollCreation" = "取消创建投票"; +"AriaCloseAttachmentModal" = "丢弃附件"; +"AriaCodeWordWrap" = "单词换行"; +"AriaComposerCancelVoice" = "取消录音"; +"AriaComposerOpenScheduled" = "开启定时消息"; +"AriaCreateGroup" = "创建群组"; +"AriaEditProfilePhoto" = "编辑头像"; +"AriaExitMessageSelection" = "退出选择模式"; +"AriaLabelSearchGifs" = "搜索 GIF"; +"AriaLabelSearchStickers" = "搜索贴纸包"; +"AriaMenuDisableNightMode" = "禁用夜间模式"; +"AriaMenuEnableNightMode" = "启用夜间模式"; +"AriaMoreButton" = "更多操作"; +"AriaNewChannelContinue" = "继续编辑群组信息"; +"AriaNewGroupContinue" = "继续编辑群组信息"; +"AriaOpenBotMenu" = "打开机器人菜单"; +"AriaOpenSymbolMenu" = "请选择表情、贴纸或 GIF 动态图"; +"AriaPasswordToggle" = "切换密码可见性"; +"AriaSearchByDate" = "按照日期查找"; +"AriaSearchInChat" = "在聊天中搜索"; +"AskAQuestion" = "向我们提问"; +"AttachAudio" = "语音消息"; +"AttachContact" = "联系人"; +"AttachDocument" = "文件"; +"AttachGame" = "游戏"; +"AttachGif" = "GIF "; +"AttachLiveLocation" = "实时位置信息"; +"AttachMenuMediaBlocked" = "此群中不允许发布媒体内容。"; +"AttachMusic" = "音乐"; +"AttachPhoto" = "图片"; +"AttachSticker" = "表情"; +"AttachVideo" = "视频"; +"AttachmentMenuPhotoOrVideo" = "图片或视频"; +"AudioPause" = "暂停音频"; +"AudioPlay" = "播放音频"; +"AudioPlayerClose" = "关闭"; +"AudioPlayerNext" = "下一首"; +"AudioPlayerPrevious" = "上一首"; +"AuthSessionsCurrentSession" = "此设备"; +"AuthSessionsLogOut" = "退出"; +"AuthSessionsLogOutApplications" = "取消所有网站的授权"; +"AuthSessionsViewAcceptTitle" = "该设备允许"; +"AuthSessionsViewBrowser" = "浏览器"; +"AuthSessionsViewLocationInfo" = "此地点是根据 IP 地址大致定位的,它并不总是精确。"; +"AutoDeleteConfirm" = "确认"; +"AutoDownloadFilesTitle" = "自动下载文件"; +"AutoDownloadMaxFileSize" = "文件大小限制"; +"AutoDownloadPhotosTitle" = "自动下载图片"; +"AutoDownloadSettingsChannels" = "频道"; +"AutoDownloadSettingsContacts" = "联系人"; +"AutoDownloadSettingsGroupChats" = "群聊"; +"AutoDownloadSettingsPrivateChats" = "其它私聊"; +"AutoDownloadVideosTitle" = "自动下载视频和 GIF 动态图 "; +"AutodownloadGroupChats" = "群聊"; +"AutodownloadPrivateChats" = "私聊"; +"AutodownloadSizeLimitUpTo" = "上限为 {limit}"; +"AutoplayMedia" = "自动播放媒体"; +"AvatarChangeTitle" = "修改你的个人资料图片"; +"Back" = "返回"; +"BackgroundBlurred" = "模糊"; +"BioLimitSubtitle" = "在个人介绍中添加更多文字,使用链接。"; +"BioLimitTitle" = "个人简介"; +"Block" = "屏蔽"; +"BlockContact" = "屏蔽"; +"BlockUserTitle" = "屏蔽 {user}"; +"BlockedListNotFound" = "未找到符合条件的用户。"; +"BlockedUsers" = "已拉黑用户"; +"BlockedUsersBlockUser" = "拉黑此用户"; +"Bot" = "机器人"; +"BotDownloadFileButton" = "下载"; +"BotDownloadFileDescription" = "**{bot}** 邀请您下载 **{filename}**"; +"BotDownloadFileTitle" = "下载文件"; +"BotHelp" = "帮助"; +"BotInfoImageAlt" = "机器人信息"; +"BotInfoTitle" = "它能做什么?"; +"BotOpenPageMessage" = "**{bot}** 想要打开其网页应用以继续。\n\n它将能够访问您的 **IP 地址** 和基本设备信息。"; +"BotOpenPageTitle" = "打开页面"; +"BotPermissionGameAlert" = "是否允许 {bot} 传送您的 Telegram 的名称及 ID(并非您的手机号码)到通过机器人打开的网页中呢?"; +"BotRestart" = "重新启用机器人"; +"BotSettings" = "设置"; +"BotStart" = "开始"; +"BotSuggestedStatus" = "你想要套用 **{bot}** 建议的表情状态吗?"; +"BotSuggestedStatusFor" = "是否要按照 **{bot}** 的建议设置此表情符号状态,持续 **{duration}**?"; +"BotSuggestedStatusTitle" = "设置表情符号状态"; +"BotSuggestedStatusUpdated" = "表情符号状态已更新。"; +"BotWebViewOpenBot" = "打开机器人"; +"BoxRemove" = "移除"; +"ButtonSensitiveAlways" = "总是显示 {years}+ 媒体"; +"ButtonUnderstood" = "了解"; +"ButtonUndo" = "撤消"; +"Call" = "通话"; +"CallAccept" = "接听"; +"CallAgain" = "再次呼叫"; +"CallBack" = "回拨"; +"CallDecline" = "挂断"; +"CallEmojiKeyTooltip" = "如果 {user} 屏幕上的表情符号和此处一致,本次通话就是 100% 安全的。"; +"CallEndCall" = "结束通话"; +"CallMessageIncoming" = "来电"; +"CallMessageIncomingDeclined" = "拒接"; +"CallMessageIncomingMissed" = "未接"; +"CallMessageOutgoing" = "拨出"; +"CallMessageOutgoingMissed" = "通话已取消"; +"CallMessageVideoIncoming" = "视频来电"; +"CallMessageVideoIncomingDeclined" = "拒绝视频通话"; +"CallMessageVideoIncomingMissed" = "未接视频来电"; +"CallMessageVideoOutgoing" = "拨出视频通话"; +"CallMessageVideoOutgoingMissed" = "已取消的视频来电"; +"CallMessageWithDuration" = "{time}({duration})"; +"CallMuteAudio" = "静音"; +"CallRateComment" = "评价(选填)"; +"CallRateLabel" = "请对您这次通话的质量作出评价 "; +"CallScreencast" = "屏幕共享"; +"CallStartVideo" = "开启摄像头"; +"CallStatusExchanging" = "交换加密密钥中…"; +"CallStatusHanging" = "挂断中…"; +"CallStatusIncoming" = "正在呼叫你…"; +"CallStatusRequesting" = "请求中…"; +"CallStatusRinging" = "拨号中…"; +"CallStatusWaiting" = "等待中…"; +"CallStopVideo" = "关闭摄像头"; +"CallUnmuteAudio" = "取消静音"; +"CanJoin#other" = "可加入 {count} 人"; +"Cancel" = "取消"; +"Caption" = "说明"; +"CaptionsLimitSubtitle" = "在图片和视频下使用更长的说明文字"; +"CaptionsLimitTitle" = "说明文字"; +"ChangePassword" = "更改密码"; +"ChangeRecipient" = "修改收件人"; +"ChannelAddException" = "添加例外"; +"ChannelAddSubscribers" = "添加订阅者"; +"ChannelAddToChannel" = "添加到频道"; +"ChannelAddToGroup" = "添加到群组"; +"ChannelAddUsers" = "添加用户"; +"ChannelAdmin" = "管理员"; +"ChannelAdminDismiss" = "解任管理员"; +"ChannelAdministrators" = "管理员"; +"ChannelBlockUser" = "封禁用户"; +"ChannelBlockedUsers" = "被移除的用户"; +"ChannelCreator" = "所有者"; +"ChannelDelete" = "删除频道"; +"ChannelDeleteAlert" = "且慢!删除此频道将会移除所有关注者并且会丢失所有消息。确定要删除此频道?"; +"ChannelDiscussionGroupLinkGroup" = "关联群组"; +"ChannelEditAdminCannotEdit" = "您无法修改此管理员的权限。"; +"ChannelEditAdminPermissionBanUsers" = "封禁用户"; +"ChannelInaccessible" = "频道无法访问"; +"ChannelIntroCreateChannel" = "创建频道"; +"ChannelJoin" = "加入"; +"ChannelJoinRequest" = "申请加入聊天组"; +"ChannelLeaveAlert" = "您确定要退出此频道吗?"; +"ChannelLeaveAlertWithName" = "您确定要退出 **{chat}** 吗?"; +"ChannelManagementAddAdminDescription" = "您可以添加管理员帮助您管理频道。"; +"ChannelManagementAddModerator" = "添加管理员"; +"ChannelManagementLinkDiscussion" = "您确定要将 {group} 设为 {channel} 的讨论群吗?"; +"ChannelManagementLinkPrivate" = "此频道中的任何人都将可以查看该群组中的消息。"; +"ChannelManagementTitle" = "管理员"; +"ChannelPermissionDeniedSendMessagesUntil" = "该群组的管理员限制了您在 {date} 之前发送消息。"; +"ChannelPermissions" = "权限"; +"ChannelPermissionsHeader" = "本群组成员有何权限?"; +"ChannelPersmissionDeniedSendMessagesDefaultRestrictedText" = "此群组不允许发送消息。"; +"ChannelPersmissionDeniedSendMessagesForever" = "该群组的管理员限制了您在此发送消息。"; +"ChannelPrivate" = "私密频道"; +"ChannelPrivateInfo" = "私密频道只能通过邀请链接关注。"; +"ChannelPrivateLinkHelp" = "任何人都可以通过此链接关注您的频道。您可以随时废除此链接。"; +"ChannelPublic" = "公开频道"; +"ChannelPublicInfo" = "公开频道可以被搜索到,且任何人都可以关注。"; +"ChannelSettingsJoinRequest" = "新成员审核"; +"ChannelSettingsJoinRequestInfo" = "启用此选项后,新成员加入前需得到管理员的批准。"; +"ChannelSettingsJoinTitle" = "谁可以发送消息?"; +"ChannelSettingsJoinToSend" = "仅成员"; +"ChannelSettingsJoinToSendInfo" = "如果您希望用户在能够发送信息之前加入您的群组,请开启此功能。"; +"ChannelSignMessages" = "消息署名"; +"ChannelStatsGraphFollowers" = "订阅者"; +"ChannelStatsGraphGrowth" = "增长"; +"ChannelStatsGraphInteractions" = "互动"; +"ChannelStatsGraphLanguage" = "语言"; +"ChannelStatsGraphNewFollowersBySource" = "新订阅者的来源"; +"ChannelStatsGraphNotifications" = "通知"; +"ChannelStatsGraphViewsByHours" = "每小时浏览次数 (UTC)"; +"ChannelStatsGraphViewsBySource" = "浏览来源"; +"ChannelStatsOverviewEnabledNotifications" = "启用通知"; +"ChannelStatsOverviewFollowers" = "订阅者"; +"ChannelStatsOverviewSharesPerPost" = "每则贴文的分享次数"; +"ChannelStatsOverviewViewsPerPost" = "每则贴文的查看次数"; +"ChannelStatsRecentHeader" = "近期贴文"; +"ChannelStatsSharesCount#other" = "{count} 次分享"; +"ChannelStatsTitle" = "频道统计"; +"ChannelStatsViewsCount#other" = "{count} 次浏览"; +"ChannelSubscribers" = "订阅者"; +"ChannelType" = "频道类型"; +"ChannelTypeHeader" = "频道类型"; +"ChannelUsernameCreatePublicLinkHelp" = "所有人都可以与其他人分享此链接,并使用 Telegram 的搜索功能找到您的频道。"; +"ChannelVisibilityForwardingChannelInfo" = "订阅者将可以拷贝、保存及转发该频道的内容。"; +"ChannelVisibilityForwardingChannelTitle" = "从此频道转发"; +"ChannelVisibilityForwardingDisabled" = "拒绝转发"; +"ChannelVisibilityForwardingEnabled" = "允许转发"; +"ChannelVisibilityForwardingGroupInfo" = "成员将可以拷贝、保存及转发此群组中的内容。"; +"ChannelVisibilityForwardingGroupTitle" = "从此群组转发"; +"ChatBackground" = "聊天壁纸"; +"ChatConfirmReportSpamChannel" = "举报骚扰行为并退出频道"; +"ChatContextReactionCount#other" = "{count} 个表情回应"; +"ChatEmptyChat" = "暂无消息"; +"ChatForwardActionHeader" = "转发消息"; +"ChatHistory" = "历史记录对新成员的可见性"; +"ChatListContextAddToFolder" = "添加到文件夹里"; +"ChatListDeleteAndLeaveGroupConfirmation" = "您确定要删除 {chat} 并退出吗?"; +"ChatListDeleteChatConfirmation" = "是否删除与 {chat} 的聊天?"; +"ChatListDeleteForCurrentUser" = "仅在我的设备中删除"; +"ChatListDeleteForEveryone" = "为我和 {user} 删除"; +"ChatListEmptyChatListEditFilter" = "编辑文件夹"; +"ChatListEmptyChatListFilterText" = "该文件夹内暂无聊天内容"; +"ChatListFilterAddToFolder" = "添加至聊天文件夹..."; +"ChatListFilterErrorEmpty" = "请添加部分聊天或聊天类型。"; +"ChatListFilterErrorTitleEmpty" = "请为该文件夹输入标题。"; +"ChatListMarkAllAsRead" = "全部标记为已读"; +"ChatListOpenInNewWindow" = "在新窗口中打开"; +"ChatListSearchNoResults" = "无结果"; +"ChatListSearchNoResultsDescription" = "找不到结果,请重试"; +"ChatListSearchShowLess" = "显示更少"; +"ChatListSearchShowMore" = "显示更多"; +"ChatMemberListNoAccess" = "您无权访问群组成员列表"; +"ChatOutgoingContextMixedReactionCount" = "{count}/{total} 人已回应"; +"ChatPanelUnpinAllMessages" = "取消所有置顶"; +"ChatPerFolderLimitSubtitle" = "在您的每个文件夹中最多添加 {count} 个聊天 "; +"ChatPerFolderLimitTitle" = "文件夹内聊天数量"; +"ChatPermissionNotAvailable" = "公开群组不具有此权限。"; +"ChatPinnedUnpinAll#other" = "取消置顶 {count} 条消息"; +"ChatPollTotalVotesResultEmpty" = "无人投票"; +"ChatQuizTotalVotesEmpty" = "暂无回答"; +"ChatUnpinAllMessagesConfirmation#other" = "您想要取消置顶聊天中的 {count} 条消息吗?"; +"Chats" = "对话"; +"ChatsMute" = "静音"; +"ChatsUnmute" = "开启通知"; +"CheckoutNewCardCardholderNamePlaceholder" = "持卡人姓名"; +"CheckoutPayPrice" = "支付 {amount}"; +"CheckoutTotalAmount" = "总计"; +"CheckoutWebConfirmationTitle" = "支付完成"; +"ClearOtherWebSessionsHelp" = "您可以在支持的网站上使用 Telegram 登录。"; +"ClearRecentEmoji" = "清空最近使用的 emoji 列表?"; +"Close" = "关闭"; +"CloseMiniApps" = "关闭小程序"; +"Code" = "验证码"; +"CollectibleStatusesCategory" = "典藏品"; +"Comments#other" = "{count} 条评论"; +"CommentsCount#other" = "{count} 条评论"; +"CommonBack" = "返回"; +"CommonClose" = "关闭"; +"CommonSelect" = "选择"; +"ComposerPlaceholderAnonymous" = "匿名发送"; +"ComposerPlaceholderBotTopicGeneral" = "非话题消息"; +"ComposerSubtitleFrozenAccount" = "点击查看详细信息"; +"ConfirmDialogRemoveFeeRefundStars" = "退还已支付的 **{amount}**"; +"ConfirmRemoveMessageFee" = "确认"; +"ContactJoined" = "联系人已加入 Telegram"; +"ContactListEmpty" = "联系人列表为空"; +"ContactPhone" = "手机号码"; +"Contacts" = "联系人"; +"ContactsPhoneNumberNotRegistred" = "此电话号码尚未在Telegram上注册。"; +"ContextArchiveToMenu" = "移动到主菜单"; +"ContextCancelDownload" = "取消下载"; +"ContextCopyImage" = "复制图片"; +"ContextCopyMessageLink" = "复制消息链接"; +"ContextCopySelected" = "复制所选文本"; +"ContextCopySelectedItems" = "复制为纯文本"; +"ContextCopyText" = "复制文本"; +"ContextForwardMsg" = "转发消息"; +"ContextRemoveFromGroup" = "从群组中移除"; +"ContextReportMsg" = "举报消息"; +"ContextSaveGif" = "保存 GIF "; +"Continue" = "继续"; +"ConversationCloudStorageInfoTitle" = "您的云存储"; +"ConversationClousStorageInfoDescription1" = "• 转发消息到此以保存它们"; +"ConversationClousStorageInfoDescription2" = "• 发送媒体/文件以存储它们"; +"ConversationClousStorageInfoDescription3" = "• 在任意设备上访问此对话"; +"ConversationClousStorageInfoDescription4" = "• 使用搜索以更快地查找"; +"ConversationContextMenuNoViews" = "无人已读"; +"ConversationContextMenuSeen#other" = "{count} 人已读"; +"ConversationDefaultRestrictedMedia" = "该群组不允许发送媒体文件。"; +"ConversationDeleteManyMessages" = "删除消息"; +"ConversationDeleteMessagesFor" = "为我和 {user} 删除"; +"ConversationDeleteMessagesForEveryone" = "为所有人删除"; +"ConversationEmptyPlaceholder" = "暂无消息…"; +"ConversationForwardOptionsHideCaption" = "隐藏说明"; +"ConversationForwardOptionsShowCaption" = "显示说明"; +"ConversationGreetingText" = "发送信息或点击下方的问候语。"; +"ConversationPinMessageAlertPinAndNotifyMembers" = "置顶并提醒所有人"; +"ConversationPinMessagesFor" = "为我和 {user} 置顶"; +"ConversationReportMessages" = "举报消息"; +"ConversationScheduleMessageSendOn" = "在 {date} {time} 发送"; +"ConversationScheduleMessageSendToday" = "在今天 {time} 发送"; +"ConversationViewBot" = "查看机器人"; +"ConversationViewChannel" = "查看频道"; +"ConversationViewPost" = "查看帖子"; +"Copy" = "复制"; +"CopyLink" = "复制链接"; +"CountryNone" = "此国际电话区号不存在"; +"Create" = "创建"; +"CreateLink" = "创建链接"; +"CreateNewContact" = "新建联系人信息"; +"CreateNewFilter" = "创建新文件夹"; +"CreateNewFilterInfo" = "为不同聊天类型创建资料夹以便快速切换。"; +"CreateNewLink" = "新建链接"; +"CreatePermanentLinkTitle" = "永久链接"; +"CreatePollAddOption" = "添加一个选项"; +"CreatePollExplanationInfo" = "选错的用户将看到这段注释,适合教学用途。"; +"CropImage" = "裁剪图片"; +"CurrentPasswordPlaceholder" = "当前密码"; +"DataAndStorageAutoplayVideos" = "视频"; +"DataSettings" = "数据和存储"; +"Days#other" = "{count} 天"; +"Delete" = "删除"; +"DeleteAll" = "删除所有"; +"DeleteAllRevokedLinkHelp" = "此操作将删除所有已撤销的链接。"; +"DeleteAllRevokedLinks" = "删除所有已废除的链接"; +"DeleteAndStop" = "删除并屏蔽"; +"DeleteContact" = "删除联系人信息"; +"DeleteForAll" = "为所有成员删除"; +"DeleteForEveryoneDescription" = "这将为此聊天的所有人删除这些消息"; +"DeleteForEveryoneHint#other" = "这将从所有人的消息记录中撤回这些消息。"; +"DeleteForMeChatHint#other" = "这些消息将只能从您(不包括其他成员)的消息记录中删除。"; +"DeleteForMeDescription" = "这仅会为您删除这些消息,其他参与者的聊天记录不受影响"; +"DeleteFromRecent" = "从最近使用文档列表中删除"; +"DeleteGroupForAll" = "为所有用户删除该群组"; +"DeleteLink" = "删除链接"; +"DeleteLinkHelp" = "这将会删除链接。"; +"DeleteMega" = "解散群组"; +"DeleteSingleMessagesTitle" = "删除消息"; +"DeleteThisChat" = "删除此对话"; +"DescriptionInfo" = "您可以给您的频道添加一段简介。"; +"DescriptionOptionalPlaceholder" = "简介(可选)"; +"DescriptionPlaceholder" = "简介"; +"DialogListSearchSectionDialogs" = "对话和联系人"; +"DialogListSearchSectionGlobal" = "全局搜索"; +"DialogPin" = "置顶"; +"DialogUnpin" = "取消置顶"; +"DiscussChannel" = "频道"; +"Discussion" = "讨论"; +"DiscussionChannelHelp" = "选择一个用于讨论的群组,它将显示在您的频道中。"; +"DiscussionChannelHelp2" = "频道内发布的消息将会被转发到与该频道关联的群组"; +"DiscussionCreateGroup" = "创建新群组"; +"DiscussionLinkGroup" = "关联群组"; +"DiscussionStarted" = "讨论已开始"; +"DiscussionUnlink" = "取消关联"; +"DiscussionUnlinkChannel" = "退出频道"; +"DiscussionUnlinkChannelAlert" = "您确定要从这个频道取消 **{chat}** 的链接吗?"; +"DiscussionUnlinkGroup" = "退出该群"; +"DiscussionUnlinkGroupAlert" = "您确定要从这个群组中取消 **{channel}** 的链接吗?"; +"DismissRequest" = "拒绝请求"; +"DlgSearchForMessages" = "搜索消息"; +"DoNotAskAgain" = "不再询问"; +"Done" = "完成"; +"DoubleTapSetting" = "快速回应"; +"Draft" = "草稿"; +"DragToSortAria" = "拖动排序"; +"Edit" = "编辑"; +"EditAdminAddAdmins" = "添加新的管理员"; +"EditAdminAddUsers" = "添加用户"; +"EditAdminBanUsers" = "封禁用户"; +"EditAdminChangeChannelInfo" = "修改频道信息"; +"EditAdminChangeGroupInfo" = "修改群组信息"; +"EditAdminConfirmDismiss" = "解任管理员"; +"EditAdminDeleteMessages" = "删除其他用户的消息"; +"EditAdminEditMessages" = "编辑其他用户的消息"; +"EditAdminGroupDeleteMessages" = "删除消息"; +"EditAdminPinMessages" = "置顶消息"; +"EditAdminPostMessages" = "发布消息"; +"EditAdminPromotedBy" = "由 {user} 设为管理员"; +"EditAdminRank" = "自定义头衔"; +"EditAdminRemoveAdmin" = "解任管理员"; +"EditAdminRights" = "修改管理员权限"; +"EditAdminSendAnonymously" = "保持匿名"; +"EditAdminTransferChangeOwner" = "更改所有者"; +"EditAdminTransferChannelOwnership" = "转让频道所有权"; +"EditAdminTransferChannelOwnershipSuccess" = "{user} 现在是该频道的所有者。"; +"EditAdminTransferGroupOwnership" = "转让群组所有权"; +"EditAdminTransferGroupOwnershipSuccess" = "{user} 现在是该群组的所有者。"; +"EditAdminTransferSetPassword" = "设置密码"; +"EditAdminWhatCanDo" = "这位管理员可以做什么?"; +"EditLink" = "编辑链接"; +"EditMessageAriaLabel" = "保存已编辑的消息"; +"EditProfileNoFirstName" = "请输入姓氏"; +"EditedDate" = "编辑时间:{date}"; +"EditedMessage" = "已编辑"; +"Emoji1" = "笑脸与人像"; +"Emoji2" = "动物与自然"; +"Emoji3" = "食物与饮料"; +"Emoji4" = "活动"; +"Emoji5" = "旅行与地点"; +"Emoji6" = "物品"; +"Emoji7" = "符号"; +"Emoji8" = "旗帜"; +"EmojiStatusAccessText" = "**{name}** 请求设置 **表情符号状态** 的权限,您可以随时在 **{name}** 的资料页面中撤销此权限。"; +"EmptyChatAppearanceDark" = "夜间"; +"EmptyChatAppearanceLight" = "日间"; +"EmptyChatAppearanceSystem" = "跟随系统"; +"EmptyGroupInfoLine1" = "成员上限为 {count} 人"; +"EmptyGroupInfoLine2" = "• 永久聊天记录"; +"EmptyGroupInfoLine3" = "• 公开链接,例如:t.me/title"; +"EmptyGroupInfoLine4" = "• 具备不同权限的管理员"; +"EmptyGroupInfoSubtitle" = "群组可以拥有:"; +"EmptyGroupInfoTitle" = "您已创建了一个群组"; +"EnablePasscode" = "启用锁定码"; +"EnableReactions" = "启用表情回应"; +"EnterChannelName" = "频道名称"; +"EnterNewPasscode" = "请输入新的锁定码"; +"ErrorSendRestrictedStickersAll" = "抱歉,该群组不允许发送贴纸。"; +"EventLog" = "近期操作"; +"EventLogAllAdmins" = "所有管理员"; +"EventLogAllEvents" = "所有操作"; +"EventLogFilterChannelInfo" = "频道信息"; +"EventLogFilterDeletedMessages" = "已删除消息"; +"EventLogFilterEditedMessages" = "已编辑消息"; +"EventLogFilterLeavingMembers" = "离开的成员"; +"EventLogFilterNewAdmins" = "新任管理员"; +"EventLogFilterNewMembers" = "新成员"; +"EventLogFilterPinnedMessages" = "已置顶消息"; +"EventLogInfoDetail" = "此列表包含了在过去 48 小时内群组成员/管理员执行的所有操作。"; +"EventLogInfoDetailChannel" = "此列表包含了在过去 48 小时内频道管理员执行的所有操作。"; +"ExpiredLink" = "失效的链接"; +"FakeMessage" = "假冒"; +"FavoriteStickers" = "收藏夹"; +"FavoriteStickersLimitSubtitle" = "在贴纸收藏中存储高达 {count} 张贴纸"; +"FavoriteStickersLimitTitle" = "贴纸收藏"; +"FileDropZoneNoCompression" = "无压缩"; +"FileDropZoneQuick" = "快速发送"; +"FileDropZoneTitle" = "将文件拖放到此处以发送"; +"FileTransferProgress" = "{currentSize} / {totalSize}"; +"FilterAddChats" = "添加对话"; +"FilterAddTo" = "添加至文件夹..."; +"FilterAllChats" = "所有聊天"; +"FilterAllChatsShort" = "所有"; +"FilterArchived" = "已归档"; +"FilterBots" = "机器人"; +"FilterChannels" = "频道"; +"FilterChatTypes" = "聊天类型"; +"FilterChats" = "聊天"; +"FilterContacts" = "联系人"; +"FilterDeleteAlert" = "您确认删除此文件夹吗?您的聊天记录将不会被删除。"; +"FilterExclude" = "排除的聊天"; +"FilterGroups" = "群组"; +"FilterInclude" = "包含的聊天"; +"FilterIncludeInfo" = "\n选择将出现在此文件夹的聊天和聊天类型。"; +"FilterMuted" = "静音"; +"FilterNameHint" = "文件夹名称"; +"FilterNew" = "新文件夹"; +"FilterNoChatsToDisplay" = "文件夹为空"; +"FilterNonContacts" = "非联系人"; +"FilterRead" = "已读"; +"FilterRecommended" = "推荐的文件夹"; +"Filters" = "聊天文件夹"; +"FinalResults" = "最终结果"; +"FirstName" = "名字(必填)"; +"FloodWait" = "尝试次数过多,请稍后再试"; +"FocusMessage" = "焦点信息"; +"FolderLinkAddFolder" = "添加文件夹"; +"FolderLinkNotificationDeletedSubtitle#other" = "您还退出了 {count} 个聊天。"; +"FoldersAllChatsDesc" = "所有未归档聊天"; +"FoldersLimitSubtitle" = "将您的聊天整理到 {count} 个文件夹中"; +"FoldersLimitTitle" = "文件夹"; +"FormatDateAtTime" = "{date} {time}"; +"FormattingAddDateAria" = "添加日期"; +"FormattingAddLinkAria" = "添加链接"; +"FormattingBoldAria" = "粗体"; +"FormattingEnterUrl" = "输入 URL..."; +"FormattingItalicAria" = "斜体"; +"FormattingMonospaceAria" = "等宽"; +"FormattingSpoilerAria" = "遮罩"; +"FormattingStrikethroughAria" = "删除线"; +"FormattingUnderlineAria" = "下划线"; +"Forward" = "转发"; +"ForwardPaidChatsPayConfirmation#other" = "您愿意支付 **{totalAmount}** 颗星星以发送 **{count}** 吗?"; +"ForwardTo" = "转发到…"; +"ForwardedDate" = "原消息: {date}"; +"ForwardedMessage" = "转发的消息"; +"FromYou" = "您"; +"FrozenAccountAppealSubtitle" = "在 {date} 之前通过 {botLink} 申诉,否则您的账户将被删除。"; +"FrozenAccountViolationSubtitle" = "\u200b您的账号因违反 Telegram 的服务条款而被冻结。"; +"General" = "通用"; +"GeneralConfirm" = "确认"; +"GeneralError" = "发生了错误"; +"GifPickerBlocked" = "本群不允许发送 GIF 动态图"; +"GifPickerEmpty" = "暂无 GIF 动态图"; +"GifsTab2" = "GIF"; +"GiftActionHide" = "隐藏"; +"GiftActionShow" = "显示"; +"GiftAttributeBackdrop" = "背景"; +"GiftAuctionEnded" = "拍卖已结束"; +"GiftAuctionInfoSubtitle" = "参与专属礼品争夺战。"; +"GiftAuctionListRound" = "第 {current} / {total} 轮"; +"GiftAuctionTopWinners#other" = "前{count}名赢家"; +"GiftCraftFailedDescription#other" = "这次制作尝试失败了。 \n** 件 {count} 礼物**已丟失。"; +"GiftCraftFailedTitle" = "制作失败"; +"GiftCraftInfoTitle" = "礼物制作中"; +"GiftCraftWarning" = "如果制作失败,这些礼物将会丟失。"; +"GiftCraftingTitle" = "制作中"; +"GiftDiscount" = "-{percent}%"; +"GiftFilterLimited" = "限量"; +"GiftFilterUnlimited" = "不限量"; +"GiftHideNameDescription" = "向 {profile} 个人资料页的访客隐藏您的名称和消息。 {receiver} 仍会看到您的名称和消息。"; +"GiftInfoAvailabilityValue#other" = "剩余 {count} / {total} "; +"GiftInfoChannelHidden" = "此礼物对频道访客不可见。{link}"; +"GiftInfoCollectible" = "典藏品 #{number}"; +"GiftInfoCollectibleBy" = "**{owner}** 持有的典藏品 #{number}"; +"GiftInfoDate" = "日期"; +"GiftInfoDescription#other" = "您可以将此礼物保留在您的个人资料页上或将其转换为 **{amount}** 颗星星。"; +"GiftInfoDescriptionFreeUpgrade" = "免费升级此礼物,将其转换为独家典藏品。"; +"GiftInfoDescriptionUpgrade2" = "升级此礼物,将其转换为独家典藏品。"; +"GiftInfoFrom" = "来自"; +"GiftInfoLastSale" = "最新成交"; +"GiftInfoOwner" = "所有者"; +"GiftInfoSaved" = "这份礼物在您的个人资料中可见。{link}"; +"GiftInfoSent" = "已发送礼物"; +"GiftInfoTitle" = "礼物"; +"GiftInfoUnlist" = "下架"; +"GiftInfoUpgrade" = "升级"; +"GiftInfoUpgradeBadge" = "升级"; +"GiftInfoUpgradeForFree" = "免费更新"; +"GiftInfoValue" = "价值"; +"GiftInfoValueLinkMore" = "了解更多"; +"GiftInfoViewUpgraded" = "查看已升级的礼物"; +"GiftInfoWear" = "佩戴"; +"GiftLeftCount" = "剩余 {count} 个"; +"GiftLimited" = "限量"; +"GiftMakeUnique" = "以 {stars} 创造独家典藏品"; +"GiftMakeUniqueAcc" = "独家定制"; +"GiftMessagePlaceholder" = "输入消息 (可选)"; +"GiftPeerUpgradeUniqueDescription" = "{user} 将获得一份独特的礼物编号、模型、背景和符号。"; +"GiftPremiumDescription" = "让 {user} 享有 Telegram 高级版专属功能. {link}"; +"GiftPremiumStarsPrice" = "或 **{stars}**"; +"GiftRibbonSale" = "在售"; +"GiftSellTitle" = "出售礼物"; +"GiftSend" = "赠送价值 {amount} 的礼物"; +"GiftSent" = "礼物已赠送!"; +"GiftSoldCount" = "售出 {count} 份"; +"GiftSoldOut" = "售罄"; +"GiftTransferConfirmDescriptionFree" = "您想要将 **{gift}** 的所有权转让给 **{peer}** 吗?"; +"GiftUnique" = "{title} #{number}"; +"GiftUpgradeTitle" = "独家定制"; +"GiftUpgradedTitle" = "礼物现在可以转换为典藏品"; +"GiftValueDescription" = "过去一个月内 **{giftName}** 礼物在 Telegram 和 Fragment 上的平均售价。"; +"GiftValueTitleAveragePrice" = "平均价格"; +"GraphZoomOut" = "缩小"; +"GroupAddMembers" = "添加成员"; +"GroupInaccessible" = "此群组无法访问"; +"GroupInfoAdminLog" = "近期操作"; +"GroupInfoDeleteAndExit" = "删除并退出"; +"GroupInfoInviteLinks" = "邀请链接"; +"GroupInfoParticipantCount#other" = "{count} 名成员"; +"GroupInviteExpireCustom" = "自定义"; +"GroupInviteTitle" = "邀请链接"; +"GroupInviteUsageCustom" = "自定义"; +"GroupLeaveGroup" = "退出群组"; +"GroupManagementAddAdminDescription" = "您可以添加管理员帮助您管理群组。"; +"GroupManagementBanUserConfirm" = "是否确定要封禁此用户并将其从群组中删除?"; +"GroupMembers" = "成员"; +"GroupMessagesChargePrice" = "为消息收取星星"; +"GroupName" = "群组名称"; +"GroupRemovedRemove" = "移除用户"; +"GroupStatsTitle" = "群组数据分析"; +"GroupStickers" = "群组表情包"; +"GroupType" = "群组类型"; +"GroupTypeHeader" = "群组类型"; +"GroupUsernameCreatePublicLinkHelp" = "所有人都可以与其他人分享此链接,并使用 Telegram 的搜索功能找到您的群组。"; +"GroupsAndChannelsLimitSubtitle" = "加入多达 {count} 个频道和大型群组"; +"GroupsAndChannelsLimitTitle" = "群组与频道"; +"HidAccount" = "该帐号被用户隐藏"; +"HiddenName" = "已销号用户"; +"HiddenSendersNameDescription" = "已移除发送者名称"; +"HideAd" = "隐藏"; +"HideCaption" = "隐藏说明"; +"HideSenderNames" = "隐藏发送者名称"; +"HideSendersName" = "隐藏发送者名称"; +"Hours#other" = "{count} 小时"; +"IfInactiveFor" = "如果我不上线"; +"ImageCropTitle" = "拖动以重新定位"; +"ImportedInfo" = "此信息由其他软件导入,无法保证其真实性。"; +"IncreaseLimit" = "提升上限"; +"Info" = "个人信息"; +"InputTitle" = "标题"; +"InviteLink" = "邀请链接"; +"InviteLinkExpired" = "已失效"; +"InviteLinkExpiresIn" = "于 {time} 后过期"; +"JoinGroup" = "加入群组"; +"JoinRequestAcceptAllDescription" = "您确定要接受所有请求吗?"; +"JoinRequestAcceptAllTitle" = "接受所有请求?"; +"JoinRequestRejectAllDescription" = "您确定要拒绝所有的请求吗?"; +"JoinRequestRejectAllTitle" = "拒绝所有请求?"; +"JoinRequests#other" = "{count} 个加入请求"; +"JumpToDate" = "跳转到此日期"; +"Language" = "语言"; +"LastName" = "姓氏"; +"LastSeenAtDate" = "最近上线于 {date}"; +"LastSeenHoursAgo#other" = "最近上线于{count} 小时前"; +"LastSeenJustNow" = "上线于不久前"; +"LastSeenMinutesAgo#other" = "最近上线于 {count} 分钟前"; +"LastSeenOffline" = "很久没上线"; +"LastSeenTitle" = "谁可以看到您最后上线的时间?"; +"LastSeenTodayAt" = "今日最后上线于 {time}"; +"LastSeenYesterdayAt" = "最近上线于昨天{time}"; +"Lately" = "最近曾上线"; +"LaunchConfetti" = "发送五彩纸屑!"; +"LeaveAComment" = "留言"; +"LeaveGroupAppointOwner" = "指定另一位所有者"; +"LimitByPeriod" = "时间限制"; +"LimitFree" = "免费"; +"LimitNumberOfUses" = "次数限制"; +"LimitPremium" = "会员"; +"LimitReached" = "达到上限"; +"LimitReachedChatInFoldersLocked" = "抱歉,您无法在此文件夹中添加超过 **{limit}** 个聊天。请再创建一个新的文件夹。我们在以后将尽力为您提高此上限。"; +"LimitReachedChatInFoldersPremium" = "抱歉,您无法在一个文件夹中添加超过 **{limit}** 个聊天,请创建新的文件夹。"; +"LimitReachedCommunities" = "您已加入 **{limit}** 个群组或频道。请在加入新的之前先退出一些——或者订阅 **Telegram 高级版**,将此上限翻倍至 **{limit2}** 个群组或频道。"; +"LimitReachedCommunitiesLocked" = "您已加入 **{limit}** 个群组或频道。请在加入新的之前先退出一些。我们正在努力能让你在未来提高此上限。"; +"LimitReachedCommunitiesPremium" = "您已经成为 {limit} 个群组和频道的成员了。请先退出一些,然后才可以加入新的。"; +"LimitReachedFavoriteGifs" = "已达到 {count} 张 GIF 动态图的上限"; +"LimitReachedFavoriteGifsSubtitle" = "一张旧的 GIF 动态图已被这张图替换。您可以 **提升上限** 至 {count} 张 GIF 动态图。"; +"LimitReachedFavoriteGifsSubtitlePremium" = "一张旧的 GIF 被这张替代了。"; +"LimitReachedFavoriteStickers" = "已达到 {count} 张贴纸的限制"; +"LimitReachedFavoriteStickersSubtitle" = "一张旧的贴纸已被这张贴纸替换。您可以 **提升上限** 至 {count} 张贴纸。"; +"LimitReachedFavoriteStickersSubtitlePremium" = "一张旧的贴纸已被该贴纸替换。"; +"LimitReachedFileSize" = "此文件已超过 **{limit}** ,无法发送。您可以订阅 **Telegram 高级版**,将此上限翻倍至每个文件 **{limit2}**。"; +"LimitReachedFileSizeLocked" = "由于大小超过了 **{limit}**,文档无法发送。我们正在努力能让您在未来提高此上限。"; +"LimitReachedFileSizePremium" = "由于大小已超过 **{limit}**,无法发送此文档。"; +"LimitReachedFolders" = "您已达到 **{limit}** 个文件夹的限额。 您可以订阅 **Telegram 高级版**,将此上限翻倍至 **{limit2}** 个文件夹。"; +"LimitReachedFoldersLocked" = "你已达到 {limit} 文件夹的上限。我们正在努力让你在未来提高此上限。"; +"LimitReachedFoldersPremium" = "您已经达到此帐号 **{limit}** 个文件夹的上限。"; +"LimitReachedPinDialogsLocked" = "抱歉,您最多只能置顶 {limit} 个聊天,请先取消置顶一些当前已置顶的聊天。我们在以后将尽力为您提高该限制。"; +"LimitReachedPublicLinks" = "您保留了太多公开链接。您可以撤消其中一个较旧群组或频道的链接,或者订阅 **Telegram 高级版**,将此上限翻倍至 **{limit2}** 个公开链接。"; +"LimitReachedPublicLinksLocked" = "您保留了太多公开链接。您可以撤消其中一个较旧群组或频道的链接。我们正在努力能让您在未来提高此上限。"; +"LimitReachedPublicLinksPremium" = "您保留了太多公开链接,试着从旧的群组或频道中撤销一些。"; +"LinkCopied" = "链接已复制到剪贴板"; +"LinkCreatedeBy" = "链接创建者"; +"LinkExpiresIn" = "链接将于 {time} 过期"; +"LinkLimitReached" = "已达人数上限"; +"LinkNameHelp" = "仅管理员有权查看该名称"; +"LinkNameHint" = "链接名称(可选)"; +"LinkedChannel" = "已关联的频道"; +"Listening" = "接听中"; +"LiveLocationUpdatedJustNow" = "刚刚更新"; +"LiveLocationUpdatedMinutesAgo#other" = "更新于 {count} 分钟前"; +"LiveLocationUpdatedTodayAt" = "上次更新于 {time}"; +"Loading" = "加载中…"; +"LocationPermissionText" = "**{name}** 请求访问您的位置信息,您可以随时在 **{name}** 的资料页面中撤销此权限。"; +"LogOutTitle" = "退出"; +"LoginEnterPasswordDescription" = "您已启用了两步验证,因此您的帐号已受到额外密码的保护。"; +"LoginHeaderPassword" = "输入密码"; +"LoginJustSentSms" = "我们已经向您的手机发送了一条包含验证码的短信。请在上方输入您的验证码。"; +"LoginNext" = "下一步"; +"LoginPhonePlaceholder" = "您的手机号码"; +"LoginQRCancel" = "手机号登录"; +"LoginQRHelp1" = "在您的手机上打开Telegram"; +"LoginQRHelp3" = "点击手机屏幕确认登录"; +"LoginQRLogin" = "扫描二维码登录"; +"LoginQRTitle" = "扫码登录 Telegram"; +"LoginRegisterDesc" = "在此处输入您的名字,并上传头像。"; +"LoginRegisterFirstNamePlaceholder" = "名"; +"LoginRegisterLastNamePlaceholder" = "姓氏"; +"LoginSelectCountryTitle" = "国家/地区"; +"LoopAnimatedStickers" = "循环播放动态贴纸"; +"ManageLinksInfoHelp" = "您可以添加额外的限制时间或使用人数的邀请链接"; +"ManagementRemoveAdminConfirm" = "您确定要取消其管理员身份吗?"; +"MediaAudioEmpty" = "暂无语音消息"; +"MediaDownload" = "下载"; +"MediaFileEmpty" = "暂无文件"; +"MediaLinkEmpty" = "暂无已分享的链接"; +"MediaLocaltionImageAlt" = "地图上的位置"; +"MediaPollSolutionAria" = "显示答案"; +"MediaSongEmpty" = "暂无音频"; +"MediaViewDownloading" = "已下载 {count}%..."; +"MediaZoomIn" = "放大"; +"MediaZoomOut" = "缩小"; +"MediaviewProfilePhoto" = "头像"; +"MegaPrivate" = "私密群组"; +"MegaPrivateInfo" = "私密群组只能通过群成员的邀请,或邀请链接加入。"; +"MegaPrivateLinkHelp" = "任何人都可以通过此链接加入您的群组。您可以随时废除此链接。"; +"MegaPublic" = "公开群组"; +"MegaPublicInfo" = "任何人都可以搜索、加入公开群组,并查看历史聊天记录。"; +"MemberRequests" = "加入请求"; +"MemberRequestsRequestToJoinChannel" = "请求加入频道"; +"MemberRequestsRequestToJoinDescriptionChannel" = "此频道只接受其管理员批准的新订阅者。"; +"MemberRequestsRequestToJoinDescriptionGroup" = "此群组只接受其管理员批准的新成员。"; +"MemberRequestsRequestToJoinGroup" = "请求加入群组"; +"MenuAddAccount" = "添加账号"; +"MenuAskText" = "请注意:Telegram 的支持服务来自志愿者。我们会尽快回复,但仍可能需要等待一段时间。\n\n请前往常见问题看看:那里有绝大多数问题的答案和重要的故障排除提示。"; +"MenuBetaChangelog" = "测试版更新日志"; +"MenuButtonAppendTodoList" = "添加任务"; +"MenuInstallApp" = "安装应用"; +"MenuMyProfile" = "我的资料"; +"MenuNightMode" = "夜间模式"; +"MenuReportBug" = "举报错误"; +"MenuSendGift" = "发送礼物"; +"MenuSwitchToK" = "切换到 K 版本"; +"Message" = "消息"; +"MessageMetaImported" = "已导入"; +"MessagePreview" = "消息预览"; +"MessageRecommendedLabel" = "推荐"; +"MessageScheduleEditTime" = "重设发送时间"; +"MessageScheduleSend" = "立即发送"; +"MessageScheduledOn" = "预定于 {date}"; +"MessageScheduledUntilOnline" = "直到在线后定时任务"; +"MessageSentPaidToastTitle#other" = "已发送 {count} 条消息!"; +"MessageTimerShortDays" = "{count} 天"; +"MessageTimerShortHours" = "{count} 小时"; +"Messages#other" = "{count} 条消息"; +"MiniAppUnavailableError" = "小程序不活跃"; +"Minutes#other" = "{count} 分钟"; +"MobileHidden" = "手机号码已隐藏"; +"Month1" = "1月"; +"Month10" = "10月"; +"Month11" = "11月"; +"Month12" = "12月"; +"Month2" = "2月"; +"Month3" = "3月"; +"Month4" = "4月"; +"Month5" = "5月"; +"Month6" = "6月"; +"Month7" = "7月"; +"Month8" = "8月"; +"Month9" = "9月"; +"Months#other" = "{count} 个月"; +"MyProfileHeader" = "我的资料"; +"NMembers#other" = "{count} 名成员"; +"NeverAllow" = "永不允许"; +"NeverAllowPlaceholder" = "永不允许…"; +"NeverShareWith" = "从不分享给"; +"New" = "新建"; +"NewChannel" = "新建频道"; +"NewChannelTitleEmptyError" = "频道标题不能为空"; +"NewChatReturnBackAria" = "返回至成员选择"; +"NewChatTitleEmptyError" = "聊天标题不能为空"; +"NewChatTooManyUsers" = "抱歉,尚未支持创建超级群组"; +"NewChatsFromNonContacts" = "未知用户的新聊天"; +"NewContact" = "新联系人"; +"NewContactAdd" = "增加联系人"; +"NewContactBlock" = "屏蔽用户"; +"NewContactPhoneHiddenText" = "一旦 {user} 将您添加为联系人,手机号码将对其**可见**。"; +"NewContactShare" = "分享我的手机号码"; +"NewGroup" = "新建群组"; +"NewLink" = "新链接"; +"NewMessageTitle" = "新建对话"; +"NewPoll" = "发起投票"; +"Next" = "下一步"; +"NoBlocked" = "当前没有已拉黑的用户"; +"NoContactsFound" = "未找到联系人"; +"NoDiscussionsLinked" = "未找到讨论群"; +"NoGIFsFound" = "找不到GIF"; +"NoGroupsInCommon" = "还没有共同加入的群组"; +"NoLimit" = "无限制"; +"NoLinksFound" = "没有找到链接"; +"NoMemberRequests" = "暂无加入请求"; +"NoMemberRequestsDescription" = "当前没有待处理的入群请求。"; +"NoMembersFound" = "没有找到成员"; +"NoMessages" = "暂无消息…"; +"NoOneJoined" = "无人加入"; +"NoOneJoinedYet" = "没人加入"; +"NoStickers" = "暂无表情"; +"NoSubscribeRequestsDescription" = "当前没有待处理的加入频道请求。"; +"NoSubscribersFound" = "没有找到订阅者"; +"NoUsernameFound" = "未找到用户名。"; +"NoWordsRecognized" = "没有识别出单词。"; +"Nothing" = "无"; +"NothingFound" = "什么也没找到"; +"NotificationMessageNotSupportedInFrozenAccount" = "此操作无效"; +"Notifications" = "通知"; +"NotificationsForChannels" = "频道通知"; +"NotificationsForGroups" = "群组通知"; +"NotificationsForPrivateChats" = "私聊通知"; +"NotificationsOffline" = "离线通知"; +"NotificationsSound" = "音效音量"; +"NotificationsWeb" = "Web 通知"; +"OK" = "确定"; +"Online" = "在线"; +"OnlineCount#other" = "{count} 人在线"; +"OpenUrlTitle" = "打开链接"; +"OptionHint" = "选项"; +"OtherSessions" = "活跃会话"; +"OtherWebSessions" = "已授权网站"; +"P2PContacts" = "我的联系人"; +"P2PEverybody" = "所有人"; +"P2PNobody" = "不允许任何人"; +"PaidMessageTransactionTotal" = "总计"; +"Participants#other" = "{count} 名参与者"; +"Passcode" = "密码锁"; +"PasscodeChange" = "更改锁定码"; +"PasscodeControllerChangeTitle" = "更改锁定码"; +"PasscodeControllerCurrentPlaceholder" = "请输入您当前的锁定码"; +"PasscodeControllerDisableTitle" = "禁用锁定码"; +"PasscodeControllerErrorCurrent" = "锁定码无效"; +"PasscodeControllerReEnterPasscodePlaceholder" = "重新输入新锁定码"; +"PasscodeEnterPasscodePlaceholder" = "请输入锁定码"; +"PasscodeTurnOff" = "关闭密码锁"; +"PasscodeWrong" = "锁定码错误"; +"PasskeyModalButtonText" = "创建通行密钥"; +"PasskeyModalFeature2Description" = "使用您的面容、指纹或密码登录。"; +"PasswordHint" = "密码提示"; +"PasswordHintPlaceholder" = "提示"; +"PaymentBillingAddress" = "账单地址"; +"PaymentCardExpireDate" = "月/年"; +"PaymentCardInfo" = "付款信息"; +"PaymentCardNumber" = "卡号"; +"PaymentCardSavePaymentInformation" = "保存付款信息"; +"PaymentCardTitle" = "付款卡"; +"PaymentCheckout" = "结算"; +"PaymentCheckoutAcceptRecurrent" = "我接受 {bot} 的 *使用条款*。"; +"PaymentCheckoutMethod" = "支付方式"; +"PaymentCheckoutName" = "姓名"; +"PaymentCheckoutPhoneNumber" = "手机号码"; +"PaymentCheckoutProvider" = "支付服务提供商"; +"PaymentCheckoutShippingMethod" = "配送方式"; +"PaymentReceipt" = "收据"; +"PaymentShippingAddress" = "配送地址"; +"PaymentShippingAddress1Placeholder" = "地址行 1(街道)"; +"PaymentShippingAddress2Placeholder" = "地址行 2(街道)"; +"PaymentShippingCityPlaceholder" = "城市"; +"PaymentShippingCountry" = "国家"; +"PaymentShippingEmailPlaceholder" = "电子邮箱"; +"PaymentShippingInfo" = "配送信息"; +"PaymentShippingMethod" = "配送方式"; +"PaymentShippingName" = "姓名"; +"PaymentShippingPhoneNumber" = "手机号码"; +"PaymentShippingReceiver" = "收件人"; +"PaymentShippingSave" = "保存配送信息"; +"PaymentShippingSaveInfo" = "您可以保存您的配送信息以便在未来使用。"; +"PaymentShippingStatePlaceholder" = "省份/州"; +"PaymentShippingZipPlaceholder" = "邮政编码"; +"PaymentTestInvoice" = "测试账单"; +"PaymentsCardCvc" = "安全码"; +"PaymentsProvidesNotSupported" = "抱歉,Telegram WebZ 还不支持与该提供商付款。\n\n请使用我们的移动应用程序之一来完成此付款。"; +"PeerInfoConfirmRemovePeer" = "要将 {user} 从群组中移除吗?"; +"PeerInfoReportProfilePhoto" = "举报头像"; +"PeerInfoReportProfileVideo" = "举报头像"; +"PeopleCanJoinViaLinkCount#other" = "该链接还能邀请 {count} 人加入。"; +"PeopleJoined#other" = "{count} 人已加入"; +"Permanent" = "主要链接"; +"Phone" = "手机号码"; +"PhoneOther" = "其它"; +"PinChatsLimitSubtitle" = "在您的主聊天列表中最多置顶 {count} 个聊天"; +"PinChatsLimitTitle" = "置顶聊天"; +"PinMessageAlert" = "是否在群组中置顶此消息?"; +"PinMessageAlertChannel" = "您确定要在频道中置顶此消息吗?"; +"PinMessageAlertChat" = "您确定要在对话中置顶此消息吗?"; +"PinMessageAlertTitle" = "置顶消息"; +"PinnedConfirmUnpin" = "您确定要取消置顶此消息吗?"; +"PinnedMessagesCount#other" = "{count} 条置顶消息"; +"PlayerPlaybackRate" = "播放速度"; +"PlayerVolume" = "音量"; +"PlayingGame" = "正在玩游戏"; +"PleaseEnterCurrentPassword" = "请输入密码"; +"PleaseEnterNewFirstPassword" = "请输入新密码"; +"PleaseEnterPassword" = "请输入您的新密码"; +"PleaseReEnterPassword" = "请再次输入您的密码"; +"PleaseWait" = "请稍候..."; +"Poll" = "投票"; +"PollMultiple" = "多项选择"; +"PollQuiz" = "问答模式"; +"PollResults" = "最终结果"; +"PollSubmitVotes" = "提交投票"; +"PollViewResults" = "查看结果"; +"PollsChooseAnswers" = "请输入至少两个选项。"; +"PollsChooseCorrect" = "请选择正确答案。"; +"PollsChooseQuestion" = "请输入一个问题。"; +"PollsRetract" = "撤回投票"; +"PollsSolutionTitle" = "注释"; +"PollsStop" = "停止投票"; +"PollsStopSure" = "停止"; +"PollsStopWarning" = "如果您现在停止这个投票,任何人都不能再进行投票了。 此操作无法撤消。"; +"PostsSearchTransaction" = "公开帖子搜索"; +"PremiumGiftHeader" = "高级版礼物"; +"PremiumPreviewAdvancedChatManagement" = "高级聊天管理"; +"PremiumPreviewAdvancedChatManagementDescription" = "可设定默认文件夹、自动归档以及隐藏来自非联系人的新聊天。"; +"PremiumPreviewAnimatedProfiles" = "动态头像"; +"PremiumPreviewAnimatedProfilesDescription" = "视频头像会在聊天列表和聊天中动起来,为您提供更多自我表达空间。"; +"PremiumPreviewDownloadSpeed" = "更快的下载速度"; +"PremiumPreviewDownloadSpeedDescription" = "将不限制文件和媒体下载速度"; +"PremiumPreviewLimits" = "双倍上限"; +"PremiumPreviewLimitsDescription" = "高达 {limit1} 个频道,{limit2} 个文件夹,{limit3} 个置顶,{limit4} 个公共链接,{limit5} 个账户以及更多。"; +"PremiumPreviewNoAds" = "无广告"; +"PremiumPreviewNoAdsDescription" = "Telegram 有时会显示广告的公开频道中不再有广告。"; +"PremiumPreviewProfileBadge" = "形象勋章"; +"PremiumPreviewProfileBadgeDescription" = "在您的名字后方显示专属徽章,以表示您已订阅 Telegram 高级版。"; +"PremiumPreviewReactions" = "专属表情回应"; +"PremiumPreviewReactionsDescription" = "使用更多动画表情回应消息,仅高级版订阅者可用。"; +"PremiumPreviewStickers" = "会员贴纸"; +"PremiumPreviewStickersDescription" = "具有额外特效的专属放大贴纸,定期更新。"; +"PremiumPreviewUploads" = "无限云端存储空间"; +"PremiumPreviewUploadsDescription" = "单个文档上传限制为 4 GB,聊天和媒体的总体存储空间无限制。"; +"PremiumPreviewVoiceToText" = "语音转文字"; +"PremiumPreviewVoiceToTextDescription" = "能够将收到的语音消息转换成文字以方便阅读。"; +"PremiumStickerTooltip" = "该贴纸包包含高级版贴纸,譬如该贴纸。"; +"PremiumStickers" = "Premium 贴纸"; +"PreviewEditMessage" = "编辑消息"; +"PreviewForwardedMessage#other" = "{count} 条转发的消息"; +"PreviewSenderSendAudio#other" = "发送 {count} 个音频"; +"PreviewSenderSendFile#other" = "发送 {count} 个文件"; +"PreviewSenderSendPhoto#other" = "发送 {count} 张图片"; +"PreviewSenderSendVideo#other" = "发送 {count} 个视频"; +"PreviewWebPageClose" = "清除网页预览"; +"PrimaryLinkHelp" = "Telegram上的任何人可以通过此链接加入您的群组。"; +"PrimaryLinkHelpChannel" = "通过此链接,Telegram上的任何人都可以加入您的频道。"; +"Privacy" = "隐私设置"; +"PrivacyChargeForMessages" = "付费消息"; +"PrivacyDescriptionMessagesContactsAndPremium" = "您可以限制非联系人且您未主动联系的用户给您发送消息。"; +"PrivacyDisablePremiumGifts" = "禁用高级版礼物"; +"PrivacyDisplayGift" = "礼物"; +"PrivacyExceptions" = "例外"; +"PrivacyForwards" = "转发的消息"; +"PrivacyForwardsTitle" = "转发我的消息时,谁可以链接至我的账号?"; +"PrivacyGiftLimitedEdition" = "限量版"; +"PrivacyGiftPremiumSubscription" = "高级版订阅"; +"PrivacyGiftUnlimited" = "不限量"; +"PrivacyGifts" = "礼物"; +"PrivacyGiftsTitle" = "谁可以在我的个人资料页上展示礼物?"; +"PrivacyLastSeen" = "最后上线时间"; +"PrivacyP2P" = "在通话中使用端对端连接"; +"PrivacyPhone" = "手机号码"; +"PrivacyPhoneTitle" = "谁可以看见我的手机号码?"; +"PrivacyProfilePhoto" = "头像"; +"PrivacyProfilePhotoTitle" = "谁能看见我的头像?"; +"PrivacySettings" = "隐私和安全"; +"PrivacySettingsLastSeen" = "最后上线时间"; +"PrivacySettingsWebSessions" = "活跃网站"; +"PrivacyTitle" = "隐私"; +"PrivacyValueBots" = "小程序"; +"Profile" = "个人资料"; +"ProfileBirthday" = "生日"; +"ProfileBotOpenAppInfoLink" = "https://telegram.org/tos/mini-apps"; +"ProfileCopyPhone" = "复制手机号码"; +"ProfileJoinChannel" = "关注频道"; +"ProfileJoinGroup" = "加入群组"; +"ProfileOpenAppAbout" = "启动此小程序即表示您同意 {terms}。"; +"ProfileOpenAppTerms" = "小程序服务条款"; +"ProfileTabFiles" = "文件"; +"ProfileTabGifs" = "GIF "; +"ProfileTabGifts" = "礼物"; +"ProfileTabLinks" = "链接"; +"ProfileTabMedia" = "媒体"; +"ProfileTabMembers" = "成员"; +"ProfileTabMusic" = "音乐"; +"ProfileTabStories" = "动态"; +"ProfileTabVoice" = "声音"; +"ProfileViewChannel" = "查看频道"; +"PublicLink" = "公开链接"; +"PublicLinksLimitSubtitle" = "保留高达 {count} 个 t.me/username 链接"; +"PublicLinksLimitTitle" = "公共链接"; +"PublicPoll" = "投票"; +"QuizPoll" = "问答"; +"RankModalOwnerTagTitle" = "所有者标签"; +"RankModalOwnerText" = "此紫色标签 {tag} 是 **{author}** 在 **{group}** 中的管理员标签。"; +"RankOwnerTag" = "所有者标签"; +"ReEnterYourPasscode" = "请再次输入新的锁定码"; +"Reactions" = "表情回应"; +"Recent" = "最近访问"; +"RecentChatsClear" = "清除最近的聊天"; +"RecentStickers" = "最近使用"; +"RecoveryEmailCode" = "恢复邮件验证码"; +"RecoveryEmailTitle" = "恢复电子邮箱"; +"Reminders" = "提醒"; +"RemoveSymbol" = "删除表情"; +"Reply" = "回复"; +"ReportButton" = "举报"; +"ReportChat" = "举报 {peer}"; +"ReportMessageTitle" = "举报消息"; +"ReportPeerAlertSuccess" = "非常感谢!\n我们的团队会尽快核查您的举报。"; +"ReportPeerReasonCopyright" = "版权"; +"ReportPeerReport" = "举报"; +"ReportReasonChildAbuse" = "虐待儿童"; +"ReportReasonDescription" = "说明"; +"ReportReasonOther" = "其它"; +"ReportReasonPornography" = "色情"; +"ReportReasonSpam" = "垃圾信息"; +"ReportReasonViolence" = "暴力"; +"ReportSelectMessages" = "选择消息"; +"ReportSpamAndLeave" = "举报垃圾信息并离开"; +"RequestToJoinChannelSentDescription" = "一旦频道管理员批准您的请求,您将被添加到频道中。"; +"RequestToJoinGroupSentDescription" = "待管理员同意后即可加入该群组"; +"ResellGiftsNoFound" = "未找到礼物"; +"RestictionReasonPrivateChannel" = "这是一个私密频道"; +"RestictionReasonPrivateChat" = "这是一个私密聊天"; +"RevokeAlert" = "您确定要废除此邀请链接吗?一旦废除,其他人将不能通过此链接加入此群组。"; +"RevokeButton" = "废除"; +"RevokeLink" = "废除链接"; +"Revoked" = "已撤销"; +"RevokedLinks" = "已废除的链接"; +"Save" = "保存"; +"SaveLink" = "保存链接"; +"SavedGifsLimitSubtitle" = "在 GIF 动态图收藏中存储高达 {count} 张 GIF 动态图"; +"SavedGifsLimitTitle" = "保存的 GIT"; +"SavedMessages" = "我的收藏"; +"SavedMessagesInfo" = "转发到此以保存"; +"ScamMessage" = "诈骗"; +"ScheduleMessage" = "定时消息"; +"ScheduleSendWhenOnline" = "在线时发送"; +"ScheduledMessagesEmptyPlaceholder" = "暂无定时消息。。。"; +"Search" = "搜索"; +"SearchAllMessagesTab" = "所有消息"; +"SearchFilesTab" = "文件"; +"SearchFriends" = "搜索联系人"; +"SearchGifsTab" = "GIF "; +"SearchGifsTitle" = "搜索 GIF"; +"SearchGroupsTab" = "群组"; +"SearchLinksTab" = "链接"; +"SearchMediaTab" = "媒体"; +"SearchMessages" = "消息"; +"SearchMessagesFound#other" = "找到 {count} 条消息"; +"SearchMusicTab" = "音乐"; +"SearchNoResults" = "没有符合条件的消息"; +"SearchResultTopics" = "话题"; +"SearchStickersHint" = "搜索贴纸包"; +"SearchVoiceTab" = "语音"; +"Seconds#other" = "{count} 秒"; +"SecretChat" = "私密聊天"; +"SelectChat" = "选择对话"; +"Send" = "发送"; +"SendActionChooseSticker" = "正在选择表情"; +"SendActionRecordAudio" = "正在录制语音消息"; +"SendActionRecordRound" = "正在录制视频消息"; +"SendActionRecordVideo" = "正在录制视频"; +"SendActionUploadAudio" = "正在发送语音消息"; +"SendActionUploadFile" = "正在发送文件"; +"SendActionUploadPhoto" = "正在发送图片"; +"SendActionUploadRound" = "正在发送视频消息"; +"SendActionUploadVideo" = "正在发送视频"; +"SendMessage" = "发送消息"; +"SendMessageAsTitle" = "以谁的身份发送"; +"SendMessageTo" = "发送消息给…"; +"SendWithoutSound" = "无声发送"; +"SentAppCode" = "我们已向您其它设备上的 **Telegram** 应用发送了一条验证码消息。"; +"SentGift" = "送出的礼物"; +"ServiceNotifications" = "服务通知"; +"SessionPreviewAcceptCalls" = "接听来电"; +"SessionPreviewAcceptSecret" = "新建私密聊天"; +"SessionPreviewApp" = "应用程序"; +"SessionPreviewIp" = "IP 地址"; +"SessionPreviewIpDesc" = "该位置根据 IP 地址估算,可能并不准确。"; +"SessionPreviewLocation" = "位置"; +"SessionPreviewTerminateSession" = "终止会话"; +"SessionPreviewTitle" = "设备"; +"SessionsTitle" = "活跃会话"; +"SetAdditionalPasswordInfo" = "您可以设置一个密码。当登陆到新设备时,它将作为输入短信验证码后的附加验证。"; +"SetAsAdmin" = "设为管理员"; +"SetColor" = "设置颜色"; +"SetRecoveryEmail" = "设置密码恢复电子邮箱"; +"SetReminder" = "设置提醒"; +"SetUrlAvailable" = "{url} 是可用的。"; +"SetUrlInUse" = "抱歉,此链接被占用了。"; +"SetUrlInvalid" = "链接包含无效字符"; +"SetUrlLong" = "链接太长了"; +"SetUrlPlaceholder" = "链接"; +"SetUrlShort" = "链接太短了"; +"Settings" = "设置"; +"SettingsAboutBio" = "您可以在此输入任何有关您个人的信息,诸如:年龄、工作或居住城市。\n示例:23岁,来自旧金山的设计师。"; +"SettingsDisableNightTheme" = "关闭夜间模式"; +"SettingsEnableNightTheme" = "开启夜间模式"; +"SettingsExperimental" = "实验性功能设置"; +"SettingsExperimentalAbout" = "警告!这些是实验性功能设置。其中有一些可能无法正常工作,甚至导致应用程序崩溃。这里的任何设置随时都可能因版本更新而消失。使用风险自负。"; +"SettingsFolderCreate" = "新建文件夹"; +"SettingsFoldersEmpty" = "你暂无文件夹"; +"SettingsInformation" = "编辑个人资料"; +"SettingsItemPrivacyPasskeys" = "通行密钥"; +"SettingsPasscodeEnabled" = "本机锁定码已启用"; +"SettingsPasscodeStart1" = "当您设置好锁定码后,聊天列表上方会出现一个锁形图标。\n点击它来锁定或解锁 Telegram WebZ。"; +"SettingsPasscodeStart2" = "注意:如果你忘记了本地锁定码,你需要登出 Telegram WebZ 并重新登入。"; +"SettingsPasscodeSuccess" = "恭喜!\n现在你可以使用锁定码锁定应用,其他人不能使用它。"; +"SettingsPasswordEqual" = "密码应该相同"; +"SettingsPerformanceAutoplayGif" = "自动播放 GIF "; +"SettingsSaveChanges" = "保存更改"; +"SettingsSendCmdenter" = "按 Cmd+Enter 键发送消息"; +"SettingsSendCtrlenter" = "按 Ctrl+Enter 键发送消息"; +"SettingsSendEnter" = "按回车键发送消息"; +"SettingsSendEnterDescription" = "以 Shift + Enter 开启新行"; +"SettingsSensitiveAbout" = "不隐藏包含仅适合成人观看的媒体。"; +"SettingsSensitiveDisableFiltering" = "停用过滤"; +"SettingsSensitiveTitle" = "敏感内容"; +"SettingsTimeFormat" = "时间格式"; +"SettingsTipsUsername" = "Telegram 提示"; +"ShareYouPhoneNumberTitle" = "是否分享您的手机号码?"; +"SharedMedia" = "分享的媒体文件"; +"SharedMediaEmptyTitle" = "暂无媒体文件"; +"ShortcutsControllerOthersLockByPasscode" = "以锁定码锁定"; +"ShowCaption" = "显示标题"; +"ShowMoreChats#other" = "显示其他 {count} 个聊天"; +"ShowMoreVoters#other" = "显示其他 {count} 位投票者"; +"ShowSenderNames" = "显示发送者名称"; +"ShowSendersName" = "显示发送者名称"; +"SlowModeHint" = "慢速模式已启用,您可以在 {time} 后发送下一条消息。"; +"SlowmodeEnabled" = "慢速模式已启用"; +"SomethingWentWrong" = "出错了"; +"Speaking" = "发言中"; +"SpeakingWithVolume" = "正以 {volume}% 音量发言"; +"SponsoredMessageAlertLearnMoreUrl" = "https://ads.telegram.org"; +"SponsoredMessageInfo" = "什么是赞助消息?"; +"SponsoredMessageInfoDescription1" = "与其他应用不同,Telegram 不会将您的隐私数据用于投放定向广告。赞助商消息的投放完全基于公开频道内所发布的内容。这意味着 Telegram 不会通过挖掘和分析用户画像来投放广告,查看频道的所有用户都会看到同样的赞助商消息。"; +"SponsoredMessageInfoDescription3" = "Telegram 向数亿用户提供免费和无限制的服务,这意味着服务器和流量的巨大开销。为了保持独立且始终如一地秉持理念,Telegram 已开发一种付费工具以推广信息,同时保证用户的隐私。我们欢迎负责任的广告商:"; +"SponsoredMessageInfoDescription4" = "赞助商消息目前仍然处于测试阶段。全面启动赞助商消息系统,并且 Telegram 能够平衡运营成本之后,我们将与显示赞助商消息的频道所有者共同分享广告收入。\n\n线上广告不应再和滥用用户隐私划等号。让我们共同携手,重新定义科技公司的运营模式。"; +"StarGift" = "星星礼物"; +"StarGiftDescriptionSelf" = "给自己买一份礼物并添加至您的个人资料页或留作日后使用。\n\n限量版礼物升级为典藏品后可以赠送给他人。"; +"StarGiftInfoTitle" = "Telegram 礼物"; +"StarGiftPurchaseTransaction" = "售出礼物"; +"StarGiftSaleTransaction" = "购买礼物"; +"StarsGiftCompleted" = "赠礼已送出!"; +"StarsGiftHeader" = "发送礼物"; +"StarsGiftHeaderSelf" = "购买礼物"; +"StarsSubscribeBotButtonMonth" = "以每月 {amount} 的价格订阅"; +"StarsSubscribeBotText#other" = "您是否想以每月 **{amount}** 颗星星的价格订阅 **{bot}** 中的 **{name}**?"; +"StarsSubscribeText#other" = "您想要以每月 **{amount} 颗星星**的价格订阅 **{chat}** 吗?"; +"StarsTransactionsIncoming" = "进账"; +"StarsTransactionsOutgoing" = "出账"; +"StartText" = "请确认您的国际电话区号并输入您的手机号码。"; +"StartVoipChat" = "开始语音聊天"; +"StartVoipChatPermission" = "管理语音聊天"; +"StatisticOverview" = "概况"; +"Statistics" = "统计信息"; +"StatsGroupActionsTitle" = "操作"; +"StatsGroupGrowthTitle" = "增长"; +"StatsGroupLanguagesTitle" = "成员主要语言"; +"StatsGroupMembers" = "成员"; +"StatsGroupMembersTitle" = "群组成员"; +"StatsGroupMessages" = "消息"; +"StatsGroupMessagesTitle" = "消息"; +"StatsGroupPosters" = "发布成员"; +"StatsGroupTopHoursTitle" = "活跃时间"; +"StatsGroupViewers" = "查看成员"; +"StatsMessageInteractionsTitle" = "互动"; +"StatsMessagePrivateShares" = "私密分享"; +"StatsMessagePublicShares" = "公开分享"; +"StatsMessageTitle" = "消息数据分析"; +"StatsMessageViews" = "浏览次数"; +"StatsNoShares" = "无人分享"; +"StickerPackAddStickerCount#other" = "添加 {count} 张贴纸"; +"StickerPackErrorNotFound" = "抱歉,此表情包似乎不存在。"; +"StickerPackRemoveStickerCount#other" = "移除 {count} 张贴纸"; +"StickerPackStickerCount#other" = "{count} 张贴纸"; +"Stickers" = "表情"; +"StickersAddToFavorites" = "添加到收藏"; +"StickersInstall" = "添加"; +"StickersInstalled" = "添加"; +"StickersRemoveFromFavorites" = "从收藏中移除"; +"StoryMenuOpenStealth" = "匿名查看"; +"StoryTooltipGifSent" = "已发送礼物!"; +"StoryTooltipStickerSent" = "已发送贴纸!"; +"SubscribeRequests" = "加入请求"; +"SubscribeToPremium" = "以每月 {price} 的价格订阅"; +"Subscribers#other" = "{count} 位订阅者"; +"SubtitleFrozenAccount" = "点击查看详细信息并提交申诉。"; +"SubtitlePrivacyAddUsers" = "添加用户"; +"SuggestStickers" = "根据 emoji 联想/推荐表情"; +"SuggestedPostConfirmDetailsWithTimeUser" = "您将支付 **{amount}** 以于 **{time}** 发布此贴子。发布后,此帖子必须至少保持可见 {duration}。"; +"SureLogout" = "您确定要退出登录吗?"; +"TabsPosition" = "标签视图"; +"TabsPositionLeft" = "左侧标签"; +"Telegram" = "Telegram"; +"TelegramFeatures" = "Telegram功能"; +"TelegramGeneralSettingsViewController" = "通用设置"; +"TelegramPremium" = "Telegram 高级版"; +"TelegramPremiumSubscribedSubtitle" = "欢迎订阅 **Telegram 高级版**。\n您已成功解锁以下特权。"; +"TelegramPremiumSubscribedTitle" = "一切都已就绪!"; +"TelegramPremiumSubtitle" = "订阅 **Telegram Premium** 后,您能 **超越限制** 并 **解锁数十项专属** 功能。"; +"TelegramPremiumUserDialogSubtitle" = "**Telegram 高级版** 账号的拥有者可以独家访问更多附加功能。"; +"TelegramPremiumUserDialogTitle" = "{user} 是 Telegram 高级版订阅者。"; +"TerminateAllSessions" = "注销所有其它设备上的会话"; +"TerminateOldSessionHeader" = "自动终止旧会话"; +"TextCopied" = "已复制文本到剪贴板"; +"TextSize" = "消息字号"; +"Theme" = "主题"; +"ThemeResetToDefaults" = "恢复默认"; +"ThisIsYou" = "您本人"; +"TimeLimitHelp" = "你可以设置该链接在一定时间后过期。"; +"TitleAddTask" = "添加任务"; +"TitleAppendToDoList" = "添加任务"; +"TitleEditToDoList" = "编辑核对清单"; +"TitleGiftLocked" = "礼物已锁定"; +"TitlePrice" = "价格"; +"TitleRating" = "评级"; +"TitleTask" = "任务"; +"TitleTime" = "时间"; +"TitleToDoList" = "核对清单"; +"TitleUserToDoList" = "{peer} 的核对清单"; +"TitleYourToDoList" = "您的核对清单"; +"TurnPasswordOff" = "停用密码"; +"TwoStepVerification" = "两步验证"; +"TwoStepVerificationPasswordReturnSettings" = "跳转到设置"; +"TwoStepVerificationPasswordSet" = "密码已设置!"; +"TwoStepVerificationPasswordSetInfo" = "在新设备上登录时除了短信验证码还需要输入此两步验证密码。"; +"TypePrivate" = "私密"; +"TypePublic" = "公开"; +"Unarchive" = "取消归档"; +"Unblock" = "移出黑名单"; +"UniqueStatusProfileDesignBenefitTitle" = "独特的个人资料页设计"; +"UniqueStatusProfileDesignDescription" = "您的个人资料页将套用此物品的背景和符号。"; +"UniqueStatusProofOfOwnershipBenefitTitle" = "所有权证明"; +"UniqueStatusProofOfOwnershipDescription" = "点击您名字旁的图标将显示礼物的所有者和属性。"; +"UniqueStatusWearTitle" = "佩带 {gift}"; +"UnlockMoreSimilarBots" = "显示更多机器人"; +"UnofficialSecurityRisk" = "{peer} 正在使用非官方的 Telegram 客户端—这可能会降低与该用户通信的安全性"; +"UnpinMessageAlertTitle" = "取消此消息置顶"; +"UnreadMessages" = "未读消息"; +"UpdateTelegram" = "升级 Telegram"; +"Updating" = "正在刷新…"; +"UploadImage" = "上传图片"; +"User" = "用户"; +"UserBio" = "个人简介"; +"UserInfoBlockConfirmationTitle" = "您是否想要屏蔽 {user} 在 Telegram 上的消息和通话?"; +"UserInfoFirstNamePlaceholder" = "名字"; +"UserInfoLastNamePlaceholder" = "姓氏"; +"UserInfoNotificationsDisabled" = "已停用"; +"UserInfoNotificationsEnabled" = "开启"; +"UserNoteTitle" = "备注"; +"UserRemovedBy" = "被 {user} 移除"; +"UserRestrictions" = "用户权限"; +"UserRestrictionsBlock" = "封禁并从群组中移除"; +"UserRestrictionsCanDo" = "这位用户可以做什么?"; +"UserRestrictionsChangeInfo" = "修改群组信息"; +"UserRestrictionsEmbedLinks" = "嵌入链接预览"; +"UserRestrictionsInviteUsers" = "添加用户"; +"UserRestrictionsNoEmbedLinks" = "无权嵌入链接"; +"UserRestrictionsNoSendMedia" = "无权发送媒体文件"; +"UserRestrictionsNoSendPolls" = "无权发起投票"; +"UserRestrictionsNoSendStickers" = "无权发送贴纸和 GIF 动态图"; +"UserRestrictionsPinMessages" = "置顶消息"; +"UserRestrictionsSend" = "发送消息"; +"UserRestrictionsSendMedia" = "发送媒体文件"; +"UserTyping" = "{user} 正在输入"; +"Username" = "用户名"; +"UsernameAvailable" = "{username} 是可用的。"; +"UsernameByPhoneNotFound" = "用户 {phone} 未找到。"; +"UsernameHelp" = "您可以在 **Telegram** 上设置一个用户名。设置后,别人将能够在不知道您的电话号码的情况下,通过此用户名找到您并与您联系。\n\n您可以使用 **a-z**、**0-9** 和下划线。最小长度为 **5** 个字符。"; +"UsernameInUse" = "抱歉,此用户名已被使用。"; +"UsernameInvalid" = "用户名包含无效字符"; +"UsernameLink" = "通过此链接可以与您进行对话:"; +"UsernameLong" = "用户名太长了"; +"UsernameShort" = "用户名太短了"; +"Users#other" = "{count} 位用户"; +"UsesLimitHelp" = "您可以设定链接在使用一定次数后自动失效。"; +"ViaBot" = "通过"; +"VideoCall" = "视频电话"; +"VideoConversionDone" = "视频已发布。"; +"VideoConversionTitle" = "改善视频中…"; +"VideoConversionView" = "查看"; +"VideoPlayerBuffering" = "正在加载..."; +"VideoPlayerFullscreen" = "全屏"; +"ViewAction" = "查看"; +"ViewButtonGiftUnique" = "查看典藏品"; +"ViewButtonVoiceChatChannel" = "实况"; +"ViewDiscussion" = "查看讨论"; +"ViewPackPreview" = "查看贴纸包"; +"VoiceOverChatMessagesSelected#other" = "已选择 {count} 条消息"; +"VoipCamera" = "相机"; +"VoipChatJoin" = "加入"; +"VoipChatStartScreenCapture" = "分享屏幕"; +"VoipChatStopScreenCapture" = "停止共享屏幕"; +"VoipFlip" = "切换摄像头"; +"VoipGroupAllowToSpeak" = "允许讲话"; +"VoipGroupCancelRaiseHand" = "取消发言请求"; +"VoipGroupEnd" = "结束"; +"VoipGroupEndAlertText" = "你确定要结束此次通话吗?"; +"VoipGroupEndAlertTitle" = "结束语音通话"; +"VoipGroupEndChat" = "结束语音通话"; +"VoipGroupInviteMember" = "邀请成员"; +"VoipGroupJoinCall" = "加入语音通话"; +"VoipGroupLeave" = "挂断"; +"VoipGroupLeaveAlertEndChat" = "退出通话"; +"VoipGroupLeaveAlertText" = "您确定要离开当前通话吗?"; +"VoipGroupLeaveAlertTitle" = "离开语音通话"; +"VoipGroupMuteForMe" = "为我静音"; +"VoipGroupMutedForMe" = "已被静音"; +"VoipGroupOpenProfile" = "打开个人资料"; +"VoipGroupPersonalAccount" = "个人账号"; +"VoipGroupUnmuteForMe" = "取消静音"; +"VoipGroupUserRemove" = "移除"; +"VoipGroupVoiceChat" = "语音通话"; +"VoipIncoming" = "来电"; +"VoipMute" = "静音"; +"VoipMutedByAdmin" = "被管理员静音"; +"VoipMutedTapedForSpeak" = "举手发言"; +"VoipSpeaker" = "扬声器"; +"VoipTapToMute" = "您在线上"; +"VoipUnmute" = "取消静音"; +"Vote" = "提交投票"; +"WaitingForNetwork" = "等待网络…"; +"WantsToSpeak" = "想要发言"; +"WebAppAddToAttachmentAdd" = "添加"; +"WebAppAddToAttachmentText" = "{bot} 请求将其作为选项添加到您的附件菜单中,以便您可以从任何聊天中访问它。"; +"WebAppAddToAttachmentUnavailableError" = "无法将此机器人添加到附件菜单。"; +"WebAppReloadPage" = "重载页面"; +"WebAppRemoveBot" = "从菜单中移除"; +"WebAppShareMessageInfo" = "{user} 小程序建议您将此消息发送到您所选的聊天。"; +"WebSessionsTitle" = "使用 Telegram 账号登录的网站"; +"Weekday1" = "周一"; +"Weekday2" = "周二"; +"Weekday3" = "周三"; +"Weekday4" = "周四"; +"Weekday5" = "周五"; +"Weekday6" = "周六"; +"Weekday7" = "周日"; +"WeekdayFriday" = "星期五"; +"WeekdayMonday" = "星期一"; +"WeekdaySaturday" = "星期六"; +"WeekdaySunday" = "星期天"; +"WeekdayThursday" = "星期四"; +"WeekdayToday" = "今天"; +"WeekdayTuesday" = "星期二"; +"WeekdayWednesday" = "星期三"; +"WeekdayYesterday" = "昨天"; +"Weeks#other" = "{count} 周"; +"WhoCanAddMe" = "谁可以邀请我加入群组及频道?"; +"WhoCanCallMe" = "谁可以给我打电话?"; +"WithinAMonth" = "最近一个月内曾上线"; +"WithinAWeek" = "最近一周内曾上线"; +"WrongNumber" = "手机号码有误?"; +"Years#other" = "{count} 年"; +"YourEmailCode" = "您的电子邮箱验证码"; +"YourEmailSkip" = "跳过"; +"YourEmailSkipWarning" = "警告"; +"YourEmailSkipWarningText" = "您确定不设置密码恢复邮箱吗?\n\n一旦您忘记了密码,您将无法再访问您的 Telegram 帐号,并且再也没有办法恢复帐号数据。"; +"YourName" = "您的姓名"; diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ce4c0ee7..eb339fee 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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 diff --git a/deploy/migrations/0001_init.down.sql b/deploy/migrations/0001_init.down.sql index 41918738..ca1bcf34 100644 --- a/deploy/migrations/0001_init.down.sql +++ b/deploy/migrations/0001_init.down.sql @@ -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; diff --git a/deploy/migrations/0001_init.up.sql b/deploy/migrations/0001_init.up.sql index 7336707c..489ff02a 100644 --- a/deploy/migrations/0001_init.up.sql +++ b/deploy/migrations/0001_init.up.sql @@ -1,48 +1,6114 @@ --- 0001_init: telesrv 第一阶段持久化 schema(auth_key / user / authorization)。 +-- 0001_init: telesrv 全量初始 schema(由 0001-0140 迁移链压缩而来,2026-06-19)。 +-- 全新项目无生产数据,故不保留逐迁移演进史;本文件 = 迁移链终态 schema(规范形式)+ 规范 seed。 +-- 生成:干净库跑满 0001-0140 → 去除 0077 遗留死表 dispatch_outbox_partitioned_legacy → +-- pg_dump --schema-only + --column-inserts seed;剔除 psql 元命令与 search_path 空重置。 +-- 功能与旧迁移链终态等价(已用全套 PG 集成测试对照验证)。回退见 0001_init.down.sql。 + +-- 触发器函数体内为非限定调用,须在 public 下解析。 +SET search_path = public; + +-- +-- PostgreSQL database dump -- --- 协议产物(auth_keys)与业务产物(authorizations)分表,遵循协议/业务隔离边界。 --- MTProto auth key:密钥交换产物。server 重启后据此解密客户端加密包,避免客户端重建密钥。 -CREATE TABLE IF NOT EXISTS auth_keys ( - auth_key_id BIGINT PRIMARY KEY, -- SHA1(auth_key) 低 64 位,小端解释为 int64 - body BYTEA NOT NULL, -- 256 字节 auth key 原文 - server_salt BIGINT NOT NULL, -- 密钥交换产出的初始 server salt - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + +-- Dumped from database version 17.10 +-- Dumped by pg_dump version 17.10 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; + + +-- +-- Name: EXTENSION pg_trgm; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON EXTENSION pg_trgm IS 'text similarity measurement and index searching based on trigrams'; + + +-- +-- Name: telesrv_adjust_channel_media_category_count(bigint, smallint, integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_adjust_channel_media_category_count(p_channel_id bigint, p_category smallint, p_delta integer) RETURNS void + LANGUAGE plpgsql + AS $$ +BEGIN + IF p_channel_id IS NULL OR p_channel_id = 0 + OR p_category IS NULL OR p_category <= 0 + OR p_delta = 0 THEN + RETURN; + END IF; + + IF p_delta > 0 THEN + INSERT INTO channel_media_category_counts (channel_id, category, media_count, updated_at) + VALUES (p_channel_id, p_category, p_delta, now()) + ON CONFLICT (channel_id, category) DO UPDATE SET + media_count = channel_media_category_counts.media_count + EXCLUDED.media_count, + updated_at = now(); + RETURN; + END IF; + + UPDATE channel_media_category_counts + SET media_count = GREATEST(0, media_count + p_delta), + updated_at = now() + WHERE channel_id = p_channel_id + AND category = p_category; + + DELETE FROM channel_media_category_counts + WHERE channel_id = p_channel_id + AND category = p_category + AND media_count <= 0; +END; +$$; + + +-- +-- Name: telesrv_adjust_private_media_category_count(bigint, bigint, smallint, integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_adjust_private_media_category_count(p_owner_user_id bigint, p_peer_id bigint, p_category smallint, p_delta integer) RETURNS void + LANGUAGE plpgsql + AS $$ +BEGIN + IF p_owner_user_id IS NULL OR p_owner_user_id = 0 + OR p_peer_id IS NULL OR p_peer_id = 0 + OR p_category IS NULL OR p_category <= 0 + OR p_delta = 0 THEN + RETURN; + END IF; + + IF p_delta > 0 THEN + INSERT INTO private_media_category_counts (owner_user_id, peer_id, category, media_count, updated_at) + VALUES (p_owner_user_id, p_peer_id, p_category, p_delta, now()) + ON CONFLICT (owner_user_id, peer_id, category) DO UPDATE SET + media_count = private_media_category_counts.media_count + EXCLUDED.media_count, + updated_at = now(); + RETURN; + END IF; + + UPDATE private_media_category_counts + SET media_count = GREATEST(0, media_count + p_delta), + updated_at = now() + WHERE owner_user_id = p_owner_user_id + AND peer_id = p_peer_id + AND category = p_category; + + DELETE FROM private_media_category_counts + WHERE owner_user_id = p_owner_user_id + AND peer_id = p_peer_id + AND category = p_category + AND media_count <= 0; +END; +$$; + + +-- +-- Name: telesrv_bump_channel_participants_for_user(bigint); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_bump_channel_participants_for_user(p_user_id bigint) RETURNS void + LANGUAGE plpgsql + AS $$ +BEGIN + IF COALESCE(p_user_id, 0) = 0 THEN + RETURN; + END IF; + + PERFORM telesrv_bump_read_model_version('channel_participants', 0, 'channel', member_channels.channel_id) + FROM ( + SELECT DISTINCT channel_id + FROM channel_members + WHERE user_id = p_user_id + ) AS member_channels; +END; +$$; + + +-- +-- Name: telesrv_bump_contact_accounts_for_user(bigint); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_bump_contact_accounts_for_user(p_user_id bigint) RETURNS void + LANGUAGE plpgsql + AS $$ +BEGIN + IF COALESCE(p_user_id, 0) = 0 THEN + RETURN; + END IF; + + PERFORM telesrv_bump_read_model_version('contact_account', c.user_id, 'user', c.user_id) + FROM contacts c + WHERE c.contact_user_id = p_user_id; +END; +$$; + + +-- +-- Name: telesrv_bump_dialog_light(bigint, text, bigint); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_bump_dialog_light(p_owner_user_id bigint, p_peer_type text, p_peer_id bigint) RETURNS void + LANGUAGE plpgsql + AS $$ +BEGIN + IF COALESCE(p_owner_user_id, 0) = 0 OR COALESCE(p_peer_id, 0) = 0 OR COALESCE(p_peer_type, '') = '' THEN + RETURN; + END IF; + + PERFORM telesrv_bump_read_model_version('dialog_light', p_owner_user_id, p_peer_type, p_peer_id); +END; +$$; + + +-- +-- Name: telesrv_bump_private_dialog_light_for_user(bigint); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_bump_private_dialog_light_for_user(p_user_id bigint) RETURNS void + LANGUAGE plpgsql + AS $$ +BEGIN + IF COALESCE(p_user_id, 0) = 0 THEN + RETURN; + END IF; + + PERFORM telesrv_bump_dialog_light(d.user_id, d.peer_type, d.peer_id) + FROM dialogs d + WHERE d.peer_type = 'user' + AND d.peer_id = p_user_id; +END; +$$; + + +-- +-- Name: telesrv_bump_read_model_version(text, bigint, text, bigint); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_bump_read_model_version(p_model text, p_owner_user_id bigint, p_peer_type text DEFAULT ''::text, p_peer_id bigint DEFAULT 0) RETURNS void + LANGUAGE plpgsql + AS $$ +DECLARE + next_version BIGINT; + next_hash BIGINT; +BEGIN + INSERT INTO read_model_versions (model, owner_user_id, peer_type, peer_id, version, hash, updated_at) + VALUES ( + p_model, + COALESCE(p_owner_user_id, 0), + COALESCE(p_peer_type, ''), + COALESCE(p_peer_id, 0), + 1, + telesrv_random_read_model_hash(), + now() + ) + ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET + version = read_model_versions.version + 1, + hash = telesrv_random_read_model_hash(), + updated_at = EXCLUDED.updated_at + RETURNING version, hash INTO next_version, next_hash; + + PERFORM pg_notify( + 'telesrv_read_model_changed', + json_build_object( + 'model', p_model, + 'owner_user_id', COALESCE(p_owner_user_id, 0), + 'peer_type', COALESCE(p_peer_type, ''), + 'peer_id', COALESCE(p_peer_id, 0), + 'version', next_version, + 'hash', next_hash + )::text + ); +END; +$$; + + +-- +-- Name: telesrv_maintain_channel_media_category_counts(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_maintain_channel_media_category_counts() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF telesrv_visible_channel_media_message(OLD.channel_id, OLD.id) THEN + PERFORM telesrv_adjust_channel_media_category_count(OLD.channel_id, OLD.category, -1); + END IF; + RETURN NULL; + END IF; + + IF TG_OP = 'INSERT' THEN + IF telesrv_visible_channel_media_message(NEW.channel_id, NEW.id) THEN + PERFORM telesrv_adjust_channel_media_category_count(NEW.channel_id, NEW.category, 1); + END IF; + RETURN NULL; + END IF; + + IF (OLD.channel_id, OLD.id, OLD.category) + IS DISTINCT FROM + (NEW.channel_id, NEW.id, NEW.category) THEN + IF telesrv_visible_channel_media_message(OLD.channel_id, OLD.id) THEN + PERFORM telesrv_adjust_channel_media_category_count(OLD.channel_id, OLD.category, -1); + END IF; + IF telesrv_visible_channel_media_message(NEW.channel_id, NEW.id) THEN + PERFORM telesrv_adjust_channel_media_category_count(NEW.channel_id, NEW.category, 1); + END IF; + END IF; + + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_maintain_channel_media_visibility_counts(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_maintain_channel_media_visibility_counts() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + rec RECORD; + delta INT; +BEGIN + IF OLD.deleted IS DISTINCT FROM NEW.deleted THEN + IF COALESCE(OLD.deleted, false) = false AND COALESCE(NEW.deleted, false) = true THEN + delta := -1; + ELSE + delta := 1; + END IF; + + FOR rec IN + SELECT mi.channel_id, mi.category + FROM channel_message_media mi + WHERE mi.channel_id = NEW.channel_id + AND mi.id = NEW.id + LOOP + PERFORM telesrv_adjust_channel_media_category_count(rec.channel_id, rec.category, delta); + END LOOP; + END IF; + + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_maintain_private_media_category_counts(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_maintain_private_media_category_counts() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF telesrv_visible_private_media_box(OLD.owner_user_id, OLD.box_id) THEN + PERFORM telesrv_adjust_private_media_category_count(OLD.owner_user_id, OLD.peer_id, OLD.category, -1); + END IF; + RETURN NULL; + END IF; + + IF TG_OP = 'INSERT' THEN + IF telesrv_visible_private_media_box(NEW.owner_user_id, NEW.box_id) THEN + PERFORM telesrv_adjust_private_media_category_count(NEW.owner_user_id, NEW.peer_id, NEW.category, 1); + END IF; + RETURN NULL; + END IF; + + IF (OLD.owner_user_id, OLD.box_id, OLD.peer_id, OLD.category) + IS DISTINCT FROM + (NEW.owner_user_id, NEW.box_id, NEW.peer_id, NEW.category) THEN + IF telesrv_visible_private_media_box(OLD.owner_user_id, OLD.box_id) THEN + PERFORM telesrv_adjust_private_media_category_count(OLD.owner_user_id, OLD.peer_id, OLD.category, -1); + END IF; + IF telesrv_visible_private_media_box(NEW.owner_user_id, NEW.box_id) THEN + PERFORM telesrv_adjust_private_media_category_count(NEW.owner_user_id, NEW.peer_id, NEW.category, 1); + END IF; + END IF; + + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_maintain_private_media_visibility_counts(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_maintain_private_media_visibility_counts() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + rec RECORD; + delta INT; +BEGIN + IF OLD.deleted IS DISTINCT FROM NEW.deleted THEN + IF COALESCE(OLD.deleted, false) = false AND COALESCE(NEW.deleted, false) = true THEN + delta := -1; + ELSE + delta := 1; + END IF; + + FOR rec IN + SELECT mi.owner_user_id, mi.peer_id, mi.category + FROM message_box_media mi + WHERE mi.owner_user_id = NEW.owner_user_id + AND mi.box_id = NEW.box_id + LOOP + PERFORM telesrv_adjust_private_media_category_count(rec.owner_user_id, rec.peer_id, rec.category, delta); + END LOOP; + END IF; + + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_bot_channel_participants_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_bot_channel_participants_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + old_bot_id BIGINT; + new_bot_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + old_bot_id := OLD.bot_user_id; + ELSIF TG_OP = 'INSERT' THEN + new_bot_id := NEW.bot_user_id; + ELSE + old_bot_id := OLD.bot_user_id; + new_bot_id := NEW.bot_user_id; + END IF; + + IF old_bot_id IS NOT NULL THEN + PERFORM telesrv_bump_channel_participants_for_user(old_bot_id); + END IF; + IF new_bot_id IS NOT NULL AND new_bot_id IS DISTINCT FROM old_bot_id THEN + PERFORM telesrv_bump_channel_participants_for_user(new_bot_id); + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_channel_active_memberships_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_channel_active_memberships_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + old_owner BIGINT; + new_owner BIGINT; + changed BOOLEAN; +BEGIN + IF TG_OP = 'DELETE' THEN + old_owner := OLD.user_id; + IF old_owner <> 0 THEN + PERFORM telesrv_bump_read_model_version('channel_active_memberships', old_owner, 'user', old_owner); + END IF; + RETURN NULL; + END IF; + + new_owner := NEW.user_id; + IF TG_OP = 'INSERT' THEN + IF new_owner <> 0 THEN + PERFORM telesrv_bump_read_model_version('channel_active_memberships', new_owner, 'user', new_owner); + END IF; + RETURN NULL; + END IF; + + old_owner := OLD.user_id; + changed := + OLD.user_id IS DISTINCT FROM NEW.user_id OR + OLD.channel_id IS DISTINCT FROM NEW.channel_id OR + OLD.status IS DISTINCT FROM NEW.status OR + OLD.deleted IS DISTINCT FROM NEW.deleted; + + IF changed THEN + IF old_owner <> 0 THEN + PERFORM telesrv_bump_read_model_version('channel_active_memberships', old_owner, 'user', old_owner); + END IF; + IF new_owner <> 0 AND new_owner IS DISTINCT FROM old_owner THEN + PERFORM telesrv_bump_read_model_version('channel_active_memberships', new_owner, 'user', new_owner); + END IF; + END IF; + + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_channel_base_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_channel_base_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + changed_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + changed_id := OLD.id; + ELSE + changed_id := NEW.id; + END IF; + PERFORM telesrv_bump_read_model_version('channel_base', 0, 'channel', changed_id); + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_channel_changed(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_channel_changed() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + changed_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + changed_id := OLD.id; + ELSE + changed_id := NEW.id; + END IF; + PERFORM pg_notify('telesrv_channel_changed', changed_id::text); + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_channel_dialog_light_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_channel_dialog_light_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + owner_id BIGINT; + channel_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + owner_id := OLD.user_id; + channel_id := OLD.channel_id; + ELSE + owner_id := NEW.user_id; + channel_id := NEW.channel_id; + END IF; + + PERFORM telesrv_bump_dialog_light(owner_id, 'channel', channel_id); + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_channel_media_count_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_channel_media_count_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + new_channel_id BIGINT; + old_channel_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + old_channel_id := OLD.channel_id; + ELSIF TG_OP = 'INSERT' THEN + new_channel_id := NEW.channel_id; + ELSE + old_channel_id := OLD.channel_id; + new_channel_id := NEW.channel_id; + END IF; + + IF old_channel_id IS NOT NULL THEN + PERFORM telesrv_bump_read_model_version('channel_media_counts', 0, 'channel', old_channel_id); + END IF; + IF new_channel_id IS NOT NULL AND new_channel_id IS DISTINCT FROM old_channel_id THEN + PERFORM telesrv_bump_read_model_version('channel_media_counts', 0, 'channel', new_channel_id); + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_channel_media_count_visibility_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_channel_media_count_visibility_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF OLD.deleted IS DISTINCT FROM NEW.deleted + AND EXISTS ( + SELECT 1 + FROM channel_message_media mi + WHERE mi.channel_id = NEW.channel_id AND mi.id = NEW.id + ) THEN + PERFORM telesrv_bump_read_model_version('channel_media_counts', 0, 'channel', NEW.channel_id); + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_channel_member_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_channel_member_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + channel_id BIGINT; + user_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + channel_id := OLD.channel_id; + user_id := OLD.user_id; + ELSE + channel_id := NEW.channel_id; + user_id := NEW.user_id; + END IF; + + PERFORM telesrv_bump_read_model_version('channel_member', user_id, 'channel', channel_id); + PERFORM telesrv_bump_dialog_light(user_id, 'channel', channel_id); + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_channel_participants_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_channel_participants_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + old_channel_id BIGINT; + new_channel_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + old_channel_id := OLD.channel_id; + ELSIF TG_OP = 'INSERT' THEN + new_channel_id := NEW.channel_id; + ELSE + old_channel_id := OLD.channel_id; + new_channel_id := NEW.channel_id; + END IF; + + IF old_channel_id IS NOT NULL THEN + PERFORM telesrv_bump_read_model_version('channel_participants', 0, 'channel', old_channel_id); + END IF; + IF new_channel_id IS NOT NULL AND new_channel_id IS DISTINCT FROM old_channel_id THEN + PERFORM telesrv_bump_read_model_version('channel_participants', 0, 'channel', new_channel_id); + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_channel_self_boosts_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_channel_self_boosts_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + old_user_id BIGINT; + old_peer_type TEXT; + old_peer_id BIGINT; + new_user_id BIGINT; + new_peer_type TEXT; + new_peer_id BIGINT; +BEGIN + IF TG_OP <> 'INSERT' THEN + old_user_id := OLD.user_id; + old_peer_type := OLD.peer_type; + old_peer_id := OLD.peer_id; + END IF; + IF TG_OP <> 'DELETE' THEN + new_user_id := NEW.user_id; + new_peer_type := NEW.peer_type; + new_peer_id := NEW.peer_id; + END IF; + + IF old_user_id IS NOT NULL AND old_user_id <> 0 AND old_peer_type = 'channel' AND old_peer_id IS NOT NULL AND old_peer_id <> 0 THEN + PERFORM telesrv_bump_read_model_version('channel_self_boosts', old_user_id, old_peer_type, old_peer_id); + END IF; + IF new_user_id IS NOT NULL + AND new_user_id <> 0 + AND new_peer_type = 'channel' + AND new_peer_id IS NOT NULL + AND new_peer_id <> 0 + AND (new_user_id, new_peer_type, new_peer_id) IS DISTINCT FROM (old_user_id, old_peer_type, old_peer_id) THEN + PERFORM telesrv_bump_read_model_version('channel_self_boosts', new_user_id, new_peer_type, new_peer_id); + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_contact_block_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_contact_block_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + owner_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + owner_id := OLD.owner_user_id; + ELSE + owner_id := NEW.owner_user_id; + END IF; + PERFORM telesrv_bump_read_model_version('contact_blocklist', owner_id, 'user', owner_id); + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_contact_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_contact_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + owner_id BIGINT; + contact_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + owner_id := OLD.user_id; + contact_id := OLD.contact_user_id; + ELSE + owner_id := NEW.user_id; + contact_id := NEW.contact_user_id; + END IF; + PERFORM telesrv_bump_read_model_version('contact_account', owner_id, 'user', owner_id); + PERFORM telesrv_bump_dialog_light(owner_id, 'user', contact_id); + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_dialog_light_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_dialog_light_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + owner_id BIGINT; + peer_type TEXT; + peer_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + owner_id := OLD.user_id; + peer_type := OLD.peer_type; + peer_id := OLD.peer_id; + ELSE + owner_id := NEW.user_id; + peer_type := NEW.peer_type; + peer_id := NEW.peer_id; + END IF; + + PERFORM telesrv_bump_dialog_light(owner_id, peer_type, peer_id); + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_privacy_channel_participants_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_privacy_channel_participants_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + old_owner_id BIGINT; + new_owner_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + old_owner_id := OLD.owner_user_id; + ELSIF TG_OP = 'INSERT' THEN + new_owner_id := NEW.owner_user_id; + ELSE + old_owner_id := OLD.owner_user_id; + new_owner_id := NEW.owner_user_id; + END IF; + + IF old_owner_id IS NOT NULL THEN + PERFORM telesrv_bump_channel_participants_for_user(old_owner_id); + END IF; + IF new_owner_id IS NOT NULL AND new_owner_id IS DISTINCT FROM old_owner_id THEN + PERFORM telesrv_bump_channel_participants_for_user(new_owner_id); + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_privacy_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_privacy_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + owner_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + owner_id := OLD.owner_user_id; + ELSE + owner_id := NEW.owner_user_id; + END IF; + + PERFORM telesrv_bump_read_model_version('privacy_rules', owner_id, 'user', owner_id); + PERFORM telesrv_bump_contact_accounts_for_user(owner_id); + PERFORM telesrv_bump_private_dialog_light_for_user(owner_id); + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_private_media_count_dialog_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_private_media_count_dialog_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + old_owner BIGINT; + old_peer BIGINT; + new_owner BIGINT; + new_peer BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + old_owner := OLD.user_id; + old_peer := OLD.peer_id; + IF OLD.peer_type = 'user' AND old_owner <> 0 AND old_peer <> 0 THEN + PERFORM telesrv_bump_read_model_version('private_media_counts', old_owner, 'user', old_peer); + END IF; + RETURN NULL; + END IF; + + IF TG_OP = 'INSERT' THEN + new_owner := NEW.user_id; + new_peer := NEW.peer_id; + IF NEW.peer_type = 'user' AND new_owner <> 0 AND new_peer <> 0 THEN + PERFORM telesrv_bump_read_model_version('private_media_counts', new_owner, 'user', new_peer); + END IF; + RETURN NULL; + END IF; + + old_owner := OLD.user_id; + old_peer := OLD.peer_id; + new_owner := NEW.user_id; + new_peer := NEW.peer_id; + + IF OLD.peer_type = 'user' AND old_owner <> 0 AND old_peer <> 0 THEN + PERFORM telesrv_bump_read_model_version('private_media_counts', old_owner, 'user', old_peer); + END IF; + IF NEW.peer_type = 'user' + AND new_owner <> 0 + AND new_peer <> 0 + AND (NEW.peer_type, new_owner, new_peer) IS DISTINCT FROM (OLD.peer_type, old_owner, old_peer) THEN + PERFORM telesrv_bump_read_model_version('private_media_counts', new_owner, 'user', new_peer); + END IF; + + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_private_media_count_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_private_media_count_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + old_owner BIGINT; + old_peer BIGINT; + new_owner BIGINT; + new_peer BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + old_owner := OLD.owner_user_id; + old_peer := OLD.peer_id; + IF old_owner <> 0 AND old_peer <> 0 THEN + PERFORM telesrv_bump_read_model_version('private_media_counts', old_owner, 'user', old_peer); + END IF; + RETURN NULL; + END IF; + + IF TG_OP = 'INSERT' THEN + new_owner := NEW.owner_user_id; + new_peer := NEW.peer_id; + IF new_owner <> 0 AND new_peer <> 0 THEN + PERFORM telesrv_bump_read_model_version('private_media_counts', new_owner, 'user', new_peer); + END IF; + RETURN NULL; + END IF; + + old_owner := OLD.owner_user_id; + old_peer := OLD.peer_id; + new_owner := NEW.owner_user_id; + new_peer := NEW.peer_id; + + IF old_owner <> 0 AND old_peer <> 0 THEN + PERFORM telesrv_bump_read_model_version('private_media_counts', old_owner, 'user', old_peer); + END IF; + IF new_owner <> 0 + AND new_peer <> 0 + AND (new_owner, new_peer) IS DISTINCT FROM (old_owner, old_peer) THEN + PERFORM telesrv_bump_read_model_version('private_media_counts', new_owner, 'user', new_peer); + END IF; + + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_private_media_count_visibility_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_private_media_count_visibility_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + rec RECORD; +BEGIN + IF OLD.deleted IS DISTINCT FROM NEW.deleted THEN + FOR rec IN + SELECT DISTINCT mi.owner_user_id, mi.peer_id + FROM message_box_media mi + WHERE mi.owner_user_id = NEW.owner_user_id + AND mi.box_id = NEW.box_id + LOOP + IF rec.owner_user_id <> 0 AND rec.peer_id <> 0 THEN + PERFORM telesrv_bump_read_model_version('private_media_counts', rec.owner_user_id, 'user', rec.peer_id); + END IF; + END LOOP; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_profile_photo_channel_participants_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_profile_photo_channel_participants_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + old_owner_type TEXT; + old_owner_id BIGINT; + new_owner_type TEXT; + new_owner_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + old_owner_type := OLD.owner_peer_type; + old_owner_id := OLD.owner_peer_id; + ELSIF TG_OP = 'INSERT' THEN + new_owner_type := NEW.owner_peer_type; + new_owner_id := NEW.owner_peer_id; + ELSE + old_owner_type := OLD.owner_peer_type; + old_owner_id := OLD.owner_peer_id; + new_owner_type := NEW.owner_peer_type; + new_owner_id := NEW.owner_peer_id; + END IF; + + IF old_owner_type = 'user' THEN + PERFORM telesrv_bump_channel_participants_for_user(old_owner_id); + ELSIF old_owner_type = 'channel' AND old_owner_id IS NOT NULL THEN + PERFORM telesrv_bump_read_model_version('channel_participants', 0, 'channel', old_owner_id); + END IF; + IF (new_owner_type, new_owner_id) IS DISTINCT FROM (old_owner_type, old_owner_id) THEN + IF new_owner_type = 'user' THEN + PERFORM telesrv_bump_channel_participants_for_user(new_owner_id); + ELSIF new_owner_type = 'channel' AND new_owner_id IS NOT NULL THEN + PERFORM telesrv_bump_read_model_version('channel_participants', 0, 'channel', new_owner_id); + END IF; + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_profile_photo_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_profile_photo_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + owner_type TEXT; + owner_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + owner_type := OLD.owner_peer_type; + owner_id := OLD.owner_peer_id; + ELSE + owner_type := NEW.owner_peer_type; + owner_id := NEW.owner_peer_id; + END IF; + + PERFORM telesrv_bump_read_model_version('profile_photo', 0, owner_type, owner_id); + IF owner_type = 'user' THEN + PERFORM telesrv_bump_contact_accounts_for_user(owner_id); + PERFORM telesrv_bump_private_dialog_light_for_user(owner_id); + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_story_peer_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_story_peer_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + o_type TEXT; + o_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + o_type := OLD.owner_peer_type; + o_id := OLD.owner_peer_id; + ELSE + o_type := NEW.owner_peer_type; + o_id := NEW.owner_peer_id; + END IF; + PERFORM telesrv_bump_read_model_version('story_peer', 0, o_type, o_id); + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_user_base_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_user_base_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + changed_id BIGINT; + projection_changed BOOLEAN; +BEGIN + IF TG_OP = 'DELETE' THEN + changed_id := OLD.id; + projection_changed := true; + ELSE + changed_id := NEW.id; + IF TG_OP = 'INSERT' THEN + projection_changed := true; + ELSE + projection_changed := + OLD.access_hash IS DISTINCT FROM NEW.access_hash OR + OLD.phone IS DISTINCT FROM NEW.phone OR + OLD.first_name IS DISTINCT FROM NEW.first_name OR + OLD.last_name IS DISTINCT FROM NEW.last_name OR + OLD.username IS DISTINCT FROM NEW.username OR + OLD.country_code IS DISTINCT FROM NEW.country_code OR + OLD.verified IS DISTINCT FROM NEW.verified OR + OLD.support IS DISTINCT FROM NEW.support OR + OLD.about IS DISTINCT FROM NEW.about OR + OLD.default_history_ttl_period IS DISTINCT FROM NEW.default_history_ttl_period OR + OLD.is_bot IS DISTINCT FROM NEW.is_bot OR + OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version OR + OLD.premium_expires_at IS DISTINCT FROM NEW.premium_expires_at OR + OLD.emoji_status_document_id IS DISTINCT FROM NEW.emoji_status_document_id OR + OLD.emoji_status_until IS DISTINCT FROM NEW.emoji_status_until OR + OLD.color_set IS DISTINCT FROM NEW.color_set OR + OLD.color IS DISTINCT FROM NEW.color OR + OLD.color_background_emoji_id IS DISTINCT FROM NEW.color_background_emoji_id OR + OLD.profile_color_set IS DISTINCT FROM NEW.profile_color_set OR + OLD.profile_color IS DISTINCT FROM NEW.profile_color OR + OLD.profile_color_background_emoji_id IS DISTINCT FROM NEW.profile_color_background_emoji_id; + END IF; + END IF; + + IF projection_changed THEN + PERFORM telesrv_bump_read_model_version('user_base', changed_id, 'user', changed_id); + IF TG_OP = 'INSERT' THEN + PERFORM telesrv_bump_read_model_version('contact_account', changed_id, 'user', changed_id); + END IF; + PERFORM telesrv_bump_read_model_version('contact_account', c.user_id, 'user', c.user_id) + FROM contacts c + WHERE c.contact_user_id = changed_id; + PERFORM telesrv_bump_private_dialog_light_for_user(changed_id); + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_notify_user_channel_participants_read_model(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_notify_user_channel_participants_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + changed_id BIGINT; + old_id BIGINT; + projection_changed BOOLEAN; +BEGIN + IF TG_OP = 'DELETE' THEN + changed_id := OLD.id; + projection_changed := true; + ELSE + changed_id := NEW.id; + IF TG_OP = 'INSERT' THEN + projection_changed := true; + PERFORM telesrv_bump_read_model_version('contact_account', changed_id, 'user', changed_id); + ELSE + old_id := OLD.id; + projection_changed := + OLD.access_hash IS DISTINCT FROM NEW.access_hash OR + OLD.phone IS DISTINCT FROM NEW.phone OR + OLD.first_name IS DISTINCT FROM NEW.first_name OR + OLD.last_name IS DISTINCT FROM NEW.last_name OR + OLD.username IS DISTINCT FROM NEW.username OR + OLD.country_code IS DISTINCT FROM NEW.country_code OR + OLD.verified IS DISTINCT FROM NEW.verified OR + OLD.support IS DISTINCT FROM NEW.support OR + OLD.about IS DISTINCT FROM NEW.about OR + OLD.default_history_ttl_period IS DISTINCT FROM NEW.default_history_ttl_period OR + OLD.is_bot IS DISTINCT FROM NEW.is_bot OR + OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version OR + OLD.premium_expires_at IS DISTINCT FROM NEW.premium_expires_at OR + OLD.emoji_status_document_id IS DISTINCT FROM NEW.emoji_status_document_id OR + OLD.emoji_status_until IS DISTINCT FROM NEW.emoji_status_until OR + OLD.color_set IS DISTINCT FROM NEW.color_set OR + OLD.color IS DISTINCT FROM NEW.color OR + OLD.color_background_emoji_id IS DISTINCT FROM NEW.color_background_emoji_id OR + OLD.profile_color_set IS DISTINCT FROM NEW.profile_color_set OR + OLD.profile_color IS DISTINCT FROM NEW.profile_color OR + OLD.profile_color_background_emoji_id IS DISTINCT FROM NEW.profile_color_background_emoji_id; + IF old_id IS DISTINCT FROM changed_id THEN + PERFORM telesrv_bump_channel_participants_for_user(old_id); + END IF; + END IF; + END IF; + + IF projection_changed THEN + PERFORM telesrv_bump_channel_participants_for_user(changed_id); + END IF; + RETURN NULL; +END; +$$; + + +-- +-- Name: telesrv_random_read_model_hash(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_random_read_model_hash() RETURNS bigint + LANGUAGE plpgsql + AS $$ +DECLARE + value BIGINT; +BEGIN + -- PostgreSQL random() provides enough entropy for a cache/version token and + -- avoids requiring pgcrypto in local/dev deployments. Keep it positive and + -- non-zero because Telegram clients use 0 as "no known hash". + value := floor(random() * 9007199254740991)::BIGINT + 1; + IF value = 0 THEN + value := 1; + END IF; + RETURN value; +END; +$$; + + +-- +-- Name: telesrv_visible_channel_media_message(bigint, integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_visible_channel_media_message(p_channel_id bigint, p_id integer) RETURNS boolean + LANGUAGE plpgsql + AS $$ +DECLARE + visible boolean; +BEGIN + SELECT NOT m.deleted + INTO visible + FROM channel_messages m + WHERE m.channel_id = p_channel_id + AND m.id = p_id; + + RETURN COALESCE(visible, false); +END; +$$; + + +-- +-- Name: telesrv_visible_private_media_box(bigint, integer); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.telesrv_visible_private_media_box(p_owner_user_id bigint, p_box_id integer) RETURNS boolean + LANGUAGE plpgsql + AS $$ +DECLARE + visible boolean; +BEGIN + SELECT NOT mb.deleted + INTO visible + FROM message_boxes mb + WHERE mb.owner_user_id = p_owner_user_id + AND mb.box_id = p_box_id; + + RETURN COALESCE(visible, false); +END; +$$; + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: account_passwords; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.account_passwords ( + user_id bigint NOT NULL, + has_recovery boolean DEFAULT false NOT NULL, + has_secure_values boolean DEFAULT false NOT NULL, + has_password boolean DEFAULT false NOT NULL, + hint character varying(256) DEFAULT ''::character varying NOT NULL, + email_unconfirmed_pattern character varying(256) DEFAULT ''::character varying NOT NULL, + login_email_pattern character varying(256) DEFAULT ''::character varying NOT NULL, + secure_random bytea DEFAULT decode('74656c657372762d746465736b746f702d6465762d7365637572652d72616e64'::text, 'hex'::text) NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + current_algo_salt1 bytea DEFAULT '\x'::bytea NOT NULL, + current_algo_salt2 bytea DEFAULT '\x'::bytea NOT NULL, + current_algo_g integer DEFAULT 0 NOT NULL, + current_algo_p bytea DEFAULT '\x'::bytea NOT NULL, + srp_id bigint DEFAULT 0 NOT NULL, + srp_verifier bytea DEFAULT '\x'::bytea NOT NULL, + srp_b_secret bytea DEFAULT '\x'::bytea NOT NULL, + srp_b bytea DEFAULT '\x'::bytea NOT NULL, + recovery_email character varying(256) DEFAULT ''::character varying NOT NULL, + recovery_code character varying(32) DEFAULT ''::character varying NOT NULL, + recovery_code_expires_at timestamp with time zone ); --- 用户:第一阶段仅登录链路必须字段。access_hash 为任何 InputUser 校验所必须,不可省。 -CREATE TABLE IF NOT EXISTS users ( - -- 普通用户 ID 从 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳开始。 - -- 777000 等系统兼容账号显式插入,低于该区间。 - id BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 1780243200) PRIMARY KEY, - access_hash BIGINT NOT NULL, - phone VARCHAR(32) NOT NULL, - first_name VARCHAR(64) NOT NULL DEFAULT '', - last_name VARCHAR(64) NOT NULL DEFAULT '', - username VARCHAR(64) NOT NULL DEFAULT '', - country_code VARCHAR(8) NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT users_phone_key UNIQUE (phone) + +-- +-- Name: account_privacy_rules; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.account_privacy_rules ( + owner_user_id bigint NOT NULL, + privacy_key text NOT NULL, + rules jsonb DEFAULT '[]'::jsonb NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL ); --- 授权:auth_key ↔ user 绑定 + initConnection 带来的设备信息。 --- 这是 auth.signIn 后绑定关系与 account.getAuthorizations 的权威来源; --- 也是 rpc.ClientInfo(device/app/layer)的持久化归宿。 -CREATE TABLE IF NOT EXISTS authorizations ( - auth_key_id BIGINT PRIMARY KEY REFERENCES auth_keys(auth_key_id) ON DELETE CASCADE, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - hash BIGINT NOT NULL DEFAULT 0, -- getAuthorizations 列表项 hash - layer INT NOT NULL DEFAULT 0, - device_model VARCHAR(128) NOT NULL DEFAULT '', - platform VARCHAR(64) NOT NULL DEFAULT '', - system_version VARCHAR(64) NOT NULL DEFAULT '', - api_id INT NOT NULL DEFAULT 0, - app_version VARCHAR(64) NOT NULL DEFAULT '', - ip VARCHAR(64) NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - active_at TIMESTAMPTZ NOT NULL DEFAULT now() + +-- +-- Name: account_reaction_settings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.account_reaction_settings ( + user_id bigint NOT NULL, + messages_notify_from text DEFAULT 'contacts'::text NOT NULL, + stories_notify_from text DEFAULT 'contacts'::text NOT NULL, + poll_votes_notify_from text DEFAULT 'contacts'::text NOT NULL, + show_previews boolean DEFAULT true NOT NULL, + default_reaction_type text DEFAULT 'emoji'::text NOT NULL, + default_reaction_value text DEFAULT '👍'::text NOT NULL, + paid_privacy_kind text DEFAULT 'default'::text NOT NULL, + paid_privacy_peer_type text, + paid_privacy_peer_id bigint, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT account_reaction_settings_check CHECK ((((paid_privacy_kind = 'peer'::text) AND (paid_privacy_peer_type = ANY (ARRAY['user'::text, 'channel'::text])) AND (paid_privacy_peer_id IS NOT NULL)) OR ((paid_privacy_kind <> 'peer'::text) AND (paid_privacy_peer_type IS NULL) AND (paid_privacy_peer_id IS NULL)))), + CONSTRAINT account_reaction_settings_default_reaction_type_check CHECK ((default_reaction_type = 'emoji'::text)), + CONSTRAINT account_reaction_settings_default_reaction_value_check CHECK ((default_reaction_value <> ''::text)), + CONSTRAINT account_reaction_settings_messages_notify_from_check CHECK ((messages_notify_from = ANY (ARRAY['none'::text, 'contacts'::text, 'all'::text]))), + CONSTRAINT account_reaction_settings_paid_privacy_kind_check CHECK ((paid_privacy_kind = ANY (ARRAY['default'::text, 'anonymous'::text, 'peer'::text]))), + CONSTRAINT account_reaction_settings_poll_votes_notify_from_check CHECK ((poll_votes_notify_from = ANY (ARRAY['none'::text, 'contacts'::text, 'all'::text]))), + CONSTRAINT account_reaction_settings_stories_notify_from_check CHECK ((stories_notify_from = ANY (ARRAY['none'::text, 'contacts'::text, 'all'::text]))) ); --- 按 user 反查授权(PushToUser / getAuthorizations)。 -CREATE INDEX IF NOT EXISTS authorizations_user_id_idx ON authorizations (user_id); + +-- +-- Name: app_configs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.app_configs ( + client character varying(64) NOT NULL, + hash integer NOT NULL, + config_json jsonb DEFAULT '{}'::jsonb NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: auth_keys; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.auth_keys ( + auth_key_id bigint NOT NULL, + body bytea NOT NULL, + server_salt bigint NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: authorizations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.authorizations ( + auth_key_id bigint NOT NULL, + user_id bigint NOT NULL, + hash bigint DEFAULT 0 NOT NULL, + layer integer DEFAULT 0 NOT NULL, + device_model character varying(128) DEFAULT ''::character varying NOT NULL, + platform character varying(64) DEFAULT ''::character varying NOT NULL, + system_version character varying(64) DEFAULT ''::character varying NOT NULL, + api_id integer DEFAULT 0 NOT NULL, + app_version character varying(64) DEFAULT ''::character varying NOT NULL, + ip character varying(64) DEFAULT ''::character varying NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + active_at timestamp with time zone DEFAULT now() NOT NULL, + password_pending boolean DEFAULT false NOT NULL +); + + +-- +-- Name: available_reactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.available_reactions ( + reaction text NOT NULL, + title text DEFAULT ''::text NOT NULL, + inactive boolean DEFAULT false NOT NULL, + premium boolean DEFAULT false NOT NULL, + static_icon_id bigint DEFAULT 0 NOT NULL, + appear_animation_id bigint DEFAULT 0 NOT NULL, + select_animation_id bigint DEFAULT 0 NOT NULL, + activate_animation_id bigint DEFAULT 0 NOT NULL, + effect_animation_id bigint DEFAULT 0 NOT NULL, + around_animation_id bigint DEFAULT 0 NOT NULL, + center_icon_id bigint DEFAULT 0 NOT NULL, + sort_order integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: bot_chat_states; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.bot_chat_states ( + bot_user_id bigint NOT NULL, + user_id bigint NOT NULL, + state jsonb NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: bot_user_permissions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.bot_user_permissions ( + bot_user_id bigint NOT NULL, + user_id bigint NOT NULL, + from_request boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT bot_user_permissions_check CHECK ((bot_user_id <> user_id)) +); + + +-- +-- Name: bots; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.bots ( + bot_user_id bigint NOT NULL, + owner_user_id bigint NOT NULL, + token_secret character varying(64) NOT NULL, + description text DEFAULT ''::text NOT NULL, + commands jsonb DEFAULT '[]'::jsonb NOT NULL, + bot_chat_history boolean DEFAULT false NOT NULL, + bot_nochats boolean DEFAULT false NOT NULL, + inline_placeholder character varying(128) DEFAULT ''::character varying NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + menu_button_type smallint DEFAULT 0 NOT NULL, + menu_button_text character varying(64) DEFAULT ''::character varying NOT NULL, + menu_button_url character varying(512) DEFAULT ''::character varying NOT NULL, + bot_inline_geo boolean DEFAULT false NOT NULL +); + + +-- +-- Name: business_automation_deliveries; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.business_automation_deliveries ( + owner_user_id bigint NOT NULL, + peer_user_id bigint NOT NULL, + kind text NOT NULL, + trigger_message_id integer NOT NULL, + shortcut_id integer DEFAULT 0 NOT NULL, + sent_at integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT business_automation_deliveries_kind_check CHECK ((kind = ANY (ARRAY['greeting'::text, 'away'::text, 'ai'::text]))) +); + + +-- +-- Name: business_chat_links; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.business_chat_links ( + slug text NOT NULL, + owner_user_id bigint NOT NULL, + link text NOT NULL, + message text NOT NULL, + entities jsonb DEFAULT '[]'::jsonb NOT NULL, + title text DEFAULT ''::text NOT NULL, + views integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: business_connected_bot_peer_states; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.business_connected_bot_peer_states ( + owner_user_id bigint NOT NULL, + peer_user_id bigint NOT NULL, + paused boolean DEFAULT false NOT NULL, + disabled boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: business_connected_bots; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.business_connected_bots ( + owner_user_id bigint NOT NULL, + bot_user_id bigint NOT NULL, + recipients jsonb DEFAULT '{}'::jsonb NOT NULL, + rights jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: channel_admin_log_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_admin_log_events ( + channel_id bigint NOT NULL, + id bigint NOT NULL, + actor_user_id bigint NOT NULL, + event_date integer NOT NULL, + event_type character varying(48) NOT NULL, + prev_string text DEFAULT ''::text NOT NULL, + new_string text DEFAULT ''::text NOT NULL, + prev_bool boolean DEFAULT false NOT NULL, + new_bool boolean DEFAULT false NOT NULL, + prev_int integer DEFAULT 0 NOT NULL, + new_int integer DEFAULT 0 NOT NULL, + prev_participant jsonb DEFAULT '{}'::jsonb NOT NULL, + new_participant jsonb DEFAULT '{}'::jsonb NOT NULL, + participant jsonb DEFAULT '{}'::jsonb NOT NULL, + message jsonb DEFAULT '{}'::jsonb NOT NULL, + prev_message jsonb DEFAULT '{}'::jsonb NOT NULL, + new_message jsonb DEFAULT '{}'::jsonb NOT NULL, + query text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_admin_log_events_type_check CHECK (((event_type)::text = ANY (ARRAY[('change_title'::character varying)::text, ('change_username'::character varying)::text, ('change_linked_chat'::character varying)::text, ('toggle_signatures'::character varying)::text, ('toggle_pre_history_hidden'::character varying)::text, ('toggle_forum'::character varying)::text, ('toggle_autotranslation'::character varying)::text, ('toggle_anti_spam'::character varying)::text, ('toggle_slow_mode'::character varying)::text, ('participant_invite'::character varying)::text, ('participant_join'::character varying)::text, ('participant_leave'::character varying)::text, ('participant_promote'::character varying)::text, ('participant_demote'::character varying)::text, ('participant_edit_rank'::character varying)::text, ('participant_ban'::character varying)::text, ('participant_unban'::character varying)::text, ('participant_kick'::character varying)::text, ('participant_unkick'::character varying)::text, ('update_pinned'::character varying)::text, ('send_message'::character varying)::text, ('edit_message'::character varying)::text, ('delete_message'::character varying)::text]))) +); + + +-- +-- Name: channel_boost_slots; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_boost_slots ( + user_id bigint NOT NULL, + slot integer NOT NULL, + peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + peer_id bigint DEFAULT 0 NOT NULL, + assigned_at integer DEFAULT 0 NOT NULL, + expires_at integer DEFAULT 0 NOT NULL, + cooldown_until integer DEFAULT 0 NOT NULL, + multiplier integer DEFAULT 1 NOT NULL, + gift boolean DEFAULT false NOT NULL, + giveaway boolean DEFAULT false NOT NULL, + unclaimed boolean DEFAULT false NOT NULL, + giveaway_msg_id integer DEFAULT 0 NOT NULL, + used_gift_slug text DEFAULT ''::text NOT NULL, + stars bigint DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_boost_slots_multiplier_check CHECK ((multiplier > 0)), + CONSTRAINT channel_boost_slots_peer_check CHECK (((((peer_type)::text = ''::text) AND (peer_id = 0)) OR (((peer_type)::text = 'channel'::text) AND (peer_id > 0)))), + CONSTRAINT channel_boost_slots_slot_check CHECK ((slot > 0)) +); + + +-- +-- Name: channel_dialogs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_dialogs ( + user_id bigint NOT NULL, + channel_id bigint NOT NULL, + folder_id integer DEFAULT 0 NOT NULL, + top_message_id integer DEFAULT 0 NOT NULL, + top_message_date integer DEFAULT 0 NOT NULL, + read_inbox_max_id integer DEFAULT 0 NOT NULL, + read_outbox_max_id integer DEFAULT 0 NOT NULL, + unread_count integer DEFAULT 0 NOT NULL, + unread_mentions_count integer DEFAULT 0 NOT NULL, + unread_reactions_count integer DEFAULT 0 NOT NULL, + pinned boolean DEFAULT false NOT NULL, + pinned_order integer DEFAULT 0 NOT NULL, + unread_mark boolean DEFAULT false NOT NULL, + view_forum_as_messages boolean DEFAULT false NOT NULL, + notify_settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + default_send_as_peer_type character varying(16), + default_send_as_peer_id bigint, + has_scheduled boolean DEFAULT false NOT NULL, + CONSTRAINT channel_dialogs_default_send_as_peer_type_check CHECK (((default_send_as_peer_type IS NULL) OR ((default_send_as_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))), + CONSTRAINT channel_dialogs_folder_id_check CHECK ((folder_id >= 0)) +); + + +-- +-- Name: channel_forum_topics; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_forum_topics ( + channel_id bigint NOT NULL, + topic_id integer NOT NULL, + creator_user_id bigint NOT NULL, + title text DEFAULT ''::text NOT NULL, + icon_color integer DEFAULT 0 NOT NULL, + icon_emoji_id bigint DEFAULT 0 NOT NULL, + title_missing boolean DEFAULT false NOT NULL, + closed boolean DEFAULT false NOT NULL, + hidden boolean DEFAULT false NOT NULL, + pinned boolean DEFAULT false NOT NULL, + pinned_order integer DEFAULT 0 NOT NULL, + date integer NOT NULL, + top_message_id integer DEFAULT 0 NOT NULL, + read_inbox_max_id integer DEFAULT 0 NOT NULL, + read_outbox_max_id integer DEFAULT 0 NOT NULL, + unread_count integer DEFAULT 0 NOT NULL, + unread_mentions_count integer DEFAULT 0 NOT NULL, + unread_reactions_count integer DEFAULT 0 NOT NULL, + unread_poll_votes_count integer DEFAULT 0 NOT NULL, + deleted boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_forum_topics_ids_check CHECK (((topic_id > 0) AND (top_message_id >= 0))), + CONSTRAINT channel_forum_topics_title_check CHECK (((title <> ''::text) OR title_missing)) +); + + +-- +-- Name: channel_invite_hashes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_invite_hashes ( + hash text NOT NULL, + channel_id bigint NOT NULL, + invite_id bigint NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_invite_hashes_nonempty_check CHECK ((hash <> ''::text)) +); + + +-- +-- Name: channel_invite_importers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_invite_importers ( + channel_id bigint NOT NULL, + invite_id bigint NOT NULL, + user_id bigint NOT NULL, + date integer NOT NULL, + requested boolean DEFAULT false NOT NULL, + approved_by bigint DEFAULT 0 NOT NULL, + via_chatlist boolean DEFAULT false NOT NULL, + about text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: channel_invites; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_invites ( + channel_id bigint NOT NULL, + invite_id bigint NOT NULL, + hash text NOT NULL, + admin_user_id bigint NOT NULL, + title text DEFAULT ''::text NOT NULL, + permanent boolean DEFAULT false NOT NULL, + revoked boolean DEFAULT false NOT NULL, + request_needed boolean DEFAULT false NOT NULL, + expire_date integer, + usage_limit integer, + usage_count integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + requested_count integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: channel_media_category_counts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_media_category_counts ( + channel_id bigint NOT NULL, + category smallint NOT NULL, + media_count integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_media_category_counts_media_count_check CHECK ((media_count >= 0)) +); + + +-- +-- Name: channel_members; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_members ( + channel_id bigint NOT NULL, + user_id bigint NOT NULL, + inviter_user_id bigint DEFAULT 0 NOT NULL, + role character varying(16) DEFAULT 'member'::character varying NOT NULL, + status character varying(16) DEFAULT 'active'::character varying NOT NULL, + joined_at integer DEFAULT 0 NOT NULL, + left_at integer DEFAULT 0 NOT NULL, + admin_rights jsonb DEFAULT '{}'::jsonb NOT NULL, + banned_rights jsonb DEFAULT '{}'::jsonb NOT NULL, + rank text DEFAULT ''::text NOT NULL, + available_min_id integer DEFAULT 0 NOT NULL, + available_min_pts integer DEFAULT 0 NOT NULL, + read_inbox_max_id integer DEFAULT 0 NOT NULL, + read_inbox_date integer DEFAULT 0 NOT NULL, + read_outbox_max_id integer DEFAULT 0 NOT NULL, + unread_mark boolean DEFAULT false NOT NULL, + slowmode_last_send_date integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_members_role_check CHECK (((role)::text = ANY (ARRAY[('creator'::character varying)::text, ('admin'::character varying)::text, ('member'::character varying)::text]))), + CONSTRAINT channel_members_status_check CHECK (((status)::text = ANY (ARRAY[('active'::character varying)::text, ('left'::character varying)::text, ('kicked'::character varying)::text, ('banned'::character varying)::text]))) +); + + +-- +-- Name: channel_message_media; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_message_media ( + channel_id bigint NOT NULL, + id integer NOT NULL, + category smallint NOT NULL, + message_date integer NOT NULL +); + + +-- +-- Name: channel_message_reactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_message_reactions ( + channel_id bigint NOT NULL, + message_id integer NOT NULL, + reacted_user_id bigint NOT NULL, + sender_user_id bigint NOT NULL, + reaction_type character varying(16) NOT NULL, + reaction_value text NOT NULL, + big boolean DEFAULT false NOT NULL, + unread boolean DEFAULT false NOT NULL, + chosen_order integer DEFAULT 1 NOT NULL, + reaction_date integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_message_reactions_order_check CHECK ((chosen_order > 0)), + CONSTRAINT channel_message_reactions_type_check CHECK (((reaction_type)::text = 'emoji'::text)), + CONSTRAINT channel_message_reactions_value_check CHECK ((reaction_value <> ''::text)) +); + + +-- +-- Name: channel_message_viewers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_message_viewers ( + channel_id bigint NOT NULL, + message_id integer NOT NULL, + viewer_user_id bigint NOT NULL, + viewed_at integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_message_viewers_positive_check CHECK (((channel_id > 0) AND (message_id > 0) AND (viewer_user_id > 0))) +); + + +-- +-- Name: channel_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_messages ( + channel_id bigint NOT NULL, + id integer NOT NULL, + random_id bigint DEFAULT 0 NOT NULL, + sender_user_id bigint NOT NULL, + from_peer_type character varying(16) DEFAULT 'user'::character varying NOT NULL, + from_peer_id bigint NOT NULL, + send_as_peer_type character varying(16), + send_as_peer_id bigint, + message_date integer NOT NULL, + edit_date integer DEFAULT 0 NOT NULL, + post boolean DEFAULT false NOT NULL, + silent boolean DEFAULT false NOT NULL, + noforwards boolean DEFAULT false NOT NULL, + body text DEFAULT ''::text NOT NULL, + entities jsonb DEFAULT '[]'::jsonb NOT NULL, + reply_to jsonb DEFAULT '{}'::jsonb NOT NULL, + reply_to_msg_id integer DEFAULT 0 NOT NULL, + reply_to_peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + reply_to_peer_id bigint DEFAULT 0 NOT NULL, + reply_to_top_id integer DEFAULT 0 NOT NULL, + fwd_from jsonb DEFAULT '{}'::jsonb NOT NULL, + discussion_channel_id bigint DEFAULT 0 NOT NULL, + discussion_message_id integer DEFAULT 0 NOT NULL, + action jsonb DEFAULT '{}'::jsonb NOT NULL, + pts integer NOT NULL, + deleted boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + views_count integer DEFAULT 0 NOT NULL, + media jsonb DEFAULT '{}'::jsonb NOT NULL, + ttl_period integer DEFAULT 0 NOT NULL, + expires_at integer DEFAULT 0 NOT NULL, + post_author text DEFAULT ''::text NOT NULL, + pinned boolean DEFAULT false NOT NULL, + via_bot_id bigint DEFAULT 0 NOT NULL, + reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL, + from_boosts_applied integer DEFAULT 0 NOT NULL, + rich_message jsonb DEFAULT '{}'::jsonb NOT NULL, + CONSTRAINT channel_messages_content_check CHECK (((body <> ''::text) OR (action <> '{}'::jsonb) OR (media <> '{}'::jsonb))), + CONSTRAINT channel_messages_peer_type_check CHECK ((((from_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])) AND ((send_as_peer_type IS NULL) OR ((send_as_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))) AND (((reply_to_peer_type)::text = ''::text) OR ((reply_to_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))))) +); + + +-- +-- Name: channel_unread_mention_index; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_unread_mention_index ( + channel_id bigint NOT NULL, + message_id integer NOT NULL, + user_id bigint NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: channel_unread_mentions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_unread_mentions ( + user_id bigint NOT NULL, + channel_id bigint NOT NULL, + message_id integer NOT NULL, + top_message_id integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + media_unread boolean DEFAULT false NOT NULL, + unread boolean DEFAULT true NOT NULL +); + + +-- +-- Name: channel_update_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_update_events ( + channel_id bigint NOT NULL, + pts integer NOT NULL, + pts_count integer DEFAULT 1 NOT NULL, + date integer NOT NULL, + event_type character varying(32) NOT NULL, + message_id integer DEFAULT 0 NOT NULL, + message_ids jsonb DEFAULT '[]'::jsonb NOT NULL, + sender_user_id bigint DEFAULT 0 NOT NULL, + user_ids jsonb DEFAULT '[]'::jsonb NOT NULL, + payload jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_update_events_pts_count_check CHECK ((pts_count > 0)), + CONSTRAINT channel_update_events_type_check CHECK (((event_type)::text = ANY (ARRAY[('new_channel_message'::character varying)::text, ('edit_channel_message'::character varying)::text, ('delete_channel_messages'::character varying)::text, ('channel_participant'::character varying)::text, ('pinned_channel_messages'::character varying)::text, ('noop'::character varying)::text]))) +); + + +-- +-- Name: channel_usernames; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channel_usernames ( + username_lower text NOT NULL, + channel_id bigint NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_usernames_nonempty_check CHECK ((username_lower <> ''::text)) +); + + +-- +-- Name: channels; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.channels ( + id bigint NOT NULL, + access_hash bigint NOT NULL, + creator_user_id bigint NOT NULL, + title text NOT NULL, + about text DEFAULT ''::text NOT NULL, + username text, + broadcast boolean DEFAULT false NOT NULL, + megagroup boolean DEFAULT false NOT NULL, + forum boolean DEFAULT false NOT NULL, + forum_tabs boolean DEFAULT false NOT NULL, + noforwards boolean DEFAULT false NOT NULL, + join_to_send boolean DEFAULT false NOT NULL, + join_request boolean DEFAULT false NOT NULL, + signatures boolean DEFAULT false NOT NULL, + pre_history_hidden boolean DEFAULT false NOT NULL, + participants_hidden boolean DEFAULT false NOT NULL, + antispam boolean DEFAULT false NOT NULL, + linked_chat_id bigint DEFAULT 0 NOT NULL, + slowmode_seconds integer DEFAULT 0 NOT NULL, + default_banned_rights jsonb DEFAULT '{}'::jsonb NOT NULL, + available_reactions jsonb DEFAULT '{}'::jsonb NOT NULL, + color_set boolean DEFAULT false NOT NULL, + color integer DEFAULT 0 NOT NULL, + color_background_emoji_id bigint DEFAULT 0 NOT NULL, + profile_color_set boolean DEFAULT false NOT NULL, + profile_color integer DEFAULT 0 NOT NULL, + profile_color_background_emoji_id bigint DEFAULT 0 NOT NULL, + emoji_status_document_id bigint DEFAULT 0 NOT NULL, + emoji_status_until integer DEFAULT 0 NOT NULL, + participants_count integer DEFAULT 0 NOT NULL, + admins_count integer DEFAULT 0 NOT NULL, + kicked_count integer DEFAULT 0 NOT NULL, + banned_count integer DEFAULT 0 NOT NULL, + top_message_id integer DEFAULT 0 NOT NULL, + pts integer DEFAULT 0 NOT NULL, + admin_log_seq bigint DEFAULT 0 NOT NULL, + ttl_period integer DEFAULT 0 NOT NULL, + date integer NOT NULL, + deleted boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + pinned_message_id integer DEFAULT 0 NOT NULL, + autotranslation boolean DEFAULT false NOT NULL, + restricted_sponsored boolean DEFAULT false NOT NULL, + broadcast_messages_allowed boolean DEFAULT false NOT NULL, + send_paid_messages_stars bigint DEFAULT 0 NOT NULL, + photo_id bigint DEFAULT 0 NOT NULL, + photo_dc_id integer DEFAULT 0 NOT NULL, + photo_stripped bytea DEFAULT '\x'::bytea NOT NULL, + read_inbox_top1_user_id bigint DEFAULT 0 NOT NULL, + read_inbox_top1 integer DEFAULT 0 NOT NULL, + read_inbox_top2 integer DEFAULT 0 NOT NULL, + active_call_id bigint DEFAULT 0 NOT NULL, + active_call_access_hash bigint DEFAULT 0 NOT NULL, + active_call_not_empty boolean DEFAULT false NOT NULL, + boosts_unrestrict integer DEFAULT 0 NOT NULL, + CONSTRAINT channels_kind_check CHECK ((((broadcast AND (NOT megagroup) AND (NOT forum)) OR (megagroup AND (NOT broadcast))) AND ((NOT forum_tabs) OR forum))), + CONSTRAINT channels_send_paid_messages_stars_nonnegative_check CHECK ((send_paid_messages_stars >= 0)), + CONSTRAINT channels_title_nonempty_check CHECK ((title <> ''::text)) +); + + +-- +-- Name: contact_blocks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.contact_blocks ( + owner_user_id bigint NOT NULL, + blocked_user_id bigint NOT NULL, + date integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: contacts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.contacts ( + user_id bigint NOT NULL, + contact_user_id bigint NOT NULL, + mutual boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + contact_phone character varying(32) DEFAULT ''::character varying NOT NULL, + contact_first_name character varying(255) DEFAULT ''::character varying NOT NULL, + contact_last_name character varying(255) DEFAULT ''::character varying NOT NULL, + note text DEFAULT ''::text NOT NULL, + note_entities jsonb DEFAULT '[]'::jsonb NOT NULL, + close_friend boolean DEFAULT false NOT NULL, + stories_hidden boolean DEFAULT false NOT NULL, + personal_photo_id bigint DEFAULT 0 NOT NULL, + personal_photo_date integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: countries; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.countries ( + iso2 character varying(2) NOT NULL, + default_name character varying(128) NOT NULL, + name character varying(128) DEFAULT ''::character varying NOT NULL, + hidden boolean DEFAULT false NOT NULL, + order_index integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: country_codes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.country_codes ( + id bigint NOT NULL, + iso2 character varying(2) NOT NULL, + country_code character varying(16) NOT NULL, + prefixes text[] DEFAULT '{}'::text[] NOT NULL, + patterns text[] DEFAULT '{}'::text[] NOT NULL, + order_index integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: country_codes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +ALTER TABLE public.country_codes ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.country_codes_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +-- +-- Name: dialog_drafts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dialog_drafts ( + user_id bigint NOT NULL, + peer_type character varying(16) NOT NULL, + peer_id bigint NOT NULL, + top_message_id integer DEFAULT 0 NOT NULL, + date integer DEFAULT 0 NOT NULL, + draft jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT dialog_drafts_peer_type_check CHECK (((peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))), + CONSTRAINT dialog_drafts_top_message_id_check CHECK ((top_message_id >= 0)) +); + + +-- +-- Name: dialog_filter_settings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dialog_filter_settings ( + user_id bigint NOT NULL, + tags_enabled boolean DEFAULT false NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + archive_pinned boolean DEFAULT false NOT NULL, + archive_pinned_order integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: dialog_filters; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dialog_filters ( + user_id bigint NOT NULL, + filter_id integer NOT NULL, + is_chatlist boolean DEFAULT false NOT NULL, + filter jsonb NOT NULL, + order_value integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT dialog_filters_filter_object_check CHECK ((jsonb_typeof(filter) = 'object'::text)), + CONSTRAINT dialog_filters_id_check CHECK ((filter_id >= 2)) +); + + +-- +-- Name: dialogs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dialogs ( + user_id bigint NOT NULL, + peer_type character varying(16) NOT NULL, + peer_id bigint NOT NULL, + top_message_id integer DEFAULT 0 NOT NULL, + top_message_date integer DEFAULT 0 NOT NULL, + read_inbox_max_id integer DEFAULT 0 NOT NULL, + read_outbox_max_id integer DEFAULT 0 NOT NULL, + unread_count integer DEFAULT 0 NOT NULL, + unread_mentions_count integer DEFAULT 0 NOT NULL, + unread_reactions_count integer DEFAULT 0 NOT NULL, + pinned boolean DEFAULT false NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + pinned_order integer DEFAULT 0 NOT NULL, + unread_mark boolean DEFAULT false NOT NULL, + hidden_peer_settings_bar boolean DEFAULT false NOT NULL, + folder_id integer DEFAULT 0 NOT NULL, + ttl_period integer DEFAULT 0 NOT NULL, + has_scheduled boolean DEFAULT false NOT NULL, + theme_emoticon text DEFAULT ''::text NOT NULL, + CONSTRAINT dialogs_folder_id_check CHECK ((folder_id >= 0)), + CONSTRAINT dialogs_peer_type_check CHECK (((peer_type)::text = 'user'::text)) +); + + +-- +-- Name: dispatch_outbox; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dispatch_outbox ( + id bigint NOT NULL, + target_user_id bigint NOT NULL, + pts integer NOT NULL, + event_type character varying(32) NOT NULL, + exclude_session_id bigint DEFAULT 0 NOT NULL, + exclude_auth_key_id bigint DEFAULT 0 NOT NULL, + status character varying(16) DEFAULT 'pending'::character varying NOT NULL, + attempts integer DEFAULT 0 NOT NULL, + next_attempt_at timestamp with time zone DEFAULT now() NOT NULL, + last_error text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT dispatch_outbox_status_check CHECK (((status)::text = ANY (ARRAY[('pending'::character varying)::text, ('dispatching'::character varying)::text, ('failed'::character varying)::text]))) +); + + +-- +-- Name: dispatch_outbox_v2_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.dispatch_outbox_v2_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: dispatch_outbox_v2_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.dispatch_outbox_v2_id_seq OWNED BY public.dispatch_outbox.id; + + +-- +-- Name: documents; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.documents ( + id bigint NOT NULL, + access_hash bigint DEFAULT 0 NOT NULL, + file_reference bytea DEFAULT '\x'::bytea NOT NULL, + date integer DEFAULT 0 NOT NULL, + mime_type text DEFAULT ''::text NOT NULL, + size bigint DEFAULT 0 NOT NULL, + dc_id integer DEFAULT 0 NOT NULL, + attributes jsonb DEFAULT '[]'::jsonb NOT NULL, + thumbs jsonb DEFAULT '[]'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: encrypted_files; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.encrypted_files ( + id bigint NOT NULL, + access_hash bigint NOT NULL, + owner_user_id bigint NOT NULL, + size bigint NOT NULL, + dc_id integer NOT NULL, + key_fingerprint integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: encrypted_message_queue; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.encrypted_message_queue ( + receiver_auth_key_id bigint NOT NULL, + qts integer NOT NULL, + receiver_user_id bigint NOT NULL, + chat_id integer NOT NULL, + random_id bigint NOT NULL, + date integer NOT NULL, + is_service boolean DEFAULT false NOT NULL, + bytes bytea NOT NULL, + file_id bigint, + file_access_hash bigint, + file_size bigint, + file_dc_id integer, + file_key_fingerprint integer, + acked boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: encrypted_state_event_delivery; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.encrypted_state_event_delivery ( + event_id bigint NOT NULL, + auth_key_id bigint NOT NULL +); + + +-- +-- Name: encrypted_state_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.encrypted_state_events ( + id bigint NOT NULL, + target_user_id bigint NOT NULL, + target_auth_key_id bigint DEFAULT 0 NOT NULL, + chat_id integer NOT NULL, + event_type smallint NOT NULL, + max_date integer DEFAULT 0 NOT NULL, + date integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: encrypted_state_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.encrypted_state_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: encrypted_state_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.encrypted_state_events_id_seq OWNED BY public.encrypted_state_events.id; + + +-- +-- Name: file_blobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.file_blobs ( + location_key text NOT NULL, + backend text DEFAULT 'localfs'::text NOT NULL, + object_key text NOT NULL, + size bigint DEFAULT 0 NOT NULL, + sha256 bytea DEFAULT '\x'::bytea NOT NULL, + mime_type text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: group_call_participant_overrides; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.group_call_participant_overrides ( + call_id bigint NOT NULL, + setter_user_id bigint NOT NULL, + target_user_id bigint NOT NULL, + muted_by_you boolean DEFAULT false NOT NULL, + volume integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: group_call_participants; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.group_call_participants ( + call_id bigint NOT NULL, + user_id bigint NOT NULL, + ssrc bigint NOT NULL, + join_date integer NOT NULL, + active_date integer DEFAULT 0 NOT NULL, + muted boolean DEFAULT false NOT NULL, + muted_by_admin boolean DEFAULT false NOT NULL, + volume_by_admin integer DEFAULT 0 NOT NULL, + raise_hand_rating bigint DEFAULT 0 NOT NULL, + video_json jsonb, + presentation_json jsonb, + left_call boolean DEFAULT false NOT NULL, + last_check_date integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: group_calls; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.group_calls ( + call_id bigint NOT NULL, + access_hash bigint NOT NULL, + channel_id bigint NOT NULL, + creator_user_id bigint NOT NULL, + state text DEFAULT 'active'::text NOT NULL, + title text DEFAULT ''::text NOT NULL, + join_muted boolean DEFAULT false NOT NULL, + version integer DEFAULT 1 NOT NULL, + participants_count integer DEFAULT 0 NOT NULL, + created_at integer NOT NULL, + discarded_at integer DEFAULT 0 NOT NULL, + duration integer DEFAULT 0 NOT NULL, + started_msg_id integer DEFAULT 0 NOT NULL, + CONSTRAINT group_calls_state_check CHECK ((state = ANY (ARRAY['active'::text, 'discarded'::text]))) +); + + +-- +-- Name: lang_pack_strings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.lang_pack_strings ( + lang_pack character varying(32) NOT NULL, + lang_code character varying(64) NOT NULL, + key character varying(128) NOT NULL, + version integer NOT NULL, + pluralized boolean DEFAULT false NOT NULL, + value text DEFAULT ''::text NOT NULL, + zero_value text DEFAULT ''::text NOT NULL, + one_value text DEFAULT ''::text NOT NULL, + two_value text DEFAULT ''::text NOT NULL, + few_value text DEFAULT ''::text NOT NULL, + many_value text DEFAULT ''::text NOT NULL, + other_value text DEFAULT ''::text NOT NULL, + deleted boolean DEFAULT false NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: lang_packs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.lang_packs ( + lang_pack character varying(32) NOT NULL, + lang_code character varying(64) NOT NULL, + version integer NOT NULL, + strings_count integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: message_box_media; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.message_box_media ( + owner_user_id bigint NOT NULL, + box_id integer NOT NULL, + peer_id bigint NOT NULL, + category smallint NOT NULL, + message_date integer NOT NULL +); + + +-- +-- Name: message_boxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.message_boxes ( + owner_user_id bigint NOT NULL, + box_id integer NOT NULL, + private_message_id bigint NOT NULL, + message_sender_id bigint NOT NULL, + peer_type character varying(16) NOT NULL, + peer_id bigint NOT NULL, + from_user_id bigint NOT NULL, + message_date integer NOT NULL, + outgoing boolean DEFAULT false NOT NULL, + body text DEFAULT ''::text NOT NULL, + entities jsonb DEFAULT '[]'::jsonb NOT NULL, + pts integer DEFAULT 0 NOT NULL, + deleted boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + edit_date integer DEFAULT 0 NOT NULL, + silent boolean DEFAULT false NOT NULL, + noforwards boolean DEFAULT false NOT NULL, + reply_to_msg_id integer DEFAULT 0 NOT NULL, + reply_to_peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + reply_to_peer_id bigint DEFAULT 0 NOT NULL, + reply_to_top_id integer DEFAULT 0 NOT NULL, + quote_text text DEFAULT ''::text NOT NULL, + quote_entities jsonb DEFAULT '[]'::jsonb NOT NULL, + quote_offset integer DEFAULT 0 NOT NULL, + fwd_from_peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + fwd_from_peer_id bigint DEFAULT 0 NOT NULL, + fwd_from_name text DEFAULT ''::text NOT NULL, + fwd_date integer DEFAULT 0 NOT NULL, + media jsonb DEFAULT '{}'::jsonb NOT NULL, + media_unread boolean DEFAULT false NOT NULL, + reaction_unread boolean DEFAULT false NOT NULL, + ttl_period integer DEFAULT 0 NOT NULL, + expires_at integer DEFAULT 0 NOT NULL, + pinned boolean DEFAULT false NOT NULL, + saved_peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + saved_peer_id bigint DEFAULT 0 NOT NULL, + fwd_saved_from_peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + fwd_saved_from_peer_id bigint DEFAULT 0 NOT NULL, + fwd_saved_from_msg_id integer DEFAULT 0 NOT NULL, + reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL, + via_bot_id bigint DEFAULT 0 NOT NULL, + rich_message jsonb DEFAULT '{}'::jsonb NOT NULL, + CONSTRAINT message_boxes_peer_type_check CHECK (((peer_type)::text = 'user'::text)) +); + + +-- +-- Name: photos; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.photos ( + id bigint NOT NULL, + access_hash bigint DEFAULT 0 NOT NULL, + file_reference bytea DEFAULT '\x'::bytea NOT NULL, + date integer DEFAULT 0 NOT NULL, + dc_id integer DEFAULT 0 NOT NULL, + has_stickers boolean DEFAULT false NOT NULL, + sizes jsonb DEFAULT '[]'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: poll_votes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.poll_votes ( + poll_id bigint NOT NULL, + user_id bigint NOT NULL, + options jsonb NOT NULL, + vote_date integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: polls; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.polls ( + poll_id bigint NOT NULL, + creator_user_id bigint NOT NULL, + multiple_choice boolean DEFAULT false NOT NULL, + quiz boolean DEFAULT false NOT NULL, + public_voters boolean DEFAULT false NOT NULL, + revoting_disabled boolean DEFAULT false NOT NULL, + hide_results boolean DEFAULT false NOT NULL, + closed boolean DEFAULT false NOT NULL, + close_period integer DEFAULT 0 NOT NULL, + close_date integer DEFAULT 0 NOT NULL, + options jsonb NOT NULL, + correct_options jsonb DEFAULT '[]'::jsonb NOT NULL, + solution text DEFAULT ''::text NOT NULL, + solution_entities jsonb DEFAULT '[]'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: private_media_category_counts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.private_media_category_counts ( + owner_user_id bigint NOT NULL, + peer_id bigint NOT NULL, + category smallint NOT NULL, + media_count integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT private_media_category_counts_media_count_check CHECK ((media_count >= 0)) +); + + +-- +-- Name: private_message_reactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.private_message_reactions ( + message_sender_id bigint NOT NULL, + private_message_id bigint NOT NULL, + user_id bigint NOT NULL, + reaction_type text NOT NULL, + reaction_value text NOT NULL, + big boolean DEFAULT false NOT NULL, + reaction_date integer NOT NULL, + chosen_order integer DEFAULT 1 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: private_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.private_messages ( + id bigint NOT NULL, + sender_user_id bigint NOT NULL, + recipient_user_id bigint NOT NULL, + random_id bigint DEFAULT 0 NOT NULL, + message_date integer NOT NULL, + body text DEFAULT ''::text NOT NULL, + entities jsonb DEFAULT '[]'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + edit_date integer DEFAULT 0 NOT NULL, + silent boolean DEFAULT false NOT NULL, + noforwards boolean DEFAULT false NOT NULL, + reply_to_msg_id integer DEFAULT 0 NOT NULL, + reply_to_peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + reply_to_peer_id bigint DEFAULT 0 NOT NULL, + reply_to_top_id integer DEFAULT 0 NOT NULL, + quote_text text DEFAULT ''::text NOT NULL, + quote_entities jsonb DEFAULT '[]'::jsonb NOT NULL, + quote_offset integer DEFAULT 0 NOT NULL, + fwd_from_peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + fwd_from_peer_id bigint DEFAULT 0 NOT NULL, + fwd_from_name text DEFAULT ''::text NOT NULL, + fwd_date integer DEFAULT 0 NOT NULL, + media jsonb DEFAULT '{}'::jsonb NOT NULL, + ttl_period integer DEFAULT 0 NOT NULL, + expires_at integer DEFAULT 0 NOT NULL, + reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL, + via_bot_id bigint DEFAULT 0 NOT NULL, + rich_message jsonb DEFAULT '{}'::jsonb NOT NULL, + CONSTRAINT private_messages_nonempty_body CHECK (((body <> ''::text) OR (media <> '{}'::jsonb))) +); + + +-- +-- Name: private_messages_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +ALTER TABLE public.private_messages ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.private_messages_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +-- +-- Name: profile_photos; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.profile_photos ( + owner_peer_type text NOT NULL, + owner_peer_id bigint NOT NULL, + photo_id bigint NOT NULL, + date integer DEFAULT 0 NOT NULL, + active boolean DEFAULT true NOT NULL, + sort_order bigint DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + kind text DEFAULT 'profile'::text NOT NULL, + CONSTRAINT profile_photos_kind_check CHECK ((kind = ANY (ARRAY['profile'::text, 'fallback'::text]))), + CONSTRAINT profile_photos_peer_type_check CHECK ((owner_peer_type = ANY (ARRAY['user'::text, 'channel'::text]))) +); + + +-- +-- Name: quick_replies; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.quick_replies ( + owner_user_id bigint NOT NULL, + shortcut_id integer NOT NULL, + shortcut text NOT NULL, + sort_order integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT quick_replies_shortcut_not_empty CHECK ((shortcut <> ''::text)) +); + + +-- +-- Name: quick_reply_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.quick_reply_messages ( + owner_user_id bigint NOT NULL, + shortcut_id integer NOT NULL, + message_id integer NOT NULL, + random_id bigint DEFAULT 0 NOT NULL, + message_date integer NOT NULL, + body text NOT NULL, + entities jsonb DEFAULT '[]'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: read_model_versions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.read_model_versions ( + model text NOT NULL, + owner_user_id bigint DEFAULT 0 NOT NULL, + peer_type text DEFAULT ''::text NOT NULL, + peer_id bigint DEFAULT 0 NOT NULL, + version bigint DEFAULT 1 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + hash bigint DEFAULT public.telesrv_random_read_model_hash() NOT NULL +); + + +-- +-- Name: saved_dialog_pins; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.saved_dialog_pins ( + user_id bigint NOT NULL, + peer_type character varying(16) NOT NULL, + peer_id bigint NOT NULL, + pinned_order integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT saved_dialog_pins_peer_type_check CHECK (((peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))) +); + + +-- +-- Name: saved_music; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.saved_music ( + user_id bigint NOT NULL, + document_id bigint NOT NULL, + sort_order integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: scheduled_messages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.scheduled_messages ( + owner_user_id bigint NOT NULL, + scheduled_id integer NOT NULL, + peer_type character varying(16) NOT NULL, + peer_id bigint NOT NULL, + random_id bigint DEFAULT 0 NOT NULL, + message_date integer DEFAULT 0 NOT NULL, + body text DEFAULT ''::text NOT NULL, + entities jsonb DEFAULT '[]'::jsonb NOT NULL, + media jsonb DEFAULT '{}'::jsonb NOT NULL, + silent boolean DEFAULT false NOT NULL, + noforwards boolean DEFAULT false NOT NULL, + reply_to_msg_id integer DEFAULT 0 NOT NULL, + reply_to_peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + reply_to_peer_id bigint DEFAULT 0 NOT NULL, + reply_to_top_id integer DEFAULT 0 NOT NULL, + quote_text text DEFAULT ''::text NOT NULL, + quote_entities jsonb DEFAULT '[]'::jsonb NOT NULL, + quote_offset integer DEFAULT 0 NOT NULL, + fwd_from_peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + fwd_from_peer_id bigint DEFAULT 0 NOT NULL, + fwd_from_name text DEFAULT ''::text NOT NULL, + fwd_date integer DEFAULT 0 NOT NULL, + send_as_peer_type character varying(16) DEFAULT ''::character varying NOT NULL, + send_as_peer_id bigint DEFAULT 0 NOT NULL, + schedule_date integer NOT NULL, + schedule_repeat_period integer DEFAULT 0 NOT NULL, + state character varying(16) DEFAULT 'pending'::character varying NOT NULL, + lease_until integer DEFAULT 0 NOT NULL, + sent_message_id integer DEFAULT 0 NOT NULL, + last_error text DEFAULT ''::text NOT NULL, + created_at integer DEFAULT 0 NOT NULL, + updated_at integer DEFAULT 0 NOT NULL, + CONSTRAINT scheduled_messages_fwd_peer_type_check CHECK ((((fwd_from_peer_type)::text = ''::text) OR ((fwd_from_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))), + CONSTRAINT scheduled_messages_peer_type_check CHECK (((peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))), + CONSTRAINT scheduled_messages_reply_peer_type_check CHECK ((((reply_to_peer_type)::text = ''::text) OR ((reply_to_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))), + CONSTRAINT scheduled_messages_send_as_peer_type_check CHECK ((((send_as_peer_type)::text = ''::text) OR ((send_as_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))), + CONSTRAINT scheduled_messages_state_check CHECK (((state)::text = ANY (ARRAY[('pending'::character varying)::text, ('dispatching'::character varying)::text, ('sent'::character varying)::text, ('deleted'::character varying)::text]))) +); + + +-- +-- Name: secret_chats; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.secret_chats ( + chat_id integer NOT NULL, + admin_access_hash bigint NOT NULL, + participant_access_hash bigint NOT NULL, + admin_user_id bigint NOT NULL, + admin_auth_key_id bigint NOT NULL, + participant_user_id bigint NOT NULL, + participant_auth_key_id bigint DEFAULT 0 NOT NULL, + state text NOT NULL, + g_a bytea, + g_b bytea, + key_fingerprint bigint DEFAULT 0 NOT NULL, + layer integer DEFAULT 0 NOT NULL, + folder_id integer DEFAULT 0 NOT NULL, + history_deleted boolean DEFAULT false NOT NULL, + random_id integer NOT NULL, + date integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: secret_qts_watermarks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.secret_qts_watermarks ( + auth_key_id bigint NOT NULL, + reserved_qts integer DEFAULT 0 NOT NULL, + confirmed_qts integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: sticker_sets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.sticker_sets ( + id bigint NOT NULL, + access_hash bigint DEFAULT 0 NOT NULL, + short_name text DEFAULT ''::text NOT NULL, + title text DEFAULT ''::text NOT NULL, + count integer DEFAULT 0 NOT NULL, + hash integer DEFAULT 0 NOT NULL, + set_kind text DEFAULT 'stickers'::text NOT NULL, + official boolean DEFAULT false NOT NULL, + animated boolean DEFAULT false NOT NULL, + videos boolean DEFAULT false NOT NULL, + emojis boolean DEFAULT false NOT NULL, + masks boolean DEFAULT false NOT NULL, + installed boolean DEFAULT false NOT NULL, + archived boolean DEFAULT false NOT NULL, + installed_date integer DEFAULT 0 NOT NULL, + thumb_document_id bigint DEFAULT 0 NOT NULL, + thumbs jsonb DEFAULT '[]'::jsonb NOT NULL, + thumb_dc_id integer DEFAULT 0 NOT NULL, + thumb_version integer DEFAULT 0 NOT NULL, + document_ids jsonb DEFAULT '[]'::jsonb NOT NULL, + packs jsonb DEFAULT '[]'::jsonb NOT NULL, + sort_order integer DEFAULT 0 NOT NULL, + system_key text DEFAULT ''::text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: stories; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.stories ( + owner_peer_type text NOT NULL, + owner_peer_id bigint NOT NULL, + story_id integer NOT NULL, + random_id bigint DEFAULT 0 NOT NULL, + date integer NOT NULL, + expire_date integer NOT NULL, + deleted boolean DEFAULT false NOT NULL, + pinned boolean DEFAULT false NOT NULL, + public boolean DEFAULT false NOT NULL, + close_friends boolean DEFAULT false NOT NULL, + contacts boolean DEFAULT false NOT NULL, + selected_contacts boolean DEFAULT false NOT NULL, + noforwards boolean DEFAULT false NOT NULL, + edited boolean DEFAULT false NOT NULL, + privacy_rules jsonb DEFAULT '[]'::jsonb NOT NULL, + allow_user_ids bigint[] DEFAULT '{}'::bigint[] NOT NULL, + disallow_user_ids bigint[] DEFAULT '{}'::bigint[] NOT NULL, + caption text DEFAULT ''::text NOT NULL, + entities jsonb DEFAULT '[]'::jsonb NOT NULL, + media jsonb DEFAULT '{}'::jsonb NOT NULL, + media_areas jsonb DEFAULT '[]'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + pinned_to_top_order integer DEFAULT 0 NOT NULL, + fwd_from jsonb DEFAULT '{}'::jsonb NOT NULL, + CONSTRAINT stories_id_check CHECK ((story_id > 0)), + CONSTRAINT stories_owner_peer_check CHECK (((owner_peer_type = ANY (ARRAY['user'::text, 'channel'::text])) AND (owner_peer_id > 0))), + CONSTRAINT stories_pinned_to_top_order_check CHECK ((pinned_to_top_order >= 0)) +); + + +-- +-- Name: story_hidden_peers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.story_hidden_peers ( + viewer_user_id bigint NOT NULL, + owner_peer_type text NOT NULL, + owner_peer_id bigint NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT story_hidden_peers_owner_peer_check CHECK (((viewer_user_id > 0) AND (owner_peer_type = ANY (ARRAY['user'::text, 'channel'::text])) AND (owner_peer_id > 0))) +); + + +-- +-- Name: story_read_states; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.story_read_states ( + viewer_user_id bigint NOT NULL, + owner_peer_type text NOT NULL, + owner_peer_id bigint NOT NULL, + max_read_id integer NOT NULL, + date integer NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT story_read_states_owner_peer_check CHECK (((viewer_user_id > 0) AND (owner_peer_type = ANY (ARRAY['user'::text, 'channel'::text])) AND (owner_peer_id > 0) AND (max_read_id > 0))) +); + + +-- +-- Name: story_views; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.story_views ( + owner_peer_type text NOT NULL, + owner_peer_id bigint NOT NULL, + story_id integer NOT NULL, + viewer_user_id bigint NOT NULL, + date integer NOT NULL, + reaction jsonb DEFAULT '{}'::jsonb NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT story_views_owner_peer_check CHECK (((owner_peer_type = ANY (ARRAY['user'::text, 'channel'::text])) AND (owner_peer_id > 0) AND (story_id > 0) AND (viewer_user_id > 0))) +); + + +-- +-- Name: temp_auth_key_bindings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.temp_auth_key_bindings ( + temp_auth_key_id bigint NOT NULL, + perm_auth_key_id bigint NOT NULL, + nonce bigint NOT NULL, + expires_at integer NOT NULL, + encrypted_message bytea NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + temp_session_id bigint DEFAULT 0 NOT NULL +); + + +-- +-- Name: update_states; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.update_states ( + auth_key_id bigint NOT NULL, + pts integer DEFAULT 0 NOT NULL, + qts integer DEFAULT 0 NOT NULL, + date integer DEFAULT 0 NOT NULL, + seq integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + user_id bigint DEFAULT 0 NOT NULL +); + + +-- +-- Name: upload_parts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.upload_parts ( + owner_user_id bigint NOT NULL, + file_id bigint NOT NULL, + part integer NOT NULL, + total_parts integer DEFAULT 0 NOT NULL, + is_big boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + backend text NOT NULL, + object_key text NOT NULL, + size bigint NOT NULL, + sha256 bytea NOT NULL +); + + +-- +-- Name: user_business_profiles; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_business_profiles ( + user_id bigint NOT NULL, + work_hours jsonb DEFAULT '{}'::jsonb NOT NULL, + location jsonb DEFAULT '{}'::jsonb NOT NULL, + intro jsonb DEFAULT '{}'::jsonb NOT NULL, + greeting_message jsonb DEFAULT '{}'::jsonb NOT NULL, + away_message jsonb DEFAULT '{}'::jsonb NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: user_channel_member_index; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_channel_member_index ( + user_id bigint NOT NULL, + channel_id bigint NOT NULL, + status character varying(16) NOT NULL, + megagroup boolean DEFAULT false NOT NULL, + broadcast boolean DEFAULT false NOT NULL, + deleted boolean DEFAULT false NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + role character varying(16) DEFAULT 'member'::character varying NOT NULL, + left_at integer DEFAULT 0 NOT NULL, + forum boolean DEFAULT false NOT NULL, + public_username boolean DEFAULT false NOT NULL, + can_pin_messages boolean DEFAULT false NOT NULL, + CONSTRAINT user_channel_member_index_role_check CHECK (((role)::text = ANY (ARRAY[('creator'::character varying)::text, ('admin'::character varying)::text, ('member'::character varying)::text]))), + CONSTRAINT user_channel_member_index_status_check CHECK (((status)::text = ANY (ARRAY[('active'::character varying)::text, ('left'::character varying)::text, ('kicked'::character varying)::text, ('banned'::character varying)::text]))) +); + + +-- +-- Name: user_recent_reactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_recent_reactions ( + user_id bigint NOT NULL, + reaction_type character varying(16) NOT NULL, + reaction_value text NOT NULL, + reaction_date integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT user_recent_reactions_reaction_type_check CHECK (((reaction_type)::text = 'emoji'::text)), + CONSTRAINT user_recent_reactions_reaction_value_check CHECK ((reaction_value <> ''::text)) +); + + +-- +-- Name: user_saved_reaction_tags; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_saved_reaction_tags ( + user_id bigint NOT NULL, + reaction_type character varying(16) NOT NULL, + reaction_value text NOT NULL, + title text DEFAULT ''::text NOT NULL, + reaction_count integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT user_saved_reaction_tags_reaction_count_check CHECK ((reaction_count >= 0)), + CONSTRAINT user_saved_reaction_tags_reaction_type_check CHECK (((reaction_type)::text = 'emoji'::text)), + CONSTRAINT user_saved_reaction_tags_reaction_value_check CHECK ((reaction_value <> ''::text)), + CONSTRAINT user_saved_reaction_tags_title_check CHECK ((char_length(title) <= 12)) +); + + +-- +-- Name: user_top_reactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_top_reactions ( + user_id bigint NOT NULL, + reaction_type character varying(16) NOT NULL, + reaction_value text NOT NULL, + reaction_count integer DEFAULT 0 NOT NULL, + reaction_date integer NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT user_top_reactions_reaction_count_check CHECK ((reaction_count >= 0)), + CONSTRAINT user_top_reactions_reaction_type_check CHECK (((reaction_type)::text = 'emoji'::text)), + CONSTRAINT user_top_reactions_reaction_value_check CHECK ((reaction_value <> ''::text)) +); + + +-- +-- Name: user_update_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_update_events ( + user_id bigint NOT NULL, + pts integer NOT NULL, + pts_count integer DEFAULT 1 NOT NULL, + date integer NOT NULL, + event_type character varying(32) NOT NULL, + message_box_id integer, + peer_type character varying(16), + peer_id bigint, + max_id integer DEFAULT 0 NOT NULL, + still_unread_count integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + event_bool boolean DEFAULT false NOT NULL, + event_peers jsonb DEFAULT '[]'::jsonb NOT NULL, + peer_settings jsonb DEFAULT '{}'::jsonb NOT NULL, + message_ids jsonb DEFAULT '[]'::jsonb NOT NULL, + dialog_filter jsonb DEFAULT '{}'::jsonb NOT NULL, + filter_order jsonb DEFAULT '[]'::jsonb NOT NULL, + folder_peers jsonb DEFAULT '[]'::jsonb NOT NULL, + filter_id integer DEFAULT 0 NOT NULL, + tags_enabled boolean DEFAULT false NOT NULL, + channel_pts integer DEFAULT 0 NOT NULL, + folder_id integer DEFAULT 0 NOT NULL, + quick_replies jsonb DEFAULT '[]'::jsonb NOT NULL, + quick_reply_message jsonb DEFAULT '{}'::jsonb NOT NULL, + story_payload jsonb DEFAULT '{}'::jsonb NOT NULL, + reaction_payload jsonb DEFAULT '{}'::jsonb NOT NULL, + CONSTRAINT user_update_events_peer_type_check CHECK (((peer_type IS NULL) OR ((peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))), + CONSTRAINT user_update_events_type_check CHECK (((event_type)::text = ANY (ARRAY[('new_message'::character varying)::text, ('read_history_inbox'::character varying)::text, ('read_history_outbox'::character varying)::text, ('read_message_contents'::character varying)::text, ('edit_message'::character varying)::text, ('message_reactions'::character varying)::text, ('message_poll'::character varying)::text, ('draft_message'::character varying)::text, ('quick_replies'::character varying)::text, ('new_quick_reply'::character varying)::text, ('delete_quick_reply'::character varying)::text, ('quick_reply_message'::character varying)::text, ('delete_quick_reply_messages'::character varying)::text, ('contacts_reset'::character varying)::text, ('dialog_pinned'::character varying)::text, ('pinned_dialogs'::character varying)::text, ('pinned_messages'::character varying)::text, ('dialog_unread_mark'::character varying)::text, ('peer_settings'::character varying)::text, ('peer_story_blocked'::character varying)::text, ('delete_messages'::character varying)::text, ('dialog_filter'::character varying)::text, ('dialog_filter_order'::character varying)::text, ('dialog_filters'::character varying)::text, ('folder_peers'::character varying)::text, ('channel_available_messages'::character varying)::text, ('channel_view_forum_as_messages'::character varying)::text, ('channel_state'::character varying)::text, ('saved_dialog_pinned'::character varying)::text, ('pinned_saved_dialogs'::character varying)::text, ('story'::character varying)::text, ('read_stories'::character varying)::text, ('sent_story_reaction'::character varying)::text, ('new_story_reaction'::character varying)::text, ('noop'::character varying)::text]))) +); + + +-- +-- Name: user_update_watermarks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_update_watermarks ( + user_id bigint NOT NULL, + contiguous_pts integer DEFAULT 0 NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.users ( + id bigint NOT NULL, + access_hash bigint NOT NULL, + phone character varying(32) NOT NULL, + first_name character varying(64) DEFAULT ''::character varying NOT NULL, + last_name character varying(64) DEFAULT ''::character varying NOT NULL, + username character varying(64) DEFAULT ''::character varying NOT NULL, + country_code character varying(8) DEFAULT ''::character varying NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + verified boolean DEFAULT false NOT NULL, + support boolean DEFAULT false NOT NULL, + about character varying(255) DEFAULT ''::character varying NOT NULL, + last_seen_at bigint DEFAULT 0 NOT NULL, + default_history_ttl_period integer DEFAULT 0 NOT NULL, + is_bot boolean DEFAULT false NOT NULL, + bot_info_version integer DEFAULT 0 NOT NULL, + premium_expires_at timestamp with time zone, + emoji_status_document_id bigint DEFAULT 0 NOT NULL, + emoji_status_until bigint DEFAULT 0 NOT NULL, + color_set boolean DEFAULT false NOT NULL, + color integer DEFAULT 0 NOT NULL, + color_background_emoji_id bigint DEFAULT 0 NOT NULL, + profile_color_set boolean DEFAULT false NOT NULL, + profile_color integer DEFAULT 0 NOT NULL, + profile_color_background_emoji_id bigint DEFAULT 0 NOT NULL +); + + +-- +-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +ALTER TABLE public.users ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.users_id_seq + START WITH 1780243200 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + +-- +-- Name: dispatch_outbox id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dispatch_outbox ALTER COLUMN id SET DEFAULT nextval('public.dispatch_outbox_v2_id_seq'::regclass); + + +-- +-- Name: encrypted_state_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.encrypted_state_events ALTER COLUMN id SET DEFAULT nextval('public.encrypted_state_events_id_seq'::regclass); + + +-- +-- Name: account_passwords account_passwords_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account_passwords + ADD CONSTRAINT account_passwords_pkey PRIMARY KEY (user_id); + + +-- +-- Name: account_privacy_rules account_privacy_rules_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account_privacy_rules + ADD CONSTRAINT account_privacy_rules_pkey PRIMARY KEY (owner_user_id, privacy_key); + + +-- +-- Name: account_reaction_settings account_reaction_settings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account_reaction_settings + ADD CONSTRAINT account_reaction_settings_pkey PRIMARY KEY (user_id); + + +-- +-- Name: app_configs app_configs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.app_configs + ADD CONSTRAINT app_configs_pkey PRIMARY KEY (client); + + +-- +-- Name: auth_keys auth_keys_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auth_keys + ADD CONSTRAINT auth_keys_pkey PRIMARY KEY (auth_key_id); + + +-- +-- Name: authorizations authorizations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.authorizations + ADD CONSTRAINT authorizations_pkey PRIMARY KEY (auth_key_id); + + +-- +-- Name: available_reactions available_reactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.available_reactions + ADD CONSTRAINT available_reactions_pkey PRIMARY KEY (reaction); + + +-- +-- Name: bot_chat_states bot_chat_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bot_chat_states + ADD CONSTRAINT bot_chat_states_pkey PRIMARY KEY (bot_user_id, user_id); + + +-- +-- Name: bot_user_permissions bot_user_permissions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bot_user_permissions + ADD CONSTRAINT bot_user_permissions_pkey PRIMARY KEY (bot_user_id, user_id); + + +-- +-- Name: bots bots_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bots + ADD CONSTRAINT bots_pkey PRIMARY KEY (bot_user_id); + + +-- +-- Name: business_automation_deliveries business_automation_deliveries_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_automation_deliveries + ADD CONSTRAINT business_automation_deliveries_pkey PRIMARY KEY (owner_user_id, peer_user_id, kind, trigger_message_id); + + +-- +-- Name: business_chat_links business_chat_links_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_chat_links + ADD CONSTRAINT business_chat_links_pkey PRIMARY KEY (slug); + + +-- +-- Name: business_connected_bot_peer_states business_connected_bot_peer_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_connected_bot_peer_states + ADD CONSTRAINT business_connected_bot_peer_states_pkey PRIMARY KEY (owner_user_id, peer_user_id); + + +-- +-- Name: business_connected_bots business_connected_bots_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_connected_bots + ADD CONSTRAINT business_connected_bots_pkey PRIMARY KEY (owner_user_id); + + +-- +-- Name: channel_admin_log_events channel_admin_log_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_admin_log_events + ADD CONSTRAINT channel_admin_log_events_pkey PRIMARY KEY (channel_id, id); + + +-- +-- Name: channel_boost_slots channel_boost_slots_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_boost_slots + ADD CONSTRAINT channel_boost_slots_pkey PRIMARY KEY (user_id, slot); + + +-- +-- Name: channel_dialogs channel_dialogs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_dialogs + ADD CONSTRAINT channel_dialogs_pkey PRIMARY KEY (user_id, channel_id); + + +-- +-- Name: channel_forum_topics channel_forum_topics_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_forum_topics + ADD CONSTRAINT channel_forum_topics_pkey PRIMARY KEY (channel_id, topic_id); + + +-- +-- Name: channel_invite_hashes channel_invite_hashes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_invite_hashes + ADD CONSTRAINT channel_invite_hashes_pkey PRIMARY KEY (hash); + + +-- +-- Name: channel_invite_importers channel_invite_importers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_invite_importers + ADD CONSTRAINT channel_invite_importers_pkey PRIMARY KEY (channel_id, user_id); + + +-- +-- Name: channel_invites channel_invites_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_invites + ADD CONSTRAINT channel_invites_pkey PRIMARY KEY (channel_id, invite_id); + + +-- +-- Name: channel_media_category_counts channel_media_category_counts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_media_category_counts + ADD CONSTRAINT channel_media_category_counts_pkey PRIMARY KEY (channel_id, category); + + +-- +-- Name: channel_members channel_members_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_pkey PRIMARY KEY (channel_id, user_id); + + +-- +-- Name: channel_message_media channel_message_media_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_message_media + ADD CONSTRAINT channel_message_media_pkey PRIMARY KEY (channel_id, id, category); + + +-- +-- Name: channel_message_reactions channel_message_reactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_message_reactions + ADD CONSTRAINT channel_message_reactions_pkey PRIMARY KEY (channel_id, message_id, reacted_user_id, reaction_type, reaction_value); + + +-- +-- Name: channel_message_viewers channel_message_viewers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_message_viewers + ADD CONSTRAINT channel_message_viewers_pkey PRIMARY KEY (channel_id, message_id, viewer_user_id); + + +-- +-- Name: channel_messages channel_messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_messages + ADD CONSTRAINT channel_messages_pkey PRIMARY KEY (channel_id, id); + + +-- +-- Name: channel_unread_mention_index channel_unread_mention_index_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_unread_mention_index + ADD CONSTRAINT channel_unread_mention_index_pkey PRIMARY KEY (channel_id, message_id, user_id); + + +-- +-- Name: channel_unread_mentions channel_unread_mentions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_unread_mentions + ADD CONSTRAINT channel_unread_mentions_pkey PRIMARY KEY (user_id, channel_id, message_id); + + +-- +-- Name: channel_update_events channel_update_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_update_events + ADD CONSTRAINT channel_update_events_pkey PRIMARY KEY (channel_id, pts); + + +-- +-- Name: channel_usernames channel_usernames_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_usernames + ADD CONSTRAINT channel_usernames_pkey PRIMARY KEY (username_lower); + + +-- +-- Name: channels channels_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channels + ADD CONSTRAINT channels_pkey PRIMARY KEY (id); + + +-- +-- Name: contact_blocks contact_blocks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contact_blocks + ADD CONSTRAINT contact_blocks_pkey PRIMARY KEY (owner_user_id, blocked_user_id); + + +-- +-- Name: contacts contacts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_pkey PRIMARY KEY (user_id, contact_user_id); + + +-- +-- Name: countries countries_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.countries + ADD CONSTRAINT countries_pkey PRIMARY KEY (iso2); + + +-- +-- Name: country_codes country_codes_iso2_country_code_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.country_codes + ADD CONSTRAINT country_codes_iso2_country_code_key UNIQUE (iso2, country_code); + + +-- +-- Name: country_codes country_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.country_codes + ADD CONSTRAINT country_codes_pkey PRIMARY KEY (id); + + +-- +-- Name: dialog_drafts dialog_drafts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dialog_drafts + ADD CONSTRAINT dialog_drafts_pkey PRIMARY KEY (user_id, peer_type, peer_id, top_message_id); + + +-- +-- Name: dialog_filter_settings dialog_filter_settings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dialog_filter_settings + ADD CONSTRAINT dialog_filter_settings_pkey PRIMARY KEY (user_id); + + +-- +-- Name: dialog_filters dialog_filters_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dialog_filters + ADD CONSTRAINT dialog_filters_pkey PRIMARY KEY (user_id, filter_id); + + +-- +-- Name: dialogs dialogs_pkey1; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dialogs + ADD CONSTRAINT dialogs_pkey1 PRIMARY KEY (user_id, peer_type, peer_id); + + +-- +-- Name: dispatch_outbox dispatch_outbox_pkey1; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dispatch_outbox + ADD CONSTRAINT dispatch_outbox_pkey1 PRIMARY KEY (id); + + +-- +-- Name: dispatch_outbox dispatch_outbox_target_user_id_id_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dispatch_outbox + ADD CONSTRAINT dispatch_outbox_target_user_id_id_key UNIQUE (target_user_id, id); + + +-- +-- Name: documents documents_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.documents + ADD CONSTRAINT documents_pkey PRIMARY KEY (id); + + +-- +-- Name: encrypted_files encrypted_files_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.encrypted_files + ADD CONSTRAINT encrypted_files_pkey PRIMARY KEY (id); + + +-- +-- Name: encrypted_message_queue encrypted_message_queue_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.encrypted_message_queue + ADD CONSTRAINT encrypted_message_queue_pkey PRIMARY KEY (receiver_auth_key_id, qts); + + +-- +-- Name: encrypted_state_event_delivery encrypted_state_event_delivery_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.encrypted_state_event_delivery + ADD CONSTRAINT encrypted_state_event_delivery_pkey PRIMARY KEY (event_id, auth_key_id); + + +-- +-- Name: encrypted_state_events encrypted_state_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.encrypted_state_events + ADD CONSTRAINT encrypted_state_events_pkey PRIMARY KEY (id); + + +-- +-- Name: file_blobs file_blobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.file_blobs + ADD CONSTRAINT file_blobs_pkey PRIMARY KEY (location_key); + + +-- +-- Name: group_call_participant_overrides group_call_participant_overrides_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_call_participant_overrides + ADD CONSTRAINT group_call_participant_overrides_pkey PRIMARY KEY (call_id, setter_user_id, target_user_id); + + +-- +-- Name: group_call_participants group_call_participants_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_call_participants + ADD CONSTRAINT group_call_participants_pkey PRIMARY KEY (call_id, user_id); + + +-- +-- Name: group_calls group_calls_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_calls + ADD CONSTRAINT group_calls_pkey PRIMARY KEY (call_id); + + +-- +-- Name: lang_pack_strings lang_pack_strings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lang_pack_strings + ADD CONSTRAINT lang_pack_strings_pkey PRIMARY KEY (lang_pack, lang_code, key); + + +-- +-- Name: lang_packs lang_packs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lang_packs + ADD CONSTRAINT lang_packs_pkey PRIMARY KEY (lang_pack, lang_code); + + +-- +-- Name: message_box_media message_box_media_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_box_media + ADD CONSTRAINT message_box_media_pkey PRIMARY KEY (owner_user_id, box_id, category); + + +-- +-- Name: message_boxes message_boxes_owner_user_id_private_message_id_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_boxes + ADD CONSTRAINT message_boxes_owner_user_id_private_message_id_key UNIQUE (owner_user_id, private_message_id); + + +-- +-- Name: message_boxes message_boxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_boxes + ADD CONSTRAINT message_boxes_pkey PRIMARY KEY (owner_user_id, box_id); + + +-- +-- Name: photos photos_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.photos + ADD CONSTRAINT photos_pkey PRIMARY KEY (id); + + +-- +-- Name: poll_votes poll_votes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.poll_votes + ADD CONSTRAINT poll_votes_pkey PRIMARY KEY (poll_id, user_id); + + +-- +-- Name: polls polls_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.polls + ADD CONSTRAINT polls_pkey PRIMARY KEY (poll_id); + + +-- +-- Name: private_media_category_counts private_media_category_counts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.private_media_category_counts + ADD CONSTRAINT private_media_category_counts_pkey PRIMARY KEY (owner_user_id, peer_id, category); + + +-- +-- Name: private_message_reactions private_message_reactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.private_message_reactions + ADD CONSTRAINT private_message_reactions_pkey PRIMARY KEY (message_sender_id, private_message_id, user_id, reaction_type, reaction_value); + + +-- +-- Name: private_messages private_messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.private_messages + ADD CONSTRAINT private_messages_pkey PRIMARY KEY (sender_user_id, id); + + +-- +-- Name: profile_photos profile_photos_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.profile_photos + ADD CONSTRAINT profile_photos_pkey PRIMARY KEY (owner_peer_type, owner_peer_id, kind, photo_id); + + +-- +-- Name: quick_replies quick_replies_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quick_replies + ADD CONSTRAINT quick_replies_pkey PRIMARY KEY (owner_user_id, shortcut_id); + + +-- +-- Name: quick_reply_messages quick_reply_messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quick_reply_messages + ADD CONSTRAINT quick_reply_messages_pkey PRIMARY KEY (owner_user_id, shortcut_id, message_id); + + +-- +-- Name: read_model_versions read_model_versions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.read_model_versions + ADD CONSTRAINT read_model_versions_pkey PRIMARY KEY (model, owner_user_id, peer_type, peer_id); + + +-- +-- Name: saved_dialog_pins saved_dialog_pins_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.saved_dialog_pins + ADD CONSTRAINT saved_dialog_pins_pkey PRIMARY KEY (user_id, peer_type, peer_id); + + +-- +-- Name: saved_music saved_music_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.saved_music + ADD CONSTRAINT saved_music_pkey PRIMARY KEY (user_id, document_id); + + +-- +-- Name: scheduled_messages scheduled_messages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.scheduled_messages + ADD CONSTRAINT scheduled_messages_pkey PRIMARY KEY (owner_user_id, scheduled_id); + + +-- +-- Name: secret_chats secret_chats_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.secret_chats + ADD CONSTRAINT secret_chats_pkey PRIMARY KEY (chat_id); + + +-- +-- Name: secret_qts_watermarks secret_qts_watermarks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.secret_qts_watermarks + ADD CONSTRAINT secret_qts_watermarks_pkey PRIMARY KEY (auth_key_id); + + +-- +-- Name: sticker_sets sticker_sets_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.sticker_sets + ADD CONSTRAINT sticker_sets_pkey PRIMARY KEY (id); + + +-- +-- Name: stories stories_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.stories + ADD CONSTRAINT stories_pkey PRIMARY KEY (owner_peer_type, owner_peer_id, story_id); + + +-- +-- Name: story_hidden_peers story_hidden_peers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.story_hidden_peers + ADD CONSTRAINT story_hidden_peers_pkey PRIMARY KEY (viewer_user_id, owner_peer_type, owner_peer_id); + + +-- +-- Name: story_read_states story_read_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.story_read_states + ADD CONSTRAINT story_read_states_pkey PRIMARY KEY (viewer_user_id, owner_peer_type, owner_peer_id); + + +-- +-- Name: story_views story_views_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.story_views + ADD CONSTRAINT story_views_pkey PRIMARY KEY (owner_peer_type, owner_peer_id, story_id, viewer_user_id); + + +-- +-- Name: temp_auth_key_bindings temp_auth_key_bindings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.temp_auth_key_bindings + ADD CONSTRAINT temp_auth_key_bindings_pkey PRIMARY KEY (temp_auth_key_id); + + +-- +-- Name: update_states update_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.update_states + ADD CONSTRAINT update_states_pkey PRIMARY KEY (auth_key_id, user_id); + + +-- +-- Name: upload_parts upload_parts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.upload_parts + ADD CONSTRAINT upload_parts_pkey PRIMARY KEY (owner_user_id, file_id, part); + + +-- +-- Name: user_business_profiles user_business_profiles_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_business_profiles + ADD CONSTRAINT user_business_profiles_pkey PRIMARY KEY (user_id); + + +-- +-- Name: user_channel_member_index user_channel_member_index_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_channel_member_index + ADD CONSTRAINT user_channel_member_index_pkey PRIMARY KEY (user_id, channel_id); + + +-- +-- Name: user_recent_reactions user_recent_reactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_recent_reactions + ADD CONSTRAINT user_recent_reactions_pkey PRIMARY KEY (user_id, reaction_type, reaction_value); + + +-- +-- Name: user_saved_reaction_tags user_saved_reaction_tags_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_saved_reaction_tags + ADD CONSTRAINT user_saved_reaction_tags_pkey PRIMARY KEY (user_id, reaction_type, reaction_value); + + +-- +-- Name: user_top_reactions user_top_reactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_top_reactions + ADD CONSTRAINT user_top_reactions_pkey PRIMARY KEY (user_id, reaction_type, reaction_value); + + +-- +-- Name: user_update_events user_update_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_update_events + ADD CONSTRAINT user_update_events_pkey PRIMARY KEY (user_id, pts); + + +-- +-- Name: user_update_watermarks user_update_watermarks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_update_watermarks + ADD CONSTRAINT user_update_watermarks_pkey PRIMARY KEY (user_id); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: authorizations_user_hash_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX authorizations_user_hash_idx ON public.authorizations USING btree (user_id, hash); + + +-- +-- Name: authorizations_user_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX authorizations_user_id_idx ON public.authorizations USING btree (user_id); + + +-- +-- Name: bot_user_permissions_user_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bot_user_permissions_user_id_idx ON public.bot_user_permissions USING btree (user_id); + + +-- +-- Name: bots_owner_user_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX bots_owner_user_id_idx ON public.bots USING btree (owner_user_id); + + +-- +-- Name: business_automation_deliveries_last_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX business_automation_deliveries_last_idx ON public.business_automation_deliveries USING btree (owner_user_id, peer_user_id, kind, sent_at DESC, trigger_message_id DESC); + + +-- +-- Name: business_chat_links_owner_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX business_chat_links_owner_idx ON public.business_chat_links USING btree (owner_user_id, created_at, slug); + + +-- +-- Name: business_connected_bot_peer_states_owner_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX business_connected_bot_peer_states_owner_idx ON public.business_connected_bot_peer_states USING btree (owner_user_id, disabled, peer_user_id); + + +-- +-- Name: business_connected_bots_bot_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX business_connected_bots_bot_idx ON public.business_connected_bots USING btree (bot_user_id, owner_user_id); + + +-- +-- Name: channel_admin_log_events_actor_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_admin_log_events_actor_idx ON public.channel_admin_log_events USING btree (channel_id, actor_user_id, id DESC); + + +-- +-- Name: channel_admin_log_events_scan_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_admin_log_events_scan_idx ON public.channel_admin_log_events USING btree (channel_id, id DESC); + + +-- +-- Name: channel_admin_log_events_type_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_admin_log_events_type_idx ON public.channel_admin_log_events USING btree (channel_id, event_type, id DESC); + + +-- +-- Name: channel_boost_slots_peer_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_boost_slots_peer_idx ON public.channel_boost_slots USING btree (peer_type, peer_id, expires_at DESC, assigned_at DESC, user_id, slot) WHERE (peer_id <> 0); + + +-- +-- Name: channel_boost_slots_user_peer_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_boost_slots_user_peer_idx ON public.channel_boost_slots USING btree (user_id, peer_type, peer_id, slot) WHERE (peer_id <> 0); + + +-- +-- Name: channel_dialogs_pinned_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_dialogs_pinned_idx ON public.channel_dialogs USING btree (user_id, pinned) WHERE pinned; + + +-- +-- Name: channel_dialogs_user_top_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_dialogs_user_top_idx ON public.channel_dialogs USING btree (user_id, folder_id, pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC); + + +-- +-- Name: channel_forum_topics_page_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_forum_topics_page_idx ON public.channel_forum_topics USING btree (channel_id, pinned DESC, pinned_order DESC, date DESC, topic_id DESC) WHERE (NOT deleted); + + +-- +-- Name: channel_forum_topics_title_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_forum_topics_title_idx ON public.channel_forum_topics USING btree (channel_id, lower(title), date DESC, topic_id DESC) WHERE (NOT deleted); + + +-- +-- Name: channel_invite_importers_link_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_invite_importers_link_idx ON public.channel_invite_importers USING btree (channel_id, invite_id, requested, date DESC, user_id DESC); + + +-- +-- Name: channel_invite_importers_requested_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_invite_importers_requested_idx ON public.channel_invite_importers USING btree (channel_id, requested, date DESC, user_id DESC); + + +-- +-- Name: channel_invites_hash_lookup_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_invites_hash_lookup_idx ON public.channel_invite_hashes USING btree (hash, channel_id, invite_id); + + +-- +-- Name: channel_members_channel_active_user_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_members_channel_active_user_idx ON public.channel_members USING btree (channel_id, user_id) WHERE ((status)::text = 'active'::text); + + +-- +-- Name: channel_members_channel_role_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_members_channel_role_idx ON public.channel_members USING btree (channel_id, role, user_id) WHERE ((status)::text = 'active'::text); + + +-- +-- Name: channel_members_read_participants_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_members_read_participants_idx ON public.channel_members USING btree (channel_id, read_inbox_max_id, user_id) WHERE ((status)::text = 'active'::text); + + +-- +-- Name: channel_members_user_active_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_members_user_active_idx ON public.channel_members USING btree (user_id, channel_id) WHERE ((status)::text = 'active'::text); + + +-- +-- Name: channel_members_user_left_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_members_user_left_idx ON public.channel_members USING btree (user_id, left_at DESC, channel_id DESC) WHERE ((status)::text = 'left'::text); + + +-- +-- Name: channel_message_media_seek_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_message_media_seek_idx ON public.channel_message_media USING btree (channel_id, category, id DESC); + + +-- +-- Name: channel_message_reactions_msg_date_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_message_reactions_msg_date_idx ON public.channel_message_reactions USING btree (channel_id, message_id, reaction_date DESC, reacted_user_id DESC, reaction_value); + + +-- +-- Name: channel_message_reactions_unread_owner_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_message_reactions_unread_owner_idx ON public.channel_message_reactions USING btree (channel_id, sender_user_id, message_id DESC) WHERE unread; + + +-- +-- Name: channel_message_reactions_value_date_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_message_reactions_value_date_idx ON public.channel_message_reactions USING btree (channel_id, message_id, reaction_type, reaction_value, reaction_date DESC, reacted_user_id DESC); + + +-- +-- Name: channel_messages_body_trgm_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_messages_body_trgm_idx ON public.channel_messages USING gin (body public.gin_trgm_ops) WHERE ((NOT deleted) AND (body <> ''::text)); + + +-- +-- Name: channel_messages_date_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_messages_date_idx ON public.channel_messages USING btree (channel_id, message_date DESC, id DESC) WHERE (NOT deleted); + + +-- +-- Name: channel_messages_discussion_ref_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_messages_discussion_ref_idx ON public.channel_messages USING btree (discussion_channel_id, discussion_message_id) WHERE ((discussion_channel_id <> 0) AND (discussion_message_id <> 0) AND (NOT deleted)); + + +-- +-- Name: channel_messages_expiry_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_messages_expiry_idx ON public.channel_messages USING btree (expires_at, channel_id, id) WHERE ((expires_at > 0) AND (NOT deleted)); + + +-- +-- Name: channel_messages_history_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_messages_history_idx ON public.channel_messages USING btree (channel_id, id DESC) WHERE (NOT deleted); + + +-- +-- Name: channel_messages_pinned_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_messages_pinned_idx ON public.channel_messages USING btree (channel_id, id DESC) WHERE pinned; + + +-- +-- Name: channel_messages_random_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX channel_messages_random_idx ON public.channel_messages USING btree (channel_id, sender_user_id, random_id) WHERE (random_id <> 0); + + +-- +-- Name: channel_messages_reply_thread_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_messages_reply_thread_idx ON public.channel_messages USING btree (channel_id, reply_to_top_id, id DESC) WHERE ((reply_to_top_id > 0) AND (NOT deleted)); + + +-- +-- Name: channel_messages_sender_history_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_messages_sender_history_idx ON public.channel_messages USING btree (channel_id, sender_user_id, id DESC) WHERE (NOT deleted); + + +-- +-- Name: channel_messages_story_forward_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_messages_story_forward_idx ON public.channel_messages USING btree (((media #>> '{story,peer,type}'::text[])), ((media #>> '{story,peer,id}'::text[])), ((media #>> '{story,id}'::text[])), message_date DESC, channel_id, id DESC) WHERE ((NOT deleted) AND ((media ->> 'kind'::text) = 'story'::text)); + + +-- +-- Name: channel_unread_mention_index_user_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_unread_mention_index_user_idx ON public.channel_unread_mention_index USING btree (channel_id, user_id, message_id); + + +-- +-- Name: channel_unread_mentions_peer_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_unread_mentions_peer_idx ON public.channel_unread_mentions USING btree (user_id, channel_id, top_message_id, message_id DESC); + + +-- +-- Name: channel_update_events_scan_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channel_update_events_scan_idx ON public.channel_update_events USING btree (channel_id, pts); + + +-- +-- Name: channels_access_hash_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX channels_access_hash_idx ON public.channels USING btree (id, access_hash); + + +-- +-- Name: channels_creator_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channels_creator_idx ON public.channels USING btree (creator_user_id, id DESC) WHERE (NOT deleted); + + +-- +-- Name: channels_linked_chat_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channels_linked_chat_idx ON public.channels USING btree (linked_chat_id) WHERE ((linked_chat_id <> 0) AND (NOT deleted)); + + +-- +-- Name: channels_public_broadcast_recommendations_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channels_public_broadcast_recommendations_idx ON public.channels USING btree (participants_count DESC, date DESC, id DESC) WHERE (broadcast AND (NOT megagroup) AND (NOT deleted) AND (COALESCE(username, ''::text) <> ''::text)); + + +-- +-- Name: channels_public_title_trgm_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channels_public_title_trgm_idx ON public.channels USING gin (lower(title) public.gin_trgm_ops) WHERE ((NOT deleted) AND (COALESCE(username, ''::text) <> ''::text)); + + +-- +-- Name: channels_public_username_trgm_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX channels_public_username_trgm_idx ON public.channels USING gin (lower(username) public.gin_trgm_ops) WHERE ((NOT deleted) AND (COALESCE(username, ''::text) <> ''::text)); + + +-- +-- Name: contact_blocks_owner_date_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contact_blocks_owner_date_idx ON public.contact_blocks USING btree (owner_user_id, date DESC, blocked_user_id DESC); + + +-- +-- Name: contacts_contact_user_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contacts_contact_user_id_idx ON public.contacts USING btree (contact_user_id); + + +-- +-- Name: contacts_personal_photo_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contacts_personal_photo_idx ON public.contacts USING btree (user_id, contact_user_id) WHERE (personal_photo_id <> 0); + + +-- +-- Name: contacts_user_name_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contacts_user_name_idx ON public.contacts USING btree (user_id, contact_first_name, contact_last_name, contact_user_id); + + +-- +-- Name: contacts_user_saved_name_trgm_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX contacts_user_saved_name_trgm_idx ON public.contacts USING gin (lower(TRIM(BOTH FROM (((contact_first_name)::text || ' '::text) || (contact_last_name)::text))) public.gin_trgm_ops); + + +-- +-- Name: dialog_drafts_user_date_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dialog_drafts_user_date_idx ON public.dialog_drafts USING btree (user_id, date DESC, peer_type, peer_id, top_message_id); + + +-- +-- Name: dialog_filters_user_order_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dialog_filters_user_order_idx ON public.dialog_filters USING btree (user_id, order_value, filter_id); + + +-- +-- Name: dialogs_user_folder_top_message_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dialogs_user_folder_top_message_idx ON public.dialogs USING btree (user_id, folder_id, pinned DESC, top_message_date DESC, top_message_id DESC, peer_id DESC); + + +-- +-- Name: dialogs_user_pinned_order_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dialogs_user_pinned_order_idx ON public.dialogs USING btree (user_id, pinned, pinned_order, top_message_date DESC, top_message_id DESC, peer_id DESC) WHERE pinned; + + +-- +-- Name: dispatch_outbox_dispatching_stale_ready_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dispatch_outbox_dispatching_stale_ready_idx ON public.dispatch_outbox USING btree (updated_at, target_user_id, pts, id) WHERE ((status)::text = 'dispatching'::text); + + +-- +-- Name: dispatch_outbox_failed_cleanup_ready_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dispatch_outbox_failed_cleanup_ready_idx ON public.dispatch_outbox USING btree (updated_at, target_user_id, id) WHERE ((status)::text = 'failed'::text); + + +-- +-- Name: dispatch_outbox_pending_ready_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX dispatch_outbox_pending_ready_idx ON public.dispatch_outbox USING btree (next_attempt_at, target_user_id, pts, id) WHERE ((status)::text = 'pending'::text); + + +-- +-- Name: group_call_participants_ssrc_uniq; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX group_call_participants_ssrc_uniq ON public.group_call_participants USING btree (call_id, ssrc) WHERE (NOT left_call); + + +-- +-- Name: group_call_participants_sweep_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX group_call_participants_sweep_idx ON public.group_call_participants USING btree (call_id, last_check_date) WHERE (NOT left_call); + + +-- +-- Name: group_calls_active_channel_uniq; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX group_calls_active_channel_uniq ON public.group_calls USING btree (channel_id) WHERE (state = 'active'::text); + + +-- +-- Name: idx_emq_unacked; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_emq_unacked ON public.encrypted_message_queue USING btree (receiver_auth_key_id, qts) WHERE (acked = false); + + +-- +-- Name: idx_ese_target; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ese_target ON public.encrypted_state_events USING btree (target_user_id, id); + + +-- +-- Name: idx_secret_chats_admin; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_secret_chats_admin ON public.secret_chats USING btree (admin_user_id); + + +-- +-- Name: idx_secret_chats_participant; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_secret_chats_participant ON public.secret_chats USING btree (participant_user_id); + + +-- +-- Name: lang_pack_strings_pack_version_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX lang_pack_strings_pack_version_idx ON public.lang_pack_strings USING btree (lang_pack, lang_code, version); + + +-- +-- Name: message_box_media_seek_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_box_media_seek_idx ON public.message_box_media USING btree (owner_user_id, peer_id, category, box_id DESC); + + +-- +-- Name: message_boxes_body_trgm_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_body_trgm_idx ON public.message_boxes USING gin (body public.gin_trgm_ops) WHERE ((NOT deleted) AND (body <> ''::text)); + + +-- +-- Name: message_boxes_dialog_date_seek_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_dialog_date_seek_idx ON public.message_boxes USING btree (owner_user_id, peer_type, peer_id, message_date DESC, box_id DESC) WHERE (NOT deleted); + + +-- +-- Name: message_boxes_dialog_seek_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_dialog_seek_idx ON public.message_boxes USING btree (owner_user_id, peer_type, peer_id, box_id DESC) WHERE (NOT deleted); + + +-- +-- Name: message_boxes_expiry_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_expiry_idx ON public.message_boxes USING btree (expires_at, owner_user_id, box_id) WHERE ((expires_at > 0) AND (NOT deleted)); + + +-- +-- Name: message_boxes_owner_date_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_owner_date_idx ON public.message_boxes USING btree (owner_user_id, message_date DESC, box_id DESC) WHERE (NOT deleted); + + +-- +-- Name: message_boxes_pinned_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_pinned_idx ON public.message_boxes USING btree (owner_user_id, peer_type, peer_id, box_id DESC) WHERE (pinned AND (NOT deleted)); + + +-- +-- Name: message_boxes_private_lookup_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_private_lookup_idx ON public.message_boxes USING btree (private_message_id, owner_user_id); + + +-- +-- Name: message_boxes_private_sender_live_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_private_sender_live_idx ON public.message_boxes USING btree (message_sender_id, private_message_id) WHERE (NOT deleted); + + +-- +-- Name: message_boxes_private_sender_owner_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_private_sender_owner_idx ON public.message_boxes USING btree (message_sender_id, private_message_id, owner_user_id) WHERE (NOT deleted); + + +-- +-- Name: message_boxes_read_receipt_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_read_receipt_idx ON public.message_boxes USING btree (owner_user_id, peer_type, peer_id, box_id DESC) WHERE ((NOT deleted) AND (NOT outgoing)); + + +-- +-- Name: message_boxes_reply_lookup_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_reply_lookup_idx ON public.message_boxes USING btree (owner_user_id, peer_type, peer_id, box_id) WHERE (NOT deleted); + + +-- +-- Name: message_boxes_saved_dialog_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX message_boxes_saved_dialog_idx ON public.message_boxes USING btree (owner_user_id, saved_peer_type, saved_peer_id, box_id DESC) WHERE ((NOT deleted) AND ((saved_peer_type)::text <> ''::text)); + + +-- +-- Name: poll_votes_poll_date_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX poll_votes_poll_date_idx ON public.poll_votes USING btree (poll_id, vote_date DESC, user_id DESC); + + +-- +-- Name: private_message_reactions_message_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX private_message_reactions_message_idx ON public.private_message_reactions USING btree (message_sender_id, private_message_id, reaction_date DESC, user_id); + + +-- +-- Name: private_messages_recipient_date_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX private_messages_recipient_date_idx ON public.private_messages USING btree (recipient_user_id, message_date DESC, id DESC); + + +-- +-- Name: private_messages_sender_random_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX private_messages_sender_random_idx ON public.private_messages USING btree (sender_user_id, random_id) WHERE (random_id <> 0); + + +-- +-- Name: profile_photos_current_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX profile_photos_current_idx ON public.profile_photos USING btree (owner_peer_type, owner_peer_id, kind, sort_order DESC) WHERE active; + + +-- +-- Name: quick_replies_owner_order_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX quick_replies_owner_order_idx ON public.quick_replies USING btree (owner_user_id, sort_order, shortcut_id); + + +-- +-- Name: quick_replies_owner_shortcut_lower_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX quick_replies_owner_shortcut_lower_idx ON public.quick_replies USING btree (owner_user_id, lower(shortcut)); + + +-- +-- Name: quick_reply_messages_owner_shortcut_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX quick_reply_messages_owner_shortcut_idx ON public.quick_reply_messages USING btree (owner_user_id, shortcut_id, message_id); + + +-- +-- Name: read_model_versions_owner_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX read_model_versions_owner_idx ON public.read_model_versions USING btree (owner_user_id, model); + + +-- +-- Name: saved_dialog_pins_order_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX saved_dialog_pins_order_idx ON public.saved_dialog_pins USING btree (user_id, pinned_order, peer_id); + + +-- +-- Name: saved_music_user_order_document_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX saved_music_user_order_document_idx ON public.saved_music USING btree (user_id, sort_order, document_id); + + +-- +-- Name: saved_music_user_order_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX saved_music_user_order_idx ON public.saved_music USING btree (user_id, sort_order); + + +-- +-- Name: scheduled_messages_due_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX scheduled_messages_due_idx ON public.scheduled_messages USING btree (schedule_date, owner_user_id, scheduled_id) WHERE ((state)::text = 'pending'::text); + + +-- +-- Name: scheduled_messages_lease_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX scheduled_messages_lease_idx ON public.scheduled_messages USING btree (lease_until, owner_user_id, scheduled_id) WHERE ((state)::text = 'dispatching'::text); + + +-- +-- Name: scheduled_messages_owner_random_pending_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX scheduled_messages_owner_random_pending_idx ON public.scheduled_messages USING btree (owner_user_id, random_id) WHERE ((random_id <> 0) AND ((state)::text = ANY (ARRAY[('pending'::character varying)::text, ('dispatching'::character varying)::text]))); + + +-- +-- Name: scheduled_messages_peer_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX scheduled_messages_peer_idx ON public.scheduled_messages USING btree (owner_user_id, peer_type, peer_id, schedule_date, scheduled_id) WHERE ((state)::text = ANY (ARRAY[('pending'::character varying)::text, ('dispatching'::character varying)::text])); + + +-- +-- Name: sticker_sets_kind_order_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX sticker_sets_kind_order_idx ON public.sticker_sets USING btree (set_kind, sort_order, id); + + +-- +-- Name: sticker_sets_short_name_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX sticker_sets_short_name_idx ON public.sticker_sets USING btree (short_name) WHERE (short_name <> ''::text); + + +-- +-- Name: sticker_sets_system_key_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX sticker_sets_system_key_idx ON public.sticker_sets USING btree (system_key) WHERE (system_key <> ''::text); + + +-- +-- Name: stories_active_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stories_active_idx ON public.stories USING btree (public, expire_date DESC, date DESC, owner_peer_type, owner_peer_id, story_id DESC) WHERE (deleted = false); + + +-- +-- Name: stories_allow_user_ids_gin_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stories_allow_user_ids_gin_idx ON public.stories USING gin (allow_user_ids) WHERE (deleted = false); + + +-- +-- Name: stories_disallow_user_ids_gin_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stories_disallow_user_ids_gin_idx ON public.stories USING gin (disallow_user_ids) WHERE (deleted = false); + + +-- +-- Name: stories_owner_active_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stories_owner_active_idx ON public.stories USING btree (owner_peer_type, owner_peer_id, expire_date DESC, story_id) WHERE (deleted = false); + + +-- +-- Name: stories_owner_archive_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stories_owner_archive_idx ON public.stories USING btree (owner_peer_type, owner_peer_id, story_id DESC) WHERE (deleted = false); + + +-- +-- Name: stories_owner_pinned_to_top_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX stories_owner_pinned_to_top_idx ON public.stories USING btree (owner_peer_type, owner_peer_id, pinned_to_top_order, story_id DESC) WHERE ((deleted = false) AND (pinned = true) AND (pinned_to_top_order > 0)); + + +-- +-- Name: stories_random_id_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX stories_random_id_unique ON public.stories USING btree (owner_peer_type, owner_peer_id, random_id) WHERE (random_id <> 0); + + +-- +-- Name: story_read_states_viewer_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX story_read_states_viewer_idx ON public.story_read_states USING btree (viewer_user_id, owner_peer_type, owner_peer_id); + + +-- +-- Name: story_views_story_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX story_views_story_idx ON public.story_views USING btree (owner_peer_type, owner_peer_id, story_id, date DESC); + + +-- +-- Name: story_views_viewer_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX story_views_viewer_idx ON public.story_views USING btree (viewer_user_id, owner_peer_type, owner_peer_id, story_id); + + +-- +-- Name: update_states_user_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX update_states_user_id_idx ON public.update_states USING btree (user_id); + + +-- +-- Name: upload_parts_created_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX upload_parts_created_at_idx ON public.upload_parts USING btree (created_at); + + +-- +-- Name: upload_parts_object_key_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX upload_parts_object_key_idx ON public.upload_parts USING btree (object_key); + + +-- +-- Name: uq_emq_dedup; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX uq_emq_dedup ON public.encrypted_message_queue USING btree (receiver_auth_key_id, chat_id, random_id); + + +-- +-- Name: uq_secret_chats_admin_random; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX uq_secret_chats_admin_random ON public.secret_chats USING btree (admin_auth_key_id, random_id) WHERE (state <> 'discarded'::text); + + +-- +-- Name: user_channel_member_index_admined_public_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_channel_member_index_admined_public_idx ON public.user_channel_member_index USING btree (user_id, channel_id DESC) WHERE (((status)::text = 'active'::text) AND ((role)::text = ANY (ARRAY[('creator'::character varying)::text, ('admin'::character varying)::text])) AND public_username AND (NOT deleted)); + + +-- +-- Name: user_channel_member_index_common_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_channel_member_index_common_idx ON public.user_channel_member_index USING btree (user_id, channel_id) WHERE (((status)::text = 'active'::text) AND megagroup AND (NOT broadcast) AND (NOT deleted)); + + +-- +-- Name: user_channel_member_index_discussion_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_channel_member_index_discussion_idx ON public.user_channel_member_index USING btree (user_id, channel_id DESC) WHERE (((status)::text = 'active'::text) AND megagroup AND (NOT broadcast) AND (NOT forum) AND (NOT deleted) AND (((role)::text = 'creator'::text) OR can_pin_messages)); + + +-- +-- Name: user_channel_member_index_left_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_channel_member_index_left_idx ON public.user_channel_member_index USING btree (user_id, left_at DESC, channel_id DESC) WHERE (((status)::text = 'left'::text) AND (NOT deleted) AND (megagroup OR broadcast)); + + +-- +-- Name: user_recent_reactions_user_date_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_recent_reactions_user_date_idx ON public.user_recent_reactions USING btree (user_id, reaction_date DESC, updated_at DESC, reaction_value); + + +-- +-- Name: user_saved_reaction_tags_user_order_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_saved_reaction_tags_user_order_idx ON public.user_saved_reaction_tags USING btree (user_id, reaction_count DESC, updated_at DESC, reaction_value); + + +-- +-- Name: user_top_reactions_user_rank_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_top_reactions_user_rank_idx ON public.user_top_reactions USING btree (user_id, reaction_count DESC, reaction_date DESC, updated_at DESC, reaction_value); + + +-- +-- Name: user_update_events_read_outbox_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_update_events_read_outbox_idx ON public.user_update_events USING btree (user_id, peer_type, peer_id, max_id, date) WHERE ((event_type)::text = 'read_history_outbox'::text); + + +-- +-- Name: user_update_events_retention_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX user_update_events_retention_idx ON public.user_update_events USING btree (user_id, date, pts); + + +-- +-- Name: users_last_seen_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_last_seen_idx ON public.users USING btree (last_seen_at DESC) WHERE (last_seen_at > 0); + + +-- +-- Name: users_name_lower_trgm_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_name_lower_trgm_idx ON public.users USING gin (lower(TRIM(BOTH FROM (((first_name)::text || ' '::text) || (last_name)::text))) public.gin_trgm_ops); + + +-- +-- Name: users_phone_prefix_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_phone_prefix_idx ON public.users USING btree (phone text_pattern_ops); + + +-- +-- Name: users_phone_unique_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX users_phone_unique_idx ON public.users USING btree (phone) WHERE ((phone)::text <> ''::text); + + +-- +-- Name: users_premium_expires_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_premium_expires_at_idx ON public.users USING btree (premium_expires_at) WHERE (premium_expires_at IS NOT NULL); + + +-- +-- Name: users_username_lower_trgm_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX users_username_lower_trgm_idx ON public.users USING gin (lower((username)::text) public.gin_trgm_ops); + + +-- +-- Name: users_username_lower_unique_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX users_username_lower_unique_idx ON public.users USING btree (lower((username)::text)) WHERE ((username)::text <> ''::text); + + +-- +-- Name: account_privacy_rules account_privacy_rules_channel_participants_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER account_privacy_rules_channel_participants_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.account_privacy_rules FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_privacy_channel_participants_read_model(); + + +-- +-- Name: account_privacy_rules account_privacy_rules_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER account_privacy_rules_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.account_privacy_rules FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_privacy_read_model(); + + +-- +-- Name: bots bots_channel_participants_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER bots_channel_participants_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.bots FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_bot_channel_participants_read_model(); + + +-- +-- Name: channel_boost_slots channel_boost_slots_self_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER channel_boost_slots_self_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.channel_boost_slots FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_channel_self_boosts_read_model(); + + +-- +-- Name: channel_dialogs channel_dialogs_dialog_light_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER channel_dialogs_dialog_light_changed AFTER INSERT OR DELETE OR UPDATE ON public.channel_dialogs FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_channel_dialog_light_read_model(); + + +-- +-- Name: channel_members channel_members_participants_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER channel_members_participants_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.channel_members FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_channel_participants_read_model(); + + +-- +-- Name: channel_members channel_members_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER channel_members_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.channel_members FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_channel_member_read_model(); + + +-- +-- Name: channel_message_media channel_message_media_category_counts_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER channel_message_media_category_counts_changed AFTER INSERT OR DELETE OR UPDATE OF channel_id, id, category ON public.channel_message_media FOR EACH ROW EXECUTE FUNCTION public.telesrv_maintain_channel_media_category_counts(); + + +-- +-- Name: channel_message_media channel_message_media_count_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER channel_message_media_count_changed AFTER INSERT OR DELETE OR UPDATE ON public.channel_message_media FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_channel_media_count_read_model(); + + +-- +-- Name: channel_messages channel_messages_media_category_count_visibility_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER channel_messages_media_category_count_visibility_changed AFTER UPDATE OF deleted ON public.channel_messages FOR EACH ROW WHEN ((old.deleted IS DISTINCT FROM new.deleted)) EXECUTE FUNCTION public.telesrv_maintain_channel_media_visibility_counts(); + + +-- +-- Name: channel_messages channel_messages_media_count_visibility_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER channel_messages_media_count_visibility_changed AFTER UPDATE OF deleted ON public.channel_messages FOR EACH ROW WHEN ((old.deleted IS DISTINCT FROM new.deleted)) EXECUTE FUNCTION public.telesrv_notify_channel_media_count_visibility_read_model(); + + +-- +-- Name: channels channels_notify_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER channels_notify_changed AFTER INSERT OR DELETE OR UPDATE ON public.channels FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_channel_changed(); + + +-- +-- Name: channels channels_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER channels_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.channels FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_channel_base_read_model(); + + +-- +-- Name: contact_blocks contact_blocks_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER contact_blocks_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.contact_blocks FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_contact_block_read_model(); + + +-- +-- Name: contacts contacts_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER contacts_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.contacts FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_contact_read_model(); + + +-- +-- Name: dialog_drafts dialog_drafts_dialog_light_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER dialog_drafts_dialog_light_changed AFTER INSERT OR DELETE OR UPDATE ON public.dialog_drafts FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_dialog_light_read_model(); + + +-- +-- Name: dialogs dialogs_dialog_light_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER dialogs_dialog_light_changed AFTER INSERT OR DELETE OR UPDATE ON public.dialogs FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_dialog_light_read_model(); + + +-- +-- Name: dialogs dialogs_private_media_count_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER dialogs_private_media_count_read_model_changed AFTER INSERT OR DELETE OR UPDATE OF user_id, peer_type, peer_id ON public.dialogs FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_private_media_count_dialog_read_model(); + + +-- +-- Name: message_box_media message_box_media_category_counts_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER message_box_media_category_counts_changed AFTER INSERT OR DELETE OR UPDATE OF owner_user_id, box_id, peer_id, category ON public.message_box_media FOR EACH ROW EXECUTE FUNCTION public.telesrv_maintain_private_media_category_counts(); + + +-- +-- Name: message_box_media message_box_media_count_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER message_box_media_count_changed AFTER INSERT OR DELETE OR UPDATE OF owner_user_id, peer_id, category ON public.message_box_media FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_private_media_count_read_model(); + + +-- +-- Name: message_boxes message_boxes_media_category_count_visibility_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER message_boxes_media_category_count_visibility_changed AFTER UPDATE OF deleted ON public.message_boxes FOR EACH ROW WHEN ((old.deleted IS DISTINCT FROM new.deleted)) EXECUTE FUNCTION public.telesrv_maintain_private_media_visibility_counts(); + + +-- +-- Name: message_boxes message_boxes_media_count_visibility_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER message_boxes_media_count_visibility_changed AFTER UPDATE OF deleted ON public.message_boxes FOR EACH ROW WHEN ((old.deleted IS DISTINCT FROM new.deleted)) EXECUTE FUNCTION public.telesrv_notify_private_media_count_visibility_read_model(); + + +-- +-- Name: profile_photos profile_photos_channel_participants_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER profile_photos_channel_participants_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.profile_photos FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_profile_photo_channel_participants_read_model(); + + +-- +-- Name: profile_photos profile_photos_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER profile_photos_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.profile_photos FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_profile_photo_read_model(); + + +-- +-- Name: stories telesrv_stories_story_peer_read_model; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER telesrv_stories_story_peer_read_model AFTER INSERT OR DELETE OR UPDATE ON public.stories FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_story_peer_read_model(); + + +-- +-- Name: story_hidden_peers telesrv_story_hidden_peers_story_peer_read_model; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER telesrv_story_hidden_peers_story_peer_read_model AFTER INSERT OR DELETE OR UPDATE ON public.story_hidden_peers FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_story_peer_read_model(); + + +-- +-- Name: user_channel_member_index user_channel_member_index_active_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER user_channel_member_index_active_read_model_changed AFTER INSERT OR DELETE OR UPDATE OF user_id, channel_id, status, deleted ON public.user_channel_member_index FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_channel_active_memberships_read_model(); + + +-- +-- Name: users users_channel_participants_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER users_channel_participants_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.users FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_user_channel_participants_read_model(); + + +-- +-- Name: users users_read_model_changed; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER users_read_model_changed AFTER INSERT OR DELETE OR UPDATE ON public.users FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_user_base_read_model(); + + +-- +-- Name: account_passwords account_passwords_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account_passwords + ADD CONSTRAINT account_passwords_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: account_privacy_rules account_privacy_rules_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account_privacy_rules + ADD CONSTRAINT account_privacy_rules_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: account_reaction_settings account_reaction_settings_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.account_reaction_settings + ADD CONSTRAINT account_reaction_settings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: authorizations authorizations_auth_key_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.authorizations + ADD CONSTRAINT authorizations_auth_key_id_fkey FOREIGN KEY (auth_key_id) REFERENCES public.auth_keys(auth_key_id) ON DELETE CASCADE; + + +-- +-- Name: authorizations authorizations_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.authorizations + ADD CONSTRAINT authorizations_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: bot_user_permissions bot_user_permissions_bot_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bot_user_permissions + ADD CONSTRAINT bot_user_permissions_bot_user_id_fkey FOREIGN KEY (bot_user_id) REFERENCES public.bots(bot_user_id) ON DELETE CASCADE; + + +-- +-- Name: bot_user_permissions bot_user_permissions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bot_user_permissions + ADD CONSTRAINT bot_user_permissions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: bots bots_bot_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bots + ADD CONSTRAINT bots_bot_user_id_fkey FOREIGN KEY (bot_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: bots bots_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bots + ADD CONSTRAINT bots_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id); + + +-- +-- Name: business_automation_deliveries business_automation_deliveries_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_automation_deliveries + ADD CONSTRAINT business_automation_deliveries_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: business_automation_deliveries business_automation_deliveries_peer_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_automation_deliveries + ADD CONSTRAINT business_automation_deliveries_peer_user_id_fkey FOREIGN KEY (peer_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: business_chat_links business_chat_links_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_chat_links + ADD CONSTRAINT business_chat_links_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: business_connected_bot_peer_states business_connected_bot_peer_states_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_connected_bot_peer_states + ADD CONSTRAINT business_connected_bot_peer_states_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: business_connected_bot_peer_states business_connected_bot_peer_states_peer_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_connected_bot_peer_states + ADD CONSTRAINT business_connected_bot_peer_states_peer_user_id_fkey FOREIGN KEY (peer_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: business_connected_bots business_connected_bots_bot_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_connected_bots + ADD CONSTRAINT business_connected_bots_bot_user_id_fkey FOREIGN KEY (bot_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: business_connected_bots business_connected_bots_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.business_connected_bots + ADD CONSTRAINT business_connected_bots_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: channel_admin_log_events channel_admin_log_events_actor_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_admin_log_events + ADD CONSTRAINT channel_admin_log_events_actor_user_id_fkey FOREIGN KEY (actor_user_id) REFERENCES public.users(id) ON DELETE RESTRICT; + + +-- +-- Name: channel_admin_log_events channel_admin_log_events_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_admin_log_events + ADD CONSTRAINT channel_admin_log_events_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_boost_slots channel_boost_slots_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_boost_slots + ADD CONSTRAINT channel_boost_slots_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: channel_dialogs channel_dialogs_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_dialogs + ADD CONSTRAINT channel_dialogs_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_dialogs channel_dialogs_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_dialogs + ADD CONSTRAINT channel_dialogs_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: channel_forum_topics channel_forum_topics_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_forum_topics + ADD CONSTRAINT channel_forum_topics_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_forum_topics channel_forum_topics_creator_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_forum_topics + ADD CONSTRAINT channel_forum_topics_creator_user_id_fkey FOREIGN KEY (creator_user_id) REFERENCES public.users(id) ON DELETE RESTRICT; + + +-- +-- Name: channel_invite_importers channel_invite_importers_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_invite_importers + ADD CONSTRAINT channel_invite_importers_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_invite_importers channel_invite_importers_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_invite_importers + ADD CONSTRAINT channel_invite_importers_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: channel_invites channel_invites_admin_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_invites + ADD CONSTRAINT channel_invites_admin_user_id_fkey FOREIGN KEY (admin_user_id) REFERENCES public.users(id) ON DELETE RESTRICT; + + +-- +-- Name: channel_invites channel_invites_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_invites + ADD CONSTRAINT channel_invites_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_media_category_counts channel_media_category_counts_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_media_category_counts + ADD CONSTRAINT channel_media_category_counts_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_members channel_members_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_members channel_members_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_members + ADD CONSTRAINT channel_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: channel_message_media channel_message_media_channel_id_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_message_media + ADD CONSTRAINT channel_message_media_channel_id_id_fkey FOREIGN KEY (channel_id, id) REFERENCES public.channel_messages(channel_id, id) ON DELETE CASCADE; + + +-- +-- Name: channel_message_reactions channel_message_reactions_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_message_reactions + ADD CONSTRAINT channel_message_reactions_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_message_reactions channel_message_reactions_channel_id_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_message_reactions + ADD CONSTRAINT channel_message_reactions_channel_id_message_id_fkey FOREIGN KEY (channel_id, message_id) REFERENCES public.channel_messages(channel_id, id) ON DELETE CASCADE; + + +-- +-- Name: channel_message_reactions channel_message_reactions_reacted_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_message_reactions + ADD CONSTRAINT channel_message_reactions_reacted_user_id_fkey FOREIGN KEY (reacted_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: channel_message_reactions channel_message_reactions_sender_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_message_reactions + ADD CONSTRAINT channel_message_reactions_sender_user_id_fkey FOREIGN KEY (sender_user_id) REFERENCES public.users(id) ON DELETE RESTRICT; + + +-- +-- Name: channel_messages channel_messages_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_messages + ADD CONSTRAINT channel_messages_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_messages channel_messages_sender_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_messages + ADD CONSTRAINT channel_messages_sender_user_id_fkey FOREIGN KEY (sender_user_id) REFERENCES public.users(id) ON DELETE RESTRICT; + + +-- +-- Name: channel_unread_mention_index channel_unread_mention_index_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_unread_mention_index + ADD CONSTRAINT channel_unread_mention_index_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_unread_mention_index channel_unread_mention_index_channel_id_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_unread_mention_index + ADD CONSTRAINT channel_unread_mention_index_channel_id_message_id_fkey FOREIGN KEY (channel_id, message_id) REFERENCES public.channel_messages(channel_id, id) ON DELETE CASCADE; + + +-- +-- Name: channel_unread_mention_index channel_unread_mention_index_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_unread_mention_index + ADD CONSTRAINT channel_unread_mention_index_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: channel_unread_mentions channel_unread_mentions_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_unread_mentions + ADD CONSTRAINT channel_unread_mentions_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channel_unread_mentions channel_unread_mentions_channel_id_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_unread_mentions + ADD CONSTRAINT channel_unread_mentions_channel_id_message_id_fkey FOREIGN KEY (channel_id, message_id) REFERENCES public.channel_messages(channel_id, id) ON DELETE CASCADE; + + +-- +-- Name: channel_unread_mentions channel_unread_mentions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_unread_mentions + ADD CONSTRAINT channel_unread_mentions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: channel_update_events channel_update_events_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channel_update_events + ADD CONSTRAINT channel_update_events_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: channels channels_creator_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.channels + ADD CONSTRAINT channels_creator_user_id_fkey FOREIGN KEY (creator_user_id) REFERENCES public.users(id) ON DELETE RESTRICT; + + +-- +-- Name: contact_blocks contact_blocks_blocked_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contact_blocks + ADD CONSTRAINT contact_blocks_blocked_user_id_fkey FOREIGN KEY (blocked_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: contact_blocks contact_blocks_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contact_blocks + ADD CONSTRAINT contact_blocks_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: contacts contacts_contact_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_contact_user_id_fkey FOREIGN KEY (contact_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: contacts contacts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.contacts + ADD CONSTRAINT contacts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: country_codes country_codes_iso2_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.country_codes + ADD CONSTRAINT country_codes_iso2_fkey FOREIGN KEY (iso2) REFERENCES public.countries(iso2) ON DELETE CASCADE; + + +-- +-- Name: dialog_drafts dialog_drafts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dialog_drafts + ADD CONSTRAINT dialog_drafts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: dialog_filter_settings dialog_filter_settings_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dialog_filter_settings + ADD CONSTRAINT dialog_filter_settings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: dialog_filters dialog_filters_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dialog_filters + ADD CONSTRAINT dialog_filters_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: dialogs dialogs_user_id_fkey1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dialogs + ADD CONSTRAINT dialogs_user_id_fkey1 FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: dispatch_outbox dispatch_outbox_target_user_id_fkey1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dispatch_outbox + ADD CONSTRAINT dispatch_outbox_target_user_id_fkey1 FOREIGN KEY (target_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: dispatch_outbox dispatch_outbox_target_user_id_pts_fkey1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dispatch_outbox + ADD CONSTRAINT dispatch_outbox_target_user_id_pts_fkey1 FOREIGN KEY (target_user_id, pts) REFERENCES public.user_update_events(user_id, pts) ON DELETE CASCADE; + + +-- +-- Name: encrypted_state_event_delivery encrypted_state_event_delivery_event_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.encrypted_state_event_delivery + ADD CONSTRAINT encrypted_state_event_delivery_event_id_fkey FOREIGN KEY (event_id) REFERENCES public.encrypted_state_events(id) ON DELETE CASCADE; + + +-- +-- Name: group_call_participant_overrides group_call_participant_overrides_call_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_call_participant_overrides + ADD CONSTRAINT group_call_participant_overrides_call_id_fkey FOREIGN KEY (call_id) REFERENCES public.group_calls(call_id) ON DELETE CASCADE; + + +-- +-- Name: group_call_participants group_call_participants_call_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_call_participants + ADD CONSTRAINT group_call_participants_call_id_fkey FOREIGN KEY (call_id) REFERENCES public.group_calls(call_id) ON DELETE CASCADE; + + +-- +-- Name: lang_pack_strings lang_pack_strings_lang_pack_lang_code_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lang_pack_strings + ADD CONSTRAINT lang_pack_strings_lang_pack_lang_code_fkey FOREIGN KEY (lang_pack, lang_code) REFERENCES public.lang_packs(lang_pack, lang_code) ON DELETE CASCADE; + + +-- +-- Name: message_box_media message_box_media_owner_user_id_box_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_box_media + ADD CONSTRAINT message_box_media_owner_user_id_box_id_fkey FOREIGN KEY (owner_user_id, box_id) REFERENCES public.message_boxes(owner_user_id, box_id) ON DELETE CASCADE; + + +-- +-- Name: message_boxes message_boxes_from_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_boxes + ADD CONSTRAINT message_boxes_from_user_id_fkey FOREIGN KEY (from_user_id) REFERENCES public.users(id) ON DELETE RESTRICT; + + +-- +-- Name: message_boxes message_boxes_message_sender_id_private_message_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_boxes + ADD CONSTRAINT message_boxes_message_sender_id_private_message_id_fkey FOREIGN KEY (message_sender_id, private_message_id) REFERENCES public.private_messages(sender_user_id, id) ON DELETE CASCADE; + + +-- +-- Name: message_boxes message_boxes_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.message_boxes + ADD CONSTRAINT message_boxes_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: poll_votes poll_votes_poll_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.poll_votes + ADD CONSTRAINT poll_votes_poll_id_fkey FOREIGN KEY (poll_id) REFERENCES public.polls(poll_id) ON DELETE CASCADE; + + +-- +-- Name: private_message_reactions private_message_reactions_message_sender_id_private_messag_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.private_message_reactions + ADD CONSTRAINT private_message_reactions_message_sender_id_private_messag_fkey FOREIGN KEY (message_sender_id, private_message_id) REFERENCES public.private_messages(sender_user_id, id) ON DELETE CASCADE; + + +-- +-- Name: private_message_reactions private_message_reactions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.private_message_reactions + ADD CONSTRAINT private_message_reactions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: private_messages private_messages_recipient_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.private_messages + ADD CONSTRAINT private_messages_recipient_user_id_fkey FOREIGN KEY (recipient_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: private_messages private_messages_sender_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.private_messages + ADD CONSTRAINT private_messages_sender_user_id_fkey FOREIGN KEY (sender_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: quick_replies quick_replies_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quick_replies + ADD CONSTRAINT quick_replies_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: quick_reply_messages quick_reply_messages_owner_user_id_shortcut_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quick_reply_messages + ADD CONSTRAINT quick_reply_messages_owner_user_id_shortcut_id_fkey FOREIGN KEY (owner_user_id, shortcut_id) REFERENCES public.quick_replies(owner_user_id, shortcut_id) ON DELETE CASCADE; + + +-- +-- Name: saved_music saved_music_document_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.saved_music + ADD CONSTRAINT saved_music_document_id_fkey FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE; + + +-- +-- Name: saved_music saved_music_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.saved_music + ADD CONSTRAINT saved_music_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: scheduled_messages scheduled_messages_owner_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.scheduled_messages + ADD CONSTRAINT scheduled_messages_owner_user_id_fkey FOREIGN KEY (owner_user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: temp_auth_key_bindings temp_auth_key_bindings_temp_auth_key_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.temp_auth_key_bindings + ADD CONSTRAINT temp_auth_key_bindings_temp_auth_key_id_fkey FOREIGN KEY (temp_auth_key_id) REFERENCES public.auth_keys(auth_key_id) ON DELETE CASCADE; + + +-- +-- Name: user_business_profiles user_business_profiles_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_business_profiles + ADD CONSTRAINT user_business_profiles_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_channel_member_index user_channel_member_index_channel_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_channel_member_index + ADD CONSTRAINT user_channel_member_index_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE; + + +-- +-- Name: user_channel_member_index user_channel_member_index_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_channel_member_index + ADD CONSTRAINT user_channel_member_index_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_recent_reactions user_recent_reactions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_recent_reactions + ADD CONSTRAINT user_recent_reactions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_saved_reaction_tags user_saved_reaction_tags_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_saved_reaction_tags + ADD CONSTRAINT user_saved_reaction_tags_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_top_reactions user_top_reactions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_top_reactions + ADD CONSTRAINT user_top_reactions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_update_events user_update_events_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_update_events + ADD CONSTRAINT user_update_events_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: user_update_events user_update_events_user_id_message_box_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_update_events + ADD CONSTRAINT user_update_events_user_id_message_box_id_fkey FOREIGN KEY (user_id, message_box_id) REFERENCES public.message_boxes(owner_user_id, box_id) ON DELETE CASCADE; + + +-- +-- Name: user_update_watermarks user_update_watermarks_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_update_watermarks + ADD CONSTRAINT user_update_watermarks_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- PostgreSQL database dump complete +-- + + + +-- ===================== 规范 seed 数据 ===================== +-- 关触发器+FK 精确装载 seed(等价 pg_restore),避免 seed 其它表触发 read-model 触发器二次生成行而撞键。 +-- session_replication_role 需超级权限,与本迁移已有的 CREATE EXTENSION pg_trgm 同一前提。 +SET session_replication_role = replica; + +-- +-- PostgreSQL database dump +-- + + +-- Dumped from database version 17.10 +-- Dumped by pg_dump version 17.10 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (777000, 6599886787491911851, '42777', 'Telegram', '', 'telegram', '', '2026-06-19 13:35:51.253491+00', '2026-06-19 13:35:51.253491+00', true, true, '', 0, 0, false, 0, NULL, 0, 0, false, 0, 0, false, 0, 0); +INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (93372553, 7421896403922962293, '', 'BotFather', '', 'BotFather', '', '2026-06-19 13:35:52.688367+00', '2026-06-19 13:35:52.688367+00', true, false, '', 0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0); + + +-- +-- Data for Name: account_passwords; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: account_privacy_rules; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: account_reaction_settings; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: app_configs; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.app_configs (client, hash, config_json, updated_at) VALUES ('tdesktop', 5, '{"quote_length_max": 1024, "reactions_default": {"_": "reactionEmoji", "emoticon": "👍"}, "reactions_uniq_max": 11, "reactions_in_chat_max": 3, "telegram_antispam_user_id": "5434988373", "pm_read_date_expire_period": 604800, "reactions_user_max_default": 1, "chat_read_mark_expire_period": 604800, "chat_read_mark_size_threshold": 50, "telegram_antispam_group_size_min": 200}', '2026-06-19 13:35:52.201256+00'); + + +-- +-- Data for Name: auth_keys; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: authorizations; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: available_reactions; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: bot_chat_states; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: bots; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.bots (bot_user_id, owner_user_id, token_secret, description, commands, bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at, menu_button_type, menu_button_text, menu_button_url, bot_inline_geo) VALUES (93372553, 93372553, '', 'BotFather is the one bot to rule them all. Use it to create new bot accounts and manage your existing bots.', '[{"command": "newbot", "description": "create a new bot"}, {"command": "mybots", "description": "list your bots"}, {"command": "token", "description": "show a bot''s token"}, {"command": "revoke", "description": "revoke a bot''s token"}, {"command": "cancel", "description": "cancel the current operation"}, {"command": "help", "description": "show help"}]', false, false, '', '2026-06-19 13:35:52.688367+00', '2026-06-19 13:35:52.688367+00', 0, '', '', false); + + +-- +-- Data for Name: bot_user_permissions; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: business_automation_deliveries; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: business_chat_links; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: business_connected_bot_peer_states; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: business_connected_bots; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channels; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_admin_log_events; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_boost_slots; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_dialogs; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_forum_topics; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_invite_hashes; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_invite_importers; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_invites; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_media_category_counts; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_members; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_messages; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_message_media; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_message_reactions; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_message_viewers; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_unread_mention_index; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_unread_mentions; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_update_events; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: channel_usernames; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: contact_blocks; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: contacts; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: countries; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.countries (iso2, default_name, name, hidden, order_index, updated_at) VALUES ('US', 'United States', '', false, 10, '2026-06-19 13:35:51.214267+00'); +INSERT INTO public.countries (iso2, default_name, name, hidden, order_index, updated_at) VALUES ('CN', 'China', '', false, 20, '2026-06-19 13:35:51.214267+00'); + + +-- +-- Data for Name: country_codes; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.country_codes (id, iso2, country_code, prefixes, patterns, order_index) VALUES (1, 'US', '1', '{""}', '{"XXX XXX XXXX"}', 10); +INSERT INTO public.country_codes (id, iso2, country_code, prefixes, patterns, order_index) VALUES (2, 'CN', '86', '{""}', '{"XXX XXXX XXXX"}', 20); + + +-- +-- Data for Name: dialog_drafts; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: dialog_filter_settings; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: dialog_filters; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: dialogs; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: private_messages; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: message_boxes; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: user_update_events; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: dispatch_outbox; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: documents; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: encrypted_files; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: encrypted_message_queue; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: encrypted_state_events; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: encrypted_state_event_delivery; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: file_blobs; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: group_calls; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: group_call_participant_overrides; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: group_call_participants; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: lang_packs; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: lang_pack_strings; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: message_box_media; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: photos; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: polls; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: poll_votes; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: private_media_category_counts; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: private_message_reactions; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: profile_photos; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: quick_replies; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: quick_reply_messages; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: read_model_versions; Type: TABLE DATA; Schema: public; Owner: - +-- + +INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash) VALUES ('contact_account', 777000, 'user', 777000, 1, '2026-06-19 13:35:53.204092+00', 8080089745885824); +INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash) VALUES ('contact_account', 93372553, 'user', 93372553, 1, '2026-06-19 13:35:53.204092+00', 3925565686878508); +INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash) VALUES ('channel_active_memberships', 777000, 'user', 777000, 1, '2026-06-19 13:35:53.311258+00', 2767535233995134); +INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash) VALUES ('channel_active_memberships', 93372553, 'user', 93372553, 1, '2026-06-19 13:35:53.311258+00', 1406287033083140); + + +-- +-- Data for Name: saved_dialog_pins; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: saved_music; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: scheduled_messages; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: secret_chats; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: secret_qts_watermarks; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: sticker_sets; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: stories; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: story_hidden_peers; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: story_read_states; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: story_views; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: temp_auth_key_bindings; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: update_states; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: upload_parts; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: user_business_profiles; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: user_channel_member_index; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: user_recent_reactions; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: user_saved_reaction_tags; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: user_top_reactions; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Data for Name: user_update_watermarks; Type: TABLE DATA; Schema: public; Owner: - +-- + + + +-- +-- Name: country_codes_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.country_codes_id_seq', 2, true); + + +-- +-- Name: dispatch_outbox_v2_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.dispatch_outbox_v2_id_seq', 1, false); + + +-- +-- Name: encrypted_state_events_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.encrypted_state_events_id_seq', 1, false); + + +-- +-- Name: private_messages_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.private_messages_id_seq', 1, false); + + +-- +-- Name: users_id_seq; Type: SEQUENCE SET; Schema: public; Owner: - +-- + +SELECT pg_catalog.setval('public.users_id_seq', 1780243199, true); + + +-- +-- PostgreSQL database dump complete +-- + + + +SET session_replication_role = DEFAULT; diff --git a/deploy/migrations/0002_channel_topic_read.down.sql b/deploy/migrations/0002_channel_topic_read.down.sql new file mode 100644 index 00000000..b495d369 --- /dev/null +++ b/deploy/migrations/0002_channel_topic_read.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.channel_topic_read; diff --git a/deploy/migrations/0002_channel_topic_read.up.sql b/deploy/migrations/0002_channel_topic_read.up.sql new file mode 100644 index 00000000..a7b4c800 --- /dev/null +++ b/deploy/migrations/0002_channel_topic_read.up.sql @@ -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); diff --git a/deploy/migrations/0002_phase1_business.down.sql b/deploy/migrations/0002_phase1_business.down.sql deleted file mode 100644 index afb39a64..00000000 --- a/deploy/migrations/0002_phase1_business.down.sql +++ /dev/null @@ -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; diff --git a/deploy/migrations/0002_phase1_business.up.sql b/deploy/migrations/0002_phase1_business.up.sql deleted file mode 100644 index 991edd18..00000000 --- a/deploy/migrations/0002_phase1_business.up.sql +++ /dev/null @@ -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); diff --git a/deploy/migrations/0003_startup_config_security.down.sql b/deploy/migrations/0003_startup_config_security.down.sql deleted file mode 100644 index fe1bf94e..00000000 --- a/deploy/migrations/0003_startup_config_security.down.sql +++ /dev/null @@ -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; diff --git a/deploy/migrations/0003_startup_config_security.up.sql b/deploy/migrations/0003_startup_config_security.up.sql deleted file mode 100644 index ed5ab84b..00000000 --- a/deploy/migrations/0003_startup_config_security.up.sql +++ /dev/null @@ -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; diff --git a/deploy/migrations/0003_user_update_events_discussion_types.down.sql b/deploy/migrations/0003_user_update_events_discussion_types.down.sql new file mode 100644 index 00000000..1f4d3113 --- /dev/null +++ b/deploy/migrations/0003_user_update_events_discussion_types.down.sql @@ -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[]) +); diff --git a/deploy/migrations/0003_user_update_events_discussion_types.up.sql b/deploy/migrations/0003_user_update_events_discussion_types.up.sql new file mode 100644 index 00000000..9e8d7d6e --- /dev/null +++ b/deploy/migrations/0003_user_update_events_discussion_types.up.sql @@ -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[]) +); diff --git a/deploy/migrations/0004_account_settings.down.sql b/deploy/migrations/0004_account_settings.down.sql new file mode 100644 index 00000000..e97ef9b5 --- /dev/null +++ b/deploy/migrations/0004_account_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.account_settings; diff --git a/deploy/migrations/0004_account_settings.up.sql b/deploy/migrations/0004_account_settings.up.sql new file mode 100644 index 00000000..0286d850 --- /dev/null +++ b/deploy/migrations/0004_account_settings.up.sql @@ -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); diff --git a/deploy/migrations/0004_temp_auth_key_binding_session.down.sql b/deploy/migrations/0004_temp_auth_key_binding_session.down.sql deleted file mode 100644 index a99d3634..00000000 --- a/deploy/migrations/0004_temp_auth_key_binding_session.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE temp_auth_key_bindings - DROP COLUMN IF EXISTS temp_session_id; diff --git a/deploy/migrations/0004_temp_auth_key_binding_session.up.sql b/deploy/migrations/0004_temp_auth_key_binding_session.up.sql deleted file mode 100644 index 7732aab4..00000000 --- a/deploy/migrations/0004_temp_auth_key_binding_session.up.sql +++ /dev/null @@ -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; diff --git a/deploy/migrations/0005_notify_settings.down.sql b/deploy/migrations/0005_notify_settings.down.sql new file mode 100644 index 00000000..b91bf264 --- /dev/null +++ b/deploy/migrations/0005_notify_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.notify_settings; diff --git a/deploy/migrations/0005_notify_settings.up.sql b/deploy/migrations/0005_notify_settings.up.sql new file mode 100644 index 00000000..2643320f --- /dev/null +++ b/deploy/migrations/0005_notify_settings.up.sql @@ -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)); diff --git a/deploy/migrations/0005_system_login_messages.down.sql b/deploy/migrations/0005_system_login_messages.down.sql deleted file mode 100644 index 65802c08..00000000 --- a/deploy/migrations/0005_system_login_messages.down.sql +++ /dev/null @@ -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; diff --git a/deploy/migrations/0005_system_login_messages.up.sql b/deploy/migrations/0005_system_login_messages.up.sql deleted file mode 100644 index 75d0665d..00000000 --- a/deploy/migrations/0005_system_login_messages.up.sql +++ /dev/null @@ -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); diff --git a/deploy/migrations/0006_sticker_collections.down.sql b/deploy/migrations/0006_sticker_collections.down.sql new file mode 100644 index 00000000..aa0b7712 --- /dev/null +++ b/deploy/migrations/0006_sticker_collections.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.user_sticker_collections; diff --git a/deploy/migrations/0006_sticker_collections.up.sql b/deploy/migrations/0006_sticker_collections.up.sql new file mode 100644 index 00000000..c5e0b4d1 --- /dev/null +++ b/deploy/migrations/0006_sticker_collections.up.sql @@ -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); diff --git a/deploy/migrations/0006_update_events.down.sql b/deploy/migrations/0006_update_events.down.sql deleted file mode 100644 index f505fd34..00000000 --- a/deploy/migrations/0006_update_events.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX IF EXISTS update_events_auth_pts_idx; -DROP TABLE IF EXISTS update_events; diff --git a/deploy/migrations/0006_update_events.up.sql b/deploy/migrations/0006_update_events.up.sql deleted file mode 100644 index ec31adfc..00000000 --- a/deploy/migrations/0006_update_events.up.sql +++ /dev/null @@ -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); diff --git a/deploy/migrations/0007_dialogs_peer_id_index.down.sql b/deploy/migrations/0007_dialogs_peer_id_index.down.sql new file mode 100644 index 00000000..d45aba20 --- /dev/null +++ b/deploy/migrations/0007_dialogs_peer_id_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS public.dialogs_peer_id_idx; diff --git a/deploy/migrations/0007_dialogs_peer_id_index.up.sql b/deploy/migrations/0007_dialogs_peer_id_index.up.sql new file mode 100644 index 00000000..697552d4 --- /dev/null +++ b/deploy/migrations/0007_dialogs_peer_id_index.up.sql @@ -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); diff --git a/deploy/migrations/0007_read_history_events.down.sql b/deploy/migrations/0007_read_history_events.down.sql deleted file mode 100644 index d6e4ded7..00000000 --- a/deploy/migrations/0007_read_history_events.down.sql +++ /dev/null @@ -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; diff --git a/deploy/migrations/0007_read_history_events.up.sql b/deploy/migrations/0007_read_history_events.up.sql deleted file mode 100644 index 94f47d66..00000000 --- a/deploy/migrations/0007_read_history_events.up.sql +++ /dev/null @@ -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')); diff --git a/deploy/migrations/0008_message_grouped_id.down.sql b/deploy/migrations/0008_message_grouped_id.down.sql new file mode 100644 index 00000000..550403ef --- /dev/null +++ b/deploy/migrations/0008_message_grouped_id.down.sql @@ -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; diff --git a/deploy/migrations/0008_message_grouped_id.up.sql b/deploy/migrations/0008_message_grouped_id.up.sql new file mode 100644 index 00000000..7e22c995 --- /dev/null +++ b/deploy/migrations/0008_message_grouped_id.up.sql @@ -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; diff --git a/deploy/migrations/0008_user_id_sequence_base.down.sql b/deploy/migrations/0008_user_id_sequence_base.down.sql deleted file mode 100644 index eb26ae39..00000000 --- a/deploy/migrations/0008_user_id_sequence_base.down.sql +++ /dev/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 -); diff --git a/deploy/migrations/0008_user_id_sequence_base.up.sql b/deploy/migrations/0008_user_id_sequence_base.up.sql deleted file mode 100644 index 3255da6f..00000000 --- a/deploy/migrations/0008_user_id_sequence_base.up.sql +++ /dev/null @@ -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 -); diff --git a/deploy/migrations/0009_private_message_pipeline.down.sql b/deploy/migrations/0009_private_message_pipeline.down.sql deleted file mode 100644 index c46e4d91..00000000 --- a/deploy/migrations/0009_private_message_pipeline.down.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 0009 rollback: remove second-stage private message pipeline tables. -DROP TABLE IF EXISTS dispatch_outbox; -DROP TABLE IF EXISTS user_update_events; -DROP TABLE IF EXISTS dialogs; -DROP TABLE IF EXISTS message_boxes; -DROP TABLE IF EXISTS private_messages; - -ALTER TABLE IF EXISTS dialogs_legacy RENAME TO dialogs; -ALTER TABLE IF EXISTS messages_legacy RENAME TO messages; - -ALTER TABLE update_states DROP CONSTRAINT IF EXISTS update_states_pkey; -ALTER TABLE update_states DROP COLUMN IF EXISTS user_id; -ALTER TABLE update_states ADD PRIMARY KEY (auth_key_id); diff --git a/deploy/migrations/0009_private_message_pipeline.up.sql b/deploy/migrations/0009_private_message_pipeline.up.sql deleted file mode 100644 index 5f1806ea..00000000 --- a/deploy/migrations/0009_private_message_pipeline.up.sql +++ /dev/null @@ -1,239 +0,0 @@ --- 0009_private_message_pipeline: second-stage private text message storage. --- --- Large tables are partitioned from the first version of the message module: --- message_boxes/dialogs/user_update_events/dispatch_outbox by owner/target user, --- private_messages by sender user for random_id idempotency locality. - -ALTER TABLE IF EXISTS messages RENAME TO messages_legacy; -ALTER TABLE IF EXISTS dialogs RENAME TO dialogs_legacy; - -CREATE TABLE IF NOT EXISTS private_messages ( - id BIGINT GENERATED BY DEFAULT AS IDENTITY, - sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - recipient_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - random_id BIGINT NOT NULL DEFAULT 0, - message_date INT NOT NULL, - body TEXT NOT NULL DEFAULT '', - entities JSONB NOT NULL DEFAULT '[]'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (sender_user_id, id), - CONSTRAINT private_messages_nonempty_body CHECK (body <> '') -) PARTITION BY HASH (sender_user_id); - -CREATE UNIQUE INDEX IF NOT EXISTS private_messages_sender_random_idx - ON private_messages (sender_user_id, random_id) - WHERE random_id <> 0; -CREATE INDEX IF NOT EXISTS private_messages_recipient_date_idx - ON private_messages (recipient_user_id, message_date DESC, id DESC); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS private_messages_p%s PARTITION OF private_messages FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -CREATE TABLE IF NOT EXISTS message_boxes ( - owner_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - box_id INT NOT NULL, - private_message_id BIGINT NOT NULL, - message_sender_id BIGINT NOT NULL, - 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, - pts INT NOT NULL DEFAULT 0, - deleted BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (owner_user_id, box_id), - UNIQUE (owner_user_id, private_message_id), - CONSTRAINT message_boxes_peer_type_check CHECK (peer_type IN ('user')), - FOREIGN KEY (message_sender_id, private_message_id) - REFERENCES private_messages(sender_user_id, id) ON DELETE CASCADE -) PARTITION BY HASH (owner_user_id); - -CREATE INDEX IF NOT EXISTS message_boxes_dialog_seek_idx - ON message_boxes (owner_user_id, peer_type, peer_id, box_id DESC) - WHERE NOT deleted; -CREATE INDEX IF NOT EXISTS message_boxes_owner_date_idx - ON message_boxes (owner_user_id, message_date DESC, box_id DESC) - WHERE NOT deleted; -CREATE INDEX IF NOT EXISTS message_boxes_private_lookup_idx - ON message_boxes (private_message_id, owner_user_id); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS message_boxes_p%s PARTITION OF message_boxes FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -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, - top_message_date 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')) -) PARTITION BY HASH (user_id); - -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 INDEX IF NOT EXISTS dialogs_user_pinned_idx - ON dialogs (user_id, pinned) WHERE pinned; - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS dialogs_p%s PARTITION OF dialogs FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -CREATE TABLE IF NOT EXISTS user_update_events ( - user_id BIGINT NOT NULL REFERENCES users(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_box_id INT, - peer_type VARCHAR(16), - peer_id BIGINT, - max_id INT NOT NULL DEFAULT 0, - still_unread_count INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, pts), - CONSTRAINT user_update_events_type_check CHECK (event_type IN ('new_message', 'read_history_inbox', 'noop')), - CONSTRAINT user_update_events_peer_type_check CHECK (peer_type IS NULL OR peer_type IN ('user')), - FOREIGN KEY (user_id, message_box_id) REFERENCES message_boxes(owner_user_id, box_id) ON DELETE CASCADE -) PARTITION BY HASH (user_id); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS user_update_events_p%s PARTITION OF user_update_events FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -CREATE TABLE IF NOT EXISTS dispatch_outbox ( - id BIGINT GENERATED BY DEFAULT AS IDENTITY, - target_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - pts INT NOT NULL, - event_type VARCHAR(32) NOT NULL, - exclude_session_id BIGINT NOT NULL DEFAULT 0, - status VARCHAR(16) NOT NULL DEFAULT 'pending', - attempts INT NOT NULL DEFAULT 0, - next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), - last_error TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (target_user_id, id), - CONSTRAINT dispatch_outbox_status_check CHECK (status IN ('pending', 'dispatching', 'delivered', 'failed')), - FOREIGN KEY (target_user_id, pts) REFERENCES user_update_events(user_id, pts) ON DELETE CASCADE -) PARTITION BY HASH (target_user_id); - -CREATE INDEX IF NOT EXISTS dispatch_outbox_pending_idx - ON dispatch_outbox (status, next_attempt_at, target_user_id, id) - WHERE status = 'pending'; - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS dispatch_outbox_p%s PARTITION OF dispatch_outbox FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -ALTER TABLE update_states - ADD COLUMN IF NOT EXISTS user_id BIGINT NOT NULL DEFAULT 0; -ALTER TABLE update_states DROP CONSTRAINT IF EXISTS update_states_pkey; -ALTER TABLE update_states ADD PRIMARY KEY (auth_key_id, user_id); -CREATE INDEX IF NOT EXISTS update_states_user_id_idx ON update_states (user_id); - -DO $$ -DECLARE - r record; - private_id bigint; -BEGIN - IF to_regclass('messages_legacy') IS NULL THEN - RETURN; - END IF; - - FOR r IN - SELECT id, owner_user_id, peer_type, peer_id, from_user_id, message_date, outgoing, body, entities - FROM messages_legacy - ORDER BY owner_user_id, id - LOOP - INSERT INTO private_messages ( - sender_user_id, recipient_user_id, random_id, message_date, body, entities - ) VALUES ( - r.from_user_id, - r.owner_user_id, - 0, - r.message_date, - r.body, - r.entities - ) - RETURNING id INTO private_id; - - INSERT INTO message_boxes ( - owner_user_id, box_id, private_message_id, message_sender_id, peer_type, peer_id, - from_user_id, message_date, outgoing, body, entities - ) VALUES ( - r.owner_user_id, r.id, private_id, r.from_user_id, r.peer_type, r.peer_id, - r.from_user_id, r.message_date, r.outgoing, r.body, r.entities - ) - ON CONFLICT (owner_user_id, box_id) DO NOTHING; - END LOOP; -END $$; - -INSERT INTO dialogs ( - user_id, peer_type, peer_id, top_message_id, top_message_date, - read_inbox_max_id, read_outbox_max_id, unread_count, - unread_mentions_count, unread_reactions_count, pinned, updated_at -) -SELECT - user_id, peer_type, peer_id, top_message_id, top_message_date, - read_inbox_max_id, read_outbox_max_id, unread_count, - unread_mentions_count, unread_reactions_count, pinned, updated_at -FROM dialogs_legacy -ON CONFLICT (user_id, peer_type, peer_id) DO NOTHING; diff --git a/deploy/migrations/0009_stars_ledger.down.sql b/deploy/migrations/0009_stars_ledger.down.sql new file mode 100644 index 00000000..b1e57251 --- /dev/null +++ b/deploy/migrations/0009_stars_ledger.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS public.stars_transactions; +DROP TABLE IF EXISTS public.stars_balances; diff --git a/deploy/migrations/0009_stars_ledger.up.sql b/deploy/migrations/0009_stars_ledger.up.sql new file mode 100644 index 00000000..8b1e85cf --- /dev/null +++ b/deploy/migrations/0009_stars_ledger.up.sql @@ -0,0 +1,29 @@ +-- Stars 本地账本:per-user 余额 + 交易流水。此前 payments.getStarsStatus 余额恒 0、 +-- getStarsTransactions 未注册、sendPaidReaction 恒返 BALANCE_TOO_LOW——无任何余额持久化。 +-- 本地账本(非真实支付):起始余额走惰性首读授予(granted 布尔幂等,新老账号都覆盖、免回填)。 +-- 借记原子性由 store 层 withTx 保证(SELECT ... FOR UPDATE + CHECK(balance>=0) + UPDATE + INSERT)。 +CREATE TABLE public.stars_balances ( + user_id bigint NOT NULL, + balance bigint DEFAULT 0 NOT NULL, + granted boolean DEFAULT false NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT stars_balances_pkey PRIMARY KEY (user_id), + CONSTRAINT stars_balances_balance_nonneg CHECK ((balance >= 0)) +); + +-- 流水:amount 带符号(贷记 > 0 / 借记 < 0);peer_* 为对手方(grant/topup 等无对手时为空)。 +CREATE TABLE public.stars_transactions ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + user_id bigint NOT NULL, + peer_type text DEFAULT '' NOT NULL, + peer_id bigint DEFAULT 0 NOT NULL, + amount bigint NOT NULL, + reason text DEFAULT 'adjust' NOT NULL, + title text DEFAULT '' NOT NULL, + description text DEFAULT '' NOT NULL, + date integer DEFAULT 0 NOT NULL, + CONSTRAINT stars_transactions_pkey PRIMARY KEY (id) +); + +-- keyset 分页:WHERE user_id=$1 [AND id < cursor] ORDER BY id DESC LIMIT n。 +CREATE INDEX stars_transactions_user_id_idx ON public.stars_transactions USING btree (user_id, id DESC); diff --git a/deploy/migrations/0010_channel_paid_reactions.down.sql b/deploy/migrations/0010_channel_paid_reactions.down.sql new file mode 100644 index 00000000..4036738d --- /dev/null +++ b/deploy/migrations/0010_channel_paid_reactions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.channel_message_paid_reactions; diff --git a/deploy/migrations/0010_channel_paid_reactions.up.sql b/deploy/migrations/0010_channel_paid_reactions.up.sql new file mode 100644 index 00000000..53501894 --- /dev/null +++ b/deploy/migrations/0010_channel_paid_reactions.up.sql @@ -0,0 +1,17 @@ +-- 频道帖子付费 reaction(messages.sendPaidReaction):每个 reactor 对一条频道消息累计 +-- 投入的 Stars 星数 + 是否匿名。此前 sendPaidReaction 恒返 BALANCE_TOO_LOW(无账本无累计)。 +-- 配合 Stars 账本(迁移 0009):rpc 先从账本 Debit,再在此累计;消息上展示 ReactionPaid 总星数 +-- 与 top reactors 排行。星数为正、按 (channel,message,user) 累加。 +CREATE TABLE public.channel_message_paid_reactions ( + channel_id bigint NOT NULL, + message_id integer NOT NULL, + reactor_user_id bigint NOT NULL, + stars bigint DEFAULT 0 NOT NULL, + anonymous boolean DEFAULT false NOT NULL, + reaction_date integer DEFAULT 0 NOT NULL, + CONSTRAINT channel_message_paid_reactions_pkey PRIMARY KEY (channel_id, message_id, reactor_user_id), + CONSTRAINT channel_message_paid_reactions_stars_pos CHECK ((stars > 0)) +); + +-- top reactors 排行按 (channel, message, stars DESC)。 +CREATE INDEX channel_message_paid_reactions_top_idx ON public.channel_message_paid_reactions USING btree (channel_id, message_id, stars DESC); diff --git a/deploy/migrations/0010_message_performance_indexes.down.sql b/deploy/migrations/0010_message_performance_indexes.down.sql deleted file mode 100644 index f11cc848..00000000 --- a/deploy/migrations/0010_message_performance_indexes.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX IF EXISTS dispatch_outbox_dispatching_stale_idx; -DROP INDEX IF EXISTS message_boxes_dialog_date_seek_idx; diff --git a/deploy/migrations/0010_message_performance_indexes.up.sql b/deploy/migrations/0010_message_performance_indexes.up.sql deleted file mode 100644 index 77b3d99b..00000000 --- a/deploy/migrations/0010_message_performance_indexes.up.sql +++ /dev/null @@ -1,9 +0,0 @@ --- 0010_message_performance_indexes: indexes for second-stage message seek paths. - -CREATE INDEX IF NOT EXISTS message_boxes_dialog_date_seek_idx - ON message_boxes (owner_user_id, peer_type, peer_id, message_date DESC, box_id DESC) - WHERE NOT deleted; - -CREATE INDEX IF NOT EXISTS dispatch_outbox_dispatching_stale_idx - ON dispatch_outbox (status, updated_at, target_user_id, id) - WHERE status = 'dispatching'; diff --git a/deploy/migrations/0011_drop_dead_tables.down.sql b/deploy/migrations/0011_drop_dead_tables.down.sql deleted file mode 100644 index 78e1a0ba..00000000 --- a/deploy/migrations/0011_drop_dead_tables.down.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 0011 down (no-op): 本迁移清除的是已被取代、运行时零引用的死表 --- (update_events / messages_legacy / dialogs_legacy)。 --- --- 全新项目明确不保留回退路径,故不在此重建这些表;历史结构可查 0002 / 0005 / 0006 / 0007 迁移脚本(保留未删)。 --- golang-migrate 执行本文件即把版本回退到 0010,不恢复任何死表或其数据。 -SELECT 1; diff --git a/deploy/migrations/0011_drop_dead_tables.up.sql b/deploy/migrations/0011_drop_dead_tables.up.sql deleted file mode 100644 index f9f2856b..00000000 --- a/deploy/migrations/0011_drop_dead_tables.up.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 0011_drop_dead_tables: 清除已被取代、运行时零引用的遗留表(全新项目,不保留回退)。 --- --- - update_events : 一阶段 auth_key 维度 update 队列(0006 建 / 0007 扩展),二阶段被 --- user_update_events 取代;无任何 query / Go 引用,仅在 sqlc 留下孤儿 model。 --- - messages_legacy: 0009 由旧 messages(0005)重命名保留的迁移残骸,数据已迁入 private_messages + message_boxes。 --- - dialogs_legacy : 0009 由旧 dialogs(0002)重命名保留的迁移残骸,数据已迁入新 dialogs。 --- --- 顺序要求:update_events.message_id 外键指向 messages_legacy(原 messages),故先删 update_events。 --- 删除后需重跑 `sqlc generate`,models.go 中 UpdateEvent / MessagesLegacy / DialogsLegacy 孤儿 model 会自动消失。 - -DROP TABLE IF EXISTS update_events; -DROP TABLE IF EXISTS messages_legacy; -DROP TABLE IF EXISTS dialogs_legacy; diff --git a/deploy/migrations/0011_star_gifts.down.sql b/deploy/migrations/0011_star_gifts.down.sql new file mode 100644 index 00000000..809ba3a2 --- /dev/null +++ b/deploy/migrations/0011_star_gifts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.user_star_gifts; diff --git a/deploy/migrations/0011_star_gifts.up.sql b/deploy/migrations/0011_star_gifts.up.sql new file mode 100644 index 00000000..fb24fc19 --- /dev/null +++ b/deploy/migrations/0011_star_gifts.up.sql @@ -0,0 +1,24 @@ +-- Star gift(payments.sendStarsForm + inputInvoiceStarGift):用户花 Stars 给另一用户送礼物 +-- (贴纸式收藏品)。此前 getStarGifts/getSavedStarGifts 返回空桩、getPaymentForm/sendStarsForm +-- 未注册。目录是从已 seed 的 animated_emoji 合成的静态内存表(不入库);本表存「已收到的礼物 +-- 实例」。配合 Stars 账本(迁移 0009):发礼 Debit、转换回 Stars 时 Credit。 +-- msg_id 是礼物在 owner 私聊里的服务消息 id(messageActionStarGift),是 save/convert 的身份键 +-- (对应 inputSavedStarGiftUser.msg_id)。 +CREATE TABLE public.user_star_gifts ( + id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL, + owner_user_id bigint NOT NULL, + from_user_id bigint DEFAULT 0 NOT NULL, + gift_id bigint NOT NULL, + msg_id integer NOT NULL, + gift_date integer DEFAULT 0 NOT NULL, + name_hidden boolean DEFAULT false NOT NULL, + unsaved boolean DEFAULT false NOT NULL, + converted boolean DEFAULT false NOT NULL, + convert_stars bigint DEFAULT 0 NOT NULL, + message text DEFAULT '' NOT NULL, + CONSTRAINT user_star_gifts_pkey PRIMARY KEY (id), + CONSTRAINT user_star_gifts_owner_msg_uniq UNIQUE (owner_user_id, msg_id) +); + +-- getSavedStarGifts keyset 分页按 (owner, gift_date DESC, id DESC)。 +CREATE INDEX user_star_gifts_owner_idx ON public.user_star_gifts USING btree (owner_user_id, gift_date DESC, id DESC); diff --git a/deploy/migrations/0012_message_reply_story.down.sql b/deploy/migrations/0012_message_reply_story.down.sql new file mode 100644 index 00000000..b0548517 --- /dev/null +++ b/deploy/migrations/0012_message_reply_story.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.message_boxes DROP COLUMN reply_to_story_id; +ALTER TABLE public.private_messages DROP COLUMN reply_to_story_id; diff --git a/deploy/migrations/0012_message_reply_story.up.sql b/deploy/migrations/0012_message_reply_story.up.sql new file mode 100644 index 00000000..bbb7c9dc --- /dev/null +++ b/deploy/migrations/0012_message_reply_story.up.sql @@ -0,0 +1,7 @@ +-- Story 回复(评论):用户对一条 story 回复时,客户端发 messages.sendMessage(reply_to= +-- inputReplyToStory{peer, story_id}),消息落入与 story 作者的私聊,并投影为 +-- messageReplyStoryHeader。此前 telesrv 直接拒(STORY_ID_INVALID)导致评论发送失败。 +-- 复用已有的 reply_to_* 列族,新增 reply_to_story_id 持久化被回复的 story id(普通消息回复恒 0)。 +-- 双盒(私聊主体 + per-owner 投影)都需要该列以便重读历史时还原 story 回复头。 +ALTER TABLE public.private_messages ADD COLUMN reply_to_story_id integer DEFAULT 0 NOT NULL; +ALTER TABLE public.message_boxes ADD COLUMN reply_to_story_id integer DEFAULT 0 NOT NULL; diff --git a/deploy/migrations/0012_outbox_delete_on_deliver.down.sql b/deploy/migrations/0012_outbox_delete_on_deliver.down.sql deleted file mode 100644 index 1bc00c19..00000000 --- a/deploy/migrations/0012_outbox_delete_on_deliver.down.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 0012 down: 恢复宽 status CHECK(含 'delivered')以保持迁移链完整。 --- 注意:方案 A 已删除的 delivered 行不可恢复;query 层(DELETE)回退需手动改回 UPDATE,本 down 不涉及代码。 -ALTER TABLE dispatch_outbox DROP CONSTRAINT IF EXISTS dispatch_outbox_status_check; -ALTER TABLE dispatch_outbox ADD CONSTRAINT dispatch_outbox_status_check - CHECK (status IN ('pending', 'dispatching', 'delivered', 'failed')); diff --git a/deploy/migrations/0012_outbox_delete_on_deliver.up.sql b/deploy/migrations/0012_outbox_delete_on_deliver.up.sql deleted file mode 100644 index 449dfb42..00000000 --- a/deploy/migrations/0012_outbox_delete_on_deliver.up.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 0012_outbox_delete_on_deliver: outbox 投递成功改为直接 DELETE(方案 A),杜绝 delivered 行无限堆积。 --- --- 配合 query 改动(MarkDispatchDelivered / MarkDispatchDeliveredBatch 由 UPDATE status='delivered' 改为 DELETE): --- 1) 清理改造前堆积的存量 delivered 行; --- 2) 收紧 status CHECK,移除不再使用的 'delivered'(状态机只剩 pending / dispatching / failed)。 --- dispatch_outbox 为 HASH 分区表,父表 DELETE / ALTER CONSTRAINT 自动作用于全部分区。 - -DELETE FROM dispatch_outbox WHERE status = 'delivered'; - -ALTER TABLE dispatch_outbox DROP CONSTRAINT IF EXISTS dispatch_outbox_status_check; -ALTER TABLE dispatch_outbox ADD CONSTRAINT dispatch_outbox_status_check - CHECK (status IN ('pending', 'dispatching', 'failed')); diff --git a/deploy/migrations/0013_bot_full_read_model_trigger.down.sql b/deploy/migrations/0013_bot_full_read_model_trigger.down.sql new file mode 100644 index 00000000..21ec9381 --- /dev/null +++ b/deploy/migrations/0013_bot_full_read_model_trigger.down.sql @@ -0,0 +1,2 @@ +DROP TRIGGER IF EXISTS users_bot_full_read_model_changed ON public.users; +DROP FUNCTION IF EXISTS public.telesrv_notify_bot_full_read_model(); diff --git a/deploy/migrations/0013_bot_full_read_model_trigger.up.sql b/deploy/migrations/0013_bot_full_read_model_trigger.up.sql new file mode 100644 index 00000000..0ab0495d --- /dev/null +++ b/deploy/migrations/0013_bot_full_read_model_trigger.up.sql @@ -0,0 +1,25 @@ +-- bot 资料(name/about/description/commands/menu_button)变更都会 bump users.bot_info_version +-- (BumpBotInfoVersion,见 bot.sql)。群信息页的 ChannelFull.bot_info 由 RPC 层 +-- channelFullBotInfoCache 缓存(键=viewer+channel,无法按 bot 定位),其跨实例失效此前只挂在 +-- channel_base/channel_member 事件上——bot 自身改资料(经 BotFather 本地路径,或其它实例的 +-- bots.* RPC)不会失效该缓存,导致群信息页里该 bot 的简介/命令陈旧最长 30 分钟(TTL)。 +-- +-- 这里给 bot 资料变更单独发一个 'bot_full' read-model 事件;ReadModelChangeListener 收到即 +-- flush channelFullBotInfoCache(本地 BotFather 路径与跨实例 bots.* RPC 两条更新路径都覆盖)。 +-- 与既有 user_base 事件(覆盖 RPC 投影/Redis user:base/bot 资料缓存)互补,不改动 user_base 热路径。 + +CREATE FUNCTION public.telesrv_notify_bot_full_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NEW.is_bot AND (OLD.bot_info_version IS DISTINCT FROM NEW.bot_info_version) THEN + PERFORM telesrv_bump_read_model_version('bot_full', NEW.id, 'user', NEW.id); + END IF; + RETURN NULL; +END; +$$; + +CREATE TRIGGER users_bot_full_read_model_changed + AFTER UPDATE ON public.users + FOR EACH ROW + EXECUTE FUNCTION public.telesrv_notify_bot_full_read_model(); diff --git a/deploy/migrations/0013_contact_profiles_and_dialog_pins.down.sql b/deploy/migrations/0013_contact_profiles_and_dialog_pins.down.sql deleted file mode 100644 index ac857aa6..00000000 --- a/deploy/migrations/0013_contact_profiles_and_dialog_pins.down.sql +++ /dev/null @@ -1,17 +0,0 @@ -DROP INDEX IF EXISTS dialogs_user_pinned_order_idx; - -ALTER TABLE dialogs - DROP COLUMN IF EXISTS hidden_peer_settings_bar, - DROP COLUMN IF EXISTS unread_mark, - DROP COLUMN IF EXISTS pinned_order; - -DROP INDEX IF EXISTS contacts_user_name_idx; - -ALTER TABLE contacts - DROP COLUMN IF EXISTS stories_hidden, - DROP COLUMN IF EXISTS close_friend, - DROP COLUMN IF EXISTS note_entities, - DROP COLUMN IF EXISTS note, - DROP COLUMN IF EXISTS contact_last_name, - DROP COLUMN IF EXISTS contact_first_name, - DROP COLUMN IF EXISTS contact_phone; diff --git a/deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql b/deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql deleted file mode 100644 index 2efac0ed..00000000 --- a/deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql +++ /dev/null @@ -1,25 +0,0 @@ --- 0013_contact_profiles_and_dialog_pins: owner-scoped contact profile fields and pin order. --- --- contact_* columns are deliberately scoped to (user_id, contact_user_id): they model the --- current account's saved name/phone/note for a peer and must not mutate users global data. - -ALTER TABLE contacts - ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(32) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS contact_first_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS contact_last_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS note_entities JSONB NOT NULL DEFAULT '[]'::jsonb, - ADD COLUMN IF NOT EXISTS close_friend BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS stories_hidden BOOLEAN NOT NULL DEFAULT false; - -CREATE INDEX IF NOT EXISTS contacts_user_name_idx - ON contacts (user_id, contact_first_name, contact_last_name, contact_user_id); - -ALTER TABLE dialogs - ADD COLUMN IF NOT EXISTS pinned_order INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS unread_mark BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS hidden_peer_settings_bar BOOLEAN NOT NULL DEFAULT false; - -CREATE INDEX IF NOT EXISTS dialogs_user_pinned_order_idx - ON dialogs (user_id, pinned, pinned_order, top_message_date DESC, top_message_id DESC, peer_id DESC) - WHERE pinned; diff --git a/deploy/migrations/0014_settings_update_events.down.sql b/deploy/migrations/0014_settings_update_events.down.sql deleted file mode 100644 index a436f80f..00000000 --- a/deploy/migrations/0014_settings_update_events.down.sql +++ /dev/null @@ -1,10 +0,0 @@ -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ('new_message', 'read_history_inbox', 'noop') - ); - -ALTER TABLE user_update_events - DROP COLUMN IF EXISTS event_bool; diff --git a/deploy/migrations/0014_settings_update_events.up.sql b/deploy/migrations/0014_settings_update_events.up.sql deleted file mode 100644 index 23ce7ed3..00000000 --- a/deploy/migrations/0014_settings_update_events.up.sql +++ /dev/null @@ -1,25 +0,0 @@ --- 0014_settings_update_events: durable updates for contacts/dialog settings. --- --- Online push is not enough: offline sessions must recover contact resets, --- dialog pin order changes, manual unread marks, and peer settings changes --- through updates.getDifference. - -ALTER TABLE user_update_events - ADD COLUMN IF NOT EXISTS event_bool BOOLEAN NOT NULL DEFAULT false; - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'noop' - ) - ); diff --git a/deploy/migrations/0014_web_pages.down.sql b/deploy/migrations/0014_web_pages.down.sql new file mode 100644 index 00000000..b909bc4f --- /dev/null +++ b/deploy/migrations/0014_web_pages.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.web_pages; diff --git a/deploy/migrations/0014_web_pages.up.sql b/deploy/migrations/0014_web_pages.up.sql new file mode 100644 index 00000000..b1f6198a --- /dev/null +++ b/deploy/migrations/0014_web_pages.up.sql @@ -0,0 +1,18 @@ +-- 链接预览(webpage preview)解析缓存。键为规范化 URL 的 63-bit 哈希;同一 URL +-- 跨发送/跨实例去重到一行,避免对同一链接重复抓取外网。snapshot 是完整的 +-- domain.MessageWebPage(含已解析卡片字段与内嵌预览图引用),与消息 media 列同构, +-- 读时无需重新抓取或 rehydrate 即可投影为 messageMediaWebPage。 +-- +-- web_page_id 由 url_hash 派生(二者同值),使 pending 占位与 done 解析结果携带 +-- 同一 webPage id,客户端按 id 关联占位与解析(见异步回填阶段)。 +-- state 冗余出来便于后续按 pending/empty 做清扫与刷新。snapshot 不可变(按内容寻址 +-- 的预览图 blob 已独立持久化于 photos/blob 存储),跨实例靠 url_hash 唯一约束去重 + +-- 每实例 TTL,故本表无需 read-model NOTIFY 触发器。 +CREATE TABLE public.web_pages ( + url_hash bigint PRIMARY KEY, + web_page_id bigint NOT NULL, + state text NOT NULL, + snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at bigint NOT NULL DEFAULT 0, + refreshed_at bigint NOT NULL DEFAULT 0 +); diff --git a/deploy/migrations/0015_update_event_payloads_and_outbox_auth.down.sql b/deploy/migrations/0015_update_event_payloads_and_outbox_auth.down.sql deleted file mode 100644 index e1da2023..00000000 --- a/deploy/migrations/0015_update_event_payloads_and_outbox_auth.down.sql +++ /dev/null @@ -1,8 +0,0 @@ -ALTER TABLE dispatch_outbox - DROP COLUMN IF EXISTS exclude_auth_key_id; - -ALTER TABLE user_update_events - DROP COLUMN IF EXISTS peer_settings; - -ALTER TABLE user_update_events - DROP COLUMN IF EXISTS event_peers; diff --git a/deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql b/deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql deleted file mode 100644 index 039d5985..00000000 --- a/deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql +++ /dev/null @@ -1,14 +0,0 @@ --- 0015_update_event_payloads_and_outbox_auth: keep setting-update payloads durable. --- --- event_peers carries ordered dialog peers for updatePinnedDialogs.order. --- peer_settings carries updatePeerSettings flags. --- exclude_auth_key_id makes outbox exclusion precise for same session_id across auth keys. - -ALTER TABLE user_update_events - ADD COLUMN IF NOT EXISTS event_peers JSONB NOT NULL DEFAULT '[]'::jsonb; - -ALTER TABLE user_update_events - ADD COLUMN IF NOT EXISTS peer_settings JSONB NOT NULL DEFAULT '{}'::jsonb; - -ALTER TABLE dispatch_outbox - ADD COLUMN IF NOT EXISTS exclude_auth_key_id BIGINT NOT NULL DEFAULT 0; diff --git a/deploy/migrations/0015_user_update_events_web_page.down.sql b/deploy/migrations/0015_user_update_events_web_page.down.sql new file mode 100644 index 00000000..ee8842ec --- /dev/null +++ b/deploy/migrations/0015_user_update_events_web_page.down.sql @@ -0,0 +1,15 @@ +-- 回退:从白名单移除 'web_page'(与 0003 形态一致)。 +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[]) +); diff --git a/deploy/migrations/0015_user_update_events_web_page.up.sql b/deploy/migrations/0015_user_update_events_web_page.up.sql new file mode 100644 index 00000000..0187d6f7 --- /dev/null +++ b/deploy/migrations/0015_user_update_events_web_page.up.sql @@ -0,0 +1,18 @@ +-- 链接预览异步回填(updateWebPage)新增 user_update_events 事件类型 'web_page':发送时挂的 +-- pending 链接预览占位被带外解析后,经 EditMessage 的 WebPageResolve 模式就地替换并 append +-- 一条 web_page durable 事件(按 webPage id 关联,不标记「已编辑」)。0001/0003 的白名单未含它, +-- 真 Postgres 上 append 会被 CHECK 约束拒绝(SQLSTATE 23514);memory store 无约束故单测未暴露。 +ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check; +ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK ( + (event_type)::text = ANY (ARRAY[ + 'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents', + 'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies', + 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', + 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', + 'peer_settings', 'peer_story_blocked', '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[]) +); diff --git a/deploy/migrations/0016_channel_update_events_web_page.down.sql b/deploy/migrations/0016_channel_update_events_web_page.down.sql new file mode 100644 index 00000000..fb854a14 --- /dev/null +++ b/deploy/migrations/0016_channel_update_events_web_page.down.sql @@ -0,0 +1,8 @@ +-- 回退:从白名单移除 'channel_web_page'。 +ALTER TABLE public.channel_update_events DROP CONSTRAINT IF EXISTS channel_update_events_type_check; +ALTER TABLE public.channel_update_events ADD CONSTRAINT channel_update_events_type_check CHECK ( + (event_type)::text = ANY (ARRAY[ + 'new_channel_message', 'edit_channel_message', 'delete_channel_messages', + 'channel_participant', 'pinned_channel_messages', 'noop' + ]::text[]) +); diff --git a/deploy/migrations/0016_channel_update_events_web_page.up.sql b/deploy/migrations/0016_channel_update_events_web_page.up.sql new file mode 100644 index 00000000..bd2c5e87 --- /dev/null +++ b/deploy/migrations/0016_channel_update_events_web_page.up.sql @@ -0,0 +1,12 @@ +-- 频道链接预览异步回填(updateChannelWebPage)新增 channel_update_events 事件类型 +-- 'channel_web_page':频道消息的 pending 链接预览被带外解析后,经 EditChannelMessage 的 +-- WebPageResolve 模式就地替换并 append 一条 channel_web_page durable 事件。0001 的白名单未含它, +-- 真 Postgres 上 append 会被 CHECK 约束拒绝(SQLSTATE 23514);memory store 无约束故单测未暴露 +-- (同 0015/0003 教训)。 +ALTER TABLE public.channel_update_events DROP CONSTRAINT IF EXISTS channel_update_events_type_check; +ALTER TABLE public.channel_update_events ADD CONSTRAINT channel_update_events_type_check CHECK ( + (event_type)::text = ANY (ARRAY[ + 'new_channel_message', 'edit_channel_message', 'channel_web_page', 'delete_channel_messages', + 'channel_participant', 'pinned_channel_messages', 'noop' + ]::text[]) +); diff --git a/deploy/migrations/0016_delete_message_updates.down.sql b/deploy/migrations/0016_delete_message_updates.down.sql deleted file mode 100644 index e36c9b41..00000000 --- a/deploy/migrations/0016_delete_message_updates.down.sql +++ /dev/null @@ -1,21 +0,0 @@ -DROP INDEX IF EXISTS message_boxes_private_sender_live_idx; - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'noop' - ) - ); - -ALTER TABLE user_update_events - DROP COLUMN IF EXISTS message_ids; diff --git a/deploy/migrations/0016_delete_message_updates.up.sql b/deploy/migrations/0016_delete_message_updates.up.sql deleted file mode 100644 index 17b59c98..00000000 --- a/deploy/migrations/0016_delete_message_updates.up.sql +++ /dev/null @@ -1,29 +0,0 @@ --- 0016_delete_message_updates: durable owner-view delete message updates. --- --- message_ids carries updateDeleteMessages.messages for offline getDifference --- and reliable online outbox delivery. - -ALTER TABLE user_update_events - ADD COLUMN IF NOT EXISTS message_ids JSONB NOT NULL DEFAULT '[]'::jsonb; - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'noop' - ) - ); - -CREATE INDEX IF NOT EXISTS message_boxes_private_sender_live_idx - ON message_boxes (message_sender_id, private_message_id) - WHERE NOT deleted; diff --git a/deploy/migrations/0017_dialog_folders.down.sql b/deploy/migrations/0017_dialog_folders.down.sql deleted file mode 100644 index a07578fb..00000000 --- a/deploy/migrations/0017_dialog_folders.down.sql +++ /dev/null @@ -1,46 +0,0 @@ --- 0017_dialog_folders rollback. - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'noop' - ) - ); - -ALTER TABLE user_update_events - DROP COLUMN IF EXISTS tags_enabled; - -ALTER TABLE user_update_events - DROP COLUMN IF EXISTS filter_id; - -ALTER TABLE user_update_events - DROP COLUMN IF EXISTS folder_peers; - -ALTER TABLE user_update_events - DROP COLUMN IF EXISTS filter_order; - -ALTER TABLE user_update_events - DROP COLUMN IF EXISTS dialog_filter; - -DROP TABLE IF EXISTS dialog_filter_settings CASCADE; - -DROP TABLE IF EXISTS dialog_filters CASCADE; - -DROP INDEX IF EXISTS dialogs_user_folder_top_message_idx; - -ALTER TABLE dialogs - DROP CONSTRAINT IF EXISTS dialogs_folder_id_check; - -ALTER TABLE dialogs - DROP COLUMN IF EXISTS folder_id; diff --git a/deploy/migrations/0017_dialog_folders.up.sql b/deploy/migrations/0017_dialog_folders.up.sql deleted file mode 100644 index 299f9fc7..00000000 --- a/deploy/migrations/0017_dialog_folders.up.sql +++ /dev/null @@ -1,91 +0,0 @@ --- 0017_dialog_folders: archive folder, custom dialog filters, and durable folder updates. - -ALTER TABLE dialogs - ADD COLUMN IF NOT EXISTS folder_id INT NOT NULL DEFAULT 0; - -ALTER TABLE dialogs - DROP CONSTRAINT IF EXISTS dialogs_folder_id_check; - -ALTER TABLE dialogs - ADD CONSTRAINT dialogs_folder_id_check CHECK (folder_id >= 0); - -CREATE INDEX IF NOT EXISTS dialogs_user_folder_top_message_idx - ON dialogs (user_id, folder_id, pinned DESC, top_message_date DESC, top_message_id DESC, peer_id DESC); - -CREATE TABLE IF NOT EXISTS dialog_filters ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - filter_id INT NOT NULL, - is_chatlist BOOLEAN NOT NULL DEFAULT false, - filter JSONB NOT NULL, - order_value INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, filter_id), - CONSTRAINT dialog_filters_id_check CHECK (filter_id >= 2), - CONSTRAINT dialog_filters_filter_object_check CHECK (jsonb_typeof(filter) = 'object') -) PARTITION BY HASH (user_id); - -CREATE INDEX IF NOT EXISTS dialog_filters_user_order_idx - ON dialog_filters (user_id, order_value, filter_id); - -CREATE TABLE IF NOT EXISTS dialog_filter_settings ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - tags_enabled BOOLEAN NOT NULL DEFAULT false, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id) -) PARTITION BY HASH (user_id); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS dialog_filters_p%s PARTITION OF dialog_filters FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS dialog_filter_settings_p%s PARTITION OF dialog_filter_settings FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -ALTER TABLE user_update_events - ADD COLUMN IF NOT EXISTS dialog_filter JSONB NOT NULL DEFAULT '{}'::jsonb; - -ALTER TABLE user_update_events - ADD COLUMN IF NOT EXISTS filter_order JSONB NOT NULL DEFAULT '[]'::jsonb; - -ALTER TABLE user_update_events - ADD COLUMN IF NOT EXISTS folder_peers JSONB NOT NULL DEFAULT '[]'::jsonb; - -ALTER TABLE user_update_events - ADD COLUMN IF NOT EXISTS filter_id INT NOT NULL DEFAULT 0; - -ALTER TABLE user_update_events - ADD COLUMN IF NOT EXISTS tags_enabled BOOLEAN NOT NULL DEFAULT false; - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'dialog_filter', - 'dialog_filter_order', - 'dialog_filters', - 'folder_peers', - 'noop' - ) - ); diff --git a/deploy/migrations/0017_login_email.down.sql b/deploy/migrations/0017_login_email.down.sql new file mode 100644 index 00000000..75913433 --- /dev/null +++ b/deploy/migrations/0017_login_email.down.sql @@ -0,0 +1 @@ +ALTER TABLE public.account_passwords DROP COLUMN login_email; diff --git a/deploy/migrations/0017_login_email.up.sql b/deploy/migrations/0017_login_email.up.sql new file mode 100644 index 00000000..fe5fcd42 --- /dev/null +++ b/deploy/migrations/0017_login_email.up.sql @@ -0,0 +1,4 @@ +-- 登录邮箱(login email):独立于 2FA 恢复邮箱的备用登录方式。新设备登录时验证码 +-- 改投递到该邮箱(auth.sentCodeTypeEmailCode)。仅存已确认的真实地址,下发时掩码。 +ALTER TABLE public.account_passwords + ADD COLUMN login_email character varying(256) DEFAULT ''::character varying NOT NULL; diff --git a/deploy/migrations/0018_passkey_credentials.down.sql b/deploy/migrations/0018_passkey_credentials.down.sql new file mode 100644 index 00000000..a7339269 --- /dev/null +++ b/deploy/migrations/0018_passkey_credentials.down.sql @@ -0,0 +1 @@ +DROP TABLE public.passkey_credentials; diff --git a/deploy/migrations/0018_passkey_credentials.up.sql b/deploy/migrations/0018_passkey_credentials.up.sql new file mode 100644 index 00000000..69b976db --- /dev/null +++ b/deploy/migrations/0018_passkey_credentials.up.sql @@ -0,0 +1,18 @@ +-- Passkey(WebAuthn/FIDO2)凭据。每条 = 一个已注册的公钥。sign_count 用 bigint +-- 容纳 uint32;credential_id 为主键(原始字节,对外 base64url)。 +CREATE TABLE public.passkey_credentials ( + credential_id bytea NOT NULL, + user_id bigint NOT NULL, + public_key bytea NOT NULL, + sign_count bigint DEFAULT 0 NOT NULL, + aaguid bytea DEFAULT '\x'::bytea NOT NULL, + name character varying(128) DEFAULT ''::character varying NOT NULL, + transports text[] DEFAULT '{}'::text[] NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + last_used_at timestamp with time zone, + CONSTRAINT passkey_credentials_pkey PRIMARY KEY (credential_id), + CONSTRAINT passkey_credentials_user_id_fkey FOREIGN KEY (user_id) + REFERENCES public.users(id) ON DELETE CASCADE +); + +CREATE INDEX passkey_credentials_user_id_idx ON public.passkey_credentials (user_id); diff --git a/deploy/migrations/0018_user_search_indexes.down.sql b/deploy/migrations/0018_user_search_indexes.down.sql deleted file mode 100644 index 5e1ffb96..00000000 --- a/deploy/migrations/0018_user_search_indexes.down.sql +++ /dev/null @@ -1,5 +0,0 @@ -DROP INDEX IF EXISTS message_boxes_body_trgm_idx; -DROP INDEX IF EXISTS contacts_user_saved_name_trgm_idx; -DROP INDEX IF EXISTS users_name_lower_trgm_idx; -DROP INDEX IF EXISTS users_username_lower_trgm_idx; -DROP INDEX IF EXISTS users_phone_prefix_idx; diff --git a/deploy/migrations/0018_user_search_indexes.up.sql b/deploy/migrations/0018_user_search_indexes.up.sql deleted file mode 100644 index 4512f05c..00000000 --- a/deploy/migrations/0018_user_search_indexes.up.sql +++ /dev/null @@ -1,19 +0,0 @@ --- 0018_user_search_indexes: keep TDesktop global user search bounded as users grow. - -CREATE EXTENSION IF NOT EXISTS pg_trgm; - -CREATE INDEX IF NOT EXISTS users_phone_prefix_idx - ON users (phone text_pattern_ops); - -CREATE INDEX IF NOT EXISTS users_username_lower_trgm_idx - ON users USING gin (lower(username) gin_trgm_ops); - -CREATE INDEX IF NOT EXISTS users_name_lower_trgm_idx - ON users USING gin (lower(trim(first_name || ' ' || last_name)) gin_trgm_ops); - -CREATE INDEX IF NOT EXISTS contacts_user_saved_name_trgm_idx - ON contacts USING gin (lower(trim(contact_first_name || ' ' || contact_last_name)) gin_trgm_ops); - -CREATE INDEX IF NOT EXISTS message_boxes_body_trgm_idx - ON message_boxes USING gin (body gin_trgm_ops) - WHERE NOT deleted AND body <> ''; diff --git a/deploy/migrations/0019_custom_themes.down.sql b/deploy/migrations/0019_custom_themes.down.sql new file mode 100644 index 00000000..a7c40648 --- /dev/null +++ b/deploy/migrations/0019_custom_themes.down.sql @@ -0,0 +1,2 @@ +DROP TABLE public.theme_user_installs; +DROP TABLE public.themes; diff --git a/deploy/migrations/0019_custom_themes.up.sql b/deploy/migrations/0019_custom_themes.up.sql new file mode 100644 index 00000000..f24d1557 --- /dev/null +++ b/deploy/migrations/0019_custom_themes.up.sql @@ -0,0 +1,37 @@ +-- 自定义云主题(account.createTheme/uploadTheme 等)。themes = 用户创建的主题目录, +-- document_id 软引用 documents(无硬外键,避免与媒体表耦合;主题可能比 blob 长存, +-- getTheme 时若 blob 缺失则退化为无 document)。settings = []domain.ThemeSettingsSpec 的 +-- JSONB(仅 accent 主题非空)。theme_user_installs = 每用户已安装/已存主题列表。 +CREATE TABLE public.themes ( + id bigint NOT NULL, + access_hash bigint DEFAULT 0 NOT NULL, + creator_user_id bigint NOT NULL, + slug text NOT NULL, + title text DEFAULT ''::text NOT NULL, + emoticon text DEFAULT ''::text NOT NULL, + for_chat boolean DEFAULT false NOT NULL, + document_id bigint DEFAULT 0 NOT NULL, + settings jsonb DEFAULT '[]'::jsonb NOT NULL, + installs_count integer DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT themes_pkey PRIMARY KEY (id), + CONSTRAINT themes_slug_key UNIQUE (slug), + CONSTRAINT themes_creator_user_id_fkey FOREIGN KEY (creator_user_id) + REFERENCES public.users(id) ON DELETE CASCADE +); + +CREATE INDEX themes_creator_user_id_idx ON public.themes (creator_user_id); + +CREATE TABLE public.theme_user_installs ( + user_id bigint NOT NULL, + theme_id bigint NOT NULL, + dark boolean DEFAULT false NOT NULL, + installed_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT theme_user_installs_pkey PRIMARY KEY (user_id, theme_id), + CONSTRAINT theme_user_installs_user_id_fkey FOREIGN KEY (user_id) + REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT theme_user_installs_theme_id_fkey FOREIGN KEY (theme_id) + REFERENCES public.themes(id) ON DELETE CASCADE +); + +CREATE INDEX theme_user_installs_user_id_idx ON public.theme_user_installs (user_id); diff --git a/deploy/migrations/0019_usernames.down.sql b/deploy/migrations/0019_usernames.down.sql deleted file mode 100644 index 784e133b..00000000 --- a/deploy/migrations/0019_usernames.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP INDEX IF EXISTS users_username_lower_unique_idx; diff --git a/deploy/migrations/0019_usernames.up.sql b/deploy/migrations/0019_usernames.up.sql deleted file mode 100644 index 198c7dbe..00000000 --- a/deploy/migrations/0019_usernames.up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 0019_usernames: primary username lifecycle. - -CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique_idx - ON users (lower(username)) - WHERE username <> ''; diff --git a/deploy/migrations/0020_channel_monoforum.down.sql b/deploy/migrations/0020_channel_monoforum.down.sql new file mode 100644 index 00000000..4a1f95db --- /dev/null +++ b/deploy/migrations/0020_channel_monoforum.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.channels DROP COLUMN IF EXISTS linked_monoforum_id; +ALTER TABLE public.channels DROP COLUMN IF EXISTS monoforum; diff --git a/deploy/migrations/0020_channel_monoforum.up.sql b/deploy/migrations/0020_channel_monoforum.up.sql new file mode 100644 index 00000000..5d286015 --- /dev/null +++ b/deploy/migrations/0020_channel_monoforum.up.sql @@ -0,0 +1,6 @@ +-- 频道私信(Direct Messages):母广播频道关联一个 monoforum 虚拟频道承载订阅者私信。 +-- monoforum=true 标记该频道是 monoforum;linked_monoforum_id 双向关联母频道 ↔ monoforum。 +ALTER TABLE public.channels + ADD COLUMN monoforum boolean DEFAULT false NOT NULL; +ALTER TABLE public.channels + ADD COLUMN linked_monoforum_id bigint DEFAULT 0 NOT NULL; diff --git a/deploy/migrations/0020_profile_message_state.down.sql b/deploy/migrations/0020_profile_message_state.down.sql deleted file mode 100644 index e1790042..00000000 --- a/deploy/migrations/0020_profile_message_state.down.sql +++ /dev/null @@ -1,34 +0,0 @@ -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'dialog_filter', - 'dialog_filter_order', - 'dialog_filters', - 'folder_peers', - 'noop' - ) - ); - -DROP INDEX IF EXISTS message_boxes_private_sender_owner_idx; -DROP INDEX IF EXISTS user_update_events_read_outbox_idx; -DROP INDEX IF EXISTS message_boxes_read_receipt_idx; - -ALTER TABLE message_boxes - DROP COLUMN IF EXISTS edit_date; - -ALTER TABLE private_messages - DROP COLUMN IF EXISTS edit_date; - -ALTER TABLE users - DROP COLUMN IF EXISTS about; diff --git a/deploy/migrations/0020_profile_message_state.up.sql b/deploy/migrations/0020_profile_message_state.up.sql deleted file mode 100644 index f4e2abce..00000000 --- a/deploy/migrations/0020_profile_message_state.up.sql +++ /dev/null @@ -1,46 +0,0 @@ --- 0020_profile_message_state: profile about, message edits, and read outbox updates. - -ALTER TABLE users - ADD COLUMN IF NOT EXISTS about VARCHAR(255) NOT NULL DEFAULT ''; - -ALTER TABLE private_messages - ADD COLUMN IF NOT EXISTS edit_date INT NOT NULL DEFAULT 0; - -ALTER TABLE message_boxes - ADD COLUMN IF NOT EXISTS edit_date INT NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS message_boxes_read_receipt_idx - ON message_boxes (owner_user_id, peer_type, peer_id, box_id DESC) - WHERE NOT deleted AND NOT outgoing; - -CREATE INDEX IF NOT EXISTS message_boxes_private_sender_owner_idx - ON message_boxes (message_sender_id, private_message_id, owner_user_id) - WHERE NOT deleted; - -CREATE INDEX IF NOT EXISTS user_update_events_read_outbox_idx - ON user_update_events (user_id, peer_type, peer_id, max_id, date) - WHERE event_type = 'read_history_outbox'; - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'read_history_outbox', - 'edit_message', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'dialog_filter', - 'dialog_filter_order', - 'dialog_filters', - 'folder_peers', - 'noop' - ) - ); diff --git a/deploy/migrations/0021_channel_message_saved_peer.down.sql b/deploy/migrations/0021_channel_message_saved_peer.down.sql new file mode 100644 index 00000000..fcdd425e --- /dev/null +++ b/deploy/migrations/0021_channel_message_saved_peer.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS channel_messages_monoforum_sublist_idx; +ALTER TABLE public.channel_messages DROP COLUMN IF EXISTS saved_peer_id; +ALTER TABLE public.channel_messages DROP COLUMN IF EXISTS saved_peer_type; diff --git a/deploy/migrations/0021_channel_message_saved_peer.up.sql b/deploy/migrations/0021_channel_message_saved_peer.up.sql new file mode 100644 index 00000000..652a172c --- /dev/null +++ b/deploy/migrations/0021_channel_message_saved_peer.up.sql @@ -0,0 +1,9 @@ +-- 频道私信(monoforum):私信消息存进 channel_messages(复用 channel pts/事件/difference), +-- 用 saved_peer 维度按订阅者分子会话。普通频道消息 saved_peer 为空,部分索引仅覆盖 monoforum 行。 +ALTER TABLE public.channel_messages + ADD COLUMN saved_peer_type character varying(16) DEFAULT ''::character varying NOT NULL; +ALTER TABLE public.channel_messages + ADD COLUMN saved_peer_id bigint DEFAULT 0 NOT NULL; +CREATE INDEX channel_messages_monoforum_sublist_idx ON public.channel_messages + USING btree (channel_id, saved_peer_id, id DESC) + WHERE ((saved_peer_id <> 0) AND (NOT deleted)); diff --git a/deploy/migrations/0021_message_reply_forward.down.sql b/deploy/migrations/0021_message_reply_forward.down.sql deleted file mode 100644 index b99ad932..00000000 --- a/deploy/migrations/0021_message_reply_forward.down.sql +++ /dev/null @@ -1,31 +0,0 @@ -DROP INDEX IF EXISTS message_boxes_reply_lookup_idx; - -ALTER TABLE message_boxes - DROP COLUMN IF EXISTS fwd_date, - DROP COLUMN IF EXISTS fwd_from_name, - DROP COLUMN IF EXISTS fwd_from_peer_id, - DROP COLUMN IF EXISTS fwd_from_peer_type, - DROP COLUMN IF EXISTS quote_offset, - DROP COLUMN IF EXISTS quote_entities, - DROP COLUMN IF EXISTS quote_text, - DROP COLUMN IF EXISTS reply_to_top_id, - DROP COLUMN IF EXISTS reply_to_peer_id, - DROP COLUMN IF EXISTS reply_to_peer_type, - DROP COLUMN IF EXISTS reply_to_msg_id, - DROP COLUMN IF EXISTS noforwards, - DROP COLUMN IF EXISTS silent; - -ALTER TABLE private_messages - DROP COLUMN IF EXISTS fwd_date, - DROP COLUMN IF EXISTS fwd_from_name, - DROP COLUMN IF EXISTS fwd_from_peer_id, - DROP COLUMN IF EXISTS fwd_from_peer_type, - DROP COLUMN IF EXISTS quote_offset, - DROP COLUMN IF EXISTS quote_entities, - DROP COLUMN IF EXISTS quote_text, - DROP COLUMN IF EXISTS reply_to_top_id, - DROP COLUMN IF EXISTS reply_to_peer_id, - DROP COLUMN IF EXISTS reply_to_peer_type, - DROP COLUMN IF EXISTS reply_to_msg_id, - DROP COLUMN IF EXISTS noforwards, - DROP COLUMN IF EXISTS silent; diff --git a/deploy/migrations/0021_message_reply_forward.up.sql b/deploy/migrations/0021_message_reply_forward.up.sql deleted file mode 100644 index 991a5d72..00000000 --- a/deploy/migrations/0021_message_reply_forward.up.sql +++ /dev/null @@ -1,35 +0,0 @@ --- 0021_message_reply_forward: private-message silent/reply/forward metadata. - -ALTER TABLE private_messages - ADD COLUMN IF NOT EXISTS silent BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS noforwards BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS reply_to_msg_id INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS reply_to_peer_type VARCHAR(16) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS reply_to_peer_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS reply_to_top_id INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS quote_text TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS quote_entities JSONB NOT NULL DEFAULT '[]'::jsonb, - ADD COLUMN IF NOT EXISTS quote_offset INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS fwd_from_peer_type VARCHAR(16) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS fwd_from_peer_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS fwd_from_name TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS fwd_date INT NOT NULL DEFAULT 0; - -ALTER TABLE message_boxes - ADD COLUMN IF NOT EXISTS silent BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS noforwards BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS reply_to_msg_id INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS reply_to_peer_type VARCHAR(16) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS reply_to_peer_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS reply_to_top_id INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS quote_text TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS quote_entities JSONB NOT NULL DEFAULT '[]'::jsonb, - ADD COLUMN IF NOT EXISTS quote_offset INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS fwd_from_peer_type VARCHAR(16) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS fwd_from_peer_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS fwd_from_name TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS fwd_date INT NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS message_boxes_reply_lookup_idx - ON message_boxes (owner_user_id, peer_type, peer_id, box_id) - WHERE NOT deleted; diff --git a/deploy/migrations/0022_channel_messages_monoforum_unique.down.sql b/deploy/migrations/0022_channel_messages_monoforum_unique.down.sql new file mode 100644 index 00000000..28bda85d --- /dev/null +++ b/deploy/migrations/0022_channel_messages_monoforum_unique.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS channel_messages_random_idx; +CREATE UNIQUE INDEX channel_messages_random_idx ON public.channel_messages + USING btree (channel_id, sender_user_id, random_id) WHERE (random_id <> 0); diff --git a/deploy/migrations/0022_channel_messages_monoforum_unique.up.sql b/deploy/migrations/0022_channel_messages_monoforum_unique.up.sql new file mode 100644 index 00000000..7b62b918 --- /dev/null +++ b/deploy/migrations/0022_channel_messages_monoforum_unique.up.sql @@ -0,0 +1,6 @@ +-- 频道私信(monoforum):同一发件人(尤其频道管理员)可向不同订阅者子会话用相同 random_id 发消息, +-- 幂等唯一性必须含 saved_peer,否则跨子会话复用 random_id 会被旧三元组唯一索引误拒/误判为重复。 +-- 普通频道消息 saved_peer_id=0 恒定,新四元组等价于原 (channel_id,sender_user_id,random_id),无行为变化。 +DROP INDEX IF EXISTS channel_messages_random_idx; +CREATE UNIQUE INDEX channel_messages_random_idx ON public.channel_messages + USING btree (channel_id, sender_user_id, saved_peer_id, random_id) WHERE (random_id <> 0); diff --git a/deploy/migrations/0022_channels.down.sql b/deploy/migrations/0022_channels.down.sql deleted file mode 100644 index 453c47b0..00000000 --- a/deploy/migrations/0022_channels.down.sql +++ /dev/null @@ -1,9 +0,0 @@ -DROP TABLE IF EXISTS channel_invites CASCADE; -DROP TABLE IF EXISTS channel_dialogs CASCADE; -DROP TABLE IF EXISTS channel_admin_log_events CASCADE; -DROP TABLE IF EXISTS channel_update_events CASCADE; -DROP TABLE IF EXISTS channel_messages CASCADE; -DROP TABLE IF EXISTS channel_members CASCADE; -DROP TABLE IF EXISTS channel_usernames CASCADE; -DROP TABLE IF EXISTS channel_invite_hashes CASCADE; -DROP TABLE IF EXISTS channels CASCADE; diff --git a/deploy/migrations/0022_channels.up.sql b/deploy/migrations/0022_channels.up.sql deleted file mode 100644 index ed8ed51e..00000000 --- a/deploy/migrations/0022_channels.up.sql +++ /dev/null @@ -1,398 +0,0 @@ --- 0022_channels: supergroup/channel storage. --- --- Channel messages are single-copy. Per-user dialog/read state is stored separately. --- Channel pts is scoped by channel_id and persisted in channel_update_events. - -CREATE TABLE IF NOT EXISTS channels ( - id BIGINT NOT NULL, - access_hash BIGINT NOT NULL, - creator_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - title TEXT NOT NULL, - about TEXT NOT NULL DEFAULT '', - username TEXT, - broadcast BOOLEAN NOT NULL DEFAULT false, - megagroup BOOLEAN NOT NULL DEFAULT false, - forum BOOLEAN NOT NULL DEFAULT false, - forum_tabs BOOLEAN NOT NULL DEFAULT false, - noforwards BOOLEAN NOT NULL DEFAULT false, - join_to_send BOOLEAN NOT NULL DEFAULT false, - join_request BOOLEAN NOT NULL DEFAULT false, - signatures BOOLEAN NOT NULL DEFAULT false, - pre_history_hidden BOOLEAN NOT NULL DEFAULT false, - participants_hidden BOOLEAN NOT NULL DEFAULT false, - antispam BOOLEAN NOT NULL DEFAULT false, - linked_chat_id BIGINT NOT NULL DEFAULT 0, - slowmode_seconds INT NOT NULL DEFAULT 0, - default_banned_rights JSONB NOT NULL DEFAULT '{}'::jsonb, - available_reactions JSONB NOT NULL DEFAULT '{}'::jsonb, - color_set BOOLEAN NOT NULL DEFAULT false, - color INT NOT NULL DEFAULT 0, - color_background_emoji_id BIGINT NOT NULL DEFAULT 0, - profile_color_set BOOLEAN NOT NULL DEFAULT false, - profile_color INT NOT NULL DEFAULT 0, - profile_color_background_emoji_id BIGINT NOT NULL DEFAULT 0, - emoji_status_document_id BIGINT NOT NULL DEFAULT 0, - emoji_status_until INT NOT NULL DEFAULT 0, - participants_count INT NOT NULL DEFAULT 0, - admins_count INT NOT NULL DEFAULT 0, - kicked_count INT NOT NULL DEFAULT 0, - banned_count INT NOT NULL DEFAULT 0, - top_message_id INT NOT NULL DEFAULT 0, - pts INT NOT NULL DEFAULT 0, - admin_log_seq BIGINT NOT NULL DEFAULT 0, - ttl_period INT NOT NULL DEFAULT 0, - date INT NOT NULL, - deleted BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (id), - CONSTRAINT channels_kind_check CHECK ( - ((broadcast AND NOT megagroup AND NOT forum) - OR (megagroup AND NOT broadcast)) - AND (NOT forum_tabs OR forum) - ), - CONSTRAINT channels_title_nonempty_check CHECK (title <> '') -) PARTITION BY HASH (id); - -CREATE UNIQUE INDEX IF NOT EXISTS channels_access_hash_idx - ON channels (id, access_hash); -CREATE INDEX IF NOT EXISTS channels_creator_idx - ON channels (creator_user_id, id DESC) - WHERE NOT deleted; -CREATE INDEX IF NOT EXISTS channels_linked_chat_idx - ON channels (linked_chat_id) - WHERE linked_chat_id <> 0 AND NOT deleted; - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channels_p%s PARTITION OF channels FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - --- PostgreSQL global unique indexes on partitioned tables must include the partition key. --- Keep username uniqueness in a compact lookup table instead of relying on channels(username). -CREATE TABLE IF NOT EXISTS channel_usernames ( - username_lower TEXT PRIMARY KEY, - channel_id BIGINT NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT channel_usernames_nonempty_check CHECK (username_lower <> '') -); - -CREATE TABLE IF NOT EXISTS channel_members ( - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - inviter_user_id BIGINT NOT NULL DEFAULT 0, - role VARCHAR(16) NOT NULL DEFAULT 'member', - status VARCHAR(16) NOT NULL DEFAULT 'active', - joined_at INT NOT NULL DEFAULT 0, - left_at INT NOT NULL DEFAULT 0, - admin_rights JSONB NOT NULL DEFAULT '{}'::jsonb, - banned_rights JSONB NOT NULL DEFAULT '{}'::jsonb, - rank TEXT NOT NULL DEFAULT '', - available_min_id INT NOT NULL DEFAULT 0, - available_min_pts INT NOT NULL DEFAULT 0, - read_inbox_max_id INT NOT NULL DEFAULT 0, - read_inbox_date INT NOT NULL DEFAULT 0, - read_outbox_max_id INT NOT NULL DEFAULT 0, - unread_mark BOOLEAN NOT NULL DEFAULT false, - slowmode_last_send_date INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, user_id), - CONSTRAINT channel_members_role_check CHECK (role IN ('creator', 'admin', 'member')), - CONSTRAINT channel_members_status_check CHECK (status IN ('active', 'left', 'kicked', 'banned')) -) PARTITION BY HASH (channel_id); - -CREATE INDEX IF NOT EXISTS channel_members_user_active_idx - ON channel_members (user_id, channel_id) - WHERE status = 'active'; -CREATE INDEX IF NOT EXISTS channel_members_user_left_idx - ON channel_members (user_id, left_at DESC, channel_id DESC) - WHERE status = 'left'; -CREATE INDEX IF NOT EXISTS channel_members_channel_role_idx - ON channel_members (channel_id, role, user_id) - WHERE status = 'active'; -CREATE INDEX IF NOT EXISTS channel_members_read_participants_idx - ON channel_members (channel_id, read_inbox_max_id, user_id) - WHERE status = 'active'; - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_members_p%s PARTITION OF channel_members FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -CREATE TABLE IF NOT EXISTS channel_messages ( - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - id INT NOT NULL, - random_id BIGINT NOT NULL DEFAULT 0, - sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - from_peer_type VARCHAR(16) NOT NULL DEFAULT 'user', - from_peer_id BIGINT NOT NULL, - send_as_peer_type VARCHAR(16), - send_as_peer_id BIGINT, - message_date INT NOT NULL, - edit_date INT NOT NULL DEFAULT 0, - post BOOLEAN NOT NULL DEFAULT false, - silent BOOLEAN NOT NULL DEFAULT false, - noforwards BOOLEAN NOT NULL DEFAULT false, - body TEXT NOT NULL DEFAULT '', - entities JSONB NOT NULL DEFAULT '[]'::jsonb, - reply_to JSONB NOT NULL DEFAULT '{}'::jsonb, - reply_to_msg_id INT NOT NULL DEFAULT 0, - reply_to_peer_type VARCHAR(16) NOT NULL DEFAULT '', - reply_to_peer_id BIGINT NOT NULL DEFAULT 0, - reply_to_top_id INT NOT NULL DEFAULT 0, - fwd_from JSONB NOT NULL DEFAULT '{}'::jsonb, - discussion_channel_id BIGINT NOT NULL DEFAULT 0, - discussion_message_id INT NOT NULL DEFAULT 0, - action JSONB NOT NULL DEFAULT '{}'::jsonb, - pts INT NOT NULL, - deleted BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, id), - CONSTRAINT channel_messages_peer_type_check CHECK ( - from_peer_type IN ('user', 'channel') - AND (send_as_peer_type IS NULL OR send_as_peer_type IN ('user', 'channel')) - AND (reply_to_peer_type = '' OR reply_to_peer_type IN ('user', 'channel')) - ), - CONSTRAINT channel_messages_content_check CHECK (body <> '' OR action <> '{}'::jsonb) -) PARTITION BY HASH (channel_id); - -CREATE UNIQUE INDEX IF NOT EXISTS channel_messages_random_idx - ON channel_messages (channel_id, sender_user_id, random_id) - WHERE random_id <> 0; -CREATE INDEX IF NOT EXISTS channel_messages_history_idx - ON channel_messages (channel_id, id DESC) - WHERE NOT deleted; -CREATE INDEX IF NOT EXISTS channel_messages_sender_history_idx - ON channel_messages (channel_id, sender_user_id, id DESC) - WHERE NOT deleted; -CREATE INDEX IF NOT EXISTS channel_messages_date_idx - ON channel_messages (channel_id, message_date DESC, id DESC) - WHERE NOT deleted; -CREATE INDEX IF NOT EXISTS channel_messages_reply_thread_idx - ON channel_messages (channel_id, reply_to_top_id, id DESC) - WHERE reply_to_top_id > 0 AND NOT deleted; -CREATE INDEX IF NOT EXISTS channel_messages_discussion_ref_idx - ON channel_messages (discussion_channel_id, discussion_message_id) - WHERE discussion_channel_id <> 0 AND discussion_message_id <> 0 AND NOT deleted; - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_messages_p%s PARTITION OF channel_messages FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -CREATE TABLE IF NOT EXISTS channel_update_events ( - channel_id BIGINT NOT NULL REFERENCES channels(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 NOT NULL DEFAULT 0, - message_ids JSONB NOT NULL DEFAULT '[]'::jsonb, - sender_user_id BIGINT NOT NULL DEFAULT 0, - user_ids JSONB NOT NULL DEFAULT '[]'::jsonb, - payload JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, pts), - CONSTRAINT channel_update_events_pts_count_check CHECK (pts_count > 0), - CONSTRAINT channel_update_events_type_check CHECK ( - event_type IN ( - 'new_channel_message', - 'edit_channel_message', - 'delete_channel_messages', - 'channel_participant', - 'pinned_channel_messages', - 'noop' - ) - ) -) PARTITION BY HASH (channel_id); - -CREATE INDEX IF NOT EXISTS channel_update_events_scan_idx - ON channel_update_events (channel_id, pts ASC); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_update_events_p%s PARTITION OF channel_update_events FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -CREATE TABLE IF NOT EXISTS channel_admin_log_events ( - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - id BIGINT NOT NULL, - actor_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - event_date INT NOT NULL, - event_type VARCHAR(48) NOT NULL, - prev_string TEXT NOT NULL DEFAULT '', - new_string TEXT NOT NULL DEFAULT '', - prev_bool BOOLEAN NOT NULL DEFAULT false, - new_bool BOOLEAN NOT NULL DEFAULT false, - prev_int INT NOT NULL DEFAULT 0, - new_int INT NOT NULL DEFAULT 0, - prev_participant JSONB NOT NULL DEFAULT '{}'::jsonb, - new_participant JSONB NOT NULL DEFAULT '{}'::jsonb, - participant JSONB NOT NULL DEFAULT '{}'::jsonb, - message JSONB NOT NULL DEFAULT '{}'::jsonb, - prev_message JSONB NOT NULL DEFAULT '{}'::jsonb, - new_message JSONB NOT NULL DEFAULT '{}'::jsonb, - query TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, id), - CONSTRAINT channel_admin_log_events_type_check CHECK ( - event_type IN ( - 'change_title', - 'change_username', - 'change_linked_chat', - 'toggle_signatures', - 'toggle_pre_history_hidden', - 'toggle_forum', - 'toggle_anti_spam', - 'toggle_slow_mode', - 'participant_invite', - 'participant_join', - 'participant_leave', - 'participant_promote', - 'participant_demote', - 'participant_ban', - 'participant_unban', - 'participant_kick', - 'participant_unkick', - 'update_pinned', - 'send_message', - 'edit_message', - 'delete_message' - ) - ) -) PARTITION BY HASH (channel_id); - -CREATE INDEX IF NOT EXISTS channel_admin_log_events_scan_idx - ON channel_admin_log_events (channel_id, id DESC); -CREATE INDEX IF NOT EXISTS channel_admin_log_events_actor_idx - ON channel_admin_log_events (channel_id, actor_user_id, id DESC); -CREATE INDEX IF NOT EXISTS channel_admin_log_events_type_idx - ON channel_admin_log_events (channel_id, event_type, id DESC); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_admin_log_events_p%s PARTITION OF channel_admin_log_events FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -CREATE TABLE IF NOT EXISTS channel_dialogs ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - folder_id INT NOT NULL DEFAULT 0, - top_message_id INT NOT NULL DEFAULT 0, - top_message_date 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, - pinned_order INT NOT NULL DEFAULT 0, - unread_mark BOOLEAN NOT NULL DEFAULT false, - view_forum_as_messages BOOLEAN NOT NULL DEFAULT false, - notify_settings JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, channel_id), - CONSTRAINT channel_dialogs_folder_id_check CHECK (folder_id >= 0) -) PARTITION BY HASH (user_id); - -CREATE INDEX IF NOT EXISTS channel_dialogs_user_top_idx - ON channel_dialogs (user_id, folder_id, pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC); -CREATE INDEX IF NOT EXISTS channel_dialogs_pinned_idx - ON channel_dialogs (user_id, pinned) - WHERE pinned; - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_dialogs_p%s PARTITION OF channel_dialogs FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; - -CREATE TABLE IF NOT EXISTS channel_invites ( - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - invite_id BIGINT NOT NULL, - hash TEXT NOT NULL, - admin_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - title TEXT NOT NULL DEFAULT '', - permanent BOOLEAN NOT NULL DEFAULT false, - revoked BOOLEAN NOT NULL DEFAULT false, - request_needed BOOLEAN NOT NULL DEFAULT false, - expire_date INT, - usage_limit INT, - usage_count INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, invite_id) -) PARTITION BY HASH (channel_id); - --- Keep invite hash uniqueness outside the partitioned table because PostgreSQL --- requires global unique indexes on partitions to include the partition key. -CREATE TABLE IF NOT EXISTS channel_invite_hashes ( - hash TEXT PRIMARY KEY, - channel_id BIGINT NOT NULL, - invite_id BIGINT NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT channel_invite_hashes_nonempty_check CHECK (hash <> '') -); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_invites_p%s PARTITION OF channel_invites FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; diff --git a/deploy/migrations/0023_channel_admin_invites.down.sql b/deploy/migrations/0023_channel_admin_invites.down.sql deleted file mode 100644 index 78a09dda..00000000 --- a/deploy/migrations/0023_channel_admin_invites.down.sql +++ /dev/null @@ -1,18 +0,0 @@ -DROP INDEX IF EXISTS channel_invites_hash_lookup_idx; - -ALTER TABLE channel_update_events - DROP CONSTRAINT IF EXISTS channel_update_events_type_check; - -ALTER TABLE channel_update_events - ADD CONSTRAINT channel_update_events_type_check CHECK ( - event_type IN ( - 'new_channel_message', - 'edit_channel_message', - 'delete_channel_messages', - 'channel_participant', - 'noop' - ) - ); - -ALTER TABLE channels - DROP COLUMN IF EXISTS pinned_message_id; diff --git a/deploy/migrations/0023_channel_admin_invites.up.sql b/deploy/migrations/0023_channel_admin_invites.up.sql deleted file mode 100644 index 94015352..00000000 --- a/deploy/migrations/0023_channel_admin_invites.up.sql +++ /dev/null @@ -1,22 +0,0 @@ --- 0023_channel_admin_invites: metadata needed by channel admin/pin/invite RPCs. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS pinned_message_id INT NOT NULL DEFAULT 0; - -ALTER TABLE channel_update_events - DROP CONSTRAINT IF EXISTS channel_update_events_type_check; - -ALTER TABLE channel_update_events - ADD CONSTRAINT channel_update_events_type_check CHECK ( - event_type IN ( - 'new_channel_message', - 'edit_channel_message', - 'delete_channel_messages', - 'channel_participant', - 'pinned_channel_messages', - 'noop' - ) - ); - -CREATE INDEX IF NOT EXISTS channel_invites_hash_lookup_idx - ON channel_invite_hashes (hash, channel_id, invite_id); diff --git a/deploy/migrations/0023_message_effect.down.sql b/deploy/migrations/0023_message_effect.down.sql new file mode 100644 index 00000000..d85f66a1 --- /dev/null +++ b/deploy/migrations/0023_message_effect.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.message_boxes DROP COLUMN effect; +ALTER TABLE public.private_messages DROP COLUMN effect; diff --git a/deploy/migrations/0023_message_effect.up.sql b/deploy/migrations/0023_message_effect.up.sql new file mode 100644 index 00000000..24e7b010 --- /dev/null +++ b/deploy/migrations/0023_message_effect.up.sql @@ -0,0 +1,6 @@ +-- 消息特效(message effect):messages.sendMessage/sendMedia 的 effect:flags2.2?long, +-- 私聊 1-1 专属动画特效(🎉/👍 等,发送方与接收方双向播放)。官方所有客户端仅在私聊 +-- 显示特效选择器,群/频道从不渲染,故只镜像私聊两张表(共享主体 + 每 owner 收件箱), +-- channel_messages 不加列。镜像 via_bot_id / grouped_id 的标量列写法(bigint,0 表无特效)。 +ALTER TABLE public.private_messages ADD COLUMN effect bigint DEFAULT 0 NOT NULL; +ALTER TABLE public.message_boxes ADD COLUMN effect bigint DEFAULT 0 NOT NULL; diff --git a/deploy/migrations/0024_channel_admin_log_backfill.down.sql b/deploy/migrations/0024_channel_admin_log_backfill.down.sql deleted file mode 100644 index 31800bf0..00000000 --- a/deploy/migrations/0024_channel_admin_log_backfill.down.sql +++ /dev/null @@ -1,6 +0,0 @@ --- No-op by design. --- --- These columns/table are part of the fresh 0022 channel schema. This migration --- only backfills older developer databases that had already applied an earlier --- 0022 draft, so dropping the objects on a one-step rollback would corrupt fresh --- schemas where 0022 legitimately owns them. diff --git a/deploy/migrations/0024_channel_admin_log_backfill.up.sql b/deploy/migrations/0024_channel_admin_log_backfill.up.sql deleted file mode 100644 index 518b0d55..00000000 --- a/deploy/migrations/0024_channel_admin_log_backfill.up.sql +++ /dev/null @@ -1,78 +0,0 @@ --- 0024_channel_admin_log_backfill: bring existing developer DBs forward after --- channel settings/admin-log fields were added to the initial 0022 draft. --- --- Fresh databases already get these objects from 0022; every statement here is --- idempotent so old local databases can migrate without reset. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS pre_history_hidden BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS slowmode_seconds INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS admin_log_seq BIGINT NOT NULL DEFAULT 0; - -CREATE TABLE IF NOT EXISTS channel_admin_log_events ( - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - id BIGINT NOT NULL, - actor_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - event_date INT NOT NULL, - event_type VARCHAR(48) NOT NULL, - prev_string TEXT NOT NULL DEFAULT '', - new_string TEXT NOT NULL DEFAULT '', - prev_bool BOOLEAN NOT NULL DEFAULT false, - new_bool BOOLEAN NOT NULL DEFAULT false, - prev_int INT NOT NULL DEFAULT 0, - new_int INT NOT NULL DEFAULT 0, - prev_participant JSONB NOT NULL DEFAULT '{}'::jsonb, - new_participant JSONB NOT NULL DEFAULT '{}'::jsonb, - participant JSONB NOT NULL DEFAULT '{}'::jsonb, - message JSONB NOT NULL DEFAULT '{}'::jsonb, - prev_message JSONB NOT NULL DEFAULT '{}'::jsonb, - new_message JSONB NOT NULL DEFAULT '{}'::jsonb, - query TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, id), - CONSTRAINT channel_admin_log_events_type_check CHECK ( - event_type IN ( - 'change_title', - 'change_username', - 'change_linked_chat', - 'toggle_signatures', - 'toggle_pre_history_hidden', - 'toggle_forum', - 'toggle_anti_spam', - 'toggle_slow_mode', - 'participant_invite', - 'participant_join', - 'participant_leave', - 'participant_promote', - 'participant_demote', - 'participant_ban', - 'participant_unban', - 'participant_kick', - 'participant_unkick', - 'update_pinned', - 'send_message', - 'edit_message', - 'delete_message' - ) - ) -) PARTITION BY HASH (channel_id); - -CREATE INDEX IF NOT EXISTS channel_admin_log_events_scan_idx - ON channel_admin_log_events (channel_id, id DESC); -CREATE INDEX IF NOT EXISTS channel_admin_log_events_actor_idx - ON channel_admin_log_events (channel_id, actor_user_id, id DESC); -CREATE INDEX IF NOT EXISTS channel_admin_log_events_type_idx - ON channel_admin_log_events (channel_id, event_type, id DESC); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_admin_log_events_p%s PARTITION OF channel_admin_log_events FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; diff --git a/deploy/migrations/0024_user_birthday_personal_channel.down.sql b/deploy/migrations/0024_user_birthday_personal_channel.down.sql new file mode 100644 index 00000000..e1bb3957 --- /dev/null +++ b/deploy/migrations/0024_user_birthday_personal_channel.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.users + DROP COLUMN personal_channel_id, + DROP COLUMN birthday_year, + DROP COLUMN birthday_month, + DROP COLUMN birthday_day; diff --git a/deploy/migrations/0024_user_birthday_personal_channel.up.sql b/deploy/migrations/0024_user_birthday_personal_channel.up.sql new file mode 100644 index 00000000..3706db64 --- /dev/null +++ b/deploy/migrations/0024_user_birthday_personal_channel.up.sql @@ -0,0 +1,8 @@ +-- account.updateBirthday / account.updatePersonalChannel:个人资料里的「生日」与「个人频道」。 +-- 生日存月/日/年(全 0 表示未设置;year=0 表示只填月日不含年份);个人频道存目标频道 id +-- (0 表示未设置/已清除),资料投影时按 id 取频道对象与其最新一帖 id。 +ALTER TABLE public.users + ADD COLUMN birthday_day integer DEFAULT 0 NOT NULL, + ADD COLUMN birthday_month integer DEFAULT 0 NOT NULL, + ADD COLUMN birthday_year integer DEFAULT 0 NOT NULL, + ADD COLUMN personal_channel_id bigint DEFAULT 0 NOT NULL; diff --git a/deploy/migrations/0025_channel_member_slowmode_backfill.down.sql b/deploy/migrations/0025_channel_member_slowmode_backfill.down.sql deleted file mode 100644 index d641134d..00000000 --- a/deploy/migrations/0025_channel_member_slowmode_backfill.down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- No-op by design. --- --- Fresh databases own this column from 0022; this migration only repairs older --- developer databases that had already applied an earlier 0022 draft. diff --git a/deploy/migrations/0025_channel_member_slowmode_backfill.up.sql b/deploy/migrations/0025_channel_member_slowmode_backfill.up.sql deleted file mode 100644 index aa6090c7..00000000 --- a/deploy/migrations/0025_channel_member_slowmode_backfill.up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 0025_channel_member_slowmode_backfill: idempotent compatibility migration for --- developer databases that applied an earlier 0022 channel schema draft. - -ALTER TABLE channel_members - ADD COLUMN IF NOT EXISTS slowmode_last_send_date INT NOT NULL DEFAULT 0; diff --git a/deploy/migrations/0025_peer_usernames.down.sql b/deploy/migrations/0025_peer_usernames.down.sql new file mode 100644 index 00000000..217275f0 --- /dev/null +++ b/deploy/migrations/0025_peer_usernames.down.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS public.channel_usernames ( + username_lower text NOT NULL, + channel_id bigint NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT channel_usernames_nonempty_check CHECK (username_lower <> '') +); + +ALTER TABLE ONLY public.channel_usernames + ADD CONSTRAINT channel_usernames_pkey PRIMARY KEY (username_lower); + +INSERT INTO public.channel_usernames (username_lower, channel_id) +SELECT username_lower, peer_id +FROM public.peer_usernames +WHERE peer_type = 'channel'; + +DROP TRIGGER IF EXISTS users_delete_peer_username ON public.users; +DROP TRIGGER IF EXISTS channels_delete_peer_username ON public.channels; +DROP FUNCTION IF EXISTS public.delete_user_peer_username(); +DROP FUNCTION IF EXISTS public.delete_channel_peer_username(); +DROP TABLE IF EXISTS public.peer_usernames; diff --git a/deploy/migrations/0025_peer_usernames.up.sql b/deploy/migrations/0025_peer_usernames.up.sql new file mode 100644 index 00000000..4a42bb0d --- /dev/null +++ b/deploy/migrations/0025_peer_usernames.up.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS public.peer_usernames ( + username_lower text NOT NULL, + peer_type text NOT NULL, + peer_id bigint NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT peer_usernames_pkey PRIMARY KEY (username_lower), + CONSTRAINT peer_usernames_nonempty_check CHECK (username_lower <> ''), + CONSTRAINT peer_usernames_peer_id_check CHECK (peer_id <> 0), + CONSTRAINT peer_usernames_peer_type_check CHECK (peer_type IN ('user', 'channel')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS peer_usernames_peer_unique_idx + ON public.peer_usernames (peer_type, peer_id); + +INSERT INTO public.peer_usernames (username_lower, peer_type, peer_id) +SELECT lower(username), 'user', id +FROM public.users +WHERE username <> ''; + +INSERT INTO public.peer_usernames (username_lower, peer_type, peer_id) +SELECT lower(username), 'channel', id +FROM public.channels +WHERE NOT deleted AND COALESCE(username, '') <> ''; + +CREATE OR REPLACE FUNCTION public.delete_user_peer_username() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + DELETE FROM public.peer_usernames WHERE peer_type = 'user' AND peer_id = OLD.id; + RETURN OLD; +END; +$$; + +CREATE OR REPLACE FUNCTION public.delete_channel_peer_username() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + DELETE FROM public.peer_usernames WHERE peer_type = 'channel' AND peer_id = OLD.id; + RETURN OLD; +END; +$$; + +CREATE TRIGGER users_delete_peer_username + AFTER DELETE ON public.users + FOR EACH ROW EXECUTE FUNCTION public.delete_user_peer_username(); + +CREATE TRIGGER channels_delete_peer_username + AFTER DELETE ON public.channels + FOR EACH ROW EXECUTE FUNCTION public.delete_channel_peer_username(); + +DROP TABLE IF EXISTS public.channel_usernames; diff --git a/deploy/migrations/0026_channel_read_participants.down.sql b/deploy/migrations/0026_channel_read_participants.down.sql deleted file mode 100644 index 7dcca018..00000000 --- a/deploy/migrations/0026_channel_read_participants.down.sql +++ /dev/null @@ -1 +0,0 @@ --- No-op: read receipt dates and TDesktop app config keys are forward-compatible. diff --git a/deploy/migrations/0026_channel_read_participants.up.sql b/deploy/migrations/0026_channel_read_participants.up.sql deleted file mode 100644 index 3a8f691c..00000000 --- a/deploy/migrations/0026_channel_read_participants.up.sql +++ /dev/null @@ -1,19 +0,0 @@ --- 0026_channel_read_participants: store channel read receipt dates and expose TDesktop read mark config. - -ALTER TABLE channel_members - ADD COLUMN IF NOT EXISTS read_inbox_date INT NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS channel_members_read_participants_idx - ON channel_members (channel_id, read_inbox_max_id, user_id) - WHERE status = 'active'; - -UPDATE app_configs -SET hash = 2, - config_json = config_json - || jsonb_build_object( - 'chat_read_mark_size_threshold', 50, - 'chat_read_mark_expire_period', 604800, - 'pm_read_date_expire_period', 604800 - ), - updated_at = now() -WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0026_peer_star_gifts.down.sql b/deploy/migrations/0026_peer_star_gifts.down.sql new file mode 100644 index 00000000..aa84f1df --- /dev/null +++ b/deploy/migrations/0026_peer_star_gifts.down.sql @@ -0,0 +1,33 @@ +DROP INDEX IF EXISTS public.peer_star_gifts_owner_idx; +DROP INDEX IF EXISTS public.peer_star_gifts_user_msg_uniq; +DROP INDEX IF EXISTS public.peer_star_gifts_channel_saved_uniq; + +ALTER TABLE public.peer_star_gifts + DROP CONSTRAINT IF EXISTS peer_star_gifts_ref_check, + DROP CONSTRAINT IF EXISTS peer_star_gifts_owner_peer_type_check; + +DELETE FROM public.peer_star_gifts WHERE owner_peer_type <> 'user'; + +ALTER TABLE public.peer_star_gifts + DROP COLUMN IF EXISTS saved_id, + DROP COLUMN IF EXISTS owner_peer_type; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'peer_star_gifts' + AND column_name = 'owner_peer_id' + ) THEN + ALTER TABLE public.peer_star_gifts RENAME COLUMN owner_peer_id TO owner_user_id; + END IF; +END $$; + +ALTER TABLE public.peer_star_gifts + ADD CONSTRAINT user_star_gifts_owner_msg_uniq UNIQUE (owner_user_id, msg_id); + +ALTER TABLE public.peer_star_gifts RENAME TO user_star_gifts; + +CREATE INDEX user_star_gifts_owner_idx + ON public.user_star_gifts (owner_user_id, gift_date DESC, id DESC); diff --git a/deploy/migrations/0026_peer_star_gifts.up.sql b/deploy/migrations/0026_peer_star_gifts.up.sql new file mode 100644 index 00000000..d808c9e4 --- /dev/null +++ b/deploy/migrations/0026_peer_star_gifts.up.sql @@ -0,0 +1,52 @@ +-- Star gifts are owned by a peer, not only by users: user gifts are addressed by +-- inputSavedStarGiftUser.msg_id, while channel gifts are addressed by +-- inputSavedStarGiftChat{peer,saved_id}. +DO $$ +BEGIN + IF to_regclass('public.user_star_gifts') IS NOT NULL + AND to_regclass('public.peer_star_gifts') IS NULL THEN + ALTER TABLE public.user_star_gifts RENAME TO peer_star_gifts; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'peer_star_gifts' + AND column_name = 'owner_user_id' + ) THEN + ALTER TABLE public.peer_star_gifts RENAME COLUMN owner_user_id TO owner_peer_id; + END IF; +END $$; + +ALTER TABLE public.peer_star_gifts + ADD COLUMN IF NOT EXISTS owner_peer_type text DEFAULT 'user' NOT NULL, + ADD COLUMN IF NOT EXISTS saved_id bigint DEFAULT 0 NOT NULL; + +ALTER TABLE public.peer_star_gifts + DROP CONSTRAINT IF EXISTS user_star_gifts_owner_msg_uniq, + DROP CONSTRAINT IF EXISTS peer_star_gifts_owner_peer_type_check, + DROP CONSTRAINT IF EXISTS peer_star_gifts_ref_check; + +DROP INDEX IF EXISTS public.user_star_gifts_owner_idx; +DROP INDEX IF EXISTS public.peer_star_gifts_owner_idx; +DROP INDEX IF EXISTS public.peer_star_gifts_user_msg_uniq; +DROP INDEX IF EXISTS public.peer_star_gifts_channel_saved_uniq; + +ALTER TABLE public.peer_star_gifts + ADD CONSTRAINT peer_star_gifts_owner_peer_type_check CHECK (owner_peer_type IN ('user', 'channel')), + ADD CONSTRAINT peer_star_gifts_ref_check CHECK ( + (owner_peer_type = 'user' AND msg_id > 0 AND saved_id = 0) + OR + (owner_peer_type = 'channel' AND msg_id > 0 AND saved_id > 0) + ); + +CREATE UNIQUE INDEX peer_star_gifts_user_msg_uniq + ON public.peer_star_gifts (owner_peer_type, owner_peer_id, msg_id) + WHERE owner_peer_type = 'user' AND msg_id > 0; + +CREATE UNIQUE INDEX peer_star_gifts_channel_saved_uniq + ON public.peer_star_gifts (owner_peer_type, owner_peer_id, saved_id) + WHERE owner_peer_type = 'channel' AND saved_id > 0; + +CREATE INDEX peer_star_gifts_owner_idx + ON public.peer_star_gifts (owner_peer_type, owner_peer_id, gift_date DESC, id DESC); diff --git a/deploy/migrations/0027_channel_message_search_indexes.down.sql b/deploy/migrations/0027_channel_message_search_indexes.down.sql deleted file mode 100644 index 86ef5802..00000000 --- a/deploy/migrations/0027_channel_message_search_indexes.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP INDEX IF EXISTS channel_messages_body_trgm_idx; diff --git a/deploy/migrations/0027_channel_message_search_indexes.up.sql b/deploy/migrations/0027_channel_message_search_indexes.up.sql deleted file mode 100644 index b0312902..00000000 --- a/deploy/migrations/0027_channel_message_search_indexes.up.sql +++ /dev/null @@ -1,10 +0,0 @@ --- 0027_channel_message_search_indexes: bounded in-channel text search. --- --- TDesktop uses messages.search with inputPeerChannel for in-chat search. --- Keep text lookup indexed on the single-copy channel_messages table. - -CREATE EXTENSION IF NOT EXISTS pg_trgm; - -CREATE INDEX IF NOT EXISTS channel_messages_body_trgm_idx - ON channel_messages USING gin (body gin_trgm_ops) - WHERE NOT deleted AND body <> ''; diff --git a/deploy/migrations/0027_revoke_system_authorizations.down.sql b/deploy/migrations/0027_revoke_system_authorizations.down.sql new file mode 100644 index 00000000..a9d9746b --- /dev/null +++ b/deploy/migrations/0027_revoke_system_authorizations.down.sql @@ -0,0 +1 @@ +-- Intentionally irreversible: system account authorizations are invalid state and must not be restored. diff --git a/deploy/migrations/0027_revoke_system_authorizations.up.sql b/deploy/migrations/0027_revoke_system_authorizations.up.sql new file mode 100644 index 00000000..65aef13b --- /dev/null +++ b/deploy/migrations/0027_revoke_system_authorizations.up.sql @@ -0,0 +1,26 @@ +WITH target AS MATERIALIZED ( + SELECT auth_key_id + FROM public.authorizations + WHERE user_id IN (777000, 93372553) +), target_temp AS MATERIALIZED ( + SELECT temp_auth_key_id + FROM public.temp_auth_key_bindings + WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target) +), deleted_update_states AS ( + DELETE FROM public.update_states + WHERE user_id IN (777000, 93372553) + OR auth_key_id IN (SELECT auth_key_id FROM target) + OR auth_key_id IN (SELECT temp_auth_key_id FROM target_temp) + RETURNING auth_key_id +), deleted_temp_bindings AS ( + DELETE FROM public.temp_auth_key_bindings + WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target) + OR temp_auth_key_id IN (SELECT temp_auth_key_id FROM target_temp) + RETURNING temp_auth_key_id +), deleted_temp_keys AS ( + DELETE FROM public.auth_keys + WHERE auth_key_id IN (SELECT temp_auth_key_id FROM target_temp) + RETURNING auth_key_id +) +DELETE FROM public.auth_keys +WHERE auth_key_id IN (SELECT auth_key_id FROM target); diff --git a/deploy/migrations/0028_channel_noforwards_backfill.down.sql b/deploy/migrations/0028_channel_noforwards_backfill.down.sql deleted file mode 100644 index d641134d..00000000 --- a/deploy/migrations/0028_channel_noforwards_backfill.down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- No-op by design. --- --- Fresh databases own this column from 0022; this migration only repairs older --- developer databases that had already applied an earlier 0022 draft. diff --git a/deploy/migrations/0028_channel_noforwards_backfill.up.sql b/deploy/migrations/0028_channel_noforwards_backfill.up.sql deleted file mode 100644 index aa6ef0a5..00000000 --- a/deploy/migrations/0028_channel_noforwards_backfill.up.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 0028_channel_noforwards_backfill: ensure older developer databases have --- the channel content-protection flag used by messages.toggleNoForwards. --- --- Fresh databases already get this column from 0022. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS noforwards BOOLEAN NOT NULL DEFAULT false; diff --git a/deploy/migrations/0028_channel_wallpaper.down.sql b/deploy/migrations/0028_channel_wallpaper.down.sql new file mode 100644 index 00000000..031e0604 --- /dev/null +++ b/deploy/migrations/0028_channel_wallpaper.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.channels + DROP COLUMN IF EXISTS wallpaper; diff --git a/deploy/migrations/0028_channel_wallpaper.up.sql b/deploy/migrations/0028_channel_wallpaper.up.sql new file mode 100644 index 00000000..972129cc --- /dev/null +++ b/deploy/migrations/0028_channel_wallpaper.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.channels + ADD COLUMN wallpaper jsonb; diff --git a/deploy/migrations/0029_channel_available_reactions.down.sql b/deploy/migrations/0029_channel_available_reactions.down.sql deleted file mode 100644 index d641134d..00000000 --- a/deploy/migrations/0029_channel_available_reactions.down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- No-op by design. --- --- Fresh databases own this column from 0022; this migration only repairs older --- developer databases that had already applied an earlier 0022 draft. diff --git a/deploy/migrations/0029_channel_available_reactions.up.sql b/deploy/migrations/0029_channel_available_reactions.up.sql deleted file mode 100644 index 5a5b28a8..00000000 --- a/deploy/migrations/0029_channel_available_reactions.up.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 0029_channel_available_reactions: persisted reaction policy for --- messages.setChatAvailableReactions and channels.getFullChannel. --- --- Fresh databases already get this column from 0022. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS available_reactions JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/deploy/migrations/0029_media_url_index_backfill.down.sql b/deploy/migrations/0029_media_url_index_backfill.down.sql new file mode 100644 index 00000000..37bacadd --- /dev/null +++ b/deploy/migrations/0029_media_url_index_backfill.down.sql @@ -0,0 +1,3 @@ +-- No-op: URL media index rows are derived data shared by normal writes and this +-- backfill. Removing only the backfilled rows cannot be done safely without +-- deleting valid post-migration index rows. diff --git a/deploy/migrations/0029_media_url_index_backfill.up.sql b/deploy/migrations/0029_media_url_index_backfill.up.sql new file mode 100644 index 00000000..ba260b15 --- /dev/null +++ b/deploy/migrations/0029_media_url_index_backfill.up.sql @@ -0,0 +1,24 @@ +-- Backfill URL shared-media index rows for messages whose URL semantics are +-- carried by entities or messageMediaWebPage. Future writes are handled by +-- domain.ClassifyMediaCategories; this repairs data created before that rule +-- recognized email/web_page and guards any earlier missed URL rows. + +INSERT INTO channel_message_media (channel_id, id, category, message_date) +SELECT m.channel_id, m.id, 8, m.message_date +FROM channel_messages m +WHERE + (m.media->>'kind' = 'web_page' + OR m.entities @> '[{"type":"url"}]'::jsonb + OR m.entities @> '[{"type":"text_url"}]'::jsonb + OR m.entities @> '[{"type":"email"}]'::jsonb) +ON CONFLICT (channel_id, id, category) DO NOTHING; + +INSERT INTO message_box_media (owner_user_id, box_id, peer_id, category, message_date) +SELECT m.owner_user_id, m.box_id, m.peer_id, 8, m.message_date +FROM message_boxes m +WHERE + (m.media->>'kind' = 'web_page' + OR m.entities @> '[{"type":"url"}]'::jsonb + OR m.entities @> '[{"type":"text_url"}]'::jsonb + OR m.entities @> '[{"type":"email"}]'::jsonb) +ON CONFLICT (owner_user_id, box_id, category) DO NOTHING; diff --git a/deploy/migrations/0030_bot_mini_apps.down.sql b/deploy/migrations/0030_bot_mini_apps.down.sql new file mode 100644 index 00000000..f13dfb40 --- /dev/null +++ b/deploy/migrations/0030_bot_mini_apps.down.sql @@ -0,0 +1,8 @@ +DROP TABLE IF EXISTS webview_custom_method_queries; +DROP TABLE IF EXISTS bot_emoji_status_permissions; +DROP TABLE IF EXISTS webview_requested_buttons; +DROP TABLE IF EXISTS attach_menu_user_states; +DROP TABLE IF EXISTS attach_menu_bots; +DROP TABLE IF EXISTS bot_app_preview_media; +DROP TABLE IF EXISTS bot_app_settings; +DROP TABLE IF EXISTS bot_apps; diff --git a/deploy/migrations/0030_bot_mini_apps.up.sql b/deploy/migrations/0030_bot_mini_apps.up.sql new file mode 100644 index 00000000..79660536 --- /dev/null +++ b/deploy/migrations/0030_bot_mini_apps.up.sql @@ -0,0 +1,124 @@ +CREATE TABLE bot_apps ( + id BIGINT PRIMARY KEY, + bot_user_id BIGINT NOT NULL REFERENCES bots(bot_user_id) ON DELETE CASCADE, + short_name VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL, + description TEXT NOT NULL DEFAULT '', + url VARCHAR(512) NOT NULL, + photo_id BIGINT NOT NULL DEFAULT 0, + document_id BIGINT NOT NULL DEFAULT 0, + access_hash BIGINT NOT NULL, + hash BIGINT NOT NULL, + inactive BOOLEAN NOT NULL DEFAULT FALSE, + request_write_access BOOLEAN NOT NULL DEFAULT FALSE, + has_settings BOOLEAN NOT NULL DEFAULT FALSE, + is_main BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT bot_apps_short_name_nonempty CHECK (short_name <> ''), + CONSTRAINT bot_apps_title_nonempty CHECK (title <> ''), + CONSTRAINT bot_apps_url_nonempty CHECK (url <> '') +); + +CREATE UNIQUE INDEX bot_apps_bot_short_name_unique_idx ON bot_apps (bot_user_id, lower(short_name)); +CREATE UNIQUE INDEX bot_apps_bot_main_unique_idx ON bot_apps (bot_user_id) WHERE is_main; +CREATE INDEX bot_apps_bot_idx ON bot_apps (bot_user_id, is_main, short_name); + +CREATE TABLE bot_app_settings ( + bot_user_id BIGINT PRIMARY KEY REFERENCES bots(bot_user_id) ON DELETE CASCADE, + placeholder_path BYTEA NOT NULL DEFAULT '\x', + background_color INTEGER, + background_dark_color INTEGER, + header_color INTEGER, + header_dark_color INTEGER, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE bot_app_preview_media ( + id BIGSERIAL PRIMARY KEY, + bot_user_id BIGINT NOT NULL REFERENCES bots(bot_user_id) ON DELETE CASCADE, + app_id BIGINT NOT NULL REFERENCES bot_apps(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + photo_id BIGINT NOT NULL DEFAULT 0, + document_id BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT bot_app_preview_media_one_media CHECK ((photo_id <> 0) <> (document_id <> 0)) +); + +CREATE UNIQUE INDEX bot_app_preview_media_position_unique_idx ON bot_app_preview_media (app_id, position); +CREATE INDEX bot_app_preview_media_bot_app_idx ON bot_app_preview_media (bot_user_id, app_id, position); + +CREATE TABLE attach_menu_bots ( + bot_user_id BIGINT PRIMARY KEY REFERENCES bots(bot_user_id) ON DELETE CASCADE, + app_id BIGINT REFERENCES bot_apps(id) ON DELETE SET NULL, + short_name VARCHAR(64) NOT NULL, + inactive BOOLEAN NOT NULL DEFAULT FALSE, + has_settings BOOLEAN NOT NULL DEFAULT FALSE, + request_write_access BOOLEAN NOT NULL DEFAULT FALSE, + show_in_attach_menu BOOLEAN NOT NULL DEFAULT TRUE, + show_in_side_menu BOOLEAN NOT NULL DEFAULT TRUE, + side_menu_disclaimer_needed BOOLEAN NOT NULL DEFAULT FALSE, + peer_types TEXT[] NOT NULL DEFAULT ARRAY['pm','chat','megagroup','broadcast']::text[], + icons JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT attach_menu_bots_short_name_nonempty CHECK (short_name <> '') +); + +CREATE INDEX attach_menu_bots_visible_idx ON attach_menu_bots (inactive, bot_user_id); + +CREATE TABLE attach_menu_user_states ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + bot_user_id BIGINT NOT NULL REFERENCES bots(bot_user_id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + write_allowed BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, bot_user_id), + CONSTRAINT attach_menu_user_states_not_self CHECK (user_id <> bot_user_id) +); + +CREATE INDEX attach_menu_user_states_bot_idx ON attach_menu_user_states (bot_user_id, user_id); + +CREATE TABLE webview_requested_buttons ( + webapp_req_id TEXT PRIMARY KEY, + bot_user_id BIGINT NOT NULL REFERENCES bots(bot_user_id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + button_id INTEGER NOT NULL, + text TEXT NOT NULL DEFAULT '', + peer_type TEXT NOT NULL, + max_quantity INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + CONSTRAINT webview_requested_buttons_not_self CHECK (bot_user_id <> user_id) +); + +CREATE INDEX webview_requested_buttons_lookup_idx ON webview_requested_buttons (bot_user_id, user_id, webapp_req_id); +CREATE INDEX webview_requested_buttons_expires_idx ON webview_requested_buttons (expires_at); + +CREATE TABLE bot_emoji_status_permissions ( + bot_user_id BIGINT NOT NULL REFERENCES bots(bot_user_id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + allowed BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (bot_user_id, user_id), + CONSTRAINT bot_emoji_status_permissions_not_self CHECK (bot_user_id <> user_id) +); + +CREATE INDEX bot_emoji_status_permissions_user_idx ON bot_emoji_status_permissions (user_id, bot_user_id); + +CREATE TABLE webview_custom_method_queries ( + id TEXT PRIMARY KEY, + bot_user_id BIGINT NOT NULL REFERENCES bots(bot_user_id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + custom_method TEXT NOT NULL, + params JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + CONSTRAINT webview_custom_method_queries_not_self CHECK (bot_user_id <> user_id) +); + +CREATE INDEX webview_custom_method_queries_bot_user_idx ON webview_custom_method_queries (bot_user_id, user_id, created_at DESC); +CREATE INDEX webview_custom_method_queries_expires_idx ON webview_custom_method_queries (expires_at); diff --git a/deploy/migrations/0030_user_update_channel_peers.down.sql b/deploy/migrations/0030_user_update_channel_peers.down.sql deleted file mode 100644 index 61412b62..00000000 --- a/deploy/migrations/0030_user_update_channel_peers.down.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 0030_user_update_channel_peers rollback. - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_peer_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_peer_type_check - CHECK (peer_type IS NULL OR peer_type IN ('user')); diff --git a/deploy/migrations/0030_user_update_channel_peers.up.sql b/deploy/migrations/0030_user_update_channel_peers.up.sql deleted file mode 100644 index 7deb58e8..00000000 --- a/deploy/migrations/0030_user_update_channel_peers.up.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 0030_user_update_channel_peers: allow account-level update events to reference channel peers. - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_peer_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_peer_type_check - CHECK (peer_type IS NULL OR peer_type IN ('user', 'channel')); diff --git a/deploy/migrations/0031_channel_available_min_pts.down.sql b/deploy/migrations/0031_channel_available_min_pts.down.sql deleted file mode 100644 index 7e2abdc2..00000000 --- a/deploy/migrations/0031_channel_available_min_pts.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE channel_members - DROP COLUMN IF EXISTS available_min_pts; diff --git a/deploy/migrations/0031_channel_available_min_pts.up.sql b/deploy/migrations/0031_channel_available_min_pts.up.sql deleted file mode 100644 index 7842ecde..00000000 --- a/deploy/migrations/0031_channel_available_min_pts.up.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 0031_channel_available_min_pts: channelDifference visibility floor per member. - -ALTER TABLE channel_members - ADD COLUMN IF NOT EXISTS available_min_pts INT NOT NULL DEFAULT 0; diff --git a/deploy/migrations/0031_story_exposures.down.sql b/deploy/migrations/0031_story_exposures.down.sql new file mode 100644 index 00000000..3d6b71a3 --- /dev/null +++ b/deploy/migrations/0031_story_exposures.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS story_exposures; diff --git a/deploy/migrations/0031_story_exposures.up.sql b/deploy/migrations/0031_story_exposures.up.sql new file mode 100644 index 00000000..e54faf0e --- /dev/null +++ b/deploy/migrations/0031_story_exposures.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE story_exposures ( + owner_peer_type TEXT NOT NULL, + owner_peer_id BIGINT NOT NULL, + story_id INTEGER NOT NULL, + viewer_user_id BIGINT NOT NULL, + date INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT story_exposures_peer_check CHECK ( + owner_peer_type IN ('user', 'channel') + AND owner_peer_id > 0 + AND story_id > 0 + AND viewer_user_id > 0 + ), + PRIMARY KEY (owner_peer_type, owner_peer_id, story_id, viewer_user_id) +); + +CREATE INDEX story_exposures_viewer_idx + ON story_exposures (viewer_user_id, owner_peer_type, owner_peer_id, story_id); diff --git a/deploy/migrations/0032_admin_backend.down.sql b/deploy/migrations/0032_admin_backend.down.sql new file mode 100644 index 00000000..e5b20118 --- /dev/null +++ b/deploy/migrations/0032_admin_backend.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS admin_audit_logs_actor_idx; +DROP INDEX IF EXISTS admin_audit_logs_target_idx; +DROP TABLE IF EXISTS admin_audit_logs; +DROP INDEX IF EXISTS admin_commands_target_idx; +DROP TABLE IF EXISTS admin_commands; +DROP INDEX IF EXISTS account_send_restrictions_frozen_idx; +DROP TABLE IF EXISTS account_send_restrictions; diff --git a/deploy/migrations/0032_admin_backend.up.sql b/deploy/migrations/0032_admin_backend.up.sql new file mode 100644 index 00000000..a8f69838 --- /dev/null +++ b/deploy/migrations/0032_admin_backend.up.sql @@ -0,0 +1,62 @@ +CREATE TABLE IF NOT EXISTS account_send_restrictions ( + user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + frozen boolean NOT NULL DEFAULT false, + reason text NOT NULL DEFAULT '', + actor text NOT NULL DEFAULT '', + command_id text NOT NULL DEFAULT '', + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT account_send_restrictions_user_id_check CHECK (user_id > 0) +); + +CREATE INDEX IF NOT EXISTS account_send_restrictions_frozen_idx + ON account_send_restrictions (user_id) + WHERE frozen; + +CREATE TABLE IF NOT EXISTS admin_commands ( + command_id text PRIMARY KEY, + actor text NOT NULL, + action varchar(96) NOT NULL, + target_user_id bigint NOT NULL DEFAULT 0, + target_peer_type varchar(16) NOT NULL DEFAULT '', + target_peer_id bigint NOT NULL DEFAULT 0, + dry_run boolean NOT NULL DEFAULT false, + reason text NOT NULL, + request jsonb NOT NULL DEFAULT '{}'::jsonb, + result jsonb NOT NULL DEFAULT '{}'::jsonb, + status varchar(16) NOT NULL DEFAULT 'running', + error text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz, + CONSTRAINT admin_commands_status_check CHECK (status IN ('running', 'completed', 'failed')), + CONSTRAINT admin_commands_target_user_check CHECK (target_user_id >= 0), + CONSTRAINT admin_commands_command_id_check CHECK (length(command_id) > 0 AND length(command_id) <= 128), + CONSTRAINT admin_commands_actor_check CHECK (length(actor) > 0 AND length(actor) <= 128), + CONSTRAINT admin_commands_reason_check CHECK (length(reason) > 0 AND length(reason) <= 1000) +); + +CREATE INDEX IF NOT EXISTS admin_commands_target_idx + ON admin_commands (target_user_id, action, created_at DESC); + +CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id bigserial PRIMARY KEY, + command_id text NOT NULL UNIQUE REFERENCES admin_commands(command_id) ON DELETE RESTRICT, + actor text NOT NULL, + action varchar(96) NOT NULL, + target_user_id bigint NOT NULL DEFAULT 0, + target_peer_type varchar(16) NOT NULL DEFAULT '', + target_peer_id bigint NOT NULL DEFAULT 0, + dry_run boolean NOT NULL DEFAULT false, + reason text NOT NULL, + request jsonb NOT NULL DEFAULT '{}'::jsonb, + result jsonb NOT NULL DEFAULT '{}'::jsonb, + status varchar(16) NOT NULL, + error text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT admin_audit_logs_status_check CHECK (status IN ('running', 'completed', 'failed')) +); + +CREATE INDEX IF NOT EXISTS admin_audit_logs_target_idx + ON admin_audit_logs (target_user_id, id DESC); + +CREATE INDEX IF NOT EXISTS admin_audit_logs_actor_idx + ON admin_audit_logs (actor, id DESC); diff --git a/deploy/migrations/0032_user_update_channel_available.down.sql b/deploy/migrations/0032_user_update_channel_available.down.sql deleted file mode 100644 index 45eee6de..00000000 --- a/deploy/migrations/0032_user_update_channel_available.down.sql +++ /dev/null @@ -1,23 +0,0 @@ -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'read_history_outbox', - 'edit_message', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'dialog_filter', - 'dialog_filter_order', - 'dialog_filters', - 'folder_peers', - 'noop' - ) - ); diff --git a/deploy/migrations/0032_user_update_channel_available.up.sql b/deploy/migrations/0032_user_update_channel_available.up.sql deleted file mode 100644 index 5f7ab776..00000000 --- a/deploy/migrations/0032_user_update_channel_available.up.sql +++ /dev/null @@ -1,26 +0,0 @@ --- 0032_user_update_channel_available: durable account update for channel local clear. - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'read_history_outbox', - 'edit_message', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'dialog_filter', - 'dialog_filter_order', - 'dialog_filters', - 'folder_peers', - 'channel_available_messages', - 'noop' - ) - ); diff --git a/deploy/migrations/0033_app_config_quote_length.down.sql b/deploy/migrations/0033_app_config_quote_length.down.sql deleted file mode 100644 index 1ebccd74..00000000 --- a/deploy/migrations/0033_app_config_quote_length.down.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 0033_app_config_quote_length rollback. - -UPDATE app_configs -SET hash = 2, - config_json = config_json - 'quote_length_max', - updated_at = now() -WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0033_app_config_quote_length.up.sql b/deploy/migrations/0033_app_config_quote_length.up.sql deleted file mode 100644 index 5e5ff21d..00000000 --- a/deploy/migrations/0033_app_config_quote_length.up.sql +++ /dev/null @@ -1,7 +0,0 @@ --- 0033_app_config_quote_length: expose TDesktop quote reply length limit explicitly. - -UPDATE app_configs -SET hash = 3, - config_json = config_json || jsonb_build_object('quote_length_max', 1024), - updated_at = now() -WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0033_channel_verified.down.sql b/deploy/migrations/0033_channel_verified.down.sql new file mode 100644 index 00000000..70e2ee7b --- /dev/null +++ b/deploy/migrations/0033_channel_verified.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS public.admin_audit_logs_peer_idx; + +ALTER TABLE public.channels + DROP COLUMN IF EXISTS verified; diff --git a/deploy/migrations/0033_channel_verified.up.sql b/deploy/migrations/0033_channel_verified.up.sql new file mode 100644 index 00000000..1cda035a --- /dev/null +++ b/deploy/migrations/0033_channel_verified.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.channels + ADD COLUMN IF NOT EXISTS verified boolean DEFAULT false NOT NULL; + +CREATE INDEX IF NOT EXISTS admin_audit_logs_peer_idx + ON public.admin_audit_logs (target_peer_type, target_peer_id, id DESC); diff --git a/deploy/migrations/0034_channel_invite_importers.down.sql b/deploy/migrations/0034_channel_invite_importers.down.sql deleted file mode 100644 index c864dfb8..00000000 --- a/deploy/migrations/0034_channel_invite_importers.down.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 0034_channel_invite_importers rollback. - -DROP TABLE IF EXISTS channel_invite_importers; - -ALTER TABLE channel_invites - DROP COLUMN IF EXISTS requested_count; diff --git a/deploy/migrations/0034_channel_invite_importers.up.sql b/deploy/migrations/0034_channel_invite_importers.up.sql deleted file mode 100644 index 8cb02fb7..00000000 --- a/deploy/migrations/0034_channel_invite_importers.up.sql +++ /dev/null @@ -1,37 +0,0 @@ --- 0034_channel_invite_importers: invite management read model. - -ALTER TABLE channel_invites - ADD COLUMN IF NOT EXISTS requested_count INT NOT NULL DEFAULT 0; - -CREATE TABLE IF NOT EXISTS channel_invite_importers ( - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - invite_id BIGINT NOT NULL, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - date INT NOT NULL, - requested BOOLEAN NOT NULL DEFAULT false, - approved_by BIGINT NOT NULL DEFAULT 0, - via_chatlist BOOLEAN NOT NULL DEFAULT false, - about TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, user_id) -) PARTITION BY HASH (channel_id); - -CREATE INDEX IF NOT EXISTS channel_invite_importers_link_idx - ON channel_invite_importers (channel_id, invite_id, requested, date DESC, user_id DESC); - -CREATE INDEX IF NOT EXISTS channel_invite_importers_requested_idx - ON channel_invite_importers (channel_id, requested, date DESC, user_id DESC); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_invite_importers_p%s PARTITION OF channel_invite_importers FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; diff --git a/deploy/migrations/0034_channel_star_gifts_recent_actions.down.sql b/deploy/migrations/0034_channel_star_gifts_recent_actions.down.sql new file mode 100644 index 00000000..32673a05 --- /dev/null +++ b/deploy/migrations/0034_channel_star_gifts_recent_actions.down.sql @@ -0,0 +1,16 @@ +ALTER TABLE public.peer_star_gifts + DROP CONSTRAINT IF EXISTS peer_star_gifts_ref_check; + +UPDATE public.peer_star_gifts +SET msg_id = saved_id::integer +WHERE owner_peer_type = 'channel' + AND msg_id = 0 + AND saved_id > 0 + AND saved_id <= 2147483647; + +ALTER TABLE public.peer_star_gifts + ADD CONSTRAINT peer_star_gifts_ref_check CHECK ( + (owner_peer_type = 'user' AND msg_id > 0 AND saved_id = 0) + OR + (owner_peer_type = 'channel' AND msg_id > 0 AND saved_id > 0) + ); diff --git a/deploy/migrations/0034_channel_star_gifts_recent_actions.up.sql b/deploy/migrations/0034_channel_star_gifts_recent_actions.up.sql new file mode 100644 index 00000000..88447cc0 --- /dev/null +++ b/deploy/migrations/0034_channel_star_gifts_recent_actions.up.sql @@ -0,0 +1,13 @@ +-- New channel Star gifts are saved-gift/admin-log records, not channel history +-- messages, so channel gifts may have msg_id = 0. The >= 0 branch keeps older +-- rows migratable without rewriting already persisted data. + +ALTER TABLE public.peer_star_gifts + DROP CONSTRAINT IF EXISTS peer_star_gifts_ref_check; + +ALTER TABLE public.peer_star_gifts + ADD CONSTRAINT peer_star_gifts_ref_check CHECK ( + (owner_peer_type = 'user' AND msg_id > 0 AND saved_id = 0) + OR + (owner_peer_type = 'channel' AND msg_id >= 0 AND saved_id > 0) + ); diff --git a/deploy/migrations/0035_channel_join_settings.down.sql b/deploy/migrations/0035_channel_join_settings.down.sql deleted file mode 100644 index 9e1cb171..00000000 --- a/deploy/migrations/0035_channel_join_settings.down.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 0035_channel_join_settings rollback. - -ALTER TABLE channels - DROP COLUMN IF EXISTS join_request, - DROP COLUMN IF EXISTS join_to_send; diff --git a/deploy/migrations/0035_channel_join_settings.up.sql b/deploy/migrations/0035_channel_join_settings.up.sql deleted file mode 100644 index fbcfbdc0..00000000 --- a/deploy/migrations/0035_channel_join_settings.up.sql +++ /dev/null @@ -1,5 +0,0 @@ --- 0035_channel_join_settings: public join/send gating flags. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS join_to_send BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS join_request BOOLEAN NOT NULL DEFAULT false; diff --git a/deploy/migrations/0035_seed_states.down.sql b/deploy/migrations/0035_seed_states.down.sql new file mode 100644 index 00000000..41b4c2d0 --- /dev/null +++ b/deploy/migrations/0035_seed_states.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public.seed_states; diff --git a/deploy/migrations/0035_seed_states.up.sql b/deploy/migrations/0035_seed_states.up.sql new file mode 100644 index 00000000..d1a29861 --- /dev/null +++ b/deploy/migrations/0035_seed_states.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE public.seed_states ( + key text PRIMARY KEY, + content_hash text NOT NULL, + updated_at timestamptz DEFAULT now() NOT NULL +); diff --git a/deploy/migrations/0036_channel_discussion_links.down.sql b/deploy/migrations/0036_channel_discussion_links.down.sql deleted file mode 100644 index 25e8cc55..00000000 --- a/deploy/migrations/0036_channel_discussion_links.down.sql +++ /dev/null @@ -1,4 +0,0 @@ -DROP INDEX IF EXISTS channels_linked_chat_idx; - -ALTER TABLE channels - DROP COLUMN IF EXISTS linked_chat_id; diff --git a/deploy/migrations/0036_channel_discussion_links.up.sql b/deploy/migrations/0036_channel_discussion_links.up.sql deleted file mode 100644 index 6f02fa8a..00000000 --- a/deploy/migrations/0036_channel_discussion_links.up.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 0036_channel_discussion_links: persist bidirectional broadcast <-> discussion group links. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS linked_chat_id BIGINT NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS channels_linked_chat_idx - ON channels (linked_chat_id) - WHERE linked_chat_id <> 0 AND NOT deleted; diff --git a/deploy/migrations/0036_channel_invites_has_link_read_model.down.sql b/deploy/migrations/0036_channel_invites_has_link_read_model.down.sql new file mode 100644 index 00000000..6966fb9c --- /dev/null +++ b/deploy/migrations/0036_channel_invites_has_link_read_model.down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER IF EXISTS channel_invites_channel_base_read_model_changed ON public.channel_invites; +DROP FUNCTION IF EXISTS public.telesrv_notify_channel_invites_channel_base_read_model(); +DROP INDEX IF EXISTS public.channel_invites_channel_active_idx; diff --git a/deploy/migrations/0036_channel_invites_has_link_read_model.up.sql b/deploy/migrations/0036_channel_invites_has_link_read_model.up.sql new file mode 100644 index 00000000..0040da8b --- /dev/null +++ b/deploy/migrations/0036_channel_invites_has_link_read_model.up.sql @@ -0,0 +1,61 @@ +CREATE INDEX IF NOT EXISTS channel_invites_channel_active_idx + ON public.channel_invites (channel_id) + WHERE NOT revoked; + +CREATE OR REPLACE FUNCTION public.telesrv_notify_channel_invites_channel_base_read_model() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + changed_id BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + changed_id := OLD.channel_id; + ELSE + changed_id := NEW.channel_id; + END IF; + PERFORM telesrv_bump_read_model_version('channel_base', 0, 'channel', changed_id); + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS channel_invites_channel_base_read_model_changed ON public.channel_invites; +CREATE TRIGGER channel_invites_channel_base_read_model_changed +AFTER INSERT OR DELETE OR UPDATE OF channel_id, revoked ON public.channel_invites +FOR EACH ROW EXECUTE FUNCTION public.telesrv_notify_channel_invites_channel_base_read_model(); + +WITH missing AS ( + SELECT + c.id AS channel_id, + COALESCE(( + SELECT MAX(ci.invite_id) + 1 + FROM public.channel_invites ci + WHERE ci.channel_id = c.id + ), floor(random() * 9007199254740991)::bigint + 1) AS invite_id, + 'automain_' || c.id::text || '_' || substr(md5(c.id::text || ':' || clock_timestamp()::text || ':' || random()::text), 1, 16) AS hash, + c.creator_user_id AS admin_user_id + FROM public.channels c + JOIN public.users u ON u.id = c.creator_user_id + WHERE NOT c.deleted + AND c.creator_user_id <> 0 + AND NOT EXISTS ( + SELECT 1 + FROM public.channel_invites ci + WHERE ci.channel_id = c.id + AND NOT ci.revoked + ) +), inserted AS ( + INSERT INTO public.channel_invites ( + channel_id, invite_id, hash, admin_user_id, permanent, revoked, request_needed, created_at, updated_at + ) + SELECT channel_id, invite_id, hash, admin_user_id, true, false, false, now(), now() + FROM missing + ON CONFLICT (channel_id, invite_id) DO NOTHING + RETURNING channel_id, invite_id, hash +) +INSERT INTO public.channel_invite_hashes (hash, channel_id, invite_id) +SELECT hash, channel_id, invite_id +FROM inserted +ON CONFLICT (hash) DO UPDATE SET + channel_id = EXCLUDED.channel_id, + invite_id = EXCLUDED.invite_id, + updated_at = now(); diff --git a/deploy/migrations/0037_channel_message_threads.down.sql b/deploy/migrations/0037_channel_message_threads.down.sql deleted file mode 100644 index 05aa8668..00000000 --- a/deploy/migrations/0037_channel_message_threads.down.sql +++ /dev/null @@ -1,10 +0,0 @@ -DROP INDEX IF EXISTS channel_messages_discussion_ref_idx; -DROP INDEX IF EXISTS channel_messages_reply_thread_idx; - -ALTER TABLE channel_messages - DROP COLUMN IF EXISTS discussion_message_id, - DROP COLUMN IF EXISTS discussion_channel_id, - DROP COLUMN IF EXISTS reply_to_top_id, - DROP COLUMN IF EXISTS reply_to_peer_id, - DROP COLUMN IF EXISTS reply_to_peer_type, - DROP COLUMN IF EXISTS reply_to_msg_id; diff --git a/deploy/migrations/0037_channel_message_threads.up.sql b/deploy/migrations/0037_channel_message_threads.up.sql deleted file mode 100644 index 23c390f2..00000000 --- a/deploy/migrations/0037_channel_message_threads.up.sql +++ /dev/null @@ -1,36 +0,0 @@ -ALTER TABLE channel_messages - ADD COLUMN IF NOT EXISTS reply_to_msg_id INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS reply_to_peer_type VARCHAR(16) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS reply_to_peer_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS reply_to_top_id INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS discussion_channel_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS discussion_message_id INT NOT NULL DEFAULT 0; - -UPDATE channel_messages -SET - reply_to_msg_id = CASE - WHEN (reply_to ->> 'MessageID') ~ '^[0-9]+$' THEN (reply_to ->> 'MessageID')::INT - ELSE reply_to_msg_id - END, - reply_to_peer_type = COALESCE(NULLIF(reply_to #>> '{Peer,Type}', ''), reply_to_peer_type), - reply_to_peer_id = CASE - WHEN (reply_to #>> '{Peer,ID}') ~ '^[0-9]+$' THEN (reply_to #>> '{Peer,ID}')::BIGINT - ELSE reply_to_peer_id - END, - reply_to_top_id = CASE - WHEN (reply_to ->> 'TopMessageID') ~ '^[0-9]+$' AND (reply_to ->> 'TopMessageID')::INT > 0 - THEN (reply_to ->> 'TopMessageID')::INT - WHEN (reply_to ->> 'MessageID') ~ '^[0-9]+$' - THEN (reply_to ->> 'MessageID')::INT - ELSE reply_to_top_id - END -WHERE reply_to <> '{}'::jsonb - AND reply_to_msg_id = 0; - -CREATE INDEX IF NOT EXISTS channel_messages_reply_thread_idx - ON channel_messages (channel_id, reply_to_top_id, id DESC) - WHERE reply_to_top_id > 0 AND NOT deleted; - -CREATE INDEX IF NOT EXISTS channel_messages_discussion_ref_idx - ON channel_messages (discussion_channel_id, discussion_message_id) - WHERE discussion_channel_id <> 0 AND discussion_message_id <> 0 AND NOT deleted; diff --git a/deploy/migrations/0038_channel_default_send_as.down.sql b/deploy/migrations/0038_channel_default_send_as.down.sql deleted file mode 100644 index 108a07f5..00000000 --- a/deploy/migrations/0038_channel_default_send_as.down.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE channel_dialogs - DROP CONSTRAINT IF EXISTS channel_dialogs_default_send_as_peer_type_check; - -ALTER TABLE channel_dialogs - DROP COLUMN IF EXISTS default_send_as_peer_id, - DROP COLUMN IF EXISTS default_send_as_peer_type; diff --git a/deploy/migrations/0038_channel_default_send_as.up.sql b/deploy/migrations/0038_channel_default_send_as.up.sql deleted file mode 100644 index 77d01deb..00000000 --- a/deploy/migrations/0038_channel_default_send_as.up.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE channel_dialogs - ADD COLUMN IF NOT EXISTS default_send_as_peer_type VARCHAR(16), - ADD COLUMN IF NOT EXISTS default_send_as_peer_id BIGINT; - -ALTER TABLE channel_dialogs - DROP CONSTRAINT IF EXISTS channel_dialogs_default_send_as_peer_type_check; - -ALTER TABLE channel_dialogs - ADD CONSTRAINT channel_dialogs_default_send_as_peer_type_check - CHECK ( - default_send_as_peer_type IS NULL - OR default_send_as_peer_type IN ('user', 'channel') - ); diff --git a/deploy/migrations/0039_channel_message_views.down.sql b/deploy/migrations/0039_channel_message_views.down.sql deleted file mode 100644 index 5d193c4a..00000000 --- a/deploy/migrations/0039_channel_message_views.down.sql +++ /dev/null @@ -1,4 +0,0 @@ -DROP TABLE IF EXISTS channel_message_viewers CASCADE; - -ALTER TABLE channel_messages - DROP COLUMN IF EXISTS views_count; diff --git a/deploy/migrations/0039_channel_message_views.up.sql b/deploy/migrations/0039_channel_message_views.up.sql deleted file mode 100644 index 70ec3a90..00000000 --- a/deploy/migrations/0039_channel_message_views.up.sql +++ /dev/null @@ -1,27 +0,0 @@ -ALTER TABLE channel_messages - ADD COLUMN IF NOT EXISTS views_count INT NOT NULL DEFAULT 0; - -CREATE TABLE IF NOT EXISTS channel_message_viewers ( - channel_id BIGINT NOT NULL, - message_id INT NOT NULL, - viewer_user_id BIGINT NOT NULL, - viewed_at INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, message_id, viewer_user_id), - CONSTRAINT channel_message_viewers_positive_check CHECK ( - channel_id > 0 AND message_id > 0 AND viewer_user_id > 0 - ) -) PARTITION BY HASH (channel_id); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_message_viewers_p%s PARTITION OF channel_message_viewers FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; diff --git a/deploy/migrations/0040_channel_unread_mentions.down.sql b/deploy/migrations/0040_channel_unread_mentions.down.sql deleted file mode 100644 index 0c401e55..00000000 --- a/deploy/migrations/0040_channel_unread_mentions.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS channel_unread_mentions CASCADE; diff --git a/deploy/migrations/0040_channel_unread_mentions.up.sql b/deploy/migrations/0040_channel_unread_mentions.up.sql deleted file mode 100644 index 0a02b19f..00000000 --- a/deploy/migrations/0040_channel_unread_mentions.up.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TABLE IF NOT EXISTS channel_unread_mentions ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - message_id INT NOT NULL, - top_message_id INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, channel_id, message_id), - FOREIGN KEY (channel_id, message_id) REFERENCES channel_messages(channel_id, id) ON DELETE CASCADE -) PARTITION BY HASH (user_id); - -CREATE INDEX IF NOT EXISTS channel_unread_mentions_peer_idx - ON channel_unread_mentions (user_id, channel_id, top_message_id, message_id DESC); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_unread_mentions_p%s PARTITION OF channel_unread_mentions FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; diff --git a/deploy/migrations/0041_channel_participants_hidden.down.sql b/deploy/migrations/0041_channel_participants_hidden.down.sql deleted file mode 100644 index 2e10778e..00000000 --- a/deploy/migrations/0041_channel_participants_hidden.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE channels - DROP COLUMN IF EXISTS participants_hidden; diff --git a/deploy/migrations/0041_channel_participants_hidden.up.sql b/deploy/migrations/0041_channel_participants_hidden.up.sql deleted file mode 100644 index 22858be1..00000000 --- a/deploy/migrations/0041_channel_participants_hidden.up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS participants_hidden BOOLEAN NOT NULL DEFAULT false; diff --git a/deploy/migrations/0042_channel_colors.down.sql b/deploy/migrations/0042_channel_colors.down.sql deleted file mode 100644 index 456a973e..00000000 --- a/deploy/migrations/0042_channel_colors.down.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE channels - DROP COLUMN IF EXISTS emoji_status_until, - DROP COLUMN IF EXISTS emoji_status_document_id, - DROP COLUMN IF EXISTS profile_color_background_emoji_id, - DROP COLUMN IF EXISTS profile_color, - DROP COLUMN IF EXISTS profile_color_set, - DROP COLUMN IF EXISTS color_background_emoji_id, - DROP COLUMN IF EXISTS color, - DROP COLUMN IF EXISTS color_set; diff --git a/deploy/migrations/0042_channel_colors.up.sql b/deploy/migrations/0042_channel_colors.up.sql deleted file mode 100644 index f13d108c..00000000 --- a/deploy/migrations/0042_channel_colors.up.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS color_set BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS color INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS color_background_emoji_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS profile_color_set BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS profile_color INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS profile_color_background_emoji_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS emoji_status_document_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS emoji_status_until INT NOT NULL DEFAULT 0; diff --git a/deploy/migrations/0043_channel_view_forum_as_messages.down.sql b/deploy/migrations/0043_channel_view_forum_as_messages.down.sql deleted file mode 100644 index 947b57d1..00000000 --- a/deploy/migrations/0043_channel_view_forum_as_messages.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE channel_dialogs - DROP COLUMN IF EXISTS view_forum_as_messages; diff --git a/deploy/migrations/0043_channel_view_forum_as_messages.up.sql b/deploy/migrations/0043_channel_view_forum_as_messages.up.sql deleted file mode 100644 index 51af5676..00000000 --- a/deploy/migrations/0043_channel_view_forum_as_messages.up.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE channel_dialogs - ADD COLUMN IF NOT EXISTS view_forum_as_messages BOOLEAN NOT NULL DEFAULT false; diff --git a/deploy/migrations/0044_channel_antispam.down.sql b/deploy/migrations/0044_channel_antispam.down.sql deleted file mode 100644 index 597db78e..00000000 --- a/deploy/migrations/0044_channel_antispam.down.sql +++ /dev/null @@ -1,40 +0,0 @@ --- 0044_channel_antispam rollback. - -UPDATE app_configs -SET hash = 3, - config_json = config_json - - 'telegram_antispam_group_size_min' - - 'telegram_antispam_user_id', - updated_at = now() -WHERE client = 'tdesktop' AND hash = 4; - -ALTER TABLE channel_admin_log_events - DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; - -ALTER TABLE channel_admin_log_events - ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( - event_type IN ( - 'change_title', - 'change_username', - 'change_linked_chat', - 'toggle_signatures', - 'toggle_pre_history_hidden', - 'toggle_slow_mode', - 'participant_invite', - 'participant_join', - 'participant_leave', - 'participant_promote', - 'participant_demote', - 'participant_ban', - 'participant_unban', - 'participant_kick', - 'participant_unkick', - 'update_pinned', - 'send_message', - 'edit_message', - 'delete_message' - ) - ); - -ALTER TABLE channels - DROP COLUMN IF EXISTS antispam; diff --git a/deploy/migrations/0044_channel_antispam.up.sql b/deploy/migrations/0044_channel_antispam.up.sql deleted file mode 100644 index 2b7882ea..00000000 --- a/deploy/migrations/0044_channel_antispam.up.sql +++ /dev/null @@ -1,44 +0,0 @@ --- 0044_channel_antispam: persist native antispam state and expose TDesktop config. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS antispam BOOLEAN NOT NULL DEFAULT false; - -ALTER TABLE channel_admin_log_events - DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; - -ALTER TABLE channel_admin_log_events - ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( - event_type IN ( - 'change_title', - 'change_username', - 'change_linked_chat', - 'toggle_signatures', - 'toggle_pre_history_hidden', - 'toggle_forum', - 'toggle_anti_spam', - 'toggle_slow_mode', - 'participant_invite', - 'participant_join', - 'participant_leave', - 'participant_promote', - 'participant_demote', - 'participant_ban', - 'participant_unban', - 'participant_kick', - 'participant_unkick', - 'update_pinned', - 'send_message', - 'edit_message', - 'delete_message' - ) - ); - -UPDATE app_configs -SET hash = 4, - config_json = config_json - || jsonb_build_object( - 'telegram_antispam_group_size_min', 200, - 'telegram_antispam_user_id', '5434988373' - ), - updated_at = now() -WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0045_channel_forum_tabs.down.sql b/deploy/migrations/0045_channel_forum_tabs.down.sql deleted file mode 100644 index a04199fe..00000000 --- a/deploy/migrations/0045_channel_forum_tabs.down.sql +++ /dev/null @@ -1,45 +0,0 @@ --- 0045_channel_forum_tabs rollback. - -ALTER TABLE channel_admin_log_events - DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; - -DELETE FROM channel_admin_log_events -WHERE event_type = 'toggle_forum'; - -ALTER TABLE channel_admin_log_events - ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( - event_type IN ( - 'change_title', - 'change_username', - 'change_linked_chat', - 'toggle_signatures', - 'toggle_pre_history_hidden', - 'toggle_anti_spam', - 'toggle_slow_mode', - 'participant_invite', - 'participant_join', - 'participant_leave', - 'participant_promote', - 'participant_demote', - 'participant_ban', - 'participant_unban', - 'participant_kick', - 'participant_unkick', - 'update_pinned', - 'send_message', - 'edit_message', - 'delete_message' - ) - ); - -ALTER TABLE channels - DROP CONSTRAINT IF EXISTS channels_kind_check; - -ALTER TABLE channels - ADD CONSTRAINT channels_kind_check CHECK ( - (broadcast AND NOT megagroup AND NOT forum) - OR (megagroup AND NOT broadcast) - ); - -ALTER TABLE channels - DROP COLUMN IF EXISTS forum_tabs; diff --git a/deploy/migrations/0045_channel_forum_tabs.up.sql b/deploy/migrations/0045_channel_forum_tabs.up.sql deleted file mode 100644 index 62600fa9..00000000 --- a/deploy/migrations/0045_channel_forum_tabs.up.sql +++ /dev/null @@ -1,48 +0,0 @@ --- 0045_channel_forum_tabs: persist Layer 225 forum layout and admin-log action. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS forum_tabs BOOLEAN NOT NULL DEFAULT false; - -UPDATE channels -SET forum_tabs = false -WHERE NOT forum AND forum_tabs; - -ALTER TABLE channels - DROP CONSTRAINT IF EXISTS channels_kind_check; - -ALTER TABLE channels - ADD CONSTRAINT channels_kind_check CHECK ( - ((broadcast AND NOT megagroup AND NOT forum) - OR (megagroup AND NOT broadcast)) - AND (NOT forum_tabs OR forum) - ); - -ALTER TABLE channel_admin_log_events - DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; - -ALTER TABLE channel_admin_log_events - ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( - event_type IN ( - 'change_title', - 'change_username', - 'change_linked_chat', - 'toggle_signatures', - 'toggle_pre_history_hidden', - 'toggle_forum', - 'toggle_anti_spam', - 'toggle_slow_mode', - 'participant_invite', - 'participant_join', - 'participant_leave', - 'participant_promote', - 'participant_demote', - 'participant_ban', - 'participant_unban', - 'participant_kick', - 'participant_unkick', - 'update_pinned', - 'send_message', - 'edit_message', - 'delete_message' - ) - ); diff --git a/deploy/migrations/0046_channel_forum_topics.down.sql b/deploy/migrations/0046_channel_forum_topics.down.sql deleted file mode 100644 index 975ceba4..00000000 --- a/deploy/migrations/0046_channel_forum_topics.down.sql +++ /dev/null @@ -1,3 +0,0 @@ --- 0046_channel_forum_topics rollback. - -DROP TABLE IF EXISTS channel_forum_topics CASCADE; diff --git a/deploy/migrations/0046_channel_forum_topics.up.sql b/deploy/migrations/0046_channel_forum_topics.up.sql deleted file mode 100644 index 122b2dc9..00000000 --- a/deploy/migrations/0046_channel_forum_topics.up.sql +++ /dev/null @@ -1,55 +0,0 @@ --- 0046_channel_forum_topics: forum topic read model. --- --- Topic ids are the channel service message ids produced by --- messages.createForumTopic. Messages stay single-copy in channel_messages; --- this table stores the bounded topic index/read counters TDesktop needs for --- messages.getForumTopics/getForumTopicsByID. - -CREATE TABLE IF NOT EXISTS channel_forum_topics ( - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - topic_id INT NOT NULL, - creator_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - title TEXT NOT NULL DEFAULT '', - icon_color INT NOT NULL DEFAULT 0, - icon_emoji_id BIGINT NOT NULL DEFAULT 0, - title_missing BOOLEAN NOT NULL DEFAULT false, - closed BOOLEAN NOT NULL DEFAULT false, - hidden BOOLEAN NOT NULL DEFAULT false, - pinned BOOLEAN NOT NULL DEFAULT false, - pinned_order INT NOT NULL DEFAULT 0, - date INT 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, - unread_poll_votes_count INT NOT NULL DEFAULT 0, - deleted BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, topic_id), - CONSTRAINT channel_forum_topics_title_check CHECK (title <> '' OR title_missing), - CONSTRAINT channel_forum_topics_ids_check CHECK (topic_id > 0 AND top_message_id >= 0) -) PARTITION BY HASH (channel_id); - -CREATE INDEX IF NOT EXISTS channel_forum_topics_page_idx - ON channel_forum_topics (channel_id, pinned DESC, pinned_order DESC, date DESC, topic_id DESC) - WHERE NOT deleted; - -CREATE INDEX IF NOT EXISTS channel_forum_topics_title_idx - ON channel_forum_topics (channel_id, lower(title), date DESC, topic_id DESC) - WHERE NOT deleted; - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_forum_topics_p%s PARTITION OF channel_forum_topics FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; diff --git a/deploy/migrations/0047_dialog_drafts.down.sql b/deploy/migrations/0047_dialog_drafts.down.sql deleted file mode 100644 index 7d0cf407..00000000 --- a/deploy/migrations/0047_dialog_drafts.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS dialog_drafts CASCADE; diff --git a/deploy/migrations/0047_dialog_drafts.up.sql b/deploy/migrations/0047_dialog_drafts.up.sql deleted file mode 100644 index f0ad3f53..00000000 --- a/deploy/migrations/0047_dialog_drafts.up.sql +++ /dev/null @@ -1,31 +0,0 @@ --- 0047_dialog_drafts: persistent cloud drafts for private dialogs and channels. - -CREATE TABLE IF NOT EXISTS dialog_drafts ( - 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, - date INT NOT NULL DEFAULT 0, - draft JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, peer_type, peer_id, top_message_id), - CONSTRAINT dialog_drafts_peer_type_check CHECK (peer_type IN ('user', 'channel')), - CONSTRAINT dialog_drafts_top_message_id_check CHECK (top_message_id >= 0) -) PARTITION BY HASH (user_id); - -CREATE INDEX IF NOT EXISTS dialog_drafts_user_date_idx - ON dialog_drafts (user_id, date DESC, peer_type, peer_id, top_message_id); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS dialog_drafts_p%s PARTITION OF dialog_drafts FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; diff --git a/deploy/migrations/0048_channel_recommendations_index.down.sql b/deploy/migrations/0048_channel_recommendations_index.down.sql deleted file mode 100644 index ce038d33..00000000 --- a/deploy/migrations/0048_channel_recommendations_index.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP INDEX IF EXISTS channels_public_broadcast_recommendations_idx; diff --git a/deploy/migrations/0048_channel_recommendations_index.up.sql b/deploy/migrations/0048_channel_recommendations_index.up.sql deleted file mode 100644 index bcf82e46..00000000 --- a/deploy/migrations/0048_channel_recommendations_index.up.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE INDEX IF NOT EXISTS channels_public_broadcast_recommendations_idx - ON channels (participants_count DESC, date DESC, id DESC) - WHERE broadcast AND NOT megagroup AND NOT deleted AND COALESCE(username, '') <> ''; diff --git a/deploy/migrations/0049_channel_public_peer_search_indexes.down.sql b/deploy/migrations/0049_channel_public_peer_search_indexes.down.sql deleted file mode 100644 index 0de12ecb..00000000 --- a/deploy/migrations/0049_channel_public_peer_search_indexes.down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX IF EXISTS channels_public_title_trgm_idx; -DROP INDEX IF EXISTS channels_public_username_trgm_idx; diff --git a/deploy/migrations/0049_channel_public_peer_search_indexes.up.sql b/deploy/migrations/0049_channel_public_peer_search_indexes.up.sql deleted file mode 100644 index 8f21b0a4..00000000 --- a/deploy/migrations/0049_channel_public_peer_search_indexes.up.sql +++ /dev/null @@ -1,11 +0,0 @@ --- 0049_channel_public_peer_search_indexes: indexed public channel peer lookup for contacts.search. - -CREATE EXTENSION IF NOT EXISTS pg_trgm; - -CREATE INDEX IF NOT EXISTS channels_public_username_trgm_idx - ON channels USING gin (lower(username) gin_trgm_ops) - WHERE NOT deleted AND COALESCE(username, '') <> ''; - -CREATE INDEX IF NOT EXISTS channels_public_title_trgm_idx - ON channels USING gin (lower(title) gin_trgm_ops) - WHERE NOT deleted AND COALESCE(username, '') <> ''; diff --git a/deploy/migrations/0050_channel_message_reactions.down.sql b/deploy/migrations/0050_channel_message_reactions.down.sql deleted file mode 100644 index 0f0efc76..00000000 --- a/deploy/migrations/0050_channel_message_reactions.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS channel_message_reactions CASCADE; diff --git a/deploy/migrations/0050_channel_message_reactions.up.sql b/deploy/migrations/0050_channel_message_reactions.up.sql deleted file mode 100644 index 018ce7ca..00000000 --- a/deploy/migrations/0050_channel_message_reactions.up.sql +++ /dev/null @@ -1,38 +0,0 @@ -CREATE TABLE IF NOT EXISTS channel_message_reactions ( - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - message_id INT NOT NULL, - reacted_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - reaction_type VARCHAR(16) NOT NULL, - reaction_value TEXT NOT NULL, - big BOOLEAN NOT NULL DEFAULT false, - unread BOOLEAN NOT NULL DEFAULT false, - chosen_order INT NOT NULL DEFAULT 1, - reaction_date INT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (channel_id, message_id, reacted_user_id, reaction_type, reaction_value), - FOREIGN KEY (channel_id, message_id) REFERENCES channel_messages(channel_id, id) ON DELETE CASCADE, - CONSTRAINT channel_message_reactions_type_check CHECK (reaction_type IN ('emoji')), - CONSTRAINT channel_message_reactions_value_check CHECK (reaction_value <> ''), - CONSTRAINT channel_message_reactions_order_check CHECK (chosen_order > 0) -) PARTITION BY HASH (channel_id); - -CREATE INDEX IF NOT EXISTS channel_message_reactions_msg_date_idx - ON channel_message_reactions (channel_id, message_id, reaction_date DESC, reacted_user_id DESC, reaction_value ASC); - -CREATE INDEX IF NOT EXISTS channel_message_reactions_value_date_idx - ON channel_message_reactions (channel_id, message_id, reaction_type, reaction_value, reaction_date DESC, reacted_user_id DESC); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS channel_message_reactions_p%s PARTITION OF channel_message_reactions FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; diff --git a/deploy/migrations/0051_channel_unread_reactions_index.down.sql b/deploy/migrations/0051_channel_unread_reactions_index.down.sql deleted file mode 100644 index e2311f05..00000000 --- a/deploy/migrations/0051_channel_unread_reactions_index.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP INDEX IF EXISTS channel_message_reactions_unread_owner_idx; diff --git a/deploy/migrations/0051_channel_unread_reactions_index.up.sql b/deploy/migrations/0051_channel_unread_reactions_index.up.sql deleted file mode 100644 index f4248bb8..00000000 --- a/deploy/migrations/0051_channel_unread_reactions_index.up.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE INDEX IF NOT EXISTS channel_message_reactions_unread_owner_idx - ON channel_message_reactions (channel_id, sender_user_id, message_id DESC) - WHERE unread; diff --git a/deploy/migrations/0052_user_recent_reactions.down.sql b/deploy/migrations/0052_user_recent_reactions.down.sql deleted file mode 100644 index cf28e17d..00000000 --- a/deploy/migrations/0052_user_recent_reactions.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS user_recent_reactions; diff --git a/deploy/migrations/0052_user_recent_reactions.up.sql b/deploy/migrations/0052_user_recent_reactions.up.sql deleted file mode 100644 index 3e8b8267..00000000 --- a/deploy/migrations/0052_user_recent_reactions.up.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE IF NOT EXISTS user_recent_reactions ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reaction_type VARCHAR(16) NOT NULL, - reaction_value TEXT NOT NULL, - reaction_date INT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, reaction_type, reaction_value), - CHECK (reaction_type IN ('emoji')), - CHECK (reaction_value <> '') -); - -CREATE INDEX IF NOT EXISTS user_recent_reactions_user_date_idx - ON user_recent_reactions (user_id, reaction_date DESC, updated_at DESC, reaction_value ASC); diff --git a/deploy/migrations/0053_user_top_reactions.down.sql b/deploy/migrations/0053_user_top_reactions.down.sql deleted file mode 100644 index cef29c69..00000000 --- a/deploy/migrations/0053_user_top_reactions.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS user_top_reactions; diff --git a/deploy/migrations/0053_user_top_reactions.up.sql b/deploy/migrations/0053_user_top_reactions.up.sql deleted file mode 100644 index 0cd37684..00000000 --- a/deploy/migrations/0053_user_top_reactions.up.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE IF NOT EXISTS user_top_reactions ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reaction_type VARCHAR(16) NOT NULL, - reaction_value TEXT NOT NULL, - reaction_count INT NOT NULL DEFAULT 0, - reaction_date INT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, reaction_type, reaction_value), - CHECK (reaction_type IN ('emoji')), - CHECK (reaction_value <> ''), - CHECK (reaction_count >= 0) -); - -CREATE INDEX IF NOT EXISTS user_top_reactions_user_rank_idx - ON user_top_reactions (user_id, reaction_count DESC, reaction_date DESC, updated_at DESC, reaction_value ASC); diff --git a/deploy/migrations/0054_user_saved_reaction_tags.down.sql b/deploy/migrations/0054_user_saved_reaction_tags.down.sql deleted file mode 100644 index 99294761..00000000 --- a/deploy/migrations/0054_user_saved_reaction_tags.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS user_saved_reaction_tags; diff --git a/deploy/migrations/0054_user_saved_reaction_tags.up.sql b/deploy/migrations/0054_user_saved_reaction_tags.up.sql deleted file mode 100644 index 88cd76a4..00000000 --- a/deploy/migrations/0054_user_saved_reaction_tags.up.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE IF NOT EXISTS user_saved_reaction_tags ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reaction_type VARCHAR(16) NOT NULL, - reaction_value TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '', - reaction_count INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, reaction_type, reaction_value), - CHECK (reaction_type IN ('emoji')), - CHECK (reaction_value <> ''), - CHECK (reaction_count >= 0), - CHECK (char_length(title) <= 12) -); - -CREATE INDEX IF NOT EXISTS user_saved_reaction_tags_user_order_idx - ON user_saved_reaction_tags (user_id, reaction_count DESC, updated_at DESC, reaction_value ASC); diff --git a/deploy/migrations/0055_channel_paid_translation_settings.down.sql b/deploy/migrations/0055_channel_paid_translation_settings.down.sql deleted file mode 100644 index e65024c4..00000000 --- a/deploy/migrations/0055_channel_paid_translation_settings.down.sql +++ /dev/null @@ -1,41 +0,0 @@ --- 0055_channel_paid_translation_settings rollback. - -ALTER TABLE channel_admin_log_events - DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; - -DELETE FROM channel_admin_log_events -WHERE event_type = 'toggle_autotranslation'; - -ALTER TABLE channel_admin_log_events - ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( - event_type IN ( - 'change_title', - 'change_username', - 'change_linked_chat', - 'toggle_signatures', - 'toggle_pre_history_hidden', - 'toggle_forum', - 'toggle_anti_spam', - 'toggle_slow_mode', - 'participant_invite', - 'participant_join', - 'participant_leave', - 'participant_promote', - 'participant_demote', - 'participant_ban', - 'participant_unban', - 'participant_kick', - 'participant_unkick', - 'update_pinned', - 'send_message', - 'edit_message', - 'delete_message' - ) - ); - -ALTER TABLE channels - DROP CONSTRAINT IF EXISTS channels_send_paid_messages_stars_nonnegative_check, - DROP COLUMN IF EXISTS send_paid_messages_stars, - DROP COLUMN IF EXISTS broadcast_messages_allowed, - DROP COLUMN IF EXISTS restricted_sponsored, - DROP COLUMN IF EXISTS autotranslation; diff --git a/deploy/migrations/0055_channel_paid_translation_settings.up.sql b/deploy/migrations/0055_channel_paid_translation_settings.up.sql deleted file mode 100644 index 8c3b7eee..00000000 --- a/deploy/migrations/0055_channel_paid_translation_settings.up.sql +++ /dev/null @@ -1,41 +0,0 @@ --- 0055_channel_paid_translation_settings: persist Layer 225 channel setting flags. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS autotranslation BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS restricted_sponsored BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS broadcast_messages_allowed BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS send_paid_messages_stars BIGINT NOT NULL DEFAULT 0; - -ALTER TABLE channels - ADD CONSTRAINT channels_send_paid_messages_stars_nonnegative_check CHECK (send_paid_messages_stars >= 0); - -ALTER TABLE channel_admin_log_events - DROP CONSTRAINT IF EXISTS channel_admin_log_events_type_check; - -ALTER TABLE channel_admin_log_events - ADD CONSTRAINT channel_admin_log_events_type_check CHECK ( - event_type IN ( - 'change_title', - 'change_username', - 'change_linked_chat', - 'toggle_signatures', - 'toggle_pre_history_hidden', - 'toggle_forum', - 'toggle_autotranslation', - 'toggle_anti_spam', - 'toggle_slow_mode', - 'participant_invite', - 'participant_join', - 'participant_leave', - 'participant_promote', - 'participant_demote', - 'participant_ban', - 'participant_unban', - 'participant_kick', - 'participant_unkick', - 'update_pinned', - 'send_message', - 'edit_message', - 'delete_message' - ) - ); diff --git a/deploy/migrations/0056_update_watermarks_retention.down.sql b/deploy/migrations/0056_update_watermarks_retention.down.sql deleted file mode 100644 index 0e0cc7a9..00000000 --- a/deploy/migrations/0056_update_watermarks_retention.down.sql +++ /dev/null @@ -1,3 +0,0 @@ -DROP INDEX IF EXISTS dispatch_outbox_failed_cleanup_idx; -DROP INDEX IF EXISTS user_update_events_retention_idx; -DROP TABLE IF EXISTS user_update_watermarks; diff --git a/deploy/migrations/0056_update_watermarks_retention.up.sql b/deploy/migrations/0056_update_watermarks_retention.up.sql deleted file mode 100644 index c475c0ac..00000000 --- a/deploy/migrations/0056_update_watermarks_retention.up.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS user_update_watermarks ( - user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - contiguous_pts INT NOT NULL DEFAULT 0, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS user_update_events_retention_idx - ON user_update_events (user_id, date, pts); - -CREATE INDEX IF NOT EXISTS dispatch_outbox_failed_cleanup_idx - ON dispatch_outbox (status, updated_at, target_user_id, id) - WHERE status = 'failed'; diff --git a/deploy/migrations/0057_media.down.sql b/deploy/migrations/0057_media.down.sql deleted file mode 100644 index 81244a8d..00000000 --- a/deploy/migrations/0057_media.down.sql +++ /dev/null @@ -1,23 +0,0 @@ --- 0057_media down: drop media catalogs and message media columns; restore body checks. - -ALTER TABLE channel_messages DROP CONSTRAINT IF EXISTS channel_messages_content_check; -ALTER TABLE channel_messages - ADD CONSTRAINT channel_messages_content_check - CHECK (body <> '' OR action <> '{}'::jsonb); - -ALTER TABLE private_messages DROP CONSTRAINT IF EXISTS private_messages_nonempty_body; -ALTER TABLE private_messages - ADD CONSTRAINT private_messages_nonempty_body - CHECK (body <> ''); - -ALTER TABLE channel_messages DROP COLUMN IF EXISTS media; -ALTER TABLE message_boxes DROP COLUMN IF EXISTS media; -ALTER TABLE private_messages DROP COLUMN IF EXISTS media; - -DROP TABLE IF EXISTS profile_photos; -DROP TABLE IF EXISTS available_reactions; -DROP TABLE IF EXISTS sticker_sets; -DROP TABLE IF EXISTS photos; -DROP TABLE IF EXISTS documents; -DROP TABLE IF EXISTS file_blobs; -DROP TABLE IF EXISTS upload_parts; diff --git a/deploy/migrations/0057_media.up.sql b/deploy/migrations/0057_media.up.sql deleted file mode 100644 index 109aba62..00000000 --- a/deploy/migrations/0057_media.up.sql +++ /dev/null @@ -1,143 +0,0 @@ --- 0057_media: media pipeline storage — upload parts, file blobs, documents, --- photos, sticker sets, available reactions, profile photos; message media column. --- --- Registry tables (documents/photos/sticker_sets/available_reactions/file_blobs) --- are low-cardinality global catalogs and are not partitioned. upload_parts is --- transient (assembled then deleted). Blob bytes live on the configured backend --- (localfs); file_blobs only indexes location_key -> backend/object_key. - --- 上传分片:upload.saveFilePart / saveBigFilePart 累积,组装成 blob 后清理。 -CREATE TABLE IF NOT EXISTS upload_parts ( - owner_user_id BIGINT NOT NULL, - file_id BIGINT NOT NULL, - part INT NOT NULL, - total_parts INT NOT NULL DEFAULT 0, - is_big BOOLEAN NOT NULL DEFAULT false, - bytes BYTEA NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (owner_user_id, file_id, part) -); - --- file_blobs:可下载二进制对象的索引(location_key -> backend/object_key)。 -CREATE TABLE IF NOT EXISTS file_blobs ( - location_key TEXT PRIMARY KEY, - backend TEXT NOT NULL DEFAULT 'localfs', - object_key TEXT NOT NULL, - size BIGINT NOT NULL DEFAULT 0, - sha256 BYTEA NOT NULL DEFAULT ''::bytea, - mime_type TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- documents:Telegram 文档(贴纸/gif/文件/视频/音频/自定义 emoji)元数据。 -CREATE TABLE IF NOT EXISTS documents ( - id BIGINT PRIMARY KEY, - access_hash BIGINT NOT NULL DEFAULT 0, - file_reference BYTEA NOT NULL DEFAULT '', - date INT NOT NULL DEFAULT 0, - mime_type TEXT NOT NULL DEFAULT '', - size BIGINT NOT NULL DEFAULT 0, - dc_id INT NOT NULL DEFAULT 0, - attributes JSONB NOT NULL DEFAULT '[]'::jsonb, - thumbs JSONB NOT NULL DEFAULT '[]'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- photos:Telegram 照片(头像/图片消息)元数据。 -CREATE TABLE IF NOT EXISTS photos ( - id BIGINT PRIMARY KEY, - access_hash BIGINT NOT NULL DEFAULT 0, - file_reference BYTEA NOT NULL DEFAULT '', - date INT NOT NULL DEFAULT 0, - dc_id INT NOT NULL DEFAULT 0, - has_stickers BOOLEAN NOT NULL DEFAULT false, - sizes JSONB NOT NULL DEFAULT '[]'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- sticker_sets:贴纸 / 自定义 emoji 集(含有序文档 id 与 packs)。 -CREATE TABLE IF NOT EXISTS sticker_sets ( - id BIGINT PRIMARY KEY, - access_hash BIGINT NOT NULL DEFAULT 0, - short_name TEXT NOT NULL DEFAULT '', - title TEXT NOT NULL DEFAULT '', - count INT NOT NULL DEFAULT 0, - hash INT NOT NULL DEFAULT 0, - set_kind TEXT NOT NULL DEFAULT 'stickers', - official BOOLEAN NOT NULL DEFAULT false, - animated BOOLEAN NOT NULL DEFAULT false, - videos BOOLEAN NOT NULL DEFAULT false, - emojis BOOLEAN NOT NULL DEFAULT false, - masks BOOLEAN NOT NULL DEFAULT false, - installed BOOLEAN NOT NULL DEFAULT false, - archived BOOLEAN NOT NULL DEFAULT false, - installed_date INT NOT NULL DEFAULT 0, - thumb_document_id BIGINT NOT NULL DEFAULT 0, - thumbs JSONB NOT NULL DEFAULT '[]'::jsonb, - thumb_dc_id INT NOT NULL DEFAULT 0, - thumb_version INT NOT NULL DEFAULT 0, - document_ids JSONB NOT NULL DEFAULT '[]'::jsonb, - packs JSONB NOT NULL DEFAULT '[]'::jsonb, - sort_order INT NOT NULL DEFAULT 0, - system_key TEXT NOT NULL DEFAULT '', - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX IF NOT EXISTS sticker_sets_short_name_idx - ON sticker_sets (short_name) WHERE short_name <> ''; -CREATE INDEX IF NOT EXISTS sticker_sets_kind_order_idx - ON sticker_sets (set_kind, sort_order, id); -CREATE INDEX IF NOT EXISTS sticker_sets_system_key_idx - ON sticker_sets (system_key) WHERE system_key <> ''; - --- available_reactions:messages.getAvailableReactions 目录(引用真实文档 id)。 -CREATE TABLE IF NOT EXISTS available_reactions ( - reaction TEXT PRIMARY KEY, - title TEXT NOT NULL DEFAULT '', - inactive BOOLEAN NOT NULL DEFAULT false, - premium BOOLEAN NOT NULL DEFAULT false, - static_icon_id BIGINT NOT NULL DEFAULT 0, - appear_animation_id BIGINT NOT NULL DEFAULT 0, - select_animation_id BIGINT NOT NULL DEFAULT 0, - activate_animation_id BIGINT NOT NULL DEFAULT 0, - effect_animation_id BIGINT NOT NULL DEFAULT 0, - around_animation_id BIGINT NOT NULL DEFAULT 0, - center_icon_id BIGINT NOT NULL DEFAULT 0, - sort_order INT NOT NULL DEFAULT 0 -); - --- profile_photos:用户/频道头像历史(current = active 中 sort_order 最大者)。 -CREATE TABLE IF NOT EXISTS profile_photos ( - owner_peer_type TEXT NOT NULL, - owner_peer_id BIGINT NOT NULL, - photo_id BIGINT NOT NULL, - date INT NOT NULL DEFAULT 0, - active BOOLEAN NOT NULL DEFAULT true, - sort_order BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (owner_peer_type, owner_peer_id, photo_id), - CONSTRAINT profile_photos_peer_type_check CHECK (owner_peer_type IN ('user', 'channel')) -); - -CREATE INDEX IF NOT EXISTS profile_photos_current_idx - ON profile_photos (owner_peer_type, owner_peer_id, sort_order DESC) - WHERE active; - --- 消息媒体快照列:避免历史读取再 join documents/photos。 -ALTER TABLE private_messages - ADD COLUMN IF NOT EXISTS media JSONB NOT NULL DEFAULT '{}'::jsonb; -ALTER TABLE message_boxes - ADD COLUMN IF NOT EXISTS media JSONB NOT NULL DEFAULT '{}'::jsonb; -ALTER TABLE channel_messages - ADD COLUMN IF NOT EXISTS media JSONB NOT NULL DEFAULT '{}'::jsonb; - --- 放宽 body 非空约束:允许「仅媒体」消息(图片/文件/贴纸,无文字 caption)。 -ALTER TABLE private_messages DROP CONSTRAINT IF EXISTS private_messages_nonempty_body; -ALTER TABLE private_messages - ADD CONSTRAINT private_messages_nonempty_body - CHECK (body <> '' OR media <> '{}'::jsonb); - -ALTER TABLE channel_messages DROP CONSTRAINT IF EXISTS channel_messages_content_check; -ALTER TABLE channel_messages - ADD CONSTRAINT channel_messages_content_check - CHECK (body <> '' OR action <> '{}'::jsonb OR media <> '{}'::jsonb); diff --git a/deploy/migrations/0058_app_config_reactions.down.sql b/deploy/migrations/0058_app_config_reactions.down.sql deleted file mode 100644 index a20b9870..00000000 --- a/deploy/migrations/0058_app_config_reactions.down.sql +++ /dev/null @@ -1,11 +0,0 @@ --- 0058_app_config_reactions down: drop reaction config keys, restore prior hash. - -UPDATE app_configs -SET hash = 3, - config_json = config_json - - 'reactions_default' - - 'reactions_uniq_max' - - 'reactions_user_max_default' - - 'reactions_in_chat_max', - updated_at = now() -WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0058_app_config_reactions.up.sql b/deploy/migrations/0058_app_config_reactions.up.sql deleted file mode 100644 index bda96ae7..00000000 --- a/deploy/migrations/0058_app_config_reactions.up.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 0058_app_config_reactions: expose default reaction + reaction limits for TDesktop. - -UPDATE app_configs -SET hash = 5, - config_json = config_json || jsonb_build_object( - 'reactions_default', jsonb_build_object('_', 'reactionEmoji', 'emoticon', '👍'), - 'reactions_uniq_max', 11, - 'reactions_user_max_default', 1, - 'reactions_in_chat_max', 3 - ), - updated_at = now() -WHERE client = 'tdesktop'; diff --git a/deploy/migrations/0059_channel_photo.down.sql b/deploy/migrations/0059_channel_photo.down.sql deleted file mode 100644 index 170ab9a0..00000000 --- a/deploy/migrations/0059_channel_photo.down.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 0059_channel_photo down: drop denormalized channel avatar columns. - -ALTER TABLE channels - DROP COLUMN IF EXISTS photo_stripped, - DROP COLUMN IF EXISTS photo_dc_id, - DROP COLUMN IF EXISTS photo_id; diff --git a/deploy/migrations/0059_channel_photo.up.sql b/deploy/migrations/0059_channel_photo.up.sql deleted file mode 100644 index 335be707..00000000 --- a/deploy/migrations/0059_channel_photo.up.sql +++ /dev/null @@ -1,6 +0,0 @@ --- 0059_channel_photo: denormalized current channel/supergroup avatar for ChatPhoto rendering. - -ALTER TABLE channels - ADD COLUMN IF NOT EXISTS photo_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS photo_dc_id INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS photo_stripped BYTEA NOT NULL DEFAULT ''::bytea; diff --git a/deploy/migrations/0060_user_last_seen.down.sql b/deploy/migrations/0060_user_last_seen.down.sql deleted file mode 100644 index ec9ac933..00000000 --- a/deploy/migrations/0060_user_last_seen.down.sql +++ /dev/null @@ -1,4 +0,0 @@ -DROP INDEX IF EXISTS users_last_seen_idx; - -ALTER TABLE users - DROP COLUMN IF EXISTS last_seen_at; diff --git a/deploy/migrations/0060_user_last_seen.up.sql b/deploy/migrations/0060_user_last_seen.up.sql deleted file mode 100644 index a534caea..00000000 --- a/deploy/migrations/0060_user_last_seen.up.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 0060_user_last_seen: persist exact last seen timestamps for UserStatusOffline.was_online. - -ALTER TABLE users - ADD COLUMN IF NOT EXISTS last_seen_at BIGINT NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS users_last_seen_idx - ON users (last_seen_at DESC) - WHERE last_seen_at > 0; diff --git a/deploy/migrations/0061_private_message_reactions.down.sql b/deploy/migrations/0061_private_message_reactions.down.sql deleted file mode 100644 index b3fc1ff7..00000000 --- a/deploy/migrations/0061_private_message_reactions.down.sql +++ /dev/null @@ -1,4 +0,0 @@ --- 0061_private_message_reactions down. - -DROP TABLE IF EXISTS private_message_reactions; - diff --git a/deploy/migrations/0061_private_message_reactions.up.sql b/deploy/migrations/0061_private_message_reactions.up.sql deleted file mode 100644 index d89ec6a5..00000000 --- a/deploy/migrations/0061_private_message_reactions.up.sql +++ /dev/null @@ -1,20 +0,0 @@ --- 0061_private_message_reactions: persist emoji reactions for private message boxes. - -CREATE TABLE IF NOT EXISTS private_message_reactions ( - message_sender_id bigint NOT NULL, - private_message_id bigint NOT NULL, - user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE, - reaction_type text NOT NULL, - reaction_value text NOT NULL, - big boolean NOT NULL DEFAULT false, - reaction_date integer NOT NULL, - chosen_order integer NOT NULL DEFAULT 1, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (message_sender_id, private_message_id, user_id, reaction_type, reaction_value), - FOREIGN KEY (message_sender_id, private_message_id) - REFERENCES private_messages(sender_user_id, id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS private_message_reactions_message_idx - ON private_message_reactions (message_sender_id, private_message_id, reaction_date DESC, user_id); diff --git a/deploy/migrations/0062_account_reaction_settings.down.sql b/deploy/migrations/0062_account_reaction_settings.down.sql deleted file mode 100644 index 4f42ed67..00000000 --- a/deploy/migrations/0062_account_reaction_settings.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS account_reaction_settings; diff --git a/deploy/migrations/0062_account_reaction_settings.up.sql b/deploy/migrations/0062_account_reaction_settings.up.sql deleted file mode 100644 index e7a74f20..00000000 --- a/deploy/migrations/0062_account_reaction_settings.up.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS account_reaction_settings ( - user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - messages_notify_from TEXT NOT NULL DEFAULT 'contacts', - stories_notify_from TEXT NOT NULL DEFAULT 'contacts', - poll_votes_notify_from TEXT NOT NULL DEFAULT 'contacts', - show_previews BOOLEAN NOT NULL DEFAULT TRUE, - default_reaction_type TEXT NOT NULL DEFAULT 'emoji', - default_reaction_value TEXT NOT NULL DEFAULT '👍', - paid_privacy_kind TEXT NOT NULL DEFAULT 'default', - paid_privacy_peer_type TEXT, - paid_privacy_peer_id BIGINT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CHECK (messages_notify_from IN ('none', 'contacts', 'all')), - CHECK (stories_notify_from IN ('none', 'contacts', 'all')), - CHECK (poll_votes_notify_from IN ('none', 'contacts', 'all')), - CHECK (default_reaction_type IN ('emoji')), - CHECK (default_reaction_value <> ''), - CHECK (paid_privacy_kind IN ('default', 'anonymous', 'peer')), - CHECK ( - (paid_privacy_kind = 'peer' AND paid_privacy_peer_type IN ('user', 'channel') AND paid_privacy_peer_id IS NOT NULL) - OR (paid_privacy_kind <> 'peer' AND paid_privacy_peer_type IS NULL AND paid_privacy_peer_id IS NULL) - ) -); diff --git a/deploy/migrations/0063_reaction_update_events.down.sql b/deploy/migrations/0063_reaction_update_events.down.sql deleted file mode 100644 index 92d40e12..00000000 --- a/deploy/migrations/0063_reaction_update_events.down.sql +++ /dev/null @@ -1,24 +0,0 @@ -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'read_history_outbox', - 'edit_message', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'dialog_filter', - 'dialog_filter_order', - 'dialog_filters', - 'folder_peers', - 'channel_available_messages', - 'noop' - ) - ); diff --git a/deploy/migrations/0063_reaction_update_events.up.sql b/deploy/migrations/0063_reaction_update_events.up.sql deleted file mode 100644 index cc33e3cc..00000000 --- a/deploy/migrations/0063_reaction_update_events.up.sql +++ /dev/null @@ -1,28 +0,0 @@ --- 0063_reaction_update_events: allow durable account-level reaction updates. - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'read_history_outbox', - 'edit_message', - 'message_reactions', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'dialog_filter', - 'dialog_filter_order', - 'dialog_filters', - 'folder_peers', - 'channel_available_messages', - 'channel_view_forum_as_messages', - 'noop' - ) - ); diff --git a/deploy/migrations/0064_channel_dialog_unread_repair.down.sql b/deploy/migrations/0064_channel_dialog_unread_repair.down.sql deleted file mode 100644 index d6c587fe..00000000 --- a/deploy/migrations/0064_channel_dialog_unread_repair.down.sql +++ /dev/null @@ -1 +0,0 @@ --- Data repair only; no rollback. diff --git a/deploy/migrations/0064_channel_dialog_unread_repair.up.sql b/deploy/migrations/0064_channel_dialog_unread_repair.up.sql deleted file mode 100644 index adb5909c..00000000 --- a/deploy/migrations/0064_channel_dialog_unread_repair.up.sql +++ /dev/null @@ -1,21 +0,0 @@ -WITH fixed AS ( - SELECT - d.user_id, - d.channel_id, - COUNT(msg.id)::int AS unread_count - FROM channel_dialogs d - LEFT JOIN channel_messages msg - ON msg.channel_id = d.channel_id - AND msg.id > d.read_inbox_max_id - AND msg.id <= d.top_message_id - AND NOT msg.deleted - AND msg.sender_user_id <> d.user_id - GROUP BY d.user_id, d.channel_id -) -UPDATE channel_dialogs d -SET unread_count = fixed.unread_count, - updated_at = now() -FROM fixed -WHERE fixed.user_id = d.user_id - AND fixed.channel_id = d.channel_id - AND d.unread_count IS DISTINCT FROM fixed.unread_count; diff --git a/deploy/migrations/0065_user_channel_member_index.down.sql b/deploy/migrations/0065_user_channel_member_index.down.sql deleted file mode 100644 index 59d72cea..00000000 --- a/deploy/migrations/0065_user_channel_member_index.down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE IF EXISTS user_channel_member_index; diff --git a/deploy/migrations/0065_user_channel_member_index.up.sql b/deploy/migrations/0065_user_channel_member_index.up.sql deleted file mode 100644 index cb025950..00000000 --- a/deploy/migrations/0065_user_channel_member_index.up.sql +++ /dev/null @@ -1,29 +0,0 @@ -CREATE TABLE IF NOT EXISTS user_channel_member_index ( - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - status VARCHAR(16) NOT NULL, - megagroup BOOLEAN NOT NULL DEFAULT false, - broadcast BOOLEAN NOT NULL DEFAULT false, - deleted BOOLEAN NOT NULL DEFAULT false, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, channel_id), - CONSTRAINT user_channel_member_index_status_check - CHECK (status IN ('active', 'left', 'kicked', 'banned')) -); - -CREATE INDEX IF NOT EXISTS user_channel_member_index_common_idx - ON user_channel_member_index (user_id, channel_id) - WHERE status = 'active' AND megagroup AND NOT broadcast AND NOT deleted; - -INSERT INTO user_channel_member_index ( - user_id, channel_id, status, megagroup, broadcast, deleted -) -SELECT m.user_id, m.channel_id, m.status, c.megagroup, c.broadcast, c.deleted -FROM channel_members m -JOIN channels c ON c.id = m.channel_id -ON CONFLICT (user_id, channel_id) DO UPDATE SET - status = EXCLUDED.status, - megagroup = EXCLUDED.megagroup, - broadcast = EXCLUDED.broadcast, - deleted = EXCLUDED.deleted, - updated_at = now(); diff --git a/deploy/migrations/0066_system_sticker_sets_not_installed.down.sql b/deploy/migrations/0066_system_sticker_sets_not_installed.down.sql deleted file mode 100644 index 66e70141..00000000 --- a/deploy/migrations/0066_system_sticker_sets_not_installed.down.sql +++ /dev/null @@ -1,4 +0,0 @@ -UPDATE sticker_sets -SET installed = true, - installed_date = 0 -WHERE set_kind = 'system'; diff --git a/deploy/migrations/0066_system_sticker_sets_not_installed.up.sql b/deploy/migrations/0066_system_sticker_sets_not_installed.up.sql deleted file mode 100644 index e0266f65..00000000 --- a/deploy/migrations/0066_system_sticker_sets_not_installed.up.sql +++ /dev/null @@ -1,8 +0,0 @@ --- System sticker sets are request-by-input resources for TDesktop --- (animated emoji, dice, generic animations). They must not be persisted as --- normal installed sticker packs, otherwise the client can abort/redo its --- installed stickers local-cache write path. -UPDATE sticker_sets -SET installed = false, - installed_date = 0 -WHERE set_kind = 'system'; diff --git a/deploy/migrations/0067_seed_document_id_namespace.down.sql b/deploy/migrations/0067_seed_document_id_namespace.down.sql deleted file mode 100644 index 7cb56e35..00000000 --- a/deploy/migrations/0067_seed_document_id_namespace.down.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Forward-only data normalization. Reintroducing upstream seed document ids on --- rollback would make local media identity ambiguous again, so there is no --- destructive down migration. diff --git a/deploy/migrations/0067_seed_document_id_namespace.up.sql b/deploy/migrations/0067_seed_document_id_namespace.up.sql deleted file mode 100644 index 8302e33a..00000000 --- a/deploy/migrations/0067_seed_document_id_namespace.up.sql +++ /dev/null @@ -1,191 +0,0 @@ --- 0067_seed_document_id_namespace: normalize imported sticker/reaction --- document ids into telesrv-owned storage ids. --- --- The seed export keeps upstream Telegram document ids in JSON and filenames. --- Those ids are source identifiers, not identities owned by this server. Older --- local databases stored them directly; normalize every catalog/message --- reference so RPC, media snapshots and upload.getFile all use one server id. - -CREATE OR REPLACE FUNCTION pg_temp.telesrv_seed_document_storage_id(source_id BIGINT) -RETURNS BIGINT -LANGUAGE SQL -IMMUTABLE -AS $$ - SELECT CASE - WHEN source_id > 4000000000000000000 THEN source_id - 4000000000000000000 - ELSE source_id - END -$$; - -CREATE OR REPLACE FUNCTION pg_temp.telesrv_seed_document_id_array(ids JSONB) -RETURNS JSONB -LANGUAGE SQL -IMMUTABLE -AS $$ - SELECT COALESCE( - jsonb_agg( - CASE - WHEN value::text ~ '^-?[0-9]+$' - THEN to_jsonb(pg_temp.telesrv_seed_document_storage_id((value::text)::BIGINT)) - ELSE value - END - ORDER BY ord - ), - '[]'::jsonb - ) - FROM jsonb_array_elements( - CASE - WHEN jsonb_typeof(COALESCE(ids, 'null'::jsonb)) = 'array' THEN ids - ELSE '[]'::jsonb - END - ) WITH ORDINALITY AS e(value, ord) -$$; - -CREATE OR REPLACE FUNCTION pg_temp.telesrv_seed_sticker_packs_document_ids(packs JSONB) -RETURNS JSONB -LANGUAGE SQL -IMMUTABLE -AS $$ - SELECT COALESCE( - jsonb_agg( - CASE - WHEN pack ? 'document_ids' THEN - jsonb_set(pack, '{document_ids}', pg_temp.telesrv_seed_document_id_array(pack->'document_ids'), false) - WHEN pack ? 'documents' THEN - jsonb_set(pack, '{documents}', pg_temp.telesrv_seed_document_id_array(pack->'documents'), false) - ELSE pack - END - ORDER BY ord - ), - '[]'::jsonb - ) - FROM jsonb_array_elements( - CASE - WHEN jsonb_typeof(COALESCE(packs, 'null'::jsonb)) = 'array' THEN packs - ELSE '[]'::jsonb - END - ) WITH ORDINALITY AS p(pack, ord) -$$; - -DROP TABLE IF EXISTS seed_document_id_map; -CREATE TEMP TABLE seed_document_id_map AS -SELECT id AS old_id, pg_temp.telesrv_seed_document_storage_id(id) AS new_id -FROM documents -WHERE id > 4000000000000000000; - -INSERT INTO documents ( - id, access_hash, file_reference, date, mime_type, size, dc_id, attributes, thumbs, created_at -) -SELECT - m.new_id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, - d.attributes, d.thumbs, d.created_at -FROM documents d -JOIN seed_document_id_map m ON m.old_id = d.id -WHERE m.old_id <> m.new_id -ON CONFLICT (id) DO UPDATE SET - access_hash = EXCLUDED.access_hash, - file_reference = EXCLUDED.file_reference, - date = EXCLUDED.date, - mime_type = EXCLUDED.mime_type, - size = EXCLUDED.size, - dc_id = EXCLUDED.dc_id, - attributes = EXCLUDED.attributes, - thumbs = EXCLUDED.thumbs; - -DELETE FROM documents d -USING seed_document_id_map m -WHERE d.id = m.old_id - AND m.old_id <> m.new_id; - -DROP TABLE IF EXISTS seed_file_blob_map; -CREATE TEMP TABLE seed_file_blob_map AS -SELECT - location_key AS old_key, - 'doc:' || - pg_temp.telesrv_seed_document_storage_id((substring(location_key FROM '^doc:([0-9]+)'))::BIGINT)::TEXT || - COALESCE(substring(location_key FROM '^doc:[0-9]+(:.*)$'), '') AS new_key -FROM file_blobs -WHERE substring(location_key FROM '^doc:([0-9]+)') IS NOT NULL - AND (substring(location_key FROM '^doc:([0-9]+)'))::BIGINT > 4000000000000000000; - -INSERT INTO file_blobs (location_key, backend, object_key, size, sha256, mime_type, created_at) -SELECT m.new_key, b.backend, b.object_key, b.size, b.sha256, b.mime_type, b.created_at -FROM file_blobs b -JOIN seed_file_blob_map m ON m.old_key = b.location_key -WHERE m.old_key <> m.new_key -ON CONFLICT (location_key) DO UPDATE SET - backend = EXCLUDED.backend, - object_key = EXCLUDED.object_key, - size = EXCLUDED.size, - sha256 = EXCLUDED.sha256, - mime_type = EXCLUDED.mime_type; - -DELETE FROM file_blobs b -USING seed_file_blob_map m -WHERE b.location_key = m.old_key - AND m.old_key <> m.new_key; - -UPDATE sticker_sets -SET thumb_document_id = pg_temp.telesrv_seed_document_storage_id(thumb_document_id), - document_ids = pg_temp.telesrv_seed_document_id_array(document_ids), - packs = pg_temp.telesrv_seed_sticker_packs_document_ids(packs); - -UPDATE available_reactions -SET static_icon_id = pg_temp.telesrv_seed_document_storage_id(static_icon_id), - appear_animation_id = pg_temp.telesrv_seed_document_storage_id(appear_animation_id), - select_animation_id = pg_temp.telesrv_seed_document_storage_id(select_animation_id), - activate_animation_id = pg_temp.telesrv_seed_document_storage_id(activate_animation_id), - effect_animation_id = pg_temp.telesrv_seed_document_storage_id(effect_animation_id), - around_animation_id = pg_temp.telesrv_seed_document_storage_id(around_animation_id), - center_icon_id = pg_temp.telesrv_seed_document_storage_id(center_icon_id); - -UPDATE private_messages -SET media = jsonb_set( - media, - '{document,id}', - to_jsonb(pg_temp.telesrv_seed_document_storage_id((media #>> '{document,id}')::BIGINT)), - false -) -WHERE media->>'kind' = 'document' - AND (media #>> '{document,id}') ~ '^[0-9]+$' - AND (media #>> '{document,id}')::BIGINT > 4000000000000000000; - -UPDATE message_boxes -SET media = jsonb_set( - media, - '{document,id}', - to_jsonb(pg_temp.telesrv_seed_document_storage_id((media #>> '{document,id}')::BIGINT)), - false -) -WHERE media->>'kind' = 'document' - AND (media #>> '{document,id}') ~ '^[0-9]+$' - AND (media #>> '{document,id}')::BIGINT > 4000000000000000000; - -UPDATE channel_messages -SET media = jsonb_set( - media, - '{document,id}', - to_jsonb(pg_temp.telesrv_seed_document_storage_id((media #>> '{document,id}')::BIGINT)), - false -) -WHERE media->>'kind' = 'document' - AND (media #>> '{document,id}') ~ '^[0-9]+$' - AND (media #>> '{document,id}')::BIGINT > 4000000000000000000; - -UPDATE channels -SET color_background_emoji_id = pg_temp.telesrv_seed_document_storage_id(color_background_emoji_id), - profile_color_background_emoji_id = pg_temp.telesrv_seed_document_storage_id(profile_color_background_emoji_id), - emoji_status_document_id = pg_temp.telesrv_seed_document_storage_id(emoji_status_document_id), - available_reactions = CASE - WHEN available_reactions ? 'CustomEmojiIDs' THEN - jsonb_set( - available_reactions, - '{CustomEmojiIDs}', - pg_temp.telesrv_seed_document_id_array(available_reactions->'CustomEmojiIDs'), - false - ) - ELSE available_reactions - END; - -DROP TABLE IF EXISTS seed_file_blob_map; -DROP TABLE IF EXISTS seed_document_id_map; diff --git a/deploy/migrations/0068_content_unread_and_blocks.down.sql b/deploy/migrations/0068_content_unread_and_blocks.down.sql deleted file mode 100644 index c1096878..00000000 --- a/deploy/migrations/0068_content_unread_and_blocks.down.sql +++ /dev/null @@ -1,35 +0,0 @@ -DROP TABLE IF EXISTS contact_blocks; - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'read_history_outbox', - 'edit_message', - 'message_reactions', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'dialog_filter', - 'dialog_filter_order', - 'dialog_filters', - 'folder_peers', - 'channel_available_messages', - 'channel_view_forum_as_messages', - 'noop' - ) - ); - -ALTER TABLE channel_unread_mentions - DROP COLUMN IF EXISTS media_unread; - -ALTER TABLE message_boxes - DROP COLUMN IF EXISTS reaction_unread, - DROP COLUMN IF EXISTS media_unread; diff --git a/deploy/migrations/0068_content_unread_and_blocks.up.sql b/deploy/migrations/0068_content_unread_and_blocks.up.sql deleted file mode 100644 index e0083861..00000000 --- a/deploy/migrations/0068_content_unread_and_blocks.up.sql +++ /dev/null @@ -1,58 +0,0 @@ -ALTER TABLE message_boxes - ADD COLUMN IF NOT EXISTS media_unread BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS reaction_unread BOOLEAN NOT NULL DEFAULT false; - -ALTER TABLE channel_unread_mentions - ADD COLUMN IF NOT EXISTS media_unread BOOLEAN NOT NULL DEFAULT false; - -ALTER TABLE user_update_events - DROP CONSTRAINT IF EXISTS user_update_events_type_check; - -ALTER TABLE user_update_events - ADD CONSTRAINT user_update_events_type_check CHECK ( - event_type IN ( - 'new_message', - 'read_history_inbox', - 'read_history_outbox', - 'read_message_contents', - 'edit_message', - 'message_reactions', - 'contacts_reset', - 'dialog_pinned', - 'pinned_dialogs', - 'dialog_unread_mark', - 'peer_settings', - 'delete_messages', - 'dialog_filter', - 'dialog_filter_order', - 'dialog_filters', - 'folder_peers', - 'channel_available_messages', - 'channel_view_forum_as_messages', - 'noop' - ) - ); - -CREATE TABLE IF NOT EXISTS contact_blocks ( - owner_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - blocked_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - date INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (owner_user_id, blocked_user_id) -) PARTITION BY HASH (owner_user_id); - -CREATE INDEX IF NOT EXISTS contact_blocks_owner_date_idx - ON contact_blocks (owner_user_id, date DESC, blocked_user_id DESC); - -DO $$ -DECLARE - i int; -BEGIN - FOR i IN 0..63 LOOP - EXECUTE format( - 'CREATE TABLE IF NOT EXISTS contact_blocks_p%s PARTITION OF contact_blocks FOR VALUES WITH (MODULUS 64, REMAINDER %s)', - lpad(i::text, 2, '0'), - i - ); - END LOOP; -END $$; diff --git a/deploy/migrations/0069_privacy_and_profile_photo_kinds.down.sql b/deploy/migrations/0069_privacy_and_profile_photo_kinds.down.sql deleted file mode 100644 index 60c83739..00000000 --- a/deploy/migrations/0069_privacy_and_profile_photo_kinds.down.sql +++ /dev/null @@ -1,26 +0,0 @@ -DROP INDEX IF EXISTS contacts_personal_photo_idx; - -ALTER TABLE contacts - DROP COLUMN IF EXISTS personal_photo_date, - DROP COLUMN IF EXISTS personal_photo_id; - -DROP INDEX IF EXISTS profile_photos_current_idx; - -ALTER TABLE profile_photos - DROP CONSTRAINT IF EXISTS profile_photos_kind_check; - -ALTER TABLE profile_photos - DROP CONSTRAINT IF EXISTS profile_photos_pkey; - -ALTER TABLE profile_photos - ADD CONSTRAINT profile_photos_pkey PRIMARY KEY (owner_peer_type, owner_peer_id, photo_id); - -ALTER TABLE profile_photos - DROP COLUMN IF EXISTS kind; - -CREATE INDEX IF NOT EXISTS profile_photos_current_idx - ON profile_photos (owner_peer_type, owner_peer_id, sort_order DESC) - WHERE active; - -DROP TABLE IF EXISTS account_privacy_rules; - diff --git a/deploy/migrations/0069_privacy_and_profile_photo_kinds.up.sql b/deploy/migrations/0069_privacy_and_profile_photo_kinds.up.sql deleted file mode 100644 index 0f9f9b4f..00000000 --- a/deploy/migrations/0069_privacy_and_profile_photo_kinds.up.sql +++ /dev/null @@ -1,39 +0,0 @@ --- 0069_privacy_and_profile_photo_kinds: account privacy rules, fallback photos, --- and viewer-private contact profile photos. - -CREATE TABLE IF NOT EXISTS account_privacy_rules ( - owner_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - privacy_key TEXT NOT NULL, - rules JSONB NOT NULL DEFAULT '[]'::jsonb, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (owner_user_id, privacy_key) -); - -ALTER TABLE profile_photos - ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'profile'; - -ALTER TABLE profile_photos - DROP CONSTRAINT IF EXISTS profile_photos_pkey; - -ALTER TABLE profile_photos - ADD CONSTRAINT profile_photos_pkey PRIMARY KEY (owner_peer_type, owner_peer_id, kind, photo_id); - -ALTER TABLE profile_photos - DROP CONSTRAINT IF EXISTS profile_photos_kind_check; - -ALTER TABLE profile_photos - ADD CONSTRAINT profile_photos_kind_check CHECK (kind IN ('profile', 'fallback')); - -DROP INDEX IF EXISTS profile_photos_current_idx; -CREATE INDEX IF NOT EXISTS profile_photos_current_idx - ON profile_photos (owner_peer_type, owner_peer_id, kind, sort_order DESC) - WHERE active; - -ALTER TABLE contacts - ADD COLUMN IF NOT EXISTS personal_photo_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS personal_photo_date INT NOT NULL DEFAULT 0; - -CREATE INDEX IF NOT EXISTS contacts_personal_photo_idx - ON contacts (user_id, contact_user_id) - WHERE personal_photo_id <> 0; - diff --git a/deploy/migrations/0070_account_auth_srp.down.sql b/deploy/migrations/0070_account_auth_srp.down.sql deleted file mode 100644 index 2ec5f984..00000000 --- a/deploy/migrations/0070_account_auth_srp.down.sql +++ /dev/null @@ -1,14 +0,0 @@ -DROP INDEX IF EXISTS authorizations_user_hash_idx; - -ALTER TABLE account_passwords - DROP COLUMN IF EXISTS recovery_code_expires_at, - DROP COLUMN IF EXISTS recovery_code, - DROP COLUMN IF EXISTS recovery_email, - DROP COLUMN IF EXISTS srp_b, - DROP COLUMN IF EXISTS srp_b_secret, - DROP COLUMN IF EXISTS srp_verifier, - DROP COLUMN IF EXISTS srp_id, - DROP COLUMN IF EXISTS current_algo_p, - DROP COLUMN IF EXISTS current_algo_g, - DROP COLUMN IF EXISTS current_algo_salt2, - DROP COLUMN IF EXISTS current_algo_salt1; diff --git a/deploy/migrations/0070_account_auth_srp.up.sql b/deploy/migrations/0070_account_auth_srp.up.sql deleted file mode 100644 index 25bc7aac..00000000 --- a/deploy/migrations/0070_account_auth_srp.up.sql +++ /dev/null @@ -1,21 +0,0 @@ --- 0070_account_auth_srp: durable SRP 2FA and authorization management fields. - -ALTER TABLE account_passwords - ADD COLUMN IF NOT EXISTS current_algo_salt1 BYTEA NOT NULL DEFAULT ''::bytea, - ADD COLUMN IF NOT EXISTS current_algo_salt2 BYTEA NOT NULL DEFAULT ''::bytea, - ADD COLUMN IF NOT EXISTS current_algo_g INT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS current_algo_p BYTEA NOT NULL DEFAULT ''::bytea, - ADD COLUMN IF NOT EXISTS srp_id BIGINT NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS srp_verifier BYTEA NOT NULL DEFAULT ''::bytea, - ADD COLUMN IF NOT EXISTS srp_b_secret BYTEA NOT NULL DEFAULT ''::bytea, - ADD COLUMN IF NOT EXISTS srp_b BYTEA NOT NULL DEFAULT ''::bytea, - ADD COLUMN IF NOT EXISTS recovery_email VARCHAR(256) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS recovery_code VARCHAR(32) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS recovery_code_expires_at TIMESTAMPTZ; - -UPDATE authorizations -SET hash = auth_key_id -WHERE hash = 0; - -CREATE UNIQUE INDEX IF NOT EXISTS authorizations_user_hash_idx - ON authorizations (user_id, hash); diff --git a/docs/channel-module.md b/docs/channel-module.md deleted file mode 100644 index 2f6ce2ea..00000000 --- a/docs/channel-module.md +++ /dev/null @@ -1,481 +0,0 @@ -# Supergroup / Channel Module Design - -Date: 2026-06-01 - -## Scope - -本模块实现 Telegram Desktop 第一兼容目标下的超级群与频道闭环。项目不保留 legacy 普通群长期形态:`messages.createChat` 在服务端直接创建 `megagroup`,TDesktop 同步创建响应仅暴露带 `migrated_to` 的 legacy `chat` 外观,后续主路径回到 `channel` / `channelFull` 语义,避免服务端维护真正的 `migrateToMegagroup` 升级链路。 - -首批真实实现: - -- `messages.createChat` -> create megagroup -- `channels.createChannel` -- `channels.getFullChannel` -- `channels.getParticipants` -- `channels.getParticipant` -- `channels.checkUsername/updateUsername/getAdminedPublicChannels` -- `channels.toggleSignatures` -- `channels.inviteToChannel` -- `channels.joinChannel` -- `channels.leaveChannel` -- `channels.editAdmin/editBanned/editTitle/deleteChannel` -- `channels.getAdminLog` -- `messages.updatePinnedMessage` -- `messages.editChatDefaultBannedRights` -- `messages.exportChatInvite/checkChatInvite/importChatInvite` -- `messages.getMessageReadParticipants` -- `messages.getMessageEditData` -- `updates.getChannelDifference` -- `messages.sendMessage/getHistory/readHistory/editMessage/deleteMessages/deleteHistory/forwardMessages/setTyping/getReplies/getDiscussionMessage/readDiscussion` 的 channel peer 分支 -- `messages.getDialogs/getPeerDialogs/getPinnedDialogs` 展示 channel / megagroup dialog - -首批兼容 stub: - -- `channels.convertToGigagroup`:当前 megagroup 已是目标形态,返回 ok 或 `CHAT_NOT_MODIFIED` 语义。 -- `messages.migrateChat`:不创建 legacy chat,不执行双写迁移;校验 change_info/creator 权限后把 `chat_id` 映射到既有 megagroup `channel_id`,返回 `updateChannel + tg.Channel`,满足 TDesktop `applyUpdates -> migrateTo()` 路径。 -- `channels.editPhoto`:files/media 头像存储未接入前只允许 `inputChatPhotoEmpty` 删除头像 no-op 兼容;真实上传/已有照片返回 `PHOTO_INVALID`,避免 TDesktop 误以为头像已成功更新。 -- `channels.togglePreHistoryHidden/toggleSlowMode/toggleForum/toggleAntiSpam/updateColor/updateEmojiStatus`:持久化 channel 设置并返回 `updateChannel`;TDesktop 通过 `ChannelFull.hidden_prehistory/slowmode_seconds/antispam`、`Channel.slowmode_enabled`、`Channel.forum/forum_tabs`、`Channel.color/profile_color/emoji_status` 恢复 UI。emoji status 当前支持 empty 与普通 document id,collectible gift 状态等 gift/read model 后续补;forum 当前保存开关/layout 与 admin log,`messages.getForumTopics*` 会在 forum 开启后返回虚拟 `General` topic(id=1) 加持久化 topic store;`messages.create/edit/pin/reorder/deleteTopicHistory` 已接入最小真实 topic mutation;anti-spam 当前只保存开关与 admin log,不接入真实垃圾消息删除管线。 -- `channels.setStickers`:files/sticker store 未接入前只允许 megagroup `inputStickerSetEmpty` 清空 no-op 兼容;非空 sticker set 返回 `STICKERSET_INVALID`,避免 TDesktop 误以为群贴纸集已持久化。`channels.reorderUsernames/toggleUsername/deactivateAllUsernames` 仍先做权限校验型兼容响应;Fragment/多 username 入口不改主 username,主 username 只允许 `channels.updateUsername` 设置或清除。 -- TDesktop 已有调用证据但非首批真实业务的入口统一注册为显式 stub:`channels.reportSpam/editLocation/convertToGigagroup/reportAntiSpamFalsePositive/setBoostsToUnblockRestrictions/setEmojiStickers/checkSearchPostsFlood/setMainProfileTab`。`channels.searchPosts` 已提升为公开频道/超级群帖子真实搜索,`checkSearchPostsFlood` 仍保持免费额度兼容 stub;二者共享 query 长度边界。`setBoostsToUnblockRestrictions` 按 Layer225 限制 0..8。`channels.restrictSponsoredMessages/updatePaidMessagesPrice/toggleAutotranslation` 已提升为最小真实设置持久化:分别回填 `ChannelFull.restricted_sponsored`、`Channel/ChannelFull.send_paid_messages_stars` + broadcast `Channel.broadcast_messages_allowed`、`Channel.autotranslation`,其中 `updatePaidMessagesPrice` 按 TDesktop 默认 app config 限制 stars<=10000,并允许 broadcast direct messages 用 `-1` 表示关闭;真实广告投放、boost/premium 校验、paid messages/monoforum/结算后续补。`channels.exportMessageLink`、`channels.readMessageContents`、`channels.getMessageAuthor`、`channels.deleteParticipantHistory`、`getInactiveChannels`、`getChannelRecommendations`、`toggleJoinToSend`、`toggleJoinRequest`、`toggleParticipantsHidden`、`toggleForum`、`toggleViewForumAsMessages`、`toggleAntiSpam`、`getLeftChannels`、`getGroupsForDiscussion`、`setDiscussionGroup` 已从该列表提升为真实实现。 -- `channels.getGroupsForDiscussion/setDiscussionGroup` 真实维护 broadcast channel 与 megagroup 的双向 `linked_chat_id`。候选列表只返回当前用户可管理的 supergroup,不返回 legacy basic group;设置时校验 access_hash、broadcast/group 类型、管理权限与 hidden prehistory,替换链接会同步清理旧 group/old broadcast,TDesktop 通过 `Channel.has_link` 和 `ChannelFull.linked_chat_id` 刷新讨论组入口。linked broadcast 发新 post 时会在 discussion megagroup 创建一条 forwarded root message,source post 保存 `discussion_channel_id/message_id`,`messages.getDiscussionMessage/getReplies/readDiscussion` 都映射到该 root 和 target channel read state。 -- TDesktop 已有源码调用但当前业务模型尚未维护独立 media/sticker/custom-emoji/saved-message tag assignment/poll/todo/scheduled 的 `messages.reportSpam/report/reportReaction/reportMessagesDelivery/reportReadMetrics/reportMusicListen/reportSponsoredMessage/getSavedReactionTags/updateSavedReactionTag/getDefaultTagReactions/getExtendedMedia/getAttachedStickers/getCustomEmojiDocuments/searchStickerSets/searchStickers/getEmojiKeywords/getEmojiKeywordsDifference/sendVote/getPollResults/getPollVotes/addPollAnswer/deletePollAnswer/getUnreadPollVotes/readPollVotes/appendTodoList/toggleTodoCompleted/getSearchCounters/getSearchResultsCalendar/getSearchResultsPositions/getOnlines/getWebPagePreview/uploadMedia/sendMedia/sendMultiMedia/getScheduledHistory/getScheduledMessages/sendScheduledMessages/deleteScheduledMessages` 均显式注册;统一做参数上限、peer 校验和空/零兼容响应,禁止落到未知 RPC。其中 `messages.readMessageContents` 已提升为 private real-content-read:清理 owner 侧 `message_boxes.media_unread/reaction_unread`,有变化才生成 durable `updateReadMessagesContents`;channel/supergroup 的 `channels.readMessageContents` 会按可见消息 ID 清理当前作者视角的 unread reaction、重算 `channel_dialogs.unread_reactions_count`,并向当前账号 session 推 `updateMessageReactions` 让 TDesktop 立即去掉 dialog reaction 角标;`messages.getMessagesViews` 已维护 channel-scoped views 去重计数并返回 replies/comment 统计;`messages.getUnreadMentions/readMentions` 已维护 channel-scoped unread mention index,history/getMessages/difference/online update 按 viewer 回填 `mentioned/media_unread`,readMentions 返回 channel pts;`messages.sendReaction/getMessagesReactions/getMessageReactionsList/getUnreadReactions/readReactions/getRecentReactions/clearRecentReactions/getTopReactions` 已提升为 private + channel/supergroup emoji reaction 最小真实实现:private 写 `private_message_reactions` 并按双端 owner-visible message box 返回/推送 `updateMessageReactions`,channel 写 `channel_message_reactions` 并按消息作者维护 unread reaction 状态,history/getMessages/search 均回填 `message.reactions`,`add_to_recent` 写账号级最近 channel reaction 列表且 get/clear 支持 hash/notModified,`getTopReactions` 按账号使用次数排序并优先用真实 `available_reactions` catalog 兜底;`messages.getSavedReactionTags/updateSavedReactionTag` 已持久化账号级 emoji saved reaction tag 标题并向其它 session 推 `updateSavedReactionTags`,但 peer 维度 count 与 saved-message tag assignment 仍返回空;`messages.getForumTopics/getForumTopicsByID/createForumTopic/editForumTopic/updatePinnedForumTopic/reorderPinnedForumTopics/deleteTopicHistory` 已从纯空响应提升为真实最小 topic store:虚拟 General + root service message topic + bounded delete;`messages.getCommonChats` 已提升为真实共同超级群查询并回填 `users.getFullUser.common_chats_count`;`messages.report` 保持 TDesktop 分步举报 UI 所需的 choose option/add comment/reported 形态但暂不落库;report/metrics/music/sponsored 这类 telemetry 入口只返回 BoolTrue/reported,不写业务状态;TDesktop 资料页 shared media count 会用 `messages.search(limit=0, filter=photo/video/document/url/gif/music/roundVoice/poll)` 取 `messages.channelMessages.count`,当前显式返回空页/count=0,避免纯文本 channel history 污染 photos/videos/files 等计数;search calendar 空结果会回填请求 offset date/id,避免空月份重复拉取;default tag、sticker/custom emoji 与 extended media 均只返回空/notModified 或明确错误,不伪造 paid media 或 tag 绑定状态;poll/todo mutating 入口在缺少媒体 store 时返回可解释错误,不伪造 `updateMessagePoll` 或 todo service message;web preview 返回 `messageMediaEmpty` 且不会抓外网,`sendMedia(inputMediaWebPage)` 降级为纯文本发送,真实 photo/document/poll/album、todo、scheduled store 留待后续模型。 -- legacy chat 管理入口 `messages.getChats/getFullChat/addChatUser/deleteChatUser/editChatTitle/editChatPhoto/editChatAdmin/editChatAbout/editChatDefaultBannedRights/editChatParticipantRank` 统一映射到 megagroup/channel 语义;其中 `about` 与 default banned rights 真实持久化,default banned rights 参与普通成员发送和邀请权限校验。`editChatCreator` 已显式注册并校验 peer/user,但账号 2FA/SRP 与所有权转移事务未接入前返回可解释的密码错误,不进入 fallback。 - -## Reference Audit - -### TDesktop - -TDesktop 创建群入口仍会调用 `MTPmessages_CreateChat`,创建频道/超级群调用 `MTPchannels_CreateChannel` 并根据 UI 类型设置 `f_megagroup` 或 `f_broadcast`。`messages.createChat` 的 `ChatCreateDone` 会从 `messages.InvitedUsers.updates.chats` 取第一条 `chat`,不接受 `channel`;因此 TDesktop ctx 下同步响应必须把 `legacy chat(migrated_to=inputChannel)` 放在第一项,同时继续附带真实 `channel.id/access_hash/title/broadcast/megagroup/participants_count/date` 供 `updateNewChannelMessage` 和迁移后的 supergroup 历史使用。服务端不持久化 legacy chat,后续 `InputPeerChat` 主路径统一映射回同 id channel。 - -频道更新走 `ChannelData` 的独立 `PtsWaiter`。`updateNewChannelMessage` 若找不到频道资料,会延迟触发 `getDifference`;找到频道且不在处理 channel difference 时,会调用 `channel->ptsUpdateAndApply(pts, pts_count, update)`。`PtsWaiter` 依赖 `pts_count` 累加判断连续性,缺口会缓存乱序 update,1 秒后拉 `updates.getChannelDifference`。因此服务端必须保证 channel pts 单调、`pts_count` 准确、每个已分配 pts 都能从 channel durable log 补到。 - -管理入口会直接触发 `MTPchannels_EditAdmin`、`MTPchannels_EditBanned`、`MTPchannels_EditTitle`、`MTPchannels_DeleteChannel`;pin 使用 `MTPmessages_UpdatePinnedMessage`,邀请链接使用 `MTPmessages_ExportChatInvite/CheckChatInvite/ImportChatInvite`。TDesktop 对这些 RPC 返回的 `Updates` 会立即 apply,因此响应必须至少带 `updateChannel`、成员变更时带 `updateChannelParticipant`,pin 时带 `updatePinnedChannelMessages`,并把相关 `chats/users` 填齐。 - -TDesktop 源码还会在频道/群 UI、导出和搜索路径触发 `MTPmessages_GetMessagesViews`、`ReadMessageContents`、`GetUnreadMentions/ReadMentions`、`GetSearchCounters`、`GetReplies`、`GetForumTopics/GetForumTopicsByID` 和 legacy basic group wrappers。参考实现 对 `messages.getOnlines` 返回固定 1,参考实现 handler 仍未实现;telesrv 比 参考实现 多走一步:使用在线 session 快照与 active channel member 有界交集返回实时在线数,缺少在线 provider 时才退回兼容 1。basic group 管理入口再下沉到 megagroup/channel 语义;参考实现 对 default banned rights 要求 `ban_users` 权限并发布频道设置命令。telesrv 借鉴这个边界:views/mentions 已有真实最小实现,forum topic create/read/edit/pin/delete/reorder 已有持久化最小实现,复杂媒体仍返回显式 stub,legacy basic group 管理统一落到 megagroup/channel 语义,default banned rights 真实落库并影响普通成员 send/invite。 - -管理员日志页调用 `MTPchannels_GetAdminLog(channel, q, events_filter, admins, max_id, min_id, limit)`,TDesktop 既会用 `max_id` 向旧事件翻页,也会用 `min_id`/polling 拉新事件;响应必须是 `channels.adminLogResults{events,chats,users}`,并让 action 里的 message/participant 能被客户端渲染。服务端必须 cap `limit/admins/query`,按 `(channel_id,id)` seek,不能按客户端传入的大 id 构造数组。 - -已读详情浮层在 outgoing 且本地已读、成员数小于 `chat_read_mark_size_threshold`、消息未超过 `chat_read_mark_expire_period` 时调用 `MTPmessages_GetMessageReadParticipants(peer,msg_id)`。TDesktop 只接收 `Vector`,不附带 users/chats;因此服务端必须只返回当前客户端已经能解析的 user id,并保证接口失败不会变成持续 `NOT_IMPLEMENTED` 噪声。 - -reply/forward 渲染依赖 `MessageReplyHeader` 与 `MessageFwdHeader` 内引用的 peer 已可解析。TDesktop 的 `ReplyFieldsFromMTP` 会优先用 `reply_to_top_id`,没有时退回 `reply_to_msg_id`;`api_updates.cpp` 的 `ForwardedInfoDataIsLoaded/ReplyDataIsLoaded` 会在 forward/reply header 引用的 peer 未加载时暂缓 apply update。因此服务端发送 channel reply 时必须用已存在、当前成员可见的 channel message 反算 `reply_to_top_id`,不能信任客户端传入;forward/reply header 如果引用 user/channel peer,当前 RPC 响应、账号级 `updates.getDifference`、在线 outbox 和 `updates.getChannelDifference` 都要补齐对应 users/chats 上下文,不能只修实时响应路径。 - -会话内搜索也走 channel peer:TDesktop 的 `api_messages_search.cpp` 与 `dialogs_widget.cpp` 在群/频道里直接发 `messages.search(peer=inputPeerChannel, q, offset_id, limit)`,并把 `offset_id` 当 seek cursor 继续翻页。参考实现 对 channel peer 把 `OwnerPeerId` 切到 channel id,从单份 message read model 查询,并补齐 sender/forward/reply 相关 users/channels。telesrv 因此不把 channel 搜索转成私聊 `message_boxes`,而是在 `channel_messages(channel_id,id)` 上做有界文本搜索;PG 使用 `channel_messages_body_trgm_idx` 辅助 `ILIKE` 命中,仍按 `id DESC LIMIT` 分页。 - -公开 username 管理入口会调用 `MTPchannels_CheckUsername`、`MTPchannels_UpdateUsername` 与 `MTPchannels_GetAdminedPublicChannels`;settings/profile 管理区还会触发 signatures、prehistory、slowmode、stickers、color、emoji status、Fragment usernames 等 RPC。主 username 是客户端可见资料,必须真实落库;Fragment/多 username、颜色、贴纸、emoji status 可先权限校验型 stub,但不能误修改主 username 或静默吞掉未知 RPC。 - -TDesktop 左侧搜索框通过 `MTPcontacts_Search` 查 peer,返回的 channel/supergroup 命中必须同时出现在 `results/my_results` 的 `peerChannel` 和 `chats` 向量中,否则 UI 无法 materialize peer;用户名跳转通过 `MTPcontacts_ResolveUsername`,公开 channel/supergroup 同样返回 `peerChannel + chats`。telesrv 只暴露带主 username 且未删除的公开频道/超级群,PG 使用 `channels_public_username_trgm_idx` / `channels_public_title_trgm_idx` 避免无索引模糊扫。非成员从公开搜索结果打开频道时,`channels.getFullChannel`、`messages.getPeerDialogs(InputPeerChannel)` 与 `messages.getHistory(InputPeerChannel)` 使用只读公开预览视图返回 full/dialog/history;私有频道、ban/kick/view_messages 禁止仍拒绝,发送、差分、管理等写路径仍要求 active member。 - -源码检索还显示导出/管理/搜索路径会触发 `MTPchannels_ExportMessageLink`、`ReadMessageContents`、`DeleteParticipantHistory`、`GetGroupsForDiscussion`、`SetDiscussionGroup`、`ToggleForum`、`ToggleJoin*`、`SearchPosts`、`CheckSearchPostsFlood`、`GetChannelRecommendations`、`GetMessageAuthor` 等 Layer 225 方法。`ToggleForum` 保存 `enabled/tabs`,仅 creator 可改,返回 `updateChannel`;`GetChannelRecommendations` 先按公开 username broadcast channel 做最小真实推荐,真实相似度/订阅画像/Premium 扩容后续补;其它未接入完整业务的入口必须显式注册、加参数上限、返回可解释 stub,并写入 compatibility matrix。 - -讨论组设置入口在 `edit_peer_info_box.cpp` 中:broadcast 侧无当前链接时会调用 `channels.getGroupsForDiscussion` 拉候选;保存时若选中的 megagroup 开启了 hidden prehistory,TDesktop 会先调用 `channels.togglePreHistoryHidden(group,false)` 再重试 `channels.setDiscussionGroup`。保存成功后客户端本地调用 `ChannelData::setDiscussionLink`,同时 `ChannelFull.linked_chat_id` 会在后续 full channel 刷新时恢复状态。因此 server 必须返回候选 chats,设置/解绑成功后更新双方 `linked_chat_id` 并推 `updateChannel`,不能只返回 BoolTrue。 - -TDesktop 导出路径的 `channels.getLeftChannels` 使用 count-offset 分页:客户端每次把 offset 增加已返回 chats 数量,`messages.chats` 立即结束,`messages.chatsSlice` 只有空 chats 才结束。telesrv 因此按 `channel_members(user_id,status='left')` 查询用户已离开的频道/超级群,pageSize 固定 100,offset 上限 10000;最终非空页返回 `messages.chats`,offset 已越过总数时返回空 `messages.chatsSlice{count}`,避免导出流程多拉或循环。 - -输入框与高级会话路径还会触发 `MTPmessages_GetWebPagePreview`、`UploadMedia`、`SendMedia`、`SendMultiMedia`,定时消息入口会触发 `GetScheduledMessages/SendScheduledMessages/DeleteScheduledMessages`,forum UI 会触发 topic create/edit/pin/reorder/delete。参考实现 对 web preview 空文本报错、无预览返回 `messageMediaEmpty`,media 和 scheduled 都有独立持久化;参考实现 的 web preview 是空响应,media 走 helper,scheduled/forum 多数仍未实现。telesrv 当前不伪造这些缺失模型:web preview 返回空,webpage media 可降级纯文本;真实 media/scheduled/forum 只做有界校验与可解释错误或 cleanup update。 - -投票和 todo 是 channel/supergroup 消息媒体的后续模型,不应在缺少 store 时假成功。TDesktop 的 `api_polls.cpp` 会在 `sendVote/addPollAnswer/deletePollAnswer/getPollResults` 成功后 apply updates,投票人列表通过 `messages.getPollVotes` 的 `next_offset` 翻页;`api_unread_things.cpp` 和 `menu_send.cpp` 还会拉取/清除未读投票,todo 则在 `api_todo_lists.cpp` 中 append/toggle 后 apply updates。参考实现 只实现旧层 poll 三件套,先按 peer+msg_id 取消息并提取 poll_id,`getPollVotes` 把 limit 压到 50;参考实现 对 poll 已有 domain event 与 sender/self update 分离,todo append 依赖 `MessageMediaToDo`,toggle/read-unread/add/delete 多数仍未实现。telesrv 当前做显式有界兼容:read-only poll 入口返回空,投票/新增/删除答案和 todo 变更返回 `MESSAGE_ID_INVALID`,并保留 `OPTIONS_TOO_MUCH/OPTION_INVALID/TODO_NOT_MODIFIED/BROADCAST_FORBIDDEN` 等客户端可理解错误,等 media store 接入后再生成真实 `updateMessagePoll`、todo service message 与 channel pts。 - -资料页、贴纸、custom emoji 与 reaction tag 也会在群/频道页面后台触发,不应落到 fallback。TDesktop `getCommonChats` 用 `max_id/limit` 分页共同群,且资料页按钮依赖 `UserFull.common_chats_count`;参考实现 都只把共同 channel 中的 megagroup 作为共同群。telesrv 只查双方 active membership 的 megagroup/supergroup,排除 broadcast/left/kicked/deleted,PG 走 `user_channel_member_index(user_id, channel_id) WHERE active megagroup` 交集和 channel id seek 分页,避免 `users.getFullUser` 私聊打开路径反向规划 `channel_members` 的 64 个 channel 分区。`getExtendedMedia` 成功后只 apply updates,paid media 未接入时空 updates 是安全结果;attached stickers、custom emoji documents、sticker search、emoji keyword difference 都接受空 vector/空 found results;reaction top/recent/saved tag titles 用 hash 缓存,rename tag 会本地先改名再调用 `updateSavedReactionTag`。sticker/custom emoji 不查外部索引,saved-message tag assignment/count/per-peer 暂不持久化,避免为了兼容 UI 预取而引入无界查询或半截状态。 - -`updates.getChannelDifference` 有三种客户端路径: - -- 无新增:`updates.channelDifferenceEmpty{final=true, pts, timeout}`。 -- 正常补差:`updates.channelDifference{final, pts, new_messages, other_updates, chats, users}`。 -- 差量过长:`updates.channelDifferenceTooLong{dialog, messages, chats, users}`,客户端会用返回 dialog 的 `pts` 重置 channel 本地状态,并拉历史范围校验。 - -### gotd / TL Layer 225 - -gotd 已提供所有需要的 Layer 225 类型和 dispatcher: - -- `MessagesCreateChatRequest` 返回 `messages.InvitedUsers`。 -- `ChannelsCreateChannelRequest` 返回 `UpdatesClass`,包含 `Broadcast/Megagroup/Forum/TTLPeriod`。 -- `UpdatesGetChannelDifferenceRequest` 返回 `UpdatesChannelDifferenceClass`,请求 limit 对普通用户建议 10-100,服务端必须 cap。 -- `UpdateNewChannelMessage` 携带 `message/pts/pts_count`。 -- `UpdateEditChannelMessage`、`UpdateDeleteChannelMessages`、`UpdatePinnedChannelMessages` 都走 channel pts;delete 的 `pts_count` 必须等于本次删除 id 数,pin/edit 为 1。 -- `UpdateChannelParticipant` 携带 actor、prev/new participant,适合 editAdmin/editBanned 的在线瞬时更新;Layer 225 该 update 不带 `pts/pts_count`,不能作为 channel pts durable event。 -- `ChatInviteExported`、`ChatInvite`、`ChatInviteAlready` 覆盖邀请链接导出、预览和已加入状态。 -- `Channel` 和 `ChannelFull` 有 TDesktop 最小必需字段:`AccessHash`、`Broadcast`、`Megagroup`、`HasLink`、`ParticipantsCount`、`AdminRights`、`BannedRights`、`DefaultBannedRights`、`LinkedChatID`、`ReadInboxMaxID`、`ReadOutboxMaxID`、`UnreadCount`、`NotifySettings`、`ExportedInvite`、`Pts`。 -- `channels.getGroupsForDiscussion#f5dad378` 无入参,返回 `messages.Chats`;`channels.setDiscussionGroup#40582bb2` 接 `broadcast:InputChannel group:InputChannel`,返回 Bool,并显式定义 `LINK_NOT_MODIFIED/BROADCAST_ID_INVALID/MEGAGROUP_ID_INVALID/MEGAGROUP_PREHISTORY_HIDDEN` 等错误。`channelAdminLogEventActionChangeLinkedChat` 可记录管理日志。 -- `messages.getReplies#22ddd30c` / `messages.getDiscussionMessage#446972fd` / `messages.readDiscussion#f731a9f4` 使用 channel peer + message id 定位 thread;`messageReplies` 的 `comments/channel_id/replies_pts/max_id/read_max_id` 是 TDesktop 展示 comment button、recent replies 与 unread state 的直接输入。 - -实现时 `tg.*` 仍只能出现在 `internal/rpc`,domain/app/store 使用自有模型。 - -### 参考实现 A - -参考实现 有一个关键语义可借鉴:`MessageSubType.AutoCreateChannelFromChat`。普通群创建最终进入 channel 创建 saga,先创建 channel creator/member/invite,再发送 `messageActionChannelCreate` 服务消息。它在 `SendMessageSaga` 中按 owner peer 分配 message id 与 pts;当 `ToPeer == Channel` 时只创建 channel owner 的 outbox message,不为每个成员创建 inbox message,随后 `SetChannelPts` 更新 channel read model 的 `Pts/TopMessageId/LastSenderPeerId/LastSendDate`。 - -成员加入/邀请/退出会创建或更新成员 dialog,并且仅 megagroup 生成 `ChatAddUser/ChatDeleteUser/Join` 等服务消息;broadcast channel 不向普通成员写这类群服务消息。它还把 channel update 保存和在线推送分开:channel durable update 供 `getChannelDifference`,在线推送只给活跃成员/管理员/被 mention 用户。 - -ack/globalSeqNo 的设计也值得保留为边界:`msgs_ack` 只确认某个 server msg_id/RPC response 已送达,ack cache 再把它映射回 pts/globalSeqNo 更新设备已确认水位。业务 pts 仍由 owner/channel 事件流决定,不能让 MTProto ack 直接产生业务事件。 - -管理语义方面,参考实现 的 editAdmin/editBanned 都先做权限校验,再构造 participant update;title 变更会产生可见服务消息;pin 走 `messages.updatePinnedMessage` 并只推进 channel pts;invite link 支持导出、检查和导入,join-approval/request_needed 是单独路径,不能误当作已经入群。TDesktop 对 `messages.importChatInvite` 明确分支处理 `INVITE_REQUEST_SENT` 与 `USERS_TOO_MUCH`,因此 request-needed 的首版兼容 stub 必须返回 `INVITE_REQUEST_SENT`,usage limit 满必须返回 `USERS_TOO_MUCH`,不能退化成坏链接/不可访问错误。 - -username 与管理项方面,参考实现 的 `channels.checkUsername` 只校验格式、access_hash 和全局 username 占用;`channels.updateUsername` 要求 channel owner,大小写不敏感相同值返回 `USERNAME_NOT_MODIFIED`;`getAdminedPublicChannels` 返回当前用户管理的公开 channel 列表。`toggleSignatures/togglePreHistoryHidden/toggleSlowMode/updateColor` 都先校验权限后发布命令;`updateEmojiStatus` handler 仍是空 updates,但 read model/converter 已有 `EmojiStatus` 字段。telesrv 采用这个边界:主 username/signatures/prehistory/slowmode/color/profile_color/普通 emoji status 真实持久化,Fragment usernames、贴纸、collectible emoji status 等依赖额外模型的入口继续显式 stub 或错误。 - -参考实现 的 `messages.toggleNoForwards` 已实现 channel 路径:先校验 access hash,再发布 `ToggleChannelNoForwardsCommand`,通过 channel 聚合返回 updates;`messages.setChatAvailableReactions` 和 `messages.setChatTheme` handler 仍未实现,但 `ChannelFullReadModel/ChannelFullMapper` 已有 `ReactionType/AvailableReactions/ReactionsLimit` 到 `chatReactions*` 的映射。telesrv 因此不把 reactions 当纯兼容噪声,而是持久化到 channel setting,并在 full channel 中恢复给 TDesktop。 - -参考实现 对 TDesktop 长尾入口多采用空/BoolTrue 响应:`readMessageContents/reportSpam/setEmojiStickers/getInactiveChannels/getChannelRecommendations/checkSearchPostsFlood/setMainProfileTab` 等直接返回兼容值;`channels.getMessageAuthor` handler 存在但未实现。参考实现 的 `messages.readMessageContents` / `channels.readMessageContents` 会先查当前用户可见消息、过滤 mention/media_unread/reaction 内容状态,再向 not-me 推 `updateReadMessagesContents` 或 `updateChannelReadMessagesContents`;未找到 `channels.getMessageAuthor` 对应实现。telesrv 已为 private 接入 owner 侧 `media_unread/reaction_unread` 持久状态与 durable `read_message_contents`,channel mention/media unread 则来自 `channel_unread_mentions` viewer-state;channel unread reaction 继续由 `channels.readMessageContents/readReactions` 清理。`getMessageAuthor` 则按 Layer225/TDesktop monoforum 右键菜单的最小可用语义,只查可见 channel message 的 `SenderUserID` 并返回 user,完整 monoforum 管理员权限留后续模型。`deleteParticipantHistory` 做管理员权限后按固定 page size 分批删除;`searchPosts` 按 参考实现 的 PublicPosts 语义落到本项目公开 username channel_messages 查询,只返回公开频道/超级群文本消息,满页用 `next_rate + offset_peer + offset_id` seek 翻页,付费 flood/限额继续由 `checkSearchPostsFlood` 免费 stub 承接;`getInactiveChannels` 按 TDesktop Premium 限额弹窗消费方式返回当前用户 active 频道/超级群,`dates[i]` 与 `chats[i]` 对齐并按最久未活跃排序;`getChannelRecommendations` 则从空响应提升为公开 broadcast channel 推荐,指定来源时排除 source,无来源时排除当前账号已加入频道;`getGroupsForDiscussion/setDiscussionGroup` 是讨论组链路。telesrv 借鉴其删除边界:按 sender 分页取一批 message id,生成一条 channel delete update,通过 `offset` 提示客户端续删,禁止一次性展开超大历史。 - -参考实现 的 `channels.getAdminLog` 当前返回空结果,但保留了 read model/query 模型:按 `channel_id`、action types、skip/limit 拉取 admin log event。telesrv 采用“有真实 event store、但只实现 TDesktop 首批可见 action”的路线,避免管理入口只能打开空页。 - -参考实现 的 `messages.getMessageReadParticipants` 先按 owner peer 校验 message 存在和 7 天过期窗口,再查询 `ReadingHistoryReadModel(TargetPeerId, MessageId >= msg_id, ReaderPeerId != self)` 并返回 `TReadParticipantDate{user_id,date}`。telesrv 采用同一语义边界,但落到 channel 单份消息模型:成员 `read_inbox_max_id >= msg_id`、`available_min_id < msg_id` 且有显式 `read_inbox_date` 才算已读者。 - -参考实现 的 `messages.getMessageEditData` 先校验 access_hash/message,再按编辑时间窗口返回 caption 可编辑标记;参考实现 对私聊/旧群先校验作者或管理员编辑权限,再返回 `messages.messageEditData{caption=false}`,channel 分支在旧项目被商业版挡住。telesrv 当前没有媒体 caption 编辑,因此采用更窄且可验证的语义:对私聊和 channel peer 都先校验消息存在、可见和作者/`edit_messages` 权限,再返回 `caption=false`,避免 TDesktop 编辑入口触发未知 RPC 或绕过权限。 - -参考实现 的 `channels.readHistory` 会读取 `max_id` 对应消息的 sender,触发 `UpdateReadChannelOutbox` saga,并向 sender 推送 `TUpdateReadChannelOutbox{channel_id,max_id}`。`channels.getFullChannel` 还会把 dialog read model 的 `ReadInboxMaxId/ReadOutboxMaxId` 写回 `ChannelFull`,dialog mapper 也持久返回 `ReadOutboxMaxId`。TDesktop 收到 `updateReadChannelOutbox` 后只调用 `History::outboxRead(max_id)`;如果在线 update 丢失或设备离线,TDesktop 会在 `messages.getDialogs/messages.getPeerDialogs` 的 dialog 字段、`channels.getFullChannel` 的 channel full 字段中重新应用 `read_outbox_max_id`。telesrv 采用同一客户端语义,但在 store 内 bounded 扫描最近 read delta,可一次推进多个相关发送者,同时保留 fanout/scan 硬上限。 - -参考实现 的 `EditPeerFoldersSaga` 明确把 `TInputPeerChannel` 转成 `PeerType.Channel` 并发布 `UpdateDialogFolderCommand`,最终回 `updateFolderPeers`;dialog unread/pinned/read-outbox 都在同一个 dialog read model 上表达。TDesktop 侧 `updateDialogPinned/updatePinnedDialogs/updateDialogUnreadMark/updateFolderPeers` 也按 `DialogPeer/Peer` 泛化消费,因此 telesrv 不能只更新私聊 `dialogs` 表,channel peer 必须更新 `channel_dialogs` 并仍走账号级 user pts/update。 - -参考实现 的 send/forward 请求转换器保留 Layer225 `InputReplyTo`,message mapper 再把业务 `ReplyTo/InputReplyTo` 转回 `MessageReplyHeader`;它还用 `MessageForwardedEvent/MessageReplyUpdatedEvent` 维护原消息回复统计。参考实现 的 message/dialog app service 会把 `FwdHeader.FromId/SavedFromPeer/SavedFromId` 放进额外 peer 集合,确保客户端 apply update 前能解析 forward 来源。telesrv 保留 `reply_to_msg_id/top_id/quote` 与 forward header 的客户端可见语义,在响应、durable difference、outbox 投递里补齐可解析的 user/channel peer 上下文,并用 `reply_to_top_id` + linked discussion root 维护首版 channel replies 统计/已读。 - -参考实现 的 admin/ban 流程先校验 `add_admins/ban_users`,再更新成员 read model,并通过 `updateChannel`/`updateChannelParticipant` 让在线客户端刷新状态;TDesktop 对 `updateChannelParticipant` 本身不走 channel pts 检查,且基线 `applyUpdateNoPtsCheck()` 不处理它。telesrv 因此不把 editAdmin/editBanned 写入 `channel_update_events`,也不为纯权限/封禁状态变化分配 channel pts;在线响应/推送只携带 `updateChannelParticipant + updateChannel`,离线客户端通过 full channel、participants 列表或后续可见消息的 channel pts 路径恢复状态。若操作产生可见 service message(例如加人/踢人消息),那条 service message 作为 `updateNewChannelMessage` 单独占 channel pts。 - -### 参考实现 B - -参考实现 的 channel 模型包含 `broadcast`、`megagroup`、`top_message`、`pts`、`participants_count`、`default_banned_rights`、`hidden_prehistory`、`slowmode`、`forum`、`noforwards` 等业务字段。它用 Redis key `channel_pts` 缓存 channel pts,并用 `channel_pts_updates` 持久化 `channel_id/pts/pts_count/update_type/update_data/date`,`updates.getChannelDifferenceV2` 按 `channel_id AND pts > ? ORDER BY pts ASC` 返回差量。 - -参考实现 对 `messages.toggleNoForwards` 先把 peer 限定为 chat/channel,再在 chat service 校验创建者权限,更新 `noforwards` 与版本后返回 chat update;`chat.setChatAvailableReactions` 会校验成员/管理员权限,把 reaction type 与 reaction list 持久化。telesrv 借鉴“设置是 channel state,不是消息事件”的边界,但权限放宽到 change_info 管理员,与当前 channel 管理设置统一。 - -参考实现 的不足需要避免:部分差量恢复时靠反解 message 的 `from_id` 临时判断 `out`,代码里有 sender TODO。telesrv 的 `channel_update_events` 必须显式保存 `sender_user_id`、`affected_user_ids`、`message_ids` 等负载,不能把客户端展示所需信息藏在 TL JSON 里。 - -参考实现 的 admin log 通过 `channel_admin_logs` 保存 actor、event、JSON action、query、date,并在 BFF 先校验 creator/admin 后调用 service。它的不足是只按最近 24h/channel 扫描且过滤 TODO;telesrv 改为 channel hash 分区、`id` 单调分页、actor/type 索引和 request cap,保留语义但不继承实现。 - -参考实现 的 `messages.getMessageReadParticipants` 在 channel 版本中通过 dialog service 拉取已读成员 id,旧 chat 版本则按参与者 dialog 的 `read_inbox_max_id` 判断是否覆盖目标消息;日期字段仍返回 0。telesrv 保留“按成员读水位判断”的核心语义,并新增 `read_inbox_date`,避免 UI 已读详情只能显示无时间。 - -参考实现 的 `messages.getMessageEditData` 私聊路径会按 owner message id 取 message box,并拒绝非作者;旧群允许具备编辑权限的管理员,最终仍返回 `caption=false`。telesrv 的 channel/supergroup 路径沿用这个权限边界,但查询单份 `channel_messages` 与当前 viewer 的 member 状态,不落 legacy chat。 - -参考实现 频道 participant/dialog 模型都保存 `read_outbox_max_id`,BFF `channels.getFullChannel` 会用当前 dialog 的 `ReadOutboxMaxId` 覆盖 `ChannelFull.ReadOutboxMaxId`,另有 `message_read_outbox` 记录读回执排查维度。telesrv 当前先把发送者 `channel_members/channel_dialogs.read_outbox_max_id` 与在线 `updateReadChannelOutbox`、离线 dialog/full channel 恢复路径打通;单条读者时间仍由 `read_inbox_date` 支撑。 - -参考实现 的 channel outbox 在写入 reply 消息前会按 `channel_id + reply_to_msg_id` 读取被回复消息:如果目标消息已有 `reply_to_top_id` 就继承,否则把当前 `reply_to_msg_id` 作为 top,最终把 `ReplyTo/ReplyToTopId` 一起落库;forum topic 发送后会更新 topic 的 top message。TDesktop 在 topic 输入框里会发送 `reply_to_msg_id=0 + top_msg_id=topicRootId`,参考实现 的 `InputReplyToMessage.TopMsgId` 也按 topic/thread 维度保存并在 header 上打 `forum_topic`。telesrv 借鉴该语义,但改成事务内校验目标或 topic root 未删除且对当前成员可见,非法目标返回 `REPLY_MESSAGE_ID_INVALID`,topic 内普通消息返回 `messageReplyHeader{forum_topic, reply_to_top_id}` 并更新 topic top message。 - -参考实现 的 `updates.getChannelDifference` 还会在当前 participant 的 `AvailableMinPts > req.pts` 时把请求 pts 抬到 `AvailableMinPts`,再去读 `channel_pts_updates`。这是避免新成员用 `pts=0` 拉到入群前消息类 durable 事件的关键边界;telesrv 因此在 `channel_members` 中同时保存 `available_min_pts`,加入/导入/受邀/重新加入时设为加入前 `channels.pts`,消息历史可见性仍由 `available_min_id` 独立控制。 - -参考实现 的 `channels.deleteHistory` 本地清空路径不写 channel pts,而是返回并同步 `updateChannelAvailableMessages{channel_id, available_min_id}`;TDesktop 在 `api_updates.cpp` 收到后设置 channel `available_min_id` 并对已加载 history 执行 `clearUpTill`,`ChannelData::setAvailableMinId` 本身不会做 max-clamp。telesrv 采用同一客户端语义:本地清空只更新当前账号成员/dialog 水位,同时写账号级 durable update 供其它设备在线推送或 `updates.getDifference` 离线恢复;返回和推送的 `available_min_id` 必须是实际应用后的单调水位 `max(old_available_min_id, requested_max_id)`,避免多设备乱序或 stale 请求把 TDesktop 本地可见下界回退。 - -## Domain Model - -新增 domain model: - -- `Channel`:`ID`、`AccessHash`、`Title`、`About`、`Username`、`CreatorUserID`、`Broadcast`、`Megagroup`、`Forum`、`ForumTabs`、`Date`、`ParticipantsCount`、`AdminsCount`、`KickedCount`、`BannedCount`、`DefaultBannedRights`、`TopMessageID`、`PinnedMessageID`、`Pts`、`Deleted`、`NoForwards`、`TTLPeriod`。 -- `Channel` 还保存设置字段:`PreHistoryHidden`、`ParticipantsHidden`、`AntiSpam`、`SlowmodeSeconds`、`Signatures`、`ReactionPolicy`、`Color`、`ProfileColor`、`EmojiStatus`。`ParticipantsHidden` 会让 `ChannelFull.participants_hidden` 可见,并让非管理员成员列表/已读详情按隐藏成员语义收敛;`AntiSpam` 会让 `ChannelFull.antispam` 可见,供 TDesktop 管理员页恢复开关;`SlowmodeSeconds>0` 会让 `tg.Channel.slowmode_enabled` 和 `tg.ChannelFull.slowmode_seconds` 可见;`Color/ProfileColor/EmojiStatus` 转成 `tg.Channel.color/profile_color/emoji_status`;`ReactionPolicy` 转成 `ChannelFull.available_reactions/reactions_limit/paid_reactions_available`。 -- `ChannelInvite`:`ChannelID`、`InviteID`、`Hash`、`AdminUserID`、`Title`、`Permanent`、`Revoked`、`RequestNeeded`、`ExpireDate`、`UsageLimit`、`UsageCount`、`Date`。 - - `importInvite` 必须在同一事务内锁定对应 invite row 后检查并递增 `usage_count`,避免多个客户端同时导入一次性链接时突破 `usage_limit`。 -- `ChannelMember`:`ChannelID`、`UserID`、`InviterUserID`、`Role`、`Status`、`JoinedAt`、`LeftAt`、`AdminRights`、`BannedRights`、`Rank`、`AvailableMinID`、`AvailableMinPts`、`ReadInboxMaxID`、`ReadInboxDate`、`ReadOutboxMaxID`、`UnreadMark`。 -- `ChannelMember.AvailableMinID`:当前成员可见历史下界;开启 prehistory hidden 后,新加入/导入/受邀成员初始化为加入前 `channel.top_message_id`,只看后续消息和自己的加入服务消息。 -- `ChannelMember.AvailableMinPts`:当前成员可恢复 channel difference 的 pts 下界;新加入/导入/受邀/重新加入成员初始化为加入前 `channels.pts`,`updates.getChannelDifference(pts=0)` 也会先抬到该值,避免入群前消息类 durable event 泄漏。 -- `ChannelMember.ReadInboxDate`:当前成员最后一次推进 `read_inbox_max_id` 的时间,用于 `messages.getMessageReadParticipants` 返回 `readParticipantDate.date`;不参与 channel pts。 -- `ChannelMember.SlowmodeLastSendDate`:普通成员最近一次成功发言时间,用于服务端按 channel 维度返回 `SLOWMODE_WAIT_X`;creator/admin 不受首批 slowmode 限制。 -- `ChannelMessage`:`ChannelID`、`ID`、`RandomID`、`SenderUserID`、`From`、`SendAs`、`Date`、`EditDate`、`Post`、`Silent`、`NoForwards`、`Body`、`Entities`、`ReplyTo`、`Forward`、`Action`、`Pts`、`Deleted`;`Mentioned` / `MediaUnread` 是按当前 viewer 回填的瞬时字段,不是全局消息真值。 -- `ChannelDialog`:当前 user 对 channel 的会话摘要,保存 `FolderID`、`Pinned`、`PinnedOrder`、`TopMessageID`、`ReadInboxMaxID`、`ReadOutboxMaxID`、`UnreadCount`、`UnreadMentions`、`UnreadMark`、`ViewForumAsMessages`、`NotifySettings`。channel message id 是 channel-scoped,跨频道 dialog 排序不能只看 `top_message_id`;统一按 `pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC` 排序,并用 `offset_date + offset_id + offset_peer(channel_id)` 做 seek cursor。folder include/exclude/read/archive/type 条件必须在 SQL `LIMIT` 前下推,避免 TDesktop dialogs 翻页重复顶部频道或自定义分组漏掉旧频道。 -- `ChannelUnreadMention`:按 `(user_id, channel_id, message_id)` 保存未读提及,`top_message_id` 支持 thread/topic 级清除,`media_unread` 表示该未读提及同时对应媒体内容未读;它是 owner 视角状态,不写入 `channel_update_events`,`messages.readMentions` 返回当前 channel pts 供客户端清本地 badge。 -- `ChannelUpdateEvent`:`ChannelID`、`Pts`、`PtsCount`、`Type`、`Date`、`MessageID`、`MessageIDs`、`SenderUserID`、`UserIDs`、`Payload`。 -- `ChannelAdminLogEvent`:`ChannelID`、`ID`、`UserID`、`Date`、`Type`、前后字符串/布尔/整数、前后 participant、相关 message、`Query`;用于 `channels.getAdminLog`,不参与 channel pts。 - -`domain.PeerType` 扩展为: - -- `user` -- `channel` - -首批不持久化 `chat` peer;RPC 层只在 `messages.createChat` 的 TDesktop 同步响应暴露 migrated legacy `chat` 外观,主消息/media/history/read 等入口遇到 `InputPeerChat/InputChat` 均映射到同 id megagroup/channel。 - -## Storage - -大表从第一版开始分区: - -| table | partition key | purpose | -|---|---|---| -| `channels` | hash(`id`) | channel/supergroup 主体 | -| `channel_members` | hash(`channel_id`) | 成员、权限、读水位、可见历史边界 | -| `user_channel_member_index` | primary key(`user_id`, `channel_id`) | user 维度成员索引;供共同群、`users.getFullUser.common_chats_count` 等 user→channel 热路径使用 | -| `channel_messages` | hash(`channel_id`) | 单份 channel message;TDesktop 看到的 message id 即 `(channel_id, id)` 中的 `id` | -| `channel_message_viewers` | hash(`channel_id`) | `(channel_id,message_id,viewer_user_id)` 去重视图,支持 `messages.getMessagesViews(increment=true)` 幂等递增 | -| `channel_message_reactions` | hash(`channel_id`) | `(channel_id,message_id,reacted_user_id,reaction)` 当前 reaction 状态,支持 `sendReaction/getMessagesReactions/getMessageReactionsList` | -| `private_message_reactions` | message id fk | `(private_message_id,user_id,reaction)` 当前私聊 reaction 状态,双端 message box 共享聚合,支持 private `sendReaction/getMessagesReactions/getMessageReactionsList` | -| `user_saved_reaction_tags` | user_id prefix | 账号级 saved-message reaction tag 标题;支持 `messages.getSavedReactionTags/updateSavedReactionTag`,不承载 message assignment/count | -| `channel_unread_mentions` | hash(`user_id`) | owner 视角未读提及/媒体未读索引,支持 `messages.getUnreadMentions/readMentions`,不复制 channel message body | -| `channel_update_events` | hash(`channel_id`) | channel pts durable log,供 `updates.getChannelDifference` | -| `channel_dialogs` | hash(`user_id`) | 当前账号对 channel 的 dialog/read/folder/pin/mute 摘要 | -| `dialog_drafts` | hash(`user_id`) | user/channel peer 云草稿;支持 forum `top_msg_id`,不复制 channel message | -| `channel_invites` | hash(`channel_id`) | 默认 invite link、管理员导出的邀请链接、usage/requested 计数 | -| `channel_invite_importers` | hash(`channel_id`) | invite importer/read model 与 pending join request;每个 `(channel_id,user_id)` 只保留当前状态 | -| `channel_admin_log_events` | hash(`channel_id`) | 管理日志;按 channel 内单调 `id` seek pagination,不复制 channel 消息正文 | - -关键索引: - -- `channel_usernames(username_lower)` 主键保存公开 channel username 占用;`channels.username` 保存展示值,避免 PG 分区表唯一索引必须包含分区键的问题。 -- `channel_members(channel_id, user_id)` 主键;父表上的 `(user_id, channel_id)` 索引不能裁剪 `channel_id` HASH 分区,只可作为单分区内辅助索引,禁止用它承载 user→channel 热路径。 -- `user_channel_member_index(user_id, channel_id) WHERE status='active' AND megagroup AND NOT broadcast AND NOT deleted` 支持共同群 count/list;由 channel member upsert、leave 和 delete channel 事务同步维护。后续凡是从 user 入口列 joined/admined/left channel,都应扩展这张 user 维度 read model 或两步取 bounded channel_id 后再按 `channel_id/id = ANY($1)` 访问分区表。 -- `channel_members(channel_id, read_inbox_max_id, user_id) WHERE status='active'` 支持小群已读详情按读水位有界查询;同时用 `available_min_id < msg_id` 排除加入前不可见历史。 -- `channel_messages(channel_id, id DESC) WHERE deleted=false` 历史 seek pagination。 -- `channel_messages(channel_id, sender_user_id, id DESC) WHERE deleted=false` 支持 `channels.deleteParticipantHistory` 按发送者有界取最近一页消息,避免成员历史删除全表扫。 -- `channel_messages(channel_id, sender_user_id, random_id)` 唯一,保障 channel send 幂等。 -- `channel_messages(channel_id, reply_to_top_id, id DESC) WHERE reply_to_top_id > 0 AND NOT deleted` 支持 `messages.getReplies` thread/comment seek pagination。 -- `channel_messages(discussion_channel_id, discussion_message_id) WHERE discussion_channel_id <> 0 AND discussion_message_id <> 0 AND NOT deleted` 支持 broadcast post 到 discussion root 的反查与排查。 -- `channel_message_viewers(channel_id, message_id, viewer_user_id)` 主键去重;`messages.getMessagesViews` 先按最多 100 个 id 过滤可见 `channel_messages`,只对首次插入的 viewer 更新 `channel_messages.views_count`,不按 viewer 做 count 聚合扫描。 -- `channel_message_reactions(channel_id, message_id, reaction_date DESC, reacted_user_id DESC, reaction_value ASC)` 支持最近 reaction 回填和列表 seek;`(channel_id,message_id,reaction_type,reaction_value,reaction_date DESC,reacted_user_id DESC)` 支持按单个 emoji 过滤,不使用 SQL OFFSET。 -- `channel_unread_mentions(user_id, channel_id, top_message_id, message_id DESC)` 支持当前账号 unread mentions seek;发送/编辑时只为解析出的 active/可见/未读成员插入,单条消息最多 100 个候选,清除时单批最多 1000 条;`media_unread` 随同一行保存,供 history/getMessages/getChannelDifference/online update 按 viewer 回填 `message.media_unread`。 -- `channel_update_events(channel_id, pts)` 主键;`(channel_id, pts)` 升序扫描差量。 -- `channel_dialogs(user_id, folder_id, pinned DESC, pinned_order DESC, top_message_date DESC, top_message_id DESC, channel_id DESC)` 支持 dialogs seek;`default_send_as_peer_type/default_send_as_peer_id` 只保存当前 owner 的默认发送身份,不参与列表排序。 -- `dialog_drafts(user_id, date DESC, peer_type, peer_id, top_message_id)` 支持 `messages.getAllDrafts/clearAllDrafts` 有界扫描;draft 内容用 domain JSON 保存,业务层不持有 `tg.*`。 -- `channel_invites(channel_id, admin_user_id, revoked, created_at DESC, hash DESC)` 支持 TDesktop invite links 管理页按 admin/revoked/offset seek 翻页;`channel_invite_hashes(hash)` 用于导入链接按 hash 快速定位,撤销 invite 不删除 hash 映射,避免管理页 detail 失效。 -- `channel_invite_importers(channel_id, invite_id, requested, date DESC, user_id DESC)` 与 `(channel_id, requested, date DESC, user_id DESC)` 支持 `messages.getChatInviteImporters` 的 link/requested/filter 查询;`q` 过滤走有界 candidate 后关联 user,不允许全表搜索。 -- `channel_admin_log_events(channel_id, id DESC)` 支持 admin log 翻页;`(channel_id, actor_user_id, id DESC)` 支持 admins 过滤;`(channel_id, event_type, id DESC)` 支持 events_filter。 - -Redis 可恢复计数: - -- `counter:channel_id`:channel id,值域应避开 user id。 -- `counter:channel_msg_id:{channel_id}`:channel message id。 -- `counter:channel_pts:{channel_id}`:channel pts。 - -管理日志 `id` 使用 `channels.admin_log_seq` 在同一事务内 `UPDATE ... RETURNING` 分配,原因是它只服务管理页 seek,不参与高频消息 pts;后续如果 admin log 写入成为瓶颈,再迁移到可恢复 Redis counter + noop 占位策略。 - -Redis miss 恢复来源: - -- channel id 从 `MAX(channels.id)` 恢复。 -- message id 从 `MAX(channel_messages.id WHERE channel_id=?)` 恢复。 -- channel pts 从 `MAX(channel_update_events.pts WHERE channel_id=?)` 恢复。 - -分配必须使用 Redis Lua 的初始化+递增原子脚本;批量 delete/clear 这类 `pts_count>1` 的操作必须一次性分配连续 range,PG fallback 也要实现 `NextChannelPtsN(current+count)`,不能用多次读取 `MAX(pts)+1` 模拟。事务失败但 pts 已分配时必须写 `noop` channel update 占位,避免 TDesktop channel PtsWaiter 永久 gap。 - -## Viewer State / Difference Nudge - -`tg.Message.mentioned` 与 `media_unread` 对 channel/supergroup 来说是 viewer-specific 状态,来源只允许是 `channel_unread_mentions` 等 owner 视角 unread 表,不写入 `channel_messages` 全局行: - -1. 发送或编辑 channel message 时,RPC 层解析 mention-name entity / `@username`,store 只为 active、可见、未读且不是 sender 自己的成员写 unread mention。带媒体的 mention 同行保存 `media_unread=true`。 -2. `messages.getHistory`、`messages.getMessages`、`updates.getChannelDifference` 与在线 `updateNewChannelMessage/updateEditChannelMessage` 都按当前 viewer 重取 unread mention 状态,再设置 TL `mentioned/media_unread`。未被 mention 的 viewer 看到同一条消息时两个 flag 必须为 false。 -3. `messages.readMentions` 清理当前 owner 的 unread mention 后,后续 history/difference 不再带 `mentioned/media_unread`。编辑移除 mention 会删除旧 unread mention;新增 mention 会补写新 owner 的 unread mention,供在线和离线差量恢复。 -4. 账号级 `updates.getDifference` 不承载 channel message 本体。若请求 date 之后当前用户 active joined channel 有 `channel_update_events`,RPC 层追加计算型 `updateChannelTooLong(channel_id, pts)` nudge,提示 TDesktop 随后调用 `updates.getChannelDifference`;该 nudge 不写 `user_update_events`,不推进账号 pts,也不影响 account difference 连续性。 -5. create/invite/join/leave/import/hide request 等 membership/state 操作即使没有 service message pts,也必须在 RPC response 和在线推送里带 `updateChannel`。megagroup 有可见 service message 时返回/推送 `updateNewChannelMessage` 占 channel pts,同时追加 `updateChannel` 刷新 participant count/self state;broadcast invite/join/leave 不占 channel pts,但仍带 viewer-specific chats 与 `updateChannel`。 - -## Create Flow - -`messages.createChat`: - -1. RPC 层解析 users/title/ttl,校验至少 1 个非自己用户,限制单次邀请数量。 -2. 调用 `ChannelService.CreateMegagroupFromCreateChat`,内部强制 `Broadcast=false, Megagroup=true`。 -3. 创建 channel、creator member、受邀成员、默认 invite link。 -4. 分配 channel message id + channel pts,写 `messageActionChannelCreate` 或 `messageActionChatCreate` 兼容服务消息。 -5. 为 creator 与受邀成员创建 `channel_dialogs`,但不复制 message body。 -6. 写 `channel_update_events(updateNewChannelMessage)`。 -7. 给 creator 当前 RPC 返回 `messages.InvitedUsers{updates}`;TDesktop ctx 下该同步响应的 `chats` 第一项是带 `migrated_to` 的 legacy `chat` 外观,第二项保留真实 `channel`。其它在线成员通过 outbox/active channel push 收到真实 channel updates,不推 legacy 外观。 - -`channels.createChannel`: - -- `broadcast=true` 创建频道,`megagroup=true` 创建超级群。 -- 如果两个 flag 都没传,TDesktop 路径按 broadcast 处理;服务端可返回 `CHANNEL_INVALID` 防错误客户端。 -- `for_import` 依赖 `messages.initHistoryImport` 与导入 session,本阶段没有历史导入模型,返回 `CHAT_INVALID`;`geo_point/address` 依赖 geogroup/location 模型,本阶段返回 `ADDRESS_INVALID`。这两类高级 flags 不能落 `NOT_IMPLEMENTED`,也不能伪造成普通群创建成功。 -- `ttl_period` 只接受非负值,负数返回 `TTL_PERIOD_INVALID`;真实 TTL 自动删除管线后续接入。 -- broadcast 的普通成员不能发普通消息;creator/admin 可发 `post` 消息。 -- megagroup 的 slowmode 只限制普通成员发普通消息;服务端检查 `slowmode_last_send_date + slowmode_seconds`,重复 `random_id` 命中幂等结果时不重新触发限速。 -- 如果 `pre_history_hidden=true`,invite/join/import invite 写成员时用加入前的 `top_message_id` 初始化 `available_min_id`;无论是否隐藏历史,都用加入前 `channels.pts` 初始化 `available_min_pts`。为了避免旧历史在惰性 unread 计算中变成未读,invite/join/import invite 都会把 `read_inbox_max_id` 初始化到加入前 top;主动 join/rejoin/import invite 生成的自服务消息会继续把当前成员 read 水位推进到该服务消息,邀请服务消息则保留为被邀请者的一条未读。 - -## Send / History Flow - -频道/超级群消息只写一份 `channel_messages`: - -1. 校验成员状态、ban/default banned rights、broadcast 发送权限、普通成员 invite/send 默认限制、slowmode、noforwards。 -2. 如带 `reply_to`,按当前 channel + 当前成员 `available_min_id` 校验目标消息存在、未删除、可见,并反算 `reply_to_top_id`:目标已有 top 则继承,否则 top=目标 msg_id;quote 文本/entities/offset 原样保留,但 `quote_text` 上限 1024、`quote_offset` 按原消息文本 offset 收口到 4096,不能按 message id 放行。 -3. 通过 Redis 分配 `channel_msg_id` 与 `channel_pts`。 -4. 写 `channel_messages` 与 `channel_update_events(updateNewChannelMessage, pts_count=1)`。 - - `random_id` 幂等重试必须返回原始 `updateNewChannelMessage` 的 durable snapshot;即使该消息之后被编辑或删除,也不能把重试响应污染成 edit/delete 事件或当前消息状态。 -5. 更新 `channels.top_message_id/pts`。 -6. 更新发送者 `channel_members/channel_dialogs.read_inbox_max_id/read_outbox_max_id/top_message`,自己的新消息立即视为已读/已发。 -7. 不写成员 message box;Postgres 用单条 set-based SQL 推进 active/可见成员的 `channel_dialogs.top_message_id/unread_count` 缓存,dialog 查询仍以 `channels.top_message_id` + 当前 member 的 `available_min_id/read_inbox_max_id` 可重算可见 top 与 unread,避免缓存陈旧影响 TDesktop 离线恢复。 -8. 在线推送只 fanout 给当前活跃 channel 成员 session,且每个接收者按自己的 viewer user id 重新生成 `tg.Message.out`,不能复用发送者视角的 update;离线设备走 `updates.getChannelDifference`。 - -`messages.getHistory(InputPeerChannel)` 直接查 `channel_messages(channel_id, id)`,按 `offset_id/max_id/min_id` 做 seek pagination,`offset_id=0` 时支持 `offset_date` date cursor,禁止 SQL 大 OFFSET。`channels.getMessages` 必须按 `id = ANY(...)` 精确批量读取,不允许把稀疏 ID 转成 min/max 范围后再 `LIMIT`,否则 TDesktop 拉 pinned/reply/跳转消息会漏旧消息。`channels.getChannels/channels.getMessages` 这类精确 ID vector 入口统一 cap=100,不能按客户端传入数量无界查库或构造响应。返回消息时按请求 user 动态设置 `out`、`post`、`from_id/send_as` 与 reply/forward 信息。 - -## Dialog / Read / Unread - -`channel_dialogs` 是 owner 视角: - -- `read_inbox_max_id`:当前用户已读到的最大 channel message id。 -- 被邀请加入已有 megagroup/channel 时,成员的初始 `read_inbox_max_id` 固定到邀请发生前的 `channels.top_message_id`;如果 megagroup 产生 invite service message,则只让这条新服务消息成为未读,不能把入群前历史全部计入 unread。主动 join/rejoin/import invite 也先把 read 水位固定到加入前 top,再把自己的 join service message 标为已读,避免 TDesktop 打开会话时把旧消息批量推进成读回执。`pre_history_hidden=true` 时 `available_min_id` 也落到同一个旧 top,既隐藏旧历史又避免未读数膨胀。成员的 `available_min_pts` 固定到加入前 `channels.pts`,避免离线补偿返回入群前权限/成员事件。 -- `joinChannel/importInvite` 必须尊重既有 member 状态:`active` 重复加入返回 `USER_ALREADY_PARTICIPANT`,`kicked`、`banned` 或 `view_messages` ban 不允许直接重新加入;`left` 用户重新加入时恢复 `participants_count`。`inviteToChannel` 对单个已加入用户返回 `USER_ALREADY_PARTICIPANT`,对被踢/被禁看消息用户只有 creator 或具备 `ban_users` 的 admin 才能通过邀请恢复,普通成员单人邀请返回 `USER_KICKED`,多人邀请跳过无权恢复的目标。`leaveChannel` 的在线 recipients 必须包含离开的 user 本人,用于同账号其它设备同步离群状态。 -- `importInvite` 的 invite link 错误码需要保持 TDesktop 可解释:`request_needed` 当前不落真实申请流,但返回 `INVITE_REQUEST_SENT`;`usage_limit` 用尽返回 `USERS_TOO_MUCH`;被踢/禁看仍返回 `INVITE_HASH_INVALID`,避免泄漏私有成员状态。 -- `read_outbox_max_id`:当前用户发送的 channel/supergroup 消息被其它成员读到的最大值。`readHistory` 只扫描 bounded recent delta,推进涉及发送者的 member/dialog outbox watermark,并通过 `updateReadChannelOutbox` 在线通知发送方;不做全员 O(n) fanout。该水位必须同时从 `messages.getDialogs/messages.getPeerDialogs` 的 dialog 和 `channels.getFullChannel` 的 `ChannelFull` 返回,作为离线设备或丢失实时 update 后的恢复路径。 -- `unread_count`:发送事务推进 active/可见成员的 `channel_dialogs` 缓存,计算方式仍是 `max(0, channels.top_message_id - read_inbox_max_id)`;删除/隐藏历史会用 `available_min_id` 修正,读请求必须可重算,不能只信缓存。 -- `top_message_id/top_message_date`:`channel_dialogs` 中的值是当前 owner 状态缓存,发送时会随最新消息推进;本地清空历史后可能落后,PG 查询仍必须优先用 `channels.top_message_id > channel_members.available_min_id` 判断当前可见 top,并 join 当前 top `channel_messages` 取真实 `message_date` 排序。 -- `folder/pin/manual unread/notify settings/default send-as/view-forum-as-messages` 仍按当前 user 保存,并通过 user_update_events 通知多设备;频道消息本身通过 channel_update_events 补。`messages.toggleDialogPin/reorderPinnedDialogs/markDialogUnread/getDialogUnreadMarks` 和 `folders.editPeerFolders` 必须把 `InputPeerChannel` 分流到 `channel_dialogs`,与私聊 `dialogs` 合并返回;这些是账号级 dialog 状态,不推进 channel pts。Layer 225 `markDialogUnread/getDialogUnreadMarks` 的 `parent_peer` 只用于 monoforum/SavedSublist;当前没有 monoforum subdialog store,因此先校验 parent channel 与 sublist peer 后 BoolTrue/空列表 no-op,避免 TDesktop 后台 `NOT_IMPLEMENTED`,真实 monoforum unread marks 留后续模型。`messages.saveDefaultSendAs` 写 `channel_dialogs.default_send_as_peer_*`,`channels.toggleViewForumAsMessages` 写 `channel_dialogs.view_forum_as_messages` 并发 `updateChannelViewForumAsMessages`,`channels.getFullChannel` 输出 `channelFull.default_send_as/view_forum_as_messages` 供 TDesktop 恢复本地选择。`folder_id=0/1` 是物理主列表/归档状态,必须在 SQL `LIMIT` 前下推;`folder_id>=2` 是 TDesktop 自定义 filter 规则,不能拿它和 `channel_dialogs.folder_id` 直接相等比较。 - -`messages.readHistory(InputPeerChannel)`: - -1. 锁当前 user 的 `channel_dialogs`。 -2. 将 `read_inbox_max_id` 推进到 `min(req.max_id, channel.top_message_id)`。 -3. 若水位前进,写 `channel_members.read_inbox_date=req.date`;再更新 `unread_count/manual unread`。 -4. 对当前 user 产生账号级 `updateReadChannelInbox` 或兼容的 dialog/user update;该事件用于多设备 dialog 未读状态同步。 - - 当前实现记录一条 `user_update_events(read_history_inbox, peer_type='channel')`,在线/离线设备转换为 TL `updateReadChannelInbox`;实时 fallback 使用 channel pts 填 `updateReadChannelInbox.pts`,可靠 outbox/difference 使用账号 pts 作为 durable cursor,TDesktop 在 pts 不匹配时仍会推进 `max_id` 并通过 dialog entry 取回精确 unread。 -5. 对读水位前进区间做 bounded sender scan:最多查看最近 `MaxChannelReadOutboxScanMessages=1000` 条、最多通知 `MaxChannelReadOutboxFanout=128` 个发送者;每个发送者只推进自身 `read_outbox_max_id` 并在线推 `updateReadChannelOutbox(channel_id,max_id)`,避免客户端传超大 `max_id` 时生成无界 update。 - -`messages.getMessageReadParticipants(InputPeerChannel)`: - -- 只对 megagroup 返回真实小群读者;broadcast channel 或成员数超过 `chat_read_mark_size_threshold` 返回空列表,避免大频道 O(n) 放大。 -- 若 `channels.participants_hidden=true`,已读详情直接返回空;TDesktop 在隐藏成员模式下也会关闭 read participants UI,服务端不通过该接口泄漏成员身份。 -- 先校验当前用户可见 channel 与目标 message,删除消息或不可见历史返回 `MESSAGE_ID_INVALID`。 -- 消息超过 `chat_read_mark_expire_period` 返回空列表;客户端正常不会发起过期请求。 -- 查询 `channel_members` 时只取 `status='active'`、未禁看消息、`available_min_id < msg_id`、`read_inbox_max_id >= msg_id` 且 `read_inbox_date > 0` 的成员,并排除请求者自己;单次最多 50 个。初始 read 水位只用于 unread 基线,不应让新加入成员凭默认水位出现在旧消息的已读列表中。 -- 返回的 date 来自 `read_inbox_date`,旧数据未记录日期时允许为 0,TDesktop 会按无具体时间展示。 - -## Channel Difference - -`updates.getChannelDifference(channel, pts, limit)`: - -- 校验 access_hash、成员/可见权限、ban。 -- `limit` cap:普通用户 `1..100`,超过按 100;内部硬上限 1000,拒绝负数和超大值。 -- 若 `pts < 0` 或 `pts > current_channel_pts` 返回 `PERSISTENT_TIMESTAMP_INVALID`,避免客户端用未来水位跳过 durable log。 -- 从 `channel_update_events` 读取 `pts > req.pts ORDER BY pts ASC LIMIT cap+1`。 -- `channel_update_events.payload` 对 new/edit/pin 等消息类事件保存 domain 快照;`updates.getChannelDifference` 必须优先使用事件时刻的 message snapshot,不能回读当前 `channel_messages` 覆盖旧事件,否则连续编辑、删除后的离线补偿会丢失中间状态。 -- 没有事件返回 `channelDifferenceEmpty{final=true, pts=current_channel_pts, timeout=30}`。 -- 事件数 `<= cap` 返回 `channelDifference{final=true, pts=max_pts, new_messages, other_updates, chats, users}`。 -- 如果当前 member 的 `available_min_pts > req.pts`,先把请求 pts 抬到 `available_min_pts`,从源头跳过入群/重新加入前的消息类 durable 事件。 -- 对 `available_min_id` 之后才可见的成员,普通差量仍扫描 durable log 并推进返回 `pts`,但会过滤 `new/edit/delete/pin` 中 `message_id <= available_min_id` 的消息内容和 id;若本页全被过滤,返回 `channelDifferenceEmpty{pts=max_scanned_pts}`,避免隐藏历史或本地清空后的旧消息通过差量恢复泄露。部分可见的 delete/pin 事件只裁剪 `messages` 向量,保留原始 `pts_count`;TDesktop 在线 update 用 `pts_count` 推进 channel PTS,差量响应最终用 `channelDifference.pts` 初始化,不要求 `len(messages)==pts_count`。 -- 若 `current_channel_pts - req.pts > cap`,返回 `channelDifferenceTooLong`,包含带当前 channel pts 的 dialog、最新一页有界消息、channel、相关 users,避免大频道旧 pts 客户端循环拉取大量差量页。 -- 否则事件数 `<= cap` 返回 `channelDifference{final=true, pts=max_pts, new_messages, other_updates, chats, users}`;事件数达到 cap 但仍未追上当前 pts 时返回 `final=false`,客户端会继续拉下一页。 -- `new_messages/other_updates` 中 message 的 `from/send_as/fwd_from/reply_to/action` 涉及的 user/channel 必须随本页 `users/chats` 返回;账号级 `user_update_events` 同样持久带出 fwd/reply 的 users/channels,供 `updates.getDifference` 和 `dispatch_outbox` 复用,避免在线响应正常但离线恢复缺 peer。 - -## Input Hash And Visibility - -- 外部 `InputChannel` / `InputPeerChannel` 带非零 `access_hash` 时,RPC 层必须先和当前 domain channel 的 `access_hash` 比对;不匹配返回 `CHANNEL_PRIVATE` 或在 vector 查询中跳过该项,禁止只靠 channel_id + member 关系兜底。 -- 这个校验不仅覆盖 `channels.*` 和 `messages.send/edit/delete/read/history/search` 主路径,也覆盖 `messages.getPeerSettings`、`messages.saveDraft`、`messages.getDialogs` offset peer、dialog pin/unread/folder、`messages.updateDialogFilter`、`folders.editPeerFolders`、reply header 的 `reply_to_peer_id` 等旁路入口。 -- `InputChannelFromMessage` / `InputPeerChannelFromMessage` 属于 min channel 解析路径,没有 access_hash 字段;服务端只接受正 channel_id,并继续执行成员/可见性校验。 -- legacy basic group wrapper 由服务端内部映射到 megagroup,内部构造的 `InputChannel{access_hash=0}` 允许跳过 hash 比对;真实客户端传来的非零 hash 一律校验。 - -硬约束: - -- `updateNewChannelMessage.PtsCount=1`。 -- `updateEditChannelMessage.PtsCount=1`。 -- `updateDeleteChannelMessages.PtsCount=len(message_ids)`,单次 cap 1000。 -- 全清历史不能生成十几万条 update,也不能一个超大 vector;`messages.deleteHistory(InputPeerChannel)` 返回 `affectedHistory.offset` 供客户端/管理端续删,`channels.deleteHistory` 的 TL 返回 `Updates` 且 TDesktop 不读取 offset,所以该入口只执行一个有界 page 或本地 `available_min_id` 清空,不能在同步 RPC 内循环展开全量历史。 -- 所有 history/search 入口的 `add_offset` 必须在 RPC/store 层 clamp 到小窗口;即使 channel 单份历史当前按 seek 查询忽略大 offset,也不能让私聊或后续 channel around-load 分支把客户端传入的极端值变成无界 slice/SQL OFFSET。 -- `channels.deleteParticipantHistory` 同样只删除一个有界 page,按 `(channel_id, sender_user_id, id DESC)` seek 最近消息,`offset=1` 表示后面仍可能有更多该成员历史需要客户端/管理端继续请求。 - -## Admin Log - -`channels.getAdminLog` 是管理页的只读审计视图,不推进 channel pts,也不写入普通用户 update。当前真实记录这些 TDesktop 首批能展示的事件: - -- 元信息/设置:title、username、signatures、prehistory、slowmode、anti-spam。 -- 成员权限:invite、join、leave、promote/demote、ban/unban、kick/unkick。 -- 消息管理:pin/unpin、channel post/send、edit、delete。 - -请求边界: - -- `limit` cap 100;`admins` cap 100;`q` cap 128。 -- `max_id`/`min_id` 只作为 seek 条件:`id < max_id`、`id > min_id`,禁止展开区间数组。 -- `q` 只做有界页面搜索,覆盖元信息字符串与消息类事件 body;后续若管理日志量级增大,再接 PG full-text/trigram 索引。 -- `events_filter` 空表示不过滤;非空只映射当前支持的 action types,未来 forum/group_call/subscription 等先保持无结果而不是伪造。 -- `admins` 过滤 actor_user_id,不按受影响成员过滤;TDesktop 侧该参数语义就是管理员 actor 列表。 - -返回 `channels.adminLogResults` 时,RPC 层按事件收集 actor、participant、message sender,填充 `users`;`chats` 返回当前 channel。domain/store 层只保存自有模型,TL action 转换集中在 `internal/rpc`。 - -当前实现状态: - -- 已实现 `messages.createChat -> megagroup`、`channels.createChannel`、成员 invite/join/leave、channel 单份文本发送、history、dialogs、read、typing 与 `updates.getChannelDifference`;`channels.createChannel` 的 history import/geogroup 高级 flags 暂不建模,已映射为显式 TL 错误而非 `NOT_IMPLEMENTED`。 -- 已实现 channel 文本 edit:只更新 `channel_messages` 单份消息,写 `channel_update_events(edit_channel_message)`,在线推 `updateEditChannelMessage`;TDesktop 文本编辑携带的 `inputMediaWebPage/inputMediaEmpty` 降级为文本编辑,真实 media/reply_markup/quick replies 仍返回显式 TL 错误等待后续模型。 -- 已实现 channel delete:`channels.deleteMessages` 与 `channels.deleteHistory(for_everyone)` 软删单份消息,单批最多 1000 个 id,写一条 `delete_channel_messages` 事件,`pts_count=len(ids)`;需要多页续删的管理路径应使用 `messages.deleteHistory(InputPeerChannel)` / `channels.deleteParticipantHistory` 这类带 `offset` 的响应继续推进,避免复现 参考实现 按超大范围构造 id vector 的 OOM 风险。 -- 已实现 `channels.deleteParticipantHistory`:管理员按 participant sender 删除一页消息,PG 走 sender history 部分索引,单批最多 1000 个 id,返回 `affectedHistory.offset` 供续删,在线推 `updateDeleteChannelMessages`。 -- 已实现当前用户本地清空 channel history:只推进该用户 `available_min_id/read_inbox` 与 `channel_dialogs`,不生成 channel pts,也不对成员写扩散;同时写账号级 `channel_available_messages` durable update,并给同账号其它 session 推 `updateChannelAvailableMessages`;重复或 stale 清空请求返回实际单调水位,不能把客户端 `available_min_id` 回退。 -- 已实现 channel reply:发送时校验被回复消息是同 channel 内当前成员可见消息,继承/计算 `reply_to_top_id`,保留 quote metadata,并在 TL 层返回 `messageReplyHeader`;非法、本地清历史后不可见目标或超界 `quote_offset` 返回 `REPLY_MESSAGE_ID_INVALID`。 -- 阶段外高级发送/转发 flags 不再返回 `NOT_IMPLEMENTED`:quick reply、effect、paid、suggested post、monoforum/todo/poll/story reply 分别映射到 `SHORTCUT_INVALID`、`EFFECT_ID_INVALID`、`PAYMENT_UNSUPPORTED`/`STARS_AMOUNT_INVALID`、`SUGGESTED_POST_PEER_INVALID`、`REPLY_TO_MONOFORUM_PEER_INVALID`、`REPLY_MESSAGE_ID_INVALID`、`POLL_OPTION_INVALID`、`STORY_ID_INVALID`;后续接入对应模型前不伪造成功 update。 -- 已实现 linked discussion/comment 基础闭环:broadcast post 写入单份 source message 时同步在 linked megagroup 写一条 forwarded root message;source message 保存 discussion ref 并在 `messageReplies` 填 `comments/channel_id/replies_pts/max_id/read_max_id`;`messages.getDiscussionMessage` 返回 root、`messages.getReplies` 读 linked group thread,`messages.readDiscussion` 推进 linked group read watermark。 -- 已实现文本转发的 channel 路径:channel→channel、channel→user、user→channel;目的 channel 仍按单份消息写入并生成 channel pts;请求携带的目标会话 `reply_to` 会正常校验和持久化,TDesktop 转发到 forum topic 时发送的 `top_msg_id` 会映射为 topic-only reply 并更新 topic top message,但源消息自身 reply 不继承;RPC 响应、账号级 difference/outbox 与 channel difference 都会为 `fwd_from/reply_to` 中可解析的 user/channel peer 补齐 users/chats,避免 TDesktop 因 header peer 未加载而延迟 apply。 -- 已实现 `messages.search(InputPeerChannel)`:支持 channel/supergroup 单份消息文本搜索、`from_id` 用户过滤、`min_date/max_date`、`offset_id/max_id/min_id` 与 limit cap,结果附带当前 channel 与消息 sender/forward/reply 所需 users/chats;`inputMessagesFilterPinned` 按当前单置顶模型只返回 `channels.pinned_message_id` 对应消息,并只给该消息设置 TL `message.pinned`。 -- 已实现 `messages.searchGlobal` 的 channel/supergroup 分支:TDesktop 主搜索的 Channels/Groups tab 会按当前账号 active membership 搜索单份 channel message,支持 `broadcasts_only/groups_only/users_only`、`offset_rate+offset_peer+offset_id` seek、folder_id=0/1 下推与 limit cap=50;未加入/left/kicked/view_messages banned 的频道不暴露,media filter 在 media store 接入前返回空结果。 -- 已实现 `messages.getMessageReadParticipants(InputPeerChannel)`:基于小 megagroup 成员读水位返回 `readParticipantDate`,含 50 人阈值、7 天过期窗口、`available_min_id` 可见性过滤与 PG 索引。 -- 已实现 `messages.getMessageEditData`:私聊与 channel peer 都做 message/peer/作者或管理员编辑权限校验;当前文本-only 编辑没有媒体 caption 状态,返回 `caption=false`。 -- 已实现 `messages.getMessagesViews(InputPeerChannel)`:TDesktop 每秒最多 100 条批量增量,服务端按 `(channel_id,message_id,viewer_user_id)` 持久去重并维护 `channel_messages.views_count` 聚合列;本地清历史前不可见、已删除或不存在的 id 不递增且返回空 view,replies/comment 信息继续从 discussion/thread model 回填。 -- 已实现 `channels.exportMessageLink`:复制频道/超级群消息链接前会校验 channel message 对当前成员真实存在且未被删除/本地清历史隐藏;公开 username 走 `t.me/{username}/{msg_id}`,私有 channel 走 `t.me/c/{channel_id}/{msg_id}`,普通 reply/thread 链接支持 `?thread={root_id}`。`grouped/html` 与 linked discussion 的 `?comment=` 细分链接留后续。 -- 已实现管理面最小真实能力:`channels.editAdmin/editBanned/editTitle/deleteChannel`、`channels.getParticipants` 的 admins/kicked/banned/search 等过滤、`messages.updatePinnedMessage/unpinAllMessages`、`messages.exportChatInvite/checkChatInvite/importChatInvite`。`channels.deleteChannel` 按 TDesktop/参考实现预期返回并推送 `updateChannel + channelForbidden`,同时 dialog 列表过滤 deleted channel。`channels.editPhoto/messages.editChatPhoto` 在头像 media store 接入前只接受 `inputChatPhotoEmpty` no-op 删除,uploaded/existing photo 明确返回 `PHOTO_INVALID`,不伪造 `messageActionChatEditPhoto`。 -- `channels.editAdmin/editBanned` 只更新成员状态/计数并写 admin log,不占 channel pts、不写 `channel_update_events`;在线响应/推送包含 `updateChannelParticipant + updateChannel`。离线客户端通过 `channels.getFullChannel/getParticipants/getParticipant` 或后续可见消息触发的 channel state 刷新补偿;如果操作另行产生可见 service message,则由该 service message 进入 channel pts。 -- 已实现公开 username 管理:`channels.checkUsername/updateUsername/getAdminedPublicChannels`,PG 用 `channel_usernames(username_lower)` 与 users username 查询避免跨 peer 占用;主 username 的清除只走 `channels.updateUsername("")`。 -- 已实现 `channels.toggleSignatures`:权限校验后持久化 `channels.signatures`,返回/在线推 `updateChannel`。 -- 已实现 `channels.updateColor/updateEmojiStatus`:颜色分别持久化 `color/profile_color` 与 background emoji id,并保留 color flag 显式 0;普通 emoji status 保存 document id/until,`emojiStatusEmpty` 清空,collectible gift 状态因缺少 gift/read model 先返回 `EMOJI_STATUS_INVALID`。响应、在线推送、`channels.getChannels` 都回填 `Channel.color/profile_color/emoji_status`。 -- 已实现 `channels.toggleViewForumAsMessages`:这是当前账号本地论坛展示模式,不修改 topic 列表;服务端写 `channel_dialogs.view_forum_as_messages`,返回/可靠投递 `updateChannelViewForumAsMessages` 给同账号其它 session,并在 `messages.getDialogs` 与 `channels.getFullChannel` 回填 `Dialog/ChannelFull.view_forum_as_messages`。 -- 已实现 forum topic 最小真实闭环:`channels.toggleForum` 打开 forum 后,`messages.getForumTopics/getForumTopicsByID` 返回虚拟 General + `channel_forum_topics` 分区表里的 topic;`messages.createForumTopic` 写 `messageActionTopicCreate` root service message,topic_id 绑定该 message id,并用 channel pts 通过 `updateNewChannelMessage` 在线推送。TDesktop 的 topic 输入框 `reply_to_msg_id=0/top_msg_id=topicRootId` 已支持:服务端校验 topic 存在且未关闭/隐藏,消息只写一份 `channel_messages`,保存 `reply_to_top_id` 和 `forum_topic` 标志,`messages.getReplies(topicRoot)` 返回 `messages.channelMessages{pts,topics}` 且 topic 用 short constructor;发送 topic 消息会更新 topic top message,`messages.forwardMessages.top_msg_id` 也走同一 topic-only reply 语义,避免转发到主题落到主会话。`messages.editForumTopic` 写 `messageActionTopicEdit` service message 且 reply_to_top_id 指向 topic root;`messages.updatePinnedForumTopic/reorderPinnedForumTopics` 更新 topic pinned/order 并推送对应 TL update;`messages.deleteTopicHistory` 每页最多删 `MaxDeleteHistoryBatch` 条 root/thread message,通过 `affectedHistory.offset` 续删,最后一页才隐藏 topic。topic page 走 limit cap=100 与 seek 条件,不按客户端超大 offset/id 展开数组。 -- 已实现 `channels.toggleAntiSpam`:服务端写 `channels.antispam`,返回/推送 `updateChannel`,在 `channels.getFullChannel` 回填 `ChannelFull.antispam`,并记录 `channelAdminLogEventActionToggleAntiSpam`;真实 native anti-spam bot 和自动删垃圾消息管线后续单独接入。 -- 已实现 `channels.getSendAs` + `messages.saveDefaultSendAs` 的保守 current-channel 链路,并让 `messages.sendMessage/forwardMessages(InputPeerChannel)` 接受同一套 `send_as` 校验结果:列表始终包含 self,只有 creator、broadcast post admin、megagroup anonymous admin 才追加 current channel,且响应必须带 current channel chat + self user 供 TDesktop 解析;不暴露 public channel 扩展候选。`channels.getSendAs` handler 在一次 `GetChannel` 中完成 access_hash 校验、成员读取与候选构造,避免 TDesktop 打开输入框时重复查同一 channel。保存 current channel 会写入 `channel_dialogs` 并在 `channelFull.default_send_as` 恢复;保存 self 清空默认身份。发送/转发未显式带 `send_as` 时会读取已保存默认身份并重新校验,默认值因权限变化失效时自动降级 self,避免陈旧状态阻塞发送。 -- 已实现 `messages.toggleNoForwards`:legacy chat/channel 内容保护入口持久化 `channels.noforwards`,返回/在线推 `updateChannel`;后续 channel/supergroup 单份消息写入时自动继承 noforwards,避免转发受保护内容。 -- 已注册 `messages.setChatTheme`,并实现 `messages.setChatAvailableReactions`:前者对 private peer 返回空 updates、对 legacy chat/channel 返回 channel context;后者参考实现 的 `available_reactions` 存储语义,用 domain policy + PG JSONB 持久化,`channels.getFullChannel` 返回 `available_reactions/reactions_limit/paid_reactions_available`,并对 reaction vector/reactions_limit 做 cap。 -- 已实现 private + channel/supergroup emoji message reaction 最小闭环:`messages.getAvailableReactions` 优先返回 seed 自真实导出的 reaction documents;`messages.sendReaction` 对 private peer 写 `private_message_reactions`,按共享 `private_message_id` 聚合但以各 owner 的 peer/msg_id 构造 `updateMessageReactions`,并写账号级 `message_reactions` durable event,离线设备经 `updates.getDifference` 收到带最新 `message.reactions` 的 message + `updateMessageReactions`;对 channel/supergroup 写 `channel_message_reactions`,替换或清除当前用户对单条 channel message 的 emoji reaction,返回并在线推 update,离线/重开由 `messages.getHistory` / `updates.getChannelDifference` / `messages.getMessagesReactions` 补偿;channel `sendReaction` 每次还累计账号级 `user_top_reactions`,`getTopReactions` 按使用次数排序并优先用真实 available reaction catalog 有界补齐;`add_to_recent` 会写账号级 `user_recent_reactions`,`messages.getRecentReactions/clearRecentReactions` 按 hash 有界返回/清空最近 emoji reaction;`messages.updateSavedReactionTag` 会写账号级 `user_saved_reaction_tags` 并推 `updateSavedReactionTags` 给同账号其它 session,`getSavedReactionTags` 全局请求按 hash 返回标题列表;channel 反应者不是消息作者时标记 unread 并重算 `channel_dialogs.unread_reactions_count`,`messages.getUnreadReactions/readReactions` 按消息作者视角拉取/清理未读 reaction;TDesktop 打开会话时的 `channels.readMessageContents` 会按 visible message ids 清理这些 unread reaction 并推 `updateMessageReactions`,避免 dialog reaction icon 重启后重新出现;`messages.getMessagesReactions` 按请求 id 返回 reaction 聚合清理/刷新 UI,`messages.getMessageReactionsList` 返回具体用户列表(channel 用 keyset offset,private 当前受 recent 嵌入上限约束)。custom emoji、paid reaction、saved-message tag assignment/count/per-peer ranking 留后续模型。 -- 已实现 `channels.togglePreHistoryHidden/toggleSlowMode`:prehistory 仅 creator 可改,并通过 `available_min_id/read_inbox_max_id` 裁剪新成员历史,同时用 `available_min_pts` 裁剪新成员 difference 起点;slowmode 允许 change_info 管理员改;PG 持久化 `pre_history_hidden/slowmode_seconds/slowmode_last_send_date`,TDesktop 可从 channel/full channel 看到状态,普通成员过快发言返回 `SLOWMODE_WAIT_X`。 -- 已实现 `channels.toggleParticipantsHidden`:creator/具备 `ban_users` 的管理员可切换 `channels.participants_hidden`,返回/推送 `updateChannel`;TDesktop 通过 `ChannelFull.participants_hidden` 恢复隐藏成员 UI,普通成员的 participants 列表只保留 aggregate count,read participants 返回空,避免用已读详情绕过隐藏成员设置。 -- 已实现 `channels.getAdminLog`:持久化 channel-scoped admin log,支持 actor/filter/query/max_id/min_id/limit 的有界查询,并返回真实 TL admin log actions;当前覆盖 metadata、成员权限、pin、send/edit/delete,forum/group_call/subscription 等长尾 action 留待对应业务模型。 -- 已注册 `stats.*` 统计域兼容入口:TDesktop 统计页会调用 `stats.getBroadcastStats/getMegagroupStats/getMessageStats/getMessagePublicForwards/loadAsyncGraph`,当前先做 access_hash、admin、类型、msg_id、limit/offset 边界校验并返回可解析空图表或空 public forwards;真实聚合表、预计算窗口和异步 graph token 后续单独设计,避免在首版频道模块里引入无界统计扫描。 -- 已注册 `premium.*` boost 域兼容入口:TDesktop 频道统计页、颜色/权限入口和 boost 弹窗会调用 `premium.getBoostsStatus/getBoostsList/getMyBoosts/applyBoost/getUserBoosts`;当前仅做 channel/access_hash/admin/user/limit/offset/slots 边界校验并返回零状态或空列表,不落库、不生成虚假 boost 关系。真实 boost slots、giveaway、premium audience 聚合后续独立建模。 -- 已实现 invite management:TDesktop 管理 invite links、admins with invites、importers、join requests 时会调用 `messages.getExportedChatInvites/getExportedChatInvite/editExportedChatInvite/deleteExportedChatInvite/deleteRevokedExportedChatInvites/getAdminsWithInvites/getChatInviteImporters/hideChatJoinRequest/hideAllChatJoinRequests`;当前已持久化 invite 列表、detail/edit/revoke/delete、按 admin 统计、importer/read model、`request_needed` pending join request、单个 approve/dismiss 与 bounded `hideAll`。`getExportedChatInvites` 使用 `offset_date + offset_link` seek,`getChatInviteImporters` limit cap=100,`hideAll` 单批最多 1000,避免按客户端超大参数生成无界更新;subscription/chatlist/paid invite 与 join-request service notification 仍留后续。 -- 已实现 public join settings:`channels.toggleJoinToSend`/`channels.toggleJoinRequest` 持久化 `channels.join_to_send/join_request` 并返回带 flags 28/29 的 `tg.Channel`;`join_request` 仅 public megagroup 可开启,非成员 `channels.joinChannel` 会写入 `channel_invite_importers(invite_id=0, requested=true)` 并返回 `INVITE_REQUEST_SENT`,之后可通过 `messages.getChatInviteImporters(requested=true)` 查询和 `messages.hideChatJoinRequest` approve/dismiss。admin 侧 `channels.getFullChannel` 会回填 `requests_pending/recent_requesters`,request-needed import/public join 以及 approve/dismiss 会向有界管理员集合推 `updatePendingJoinRequests`;该状态不写入无界 durable update log,离线管理员重新打开 full channel 时补偿。 -- 已修正当前普通成员的 `channels.getParticipant(inputPeerSelf)` 与 participants 列表 TL constructor:TDesktop `requestSelf` 期望普通本人是 `channelParticipantSelf`,creator/admin 仍分别返回 creator/admin self 语义;避免客户端记录 `Got self regular participant`,且不改变 domain/store 成员模型。 -- PG channel pts 已补失败保护:现有 channel 的 send/edit/deleteHistory/deleteMessages/pin 和加入/退出/标题等可见 service message 在事务失败或权限失败后写 `noop` 占位,避免 Redis 分配过的 channel pts 形成 TDesktop `PtsWaiter` 永久 gap。 -- 2026-06-01 双 TDesktop 在线/离线实测已覆盖超级群发送、reply、forward、edit 与离线恢复:Bob 对 Alice 消息 reply,Bob 将回复 forward 回同一超级群,随后编辑该频道消息;Bob 关闭期间 Alice 发送 channel 消息,Bob 重启后 dialog 未读数=1,打开群后看到离线消息;Alice/Bob 双窗口均实时显示,server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic。删除、清历史、踢/禁言仍需用户行动时确认后做 UI 实测。 -- 2026-06-02 Computer Use 双 TDesktop 复测已覆盖当前非破坏性频道/超级群 UI 路径:Alice/Bob 在 `E2E Super 0307` 中双向发送 `cu-round-alice-*` / `cu-round-bob-*` 并实时互见,成员栏显示 2 members/online,Alice 全局搜索 Bob 的新消息返回 `Found 1 message`;Alice 在 `CU Public Search 44238` 频道发布 `cu-channel-round-*` 后频道消息流和左侧 preview 同步更新。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,客户端本轮无新增 `Bad participant` / `Got self regular participant`;清空搜索框产生的 `SEARCH_QUERY_EMPTY` 保持可解释。 -- 2026-06-02 09:55 Computer Use reaction/sticker 启动复测:Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`,互发 `cu-stubfix-alice-*` / `cu-stubfix-bob-*` 后双方消息列表和左侧 preview 均可见;打开 emoji 面板触发 `messages.getAvailableReactions` / `messages.getStickerSet` / `messages.getAvailableEffects`,server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前 `log.txt` 无新增 `Unexpected messages.stickerSetNotModified` / participant 告警。右键消息菜单可打开但本轮未显示 reaction 快捷项,真实 reaction sticker animations/custom UI 仍留后续。 -- 2026-06-02 10:10 Computer Use 主干路径复测:Debug/Alice 与 DebugBob/Bob 在 `E2E Super 0307` 中双向在线发送 `cu-continue-*`,消息流与左侧 preview 双端可见,未读通过 `channels.readHistory` 清除;Alice 在 `CU Public Search 44238` 发布 `cu-channel-continue-*`,频道消息流和左侧 preview 同步更新。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error;DebugBob 主日志仅有 08:05 前旧 sticker/participant 噪声。 -- 2026-06-02 10:49 Computer Use unread reaction 回归冒烟:本轮 server 启动后自动应用 migration 0051,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Alice 发送 `cu-unreadrx-alice-1780368577183` 后 Bob 左侧 preview 出现新消息/未读 badge,Bob 发送 `cu-super-bob-1780356886095` 后 Alice 消息流实时显示。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug/DebugBob 最新 `DebugLogs/log_10_45.txt` 无新增 API error/Unexpected;当前 TDesktop reaction 快捷入口仍未稳定显示,未读 reaction 语义由 `internal/rpc` router 测试覆盖。 -- 2026-06-02 11:20 Computer Use recent reactions 相关冒烟:本轮 server 启动后自动应用 migration 0052,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Bob 发送 `cu-recent-bob-1780370400982` 后双方消息流和左侧 preview 可见,Bob 打开 emoji 面板后 Emoji/Stickers/GIFs 面板正常渲染。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug/DebugBob 最新日志无新增 API error/Unexpected;TDesktop 本轮未重新请求 `messages.getRecentReactions`,recent get/clear 的 hash/notModified/clear 后空列表语义由 `internal/rpc` router 测试覆盖。 -- 2026-06-02 11:36 Computer Use top reactions 回归冒烟:本轮 server 启动后自动应用 migration 0053,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Bob 发送 `cu-toprx-bob-1780371301984` 后双方消息流和左侧 preview 可见,Bob/Alice emoji 面板正常渲染,Bob 右键最新消息菜单可打开。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error,DebugBob 仅有 08:05 前旧 sticker/participant 噪声;本轮 TDesktop 没重新请求 `messages.getTopReactions`,top 排序、静态 catalog 兜底、hash/notModified 语义由 `internal/rpc` router 测试覆盖。 -- 2026-06-02 12:23 Computer Use saved reaction tag title 回归冒烟:本轮 server 启动后自动应用 migration 0054,Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`;Bob 发送 `cu-savedtags-final-bob-1780374193558` 后另一客户端消息流可见并触发 channel difference。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic/error;本轮 TDesktop 未直接触发 `messages.getSavedReactionTags/updateSavedReactionTag`,账号级标题持久化、hash/notModified 与 `updateSavedReactionTags` 推送语义由 `internal/rpc` router 测试覆盖。 -- 2026-06-03 unread reaction / 打开历史卡顿回归:Alice Debug 中 `AAAA` 的 unread reaction 由 Bob 对 Alice 自己消息的 reaction 产生;修复后 `channels.readMessageContents` 按 visible message ids 清理该作者视角 unread reaction 并回推 `updateMessageReactions`。Computer Use 验证 Alice 点开 `AAAA` 后 PG `channel_dialogs.unread_reactions_count=0`、`channel_message_reactions.unread=false`;重启 Alice 后 `AAAA` 列表无红色 unread reaction 图标;Bob DebugBob 同时启动并可打开 `AAAA`。同轮修复 `channels.getSendAs` 内重复 `GetChannel`,打开历史时 `messages.getHistory` 为十毫秒级,日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg`。 -- `channels.editPhoto/setStickers/setEmojiStickers/reorderUsernames/toggleUsername/deactivateAllUsernames` 仍为兼容 stub:头像删除只做 no-op channel state 响应,真实上传/已有照片返回 `PHOTO_INVALID`;群贴纸/custom emoji 只允许 megagroup `inputStickerSetEmpty` no-op 清空,非空 sticker set 返回 `STICKERSET_INVALID`;Fragment/多 username 入口不修改主 username,避免数据破坏。 -- Layer 225 `OnChannels*` dispatcher 与 legacy chat/channel 入口已全部显式注册;未进入首批真实业务的入口均为参数有界、权限校验或空结果 stub,避免 TDesktop 实测出现 `NOT_IMPLEMENTED`/unknown RPC。 - -## Permissions - -首批权限模型: - -- creator:全权限。 -- admin:按 `AdminRights` 判断 invite/delete/edit/bans/change_info/post_messages。 -- member:megagroup 可发消息,broadcast 默认不可发。 -- left/kicked/banned:不可读写;banned rights 可限制 `send_messages/view_messages/invite_users/pin_messages/change_info`。 -- default banned rights:channel 级默认禁言,member 无显式权限时继承。 - -TDesktop 最小入口: - -- `channels.getFullChannel` 必须返回当前用户 admin/banned/default rights、participants/read/unread/notify/invite link/pts。 -- `channels.getParticipants` 支持 recent/admins/search/kicked/banned/bots/contacts/mentions 的最小分页,limit cap 200;banned/kicked 只向 admin 暴露。 -- `channels.editAdmin/editBanned/editTitle/deleteChannel/updateUsername/toggleSignatures/togglePreHistoryHidden/toggleParticipantsHidden/toggleForum/toggleAntiSpam/toggleSlowMode/updateColor/updateEmojiStatus/toggleViewForumAsMessages/getAdminLog/exportMessageLink` 已有真实权限/可见性校验;`channels.editPhoto/setStickers/setEmojiStickers/reorderUsernames/toggleUsername/deactivateAllUsernames` 暂为兼容 stub,后续接 files/Fragment username/stickers/custom-emoji/boost gating 等模型。 - -## Online Push - -在线推送分两类: - -- user 维度:dialog/folder/pin/notify/read state 等 owner 状态,继续写 `user_update_events + dispatch_outbox`。 -- channel 维度:消息/edit/delete/pin/member service message,写 `channel_update_events`,在线 fanout 给 active channel sessions。 - -为避免大频道 O(n) 离线写扩散: - -- 不把每条 channel message 写入所有成员的 `dispatch_outbox`。 -- 参考实现 的 status online sessions / `MutableChannelOnlinePush` 边界,当前实现先从 `SessionManager.OnlineUserIDs` 取有界在线 user 快照,再用 `channel_members(channel_id,user_id)` 过滤 active member,最后合并操作显式 recipient(例如 leave/kick 后的本人)。这样避免“大频道取前 500 个成员却漏掉真实在线用户”,也避免每条消息按全体成员写扩散;后续可升级为 `channel_id -> active sessions` 订阅索引。 -- `messages.setTyping(InputPeerChannel)` 是纯瞬时在线推送:TDesktop topic 输入框带来的 `top_msg_id` 只在 `updateChannelUserTyping` 中透传给在线成员,先做 `0..MaxMessageBoxID` 边界校验,非法返回 `MSG_ID_INVALID`,避免异常 topic id 扩散到其它 session。 -- 离线成员不接 outbox;重新打开会话或 gap 时通过 `updates.getChannelDifference` 补齐。 - -## Safety / Performance - -- 所有 list/id/vector 参数必须 cap:participants limit 200、offset 最大 10000、participants/admin-log 搜索 q 最大 128 字符、history/search/searchPosts q 最大 256 字符、searchPosts limit 50、paid message stars 10000、boost unrestrict 0..8、history 100、get ids 100、delete ids 1000、forward ids 100、channel difference 100。 -- 所有实时 fanout 必须有硬上限:首版 `MaxChannelRealtimeFanout=500`,不能因为一个大频道消息或 typing 动作把全体成员读入内存。 -- channel history/search 只返回有界页;PG 用 `limit+1` 判断是否还有下一页,不为 UI 计数执行 `count(*) over()` 或全量 count。 -- 禁止按客户端传入 `int64 max/max_id` 构造数组;deleteHistory 必须批量 seek。 -- `channel_messages` 和 `channel_update_events` 查询必须带 `channel_id` 分区键。 -- `channel_dialogs` 查询必须带 `user_id` 分区键。 -- 从 user 入口访问 channel membership 时不得直接扫 `channel_members WHERE user_id=...`;该表按 `channel_id` 分区,实测会展开 64 个分区。必须走 `user_channel_member_index`,或先取 bounded channel_id 列表再用 `channel_id/id = ANY($1)` 二段查询。 -- 对 `channels` / `channel_members` 这类分区表,SQL 内动态 join key 不能保证分区裁剪;`index -> partitioned table` 单条 join 实测仍会展开全部分区。 -- 单条消息文本沿用 4096 code point 限制,entities cap 256。 -- 大频道 unread 不能在应用层逐成员循环;当前兼容实现只允许数据库 set-based 缓存推进并保留 read watermark 可重算路径,后续高规模频道需拆后台 fanout 或更强惰性缓存。 -- 所有 channel pts 分配失败/事务失败要有 noop gap 补偿与指标。 -- 压测目标:单机 1000 msg/s megagroup send p99 < 150ms;`getChannelDifference` p99 < 100ms;10 万成员频道单条 broadcast 不产生 O(n) 数据库写。 - -## Implementation Plan - -1. Domain/store:补 `PeerTypeChannel`、channel/member/message/dialog/update DTO 与 store interfaces。 -2. Migration:新增分区表、索引、Redis allocator source。 -3. App:新增 `internal/app/channels`,封装创建、成员、发送、history、read、difference。 -4. RPC:注册 `channels.*`、`messages.createChat`、`updates.getChannelDifference`,给 messages 现有 RPC 增加 channel peer 分支。 -5. TL 转换:只在 `internal/rpc` 实现 domain ↔ `tg.Channel/ChannelFull/Message/UpdateNewChannelMessage`。 -6. Online push:新增 channel active fanout,user dialog 状态继续用 reliable outbox。 -7. Tests:domain/store/app/rpc 单元测试,PG/Redis 集成测试覆盖 pts 连续、差量、deleteHistory 分批、权限。 -8. TDesktop:双端/多端验证创建超级群、频道、邀请、退出、在线/离线消息、history、read、edit/delete、forward/reply、typing、dialog/filter/归档展示。 - -## 媒体与频道头像(2026-06-02) - -- `channel_messages` 增 `media` JSONB 快照列(与私聊同构),`SendChannelMessage` 透传 `req.Media`,讨论组联动消息一并带 media;`scanChannel*`/`channelMessageColumns` 统一加 `media::text`,所有 history/getMessages/replies/difference 读取路径自动带出。`tgChannelMessage` 在 media 非空时 `SetMedia`。放宽 `channel_messages` content CHECK 为 `body<>'' OR action<>'{}' OR media<>'{}'`。 -- 频道头像:`channels` 表反范式 `photo_id/photo_dc_id/photo_stripped`(migration `0059`),`channelColumns` + 全部 5 处 channel scanner 同步;`channels.editPhoto`/`messages.editChatPhoto` 经 `resolveInputChatPhoto` 上传或引用照片,admin(change_info)校验后落列并返回 `updateChannel` + 推 channel state;`tgChannel.Photo`(ChatPhoto)/`tgChannelFull.ChatPhoto` 渲染真实头像(`getFile` 按 `photo::` 解析忽略 access_hash,合成 a/c 尺寸即可下载)。 -- 2026-06-03 接手审计修正:`SendChannelMessage` 的空内容校验已把 `req.Media` 纳入,允许超级群/频道发送无 caption 的 photo/document/sticker;新增 PG 集成测试覆盖 channel media 经 `ListChannelDifference` 恢复,防止离线 TDesktop 拉差分丢媒体。 -- 范围外:in-history `MessageActionChatEditPhoto` service 消息留 todo。 diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md deleted file mode 100644 index cde777bf..00000000 --- a/docs/compatibility-matrix.md +++ /dev/null @@ -1,449 +0,0 @@ -# Telegram Desktop Compatibility Matrix - -Client: Telegram Desktop dev 9caf32dffc (describe v6.8.4-15-g9caf32dffc, pinned baseline) -Server: telesrv -gotd/td: v0.144.0 -Layer: 225 -Date: 2026-06-02 - -status 取值:done(真实实现) / stub(兼容响应) / todo(已发现未实现) / blocked(需先补协议或数据结构) - -> 连接层(mtprotoedge)M0–M5 已完成。**登录注册闭环已打通**: -> auth.sendCode/signIn/signUp/logOut + users.getUsers + updates.getState 真实实现, -> telegram.Client 端到端验证(sendCode→signIn→signUp→getUsers→启动后空账号 RPC,TestLoginRegisterFlow)。 -> 启动必经的空账号 RPC 已从硬编码 stub 推进到 PG-backed 业务查询: -> app_configs / countries / account_passwords / temp_auth_key_bindings / -> update_states / contacts / dialogs / langpack,语言包 seed 来自导出的 TDesktop 数据。 -> TDesktop 进入空账号主界面后的异步预取 RPC 已补第一阶段空响应,避免后台重试噪声。 -> PG 集成测试覆盖 AuthKeyStore 与业务 store 往返(TestBusinessStoresRoundTrip)。 -> active session 已缓存 raw auth_key_id、业务 auth_key_id、session_id、user_id;router 入口只做一次 temp→perm 与 auth_key→user_id 解析,业务服务按 user_id 查询;普通注册 user_id 从 1780243200 起递增。 -> 二阶段私聊文本消息链路已落地:`messages.sendMessage` 事务内写 `private_messages` / 双端 `message_boxes` / `dialogs` / `user_update_events` / `dispatch_outbox`,`pts` 与 owner 视角 `box_id` 由 Redis 原子计数并可从 PG durable log 恢复;outbox worker 批量推送在线 session,链路指标覆盖发送、限流、outbox claim/deliver/fail。 -> 联系人与 dialog owner 视角已补齐:`contacts.addContact/acceptContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses/search`、dialog pinned/manual unread/peer settings、`messages.*DialogFilter*` 与 `folders.editPeerFolders` 均真实落库;`contacts` 保存每个 owner 对同一 user 的独立姓名/电话/备注,`dialogs` 保存置顶顺序、manual unread、action bar 隐藏态与 archive folder_id,自定义 filters 独立存 `dialog_filters`。 -> username 生命周期已补齐:注册仍按 Telegram 语义只创建手机号/姓名账号,后续通过 `account.checkUsername/updateUsername` 设置或清除主 username;PG 以 `lower(username)` partial unique index 防并发占用,`contacts.resolveUsername/resolvePhone` 可用于 TDesktop 搜索/链接解析。 -> 注册资料闭环已补齐:TDesktop 手机号验证码后进入 sign-up 页,`auth.signUp` 携带 first_name/last_name 完成建号;主界面 Settings/Profile 的姓名与 bio 变更走 `account.updateProfile`,写入 `users.first_name/last_name/about` 并向其它设备可靠推送 `updateUserName`。 -> 联系人 reset、dialog pinned/order/manual unread/peer settings、dialog filters/order/reload、folder peers 已写入账号级 durable update log,并同事务写入 `dispatch_outbox`;离线设备可通过 `updates.getDifference` 恢复这些状态变化,在线其它端由 outbox worker 可靠投递且排除当前 session。 -> PG 集成测试覆盖“两个 owner 给同一联系人不同备注、改备注 hash 变化、互相关系同步、删除反向 mutual 清理、dialog 列表按当前 owner 备注展示、置顶重排、manual unread、隐藏 peer settings bar、归档 folder 与自定义 filter”。 -> 2026-05-31 TDesktop Debug 客户端已用独立 workdir 启动并连到当前 server,日志证明 key exchange、`auth.bindTempAuthKey`、启动配置 RPC、搜索用户、打开私聊、发送消息、`messages.getDialogFilters` 与自定义 filter 左侧分组展示均可走通;本轮 Settings/Folders 搜索页额外触发的 account/contacts/messages 预取 RPC 已补第一阶段兼容 stub。 -> 2026-05-31 双 TDesktop 在线/离线复测已覆盖私聊 read/edit/delete:在线 read 触发 sender `Seen`,在线 edit/delete 两端同步;Bob 离线时 Alice send+edit,Bob 重启拿到最终文本并可回读,Bob 离线期间 Alice revoke delete,Bob 重启后会话和消息均清除。联调中补齐 `messages.getOutboxReadDate`,并修正 MTProto content-related service request 口径(`ping*` / `get_future_salts` / `msgs_state_req` / `msg_resend_req` / `destroy_*` 需要 odd seq_no 与 msgs_ack),23:51 后复跑日志无新增 bad_msg / NOT_IMPLEMENTED。 -> 2026-06-01 双 TDesktop 在线/离线复测已覆盖超级群普通发送、目标会话 reply、forward 到同一超级群、频道消息编辑与离线恢复:Alice/Bob 双窗口均能实时看到对应 `UpdateNewChannelMessage` / `UpdateEditChannelMessage` 结果;Bob 关闭期间 Alice 发送 channel 消息,Bob 重启后 dialog 未读数=1,打开群后通过恢复路径看到离线消息;server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic。删除、清历史、踢/禁言属于破坏性 UI 操作,需行动时确认后再点客户端确认。 -> 2026-06-02 Computer Use 双 TDesktop 复测已覆盖超级群双向在线发送、成员栏显示、全局搜索命中 channel/supergroup 消息与公开频道广播发帖:Alice/Bob 在 `E2E Super 0307` 中互发 `cu-round-alice-*` / `cu-round-bob-*` 并实时显示,Alice 搜索 Bob 消息返回 `Found 1 message`,`CU Public Search 44238` 频道发 `cu-channel-round-*` 后消息流与左侧预览同步更新;server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,客户端本轮无新增 participant 告警。清空搜索框触发的 `SEARCH_QUERY_EMPTY` 保持可解释。 -> 2026-06-02 09:55 Computer Use 双 TDesktop 复测补充覆盖 reaction/sticker 启动依赖:Debug/Alice 与 DebugBob/Bob 同时打开 `E2E Super 0307`,互发 `cu-stubfix-alice-*` / `cu-stubfix-bob-*` 并在双方消息列表与左侧 preview 可见;打开 emoji 面板触发 `messages.getAvailableReactions` / `messages.getStickerSet` / `messages.getAvailableEffects`,server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前 `log.txt` 无新增 `Unexpected messages.stickerSetNotModified` / participant 告警。右键消息菜单可打开但未显示 reaction 快捷项,真实 reaction sticker animations/custom UI 留后续。 -> 2026-06-02 10:10 Computer Use 双 TDesktop 复测再次覆盖当前主干频道/超级群路径:Debug/Alice 与 DebugBob/Bob 在 `E2E Super 0307` 中双向在线发送 `cu-continue-*`,消息流与左侧 preview 双端可见,`channels.readHistory` 清除未读;Alice 在 `CU Public Search 44238` 发布 `cu-channel-continue-*`,频道消息流和左侧 preview 同步更新。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error;DebugBob 主日志仅有 08:05 前旧 sticker/participant 噪声。 -> 2026-06-02 11:20 Computer Use 双 TDesktop recent reactions 相关冒烟:新 server 应用 migration 0052 后,Debug/Alice 与 DebugBob/Bob 同时连接 `E2E Super 0307`;Bob 发送 `cu-recent-bob-1780370400982`,双方消息流与左侧 preview 可见;Bob 打开 emoji 面板,Emoji/Stickers/GIFs 面板正常渲染。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug/DebugBob 最新日志无新增 API error/Unexpected;本轮 TDesktop 受客户端缓存影响未观察到重新发送 `messages.getRecentReactions`,get/clear 的 hash/notModified/clear 后空列表语义由 `internal/rpc` router 测试覆盖。 -> 2026-06-02 11:36 Computer Use 双 TDesktop top reactions 回归:新 server 应用 migration 0053 后,Debug/Alice 与 DebugBob/Bob 同时连接 `E2E Super 0307`;Bob 发送 `cu-toprx-bob-1780371301984` 后双方消息流与左侧 preview 可见,emoji 面板保持 Emoji/Stickers/GIFs 正常渲染,右键消息菜单可打开。server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic,Debug 当前日志无新增 API error,DebugBob 仅有 08:05 前旧 sticker/participant 噪声;TDesktop 本轮命中本地 reaction 缓存未重新请求 `messages.getTopReactions`,账号 top 排序、catalog 兜底与 hash/notModified 语义由 `internal/rpc` router 测试覆盖。 -> 2026-06-02 媒体闭环已落地:migration `0057_media`(upload_parts/file_blobs/documents/photos/sticker_sets/available_reactions/profile_photos + 消息表 media JSONB 列)/`0058`(appConfig reactions_default)/`0059`(channel photo 列);新增 `upload.saveFilePart/saveBigFilePart/getFile/getFileHashes`、`photos.uploadProfilePhoto/updateProfilePhoto/getUserPhotos/deletePhotos`,本地磁盘 blob backend;`getAvailableReactions/getStickerSet/getAllStickers/getEmojiStickers/getCustomEmojiDocuments` 返回真实 document(启动从 `TELESRV_STICKER_SEED_DIR` 幂等 seed 74 reactions/24 sets/~1.5k docs/~3k blob 索引);`messages.uploadMedia/sendMedia/sendMultiMedia` 支持 photo/document/sticker 主路径(私聊 + channel,复用 pts/box/outbox/在线推送/离线 difference);User/Channel/Full 渲染真实头像。**自动化验证**:`go test ./...`(含 PG 集成测试 167s 全过,覆盖媒体列 + 频道 photo 列扫描)、server 启动 migrate+seed 无错误、`internal/rpc` 新增贴纸/图片 sendMedia 端到端单测全过。**待人工**:双 TDesktop 测私聊/超级群/频道的图片/文件/贴纸消息、reaction 面板真实动画、头像、在线 fanout 与离线 difference,确认日志无新增 `NOT_IMPLEMENTED`/`Unhandled RPC`/`bad_msg`/panic/API error。范围外 poll/todo/scheduled/paid/CDN/sticker 管理(install/uninstall/reorder)/grouped_id 相册聚合继续显式 stub/todo。 -> 2026-06-03 媒体接手审计补充:全量 `go test ./...`(PG/Redis env-gated,169s)通过,`TestSeedMediaFromRealExport -count=1` 确认 `TELESRV_STICKER_SEED_DIR` seed 为 74 reactions / 11 sets(maxRegularSets=2)/ 1355 docs / 2682 blobs。参考 TDesktop `image_location_factory.cpp` 发现 `StickerSet.thumbs` 的可下载 `PhotoSize` 会触发 `inputStickerSetThumb`,而当前导出目录仅有 set_cover `PhotoPathSize` SVG、无可服务 raster blob;已在 seed 与 TL 转换层过滤 sticker set cover 的 downloadable thumb,只保留非下载占位,避免 TDesktop 生成不可满足的 cover 下载请求。另修复 channel/supergroup 仅媒体无 caption 被 `SendChannelMessage` 误判空消息的问题,并新增 PG 集成测试覆盖 channel media `updates.getChannelDifference` 恢复。**仍待人工**:双 TDesktop 按本轮目标完整验证图片/文件/贴纸、头像、reaction 动画下载与在线/离线同步日志。 -> 2026-06-03 头像/资料路径审计补充:Alice 上传用户头像时 TDesktop 额外触发 `account.getDefaultGroupPhotoEmojis`、`account.getConnectedBots`、`stories.getAlbums`;群/频道头像编辑页继续触发 `messages.getEmojiProfilePhotoGroups`。参考 TDesktop 消费点、gotd Layer225 返回类型、参考实现 空集合 handler、参考实现 emoji-categories DAO/connected-bots/custom-emoji 空集合实现后,当前按有界兼容 stub 返回空列表,避免资料页/头像编辑后台 `NOT_IMPLEMENTED` 与 business/stories/custom-emoji 模型扩张。自动化验证:`go test ./internal/compat/tdesktop ./internal/rpc -run "Test(EmojiProfilePhotoGroups|TDesktopStartupRPCsEncode)" -count=1`、`go test ./...`、`TestSeedMediaFromRealExport -count=1` 均通过,migration 状态 `59|f`;双 TDesktop 群/频道头像 UI 回归仍待 Computer Use 恢复后补证据。 -> 2026-06-03 reaction 全路径审计与双 TDesktop 回归:参考 TDesktop `api_reactions_notify_settings.cpp`、`data_message_reactions.cpp`、`api_updates.cpp`,参考实现 reaction 设置/聚合/更新语义,以及 gotd Layer225 `messages.sendReaction` / `updateMessageReactions` / paid/privacy/participant/stories 入口后,补齐私聊 reaction durable event 与账号设置/paid privacy/participant 删除/stories 兼容入口。自动化验证:`go test ./internal/app/updates ./internal/rpc -run "Test(GetState|GetDifference|Record|UpdatesGetState|UpdatesDifferenceIncludesReaction|MessagesSendReactionPrivate)" -count=1`、`go test ./... -count=1` 通过。Computer Use 双端实测仅使用 `patched Telegram.exe -workdir .tdata-alice`(Alice)与 `patched Telegram.exe -workdir .tdata-bob`(Bob):私聊在线 reaction Alice 实时同步;Alice 离线后 Bob 改 reaction,Alice 重启经 `updates.getDifference` 恢复;超级群 `AAAA` 右键 reaction 快捷栏不再空白,在线同步正常;Alice 离线后 Bob 改群消息 reaction,Alice 重启打开群后经 `messages.getHistory` / `updates.getChannelDifference` / `messages.getMessagesReactions` 恢复。最终 server `bin\\telesrv.exe` 监听 2398,日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / panic / internal error。 -> 2026-06-03 unread reaction / 打开历史卡顿修复回归:参考 TDesktop `UnreadThings::requestReactions`、`History::clearUnreadReactionsFor`、`SendAsPeers::request`,参考实现 `readMessageContents/readReactions` 重算 unread reaction 语义,参考实现 send-as/reaction read model 与 gotd Layer225 `channels.readMessageContents` / `messages.getUnreadReactions` / `channels.getSendAs` 后,修复 channel/supergroup 打开会话只读 visible messages、不清作者视角 unread reaction 的问题;并去掉 `channels.getSendAs` handler 内 access_hash 校验 + 后续取 channel 的重复 `GetChannel`。验证:Alice Debug 打开 `AAAA` 后 `channel_dialogs.unread_reactions_count` 从 1 变 0,`channel_message_reactions.unread` 从 true 变 false;重启 Alice 后 `AAAA` 会话列表不再出现红色 unread reaction 图标;DebugBob/Bob 同时启动并打开 `AAAA`,history 正常展开。最新 server 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg`,Alice 打开 `AAAA` 的 `messages.getHistory` 为 10.8ms/5.3ms,Bob 为 34.0ms/15.9ms,`channels.getSendAs` 去重后主路径约 10-27ms(Alice 一条 72ms 属独立预取调用,未伴随 history 慢查询)。 -> 2026-06-03 sticker/reaction 启动资源检查修复:参考 TDesktop `api_hash.cpp` / `data_stickers.cpp` 后,`messages.getAllStickers` / `messages.getEmojiStickers` 的 catalog hash 改为客户端同公式(按 set 顺序仅 `HashUpdate(set.hash)`,跳过 archived/default set),避免 Debug/DebugBob 日志反复出现 `received stickers hash ... while counted hash ...` 并导致重启后 full catalog 失效重拉;`messages.getAvailableReactions` 继续用服务端稳定 hash,但统一到同一 hash helper,命中时返回 `messages.availableReactionsNotModified`。自动化验证:`go test ./internal/rpc -run "TestSticker|TestMessagesGetAllStickers|TestMessagesGetAvailableReactions" -count=1`、`go test ./internal/rpc -count=1`、`go test ./internal/app/files -count=1`、`go test ./... -count=1` 均通过;双 TDesktop Debug/Alice + DebugBob/Bob 连新 server 重启两轮,server 日志出现 `messages.getAvailableReactions/getStickerSet/getStickers` 与一次成功 `upload.getFile`,无 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / `LOCATION_INVALID`,客户端新日志无 `received stickers hash ... counted ...` / `Unexpected messages.stickerSetNotModified`。 -> 2026-06-03 sticker/reaction 历史首开资源冷路径修复:参考 TDesktop `ApiWrap::requestStickerSets`、`Data::Stickers::somethingReceived`、`DocumentMedia::checkStickerSmall` 后确认,打开历史时若 sticker set 处于 `NotLoaded` 或文档缩略图/动画首次解码,客户端会拉完整 `messages.getStickerSet(hash=0)` 并依赖 128px document thumb 先显示。Files service 新增 `object_key→小 blob bytes` LRU(单项 ≤256KB,总 64MB)和完整 sticker set cache,server 启动 `WarmCaches` 从已 seed 的 sticker/reaction 元数据预热 set、document 与可下载缩略图。验证:`go test ./internal/app/files -count=1`、`go test ./internal/rpc -count=1`、`go test ./... -count=1` 均通过;`bin\\telesrv.exe` 启动日志预热 `sticker_sets=24 documents=1554 blobs=2809`;Computer Use 启动 Debug/Alice 与 DebugBob/Bob 并点击含 sticker 历史会话,`messages.getStickerSet` 从冷路径约 18-24ms 降到 0-1.7ms,点击历史 `messages.getHistory` 为 5-16ms,未再触发 `upload.getFile` 冷下载;server/client 日志无新增 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / `LOCATION_INVALID` / `Unexpected messages.stickerSetNotModified`。 -> 2026-06-03 sticker installed-cache 语义修复:继续对照 TDesktop `Storage::Account::writeInstalledStickers` / `Data::ParseStickersSetFlags` 后确认,`installed_date` 会让 set 进入 Installed;普通 installed stickers 集若仍处于 `NotLoaded`,客户端会中止本地 installed stickers 写入。telesrv seed 过去把 `InputStickerSetAnimatedEmoji` / dice / generic animations 等 system set 也标成 installed,导致系统资源混入普通 installed stickers 缓存路径。现 migration `0066_system_sticker_sets_not_installed` 修复既有库,并改 seed:`set_kind=system` 仅作为按 input 系统 key 解析的内置资源,不再声明为 installed。 -> 2026-06-03 sticker 静态缩略图元数据修复:继续读 TDesktop `history_view_sticker.cpp` / `data_document_media.cpp` / `data_cloud_file.cpp` 后确认,历史页若看到 document `PhotoPathSize` 会把它当成 vector placeholder,`dataMediaCreated()` 因 `thumbnailPath()` 非空跳过 `thumbnailWanted()`,从而挡住同时存在的真实 raster thumb。seed 现将 ≤32KB 可下载 document thumb 转为 `PhotoCachedSize` 并写入 image cache 所需 bytes;当 document 已有 raster/default/cached/progressive thumb 时丢弃 `PhotoPathSize`,仅没有 raster 的 set cover 继续保留 path 占位;thumb blob MIME 由字节魔数写入,`upload.getFile` 也优先按魔数返回 `storage.fileWebp`,兼容旧库误标 `image/jpeg`。验证:`go test ./internal/app/files -run "TestSeedMediaFromRealExport|TestSeedPreferRasterDocumentThumbsDropsPathWhenRasterExists|TestDocumentsNeedInlineCachedThumbsDetectsPathWithRaster|TestDocumentsNeedInlineCachedThumbsDetectsStaleMime" -count=1`、`go test ./internal/rpc -run "TestStorageFileType" -count=1`、`go test ./... -count=1` 通过;新 server 启动 repair 后 PG `documents.thumbs` kind 分布为 `cached=2313`,两条样例 `5415908822013185960/5381935901284774004` 均仅 `cached/m`,`file_blobs` document thumb MIME 为 `image/webp=2313`;服务进程 PID 53892 监听 2398。 -> 2026-06-03 sticker 文档身份修复:继续对比 TDesktop `data_session.cpp` / `data_document.cpp` / `history_view_sticker.cpp` 后确认,TDesktop 以 `document_id` 复用 `DocumentData`,且 `DocumentData::updateThumbnails()` 不会清掉旧的 inline/path thumbnail 状态;一旦服务端把外部导出 document id 当成本服资源主键,旧 Debug tdata 中同 id 的污染对象会持续影响历史页渲染。telesrv 现把导出 JSON/文件名中的 document id 只作为 seed source id,在 `internal/app/files` 导入阶段归一为 telesrv-owned storage id;RPC、`InputDocument`、`inputDocumentFileLocation`、`getCustomEmojiDocuments`、channel custom emoji status/reaction/color 均直接使用同一个服务端 document id,不再做边界映射。migration `0067_seed_document_id_namespace` 同步修复既有开发库中的 documents/file_blobs/sticker_sets/available_reactions/message media/channel appearance 引用。document thumb 在 store 中可保留 cached bytes,但 RPC 对 document 统一暴露 downloadable `photoSize m`,避免 `PhotoCachedSize` 与本地旧 cache 组合出不可替换状态。验证:`go test ./internal/app/files -run TestSeedMediaFromRealExport -count=1 -v`、`go test ./... -count=1` 通过;server `bin\\telesrv.exe` 监听 2398,migration 状态 `67|false`,PG `documents/file_blobs/message media/available_reactions/sticker_sets` 均无 `>4e18` 外部 document id;Computer Use 重启 Debug/Alice 与 DebugBob/Bob 后分别打开 Bob B/Alice A,250ms 截图已显示 sticker,server 与两端客户端日志无 `NOT_IMPLEMENTED` / `Unhandled RPC` / `bad_msg` / `LOCATION_INVALID` / `API Error` / sticker hash 异常。 -> 2026-06-07 sticker 静态图慢复盘 / 撤销 a.2 的 path 丢弃:再读 TDesktop `history_view_sticker.cpp`(`draw→paintPixmap→paintPath` 占位优先级,`thumbnailInline` 内联分支被源码注释禁用)与 `data_document_media.cpp`(`checkStickerLarge→automaticLoad` 下载完整 `.tgs`,与 path/thumbnail 无关)后确认:上方 2026-06-03「sticker 静态缩略图元数据修复」丢弃 `PhotoPathSize` 是误判(真根因是同日 document id 污染,已由 `0067` 修复);id 修复后再丢 path 反而使 animated sticker 失去唯一即时占位 ⇒ 打开会话先空白,并因 `thumbnailPath()` 变空多触发一次缩略图 `getFile`,与完整 `.tgs` 争用下载通道 ⇒「静态图特别慢、不像本地加载」。现 `internal/app/files/seed.go` 恢复 `PhotoPathSize` 占位(删 `seedPreferRasterDocumentThumbs` 及 path+raster stale 判定),document.thumbs = path + downloadable `photoSize m`。已实测排除磁盘 IO/缓存容量(blob 4330 文件 71.3MB,几乎全 ≤64KB)与 `document.Size` 不匹配(28/28 一致)。验证:`go test ./internal/app/files ./internal/rpc -count=1`、`TestSeedMediaFromRealExport -count=1`(真实导出 74 reactions/11 sets/1355 docs/2710 blobs,断言 seed 后 document 保留 path)、`go vet` 通过。**现有库需重新 seed 一次**:`DELETE FROM sticker_sets; DELETE FROM available_reactions;` 重启触发全量 reseed(`PutDocument`/`PutFileBlob` upsert,不删用户上传媒体)。待双 TDesktop 人工验证渲染体感。 - -## Transport / MTProto 服务消息 - -| 消息 | status | behavior | note | -|---|---|---|---| -| transport codec 协商 | done | real | intermediate/abridged/full 自动探测 | -| TCP obfuscation | done | real | TDesktop tcpo_only:先解 64 字节 obfuscated 前缀,再探测 abridged codec | -| TDesktop reconnect fake req_pq | done | real | 既有 auth key 重连时先 fake req_pq 再发加密帧;server 回 resPQ 后 replay 后续加密帧,不断开 session | -| 密钥交换 (req_pq…dh_gen_ok) | done | real | exchange.Server,auth key 落 AuthKeyStore | -| 加密消息收发 | done | real | crypto.ServerCipher + container/gzip | -| new_session_created | done | real | 连接首个加密消息后发送 | -| msgs_ack | done | real | content-related 消息统一确认;口径对齐 TDesktop `SerializedRequest::needAck()`,`ping*` / `get_future_salts` / `msgs_state_req` / `msg_resend_req` / `destroy_*` 都按 content-related 处理 | -| msgs_state_req | done | real-minimal | 连接层按当前连接已见 client msg_id 返回 msgs_state_info,并作为 content-related request 回 ack,避免 TDesktop 重连状态探测落到 RPC fallback;完整 out queue 状态留后续 session 状态机扩展 | -| msg_resend_req | done | real-minimal | 连接层按当前连接已见 client msg_id 兜底回复 msgs_state_info,并作为 content-related request 回 ack;后续完整 outgoing queue 后再补真实重发 | -| rpc_drop_answer | done | real-minimal | 连接层以 rpc_result 包装 rpc_answer_unknown,避免清理请求落到业务 RPC fallback | -| destroy_session | done | real-minimal | 返回 destroy_session_ok/none;命中非当前活跃 session 时清理运行态索引与 SessionStore 记录 | -| http_wait | done | real-minimal | TCP/container 场景下解码后吞掉,不加入 msgs_ack;HTTP long-poll 语义第一阶段不启用 | -| destroy_auth_key | done | real-minimal | TDesktop 清理旧 auth key 时连接层直接返回 destroy_auth_key_ok,避免落到业务 RPC fallback;物理删除 auth key 留后续安全清理任务 | -| active session update gate | done | real | 在线 session 先注册但不接收业务 updates;`updates.getState/getDifference` 后才放开,期间主动推送暂存,语义对齐 参考实现 session 的 canSync | -| ping / ping_delay_disconnect | done | real | 回 pong,并作为 content-related request 回 msgs_ack;TDesktop 保活 odd seq_no 不再触发 bad_msg code 34 | -| get_future_salts | done | real | 返回当前 auth key 的权威 server_salt 有效窗口,并作为 content-related request 回 ack | -| bad_msg_notification | done | real | msg_id==0 等非法情形 | -| bad_server_salt | done | real | 客户端带错 salt 时返回 error_code=48 与当前权威 salt | - -## Wrapper - -| 方法 | status | behavior | note | -|---|---|---|---| -| invokeWithLayer | done | unwrap | 注入 layer 到 RPC ctx | -| initConnection | done | unwrap | 注入设备/应用信息到 RPC ctx | -| invokeWithoutUpdates | done | unwrap | 透传内层 query | - -## Boot / Config - -| method | status | behavior | note | -|---|---|---|---| -| help.getConfig | done | real | 返回含自建 DC 的 DCOptions | -| help.getNearestDc | done | real | 返回自建 DC | -| help.getAppConfig | done | real | PG-backed app_configs;默认 seed 包含 TDesktop read mark config:`chat_read_mark_size_threshold=50`、`chat_read_mark_expire_period=604800`、quote reply config:`quote_length_max=1024`,以及 anti-spam UI config:`telegram_antispam_group_size_min=200`、`telegram_antispam_user_id=5434988373` | -| help.getCountriesList | done | real | PG-backed countries/country_codes;默认 seed US/CN | -| help.getTimezonesList | stub | small-list | TDesktop Business hours 预加载入口;返回非空常用时区列表和 hash/notModified,避免客户端周期性 `NOT_IMPLEMENTED` | -| help.getPeerColors | stub | notModified | TDesktop 主界面加载 peer 色板;第一阶段无服务端色板 | -| help.getPeerProfileColors | stub | notModified | TDesktop 主界面加载 profile 色板;第一阶段无服务端色板 | -| help.getPromoData | stub | empty | 无 PSA/MTProxy 推广,返回空并设置短期 expires | -| help.getTermsOfServiceUpdate | stub | empty | 第一阶段无 TOS 更新 | -| help.getPremiumPromo | stub | empty | 第一阶段不做 Premium 展示数据 | -| help.getInviteText | stub | default-text | DrKLO Android 主界面预取邀请文案;当前不接配置中心 invite text,返回默认短文避免启动后 NOT_IMPLEMENTED | - -## Payments / Stars - -| method | status | behavior | note | -|---|---|---|---| -| payments.getStarsStatus | stub | zero-balance | DrKLO Android 消息页会后台预取 stars/TON 余额;当前不实现 Stars 商业模型,按请求类型返回零余额 `payments.starsStatus`,history/chats/users 为空,避免发送后 NOT_IMPLEMENTED | - -## Auth - -| method | status | behavior | note | -|---|---|---|---| -| auth.bindTempAuthKey | done | real-validated | 校验 encrypted bind_auth_key_inner(nonce/temp key/perm key/session/expires_at)后记录 temp_auth_key_bindings;后续 temp auth_key RPC 会解析为 perm auth_key 身份,并刷新 active session 的业务 auth_key_id / user_id 缓存 | -| auth.exportLoginToken / auth.importLoginToken | stub | tdesktop-qr-placeholder | TDesktop QR 登录页轮询;当前单 DC 主路径只支持手机号登录,返回短期 auth.loginToken 避免未实现噪声;`auth.acceptLoginToken` 显式返回 `AUTH_TOKEN_INVALID`,不伪造跨设备 QR 授权 | -| auth.exportAuthorization / auth.importAuthorization / auth.dropTempAuthKeys | stub | single-dc-bounded | 单 DC 当前不做跨 DC authorization handoff;export/import 返回 `DC_ID_INVALID`,dropTempAuthKeys 返回成功避免旧 temp key 清理路径噪声 | -| auth.initPasskeyLogin | stub | empty-options | DrKLO Android 手机号页会后台预取 passkey 登录;当前不接 passkey,返回空 `auth.passkeyLoginOptions` 避免反复 NOT_IMPLEMENTED | -| auth.sendCode / auth.resendCode / auth.cancelCode | done | dev-code | 开发固定验证码,phone_code_hash 存 Redis CodeStore;手机号统一规范为纯数字;resend 会校验并失效旧 hash 后生成新 hash,cancel 会删除 pending code | -| auth.signIn | done | real+2fa-gate | 校验码;兼容 TDesktop sendCode 后 signIn 使用 DigitsOnly(phone);无 2FA 时登录成功绑定在线 session 的 user_id,写入并延迟推送 777000 官方登录消息 updateNewMessage,同时向其它在线 session 推送新登录 updateServiceNotification;账号已启用 2FA 时返回 `SESSION_PASSWORD_NEEDED` 并等待 `auth.checkPassword` | -| auth.signUp | done | real | 建 user + 绑定 authorization;登录成功绑定在线 session 的 user_id,写入并延迟推送 777000 官方登录消息 updateNewMessage | -| auth.logOut | done | real | 解绑当前 auth_key 的授权,并清理同业务 auth_key 的活跃连接 user_id 缓存与 auth_key+user update state;账号级 user_update_events 不随设备退出删除,退出后同设备换号不会继承旧账号差分 | -| auth.resetAuthorizations | done | real | 删除当前账号除当前 auth_key 外的其它授权,清理对应 session/auth cache 与设备 update state | -| auth.checkPassword / auth.requestPasswordRecovery / auth.checkRecoveryPassword / auth.recoverPassword | done | real-srp-dev-email | 按 Telegram SRP KDF 校验 `inputCheckPasswordSRP`;恢复邮箱只保存/返回 pattern,开发环境使用固定恢复码 `12345`,未接真实邮件网关;recover 可清除或重设 2FA | -| account.checkUsername | done | real | username 规则为 ASCII 字母开头、后续字母/数字/下划线、5-32 字符;大小写不敏感检查占用,已被本人占用视为可用 | -| account.updateUsername | done | real | 设置或清除当前账号主 username;大小写不敏感唯一约束由 PG partial unique index 兜底,并向其它在线 session 推送 updateUserName | -| account.updateProfile | done | real | 更新当前账号 first_name/last_name/about;first_name 必填且姓名 64 字符内,about 70 字符内;资料变化后向其它在线 session 推送 updateUserName,`users.getFullUser` 返回 about | -| account.getPassword / account.getPasswordSettings / account.updatePasswordSettings | done | real-srp | PG-backed `account_passwords`;返回 Layer225 `passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow` 参数;支持设置/修改/清除 2FA、hint、恢复邮箱、SRP challenge 轮换与错误映射 | -| account.confirmPasswordEmail / account.resendPasswordEmail / account.cancelPasswordEmail | done | dev-email-code | 开发环境恢复邮箱码固定为 `12345`;confirm/resend/cancel 走持久 recovery code 状态,未接真实邮件网关 | -| account.resetPassword / account.declinePasswordReset | done | real-wait-state | 无恢复邮箱的 2FA 账号可进入 7 天 reset wait,等待期到期再次调用清除 2FA;decline 清除 pending reset;有恢复邮箱时返回 `PASSWORD_RECOVERY_NA` 让客户端走邮箱恢复 | -| account.getAccountTTL | stub | default-365d | TDesktop Settings/self-destruct 预取;当前不做账号自动销毁持久配置,返回正数默认 TTL 避免后台 NOT_IMPLEMENTED | -| account.setAccountTTL | stub | ack | 当前不持久化账号自动销毁 TTL,接受写入并返回成功 | -| account.getNotifySettings | stub | default | TDesktop 主界面读取通知设置;显式返回 show_previews=true、silent=false、mute_until=0 与 default sound,避免空 settings 被客户端按静默展示 | -| account.updateNotifySettings / account.resetNotifySettings / account.getNotifyExceptions | stub | ok-empty | 当前不持久化通知偏好/例外,但接受 TDesktop 设置写入并返回空 updates,避免本地通知状态 RPC 报错 | -| account.getPrivacy | done | real-privacy | 按 owner+key 读取 `account_privacy_rules`;无记录时默认 PhoneNumber=DisallowAll、Birthday=AllowContacts、其它=AllowAll;支持 Layer225 全部 privacy key/rule kind,users/chats 按规则回填 | -| account.setPrivacy | done | real-privacy/online-update | 校验 `InputPrivacyKey/InputPrivacyRule` 与 input user access_hash 后持久化;返回 `account.privacyRules` 并向当前账号在线 session 推 `updatePrivacy`;Stars/Premium/Bot 等高级产品只保存规则,不扩展商业模型。离线 `updates.getDifference` 恢复 `updatePrivacy` 仍需扩展 `user_update_events` privacy payload | -| account.getAuthorizations / account.resetAuthorization | done | real-devices | 从 `authorizations` 返回当前账号设备列表,带 current/hash/device/app/ip/date;resetAuthorization 按 hash 删除同账号授权并清理 session/auth cache | -| account.getWebAuthorizations / account.resetWebAuthorization / account.resetWebAuthorizations | stub | empty/ack | 当前无 Telegram Login Widget/Web 授权模型,返回空列表并接受清理请求 | -| account.registerDevice / account.unregisterDevice | stub | ack+legacy-adapter | 当前未接真实 APNS/FCM 推送网关,接受 TDesktop/DrKLO 设备 token 注册/注销避免后台噪声;DrKLO Android 仍会发 legacy `account.registerDevice#637ea878 token_type:int token:string`,RPC edge 解码后返回 true | -| account.getDefaultEmojiStatuses / account.getRecentEmojiStatuses / account.clearRecentEmojiStatuses / account.updateEmojiStatus | stub | empty/notModified | 当前不提供云 emoji status;读取返回空或 notModified,写入/清理返回成功 | -| account.getCollectibleEmojiStatuses | stub | empty | TDesktop 启动/emoji status 面板会刷新 collectible gift emoji statuses;当前无 gift/status 模型,返回空 `account.emojiStatuses` | -| account.getDefaultProfilePhotoEmojis / account.getDefaultGroupPhotoEmojis / account.getDefaultBackgroundEmojis / account.getChannelDefaultEmojiStatuses / account.getChannelRestrictedStatusEmojis | stub | empty | TDesktop 头像/背景/频道状态编辑页默认 emoji 入口;当前无 custom-emoji 候选,返回空 `emojiList` 或空 statuses | -| account.getConnectedBots | stub | empty | TDesktop business chatbot 资料预取;当前无 business bot 模型,返回空 `account.connectedBots`,避免客户端 API error | -| account.getReactionsNotifySettings / account.setReactionsNotifySettings | done | real-account-settings | 持久化账号 reaction 通知范围(none/contacts/all)与 show_previews,返回默认提示音;无记录时默认 contacts + previews | -| account.getContactSignUpNotification / account.setContactSignUpNotification | stub | false/ack | 当前不推送联系人注册提醒,读取 false、写入接受 | -| account.getThemes | stub | notModified | Settings/theme 预取,第一阶段不提供云主题 | -| account.getContentSettings / account.setContentSettings | stub | default/ack | Settings 内容敏感项预取,当前不启用 NSFW 内容配置,写入接受 | -| account.getGlobalPrivacySettings / account.setGlobalPrivacySettings | stub | default/echo | Settings 全局隐私项预取,返回空默认设置;写入回显,不扩展全局 privacy 模型 | -| account.getPasskeys | stub | empty | Settings 安全项预取,第一阶段不提供 passkeys | -| account.getAutoDownloadSettings / account.saveAutoDownloadSettings | stub | defaults/ack | TDesktop 自动下载设置预取;返回保守默认下载阈值,写入接受但不持久化 | -| account.setAuthorizationTTL / account.changeAuthorizationSettings | stub | ack | Settings 设备管理长尾写入入口;当前只维护 active authorization 删除,不持久化授权 TTL/设备名设置 | -| account.getSavedMusicIds | stub | empty | TDesktop saved/profile music 预取;当前没有 profile music media store,返回空 id vector | -| account.getSavedRingtones | stub | empty | DrKLO Android 主界面通知音预取;当前无 saved ringtone store,返回空 `account.savedRingtones` | -| account.updateStatus | done | persisted-presence | 记录 `auth_key/session/user` 维度运行时在线状态并持久化 `users.last_seen_at`;`offline=false` 与重连后 session 身份恢复都会写入 last_seen 并推送 `updateUserStatus(userStatusOnline expires=now+5m)`,`offline=true` 写入精确 `userStatusOffline.was_online`;最后一个 MTProto session 断开/destroy_session 也会写 last_seen 并推送 offline。状态推送给当前用户其它在线 session、在线联系人、以及已有私聊 dialog 的在线对端;session 恢复时还会向当前 session 补发在线联系人/私聊对端状态。users/dialog/history projection 已按 StatusTimestamp privacy 裁剪返回值;presence fanout 的精细 privacy 过滤仍为后续项 | - -## Updates - -| method | status | behavior | note | -|---|---|---|---| -| updates.getState | done | real | auth_key+user 维度持久化 update_states;账号级当前 pts 报告**最大连续已提交 pts**(MaxContiguousPts,非 allocator 最大已分配值),避免同设备换号/多账号串差分,也避免越过在途空洞 | -| updates.getDifference | done | real+legacy-adapter | 按 user_id 从 user_update_events 拉取 pts 后增量;只返回从客户端 pts 起**连续**的事件(遇在途空洞即截断),超 100 条置 differenceSlice;支持 new_message、read_history_inbox/read_history_outbox(私聊与 channel peer,channel read 映射 updateReadChannelInbox)、edit_message、message_reactions(输出带最新 `message.reactions` 的 affected message + `updateMessageReactions`,并按 viewer 重取最新聚合,避免 TDesktop 离线恢复时本地 message cache 不刷新)、read_message_contents、delete_messages、contacts_reset、dialog pinned/order/manual unread、peer_settings、dialog filters/folder peers 与 noop gap;消息事件携带 fwd/reply 所需 users/chats,payload 来自 durable log。若请求 date 后存在当前账号 active channel 的 channel durable events,会追加计算型 `updateChannelTooLong(channel_id,pts)` nudge,提示 TDesktop 再走 `updates.getChannelDifference`;该 nudge 不写 `user_update_events`,不消耗账号 pts。DrKLO Android 仍会发 legacy `#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int`,RPC edge 映射到当前 Layer225 request 后复用本实现。**账号级绝不返回 `differenceTooLong`**:已核对 TDesktop 基线 `api_updates.cpp:516`——对账号级 differenceTooLong 只打一行日志、不读 pts 且漏 `setRequesting(false)`,会永久锁死 update 引擎(`:689` 早退,重连/新 session 不可恢复);落后客户端改用 `differenceSlice` 续传。因此 `user_update_events` **永久保留、不做 retention 裁剪**(对齐 参考实现),详见 docs/performance-audit.md 附录 C | -| updates.getChannelDifference | done | real-channel | 超级群/频道使用 channel 维度 durable log:`channel_update_events(channel_id, pts, pts_count, ...)`;按 channel pts 返回 `channelDifferenceEmpty/channelDifference/channelDifferenceTooLong`,limit cap=100;`pts < 0` 或 `pts > current_channel_pts` 返回 `PERSISTENT_TIMESTAMP_INVALID`;当前 member 的 `available_min_pts` 会抬高请求 pts,避免新加入/重新加入成员拉到入群前消息类 durable 事件;公共 username 频道允许非成员以只读预览身份拉可见差分并返回 synthetic read dialog,私有频道和禁看用户仍返回权限错误;当 `current_channel_pts-pts > cap` 时返回带当前 dialog pts 和最新有界消息快照的 `channelDifferenceTooLong`,避免大频道旧 pts 客户端循环拉大量页;普通 difference 优先使用事件 payload 中的 message 快照,连续编辑/删除后不会被当前消息状态污染;`updateChannelParticipant` 不进入 channel pts log,成员权限/封禁状态靠在线 `updateChannelParticipant/updateChannel` 与 `channels.getFullChannel/getParticipants/getParticipant` 刷新;本页消息的 sender/send_as/fwd_from/reply_to/action peers 会随 users/chats 返回,禁止复用 user_update_events | - -### 兼容硬约束:pts / pts_count(跨所有产生 update 的 RPC) - -> 已核对 TDesktop 基线源码(`data_pts_waiter.cpp` / `.h`、`api_updates.cpp`)。详细推导见 docs/message-module.md「客户端 pts 重排依赖 pts_count 准确」。 - -outbox 多 worker 并发 + 发送事务乱序提交 → **主动推送可能乱序到达客户端**(pts=6 先于 pts=5)。客户端 `PtsWaiter` 靠 `_count += pts_count` 累加判连续:乱序的先缓存、等空缺补齐再按序应用,1 秒补不齐才 `getDifference`。因此**任何产生 update 的 RPC 都必须遵守**: - -1. **每条 update 的 `pts_count` 必须准确等于它推进的 pts 步数**。私聊文本、已读 inbox/outbox、文本编辑均为 1;批量删除为本次删除的 owner 视角 message_id 数量。 -2. **每个分配出去的 pts 最终都要能被 getDifference 拿到**——事务回滚也要写 `noop` 占位(`recordPtsGaps`),否则连续水位永久卡死、客户端永久 gap。 - -当前私聊文本、转发、已读回执、文本编辑与删除消息满足两条(new_message/forward/read/edit pts_count=1,delete_messages pts_count=len(message_ids),noop 补洞,连续水位兜底)。**新增任何其它 `pts_count ≠ 1` 的场景(批量 service action、频道 editMessage 等)时,必须回到此约束重新核对**,否则客户端 `_count` 永久错位。 - -## Dialogs / Messages - -| method | status | behavior | note | -|---|---|---|---| -| messages.getDialogFilters | done | real | 返回 dialogFilterDefault + 当前账号持久化自定义 filters,并带 tags_enabled;离线设备启动可重拉完整分组状态 | -| messages.getSuggestedDialogFilters | stub | empty | 第一阶段不提供推荐文件夹 | -| messages.updateDialogFilter | done | real | 持久化/删除当前账号自定义 filter,校验 filter_id/title/peer 数量;channel peer 必须校验非零 access_hash;写 durable updateDialogFilter + dispatch_outbox | -| messages.updateDialogFiltersOrder | done | real | 持久化自定义 filter 顺序,去重并忽略默认/归档保留 ID,写 durable updateDialogFilterOrder + dispatch_outbox | -| messages.toggleDialogFilterTags | done | real | 持久化 folder tags 开关,写 durable updateDialogFilters reload + dispatch_outbox | -| folders.editPeerFolders | done | real | 支持 folder_id=0/1 的归档/还原;user peer 更新 dialogs.folder_id,channel peer 更新 channel_dialogs.folder_id;返回/记录 updateFolderPeers(pts,pts_count=1),可靠投递其它 session | -| messages.getDialogs | done | real | 从按 user_id HASH 分区的 dialogs/message_boxes/users 查询当前账号私聊会话,并合并 channel_dialogs/channels/channel_messages;返回 users 统一走 viewer user projection(联系人名/手机号可见性 + 当前 `profile_photos` 头像),避免 TDesktop dialog 列表退回字母头像或手机号标题;支持 exclude_pinned、folder_id(0 主列表/1 归档/2+自定义 filter)、offset_date/offset_id/offset_peer seek pagination、limit、hash notModified;channel offset_peer 校验非零 access_hash;channel dialog 返回持久 `read_outbox_max_id`,可恢复离线/丢失的 `updateReadChannelOutbox`;不会把返回的 channel 宽泛标记为 active viewer,避免 typing/reaction 等瞬时事件误推给全体 dialog 列表;登录后可见 777000 官方系统会话 | -| messages.getPinnedDialogs | done | real | 从 dialogs + channel_dialogs 合并查询 pinned 会话,返回 top messages/users/channels,并附持久化 update state | -| messages.getPeerDialogs | done | real | 按 InputDialogPeer 精确查询当前账号会话,返回 dialog/top message/users/update state;channel peer 同样返回持久 `read_outbox_max_id`;公开 username channel/supergroup 对非成员返回只读 preview dialog/top message,private/ban/kick/view_messages 不暴露;未建会话的 user peer 返回空 dialog 占位;peer vector cap=100 | -| messages.getPeerSettings | done | real | 当前 owner 视角 peerSettings:非联系人显示 add/block;当前 owner 已保存对方但双方未 mutual 时显示 shareContact;acceptContact/add/import 形成 mutual 后清除 shareContact;若该 owner 已 hide peer settings bar 则返回空 action bar;附带当前 owner 视角用户资料 | -| messages.getMessages | done | real-private | TDesktop reply/webpage/media lazy load 入口;当前按 owner 视角 `InputMessageID` 精确拉取私聊 message_box,ID 不存在返回 messageEmpty,单次最多 100 个 | -| messages.getHistory | done | real | 从按 owner_user_id HASH 分区的 message_boxes 按当前账号+peer 查询历史;支持 offset_id、offset_date、add_offset、limit、max_id、min_id、hash,查询条件走分区索引与 seek pagination;`add_offset` clamp 到 `[-100,100]`,避免异常客户端触发超大跳过扫描或内存分配 | -| messages.readHistory | done | real-partial | 标记 reader dialog 的 read_inbox_max_id/unread_count 并清 manual unread;真正发生已读时给 reader 生成 updateReadHistoryInbox,同时推进 sender dialog 的 read_outbox_max_id 并生成 updateReadHistoryOutbox;发送消息时不预先推进 read_outbox,避免假已读 | -| messages.getOutboxReadDate | done | real-private | TDesktop 已读详情入口;按当前 owner+peer+msg_id 校验 outgoing message,读取最早覆盖该 msg_id 的 `read_history_outbox` durable event 日期,无读回执时返回 `MESSAGE_NOT_READ_YET` | -| messages.getMessageReadParticipants | done | real-channel-small | TDesktop 小群已读详情入口;仅 channel/megagroup peer,校验消息存在与可见历史,按 `channel_members.read_inbox_max_id >= msg_id AND read_inbox_date > 0` 返回最多 50 个 `readParticipantDate`,并用 `read_inbox_date` 给出已读时间;初始 join/invite read 水位只用于 unread 基线,不会把新成员伪造成旧消息读者;broadcast/超阈值/过期返回空 | -| messages.getMessageEditData | done | real-text-only | 参考实现/参考实现 的编辑前校验语义:按 peer/msg_id 校验消息存在与作者/管理员 edit_messages 权限;当前只支持文本消息编辑,没有媒体 caption 状态,因此返回 `messages.messageEditData{caption=false}` | -| messages.getWebPagePreview | stub | empty-preview | TDesktop 输入框链接预览入口;参考实现 空预览与 参考实现 空文本校验,`message` trim 后为空返回 `MESSAGE_EMPTY`,entities/text 有界,返回 `messages.webPagePreview{media=messageMediaEmpty}`,不抓取外网、不落缓存 | -| messages.uploadMedia / messages.sendMedia / messages.sendMultiMedia | done | photo/document/sticker | 接入 files/media 存储(documents/photos/file_blobs + 本地 blob backend)与消息 media 快照列;`resolveInputMedia` 解析 `inputMediaUploadedPhoto/Document`(组装上传分片→建 Photo/Document)与 `inputMediaPhoto/Document`(按服务端 document id 引用已存在资源,含贴纸)→ `domain.MessageMedia`,经 sendOutgoing 走与文本相同的 pts/box/outbox/在线推送/离线 difference;私聊与 channel 均支持;`sendMedia(inputMediaEmpty/WebPage)` 仍降级为纯文本 `sendMessage`;`uploadMedia` 返回可复用 `messageMedia`;sendMultiMedia 各条作为独立消息发送(grouped_id 相册聚合留 todo);geo/contact/poll/todo/dice/story 等仍返回 `MEDIA_INVALID`;album cap=10,caption/entities/random_id/peer/access_hash 均校验 | -| messages.readMessageContents | done | real-content-read | TDesktop 普通消息内容已读入口;校验 id cap=100、message_id 范围与当前账号 exact 可见私聊消息;私聊 incoming media 写入 recipient box 时置 `media_unread`,对端 reaction 写入消息作者 box 时置 `reaction_unread` 并重算 dialog unread reaction 计数。事务内只清理实际 unread 的 message boxes;有变化才分配 user pts、写 durable `updateReadMessagesContents` + dispatch_outbox,其它在线 session 与离线 `updates.getDifference` 可恢复;重复调用/不可见 id/已读 id 返回当前 affectedMessages 且 `pts_count=0` | -| messages.getMessagesViews | done | real-channel-views | TDesktop 频道浏览计数入口;channel/supergroup peer 校验 access_hash 与 id cap=100,`increment=true` 时按 `(channel_id,message_id,viewer_user_id)` 去重后递增 `channel_messages.views_count`,按请求顺序返回显式 `views` 与 replies/comment context;不存在、删除或本地清历史前不可见的 id 返回空 `messageViews`;forwards 计数与 forwarded-channel source views 透传仍待媒体/转发统计模型补齐 | -| messages.getUnreadMentions | done | real-channel-mentions | Channel/supergroup peer 维护 `channel_unread_mentions(user_id,channel_id,message_id,media_unread)` 独立索引;sendMessage 解析 mention-name entity 与 `@username`,写入 active 且可见成员;history/getMessages/getChannelDifference/online update 按 viewer 回填 `message.mentioned/media_unread`,readMentions 后同一 viewer 不再带 flag;查询按 user+channel+top_msg_id+message_id seek,limit cap=100,返回 channel context | -| messages.readMentions | done | real-channel-mentions | Channel/supergroup peer 按 top_msg_id 有界清除 unread mention,单次最多 1000 条,重算 `channel_dialogs.unread_mentions_count`;返回 current channel pts/offset,供 TDesktop channel PtsWaiter 消费 | -| messages.reportSpam / messages.report | stub | reported-bounded | TDesktop peer bar/消息举报入口;校验 peer/access_hash/message id/option/comment 上限,不落 report 表;`report` 空 option 返回举报原因,`other` 返回 addComment,其余合法 option 返回 reported,非法 option 返回 `OPTION_INVALID` | -| messages.reportReaction / messages.reportMessagesDelivery / messages.reportReadMetrics / messages.reportMusicListen / messages.reportSponsoredMessage | stub | telemetry-noop | TDesktop 反应举报、Gateway 送达、阅读指标、音乐播放、广告举报入口;校验 peer/access_hash/id vector/metrics/document/duration/random_id 上限后返回 BoolTrue 或 sponsored reported,不落 telemetry/report 表 | -| messages.sendReaction / messages.getMessagesReactions / messages.getMessageReactionsList | done | real-private-channel-emoji | Private peer 支持 emoji reaction 持久化到 `private_message_reactions(private_message_id,user_id,reaction)`,双端 owner-visible message box 共享 reaction 聚合但按 viewer 重算 `my/chosen_order`,`sendReaction` 返回当前端 `updateMessageReactions`,向当前账号其它 session 与对端在线 session 推对应 peer/msg_id update,并写账号级 `message_reactions` durable event;离线设备经 `updates.getDifference` 补带 `message.reactions` 的 message + `updateMessageReactions`;history/getMessages/search 回填 `message.reactions`。Channel/supergroup peer 继续持久化到 `channel_message_reactions`,按当前用户替换/清除本消息 reaction,在线瞬时推送面向当前 active viewers 与反应者其它 session,history/getMessages/getMessagesReactions 可恢复当前聚合,list 按 `(reaction_date,reacted_user_id,reaction_value)` seek 分页,limit cap=100;custom emoji 普通消息 reaction 仍拒绝,paid reaction 走独立入口 | -| messages.setDefaultReaction | done | real-account-default | TDesktop quick/favorite reaction 保存入口;校验 emoji reaction 后持久化账号默认 reaction(默认 👍),供后续启动/设置路径复用 | -| messages.getPaidReactionPrivacy / messages.togglePaidReactionPrivacy / messages.sendPaidReaction | stub | privacy-real/send-balance-low | paid reaction privacy 持久化 default/anonymous/peer,并向当前账号其它 session 推 `updatePaidReactionPrivacy`;当前无 Stars 余额/paid reaction 计费模型,`sendPaidReaction` 校验 peer/msg_id/count/private 后返回 `BALANCE_TOO_LOW`,不伪造付费 reaction 聚合 | -| messages.deleteParticipantReaction / messages.deleteParticipantReactions | done | real-channel-moderation | 群/超级群管理员删除用户 reaction 入口;校验 peer/access_hash、participant user 与 delete_messages 权限,单条返回真实 `updateMessageReactions`,批量按最多 1000 条删除并给在线 active viewers 推受影响消息反应更新,同时刷新消息作者 unread reaction 计数;private peer 当前返回兼容成功/空 updates | -| messages.getUnreadReactions / messages.readReactions | done | real-channel-emoji-read | Channel/supergroup peer 按 `sender_user_id = 当前用户 AND unread` 维护 emoji reaction 未读状态;`sendReaction` 在反应者不是消息作者时写 unread 并重算 `channel_dialogs.unread_reactions_count`,查询按 user+channel+top_msg_id+message_id seek,limit cap=100,返回带 unread recent reaction 的 channel messages;`readReactions` 单次最多清 1000 条并重算 dialog count,返回 current channel pts/offset;TDesktop 打开 channel/supergroup history 发出的 `channels.readMessageContents` 会清理可见消息上的 unread reaction、回推 `updateMessageReactions` 并刷新 dialog reaction 计数;saved_peer/monoforum 与 reaction tag/ranking 仍留后续模型 | -| messages.getRecentReactions / messages.clearRecentReactions | done | real-account-recent | TDesktop reaction 面板后台入口;`messages.sendReaction(add_to_recent)` 会记录当前账号最近使用的私聊/channel/supergroup emoji reaction,limit cap=100,内容 hash 命中返回 notModified,clear 后返回 hash=0 空列表 | -| messages.getTopReactions | done | real-account-top | TDesktop reaction 面板后台入口;`messages.sendReaction` 会按当前账号累计私聊/channel/supergroup emoji reaction 使用次数,limit 0 时按 TDesktop 默认 14、cap=100,内容 hash 命中返回 notModified;账号 top 不足时优先用真实 `available_reactions` catalog(Files 服务缺失时才静态兜底)有界补齐,保证 top/recent/default 的 emoji id 能被 `messages.getAvailableReactions` 解析,避免右键 reaction selector 只有空白占位 | -| messages.getDefaultTagReactions | stub | empty/notModified | TDesktop saved-message tags 默认候选入口;当前无 saved reaction tag/custom-emoji document store,返回空 reactions/notModified;真实 tag ranking 与 custom emoji 候选后续补 | -| messages.getSavedReactionTags / messages.updateSavedReactionTag | done | real-account-title | TDesktop saved messages tag 入口;全局请求返回账号级 emoji saved reaction tag 标题,内容 hash 命中返回 notModified;update 校验 emoji reaction 与 title<=12 后持久化并向其它 session 推 `updateSavedReactionTags`;可选 peer 仍只校验 access_hash 并返回空,saved-message tag assignment/count、custom emoji 与默认 tag 候选留后续模型 | -| messages.getExtendedMedia | stub | empty-updates | TDesktop paid media 可见消息轮询入口;校验 peer/access_hash 与 id cap=100,当前无 paid media/extended media store,返回空 Updates,不伪造 `updateMessageExtendedMedia` | -| messages.sendVote / messages.getPollResults / messages.getPollVotes / messages.addPollAnswer / messages.deletePollAnswer / messages.getUnreadPollVotes / messages.readPollVotes | blocked | poll-store-missing | TDesktop poll UI 会直接调用这些入口;参考实现 都先由 peer+msg_id 找 poll media/poll_id,getPollVotes 有分页上限。当前 media/poll store 未接入,显式注册并校验 peer/access_hash/msg_id/options/offset/limit;read-only 返回空 updates/votes/unread 或 affectedHistory,真实 vote/add/delete 返回 `MESSAGE_ID_INVALID`,避免伪造 poll update;broadcast channel 拉投票人返回 `BROADCAST_FORBIDDEN` | -| messages.appendTodoList / messages.toggleTodoCompleted | blocked | todo-store-missing | Layer225 todo 媒体未接入;参考实现 的 todo item 上限配置与 append 对 `MessageMediaToDo` 的依赖,当前只做 peer/msg_id/list/id/title/entities 有界校验,空变更返回 `TODO_NOT_MODIFIED`,非空变更返回 `MESSAGE_ID_INVALID`,不生成不可恢复的 fake updates | -| messages.getSearchCounters / messages.search(media filters) | stub | zero-counters | TDesktop 搜索面板 counters 返回每个 filter 的 0 count;资料页 shared media count 会发 `messages.search(limit=0, filter=photo/video/document/url/gif/music/roundVoice/poll)`,当前无 media/webpage/poll/document store,显式返回空页/count=0,避免纯文本 channel history 污染 photos/videos/files 等计数;filter cap=32 | -| messages.getSearchResultsCalendar / messages.getSearchResultsPositions | stub | empty-bounded | TDesktop shared media 日历/位置辅助入口;校验 peer/access_hash/saved_peer/offset/limit 后返回空结果,calendar 回填请求 offset date/id,避免空月份重复拉取;真实媒体索引与按日聚合后续补 | -| messages.getReplies | done | real-channel-thread | 支持 channel/supergroup `reply_to_top_id` thread 分页;broadcast post 若已 linked discussion group,会映射到 linked megagroup root 后读取评论;forum topic root 支持 TDesktop `reply_to_msg_id=0/top_msg_id=topicRoot` 发送路径,返回 `messages.channelMessages{pts,topics}` 与 short topic;limit cap=100,add_offset clamp,按 message id seek,hash 命中返回 notModified,响应补齐 source/group chats/users | -| messages.getDiscussionMessage | done | linked-discussion-root | 校验 channel peer/access_hash/msg_id 后返回 thread root、`max_id/read_inbox/read_outbox/unread_count` 与 source/group chats;broadcast linked post 返回 discussion megagroup 中的 forwarded root message,未 linked 时返回当前 channel root/context;forum topic root 已由 `createForumTopic` 落 service message,topic 消息历史由 `messages.getReplies(topicRoot)` 补齐 | -| messages.readDiscussion | done | linked-read-state | 校验 root 后把 broadcast post 映射到 linked discussion megagroup,再推进该 target channel 的 read inbox 水位;不生成 channel pts,账号级 read update/outbox 用现有 channel read 机制补偿其它 session/作者回执 | -| messages.getForumTopics | done | forum-topic-page | 校验 channel peer/q/limit/offset,非 forum 返回 `CHANNEL_FORUM_MISSING`;forum 开启后返回虚拟 `General` topic(id=1) + 持久化 topic store,topic page 按 `(pinned,pinned_order,date,topic_id)` seek,不用 OFFSET;响应带 channel pts、channel context、root service messages 和 creator users | -| messages.getForumTopicsByID | done | forum-topic-lookup | topic id cap=100,非 forum 返回 `CHANNEL_FORUM_MISSING`;id=1 返回虚拟 `General`,其它 id 从 topic store 精确查询并补 root service message/context | -| messages.createForumTopic | done | root-service-message | 校验 peer/access_hash、forum megagroup、title/random_id/send_as;创建 `messageActionTopicCreate` channel service message,topic_id 使用 root message id,写入 `channel_forum_topics` 分区表,返回 `updateMessageID + updateNewChannelMessage` 并在线推送其它成员;重复 random_id 幂等返回既有 root | -| messages.editForumTopic | done | topic-edit-service | 校验 peer/access_hash、forum topic、title/icon/closed/hidden flags;topic creator 或具备 pin/manage 能力的成员可改;写 `messageActionTopicEdit` service message,reply_to_top_id 指向 topic root,更新 topic read model 并返回/推送 `updateNewChannelMessage` | -| messages.updatePinnedForumTopic / messages.reorderPinnedForumTopics | done | pinned-topic-updates | 校验 topic id/order cap=100 和 pin 权限;更新 `channel_forum_topics.pinned/pinned_order`,返回/在线推送 `updatePinnedForumTopic` 或 `updatePinnedForumTopics(order)`;该 TL update 本身不带 pts,离线重开通过 getForumTopics 补状态 | -| messages.deleteTopicHistory | done | bounded-topic-delete | 校验 forum topic 与权限;每次最多删除 `MaxDeleteHistoryBatch` 条 topic root/reply_to_top_id 消息,返回 `messages.affectedHistory.offset` 供 TDesktop 续删;最后一页才从 topic store 隐藏 topic,避免对十几万消息一次性生成 update 或 id vector | -| messages.getOnlines | done | bounded-online-members | 超级群/频道在线人数入口;校验 peer/access_hash 后用 `SessionManager.OnlineUserIDs(limit<=500)` 与 active channel member 做有界交集,caller 自动加入候选避免本连接快照遗漏;无在线 provider 时保留 参考实现 兼容 `onlines=1` | -| messages.editMessage | done | real-partial | 当前阶段支持私聊文本消息编辑:仅原发送者可编辑自己的 outgoing message,更新 shared private_messages 与所有可见 owner message_boxes,生成 updateEditMessage(pts_count=1) 并可靠投递;`inputMediaWebPage/inputMediaEmpty` 降级为文本编辑,真实 media 返回 `MEDIA_INVALID`,reply_markup 返回 `REPLY_MARKUP_INVALID`,quick replies 返回 `MESSAGE_ID_INVALID`,scheduled 返回 `SCHEDULE_DATE_INVALID` | -| messages.deleteMessages | done | real-partial | 当前 owner 视角软删除指定私聊消息;`revoke` 会删除同一 private_message 在其它 owner 视角的 message_box;按 owner 生成 `updateDeleteMessages`,`pts_count=len(message_ids)`,并重算或删除 dialog | -| messages.deleteHistory | done | real-partial | 当前 owner 视角按 peer/max_id 清空历史;默认清空后无可见消息则删除 dialog,后续新消息可重建;`just_clear` 保留空 dialog;`revoke` 同步清理对端 owner 视角;min_date/max_date 第一阶段兼容 no-op | -| messages.search | done | real | 当前账号消息搜索;user peer 走 `message_boxes`,channel/supergroup peer 走单份 `channel_messages`;支持 q/from_id/min_date/max_date/offset_id/add_offset/limit/max_id/min_id/hash,limit cap,文本命中有 pg_trgm GIN 索引兜底;`inputMessagesFilterPinned` 只返回当前 `channels.pinned_message_id` 对应消息并设置单条 `message.pinned`,避免 Android pinned list 把普通历史全标置顶;channel 结果附带 sender/fwd/reply 所需 users/chats | -| messages.searchGlobal | done | real-partial | TDesktop 搜索框全局消息分支;当前查当前 owner 私聊 message_boxes,并合并当前账号 active membership 的频道/超级群单份文本消息;支持 users_only/groups_only/broadcasts_only、filterEmpty、q、limit cap=50、offset_rate+offset_peer+offset_id channel seek、min/max_date 与 folder_id=0/1 下推,media filters 显式空结果;参考实现 的 joined channel list 语义与 参考实现 的 bounded search,后续再接外部全文搜索/媒体索引 | -| messages.toggleDialogPin | done | real | 当前 owner 的 dialog pinned/pinned_order 持久化;user peer 写 dialogs,channel peer 写 channel_dialogs;写 durable updateDialogPinned + dispatch_outbox,可靠投递给其它在线 session | -| messages.reorderPinnedDialogs | done | real | 当前 owner 的 pinned dialog 顺序持久化;支持混合 user/channel peer 与 force 清理未在 order 中的 pinned dialog,写 durable updatePinnedDialogs(order) + dispatch_outbox,可靠投递给其它在线 session | -| messages.markDialogUnread | done | real | 当前 owner manual unread mark 持久化;user peer 写 dialogs,channel peer 写 channel_dialogs/channel_members,`messages.getDialogs/getPeerDialogs` 返回 dialog.unread_mark;readHistory 清除 unread_mark,写 durable updateDialogUnreadMark + dispatch_outbox | -| messages.getDialogUnreadMarks | done | real+monoforum-stub | 返回当前 owner 所有 user/channel manual unread peer;`parent_peer` 分支校验 parent channel 后返回空 monoforum marks,`messages.markDialogUnread(parent_peer)` 同样校验 parent/sublist peer 后 BoolTrue no-op,避免 TDesktop SavedSublist/monoforum 后台触发 `NOT_IMPLEMENTED`;真实 monoforum topic unread store 后续补 | -| messages.hidePeerSettingsBar | done | real | 当前 owner 隐藏 peer action bar 状态持久化;后续 getPeerSettings 返回空 action bar,写 durable updatePeerSettings(settings) + dispatch_outbox,并可靠投递给其它在线 session | -| messages.setTyping | done | real-transient | 私聊 typing/cancel/upload 等 SendMessageAction 实时包装为 updateShort(updateUserTyping) 推给对端在线 session,并排除当前 auth_key_id+session_id;InputPeerChannel 走 `updateChannelUserTyping` 在线瞬时推送;`top_msg_id` 有界校验,非法返回 `MSG_ID_INVALID`;typing 不写 durable log、不做离线补偿 | -| messages.saveDraft | done | real-partial | 接受 TDesktop 输入框云草稿保存/清空 RPC,channel peer 校验非零 access_hash,持久化 user/channel peer 文本、entities、reply、forum `top_msg_id`、no_webpage/invert_media/effect 与 webpage URL,并按 参考实现 语义向同账号其它在线 session 推 `updateDraftMessage`;发送消息 `clear_draft` 会清对应 draft 并推空 draft update;文件 media、suggested_post、monoforum saved_peer 与 getDifference draft event 后续补 | -| messages.getAllDrafts | done | real-partial | 从 `dialog_drafts` 读取最多 1000 条云草稿,返回对应 `updateDraftMessage`,forum draft 设置 `top_msg_id`,附带已知 users/chats;文件 media、suggested_post、monoforum saved_peer 暂不返回 | -| messages.clearAllDrafts | done | real-partial | 有界清空最多 1000 条当前账号云草稿,向其它在线 session 推 `draftMessageEmpty` updates;超大 draft 集合按后续调用继续清理,durable difference 事件后续补 | -| messages.getSavedDialogs | stub | empty/notModified | 第一阶段不做 saved messages 分会话 | -| messages.getPinnedSavedDialogs | stub | empty | 第一阶段不做 saved messages 分会话置顶 | -| messages.toggleSavedDialogPin | stub | ok | 第一阶段不做 saved dialogs 置顶,显式接受避免未知 RPC | -| messages.reorderPinnedSavedDialogs | stub | ok | 第一阶段不做 saved dialogs 置顶排序 | -| messages.getSavedDialogsByID | stub | empty | 第一阶段不做 saved dialogs 指定查询 | -| messages.getSavedHistory | stub | empty/notModified-bounded | TDesktop 打开群/用户资料页时会后台查询 saved history/monoforum 入口;当前不做 saved history,但会校验可选 `parent_peer` 必须是可见 channel、peer/access_hash、offset/add_offset/limit/id/date 边界,返回空 messages 或 hash notModified,并给 parent/peer channel 补 chat context | -| messages.readSavedHistory | stub | ok-bounded | 当前不维护 saved history read state;按 monoforum 语义要求 `parent_peer` 是可见 channel,校验 peer/access_hash 与 max_id 后返回 BoolTrue | -| messages.deleteSavedHistory | stub | affected-empty-bounded | 当前不维护 saved history;校验可选 `parent_peer`、peer/access_hash、max_id/min_date/max_date 后返回 `affectedHistory{pts=current,pts_count=0,offset=0}` | -| messages.getCommonChats | done | common-megagroups | TDesktop 用户资料页共同群/机器人共同群入口;校验 input user/access_hash、max_id、limit<=100,只返回双方均 active 的 megagroup/supergroup,排除 broadcast/left/kicked/deleted,按 channel id seek 分页;PG 走按 `user_id` 主键的 `user_channel_member_index`,避免 `users.getFullUser.common_chats_count` 在私聊打开路径反向规划 `channel_members` 64 分区;`users.getFullUser` 同步填 `common_chats_count` | -| messages.getScheduledHistory / messages.getScheduledMessages / messages.sendScheduledMessages / messages.deleteScheduledMessages | stub | scheduled-store-missing | 第一阶段不做定时消息;history/exact get 返回空 messages 并校验 peer/id cap=100,send-now 对非空 id 返回 `MESSAGE_ID_INVALID`,delete 返回 `updateDeleteScheduledMessages` 清理调用端本地幽灵项;真实 scheduled store、has_scheduled dialog 标记与 job sender 后续补 | -| messages.getAvailableReactions | done | real-resources | 从 `available_reactions` 表(由 `TELESRV_STICKER_SEED_DIR` 真实导出 seed)加载 74 个 reaction,static_icon/appear/select/activate/effect/around/center 均返回真实 `document`(seed source id 已在导入阶段归一为服务端 document id,access_hash/file_reference/dc_id/attributes/thumbs 重写为本 DC 数据),TDesktop 经 `upload.getFile` 从本地 blob backend 下载动画;启动 seed 会校验所有 reaction document 的 `doc:` 主 blob,自动修复半导入状态,且导入扫描只把 `_thumb\d+_` 识别为缩略图,避免 `reaction_thumbs_up/down...` 主文件被误跳过;hash 命中返回 notModified;Files 服务缺失或未 seed 时回退固定 emoji + documentEmpty stub | -| messages.getAvailableEffects | stub | empty | TDesktop message effects 后台预取;参考实现 空响应与 参考实现 无数据响应,返回 `messages.availableEffects{hash=0,effects=[],documents=[]}` | -| messages.getAllStickers / messages.getEmojiStickers | done | real-sets | 列出已 seed 的贴纸集(`getAllStickers`→stickers 类,`getEmojiStickers`→emoji 类)元数据(cover/thumb),稳定 hash 命中返回 notModified;TDesktop 再按集调 `getStickerSet` 拉文档;无 seed 时回退空目录 | -| messages.getRecentStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取最近贴纸;第一阶段返回空列表或 hash notModified | -| messages.getFavedStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取收藏贴纸;第一阶段返回空列表或 hash notModified | -| messages.getStickers | stub | notModified | 第一阶段不提供贴纸搜索结果 | -| messages.getArchivedStickers | stub | empty | DrKLO Android 聊天页/贴纸设置后台预取归档贴纸;当前无 archived sticker store,返回空 `messages.archivedStickers`,避免后台 NOT_IMPLEMENTED 重试 | -| messages.getStickerSet | done | real-sets | `stickerSetRefFromInput` 把 `inputStickerSetID/ShortName/AnimatedEmoji/Dice/EmojiGenericAnimations` 解析为 `domain.StickerSetRef`,从 `sticker_sets` 加载集元数据 + 按 `document_ids` 顺序加载真实文档(含 packs;document id / pack document id / thumb_document_id 均为 seed 阶段归一后的服务端 id),返回完整 `messages.stickerSet`;hash 命中返回 notModified;未 seed 的系统集/未知短名回退空集 stub 避免破坏客户端 | -| messages.getFeaturedStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取推荐贴纸;第一阶段返回空列表或 hash notModified | -| messages.getEmojiGroups | stub | notModified | 第一阶段不提供 emoji 分组目录 | -| messages.getEmojiStickerGroups | stub | notModified | 第一阶段不提供 emoji sticker 分组目录 | -| messages.getEmojiProfilePhotoGroups | stub | empty | TDesktop 头像/群头像编辑页 custom-emoji profile photo categories 入口;当前无 custom-emoji profile photo 分组,返回空 `messages.emojiGroups`,避免后台 `NOT_IMPLEMENTED` 重试 | -| messages.getEmojiStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取 emoji stickers;第一阶段返回空目录或 hash notModified | -| messages.getFeaturedEmojiStickers | stub | empty/notModified | TDesktop 输入框/emoji 面板预取 featured emoji stickers;第一阶段返回空列表或 hash notModified | -| messages.getEmojiKeywordsLanguages | stub | empty | TDesktop 输入框打开后拉 emoji keyword 语言,第一阶段返回空列表 | -| messages.getEmojiKeywords / messages.getEmojiKeywordsDifference | stub | empty-difference | TDesktop emoji keyword 增量入口;校验 lang_code 和 from_version,返回空 `emojiKeywordsDifference`,difference 版本回显客户端 from_version,避免重复大包拉取 | -| messages.getCustomEmojiDocuments | done | real-documents | 按服务端 document id 批量从 `documents` 表加载真实文档(单批 cap=100、id 必须为正),命中返回真实 `document`,未命中返回 `documentEmpty{id}` 占位;无 Files 服务时返回空 | -| messages.getAttachedStickers | stub | empty | TDesktop 图片/视频“查看相关贴纸包”入口;nil media 返回 `MEDIA_EMPTY`,有效 media 返回空 sticker set list | -| messages.searchStickerSets / messages.searchStickers | stub | empty-bounded | TDesktop 贴纸/emoji 云搜索入口;query/emoticon/lang/offset/limit/hash 有界,hash 非零返回 notModified,当前无云贴纸索引,返回空 found results | -| messages.getSavedGifs | stub | empty/notModified | TDesktop 输入框/emoji 面板预取 saved GIFs;第一阶段返回空列表或 hash notModified | -| messages.getAttachMenuBots | stub | notModified | 第一阶段不提供 attach menu bot | -| messages.getQuickReplies | stub | notModified | 第一阶段不做 business quick replies | -| messages.getWebPage | stub | empty | Settings/消息预览路径可能预取 instant view;第一阶段返回 webPageEmpty | -| messages.getDefaultHistoryTTL | stub | disabled | 参考实现,返回 `defaultHistoryTTL{period=0}`,表示新聊天默认不自毁 | -| messages.getSponsoredMessages | stub | empty | 参考实现,返回 `messages.sponsoredMessagesEmpty`,避免群/频道页面后台广告拉取重试 | -| messages.sendMessage | done | real-private-text | 私聊文本消息;支持 random_id 幂等、silent/noforwards、reply_to 私聊消息头(含 quote 文本/entities/offset 与双端 message_id 翻译;quote_text≤1024,quote_offset 按消息文本 offset 限制在 4096 内)、双端 message box、dialog、pts/update event、transactional outbox、在线 session 批量推送、4096 字符上限与 Redis 用户级窗口限流;outbox 排除当前设备使用 auth_key_id+session_id;pts_count 恒 1;channel peer 的 `send_as` 支持 self/current channel 校验链路;media/scheduled 仍返回 `MEDIA_INVALID`/`SCHEDULE_DATE_INVALID`;reply_markup/quick_reply/effect/paid/suggested 与 story/monoforum/todo/poll reply 返回显式 TL 错误,避免 TDesktop 高级输入路径产生 `NOT_IMPLEMENTED` | -| messages.forwardMessages | done | real-private-text | 支持当前 owner 可见文本消息在 user/channel peer 间转发,保留原 forward header 或生成 messageFwdHeader,支持目标会话 `reply_to`、TDesktop forum topic `top_msg_id`、silent/noforwards/drop_author、random_id 幂等和 100 条上限;目标 channel 的 `send_as` 支持 self/current channel 校验链路;源消息自身 reply 不继承,响应、difference 与 outbox 都补齐 fwd/reply/send_as 来源 users/chats;被 noforwards 保护的源消息返回 `CHAT_FORWARDS_RESTRICTED`;scheduled/monoforum/quick_reply/effect/video_timestamp/paid/suggested 返回显式 TL 错误,不再落 `NOT_IMPLEMENTED` | -| messages.createChat | done | real-as-megagroup | 不落 legacy chat 持久化;按 docs/channel-module.md 直接创建 `megagroup` channel。TDesktop New Group 回调只接受 `updates.chats[0] == chat`,因此 TDesktop ctx 下同步响应返回 `legacy chat(migrated_to=channel)` + 真实 `channel`;后续 `InputPeerChat` 主路径映射回同 id channel,避免 `ContactsBox::creationDone` 的 `chat not found in updates` | -| messages.getChats | done | legacy-as-channel | 无 legacy chat 长期形态;若 TDesktop/export 路径用 chat_id 询问,按同 id 查询 megagroup channel 并返回 `tg.Channel` | -| messages.getFullChat | done | legacy-as-channel | 无 legacy chat 长期形态;将 chat_id 映射为 channel_id,返回 `channels.getFullChannel` 语义 | -| messages.addChatUser | done | legacy-as-channel | legacy 普通群入口映射到 `channels.inviteToChannel`;仍执行 channel invite 权限和单次 invite cap | -| messages.deleteChatUser | done | legacy-as-channel | legacy 普通群入口映射到 self leave 或 channel kick;仍执行 ban/kick 权限 | -| messages.editChatTitle | done | legacy-as-channel | legacy 普通群标题入口映射到 `channels.editTitle`,生成 megagroup service message 与 channel pts | -| messages.editChatPhoto | done | legacy-as-channel | legacy 普通群头像入口映射到 `channels.editPhoto`:上传/引用照片落 `channels.photo_*` 反范式列并返回 `updateChannel` + 推 channel state;`inputChatPhotoEmpty` 清除头像 | -| messages.editChatAdmin | done | legacy-as-channel | legacy 普通群管理员入口映射到 `channels.editAdmin`;`is_admin=true` 授予 basic group 管理权,`false` 清空 admin rights | -| messages.editChatAbout | done | legacy-as-channel | legacy/basic wrapper 支持 `InputPeerChat` 与 `InputPeerChannel`,真实持久化 `channels.about` 并校验 change_info 权限 | -| messages.editChatDefaultBannedRights | done | real-channel-permissions | 按 参考实现 语义要求 ban_users 权限;持久化 `channels.default_banned_rights`,TDesktop full channel 可见;普通成员发送/邀请会实时受 default rights 与个人 banned rights 限制 | -| messages.editChatParticipantRank | done | legacy-as-channel | legacy 管理员 rank 入口映射到 `channels.editAdmin`,保留当前 admin rights,仅更新 rank,rank 长度有界 | -| messages.editChatCreator | blocked | needs-2fa-transfer | 显式注册并校验 peer/user;当前未实现账号 2FA/SRP 与所有权转移事务,返回 `PASSWORD_HASH_INVALID` 而非 fallback | -| messages.setChatTheme | stub | channel-context/private-ack | TDesktop private/theme 入口;user peer 返回空 updates,legacy chat/channel peer 校验可见性并返回 channel state,避免设置页后台 unknown RPC | -| messages.toggleNoForwards | done | real-channel | legacy chat/channel content protection 入口映射到 `channels.noforwards`,校验 change_info 权限,返回/推送 `updateChannel`;后续 channel message 自动继承 noforwards | -| messages.setChatAvailableReactions | done | real-channel-policy | legacy chat/channel reaction 设置入口;参考实现 持久化 channel reaction policy,校验 change_info、reaction vector/reactions_limit cap=64,`channels.getFullChannel` 返回 `available_reactions/reactions_limit/paid_reactions_available`,返回/推送 channel state | -| messages.saveDefaultSendAs | done | real-current-channel | TDesktop send-as 选择器保存入口;当前 `channels.getSendAs` 返回 self,并在 creator、broadcast post admin、megagroup anonymous admin 可用时追加 current channel;保存入口同样只接受 self/current channel,拒绝其它 `send_as`;默认身份写入当前 user 的 `channel_dialogs.default_send_as_peer_*`,保存 self 会清空默认;`channels.getFullChannel` 只在默认身份仍有效时输出 `channelFull.default_send_as`;`sendMessage/forwardMessages(InputPeerChannel)` 未显式带 `send_as` 时会读取并重新校验该默认身份,失效默认值自动降级 self | -| messages.migrateChat | done | legacy-as-channel | 服务端无 legacy chat 长期形态;校验 change_info/creator 权限后把 chat_id 视作既有 megagroup channel_id,返回 `updateChannel + tg.Channel`,不创建迁移双写路径 | -| messages.sendMessage(InputPeerChannel) | done | real-channel-text | channel/supergroup 单份消息写 `channel_messages`,分配 channel message id + channel pts,生成 `updateNewChannelMessage`;支持同 channel 可见消息 reply_to,服务端反算 `reply_to_top_id` 并保留 quote metadata;支持 `send_as` self/current channel,未显式带 `send_as` 时读取已保存默认身份并重新校验,消息 `from_id` 按 send_as 输出并补齐 peers;`random_id` 幂等重试返回原始 send snapshot,不受后续 edit/delete 污染;broadcast 校验 post 权限;发送事务只对小超级群同步刷新 `channel_dialogs` 普通未读缓存,broadcast/超阈值 megagroup 不做全员 unread 写入,`messages.getDialogs/getPeerDialogs/channels.getFullChannel` 按 `channel_members.read_inbox_max_id + available_min_id + channels.top_message_id + channel_messages.deleted` 读时派生普通未读;发送者 `channel_members.read_inbox` 推到新消息,自己消息不计入动态未读;持久 channel updates 在线 fanout 走 updates-ready session 的 channel membership 索引,按当前在线 active 成员 best-effort 推送(不再受 500 cap 截断),离线靠 `updates.getChannelDifference` 补偿 | -| messages.getHistory(InputPeerChannel) | done | real-channel | 查 `channel_messages(channel_id,id)`,按 seek pagination 返回;PG 使用 `limit+1` 探测下一页,不做 `count(*) over()` / SQL 大 OFFSET;带 username 的公开 channel/supergroup 允许非成员只读预览历史,私有频道与 ban/kick/view_messages 禁止仍返回对应错误 | -| messages.readHistory(InputPeerChannel) | done | real-channel | 推进当前 user 的 `channel_dialogs.read_inbox_max_id`,未读惰性重算;bounded 扫描最近 read delta,推进相关发送者 `read_outbox_max_id` 并在线推 `updateReadChannelOutbox`,不对全体成员做 O(n) fanout | -| messages.editMessage(InputPeerChannel) | done | real-channel-text | 作者或具备 edit_messages 权限的管理员可编辑单份 `channel_messages` 文本,生成 `updateEditChannelMessage(pts_count=1)` 并进入 `channel_update_events`;网页预览 media 降级文本编辑,真实 media/reply_markup/quick replies/scheduled 按现有 messages.editMessage 显式错误拒绝 | -| messages.deleteHistory(InputPeerChannel) / channels.deleteHistory(local) | done | real-channel-clear | 当前 user 本地清空时只推进 `available_min_id/read_inbox` 和 `channel_dialogs`,不生成 channel pts;同时写账号级 `channel_available_messages` durable update,并在线推 `updateChannelAvailableMessages` 给同账号其它 session,TDesktop 会 `clearUpTill(available_min_id)`;返回/推送实际应用后的单调 `available_min_id=max(old, requested)`,避免多设备 stale clear 让客户端水位回退;`messages.deleteHistory(InputPeerChannel,revoke)` 映射为有权限时的 channel 维度批量删除,返回 `affectedHistory.offset` 供后续 RPC 续删,单批 cap=1000 | -| messages.forwardMessages(InputPeerChannel) | done | real-channel-text | 支持 channel→channel、channel→user、user→channel 的文本转发,保留 forward header 或按 `drop_author` 隐藏;支持目标会话 `reply_to` 与 forum topic `top_msg_id`,后者映射为 topic-only reply 并由 store 校验 topic/权限/可见性,但不继承源消息自身 reply;目的 channel 仍单份写入并生成 channel pts;目标 channel 支持 `send_as` self/current channel,未显式带 `send_as` 时读取已保存默认身份并重新校验,响应/difference/outbox/channelDifference 补齐可解析的 send_as/fwd_from/reply_to user/channel 上下文,单次 cap=100 | -| messages.setTyping(InputPeerChannel) | done | real-channel-transient | megagroup/topic typing 走 `updateChannelUserTyping` 在线瞬时推送;`top_msg_id` 只作为 forum/thread 维度透传,范围非法返回 `MSG_ID_INVALID` 且不广播;不写 durable log、不做离线补偿;仅推给已通过 channel-specific RPC(如 getHistory/getFullChannel/getChannelDifference/getPeerDialogs)标记的 active viewers,不向全体在线成员扩散 | - -## Channels / Supergroups - -| method | status | behavior | note | -|---|---|---|---| -| channels.createChannel | done | real | 创建 broadcast channel 或 megagroup;写 channel、creator member、service message、channel_update_events;响应包含 `tg.Channel`;history import/geogroup 暂无业务模型,显式返回 `CHAT_INVALID`/`ADDRESS_INVALID`,负 `ttl_period` 返回 `TTL_PERIOD_INVALID`,避免阶段外 flags 继续落 `NOT_IMPLEMENTED` | -| channels.getChannels | done | real | 按 inputChannel 返回当前用户可见的 `tg.Channel` 列表;精确 ID vector cap=100;非零 access_hash 不匹配时跳过该项,避免异常客户端触发无界逐项查询或绕过最小 channel hash 校验 | -| channels.getFullChannel | done | real-minimal | 返回 `ChannelFull` 最小必需字段:about、participants/read/unread/notify/pts/admin rights/default banned rights;非零 access_hash 不匹配返回 `CHANNEL_PRIVATE`;带 username 的公开 channel/supergroup 允许非成员只读预览 full/history,并以 left/self 无 unread 视图返回,避免 TDesktop 公开搜索结果打开后空白;`read_outbox_max_id` 来自当前 viewer 的 channel dialog/member 状态;有邀请管理权限的管理员会收到 `requests_pending/recent_requesters`,供 TDesktop join request 管理入口恢复 | -| channels.getSendAs | done | real-current-channel | TDesktop 群/频道输入框预取 send-as peers;参考实现/参考实现 语义,当前返回 self,并在 creator、broadcast post admin、megagroup anonymous admin 可用时追加 current channel,同时返回可解析的 current channel chat + self user;公开非成员预览返回 self-only,不因非成员报 `CHANNEL_PRIVATE`;真实 public channel send_as 候选留后续 | -| channels.checkUsername | done | real | 校验 channel 可见性与 username 规则,大小写不敏感检查 users/channel_usernames 占用;同一 channel 当前 username 视为可用 | -| channels.updateUsername | done | real | creator 可设置/清除频道或超级群主 username;PG 事务内更新 `channels.username` 与 `channel_usernames`,大小写不敏感唯一约束兜底,成功后向在线成员推 `updateChannel` | -| channels.getAdminedPublicChannels | done | real | 返回当前用户 active creator/admin 且带主 username 的公开 channel/supergroup,用于 TDesktop public username limit/选择入口 | -| channels.toggleSignatures | done | real-minimal | creator/change_info admin 可切换 `channels.signatures` 并返回/推送 `updateChannel`;profiles_enabled 先不单独持久化 | -| channels.togglePreHistoryHidden | done | real-minimal | creator 可切换 `channels.pre_history_hidden`,返回/在线推 `updateChannel`,`getFullChannel` 暴露 `hidden_prehistory`;新加入/导入/受邀成员按当前 `top_message_id` 初始化 `available_min_id/read_inbox`,并按加入前 channel pts 初始化 `available_min_pts`,避免看到旧历史或补到入群前消息类 durable 事件 | -| channels.toggleSlowMode | done | real-minimal | change_info 管理员可持久化 `channels.slowmode_seconds`,`Channel.slowmode_enabled/ChannelFull.slowmode_seconds` 可见;普通成员发送按 `channel_members.slowmode_last_send_date` 返回 `SLOWMODE_WAIT_X`,管理员/creator 豁免 | -| channels.setStickers | stub | empty-only | 当前无群贴纸集 store;仅 megagroup + `inputStickerSetEmpty` 清空入口做权限校验 no-op 成功,非空 sticker set 返回 `STICKERSET_INVALID`,避免 TDesktop 误以为贴纸集已落库 | -| channels.reorderUsernames | stub | permission-ok | 当前无 Fragment/多 username 模型,限制 order<=32、校验 change_info 后返回 BoolTrue | -| channels.toggleUsername | stub | permission-ok | Fragment/额外 username 入口;当前不改主 username,只校验 username 格式与 change_info 权限后返回 BoolTrue,避免误删 `channels.updateUsername` 的主 username | -| channels.deactivateAllUsernames | stub | permission-ok | Fragment/额外 username 入口;当前无 purchased username 模型,仅校验 change_info 后返回 BoolTrue | -| channels.updateColor | done | real-appearance | 持久化 `channels.color/profile_color` 与 background emoji id,保留 color flag 显式 0;校验 access_hash + change_info 后返回/推送带 `Channel.color/profile_color` 的 `updateChannel`;boost level 暂不强制,避免本地测试频道外观入口失效 | -| channels.updateEmojiStatus | done | real-minimal | 支持 `emojiStatusEmpty` 清除与普通 `emojiStatus(document_id,until)` 持久化并输出到 `Channel.emoji_status`;collectible emoji status 缺少 gift/read model 时返回 `EMOJI_STATUS_INVALID`,不伪造 collectible 元数据 | -| channels.exportMessageLink | done | real-message-link | 校验 channel/access_hash、msg_id 范围和当前成员可见的单份 channel message;公开 channel 返回 `t.me/{username}/{msg_id}`,私有 channel 返回 `t.me/c/{channel_id}/{msg_id}`,`thread=true` 且消息有 reply root 时追加 `?thread={root_id}`;`grouped/html` 与 public discussion `comment=` 精细链接后续补 | -| channels.readMessageContents | done | real-channel-content-read | 校验 channel/access_hash、id vector cap=100 与可见 exact message;按当前作者视角清理 visible messages 的 unread reaction、重算 `channel_dialogs.unread_reactions_count`,并向当前账号其它 session 推 `updateChannelReadMessagesContents` / `updateMessageReactions` 刷新 TDesktop 角标;不生成 channel pts | -| channels.reportSpam | stub | ok | 校验 channel、participant 与 id cap=100 后返回 BoolTrue;风控/举报队列后续补 | -| channels.getLeftChannels | done | real-left-export | TDesktop takeout/export 路径;按当前 user 的 left channel/supergroup membership 返回有界 pageSize=100,offset<=10000,带 full count 与 left channel flag;最终非空页返回 `messages.chats`,越界空页返回空 `messages.chatsSlice` 让导出流程结束 | -| channels.getInactiveChannels | done | real-least-active | Premium limits 路径;按当前用户 active 频道/超级群的可见 top message date 旧到新返回,dates 与 chats 对齐,limit cap=100;不实现 Premium 限额策略 | -| channels.getGroupsForDiscussion | done | real-owned-megagroups | 讨论组选择入口;返回当前用户可管理的 megagroup/supergroup(无 legacy basic group),按 ID 倒序限流,TDesktop 可直接选择 | -| channels.setDiscussionGroup | done | real-link | 校验 access_hash、broadcast/group 类型、管理权限与 hidden prehistory;双向维护 `linked_chat_id`,清理旧链接,返回 BoolTrue 并推送相关 `updateChannel` | -| channels.editLocation | stub | permission-ok | geogroup 未接入;校验 change_info 后返回 BoolTrue | -| channels.convertToGigagroup | stub | updateChannel | 当前 createChat 已直接创建 megagroup;校验 change_info 后返回 `updateChannel` | -| channels.deleteParticipantHistory | done | bounded-delete | 管理员按 `participant` 的 `sender_user_id` 分批软删 channel_messages,单批 cap=1000,返回 `affectedHistory(offset=1 表示仍需续删)` 并生成 `updateDeleteChannelMessages`,`pts_count=len(ids)` | -| channels.toggleJoinToSend | done | real-join-settings | 持久化 `join_to_send`,校验 megagroup + invite/admin 权限,返回/推送带 flags.28 的 `updateChannel` | -| channels.toggleJoinRequest | done | real-join-settings | 持久化 `join_request`,仅 public megagroup 可开启;`channels.joinChannel` 对非成员写入 pending public join request 并返回 `INVITE_REQUEST_SENT`,并向管理员推有界 `updatePendingJoinRequests`;approve/dismiss 复用 `messages.hideChatJoinRequest` 有界路径 | -| channels.toggleForum | done | real-setting | 持久化 megagroup `forum/forum_tabs`,仅 creator 可改;已绑定 discussion group 时返回 `CHAT_DISCUSSION_UNALLOWED`;返回/推送 `updateChannel` 并写 `channelAdminLogEventActionToggleForum`;`messages.getForumTopics*` 与 create/edit/pin/reorder/deleteTopicHistory 已支持虚拟 General + 持久化 topic store | -| channels.toggleAntiSpam | done | real-setting | megagroup 管理员可持久化 `channels.antispam`,返回/推送 `updateChannel`;`ChannelFull.antispam` 恢复 TDesktop 开关状态,并写 `channelAdminLogEventActionToggleAntiSpam`;真实反垃圾删除管线未接入 | -| channels.reportAntiSpamFalsePositive | stub | ok | 校验 change_info 与 msg_id 后返回 BoolTrue;真实 antispam admin-log 后续补 | -| channels.toggleParticipantsHidden | done | real-hidden-members | 持久化 `channels.participants_hidden`;creator/具备 ban_users 的管理员可切换,返回/推送 `updateChannel`;`ChannelFull.participants_hidden` 恢复 UI 状态,非管理员成员 `getParticipants(recent/search/bots/contacts/mentions)` 只返回 aggregate count 不暴露列表,`messages.getMessageReadParticipants` 返回空避免隐藏成员场景泄漏已读名单 | -| channels.toggleViewForumAsMessages | done | real-local-dialog | 持久化当前账号 `channel_dialogs.view_forum_as_messages`;返回/可靠投递 `updateChannelViewForumAsMessages` 同步同账号其它 session,并在 `messages.getDialogs` 的 `Dialog.view_forum_as_messages` 与 `channels.getFullChannel` 的 `ChannelFull.view_forum_as_messages` 回填;forum topic create/read/edit/pin/reorder/delete 已接入 | -| channels.getChannelRecommendations | done | real-public-broadcasts | TDesktop similar/recommended 入口;返回公开 username broadcast channel,指定 source 时校验 access_hash 且排除 source,无 source 时排除当前用户 active membership;默认 10、cap=100,真实相似度/订阅画像/Premium 扩容后续补 | -| channels.setBoostsToUnblockRestrictions | stub | permission-update | boost bypass 未接入;按 Layer225 限制 boosts 为 0..8(0 关闭)并校验 change_info 后返回/推送 `updateChannel` | -| channels.setEmojiStickers | stub | empty-only | 当前无 custom emoji sticker store/boost gating;仅 megagroup + `inputStickerSetEmpty` 清空入口做权限校验 no-op 成功,非空 sticker set 返回 `STICKERSET_INVALID` | -| channels.restrictSponsoredMessages | done | real-setting | 持久化 `channels.restricted_sponsored`,校验 change_info 后返回/推送 `updateChannel`;`channels.getFullChannel` 回填 `ChannelFull.restricted_sponsored`,真实广告投放/boost 等级校验后续补 | -| channels.searchPosts | done | real-public-post-search | 全局公开频道/超级群帖子搜索;按 Layer225 校验 query/hashtag 二选一、文本长度<=256、limit cap=50、offset_id 范围、offset_peer 可为空或公开/可见 channel peer、paid stars 非负;只搜索 username 非空且未删除的公开 channel_messages,PG 走 body trigram `ILIKE` + `(message_date,channel_id,id)` seek 分页,满页返回 `messages.messagesSlice(next_rate,search_flood)` | -| channels.updatePaidMessagesPrice | done | real-setting | 持久化 `channels.send_paid_messages_stars` 与 broadcast `channels.broadcast_messages_allowed`;按 TDesktop app config 默认限制 stars<=10000,supergroup 负数返回 `STARS_AMOUNT_INVALID`,broadcast direct messages 允许 `-1` 表示关闭;返回/推送 `updateChannel` 并在 `Channel`/`ChannelFull` 回填状态;真实 paid messages/monoforum/结算后续补 | -| channels.toggleAutotranslation | done | real-setting | 持久化 `channels.autotranslation` 并记录 admin log `toggle_autotranslation`;校验 change_info 后返回/推送 `updateChannel`,`channels.getChannels` 回填 `Channel.autotranslation` | -| channels.getMessageAuthor | done | real-partial | monoforum 专有权限未接入;按可见 channel message 精确 id 查 sender 并返回 user,找不到或非用户作者报 MESSAGE_ID_INVALID | -| channels.checkSearchPostsFlood | stub | free-bounded | 校验 query 必填且长度<=256,返回 query_is_free=true 与剩余免费次数,避免 Search Posts 付费检查阻塞;真实付费/限额模型后续接入 | -| channels.setMainProfileTab | stub | permission-ok | profile tabs 未接入;校验 change_info 后返回 BoolTrue | -| channels.getMessages | done | real-channel | 按 channel_id + InputMessageID 精确拉取 channel_messages,单次 cap=100;稀疏 id 走 `id=ANY`,不存在返回 messageEmpty | -| channels.getParticipants | done | real | 支持 recent/admins/search/kicked/banned/bots/contacts/mentions 最小过滤语义,limit cap=200、offset cap=10000;banned/kicked 对非 admin 不暴露管理列表,搜索走 user 字段匹配,避免恶意深分页拖垮 PG | -| channels.getParticipant | done | real | 返回当前/指定 user 的成员身份与权限;`inputPeerSelf` 对普通成员返回 `channelParticipantSelf`,避免 TDesktop 记录 `Got self regular participant`;缺失按 TL 错误映射 | -| channels.inviteToChannel | done | real | 校验 invite 权限与用户数量 cap,创建成员/dialog,megagroup 生成 service message;单人重复邀请返回 `USER_ALREADY_PARTICIPANT`,被踢/禁看用户只能由 creator 或具备 ban_users 的 admin 恢复,普通成员单人恢复返回 `USER_KICKED`;broadcast 不写普通成员服务消息 | -| channels.joinChannel | done | real | 创建/恢复 member/dialog;megagroup 生成 join service message;重复加入返回 `USER_ALREADY_PARTICIPANT`;join/rejoin read 水位推进到加入动作后的 top,避免旧历史计入未读或后续打开会话时批量生成旧消息读回执 | -| channels.leaveChannel | done | real | 标记 member left,更新 participants_count;megagroup 生成 leave service message | -| channels.readHistory | done | real | TDesktop 可能直接调用 channels.readHistory;语义同 messages.readHistory(InputPeerChannel),含发送方 `updateReadChannelOutbox` 在线通知 | -| channels.deleteMessages | done | real-channel | 管理员/作者权限校验后软删单份 channel messages,生成有界 `updateDeleteChannelMessages`,`pts_count=len(ids)`,单次 id cap=1000 | -| channels.deleteHistory | done | real-partial | `for_everyone` 执行一个有界管理员删除 page 并推 `updateDeleteChannelMessages`,单批 cap=1000;该 TL 返回 `Updates` 且 TDesktop 不读取 offset,禁止在同步 RPC 内循环构造超大 id/update;非 for_everyone 只清当前用户可见历史/read/dialog,不写扩散、不生成超大 update | -| channels.editAdmin | done | real | creator 或具备 add_admins 的 admin 可更新 admin rights/rank;非 creator 只能授予自己拥有的权限;禁止改 creator;写 admin log,不占 channel pts、不写 `channel_update_events`;返回/在线推 `updateChannelParticipant + updateChannel`,离线设备通过 full channel/participants 刷新状态 | -| channels.editBanned | done | real | creator 或具备 ban_users 的 admin 可更新 banned rights/kicked 状态,刷新 participants/admin/banned/kicked 计数;写 admin log,不占 channel pts、不写 `channel_update_events`;返回/在线推 `updateChannelParticipant + updateChannel`,若后续产生可见踢人/加人 service message,则 service message 单独占 channel pts | -| channels.editTitle | done | real | creator/change_info admin 可改标题;写 megagroup service message `messageActionChatEditTitle` 与 channel pts,返回 `updateChannel + updateNewChannelMessage` | -| channels.editPhoto | done | real-photo | change_info 权限校验后解析 `inputChatPhoto`:`inputChatUploadedPhoto` 组装上传→建 Photo,`inputChatPhoto{inputPhoto}` 引用已存在照片,`inputChatPhotoEmpty`/`inputPhotoEmpty` 清除;落 `channels.photo_id/photo_dc_id/photo_stripped` 反范式列,`tgChannel.Photo`(ChatPhoto)/`tgChannelFull.ChatPhoto` 渲染真实头像,返回 `updateChannel` + 推 channel state;服务端无 Files 时按 `PHOTO_INVALID` 处理;in-history `MessageActionChatEditPhoto` service 消息留 todo | -| channels.deleteChannel | done | real | creator 权限,标记 channel deleted,返回/推送 `updateChannel + channelForbidden`,dialog 列表过滤 deleted channel;后续 admin log 细化 | -| channels.getAdminLog | done | real-minimal | 校验 creator/admin,按 channel admin log event store 有界查询;支持 actor admins、events_filter、q、max_id/min_id/limit,覆盖 metadata、成员权限、pin、send/edit/delete;forum/group_call/subscription 等长尾 action 待对应业务模型 | -| messages.updatePinnedMessage(InputPeerChannel) | done | real-channel | pin/unpin channel message,校验 pin 权限,更新 `channels.pinned_message_id`,写 `updatePinnedChannelMessages(pts_count=1)`;不生成超大 update | -| messages.unpinAllMessages(InputPeerChannel) | done | real-channel-single | TDesktop pinned section 清空入口;当前 channel 模型只维护单个 `pinned_message_id`,有置顶则复用单条 unpin 生成 `affectedHistory` 与 `updatePinnedChannelMessages`,无置顶返回当前 channel pts + `pts_count=0`;forum/monoforum 参数有界 no-op,后续多置顶模型再做分页 offset | -| stats.getBroadcastStats / stats.getMegagroupStats | stub | empty-graphs | TDesktop 频道/超级群统计页入口;校验 channel/access_hash/admin 与 broadcast/megagroup 类型后返回可解析空图表,真实统计聚合后续补 | -| stats.getMessageStats / stats.getMessagePublicForwards | stub | empty-graphs | TDesktop 消息统计/公开转发入口;校验 channel/access_hash/admin/msg_id,public forwards `limit<=100`、`offset<=128` 后返回空结果,避免 unknown RPC 与无界分页 | -| stats.loadAsyncGraph / stats.getStoryStats / stats.getStoryPublicForwards / stats.getPollStats | stub | empty/error | Layer 225 stats 域剩余入口全部显式注册;graph token 过长返回 `statsGraphError`,story/poll 校验 peer/id/limit 后返回空图表或空 public forwards | -| premium.getBoostsStatus | stub | zero-status | TDesktop 频道统计、颜色/权限和 boost 弹窗入口;校验 channel/access_hash 后返回零 boost 状态和必填 `boost_url` 空串,不写入虚假 boost 状态 | -| premium.getBoostsList / premium.getUserBoosts | stub | empty-list | TDesktop boost 列表入口;校验 channel/access_hash/admin/user,`limit<=100`、`offset<=128` 后返回空列表,真实 boost 聚合后续补 | -| premium.getMyBoosts / premium.applyBoost | stub | empty-noop | TDesktop boost 弹窗入口;`getMyBoosts` 返回空槽位,`applyBoost` 校验 slots 存在、非空、数量上限后空 no-op,真实 premium/boost 模型后续补 | -| messages.exportChatInvite(InputPeerChannel) | done | real-channel | 支持导出 channel invite link,校验 invite/change_info 权限,支持 expire/usage_limit/request_needed/title 与 legacy revoke permanent | -| messages.checkChatInvite | done | real-channel | 按 invite hash 返回 `chatInvite` / `chatInviteAlready`,处理过期、撤销、已封禁与已加入状态 | -| messages.importChatInvite | done | real-channel | 通过 invite hash 加入 channel/megagroup,megagroup 生成 join service message 与 channel pts;read 水位先落到加入前 top,再推进到自己的 join service,避免旧历史计入未读或打开会话时批量生成旧消息读回执;PG 在事务内锁 invite row 后检查/递增 usage_count,usage limit 满返回 `USERS_TOO_MUCH`,request_needed 持久化 pending importer、返回 `INVITE_REQUEST_SENT` 并向管理员推 `updatePendingJoinRequests`,用户后续通过其它 invite 加入会清理旧 pending request | -| messages.getExportedChatInvites / messages.getExportedChatInvite | done | real-invite-list | TDesktop invite links 管理页入口;校验 channel/access_hash/admin、admin user、link、limit/offset 边界后按 admin/revoked 返回持久化 invite 列表与 detail;`limit<=100`,分页使用 `offset_date + offset_link` seek,不做 OFFSET 大分页 | -| messages.editExportedChatInvite / messages.deleteExportedChatInvite / messages.deleteRevokedExportedChatInvites | done | real-invite-state | 支持 title/expire/usage_limit/request_needed 修改、永久链接 revoke 后返回 replacement、单链接删除与按 admin 清理 revoked invites;撤销链接仍可被管理页 detail 查询,import 会拒绝 revoked hash | -| messages.getAdminsWithInvites / messages.getChatInviteImporters | done | real-importers | TDesktop invite admins、importers、join request 管理入口;按 admin 聚合 active/revoked invite 数,importer 支持 requested/link/q/offset_user/offset_date 查询;`limit<=100`、link/q 长度有界,`q+link` 返回 `SEARCH_WITH_LINK_NOT_SUPPORTED` | -| messages.hideChatJoinRequest / messages.hideAllChatJoinRequests | done | real-join-request | 支持 pending join request approve/dismiss;approve 复用 channel join 流并生成 megagroup join service update,dismiss 清理 pending;响应和管理员在线 push 都包含新的 `updatePendingJoinRequests`;`hideAll` 单批 cap=1000,避免对超大 dialog 硬生成无界 updates | - -## Contacts / Users - -| method | status | behavior | note | -|---|---|---|---| -| contacts.getContacts | done | real | 从 contacts 表查询当前账号通讯录;支持 hash notModified;返回当前 owner 保存的姓名/电话/备注视角,并经统一 user projection 补当前头像/状态;互相关系按双方是否互存维护 | -| contacts.getContactIDs | done | real | 返回当前 owner 通讯录 user_id 列表,支持 hash notModified | -| contacts.getStatuses | done | persisted-presence | 返回当前通讯录联系人 `contactStatus`;状态来自运行时 presence、活跃 session 兜底与持久化 `users.last_seen_at`,离线联系人返回精确 `userStatusOffline.was_online`,未知用户退回 `userStatusRecently`。users/dialog/history projection 已裁剪 StatusTimestamp,`contacts.getStatuses` 的逐项 privacy 降级仍为后续项 | -| contacts.importContacts | done | real | 按手机号匹配已注册用户,写入当前 owner 视角的联系人姓名/电话/备注,返回 imported/users,并写 durable updatePeerSettings + updateContactsReset + dispatch_outbox | -| contacts.addContact | done | real | 写入当前 owner 视角联系人资料;禁止 self/空姓名/不存在 user,维护 reverse mutual,并写 durable updatePeerSettings + updateContactsReset + dispatch_outbox | -| contacts.acceptContact | done | real-share-phone | TDesktop “Share my phone number” 入口;要求当前 owner 已有该联系人,否则 CONTACT_REQ_MISSING;把当前用户手机号/姓名写入对方 owner 视角联系人,维护 mutual,返回 updatePeerSettings(shareContact=false)+updateContactsReset,并为双方写 durable peer settings / contacts reset | -| contacts.deleteContacts | done | real | 删除当前 owner 的联系人关系,并清理对方 reverse mutual;写 durable updatePeerSettings + updateContactsReset + dispatch_outbox | -| contacts.updateContactNote | done | real | 更新当前 owner 对某个联系人的备注与备注实体,不影响其它 owner 对同一 user 的备注,并写 durable updateContactsReset + dispatch_outbox | -| contacts.search | done | real | TDesktop 搜索框 peer 分支;strip `@`、空/过短查询报 SEARCH_QUERY_EMPTY/QUERY_TOO_SHORT,limit cap=50;联系人 user 进 MyResults,非联系人 user 进 Results;公开 username channel/supergroup 同步返回 PeerChannel + Chats,当前已加入的放 MyResults,其它公开命中放 Results 并以 left chat 标记只读预览,避免 TDesktop 误显示已加入;用户搜索走手机号前缀/username/姓名/owner 保存姓名索引,公开频道搜索走 username/title trgm 索引 | -| contacts.resolveUsername | done | real | 按大小写不敏感 username 解析 user 或公开 channel/supergroup peer;channel 返回 PeerChannel + Chats;不存在返回 USERNAME_NOT_OCCUPIED,非法格式返回 USERNAME_INVALID | -| contacts.resolvePhone | done | real-partial | 按手机号解析 user peer;PhoneNumber privacy 已用于资料投影与 ShareContact 例外,手机号反查入口仍按已知手机号解析,未命中返回 PHONE_NOT_OCCUPIED;按 AddedByPhone/PhoneNumber privacy 限制反查为后续项 | -| contacts.block | done | real-blocklist | 写入当前 owner blocklist,幂等;同步刷新 peer settings,story-only block flag 当前按主 blocklist 处理,完整 stories privacy 留后续 | -| contacts.unblock | done | real-blocklist | 从当前 owner blocklist 删除 peer,幂等;同步刷新 peer settings | -| contacts.getBlocked | done | real-blocklist | Settings 隐私/安全预取;`contacts.block/unblock/getBlocked` 维护 `owner_user_id + blocked_user_id` 唯一 blocklist,limit cap=100,按 date/user_id 返回 `peerBlocked` + users;`contacts.getPeerSettings` 按当前 owner block 状态返回 block/unblock action,并按 PhoneNumber privacy 决定 `ShareContact`。blocklist 仍是私聊 send/edit/delete 的拒绝 gate,完整 privacy key 用于资料投影/手机号分享 | -| contacts.getBirthdays | stub | empty | DrKLO Android 主界面周期性预取联系人生日;当前无 birthday reminder store,返回空 contacts/users | -| contacts.getTopPeers | stub | disabled | 第一阶段不维护 top peers 统计 | -| contacts.getSponsoredPeers | stub | empty | 第一阶段不做 sponsored peers,TDesktop 搜索框分支返回 sponsoredPeersEmpty | -| users.getUsers | done | real | InputUserSelf 与已知 InputUser 返回用户(含 777000 官方账号);未登录则跳过(空列表) | -| users.getFullUser | done | real-minimal | InputUserSelf/已知 InputUser 返回 user + 最小 userFull;空账号主界面需要 | -| users.getSavedMusic | stub | empty | TDesktop 资料页/profile music/export 路径会按 offset/limit/hash 拉取;校验 input user、offset>=0、limit<=100,当前无 profile music media store,返回 `users.savedMusic{count=0,documents=[]}` | -| users.getSavedMusicByID | stub | empty | TDesktop saved music 文件引用刷新入口;校验 input user 与 documents vector cap=100,当前返回空 `users.savedMusic` | - -## Stories / Payments / AI Compose - -| method | status | behavior | note | -|---|---|---|---| -| stories.getAllStories | stub | empty | 第一阶段不做 stories,返回空列表与空 stealth_mode | -| stories.getStoriesArchive | stub | empty | TDesktop 主界面后台拉 archived stories;第一阶段返回空 archive | -| stories.getPinnedStories | stub | empty | TDesktop 资料页 stories 区域后台查询;当前返回空 `stories.stories` | -| stories.getAlbums | stub | empty | TDesktop 资料页 story albums 后台查询;当前无 story album 模型,返回空 `stories.albums` | -| stories.getAllReadPeerStories | stub | empty-updates | DrKLO Android 登录后拉已读 story 状态;当前无 story read store,返回空 updates | -| stories.getPeerMaxIDs | stub | empty-recent | DrKLO Android 登录后按 peer 拉已读 story max id;当前无 story read store,按输入 peer 数返回空 `recentStory` | -| stories.sendReaction | stub | validated-noop | 当前无 story store;校验 peer/story_id/reaction,`add_to_recent` 会更新账号 top/recent emoji reaction,返回空 updates,避免 story 反应入口落 unknown | -| payments.getStarGiftActiveAuctions | stub | notModified | 第一阶段不做 stars/gifts | -| payments.getSavedStarGifts | stub | empty | TDesktop 资料页 gifts 区域后台查询;当前返回空 `payments.savedStarGifts` | -| payments.getSavedStarGift | stub | empty | 单个 gift 详情入口;当前返回空 `payments.savedStarGifts` | -| aicompose.getTones | stub | notModified | 第一阶段不做 AI compose tones | - -## Langpack - -| method | status | behavior | note | -|---|---|---|---| -| langpack.getLangPack | done | real+legacy-adapter | PG-backed;启动 seed `tdesktop_en/zh-hans_v12000000.strings`;DrKLO Android legacy `#9ab5c58e lang_code:string` 由 RPC edge 补 `lang_pack` 后复用当前实现 | -| langpack.getDifference | done | real | 按 version 返回语言包差异;当前 seed 同版本全量 | -| langpack.getStrings | done | real+legacy-adapter | 按 key 查询 PG 中的语言包字符串;DrKLO Android legacy `#2e1ee318 lang_code:string keys:Vector` 由 RPC edge 补 `lang_pack`,keys 上限 512 | -| langpack.getLanguages | done | compat-static | Layer 225 `#42c6978f` 已注册;DrKLO Android 仍会发 legacy `#800fd57d` 无参版本,RPC edge 显式 adapter 后返回语言列表 | - -## Files / Media / Photos - -| method | status | behavior | note | -|---|---|---|---| -| upload.saveFilePart | done | real-localfs | 累积 small file 分片到 `upload_parts`(PG),part>=0、单片 ≤512KB;需已登录 | -| upload.saveBigFilePart | done | real-localfs | 累积 big file 分片,校验 total_parts 上限;组装见 uploadMedia/uploadProfilePhoto | -| upload.getFile | done | real-localfs | 把 `inputDocumentFileLocation/inputPhotoFileLocation/inputPeerPhotoFileLocation` 推导为 `file_blobs.location_key`(document 查 `doc:[:type]`,photo 查 `photo::`),从本地 blob backend 按 offset/limit 切片返回 `upload.file`,storage type 优先按 bytes 魔数、再按 mime 判定,避免历史 seed 把 WebP thumb 误标 `image/jpeg` 后客户端解码失败;`location_key→FileBlob` 元数据 LRU 消除每 chunk PG 查,≤256KB sticker/reaction/thumb 小 blob 走 `object_key→bytes` LRU 并由启动 `WarmCaches` 预热;CDN/legacy `inputFileLocation`/`inputStickerSetThumb` 返回 `LOCATION_INVALID`(todo);location 仅按 `id` 解析,**不校验 `access_hash`/`file_reference`**(安全取舍见下方 note) | -| upload.getFileHashes | stub | empty | 本阶段不做 CDN/分片完整性校验,返回空 hash 列表(客户端信任数据) | -| photos.uploadProfilePhoto | done | real-photo | 组装上传分片→建头像 Photo(合成 a/c 尺寸,落 `photos`/`file_blobs`/`profile_photos(kind=profile|fallback)`),`fallback=true` 设为 fallback 当前头像,否则设为真实 profile 当前头像;返回 `photos.photo{photo, 带头像 self}` 并向其它在线 session 推 `updateUser`+self;video/emoji-markup 返回 `PHOTO_INVALID` | -| photos.updateProfilePhoto | done | real-photo | `inputPhoto` 把历史 photo 设为当前 profile/fallback;`inputPhotoEmpty` 停用对应 kind 当前头像;变更后向其它在线 session 推 `updateUser`+self 同步头像 | -| photos.uploadContactProfilePhoto | todo | personal-real/suggest-pending | `save=true + file` 设置当前 viewer 对目标 contact 的 personal photo,后续 `userProfilePhoto.personal=true`;`save=true + no file` 清除 personal photo;`suggest=true` 当前只创建/返回 photo,私聊 `messageActionSuggestProfilePhoto` 仍待 private service-message action 模型 | -| photos.getUserPhotos | done | real-photo | 只按真实 `profile_photos(kind=profile)` 返回某用户头像历史(最新在前),不混入 fallback/personal;offset/limit/max_id 有界,返回 `photos.photos[Slice]` + target user | -| photos.deletePhotos | done | real-photo | 只停用真实 profile kind 的 inputPhoto id,返回被删 id 列表;fallback/personal 不受影响 | - -> 头像渲染:用户头像统一来自 `internal/app/userprojection`,顺序为 viewer personal photo > ProfilePhoto privacy 允许的真实 profile > fallback > 空头像;同时叠加联系人备注/本地手机号、PhoneNumber/StatusTimestamp privacy 裁剪,`tgUser` 输出 `userProfilePhoto.personal`。`users.getFullUser` 额外回填 `personal_photo/profile_photo/fallback_photo` 与 About privacy。channel 头像反范式 `channels.photo_*`,`tgChannel.Photo`/`tgChannelFull.ChatPhoto` 渲染。资源 seed 来自 `TELESRV_STICKER_SEED_DIR` 真实导出,启动时幂等导入(reactions/default 系统集/常规集)。 - -> 头像多设备同步:`uploadProfilePhoto`/`updateProfilePhoto`(含 `inputPhotoEmpty` 清除)变更后复用 `pushUserUpdates`,向该账号其它在线 session 推 `updateUser` + `Updates.users` 携带含新 `userProfilePhoto` 的 self;当前设备经 RPC 返回更新,TDesktop 经 `processUser→setPhoto→peerUpdated(Photo)` 即时刷新(与 参考实现 `MakeUpdatesByUpdatesUsers([self],updateUser)` 对齐;`updateUserName` 不含 photo 无法刷新头像)。联系人/对话方不主动广播头像(Telegram 同行为,对端下次拉取 user 时刷新);频道头像变更经 `channels.editPhoto` → `pushChannelStateToMembers` 推在线成员。 - -> 安全取舍(待生产补强):`upload.getFile` 与 `messages.sendMedia` 引用既有 `inputMediaPhoto`/`inputMediaDocument` 均只按 `id` 解析、**不校验 `access_hash`/`file_reference`**(不返回 `FILE_REFERENCE_EXPIRED`),依赖 64-bit `crypto/rand` 不可枚举 id 防越权——任意登录用户若得知某 `document`/`photo` id 即可引用/下载其内容。dev 单 DC 可接受,生产需补 owner/access_hash 校验与 file_reference 轮换。 - -> 媒体性能与容量债见 `performance-audit.md`「媒体管线」条目:`upload_parts` 无 GC/每用户配额(未 assemble 分片滞留 PG)、`media` JSONB 内联放大 fan-out 写、`getUserPhotos` N+1 + OFFSET 分页。`upload.getFile` 整文件入内存 + 每 chunk 查 PG 已于 2026-06-03 修:`BlobBackend.GetRange` 段读(`ReadAt`)+ `location_key→FileBlob` 元数据 LRU;sticker/reaction/thumb 首开冷路径已补 `object_key→bytes` 小 blob LRU、完整 sticker set cache 与启动预热。 - -## Unknown Trace - -未注册 RPC 经 rpc.Router fallback 记录到日志(type_id + layer),返回 NOT_IMPLEMENTED rpc_error。 -最近一次 TDesktop 联调发现的异步预取 trace 已全部转入上方各分区并实现第一阶段空响应;2026-05-31 复跑登录注册→空账号主界面后,无 `Unhandled RPC` / `NOT_IMPLEMENTED`。 - -| method/type_id | first_seen | raw_note | -|---|---|---| -| — | — | 当前无未实现 RPC trace | diff --git a/docs/message-module.md b/docs/message-module.md deleted file mode 100644 index b5cb7b03..00000000 --- a/docs/message-module.md +++ /dev/null @@ -1,270 +0,0 @@ -# Message Module Design - -Date: 2026-05-31 - -## Scope - -二阶段先实现私聊文本闭环: - -- `messages.sendMessage` -- `messages.forwardMessages` -- `messages.getDialogs` -- `messages.getHistory` -- `messages.readHistory` -- `messages.editMessage` -- `messages.deleteMessages` -- `messages.deleteHistory` -- `updates.getState` -- `updates.getDifference` -- 在线 session update 推送 - -不在本阶段实现:媒体、定时消息、群组/频道、Bot API、文件 DC。 - -## Storage - -大表从第一版开始分区: - -| table | partition key | purpose | -|---|---|---| -| `private_messages` | `sender_user_id` HASH | 共享私聊消息主体;`sender_user_id + random_id` 保证发送幂等 | -| `message_boxes` | `owner_user_id` HASH | owner 视角消息盒;TDesktop 看到的 message id 即 `box_id`,并保存当前 owner 的 `media_unread/reaction_unread` 内容已读状态 | -| `dialogs` | `user_id` HASH | 会话摘要;`folder_id=0/1` 表示主列表/归档,置顶、manual unread、action bar 隐藏均是 owner 视角 | -| `contact_blocks` | `owner_user_id` HASH | 当前 owner 的 blocklist;`owner_user_id + blocked_user_id` 唯一,作为本阶段私聊 privacy gate | -| `dialog_filters` / `dialog_filter_settings` | `user_id` HASH | TDesktop 自定义 dialog filter、filter 顺序与 folder tags 开关;不把自定义 filter 伪装成 dialogs.folder_id | -| `user_update_events` | `user_id` HASH | 账号级 pts durable log;承载新消息、已读 inbox/outbox、文本编辑、删除消息,也承载 contacts reset、dialog pinned/order/manual unread、peer settings、dialog filters 与 folder peers 等 owner 视角状态事件 | -| `dispatch_outbox` | `target_user_id` HASH | transactional outbox,事务后批量推送在线 session;投递成功即删除,仅保留未完成任务 | - -Redis 只存可恢复计数: - -- `counter:pts:{user_id}`:账号级 pts。 -- `counter:box_id:{user_id}`:owner 视角 box_id。 -- `ratelimit:messages:send:{user_id}`:发送窗口限流,当前为每用户每分钟 30 条。 - -Redis miss 时分别从 `MAX(user_update_events.pts)` 与 `MAX(message_boxes.box_id)` 恢复。 -分配器热路径使用 Redis Lua 脚本递增;首次 miss 时先从 PG durable log 读取恢复值,再通过恢复脚本完成「初始化 + 首次自增」,避免并发 first-use 把 `pts` / `box_id` 分配出重复或回退。 - -## Send Flow - -`messages.sendMessage` 在 RPC 层只做 TL 转换和当前 user/session 校验,业务写入由 `MessageStore.SendPrivateText` 单事务完成: - -1. 写 `private_messages`,遇到同 `sender_user_id + random_id` 直接返回原消息盒。 -2. Redis 分配 sender/recipient 的 `box_id` 与 `pts`。 -3. 写 sender/recipient `message_boxes`,并保存 `silent/noforwards/reply_to/fwd_from` 元数据;reply 会把当前 owner 的 `reply_to_msg_id` 翻译成对端 owner 视角的 box_id。incoming media 在 recipient box 上置 `media_unread=true`,sender 自己始终为 false。 -4. upsert 双方 `dialogs`。 -5. 写双方 `user_update_events(new_message)`。 -6. 写双方 `dispatch_outbox`,sender 侧带 `exclude_session_id`。 -7. 提交后由 outbox worker 批量推送在线 session。 - -若事务失败但 Redis 已分配 pts,store 会尽力写 `noop` 事件占位,避免 pts 回退;PG 不可用时该补偿也可能失败,后续需要告警指标覆盖。 - -如果 recipient 已 block sender,`SendPrivateText` 仍写 sender outbox/dialog/update,保证当前用户能看到自己发出的消息;但不创建 recipient message box、不推进 recipient pts、不写 recipient dispatch_outbox,也不会进入 recipient 离线 `updates.getDifference`。该规则同样适用于 `messages.sendMedia/sendMultiMedia` 和私聊转发,因为它们共用 `sendOutgoing`/`SendPrivateText`。 - -## Forward / Reply Flow - -`messages.forwardMessages` 当前覆盖私聊文本转发,参考实现 的业务语义但保持 telesrv 的 owner 视角模型: - -1. RPC 层校验 from/to peer、id/random_id 等长、单次最多 100 条;scheduled/monoforum/quick reply/effect/paid/suggested 等当前阶段外能力用显式 TL 错误拒绝,目标为 channel 的 `send_as` 交由频道模块校验 self/current channel。 -2. store 按当前 owner + from peer + box_id 保序读取源消息,源消息带 `noforwards` 时返回 `CHAT_FORWARDS_RESTRICTED`。 -3. 未设置 `drop_author` 时,若源消息已有 forward header 则沿用;否则用源消息 `from/date` 生成 `messageFwdHeader`。`drop_author` 会去掉 forward header。 -4. 每条转发复用 `SendPrivateText` 写入双端 message box / dialog / update event / dispatch outbox,`pts_count=1`,并使用对应 random_id 保证幂等。 - -`messages.sendMessage` 的 `reply_to` 支持 `inputReplyToMessage` 的私聊同 peer 回复和 quote 文本/entities/offset;`quote_text` 按 TDesktop `quote_length_max=1024` 限制,`quote_offset` 是原消息文本内 offset,不是 message id,按当前文本消息上限 4096 收口。cross-peer reply、story、monoforum、todo/poll reply 仍拒绝,但返回 `REPLY_MESSAGE_ID_INVALID` / `STORY_ID_INVALID` / `REPLY_TO_MONOFORUM_PEER_INVALID` / `POLL_OPTION_INVALID` 等显式错误,不再落 `NOT_IMPLEMENTED`。服务端保存 sender 视角和 recipient 视角各自的 `reply_to_msg_id`,避免一个 owner 的 box_id 泄漏到另一个 owner。 - -## Read Flow - -`messages.readHistory` 采用 owner 视角的双端回执模型,参考实现 的 inbox 已读链路和 参考实现 的 inbox/outbox read history event: - -1. reader 侧锁定当前 dialog,按 `max_id` 与当前可见 incoming message 计算新的 `read_inbox_max_id`,清零 unread_count 并清除 manual unread。 -2. 如果 reader 确实推进了已读水位,给 reader 生成 `updateReadHistoryInbox(pts_count=1)`,写入 `user_update_events + dispatch_outbox`。 -3. 同一事务中找出本次被读到的最新 incoming message,定位原 sender 的 dialog,推进 sender 侧 `read_outbox_max_id`。 -4. sender 水位真正推进时,给 sender 生成 `updateReadHistoryOutbox(pts_count=1)`,离线设备可通过 `updates.getDifference` 补齐。 - -发送消息时不会预先推进 sender 的 `read_outbox_max_id`;只有对端实际 readHistory 后才产生 outbox 已读回执,避免“刚发出就被标成已读”的假状态。 - -`messages.getOutboxReadDate` 复用 sender 侧 durable `read_history_outbox` 事件:先校验当前 owner 的 `msg_id` 是该 peer 下可见 outgoing message,再取最早一条 `max_id >= msg_id` 的 outbox read event 日期返回 `outboxReadDate`;未被读到返回 `MESSAGE_NOT_READ_YET`。PG 上有 `(user_id, peer_type, peer_id, max_id, date) WHERE event_type='read_history_outbox'` partial index,避免 TDesktop 已读详情查询扫全量 update log。 - -## Content Read Flow - -`messages.readMessageContents` 处理 TDesktop 打开媒体/反应等“内容已读”入口,不等同于 history read 水位: - -1. 私聊 incoming media 创建 recipient message box 时置 `media_unread=true`;sender 自己为 false。 -2. 私聊 reaction 写入时,若反应者不是原消息作者,会把原作者 owner 视角的 message box 标记 `reaction_unread=true`,并重算该 dialog 的 `unread_reactions_count`。 -3. `readMessageContents` 在事务内锁定当前 owner 的 exact message boxes,只清理 `media_unread OR reaction_unread` 的行;不可见 id、已删除 id、已经 read 的 id 都不生成新 pts。 -4. 实际清理时为当前 owner 分配连续 user pts,写 `user_update_events(read_message_contents)` 与 `dispatch_outbox`,TL 转换为 `updateReadMessagesContents{messages,pts,pts_count}`。重复调用返回当前 affectedMessages,`pts_count=0`。 -5. 该事件排除当前 auth_key/session,其它在线 session 走 reliable outbox,离线设备通过 `updates.getDifference` 恢复。 - -## Edit Flow - -`messages.editMessage` 当前只支持私聊文本编辑: - -1. RPC 层校验当前账号、peer、message id、文本长度和 entities;`inputMediaWebPage/inputMediaEmpty` 降级为文本编辑,真实 media/reply_markup/quick replies/scheduled 返回显式 TL 错误。 -2. store 锁定当前 owner 的 message_box,确认它是当前 user 发出的 outgoing 私聊消息。 -3. 更新共享 `private_messages` 文本与 `edit_date`,并更新同一 private message 下所有未删除 owner message_box。 -4. 每个受影响 owner 各自分配 `pts`,写 `updateEditMessage(pts_count=1)` 与 `dispatch_outbox`。 -5. 当前请求设备直接拿到 `updates` 响应;其它在线设备走 reliable outbox,离线设备走 `updates.getDifference`。 - -如果文本和 entities 完全未变化,返回 `MESSAGE_NOT_MODIFIED`;非作者编辑返回 `MESSAGE_AUTHOR_REQUIRED`。 - -若 peer 已 block 当前用户,私聊 edit 会返回 `EDIT_MESSAGES_FORBIDDEN`,避免修改对方侧已存在的 message box。该 gate 只来自 `contact_blocks`;完整 Telegram privacy keys 暂不扩展。 - -## Delete Flow - -`messages.deleteMessages` / `messages.deleteHistory` 以 owner 视角软删除 `message_boxes`,不会删除共享 `private_messages` 主体。默认 deleteHistory 清空后如果该 peer 已无可见消息,则删除当前 owner 的 dialog;后续任意新消息会通过正常 send/upsert 路径重建 dialog。`just_clear=true` 对齐 参考实现 语义:清空历史但保留一个空 dialog(当前阶段不生成 `messageActionHistoryClear` 服务消息)。 - -`revoke=true` 时按 `(message_sender_id, private_message_id)` 找到同一私聊消息在其它 owner 下的 message_box 并软删除。每个受影响 owner 都生成自己的 `updateDeleteMessages`,`message_ids` 是该 owner 视角 box_id,`pts_count=len(message_ids)`,并写入 `user_update_events + dispatch_outbox`。如果删除后仍有可见消息,dialog 的 top/unread 会按剩余消息重算;否则删除 dialog 或在 `just_clear` 下保留空 dialog。 - -若 `revoke=true` 会影响已 block 当前用户的一方,RPC 层返回 `DELETE_MESSAGES_FORBIDDEN`;`revoke=false` 或本地清理仍只影响当前 owner,可继续执行。 - -全清也必须让所有被删的 owner 视角 message_id 最终进入 update/difference,但不能合成一个超大 update。`messages.deleteHistory` 单次最多删除 `MaxDeleteHistoryBatch=1000` 条,按 box_id 倒序批量软删并返回 `affectedHistory.offset=1` 表示客户端应继续发起下一批;每一批各自产生一条有界 `updateDeleteMessages`。`messages.deleteMessages` 单次 id 数限制为 `MaxDeleteMessageIDs=1000`,服务端还会丢弃 `<=0` 或超过 TL/PG int4 可表达范围的 id。 - -## Query Path - -- `messages.getHistory` 以 `owner_user_id + peer_user_id + box_id/date` 命中 `message_boxes` 分区索引;`offset_id`、`offset_date`、`max_id`、`min_id` 都下推为游标条件;`add_offset` 只允许 `[-100,100]` 的小窗口偏移,避免异常客户端把超大偏移变成 SQL 跳过扫描或内存 slice capacity。 -- `messages.search` / `messages.searchGlobal` 当前只覆盖当前 owner 私聊文本搜索;查询仍限定在 `owner_user_id` HASH 分区内,文本条件由 `pg_trgm` GIN 索引兜底。参考实现 的经验,未接外部全文搜索前禁止无索引大表模糊扫;后续群组/频道/全局多 peer 搜索应接专用搜索索引或 FTS。 -- `messages.getDialogs` 以 `user_id` 分区定位当前账号,再按 `top_message_date/top_message_id/peer_id` 做 seek pagination;folder_id=0/1 直接走 `dialogs.folder_id`,folder_id>=2 先取当前账号 `dialog_filters` 后按 include/exclude/contact 规则过滤;`hash` 与 `count` 基于当前筛选后的完整会话集计算。 -- `messages.getDialogs/getPeerDialogs/getHistory/getMessages/search` 返回的 users 统一经过 `internal/app/userprojection`:批量读取 viewer personal photo、`profile_photos(kind=profile|fallback)`、当前 viewer contacts 与 account privacy,按 personal > allowed profile > fallback > empty 选择头像,并按联系人备注/PhoneNumber/StatusTimestamp privacy 裁剪姓名、手机号与状态。该路径是 TDesktop dialog 标题、头像、history 标题与 action bar 用户状态的公共入口,禁止在各 handler 内重复拼用户资料。 -- `messages.getDialogFilters` 返回 `dialogFilterDefault` + 当前账号持久化 filters;`messages.updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags` 与 `folders.editPeerFolders` 都是 owner 视角写入,归档只允许 folder_id 0/1,自定义 filters 从 ID 2 开始。 -- `updates.getDifference` 只按 `user_id + pts` 顺序扫描 `user_update_events`,多设备各自的 `(auth_key_id,user_id)` state 只记录消费位置,不参与账号事件归属;离线设备通过同一条 durable log 恢复新消息、已读 inbox/outbox、内容已读、文本编辑、删除消息、联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filters/order/reload 与 folder peers 变化,置顶顺序、peer settings flags、filter payload 与 folder peers 会随事件负载持久化。 - -## 参考实现 Comparison - -参考实现 的 `SyncUpdatesNotMe` 设计有两个值得借鉴的点:所有“其它端通知”收敛到 sync 服务,以及带 pts 的消息/已读类 update 会写 `user_pts_updates` 后再推 session。它的不足也很明确:`updatePeerSettings` 只在线 push 不入 durable pts 队列,`markDialogUnread` / `hidePeerSettingsBar` 仍有 TODO,离线设备可能依赖后续主动刷新才能看到状态变化。 - -telesrv 当前做得更进一步:联系人 reset、dialog pinned/order/manual unread、peer settings、dialog filter/order/reload 与 folder peers 都写入账号级 `user_update_events`,并通过 Postgres `AppendWithDispatch` 在同一事务里写入 `dispatch_outbox`。`updatePinnedDialogs.order`、`updatePeerSettings.settings`、`updateDialogFilter.filter`、`updateDialogFilterOrder.order`、`updateFolderPeers.folder_peers` 都是 durable payload,不依赖后续主动刷新;在线投递由 outbox worker 负责,排除当前设备时同时携带 `exclude_auth_key_id + exclude_session_id`,避免同一 session_id 在不同 auth key 下误排除或串号。RPC handler 在检测到可靠 outbox 后不再额外手动 push,避免其它设备收到重复在线通知。 - -dialog 分组借鉴 参考实现 的 `dialog_filters`/`editPeerFolders` 业务语义与 参考实现 的 `UpdateDialogFilter` / `UpdateFolderPeers` 事件模型,但修正两点:filter 设置不仅在线 push,也有持久化真值表和 durable update;归档 folder 与自定义 filter 分离,避免一个 `folder_id` 字段同时承担归档状态和任意筛选规则。 - -删除链路借鉴 参考实现 的 `just_clear` / dialog 删除边界:普通 deleteHistory 会移除 dialog,`just_clear` 保留 dialog。借鉴 参考实现 的点是“先按 owner 计算受影响 message_id,再按 owner 重建 top message 并发 `updateDeleteMessages`”,而不是只对当前请求账号返回 affectedMessages;这样 revoke、多设备离线补偿和 dialog 后续重建都在同一条 owner 视角语义里。 - -已读和编辑链路也分别借鉴了两边的优点:参考实现 在 edit 时会校验 sender 并把编辑同步到 inbox/outbox,参考实现 把 readHistory 分成 reader 的 `UpdateReadHistoryInbox` 和 sender 的 `UpdateReadHistoryOutbox`。telesrv 将这两类事件都落到账号级 durable log,同事务写 reliable outbox,且 edit 更新共享消息主体和所有 owner 视角 message_box,避免只在线 push 或只改当前盒子导致离线设备/对端历史不一致。 - -reply/forward 的语义参考实现 的 handler:send/forward 都透传 `silent` 和 `noforwards`,reply 用 `inputReplyToMessage`,forward 在源消息没有 `fwd_from` 时构造原作者 header,遇到受保护内容拒绝转发。telesrv 额外修正 owner 视角 id 翻译与 durable update 负载:对端收到的 reply header 使用对端自己的 message id,离线设备通过 `updates.getDifference` 也能拿到同样的 `reply_to/fwd_from` 元数据。 - -## Observability - -消息链路 RPC 层预留 `Metrics` 接口,当前覆盖: - -- `MessageSend`:记录发送成功、payload 长度与双端 pts。 -- `MessageRateLimited`:记录用户级窗口限流。 -- `OutboxClaimed` / `OutboxDelivered` / `OutboxFailed`:记录 outbox claim、在线推送成功与重试失败。 - -默认实现是 no-op,生产接 Prometheus/OpenTelemetry 时只需在 `rpc.Deps` 注入实现,不污染业务服务和 store 边界。 - -## Next Execution Plan - -连接层已具备 per-connection outbound actor、scoped session context 与 bounded inbound RPC scheduler。消息模块下一轮按以下顺序推进,不再先扩 RPC 面: - -1. 压测基线:单机目标 200 msg/s 私聊文本、p99 sendMessage < 150ms、p99 getDifference < 100ms。已落地 `internal/loadtest` 真实 PG+Redis 压测 harness,首版基线见下「Load Baseline」节;1k online session 的网络 fanout 属连接层,本轮 harness 未覆盖(binder 用零连接 SessionManager,只测 outbox 的 PG 排空)。 -2. Redis 计数强一致:`pts` / `box_id` 分配已收敛到 Lua 脚本路径,覆盖 Redis miss 从 PG durable log 恢复与并发 first-use 连续分配;事务失败 noop gap 与 random_id 幂等已有 store 测试覆盖,后续补真实 Redis+PG 混合压测。 -3. PG 分区验收:已有 `message_boxes` history seek 与 `dispatch_outbox` stale retry 索引;已补 explain 集成测试,固定 `message_boxes` / `user_update_events` 单用户查询命中分区索引,`dispatch_outbox` 全局 claim 允许跨分区但必须走分区 partial index、不出现 Seq Scan。 -4. Outbox 背压(已做到生产级,见下「Load Baseline」):worker 有 batch claim、lease timeout、retry/backoff、failed 终态和 claim/deliver/fail 指标;batch size / interval / lease timeout / **worker 数** 均配置化(env `TELESRV_OUTBOX_BATCH` / `_INTERVAL` / `_LEASE_TIMEOUT` / `_WORKERS`)。**已并行化(N worker 竞争 `FOR UPDATE SKIP LOCKED` claim)并批量化(每批一次 `BatchListDispatchEvents` unnest join + 一次 `MarkDispatchDeliveredBatch`,取代逐条往返)**,排空上限从单 worker ~270 行/s 提到 ~12k 行/s(本机 PG,~45x);批量化后瓶颈转移到 PG 本身。主动 push 仍只走 `auth_key_id + session_id + user_id` scoped API。 -5. TDesktop 私聊闭环:用双账号/多设备验证 sendMessage、forwardMessages、reply、getDialogs、getHistory、readHistory、getOutboxReadDate、editMessage、deleteMessages/deleteHistory、updates.getDifference、当前设备过滤与退出换号不串号。2026-05-31 已用 Alice/Bob 两个 TDesktop workdir 验证在线 read/edit/delete 与 Bob 离线 send+edit/read/delete 差量同步;删除后 dialog 可清除,后续新消息仍按正常路径重建。2026-06-01 已补 reply/forward 服务端路径和单测,仍待重新用双 TDesktop 实机点 UI 行为(通知声音、reply 预览、forward header)。 -6. 兼容矩阵回填:所有真实跑到的未知 RPC 自动入 trace,按 TDesktop 首屏和私聊流程补 `done/stub/todo` 状态。 - -## Load Baseline - -压测 harness:[`internal/loadtest/send_load_test.go`](../internal/loadtest/send_load_test.go),env-gated,未设 `TELESRV_TEST_POSTGRES_DSN`+`TELESRV_TEST_REDIS_ADDR` 时 Skip,对默认 `go test ./...` 无副作用。 - -**方法**:与 main.go 一致装配连接池 + Redis 分配器 + PG 消息存储 + transactional outbox + 真实 `OutboxDispatcher`(binder 用零连接 `SessionManager`,`PushToUserExceptSession` 返回 0,因此 outbox 走完整 `claim→批量取事件→push→批量标记` 的 PG 往返,测排空而非网络 fanout)。closed-loop 饱和:`concurrency` 个 worker 各自不停发满 `messages` 条。`TELESRV_LOAD_DEFER_DISPATCH=1` 时先把积压攒满、发送结束才启 dispatcher,用以隔离测量 outbox **纯排空上限**(否则 dispatcher 实时跟上发送、积压近 0 量不出天花板)。 - -**运行**(PowerShell): - -```powershell -$env:TELESRV_TEST_POSTGRES_DSN = "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable" -$env:TELESRV_TEST_REDIS_ADDR = "localhost:6399" -# 稳态(dispatcher 与发送并发) -go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 -timeout 240s -# 纯排空上限(先攒满积压再排空) -$env:TELESRV_LOAD_MESSAGES=30000; $env:TELESRV_OUTBOX_BATCH=200; $env:TELESRV_LOAD_DEFER_DISPATCH=1 -go test ./internal/loadtest/ -run TestMessageSendBaseline -v -count=1 -timeout 300s -``` - -可调 env:`TELESRV_LOAD_USERS`(50) / `_CONCURRENCY`(32) / `_MESSAGES`(5000) / `_POOL_CONNS`(64) / `_DEFER_DISPATCH`;outbox 用 `TELESRV_OUTBOX_WORKERS`(loadtest 默认 8,运行时默认 2) / `_BATCH`(100) / `_INTERVAL` / `_LEASE_TIMEOUT`;`TELESRV_LOAD_ENFORCE_SLO=1` 时 SLO 超标硬失败(回归门禁)。正确性(无发送错误/无意外重复/全部发出/outbox 必须排空/无终态失败)始终硬断言。 - -**生产化做了什么**(2026-05-31): - -- **连接池配置化**(`TELESRV_POSTGRES_MAX_CONNS` 默认 50,`TELESRV_POSTGRES_MIN_CONNS` 默认 16,经 `postgres.WithMaxConns/WithMinConns`)。`postgres.Open` 会在启动时显式预热 min 连接,避免 TDesktop 双端冷启动时大量 RPC 一边建 PG 连接一边排队;pgx 默认 `max(4,NumCPU)` 在高并发下排队,是首版 send max 2s 突刺的根因。 -- **dispatcher 并行**(`TELESRV_OUTBOX_WORKERS`,运行时默认 2,loadtest 默认 8,`rpc.WithOutboxWorkers`)。N worker 各跑 claim 循环,靠 `FOR UPDATE SKIP LOCKED` 互不重叠;本地 TDesktop 双端启动默认偏保守,避免多个 worker 同扫 64 分区父表触发 PG lock/shared-memory 压力。 -- **dispatcher 批量**(`store` 具备批量能力时自动启用,否则逐条回退)。每批一次 `BatchListDispatchEvents`(双 `unnest WITH ORDINALITY USING(ord)` 配对 (user_id,pts) 的 join)+ 一次 `MarkDispatchDeliveredBatch`,把每条 ~2 次 PG 往返降到每批 ~3 次。 - -**outbox 排空上限演进**(本机 docker PG+Redis,攒满 6 万行积压后纯排空): - -| 方案 | drain 速率 | 相对 | -|---|---|---| -| 单 worker 逐条(首版) | ~270 行/s | 1x | -| 8 worker 逐条(仅并行) | ~1711 行/s | ~6x | -| **8 worker 批量(当前)** | **~12285 行/s** | **~45x** | - -稳态下(concurrent,出厂默认 users=1000/concurrency=32/8 worker/batch100):**峰值积压仅 ~330 行、排空 ~80ms**——dispatcher 实时跟上发送(~2500 行/s 入队);send p99 ~114ms、getDifference p99 ~2ms、吞吐 ~1255 msg/s,SLO 全过。12k 行/s ≈ 6k msg/s 可持续 outbox 吞吐;16 worker/batch500 仍 ~11.7k 行/s(与 8 worker 持平 → 瓶颈已是 PG 而非 worker 数)。 - -> send p99 对**用户池集中度**敏感:harness 用 50 用户时 p99 飙到 ~220–325ms(32 并发挤少数用户的 dialog/message_box 行,行锁争用),2000 用户降到 ~58ms。这是小池假象,生产 20 万用户分散后争用极低;默认已取 users=1000。 - -**面向 20 万在线的剩余 levers(按 §8 记账,本轮未做)**: - -1. **PG 已是共享瓶颈**:批量化后 send 与 drain 都打在同一 PG 上,本机 ~12k 行/s 触顶。生产需更强的 PG(多核/NVMe/调参),更高规模再上读副本 / 按 user 分库。worker/batch/池均可调以匹配硬件。 -2. **send 路径 Redis 分配已移出事务(已做)**:原先 sender/recipient 的 `pts`/`box_id` 分配(4 次 Redis 往返)嵌在 PG 事务内,持连接空等 Redis。现已移到 `BeginTx` 之前——分配本就走 Redis 不属 PG 事务,前移后不再在持有 PG 连接(与行锁)期间空等,连接周转更快(池受压时收益最大;收益随 Redis RTT 增大,远端 Redis 更明显)。本机 docker(Redis 亚毫秒 RTT)下 pool=24/conc=96(4x 超订)实测 send p99 ~130ms(<150ms)、~1800 msg/s、0 err 0 dup、SLO 全过。 - - **配套的 pts 连续性兜底(mtproto 对齐,已做)**:分配前移加宽了「pts 已分配未提交」的瞬时空洞窗口(并发发送 commit 重排序,本就存在),故 `updates` 服务改为只暴露**连续 pts**:`getState` 报告最大连续已提交 pts(非 allocator 最大已分配值),`getDifference` 只返回从客户端 pts 起连续的事件、遇空洞即截断、`State.Pts` 取最后连续值、超 `limit`(100) 置 `updates.differenceSlice`。客户端永不越过在途空洞,空洞由 commit/补洞在毫秒内自愈,下次拉取补齐——绝不丢消息。连续值由 `UpdateEventStore.MaxContiguousPts`(PG 顶部 4096 窗口)计算。正确性已由单测(空洞截断/slice 翻页/getState 连续)+ 真实 PG 集成测试(200 并发发送 pts 严格 1..200 无空洞无重复无丢失、PG 空洞场景)覆盖。 - - 仍可继续:把 4 次 Redis 分配 pipeline/合并成 1 次(每用户 pts+box 一个 Lua、sender/recipient 并行),进一步缩短分配耗时。 -3. **连接层 `SessionManager` 全局锁——已实测,非吞吐瓶颈(结论被数据修正)**:原假设是"pushToUser 抢全局锁 = 20 万在线最大瓶颈"。新增连接层 benchmark(`session_manager_bench_test.go`,20 万连接、内存 Conn 不走真实 socket)实测: - - **push fanout**:~4.6M push/s(24 核),延迟 1→24 核仅 249ns→328ns,**几乎不随核数恶化**——临界区只是 snapshot 一个 per-user 小 map,锁极短。20 万在线 @ 200 msg/s × 双端 = ~40 万 push/s,**比上限低一个数量级,push 不是瓶颈**。 - - **Register/Unregister churn**:全局**写锁**,~2067ns/op @ 24 核 ≈ 48 万 churn/s。稳态 churn(20 万连接 × 平均存活分钟级)≈ 千级/s,余量巨大。 - - mutexprofile 确认两者都 100% 串行在同一把锁,但因临界区极短,**当前规模下不构成瓶颈**。 - - **真正的风险点**(分片的价值所在,但非紧急):重连风暴(断网恢复 / 发版重启)时瞬时 churn 飙升,**写锁会阻塞同期所有 push 读锁**。按 userID 分片可把写锁从全局 1 把拆成 N 片,churn 风暴只影响 1/N 的 push——这是**稳健性/尾延迟**提升,不是吞吐解锁。建议留待真有重连风暴尾延迟问题、或单机连接数再上一个量级时再做。 - - 仍未覆盖:真实 socket 的 per-conn 加密/写出背压、20 万 fd 的内核态开销——那需要真实网络压测环境,非本 harness(内存 Conn)目标。 - -## Multi-Account / Multi-Device - -- `authorizations` 是 auth_key 到 user 的权威绑定。 -- active session 缓存 `auth_key_id + user_id`;router 额外按业务 auth_key 缓存 `user_id`,并用 singleflight 合并启动期并发 miss,避免同一永久 auth_key 派生的多个 temp session 重复查授权表。 -- `update_states` 主键是 `(auth_key_id, user_id)`;同设备退出登录或换账号只清 auth_key 设备状态,不删除账号级 `user_update_events`。 -- 一个账号多设备共享 `user_update_events`,各设备通过自己的 `auth_key_id + user_id` 状态和 `updates.getDifference` 补偿。 -- 联系人备注、联系人 mutual/shareContact 状态、dialog pinned/order/manual unread、peer settings 都是 owner 视角数据;写业务表后同步写账号级 durable update event,并把投递任务写入 `dispatch_outbox`,避免“只在线 push、离线设备永远不知道”的状态漂移。 - -## ACK / Global Sequence Note - -`参考实现` 的可借鉴点是:`msgs_ack` 不直接生成业务事件,它只确认某个已发送 server msg_id / RPC response 已被客户端收到;server 通过 ack cache 找回该响应对应的 `pts` / `globalSeqNo`,再推进 `(auth_key_id,user_id)` 维度的已确认水位。这个设计适合频道、多 peer 混合更新和 server 侧 delivered watermark。 - -当前阶段私聊文本只需要账号级 `pts` durable log,客户端 `updates.getDifference` 仍以请求里的 `pts` 为准;MTProto ACK 只释放出站重发缓存。后续引入频道或跨 peer 全局更新流时,再新增独立的 per-device `global_seq_no` / delivered watermark,不把它塞进 MTProto ACK 状态本身。 - -## 客户端 pts 重排依赖 pts_count 准确(TDesktop PtsWaiter,已核对源码) - -> 结论来自实读 TDesktop pinned baseline(`9caf32dffc`)源码: -> `tdesktop/Telegram/SourceFiles/data/data_pts_waiter.cpp`、`data_pts_waiter.h`、`api/api_updates.cpp`。 - -**背景**:outbox worker 多 worker 并发 claim + 发送事务乱序提交,所以**主动推送可能乱序到达客户端**(pts=6 的 `UpdateNewMessage` 先于 pts=5 发出)。这不会让客户端乱序或丢消息——前提是每条 update 的 `pts` / `pts_count` 准确。 - -**客户端如何处理乱序**(`PtsWaiter::check`,data_pts_waiter.cpp:170-187):客户端维护 `_good`(已应用的最大连续 pts)、`_last = max(见过的 pts)`、`_count += 每条 update 的 pts_count`: - -- 收到 pts=6(count=1):`_last=6, _count=5` → `_last > _count`(中间缺 pts)→ 这条 update **不应用,先缓存进 `_updatesQueue`**,`setWaitingForSkipped(1000ms)`。 -- 随后收到 pts=5(count=1):`_last=6, _count=6` → 相等 → `_good=6`,`applySkippedUpdates` 把缓存的 5、6 **按序应用**(data_pts_waiter.cpp:48-72)。 -- 若 1 秒(`kWaitForSkippedTimeout`,data_pts_waiter.h:24)内空缺没补上 → 触发 `getDifference` 主动补齐。 - -即客户端**缓存乱序、等空缺、按序应用,1 秒补不齐才 getDifference**——比"丢弃重拉"更优雅。普通私聊 updates 与 channel 复用同一个 `_ptsWaiter` 单例(调用时 channel 传 `nullptr`,api_updates.cpp:603-604)。 - -**对 server 的硬约束**:客户端这套重排完全依赖 `_count += pts_count` 累加,因此 server 必须保证: - -1. **每条 update 的 `pts_count` 准确等于它推进的 pts 步数**。私聊文本、已读 inbox/outbox、文本编辑恒为 1;批量删除为 owner 视角删除 message_id 数量:`UpdateDeleteMessages{Pts: event.Pts, PtsCount: len(message_ids)}`。 -2. **每个分配出去的 pts 最终都能被 getDifference 拿到**(即使事务回滚,也要写 `noop` 占位,见 `recordPtsGaps`)——否则连续水位永远卡在空缺处,客户端会永久 `getDifference` 重试或永久 gap。 - -**当前安全**:私聊文本、转发、已读回执、文本编辑 `pts_count` 恒为 1,删除消息 `pts_count` 等于删除数量;每个 pts 都有真实事件或 noop 占位,且 getState/getDifference 只暴露连续 pts(见上文 Load Baseline 的「pts 连续性兜底」)。**未来风险**:引入其它“一次操作产生多条 update / `pts_count ≠ 1`”的场景(批量 service action、频道 editMessage 等)时,若 server 的 `pts_count` 算错,客户端 `_count` 会永久错位 → 永久 gap。新增此类 RPC 时必须同步核对 pts_count 语义。 - -## Current Limits - -- `messages.sendMessage` 仅支持私聊文本;reply 仅支持同一私聊 peer 的 `inputReplyToMessage`。 -- `messages.forwardMessages` 仅支持当前 owner 可见的私聊文本消息转发到 user peer,单次最多 100 条。 -- `messages.editMessage` 仅支持文本消息编辑;网页预览 media 降级文本编辑,真实媒体、reply markup、quick replies、scheduled edit 留后续并返回显式 TL 错误。 -- `messages.deleteMessages` / `messages.deleteHistory` 仅支持私聊消息;`min_date/max_date` 当前作为兼容 no-op,服务消息 `messageActionHistoryClear` 留到后续消息类型扩展。 -- `messages.deleteHistory` 全清按 1000 条一批推进,`offset>0` 续删;`deleteMessages` 单次最多 1000 个 id,避免客户端构造超大数组或超大 `max_id` 导致服务端 OOM。 -- `messages.getHistory/messages.search` 的 `add_offset` 统一 clamp 到 `[-100,100]`;TDesktop 正常滚动只会使用小偏移,恶意超大值不能触发无界内存分配或大 SQL OFFSET。 -- `reply_markup`、quick replies、effects、paid send、suggested posts、story/monoforum/todo/poll reply 均返回客户端可理解的显式 TL 错误(如 `REPLY_MARKUP_INVALID`、`SHORTCUT_INVALID`、`EFFECT_ID_INVALID`、`PAYMENT_UNSUPPORTED`、`SUGGESTED_POST_PEER_INVALID`、`STORY_ID_INVALID`、`REPLY_TO_MONOFORUM_PEER_INVALID`、`POLL_OPTION_INVALID`),不再落 `NOT_IMPLEMENTED`;`send_as` 仅在目标为 channel 时按频道模块规则接受 self/current channel,私聊目标返回 `SEND_AS_PEER_INVALID`;目标为 channel 且请求未显式带 `send_as` 时,RPC 层读取 `messages.saveDefaultSendAs` 保存的默认身份并重新校验。 -- 定时消息返回 `SCHEDULE_DATE_INVALID`。 -- `messages.search` / `messages.searchGlobal` 当前是私聊文本 `ILIKE` + `pg_trgm`,后续大规模数据需要按语言/分词策略补全文索引或外部搜索服务。 -- 单条文本当前限制 4096 个 Unicode code point;超限返回 `MESSAGE_TOO_LONG`,触发窗口限流返回 `FLOOD_WAIT_X`。 - -## 媒体消息(2026-06-02) - -- `messages.sendMedia` / `uploadMedia` / `sendMultiMedia` 接入:RPC 层 `resolveInputMedia` 把 `inputMediaUploadedPhoto/Document`(组装 `upload.*` 分片→建 `Photo`/`Document`)与 `inputMediaPhoto/Document`(引用已存在资源,含贴纸)转成 `domain.MessageMedia`,经抽取的 `sendOutgoing` 走与文本完全相同的 pts/box/outbox/在线推送/离线 `getDifference` 路径;私聊与 channel 共用。 -- `private_messages` / `message_boxes` 增 `media` JSONB 快照列:发送在事务内随 body/entities 一起写双端盒子,历史 `getHistory`/`getMessages`/dialog preview 读取时随消息一并解码,无需 join `documents`/`photos`。转发复制源消息 media(同一文档引用);文本编辑保留 media。 -- `tgMessage` 在 media 非空时 `SetMedia(MessageMediaPhoto/Document)`,客户端经 `upload.getFile` 从 blob backend 下载。Document id 在 domain/store 中保持 telesrv-owned 正数;外部 seed source id 在导入阶段归一,`InputDocument` / `inputDocumentFileLocation` 入站直接按服务端 id 解析,避免把第三方导出 id 当成本服资源身份。 -- 仅媒体消息(无 caption):放宽 `private_messages` body 非空 CHECK 为 `body<>'' OR media<>'{}'`。 -- 范围外:grouped_id 相册聚合(sendMultiMedia 当前各条独立成消息)、geo/contact/poll/todo/dice/story media 仍 `MEDIA_INVALID`。 diff --git a/docs/performance-audit.md b/docs/performance-audit.md deleted file mode 100644 index 84917a38..00000000 --- a/docs/performance-audit.md +++ /dev/null @@ -1,249 +0,0 @@ -# telesrv 性能审计报告 - -- **日期**:2026-06-02 -- **审计范围**:全部 140 个 Go 源文件(约 4.7 万行非测试代码)——连接层 `mtprotoedge`、`rpc`、业务 `app/*`、`store`(PostgreSQL / Redis / memory)、`queries/*.sql` 与 55 个迁移的索引。 -- **方法**:5 个并行深度审计(PG 私聊查询 / PG channel 查询 / 连接层 fan-out / RPC 私聊热路径 / channel RPC + Redis),再对全部 P0 逐条读码核实(见附录 A)。 -- **性质**:本文是当前阶段的**性能快照与债务维护清单**。RPC 实现进度仍以 [compatibility-matrix.md](compatibility-matrix.md) 为权威;性能项完成后需在本文记录状态、验证方式与剩余阻断。 -- **当前阶段**:已不再是第一阶段/纯私聊阶段;当前目标是 TDesktop 主路径功能闭环 + 性能/可靠性硬化,大规模群组/频道放开前必须先清掉 P0 阻断项。 -- **本轮落地状态(2026-06-02 / 2026-06-03)**:已修复 P0-1/P0-2/P0-4/P0-5、P1-b/P1-e/P1-i/P1-l、`users.getFullUser.common_chats_count` 反向规划 channel 分区、媒体 range read/元数据 LRU/sticker 小资源预热与 P2 update watermark/retention 的公共根因;P0-6 的 AES key schedule 属 MTProto per-message msg_key 成本,不做错误缓存优化,先保留 buffer/编码压测项;P1-a 受 `gotd/td` transport 抽象限制,禁止修改 `github.com/gotd/td`,需另做 telesrv-owned transport writer spike。 -- **严重度口径**:`P0` 当前即瓶颈 / 开闸即炸;`P1` 上量级会炸或高频路径浪费;`P2` 浪费但不致命;`P3` 轻微。 -- **规模假设**(用于判断后果):单机目标 20 万在线 TCP 长连接;私聊 ~200 msg/s × 双端写;在线推送 fan-out ~40 万 push/s;PostgreSQL(默认连接池 50)+ Redis。 - ---- - -## 1. 总体结论 - -**基础工程素养很好**——大表按 user / channel 做 HASH 64 分区、私聊热路径 keyset 游标分页(无大 OFFSET)、服务端 limit 钳制几乎覆盖所有 RPC、`convert` 层预分配 + 去重、send 路径 Redis 已合并单 Lua、outbox 已批量化、SessionManager 锁临界区已被实测确认极短。**没有发现「按客户端巨值无界分配」这类可被单请求 DoS 打爆的洞。** - -真正的性能债高度集中,归为**两条主线**: - -| 主线 | 何时致命 | 性质 | -|---|---|---| -| **A. channel / 群组 fan-out 乘积放大** | 放开群组规模后 | 一条群消息触发「全员写 + 全量排序 + 数千次单查」的乘积爆炸 | -| **B. 连接层出站热路径 + 服务层缺批量** | 当前 TDesktop 主路径上量后放大 | 全局锁串行、每消息多次堆分配 / syscall、N+1 | - -> 注:channel 代码虽已实现并通过功能联调,但**性能压测主要覆盖私聊**。主线 A 的几项在小规模群联调下不会暴露,需在放开大规模群组前先拆。 - ---- - -## 2. 核心洞察:一条群消息的乘积放大链 - -发一条 megagroup 消息,会同步触发**四段独立放大**(全部经代码核实,见附录 A): - -``` -发送 1 条群消息(设 10 万成员,其中在线池 20 万里有该群成员若干) -│ -├─① 存储层:upsertChannelDialogsForMessageTx internal/store/postgres/channel.go:7958 -│ 对【全体活跃成员】INSERT…ON CONFLICT,WHERE 无 LIMIT -│ → 写 ~100,000 行 / 条消息,全在发送事务内,长持行锁 + 撑爆 WAL -│ -├─② 选收件人:pushChannelUpdates → OnlineChannelMemberUserIDs -│ 已从旧的全局在线 map 遍历改为 channel→online session 成员索引 -│ → 持久 channel updates 按当前在线 active 成员 best-effort 推送;typing/reaction 等瞬时事件走 viewer 索引 -│ -├─③ 推送构造:pushChannelUpdates 对【每个 recipient】调 build(viewer) internal/rpc/channels.go:3053 -│ 发送者 / 频道信息对所有 recipient 相同,却被重算 500 遍 -│ -└─④ 每次 build 内:tgUsersForIDs 逐个 Users.ByID internal/rpc/channels.go:3143 - 而 Service.ByID 每次先查 Self 再查目标(×2) internal/app/users/service.go:50 - → 500 viewer × ~5 引用 × 2 ≈ 5,000 次单行 PG 查询 / 条消息 -``` - -**旧链路合计:单条群消息 ≈ 10 万行写 + 一次 20 万项排序 + ~5,000 次单行查询。** 当前已拆掉全局在线排序和 users 公共 N+1,剩余风险集中在大群 dialog 写放大、收件人无关 update 模板化,以及 channel 读 RPC 的专用批量化。 - ---- - -## 3. P0 — 必修(当前即瓶颈 / 开闸即炸) - -### P0-1 广播频道发消息对全员同步写 dialog(无上限写放大) -- **位置**:`internal/store/postgres/channel.go:7958` `upsertChannelDialogsForMessageTx`,由 `sendChannelMessageOnce` 在每条消息事务内调用。 -- **问题**:`FROM channel_members WHERE channel_id=$1 AND status='active'` 无 LIMIT,每发一条消息写 / 更新 = 成员数 行;讨论组联动再来一遍。 -- **影响**:唯一随频道规模**线性放大且在同步关键路径**的写。百万成员频道单条 post 跑数秒、长持行锁、阻塞该频道后续所有写。 -- **建议**:广播频道(broadcast)不维护 per-member dialog,改 Telegram 式「单一 read state + 读时按 `top_message_id` vs `read_inbox_max_id` 懒算未读」(`ListChannelDialogs` 已有该回退逻辑,fan-out 行其实可删);megagroup 设成员阈值,超阈值改异步 / 分批 job。 -- **状态**:已加同步写闸门:broadcast 跳过全员 dialog 写;megagroup 仅 `ParticipantsCount <= 1000` 时同步写,未知/超阈值不做全员写。读取端已不再信任 broadcast/大超级群的 `channel_dialogs.unread_count` 缓存,改按 read watermark、`available_min_id`、channel top 与未删除消息动态派生普通未读。仍需大频道 PG `EXPLAIN` 与端到端压测。 - -### P0-2 `OnlineUserIDs` 每次 fan-out 全量遍历 + 排序 20 万在线 -- **位置**:`internal/mtprotoedge/session_manager.go:469`。 -- **问题**:截断发生在排序**之后**——为拿 500 个先把 20 万个全 append 全 `sort.Slice`,且全程持 `m.mu.RLock`。 -- **影响**:每次 channel 更新都触发 O(n log n) 排序 + ~200KB 分配,持读锁阻塞连接注册 / 注销,是连接层热锁上的持续 CPU / GC 压力。 -- **建议**:提供 O(1) 的 `IsOnline(userID)`,用频道成员(已 LIMIT 500)反向过滤,不导出全量在线集;若必须导出则「遍历到 limit 即停、不排序」。 -- **状态**:已加 `IsUserOnline` / `OnlineUserIDsForCandidates` / viewer 索引 `OnlineChannelUserIDs` / membership 索引 `OnlineChannelMemberUserIDs`。`updates.getState/getDifference` 会分页加载当前 session 已加入的 active channel;join/leave/invite/ban/delete 路径会运行态增删索引。持久 channel updates 走在线成员索引并经 PG active membership 复核,取消 500 cap;typing/reaction 等瞬时事件走 viewer-only scope;`messages.getDialogs` 不再标记 viewer。旧 `OnlineUserIDs` 保留兼容但已改为到 limit 即停、不排序。Redis/PG 缓存在线关系暂不引入,待多实例或压测数据证明需要再做。 - -### P0-3 channel fan-out 每收件人重建相同 user/chat + N+1 单查 -- **位置**:`internal/rpc/channels.go:3053` `pushChannelUpdates` → `internal/rpc/channels.go:3143` `tgUsersForIDs`。 -- **问题**:收件人无关的发送者 / 频道信息被每个 viewer 重算;每次 `tgUsersForIDs` 逐个 `Users.ByID`。 -- **建议**:把收件人无关的富集提到 fan-out 循环**外**做一次,循环内只算 viewer 差异(out/self,纯内存);配合 P0-4 的批量接口。 -- **状态**:已先把 `tgUsersForIDs` 改为批量 `Users.ByIDs`,消除该公共 N+1。收件人无关的 channel update 模板化仍待压测后继续拆。 - -### P0-4 `Users.Service.ByID` 每次额外查一次 `Self`(全局 ×2 放大)+ 服务层缺批量 -- **位置**:`internal/app/users/service.go:50`,被 RPC 层 **24 处** N+1 循环调用。 -- **问题**:每次解析一个 user = 2 次 PG 查询;且 `UsersService` / `MessagesService` / `ChannelsService` **根本没暴露批量 `ByIDs`**,存储层明明已有 `listUsersByIDs` / `listChannelsByIDs`(`= ANY`)却用不上。 -- **建议**:① 给三个 service 加批量 `ByIDs`,底层接已有的 `= ANY` 查询;② `ByID` 热路径不必每次重查 `Self`(`currentUserID` 已由 session 鉴权)。**这是 P0-3 与下面一批 P1 N+1 的公共根因。** -- **状态**:已给 `UserStore` / `UsersService` 增加 `ByIDs`、`ByPhones`,`UsersService.ByID` 去掉重复 `Self` 查询;channel/user 富集公共函数已迁到批量 users。Messages/Channels 更细粒度批量接口仍在后续项。 - -### P0-5 `MessageIDGen` 进程级全局锁串行化所有连接的出站写 -- **位置**:`internal/mtprotoedge/outbound.go:432` → 共享单例 `internal/mtprotoedge/server.go:184`(创建)/ `server.go:202`(注入每个 Conn)。gotd `proto.MessageIDGen.New` 全程持 `g.mux`。 -- **问题**:全进程一个 `s.msgID`,20 万连接共享;每条出站消息(rpc_result / push / ack / pong)必过这把锁。40 万 push/s ⇒ ~50 万次/s 全局锁 acquire,多核下 cache-line 争用 + lock convoy,把本可并行的 N 个 outbound actor 强行串到一点。 -- **建议**:改 **per-Conn `MessageIDGen`**(msg_id 只需单连接内单调,无需跨连接全局唯一)。msg_id 已只在该连接 outbound actor 单 goroutine 内调用 → per-Conn 后退化为无争用锁甚至无锁 int64。**改动最小、收益最大。** -- **状态**:已完成 per-Conn `MessageIDGen`,移除 server 级共享 generator。 - -### P0-6 出站写路径每条消息 4–6 次堆分配,gotd 零拷贝编码被旁路 -- **位置**:`internal/mtprotoedge/outbound.go:419-481`(`body.Copy()` + `var out bin.Buffer` + 回退到 `Encode` 再复制 + 每条 `aes.NewCipher` 重建 key schedule)。 -- **影响**:40 万 push/s × 5~7 次分配 ≈ **250 万 alloc/s**,是稳态主导 GC 压力;20 万连接广播同一 update 时同份 body 被各连接独立 encode + copy + encrypt。 -- **建议**:outbound actor 内复用 per-Conn encode / encrypt buffer(单 goroutine 天然安全,免锁);走 `Message`-based `EncodeWithoutCopy`;**广播 push 在 fan-out 前把 body 编码一次**,各连接只做 salt/msgID 包头 + 加密。 -- **状态**:未完全落地。AES `NewCipher` 不作为缓存目标:MTProto 2.0 每条消息由 msg_key 派生 AES key/iv,不能按 auth key 长期复用 cipher。后续只做 TL body 预编码、buffer 复用和实测分配优化。 - ---- - -## 4. P1 — 上量级会炸 / 高频路径浪费 - -### 连接层(TDesktop 主路径上量后相关) -- **P1-a 每帧两次裸 `write` syscall,无 bufio** — `internal/mtprotoedge/outbound.go:473` → transport 直写裸 `net.Conn`,每条 ≈3 次 syscall(含 `SetWriteDeadline`)。40 万 push/s ⇒ ~120 万 write syscall/s。`gotd/td` transport 当前隐藏底层 writer,项目规则禁止改 `github.com/gotd/td`;已在 `Conn` 内加 telesrv-owned outbound writer 接口作为替换点,但真正 bufio/syscall 合并仍需 transport writer/codec spike 后再落地。 -- **P1-b fan-out 中 `c.Send` 同步阻塞,慢连接拖累整批** — `internal/mtprotoedge/session_manager.go:446` + `internal/mtprotoedge/outbound.go:117`:`Send` 同步等到写完 / 超时,队列满时阻塞 fan-out 协程最长到 ctx 超时(5s)。**状态:已新增 `Conn.SendBestEffort` 与 SessionManager best-effort fan-out,updates push 默认只等 `TELESRV_OUTBOUND_PUSH_TIMEOUT=200ms` 入队;RPC result/ack/pong 仍走可靠同步发送,outbox 入队失败不删 durable 任务。** -- **P1-c 入站每帧分配新 buffer** — `internal/mtprotoedge/server.go:344`:连接级 `bin.Buffer` 复用被 codec `ResetN` 的 `make` 抵消,每帧新分配明文缓冲。建议入站缓冲走 `sync.Pool`。 - -### N+1(根因 = P0-4 服务层缺批量) -- **P1-d `forwardSources` 转发逐条查源 + 富集 N+1** — `internal/rpc/messages.go:4155`:100 条转发 = 100 次取源 + ~100 次富集查询。 -- **P1-e `getMessages` 逐 ID 单查** — `internal/rpc/messages.go:2888`:高频 RPC,100 个 ID = 100 次 `Search(Limit:1)`。**状态:已新增 `MessagesService.GetMessages` / `MessageStore.GetByIDs`,PG 用 `unnest(ids) WITH ORDINALITY` 一次取 owner-visible box ids;RPC 保持缺失项返回 `MessageEmpty`。** -- **P1-f `getDifference` 富集逐 ID 单查 + 跨事件重复查** — `internal/rpc/update_peer_refs.go:9`:登录风暴期高频,每事件重建 map + 同 user 在多事件被重复查。 -- **P1-g channel 读 RPC 普遍 N+1** — `getParticipants` / `getAdminLog` / `getHistory` 等 24 处逐个 `Users.ByID`(`internal/rpc/channels.go:3350` 等)。 -- **P1-h `enrichChannelHistory` 与存储层重复解析** — `internal/rpc/update_peer_refs.go:45`:存储层已批量填好 `Users` / `Channels`,RPC 层又逐个重查一遍后 merge 丢弃(纯浪费)。 -- **P1-i `importContacts` 逐条 `ByPhone` + `Upsert`** — `internal/app/contacts/service.go:84`:登录潮高发,500 条 = ~1000 次串行往返,单请求长期占一条 PG 连接。建议 `WHERE phone = ANY($1)` + `unnest` 批量 upsert(~1000 → ~2)。 -- **状态**:已改为批量 `ByPhones` + 批量 `UpsertMany`,同一目标用户去重写入,`Imported` 保留各 client_id。 -- **P1-j `ListForumTopics` 每话题 3 次 COUNT** — `internal/store/postgres/channel.go:5513` → `:6804`:单次最多 300 条额外 COUNT。建议改 `GROUP BY` 批量聚合(同文件 reactions / replies 已有该范式)。 -- **P1-k `ListChannelDialogs` 逐个回查 top message** — `internal/store/postgres/channel.go:3601`:主查询已 JOIN 到 top_msg 却只取了 date,又逐行 +100 单查;且过量取数 500 再内存排序切片。 - -### COUNT(*) 全量统计 -- **P1-l `ListMessagesByUser` 每页都 COUNT 全量** — `internal/store/postgres/queries/message.sql:380` + `internal/store/postgres/message.go:566`:每次翻页 / 搜索都把整个匹配集数一遍(trigram 只缩候选,count 仍大扫)。建议仅首页算 count、翻页省略(Telegram 客户端只首屏用总数)。 -- **状态**:已新增 `MessageFilter.NeedTotalCount`,SQL 默认不 COUNT;私聊搜索首屏按显式 flag 计算,其余 history/翻页默认返回 0 count。 -- **P1-m `ListChannelRecommendations` 全库 COUNT** — `internal/store/postgres/channel.go:3824`:对全系统公开广播频道计数,带 `NOT EXISTS` 反连接。建议返回近似值或不返回精确总数。 -- **P1-n `users.getFullUser.common_chats_count` 反向规划 channel 分区** — ✅ **已修(2026-06-03)**:TDesktop 打开私聊会触发 `users.getFullUser`,旧实现为填 `common_chats_count` 调 `ListCommonChannels(limit=1)`,同时跑 COUNT + list,并从 `user_id` 入口反查按 `channel_id` hash 分区的 `channel_members/channels`,实测 Alice/Bob `EXPLAIN` planning 约 70ms、执行约 1.6ms,UI 表现为点开 Bob B 卡一下。现新增 `user_channel_member_index(user_id, channel_id)`,由 member upsert/leave/delete channel 事务同步;`users.getFullUser` 走 `CountOnly`,只在该 user 维度索引上 count。实测同一 count query planning 0.873ms、execution 0.079ms,Alice 打开 Bob B 日志中 `users.getFullUser` handler 3.26ms、端到端 9.49ms。 - -### PG 分区访问维度审计(2026-06-03) - -本轮逐个核对 20 张 HASH 分区表,重点检查「查询入口维度」是否等于分区键。结论:`dialogs/dialog_drafts/dialog_filters/dialog_filter_settings/user_update_events/message_boxes` 的 owner/user 主路径基本都带 user 分区键;`channel_messages/channel_update_events/channel_forum_topics/channel_invites/channel_invite_importers/channel_admin_log_events/channel_message_viewers/channel_message_reactions` 的 channel 主路径基本都带 channel 分区键。剩余风险集中在需要反向访问的 read model / 队列: - -| 级别 | 问题 | 实测计划 | 修复方向 | -|---|---|---:|---| -| P1 | `ListChannelDialogs` / `ListInactiveChannels` / `ListLeftChannels` / `ListDiscussionGroups` / `ListAdminedPublicChannels` / `ListActiveChannelIDsForUser` 从 `user_id` 入口读 `channel_members`,但表按 `channel_id` 分区 | `ListChannelDialogs` planning 118ms,`ListInactiveChannels` 114ms,`ListActiveChannelIDsForUser` 26ms,均展开 64 个 `channel_members` 分区;带 `channels/channel_messages` join 时最多展开 192 个分区 | 把 `user_channel_member_index` 升级为正式 user 维度 membership read model,或两步取 bounded channel_id 后用 `channel_id/id = ANY($1)` 访问 channel 分区;禁止 SQL 内直接 `index -> channel_members/channels` 动态 join,实测仍会展开 64 分区 | -| P1 | `message_boxes` 的编辑/reaction 可见 box 查询与 revoke 删除按 `(message_sender_id, private_message_id)` 反查,表按 `owner_user_id` 分区 | `ListVisibleMessageBoxesByPrivateMessage` planning 50ms、`DeleteMessageBoxesByPrivateMessages` 等价查询 48ms,展开 64 个 `message_boxes` 分区 | 增加 unpartitioned `private_message_box_index(message_sender_id, private_message_id, owner_user_id, box_id)`,或从 `private_messages(sender_user_id,id)` 得到 sender/recipient 后按 owner 分区两点查询 | -| P1 | `ListUserUpdateEventsAfter` / `BatchListDispatchEvents` 在账号级 update 查询里直接 left join `channels fwd_ch/reply_ch`,`channels` 按 id 分区但 join key 来自消息行 | planning 37ms,展开 128 个 `channels` 分区;`user_update_events/message_boxes` 本身已正确裁剪到 1 个 user 分区 | update 查询只返回 channel ref id,Go 层收集后批量 `listChannelsByIDs(id = ANY($1))` 富集,复用 channel difference 的做法 | -| P1 | `dispatch_outbox` worker 全局 claim/failed cleanup 按 `status/next_attempt_at/updated_at` 取任务,表按 `target_user_id` 分区 | claim planning 28ms,展开 128 个 `dispatch_outbox` 分区;这是队列热路径,不是用户 RPC 单次路径 | 引入 unpartitioned ready queue / claim index table,或改为固定 worker shard 维度分区;按 target_user_id 分区适合投递/删除,不适合全局 claim | -| P2 | `ResolvePublicChannelUsername` 从 `channel_usernames` join `channels`,动态 channel id 让 `channels` 64 分区全展开 | planning 37ms,展开 64 个 `channels` 分区 | 先查 `channel_usernames` 得到 channel_id,再 `getChannelByID` 单分区读取 | -| P2 | 删除频道消息时 `deleteChannelUnreadMentionsTx` 按 `channel_id + message_id` 删除 `channel_unread_mentions`,但该表按 `user_id` 分区;随后按 affected user 更新 `channel_dialogs` 也会动态展开 user 分区 | mentions delete planning 16ms,affected dialog update planning 24ms,分别展开 64 个 `channel_unread_mentions` / `channel_dialogs` 分区 | 删除前从 message mention read model 取 bounded affected user_id,或增加 `(channel_id,message_id,user_id)` 辅助索引表;更新 dialog 按 user_id 分批两点写 | - -验证补充: - -- `channels WHERE id = ANY($1)`、`channel_members WHERE channel_id = ANY($1)` 在参数/常量数组已知时能裁剪到对应分区;但 `WITH ids AS (...) SELECT ... WHERE id = ANY(ARRAY(SELECT ...))` 这种 SQL 内动态数组不会裁剪,planning 仍约 33ms/64 分区。 -- `user_channel_member_index` 单表 user 维度查询 planning 0.7ms;但 `user_channel_member_index -> channel_members/channels` 一条 SQL 动态 join planning 71ms,仍不是可接受修法。 -- `private_messages` 目前仅按 `sender_user_id` 幂等/编辑主体查询,未发现 recipient-only 热查询;`recipient_user_id` 索引存在但不会改变 sender 分区裁剪限制。 - -### 媒体管线(2026-06-03 媒体闭环审计新增,放开大文件 / 大群前评估) -- **P1-媒体-a `upload.getFile` 整文件入内存 + 每 chunk 查 PG / sticker 小资源首开冷路径** — ✅ **已修(2026-06-03)**:原 `blobs.Get` 一次 `os.ReadFile` 读整个 blob 再切片,客户端按 ≤512KB/1MB 分块多次请求 ⇒ 同一大文件被重复整读 N 次(O(N²))+ 每 chunk 一次 `GetFileBlob` PG 往返;sticker/reaction/thumb 虽小,但 TDesktop 重启或打开历史首次渲染时仍可能在 `messages.getStickerSet` + `upload.getFile` + 本地文件冷读上形成可感知卡顿。现 `BlobBackend.GetRange`/`LocalFS`(`internal/app/files/blobfs.go`)用 `ReadAt` 只读 offset+limit 段(`n` 受文件大小约束,超大 limit 不会按客户端值分配内存);并加 `location_key→FileBlob` 进程内 LRU(容量 65536,元数据不可变故只读填充无需失效)消除每 chunk 的 PG 查;再加 `object_key→bytes` 小 blob LRU(单项 ≤256KB,总 64MB)、完整 sticker set cache 与启动 `WarmCaches`,从已 seed 元数据预热 sticker/reaction document 和可下载缩略图。参考实现 用 MinIO ranged read(`object.ReadAt`)+ SSDB 两级,但其下载命中冷存储不回填、无 LRU/阈值、photo/头像直连 MinIO;telesrv 当前先做进程内元数据/小字节 LRU + 段读,**多实例共享缓存(Redis)仍待二阶段换对象存储时评估**。实测启动预热 `49 sets / 2313 docs / 2298 blobs`,TDesktop `messages.getStickerSet` 从约 18-24ms 降至 0-1.7ms;seed document id 归一后,首次新服务端 id 会触发必要的 `upload.getFile` 主体/缩略图请求,但 server 端大多 0.5-6ms、样例最长 59ms,重复打开 Bob 会话 0.58s 截图已可见 sticker。 - -- **P1-媒体-a.1 system sticker set 污染 installed stickers 本地索引** — ✅ **已修(2026-06-03)**:TDesktop 贴纸正文缓存 key 为 `dc_id + document_id`,但 installed stickers 本地索引写入依赖 set flags;`installed_date` 会使 set 进入 Installed,普通 stickers 类型的 `Installed + NotLoaded` set 会让 `writeInstalledStickers()` 中止。此前 seed 把 animated emoji/dice/generic animations 这类 system set 也标成 installed,可能导致重启后 installed sticker 索引反复失效与 set 元数据重拉。现 migration `0066_system_sticker_sets_not_installed` 修复既有库,seed 后续也不再把 `set_kind=system` 声明为 installed。 -- **P1-媒体-a.2 历史页 sticker 静态图慢 / 打开会话先空白** — ✅ **已修正(2026-06-07,撤销 2026-06-03 的过度修复)**:根因复盘 — TDesktop `history_view_sticker.cpp` 对 animated sticker 的占位渲染优先级为 `getStickerLarge()`(完整 `.tgs` 首帧,需下载)→ ~~`thumbnailInline()`(stripped 内联,源码显式注释禁用:sticker 需 alpha 通道)~~ → `goodThumbnail()` → 下载的 `thumbnail()` → **`paintPath()`(`PhotoPathSize` 矢量轮廓,内联即时,唯一不依赖下载的占位)** → 空白;且完整 `.tgs` 由 `DocumentMedia::checkStickerLarge()`→`automaticLoad()` 下载,**与 path/thumbnail 无关**。2026-06-03 的 a.2 误把"卡在 path 等完整 TGS"当作 path 短路(真根因是 a.3 的 document id 污染让完整文件下载/缓存失败),改为有 raster 时过滤 `PhotoPathSize`。a.3 修掉 id 后,这个过滤变成净负面:document 失去唯一即时占位 ⇒ 打开会话 sticker 先空白;且 `thumbnailPath()` 变空触发 `thumbnailWanted()` ⇒ 每个 sticker 多下载一次缩略图,与完整 `.tgs` 争用下载通道,几十个 sticker 排队 ⇒ "静态图特别慢、不像本地加载"。现 seed 恢复 `PhotoPathSize` 占位(与官方 sticker 一致,`internal/app/files/seed.go` 不再过滤 path,删 `seedPreferRasterDocumentThumbs`/`documentThumbsHave{Raster,Path}`),document.thumbs = path + downloadable `photoSize m`:打开即时画轮廓占位、有 path 后不再主动下载缩略图、下载通道全给完整 `.tgs`、第二次本地缓存秒显。已实测排除磁盘 IO/缓存容量(blob 4330 文件 71.3MB,几乎全 ≤64KB)与 `document.Size` 不匹配(28/28 一致)。**现有库需重新 seed 一次恢复 path**:`DELETE FROM sticker_sets; DELETE FROM available_reactions;` 后重启触发全量 reseed(`PutDocument`/`PutFileBlob` upsert,不删用户上传媒体)。`TestSeedMediaFromRealExport` 用真实导出验证 seed 后 document 保留 path。**待双 TDesktop 人工验证渲染体感。** -- **P1-媒体-a.3 复用导出 document id 命中 TDesktop 旧 `DocumentData` 缓存** — ✅ **已修(2026-06-03)**:TDesktop `Data::Session::document(id)` 按 `document_id` 单例化,`DocumentData::updateThumbnails()` 不会清空旧 inline/path thumbnail;因此 server 修掉坏 thumb 字段后,旧 Debug tdata 仍可能用同 id 的污染对象,打开历史先空白再等完整 TGS。根因是 seed 曾把外部导出 document id 直接作为本服资源主键。现 `internal/app/files` 在导入阶段把 source id 归一为 telesrv-owned storage id,RPC/download/custom emoji/channel appearance 全部直接使用该服务端 id;migration `0067_seed_document_id_namespace` 修复既有库的 documents/file_blobs/sticker_sets/available_reactions/message media/channel appearance 引用。这样不需要改官方客户端,也不在 RPC 层保留客户端特判。复测:migration 后 PG 相关引用均无 `>4e18` 外部 document id;双 TDesktop 重启后打开 Bob B/Alice A 首屏 250ms 已显示 sticker,`messages.getHistory` 约 6-12ms,`upload.getFile` 命中新 id 成功,server/client 日志无 location/hash/API 错误。 -- **P1-媒体-b `upload_parts` 无 GC / 无每用户配额** — `internal/app/files/service.go:30` + `deploy/migrations/0057_media.up.sql`:分片直接进 PG `bytea`,仅 `assembleUpload` 成功才删;**未 assemble 的分片永久滞留**,且不同 `file_id` 无上限 ⇒ 任意登录用户可用海量 fileID 各传几片撑爆 PG(容量 DoS)。建议每用户 in-flight 上传字节/分片配额 + 后台按 `created_at` 过期清理 worker。 -- **P1-媒体-c `media` JSONB 内联放大 fan-out 写** — `deploy/migrations/0057_media.up.sql:129`:`media` 快照内联在 `message_boxes`(owner 双份)/`channel_messages`,含 stripped thumb/attributes 使单行变大;叠加主线 A 的 channel 全员写扇出时,大群每条媒体消息按成员数复制整个 media JSONB。建议随主线 A 改懒算/单副本时一并评估 media 是否只存引用、下沉 `documents`/`photos`。 -- **P2-媒体-d `photos.getUserPhotos` N+1 + OFFSET** — `internal/app/files/photos.go:165` 逐个 `GetPhoto` + `internal/store/postgres/queries/media.sql:314` OFFSET 分页。头像数少、`limit<=100`,影响有限;建议批量 `GetPhotos(ids)` + keyset 分页。 -- **P2-媒体-e sticker set cover 元数据声明可下载但 seed 无 raster blob** — ✅ **已修(2026-06-03)**:TDesktop 会把 `StickerSet.thumbs` 中的 downloadable `PhotoSize` 变成 `inputStickerSetThumb` 下载;`TELESRV_STICKER_SEED_DIR` 当前 set_cover 只有 `PhotoPathSize` SVG(40 个 only-svg、4 个 empty),没有可服务的 jpg/png/webp。现 seed 与 `tgStickerSet` 转换均过滤 sticker set cover 的 downloadable thumb,只保留非下载占位;实际 sticker/reaction document 缩略图仍按 `inputDocumentFileLocation` 服务。 - ---- - -## 5. P2 / P3 — 浪费但不致命 / 轻微 - -| 级别 | 问题 | 位置 | -|---|---|---| -| P2 | `getDialogs` 全量拉内存排序 + 无条件拉 1000 条草稿(最高频 RPC 之一,应下推 SQL `ORDER BY LIMIT` + 按页 peer 取草稿) | `internal/app/dialogs/service.go:31` | -| P2 | `MaxContiguousPts` 每次 getState 读 4096 行(应 O(1) 持久化单列 / Redis) | ✅ 已新增 `user_update_watermarks`,`MaxContiguousPts` O(1) 读水位;缺行时一次性补算并 upsert。`Current()` 保留最大已提交 pts 语义,供 Redis allocator 恢复最大分配点,避免 gap 时回退 | -| P2 | **`user_update_events` / dispatch/outbox delivered rows 无保留期清理**,按长期在线量永久膨胀;`message_boxes` 是用户历史,不得在没有产品保留策略时通用 TTL 删除 | ✅ 已确认账号级 `user_update_events` 不能通用 TTL 裁剪,需永久保留以支持 TDesktop `differenceSlice` 续传;retention worker 仅清理 failed/过期 outbox,delivered outbox 已投递即删,不删除 `message_boxes` | -| P2 | `advanceChannelReadOutboxTx` 最多 128 次串行已读写(应单条集合写) | `internal/store/postgres/channel.go:5952` | -| P2 | `InviteToChannel` 200 人逐人循环 ~800 往返(应 `unnest` 批量) | `internal/store/postgres/channel.go:426` | -| P2 | `GetParticipants` OFFSET 分页 + `CASE role` 排序必触发 sort | `internal/store/postgres/channel.go:355` | -| P2 | `ReadHistory` / `EditMessage` / `DeleteMessages` 在 PG 事务内做 Redis pts 分配(send 已优化,这三条没跟上) | `internal/store/postgres/message.go:650` | -| P2 | `ReadHistory` / `RefreshDialog` 用 `COUNT(*)` 重算未读(应纯增量维护) | `internal/store/postgres/queries/message.sql:651` | -| P2 | `ListChannelDifference` 逐事件回查消息 | `internal/store/postgres/channel.go:6115` | -| P2 | `ListInactiveChannels` / `ListLeftChannels` 计算表达式排序 / OFFSET 分页(低频) | `channel.go:3756` / `:3729` | -| P2 | 私聊发送富集仍单查 self / 对端(最热路径,可缓存 self + 透传已查的对端) | `internal/rpc/messages.go:3911` | -| P2 | memory store 多处全 map O(n) 扫描(**仅测试 / 本地用,不影响生产**) | `internal/store/memory/memory.go:1287` 等 | -| P3 | SessionManager 注册表分片(仅重连风暴尾延迟,实测非稳态瓶颈,与既有结论一致) | `internal/mtprotoedge/session_manager.go` | -| P3 | `validateSeq` 每消息 O(400) map 扫描、热路径 hex 日志求值、`enrichUpdateEvents` 整片复制、`pushToUser` 每次切片快照 | 多处 | - ---- - -## 6. 已确认健康(避免误报,证明审计严谨性) - -- **send 路径 Redis 已是单 Lua**(`user_counter_allocator` 把 pts + box 合一,hash-tag 同 slot)——既定优化已落地,热路径仅 1 次 RTT。 -- **私聊核心已达标**:双写 / dialog upsert / event / outbox 单事务、`ON CONFLICT(sender, random_id)` 幂等、keyset 分页无大 OFFSET、批量转发 / 删除已 `unnest`、outbox `FOR UPDATE SKIP LOCKED` + 多 worker + 投递即删、相关索引齐全。 -- **channel 存储核心决策正确**:消息单副本存储(不给每成员写一行)、keyset 分页、服务端 limit 钳制、trigram 搜索索引、按 channel_id 分区;在线 fan-out 先用内存索引缩小到当前在线成员/active viewers,再用 `= ANY` 分批复核 active membership。 -- **`convert.go` 预分配 + 去重到位,RPC 入参钳制完整**——无「按客户端值无界分配」DoS。 -- **Redis 健康**:无大 key、无 `KEYS` / `SCAN` 全库扫、无 `SMEMBERS / LRANGE 0 -1`,RateLimiter 正常放行 1 RTT。 -- **SessionManager 锁**:push 在锁外发送,临界区极短——既有 benchmark 结论仍成立(注意:这与 P0-2 的 `OnlineUserIDs` 全量排序是两回事,后者是新发现)。 - ---- - -## 7. 修复优先级路线图 - -**第一梯队 — 当前主路径就该做(与群组规模无关,直接影响 20 万连接稳态)** -1. **P0-5 `MessageIDGen` 改 per-Conn** — ✅ 已完成。 -2. **P0-6 + P1-a 出站去分配 + bufio 合并 syscall** — 直击 250 万 alloc/s + 120 万 syscall/s。 -3. **P0-4 服务层加批量 `ByIDs` + 去掉 `ByID` 重复 `Self`** — ✅ 已完成 users 公共根因;messages/channels 专用批量接口继续推进。 -4. **P1-i `importContacts` 批量化** — ✅ 已完成。 -5. **P1-l `getHistory` 翻页省略 COUNT** — ✅ 已完成 `NeedTotalCount` 默认 false。 -6. **P2 `MaxContiguousPts` O(1) 化 + outbox 死任务清理** — ✅ 已完成 watermark;账号级 `user_update_events` 永久保留,retention worker 只清理 failed outbox。 - -**第二梯队 — 放开大规模群组 / 频道前必须拆雷(否则开闸即炸)** -7. **P0-1 广播频道去全员写扇出**(改懒算未读)— ✅ 已加同步写闸门,读取端已改为大群动态未读;仍需压测。 -8. **P0-2 `OnlineUserIDs` 去全量排序**(改 `IsOnline` / 频道 viewer+member 在线索引)— ✅ 已完成主 fan-out 路径;Redis 跨实例在线关系暂缓。 -9. **P0-3 + P1-g/h channel fan-out 与读 RPC 批量化、富集提到循环外、去重复解析。** — 部分完成:users 富集已批量化,模板化与 messages/channels 专用批量仍待做。 -10. P1-j/k、P2 channel 各项。 - -**第三梯队 — 稳健性 / 尾延迟** -11. P1-b fan-out 满即丢 — ✅ 已完成 best-effort updates push;P3 注册表分片待真有重连风暴尾延迟再做。 - ---- - -## 附录 A:关键 P0 复核记录 - -下列 P0 在出报告前已逐条读码核实属实(非纯 agent 转述): - -| 发现 | 核实点 | 结论 | -|---|---|---| -| P0-1 | `channel.go:7958` 的 SQL `WHERE m.channel_id=$1 AND m.status='active'` 确无 LIMIT,在发送事务内 | ✅ 属实 | -| P0-2 | `session_manager.go:469-487`:`make(…, len(m.byUser))` → `range` 全 map → `sort.Slice` → `ids[:limit]`(排序在截断前) | ✅ 属实 | -| P0-3 | `channels.go:3053-3082` `for _, userID := range recipients { build(userID) }`;`:3143` `tgUsersForIDs` 循环内 `Users.ByID` | ✅ 属实 | -| P0-4 | `users/service.go:49-58` `ByID` 先 `s.Self(ctx, currentUserID)` 再 `s.users.ByID`(每次 ×2) | ✅ 属实 | -| P0-5 | `server.go:147` 字段、`:184` 建一个、`:202` 注入每个 Conn 共享;`outbound.go:432` 每条出站调 `c.msgID.New` | ✅ 属实 | - -## 附录 B:复跑与验证 - -- **连接层**:`internal/mtprotoedge` 的 `BenchmarkSessionManager*` + `-mutexprofile`(既有 harness)。已新增 `BenchmarkSessionManagerOnlineCandidateFilter`,用于 20 万在线下候选过滤路径复跑。 -- **私聊 / 消息**:`internal/loadtest` + 本机 docker PG/Redis(env-gated `TELESRV_TEST_POSTGRES_DSN` + `TELESRV_TEST_REDIS_ADDR`,未设则 Skip)。 -- **PG 查询计划**:对 P1-l / P1-m / P0-1 用 `EXPLAIN (ANALYZE, BUFFERS)` 在有量级数据的分区上验证扫描行数与是否走索引。 -- **channel fan-out**:在放开大规模群组前,构造「大成员群 + 高在线占比」场景压测主线 A 的端到端放大。 -- **本轮回归**:`go test ./...` 已通过(2026-06-02)。2026-06-03 media/avatar 接手回归补充:`go test ./...`(PG/Redis env-gated)通过,`TestSeedMediaFromRealExport -count=1` 确认 sticker seed 74 reactions / 11 sets / 1355 docs / 2682 blobs,migration 状态 `59|f`;本轮新增头像编辑页 `messages.getEmojiProfilePhotoGroups` empty stub 属 TDesktop 兼容面补齐,不改变性能债优先级。双 TDesktop 群/频道头像 UI 回归仍待 Windows Computer Use 恢复后补实测证据。 - ---- - -## 附录 C:复核与落地轮(2026-06-02,第二轮) - -对第一轮已落地的优化做正确性复核,并把剩余 P0/P1 改到位 + 加测试验证。 - -### 协议关键纠正:账号级 `differenceTooLong` 不可用(§2.5 参考审计救场) -原计划给账号级 `getDifference` 补 `differenceTooLong` 让落后客户端整库重置。审计 TDesktop 基线源码发现 **`api_updates.cpp:516` 对账号级 differenceTooLong 只打一行日志、不读 pts、且漏 `setRequesting(false)`**——收到后 `_ptsWaiter.requesting()` 永真,之后所有 `getDifference()` 在 `:689` 早退,**永久锁死整个 update 引擎,重连/新 session 都救不回**。参考实现 也都故意不发账号级 too-long。 -→ 结论:账号级落后只能 `differenceSlice` 分批续传;**绝不能裁剪 `user_update_events` 到客户端够不着**——这直接决定了 P0 的修法(不删事件,而非加 too-long)。 - -### 本轮落地与验证 -- **P0(retention)✅**:retention worker 改为**不再删 `user_update_events`**(pts log 永久保留,对齐参考实现),仅保留 outbox failed 清理。根除「裁剪事件→落后客户端静默丢消息」。 -- **P1(watermark 死锁)✅**:先识破「预锁 watermark」会引入 watermark↔dialog 跨类型死锁,且 send 双向并发本就有 **dialog 行 pre-existing AB-BA**。终选**事务级 advisory lock 按 user_id 升序串行化同一对用户的写**(`lockUsersForUpdate`,独立锁空间、不与行锁交叉),一举消除 watermark+dialog+box 所有 AB-BA;覆盖 send/read/edit/delete/deleteHistory,delete 另配 dialog rebuild 升序。**强验证**:新增 `TestMessageStoreBidirectionalConcurrencyNoDeadlock`,临时禁用 advisory 时 116/320 操作 `deadlock detected (40P01)`(死锁点 `upsert dialog`),启用后 0 失败。 -- **P1(tgUsersForIDs)✅**:批量 `ByIDs` 失败不再静默 `return nil`,记 Warn 日志(批量无部分结果,不降级逐个以免 DB 抖动放大)。 -- **P1(outbox 拥塞)✅**:`pushOutboxUpdate` 区分 `retriable`——best-effort 队列拥塞保留 dispatching 行靠租约(30s)重投、**不计入 attempts 升级**;新增 `TestOutboxDispatcherDefersOnPushQueueFull`。 - -验证:`go build/vet ./...` 全绿;单测 16 ok / 0 FAIL;PG 集成**单独/分批**全绿(watermark / retention / 并发 / 双向死锁 / outbox 拥塞 / send-read-edit-delete 往返)。 - -### 发现:一批 pre-existing 集成测试失败(第一轮优化遗留,非本轮引入) -全量跑 `internal/store/postgres` 集成套件**整体不稳定**(stash 掉本轮改动的基线也大量失败,含无关的 `TestAuthKeyStoreRoundTrip`——根因是每测试建独立连接池不释放,全量连接耗尽)。逐个单独跑时,下列测试仍失败,**根因是第一轮优化改了行为但旧断言未同步**,已逐条核对非本轮 4 项修复引入: -- `TestMessageStoreDeleteHistoryBatchesHugeMaxID`:断言 `history.Count==2`,但 P1-l(`NeedTotalCount` 默认 false)使 history 返回 `Count=0`(删除断言本身通过)。 -- `TestChannelStoreSendMessageFansOutDialogRows` / `...ReadOutboxDoesNotRegressSenderDialogUnread` / `...SendFailureRecordsNoopPtsGap`:channel fan-out / read outbox 行为随 P0-1 等 channel 优化变化,旧断言未更新(`channel.go` 本轮未改)。 -→ **待办**:更新这些测试断言对齐新行为(count 语义 / channel fan-out),或确认行为符合预期;并考虑给集成测试套件改共享连接池以支持全量运行。属第一轮优化的测试债,独立于本轮性能修复。 \ No newline at end of file diff --git a/docs/persistence-layer.md b/docs/persistence-layer.md deleted file mode 100644 index 5cb9cf8c..00000000 --- a/docs/persistence-layer.md +++ /dev/null @@ -1,178 +0,0 @@ -# telesrv 持久化层设计 - -> 第一阶段(协议 + 登录 + 空账号主界面)的存储地基。后端已定:**PostgreSQL + Redis**, -> 依赖由 [`deploy/docker-compose.yml`](../deploy/docker-compose.yml) 启动;对象存储(MinIO)留第二阶段文件域。 -> 决策见 README.md。 - -## 0. 定位与第一价值 - -把登录链路所需状态可靠落地,使 **server 可反复重启而不丢握手态 / 登录态**——这是真机联调能否愉快迭代的前提: - -- **auth_key 丢** → TDesktop 缓存的 key 在 server 端查不到 → 客户端加密包解不开 → 触发重建密钥甚至重登。 -- **登录态丢** → 每次重启都要重走 `sendCode` / `signIn`。 - -所以持久化第一价值是「联调可重启」,不是上规模。 - -## 1. 后端职责划分 - -| 维度 | PostgreSQL(权威、强一致、可查询) | Redis(高频、易失、TTL、原子计数) | -|---|---|---| -| 第一阶段 | `auth_keys`、`users`、`authorizations`、`account_passwords`、`temp_auth_key_bindings`、`app_configs`、`countries`、`country_codes`、`update_states`、`contacts`、`lang_packs`、`lang_pack_strings` | 验证码 `phone_code_hash → code`(短 TTL)、session、登录尝试限流 | -| 第二阶段 | `private_messages`、`message_boxes`、`dialogs`、`user_update_events`、`dispatch_outbox` | `pts` / owner `box_id` 原子自增(Redis miss 时从 PG durable log 恢复)、在线态缓存 | -| 不放这里 | 大对象/媒体(→ 二阶段 MinIO) | 任何需要持久强一致的业务事实 | - -原则:**PG 存「事实」,Redis 存「态与计数」**。Redis 丢了能从 PG/重新计算恢复;PG 丢了就是数据丢失。 - -## 2. 第一阶段 schema(PostgreSQL) - -DDL 见 [`deploy/migrations/0001_init.up.sql`](../deploy/migrations/0001_init.up.sql)、 -[`deploy/migrations/0002_phase1_business.up.sql`](../deploy/migrations/0002_phase1_business.up.sql) 与 -[`deploy/migrations/0003_startup_config_security.up.sql`](../deploy/migrations/0003_startup_config_security.up.sql)、 -[`deploy/migrations/0004_temp_auth_key_binding_session.up.sql`](../deploy/migrations/0004_temp_auth_key_binding_session.up.sql) 与 -[`deploy/migrations/0005_system_login_messages.up.sql`](../deploy/migrations/0005_system_login_messages.up.sql)、 -[`deploy/migrations/0006_update_events.up.sql`](../deploy/migrations/0006_update_events.up.sql)、 -[`deploy/migrations/0007_read_history_events.up.sql`](../deploy/migrations/0007_read_history_events.up.sql)、 -[`deploy/migrations/0008_user_id_sequence_base.up.sql`](../deploy/migrations/0008_user_id_sequence_base.up.sql) 与 -[`deploy/migrations/0009_private_message_pipeline.up.sql`](../deploy/migrations/0009_private_message_pipeline.up.sql)、 -[`deploy/migrations/0010_message_performance_indexes.up.sql`](../deploy/migrations/0010_message_performance_indexes.up.sql) 与 -[`deploy/migrations/0011_drop_dead_tables.up.sql`](../deploy/migrations/0011_drop_dead_tables.up.sql)、 -[`deploy/migrations/0012_outbox_delete_on_deliver.up.sql`](../deploy/migrations/0012_outbox_delete_on_deliver.up.sql) 与 -[`deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql`](../deploy/migrations/0013_contact_profiles_and_dialog_pins.up.sql)、 -[`deploy/migrations/0014_settings_update_events.up.sql`](../deploy/migrations/0014_settings_update_events.up.sql)、 -[`deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql`](../deploy/migrations/0015_update_event_payloads_and_outbox_auth.up.sql)、 -[`deploy/migrations/0016_delete_message_updates.up.sql`](../deploy/migrations/0016_delete_message_updates.up.sql)、 -[`deploy/migrations/0017_dialog_folders.up.sql`](../deploy/migrations/0017_dialog_folders.up.sql)、 -[`deploy/migrations/0018_user_search_indexes.up.sql`](../deploy/migrations/0018_user_search_indexes.up.sql)、 -[`deploy/migrations/0019_usernames.up.sql`](../deploy/migrations/0019_usernames.up.sql) 与 -[`deploy/migrations/0020_profile_message_state.up.sql`](../deploy/migrations/0020_profile_message_state.up.sql)。 - -> `0011` 清除了三张已被取代、运行时零引用的死表:`update_events`(一阶段 auth_key 级 update 队列,被 `user_update_events` 取代)、`messages_legacy` 与 `dialogs_legacy`(`0009` 重命名保留的迁移残骸,数据已迁入 `private_messages` / `message_boxes` / 新 `dialogs`)。`0006` / `0007` 作为历史演进记录保留,但其建立的 `update_events` 已不再使用。 - -第一批表: - -- **`auth_keys`** —— 密钥交换产物。`auth_key_id`(BIGINT,SHA1 低 64 位小端 int64) + `body`(256B BYTEA) + `server_salt`。 -- **`users`** —— 登录链路必须字段:`id` / `access_hash` / `phone`(UNIQUE) / `first_name` / `last_name` / `username` / `about` / `country_code` / `verified` / `support`。`access_hash` 为任何 `InputUser` 校验所必须,不可省;普通注册用户 ID 从 `1780243200`(2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳)起递增,内置 777000 官方系统账号显式保留在低位区间。注册页只写手机号与姓名,后续 Settings/Profile 通过 `account.updateProfile` 更新姓名与 bio。 -- **`authorizations`** —— `auth_key ↔ user` 绑定 + 设备信息(`layer` / `device_model` / `app_version` / `api_id` …)。PK 为 `auth_key_id`(一个 auth_key 一条授权),外键挂 `auth_keys` 与 `users`;`hash` 是 `account.getAuthorizations/resetAuthorization` 对外稳定设备句柄,按 `(user_id, hash)` 唯一,reset 时只允许删除当前账号自己的授权。 -- **`account_passwords`** —— 账号 2FA/SRP 配置。保存 Layer225 Telegram SRP 所需的 `current_algo_salt1/current_algo_salt2/current_algo_g/current_algo_p`、`srp_id`、`srp_verifier`、当前 challenge 的 `srp_b_secret/srp_b`、hint、恢复邮箱与开发恢复码状态;`account.getPassword` 会轮换 challenge,`account.updatePasswordSettings/auth.checkPassword/auth.recoverPassword` 都按 SRP 校验。`pending_reset_date` 表示无恢复邮箱密码重置等待期,`account.declinePasswordReset` 会清除它。 -- **`temp_auth_key_bindings`** —— `auth.bindTempAuthKey` 的 temp→perm 绑定记录。写入前校验 encrypted `bind_auth_key_inner`,持久化 `temp_session_id`,后续 temp auth_key RPC 在 router 入口解析为 perm auth_key 身份,并缓存在 active session 上。 -- **`app_configs`** —— `help.getAppConfig` 的 data-backed JSON config,包含 TDesktop read mark、quote reply 与 native anti-spam 管理入口所需参数。 -- **`countries` / `country_codes`** —— `help.getCountriesList` 的登录页国家区号目录。 -- **`update_states`** —— `auth_key_id + user_id` 维度的设备状态快照。账号级 pts 以 `user_update_events` 为权威,设备退出/换号只清当前 auth_key 状态,不删除账号事件。 -- **`user_update_events`** —— 按 `user_id` HASH 分区的账号级增量事件队列。承载 `new_message`、`read_history_inbox/read_history_outbox`(私聊与 channel peer)、`edit_message`、`read_message_contents`、`delete_messages`、`contacts_reset`、dialog 置顶/顺序/manual unread/peer settings、dialog filter/order/reload、folder peers、channel 本地清空后的 `channel_available_messages`、当前账号 forum 展示模式 `channel_view_forum_as_messages` 与 allocator gap 的 `noop`,供 `updates.getDifference` 和 outbox worker 补偿错过的推送;设置类事件通过 `UpdateEventStore.AppendWithDispatch` 与 `dispatch_outbox` 同事务写入,并持久化 `event_peers` / `peer_settings` / `message_ids` / `dialog_filter` / `filter_order` / `folder_peers` 负载,避免状态只存在在线 push 中。account difference 中的 `updateChannelTooLong` channel nudge 是按 channel events + request date 计算出来的提示,不写入本表、不消耗账号 pts。 -- **`contacts`** —— 当前账号通讯录关系与 owner 视角联系人资料。`contact_phone/contact_first_name/contact_last_name/note/note_entities` 均只属于 `(user_id, contact_user_id)`,同一个全局 user 在不同 owner 的通讯录中可以有不同姓名、电话和备注;`mutual` 由双方是否互存维护,删除一方联系人会清理对方 reverse mutual。 -- **`contact_blocks`** —— 当前账号 blocklist,按 `owner_user_id` HASH 分区;`(owner_user_id, blocked_user_id)` 唯一,按 `date DESC, blocked_user_id DESC` 返回 `contacts.getBlocked`。私聊 send/edit/delete 的拒绝来源仍是这张表;完整 account privacy keys 已由 `account_privacy_rules` 承载,用于资料投影与手机号分享例外。 -- **`account_privacy_rules`** —— 当前账号 privacy key 规则,`(owner_user_id, privacy_key)` 唯一,`rules` 为 domain PrivacyRule JSONB。无记录时由 app/privacy 计算默认规则:PhoneNumber=DisallowAll、Birthday=AllowContacts、其它=AllowAll;Premium/Bot/Stars/SavedMusic 等产品只保存/返回规则,不扩展商业模型。 -- **`private_messages`** —— 共享私聊消息主体,按 `sender_user_id` HASH 分区;`sender_user_id + random_id` 唯一保证 `messages.sendMessage/forwardMessages` 幂等;文本编辑更新共享 body/entities/edit_date;silent/noforwards/reply_to/fwd_from 元数据随消息持久化。 -- **`message_boxes`** —— owner 视角消息盒,按 `owner_user_id` HASH 分区;每个账号看到自己的 `box_id`、peer、outgoing、pts、edit_date、`media_unread/reaction_unread` 与删除状态,历史/搜索走该表索引。删除只软删 owner 视角 message_box,`revoke` 通过 `(message_sender_id, private_message_id)` 定位其它 owner 视角并软删;编辑会同步所有可见 owner 视角盒子。`messages.readMessageContents` 只锁定当前 owner exact ids 且仅清理 unread 状态为 true 的行,有变化才写 `read_message_contents` durable event。该反向定位与分区键不一致,规划会展开全部 owner 分区,后续应增加 unpartitioned box 映射或先推导 owner_user_id 后再按 owner 分区点查。 -- **`dialogs`** —— 当前账号会话摘要,按 `user_id` HASH 分区;只允许 `user` peer,支持 top message、置顶过滤、folder_id=0/1 主列表/归档与 offset 分页,并保存当前 owner 的 `pinned_order`、manual `unread_mark` 与 `hidden_peer_settings_bar`。列表查询 join `contacts` 时优先返回当前 owner 保存的联系人姓名/电话,避免不同账号看同一 peer 串备注;相关状态变化会写入账号级 durable update log,离线设备可通过 `updates.getDifference` 恢复。 -- **`channels` / `channel_messages` / `channel_message_viewers` / `channel_update_events`** —— 超级群/频道单份消息模型,按 `channel_id` HASH 分区;`channels.pts` 是 channel-scoped durable log 水位,`channels.forum/forum_tabs` 持久化 megagroup topics 开关与 TDesktop tabs/list 布局,`channels.participants_hidden` 持久化隐藏成员设置并由 `ChannelFull.participants_hidden` 恢复 TDesktop UI,`channels.antispam` 持久化 native anti-spam 开关并由 `ChannelFull.antispam` 恢复管理入口,`channels.color_set/color/color_background_emoji_id/profile_color_set/profile_color/profile_color_background_emoji_id/emoji_status_document_id/emoji_status_until` 持久化频道外观并回填 `Channel.color/profile_color/emoji_status`,`channels.linked_chat_id` 维护 broadcast 与 discussion megagroup 的双向链接并走 `channels_linked_chat_idx` 反查,`channel_update_events(channel_id, pts)` 存 new/edit/delete/pin/noop 的恢复负载,成员权限/封禁变化不进入该 log;`channel_messages(channel_id, id)` 走 seek pagination 并保存 `views_count` 聚合列,`channel_message_viewers(channel_id,message_id,viewer_user_id)` 用主键完成 views 去重递增,`reply_to_msg_id/reply_to_top_id` 支撑 thread/comment 分页,`discussion_channel_id/discussion_message_id` 把 broadcast post 映射到 linked megagroup root,禁止按成员写扩散。 -- **`channel_members` / `channel_dialogs` / `channel_unread_mentions`** —— 成员权限、读水位、owner 视角 channel dialog 与未读提及索引,分别按 `channel_id` / `user_id` / `user_id` HASH 分区;`available_min_id` 限制成员可见历史消息,`available_min_pts` 限制 `updates.getChannelDifference` 起点,避免新成员或重新加入成员恢复到入群前的消息类 durable 事件。`channel_dialogs.unread_count` 是小超级群普通未读缓存字段,不是 broadcast/大超级群真值;大频道读取 dialog/full channel 时按 `channel_members.read_inbox_max_id`、`available_min_id`、`channels.top_message_id` 与未删除消息动态派生普通未读。`channel_dialogs.default_send_as_peer_type/default_send_as_peer_id` 保存当前 owner 的默认发送身份,由 `channels.getFullChannel` 输出为 `channelFull.default_send_as`,不参与历史分页或 dialog 排序;`channel_dialogs.view_forum_as_messages` 是当前账号本地 forum 展示模式,由 `Dialog/ChannelFull.view_forum_as_messages` 恢复 UI,并通过账号级 durable update 同步多 session。`channel_unread_mentions(user_id,channel_id,message_id,media_unread)` 不复制消息正文,发送/编辑时只写解析出的 active/可见/未读成员,清除后重算 `channel_dialogs.unread_mentions_count`;history/getMessages/getChannelDifference/online update 按 viewer 用它回填 `mentioned/media_unread`。共同超级群查询已迁到 `user_channel_member_index(user_id, channel_id)`,排除 broadcast 与非 active/deleted 成员;后续 `channels.getLeftChannels`、joined/admined channel 列表和启动 dialog 聚合也必须从 user 维度 read model 或两步 channel_id 列表读取,不能直接用 `channel_members WHERE user_id=...` 反向扫 `channel_id` 分区。 -- **`channel_invites` / `channel_invite_importers`** —— 邀请链接、导入者与 join request read model,均按 `channel_id` HASH 分区;invite 保存 `usage_count/requested_count`,importer 以 `(channel_id,user_id)` 保证同一用户只有一个 pending/approved 状态,管理页查询走 `admin/revoked/offset_link` 与 `requested/link/date/user_id` seek 索引,禁止按超大 limit 或 hash 反查做全表扫。public `channels.toggleJoinRequest` 使用 `channels.join_request` 与 `channel_invite_importers(invite_id=0, requested=true)` 表达非 invite-link pending request;管理员实时提醒用 bounded `updatePendingJoinRequests` + full channel 回填,不为每条 pending 状态生成无界 durable updates。 -- **`dialog_filters` / `dialog_filter_settings`** —— 当前账号自定义 dialog filter、filter 顺序与 folder tags 开关,按 `user_id` HASH 分区;自定义 filter 从 ID 2 开始,归档只由 `dialogs.folder_id=1` 表达,避免一列同时承担归档状态和任意筛选规则。 -- **`dispatch_outbox`** —— 按 `target_user_id` HASH 分区的 transactional outbox。发送事务内写入,RPC outbox worker 用 `FOR UPDATE SKIP LOCKED` 批量 claim,成功标记 delivered,失败退避重试;排除当前设备使用 `exclude_auth_key_id + exclude_session_id`,避免一个设备换号或多账号登录时误过滤。按 target 分区适合投递完成/失败按用户更新,但全局 claim/cleanup 与分区键不一致,规划会展开所有分区;上量前需引入 ready queue 或 worker shard read model。 -- **`lang_packs` / `lang_pack_strings`** —— TDesktop 语言包元信息与字符串。开发 seed 来自 导出的 `.strings` 数据文件。 - -搜索相关索引:`0018` 启用 `pg_trgm`,为 `users.phone` 前缀、`users.username`/姓名、`contacts` owner 保存姓名和 `message_boxes.body` 建索引;`0049` 为公开 username channel/supergroup peer 搜索补 `channels.username/title` trgm 索引。`contacts.search` 先按当前 owner 视角区分联系人/非联系人,并补公开频道/超级群的 `PeerChannel + Chats`;`messages.searchGlobal` 当前只查当前账号私聊文本;跨群组/频道的大规模全局搜索后续应接全文索引或外部搜索服务。 - -## 3. store 接口蓝图 - -```text -internal/domain/ - user.go # User(不依赖 tg.*) - authorization.go # Authorization(含设备信息) - -internal/store/ - authkey.go # AuthKeyStore —— PG(去掉现有 AuthKeyData.UserID) - session.go # SessionStore —— PG / Redis - user.go # UserStore —— PG:ByID / ByPhone / Search / Create / Update - authorization.go # AuthorizationStore —— PG:Bind / ByAuthKey / ByUser / Delete - code.go # CodeStore —— Redis:Set(TTL) / Get / Del - updatestate.go # UpdateStateStore —— PG:auth_key+user 维度 pts/qts/seq - update_event.go # UpdateEventStore —— PG:user 维度 getDifference 事件 - dispatch_outbox.go # DispatchOutboxStore —— PG:在线 update transactional outbox - contact.go # ContactStore —— PG:当前账号通讯录 - dialog.go # DialogStore —— PG:当前账号会话摘要 - message.go # MessageStore —— PG:账号视角下的私聊消息 - langpack.go # LangPackStore —— PG:TDesktop 语言包 - account.go # PasswordStore —— PG:账号 2FA/SRP 配置 - help.go # AppConfig/Country —— PG:启动配置与国家区号目录 - temp_auth_key.go # TempAuthKeyBinding —— PG:temp→perm auth key 绑定 - memory/ # 现有内存实现(保留作测试替身) - postgres/ # pgxpool 实现 + 迁移 runner - redisstore/ # go-redis 实现 -``` - -## 4. 关键设计决策 - -1. **auth_key 与 authorization 分表**:auth_key 是协议产物、授权是业务产物(README.md §3)。从现有 [`AuthKeyData`](../internal/store/authkey.go:11) 拆出 `UserID`,新增 `domain.Authorization` + `AuthorizationStore`。授权表同时是 [`rpc.ClientInfo`](../internal/rpc/context.go) 的落库归宿。 -2. **store 接口收发 domain 实体**,DTO 仅存在于实现内部;`AuthKeyData` / `SessionData` 这类纯协议数据保留为 store 层 DTO(它们不属业务 domain)。domain 不依赖 `tg.*`,下沉到接口签名安全。 -3. **`auth_key_id` 存 BIGINT**:内部 `[8]byte` 在 store 边界按小端转 int64(MTProto auth_key_id 定义即 SHA1 低 64 位)。可读、可索引、与日志一致。 -4. **update 状态分两层**:`user_update_events` 是账号级 durable log;`update_states(auth_key_id,user_id)` 是设备在某账号下看到的状态。这样同一设备退出后换号、或一个设备先后登录多个账号,都不会把旧账号差分串到新账号。 -5. **连接身份缓存**:`authorizations` 仍是权威事实;router 在 active session 首次 RPC 时解析 temp→perm auth_key,并把业务 auth_key_id 写回连接上下文;`auth_key→user_id` 额外做 router 级业务 auth_key 缓存并用 singleflight 合并并发 miss,避免 TDesktop 启动时多个 temp session 同时打 `authorizations`。登录前的未授权结果也做缓存;`auth.bindTempAuthKey` 切换业务 auth_key 时清掉旧 raw key 的 user 缓存,`auth.signIn/signUp` 写入真实 user,`auth.logOut` 清理同业务 auth_key 的活跃连接身份与 auth_key+user update state,账号级事件不随设备退出删除。 -6. **大表查询必须游标化**:`message_boxes`、`dialogs`、`user_update_events` 和 `dispatch_outbox` 从建表开始 HASH 分区;历史和会话列表使用 `box_id/date` 或 `top_message_date/top_message_id/peer_id` seek pagination,避免在 owner 分区内做大 SQL `OFFSET` 扫描。 -7. **查询入口必须匹配分区键**:分区表上的二级索引不能替代分区裁剪。user→channel、message→owners、global queue claim 这类反向访问必须有独立 read model,或拆成「先取 bounded id,再按分区键批量点查」两段;禁止把动态 join 当作分区裁剪。 -8. **联系人和 dialog 均是 owner 视角**:`users` 只保存全局账号资料;通讯录姓名/电话/备注、dialog 置顶顺序、manual unread、隐藏 action bar 都必须落在当前 owner 维度。后续群消息、群成员备注、会话排序可以复用这条边界,不能把个人备注写回全局 user。 -9. **搜索不能无界扫大表**:用户搜索限制 query/limit,并用 phone prefix / username / owner 保存姓名索引;消息全局搜索第一版只在当前 owner 分区内执行,并有 `pg_trgm` 兜底。参考实现,未接 Meilisearch/FTS 前不做跨全库模糊扫。 - -## 5. 选型 - -| 组件 | 选型 | 理由 | -|---|---|---| -| PG 驱动/连接池 | `jackc/pgx/v5` + `pgxpool` | Go 生态主流,性能好,原生 PG 协议,不引 ORM | -| Redis 客户端 | `redis/go-redis/v9` | 事实标准 | -| 迁移 | `golang-migrate/migrate/v4`,`iofs` 嵌入 `deploy/migrations` | 版本化、可 up/down,启动自动 `up` | -| 查询层 | `sqlc` 生成 pgx/v5 代码(以 `deploy/migrations` 为 schema 源,与 golang-migrate 共用同一份 SQL) | 类型安全、消除 `rows.Scan` 样板、schema 演进编译期暴露;动态查询用 pgx 手写补充 | - -## 6. 落地里程碑 - -- **P0 基础设施**(本文档 + docker-compose + 0001 schema + config 扩展)—— ✅ -- **P1 Go 接入**:`store/postgres`(pgxpool + golang-migrate runner + sqlc `AuthKeyStore`)+ `store/redisstore`(`SessionStore`);main 启动迁移并注入。连接层测试保持 Memory 替身(测协议不被 DB 绑架),PG/Redis 由 env-gate 往返集成测试验证。—— ✅ -- **P2 业务 store**:`UserStore` / `AuthorizationStore`(PG)+ `CodeStore`(Redis);Memory 实现抽到 `store/memory` 子包(三后端对称)。登录注册闭环已用上(auth.* + users.getUsers + updates.getState),手机号在 auth 业务层规范为纯数字以匹配 TDesktop 验证码页行为。—— ✅ -- **P3 启动业务 RPC 持久化**:`UpdateStateStore` / `ContactStore` / `DialogStore` / `LangPackStore`, - `updates.getDifference`、`contacts.getContacts`、`messages.getDialogs/getPinnedDialogs`、`langpack.*` - 已由 PG-backed 服务响应;空账号仍返回空业务数据。—— ✅ -- **P3.5 启动配置与账号安全持久化**:`PasswordStore` / `AppConfigStore` / `CountryStore` / - `TempAuthKeyBindingStore`,`account.getPassword`、`help.getAppConfig/getCountriesList`、 - `auth.bindTempAuthKey` 已由 PG-backed 服务校验并响应,temp auth_key 会映射到 perm auth_key 授权。—— ✅ -- **P3.6 官方系统账号与登录消息**:内置 777000 官方账号,`auth.signIn/signUp` 成功后写入登录消息, - `messages.getDialogs/getHistory/search/readHistory` 返回或更新账号视角下的官方会话,并支持 TDesktop - 的 offset/hash 参数;`messages.getDialogs` 的 count/hash 按企业版同样的“完整列表先统计、再分页”语义计算; - `messages.getPeerDialogs` 按企业版同样的指定 peer 查询路径返回 dialog/top message/users/state,缺失 user peer 返回空 dialog 占位;RPC 层延迟向当前 - session 推送 `updateNewMessage`,并向其它在线 session 推送新登录 `updateServiceNotification`; - 在线 session 需先完成 `updates.getState/getDifference` 才接收主动 updates,未 ready 的推送先暂存; - 同时写入账号级 `user_update_events`,`updates.getDifference` 可补偿登录官方消息与官方会话已读事件。—— ✅ -- **P4 消息闭环**:私聊文本 `messages.sendMessage/forwardMessages/readHistory/editMessage/deleteMessages/deleteHistory`、reply_to/forward header 元数据、双端 `message_boxes`、已读 inbox/outbox 回执、`user_update_events`、Redis `pts/box_id` allocator、transactional outbox、在线 session 批量推送、窗口限流、链路指标和游标分页已落地;媒体、群组/频道、完整 qts/seq 仍是后续阶段。 -- **P4.1 联系人与 dialog owner 视角**:`contacts.addContact/acceptContact/importContacts/deleteContacts/updateContactNote/getContactIDs/getStatuses` 与 `messages.toggleDialogPin/reorderPinnedDialogs/markDialogUnread/getDialogUnreadMarks/hidePeerSettingsBar/getPeerSettings` 已接入 PG-backed 服务;`contacts.acceptContact` 复用联系人反向 upsert,把当前用户手机号/姓名写入对方 owner 视角并清除双方单向联系人 shareContact action;集成测试覆盖不同 owner 对同一 user 的独立备注、备注 hash、reverse mutual、dialog 用户视角、置顶重排、manual unread 与隐藏 action bar。—— ✅ -- **P4.2 设置类 durable updates**:联系人 reset、dialog pinned、pinned order、manual unread、peer settings、dialog filters/order/reload 与 folder peers 均写入 `user_update_events`,并同事务写 `dispatch_outbox`;`updates.getDifference` 与 outbox TL 转换支持这些事件,且保存 pinned order / peer settings flags / dialog filter payload / folder peers / exclude auth key,在线 push 不再是唯一通知路径,也不会与 reliable outbox 双重推送。—— ✅ -- **P4.3 删除消息/清空历史**:`messages.deleteMessages/deleteHistory` 已支持 owner 视角软删除、`revoke` 对端清理、dialog top 重算/删除/`just_clear` 保留空 dialog、`updateDeleteMessages` durable payload(`message_ids` + `pts_count=len(message_ids)`)与 outbox 投递;后续新消息会正常重建被删除的 dialog。—— ✅ -- **P4.4 Dialog 分组/归档**:`messages.getDialogFilters/updateDialogFilter/updateDialogFiltersOrder/toggleDialogFilterTags` 与 `folders.editPeerFolders` 已接入 PG-backed 服务;集成测试覆盖 folder_id 0/1 主列表/归档、自定义 filter、tags 和归档还原。—— ✅ -- **P4.5 TDesktop 搜索入口**:`contacts.search` 已支持联系人/非联系人用户搜索,`contacts.getSponsoredPeers` 返回空 sponsored peers,`messages.searchGlobal` 接当前 owner 私聊文本搜索;查询保护与索引设计参考实现,避免客户端搜索框卡在 Loading。—— ✅ -- **P4.6 内容已读、blocklist gate 与 account privacy**:`message_boxes.media_unread/reaction_unread`、`user_update_events(read_message_contents)`、`contact_blocks`、`account_privacy_rules` 已落地;`messages.readMessageContents` 只在实际清理 unread 内容状态时分配 pts 并 durable 推送,被 block 后私聊 send 只写 sender outbox,edit/revoke delete 会返回 forbidden;account privacy 用于用户资料/头像/手机号分享投影。—— ✅ - -## 7. 与铁律的关系 - -- §2 类型边界:`store` 与 `domain` 均不依赖 `tg.*`,仅在 RPC 边界转换。 -- §3 协议/业务隔离:`auth_keys`(协议)与 `authorizations`(业务)分表落地。 -- §6 范围:第一阶段只实现登录 + 空账号主界面必经 RPC,以及登录必需的官方系统消息;二阶段已补私聊文本闭环,群组/频道/媒体仍留后续阶段。 - -## 8. 媒体层(documents / photos / blobs,2026-06-02) - -媒体闭环新增表(migration `0057_media`): - -- `upload_parts`(owner+file_id+part):`upload.saveFilePart/saveBigFilePart` 累积的分片,组装成 blob 后清理(transient)。 -- `file_blobs`(PK `location_key`):可下载二进制的索引,`doc:[:type]` / `photo::` → `backend/object_key/size/mime`;字节本身落 blob backend。 -- `documents` / `photos`:Telegram 文档 / 照片元数据(id、access_hash、file_reference、dc_id、attributes/thumbs/sizes JSONB)。 -- `documents.id` 持久化为 telesrv-owned 正数 id;外部导出资源的 source id 只在 seed 扫描文件名/JSON 时使用,进入 `documents` / `file_blobs` / `sticker_sets` / `available_reactions` 前已归一为服务端 id。RPC、`InputDocument` 与 `inputDocumentFileLocation` 均直接使用该 id。 -- `sticker_sets`:贴纸 / 自定义 emoji 集(含有序 `document_ids`、`packs`、`system_key` 路由系统集)。 -- `available_reactions`:reaction 目录(引用真实文档 id)。 -- `profile_photos`(owner_peer_type+id+kind+photo_id):用户/频道头像历史,`kind=profile|fallback`,current=active 中 sort_order 最大者;`photos.getUserPhotos/deletePhotos` 只操作 profile kind。 -- `contacts.personal_photo_id/personal_photo_date`:viewer 对某个 contact 的私有头像引用;`userprojection` 优先于 profile/fallback 使用,并输出 `userProfilePhoto.personal=true`。 -- 消息表(`private_messages`/`message_boxes`/`channel_messages`)新增 `media` JSONB 快照列;放宽 body 非空 CHECK 为「body 非空 OR media 非空(OR channel action)」,支持「仅媒体」消息。 -- 头像投影:用户头像当前值从 `contacts.personal_photo_id` 与 `profile_photos(kind=profile|fallback)` 批量查询,在 app 层 `userprojection` 按 viewer privacy 富化到 `domain.User.PhotoID/PhotoDCID/PhotoStripped/PhotoPersonal`;channel 头像仍反范式在 `channels.photo_*`(migration `0059`)。 -- `app_configs` 增 `reactions_default` 等(migration `0058`)。 - -blob backend:`internal/app/files`(`BlobBackend` 接口 + `LocalFS` 本地磁盘实现,内容寻址 sha256 两级 fanout 去重,默认 `data/blobs`)。`MediaStore`(PG)只管元数据 + `file_blobs` 索引;字节由 backend 按 `object_key` 读写。 - -种子导入:启动时 `files.Service.SeedMedia` 从 `TELESRV_STICKER_SEED_DIR`(真实 Telegram 导出,含 `available_reactions_raw.json` + 各集 `set_info.json` + `.tgs/.webp/缩略图`)幂等导入:JSON 元数据→表,二进制→blob,`dc_id` 重写为本 server DC。实测 74 reactions / 24 sets / ~1.5k documents / ~3k blob 索引 / ~2.8k 去重磁盘文件。 - -类型边界:`domain.Document/Photo/MessageMedia/StickerSet/AvailableReaction` 带 json tag(store 直接 marshal JSONB),完全不依赖 `tg.*`;domain↔tg 转换集中在 `internal/rpc/convert_media.go`。 diff --git a/docs/tdesktop-patch-notes.md b/docs/tdesktop-patch-notes.md deleted file mode 100644 index 8d8b5a3e..00000000 --- a/docs/tdesktop-patch-notes.md +++ /dev/null @@ -1,116 +0,0 @@ -# Telegram Desktop Patch Notes - -These notes describe the minimal Telegram Desktop patch required to connect a -self-built client to `telesrv`. - -## Target - -| Item | Value | -|---|---| -| Telegram Desktop commit | `9caf32dffc90ddd9bb08ad5777b865f729fa167b` | -| Describe | `v6.8.4-15-g9caf32dffc` | -| TL layer | 225 | -| Local DC id | `2` | -| Local endpoint | `127.0.0.1:2398` | -| IPv6 endpoint | `[::1]:2398` | - -`telesrv` defaults match this patch: - -```powershell -TELESRV_LISTEN=0.0.0.0:2398 -TELESRV_ADVERTISE_IP=127.0.0.1 -TELESRV_DC=2 -``` - -## RSA Key - -Telegram Desktop must contain the public key that matches the private key used -by your local `telesrv` instance. - -Start `telesrv` once so it creates `data/server_rsa.pem`, then export: - -```powershell -openssl rsa -in data/server_rsa.pem -RSAPublicKey_out -out data/server_rsa.pub -``` - -Copy the PEM contents of `data/server_rsa.pub` into both `kPublicRSAKeys` and -`kTestPublicRSAKeys` in: - -```text -Telegram/SourceFiles/mtproto/mtproto_dc_options.cpp -``` - -## DC List Patch - -In the same file, replace the built-in DC arrays with: - -```cpp -const BuiltInDc kBuiltInDcs[] = { - { 2, "127.0.0.1", 2398 }, -}; - -const BuiltInDc kBuiltInDcsIPv6[] = { - { 2, "::1", 2398 }, -}; - -const BuiltInDc kBuiltInDcsTest[] = { - { 2, "127.0.0.1", 2398 }, -}; - -const BuiltInDc kBuiltInDcsIPv6Test[] = { - { 2, "::1", 2398 }, -}; -``` - -In `DcOptions::constructFromBuiltIn()`, mark built-in endpoints TCP-only: - -```cpp -const auto flags = Flag::f_static | Flag::f_tcpo_only; -const auto flags = Flag::f_static | Flag::f_ipv6 | Flag::f_tcpo_only; -``` - -That is the whole client patch: local DC endpoints, matching RSA public key, -and TCP-only flags. - -## Build - -Follow Telegram Desktop's upstream build docs for your platform. For Windows -x64 at the pinned baseline: - -```powershell -git clone --recursive https://github.com/telegramdesktop/tdesktop.git -cd tdesktop -git checkout 9caf32dffc90ddd9bb08ad5777b865f729fa167b -git submodule update --init --recursive - -Telegram\build\prepare\win.bat -cd Telegram -configure.bat x64 -D TDESKTOP_API_ID=YOUR_API_ID -D TDESKTOP_API_HASH=YOUR_API_HASH -``` - -Open `out\Telegram.slnx` in Visual Studio and build the `Telegram` project. - -## Multi-Client Local Test - -Run two clients with isolated working directories: - -```powershell -$tdesktop = "C:\path\to\tdesktop\out\Debug\Telegram.exe" -Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-alice") -Start-Process $tdesktop -ArgumentList @("-workdir", "$PWD\.tdata-bob") -``` - -Use different phone numbers for Alice and Bob. The default development login -code is `12345`. - -## Verification Checklist - -- `telesrv` logs `tl_layer=225` and listens on `2398`. -- Telegram Desktop connects without reconnect loops. -- No new `NOT_IMPLEMENTED`, `Unhandled RPC`, `bad_msg`, panic, or internal error - appears in server logs during startup and basic messaging. -- A second client with a separate `-workdir` can sign in and exchange messages - with the first client. - -Keep every client-side workaround mapped to a server-side compatibility decision -and keep protocol changes separate from UI experiments. \ No newline at end of file diff --git a/docs/upstream-sync.md b/docs/upstream-sync.md deleted file mode 100644 index d2c59d29..00000000 --- a/docs/upstream-sync.md +++ /dev/null @@ -1,39 +0,0 @@ -# Upstream Sync Log - -This file tracks manual syncs from the private `telesrv` workspace into the public -`gramsrv` repository. Keep it append-only so future syncs can start from the last -confirmed source checkpoint. - -## Current Checkpoint - -- Last synced source commit: `937a57b43e4a0a53c09e66c71ce232c7a0735fc9` -- Last synced target commit: `7ccb8ea` -- Sync date: 2026-06-26 -- Source path used locally: `D:\work\waikuai\telegram\telesrv` -- Target path used locally: `D:\work\waikuai\telegram\gramsrv-public` - -## Sync History - -| Date | Source commit | Target commit | Status | Notes | -|---|---|---|---|---| -| 2026-06-09 | `488e409a1898e9c739cc0bd24cb9791636dfd6b3` | `23a2b2a` | synced | Restored sticker path placeholders, media in dialogs/history, media forwarding to channels, and channel history pagination fixes. Source README changes were already covered by the public repo's gramsrv README wording, so no README file changed in the target cherry-pick. | -| 2026-06-09 | `07b2497664bd108dec84f6cfe43715540faf2688` | `6fd690a` | synced | Kept admin/ban participant changes as transient `updateChannelParticipant` pushes instead of durable channel pts events; added memory/Postgres regression coverage. | -| 2026-06-10 | `c65f76f56278f74082c4fa792ed49104d5d33c38` | `d84fa6e` | synced | Aligned private and channel read/update semantics, including content unread state, block-aware private sends/forwards, channel difference nudges, and durable read-content events. | -| 2026-06-10 | `c0a0e5b52240ed415d3b43ba77659821887bf50b` | `860e581` | synced | Added viewer-specific user projection, contact accept/phone sharing behavior, and regression coverage for contact/user/message projections. | -| 2026-06-14 | `a636192ef22ee69d792b0ca7db1c6be963be9cb2` | `8ff3343` | synced | Added privacy-aware user projection, account privacy persistence/service, profile/fallback photo kinds, and viewer-specific dialog/message/user projections. | -| 2026-06-14 | `77b033c8bf8c0a76ff0d7065e2192cbe55d3a3b6` | `75a8861` | synced | Exposed account privacy RPCs and profile/contact photo RPCs, plus `users.getFullUser` privacy and photo projection updates. | -| 2026-06-16 | `04f4527df32ad5c35720cccc41d27fe51549612f` | `6dc4294` | synced | Completed account authorization flows, including active sessions, authorization reset, 2FA/SRP password settings, recovery paths, and auth error mapping. | -| 2026-06-16 | `06b6ae2bb2f90bd7cc1d6a8404a82aff507e6821` | `b435784` | synced | Added Android legacy startup compatibility for MTProto exchange replay/acks and allowlisted legacy `langpack.getLanguages` handling. | -| 2026-06-19 | `3e6321d99607160d0d77bc63977619d5e1d28516` | `82dd299` | synced | Updated account authorization and Android startup/langpack compatibility notes. | -| 2026-06-19 | `53904e72f7c3b96e6ae5e24196d0e551fa8aebce` | `294e994` | synced | Added Android login startup compatibility for `auth.initPasskeyLogin`, legacy `langpack.getLangPack`/`langpack.getStrings` adapters, and `destroy_session` no-ack handling. | -| 2026-06-22 | `d718593156cc7310105007f37645948c17637e0a` | `b4e47c8` | synced | Added Android startup and messaging compatibility stubs/adapters, including legacy `account.registerDevice`, legacy `updates.getDifference`, empty startup surfaces, and related router coverage. | -| 2026-06-22 | `70978db98c3af3fe72100fa6646780c7a5a13daa` | `3642e37` | synced | Fixed Android channel pinned-message search by carrying pinned state from channel metadata through history/getMessages projections. | -| 2026-06-26 | `d051bc37bd14076fdd0a83ad41cd507929b20ece` | `091d8f0` | synced | Added MTProto transport quick-ack compatibility for abridged/intermediate/padded-intermediate clients and reused encoded outbound payloads across fanout sends. | -| 2026-06-26 | `937a57b43e4a0a53c09e66c71ce232c7a0735fc9` | `7ccb8ea` | synced | Autodetect default advertise IP from private non-loopback interfaces and default outbox workers to one for ordered online delivery. | - -## Next Sync - -Start the next batch from source commits after `937a57b43e4a0a53c09e66c71ce232c7a0735fc9`. -At the time this log was created, newer `telesrv` commits existed after that point and were -intentionally left out because the latest batch was scoped to the next two commits after -`70978db98c3af3fe72100fa6646780c7a5a13daa`. diff --git a/go.mod b/go.mod index f085caf2..f4f18440 100644 --- a/go.mod +++ b/go.mod @@ -3,50 +3,76 @@ module telesrv go 1.25.0 require ( + github.com/fxamacker/cbor/v2 v2.8.0 + github.com/go-faster/errors v0.7.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/gotd/ige v0.2.2 - github.com/gotd/td v0.144.0 + github.com/gotd/log/logzap v0.1.1 + github.com/gotd/td v0.158.0 + github.com/gotd/tl v0.4.0 github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa github.com/jackc/pgx/v5 v5.9.2 + github.com/pion/datachannel v1.6.0 + github.com/pion/dtls/v3 v3.1.4 + github.com/pion/ice/v4 v4.2.7 + github.com/pion/logging v0.2.4 + github.com/pion/rtcp v1.2.16 + github.com/pion/rtp v1.10.2 + github.com/pion/sctp v1.10.0 + github.com/pion/srtp/v3 v3.0.11 + github.com/pion/transport/v4 v4.0.2 + github.com/pion/turn/v5 v5.0.9 github.com/redis/go-redis/v9 v9.20.0 + go.uber.org/multierr v1.11.0 go.uber.org/zap v1.28.0 - golang.org/x/sync v0.20.0 + golang.org/x/crypto v0.53.0 + golang.org/x/image v0.31.0 + golang.org/x/net v0.56.0 + golang.org/x/sync v0.21.0 ) require ( + github.com/andybalholm/brotli v1.2.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/coder/websocket v1.8.14 // indirect - github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/fatih/color v1.18.0 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/fatih/color v1.19.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect - github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/jx v1.2.0 // indirect github.com/go-faster/xor v1.0.0 // indirect github.com/go-faster/yaml v0.4.6 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gotd/log v0.1.0 // indirect github.com/gotd/neo v0.1.5 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.6 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ogen-go/ogen v1.19.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/ogen-go/ogen v1.22.0 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/stun/v3 v3.1.5 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/yuin/goldmark v1.8.2 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/net v0.52.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.46.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect rsc.io/qr v0.2.0 // indirect ) + +replace github.com/gotd/td => ../td diff --git a/go.sum b/go.sum index c0099c11..b6a5299f 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -10,8 +12,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -23,18 +25,20 @@ github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= +github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= @@ -60,10 +64,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk= github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0= +github.com/gotd/log v0.1.0 h1:4LJUEvafD1xtBwx2QkrlzFnRgbYXTlWqJPDi8BvrLbU= +github.com/gotd/log v0.1.0/go.mod h1:5ilhdu1Ux0QvDY/FF3Ojfw24Ws3SlCtyLwOpXy8KYXs= +github.com/gotd/log/logzap v0.1.1 h1:O6l7d8HUbODe+UMcrM47eXYDwdJ6RNmpQejLjrlcEIQ= +github.com/gotd/log/logzap v0.1.1/go.mod h1:5ObZkITbfhbsBOLzBkzmMk9QxXc0eNQpimau7zRL+Y8= github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= -github.com/gotd/td v0.144.0 h1:FOSnbzutdWGDL/6w8v/pilqbUJNWqbKI8GrqusOQeOM= -github.com/gotd/td v0.144.0/go.mod h1:h56ixbXbenLoRQKiy0Qq668I9JWHMbeuv6BuFRmOaPI= +github.com/gotd/tl v0.4.0 h1:8k2z0drujiPyhpLDa9PRm/yU1Gwlfn3iUzeInPiXwMA= +github.com/gotd/tl v0.4.0/go.mod h1:CMIcjPWFS4qxxJ+1Ce7U/ilbtPrkoVo/t8uhN5Y/D7c= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -86,20 +94,49 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/ogen-go/ogen v1.19.0 h1:YvdNpeQJ8A8dLLpS6Vs4WxXL53BT6tBPxH0VSjfALhA= -github.com/ogen-go/ogen v1.19.0/go.mod h1:DeShwO+TEpLYXNCuZliSAedphphXsJaTGGbmSomWUjE= +github.com/ogen-go/ogen v1.22.0 h1:7wU+jcIKg/JBAhM95909ULLdAkGr43KQOuvNpJ7Mxb4= +github.com/ogen-go/ogen v1.22.0/go.mod h1:7BOh9a51QiPCC92RMrj1LlkLjejhBAyPhR+oMc6lR9g= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= +github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= +github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao= +github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= +github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg= +github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= +github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ= +github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= +github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8= +github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= +github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM= +github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -107,10 +144,17 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y= +github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -118,18 +162,26 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -140,24 +192,27 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= +golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/admin/service.go b/internal/admin/service.go new file mode 100644 index 00000000..fa95bca3 --- /dev/null +++ b/internal/admin/service.go @@ -0,0 +1,789 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "telesrv/internal/domain" +) + +const ( + ActionSetSendFrozen = "account.set_send_frozen" + ActionGrantPremium = "account.grant_premium" + ActionSetVerified = "account.set_verified" + ActionSetChannelVerified = "channel.set_verified" + ActionRevokeSessions = "account.revoke_sessions" + ActionDeletePrivateMessages = "messages.delete_private_messages" + ActionDeletePrivateHistory = "messages.delete_private_history" + + maxCommandIDLength = 128 + maxActorLength = 128 + maxReasonLength = 1000 + maxHistoryBatches = 100 + maxPremiumMonths = 120 +) + +type CommandRepository interface { + BeginCommand(ctx context.Context, cmd domain.AdminCommand) (domain.AdminCommand, bool, error) + FinishCommand(ctx context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) +} + +type RestrictionStore interface { + GetSendRestriction(ctx context.Context, userID int64) (domain.AccountSendRestriction, bool, error) + SetSendRestriction(ctx context.Context, restriction domain.AccountSendRestriction) (domain.AccountSendRestriction, error) + IsSendFrozen(ctx context.Context, userID int64) (bool, error) +} + +type AuthService interface { + ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) + ResetAuthorization(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) + ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) +} + +type AuthKeyRevoker interface { + RevokeAuthorizationAuthKey(ctx context.Context, authKeyID [8]byte, userID int64) error +} + +type UsersService interface { + AdminUser(ctx context.Context, userID int64) (domain.User, bool, error) + GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error) + SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error) +} + +type UserNotifier interface { + NotifyUserChanged(ctx context.Context, u domain.User) error +} + +type ChannelsService interface { + GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) + SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error) +} + +type ChannelNotifier interface { + NotifyChannelChanged(ctx context.Context, ch domain.Channel) error +} + +type MessagesService interface { + GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) + GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) + DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) + DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) +} + +type Dependencies struct { + Commands CommandRepository + Restrictions RestrictionStore + Auth AuthService + Revoker AuthKeyRevoker + Users UsersService + UserNotifier UserNotifier + Channels ChannelsService + ChannelNotifier ChannelNotifier + Messages MessagesService + Now func() time.Time +} + +type Service struct { + commands CommandRepository + restrictions RestrictionStore + auth AuthService + revoker AuthKeyRevoker + users UsersService + userNotifier UserNotifier + channels ChannelsService + channelNotifier ChannelNotifier + messages MessagesService + now func() time.Time +} + +func NewService(deps Dependencies) *Service { + s := &Service{now: time.Now} + return s.Configure(deps) +} + +func (s *Service) Configure(deps Dependencies) *Service { + if deps.Commands != nil { + s.commands = deps.Commands + } + if deps.Restrictions != nil { + s.restrictions = deps.Restrictions + } + if deps.Auth != nil { + s.auth = deps.Auth + } + if deps.Revoker != nil { + s.revoker = deps.Revoker + } + if deps.Users != nil { + s.users = deps.Users + } + if deps.UserNotifier != nil { + s.userNotifier = deps.UserNotifier + } + if deps.Channels != nil { + s.channels = deps.Channels + } + if deps.ChannelNotifier != nil { + s.channelNotifier = deps.ChannelNotifier + } + if deps.Messages != nil { + s.messages = deps.Messages + } + if deps.Now != nil { + s.now = deps.Now + } + if s.now == nil { + s.now = time.Now + } + return s +} + +type CommandMeta struct { + CommandID string `json:"command_id"` + Actor string `json:"actor"` + Reason string `json:"reason"` + DryRun bool `json:"dry_run"` +} + +type CommandResult struct { + CommandID string `json:"command_id"` + Action string `json:"action"` + Status string `json:"status"` + AlreadyExecuted bool `json:"already_executed"` + DryRun bool `json:"dry_run"` + TargetUserID int64 `json:"target_user_id,omitempty"` + TargetPeer domain.Peer `json:"target_peer,omitempty"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` + Error string `json:"error,omitempty"` +} + +type SetSendFrozenRequest struct { + CommandMeta + UserID int64 `json:"user_id"` + Frozen bool `json:"frozen"` +} + +type GrantPremiumRequest struct { + CommandMeta + UserID int64 `json:"user_id"` + Months int `json:"months"` +} + +type SetVerifiedRequest struct { + CommandMeta + UserID int64 `json:"user_id"` + Verified bool `json:"verified"` +} + +type SetChannelVerifiedRequest struct { + CommandMeta + ChannelID int64 `json:"channel_id"` + Verified bool `json:"verified"` +} + +type RevokeSessionsRequest struct { + CommandMeta + UserID int64 `json:"user_id"` + Hash int64 `json:"hash,omitempty"` + KeepHash int64 `json:"keep_hash,omitempty"` + RevokeAll bool `json:"revoke_all,omitempty"` +} + +type DeletePrivateMessagesRequest struct { + CommandMeta + OwnerUserID int64 `json:"owner_user_id"` + Peer domain.Peer `json:"peer"` + IDs []int `json:"ids"` + Revoke bool `json:"revoke"` +} + +type DeletePrivateHistoryRequest struct { + CommandMeta + OwnerUserID int64 `json:"owner_user_id"` + Peer domain.Peer `json:"peer"` + MaxID int `json:"max_id,omitempty"` + MinDate int `json:"min_date,omitempty"` + MaxDate int `json:"max_date,omitempty"` + JustClear bool `json:"just_clear,omitempty"` + Revoke bool `json:"revoke"` + MaxBatches int `json:"max_batches,omitempty"` +} + +func (s *Service) CanSendMessages(ctx context.Context, userID int64) error { + if s == nil || s.restrictions == nil || userID == 0 { + return nil + } + frozen, err := s.restrictions.IsSendFrozen(ctx, userID) + if err != nil { + return err + } + if frozen { + return domain.ErrUserSendRestricted + } + return nil +} + +func (s *Service) SetSendFrozen(ctx context.Context, req SetSendFrozenRequest) (CommandResult, error) { + if req.UserID <= 0 { + return CommandResult{}, fmt.Errorf("user_id is required") + } + if s == nil || s.restrictions == nil { + return CommandResult{}, fmt.Errorf("admin restriction store is not configured") + } + return s.runCommand(ctx, req.CommandMeta, ActionSetSendFrozen, req.UserID, domain.Peer{}, req, func() (CommandResult, error) { + prev, found, err := s.restrictions.GetSendRestriction(ctx, req.UserID) + if err != nil { + return CommandResult{}, err + } + details := map[string]any{ + "previous_frozen": found && prev.Frozen, + "new_frozen": req.Frozen, + "would_change": !found || prev.Frozen != req.Frozen, + } + if req.DryRun { + return CommandResult{Message: "dry-run completed", Details: details}, nil + } + updated, err := s.restrictions.SetSendRestriction(ctx, domain.AccountSendRestriction{ + UserID: req.UserID, + Frozen: req.Frozen, + Reason: req.Reason, + Actor: req.Actor, + CommandID: req.CommandID, + }) + if err != nil { + return CommandResult{}, err + } + details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339) + return CommandResult{Message: "send restriction updated", Details: details}, nil + }) +} + +func (s *Service) GrantPremium(ctx context.Context, req GrantPremiumRequest) (CommandResult, error) { + if req.UserID <= 0 { + return CommandResult{}, fmt.Errorf("user_id is required") + } + if req.Months < 0 || req.Months > maxPremiumMonths { + return CommandResult{}, fmt.Errorf("months must be between 0 and %d", maxPremiumMonths) + } + if s == nil || s.users == nil { + return CommandResult{}, fmt.Errorf("admin user dependency is not configured") + } + return s.runCommand(ctx, req.CommandMeta, ActionGrantPremium, req.UserID, domain.Peer{}, req, func() (CommandResult, error) { + u, found, err := s.users.AdminUser(ctx, req.UserID) + if err != nil { + return CommandResult{}, err + } + if !found { + return CommandResult{}, domain.ErrUserNotFound + } + if u.Bot { + return CommandResult{}, domain.ErrPremiumBotUnsupported + } + details := premiumCommandDetails(u, req.Months, s.now()) + if req.DryRun { + return CommandResult{Message: "dry-run completed", Details: details}, nil + } + updated, err := s.users.GrantPremium(ctx, req.UserID, req.Months) + if err != nil { + return CommandResult{}, err + } + details["updated_premium_until"] = updated.PremiumUntil + details["updated_premium_active"] = updated.PremiumActiveAt(s.now().Unix()) + if err := s.notifyUserChanged(ctx, updated); err != nil { + details["notify_error"] = err.Error() + } + msg := "premium updated" + if req.Months == 0 { + msg = "premium cleared" + } + return CommandResult{Message: msg, Details: details}, nil + }) +} + +func (s *Service) SetVerified(ctx context.Context, req SetVerifiedRequest) (CommandResult, error) { + if req.UserID <= 0 { + return CommandResult{}, fmt.Errorf("user_id is required") + } + if s == nil || s.users == nil { + return CommandResult{}, fmt.Errorf("admin user dependency is not configured") + } + return s.runCommand(ctx, req.CommandMeta, ActionSetVerified, req.UserID, domain.Peer{}, req, func() (CommandResult, error) { + u, found, err := s.users.AdminUser(ctx, req.UserID) + if err != nil { + return CommandResult{}, err + } + if !found { + return CommandResult{}, domain.ErrUserNotFound + } + details := map[string]any{ + "previous_verified": u.Verified, + "new_verified": req.Verified, + "would_change": u.Verified != req.Verified, + } + if req.DryRun { + return CommandResult{Message: "dry-run completed", Details: details}, nil + } + updated, err := s.users.SetVerified(ctx, req.UserID, req.Verified) + if err != nil { + return CommandResult{}, err + } + details["updated_verified"] = updated.Verified + if err := s.notifyUserChanged(ctx, updated); err != nil { + details["notify_error"] = err.Error() + } + return CommandResult{Message: "verified updated", Details: details}, nil + }) +} + +func (s *Service) SetChannelVerified(ctx context.Context, req SetChannelVerifiedRequest) (CommandResult, error) { + if req.ChannelID <= 0 { + return CommandResult{}, fmt.Errorf("channel_id is required") + } + if s == nil || s.channels == nil { + return CommandResult{}, fmt.Errorf("admin channel dependency is not configured") + } + target := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID} + return s.runCommand(ctx, req.CommandMeta, ActionSetChannelVerified, 0, target, req, func() (CommandResult, error) { + ch, err := s.channels.GetChannelByID(ctx, req.ChannelID) + if err != nil { + return CommandResult{}, err + } + if ch.Monoforum || (!ch.Broadcast && !ch.Megagroup) { + return CommandResult{}, domain.ErrChannelInvalid + } + details := map[string]any{ + "title": ch.Title, + "username": ch.Username, + "broadcast": ch.Broadcast, + "megagroup": ch.Megagroup, + "previous_verified": ch.Verified, + "new_verified": req.Verified, + "would_change": ch.Verified != req.Verified, + } + if req.DryRun { + return CommandResult{Message: "dry-run completed", Details: details}, nil + } + updated, err := s.channels.SetVerified(ctx, req.ChannelID, req.Verified) + if err != nil { + return CommandResult{}, err + } + details["updated_verified"] = updated.Verified + if err := s.notifyChannelChanged(ctx, updated); err != nil { + details["notify_error"] = err.Error() + } + return CommandResult{Message: "channel verified updated", Details: details}, nil + }) +} + +func (s *Service) RevokeSessions(ctx context.Context, req RevokeSessionsRequest) (CommandResult, error) { + if req.UserID <= 0 { + return CommandResult{}, fmt.Errorf("user_id is required") + } + if s == nil || s.auth == nil || s.revoker == nil { + return CommandResult{}, fmt.Errorf("admin auth dependencies are not configured") + } + if (req.Hash == 0 && req.KeepHash == 0 && !req.RevokeAll) || (req.Hash != 0 && (req.KeepHash != 0 || req.RevokeAll)) { + return CommandResult{}, fmt.Errorf("choose one revoke mode") + } + return s.runCommand(ctx, req.CommandMeta, ActionRevokeSessions, req.UserID, domain.Peer{}, req, func() (CommandResult, error) { + items, err := s.auth.ListAuthorizations(ctx, req.UserID) + if err != nil { + return CommandResult{}, err + } + targets, keep, err := revokeTargets(items, req) + if err != nil { + return CommandResult{}, err + } + details := map[string]any{ + "target_hashes": authorizationHashes(targets), + "target_count": len(targets), + "keep_hash": keep.Hash, + } + if req.DryRun { + return CommandResult{Message: "dry-run completed", Details: details}, nil + } + var revoked []domain.Authorization + if req.Hash != 0 { + deleted, found, err := s.auth.ResetAuthorization(ctx, req.UserID, req.Hash) + if err != nil { + return CommandResult{}, err + } + if found { + revoked = append(revoked, deleted) + } + } else { + deleted, err := s.auth.ResetAuthorizations(ctx, req.UserID, keep.AuthKeyID) + if err != nil { + return CommandResult{}, err + } + revoked = append(revoked, deleted...) + } + for _, a := range revoked { + if err := s.revoker.RevokeAuthorizationAuthKey(ctx, a.AuthKeyID, req.UserID); err != nil { + return CommandResult{}, err + } + } + details["revoked_hashes"] = authorizationHashes(revoked) + details["revoked_count"] = len(revoked) + return CommandResult{Message: "sessions revoked", Details: details}, nil + }) +} + +func (s *Service) DeletePrivateMessages(ctx context.Context, req DeletePrivateMessagesRequest) (CommandResult, error) { + ids, err := normalizeIDs(req.IDs) + if err != nil { + return CommandResult{}, err + } + req.IDs = ids + if req.OwnerUserID <= 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID <= 0 { + return CommandResult{}, fmt.Errorf("owner_user_id and user peer are required") + } + if s == nil || s.messages == nil { + return CommandResult{}, fmt.Errorf("admin message dependency is not configured") + } + return s.runCommand(ctx, req.CommandMeta, ActionDeletePrivateMessages, req.OwnerUserID, req.Peer, req, func() (CommandResult, error) { + list, err := s.messages.GetMessages(ctx, req.OwnerUserID, req.IDs) + if err != nil { + return CommandResult{}, err + } + found, missing, err := validatePrivateMessageSelection(req.OwnerUserID, req.Peer, req.IDs, list.Messages) + if err != nil { + return CommandResult{}, err + } + details := map[string]any{ + "requested_ids": req.IDs, + "found_ids": found, + "missing_ids": missing, + "revoke": req.Revoke, + } + if req.DryRun { + return CommandResult{Message: "dry-run completed", Details: details}, nil + } + if len(missing) > 0 { + return CommandResult{}, fmt.Errorf("messages not found for owner/peer: %v", missing) + } + res, err := s.messages.DeleteMessages(ctx, req.OwnerUserID, domain.DeleteMessagesRequest{ + OwnerUserID: req.OwnerUserID, + IDs: req.IDs, + Revoke: req.Revoke, + Date: int(s.now().Unix()), + }) + if err != nil { + return CommandResult{}, err + } + details["deleted"] = summarizeDeleteResult(res) + details["changed"] = res.Changed() + return CommandResult{Message: "messages deleted", Details: details}, nil + }) +} + +func (s *Service) DeletePrivateHistory(ctx context.Context, req DeletePrivateHistoryRequest) (CommandResult, error) { + if req.OwnerUserID <= 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID <= 0 { + return CommandResult{}, fmt.Errorf("owner_user_id and user peer are required") + } + if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID || req.MinDate < 0 || req.MaxDate < 0 { + return CommandResult{}, domain.ErrMessageIDInvalid + } + if req.MaxBatches <= 0 { + req.MaxBatches = 10 + } + if req.MaxBatches > maxHistoryBatches { + return CommandResult{}, fmt.Errorf("max_batches exceeds %d", maxHistoryBatches) + } + if s == nil || s.messages == nil { + return CommandResult{}, fmt.Errorf("admin message dependency is not configured") + } + return s.runCommand(ctx, req.CommandMeta, ActionDeletePrivateHistory, req.OwnerUserID, req.Peer, req, func() (CommandResult, error) { + preview, err := s.messages.GetHistory(ctx, req.OwnerUserID, domain.MessageFilter{ + HasPeer: true, + Peer: req.Peer, + MaxID: req.MaxID, + Limit: 50, + }) + if err != nil { + return CommandResult{}, err + } + details := map[string]any{ + "preview_ids": messageIDs(preview.Messages), + "preview_count": len(preview.Messages), + "batch_limit": domain.MaxDeleteHistoryBatch, + "max_batches": req.MaxBatches, + "revoke": req.Revoke, + "just_clear": req.JustClear, + "date_range_filter": req.MinDate != 0 || req.MaxDate != 0, + } + if req.DryRun { + return CommandResult{Message: "dry-run completed", Details: details}, nil + } + totalDeleted := 0 + ownerBatches := make([]any, 0, req.MaxBatches) + offset := 0 + for batch := 0; batch < req.MaxBatches; batch++ { + res, err := s.messages.DeleteHistory(ctx, req.OwnerUserID, domain.DeleteHistoryRequest{ + OwnerUserID: req.OwnerUserID, + Peer: req.Peer, + MaxID: req.MaxID, + MinDate: req.MinDate, + MaxDate: req.MaxDate, + JustClear: req.JustClear, + Revoke: req.Revoke, + Date: int(s.now().Unix()), + }) + if err != nil { + return CommandResult{}, err + } + self := res.Self() + totalDeleted += len(self.MessageIDs) + ownerBatches = append(ownerBatches, summarizeDeleteResult(res)...) + offset = res.Offset + if res.Offset == 0 { + break + } + } + details["deleted_count"] = totalDeleted + details["deleted"] = ownerBatches + details["has_more"] = offset != 0 + msg := "history deleted" + if offset != 0 { + msg = "history partially deleted; run another command to continue" + } + return CommandResult{Message: msg, Details: details}, nil + }) +} + +func (s *Service) runCommand(ctx context.Context, meta CommandMeta, action string, targetUserID int64, targetPeer domain.Peer, request any, fn func() (CommandResult, error)) (CommandResult, error) { + if s == nil || s.commands == nil { + return CommandResult{}, fmt.Errorf("admin command store is not configured") + } + meta.CommandID = strings.TrimSpace(meta.CommandID) + meta.Actor = strings.TrimSpace(meta.Actor) + meta.Reason = strings.TrimSpace(meta.Reason) + if meta.CommandID == "" || len(meta.CommandID) > maxCommandIDLength { + return CommandResult{}, fmt.Errorf("command_id is required and must be <= %d bytes", maxCommandIDLength) + } + if meta.Actor == "" || len(meta.Actor) > maxActorLength { + return CommandResult{}, fmt.Errorf("actor is required and must be <= %d bytes", maxActorLength) + } + if meta.Reason == "" || len(meta.Reason) > maxReasonLength { + return CommandResult{}, fmt.Errorf("reason is required and must be <= %d bytes", maxReasonLength) + } + requestJSON, err := json.Marshal(request) + if err != nil { + return CommandResult{}, fmt.Errorf("marshal admin request: %w", err) + } + cmd, created, err := s.commands.BeginCommand(ctx, domain.AdminCommand{ + CommandID: meta.CommandID, + Actor: meta.Actor, + Action: action, + TargetUserID: targetUserID, + TargetPeer: targetPeer, + DryRun: meta.DryRun, + Reason: meta.Reason, + RequestJSON: requestJSON, + Status: domain.AdminCommandRunning, + CreatedAt: s.now(), + }) + if err != nil { + return CommandResult{}, err + } + if !created { + return resultFromCommand(cmd), nil + } + result, opErr := fn() + result.CommandID = meta.CommandID + result.Action = action + result.DryRun = meta.DryRun + result.TargetUserID = targetUserID + result.TargetPeer = targetPeer + status := domain.AdminCommandCompleted + if opErr != nil { + status = domain.AdminCommandFailed + result.Status = string(status) + result.Error = opErr.Error() + if result.Message == "" { + result.Message = "command failed" + } + } else { + result.Status = string(status) + } + resultJSON, marshalErr := json.Marshal(result) + if marshalErr != nil { + return result, fmt.Errorf("marshal admin result: %w", marshalErr) + } + errorText := "" + if opErr != nil { + errorText = opErr.Error() + } + if _, err := s.commands.FinishCommand(ctx, meta.CommandID, status, resultJSON, errorText); err != nil { + return result, err + } + return result, opErr +} + +func resultFromCommand(cmd domain.AdminCommand) CommandResult { + var result CommandResult + if len(cmd.ResultJSON) > 0 { + if err := json.Unmarshal(cmd.ResultJSON, &result); err == nil { + result.AlreadyExecuted = true + return result + } + } + result = CommandResult{ + CommandID: cmd.CommandID, + Action: cmd.Action, + Status: string(cmd.Status), + AlreadyExecuted: true, + DryRun: cmd.DryRun, + TargetUserID: cmd.TargetUserID, + TargetPeer: cmd.TargetPeer, + Message: "command already exists", + Error: cmd.Error, + } + return result +} + +func (s *Service) notifyUserChanged(ctx context.Context, u domain.User) error { + if s == nil || s.userNotifier == nil { + return nil + } + return s.userNotifier.NotifyUserChanged(ctx, u) +} + +func (s *Service) notifyChannelChanged(ctx context.Context, ch domain.Channel) error { + if s == nil || s.channelNotifier == nil { + return nil + } + return s.channelNotifier.NotifyChannelChanged(ctx, ch) +} + +func premiumCommandDetails(u domain.User, months int, now time.Time) map[string]any { + active := u.PremiumActiveAt(now.Unix()) + base := now + if active { + base = time.Unix(int64(u.PremiumUntil), 0) + } + projected := 0 + if months > 0 { + projected = int(base.AddDate(0, months, 0).Unix()) + } + return map[string]any{ + "previous_premium_until": u.PremiumUntil, + "previous_premium_active": active, + "months": months, + "new_premium_until": projected, + "would_change": months > 0 || u.PremiumUntil != 0, + } +} + +func revokeTargets(items []domain.Authorization, req RevokeSessionsRequest) ([]domain.Authorization, domain.Authorization, error) { + if req.Hash != 0 { + for _, a := range items { + if a.Hash == req.Hash { + return []domain.Authorization{a}, domain.Authorization{}, nil + } + } + return nil, domain.Authorization{}, nil + } + var keep domain.Authorization + if req.KeepHash != 0 { + found := false + for _, a := range items { + if a.Hash == req.KeepHash { + keep = a + found = true + break + } + } + if !found { + return nil, domain.Authorization{}, fmt.Errorf("keep_hash authorization not found") + } + } + targets := make([]domain.Authorization, 0, len(items)) + for _, a := range items { + if req.KeepHash != 0 && a.Hash == req.KeepHash { + continue + } + targets = append(targets, a) + } + return targets, keep, nil +} + +func authorizationHashes(items []domain.Authorization) []int64 { + out := make([]int64, 0, len(items)) + for _, a := range items { + out = append(out, a.Hash) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func normalizeIDs(ids []int) ([]int, error) { + if len(ids) == 0 { + return nil, domain.ErrMessageIDInvalid + } + if len(ids) > domain.MaxDeleteMessageIDs { + return nil, fmt.Errorf("too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs) + } + seen := make(map[int]struct{}, len(ids)) + out := make([]int, 0, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return nil, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Ints(out) + return out, nil +} + +func validatePrivateMessageSelection(ownerUserID int64, peer domain.Peer, ids []int, messages []domain.Message) ([]int, []int, error) { + foundSet := make(map[int]domain.Message, len(messages)) + for _, msg := range messages { + foundSet[msg.ID] = msg + if msg.OwnerUserID != ownerUserID || msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peer.ID { + return nil, nil, domain.ErrMessageIDInvalid + } + } + found := make([]int, 0, len(messages)) + missing := make([]int, 0) + for _, id := range ids { + if _, ok := foundSet[id]; ok { + found = append(found, id) + continue + } + missing = append(missing, id) + } + return found, missing, nil +} + +func summarizeDeleteResult(res domain.DeleteMessagesResult) []any { + out := make([]any, 0, len(res.Deleted)) + for _, item := range res.Deleted { + ids := append([]int(nil), item.MessageIDs...) + sort.Ints(ids) + out = append(out, map[string]any{ + "user_id": item.UserID, + "message_ids": ids, + "pts": item.Event.Pts, + "pts_count": item.Event.PtsCount, + }) + } + return out +} + +func messageIDs(messages []domain.Message) []int { + out := make([]int, 0, len(messages)) + for _, msg := range messages { + out = append(out, msg.ID) + } + return out +} diff --git a/internal/admin/service_test.go b/internal/admin/service_test.go new file mode 100644 index 00000000..1feac430 --- /dev/null +++ b/internal/admin/service_test.go @@ -0,0 +1,510 @@ +package admin + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestSetSendFrozenDryRunExecuteAndIdempotency(t *testing.T) { + ctx := context.Background() + repo := newMemoryCommandRepo() + restrictions := &fakeRestrictionStore{} + svc := NewService(Dependencies{ + Commands: repo, + Restrictions: restrictions, + Now: fixedNow, + }) + + dry, err := svc.SetSendFrozen(ctx, SetSendFrozenRequest{ + CommandMeta: CommandMeta{CommandID: "dry-freeze", Actor: "ops", Reason: "test", DryRun: true}, + UserID: 1001, + Frozen: true, + }) + if err != nil { + t.Fatalf("dry-run freeze: %v", err) + } + if !dry.DryRun || dry.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 0 { + t.Fatalf("dry-run result=%+v setCalls=%d, want completed dry-run without mutation", dry, restrictions.setCalls) + } + + execReq := SetSendFrozenRequest{ + CommandMeta: CommandMeta{CommandID: "exec-freeze", Actor: "ops", Reason: "incident", DryRun: false}, + UserID: 1001, + Frozen: true, + } + exec, err := svc.SetSendFrozen(ctx, execReq) + if err != nil { + t.Fatalf("execute freeze: %v", err) + } + if exec.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 1 { + t.Fatalf("execute result=%+v setCalls=%d", exec, restrictions.setCalls) + } + if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserSendRestricted) { + t.Fatalf("CanSendMessages err=%v, want ErrUserSendRestricted", err) + } + + again, err := svc.SetSendFrozen(ctx, execReq) + if err != nil { + t.Fatalf("duplicate freeze: %v", err) + } + if !again.AlreadyExecuted || restrictions.setCalls != 1 { + t.Fatalf("duplicate result=%+v setCalls=%d, want idempotent replay", again, restrictions.setCalls) + } +} + +func TestGrantPremiumDryRunExecuteAndIdempotency(t *testing.T) { + ctx := context.Background() + users := &fakeUsersService{users: map[int64]domain.User{ + 1001: {ID: 1001, FirstName: "Alice"}, + }} + notifier := &fakeUserNotifier{} + svc := NewService(Dependencies{ + Commands: newMemoryCommandRepo(), + Users: users, + UserNotifier: notifier, + Now: fixedNow, + }) + + dry, err := svc.GrantPremium(ctx, GrantPremiumRequest{ + CommandMeta: CommandMeta{CommandID: "dry-premium", Actor: "ops", Reason: "test", DryRun: true}, + UserID: 1001, + Months: 3, + }) + if err != nil { + t.Fatalf("dry-run premium: %v", err) + } + if !dry.DryRun || users.grantCalls != 0 || len(notifier.users) != 0 { + t.Fatalf("dry=%+v grantCalls=%d notified=%v, want no mutation", dry, users.grantCalls, notifier.users) + } + + req := GrantPremiumRequest{ + CommandMeta: CommandMeta{CommandID: "exec-premium", Actor: "ops", Reason: "grant"}, + UserID: 1001, + Months: 2, + } + exec, err := svc.GrantPremium(ctx, req) + if err != nil { + t.Fatalf("execute premium: %v", err) + } + if exec.Status != string(domain.AdminCommandCompleted) || users.grantCalls != 1 || users.lastMonths != 2 || len(notifier.users) != 1 { + t.Fatalf("exec=%+v grantCalls=%d months=%d notified=%v", exec, users.grantCalls, users.lastMonths, notifier.users) + } + again, err := svc.GrantPremium(ctx, req) + if err != nil { + t.Fatalf("duplicate premium: %v", err) + } + if !again.AlreadyExecuted || users.grantCalls != 1 || len(notifier.users) != 1 { + t.Fatalf("again=%+v grantCalls=%d notified=%v, want idempotent replay", again, users.grantCalls, notifier.users) + } +} + +func TestSetVerifiedDryRunExecuteAndIdempotency(t *testing.T) { + ctx := context.Background() + users := &fakeUsersService{users: map[int64]domain.User{ + 1001: {ID: 1001, FirstName: "Alice"}, + }} + notifier := &fakeUserNotifier{} + svc := NewService(Dependencies{ + Commands: newMemoryCommandRepo(), + Users: users, + UserNotifier: notifier, + Now: fixedNow, + }) + + dry, err := svc.SetVerified(ctx, SetVerifiedRequest{ + CommandMeta: CommandMeta{CommandID: "dry-verified", Actor: "ops", Reason: "test", DryRun: true}, + UserID: 1001, + Verified: true, + }) + if err != nil { + t.Fatalf("dry-run verified: %v", err) + } + if !dry.DryRun || users.verifiedCalls != 0 || len(notifier.users) != 0 { + t.Fatalf("dry=%+v verifiedCalls=%d notified=%v, want no mutation", dry, users.verifiedCalls, notifier.users) + } + + req := SetVerifiedRequest{ + CommandMeta: CommandMeta{CommandID: "exec-verified", Actor: "ops", Reason: "official"}, + UserID: 1001, + Verified: true, + } + exec, err := svc.SetVerified(ctx, req) + if err != nil { + t.Fatalf("execute verified: %v", err) + } + if exec.Status != string(domain.AdminCommandCompleted) || users.verifiedCalls != 1 || !users.users[1001].Verified || len(notifier.users) != 1 { + t.Fatalf("exec=%+v verifiedCalls=%d user=%+v notified=%v", exec, users.verifiedCalls, users.users[1001], notifier.users) + } + again, err := svc.SetVerified(ctx, req) + if err != nil { + t.Fatalf("duplicate verified: %v", err) + } + if !again.AlreadyExecuted || users.verifiedCalls != 1 || len(notifier.users) != 1 { + t.Fatalf("again=%+v verifiedCalls=%d notified=%v, want idempotent replay", again, users.verifiedCalls, notifier.users) + } +} + +func TestSetChannelVerifiedDryRunExecuteAndIdempotency(t *testing.T) { + ctx := context.Background() + channels := &fakeChannelsService{channels: map[int64]domain.Channel{ + 2001: {ID: 2001, CreatorUserID: 1001, Title: "Ops Channel", Username: "ops", Broadcast: true}, + }} + notifier := &fakeChannelNotifier{} + svc := NewService(Dependencies{ + Commands: newMemoryCommandRepo(), + Channels: channels, + ChannelNotifier: notifier, + Now: fixedNow, + }) + + dry, err := svc.SetChannelVerified(ctx, SetChannelVerifiedRequest{ + CommandMeta: CommandMeta{CommandID: "dry-channel-verified", Actor: "ops", Reason: "test", DryRun: true}, + ChannelID: 2001, + Verified: true, + }) + if err != nil { + t.Fatalf("dry-run channel verified: %v", err) + } + if !dry.DryRun || channels.verifiedCalls != 0 || len(notifier.channels) != 0 { + t.Fatalf("dry=%+v verifiedCalls=%d notified=%v, want no mutation", dry, channels.verifiedCalls, notifier.channels) + } + + req := SetChannelVerifiedRequest{ + CommandMeta: CommandMeta{CommandID: "exec-channel-verified", Actor: "ops", Reason: "official"}, + ChannelID: 2001, + Verified: true, + } + exec, err := svc.SetChannelVerified(ctx, req) + if err != nil { + t.Fatalf("execute channel verified: %v", err) + } + if exec.Status != string(domain.AdminCommandCompleted) || channels.verifiedCalls != 1 || !channels.channels[2001].Verified || len(notifier.channels) != 1 { + t.Fatalf("exec=%+v verifiedCalls=%d channel=%+v notified=%v", exec, channels.verifiedCalls, channels.channels[2001], notifier.channels) + } + if exec.TargetPeer.Type != domain.PeerTypeChannel || exec.TargetPeer.ID != 2001 || exec.TargetUserID != 0 { + t.Fatalf("target user=%d peer=%+v, want channel target", exec.TargetUserID, exec.TargetPeer) + } + again, err := svc.SetChannelVerified(ctx, req) + if err != nil { + t.Fatalf("duplicate channel verified: %v", err) + } + if !again.AlreadyExecuted || channels.verifiedCalls != 1 || len(notifier.channels) != 1 { + t.Fatalf("again=%+v verifiedCalls=%d notified=%v, want idempotent replay", again, channels.verifiedCalls, notifier.channels) + } +} + +func TestDeletePrivateMessagesUsesMessageServiceAndIdempotency(t *testing.T) { + ctx := context.Background() + repo := newMemoryCommandRepo() + messages := &fakeMessagesService{ + byID: []domain.Message{ + {OwnerUserID: 1001, ID: 11, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}}, + {OwnerUserID: 1001, ID: 12, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}}, + }, + } + svc := NewService(Dependencies{Commands: repo, Messages: messages, Now: fixedNow}) + req := DeletePrivateMessagesRequest{ + CommandMeta: CommandMeta{CommandID: "delete-1", Actor: "ops", Reason: "abuse"}, + OwnerUserID: 1001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + IDs: []int{12, 11}, + Revoke: true, + } + + if _, err := svc.DeletePrivateMessages(ctx, req); err != nil { + t.Fatalf("delete messages: %v", err) + } + if messages.deleteCalls != 1 || !reflect.DeepEqual(messages.lastDelete.IDs, []int{11, 12}) || !messages.lastDelete.Revoke { + t.Fatalf("delete calls=%d req=%+v", messages.deleteCalls, messages.lastDelete) + } + if _, err := svc.DeletePrivateMessages(ctx, req); err != nil { + t.Fatalf("duplicate delete messages: %v", err) + } + if messages.deleteCalls != 1 { + t.Fatalf("duplicate delete calls=%d, want 1", messages.deleteCalls) + } +} + +func TestDeletePrivateMessagesRejectsMissingOnExecute(t *testing.T) { + ctx := context.Background() + svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Messages: &fakeMessagesService{}, Now: fixedNow}) + _, err := svc.DeletePrivateMessages(ctx, DeletePrivateMessagesRequest{ + CommandMeta: CommandMeta{CommandID: "delete-missing", Actor: "ops", Reason: "test"}, + OwnerUserID: 1001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + IDs: []int{99}, + }) + if err == nil { + t.Fatal("delete missing message err=nil, want error") + } +} + +func TestRevokeSessionsSpecifiedClosesRevokedAuthKey(t *testing.T) { + ctx := context.Background() + key := [8]byte{1, 2, 3} + auth := &fakeAuthService{items: []domain.Authorization{ + {AuthKeyID: key, UserID: 1001, Hash: 555}, + }} + revoker := &fakeRevoker{} + svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Auth: auth, Revoker: revoker, Now: fixedNow}) + if _, err := svc.RevokeSessions(ctx, RevokeSessionsRequest{ + CommandMeta: CommandMeta{CommandID: "revoke-1", Actor: "ops", Reason: "lost device"}, + UserID: 1001, + Hash: 555, + }); err != nil { + t.Fatalf("revoke sessions: %v", err) + } + if auth.resetHash != 555 || len(revoker.keys) != 1 || revoker.keys[0] != key { + t.Fatalf("resetHash=%d revoked=%v", auth.resetHash, revoker.keys) + } +} + +func TestDeletePrivateHistoryLoopsUntilOffsetClears(t *testing.T) { + ctx := context.Background() + messages := &fakeMessagesService{historyOffsets: []int{1, 0}} + svc := NewService(Dependencies{Commands: newMemoryCommandRepo(), Messages: messages, Now: fixedNow}) + res, err := svc.DeletePrivateHistory(ctx, DeletePrivateHistoryRequest{ + CommandMeta: CommandMeta{CommandID: "history-1", Actor: "ops", Reason: "clear"}, + OwnerUserID: 1001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + MaxBatches: 5, + }) + if err != nil { + t.Fatalf("delete history: %v", err) + } + if messages.historyCalls != 2 || res.Details["has_more"] != false { + t.Fatalf("historyCalls=%d result=%+v", messages.historyCalls, res) + } +} + +func fixedNow() time.Time { + return time.Unix(1_700_000_000, 0).UTC() +} + +type memoryCommandRepo struct { + items map[string]domain.AdminCommand +} + +func newMemoryCommandRepo() *memoryCommandRepo { + return &memoryCommandRepo{items: map[string]domain.AdminCommand{}} +} + +func (m *memoryCommandRepo) BeginCommand(_ context.Context, cmd domain.AdminCommand) (domain.AdminCommand, bool, error) { + if existing, ok := m.items[cmd.CommandID]; ok { + return existing, false, nil + } + m.items[cmd.CommandID] = cmd + return cmd, true, nil +} + +func (m *memoryCommandRepo) FinishCommand(_ context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) { + cmd := m.items[commandID] + cmd.Status = status + cmd.ResultJSON = resultJSON + cmd.Error = errorText + m.items[commandID] = cmd + return cmd, nil +} + +type fakeRestrictionStore struct { + items map[int64]domain.AccountSendRestriction + setCalls int +} + +func (f *fakeRestrictionStore) GetSendRestriction(_ context.Context, userID int64) (domain.AccountSendRestriction, bool, error) { + if f.items == nil { + return domain.AccountSendRestriction{}, false, nil + } + r, ok := f.items[userID] + return r, ok, nil +} + +func (f *fakeRestrictionStore) SetSendRestriction(_ context.Context, r domain.AccountSendRestriction) (domain.AccountSendRestriction, error) { + if f.items == nil { + f.items = map[int64]domain.AccountSendRestriction{} + } + f.setCalls++ + r.UpdatedAt = fixedNow() + f.items[r.UserID] = r + return r, nil +} + +func (f *fakeRestrictionStore) IsSendFrozen(_ context.Context, userID int64) (bool, error) { + if f.items == nil { + return false, nil + } + return f.items[userID].Frozen, nil +} + +type fakeMessagesService struct { + byID []domain.Message + deleteCalls int + lastDelete domain.DeleteMessagesRequest + historyCalls int + historyOffsets []int +} + +func (f *fakeMessagesService) GetMessages(_ context.Context, _ int64, _ []int) (domain.MessageList, error) { + return domain.MessageList{Messages: f.byID}, nil +} + +func (f *fakeMessagesService) GetHistory(_ context.Context, _ int64, _ domain.MessageFilter) (domain.MessageList, error) { + return domain.MessageList{Messages: []domain.Message{{ID: 1}}}, nil +} + +func (f *fakeMessagesService) DeleteMessages(_ context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { + f.deleteCalls++ + f.lastDelete = req + return domain.DeleteMessagesResult{ + OwnerUserID: userID, + Deleted: []domain.DeletedMessagesForUser{{ + UserID: userID, + MessageIDs: req.IDs, + Event: domain.UpdateEvent{Pts: 10, PtsCount: len(req.IDs)}, + }}, + }, nil +} + +func (f *fakeMessagesService) DeleteHistory(_ context.Context, userID int64, _ domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { + offset := 0 + if f.historyCalls < len(f.historyOffsets) { + offset = f.historyOffsets[f.historyCalls] + } + f.historyCalls++ + return domain.DeleteMessagesResult{ + OwnerUserID: userID, + Deleted: []domain.DeletedMessagesForUser{{ + UserID: userID, + MessageIDs: []int{f.historyCalls}, + Event: domain.UpdateEvent{Pts: f.historyCalls, PtsCount: 1}, + }}, + Offset: offset, + }, nil +} + +type fakeAuthService struct { + items []domain.Authorization + resetHash int64 +} + +func (f *fakeAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) { + return f.items, nil +} + +func (f *fakeAuthService) ResetAuthorization(_ context.Context, _ int64, hash int64) (domain.Authorization, bool, error) { + f.resetHash = hash + for _, a := range f.items { + if a.Hash == hash { + return a, true, nil + } + } + return domain.Authorization{}, false, nil +} + +func (f *fakeAuthService) ResetAuthorizations(_ context.Context, _ int64, keep [8]byte) ([]domain.Authorization, error) { + out := make([]domain.Authorization, 0) + for _, a := range f.items { + if a.AuthKeyID != keep { + out = append(out, a) + } + } + return out, nil +} + +type fakeRevoker struct { + keys [][8]byte +} + +func (f *fakeRevoker) RevokeAuthorizationAuthKey(_ context.Context, key [8]byte, _ int64) error { + f.keys = append(f.keys, key) + return nil +} + +type fakeUsersService struct { + users map[int64]domain.User + grantCalls int + lastMonths int + verifiedCalls int +} + +func (f *fakeUsersService) AdminUser(_ context.Context, userID int64) (domain.User, bool, error) { + u, ok := f.users[userID] + return u, ok, nil +} + +func (f *fakeUsersService) GrantPremium(_ context.Context, userID int64, months int) (domain.User, error) { + f.grantCalls++ + f.lastMonths = months + u, ok := f.users[userID] + if !ok { + return domain.User{}, domain.ErrUserNotFound + } + if u.Bot { + return domain.User{}, domain.ErrPremiumBotUnsupported + } + if months <= 0 { + u.PremiumUntil = 0 + } else { + u.PremiumUntil = int(fixedNow().AddDate(0, months, 0).Unix()) + } + f.users[userID] = u + return u, nil +} + +func (f *fakeUsersService) SetVerified(_ context.Context, userID int64, verified bool) (domain.User, error) { + f.verifiedCalls++ + u, ok := f.users[userID] + if !ok { + return domain.User{}, domain.ErrUserNotFound + } + u.Verified = verified + f.users[userID] = u + return u, nil +} + +type fakeUserNotifier struct { + users []int64 +} + +func (f *fakeUserNotifier) NotifyUserChanged(_ context.Context, u domain.User) error { + f.users = append(f.users, u.ID) + return nil +} + +type fakeChannelsService struct { + channels map[int64]domain.Channel + verifiedCalls int +} + +func (f *fakeChannelsService) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) { + ch, ok := f.channels[channelID] + if !ok { + return domain.Channel{}, domain.ErrChannelInvalid + } + return ch, nil +} + +func (f *fakeChannelsService) SetVerified(_ context.Context, channelID int64, verified bool) (domain.Channel, error) { + f.verifiedCalls++ + ch, ok := f.channels[channelID] + if !ok { + return domain.Channel{}, domain.ErrChannelInvalid + } + ch.Verified = verified + f.channels[channelID] = ch + return ch, nil +} + +type fakeChannelNotifier struct { + channels []int64 +} + +func (f *fakeChannelNotifier) NotifyChannelChanged(_ context.Context, ch domain.Channel) error { + f.channels = append(f.channels, ch.ID) + return nil +} diff --git a/internal/adminapi/server.go b/internal/adminapi/server.go new file mode 100644 index 00000000..ce85fc6e --- /dev/null +++ b/internal/adminapi/server.go @@ -0,0 +1,192 @@ +package adminapi + +import ( + "context" + "crypto/subtle" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "go.uber.org/zap" + + "telesrv/internal/admin" +) + +type Config struct { + Addr string + Token string +} + +type Service interface { + SetSendFrozen(ctx context.Context, req admin.SetSendFrozenRequest) (admin.CommandResult, error) + GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error) + SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error) + SetChannelVerified(ctx context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error) + RevokeSessions(ctx context.Context, req admin.RevokeSessionsRequest) (admin.CommandResult, error) + DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) + DeletePrivateHistory(ctx context.Context, req admin.DeletePrivateHistoryRequest) (admin.CommandResult, error) +} + +func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) { + cfg.Addr = strings.TrimSpace(cfg.Addr) + if cfg.Addr == "" { + return nil, nil + } + if strings.TrimSpace(cfg.Token) == "" { + return nil, fmt.Errorf("TELESRV_ADMIN_API_TOKEN is required when TELESRV_ADMIN_API_ADDR is set") + } + if svc == nil { + return nil, fmt.Errorf("admin api service is nil") + } + if log == nil { + log = zap.NewNop() + } + server := &Server{token: cfg.Token, svc: svc, log: log} + httpServer := &http.Server{ + Addr: cfg.Addr, + Handler: server.routes(), + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + log.Info("Admin API 已启用", zap.String("addr", cfg.Addr)) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Warn("Admin API 退出", zap.Error(err)) + } + }() + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdownCtx) + }() + return httpServer, nil +} + +type Server struct { + token string + svc Service + log *zap.Logger +} + +func (s *Server) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + mux.HandleFunc("POST /v1/accounts/freeze-send", s.authenticated(s.handleFreezeSend)) + mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium)) + mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified)) + mux.HandleFunc("POST /v1/accounts/revoke-sessions", s.authenticated(s.handleRevokeSessions)) + mux.HandleFunc("POST /v1/channels/set-verified", s.authenticated(s.handleSetChannelVerified)) + mux.HandleFunc("POST /v1/messages/delete", s.authenticated(s.handleDeleteMessages)) + mux.HandleFunc("POST /v1/messages/delete-history", s.authenticated(s.handleDeleteHistory)) + return mux +} + +func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + next(w, r) + } +} + +func (s *Server) handleFreezeSend(w http.ResponseWriter, r *http.Request) { + var req admin.SetSendFrozenRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.SetSendFrozen(r.Context(), req) + writeCommandResult(w, result, err) +} + +func (s *Server) handleGrantPremium(w http.ResponseWriter, r *http.Request) { + var req admin.GrantPremiumRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.GrantPremium(r.Context(), req) + writeCommandResult(w, result, err) +} + +func (s *Server) handleSetVerified(w http.ResponseWriter, r *http.Request) { + var req admin.SetVerifiedRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.SetVerified(r.Context(), req) + writeCommandResult(w, result, err) +} + +func (s *Server) handleSetChannelVerified(w http.ResponseWriter, r *http.Request) { + var req admin.SetChannelVerifiedRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.SetChannelVerified(r.Context(), req) + writeCommandResult(w, result, err) +} + +func (s *Server) handleRevokeSessions(w http.ResponseWriter, r *http.Request) { + var req admin.RevokeSessionsRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.RevokeSessions(r.Context(), req) + writeCommandResult(w, result, err) +} + +func (s *Server) handleDeleteMessages(w http.ResponseWriter, r *http.Request) { + var req admin.DeletePrivateMessagesRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.DeletePrivateMessages(r.Context(), req) + writeCommandResult(w, result, err) +} + +func (s *Server) handleDeleteHistory(w http.ResponseWriter, r *http.Request) { + var req admin.DeletePrivateHistoryRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.DeletePrivateHistory(r.Context(), req) + writeCommandResult(w, result, err) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + defer r.Body.Close() + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + writeError(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return false + } + return true +} + +func writeCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) { + status := http.StatusOK + if err != nil { + status = http.StatusBadRequest + if result.CommandID == "" { + result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()} + } + } + writeJSON(w, status, result) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/adminapi/server_test.go b/internal/adminapi/server_test.go new file mode 100644 index 00000000..28f13c4c --- /dev/null +++ b/internal/adminapi/server_test.go @@ -0,0 +1,93 @@ +package adminapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "telesrv/internal/admin" +) + +func TestAdminAPIRequiresBearerToken(t *testing.T) { + srv := &Server{token: "secret", svc: fakeService{}} + req := httptest.NewRequest(http.MethodPost, "/v1/accounts/freeze-send", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + srv.routes().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status=%d, want 401", rec.Code) + } +} + +func TestAdminAPIFreezeSend(t *testing.T) { + srv := &Server{token: "secret", svc: fakeService{}} + req := httptest.NewRequest(http.MethodPost, "/v1/accounts/freeze-send", strings.NewReader(`{"command_id":"c1","actor":"ops","reason":"test","dry_run":true,"user_id":1001,"frozen":true}`)) + req.Header.Set("Authorization", "Bearer secret") + 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(), `"command_id":"c1"`) { + t.Fatalf("body=%s", rec.Body.String()) + } +} + +func TestAdminAPISetVerified(t *testing.T) { + srv := &Server{token: "secret", svc: fakeService{}} + req := httptest.NewRequest(http.MethodPost, "/v1/accounts/set-verified", strings.NewReader(`{"command_id":"c2","actor":"ops","reason":"official","dry_run":true,"user_id":1001,"verified":true}`)) + req.Header.Set("Authorization", "Bearer secret") + 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(), `"command_id":"c2"`) { + t.Fatalf("body=%s", rec.Body.String()) + } +} + +func TestAdminAPISetChannelVerified(t *testing.T) { + srv := &Server{token: "secret", svc: fakeService{}} + req := httptest.NewRequest(http.MethodPost, "/v1/channels/set-verified", strings.NewReader(`{"command_id":"c3","actor":"ops","reason":"official","dry_run":true,"channel_id":2001,"verified":true}`)) + req.Header.Set("Authorization", "Bearer secret") + 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(), `"command_id":"c3"`) { + t.Fatalf("body=%s", rec.Body.String()) + } +} + +type fakeService struct{} + +func (fakeService) SetSendFrozen(_ context.Context, req admin.SetSendFrozenRequest) (admin.CommandResult, error) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (fakeService) GrantPremium(_ context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (fakeService) SetVerified(_ context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (fakeService) SetChannelVerified(_ context.Context, req admin.SetChannelVerifiedRequest) (admin.CommandResult, error) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + +func (fakeService) RevokeSessions(context.Context, admin.RevokeSessionsRequest) (admin.CommandResult, error) { + return admin.CommandResult{}, nil +} + +func (fakeService) DeletePrivateMessages(context.Context, admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) { + return admin.CommandResult{}, nil +} + +func (fakeService) DeletePrivateHistory(context.Context, admin.DeletePrivateHistoryRequest) (admin.CommandResult, error) { + return admin.CommandResult{}, nil +} diff --git a/internal/app/account/business.go b/internal/app/account/business.go new file mode 100644 index 00000000..3581565c --- /dev/null +++ b/internal/app/account/business.go @@ -0,0 +1,506 @@ +package account + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "telesrv/internal/domain" +) + +const ( + maxBusinessLocationAddress = 96 + maxBusinessIntroTitle = 64 + maxBusinessIntroDesc = 160 +) + +func (s *Service) GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.BusinessProfile{UserID: userID}, false, nil + } + return s.business.GetBusinessProfile(ctx, userID) +} + +func (s *Service) UpdateBusinessWorkHours(ctx context.Context, userID int64, hours *domain.BusinessWorkHours) (domain.BusinessProfile, error) { + profile, err := s.businessProfileForUpdate(ctx, userID) + if err != nil { + return domain.BusinessProfile{}, err + } + normalized, err := normalizeBusinessWorkHours(hours) + if err != nil { + return domain.BusinessProfile{}, err + } + profile.WorkHours = normalized + return s.saveBusinessProfile(ctx, profile) +} + +func (s *Service) UpdateBusinessLocation(ctx context.Context, userID int64, location *domain.BusinessLocation) (domain.BusinessProfile, error) { + profile, err := s.businessProfileForUpdate(ctx, userID) + if err != nil { + return domain.BusinessProfile{}, err + } + normalized, err := normalizeBusinessLocation(location) + if err != nil { + return domain.BusinessProfile{}, err + } + profile.Location = normalized + return s.saveBusinessProfile(ctx, profile) +} + +func (s *Service) UpdateBusinessIntro(ctx context.Context, userID int64, intro *domain.BusinessIntro) (domain.BusinessProfile, error) { + profile, err := s.businessProfileForUpdate(ctx, userID) + if err != nil { + return domain.BusinessProfile{}, err + } + normalized, err := normalizeBusinessIntro(intro) + if err != nil { + return domain.BusinessProfile{}, err + } + profile.Intro = normalized + return s.saveBusinessProfile(ctx, profile) +} + +func (s *Service) UpdateBusinessGreetingMessage(ctx context.Context, userID int64, greeting *domain.BusinessGreetingMessage) (domain.BusinessProfile, error) { + profile, err := s.businessProfileForUpdate(ctx, userID) + if err != nil { + return domain.BusinessProfile{}, err + } + normalized, err := normalizeBusinessGreeting(greeting) + if err != nil { + return domain.BusinessProfile{}, err + } + profile.Greeting = normalized + return s.saveBusinessProfile(ctx, profile) +} + +func (s *Service) UpdateBusinessAwayMessage(ctx context.Context, userID int64, away *domain.BusinessAwayMessage) (domain.BusinessProfile, error) { + profile, err := s.businessProfileForUpdate(ctx, userID) + if err != nil { + return domain.BusinessProfile{}, err + } + normalized, err := normalizeBusinessAway(away) + if err != nil { + return domain.BusinessProfile{}, err + } + profile.Away = normalized + return s.saveBusinessProfile(ctx, profile) +} + +func (s *Service) ListBusinessChatLinks(ctx context.Context, userID int64) ([]domain.BusinessChatLink, error) { + if s == nil || s.business == nil || userID == 0 { + return nil, nil + } + return s.business.ListBusinessChatLinks(ctx, userID) +} + +func (s *Service) CreateBusinessChatLink(ctx context.Context, userID int64, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.BusinessChatLink{}, domain.ErrPremiumRequired + } + normalized, err := domain.NormalizeBusinessChatLinkInput(input) + if err != nil { + return domain.BusinessChatLink{}, err + } + now := time.Now().Unix() + for i := 0; i < 8; i++ { + slug, err := randomBusinessChatLinkSlug() + if err != nil { + return domain.BusinessChatLink{}, err + } + link, err := s.business.CreateBusinessChatLink(ctx, domain.BusinessChatLink{ + OwnerUserID: userID, + Slug: slug, + Link: businessChatLinkURL(slug), + Message: normalized.Message, + Entities: normalized.Entities, + Title: normalized.Title, + CreatedAt: now, + UpdatedAt: now, + }) + if err == nil { + return link, nil + } + if !errors.Is(err, domain.ErrBusinessChatLinkInvalid) { + return domain.BusinessChatLink{}, err + } + } + return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkInvalid +} + +func (s *Service) EditBusinessChatLink(ctx context.Context, userID int64, slug string, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.BusinessChatLink{}, domain.ErrPremiumRequired + } + normalized, err := domain.NormalizeBusinessChatLinkInput(input) + if err != nil { + return domain.BusinessChatLink{}, err + } + return s.business.UpdateBusinessChatLink(ctx, userID, strings.TrimSpace(slug), normalized) +} + +func (s *Service) DeleteBusinessChatLink(ctx context.Context, userID int64, slug string) (bool, error) { + if s == nil || s.business == nil || userID == 0 { + return false, domain.ErrBusinessChatLinkNotFound + } + return s.business.DeleteBusinessChatLink(ctx, userID, strings.TrimSpace(slug)) +} + +func (s *Service) ResolveBusinessChatLink(ctx context.Context, slug string, bumpViews bool) (domain.BusinessChatLink, bool, error) { + if s == nil || s.business == nil { + return domain.BusinessChatLink{}, false, nil + } + return s.business.ResolveBusinessChatLink(ctx, strings.TrimSpace(slug), bumpViews) +} + +func (s *Service) GetConnectedBusinessBot(ctx context.Context, ownerUserID int64) (domain.ConnectedBusinessBot, bool, error) { + if s == nil || s.business == nil || ownerUserID == 0 { + return domain.ConnectedBusinessBot{}, false, nil + } + return s.business.GetConnectedBusinessBot(ctx, ownerUserID) +} + +func (s *Service) SaveConnectedBusinessBot(ctx context.Context, ownerUserID int64, bot domain.ConnectedBusinessBot) (domain.ConnectedBusinessBot, error) { + if s == nil || s.business == nil || ownerUserID == 0 || bot.BotUserID == 0 || bot.BotUserID == ownerUserID { + return domain.ConnectedBusinessBot{}, domain.ErrBotBusinessMissing + } + recipients, err := normalizeBusinessBotRecipients(bot.Recipients) + if err != nil { + return domain.ConnectedBusinessBot{}, err + } + now := time.Now().Unix() + bot.OwnerUserID = ownerUserID + bot.Recipients = recipients + if bot.CreatedAtUnix == 0 { + bot.CreatedAtUnix = now + } + bot.UpdatedAtUnix = now + return s.business.SaveConnectedBusinessBot(ctx, bot) +} + +func (s *Service) DeleteConnectedBusinessBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) { + if s == nil || s.business == nil || ownerUserID == 0 || botUserID == 0 { + return false, domain.ErrBotBusinessMissing + } + return s.business.DeleteConnectedBusinessBot(ctx, ownerUserID, botUserID) +} + +func (s *Service) SetConnectedBusinessBotPaused(ctx context.Context, ownerUserID, peerUserID int64, paused bool) (domain.ConnectedBusinessBotPeerState, error) { + if s == nil || s.business == nil || ownerUserID == 0 || peerUserID == 0 || ownerUserID == peerUserID { + return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing + } + if _, ok, err := s.business.GetConnectedBusinessBot(ctx, ownerUserID); err != nil { + return domain.ConnectedBusinessBotPeerState{}, err + } else if !ok { + return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing + } + state, err := s.business.SetConnectedBusinessBotPaused(ctx, ownerUserID, peerUserID, paused) + if err != nil { + return domain.ConnectedBusinessBotPeerState{}, err + } + if state.UpdatedAtUnix == 0 { + state.UpdatedAtUnix = time.Now().Unix() + } + return state, nil +} + +func (s *Service) DisableConnectedBusinessBotForPeer(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, error) { + if s == nil || s.business == nil || ownerUserID == 0 || peerUserID == 0 || ownerUserID == peerUserID { + return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing + } + if _, ok, err := s.business.GetConnectedBusinessBot(ctx, ownerUserID); err != nil { + return domain.ConnectedBusinessBotPeerState{}, err + } else if !ok { + return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing + } + state, err := s.business.DisableConnectedBusinessBotForPeer(ctx, ownerUserID, peerUserID) + if err != nil { + return domain.ConnectedBusinessBotPeerState{}, err + } + if state.UpdatedAtUnix == 0 { + state.UpdatedAtUnix = time.Now().Unix() + } + return state, nil +} + +func (s *Service) GetConnectedBusinessBotPeerState(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, bool, error) { + if s == nil || s.business == nil || ownerUserID == 0 || peerUserID == 0 { + return domain.ConnectedBusinessBotPeerState{}, false, nil + } + return s.business.GetConnectedBusinessBotPeerState(ctx, ownerUserID, peerUserID) +} + +func (s *Service) ListQuickReplies(ctx context.Context, userID int64) (domain.QuickReplyList, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.QuickReplyList{OwnerUserID: userID}, nil + } + return s.business.ListQuickReplies(ctx, userID, true) +} + +func (s *Service) CheckQuickReplyShortcut(ctx context.Context, userID int64, shortcut string) (bool, error) { + if s == nil || s.business == nil || userID == 0 { + if _, err := domain.NormalizeQuickReplyShortcut(shortcut); err != nil { + return false, err + } + return true, nil + } + return s.business.CheckQuickReplyShortcut(ctx, userID, shortcut) +} + +func (s *Service) SaveQuickReplyText(ctx context.Context, userID int64, shortcut string, msg domain.QuickReplyMessage) (domain.QuickReplyMutation, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.QuickReplyMutation{}, domain.ErrPremiumRequired + } + if msg.Message == "" || utf8.RuneCountInString(msg.Message) > domain.MaxMessageTextLength || len(msg.Entities) > domain.MaxMessageEntityCount { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + return s.business.SaveQuickReplyText(ctx, userID, shortcut, msg) +} + +func (s *Service) GetQuickReplyMessages(ctx context.Context, userID int64, shortcutID int, ids []int) (domain.QuickReplyMessages, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.QuickReplyMessages{}, domain.ErrShortcutInvalid + } + return s.business.GetQuickReplyMessages(ctx, userID, shortcutID, ids) +} + +func (s *Service) RenameQuickReplyShortcut(ctx context.Context, userID int64, shortcutID int, shortcut string) (domain.QuickReplyMutation, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.QuickReplyMutation{}, domain.ErrPremiumRequired + } + return s.business.RenameQuickReplyShortcut(ctx, userID, shortcutID, shortcut) +} + +func (s *Service) ReorderQuickReplies(ctx context.Context, userID int64, order []int) (domain.QuickReplyMutation, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.QuickReplyMutation{}, domain.ErrPremiumRequired + } + return s.business.ReorderQuickReplies(ctx, userID, append([]int(nil), order...)) +} + +func (s *Service) DeleteQuickReplyShortcut(ctx context.Context, userID int64, shortcutID int) (domain.QuickReplyMutation, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + return s.business.DeleteQuickReplyShortcut(ctx, userID, shortcutID) +} + +func (s *Service) DeleteQuickReplyMessages(ctx context.Context, userID int64, shortcutID int, ids []int) (domain.QuickReplyMutation, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + return s.business.DeleteQuickReplyMessages(ctx, userID, shortcutID, append([]int(nil), ids...)) +} + +func (s *Service) businessProfileForUpdate(ctx context.Context, userID int64) (domain.BusinessProfile, error) { + if s == nil || s.business == nil || userID == 0 { + return domain.BusinessProfile{}, domain.ErrPremiumRequired + } + profile, _, err := s.business.GetBusinessProfile(ctx, userID) + if err != nil { + return domain.BusinessProfile{}, err + } + profile.UserID = userID + return profile, nil +} + +func (s *Service) saveBusinessProfile(ctx context.Context, profile domain.BusinessProfile) (domain.BusinessProfile, error) { + profile.UpdatedAtUnix = time.Now().Unix() + if err := s.business.SaveBusinessProfile(ctx, profile); err != nil { + return domain.BusinessProfile{}, err + } + return profile, nil +} + +func normalizeBusinessWorkHours(in *domain.BusinessWorkHours) (*domain.BusinessWorkHours, error) { + if in == nil { + return nil, nil + } + out := *in + out.TimezoneID = strings.TrimSpace(out.TimezoneID) + out.OpenNow = false + if out.TimezoneID == "" || len(out.WeeklyOpen) == 0 || len(out.WeeklyOpen) > domain.MaxBusinessWorkHourIntervals { + return nil, domain.ErrBusinessProfileInvalid + } + out.WeeklyOpen = append([]domain.BusinessWeeklyOpen(nil), out.WeeklyOpen...) + for _, item := range out.WeeklyOpen { + if item.StartMinute < 0 || item.EndMinute <= item.StartMinute || item.EndMinute > 8*24*60 { + return nil, domain.ErrBusinessProfileInvalid + } + } + return &out, nil +} + +func normalizeBusinessLocation(in *domain.BusinessLocation) (*domain.BusinessLocation, error) { + if in == nil { + return nil, nil + } + out := *in + out.Address = strings.TrimSpace(out.Address) + if out.Address == "" || utf8.RuneCountInString(out.Address) > maxBusinessLocationAddress { + return nil, domain.ErrBusinessProfileInvalid + } + if out.Geo != nil { + geo := *out.Geo + if geo.Lat < -90 || geo.Lat > 90 || geo.Long < -180 || geo.Long > 180 { + return nil, domain.ErrBusinessProfileInvalid + } + out.Geo = &geo + } + return &out, nil +} + +func normalizeBusinessIntro(in *domain.BusinessIntro) (*domain.BusinessIntro, error) { + if in == nil { + return nil, nil + } + out := *in + out.Title = strings.TrimSpace(out.Title) + out.Description = strings.TrimSpace(out.Description) + if out.Title == "" && out.Description == "" && out.StickerDocumentID == 0 { + return nil, nil + } + if utf8.RuneCountInString(out.Title) > maxBusinessIntroTitle || utf8.RuneCountInString(out.Description) > maxBusinessIntroDesc { + return nil, domain.ErrBusinessProfileInvalid + } + return &out, nil +} + +func normalizeBusinessGreeting(in *domain.BusinessGreetingMessage) (*domain.BusinessGreetingMessage, error) { + if in == nil { + return nil, nil + } + out := *in + if out.ShortcutID <= 0 || !validGreetingNoActivityDays(out.NoActivityDays) { + return nil, domain.ErrBusinessProfileInvalid + } + recipients, err := normalizeBusinessRecipients(out.Recipients) + if err != nil { + return nil, err + } + out.Recipients = recipients + return &out, nil +} + +func normalizeBusinessAway(in *domain.BusinessAwayMessage) (*domain.BusinessAwayMessage, error) { + if in == nil { + return nil, nil + } + out := *in + if out.ShortcutID <= 0 { + return nil, domain.ErrBusinessProfileInvalid + } + switch out.Schedule.Kind { + case domain.BusinessAwayScheduleAlways, domain.BusinessAwayScheduleOutsideWorkHours: + out.Schedule.StartDate = 0 + out.Schedule.EndDate = 0 + case domain.BusinessAwayScheduleCustom: + if out.Schedule.StartDate <= 0 || out.Schedule.EndDate <= out.Schedule.StartDate { + return nil, domain.ErrBusinessProfileInvalid + } + default: + return nil, domain.ErrBusinessProfileInvalid + } + recipients, err := normalizeBusinessRecipients(out.Recipients) + if err != nil { + return nil, err + } + out.Recipients = recipients + return &out, nil +} + +func normalizeBusinessRecipients(in domain.BusinessRecipients) (domain.BusinessRecipients, error) { + out := in + out.Users = append([]int64(nil), in.Users...) + if len(out.Users) > domain.MaxBusinessRecipientUsers { + return domain.BusinessRecipients{}, domain.ErrBusinessProfileInvalid + } + seen := make(map[int64]struct{}, len(out.Users)) + users := out.Users[:0] + for _, id := range out.Users { + if id <= 0 { + return domain.BusinessRecipients{}, domain.ErrBusinessProfileInvalid + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + users = append(users, id) + } + out.Users = users + if !out.ExistingChats && !out.NewChats && !out.Contacts && !out.NonContacts && len(out.Users) == 0 { + return domain.BusinessRecipients{}, domain.ErrBusinessProfileInvalid + } + return out, nil +} + +func normalizeBusinessBotRecipients(in domain.BusinessBotRecipients) (domain.BusinessBotRecipients, error) { + out := in + users, err := dedupeBusinessUserIDs(in.Users) + if err != nil { + return domain.BusinessBotRecipients{}, err + } + excluded, err := dedupeBusinessUserIDs(in.ExcludeUsers) + if err != nil { + return domain.BusinessBotRecipients{}, err + } + if len(users)+len(excluded) > domain.MaxBusinessRecipientUsers { + return domain.BusinessBotRecipients{}, domain.ErrBusinessProfileInvalid + } + if out.ExcludeSelected { + merged := append(users, excluded...) + users, err = dedupeBusinessUserIDs(merged) + if err != nil { + return domain.BusinessBotRecipients{}, err + } + excluded = nil + } + out.Users = users + out.ExcludeUsers = excluded + if !out.ExcludeSelected && !out.ExistingChats && !out.NewChats && !out.Contacts && !out.NonContacts && len(out.Users) == 0 { + return domain.BusinessBotRecipients{}, domain.ErrBusinessRecipientsEmpty + } + return out, nil +} + +func dedupeBusinessUserIDs(in []int64) ([]int64, error) { + if len(in) == 0 { + return nil, nil + } + seen := make(map[int64]struct{}, len(in)) + out := make([]int64, 0, len(in)) + for _, id := range in { + if id <= 0 { + return nil, domain.ErrBusinessProfileInvalid + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out, nil +} + +func validGreetingNoActivityDays(days int) bool { + switch days { + case 7, 14, 21, 28: + return true + default: + return false + } +} + +func randomBusinessChatLinkSlug() (string, error) { + value, err := randomInt64() + if err != nil { + return "", err + } + return fmt.Sprintf("%x", value), nil +} + +func businessChatLinkURL(slug string) string { + return "https://telesrv.net/m/" + slug +} diff --git a/internal/app/account/business_test.go b/internal/app/account/business_test.go new file mode 100644 index 00000000..2f5e3d39 --- /dev/null +++ b/internal/app/account/business_test.go @@ -0,0 +1,158 @@ +package account + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestBusinessProfileAndChatLinks(t *testing.T) { + ctx := context.Background() + const userID int64 = 1001 + store := memory.NewPasswordStore() + svc := NewService(store, WithBusinessAutomation(store)) + + profile, err := svc.UpdateBusinessWorkHours(ctx, userID, &domain.BusinessWorkHours{ + TimezoneID: "Asia/Shanghai", + WeeklyOpen: []domain.BusinessWeeklyOpen{{ + StartMinute: 6*24*60 + 21*60, + EndMinute: 7*24*60 + 4*60, + }}, + OpenNow: true, + }) + if err != nil { + t.Fatalf("UpdateBusinessWorkHours: %v", err) + } + if profile.WorkHours == nil || profile.WorkHours.OpenNow { + t.Fatalf("WorkHours = %+v, want persisted hours with OpenNow cleared", profile.WorkHours) + } + if _, err := svc.UpdateBusinessLocation(ctx, userID, &domain.BusinessLocation{ + Address: "No. 1 Test Road", + Geo: &domain.GeoPoint{Lat: 31.2, Long: 121.5}, + }); err != nil { + t.Fatalf("UpdateBusinessLocation: %v", err) + } + if _, err := svc.UpdateBusinessIntro(ctx, userID, &domain.BusinessIntro{ + Title: "Support", + Description: "Fast replies", + }); err != nil { + t.Fatalf("UpdateBusinessIntro: %v", err) + } + got, found, err := svc.GetBusinessProfile(ctx, userID) + if err != nil || !found { + t.Fatalf("GetBusinessProfile found=%v err=%v", found, err) + } + if got.Location == nil || got.Intro == nil { + t.Fatalf("profile = %+v, want location and intro", got) + } + + link, err := svc.CreateBusinessChatLink(ctx, userID, domain.BusinessChatLinkInput{ + Message: "Hello from link", + Title: "Support link", + Entities: []domain.MessageEntity{{ + Type: domain.MessageEntityBold, + Offset: 0, + Length: 5, + }}, + }) + if err != nil { + t.Fatalf("CreateBusinessChatLink: %v", err) + } + if link.Slug == "" || link.Link == "" { + t.Fatalf("created link = %+v, want slug/link", link) + } + links, err := svc.ListBusinessChatLinks(ctx, userID) + if err != nil || len(links) != 1 { + t.Fatalf("ListBusinessChatLinks len=%d err=%v", len(links), err) + } + resolved, found, err := svc.ResolveBusinessChatLink(ctx, link.Slug, true) + if err != nil || !found || resolved.Views != 1 { + t.Fatalf("ResolveBusinessChatLink found=%v link=%+v err=%v", found, resolved, err) + } + edited, err := svc.EditBusinessChatLink(ctx, userID, link.Slug, domain.BusinessChatLinkInput{ + Message: "Edited", + Title: "New title", + }) + if err != nil { + t.Fatalf("EditBusinessChatLink: %v", err) + } + if edited.Message != "Edited" || edited.Title != "New title" { + t.Fatalf("edited link = %+v", edited) + } + deleted, err := svc.DeleteBusinessChatLink(ctx, userID, link.Slug) + if err != nil || !deleted { + t.Fatalf("DeleteBusinessChatLink deleted=%v err=%v", deleted, err) + } + if _, found, err := svc.ResolveBusinessChatLink(ctx, link.Slug, false); err != nil || found { + t.Fatalf("Resolve deleted found=%v err=%v", found, err) + } +} + +func TestQuickRepliesLifecycle(t *testing.T) { + ctx := context.Background() + const userID int64 = 1002 + store := memory.NewPasswordStore() + svc := NewService(store, WithBusinessAutomation(store)) + + available, err := svc.CheckQuickReplyShortcut(ctx, userID, "hello") + if err != nil || !available { + t.Fatalf("CheckQuickReplyShortcut available=%v err=%v", available, err) + } + mutation, err := svc.SaveQuickReplyText(ctx, userID, "hello", domain.QuickReplyMessage{ + RandomID: 11, + Date: 123, + Message: "First template", + Entities: []domain.MessageEntity{{Type: domain.MessageEntityItalic, Offset: 0, Length: 5}}, + }) + if err != nil { + t.Fatalf("SaveQuickReplyText first: %v", err) + } + if mutation.Kind != domain.QuickReplyMutationNew || mutation.ShortcutID == 0 || mutation.Message.ID == 0 { + t.Fatalf("first mutation = %+v", mutation) + } + shortcutID := mutation.ShortcutID + second, err := svc.SaveQuickReplyText(ctx, userID, "hello", domain.QuickReplyMessage{ + RandomID: 12, + Date: 124, + Message: "Second template", + }) + if err != nil { + t.Fatalf("SaveQuickReplyText second: %v", err) + } + if second.Kind != domain.QuickReplyMutationMessage || second.ShortcutID != shortcutID { + t.Fatalf("second mutation = %+v", second) + } + if available, err := svc.CheckQuickReplyShortcut(ctx, userID, "hello"); err != nil || available { + t.Fatalf("CheckQuickReplyShortcut duplicate available=%v err=%v", available, err) + } + list, err := svc.ListQuickReplies(ctx, userID) + if err != nil || len(list.QuickReplies) != 1 || list.QuickReplies[0].Count != 2 || list.Hash == 0 { + t.Fatalf("ListQuickReplies = %+v err=%v", list, err) + } + msgs, err := svc.GetQuickReplyMessages(ctx, userID, shortcutID, nil) + if err != nil || msgs.Count != 2 || len(msgs.Messages) != 2 || msgs.Hash == 0 { + t.Fatalf("GetQuickReplyMessages = %+v err=%v", msgs, err) + } + if _, err := svc.RenameQuickReplyShortcut(ctx, userID, shortcutID, "renamed"); err != nil { + t.Fatalf("RenameQuickReplyShortcut: %v", err) + } + if _, err := svc.ReorderQuickReplies(ctx, userID, []int{shortcutID}); err != nil { + t.Fatalf("ReorderQuickReplies: %v", err) + } + deleteMutation, err := svc.DeleteQuickReplyMessages(ctx, userID, shortcutID, []int{msgs.Messages[0].ID}) + if err != nil { + t.Fatalf("DeleteQuickReplyMessages: %v", err) + } + if deleteMutation.Kind != domain.QuickReplyMutationIDs || len(deleteMutation.MessageIDs) != 1 { + t.Fatalf("delete mutation = %+v", deleteMutation) + } + if _, err := svc.DeleteQuickReplyShortcut(ctx, userID, shortcutID); err != nil { + t.Fatalf("DeleteQuickReplyShortcut: %v", err) + } + if _, err := svc.GetQuickReplyMessages(ctx, userID, shortcutID, nil); !errors.Is(err, domain.ErrShortcutInvalid) { + t.Fatalf("GetQuickReplyMessages deleted err = %v, want ErrShortcutInvalid", err) + } +} diff --git a/internal/app/account/login_email_test.go b/internal/app/account/login_email_test.go new file mode 100644 index 00000000..b7f23b76 --- /dev/null +++ b/internal/app/account/login_email_test.go @@ -0,0 +1,116 @@ +package account + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func newLoginEmailService(t *testing.T) (*Service, *memory.UserStore) { + t.Helper() + users := memory.NewUserStore() + svc := NewService(memory.NewPasswordStore(), WithUsers(users)) + return svc, users +} + +func createUser(t *testing.T, users *memory.UserStore, phone string) domain.User { + t.Helper() + u, err := users.Create(context.Background(), domain.User{Phone: phone, FirstName: "Test"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + return u +} + +// TestSetLoginEmailPersistsAndMasks 设置登录邮箱后,GetPassword 下发掩码 pattern,原始 +// 地址只在 LoginEmail 读路径可见。 +func TestSetLoginEmailPersistsAndMasks(t *testing.T) { + ctx := context.Background() + svc, users := newLoginEmailService(t) + u := createUser(t, users, "15550010001") + + if err := svc.SetLoginEmail(ctx, u.ID, "alice@example.com"); err != nil { + t.Fatalf("SetLoginEmail: %v", err) + } + + settings, err := svc.GetPassword(ctx, u.ID) + if err != nil { + t.Fatalf("GetPassword: %v", err) + } + if got, want := settings.LoginEmailPattern, "a***e@example.com"; got != want { + t.Fatalf("LoginEmailPattern = %q, want %q", got, want) + } + if settings.LoginEmail != "alice@example.com" { + t.Fatalf("LoginEmail = %q, want raw address", settings.LoginEmail) + } + + email, found, err := svc.LoginEmail(ctx, u.ID) + if err != nil || !found || email != "alice@example.com" { + t.Fatalf("LoginEmail = %q found=%v err=%v", email, found, err) + } +} + +// TestLoginEmailByPhoneAndClear 验证按手机号读取/清除登录邮箱(sendCode 检测 + reset 用)。 +func TestLoginEmailByPhoneAndClear(t *testing.T) { + ctx := context.Background() + svc, users := newLoginEmailService(t) + createUser(t, users, "15550010002") + + if err := svc.SetLoginEmailByPhone(ctx, "+1 555 001 0002", "bob@mail.com"); err != nil { + t.Fatalf("SetLoginEmailByPhone: %v", err) + } + email, found, err := svc.LoginEmailByPhone(ctx, "15550010002") + if err != nil || !found || email != "bob@mail.com" { + t.Fatalf("LoginEmailByPhone = %q found=%v err=%v", email, found, err) + } + + if err := svc.ClearLoginEmailByPhone(ctx, "15550010002"); err != nil { + t.Fatalf("ClearLoginEmailByPhone: %v", err) + } + if _, found, _ := svc.LoginEmailByPhone(ctx, "15550010002"); found { + t.Fatal("login email still present after clear") + } +} + +// TestSetLoginEmailRejectsInvalid 空/无 @ 的邮箱被拒。 +func TestSetLoginEmailRejectsInvalid(t *testing.T) { + ctx := context.Background() + svc, users := newLoginEmailService(t) + u := createUser(t, users, "15550010003") + + for _, bad := range []string{"", " ", "not-an-email"} { + if err := svc.SetLoginEmail(ctx, u.ID, bad); !errors.Is(err, domain.ErrEmailInvalid) { + t.Fatalf("SetLoginEmail(%q) err = %v, want ErrEmailInvalid", bad, err) + } + } +} + +// TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern 是核心解耦回归:设置 2FA 恢复邮箱 +// 不得把恢复邮箱掩码写进 login_email_pattern(历史 bug)。 +func TestRecoveryEmailDoesNotLeakIntoLoginEmailPattern(t *testing.T) { + ctx := context.Background() + svc, users := newLoginEmailService(t) + u := createUser(t, users, "15550010004") + + // 设置 2FA 恢复邮箱(email-only 路径即可触发历史 bug 的写入点)。 + if err := svc.UpdatePasswordSettings(ctx, u.ID, domain.PasswordCheck{Empty: true}, domain.PasswordInputSettings{ + Email: "recovery@secret.com", + HasEmail: true, + }); err != nil { + t.Fatalf("UpdatePasswordSettings: %v", err) + } + + settings, err := svc.GetPassword(ctx, u.ID) + if err != nil { + t.Fatalf("GetPassword: %v", err) + } + if settings.LoginEmailPattern != "" { + t.Fatalf("LoginEmailPattern = %q, want empty (recovery email must not leak into login email)", settings.LoginEmailPattern) + } + if !settings.HasRecovery { + t.Fatal("HasRecovery = false, want true after setting recovery email") + } +} diff --git a/internal/app/account/service.go b/internal/app/account/service.go index 556aee14..f39afda1 100644 --- a/internal/app/account/service.go +++ b/internal/app/account/service.go @@ -4,7 +4,6 @@ import ( "context" "crypto/rand" "crypto/subtle" - "fmt" "strings" "time" @@ -19,22 +18,17 @@ const ( passwordResetRetry = 24 * time.Hour ) -// EmailUnconfirmedError reports the dev recovery-code length expected by TDesktop. -type EmailUnconfirmedError struct { - Length int -} - -func (e EmailUnconfirmedError) Error() string { - if e.Length <= 0 { - return "email unconfirmed" - } - return fmt.Sprintf("email unconfirmed: %d", e.Length) -} - // Service 提供账号安全配置查询。 type Service struct { - passwords store.PasswordStore - reactions store.AccountReactionSettingsStore + passwords store.PasswordStore + reactions store.AccountReactionSettingsStore + settings store.AccountSettingsStore + notify store.NotifySettingsStore + stickers store.StickerCollectionStore + savedMusic store.SavedMusicStore + business store.BusinessAutomationStore + // users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。 + users store.UserStore } // ServiceOption 调整 account 服务依赖。 @@ -47,6 +41,48 @@ func WithReactionSettings(reactions store.AccountReactionSettingsStore) ServiceO } } +// WithAccountSettings 注入账号级单例设置(全局隐私/TTL/敏感内容/注册通知)持久化。 +func WithAccountSettings(settings store.AccountSettingsStore) ServiceOption { + return func(s *Service) { + s.settings = settings + } +} + +// WithNotifySettings 注入 per-scope 通知设置持久化。 +func WithNotifySettings(notify store.NotifySettingsStore) ServiceOption { + return func(s *Service) { + s.notify = notify + } +} + +// WithStickerCollections 注入个人贴纸/GIF 集合持久化(faved/recent/gif)。 +func WithStickerCollections(stickers store.StickerCollectionStore) ServiceOption { + return func(s *Service) { + s.stickers = stickers + } +} + +// WithSavedMusic 注入账号级 profile music 列表持久化。 +func WithSavedMusic(savedMusic store.SavedMusicStore) ServiceOption { + return func(s *Service) { + s.savedMusic = savedMusic + } +} + +// WithBusinessAutomation 注入账号级 Business Profile/Quick Replies/Chat Links 持久化。 +func WithBusinessAutomation(business store.BusinessAutomationStore) ServiceOption { + return func(s *Service) { + s.business = business + } +} + +// WithUsers 注入用户读存储,供登录邮箱的 phone→user 解析使用。 +func WithUsers(users store.UserStore) ServiceOption { + return func(s *Service) { + s.users = users + } +} + // NewService 创建 account 服务。 func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { s := &Service{passwords: passwords} @@ -56,6 +92,42 @@ func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service { return s } +// SaveMusic adds, removes, or reorders a song in the current user's profile music list. +func (s *Service) SaveMusic(ctx context.Context, userID int64, req domain.SaveMusicRequest) (bool, error) { + if userID == 0 || req.Document.ID == 0 || !req.Document.IsMusic() { + return false, domain.ErrDocumentInvalid + } + if s == nil || s.savedMusic == nil { + return true, nil + } + req.UserID = userID + return true, s.savedMusic.SaveMusic(ctx, req) +} + +// ListSavedMusicIDs returns the full ordered id list for account.getSavedMusicIds. +func (s *Service) ListSavedMusicIDs(ctx context.Context, userID int64, limit int) ([]int64, error) { + if s == nil || s.savedMusic == nil || userID == 0 { + return nil, nil + } + return s.savedMusic.ListSavedMusicIDs(ctx, userID, limit) +} + +// ListSavedMusic returns an ordered saved/profile music page. +func (s *Service) ListSavedMusic(ctx context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) { + if s == nil || s.savedMusic == nil || userID == 0 { + return domain.SavedMusicList{UserID: userID}, nil + } + return s.savedMusic.ListSavedMusic(ctx, userID, offset, limit) +} + +// GetSavedMusicByIDs refreshes file references for songs still present in the user's list. +func (s *Service) GetSavedMusicByIDs(ctx context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) { + if s == nil || s.savedMusic == nil || userID == 0 || len(ids) == 0 { + return domain.SavedMusicList{UserID: userID}, nil + } + return s.savedMusic.GetSavedMusicByIDs(ctx, userID, ids) +} + // GetPassword 返回当前账号 2FA 配置。未登录或无记录时返回持久化策略的默认 no-password 配置。 func (s *Service) GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) { if s == nil || s.passwords == nil || userID == 0 { @@ -110,6 +182,9 @@ func normalizePasswordSettings(settings domain.PasswordSettings) domain.Password if settings.RecoveryEmail != "" { settings.HasRecovery = true } + // login_email_pattern 始终从已确认的登录邮箱派生,与 2FA 恢复邮箱 RecoveryEmail + // 解耦(历史实现曾把恢复邮箱掩码误写进此字段,导致客户端把恢复邮箱当成登录邮箱显示)。 + settings.LoginEmailPattern = emailPattern(settings.LoginEmail) return settings } @@ -197,7 +272,6 @@ func (s *Service) UpdatePasswordSettings(ctx context.Context, userID int64, chec } settings.RecoveryEmail = email settings.HasRecovery = email != "" - settings.LoginEmailPattern = emailPattern(email) settings.EmailUnconfirmedPattern = "" } settings.SecureRandom = randomBytesOrDefault(passwordHashSize, settings.SecureRandom) @@ -378,15 +452,98 @@ func randomInt64() (int64, error) { } func emailPattern(email string) string { - if email == "" { - return "" + return domain.MaskEmail(email) +} + +// validLoginEmail 是登录邮箱的最小校验:非空且含 '@'。开发环境不做更严格的 RFC 校验。 +func validLoginEmail(email string) bool { + return email != "" && strings.Contains(email, "@") +} + +// SetLoginEmail 为已登录用户写入登录邮箱(authed 的 emailVerifyPurposeLoginChange)。 +// 账号无 2FA 也可设置:account_passwords 行可在 has_password=false 下仅承载登录邮箱。 +func (s *Service) SetLoginEmail(ctx context.Context, userID int64, email string) error { + if s == nil || s.passwords == nil || userID == 0 { + return domain.ErrEmailInvalid } - at := strings.Index(email, "@") - if at <= 1 { - return email + email = strings.TrimSpace(email) + if !validLoginEmail(email) { + return domain.ErrEmailInvalid } - name := email[:at] - return name[:1] + "***" + name[len(name)-1:] + email[at:] + settings, err := s.GetPasswordWithoutRefresh(ctx, userID) + if err != nil { + return err + } + settings.LoginEmail = email + settings.LoginEmailPattern = emailPattern(email) + return s.passwords.Save(ctx, userID, settings) +} + +// SetLoginEmailByPhone 为某手机号对应的账号写入登录邮箱(登录流程中的 +// emailVerifyPurposeLoginSetup,此时尚未鉴权,只能凭 phone 定位用户)。 +func (s *Service) SetLoginEmailByPhone(ctx context.Context, phone, email string) error { + userID, found, err := s.userIDByPhone(ctx, phone) + if err != nil { + return err + } + if !found { + return domain.ErrEmailInvalid + } + return s.SetLoginEmail(ctx, userID, email) +} + +// LoginEmail 返回已登录用户的登录邮箱原始地址(用于 verifyEmail 回显 emailVerified.email)。 +func (s *Service) LoginEmail(ctx context.Context, userID int64) (string, bool, error) { + if s == nil || s.passwords == nil || userID == 0 { + return "", false, nil + } + settings, found, err := s.passwords.GetByUser(ctx, userID) + if err != nil { + return "", false, err + } + if !found || settings.LoginEmail == "" { + return "", false, nil + } + return settings.LoginEmail, true, nil +} + +// LoginEmailByPhone 按手机号返回登录邮箱原始地址(供 auth.sendCode 检测是否改投邮箱、 +// login-setup 回显、reset 回显使用)。 +func (s *Service) LoginEmailByPhone(ctx context.Context, phone string) (string, bool, error) { + userID, found, err := s.userIDByPhone(ctx, phone) + if err != nil || !found { + return "", false, err + } + return s.LoginEmail(ctx, userID) +} + +// ClearLoginEmailByPhone 清除某手机号账号的登录邮箱(auth.resetLoginEmail)。 +func (s *Service) ClearLoginEmailByPhone(ctx context.Context, phone string) error { + userID, found, err := s.userIDByPhone(ctx, phone) + if err != nil { + return err + } + if !found { + return nil + } + settings, found, err := s.passwords.GetByUser(ctx, userID) + if err != nil || !found { + return err + } + settings.LoginEmail = "" + settings.LoginEmailPattern = "" + return s.passwords.Save(ctx, userID, settings) +} + +func (s *Service) userIDByPhone(ctx context.Context, phone string) (int64, bool, error) { + if s == nil || s.users == nil { + return 0, false, nil + } + u, found, err := s.users.ByPhone(ctx, domain.NormalizePhone(phone)) + if err != nil || !found { + return 0, false, err + } + return u.ID, true, nil } // GetReactionSettings returns account-level reaction preferences. @@ -420,7 +577,7 @@ func (s *Service) SetDefaultReaction(ctx context.Context, userID int64, reaction if err != nil { return domain.AccountReactionSettings{}, err } - if reaction.Type == "" || reaction.Emoticon == "" { + if !reaction.Valid() { reaction = domain.DefaultAccountReactionSettings().DefaultReaction } settings.DefaultReaction = reaction @@ -445,10 +602,157 @@ func (s *Service) saveReactionSettings(ctx context.Context, userID int64, settin return settings, s.reactions.SaveReactionSettings(ctx, userID, settings) } +// GetAccountSettings 返回账号级单例设置(未持久化时回落默认)。 +func (s *Service) GetAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) { + if s == nil || s.settings == nil || userID == 0 { + return domain.DefaultAccountSettings(), nil + } + settings, found, err := s.settings.GetAccountSettings(ctx, userID) + if err != nil { + return domain.AccountSettings{}, err + } + if !found { + return domain.DefaultAccountSettings(), nil + } + settings.AccountTTLDays = settings.NormalizedTTLDays() + return settings, nil +} + +// SetGlobalPrivacy 持久化账号全局隐私开关,返回合并后的完整设置。 +func (s *Service) SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) { + settings, err := s.GetAccountSettings(ctx, userID) + if err != nil { + return domain.AccountSettings{}, err + } + if privacy.NoncontactPeersPaidStars < 0 { + privacy.NoncontactPeersPaidStars = 0 + } + settings.GlobalPrivacy = privacy + return s.saveAccountSettings(ctx, userID, settings) +} + +// SetAccountTTL 持久化账号自毁期限(钳制 >0)。 +func (s *Service) SetAccountTTL(ctx context.Context, userID int64, days int) (domain.AccountSettings, error) { + settings, err := s.GetAccountSettings(ctx, userID) + if err != nil { + return domain.AccountSettings{}, err + } + settings.AccountTTLDays = days + settings.AccountTTLDays = settings.NormalizedTTLDays() + return s.saveAccountSettings(ctx, userID, settings) +} + +// SetSensitiveContent 持久化敏感内容查看开关。 +func (s *Service) SetSensitiveContent(ctx context.Context, userID int64, enabled bool) (domain.AccountSettings, error) { + settings, err := s.GetAccountSettings(ctx, userID) + if err != nil { + return domain.AccountSettings{}, err + } + settings.SensitiveContentEnabled = enabled + return s.saveAccountSettings(ctx, userID, settings) +} + +// SetContactSignUpSilent 持久化“联系人注册时是否静音通知”。 +func (s *Service) SetContactSignUpSilent(ctx context.Context, userID int64, silent bool) (domain.AccountSettings, error) { + settings, err := s.GetAccountSettings(ctx, userID) + if err != nil { + return domain.AccountSettings{}, err + } + settings.ContactSignUpSilent = silent + return s.saveAccountSettings(ctx, userID, settings) +} + +func (s *Service) saveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) (domain.AccountSettings, error) { + settings.AccountTTLDays = settings.NormalizedTTLDays() + if settings.GlobalPrivacy.NoncontactPeersPaidStars < 0 { + settings.GlobalPrivacy.NoncontactPeersPaidStars = 0 + } + if s == nil || s.settings == nil || userID == 0 { + return settings, nil + } + return settings, s.settings.SaveAccountSettings(ctx, userID, settings) +} + +// GetNotifySettings 返回某作用域的通知设置(未配置返回零值=继承默认)。 +func (s *Service) GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, error) { + if s == nil || s.notify == nil || ownerUserID == 0 { + return domain.PeerNotifySettings{}, nil + } + settings, _, err := s.notify.GetNotifySettings(ctx, ownerUserID, scope) + if err != nil { + return domain.PeerNotifySettings{}, err + } + return settings, nil +} + +// SaveNotifySettings 持久化某作用域的通知设置。 +func (s *Service) SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error { + if s == nil || s.notify == nil || ownerUserID == 0 { + return nil + } + return s.notify.SaveNotifySettings(ctx, ownerUserID, scope, settings) +} + +// ResetNotifySettings 清空该用户全部作用域的通知设置(恢复默认)。 +func (s *Service) ResetNotifySettings(ctx context.Context, ownerUserID int64) error { + if s == nil || s.notify == nil || ownerUserID == 0 { + return nil + } + return s.notify.ResetNotifySettings(ctx, ownerUserID) +} + +// PeerNotifySettings 批量取一组 peer 的整-peer 通知设置(dialog 列表投影)。 +func (s *Service) PeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) { + if s == nil || s.notify == nil || ownerUserID == 0 || len(peers) == 0 { + return nil, nil + } + return s.notify.GetPeerNotifySettings(ctx, ownerUserID, peers) +} + +// AllPeerNotifySettings 一次取该用户全部整-peer 通知设置(per-user notify 缓存的加载源)。 +func (s *Service) AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) { + if s == nil || s.notify == nil || ownerUserID == 0 { + return nil, nil + } + return s.notify.AllPeerNotifySettings(ctx, ownerUserID) +} + +// ListNotifyExceptions 列出该用户全部 per-peer 非默认通知设置(getNotifyExceptions)。 +func (s *Service) ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error) { + if s == nil || s.notify == nil || ownerUserID == 0 { + return nil, nil + } + return s.notify.ListNotifyExceptions(ctx, ownerUserID) +} + +// SaveStickerCollectionItem 收藏/最近/GIF 集合的加入或移除(最新置顶、按类别上界截断)。 +func (s *Service) SaveStickerCollectionItem(ctx context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now int) error { + if s == nil || s.stickers == nil || userID == 0 { + return nil + } + return s.stickers.SaveStickerCollectionItem(ctx, userID, kind, documentID, unsave, now, domain.MaxStickerCollectionItems(kind)) +} + +// ListStickerCollection 取某类个人贴纸集合(最新在前)。 +func (s *Service) ListStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) { + if s == nil || s.stickers == nil || userID == 0 { + return nil, nil + } + return s.stickers.ListStickerCollection(ctx, userID, kind, limit) +} + +// ClearStickerCollection 清空某类个人贴纸集合。 +func (s *Service) ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error { + if s == nil || s.stickers == nil || userID == 0 { + return nil + } + return s.stickers.ClearStickerCollection(ctx, userID, kind) +} + func normalizeReactionSettings(settings domain.AccountReactionSettings) domain.AccountReactionSettings { defaults := domain.DefaultAccountReactionSettings() settings.Notify = normalizeNotifySettings(settings.Notify) - if settings.DefaultReaction.Type == "" || settings.DefaultReaction.Emoticon == "" { + if !settings.DefaultReaction.Valid() { settings.DefaultReaction = defaults.DefaultReaction } settings.PaidPrivacy = normalizePaidPrivacy(settings.PaidPrivacy) diff --git a/internal/app/account/service_test.go b/internal/app/account/service_test.go index 5fe9de12..0d5b2831 100644 --- a/internal/app/account/service_test.go +++ b/internal/app/account/service_test.go @@ -3,11 +3,14 @@ package account import ( "bytes" "context" + "crypto/sha512" "errors" "math/big" "testing" "time" + "golang.org/x/crypto/pbkdf2" + "telesrv/internal/domain" "telesrv/internal/store/memory" ) @@ -183,3 +186,19 @@ func clientPasswordCheck(t *testing.T, settings domain.PasswordSettings, passwor ) return domain.PasswordCheck{SRPID: settings.SRPID, A: aForHash, M1: m1} } + +// passwordDigest 与 verifierForPassword 是客户端侧(明文口令 → verifier)的模拟助手, +// 服务端从不执行明文口令路径,仅供这里的客户端 SRP helper 构造测试输入。 +func passwordDigest(algo domain.PasswordKDFAlgo, password []byte) []byte { + hash1 := hashBytes(algo.Salt1, password, algo.Salt1) + hash2 := hashBytes(algo.Salt2, hash1, algo.Salt2) + hash3 := pbkdf2.Key(hash2, algo.Salt1, 100000, 64, sha512.New) + return hashBytes(algo.Salt2, hash3, algo.Salt2) +} + +func verifierForPassword(algo domain.PasswordKDFAlgo, password []byte) []byte { + p := new(big.Int).SetBytes(algo.P) + g := big.NewInt(int64(algo.G)) + x := new(big.Int).SetBytes(passwordDigest(algo, password)) + return padToHash(new(big.Int).Exp(g, x, p).Bytes()) +} diff --git a/internal/app/account/srp.go b/internal/app/account/srp.go index 7999ebad..accd809b 100644 --- a/internal/app/account/srp.go +++ b/internal/app/account/srp.go @@ -4,12 +4,9 @@ import ( "bytes" "crypto/rand" "crypto/sha256" - "crypto/sha512" "encoding/hex" "math/big" - "golang.org/x/crypto/pbkdf2" - "telesrv/internal/domain" ) @@ -160,20 +157,6 @@ func hashBytes(parts ...[]byte) []byte { return h.Sum(nil) } -func passwordDigest(algo domain.PasswordKDFAlgo, password []byte) []byte { - hash1 := hashBytes(algo.Salt1, password, algo.Salt1) - hash2 := hashBytes(algo.Salt2, hash1, algo.Salt2) - hash3 := pbkdf2.Key(hash2, algo.Salt1, 100000, 64, sha512.New) - return hashBytes(algo.Salt2, hash3, algo.Salt2) -} - -func verifierForPassword(algo domain.PasswordKDFAlgo, password []byte) []byte { - p := new(big.Int).SetBytes(algo.P) - g := big.NewInt(int64(algo.G)) - x := new(big.Int).SetBytes(passwordDigest(algo, password)) - return padToHash(new(big.Int).Exp(g, x, p).Bytes()) -} - func padToHash(in []byte) []byte { if len(in) >= passwordHashSize { return append([]byte(nil), in[len(in)-passwordHashSize:]...) diff --git a/internal/app/auth/login_email_test.go b/internal/app/auth/login_email_test.go new file mode 100644 index 00000000..dccd5113 --- /dev/null +++ b/internal/app/auth/login_email_test.go @@ -0,0 +1,102 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestSignInWithEmailCompletesLogin 验证带 email_verification 的登录:注册账号→登出→ +// 重新 sendCode→用任意邮箱验证码经 SignInWithEmail 完成登录。 +func TestSignInWithEmailCompletesLogin(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + authz := memory.NewAuthorizationStore() + svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345") + var key [8]byte + key[0] = 0x42 + + hash, err := svc.SendCode(ctx, "+15550009001") + if err != nil { + t.Fatalf("SendCode signup: %v", err) + } + u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009001", hash, "Email", "Login") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + if err := svc.LogOut(ctx, key); err != nil { + t.Fatalf("LogOut: %v", err) + } + + hash, err = svc.SendCode(ctx, "+15550009001") + if err != nil { + t.Fatalf("SendCode signin: %v", err) + } + got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009001", hash, "anything-goes") + if err != nil { + t.Fatalf("SignInWithEmail: %v", err) + } + if needSignUp || got.ID != u.ID { + t.Fatalf("SignInWithEmail user=%+v needSignUp=%v, want existing user %d", got, needSignUp, u.ID) + } + bound, found, err := svc.UserID(ctx, key) + if err != nil || !found || bound != u.ID { + t.Fatalf("UserID after email signin = %d found=%v err=%v, want %d", bound, found, err, u.ID) + } +} + +// TestSignInWithEmailRejectsEmptyCode 空邮箱验证码必须被拒(即使开发环境码任意,也不能空)。 +func TestSignInWithEmailRejectsEmptyCode(t *testing.T) { + ctx := context.Background() + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345") + hash, err := svc.SendCode(ctx, "+15550009002") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009002", hash, " "); !errors.Is(err, ErrCodeInvalid) { + t.Fatalf("SignInWithEmail empty code err = %v, want ErrCodeInvalid", err) + } +} + +// TestSignInWithEmailStillHonorsTwoFactor 登录邮箱与 2FA 正交:即使走邮箱验证码, +// 开启了两步验证的账号仍停在 SESSION_PASSWORD_NEEDED,不能绕过密码。 +func TestSignInWithEmailStillHonorsTwoFactor(t *testing.T) { + ctx := context.Background() + passwords := memory.NewPasswordStore() + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPasswords(passwords)) + var key [8]byte + key[0] = 0x43 + + hash, err := svc.SendCode(ctx, "+15550009003") + if err != nil { + t.Fatalf("SendCode signup: %v", err) + } + u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009003", hash, "Two", "Factor") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + if err := svc.LogOut(ctx, key); err != nil { + t.Fatalf("LogOut: %v", err) + } + if err := passwords.Save(ctx, u.ID, domain.PasswordSettings{HasPassword: true}); err != nil { + t.Fatalf("save password settings: %v", err) + } + + hash, err = svc.SendCode(ctx, "+15550009003") + if err != nil { + t.Fatalf("SendCode signin: %v", err) + } + got, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009003", hash, "any-email-code") + if !errors.Is(err, domain.ErrSessionPasswordNeeded) { + t.Fatalf("SignInWithEmail err = %v, want ErrSessionPasswordNeeded", err) + } + if got.ID != u.ID { + t.Fatalf("SignInWithEmail user = %+v, want pending 2FA user %d", got, u.ID) + } + if bound, found, err := svc.UserID(ctx, key); err != nil || found || bound != 0 { + t.Fatalf("UserID after email signin with 2FA = %d found=%v err=%v, want not-found", bound, found, err) + } +} diff --git a/internal/app/auth/premium_grant_test.go b/internal/app/auth/premium_grant_test.go new file mode 100644 index 00000000..7ddfb635 --- /dev/null +++ b/internal/app/auth/premium_grant_test.go @@ -0,0 +1,51 @@ +package auth + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestSignUpPremiumGrant 验证新注册账号默认赠送 3 个月会员(WithPremiumGrant), +// 以及 0 = 关闭赠送分支。 +func TestSignUpPremiumGrant(t *testing.T) { + ctx := context.Background() + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPremiumGrant(3)) + + hash, err := svc.SendCode(ctx, "+15550004401") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004401", hash, "Prem", "User") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + wantMin := time.Now().AddDate(0, 3, 0).Add(-time.Minute).Unix() + wantMax := time.Now().AddDate(0, 3, 0).Add(time.Minute).Unix() + if int64(u.PremiumUntil) < wantMin || int64(u.PremiumUntil) > wantMax { + t.Fatalf("PremiumUntil = %d, want ~now+3mo [%d,%d]", u.PremiumUntil, wantMin, wantMax) + } + if !u.PremiumActiveAt(time.Now().Unix()) { + t.Fatal("new user should be premium active") + } +} + +func TestSignUpPremiumGrantDisabled(t *testing.T) { + ctx := context.Background() + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPremiumGrant(0)) + + hash, err := svc.SendCode(ctx, "+15550004402") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004402", hash, "Free", "User") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + if u.PremiumUntil != 0 { + t.Fatalf("PremiumUntil = %d, want 0 (grant disabled)", u.PremiumUntil) + } +} diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index cf321a77..643df5af 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -4,6 +4,7 @@ import ( "context" "crypto/aes" "crypto/rand" + "crypto/subtle" "encoding/binary" "encoding/hex" "errors" @@ -25,8 +26,39 @@ var ( ErrCodeExpired = errors.New("phone code expired or not found") ErrCodeInvalid = errors.New("phone code invalid") ErrEncryptedMessageInvalid = errors.New("encrypted message invalid") + // ErrPhoneNumberInvalid 表示手机号为空或非纯数字/长度越界。 + // 0090 把 users.phone 唯一约束改为忽略空串的部分索引(bot 行 phone=''), + // 因此 phone 校验必须前移到 auth 入口,否则 sendCode/signUp 可无限铸造 + // phone='' 的幽灵人类账号(且因 ByPhone('') 短路永远无法再登录)。 + ErrPhoneNumberInvalid = errors.New("phone number invalid") + // ErrSystemUserLoginForbidden 表示内置系统账号被尝试绑定为普通业务会话。 + ErrSystemUserLoginForbidden = errors.New("system user login forbidden") ) +// validPhone 校验规范化后的手机号:5-32 位纯数字(上限对齐 users.phone 列宽)。 +// 核心目的是拒绝空/非数字 phone(防 0090 partial index 下无限铸造幽灵账号), +// 长度上限从宽,不强求 E.164 精确位数(测试常用更长的唯一 phone)。 +func validPhone(phone string) bool { + if len(phone) < 5 || len(phone) > 32 { + return false + } + for _, r := range phone { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func systemUserLoginForbidden(u domain.User) bool { + return domain.IsSystemUserID(u.ID) +} + +func systemLoginPhoneForbidden(phone string) bool { + _, ok := domain.SystemUserByPhone(phone) + return ok +} + // Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。 type Service struct { users store.UserStore @@ -37,8 +69,16 @@ type Service struct { passwords store.PasswordStore messages store.MessageStore dialogs store.DialogStore + bots store.BotStore fixedCode string codeTTL time.Duration + // premiumGrantMonths 是新注册账号默认赠送的会员月数;0 表示关闭赠送。 + premiumGrantMonths int +} + +type authorizationRevoker interface { + RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) + RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) } // Option 调整登录服务的可选依赖。 @@ -59,6 +99,21 @@ func WithPasswords(passwords store.PasswordStore) Option { } } +// WithBotLogin 启用 auth.importBotAuthorization 的 bot token 登录。 +func WithBotLogin(bots store.BotStore) Option { + return func(s *Service) { + s.bots = bots + } +} + +// WithPremiumGrant 让新注册账号默认获得 months 个月会员(0 = 关闭赠送)。 +// 存量账号的同等赠送由迁移 0094 一次性 backfill。 +func WithPremiumGrant(months int) Option { + return func(s *Service) { + s.premiumGrantMonths = months + } +} + // NewService 创建登录服务。fixedCode 为开发固定验证码。 func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service { s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute} @@ -84,6 +139,13 @@ func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding } // ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。 +// +// 过期处理是有意的连续性权衡(见 TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey): +// temp 绑定 expires_at 已过时,仅当 perm key 也未授权才拒绝;perm 仍授权则继续解析, +// 避免已登录会话因 temp key 过期而被强制踢下线。严格 PFS 要求过期 temp key 一律失效 +// (不以 perm 授权豁免),但收紧前需先核实目标客户端(TDesktop/DrKLO)会在过期前主动 +// 轮换 temp key 并优雅处理拒绝,否则会造成在线会话掉线。RetentionWorker 的 DeleteExpired +// 已把残留窗口限制在 expires_at + 宽限(约 24h)内。收紧为显式硬化任务,需客户端验证。 func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) { if s == nil || s.tempKeys == nil { return [8]byte{}, false, nil @@ -92,13 +154,22 @@ func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byt if err != nil || !found { return [8]byte{}, found, err } - if binding.ExpiresAt <= int(time.Now().Unix()) { + permID := authKeyIDFromInt64(binding.PermAuthKeyID) + if binding.ExpiresAt <= int(time.Now().Unix()) && !s.permAuthKeyAuthorized(ctx, permID) { return [8]byte{}, false, nil } - return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil + return permID, true, nil } -// UserID 返回 auth_key 当前绑定的用户。未登录时 found=false。 +func (s *Service) permAuthKeyAuthorized(ctx context.Context, authKeyID [8]byte) bool { + if s == nil || s.auths == nil { + return false + } + _, found, err := s.auths.ByAuthKey(ctx, authKeyID) + return err == nil && found +} + +// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。 func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) { if s == nil || s.auths == nil { return 0, false, nil @@ -107,16 +178,56 @@ func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, e if err != nil || !found { return 0, found, err } + if a.PasswordPending { + // 两步验证未完成:业务鉴权视为未登录,仅允许 auth.checkPassword 继续。 + return 0, false, nil + } + if domain.IsSystemUserID(a.UserID) { + _ = s.auths.Delete(ctx, authKeyID) + return 0, false, nil + } return a.UserID, true, nil } +// PendingPasswordUserID 返回处于"待两步验证"状态的 auth_key 对应的用户。 +// UserID 对 password_pending 的 auth_key 返回未登录,auth.checkPassword 借此仍能定位待验证用户。 +func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) { + if s == nil || s.auths == nil { + return 0, false, nil + } + a, found, err := s.auths.ByAuthKey(ctx, authKeyID) + if err != nil || !found || !a.PasswordPending { + return 0, false, err + } + if domain.IsSystemUserID(a.UserID) { + _ = s.auths.Delete(ctx, authKeyID) + return 0, false, nil + } + return a.UserID, true, nil +} + +// CompletePasswordSignIn 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。 +func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) error { + if s == nil || s.auths == nil { + return nil + } + return s.auths.MarkPasswordPassed(ctx, authKeyID) +} + // SendCode 为 phone 生成 phone_code_hash,暂存(开发)固定验证码,返回 hash。 func (s *Service) SendCode(ctx context.Context, phone string) (string, error) { + phone = normalizePhone(phone) + if !validPhone(phone) { + return "", ErrPhoneNumberInvalid + } + if systemLoginPhoneForbidden(phone) { + return "", ErrSystemUserLoginForbidden + } hash, err := randomHex(8) if err != nil { return "", err } - if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: normalizePhone(phone), Code: s.fixedCode}, s.codeTTL); err != nil { + if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: phone, Code: s.fixedCode}, s.codeTTL); err != nil { return "", fmt.Errorf("store code: %w", err) } return hash, nil @@ -159,6 +270,9 @@ func (s *Service) CancelCode(ctx context.Context, phone, phoneCodeHash string) e // needSignUp=true 表示验证码正确但用户不存在,调用方应引导注册(此时不删验证码,留给 SignUp)。 func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (u domain.User, loginMessage domain.Message, needSignUp bool, err error) { phone = normalizePhone(phone) + if systemLoginPhoneForbidden(phone) { + return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden + } rec, found, err := s.codes.Get(ctx, phoneCodeHash) if err != nil { return domain.User{}, domain.Message{}, false, err @@ -177,14 +291,61 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone, if !found { return domain.User{}, domain.Message{}, true, nil // 验证码对、但需注册 } + return s.finishSignIn(ctx, auth, existing, phoneCodeHash, rec.Code) +} + +// SignInWithEmail 处理带 email_verification 的 auth.signIn:账号设置了登录邮箱后,新设备 +// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开发环境接受任意非空邮箱码 +// (与短信固定码同口径,"随意输入");仍校验 phone_code_hash 有效、手机号匹配,并与短信 +// 登录共用 2FA 门控——即便走邮箱验证,开启了两步验证的账号同样会停在 SESSION_PASSWORD_NEEDED。 +func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) { + phone = normalizePhone(phone) + if systemLoginPhoneForbidden(phone) { + return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden + } + rec, found, err := s.codes.Get(ctx, phoneCodeHash) + if err != nil { + return domain.User{}, domain.Message{}, false, err + } + if !found { + return domain.User{}, domain.Message{}, false, ErrCodeExpired + } + if rec.Phone != phone { + return domain.User{}, domain.Message{}, false, ErrCodeInvalid + } + if strings.TrimSpace(code) == "" { + return domain.User{}, domain.Message{}, false, ErrCodeInvalid + } + existing, found, err := s.users.ByPhone(ctx, phone) + if err != nil { + return domain.User{}, domain.Message{}, false, err + } + if !found { + return domain.User{}, domain.Message{}, true, nil + } + return s.finishSignIn(ctx, auth, existing, phoneCodeHash, rec.Code) +} + +// finishSignIn 是短信/邮箱两条登录路径在「验证码已通过、用户已存在」之后的共用收尾: +// 处理 2FA password_pending 绑定、写登录消息、消费验证码。 +func (s *Service) finishSignIn(ctx context.Context, auth domain.Authorization, existing domain.User, phoneCodeHash, loginCode string) (domain.User, domain.Message, bool, error) { + if systemUserLoginForbidden(existing) { + _ = s.codes.Del(ctx, phoneCodeHash) + return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden + } + // 开启两步验证的账号:把授权标记为 password_pending 再写入,业务鉴权据此拒绝该 auth_key, + // 直到 auth.checkPassword 通过。绝不能先以完全授权写入再返回 SESSION_PASSWORD_NEEDED, + // 否则客户端忽略该错误即可直接调用业务 RPC 绕过两步验证。 + passwordNeeded := s.passwordNeeded(ctx, existing.ID) + auth.PasswordPending = passwordNeeded if err := s.bind(ctx, auth, existing.ID); err != nil { return domain.User{}, domain.Message{}, false, err } - if s.passwordNeeded(ctx, existing.ID) { + if passwordNeeded { _ = s.codes.Del(ctx, phoneCodeHash) return existing, domain.Message{}, false, domain.ErrSessionPasswordNeeded } - loginMessage, err = s.recordLoginMessage(ctx, existing.ID, rec.Code) + loginMessage, err := s.recordLoginMessage(ctx, existing.ID, loginCode) if err != nil { return domain.User{}, domain.Message{}, false, err } @@ -196,6 +357,12 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone, // signUp 的 TL 请求不带验证码,这里校验 phone_code_hash 仍有效且手机号匹配。 func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) { phone = normalizePhone(phone) + if !validPhone(phone) { + return domain.User{}, domain.Message{}, ErrPhoneNumberInvalid + } + if systemLoginPhoneForbidden(phone) { + return domain.User{}, domain.Message{}, ErrSystemUserLoginForbidden + } firstName = strings.TrimSpace(firstName) lastName = strings.TrimSpace(lastName) if firstName == "" || utf8.RuneCountInString(firstName) > 64 || utf8.RuneCountInString(lastName) > 64 { @@ -216,12 +383,18 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, if err != nil { return domain.User{}, domain.Message{}, err } - u, err := s.users.Create(ctx, domain.User{ + newUser := domain.User{ AccessHash: accessHash, Phone: phone, FirstName: firstName, LastName: lastName, - }) + } + // 新账号默认赠送会员:到期时间 = 注册时刻 + N 个月(与迁移 0094 对存量 + // 账号的 backfill 同一语义)。premium 状态由下发路径按该时间即时派生。 + if s.premiumGrantMonths > 0 { + newUser.PremiumUntil = int(time.Now().AddDate(0, s.premiumGrantMonths, 0).Unix()) + } + u, err := s.users.Create(ctx, newUser) if err != nil { return domain.User{}, domain.Message{}, err } @@ -236,11 +409,115 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, return u, loginMessage, nil } +// SignInBot 处理 auth.importBotAuthorization:校验 bot token 并把当前 auth_key +// 绑定到 bot 账号。token 校验必须先于 bind(bind 即授权生效);bot 无 2FA, +// PasswordPending 恒 false;不写登录消息、不推 signIn 通知(手机登录语义)。 +// 任何校验失败统一返回 domain.ErrBotTokenInvalid,不区分原因避免泄漏存在性。 +func (s *Service) SignInBot(ctx context.Context, auth domain.Authorization, token string) (domain.User, error) { + if s == nil || s.bots == nil || s.users == nil { + return domain.User{}, domain.ErrBotTokenInvalid + } + botUserID, secret, ok := domain.ParseBotToken(strings.TrimSpace(token)) + if !ok { + return domain.User{}, domain.ErrBotTokenInvalid + } + profile, found, err := s.bots.GetBot(ctx, botUserID) + if err != nil { + return domain.User{}, err + } + // 空 secret(内置 BotFather)永不可登录;比较走常数时间。 + if !found || profile.TokenSecret == "" || + subtle.ConstantTimeCompare([]byte(profile.TokenSecret), []byte(secret)) != 1 { + return domain.User{}, domain.ErrBotTokenInvalid + } + u, found, err := s.users.ByID(ctx, botUserID) + if err != nil { + return domain.User{}, err + } + if !found || !u.Bot { + return domain.User{}, domain.ErrBotTokenInvalid + } + if systemUserLoginForbidden(u) { + return domain.User{}, domain.ErrBotTokenInvalid + } + auth.PasswordPending = false + if err := s.bind(ctx, auth, u.ID); err != nil { + return domain.User{}, err + } + // check-bind-recheck:SignInBot 的「校验 secret → bind」非原子,并发 /revoke + // 可能在两步之间换 secret 并删除已有 authorization。此处 bind 写入的新行不会被 + // 那次删除覆盖(删除发生在 bind 之前),会逃过 session 撤销。bind 后复核 secret: + // 若已被换掉,撤销刚写入的授权并拒登,闭合竞态窗口。 + if again, found, err := s.bots.GetBot(ctx, botUserID); err != nil { + _ = s.auths.Delete(ctx, auth.AuthKeyID) + return domain.User{}, err + } else if !found || again.TokenSecret == "" || + subtle.ConstantTimeCompare([]byte(again.TokenSecret), []byte(secret)) != 1 { + _ = s.auths.Delete(ctx, auth.AuthKeyID) + return domain.User{}, domain.ErrBotTokenInvalid + } + return u, nil +} + +// BindVerifiedLogin 把当前 auth_key 绑定到一个已由外部强因子(如 passkey)验证过身份的 +// 用户,直接完成授权。passkey 是独立强因子,不再叠加 2FA password(PasswordPending=false), +// 与官方"passkey 登录跳过密码步骤"一致。校验已发生在调用方(passkey 断言验证),此处只负责绑定。 +func (s *Service) BindVerifiedLogin(ctx context.Context, auth domain.Authorization, userID int64) (domain.User, error) { + if s == nil || s.users == nil || userID == 0 { + return domain.User{}, domain.ErrPasskeyInvalid + } + u, found, err := s.users.ByID(ctx, userID) + if err != nil { + return domain.User{}, err + } + if !found { + return domain.User{}, domain.ErrPasskeyNotFound + } + if systemUserLoginForbidden(u) { + return domain.User{}, ErrSystemUserLoginForbidden + } + auth.PasswordPending = false + if err := s.bind(ctx, auth, userID); err != nil { + return domain.User{}, err + } + return u, nil +} + +// AcceptLoginToken 把 QR 登录请求方的 auth_key 绑定到扫码确认的 user。 +func (s *Service) AcceptLoginToken(ctx context.Context, auth domain.Authorization, userID int64) (domain.Authorization, error) { + if s == nil || s.auths == nil || userID == 0 || auth.AuthKeyID == ([8]byte{}) { + return domain.Authorization{}, fmt.Errorf("accept login token: invalid authorization") + } + if domain.IsSystemUserID(userID) { + return domain.Authorization{}, ErrSystemUserLoginForbidden + } + auth.PasswordPending = false + if err := s.bind(ctx, auth, userID); err != nil { + return domain.Authorization{}, err + } + bound, found, err := s.auths.ByAuthKey(ctx, auth.AuthKeyID) + if err != nil { + return domain.Authorization{}, err + } + if found { + return bound, nil + } + auth.UserID = userID + return auth, nil +} + // LogOut 解绑当前 auth_key 的授权。 func (s *Service) LogOut(ctx context.Context, authKeyID [8]byte) error { return s.auths.Delete(ctx, authKeyID) } +func (s *Service) Authorization(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error) { + if s == nil || s.auths == nil || authKeyID == ([8]byte{}) { + return domain.Authorization{}, false, nil + } + return s.auths.ByAuthKey(ctx, authKeyID) +} + func (s *Service) ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) { if s == nil || s.auths == nil || userID == 0 { return nil, nil @@ -252,14 +529,78 @@ func (s *Service) ResetAuthorization(ctx context.Context, userID, hash int64) (d if s == nil || s.auths == nil || userID == 0 { return domain.Authorization{}, false, nil } - return s.auths.DeleteByHash(ctx, userID, hash) + if revoker, ok := s.auths.(authorizationRevoker); ok { + return revoker.RevokeByHash(ctx, userID, hash) + } + target, found, err := s.authorizationByHash(ctx, userID, hash) + if err != nil || !found { + return target, found, err + } + if err := s.deleteAuthKey(ctx, target.AuthKeyID); err != nil { + return target, true, err + } + deleted, found, err := s.auths.DeleteByHash(ctx, userID, hash) + if err != nil || !found { + return deleted, found, err + } + return deleted, true, nil } func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) { if s == nil || s.auths == nil || userID == 0 { return nil, nil } - return s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID) + if revoker, ok := s.auths.(authorizationRevoker); ok { + return revoker.RevokeByUserExcept(ctx, userID, keepAuthKeyID) + } + targets, err := s.authorizationsByUserExcept(ctx, userID, keepAuthKeyID) + if err != nil { + return nil, err + } + for _, a := range targets { + if err := s.deleteAuthKey(ctx, a.AuthKeyID); err != nil { + return nil, err + } + } + deleted, err := s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID) + if err != nil { + return nil, err + } + return deleted, nil +} + +func (s *Service) deleteAuthKey(ctx context.Context, authKeyID [8]byte) error { + if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) { + return nil + } + return s.authKeys.Delete(ctx, authKeyID) +} + +func (s *Service) authorizationByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) { + items, err := s.auths.ListByUser(ctx, userID) + if err != nil { + return domain.Authorization{}, false, err + } + for _, a := range items { + if a.Hash == hash { + return a, true, nil + } + } + return domain.Authorization{}, false, nil +} + +func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) { + items, err := s.auths.ListByUser(ctx, userID) + if err != nil { + return nil, err + } + out := make([]domain.Authorization, 0, len(items)) + for _, a := range items { + if a.AuthKeyID != keepAuthKeyID { + out = append(out, a) + } + } + return out, nil } func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error { @@ -301,11 +642,10 @@ func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code str if err != nil { return domain.Message{}, err } - if err := s.dialogs.Upsert(ctx, userID, domain.Dialog{ + if err := s.dialogs.UpsertInbox(ctx, userID, domain.Dialog{ Peer: msg.Peer, TopMessage: msg.ID, TopMessageDate: msg.Date, - UnreadCount: 1, }); err != nil { return domain.Message{}, err } @@ -406,17 +746,7 @@ func authKeyIDInt64(id [8]byte) int64 { } func normalizePhone(phone string) string { - var b strings.Builder - b.Grow(len(phone)) - for _, r := range phone { - if r >= '0' && r <= '9' { - b.WriteRune(r) - } - } - if b.Len() == 0 { - return phone - } - return b.String() + return domain.NormalizePhone(phone) } func randomHex(n int) (string, error) { diff --git a/internal/app/auth/service_test.go b/internal/app/auth/service_test.go index b84c1f28..c37bdff4 100644 --- a/internal/app/auth/service_test.go +++ b/internal/app/auth/service_test.go @@ -95,6 +95,58 @@ func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) { } } +func TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey(t *testing.T) { + ctx := context.Background() + tempBindings := memory.NewTempAuthKeyBindingStore() + authz := memory.NewAuthorizationStore() + permKey := testAuthKey(0x21) + tempKey := testAuthKey(0x65) + svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, tempBindings, "12345") + + if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: permKey.ID, UserID: 1000000001}); err != nil { + t.Fatalf("bind authorization: %v", err) + } + if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{ + TempAuthKeyID: tempKey.ID, + PermAuthKeyID: permKey.IntID(), + ExpiresAt: int(time.Now().Add(-time.Minute).Unix()), + }); err != nil { + t.Fatalf("save temp binding: %v", err) + } + + got, ok, err := svc.ResolveAuthKey(ctx, tempKey.ID) + if err != nil { + t.Fatalf("ResolveAuthKey: %v", err) + } + if !ok || got != permKey.ID { + t.Fatalf("resolved = %x ok=%v, want authorized perm %x", got, ok, permKey.ID) + } +} + +func TestResolveAuthKeyRejectsExpiredTempBindingWithoutAuthorizedPermKey(t *testing.T) { + ctx := context.Background() + tempBindings := memory.NewTempAuthKeyBindingStore() + permKey := testAuthKey(0x31) + tempKey := testAuthKey(0x75) + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, tempBindings, "12345") + + if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{ + TempAuthKeyID: tempKey.ID, + PermAuthKeyID: permKey.IntID(), + ExpiresAt: int(time.Now().Add(-time.Minute).Unix()), + }); err != nil { + t.Fatalf("save temp binding: %v", err) + } + + got, ok, err := svc.ResolveAuthKey(ctx, tempKey.ID) + if err != nil { + t.Fatalf("ResolveAuthKey: %v", err) + } + if ok || got != ([8]byte{}) { + t.Fatalf("resolved = %x ok=%v, want expired unresolved", got, ok) + } +} + func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) { ctx := context.Background() users := memory.NewUserStore() @@ -126,6 +178,73 @@ func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) { } } +func TestSystemUserPhoneCannotLoginOrSignUp(t *testing.T) { + ctx := context.Background() + codes := memory.NewCodeStore() + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345") + phone := domain.OfficialSystemUser().Phone + + if _, err := svc.SendCode(ctx, phone); !errors.Is(err, ErrSystemUserLoginForbidden) { + t.Fatalf("SendCode official system phone err = %v, want ErrSystemUserLoginForbidden", err) + } + + if err := codes.Set(ctx, "system-signin", store.PhoneCode{Phone: phone, Code: "12345"}, time.Minute); err != nil { + t.Fatalf("seed sign-in code: %v", err) + } + if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, phone, "system-signin", "12345"); !errors.Is(err, ErrSystemUserLoginForbidden) { + t.Fatalf("SignIn official system phone err = %v, want ErrSystemUserLoginForbidden", err) + } + + if err := codes.Set(ctx, "system-email", store.PhoneCode{Phone: phone, Code: "12345"}, time.Minute); err != nil { + t.Fatalf("seed email code: %v", err) + } + if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, phone, "system-email", "anything"); !errors.Is(err, ErrSystemUserLoginForbidden) { + t.Fatalf("SignInWithEmail official system phone err = %v, want ErrSystemUserLoginForbidden", err) + } + + if err := codes.Set(ctx, "system-signup", store.PhoneCode{Phone: phone, Code: "12345"}, time.Minute); err != nil { + t.Fatalf("seed sign-up code: %v", err) + } + if _, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, "system-signup", "System", "User"); !errors.Is(err, ErrSystemUserLoginForbidden) { + t.Fatalf("SignUp official system phone err = %v, want ErrSystemUserLoginForbidden", err) + } +} + +func TestSystemUserAuthorizationIsRejectedAndRevoked(t *testing.T) { + ctx := context.Background() + authz := memory.NewAuthorizationStore() + svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345") + + authKeyID := [8]byte{0x71} + if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: domain.OfficialSystemUserID}); err != nil { + t.Fatalf("bind system authorization: %v", err) + } + if got, found, err := svc.UserID(ctx, authKeyID); err != nil || found || got != 0 { + t.Fatalf("UserID(system auth) = %d found=%v err=%v, want not found", got, found, err) + } + if _, found, err := authz.ByAuthKey(ctx, authKeyID); err != nil || found { + t.Fatalf("system authorization after UserID found=%v err=%v, want deleted", found, err) + } + + pendingAuthKeyID := [8]byte{0x72} + if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: pendingAuthKeyID, UserID: domain.OfficialSystemUserID, PasswordPending: true}); err != nil { + t.Fatalf("bind pending system authorization: %v", err) + } + if got, pending, err := svc.PendingPasswordUserID(ctx, pendingAuthKeyID); err != nil || pending || got != 0 { + t.Fatalf("PendingPasswordUserID(system auth) = %d pending=%v err=%v, want not pending", got, pending, err) + } + if _, found, err := authz.ByAuthKey(ctx, pendingAuthKeyID); err != nil || found { + t.Fatalf("pending system authorization after lookup found=%v err=%v, want deleted", found, err) + } + + if _, err := svc.BindVerifiedLogin(ctx, domain.Authorization{AuthKeyID: [8]byte{0x73}}, domain.OfficialSystemUserID); !errors.Is(err, ErrSystemUserLoginForbidden) { + t.Fatalf("BindVerifiedLogin official system user err = %v, want ErrSystemUserLoginForbidden", err) + } + if _, err := svc.AcceptLoginToken(ctx, domain.Authorization{AuthKeyID: [8]byte{0x74}}, domain.OfficialSystemUserID); !errors.Is(err, ErrSystemUserLoginForbidden) { + t.Fatalf("AcceptLoginToken official system user err = %v, want ErrSystemUserLoginForbidden", err) + } +} + func TestMultipleAuthKeysKeepSeparateUsers(t *testing.T) { ctx := context.Background() authz := memory.NewAuthorizationStore() @@ -205,6 +324,77 @@ func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) { } } +func TestResetAuthorizationDeletesProtocolAuthKey(t *testing.T) { + ctx := context.Background() + authz := memory.NewAuthorizationStore() + keys := memory.NewAuthKeyStore() + svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), keys, nil, "12345") + key := [8]byte{0x31} + if err := keys.Save(ctx, store.AuthKeyData{ID: key}); err != nil { + t.Fatalf("save auth key: %v", err) + } + hash, err := svc.SendCode(ctx, "+15550007001") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550007001", hash, "One", "") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + items, err := authz.ListByUser(ctx, u.ID) + if err != nil || len(items) != 1 { + t.Fatalf("ListByUser = %d err=%v, want one authorization", len(items), err) + } + + deleted, found, err := svc.ResetAuthorization(ctx, u.ID, items[0].Hash) + if err != nil || !found || deleted.AuthKeyID != key { + t.Fatalf("ResetAuthorization deleted=%x found=%v err=%v, want key %x", deleted.AuthKeyID, found, err, key) + } + if _, found, err := keys.Get(ctx, key); err != nil || found { + t.Fatalf("auth key after reset found=%v err=%v, want missing", found, err) + } + if _, found, err := svc.UserID(ctx, key); err != nil || found { + t.Fatalf("user after reset found=%v err=%v, want missing", found, err) + } +} + +func TestResetAuthorizationsDeletesOnlyRevokedProtocolAuthKeys(t *testing.T) { + ctx := context.Background() + authz := memory.NewAuthorizationStore() + keys := memory.NewAuthKeyStore() + users := memory.NewUserStore() + svc := NewService(users, authz, memory.NewCodeStore(), keys, nil, "12345") + keep := [8]byte{0x41} + revoked := [8]byte{0x42} + for _, key := range [][8]byte{keep, revoked} { + if err := keys.Save(ctx, store.AuthKeyData{ID: key}); err != nil { + t.Fatalf("save auth key %x: %v", key, err) + } + } + hash, err := svc.SendCode(ctx, "+15550007002") + if err != nil { + t.Fatalf("SendCode: %v", err) + } + u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: keep}, "+15550007002", hash, "Two", "") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: revoked, UserID: u.ID}); err != nil { + t.Fatalf("bind revoked authorization: %v", err) + } + + deleted, err := svc.ResetAuthorizations(ctx, u.ID, keep) + if err != nil || len(deleted) != 1 || deleted[0].AuthKeyID != revoked { + t.Fatalf("ResetAuthorizations deleted=%v err=%v, want revoked key", deleted, err) + } + if _, found, err := keys.Get(ctx, revoked); err != nil || found { + t.Fatalf("revoked auth key found=%v err=%v, want missing", found, err) + } + if _, found, err := keys.Get(ctx, keep); err != nil || !found { + t.Fatalf("kept auth key found=%v err=%v, want present", found, err) + } +} + func TestSignUpWritesOfficialLoginMessage(t *testing.T) { ctx := context.Background() dialogs := memory.NewDialogStore() @@ -241,6 +431,63 @@ func TestSignUpWritesOfficialLoginMessage(t *testing.T) { } } +func TestSignInLoginMessagePreservesOfficialDialogReadWatermark(t *testing.T) { + ctx := context.Background() + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithLoginMessages(messages, dialogs)) + phone := "+15550004312" + + hash, err := svc.SendCode(ctx, phone) + if err != nil { + t.Fatalf("SendCode signup: %v", err) + } + u, first, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Test", "User") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} + if read, err := dialogs.MarkRead(ctx, u.ID, peer, domain.MaxMessageBoxID); err != nil { + t.Fatalf("MarkRead first login message: %v", err) + } else if read.MaxID != first.ID || read.StillUnreadCount != 0 { + t.Fatalf("read first login message = %+v, want max_id %d unread 0", read, first.ID) + } + + hash, err = svc.SendCode(ctx, phone) + if err != nil { + t.Fatalf("SendCode signin second: %v", err) + } + _, second, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345") + if err != nil || needSignUp { + t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err) + } + assertOfficialDialog := func(wantTop, wantRead, wantUnread int) { + t.Helper() + list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if len(list.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want official dialog", list.Dialogs) + } + got := list.Dialogs[0] + if got.TopMessage != wantTop || got.ReadInboxMaxID != wantRead || got.UnreadCount != wantUnread { + t.Fatalf("dialog = %+v, want top=%d read=%d unread=%d", got, wantTop, wantRead, wantUnread) + } + } + assertOfficialDialog(second.ID, first.ID, 1) + + hash, err = svc.SendCode(ctx, phone) + if err != nil { + t.Fatalf("SendCode signin third: %v", err) + } + _, third, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345") + if err != nil || needSignUp { + t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err) + } + assertOfficialDialog(third.ID, first.ID, 2) +} + func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) { ctx := context.Background() passwords := memory.NewPasswordStore() @@ -274,9 +521,23 @@ func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) { if needSignUp || got.ID != u.ID { t.Fatalf("SignIn user=%+v needSignUp=%v, want existing 2FA user", got, needSignUp) } + // 两步验证未完成:业务鉴权(UserID)必须视为未登录,避免绕过 2FA。 bound, found, err := svc.UserID(ctx, key) + if err != nil || found || bound != 0 { + t.Fatalf("UserID after password-needed = %d found=%v err=%v, want not-found", bound, found, err) + } + // 但仍可定位待验证用户,供 auth.checkPassword 继续。 + pendingUID, pending, err := svc.PendingPasswordUserID(ctx, key) + if err != nil || !pending || pendingUID != u.ID { + t.Fatalf("PendingPasswordUserID = %d pending=%v err=%v, want %d", pendingUID, pending, err, u.ID) + } + // 两步验证通过后转为完全授权。 + if err := svc.CompletePasswordSignIn(ctx, key); err != nil { + t.Fatalf("CompletePasswordSignIn: %v", err) + } + bound, found, err = svc.UserID(ctx, key) if err != nil || !found || bound != u.ID { - t.Fatalf("UserID after password-needed = %d found=%v err=%v, want %d", bound, found, err, u.ID) + t.Fatalf("UserID after 2FA passed = %d found=%v err=%v, want %d", bound, found, err, u.ID) } } diff --git a/internal/app/bots/botfather.go b/internal/app/bots/botfather.go new file mode 100644 index 00000000..53ca1013 --- /dev/null +++ b/internal/app/bots/botfather.go @@ -0,0 +1,686 @@ +package bots + +import ( + "context" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// BotFather 对话状态机:用户发给 BotFather 的每条私聊消息经 app/messages 的 +// responder hook 进入 OnPrivateMessage;用户消息已先行入库,这里只负责生成并 +// 写入 BotFather 的回复(完整 SendPrivateText 链路:双盒+事件+outbox 推送)。 + +const ( + botFatherCmdNewBot = "newbot" + botFatherCmdToken = "token" + botFatherCmdRevoke = "revoke" + botFatherCmdSetName = "setname" + botFatherCmdSetDescription = "setdescription" + botFatherCmdSetAbout = "setabouttext" + botFatherCmdSetCommands = "setcommands" + botFatherCmdSetInline = "setinline" + botFatherCmdSetInlineGeo = "setinlinegeo" + botFatherCmdSetInlineFB = "setinlinefeedback" + botFatherCmdSetJoinGroups = "setjoingroups" + botFatherCmdSetPrivacy = "setprivacy" + + botFatherStepName = "name" + botFatherStepUsername = "username" + botFatherStepChoose = "choose" + botFatherStepValue = "value" + + botFatherDraftBotID = "bot_id" + botFatherDraftBotUsername = "bot_username" +) + +const botFatherHelpText = `I can help you create and manage Telegram bots. + +You can control me by sending these commands: + +/newbot - create a new bot +/mybots - list your bots +/token - show a bot's token +/revoke - revoke a bot's token +/setname - change a bot's name +/setdescription - change a bot's description +/setabouttext - change a bot's about info +/setcommands - change a bot's command list +/setinline - toggle inline mode +/setinlinegeo - toggle inline location requests +/setinlinefeedback - change inline feedback settings +/setjoingroups - toggle whether a bot can join groups +/setprivacy - toggle a bot's group privacy mode +/cancel - cancel the current operation +/help - show this message` + +// botReply 是 BotFather 的一条回复。 +type botReply struct { + Text string + Entities []domain.MessageEntity +} + +// HandlesBot 报告该收件人是否为内置应答 bot(messages.BotResponder 实现)。 +func (s *Service) HandlesBot(botUserID int64) bool { + return s != nil && botUserID == domain.BotFatherUserID +} + +// OnPrivateMessage 处理投递给内置 bot 的私聊消息(messages.BotResponder 实现)。 +// msg 是 bot 视角的收件 box 行。回复异步生成(不占用户 sendMessage 的 RPC +// goroutine——官方 bot 回复本就异步到达),失败只记日志,绝不影响用户消息本身。 +func (s *Service) OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message) { + if s == nil || s.messages == nil || botUserID != domain.BotFatherUserID { + return + } + userID := msg.From.ID + if msg.From.Type != domain.PeerTypeUser || userID == 0 || userID == botUserID { + return + } + go s.respondAsBotFather(userID, msg.Body) +} + +// respondAsBotFather 生成并写入 BotFather 回复(OnPrivateMessage 在 goroutine 内调用)。 +// 按用户取条带锁串行:状态机 Get→modify→Upsert/Delete 的 RMW 因此原子、回复保序, +// 不同用户并发不受影响。ctx 用 Background(脱离已返回的用户 RPC),限较长超时。 +func (s *Service) respondAsBotFather(userID int64, body string) { + mu := &s.replyLocks[uint64(userID)%replyLockStripes] + mu.Lock() + defer mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + reply := s.handleBotFather(ctx, userID, body) + if reply.Text == "" { + return + } + blocked := false + if s.blocker != nil { + if b, err := s.blocker.IsBlocked(ctx, userID, domain.BotFatherUserID); err != nil { + s.log.Warn("botfather: check block", zap.Int64("user_id", userID), zap.Error(err)) + } else { + blocked = b + } + } + if _, err := s.messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: domain.BotFatherUserID, + RecipientUserID: userID, + RandomID: s.botReplyRandomID(), + Message: reply.Text, + Entities: reply.Entities, + Date: int(s.now().Unix()), + RecipientBlocked: blocked, + }); err != nil { + s.log.Error("botfather: send reply", zap.Int64("user_id", userID), zap.Error(err)) + } +} + +// botReplyRandomID 为服务端回复构造非零幂等键((sender, random_id) 唯一索引)。 +// 所有 BotFather 回复共享 sender=BotFather 一个命名空间,必须全局唯一——用 +// crypto/rand 取 64 位随机数(碰撞概率可忽略),熵源失败时退化为纳秒+单调序列。 +func (s *Service) botReplyRandomID() int64 { + if v, err := randomInt64(); err == nil && v != 0 { + return v + } + v := s.now().UnixNano() + s.replySeq.Add(1) + if v == 0 { + v = 1 + } + return v +} + +// botFatherGlobalCommands 是任何状态下都优先按命令处理的全局命令。其余以 "/" +// 开头的文本(如 /empty、或粘贴的 "/start - Begin" 命令列表首行)在收值步骤里 +// 必须作为原始内容透传给状态机,否则 /setcommands 的 /empty 永不可达、且首行 +// 带斜杠的命令列表会被截成命令名 "start" 静默销毁整个流程。 +var botFatherGlobalCommands = map[string]bool{ + "start": true, "help": true, "cancel": true, + botFatherCmdNewBot: true, "mybots": true, + botFatherCmdToken: true, botFatherCmdRevoke: true, + botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true, + botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true, + botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true, +} + +func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply { + text = strings.TrimSpace(text) + state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID) + if err != nil { + s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + // 命令拦截:仅当不在收值步骤、或文本是已知全局命令时,才走命令分发。收值步骤 + // 下的非全局 "/..." 文本(/empty、命令列表首行)必须当原始值透传给状态机。 + if cmd, ok := parseBotCommand(text); ok { + inValueStep := found && state.Step == botFatherStepValue + if !inValueStep || botFatherGlobalCommands[cmd] { + return s.handleBotFatherCommand(ctx, userID, cmd) + } + } + if text == "" { + // 空白文本 / 贴纸 / 无 caption 媒体:有活动状态时回当前步骤提示, + // 无状态保持沉默(避免对任意非文本消息刷屏)。 + if !found { + return botReply{} + } + return s.stepPrompt(state) + } + if !found { + return botReply{Text: "I can only help you create and manage bots. Send /help for a list of commands."} + } + switch { + case state.Command == botFatherCmdNewBot && state.Step == botFatherStepName: + return s.handleNewBotName(ctx, state, text) + case state.Command == botFatherCmdNewBot && state.Step == botFatherStepUsername: + return s.handleNewBotUsername(ctx, state, text) + case state.Step == botFatherStepChoose: + return s.handleChooseBot(ctx, state, text) + case state.Step == botFatherStepValue: + return s.handleSetValue(ctx, state, text) + default: + // 不可达的脏状态:清掉重来,避免用户被卡死。 + _ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID) + return botReply{Text: "Something went wrong, I forgot what we were doing. Send /help for a list of commands."} + } +} + +// pickerCommands 是「先选 bot」的命令集(choose step 后按命令分流)。 +var pickerPrompts = map[string]string{ + botFatherCmdToken: "Choose a bot to generate a token for. Send the bot's username:", + botFatherCmdRevoke: "Choose a bot to revoke the token of. Send the bot's username:", + botFatherCmdSetName: "Choose a bot to change the name of. Send the bot's username:", + botFatherCmdSetDescription: "Choose a bot to change the description of. Send the bot's username:", + botFatherCmdSetAbout: "Choose a bot to change the about info of. Send the bot's username:", + botFatherCmdSetCommands: "Choose a bot to change the command list of. Send the bot's username:", + botFatherCmdSetInline: "Choose a bot to change inline mode for. Send the bot's username:", + botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:", + botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:", + botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:", +} + +// startBotPicker 列出 owner 的 bot 并进入 choose step(所有需先选 bot 的命令共用)。 +func (s *Service) startBotPicker(ctx context.Context, userID int64, cmd string) botReply { + usernames, err := s.ownedBotUsernames(ctx, userID) + if err != nil { + s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + if len(usernames) == 0 { + return botReply{Text: "You don't have any bots yet. Use /newbot to create one."} + } + if err := s.bots.UpsertBotChatState(ctx, domain.BotChatState{ + BotUserID: domain.BotFatherUserID, + UserID: userID, + Command: cmd, + Step: botFatherStepChoose, + }); err != nil { + s.log.Error("botfather: save chat state", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + return botReply{Text: pickerPrompts[cmd] + "\n\n@" + strings.Join(usernames, "\n@")} +} + +// stepPrompt 返回当前对话步骤的引导文案(空输入兜底用)。 +func (s *Service) stepPrompt(state domain.BotChatState) botReply { + switch { + case state.Command == botFatherCmdNewBot && state.Step == botFatherStepName: + return botReply{Text: "Please choose a name for your bot, or /cancel."} + case state.Command == botFatherCmdNewBot && state.Step == botFatherStepUsername: + return botReply{Text: "Please send a username for your bot. It must end in `bot`. Or /cancel."} + case state.Step == botFatherStepChoose: + return botReply{Text: "Please send the username of one of your bots, or /cancel."} + case state.Step == botFatherStepValue: + return botReply{Text: valuePrompt(state.Command, state.Draft[botFatherDraftBotUsername])} + default: + return botReply{Text: "Send /help for a list of commands."} + } +} + +func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd string) botReply { + switch cmd { + case "start", "help": + _ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID) + return botReply{Text: botFatherHelpText} + case "cancel": + _, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID) + if err != nil { + s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + if !found { + return botReply{Text: "No active command to cancel. I wasn't doing anything anyway."} + } + if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil { + s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "The command has been cancelled. Anything else I can do for you? Send /help for a list of commands."} + case botFatherCmdNewBot: + count, err := s.bots.CountBotsByOwner(ctx, userID) + if err != nil { + s.log.Error("botfather: count bots", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + if count >= domain.MaxBotsPerOwner { + return botReply{Text: fmt.Sprintf("That I cannot do. You have reached the limit of %d bots per account.", domain.MaxBotsPerOwner)} + } + if err := s.bots.UpsertBotChatState(ctx, domain.BotChatState{ + BotUserID: domain.BotFatherUserID, + UserID: userID, + Command: botFatherCmdNewBot, + Step: botFatherStepName, + }); err != nil { + s.log.Error("botfather: save chat state", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "Alright, a new bot. How are we going to call it? Please choose a name for your bot."} + case "mybots": + usernames, err := s.ownedBotUsernames(ctx, userID) + if err != nil { + s.log.Error("botfather: list bots", zap.Int64("user_id", userID), zap.Error(err)) + return internalReply() + } + if len(usernames) == 0 { + return botReply{Text: "You don't have any bots yet. Use /newbot to create one."} + } + return botReply{Text: "Here are your bots:\n\n@" + strings.Join(usernames, "\n@")} + case botFatherCmdToken, botFatherCmdRevoke, + botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout, + botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo, + botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy: + return s.startBotPicker(ctx, userID, cmd) + case botFatherCmdSetInlineFB: + _ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID) + return botReply{Text: "Inline feedback settings are not supported yet. Use /setinline to enable or disable inline mode."} + default: + return botReply{Text: "Unrecognized command. Say what? Send /help for a list of commands."} + } +} + +// valuePrompts 是选中 bot 后、value step 的收值提示(按命令)。 +func valuePrompt(cmd, username string) string { + switch cmd { + case botFatherCmdSetName: + return fmt.Sprintf("OK. Send me the new name for @%s.", username) + case botFatherCmdSetDescription: + return fmt.Sprintf("OK. Send me the new description for @%s. People will see it on the bot's profile page, before they start a chat with it.", username) + case botFatherCmdSetAbout: + return fmt.Sprintf("OK. Send me the new about text for @%s. People will see this text on the bot's profile page and it will be sent together with a link to your bot when they share it with someone.", username) + case botFatherCmdSetCommands: + return fmt.Sprintf("OK. Send me a list of commands for @%s. Please use this format:\n\ncommand1 - Description\ncommand2 - Another description\n\nSend /empty to clear the list.", username) + case botFatherCmdSetInline: + return fmt.Sprintf("This will enable inline queries for @%s. Send me the placeholder text people will see after typing the bot username, or send /empty to disable inline mode.", username) + case botFatherCmdSetInlineGeo: + return fmt.Sprintf("Send 'enable' to allow @%s to receive location in inline queries, or 'disable' to turn it off.", username) + case botFatherCmdSetJoinGroups: + return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username) + case botFatherCmdSetPrivacy: + return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username) + default: + return "Send the new value, or /cancel." + } +} + +func (s *Service) handleNewBotName(ctx context.Context, state domain.BotChatState, name string) botReply { + if name == "" || len([]rune(name)) > domain.MaxBotNameLength { + return botReply{Text: fmt.Sprintf("Sorry, the bot name must be 1-%d characters long. Please choose a different name.", domain.MaxBotNameLength)} + } + state.Step = botFatherStepUsername + if state.Draft == nil { + state.Draft = map[string]string{} + } + state.Draft["name"] = name + if err := s.bots.UpsertBotChatState(ctx, state); err != nil { + s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot."} +} + +func (s *Service) handleNewBotUsername(ctx context.Context, state domain.BotChatState, username string) botReply { + u, token, err := s.CreateBot(ctx, state.UserID, state.Draft["name"], username) + switch { + case errors.Is(err, domain.ErrBotUsernameInvalid): + return botReply{Text: "Sorry, this username is invalid. A bot username must be 5-32 characters long, start with a letter, contain only Latin letters, digits and underscores, and end in 'bot' (e.g. tetris_bot)."} + case errors.Is(err, domain.ErrUsernameOccupied): + return botReply{Text: "Sorry, this username is already taken. Please try something different."} + case errors.Is(err, domain.ErrBotsTooMany): + _ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID) + return botReply{Text: fmt.Sprintf("That I cannot do. You have reached the limit of %d bots per account.", domain.MaxBotsPerOwner)} + case errors.Is(err, domain.ErrBotNameInvalid): + // name 步已校验,这里只可能是脏状态;重新走 name 步。 + state.Step = botFatherStepName + if err := s.bots.UpsertBotChatState(ctx, state); err != nil { + s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + return botReply{Text: "Please choose a name for your bot first."} + case err != nil: + s.log.Error("botfather: create bot", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, state.UserID); err != nil { + s.log.Error("botfather: delete chat state", zap.Int64("user_id", state.UserID), zap.Error(err)) + } + head := fmt.Sprintf("Done! Congratulations on your new bot. You will find it at telesrv.net/%s.\n\nUse this token to access the HTTP API:\n", u.Username) + return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.") +} + +func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState, text string) botReply { + username := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(text, "@"))) + profiles, err := s.ownedBots(ctx, state.UserID) + if err != nil { + s.log.Error("botfather: list bots", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + var chosen *domain.User + for i := range profiles { + if strings.ToLower(profiles[i].user.Username) == username { + chosen = &profiles[i].user + break + } + } + if chosen == nil { + return botReply{Text: "I don't see that bot among yours. Send the username of one of your bots, or /cancel."} + } + switch state.Command { + case botFatherCmdToken: + defer s.clearState(ctx, state.UserID) + profile, found, err := s.bots.GetBot(ctx, chosen.ID) + if err != nil || !found || profile.TokenSecret == "" { + if err != nil { + s.log.Error("botfather: get bot", zap.Int64("bot_user_id", chosen.ID), zap.Error(err)) + } + return internalReply() + } + head := fmt.Sprintf("You can use this token to access the HTTP API for @%s:\n", chosen.Username) + return tokenReply(head, domain.FormatBotToken(chosen.ID, profile.TokenSecret), "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.") + case botFatherCmdRevoke: + defer s.clearState(ctx, state.UserID) + token, err := s.RevokeBotToken(ctx, state.UserID, chosen.ID) + switch { + case errors.Is(err, domain.ErrBotSessionsNotRevoked): + // token 已换(旧 token 不可再登录),但未能终止已建立的 session—— + // 诚实告知用户重试,不谎称已止血。 + head := fmt.Sprintf("Token for @%s has been changed, so the old token can no longer log in. But I couldn't terminate sessions that are already logged in — please run /revoke again to make sure they're cut off. New token:\n", chosen.Username) + return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.") + case err != nil: + s.log.Error("botfather: revoke token", zap.Int64("bot_user_id", chosen.ID), zap.Error(err)) + return internalReply() + } + head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username) + return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.") + case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout, + botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo, + botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy: + // 选中 bot 后进入收值 step,把目标 bot 暂存进 Draft。 + state.Step = botFatherStepValue + if state.Draft == nil { + state.Draft = map[string]string{} + } + state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10) + state.Draft[botFatherDraftBotUsername] = chosen.Username + if err := s.bots.UpsertBotChatState(ctx, state); err != nil { + s.log.Error("botfather: save chat state", zap.Int64("user_id", state.UserID), zap.Error(err)) + return internalReply() + } + return botReply{Text: valuePrompt(state.Command, chosen.Username)} + default: + s.clearState(ctx, state.UserID) + return internalReply() + } +} + +// handleSetValue 处理选中 bot 后的收值步骤(/setname 等)。 +func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState, text string) botReply { + botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64) + username := state.Draft[botFatherDraftBotUsername] + if botID == 0 { + s.clearState(ctx, state.UserID) + return botReply{Text: "Something went wrong, I forgot which bot we were editing. Send /help."} + } + // 防御性复核 owner(状态是服务端存的,正常已是 owned bot)。 + if owns, err := s.OwnsBot(ctx, state.UserID, botID); err != nil { + s.log.Error("botfather: owns bot", zap.Int64("bot_user_id", botID), zap.Error(err)) + return internalReply() + } else if !owns { + s.clearState(ctx, state.UserID) + return botReply{Text: "That bot is no longer available."} + } + + var ( + reply botReply + err error + ) + switch state.Command { + case botFatherCmdSetName: + _, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetName: true, Name: text}) + reply = okReply(err, fmt.Sprintf("Success! Name updated for @%s.", username), "Sorry, that name is invalid. Please try a different one.") + case botFatherCmdSetDescription: + _, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetDescription: true, Description: text}) + reply = okReply(err, "Success! Description updated.", "Sorry, that description is too long.") + case botFatherCmdSetAbout: + _, err = s.SetBotInfo(ctx, botID, domain.BotInfoUpdate{SetAbout: true, About: text}) + reply = okReply(err, "Success! About section updated.", "Sorry, that about text is too long.") + case botFatherCmdSetCommands: + reply, err = s.applySetCommands(ctx, botID, text) + case botFatherCmdSetInline: + reply, err = s.applySetInline(ctx, botID, text) + case botFatherCmdSetInlineGeo: + reply, err = s.applySetInlineGeo(ctx, botID, text) + case botFatherCmdSetJoinGroups: + reply, err = s.applyToggle(ctx, botID, text, true) + case botFatherCmdSetPrivacy: + reply, err = s.applyToggle(ctx, botID, text, false) + default: + s.clearState(ctx, state.UserID) + return internalReply() + } + if err != nil { + // 校验类错误已转成提示文案;非校验错误内部已记日志。保留 state 让用户重试。 + if reply.Text == "" { + return internalReply() + } + return reply + } + s.clearState(ctx, state.UserID) + return reply +} + +// applySetCommands 解析多行 "command - Description"(/empty 清空)并写入。 +func (s *Service) applySetCommands(ctx context.Context, botID int64, text string) (botReply, error) { + var commands []domain.BotCommand + if strings.TrimSpace(text) != "/empty" { + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + cmd, desc, ok := strings.Cut(line, "-") + if !ok { + return botReply{Text: "Invalid format. Each line must be: command - Description. Try again or /cancel."}, domain.ErrBotCommandInvalid + } + commands = append(commands, domain.BotCommand{ + Command: strings.TrimSpace(cmd), + Description: strings.TrimSpace(desc), + }) + } + } + if _, err := s.SetBotCommands(ctx, botID, commands); err != nil { + return botReply{Text: "Invalid command list. Each command must be 1-32 chars (letters, digits, underscores) with a non-empty description. Try again or /cancel."}, err + } + return botReply{Text: "Success! Command list updated. /help"}, nil +} + +// applySetInline 写入 inline placeholder;/empty 清空并关闭 inline mode。 +func (s *Service) applySetInline(ctx context.Context, botID int64, text string) (botReply, error) { + placeholder := strings.TrimSpace(text) + if placeholder == "/empty" { + placeholder = "" + } + if _, err := s.SetInlinePlaceholder(ctx, botID, placeholder); err != nil { + return botReply{Text: fmt.Sprintf("Sorry, inline placeholder must be at most %d characters. Try again or /cancel.", domain.MaxBotInlinePlaceholderLen)}, err + } + if placeholder == "" { + return botReply{Text: "Success! Inline mode disabled. /help"}, nil + } + return botReply{Text: "Success! Inline settings updated. /help"}, nil +} + +func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text string) (botReply, error) { + var enabled bool + switch strings.ToLower(strings.TrimSpace(text)) { + case "enable", "on", "yes": + enabled = true + case "disable", "off", "no": + enabled = false + default: + return botReply{Text: "Please send 'enable' or 'disable', or /cancel."}, domain.ErrBotInfoInvalid + } + if _, err := s.SetInlineGeo(ctx, botID, enabled); err != nil { + s.log.Error("botfather: set inline geo", zap.Int64("bot_user_id", botID), zap.Error(err)) + return botReply{}, err + } + state := "disabled" + if enabled { + state = "enabled" + } + return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil +} + +// applyToggle 解析 enable/disable 并设置 joingroups(join=true)或 privacy(join=false)。 +func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) { + var on bool + switch strings.ToLower(strings.TrimSpace(text)) { + case "enable", "on", "yes": + on = true + case "disable", "off", "no": + on = false + default: + return botReply{Text: "Please send 'enable' or 'disable', or /cancel."}, domain.ErrBotInfoInvalid + } + var err error + if join { + _, err = s.SetJoinGroups(ctx, botID, on) + } else { + _, err = s.SetPrivacy(ctx, botID, on) + } + if err != nil { + s.log.Error("botfather: apply toggle", zap.Int64("bot_user_id", botID), zap.Bool("join", join), zap.Error(err)) + return botReply{}, err + } + what := "Group privacy" + if join { + what = "Group joining" + } + state := "disabled" + if on { + state = "enabled" + } + return botReply{Text: fmt.Sprintf("Success! %s is now %s.", what, state)}, nil +} + +// okReply 把 service 调用结果转成提示:err==nil 回成功,否则回校验失败提示。 +func okReply(err error, ok, fail string) botReply { + if err != nil { + return botReply{Text: fail} + } + return botReply{Text: ok} +} + +// clearState 删除 BotFather 对话状态(忽略错误,仅记日志)。 +func (s *Service) clearState(ctx context.Context, userID int64) { + if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil { + s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err)) + } +} + +type ownedBot struct { + profile domain.BotProfile + user domain.User +} + +func (s *Service) ownedBots(ctx context.Context, ownerUserID int64) ([]ownedBot, error) { + profiles, err := s.bots.ListBotsByOwner(ctx, ownerUserID) + if err != nil { + return nil, err + } + if len(profiles) == 0 { + return nil, nil + } + ids := make([]int64, 0, len(profiles)) + for _, p := range profiles { + ids = append(ids, p.BotUserID) + } + users, err := s.users.ByIDs(ctx, ids) + if err != nil { + return nil, err + } + byID := make(map[int64]domain.User, len(users)) + for _, u := range users { + byID[u.ID] = u + } + out := make([]ownedBot, 0, len(profiles)) + for _, p := range profiles { + u, ok := byID[p.BotUserID] + if !ok { + continue + } + out = append(out, ownedBot{profile: p, user: u}) + } + sort.Slice(out, func(i, j int) bool { return out[i].user.ID < out[j].user.ID }) + return out, nil +} + +func (s *Service) ownedBotUsernames(ctx context.Context, ownerUserID int64) ([]string, error) { + owned, err := s.ownedBots(ctx, ownerUserID) + if err != nil { + return nil, err + } + out := make([]string, 0, len(owned)) + for _, b := range owned { + if b.user.Username != "" { + out = append(out, b.user.Username) + } + } + return out, nil +} + +// tokenReply 拼装带 code entity 的 token 消息(全 ASCII 文本,offset 按字节即按 +// UTF-16 code unit 成立)。 +func tokenReply(head, token, tail string) botReply { + return botReply{ + Text: head + token + tail, + Entities: []domain.MessageEntity{ + {Type: domain.MessageEntityCode, Offset: len(head), Length: len(token)}, + }, + } +} + +func internalReply() botReply { + return botReply{Text: "Something went wrong on my side. Please try again later."} +} + +// parseBotCommand 解析行首 "/cmd"(容忍 "/cmd@BotFather" 与尾随参数),返回小写命令名。 +func parseBotCommand(text string) (string, bool) { + if !strings.HasPrefix(text, "/") { + return "", false + } + cmd := text[1:] + if i := strings.IndexAny(cmd, " \t\n"); i >= 0 { + cmd = cmd[:i] + } + if i := strings.IndexByte(cmd, '@'); i >= 0 { + cmd = cmd[:i] + } + if cmd == "" { + return "", false + } + return strings.ToLower(cmd), true +} diff --git a/internal/app/bots/manage_test.go b/internal/app/bots/manage_test.go new file mode 100644 index 00000000..7af2d252 --- /dev/null +++ b/internal/app/bots/manage_test.go @@ -0,0 +1,413 @@ +package bots + +import ( + "context" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// makeBot 创建一个 owned bot,返回其 user。 +func makeBot(t *testing.T, svc *Service, owner domain.User, name, username string) domain.User { + t.Helper() + u, _, err := svc.CreateBot(context.Background(), owner.ID, name, username) + if err != nil { + t.Fatalf("create bot %q: %v", username, err) + } + return u +} + +func TestSetBotCommandsAndBump(t *testing.T) { + svc, users, _, _ := newTestService(t) + owner := newOwner(t, users, "+2000") + bot := makeBot(t, svc, owner, "Cmd Bot", "cmd_test_bot") + ctx := context.Background() + + before, _, _ := users.ByID(ctx, bot.ID) + v1, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{ + {Command: "/Start", Description: "begin"}, + {Command: "help", Description: "show help"}, + }) + if err != nil { + t.Fatalf("set commands: %v", err) + } + if v1 <= before.BotInfoVersion { + t.Fatalf("bot_info_version not bumped: before=%d after=%d", before.BotInfoVersion, v1) + } + got, err := svc.GetBotCommands(ctx, bot.ID) + if err != nil { + t.Fatalf("get commands: %v", err) + } + if len(got) != 2 || got[0].Command != "start" || got[1].Command != "help" { + t.Fatalf("commands = %+v, want normalized [start,help]", got) + } + + // 非法命令名 → ErrBotCommandInvalid。 + if _, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "bad name!", Description: "x"}}); err != domain.ErrBotCommandInvalid { + t.Fatalf("invalid command err = %v, want ErrBotCommandInvalid", err) + } + // 空描述 → 非法。 + if _, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "ok", Description: ""}}); err != domain.ErrBotCommandInvalid { + t.Fatalf("empty desc err = %v, want ErrBotCommandInvalid", err) + } + // 清空。 + if _, err := svc.SetBotCommands(ctx, bot.ID, nil); err != nil { + t.Fatalf("reset commands: %v", err) + } + if got, _ := svc.GetBotCommands(ctx, bot.ID); len(got) != 0 { + t.Fatalf("after reset commands = %+v, want empty", got) + } +} + +func TestSetBotInfoFields(t *testing.T) { + svc, users, _, _ := newTestService(t) + owner := newOwner(t, users, "+2001") + bot := makeBot(t, svc, owner, "Info Bot", "info_test_bot") + ctx := context.Background() + + if _, err := svc.SetBotInfo(ctx, bot.ID, domain.BotInfoUpdate{ + SetName: true, Name: "Renamed Bot", + SetAbout: true, About: "about line", + SetDescription: true, Description: "what this bot does", + }); err != nil { + t.Fatalf("set bot info: %v", err) + } + name, about, description, err := svc.GetBotInfo(ctx, bot.ID) + if err != nil { + t.Fatalf("get bot info: %v", err) + } + if name != "Renamed Bot" || about != "about line" || description != "what this bot does" { + t.Fatalf("bot info = name=%q about=%q desc=%q", name, about, description) + } + // name 落到 users.first_name。 + u, _, _ := users.ByID(ctx, bot.ID) + if u.FirstName != "Renamed Bot" || u.About != "about line" { + t.Fatalf("user row = first_name=%q about=%q, want name/about persisted", u.FirstName, u.About) + } + // 空 name 非法。 + if _, err := svc.SetBotInfo(ctx, bot.ID, domain.BotInfoUpdate{SetName: true, Name: " "}); err != domain.ErrBotInfoInvalid { + t.Fatalf("empty name err = %v, want ErrBotInfoInvalid", err) + } + // 全空更新非法。 + if _, err := svc.SetBotInfo(ctx, bot.ID, domain.BotInfoUpdate{}); err != domain.ErrBotInfoInvalid { + t.Fatalf("noop update err = %v, want ErrBotInfoInvalid", err) + } +} + +func TestSetBotMenuButton(t *testing.T) { + svc, users, _, _ := newTestService(t) + owner := newOwner(t, users, "+2002") + bot := makeBot(t, svc, owner, "Menu Bot", "menu_test_bot") + ctx := context.Background() + + if _, err := svc.SetBotMenuButton(ctx, bot.ID, domain.BotMenuButton{ + Type: domain.BotMenuButtonWebView, Text: "Open", URL: "https://example.com/app", + }); err != nil { + t.Fatalf("set menu button: %v", err) + } + btn, err := svc.GetBotMenuButton(ctx, bot.ID) + if err != nil { + t.Fatalf("get menu button: %v", err) + } + if btn.Type != domain.BotMenuButtonWebView || btn.Text != "Open" || btn.URL != "https://example.com/app" { + t.Fatalf("menu button = %+v", btn) + } + // webview 缺 text/url 非法。 + if _, err := svc.SetBotMenuButton(ctx, bot.ID, domain.BotMenuButton{Type: domain.BotMenuButtonWebView, URL: "https://x"}); err != domain.ErrBotMenuButtonInvalid { + t.Fatalf("webview missing text err = %v, want ErrBotMenuButtonInvalid", err) + } + // commands 型清空 text/url。 + if _, err := svc.SetBotMenuButton(ctx, bot.ID, domain.BotMenuButton{Type: domain.BotMenuButtonCommands, Text: "x", URL: "y"}); err != nil { + t.Fatalf("set commands menu: %v", err) + } + if btn, _ := svc.GetBotMenuButton(ctx, bot.ID); btn.Type != domain.BotMenuButtonCommands || btn.Text != "" || btn.URL != "" { + t.Fatalf("commands menu = %+v, want cleared text/url", btn) + } +} + +func TestSetInlinePlaceholder(t *testing.T) { + svc, users, bots, _ := newTestService(t) + owner := newOwner(t, users, "+2010") + bot := makeBot(t, svc, owner, "Inline Bot", "inline_test_bot") + ctx := context.Background() + + before, _, _ := users.ByID(ctx, bot.ID) + version, err := svc.SetInlinePlaceholder(ctx, bot.ID, "Search things") + if err != nil { + t.Fatalf("set inline placeholder: %v", err) + } + if version <= before.BotInfoVersion { + t.Fatalf("bot_info_version not bumped: before=%d after=%d", before.BotInfoVersion, version) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "Search things" { + t.Fatalf("inline placeholder = %q, want Search things", p.InlinePlaceholder) + } + if _, err := svc.SetInlinePlaceholder(ctx, bot.ID, strings.Repeat("x", domain.MaxBotInlinePlaceholderLen+1)); err != domain.ErrBotInlinePlaceholderInvalid { + t.Fatalf("overlong placeholder err = %v, want ErrBotInlinePlaceholderInvalid", err) + } + if _, err := svc.SetInlinePlaceholder(ctx, bot.ID, ""); err != nil { + t.Fatalf("clear inline placeholder: %v", err) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "" { + t.Fatalf("inline placeholder after clear = %q, want empty", p.InlinePlaceholder) + } +} + +func TestSetJoinGroupsAndPrivacy(t *testing.T) { + svc, users, bots, _ := newTestService(t) + owner := newOwner(t, users, "+2003") + bot := makeBot(t, svc, owner, "Flag Bot", "flag_test_bot") + ctx := context.Background() + + // joingroups disable → nochats=true。 + if _, err := svc.SetJoinGroups(ctx, bot.ID, false); err != nil { + t.Fatalf("set join groups: %v", err) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); !p.Nochats { + t.Fatalf("nochats = false, want true after disable join") + } + if _, err := svc.SetJoinGroups(ctx, bot.ID, true); err != nil { + t.Fatalf("re-enable join: %v", err) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); p.Nochats { + t.Fatalf("nochats = true, want false after enable join") + } + // privacy enable → chat_history=false(隐私开=只收命令)。 + if _, err := svc.SetPrivacy(ctx, bot.ID, true); err != nil { + t.Fatalf("set privacy: %v", err) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); p.ChatHistory { + t.Fatalf("chat_history = true, want false when privacy enabled") + } + if _, err := svc.SetPrivacy(ctx, bot.ID, false); err != nil { + t.Fatalf("disable privacy: %v", err) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); !p.ChatHistory { + t.Fatalf("chat_history = false, want true when privacy disabled") + } + before, _, _ := users.ByID(ctx, bot.ID) + version, err := svc.SetInlineGeo(ctx, bot.ID, true) + if err != nil { + t.Fatalf("set inline geo: %v", err) + } + if version <= before.BotInfoVersion { + t.Fatalf("bot_info_version not bumped for inline geo: before=%d after=%d", before.BotInfoVersion, version) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); !p.InlineGeo { + t.Fatalf("inline_geo = false, want true after enable") + } + if _, err := svc.SetInlineGeo(ctx, bot.ID, false); err != nil { + t.Fatalf("disable inline geo: %v", err) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlineGeo { + t.Fatalf("inline_geo = true, want false after disable") + } +} + +func TestOwnsBot(t *testing.T) { + svc, users, _, _ := newTestService(t) + owner := newOwner(t, users, "+2004") + other := newOwner(t, users, "+2005") + bot := makeBot(t, svc, owner, "Owned Bot", "owned_test_bot") + ctx := context.Background() + + if owns, err := svc.OwnsBot(ctx, owner.ID, bot.ID); err != nil || !owns { + t.Fatalf("owner OwnsBot = %v,%v, want true", owns, err) + } + if owns, _ := svc.OwnsBot(ctx, other.ID, bot.ID); owns { + t.Fatalf("non-owner OwnsBot = true, want false") + } + // BotFather 自身不算任何人 owned。 + if owns, _ := svc.OwnsBot(ctx, domain.BotFatherUserID, domain.BotFatherUserID); owns { + t.Fatalf("BotFather self OwnsBot = true, want false") + } +} + +func TestBotFatherSetCommandsFlow(t *testing.T) { + svc, users, bots, messages := newTestService(t) + owner := newOwner(t, users, "+2006") + bot := makeBot(t, svc, owner, "Flow Bot", "flow_test_bot") + ctx := context.Background() + + if reply := sendToBotFather(t, svc, messages, owner, "/setcommands"); !strings.Contains(reply, "username") { + t.Fatalf("/setcommands reply = %q, want pick bot", reply) + } + if reply := sendToBotFather(t, svc, messages, owner, "@flow_test_bot"); !strings.Contains(reply, "list of commands") { + t.Fatalf("choose reply = %q, want value prompt", reply) + } + if reply := sendToBotFather(t, svc, messages, owner, "start - Begin\nhelp - Show help"); !strings.Contains(reply, "Success") { + t.Fatalf("set commands reply = %q, want success", reply) + } + got, _, _ := bots.GetBot(ctx, bot.ID) + if len(got.Commands) != 2 || got.Commands[0].Command != "start" { + t.Fatalf("stored commands = %+v, want [start,help]", got.Commands) + } + // 非法格式(无 -)保留 state 提示重试。 + sendToBotFather(t, svc, messages, owner, "/setcommands") + sendToBotFather(t, svc, messages, owner, "@flow_test_bot") + if reply := sendToBotFather(t, svc, messages, owner, "noseparator"); !strings.Contains(reply, "Invalid format") { + t.Fatalf("invalid format reply = %q", reply) + } +} + +func TestBotFatherSetNameAndAboutFlow(t *testing.T) { + svc, users, _, messages := newTestService(t) + owner := newOwner(t, users, "+2007") + bot := makeBot(t, svc, owner, "Name Bot", "name_test_bot") + ctx := context.Background() + + sendToBotFather(t, svc, messages, owner, "/setname") + sendToBotFather(t, svc, messages, owner, "@name_test_bot") + if reply := sendToBotFather(t, svc, messages, owner, "Brand New Name"); !strings.Contains(reply, "Success") { + t.Fatalf("setname reply = %q", reply) + } + if u, _, _ := users.ByID(ctx, bot.ID); u.FirstName != "Brand New Name" { + t.Fatalf("bot first_name = %q, want renamed", u.FirstName) + } + + sendToBotFather(t, svc, messages, owner, "/setabouttext") + sendToBotFather(t, svc, messages, owner, "@name_test_bot") + if reply := sendToBotFather(t, svc, messages, owner, "my about"); !strings.Contains(reply, "Success") { + t.Fatalf("setabouttext reply = %q", reply) + } + if u, _, _ := users.ByID(ctx, bot.ID); u.About != "my about" { + t.Fatalf("bot about = %q, want updated", u.About) + } +} + +func TestBotFatherSetJoinGroupsFlow(t *testing.T) { + svc, users, bots, messages := newTestService(t) + owner := newOwner(t, users, "+2008") + bot := makeBot(t, svc, owner, "Join Bot", "join_test_bot") + ctx := context.Background() + + sendToBotFather(t, svc, messages, owner, "/setjoingroups") + sendToBotFather(t, svc, messages, owner, "@join_test_bot") + if reply := sendToBotFather(t, svc, messages, owner, "disable"); !strings.Contains(reply, "Success") { + t.Fatalf("setjoingroups disable reply = %q", reply) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); !p.Nochats { + t.Fatalf("nochats = false, want true after disable") + } + // 非 enable/disable 输入保留 state 提示。 + sendToBotFather(t, svc, messages, owner, "/setprivacy") + sendToBotFather(t, svc, messages, owner, "@join_test_bot") + if reply := sendToBotFather(t, svc, messages, owner, "maybe"); !strings.Contains(reply, "enable") { + t.Fatalf("bad toggle reply = %q, want hint", reply) + } +} + +func TestBotFatherSetInlineFlow(t *testing.T) { + svc, users, bots, messages := newTestService(t) + owner := newOwner(t, users, "+2011") + bot := makeBot(t, svc, owner, "Inline Flow", "inline_flow_bot") + ctx := context.Background() + + if reply := sendToBotFather(t, svc, messages, owner, "/setinline"); !strings.Contains(reply, "inline mode") { + t.Fatalf("/setinline reply = %q, want pick bot", reply) + } + if reply := sendToBotFather(t, svc, messages, owner, "@inline_flow_bot"); !strings.Contains(reply, "placeholder") { + t.Fatalf("choose reply = %q, want placeholder prompt", reply) + } + if reply := sendToBotFather(t, svc, messages, owner, "Search inline stuff"); !strings.Contains(reply, "Success") { + t.Fatalf("setinline reply = %q, want success", reply) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "Search inline stuff" { + t.Fatalf("inline placeholder = %q", p.InlinePlaceholder) + } + + sendToBotFather(t, svc, messages, owner, "/setinline") + sendToBotFather(t, svc, messages, owner, "@inline_flow_bot") + if reply := sendToBotFather(t, svc, messages, owner, "/empty"); !strings.Contains(reply, "disabled") { + t.Fatalf("setinline /empty reply = %q, want disabled", reply) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlinePlaceholder != "" { + t.Fatalf("inline placeholder after /empty = %q, want empty", p.InlinePlaceholder) + } + + if reply := sendToBotFather(t, svc, messages, owner, "/setinlinegeo"); !strings.Contains(reply, "location requests") { + t.Fatalf("/setinlinegeo reply = %q, want pick bot", reply) + } + if reply := sendToBotFather(t, svc, messages, owner, "@inline_flow_bot"); !strings.Contains(reply, "location") { + t.Fatalf("choose inline geo reply = %q, want location prompt", reply) + } + if reply := sendToBotFather(t, svc, messages, owner, "enable"); !strings.Contains(reply, "Success") { + t.Fatalf("setinlinegeo enable reply = %q, want success", reply) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); !p.InlineGeo { + t.Fatalf("inline_geo = false, want enabled") + } + sendToBotFather(t, svc, messages, owner, "/setinlinegeo") + sendToBotFather(t, svc, messages, owner, "@inline_flow_bot") + if reply := sendToBotFather(t, svc, messages, owner, "disable"); !strings.Contains(reply, "Success") { + t.Fatalf("setinlinegeo disable reply = %q, want success", reply) + } + if p, _, _ := bots.GetBot(ctx, bot.ID); p.InlineGeo { + t.Fatalf("inline_geo = true, want disabled") + } + if reply := sendToBotFather(t, svc, messages, owner, "/setinlinefeedback"); !strings.Contains(reply, "not supported yet") { + t.Fatalf("/setinlinefeedback reply = %q, want explicit stub", reply) + } +} + +func TestRevokeBotTokenRevokesSessions(t *testing.T) { + users := memory.NewUserStore() + bots := memory.NewBotStore(users) + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + rev := &captureRevoker{} + svc := NewService(users, bots, messages) + svc.SetRouterHooks(rev) + owner := newOwner(t, users, "+2009") + bot := makeBot(t, svc, owner, "Rev Bot", "rev_test_bot") + ctx := context.Background() + + if _, err := svc.RevokeBotToken(ctx, owner.ID, bot.ID); err != nil { + t.Fatalf("revoke: %v", err) + } + if rev.botUserID != bot.ID { + t.Fatalf("RevokeBotSessions called with %d, want %d", rev.botUserID, bot.ID) + } +} + +func TestBotWriteAccessGrant(t *testing.T) { + svc, users, _, _ := newTestService(t) + owner := newOwner(t, users, "+2012") + bot := makeBot(t, svc, owner, "Write Bot", "write_access_bot") + ctx := context.Background() + + can, err := svc.CanSendMessage(ctx, owner.ID, bot.ID) + if err != nil || can { + t.Fatalf("CanSendMessage before allow = %v,%v, want false,nil", can, err) + } + created, err := svc.AllowSendMessage(ctx, owner.ID, bot.ID, true) + if err != nil || !created { + t.Fatalf("AllowSendMessage first = %v,%v, want true,nil", created, err) + } + can, err = svc.CanSendMessage(ctx, owner.ID, bot.ID) + if err != nil || !can { + t.Fatalf("CanSendMessage after allow = %v,%v, want true,nil", can, err) + } + created, err = svc.AllowSendMessage(ctx, owner.ID, bot.ID, true) + if err != nil || created { + t.Fatalf("AllowSendMessage repeat = %v,%v, want false,nil", created, err) + } +} + +type captureRevoker struct { + botUserID int64 + pushedCommandsTo int64 + pushedCommands []domain.BotCommand +} + +func (c *captureRevoker) RevokeBotSessions(_ context.Context, botUserID int64) error { + c.botUserID = botUserID + return nil +} + +func (c *captureRevoker) PushBotCommandsChanged(_ context.Context, botUserID int64, commands []domain.BotCommand) { + c.pushedCommandsTo = botUserID + c.pushedCommands = append([]domain.BotCommand(nil), commands...) +} diff --git a/internal/app/bots/miniapp.go b/internal/app/bots/miniapp.go new file mode 100644 index 00000000..4ad671a9 --- /dev/null +++ b/internal/app/bots/miniapp.go @@ -0,0 +1,373 @@ +package bots + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "net/url" + "strings" + "time" + "unicode/utf8" + + "telesrv/internal/domain" +) + +const ( + defaultMainAppShortName = "main" + requestedWebViewButtonTTL = 10 * time.Minute + webViewCustomMethodQueryTTL = 5 * time.Minute +) + +func (s *Service) UpsertBotApp(ctx context.Context, botUserID int64, app domain.BotApp) (domain.BotApp, int, error) { + clean, err := s.normalizeBotApp(ctx, botUserID, app) + if err != nil { + return domain.BotApp{}, 0, err + } + out, version, err := s.bots.UpsertBotApp(ctx, clean) + if err != nil { + return domain.BotApp{}, 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + return out, version, nil +} + +func (s *Service) EnsureMenuBotApp(ctx context.Context, botUserID int64, button domain.BotMenuButton) (domain.BotApp, int, error) { + if button.Type != domain.BotMenuButtonWebView { + return domain.BotApp{}, 0, nil + } + app, version, err := s.UpsertBotApp(ctx, botUserID, domain.BotApp{ + BotUserID: botUserID, + ShortName: defaultMainAppShortName, + Title: button.Text, + URL: button.URL, + Main: true, + RequestWriteAccess: true, + }) + if err != nil { + return domain.BotApp{}, 0, err + } + if _, err := s.UpsertAttachMenuBot(ctx, botUserID, domain.BotAttachMenuBot{ + BotUserID: botUserID, + AppID: app.ID, + ShortName: app.ShortName, + RequestWriteAccess: app.RequestWriteAccess, + ShowInAttachMenu: true, + ShowInSideMenu: true, + }); err != nil { + return domain.BotApp{}, 0, err + } + return app, version, nil +} + +func (s *Service) normalizeBotApp(ctx context.Context, botUserID int64, app domain.BotApp) (domain.BotApp, error) { + if s == nil || s.bots == nil || botUserID == 0 { + return domain.BotApp{}, domain.ErrBotAppInvalid + } + app.BotUserID = botUserID + app.ShortName = strings.ToLower(strings.TrimSpace(app.ShortName)) + app.Title = strings.TrimSpace(app.Title) + app.Description = strings.TrimSpace(app.Description) + app.URL = strings.TrimSpace(app.URL) + if !validBotAppShortName(app.ShortName) { + return domain.BotApp{}, domain.ErrBotAppShortNameInvalid + } + if app.Title == "" || utf8.RuneCountInString(app.Title) > domain.MaxBotAppTitleLen || + utf8.RuneCountInString(app.Description) > domain.MaxBotAppDescriptionLen || + len(app.URL) > domain.MaxBotAppURLLen || !validHTTPSURL(app.URL) { + return domain.BotApp{}, domain.ErrBotAppInvalid + } + if app.ID == 0 { + app.ID = stableBotAppInt64("bot-app-id", fmt.Sprint(botUserID), app.ShortName) + } + if app.AccessHash == 0 { + if existing, found, err := s.bots.GetBotAppByShortName(ctx, botUserID, app.ShortName); err == nil && found { + app.AccessHash = existing.AccessHash + if app.ID == 0 { + app.ID = existing.ID + } + } + } + if app.AccessHash == 0 { + app.AccessHash = stableBotAppInt64("bot-app-access", fmt.Sprint(botUserID), app.ShortName, app.URL) + } + app.Hash = botAppHash(app) + return app, nil +} + +func (s *Service) GetBotAppByID(ctx context.Context, appID, accessHash int64) (domain.BotApp, bool, error) { + if s == nil || s.bots == nil { + return domain.BotApp{}, false, nil + } + return s.bots.GetBotAppByID(ctx, appID, accessHash) +} + +func (s *Service) GetBotAppByShortName(ctx context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) { + if s == nil || s.bots == nil { + return domain.BotApp{}, false, nil + } + return s.bots.GetBotAppByShortName(ctx, botUserID, strings.ToLower(strings.TrimSpace(shortName))) +} + +func (s *Service) GetMainBotApp(ctx context.Context, botUserID int64) (domain.BotApp, bool, error) { + if s == nil || s.bots == nil { + return domain.BotApp{}, false, nil + } + return s.bots.GetMainBotApp(ctx, botUserID) +} + +func (s *Service) ListBotApps(ctx context.Context, botUserID int64) ([]domain.BotApp, error) { + if s == nil || s.bots == nil { + return nil, nil + } + return s.bots.ListBotApps(ctx, botUserID) +} + +func (s *Service) GetBotAppSettings(ctx context.Context, botUserID int64) (domain.BotAppSettings, bool, error) { + if s == nil || s.bots == nil { + return domain.BotAppSettings{}, false, nil + } + return s.bots.GetBotAppSettings(ctx, botUserID) +} + +func (s *Service) UpsertBotAppSettings(ctx context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) { + if s == nil || s.bots == nil || botUserID == 0 { + return 0, domain.ErrBotAppInvalid + } + version, err := s.bots.UpsertBotAppSettings(ctx, botUserID, settings) + if err != nil { + return 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + return version, nil +} + +func (s *Service) ListBotAppPreviewMedia(ctx context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) { + if s == nil || s.bots == nil { + return nil, nil + } + return s.bots.ListBotAppPreviewMedia(ctx, botUserID, appID) +} + +func (s *Service) UpsertBotAppPreviewMedia(ctx context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error) { + if s == nil || s.bots == nil { + return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid + } + if media.ID == 0 { + items, err := s.bots.ListBotAppPreviewMedia(ctx, media.BotUserID, media.AppID) + if err != nil { + return domain.BotAppPreviewMedia{}, 0, err + } + if len(items) >= domain.MaxBotPreviewMedia { + return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid + } + } + out, version, err := s.bots.UpsertBotAppPreviewMedia(ctx, media) + if err != nil { + return domain.BotAppPreviewMedia{}, 0, err + } + s.invalidateBotReadCaches(ctx, media.BotUserID) + return out, version, nil +} + +func (s *Service) DeleteBotAppPreviewMedia(ctx context.Context, botUserID, appID, mediaID int64) (int, error) { + version, err := s.bots.DeleteBotAppPreviewMedia(ctx, botUserID, appID, mediaID) + if err != nil { + return 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + return version, nil +} + +func (s *Service) ReorderBotAppPreviewMedia(ctx context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) { + version, err := s.bots.ReorderBotAppPreviewMedia(ctx, botUserID, appID, mediaIDs) + if err != nil { + return 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + return version, nil +} + +func (s *Service) UpsertAttachMenuBot(ctx context.Context, botUserID int64, bot domain.BotAttachMenuBot) (int, error) { + if s == nil || s.bots == nil || botUserID == 0 { + return 0, domain.ErrBotAttachMenuInvalid + } + bot.BotUserID = botUserID + bot.ShortName = strings.ToLower(strings.TrimSpace(bot.ShortName)) + if bot.ShortName == "" { + if app, found, err := s.GetMainBotApp(ctx, botUserID); err == nil && found { + bot.AppID = app.ID + bot.ShortName = app.ShortName + bot.HasSettings = app.HasSettings + bot.RequestWriteAccess = app.RequestWriteAccess + } + } + if !validBotAppShortName(bot.ShortName) { + return 0, domain.ErrBotAttachMenuInvalid + } + if len(bot.PeerTypes) == 0 { + bot.PeerTypes = []string{"pm", "chat", "megagroup", "broadcast"} + } + if len(bot.PeerTypes) > domain.MaxBotAttachMenuPeerTypes || len(bot.Icons) > domain.MaxBotAttachMenuIcons { + return 0, domain.ErrBotAttachMenuInvalid + } + if !bot.ShowInAttachMenu && !bot.ShowInSideMenu { + bot.ShowInAttachMenu = true + bot.ShowInSideMenu = true + } + version, err := s.bots.UpsertAttachMenuBot(ctx, bot) + if err != nil { + return 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + return version, nil +} + +func (s *Service) GetAttachMenuBot(ctx context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) { + if s == nil || s.bots == nil { + return domain.BotAttachMenuBot{}, false, nil + } + return s.bots.GetAttachMenuBot(ctx, botUserID) +} + +func (s *Service) ListAttachMenuBots(ctx context.Context) ([]domain.BotAttachMenuBot, error) { + if s == nil || s.bots == nil { + return nil, nil + } + return s.bots.ListAttachMenuBots(ctx) +} + +func (s *Service) GetAttachMenuState(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) { + if s == nil || s.bots == nil { + return domain.BotAttachMenuState{}, false, nil + } + return s.bots.GetAttachMenuState(ctx, userID, botUserID) +} + +func (s *Service) SetAttachMenuState(ctx context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) { + if s == nil || s.bots == nil { + return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid + } + return s.bots.SetAttachMenuState(ctx, state) +} + +func (s *Service) SaveRequestedWebViewButton(ctx context.Context, button domain.BotRequestedWebViewButton) (domain.BotRequestedWebViewButton, error) { + if s == nil || s.bots == nil || button.BotUserID == 0 || button.UserID == 0 || button.ButtonID == 0 { + return domain.BotRequestedWebViewButton{}, domain.ErrBotRequestedButtonInvalid + } + if button.WebAppReqID == "" { + rnd, err := randomInt64() + if err != nil { + return domain.BotRequestedWebViewButton{}, err + } + button.WebAppReqID = hex.EncodeToString([]byte(fmt.Sprintf("%d:%d:%d", button.BotUserID, button.UserID, rnd))) + } + if button.MaxQuantity <= 0 { + button.MaxQuantity = 1 + } + if button.MaxQuantity > domain.MaxBotRequestedPeerQuantity { + return domain.BotRequestedWebViewButton{}, domain.ErrBotRequestedButtonInvalid + } + now := s.now() + if button.CreatedAt.IsZero() { + button.CreatedAt = now + } + if button.ExpiresAt.IsZero() { + button.ExpiresAt = now.Add(requestedWebViewButtonTTL) + } + if err := s.bots.SaveRequestedWebViewButton(ctx, button); err != nil { + return domain.BotRequestedWebViewButton{}, err + } + return button, nil +} + +func (s *Service) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, reqID string) (domain.BotRequestedWebViewButton, bool, error) { + if s == nil || s.bots == nil { + return domain.BotRequestedWebViewButton{}, false, nil + } + return s.bots.GetRequestedWebViewButton(ctx, botUserID, userID, reqID) +} + +func (s *Service) DeleteRequestedWebViewButton(ctx context.Context, botUserID, userID int64, reqID string) error { + if s == nil || s.bots == nil { + return nil + } + return s.bots.DeleteRequestedWebViewButton(ctx, botUserID, userID, reqID) +} + +func (s *Service) SetBotEmojiStatusPermission(ctx context.Context, botUserID, userID int64, allowed bool) error { + if s == nil || s.bots == nil { + return domain.ErrBotNotFound + } + return s.bots.SetBotEmojiStatusPermission(ctx, botUserID, userID, allowed) +} + +func (s *Service) BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) { + if s == nil || s.bots == nil { + return false, nil + } + return s.bots.BotEmojiStatusPermission(ctx, botUserID, userID) +} + +func (s *Service) PutWebViewCustomMethodQuery(ctx context.Context, botUserID, userID int64, method, paramsJSON string) (domain.BotWebViewCustomMethodQuery, error) { + method = strings.TrimSpace(method) + if s == nil || s.bots == nil || botUserID == 0 || userID == 0 || method == "" || len(method) > domain.MaxBotCustomMethodLen || len(paramsJSON) > domain.MaxBotCustomMethodPayloadLen { + return domain.BotWebViewCustomMethodQuery{}, domain.ErrBotCustomMethodUnavailable + } + rnd, err := randomInt64() + if err != nil { + return domain.BotWebViewCustomMethodQuery{}, err + } + now := s.now() + query := domain.BotWebViewCustomMethodQuery{ + ID: fmt.Sprintf("%d:%d:%d:%d", botUserID, userID, now.UnixNano(), rnd), + BotUserID: botUserID, + UserID: userID, + CustomMethod: method, + ParamsJSON: paramsJSON, + CreatedAt: now, + ExpiresAt: now.Add(webViewCustomMethodQueryTTL), + } + if err := s.bots.PutWebViewCustomMethodQuery(ctx, query); err != nil { + return domain.BotWebViewCustomMethodQuery{}, err + } + return query, nil +} + +func validHTTPSURL(raw string) bool { + u, err := url.Parse(raw) + return err == nil && u.Scheme == "https" && u.Host != "" +} + +func validBotAppShortName(shortName string) bool { + if shortName == "" || len(shortName) > domain.MaxBotAppShortNameLen { + return false + } + for _, r := range shortName { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_': + default: + return false + } + } + return true +} + +func botAppHash(app domain.BotApp) int64 { + return stableBotAppInt64("bot-app-hash", fmt.Sprint(app.BotUserID), app.ShortName, app.Title, app.Description, app.URL, fmt.Sprint(app.PhotoID), fmt.Sprint(app.DocumentID), fmt.Sprint(app.Inactive), fmt.Sprint(app.RequestWriteAccess), fmt.Sprint(app.HasSettings), fmt.Sprint(app.Main)) +} + +func stableBotAppInt64(parts ...string) int64 { + h := sha256.New() + for _, part := range parts { + _, _ = h.Write([]byte(part)) + _, _ = h.Write([]byte{0}) + } + sum := h.Sum(nil) + value := int64(binary.BigEndian.Uint64(sum[:8]) & 0x7fffffffffffffff) + if value == 0 { + return 1 + } + return value +} diff --git a/internal/app/bots/profile_cache.go b/internal/app/bots/profile_cache.go new file mode 100644 index 00000000..09ba5233 --- /dev/null +++ b/internal/app/bots/profile_cache.go @@ -0,0 +1,130 @@ +package bots + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +const ( + botProfileCacheMaxEntries = 100000 + botProfileCacheTTL = 24 * time.Hour +) + +// botProfileValue 是缓存值,found=false 表示「查过但该 bot 不存在」(负缓存), +// 避免未注册 bot 的 id 反复打后端。 +type botProfileValue struct { + profile domain.BotProfile + found bool +} + +// botProfileCache 由统一缓存原语承载(LRU 单条驱逐 / epoch 守卫 / clone 内建)。 +// 单个走 GetOrLoad,批量走 GetOrLoadBatch(一次 LoadEpoch + 合批 load + per-key epoch 写回)。 +type botProfileCache struct { + cache *readmodelcache.Cache[int64, botProfileValue] +} + +func newBotProfileCache(max int, ttl time.Duration) *botProfileCache { + cache := readmodelcache.New[int64, botProfileValue](readmodelcache.Config[int64, botProfileValue]{ + MaxEntries: max, + TTL: ttl, + Clone: cloneBotProfileValue, + }) + if cache == nil { + return nil + } + return &botProfileCache{cache: cache} +} + +// getOrLoad 解析单个 bot;load 返回 (profile, found, err)。 +func (c *botProfileCache) getOrLoad(ctx context.Context, botUserID int64, load func() (domain.BotProfile, bool, error)) (domain.BotProfile, bool, error) { + if c == nil || botUserID == 0 { + return load() + } + v, err := c.cache.GetOrLoad(ctx, botUserID, func() (botProfileValue, error) { + profile, found, err := load() + if err != nil { + return botProfileValue{}, err + } + return botProfileValue{profile: normalizeBotProfile(botUserID, profile, found), found: found}, nil + }) + if err != nil { + return domain.BotProfile{}, false, err + } + return v.profile, v.found, nil +} + +// getMany 批量解析;loadMissing 返回 misses 的 profiles(仅存在的;缺失即视为负结果)。 +// 返回的 map 只含存在(found)的 bot,与旧 getMany 语义一致。 +func (c *botProfileCache) getMany(ctx context.Context, ids []int64, loadMissing func(context.Context, []int64) (map[int64]domain.BotProfile, error)) (map[int64]domain.BotProfile, error) { + unique := uniqueBotUserIDs(ids) + if c == nil { + return loadMissing(ctx, unique) + } + values, err := c.cache.GetOrLoadBatch(ctx, unique, + func(int64) (int64, bool) { return 0, true }, // 纯 TTL,无版本闸门 + func(ctx context.Context, missing []int64) (map[int64]botProfileValue, error) { + loaded, err := loadMissing(ctx, missing) + if err != nil { + return nil, err + } + out := make(map[int64]botProfileValue, len(missing)) + for _, id := range missing { + profile, found := loaded[id] + out[id] = botProfileValue{profile: normalizeBotProfile(id, profile, found), found: found} + } + return out, nil + }) + if err != nil { + return nil, err + } + out := make(map[int64]domain.BotProfile, len(values)) + for id, v := range values { + if v.found { + out[id] = v.profile + } + } + return out, nil +} + +func (c *botProfileCache) put(botUserID int64, profile domain.BotProfile, found bool) { + if c == nil || botUserID == 0 { + return + } + c.cache.Store(botUserID, botProfileValue{profile: normalizeBotProfile(botUserID, profile, found), found: found}) +} + +func (c *botProfileCache) delete(botUserID int64) { + if c == nil || botUserID == 0 { + return + } + c.cache.Invalidate(botUserID) +} + +func (c *botProfileCache) flush() { + if c == nil { + return + } + c.cache.Flush() +} + +func normalizeBotProfile(botUserID int64, profile domain.BotProfile, found bool) domain.BotProfile { + if found && profile.BotUserID == 0 { + profile.BotUserID = botUserID + } + return profile +} + +func cloneBotProfileValue(v botProfileValue) botProfileValue { + v.profile = cloneBotProfile(v.profile) + return v +} + +func cloneBotProfile(profile domain.BotProfile) domain.BotProfile { + if len(profile.Commands) > 0 { + profile.Commands = append([]domain.BotCommand(nil), profile.Commands...) + } + return profile +} diff --git a/internal/app/bots/service.go b/internal/app/bots/service.go new file mode 100644 index 00000000..ee805a60 --- /dev/null +++ b/internal/app/bots/service.go @@ -0,0 +1,645 @@ +// Package bots 实现 bot 账号业务:BotFather 对话状态机、bot 创建、token 管理与 +// botInfo 查询。bot 登录(auth.importBotAuthorization)在 app/auth 经 store.BotStore +// 直接校验 token,不依赖本包。 +package bots + +import ( + "context" + "crypto/rand" + "fmt" + "net/url" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// blockChecker 报告 userID 是否 block 了 blockedUserID(store.ContactStore 子集)。 +type blockChecker interface { + IsBlocked(ctx context.Context, userID, blockedUserID int64) (bool, error) +} + +type publicChannelUsernameResolver interface { + ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) +} + +// RouterHooks 是 rpc 层回调(router 创建后经 SetRouterHooks 延迟注入,打破 +// router↔bots 的构造循环;两个能力都依赖 tg.*/连接层边界,不能在 app 层实现): +// - RevokeBotSessions:token revoke 后撤销 bot 的全部已登录 session(删 +// authorization + 强制断连)。 +// - PushBotCommandsChanged:命令变更后给在线相关用户推 updateBotCommands +// (无 pts 的 ephemeral update,离线用户靠 bot_info_version bump 兜底)。 +type RouterHooks interface { + RevokeBotSessions(ctx context.Context, botUserID int64) error + PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand) +} + +// replyLockStripes 是回复串行化条带数:同一用户的 BotFather 回复落同一条带、 +// 串行执行(状态机 RMW 原子 + 回复保序),不同用户并发;固定大小不随用户数增长。 +const replyLockStripes = 256 + +// Service 提供 bot 账号业务。 +type Service struct { + users store.UserStore + bots store.BotStore + messages store.MessageStore + blocker blockChecker + channels publicChannelUsernameResolver + hooks RouterHooks + userCache store.UserCache + cache *botProfileCache + log *zap.Logger + now func() time.Time + // replySeq 是回复 randomID 在 crypto/rand 失败时的兜底单调序列。 + replySeq atomic.Int64 + replyLocks [replyLockStripes]sync.Mutex +} + +// Option 调整 bots 服务的可选依赖。 +type Option func(*Service) + +// WithLogger 注入日志器(缺省 zap.NewNop)。 +func WithLogger(log *zap.Logger) Option { + return func(s *Service) { + if log != nil { + s.log = log + } + } +} + +// WithNow 注入时钟(测试用)。 +func WithNow(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +// WithBlockChecker 注入 block 关系查询:BotFather 回复前据此设置 RecipientBlocked, +// 用户 block 掉 BotFather 后不再向其收件箱投递(对齐 rpc 发送路径语义)。 +func WithBlockChecker(c blockChecker) Option { + return func(s *Service) { + if c != nil { + s.blocker = c + } + } +} + +// WithPublicChannelUsernameResolver 注入公开频道 username 查询能力,用于 bot +// username 预检,避免 bot 与 public channel 产生同名可见入口。 +func WithPublicChannelUsernameResolver(c publicChannelUsernameResolver) Option { + return func(s *Service) { + if c != nil { + s.channels = c + } + } +} + +// WithUserCache 注入 users 基础资料缓存:bot 元数据写入(first_name/about/ +// bot_info_version bump)后必须失效该 bot 的缓存条目,否则 TTL 内 getUsers +// 返回旧 first_name 与旧 bot_info_version——version bump 被缓存遮蔽,客户端 +// 感知不到变更、不会重拉 getFullUser。 +func WithUserCache(c store.UserCache) Option { + return func(s *Service) { + if c != nil { + s.userCache = c + } + } +} + +// invalidateUserCache 在 bot 的 users 行变更(含 version bump)后清缓存。 +// 失效失败只记日志:缓存最长 TTL 后自愈,不阻塞写路径。 +func (s *Service) invalidateUserCache(ctx context.Context, botUserID int64) { + if s.userCache == nil { + return + } + if err := s.userCache.Delete(ctx, []int64{botUserID}); err != nil { + s.log.Warn("invalidate bot user cache", zap.Int64("bot_user_id", botUserID), zap.Error(err)) + } +} + +func (s *Service) invalidateBotProfileCache(botUserID int64) { + if s.cache != nil { + s.cache.delete(botUserID) + } +} + +func (s *Service) invalidateBotReadCaches(ctx context.Context, botUserID int64) { + s.invalidateBotProfileCache(botUserID) + s.invalidateUserCache(ctx, botUserID) +} + +// InvalidateBotProfileReadModel 供 ReadModelChangeListener 在 user_base 事件(bot 写会 +// bump bot_info_version)时跨实例失效本进程 bot 资料缓存。 +func (s *Service) InvalidateBotProfileReadModel(userID int64) { + if s == nil { + return + } + s.invalidateBotProfileCache(userID) +} + +// FlushBotProfileReadModel 供 listener 重连时整表 flush,兜住断连窗口内丢失的 user_base 通知。 +func (s *Service) FlushBotProfileReadModel() { + if s == nil || s.cache == nil { + return + } + s.cache.flush() +} + +// SetRouterHooks 注入 rpc 层回调(router 创建后装配,与 P1 的 +// SetLifecycleObserver 同款延迟注入)。 +func (s *Service) SetRouterHooks(h RouterHooks) { + if s != nil { + s.hooks = h + } +} + +// NewService 创建 bots 服务。 +func NewService(users store.UserStore, bots store.BotStore, messages store.MessageStore, opts ...Option) *Service { + s := &Service{ + users: users, + bots: bots, + messages: messages, + cache: newBotProfileCache(botProfileCacheMaxEntries, botProfileCacheTTL), + log: zap.NewNop(), + now: time.Now, + } + for _, opt := range opts { + opt(s) + } + return s +} + +func (s *Service) botProfile(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) { + if s == nil || s.bots == nil || botUserID == 0 { + return domain.BotProfile{}, false, nil + } + if s.cache != nil { + return s.cache.getOrLoad(ctx, botUserID, func() (domain.BotProfile, bool, error) { + return s.bots.GetBot(ctx, botUserID) + }) + } + return s.bots.GetBot(ctx, botUserID) +} + +func (s *Service) botProfiles(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) { + if s == nil || s.bots == nil || len(botUserIDs) == 0 { + return nil, nil + } + ids := uniqueBotUserIDs(botUserIDs) + if len(ids) == 0 { + return nil, nil + } + if s.cache == nil { + return s.loadBotProfiles(ctx, ids) + } + return s.cache.getMany(ctx, ids, s.loadBotProfiles) +} + +func (s *Service) loadBotProfiles(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) { + if batch, ok := s.bots.(botBatchStore); ok { + return batch.GetBots(ctx, botUserIDs) + } + out := make(map[int64]domain.BotProfile) + for _, id := range uniqueBotUserIDs(botUserIDs) { + profile, found, err := s.bots.GetBot(ctx, id) + if err != nil { + return nil, err + } + if found { + out[id] = profile + } + } + return out, nil +} + +// BotInfo 返回 bot 的元数据(userFull.bot_info hydrate 用)。 +func (s *Service) BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) { + return s.botProfile(ctx, botUserID) +} + +type botBatchStore interface { + GetBots(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) +} + +// BotInfos 批量返回 bot 元数据,供频道 full info / participants 这类高频富化路径避免逐 bot 点查。 +func (s *Service) BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) { + return s.botProfiles(ctx, botUserIDs) +} + +func uniqueBotUserIDs(ids []int64) []int64 { + if len(ids) == 0 { + return nil + } + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +// CheckUsername 校验 bot username 语法与全局可见入口占用(users + public channels)。 +func (s *Service) CheckUsername(ctx context.Context, ownerUserID int64, username string) (bool, error) { + if s == nil || s.users == nil || ownerUserID == 0 { + return false, domain.ErrBotUsernameInvalid + } + username = strings.TrimSpace(strings.TrimPrefix(username, "@")) + if !domain.ValidBotUsername(username) { + return false, domain.ErrBotUsernameInvalid + } + if _, found, err := s.users.ByUsername(ctx, username); err != nil { + return false, err + } else if found { + return false, nil + } + if s.channels != nil { + if _, found, err := s.channels.ResolvePublicChannelUsername(ctx, ownerUserID, username); err != nil { + return false, err + } else if found { + return false, nil + } + } + return true, nil +} + +// CreateBot 创建一个新 bot 账号:users 行(is_bot, bot_info_version=1, 无 phone)+ +// bots 行(owner、token)。返回新账号与完整 token(唯一一次返回明文的途径之一)。 +func (s *Service) CreateBot(ctx context.Context, ownerUserID int64, name, username string) (domain.User, string, error) { + if s == nil || s.users == nil || s.bots == nil || ownerUserID == 0 { + return domain.User{}, "", domain.ErrBotNameInvalid + } + name = strings.TrimSpace(name) + if name == "" || utf8.RuneCountInString(name) > domain.MaxBotNameLength { + return domain.User{}, "", domain.ErrBotNameInvalid + } + username = strings.TrimSpace(strings.TrimPrefix(username, "@")) + if !domain.ValidBotUsername(username) { + return domain.User{}, "", domain.ErrBotUsernameInvalid + } + ok, err := s.CheckUsername(ctx, ownerUserID, username) + if err != nil { + return domain.User{}, "", err + } + if !ok { + return domain.User{}, "", domain.ErrUsernameOccupied + } + count, err := s.bots.CountBotsByOwner(ctx, ownerUserID) + if err != nil { + return domain.User{}, "", err + } + if count >= domain.MaxBotsPerOwner { + return domain.User{}, "", domain.ErrBotsTooMany + } + accessHash, err := randomInt64() + if err != nil { + return domain.User{}, "", err + } + secret, err := randomTokenSecret() + if err != nil { + return domain.User{}, "", err + } + u, profile, err := s.bots.CreateBotAccount(ctx, domain.User{ + AccessHash: accessHash, + FirstName: name, + Username: username, + Bot: true, + BotInfoVersion: 1, + }, domain.BotProfile{ + OwnerUserID: ownerUserID, + TokenSecret: secret, + }) + if err != nil { + return domain.User{}, "", err + } + if s.cache != nil { + s.cache.put(u.ID, profile, true) + } + return u, domain.FormatBotToken(u.ID, secret), nil +} + +// ListOwnedBots 返回当前 owner 管理的 bot 用户列表(排除 BotFather 种子)。 +func (s *Service) ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domain.User, error) { + owned, err := s.ownedBots(ctx, ownerUserID) + if err != nil { + return nil, err + } + out := make([]domain.User, 0, len(owned)) + for _, item := range owned { + out = append(out, item.user) + } + return out, nil +} + +// ExportBotToken 返回 bot token;revoke=true 时先轮换 secret 并撤销已登录 session。 +func (s *Service) ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) { + if revoke { + return s.RevokeBotToken(ctx, ownerUserID, botUserID) + } + profile, found, err := s.botProfile(ctx, botUserID) + if err != nil { + return "", err + } + if !found || profile.OwnerUserID != ownerUserID || botUserID == domain.BotFatherUserID || profile.TokenSecret == "" { + return "", domain.ErrBotNotFound + } + return domain.FormatBotToken(botUserID, profile.TokenSecret), nil +} + +// RevokeBotToken 生成新 token 随机段并落库;旧 token 立即不可登录,并踢掉所有 +// 已凭旧 token 登录的 session(经注入的 SessionRevoker)。 +func (s *Service) RevokeBotToken(ctx context.Context, ownerUserID, botUserID int64) (string, error) { + profile, found, err := s.botProfile(ctx, botUserID) + if err != nil { + return "", err + } + if !found || profile.OwnerUserID != ownerUserID || botUserID == domain.BotFatherUserID { + return "", domain.ErrBotNotFound + } + secret, err := randomTokenSecret() + if err != nil { + return "", err + } + if err := s.bots.UpdateBotTokenSecret(ctx, botUserID, secret); err != nil { + return "", err + } + s.invalidateBotProfileCache(botUserID) + token := domain.FormatBotToken(botUserID, secret) + // 撤销已登录 session:旧 token 已不可重新登录,但已建立的连接仍持有 auth_key, + // 必须主动失效(删 authorization + 断连),否则旧持有者继续以 bot 身份操作。 + // secret 已轮换不可回滚,故失败时仍返回新 token,但透出 ErrBotSessionsNotRevoked + // 让调用方诚实告知用户「需重试以确保旧 session 终止」,绝不谎称已止血。 + if s.hooks != nil { + if err := s.hooks.RevokeBotSessions(ctx, botUserID); err != nil { + s.log.Warn("revoke bot sessions", zap.Int64("bot_user_id", botUserID), zap.Error(err)) + return token, domain.ErrBotSessionsNotRevoked + } + } + return token, nil +} + +// SetBotCommands 覆盖式写入 bot 的 default scope 命令(bots.setBotCommands / +// BotFather /setcommands 共用收口)。校验命令名/描述/数量;写库(含 version bump) +// 成功后给在线相关用户推 updateBotCommands。返回 bump 后的 bot_info_version。 +func (s *Service) SetBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error) { + if len(commands) > domain.MaxBotCommands { + return 0, domain.ErrBotCommandInvalid + } + clean := make([]domain.BotCommand, 0, len(commands)) + for _, c := range commands { + cmd := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(c.Command, "/"))) + desc := strings.TrimSpace(c.Description) + if !domain.ValidBotCommandName(cmd) || desc == "" || len(desc) > domain.MaxBotCommandDescriptionLen { + return 0, domain.ErrBotCommandInvalid + } + clean = append(clean, domain.BotCommand{Command: cmd, Description: desc}) + } + // 同值短路:bot 框架启动时普遍无条件重发相同命令集,跳过可避免无意义的 + // bot_info_version bump(驱动全体客户端多打一轮 getFullUser)与多余推送。 + // 非原子(读后他写不影响正确性:要么对方已 bump、要么我们多 bump 一次)。 + cur, found, err := s.botProfile(ctx, botUserID) + if err != nil { + return 0, err + } + if !found { + return 0, domain.ErrBotNotFound + } + if botCommandsEqual(cur.Commands, clean) { + return 0, nil // 无变更;调用方忽略返回的 version + } + version, err := s.bots.UpdateBotCommands(ctx, botUserID, clean) + if err != nil { + return 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + if s.hooks != nil { + s.hooks.PushBotCommandsChanged(ctx, botUserID, clean) + } + return version, nil +} + +func botCommandsEqual(a, b []domain.BotCommand) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Command != b[i].Command || a[i].Description != b[i].Description { + return false + } + } + return true +} + +// GetBotCommands 返回 bot 的 default scope 命令。 +func (s *Service) GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error) { + profile, found, err := s.botProfile(ctx, botUserID) + if err != nil { + return nil, err + } + if !found { + return nil, domain.ErrBotNotFound + } + return profile.Commands, nil +} + +// SetBotInfo 更新 bot 的 name(users.first_name)/about(users.about)/description +// (bots.description),返回 bump 后的 bot_info_version。 +func (s *Service) SetBotInfo(ctx context.Context, botUserID int64, upd domain.BotInfoUpdate) (int, error) { + if upd.SetName { + upd.Name = strings.TrimSpace(upd.Name) + if upd.Name == "" || utf8.RuneCountInString(upd.Name) > domain.MaxBotNameLength { + return 0, domain.ErrBotInfoInvalid + } + } + if upd.SetAbout && utf8.RuneCountInString(upd.About) > domain.MaxBotAboutLen { + return 0, domain.ErrBotInfoInvalid + } + if upd.SetDescription && utf8.RuneCountInString(upd.Description) > domain.MaxBotDescriptionLen { + return 0, domain.ErrBotInfoInvalid + } + if !upd.SetName && !upd.SetAbout && !upd.SetDescription { + return 0, domain.ErrBotInfoInvalid + } + version, err := s.bots.UpdateBotInfo(ctx, botUserID, upd) + if err != nil { + return 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + return version, nil +} + +// GetBotInfo 返回 bot 的 name/about/description(name=users.first_name、 +// about=users.about、description=bots.description)。 +func (s *Service) GetBotInfo(ctx context.Context, botUserID int64) (name, about, description string, err error) { + profile, found, err := s.botProfile(ctx, botUserID) + if err != nil { + return "", "", "", err + } + if !found { + return "", "", "", domain.ErrBotNotFound + } + u, found, err := s.users.ByID(ctx, botUserID) + if err != nil { + return "", "", "", err + } + if !found { + return "", "", "", domain.ErrBotNotFound + } + return u.FirstName, u.About, profile.Description, nil +} + +// SetBotMenuButton 设置 bot 的 menu button(per-bot 全局),返回新 bot_info_version。 +func (s *Service) SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error) { + switch button.Type { + case domain.BotMenuButtonDefault, domain.BotMenuButtonCommands: + button.Text, button.URL = "", "" + case domain.BotMenuButtonWebView: + button.Text = strings.TrimSpace(button.Text) + button.URL = strings.TrimSpace(button.URL) + if button.Text == "" || len(button.Text) > domain.MaxBotMenuButtonTextLen || + button.URL == "" || len(button.URL) > domain.MaxBotMenuButtonURLLen { + return 0, domain.ErrBotMenuButtonInvalid + } + // 强制 https(对齐官方 BUTTON_URL_INVALID):menu button URL 经 + // userFull.bot_info.menu_button 下发给所有交互用户的客户端 webview 入口, + // 拒绝 javascript:/file:/intent: 等非 https scheme,防 bot 投毒。 + if u, err := url.Parse(button.URL); err != nil || u.Scheme != "https" || u.Host == "" { + return 0, domain.ErrBotMenuButtonInvalid + } + default: + return 0, domain.ErrBotMenuButtonInvalid + } + version, err := s.bots.UpdateBotMenuButton(ctx, botUserID, button) + if err != nil { + return 0, err + } + if button.Type == domain.BotMenuButtonWebView { + if _, _, err := s.EnsureMenuBotApp(ctx, botUserID, button); err != nil { + return 0, err + } + } + s.invalidateBotReadCaches(ctx, botUserID) + return version, nil +} + +// GetBotMenuButton 返回 bot 的 menu button。 +func (s *Service) GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error) { + profile, found, err := s.botProfile(ctx, botUserID) + if err != nil { + return domain.BotMenuButton{}, err + } + if !found { + return domain.BotMenuButton{}, domain.ErrBotNotFound + } + return profile.MenuButton, nil +} + +// SetInlinePlaceholder 设置 inline mode placeholder;空字符串表示关闭 inline mode。 +func (s *Service) SetInlinePlaceholder(ctx context.Context, botUserID int64, placeholder string) (int, error) { + placeholder = strings.TrimSpace(placeholder) + if utf8.RuneCountInString(placeholder) > domain.MaxBotInlinePlaceholderLen { + return 0, domain.ErrBotInlinePlaceholderInvalid + } + version, err := s.bots.SetBotInlinePlaceholder(ctx, botUserID, placeholder) + if err != nil { + return 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + return version, nil +} + +// SetInlineGeo 设置 bot 是否可在 inline query 中接收用户位置。 +func (s *Service) SetInlineGeo(ctx context.Context, botUserID int64, enabled bool) (int, error) { + version, err := s.bots.SetBotInlineGeo(ctx, botUserID, enabled) + if err != nil { + return 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + return version, nil +} + +// SetJoinGroups 设置 bot 能否被加入群组(allow=true → bot_nochats=false)。 +func (s *Service) SetJoinGroups(ctx context.Context, botUserID int64, allow bool) (int, error) { + version, err := s.bots.SetBotNochats(ctx, botUserID, !allow) + if err != nil { + return 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + return version, nil +} + +// SetPrivacy 设置 bot 群内 privacy mode(enabled=true 隐私模式开 → bot_chat_history=false, +// 即 bot 只看命令/回复;enabled=false → 关闭隐私 → bot_chat_history=true,能看全部消息)。 +func (s *Service) SetPrivacy(ctx context.Context, botUserID int64, enabled bool) (int, error) { + version, err := s.bots.SetBotChatHistory(ctx, botUserID, !enabled) + if err != nil { + return 0, err + } + s.invalidateBotReadCaches(ctx, botUserID) + return version, nil +} + +// CanSendMessage reports whether botUserID has explicit permission to initiate +// direct messages with userID. +func (s *Service) CanSendMessage(ctx context.Context, userID, botUserID int64) (bool, error) { + if s == nil || s.bots == nil || userID == 0 || botUserID == 0 || userID == botUserID { + return false, nil + } + return s.bots.CanBotSendMessage(ctx, botUserID, userID) +} + +// AllowSendMessage records an explicit user grant for botUserID to message userID. +func (s *Service) AllowSendMessage(ctx context.Context, userID, botUserID int64, fromRequest bool) (bool, error) { + if s == nil || s.bots == nil || userID == 0 || botUserID == 0 || userID == botUserID { + return false, domain.ErrBotNotFound + } + return s.bots.AllowBotSendMessage(ctx, botUserID, userID, fromRequest) +} + +// OwnsBot 报告 ownerUserID 是否为 botUserID 的 owner(非 BotFather 自身)。 +func (s *Service) OwnsBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) { + profile, found, err := s.botProfile(ctx, botUserID) + if err != nil { + return false, err + } + return found && profile.OwnerUserID == ownerUserID && botUserID != domain.BotFatherUserID, nil +} + +// tokenSecretAlphabet 对齐官方 token 随机段字符集。 +const tokenSecretAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" + +func randomTokenSecret() (string, error) { + raw := make([]byte, domain.BotTokenSecretLength) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("rand: %w", err) + } + out := make([]byte, len(raw)) + for i, b := range raw { + out[i] = tokenSecretAlphabet[int(b)%len(tokenSecretAlphabet)] + } + return string(out), nil +} + +func randomInt64() (int64, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, fmt.Errorf("rand: %w", err) + } + v := int64(uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 | + uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])) + return v, nil +} diff --git a/internal/app/bots/service_test.go b/internal/app/bots/service_test.go new file mode 100644 index 00000000..29de4f22 --- /dev/null +++ b/internal/app/bots/service_test.go @@ -0,0 +1,438 @@ +package bots + +import ( + "context" + "fmt" + "regexp" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func newTestService(t *testing.T) (*Service, *memory.UserStore, *memory.BotStore, *memory.MessageStore) { + t.Helper() + users := memory.NewUserStore() + bots := memory.NewBotStore(users) + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + return NewService(users, bots, messages), users, bots, messages +} + +func newOwner(t *testing.T, users *memory.UserStore, phone string) domain.User { + t.Helper() + u, err := users.Create(context.Background(), domain.User{AccessHash: 1, Phone: phone, FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + return u +} + +// sendToBotFather 同步驱动 responder(绕过 OnPrivateMessage 的 goroutine 派发以 +// 保证单测确定性;异步派发由 mtprotoedge bot e2e 覆盖),返回 BotFather 最新回复文本。 +func sendToBotFather(t *testing.T, svc *Service, messages *memory.MessageStore, owner domain.User, text string) string { + t.Helper() + ctx := context.Background() + svc.respondAsBotFather(owner.ID, text) + list, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID}, + Limit: 100, + }) + if err != nil { + t.Fatalf("list history: %v", err) + } + var latest domain.Message + for _, msg := range list.Messages { + if msg.From.ID == domain.BotFatherUserID && msg.ID > latest.ID { + latest = msg + } + } + if latest.ID == 0 { + t.Fatalf("no BotFather reply after sending %q", text) + } + return latest.Body +} + +var tokenRe = regexp.MustCompile(`(\d+):([A-Za-z0-9_-]{35})`) + +func TestBotFatherNewBotFlow(t *testing.T) { + svc, users, bots, messages := newTestService(t) + owner := newOwner(t, users, "+1000") + ctx := context.Background() + + if reply := sendToBotFather(t, svc, messages, owner, "/start"); !strings.Contains(reply, "/newbot") { + t.Fatalf("/start reply = %q, want help text", reply) + } + if reply := sendToBotFather(t, svc, messages, owner, "/newbot"); !strings.Contains(reply, "choose a name") { + t.Fatalf("/newbot reply = %q, want name prompt", reply) + } + if reply := sendToBotFather(t, svc, messages, owner, "My Test Bot"); !strings.Contains(reply, "username") { + t.Fatalf("name reply = %q, want username prompt", reply) + } + // 非法 username:不以 bot 结尾。 + if reply := sendToBotFather(t, svc, messages, owner, "mytest"); !strings.Contains(reply, "invalid") { + t.Fatalf("invalid username reply = %q, want invalid notice", reply) + } + reply := sendToBotFather(t, svc, messages, owner, "my_test_bot") + match := tokenRe.FindStringSubmatch(reply) + if match == nil { + t.Fatalf("done reply = %q, want token", reply) + } + if !strings.Contains(reply, "telesrv.net/my_test_bot") { + t.Fatalf("done reply = %q, want deep link", reply) + } + + created, found, err := users.ByUsername(ctx, "my_test_bot") + if err != nil || !found { + t.Fatalf("bot user not found: %v", err) + } + if !created.Bot || created.BotInfoVersion < 1 || created.Phone != "" { + t.Fatalf("bot user = %+v, want bot with bot_info_version>=1 and empty phone", created) + } + profile, found, err := bots.GetBot(ctx, created.ID) + if err != nil || !found { + t.Fatalf("bot profile not found: %v", err) + } + if profile.OwnerUserID != owner.ID { + t.Fatalf("bot owner = %d, want %d", profile.OwnerUserID, owner.ID) + } + if fmt.Sprintf("%d", created.ID) != match[1] || profile.TokenSecret != match[2] { + t.Fatalf("token %q does not match stored bot %d/%q", match[0], created.ID, profile.TokenSecret) + } + // 状态机已复位:普通文本回兜底提示。 + if reply := sendToBotFather(t, svc, messages, owner, "hello"); !strings.Contains(reply, "/help") { + t.Fatalf("post-done reply = %q, want fallback", reply) + } +} + +func TestBotProfileCacheCachesPositiveAndNegativeProfiles(t *testing.T) { + users := memory.NewUserStore() + botsStore := &countingBotStore{BotStore: memory.NewBotStore(users)} + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + svc := NewService(users, botsStore, messages) + owner := newOwner(t, users, "+1090") + ctx := context.Background() + + bot, _, err := svc.CreateBot(ctx, owner.ID, "Cache Bot", "cache_test_bot") + if err != nil { + t.Fatalf("create bot: %v", err) + } + + botsStore.reset() + if _, found, err := svc.BotInfo(ctx, 424242); err != nil || found { + t.Fatalf("negative BotInfo = found %v err %v, want false,nil", found, err) + } + if _, found, err := svc.BotInfo(ctx, 424242); err != nil || found { + t.Fatalf("second negative BotInfo = found %v err %v, want false,nil", found, err) + } + if botsStore.getBotCalls != 1 { + t.Fatalf("negative GetBot calls = %d, want 1", botsStore.getBotCalls) + } + + botsStore.reset() + if profile, found, err := svc.BotInfo(ctx, bot.ID); err != nil || !found || profile.BotUserID != bot.ID { + t.Fatalf("cached positive BotInfo = profile %+v found %v err %v", profile, found, err) + } + if _, found, err := svc.BotInfo(ctx, bot.ID); err != nil || !found { + t.Fatalf("second positive BotInfo = found %v err %v, want true,nil", found, err) + } + if botsStore.getBotCalls != 0 { + t.Fatalf("positive GetBot calls after create prewarm = %d, want 0", botsStore.getBotCalls) + } + + botsStore.reset() + profiles, err := svc.BotInfos(ctx, []int64{bot.ID, 424242, 424243, 424243}) + if err != nil { + t.Fatalf("batch BotInfos: %v", err) + } + if len(profiles) != 1 || profiles[bot.ID].BotUserID != bot.ID { + t.Fatalf("batch profiles = %+v, want only bot", profiles) + } + if botsStore.getBotsCalls != 1 { + t.Fatalf("batch GetBots calls = %d, want 1 for new miss", botsStore.getBotsCalls) + } + if _, err := svc.BotInfos(ctx, []int64{bot.ID, 424242, 424243}); err != nil { + t.Fatalf("second batch BotInfos: %v", err) + } + if botsStore.getBotsCalls != 1 { + t.Fatalf("second batch GetBots calls = %d, want still 1", botsStore.getBotsCalls) + } + + if _, err := svc.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "start", Description: "begin"}}); err != nil { + t.Fatalf("set commands: %v", err) + } + botsStore.reset() + profile, found, err := svc.BotInfo(ctx, bot.ID) + if err != nil || !found { + t.Fatalf("BotInfo after invalidation = found %v err %v", found, err) + } + if len(profile.Commands) != 1 || profile.Commands[0].Command != "start" { + t.Fatalf("commands after invalidation = %+v, want [start]", profile.Commands) + } + if botsStore.getBotCalls != 1 { + t.Fatalf("GetBot calls after invalidation = %d, want 1", botsStore.getBotCalls) + } +} + +type countingBotStore struct { + *memory.BotStore + getBotCalls int + getBotsCalls int +} + +func (s *countingBotStore) reset() { + s.getBotCalls = 0 + s.getBotsCalls = 0 +} + +func (s *countingBotStore) GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) { + s.getBotCalls++ + return s.BotStore.GetBot(ctx, botUserID) +} + +func (s *countingBotStore) GetBots(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) { + s.getBotsCalls++ + return s.BotStore.GetBots(ctx, botUserIDs) +} + +func TestBotFatherCancelAndUnknown(t *testing.T) { + svc, users, _, messages := newTestService(t) + owner := newOwner(t, users, "+1001") + + if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "No active command") { + t.Fatalf("idle /cancel reply = %q", reply) + } + sendToBotFather(t, svc, messages, owner, "/newbot") + if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "cancelled") { + t.Fatalf("active /cancel reply = %q", reply) + } + // 取消后名字输入不再被当作 newbot 步骤。 + if reply := sendToBotFather(t, svc, messages, owner, "Some Name"); !strings.Contains(reply, "/help") { + t.Fatalf("post-cancel reply = %q, want fallback", reply) + } + if reply := sendToBotFather(t, svc, messages, owner, "/definitelynotacommand"); !strings.Contains(reply, "Unrecognized") { + t.Fatalf("unknown command reply = %q", reply) + } +} + +func TestBotFatherUsernameTaken(t *testing.T) { + svc, users, _, messages := newTestService(t) + owner := newOwner(t, users, "+1002") + + if _, _, err := svc.CreateBot(context.Background(), owner.ID, "First", "taken_bot"); err != nil { + t.Fatalf("seed first bot: %v", err) + } + sendToBotFather(t, svc, messages, owner, "/newbot") + sendToBotFather(t, svc, messages, owner, "Second") + if reply := sendToBotFather(t, svc, messages, owner, "taken_bot"); !strings.Contains(reply, "already taken") { + t.Fatalf("taken username reply = %q", reply) + } + // 状态保留:可继续尝试新 username。 + if reply := sendToBotFather(t, svc, messages, owner, "second_bot"); !strings.Contains(reply, "telesrv.net/second_bot") { + t.Fatalf("retry username reply = %q", reply) + } +} + +type usernameResolverStub struct { + taken string +} + +func (s usernameResolverStub) ResolvePublicChannelUsername(_ context.Context, _ int64, username string) (domain.Channel, bool, error) { + return domain.Channel{}, strings.EqualFold(username, s.taken), nil +} + +func TestCheckUsernameRejectsUserAndPublicChannelCollision(t *testing.T) { + users := memory.NewUserStore() + botsStore := memory.NewBotStore(users) + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + svc := NewService(users, botsStore, messages, WithPublicChannelUsernameResolver(usernameResolverStub{taken: "channel_bot"})) + owner := newOwner(t, users, "+1012") + ctx := context.Background() + + if ok, err := svc.CheckUsername(ctx, owner.ID, "fresh_bot"); err != nil || !ok { + t.Fatalf("fresh username = %v,%v, want true,nil", ok, err) + } + if _, _, err := svc.CreateBot(ctx, owner.ID, "Taken", "user_taken_bot"); err != nil { + t.Fatalf("seed bot: %v", err) + } + if ok, err := svc.CheckUsername(ctx, owner.ID, "user_taken_bot"); err != nil || ok { + t.Fatalf("user collision = %v,%v, want false,nil", ok, err) + } + if ok, err := svc.CheckUsername(ctx, owner.ID, "channel_bot"); err != nil || ok { + t.Fatalf("channel collision = %v,%v, want false,nil", ok, err) + } + if _, _, err := svc.CreateBot(ctx, owner.ID, "Channel Collision", "channel_bot"); err != domain.ErrUsernameOccupied { + t.Fatalf("create channel collision err = %v, want ErrUsernameOccupied", err) + } + if _, err := svc.CheckUsername(ctx, owner.ID, "notvalid"); err != domain.ErrBotUsernameInvalid { + t.Fatalf("invalid username err = %v, want ErrBotUsernameInvalid", err) + } +} + +func TestBotFatherTokenAndRevoke(t *testing.T) { + svc, users, bots, messages := newTestService(t) + owner := newOwner(t, users, "+1003") + ctx := context.Background() + + created, token, err := svc.CreateBot(ctx, owner.ID, "Token Bot", "tok_test_bot") + if err != nil { + t.Fatalf("create bot: %v", err) + } + + sendToBotFather(t, svc, messages, owner, "/token") + if reply := sendToBotFather(t, svc, messages, owner, "@tok_test_bot"); !strings.Contains(reply, token) { + t.Fatalf("/token reply = %q, want current token %q", reply, token) + } + + sendToBotFather(t, svc, messages, owner, "/revoke") + reply := sendToBotFather(t, svc, messages, owner, "tok_test_bot") + match := tokenRe.FindStringSubmatch(reply) + if match == nil { + t.Fatalf("/revoke reply = %q, want new token", reply) + } + newToken := match[0] + if newToken == token { + t.Fatalf("revoke kept old token %q", token) + } + profile, _, err := bots.GetBot(ctx, created.ID) + if err != nil { + t.Fatalf("get bot: %v", err) + } + if domain.FormatBotToken(created.ID, profile.TokenSecret) != newToken { + t.Fatalf("stored secret %q does not match revoked token %q", profile.TokenSecret, newToken) + } + + // 选择不属于自己的 bot。 + sendToBotFather(t, svc, messages, owner, "/token") + if reply := sendToBotFather(t, svc, messages, owner, "@nosuch_bot"); !strings.Contains(reply, "don't see that bot") { + t.Fatalf("unknown choose reply = %q", reply) + } +} + +func TestBotFatherMyBotsAndLimit(t *testing.T) { + svc, users, _, messages := newTestService(t) + owner := newOwner(t, users, "+1004") + ctx := context.Background() + + if reply := sendToBotFather(t, svc, messages, owner, "/mybots"); !strings.Contains(reply, "don't have any bots") { + t.Fatalf("empty /mybots reply = %q", reply) + } + for i := 0; i < domain.MaxBotsPerOwner; i++ { + if _, _, err := svc.CreateBot(ctx, owner.ID, fmt.Sprintf("Bot %d", i), fmt.Sprintf("limit%d_bot", i)); err != nil { + t.Fatalf("create bot %d: %v", i, err) + } + } + if reply := sendToBotFather(t, svc, messages, owner, "/mybots"); !strings.Contains(reply, "@limit0_bot") { + t.Fatalf("/mybots reply = %q, want bot list", reply) + } + if _, _, err := svc.CreateBot(ctx, owner.ID, "One Too Many", "toomany_bot"); err != domain.ErrBotsTooMany { + t.Fatalf("create over limit err = %v, want ErrBotsTooMany", err) + } + if reply := sendToBotFather(t, svc, messages, owner, "/newbot"); !strings.Contains(reply, "limit") { + t.Fatalf("over-limit /newbot reply = %q", reply) + } +} + +func TestParseBotCommand(t *testing.T) { + cases := []struct { + in string + cmd string + ok bool + }{ + {"/newbot", "newbot", true}, + {"/NewBot@BotFather", "newbot", true}, + {"/token extra args", "token", true}, + {"plain text", "", false}, + {"/", "", false}, + } + for _, tc := range cases { + cmd, ok := parseBotCommand(tc.in) + if cmd != tc.cmd || ok != tc.ok { + t.Errorf("parseBotCommand(%q) = %q,%v want %q,%v", tc.in, cmd, ok, tc.cmd, tc.ok) + } + } +} + +type stubBlocker struct { + blocked bool + gotUser int64 + gotPeer int64 +} + +func (s *stubBlocker) IsBlocked(_ context.Context, userID, blockedUserID int64) (bool, error) { + s.gotUser, s.gotPeer = userID, blockedUserID + return s.blocked, nil +} + +func TestBotFatherReplyRespectsBlock(t *testing.T) { + users := memory.NewUserStore() + bots := memory.NewBotStore(users) + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + blocker := &stubBlocker{blocked: true} + svc := NewService(users, bots, messages, WithBlockChecker(blocker)) + owner := newOwner(t, users, "+1099") + ctx := context.Background() + + svc.respondAsBotFather(owner.ID, "/help") + + // IsBlocked 参数语义:owner(userID) 是否 block 了 BotFather(blockedUserID)。 + if blocker.gotUser != owner.ID || blocker.gotPeer != domain.BotFatherUserID { + t.Fatalf("IsBlocked called with (user=%d, peer=%d), want (%d, %d)", blocker.gotUser, blocker.gotPeer, owner.ID, domain.BotFatherUserID) + } + // 被 block:回复不投递到 owner 收件箱。 + list, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("list history: %v", err) + } + for _, msg := range list.Messages { + if msg.From.ID == domain.BotFatherUserID { + t.Fatalf("blocked owner received BotFather reply: %q", msg.Body) + } + } + + // 未 block:回复正常投递。 + blocker.blocked = false + other := newOwner(t, users, "+1098") + svc.respondAsBotFather(other.ID, "/help") + otherList, err := messages.ListByUser(ctx, other.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.BotFatherUserID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("list other history: %v", err) + } + delivered := false + for _, msg := range otherList.Messages { + if msg.From.ID == domain.BotFatherUserID { + delivered = true + } + } + if !delivered { + t.Fatal("unblocked user did not receive BotFather reply") + } +} + +func TestValidBotUsername(t *testing.T) { + valid := []string{"my_bot", "TetrisBot", "a1234bot", "x_bot_BOT"} + invalid := []string{"bot", "abot", "1abcbot", "_abcbot", "has space bot", "endsinbo", strings.Repeat("a", 30) + "bot" + "x"} + for _, u := range valid { + if !domain.ValidBotUsername(u) { + t.Errorf("ValidBotUsername(%q) = false, want true", u) + } + } + for _, u := range invalid { + if domain.ValidBotUsername(u) { + t.Errorf("ValidBotUsername(%q) = true, want false", u) + } + } +} diff --git a/internal/app/channels/active_ids_cache.go b/internal/app/channels/active_ids_cache.go new file mode 100644 index 00000000..15b34f6a --- /dev/null +++ b/internal/app/channels/active_ids_cache.go @@ -0,0 +1,91 @@ +package channels + +import ( + "context" + "time" + + "telesrv/internal/app/readmodel" + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +const ( + defaultActiveChannelIDsReadModelTTL = 24 * time.Hour + activeChannelIDsReadModelMaxEntries = 8192 + activeChannelIDsNoVersionHash = -1 +) + +type activeChannelIDsCacheKey struct { + userID int64 + afterChannelID int64 + limit int +} + +// activeChannelIDsReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。 +// 无版本(version 缺失)时用 activeChannelIDsNoVersionHash 哨兵作版本,仍享 TTL+epoch 失效。 +type activeChannelIDsReadModelCache struct { + cache *readmodelcache.Cache[activeChannelIDsCacheKey, []int64] +} + +func newActiveChannelIDsReadModelCache(ttl time.Duration) *activeChannelIDsReadModelCache { + if ttl <= 0 { + ttl = defaultActiveChannelIDsReadModelTTL + } + return &activeChannelIDsReadModelCache{ + cache: readmodelcache.New[activeChannelIDsCacheKey, []int64](readmodelcache.Config[activeChannelIDsCacheKey, []int64]{ + MaxEntries: activeChannelIDsReadModelMaxEntries, + TTL: ttl, + Clone: cloneInt64s, + }), + } +} + +func (c *activeChannelIDsReadModelCache) getOrLoad(ctx context.Context, key activeChannelIDsCacheKey, hash int64, load func() ([]int64, error)) ([]int64, error) { + if c == nil { + return load() + } + return c.cache.GetOrLoadVersioned(ctx, key, hash, load) +} + +func (c *activeChannelIDsReadModelCache) invalidateUsers(userIDs ...int64) { + if c == nil || len(userIDs) == 0 { + return + } + seen := make(map[int64]struct{}, len(userIDs)) + for _, userID := range userIDs { + if userID != 0 { + seen[userID] = struct{}{} + } + } + if len(seen) == 0 { + return + } + c.cache.InvalidateWhere(func(k activeChannelIDsCacheKey) bool { + _, ok := seen[k.userID] + return ok + }) +} + +func (s *Service) cachedActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { + if s.activeIDsCache == nil || s.versions == nil { + return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit) + } + hash, ok, err := s.versions.ReadModelHash(ctx, readmodel.ModelChannelActiveIDs, userID, domain.PeerTypeUser, userID) + if err != nil { + return nil, err + } + if !ok || hash == 0 { + hash = activeChannelIDsNoVersionHash + } + key := activeChannelIDsCacheKey{userID: userID, afterChannelID: afterChannelID, limit: limit} + return s.activeIDsCache.getOrLoad(ctx, key, hash, func() ([]int64, error) { + return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit) + }) +} + +func cloneInt64s(in []int64) []int64 { + if len(in) == 0 { + return nil + } + return append([]int64(nil), in...) +} diff --git a/internal/app/channels/bot_policy.go b/internal/app/channels/bot_policy.go new file mode 100644 index 00000000..5ecd78c3 --- /dev/null +++ b/internal/app/channels/bot_policy.go @@ -0,0 +1,311 @@ +package channels + +import ( + "context" + "sort" + "strings" + + "telesrv/internal/domain" +) + +// BotProfileResolver is the domain-only view of bot metadata used by channel policy. +type BotProfileResolver interface { + BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) +} + +type botProfileBatchResolver interface { + BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) +} + +type activeChannelBotMemberLister interface { + ListActiveChannelBotMembers(ctx context.Context, viewerUserID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error) +} + +type activeChannelBotMemberIDLister interface { + ListActiveChannelBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) +} + +func (s *Service) getBotParticipants(ctx context.Context, userID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error) { + if lister, ok := s.channels.(activeChannelBotMemberLister); ok { + return lister.ListActiveChannelBotMembers(ctx, userID, channelID, offset, limit) + } + channel, viewer, active, err := s.channels.ListActiveChannelMembers(ctx, userID, channelID, domain.MaxSynchronousChannelDialogFanout) + if err != nil { + return domain.ChannelParticipantList{}, err + } + if channel.ParticipantsHidden && !channelServiceMemberIsAdmin(viewer) { + return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil + } + if offset < 0 { + offset = 0 + } + if offset > domain.MaxChannelParticipantsOffset { + offset = domain.MaxChannelParticipantsOffset + } + ids := make([]int64, 0, len(active)) + for _, member := range active { + if member.UserID != 0 { + ids = append(ids, member.UserID) + } + } + profiles, err := s.botProfiles(ctx, ids) + if err != nil { + return domain.ChannelParticipantList{}, err + } + members := make([]domain.ChannelMember, 0, limit) + count := 0 + for _, member := range active { + if _, found := profiles[member.UserID]; !found { + continue + } + if count >= offset && len(members) < limit { + members = append(members, member) + } + count++ + } + return domain.ChannelParticipantList{Channel: channel, Participants: members, Count: count}, nil +} + +func (s *Service) botProfiles(ctx context.Context, ids []int64) (map[int64]domain.BotProfile, error) { + if len(ids) == 0 || s.bots == nil { + return nil, nil + } + if batch, ok := s.bots.(botProfileBatchResolver); ok { + return batch.BotInfos(ctx, ids) + } + out := make(map[int64]domain.BotProfile) + for _, id := range uniqueNonZero(ids) { + profile, found, err := s.bots.BotInfo(ctx, id) + if err != nil { + return nil, err + } + if found { + out[id] = profile + } + } + return out, nil +} + +func (s *Service) rejectBlockedBotInvites(ctx context.Context, userIDs []int64) error { + if s.bots == nil { + return nil + } + for _, id := range uniqueNonZero(userIDs) { + profile, found, err := s.bots.BotInfo(ctx, id) + if err != nil { + return err + } + if found && profile.Nochats { + return domain.ErrBotGroupsBlocked + } + } + return nil +} + +func (s *Service) skippedBotDeliveryUserIDs(ctx context.Context, req domain.SendChannelMessageRequest) ([]int64, error) { + if s.bots == nil || req.ChannelID == 0 || req.UserID == 0 { + return nil, nil + } + if lister, ok := s.channels.(activeChannelBotMemberIDLister); ok { + memberIDs, err := lister.ListActiveChannelBotMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxSynchronousChannelDialogFanout) + if err != nil { + return nil, err + } + return s.skippedBotDeliveryUserIDsForIDs(ctx, req, memberIDs) + } + memberIDs, err := s.channels.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxSynchronousChannelDialogFanout) + if err != nil { + return nil, err + } + return s.skippedBotDeliveryUserIDsForIDs(ctx, req, memberIDs) +} + +func (s *Service) skippedBotDeliveryUserIDsForIDs(ctx context.Context, req domain.SendChannelMessageRequest, memberIDs []int64) ([]int64, error) { + profiles, err := s.botProfiles(ctx, memberIDs) + if err != nil { + return nil, err + } + msg := domain.ChannelMessage{ + ChannelID: req.ChannelID, + SenderUserID: req.UserID, + Body: req.Message, + ReplyTo: req.ReplyTo, + Action: req.Action, + } + skip := make([]int64, 0) + for _, id := range memberIDs { + if id == req.UserID { + continue + } + profile, found := profiles[id] + if !found || profile.ChatHistory { + continue + } + visible, err := s.botCanSeeChannelMessage(ctx, id, msg, req.MentionUserIDs) + if err != nil { + return nil, err + } + if !visible { + skip = append(skip, id) + } + } + return skip, nil +} + +func (s *Service) filterBotChannelHistory(ctx context.Context, userID int64, history domain.ChannelHistory) domain.ChannelHistory { + if s.bots == nil || userID == 0 || history.Channel.ID == 0 { + return history + } + profile, found, err := s.bots.BotInfo(ctx, userID) + if err != nil || !found || profile.ChatHistory { + return history + } + filtered := history + filtered.Messages = make([]domain.ChannelMessage, 0, len(history.Messages)) + for _, msg := range history.Messages { + if visible, err := s.botCanSeeChannelMessage(ctx, userID, msg, nil); err == nil && visible { + filtered.Messages = append(filtered.Messages, msg) + } + } + filtered.Count = len(filtered.Messages) + filtered.Users = nil + filtered.Channels = nil + return filtered +} + +func (s *Service) filterBotChannelDifference(ctx context.Context, userID int64, diff domain.ChannelDifference) domain.ChannelDifference { + if s.bots == nil || userID == 0 || diff.Channel.ID == 0 { + return diff + } + profile, found, err := s.bots.BotInfo(ctx, userID) + if err != nil || !found || profile.ChatHistory { + return diff + } + filtered := diff + filtered.NewMessages = nil + filtered.OtherUpdates = nil + filtered.Events = nil + filtered.Users = nil + filtered.Channels = nil + if diff.TooLong { + for _, msg := range diff.NewMessages { + if visible, err := s.botCanSeeChannelMessage(ctx, userID, msg, nil); err == nil && visible { + filtered.NewMessages = append(filtered.NewMessages, msg) + } + } + return filtered + } + for _, event := range diff.Events { + visibleEvent, ok := s.filterBotChannelEvent(ctx, userID, event) + if !ok { + continue + } + filtered.Events = append(filtered.Events, visibleEvent) + switch visibleEvent.Type { + case domain.ChannelUpdateNewMessage: + filtered.NewMessages = append(filtered.NewMessages, visibleEvent.Message) + default: + filtered.OtherUpdates = append(filtered.OtherUpdates, visibleEvent) + } + } + return filtered +} + +func (s *Service) filterBotChannelEvent(ctx context.Context, botUserID int64, event domain.ChannelUpdateEvent) (domain.ChannelUpdateEvent, bool) { + switch event.Type { + case domain.ChannelUpdateNewMessage, domain.ChannelUpdateEditMessage: + if event.Message.ID == 0 { + return event, true + } + visible, err := s.botCanSeeChannelMessage(ctx, botUserID, event.Message, nil) + if err != nil || !visible { + return domain.ChannelUpdateEvent{}, false + } + return event, true + case domain.ChannelUpdateDeleteMessages: + // 删除事件只携带消息 id、不含任何内容,且在线推送路径(channels_updates 的 + // channelDeleteMessagesUpdates→enqueueChannelFanout)本就对全体成员无差别投递删除。 + // 若在此按可见性过滤,会因被删消息无法重取(GetChannelMessages 带 AND NOT deleted + // 恒返空)而把整条 delete 事件丢弃——privacy bot 经 getChannelDifference 补差时将 + // 对所有删除失明(连它本可见消息的删除也收不到),客户端缓存残留"未删"态。故直接放行, + // 与在线推送行为一致(删除 id 不泄漏内容)。 + return event, true + case domain.ChannelUpdatePinnedMessages: + if len(event.MessageIDs) == 0 { + return event, true + } + ids := make([]int, 0, len(event.MessageIDs)) + for _, id := range event.MessageIDs { + history, err := s.channels.GetChannelMessages(ctx, botUserID, event.ChannelID, []int{id}) + if err != nil || len(history.Messages) == 0 { + continue + } + visible, err := s.botCanSeeChannelMessage(ctx, botUserID, history.Messages[0], nil) + if err == nil && visible { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return domain.ChannelUpdateEvent{}, false + } + event.MessageIDs = ids + return event, true + default: + return event, true + } +} + +func (s *Service) botCanSeeChannelMessage(ctx context.Context, botUserID int64, msg domain.ChannelMessage, mentionUserIDs []int64) (bool, error) { + if botUserID == 0 { + return true, nil + } + if msg.SenderUserID == botUserID { + return true, nil + } + if msg.Mentioned || containsInt64(mentionUserIDs, botUserID) { + return true, nil + } + if msg.Action != nil && containsInt64(msg.Action.UserIDs, botUserID) { + return true, nil + } + if messageIsCommand(msg.Body) { + return true, nil + } + if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 { + history, err := s.channels.GetChannelMessages(ctx, botUserID, msg.ChannelID, []int{msg.ReplyTo.MessageID}) + if err != nil { + return false, nil + } + for _, target := range history.Messages { + if target.SenderUserID == botUserID { + return true, nil + } + } + } + return false, nil +} + +func messageIsCommand(message string) bool { + message = strings.TrimSpace(message) + return strings.HasPrefix(message, "/") && len(message) > 1 +} + +func containsInt64(ids []int64, target int64) bool { + for _, id := range ids { + if id == target { + return true + } + } + return false +} + +func channelServiceMemberIsAdmin(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin +} + +func mergeSkippedUserIDs(a, b []int64) []int64 { + out := append(append([]int64(nil), a...), b...) + out = uniqueNonZero(out) + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} diff --git a/internal/app/channels/media_count_cache.go b/internal/app/channels/media_count_cache.go new file mode 100644 index 00000000..75831547 --- /dev/null +++ b/internal/app/channels/media_count_cache.go @@ -0,0 +1,134 @@ +package channels + +import ( + "context" + "time" + + "telesrv/internal/app/readmodel" + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" + "telesrv/internal/store" +) + +const ( + defaultMediaCountReadModelTTL = 24 * time.Hour + mediaCountReadModelMaxEntries = 8192 +) + +type mediaCountCacheKey struct { + userID int64 + channelID int64 +} + +// mediaCountReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。 +type mediaCountReadModelCache struct { + cache *readmodelcache.Cache[mediaCountCacheKey, domain.MediaCategoryCounts] +} + +func newMediaCountReadModelCache(ttl time.Duration) *mediaCountReadModelCache { + if ttl <= 0 { + ttl = defaultMediaCountReadModelTTL + } + return &mediaCountReadModelCache{ + cache: readmodelcache.New[mediaCountCacheKey, domain.MediaCategoryCounts](readmodelcache.Config[mediaCountCacheKey, domain.MediaCategoryCounts]{ + MaxEntries: mediaCountReadModelMaxEntries, + TTL: ttl, + Clone: cloneMediaCategoryCounts, + }), + } +} + +func (c *mediaCountReadModelCache) getOrLoad(ctx context.Context, key mediaCountCacheKey, hash int64, load func() (domain.MediaCategoryCounts, error)) (domain.MediaCategoryCounts, error) { + if c == nil { + return load() + } + return c.cache.GetOrLoadVersioned(ctx, key, hash, load) +} + +func (c *mediaCountReadModelCache) invalidateChannel(channelID int64) { + if c == nil || c.cache == nil || channelID == 0 { + return + } + c.cache.InvalidateWhere(func(key mediaCountCacheKey) bool { + return key.channelID == channelID + }) +} + +func (c *mediaCountReadModelCache) invalidateViewer(userID, channelID int64) { + if c == nil || c.cache == nil || userID == 0 || channelID == 0 { + return + } + c.cache.Invalidate(mediaCountCacheKey{userID: userID, channelID: channelID}) +} + +func (c *mediaCountReadModelCache) flush() { + if c == nil || c.cache == nil { + return + } + c.cache.Flush() +} + +func (s *Service) InvalidateChannelMediaCountReadModel(channelID int64) { + if s == nil || s.mediaCountCache == nil { + return + } + s.mediaCountCache.invalidateChannel(channelID) +} + +func (s *Service) InvalidateChannelMediaCountReadModelForViewer(userID, channelID int64) { + if s == nil || s.mediaCountCache == nil { + return + } + s.mediaCountCache.invalidateViewer(userID, channelID) +} + +func (s *Service) FlushChannelMediaCountReadModel() { + if s == nil || s.mediaCountCache == nil { + return + } + s.mediaCountCache.flush() +} + +func (s *Service) cachedChannelMediaCounts(ctx context.Context, userID, channelID int64) (domain.MediaCategoryCounts, error) { + if s.mediaCountCache == nil || s.versions == nil { + return s.channels.CountChannelMediaCategories(ctx, userID, channelID) + } + hash, err := s.channelMediaCountHash(ctx, userID, channelID) + if err != nil { + return nil, err + } + if hash == 0 { + return s.channels.CountChannelMediaCategories(ctx, userID, channelID) + } + key := mediaCountCacheKey{userID: userID, channelID: channelID} + return s.mediaCountCache.getOrLoad(ctx, key, hash, func() (domain.MediaCategoryCounts, error) { + return s.channels.CountChannelMediaCategories(ctx, userID, channelID) + }) +} + +func (s *Service) channelMediaCountHash(ctx context.Context, userID, channelID int64) (int64, error) { + keys := []store.ReadModelKey{ + {Model: readmodel.ModelChannelMediaCounts, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + {Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + } + rows, err := s.versions.ReadModelHashes(ctx, keys) + if err != nil { + return 0, err + } + media := rows[keys[0]] + if media == 0 { + return 0, nil + } + return readmodel.MixHashes(media, rows[keys[1]]), nil +} + +func cloneMediaCategoryCounts(in domain.MediaCategoryCounts) domain.MediaCategoryCounts { + if len(in) == 0 { + return domain.MediaCategoryCounts{} + } + out := make(domain.MediaCategoryCounts, len(in)) + for category, count := range in { + out[category] = count + } + return out +} diff --git a/internal/app/channels/music_filter_test.go b/internal/app/channels/music_filter_test.go new file mode 100644 index 00000000..d4e43866 --- /dev/null +++ b/internal/app/channels/music_filter_test.go @@ -0,0 +1,62 @@ +package channels + +import ( + "context" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestChannelHistoryMusicOnlyFiltersAudioDocuments(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + service := NewService(store) + created, err := service.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "Music Filter", + Megagroup: true, + MemberUserIDs: []int64{1002}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + music := domain.Document{ + ID: 301, + AccessHash: 3001, + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio, AudioDuration: 200, Title: "Channel Song"}}, + } + voice := domain.Document{ + ID: 302, + AccessHash: 3002, + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio, Voice: true, AudioDuration: 4}}, + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &voice, Voice: true}, + Date: 11, + }); err != nil { + t.Fatalf("SendMessage voice: %v", err) + } + if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &music}, + Date: 12, + }); err != nil { + t.Fatalf("SendMessage music: %v", err) + } + + history, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + MusicOnly: true, + Limit: 10, + }) + if err != nil { + t.Fatalf("GetHistory music: %v", err) + } + if len(history.Messages) != 1 || history.Messages[0].Media == nil || history.Messages[0].Media.Document == nil || history.Messages[0].Media.Document.ID != music.ID { + t.Fatalf("music history = %+v, want only music document", history.Messages) + } +} diff --git a/internal/app/channels/participants_cache.go b/internal/app/channels/participants_cache.go new file mode 100644 index 00000000..59074d78 --- /dev/null +++ b/internal/app/channels/participants_cache.go @@ -0,0 +1,160 @@ +package channels + +import ( + "context" + "encoding/binary" + "hash/fnv" + "strings" + "time" + + "telesrv/internal/app/readmodel" + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" + "telesrv/internal/store" +) + +const ( + defaultParticipantsReadModelTTL = 30 * time.Minute + participantsReadModelMaxEntries = 8192 +) + +type participantsCacheKey struct { + userID int64 + channelID int64 + kind domain.ChannelParticipantsFilterKind + query string + offset int + limit int +} + +// participantsReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU 单条驱逐 / clone)。 +// LRU 终于给 (query,offset,limit) 维度的 page-key 上界,消掉原先无界 query-string 基数增长。 +type participantsReadModelCache struct { + cache *readmodelcache.Cache[participantsCacheKey, domain.ChannelParticipantList] +} + +func newParticipantsReadModelCache(ttl time.Duration) *participantsReadModelCache { + if ttl <= 0 { + ttl = defaultParticipantsReadModelTTL + } + return &participantsReadModelCache{ + cache: readmodelcache.New[participantsCacheKey, domain.ChannelParticipantList](readmodelcache.Config[participantsCacheKey, domain.ChannelParticipantList]{ + MaxEntries: participantsReadModelMaxEntries, + TTL: ttl, + Clone: cloneParticipantList, + }), + } +} + +func (c *participantsReadModelCache) getOrLoad(ctx context.Context, key participantsCacheKey, hash int64, load func() (domain.ChannelParticipantList, error)) (domain.ChannelParticipantList, error) { + if c == nil { + return load() + } + return c.cache.GetOrLoadVersioned(ctx, key, hash, load) +} + +func (s *Service) cachedParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { + filter, offset, limit = normalizeParticipantsRequest(filter, offset, limit) + if s.participantCache == nil || s.versions == nil { + return s.loadParticipants(ctx, userID, channelID, filter, offset, limit) + } + key := participantsCacheKey{ + userID: userID, + channelID: channelID, + kind: filter.Kind, + query: normalizeParticipantsQuery(filter.Query), + offset: offset, + limit: limit, + } + hash, err := s.channelParticipantsHash(ctx, userID, channelID, key) + if err != nil { + return domain.ChannelParticipantList{}, err + } + if hash == 0 { + return s.loadParticipants(ctx, userID, channelID, filter, offset, limit) + } + return s.participantCache.getOrLoad(ctx, key, hash, func() (domain.ChannelParticipantList, error) { + list, err := s.loadParticipants(ctx, userID, channelID, filter, offset, limit) + if err != nil { + return domain.ChannelParticipantList{}, err + } + list.Hash = hash + return list, nil + }) +} + +func (s *Service) loadParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { + if filter.Kind == domain.ChannelParticipantsBots && s.bots != nil { + return s.getBotParticipants(ctx, userID, channelID, offset, limit) + } + return s.channels.GetParticipants(ctx, userID, channelID, filter, offset, limit) +} + +func (s *Service) channelParticipantsHash(ctx context.Context, userID, channelID int64, key participantsCacheKey) (int64, error) { + keys := []store.ReadModelKey{ + {Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + {Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + {Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + {Model: readmodel.ModelContactAccount, OwnerUserID: userID, PeerType: domain.PeerTypeUser, PeerID: userID}, + } + rows, err := s.versions.ReadModelHashes(ctx, keys) + if err != nil { + return 0, err + } + base := rows[keys[0]] + participants := rows[keys[1]] + if base == 0 || participants == 0 { + return 0, nil + } + return readmodel.MixHashes(base, participants, rows[keys[2]], rows[keys[3]], participantsPageHash(key)), nil +} + +func normalizeParticipantsRequest(filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantsFilter, int, int) { + if filter.Kind == "" { + filter.Kind = domain.ChannelParticipantsRecent + } + filter.Query = normalizeParticipantsQuery(filter.Query) + if offset < 0 { + offset = 0 + } + if offset > domain.MaxChannelParticipantsOffset { + offset = domain.MaxChannelParticipantsOffset + } + if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { + limit = domain.MaxChannelParticipantsLimit + } + return filter, offset, limit +} + +func normalizeParticipantsQuery(query string) string { + return strings.ToLower(strings.TrimSpace(query)) +} + +func participantsPageHash(key participantsCacheKey) int64 { + h := fnv.New64a() + _, _ = h.Write([]byte(key.kind)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(key.query)) + var buf [16]byte + binary.LittleEndian.PutUint64(buf[0:8], uint64(key.offset)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(key.limit)) + _, _ = h.Write(buf[:]) + sum := int64(h.Sum64() & 0x7fffffffffffffff) + if sum == 0 { + return 1 + } + return sum +} + +func cloneParticipantList(in domain.ChannelParticipantList) domain.ChannelParticipantList { + in.Channel = cloneChannel(in.Channel) + in.Participants = append([]domain.ChannelMember(nil), in.Participants...) + if len(in.Users) > 0 { + in.Users = make([]domain.User, len(in.Users)) + for i, user := range in.Users { + user.PhotoStripped = append([]byte(nil), user.PhotoStripped...) + in.Users[i] = user + } + } + return in +} diff --git a/internal/app/channels/read_model_cache.go b/internal/app/channels/read_model_cache.go new file mode 100644 index 00000000..ad26eac3 --- /dev/null +++ b/internal/app/channels/read_model_cache.go @@ -0,0 +1,107 @@ +package channels + +import ( + "context" + "time" + + "telesrv/internal/app/readmodel" + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" + "telesrv/internal/store" +) + +const ( + // Full channel views are version-token guarded by channel_base, + // channel_member, and dialog_light. Keep the snapshot long-lived; write-side + // read-model bumps, not time, drive correctness. + defaultChannelViewReadModelTTL = 24 * time.Hour + channelViewReadModelMaxEntries = 8192 +) + +type channelViewCacheKey struct { + userID int64 + channelID int64 +} + +// channelViewReadModelCache 与 channelResolveReadModelCache 都缓存 domain.ChannelView, +// 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。 +type channelViewReadModelCache struct { + cache *readmodelcache.Cache[channelViewCacheKey, domain.ChannelView] +} + +func newChannelViewReadModelCache(ttl time.Duration) *channelViewReadModelCache { + if ttl <= 0 { + ttl = defaultChannelViewReadModelTTL + } + return &channelViewReadModelCache{ + cache: readmodelcache.New[channelViewCacheKey, domain.ChannelView](readmodelcache.Config[channelViewCacheKey, domain.ChannelView]{ + MaxEntries: channelViewReadModelMaxEntries, + TTL: ttl, + Clone: cloneChannelView, + }), + } +} + +func (c *channelViewReadModelCache) getOrLoad(ctx context.Context, key channelViewCacheKey, hash int64, load func() (domain.ChannelView, error)) (domain.ChannelView, error) { + if c == nil { + return load() + } + return c.cache.GetOrLoadVersioned(ctx, key, hash, load) +} + +func (s *Service) cachedChannelView(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) { + if s.viewCache == nil || s.versions == nil { + return s.channels.GetChannel(ctx, userID, channelID) + } + hash, err := s.channelViewHash(ctx, userID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + if hash == 0 { + return s.channels.GetChannel(ctx, userID, channelID) + } + key := channelViewCacheKey{userID: userID, channelID: channelID} + return s.viewCache.getOrLoad(ctx, key, hash, func() (domain.ChannelView, error) { + return s.channels.GetChannel(ctx, userID, channelID) + }) +} + +func (s *Service) channelViewHash(ctx context.Context, userID, channelID int64) (int64, error) { + keys := []store.ReadModelKey{ + {Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + {Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + {Model: readmodel.ModelDialogLight, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + // 快照含 SelfBoostsApplied:必须把 channel_self_boosts 纳入校验 token,否则 apply/revoke + // 加成不会失效这份长 TTL 快照。注:boost 自然到期是 time-based、不触发写,故其残余仍受 TTL 约束。 + {Model: readmodel.ModelChannelSelfBoosts, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + } + rows, err := s.versions.ReadModelHashes(ctx, keys) + if err != nil { + return 0, err + } + base := rows[keys[0]] + if base == 0 { + return 0, nil + } + return readmodel.MixHashes(base, rows[keys[1]], rows[keys[2]], rows[keys[3]]), nil +} + +func cloneChannelView(in domain.ChannelView) domain.ChannelView { + in.Channel = cloneChannel(in.Channel) + if in.Dialog.DefaultSendAs != nil { + peer := *in.Dialog.DefaultSendAs + in.Dialog.DefaultSendAs = &peer + } + if in.ExportedInvite != nil { + invite := *in.ExportedInvite + in.ExportedInvite = &invite + } + return in +} + +func cloneChannel(in domain.Channel) domain.Channel { + in.PhotoStripped = append([]byte(nil), in.PhotoStripped...) + in.ReactionPolicy.Emoticons = append([]string(nil), in.ReactionPolicy.Emoticons...) + in.ReactionPolicy.CustomEmojiIDs = append([]int64(nil), in.ReactionPolicy.CustomEmojiIDs...) + return in +} diff --git a/internal/app/channels/resolve_cache.go b/internal/app/channels/resolve_cache.go new file mode 100644 index 00000000..4ae6ae28 --- /dev/null +++ b/internal/app/channels/resolve_cache.go @@ -0,0 +1,74 @@ +package channels + +import ( + "context" + "time" + + "telesrv/internal/app/readmodel" + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" + "telesrv/internal/store" +) + +const ( + defaultChannelResolveReadModelTTL = 24 * time.Hour + channelResolveReadModelMaxEntries = 16384 +) + +// channelResolveReadModelCache 由统一缓存原语承载(版本闸门 / epoch 守卫 / LRU / clone)。 +type channelResolveReadModelCache struct { + cache *readmodelcache.Cache[channelViewCacheKey, domain.ChannelView] +} + +func newChannelResolveReadModelCache(ttl time.Duration) *channelResolveReadModelCache { + if ttl <= 0 { + ttl = defaultChannelResolveReadModelTTL + } + return &channelResolveReadModelCache{ + cache: readmodelcache.New[channelViewCacheKey, domain.ChannelView](readmodelcache.Config[channelViewCacheKey, domain.ChannelView]{ + MaxEntries: channelResolveReadModelMaxEntries, + TTL: ttl, + Clone: cloneChannelView, + }), + } +} + +func (c *channelResolveReadModelCache) getOrLoad(ctx context.Context, key channelViewCacheKey, hash int64, load func() (domain.ChannelView, error)) (domain.ChannelView, error) { + if c == nil { + return load() + } + return c.cache.GetOrLoadVersioned(ctx, key, hash, load) +} + +func (s *Service) cachedResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) { + if s.resolveCache == nil || s.versions == nil { + return s.channels.ResolveChannel(ctx, userID, channelID) + } + hash, err := s.channelResolveHash(ctx, userID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + if hash == 0 { + return s.channels.ResolveChannel(ctx, userID, channelID) + } + key := channelViewCacheKey{userID: userID, channelID: channelID} + return s.resolveCache.getOrLoad(ctx, key, hash, func() (domain.ChannelView, error) { + return s.channels.ResolveChannel(ctx, userID, channelID) + }) +} + +func (s *Service) channelResolveHash(ctx context.Context, userID, channelID int64) (int64, error) { + keys := []store.ReadModelKey{ + {Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + {Model: readmodel.ModelChannelMember, OwnerUserID: userID, PeerType: domain.PeerTypeChannel, PeerID: channelID}, + } + rows, err := s.versions.ReadModelHashes(ctx, keys) + if err != nil { + return 0, err + } + base := rows[keys[0]] + if base == 0 { + return 0, nil + } + return readmodel.MixHashes(base, rows[keys[1]]), nil +} diff --git a/internal/app/channels/service.go b/internal/app/channels/service.go index 24f0e262..b0e7e6dc 100644 --- a/internal/app/channels/service.go +++ b/internal/app/channels/service.go @@ -2,6 +2,7 @@ package channels import ( "context" + "errors" "strings" "unicode/utf8" @@ -12,11 +13,57 @@ import ( // Service exposes channel/supergroup business operations. type Service struct { channels store.ChannelStore + bots BotProfileResolver + versions store.ReadModelVersionStore + sendGate SendPermissionChecker + + viewCache *channelViewReadModelCache + resolveCache *channelResolveReadModelCache + mediaCountCache *mediaCountReadModelCache + participantCache *participantsReadModelCache + activeIDsCache *activeChannelIDsReadModelCache +} + +type Option func(*Service) + +type SendPermissionChecker interface { + CanSendMessages(ctx context.Context, userID int64) error } // NewService creates a channel service. -func NewService(channels store.ChannelStore) *Service { - return &Service{channels: channels} +func NewService(channels store.ChannelStore, opts ...Option) *Service { + s := &Service{ + channels: channels, + viewCache: newChannelViewReadModelCache(defaultChannelViewReadModelTTL), + resolveCache: newChannelResolveReadModelCache(defaultChannelResolveReadModelTTL), + mediaCountCache: newMediaCountReadModelCache(defaultMediaCountReadModelTTL), + participantCache: newParticipantsReadModelCache(defaultParticipantsReadModelTTL), + activeIDsCache: newActiveChannelIDsReadModelCache(defaultActiveChannelIDsReadModelTTL), + } + for _, opt := range opts { + opt(s) + } + return s +} + +// WithBotProfileResolver enables group bot membership and privacy policy checks. +func WithBotProfileResolver(bots BotProfileResolver) Option { + return func(s *Service) { + s.bots = bots + } +} + +// WithReadModelVersions enables version-token guarded channel full-view caching. +func WithReadModelVersions(v store.ReadModelVersionStore) Option { + return func(s *Service) { + s.versions = v + } +} + +func WithSendPermissionChecker(c SendPermissionChecker) Option { + return func(s *Service) { + s.sendGate = c + } } // CreateMegagroupFromCreateChat handles messages.createChat by directly creating a megagroup. @@ -44,10 +91,24 @@ func (s *Service) CreateChannel(ctx context.Context, userID int64, req domain.Cr if len(req.MemberUserIDs) > domain.MaxChannelInviteUsers { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } + if err := s.rejectBlockedBotInvites(ctx, req.MemberUserIDs); err != nil { + return domain.CreateChannelResult{}, err + } if !req.Broadcast && !req.Megagroup { req.Broadcast = true } - return s.channels.CreateChannel(ctx, req) + res, err := s.channels.CreateChannel(ctx, req) + if err != nil { + return res, err + } + s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(userID, res.Members)...) + // 官方语义:创建即生成创建者的永久主链接(DrKLO 建频道后立刻 + // getExportedChatInvites 取 invites[0])。失败不阻断创建—— + // ListExportedInvites 首页自愈会兜底补上。 + if res.Channel.ID != 0 { + _, _ = s.channels.EnsurePermanentInvite(ctx, res.Channel.ID, userID, req.Date) + } + return res, nil } // GetChannel returns channel data personalized for userID. @@ -58,6 +119,67 @@ func (s *Service) GetChannel(ctx context.Context, userID, channelID int64) (doma return s.channels.GetChannel(ctx, userID, channelID) } +// GetChannelReadModel returns the full channel view through a version-token guarded +// read model cache. It is intended for read-only RPC projection paths, not write +// permission checks. +func (s *Service) GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + return s.cachedChannelView(ctx, userID, channelID) +} + +// ResolveChannel 是 GetChannel 的轻量版(仅访问校验 + Channel/Self,跳过 dialog/boost 查询), +// 供只需 access_hash / 频道标志的 peer 解析路径用。访问语义与 GetChannel 一致。 +func (s *Service) ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + return s.cachedResolveChannel(ctx, userID, channelID) +} + +// SearchChannelMedia 返回某频道中属于给定媒体类别的消息(共享媒体标签页)。 +func (s *Service) SearchChannelMedia(ctx context.Context, userID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + return s.channels.SearchChannelMedia(ctx, userID, channelID, req) +} + +// CountChannelMediaCategories 返回某频道对当前 viewer 可见消息按基础媒体类别聚合的精确计数。 +func (s *Service) CountChannelMediaCategories(ctx context.Context, userID, channelID int64) (domain.MediaCategoryCounts, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.MediaCategoryCounts{}, domain.ErrChannelInvalid + } + return s.cachedChannelMediaCounts(ctx, userID, channelID) +} + +// GetChannels returns channel data personalized for userID, ordered by the first occurrence in channelIDs. +func (s *Service) GetChannels(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, domain.ErrChannelInvalid + } + ids := uniqueNonZero(channelIDs) + if len(ids) == 0 { + return nil, nil + } + if s.versions != nil { + out := make([]domain.ChannelView, 0, len(ids)) + for _, id := range ids { + view, err := s.GetChannelReadModel(ctx, userID, id) + if err != nil { + if errors.Is(err, domain.ErrChannelPrivate) || errors.Is(err, domain.ErrChannelInvalid) { + continue + } + return s.channels.GetChannels(ctx, userID, ids) + } + out = append(out, view) + } + return out, nil + } + return s.channels.GetChannels(ctx, userID, ids) +} + // GetJoinableChannel returns a channel shell so RPC can verify access hash before join. func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) { if s == nil || s.channels == nil || userID == 0 || channelID == 0 { @@ -66,6 +188,14 @@ func (s *Service) GetJoinableChannel(ctx context.Context, userID, channelID int6 return s.channels.GetChannelByID(ctx, channelID) } +// GetChannelByID returns the non-personalized channel base row for internal admin use. +func (s *Service) GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) { + if s == nil || s.channels == nil || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.GetChannelByID(ctx, channelID) +} + // GetParticipants returns a bounded participants page. func (s *Service) GetParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { if s == nil || s.channels == nil || userID == 0 || channelID == 0 { @@ -74,7 +204,7 @@ func (s *Service) GetParticipants(ctx context.Context, userID, channelID int64, if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength { return domain.ChannelParticipantList{}, domain.ErrChannelInvalid } - return s.channels.GetParticipants(ctx, userID, channelID, filter, offset, capLimit(limit, domain.MaxChannelParticipantsLimit)) + return s.cachedParticipants(ctx, userID, channelID, filter, offset, limit) } // GetParticipant returns one participant. @@ -85,6 +215,14 @@ func (s *Service) GetParticipant(ctx context.Context, userID, channelID, partici return s.channels.GetParticipant(ctx, userID, channelID, participantUserID) } +// FutureCreatorAfterLeave returns the member that will become creator if userID leaves. +func (s *Service) FutureCreatorAfterLeave(ctx context.Context, userID, channelID int64) (domain.ChannelMember, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelMember{}, domain.ErrChannelInvalid + } + return s.channels.FutureCreatorAfterLeave(ctx, channelID, userID) +} + // InviteToChannel invites users to a channel/supergroup. func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) { if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(userIDs) == 0 { @@ -93,7 +231,14 @@ func (s *Service) InviteToChannel(ctx context.Context, userID, channelID int64, if len(userIDs) > domain.MaxChannelInviteUsers { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } - return s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date) + if err := s.rejectBlockedBotInvites(ctx, userIDs); err != nil { + return domain.CreateChannelResult{}, err + } + res, err := s.channels.InviteToChannel(ctx, channelID, userID, userIDs, date) + if err == nil { + s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...) + } + return res, err } // JoinChannel joins current user to a channel/supergroup. @@ -101,7 +246,11 @@ func (s *Service) JoinChannel(ctx context.Context, userID, channelID int64, date if s == nil || s.channels == nil || userID == 0 || channelID == 0 { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } - return s.channels.JoinChannel(ctx, channelID, userID, date) + res, err := s.channels.JoinChannel(ctx, channelID, userID, date) + if err == nil { + s.invalidateActiveChannelIDs(userID) + } + return res, err } // LeaveChannel leaves current user from a channel/supergroup. @@ -109,7 +258,11 @@ func (s *Service) LeaveChannel(ctx context.Context, userID, channelID int64, dat if s == nil || s.channels == nil || userID == 0 || channelID == 0 { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } - return s.channels.LeaveChannel(ctx, channelID, userID, date) + res, err := s.channels.LeaveChannel(ctx, channelID, userID, date) + if err == nil { + s.invalidateActiveChannelIDs(userID) + } + return res, err } // EditTitle edits a channel/supergroup title. @@ -126,6 +279,20 @@ func (s *Service) EditTitle(ctx context.Context, userID int64, req domain.EditCh return s.channels.EditChannelTitle(ctx, req) } +// SetWallpaper sets or clears the channel/supergroup wallpaper. +func (s *Service) SetWallpaper(ctx context.Context, userID int64, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid + } + return s.channels.SetChannelWallpaper(ctx, req) +} + // EditAbout edits a channel/supergroup description. func (s *Service) EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error) { if s == nil || s.channels == nil || userID == 0 { @@ -154,6 +321,21 @@ func (s *Service) EditAdmin(ctx context.Context, userID int64, req domain.EditCh return s.channels.EditChannelAdmin(ctx, req) } +// EditMemberRank sets or clears a participant's member tag without touching +// their role or admin rights. +func (s *Service) EditMemberRank(ctx context.Context, userID int64, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 || req.MemberID == 0 || len(req.Rank) > domain.MaxChannelAdminRankLength { + return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid + } + return s.channels.EditChannelMemberRank(ctx, req) +} + // EditBanned edits a participant's banned rights. func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) { if s == nil || s.channels == nil || userID == 0 { @@ -165,7 +347,11 @@ func (s *Service) EditBanned(ctx context.Context, userID int64, req domain.EditC if req.UserID != userID || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 { return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid } - return s.channels.EditChannelBanned(ctx, req) + res, err := s.channels.EditChannelBanned(ctx, req) + if err == nil { + s.invalidateActiveChannelIDs(req.Participant.ID) + } + return res, err } // EditDefaultBannedRights edits the channel/supergroup default restrictions. @@ -193,7 +379,11 @@ func (s *Service) DeleteChannel(ctx context.Context, userID int64, req domain.De if req.UserID != userID || req.ChannelID == 0 { return domain.DeleteChannelResult{}, domain.ErrChannelInvalid } - return s.channels.DeleteChannel(ctx, req) + res, err := s.channels.DeleteChannel(ctx, req) + if err == nil { + s.invalidateActiveChannelIDs(uniqueUserIDs(append([]int64{userID}, res.Recipients...)...)...) + } + return res, err } // CheckUsername checks whether a channel username is syntactically valid and free. @@ -226,6 +416,14 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, req domain.U return s.channels.UpdateUsername(ctx, req) } +// SetVerified sets or clears the channel/supergroup verified badge through the internal admin path. +func (s *Service) SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error) { + if s == nil || s.channels == nil || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetChannelVerified(ctx, channelID, verified) +} + // ListAdminedPublicChannels returns public channels/supergroups administered by user. func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { if s == nil || s.channels == nil || userID == 0 { @@ -234,6 +432,22 @@ func (s *Service) ListAdminedPublicChannels(ctx context.Context, userID int64) ( return s.channels.ListAdminedPublicChannels(ctx, userID) } +// ListStoryPostableChannels returns channels where user can publish stories. +func (s *Service) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, nil + } + return s.channels.ListStoryPostableChannels(ctx, userID) +} + +// ListSendAsChannels returns the broadcast channels the user may post messages as in groups. +func (s *Service) ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 { + return nil, nil + } + return s.channels.ListSendAsChannels(ctx, userID) +} + // ResolvePublicUsername resolves a public channel/supergroup username visible to userID. func (s *Service) ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error) { if s == nil || s.channels == nil || userID == 0 { @@ -318,9 +532,9 @@ func (s *Service) SetRestrictedSponsored(ctx context.Context, userID, channelID } // SetPaidMessagesPrice stores the currently advertised paid-message price state. -func (s *Service) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) { +func (s *Service) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.ChannelPaidMessagesPriceResult, error) { if s == nil || s.channels == nil || userID == 0 || channelID == 0 || stars < 0 { - return domain.Channel{}, domain.ErrChannelInvalid + return domain.ChannelPaidMessagesPriceResult{}, domain.ErrChannelInvalid } return s.channels.SetPaidMessagesPrice(ctx, userID, channelID, stars, broadcastMessagesAllowed) } @@ -341,6 +555,15 @@ func (s *Service) SetSlowMode(ctx context.Context, userID, channelID int64, seco return s.channels.SetSlowMode(ctx, userID, channelID, seconds) } +// SetBoostsToUnblockRestrictions stores the boost threshold that lets boosted +// members bypass default send-message restrictions. +func (s *Service) SetBoostsToUnblockRestrictions(ctx context.Context, userID, channelID int64, boosts int) (domain.Channel, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || boosts < 0 || boosts > domain.MaxChannelBoostsToUnblockRestrictions { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetBoostsToUnblockRestrictions(ctx, userID, channelID, boosts) +} + // SetNoForwards toggles channel/supergroup content protection. func (s *Service) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { if s == nil || s.channels == nil || userID == 0 || channelID == 0 { @@ -349,6 +572,30 @@ func (s *Service) SetNoForwards(ctx context.Context, userID, channelID int64, en return s.channels.SetNoForwards(ctx, userID, channelID, enabled) } +// SetHistoryTTL updates channel/supergroup message auto-delete period. +func (s *Service) SetHistoryTTL(ctx context.Context, userID, channelID int64, period int, date int) (domain.Channel, []int64, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || period < 0 { + return domain.Channel{}, nil, domain.ErrChannelInvalid + } + ttl, ok := s.channels.(store.ChannelHistoryTTLStore) + if !ok { + return domain.Channel{}, nil, domain.ErrChannelInvalid + } + return ttl.SetChannelHistoryTTL(ctx, userID, channelID, period, date) +} + +// ClaimExpiredMessages returns expired channel delete batches for the TTL worker. +func (s *Service) ClaimExpiredMessages(ctx context.Context, now, limit int) ([]domain.DeleteChannelMessagesRequest, error) { + if s == nil || s.channels == nil { + return nil, nil + } + ttl, ok := s.channels.(store.ChannelHistoryTTLStore) + if !ok { + return nil, nil + } + return ttl.ClaimExpiredChannelMessages(ctx, now, limit) +} + // SetJoinToSend toggles whether non-members must join before sending in a megagroup. func (s *Service) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { if s == nil || s.channels == nil || userID == 0 || channelID == 0 { @@ -370,9 +617,9 @@ func (s *Service) SetAvailableReactions(ctx context.Context, userID, channelID i if s == nil || s.channels == nil || userID == 0 || channelID == 0 { return domain.Channel{}, domain.ErrChannelInvalid } - if len(policy.Emoticons)+len(policy.CustomEmojiIDs) > domain.MaxChannelReactionItems || + if len(policy.Emoticons)+len(policy.CustomEmojiIDs) > domain.MaxChannelReactionTypes || policy.Limit < 0 || - policy.Limit > domain.MaxChannelReactionItems { + policy.Limit > domain.MaxChannelReactionsLimit { return domain.Channel{}, domain.ErrChannelInvalid } for _, emoticon := range policy.Emoticons { @@ -380,6 +627,11 @@ func (s *Service) SetAvailableReactions(ctx context.Context, userID, channelID i return domain.Channel{}, domain.ErrChannelInvalid } } + for _, documentID := range policy.CustomEmojiIDs { + if documentID <= 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + } return s.channels.SetAvailableReactions(ctx, userID, channelID, policy) } @@ -402,6 +654,55 @@ func (s *Service) SetEmojiStatus(ctx context.Context, userID, channelID int64, s return s.channels.SetEmojiStatus(ctx, userID, channelID, status) } +func (s *Service) GetPremiumBoostStatus(ctx context.Context, userID, channelID int64, now int) (domain.PremiumBoostStatus, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || now < 0 { + return domain.PremiumBoostStatus{}, domain.ErrChannelInvalid + } + return s.channels.GetPremiumBoostStatus(ctx, userID, channelID, now) +} + +func (s *Service) ListPremiumBoosts(ctx context.Context, userID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || now < 0 || len(offset) > domain.MaxPremiumBoostsOffsetBytes { + return domain.PremiumBoostList{}, domain.ErrChannelInvalid + } + if limit <= 0 { + limit = domain.MaxPremiumBoostsListLimit + } + if limit > domain.MaxPremiumBoostsListLimit { + limit = domain.MaxPremiumBoostsListLimit + } + return s.channels.ListPremiumBoosts(ctx, userID, channelID, gifts, offset, limit, now) +} + +func (s *Service) GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) { + if s == nil || s.channels == nil || userID == 0 || now < 0 || premiumUntil < 0 { + return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid + } + return s.channels.GetPremiumMyBoosts(ctx, userID, now, premiumUntil) +} + +func (s *Service) ApplyPremiumBoost(ctx context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || len(slots) == 0 || len(slots) > domain.MaxPremiumBoostSlotsPerApply || now < 0 || premiumUntil < 0 { + return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid + } + for _, slot := range slots { + if slot <= 0 { + return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid + } + } + if premiumUntil <= now { + return domain.PremiumMyBoosts{}, domain.ErrPremiumRequired + } + return s.channels.ApplyPremiumBoost(ctx, userID, channelID, slots, now, premiumUntil) +} + +func (s *Service) GetPremiumUserBoosts(ctx context.Context, userID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 || targetUserID == 0 || now < 0 { + return domain.PremiumBoostList{}, domain.ErrChannelInvalid + } + return s.channels.GetPremiumUserBoosts(ctx, userID, channelID, targetUserID, now) +} + // ListAdminLog returns one bounded, channel-scoped admin log page. func (s *Service) ListAdminLog(ctx context.Context, userID int64, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) { if s == nil || s.channels == nil || userID == 0 { @@ -432,6 +733,46 @@ func (s *Service) GetChannelForChangeInfo(ctx context.Context, userID, channelID return domain.ChannelView{}, domain.ErrChannelAdminRequired } +// CanPostStory validates whether the current user can publish a channel story. +func (s *Service) CanPostStory(ctx context.Context, userID, channelID int64) error { + return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool { + return rights.PostStories + }) +} + +// CanEditStory validates whether the current user can edit a channel story. +func (s *Service) CanEditStory(ctx context.Context, userID, channelID int64) error { + return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool { + return rights.EditStories + }) +} + +// CanDeleteStory validates whether the current user can delete a channel story. +func (s *Service) CanDeleteStory(ctx context.Context, userID, channelID int64) error { + return s.canManageStory(ctx, userID, channelID, func(rights domain.ChannelAdminRights) bool { + return rights.DeleteStories + }) +} + +// CanPinStory validates whether the current user can change channel story pin state. +func (s *Service) CanPinStory(ctx context.Context, userID, channelID int64) error { + return s.CanEditStory(ctx, userID, channelID) +} + +func (s *Service) canManageStory(ctx context.Context, userID, channelID int64, allowed func(domain.ChannelAdminRights) bool) error { + view, err := s.GetChannel(ctx, userID, channelID) + if err != nil { + return err + } + if view.Self.Role == domain.ChannelRoleCreator { + return nil + } + if view.Self.Role == domain.ChannelRoleAdmin && allowed(view.Self.AdminRights) { + return nil + } + return domain.ErrChannelAdminRequired +} + // SaveDefaultSendAs persists the current user's default send-as peer for one channel/supergroup dialog. func (s *Service) SaveDefaultSendAs(ctx context.Context, userID int64, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) { if s == nil || s.channels == nil || userID == 0 { @@ -482,6 +823,49 @@ func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req dom return s.channels.SetChannelMessageReactions(ctx, req) } +// SendPaidReaction 为一条广播频道消息增投付费 reaction 星数;扣费在 rpc 层经 Stars 账本 +// Debit 完成,本方法只负责累计与聚合。 +func (s *Service) SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID || req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest { + return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid + } + return s.channels.AddChannelMessagePaidReaction(ctx, req) +} + +// VoteMessagePoll 给频道/超级群消息上的 poll 投票(options 为空 = 撤票)。 +func (s *Service) VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid + } + return s.channels.VoteChannelMessagePoll(ctx, req) +} + +// CloseMessagePoll 关闭频道/超级群消息上的 poll(仅 poll 创建者)。 +func (s *Service) CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid + } + return s.channels.CloseChannelMessagePoll(ctx, req) +} + // GetMessageReactions returns reaction summaries for exact channel/supergroup message ids. func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 { @@ -813,9 +1197,24 @@ func (s *Service) SendMessage(ctx context.Context, userID int64, req domain.Send if req.UserID != userID { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } + if err := s.ensureCanSend(ctx, req.UserID); err != nil { + return domain.SendChannelMessageResult{}, err + } + skipped, err := s.skippedBotDeliveryUserIDs(ctx, req) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + req.SkipDeliveryUserIDs = mergeSkippedUserIDs(req.SkipDeliveryUserIDs, skipped) return s.channels.SendChannelMessage(ctx, req) } +func (s *Service) ensureCanSend(ctx context.Context, userID int64) error { + if s == nil || s.sendGate == nil || userID == 0 { + return nil + } + return s.sendGate.CanSendMessages(ctx, userID) +} + // EditMessage edits a channel/supergroup text message. func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { if s == nil || s.channels == nil || userID == 0 { @@ -830,6 +1229,23 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit return s.channels.EditChannelMessage(ctx, req) } +// GetInlineBotMessage returns one live channel message addressed by a signed inline id. +func (s *Service) GetInlineBotMessage(ctx context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) { + if s == nil || s.channels == nil || botID == 0 || channelID == 0 || id <= 0 || id > domain.MaxMessageBoxID { + return domain.Channel{}, domain.ChannelMessage{}, false, domain.ErrChannelInvalid + } + return s.channels.GetChannelMessageForInlineBot(ctx, botID, channelID, id) +} + +// EditInlineBotMessage edits a channel message through its via-bot inline id. +func (s *Service) EditInlineBotMessage(ctx context.Context, botID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { + if s == nil || s.channels == nil || botID == 0 || req.ChannelID == 0 || req.ID <= 0 || req.UserID == 0 { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + req.ViaBotEditBotID = botID + return s.channels.EditChannelMessage(ctx, req) +} + // DeleteMessages deletes a bounded set of channel/supergroup messages. func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) { if s == nil || s.channels == nil || userID == 0 { @@ -889,6 +1305,20 @@ func (s *Service) UpdatePinnedMessage(ctx context.Context, userID int64, req dom return s.channels.UpdatePinnedMessage(ctx, req) } +// UnpinAllMessages clears every pinned message in a channel/supergroup. +func (s *Service) UnpinAllMessages(ctx context.Context, userID int64, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.ChannelID == 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid + } + return s.channels.UnpinAllChannelMessages(ctx, req) +} + // ExportInvite exports a channel/supergroup invite link. func (s *Service) ExportInvite(ctx context.Context, userID int64, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) { if s == nil || s.channels == nil || userID == 0 { @@ -926,7 +1356,11 @@ func (s *Service) ImportInvite(ctx context.Context, userID int64, req domain.Imp return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty } req.Hash = strings.TrimSpace(req.Hash) - return s.channels.ImportInvite(ctx, req) + res, err := s.channels.ImportInvite(ctx, req) + if err == nil { + s.invalidateActiveChannelIDs(userID) + } + return res, err } // ListExportedInvites returns a bounded invite management page. @@ -942,6 +1376,13 @@ func (s *Service) ListExportedInvites(ctx context.Context, userID int64, req dom } req.OffsetHash = strings.TrimSpace(req.OffsetHash) req.Limit = capLimit(req.Limit, domain.MaxChannelInviteListLimit) + // 官方语义自愈:管理员查看自己的有效链接首页时,永久主链接必须存在 + //(存量频道/创建路径漏建借此补上;权限校验由 store 内部完成)。 + if req.AdminUserID == userID && !req.Revoked && req.OffsetDate == 0 && req.OffsetHash == "" { + if _, err := s.channels.EnsurePermanentInvite(ctx, req.ChannelID, userID, 0); err != nil { + return domain.ChannelInviteList{}, err + } + } return s.channels.ListExportedInvites(ctx, req) } @@ -1056,7 +1497,11 @@ func (s *Service) HideChatJoinRequest(ctx context.Context, userID int64, req dom if req.UserID != userID || req.ChannelID == 0 || req.TargetUserID == 0 { return domain.CreateChannelResult{}, domain.ErrChannelInvalid } - return s.channels.HideChatJoinRequest(ctx, req) + res, err := s.channels.HideChatJoinRequest(ctx, req) + if err == nil { + s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(req.TargetUserID, res.Members)...) + } + return res, err } // HideAllChatJoinRequests approves or dismisses pending join requests in a bounded batch. @@ -1072,7 +1517,11 @@ func (s *Service) HideAllChatJoinRequests(ctx context.Context, userID int64, req return domain.CreateChannelResult{}, domain.ErrChannelInvalid } req.Limit = capLimit(req.Limit, domain.MaxChannelHideJoinRequests) - return s.channels.HideAllChatJoinRequests(ctx, req) + res, err := s.channels.HideAllChatJoinRequests(ctx, req) + if err == nil { + s.invalidateActiveChannelIDs(activeMembershipUserIDsFromMembers(0, res.Members)...) + } + return res, err } // ListDialogs returns current user's channel/supergroup dialog page. @@ -1180,7 +1629,11 @@ func (s *Service) GetHistory(ctx context.Context, userID int64, filter domain.Ch return domain.ChannelHistory{}, domain.ErrChannelInvalid } filter.Limit = capLimit(filter.Limit, 100) - return s.channels.ListChannelHistory(ctx, userID, filter) + history, err := s.channels.ListChannelHistory(ctx, userID, filter) + if err != nil { + return domain.ChannelHistory{}, err + } + return s.filterBotChannelHistory(ctx, userID, history), nil } // SearchPosts returns a bounded page of public channel/supergroup posts. @@ -1223,7 +1676,7 @@ func (s *Service) SearchJoinedMessages(ctx context.Context, userID int64, req do return domain.ChannelHistory{}, nil } req.Query = strings.TrimSpace(req.Query) - if req.Query == "" { + if req.Query == "" && !req.MusicOnly { return domain.ChannelHistory{}, domain.ErrChannelInvalid } if utf8.RuneCountInString(req.Query) > domain.MaxChannelHistoryQueryLength { @@ -1243,7 +1696,56 @@ func (s *Service) GetReplies(ctx context.Context, userID int64, filter domain.Ch } filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit) - return s.channels.ListChannelReplies(ctx, userID, filter) + history, err := s.channels.ListChannelReplies(ctx, userID, filter) + if err != nil { + return domain.ChannelHistory{}, err + } + return s.filterBotChannelHistory(ctx, userID, history), nil +} + +// SendMonoforumMessage 发送频道私信(monoforum)。发件权限(订阅者身份 / monoforum 管理员) +// 由 RPC 层校验,此处仅参数校验并委托 store(store 不要求发件人是 monoforum 成员)。 +func (s *Service) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) { + if s == nil || s.channels == nil || req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if err := s.ensureCanSend(ctx, req.SenderUserID); err != nil { + return domain.SendChannelMessageResult{}, err + } + return s.channels.SendMonoforumMessage(ctx, req) +} + +// ListMonoforumHistory 拉取某订阅者在频道私信(monoforum)内的历史。 +func (s *Service) ListMonoforumHistory(ctx context.Context, filter domain.MonoforumHistoryFilter) (domain.ChannelHistory, error) { + if s == nil || s.channels == nil || filter.MonoforumID == 0 || filter.SavedPeer.ID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit) + return s.channels.ListMonoforumHistory(ctx, filter) +} + +// ListMonoforumDialogs 列出 monoforum 的订阅者子会话(管理员视角私信列表)。访问权限(仅管理员) +// 由 RPC 层校验。 +func (s *Service) ListMonoforumDialogs(ctx context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error) { + if s == nil || s.channels == nil || filter.MonoforumID == 0 { + return domain.MonoforumDialogList{}, domain.ErrChannelInvalid + } + if filter.OffsetID < 0 || filter.OffsetID > domain.MaxMessageBoxID { + return domain.MonoforumDialogList{}, domain.ErrMessageIDInvalid + } + filter.Limit = capLimit(filter.Limit, domain.MaxChannelRepliesLimit) + return s.channels.ListMonoforumDialogs(ctx, filter) +} + +// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求成员身份)并返回调用者是否为其母频道管理员。 +func (s *Service) ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) { + if s == nil || s.channels == nil || viewerUserID == 0 || monoforumID == 0 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + return s.channels.ResolveMonoforumSend(ctx, viewerUserID, monoforumID) } // GetUnreadMentions returns a bounded unread mention page for a channel/supergroup. @@ -1321,7 +1823,73 @@ func (s *Service) GetMessages(ctx context.Context, userID, channelID int64, ids return domain.ChannelHistory{}, domain.ErrMessageIDInvalid } } - return s.channels.GetChannelMessages(ctx, userID, channelID, ids) + history, err := s.channels.GetChannelMessages(ctx, userID, channelID, ids) + if err != nil { + return domain.ChannelHistory{}, err + } + return s.filterBotChannelHistory(ctx, userID, history), nil +} + +// ChannelPollFanoutViews 批量为一组 viewer 返回频道 poll 消息的 per-viewer enrich(fan-out 模板化, +// 消除逐 viewer GetMessages 的 N+1)。store 负责成员/AvailableMinID 可见性 + 模板聚合;此处叠加 +// bot 历史可见性过滤(复刻 filterBotChannelHistory:无 ChatHistory 的 bot 看不到该消息→置 nil)。 +// 返回 map[viewer]:key 存在=已评估(nil=不可见,调用方据此跳过且无需回退);非 nil=该 viewer enrich poll。 +func (s *Service) ChannelPollFanoutViews(ctx context.Context, channelID int64, msgID int, viewers []int64, now int) (map[int64]*domain.MessagePoll, error) { + if s == nil || s.channels == nil || channelID == 0 || msgID <= 0 || len(viewers) == 0 { + return map[int64]*domain.MessagePoll{}, nil + } + views, err := s.channels.ChannelPollFanoutViews(ctx, channelID, msgID, viewers, now) + if err != nil { + return nil, err + } + if !views.Found { + return map[int64]*domain.MessagePoll{}, nil + } + if s.bots != nil { + for viewer, poll := range views.Polls { + if poll == nil { + continue + } + if !s.botViewerCanSeeChannelMessage(ctx, viewer, views.Message) { + views.Polls[viewer] = nil + } + } + } + return views.Polls, nil +} + +// botViewerCanSeeChannelMessage 复刻 filterBotChannelHistory 的单消息判定:非 bot 或带 ChatHistory +// 的 bot 一律可见;无 ChatHistory 的 bot 按 botCanSeeChannelMessage 判定该条消息是否可见。 +func (s *Service) botViewerCanSeeChannelMessage(ctx context.Context, viewer int64, msg domain.ChannelMessage) bool { + if s.bots == nil || viewer == 0 { + return true + } + profile, found, err := s.bots.BotInfo(ctx, viewer) + if err != nil || !found || profile.ChatHistory { + return true + } + visible, err := s.botCanSeeChannelMessage(ctx, viewer, msg, nil) + return err == nil && visible +} + +// ListStoryMessageForwards returns public channel/supergroup messages that +// shared a source story as messageMediaStory. +func (s *Service) ListStoryMessageForwards(ctx context.Context, userID int64, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) { + if s == nil || s.channels == nil || userID == 0 { + return domain.StoryMessageForwardList{}, domain.ErrChannelInvalid + } + if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID || req.Owner.ID == 0 { + return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid + } + if req.Owner.Type != domain.PeerTypeUser && req.Owner.Type != domain.PeerTypeChannel { + return domain.StoryMessageForwardList{}, domain.ErrStoryPeerInvalid + } + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return domain.StoryMessageForwardList{}, err + } + req.ViewerUserID = userID + req.Limit = capLimit(req.Limit, domain.MaxStoryInteractionListLimit) + return s.channels.ListStoryMessageForwards(ctx, req) } // GetDiscussionMessage returns the root message used to open a discussion thread. @@ -1349,6 +1917,28 @@ func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.Read return s.channels.ReadChannelHistory(ctx, req) } +// ReadTopicHistory advances current user's per-topic read watermark inside a forum. +func (s *Service) ReadTopicHistory(ctx context.Context, userID int64, req domain.ReadChannelTopicHistoryRequest) (domain.ReadChannelTopicHistoryResult, error) { + if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID { + return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid + } + return s.channels.ReadChannelTopicHistory(ctx, req) +} + +// GeneralForumTopic 现算 forum General 话题(id=1)对 viewer 的状态。 +func (s *Service) GeneralForumTopic(ctx context.Context, userID, channelID int64) (domain.ChannelForumTopic, error) { + if s == nil || s.channels == nil || userID == 0 || channelID == 0 { + return domain.ChannelForumTopic{}, domain.ErrChannelInvalid + } + return s.channels.GeneralForumTopic(ctx, userID, channelID) +} + // GetMessageReadParticipants returns a bounded read receipt list for a small megagroup message. func (s *Service) GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) { if s == nil || s.channels == nil || userID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { @@ -1372,7 +1962,7 @@ func (s *Service) ActiveChannelIDsForUser(ctx context.Context, userID, afterChan if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { limit = domain.MaxSynchronousChannelDialogFanout } - return s.channels.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit) + return s.cachedActiveChannelIDsForUser(ctx, userID, afterChannelID, limit) } // DirtyActiveChannelsForUser pages active joined channels with channel events after sinceDate. @@ -1433,7 +2023,19 @@ func (s *Service) GetDifference(ctx context.Context, userID int64, req domain.Ch return domain.ChannelDifference{}, domain.ErrChannelInvalid } req.Limit = capLimit(req.Limit, domain.MaxChannelDifferenceLimit) - return s.channels.ListChannelDifference(ctx, req) + diff, err := s.channels.ListChannelDifference(ctx, req) + if err != nil { + return domain.ChannelDifference{}, err + } + return s.filterBotChannelDifference(ctx, userID, diff), nil +} + +// ClearDanglingPinnedMessage 清除指向已删除消息的悬挂置顶值(unpinAll 自愈)。 +func (s *Service) ClearDanglingPinnedMessage(ctx context.Context, channelID int64, messageID int) error { + if s == nil || s.channels == nil || channelID == 0 || messageID <= 0 { + return domain.ErrChannelInvalid + } + return s.channels.ClearDanglingPinnedMessage(ctx, channelID, messageID) } func capLimit(limit, max int) int { @@ -1449,28 +2051,6 @@ func capLimit(limit, max int) int { return limit } -func uniqueNonZeroLimit(ids []int64, limit int) []int64 { - if limit <= 0 { - return nil - } - out := make([]int64, 0, minInt(len(ids), limit)) - seen := make(map[int64]struct{}, minInt(len(ids), limit)) - for _, id := range ids { - if id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - if len(out) >= limit { - break - } - } - return out -} - func uniqueNonZero(ids []int64) []int64 { out := make([]int64, 0, len(ids)) seen := make(map[int64]struct{}, len(ids)) @@ -1487,11 +2067,26 @@ func uniqueNonZero(ids []int64) []int64 { return out } -func minInt(a, b int) int { - if a < b { - return a +func uniqueUserIDs(ids ...int64) []int64 { + return uniqueNonZero(ids) +} + +func activeMembershipUserIDsFromMembers(primary int64, members []domain.ChannelMember) []int64 { + ids := make([]int64, 0, len(members)+1) + if primary != 0 { + ids = append(ids, primary) } - return b + for _, member := range members { + ids = append(ids, member.UserID) + } + return uniqueNonZero(ids) +} + +func (s *Service) invalidateActiveChannelIDs(userIDs ...int64) { + if s == nil || s.activeIDsCache == nil { + return + } + s.activeIDsCache.invalidateUsers(userIDs...) } func normalizeChannelUsername(username string) string { diff --git a/internal/app/channels/service_groupcall.go b/internal/app/channels/service_groupcall.go new file mode 100644 index 00000000..67163d77 --- /dev/null +++ b/internal/app/channels/service_groupcall.go @@ -0,0 +1,38 @@ +package channels + +import ( + "context" + + "telesrv/internal/domain" +) + +// SetActiveCall 写入/清除频道行上的活跃群通话关联(groupcalls 模块专用)。 +func (s *Service) SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error) { + if s == nil || s.channels == nil || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channels.SetActiveCall(ctx, channelID, callID, callAccessHash, notEmpty) +} + +// AppendCallServiceMessage 生成群通话服务消息(started/ended/invite,带频道 pts)。 +func (s *Service) AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) { + if s == nil || s.channels == nil { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if err := s.ensureCanSend(ctx, senderUserID); err != nil { + return domain.SendChannelMessageResult{}, err + } + return s.channels.AppendCallServiceMessage(ctx, channelID, senderUserID, date, action) +} + +// AppendStarGiftAdminLog 记录频道 Star gift 的 Recent Actions 快照;它不是频道历史消息, +// 因此不产生 channel pts / updateNewChannelMessage / subscriber fanout。 +func (s *Service) AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error { + if s == nil || s.channels == nil { + return domain.ErrChannelInvalid + } + if err := s.ensureCanSend(ctx, senderUserID); err != nil { + return err + } + return s.channels.AppendStarGiftAdminLog(ctx, channelID, senderUserID, savedID, date, action) +} diff --git a/internal/app/channels/service_test.go b/internal/app/channels/service_test.go index 514ff74c..05b6fc5b 100644 --- a/internal/app/channels/service_test.go +++ b/internal/app/channels/service_test.go @@ -3,13 +3,571 @@ package channels import ( "context" "errors" + "slices" "strings" + "sync" "testing" + "time" + "telesrv/internal/app/readmodel" "telesrv/internal/domain" + "telesrv/internal/store" "telesrv/internal/store/memory" ) +type testBotProfiles map[int64]domain.BotProfile + +func TestServiceSendMessageHonorsSendPermissionGate(t *testing.T) { + ctx := context.Background() + svc := NewService(memory.NewChannelStore(), WithSendPermissionChecker(channelDenySendChecker{})) + if _, err := svc.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + UserID: 1001, + ChannelID: 2001, + RandomID: 1, + Message: "blocked", + }); !errors.Is(err, domain.ErrUserSendRestricted) { + t.Fatalf("SendMessage err=%v, want ErrUserSendRestricted", err) + } +} + +func TestServiceSendMonoforumMessageHonorsSendPermissionGate(t *testing.T) { + ctx := context.Background() + svc := NewService(memory.NewChannelStore(), WithSendPermissionChecker(channelDenySendChecker{})) + if _, err := svc.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: 2001, + SenderUserID: 1001, + SavedPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + RandomID: 1, + Message: "blocked", + }); !errors.Is(err, domain.ErrUserSendRestricted) { + t.Fatalf("SendMonoforumMessage err=%v, want ErrUserSendRestricted", err) + } +} + +type channelDenySendChecker struct{} + +func (channelDenySendChecker) CanSendMessages(context.Context, int64) error { + return domain.ErrUserSendRestricted +} + +func (p testBotProfiles) BotInfo(_ context.Context, botUserID int64) (domain.BotProfile, bool, error) { + profile, ok := p[botUserID] + return profile, ok, nil +} + +type countingChannelStore struct { + *memory.ChannelStore + mu sync.Mutex + getChannelCalls int + resolveChannelCalls int + countMediaCalls int + getParticipantCalls int + listActiveIDsCalls int + resolveStarted chan struct{} + resolveRelease <-chan struct{} + resolveStartOnce sync.Once +} + +func (s *countingChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { + s.getChannelCalls++ + return s.ChannelStore.GetChannel(ctx, viewerUserID, channelID) +} + +func (s *countingChannelStore) ResolveChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { + s.mu.Lock() + s.resolveChannelCalls++ + if s.resolveStarted != nil { + s.resolveStartOnce.Do(func() { close(s.resolveStarted) }) + } + release := s.resolveRelease + s.mu.Unlock() + if release != nil { + <-release + } + return s.ChannelStore.ResolveChannel(ctx, viewerUserID, channelID) +} + +func (s *countingChannelStore) CountChannelMediaCategories(ctx context.Context, viewerUserID, channelID int64) (domain.MediaCategoryCounts, error) { + s.countMediaCalls++ + return s.ChannelStore.CountChannelMediaCategories(ctx, viewerUserID, channelID) +} + +func (s *countingChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { + s.getParticipantCalls++ + return s.ChannelStore.GetParticipants(ctx, viewerUserID, channelID, filter, offset, limit) +} + +func (s *countingChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { + s.listActiveIDsCalls++ + return s.ChannelStore.ListActiveChannelIDsForUser(ctx, userID, afterChannelID, limit) +} + +type fakeReadModelVersions struct { + hashes map[store.ReadModelKey]int64 +} + +func (f *fakeReadModelVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) { + hash := f.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}] + return hash, hash != 0, nil +} + +func (f *fakeReadModelVersions) ReadModelHashes(_ context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) { + out := make(map[store.ReadModelKey]int64, len(keys)) + for _, key := range keys { + if hash := f.hashes[key]; hash != 0 { + out[key] = hash + } + } + return out, nil +} + +func TestChannelFullViewReadModelCacheDefaultTTLIsLongLived(t *testing.T) { + if defaultChannelViewReadModelTTL != 24*time.Hour { + t.Fatalf("default full channel read model TTL = %v, want 24h", defaultChannelViewReadModelTTL) + } + if cache := newChannelViewReadModelCache(0); cache == nil { + t.Fatal("newChannelViewReadModelCache(0) 应返回非 nil 缓存") + } +} + +func TestGetChannelCachesFullViewByCompositeReadModelHash(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + base := &countingChannelStore{ChannelStore: memory.NewChannelStore()} + service := NewService(base) + created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Cached Full", + Megagroup: true, + Date: 1700004100, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{ + {Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 11, + {Model: readmodel.ModelChannelMember, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 22, + {Model: readmodel.ModelDialogLight, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 33, + }} + service = NewService(base, WithReadModelVersions(versions)) + + first, err := service.GetChannelReadModel(ctx, ownerID, created.Channel.ID) + if err != nil { + t.Fatalf("first GetChannel: %v", err) + } + if first.Channel.ID != created.Channel.ID || first.Self.UserID != ownerID { + t.Fatalf("first channel view = %+v, want owner view", first) + } + first.Channel.PhotoStripped = []byte{1, 2, 3} + first.Dialog.DefaultSendAs = &peer + second, err := service.GetChannelReadModel(ctx, ownerID, created.Channel.ID) + if err != nil { + t.Fatalf("second GetChannel: %v", err) + } + if base.getChannelCalls != 1 { + t.Fatalf("GetChannel calls = %d, want 1 after cache hit", base.getChannelCalls) + } + if len(second.Channel.PhotoStripped) != 0 || second.Dialog.DefaultSendAs != nil { + t.Fatalf("cached channel view was mutated by caller: %+v", second) + } + + versions.hashes[store.ReadModelKey{Model: readmodel.ModelChannelMember, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 44 + if _, err := service.GetChannelReadModel(ctx, ownerID, created.Channel.ID); err != nil { + t.Fatalf("GetChannel after hash bump: %v", err) + } + if base.getChannelCalls != 2 { + t.Fatalf("GetChannel calls after hash bump = %d, want 2", base.getChannelCalls) + } +} + +func TestResolveChannelCachesAccessViewByCompositeReadModelHash(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + base := &countingChannelStore{ChannelStore: memory.NewChannelStore()} + service := NewService(base) + created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Cached Resolve", + Megagroup: true, + Date: 1700004103, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + memberKey := store.ReadModelKey{Model: readmodel.ModelChannelMember, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID} + baseKey := store.ReadModelKey{Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID} + versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{ + baseKey: 51, + memberKey: 52, + }} + service = NewService(base, WithReadModelVersions(versions)) + + first, err := service.ResolveChannel(ctx, ownerID, created.Channel.ID) + if err != nil { + t.Fatalf("first ResolveChannel: %v", err) + } + if first.Channel.ID != created.Channel.ID || first.Self.UserID != ownerID { + t.Fatalf("first resolve view = %+v, want owner view", first) + } + first.Channel.PhotoStripped = []byte{1, 2, 3} + first.Dialog.DefaultSendAs = &peer + second, err := service.ResolveChannel(ctx, ownerID, created.Channel.ID) + if err != nil { + t.Fatalf("second ResolveChannel: %v", err) + } + if base.resolveChannelCalls != 1 { + t.Fatalf("ResolveChannel calls = %d, want 1 after cache hit", base.resolveChannelCalls) + } + if len(second.Channel.PhotoStripped) != 0 || second.Dialog.DefaultSendAs != nil { + t.Fatalf("cached resolve view was mutated by caller: %+v", second) + } + + versions.hashes[memberKey] = 53 + if _, err := service.ResolveChannel(ctx, ownerID, created.Channel.ID); err != nil { + t.Fatalf("ResolveChannel after member hash bump: %v", err) + } + if base.resolveChannelCalls != 2 { + t.Fatalf("ResolveChannel calls after member hash bump = %d, want 2", base.resolveChannelCalls) + } + + versions.hashes[baseKey] = 54 + if _, err := service.ResolveChannel(ctx, ownerID, created.Channel.ID); err != nil { + t.Fatalf("ResolveChannel after base hash bump: %v", err) + } + if base.resolveChannelCalls != 3 { + t.Fatalf("ResolveChannel calls after base hash bump = %d, want 3", base.resolveChannelCalls) + } +} + +func TestActiveChannelIDsForUserCachesPageByReadModelHash(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + base := &countingChannelStore{ChannelStore: memory.NewChannelStore()} + service := NewService(base) + firstChannel, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Active One", + Megagroup: true, + Date: 1700004110, + }) + if err != nil { + t.Fatalf("CreateChannel first: %v", err) + } + secondChannel, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Active Two", + Megagroup: true, + Date: 1700004111, + }) + if err != nil { + t.Fatalf("CreateChannel second: %v", err) + } + key := store.ReadModelKey{Model: readmodel.ModelChannelActiveIDs, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID} + versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 201}} + service = NewService(base, WithReadModelVersions(versions)) + + want := []int64{firstChannel.Channel.ID, secondChannel.Channel.ID} + first, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, domain.MaxSynchronousChannelDialogFanout) + if err != nil { + t.Fatalf("first ActiveChannelIDsForUser: %v", err) + } + if !slices.Equal(first, want) { + t.Fatalf("first active ids = %v, want %v", first, want) + } + first[0] = 999 + second, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, domain.MaxSynchronousChannelDialogFanout) + if err != nil { + t.Fatalf("second ActiveChannelIDsForUser: %v", err) + } + if base.listActiveIDsCalls != 1 { + t.Fatalf("ListActiveChannelIDsForUser calls = %d, want 1 after cache hit", base.listActiveIDsCalls) + } + if !slices.Equal(second, want) { + t.Fatalf("cached active ids were mutated: got %v, want %v", second, want) + } + + versions.hashes[key] = 202 + if _, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, domain.MaxSynchronousChannelDialogFanout); err != nil { + t.Fatalf("active ids after hash bump: %v", err) + } + if base.listActiveIDsCalls != 2 { + t.Fatalf("ListActiveChannelIDsForUser calls after hash bump = %d, want 2", base.listActiveIDsCalls) + } +} + +func TestActiveChannelIDsForUserCachesEmptyMissingReadModelHash(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + base := &countingChannelStore{ChannelStore: memory.NewChannelStore()} + service := NewService(base, WithReadModelVersions(&fakeReadModelVersions{})) + + first, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, domain.MaxSynchronousChannelDialogFanout) + if err != nil { + t.Fatalf("first ActiveChannelIDsForUser: %v", err) + } + if len(first) != 0 { + t.Fatalf("first active ids = %v, want empty", first) + } + second, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, domain.MaxSynchronousChannelDialogFanout) + if err != nil { + t.Fatalf("second ActiveChannelIDsForUser: %v", err) + } + if len(second) != 0 { + t.Fatalf("second active ids = %v, want empty", second) + } + if base.listActiveIDsCalls != 1 { + t.Fatalf("ListActiveChannelIDsForUser calls = %d, want 1 after empty cache hit", base.listActiveIDsCalls) + } + + created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Now Active", + Megagroup: true, + Date: 1700004114, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + afterWrite, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, domain.MaxSynchronousChannelDialogFanout) + if err != nil { + t.Fatalf("active ids after write: %v", err) + } + if want := []int64{created.Channel.ID}; !slices.Equal(afterWrite, want) { + t.Fatalf("active ids after write = %v, want %v", afterWrite, want) + } + if base.listActiveIDsCalls != 2 { + t.Fatalf("ListActiveChannelIDsForUser calls after write = %d, want 2", base.listActiveIDsCalls) + } + if _, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, domain.MaxSynchronousChannelDialogFanout); err != nil { + t.Fatalf("active ids cached after write: %v", err) + } + if base.listActiveIDsCalls != 2 { + t.Fatalf("ListActiveChannelIDsForUser calls after second post-write read = %d, want 2", base.listActiveIDsCalls) + } +} + +func TestActiveChannelIDsCacheInvalidatesOnMembershipWrite(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + base := &countingChannelStore{ChannelStore: memory.NewChannelStore()} + key := store.ReadModelKey{Model: readmodel.ModelChannelActiveIDs, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID} + versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 301}} + service := NewService(base, WithReadModelVersions(versions)) + firstChannel, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Write One", + Megagroup: true, + Date: 1700004112, + }) + if err != nil { + t.Fatalf("CreateChannel first: %v", err) + } + if _, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, domain.MaxSynchronousChannelDialogFanout); err != nil { + t.Fatalf("warm active ids: %v", err) + } + if base.listActiveIDsCalls != 1 { + t.Fatalf("ListActiveChannelIDsForUser calls after warm = %d, want 1", base.listActiveIDsCalls) + } + + secondChannel, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Write Two", + Megagroup: true, + Date: 1700004113, + }) + if err != nil { + t.Fatalf("CreateChannel second: %v", err) + } + got, err := service.ActiveChannelIDsForUser(ctx, ownerID, 0, domain.MaxSynchronousChannelDialogFanout) + if err != nil { + t.Fatalf("active ids after create: %v", err) + } + want := []int64{firstChannel.Channel.ID, secondChannel.Channel.ID} + if !slices.Equal(got, want) { + t.Fatalf("active ids after create = %v, want %v", got, want) + } + if base.listActiveIDsCalls != 2 { + t.Fatalf("ListActiveChannelIDsForUser calls after create invalidation = %d, want 2", base.listActiveIDsCalls) + } +} + +func TestResolveChannelSingleflightsConcurrentMiss(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + started := make(chan struct{}) + release := make(chan struct{}) + base := &countingChannelStore{ + ChannelStore: memory.NewChannelStore(), + resolveStarted: started, + resolveRelease: release, + } + service := NewService(base) + created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Concurrent Resolve", + Megagroup: true, + Date: 1700004104, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{ + {Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 61, + {Model: readmodel.ModelChannelMember, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 62, + }} + service = NewService(base, WithReadModelVersions(versions)) + + const goroutines = 16 + var wg sync.WaitGroup + errs := make(chan error, goroutines) + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + view, err := service.ResolveChannel(ctx, ownerID, created.Channel.ID) + if err != nil { + errs <- err + return + } + if view.Channel.ID != created.Channel.ID || view.Self.UserID != ownerID { + errs <- errors.New("unexpected resolve view") + } + }() + } + <-started + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if base.resolveChannelCalls != 1 { + t.Fatalf("ResolveChannel calls = %d, want 1", base.resolveChannelCalls) + } +} + +func TestCountChannelMediaCategoriesCachesByCompositeReadModelHash(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + base := &countingChannelStore{ChannelStore: memory.NewChannelStore()} + service := NewService(base) + created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Media Counts", + Megagroup: true, + Date: 1700004101, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{ + {Model: readmodel.ModelChannelMediaCounts, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 91, + {Model: readmodel.ModelChannelMember, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 92, + }} + service = NewService(base, WithReadModelVersions(versions)) + + first, err := service.CountChannelMediaCategories(ctx, ownerID, created.Channel.ID) + if err != nil { + t.Fatalf("first count media: %v", err) + } + first[domain.MediaCategoryPhoto] = 99 + second, err := service.CountChannelMediaCategories(ctx, ownerID, created.Channel.ID) + if err != nil { + t.Fatalf("second count media: %v", err) + } + if base.countMediaCalls != 1 { + t.Fatalf("CountChannelMediaCategories calls = %d, want 1 after cache hit", base.countMediaCalls) + } + if second[domain.MediaCategoryPhoto] != 0 { + t.Fatalf("cached media counts were mutated by caller: %+v", second) + } + + service.InvalidateChannelMediaCountReadModel(created.Channel.ID) + if _, err := service.CountChannelMediaCategories(ctx, ownerID, created.Channel.ID); err != nil { + t.Fatalf("count media after explicit invalidation: %v", err) + } + if base.countMediaCalls != 2 { + t.Fatalf("CountChannelMediaCategories calls after explicit invalidation = %d, want 2", base.countMediaCalls) + } + + versions.hashes[store.ReadModelKey{Model: readmodel.ModelChannelMember, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 93 + if _, err := service.CountChannelMediaCategories(ctx, ownerID, created.Channel.ID); err != nil { + t.Fatalf("count media after hash bump: %v", err) + } + if base.countMediaCalls != 3 { + t.Fatalf("CountChannelMediaCategories calls after hash bump = %d, want 3", base.countMediaCalls) + } +} + +func TestGetParticipantsCachesPageByCompositeReadModelHash(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + base := &countingChannelStore{ChannelStore: memory.NewChannelStore()} + service := NewService(base) + created, err := service.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Participants", + Megagroup: true, + MemberUserIDs: []int64{1002, 1003}, + Date: 1700004102, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + versions := &fakeReadModelVersions{hashes: map[store.ReadModelKey]int64{ + {Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 101, + {Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 102, + {Model: readmodel.ModelChannelMember, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 103, + {Model: readmodel.ModelContactAccount, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}: 104, + }} + service = NewService(base, WithReadModelVersions(versions)) + + first, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}, 0, 20) + if err != nil { + t.Fatalf("first participants: %v", err) + } + if first.Hash == 0 || len(first.Participants) != 3 { + t.Fatalf("first participants = %+v, want hash and three members", first) + } + first.Channel.PhotoStripped = []byte{1, 2, 3} + first.Participants[0].Rank = "mutated" + second, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, domain.ChannelParticipantsFilter{}, 0, 20) + if err != nil { + t.Fatalf("second participants: %v", err) + } + if base.getParticipantCalls != 1 { + t.Fatalf("GetParticipants calls = %d, want 1 after cache hit", base.getParticipantCalls) + } + if second.Hash != first.Hash { + t.Fatalf("second hash = %d, want %d", second.Hash, first.Hash) + } + if len(second.Channel.PhotoStripped) != 0 || second.Participants[0].Rank == "mutated" { + t.Fatalf("cached participants were mutated by caller: %+v", second) + } + + versions.hashes[store.ReadModelKey{Model: readmodel.ModelContactAccount, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: ownerID}] = 105 + third, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}, 0, 20) + if err != nil { + t.Fatalf("participants after contact hash bump: %v", err) + } + if base.getParticipantCalls != 2 { + t.Fatalf("GetParticipants calls after contact hash bump = %d, want 2", base.getParticipantCalls) + } + if third.Hash == first.Hash { + t.Fatalf("third hash = %d, want changed from %d", third.Hash, first.Hash) + } + + versions.hashes[store.ReadModelKey{Model: readmodel.ModelChannelParticipants, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}] = 106 + fourth, err := service.GetParticipants(ctx, ownerID, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}, 0, 20) + if err != nil { + t.Fatalf("participants after participant hash bump: %v", err) + } + if base.getParticipantCalls != 3 { + t.Fatalf("GetParticipants calls after participant hash bump = %d, want 3", base.getParticipantCalls) + } + if fourth.Hash == third.Hash { + t.Fatalf("fourth hash = %d, want changed from %d", fourth.Hash, third.Hash) + } +} + func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) { ctx := context.Background() store := memory.NewChannelStore() @@ -37,6 +595,7 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) { ChannelID: created.Channel.ID, RandomID: 99, Message: "hello", + ViaBotID: 1003, Date: 11, }) if err != nil { @@ -45,6 +604,9 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) { if sent.Message.ID != 2 || sent.Message.Pts != 2 || sent.Event.Pts != 2 || sent.Event.PtsCount != 1 { t.Fatalf("sent = %+v event=%+v, want message id/pts=2", sent.Message, sent.Event) } + if sent.Message.ViaBotID != 1003 || sent.Event.Message.ViaBotID != 1003 { + t.Fatalf("sent via_bot_id = msg %d event %d, want 1003", sent.Message.ViaBotID, sent.Event.Message.ViaBotID) + } duplicate, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ ChannelID: created.Channel.ID, @@ -58,6 +620,9 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) { if !duplicate.Duplicate || duplicate.Message.ID != sent.Message.ID || duplicate.Message.Body != "hello" { t.Fatalf("duplicate = %+v, want original single-copy message", duplicate) } + if duplicate.Message.ViaBotID != 1003 || duplicate.Event.Message.ViaBotID != 1003 { + t.Fatalf("duplicate via_bot_id = msg %d event %d, want 1003", duplicate.Message.ViaBotID, duplicate.Event.Message.ViaBotID) + } history, err := service.GetHistory(ctx, 1002, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) if err != nil { @@ -66,6 +631,9 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) { if len(history.Messages) != 2 || history.Messages[0].ID != 2 || history.Messages[1].ID != 1 { t.Fatalf("history = %+v, want channel messages newest first", history.Messages) } + if history.Messages[0].ViaBotID != 1003 { + t.Fatalf("history via_bot_id = %d, want 1003", history.Messages[0].ViaBotID) + } diff, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: 1, Limit: 10}) if err != nil { @@ -74,6 +642,9 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) { if !diff.Final || diff.Pts != 2 || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "hello" { t.Fatalf("diff = %+v, want single new channel message at pts=2", diff) } + if diff.NewMessages[0].ViaBotID != 1003 { + t.Fatalf("diff via_bot_id = %d, want 1003", diff.NewMessages[0].ViaBotID) + } if _, err := service.GetDifference(ctx, 1002, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: sent.Event.Pts + 1, Limit: 10}); !errors.Is(err, domain.ErrPersistentTimestamp) { t.Fatalf("future pts diff err = %v, want persistent timestamp invalid", err) } @@ -97,6 +668,198 @@ func TestCreateChatCreatesMegagroupWithChannelPts(t *testing.T) { } } +func TestGroupBotPolicies(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + bots := testBotProfiles{ + 1003: {BotUserID: 1003, ChatHistory: false, Nochats: false}, + 1004: {BotUserID: 1004, ChatHistory: false, Nochats: true}, + } + service := NewService(store, WithBotProfileResolver(bots)) + + if _, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Blocked", + MemberUserIDs: []int64{1004}, + Date: 10, + }); !errors.Is(err, domain.ErrBotGroupsBlocked) { + t.Fatalf("create chat with nochats bot err = %v, want ErrBotGroupsBlocked", err) + } + + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Bots", + MemberUserIDs: []int64{1002, 1003}, + Date: 20, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if _, err := service.InviteToChannel(ctx, 1001, created.Channel.ID, []int64{1004}, 21); !errors.Is(err, domain.ErrBotGroupsBlocked) { + t.Fatalf("invite nochats bot err = %v, want ErrBotGroupsBlocked", err) + } + botParticipants, err := service.GetParticipants(ctx, 1001, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBots}, 0, 20) + if err != nil { + t.Fatalf("GetParticipants bots: %v", err) + } + if botParticipants.Count != 1 || len(botParticipants.Participants) != 1 || botParticipants.Participants[0].UserID != 1003 { + t.Fatalf("bot participants = %+v, want bot 1003 only", botParticipants) + } + + plain, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2001, + Message: "plain group text", + Date: 22, + }) + if err != nil { + t.Fatalf("SendMessage plain: %v", err) + } + if testContainsInt64(plain.Recipients, 1003) { + t.Fatalf("plain recipients = %+v, privacy bot must be skipped", plain.Recipients) + } + hiddenDiff, err := service.GetDifference(ctx, 1003, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: created.Event.Pts, Limit: 20}) + if err != nil { + t.Fatalf("GetDifference hidden: %v", err) + } + if hiddenDiff.Pts != plain.Event.Pts || len(hiddenDiff.NewMessages) != 0 || len(hiddenDiff.Events) != 0 { + t.Fatalf("hidden diff = %+v, want pts advanced without messages", hiddenDiff) + } + hiddenHistory, err := service.GetHistory(ctx, 1003, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 20}) + if err != nil { + t.Fatalf("GetHistory hidden: %v", err) + } + for _, msg := range hiddenHistory.Messages { + if msg.Body == "plain group text" { + t.Fatalf("bot history leaked hidden message: %+v", hiddenHistory.Messages) + } + } + + command, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2002, + Message: "/status", + Date: 23, + }) + if err != nil { + t.Fatalf("SendMessage command: %v", err) + } + if !testContainsInt64(command.Recipients, 1003) { + t.Fatalf("command recipients = %+v, want privacy bot", command.Recipients) + } + visibleDiff, err := service.GetDifference(ctx, 1003, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: plain.Event.Pts, Limit: 20}) + if err != nil { + t.Fatalf("GetDifference command: %v", err) + } + if len(visibleDiff.NewMessages) != 1 || visibleDiff.NewMessages[0].Body != "/status" { + t.Fatalf("visible diff = %+v, want command only", visibleDiff.NewMessages) + } + view, err := service.GetChannel(ctx, 1003, created.Channel.ID) + if err != nil { + t.Fatalf("GetChannel bot: %v", err) + } + if view.Dialog.UnreadCount != 1 || view.Dialog.ReadInboxMaxID != plain.Message.ID { + t.Fatalf("bot dialog unread/read = %d/%d, want only command unread after hidden boundary %d", view.Dialog.UnreadCount, view.Dialog.ReadInboxMaxID, plain.Message.ID) + } + + botMessage, err := service.SendMessage(ctx, 1003, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 3001, + Message: "bot said", + Date: 24, + }) + if err != nil { + t.Fatalf("SendMessage bot: %v", err) + } + reply, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2003, + Message: "reply to bot", + ReplyTo: &domain.MessageReply{MessageID: botMessage.Message.ID}, + Date: 25, + }) + if err != nil { + t.Fatalf("SendMessage reply: %v", err) + } + if !testContainsInt64(reply.Recipients, 1003) { + t.Fatalf("reply recipients = %+v, want privacy bot", reply.Recipients) + } +} + +// TestPrivacyBotReceivesDeleteEventsInDifference 回归: privacy-mode bot 经 +// getChannelDifference 补差必须收到删除事件。旧逻辑用 GetChannelMessages 重取已删消息 +// 判可见性,而该查询带 AND NOT deleted 恒返空→整条 delete 事件被丢弃,导致 bot 对所有删除 +// 失明、客户端缓存残留"未删"态。修复后删除事件直接放行(与在线推送一致,删除 id 不泄漏内容)。 +func TestPrivacyBotReceivesDeleteEventsInDifference(t *testing.T) { + ctx := context.Background() + store := memory.NewChannelStore() + bots := testBotProfiles{ + 1003: {BotUserID: 1003, ChatHistory: false, Nochats: false}, + } + service := NewService(store, WithBotProfileResolver(bots)) + + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Bots", + MemberUserIDs: []int64{1002, 1003}, + Date: 20, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + + // 命令消息 privacy bot 可见(messageIsCommand)。 + command, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2001, + Message: "/status", + Date: 22, + }) + if err != nil { + t.Fatalf("SendMessage command: %v", err) + } + + deleted, err := service.DeleteMessages(ctx, 1001, domain.DeleteChannelMessagesRequest{ + ChannelID: created.Channel.ID, + IDs: []int{command.Message.ID}, + Date: 23, + }) + if err != nil { + t.Fatalf("DeleteMessages: %v", err) + } + if deleted.Event.Type != domain.ChannelUpdateDeleteMessages { + t.Fatalf("delete event type = %v, want delete", deleted.Event.Type) + } + + diff, err := service.GetDifference(ctx, 1003, domain.ChannelDifferenceRequest{ChannelID: created.Channel.ID, Pts: command.Event.Pts, Limit: 20}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + foundDelete := false + for _, ev := range diff.Events { + if ev.Type != domain.ChannelUpdateDeleteMessages { + continue + } + for _, id := range ev.MessageIDs { + if id == command.Message.ID { + foundDelete = true + } + } + } + if !foundDelete { + t.Fatalf("privacy bot diff = %+v, want delete event for msg %d", diff, command.Message.ID) + } + if diff.Pts != deleted.Event.Pts { + t.Fatalf("diff pts = %d, want advanced to %d", diff.Pts, deleted.Event.Pts) + } +} + +func testContainsInt64(ids []int64, target int64) bool { + for _, id := range ids { + if id == target { + return true + } + } + return false +} + func TestChannelUnreadMentionsArePagedAndCleared(t *testing.T) { ctx := context.Background() store := memory.NewChannelStore() @@ -189,8 +952,9 @@ func TestChannelUnreadMentionsArePagedAndCleared(t *testing.T) { if err != nil { t.Fatalf("GetHistory after read mentions: %v", err) } - if len(history.Messages) == 0 || history.Messages[0].Mentioned || history.Messages[0].MediaUnread { - t.Fatalf("mentioned history after read = %+v, want mention flags cleared", history.Messages) + // 官方语义:mention 已读后 mentioned 高亮永久保留,仅 media_unread 清除。 + if len(history.Messages) == 0 || !history.Messages[0].Mentioned || history.Messages[0].MediaUnread { + t.Fatalf("mentioned history after read = %+v, want mentioned kept with media_unread cleared", history.Messages) } } @@ -410,18 +1174,40 @@ func TestDefaultBannedRightsRestrictMemberSendAndInvite(t *testing.T) { if _, err := service.InviteToChannel(ctx, 1002, created.Channel.ID, []int64{1003}, 12); !errors.Is(err, domain.ErrChannelAdminRequired) { t.Fatalf("member InviteToChannel err = %v, want ErrChannelAdminRequired", err) } + updated, err = service.SetBoostsToUnblockRestrictions(ctx, 1001, created.Channel.ID, 1) + if err != nil { + t.Fatalf("SetBoostsToUnblockRestrictions: %v", err) + } + if updated.BoostsUnrestrict != 1 { + t.Fatalf("boosts unrestrict = %d, want 1", updated.BoostsUnrestrict) + } + if _, err := service.ApplyPremiumBoost(ctx, 1002, created.Channel.ID, []int{domain.DefaultPremiumBoostSlotID}, 13, 1000); err != nil { + t.Fatalf("ApplyPremiumBoost member: %v", err) + } + boosted, err := service.SendMessage(ctx, 1002, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 4, + Message: "member boosted ok", + Date: 14, + }) + if err != nil { + t.Fatalf("member SendMessage after boost: %v", err) + } + if boosted.Message.FromBoostsApplied != 1 { + t.Fatalf("from_boosts_applied = %d, want 1", boosted.Message.FromBoostsApplied) + } if _, err := service.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ ChannelID: created.Channel.ID, RandomID: 2, Message: "owner ok", - Date: 13, + Date: 15, }); err != nil { t.Fatalf("creator SendMessage under default rights: %v", err) } if _, err := service.EditDefaultBannedRights(ctx, 1001, domain.EditChannelDefaultBannedRightsRequest{ ChannelID: created.Channel.ID, BannedRights: domain.ChannelBannedRights{}, - Date: 14, + Date: 16, }); err != nil { t.Fatalf("clear default banned rights: %v", err) } @@ -429,7 +1215,7 @@ func TestDefaultBannedRightsRestrictMemberSendAndInvite(t *testing.T) { ChannelID: created.Channel.ID, RandomID: 3, Message: "member ok", - Date: 15, + Date: 17, }); err != nil { t.Fatalf("member SendMessage after clear: %v", err) } @@ -632,6 +1418,61 @@ func TestParticipantsHiddenHidesMemberListAndReadParticipants(t *testing.T) { } } +func TestAnonymousAdminHiddenFromRegularParticipantLists(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + created, err := service.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Anonymous Admins", + MemberUserIDs: []int64{1002, 1003}, + Date: 10, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + if _, err := service.EditAdmin(ctx, 1001, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: 1002, + AdminRights: domain.ChannelAdminRights{ + Anonymous: true, + ChangeInfo: true, + }, + Date: 11, + }); err != nil { + t.Fatalf("EditAdmin anonymous: %v", err) + } + + recent, err := service.GetParticipants(ctx, 1003, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}, 0, 10) + if err != nil { + t.Fatalf("regular GetParticipants recent: %v", err) + } + if containsChannelParticipant(recent.Participants, 1002) || recent.Count != 2 { + t.Fatalf("regular recent participants = %+v count=%d, want anonymous admin hidden and count adjusted", recent.Participants, recent.Count) + } + admins, err := service.GetParticipants(ctx, 1003, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}, 0, 10) + if err != nil { + t.Fatalf("regular GetParticipants admins: %v", err) + } + if containsChannelParticipant(admins.Participants, 1002) || len(admins.Participants) != 1 || admins.Participants[0].UserID != 1001 { + t.Fatalf("regular admin participants = %+v, want only creator visible", admins.Participants) + } + adminView, err := service.GetParticipants(ctx, 1001, created.Channel.ID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}, 0, 10) + if err != nil { + t.Fatalf("owner GetParticipants admins: %v", err) + } + if !containsChannelParticipant(adminView.Participants, 1002) { + t.Fatalf("owner admin participants = %+v, want anonymous admin visible to admins", adminView.Participants) + } +} + +func containsChannelParticipant(participants []domain.ChannelMember, userID int64) bool { + for _, participant := range participants { + if participant.UserID == userID { + return true + } + } + return false +} + func TestBroadcastRejectsMemberPost(t *testing.T) { ctx := context.Background() service := NewService(memory.NewChannelStore()) @@ -1051,8 +1892,13 @@ func TestChannelBanAndDeletePermissions(t *testing.T) { if banned.Participant.Status != domain.ChannelMemberKicked || banned.Channel.ParticipantsCount != 1 || banned.Channel.KickedCount != 1 { t.Fatalf("banned = %+v, want kicked participant and counts", banned) } - if banned.Channel.Pts != ptsBeforeBan { - t.Fatalf("banned channel pts = %d, want unchanged %d", banned.Channel.Pts, ptsBeforeBan) + // megagroup 踢人产生 "X removed Y" 服务消息并占一个 channel pts; + // participant update 自身仍是 transient。 + if banned.Channel.Pts != ptsBeforeBan+1 || banned.ServiceEvent.Pts != ptsBeforeBan+1 { + t.Fatalf("banned channel pts = %d service %d, want kick service message at %d", banned.Channel.Pts, banned.ServiceEvent.Pts, ptsBeforeBan+1) + } + if banned.Message.Action == nil || banned.Message.Action.Type != domain.ChannelActionChatDelete { + t.Fatalf("kick service message = %+v, want ChatDelete action", banned.Message) } if banned.Event.Type != domain.ChannelUpdateParticipant || banned.Event.Participant.Status != domain.ChannelMemberKicked || banned.Event.Pts != 0 || banned.Event.PtsCount != 0 { t.Fatalf("ban participant event = %+v, want transient kicked transition", banned.Event) @@ -1231,6 +2077,192 @@ func TestChannelUsernameAndSignatures(t *testing.T) { } } +func TestListStoryPostableChannelsFiltersPostStoryRights(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + userID := int64(1001) + creatorID := int64(2001) + created, err := service.CreateChannel(ctx, userID, domain.CreateChannelRequest{ + CreatorUserID: userID, + Title: "own private story channel", + Broadcast: true, + Date: 1, + }) + if err != nil { + t.Fatalf("CreateChannel own: %v", err) + } + postable, err := service.CreateChannel(ctx, creatorID, domain.CreateChannelRequest{ + CreatorUserID: creatorID, + Title: "post stories admin", + Broadcast: true, + MemberUserIDs: []int64{userID}, + Date: 2, + }) + if err != nil { + t.Fatalf("CreateChannel postable: %v", err) + } + if _, err := service.EditAdmin(ctx, creatorID, domain.EditChannelAdminRequest{ + ChannelID: postable.Channel.ID, + MemberID: userID, + AdminRights: domain.ChannelAdminRights{ + PostStories: true, + }, + Date: 3, + }); err != nil { + t.Fatalf("EditAdmin post stories: %v", err) + } + editOnly, err := service.CreateChannel(ctx, creatorID, domain.CreateChannelRequest{ + CreatorUserID: creatorID, + Title: "edit stories only", + Broadcast: true, + MemberUserIDs: []int64{userID}, + Date: 4, + }) + if err != nil { + t.Fatalf("CreateChannel edit-only: %v", err) + } + if _, err := service.EditAdmin(ctx, creatorID, domain.EditChannelAdminRequest{ + ChannelID: editOnly.Channel.ID, + MemberID: userID, + AdminRights: domain.ChannelAdminRights{ + EditStories: true, + }, + Date: 5, + }); err != nil { + t.Fatalf("EditAdmin edit stories: %v", err) + } + memberOnly, err := service.CreateChannel(ctx, creatorID, domain.CreateChannelRequest{ + CreatorUserID: creatorID, + Title: "member story channel", + Broadcast: true, + MemberUserIDs: []int64{userID}, + Date: 6, + }) + if err != nil { + t.Fatalf("CreateChannel member-only: %v", err) + } + + list, err := service.ListStoryPostableChannels(ctx, userID) + if err != nil { + t.Fatalf("ListStoryPostableChannels: %v", err) + } + got := make([]int64, 0, len(list)) + for _, channel := range list { + got = append(got, channel.ID) + } + want := []int64{postable.Channel.ID, created.Channel.ID} + if len(got) != len(want) { + t.Fatalf("story postable channel ids = %v, want %v; excluded edit-only=%d member-only=%d", got, want, editOnly.Channel.ID, memberOnly.Channel.ID) + } + if got[0] != want[0] || got[1] != want[1] { + t.Fatalf("story postable channel ids = %v, want %v; excluded edit-only=%d member-only=%d", got, want, editOnly.Channel.ID, memberOnly.Channel.ID) + } +} + +func TestListSendAsChannelsFiltersPostMessageRights(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewChannelStore()) + userID := int64(1001) + creatorID := int64(2001) + + // Broadcast channel the user created → eligible. + owned, err := service.CreateChannel(ctx, userID, domain.CreateChannelRequest{ + CreatorUserID: userID, + Title: "own broadcast", + Broadcast: true, + Date: 1, + }) + if err != nil { + t.Fatalf("CreateChannel owned: %v", err) + } + // Broadcast channel where the user is an admin holding PostMessages → eligible. + postAdmin, err := service.CreateChannel(ctx, creatorID, domain.CreateChannelRequest{ + CreatorUserID: creatorID, + Title: "post admin broadcast", + Broadcast: true, + MemberUserIDs: []int64{userID}, + Date: 2, + }) + if err != nil { + t.Fatalf("CreateChannel post-admin: %v", err) + } + if _, err := service.EditAdmin(ctx, creatorID, domain.EditChannelAdminRequest{ + ChannelID: postAdmin.Channel.ID, + MemberID: userID, + AdminRights: domain.ChannelAdminRights{PostMessages: true}, + Date: 3, + }); err != nil { + t.Fatalf("EditAdmin post messages: %v", err) + } + // Broadcast channel where the user is an admin WITHOUT PostMessages → excluded. + editAdmin, err := service.CreateChannel(ctx, creatorID, domain.CreateChannelRequest{ + CreatorUserID: creatorID, + Title: "edit admin broadcast", + Broadcast: true, + MemberUserIDs: []int64{userID}, + Date: 4, + }) + if err != nil { + t.Fatalf("CreateChannel edit-admin: %v", err) + } + if _, err := service.EditAdmin(ctx, creatorID, domain.EditChannelAdminRequest{ + ChannelID: editAdmin.Channel.ID, + MemberID: userID, + AdminRights: domain.ChannelAdminRights{EditMessages: true, DeleteMessages: true}, + Date: 5, + }); err != nil { + t.Fatalf("EditAdmin edit messages: %v", err) + } + // Megagroup the user created → excluded (sending as an owned megagroup is not a real capability). + megagroup, err := service.CreateChannel(ctx, userID, domain.CreateChannelRequest{ + CreatorUserID: userID, + Title: "own megagroup", + Megagroup: true, + Date: 6, + }) + if err != nil { + t.Fatalf("CreateChannel megagroup: %v", err) + } + // Broadcast channel the user is only a member of → excluded. + memberOnly, err := service.CreateChannel(ctx, creatorID, domain.CreateChannelRequest{ + CreatorUserID: creatorID, + Title: "member broadcast", + Broadcast: true, + MemberUserIDs: []int64{userID}, + Date: 7, + }) + if err != nil { + t.Fatalf("CreateChannel member-only: %v", err) + } + + list, err := service.ListSendAsChannels(ctx, userID) + if err != nil { + t.Fatalf("ListSendAsChannels: %v", err) + } + got := make(map[int64]bool, len(list)) + for _, channel := range list { + got[channel.ID] = true + } + if !got[owned.Channel.ID] { + t.Fatalf("send-as channels %v missing creator-owned broadcast %d", got, owned.Channel.ID) + } + if !got[postAdmin.Channel.ID] { + t.Fatalf("send-as channels %v missing post-admin broadcast %d", got, postAdmin.Channel.ID) + } + if got[editAdmin.Channel.ID] { + t.Fatalf("send-as channels %v should exclude admin-without-post %d", got, editAdmin.Channel.ID) + } + if got[megagroup.Channel.ID] { + t.Fatalf("send-as channels %v should exclude owned megagroup %d", got, megagroup.Channel.ID) + } + if got[memberOnly.Channel.ID] { + t.Fatalf("send-as channels %v should exclude member-only broadcast %d", got, memberOnly.Channel.ID) + } + if len(list) != 2 { + t.Fatalf("send-as channels = %d entries, want 2 (owned + post-admin)", len(list)) + } +} + func TestPublicChannelSearchAndResolveUsername(t *testing.T) { ctx := context.Background() service := NewService(memory.NewChannelStore()) diff --git a/internal/app/contacts/read_model_cache.go b/internal/app/contacts/read_model_cache.go new file mode 100644 index 00000000..205d1027 --- /dev/null +++ b/internal/app/contacts/read_model_cache.go @@ -0,0 +1,136 @@ +package contacts + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +const ( + contactAccountReadModel = "contact_account" + defaultContactListReadModelTTL = 24 * time.Hour + contactListReadModelMaxUsers = 4096 +) + +// contactListReadModelCache 是 contact list read-model 的 per-viewer 缓存,由统一缓存原语 +// readmodelcache.Cache 承载(版本闸门 / epoch 守卫 / LRU 单条驱逐 / clone 内建)。 +type contactListReadModelCache struct { + cache *readmodelcache.Cache[int64, domain.ContactList] +} + +func newContactListReadModelCache(ttl time.Duration) *contactListReadModelCache { + if ttl <= 0 { + ttl = defaultContactListReadModelTTL + } + return &contactListReadModelCache{ + cache: readmodelcache.New[int64, domain.ContactList](readmodelcache.Config[int64, domain.ContactList]{ + MaxEntries: contactListReadModelMaxUsers, + TTL: ttl, + Clone: cloneContactList, + }), + } +} + +// getOrLoad 命中即返回 clone,否则经 singleflight load。版本闸门:hasHash 且 currentHash!=0 +// 时仅复用 storedHash==currentHash 的快照,否则重载(对齐 contact_account 版本脊)。 +func (c *contactListReadModelCache) getOrLoad(ctx context.Context, userID int64, currentHash int64, hasHash bool, load func() (domain.ContactList, error)) (domain.ContactList, error) { + if c == nil { + return load() + } + effectiveHash := int64(0) + if hasHash { + effectiveHash = currentHash + } + return c.cache.GetOrLoadVersioned(ctx, userID, effectiveHash, load) +} + +func (c *contactListReadModelCache) invalidate(ids ...int64) { + if c == nil { + return + } + c.cache.Invalidate(ids...) +} + +func (c *contactListReadModelCache) flush() { + if c == nil { + return + } + c.cache.Flush() +} + +func (s *Service) contactAccountHash(ctx context.Context, userID int64) (int64, bool, error) { + if s == nil || s.versions == nil || userID == 0 { + return 0, false, nil + } + return s.versions.ReadModelHash(ctx, contactAccountReadModel, userID, domain.PeerTypeUser, userID) +} + +func (s *Service) contactListReadModel(ctx context.Context, userID int64, currentHash int64, hasHash bool) (domain.ContactList, error) { + if s == nil { + return domain.ContactList{}, nil + } + if s.cache == nil { + return s.loadContactListReadModel(ctx, userID, currentHash, hasHash) + } + return s.cache.getOrLoad(ctx, userID, currentHash, hasHash, func() (domain.ContactList, error) { + return s.loadContactListReadModel(ctx, userID, currentHash, hasHash) + }) +} + +func (s *Service) loadContactListReadModel(ctx context.Context, userID int64, currentHash int64, hasHash bool) (domain.ContactList, error) { + list, err := s.contacts.ListByUser(ctx, userID) + if err != nil { + return domain.ContactList{}, err + } + if err := s.projectContactUsers(ctx, userID, &list); err != nil { + return domain.ContactList{}, err + } + if hasHash && currentHash != 0 { + list.Hash = currentHash + } + return list, nil +} + +func (s *Service) InvalidateViewers(ids ...int64) { + if s == nil || s.cache == nil { + return + } + s.cache.invalidate(ids...) +} + +func (s *Service) FlushReadModelCache() { + if s == nil || s.cache == nil { + return + } + s.cache.flush() +} + +func cloneContactList(in domain.ContactList) domain.ContactList { + in.Contacts = cloneContacts(in.Contacts) + return in +} + +func cloneContacts(in []domain.Contact) []domain.Contact { + out := make([]domain.Contact, len(in)) + for i := range in { + out[i] = cloneContact(in[i]) + } + return out +} + +func cloneContact(in domain.Contact) domain.Contact { + in.User = cloneUser(in.User) + if in.NoteEntities != nil { + in.NoteEntities = append([]domain.MessageEntity(nil), in.NoteEntities...) + } + return in +} + +func cloneUser(in domain.User) domain.User { + if in.PhotoStripped != nil { + in.PhotoStripped = append([]byte(nil), in.PhotoStripped...) + } + return in +} diff --git a/internal/app/contacts/service.go b/internal/app/contacts/service.go index 551cd43e..01bc7ede 100644 --- a/internal/app/contacts/service.go +++ b/internal/app/contacts/service.go @@ -18,6 +18,7 @@ var ( ) const maxSearchLimit = 50 +const maxCloseFriendsCount = 5000 type phonePrivacyService interface { userprojection.PrivacyEvaluator @@ -31,6 +32,8 @@ type Service struct { photos userprojection.ProfilePhotoProvider privacy phonePrivacyService projector *userprojection.Projector + versions store.ReadModelVersionStore + cache *contactListReadModelCache } // Option adjusts optional contacts service dependencies. @@ -46,9 +49,14 @@ func WithPrivacyEvaluator(p phonePrivacyService) Option { return func(s *Service) { s.privacy = p } } +// WithReadModelVersions enables durable hash-token fast paths for NotModified RPCs. +func WithReadModelVersions(v store.ReadModelVersionStore) Option { + return func(s *Service) { s.versions = v } +} + // NewService 创建 contacts 服务。 func NewService(contacts store.ContactStore, users ...store.UserStore) *Service { - s := &Service{contacts: contacts} + s := &Service{contacts: contacts, cache: newContactListReadModelCache(defaultContactListReadModelTTL)} if len(users) > 0 { s.users = users[0] } @@ -84,16 +92,15 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do if s == nil || s.contacts == nil || userID == 0 { return domain.ContactList{}, false, nil } - list, err := s.contacts.ListByUser(ctx, userID) + currentHash, hasHash, err := s.contactAccountHash(ctx, userID) if err != nil { return domain.ContactList{}, false, err } - if s.users != nil && len(list.Contacts) > 0 { - if err := s.attachCurrentLastSeen(ctx, &list); err != nil { - return domain.ContactList{}, false, err - } + if hash != 0 && hasHash && hash == currentHash { + return domain.ContactList{Hash: currentHash}, true, nil } - if err := s.projectContactUsers(ctx, userID, &list); err != nil { + list, err := s.contactListReadModel(ctx, userID, currentHash, hasHash) + if err != nil { return domain.ContactList{}, false, err } if hash != 0 && hash == list.Hash { @@ -102,40 +109,6 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do return list, false, nil } -func (s *Service) attachCurrentLastSeen(ctx context.Context, list *domain.ContactList) error { - ids := make([]int64, 0, len(list.Contacts)) - seen := make(map[int64]struct{}, len(list.Contacts)) - for _, contact := range list.Contacts { - id := contact.User.ID - if id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - ids = append(ids, id) - } - if len(ids) == 0 { - return nil - } - users, err := s.users.ByIDs(ctx, ids) - if err != nil { - return err - } - current := make(map[int64]domain.User, len(users)) - for _, u := range users { - current[u.ID] = u - } - for i := range list.Contacts { - if u, ok := current[list.Contacts[i].User.ID]; ok { - list.Contacts[i].User.LastSeenAt = u.LastSeenAt - list.Contacts[i].User.Status = u.Status - } - } - return nil -} - func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID { return domain.Contact{}, ErrContactIDInvalid @@ -143,6 +116,9 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con if input.FirstName == "" && input.LastName == "" { return domain.Contact{}, ErrContactNameEmpty } + // Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空), + // 归一成纯数字;无数字时落空串,走下方 target.Phone 回填。 + input.Phone = digitsOnly(input.Phone) if s.users != nil { target, found, err := s.users.ByID(ctx, input.ContactUserID) if err != nil { @@ -159,6 +135,7 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con if err != nil { return domain.Contact{}, err } + s.InvalidateViewers(userID, input.ContactUserID) if input.AddPhonePrivacyException && s.privacy != nil { if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, input.ContactUserID); err != nil { return domain.Contact{}, err @@ -205,6 +182,7 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64 if err != nil { return domain.Contact{}, err } + s.InvalidateViewers(userID, contactUserID) if s.privacy != nil { if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, contactUserID); err != nil { return domain.Contact{}, err @@ -288,6 +266,12 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom if err != nil { return domain.ImportContactsResult{}, err } + changedIDs := make([]int64, 0, len(upserts)+1) + changedIDs = append(changedIDs, userID) + for _, input := range upserts { + changedIDs = append(changedIDs, input.ContactUserID) + } + s.InvalidateViewers(changedIDs...) if s.privacy != nil { for _, input := range upserts { if !input.AddPhonePrivacyException || input.ContactUserID == 0 { @@ -331,7 +315,45 @@ func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserI if s == nil || s.contacts == nil || userID == 0 { return 0, nil } - return s.contacts.Delete(ctx, userID, contactUserIDs) + count, err := s.contacts.Delete(ctx, userID, contactUserIDs) + if err == nil { + ids := make([]int64, 0, len(contactUserIDs)+1) + ids = append(ids, userID) + ids = append(ids, contactUserIDs...) + s.InvalidateViewers(ids...) + } + return count, err +} + +func (s *Service) EditCloseFriends(ctx context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) { + if s == nil || s.contacts == nil || userID == 0 || len(contactUserIDs) > maxCloseFriendsCount { + return domain.CloseFriendsEditResult{}, ErrContactIDInvalid + } + ids := normalizeCloseFriendIDs(userID, contactUserIDs) + if s.users != nil && len(ids) > 0 { + users, err := s.users.ByIDs(ctx, ids) + if err != nil { + return domain.CloseFriendsEditResult{}, err + } + exists := make(map[int64]struct{}, len(users)) + for _, user := range users { + if user.ID != 0 && !user.Bot { + exists[user.ID] = struct{}{} + } + } + filtered := ids[:0] + for _, id := range ids { + if _, ok := exists[id]; ok { + filtered = append(filtered, id) + } + } + ids = filtered + } + result, err := s.contacts.SetCloseFriends(ctx, userID, ids) + if err == nil { + s.InvalidateViewers(userID) + } + return result, err } func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) { @@ -345,6 +367,7 @@ func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID i if !found { return domain.Contact{}, ErrContactIDInvalid } + s.InvalidateViewers(userID) return contact, nil } @@ -359,6 +382,7 @@ func (s *Service) SetPersonalPhoto(ctx context.Context, userID, contactUserID in if !found { return domain.Contact{}, ErrContactReqMissing } + s.InvalidateViewers(userID) return s.projectContact(ctx, userID, contact) } @@ -373,6 +397,7 @@ func (s *Service) ClearPersonalPhoto(ctx context.Context, userID, contactUserID if !found { return domain.Contact{}, ErrContactReqMissing } + s.InvalidateViewers(userID) return s.projectContact(ctx, userID, contact) } @@ -422,7 +447,11 @@ func (s *Service) BlockContact(ctx context.Context, userID, peerUserID int64, da return false, ErrContactIDInvalid } } - return s.contacts.Block(ctx, userID, peerUserID, date) + changed, err := s.contacts.Block(ctx, userID, peerUserID, date) + if err == nil { + s.InvalidateViewers(userID, peerUserID) + } + return changed, err } // UnblockContact removes peer from the current user's blocklist. @@ -430,7 +459,11 @@ func (s *Service) UnblockContact(ctx context.Context, userID, peerUserID int64) if s == nil || s.contacts == nil || userID == 0 || peerUserID == 0 || peerUserID == userID { return false, ErrContactIDInvalid } - return s.contacts.Unblock(ctx, userID, peerUserID) + changed, err := s.contacts.Unblock(ctx, userID, peerUserID) + if err == nil { + s.InvalidateViewers(userID, peerUserID) + } + return changed, err } // IsBlocked reports whether owner has blocked peer. @@ -509,10 +542,10 @@ func (s *Service) projectSearchResult(ctx context.Context, userID int64, res dom return res, nil } -func normalizePhone(phone string) string { - if !utf8.ValidString(phone) { - return "" - } +// digitsOnly 只保留数字字符。保存进 contacts.contact_phone 的号码必须与 users.phone +// 一样是不带 "+" 的纯数字:下发时 contact_phone 优先充当 TL user.phone,而客户端展示 +// user.phone 时会自行补 "+",任何非数字前缀都会变成 "++<号码>" 这类坏显示。 +func digitsOnly(phone string) string { var b strings.Builder b.Grow(len(phone)) for _, r := range phone { @@ -520,8 +553,31 @@ func normalizePhone(phone string) string { b.WriteRune(r) } } - if b.Len() == 0 { - return phone - } return b.String() } + +func normalizePhone(phone string) string { + if !utf8.ValidString(phone) { + return "" + } + if digits := digitsOnly(phone); digits != "" { + return digits + } + return phone +} + +func normalizeCloseFriendIDs(userID int64, ids []int64) []int64 { + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 || id == userID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} diff --git a/internal/app/contacts/service_test.go b/internal/app/contacts/service_test.go index f3b5409a..6a3b919c 100644 --- a/internal/app/contacts/service_test.go +++ b/internal/app/contacts/service_test.go @@ -3,12 +3,130 @@ package contacts import ( "context" "errors" + "reflect" "testing" "telesrv/internal/domain" + "telesrv/internal/store" "telesrv/internal/store/memory" ) +type serviceCountingContactStore struct { + store.ContactStore + listCalls int +} + +func (s *serviceCountingContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) { + s.listCalls++ + return s.ContactStore.ListByUser(ctx, userID) +} + +type serviceCountingUserStore struct { + store.UserStore + byIDsCalls int +} + +func (s *serviceCountingUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) { + s.byIDsCalls++ + return s.UserStore.ByIDs(ctx, ids) +} + +type fakeReadModelVersions struct { + hash int64 + found bool +} + +func (f *fakeReadModelVersions) ReadModelHash(_ context.Context, _ string, _ int64, _ domain.PeerType, _ int64) (int64, bool, error) { + return f.hash, f.found, nil +} + +func (f *fakeReadModelVersions) ReadModelHashes(ctx context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) { + out := make(map[store.ReadModelKey]int64, len(keys)) + for _, key := range keys { + hash, found, err := f.ReadModelHash(ctx, key.Model, key.OwnerUserID, key.PeerType, key.PeerID) + if err != nil { + return nil, err + } + if found { + out[key] = hash + } + } + return out, nil +} + +func TestGetContactsReturnsNotModifiedFromReadModelHashWithoutLoadingList(t *testing.T) { + ctx := context.Background() + base := memory.NewContactStore() + counting := &serviceCountingContactStore{ContactStore: base} + versions := &fakeReadModelVersions{hash: 99101, found: true} + svc := NewService(counting).Configure(WithReadModelVersions(versions)) + + list, notModified, err := svc.GetContacts(ctx, 1, versions.hash) + if err != nil { + t.Fatalf("GetContacts: %v", err) + } + if !notModified { + t.Fatalf("notModified = false, want true") + } + if list.Hash != versions.hash { + t.Fatalf("notModified list hash = %d, want %d", list.Hash, versions.hash) + } + if counting.listCalls != 0 { + t.Fatalf("ListByUser calls = %d, want 0 on hash hit", counting.listCalls) + } +} + +func TestGetContactsCachesProjectedReadModelAndRejectsStaleHash(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + target, err := users.Create(ctx, domain.User{Phone: "101", FirstName: "Target"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + base := memory.NewContactStore() + if _, err := base.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: target.ID, FirstName: "Saved"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + counting := &serviceCountingContactStore{ContactStore: base} + countingUsers := &serviceCountingUserStore{UserStore: users} + versions := &fakeReadModelVersions{hash: 12345, found: true} + svc := NewService(counting, countingUsers).Configure(WithReadModelVersions(versions)) + + first, notModified, err := svc.GetContacts(ctx, owner.ID, 0) + if err != nil || notModified { + t.Fatalf("first GetContacts notModified=%v err=%v", notModified, err) + } + if first.Hash != versions.hash || len(first.Contacts) != 1 { + t.Fatalf("first result hash=%d contacts=%d, want hash %d and one contact", first.Hash, len(first.Contacts), versions.hash) + } + second, notModified, err := svc.GetContacts(ctx, owner.ID, 0) + if err != nil || notModified { + t.Fatalf("second GetContacts notModified=%v err=%v", notModified, err) + } + if second.Hash != versions.hash || counting.listCalls != 1 { + t.Fatalf("second result hash=%d listCalls=%d, want cached hash %d and one load", second.Hash, counting.listCalls, versions.hash) + } + + versions.hash = 67890 + third, notModified, err := svc.GetContacts(ctx, owner.ID, 12345) + if err != nil || notModified { + t.Fatalf("third GetContacts notModified=%v err=%v", notModified, err) + } + if third.Hash != versions.hash { + t.Fatalf("third hash = %d, want new read-model hash %d", third.Hash, versions.hash) + } + if counting.listCalls != 2 { + t.Fatalf("ListByUser calls after hash change = %d, want 2", counting.listCalls) + } + if countingUsers.byIDsCalls != 0 { + t.Fatalf("Users.ByIDs calls = %d, want 0; presence must stay out of contact read model", countingUsers.byIDsCalls) + } +} + func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) { ctx := context.Background() users := memory.NewUserStore() @@ -89,6 +207,73 @@ func TestGetContactsProjectsCurrentProfilePhoto(t *testing.T) { } } +func TestEditCloseFriendsReplacesOwnerContactFlags(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contactsStore := memory.NewContactStore() + owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + bob, err := users.Create(ctx, domain.User{Phone: "101", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + carol, err := users.Create(ctx, domain.User{Phone: "102", FirstName: "Carol"}) + if err != nil { + t.Fatalf("create carol: %v", err) + } + bot, err := users.Create(ctx, domain.User{Phone: "103", FirstName: "Bot", Bot: true, BotInfoVersion: 1}) + if err != nil { + t.Fatalf("create bot: %v", err) + } + for _, user := range []domain.User{bob, carol, bot} { + if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: user.ID, FirstName: user.FirstName}); err != nil { + t.Fatalf("upsert contact %d: %v", user.ID, err) + } + } + svc := NewService(contactsStore, users) + + result, err := svc.EditCloseFriends(ctx, owner.ID, []int64{bob.ID, bob.ID, 0, owner.ID, bot.ID, 999999}) + if err != nil { + t.Fatalf("EditCloseFriends first: %v", err) + } + if got, want := result.AddedUserIDs, []int64{bob.ID}; !reflect.DeepEqual(got, want) { + t.Fatalf("first added = %v, want %v", got, want) + } + if len(result.RemovedUserIDs) != 0 { + t.Fatalf("first removed = %v, want empty", result.RemovedUserIDs) + } + list, notModified, err := svc.GetContacts(ctx, owner.ID, 0) + if err != nil || notModified { + t.Fatalf("GetContacts after first edit notModified=%v err=%v", notModified, err) + } + if !contactByID(t, list, bob.ID).CloseFriend || !contactByID(t, list, bob.ID).User.CloseFriend { + t.Fatalf("bob close friend projection = %+v, want true", contactByID(t, list, bob.ID)) + } + if contactByID(t, list, carol.ID).CloseFriend || contactByID(t, list, bot.ID).CloseFriend { + t.Fatalf("carol/bot close friend flags = %+v / %+v, want false", contactByID(t, list, carol.ID), contactByID(t, list, bot.ID)) + } + + result, err = svc.EditCloseFriends(ctx, owner.ID, []int64{carol.ID}) + if err != nil { + t.Fatalf("EditCloseFriends replace: %v", err) + } + if got, want := result.AddedUserIDs, []int64{carol.ID}; !reflect.DeepEqual(got, want) { + t.Fatalf("replace added = %v, want %v", got, want) + } + if got, want := result.RemovedUserIDs, []int64{bob.ID}; !reflect.DeepEqual(got, want) { + t.Fatalf("replace removed = %v, want %v", got, want) + } + replaced, _, err := svc.GetContacts(ctx, owner.ID, 0) + if err != nil { + t.Fatalf("GetContacts after replace: %v", err) + } + if contactByID(t, replaced, bob.ID).CloseFriend || !contactByID(t, replaced, carol.ID).CloseFriend { + t.Fatalf("replace flags bob=%+v carol=%+v, want bob false carol true", contactByID(t, replaced, bob.ID), contactByID(t, replaced, carol.ID)) + } +} + func TestAcceptContactSharesPhoneAndClearsShareContact(t *testing.T) { ctx := context.Background() users := memory.NewUserStore() @@ -157,6 +342,55 @@ func TestAcceptContactSharesPhoneAndClearsShareContact(t *testing.T) { } } +func TestAddContactNormalizesPhoneToDigits(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contactsStore := memory.NewContactStore() + alice, err := users.Create(ctx, domain.User{Phone: "15550060301", FirstName: "Alice", LastName: "A"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := users.Create(ctx, domain.User{Phone: "15550060302", FirstName: "Bob", LastName: "B"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + svc := NewService(contactsStore, users) + + contact, err := svc.AddContact(ctx, alice.ID, domain.ContactInput{ + ContactUserID: bob.ID, + Phone: "+1 555-006-0302", + FirstName: "Bob B", + }) + if err != nil { + t.Fatalf("AddContact: %v", err) + } + if contact.Phone != "15550060302" { + t.Fatalf("contact phone = %q, want digits-only 15550060302", contact.Phone) + } + if contact.User.Phone != "15550060302" { + t.Fatalf("projected user phone = %q, want digits-only 15550060302", contact.User.Phone) + } + stored, found, err := contactsStore.Get(ctx, alice.ID, bob.ID) + if err != nil || !found { + t.Fatalf("stored contact found=%v err=%v", found, err) + } + if stored.Phone != "15550060302" { + t.Fatalf("stored contact phone = %q, want digits-only 15550060302", stored.Phone) + } + + emptied, err := svc.AddContact(ctx, alice.ID, domain.ContactInput{ + ContactUserID: bob.ID, + Phone: "+", + FirstName: "Bob B", + }) + if err != nil { + t.Fatalf("AddContact digitless phone: %v", err) + } + if emptied.Phone != bob.Phone { + t.Fatalf("digitless phone contact = %q, want fallback to target phone %q", emptied.Phone, bob.Phone) + } +} + func TestAcceptContactRequiresExistingContactRequest(t *testing.T) { ctx := context.Background() users := memory.NewUserStore() @@ -176,6 +410,17 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) { } } +func contactByID(t *testing.T, list domain.ContactList, id int64) domain.Contact { + t.Helper() + for _, contact := range list.Contacts { + if contact.User.ID == id { + return contact + } + } + t.Fatalf("contact %d not found in %+v", id, list.Contacts) + return domain.Contact{} +} + type contactProfilePhotos map[int64]domain.ProfilePhotoRef func (p contactProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) { diff --git a/internal/app/dialogs/list_hash_cache.go b/internal/app/dialogs/list_hash_cache.go new file mode 100644 index 00000000..0d8f585e --- /dev/null +++ b/internal/app/dialogs/list_hash_cache.go @@ -0,0 +1,132 @@ +package dialogs + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +const ( + defaultDialogListHashCacheTTL = 24 * time.Hour + dialogListHashCacheMaxEntries = 4096 +) + +type dialogListHashCacheKey struct { + userID int64 + pinnedOnly bool + excludePinned bool + hasFolderID bool + folderID int + limit int +} + +type dialogListHashValue struct { + hash int64 + count int +} + +// dialogListHashCache 由统一缓存原语承载,走「外部构建再写回」(GetDialogs 在加载前 cacheEpoch +// 快照 epoch → rememberDialogListHash 经 putIfEpoch 写回)。原语的 epoch 仅在 Invalidate/Flush +// (写驱动失效)自增,被动 TTL 过期纯 per-key 不动 epoch——正是本缓存所需(避免误返 NotModified)。 +type dialogListHashCache struct { + cache *readmodelcache.Cache[dialogListHashCacheKey, dialogListHashValue] +} + +func newDialogListHashCache(ttl time.Duration) *dialogListHashCache { + if ttl <= 0 { + ttl = defaultDialogListHashCacheTTL + } + return &dialogListHashCache{ + cache: readmodelcache.New[dialogListHashCacheKey, dialogListHashValue](readmodelcache.Config[dialogListHashCacheKey, dialogListHashValue]{ + MaxEntries: dialogListHashCacheMaxEntries, + TTL: ttl, + }), + } +} + +func (s *Service) GetDialogsHash(_ context.Context, userID int64, filter domain.DialogFilter) (domain.DialogHashCheck, error) { + if s == nil || s.listHashCache == nil || filter.Hash == 0 { + return domain.DialogHashCheck{}, nil + } + key, ok := dialogListHashKey(userID, filter) + if !ok { + return domain.DialogHashCheck{}, nil + } + snap, ok := s.listHashCache.lookup(key) + if !ok { + return domain.DialogHashCheck{}, nil + } + return domain.DialogHashCheck{ + Known: true, + Matched: snap.hash == filter.Hash, + Hash: snap.hash, + Count: snap.count, + }, nil +} + +func (s *Service) rememberDialogListHash(userID int64, filter domain.DialogFilter, list domain.DialogList, loadEpoch uint64) { + if s == nil || s.listHashCache == nil || list.Hash == 0 { + return + } + key, ok := dialogListHashKey(userID, filter) + if !ok { + return + } + s.listHashCache.putIfEpoch(key, list.Hash, list.Count, loadEpoch) +} + +func dialogListHashKey(userID int64, filter domain.DialogFilter) (dialogListHashCacheKey, bool) { + if userID == 0 || filter.OffsetDate != 0 || filter.OffsetID != 0 || filter.HasOffsetPeer { + return dialogListHashCacheKey{}, false + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + return dialogListHashCacheKey{ + userID: userID, + pinnedOnly: filter.PinnedOnly, + excludePinned: filter.ExcludePinned, + hasFolderID: filter.HasFolderID, + folderID: filter.FolderID, + limit: limit, + }, true +} + +func (c *dialogListHashCache) lookup(key dialogListHashCacheKey) (dialogListHashValue, bool) { + if c == nil { + return dialogListHashValue{}, false + } + return c.cache.Peek(key) +} + +// putIfEpoch 仅在 epoch 未变(加载期间没有写驱动失效)时写入,堵住 stale write-back race。 +func (c *dialogListHashCache) putIfEpoch(key dialogListHashCacheKey, hash int64, count int, loadEpoch uint64) { + if c == nil || key.userID == 0 || hash == 0 { + return + } + c.cache.StoreIfEpoch(key, dialogListHashValue{hash: hash, count: count}, loadEpoch) +} + +func (c *dialogListHashCache) cacheEpoch() uint64 { + if c == nil { + return 0 + } + return c.cache.LoadEpoch() +} + +func (c *dialogListHashCache) invalidateOwner(userID int64) { + if c == nil || userID == 0 { + return + } + c.cache.InvalidateWhere(func(key dialogListHashCacheKey) bool { return key.userID == userID }) +} + +func (c *dialogListHashCache) flush() { + if c == nil { + return + } + c.cache.Flush() +} diff --git a/internal/app/dialogs/read_model_cache.go b/internal/app/dialogs/read_model_cache.go new file mode 100644 index 00000000..0e9aa838 --- /dev/null +++ b/internal/app/dialogs/read_model_cache.go @@ -0,0 +1,506 @@ +package dialogs + +import ( + "context" + "time" + + "telesrv/internal/app/readmodel" + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" + "telesrv/internal/store" +) + +const ( + dialogLightReadModel = readmodel.ModelDialogLight + channelBaseReadModel = readmodel.ModelChannelBase + channelMemberReadModel = readmodel.ModelChannelMember + defaultDialogPeerReadModelTTL = 24 * time.Hour + dialogPeerReadModelMaxEntries = 8192 +) + +type dialogPeerCacheKey struct { + userID int64 + peer domain.Peer +} + +// dialogPeerReadModelCache 由统一缓存原语承载(epoch 守卫 / LRU / clone)。它走 per-peer +// 外部构建:Service 按 peer 查缓存、把 miss 合批打一次后端、再 per-peer 写回。版本闸门用 +// 值自带的 DialogList.Hash 比对(原语存 hash=0,版本由值携带)。返回的是整批原始 list(非 +// per-peer 切片重组),故不用 GetOrLoadBatch——它返回 per-key 值会丢非 peer 归属的全局元素。 +type dialogPeerReadModelCache struct { + cache *readmodelcache.Cache[dialogPeerCacheKey, domain.DialogList] +} + +func newDialogPeerReadModelCache(ttl time.Duration) *dialogPeerReadModelCache { + if ttl <= 0 { + ttl = defaultDialogPeerReadModelTTL + } + return &dialogPeerReadModelCache{ + cache: readmodelcache.New[dialogPeerCacheKey, domain.DialogList](readmodelcache.Config[dialogPeerCacheKey, domain.DialogList]{ + MaxEntries: dialogPeerReadModelMaxEntries, + TTL: ttl, + Clone: cloneDialogList, + }), + } +} + +func (s *Service) userPeerDialogsReadModel(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { + if s == nil { + return domain.DialogList{}, nil + } + unique := uniqueUserPeers(peers) + if len(unique) == 0 { + return domain.DialogList{}, nil + } + return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.userDialogHashes, s.loadUserPeerDialogs) +} + +func (s *Service) channelPeerDialogsReadModel(ctx context.Context, userID int64, channelIDs []int64) (domain.DialogList, error) { + if s == nil { + return domain.DialogList{}, nil + } + unique := uniqueChannelPeers(channelIDs) + if len(unique) == 0 { + return domain.DialogList{}, nil + } + return s.cachedPeerDialogsReadModel(ctx, userID, unique, s.channelDialogHashes, s.loadChannelPeerDialogsByPeers) +} + +func (s *Service) cachedPeerDialogsReadModel( + ctx context.Context, + userID int64, + peers []domain.Peer, + hashesFor func(context.Context, int64, []domain.Peer) (map[domain.Peer]int64, error), + load func(context.Context, int64, []domain.Peer) (domain.DialogList, error), +) (domain.DialogList, error) { + if s.peerCache == nil || s.versions == nil { + return load(ctx, userID, peers) + } + hashes, err := hashesFor(ctx, userID, peers) + if err != nil { + return domain.DialogList{}, err + } + loadEpoch := s.peerCache.cacheEpoch() + var out domain.DialogList + misses := make([]domain.Peer, 0, len(peers)) + for _, peer := range peers { + hash := hashes[peer] + if hash != 0 { + if cached, ok := s.peerCache.lookup(dialogPeerCacheKey{userID: userID, peer: peer}, hash); ok { + out = mergeDialogLists(out, cached) + continue + } + } + misses = append(misses, peer) + } + if len(misses) == 0 { + out.Count = len(out.Dialogs) + return out, nil + } + list, err := load(ctx, userID, misses) + if err != nil { + return domain.DialogList{}, err + } + for _, peer := range misses { + hash := hashes[peer] + if hash == 0 { + continue + } + peerList := dialogListForPeer(list, peer) + peerList.Hash = hash + s.peerCache.putIfEpoch(dialogPeerCacheKey{userID: userID, peer: peer}, peerList, hash, loadEpoch) + } + if len(out.Dialogs) > 0 || len(out.Messages) > 0 || len(out.ChannelMessages) > 0 || len(out.Users) > 0 || len(out.Channels) > 0 { + return mergeDialogLists(out, list), nil + } + return list, nil +} + +func (s *Service) loadUserPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { + if s == nil || s.dialogs == nil || len(peers) == 0 { + return domain.DialogList{}, nil + } + list, err := s.dialogs.ListByPeers(ctx, userID, peers) + if err != nil { + return domain.DialogList{}, err + } + if err := s.attachDrafts(ctx, userID, &list); err != nil { + return domain.DialogList{}, err + } + if err := s.projectDialogUsers(ctx, userID, &list); err != nil { + return domain.DialogList{}, err + } + return list, nil +} + +func (s *Service) loadChannelPeerDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { + if s == nil || s.channels == nil || len(peers) == 0 { + return domain.DialogList{}, nil + } + channelIDs := channelPeerIDs(peers) + if len(channelIDs) == 0 { + return domain.DialogList{}, nil + } + list, err := s.channels.GetChannelDialogs(ctx, userID, channelIDs) + if err != nil { + return domain.DialogList{}, err + } + out := mergeChannelDialogs(domain.DialogList{}, list) + out, err = s.appendMissingChannelPeerPreviews(ctx, userID, channelIDs, out) + if err != nil { + return domain.DialogList{}, err + } + if err := s.attachDrafts(ctx, userID, &out); err != nil { + return domain.DialogList{}, err + } + if err := s.projectDialogUsers(ctx, userID, &out); err != nil { + return domain.DialogList{}, err + } + return out, nil +} + +func (s *Service) userDialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) { + keys := make([]store.ReadModelKey, 0, len(peers)) + for _, peer := range peers { + keys = append(keys, store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}) + } + rows, err := s.versions.ReadModelHashes(ctx, keys) + if err != nil { + return nil, err + } + out := make(map[domain.Peer]int64, len(peers)) + for _, peer := range peers { + out[peer] = rows[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}] + } + return out, nil +} + +func (s *Service) channelDialogHashes(ctx context.Context, userID int64, peers []domain.Peer) (map[domain.Peer]int64, error) { + keys := make([]store.ReadModelKey, 0, len(peers)*3) + for _, peer := range peers { + keys = append(keys, + store.ReadModelKey{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}, + store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}, + store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}, + ) + } + rows, err := s.versions.ReadModelHashes(ctx, keys) + if err != nil { + return nil, err + } + out := make(map[domain.Peer]int64, len(peers)) + for _, peer := range peers { + base := rows[store.ReadModelKey{Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}] + if base == 0 { + continue + } + member := rows[store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}] + dialog := rows[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: userID, PeerType: peer.Type, PeerID: peer.ID}] + out[peer] = readmodel.MixHashes(base, member, dialog) + } + return out, nil +} + +// lookup 命中且版本(值自带 DialogList.Hash)匹配才返回;原语已在返回边界 clone。 +func (c *dialogPeerReadModelCache) lookup(key dialogPeerCacheKey, currentHash int64) (domain.DialogList, bool) { + if c == nil { + return domain.DialogList{}, false + } + list, ok := c.cache.Peek(key) + if !ok || (currentHash != 0 && list.Hash != currentHash) { + return domain.DialogList{}, false + } + return list, true +} + +func (c *dialogPeerReadModelCache) putIfEpoch(key dialogPeerCacheKey, list domain.DialogList, hash int64, expectedEpoch uint64) { + if c == nil || key.userID == 0 || key.peer.Type == "" || key.peer.ID == 0 || hash == 0 { + return + } + list.Hash = hash + c.cache.StoreIfEpoch(key, list, expectedEpoch) +} + +func (c *dialogPeerReadModelCache) invalidate(key dialogPeerCacheKey) { + if c == nil { + return + } + c.cache.Invalidate(key) +} + +func (c *dialogPeerReadModelCache) flush() { + if c == nil { + return + } + c.cache.Flush() +} + +func (c *dialogPeerReadModelCache) cacheEpoch() uint64 { + if c == nil { + return 0 + } + return c.cache.LoadEpoch() +} + +func (s *Service) InvalidateDialog(userID int64, peer domain.Peer) { + if s == nil || userID == 0 { + return + } + s.invalidateDialogListHashes(userID) + if s.peerCache == nil || peer.Type == "" || peer.ID == 0 { + return + } + s.peerCache.invalidate(dialogPeerCacheKey{userID: userID, peer: peer}) +} + +func (s *Service) FlushReadModelCache() { + if s == nil { + return + } + if s.peerCache != nil { + s.peerCache.flush() + } + if s.listHashCache != nil { + s.listHashCache.flush() + } +} + +func (s *Service) invalidateDialogListHashes(userID int64) { + if s == nil || s.listHashCache == nil || userID == 0 { + return + } + s.listHashCache.invalidateOwner(userID) +} + +func uniqueUserPeers(peers []domain.Peer) []domain.Peer { + return uniquePeersOfType(peers, domain.PeerTypeUser) +} + +func uniqueChannelPeers(channelIDs []int64) []domain.Peer { + out := make([]domain.Peer, 0, len(channelIDs)) + seen := make(map[int64]struct{}, len(channelIDs)) + for _, id := range channelIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: id}) + } + return out +} + +func uniquePeersOfType(peers []domain.Peer, peerType domain.PeerType) []domain.Peer { + out := make([]domain.Peer, 0, len(peers)) + seen := make(map[domain.Peer]struct{}, len(peers)) + for _, peer := range peers { + if peer.Type != peerType || peer.ID == 0 { + continue + } + if _, ok := seen[peer]; ok { + continue + } + seen[peer] = struct{}{} + out = append(out, peer) + } + return out +} + +func channelPeerIDs(peers []domain.Peer) []int64 { + out := make([]int64, 0, len(peers)) + for _, peer := range peers { + if peer.Type == domain.PeerTypeChannel && peer.ID != 0 { + out = append(out, peer.ID) + } + } + return out +} + +func dialogListForPeer(list domain.DialogList, peer domain.Peer) domain.DialogList { + out := domain.DialogList{Hash: list.Hash} + for _, dialog := range list.Dialogs { + if dialog.Peer == peer { + out.Dialogs = append(out.Dialogs, cloneDialog(dialog)) + } + } + for _, msg := range list.Messages { + if msg.Peer == peer { + out.Messages = append(out.Messages, cloneMessageForDialogCache(msg)) + } + } + for _, msg := range list.ChannelMessages { + if msg.ChannelID == peer.ID && peer.Type == domain.PeerTypeChannel { + out.ChannelMessages = append(out.ChannelMessages, cloneChannelMessageForDialogCache(msg)) + } + } + switch peer.Type { + case domain.PeerTypeUser: + for _, user := range list.Users { + if user.ID == peer.ID { + out.Users = append(out.Users, cloneDialogUser(user)) + } + } + case domain.PeerTypeChannel: + var linkedID int64 + for _, channel := range list.Channels { + if channel.ID == peer.ID { + out.Channels = append(out.Channels, cloneDialogChannel(channel)) + // monoforum 与母广播频道互为 linked_monoforum_id。per-peer 缓存必须把同批下发的关联频道 + // 一并保留,否则缓存命中时 getPeerDialogs 只回该 peer 自身、丢掉关联频道,客户端无法 + // resolve linked_monoforum_id(GetChannelDialogs 的同批下发在缓存层被抹掉)。 + if channel.LinkedMonoforumID != 0 && (channel.Monoforum || channel.BroadcastMessagesAllowed) { + linkedID = channel.LinkedMonoforumID + } + } + } + if linkedID != 0 { + for _, channel := range list.Channels { + if channel.ID == linkedID { + out.Channels = append(out.Channels, cloneDialogChannel(channel)) + } + } + } + } + out.Count = len(out.Dialogs) + return out +} + +func cloneDialogList(in domain.DialogList) domain.DialogList { + in.Dialogs = cloneDialogSlice(in.Dialogs) + in.Messages = cloneDialogMessages(in.Messages) + in.ChannelMessages = cloneDialogChannelMessages(in.ChannelMessages) + in.Users = cloneDialogUsers(in.Users) + in.Channels = cloneDialogChannels(in.Channels) + return in +} + +func cloneDialogSlice(in []domain.Dialog) []domain.Dialog { + out := make([]domain.Dialog, len(in)) + for i := range in { + out[i] = cloneDialog(in[i]) + } + return out +} + +func cloneDialog(in domain.Dialog) domain.Dialog { + if in.Draft != nil { + draft := cloneDraft(*in.Draft) + in.Draft = &draft + } + return in +} + +func cloneDialogMessages(in []domain.Message) []domain.Message { + out := make([]domain.Message, len(in)) + for i := range in { + out[i] = cloneMessageForDialogCache(in[i]) + } + return out +} + +func cloneMessageForDialogCache(msg domain.Message) domain.Message { + msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) + if msg.ReplyTo != nil { + reply := *msg.ReplyTo + reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...) + msg.ReplyTo = &reply + } + if msg.Forward != nil { + forward := *msg.Forward + msg.Forward = &forward + } + return msg +} + +func cloneDialogChannelMessages(in []domain.ChannelMessage) []domain.ChannelMessage { + out := make([]domain.ChannelMessage, len(in)) + for i := range in { + out[i] = cloneChannelMessageForDialogCache(in[i]) + } + return out +} + +func cloneChannelMessageForDialogCache(msg domain.ChannelMessage) domain.ChannelMessage { + msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) + if msg.ReplyTo != nil { + reply := *msg.ReplyTo + reply.QuoteEntities = append([]domain.MessageEntity(nil), msg.ReplyTo.QuoteEntities...) + msg.ReplyTo = &reply + } + if msg.Forward != nil { + forward := *msg.Forward + msg.Forward = &forward + } + if msg.SendAs != nil { + sendAs := *msg.SendAs + msg.SendAs = &sendAs + } + if msg.Reactions != nil { + reactions := *msg.Reactions + reactions.Results = append([]domain.ChannelMessageReactionCount(nil), msg.Reactions.Results...) + reactions.Recent = append([]domain.ChannelMessagePeerReaction(nil), msg.Reactions.Recent...) + msg.Reactions = &reactions + } + if msg.ReplyMarkup != nil { + msg.ReplyMarkup = cloneReplyMarkupForDialogCache(msg.ReplyMarkup) + } + if msg.Action != nil { + action := *msg.Action + action.UserIDs = append([]int64(nil), msg.Action.UserIDs...) + action.Completed = append([]int(nil), msg.Action.Completed...) + action.Incompleted = append([]int(nil), msg.Action.Incompleted...) + action.TodoItems = append([]domain.MessageTodoItem(nil), msg.Action.TodoItems...) + msg.Action = &action + } + return msg +} + +func cloneReplyMarkupForDialogCache(in *domain.MessageReplyMarkup) *domain.MessageReplyMarkup { + if in == nil { + return nil + } + out := &domain.MessageReplyMarkup{} + if len(in.Inline) > 0 { + out.Inline = make([][]domain.MarkupButton, len(in.Inline)) + for i, row := range in.Inline { + out.Inline[i] = make([]domain.MarkupButton, len(row)) + for j, button := range row { + out.Inline[i][j] = button + out.Inline[i][j].Data = append([]byte(nil), button.Data...) + } + } + } + return out +} + +func cloneDialogUsers(in []domain.User) []domain.User { + out := make([]domain.User, len(in)) + for i := range in { + out[i] = cloneDialogUser(in[i]) + } + return out +} + +func cloneDialogUser(in domain.User) domain.User { + if in.PhotoStripped != nil { + in.PhotoStripped = append([]byte(nil), in.PhotoStripped...) + } + return in +} + +func cloneDialogChannels(in []domain.Channel) []domain.Channel { + out := make([]domain.Channel, len(in)) + for i := range in { + out[i] = cloneDialogChannel(in[i]) + } + return out +} + +func cloneDialogChannel(in domain.Channel) domain.Channel { + in.PhotoStripped = append([]byte(nil), in.PhotoStripped...) + in.ReactionPolicy.Emoticons = append([]string(nil), in.ReactionPolicy.Emoticons...) + in.ReactionPolicy.CustomEmojiIDs = append([]int64(nil), in.ReactionPolicy.CustomEmojiIDs...) + return in +} diff --git a/internal/app/dialogs/service.go b/internal/app/dialogs/service.go index cfe3a8e4..a4ff8208 100644 --- a/internal/app/dialogs/service.go +++ b/internal/app/dialogs/service.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "errors" "hash/fnv" + "reflect" "sort" "unicode/utf8" @@ -13,14 +14,21 @@ import ( "telesrv/internal/store" ) +// PremiumChecker 报告用户当前是否有效会员(pin 上限双档判断用)。 +type PremiumChecker func(ctx context.Context, userID int64) bool + // Service 提供会话列表查询。 type Service struct { - dialogs store.DialogStore - channels store.ChannelStore - contacts store.ContactStore - photos userprojection.ProfilePhotoProvider - privacy userprojection.PrivacyEvaluator - projector *userprojection.Projector + dialogs store.DialogStore + channels store.ChannelStore + contacts store.ContactStore + photos userprojection.ProfilePhotoProvider + privacy userprojection.PrivacyEvaluator + premium PremiumChecker + projector *userprojection.Projector + versions store.ReadModelVersionStore + peerCache *dialogPeerReadModelCache + listHashCache *dialogListHashCache } // Option adjusts optional dialogs service dependencies. @@ -31,6 +39,11 @@ func WithContactStore(c store.ContactStore) Option { return func(s *Service) { s.contacts = c } } +// WithPremiumChecker 启用 pin 上限的 premium 双档(缺省一律按默认档)。 +func WithPremiumChecker(p PremiumChecker) Option { + return func(s *Service) { s.premium = p } +} + // WithPhotoProvider enables current profile photo enrichment for dialog users. func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option { return func(s *Service) { s.photos = p } @@ -41,9 +54,18 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option { return func(s *Service) { s.privacy = p } } +// WithReadModelVersions enables durable version-token backed peer dialog caching. +func WithReadModelVersions(v store.ReadModelVersionStore) Option { + return func(s *Service) { s.versions = v } +} + // NewService 创建 dialogs 服务。 func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Service { - s := &Service{dialogs: dialogs} + s := &Service{ + dialogs: dialogs, + peerCache: newDialogPeerReadModelCache(defaultDialogPeerReadModelTTL), + listHashCache: newDialogListHashCache(defaultDialogListHashCacheTTL), + } if len(channels) > 0 { s.channels = channels[0] } @@ -76,6 +98,15 @@ func (s *Service) rebuildProjector() { // GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。 func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { + return s.getDialogs(ctx, userID, filter, false) +} + +// getDialogs 是 GetDialogs 的实现。lightweight=true 跳过草稿附加、viewer 投影与 +// list-hash 写回,供 attachArchiveSummary 取归档顶部会话用:归档摘要只需 top +// peer/message,草稿无意义;且追加进来的归档 users 会被外层 GetDialogs 的 +// projectDialogUsers 统一投影,内层再投影纯属重复(原归档递归走完整 GetDialogs +// 会多跑一次 ListDrafts + 一次投影)。 +func (s *Service) getDialogs(ctx context.Context, userID int64, filter domain.DialogFilter, lightweight bool) (domain.DialogList, error) { if s == nil || userID == 0 { return domain.DialogList{}, nil } @@ -92,6 +123,9 @@ func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.Di } filter.Folder = &folder } + // 在加载任何会话状态前快照 list-hash epoch:若加载/投影期间发生 dialog_light 写失效, + // rememberDialogListHash 会据此拒绝写回 stale hash,避免后续 getDialogs 误返 NotModified。 + listHashEpoch := s.listHashCache.cacheEpoch() var out domain.DialogList if s.dialogs != nil { list, err := s.dialogs.ListByUser(ctx, userID, filter) @@ -125,15 +159,100 @@ func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.Di if out.Count == 0 { out.Count = len(out.Dialogs) } + if err := s.attachArchiveSummary(ctx, userID, filter, &out); err != nil { + return domain.DialogList{}, err + } + if lightweight { + // 归档摘要顶部会话:不附草稿、不投影(由外层统一投影)、不写 list-hash。 + return out, nil + } if err := s.attachDrafts(ctx, userID, &out); err != nil { return domain.DialogList{}, err } if err := s.projectDialogUsers(ctx, userID, &out); err != nil { return domain.DialogList{}, err } + s.rememberDialogListHash(userID, filter, out, listHashEpoch) return out, nil } +// attachArchiveSummary 在主列表第一页响应上聚合归档摘要:TDesktop 只能靠 +// 响应头部的 dialogFolder 条目发现 archive(新登录设备没有任何 update 可 +// 重放),缺少它归档会话将彻底不可见。归档/自定义 filter/置顶/翻页请求不附加。 +func (s *Service) attachArchiveSummary(ctx context.Context, userID int64, filter domain.DialogFilter, out *domain.DialogList) error { + if s == nil || out == nil { + return nil + } + if filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID { + return nil + } + // exclude_pinned 请求按官方语义排除 folder 条目(archive 行属 pinned 集合)。 + // PinnedOnly(getPinnedDialogs)不能跳过:DrKLO 主列表 getDialogs 一律带 + // exclude_pinned,archive 行的发现完全依赖 getPinnedDialogs 响应里的 + // dialogFolder 条目(fetchFolderInLoadedPinnedDialogs)。 + if filter.ExcludePinned { + return nil + } + if filter.OffsetID != 0 || filter.OffsetDate != 0 || filter.HasOffsetPeer { + return nil + } + top, err := s.getDialogs(ctx, userID, domain.DialogFilter{ + HasFolderID: true, + FolderID: domain.DialogArchiveFolderID, + Limit: 1, + }, true) + if err != nil { + return err + } + if len(top.Dialogs) == 0 { + return nil + } + unreadPeers, unreadMessages := 0, 0 + if s.dialogs != nil { + peers, messages, err := s.dialogs.CountArchiveUnread(ctx, userID) + if err != nil { + return err + } + unreadPeers += peers + unreadMessages += messages + } + if s.channels != nil { + peers, messages, err := s.channels.CountChannelArchiveUnread(ctx, userID) + if err != nil { + return err + } + unreadPeers += peers + unreadMessages += messages + } + archivePinned := true + if s.dialogs != nil { + pinned, err := s.dialogs.ArchivePinned(ctx, userID) + if err != nil { + return err + } + archivePinned = pinned + } + // getPinnedDialogs 只返回置顶集合:archive 行被 unpin 后不再属于它。 + if filter.PinnedOnly && !archivePinned { + return nil + } + topDialog := top.Dialogs[0] + out.ArchiveSummary = &domain.DialogArchiveSummary{ + TopPeer: topDialog.Peer, + TopMessage: topDialog.TopMessage, + UnreadPeersCount: unreadPeers, + UnreadMessagesCount: unreadMessages, + Pinned: archivePinned, + } + // dialogFolder.peer 指向的会话对象必须随响应下发:TDesktop + // Folder::applyDialog 会立即解引用该 peer(owner().history(peerId))。 + out.Messages = append(out.Messages, top.Messages...) + out.ChannelMessages = append(out.ChannelMessages, top.ChannelMessages...) + out.Users = append(out.Users, top.Users...) + out.Channels = append(out.Channels, top.Channels...) + return nil +} + // GetPeerDialogs 返回指定 peer 的会话摘要。缺失的 peer 由 store 按空会话占位返回。 func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { if s == nil || userID == 0 || len(peers) == 0 { @@ -154,28 +273,18 @@ func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []doma } var out domain.DialogList if len(userPeers) > 0 && s.dialogs != nil { - list, err := s.dialogs.ListByPeers(ctx, userID, userPeers) + list, err := s.userPeerDialogsReadModel(ctx, userID, userPeers) if err != nil { return domain.DialogList{}, err } out = mergeDialogLists(out, list) } if len(channelIDs) > 0 && s.channels != nil { - list, err := s.channels.GetChannelDialogs(ctx, userID, channelIDs) + channelOut, err := s.channelPeerDialogsReadModel(ctx, userID, channelIDs) if err != nil { return domain.DialogList{}, err } - out = mergeChannelDialogs(out, list) - out, err = s.appendMissingChannelPeerPreviews(ctx, userID, channelIDs, out) - if err != nil { - return domain.DialogList{}, err - } - } - if err := s.attachDrafts(ctx, userID, &out); err != nil { - return domain.DialogList{}, err - } - if err := s.projectDialogUsers(ctx, userID, &out); err != nil { - return domain.DialogList{}, err + out = mergeDialogLists(out, channelOut) } return out, nil } @@ -191,6 +300,7 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i } } seen := make(map[int64]struct{}, len(channelIDs)) + missingIDs := make([]int64, 0, len(channelIDs)) for _, channelID := range channelIDs { if channelID == 0 { continue @@ -203,12 +313,28 @@ func (s *Service) appendMissingChannelPeerPreviews(ctx context.Context, userID i continue } - view, err := s.channels.GetChannel(ctx, userID, channelID) - if err != nil { - if isChannelPreviewAccessError(err) { - continue - } - return domain.DialogList{}, err + missingIDs = append(missingIDs, channelID) + } + if len(missingIDs) == 0 { + return out, nil + } + views, err := s.channels.GetChannels(ctx, userID, missingIDs) + if err != nil { + if isChannelPreviewAccessError(err) { + return out, nil + } + return domain.DialogList{}, err + } + viewsByID := make(map[int64]domain.ChannelView, len(views)) + for _, view := range views { + if view.Channel.ID != 0 { + viewsByID[view.Channel.ID] = view + } + } + for _, channelID := range missingIDs { + view, ok := viewsByID[channelID] + if !ok || view.Forbidden { + continue } history, err := s.channels.ListChannelHistory(ctx, userID, domain.ChannelHistoryFilter{ ChannelID: channelID, @@ -256,26 +382,63 @@ func dialogFromChannelView(view domain.ChannelView) domain.Dialog { ReadOutboxMaxID: dialog.ReadOutboxMaxID, UnreadCount: dialog.UnreadCount, UnreadMentions: dialog.UnreadMentions, + UnreadReactions: dialog.UnreadReactions, Pinned: dialog.Pinned, PinnedOrder: dialog.PinnedOrder, UnreadMark: dialog.UnreadMark, ViewForumAsMessages: dialog.ViewForumAsMessages, + HasScheduled: dialog.HasScheduled, + Pts: view.Channel.Pts, } } // SaveDraft stores or clears a cloud draft for one peer/topic. -func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error { +// It returns whether the authoritative draft content changed. A repeated save +// with identical content does not refresh Date, invalidate read models, or +// force a durable update. +func (s *Service) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) (bool, error) { if s == nil || s.dialogs == nil || userID == 0 { - return nil + return false, nil } if err := validateDraft(draft); err != nil { - return err + return false, err } if draft.Empty() { - _, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID) - return err + changed, err := s.dialogs.DeleteDraft(ctx, userID, draft.Peer, draft.TopMessageID) + if err == nil && changed { + s.InvalidateDialog(userID, draft.Peer) + } + return changed, err } - return s.dialogs.SaveDraft(ctx, userID, draft) + existing, found, err := s.dialogs.GetDraft(ctx, userID, draft.Peer, draft.TopMessageID) + if err != nil { + return false, err + } + if found && sameDialogDraftContent(existing, draft) { + return false, nil + } + if err := s.dialogs.SaveDraft(ctx, userID, draft); err != nil { + return false, err + } + s.InvalidateDialog(userID, draft.Peer) + return true, nil +} + +func sameDialogDraftContent(a, b domain.DialogDraft) bool { + a.Date = 0 + b.Date = 0 + return reflect.DeepEqual(a, b) +} + +// GetDraft 读取某会话当前云草稿(draft_message 事件重放时按 peer 重载用)。 +func (s *Service) GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) { + if s == nil || s.dialogs == nil || userID == 0 { + return domain.DialogDraft{}, false, nil + } + if err := validateDraftKey(peer, topMessageID); err != nil { + return domain.DialogDraft{}, false, err + } + return s.dialogs.GetDraft(ctx, userID, peer, topMessageID) } // DeleteDraft clears one cloud draft. @@ -286,7 +449,11 @@ func (s *Service) DeleteDraft(ctx context.Context, userID int64, peer domain.Pee if err := validateDraftKey(peer, topMessageID); err != nil { return false, err } - return s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID) + changed, err := s.dialogs.DeleteDraft(ctx, userID, peer, topMessageID) + if err == nil && changed { + s.InvalidateDialog(userID, peer) + } + return changed, err } // ListDrafts returns bounded cloud drafts for messages.getAllDrafts. @@ -302,42 +469,185 @@ func (s *Service) ClearDrafts(ctx context.Context, userID int64, limit int) ([]d if s == nil || s.dialogs == nil || userID == 0 { return nil, nil } - return s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit)) + drafts, err := s.dialogs.ClearDrafts(ctx, userID, clampDraftLimit(limit)) + if err == nil { + for _, draft := range drafts { + s.InvalidateDialog(userID, draft.Peer) + } + } + return drafts, err } -func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) { +// TogglePinned 置顶/取消置顶一条会话;置顶顺序在会话当前 folder 内分配, +// 返回 (changed, 该会话所在 folder_id) 供 updateDialogPinned.folder_id 使用。 +// pin 时按 folder 校验上限(重复 pin 幂等放行),超限返回 ErrPinnedDialogsTooMuch。 +func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) { if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { - return false, nil + return false, 0, nil + } + if pinned { + if err := s.checkPinnedLimit(ctx, userID, peer); err != nil { + return false, 0, err + } } switch peer.Type { case domain.PeerTypeChannel: if s.channels == nil { - return false, nil + return false, 0, nil } - return s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned) + changed, folderID, err := s.channels.SetChannelDialogPinned(ctx, userID, peer.ID, pinned) + if err == nil && changed { + if pinned { + if err := s.promotePinnedDialog(ctx, userID, folderID, peer); err != nil { + return changed, folderID, err + } + } + s.InvalidateDialog(userID, peer) + } + return changed, folderID, err default: if s.dialogs == nil { - return false, nil + return false, 0, nil } - return s.dialogs.SetPinned(ctx, userID, peer, pinned) + changed, folderID, err := s.dialogs.SetPinned(ctx, userID, peer, pinned) + if err == nil && changed { + if pinned { + if err := s.promotePinnedDialog(ctx, userID, folderID, peer); err != nil { + return changed, folderID, err + } + } + s.InvalidateDialog(userID, peer) + } + return changed, folderID, err } } -func (s *Service) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error { - if s == nil || userID == 0 { +func (s *Service) promotePinnedDialog(ctx context.Context, userID int64, folderID int, peer domain.Peer) error { + if s == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { return nil } + list, err := s.GetDialogs(ctx, userID, domain.DialogFilter{ + PinnedOnly: true, + HasFolderID: true, + FolderID: folderID, + Limit: domain.PinnedDialogsLimit(folderID, true), + }) + if err != nil { + return err + } + order := make([]domain.Peer, 0, len(list.Dialogs)) + order = append(order, peer) + seen := map[domain.Peer]struct{}{peer: {}} + found := false + for _, dialog := range list.Dialogs { + if dialog.Peer == peer { + found = true + continue + } + if !dialog.Pinned { + continue + } + if _, ok := seen[dialog.Peer]; ok { + continue + } + seen[dialog.Peer] = struct{}{} + order = append(order, dialog.Peer) + } + if !found { + return nil + } + _, err = s.ReorderPinned(ctx, userID, folderID, order, false) + return err +} + +func (s *Service) checkPinnedLimit(ctx context.Context, userID int64, peer domain.Peer) error { + current, err := s.GetPeerDialogs(ctx, userID, []domain.Peer{peer}) + if err != nil { + return err + } + folderID := domain.DialogMainFolderID + for _, dialog := range current.Dialogs { + if dialog.Peer != peer { + continue + } + if dialog.Pinned { + // 重复 pin 幂等,不占新名额。 + return nil + } + folderID = dialog.FolderID + break + } + premium := s.premium != nil && s.premium(ctx, userID) + limit := domain.PinnedDialogsLimit(folderID, premium) + pinnedList, err := s.GetDialogs(ctx, userID, domain.DialogFilter{ + PinnedOnly: true, + HasFolderID: true, + FolderID: folderID, + Limit: limit, + }) + if err != nil { + return err + } + if pinnedList.Count >= limit { + return domain.ErrPinnedDialogsTooMuch + } + return nil +} + +// ToggleArchivePinned 置顶/取消置顶 archive folder 行本身 +// (toggleDialogPin(inputDialogPeerFolder)),返回是否变化。 +func (s *Service) ToggleArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error) { + if s == nil || s.dialogs == nil || userID == 0 { + return false, nil + } + changed, err := s.dialogs.SetArchivePinned(ctx, userID, pinned) + if err == nil && changed { + s.invalidateDialogListHashes(userID) + } + return changed, err +} + +// ReorderPinned 重排指定 folder(0 主列表/1 归档)内的置顶顺序; +// force 只清除该 folder 内不在 order 中的置顶,绝不跨 folder 误伤。 +func (s *Service) ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) { + if s == nil || userID == 0 { + return false, nil + } + changed := false if s.dialogs != nil { - if err := s.dialogs.ReorderPinned(ctx, userID, order, force); err != nil { - return err + privateChanged, err := s.dialogs.ReorderPinned(ctx, userID, folderID, order, force) + if err != nil { + return false, err + } + if privateChanged { + changed = true + for _, peer := range order { + if peer.Type != domain.PeerTypeChannel { + s.InvalidateDialog(userID, peer) + } + } } } if s.channels != nil { - if err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, order, force); err != nil { - return err + channelChanged, err := s.channels.ReorderChannelPinnedDialogs(ctx, userID, folderID, order, force) + if err != nil { + return false, err + } + if channelChanged { + changed = true + for _, peer := range order { + if peer.Type == domain.PeerTypeChannel { + s.InvalidateDialog(userID, peer) + } + } } } - return nil + if changed && force { + // force may unpin peers omitted from order; the store returns only a boolean, + // so flush the small peer-dialog snapshot cache to avoid stale omitted peers. + s.FlushReadModelCache() + } + return changed, nil } func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) { @@ -349,12 +659,20 @@ func (s *Service) MarkUnread(ctx context.Context, userID int64, peer domain.Peer if s.channels == nil { return false, nil } - return s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread) + changed, err := s.channels.SetChannelDialogUnreadMark(ctx, userID, peer.ID, unread) + if err == nil && changed { + s.InvalidateDialog(userID, peer) + } + return changed, err default: if s.dialogs == nil { return false, nil } - return s.dialogs.SetUnreadMark(ctx, userID, peer, unread) + changed, err := s.dialogs.SetUnreadMark(ctx, userID, peer, unread) + if err == nil && changed { + s.InvalidateDialog(userID, peer) + } + return changed, err } } @@ -384,7 +702,11 @@ func (s *Service) HidePeerSettingsBar(ctx context.Context, userID int64, peer do if s == nil || s.dialogs == nil || userID == 0 || peer.Type == "" || peer.ID == 0 { return false, nil } - return s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer) + changed, err := s.dialogs.SetPeerSettingsBarHidden(ctx, userID, peer) + if err == nil && changed { + s.InvalidateDialog(userID, peer) + } + return changed, err } func (s *Service) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) { @@ -405,21 +727,33 @@ func (s *Service) SaveDialogFolder(ctx context.Context, userID int64, folder dom if s == nil || s.dialogs == nil || userID == 0 { return nil } - return s.dialogs.UpsertFolder(ctx, userID, folder) + if err := s.dialogs.UpsertFolder(ctx, userID, folder); err != nil { + return err + } + s.invalidateDialogListHashes(userID) + return nil } func (s *Service) DeleteDialogFolder(ctx context.Context, userID int64, folderID int) error { if s == nil || s.dialogs == nil || userID == 0 { return nil } - return s.dialogs.DeleteFolder(ctx, userID, folderID) + if err := s.dialogs.DeleteFolder(ctx, userID, folderID); err != nil { + return err + } + s.invalidateDialogListHashes(userID) + return nil } func (s *Service) ReorderDialogFolders(ctx context.Context, userID int64, order []int) error { if s == nil || s.dialogs == nil || userID == 0 { return nil } - return s.dialogs.ReorderFolders(ctx, userID, order) + if err := s.dialogs.ReorderFolders(ctx, userID, order); err != nil { + return err + } + s.invalidateDialogListHashes(userID) + return nil } func (s *Service) ToggleDialogFolderTags(ctx context.Context, userID int64, enabled bool) error { @@ -446,11 +780,17 @@ func (s *Service) EditPeerFolders(ctx context.Context, userID int64, peers []dom if err := s.dialogs.EditPeerFolders(ctx, userID, privatePeers); err != nil { return err } + for _, update := range privatePeers { + s.InvalidateDialog(userID, update.Peer) + } } if len(channelPeers) > 0 && s.channels != nil { if err := s.channels.EditChannelPeerFolders(ctx, userID, channelPeers); err != nil { return err } + for _, update := range channelPeers { + s.InvalidateDialog(userID, update.Peer) + } } return nil } @@ -591,7 +931,9 @@ func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 { func mergeDialogLists(out, in domain.DialogList) domain.DialogList { out.Dialogs = append(out.Dialogs, in.Dialogs...) out.Messages = append(out.Messages, in.Messages...) + out.ChannelMessages = append(out.ChannelMessages, in.ChannelMessages...) out.Users = append(out.Users, in.Users...) + out.Channels = append(out.Channels, in.Channels...) out.Count += in.Count out.Hash ^= in.Hash return out diff --git a/internal/app/dialogs/service_test.go b/internal/app/dialogs/service_test.go index 16085d53..5f51dd3a 100644 --- a/internal/app/dialogs/service_test.go +++ b/internal/app/dialogs/service_test.go @@ -4,12 +4,376 @@ import ( "context" "errors" "testing" + "time" appchannels "telesrv/internal/app/channels" "telesrv/internal/domain" + "telesrv/internal/store" "telesrv/internal/store/memory" ) +type countingDialogStore struct { + store.DialogStore + listByUserCalls int + listByPeersCalls int + listByPeersBatches [][]domain.Peer + listDraftsCalls int +} + +func (s *countingDialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { + s.listByUserCalls++ + return s.DialogStore.ListByUser(ctx, userID, filter) +} + +func (s *countingDialogStore) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { + s.listByPeersCalls++ + s.listByPeersBatches = append(s.listByPeersBatches, append([]domain.Peer(nil), peers...)) + return s.DialogStore.ListByPeers(ctx, userID, peers) +} + +func (s *countingDialogStore) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + s.listDraftsCalls++ + return s.DialogStore.ListDrafts(ctx, userID, limit) +} + +type fakeDialogReadModelVersions struct { + hashes map[store.ReadModelKey]int64 +} + +func (f *fakeDialogReadModelVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) { + hash := f.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}] + return hash, hash != 0, nil +} + +func (f *fakeDialogReadModelVersions) ReadModelHashes(_ context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) { + out := make(map[store.ReadModelKey]int64, len(keys)) + for _, key := range keys { + if hash := f.hashes[key]; hash != 0 { + out[key] = hash + } + } + return out, nil +} + +type countingDialogChannelStore struct { + *memory.ChannelStore + getChannelCalls int + getChannelsCalls int + getChannelDialogsCalls int +} + +func (s *countingDialogChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { + s.getChannelCalls++ + return s.ChannelStore.GetChannel(ctx, viewerUserID, channelID) +} + +func (s *countingDialogChannelStore) GetChannels(ctx context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error) { + s.getChannelsCalls++ + return s.ChannelStore.GetChannels(ctx, viewerUserID, channelIDs) +} + +func (s *countingDialogChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) { + s.getChannelDialogsCalls++ + return s.ChannelStore.GetChannelDialogs(ctx, viewerUserID, channelIDs) +} + +func TestGetDialogsHashUsesWarmStableHashCacheAndInvalidatesOnWrite(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002} + base := memory.NewDialogStore() + if err := base.SaveList(ctx, ownerID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: peer, + TopMessage: 7, + TopMessageDate: 70, + UnreadCount: 1, + }}, + Messages: []domain.Message{{ + ID: 7, + OwnerUserID: ownerID, + Peer: peer, + From: peer, + Date: 70, + Body: "cached top", + }}, + Users: []domain.User{{ID: peer.ID, AccessHash: 22, FirstName: "Peer"}}, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + counting := &countingDialogStore{DialogStore: base} + dialogs := NewService(counting) + filter := domain.DialogFilter{ExcludePinned: true, Limit: 10} + list, err := dialogs.GetDialogs(ctx, ownerID, filter) + if err != nil { + t.Fatalf("GetDialogs warm: %v", err) + } + if list.Hash == 0 { + t.Fatal("warmed list hash = 0, want stable non-zero hash") + } + + check, err := dialogs.GetDialogsHash(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 10, Hash: list.Hash}) + if err != nil { + t.Fatalf("GetDialogsHash: %v", err) + } + if !check.Known || !check.Matched || check.Count != list.Count { + t.Fatalf("hash check = %+v, want known matched count %d", check, list.Count) + } + if counting.listByUserCalls != 1 { + t.Fatalf("ListByUser calls = %d, want only warm load", counting.listByUserCalls) + } + + if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 71, Message: "new draft"}); err != nil { + t.Fatalf("SaveDraft: %v", err) + } + check, err = dialogs.GetDialogsHash(ctx, ownerID, domain.DialogFilter{ExcludePinned: true, Limit: 10, Hash: list.Hash}) + if err != nil { + t.Fatalf("GetDialogsHash after invalidation: %v", err) + } + if check.Known { + t.Fatalf("hash check after invalidation = %+v, want unknown", check) + } +} + +func TestSaveDraftNoopsWhenOnlyDateChanges(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002} + base := memory.NewDialogStore() + dialogs := NewService(base) + + changed, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 71, Message: "draft"}) + if err != nil { + t.Fatalf("SaveDraft first: %v", err) + } + if !changed { + t.Fatalf("SaveDraft first changed = false, want true") + } + changed, err = dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 72, Message: "draft"}) + if err != nil { + t.Fatalf("SaveDraft same content: %v", err) + } + if changed { + t.Fatalf("SaveDraft same content changed = true, want false") + } + got, found, err := base.GetDraft(ctx, ownerID, peer, 0) + if err != nil || !found { + t.Fatalf("GetDraft = found %v err %v, want stored draft", found, err) + } + if got.Date != 71 { + t.Fatalf("draft date = %d, want original 71", got.Date) + } + + changed, err = dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 73, Message: "updated"}) + if err != nil { + t.Fatalf("SaveDraft updated: %v", err) + } + if !changed { + t.Fatalf("SaveDraft updated changed = false, want true") + } +} + +func TestGetPeerDialogsCachesPrivatePeerReadModelByHash(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002} + base := memory.NewDialogStore() + if err := base.SaveList(ctx, ownerID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: peer, + TopMessage: 7, + TopMessageDate: 70, + UnreadCount: 1, + }}, + Messages: []domain.Message{{ + ID: 7, + OwnerUserID: ownerID, + Peer: peer, + From: peer, + Date: 70, + Body: "cached top", + }}, + Users: []domain.User{{ID: peer.ID, AccessHash: 22, FirstName: "Peer"}}, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + if err := base.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 71, Message: "draft"}); err != nil { + t.Fatalf("SaveDraft: %v", err) + } + counting := &countingDialogStore{DialogStore: base} + versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ + {Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 101, + }} + dialogs := NewService(counting).Configure(WithReadModelVersions(versions)) + + first, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}) + if err != nil { + t.Fatalf("first GetPeerDialogs: %v", err) + } + if len(first.Dialogs) != 1 || first.Dialogs[0].Draft == nil || first.Dialogs[0].Draft.Message != "draft" { + t.Fatalf("first dialog = %+v, want cached draft attached", first.Dialogs) + } + second, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}) + if err != nil { + t.Fatalf("second GetPeerDialogs: %v", err) + } + if len(second.Dialogs) != 1 || second.Dialogs[0].TopMessage != 7 { + t.Fatalf("second dialog = %+v, want cached top message", second.Dialogs) + } + if counting.listByPeersCalls != 1 || counting.listDraftsCalls != 1 { + t.Fatalf("store calls ListByPeers/ListDrafts = %d/%d, want 1/1 after cache hit", counting.listByPeersCalls, counting.listDraftsCalls) + } + + versions.hashes[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 202 + if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil { + t.Fatalf("third GetPeerDialogs after hash bump: %v", err) + } + if counting.listByPeersCalls != 2 || counting.listDraftsCalls != 2 { + t.Fatalf("store calls after hash bump = %d/%d, want 2/2", counting.listByPeersCalls, counting.listDraftsCalls) + } + + if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 72, Message: "new draft"}); err != nil { + t.Fatalf("service SaveDraft: %v", err) + } + if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil { + t.Fatalf("GetPeerDialogs after service invalidation: %v", err) + } + if counting.listByPeersCalls != 3 || counting.listDraftsCalls != 3 { + t.Fatalf("store calls after explicit invalidation = %d/%d, want 3/3", counting.listByPeersCalls, counting.listDraftsCalls) + } +} + +func TestGetPeerDialogsReloadsOnlyReadModelCacheMisses(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + firstPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002} + secondPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1003} + base := memory.NewDialogStore() + if err := base.SaveList(ctx, ownerID, domain.DialogList{ + Dialogs: []domain.Dialog{ + {Peer: firstPeer, TopMessage: 7, TopMessageDate: 70}, + {Peer: secondPeer, TopMessage: 8, TopMessageDate: 80}, + }, + Messages: []domain.Message{ + {ID: 7, OwnerUserID: ownerID, Peer: firstPeer, From: firstPeer, Date: 70, Body: "first"}, + {ID: 8, OwnerUserID: ownerID, Peer: secondPeer, From: secondPeer, Date: 80, Body: "second"}, + }, + Users: []domain.User{ + {ID: firstPeer.ID, AccessHash: 22, FirstName: "First"}, + {ID: secondPeer.ID, AccessHash: 33, FirstName: "Second"}, + }, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + counting := &countingDialogStore{DialogStore: base} + versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ + {Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: firstPeer.Type, PeerID: firstPeer.ID}: 101, + {Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: secondPeer.Type, PeerID: secondPeer.ID}: 202, + }} + dialogs := NewService(counting).Configure(WithReadModelVersions(versions)) + peers := []domain.Peer{firstPeer, secondPeer} + + if _, err := dialogs.GetPeerDialogs(ctx, ownerID, peers); err != nil { + t.Fatalf("first GetPeerDialogs: %v", err) + } + versions.hashes[store.ReadModelKey{Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: secondPeer.Type, PeerID: secondPeer.ID}] = 303 + if _, err := dialogs.GetPeerDialogs(ctx, ownerID, peers); err != nil { + t.Fatalf("second GetPeerDialogs after one hash bump: %v", err) + } + if counting.listByPeersCalls != 2 { + t.Fatalf("ListByPeers calls = %d, want 2", counting.listByPeersCalls) + } + lastBatch := counting.listByPeersBatches[len(counting.listByPeersBatches)-1] + if len(lastBatch) != 1 || lastBatch[0] != secondPeer { + t.Fatalf("last ListByPeers batch = %+v, want only %+v", lastBatch, secondPeer) + } +} + +func TestGetPeerDialogsCachesChannelPeerReadModelByCompositeHash(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + dialogStore := &countingDialogStore{DialogStore: memory.NewDialogStore()} + channelStore := &countingDialogChannelStore{ChannelStore: memory.NewChannelStore()} + channels := appchannels.NewService(channelStore) + created, err := channels.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Cached Channel Dialog", + Megagroup: true, + Date: 1700003200, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + sent, err := channels.SendMessage(ctx, ownerID, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 11, + Message: "cached channel top", + Date: 1700003210, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + versions := &fakeDialogReadModelVersions{hashes: map[store.ReadModelKey]int64{ + {Model: channelBaseReadModel, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 11, + {Model: channelMemberReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 22, + {Model: dialogLightReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}: 33, + }} + dialogs := NewService(dialogStore, channelStore).Configure(WithReadModelVersions(versions)) + + first, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}) + if err != nil { + t.Fatalf("first GetPeerDialogs: %v", err) + } + if len(first.Dialogs) != 1 || first.Dialogs[0].TopMessage != sent.Message.ID { + t.Fatalf("first dialogs = %+v, want channel top %d", first.Dialogs, sent.Message.ID) + } + if len(first.ChannelMessages) != 1 || first.ChannelMessages[0].Body != "cached channel top" { + t.Fatalf("first channel messages = %+v, want cached top message", first.ChannelMessages) + } + if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil { + t.Fatalf("second GetPeerDialogs: %v", err) + } + if channelStore.getChannelDialogsCalls != 1 || dialogStore.listDraftsCalls != 1 { + t.Fatalf("store calls GetChannelDialogs/ListDrafts = %d/%d, want 1/1 after channel cache hit", + channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls) + } + + versions.hashes[store.ReadModelKey{Model: channelMemberReadModel, OwnerUserID: ownerID, PeerType: peer.Type, PeerID: peer.ID}] = 44 + if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil { + t.Fatalf("third GetPeerDialogs after member hash bump: %v", err) + } + if channelStore.getChannelDialogsCalls != 2 || dialogStore.listDraftsCalls != 2 { + t.Fatalf("store calls after member hash bump = %d/%d, want 2/2", + channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls) + } + + if _, err := dialogs.SaveDraft(ctx, ownerID, domain.DialogDraft{Peer: peer, Date: 1700003220, Message: "channel draft"}); err != nil { + t.Fatalf("SaveDraft: %v", err) + } + if _, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{peer}); err != nil { + t.Fatalf("GetPeerDialogs after draft invalidation: %v", err) + } + if channelStore.getChannelDialogsCalls != 3 || dialogStore.listDraftsCalls != 3 { + t.Fatalf("store calls after draft invalidation = %d/%d, want 3/3", + channelStore.getChannelDialogsCalls, dialogStore.listDraftsCalls) + } +} + +func TestDialogPeerReadModelCacheRejectsStaleFillAfterInvalidation(t *testing.T) { + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002} + key := dialogPeerCacheKey{userID: 1001, peer: peer} + cache := newDialogPeerReadModelCache(time.Hour) + epoch := cache.cacheEpoch() + cache.invalidate(key) + cache.putIfEpoch(key, domain.DialogList{ + Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 7}}, + Hash: 101, + }, 101, epoch) + if _, ok := cache.lookup(key, 101); ok { + t.Fatalf("stale cache fill survived invalidation") + } +} + func TestGetDialogsIncludesChannelReadOutboxAfterOfflineRead(t *testing.T) { ctx := context.Background() channelStore := memory.NewChannelStore() @@ -143,14 +507,14 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) { firstPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID} secondPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: second.Channel.ID} - if changed, err := dialogs.TogglePinned(ctx, 1001, firstPeer, true); err != nil || !changed { + if changed, _, err := dialogs.TogglePinned(ctx, 1001, firstPeer, true); err != nil || !changed { t.Fatalf("TogglePinned first = changed %v err %v, want changed", changed, err) } - if changed, err := dialogs.TogglePinned(ctx, 1001, secondPeer, true); err != nil || !changed { + if changed, _, err := dialogs.TogglePinned(ctx, 1001, secondPeer, true); err != nil || !changed { t.Fatalf("TogglePinned second = changed %v err %v, want changed", changed, err) } - if err := dialogs.ReorderPinned(ctx, 1001, []domain.Peer{secondPeer, firstPeer}, true); err != nil { - t.Fatalf("ReorderPinned: %v", err) + if changed, err := dialogs.ReorderPinned(ctx, 1001, domain.DialogMainFolderID, []domain.Peer{secondPeer, firstPeer}, true); err != nil || changed { + t.Fatalf("ReorderPinned same order = changed %v err %v, want no-op", changed, err) } if changed, err := dialogs.MarkUnread(ctx, 1001, firstPeer, true); err != nil || !changed { t.Fatalf("MarkUnread = changed %v err %v, want changed", changed, err) @@ -165,9 +529,15 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) { if err != nil { t.Fatalf("GetDialogs: %v", err) } - firstDialog := findChannelDialog(t, list, first.Channel.ID) - if !firstDialog.Pinned || firstDialog.PinnedOrder != 1 || !firstDialog.UnreadMark || firstDialog.FolderID != domain.DialogArchiveFolderID { - t.Fatalf("first dialog = %+v, want pinned order 1, unread mark, archived", firstDialog) + // 归档对话不再出现在主列表(缺省 folder 视为 folder 0),主列表以 + // ArchiveSummary 聚合呈现归档状态。 + for _, dialog := range list.Dialogs { + if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID == first.Channel.ID { + t.Fatalf("archived dialog leaked into main list: %+v", dialog) + } + } + if list.ArchiveSummary == nil { + t.Fatalf("main list archive summary = nil, want attached after archiving") } secondDialog := findChannelDialog(t, list, second.Channel.ID) if !secondDialog.Pinned || secondDialog.PinnedOrder != 2 { @@ -188,9 +558,15 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) { if err != nil { t.Fatalf("GetDialogs archive: %v", err) } - if got := findChannelDialog(t, archived, first.Channel.ID); got.FolderID != domain.DialogArchiveFolderID { + got := findChannelDialog(t, archived, first.Channel.ID) + if got.FolderID != domain.DialogArchiveFolderID { t.Fatalf("archived dialog = %+v, want archive folder", got) } + // 归档清除 pinned(对齐 TDesktop History::setFolderPointer 的本地 unpin), + // unread_mark 保留。 + if got.Pinned || got.PinnedOrder != 0 || !got.UnreadMark { + t.Fatalf("archived dialog = %+v, want unpinned with unread mark", got) + } custom, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ HasFolderID: true, @@ -206,6 +582,64 @@ func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) { } } +func TestTogglePinnedPromotesNewestPinnedAcrossPrivateAndChannelDialogs(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + dialogStore := memory.NewDialogStore() + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore) + dialogs := NewService(dialogStore, channelStore) + privatePeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1002} + if err := dialogStore.SaveList(ctx, ownerID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: privatePeer, + TopMessage: 30, + TopMessageDate: 3000, + }}, + Messages: []domain.Message{{ + ID: 30, + OwnerUserID: ownerID, + Peer: privatePeer, + From: privatePeer, + Date: 3000, + Body: "newer private top", + }}, + Users: []domain.User{{ID: privatePeer.ID, AccessHash: 22, FirstName: "Private"}}, + }); err != nil { + t.Fatalf("SaveList private dialog: %v", err) + } + created, err := channels.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Older Channel", + Megagroup: true, + Date: 1000, + }) + if err != nil { + t.Fatalf("CreateChannel: %v", err) + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + + if changed, _, err := dialogs.TogglePinned(ctx, ownerID, privatePeer, true); err != nil || !changed { + t.Fatalf("TogglePinned private = changed %v err %v, want changed", changed, err) + } + if changed, _, err := dialogs.TogglePinned(ctx, ownerID, channelPeer, true); err != nil || !changed { + t.Fatalf("TogglePinned channel = changed %v err %v, want changed", changed, err) + } + + list, err := dialogs.GetDialogs(ctx, ownerID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("GetDialogs: %v", err) + } + if len(list.Dialogs) < 2 { + t.Fatalf("dialogs = %+v, want private and channel dialogs", list.Dialogs) + } + if list.Dialogs[0].Peer != channelPeer || list.Dialogs[0].PinnedOrder != 2 { + t.Fatalf("first dialog = %+v, want newly pinned channel with highest order", list.Dialogs[0]) + } + if list.Dialogs[1].Peer != privatePeer || list.Dialogs[1].PinnedOrder != 1 { + t.Fatalf("second dialog = %+v, want older pinned private dialog", list.Dialogs[1]) + } +} + func TestGetDialogsAppliesChannelDialogOffset(t *testing.T) { ctx := context.Background() channelStore := memory.NewChannelStore() @@ -328,6 +762,99 @@ func TestGetPeerDialogsIncludesPublicChannelPreviewForNonMember(t *testing.T) { } } +func TestGetPeerDialogsBatchesMissingChannelPreviews(t *testing.T) { + ctx := context.Background() + channelStore := &countingDialogChannelStore{ChannelStore: memory.NewChannelStore()} + channels := appchannels.NewService(channelStore) + dialogs := NewService(nil, channelStore) + + first, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "Batch Preview One", + Broadcast: true, + Date: 1700002100, + }) + if err != nil { + t.Fatalf("CreateChannel first: %v", err) + } + if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ + UserID: 1001, + ChannelID: first.Channel.ID, + Username: "batch_preview_one", + }); err != nil { + t.Fatalf("UpdateUsername first: %v", err) + } + firstMsg, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: first.Channel.ID, + RandomID: 101, + Message: "first public preview", + Date: 1700002110, + }) + if err != nil { + t.Fatalf("SendMessage first: %v", err) + } + + second, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "Batch Preview Two", + Broadcast: true, + Date: 1700002120, + }) + if err != nil { + t.Fatalf("CreateChannel second: %v", err) + } + if _, err := channels.UpdateUsername(ctx, 1001, domain.UpdateChannelUsernameRequest{ + UserID: 1001, + ChannelID: second.Channel.ID, + Username: "batch_preview_two", + }); err != nil { + t.Fatalf("UpdateUsername second: %v", err) + } + secondMsg, err := channels.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: second.Channel.ID, + RandomID: 102, + Message: "second public preview", + Date: 1700002130, + }) + if err != nil { + t.Fatalf("SendMessage second: %v", err) + } + + private, err := channels.CreateChannel(ctx, 1001, domain.CreateChannelRequest{ + Title: "Batch Preview Private", + Broadcast: true, + Date: 1700002140, + }) + if err != nil { + t.Fatalf("CreateChannel private: %v", err) + } + + channelStore.getChannelCalls = 0 + channelStore.getChannelsCalls = 0 + list, err := dialogs.GetPeerDialogs(ctx, 1002, []domain.Peer{ + {Type: domain.PeerTypeChannel, ID: first.Channel.ID}, + {Type: domain.PeerTypeChannel, ID: private.Channel.ID}, + {Type: domain.PeerTypeChannel, ID: second.Channel.ID}, + {Type: domain.PeerTypeChannel, ID: first.Channel.ID}, + }) + if err != nil { + t.Fatalf("GetPeerDialogs batch previews: %v", err) + } + if channelStore.getChannelsCalls != 1 || channelStore.getChannelCalls != 0 { + t.Fatalf("preview channel calls: GetChannels=%d GetChannel=%d, want one batch call only", channelStore.getChannelsCalls, channelStore.getChannelCalls) + } + if len(list.Dialogs) != 2 { + t.Fatalf("dialogs = %+v, want two public previews", list.Dialogs) + } + if got := findChannelDialog(t, list, first.Channel.ID); got.TopMessage != firstMsg.Message.ID { + t.Fatalf("first preview top = %d, want %d", got.TopMessage, firstMsg.Message.ID) + } + if got := findChannelDialog(t, list, second.Channel.ID); got.TopMessage != secondMsg.Message.ID { + t.Fatalf("second preview top = %d, want %d", got.TopMessage, secondMsg.Message.ID) + } + if len(list.ChannelMessages) != 2 { + t.Fatalf("channel messages = %+v, want two top messages", list.ChannelMessages) + } +} + func findChannelDialog(t *testing.T, list domain.DialogList, channelID int64) domain.Dialog { t.Helper() for _, dialog := range list.Dialogs { @@ -361,3 +888,149 @@ func (p dialogProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.Pe } return out, nil } + +func TestGetDialogsMainListAttachesArchiveSummary(t *testing.T) { + ctx := context.Background() + dialogStore := memory.NewDialogStore() + dialogs := NewService(dialogStore) + + archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002} + mainPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2003} + if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{ + Peer: archivedPeer, + FolderID: domain.DialogArchiveFolderID, + TopMessage: 7, + TopMessageDate: 30, + UnreadCount: 3, + }); err != nil { + t.Fatalf("upsert archived dialog: %v", err) + } + if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{ + Peer: mainPeer, + TopMessage: 9, + TopMessageDate: 40, + }); err != nil { + t.Fatalf("upsert main dialog: %v", err) + } + + list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("GetDialogs main: %v", err) + } + if list.ArchiveSummary == nil { + t.Fatalf("main list archive summary = nil, want attached") + } + if list.ArchiveSummary.TopPeer != archivedPeer || list.ArchiveSummary.TopMessage != 7 { + t.Fatalf("archive summary top = %+v, want peer %+v message 7", list.ArchiveSummary, archivedPeer) + } + if list.ArchiveSummary.UnreadPeersCount != 1 || list.ArchiveSummary.UnreadMessagesCount != 3 { + t.Fatalf("archive summary counts = %+v, want 1 peer / 3 messages", list.ArchiveSummary) + } + for _, dialog := range list.Dialogs { + if dialog.Peer == archivedPeer { + t.Fatalf("archived dialog leaked into main list: %+v", dialog) + } + } + + archived, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ + HasFolderID: true, + FolderID: domain.DialogArchiveFolderID, + Limit: 10, + }) + if err != nil { + t.Fatalf("GetDialogs archive: %v", err) + } + if archived.ArchiveSummary != nil { + t.Fatalf("archive list summary = %+v, want nil", archived.ArchiveSummary) + } + + paged, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10, OffsetID: 9}) + if err != nil { + t.Fatalf("GetDialogs paged: %v", err) + } + if paged.ArchiveSummary != nil { + t.Fatalf("paged list summary = %+v, want nil (first page only)", paged.ArchiveSummary) + } +} + +func TestGetDialogsMainListSkipsArchiveSummaryWhenEmpty(t *testing.T) { + ctx := context.Background() + dialogStore := memory.NewDialogStore() + dialogs := NewService(dialogStore) + if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2003}, + TopMessage: 9, + TopMessageDate: 40, + }); err != nil { + t.Fatalf("upsert main dialog: %v", err) + } + list, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("GetDialogs: %v", err) + } + if list.ArchiveSummary != nil { + t.Fatalf("archive summary = %+v, want nil when no archived dialogs", list.ArchiveSummary) + } +} + +func TestGetDialogsPinnedOnlyAttachesArchiveSummary(t *testing.T) { + ctx := context.Background() + dialogStore := memory.NewDialogStore() + dialogs := NewService(dialogStore) + archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002} + if err := dialogStore.Upsert(ctx, 1001, domain.Dialog{ + Peer: archivedPeer, + FolderID: domain.DialogArchiveFolderID, + TopMessage: 7, + TopMessageDate: 30, + UnreadCount: 3, + }); err != nil { + t.Fatalf("upsert archived dialog: %v", err) + } + // getPinnedDialogs(folder_id=0) 路径:DrKLO 的 archive 行发现完全依赖 + // 该响应里的 dialogFolder 条目。 + pinned, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ + PinnedOnly: true, + HasFolderID: true, + FolderID: domain.DialogMainFolderID, + Limit: 100, + }) + if err != nil { + t.Fatalf("GetDialogs pinned: %v", err) + } + if pinned.ArchiveSummary == nil || !pinned.ArchiveSummary.Pinned || pinned.ArchiveSummary.TopPeer != archivedPeer { + t.Fatalf("pinned archive summary = %+v, want pinned archive entry", pinned.ArchiveSummary) + } + // archive 行被 unpin 后不属于 pinned 集合。 + if _, err := dialogStore.SetArchivePinned(ctx, 1001, false); err != nil { + t.Fatalf("set archive pinned: %v", err) + } + unpinned, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ + PinnedOnly: true, + HasFolderID: true, + FolderID: domain.DialogMainFolderID, + Limit: 100, + }) + if err != nil { + t.Fatalf("GetDialogs pinned after unpin: %v", err) + } + if unpinned.ArchiveSummary != nil { + t.Fatalf("pinned archive summary after unpin = %+v, want nil", unpinned.ArchiveSummary) + } + // 主列表第一页仍输出条目(pinned flag 用真值),与官方一致。 + main, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("GetDialogs main: %v", err) + } + if main.ArchiveSummary == nil || main.ArchiveSummary.Pinned { + t.Fatalf("main archive summary = %+v, want entry with pinned=false", main.ArchiveSummary) + } + // exclude_pinned 请求按官方语义不带条目。 + excluded, err := dialogs.GetDialogs(ctx, 1001, domain.DialogFilter{ExcludePinned: true, Limit: 10}) + if err != nil { + t.Fatalf("GetDialogs exclude pinned: %v", err) + } + if excluded.ArchiveSummary != nil { + t.Fatalf("exclude_pinned archive summary = %+v, want nil", excluded.ArchiveSummary) + } +} diff --git a/internal/app/files/appearance_seed.go b/internal/app/files/appearance_seed.go new file mode 100644 index 00000000..6acbeedc --- /dev/null +++ b/internal/app/files/appearance_seed.go @@ -0,0 +1,282 @@ +package files + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + + "telesrv/internal/domain" + "telesrv/internal/seed/appearance" +) + +// AppearanceSeedStats reports default appearance resources imported into media storage. +type AppearanceSeedStats struct { + Wallpapers int + Documents int + Blobs int + Skipped bool +} + +// SeedAppearance imports the default wallpaper document catalog into telesrv media storage. +func (s *Service) SeedAppearance(ctx context.Context) (AppearanceSeedStats, error) { + var stats AppearanceSeedStats + catalog := appearance.Default() + if len(catalog.Wallpapers) == 0 && len(catalog.ChatThemes) == 0 { + stats.Skipped = true + return stats, nil + } + stateHash, err := s.seedAppearanceStateHash() + if err != nil { + return stats, err + } + ready, err := s.appearanceSeedReady(ctx, catalog) + if err != nil { + return stats, err + } + matched, err := s.seedStateMatches(ctx, seedAppearanceStateKey, stateHash) + if err != nil { + return stats, err + } + if matched && ready { + stats.Wallpapers = len(catalog.Wallpapers) + stats.Skipped = true + return stats, nil + } + seen := make(map[int64]bool) + seedDoc := func(in appearance.Document, label string) error { + if in.ID == 0 || seen[in.ID] { + return nil + } + seen[in.ID] = true + doc, blobs, err := s.seedAppearanceDocument(ctx, in) + if err != nil { + return fmt.Errorf("seed %s %d: %w", label, in.ID, err) + } + if doc.ID != 0 { + stats.Documents++ + } + stats.Blobs += blobs + return nil + } + for _, wallpaper := range catalog.Wallpapers { + if err := seedDoc(wallpaper.Document, "wallpaper"); err != nil { + return stats, err + } + stats.Wallpapers++ + } + // 聊天主题的主题背景墙纸文档也要进媒体库,否则客户端取主题背景会 404。 + for _, ct := range catalog.ChatThemes { + for _, setting := range ct.Settings { + if err := seedDoc(setting.Wallpaper.Document, "chat theme wallpaper"); err != nil { + return stats, err + } + } + } + if err := s.putSeedState(ctx, seedAppearanceStateKey, stateHash); err != nil { + return stats, err + } + return stats, nil +} + +func (s *Service) seedAppearanceStateHash() (string, error) { + raw, err := appearance.FS.ReadFile("default_appearance_seed.json") + if err != nil { + return "", err + } + return seedStateHash(func(h hash.Hash) error { + writeSeedStateHeader(h, seedAppearanceStateVersion, s.dc) + _, _ = h.Write(raw) + return nil + }) +} + +func (s *Service) appearanceSeedReady(ctx context.Context, catalog appearance.Catalog) (bool, error) { + docs := make(map[int64]appearance.Document) + add := func(doc appearance.Document) { + if doc.ID != 0 { + docs[doc.ID] = doc + } + } + for _, wallpaper := range catalog.Wallpapers { + add(wallpaper.Document) + } + for _, ct := range catalog.ChatThemes { + for _, setting := range ct.Settings { + add(setting.Wallpaper.Document) + } + } + if len(docs) == 0 { + return true, nil + } + ids := make([]int64, 0, len(docs)) + locationKeys := make([]string, 0, len(docs)*2) + for id, doc := range docs { + ids = append(ids, id) + if doc.Path != "" { + locationKeys = append(locationKeys, fmt.Sprintf("doc:%d", id)) + } + for _, thumb := range doc.Thumbs { + if thumb.Path != "" && thumb.Type != "" { + locationKeys = append(locationKeys, fmt.Sprintf("doc:%d:%s", id, thumb.Type)) + } + } + } + stored, err := s.media.GetDocuments(ctx, ids) + if err != nil { + return false, err + } + if len(stored) < len(docs) { + return false, nil + } + for _, doc := range stored { + want, ok := docs[doc.ID] + if !ok { + continue + } + if doc.DCID != s.dc || doc.MimeType != want.MimeType || doc.Size != want.Size { + return false, nil + } + delete(docs, doc.ID) + } + if len(docs) > 0 { + return false, nil + } + if len(locationKeys) == 0 { + return true, nil + } + blobs, err := s.media.GetFileBlobs(ctx, locationKeys) + if err != nil { + return false, err + } + for _, key := range locationKeys { + if _, ok := blobs[key]; !ok { + return false, nil + } + } + return true, nil +} + +func (s *Service) seedAppearanceDocument(ctx context.Context, in appearance.Document) (domain.Document, int, error) { + if in.ID == 0 { + return domain.Document{}, 0, nil + } + doc := domain.Document{ + ID: in.ID, + AccessHash: in.AccessHash, + Date: in.Date, + MimeType: in.MimeType, + Size: in.Size, + DCID: s.dc, + Attributes: appearanceDocumentAttributes(in.Attributes), + Thumbs: appearanceDocumentThumbs(in.Thumbs), + } + blobs := 0 + if in.Path != "" { + data, sum, err := readAppearanceSeedBlob(in.Path, in.SHA256) + if err != nil { + return domain.Document{}, blobs, err + } + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return domain.Document{}, blobs, err + } + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d", in.ID), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + SHA256: sum, + MimeType: in.MimeType, + }); err != nil { + return domain.Document{}, blobs, err + } + s.prewarmSmallBlob(objectKey, data) + blobs++ + } + for _, thumb := range in.Thumbs { + if thumb.Path == "" || thumb.Type == "" { + continue + } + data, sum, err := readAppearanceSeedBlob(thumb.Path, thumb.SHA256) + if err != nil { + return domain.Document{}, blobs, err + } + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return domain.Document{}, blobs, err + } + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d:%s", in.ID, thumb.Type), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + SHA256: sum, + MimeType: seedThumbMimeType(data), + }); err != nil { + return domain.Document{}, blobs, err + } + s.prewarmSmallBlob(objectKey, data) + blobs++ + } + if err := s.media.PutDocument(ctx, doc); err != nil { + return domain.Document{}, blobs, err + } + return doc, blobs, nil +} + +func readAppearanceSeedBlob(path, wantSHA string) ([]byte, []byte, error) { + data, err := appearance.FS.ReadFile(path) + if err != nil { + return nil, nil, err + } + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if wantSHA != "" && got != wantSHA { + return nil, nil, fmt.Errorf("%s sha256 = %s, want %s", path, got, wantSHA) + } + return data, append([]byte(nil), sum[:]...), nil +} + +func appearanceDocumentAttributes(in []appearance.DocumentAttribute) []domain.DocumentAttribute { + out := make([]domain.DocumentAttribute, 0, len(in)) + for _, attr := range in { + switch attr.Kind { + case "image_size": + out = append(out, domain.DocumentAttribute{ + Kind: domain.DocAttrImageSize, + W: attr.W, + H: attr.H, + }) + case "filename": + if attr.FileName != "" { + out = append(out, domain.DocumentAttribute{ + Kind: domain.DocAttrFilename, + FileName: attr.FileName, + }) + } + } + } + return out +} + +func appearanceDocumentThumbs(in []appearance.PhotoSize) []domain.PhotoSize { + out := make([]domain.PhotoSize, 0, len(in)) + for _, thumb := range in { + if thumb.Type == "" { + continue + } + switch thumb.Kind { + case "size": + out = append(out, domain.PhotoSize{ + Kind: domain.PhotoSizeKindDefault, + Type: thumb.Type, + W: thumb.W, + H: thumb.H, + Size: thumb.Size, + }) + } + } + return out +} diff --git a/internal/app/files/appearance_seed_test.go b/internal/app/files/appearance_seed_test.go new file mode 100644 index 00000000..b9bd556b --- /dev/null +++ b/internal/app/files/appearance_seed_test.go @@ -0,0 +1,98 @@ +package files + +import ( + "context" + "fmt" + "testing" + + "telesrv/internal/seed/appearance" +) + +func TestSeedAppearanceImportsDefaultWallpaperDocuments(t *testing.T) { + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(media, blobs, 2) + + stats, err := svc.SeedAppearance(context.Background()) + if err != nil { + t.Fatalf("SeedAppearance: %v", err) + } + if stats.Skipped || stats.Wallpapers == 0 || stats.Documents == 0 || stats.Blobs < stats.Documents { + t.Fatalf("SeedAppearance stats = %+v, want non-empty wallpapers/documents with >=1 blob each", stats) + } + + var first appearance.Wallpaper + for _, w := range appearance.Default().Wallpapers { + if w.Document.ID != 0 { + first = w + break + } + } + if first.Document.ID == 0 { + t.Fatalf("no wallpaper with a document in catalog") + } + doc, ok, err := media.GetDocument(context.Background(), first.Document.ID) + if err != nil || !ok { + t.Fatalf("GetDocument(%d) = ok %v err %v", first.Document.ID, ok, err) + } + if doc.DCID != 2 || doc.MimeType != first.Document.MimeType || doc.Size != first.Document.Size { + t.Fatalf("document = dc %d mime %q size %d, want dc 2 mime %q size %d", + doc.DCID, doc.MimeType, doc.Size, first.Document.MimeType, first.Document.Size) + } + if len(doc.Thumbs) == 0 || doc.Thumbs[0].Type != "m" { + t.Fatalf("document thumbs = %+v, want m thumbnail", doc.Thumbs) + } + if _, ok, err := media.GetFileBlob(context.Background(), fmt.Sprintf("doc:%d", first.Document.ID)); err != nil || !ok { + t.Fatalf("main blob ok=%v err=%v, want present", ok, err) + } + if _, ok, err := media.GetFileBlob(context.Background(), fmt.Sprintf("doc:%d:m", first.Document.ID)); err != nil || !ok { + t.Fatalf("thumb blob ok=%v err=%v, want present", ok, err) + } +} + +func TestSeedAppearanceSkipsUnchangedCatalogAndRepairsMissingBlob(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(media, blobs, 2) + + first, err := svc.SeedAppearance(ctx) + if err != nil { + t.Fatalf("first SeedAppearance: %v", err) + } + if first.Skipped || first.Documents == 0 || first.Blobs == 0 { + t.Fatalf("first stats = %+v, want import", first) + } + second, err := svc.SeedAppearance(ctx) + if err != nil { + t.Fatalf("second SeedAppearance: %v", err) + } + if !second.Skipped || second.Documents != 0 || second.Blobs != 0 { + t.Fatalf("second stats = %+v, want unchanged catalog skip", second) + } + + var firstDocID int64 + for _, w := range appearance.Default().Wallpapers { + if w.Document.ID != 0 && w.Document.Path != "" { + firstDocID = w.Document.ID + break + } + } + if firstDocID == 0 { + t.Fatal("no wallpaper document found") + } + delete(media.blobs, fmt.Sprintf("doc:%d", firstDocID)) + repaired, err := svc.SeedAppearance(ctx) + if err != nil { + t.Fatalf("repair SeedAppearance: %v", err) + } + if repaired.Skipped || repaired.Documents == 0 || repaired.Blobs == 0 { + t.Fatalf("repair stats = %+v, want missing blob to force reimport", repaired) + } +} diff --git a/internal/app/files/blobcache.go b/internal/app/files/blobcache.go index 5d542282..14483c2b 100644 --- a/internal/app/files/blobcache.go +++ b/internal/app/files/blobcache.go @@ -2,11 +2,74 @@ package files import ( "container/list" + "strconv" "sync" + "time" "telesrv/internal/domain" ) +// stickerSetNegativeCache 是「按 ref 查不到的贴纸集」的短 TTL 负缓存。未 seed 的 short_name +// 集合会被客户端反复 getStickerSet(每次都打一发 PG GetStickerSetByShortName),这里缓存 +// not-found 结果,TTL 内直接短路、不再查库。TTL 短(自愈):运营/运行时新增的集合最多 TTL 后 +// 才被解析,避免负缓存长期遮住真实存在的集合。 +type stickerSetNegativeCache struct { + mu sync.Mutex + ttl time.Duration + entries map[string]time.Time +} + +const stickerSetNegativeCacheMaxEntries = 100000 + +func newStickerSetNegativeCache(ttl time.Duration) *stickerSetNegativeCache { + return &stickerSetNegativeCache{ttl: ttl, entries: map[string]time.Time{}} +} + +func stickerSetRefKey(ref domain.StickerSetRef) string { + switch ref.Kind { + case domain.StickerSetRefByID: + return "id:" + strconv.FormatInt(ref.ID, 10) + case domain.StickerSetRefByShortName: + return "short:" + ref.ShortName + case domain.StickerSetRefBySystem: + return "sys:" + ref.SystemKey + default: + return "" + } +} + +func (c *stickerSetNegativeCache) has(ref domain.StickerSetRef) bool { + key := stickerSetRefKey(ref) + if key == "" { + return false + } + c.mu.Lock() + defer c.mu.Unlock() + exp, ok := c.entries[key] + if !ok { + return false + } + if time.Now().After(exp) { + delete(c.entries, key) + return false + } + return true +} + +func (c *stickerSetNegativeCache) put(ref domain.StickerSetRef) { + key := stickerSetRefKey(ref) + if key == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + // 简单上限防无界增长:超限整表清空(短 TTL 下冷启代价可忽略)。 + if len(c.entries) >= stickerSetNegativeCacheMaxEntries { + c.entries = make(map[string]time.Time, 1024) + } + c.entries[key] = time.Now().Add(c.ttl) +} + // blobMetaCache 是 location_key → FileBlob 元数据的进程内 LRU,用于消除 upload.getFile // 每个 chunk 一次 GetFileBlob 的 PG 往返(一个文件按 ≤512KB/1MB 分多次 getFile,热门贴纸/ // reaction/头像更被大量用户重复拉)。 diff --git a/internal/app/files/blobfs.go b/internal/app/files/blobfs.go index dbb17f04..e64983e8 100644 --- a/internal/app/files/blobfs.go +++ b/internal/app/files/blobfs.go @@ -1,13 +1,20 @@ package files import ( + "bytes" "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "io" "os" "path/filepath" + "strings" + "sync" + "time" + + "telesrv/internal/domain" ) // BlobBackend 是 blob 字节内容的存储后端。第一阶段只有本地磁盘实现。 @@ -15,15 +22,42 @@ import ( type BlobBackend interface { Name() string Put(ctx context.Context, data []byte) (objectKey string, err error) + PutReader(ctx context.Context, r io.Reader) (objectKey string, size int64, sha256 []byte, err error) Get(ctx context.Context, objectKey string) ([]byte, error) // GetRange 只读 [offset, offset+limit) 段并返回该段字节与文件总大小(limit<=0 读到末尾), // 避免大文件每个 chunk 都整文件读入内存(getFile 按 chunk 多次请求 ⇒ 否则 O(N²) 放大)。 GetRange(ctx context.Context, objectKey string, offset, limit int64) (data []byte, total int64, err error) } +// UploadPartBackend 保存 upload.saveFilePart/saveBigFilePart 的临时分片字节。 +// 与正式 blob 不同,上传分片 key 唯一且可删除,成功组装/覆盖重传/GC 后必须清理。 +type UploadPartBackend interface { + PutUploadPart(ctx context.Context, ownerUserID, fileID int64, part int, data []byte) (uploadPartObject, error) + GetUploadPart(ctx context.Context, objectKey string) ([]byte, error) + OpenUploadPart(ctx context.Context, objectKey string) (io.ReadCloser, error) + DeleteUploadPart(ctx context.Context, objectKey string) error + DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) (int64, error) +} + +type uploadPartObject struct { + Backend domain.MediaBackend + ObjectKey string + Size int64 + SHA256 []byte +} + // LocalFS 把 blob 字节存到本地磁盘根目录下,路径按内容 hash 两级 fanout。 type LocalFS struct { root string + + mu sync.Mutex + openBlobFiles map[string]*sharedBlobFile +} + +type sharedBlobFile struct { + key string + file *os.File + refs int } // NewLocalFS 创建本地磁盘 blob backend,确保根目录存在。 @@ -34,7 +68,7 @@ func NewLocalFS(root string) (*LocalFS, error) { if err := os.MkdirAll(root, 0o755); err != nil { return nil, fmt.Errorf("create blob root %q: %w", root, err) } - return &LocalFS{root: root}, nil + return &LocalFS{root: root, openBlobFiles: make(map[string]*sharedBlobFile)}, nil } // Name 返回后端标识,与 file_blobs.backend 一致。 @@ -48,24 +82,90 @@ func (l *LocalFS) pathFor(objectKey string) string { } // Put 写入内容并返回 sha256 hex 作为 objectKey;同内容已存在则跳过写入(去重)。 -func (l *LocalFS) Put(_ context.Context, data []byte) (string, error) { - sum := sha256.Sum256(data) - key := hex.EncodeToString(sum[:]) +func (l *LocalFS) Put(ctx context.Context, data []byte) (string, error) { + key, _, _, err := l.PutReader(ctx, bytes.NewReader(data)) + return key, err +} + +// PutReader 流式写入内容,边复制边计算 sha256,避免上层为大视频先拼出完整 []byte。 +func (l *LocalFS) PutReader(ctx context.Context, r io.Reader) (string, int64, []byte, error) { + tmpDir := filepath.Join(l.root, "_tmp") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return "", 0, nil, fmt.Errorf("create blob tmp dir: %w", err) + } + tmp, err := os.CreateTemp(tmpDir, "blob-*.tmp") + if err != nil { + return "", 0, nil, fmt.Errorf("create blob tmp file: %w", err) + } + tmpPath := tmp.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(tmpPath) + } + }() + + h := sha256.New() + size, err := copyWithContext(ctx, io.MultiWriter(tmp, h), r) + closeErr := tmp.Close() + if err != nil { + return "", 0, nil, fmt.Errorf("write blob stream: %w", err) + } + if closeErr != nil { + return "", 0, nil, fmt.Errorf("close blob stream: %w", closeErr) + } + sum := h.Sum(nil) + key := hex.EncodeToString(sum) path := l.pathFor(key) if _, err := os.Stat(path); err == nil { - return key, nil + committed = true + _ = os.Remove(tmpPath) + return key, size, append([]byte(nil), sum...), nil + } else if err != nil && !os.IsNotExist(err) { + return "", 0, nil, fmt.Errorf("stat blob: %w", err) } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return "", fmt.Errorf("create blob dir: %w", err) + return "", 0, nil, fmt.Errorf("create blob dir: %w", err) } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return "", fmt.Errorf("write blob: %w", err) + if err := os.Rename(tmpPath, path); err != nil { + if _, statErr := os.Stat(path); statErr == nil { + committed = true + _ = os.Remove(tmpPath) + return key, size, append([]byte(nil), sum...), nil + } + return "", 0, nil, fmt.Errorf("commit blob: %w", err) } - if err := os.Rename(tmp, path); err != nil { - return "", fmt.Errorf("commit blob: %w", err) + committed = true + return key, size, append([]byte(nil), sum...), nil +} + +func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) { + buf := make([]byte, 256<<10) + var written int64 + for { + select { + case <-ctx.Done(): + return written, ctx.Err() + default: + } + n, readErr := src.Read(buf) + if n > 0 { + w, writeErr := dst.Write(buf[:n]) + written += int64(w) + if writeErr != nil { + return written, writeErr + } + if w != n { + return written, io.ErrShortWrite + } + } + if readErr == io.EOF { + return written, nil + } + if readErr != nil { + return written, readErr + } } - return key, nil } // Get 读取 objectKey 对应的全部字节。 @@ -76,11 +176,12 @@ func (l *LocalFS) Get(_ context.Context, objectKey string) ([]byte, error) { // GetRange 用 ReadAt 只读 [offset, offset+limit) 段,total 取自文件大小; // n 受 total 约束,故即便客户端传超大 limit 也只分配文件实际大小,不会按客户端巨值分配。 func (l *LocalFS) GetRange(_ context.Context, objectKey string, offset, limit int64) ([]byte, int64, error) { - f, err := os.Open(l.pathFor(objectKey)) + blobFile, err := l.openBlobFile(objectKey) if err != nil { return nil, 0, err } - defer f.Close() + defer l.releaseBlobFile(blobFile) + f := blobFile.file info, err := f.Stat() if err != nil { return nil, 0, err @@ -103,3 +204,157 @@ func (l *LocalFS) GetRange(_ context.Context, objectKey string, offset, limit in } return buf[:read], total, nil } + +func (l *LocalFS) openBlobFile(objectKey string) (*sharedBlobFile, error) { + l.mu.Lock() + defer l.mu.Unlock() + if f, ok := l.openBlobFiles[objectKey]; ok { + f.refs++ + return f, nil + } + f, err := os.Open(l.pathFor(objectKey)) + if err != nil { + return nil, err + } + blobFile := &sharedBlobFile{ + key: objectKey, + file: f, + refs: 1, + } + l.openBlobFiles[objectKey] = blobFile + return blobFile, nil +} + +func (l *LocalFS) releaseBlobFile(blobFile *sharedBlobFile) { + if blobFile == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + if blobFile.refs > 0 { + blobFile.refs-- + } + if blobFile.refs != 0 { + return + } + if current := l.openBlobFiles[blobFile.key]; current == blobFile { + delete(l.openBlobFiles, blobFile.key) + } + _ = blobFile.file.Close() +} + +func (l *LocalFS) PutUploadPart(_ context.Context, ownerUserID, fileID int64, part int, data []byte) (uploadPartObject, error) { + sum := sha256.Sum256(data) + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return uploadPartObject{}, fmt.Errorf("generate upload part key: %w", err) + } + key := filepath.ToSlash(filepath.Join( + "upload_parts", + fmt.Sprintf("%d", ownerUserID), + fmt.Sprintf("%d", fileID), + fmt.Sprintf("%06d-%s.part", part, hex.EncodeToString(nonce[:])), + )) + path, err := l.uploadPartPath(key) + if err != nil { + return uploadPartObject{}, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return uploadPartObject{}, fmt.Errorf("create upload part dir: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return uploadPartObject{}, fmt.Errorf("write upload part: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return uploadPartObject{}, fmt.Errorf("commit upload part: %w", err) + } + return uploadPartObject{ + Backend: domain.MediaBackend(l.Name()), + ObjectKey: key, + Size: int64(len(data)), + SHA256: append([]byte(nil), sum[:]...), + }, nil +} + +func (l *LocalFS) GetUploadPart(_ context.Context, objectKey string) ([]byte, error) { + path, err := l.uploadPartPath(objectKey) + if err != nil { + return nil, err + } + return os.ReadFile(path) +} + +func (l *LocalFS) OpenUploadPart(_ context.Context, objectKey string) (io.ReadCloser, error) { + path, err := l.uploadPartPath(objectKey) + if err != nil { + return nil, err + } + return os.Open(path) +} + +func (l *LocalFS) DeleteUploadPart(_ context.Context, objectKey string) error { + path, err := l.uploadPartPath(objectKey) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete upload part: %w", err) + } + return nil +} + +func (l *LocalFS) DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) (int64, error) { + if limit <= 0 { + return 0, nil + } + root := filepath.Join(l.root, "upload_parts") + if _, err := os.Stat(root); os.IsNotExist(err) { + return 0, nil + } else if err != nil { + return 0, fmt.Errorf("stat upload parts root: %w", err) + } + var deleted int64 + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if deleted >= int64(limit) { + return filepath.SkipAll + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if !info.ModTime().Before(before) { + return nil + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + deleted++ + return nil + }) + if err != nil { + return deleted, fmt.Errorf("delete expired upload part objects: %w", err) + } + return deleted, nil +} + +func (l *LocalFS) uploadPartPath(objectKey string) (string, error) { + clean := filepath.Clean(filepath.FromSlash(objectKey)) + prefix := "upload_parts" + string(os.PathSeparator) + if clean == "." || clean == ".." || filepath.IsAbs(clean) || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || !strings.HasPrefix(clean, prefix) { + return "", fmt.Errorf("invalid upload part object key %q", objectKey) + } + return filepath.Join(l.root, clean), nil +} diff --git a/internal/app/files/blobfs_test.go b/internal/app/files/blobfs_test.go index b5a3ce69..b5bc66c3 100644 --- a/internal/app/files/blobfs_test.go +++ b/internal/app/files/blobfs_test.go @@ -3,7 +3,11 @@ package files import ( "bytes" "context" + "os" + "strings" + "sync" "testing" + "time" ) func TestLocalFSPutGetRoundTrip(t *testing.T) { @@ -40,6 +44,68 @@ func TestLocalFSPutGetRoundTrip(t *testing.T) { } } +func TestLocalFSPutReaderRoundTrip(t *testing.T) { + fs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + ctx := context.Background() + data := strings.Repeat("streamed-", 1024) + + key, size, sum, err := fs.PutReader(ctx, strings.NewReader(data)) + if err != nil { + t.Fatalf("put reader: %v", err) + } + if key == "" || size != int64(len(data)) || len(sum) != 32 { + t.Fatalf("stream metadata key=%q size=%d sha=%d", key, size, len(sum)) + } + got, err := fs.Get(ctx, key) + if err != nil { + t.Fatalf("get: %v", err) + } + if string(got) != data { + t.Fatalf("roundtrip mismatch") + } +} + +func TestLocalFSDeleteExpiredUploadParts(t *testing.T) { + fs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + ctx := context.Background() + oldPart, err := fs.PutUploadPart(ctx, 10, 100, 0, []byte("old")) + if err != nil { + t.Fatalf("put old upload part: %v", err) + } + freshPart, err := fs.PutUploadPart(ctx, 10, 100, 1, []byte("fresh")) + if err != nil { + t.Fatalf("put fresh upload part: %v", err) + } + oldPath, err := fs.uploadPartPath(oldPart.ObjectKey) + if err != nil { + t.Fatalf("old upload part path: %v", err) + } + oldTime := time.Now().Add(-48 * time.Hour) + if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil { + t.Fatalf("age old upload part: %v", err) + } + + deleted, err := fs.DeleteExpiredUploadParts(ctx, time.Now().Add(-24*time.Hour), 10) + if err != nil { + t.Fatalf("delete expired upload parts: %v", err) + } + if deleted != 1 { + t.Fatalf("deleted = %d, want 1", deleted) + } + if _, err := fs.GetUploadPart(ctx, oldPart.ObjectKey); err == nil { + t.Fatalf("old upload part still exists") + } + if data, err := fs.GetUploadPart(ctx, freshPart.ObjectKey); err != nil || string(data) != "fresh" { + t.Fatalf("fresh upload part = %q err=%v", data, err) + } +} + func TestLocalFSDistinctContent(t *testing.T) { fs, err := NewLocalFS(t.TempDir()) if err != nil { @@ -90,3 +156,143 @@ func TestLocalFSGetRange(t *testing.T) { }) } } + +func TestLocalFSReusesOpenBlobFileWhileActive(t *testing.T) { + fs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + ctx := context.Background() + key, err := fs.Put(ctx, []byte("0123456789")) + if err != nil { + t.Fatalf("put: %v", err) + } + + first, err := fs.openBlobFile(key) + if err != nil { + t.Fatalf("open first: %v", err) + } + + second, err := fs.openBlobFile(key) + if err != nil { + t.Fatalf("open second: %v", err) + } + firstReleased, secondReleased := false, false + t.Cleanup(func() { + if !firstReleased { + fs.releaseBlobFile(first) + } + if !secondReleased { + fs.releaseBlobFile(second) + } + }) + if first != second { + t.Fatal("same active blob should reuse one open file handle") + } + fs.mu.Lock() + open, refs := len(fs.openBlobFiles), first.refs + fs.mu.Unlock() + if open != 1 || refs != 2 { + t.Fatalf("active files=%d refs=%d, want 1/2", open, refs) + } + + fs.releaseBlobFile(first) + firstReleased = true + fs.mu.Lock() + open, refs = len(fs.openBlobFiles), second.refs + fs.mu.Unlock() + if open != 1 || refs != 1 { + t.Fatalf("after first release active files=%d refs=%d, want 1/1", open, refs) + } + + var buf [4]byte + if n, err := second.file.ReadAt(buf[:], 3); err != nil || n != len(buf) || string(buf[:]) != "3456" { + t.Fatalf("shared file ReadAt n=%d err=%v bytes=%q, want 3456", n, err, buf[:]) + } + + fs.releaseBlobFile(second) + secondReleased = true + fs.mu.Lock() + open = len(fs.openBlobFiles) + fs.mu.Unlock() + if open != 0 { + t.Fatalf("after final release active files=%d, want 0", open) + } +} + +func TestLocalFSReusesOpenBlobFileUnderConcurrentOpen(t *testing.T) { + fs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("new local fs: %v", err) + } + ctx := context.Background() + key, err := fs.Put(ctx, []byte(strings.Repeat("x", 1024))) + if err != nil { + t.Fatalf("put: %v", err) + } + + const readers = 32 + start := make(chan struct{}) + files := make([]*sharedBlobFile, readers) + errs := make([]error, readers) + var wg sync.WaitGroup + for i := range files { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + files[i], errs[i] = fs.openBlobFile(key) + }(i) + } + close(start) + wg.Wait() + + released := false + t.Cleanup(func() { + if released { + return + } + for _, f := range files { + if f != nil { + fs.releaseBlobFile(f) + } + } + }) + for i, err := range errs { + if err != nil { + t.Fatalf("open %d: %v", i, err) + } + } + first := files[0] + if first == nil { + t.Fatal("first open returned nil") + } + for i, f := range files { + if f != first { + t.Fatalf("file %d = %p, want shared %p", i, f, first) + } + } + fs.mu.Lock() + open, refs := len(fs.openBlobFiles), first.refs + fs.mu.Unlock() + if open != 1 || refs != readers { + t.Fatalf("active files=%d refs=%d, want 1/%d", open, refs, readers) + } + + wg = sync.WaitGroup{} + for _, f := range files { + wg.Add(1) + go func(f *sharedBlobFile) { + defer wg.Done() + fs.releaseBlobFile(f) + }(f) + } + wg.Wait() + released = true + fs.mu.Lock() + open = len(fs.openBlobFiles) + fs.mu.Unlock() + if open != 0 { + t.Fatalf("after concurrent release active files=%d, want 0", open) + } +} diff --git a/internal/app/files/default_statuses.go b/internal/app/files/default_statuses.go new file mode 100644 index 00000000..b065ffaf --- /dev/null +++ b/internal/app/files/default_statuses.go @@ -0,0 +1,148 @@ +package files + +import ( + "context" + "fmt" + "strings" + + "telesrv/internal/domain" +) + +// 合成集的固定 ID/AccessHash:种子导出的真实集 ID 在 1.2e15 量级,取明显隔离的 +// 常量段避免撞键;幂等性由 system_key 查询保证,常量仅需稳定。 +const ( + defaultEmojiStatusSetID int64 = 7_777_000_000_000_001 + defaultEmojiStatusSetAccessHash int64 = 7_777_000_000_000_002 +) + +// defaultEmojiStatusEmoticons 是默认状态的精选 emoji(对齐官方默认状态选盘的 +// 常见项),按展示顺序排列。匹配不到的 emoticon 静默跳过(取决于 seed 内容)。 +var defaultEmojiStatusEmoticons = []string{ + "\U0001f4bc", // 💼 工作 + "\U0001f393", // 🎓 学习 + "\U0001f3e0", // 🏠 在家 + "\U0001f334", // 🌴 度假 + "\U0001f3d6", // 🏖 海滩 + "✈", // ✈️ 旅行 + "\U0001f912", // 🤒 生病 + "\U0001f634", // 😴 睡觉 + "☕", // ☕ 咖啡 + "\U0001f4bb", // 💻 编码/办公 + "\U0001f4da", // 📚 阅读 + "\U0001f3ae", // 🎮 游戏 + "\U0001f3a7", // 🎧 听歌 + "⚽", // ⚽ 运动 + "\U0001f3c6", // 🏆 获胜 + "❤", // ❤️ 爱心 + "\U0001f60e", // 😎 酷 + "\U0001f319", // 🌙 勿扰 + "⭐", // ⭐ 星标 + "\U0001f525", // 🔥 火 + "\U0001f44d", // 👍 赞 + "\U0001f389", // 🎉 庆祝 + "\U0001f914", // 🤔 思考 + "\U0001f607", // 😇 天使 + "\U0001f973", // 🥳 派对 + "\U0001f602", // 😂 大笑 + "\U0001f970", // 🥰 喜爱 + "\U0001f62d", // 😭 大哭 + "\U0001f92f", // 🤯 爆炸 + "\U0001f440", // 👀 围观 + "\U0001f4af", // 💯 满分 + "\U0001f64f", // 🙏 感谢 + "\U0001f91d", // 🤝 合作 + "✍", // ✍️ 写作 + "\U0001f697", // 🚗 通勤 + "\U0001f355", // 🍕 干饭 + "\U0001f382", // 🎂 生日 + "\U0001f338", // 🌸 春天 + "⛄", // ⛄ 冬天 + "\U0001f984", // 🦄 独角兽 +} + +// EnsureDefaultEmojiStatusSet 幂等地合成默认 emoji status 系统集:从已 seed 的 +// animated_emoji 系统集按 emoticon 精选文档(复用文档行与 blob,不复制字节)。 +// 返回 (集内文档数, 是否本次新建)。animated_emoji 未 seed 时静默跳过。 +func (s *Service) EnsureDefaultEmojiStatusSet(ctx context.Context) (int, bool, error) { + if existing, found, err := s.media.GetStickerSetBySystemKey(ctx, domain.StickerSetSystemKeyEmojiDefaultStatuses); err != nil { + return 0, false, fmt.Errorf("lookup default emoji status set: %w", err) + } else if found { + return len(existing.DocumentIDs), false, nil + } + source, found, err := s.media.GetStickerSetBySystemKey(ctx, "animated_emoji") + if err != nil { + return 0, false, fmt.Errorf("lookup animated_emoji set: %w", err) + } + if !found || len(source.Packs) == 0 { + return 0, false, nil + } + byEmoticon := make(map[string][]int64, len(source.Packs)) + for _, pack := range source.Packs { + key := normalizeStatusEmoticon(pack.Emoticon) + if key == "" || len(pack.DocumentIDs) == 0 { + continue + } + byEmoticon[key] = append(byEmoticon[key], pack.DocumentIDs...) + } + set := domain.StickerSet{ + ID: defaultEmojiStatusSetID, + AccessHash: defaultEmojiStatusSetAccessHash, + ShortName: "TelesrvDefaultStatuses", + Title: "Default Emoji Statuses", + Kind: domain.StickerSetKindSystem, + SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses, + Official: true, + Animated: source.Animated, + Emojis: true, + } + seen := make(map[int64]struct{}) + for _, emoticon := range defaultEmojiStatusEmoticons { + ids := byEmoticon[normalizeStatusEmoticon(emoticon)] + if len(ids) == 0 { + continue + } + pack := domain.StickerPack{Emoticon: emoticon} + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + set.DocumentIDs = append(set.DocumentIDs, id) + pack.DocumentIDs = append(pack.DocumentIDs, id) + } + if len(pack.DocumentIDs) > 0 { + set.Packs = append(set.Packs, pack) + } + } + if len(set.DocumentIDs) == 0 { + return 0, false, nil + } + set.Count = len(set.DocumentIDs) + set.Hash = stickerSetDocsHash(set.DocumentIDs) + docs, err := s.media.GetDocuments(ctx, set.DocumentIDs) + if err != nil { + return 0, false, fmt.Errorf("load default emoji status documents: %w", err) + } + if err := s.media.PutStickerSet(ctx, set); err != nil { + return 0, false, fmt.Errorf("persist default emoji status set: %w", err) + } + s.stickerSetCache.put(set, orderDocuments(docs, set.DocumentIDs)) + return set.Count, true, nil +} + +// normalizeStatusEmoticon 统一变体选择符差异("❤" vs "❤️"),匹配 seed packs 与 +// 精选清单两侧的书写形态。 +func normalizeStatusEmoticon(e string) string { + return strings.ReplaceAll(strings.TrimSpace(e), "️", "") +} + +// stickerSetDocsHash 由文档 ID 列表算稳定 set hash(messages.getStickerSet 与 +// account.getDefaultEmojiStatuses 共用一份缓存判定)。 +func stickerSetDocsHash(ids []int64) int { + var h uint64 + for _, id := range ids { + h ^= uint64(id) + h = h*0x4f25 + uint64(id) + } + return int(h & 0x7fffffff) +} diff --git a/internal/app/files/default_statuses_test.go b/internal/app/files/default_statuses_test.go new file mode 100644 index 00000000..c42eea29 --- /dev/null +++ b/internal/app/files/default_statuses_test.go @@ -0,0 +1,117 @@ +package files + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func defaultStatusesTestService(t *testing.T) (*Service, *fakeMediaStore) { + t.Helper() + media := newFakeMediaStore() + return NewService(media, nil, 2), media +} + +func putAnimatedEmojiSet(t *testing.T, media *fakeMediaStore, packs []domain.StickerPack) { + t.Helper() + ctx := context.Background() + var ids []int64 + for _, p := range packs { + ids = append(ids, p.DocumentIDs...) + } + for _, id := range ids { + if err := media.PutDocument(ctx, domain.Document{ID: id, AccessHash: id, MimeType: "application/x-tgsticker"}); err != nil { + t.Fatalf("put document %d: %v", id, err) + } + } + if err := media.PutStickerSet(ctx, domain.StickerSet{ + ID: 1, + ShortName: "AnimatedEmojies", + Kind: domain.StickerSetKindSystem, + SystemKey: "animated_emoji", + Animated: true, + DocumentIDs: ids, + Packs: packs, + }); err != nil { + t.Fatalf("put animated_emoji set: %v", err) + } +} + +func TestEnsureDefaultEmojiStatusSet(t *testing.T) { + ctx := context.Background() + svc, media := defaultStatusesTestService(t) + putAnimatedEmojiSet(t, media, []domain.StickerPack{ + // seed 导出常见为裸码点(无 FE0F),精选清单两种形态都必须匹配。 + {Emoticon: "❤", DocumentIDs: []int64{101}}, + {Emoticon: "👍", DocumentIDs: []int64{102}}, + {Emoticon: "☕️", DocumentIDs: []int64{103}}, // 带 FE0F 的反向形态 + {Emoticon: "🥔", DocumentIDs: []int64{999}}, // 不在精选清单,必须被排除 + }) + + count, created, err := svc.EnsureDefaultEmojiStatusSet(ctx) + if err != nil { + t.Fatalf("ensure: %v", err) + } + if !created || count != 3 { + t.Fatalf("ensure = (count=%d, created=%v), want (3, true)", count, created) + } + set, found, err := media.GetStickerSetBySystemKey(ctx, domain.StickerSetSystemKeyEmojiDefaultStatuses) + if err != nil || !found { + t.Fatalf("synthesized set not found: found=%v err=%v", found, err) + } + if set.Kind != domain.StickerSetKindSystem || !set.Emojis || set.Count != 3 || set.Hash == 0 { + t.Fatalf("set meta = %+v, want system kind, emojis, count 3, non-zero hash", set) + } + got := map[int64]bool{} + for _, id := range set.DocumentIDs { + got[id] = true + } + if !got[101] || !got[102] || !got[103] || got[999] { + t.Fatalf("document ids = %v, want 101/102/103 without 999", set.DocumentIDs) + } + // 精选顺序:☕(=103) 在 ❤(=101) 之前、❤ 在 👍(=102) 之前(按清单序而非 pack 序)。 + index := map[int64]int{} + for i, id := range set.DocumentIDs { + index[id] = i + } + if !(index[103] < index[101] && index[101] < index[102]) { + t.Fatalf("document order = %v, want curated order ☕<❤<👍", set.DocumentIDs) + } + + // 幂等:第二次调用不得重建。 + count2, created2, err := svc.EnsureDefaultEmojiStatusSet(ctx) + if err != nil { + t.Fatalf("ensure again: %v", err) + } + if created2 || count2 != 3 { + t.Fatalf("ensure again = (count=%d, created=%v), want (3, false)", count2, created2) + } + + // ResolveStickerSet(inputStickerSetEmojiDefaultStatuses 的服务路径)能解析。 + resolved, docs, found, err := svc.ResolveStickerSet(ctx, domain.StickerSetRef{ + Kind: domain.StickerSetRefBySystem, + SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses, + }) + if err != nil || !found { + t.Fatalf("resolve: found=%v err=%v", found, err) + } + if resolved.ID != set.ID || len(docs) != 3 { + t.Fatalf("resolve = set %d with %d docs, want %d with 3", resolved.ID, len(docs), set.ID) + } +} + +func TestEnsureDefaultEmojiStatusSetWithoutSeed(t *testing.T) { + ctx := context.Background() + svc, media := defaultStatusesTestService(t) + count, created, err := svc.EnsureDefaultEmojiStatusSet(ctx) + if err != nil { + t.Fatalf("ensure without seed: %v", err) + } + if created || count != 0 { + t.Fatalf("ensure without seed = (count=%d, created=%v), want (0, false)", count, created) + } + if _, found, _ := media.GetStickerSetBySystemKey(ctx, domain.StickerSetSystemKeyEmojiDefaultStatuses); found { + t.Fatal("set must not be created without animated_emoji seed") + } +} diff --git a/internal/app/files/effects_seed.go b/internal/app/files/effects_seed.go new file mode 100644 index 00000000..20c60e2b --- /dev/null +++ b/internal/app/files/effects_seed.go @@ -0,0 +1,167 @@ +package files + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "hash" + "hash/fnv" + "os" + "path/filepath" + + "telesrv/internal/domain" +) + +// telegram_effects_export/effects.json 的解析结构。effects 引用文档 id,文档全量元数据 +// 在 documents[] 里(与 messages.availableEffects 同构),blob 在 documents/.。 +type seedEffectJSON struct { + ID int64 `json:"id"` + Emoticon string `json:"emoticon"` + StaticIconID int64 `json:"static_icon_id"` + EffectStickerID int64 `json:"effect_sticker_id"` + EffectAnimationID int64 `json:"effect_animation_id"` + PremiumRequired bool `json:"premium_required"` +} + +type seedEffectsFileJSON struct { + Result struct { + Effects []seedEffectJSON `json:"effects"` + Documents []seedDocumentJSON `json:"documents"` + } `json:"result"` +} + +// seedEffects 从 telegram_effects_export 导入消息特效。特效元数据每次启动都从 JSON +// 重建进内存;document/blob 只有 catalog hash 变化或持久化资源不完整时才重导。 +func (s *Service) seedEffects(ctx context.Context, root string, stats *SeedStats) error { + dir := filepath.Join(root, "telegram_effects_export") + raw, err := os.ReadFile(filepath.Join(dir, "effects.json")) + if err != nil { + return nil // 无 effects 资源 → 跳过 + } + var parsed seedEffectsFileJSON + if err := json.Unmarshal(raw, &parsed); err != nil { + return fmt.Errorf("parse effects.json: %w", err) + } + docsDir := filepath.Join(dir, "documents") + index, err := scanSeedDir(docsDir) + if err != nil { + return err + } + effects, requiredDocs := seedEffectsCatalog(parsed) + s.effects = effects + s.effectsHash = effectsCatalogHash(effects) + stats.Effects = len(effects) + + stateHash, err := s.seedEffectsStateHash(raw, docsDir) + if err != nil { + return err + } + ready, err := s.seedDocumentJSONsReady(ctx, requiredDocs, index) + if err != nil { + return err + } + matched, err := s.seedStateMatches(ctx, seedEffectsStateKey, stateHash) + if err != nil { + return err + } + if matched && ready { + return nil + } + + // 多个 effect 常共享同一文档(static icon 尤甚):每个唯一源文档只导一次。 + for _, dj := range requiredDocs { + if _, err := s.importDocument(ctx, dj, docsDir, index, stats); err != nil { + return err + } + } + return s.putSeedState(ctx, seedEffectsStateKey, stateHash) +} + +func seedEffectsCatalog(parsed seedEffectsFileJSON) ([]domain.AvailableEffect, []seedDocumentJSON) { + docByID := make(map[int64]seedDocumentJSON, len(parsed.Result.Documents)) + for _, d := range parsed.Result.Documents { + docByID[d.ID] = d + } + required := make(map[int64]struct{}, len(docByID)) + storageID := func(sourceID int64) int64 { + if sourceID == 0 { + return 0 + } + if _, ok := docByID[sourceID]; !ok { + return 0 + } + required[sourceID] = struct{}{} + return seedDocumentStorageID(sourceID) + } + effects := make([]domain.AvailableEffect, 0, len(parsed.Result.Effects)) + for i, ej := range parsed.Result.Effects { + if ej.ID == 0 || ej.EffectStickerID == 0 { + continue + } + staticID := storageID(ej.StaticIconID) + stickerID := storageID(ej.EffectStickerID) + if stickerID == 0 { + continue + } + animID := storageID(ej.EffectAnimationID) + effects = append(effects, domain.AvailableEffect{ + ID: ej.ID, + Emoticon: ej.Emoticon, + StaticIconID: staticID, + EffectStickerID: stickerID, + EffectAnimationID: animID, + PremiumRequired: ej.PremiumRequired, + Order: i, + }) + } + docs := make([]seedDocumentJSON, 0, len(required)) + for _, d := range parsed.Result.Documents { + if _, ok := required[d.ID]; ok { + docs = append(docs, d) + } + } + return effects, docs +} + +func (s *Service) seedEffectsStateHash(raw []byte, docsDir string) (string, error) { + return seedStateHash(func(h hash.Hash) error { + writeSeedStateHeader(h, seedEffectsStateVersion, s.dc) + _, _ = h.Write(raw) + _, _ = h.Write([]byte{'\n'}) + return writeSeedDirFingerprint(h, docsDir) + }) +} + +// AvailableEffects 返回 seed 进内存的消息特效目录与其内容 hash(全局静态;hash 在 seed 时 +// 算一次,handler 直接比对返回 NotModified,无需每次 RPC 重算)。 +func (s *Service) AvailableEffects(ctx context.Context) ([]domain.AvailableEffect, int, error) { + return s.effects, s.effectsHash, nil +} + +// effectsCatalogHash 由 effect 字段算稳定正整数 hash(FNV-1a)。内容变则 hash 变, +// 客户端发旧 hash 即不命中→重取。 +func effectsCatalogHash(effects []domain.AvailableEffect) int { + if len(effects) == 0 { + return 0 + } + h := fnv.New64a() + var buf [8]byte + put := func(v int64) { + binary.LittleEndian.PutUint64(buf[:], uint64(v)) + _, _ = h.Write(buf[:]) + } + for _, e := range effects { + put(e.ID) + _, _ = h.Write([]byte(e.Emoticon)) + put(e.StaticIconID) + put(e.EffectStickerID) + put(e.EffectAnimationID) + if e.PremiumRequired { + put(1) + } else { + put(0) + } + } + return int(h.Sum64() & 0x7fffffff) +} diff --git a/internal/app/files/encrypted_file.go b/internal/app/files/encrypted_file.go new file mode 100644 index 00000000..576b9ac2 --- /dev/null +++ b/internal/app/files/encrypted_file.go @@ -0,0 +1,50 @@ +package files + +import ( + "context" + "fmt" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// CreateEncryptedFileFromUpload 把已上传分片组装成密聊文件 blob 并铸造 EncryptedFile 快照。 +// 盲中继:内容是客户端加密的 bytes,不解析、不分类、不缩略图;blob 落 location_key +// "enc:"(复用 BlobBackend,下载经 inputEncryptedFileLocation → 同 key)。 +// access_hash 不强校验(沿用现有媒体 dev 姿态,依赖不可枚举 id)。元数据持久化由调用方 +// (rpc 层经 SecretChats.PutEncryptedFile)负责。 +func (s *Service) CreateEncryptedFileFromUpload(ctx context.Context, file domain.UploadedFileRef, keyFingerprint int) (domain.EncryptedFileRef, error) { + body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts) + if err != nil { + return domain.EncryptedFileRef{}, err + } + if body.Size == 0 { + return domain.EncryptedFileRef{}, domain.ErrDocumentInvalid + } + id := randomID() + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("enc:%d", id), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: body.ObjectKey, + Size: body.Size, + SHA256: body.SHA256, + MimeType: "application/octet-stream", + }); err != nil { + return domain.EncryptedFileRef{}, err + } + if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil { + s.log.Warn("cleanup encrypted file upload parts failed", + zap.Int64("owner_user_id", file.OwnerUserID), + zap.Int64("file_id", file.FileID), + zap.Int64("encrypted_file_id", id), + zap.Error(err)) + } + return domain.EncryptedFileRef{ + ID: id, + AccessHash: randomID(), + Size: body.Size, + DCID: s.dc, + KeyFingerprint: keyFingerprint, + }, nil +} diff --git a/internal/app/files/external_media.go b/internal/app/files/external_media.go new file mode 100644 index 00000000..6185a0ca --- /dev/null +++ b/internal/app/files/external_media.go @@ -0,0 +1,223 @@ +package files + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path" + "strings" + "sync" + "syscall" + "time" + + "telesrv/internal/domain" +) + +// 外链媒体:inputMediaPhotoExternal / inputMediaDocumentExternal——客户端给一个 URL, +// 服务端抓取并铸造 Photo/Document。抓取任意用户可控 URL,安全是核心: +// - SSRF 防护:自定义 Dialer.Control 在连接前检查**解析出的目标 IP**,挡掉 loopback/ +// 私网/link-local/CGNAT/multicast/unspecified。因为每次实际 dial 都查,所以同时防住 +// DNS rebinding(公网域名解析到内网 IP)与重定向(每一跳都重新 dial→重新检查)。 +// - 仅 http/https;重定向上限;响应大小上限(LimitReader);请求超时;全局抓取限速 +// (防一条消息触发大量服务端外网抓取的放大攻击)。 + +var ( + // ErrExternalMediaDisabled 表示未启用外链媒体抓取(rpc 层映射为 MEDIA_INVALID)。 + ErrExternalMediaDisabled = errors.New("external media disabled") + // ErrExternalMediaInvalid 表示 URL 不合法/被 SSRF 防护拦截/上游失败/超限。 + ErrExternalMediaInvalid = errors.New("external media invalid") +) + +const ( + externalMediaTimeout = 15 * time.Second + externalMediaMaxRedirects = 5 + // DefaultExternalMediaMaxBytes 是抓取响应体上限。 + DefaultExternalMediaMaxBytes = int64(10 << 20) + // DefaultExternalMediaRatePerMin 是全局每分钟抓取上限(防放大攻击)。 + DefaultExternalMediaRatePerMin = 60 + externalMediaRateWindow = time.Minute +) + +type externalMediaFetcher struct { + client *http.Client + maxBytes int64 + rateLimit int + + mu sync.Mutex + fetchTimes []time.Time +} + +// WithExternalMedia 启用外链媒体抓取(inputMediaPhoto/DocumentExternal)。 +// maxBytes<=0 用默认;ratePerMin<=0 用默认。SSRF 防护恒开。 +func WithExternalMedia(maxBytes int64, ratePerMin int) Option { + return func(s *Service) { + if maxBytes <= 0 { + maxBytes = DefaultExternalMediaMaxBytes + } + if ratePerMin <= 0 { + ratePerMin = DefaultExternalMediaRatePerMin + } + s.externalMedia = newExternalMediaFetcher(maxBytes, ratePerMin, false) + } +} + +// newExternalMediaFetcher 构造抓取器。allowPrivate 仅供测试(指向 httptest loopback); +// 生产恒 false。 +func newExternalMediaFetcher(maxBytes int64, ratePerMin int, allowPrivate bool) *externalMediaFetcher { + dialer := &net.Dialer{Timeout: externalMediaTimeout} + dialer.Control = func(network, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return ErrExternalMediaInvalid + } + ip := net.ParseIP(host) + if ip == nil { + return ErrExternalMediaInvalid + } + if !allowPrivate && isBlockedExternalIP(ip) { + return fmt.Errorf("%w: blocked address %s (SSRF guard)", ErrExternalMediaInvalid, host) + } + return nil + } + client := &http.Client{ + Timeout: externalMediaTimeout, + Transport: &http.Transport{DialContext: dialer.DialContext, DisableKeepAlives: true}, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= externalMediaMaxRedirects { + return fmt.Errorf("%w: too many redirects", ErrExternalMediaInvalid) + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return fmt.Errorf("%w: blocked redirect scheme %q", ErrExternalMediaInvalid, req.URL.Scheme) + } + return nil + }, + } + return &externalMediaFetcher{client: client, maxBytes: maxBytes, rateLimit: ratePerMin} +} + +// isBlockedExternalIP 报告是否为不可对外抓取的内网/特殊地址(SSRF 防护)。 +func isBlockedExternalIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsMulticast() || ip.IsInterfaceLocalMulticast() { + return true + } + // CGNAT 100.64.0.0/10(运营商级 NAT,常用于内部基础设施)。 + if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { + return true + } + return false +} + +func (f *externalMediaFetcher) allowFetch() bool { + f.mu.Lock() + defer f.mu.Unlock() + now := time.Now() + kept := f.fetchTimes[:0] + for _, at := range f.fetchTimes { + if now.Sub(at) <= externalMediaRateWindow { + kept = append(kept, at) + } + } + f.fetchTimes = kept + if len(f.fetchTimes) >= f.rateLimit { + return false + } + f.fetchTimes = append(f.fetchTimes, now) + return true +} + +// fetch 抓取 URL,返回 (字节, content-type)。SSRF 检查在 dial 阶段发生。 +func (f *externalMediaFetcher) fetch(ctx context.Context, rawURL string) ([]byte, string, error) { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, "", ErrExternalMediaInvalid + } + if !f.allowFetch() { + return nil, "", fmt.Errorf("%w: rate limited", ErrExternalMediaInvalid) + } + ctx, cancel := context.WithTimeout(ctx, externalMediaTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, "", ErrExternalMediaInvalid + } + req.Header.Set("User-Agent", "telesrv-media-fetch") + resp, err := f.client.Do(req) + if err != nil { + // 含 SSRF 拦截、超时、传输错误。 + return nil, "", fmt.Errorf("%w: %v", ErrExternalMediaInvalid, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("%w: upstream status %d", ErrExternalMediaInvalid, resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, f.maxBytes+1)) + if err != nil { + return nil, "", fmt.Errorf("%w: read body: %v", ErrExternalMediaInvalid, err) + } + if len(data) == 0 || int64(len(data)) > f.maxBytes { + return nil, "", fmt.Errorf("%w: body size %d", ErrExternalMediaInvalid, len(data)) + } + contentType := resp.Header.Get("Content-Type") + if i := strings.IndexByte(contentType, ';'); i >= 0 { + contentType = contentType[:i] + } + return data, strings.TrimSpace(contentType), nil +} + +// CreatePhotoFromURL 抓取 URL 并铸造 Photo(CreatePhotoFromBytes 会解码校验是否为图片)。 +func (s *Service) CreatePhotoFromURL(ctx context.Context, rawURL string) (domain.Photo, error) { + if s == nil || s.externalMedia == nil { + return domain.Photo{}, ErrExternalMediaDisabled + } + data, _, err := s.externalMedia.fetch(ctx, rawURL) + if err != nil { + return domain.Photo{}, err + } + photo, err := s.CreatePhotoFromBytes(ctx, data) + if err != nil { + // 非图片字节 → ErrPhotoInvalid,对外统一为 external invalid。 + return domain.Photo{}, fmt.Errorf("%w: %v", ErrExternalMediaInvalid, err) + } + return photo, nil +} + +// CreateDocumentFromURL 抓取 URL 并铸造 Document:mime 取 Content-Type,文件名取 URL basename。 +func (s *Service) CreateDocumentFromURL(ctx context.Context, rawURL string) (domain.Document, error) { + if s == nil || s.externalMedia == nil { + return domain.Document{}, ErrExternalMediaDisabled + } + data, contentType, err := s.externalMedia.fetch(ctx, rawURL) + if err != nil { + return domain.Document{}, err + } + mime := contentType + if mime == "" { + mime = "application/octet-stream" + } + spec := domain.DocumentSpec{ + MimeType: mime, + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: externalMediaFilename(rawURL)}}, + } + doc, err := s.CreateDocumentFromBytes(ctx, data, spec) + if err != nil { + return domain.Document{}, fmt.Errorf("%w: %v", ErrExternalMediaInvalid, err) + } + return doc, nil +} + +// externalMediaFilename 从 URL path 取 basename;缺失时回退通用名。 +func externalMediaFilename(rawURL string) string { + u, err := url.Parse(rawURL) + if err == nil { + if base := path.Base(u.Path); base != "" && base != "." && base != "/" { + return base + } + } + return "file" +} diff --git a/internal/app/files/external_media_test.go b/internal/app/files/external_media_test.go new file mode 100644 index 00000000..f6075486 --- /dev/null +++ b/internal/app/files/external_media_test.go @@ -0,0 +1,106 @@ +package files + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsBlockedExternalIP(t *testing.T) { + cases := []struct { + ip string + blocked bool + }{ + {"127.0.0.1", true}, // loopback + {"::1", true}, // loopback v6 + {"10.0.0.5", true}, // private + {"172.16.3.4", true}, // private + {"192.168.1.1", true}, // private + {"169.254.1.1", true}, // link-local + {"fe80::1", true}, // link-local v6 + {"0.0.0.0", true}, // unspecified + {"100.64.0.1", true}, // CGNAT + {"100.127.255.1", true}, // CGNAT 上界 + {"224.0.0.1", true}, // multicast + {"8.8.8.8", false}, // 公网 + {"1.1.1.1", false}, // 公网 + {"100.63.255.1", false}, // CGNAT 下界外(公网) + {"100.128.0.1", false}, // CGNAT 上界外(公网) + {"2606:4700:4700::1111", false}, // 公网 v6 + } + for _, c := range cases { + ip := net.ParseIP(c.ip) + if ip == nil { + t.Fatalf("parse %s failed", c.ip) + } + if got := isBlockedExternalIP(ip); got != c.blocked { + t.Errorf("isBlockedExternalIP(%s) = %v, want %v", c.ip, got, c.blocked) + } + } +} + +// TestExternalMediaFetcherSSRFGuard 验证 SSRF 防护:httptest 在 loopback 上, +// allowPrivate=false 必须拦截(不连接内网),allowPrivate=true 放行抓取到字节。 +func TestExternalMediaFetcherSSRFGuard(t *testing.T) { + body := []byte("hello-external-bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(body) + })) + defer srv.Close() + + // 生产配置(allowPrivate=false):loopback 目标被 SSRF 防护拦截。 + guarded := newExternalMediaFetcher(DefaultExternalMediaMaxBytes, DefaultExternalMediaRatePerMin, false) + if _, _, err := guarded.fetch(context.Background(), srv.URL); !errors.Is(err, ErrExternalMediaInvalid) { + t.Fatalf("SSRF guard fetch err = %v, want ErrExternalMediaInvalid (loopback 应被拦)", err) + } + + // 测试放行(allowPrivate=true):抓取成功。 + open := newExternalMediaFetcher(DefaultExternalMediaMaxBytes, DefaultExternalMediaRatePerMin, true) + data, ct, err := open.fetch(context.Background(), srv.URL) + if err != nil { + t.Fatalf("open fetch err = %v", err) + } + if string(data) != string(body) { + t.Fatalf("fetched %q, want %q", data, body) + } + if ct != "application/octet-stream" { + t.Fatalf("content-type = %q, want application/octet-stream", ct) + } +} + +// TestExternalMediaFetcherRejectsBadURL 非 http(s)/空 host 直接拒。 +func TestExternalMediaFetcherRejectsBadURL(t *testing.T) { + f := newExternalMediaFetcher(DefaultExternalMediaMaxBytes, DefaultExternalMediaRatePerMin, true) + for _, bad := range []string{"", "ftp://x/y", "file:///etc/passwd", "javascript:alert(1)", "http://", "not a url"} { + if _, _, err := f.fetch(context.Background(), bad); !errors.Is(err, ErrExternalMediaInvalid) { + t.Errorf("fetch(%q) err = %v, want ErrExternalMediaInvalid", bad, err) + } + } +} + +// TestExternalMediaFetcherSizeLimit 超大小上限拒。 +func TestExternalMediaFetcherSizeLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(make([]byte, 2048)) + })) + defer srv.Close() + f := newExternalMediaFetcher(1024, DefaultExternalMediaRatePerMin, true) + if _, _, err := f.fetch(context.Background(), srv.URL); !errors.Is(err, ErrExternalMediaInvalid) { + t.Fatalf("oversize fetch err = %v, want ErrExternalMediaInvalid", err) + } +} + +// TestExternalMediaDisabled 未启用时 Create*FromURL 返回 ErrExternalMediaDisabled。 +func TestExternalMediaDisabled(t *testing.T) { + s := &Service{} + if _, err := s.CreatePhotoFromURL(context.Background(), "http://x/y.png"); !errors.Is(err, ErrExternalMediaDisabled) { + t.Fatalf("disabled photo err = %v, want ErrExternalMediaDisabled", err) + } + if _, err := s.CreateDocumentFromURL(context.Background(), "http://x/y.bin"); !errors.Is(err, ErrExternalMediaDisabled) { + t.Fatalf("disabled doc err = %v, want ErrExternalMediaDisabled", err) + } +} diff --git a/internal/app/files/maptile.go b/internal/app/files/maptile.go new file mode 100644 index 00000000..2ca1340a --- /dev/null +++ b/internal/app/files/maptile.go @@ -0,0 +1,183 @@ +package files + +import ( + "bytes" + "image" + "image/color" + "image/png" + "math" + + "go.uber.org/zap" +) + +// 本文件实现 geo 消息地图缩略图(upload.getWebFile / inputWebFileGeoPointLocation)的 +// 本地占位渲染:服务端按坐标确定性合成一张「街区网格 + 定位针」风格的静态图, +// 同一 (lat,long,zoom,w,h,scale) 输入字节级可重现,保证客户端分片续传一致。 +// 配置了 Mapbox 代理(maptile_proxy.go)时优先返回真实地图,本渲染降级为故障回退。 + +const ( + mapTileMinEdge = 16 + mapTileMaxEdge = 1024 + mapTileMinZoom = 13 + mapTileMaxZoom = 20 + mapTileMaxScale = 3 +) + +// GeoMapTile 返回一张 w×h(逻辑像素,输出按 scale 放大)的静态地图与 mime。 +// 入参越界时按协议约束 clamp(w/h 16-1024、zoom 13-20、scale 1-3),不报错。 +// 配置了 Mapbox 代理时优先真实地图(落盘缓存),抓取失败回退确定性占位渲染。 +func (s *Service) GeoMapTile(lat, long float64, w, h, zoom, scale int) ([]byte, string) { + w = clampInt(w, mapTileMinEdge, mapTileMaxEdge) + h = clampInt(h, mapTileMinEdge, mapTileMaxEdge) + zoom = clampInt(zoom, mapTileMinZoom, mapTileMaxZoom) + scale = clampInt(scale, 1, mapTileMaxScale) + // nil receiver 合法:占位渲染是纯函数(既有调用方/测试依赖这一点),代理仅在配置后启用。 + if s != nil && s.mapTiles != nil { + if data, mime, err := s.mapTiles.tile(lat, long, w, h, zoom, scale); err == nil { + return data, mime + } else if s.log != nil { + s.log.Warn("map tile proxy failed, fallback to placeholder", + zap.Error(err), zap.Float64("lat", lat), zap.Float64("long", long), zap.Int("zoom", zoom)) + } + } + pw, ph := w*scale, h*scale + + img := image.NewRGBA(image.Rect(0, 0, pw, ph)) + background := color.RGBA{R: 0xEB, G: 0xE7, B: 0xDE, A: 0xFF} + park := color.RGBA{R: 0xCF, G: 0xE4, B: 0xC2, A: 0xFF} + road := color.RGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF} + roadEdge := color.RGBA{R: 0xD9, G: 0xD3, B: 0xC7, A: 0xFF} + fillRect(img, 0, 0, pw, ph, background) + + rng := newMapTileRNG(lat, long, zoom) + + // 街区网格:间距与抖动由坐标种子决定,平移随经纬度连续变化,避免所有地点一张脸。 + spacing := (48 + int(rng.next()%32)) * scale + offX := int(math.Abs(long*1e4)) % spacing + offY := int(math.Abs(lat*1e4)) % spacing + for x := -offX; x < pw; x += spacing { + major := ((x+offX)/spacing)%3 == int(rng.next()%3) + drawVerticalRoad(img, x+int(rng.next()%uint64(spacing/3)), pw, ph, scale, major, road, roadEdge) + } + for y := -offY; y < ph; y += spacing { + major := ((y+offY)/spacing)%3 == int(rng.next()%3) + drawHorizontalRoad(img, y+int(rng.next()%uint64(spacing/3)), pw, ph, scale, major, road, roadEdge) + } + + // 两块「绿地」:取网格内随机街块,铺底色之上、道路之下的视觉层级太复杂, + // 这里直接半覆盖即可(占位图不追求制图精度)。 + for i := 0; i < 2; i++ { + bx := int(rng.next() % uint64(pw)) + by := int(rng.next() % uint64(ph)) + bw := (spacing * 3) / 4 + fillRect(img, bx, by, minInt(bx+bw, pw), minInt(by+bw, ph), park) + } + + drawCenterPin(img, pw, ph, scale) + + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes(), "image/png" +} + +// mapTileRNG 是确定性 xorshift64,种子来自量化坐标与 zoom。 +type mapTileRNG struct{ state uint64 } + +func newMapTileRNG(lat, long float64, zoom int) *mapTileRNG { + seed := uint64(int64(lat*1e5))*1000003 ^ uint64(int64(long*1e5))*998244353 ^ uint64(zoom)*0x9E3779B97F4A7C15 + if seed == 0 { + seed = 0x9E3779B97F4A7C15 + } + return &mapTileRNG{state: seed} +} + +func (r *mapTileRNG) next() uint64 { + r.state ^= r.state << 13 + r.state ^= r.state >> 7 + r.state ^= r.state << 17 + return r.state +} + +func fillRect(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) { + bounds := img.Bounds() + x0, y0 = maxInt(x0, bounds.Min.X), maxInt(y0, bounds.Min.Y) + x1, y1 = minInt(x1, bounds.Max.X), minInt(y1, bounds.Max.Y) + for y := y0; y < y1; y++ { + for x := x0; x < x1; x++ { + img.SetRGBA(x, y, c) + } + } +} + +func drawVerticalRoad(img *image.RGBA, x, pw, ph, scale int, major bool, road, edge color.RGBA) { + width := 2 * scale + if major { + width = 4 * scale + } + fillRect(img, x-width/2-1, 0, x+width/2+1, ph, edge) + fillRect(img, x-width/2, 0, x+width/2, ph, road) +} + +func drawHorizontalRoad(img *image.RGBA, y, pw, ph, scale int, major bool, road, edge color.RGBA) { + width := 2 * scale + if major { + width = 4 * scale + } + fillRect(img, 0, y-width/2-1, pw, y+width/2+1, edge) + fillRect(img, 0, y-width/2, pw, y+width/2, road) +} + +// drawCenterPin 在图中心画红色定位针(圆头 + 下尖三角 + 白色内点),中心即坐标点。 +func drawCenterPin(img *image.RGBA, pw, ph, scale int) { + pin := color.RGBA{R: 0xE5, G: 0x39, B: 0x35, A: 0xFF} + pinDark := color.RGBA{R: 0xB7, G: 0x1C, B: 0x1C, A: 0xFF} + white := color.RGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF} + cx, cy := pw/2, ph/2 + headR := 9 * scale + headCY := cy - 14*scale + // 尖角三角形:从圆头两侧收敛到坐标点。 + for y := headCY; y <= cy; y++ { + t := float64(y-headCY) / float64(cy-headCY) + half := int(float64(headR) * (1 - t) * 0.82) + for x := cx - half; x <= cx+half; x++ { + img.SetRGBA(x, y, pin) + } + } + // 圆头(带一圈深色描边)。 + for dy := -headR - scale; dy <= headR+scale; dy++ { + for dx := -headR - scale; dx <= headR+scale; dx++ { + d2 := dx*dx + dy*dy + switch { + case d2 <= headR*headR: + img.SetRGBA(cx+dx, headCY+dy, pin) + case d2 <= (headR+scale)*(headR+scale): + img.SetRGBA(cx+dx, headCY+dy, pinDark) + } + } + } + innerR := 3 * scale + for dy := -innerR; dy <= innerR; dy++ { + for dx := -innerR; dx <= innerR; dx++ { + if dx*dx+dy*dy <= innerR*innerR { + img.SetRGBA(cx+dx, headCY+dy, white) + } + } + } +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/app/files/maptile_proxy.go b/internal/app/files/maptile_proxy.go new file mode 100644 index 00000000..bdda4072 --- /dev/null +++ b/internal/app/files/maptile_proxy.go @@ -0,0 +1,404 @@ +package files + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "go.uber.org/zap" + "golang.org/x/sync/singleflight" +) + +// 本文件实现地图缩略图的真实数据源:upload.getWebFile 命中 geo 坐标时,服务端代理 +// Mapbox Static Images API 抓取一张静态地图并落盘缓存。客户端按 offset/limit 分片下载 +// 同一文件,字节必须全程一致,因此: +// - 抓取成功先原子落盘(temp+rename),所有分片一律从缓存文件读; +// - 落盘失败时字节进短 TTL 进程内缓存兜底——分片是顺序请求、singleflight 合并不了, +// 没有这层缓存会退化成每分片一次外网抓取,且两次抓取的字节无逐字节一致保证; +// - 抓取失败记一个短 TTL 负缓存,期间该 key 直接走确定性占位图,避免同一次下载 +// 前后分片在「真图/占位图」之间翻转,也避免上游故障时每个分片都打一次外网。 +// +// 资源边界(key 空间由客户端可控的坐标×尺寸组合构成,必须设防): +// - 抓取尺寸量化到 32px 档位,收窄 key 空间与缓存基数; +// - 磁盘缓存有总量上限,超限按 mtime 从旧到新淘汰(顺带回收崩溃遗留 .tmp); +// - 上游抓取有全局速率上限,超限当次退占位图,防止恶意枚举烧 Mapbox 配额。 +// +// 地图不内嵌定位针:TDesktop(historyMapPoint icon)与 DrKLO 都在客户端叠加 marker, +// 与官方静态图行为一致。 + +const ( + mapTileFetchTimeout = 15 * time.Second + mapTileMaxFetchBytes = 8 << 20 // 防御性上限;640x640@2x PNG 远小于此 + mapTileFailureTTL = time.Minute + mapboxStaticStyleBase = "/styles/v1/mapbox/streets-v12/static" + + // mapTileEdgeStep 是抓取尺寸的量化步长(向上取整);客户端拿到略大的图自适应缩放。 + mapTileEdgeStep = 32 + // mapTileMemTTL/mapTileMemMaxBytes 是落盘失败兜底字节缓存的保留期与总量上限。 + mapTileMemTTL = 10 * time.Minute + mapTileMemMaxBytes = 32 << 20 + // mapTileDiskMaxBytes 是磁盘缓存总量上限;超限按 mtime 淘汰到 90%。 + mapTileDiskMaxBytes = int64(256 << 20) + // mapTileFetchRateLimit 是全局每分钟上游抓取上限(防恶意坐标枚举烧配额)。 + mapTileFetchRateLimit = 120 + mapTileFetchRateWindow = time.Minute + // mapTileTmpMaxAge 是崩溃遗留 .tmp 的回收阈值。 + mapTileTmpMaxAge = time.Hour +) + +type memTileEntry struct { + data []byte + at time.Time +} + +type mapTileProxy struct { + token string + baseURL string // 默认 https://api.mapbox.com;测试注入 httptest 地址 + dir string + client *http.Client + log *zap.Logger + maxDiskBytes int64 + + group singleflight.Group + sweepMu sync.Mutex + + mu sync.Mutex + failures map[string]time.Time // key → 失败时刻(负缓存) + memTiles map[string]memTileEntry + memBytes int + fetchTimes []time.Time // 上游抓取滑动窗口 +} + +// WithMapboxMapTiles 启用 Mapbox 静态地图代理;token 为空时不启用(保持占位图)。 +// logger 由 NewService 在全部 Option 应用后统一注入。 +func WithMapboxMapTiles(token, cacheDir string) Option { + return func(s *Service) { + if token == "" || cacheDir == "" { + return + } + s.mapTiles = &mapTileProxy{ + token: token, + baseURL: "https://api.mapbox.com", + dir: cacheDir, + client: &http.Client{Timeout: mapTileFetchTimeout}, + maxDiskBytes: mapTileDiskMaxBytes, + failures: make(map[string]time.Time), + memTiles: make(map[string]memTileEntry), + } + } +} + +// tile 返回 (lat,long,zoom,w,h,scale) 对应的静态地图字节;入参须已 clamp。 +func (p *mapTileProxy) tile(lat, long float64, w, h, zoom, scale int) ([]byte, string, error) { + // Mapbox 静态图只支持 @2x;scale 3 同样按 2x 抓(客户端按目标尺寸自适应缩放)。 + retina := "" + if scale >= 2 { + retina = "@2x" + } + // 尺寸量化收窄客户端可铸造的 key 空间;客户端按目标矩形自适应缩放略大的图。 + w = quantizeTileEdge(w) + h = quantizeTileEdge(h) + key := fmt.Sprintf("v1-%.5f-%.5f-%d-%dx%d%s", lat, long, zoom, w, h, retina) + path := p.cachePath(key) + if data, err := os.ReadFile(path); err == nil && len(data) > 0 { + return data, mapTileMime(data), nil + } + if data, ok := p.cachedMemTile(key); ok { + return data, mapTileMime(data), nil + } + if p.recentlyFailed(key) { + return nil, "", errors.New("map tile fetch in failure backoff") + } + v, err, _ := p.group.Do(key, func() (any, error) { + if data, err := os.ReadFile(path); err == nil && len(data) > 0 { + return data, nil + } + if data, ok := p.cachedMemTile(key); ok { + return data, nil + } + if !p.allowFetch() { + // 全局抓取限速:负缓存让该 key 短期稳定走占位图(保持分片字节一致),不打上游。 + p.markFailed(key) + return nil, errors.New("map tile fetch rate limited") + } + data, err := p.fetch(lat, long, w, h, zoom, retina) + if err != nil { + p.markFailed(key) + return nil, err + } + if err := p.store(path, data); err != nil { + // 分片是顺序请求,singleflight 合并不了后续分片;字节必须进内存缓存兜底, + // 否则磁盘持续故障会退化成每分片一次外网抓取且字节无一致性保证。 + p.rememberMemTile(key, data) + p.log.Warn("map tile cache write failed, serving from memory", zap.Error(err), zap.String("key", key)) + } else { + p.sweepDisk() + } + return data, nil + }) + if err != nil { + return nil, "", err + } + data := v.([]byte) + return data, mapTileMime(data), nil +} + +// quantizeTileEdge 把边长向上量化到 mapTileEdgeStep 的整数倍(caller 已 clamp 到 16..1024)。 +func quantizeTileEdge(v int) int { + q := ((v + mapTileEdgeStep - 1) / mapTileEdgeStep) * mapTileEdgeStep + if q > mapTileMaxEdge { + return mapTileMaxEdge + } + return q +} + +// fetch 请求 Mapbox Static Images API。注意 URL 坐标顺序是 {long},{lat}。 +func (p *mapTileProxy) fetch(lat, long float64, w, h, zoom int, retina string) ([]byte, error) { + endpoint := fmt.Sprintf("%s%s/%.5f,%.5f,%d/%dx%d%s?access_token=%s&attribution=false&logo=false", + p.baseURL, mapboxStaticStyleBase, long, lat, zoom, w, h, retina, url.QueryEscape(p.token)) + // 不透传 RPC ctx:singleflight 结果被并发分片共享,单个调用方取消不应拖垮整次抓取。 + ctx, cancel := context.WithTimeout(context.Background(), mapTileFetchTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("build map tile request: %w", err) + } + resp, err := p.client.Do(req) + if err != nil { + // 传输层错误内嵌完整 URL(含 access_token),脱敏后再向上传播/落日志。 + return nil, fmt.Errorf("fetch map tile: %s", p.redactToken(err.Error())) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetch map tile: upstream status %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, mapTileMaxFetchBytes+1)) + if err != nil { + return nil, fmt.Errorf("read map tile body: %w", err) + } + if len(data) == 0 || len(data) > mapTileMaxFetchBytes { + return nil, fmt.Errorf("map tile body size invalid: %d", len(data)) + } + if mime := mapTileMime(data); mime == "" { + return nil, errors.New("map tile body is not an image") + } + return data, nil +} + +func (p *mapTileProxy) cachePath(key string) string { + sum := sha256.Sum256([]byte(key)) + return filepath.Join(p.dir, hex.EncodeToString(sum[:])+".img") +} + +func (p *mapTileProxy) store(path string, data []byte) error { + if err := os.MkdirAll(p.dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(p.dir, "tile-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return err + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return err + } + return nil +} + +// redactToken 把错误文本中的 access token(原样或 URL 转义形态)替换为占位符。 +func (p *mapTileProxy) redactToken(s string) string { + if p.token == "" { + return s + } + s = strings.ReplaceAll(s, p.token, "***") + if escaped := url.QueryEscape(p.token); escaped != p.token { + s = strings.ReplaceAll(s, escaped, "***") + } + return s +} + +// cachedMemTile 返回落盘失败兜底缓存中的字节(TTL 内)。 +func (p *mapTileProxy) cachedMemTile(key string) ([]byte, bool) { + p.mu.Lock() + defer p.mu.Unlock() + entry, ok := p.memTiles[key] + if !ok { + return nil, false + } + if time.Since(entry.at) > mapTileMemTTL { + p.memBytes -= len(entry.data) + delete(p.memTiles, key) + return nil, false + } + return entry.data, true +} + +// rememberMemTile 在落盘失败时暂存字节;超总量按最旧淘汰。 +func (p *mapTileProxy) rememberMemTile(key string, data []byte) { + if len(data) > mapTileMemMaxBytes { + return + } + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + for k, entry := range p.memTiles { + if now.Sub(entry.at) > mapTileMemTTL { + p.memBytes -= len(entry.data) + delete(p.memTiles, k) + } + } + if old, ok := p.memTiles[key]; ok { + p.memBytes -= len(old.data) + } + for p.memBytes+len(data) > mapTileMemMaxBytes && len(p.memTiles) > 0 { + oldestKey := "" + var oldestAt time.Time + for k, entry := range p.memTiles { + if oldestKey == "" || entry.at.Before(oldestAt) { + oldestKey, oldestAt = k, entry.at + } + } + p.memBytes -= len(p.memTiles[oldestKey].data) + delete(p.memTiles, oldestKey) + } + p.memTiles[key] = memTileEntry{data: data, at: now} + p.memBytes += len(data) +} + +// allowFetch 是全局上游抓取限速(滑动窗口);超限返回 false。 +func (p *mapTileProxy) allowFetch() bool { + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + kept := p.fetchTimes[:0] + for _, at := range p.fetchTimes { + if now.Sub(at) <= mapTileFetchRateWindow { + kept = append(kept, at) + } + } + p.fetchTimes = kept + if len(p.fetchTimes) >= mapTileFetchRateLimit { + return false + } + p.fetchTimes = append(p.fetchTimes, now) + return true +} + +// sweepDisk 在新写入后核算缓存目录总量,超限按 mtime 从旧到新淘汰到 90%, +// 顺带回收崩溃遗留的过期 .tmp。store 仅发生在上游抓取后(低频),同步扫描可接受。 +func (p *mapTileProxy) sweepDisk() { + p.sweepMu.Lock() + defer p.sweepMu.Unlock() + entries, err := os.ReadDir(p.dir) + if err != nil { + return + } + type tileFile struct { + name string + size int64 + mod time.Time + } + var files []tileFile + var total int64 + now := time.Now() + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if strings.HasSuffix(entry.Name(), ".tmp") { + if now.Sub(info.ModTime()) > mapTileTmpMaxAge { + _ = os.Remove(filepath.Join(p.dir, entry.Name())) + } + continue + } + if !strings.HasSuffix(entry.Name(), ".img") { + continue + } + files = append(files, tileFile{name: entry.Name(), size: info.Size(), mod: info.ModTime()}) + total += info.Size() + } + if total <= p.maxDiskBytes { + return + } + sort.Slice(files, func(i, j int) bool { return files[i].mod.Before(files[j].mod) }) + target := p.maxDiskBytes * 9 / 10 + removed := 0 + for _, f := range files { + if total <= target { + break + } + if err := os.Remove(filepath.Join(p.dir, f.name)); err == nil { + total -= f.size + removed++ + } + } + if removed > 0 && p.log != nil { + p.log.Info("map tile cache swept", zap.Int("removed", removed), zap.Int64("remaining_bytes", total)) + } +} + +func (p *mapTileProxy) recentlyFailed(key string) bool { + p.mu.Lock() + defer p.mu.Unlock() + at, ok := p.failures[key] + if !ok { + return false + } + if time.Since(at) > mapTileFailureTTL { + delete(p.failures, key) + return false + } + return true +} + +func (p *mapTileProxy) markFailed(key string) { + p.mu.Lock() + defer p.mu.Unlock() + // 顺手清理过期项,防 map 无界增长(key 空间本身有限:坐标×尺寸枚举)。 + now := time.Now() + for k, at := range p.failures { + if now.Sub(at) > mapTileFailureTTL { + delete(p.failures, k) + } + } + p.failures[key] = now +} + +// mapTileMime 按魔数识别图片类型;非图片返回空串。 +func mapTileMime(data []byte) string { + switch { + case len(data) > 8 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G': + return "image/png" + case len(data) > 3 && data[0] == 0xFF && data[1] == 0xD8: + return "image/jpeg" + case len(data) > 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP": + return "image/webp" + default: + return "" + } +} diff --git a/internal/app/files/maptile_proxy_test.go b/internal/app/files/maptile_proxy_test.go new file mode 100644 index 00000000..22888744 --- /dev/null +++ b/internal/app/files/maptile_proxy_test.go @@ -0,0 +1,243 @@ +package files + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// fakePNG 是最小合法 PNG 头 + 填充(只需通过魔数识别,不需要可解码)。 +var fakePNG = append([]byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, bytes.Repeat([]byte{0x42}, 64)...) + +func newProxyTestService(t *testing.T, handler http.Handler) (*Service, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + s := NewService(nil, nil, 2, WithMapboxMapTiles("test-token", t.TempDir())) + if s.mapTiles == nil { + t.Fatal("map tile proxy not configured") + } + s.mapTiles.baseURL = srv.URL + return s, srv +} + +func TestGeoMapTileProxyFetchesAndCaches(t *testing.T) { + var calls atomic.Int32 + var lastPath, lastQuery string + s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + lastPath = r.URL.Path + lastQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNG) + })) + + first, mime := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2) + if mime != "image/png" { + t.Fatalf("mime = %q, want image/png", mime) + } + if !bytes.Equal(first, fakePNG) { + t.Fatal("first fetch did not return upstream bytes") + } + // Mapbox 形态:/styles/v1/mapbox/streets-v12/static/{long},{lat},{zoom}/{w}x{h}@2x + if !strings.Contains(lastPath, "/static/116.40740,39.90420,15/256x128@2x") { + t.Fatalf("unexpected upstream path: %s", lastPath) + } + if !strings.Contains(lastQuery, "access_token=test-token") { + t.Fatalf("missing access token in query: %s", lastQuery) + } + + // 第二次(含分片重复读)必须走磁盘缓存,不再触发上游请求。 + second, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2) + if !bytes.Equal(first, second) { + t.Fatal("cached bytes differ from first fetch") + } + if got := calls.Load(); got != 1 { + t.Fatalf("upstream calls = %d, want 1 (second hit must be cached)", got) + } +} + +func TestGeoMapTileProxyScaleOneOmitsRetina(t *testing.T) { + var lastPath string + s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lastPath = r.URL.Path + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNG) + })) + s.GeoMapTile(1.5, 2.5, 100, 100, 16, 1) + if strings.Contains(lastPath, "@2x") { + t.Fatalf("scale=1 must not request retina: %s", lastPath) + } +} + +func TestGeoMapTileProxyFallsBackToPlaceholder(t *testing.T) { + var calls atomic.Int32 + s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "boom", http.StatusInternalServerError) + })) + + data, mime := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2) + if len(data) == 0 || mime != "image/png" { + t.Fatalf("fallback placeholder missing: len=%d mime=%q", len(data), mime) + } + // 占位图确定性:回退路径必须与纯占位服务字节一致(分片续传一致性)。 + plain := NewService(nil, nil, 2) + expected, _ := plain.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2) + if !bytes.Equal(data, expected) { + t.Fatal("fallback placeholder differs from deterministic rendering") + } + + // 负缓存:失败后的后续分片请求不应继续打上游。 + again, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2) + if !bytes.Equal(again, expected) { + t.Fatal("placeholder must stay byte-identical during failure backoff") + } + if got := calls.Load(); got != 1 { + t.Fatalf("upstream calls = %d, want 1 (failure must be negative-cached)", got) + } + + // 负缓存过期后允许重试并恢复真实地图。 + s.mapTiles.mu.Lock() + for k := range s.mapTiles.failures { + s.mapTiles.failures[k] = time.Now().Add(-2 * mapTileFailureTTL) + } + s.mapTiles.mu.Unlock() + // 上游恢复。 + s.mapTiles.baseURL = newRecoveredUpstream(t) + recovered, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2) + if !bytes.Equal(recovered, fakePNG) { + t.Fatal("proxy must recover after failure TTL expires") + } +} + +func newRecoveredUpstream(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNG) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +// 落盘失败时字节必须进内存兜底缓存:顺序分片不再逐片打上游,且字节全程一致。 +func TestGeoMapTileProxyStoreFailureServesFromMemory(t *testing.T) { + var calls atomic.Int32 + s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNG) + })) + // 让缓存目录路径指向一个普通文件 → MkdirAll/写盘必然失败。 + blocked := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(blocked, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + s.mapTiles.dir = blocked + + first, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2) + second, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2) + if !bytes.Equal(first, fakePNG) || !bytes.Equal(second, fakePNG) { + t.Fatal("store-failure path must keep serving upstream bytes") + } + if got := calls.Load(); got != 1 { + t.Fatalf("upstream calls = %d, want 1 (memory cache must absorb后续分片)", got) + } +} + +// 抓取尺寸量化到 32px 档位,收窄客户端可铸造的缓存 key 空间。 +func TestGeoMapTileProxyQuantizesFetchDimensions(t *testing.T) { + var mu sync.Mutex + var lastPath string + s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + lastPath = r.URL.Path + mu.Unlock() + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNG) + })) + s.GeoMapTile(1.5, 2.5, 100, 50, 15, 2) + mu.Lock() + defer mu.Unlock() + if !strings.Contains(lastPath, "/128x64@2x") { + t.Fatalf("dimensions not quantized to 32px steps: %s", lastPath) + } +} + +// 磁盘缓存超总量上限后按 mtime 从旧到新淘汰。 +func TestGeoMapTileProxyDiskSweepEvictsOldest(t *testing.T) { + s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNG) + })) + s.mapTiles.maxDiskBytes = int64(len(fakePNG)*2 + 8) // 容得下 2 张,第 3 张触发淘汰 + for i := 0; i < 3; i++ { + s.GeoMapTile(10+float64(i), 20, 128, 128, 15, 1) + time.Sleep(20 * time.Millisecond) // 保证 mtime 可区分 + } + entries, err := os.ReadDir(s.mapTiles.dir) + if err != nil { + t.Fatal(err) + } + var total int64 + count := 0 + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + t.Fatal(err) + } + total += info.Size() + count++ + } + if count >= 3 || total > s.mapTiles.maxDiskBytes { + t.Fatalf("sweep did not evict: files=%d total=%d max=%d", count, total, s.mapTiles.maxDiskBytes) + } +} + +// 全局抓取限速:超限的新 key 退占位图且不打上游。 +func TestGeoMapTileProxyFetchRateLimit(t *testing.T) { + var calls atomic.Int32 + s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(fakePNG) + })) + // 预填满滑动窗口。 + s.mapTiles.mu.Lock() + now := time.Now() + for i := 0; i < mapTileFetchRateLimit; i++ { + s.mapTiles.fetchTimes = append(s.mapTiles.fetchTimes, now) + } + s.mapTiles.mu.Unlock() + + data, mime := s.GeoMapTile(33.3, 44.4, 128, 128, 15, 1) + plain := NewService(nil, nil, 2) + expected, _ := plain.GeoMapTile(33.3, 44.4, 128, 128, 15, 1) + if !bytes.Equal(data, expected) || mime != "image/png" { + t.Fatal("rate-limited request must fall back to deterministic placeholder") + } + if got := calls.Load(); got != 0 { + t.Fatalf("upstream calls = %d, want 0 when rate limited", got) + } +} + +func TestGeoMapTileProxyRejectsNonImageBody(t *testing.T) { + s, _ := newProxyTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte("not a map")) + })) + data, mime := s.GeoMapTile(10, 20, 128, 128, 15, 1) + plain := NewService(nil, nil, 2) + expected, _ := plain.GeoMapTile(10, 20, 128, 128, 15, 1) + if !bytes.Equal(data, expected) || mime != "image/png" { + t.Fatal("non-image upstream body must fall back to placeholder") + } +} diff --git a/internal/app/files/maptile_test.go b/internal/app/files/maptile_test.go new file mode 100644 index 00000000..3b7620de --- /dev/null +++ b/internal/app/files/maptile_test.go @@ -0,0 +1,48 @@ +package files + +import ( + "bytes" + "image/png" + "testing" +) + +func TestGeoMapTileDeterministicPNG(t *testing.T) { + var s *Service + first, mime := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2) + second, _ := s.GeoMapTile(39.9042, 116.4074, 256, 128, 15, 2) + if mime != "image/png" { + t.Fatalf("mime = %q, want image/png", mime) + } + if !bytes.Equal(first, second) { + t.Fatal("map tile must be byte-identical for identical input (chunked download consistency)") + } + img, err := png.Decode(bytes.NewReader(first)) + if err != nil { + t.Fatalf("decode png: %v", err) + } + if img.Bounds().Dx() != 512 || img.Bounds().Dy() != 256 { + t.Fatalf("tile dims = %dx%d, want 512x256 (w*scale x h*scale)", img.Bounds().Dx(), img.Bounds().Dy()) + } +} + +func TestGeoMapTileClampsBounds(t *testing.T) { + var s *Service + tile, _ := s.GeoMapTile(0, 0, 99999, -5, 99, 9) + img, err := png.Decode(bytes.NewReader(tile)) + if err != nil { + t.Fatalf("decode png: %v", err) + } + // w clamp 1024、h clamp 16、scale clamp 3。 + if img.Bounds().Dx() != 1024*3 || img.Bounds().Dy() != 16*3 { + t.Fatalf("tile dims = %dx%d, want 3072x48", img.Bounds().Dx(), img.Bounds().Dy()) + } +} + +func TestGeoMapTileDiffersByLocation(t *testing.T) { + var s *Service + a, _ := s.GeoMapTile(39.9042, 116.4074, 128, 128, 15, 1) + b, _ := s.GeoMapTile(31.2304, 121.4737, 128, 128, 15, 1) + if bytes.Equal(a, b) { + t.Fatal("different locations should render different tiles") + } +} diff --git a/internal/app/files/mp4faststart.go b/internal/app/files/mp4faststart.go new file mode 100644 index 00000000..487f7adc --- /dev/null +++ b/internal/app/files/mp4faststart.go @@ -0,0 +1,243 @@ +package files + +import "encoding/binary" + +// faststartMP4 把 MP4 的 moov 原子移到 mdat 之前(faststart),并把 moov 内所有 +// stco/co64 chunk 偏移整体加上 moov 的大小(因为 moov 插到 mdat 前会把媒体数据整体后移)。 +// 返回 (newData, changed)。非 MP4 / 已 faststart / 任何解析异常时返回 (data, false) 原样, +// 绝不破坏数据——这是上传热路径,宁可不优化也不能把好视频改坏。 +// +// 背景:TDesktop 的 story 流式播放路径无法处理 moov 在文件末尾的视频(av_read_frame +// 报 Invalid data),普通 Telegram 客户端上传前会 faststart。telesrv 在落盘前补这一步, +// 让 supports_streaming=true 的承诺对所有客户端成立。不转码、保留原编码(含 HEVC)。 +func faststartMP4(data []byte) ([]byte, bool) { + boxes, ok := parseTopLevelBoxes(data) + if !ok { + return data, false + } + ftypIdx, moovIdx, firstMdatIdx := -1, -1, -1 + for i, b := range boxes { + switch b.typ { + case "ftyp": + if ftypIdx < 0 { + ftypIdx = i + } + case "moov": + if moovIdx < 0 { + moovIdx = i + } + case "mdat": + if firstMdatIdx < 0 { + firstMdatIdx = i + } + } + } + // 必须有 ftyp(且在最前)、moov、mdat;moov 已在 mdat 前则已 faststart。 + if ftypIdx != 0 || moovIdx < 0 || firstMdatIdx < 0 { + return data, false + } + if moovIdx < firstMdatIdx { + return data, false + } + + // 拷出 moov(独立底层数组,后续就地改偏移不影响原 data)。 + moov := append([]byte(nil), data[boxes[moovIdx].start:boxes[moovIdx].end]...) + moovSize := int64(len(moov)) + if !patchChunkOffsets(moov, moovSize) { + return data, false + } + + // 重组:ftyp + moov + 其余 box(除 ftyp/moov)按原顺序。 + out := make([]byte, 0, len(data)) + out = append(out, data[boxes[ftypIdx].start:boxes[ftypIdx].end]...) + out = append(out, moov...) + for i, b := range boxes { + if i == ftypIdx || i == moovIdx { + continue + } + out = append(out, data[b.start:b.end]...) + } + if len(out) != len(data) { + // 长度必须守恒(只搬不改大小);不守恒说明哪里算错,保守放弃。 + return data, false + } + return out, true +} + +// mp4Layout 是只读 box 头得出的顶层结构信息,用于在不读取整段媒体的前提下判断是否需要 +// faststart 以及如何流式重写。 +type mp4Layout struct { + needsFaststart bool // moov 在 mdat 之后 + moovIsLast bool // moov 是最后一个顶层 box(可走流式重写) + ftypStart, ftypEnd int64 + moovStart, moovEnd int64 +} + +// inspectMP4Layout 只读顶层 box 头(每个 ≤16 字节,跳过 box 负载)来判定结构,避免为了 +// 「检查是否已 faststart」而把整段视频读进内存。readAt(off, n) 读取 [off, off+n) 字节。 +// 非 MP4 / 结构异常返回 (·, false)。 +func inspectMP4Layout(size int64, readAt func(off, n int64) ([]byte, error)) (mp4Layout, bool) { + l := mp4Layout{moovStart: -1, moovEnd: -1} + mdatStart := int64(-1) + ftypSeen := false + p := int64(0) + for boxes := 0; p+8 <= size; boxes++ { + if boxes > 1024 { // 顶层 box 数量上限,防御异常文件 + return mp4Layout{}, false + } + hdr, err := readAt(p, 16) + if err != nil || int64(len(hdr)) < 8 { + return mp4Layout{}, false + } + boxSize := int64(binary.BigEndian.Uint32(hdr[0:4])) + typ := string(hdr[4:8]) + switch { + case boxSize == 1: + if len(hdr) < 16 { + return mp4Layout{}, false + } + boxSize = int64(binary.BigEndian.Uint64(hdr[8:16])) + case boxSize == 0: + boxSize = size - p + } + if boxSize < 8 || p+boxSize > size { + return mp4Layout{}, false + } + switch typ { + case "ftyp": + if p != 0 { + return mp4Layout{}, false // ftyp 必须在最前 + } + ftypSeen = true + l.ftypStart, l.ftypEnd = p, p+boxSize + case "moov": + if l.moovStart < 0 { + l.moovStart, l.moovEnd = p, p+boxSize + } + case "mdat": + if mdatStart < 0 { + mdatStart = p + } + } + p += boxSize + } + if p != size || !ftypSeen || l.moovStart < 0 || mdatStart < 0 { + return mp4Layout{}, false + } + l.needsFaststart = l.moovStart > mdatStart + l.moovIsLast = l.moovEnd == size + return l, true +} + +type boxRef struct { + typ string + start, end int +} + +// parseTopLevelBoxes 顺序解析顶层 box,要求恰好无缝覆盖整个 data,否则视为非法不处理。 +func parseTopLevelBoxes(data []byte) ([]boxRef, bool) { + var boxes []boxRef + p := 0 + for p+8 <= len(data) { + size := int(binary.BigEndian.Uint32(data[p : p+4])) + typ := string(data[p+4 : p+8]) + switch { + case size == 1: + if p+16 > len(data) { + return nil, false + } + size64 := binary.BigEndian.Uint64(data[p+8 : p+16]) + size = int(size64) + case size == 0: + size = len(data) - p + } + if size < 8 || p+size > len(data) { + return nil, false + } + boxes = append(boxes, boxRef{typ: typ, start: p, end: p + size}) + p += size + } + if p != len(data) { + return nil, false + } + return boxes, true +} + +// patchChunkOffsets 递归进入 moov 的容器 box,把 stco/co64 的每个偏移 += delta。 +func patchChunkOffsets(box []byte, delta int64) bool { + if len(box) < 8 { + return false + } + p := 8 // 跳过自身 box 头(moov 用 32 位 size,极少 64 位;若 64 位则下面 walk 仍从 8 起会错→由调用方 moov 头守恒保证) + for p+8 <= len(box) { + size := int(binary.BigEndian.Uint32(box[p : p+4])) + typ := string(box[p+4 : p+8]) + hdr := 8 + switch { + case size == 1: + if p+16 > len(box) { + return false + } + size = int(binary.BigEndian.Uint64(box[p+8 : p+16])) + hdr = 16 + case size == 0: + size = len(box) - p + } + if size < hdr || p+size > len(box) { + return false + } + child := box[p : p+size] + switch typ { + case "stco": + if !patchStco(child, delta) { + return false + } + case "co64": + if !patchCo64(child, delta) { + return false + } + case "trak", "mdia", "minf", "stbl", "edts": + if !patchChunkOffsets(child, delta) { + return false + } + } + p += size + } + return true +} + +// patchStco:stco = [size(4)][type(4)][version+flags(4)][entry_count(4)][offsets 4*count]。 +func patchStco(box []byte, delta int64) bool { + if len(box) < 16 { + return false + } + count := binary.BigEndian.Uint32(box[12:16]) + off := 16 + if int64(off)+int64(count)*4 > int64(len(box)) { + return false + } + for i := uint32(0); i < count; i++ { + v := binary.BigEndian.Uint32(box[off : off+4]) + binary.BigEndian.PutUint32(box[off:off+4], uint32(int64(v)+delta)) + off += 4 + } + return true +} + +// patchCo64:co64 entries 为 8 字节。 +func patchCo64(box []byte, delta int64) bool { + if len(box) < 16 { + return false + } + count := binary.BigEndian.Uint32(box[12:16]) + off := 16 + if int64(off)+int64(count)*8 > int64(len(box)) { + return false + } + for i := uint32(0); i < count; i++ { + v := binary.BigEndian.Uint64(box[off : off+8]) + binary.BigEndian.PutUint64(box[off:off+8], v+uint64(delta)) + off += 8 + } + return true +} diff --git a/internal/app/files/mp4faststart_test.go b/internal/app/files/mp4faststart_test.go new file mode 100644 index 00000000..42cce91f --- /dev/null +++ b/internal/app/files/mp4faststart_test.go @@ -0,0 +1,205 @@ +package files + +import ( + "bytes" + "encoding/binary" + "testing" +) + +// box 构造一个 MP4 box:[size(4)][type(4)][payload]。 +func box(typ string, payload []byte) []byte { + b := make([]byte, 8+len(payload)) + binary.BigEndian.PutUint32(b[0:4], uint32(8+len(payload))) + copy(b[4:8], typ) + copy(b[8:], payload) + return b +} + +// stcoBox 构造一个含单个 chunk 偏移的 stco:[ver+flags(4)][count(4)=1][offset(4)]。 +func stcoBox(offset uint32) []byte { + p := make([]byte, 12) + binary.BigEndian.PutUint32(p[4:8], 1) // entry_count + binary.BigEndian.PutUint32(p[8:12], offset) + return box("stco", p) +} + +// buildMoovEndMP4 构造一个 moov 在末尾、stco 指向 mdat 内某偏移的最小合法 MP4。 +// 返回 (mp4, mdatPayloadAbsOffset)。 +func buildMoovEndMP4(mdatPayload []byte) ([]byte, uint32) { + ftyp := box("ftyp", []byte("isom\x00\x00\x02\x00")) + mdat := box("mdat", mdatPayload) + mdatAbs := uint32(len(ftyp) + 8) // mdat payload 紧跟 mdat 头(8 字节) + // moov→trak→mdia→minf→stbl→stco(offset=mdatAbs) + stbl := box("stbl", stcoBox(mdatAbs)) + minf := box("minf", stbl) + mdia := box("mdia", minf) + trak := box("trak", mdia) + moov := box("moov", trak) + out := append([]byte(nil), ftyp...) + out = append(out, mdat...) + out = append(out, moov...) + return out, mdatAbs +} + +func TestFaststartMP4MovesMoovAndFixesOffsets(t *testing.T) { + marker := []byte("THE-REAL-CHUNK-DATA-HERE") + // mdat payload:前面填充 + marker,stco 指向 marker 的绝对偏移。 + pad := bytes.Repeat([]byte{0xAB}, 40) + mdatPayload := append(append([]byte(nil), pad...), marker...) + _, mdatAbs := buildMoovEndMP4(mdatPayload) + markerAbs := mdatAbs + uint32(len(pad)) // marker 在原文件里的绝对偏移 + + // 原 stco 指向 mdatAbs(mdat payload 头)。这里把 stco 改成指向 marker 以便断言。 + in2, _ := buildMoovEndMP4Marker(mdatPayload, markerAbs) + // 校验前置:原文件里 markerAbs 处确实是 marker。 + if !bytes.Equal(in2[markerAbs:markerAbs+uint32(len(marker))], marker) { + t.Fatalf("setup: 原文件 markerAbs 处非 marker") + } + + out, changed := faststartMP4(in2) + if !changed { + t.Fatalf("changed = false, want true(moov 在末尾应被搬动)") + } + if len(out) != len(in2) { + t.Fatalf("size 不守恒: in=%d out=%d", len(in2), len(out)) + } + // 输出顺序:ftyp, moov, mdat。 + boxes, ok := parseTopLevelBoxes(out) + if !ok || len(boxes) != 3 || boxes[0].typ != "ftyp" || boxes[1].typ != "moov" || boxes[2].typ != "mdat" { + t.Fatalf("输出顶层顺序错: %+v ok=%v", boxes, ok) + } + // 取出输出 moov 里的 stco 偏移,应指向输出文件里仍是 marker 的位置。 + newOff := readSingleStcoOffset(t, out[boxes[1].start:boxes[1].end]) + if int(newOff)+len(marker) > len(out) { + t.Fatalf("新偏移越界: %d", newOff) + } + got := out[newOff : int(newOff)+len(marker)] + if !bytes.Equal(got, marker) { + t.Fatalf("新 stco 偏移 %d 指向 %q, want %q(偏移修正错误)", newOff, got, marker) + } + // 新偏移 = 原偏移 + moovSize。 + moovSize := boxes[1].end - boxes[1].start + if int(newOff) != int(markerAbs)+moovSize { + t.Fatalf("新偏移 = %d, want 原 %d + moovSize %d", newOff, markerAbs, moovSize) + } +} + +func TestFaststartMP4NoopWhenAlreadyFaststart(t *testing.T) { + // moov 在 mdat 前 → 已 faststart,应原样返回。 + ftyp := box("ftyp", []byte("isom\x00\x00\x02\x00")) + stbl := box("stbl", stcoBox(100)) + moov := box("moov", box("trak", box("mdia", box("minf", stbl)))) + mdat := box("mdat", bytes.Repeat([]byte{1}, 64)) + in := append(append(append([]byte(nil), ftyp...), moov...), mdat...) + out, changed := faststartMP4(in) + if changed { + t.Fatalf("已 faststart 不应改动") + } + if !bytes.Equal(out, in) { + t.Fatalf("应原样返回") + } +} + +func TestInspectMP4LayoutDetectsMoovAtEnd(t *testing.T) { + in, _ := buildMoovEndMP4(bytes.Repeat([]byte{0xCD}, 100)) + readAt := func(off, n int64) ([]byte, error) { + if off+n > int64(len(in)) { + n = int64(len(in)) - off + } + return in[off : off+n], nil + } + l, ok := inspectMP4Layout(int64(len(in)), readAt) + if !ok { + t.Fatalf("inspect 失败") + } + if !l.needsFaststart { + t.Fatalf("moov 在末尾应 needsFaststart=true") + } + if !l.moovIsLast { + t.Fatalf("moov 是最后一个 box 应 moovIsLast=true") + } + if l.ftypStart != 0 || l.moovEnd != int64(len(in)) { + t.Fatalf("range 不对: %+v (len=%d)", l, len(in)) + } +} + +func TestInspectMP4LayoutNoFaststartWhenMoovFirst(t *testing.T) { + ftyp := box("ftyp", []byte("isom\x00\x00\x02\x00")) + moov := box("moov", box("trak", box("mdia", box("minf", box("stbl", stcoBox(100)))))) + mdat := box("mdat", bytes.Repeat([]byte{1}, 100)) + in := append(append(append([]byte(nil), ftyp...), moov...), mdat...) + readAt := func(off, n int64) ([]byte, error) { + if off+n > int64(len(in)) { + n = int64(len(in)) - off + } + return in[off : off+n], nil + } + l, ok := inspectMP4Layout(int64(len(in)), readAt) + if !ok || l.needsFaststart { + t.Fatalf("moov 在前应 needsFaststart=false, got ok=%v %+v", ok, l) + } +} + +// 流式拼接(ftyp + patched moov + 中段)必须与全量 faststartMP4 输出逐字节一致。 +func TestStreamingAssemblyMatchesFullRewrite(t *testing.T) { + in, _ := buildMoovEndMP4(bytes.Repeat([]byte{0xEE}, 256)) + readAt := func(off, n int64) ([]byte, error) { return in[off : off+n], nil } + l, ok := inspectMP4Layout(int64(len(in)), readAt) + if !ok || !l.needsFaststart || !l.moovIsLast { + t.Fatalf("setup: %+v ok=%v", l, ok) + } + // 流式版本:ftyp + patched moov + in[ftypEnd:moovStart] + ftyp := append([]byte(nil), in[l.ftypStart:l.ftypEnd]...) + moov := append([]byte(nil), in[l.moovStart:l.moovEnd]...) + if !patchChunkOffsets(moov, l.moovEnd-l.moovStart) { + t.Fatalf("patch 失败") + } + var streaming []byte + streaming = append(streaming, ftyp...) + streaming = append(streaming, moov...) + streaming = append(streaming, in[l.ftypEnd:l.moovStart]...) + + full, changed := faststartMP4(in) + if !changed { + t.Fatalf("full 应 changed") + } + if !bytes.Equal(streaming, full) { + t.Fatalf("流式拼接与全量重写不一致: streaming=%d full=%d", len(streaming), len(full)) + } +} + +func TestFaststartMP4NoopOnNonMP4(t *testing.T) { + for _, data := range [][]byte{ + nil, + []byte("not an mp4 at all"), + {0, 0, 0, 4}, // size 太小 + } { + if out, changed := faststartMP4(data); changed || !bytes.Equal(out, data) { + t.Fatalf("非 MP4 应原样不动: changed=%v", changed) + } + } +} + +// buildMoovEndMP4Marker 同 buildMoovEndMP4,但 stco 指向给定绝对偏移。 +func buildMoovEndMP4Marker(mdatPayload []byte, stcoOffset uint32) ([]byte, uint32) { + ftyp := box("ftyp", []byte("isom\x00\x00\x02\x00")) + mdat := box("mdat", mdatPayload) + mdatAbs := uint32(len(ftyp) + 8) + stbl := box("stbl", stcoBox(stcoOffset)) + moov := box("moov", box("trak", box("mdia", box("minf", stbl)))) + out := append([]byte(nil), ftyp...) + out = append(out, mdat...) + out = append(out, moov...) + return out, mdatAbs +} + +func readSingleStcoOffset(t *testing.T, moov []byte) uint32 { + t.Helper() + idx := bytes.Index(moov, []byte("stco")) + if idx < 0 { + t.Fatalf("moov 里找不到 stco") + } + // stco 头后:type(4) 已在 idx;payload 从 idx+4 起 = ver+flags(4)+count(4)+offset(4) + off := idx + 4 + 4 + 4 + return binary.BigEndian.Uint32(moov[off : off+4]) +} diff --git a/internal/app/files/photos.go b/internal/app/files/photos.go index c227c6b6..6a215fe6 100644 --- a/internal/app/files/photos.go +++ b/internal/app/files/photos.go @@ -7,11 +7,20 @@ import ( "encoding/binary" "fmt" "image" + "image/color" + "io" + stddraw "image/draw" _ "image/jpeg" // 注册 jpeg DecodeConfig,用于读取上传头像/图片尺寸 - _ "image/png" // 注册 png DecodeConfig + "image/png" + "math" + "strings" "time" "telesrv/internal/domain" + + "go.uber.org/zap" + xdraw "golang.org/x/image/draw" + _ "golang.org/x/image/webp" // 注册 webp Decode,用于 custom emoji / sticker 静态缩略图合成 ) // 头像与图片消息共用的尺寸 type:'a' 小图(≤160),'c' 大图,'x' 通用下载尺寸。 @@ -24,17 +33,10 @@ func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerT // UploadProfilePhotoKind stores a profile or fallback photo and makes it current for that kind. func (s *Service) UploadProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, file domain.UploadedFileRef, date int) (domain.Photo, error) { - data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) - if err != nil { - return domain.Photo{}, err - } - if len(data) == 0 { - return domain.Photo{}, domain.ErrPhotoInvalid - } if date == 0 { date = int(time.Now().Unix()) } - photo, err := s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data)) + photo, err := s.CreateAvatarFromUpload(ctx, file) if err != nil { return domain.Photo{}, err } @@ -56,6 +58,14 @@ func (s *Service) CreatePhotoFromUpload(ctx context.Context, file domain.Uploade return s.createPhoto(ctx, data, photoSizeSpecsForMessage(data)) } +// CreatePhotoFromBytes stores already-fetched image bytes as a message Photo. +func (s *Service) CreatePhotoFromBytes(ctx context.Context, data []byte) (domain.Photo, error) { + if len(data) == 0 { + return domain.Photo{}, domain.ErrPhotoInvalid + } + return s.createPhoto(ctx, data, photoSizeSpecsForMessage(data)) +} + // GetPhoto 按 id 返回已存储照片。 func (s *Service) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) { return s.media.GetPhoto(ctx, id) @@ -79,25 +89,137 @@ func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.Upload return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data)) } +// CreateAvatarVideoFromUpload stores an animated profile video as photo.video_sizes. +func (s *Service) CreateAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64) (domain.Photo, error) { + return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, nil) +} + +// CreateAvatarVideoMarkupFromUpload stores Android-style generated avatar video plus its emoji/sticker markup. +func (s *Service) CreateAvatarVideoMarkupFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, markup domain.PhotoSize) (domain.Photo, error) { + if err := validateAvatarMarkupSize(markup); err != nil { + return domain.Photo{}, err + } + return s.createAvatarVideoFromUpload(ctx, file, videoStartTs, []domain.PhotoSize{markup}) +} + +func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, extraSizes []domain.PhotoSize) (domain.Photo, error) { + body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts) + if err != nil { + return domain.Photo{}, err + } + if body.Size == 0 { + return domain.Photo{}, domain.ErrPhotoInvalid + } + photoID := randomID() + blob := domain.FileBlob{ + LocationKey: fmt.Sprintf("photo:%d:u", photoID), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: body.ObjectKey, + Size: body.Size, + SHA256: body.SHA256, + MimeType: "video/mp4", + } + if err := s.media.PutFileBlob(ctx, blob); err != nil { + return domain.Photo{}, err + } + s.blobCache.put(blob.LocationKey, blob) + stillBytes := s.avatarVideoStill(ctx, body, extraSizes) + sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes)) + if err != nil { + return domain.Photo{}, err + } + sizes = append(sizes, domain.PhotoSize{ + Kind: domain.PhotoSizeKindVideo, + Type: "u", + W: 640, + H: 640, + Size: int(body.Size), + VideoStartTs: videoStartTs, + }) + sizes = append(sizes, extraSizes...) + photo := domain.Photo{ + ID: photoID, + AccessHash: randomID(), + FileReference: randomFileReference(), + Date: int(time.Now().Unix()), + DCID: s.dc, + Sizes: sizes, + } + if err := s.media.PutPhoto(ctx, photo); err != nil { + return domain.Photo{}, err + } + if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil { + s.log.Warn("cleanup assembled avatar video upload parts failed", + zap.Int64("owner_user_id", file.OwnerUserID), + zap.Int64("file_id", file.FileID), + zap.Int64("photo_id", photoID), + zap.Error(err)) + } + return photo, nil +} + +// CreateAvatarMarkup stores an emoji/sticker animated profile markup as photo.video_sizes. +func (s *Service) CreateAvatarMarkup(ctx context.Context, size domain.PhotoSize) (domain.Photo, error) { + if err := validateAvatarMarkupSize(size); err != nil { + return domain.Photo{}, err + } + photoID := randomID() + stillBytes := s.generatedAvatarStill(ctx, size) + sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes)) + if err != nil { + return domain.Photo{}, err + } + sizes = append(sizes, size) + photo := domain.Photo{ + ID: photoID, + AccessHash: randomID(), + FileReference: randomFileReference(), + Date: int(time.Now().Unix()), + DCID: s.dc, + Sizes: sizes, + } + if err := s.media.PutPhoto(ctx, photo); err != nil { + return domain.Photo{}, err + } + return photo, nil +} + +func validateAvatarMarkupSize(size domain.PhotoSize) error { + switch size.Kind { + case domain.PhotoSizeKindVideoEmojiMarkup: + if size.EmojiID == 0 || len(size.BackgroundColors) == 0 { + return domain.ErrPhotoInvalid + } + case domain.PhotoSizeKindVideoStickerMarkup: + if size.StickerID == 0 || len(size.BackgroundColors) == 0 { + return domain.ErrPhotoInvalid + } + default: + return domain.ErrPhotoInvalid + } + return nil +} + // CreateDocumentFromUpload 把已上传文件组装成 Document(文件/视频/音频/gif/贴纸消息),落 blob + documents。 func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) { - data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) + body, err := s.assembleUploadBlob(ctx, file.OwnerUserID, file.FileID, file.Parts) if err != nil { return domain.Document{}, err } - if len(data) == 0 { + if body.Size == 0 { return domain.Document{}, domain.ErrDocumentInvalid } - objectKey, err := s.blobs.Put(ctx, data) - if err != nil { - return domain.Document{}, err - } + // faststart:MP4 视频若 moov 在末尾,搬到文件头以支持流式播放。普通 Telegram 客户端 + // 上传前会做这步;DrKLO 发 story 视频不转码导致 moov 在末尾,TDesktop 流式播放路径 + // 无法解复用(av_read_frame Invalid data)。不转码、保留原编码(含 HEVC)。 + body = s.maybeFaststartVideoBlob(ctx, spec.MimeType, body) docID := randomID() if err := s.media.PutFileBlob(ctx, domain.FileBlob{ LocationKey: fmt.Sprintf("doc:%d", docID), Backend: domain.MediaBackend(s.blobs.Name()), - ObjectKey: objectKey, - Size: int64(len(data)), + ObjectKey: body.ObjectKey, + Size: body.Size, + SHA256: body.SHA256, MimeType: spec.MimeType, }); err != nil { return domain.Document{}, err @@ -108,28 +230,183 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo FileReference: randomFileReference(), Date: int(time.Now().Unix()), MimeType: spec.MimeType, - Size: int64(len(data)), + Size: body.Size, DCID: s.dc, Attributes: spec.Attributes, } if spec.Thumb != nil { thumbData, err := s.assembleUpload(ctx, spec.Thumb.OwnerUserID, spec.Thumb.FileID, spec.Thumb.Parts) if err == nil && len(thumbData) > 0 { - thumbKey, err := s.blobs.Put(ctx, thumbData) - if err == nil { - w, h := imageDimensions(thumbData, 0, 0) - if err := s.media.PutFileBlob(ctx, domain.FileBlob{ - LocationKey: fmt.Sprintf("doc:%d:m", docID), - Backend: domain.MediaBackend(s.blobs.Name()), - ObjectKey: thumbKey, - Size: int64(len(thumbData)), - MimeType: "image/jpeg", - }); err == nil { - doc.Thumbs = []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "m", W: w, H: h, Size: len(thumbData)}} - } + if thumb, err := s.putDocumentThumb(ctx, docID, thumbData); err == nil { + doc.Thumbs = []domain.PhotoSize{thumb} } } } + if len(doc.Thumbs) == 0 { + if thumb, ok := s.generateVideoThumbFallbackFromBlob(ctx, docID, body.ObjectKey, body.Size, spec); ok { + doc.Thumbs = []domain.PhotoSize{thumb} + } + } + if err := s.media.PutDocument(ctx, doc); err != nil { + return domain.Document{}, err + } + if err := s.cleanupUploadParts(ctx, file.OwnerUserID, file.FileID); err != nil { + s.log.Warn("cleanup assembled document upload parts failed", + zap.Int64("owner_user_id", file.OwnerUserID), + zap.Int64("file_id", file.FileID), + zap.Int64("document_id", docID), + zap.Error(err)) + } + return doc, nil +} + +// maxFaststartBytes 限制 faststart 一次性载入内存的视频大小;超过则跳过(流式 faststart +// 复杂度高,超大视频是边角)。与缩略图回退路径同样全量读 blob,内存模式无新增。 +const maxFaststartBytes = 200 << 20 + +// faststartVideoMimes 是会用 moov/mdat 结构、值得尝试 faststart 的容器 mime。 +// 其它 video/*(webm 等)结构不同,faststartMP4 也会自检后 no-op,故不在此列以免白读 blob。 +var faststartVideoMimes = map[string]bool{ + "video/mp4": true, + "video/quicktime": true, + "video/x-m4v": true, +} + +// maybeFaststartVideoBlob 对 MP4/MOV 视频上传尝试 faststart。性能考量: +// 1. 先只读顶层 box 头(廉价探测)判断是否需要——绝大多数客户端上传的视频本就已 faststart, +// 此时只发生几次 16 字节读,不读整段媒体。 +// 2. 仅 moov 在末尾时才重写;且优先走流式(仅 ftyp+moov 进内存,mdat 大块分块流式拼接), +// 不把整段视频 2× 驻留内存。moov 非末尾的罕见排布回退到全量重排。 +// 任何不适用/失败都返回原 body,绝不让上传失败或损坏数据。 +func (s *Service) maybeFaststartVideoBlob(ctx context.Context, mimeType string, body assembledUploadBlob) assembledUploadBlob { + if !faststartVideoMimes[strings.ToLower(strings.TrimSpace(mimeType))] { + return body + } + if body.Size <= 0 || body.Size > maxFaststartBytes { + return body + } + readAt := func(off, n int64) ([]byte, error) { + data, _, err := s.blobs.GetRange(ctx, body.ObjectKey, off, n) + return data, err + } + layout, ok := inspectMP4Layout(body.Size, readAt) + if !ok || !layout.needsFaststart { + return body // 非 MP4 / 已 faststart —— 未读整段媒体 + } + + var reader io.Reader + if layout.moovIsLast { + // 流式重写:只把 ftyp + moov 读进内存并 patch 偏移,mdat 区段分块流式。 + ftyp, e1 := readAt(layout.ftypStart, layout.ftypEnd-layout.ftypStart) + moov, e2 := readAt(layout.moovStart, layout.moovEnd-layout.moovStart) + moovSize := layout.moovEnd - layout.moovStart + if e1 != nil || e2 != nil || + int64(len(ftyp)) != layout.ftypEnd-layout.ftypStart || + int64(len(moov)) != moovSize || + !patchChunkOffsets(moov, moovSize) { + return body + } + mid := &blobRangeReader{ctx: ctx, blobs: s.blobs, key: body.ObjectKey, pos: layout.ftypEnd, end: layout.moovStart} + reader = io.MultiReader(bytes.NewReader(ftyp), bytes.NewReader(moov), mid) + } else { + // 罕见:moov 非末尾。回退到全量读 + 重排(已测函数)。 + data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size) + if err != nil || total != body.Size || int64(len(data)) != body.Size { + return body + } + out, changed := faststartMP4(data) + if !changed { + return body + } + reader = bytes.NewReader(out) + } + + key, size, sum, err := s.blobs.PutReader(ctx, reader) + if err != nil { + s.log.Warn("faststart re-store failed; keeping original blob", + zap.String("mime", mimeType), zap.Int64("size", body.Size), zap.Error(err)) + return body + } + if size != body.Size { + // faststart 守恒大小;不等说明流式拼接出错,丢弃新 blob 用原 blob 兜底。 + s.log.Warn("faststart size mismatch; keeping original blob", + zap.Int64("orig", body.Size), zap.Int64("got", size)) + return body + } + s.log.Info("faststart applied to uploaded video", + zap.String("mime", mimeType), zap.Int64("size", size)) + return assembledUploadBlob{ObjectKey: key, Size: size, SHA256: sum} +} + +// blobRangeReader 把 blob 的 [pos, end) 区段按 io.Reader 调用方给的缓冲大小分块流式读出, +// 用于 faststart 流式拼接 mdat,避免整段媒体驻留内存。 +type blobRangeReader struct { + ctx context.Context + blobs BlobBackend + key string + pos int64 + end int64 +} + +func (r *blobRangeReader) Read(p []byte) (int, error) { + if r.pos >= r.end { + return 0, io.EOF + } + want := r.end - r.pos + if want > int64(len(p)) { + want = int64(len(p)) + } + data, _, err := r.blobs.GetRange(r.ctx, r.key, r.pos, want) + if err != nil { + return 0, err + } + if len(data) == 0 { + return 0, io.ErrUnexpectedEOF // 区段内不应读到空,避免静默截断 + } + n := copy(p, data) + r.pos += int64(n) + return n, nil +} + +// CreateDocumentFromBytes stores already-fetched bytes as a message Document. +func (s *Service) CreateDocumentFromBytes(ctx context.Context, data []byte, spec domain.DocumentSpec) (domain.Document, error) { + if len(data) == 0 { + return domain.Document{}, domain.ErrDocumentInvalid + } + if strings.TrimSpace(spec.MimeType) == "" { + spec.MimeType = "application/octet-stream" + } + objectKey, size, sum, err := s.blobs.PutReader(ctx, bytes.NewReader(data)) + if err != nil { + return domain.Document{}, err + } + if size == 0 { + return domain.Document{}, domain.ErrDocumentInvalid + } + docID := randomID() + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d", docID), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: size, + SHA256: sum, + MimeType: spec.MimeType, + }); err != nil { + return domain.Document{}, err + } + doc := domain.Document{ + ID: docID, + AccessHash: randomID(), + FileReference: randomFileReference(), + Date: int(time.Now().Unix()), + MimeType: spec.MimeType, + Size: size, + DCID: s.dc, + Attributes: spec.Attributes, + } + if thumb, ok := s.generateVideoThumbFallbackFromBlob(ctx, docID, objectKey, size, spec); ok { + doc.Thumbs = []domain.PhotoSize{thumb} + } if err := s.media.PutDocument(ctx, doc); err != nil { return domain.Document{}, err } @@ -177,19 +454,7 @@ func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerTyp // GetProfilePhotosKind returns profile/fallback photo history. func (s *Service) GetProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) { - ids, total, err := s.media.ListProfilePhotosKind(ctx, ownerType, ownerID, kind, offset, limit, maxID) - if err != nil { - return nil, 0, err - } - photos := make([]domain.Photo, 0, len(ids)) - for _, id := range ids { - if p, ok, err := s.media.GetPhoto(ctx, id); err != nil { - return nil, 0, err - } else if ok { - photos = append(photos, p) - } - } - return photos, total, nil + return s.media.ListProfilePhotoDetailsKind(ctx, ownerType, ownerID, kind, offset, limit, maxID) } // DeleteProfilePhotos 停用指定头像,返回成功停用数量。 @@ -208,24 +473,11 @@ func (s *Service) DeleteProfilePhotosKind(ctx context.Context, ownerType domain. // createPhoto 把字节落 blob(每个尺寸一个 location_key,指向同一内容)并写 photos 表。 func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSizeSpec) (domain.Photo, error) { - objectKey, err := s.blobs.Put(ctx, data) + photoID := randomID() + sizes, err := s.putPhotoStaticSizes(ctx, photoID, data, specs) if err != nil { return domain.Photo{}, err } - photoID := randomID() - sizes := make([]domain.PhotoSize, 0, len(specs)) - for _, spec := range specs { - if err := s.media.PutFileBlob(ctx, domain.FileBlob{ - LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type), - Backend: domain.MediaBackend(s.blobs.Name()), - ObjectKey: objectKey, - Size: int64(len(data)), - MimeType: "image/jpeg", - }); err != nil { - return domain.Photo{}, err - } - sizes = append(sizes, domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: spec.Type, W: spec.W, H: spec.H, Size: len(data)}) - } photo := domain.Photo{ ID: photoID, AccessHash: randomID(), @@ -240,6 +492,116 @@ func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSiz return photo, nil } +func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) { + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return nil, err + } + mimeType := imageMimeType(data) + sizes := make([]domain.PhotoSize, 0, len(specs)) + for _, spec := range specs { + blob := domain.FileBlob{ + LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + MimeType: mimeType, + } + if err := s.media.PutFileBlob(ctx, blob); err != nil { + return nil, err + } + s.blobCache.put(blob.LocationKey, blob) + sizes = append(sizes, domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: spec.Type, W: spec.W, H: spec.H, Size: len(data)}) + } + s.prewarmSmallBlob(objectKey, data) + return sizes, nil +} + +func (s *Service) putDocumentThumb(ctx context.Context, docID int64, thumbData []byte) (domain.PhotoSize, error) { + if len(thumbData) == 0 { + return domain.PhotoSize{}, fmt.Errorf("empty document thumbnail") + } + thumbKey, err := s.blobs.Put(ctx, thumbData) + if err != nil { + return domain.PhotoSize{}, err + } + w, h := imageDimensions(thumbData, 0, 0) + blob := domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d:m", docID), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: thumbKey, + Size: int64(len(thumbData)), + MimeType: "image/jpeg", + } + if err := s.media.PutFileBlob(ctx, blob); err != nil { + return domain.PhotoSize{}, err + } + s.blobCache.put(blob.LocationKey, blob) + s.prewarmSmallBlob(thumbKey, thumbData) + return domain.PhotoSize{Kind: domain.PhotoSizeKindDefault, Type: "m", W: w, H: h, Size: len(thumbData)}, nil +} + +func (s *Service) generateVideoThumbFallback(ctx context.Context, docID int64, data []byte, spec domain.DocumentSpec) (domain.PhotoSize, bool) { + if s.thumbs == nil || !documentSpecIsVideo(spec) || len(data) > videoThumbnailMaxInputBytes { + return domain.PhotoSize{}, false + } + thumbData, err := s.thumbs.Extract(ctx, data, spec.MimeType) + if err != nil { + s.log.Warn("server-side video thumbnail fallback failed", + zap.Int64("document_id", docID), + zap.String("mime_type", spec.MimeType), + zap.Int64("bytes", int64(len(data))), + zap.Error(err)) + return domain.PhotoSize{}, false + } + thumb, err := s.putDocumentThumb(ctx, docID, thumbData) + if err != nil { + s.log.Warn("store server-side video thumbnail failed", + zap.Int64("document_id", docID), + zap.String("mime_type", spec.MimeType), + zap.Int64("thumb_bytes", int64(len(thumbData))), + zap.Error(err)) + return domain.PhotoSize{}, false + } + return thumb, true +} + +func (s *Service) generateVideoThumbFallbackFromBlob(ctx context.Context, docID int64, objectKey string, size int64, spec domain.DocumentSpec) (domain.PhotoSize, bool) { + if s.thumbs == nil || !documentSpecIsVideo(spec) || size > videoThumbnailMaxInputBytes { + return domain.PhotoSize{}, false + } + data, total, err := s.blobs.GetRange(ctx, objectKey, 0, size) + if err != nil { + s.log.Warn("read video blob for thumbnail fallback failed", + zap.Int64("document_id", docID), + zap.String("mime_type", spec.MimeType), + zap.Int64("bytes", size), + zap.Error(err)) + return domain.PhotoSize{}, false + } + if int64(len(data)) != total || total != size { + s.log.Warn("video blob size mismatch for thumbnail fallback", + zap.Int64("document_id", docID), + zap.Int64("expected_size", size), + zap.Int64("total_size", total), + zap.Int("read_bytes", len(data))) + return domain.PhotoSize{}, false + } + return s.generateVideoThumbFallback(ctx, docID, data, spec) +} + +func documentSpecIsVideo(spec domain.DocumentSpec) bool { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(spec.MimeType)), "video/") { + return true + } + for _, attr := range spec.Attributes { + if attr.Kind == domain.DocAttrVideo { + return true + } + } + return false +} + type photoSizeSpec struct { Type string W int @@ -286,6 +648,281 @@ func scaleDown(w, h, max int) (int, int) { return max * w / h, max } +const ( + avatarStillSize = 640 + avatarMarkupScale = 0.70 + avatarMarkupMaxSourceBytes = 2 << 20 // emoji/sticker thumb 小对象保护线。 +) + +// avatarVideoStill 生成动画头像的静态尺寸字节:优先抽取上传视频首帧——动画头像 +// (emoji/sticker 构造器或自选视频)的首帧就是用户在客户端看到的真实画面(彩色 +// emoji、圆角、布局都一致);抽帧不可用时回退到按 markup 服务端合成。 +func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob, extraSizes []domain.PhotoSize) []byte { + if s.thumbs != nil && body.Size > 0 && body.Size <= videoThumbnailMaxInputBytes { + data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size) + if err == nil && int64(len(data)) == total && total == body.Size { + if thumb, err := s.thumbs.Extract(ctx, data, "video/mp4"); err == nil && len(thumb) > 0 { + return thumb + } else if err != nil { + s.log.Debug("extract avatar video first frame failed, falling back to composed still", + zap.String("object_key", body.ObjectKey), + zap.Int64("bytes", body.Size), + zap.Error(err)) + } + } else if err != nil { + s.log.Warn("read avatar video blob for still failed", + zap.String("object_key", body.ObjectKey), + zap.Int64("bytes", body.Size), + zap.Error(err)) + } + } + return s.generatedAvatarStill(ctx, avatarStillMarkup(extraSizes)) +} + +func (s *Service) generatedAvatarStill(ctx context.Context, markup domain.PhotoSize) []byte { + img := generatedAvatarBackground(markup.BackgroundColors) + if overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup); ok { + drawAvatarMarkup(img, overlay, tintWhite) + } + return encodeAvatarPNG(img) +} + +func generatedAvatarBackground(colors []int) *image.RGBA { + if len(colors) == 0 { + colors = []int{0x5b8def, 0x53c6a4} + } + first := rgbColor(colors[0]) + last := first + if len(colors) > 1 { + last = rgbColor(colors[len(colors)-1]) + } + img := image.NewRGBA(image.Rect(0, 0, avatarStillSize, avatarStillSize)) + for y := 0; y < avatarStillSize; y++ { + t := float64(y) / float64(avatarStillSize-1) + row := lerpColor(first, last, t) + for x := 0; x < avatarStillSize; x++ { + img.SetRGBA(x, y, row) + } + } + return img +} + +func encodeAvatarPNG(img image.Image) []byte { + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes() +} + +// avatarMarkupOverlay 加载 markup 引用文档的静态缩略图作为合成贴图。第二个返回值 +// 表示是否染白:仅 text_color 的 custom emoji(单色、由客户端按文字色适配渲染, +// 头像背景上的约定呈现是白色剪影)需要染白,普通彩色 emoji / sticker 保留原色。 +func (s *Service) avatarMarkupOverlay(ctx context.Context, markup domain.PhotoSize) (image.Image, bool, bool) { + if s == nil || s.media == nil { + return nil, false, false + } + docID := int64(0) + switch markup.Kind { + case domain.PhotoSizeKindVideoEmojiMarkup: + docID = markup.EmojiID + case domain.PhotoSizeKindVideoStickerMarkup: + docID = markup.StickerID + default: + return nil, false, false + } + if docID == 0 { + return nil, false, false + } + doc, found, err := s.media.GetDocument(ctx, docID) + if err != nil { + s.log.Warn("load avatar markup document failed", zap.Int64("document_id", docID), zap.Error(err)) + return nil, false, false + } + if !found { + return nil, false, false + } + data, ok := s.avatarMarkupBytes(ctx, doc) + if !ok { + return nil, false, false + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + s.log.Debug("decode avatar markup thumbnail failed", + zap.Int64("document_id", doc.ID), + zap.String("mime_type", doc.MimeType), + zap.Error(err)) + return nil, false, false + } + return img, documentIsTextColorEmoji(doc), true +} + +// documentIsTextColorEmoji 判断文档是否为声明 text_color 的 custom emoji。 +func documentIsTextColorEmoji(doc domain.Document) bool { + for _, attr := range doc.Attributes { + if attr.Kind == domain.DocAttrCustomEmoji { + return attr.TextColor + } + } + return false +} + +func (s *Service) avatarMarkupBytes(ctx context.Context, doc domain.Document) ([]byte, bool) { + var best []byte + bestScore := -1 + for _, thumb := range doc.Thumbs { + score := avatarThumbScore(thumb) + if score <= bestScore { + continue + } + switch thumb.Kind { + case domain.PhotoSizeKindCached: + if len(thumb.Bytes) == 0 || len(thumb.Bytes) > avatarMarkupMaxSourceBytes { + continue + } + best = append([]byte(nil), thumb.Bytes...) + bestScore = score + case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive: + if thumb.Type == "" || thumb.Size <= 0 || thumb.Size > avatarMarkupMaxSourceBytes { + continue + } + data, ok := s.readSmallBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type), int64(thumb.Size)) + if !ok { + continue + } + best = data + bestScore = score + } + } + if len(best) > 0 { + return best, true + } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(doc.MimeType)), "image/") && + doc.Size > 0 && doc.Size <= avatarMarkupMaxSourceBytes { + return s.readSmallBlob(ctx, fmt.Sprintf("doc:%d", doc.ID), doc.Size) + } + return nil, false +} + +func (s *Service) readSmallBlob(ctx context.Context, locationKey string, expectedSize int64) ([]byte, bool) { + if expectedSize <= 0 || expectedSize > avatarMarkupMaxSourceBytes { + return nil, false + } + blob, found, err := s.media.GetFileBlob(ctx, locationKey) + if err != nil { + s.log.Warn("load avatar markup blob metadata failed", + zap.String("location_key", locationKey), + zap.Error(err)) + return nil, false + } + if !found || blob.Size <= 0 || blob.Size > avatarMarkupMaxSourceBytes { + return nil, false + } + data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blob.Size) + if err != nil { + s.log.Warn("read avatar markup blob failed", + zap.String("location_key", locationKey), + zap.String("object_key", blob.ObjectKey), + zap.Error(err)) + return nil, false + } + if int64(len(data)) != total || total != blob.Size { + return nil, false + } + return data, true +} + +func avatarThumbScore(size domain.PhotoSize) int { + if size.W > 0 && size.H > 0 { + return size.W * size.H + } + if size.Size > 0 { + return size.Size + } + return len(size.Bytes) +} + +// drawAvatarMarkup 把贴图缩放后居中画到背景上。默认保留贴图原色(彩色 emoji / +// sticker 头像),仅 tintWhite 时染成白色剪影。 +func drawAvatarMarkup(dst *image.RGBA, src image.Image, tintWhite bool) { + srcBounds := src.Bounds() + sw, sh := srcBounds.Dx(), srcBounds.Dy() + if sw <= 0 || sh <= 0 { + return + } + max := int(math.Round(float64(dst.Bounds().Dx()) * avatarMarkupScale)) + if max <= 0 { + return + } + scale := math.Min(float64(max)/float64(sw), float64(max)/float64(sh)) + w := maxInt(1, int(math.Round(float64(sw)*scale))) + h := maxInt(1, int(math.Round(float64(sh)*scale))) + rect := image.Rect( + (dst.Bounds().Dx()-w)/2, + (dst.Bounds().Dy()-h)/2, + (dst.Bounds().Dx()+w)/2, + (dst.Bounds().Dy()+h)/2, + ) + scaled := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy())) + xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), src, srcBounds, xdraw.Src, nil) + if tintWhite { + tintWhitePremultiplied(scaled) + } + stddraw.Draw(dst, rect, scaled, image.Point{}, stddraw.Over) +} + +// tintWhitePremultiplied 把贴图就地染成白色剪影(保留 alpha 形状)。image.RGBA 是 +// alpha 预乘存储,分量必须满足 R,G,B ≤ A:若写入 R=G=B=255 而 A<255 的非法值, +// draw.Over 合成会算术溢出回绕,凡 alpha 不恰为 255 的像素整片输出近黑色。 +func tintWhitePremultiplied(img *image.RGBA) { + for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { + for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { + _, _, _, a := img.At(x, y).RGBA() + v := uint8(a >> 8) + img.SetRGBA(x, y, color.RGBA{R: v, G: v, B: v, A: v}) + } + } +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func rgbColor(v int) color.RGBA { + u := uint32(v) + return color.RGBA{R: uint8(u >> 16), G: uint8(u >> 8), B: uint8(u), A: 255} +} + +func lerpColor(a, b color.RGBA, t float64) color.RGBA { + lerp := func(x, y uint8) uint8 { + return uint8(float64(x)*(1-t) + float64(y)*t) + } + return color.RGBA{R: lerp(a.R, b.R), G: lerp(a.G, b.G), B: lerp(a.B, b.B), A: 255} +} + +func imageMimeType(data []byte) string { + if len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) { + return "image/png" + } + if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff { + return "image/jpeg" + } + return "application/octet-stream" +} + +func avatarStillMarkup(sizes []domain.PhotoSize) domain.PhotoSize { + for _, size := range sizes { + if size.Kind == domain.PhotoSizeKindVideoEmojiMarkup || size.Kind == domain.PhotoSizeKindVideoStickerMarkup { + return size + } + if len(size.BackgroundColors) > 0 { + return domain.PhotoSize{BackgroundColors: append([]int(nil), size.BackgroundColors...)} + } + } + return domain.PhotoSize{} +} + func randomID() int64 { var b [8]byte _, _ = rand.Read(b[:]) diff --git a/internal/app/files/photos_test.go b/internal/app/files/photos_test.go new file mode 100644 index 00000000..6a52edd8 --- /dev/null +++ b/internal/app/files/photos_test.go @@ -0,0 +1,466 @@ +package files + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" + + "telesrv/internal/domain" +) + +func TestCreateDocumentFromUploadGeneratesVideoThumbWhenMissing(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + thumbBytes := testJPEG(t, 4, 2) + thumbnailer := &fakeVideoThumbnailer{thumb: thumbBytes} + svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer)) + + if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("fake-video-bytes")); err != nil { + t.Fatalf("SaveFilePart: %v", err) + } + doc, err := svc.CreateDocumentFromUpload(ctx, + domain.UploadedFileRef{OwnerUserID: 10, FileID: 100, Parts: 1, Name: "video.mp4"}, + domain.DocumentSpec{ + MimeType: "video/mp4", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 1}}, + }) + if err != nil { + t.Fatalf("CreateDocumentFromUpload: %v", err) + } + if thumbnailer.calls != 1 { + t.Fatalf("thumbnailer calls = %d, want 1", thumbnailer.calls) + } + if len(doc.Thumbs) != 1 { + t.Fatalf("thumbs = %+v, want one generated thumbnail", doc.Thumbs) + } + if got := doc.Thumbs[0]; got.Type != "m" || got.W != 4 || got.H != 2 || got.Size != len(thumbBytes) { + t.Fatalf("thumb = %+v, want m 4x2 size=%d", got, len(thumbBytes)) + } + blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:m", doc.ID)) + if err != nil || !ok { + t.Fatalf("generated thumb blob ok=%v err=%v", ok, err) + } + gotBytes, err := blobs.Get(ctx, blob.ObjectKey) + if err != nil { + t.Fatalf("read generated thumb blob: %v", err) + } + if !bytes.Equal(gotBytes, thumbBytes) { + t.Fatalf("generated thumb bytes mismatch") + } +} + +func TestCreateDocumentFromUploadKeepsClientThumb(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + thumbnailer := &fakeVideoThumbnailer{err: errors.New("should not be called")} + svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer)) + clientThumb := testJPEG(t, 3, 5) + + if _, err := svc.SaveFilePart(ctx, 10, 200, 0, []byte("fake-video-bytes")); err != nil { + t.Fatalf("SaveFilePart video: %v", err) + } + if _, err := svc.SaveFilePart(ctx, 10, 201, 0, clientThumb); err != nil { + t.Fatalf("SaveFilePart thumb: %v", err) + } + doc, err := svc.CreateDocumentFromUpload(ctx, + domain.UploadedFileRef{OwnerUserID: 10, FileID: 200, Parts: 1, Name: "video.mp4"}, + domain.DocumentSpec{ + MimeType: "video/mp4", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 1}}, + Thumb: &domain.UploadedFileRef{OwnerUserID: 10, FileID: 201, Parts: 1, Name: "thumb.jpg"}, + }) + if err != nil { + t.Fatalf("CreateDocumentFromUpload: %v", err) + } + if thumbnailer.calls != 0 { + t.Fatalf("thumbnailer calls = %d, want 0 when client thumb is available", thumbnailer.calls) + } + if len(doc.Thumbs) != 1 { + t.Fatalf("thumbs = %+v, want client thumbnail", doc.Thumbs) + } + if got := doc.Thumbs[0]; got.W != 3 || got.H != 5 || got.Size != len(clientThumb) { + t.Fatalf("thumb = %+v, want client thumb dimensions", got) + } +} + +func TestCreateDocumentFromUploadWithoutThumbnailerDoesNotBlockVideo(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(media, blobs, 2, WithVideoThumbnailer(nil)) + + if _, err := svc.SaveFilePart(ctx, 10, 300, 0, []byte("fake-video-bytes")); err != nil { + t.Fatalf("SaveFilePart: %v", err) + } + doc, err := svc.CreateDocumentFromUpload(ctx, + domain.UploadedFileRef{OwnerUserID: 10, FileID: 300, Parts: 1, Name: "video.mp4"}, + domain.DocumentSpec{ + MimeType: "video/mp4", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, W: 640, H: 360, Duration: 1}}, + }) + if err != nil { + t.Fatalf("CreateDocumentFromUpload without thumbnailer: %v", err) + } + if len(doc.Thumbs) != 0 { + t.Fatalf("thumbs = %+v, want no fallback thumbnail when thumbnailer is disabled", doc.Thumbs) + } +} + +func TestCreatePhotoFromBytesStoresDownloadableMessageSizes(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(media, blobs, 2) + data := testJPEG(t, 16, 9) + + photo, err := svc.CreatePhotoFromBytes(ctx, data) + if err != nil { + t.Fatalf("CreatePhotoFromBytes: %v", err) + } + if photo.ID == 0 || photo.AccessHash == 0 || photo.DCID != 2 || len(photo.Sizes) != 2 { + t.Fatalf("photo = %+v, want stored photo with message sizes", photo) + } + blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("photo:%d:x", photo.ID)) + if err != nil || !ok { + t.Fatalf("photo blob ok=%v err=%v", ok, err) + } + got, err := blobs.Get(ctx, blob.ObjectKey) + if err != nil { + t.Fatalf("read photo blob: %v", err) + } + if !bytes.Equal(got, data) { + t.Fatalf("photo blob bytes mismatch") + } +} + +func TestCreateDocumentFromBytesStoresBodyAndAttributes(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(media, blobs, 2, WithVideoThumbnailer(nil)) + data := []byte("inline document body") + spec := domain.DocumentSpec{ + MimeType: "application/pdf", + Attributes: []domain.DocumentAttribute{ + {Kind: domain.DocAttrFilename, FileName: "inline.pdf"}, + }, + } + + doc, err := svc.CreateDocumentFromBytes(ctx, data, spec) + if err != nil { + t.Fatalf("CreateDocumentFromBytes: %v", err) + } + if doc.ID == 0 || doc.AccessHash == 0 || doc.Size != int64(len(data)) || doc.MimeType != "application/pdf" || len(doc.Attributes) != 1 { + t.Fatalf("document = %+v, want stored document body and attributes", doc) + } + blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d", doc.ID)) + if err != nil || !ok { + t.Fatalf("document blob ok=%v err=%v", ok, err) + } + got, err := blobs.Get(ctx, blob.ObjectKey) + if err != nil { + t.Fatalf("read document blob: %v", err) + } + if !bytes.Equal(got, data) || blob.MimeType != "application/pdf" { + t.Fatalf("document blob mime=%q bytes=%q", blob.MimeType, got) + } +} + +func TestCreateAvatarMarkupGeneratesDownloadableStaticSizes(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(media, blobs, 2) + + photo, err := svc.CreateAvatarMarkup(ctx, domain.PhotoSize{ + Kind: domain.PhotoSizeKindVideoEmojiMarkup, + EmojiID: 99, + BackgroundColors: []int{0xff3b30, 0x34c759}, + }) + if err != nil { + t.Fatalf("CreateAvatarMarkup: %v", err) + } + if !domain.PhotoHasVideo(photo.Sizes) { + t.Fatalf("avatar markup photo sizes = %+v, want video markup", photo.Sizes) + } + assertDownloadableAvatarSize(t, svc, photo.ID, "a") + assertDownloadableAvatarSize(t, svc, photo.ID, "c") +} + +// TestCreateAvatarMarkupComposesEmojiThumbIntoStaticSizes 守护两个行为: +// 1. 普通彩色 emoji 合成进静态头像时保留原色(不得染白/变黑); +// 2. 贴图含非满 alpha 像素(抗锯齿常态)时不得因预乘溢出整片变黑——曾因把 +// R=G=B=255、A<255 的非法预乘值喂给 draw.Over 溢出回绕,emoji 输出近黑色。 +func TestCreateAvatarMarkupComposesEmojiThumbIntoStaticSizes(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + const emojiID = int64(99) + if err := media.PutDocument(ctx, domain.Document{ + ID: emojiID, + MimeType: "application/x-tgsticker", + Thumbs: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindCached, + Type: "m", + W: 64, + H: 64, + Bytes: testTransparentThumbPNG(t), + }}, + }); err != nil { + t.Fatalf("PutDocument: %v", err) + } + svc := NewService(media, blobs, 2) + + photo, err := svc.CreateAvatarMarkup(ctx, domain.PhotoSize{ + Kind: domain.PhotoSizeKindVideoEmojiMarkup, + EmojiID: emojiID, + BackgroundColors: []int{0x112233, 0x445566}, + }) + if err != nil { + t.Fatalf("CreateAvatarMarkup: %v", err) + } + + r, g, b, a := avatarStillCenterPixel(t, svc, photo.ID) + if a < 250 { + t.Fatalf("center pixel alpha=%d, want opaque still", a) + } + if r < 200 || g > 90 || b > 90 { + t.Fatalf("center pixel rgb=(%d,%d,%d), want red emoji color preserved", r, g, b) + } +} + +// TestCreateAvatarMarkupTintsTextColorEmojiWhite 守护 text_color custom emoji 的 +// 白色剪影呈现:染色必须写合法预乘值(R=G=B=A),非满 alpha 像素不得溢出变黑。 +func TestCreateAvatarMarkupTintsTextColorEmojiWhite(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + const emojiID = int64(120) + if err := media.PutDocument(ctx, domain.Document{ + ID: emojiID, + MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{{ + Kind: domain.DocAttrCustomEmoji, + TextColor: true, + }}, + Thumbs: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindCached, + Type: "m", + W: 64, + H: 64, + Bytes: testTransparentThumbPNG(t), + }}, + }); err != nil { + t.Fatalf("PutDocument: %v", err) + } + svc := NewService(media, blobs, 2) + + photo, err := svc.CreateAvatarMarkup(ctx, domain.PhotoSize{ + Kind: domain.PhotoSizeKindVideoEmojiMarkup, + EmojiID: emojiID, + BackgroundColors: []int{0x112233, 0x445566}, + }) + if err != nil { + t.Fatalf("CreateAvatarMarkup: %v", err) + } + + r, g, b, _ := avatarStillCenterPixel(t, svc, photo.ID) + if r < 230 || g < 230 || b < 230 { + t.Fatalf("center pixel rgb=(%d,%d,%d), want white silhouette for text_color emoji", r, g, b) + } +} + +func avatarStillCenterPixel(t *testing.T, svc *Service, photoID int64) (r, g, b, a uint32) { + t.Helper() + chunk, found, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{ + LocationKey: fmt.Sprintf("photo:%d:c", photoID), + Offset: 0, + Limit: 1 << 20, + }) + if err != nil || !found { + t.Fatalf("avatar c blob found=%v err=%v", found, err) + } + img, _, err := image.Decode(bytes.NewReader(chunk.Bytes)) + if err != nil { + t.Fatalf("decode avatar still: %v", err) + } + r, g, b, a = img.At(avatarStillSize/2, avatarStillSize/2).RGBA() + return r >> 8, g >> 8, b >> 8, a >> 8 +} + +func TestCreateAvatarVideoMarkupGeneratesDownloadableStaticSizes(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(media, blobs, 2) + + if _, err := svc.SaveFilePart(ctx, 10, 400, 0, []byte("fake-profile-video")); err != nil { + t.Fatalf("SaveFilePart: %v", err) + } + photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx, + domain.UploadedFileRef{OwnerUserID: 10, FileID: 400, Parts: 1, Name: "avatar.mp4"}, + 0.25, + domain.PhotoSize{ + Kind: domain.PhotoSizeKindVideoEmojiMarkup, + EmojiID: 100, + BackgroundColors: []int{0x536dfe, 0x26a69a}, + }) + if err != nil { + t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err) + } + assertDownloadableAvatarSize(t, svc, photo.ID, "a") + assertDownloadableAvatarSize(t, svc, photo.ID, "c") + chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{ + LocationKey: fmt.Sprintf("photo:%d:u", photo.ID), + Offset: 0, + Limit: 1024, + }) + if err != nil || !found { + t.Fatalf("video avatar blob found=%v err=%v", found, err) + } + if string(chunk.Bytes) != "fake-profile-video" { + t.Fatalf("video avatar bytes = %q", chunk.Bytes) + } +} + +// TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame 守护动画头像静态尺寸优先取 +// 上传视频首帧(客户端真实渲染画面),而不是服务端合成的近似 still。 +func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + frame := testJPEG(t, 640, 640) + thumbnailer := &fakeVideoThumbnailer{thumb: frame} + svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer)) + + if _, err := svc.SaveFilePart(ctx, 10, 500, 0, []byte("fake-profile-video")); err != nil { + t.Fatalf("SaveFilePart: %v", err) + } + photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx, + domain.UploadedFileRef{OwnerUserID: 10, FileID: 500, Parts: 1, Name: "avatar.mp4"}, + 0, + domain.PhotoSize{ + Kind: domain.PhotoSizeKindVideoEmojiMarkup, + EmojiID: 77, + BackgroundColors: []int{0x112233}, + }) + if err != nil { + t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err) + } + if thumbnailer.calls != 1 { + t.Fatalf("thumbnailer calls = %d, want 1", thumbnailer.calls) + } + chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{ + LocationKey: fmt.Sprintf("photo:%d:a", photo.ID), + Offset: 0, + Limit: 1 << 20, + }) + if err != nil || !found { + t.Fatalf("avatar a blob found=%v err=%v", found, err) + } + if !bytes.Equal(chunk.Bytes, frame) { + t.Fatalf("avatar still bytes != extracted first frame (got %d bytes, want %d)", len(chunk.Bytes), len(frame)) + } + if chunk.MimeType != "image/jpeg" { + t.Fatalf("avatar still mime = %q, want image/jpeg from extracted frame", chunk.MimeType) + } +} + +func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, sizeType string) { + t.Helper() + chunk, found, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{ + LocationKey: fmt.Sprintf("photo:%d:%s", photoID, sizeType), + Offset: 0, + Limit: 1 << 20, + }) + if err != nil || !found { + t.Fatalf("avatar %s blob found=%v err=%v", sizeType, found, err) + } + if len(chunk.Bytes) == 0 || chunk.MimeType != "image/png" { + t.Fatalf("avatar %s chunk mime=%q bytes=%d, want image/png bytes", sizeType, chunk.MimeType, len(chunk.Bytes)) + } +} + +// testTransparentThumbPNG 构造红色方块贴图:周边透明、中心 alpha=250(模拟抗锯齿 +// 的非满 alpha),用于守护预乘溢出回归——溢出代码会把 alpha≠255 的像素整片渲染成黑。 +func testTransparentThumbPNG(t *testing.T) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, 64, 64)) + for y := 8; y < 56; y++ { + for x := 8; x < 56; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: 240, G: 30, B: 30, A: 250}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode test thumb: %v", err) + } + return buf.Bytes() +} + +type fakeVideoThumbnailer struct { + calls int + thumb []byte + err error +} + +func (f *fakeVideoThumbnailer) Extract(context.Context, []byte, string) ([]byte, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return append([]byte(nil), f.thumb...), nil +} + +func testJPEG(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: uint8(40 + x), G: uint8(80 + y), B: 120, A: 255}) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + return buf.Bytes() +} diff --git a/internal/app/files/seed.go b/internal/app/files/seed.go index 8401a893..7d4a973e 100644 --- a/internal/app/files/seed.go +++ b/internal/app/files/seed.go @@ -1,10 +1,14 @@ package files import ( + "bytes" "context" "encoding/hex" "encoding/json" "fmt" + "image" + "image/color" + "image/png" "os" "path/filepath" "regexp" @@ -13,6 +17,8 @@ import ( "strings" "time" + "go.uber.org/zap" + "telesrv/internal/domain" ) @@ -25,6 +31,7 @@ import ( type SeedStats struct { Reactions int StickerSets int + Effects int Documents int Blobs int Skipped bool @@ -38,11 +45,19 @@ func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int return stats, nil } if _, err := os.Stat(root); err != nil { - // 目录不存在:跳过而非失败(开发机可能未放资源)。 + // 目录不存在:跳过而非失败(开发机可能未放资源)。但显式 WARN——否则「配置的 seed 目录 + // 不存在 → 静默不导入贴纸/reaction」会被埋没(DB 已有旧数据时尤其隐蔽,表现为客户端反复 + // 拉取未 seed 的集)。配置 TELESRV_STICKER_SEED_DIR 指向真实导出目录即可。 + if s.log != nil { + s.log.Warn("sticker/reaction seed 目录不存在,跳过媒体种子导入(配置 TELESRV_STICKER_SEED_DIR)", + zap.String("dir", root), zap.Error(err)) + } stats.Skipped = true return stats, nil } + phaseStarted := time.Now() + phaseBefore := stats // reactions if n, err := s.media.CountAvailableReactions(ctx); err != nil { return stats, err @@ -57,28 +72,58 @@ func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int return stats, fmt.Errorf("repair reactions: %w", err) } } + s.logSeedPhase("reactions", phaseStarted, phaseBefore, stats) - // sticker sets(default 系统集 + 常规集) + phaseStarted = time.Now() + phaseBefore = stats + // sticker sets(default 系统集 + 常规集 + emoji 集):始终扫描导出目录以**增量**拾取 + // 新增的 set 目录——importStickerSetDir 跳过内容(hash)未变的已有集,只导入新集/变更集。 + // 这样向已部署(非空 store)的 data/sticker-seed 丢新集后重启即可生效,无需清库重 seed。 + // 仅当检测到旧版缩略图/可渲染预览元数据缺失时 force=true 全量重导修复。 + forceSticker := false if n, err := s.media.CountStickerSets(ctx); err != nil { return stats, err - } else if n == 0 { - if err := s.seedStickerSets(ctx, root, maxRegularSets, &stats); err != nil { - return stats, fmt.Errorf("seed sticker sets: %w", err) - } - } else if stale, err := s.stickerSetDocumentThumbsNeedInlineCache(ctx); err != nil { - return stats, err - } else if stale { - if err := s.seedStickerSets(ctx, root, maxRegularSets, &stats); err != nil { - return stats, fmt.Errorf("repair sticker set thumbs: %w", err) + } else if n > 0 { + stale, err := s.stickerSetDocumentsNeedSeedRepair(ctx) + if err != nil { + return stats, err } + forceSticker = stale } + if err := s.seedStickerSets(ctx, root, maxRegularSets, forceSticker, &stats); err != nil { + return stats, fmt.Errorf("seed sticker sets: %w", err) + } + s.logSeedPhase("sticker_sets", phaseStarted, phaseBefore, stats) - if stats.Reactions == 0 && stats.StickerSets == 0 { + phaseStarted = time.Now() + phaseBefore = stats + // 消息发送特效:全局静态目录,每次启动重建内存 s.effects(文档导入幂等)。 + if err := s.seedEffects(ctx, root, &stats); err != nil { + return stats, fmt.Errorf("seed effects: %w", err) + } + s.logSeedPhase("effects", phaseStarted, phaseBefore, stats) + + if stats.Reactions == 0 && stats.StickerSets == 0 && stats.Effects == 0 { stats.Skipped = true } return stats, nil } +func (s *Service) logSeedPhase(phase string, started time.Time, before, after SeedStats) { + if s.log == nil { + return + } + s.log.Info("媒体种子阶段完成", + zap.String("phase", phase), + zap.Duration("elapsed", time.Since(started)), + zap.Int("reactions", after.Reactions-before.Reactions), + zap.Int("sticker_sets", after.StickerSets-before.StickerSets), + zap.Int("effects", after.Effects-before.Effects), + zap.Int("documents", after.Documents-before.Documents), + zap.Int("blobs", after.Blobs-before.Blobs), + ) +} + // ---- reactions ---- func (s *Service) seedReactions(ctx context.Context, root string, stats *SeedStats) error { @@ -175,7 +220,7 @@ func (s *Service) availableReactionSeedNeedsRepair(ctx context.Context) (bool, e // ---- sticker sets ---- -func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular int, stats *SeedStats) error { +func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular int, force bool, stats *SeedStats) error { // default 系统集:目录名 → system_key。 defaultDir := filepath.Join(root, "telegram_default_stickers_export") order := 0 @@ -190,13 +235,33 @@ func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular i for _, name := range names { systemKey := systemKeyForDefaultSet(name) setDir := filepath.Join(defaultDir, name) - if err := s.importStickerSetDir(ctx, setDir, systemKey, order, stats); err != nil { + if err := s.importStickerSetDir(ctx, setDir, systemKey, order, force, stats); err != nil { return fmt.Errorf("import default set %s: %w", name, err) } order++ } } + // custom-emoji 集(telegram_emoji_export//):不受 maxRegular 限制,按 set_info 的 + // emojis 标志归入 StickerSetKindEmoji(getEmojiStickers/getFeaturedEmojiStickers 下发)。 + emojiDir := filepath.Join(root, "telegram_emoji_export") + if entries, err := os.ReadDir(emojiDir); err == nil { + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + names = append(names, e.Name()) + } + } + sort.Strings(names) + for _, name := range names { + setDir := filepath.Join(emojiDir, name) + if err := s.importStickerSetDir(ctx, setDir, "", order, force, stats); err != nil { + return fmt.Errorf("import emoji set %s: %w", name, err) + } + order++ + } + } + // 常规贴纸集。 regularDir := filepath.Join(root, "telegram_stickers_export") if entries, err := os.ReadDir(regularDir); err == nil { @@ -213,7 +278,7 @@ func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular i break } setDir := filepath.Join(regularDir, name) - if err := s.importStickerSetDir(ctx, setDir, "", order, stats); err != nil { + if err := s.importStickerSetDir(ctx, setDir, "", order, force, stats); err != nil { return fmt.Errorf("import sticker set %s: %w", name, err) } order++ @@ -223,7 +288,7 @@ func (s *Service) seedStickerSets(ctx context.Context, root string, maxRegular i return nil } -func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey string, order int, stats *SeedStats) error { +func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey string, order int, force bool, stats *SeedStats) error { infoPath := filepath.Join(setDir, "set_info.json") raw, err := os.ReadFile(infoPath) if err != nil { @@ -239,6 +304,16 @@ func (s *Service) importStickerSetDir(ctx context.Context, setDir, systemKey str if sj.ID == 0 { return nil } + // 增量 seed:集已存在且内容 hash 未变则跳过(不重读文档/重传 blob)。force 时强制重导 + // (缩略图内联缓存修复路径)。这让 seedStickerSets 可在非空 store 上每次启动安全重扫, + // 仅导入新增/变更集。 + if !force { + if existing, found, err := s.media.GetStickerSetByID(ctx, sj.ID); err != nil { + return err + } else if found && existing.Hash == sj.Hash { + return nil + } + } stickersDir := filepath.Join(setDir, "stickers") index, err := scanSeedDir(stickersDir) if err != nil { @@ -382,6 +457,10 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi } doc.Thumbs = thumbs + if err := s.ensureTGStickerPreviewThumb(ctx, &doc, stats); err != nil { + return domain.Document{}, err + } + if err := s.media.PutDocument(ctx, doc); err != nil { return domain.Document{}, err } @@ -406,6 +485,7 @@ var seedTrailingDigits = regexp.MustCompile(`(\d{6,})`) var seedThumbMarker = regexp.MustCompile(`_thumb\d+_`) const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024 +const seedSyntheticDocumentThumbType = "m" // Exported Telegram resources keep their original id in filenames/JSON, but // telesrv owns the document catalog it serves. Imported high source ids are @@ -413,6 +493,7 @@ const seedInlineCachedDocumentThumbMaxBytes = 32 * 1024 const seedExternalDocumentIDOffset int64 = 4_000_000_000_000_000_000 var seedThumbType = regexp.MustCompile(`PhotoSize_type([a-z])`) +var seedSyntheticTGStickerPreviewThumbPNG = makeSeedSyntheticTGStickerPreviewThumbPNG() func seedDocumentStorageID(sourceID int64) int64 { if sourceID <= 0 { @@ -559,17 +640,6 @@ func seedDocumentAttributes(attrs []seedAttrJSON) []domain.DocumentAttribute { return out } -func seedPhotoSizes(thumbs []seedThumbJSON) []domain.PhotoSize { - out := make([]domain.PhotoSize, 0, len(thumbs)) - for _, t := range thumbs { - ps, _ := seedPhotoSize(t) - if ps.Kind != "" { - out = append(out, ps) - } - } - return out -} - func seedStickerSetPhotoSizes(thumbs []seedThumbJSON) []domain.PhotoSize { out := make([]domain.PhotoSize, 0, len(thumbs)) for _, t := range thumbs { @@ -612,6 +682,63 @@ func seedInlineCachedDocumentThumb(ps domain.PhotoSize, data []byte) domain.Phot return ps } +func (s *Service) ensureTGStickerPreviewThumb(ctx context.Context, doc *domain.Document, stats *SeedStats) error { + if !seedDocumentNeedsSyntheticTGStickerPreviewThumb(*doc) { + return nil + } + if s.blobs == nil { + return fmt.Errorf("blob backend not configured for synthetic sticker preview thumb") + } + data := seedSyntheticTGStickerPreviewThumbPNG + objectKey, err := s.blobs.Put(ctx, data) + if err != nil { + return err + } + if err := s.media.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, seedSyntheticDocumentThumbType), + Backend: domain.MediaBackend(s.blobs.Name()), + ObjectKey: objectKey, + Size: int64(len(data)), + MimeType: "image/png", + }); err != nil { + return err + } + doc.Thumbs = append(doc.Thumbs, domain.PhotoSize{ + Kind: domain.PhotoSizeKindCached, + Type: seedSyntheticDocumentThumbType, + W: 1, + H: 1, + Bytes: append([]byte(nil), data...), + }) + s.prewarmSmallBlob(objectKey, data) + stats.Blobs++ + return nil +} + +func seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc domain.Document) bool { + if doc.MimeType != "application/x-tgsticker" || len(doc.Thumbs) > 0 { + return false + } + return seedDocumentHasAttribute(doc.Attributes, domain.DocAttrCustomEmoji) +} + +func seedDocumentHasAttribute(attrs []domain.DocumentAttribute, kind domain.DocumentAttributeKind) bool { + for _, attr := range attrs { + if attr.Kind == kind { + return true + } + } + return false +} + +func makeSeedSyntheticTGStickerPreviewThumbPNG() []byte { + var buf bytes.Buffer + img := image.NewNRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.NRGBA{}) + _ = png.Encode(&buf, img) + return buf.Bytes() +} + func seedThumbMimeType(data []byte) string { switch { case len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && @@ -628,7 +755,7 @@ func seedThumbMimeType(data []byte) string { } } -func (s *Service) stickerSetDocumentThumbsNeedInlineCache(ctx context.Context) (bool, error) { +func (s *Service) stickerSetDocumentsNeedSeedRepair(ctx context.Context) (bool, error) { var ids []int64 for _, kind := range []domain.StickerSetKind{ domain.StickerSetKindStickers, @@ -644,10 +771,14 @@ func (s *Service) stickerSetDocumentThumbsNeedInlineCache(ctx context.Context) ( ids = append(ids, set.DocumentIDs...) } } - return s.documentsNeedInlineCachedThumbs(ctx, ids) + return s.documentsNeedSeedRepair(ctx, ids) } func (s *Service) documentsNeedInlineCachedThumbs(ctx context.Context, ids []int64) (bool, error) { + return s.documentsNeedSeedRepair(ctx, ids) +} + +func (s *Service) documentsNeedSeedRepair(ctx context.Context, ids []int64) (bool, error) { if len(ids) == 0 { return false, nil } @@ -668,6 +799,9 @@ func (s *Service) documentsNeedInlineCachedThumbs(ctx context.Context, ids []int return false, err } for _, doc := range docs { + if seedDocumentNeedsSyntheticTGStickerPreviewThumb(doc) { + return true, nil + } for _, thumb := range doc.Thumbs { if thumb.Kind == domain.PhotoSizeKindDefault && thumb.Size > 0 && thumb.Size <= seedInlineCachedDocumentThumbMaxBytes { return true, nil diff --git a/internal/app/files/seed_state.go b/internal/app/files/seed_state.go new file mode 100644 index 00000000..cbfe1d73 --- /dev/null +++ b/internal/app/files/seed_state.go @@ -0,0 +1,172 @@ +package files + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "io" + "os" + "path/filepath" + "sort" + + "telesrv/internal/domain" +) + +const ( + seedEffectsStateKey = "files.effects" + seedEffectsStateVersion = "effects-v2" + seedAppearanceStateKey = "files.appearance" + seedAppearanceStateVersion = "appearance-v1" +) + +func (s *Service) seedStateMatches(ctx context.Context, key, want string) (bool, error) { + if want == "" { + return false, nil + } + got, found, err := s.media.GetSeedState(ctx, key) + if err != nil || !found { + return false, err + } + return got == want, nil +} + +func (s *Service) putSeedState(ctx context.Context, key, hash string) error { + if hash == "" { + return nil + } + return s.media.PutSeedState(ctx, key, hash) +} + +func seedStateHash(write func(hash.Hash) error) (string, error) { + h := sha256.New() + if err := write(h); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func writeSeedStateHeader(h io.Writer, version string, dc int) { + _, _ = fmt.Fprintf(h, "version=%s\ndc=%d\n", version, dc) +} + +func writeSeedDirFingerprint(h io.Writer, dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + names := make([]string, 0, len(entries)) + byName := make(map[string]os.DirEntry, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + names = append(names, entry.Name()) + byName[entry.Name()] = entry + } + sort.Strings(names) + for _, name := range names { + info, err := byName[name].Info() + if err != nil { + return err + } + rel := filepath.ToSlash(name) + _, _ = fmt.Fprintf(h, "file=%s\x00size=%d\x00mtime=%d\n", rel, info.Size(), info.ModTime().UnixNano()) + } + return nil +} + +func seedDocumentJSONLocationKeys(dj seedDocumentJSON, index seedDirIndex) []string { + storageID := seedDocumentStorageID(dj.ID) + if storageID == 0 { + return nil + } + keys := make([]string, 0, 1+len(dj.Thumbs)) + if _, ok := index.main[dj.ID]; ok { + keys = append(keys, fmt.Sprintf("doc:%d", storageID)) + } + for _, tj := range dj.Thumbs { + ps, downloadable := seedPhotoSize(tj) + if !downloadable || ps.Type == "" { + continue + } + if _, ok := index.thumb[dj.ID][ps.Type]; ok { + keys = append(keys, fmt.Sprintf("doc:%d:%s", storageID, ps.Type)) + } + } + if seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj) { + keys = append(keys, fmt.Sprintf("doc:%d:%s", storageID, seedSyntheticDocumentThumbType)) + } + return keys +} + +func seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj seedDocumentJSON) bool { + if dj.MimeType != "application/x-tgsticker" || len(dj.Thumbs) > 0 { + return false + } + return seedDocumentHasAttribute(seedDocumentAttributes(dj.Attributes), domain.DocAttrCustomEmoji) +} + +func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumentJSON, index seedDirIndex) (bool, error) { + expected := make(map[int64]seedDocumentJSON, len(docs)) + ids := make([]int64, 0, len(docs)) + locationKeys := make([]string, 0, len(docs)) + seenLocationKeys := make(map[string]struct{}, len(docs)) + for _, dj := range docs { + storageID := seedDocumentStorageID(dj.ID) + if storageID == 0 { + continue + } + if _, ok := expected[storageID]; !ok { + expected[storageID] = dj + ids = append(ids, storageID) + } + for _, key := range seedDocumentJSONLocationKeys(dj, index) { + if _, ok := seenLocationKeys[key]; ok { + continue + } + seenLocationKeys[key] = struct{}{} + locationKeys = append(locationKeys, key) + } + } + if len(ids) == 0 { + return true, nil + } + stored, err := s.media.GetDocuments(ctx, ids) + if err != nil { + return false, err + } + if len(stored) < len(expected) { + return false, nil + } + for _, doc := range stored { + dj, ok := expected[doc.ID] + if !ok { + continue + } + if doc.DCID != s.dc || doc.MimeType != dj.MimeType || doc.Size != dj.Size { + return false, nil + } + delete(expected, doc.ID) + } + if len(expected) > 0 { + return false, nil + } + if len(locationKeys) == 0 { + return true, nil + } + blobs, err := s.media.GetFileBlobs(ctx, locationKeys) + if err != nil { + return false, err + } + for _, key := range locationKeys { + if _, ok := blobs[key]; !ok { + return false, nil + } + } + return true, nil +} diff --git a/internal/app/files/seed_test.go b/internal/app/files/seed_test.go index 01aab05c..e18da80e 100644 --- a/internal/app/files/seed_test.go +++ b/internal/app/files/seed_test.go @@ -2,10 +2,13 @@ package files import ( "context" + "fmt" "os" "path/filepath" + "sort" "sync" "testing" + "time" "telesrv/internal/domain" ) @@ -19,23 +22,96 @@ type fakeMediaStore struct { sets map[int64]domain.StickerSet reactions []domain.AvailableReaction parts map[string][]domain.UploadPart + webPages map[int64]domain.MessageWebPage + seedState map[string]string } func newFakeMediaStore() *fakeMediaStore { return &fakeMediaStore{ - blobs: map[string]domain.FileBlob{}, - docs: map[int64]domain.Document{}, - photos: map[int64]domain.Photo{}, - sets: map[int64]domain.StickerSet{}, - parts: map[string][]domain.UploadPart{}, + blobs: map[string]domain.FileBlob{}, + docs: map[int64]domain.Document{}, + photos: map[int64]domain.Photo{}, + sets: map[int64]domain.StickerSet{}, + parts: map[string][]domain.UploadPart{}, + seedState: map[string]string{}, } } -func (f *fakeMediaStore) SaveFilePart(_ context.Context, _ domain.UploadPart) error { return nil } -func (f *fakeMediaStore) LoadFileParts(_ context.Context, _, _ int64) ([]domain.UploadPart, error) { +func (f *fakeMediaStore) SaveFilePart(_ context.Context, part domain.UploadPart) error { + f.mu.Lock() + defer f.mu.Unlock() + key := fakeUploadPartKey(part.OwnerUserID, part.FileID) + part.SHA256 = append([]byte(nil), part.SHA256...) + parts := f.parts[key] + for i := range parts { + if parts[i].Part == part.Part { + parts[i] = part + f.parts[key] = parts + return nil + } + } + f.parts[key] = append(parts, part) + return nil +} +func (f *fakeMediaStore) UploadPartUsage(_ context.Context, ownerUserID int64) (domain.UploadPartUsage, error) { + f.mu.Lock() + defer f.mu.Unlock() + var usage domain.UploadPartUsage + files := map[int64]struct{}{} + for _, parts := range f.parts { + for _, p := range parts { + if p.OwnerUserID != ownerUserID { + continue + } + usage.Bytes += p.Size + usage.Parts++ + files[p.FileID] = struct{}{} + } + } + usage.Files = len(files) + return usage, nil +} +func (f *fakeMediaStore) UploadPartSlot(_ context.Context, ownerUserID, fileID int64, part int) (domain.UploadPartSlot, error) { + f.mu.Lock() + defer f.mu.Unlock() + parts := f.parts[fakeUploadPartKey(ownerUserID, fileID)] + slot := domain.UploadPartSlot{FileParts: len(parts)} + for _, p := range parts { + if p.Part == part { + slot.ExistingBytes = p.Size + slot.ObjectKey = p.ObjectKey + slot.Found = true + break + } + } + return slot, nil +} +func (f *fakeMediaStore) LoadFileParts(_ context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) { + f.mu.Lock() + defer f.mu.Unlock() + parts := append([]domain.UploadPart(nil), f.parts[fakeUploadPartKey(ownerUserID, fileID)]...) + sort.Slice(parts, func(i, j int) bool { return parts[i].Part < parts[j].Part }) + return parts, nil +} +func (f *fakeMediaStore) DeleteFileParts(_ context.Context, ownerUserID, fileID int64) ([]string, error) { + f.mu.Lock() + defer f.mu.Unlock() + key := fakeUploadPartKey(ownerUserID, fileID) + parts := f.parts[key] + keys := make([]string, 0, len(parts)) + for _, p := range parts { + keys = append(keys, p.ObjectKey) + } + delete(f.parts, key) + return keys, nil +} +func (f *fakeMediaStore) DeleteExpiredUploadParts(_ context.Context, _ time.Time, _ int) ([]string, error) { return nil, nil } -func (f *fakeMediaStore) DeleteFileParts(_ context.Context, _, _ int64) error { return nil } + +func fakeUploadPartKey(ownerUserID, fileID int64) string { + return fmt.Sprintf("%d:%d", ownerUserID, fileID) +} func (f *fakeMediaStore) PutFileBlob(_ context.Context, blob domain.FileBlob) error { f.mu.Lock() @@ -50,6 +126,32 @@ func (f *fakeMediaStore) GetFileBlob(_ context.Context, key string) (domain.File return b, ok, nil } +func (f *fakeMediaStore) GetFileBlobs(_ context.Context, keys []string) (map[string]domain.FileBlob, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make(map[string]domain.FileBlob, len(keys)) + for _, key := range keys { + if b, ok := f.blobs[key]; ok { + out[key] = b + } + } + return out, nil +} + +func (f *fakeMediaStore) GetSeedState(_ context.Context, key string) (string, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + hash, ok := f.seedState[key] + return hash, ok, nil +} + +func (f *fakeMediaStore) PutSeedState(_ context.Context, key, hash string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.seedState[key] = hash + return nil +} + func (f *fakeMediaStore) PutDocument(_ context.Context, doc domain.Document) error { f.mu.Lock() defer f.mu.Unlock() @@ -85,6 +187,21 @@ func (f *fakeMediaStore) GetPhoto(_ context.Context, id int64) (domain.Photo, bo p, ok := f.photos[id] return p, ok, nil } +func (f *fakeMediaStore) PutWebPage(_ context.Context, urlHash int64, page domain.MessageWebPage, _ int) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.webPages == nil { + f.webPages = map[int64]domain.MessageWebPage{} + } + f.webPages[urlHash] = page + return nil +} +func (f *fakeMediaStore) GetWebPageByURLHash(_ context.Context, urlHash int64) (domain.MessageWebPage, int, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + p, ok := f.webPages[urlHash] + return p, 0, ok, nil +} func (f *fakeMediaStore) PutStickerSet(_ context.Context, set domain.StickerSet) error { f.mu.Lock() @@ -156,15 +273,9 @@ func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error) defer f.mu.Unlock() return len(f.reactions), nil } -func (f *fakeMediaStore) AddProfilePhoto(_ context.Context, _ domain.PeerType, _, _ int64, _ int) error { - return nil -} func (f *fakeMediaStore) AddProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ int64, _ int) error { return nil } -func (f *fakeMediaStore) CurrentProfilePhoto(_ context.Context, _ domain.PeerType, _ int64) (int64, bool, error) { - return 0, false, nil -} func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind) (int64, bool, error) { return 0, false, nil } @@ -174,10 +285,10 @@ func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerTy func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, _ []int64, _ domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { return map[int64]domain.ProfilePhotoRef{}, nil } -func (f *fakeMediaStore) ListProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _, _ int, _ int64) ([]int64, int, error) { +func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]int64, int, error) { return nil, 0, nil } -func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]int64, int, error) { +func (f *fakeMediaStore) ListProfilePhotoDetailsKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]domain.Photo, int, error) { return nil, 0, nil } func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) { @@ -252,6 +363,146 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) { } } +func TestSeedCustomEmojiTGSWithoutThumbGetsSyntheticPreview(t *testing.T) { + ctx := context.Background() + seedDir := t.TempDir() + const sourceID int64 = 4444444 + writeStatusPackWithoutThumbSeed(t, seedDir, sourceID, 17) + + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + svc := NewService(media, blobs, 2) + stats, err := svc.SeedMedia(ctx, seedDir, 0) + if err != nil { + t.Fatalf("seed media: %v", err) + } + if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 || stats.Skipped { + t.Fatalf("stats = %+v, want one set, one doc, main blob plus synthetic preview", stats) + } + + set, ok, err := media.GetStickerSetByShortName(ctx, "StatusPack") + if err != nil || !ok { + t.Fatalf("StatusPack ok=%v err=%v", ok, err) + } + doc, ok, err := media.GetDocument(ctx, set.DocumentIDs[0]) + if err != nil || !ok { + t.Fatalf("StatusPack document ok=%v err=%v", ok, err) + } + if !seedDocumentHasAttribute(doc.Attributes, domain.DocAttrCustomEmoji) { + t.Fatalf("document attributes = %+v, want custom emoji", doc.Attributes) + } + thumb, ok := findCachedThumb(doc.Thumbs) + if !ok { + t.Fatalf("document thumbs = %+v, want synthetic cached preview", doc.Thumbs) + } + if thumb.Type != seedSyntheticDocumentThumbType || thumb.W != 1 || thumb.H != 1 || len(thumb.Bytes) == 0 { + t.Fatalf("synthetic thumb = %+v, want 1x1 cached %q thumb", thumb, seedSyntheticDocumentThumbType) + } + blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)) + if err != nil || !ok { + t.Fatalf("synthetic thumb blob ok=%v err=%v", ok, err) + } + if blob.MimeType != "image/png" { + t.Fatalf("synthetic thumb blob mime = %q, want image/png", blob.MimeType) + } +} + +func TestSeedMediaRepairsCustomEmojiTGSWithoutThumb(t *testing.T) { + ctx := context.Background() + seedDir := t.TempDir() + const sourceID int64 = 5555555 + const setHash = 23 + writeStatusPackWithoutThumbSeed(t, seedDir, sourceID, setHash) + + media := newFakeMediaStore() + if err := media.PutDocument(ctx, domain.Document{ + ID: sourceID, + MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{{ + Kind: domain.DocAttrCustomEmoji, + Alt: "\U0001f44b", + TextColor: true, + }}, + }); err != nil { + t.Fatalf("put stale document: %v", err) + } + if err := media.PutStickerSet(ctx, domain.StickerSet{ + ID: 773947703670341676, + AccessHash: 1, + ShortName: "StatusPack", + Title: "Status Pack", + Hash: setHash, + Kind: domain.StickerSetKindEmoji, + Emojis: true, + DocumentIDs: []int64{ + sourceID, + }, + }); err != nil { + t.Fatalf("put stale sticker set: %v", err) + } + + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + svc := NewService(media, blobs, 2) + stats, err := svc.SeedMedia(ctx, seedDir, 0) + if err != nil { + t.Fatalf("repair seed: %v", err) + } + if stats.StickerSets != 1 || stats.Documents != 1 || stats.Blobs != 2 || stats.Skipped { + t.Fatalf("repair stats = %+v, want forced reimport", stats) + } + doc, ok, err := media.GetDocument(ctx, sourceID) + if err != nil || !ok { + t.Fatalf("repaired document ok=%v err=%v", ok, err) + } + if _, ok := findCachedThumb(doc.Thumbs); !ok { + t.Fatalf("repaired document thumbs = %+v, want synthetic cached preview", doc.Thumbs) + } +} + +func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) { + ctx := context.Background() + seedDir := t.TempDir() + const sourceID int64 = 6666666 + writeEffectsSeed(t, seedDir, sourceID) + + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("local fs: %v", err) + } + svc := NewService(media, blobs, 2) + first, err := svc.SeedMedia(ctx, seedDir, 0) + if err != nil { + t.Fatalf("first seed: %v", err) + } + if first.Effects != 1 || first.Documents != 1 || first.Blobs != 1 { + t.Fatalf("first stats = %+v, want one imported effect document/blob", first) + } + + second, err := svc.SeedMedia(ctx, seedDir, 0) + if err != nil { + t.Fatalf("second seed: %v", err) + } + if second.Effects != 1 || second.Documents != 0 || second.Blobs != 0 { + t.Fatalf("second stats = %+v, want effects catalog loaded without document/blob import", second) + } + + delete(media.blobs, fmt.Sprintf("doc:%d", sourceID)) + repaired, err := svc.SeedMedia(ctx, seedDir, 0) + if err != nil { + t.Fatalf("repair seed: %v", err) + } + if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 1 { + t.Fatalf("repair stats = %+v, want missing blob to force reimport", repaired) + } +} + func TestSeedMediaFromRealExport(t *testing.T) { seedDir := os.Getenv("TELESRV_REAL_STICKER_SEED_DIR") if seedDir == "" { @@ -355,6 +606,37 @@ func TestSeedMediaFromRealExport(t *testing.T) { } } +func writeStatusPackWithoutThumbSeed(t *testing.T, seedDir string, sourceID int64, setHash int) { + t.Helper() + setDir := filepath.Join(seedDir, "telegram_emoji_export", "StatusPack_773947703670341676") + stickersDir := filepath.Join(setDir, "stickers") + if err := os.MkdirAll(stickersDir, 0o755); err != nil { + t.Fatal(err) + } + raw := fmt.Sprintf(`{"result":{"set":{"id":773947703670341676,"access_hash":1,"title":"Status Pack","short_name":"StatusPack","count":1,"hash":%d,"emojis":true,"packs":[{"emoticon":"👋","documents":[%d]}]},"packs":[{"emoticon":"👋","documents":[%d]}],"documents":[{"id":%d,"access_hash":2,"file_reference":"","date":"2026-06-29T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":"DocumentAttributeCustomEmoji","alt":"👋","text_color":true,"stickerset":{"id":773947703670341676,"access_hash":1}},{"_":"DocumentAttributeFilename","file_name":"AnimatedSticker.tgs"}],"thumbs":[]}]}}`, setHash, sourceID, sourceID, sourceID) + if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), []byte(raw), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stickersDir, fmt.Sprintf("status_%d.tgs", sourceID)), []byte("tgs!"), 0o644); err != nil { + t.Fatal(err) + } +} + +func writeEffectsSeed(t *testing.T, seedDir string, sourceID int64) { + t.Helper() + docsDir := filepath.Join(seedDir, "telegram_effects_export", "documents") + if err := os.MkdirAll(docsDir, 0o755); err != nil { + t.Fatal(err) + } + raw := fmt.Sprintf(`{"result":{"effects":[{"id":77,"emoticon":"🔥","effect_sticker_id":%d}],"documents":[{"id":%d,"access_hash":2,"file_reference":"","date":"2026-06-29T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":"DocumentAttributeFilename","file_name":"effect.tgs"}],"thumbs":[]}]}}`, sourceID, sourceID) + if err := os.WriteFile(filepath.Join(seedDir, "telegram_effects_export", "effects.json"), []byte(raw), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(docsDir, fmt.Sprintf("effect_%d.tgs", sourceID)), []byte("tgs!"), 0o644); err != nil { + t.Fatal(err) + } +} + func TestSeedDocumentStorageIDNormalizesExternalIDs(t *testing.T) { const sourceID int64 = 5382305375846410902 const want int64 = 1382305375846410902 diff --git a/internal/app/files/service.go b/internal/app/files/service.go index 181fd161..f97ae7fd 100644 --- a/internal/app/files/service.go +++ b/internal/app/files/service.go @@ -1,11 +1,19 @@ package files import ( + "bytes" "context" + "crypto/sha256" "fmt" + "hash" + "io" + "time" "telesrv/internal/domain" "telesrv/internal/store" + + "go.uber.org/zap" + "golang.org/x/sync/singleflight" ) // 上传分片上限:与 Telegram 客户端约定一致(单片 ≤512KB;分片总数有上限防止 OOM)。 @@ -14,6 +22,15 @@ const ( MaxUploadParts = 8000 // 512KB * 8000 ≈ 4GB 理论上限,足够主路径媒体 ) +const ( + DefaultUploadPartTTL = 24 * time.Hour + DefaultUploadPartGCInterval = 30 * time.Minute + DefaultUploadPartGCBatch = 10000 + DefaultUploadInFlightMaxBytes = int64(MaxUploadPartBytes) * int64(MaxUploadParts) + DefaultUploadInFlightMaxParts = MaxUploadParts + DefaultUploadInFlightMaxFiles = 64 +) + // blobMetaCacheCapacity 是 location_key→FileBlob 元数据 LRU 容量(每项约百字节,约 13MB)。 const blobMetaCacheCapacity = 1 << 16 @@ -21,28 +38,104 @@ const blobMetaCacheCapacity = 1 << 16 const ( blobBytesCacheMaxEntryBytes = 256 << 10 // 256KB blobBytesCacheMaxBytes = 64 << 20 // 64MB + // stickerSetNegativeCacheTTL 是未找到贴纸集的负缓存有效期:未 seed 的 short_name 会被客户端 + // 反复 getStickerSet,这里短时缓存 not-found 短路掉 PG。短 TTL 保证运行时新增集合最多滞后这么久。 + stickerSetNegativeCacheTTL = 30 * time.Second ) // Service 实现 upload 分片累积、blob 落盘、getFile 下载,并把上传文件组装成 Photo / Document。 type Service struct { - media store.MediaStore - blobs BlobBackend - dc int - blobCache *blobMetaCache - byteCache *blobBytesCache - stickerSetCache *stickerSetFullCache + media store.MediaStore + blobs BlobBackend + uploadParts UploadPartBackend + dc int + log *zap.Logger + thumbs VideoThumbnailer + thumbsSet bool + blobCache *blobMetaCache + byteCache *blobBytesCache + // blobMetaSF/blobBytesSF 合并对同一热 blob 的并发首次访问:否则每个并发 getFile 都各打 + // 一发 PG GetFileBlob + backend GetRange(热门贴纸/reaction/头像被大量用户同时拉时尤甚)。 + blobMetaSF singleflight.Group + blobBytesSF singleflight.Group + stickerSetCache *stickerSetFullCache + stickerSetNegCache *stickerSetNegativeCache + uploadQuota domain.UploadPartQuota + mapTiles *mapTileProxy + externalMedia *externalMediaFetcher + webpage *webpageFetcher + // effects 是消息发送特效目录(messages.getAvailableEffects)。全局静态,启动 seedEffects + // 一次写入后只读,故无锁——与各 read-model 缓存一样在服务就绪前完成填充。 + // effectsHash 在 seed 时算一次,handler 直接比对返回 NotModified,无需每次 RPC 重算。 + effects []domain.AvailableEffect + effectsHash int +} + +// Option 配置 files 服务的可选能力。 +type Option func(*Service) + +// WithLogger 注入日志器。未注入时使用 no-op logger。 +func WithLogger(log *zap.Logger) Option { + return func(s *Service) { + if log != nil { + s.log = log + } + } +} + +// WithVideoThumbnailer 覆盖视频缩略图生成器。传 nil 可显式关闭服务端抽帧 fallback。 +func WithVideoThumbnailer(thumbnailer VideoThumbnailer) Option { + return func(s *Service) { + s.thumbs = thumbnailer + s.thumbsSet = true + } +} + +// WithUploadPartQuota 覆盖用户级 in-flight 上传分片配额;字段 <=0 表示该维度不限制。 +func WithUploadPartQuota(quota domain.UploadPartQuota) Option { + return func(s *Service) { + s.uploadQuota = quota + } } // NewService 创建 files 服务。dc 是本 server 的 DC id,写入新建 document/photo 的 dc_id。 -func NewService(media store.MediaStore, blobs BlobBackend, dc int) *Service { - return &Service{ - media: media, - blobs: blobs, - dc: dc, - blobCache: newBlobMetaCache(blobMetaCacheCapacity), - byteCache: newBlobBytesCache(blobBytesCacheMaxBytes), - stickerSetCache: newStickerSetFullCache(), +func NewService(media store.MediaStore, blobs BlobBackend, dc int, opts ...Option) *Service { + s := &Service{ + media: media, + blobs: blobs, + dc: dc, + log: zap.NewNop(), + blobCache: newBlobMetaCache(blobMetaCacheCapacity), + byteCache: newBlobBytesCache(blobBytesCacheMaxBytes), + stickerSetCache: newStickerSetFullCache(), + stickerSetNegCache: newStickerSetNegativeCache(stickerSetNegativeCacheTTL), + uploadQuota: domain.UploadPartQuota{ + MaxBytes: DefaultUploadInFlightMaxBytes, + MaxParts: DefaultUploadInFlightMaxParts, + MaxFiles: DefaultUploadInFlightMaxFiles, + }, } + if partBackend, ok := blobs.(UploadPartBackend); ok { + s.uploadParts = partBackend + } + for _, opt := range opts { + if opt != nil { + opt(s) + } + } + if s.mapTiles != nil { + // 选项应用顺序无关:logger 在全部 Option 跑完后统一注入。 + s.mapTiles.log = s.log + } + if !s.thumbsSet { + thumbnailer, err := NewFFmpegVideoThumbnailer() + if err != nil { + s.log.Warn("ffmpeg not found; server-side video thumbnail fallback disabled", zap.Error(err)) + } else { + s.thumbs = thumbnailer + } + } + return s } // SaveFilePart 累积一个 small file 分片。 @@ -50,12 +143,12 @@ func (s *Service) SaveFilePart(ctx context.Context, ownerUserID, fileID int64, p if err := validatePart(part, len(bytes)); err != nil { return false, err } - if err := s.media.SaveFilePart(ctx, domain.UploadPart{ + if err := s.saveFilePart(ctx, domain.UploadPart{ OwnerUserID: ownerUserID, FileID: fileID, Part: part, - Bytes: bytes, - }); err != nil { + Size: int64(len(bytes)), + }, bytes); err != nil { return false, err } return true, nil @@ -69,37 +162,142 @@ func (s *Service) SaveBigFilePart(ctx context.Context, ownerUserID, fileID int64 if totalParts <= 0 || totalParts > MaxUploadParts { return false, domain.ErrFilePartsInvalid } - if err := s.media.SaveFilePart(ctx, domain.UploadPart{ + if err := s.saveFilePart(ctx, domain.UploadPart{ OwnerUserID: ownerUserID, FileID: fileID, Part: part, TotalParts: totalParts, Big: true, - Bytes: bytes, - }); err != nil { + Size: int64(len(bytes)), + }, bytes); err != nil { return false, err } return true, nil } +func (s *Service) saveFilePart(ctx context.Context, part domain.UploadPart, bytes []byte) error { + if s.uploadParts == nil { + return fmt.Errorf("upload part backend not configured") + } + slot, err := s.checkUploadPartQuota(ctx, part) + if err != nil { + return err + } + obj, err := s.uploadParts.PutUploadPart(ctx, part.OwnerUserID, part.FileID, part.Part, bytes) + if err != nil { + return err + } + part.Backend = obj.Backend + part.ObjectKey = obj.ObjectKey + part.Size = obj.Size + part.SHA256 = obj.SHA256 + if err := s.media.SaveFilePart(ctx, part); err != nil { + _ = s.uploadParts.DeleteUploadPart(ctx, obj.ObjectKey) + return err + } + if slot.Found && slot.ObjectKey != "" && slot.ObjectKey != obj.ObjectKey { + if err := s.uploadParts.DeleteUploadPart(ctx, slot.ObjectKey); err != nil { + s.log.Warn("delete replaced upload part failed", zap.String("object_key", slot.ObjectKey), zap.Error(err)) + } + } + return nil +} + +func (s *Service) checkUploadPartQuota(ctx context.Context, part domain.UploadPart) (domain.UploadPartSlot, error) { + slot, err := s.media.UploadPartSlot(ctx, part.OwnerUserID, part.FileID, part.Part) + if err != nil { + return domain.UploadPartSlot{}, err + } + quota := s.uploadQuota + if quota.MaxBytes <= 0 && quota.MaxParts <= 0 && quota.MaxFiles <= 0 { + return slot, nil + } + usage, err := s.media.UploadPartUsage(ctx, part.OwnerUserID) + if err != nil { + return domain.UploadPartSlot{}, err + } + next := usage + next.Bytes += part.Size - slot.ExistingBytes + if !slot.Found { + next.Parts++ + } + if slot.FileParts == 0 { + next.Files++ + } + if quota.MaxBytes > 0 && next.Bytes > quota.MaxBytes { + return domain.UploadPartSlot{}, domain.ErrUploadQuotaExceeded + } + if quota.MaxParts > 0 && next.Parts > quota.MaxParts { + return domain.UploadPartSlot{}, domain.ErrUploadQuotaExceeded + } + if quota.MaxFiles > 0 && next.Files > quota.MaxFiles { + return domain.UploadPartSlot{}, domain.ErrUploadQuotaExceeded + } + return slot, nil +} + +// DeleteExpiredUploadParts 清理超过保留期仍未组装的 transient 上传分片。 +func (s *Service) DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) (int64, error) { + if limit <= 0 { + return 0, nil + } + keys, err := s.media.DeleteExpiredUploadParts(ctx, before, limit) + if err != nil { + return 0, err + } + if err := s.deleteUploadPartObjects(ctx, keys); err != nil { + return int64(len(keys)), err + } + var orphanDeleted int64 + if s.uploadParts != nil { + n, err := s.uploadParts.DeleteExpiredUploadParts(ctx, before, limit) + if err != nil { + return int64(len(keys)), err + } + orphanDeleted = n + } + return int64(len(keys)) + orphanDeleted, nil +} + // GetFile 按 location_key 取一段 blob 内容。found=false 表示该 location 无对应 blob。 // 元数据走进程内 LRU(消除每 chunk 一次 PG 查);小 blob 全量字节进 LRU,供 sticker / // reaction / thumbnail 热路径直接内存切片;大 blob 仍按 offset/limit 段读。 +type blobMetaResult struct { + blob domain.FileBlob + found bool +} + +type blobBytesResult struct { + data []byte + total int64 + cacheable bool +} + func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) { blob, ok := s.blobCache.get(req.LocationKey) if !ok { - var ( - found bool - err error - ) - blob, found, err = s.media.GetFileBlob(ctx, req.LocationKey) + // 同一 location_key 的并发首访合并成一次 PG GetFileBlob。 + v, err, _ := s.blobMetaSF.Do(req.LocationKey, func() (any, error) { + if cached, ok := s.blobCache.get(req.LocationKey); ok { + return blobMetaResult{blob: cached, found: true}, nil + } + b, found, err := s.media.GetFileBlob(ctx, req.LocationKey) + if err != nil { + return blobMetaResult{}, err + } + if found { + s.blobCache.put(req.LocationKey, b) + } + return blobMetaResult{blob: b, found: found}, nil + }) if err != nil { return domain.FileChunk{}, false, err } - if !found { + res := v.(blobMetaResult) + if !res.found { return domain.FileChunk{}, false, nil } - s.blobCache.put(req.LocationKey, blob) + blob = res.blob } if blob.Size > 0 && blob.Size <= blobBytesCacheMaxEntryBytes { if data, ok := s.byteCache.get(blob.ObjectKey); ok { @@ -109,18 +307,33 @@ func (s *Service) GetFile(ctx context.Context, req domain.FileDownloadRequest) ( Total: int64(len(data)), }, true, nil } - data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1) + // 同一 object_key 的小 blob 并发首访合并成一次 backend 全量读 + 一次 byteCache 填充。 + v, err, _ := s.blobBytesSF.Do(blob.ObjectKey, func() (any, error) { + if cached, ok := s.byteCache.get(blob.ObjectKey); ok { + return blobBytesResult{data: cached, total: int64(len(cached)), cacheable: true}, nil + } + data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, 0, blobBytesCacheMaxEntryBytes+1) + if err != nil { + return blobBytesResult{}, err + } + if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total { + s.byteCache.put(blob.ObjectKey, data) + return blobBytesResult{data: data, total: total, cacheable: true}, nil + } + return blobBytesResult{cacheable: false}, nil + }) if err != nil { return domain.FileChunk{}, false, fmt.Errorf("read blob %q: %w", blob.LocationKey, err) } - if total <= blobBytesCacheMaxEntryBytes && int64(len(data)) == total { - s.byteCache.put(blob.ObjectKey, data) + // res.data 在并发 caller 间只读共享,sliceBlobBytes 各自拷贝出自己的分片,安全。 + if res := v.(blobBytesResult); res.cacheable { return domain.FileChunk{ - Bytes: sliceBlobBytes(data, req.Offset, int64(req.Limit)), + Bytes: sliceBlobBytes(res.data, req.Offset, int64(req.Limit)), MimeType: blob.MimeType, - Total: total, + Total: res.total, }, true, nil } + // 大小不符/超限:落到下面的按需 range 读(与原行为一致)。 } data, total, err := s.blobs.GetRange(ctx, blob.ObjectKey, req.Offset, int64(req.Limit)) if err != nil { @@ -170,6 +383,11 @@ func (s *Service) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRe if set, docs, ok := s.stickerSetCache.get(ref); ok { return set, docs, true, nil } + // 负缓存:已 seed 集启动即进正缓存(WarmCaches),能走到这里的 miss 多是「未 seed 的 short_name」 + // 被客户端反复请求。TTL 内直接当 not-found 短路,避免每次都打 PG GetStickerSetByShortName。 + if s.stickerSetNegCache != nil && s.stickerSetNegCache.has(ref) { + return domain.StickerSet{}, nil, false, nil + } var ( set domain.StickerSet found bool @@ -186,6 +404,9 @@ func (s *Service) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRe return domain.StickerSet{}, nil, false, nil } if err != nil || !found { + if err == nil && !found && s.stickerSetNegCache != nil { + s.stickerSetNegCache.put(ref) + } return domain.StickerSet{}, nil, found, err } docs, err := s.media.GetDocuments(ctx, set.DocumentIDs) @@ -215,33 +436,226 @@ func orderDocuments(docs []domain.Document, ids []int64) []domain.Document { // assembleUpload 把已上传分片按 part 顺序拼成完整字节,并清理分片。 // expectedParts>0 时校验分片连续且齐全。 func (s *Service) assembleUpload(ctx context.Context, ownerUserID, fileID int64, expectedParts int) ([]byte, error) { - parts, err := s.media.LoadFileParts(ctx, ownerUserID, fileID) + parts, _, err := s.loadAndValidateUploadParts(ctx, ownerUserID, fileID, expectedParts) if err != nil { return nil, err } - if len(parts) == 0 { - return nil, domain.ErrFilePartsInvalid - } - if expectedParts > 0 && len(parts) != expectedParts { - return nil, domain.ErrFilePartsInvalid - } - total := 0 - for i, p := range parts { - if p.Part != i { - return nil, domain.ErrFilePartsInvalid // 缺片或乱序 - } - total += len(p.Bytes) - } - buf := make([]byte, 0, total) + buf := make([]byte, 0, uploadPartsTotalSize(parts)) for _, p := range parts { - buf = append(buf, p.Bytes...) + if s.uploadParts == nil { + return nil, fmt.Errorf("upload part backend not configured") + } + data, err := s.uploadParts.GetUploadPart(ctx, p.ObjectKey) + if err != nil { + return nil, fmt.Errorf("read upload part %d: %w", p.Part, err) + } + if err := validateUploadPartBytes(p, data); err != nil { + return nil, err + } + buf = append(buf, data...) } - if err := s.media.DeleteFileParts(ctx, ownerUserID, fileID); err != nil { + if err := s.cleanupUploadParts(ctx, ownerUserID, fileID); err != nil { return nil, err } return buf, nil } +type assembledUploadBlob struct { + ObjectKey string + Size int64 + SHA256 []byte +} + +// assembleUploadBlob 把上传分片流式写入正式 blob。调用方应在 durable media 元数据 +// 成功提交后调用 cleanupUploadParts,避免 metadata 写失败时丢失可重试的上传分片。 +func (s *Service) assembleUploadBlob(ctx context.Context, ownerUserID, fileID int64, expectedParts int) (assembledUploadBlob, error) { + parts, _, err := s.loadAndValidateUploadParts(ctx, ownerUserID, fileID, expectedParts) + if err != nil { + return assembledUploadBlob{}, err + } + if s.uploadParts == nil { + return assembledUploadBlob{}, fmt.Errorf("upload part backend not configured") + } + reader := &uploadPartsReader{ + ctx: ctx, + backend: s.uploadParts, + parts: parts, + } + defer reader.Close() + objectKey, size, sum, err := s.blobs.PutReader(ctx, reader) + if err != nil { + return assembledUploadBlob{}, err + } + return assembledUploadBlob{ + ObjectKey: objectKey, + Size: size, + SHA256: sum, + }, nil +} + +func (s *Service) loadAndValidateUploadParts(ctx context.Context, ownerUserID, fileID int64, expectedParts int) ([]domain.UploadPart, int64, error) { + parts, err := s.media.LoadFileParts(ctx, ownerUserID, fileID) + if err != nil { + return nil, 0, err + } + if len(parts) == 0 { + return nil, 0, domain.ErrFilePartsInvalid + } + if expectedParts > 0 && len(parts) != expectedParts { + return nil, 0, domain.ErrFilePartsInvalid + } + var total int64 + for i, p := range parts { + if p.Part != i { + return nil, 0, domain.ErrFilePartsInvalid // 缺片或乱序 + } + if p.Size <= 0 || p.Size > MaxUploadPartBytes || p.ObjectKey == "" { + return nil, 0, domain.ErrFilePartsInvalid + } + total += p.Size + if total > DefaultUploadInFlightMaxBytes { + return nil, 0, domain.ErrFilePartsInvalid + } + } + return parts, total, nil +} + +func uploadPartsTotalSize(parts []domain.UploadPart) int { + var total int64 + for _, p := range parts { + total += p.Size + } + return int(total) +} + +func validateUploadPartBytes(part domain.UploadPart, data []byte) error { + if int64(len(data)) != part.Size { + return domain.ErrFilePartsInvalid + } + if len(part.SHA256) > 0 { + sum := sha256.Sum256(data) + if !bytes.Equal(sum[:], part.SHA256) { + return domain.ErrFilePartsInvalid + } + } + return nil +} + +func (s *Service) cleanupUploadParts(ctx context.Context, ownerUserID, fileID int64) error { + keys, err := s.media.DeleteFileParts(ctx, ownerUserID, fileID) + if err != nil { + return err + } + if err := s.deleteUploadPartObjects(ctx, keys); err != nil { + return err + } + return nil +} + +type uploadPartsReader struct { + ctx context.Context + backend UploadPartBackend + parts []domain.UploadPart + index int + current io.ReadCloser + currentRead int64 + currentHash hash.Hash +} + +func (r *uploadPartsReader) Read(buf []byte) (int, error) { + for { + if r.current == nil { + if r.index >= len(r.parts) { + return 0, io.EOF + } + select { + case <-r.ctx.Done(): + return 0, r.ctx.Err() + default: + } + part := r.parts[r.index] + rc, err := r.backend.OpenUploadPart(r.ctx, part.ObjectKey) + if err != nil { + return 0, fmt.Errorf("open upload part %d: %w", part.Part, err) + } + r.current = rc + r.currentRead = 0 + if len(part.SHA256) > 0 { + r.currentHash = sha256.New() + } else { + r.currentHash = nil + } + } + n, err := r.current.Read(buf) + if n > 0 { + r.currentRead += int64(n) + if r.currentHash != nil { + _, _ = r.currentHash.Write(buf[:n]) + } + return n, nil + } + if err == io.EOF { + if err := r.finishCurrentPart(); err != nil { + return 0, err + } + continue + } + if err != nil { + _ = r.current.Close() + part := r.parts[r.index] + r.current = nil + return 0, fmt.Errorf("read upload part %d: %w", part.Part, err) + } + return 0, nil + } +} + +func (r *uploadPartsReader) finishCurrentPart() error { + part := r.parts[r.index] + closeErr := r.current.Close() + r.current = nil + if closeErr != nil { + return fmt.Errorf("close upload part %d: %w", part.Part, closeErr) + } + if r.currentRead != part.Size { + return domain.ErrFilePartsInvalid + } + if r.currentHash != nil && !bytes.Equal(r.currentHash.Sum(nil), part.SHA256) { + return domain.ErrFilePartsInvalid + } + r.currentHash = nil + r.currentRead = 0 + r.index++ + return nil +} + +func (r *uploadPartsReader) Close() error { + if r.current == nil { + return nil + } + err := r.current.Close() + r.current = nil + return err +} + +func (s *Service) deleteUploadPartObjects(ctx context.Context, keys []string) error { + if len(keys) == 0 { + return nil + } + if s.uploadParts == nil { + return fmt.Errorf("upload part backend not configured") + } + for _, key := range keys { + if key == "" { + continue + } + if err := s.uploadParts.DeleteUploadPart(ctx, key); err != nil { + return err + } + } + return nil +} + func validatePart(part, size int) error { if part < 0 || part >= MaxUploadParts { return domain.ErrFilePartInvalid diff --git a/internal/app/files/star_gifts_catalog.go b/internal/app/files/star_gifts_catalog.go new file mode 100644 index 00000000..acd24499 --- /dev/null +++ b/internal/app/files/star_gifts_catalog.go @@ -0,0 +1,97 @@ +package files + +import ( + "context" + "fmt" + + "telesrv/internal/domain" +) + +// Star gift 目录:从已 seed 的 animated_emoji 集按 emoticon 精选贴纸文档合成(复用文档行与 +// blob,不复制字节),镜像 EnsureDefaultEmojiStatusSet。目录是静态的,不入库。 +// 礼物 ID 取明显隔离的常量段避免撞键。 + +const starGiftIDBase int64 = 8_888_000_000_000_000 + +type starGiftSeed struct { + id int64 + emoticon string + stars int64 + title string +} + +// starGiftSeeds 是固定礼物目录(emoticon 需在 animated_emoji 集里,否则该礼物被跳过)。 +// convert_stars = stars(v1 全额转换,视作用新购 Stars 买入)。 +var starGiftSeeds = []starGiftSeed{ + {starGiftIDBase + 1, "❤", 15, "Heart"}, + {starGiftIDBase + 2, "\U0001f382", 50, "Cake"}, // 🎂 + {starGiftIDBase + 3, "\U0001f389", 100, "Party"}, // 🎉 + {starGiftIDBase + 4, "\U0001f525", 250, "Fire"}, // 🔥 + {starGiftIDBase + 5, "\U0001f3c6", 500, "Trophy"}, // 🏆 + {starGiftIDBase + 6, "\U0001f48e", 1000, "Diamond"}, // 💎 + {starGiftIDBase + 7, "\U0001f680", 2500, "Rocket"}, // 🚀 +} + +// BuildStarGiftCatalog 合成可购买礼物目录:解析每个 seed emoticon 的贴纸文档,跳过未 seed 的。 +// animated_emoji 未 seed 时返回空目录(客户端显示空礼物面板,购买流仍可对已知 gift_id 工作)。 +func (s *Service) BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error) { + source, found, err := s.media.GetStickerSetBySystemKey(ctx, "animated_emoji") + if err != nil { + return nil, fmt.Errorf("lookup animated_emoji set for star gifts: %w", err) + } + if !found || len(source.Packs) == 0 { + return nil, nil + } + byEmoticon := make(map[string]int64, len(source.Packs)) + for _, pack := range source.Packs { + key := normalizeStatusEmoticon(pack.Emoticon) + if key == "" || len(pack.DocumentIDs) == 0 { + continue + } + if _, ok := byEmoticon[key]; !ok { + byEmoticon[key] = pack.DocumentIDs[0] + } + } + // 收集要加载的文档 id(去重)。 + docIDs := make([]int64, 0, len(starGiftSeeds)) + chosen := make([]starGiftSeed, 0, len(starGiftSeeds)) + seen := make(map[int64]struct{}) + for _, seed := range starGiftSeeds { + id, ok := byEmoticon[normalizeStatusEmoticon(seed.emoticon)] + if !ok || id == 0 { + continue + } + chosen = append(chosen, seed) + if _, dup := seen[id]; !dup { + seen[id] = struct{}{} + docIDs = append(docIDs, id) + } + } + if len(chosen) == 0 { + return nil, nil + } + docs, err := s.media.GetDocuments(ctx, docIDs) + if err != nil { + return nil, fmt.Errorf("load star gift sticker documents: %w", err) + } + docByID := make(map[int64]domain.Document, len(docs)) + for _, d := range docs { + docByID[d.ID] = d + } + catalog := make([]domain.StarGift, 0, len(chosen)) + for _, seed := range chosen { + id := byEmoticon[normalizeStatusEmoticon(seed.emoticon)] + doc, ok := docByID[id] + if !ok || doc.ID == 0 { + continue + } + catalog = append(catalog, domain.StarGift{ + ID: seed.id, + Stars: seed.stars, + ConvertStars: seed.stars, + Title: seed.title, + Sticker: doc, + }) + } + return catalog, nil +} diff --git a/internal/app/files/upload_gc.go b/internal/app/files/upload_gc.go new file mode 100644 index 00000000..d86b7183 --- /dev/null +++ b/internal/app/files/upload_gc.go @@ -0,0 +1,67 @@ +package files + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +// UploadPartGCWorker 周期性清理未组装的过期上传分片。 +type UploadPartGCWorker struct { + files *Service + logger *zap.Logger + ttl time.Duration + interval time.Duration + batch int +} + +func NewUploadPartGCWorker(files *Service, logger *zap.Logger, ttl, interval time.Duration, batch int) *UploadPartGCWorker { + if logger == nil { + logger = zap.NewNop() + } + if ttl <= 0 { + ttl = DefaultUploadPartTTL + } + if interval <= 0 { + interval = DefaultUploadPartGCInterval + } + if batch <= 0 { + batch = DefaultUploadPartGCBatch + } + return &UploadPartGCWorker{ + files: files, + logger: logger, + ttl: ttl, + interval: interval, + batch: batch, + } +} + +func (w *UploadPartGCWorker) Run(ctx context.Context) { + w.runOnce(ctx) + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.runOnce(ctx) + } + } +} + +func (w *UploadPartGCWorker) runOnce(ctx context.Context) { + if w.files == nil { + return + } + deleted, err := w.files.DeleteExpiredUploadParts(ctx, time.Now().Add(-w.ttl), w.batch) + if err != nil { + w.logger.Warn("清理过期 upload_parts 失败", zap.Error(err)) + return + } + if deleted > 0 { + w.logger.Info("清理过期 upload_parts 完成", zap.Int64("deleted", deleted)) + } +} diff --git a/internal/app/files/upload_parts_test.go b/internal/app/files/upload_parts_test.go new file mode 100644 index 00000000..fc764739 --- /dev/null +++ b/internal/app/files/upload_parts_test.go @@ -0,0 +1,149 @@ +package files + +import ( + "context" + "errors" + "strconv" + "strings" + "testing" + + "telesrv/internal/domain" +) + +func TestSaveFilePartQuotaTreatsRetryAsOverwrite(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + svc, blobs := newUploadPartTestService(t, media, domain.UploadPartQuota{MaxBytes: 4, MaxParts: 1, MaxFiles: 1}) + + if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("1234")); err != nil { + t.Fatalf("save first part: %v", err) + } + firstParts, err := media.LoadFileParts(ctx, 10, 100) + if err != nil || len(firstParts) != 1 || firstParts[0].ObjectKey == "" { + t.Fatalf("load first part metadata: parts=%+v err=%v", firstParts, err) + } + firstKey := firstParts[0].ObjectKey + if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("1234")); err != nil { + t.Fatalf("retry same part should overwrite without extra quota: %v", err) + } + parts, err := media.LoadFileParts(ctx, 10, 100) + if err != nil { + t.Fatalf("load parts: %v", err) + } + if len(parts) != 1 || parts[0].Size != 4 || parts[0].ObjectKey == "" || parts[0].ObjectKey == firstKey { + t.Fatalf("parts after retry = %+v", parts) + } + if _, err := blobs.GetUploadPart(ctx, firstKey); err == nil { + t.Fatalf("replaced upload part object %q still exists", firstKey) + } + data, err := svc.assembleUpload(ctx, 10, 100, 1) + if err != nil { + t.Fatalf("assemble upload: %v", err) + } + if string(data) != "1234" { + t.Fatalf("assembled data = %q", data) + } +} + +func TestSaveFilePartQuotaRejectsNewFileOverLimit(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + svc, _ := newUploadPartTestService(t, media, domain.UploadPartQuota{MaxBytes: 8, MaxParts: 4, MaxFiles: 1}) + + if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("1234")); err != nil { + t.Fatalf("save first file part: %v", err) + } + _, err := svc.SaveFilePart(ctx, 10, 101, 0, []byte("12")) + if !errors.Is(err, domain.ErrUploadQuotaExceeded) { + t.Fatalf("save second file err = %v, want ErrUploadQuotaExceeded", err) + } +} + +func TestSaveFilePartQuotaRejectsPartAndByteOverLimit(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + svc, _ := newUploadPartTestService(t, media, domain.UploadPartQuota{MaxBytes: 5, MaxParts: 1, MaxFiles: 2}) + + if _, err := svc.SaveFilePart(ctx, 10, 100, 0, []byte("1234")); err != nil { + t.Fatalf("save first part: %v", err) + } + _, err := svc.SaveFilePart(ctx, 10, 100, 1, []byte("12")) + if !errors.Is(err, domain.ErrUploadQuotaExceeded) { + t.Fatalf("save second part err = %v, want ErrUploadQuotaExceeded", err) + } + _, err = svc.SaveFilePart(ctx, 10, 100, 0, []byte("123456")) + if !errors.Is(err, domain.ErrUploadQuotaExceeded) { + t.Fatalf("grow retried part err = %v, want ErrUploadQuotaExceeded", err) + } +} + +func TestCreateDocumentFromUploadStreamsBodyAndCleansParts(t *testing.T) { + ctx := context.Background() + media := newFakeMediaStore() + local, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + blobs := &countingUploadPartBackend{LocalFS: local} + svc := NewService(media, blobs, 2, WithVideoThumbnailer(nil)) + + parts := []string{ + strings.Repeat("a", 1024), + strings.Repeat("b", 1024), + strings.Repeat("c", 1024), + } + for i, part := range parts { + if _, err := svc.SaveBigFilePart(ctx, 10, 200, i, len(parts), []byte(part)); err != nil { + t.Fatalf("SaveBigFilePart %d: %v", i, err) + } + } + doc, err := svc.CreateDocumentFromUpload(ctx, + domain.UploadedFileRef{OwnerUserID: 10, FileID: 200, Parts: len(parts), Name: "large.bin", Big: true}, + domain.DocumentSpec{MimeType: "application/octet-stream"}, + ) + if err != nil { + t.Fatalf("CreateDocumentFromUpload: %v", err) + } + if doc.Size != int64(len(parts[0])+len(parts[1])+len(parts[2])) { + t.Fatalf("doc size = %d", doc.Size) + } + if blobs.getUploadPartCalls != 0 { + t.Fatalf("streaming document path called GetUploadPart %d times", blobs.getUploadPartCalls) + } + if remaining, err := media.LoadFileParts(ctx, 10, 200); err != nil || len(remaining) != 0 { + t.Fatalf("upload parts after success = %+v err=%v", remaining, err) + } + blob, ok, err := media.GetFileBlob(ctx, "doc:"+strconv.FormatInt(doc.ID, 10)) + if err != nil || !ok { + t.Fatalf("body file blob ok=%v err=%v", ok, err) + } + body, err := local.Get(ctx, blob.ObjectKey) + if err != nil { + t.Fatalf("read body blob: %v", err) + } + if string(body) != strings.Join(parts, "") { + t.Fatalf("body blob mismatch") + } +} + +type countingUploadPartBackend struct { + *LocalFS + getUploadPartCalls int +} + +func (c *countingUploadPartBackend) GetUploadPart(ctx context.Context, objectKey string) ([]byte, error) { + c.getUploadPartCalls++ + return c.LocalFS.GetUploadPart(ctx, objectKey) +} + +func newUploadPartTestService(t *testing.T, media *fakeMediaStore, quota domain.UploadPartQuota) (*Service, *LocalFS) { + t.Helper() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + return NewService(media, blobs, 2, + WithVideoThumbnailer(nil), + WithUploadPartQuota(quota), + ), blobs +} diff --git a/internal/app/files/video_thumbnail.go b/internal/app/files/video_thumbnail.go new file mode 100644 index 00000000..d0550d2a --- /dev/null +++ b/internal/app/files/video_thumbnail.go @@ -0,0 +1,132 @@ +package files + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const ( + videoThumbnailTimeout = 5 * time.Second + videoThumbnailMaxInputBytes = 200 << 20 // 200MB;更大文件本阶段跳过 fallback,避免阻塞发送。 + videoThumbnailMaxConcurrent = 2 +) + +// VideoThumbnailer 从视频字节中抽取静态缩略图。实现必须可失败降级,不影响原发送流程。 +type VideoThumbnailer interface { + Extract(ctx context.Context, data []byte, mimeType string) ([]byte, error) +} + +// FFmpegVideoThumbnailer 使用本机 ffmpeg 抽取第一帧 JPEG。 +type FFmpegVideoThumbnailer struct { + path string + timeout time.Duration + slots chan struct{} +} + +// NewFFmpegVideoThumbnailer 返回基于 PATH 中 ffmpeg 的抽帧器。 +func NewFFmpegVideoThumbnailer() (*FFmpegVideoThumbnailer, error) { + path, err := exec.LookPath("ffmpeg") + if err != nil { + return nil, err + } + return &FFmpegVideoThumbnailer{ + path: path, + timeout: videoThumbnailTimeout, + slots: make(chan struct{}, videoThumbnailMaxConcurrent), + }, nil +} + +// Extract 抽取第一帧并输出 JPEG bytes。 +func (t *FFmpegVideoThumbnailer) Extract(ctx context.Context, data []byte, mimeType string) ([]byte, error) { + if t == nil || t.path == "" { + return nil, fmt.Errorf("ffmpeg thumbnailer unavailable") + } + if len(data) == 0 { + return nil, fmt.Errorf("empty video data") + } + if len(data) > videoThumbnailMaxInputBytes { + return nil, fmt.Errorf("video too large for thumbnail fallback: %d bytes", len(data)) + } + select { + case t.slots <- struct{}{}: + defer func() { <-t.slots }() + case <-ctx.Done(): + return nil, ctx.Err() + } + + runCtx, cancel := context.WithTimeout(ctx, t.timeout) + defer cancel() + + input, err := os.CreateTemp("", "telesrv-video-*"+videoTempExt(mimeType)) + if err != nil { + return nil, fmt.Errorf("create temp video: %w", err) + } + inputPath := input.Name() + defer os.Remove(inputPath) + if _, err := input.Write(data); err != nil { + input.Close() + return nil, fmt.Errorf("write temp video: %w", err) + } + if err := input.Close(); err != nil { + return nil, fmt.Errorf("close temp video: %w", err) + } + + output, err := os.CreateTemp("", "telesrv-video-thumb-*.jpg") + if err != nil { + return nil, fmt.Errorf("create temp thumbnail: %w", err) + } + outputPath := output.Name() + output.Close() + defer os.Remove(outputPath) + + cmd := exec.CommandContext( + runCtx, + t.path, + "-hide_banner", + "-loglevel", "error", + "-y", + "-i", inputPath, + "-map", "0:v:0", + "-frames:v", "1", + "-an", + "-vf", "scale=320:320:force_original_aspect_ratio=decrease", + "-q:v", "3", + outputPath, + ) + stderr, err := cmd.CombinedOutput() + if runCtx.Err() != nil { + return nil, runCtx.Err() + } + if err != nil { + msg := strings.TrimSpace(string(stderr)) + if msg != "" { + return nil, fmt.Errorf("ffmpeg extract thumbnail: %w: %s", err, msg) + } + return nil, fmt.Errorf("ffmpeg extract thumbnail: %w", err) + } + thumb, err := os.ReadFile(outputPath) + if err != nil { + return nil, fmt.Errorf("read thumbnail: %w", err) + } + if len(thumb) == 0 { + return nil, fmt.Errorf("ffmpeg produced empty thumbnail") + } + return thumb, nil +} + +func videoTempExt(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "video/mp4": + return ".mp4" + case "video/quicktime": + return ".mov" + case "video/webm": + return ".webm" + default: + return ".bin" + } +} diff --git a/internal/app/files/warm.go b/internal/app/files/warm.go index 8682f637..6a62e4d9 100644 --- a/internal/app/files/warm.go +++ b/internal/app/files/warm.go @@ -18,7 +18,19 @@ type WarmStats struct { // SeedMedia 在已有数据时会跳过导入;该方法保证普通 server 重启后历史 sticker 首次渲染也不是冷缓存。 func (s *Service) WarmCaches(ctx context.Context) (WarmStats, error) { var stats WarmStats + // 第一阶段:收集所有待预热文档(贴纸集 + reaction),按 doc ID 去重。 seenDocs := make(map[int64]struct{}) + docs := make([]domain.Document, 0, 256) + collect := func(doc domain.Document) { + if doc.ID == 0 { + return + } + if _, ok := seenDocs[doc.ID]; ok { + return + } + seenDocs[doc.ID] = struct{}{} + docs = append(docs, doc) + } for _, kind := range []domain.StickerSetKind{ domain.StickerSetKindStickers, domain.StickerSetKindEmoji, @@ -30,24 +42,15 @@ func (s *Service) WarmCaches(ctx context.Context) (WarmStats, error) { return stats, err } for _, set := range sets { - docs, err := s.media.GetDocuments(ctx, set.DocumentIDs) + setDocs, err := s.media.GetDocuments(ctx, set.DocumentIDs) if err != nil { return stats, err } - ordered := orderDocuments(docs, set.DocumentIDs) + ordered := orderDocuments(setDocs, set.DocumentIDs) s.stickerSetCache.put(set, ordered) stats.StickerSets++ for _, doc := range ordered { - if _, ok := seenDocs[doc.ID]; ok { - continue - } - seenDocs[doc.ID] = struct{}{} - stats.Documents++ - warmed, err := s.prewarmDocumentBlobs(ctx, doc) - if err != nil { - return stats, err - } - stats.Blobs += warmed + collect(doc) } } } @@ -59,68 +62,62 @@ func (s *Service) WarmCaches(ctx context.Context) (WarmStats, error) { for _, reaction := range reactions { reactionIDs = append(reactionIDs, reaction.DocumentIDs()...) } - docs, err := s.media.GetDocuments(ctx, reactionIDs) + reactionDocs, err := s.media.GetDocuments(ctx, reactionIDs) if err != nil { return stats, err } + for _, doc := range reactionDocs { + collect(doc) + } + stats.Documents = len(docs) + + // 第二阶段:一发 ANY 查询批量取所有 location key 的 blob 元数据,替代过去逐个 + // GetFileBlob 的启动期 N+1(~2400 个 blob 各打一次 PG → 一次往返)。 + keys := make([]string, 0, len(docs)*2) for _, doc := range docs { - if _, ok := seenDocs[doc.ID]; ok { + keys = append(keys, blobLocationKeys(doc)...) + } + blobs, err := s.media.GetFileBlobs(ctx, keys) + if err != nil { + return stats, err + } + // 第三阶段:填充元数据缓存,并把小 blob 的全量字节读入字节缓存(blob backend 读,非 PG)。 + for _, key := range keys { + blob, ok := blobs[key] + if !ok { continue } - seenDocs[doc.ID] = struct{}{} - stats.Documents++ - warmed, err := s.prewarmDocumentBlobs(ctx, doc) + s.blobCache.put(key, blob) + warmed, err := s.warmBlobBytes(ctx, blob) if err != nil { return stats, err } - stats.Blobs += warmed + if warmed { + stats.Blobs++ + } } return stats, nil } -func (s *Service) prewarmDocumentBlobs(ctx context.Context, doc domain.Document) (int, error) { +// blobLocationKeys 返回一个文档需预热的全部 location key(主体 + 可下载缩略图)。 +func blobLocationKeys(doc domain.Document) []string { if doc.ID == 0 { - return 0, nil - } - warmed := 0 - ok, err := s.prewarmLocationKey(ctx, fmt.Sprintf("doc:%d", doc.ID)) - if err != nil { - return 0, err - } - if ok { - warmed++ + return nil } + keys := make([]string, 0, 1+len(doc.Thumbs)) + keys = append(keys, fmt.Sprintf("doc:%d", doc.ID)) for _, thumb := range doc.Thumbs { if !thumb.Downloadable() { continue } - ok, err := s.prewarmLocationKey(ctx, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)) - if err != nil { - return 0, err - } - if ok { - warmed++ - } + keys = append(keys, fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)) } - return warmed, nil + return keys } -func (s *Service) prewarmLocationKey(ctx context.Context, locationKey string) (bool, error) { - blob, ok := s.blobCache.get(locationKey) - if !ok { - var ( - found bool - err error - ) - blob, found, err = s.media.GetFileBlob(ctx, locationKey) - if err != nil { - return false, err - } - if !found { - return false, nil - } - s.blobCache.put(locationKey, blob) - } +// warmBlobBytes 把小 blob 的全量字节读入 byteCache(大 blob 跳过,仍由 GetRange 分段读)。 +// 返回是否实际写入了字节缓存。 +func (s *Service) warmBlobBytes(ctx context.Context, blob domain.FileBlob) (bool, error) { if blob.Size <= 0 || blob.Size > blobBytesCacheMaxEntryBytes || s.byteCache.has(blob.ObjectKey) { return false, nil } diff --git a/internal/app/files/webpage.go b/internal/app/files/webpage.go new file mode 100644 index 00000000..2ec465c0 --- /dev/null +++ b/internal/app/files/webpage.go @@ -0,0 +1,525 @@ +package files + +import ( + "bytes" + "context" + "errors" + "fmt" + "hash/fnv" + "image" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "syscall" + "time" + + "go.uber.org/zap" + "golang.org/x/net/html" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +// 链接预览(webpage preview):抓取消息里的 URL,解析 OpenGraph/Twitter-card/+meta +// 元数据,铸造预览卡片(含可选预览图)。安全模型与 external_media 同构(SSRF 拨号期 IP 校验、 +// 仅 http/https、重定向/大小/超时上限、全局限速),但用独立的限速器与缓存,避免与外链媒体抓取 +// 争用同一预算。HTML 与预览图共用一个总时长预算(父 ctx deadline)。 +// +// 解析结果(done / empty)经 L1 进程内缓存(singleflight 折叠并发同 URL 抓取)+ L3 web_pages +// 表(按规范化 URL 哈希跨实例去重)。瞬时失败(网络/限速/SSRF 拦截)返回 error 不缓存,避免一次 +// 抖动把热门链接毒成"无预览"。 + +var ( + // ErrWebPagePreviewDisabled 表示未启用链接预览抓取。 + ErrWebPagePreviewDisabled = errors.New("web page preview disabled") + // ErrWebPagePreviewInvalid 表示 URL 不合法/被 SSRF 拦截/上游失败/超限。 + ErrWebPagePreviewInvalid = errors.New("web page preview invalid") + // errWebPageTerminal 标记「确定性、短期不会变」的失败(SSRF 拦截/4xx/非法 URL)。这类 + // 解析为终态空预览并负缓存,避免每次按键/发送重复打 PG+外网;瞬时失败(5xx/超时/限速/ + // dial 失败)不带此标记、不缓存、可重试。 + errWebPageTerminal = errors.New("web page terminal") +) + +// terminalFetchErr 构造一个终态失败错误(会被负缓存)。 +func terminalFetchErr(msg string) error { + return fmt.Errorf("%w: %w: %s", ErrWebPagePreviewInvalid, errWebPageTerminal, msg) +} + +const ( + webpageRequestTimeout = 15 * time.Second + webpageTotalTimeout = 20 * time.Second + webpageMaxRedirects = 5 + // DefaultWebPagePreviewMaxBytes 覆盖 HTML 抓取与预览图抓取(head 在页首,足够)。 + DefaultWebPagePreviewMaxBytes = int64(5 << 20) + // DefaultWebPagePreviewRatePerMin 是全局每分钟抓取上限;一次解析最多 2 次上游(HTML+图)。 + // 60 是单用户口径,多用户实例偏低(输入预览与真实发送共用此预算易互相饿死),上调到 300。 + DefaultWebPagePreviewRatePerMin = 300 + webpageRateWindow = time.Minute + // maxWebpageImagePixels 是预览图解压炸弹上界(解码前按 DecodeConfig 尺寸拦截)。 + maxWebpageImagePixels = int64(25_000_000) + webpageCacheMaxEntries = 4096 + webpageCacheTTL = 10 * time.Minute + // webPageRefreshTTL 是已解析卡片的陈旧阈值:L3 命中且超过此龄时后台 stale-while-revalidate + // 刷新(返回的仍是旧卡片,不阻塞)。webPageRefreshConcurrency 限并发刷新 goroutine。 + webPageRefreshTTL = 24 * time.Hour + webPageRefreshConcurrency = 8 + // webpageUserAgent 用 Telegram 爬虫标识:很多站点只对已知爬虫吐 OG 标签。 + webpageUserAgent = "TelegramBot (like TwitterBot)" + acceptHTML = "text/html,application/xhtml+xml" + acceptImage = "image/*" +) + +type webpageFetcher struct { + client *http.Client + maxBytes int64 + rateLimit int + cache *readmodelcache.Cache[int64, domain.MessageWebPage] + refreshSem chan struct{} + + mu sync.Mutex + fetchTimes []time.Time +} + +// WithWebPagePreview 启用链接预览抓取。maxBytes<=0 / ratePerMin<=0 用默认。SSRF 防护恒开。 +func WithWebPagePreview(maxBytes int64, ratePerMin int) Option { + return func(s *Service) { + if maxBytes <= 0 { + maxBytes = DefaultWebPagePreviewMaxBytes + } + if ratePerMin <= 0 { + ratePerMin = DefaultWebPagePreviewRatePerMin + } + s.webpage = newWebpageFetcher(maxBytes, ratePerMin, false) + } +} + +// newWebpageFetcher 构造抓取器。allowPrivate 仅供测试(指向 httptest loopback);生产恒 false。 +func newWebpageFetcher(maxBytes int64, ratePerMin int, allowPrivate bool) *webpageFetcher { + dialer := &net.Dialer{Timeout: webpageRequestTimeout} + dialer.Control = func(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return ErrWebPagePreviewInvalid + } + ip := net.ParseIP(host) + if ip == nil { + return ErrWebPagePreviewInvalid + } + if !allowPrivate && isBlockedExternalIP(ip) { + // SSRF 拦截是确定性失败 → 标记终态供负缓存(否则每次按键重打 PG+重 dial)。 + return terminalFetchErr("blocked address " + host + " (SSRF guard)") + } + return nil + } + client := &http.Client{ + Timeout: webpageRequestTimeout, + Transport: &http.Transport{DialContext: dialer.DialContext, DisableKeepAlives: true, Proxy: nil}, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= webpageMaxRedirects { + return fmt.Errorf("%w: too many redirects", ErrWebPagePreviewInvalid) + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return fmt.Errorf("%w: blocked redirect scheme %q", ErrWebPagePreviewInvalid, req.URL.Scheme) + } + return nil + }, + } + return &webpageFetcher{ + client: client, + maxBytes: maxBytes, + rateLimit: ratePerMin, + refreshSem: make(chan struct{}, webPageRefreshConcurrency), + cache: readmodelcache.New(readmodelcache.Config[int64, domain.MessageWebPage]{ + MaxEntries: webpageCacheMaxEntries, + TTL: webpageCacheTTL, + }), + } +} + +func (f *webpageFetcher) allowFetch() bool { + f.mu.Lock() + defer f.mu.Unlock() + now := time.Now() + kept := f.fetchTimes[:0] + for _, at := range f.fetchTimes { + if now.Sub(at) <= webpageRateWindow { + kept = append(kept, at) + } + } + f.fetchTimes = kept + if len(f.fetchTimes) >= f.rateLimit { + return false + } + f.fetchTimes = append(f.fetchTimes, now) + return true +} + +// fetch 抓取 URL,返回 (字节, content-type)。SSRF 检查在 dial 阶段发生;ctx 承载共享总预算。 +func (f *webpageFetcher) fetch(ctx context.Context, rawURL, accept string) ([]byte, string, error) { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return nil, "", terminalFetchErr("bad url") // 非法 URL 是终态 + } + if !f.allowFetch() { + return nil, "", fmt.Errorf("%w: rate limited", ErrWebPagePreviewInvalid) // 限速=瞬时,可重试 + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, "", terminalFetchErr("bad request") + } + req.Header.Set("User-Agent", webpageUserAgent) + req.Header.Set("Accept", accept) + resp, err := f.client.Do(req) + if err != nil { + // SSRF 拦截(dial Control 返回的 terminal)经 url.Error 传上来,errors.Is 仍能识别; + // 其余 dial/超时错误是瞬时。 + return nil, "", fmt.Errorf("%w: %v", ErrWebPagePreviewInvalid, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // 4xx=确定性(404/403/410…)终态负缓存;5xx=瞬时可重试。 + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + return nil, "", terminalFetchErr(fmt.Sprintf("upstream status %d", resp.StatusCode)) + } + return nil, "", fmt.Errorf("%w: upstream status %d", ErrWebPagePreviewInvalid, resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, f.maxBytes+1)) + if err != nil { + return nil, "", fmt.Errorf("%w: read body: %v", ErrWebPagePreviewInvalid, err) + } + if len(data) == 0 || int64(len(data)) > f.maxBytes { + return nil, "", fmt.Errorf("%w: body size %d", ErrWebPagePreviewInvalid, len(data)) + } + contentType := resp.Header.Get("Content-Type") + if i := strings.IndexByte(contentType, ';'); i >= 0 { + contentType = contentType[:i] + } + return data, strings.TrimSpace(strings.ToLower(contentType)), nil +} + +// WebPagePreviewEnabled 报告链接预览抓取是否启用。 +func (s *Service) WebPagePreviewEnabled() bool { + return s != nil && s.webpage != nil +} + +// LookupWebPage 仅查缓存(L1 进程内 → L3 web_pages 表)返回已解析的链接预览,不抓取。命中 +// 返回 (page,true);未缓存或未启用返回 false。发送路径用它在 echo 直接带 done 卡片。 +// 先 Peek L1:客户端输入时 getWebPagePreview 多半已把同一 URL 解析进 L1,发送时即免一次 PG。 +func (s *Service) LookupWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, bool) { + if s == nil || s.webpage == nil { + return domain.MessageWebPage{}, false + } + normalized, ok := domain.NormalizeWebPageURL(rawURL) + if !ok { + return domain.MessageWebPage{}, false + } + urlHash := domain.WebPageURLHash(normalized) + if page, ok := s.webpage.cache.Peek(urlHash); ok { + return page, true + } + page, _, found, err := s.media.GetWebPageByURLHash(ctx, urlHash) + if err != nil || !found { + return domain.MessageWebPage{}, false + } + s.webpage.cache.Store(urlHash, page) // 回填 L1,后续 Peek 命中。 + return page, true +} + +// ResolveWebPage 解析链接预览,经 L1 缓存(singleflight 去重)+ L3 web_pages 持久去重。 +// 返回 done / empty 形态的 MessageWebPage;瞬时失败返回 error(调用方降级为空,不报错给用户)。 +func (s *Service) ResolveWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, error) { + if s == nil || s.webpage == nil { + return domain.MessageWebPage{}, ErrWebPagePreviewDisabled + } + normalized, ok := domain.NormalizeWebPageURL(rawURL) + if !ok { + return domain.MessageWebPage{}, ErrWebPagePreviewInvalid + } + urlHash := domain.WebPageURLHash(normalized) + return s.webpage.cache.GetOrLoad(ctx, urlHash, func() (domain.MessageWebPage, error) { + // L3 durable 命中:直接复用,跨实例去重;超龄则后台刷新(返回旧卡片不阻塞)。 + if page, refreshedAt, found, err := s.media.GetWebPageByURLHash(ctx, urlHash); err == nil && found { + s.webpage.maybeRefresh(s, normalized, urlHash, refreshedAt) + return page, nil + } + // miss:抓取 + 解析(+ 图)。瞬时失败返回 error → GetOrLoad 不缓存(热门链接不被毒化)。 + page, err := s.webpage.resolve(ctx, s, normalized, urlHash) + if err != nil { + return domain.MessageWebPage{}, err + } + // 终态(done / empty):落 L3 供跨实例 + 重启复用。 + if perr := s.media.PutWebPage(ctx, urlHash, page, int(time.Now().Unix())); perr != nil { + s.log.Warn("persist web page preview failed", zap.Int64("url_hash", urlHash), zap.Error(perr)) + } + return page, nil + }) +} + +// maybeRefresh 在卡片超过 webPageRefreshTTL 龄时后台 stale-while-revalidate 刷新一次。 +// 并发受 refreshSem 限;满则跳过(下次再刷)。瞬时失败保留旧卡片。 +func (f *webpageFetcher) maybeRefresh(s *Service, normalizedURL string, urlHash int64, refreshedAt int) { + if refreshedAt == 0 || time.Now().Unix()-int64(refreshedAt) < int64(webPageRefreshTTL/time.Second) { + return + } + select { + case f.refreshSem <- struct{}{}: + default: + return + } + go func() { + defer func() { <-f.refreshSem }() + ctx, cancel := context.WithTimeout(context.Background(), webpageTotalTimeout) + defer cancel() + page, err := f.resolve(ctx, s, normalizedURL, urlHash) + if err != nil { + return // 瞬时失败:保留旧卡片。 + } + if perr := s.media.PutWebPage(ctx, urlHash, page, int(time.Now().Unix())); perr != nil { + return + } + f.cache.Store(urlHash, page) // 刷新 L1,使后续读到新卡片。 + }() +} + +// resolve 实际抓取并构造卡片。HTML 与预览图共享 ctx 总时长预算。 +func (f *webpageFetcher) resolve(ctx context.Context, s *Service, normalizedURL string, urlHash int64) (domain.MessageWebPage, error) { + ctx, cancel := context.WithTimeout(ctx, webpageTotalTimeout) + defer cancel() + + data, contentType, err := f.fetch(ctx, normalizedURL, acceptHTML) + if err != nil { + // 终态失败(SSRF/4xx/非法 URL)→ 负缓存为空预览,避免重复按键/发送重打 PG+外网。 + // 瞬时失败(5xx/超时/dial/限速)→ 上抛 error,GetOrLoad 不缓存、可重试。 + if errors.Is(err, errWebPageTerminal) { + return emptyWebPage(normalizedURL, urlHash), nil + } + return domain.MessageWebPage{}, err + } + if !isHTMLContentType(contentType) { + // 非 HTML(如直接指向图片/二进制):终态空预览。 + return emptyWebPage(normalizedURL, urlHash), nil + } + meta := parseWebPageMeta(data, normalizedURL) + if meta.empty() { + return emptyWebPage(normalizedURL, urlHash), nil + } + page := doneWebPage(meta, normalizedURL, urlHash) + if meta.image != "" { + if photo, ok := f.fetchImage(ctx, s, meta.image); ok { + page.Photo = &photo + page.HasLargeMedia = true + } + } + return page, nil +} + +// fetchImage 抓取并铸造预览图(best-effort)。解码前按尺寸拦截解压炸弹;非图片/失败丢弃。 +func (f *webpageFetcher) fetchImage(ctx context.Context, s *Service, imageURL string) (domain.Photo, bool) { + data, _, err := f.fetch(ctx, imageURL, acceptImage) + if err != nil { + return domain.Photo{}, false + } + cfg, _, derr := image.DecodeConfig(bytes.NewReader(data)) + if derr != nil || cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxWebpageImagePixels { + return domain.Photo{}, false + } + photo, err := s.CreatePhotoFromBytes(ctx, data) + if err != nil { + return domain.Photo{}, false + } + return photo, true +} + +func emptyWebPage(rawURL string, urlHash int64) domain.MessageWebPage { + return domain.MessageWebPage{State: domain.MessageWebPageStateEmpty, ID: urlHash, URL: rawURL} +} + +func doneWebPage(meta webpageMeta, rawURL string, urlHash int64) domain.MessageWebPage { + page := domain.MessageWebPage{ + State: domain.MessageWebPageStateDone, + ID: urlHash, + URL: rawURL, + DisplayURL: webpageDisplayURL(rawURL), + Type: meta.pageType(), + SiteName: meta.siteName, + Title: meta.title, + Description: meta.description, + Author: meta.author, + } + page.Hash = webpageContentHash(page) + return page +} + +// webpageMeta 是从 HTML head 提取的预览元数据(已按 og>twitter>title/meta 优先级归并)。 +type webpageMeta struct { + title string + description string + siteName string + image string + author string + ogType string +} + +func (m webpageMeta) empty() bool { + return m.title == "" && m.description == "" && m.siteName == "" && m.image == "" && m.author == "" +} + +func (m webpageMeta) pageType() string { + if m.ogType != "" { + return m.ogType + } + if m.image != "" && m.title == "" && m.description == "" { + return "photo" + } + return "article" +} + +// parseWebPageMeta 扫描 HTML head 的 <meta>/<title>,提取 OpenGraph/Twitter-card/标准元数据。 +// 遇到 <body> 或 </head> 即停止(元数据都在 head)。og:image 相对 URL 按 baseURL 解析为绝对。 +func parseWebPageMeta(htmlBytes []byte, baseURL string) webpageMeta { + var ( + m webpageMeta + ogTitle, twTitle, docTitle string + ogDesc, twDesc, metaDesc string + ogImage, twImage string + inTitle bool + ) + z := html.NewTokenizer(bytes.NewReader(htmlBytes)) +scan: + for { + switch z.Next() { + case html.ErrorToken: + break scan + case html.StartTagToken, html.SelfClosingTagToken: + name, hasAttr := z.TagName() + switch string(name) { + case "meta": + key, content := metaKeyContent(z, hasAttr) + if content == "" { + continue + } + switch key { + case "og:title": + ogTitle = content + case "og:description": + ogDesc = content + case "og:site_name": + m.siteName = content + case "og:type": + m.ogType = content + case "og:image", "og:image:url", "og:image:secure_url": + if ogImage == "" { + ogImage = content + } + case "twitter:title": + twTitle = content + case "twitter:description": + twDesc = content + case "twitter:image", "twitter:image:src": + if twImage == "" { + twImage = content + } + case "description": + metaDesc = content + case "author", "article:author": + if m.author == "" { + m.author = content + } + } + case "title": + inTitle = true + case "body": + break scan + } + case html.TextToken: + if inTitle && docTitle == "" { + docTitle = strings.TrimSpace(string(z.Text())) + } + case html.EndTagToken: + name, _ := z.TagName() + switch string(name) { + case "title": + inTitle = false + case "head": + break scan + } + } + } + m.title = firstNonEmpty(ogTitle, twTitle, docTitle) + m.description = firstNonEmpty(ogDesc, twDesc, metaDesc) + if img := firstNonEmpty(ogImage, twImage); img != "" { + if abs, ok := resolveAbsoluteURL(baseURL, img); ok { + m.image = abs + } + } + return m +} + +// metaKeyContent 从一个 <meta> 标签收集 (property|name) 与 content。 +func metaKeyContent(z *html.Tokenizer, hasAttr bool) (string, string) { + var key, content string + for hasAttr { + var k, v []byte + k, v, hasAttr = z.TagAttr() + switch strings.ToLower(string(k)) { + case "property", "name": + if key == "" { + key = strings.ToLower(strings.TrimSpace(string(v))) + } + case "content": + content = strings.TrimSpace(string(v)) + } + } + return key, content +} + +func resolveAbsoluteURL(base, ref string) (string, bool) { + b, err := url.Parse(base) + if err != nil { + return "", false + } + r, err := url.Parse(strings.TrimSpace(ref)) + if err != nil { + return "", false + } + abs := b.ResolveReference(r) + if abs.Scheme != "http" && abs.Scheme != "https" { + return "", false + } + return abs.String(), true +} + +func webpageDisplayURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return raw + } + return strings.TrimPrefix(u.Host, "www.") +} + +// webpageContentHash 对卡片内容算稳定 31-bit 哈希(webPage.hash 是 TL int,用于 getWebPage +// NotModified 短路)。仅覆盖文本字段,预览图变化不计入(同 URL 预览图按内容寻址已去重)。 +func webpageContentHash(p domain.MessageWebPage) int { + h := fnv.New32a() + for _, s := range []string{p.URL, p.Title, p.Description, p.SiteName, p.Author, p.Type} { + _, _ = h.Write([]byte(s)) + _, _ = h.Write([]byte{0}) + } + return int(h.Sum32() & 0x7fffffff) +} + +func isHTMLContentType(ct string) bool { + return ct == "" || ct == "text/html" || ct == "application/xhtml+xml" +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/app/files/webpage_test.go b/internal/app/files/webpage_test.go new file mode 100644 index 00000000..c157926d --- /dev/null +++ b/internal/app/files/webpage_test.go @@ -0,0 +1,228 @@ +package files + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "telesrv/internal/domain" +) + +// newWebpageTestService 构造一个带 loopback-allowed 抓取器的 Service(生产恒禁 loopback)。 +func newWebpageTestService(t *testing.T, allowPrivate bool) *Service { + t.Helper() + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(media, blobs, 2) + svc.webpage = newWebpageFetcher(DefaultWebPagePreviewMaxBytes, 600, allowPrivate) + return svc +} + +func TestResolveWebPageDoneCardWithImage(t *testing.T) { + imgBytes := testJPEG(t, 8, 6) + var pageHits, imgHits int32 + mux := http.NewServeMux() + mux.HandleFunc("/img.jpg", func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&imgHits, 1) + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(imgBytes) + }) + mux.HandleFunc("/article", func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&pageHits, 1) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, `<html><head> + <title>Fallback Title + + + + + + ignored body`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + svc := newWebpageTestService(t, true) + ctx := context.Background() + pageURL := srv.URL + "/article" + + page, err := svc.ResolveWebPage(ctx, pageURL) + if err != nil { + t.Fatalf("ResolveWebPage: %v", err) + } + if page.State != domain.MessageWebPageStateDone { + t.Fatalf("state = %q, want done", page.State) + } + if page.Title != "OG Title" || page.Description != "OG Description" || page.SiteName != "Example Site" || page.Type != "article" { + t.Fatalf("card fields = %+v", page) + } + if page.Photo == nil || page.Photo.ID == 0 || len(page.Photo.Sizes) == 0 { + t.Fatalf("expected minted preview photo, got %+v", page.Photo) + } + // id == url_hash(保证 pending↔done 关联)。 + normalized, _ := domain.NormalizeWebPageURL(pageURL) + if page.ID != domain.WebPageURLHash(normalized) { + t.Fatalf("webPage id %d != url_hash %d", page.ID, domain.WebPageURLHash(normalized)) + } + + // 第二次解析命中 L1 缓存(singleflight/LRU),不再打上游。 + if _, err := svc.ResolveWebPage(ctx, pageURL); err != nil { + t.Fatalf("second ResolveWebPage: %v", err) + } + if h := atomic.LoadInt32(&pageHits); h != 1 { + t.Fatalf("page fetched %d times, want 1 (cache dedup)", h) + } + if h := atomic.LoadInt32(&imgHits); h != 1 { + t.Fatalf("image fetched %d times, want 1", h) + } +} + +// TestWebPageMaybeRefreshStale 验证超龄卡片触发后台 stale-while-revalidate 刷新(写回 done)。 +func TestWebPageMaybeRefreshStale(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/article", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, ``) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + ctx := context.Background() + svc := newWebpageTestService(t, true) + normalized, _ := domain.NormalizeWebPageURL(srv.URL + "/article") + urlHash := domain.WebPageURLHash(normalized) + + // 触发刷新(refreshedAt=1 → 远超 TTL)。 + svc.webpage.maybeRefresh(svc, normalized, urlHash, 1) + + // 轮询直到刷新写回 done 卡片。 + var ok bool + for i := 0; i < 100; i++ { + if page, _, found, err := svc.media.GetWebPageByURLHash(ctx, urlHash); err == nil && found && page.State == domain.MessageWebPageStateDone && page.Title == "Fresh" { + ok = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !ok { + t.Fatalf("stale refresh did not write fresh done card") + } + + // refreshedAt=0 或新鲜 → 不刷新(不 panic、立即返回)。 + svc.webpage.maybeRefresh(svc, normalized, urlHash, 0) + svc.webpage.maybeRefresh(svc, normalized, urlHash, int(time.Now().Unix())) +} + +// TestResolveWebPageTerminalFailureNegativeCached 验证 4xx(终态)解析为空预览并负缓存—— +// 第二次不再打上游(否则每次按键/发送重复抓取坏 URL)。 +func TestResolveWebPageTerminalFailureNegativeCached(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + svc := newWebpageTestService(t, true) + ctx := context.Background() + + page, err := svc.ResolveWebPage(ctx, srv.URL+"/x") + if err != nil { + t.Fatalf("404 should be terminal-empty (not error): %v", err) + } + if page.State != domain.MessageWebPageStateEmpty { + t.Fatalf("state = %q, want empty", page.State) + } + if _, err := svc.ResolveWebPage(ctx, srv.URL+"/x"); err != nil { + t.Fatalf("second resolve: %v", err) + } + if h := atomic.LoadInt32(&hits); h != 1 { + t.Fatalf("terminal URL fetched %d times, want 1 (negative cached)", h) + } +} + +// TestResolveWebPageTransientFailureNotCached 验证 5xx(瞬时)返回 error 且不缓存——可重试。 +func TestResolveWebPageTransientFailureNotCached(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + svc := newWebpageTestService(t, true) + ctx := context.Background() + + if _, err := svc.ResolveWebPage(ctx, srv.URL+"/x"); err == nil { + t.Fatalf("500 should return transient error") + } + _, _ = svc.ResolveWebPage(ctx, srv.URL+"/x") + if h := atomic.LoadInt32(&hits); h != 2 { + t.Fatalf("transient URL fetched %d times, want 2 (not cached, retryable)", h) + } +} + +func TestResolveWebPageNoMetadataIsEmpty(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, `no meta here`) + })) + defer srv.Close() + + svc := newWebpageTestService(t, true) + page, err := svc.ResolveWebPage(context.Background(), srv.URL+"/x") + if err != nil { + t.Fatalf("ResolveWebPage: %v", err) + } + if page.State != domain.MessageWebPageStateEmpty { + t.Fatalf("state = %q, want empty", page.State) + } +} + +func TestResolveWebPageNonHTMLIsEmpty(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + _, _ = w.Write([]byte("%PDF-1.4 binary")) + })) + defer srv.Close() + + svc := newWebpageTestService(t, true) + page, err := svc.ResolveWebPage(context.Background(), srv.URL+"/doc") + if err != nil { + t.Fatalf("ResolveWebPage: %v", err) + } + if page.State != domain.MessageWebPageStateEmpty { + t.Fatalf("state = %q, want empty for non-HTML", page.State) + } +} + +// TestResolveWebPageSSRFBlocksLoopback 验证生产配置(allowPrivate=false)拦截指向 loopback 的 URL。 +func TestResolveWebPageSSRFBlocksLoopback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, ``) + })) + defer srv.Close() + + svc := newWebpageTestService(t, false) // 生产口径:禁 loopback + if _, err := svc.ResolveWebPage(context.Background(), srv.URL+"/x"); err == nil { + t.Fatalf("expected SSRF guard to block loopback fetch") + } +} + +func TestResolveWebPageDisabled(t *testing.T) { + media := newFakeMediaStore() + blobs, err := NewLocalFS(t.TempDir()) + if err != nil { + t.Fatalf("NewLocalFS: %v", err) + } + svc := NewService(media, blobs, 2) // 未启用 webpage 抓取 + if _, err := svc.ResolveWebPage(context.Background(), "https://example.com"); err == nil { + t.Fatalf("expected ErrWebPagePreviewDisabled") + } +} diff --git a/internal/app/groupcalls/service.go b/internal/app/groupcalls/service.go new file mode 100644 index 00000000..3381c803 --- /dev/null +++ b/internal/app/groupcalls/service.go @@ -0,0 +1,121 @@ +// Package groupcalls 实现超级群语音聊天(group call)的信令业务层: +// ID/access_hash 分配与 store 编排。权限(admin/成员资格)由 rpc 层校验, +// version 单调性与并发串行化由 store 层事务保证。 +package groupcalls + +import ( + "context" + "crypto/rand" + "encoding/binary" + "fmt" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 是群通话业务服务。 +type Service struct { + store store.GroupCallStore +} + +// NewService 创建群通话服务。 +func NewService(st store.GroupCallStore) *Service { + return &Service{store: st} +} + +// Create 分配 id/access_hash 并建会。 +func (s *Service) Create(ctx context.Context, channelID, creatorUserID int64, title string, now int) (domain.GroupCall, error) { + id, err := randomPositiveInt64() + if err != nil { + return domain.GroupCall{}, err + } + accessHash, err := randomPositiveInt64() + if err != nil { + return domain.GroupCall{}, err + } + return s.store.CreateGroupCall(ctx, domain.GroupCall{ + ID: id, + AccessHash: accessHash, + ChannelID: channelID, + CreatorUserID: creatorUserID, + Title: title, + Version: 1, + CreatedAt: now, + }) +} + +func (s *Service) Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error) { + return s.store.GetGroupCall(ctx, callID) +} + +func (s *Service) Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) { + return s.store.JoinGroupCall(ctx, req) +} + +func (s *Service) Leave(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error) { + return s.store.LeaveGroupCall(ctx, callID, userID, now) +} + +func (s *Service) Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) { + return s.store.DiscardGroupCall(ctx, callID, now) +} + +func (s *Service) Touch(ctx context.Context, callID, userID int64, now int) ([]int64, bool, error) { + return s.store.TouchParticipant(ctx, callID, userID, now) +} + +func (s *Service) Participant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error) { + return s.store.GetParticipant(ctx, callID, userID) +} + +func (s *Service) Participants(ctx context.Context, callID int64, offset string, limit int) (domain.GroupCallParticipantPage, error) { + return s.store.ListParticipants(ctx, callID, offset, limit) +} + +func (s *Service) UpdateParticipant(ctx context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) { + return s.store.UpdateParticipant(ctx, callID, userID, update) +} + +func (s *Service) SetTitle(ctx context.Context, callID int64, title string) (domain.GroupCall, bool, error) { + return s.store.SetGroupCallTitle(ctx, callID, title) +} + +func (s *Service) SetJoinMuted(ctx context.Context, callID int64, joinMuted bool) (domain.GroupCall, bool, error) { + return s.store.SetGroupCallJoinMuted(ctx, callID, joinMuted) +} + +func (s *Service) SetStartedMessageID(ctx context.Context, callID int64, msgID int) error { + return s.store.SetStartedMessageID(ctx, callID, msgID) +} + +func (s *Service) SweepStale(ctx context.Context, checkOlderThan, now, limit int) ([]domain.GroupCallMutation, error) { + return s.store.SweepStaleParticipants(ctx, checkOlderThan, now, limit) +} + +func (s *Service) ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error) { + return s.store.ResetAllParticipants(ctx, now) +} + +func (s *Service) NextRaiseHandRating(ctx context.Context, callID int64) (int64, error) { + return s.store.NextRaiseHandRating(ctx, callID) +} + +func (s *Service) SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error { + return s.store.SetParticipantOverride(ctx, callID, setterUserID, targetUserID, override, clear) +} + +func (s *Service) ParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error) { + return s.store.GetParticipantOverride(ctx, callID, setterUserID, targetUserID) +} + +func randomPositiveInt64() (int64, error) { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return 0, fmt.Errorf("groupcalls: random id: %w", err) + } + v := int64(binary.BigEndian.Uint64(buf[:]) >> 1) + if v == 0 { + v = 1 + } + return v, nil +} diff --git a/internal/app/help/service.go b/internal/app/help/service.go index 26b5822d..83b3704a 100644 --- a/internal/app/help/service.go +++ b/internal/app/help/service.go @@ -2,58 +2,165 @@ package help import ( "context" + "encoding/json" + "hash/crc32" + "sync" "telesrv/internal/domain" + "telesrv/internal/seed/catalog" "telesrv/internal/store" ) const tdesktopClient = "tdesktop" -const tdesktopDefaultAppConfig = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_in_chat_max":3}` + +// upload_markup_video=true 即官方默认(emoji/sticker 头像由客户端本地渲染 mp4 后随 +// markup 一起上传)。显式下发是为了把曾收到过 false 的客户端持久化配置洗回默认—— +// 客户端对缺失的 key 会保留本地旧值,仅删除 key 无法恢复。 +// emojies_send_dice 是客户端把「单 emoji 消息」转成 InputMediaDice 的白名单; +// 必须与 rpc 层 diceValueSides 的取值表保持同步(⚽ 用裸码点,客户端会自行匹配变体形态)。 +// tdesktop_config_map 是 TDesktop 位置选点器(WebView+Mapbox GL)与 business 位置设置的 +// Mapbox access token:maps/geo=聊天附件选点(地图瓦片/地理编码),bmaps/bgeo=business 位置。 +// 该 token 必须来自运行期配置;未配置时不下发 tdesktop_config_map,避免公共源码携带第三方 token。 +// premium 相关 key(docs/premium-module.md): +// - premium_purchase_blocked=false 必须显式下发:DrKLO(ProfileActivity:12059)与 TDesktop +// (window_peer_menu.cpp:1549)把「Send a Gift」(star gift)入口与 premiumCanBuy()= +// !premium_purchase_blocked 耦合在同一 flag——置 true 会同时隐藏送礼入口。star gift +// 已实现(Stars 账本),故必须 false 才能送礼;副作用是 premium 购买 UI 重现,但 premium +// 已自动授予(0094)、购买流走 stub getPaymentForm 优雅报错,可接受。 +// - stargifts_blocked=false 必须显式下发:DrKLO MessagesController:1752 缺省 stargiftsBlocked= +// true(屏蔽),GiftSheet:967 据此隐藏整个 star gift 送礼网格——缺 key 则送礼选择器恒空。 +// - reactions_user_max_premium=3 与服务端 domain.MaxMessageReactionsPerUserPremium +// 联动:premium 用户可在同一消息放 3 个 reaction,服务端档位必须 ≥ 该宣告值。 +// - dialog_filters_enabled=true 必须显式下发:TDesktop settings_main.cpp:394 据此(或账号 +// 已有文件夹)才在 Settings 显示「Folders」入口,缺 key → 新账号看不到文件夹管理、无法 +// 建文件夹/采纳 getSuggestedDialogFilters 模板。 +// - *_limit_default/_premium 双档限额对齐官方值;其中 about/dialogs_pinned/ +// dialogs_folder_pinned 有服务端 enforcement 双档,其余(channels/saved_gifs/ +// stickers_faved/dialog_filters/caption/fileparts 等)服务端为宽兜底或未 enforce, +// 客户端按 self premium flag 自限。bots_create_limit 故意不下发(服务端统一 20, +// 见 compatibility-matrix todo);chatlists 和 story 配额/商业化 key 不下发 +// (功能全族未实现,下发会诱导客户端走进未实现路径)。stories_stealth_* 是客户端 +// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。 +const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000` + +const defaultAppConfigHash = 17 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 // Service 提供客户端启动配置与国家区号目录。 +// +// app config 与国家区号属「启动后基本不变」的参考目录:运行期无写入(UpsertAppConfig/ +// UpsertCountries 仅 seed/迁移用),故各加载一次缓存进内存,之后所有 RPC 走内存、不再查库 +// (登录页/启动配置是高频握手路径)。运维改库需重启生效。timezones/emoji 等其余目录走 +// internal/seed/catalog(go:embed 一次解析),本就在内存。 type Service struct { - appConfigs store.AppConfigStore - countries store.CountryStore + appConfigs store.AppConfigStore + countries store.CountryStore + mapboxToken string + + appConfigOnce sync.Once + appConfigCache domain.AppConfig + countriesOnce sync.Once + countriesCache domain.CountriesList +} + +// Option 配置 help 服务运行期默认目录。 +type Option func(*Service) + +// WithMapboxToken 设置 TDesktop appConfig 与地图缩略图代理共用的 Mapbox token。 +func WithMapboxToken(token string) Option { + return func(s *Service) { + s.mapboxToken = token + } } // NewService 创建 help 服务。 -func NewService(appConfigs store.AppConfigStore, countries store.CountryStore) *Service { - return &Service{appConfigs: appConfigs, countries: countries} +func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, opts ...Option) *Service { + s := &Service{appConfigs: appConfigs, countries: countries} + for _, opt := range opts { + if opt != nil { + opt(s) + } + } + return s } -// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。 -func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) { - if s == nil || s.appConfigs == nil { - cfg := domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)} - return cfg, hash == cfg.Hash, nil +func defaultAppConfig(mapboxToken string) domain.AppConfig { + jsonBytes := defaultAppConfigJSON(mapboxToken) + return domain.AppConfig{Client: tdesktopClient, Hash: defaultAppConfigHashFor(mapboxToken), JSON: jsonBytes} +} + +func defaultAppConfigJSON(mapboxToken string) []byte { + if mapboxToken == "" { + return []byte(tdesktopDefaultAppConfigBase + `}`) } - cfg, found, err := s.appConfigs.GetAppConfig(ctx, tdesktopClient) + token, err := json.Marshal(mapboxToken) if err != nil { - return domain.AppConfig{}, false, err + return []byte(tdesktopDefaultAppConfigBase + `}`) } - if !found { - cfg = domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)} + tokenJSON := string(token) + return []byte(tdesktopDefaultAppConfigBase + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`) +} + +func defaultAppConfigHashFor(mapboxToken string) int { + if mapboxToken == "" { + return defaultAppConfigHash } + return defaultAppConfigHash + 1 + int(crc32.ChecksumIEEE([]byte(mapboxToken))&0x3fffffff) +} + +// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。首次调用加载一次后缓存。 +func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) { + cfg := s.loadAppConfig(ctx) return cfg, hash != 0 && hash == cfg.Hash, nil } -// GetCountries 返回国家区号目录,hash 命中时返回 notModified。 +func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig { + if s == nil { + return defaultAppConfig("") + } + defaultCfg := defaultAppConfig(s.mapboxToken) + s.appConfigOnce.Do(func() { + if s.appConfigs == nil { + s.appConfigCache = defaultCfg + return + } + cfg, found, err := s.appConfigs.GetAppConfig(ctx, tdesktopClient) + // DB 行允许运维覆盖,但 hash 落后于代码默认值时视为陈旧(历史 seed 残留), + // 以默认值为准——否则新增配置 key 永远被旧行遮蔽(曾导致 emojies_send_dice 未下发)。 + // 读失败也回退默认值(默认值恒有效),不让一次瞬时 DB 抖动污染整个进程生命周期的缓存。 + if err != nil || !found || cfg.Hash < defaultAppConfigHash { + cfg = defaultCfg + } + s.appConfigCache = cfg + }) + return s.appConfigCache +} + +// GetCountries 返回国家区号目录,hash 命中时返回 notModified。首次调用加载一次后缓存。 func (s *Service) GetCountries(ctx context.Context, langCode string, hash int) (domain.CountriesList, bool, error) { - if s == nil || s.countries == nil { - list := defaultCountries() - return list, hash != 0 && hash == list.Hash, nil - } - list, err := s.countries.ListCountries(ctx, langCode) - if err != nil { - return domain.CountriesList{}, false, err - } - if len(list.Countries) == 0 { - list = defaultCountries() - } + list := s.loadCountries(ctx) return list, hash != 0 && hash == list.Hash, nil } +func (s *Service) loadCountries(ctx context.Context) domain.CountriesList { + if s == nil || s.countries == nil { + return defaultCountries() + } + s.countriesOnce.Do(func() { + list, err := s.countries.ListCountries(ctx, "") + if err != nil || len(list.Countries) == 0 { + list = defaultCountries() + } + s.countriesCache = list + }) + return s.countriesCache +} + +// defaultCountries 返回内置国家区号目录:优先用 catalog 固化的官方全量(~235 国), +// 未 seed 时回退最小集(US/CN)。countries 表通常为空,故这就是生产实际下发的目录。 func defaultCountries() domain.CountriesList { + if c := catalog.Countries(); len(c.Countries) > 0 { + return c + } return domain.CountriesList{ Hash: 1, Countries: []domain.Country{ @@ -61,14 +168,14 @@ func defaultCountries() domain.CountriesList { ISO2: "US", DefaultName: "United States", CountryCodes: []domain.CountryCode{ - {CountryCode: "1", Prefixes: []string{"1"}}, + {CountryCode: "1", Prefixes: []string{""}, Patterns: []string{"XXX XXX XXXX"}}, }, }, { ISO2: "CN", DefaultName: "China", CountryCodes: []domain.CountryCode{ - {CountryCode: "86", Prefixes: []string{"86"}}, + {CountryCode: "86", Prefixes: []string{""}, Patterns: []string{"XXX XXXX XXXX"}}, }, }, }, diff --git a/internal/app/help/service_premium_test.go b/internal/app/help/service_premium_test.go new file mode 100644 index 00000000..b2440377 --- /dev/null +++ b/internal/app/help/service_premium_test.go @@ -0,0 +1,108 @@ +package help + +import ( + "context" + "encoding/json" + "testing" +) + +// TestAppConfigPremiumKeys 断言 premium 相关 key 完整下发且 hash 已递增: +// premium_purchase_blocked 必须显式为 false——客户端把 star gift「Send a Gift」入口与 +// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口; +// reactions_user_max_premium 必须与服务端 enforcement 档位一致。 +func TestAppConfigPremiumKeys(t *testing.T) { + cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0) + if err != nil || notModified { + t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err) + } + if cfg.Hash != defaultAppConfigHash || cfg.Hash < 10 { + t.Fatalf("hash = %d, want defaultAppConfigHash(≥10)", cfg.Hash) + } + var decoded map[string]any + if err := json.Unmarshal(cfg.JSON, &decoded); err != nil { + t.Fatalf("app config json invalid: %v", err) + } + if blocked, ok := decoded["premium_purchase_blocked"].(bool); !ok || blocked { + t.Fatalf("premium_purchase_blocked = %v, want false (star gift 送礼入口耦合此 flag)", decoded["premium_purchase_blocked"]) + } + // DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格,必须显式下发 false。 + if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked { + t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"]) + } + wantNumbers := map[string]float64{ + "reactions_user_max_default": 1, + "reactions_user_max_premium": 3, + "about_length_limit_default": 70, + "about_length_limit_premium": 140, + "dialogs_pinned_limit_default": 5, + "dialogs_pinned_limit_premium": 10, + "dialogs_folder_pinned_limit_default": 100, + "dialogs_folder_pinned_limit_premium": 200, + "saved_dialogs_pinned_limit_default": 5, + "saved_dialogs_pinned_limit_premium": 100, + "caption_length_limit_default": 1024, + "caption_length_limit_premium": 4096, + "channels_limit_default": 500, + "channels_limit_premium": 1000, + "dialog_filters_limit_default": 10, + "dialog_filters_limit_premium": 20, + "upload_max_fileparts_default": 4000, + "upload_max_fileparts_premium": 8000, + "stories_stealth_future_period": 1500, + "stories_stealth_past_period": 300, + "stories_stealth_cooldown_period": 10800, + } + for key, want := range wantNumbers { + got, ok := decoded[key].(float64) + if !ok || got != want { + t.Errorf("appConfig[%q] = %v, want %v", key, decoded[key], want) + } + } + // 未实现功能族的 key 不得下发(诱导客户端进入未实现路径)。 + for _, forbidden := range []string{"chatlists_joined_limit_default", "stories_sent_weekly_limit_default", "premium_bot_username", "premium_invoice_slug"} { + if _, ok := decoded[forbidden]; ok { + t.Errorf("appConfig 不应包含 %q", forbidden) + } + } +} + +func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) { + cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0) + if err != nil || notModified { + t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err) + } + var decoded map[string]any + if err := json.Unmarshal(cfg.JSON, &decoded); err != nil { + t.Fatalf("app config json invalid: %v", err) + } + if _, ok := decoded["tdesktop_config_map"]; ok { + t.Fatal("tdesktop_config_map present without configured Mapbox token") + } +} + +func TestAppConfigUsesConfiguredMapboxTokenAndHash(t *testing.T) { + svc := NewService(nil, nil, WithMapboxToken("pk.test-token")) + cfg, notModified, err := svc.GetAppConfig(context.Background(), 0) + if err != nil || notModified { + t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err) + } + if cfg.Hash == defaultAppConfigHash { + t.Fatalf("hash = %d, want token-specific hash", cfg.Hash) + } + if _, notModified, err := svc.GetAppConfig(context.Background(), cfg.Hash); err != nil || !notModified { + t.Fatalf("GetAppConfig(hash) = notModified %v err %v, want notModified", notModified, err) + } + var decoded map[string]any + if err := json.Unmarshal(cfg.JSON, &decoded); err != nil { + t.Fatalf("app config json invalid: %v", err) + } + configMap, ok := decoded["tdesktop_config_map"].(map[string]any) + if !ok { + t.Fatalf("tdesktop_config_map = %T, want object", decoded["tdesktop_config_map"]) + } + for _, key := range []string{"maps", "geo", "bmaps", "bgeo"} { + if got := configMap[key]; got != "pk.test-token" { + t.Fatalf("tdesktop_config_map[%q] = %v, want token", key, got) + } + } +} diff --git a/internal/app/langpack/parser.go b/internal/app/langpack/parser.go index f83c0b87..3f318cf6 100644 --- a/internal/app/langpack/parser.go +++ b/internal/app/langpack/parser.go @@ -13,7 +13,7 @@ import ( var tdesktopStringRE = regexp.MustCompile(`(?s)"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)";`) -// ParseTDesktopFile 解析 TDesktop .strings 文件为 domain 语言包。 +// ParseTDesktopFile 解析客户端 .strings 文件为 domain 语言包。 func ParseTDesktopFile(path string) (domain.LangPack, error) { pack, err := packFromFilename(path) if err != nil { @@ -21,7 +21,7 @@ func ParseTDesktopFile(path string) (domain.LangPack, error) { } data, err := os.ReadFile(path) if err != nil { - return domain.LangPack{}, fmt.Errorf("read tdesktop langpack %q: %w", path, err) + return domain.LangPack{}, fmt.Errorf("read langpack %q: %w", path, err) } plain := make([]domain.LangPackString, 0) @@ -54,22 +54,37 @@ func ParseTDesktopFile(path string) (domain.LangPack, error) { func packFromFilename(path string) (domain.LangPack, error) { name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) - const prefix = "tdesktop_" - if !strings.HasPrefix(name, prefix) { - return domain.LangPack{}, fmt.Errorf("invalid tdesktop langpack filename %q", filepath.Base(path)) + idx := strings.LastIndex(name, "_v") + if idx <= 0 || idx+2 >= len(name) { + return domain.LangPack{}, fmt.Errorf("invalid langpack filename %q", filepath.Base(path)) } - rest := strings.TrimPrefix(name, prefix) - idx := strings.LastIndex(rest, "_v") - if idx <= 0 || idx+2 >= len(rest) { - return domain.LangPack{}, fmt.Errorf("invalid tdesktop langpack filename %q", filepath.Base(path)) - } - version, err := strconv.Atoi(rest[idx+2:]) + version, err := strconv.Atoi(name[idx+2:]) if err != nil { - return domain.LangPack{}, fmt.Errorf("parse langpack version %q: %w", rest[idx+2:], err) + return domain.LangPack{}, fmt.Errorf("parse langpack version %q: %w", name[idx+2:], err) + } + + head := name[:idx] + dirPack := filepath.Base(filepath.Dir(path)) + prefix := dirPack + "_" + langPack := "" + langCode := "" + if dirPack != "." && dirPack != "" && strings.HasPrefix(head, prefix) { + langPack = dirPack + langCode = strings.TrimPrefix(head, prefix) + } else { + sep := strings.Index(head, "_") + if sep <= 0 || sep+1 >= len(head) { + return domain.LangPack{}, fmt.Errorf("invalid langpack filename %q", filepath.Base(path)) + } + langPack = head[:sep] + langCode = head[sep+1:] + } + if langPack == "" || langCode == "" { + return domain.LangPack{}, fmt.Errorf("invalid langpack filename %q", filepath.Base(path)) } return domain.LangPack{ - LangPack: "tdesktop", - LangCode: rest[:idx], + LangPack: langPack, + LangCode: langCode, Version: version, }, nil } diff --git a/internal/app/langpack/parser_test.go b/internal/app/langpack/parser_test.go index b1d1c88d..a419101c 100644 --- a/internal/app/langpack/parser_test.go +++ b/internal/app/langpack/parser_test.go @@ -1,9 +1,12 @@ package langpack import ( + "context" "os" "path/filepath" "testing" + + "telesrv/internal/store/memory" ) func TestParseTDesktopFile(t *testing.T) { @@ -35,3 +38,61 @@ func TestParseTDesktopFile(t *testing.T) { t.Fatalf("plural string = %+v", plural) } } + +func TestParseClientLangPackFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "weba_en_v12000000.strings") + if err := os.WriteFile(path, []byte(` +"NewMessageTitle" = "New Message"; +`), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + pack, err := ParseTDesktopFile(path) + if err != nil { + t.Fatalf("parse: %v", err) + } + if pack.LangPack != "weba" || pack.LangCode != "en" || pack.Version != 12000000 { + t.Fatalf("pack meta = %+v", pack) + } + if len(pack.Strings) != 1 || pack.Strings[0].Key != "NewMessageTitle" { + t.Fatalf("strings = %+v", pack.Strings) + } +} + +func TestSeedDirectoryWalksClientSubdirs(t *testing.T) { + root := t.TempDir() + for _, item := range []struct { + dir string + file string + key string + }{ + {dir: "tdesktop", file: "tdesktop_en_v1.strings", key: "lng_language_name"}, + {dir: "weba", file: "weba_en_v2.strings", key: "NewMessageTitle"}, + } { + dir := filepath.Join(root, item.dir) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir fixture: %v", err) + } + content := []byte(`"` + item.key + `" = "value";`) + if err := os.WriteFile(filepath.Join(dir, item.file), content, 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + } + + store := memory.NewLangPackStore() + service := NewService(store) + seeded, err := service.SeedDirectory(context.Background(), root) + if err != nil { + t.Fatalf("seed: %v", err) + } + if seeded != 2 { + t.Fatalf("seeded = %d, want 2", seeded) + } + pack, err := service.GetLangPack(context.Background(), "weba", "en") + if err != nil { + t.Fatalf("get weba pack: %v", err) + } + if pack.Version != 2 || len(pack.Strings) != 1 || pack.Strings[0].Key != "NewMessageTitle" { + t.Fatalf("weba pack = %+v", pack) + } +} diff --git a/internal/app/langpack/seed.go b/internal/app/langpack/seed.go index 5ab4eadc..1abbf513 100644 --- a/internal/app/langpack/seed.go +++ b/internal/app/langpack/seed.go @@ -22,35 +22,34 @@ func (s *Service) SeedDirectory(ctx context.Context, root string) (int, error) { } return 0, fmt.Errorf("stat langpack seed dir: %w", err) } - tdesktopDir := filepath.Join(dir, "tdesktop") - if info, err := os.Stat(tdesktopDir); err == nil && info.IsDir() { - dir = tdesktopDir - } - entries, err := os.ReadDir(dir) - if err != nil { - return 0, fmt.Errorf("read langpack seed dir: %w", err) - } seeded := 0 - for _, entry := range entries { - if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") { - continue - } - pack, err := ParseTDesktopFile(filepath.Join(dir, entry.Name())) + err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { if err != nil { - return seeded, err + return err + } + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".strings") { + return nil + } + pack, err := ParseTDesktopFile(path) + if err != nil { + return err } existing, err := s.packs.GetPack(ctx, pack.LangPack, pack.LangCode, pack.Version) if err != nil { - return seeded, err + return err } if existing.Version >= pack.Version { - continue + return nil } if err := s.packs.UpsertPack(ctx, pack); err != nil { - return seeded, err + return err } seeded += len(pack.Strings) + return nil + }) + if err != nil { + return seeded, fmt.Errorf("walk langpack seed dir: %w", err) } return seeded, nil } diff --git a/internal/app/maintenance/retention.go b/internal/app/maintenance/retention.go index 9c13087c..b668c7c6 100644 --- a/internal/app/maintenance/retention.go +++ b/internal/app/maintenance/retention.go @@ -12,6 +12,16 @@ type DispatchOutboxRetentionStore interface { DeleteFailed(ctx context.Context, olderThan time.Duration, limit int) (int, error) } +// TempAuthKeyRetentionStore 回收过期的 PFS temp auth key 绑定。 +type TempAuthKeyRetentionStore interface { + DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error) +} + +// tempAuthKeyExpiryGrace 是 temp key 过期后的回收宽限:ResolveAuthKey 对 +// 「已过期但 perm 已授权」的绑定是容忍的,立即删除会突然断掉这批宽限中的 +// 连接;回收目标是清堆积,晚一天无妨。 +const tempAuthKeyExpiryGrace = 24 * time.Hour + // RetentionWorker 周期性回收存储中的死数据。 // // 注意:本 worker 刻意不清理 user_update_events —— pts log 永久保留。原因:TDesktop 不支持 @@ -22,13 +32,14 @@ type DispatchOutboxRetentionStore interface { // 长期膨胀作为已知 todo。 type RetentionWorker struct { outbox DispatchOutboxRetentionStore + tempKeys TempAuthKeyRetentionStore // 可为 nil(不回收 temp key 绑定) logger *zap.Logger retention time.Duration interval time.Duration batch int } -func NewRetentionWorker(outbox DispatchOutboxRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker { +func NewRetentionWorker(outbox DispatchOutboxRetentionStore, tempKeys TempAuthKeyRetentionStore, logger *zap.Logger, retention, interval time.Duration, batch int) *RetentionWorker { if logger == nil { logger = zap.NewNop() } @@ -43,6 +54,7 @@ func NewRetentionWorker(outbox DispatchOutboxRetentionStore, logger *zap.Logger, } return &RetentionWorker{ outbox: outbox, + tempKeys: tempKeys, logger: logger, retention: retention, interval: interval, @@ -71,4 +83,13 @@ func (w *RetentionWorker) runOnce(ctx context.Context) { } else if outboxDeleted > 0 { w.logger.Info("清理 failed dispatch_outbox 完成", zap.Int("deleted", outboxDeleted)) } + if w.tempKeys != nil { + expiredBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix() + tempDeleted, err := w.tempKeys.DeleteExpired(ctx, expiredBefore, w.batch) + if err != nil { + w.logger.Warn("回收过期 temp auth key 绑定失败", zap.Error(err)) + } else if tempDeleted > 0 { + w.logger.Info("回收过期 temp auth key 绑定完成", zap.Int("deleted", tempDeleted)) + } + } } diff --git a/internal/app/maintenance/retention_test.go b/internal/app/maintenance/retention_test.go new file mode 100644 index 00000000..ca34aaba --- /dev/null +++ b/internal/app/maintenance/retention_test.go @@ -0,0 +1,59 @@ +package maintenance + +import ( + "context" + "testing" + "time" + + "go.uber.org/zap" +) + +type fakeOutboxRetention struct { + calls int +} + +func (f *fakeOutboxRetention) DeleteFailed(context.Context, time.Duration, int) (int, error) { + f.calls++ + return 0, nil +} + +type fakeTempKeyRetention struct { + calls int + expiredBefore int64 + limit int +} + +func (f *fakeTempKeyRetention) DeleteExpired(_ context.Context, expiredBefore int64, limit int) (int, error) { + f.calls++ + f.expiredBefore = expiredBefore + f.limit = limit + return 3, nil +} + +func TestRetentionWorkerReclaimsExpiredTempKeys(t *testing.T) { + outbox := &fakeOutboxRetention{} + temp := &fakeTempKeyRetention{} + w := NewRetentionWorker(outbox, temp, zap.NewNop(), time.Hour, time.Hour, 100) + + w.runOnce(context.Background()) + + if outbox.calls != 1 || temp.calls != 1 { + t.Fatalf("calls outbox=%d temp=%d, want 1/1", outbox.calls, temp.calls) + } + if temp.limit != 100 { + t.Fatalf("limit = %d, want batch 100", temp.limit) + } + wantBefore := time.Now().Add(-tempAuthKeyExpiryGrace).Unix() + if diff := temp.expiredBefore - wantBefore; diff < -5 || diff > 5 { + t.Fatalf("expiredBefore = %d, want ≈ now-grace (%d)", temp.expiredBefore, wantBefore) + } +} + +func TestRetentionWorkerSkipsNilTempKeyStore(t *testing.T) { + outbox := &fakeOutboxRetention{} + w := NewRetentionWorker(outbox, nil, zap.NewNop(), time.Hour, time.Hour, 100) + w.runOnce(context.Background()) // 不应 panic + if outbox.calls != 1 { + t.Fatalf("outbox calls = %d, want 1", outbox.calls) + } +} diff --git a/internal/app/messages/business_ai_echo.go b/internal/app/messages/business_ai_echo.go new file mode 100644 index 00000000..14b4aa62 --- /dev/null +++ b/internal/app/messages/business_ai_echo.go @@ -0,0 +1,27 @@ +package messages + +import ( + "context" + "strings" + + "telesrv/internal/domain" +) + +type EchoBusinessAutomationProvider struct{} + +func NewEchoBusinessAutomationProvider() EchoBusinessAutomationProvider { + return EchoBusinessAutomationProvider{} +} + +func (EchoBusinessAutomationProvider) BusinessAutomationReplies(_ context.Context, input BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error) { + body := input.TriggerMessage.Body + if strings.TrimSpace(body) == "" { + return nil, nil + } + return []domain.QuickReplyMessage{{ + ID: 1, + Date: input.Now, + Message: body, + Entities: append([]domain.MessageEntity(nil), input.TriggerMessage.Entities...), + }}, nil +} diff --git a/internal/app/messages/business_automation.go b/internal/app/messages/business_automation.go new file mode 100644 index 00000000..f52de789 --- /dev/null +++ b/internal/app/messages/business_automation.go @@ -0,0 +1,418 @@ +package messages + +import ( + "context" + "encoding/binary" + "errors" + "hash/fnv" + "time" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +type BusinessAutomationOnlineChecker interface { + IsUserOnline(userID int64) bool +} + +type BusinessAutomationReplyProvider interface { + BusinessAutomationReplies(ctx context.Context, input BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error) +} + +type BusinessAutomationReplyInput struct { + Kind domain.BusinessAutomationKind + OwnerUserID int64 + CustomerUserID int64 + Profile domain.BusinessProfile + TriggerMessage domain.Message + Templates []domain.QuickReplyMessage + Now int +} + +type businessAutomationConfig struct { + store store.BusinessAutomationStore + online BusinessAutomationOnlineChecker + replyProvider BusinessAutomationReplyProvider +} + +type BusinessAutomationOption func(*businessAutomationConfig) + +func WithBusinessAutomation(business store.BusinessAutomationStore, opts ...BusinessAutomationOption) Option { + return func(s *Service) { + cfg := &businessAutomationConfig{store: business} + for _, opt := range opts { + opt(cfg) + } + s.business = cfg + } +} + +func WithBusinessAutomationOnlineChecker(online BusinessAutomationOnlineChecker) BusinessAutomationOption { + return func(cfg *businessAutomationConfig) { + cfg.online = online + } +} + +func WithBusinessAutomationReplyProvider(provider BusinessAutomationReplyProvider) BusinessAutomationOption { + return func(cfg *businessAutomationConfig) { + cfg.replyProvider = provider + } +} + +type businessAutomationContext struct { + ownerUserID int64 + customerUserID int64 + existingChat bool + lastActivityDate int + isContact bool +} + +func (s *Service) prepareBusinessAutomation(ctx context.Context, req domain.SendPrivateTextRequest) (businessAutomationContext, bool) { + if !s.shouldConsiderBusinessAutomation(req) { + return businessAutomationContext{}, false + } + out := businessAutomationContext{ + ownerUserID: req.RecipientUserID, + customerUserID: req.SenderUserID, + } + if s.dialogs != nil { + list, err := s.dialogs.ListByPeers(ctx, req.RecipientUserID, []domain.Peer{{Type: domain.PeerTypeUser, ID: req.SenderUserID}}) + if err != nil { + return businessAutomationContext{}, false + } + if len(list.Dialogs) > 0 && list.Dialogs[0].TopMessage > 0 { + out.existingChat = true + out.lastActivityDate = list.Dialogs[0].TopMessageDate + } + } + if s.contacts != nil { + _, ok, err := s.contacts.Get(ctx, req.RecipientUserID, req.SenderUserID) + if err != nil { + return businessAutomationContext{}, false + } + out.isContact = ok + } + return out, true +} + +func (s *Service) shouldConsiderBusinessAutomation(req domain.SendPrivateTextRequest) bool { + if s == nil || s.business == nil || s.business.store == nil { + return false + } + if req.BusinessAutomationKind != "" || req.RecipientBlocked { + return false + } + if req.SenderUserID == 0 || req.RecipientUserID == 0 || req.SenderUserID == req.RecipientUserID { + return false + } + if s.botResponder != nil && (s.botResponder.HandlesBot(req.SenderUserID) || s.botResponder.HandlesBot(req.RecipientUserID)) { + return false + } + return true +} + +func (s *Service) runBusinessAutomation(ctx context.Context, req domain.SendPrivateTextRequest, res domain.SendPrivateTextResult, automation businessAutomationContext) { + now := req.Date + if now == 0 { + now = res.RecipientMessage.Date + } + if now == 0 { + now = int(time.Now().Unix()) + } + trigger := res.RecipientMessage + if trigger.ID == 0 { + return + } + delivered, err := s.deliverConnectedBusinessBotAutomation(ctx, trigger, automation, now) + if err != nil || delivered { + return + } + profile, ok, err := s.business.store.GetBusinessProfile(ctx, automation.ownerUserID) + if err != nil || !ok { + return + } + profile.UserID = automation.ownerUserID + if profile.Greeting != nil && s.businessGreetingEligible(*profile.Greeting, automation, now) { + _ = s.deliverBusinessAutomation(ctx, profile, trigger, automation.customerUserID, domain.BusinessAutomationGreeting, profile.Greeting.ShortcutID, now) + return + } + if profile.Away != nil && s.businessAwayEligible(ctx, profile, *profile.Away, automation, now) { + _ = s.deliverBusinessAutomation(ctx, profile, trigger, automation.customerUserID, domain.BusinessAutomationAway, profile.Away.ShortcutID, now) + } +} + +func (s *Service) businessGreetingEligible(greeting domain.BusinessGreetingMessage, automation businessAutomationContext, now int) bool { + if !businessRecipientsMatch(greeting.Recipients, automation.existingChat, automation.isContact, automation.customerUserID) { + return false + } + if !automation.existingChat { + return true + } + if automation.lastActivityDate <= 0 { + return false + } + return now-automation.lastActivityDate >= greeting.NoActivityDays*24*60*60 +} + +func (s *Service) businessAwayEligible(ctx context.Context, profile domain.BusinessProfile, away domain.BusinessAwayMessage, automation businessAutomationContext, now int) bool { + if !businessRecipientsMatch(away.Recipients, automation.existingChat, automation.isContact, automation.customerUserID) { + return false + } + if away.OfflineOnly && s.business.online != nil && s.business.online.IsUserOnline(automation.ownerUserID) { + return false + } + if !businessAwayScheduleActive(profile.WorkHours, away.Schedule, now) { + return false + } + last, ok, err := s.business.store.LastBusinessAutomationDelivery(ctx, automation.ownerUserID, automation.customerUserID, domain.BusinessAutomationAway) + if err != nil { + return false + } + if !ok { + return true + } + if away.Schedule.Kind == domain.BusinessAwayScheduleCustom && last.SentAt < away.Schedule.StartDate { + return true + } + return now-last.SentAt >= domain.BusinessAwayCooldownSeconds +} + +func (s *Service) deliverConnectedBusinessBotAutomation(ctx context.Context, trigger domain.Message, automation businessAutomationContext, now int) (bool, error) { + if s.business.replyProvider == nil { + return false, nil + } + connected, ok, err := s.business.store.GetConnectedBusinessBot(ctx, automation.ownerUserID) + if err != nil || !ok || connected.BotUserID == 0 || !connected.Rights.Reply { + return false, err + } + state, stateFound, err := s.business.store.GetConnectedBusinessBotPeerState(ctx, automation.ownerUserID, automation.customerUserID) + if err != nil { + return false, err + } + if stateFound && (state.Paused || state.Disabled) { + return false, nil + } + if !domain.BusinessBotRecipientsMatch(connected.Recipients, automation.existingChat, automation.isContact, automation.customerUserID) { + return false, nil + } + profile := domain.BusinessProfile{UserID: automation.ownerUserID} + msgs, err := s.businessAutomationMessages(ctx, profile, trigger, automation.customerUserID, domain.BusinessAutomationAI, 0, now) + if err != nil || len(msgs) == 0 { + return false, err + } + if s.contacts != nil { + blocked, err := s.contacts.IsBlocked(ctx, automation.customerUserID, automation.ownerUserID) + if err != nil || blocked { + return false, err + } + } + reserved, err := s.business.store.ReserveBusinessAutomationDelivery(ctx, domain.BusinessAutomationDelivery{ + OwnerUserID: automation.ownerUserID, + PeerUserID: automation.customerUserID, + Kind: domain.BusinessAutomationAI, + TriggerMessageID: trigger.ID, + ShortcutID: 0, + SentAt: now, + }) + if err != nil || !reserved { + return false, err + } + for i, msg := range msgs { + _, err := s.SendPrivateText(ctx, automation.ownerUserID, domain.SendPrivateTextRequest{ + SenderUserID: automation.ownerUserID, + RecipientUserID: automation.customerUserID, + RandomID: businessAutomationRandomID(domain.BusinessAutomationAI, automation.ownerUserID, automation.customerUserID, trigger.ID, msg.ID, i), + Message: msg.Message, + Entities: append([]domain.MessageEntity(nil), msg.Entities...), + Date: now, + ViaBotID: connected.BotUserID, + BusinessAutomationKind: domain.BusinessAutomationAI, + }) + if err != nil { + return false, err + } + } + return true, nil +} + +func (s *Service) deliverBusinessAutomation(ctx context.Context, profile domain.BusinessProfile, trigger domain.Message, customerUserID int64, kind domain.BusinessAutomationKind, shortcutID int, now int) error { + if shortcutID <= 0 { + return nil + } + msgs, err := s.businessAutomationMessages(ctx, profile, trigger, customerUserID, kind, shortcutID, now) + if err != nil || len(msgs) == 0 { + return err + } + if s.contacts != nil { + blocked, err := s.contacts.IsBlocked(ctx, customerUserID, profile.UserID) + if err != nil || blocked { + return err + } + } + reserved, err := s.business.store.ReserveBusinessAutomationDelivery(ctx, domain.BusinessAutomationDelivery{ + OwnerUserID: profile.UserID, + PeerUserID: customerUserID, + Kind: kind, + TriggerMessageID: trigger.ID, + ShortcutID: shortcutID, + SentAt: now, + }) + if err != nil || !reserved { + return err + } + for i, msg := range msgs { + _, err := s.SendPrivateText(ctx, profile.UserID, domain.SendPrivateTextRequest{ + SenderUserID: profile.UserID, + RecipientUserID: customerUserID, + RandomID: businessAutomationRandomID(kind, profile.UserID, customerUserID, trigger.ID, msg.ID, i), + Message: msg.Message, + Entities: append([]domain.MessageEntity(nil), msg.Entities...), + Date: now, + BusinessAutomationKind: kind, + }) + if err != nil { + return err + } + } + return nil +} + +func (s *Service) businessAutomationMessages(ctx context.Context, profile domain.BusinessProfile, trigger domain.Message, customerUserID int64, kind domain.BusinessAutomationKind, shortcutID int, now int) ([]domain.QuickReplyMessage, error) { + var templateMessages []domain.QuickReplyMessage + templates, err := s.business.store.GetQuickReplyMessages(ctx, profile.UserID, shortcutID, nil) + if err != nil { + if s.business.replyProvider == nil || !errors.Is(err, domain.ErrShortcutInvalid) { + return nil, err + } + } else { + templateMessages = templates.Messages + } + msgs := cloneBusinessAutomationMessages(templateMessages) + if s.business.replyProvider != nil { + msgs, err = s.business.replyProvider.BusinessAutomationReplies(ctx, BusinessAutomationReplyInput{ + Kind: kind, + OwnerUserID: profile.UserID, + CustomerUserID: customerUserID, + Profile: profile, + TriggerMessage: trigger, + Templates: cloneBusinessAutomationMessages(templateMessages), + Now: now, + }) + if err != nil { + return nil, err + } + msgs = cloneBusinessAutomationMessages(msgs) + } + out := make([]domain.QuickReplyMessage, 0, len(msgs)) + for _, msg := range msgs { + if msg.Message == "" || utf8.RuneCountInString(msg.Message) > domain.MaxMessageTextLength || len(msg.Entities) > domain.MaxMessageEntityCount { + continue + } + out = append(out, msg) + if len(out) >= domain.MaxQuickReplyMessages { + break + } + } + return out, nil +} + +func businessRecipientsMatch(recipients domain.BusinessRecipients, existingChat, isContact bool, userID int64) bool { + selected := false + if existingChat && recipients.ExistingChats { + selected = true + } + if !existingChat && recipients.NewChats { + selected = true + } + if isContact && recipients.Contacts { + selected = true + } + if !isContact && recipients.NonContacts { + selected = true + } + for _, id := range recipients.Users { + if id == userID { + selected = true + break + } + } + if recipients.ExcludeSelected { + return !selected + } + return selected +} + +func businessAwayScheduleActive(hours *domain.BusinessWorkHours, schedule domain.BusinessAwaySchedule, now int) bool { + switch schedule.Kind { + case domain.BusinessAwayScheduleAlways: + return true + case domain.BusinessAwayScheduleCustom: + return now >= schedule.StartDate && now < schedule.EndDate + case domain.BusinessAwayScheduleOutsideWorkHours: + open, ok := businessWorkHoursOpen(hours, now) + return ok && !open + default: + return false + } +} + +func businessWorkHoursOpen(hours *domain.BusinessWorkHours, now int) (bool, bool) { + if hours == nil || hours.TimezoneID == "" || len(hours.WeeklyOpen) == 0 { + return false, false + } + loc, err := time.LoadLocation(hours.TimezoneID) + if err != nil { + return false, false + } + local := time.Unix(int64(now), 0).In(loc) + weekday := (int(local.Weekday()) + 6) % 7 + minute := weekday*24*60 + local.Hour()*60 + local.Minute() + const weekMinutes = 7 * 24 * 60 + for _, item := range hours.WeeklyOpen { + if item.StartMinute < 0 || item.EndMinute <= item.StartMinute || item.EndMinute > 8*24*60 { + continue + } + if item.EndMinute <= weekMinutes { + if minute >= item.StartMinute && minute < item.EndMinute { + return true, true + } + continue + } + if minute >= item.StartMinute || minute+weekMinutes < item.EndMinute { + return true, true + } + } + return false, true +} + +func businessAutomationRandomID(kind domain.BusinessAutomationKind, ownerUserID, customerUserID int64, triggerMessageID, templateMessageID, index int) int64 { + h := fnv.New64a() + _, _ = h.Write([]byte(kind)) + _, _ = h.Write([]byte{0}) + writeBusinessAutomationHashInt64(h, ownerUserID) + writeBusinessAutomationHashInt64(h, customerUserID) + writeBusinessAutomationHashInt64(h, int64(triggerMessageID)) + writeBusinessAutomationHashInt64(h, int64(templateMessageID)) + writeBusinessAutomationHashInt64(h, int64(index)) + id := int64(h.Sum64() & 0x7fffffffffffffff) + if id == 0 { + return 1 + } + return id +} + +func writeBusinessAutomationHashInt64(h interface{ Write([]byte) (int, error) }, v int64) { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], uint64(v)) + _, _ = h.Write(buf[:]) +} + +func cloneBusinessAutomationMessages(in []domain.QuickReplyMessage) []domain.QuickReplyMessage { + out := make([]domain.QuickReplyMessage, 0, len(in)) + for _, msg := range in { + msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) + out = append(out, msg) + } + return out +} diff --git a/internal/app/messages/private_media_count_cache.go b/internal/app/messages/private_media_count_cache.go new file mode 100644 index 00000000..35b8ccaa --- /dev/null +++ b/internal/app/messages/private_media_count_cache.go @@ -0,0 +1,163 @@ +package messages + +import ( + "context" + "sync" + "time" + + "telesrv/internal/app/readmodel" + "telesrv/internal/domain" +) + +const ( + defaultPrivateMediaCountReadModelTTL = 24 * time.Hour + privateMediaCountReadModelMaxEntries = 8192 +) + +type privateMediaCountCacheKey struct { + userID int64 + peerID int64 +} + +type privateMediaCountSnapshot struct { + counts domain.MediaCategoryCounts + hash int64 + expireAt time.Time +} + +type privateMediaCountReadModelCache struct { + ttl time.Duration + now func() time.Time + + mu sync.RWMutex + snapshots map[privateMediaCountCacheKey]privateMediaCountSnapshot + epoch uint64 +} + +func newPrivateMediaCountReadModelCache(ttl time.Duration) *privateMediaCountReadModelCache { + if ttl <= 0 { + ttl = defaultPrivateMediaCountReadModelTTL + } + return &privateMediaCountReadModelCache{ + ttl: ttl, + now: time.Now, + snapshots: make(map[privateMediaCountCacheKey]privateMediaCountSnapshot, 1024), + } +} + +func (s *Service) cachedPrivateMediaCounts(ctx context.Context, userID, peerID int64) (domain.MediaCategoryCounts, error) { + if s.privateMediaCountCache == nil || s.versions == nil { + return s.messages.CountPrivateMediaCategories(ctx, userID, peerID) + } + hash, found, err := s.versions.ReadModelHash(ctx, readmodel.ModelPrivateMediaCounts, userID, domain.PeerTypeUser, peerID) + if err != nil { + return nil, err + } + if !found || hash == 0 { + return s.messages.CountPrivateMediaCategories(ctx, userID, peerID) + } + key := privateMediaCountCacheKey{userID: userID, peerID: peerID} + loadEpoch := s.privateMediaCountCache.cacheEpoch() + if snap, ok := s.privateMediaCountCache.lookup(key, s.privateMediaCountCache.now(), hash); ok { + return clonePrivateMediaCategoryCounts(snap.counts), nil + } + counts, err := s.messages.CountPrivateMediaCategories(ctx, userID, peerID) + if err != nil { + return nil, err + } + s.privateMediaCountCache.putIfEpoch(key, counts, hash, loadEpoch) + return counts, nil +} + +func (c *privateMediaCountReadModelCache) lookup(key privateMediaCountCacheKey, now time.Time, currentHash int64) (privateMediaCountSnapshot, bool) { + c.mu.RLock() + snap, ok := c.snapshots[key] + c.mu.RUnlock() + if !ok || !snap.expireAt.After(now) { + if ok { + c.invalidate(key) + } + return privateMediaCountSnapshot{}, false + } + if currentHash != 0 && snap.hash != currentHash { + return privateMediaCountSnapshot{}, false + } + return snap, true +} + +func (c *privateMediaCountReadModelCache) putIfEpoch(key privateMediaCountCacheKey, counts domain.MediaCategoryCounts, hash int64, expectedEpoch uint64) { + if c == nil || key.userID == 0 || key.peerID == 0 || hash == 0 { + return + } + if c.cacheEpoch() != expectedEpoch { + return + } + snap := privateMediaCountSnapshot{ + counts: clonePrivateMediaCategoryCounts(counts), + hash: hash, + expireAt: c.now().Add(c.ttl), + } + c.mu.Lock() + defer c.mu.Unlock() + if c.epoch != expectedEpoch { + return + } + if len(c.snapshots) >= privateMediaCountReadModelMaxEntries { + c.snapshots = make(map[privateMediaCountCacheKey]privateMediaCountSnapshot, 1024) + } + c.snapshots[key] = snap +} + +func (c *privateMediaCountReadModelCache) invalidate(keys ...privateMediaCountCacheKey) { + if c == nil || len(keys) == 0 { + return + } + c.mu.Lock() + c.epoch++ + for _, key := range keys { + delete(c.snapshots, key) + } + c.mu.Unlock() +} + +func (c *privateMediaCountReadModelCache) flush() { + if c == nil { + return + } + c.mu.Lock() + c.epoch++ + c.snapshots = make(map[privateMediaCountCacheKey]privateMediaCountSnapshot, 1024) + c.mu.Unlock() +} + +func (s *Service) InvalidatePrivateMediaCountReadModel(userID, peerID int64) { + if s == nil || s.privateMediaCountCache == nil || userID == 0 || peerID == 0 { + return + } + s.privateMediaCountCache.invalidate(privateMediaCountCacheKey{userID: userID, peerID: peerID}) +} + +func (s *Service) FlushPrivateMediaCountReadModel() { + if s == nil || s.privateMediaCountCache == nil { + return + } + s.privateMediaCountCache.flush() +} + +func (c *privateMediaCountReadModelCache) cacheEpoch() uint64 { + c.mu.RLock() + epoch := c.epoch + c.mu.RUnlock() + return epoch +} + +func clonePrivateMediaCategoryCounts(in domain.MediaCategoryCounts) domain.MediaCategoryCounts { + if len(in) == 0 { + return domain.MediaCategoryCounts{} + } + out := make(domain.MediaCategoryCounts, len(in)) + for category, count := range in { + out[category] = count + } + return out +} diff --git a/internal/app/messages/service.go b/internal/app/messages/service.go index 33ea90cc..0d2f305d 100644 --- a/internal/app/messages/service.go +++ b/internal/app/messages/service.go @@ -10,12 +10,32 @@ import ( // Service 提供消息历史、搜索与已读业务。 type Service struct { - messages store.MessageStore - dialogs store.DialogStore - contacts store.ContactStore - photos userprojection.ProfilePhotoProvider - privacy userprojection.PrivacyEvaluator - projector *userprojection.Projector + messages store.MessageStore + dialogs store.DialogStore + contacts store.ContactStore + photos userprojection.ProfilePhotoProvider + privacy userprojection.PrivacyEvaluator + versions store.ReadModelVersionStore + projector *userprojection.Projector + botResponder BotResponder + sendGate SendPermissionChecker + business *businessAutomationConfig + + privateMediaCountCache *privateMediaCountReadModelCache +} + +type SendPermissionChecker interface { + CanSendMessages(ctx context.Context, userID int64) error +} + +// BotResponder 响应投递给服务端内置 bot(BotFather)的私聊消息。 +// 实现方在用户消息已成功入库后被同步调用;回复失败只能记日志, +// 绝不允许影响用户消息发送结果。 +type BotResponder interface { + // HandlesBot 报告 botUserID 是否为该 responder 负责的内置 bot。 + HandlesBot(botUserID int64) bool + // OnPrivateMessage 处理一条投递给内置 bot 的消息;msg 为 bot 视角收件 box 行。 + OnPrivateMessage(ctx context.Context, botUserID int64, msg domain.Message) } // Option adjusts optional message service dependencies. @@ -36,9 +56,27 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option { return func(s *Service) { s.privacy = p } } +// WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。 +func WithBotResponder(r BotResponder) Option { + return func(s *Service) { s.botResponder = r } +} + +func WithSendPermissionChecker(c SendPermissionChecker) Option { + return func(s *Service) { s.sendGate = c } +} + +// WithReadModelVersions enables durable hash-token guarded media count caching. +func WithReadModelVersions(v store.ReadModelVersionStore) Option { + return func(s *Service) { s.versions = v } +} + // NewService 创建 messages 服务。 func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...Option) *Service { - s := &Service{messages: messages, dialogs: dialogs} + s := &Service{ + messages: messages, + dialogs: dialogs, + privateMediaCountCache: newPrivateMediaCountReadModelCache(defaultPrivateMediaCountReadModelTTL), + } for _, opt := range opts { opt(s) } @@ -58,7 +96,102 @@ func (s *Service) SendPrivateText(ctx context.Context, userID int64, req domain. if req.SenderUserID == 0 { req.SenderUserID = userID } - return s.messages.SendPrivateText(ctx, req) + if err := s.ensureCanSend(ctx, req.SenderUserID); err != nil { + return domain.SendPrivateTextResult{}, err + } + automation, automationOK := s.prepareBusinessAutomation(ctx, req) + res, err := s.messages.SendPrivateText(ctx, req) + if err == nil && !res.Duplicate && automationOK { + s.runBusinessAutomation(ctx, req, res, automation) + } + // 内置 bot 应答:用户消息已提交(幂等重放除外)后同步触发;responder 自行 + // 兜错,不回传失败。bot 自己发出的消息不触发(SenderUserID 不会是内置 bot + // 的对话对象集合里关心的方向——hook 只看收件人)。 + if err == nil && !res.Duplicate && req.BusinessAutomationKind == "" && s.botResponder != nil && s.botResponder.HandlesBot(req.RecipientUserID) { + s.botResponder.OnPrivateMessage(ctx, req.RecipientUserID, res.RecipientMessage) + } + return res, err +} + +func (s *Service) ensureCanSend(ctx context.Context, userID int64) error { + if s == nil || s.sendGate == nil || userID == 0 { + return nil + } + return s.sendGate.CanSendMessages(ctx, userID) +} + +// SetChatTheme updates the shared private-chat theme and records the timeline service message. +func (s *Service) SetChatTheme(ctx context.Context, userID int64, req domain.SetPrivateChatThemeRequest) (domain.SetPrivateChatThemeResult, error) { + out := domain.SetPrivateChatThemeResult{ + OwnerUserID: userID, + Peer: req.Peer, + Emoticon: req.Emoticon, + } + if s == nil || s.messages == nil || s.dialogs == nil || userID == 0 { + return out, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + if req.OwnerUserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { + return out, domain.ErrMessageIDInvalid + } + changedSelf, err := s.dialogs.SetChatTheme(ctx, userID, req.Peer, req.Emoticon) + if err != nil { + return out, err + } + otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + changedPeer := false + if req.Peer.ID != userID && !req.RecipientBlocked { + changedPeer, err = s.dialogs.SetChatTheme(ctx, req.Peer.ID, otherPeer, req.Emoticon) + if err != nil { + return out, err + } + } + out.Changed = changedSelf || changedPeer + if !out.Changed { + return out, nil + } + send, err := s.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: req.Peer.ID, + RandomID: chatThemeServiceMessageRandomID(userID, req.Peer.ID, req.Emoticon, req.Date), + Media: chatThemeServiceMedia(req.Emoticon), + Silent: true, + Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, + OriginSessionID: req.OriginSessionID, + RecipientBlocked: req.RecipientBlocked, + }) + if err != nil { + return out, err + } + out.Send = send + return out, nil +} + +func chatThemeServiceMedia(emoticon string) *domain.MessageMedia { + return &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionSetChatTheme, + ChatThemeEmoticon: emoticon, + }, + } +} + +func chatThemeServiceMessageRandomID(userID, peerUserID int64, emoticon string, date int) int64 { + var id int64 = 0x43485448454d45 + id ^= userID << 21 + id ^= peerUserID << 7 + id ^= int64(date) << 33 + for _, r := range emoticon { + id = (id << 5) - id + int64(r) + } + if id == 0 { + return 0x43485401 + } + return id } // ForwardPrivateMessages 转发当前账号可见的私聊文本消息。 @@ -69,6 +202,9 @@ func (s *Service) ForwardPrivateMessages(ctx context.Context, userID int64, req if req.OwnerUserID == 0 { req.OwnerUserID = userID } + if err := s.ensureCanSend(ctx, req.OwnerUserID); err != nil { + return domain.ForwardPrivateMessagesResult{OwnerUserID: req.OwnerUserID}, err + } return s.messages.ForwardPrivateMessages(ctx, req) } @@ -94,6 +230,22 @@ func (s *Service) Search(ctx context.Context, userID int64, filter domain.Messag return s.list(ctx, userID, filter) } +// SearchPrivateMedia 返回某私聊会话中属于给定媒体类别的消息(共享媒体标签页)。 +func (s *Service) SearchPrivateMedia(ctx context.Context, userID, peerID int64, req domain.MediaSearchRequest) (domain.MessageList, error) { + if s == nil || s.messages == nil || userID == 0 || peerID == 0 { + return domain.MessageList{}, nil + } + return s.messages.SearchPrivateMedia(ctx, userID, peerID, req) +} + +// CountPrivateMediaCategories 返回某私聊会话按基础媒体类别聚合的精确计数。 +func (s *Service) CountPrivateMediaCategories(ctx context.Context, userID, peerID int64) (domain.MediaCategoryCounts, error) { + if s == nil || s.messages == nil || userID == 0 || peerID == 0 { + return domain.MediaCategoryCounts{}, nil + } + return s.cachedPrivateMediaCounts(ctx, userID, peerID) +} + // ReadHistory 将当前账号某个 peer 的 inbox 标记为已读,并为发送方生成 outbox 已读回执。 func (s *Service) ReadHistory(ctx context.Context, userID int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) { if s == nil || userID == 0 { @@ -156,6 +308,34 @@ func (s *Service) SetMessageReactions(ctx context.Context, userID int64, req dom return s.messages.SetMessageReactions(ctx, req) } +// VoteMessagePoll 给私聊消息上的 poll 投票(options 为空 = 撤票)。 +func (s *Service) VoteMessagePoll(ctx context.Context, userID int64, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid + } + return s.messages.VoteMessagePoll(ctx, req) +} + +// CloseMessagePoll 关闭私聊消息上的 poll(仅 poll 创建者)。 +func (s *Service) CloseMessagePoll(ctx context.Context, userID int64, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid + } + if req.UserID == 0 { + req.UserID = userID + } + if req.UserID != userID || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid + } + return s.messages.CloseMessagePoll(ctx, req) +} + // GetMessageReactions returns reaction summaries for visible private messages. func (s *Service) GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { if s == nil || s.messages == nil || userID == 0 { @@ -186,6 +366,35 @@ func (s *Service) EditMessage(ctx context.Context, userID int64, req domain.Edit return s.messages.EditMessage(ctx, req) } +// PinPrivateMessage 翻转当前账号可见私聊消息的置顶状态;非 pm_oneside +// 时同步翻转对端视角。 +func (s *Service) PinPrivateMessage(ctx context.Context, userID int64, req domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + if req.OwnerUserID != userID { + return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid + } + return s.messages.PinPrivateMessage(ctx, req) +} + +// UnpinAllPrivateMessages 清空当前账号与某私聊 peer 的全部置顶。 +func (s *Service) UnpinAllPrivateMessages(ctx context.Context, userID int64, req domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + if req.OwnerUserID != userID { + return domain.PinPrivateMessageResult{OwnerUserID: userID}, domain.ErrMessageIDInvalid + } + return s.messages.UnpinAllPrivateMessages(ctx, req) +} + // DeleteMessages 删除当前账号视角下的一组消息;revoke 时同步删除对端私聊盒子。 func (s *Service) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { if s == nil || s.messages == nil || userID == 0 { @@ -208,6 +417,240 @@ func (s *Service) DeleteHistory(ctx context.Context, userID int64, req domain.De return s.messages.DeleteHistory(ctx, req) } +// GetSavedDialogs 返回收藏夹子会话分页(messages.getSavedDialogs)。 +func (s *Service) GetSavedDialogs(ctx context.Context, userID int64, filter domain.SavedDialogsFilter) (domain.SavedDialogList, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.SavedDialogList{Full: true}, nil + } + return s.messages.ListSavedDialogs(ctx, userID, filter) +} + +// GetPinnedSavedDialogs 返回全部置顶收藏夹子会话(messages.getPinnedSavedDialogs)。 +func (s *Service) GetPinnedSavedDialogs(ctx context.Context, userID int64) (domain.SavedDialogList, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.SavedDialogList{Full: true}, nil + } + return s.messages.ListPinnedSavedDialogs(ctx, userID) +} + +// GetSavedDialogsByPeers 返回指定收藏夹子会话(messages.getSavedDialogsByID)。 +func (s *Service) GetSavedDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error) { + if s == nil || s.messages == nil || userID == 0 || len(peers) == 0 { + return domain.SavedDialogList{Full: true}, nil + } + return s.messages.ListSavedDialogsByPeers(ctx, userID, peers) +} + +// ToggleSavedDialogPin 翻转收藏夹子会话置顶状态,返回是否实际变化。 +func (s *Service) ToggleSavedDialogPin(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) { + if s == nil || s.messages == nil || userID == 0 { + return false, nil + } + return s.messages.ToggleSavedDialogPin(ctx, userID, peer, pinned) +} + +// ReorderPinnedSavedDialogs 全量重排收藏夹置顶顺序。 +func (s *Service) ReorderPinnedSavedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error { + if s == nil || s.messages == nil || userID == 0 { + return nil + } + return s.messages.ReorderPinnedSavedDialogs(ctx, userID, order, force) +} + +// DeleteSavedHistory 删除收藏夹一个子会话的消息(单批)。 +func (s *Service) DeleteSavedHistory(ctx context.Context, userID int64, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.DeleteSavedHistoryResult{}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + return s.messages.DeleteSavedHistory(ctx, req) +} + +func (s *Service) ScheduleMessage(ctx context.Context, userID int64, req domain.ScheduleMessageRequest) (domain.ScheduledMessage, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.ScheduledMessage{}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + scheduled, ok := s.messages.(store.ScheduledMessageStore) + if !ok { + return domain.ScheduledMessage{}, nil + } + return scheduled.CreateScheduledMessage(ctx, req) +} + +func (s *Service) ListScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.ScheduledMessageList{}, nil + } + if filter.OwnerUserID == 0 { + filter.OwnerUserID = userID + } + scheduled, ok := s.messages.(store.ScheduledMessageStore) + if !ok { + return domain.ScheduledMessageList{}, nil + } + return scheduled.ListScheduledMessages(ctx, filter) +} + +func (s *Service) EditScheduledMessage(ctx context.Context, userID int64, req domain.EditScheduledMessageRequest) (domain.ScheduledMessage, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.ScheduledMessage{}, nil + } + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + scheduled, ok := s.messages.(store.ScheduledMessageStore) + if !ok { + return domain.ScheduledMessage{}, nil + } + return scheduled.EditScheduledMessage(ctx, req) +} + +func (s *Service) GetScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) { + if s == nil || s.messages == nil || userID == 0 { + return domain.ScheduledMessageList{}, nil + } + if filter.OwnerUserID == 0 { + filter.OwnerUserID = userID + } + scheduled, ok := s.messages.(store.ScheduledMessageStore) + if !ok { + return domain.ScheduledMessageList{}, nil + } + return scheduled.GetScheduledMessages(ctx, filter) +} + +func (s *Service) DeleteScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter, date int) ([]domain.ScheduledMessage, error) { + if s == nil || s.messages == nil || userID == 0 { + return nil, nil + } + if filter.OwnerUserID == 0 { + filter.OwnerUserID = userID + } + scheduled, ok := s.messages.(store.ScheduledMessageStore) + if !ok { + return nil, nil + } + return scheduled.DeleteScheduledMessages(ctx, filter, date) +} + +func (s *Service) ClaimScheduledMessages(ctx context.Context, userID int64, claim domain.ScheduledMessageClaim) ([]domain.ScheduledMessage, error) { + if s == nil || s.messages == nil || userID == 0 { + return nil, nil + } + if claim.OwnerUserID == 0 { + claim.OwnerUserID = userID + } + scheduled, ok := s.messages.(store.ScheduledMessageStore) + if !ok { + return nil, nil + } + return scheduled.ClaimScheduledMessages(ctx, claim) +} + +func (s *Service) ClaimDueScheduledMessages(ctx context.Context, now, limit, leaseSeconds int) ([]domain.ScheduledMessage, error) { + if s == nil || s.messages == nil { + return nil, nil + } + scheduled, ok := s.messages.(store.ScheduledMessageStore) + if !ok { + return nil, nil + } + return scheduled.ClaimDueScheduledMessages(ctx, now, limit, leaseSeconds) +} + +func (s *Service) MarkScheduledMessageSent(ctx context.Context, ownerUserID int64, id, sentMessageID, date int) error { + if s == nil || s.messages == nil { + return nil + } + scheduled, ok := s.messages.(store.ScheduledMessageStore) + if !ok { + return nil + } + return scheduled.MarkScheduledMessageSent(ctx, ownerUserID, id, sentMessageID, date) +} + +func (s *Service) ReleaseScheduledMessage(ctx context.Context, ownerUserID int64, id int, errText string) error { + if s == nil || s.messages == nil { + return nil + } + scheduled, ok := s.messages.(store.ScheduledMessageStore) + if !ok { + return nil + } + return scheduled.ReleaseScheduledMessage(ctx, ownerUserID, id, errText) +} + +func (s *Service) HasScheduledMessages(ctx context.Context, userID int64, peer domain.Peer) (bool, error) { + if s == nil || s.messages == nil || userID == 0 { + return false, nil + } + scheduled, ok := s.messages.(store.ScheduledMessageStore) + if !ok { + return false, nil + } + return scheduled.HasScheduledMessages(ctx, userID, peer) +} + +func (s *Service) GetPrivateHistoryTTL(ctx context.Context, userID int64, peer domain.Peer) (int, error) { + if s == nil || s.messages == nil || userID == 0 { + return 0, nil + } + ttl, ok := s.messages.(store.HistoryTTLStore) + if !ok { + return 0, nil + } + return ttl.GetPrivateHistoryTTL(ctx, userID, peer) +} + +func (s *Service) SetPrivateHistoryTTL(ctx context.Context, userID int64, peer domain.Peer, period int) error { + if s == nil || s.messages == nil || userID == 0 { + return nil + } + ttl, ok := s.messages.(store.HistoryTTLStore) + if !ok { + return nil + } + return ttl.SetPrivateHistoryTTL(ctx, userID, peer, period) +} + +func (s *Service) DefaultHistoryTTL(ctx context.Context, userID int64) (int, error) { + if s == nil || s.messages == nil || userID == 0 { + return 0, nil + } + ttl, ok := s.messages.(store.HistoryTTLStore) + if !ok { + return 0, nil + } + return ttl.DefaultHistoryTTL(ctx, userID) +} + +func (s *Service) SetDefaultHistoryTTL(ctx context.Context, userID int64, period int) error { + if s == nil || s.messages == nil || userID == 0 { + return nil + } + ttl, ok := s.messages.(store.HistoryTTLStore) + if !ok { + return nil + } + return ttl.SetDefaultHistoryTTL(ctx, userID, period) +} + +func (s *Service) ClaimExpiredPrivateMessages(ctx context.Context, now, limit int) ([]domain.DeleteMessagesRequest, error) { + if s == nil || s.messages == nil { + return nil, nil + } + ttl, ok := s.messages.(store.HistoryTTLStore) + if !ok { + return nil, nil + } + return ttl.ClaimExpiredPrivateMessages(ctx, now, limit) +} + func (s *Service) list(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { if s == nil || s.messages == nil || userID == 0 { return domain.MessageList{}, nil @@ -230,3 +673,26 @@ func (s *Service) projectMessageUsers(ctx context.Context, userID int64, list do list.Users = users return list, nil } + +type privateUnreadReactionsStore interface { + ListUnreadReactionMessages(ctx context.Context, ownerUserID int64, peer domain.Peer, limit int) ([]domain.Message, error) + ReadPeerReactions(ctx context.Context, ownerUserID int64, peer domain.Peer) (int, error) +} + +// ListUnreadReactionMessages 返回私聊 peer 下带未读 reaction 的消息。 +func (s *Service) ListUnreadReactionMessages(ctx context.Context, userID int64, peer domain.Peer, limit int) ([]domain.Message, error) { + store, ok := s.messages.(privateUnreadReactionsStore) + if !ok || userID == 0 { + return nil, nil + } + return store.ListUnreadReactionMessages(ctx, userID, peer, limit) +} + +// ReadPeerReactions 清理私聊 peer 下的全部未读 reaction。 +func (s *Service) ReadPeerReactions(ctx context.Context, userID int64, peer domain.Peer) (int, error) { + store, ok := s.messages.(privateUnreadReactionsStore) + if !ok || userID == 0 { + return 0, nil + } + return store.ReadPeerReactions(ctx, userID, peer) +} diff --git a/internal/app/messages/service_test.go b/internal/app/messages/service_test.go index efa53310..a18d04e9 100644 --- a/internal/app/messages/service_test.go +++ b/internal/app/messages/service_test.go @@ -2,12 +2,51 @@ package messages import ( "context" + "errors" "testing" + "telesrv/internal/app/account" + "telesrv/internal/app/readmodel" "telesrv/internal/domain" + "telesrv/internal/store" "telesrv/internal/store/memory" ) +func TestServiceSendPrivateTextHonorsSendPermissionGate(t *testing.T) { + ctx := context.Background() + store := &gateMessageStore{} + svc := NewService(store, nil, WithSendPermissionChecker(denySendChecker{})) + if _, err := svc.SendPrivateText(ctx, 1001, domain.SendPrivateTextRequest{ + SenderUserID: 1001, + RecipientUserID: 1002, + RandomID: 1, + Message: "blocked", + }); !errors.Is(err, domain.ErrUserSendRestricted) { + t.Fatalf("SendPrivateText err=%v, want ErrUserSendRestricted", err) + } + if store.sends != 0 { + t.Fatalf("store sends=%d, want 0", store.sends) + } +} + +func TestServiceForwardPrivateMessagesHonorsSendPermissionGate(t *testing.T) { + ctx := context.Background() + store := &gateMessageStore{} + svc := NewService(store, nil, WithSendPermissionChecker(denySendChecker{})) + if _, err := svc.ForwardPrivateMessages(ctx, 1001, domain.ForwardPrivateMessagesRequest{ + OwnerUserID: 1001, + FromPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + ToUserID: 1003, + MessageIDs: []int{1}, + RandomIDs: []int64{2}, + }); !errors.Is(err, domain.ErrUserSendRestricted) { + t.Fatalf("ForwardPrivateMessages err=%v, want ErrUserSendRestricted", err) + } + if store.forwards != 0 { + t.Fatalf("store forwards=%d, want 0", store.forwards) + } +} + func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) { ctx := context.Background() const ownerID int64 = 1001 @@ -58,6 +97,281 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) { } } +func TestBusinessAutomationGreetingSendsQuickReplyWithoutLoop(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 2001 + const customerID int64 = 2002 + + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + business := memory.NewPasswordStore() + accountSvc := account.NewService(business, account.WithBusinessAutomation(business)) + + ownerShortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "hello", "owner hello", 100) + customerShortcutID := saveBusinessQuickReply(t, ctx, accountSvc, customerID, "hello", "customer hello", 101) + if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, ownerID, &domain.BusinessGreetingMessage{ + ShortcutID: ownerShortcutID, + Recipients: businessAutomationAllRecipients(), + NoActivityDays: 7, + }); err != nil { + t.Fatalf("update owner greeting: %v", err) + } + if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, customerID, &domain.BusinessGreetingMessage{ + ShortcutID: customerShortcutID, + Recipients: businessAutomationAllRecipients(), + NoActivityDays: 7, + }); err != nil { + t.Fatalf("update customer greeting: %v", err) + } + + svc := NewService(messages, dialogs, WithBusinessAutomation(business)) + if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{ + SenderUserID: customerID, + RecipientUserID: ownerID, + RandomID: 10001, + Message: "hi", + Date: 1_700_000_000, + }); err != nil { + t.Fatalf("send first message: %v", err) + } + + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "owner hello"); got != 1 { + t.Fatalf("customer owner hello count = %d, want 1", got) + } + if got := countBusinessMessagesByBody(t, ctx, messages, ownerID, customerID, "owner hello"); got != 1 { + t.Fatalf("owner outgoing hello count = %d, want 1", got) + } + if got := countBusinessMessagesByBody(t, ctx, messages, ownerID, customerID, "customer hello"); got != 0 { + t.Fatalf("recursive customer hello count = %d, want 0", got) + } + + if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{ + SenderUserID: customerID, + RecipientUserID: ownerID, + RandomID: 10002, + Message: "again", + Date: 1_700_000_060, + }); err != nil { + t.Fatalf("send second message: %v", err) + } + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "owner hello"); got != 1 { + t.Fatalf("owner hello after second incoming = %d, want 1", got) + } +} + +func TestBusinessAutomationAwayHonorsOnlineStateAndCooldown(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 2101 + const customerID int64 = 2102 + + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + business := memory.NewPasswordStore() + accountSvc := account.NewService(business, account.WithBusinessAutomation(business)) + + shortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "away", "away reply", 200) + if _, err := accountSvc.UpdateBusinessAwayMessage(ctx, ownerID, &domain.BusinessAwayMessage{ + ShortcutID: shortcutID, + Schedule: domain.BusinessAwaySchedule{Kind: domain.BusinessAwayScheduleAlways}, + Recipients: businessAutomationAllRecipients(), + OfflineOnly: true, + }); err != nil { + t.Fatalf("update away: %v", err) + } + + online := businessAutomationOnline{ownerID: true} + svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationOnlineChecker(online))) + if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{ + SenderUserID: customerID, + RecipientUserID: ownerID, + RandomID: 20001, + Message: "online?", + Date: 1_700_010_000, + }); err != nil { + t.Fatalf("send while online: %v", err) + } + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "away reply"); got != 0 { + t.Fatalf("away while online count = %d, want 0", got) + } + + online[ownerID] = false + if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{ + SenderUserID: customerID, + RecipientUserID: ownerID, + RandomID: 20002, + Message: "offline?", + Date: 1_700_010_060, + }); err != nil { + t.Fatalf("send while offline: %v", err) + } + if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{ + SenderUserID: customerID, + RecipientUserID: ownerID, + RandomID: 20003, + Message: "still offline?", + Date: 1_700_010_120, + }); err != nil { + t.Fatalf("send during cooldown: %v", err) + } + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "away reply"); got != 1 { + t.Fatalf("away reply count = %d, want 1", got) + } +} + +func TestBusinessAutomationReplyProviderCanReplaceTemplate(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 2201 + const customerID int64 = 2202 + + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + business := memory.NewPasswordStore() + accountSvc := account.NewService(business, account.WithBusinessAutomation(business)) + + shortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "hello", "template reply", 300) + if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, ownerID, &domain.BusinessGreetingMessage{ + ShortcutID: shortcutID, + Recipients: businessAutomationAllRecipients(), + NoActivityDays: 7, + }); err != nil { + t.Fatalf("update greeting: %v", err) + } + svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationReplyProvider(staticBusinessAutomationProvider{message: "ai reply"}))) + if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{ + SenderUserID: customerID, + RecipientUserID: ownerID, + RandomID: 30001, + Message: "hi", + Date: 1_700_020_000, + }); err != nil { + t.Fatalf("send first message: %v", err) + } + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "ai reply"); got != 1 { + t.Fatalf("provider reply count = %d, want 1", got) + } + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "template reply"); got != 0 { + t.Fatalf("template reply count = %d, want 0", got) + } +} + +func TestBusinessAutomationEchoProviderEchoesTriggerText(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 2301 + const customerID int64 = 2302 + + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + business := memory.NewPasswordStore() + accountSvc := account.NewService(business, account.WithBusinessAutomation(business)) + + shortcutID := saveBusinessQuickReply(t, ctx, accountSvc, ownerID, "hello", "template reply", 400) + if _, err := accountSvc.UpdateBusinessGreetingMessage(ctx, ownerID, &domain.BusinessGreetingMessage{ + ShortcutID: shortcutID, + Recipients: businessAutomationAllRecipients(), + NoActivityDays: 7, + }); err != nil { + t.Fatalf("update greeting: %v", err) + } + svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationReplyProvider(NewEchoBusinessAutomationProvider()))) + if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{ + SenderUserID: customerID, + RecipientUserID: ownerID, + RandomID: 40001, + Message: "echo this", + Date: 1_700_030_000, + }); err != nil { + t.Fatalf("send first message: %v", err) + } + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "echo this"); got != 2 { + t.Fatalf("echo body count in customer history = %d, want 2 (outgoing + echo reply)", got) + } + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "template reply"); got != 0 { + t.Fatalf("template reply count = %d, want 0", got) + } +} + +func TestConnectedBusinessBotEchoHonorsPauseAndDisable(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 2401 + const customerID int64 = 2402 + const botID int64 = 2403 + + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + business := memory.NewPasswordStore() + accountSvc := account.NewService(business, account.WithBusinessAutomation(business)) + if _, err := accountSvc.SaveConnectedBusinessBot(ctx, ownerID, domain.ConnectedBusinessBot{ + BotUserID: botID, + Recipients: domain.BusinessBotRecipients{ExcludeSelected: true}, + Rights: domain.BusinessBotRights{Reply: true}, + }); err != nil { + t.Fatalf("save connected bot: %v", err) + } + + svc := NewService(messages, dialogs, WithBusinessAutomation(business, WithBusinessAutomationReplyProvider(NewEchoBusinessAutomationProvider()))) + if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{ + SenderUserID: customerID, + RecipientUserID: ownerID, + RandomID: 50001, + Message: "connected echo", + Date: 1_700_050_000, + }); err != nil { + t.Fatalf("send connected echo: %v", err) + } + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "connected echo"); got != 2 { + t.Fatalf("connected echo customer count = %d, want 2 (original + echo)", got) + } + assertMessageViaBot(t, ctx, messages, customerID, ownerID, "connected echo", botID) + + if _, err := accountSvc.SetConnectedBusinessBotPaused(ctx, ownerID, customerID, true); err != nil { + t.Fatalf("pause connected bot: %v", err) + } + if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{ + SenderUserID: customerID, + RecipientUserID: ownerID, + RandomID: 50002, + Message: "paused echo", + Date: 1_700_050_060, + }); err != nil { + t.Fatalf("send paused echo: %v", err) + } + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "paused echo"); got != 1 { + t.Fatalf("paused echo customer count = %d, want 1", got) + } + + if _, err := accountSvc.SetConnectedBusinessBotPaused(ctx, ownerID, customerID, false); err != nil { + t.Fatalf("unpause connected bot: %v", err) + } + if _, err := accountSvc.DisableConnectedBusinessBotForPeer(ctx, ownerID, customerID); err != nil { + t.Fatalf("disable connected bot peer: %v", err) + } + if _, err := svc.SendPrivateText(ctx, customerID, domain.SendPrivateTextRequest{ + SenderUserID: customerID, + RecipientUserID: ownerID, + RandomID: 50003, + Message: "disabled echo", + Date: 1_700_050_120, + }); err != nil { + t.Fatalf("send disabled echo: %v", err) + } + if got := countBusinessMessagesByBody(t, ctx, messages, customerID, ownerID, "disabled echo"); got != 1 { + t.Fatalf("disabled echo customer count = %d, want 1", got) + } +} + +func TestEchoBusinessAutomationProviderSkipsEmptyText(t *testing.T) { + msgs, err := NewEchoBusinessAutomationProvider().BusinessAutomationReplies(context.Background(), BusinessAutomationReplyInput{ + TriggerMessage: domain.Message{Body: " \t\n"}, + Now: 1_700_040_000, + }) + if err != nil { + t.Fatalf("BusinessAutomationReplies: %v", err) + } + if len(msgs) != 0 { + t.Fatalf("messages = %+v, want none", msgs) + } +} + func findUser(t *testing.T, users []domain.User, id int64) domain.User { t.Helper() for _, user := range users { @@ -69,12 +383,191 @@ func findUser(t *testing.T, users []domain.User, id int64) domain.User { return domain.User{} } +func TestCountPrivateMediaCategoriesCachesByReadModelHash(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1001 + const peerID int64 = 1002 + key := store.ReadModelKey{Model: readmodel.ModelPrivateMediaCounts, OwnerUserID: ownerID, PeerType: domain.PeerTypeUser, PeerID: peerID} + versions := &fakeMessageReadModelVersions{hashes: map[store.ReadModelKey]int64{key: 101}} + counting := &countingPrivateMediaStore{counts: domain.MediaCategoryCounts{ + domain.MediaCategoryPhoto: 3, + domain.MediaCategoryVideo: 2, + }} + svc := NewService(counting, nil, WithReadModelVersions(versions)) + + first, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID) + if err != nil { + t.Fatalf("CountPrivateMediaCategories first: %v", err) + } + first[domain.MediaCategoryPhoto] = 99 + second, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID) + if err != nil { + t.Fatalf("CountPrivateMediaCategories second: %v", err) + } + if counting.countPrivateMediaCalls != 1 { + t.Fatalf("count calls = %d, want 1", counting.countPrivateMediaCalls) + } + if got := second[domain.MediaCategoryPhoto]; got != 3 { + t.Fatalf("cached photo count = %d, want 3", got) + } + + svc.InvalidatePrivateMediaCountReadModel(ownerID, peerID) + if _, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID); err != nil { + t.Fatalf("CountPrivateMediaCategories after explicit invalidation: %v", err) + } + if counting.countPrivateMediaCalls != 2 { + t.Fatalf("count calls after explicit invalidation = %d, want 2", counting.countPrivateMediaCalls) + } + + versions.hashes[key] = 202 + counting.counts[domain.MediaCategoryPhoto] = 4 + third, err := svc.CountPrivateMediaCategories(ctx, ownerID, peerID) + if err != nil { + t.Fatalf("CountPrivateMediaCategories third: %v", err) + } + if counting.countPrivateMediaCalls != 3 { + t.Fatalf("count calls after hash change = %d, want 3", counting.countPrivateMediaCalls) + } + if got := third[domain.MediaCategoryPhoto]; got != 4 { + t.Fatalf("reloaded photo count = %d, want 4", got) + } +} + type projectionMessageStore struct { list domain.MessageList } +type denySendChecker struct{} + +func (denySendChecker) CanSendMessages(context.Context, int64) error { + return domain.ErrUserSendRestricted +} + +type gateMessageStore struct { + projectionMessageStore + sends int + forwards int +} + +func (s *gateMessageStore) SendPrivateText(context.Context, domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { + s.sends++ + return domain.SendPrivateTextResult{}, nil +} + +func (s *gateMessageStore) ForwardPrivateMessages(context.Context, domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { + s.forwards++ + return domain.ForwardPrivateMessagesResult{}, nil +} + +type countingPrivateMediaStore struct { + projectionMessageStore + counts domain.MediaCategoryCounts + countPrivateMediaCalls int +} + +func (s *countingPrivateMediaStore) CountPrivateMediaCategories(context.Context, int64, int64) (domain.MediaCategoryCounts, error) { + s.countPrivateMediaCalls++ + out := make(domain.MediaCategoryCounts, len(s.counts)) + for category, count := range s.counts { + out[category] = count + } + return out, nil +} + +type fakeMessageReadModelVersions struct { + hashes map[store.ReadModelKey]int64 +} + +func (f *fakeMessageReadModelVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) { + hash := f.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}] + return hash, hash != 0, nil +} + +func (f *fakeMessageReadModelVersions) ReadModelHashes(_ context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) { + out := make(map[store.ReadModelKey]int64, len(keys)) + for _, key := range keys { + if hash := f.hashes[key]; hash != 0 { + out[key] = hash + } + } + return out, nil +} + type messageProfilePhotos map[int64]domain.ProfilePhotoRef +type businessAutomationOnline map[int64]bool + +func (o businessAutomationOnline) IsUserOnline(userID int64) bool { + return o[userID] +} + +type staticBusinessAutomationProvider struct { + message string +} + +func (p staticBusinessAutomationProvider) BusinessAutomationReplies(context.Context, BusinessAutomationReplyInput) ([]domain.QuickReplyMessage, error) { + return []domain.QuickReplyMessage{{ID: 1, Message: p.message}}, nil +} + +func businessAutomationAllRecipients() domain.BusinessRecipients { + return domain.BusinessRecipients{ + ExistingChats: true, + NewChats: true, + Contacts: true, + NonContacts: true, + } +} + +func saveBusinessQuickReply(t *testing.T, ctx context.Context, svc *account.Service, ownerID int64, shortcut, message string, randomID int64) int { + t.Helper() + mutation, err := svc.SaveQuickReplyText(ctx, ownerID, shortcut, domain.QuickReplyMessage{ + RandomID: randomID, + Date: 1_700_000_000, + Message: message, + }) + if err != nil { + t.Fatalf("save quick reply %s: %v", shortcut, err) + } + return mutation.ShortcutID +} + +func countBusinessMessagesByBody(t *testing.T, ctx context.Context, messages *memory.MessageStore, ownerID, peerID int64, body string) int { + t.Helper() + list, err := messages.ListByUser(ctx, ownerID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + Limit: 50, + }) + if err != nil { + t.Fatalf("list messages: %v", err) + } + count := 0 + for _, msg := range list.Messages { + if msg.Body == body { + count++ + } + } + return count +} + +func assertMessageViaBot(t *testing.T, ctx context.Context, messages *memory.MessageStore, ownerID, peerID int64, body string, botID int64) { + t.Helper() + list, err := messages.ListByUser(ctx, ownerID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + Limit: 50, + }) + if err != nil { + t.Fatalf("list messages: %v", err) + } + for _, msg := range list.Messages { + if msg.Body == body && msg.ViaBotID == botID { + return + } + } + t.Fatalf("message body %q via bot %d not found in %+v", body, botID, list.Messages) +} + func (p messageProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) { out := make(map[int64]domain.ProfilePhotoRef, len(ids)) for _, id := range ids { @@ -117,14 +610,54 @@ func (s projectionMessageStore) GetMessageReactions(context.Context, domain.Priv return domain.PrivateMessageReactionsResult{}, nil } +func (s projectionMessageStore) VoteMessagePoll(context.Context, domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) { + return domain.PrivateMessagePollResult{}, nil +} + +func (s projectionMessageStore) CloseMessagePoll(context.Context, domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) { + return domain.PrivateMessagePollResult{}, nil +} + func (s projectionMessageStore) EditMessage(context.Context, domain.EditMessageRequest) (domain.EditMessageResult, error) { return domain.EditMessageResult{}, nil } +func (s projectionMessageStore) PinPrivateMessage(context.Context, domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) { + return domain.PinPrivateMessageResult{}, nil +} + +func (s projectionMessageStore) UnpinAllPrivateMessages(context.Context, domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) { + return domain.PinPrivateMessageResult{}, nil +} + func (s projectionMessageStore) DeleteMessages(context.Context, domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { return domain.DeleteMessagesResult{}, nil } +func (s projectionMessageStore) ListSavedDialogs(context.Context, int64, domain.SavedDialogsFilter) (domain.SavedDialogList, error) { + return domain.SavedDialogList{}, nil +} + +func (s projectionMessageStore) ListPinnedSavedDialogs(context.Context, int64) (domain.SavedDialogList, error) { + return domain.SavedDialogList{}, nil +} + +func (s projectionMessageStore) ListSavedDialogsByPeers(context.Context, int64, []domain.Peer) (domain.SavedDialogList, error) { + return domain.SavedDialogList{}, nil +} + +func (s projectionMessageStore) ToggleSavedDialogPin(context.Context, int64, domain.Peer, bool) (bool, error) { + return false, nil +} + +func (s projectionMessageStore) ReorderPinnedSavedDialogs(context.Context, int64, []domain.Peer, bool) error { + return nil +} + +func (s projectionMessageStore) DeleteSavedHistory(context.Context, domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) { + return domain.DeleteSavedHistoryResult{}, nil +} + func (s projectionMessageStore) DeleteHistory(context.Context, domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { return domain.DeleteMessagesResult{}, nil } @@ -136,3 +669,11 @@ func (s projectionMessageStore) GetByIDs(context.Context, int64, []int) (domain. func (s projectionMessageStore) ListByUser(context.Context, int64, domain.MessageFilter) (domain.MessageList, error) { return s.list, nil } + +func (s projectionMessageStore) SearchPrivateMedia(context.Context, int64, int64, domain.MediaSearchRequest) (domain.MessageList, error) { + return domain.MessageList{}, nil +} + +func (s projectionMessageStore) CountPrivateMediaCategories(context.Context, int64, int64) (domain.MediaCategoryCounts, error) { + return domain.MediaCategoryCounts{}, nil +} diff --git a/internal/app/passkey/service.go b/internal/app/passkey/service.go new file mode 100644 index 00000000..dee752c7 --- /dev/null +++ b/internal/app/passkey/service.go @@ -0,0 +1,274 @@ +// Package passkey 实现 passkey(WebAuthn)登录与管理的业务编排:生成挑战选项、 +// 验证注册 attestation、验证登录 assertion,并维护凭据/挑战持久化。 +// 密码学验证委托 internal/webauthn;auth_key 绑定委托 auth.Service。 +package passkey + +import ( + "context" + "crypto/rand" + "fmt" + "strconv" + "strings" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/webauthn" +) + +const ( + defaultChallengeTTL = 5 * time.Minute + challengeSize = 32 +) + +// Service 提供 passkey 登录/注册业务。 +type Service struct { + creds store.PasskeyStore + challenges store.PasskeyChallengeStore + rpID string + rpName string + allowedOrigins []string + dcID int + challengeTTL time.Duration + now func() time.Time +} + +// Option 调整 passkey 服务可选项。 +type Option func(*Service) + +// WithRPName 设置 relying-party 显示名(authenticator UI 展示)。 +func WithRPName(name string) Option { return func(s *Service) { s.rpName = name } } + +// WithAllowedOrigins 设置允许的 WebAuthn origin 白名单;为空表示不强校验 origin +//(服务端通常不预知 Android apk-key-hash origin)。 +func WithAllowedOrigins(origins []string) Option { + return func(s *Service) { s.allowedOrigins = append([]string(nil), origins...) } +} + +// WithChallengeTTL 覆盖挑战有效期。 +func WithChallengeTTL(d time.Duration) Option { + return func(s *Service) { + if d > 0 { + s.challengeTTL = d + } + } +} + +// WithClock 注入时钟(测试用)。 +func WithClock(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +// NewService 创建 passkey 服务。rpID 为 WebAuthn relying-party id(域名);dcID 写入 +// user_handle 供客户端 DC 重路由(本部署单 DC,仅做回显)。 +func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore, rpID string, dcID int, opts ...Option) *Service { + s := &Service{ + creds: creds, + challenges: challenges, + rpID: rpID, + rpName: "Telegram", + dcID: dcID, + challengeTTL: defaultChallengeTTL, + now: time.Now, + } + for _, opt := range opts { + opt(s) + } + return s +} + +func (s *Service) randomChallenge() ([]byte, error) { + b := make([]byte, challengeSize) + if _, err := rand.Read(b); err != nil { + return nil, err + } + return b, nil +} + +func (s *Service) userHandle(userID int64) string { + return fmt.Sprintf("%d:%d", s.dcID, userID) +} + +func parseUserHandle(h string) (int64, error) { + h = strings.TrimSpace(h) + if h == "" { + return 0, domain.ErrPasskeyUserHandleInvalid + } + if i := strings.LastIndex(h, ":"); i >= 0 { + h = h[i+1:] + } + id, err := strconv.ParseInt(h, 10, 64) + if err != nil || id == 0 { + return 0, domain.ErrPasskeyUserHandleInvalid + } + return id, nil +} + +// InitRegistration 为已登录用户生成注册选项(creation options DataJSON)。 +func (s *Service) InitRegistration(ctx context.Context, userID int64, displayName string) ([]byte, error) { + if s == nil || s.creds == nil || s.challenges == nil || userID == 0 { + return nil, domain.ErrPasskeyInvalid + } + challenge, err := s.randomChallenge() + if err != nil { + return nil, err + } + if err := s.challenges.SavePasskeyChallenge(ctx, challenge, domain.PasskeyChallenge{ + Purpose: domain.PasskeyChallengeRegister, + UserID: userID, + ExpiresAt: s.now().Add(s.challengeTTL).Unix(), + }, s.challengeTTL); err != nil { + return nil, err + } + existing, err := s.creds.ListPasskeysByUser(ctx, userID) + if err != nil { + return nil, err + } + exclude := make([][]byte, 0, len(existing)) + for _, c := range existing { + exclude = append(exclude, c.CredentialID) + } + if displayName == "" { + displayName = fmt.Sprintf("user%d", userID) + } + return webauthn.BuildRegistrationOptions(webauthn.RegistrationParams{ + RPID: s.rpID, + RPName: s.rpName, + UserID: []byte(s.userHandle(userID)), + UserName: displayName, + UserDisplay: displayName, + Challenge: challenge, + ExcludeIDs: exclude, + }) +} + +// Register 验证注册 attestation 并持久化凭据。credentialID 为原始字节(rpc 已 base64url 解码)。 +func (s *Service) Register(ctx context.Context, userID int64, credentialID, clientDataJSON, attestationObject []byte, name string) (domain.PasskeyCredential, error) { + if s == nil || s.creds == nil || s.challenges == nil || userID == 0 { + return domain.PasskeyCredential{}, domain.ErrPasskeyInvalid + } + challenge, err := webauthn.ChallengeFromClientData(clientDataJSON) + if err != nil { + return domain.PasskeyCredential{}, domain.ErrPasskeyInvalid + } + meta, found, err := s.challenges.ConsumePasskeyChallenge(ctx, challenge) + if err != nil { + return domain.PasskeyCredential{}, err + } + if !found || meta.Purpose != domain.PasskeyChallengeRegister || meta.UserID != userID { + return domain.PasskeyCredential{}, domain.ErrPasskeyChallengeInvalid + } + cred, err := webauthn.VerifyRegistration(webauthn.VerifyRegistrationInput{ + ClientDataJSON: clientDataJSON, + AttestationObject: attestationObject, + RPID: s.rpID, + Challenge: challenge, + AllowedOrigins: s.allowedOrigins, + }) + if err != nil { + return domain.PasskeyCredential{}, fmt.Errorf("%w: %v", domain.ErrPasskeyInvalid, err) + } + // credentialID 来自 TL 的 ID/RawID;以 authData 内的 credID 为准(防客户端不一致)。 + now := s.now().Unix() + record := domain.PasskeyCredential{ + CredentialID: cred.ID, + UserID: userID, + PublicKey: cred.COSEPublicKey, + SignCount: cred.SignCount, + AAGUID: cred.AAGUID, + Name: strings.TrimSpace(name), + CreatedAt: now, + } + if err := s.creds.InsertPasskey(ctx, record); err != nil { + return domain.PasskeyCredential{}, err + } + return record, nil +} + +// InitLogin 生成登录选项(request options DataJSON,discoverable,无 allowCredentials)。 +func (s *Service) InitLogin(ctx context.Context) ([]byte, error) { + if s == nil || s.challenges == nil { + return nil, domain.ErrPasskeyInvalid + } + challenge, err := s.randomChallenge() + if err != nil { + return nil, err + } + if err := s.challenges.SavePasskeyChallenge(ctx, challenge, domain.PasskeyChallenge{ + Purpose: domain.PasskeyChallengeLogin, + ExpiresAt: s.now().Add(s.challengeTTL).Unix(), + }, s.challengeTTL); err != nil { + return nil, err + } + return webauthn.BuildLoginOptions(webauthn.LoginParams{RPID: s.rpID, Challenge: challenge}) +} + +// FinishLogin 验证登录 assertion,成功返回该 passkey 所属用户 id(auth_key 绑定由调用方完成)。 +func (s *Service) FinishLogin(ctx context.Context, credentialID, clientDataJSON, authenticatorData, signature []byte, userHandle string) (int64, error) { + if s == nil || s.creds == nil || s.challenges == nil { + return 0, domain.ErrPasskeyInvalid + } + handleUserID, err := parseUserHandle(userHandle) + if err != nil { + return 0, err + } + cred, found, err := s.creds.GetPasskeyByCredentialID(ctx, credentialID) + if err != nil { + return 0, err + } + if !found { + return 0, domain.ErrPasskeyNotFound + } + // 交叉校验:credential 所属用户须与 user_handle 一致。 + if cred.UserID != handleUserID { + return 0, domain.ErrPasskeyInvalid + } + challenge, err := webauthn.ChallengeFromClientData(clientDataJSON) + if err != nil { + return 0, domain.ErrPasskeyInvalid + } + meta, found, err := s.challenges.ConsumePasskeyChallenge(ctx, challenge) + if err != nil { + return 0, err + } + if !found || meta.Purpose != domain.PasskeyChallengeLogin { + return 0, domain.ErrPasskeyChallengeInvalid + } + newCount, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{ + COSEPublicKey: cred.PublicKey, + ClientDataJSON: clientDataJSON, + AuthenticatorData: authenticatorData, + Signature: signature, + RPID: s.rpID, + Challenge: challenge, + AllowedOrigins: s.allowedOrigins, + StoredSignCount: cred.SignCount, + }) + if err != nil { + return 0, fmt.Errorf("%w: %v", domain.ErrPasskeyInvalid, err) + } + if err := s.creds.UpdatePasskeyUsage(ctx, cred.CredentialID, newCount, s.now().Unix()); err != nil { + return 0, err + } + return cred.UserID, nil +} + +// List 返回用户的全部 passkey(管理页)。 +func (s *Service) List(ctx context.Context, userID int64) ([]domain.PasskeyCredential, error) { + if s == nil || s.creds == nil || userID == 0 { + return nil, nil + } + return s.creds.ListPasskeysByUser(ctx, userID) +} + +// Delete 删除用户的某个 passkey。credentialID 为原始字节。 +func (s *Service) Delete(ctx context.Context, userID int64, credentialID []byte) (bool, error) { + if s == nil || s.creds == nil || userID == 0 { + return false, nil + } + return s.creds.DeletePasskey(ctx, userID, credentialID) +} diff --git a/internal/app/peerview/cache.go b/internal/app/peerview/cache.go new file mode 100644 index 00000000..76cd7ec5 --- /dev/null +++ b/internal/app/peerview/cache.go @@ -0,0 +1,141 @@ +package peerview + +import ( + "context" + + "telesrv/internal/domain" +) + +// UserResolver resolves users after applying viewer-specific projection. +type UserResolver interface { + ByIDs(ctx context.Context, viewerUserID int64, userIDs []int64) ([]domain.User, error) +} + +// BatchCache caches projected users only for one update-building batch. +// The cache key includes viewerUserID because contacts, privacy, phone visibility +// and personal/fallback photos are viewer-specific. +type BatchCache struct { + users UserResolver + + byViewer map[int64]map[int64]domain.User + missing map[int64]map[int64]struct{} +} + +func NewBatchCache(users UserResolver) *BatchCache { + return &BatchCache{ + users: users, + byViewer: make(map[int64]map[int64]domain.User), + missing: make(map[int64]map[int64]struct{}), + } +} + +func (c *BatchCache) UsersForView(ctx context.Context, viewerUserID int64, ids []int64) ([]domain.User, error) { + unique := uniqueIDs(ids) + if len(unique) == 0 { + return nil, nil + } + byID := c.viewerUsers(viewerUserID) + missing := c.viewerMissing(viewerUserID) + load := make([]int64, 0, len(unique)) + for _, id := range unique { + if _, ok := byID[id]; ok { + continue + } + if _, ok := missing[id]; ok { + continue + } + if u, ok := domain.SystemUserByID(id); ok { + byID[id] = u + continue + } + if c.users == nil || viewerUserID == 0 { + missing[id] = struct{}{} + continue + } + load = append(load, id) + } + var err error + if len(load) > 0 { + var resolved []domain.User + resolved, err = c.users.ByIDs(ctx, viewerUserID, load) + if err == nil { + found := make(map[int64]struct{}, len(resolved)) + for _, u := range resolved { + if u.ID == 0 { + continue + } + byID[u.ID] = u + found[u.ID] = struct{}{} + } + for _, id := range load { + if _, ok := found[id]; !ok { + missing[id] = struct{}{} + } + } + } + } + out := make([]domain.User, 0, len(unique)) + for _, id := range unique { + if u, ok := byID[id]; ok { + out = append(out, u) + } + } + return out, err +} + +// Prime 预热某 viewer 的已投影用户(fan-out 跨 viewer 模板化把 per-recipient ForViewer 折叠成 +// 一次 ForViewers 后回填)。已存在的 id 不覆盖(保留同批已解析结果),避免预热与按需解析互相打架。 +// 预热的是「投影后、未叠加实时 presence」的用户——与 UsersForView 缓存层一致,presence 仍由 +// 上层在输出端叠加,故预热不破坏 presence 新鲜度。 +func (c *BatchCache) Prime(viewerUserID int64, users []domain.User) { + if c == nil || viewerUserID == 0 || len(users) == 0 { + return + } + byID := c.viewerUsers(viewerUserID) + for _, u := range users { + if u.ID == 0 { + continue + } + if _, ok := byID[u.ID]; ok { + continue + } + byID[u.ID] = u + } +} + +func (c *BatchCache) viewerUsers(viewerUserID int64) map[int64]domain.User { + if byID, ok := c.byViewer[viewerUserID]; ok { + return byID + } + byID := make(map[int64]domain.User) + c.byViewer[viewerUserID] = byID + return byID +} + +func (c *BatchCache) viewerMissing(viewerUserID int64) map[int64]struct{} { + if missing, ok := c.missing[viewerUserID]; ok { + return missing + } + missing := make(map[int64]struct{}) + c.missing[viewerUserID] = missing + return missing +} + +func uniqueIDs(ids []int64) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} diff --git a/internal/app/peerview/cache_test.go b/internal/app/peerview/cache_test.go new file mode 100644 index 00000000..e9789663 --- /dev/null +++ b/internal/app/peerview/cache_test.go @@ -0,0 +1,108 @@ +package peerview + +import ( + "context" + "reflect" + "testing" + + "telesrv/internal/domain" +) + +func TestBatchCacheCachesPerViewer(t *testing.T) { + resolver := &captureUserResolver{ + users: map[int64]domain.User{ + 1000000001: {ID: 1000000001, FirstName: "Alice"}, + }, + } + cache := NewBatchCache(resolver) + + got, err := cache.UsersForView(context.Background(), 1000000002, []int64{1000000001, 1000000001}) + if err != nil { + t.Fatalf("UsersForView: %v", err) + } + if len(got) != 1 || got[0].ID != 1000000001 { + t.Fatalf("users = %+v, want Alice once", got) + } + got, err = cache.UsersForView(context.Background(), 1000000002, []int64{1000000001}) + if err != nil { + t.Fatalf("UsersForView cached: %v", err) + } + got, err = cache.UsersForView(context.Background(), 1000000003, []int64{1000000001}) + if err != nil { + t.Fatalf("UsersForView other viewer: %v", err) + } + + wantCalls := []resolverCall{ + {viewerUserID: 1000000002, ids: []int64{1000000001}}, + {viewerUserID: 1000000003, ids: []int64{1000000001}}, + } + if !reflect.DeepEqual(resolver.calls, wantCalls) { + t.Fatalf("resolver calls = %+v, want %+v", resolver.calls, wantCalls) + } +} + +// TestBatchCachePrimeServesWithoutResolver:Prime 预热的 viewer 用户被 UsersForView 直接命中, +// 不再回落 resolver(fan-out 跨 viewer 投影预热把 per-recipient ByIDs 折叠成一次 ForViewers 的前提); +// 且 Prime 不覆盖已解析的同 id(按需解析优先)。 +func TestBatchCachePrimeServesWithoutResolver(t *testing.T) { + resolver := &captureUserResolver{ + users: map[int64]domain.User{ + 1000000001: {ID: 1000000001, FirstName: "Resolved"}, + }, + } + cache := NewBatchCache(resolver) + + const viewer = int64(1000000002) + cache.Prime(viewer, []domain.User{{ID: 1000000001, FirstName: "Primed"}, {ID: 1000000009, FirstName: "PrimedOnly"}}) + + got, err := cache.UsersForView(context.Background(), viewer, []int64{1000000001, 1000000009}) + if err != nil { + t.Fatalf("UsersForView: %v", err) + } + byID := map[int64]domain.User{} + for _, u := range got { + byID[u.ID] = u + } + if byID[1000000001].FirstName != "Primed" || byID[1000000009].FirstName != "PrimedOnly" { + t.Fatalf("primed users = %+v, want Primed/PrimedOnly served from cache", got) + } + if len(resolver.calls) != 0 { + t.Fatalf("resolver called %d times, want 0 (all served from prime)", len(resolver.calls)) + } + + // 已解析的 id 不被后续 Prime 覆盖。 + other := &captureUserResolver{users: map[int64]domain.User{1000000003: {ID: 1000000003, FirstName: "Resolved3"}}} + c2 := NewBatchCache(other) + if _, err := c2.UsersForView(context.Background(), viewer, []int64{1000000003}); err != nil { + t.Fatalf("resolve 3: %v", err) + } + c2.Prime(viewer, []domain.User{{ID: 1000000003, FirstName: "ShouldNotOverwrite"}}) + got2, err := c2.UsersForView(context.Background(), viewer, []int64{1000000003}) + if err != nil { + t.Fatalf("UsersForView after prime: %v", err) + } + if len(got2) != 1 || got2[0].FirstName != "Resolved3" { + t.Fatalf("after prime = %+v, want resolved value preserved (no overwrite)", got2) + } +} + +type resolverCall struct { + viewerUserID int64 + ids []int64 +} + +type captureUserResolver struct { + users map[int64]domain.User + calls []resolverCall +} + +func (r *captureUserResolver) ByIDs(_ context.Context, viewerUserID int64, userIDs []int64) ([]domain.User, error) { + r.calls = append(r.calls, resolverCall{viewerUserID: viewerUserID, ids: append([]int64(nil), userIDs...)}) + out := make([]domain.User, 0, len(userIDs)) + for _, id := range userIDs { + if u, ok := r.users[id]; ok { + out = append(out, u) + } + } + return out, nil +} diff --git a/internal/app/phone/dh.go b/internal/app/phone/dh.go new file mode 100644 index 00000000..cb9c258a --- /dev/null +++ b/internal/app/phone/dh.go @@ -0,0 +1,58 @@ +// Package phone 实现私聊 1:1 通话的信令状态机与 DH 参数下发。 +// +// 服务端职责边界:信令转发、状态机、commit-reveal 核验(SHA256(g_a)==g_a_hash)、 +// connections 下发。媒体面由客户端 tgcalls 走 P2P/TURN,密钥交换是 E2E 的, +// 服务端不知道也无法验证共享密钥本身。 +package phone + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +// DHConfigVersion 是 messages.getDhConfig 的静态版本号。p/g 是编译期常量, +// 客户端缓存命中(请求 version 相同)时只回 dhConfigNotModified{random}。 +const DHConfigVersion = 1 + +// DHG 是 DH generator。与官方一致取 3:TDesktop MTP::IsPrimeAndGood 对 +// 「官方 2048-bit prime + g∈{3,4,5,7}」有白名单快速通过路径,DrKLO native 同。 +const DHG = 3 + +// dhPrimeHex 是官方 2048-bit safe prime。与 internal/app/account/srp.go 的 +// baseP 同值(SRP 与通话 DH 共用官方参数);改动任一处须同步另一处。 +const dhPrimeHex = "c71caeb9c6b1c9048e6c522f70f13f73980d40238e3e21c14934d037563d930f48198a0aa7c14058229493d22530f4dbfa336f6e0ac925139543aed44cce7c3720fd51f69458705ac68cd4fe6b6b13abdc9746512969328454f18faf8c595f642477fe96bb2a941d5bcd1d4ac8cc49880708fa9b378e3c4f3a9060bee67cf9a4a4a695811051907e162753b56b0f6b410dba74d8a84b2a14b3144e0ef1284754fd17ed950d5965b4b9dd46582db1178d169c6bc465b0d6ff9ca3928fef5b9ae4e418fc15e83ebea0f87fa9ff5eed70050ded2849f47bf959d956850ce929851f0d8115f635b105ee2e4e15d04b2454bf6f4fadf034b10403119cd8e3b92fcc5b" + +var dhPrime = mustDecodeHex(dhPrimeHex) + +// maxDHRandomLength 钳制客户端请求的随机字节数,防御恶意大请求。 +const maxDHRandomLength = 1024 + +// DHPrime 返回官方 2048-bit prime 的拷贝。 +func DHPrime() []byte { + return append([]byte(nil), dhPrime...) +} + +// DHRandom 生成恰好 n 字节加密随机数;n 钳制到 [0, maxDHRandomLength]。 +// 客户端契约要求 random 尺寸与请求一致(TDesktop 校验 random.size() 与其请求相同)。 +func DHRandom(n int) ([]byte, error) { + if n < 0 { + n = 0 + } + if n > maxDHRandomLength { + n = maxDHRandomLength + } + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return nil, fmt.Errorf("phone: dh random: %w", err) + } + return buf, nil +} + +func mustDecodeHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(fmt.Sprintf("phone: invalid dh prime hex: %v", err)) + } + return b +} diff --git a/internal/app/phone/registry.go b/internal/app/phone/registry.go new file mode 100644 index 00000000..4e72bca3 --- /dev/null +++ b/internal/app/phone/registry.go @@ -0,0 +1,120 @@ +package phone + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + "sync" + + "telesrv/internal/domain" +) + +// registry 是 active call 的进程内权威存储:单实现、不进 store 双实现体系。 +// +// 论证(设计已确认,勿改回双 store):active call 是秒级短命状态,服务端重启后 +// 客户端侧媒体连接早已断开,「重启恢复半截通话」不是有效需求;单一实现让测试与 +// 生产共用同一份代码,从构造上消灭 memory/postgres 行为漂移。多实例化时以同样的 +// 窄接口换 Redis 实现,信令层零改动。 +type registry struct { + mu sync.Mutex + byID map[int64]*entry + byRandom map[randomKey]int64 // (callerID, randomID) → callID,吸收客户端 RPC 重试 + active map[int64]int // userID → 非终态通话数(并发上限依据) +} + +type randomKey struct { + callerID int64 + randomID int64 +} + +// entry 持有一通通话;call 字段由 registry.mu 保护。 +// sigMu 单独串行化该通话的信令转发(锁内做推送入队),与状态锁分离, +// 保证 discard 等状态迁移不被对端出站队列堵塞拖住。 +type entry struct { + call domain.PhoneCall + + sigMu sync.Mutex + sigWindowSec int64 + sigCount int +} + +func newRegistry() *registry { + return ®istry{ + byID: make(map[int64]*entry), + byRandom: make(map[randomKey]int64), + active: make(map[int64]int), + } +} + +// sweepLocked 是 P1 的纯年龄 GC(调用方持有 r.mu): +// - 终态 tombstone 超过 tombstoneTTL → 回收(密钥材料随之销毁); +// - 非终态超过 2×ringTimeout → 直接回收(双端同时崩溃的兜底,防僵尸通话 +// 吃满并发上限;不推送、不落历史,正常超时由客户端定时器与 P2 dispatcher 处理)。 +func (r *registry) sweepLocked(nowUnix int64, ringTimeoutSec, tombstoneTTLSec int64) { + for id, e := range r.byID { + switch { + case e.call.Terminal(): + if nowUnix-int64(e.call.DiscardedAt) > tombstoneTTLSec { + r.removeLocked(id, e, false) + } + default: + if nowUnix-int64(e.call.Date) > 2*ringTimeoutSec { + r.removeLocked(id, e, true) + } + } + } +} + +func (r *registry) removeLocked(id int64, e *entry, wasActive bool) { + delete(r.byID, id) + delete(r.byRandom, randomKey{callerID: e.call.AdminID, randomID: e.call.RandomID}) + if wasActive { + r.decActiveLocked(e.call.AdminID) + } +} + +func (r *registry) decActiveLocked(userID int64) { + if n := r.active[userID]; n <= 1 { + delete(r.active, userID) + } else { + r.active[userID] = n - 1 + } +} + +// markDiscardedLocked 把非终态 entry 迁入终态并更新并发计数。 +func (r *registry) markDiscardedLocked(e *entry, reason domain.PhoneCallDiscardReason, duration, nowUnix int) { + if e.call.Terminal() { + return + } + e.call.State = domain.PhoneCallStateDiscarded + e.call.DiscardReason = reason + e.call.Duration = duration + e.call.DiscardedAt = nowUnix + r.decActiveLocked(e.call.AdminID) +} + +// newID 生成 registry 内唯一的正 int64(调用方持有 r.mu)。 +func (r *registry) newIDLocked() (int64, error) { + for i := 0; i < 32; i++ { + id, err := randomPositiveInt64() + if err != nil { + return 0, err + } + if _, exists := r.byID[id]; !exists { + return id, nil + } + } + return 0, fmt.Errorf("phone: exhausted call id attempts") +} + +func randomPositiveInt64() (int64, error) { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return 0, fmt.Errorf("phone: random id: %w", err) + } + v := int64(binary.BigEndian.Uint64(buf[:]) >> 1) + if v == 0 { + v = 1 + } + return v, nil +} diff --git a/internal/app/phone/service.go b/internal/app/phone/service.go new file mode 100644 index 00000000..d23ef955 --- /dev/null +++ b/internal/app/phone/service.go @@ -0,0 +1,502 @@ +package phone + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "strconv" + "strings" + "time" + + "github.com/gotd/td/clock" + + "telesrv/internal/domain" +) + +// 客户端可识别的业务错误;rpc 层映射为对应 RPC_ERROR(CALL_* 等)。 +var ( + ErrPeerInvalid = errors.New("phone: call peer invalid") + ErrAlreadyAccepted = errors.New("phone: call already accepted") + ErrAlreadyDeclined = errors.New("phone: call already declined") + ErrOccupyFailed = errors.New("phone: too many active calls") + ErrProtocolLayerInvalid = errors.New("phone: protocol layer invalid") + ErrProtocolCompatLayerInvalid = errors.New("phone: protocol compat layer invalid") + ErrProtocolFlagsInvalid = errors.New("phone: protocol flags invalid") + // ErrGAHashMismatch:confirmCall 揭示的 g_a 与 requestCall 承诺的 SHA256 不符。 + // 服务端同时把通话强制置为 discarded(disconnect),防止攻击者卡死状态机。 + ErrGAHashMismatch = errors.New("phone: g_a does not match committed hash") +) + +// minSupportedLayer 是 libtgvoip/tgcalls 的最低协议层(TDesktop kMinLayer、 +// DrKLO VoIPService.CALL_MIN_LAYER 均为 65)。 +const minSupportedLayer = 65 + +const ( + gaHashSize = sha256.Size // 32 + dhPubSize = 256 // g_a / g_b 都是 2048-bit +) + +// Config 是通话服务的运行参数。 +type Config struct { + // RingTimeout 是服务端兜底超时(与下发给客户端的 callRingTimeoutMs 同源,默认 90s)。 + RingTimeout time.Duration + // TombstoneTTL 是终态 tombstone 保留期(吸收双方同时挂断/晚到 RPC 的幂等窗口)。 + TombstoneTTL time.Duration + // MaxActivePerUser 是单用户并发非终态通话上限(防呼叫轰炸自锁)。 + MaxActivePerUser int + // SignalingRatePerSecond 是单通话每秒信令转发上限;超限静默丢弃(不破坏客户端状态机)。 + SignalingRatePerSecond int +} + +func (c Config) withDefaults() Config { + if c.RingTimeout <= 0 { + c.RingTimeout = 90 * time.Second + } + if c.TombstoneTTL <= 0 { + c.TombstoneTTL = 60 * time.Second + } + if c.MaxActivePerUser <= 0 { + c.MaxActivePerUser = 4 + } + if c.SignalingRatePerSecond <= 0 { + c.SignalingRatePerSecond = 50 + } + return c +} + +// Service 实现私聊通话信令状态机。所有方法返回的 domain.PhoneCall 都是当时快照。 +type Service struct { + cfg Config + clk clock.Clock + reg *registry +} + +// Option 配置 Service。 +type Option func(*Service) + +// WithClock 注入测试时钟。 +func WithClock(clk clock.Clock) Option { + return func(s *Service) { s.clk = clk } +} + +// NewService 创建通话服务。 +func NewService(cfg Config, opts ...Option) *Service { + s := &Service{cfg: cfg.withDefaults(), clk: clock.System, reg: newRegistry()} + for _, opt := range opts { + opt(s) + } + return s +} + +// RequestCall 受理主叫请求:校验协议与配额、(callerID, randomID) 幂等去重、建档。 +// 隐私/拉黑/目标用户合法性由 rpc 层先行校验。 +func (s *Service) RequestCall(ctx context.Context, callerID int64, in domain.PhoneCallRequest) (domain.PhoneCall, error) { + if err := validateProtocol(in.Protocol); err != nil { + return domain.PhoneCall{}, err + } + if len(in.GAHash) != gaHashSize { + return domain.PhoneCall{}, ErrProtocolFlagsInvalid + } + now := s.clk.Now() + nowUnix := now.Unix() + + s.reg.mu.Lock() + defer s.reg.mu.Unlock() + s.reg.sweepLocked(nowUnix, int64(s.cfg.RingTimeout/time.Second), int64(s.cfg.TombstoneTTL/time.Second)) + + // 幂等:同一 (callerID, randomID) 的未终结通话直接返回快照,吸收客户端重试。 + key := randomKey{callerID: callerID, randomID: in.RandomID} + if id, ok := s.reg.byRandom[key]; ok { + if e, ok := s.reg.byID[id]; ok && !e.call.Terminal() { + return e.call, nil + } + } + if s.reg.active[callerID] >= s.cfg.MaxActivePerUser { + return domain.PhoneCall{}, ErrOccupyFailed + } + + id, err := s.reg.newIDLocked() + if err != nil { + return domain.PhoneCall{}, err + } + accessHash, err := randomPositiveInt64() + if err != nil { + return domain.PhoneCall{}, err + } + call := domain.PhoneCall{ + ID: id, + AccessHash: accessHash, + AdminID: callerID, + ParticipantID: in.CalleeID, + Video: in.Video, + State: domain.PhoneCallStateRequested, + Date: int(nowUnix), + GAHash: append([]byte(nil), in.GAHash...), + CallerProtocol: in.Protocol, + Protocol: in.Protocol, + RandomID: in.RandomID, + CallerDevice: in.CallerDevice, + PrivacyP2P: in.PrivacyP2P, + Connections: append([]domain.PhoneCallConnection(nil), in.Connections...), + } + s.reg.byID[id] = &entry{call: call} + s.reg.byRandom[key] = id + s.reg.active[callerID]++ + return call, nil +} + +// ReceivedCall 标记被叫设备已收到来电。首次(Requested→Ringing)返回 transitioned=true, +// 其余状态幂等成功(多设备各自上报、晚到无害)。 +func (s *Service) ReceivedCall(ctx context.Context, userID, callID, accessHash int64) (domain.PhoneCall, bool, error) { + s.reg.mu.Lock() + defer s.reg.mu.Unlock() + e, err := s.lookupLocked(callID, accessHash) + if err != nil { + return domain.PhoneCall{}, false, err + } + if userID != e.call.ParticipantID { + // receivedCall 只能由被叫上报。 + return domain.PhoneCall{}, false, ErrPeerInvalid + } + if e.call.State == domain.PhoneCallStateRequested { + e.call.State = domain.PhoneCallStateRinging + e.call.ReceiveDate = int(s.clk.Now().Unix()) + return e.call, true, nil + } + return e.call, false, nil +} + +// AcceptCall 受理被叫接听。多设备并发竞争由 registry 锁串行化:首个完成迁移者赢, +// 后到者收 ErrAlreadyAccepted(其 UI 自行收场)。 +func (s *Service) AcceptCall(ctx context.Context, userID, callID, accessHash int64, gb []byte, proto domain.PhoneCallProtocol, device domain.SessionRef) (domain.PhoneCall, error) { + if err := validateProtocol(proto); err != nil { + return domain.PhoneCall{}, err + } + if len(gb) != dhPubSize || allZero(gb) { + return domain.PhoneCall{}, ErrProtocolFlagsInvalid + } + s.reg.mu.Lock() + defer s.reg.mu.Unlock() + e, err := s.lookupLocked(callID, accessHash) + if err != nil { + return domain.PhoneCall{}, err + } + if userID != e.call.ParticipantID { + return domain.PhoneCall{}, ErrPeerInvalid + } + switch e.call.State { + case domain.PhoneCallStateRequested, domain.PhoneCallStateRinging: + // 合法路径;未 receivedCall 直接 accept 也允许。 + case domain.PhoneCallStateAccepted, domain.PhoneCallStateConfirmed: + return domain.PhoneCall{}, ErrAlreadyAccepted + case domain.PhoneCallStateDiscarded: + return domain.PhoneCall{}, ErrAlreadyDeclined + } + negotiated, err := negotiateProtocol(e.call.CallerProtocol, proto) + if err != nil { + return domain.PhoneCall{}, err + } + e.call.GB = append([]byte(nil), gb...) + e.call.CalleeProtocol = proto + e.call.Protocol = negotiated + e.call.CalleeDevice = device + e.call.State = domain.PhoneCallStateAccepted + // 不解除超时:同一只表继续走,主叫永不 confirm 时由兜底超时收尾。 + return e.call, nil +} + +// ConfirmCall 受理主叫确认:核验 SHA256(g_a) 与承诺一致后进入 Confirmed。 +// 核验失败时通话被强制置为 discarded(disconnect),返回 (终态快照, true, ErrGAHashMismatch), +// 调用方须把终态推送给双方。 +func (s *Service) ConfirmCall(ctx context.Context, userID, callID, accessHash int64, ga []byte, keyFingerprint int64, proto domain.PhoneCallProtocol) (domain.PhoneCall, bool, error) { + s.reg.mu.Lock() + defer s.reg.mu.Unlock() + e, err := s.lookupLocked(callID, accessHash) + if err != nil { + return domain.PhoneCall{}, false, err + } + if userID != e.call.AdminID { + return domain.PhoneCall{}, false, ErrPeerInvalid + } + switch e.call.State { + case domain.PhoneCallStateAccepted: + case domain.PhoneCallStateConfirmed: + return domain.PhoneCall{}, false, ErrAlreadyAccepted + case domain.PhoneCallStateDiscarded: + return domain.PhoneCall{}, false, ErrAlreadyDeclined + default: // Requested / Ringing:被叫尚未 accept,confirm 非法。 + return domain.PhoneCall{}, false, ErrPeerInvalid + } + // confirmCall 携带的第三份 protocol 仅校验合法性,不改 accept 时的协商结果 + //(官方语义:协商在 accept 完成,confirm 的 protocol 是回显)。 + if err := validateProtocol(proto); err != nil { + return domain.PhoneCall{}, false, err + } + if len(ga) != dhPubSize || sha256Mismatch(ga, e.call.GAHash) { + now := int(s.clk.Now().Unix()) + s.reg.markDiscardedLocked(e, domain.PhoneCallDiscardReasonDisconnect, 0, now) + return e.call, true, ErrGAHashMismatch + } + e.call.GA = append([]byte(nil), ga...) + e.call.KeyFingerprint = keyFingerprint + e.call.StartDate = int(s.clk.Now().Unix()) + // p2p_allowed = 双方 protocol 都允许 P2P ∧ phone_p2p 隐私双向放行(P3 起 + // PrivacyP2P 由 rpc 层算定,强制 relay 时也走它置 false)。false 时 tgcalls + // 只用 relay candidates——前提是 connections 里有可用 TURN。 + e.call.P2PAllowed = e.call.CallerProtocol.UDPP2P && e.call.CalleeProtocol.UDPP2P && e.call.PrivacyP2P + e.call.State = domain.PhoneCallStateConfirmed + return e.call, false, nil +} + +// DiscardCall 挂断:任意非终态可达,幂等。already=true 表示通话此前已是终态 +// (双方同时挂断:先到者定 reason,后到者拿快照)。 +func (s *Service) DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (domain.PhoneCall, bool, error) { + s.reg.mu.Lock() + defer s.reg.mu.Unlock() + e, err := s.lookupLocked(callID, accessHash) + if err != nil { + return domain.PhoneCall{}, false, err + } + if !e.call.HasParticipant(userID) { + return domain.PhoneCall{}, false, ErrPeerInvalid + } + if e.call.Terminal() { + return e.call, true, nil + } + if reason == "" { + reason = domain.PhoneCallDiscardReasonHangup + } + // duration 只在通话真正建立(Confirmed)后才认,防止客户端把振铃时长报成通话时长。 + if e.call.StartDate == 0 || duration < 0 { + duration = 0 + } + s.reg.markDiscardedLocked(e, reason, duration, int(s.clk.Now().Unix())) + return e.call, false, nil +} + +// ExpireDue 把超时的非终态通话迁入终态并返回快照(调用方负责推送与落历史): +// Requested/Ringing 超时 → missed(即「未接来电」来源);Accepted 悬挂(主叫 +// 永不 confirm)→ disconnect。Confirmed 通话没有服务端时长上限,不在此回收。 +// 顺带做 tombstone GC。 +func (s *Service) ExpireDue(ctx context.Context, now time.Time) []domain.PhoneCall { + nowUnix := now.Unix() + ringSec := int64(s.cfg.RingTimeout / time.Second) + s.reg.mu.Lock() + defer s.reg.mu.Unlock() + var expired []domain.PhoneCall + for _, e := range s.reg.byID { + if e.call.Terminal() || e.call.State == domain.PhoneCallStateConfirmed { + continue + } + if nowUnix-int64(e.call.Date) <= ringSec { + continue + } + reason := domain.PhoneCallDiscardReasonMissed + if e.call.State == domain.PhoneCallStateAccepted { + reason = domain.PhoneCallDiscardReasonDisconnect + } + s.reg.markDiscardedLocked(e, reason, 0, int(nowUnix)) + expired = append(expired, e.call) + } + s.reg.sweepLocked(nowUnix, ringSec, int64(s.cfg.TombstoneTTL/time.Second)) + return expired +} + +// Signal 校验并串行转发一条信令。forward 在该通话专属的信令顺序锁内执行 +// (保证转发顺序与受理顺序一致),状态锁不跨 forward 持有。peerDevice 是对端 +// 受理设备锚点(可能为零值/已失效),仅作定向推送 fast-path 提示。 +// drop=true 表示按契约静默吞掉(tombstone 尾包 / 超过速率上限),调用方应返回成功。 +func (s *Service) Signal(ctx context.Context, userID, callID, accessHash int64, forward func(peerUserID int64, peerDevice domain.SessionRef)) (drop bool, err error) { + s.reg.mu.Lock() + e, lookupErr := s.lookupLocked(callID, accessHash) + if lookupErr != nil { + s.reg.mu.Unlock() + return false, lookupErr + } + if !e.call.HasParticipant(userID) { + s.reg.mu.Unlock() + return false, ErrPeerInvalid + } + state := e.call.State + peer := e.call.PeerOf(userID) + peerDevice := e.call.CalleeDevice + if peer == e.call.AdminID { + peerDevice = e.call.CallerDevice + } + s.reg.mu.Unlock() + + switch state { + case domain.PhoneCallStateAccepted, domain.PhoneCallStateConfirmed: + // 可转发。DrKLO 在 confirm 前后都可能发信令,Accepted 即放行。 + case domain.PhoneCallStateDiscarded: + // 挂断瞬间的尾包:返回错误会让 TDesktop 把正常挂断渲染成「通话失败」 + //(其 sendSignalingData 的 .done 校验 mtpIsTrue),静默丢弃。 + return true, nil + default: + return false, ErrPeerInvalid + } + + e.sigMu.Lock() + defer e.sigMu.Unlock() + nowSec := s.clk.Now().Unix() + if e.sigWindowSec != nowSec { + e.sigWindowSec = nowSec + e.sigCount = 0 + } + if e.sigCount >= s.cfg.SignalingRatePerSecond { + return true, nil + } + e.sigCount++ + forward(peer, peerDevice) + return false, nil +} + +// Lookup 返回通话快照(rpc 层宽容校验 setCallRating/saveCallDebug 等晚到请求用)。 +func (s *Service) Lookup(ctx context.Context, callID, accessHash int64) (domain.PhoneCall, bool) { + s.reg.mu.Lock() + defer s.reg.mu.Unlock() + e, err := s.lookupLocked(callID, accessHash) + if err != nil { + return domain.PhoneCall{}, false + } + return e.call, true +} + +func (s *Service) lookupLocked(callID, accessHash int64) (*entry, error) { + e, ok := s.reg.byID[callID] + if !ok || e.call.AccessHash != accessHash { + return nil, ErrPeerInvalid + } + return e, nil +} + +func validateProtocol(p domain.PhoneCallProtocol) error { + if p.MinLayer > p.MaxLayer { + return ErrProtocolLayerInvalid + } + if p.MaxLayer < minSupportedLayer { + return ErrProtocolCompatLayerInvalid + } + if !p.UDPP2P && !p.UDPReflector { + return ErrProtocolFlagsInvalid + } + if len(p.LibraryVersions) == 0 { + return ErrProtocolFlagsInvalid + } + return nil +} + +// negotiateProtocol 在 accept 时合并双方 protocol(官方语义:服务端只回「最优」单值 +// library version)。版本无交集时透传被叫列表(被叫是先实例化 tgcalls 的一方, +// 主叫侧有 kDefaultVersion 兜底)——绝不因版本差异拒绝通话。 +func negotiateProtocol(caller, callee domain.PhoneCallProtocol) (domain.PhoneCallProtocol, error) { + out := domain.PhoneCallProtocol{ + UDPP2P: caller.UDPP2P && callee.UDPP2P, + UDPReflector: caller.UDPReflector || callee.UDPReflector, + MinLayer: maxInt(caller.MinLayer, callee.MinLayer), + MaxLayer: minInt(caller.MaxLayer, callee.MaxLayer), + } + if out.MinLayer > out.MaxLayer { + return domain.PhoneCallProtocol{}, ErrProtocolCompatLayerInvalid + } + if best, ok := bestCommonVersion(caller.LibraryVersions, callee.LibraryVersions); ok { + out.LibraryVersions = []string{best} + } else { + out.LibraryVersions = append([]string(nil), callee.LibraryVersions...) + } + return out, nil +} + +// preferredVersions 是交集命中时的优先选择序(读客户端源码定下的硬约束): +// - "9.0.0"=InstanceV2Impl+V2 消息级信令,TDesktop 与 DrKLO 注册表都有,最稳; +// - ⚠ 绝不能选 "10.0.0"/"11.0.0"/"12.0.0"/"13.0.0" 当 [0]:DrKLO 的视频可用性 +// 判断是 `"2.7.7".compareTo(versions[0]) <= 0` 的**字符串字典序**比较 +// (VoIPService.java:3464),"1x.0.0" 字典序小于 "2.7.7" 会让 Android 直接 +// 销毁摄像头采集(视频通话黑屏);"12/13" 还是 V3 SCTP-over-signaling, +// 对服务端信令限速不友好; +// - 也绝不能选 "2.7.7"/"5.0.0"/"2.4.4":legacy 实现依赖我们不下发的 +// reflector endpoints,无路可走。 +var preferredVersions = []string{"9.0.0", "8.0.0", "7.0.0"} + +// bestCommonVersion 取两侧版本集合交集:优先 preferredVersions 顺位命中, +// 否则退化为语义化最高者(容忍未来未知版本集)。 +func bestCommonVersion(a, b []string) (string, bool) { + inB := make(map[string]struct{}, len(b)) + for _, v := range b { + inB[v] = struct{}{} + } + inBoth := make(map[string]struct{}, len(a)) + for _, v := range a { + if _, ok := inB[v]; ok { + inBoth[v] = struct{}{} + } + } + for _, v := range preferredVersions { + if _, ok := inBoth[v]; ok { + return v, true + } + } + best, found := "", false + for v := range inBoth { + if !found || compareVersion(v, best) > 0 { + best, found = v, true + } + } + return best, found +} + +// compareVersion 按点分十进制比较("9.0.0" < "10.0.0");非数字段退化为字符串比较。 +func compareVersion(a, b string) int { + as, bs := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(as) || i < len(bs); i++ { + var av, bv string + if i < len(as) { + av = as[i] + } + if i < len(bs) { + bv = bs[i] + } + an, aerr := strconv.Atoi(av) + bn, berr := strconv.Atoi(bv) + switch { + case aerr == nil && berr == nil: + if an != bn { + return an - bn + } + default: + if c := strings.Compare(av, bv); c != 0 { + return c + } + } + } + return 0 +} + +func sha256Mismatch(data, want []byte) bool { + got := sha256.Sum256(data) + return !bytes.Equal(got[:], want) +} + +func allZero(b []byte) bool { + for _, v := range b { + if v != 0 { + return false + } + } + return true +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/app/phone/service_test.go b/internal/app/phone/service_test.go new file mode 100644 index 00000000..50909cc3 --- /dev/null +++ b/internal/app/phone/service_test.go @@ -0,0 +1,479 @@ +package phone + +import ( + "context" + "crypto/sha256" + "errors" + "sync" + "testing" + "time" + + "github.com/gotd/td/clock" + + "telesrv/internal/domain" +) + +type testClock struct { + mu sync.Mutex + now time.Time +} + +func newTestClock() *testClock { + return &testClock{now: time.Unix(1_700_000_000, 0)} +} + +func (c *testClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *testClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +func (c *testClock) Timer(d time.Duration) clock.Timer { return clock.System.Timer(d) } +func (c *testClock) Ticker(d time.Duration) clock.Ticker { return clock.System.Ticker(d) } + +func testProtocol(versions ...string) domain.PhoneCallProtocol { + if len(versions) == 0 { + versions = []string{"11.0.0", "10.0.0"} + } + return domain.PhoneCallProtocol{ + UDPP2P: true, + UDPReflector: true, + MinLayer: 65, + MaxLayer: 92, + LibraryVersions: versions, + } +} + +func testGA() ([]byte, []byte) { + ga := make([]byte, 256) + for i := range ga { + ga[i] = byte(i + 1) + } + hash := sha256.Sum256(ga) + return ga, hash[:] +} + +func testGB() []byte { + gb := make([]byte, 256) + for i := range gb { + gb[i] = byte(255 - i%200) + } + return gb +} + +func newTestService(clk clock.Clock, mutate ...func(*Config)) *Service { + cfg := Config{ + RingTimeout: 90 * time.Second, + TombstoneTTL: 60 * time.Second, + MaxActivePerUser: 4, + SignalingRatePerSecond: 50, + } + for _, fn := range mutate { + fn(&cfg) + } + return NewService(cfg, WithClock(clk)) +} + +func mustRequest(t *testing.T, s *Service, caller, callee int64, gaHash []byte) domain.PhoneCall { + t.Helper() + call, err := s.RequestCall(context.Background(), caller, domain.PhoneCallRequest{ + CalleeID: callee, + RandomID: caller*1000 + callee, + GAHash: gaHash, + Protocol: testProtocol(), + PrivacyP2P: true, // rpc 层算定的 phone_p2p 双向放行(P3 起参与 p2p_allowed AND) + }) + if err != nil { + t.Fatalf("RequestCall: %v", err) + } + return call +} + +func TestPhoneCallHappyPath(t *testing.T) { + clk := newTestClock() + s := newTestService(clk) + ctx := context.Background() + ga, gaHash := testGA() + gb := testGB() + + call := mustRequest(t, s, 1, 2, gaHash) + if call.State != domain.PhoneCallStateRequested || call.AdminID != 1 || call.ParticipantID != 2 { + t.Fatalf("requested call = %+v", call) + } + + clk.Advance(2 * time.Second) + ringing, transitioned, err := s.ReceivedCall(ctx, 2, call.ID, call.AccessHash) + if err != nil || !transitioned || ringing.State != domain.PhoneCallStateRinging || ringing.ReceiveDate == 0 { + t.Fatalf("ReceivedCall = %+v transitioned=%v err=%v", ringing, transitioned, err) + } + if _, again, err := s.ReceivedCall(ctx, 2, call.ID, call.AccessHash); err != nil || again { + t.Fatalf("second ReceivedCall transitioned=%v err=%v, want idempotent", again, err) + } + + accepted, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{SessionID: 22}) + if err != nil || accepted.State != domain.PhoneCallStateAccepted { + t.Fatalf("AcceptCall = %+v err=%v", accepted, err) + } + if string(accepted.GB) != string(gb) { + t.Fatalf("accepted.GB mismatch") + } + + confirmed, forced, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, ga, 0x1234, testProtocol()) + if err != nil || forced || confirmed.State != domain.PhoneCallStateConfirmed { + t.Fatalf("ConfirmCall = %+v forced=%v err=%v", confirmed, forced, err) + } + if !confirmed.P2PAllowed || confirmed.KeyFingerprint != 0x1234 || confirmed.StartDate == 0 { + t.Fatalf("confirmed snapshot = %+v", confirmed) + } + + clk.Advance(30 * time.Second) + discarded, already, err := s.DiscardCall(ctx, 2, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 30) + if err != nil || already || discarded.State != domain.PhoneCallStateDiscarded || discarded.Duration != 30 { + t.Fatalf("DiscardCall = %+v already=%v err=%v", discarded, already, err) + } + // 双方同时挂断:后到者幂等拿快照,reason 由先到者决定。 + again, already, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonBusy, 0) + if err != nil || !already || again.DiscardReason != domain.PhoneCallDiscardReasonHangup { + t.Fatalf("second DiscardCall = %+v already=%v err=%v", again, already, err) + } +} + +func TestPhoneCallStateErrors(t *testing.T) { + clk := newTestClock() + s := newTestService(clk) + ctx := context.Background() + ga, gaHash := testGA() + gb := testGB() + call := mustRequest(t, s, 1, 2, gaHash) + + // confirm 前置必须 Accepted。 + if _, _, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, ga, 1, testProtocol()); !errors.Is(err, ErrPeerInvalid) { + t.Fatalf("confirm before accept err = %v, want ErrPeerInvalid", err) + } + // 非被叫不能 accept / receivedCall。 + if _, err := s.AcceptCall(ctx, 1, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrPeerInvalid) { + t.Fatalf("accept by caller err = %v, want ErrPeerInvalid", err) + } + if _, _, err := s.ReceivedCall(ctx, 1, call.ID, call.AccessHash); !errors.Is(err, ErrPeerInvalid) { + t.Fatalf("receivedCall by caller err = %v, want ErrPeerInvalid", err) + } + // access_hash 不符。 + if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash+1, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrPeerInvalid) { + t.Fatalf("wrong access hash err = %v, want ErrPeerInvalid", err) + } + + if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); err != nil { + t.Fatalf("accept: %v", err) + } + if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrAlreadyAccepted) { + t.Fatalf("double accept err = %v, want ErrAlreadyAccepted", err) + } + if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil { + t.Fatalf("discard: %v", err) + } + if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, gb, testProtocol(), domain.SessionRef{}); !errors.Is(err, ErrAlreadyDeclined) { + t.Fatalf("accept after discard err = %v, want ErrAlreadyDeclined", err) + } +} + +func TestPhoneCallGAHashMismatchForcesDiscard(t *testing.T) { + clk := newTestClock() + s := newTestService(clk) + ctx := context.Background() + _, gaHash := testGA() + call := mustRequest(t, s, 1, 2, gaHash) + if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil { + t.Fatalf("accept: %v", err) + } + wrong := make([]byte, 256) + wrong[0] = 0x7 + snap, forced, err := s.ConfirmCall(ctx, 1, call.ID, call.AccessHash, wrong, 1, testProtocol()) + if !errors.Is(err, ErrGAHashMismatch) || !forced { + t.Fatalf("confirm with wrong ga: forced=%v err=%v", forced, err) + } + if snap.State != domain.PhoneCallStateDiscarded || snap.DiscardReason != domain.PhoneCallDiscardReasonDisconnect { + t.Fatalf("forced discard snapshot = %+v", snap) + } +} + +func TestPhoneCallConcurrentAcceptSingleWinner(t *testing.T) { + clk := newTestClock() + s := newTestService(clk) + ctx := context.Background() + _, gaHash := testGA() + call := mustRequest(t, s, 1, 2, gaHash) + + const devices = 8 + var wg sync.WaitGroup + wins := make(chan int64, devices) + losses := make(chan error, devices) + for i := 0; i < devices; i++ { + wg.Add(1) + go func(sessionID int64) { + defer wg.Done() + _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{SessionID: sessionID}) + if err == nil { + wins <- sessionID + } else { + losses <- err + } + }(int64(100 + i)) + } + wg.Wait() + close(wins) + close(losses) + if len(wins) != 1 { + t.Fatalf("winners = %d, want exactly 1", len(wins)) + } + for err := range losses { + if !errors.Is(err, ErrAlreadyAccepted) { + t.Fatalf("loser err = %v, want ErrAlreadyAccepted", err) + } + } + winner := <-wins + snap, ok := s.Lookup(ctx, call.ID, call.AccessHash) + if !ok || snap.CalleeDevice.SessionID != winner { + t.Fatalf("callee device = %+v, want session %d", snap.CalleeDevice, winner) + } +} + +func TestPhoneCallRandomIDIdempotent(t *testing.T) { + clk := newTestClock() + s := newTestService(clk) + ctx := context.Background() + _, gaHash := testGA() + req := domain.PhoneCallRequest{CalleeID: 2, RandomID: 777, GAHash: gaHash, Protocol: testProtocol()} + first, err := s.RequestCall(ctx, 1, req) + if err != nil { + t.Fatalf("request: %v", err) + } + second, err := s.RequestCall(ctx, 1, req) + if err != nil || second.ID != first.ID { + t.Fatalf("retry id = %d err=%v, want %d", second.ID, err, first.ID) + } + // 终结后同 random_id 重新可用(新通话)。 + if _, _, err := s.DiscardCall(ctx, 1, first.ID, first.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil { + t.Fatalf("discard: %v", err) + } + third, err := s.RequestCall(ctx, 1, req) + if err != nil || third.ID == first.ID { + t.Fatalf("post-discard request id = %d err=%v, want fresh call", third.ID, err) + } +} + +func TestPhoneCallQuotaAndSweep(t *testing.T) { + clk := newTestClock() + s := newTestService(clk, func(c *Config) { c.MaxActivePerUser = 2 }) + ctx := context.Background() + _, gaHash := testGA() + + for i := int64(0); i < 2; i++ { + if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 10 + i, RandomID: i, GAHash: gaHash, Protocol: testProtocol()}); err != nil { + t.Fatalf("request %d: %v", i, err) + } + } + if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); !errors.Is(err, ErrOccupyFailed) { + t.Fatalf("over quota err = %v, want ErrOccupyFailed", err) + } + // 双端崩溃兜底:超过 2×RingTimeout 的僵尸通话被纯年龄 GC 回收,配额释放。 + clk.Advance(181 * time.Second) + if _, err := s.RequestCall(ctx, 1, domain.PhoneCallRequest{CalleeID: 99, RandomID: 99, GAHash: gaHash, Protocol: testProtocol()}); err != nil { + t.Fatalf("request after sweep: %v", err) + } +} + +func TestPhoneCallTombstoneGC(t *testing.T) { + clk := newTestClock() + s := newTestService(clk) + ctx := context.Background() + _, gaHash := testGA() + call := mustRequest(t, s, 1, 2, gaHash) + if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil { + t.Fatalf("discard: %v", err) + } + if _, ok := s.Lookup(ctx, call.ID, call.AccessHash); !ok { + t.Fatalf("tombstone should be visible before TTL") + } + clk.Advance(61 * time.Second) + mustRequest(t, s, 3, 4, gaHash) // 触发 sweep + if _, ok := s.Lookup(ctx, call.ID, call.AccessHash); ok { + t.Fatalf("tombstone should be collected after TTL") + } +} + +func TestPhoneCallDurationOnlyWhenConfirmed(t *testing.T) { + clk := newTestClock() + s := newTestService(clk) + ctx := context.Background() + _, gaHash := testGA() + call := mustRequest(t, s, 1, 2, gaHash) + snap, _, err := s.DiscardCall(ctx, 2, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonBusy, 55) + if err != nil || snap.Duration != 0 { + t.Fatalf("unconfirmed discard duration = %d err=%v, want 0", snap.Duration, err) + } +} + +func TestPhoneCallSignal(t *testing.T) { + clk := newTestClock() + s := newTestService(clk, func(c *Config) { c.SignalingRatePerSecond = 2 }) + ctx := context.Background() + _, gaHash := testGA() + call := mustRequest(t, s, 1, 2, gaHash) + + // Accepted 前不可转发。 + if _, err := s.Signal(ctx, 1, call.ID, call.AccessHash, func(int64, domain.SessionRef) {}); !errors.Is(err, ErrPeerInvalid) { + t.Fatalf("signal before accept err = %v, want ErrPeerInvalid", err) + } + if _, err := s.AcceptCall(ctx, 2, call.ID, call.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil { + t.Fatalf("accept: %v", err) + } + var forwarded []int64 + forward := func(peer int64, _ domain.SessionRef) { forwarded = append(forwarded, peer) } + for i := 0; i < 3; i++ { + if _, err := s.Signal(ctx, 1, call.ID, call.AccessHash, forward); err != nil { + t.Fatalf("signal %d: %v", i, err) + } + } + // 限速 2/s:第三条被静默丢弃。 + if len(forwarded) != 2 || forwarded[0] != 2 || forwarded[1] != 2 { + t.Fatalf("forwarded = %v, want [2 2]", forwarded) + } + clk.Advance(time.Second) + if drop, err := s.Signal(ctx, 2, call.ID, call.AccessHash, forward); err != nil || drop { + t.Fatalf("signal new window drop=%v err=%v", drop, err) + } + if forwarded[len(forwarded)-1] != 1 { + t.Fatalf("callee→caller forward peer = %d, want 1", forwarded[len(forwarded)-1]) + } + // 终态尾包静默吞掉。 + if _, _, err := s.DiscardCall(ctx, 1, call.ID, call.AccessHash, domain.PhoneCallDiscardReasonHangup, 0); err != nil { + t.Fatalf("discard: %v", err) + } + if drop, err := s.Signal(ctx, 1, call.ID, call.AccessHash, forward); err != nil || !drop { + t.Fatalf("signal after discard drop=%v err=%v, want drop", drop, err) + } +} + +func TestNegotiateProtocol(t *testing.T) { + base := func(min, max int, versions ...string) domain.PhoneCallProtocol { + return domain.PhoneCallProtocol{UDPP2P: true, UDPReflector: true, MinLayer: min, MaxLayer: max, LibraryVersions: versions} + } + t.Run("layer intersection", func(t *testing.T) { + out, err := negotiateProtocol(base(65, 92, "9.0.0"), base(70, 110, "9.0.0")) + if err != nil || out.MinLayer != 70 || out.MaxLayer != 92 { + t.Fatalf("negotiated = %+v err=%v", out, err) + } + }) + t.Run("layer disjoint", func(t *testing.T) { + if _, err := negotiateProtocol(base(65, 70, "9.0.0"), base(80, 92, "9.0.0")); !errors.Is(err, ErrProtocolCompatLayerInvalid) { + t.Fatalf("err = %v, want ErrProtocolCompatLayerInvalid", err) + } + }) + t.Run("best common version is semver max", func(t *testing.T) { + out, err := negotiateProtocol(base(65, 92, "11.0.0", "9.0.0", "2.4.4"), base(65, 92, "2.4.4", "9.0.0")) + if err != nil || len(out.LibraryVersions) != 1 || out.LibraryVersions[0] != "9.0.0" { + t.Fatalf("versions = %v err=%v, want [9.0.0]", out.LibraryVersions, err) + } + }) + t.Run("preferred version beats semver max", func(t *testing.T) { + // ⚠ "9.0.0" 优先于更高版本:DrKLO 视频 gate 是字符串字典序比较 + //("1x.0.0" < "2.7.7" 会判不支持视频),且 12/13 走 V3 SCTP 信令。 + out, err := negotiateProtocol(base(65, 92, "13.0.0", "10.0.0", "9.0.0"), base(65, 92, "9.0.0", "10.0.0", "13.0.0")) + if err != nil || out.LibraryVersions[0] != "9.0.0" { + t.Fatalf("versions = %v err=%v, want preferred [9.0.0]", out.LibraryVersions, err) + } + }) + t.Run("numeric compare fallback not lexicographic", func(t *testing.T) { + // 交集无 preferred 版本时退化为语义化最高(数值比较,非字典序)。 + out, err := negotiateProtocol(base(65, 92, "10.0.0", "11.0.0"), base(65, 92, "11.0.0", "10.0.0")) + if err != nil || out.LibraryVersions[0] != "11.0.0" { + t.Fatalf("versions = %v err=%v, want [11.0.0]", out.LibraryVersions, err) + } + }) + t.Run("no common versions passes callee list through", func(t *testing.T) { + // ⚠ P1-3:版本无交集绝不拒绝通话,透传被叫列表。 + out, err := negotiateProtocol(base(65, 92, "11.0.0"), base(65, 92, "2.4.4", "3.0.0")) + if err != nil || len(out.LibraryVersions) != 2 || out.LibraryVersions[0] != "2.4.4" { + t.Fatalf("versions = %v err=%v, want callee passthrough", out.LibraryVersions, err) + } + }) +} + +func TestValidateProtocol(t *testing.T) { + cases := []struct { + name string + p domain.PhoneCallProtocol + want error + }{ + {"min over max", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 93, MaxLayer: 92, LibraryVersions: []string{"9.0.0"}}, ErrProtocolLayerInvalid}, + {"max below 65", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 60, MaxLayer: 64, LibraryVersions: []string{"9.0.0"}}, ErrProtocolCompatLayerInvalid}, + {"no transport flags", domain.PhoneCallProtocol{MinLayer: 65, MaxLayer: 92, LibraryVersions: []string{"9.0.0"}}, ErrProtocolFlagsInvalid}, + {"no versions", domain.PhoneCallProtocol{UDPP2P: true, MinLayer: 65, MaxLayer: 92}, ErrProtocolFlagsInvalid}, + {"ok", testProtocol(), nil}, + } + for _, tc := range cases { + if err := validateProtocol(tc.p); !errors.Is(err, tc.want) { + t.Fatalf("%s: err = %v, want %v", tc.name, err, tc.want) + } + } +} + +func TestPhoneCallExpireDue(t *testing.T) { + clk := newTestClock() + s := newTestService(clk) + ctx := context.Background() + ga, gaHash := testGA() + + // 三通通话:振铃中(→missed)、Accepted 悬挂(→disconnect)、Confirmed(不回收)。 + ringingCall := mustRequest(t, s, 1, 2, gaHash) + acceptedCall, err := s.RequestCall(ctx, 3, domain.PhoneCallRequest{CalleeID: 4, RandomID: 1, GAHash: gaHash, Protocol: testProtocol()}) + if err != nil { + t.Fatalf("request accepted call: %v", err) + } + if _, err := s.AcceptCall(ctx, 4, acceptedCall.ID, acceptedCall.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil { + t.Fatalf("accept: %v", err) + } + confirmedCall, err := s.RequestCall(ctx, 5, domain.PhoneCallRequest{CalleeID: 6, RandomID: 2, GAHash: gaHash, Protocol: testProtocol()}) + if err != nil { + t.Fatalf("request confirmed call: %v", err) + } + if _, err := s.AcceptCall(ctx, 6, confirmedCall.ID, confirmedCall.AccessHash, testGB(), testProtocol(), domain.SessionRef{}); err != nil { + t.Fatalf("accept confirmed: %v", err) + } + if _, _, err := s.ConfirmCall(ctx, 5, confirmedCall.ID, confirmedCall.AccessHash, ga, 1, testProtocol()); err != nil { + t.Fatalf("confirm: %v", err) + } + + if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 { + t.Fatalf("nothing should expire yet, got %d", len(got)) + } + clk.Advance(91 * time.Second) + expired := s.ExpireDue(ctx, clk.Now()) + if len(expired) != 2 { + t.Fatalf("expired = %d, want 2 (ringing+accepted)", len(expired)) + } + reasons := map[int64]domain.PhoneCallDiscardReason{} + for _, c := range expired { + reasons[c.ID] = c.DiscardReason + } + if reasons[ringingCall.ID] != domain.PhoneCallDiscardReasonMissed { + t.Fatalf("ringing call reason = %s, want missed", reasons[ringingCall.ID]) + } + if reasons[acceptedCall.ID] != domain.PhoneCallDiscardReasonDisconnect { + t.Fatalf("accepted call reason = %s, want disconnect", reasons[acceptedCall.ID]) + } + // Confirmed 通话不受服务端时长限制。 + if snap, ok := s.Lookup(ctx, confirmedCall.ID, confirmedCall.AccessHash); !ok || snap.State != domain.PhoneCallStateConfirmed { + t.Fatalf("confirmed call = %+v ok=%v, want untouched", snap, ok) + } + // 幂等:再跑一轮无新增。 + if got := s.ExpireDue(ctx, clk.Now()); len(got) != 0 { + t.Fatalf("second ExpireDue = %d, want 0", len(got)) + } +} diff --git a/internal/app/polls/service.go b/internal/app/polls/service.go new file mode 100644 index 00000000..25b7a31e --- /dev/null +++ b/internal/app/polls/service.go @@ -0,0 +1,49 @@ +package polls + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 提供 poll 权威态的发送时创建与投票人列表查询。 +// 投票/关闭走 messages/channels 服务(需要消息可见性),不经本服务。 +type Service struct { + polls store.PollStore +} + +// NewService 创建 poll 服务。 +func NewService(polls store.PollStore) *Service { + return &Service{polls: polls} +} + +// CreatePoll 在消息发送前落 poll 权威行(消息发送失败产生的孤儿行无害)。 +func (s *Service) CreatePoll(ctx context.Context, def domain.PollDefinition) error { + if s == nil || s.polls == nil { + return domain.ErrPollInvalid + } + if def.ID == 0 || def.CreatorUserID == 0 || len(def.Options) < domain.MinPollAnswers || len(def.Options) > domain.MaxPollAnswers { + return domain.ErrPollInvalid + } + return s.polls.CreatePoll(ctx, def) +} + +// GetPollDefinition 返回权威定义(getPollVotes 的 public_voters/broadcast 校验用)。 +func (s *Service) GetPollDefinition(ctx context.Context, pollID int64) (domain.PollDefinition, bool, error) { + if s == nil || s.polls == nil || pollID == 0 { + return domain.PollDefinition{}, false, nil + } + return s.polls.GetPollDefinition(ctx, pollID) +} + +// ListPollVotes 分页列出投票人(仅 public_voters poll 由 rpc 层放行)。 +func (s *Service) ListPollVotes(ctx context.Context, req domain.PollVotesListRequest) (domain.PollVotesList, error) { + if s == nil || s.polls == nil { + return domain.PollVotesList{}, domain.ErrPollNotFound + } + if req.PollID == 0 || req.Limit <= 0 || req.Limit > domain.MaxPollVotesPageLimit { + return domain.PollVotesList{}, domain.ErrPollInvalid + } + return s.polls.ListPollVotes(ctx, req) +} diff --git a/internal/app/privacy/cache.go b/internal/app/privacy/cache.go new file mode 100644 index 00000000..e94d16e7 --- /dev/null +++ b/internal/app/privacy/cache.go @@ -0,0 +1,209 @@ +package privacy + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" + "telesrv/internal/store" +) + +const ( + DefaultPrivacyRulesCacheTTL = 24 * time.Hour + + privacySnapshotMaxOwners = 8192 +) + +var allPrivacyRuleKeys = []domain.PrivacyKey{ + domain.PrivacyKeyStatusTimestamp, + domain.PrivacyKeyChatInvite, + domain.PrivacyKeyPhoneCall, + domain.PrivacyKeyPhoneP2P, + domain.PrivacyKeyForwards, + domain.PrivacyKeyProfilePhoto, + domain.PrivacyKeyPhoneNumber, + domain.PrivacyKeyAddedByPhone, + domain.PrivacyKeyVoiceMessages, + domain.PrivacyKeyAbout, + domain.PrivacyKeyBirthday, + domain.PrivacyKeyStarGiftsAutoSave, + domain.PrivacyKeyNoPaidMessages, + domain.PrivacyKeySavedMusic, +} + +// privacyRulesMap 是单个 owner 的全部隐私规则(空 map = 查过且无规则,即负缓存)。 +type privacyRulesMap map[domain.PrivacyKey]domain.PrivacyRules + +// CachedPrivacyStore 是 account privacy rules 的 owner 级 read-model 缓存,由统一缓存原语承载 +// (LRU 单条驱逐 / epoch 守卫 / clone)。owner 级、变更稀少:一次性装入某 owner 全部 key,让 +// projectBatch/CanSeeMatrix 在内存里判 phone/status/photo 可见性,免去反复规划 account_privacy_rules。 +// 单 owner 走 GetOrLoad,多 owner 走 GetOrLoadBatch(一次 LoadEpoch + 合批 ListPrivacyRules + 写回)。 +type CachedPrivacyStore struct { + inner store.PrivacyStore + cache *readmodelcache.Cache[int64, privacyRulesMap] +} + +func NewCachedPrivacyStore(inner store.PrivacyStore, ttl time.Duration) *CachedPrivacyStore { + if inner == nil { + return nil + } + if ttl <= 0 { + ttl = DefaultPrivacyRulesCacheTTL + } + return &CachedPrivacyStore{ + inner: inner, + cache: readmodelcache.New[int64, privacyRulesMap](readmodelcache.Config[int64, privacyRulesMap]{ + MaxEntries: privacySnapshotMaxOwners, + TTL: ttl, + Clone: clonePrivacyRulesMap, + }), + } +} + +func (c *CachedPrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) { + if ownerUserID == 0 { + return domain.PrivacyRules{}, false, nil + } + rules, err := c.ownerRules(ctx, ownerUserID) + if err != nil { + return domain.PrivacyRules{}, false, err + } + r, ok := rules[key] + if !ok { + return domain.PrivacyRules{}, false, nil + } + return cloneRules(r), true, nil +} + +func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error { + if err := c.inner.SetPrivacyRules(ctx, rules); err != nil { + return err + } + c.InvalidateOwners(rules.OwnerUserID) + return nil +} + +func (c *CachedPrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) { + owners := dedupPrivacyOwnerIDs(ownerUserIDs) + if len(owners) == 0 || len(keys) == 0 { + return nil, nil + } + byOwner, err := c.ownerRulesBatch(ctx, owners) + if err != nil { + return nil, err + } + keySet := make(map[domain.PrivacyKey]struct{}, len(keys)) + for _, key := range keys { + keySet[key] = struct{}{} + } + out := make([]domain.PrivacyRules, 0, len(owners)*len(keys)) + for _, owner := range owners { + for key, rules := range byOwner[owner] { + if _, want := keySet[key]; !want { + continue + } + out = append(out, cloneRules(rules)) + } + } + return out, nil +} + +func (c *CachedPrivacyStore) ownerRules(ctx context.Context, ownerUserID int64) (privacyRulesMap, error) { + load := func() (privacyRulesMap, error) { + list, err := c.inner.ListPrivacyRules(ctx, []int64{ownerUserID}, allPrivacyRuleKeys) + if err != nil { + return nil, err + } + return buildPrivacyRulesByOwner(list, []int64{ownerUserID})[ownerUserID], nil + } + if c == nil || c.cache == nil { + return load() + } + return c.cache.GetOrLoad(ctx, ownerUserID, load) +} + +func (c *CachedPrivacyStore) ownerRulesBatch(ctx context.Context, owners []int64) (map[int64]privacyRulesMap, error) { + loadMissing := func(ctx context.Context, missing []int64) (map[int64]privacyRulesMap, error) { + list, err := c.inner.ListPrivacyRules(ctx, missing, allPrivacyRuleKeys) + if err != nil { + return nil, err + } + return buildPrivacyRulesByOwner(list, missing), nil + } + if c == nil || c.cache == nil { + return loadMissing(ctx, owners) + } + return c.cache.GetOrLoadBatch(ctx, owners, + func(int64) (int64, bool) { return 0, true }, // 纯 TTL,无版本闸门 + loadMissing, + ) +} + +func (c *CachedPrivacyStore) InvalidateOwners(ids ...int64) { + if c == nil || c.cache == nil || len(ids) == 0 { + return + } + nonZero := make([]int64, 0, len(ids)) + for _, id := range ids { + if id != 0 { + nonZero = append(nonZero, id) + } + } + c.cache.Invalidate(nonZero...) +} + +func (c *CachedPrivacyStore) FlushReadModelCache() { + if c == nil || c.cache == nil { + return + } + c.cache.Flush() +} + +// buildPrivacyRulesByOwner 把扁平规则按 owner 归组;每个 owner 都建一个条目(无规则即空 map), +// 这样「查过且无规则」的 owner 也被负缓存,不会反复打后端。 +func buildPrivacyRulesByOwner(list []domain.PrivacyRules, owners []int64) map[int64]privacyRulesMap { + out := make(map[int64]privacyRulesMap, len(owners)) + for _, owner := range owners { + out[owner] = make(privacyRulesMap) + } + for _, item := range list { + if item.OwnerUserID == 0 || item.Key == "" { + continue + } + m, ok := out[item.OwnerUserID] + if !ok { + m = make(privacyRulesMap) + out[item.OwnerUserID] = m + } + m[item.Key] = cloneRules(item) + } + return out +} + +func clonePrivacyRulesMap(in privacyRulesMap) privacyRulesMap { + if in == nil { + return nil + } + out := make(privacyRulesMap, len(in)) + for key, rules := range in { + out[key] = cloneRules(rules) + } + return out +} + +func dedupPrivacyOwnerIDs(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} diff --git a/internal/app/privacy/cache_test.go b/internal/app/privacy/cache_test.go new file mode 100644 index 00000000..c0851fcf --- /dev/null +++ b/internal/app/privacy/cache_test.go @@ -0,0 +1,355 @@ +package privacy + +import ( + "context" + "sync" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +type blockingFirstPrivacyStore struct { + store.PrivacyStore + started chan struct{} + release chan struct{} + first []domain.PrivacyRules + + mu sync.Mutex + firstUsed bool +} + +func (s *blockingFirstPrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) { + s.mu.Lock() + if !s.firstUsed { + s.firstUsed = true + s.mu.Unlock() + close(s.started) + select { + case <-s.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + out := make([]domain.PrivacyRules, len(s.first)) + for i := range s.first { + out[i] = cloneRules(s.first[i]) + } + return out, nil + } + s.mu.Unlock() + return s.PrivacyStore.ListPrivacyRules(ctx, ownerUserIDs, keys) +} + +func waitForPrivacyCacheTestSignal(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for cache test signal") + } +} + +type countingPrivacyStore struct { + store.PrivacyStore + getCalls int + listCalls int + setCalls int +} + +func (s *countingPrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) { + s.getCalls++ + return s.PrivacyStore.GetPrivacyRules(ctx, ownerUserID, key) +} + +func (s *countingPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error { + s.setCalls++ + return s.PrivacyStore.SetPrivacyRules(ctx, rules) +} + +func (s *countingPrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) { + s.listCalls++ + return s.PrivacyStore.ListPrivacyRules(ctx, ownerUserIDs, keys) +} + +func TestCachedPrivacyStoreUsesOwnerSnapshot(t *testing.T) { + ctx := context.Background() + base := memory.NewPrivacyStore() + if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyPhoneNumber, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}, + }); err != nil { + t.Fatalf("seed privacy: %v", err) + } + counting := &countingPrivacyStore{PrivacyStore: base} + cached := NewCachedPrivacyStore(counting, 0) + + first, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber) + if err != nil || !ok { + t.Fatalf("first get ok=%v err=%v", ok, err) + } + if first.Rules[0].Kind != domain.PrivacyRuleDisallowAll { + t.Fatalf("first rules = %+v, want disallow all", first.Rules) + } + second, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber) + if err != nil || !ok { + t.Fatalf("second get ok=%v err=%v", ok, err) + } + if second.Rules[0].Kind != domain.PrivacyRuleDisallowAll { + t.Fatalf("second rules = %+v, want disallow all", second.Rules) + } + if counting.getCalls != 0 { + t.Fatalf("GetPrivacyRules calls = %d, want 0", counting.getCalls) + } + if counting.listCalls != 1 { + t.Fatalf("ListPrivacyRules calls = %d, want 1 owner snapshot load", counting.listCalls) + } +} + +func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) { + ctx := context.Background() + base := memory.NewPrivacyStore() + counting := &countingPrivacyStore{PrivacyStore: base} + cached := NewCachedPrivacyStore(counting, 0) + if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyPhoneNumber, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}, + }); err != nil { + t.Fatalf("set first: %v", err) + } + if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok { + t.Fatalf("prime get ok=%v err=%v", ok, err) + } + if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyPhoneNumber, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}, + }); err != nil { + t.Fatalf("set second: %v", err) + } + got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber) + if err != nil || !ok { + t.Fatalf("after invalidation get ok=%v err=%v", ok, err) + } + if got.Rules[0].Kind != domain.PrivacyRuleAllowAll { + t.Fatalf("rules after invalidation = %+v, want allow all", got.Rules) + } + if counting.listCalls != 2 { + t.Fatalf("ListPrivacyRules calls = %d, want 2 after invalidation", counting.listCalls) + } +} + +func TestCachedPrivacyStoreExternalInvalidationAndFlush(t *testing.T) { + ctx := context.Background() + base := memory.NewPrivacyStore() + if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyPhoneNumber, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}, + }); err != nil { + t.Fatalf("seed privacy: %v", err) + } + counting := &countingPrivacyStore{PrivacyStore: base} + cached := NewCachedPrivacyStore(counting, 0) + + if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok { + t.Fatalf("prime get ok=%v err=%v", ok, err) + } + if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyPhoneNumber, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}, + }); err != nil { + t.Fatalf("direct set: %v", err) + } + cached.InvalidateOwners(1001) + got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber) + if err != nil || !ok { + t.Fatalf("after external invalidation ok=%v err=%v", ok, err) + } + if got.Rules[0].Kind != domain.PrivacyRuleAllowAll { + t.Fatalf("after invalidation = %+v, want allow all", got.Rules) + } + + if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyPhoneNumber, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}, + }); err != nil { + t.Fatalf("direct set 2: %v", err) + } + cached.FlushReadModelCache() + got, ok, err = cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber) + if err != nil || !ok { + t.Fatalf("after flush ok=%v err=%v", ok, err) + } + if got.Rules[0].Kind != domain.PrivacyRuleDisallowAll { + t.Fatalf("after flush = %+v, want disallow all", got.Rules) + } + if counting.listCalls != 3 { + t.Fatalf("ListPrivacyRules calls = %d, want 3 after prime+invalidate+flush", counting.listCalls) + } +} + +func TestCachedPrivacyStoreDoesNotRefillStaleSnapshotAfterInvalidation(t *testing.T) { + ctx := context.Background() + base := memory.NewPrivacyStore() + if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyPhoneNumber, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}, + }); err != nil { + t.Fatalf("seed privacy: %v", err) + } + first, err := base.ListPrivacyRules(ctx, []int64{1001}, allPrivacyRuleKeys) + if err != nil { + t.Fatalf("snapshot first privacy rules: %v", err) + } + blocking := &blockingFirstPrivacyStore{ + PrivacyStore: base, + started: make(chan struct{}), + release: make(chan struct{}), + first: first, + } + cached := NewCachedPrivacyStore(blocking, 0) + + type readResult struct { + rules domain.PrivacyRules + ok bool + err error + } + resultCh := make(chan readResult, 1) + go func() { + rules, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber) + resultCh <- readResult{rules: rules, ok: ok, err: err} + }() + waitForPrivacyCacheTestSignal(t, blocking.started) + + if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyPhoneNumber, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}, + }); err != nil { + t.Fatalf("update privacy while first load is blocked: %v", err) + } + cached.InvalidateOwners(1001) + close(blocking.release) + + var result readResult + select { + case result = <-resultCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for privacy read") + } + if result.err != nil || !result.ok { + t.Fatalf("privacy read ok=%v err=%v", result.ok, result.err) + } + if result.rules.Rules[0].Kind != domain.PrivacyRuleAllowAll { + t.Fatalf("privacy after concurrent invalidation = %+v, want allow all", result.rules.Rules) + } + + cachedHit, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber) + if err != nil || !ok { + t.Fatalf("cached hit after stale load retry ok=%v err=%v", ok, err) + } + if cachedHit.Rules[0].Kind != domain.PrivacyRuleAllowAll { + t.Fatalf("cached privacy after stale load retry = %+v, want allow all", cachedHit.Rules) + } +} + +func TestCachedPrivacyStoreDoesNotRefillStaleBatchAfterInvalidation(t *testing.T) { + ctx := context.Background() + base := memory.NewPrivacyStore() + if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyProfilePhoto, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}, + }); err != nil { + t.Fatalf("seed privacy: %v", err) + } + first, err := base.ListPrivacyRules(ctx, []int64{1001}, allPrivacyRuleKeys) + if err != nil { + t.Fatalf("snapshot first privacy rules: %v", err) + } + blocking := &blockingFirstPrivacyStore{ + PrivacyStore: base, + started: make(chan struct{}), + release: make(chan struct{}), + first: first, + } + cached := NewCachedPrivacyStore(blocking, 0) + + type readResult struct { + rules []domain.PrivacyRules + err error + } + resultCh := make(chan readResult, 1) + go func() { + rules, err := cached.ListPrivacyRules(ctx, []int64{1001}, []domain.PrivacyKey{domain.PrivacyKeyProfilePhoto}) + resultCh <- readResult{rules: rules, err: err} + }() + waitForPrivacyCacheTestSignal(t, blocking.started) + + if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyProfilePhoto, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}, + }); err != nil { + t.Fatalf("update privacy while first batch load is blocked: %v", err) + } + cached.InvalidateOwners(1001) + close(blocking.release) + + var result readResult + select { + case result = <-resultCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for privacy batch read") + } + if result.err != nil { + t.Fatalf("privacy batch read: %v", result.err) + } + if len(result.rules) != 1 || result.rules[0].Rules[0].Kind != domain.PrivacyRuleAllowAll { + t.Fatalf("privacy batch after concurrent invalidation = %+v, want allow all", result.rules) + } +} + +func TestCachedPrivacyStoreListUsesBatchOwnerSnapshots(t *testing.T) { + ctx := context.Background() + base := memory.NewPrivacyStore() + if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1001, + Key: domain.PrivacyKeyPhoneNumber, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}, + }); err != nil { + t.Fatalf("seed 1001: %v", err) + } + if err := base.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: 1002, + Key: domain.PrivacyKeyProfilePhoto, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}, + }); err != nil { + t.Fatalf("seed 1002: %v", err) + } + counting := &countingPrivacyStore{PrivacyStore: base} + cached := NewCachedPrivacyStore(counting, 0) + + keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyProfilePhoto} + first, err := cached.ListPrivacyRules(ctx, []int64{1001, 1002}, keys) + if err != nil { + t.Fatalf("list first: %v", err) + } + second, err := cached.ListPrivacyRules(ctx, []int64{1001, 1002}, keys) + if err != nil { + t.Fatalf("list second: %v", err) + } + if len(first) != 2 || len(second) != 2 { + t.Fatalf("list sizes = %d/%d, want 2/2", len(first), len(second)) + } + if counting.listCalls != 1 { + t.Fatalf("ListPrivacyRules calls = %d, want 1 batch load", counting.listCalls) + } +} diff --git a/internal/app/privacy/service.go b/internal/app/privacy/service.go index a57e62c9..c920e6e6 100644 --- a/internal/app/privacy/service.go +++ b/internal/app/privacy/service.go @@ -110,6 +110,185 @@ func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, k return Evaluate(rules, evalCtx), nil } +// CanSeeBatch 批量评估多个 owner 对同一 viewer 在多个 key 上的可见性,结果等价于对每个 +// (owner,key) 调一次 CanSee,但只用一次 ListPrivacyRules + 一次 GetReverseContacts + 内存 +// Evaluate(消除 projectBatch / fan-out 投影里 per-user 3×CanSee×2行 的 N+1)。返回 +// map[ownerUserID]map[key]bool;owner==viewer 恒 true(与 CanSee 一致)。 +func (s *Service) CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64, keys []domain.PrivacyKey) (map[int64]map[domain.PrivacyKey]bool, error) { + out := make(map[int64]map[domain.PrivacyKey]bool, len(ownerUserIDs)) + if viewerUserID == 0 || len(ownerUserIDs) == 0 || len(keys) == 0 { + return out, nil + } + for _, k := range keys { + if !ValidKey(k) { + return nil, domain.ErrPrivacyKeyInvalid + } + } + owners := make([]int64, 0, len(ownerUserIDs)) + seen := make(map[int64]struct{}, len(ownerUserIDs)) + for _, id := range ownerUserIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + if id == viewerUserID { + // 自己恒可见全部 key(与 CanSee 的 ownerUserID==viewerUserID 分支一致)。 + m := make(map[domain.PrivacyKey]bool, len(keys)) + for _, k := range keys { + m[k] = true + } + out[id] = m + continue + } + owners = append(owners, id) + } + if len(owners) == 0 { + return out, nil + } + // 批量取 rules:存在的行进 map,缺失的 (owner,key) 用 defaultRules(复刻 GetRules 兜底)。 + rulesByOwner := make(map[int64]map[domain.PrivacyKey]domain.PrivacyRules, len(owners)) + if s != nil && s.rules != nil { + list, err := s.rules.ListPrivacyRules(ctx, owners, keys) + if err != nil { + return nil, err + } + for _, r := range list { + if !ValidKey(r.Key) { + continue + } + if len(r.Rules) == 0 { + r.Rules = domain.DefaultPrivacyRules(r.Key) + } + if rulesByOwner[r.OwnerUserID] == nil { + rulesByOwner[r.OwnerUserID] = make(map[domain.PrivacyKey]domain.PrivacyRules, len(keys)) + } + rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r) + } + } + // 批量取「viewer 是否在 owner 的联系人里」(owner→viewer 方向,对应 CanSee 的 + // contacts.Get(owner, viewer))。 + var reverse map[int64]domain.Contact + if s != nil && s.contacts != nil { + var err error + reverse, err = s.contacts.GetReverseContacts(ctx, viewerUserID, owners) + if err != nil { + return nil, err + } + } + for _, owner := range owners { + _, isContact := reverse[owner] + m := make(map[domain.PrivacyKey]bool, len(keys)) + for _, k := range keys { + rules, ok := rulesByOwner[owner][k] + if !ok { + rules = defaultRules(owner, k) + } + m[k] = Evaluate(rules, domain.PrivacyContext{ + OwnerUserID: owner, + ViewerUserID: viewerUserID, + ViewerIsContact: isContact, + }) + } + out[owner] = m + } + return out, nil +} + +// CanSeeMatrix 批量评估 owners × viewers × keys 的可见性矩阵,结果等价于逐 (owner,viewer,key) +// 调 CanSee,但只用一次 ListPrivacyRules + 每 owner 一次 GetMany(owner,viewers) + 内存 Evaluate +// (把 fan-out 投影从 O(viewer) 次 privacy 查询降到 O(owner))。返回 map[owner]map[viewer]map[key]bool。 +func (s *Service) CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs []int64, keys []domain.PrivacyKey) (map[int64]map[int64]map[domain.PrivacyKey]bool, error) { + out := make(map[int64]map[int64]map[domain.PrivacyKey]bool, len(ownerUserIDs)) + if len(ownerUserIDs) == 0 || len(viewerUserIDs) == 0 || len(keys) == 0 { + return out, nil + } + for _, k := range keys { + if !ValidKey(k) { + return nil, domain.ErrPrivacyKeyInvalid + } + } + owners := dedupNonZero(ownerUserIDs) + viewers := dedupNonZero(viewerUserIDs) + if len(owners) == 0 || len(viewers) == 0 { + return out, nil + } + rulesByOwner := make(map[int64]map[domain.PrivacyKey]domain.PrivacyRules, len(owners)) + if s != nil && s.rules != nil { + list, err := s.rules.ListPrivacyRules(ctx, owners, keys) + if err != nil { + return nil, err + } + for _, r := range list { + if !ValidKey(r.Key) { + continue + } + if len(r.Rules) == 0 { + r.Rules = domain.DefaultPrivacyRules(r.Key) + } + if rulesByOwner[r.OwnerUserID] == nil { + rulesByOwner[r.OwnerUserID] = make(map[domain.PrivacyKey]domain.PrivacyRules, len(keys)) + } + rulesByOwner[r.OwnerUserID][r.Key] = cloneRules(r) + } + } + for _, owner := range owners { + // owner 的联系人中哪些是本批 viewer(= privacy 的 ViewerIsContact,对应 contacts.Get(owner,viewer))。 + var ownerContacts map[int64]domain.Contact + if s != nil && s.contacts != nil { + var err error + ownerContacts, err = s.contacts.GetMany(ctx, owner, viewers) + if err != nil { + return nil, err + } + } + perViewer := make(map[int64]map[domain.PrivacyKey]bool, len(viewers)) + for _, viewer := range viewers { + m := make(map[domain.PrivacyKey]bool, len(keys)) + if owner == viewer { + for _, k := range keys { + m[k] = true + } + perViewer[viewer] = m + continue + } + _, isContact := ownerContacts[viewer] + for _, k := range keys { + rules, ok := rulesByOwner[owner][k] + if !ok { + rules = defaultRules(owner, k) + } + m[k] = Evaluate(rules, domain.PrivacyContext{ + OwnerUserID: owner, + ViewerUserID: viewer, + ViewerIsContact: isContact, + }) + } + perViewer[viewer] = m + } + out[owner] = perViewer + } + return out, nil +} + +func dedupNonZero(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + func Evaluate(rules domain.PrivacyRules, ctx domain.PrivacyContext) bool { if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID { return true diff --git a/internal/app/privacy/service_test.go b/internal/app/privacy/service_test.go index aaef5629..3bb5731c 100644 --- a/internal/app/privacy/service_test.go +++ b/internal/app/privacy/service_test.go @@ -73,3 +73,96 @@ func TestExplicitDisallowUserWins(t *testing.T) { t.Fatal("explicit disallow user should win over allow all") } } + +// TestCanSeeBatchEquivalentToCanSee 锁定批量 privacy 评估与逐 CanSee 字节等价(projectBatch +// fan-out N+1 优化的正确性前提):覆盖默认规则/allow-all/disallow-all/allow-contacts(含联系人)/self。 +func TestCanSeeBatchEquivalentToCanSee(t *testing.T) { + ctx := context.Background() + contacts := memory.NewContactStore() + svc := NewService(memory.NewPrivacyStore(), contacts) + const viewer = int64(1002) + owners := []int64{1001, 1003, 1004, 1005, viewer} + + if _, err := svc.SetRules(ctx, 1003, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}); err != nil { + t.Fatalf("set 1003 phone: %v", err) + } + if _, err := svc.SetRules(ctx, 1004, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil { + t.Fatalf("set 1004 status: %v", err) + } + if _, err := svc.SetRules(ctx, 1005, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil { + t.Fatalf("set 1005 phone: %v", err) + } + // owner 1005 把 viewer 加为联系人(GetReverseContacts(viewer,[1005]) 命中 → allow-contacts 可见)。 + if _, err := contacts.Upsert(ctx, 1005, domain.ContactInput{ContactUserID: viewer}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + + keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyStatusTimestamp, domain.PrivacyKeyProfilePhoto} + batch, err := svc.CanSeeBatch(ctx, owners, viewer, keys) + if err != nil { + t.Fatalf("CanSeeBatch: %v", err) + } + for _, owner := range owners { + for _, k := range keys { + want, err := svc.CanSee(ctx, owner, viewer, k) + if err != nil { + t.Fatalf("CanSee(%d,%d,%v): %v", owner, viewer, k, err) + } + got, ok := batch[owner][k] + if !ok { + t.Fatalf("CanSeeBatch missing owner=%d key=%v", owner, k) + } + if got != want { + t.Fatalf("CanSeeBatch[%d][%v]=%v != CanSee=%v (must be equivalent)", owner, k, got, want) + } + } + } +} + +// TestCanSeeMatrixEquivalentToCanSee 锁定 owners×viewers×keys 矩阵评估与逐 CanSee 字节等价 +// (ForViewers fan-out 模板化把 privacy 查询降到 O(owner) 的正确性前提)。覆盖多 owner 多 viewer: +// 不同规则、联系人方向(owner 把 viewer 加为联系人才命中 allow-contacts)、self(owner==viewer)。 +func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) { + ctx := context.Background() + contacts := memory.NewContactStore() + svc := NewService(memory.NewPrivacyStore(), contacts) + owners := []int64{6001, 6002, 6003, 6004} + viewers := []int64{7001, 7002, 6002} // 6002 既是 owner 又是 viewer → 命中 self 分支 + + if _, err := svc.SetRules(ctx, 6002, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}); err != nil { + t.Fatalf("set 6002 phone: %v", err) + } + if _, err := svc.SetRules(ctx, 6003, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil { + t.Fatalf("set 6003 status: %v", err) + } + if _, err := svc.SetRules(ctx, 6004, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil { + t.Fatalf("set 6004 phone: %v", err) + } + // owner 6004 把 viewer 7001 加为联系人(owner→viewer 方向 = privacy 的 ViewerIsContact)。 + if _, err := contacts.Upsert(ctx, 6004, domain.ContactInput{ContactUserID: 7001}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + + keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyStatusTimestamp, domain.PrivacyKeyProfilePhoto} + matrix, err := svc.CanSeeMatrix(ctx, owners, viewers, keys) + if err != nil { + t.Fatalf("CanSeeMatrix: %v", err) + } + for _, owner := range owners { + for _, viewer := range viewers { + for _, k := range keys { + want, err := svc.CanSee(ctx, owner, viewer, k) + if err != nil { + t.Fatalf("CanSee(%d,%d,%v): %v", owner, viewer, k, err) + } + got, ok := matrix[owner][viewer][k] + if !ok { + t.Fatalf("CanSeeMatrix missing owner=%d viewer=%d key=%v", owner, viewer, k) + } + if got != want { + t.Fatalf("CanSeeMatrix[%d][%d][%v]=%v != CanSee=%v (must be equivalent)", owner, viewer, k, got, want) + } + } + } + } +} diff --git a/internal/app/readmodel/hash.go b/internal/app/readmodel/hash.go new file mode 100644 index 00000000..17c000f2 --- /dev/null +++ b/internal/app/readmodel/hash.go @@ -0,0 +1,32 @@ +package readmodel + +import ( + "encoding/binary" + "hash/fnv" +) + +const ( + ModelDialogLight = "dialog_light" + ModelContactAccount = "contact_account" + ModelChannelBase = "channel_base" + ModelChannelMember = "channel_member" + ModelChannelActiveIDs = "channel_active_memberships" + ModelChannelMediaCounts = "channel_media_counts" + ModelPrivateMediaCounts = "private_media_counts" + ModelChannelParticipants = "channel_participants" + ModelChannelSelfBoosts = "channel_self_boosts" +) + +func MixHashes(values ...int64) int64 { + h := fnv.New64a() + var buf [8]byte + for _, value := range values { + binary.LittleEndian.PutUint64(buf[:], uint64(value)) + _, _ = h.Write(buf[:]) + } + sum := int64(h.Sum64() & 0x7fffffffffffffff) + if sum == 0 { + return 1 + } + return sum +} diff --git a/internal/app/secretchat/dh.go b/internal/app/secretchat/dh.go new file mode 100644 index 00000000..2d9e6d32 --- /dev/null +++ b/internal/app/secretchat/dh.go @@ -0,0 +1,55 @@ +// Package secretchat 实现私聊端对端加密(Secret Chat / EncryptedChat)的握手 +// 状态机。服务端是盲中继:g_a/g_b/key_fingerprint/加密 bytes 全部不透明存储与 +// 原样转发,唯一参与密码学的点是对 g_a/g_b 做 DH 范围边界校验(防弱 DH/MITM)。 +// 共享密钥与明文是 E2E 客户端职责,服务端不知道也无法计算。 +// 设计见 docs/secret-chat-module.md。 +package secretchat + +import ( + "errors" + "math/big" + + appphone "telesrv/internal/app/phone" +) + +// dhPubSize 是 g_a/g_b 的规范字节长度(2048-bit)。 +const dhPubSize = 256 + +// ErrGAInvalid:g_a/g_b 不在合法 DH 区间 → rpc 层映射为 DH_G_A_INVALID。 +var ErrGAInvalid = errors.New("secretchat: dh parameter invalid") + +var ( + // dhPrimeMinusOne/边界值复用 phone 域官方 2048-bit safe prime(与 getDhConfig 下发的 p 同源)。 + dhPrimeMinusOne = new(big.Int).Sub(new(big.Int).SetBytes(appphone.DHPrime()), big.NewInt(1)) + // 2^(2048-64) 下界与 p-2^(2048-64) 上界(官方 isGoodPrime 同款边界)。 + dhLowerBound = new(big.Int).Lsh(big.NewInt(1), 2048-64) + dhUpperBound = new(big.Int).Sub(new(big.Int).SetBytes(appphone.DHPrime()), new(big.Int).Lsh(big.NewInt(1), 2048-64)) + dhOne = big.NewInt(1) +) + +// validateDHParam 对 g_a/g_b 做范围校验,通过后左补零到 256 字节返回(规范线格式; +// 首字节为 0 被裁的合法 g_a 不能误拒)。校验用的是 big.Int 值,与补零无关。 +func validateDHParam(g []byte) ([]byte, error) { + if len(g) == 0 || len(g) > dhPubSize { + return nil, ErrGAInvalid + } + x := new(big.Int).SetBytes(g) + // 1 < x < p-1 且 2^(2048-64) < x < p-2^(2048-64) + if x.Cmp(dhOne) <= 0 || x.Cmp(dhPrimeMinusOne) >= 0 { + return nil, ErrGAInvalid + } + if x.Cmp(dhLowerBound) <= 0 || x.Cmp(dhUpperBound) >= 0 { + return nil, ErrGAInvalid + } + return leftPad256(g), nil +} + +// leftPad256 左补零到 256 字节(输入已保证 ≤256)。 +func leftPad256(b []byte) []byte { + if len(b) == dhPubSize { + return append([]byte(nil), b...) + } + out := make([]byte, dhPubSize) + copy(out[dhPubSize-len(b):], b) + return out +} diff --git a/internal/app/secretchat/service.go b/internal/app/secretchat/service.go new file mode 100644 index 00000000..ee3782e9 --- /dev/null +++ b/internal/app/secretchat/service.go @@ -0,0 +1,320 @@ +package secretchat + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// idAllocRetries 是 chat_id 撞键自愈的有界重试次数。 +const idAllocRetries = 4 + +// Service 实现密聊握手状态机 + qts 消息投递。所有返回的 domain.SecretChat 都是当时快照。 +// 访问校验(self/bot/拉黑/隐私)在 rpc 层先行;本层做 DH 校验、id/access_hash 分配、 +// 状态机迁移与 qts 队列写入。绑定维度是设备级 perm auth_key(int64)。 +type Service struct { + store store.SecretChatStore + queue store.EncryptedQueueStore + ids store.SecretChatIDAllocator +} + +// NewService 创建密聊服务。 +func NewService(st store.SecretChatStore, queue store.EncryptedQueueStore, ids store.SecretChatIDAllocator) *Service { + return &Service{store: st, queue: queue, ids: ids} +} + +// RequestEncryption 受理 requestEncryption:校验 g_a → 幂等去重 → 分配 chat_id + 双 +// access_hash → 盲存 g_a → 落 requested 态。返回的密聊由 rpc 层投影为 admin 视角 +// encryptedChatWaiting(同步响应)与 participant 视角 encryptedChatRequested(推送)。 +func (s *Service) RequestEncryption(ctx context.Context, req domain.SecretChatRequest) (domain.SecretChat, error) { + if req.AdminUserID == 0 || req.ParticipantUserID == 0 || req.AdminAuthKeyID == 0 { + return domain.SecretChat{}, ErrGAInvalid + } + ga, err := validateDHParam(req.GA) + if err != nil { + return domain.SecretChat{}, err + } + // 幂等:同发起设备 + random_id 重发返回既有 chat(DISCARDED 视为新请求)。 + if existing, ok, err := s.store.GetByAdminRandom(ctx, req.AdminAuthKeyID, req.RandomID); err != nil { + return domain.SecretChat{}, err + } else if ok && !existing.Terminal() { + return existing, nil + } + adminAH, err := randomAccessHash() + if err != nil { + return domain.SecretChat{}, err + } + participantAH, err := randomAccessHash() + if err != nil { + return domain.SecretChat{}, err + } + chat := domain.SecretChat{ + AdminAccessHash: adminAH, + ParticipantAccessHash: participantAH, + AdminUserID: req.AdminUserID, + ParticipantUserID: req.ParticipantUserID, + AdminAuthKeyID: req.AdminAuthKeyID, + State: domain.SecretChatStateRequested, + GA: ga, + RandomID: req.RandomID, + Date: req.Date, + } + for attempt := 0; ; attempt++ { + chatID, err := s.nextChatID(ctx, attempt) + if err != nil { + return domain.SecretChat{}, err + } + chat.ID = chatID + err = s.store.CreateSecretChat(ctx, chat) + if err == nil { + return chat, nil + } + if errors.Is(err, domain.ErrSecretChatIDConflict) && attempt < idAllocRetries { + continue + } + return domain.SecretChat{}, err + } +} + +// nextChatID 分配下一个 chat_id;撞键后用 AtLeast(MaxSecretChatID) 顶起计数器自愈。 +// 校验 int32 正区间上界(EncryptedChat.ID 是 int32 量级)。 +func (s *Service) nextChatID(ctx context.Context, attempt int) (int, error) { + var ( + id int + err error + ) + if attempt == 0 { + id, err = s.ids.NextSecretChatID(ctx) + } else { + floor, ferr := s.store.MaxSecretChatID(ctx) + if ferr != nil { + return 0, ferr + } + id, err = s.ids.NextSecretChatIDAtLeast(ctx, floor) + } + if err != nil { + return 0, err + } + if id <= 0 || id > 0x7fffffff { + return 0, fmt.Errorf("secretchat: chat id out of int32 range: %d", id) + } + return id, nil +} + +// AcceptEncryption 受理 acceptEncryption:定位 + participant 视角 access_hash 校验 → +// 校验 g_b → 原子 CAS 绑定接受设备并落 g_b/key_fingerprint → normal。返回的密聊由 +// rpc 层投影为 participant 视角 encryptedChat(GAOrB=g_a,同步响应)与 admin 视角 +// encryptedChat(GAOrB=g_b,推送)。 +func (s *Service) AcceptEncryption(ctx context.Context, chatID int, viewerUserID, participantAuthKeyID, accessHash int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error) { + gbPadded, err := validateDHParam(gb) + if err != nil { + return domain.SecretChat{}, err + } + chat, ok, err := s.store.GetSecretChat(ctx, chatID) + if err != nil { + return domain.SecretChat{}, err + } + // 视角校验:调用方必须是接受方本人且 access_hash 匹配 participant 视角。 + if !ok || chat.ParticipantUserID != viewerUserID || chat.ParticipantAccessHash != accessHash { + return domain.SecretChat{}, domain.ErrSecretChatNotFound + } + switch chat.State { + case domain.SecretChatStateNormal: + return domain.SecretChat{}, domain.ErrSecretChatAlreadyAccepted + case domain.SecretChatStateDiscarded: + return domain.SecretChat{}, domain.ErrSecretChatAlreadyDeclined + } + return s.store.AcceptSecretChat(ctx, chatID, participantAuthKeyID, gbPadded, keyFingerprint) +} + +// DiscardEncryption 受理 discardEncryption:定位 + 参与者校验 → 迁移到 discarded。 +// already=true 表示已是终态(幂等成功)。返回的密聊由 rpc 层投影为对端 +// encryptedChatDiscarded 推送。 +func (s *Service) DiscardEncryption(ctx context.Context, chatID int, viewerUserID int64, deleteHistory bool) (domain.SecretChat, bool, error) { + chat, ok, err := s.store.GetSecretChat(ctx, chatID) + if err != nil { + return domain.SecretChat{}, false, err + } + if !ok || !chat.HasParticipant(viewerUserID) { + return domain.SecretChat{}, false, domain.ErrSecretChatNotFound + } + return s.store.DiscardSecretChat(ctx, chatID, deleteHistory) +} + +// GetSecretChat 取密聊快照(rpc 层访问校验用)。 +func (s *Service) GetSecretChat(ctx context.Context, chatID int) (domain.SecretChat, bool, error) { + return s.store.GetSecretChat(ctx, chatID) +} + +// DiscardForAuthKey 级联 discard 绑定该设备 perm auth_key(作为 admin 或 participant)的 +// 全部活跃密聊,用于设备登出 / 授权撤销。返回本次实际从非终态迁移到 discarded 的密聊快照 +// (已是终态的不返回),供 rpc 层据此向对端推送 encryptedChatDiscarded。盲中继:不删历史 +// (history_deleted=false,对端自行决定本地处置)。出错时返回已成功 discard 的部分 + err, +// 让调用方仍能通知这部分对端(登出/撤销是 best-effort,不因此回退)。 +func (s *Service) DiscardForAuthKey(ctx context.Context, authKeyID int64) ([]domain.SecretChat, error) { + if authKeyID == 0 { + return nil, nil + } + chats, err := s.store.ListActiveSecretChatsByAuthKey(ctx, authKeyID) + if err != nil { + return nil, err + } + var discarded []domain.SecretChat + for _, c := range chats { + updated, already, derr := s.store.DiscardSecretChat(ctx, c.ID, false) + if derr != nil { + if errors.Is(derr, domain.ErrSecretChatNotFound) { + continue + } + return discarded, derr + } + if !already { + discarded = append(discarded, updated) + } + } + return discarded, nil +} + +// SendEncrypted 受理 sendEncrypted*:定位 + 发送方视角 access_hash 校验 + 态须 normal → +// 给【对端绑定设备】分配 qts 并把不透明 bytes 写入投递队列(幂等:同 chat+random_id 返既有 +// qts/date)。返回密聊快照 + 已落库消息(携 qts/date,rpc 层据此推 updateNewEncryptedMessage +// 并回 SentEncryptedMessage{date})。盲中継:不解密 bytes。 +func (s *Service) SendEncrypted(ctx context.Context, chatID int, viewerUserID, accessHash int64, delivery domain.SecretMessageDelivery) (domain.SecretChat, domain.SecretChatMessage, error) { + chat, ok, err := s.store.GetSecretChat(ctx, chatID) + if err != nil { + return domain.SecretChat{}, domain.SecretChatMessage{}, err + } + if !ok || !chat.HasParticipant(viewerUserID) || chat.AccessHashFor(viewerUserID) != accessHash { + return domain.SecretChat{}, domain.SecretChatMessage{}, domain.ErrSecretChatNotFound + } + if chat.State != domain.SecretChatStateNormal { + // 未成型 / 已销毁的密聊不能收发(CHAT_ID_INVALID)。 + return domain.SecretChat{}, domain.SecretChatMessage{}, domain.ErrSecretChatNotFound + } + receiverUserID := chat.PeerOf(viewerUserID) + receiverAuthKeyID := chat.AdminAuthKeyID + if chat.IsAdmin(viewerUserID) { + receiverAuthKeyID = chat.ParticipantAuthKeyID + } + if receiverUserID == 0 || receiverAuthKeyID == 0 { + return domain.SecretChat{}, domain.SecretChatMessage{}, domain.ErrSecretChatNotFound + } + stored, _, err := s.queue.AppendEncryptedMessage(ctx, domain.SecretChatMessage{ + ReceiverAuthKeyID: receiverAuthKeyID, + ReceiverUserID: receiverUserID, + ChatID: chatID, + RandomID: delivery.RandomID, + Date: delivery.Date, + IsService: delivery.IsService, + Bytes: delivery.Bytes, + File: delivery.File, + }) + if err != nil { + return domain.SecretChat{}, domain.SecretChatMessage{}, err + } + return chat, stored, nil +} + +// ListNewMessages 返回某设备 qts > sinceQts 的连续加密消息(getDifference 补差分用)。 +func (s *Service) ListNewMessages(ctx context.Context, deviceAuthKeyID int64, sinceQts, limit int) ([]domain.SecretChatMessage, error) { + if deviceAuthKeyID == 0 { + return nil, nil + } + return s.queue.ListEncryptedMessagesSince(ctx, deviceAuthKeyID, sinceQts, limit) +} + +// DeviceReservedQts 返回某设备当前已分配的最高 qts(getState 用)。 +func (s *Service) DeviceReservedQts(ctx context.Context, deviceAuthKeyID int64) (int, error) { + if deviceAuthKeyID == 0 { + return 0, nil + } + return s.queue.ReservedQts(ctx, deviceAuthKeyID) +} + +// AckQueue 推进某设备的 confirmed qts 并标记 acked(receivedQueue)。 +func (s *Service) AckQueue(ctx context.Context, deviceAuthKeyID int64, maxQts int) error { + if deviceAuthKeyID == 0 || maxQts <= 0 { + return nil + } + return s.queue.AckEncryptedMessages(ctx, deviceAuthKeyID, maxQts) +} + +// RecordEncryptionEvent 写入 durable updateEncryption 状态事件(离线补偿)。 +// targetAuthKeyID=0 表示账号级(建链前邀请/撤回对 target 所有设备可见),非 0 表示 +// 绑定设备定向。投递时按 secret_chats 权威态重建(不固化快照)。 +func (s *Service) RecordEncryptionEvent(ctx context.Context, chatID int, targetUserID, targetAuthKeyID int64, date int) error { + if targetUserID == 0 { + return nil + } + _, err := s.queue.AppendStateEvent(ctx, domain.EncryptedStateEvent{ + TargetUserID: targetUserID, + TargetAuthKeyID: targetAuthKeyID, + ChatID: chatID, + Type: domain.EncryptedStateEventEncryption, + Date: date, + }) + return err +} + +// RecordReadEvent 写入 durable updateEncryptedMessagesRead 状态事件(离线补偿,设备定向)。 +func (s *Service) RecordReadEvent(ctx context.Context, chatID int, targetUserID, targetAuthKeyID int64, maxDate, date int) error { + if targetUserID == 0 { + return nil + } + _, err := s.queue.AppendStateEvent(ctx, domain.EncryptedStateEvent{ + TargetUserID: targetUserID, + TargetAuthKeyID: targetAuthKeyID, + ChatID: chatID, + Type: domain.EncryptedStateEventRead, + MaxDate: maxDate, + Date: date, + }) + return err +} + +// ListStateEvents 返回某设备未投递的密聊状态事件(getDifference 补偿用)。 +func (s *Service) ListStateEvents(ctx context.Context, userID, deviceAuthKeyID int64, limit int) ([]domain.EncryptedStateEvent, error) { + if userID == 0 || deviceAuthKeyID == 0 { + return nil, nil + } + return s.queue.ListUndeliveredStateEvents(ctx, userID, deviceAuthKeyID, limit) +} + +// MarkStateEventsDelivered 登记某设备已投递这些状态事件。 +func (s *Service) MarkStateEventsDelivered(ctx context.Context, deviceAuthKeyID int64, eventIDs []int64) error { + if deviceAuthKeyID == 0 || len(eventIDs) == 0 { + return nil + } + return s.queue.MarkStateEventsDelivered(ctx, deviceAuthKeyID, eventIDs) +} + +// PutEncryptedFile 持久化密聊文件元数据快照(铸造后写一次)。 +func (s *Service) PutEncryptedFile(ctx context.Context, ownerUserID int64, ref domain.EncryptedFileRef) error { + if ref.ID == 0 { + return nil + } + return s.queue.PutEncryptedFile(ctx, ownerUserID, ref) +} + +// GetEncryptedFile 按 id + access_hash 回查文件快照(inputEncryptedFile 复用路径)。 +func (s *Service) GetEncryptedFile(ctx context.Context, id, accessHash int64) (domain.EncryptedFileRef, bool, error) { + return s.queue.GetEncryptedFile(ctx, id, accessHash) +} + +// randomAccessHash 生成正 int64 access_hash(rand 8B → 高位清零保正,0 置 1)。 +func randomAccessHash() (int64, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, fmt.Errorf("secretchat: access hash rand: %w", err) + } + v := int64(binary.BigEndian.Uint64(b[:]) >> 1) + if v == 0 { + v = 1 + } + return v, nil +} diff --git a/internal/app/secretchat/service_test.go b/internal/app/secretchat/service_test.go new file mode 100644 index 00000000..126c2f4d --- /dev/null +++ b/internal/app/secretchat/service_test.go @@ -0,0 +1,346 @@ +package secretchat + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// fakeChatIDAllocator 是单调自增的测试分配器(无 Redis)。 +type fakeChatIDAllocator struct{ n int } + +func (a *fakeChatIDAllocator) NextSecretChatID(context.Context) (int, error) { + a.n++ + return a.n, nil +} + +func (a *fakeChatIDAllocator) NextSecretChatIDAtLeast(_ context.Context, floor int) (int, error) { + if a.n < floor { + a.n = floor + } + a.n++ + return a.n, nil +} + +func (a *fakeChatIDAllocator) CurrentSecretChatID(context.Context) (int, error) { return a.n, nil } + +// validGA 返回一个落在合法 DH 区间的 256 字节 g_a(首字节 0x55 ≈ 2^2046, +// 既 > 2^1984 又 < p≈0xc7..)。 +func validGA() []byte { + b := make([]byte, 256) + for i := range b { + b[i] = 0x42 + } + b[0] = 0x55 + return b +} + +func newTestService() (*Service, *memory.SecretChatStore) { + st := memory.NewSecretChatStore() + return NewService(st, memory.NewEncryptedQueueStore(), &fakeChatIDAllocator{}), st +} + +const ( + adminUser = int64(1001) + partUser = int64(2002) + adminAuthKey = int64(0x1111) + partAuthKey = int64(0x2222) + keyFP = int64(0x0123456789abcdef) +) + +func requestFixture() domain.SecretChatRequest { + return domain.SecretChatRequest{ + AdminUserID: adminUser, + AdminAuthKeyID: adminAuthKey, + ParticipantUserID: partUser, + RandomID: 12345, + GA: validGA(), + Date: 1000, + } +} + +func TestRequestEncryption(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat, err := svc.RequestEncryption(ctx, requestFixture()) + if err != nil { + t.Fatalf("RequestEncryption: %v", err) + } + if chat.ID <= 0 || chat.ID > 0x7fffffff { + t.Fatalf("chat id out of int32 range: %d", chat.ID) + } + if chat.State != domain.SecretChatStateRequested { + t.Fatalf("state = %q, want requested", chat.State) + } + if len(chat.GA) != dhPubSize { + t.Fatalf("g_a length = %d, want %d (left-padded)", len(chat.GA), dhPubSize) + } + if chat.AdminAccessHash == 0 || chat.ParticipantAccessHash == 0 { + t.Fatal("access hashes must be non-zero") + } + if chat.AdminAccessHash == chat.ParticipantAccessHash { + t.Fatal("admin/participant access hashes must differ (per-viewer)") + } + if chat.ParticipantAuthKeyID != 0 { + t.Fatalf("participant auth key must be unbound before accept, got %d", chat.ParticipantAuthKeyID) + } +} + +func TestRequestEncryptionIdempotent(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + first, err := svc.RequestEncryption(ctx, requestFixture()) + if err != nil { + t.Fatalf("first request: %v", err) + } + second, err := svc.RequestEncryption(ctx, requestFixture()) + if err != nil { + t.Fatalf("second request: %v", err) + } + if first.ID != second.ID { + t.Fatalf("idempotent re-request must return same chat: %d vs %d", first.ID, second.ID) + } +} + +func TestRequestEncryptionInvalidGA(t *testing.T) { + svc, _ := newTestService() + req := requestFixture() + req.GA = []byte{0x01} // value 1 → 不在 (1, p-1) + if _, err := svc.RequestEncryption(context.Background(), req); !errors.Is(err, ErrGAInvalid) { + t.Fatalf("err = %v, want ErrGAInvalid", err) + } +} + +func TestAcceptEncryption(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat, err := svc.RequestEncryption(ctx, requestFixture()) + if err != nil { + t.Fatalf("request: %v", err) + } + accepted, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, validGA(), keyFP) + if err != nil { + t.Fatalf("accept: %v", err) + } + if accepted.State != domain.SecretChatStateNormal { + t.Fatalf("state = %q, want normal", accepted.State) + } + if accepted.KeyFingerprint != keyFP { + t.Fatalf("key fingerprint not relayed byte-for-byte: got %x want %x", accepted.KeyFingerprint, keyFP) + } + if accepted.ParticipantAuthKeyID != partAuthKey { + t.Fatalf("participant auth key not bound: %d", accepted.ParticipantAuthKeyID) + } + if len(accepted.GB) != dhPubSize { + t.Fatalf("g_b length = %d, want %d", len(accepted.GB), dhPubSize) + } +} + +func TestAcceptEncryptionWrongAccessHash(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat, _ := svc.RequestEncryption(ctx, requestFixture()) + _, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash+1, validGA(), keyFP) + if !errors.Is(err, domain.ErrSecretChatNotFound) { + t.Fatalf("err = %v, want ErrSecretChatNotFound", err) + } +} + +func TestAcceptEncryptionWrongUser(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat, _ := svc.RequestEncryption(ctx, requestFixture()) + // admin 自己冒充接受方。 + _, err := svc.AcceptEncryption(ctx, chat.ID, adminUser, adminAuthKey, chat.ParticipantAccessHash, validGA(), keyFP) + if !errors.Is(err, domain.ErrSecretChatNotFound) { + t.Fatalf("err = %v, want ErrSecretChatNotFound", err) + } +} + +func TestAcceptEncryptionDoubleAccept(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat, _ := svc.RequestEncryption(ctx, requestFixture()) + if _, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, validGA(), keyFP); err != nil { + t.Fatalf("first accept: %v", err) + } + // 第二台设备 accept:CAS 落空 → ENCRYPTION_ALREADY_ACCEPTED。 + _, err := svc.AcceptEncryption(ctx, chat.ID, partUser, int64(0x3333), chat.ParticipantAccessHash, validGA(), keyFP) + if !errors.Is(err, domain.ErrSecretChatAlreadyAccepted) { + t.Fatalf("err = %v, want ErrSecretChatAlreadyAccepted", err) + } +} + +func TestAcceptEncryptionInvalidGB(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat, _ := svc.RequestEncryption(ctx, requestFixture()) + _, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, []byte{0x01}, keyFP) + if !errors.Is(err, ErrGAInvalid) { + t.Fatalf("err = %v, want ErrGAInvalid", err) + } +} + +func TestDiscardEncryption(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat, _ := svc.RequestEncryption(ctx, requestFixture()) + got, already, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, true) + if err != nil { + t.Fatalf("discard: %v", err) + } + if already { + t.Fatal("first discard must not report already") + } + if got.State != domain.SecretChatStateDiscarded || !got.HistoryDeleted { + t.Fatalf("discarded chat = %+v", got) + } + // 幂等:再 discard 返回 already=true。 + _, already, err = svc.DiscardEncryption(ctx, chat.ID, partUser, false) + if err != nil || !already { + t.Fatalf("idempotent discard: already=%v err=%v", already, err) + } +} + +func TestDiscardEncryptionNonParticipant(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat, _ := svc.RequestEncryption(ctx, requestFixture()) + _, _, err := svc.DiscardEncryption(ctx, chat.ID, int64(9999), false) + if !errors.Is(err, domain.ErrSecretChatNotFound) { + t.Fatalf("err = %v, want ErrSecretChatNotFound", err) + } +} + +// acceptedChat 跑完 request→accept,返回 normal 态密聊。 +func acceptedChat(t *testing.T, svc *Service) domain.SecretChat { + t.Helper() + ctx := context.Background() + chat, err := svc.RequestEncryption(ctx, requestFixture()) + if err != nil { + t.Fatalf("request: %v", err) + } + accepted, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, dhParamGB(), keyFP) + if err != nil { + t.Fatalf("accept: %v", err) + } + return accepted +} + +func dhParamGB() []byte { + b := make([]byte, 256) + for i := range b { + b[i] = 0x42 + } + b[0] = 0x66 + return b +} + +func TestSendEncryptedQtsAllocation(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat := acceptedChat(t, svc) + + // admin 发 → 投给 participant 设备(partAuthKey),qts 从 1 起。 + _, m1, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 111, Bytes: []byte{1, 2, 3}, Date: 2000}) + if err != nil { + t.Fatalf("send 1: %v", err) + } + if m1.Qts != 1 || m1.ReceiverAuthKeyID != partAuthKey || m1.ReceiverUserID != partUser { + t.Fatalf("msg1 = %+v (want qts=1, receiver=participant device)", m1) + } + _, m2, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 222, Bytes: []byte{4}, Date: 2001}) + if err != nil || m2.Qts != 2 { + t.Fatalf("msg2 qts = %d err=%v, want 2", m2.Qts, err) + } + + // 幂等重发同 random_id → 返回首次 qts/date,不分配新 qts。 + _, dup, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 111, Bytes: []byte{1, 2, 3}, Date: 9999}) + if err != nil { + t.Fatalf("dup send: %v", err) + } + if dup.Qts != 1 || dup.Date != 2000 { + t.Fatalf("idempotent resend = %+v, want qts=1 date=2000 (首次落库值)", dup) + } + + // participant 发 → 投给 admin 设备(adminAuthKey),独立 qts 序列从 1 起。 + _, pm, err := svc.SendEncrypted(ctx, chat.ID, partUser, chat.ParticipantAccessHash, domain.SecretMessageDelivery{RandomID: 333, Bytes: []byte{9}, Date: 2002}) + if err != nil { + t.Fatalf("participant send: %v", err) + } + if pm.Qts != 1 || pm.ReceiverAuthKeyID != adminAuthKey || pm.ReceiverUserID != adminUser { + t.Fatalf("participant msg = %+v (want qts=1, receiver=admin device)", pm) + } +} + +func TestSendEncryptedWrongAccessHash(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat := acceptedChat(t, svc) + _, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash+1, domain.SecretMessageDelivery{RandomID: 1, Bytes: []byte{1}, Date: 2000}) + if !errors.Is(err, domain.ErrSecretChatNotFound) { + t.Fatalf("err = %v, want ErrSecretChatNotFound", err) + } +} + +func TestSendEncryptedNonNormal(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat, _ := svc.RequestEncryption(ctx, requestFixture()) // requested, 未 accept + _, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: 1, Bytes: []byte{1}, Date: 2000}) + if !errors.Is(err, domain.ErrSecretChatNotFound) { + t.Fatalf("err = %v, want ErrSecretChatNotFound (未成型不能发)", err) + } +} + +func TestListNewMessagesAndAck(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat := acceptedChat(t, svc) + for i := 0; i < 3; i++ { + if _, _, err := svc.SendEncrypted(ctx, chat.ID, adminUser, chat.AdminAccessHash, domain.SecretMessageDelivery{RandomID: int64(1000 + i), Bytes: []byte{byte(i)}, Date: 2000 + i}); err != nil { + t.Fatalf("send %d: %v", i, err) + } + } + // 接收设备(participant)补差分:qts>0 全部 3 条。 + msgs, err := svc.ListNewMessages(ctx, partAuthKey, 0, 0) + if err != nil || len(msgs) != 3 { + t.Fatalf("list since 0 = %d msgs err=%v, want 3", len(msgs), err) + } + if msgs[0].Qts != 1 || msgs[2].Qts != 3 { + t.Fatalf("qts sequence broken: %d..%d", msgs[0].Qts, msgs[2].Qts) + } + // qts>1 → 剩 2 条。 + msgs, _ = svc.ListNewMessages(ctx, partAuthKey, 1, 0) + if len(msgs) != 2 || msgs[0].Qts != 2 { + t.Fatalf("list since 1 = %+v, want qts 2,3", msgs) + } + // reserved qts = 3。 + if q, _ := svc.DeviceReservedQts(ctx, partAuthKey); q != 3 { + t.Fatalf("reserved qts = %d, want 3", q) + } + // ack 到 3:不报错(confirmed 推进)。 + if err := svc.AckQueue(ctx, partAuthKey, 3); err != nil { + t.Fatalf("ack: %v", err) + } + // 未参与设备 qts=0。 + if q, _ := svc.DeviceReservedQts(ctx, int64(0xDEAD)); q != 0 { + t.Fatalf("unrelated device reserved qts = %d, want 0", q) + } +} + +func TestAcceptAfterDiscard(t *testing.T) { + svc, _ := newTestService() + ctx := context.Background() + chat, _ := svc.RequestEncryption(ctx, requestFixture()) + if _, _, err := svc.DiscardEncryption(ctx, chat.ID, adminUser, false); err != nil { + t.Fatalf("discard: %v", err) + } + _, err := svc.AcceptEncryption(ctx, chat.ID, partUser, partAuthKey, chat.ParticipantAccessHash, validGA(), keyFP) + if !errors.Is(err, domain.ErrSecretChatAlreadyDeclined) { + t.Fatalf("err = %v, want ErrSecretChatAlreadyDeclined", err) + } +} diff --git a/internal/app/stargifts/service.go b/internal/app/stargifts/service.go new file mode 100644 index 00000000..7de7c932 --- /dev/null +++ b/internal/app/stargifts/service.go @@ -0,0 +1,124 @@ +// Package stargifts 实现 Star 礼物应用服务:礼物目录(从 seed 合成、懒加载缓存)+ peer 收到的 +// 礼物实例 CRUD。扣费/退款/服务消息投递由 rpc 层编排(复用 Stars 账本 + SendPrivateText), +// 本层只管目录与持久化。 +package stargifts + +import ( + "context" + "sync" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// CatalogProvider 合成礼物目录(app/files 实现)。 +type CatalogProvider interface { + BuildStarGiftCatalog(ctx context.Context) ([]domain.StarGift, error) +} + +// Service 是 Star 礼物应用服务。 +type Service struct { + store store.StarGiftStore + catalog CatalogProvider + + mu sync.Mutex + built bool + gifts []domain.StarGift + byID map[int64]domain.StarGift + hash int +} + +// NewService 创建 Star 礼物服务。 +func NewService(st store.StarGiftStore, catalog CatalogProvider) *Service { + return &Service{store: st, catalog: catalog} +} + +// ensureCatalog 懒加载并缓存目录(静态数据,构建一次)。 +func (s *Service) ensureCatalog(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.built { + return nil + } + gifts, err := s.catalog.BuildStarGiftCatalog(ctx) + if err != nil { + return err + } + s.gifts = gifts + s.byID = make(map[int64]domain.StarGift, len(gifts)) + for _, g := range gifts { + s.byID[g.ID] = g + } + s.hash = domain.StarGiftCatalogHash(gifts) + s.built = true + return nil +} + +// Catalog 返回礼物目录。 +func (s *Service) Catalog(ctx context.Context) ([]domain.StarGift, error) { + if err := s.ensureCatalog(ctx); err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]domain.StarGift, len(s.gifts)) + copy(out, s.gifts) + return out, nil +} + +// CatalogHash 返回目录 hash(getStarGifts NotModified 判定)。 +func (s *Service) CatalogHash(ctx context.Context) (int, error) { + if err := s.ensureCatalog(ctx); err != nil { + return 0, err + } + s.mu.Lock() + defer s.mu.Unlock() + return s.hash, nil +} + +// GiftByID 返回目录中指定礼物,不存在返回 ok=false。 +func (s *Service) GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) { + if err := s.ensureCatalog(ctx); err != nil { + return domain.StarGift{}, false, err + } + s.mu.Lock() + defer s.mu.Unlock() + g, ok := s.byID[id] + return g, ok, nil +} + +// RecordSavedGift 持久化一条收到的礼物实例,返回行 id。 +func (s *Service) RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) { + return s.store.Create(ctx, gift) +} + +// ListSaved 分页返回某 owner 收到的礼物。 +func (s *Service) ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) { + if len(offset) > domain.MaxStarGiftsOffsetBytes { + offset = "" + } + if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit { + limit = domain.MaxSavedStarGiftsLimit + } + return s.store.ListByOwner(ctx, owner, excludeUnsaved, offset, limit) +} + +// GetSaved 按协议引用取礼物实例。 +func (s *Service) GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) { + return s.store.GetByRef(ctx, ref) +} + +// CountSaved 返回某 owner 展示在资料的礼物数(full.stargifts_count)。 +func (s *Service) CountSaved(ctx context.Context, owner domain.Peer) (int, error) { + return s.store.CountByOwner(ctx, owner) +} + +// ToggleSaved 切换礼物在资料的展示(saveStarGift)。 +func (s *Service) ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) { + return s.store.SetUnsaved(ctx, ref, unsaved) +} + +// Convert 把礼物标记为已转换(convertStarGift),返回该行供调用方据 ConvertStars 入账。 +func (s *Service) Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { + return s.store.MarkConverted(ctx, ref) +} diff --git a/internal/app/stargifts/service_test.go b/internal/app/stargifts/service_test.go new file mode 100644 index 00000000..0fb4ac87 --- /dev/null +++ b/internal/app/stargifts/service_test.go @@ -0,0 +1,150 @@ +package stargifts + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +type fakeCatalog struct { + gifts []domain.StarGift + calls int +} + +func (f *fakeCatalog) BuildStarGiftCatalog(_ context.Context) ([]domain.StarGift, error) { + f.calls++ + return f.gifts, nil +} + +func newTestService(gifts []domain.StarGift) (*Service, *fakeCatalog) { + cat := &fakeCatalog{gifts: gifts} + return NewService(memory.NewStarGiftStore(), cat), cat +} + +func TestCatalogCachedAndHash(t *testing.T) { + gifts := []domain.StarGift{ + {ID: 1, Stars: 15, ConvertStars: 15, Title: "Heart"}, + {ID: 2, Stars: 50, ConvertStars: 50, Title: "Cake"}, + } + svc, cat := newTestService(gifts) + ctx := context.Background() + + got, err := svc.Catalog(ctx) + if err != nil || len(got) != 2 { + t.Fatalf("catalog = %d err %v, want 2", len(got), err) + } + // 再取一次不重新构建(缓存)。 + if _, err := svc.Catalog(ctx); err != nil { + t.Fatalf("catalog#2: %v", err) + } + if cat.calls != 1 { + t.Fatalf("BuildStarGiftCatalog called %d times, want 1 (cached)", cat.calls) + } + hash, err := svc.CatalogHash(ctx) + if err != nil || hash != domain.StarGiftCatalogHash(gifts) { + t.Fatalf("hash = %d err %v, want %d", hash, err, domain.StarGiftCatalogHash(gifts)) + } + if g, ok, _ := svc.GiftByID(ctx, 2); !ok || g.Stars != 50 { + t.Fatalf("GiftByID(2) = %+v ok %v, want Cake 50", g, ok) + } + if _, ok, _ := svc.GiftByID(ctx, 999); ok { + t.Fatalf("GiftByID(999) found, want missing") + } +} + +func TestSavedGiftLifecycle(t *testing.T) { + svc, _ := newTestService(nil) + ctx := context.Background() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + + id, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{ + Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 50, Date: 1700000000, ConvertStars: 15, + }) + if err != nil || id == 0 { + t.Fatalf("RecordSavedGift = %d err %v", id, err) + } + + page, err := svc.ListSaved(ctx, owner, false, "", 100) + if err != nil || len(page.Gifts) != 1 || page.Count != 1 { + t.Fatalf("list = %d count %d err %v, want 1/1", len(page.Gifts), page.Count, err) + } + if page.NextOffset != "" { + t.Fatalf("single page next_offset = %q, want empty", page.NextOffset) + } + + // 隐藏(unsave=true)→ excludeUnsaved 列表为空。 + ref := domain.SavedStarGiftRef{Owner: owner, MsgID: 50} + if ok, err := svc.ToggleSaved(ctx, ref, true); err != nil || !ok { + t.Fatalf("ToggleSaved hide = %v err %v", ok, err) + } + hidden, _ := svc.ListSaved(ctx, owner, true, "", 100) + if len(hidden.Gifts) != 0 { + t.Fatalf("excludeUnsaved list = %d, want 0 after hide", len(hidden.Gifts)) + } + // 不带 exclude 仍能看到。 + all, _ := svc.ListSaved(ctx, owner, false, "", 100) + if len(all.Gifts) != 1 { + t.Fatalf("full list = %d, want 1 (hidden still listed)", len(all.Gifts)) + } + + // 转换回 Stars → 标记 converted,从列表消失。 + saved, err := svc.Convert(ctx, ref) + if err != nil || saved.ConvertStars != 15 { + t.Fatalf("Convert = %+v err %v, want ConvertStars 15", saved, err) + } + after, _ := svc.ListSaved(ctx, owner, false, "", 100) + if len(after.Gifts) != 0 { + t.Fatalf("list after convert = %d, want 0", len(after.Gifts)) + } + // 重复转换被拒。 + if _, err := svc.Convert(ctx, ref); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) { + t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err) + } +} + +func TestChannelSavedGiftAllocatesSavedIDWithoutMessage(t *testing.T) { + svc, _ := newTestService(nil) + ctx := context.Background() + owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 2001} + + savedID, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{ + Owner: owner, FromUserID: 1001, GiftID: 1, MsgID: 0, SavedID: 0, + Date: 1700000000, ConvertStars: 15, + }) + if err != nil || savedID == 0 { + t.Fatalf("RecordSavedGift(channel) = %d err %v, want allocated saved_id", savedID, err) + } + + gift, found, err := svc.GetSaved(ctx, domain.SavedStarGiftRef{Owner: owner, SavedID: savedID}) + if err != nil || !found { + t.Fatalf("GetSaved(channel) found=%v err=%v, want hit", found, err) + } + if gift.MsgID != 0 || gift.SavedID != savedID { + t.Fatalf("channel saved gift ids = msg_id %d saved_id %d, want 0/%d", gift.MsgID, gift.SavedID, savedID) + } +} + +func TestSavedGiftPagination(t *testing.T) { + svc, _ := newTestService(nil) + ctx := context.Background() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + for i := 0; i < 5; i++ { + if _, err := svc.RecordSavedGift(ctx, domain.SavedStarGift{ + Owner: owner, FromUserID: 2002, GiftID: 1, MsgID: 100 + i, Date: 1700000000 + i, ConvertStars: 15, + }); err != nil { + t.Fatalf("record#%d: %v", i, err) + } + } + page1, _ := svc.ListSaved(ctx, owner, false, "", 2) + if len(page1.Gifts) != 2 || page1.NextOffset == "" { + t.Fatalf("page1 = %d next=%q, want 2 + next", len(page1.Gifts), page1.NextOffset) + } + page2, _ := svc.ListSaved(ctx, owner, false, page1.NextOffset, 2) + page3, _ := svc.ListSaved(ctx, owner, false, page2.NextOffset, 2) + if len(page3.Gifts) != 1 || page3.NextOffset != "" { + t.Fatalf("page3 = %d next=%q, want 1 + empty (terminal)", len(page3.Gifts), page3.NextOffset) + } +} diff --git a/internal/app/stars/service.go b/internal/app/stars/service.go new file mode 100644 index 00000000..fc35df63 --- /dev/null +++ b/internal/app/stars/service.go @@ -0,0 +1,93 @@ +// Package stars 实现 Stars 本地账本应用服务:余额查询、贷记/借记、流水分页, +// 以及「惰性首读授予」起始余额(靠 stars_balances.granted 布尔幂等,新老账号都覆盖、 +// 无需回填迁移)。原子性由 store 事务保证;本层只做校验 + 授予策略。 +package stars + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service 是 Stars 账本应用服务。 +type Service struct { + store store.StarsStore + grantAmount int64 + now func() time.Time +} + +// Option 配置 Service。 +type Option func(*Service) + +// WithStartingGrant 设置惰性首读授予的起始余额;amount<=0 关闭自动授予。 +func WithStartingGrant(amount int64) Option { + return func(s *Service) { s.grantAmount = amount } +} + +// WithClock 注入时钟(测试用)。 +func WithClock(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +// NewService 创建 Stars 账本服务,默认起始授予 domain.DefaultStarsStartingGrant。 +func NewService(st store.StarsStore, opts ...Option) *Service { + s := &Service{store: st, grantAmount: domain.DefaultStarsStartingGrant, now: time.Now} + for _, opt := range opts { + opt(s) + } + return s +} + +// ensureGranted 惰性应用一次起始授予(幂等),返回最新余额。 +func (s *Service) ensureGranted(ctx context.Context, userID int64) (domain.StarsBalance, error) { + if s.grantAmount > 0 { + bal, _, err := s.store.EnsureGrant(ctx, userID, s.grantAmount, int(s.now().Unix())) + return bal, err + } + return s.store.GetBalance(ctx, userID) +} + +// GetBalance 返回账号余额,首读时惰性授予起始余额。 +func (s *Service) GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) { + return s.ensureGranted(ctx, userID) +} + +// Credit 为账号入账(amount>0)。 +func (s *Service) Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) { + if amount <= 0 { + return domain.StarsBalance{}, domain.ErrStarsInvalidAmount + } + return s.store.Credit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc) +} + +// Debit 从账号扣款(amount>0),余额不足返回 domain.ErrStarsInsufficient。 +// 先确保起始授予已应用,避免新账号在尚未首读余额前借记被误判余额不足。 +func (s *Service) Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) { + if amount <= 0 { + return domain.StarsBalance{}, domain.ErrStarsInvalidAmount + } + if _, err := s.ensureGranted(ctx, userID); err != nil { + return domain.StarsBalance{}, err + } + return s.store.Debit(ctx, userID, amount, reason, peer, int(s.now().Unix()), title, desc) +} + +// ListTransactions 按 keyset 分页返回流水 + 当前余额,首读时惰性授予。 +func (s *Service) ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) { + if len(offset) > domain.MaxStarsTransactionsOffsetBytes { + offset = "" + } + if limit <= 0 || limit > domain.MaxStarsTransactionsLimit { + limit = domain.MaxStarsTransactionsLimit + } + if _, err := s.ensureGranted(ctx, userID); err != nil { + return domain.StarsTransactionPage{}, err + } + return s.store.ListTransactions(ctx, userID, offset, limit) +} diff --git a/internal/app/stars/service_test.go b/internal/app/stars/service_test.go new file mode 100644 index 00000000..f8df4e03 --- /dev/null +++ b/internal/app/stars/service_test.go @@ -0,0 +1,137 @@ +package stars + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func newTestService(grant int64) *Service { + return NewService(memory.NewStarsStore(), WithStartingGrant(grant)) +} + +// 起始授予幂等:多次 GetBalance 只授予一次。 +func TestStartingGrantOnce(t *testing.T) { + svc := newTestService(1000) + ctx := context.Background() + bal, err := svc.GetBalance(ctx, 7) + if err != nil { + t.Fatalf("GetBalance: %v", err) + } + if bal.Balance != 1000 || !bal.Granted { + t.Fatalf("first balance = %+v, want 1000 granted", bal) + } + // 再读不应重复授予。 + bal2, err := svc.GetBalance(ctx, 7) + if err != nil { + t.Fatalf("GetBalance#2: %v", err) + } + if bal2.Balance != 1000 { + t.Fatalf("second balance = %d, want 1000 (no double grant)", bal2.Balance) + } + // 流水里应恰有一条 grant。 + page, err := svc.ListTransactions(ctx, 7, "", 100) + if err != nil { + t.Fatalf("ListTransactions: %v", err) + } + if len(page.Transactions) != 1 || page.Transactions[0].Reason != domain.StarsReasonGrant || page.Transactions[0].Amount != 1000 { + t.Fatalf("grant txns = %+v, want one +1000 grant", page.Transactions) + } +} + +// 关闭授予(grant=0)时余额为 0、无 grant 流水。 +func TestGrantDisabled(t *testing.T) { + svc := newTestService(0) + bal, err := svc.GetBalance(context.Background(), 9) + if err != nil { + t.Fatalf("GetBalance: %v", err) + } + if bal.Balance != 0 || bal.Granted { + t.Fatalf("balance = %+v, want 0 not granted", bal) + } +} + +// 借记成功扣减余额并写负流水;余额不足返回 ErrStarsInsufficient 且不动账。 +func TestDebitAndInsufficient(t *testing.T) { + svc := newTestService(1000) + ctx := context.Background() + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 555} + + bal, err := svc.Debit(ctx, 7, 300, domain.StarsReasonReaction, peer, "paid reaction", "") + if err != nil { + t.Fatalf("Debit: %v", err) + } + if bal.Balance != 700 { + t.Fatalf("after debit = %d, want 700", bal.Balance) + } + + // 余额不足。 + if _, err := svc.Debit(ctx, 7, 10_000, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInsufficient) { + t.Fatalf("over-debit err = %v, want ErrStarsInsufficient", err) + } + // 余额未被改动。 + after, _ := svc.GetBalance(ctx, 7) + if after.Balance != 700 { + t.Fatalf("balance after failed debit = %d, want 700 unchanged", after.Balance) + } + + // 非法金额。 + if _, err := svc.Debit(ctx, 7, 0, domain.StarsReasonReaction, peer, "", ""); !errors.Is(err, domain.ErrStarsInvalidAmount) { + t.Fatalf("zero debit err = %v, want ErrStarsInvalidAmount", err) + } +} + +// 贷记增加余额并写正流水。 +func TestCredit(t *testing.T) { + svc := newTestService(0) // 关闭起始授予,单测贷记 + ctx := context.Background() + bal, err := svc.Credit(ctx, 7, 250, domain.StarsReasonTopup, domain.Peer{}, "topup", "") + if err != nil { + t.Fatalf("Credit: %v", err) + } + if bal.Balance != 250 { + t.Fatalf("after credit = %d, want 250", bal.Balance) + } +} + +// keyset 分页:末页 NextOffset 必须为空(否则客户端死循环)。 +func TestListTransactionsPagination(t *testing.T) { + svc := newTestService(0) + ctx := context.Background() + for i := 0; i < 5; i++ { + if _, err := svc.Credit(ctx, 7, int64(10+i), domain.StarsReasonTopup, domain.Peer{}, "", ""); err != nil { + t.Fatalf("Credit#%d: %v", i, err) + } + } + page1, err := svc.ListTransactions(ctx, 7, "", 2) + if err != nil { + t.Fatalf("page1: %v", err) + } + if len(page1.Transactions) != 2 || page1.NextOffset == "" { + t.Fatalf("page1 = %d txns next=%q, want 2 + nonempty next", len(page1.Transactions), page1.NextOffset) + } + // 倒序:最新(id 最大,amount=14)在前。 + if page1.Transactions[0].Amount != 14 { + t.Fatalf("page1[0].Amount = %d, want 14 (newest first)", page1.Transactions[0].Amount) + } + page2, err := svc.ListTransactions(ctx, 7, page1.NextOffset, 2) + if err != nil { + t.Fatalf("page2: %v", err) + } + if len(page2.Transactions) != 2 { + t.Fatalf("page2 = %d txns, want 2", len(page2.Transactions)) + } + page3, err := svc.ListTransactions(ctx, 7, page2.NextOffset, 2) + if err != nil { + t.Fatalf("page3: %v", err) + } + if len(page3.Transactions) != 1 { + t.Fatalf("page3 = %d txns, want 1 (last)", len(page3.Transactions)) + } + if page3.NextOffset != "" { + t.Fatalf("last page NextOffset = %q, want empty (no infinite paging)", page3.NextOffset) + } +} diff --git a/internal/app/stories/doc.go b/internal/app/stories/doc.go new file mode 100644 index 00000000..92d1f4d9 --- /dev/null +++ b/internal/app/stories/doc.go @@ -0,0 +1,2 @@ +// Package stories contains protocol-neutral story application services. +package stories diff --git a/internal/app/stories/service.go b/internal/app/stories/service.go new file mode 100644 index 00000000..ecfa824d --- /dev/null +++ b/internal/app/stories/service.go @@ -0,0 +1,560 @@ +package stories + +import ( + "context" + "strings" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// Service owns Telegram story read/watch state. +type Service struct { + stories store.StoryStore + channelAccess ChannelStoryAccess +} + +// ChannelStoryAccess validates channel story mutation rights without leaking TL +// types into the app layer. +type ChannelStoryAccess interface { + CanPostStory(ctx context.Context, userID, channelID int64) error + CanEditStory(ctx context.Context, userID, channelID int64) error + CanDeleteStory(ctx context.Context, userID, channelID int64) error + CanPinStory(ctx context.Context, userID, channelID int64) error +} + +// Option adjusts optional story service dependencies. +type Option func(*Service) + +// WithChannelStoryAccess enables channel story publishing/admin checks. +func WithChannelStoryAccess(access ChannelStoryAccess) Option { + return func(s *Service) { s.channelAccess = access } +} + +// NewService creates a story service. +func NewService(stories store.StoryStore, opts ...Option) *Service { + s := &Service{stories: stories} + for _, opt := range opts { + opt(s) + } + return s +} + +func (s *Service) CreateStory(ctx context.Context, userID int64, req domain.StoryCreateRequest) (domain.StoryCreateResult, error) { + if err := s.authorizeStoryOwner(ctx, userID, req.Owner, storyOwnerPost); err != nil { + return domain.StoryCreateResult{}, err + } + if req.RandomID == 0 { + return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid + } + if req.Media == nil { + return domain.StoryCreateResult{}, domain.ErrStoryNotFound + } + req.Period = normalizeStoryPeriod(req.Period) + if req.Period == 0 { + return domain.StoryCreateResult{}, domain.ErrStoryPeriodInvalid + } + if s == nil || s.stories == nil { + return domain.StoryCreateResult{}, domain.ErrStoryNotFound + } + return s.stories.CreateStory(ctx, req) +} + +// UpsertStory stores a story snapshot. It is used by tests now and by the +// publishing flow once sendStory/editStory are promoted from stubs. +func (s *Service) UpsertStory(ctx context.Context, req domain.UpsertStoryRequest) (domain.Story, error) { + if s == nil || s.stories == nil { + return domain.Story{}, domain.ErrStoryNotFound + } + return s.stories.UpsertStory(ctx, req) +} + +func (s *Service) GetAllStories(ctx context.Context, viewerUserID int64, hidden bool, now, limit int) (domain.StoryList, error) { + return s.GetAllStoriesPage(ctx, viewerUserID, hidden, now, domain.StoryListCursor{}, limit) +} + +func (s *Service) GetAllStoriesPage(ctx context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) (domain.StoryList, error) { + if s == nil || s.stories == nil || viewerUserID == 0 { + return domain.StoryList{}, nil + } + if cursor.Set { + if err := validateStoryListCursor(cursor); err != nil { + return domain.StoryList{}, domain.ErrStoryOffsetInvalid + } + } + return s.stories.ListActiveStoriesPage(ctx, viewerUserID, hidden, now, cursor, clampStoryLimit(limit)) +} + +func (s *Service) GetAllStoriesDigest(ctx context.Context, viewerUserID int64, hidden bool, now int) (domain.StoryListDigest, error) { + if s == nil || s.stories == nil || viewerUserID == 0 { + return domain.StoryListDigest{}, nil + } + return s.stories.ActiveStoriesDigest(ctx, viewerUserID, hidden, now) +} + +func (s *Service) ListOwnerActiveStories(ctx context.Context, userID int64, owner domain.Peer, now, limit int) (domain.StoryList, error) { + if err := validateStoryOwner(userID, owner); err != nil { + return domain.StoryList{}, err + } + if s == nil || s.stories == nil { + return domain.StoryList{}, nil + } + return s.stories.ListOwnerActiveStories(ctx, owner, now, clampStoryLimit(limit)) +} + +func (s *Service) GetPeerStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (domain.PeerStories, error) { + if s == nil || s.stories == nil || viewerUserID == 0 { + return domain.PeerStories{Peer: peer}, nil + } + return s.stories.GetPeerStories(ctx, viewerUserID, peer, now) +} + +func (s *Service) GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error) { + ids, err := normalizeStoryIDsNonEmpty(ids) + if err != nil { + return domain.StoryList{}, err + } + if s == nil || s.stories == nil || viewerUserID == 0 { + return domain.StoryList{}, nil + } + return s.stories.GetStoriesByID(ctx, viewerUserID, peer, ids, now) +} + +func (s *Service) GetStoriesArchive(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) { + if err := s.authorizeStoryOwner(ctx, viewerUserID, peer, storyOwnerEdit); err != nil { + return domain.StoryList{}, err + } + if s == nil || s.stories == nil { + return domain.StoryList{}, nil + } + return s.stories.ListStoriesArchive(ctx, viewerUserID, peer, offsetID, clampStoryArchiveLimit(limit), now) +} + +func (s *Service) GetPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) { + if s == nil || s.stories == nil || viewerUserID == 0 { + return domain.StoryList{}, nil + } + return s.stories.ListPinnedStories(ctx, viewerUserID, peer, offsetID, clampStoryLimit(limit), now) +} + +func (s *Service) HasPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) { + if s == nil || s.stories == nil || viewerUserID == 0 { + return false, nil + } + return s.stories.HasPinnedStories(ctx, viewerUserID, peer, now) +} + +func (s *Service) ListReadStates(ctx context.Context, viewerUserID int64) ([]domain.StoryReadState, error) { + if s == nil || s.stories == nil || viewerUserID == 0 { + return nil, nil + } + return s.stories.ListReadStates(ctx, viewerUserID) +} + +func (s *Service) GetPeerMaxIDs(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) { + if len(peers) > domain.MaxStoryIDs { + return nil, domain.ErrStoryIDInvalid + } + if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 { + out := make([]domain.RecentStory, len(peers)) + for i, peer := range peers { + out[i].Peer = peer + } + return out, nil + } + return s.stories.GetPeerMaxIDs(ctx, viewerUserID, peers, now) +} + +func (s *Service) GetPeerHiddenStates(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) { + if len(peers) > domain.MaxStoryIDs { + return nil, domain.ErrStoryIDInvalid + } + if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 { + return map[domain.Peer]bool{}, nil + } + return s.stories.GetPeerHiddenStates(ctx, viewerUserID, peers) +} + +func (s *Service) GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) { + if len(peers) > domain.MaxStoryIDs { + return nil, domain.ErrStoryIDInvalid + } + out := make([]domain.PeerStoryProjection, len(peers)) + for i, peer := range peers { + out[i].Peer = peer + out[i].Recent.Peer = peer + } + if s == nil || s.stories == nil || viewerUserID == 0 || len(peers) == 0 { + return out, nil + } + return s.stories.GetPeerStoryProjections(ctx, viewerUserID, peers, now) +} + +func (s *Service) ReadStories(ctx context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) { + if maxID <= 0 || maxID > domain.MaxStoryID { + return domain.StoryReadResult{}, domain.ErrStoryIDInvalid + } + if s == nil || s.stories == nil || viewerUserID == 0 { + return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, Date: date}, nil + } + recent, err := s.stories.GetPeerMaxIDs(ctx, viewerUserID, []domain.Peer{peer}, date) + if err != nil { + return domain.StoryReadResult{}, err + } + if len(recent) == 0 || recent[0].MaxID <= 0 { + return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, Date: date}, nil + } + if maxID > recent[0].MaxID { + maxID = recent[0].MaxID + } + return s.stories.MarkRead(ctx, viewerUserID, peer, maxID, date) +} + +func (s *Service) IncrementViews(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, date int) (int, error) { + ids, err := normalizeStoryIDsNonEmpty(ids) + if err != nil { + return 0, err + } + if s == nil || s.stories == nil || viewerUserID == 0 { + return 0, nil + } + return s.stories.IncrementViews(ctx, viewerUserID, peer, ids, date) +} + +func (s *Service) SendReaction(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction *domain.MessageReaction, date int) (domain.StoryReactionResult, error) { + if storyID <= 0 || storyID > domain.MaxStoryID { + return domain.StoryReactionResult{}, domain.ErrStoryIDInvalid + } + if s == nil || s.stories == nil || viewerUserID == 0 { + return domain.StoryReactionResult{ViewerID: viewerUserID, Peer: peer, StoryID: storyID, Reaction: reaction, Date: date}, nil + } + return s.stories.SetReaction(ctx, viewerUserID, peer, storyID, reaction, date) +} + +func (s *Service) GetStoryViewsList(ctx context.Context, viewerUserID int64, req domain.StoryViewListRequest) (domain.StoryViewList, error) { + if err := s.authorizeStoryOwner(ctx, viewerUserID, req.Owner, storyOwnerEdit); err != nil { + return domain.StoryViewList{}, err + } + if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID { + return domain.StoryViewList{}, domain.ErrStoryIDInvalid + } + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return domain.StoryViewList{}, err + } + if s == nil || s.stories == nil { + return domain.StoryViewList{}, nil + } + req.ViewerUserID = viewerUserID + req.Limit = clampStoryInteractionLimit(req.Limit) + req.Query = normalizeStoryViewQuery(req.Query) + return s.stories.ListStoryViews(ctx, req) +} + +func (s *Service) GetStoryReactionsList(ctx context.Context, viewerUserID int64, req domain.StoryReactionListRequest) (domain.StoryReactionList, error) { + if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID { + return domain.StoryReactionList{}, domain.ErrStoryIDInvalid + } + if req.Owner.Type == domain.PeerTypeChannel { + if viewerUserID == 0 || !req.CanViewOwnerInteractions { + return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid + } + } else { + return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid + } + if err := domain.ValidateStoryReactionInteractionOffset(req.Offset, req.ForwardsFirst); err != nil { + return domain.StoryReactionList{}, err + } + if s == nil || s.stories == nil { + return domain.StoryReactionList{}, nil + } + req.ViewerUserID = viewerUserID + req.Limit = clampStoryInteractionLimit(req.Limit) + return s.stories.ListStoryReactions(ctx, req) +} + +func (s *Service) GetStoryPublicForwards(ctx context.Context, viewerUserID int64, req domain.StoryPublicForwardListRequest) (domain.StoryPublicForwardList, error) { + if err := s.authorizeStoryOwner(ctx, viewerUserID, req.Owner, storyOwnerEdit); err != nil { + return domain.StoryPublicForwardList{}, err + } + if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID { + return domain.StoryPublicForwardList{}, domain.ErrStoryIDInvalid + } + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return domain.StoryPublicForwardList{}, err + } + if s == nil || s.stories == nil { + return domain.StoryPublicForwardList{}, nil + } + req.ViewerUserID = viewerUserID + req.Limit = clampStoryInteractionLimit(req.Limit) + return s.stories.ListStoryPublicForwards(ctx, req) +} + +func (s *Service) CanViewStoryStats(ctx context.Context, userID int64, peer domain.Peer) error { + return s.authorizeStoryOwner(ctx, userID, peer, storyOwnerEdit) +} + +func (s *Service) ListStoryViewerIDs(ctx context.Context, userID int64, owner domain.Peer, storyID, limit int) ([]int64, error) { + if err := validateStoryOwner(userID, owner); err != nil { + return nil, err + } + if storyID <= 0 || storyID > domain.MaxStoryID { + return nil, domain.ErrStoryIDInvalid + } + if s == nil || s.stories == nil { + return nil, nil + } + return s.stories.ListStoryViewerIDs(ctx, owner, storyID, clampStoryPrivacyFanoutLimit(limit)) +} + +func (s *Service) EditStory(ctx context.Context, userID int64, req domain.StoryEditRequest) (domain.StoryEditResult, error) { + if err := s.authorizeStoryOwner(ctx, userID, req.Owner, storyOwnerEdit); err != nil { + return domain.StoryEditResult{}, err + } + if req.ID <= 0 || req.ID > domain.MaxStoryID { + return domain.StoryEditResult{}, domain.ErrStoryIDInvalid + } + if !req.UpdateMedia && !req.UpdateCaption && !req.UpdatePrivacy && !req.UpdateMediaAreas { + return domain.StoryEditResult{}, domain.ErrStoryNotModified + } + if req.UpdateMedia && req.Media == nil { + return domain.StoryEditResult{}, domain.ErrStoryNotFound + } + if s == nil || s.stories == nil { + return domain.StoryEditResult{}, domain.ErrStoryNotFound + } + return s.stories.EditStory(ctx, req) +} + +func (s *Service) DeleteStories(ctx context.Context, userID int64, peer domain.Peer, ids []int, date int) (domain.StoryMutationResult, error) { + if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerDelete); err != nil { + return domain.StoryMutationResult{}, err + } + ids, err := normalizeStoryIDsNonEmpty(ids) + if err != nil { + return domain.StoryMutationResult{}, err + } + if s == nil || s.stories == nil { + return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)}, nil + } + return s.stories.DeleteStories(ctx, peer, ids, date) +} + +func (s *Service) TogglePinned(ctx context.Context, userID int64, peer domain.Peer, ids []int, pinned bool, date int) (domain.StoryMutationResult, error) { + if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerPin); err != nil { + return domain.StoryMutationResult{}, err + } + ids, err := normalizeStoryIDs(ids) + if err != nil { + return domain.StoryMutationResult{}, err + } + if s == nil || s.stories == nil { + return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)}, nil + } + return s.stories.TogglePinned(ctx, peer, ids, pinned, date) +} + +func (s *Service) TogglePinnedToTop(ctx context.Context, userID int64, peer domain.Peer, ids []int) error { + if err := s.authorizeStoryOwner(ctx, userID, peer, storyOwnerPin); err != nil { + return err + } + ids, err := normalizeStoryPinnedToTopIDs(ids) + if err != nil { + return err + } + if s == nil || s.stories == nil { + return nil + } + return s.stories.TogglePinnedToTop(ctx, peer, ids) +} + +func (s *Service) TogglePeerStoriesHidden(ctx context.Context, viewerUserID int64, peer domain.Peer, hidden bool) error { + if s == nil || s.stories == nil || viewerUserID == 0 { + return nil + } + return s.stories.SetPeerHidden(ctx, viewerUserID, peer, hidden) +} + +func (s *Service) CanSendStory(ctx context.Context, viewerUserID int64, peer domain.Peer) (int, error) { + if err := s.authorizeStoryOwner(ctx, viewerUserID, peer, storyOwnerPost); err != nil { + return 0, err + } + return domain.DefaultStoryCanSendRemaining, nil +} + +type storyOwnerAction int + +const ( + storyOwnerPost storyOwnerAction = iota + storyOwnerEdit + storyOwnerDelete + storyOwnerPin +) + +func (s *Service) authorizeStoryOwner(ctx context.Context, userID int64, peer domain.Peer, action storyOwnerAction) error { + if userID == 0 { + return domain.ErrStoryPeerInvalid + } + switch peer.Type { + case domain.PeerTypeUser: + if peer.ID == userID { + return nil + } + case domain.PeerTypeChannel: + if peer.ID == 0 || s == nil || s.channelAccess == nil { + return domain.ErrStoryPeerInvalid + } + switch action { + case storyOwnerPost: + return s.channelAccess.CanPostStory(ctx, userID, peer.ID) + case storyOwnerEdit: + return s.channelAccess.CanEditStory(ctx, userID, peer.ID) + case storyOwnerDelete: + return s.channelAccess.CanDeleteStory(ctx, userID, peer.ID) + case storyOwnerPin: + return s.channelAccess.CanPinStory(ctx, userID, peer.ID) + } + } + return domain.ErrStoryPeerInvalid +} + +func validateStoryIDs(ids []int) error { + if len(ids) > domain.MaxStoryIDs { + return domain.ErrStoryIDInvalid + } + for _, id := range ids { + if id <= 0 || id > domain.MaxStoryID { + return domain.ErrStoryIDInvalid + } + } + return nil +} + +func validateStoryIDsNonEmpty(ids []int) error { + if len(ids) == 0 { + return domain.ErrStoryIDInvalid + } + return validateStoryIDs(ids) +} + +func normalizeStoryIDsNonEmpty(ids []int) ([]int, error) { + if err := validateStoryIDsNonEmpty(ids); err != nil { + return nil, err + } + return normalizeStoryIDsUnchecked(ids), nil +} + +func normalizeStoryIDs(ids []int) ([]int, error) { + if err := validateStoryIDs(ids); err != nil { + return nil, err + } + return normalizeStoryIDsUnchecked(ids), nil +} + +func normalizeStoryPinnedToTopIDs(ids []int) ([]int, error) { + ids, err := normalizeStoryIDs(ids) + if err != nil { + return nil, err + } + if len(ids) > domain.MaxStoryPinnedToTop { + return nil, domain.ErrStoryIDInvalid + } + return ids, nil +} + +func normalizeStoryIDsUnchecked(ids []int) []int { + seen := make(map[int]struct{}, len(ids)) + out := make([]int, 0, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func validateStoryOwner(userID int64, peer domain.Peer) error { + if userID == 0 { + return domain.ErrStoryPeerInvalid + } + if peer.Type == domain.PeerTypeUser && peer.ID == userID { + return nil + } + return domain.ErrStoryPeerInvalid +} + +func validateStoryListCursor(cursor domain.StoryListCursor) error { + if !cursor.Set { + return nil + } + if cursor.Date <= 0 || cursor.Peer.ID <= 0 { + return domain.ErrStoryOffsetInvalid + } + switch cursor.Peer.Type { + case domain.PeerTypeUser, domain.PeerTypeChannel: + return nil + default: + return domain.ErrStoryOffsetInvalid + } +} + +func normalizeStoryPeriod(period int) int { + if period == 0 { + return domain.DefaultStoryPeriod + } + if validStoryPeriod(period) { + return period + } + return 0 +} + +func validStoryPeriod(period int) bool { + switch period { + case 6 * 3600, 12 * 3600, domain.DefaultStoryPeriod, 2 * domain.DefaultStoryPeriod: + return true + default: + return false + } +} + +func clampStoryLimit(limit int) int { + if limit <= 0 || limit > domain.MaxStoryListLimit { + return domain.MaxStoryListLimit + } + return limit +} + +func clampStoryArchiveLimit(limit int) int { + if limit == 0 { + return 0 + } + if limit < 0 || limit > domain.MaxStoryListLimit { + return domain.MaxStoryListLimit + } + return limit +} + +func clampStoryInteractionLimit(limit int) int { + if limit <= 0 || limit > domain.MaxStoryInteractionListLimit { + return domain.MaxStoryInteractionListLimit + } + return limit +} + +func clampStoryPrivacyFanoutLimit(limit int) int { + if limit <= 0 || limit > domain.MaxStoryPrivacyFanoutTargets { + return domain.MaxStoryPrivacyFanoutTargets + } + return limit +} + +func normalizeStoryViewQuery(query string) string { + query = strings.TrimSpace(query) + runes := []rune(query) + if len(runes) > domain.MaxStoryViewQueryLength { + return string(runes[:domain.MaxStoryViewQueryLength]) + } + return query +} diff --git a/internal/app/stories/service_test.go b/internal/app/stories/service_test.go new file mode 100644 index 00000000..369475c6 --- /dev/null +++ b/internal/app/stories/service_test.go @@ -0,0 +1,313 @@ +package stories + +import ( + "context" + "errors" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestReadStoriesClampsFutureMaxIDToVisibleActiveStory(t *testing.T) { + ctx := context.Background() + store := memory.NewStoryStore() + service := NewService(store) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + viewerID := int64(2001) + for _, id := range []int{1, 2} { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: id, + Date: 100 + id, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %d: %v", id, err) + } + } + + read, err := service.ReadStories(ctx, viewerID, owner, 99, 200) + if err != nil { + t.Fatalf("read stories: %v", err) + } + if !read.Advanced || read.MaxReadID != 2 { + t.Fatalf("read = %+v, want advanced max 2", read) + } + + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 3, + Date: 300, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert future story: %v", err) + } + peerStories, err := store.GetPeerStories(ctx, viewerID, owner, 400) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + if peerStories.MaxReadID != 2 || len(peerStories.Stories) != 3 { + t.Fatalf("peer stories = %+v, want max read 2 and three active stories", peerStories) + } +} + +func TestReadStoriesWithoutVisibleActiveStoryDoesNotWriteFutureBoundary(t *testing.T) { + ctx := context.Background() + store := memory.NewStoryStore() + service := NewService(store) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + viewerID := int64(2001) + + read, err := service.ReadStories(ctx, viewerID, owner, 99, 200) + if err != nil { + t.Fatalf("read stories: %v", err) + } + if read.Advanced || read.MaxReadID != 0 { + t.Fatalf("read = %+v, want no-op zero boundary", read) + } + states, err := store.ListReadStates(ctx, viewerID) + if err != nil { + t.Fatalf("list read states: %v", err) + } + if len(states) != 0 { + t.Fatalf("read states = %+v, want none", states) + } + + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 300, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story after no-op read: %v", err) + } + peerStories, err := store.GetPeerStories(ctx, viewerID, owner, 400) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + if peerStories.MaxReadID != 0 || len(peerStories.Stories) != 1 { + t.Fatalf("peer stories = %+v, want unread first story", peerStories) + } +} + +func TestReadStoriesWithoutStoreIsNoop(t *testing.T) { + ctx := context.Background() + service := NewService(nil) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + + read, err := service.ReadStories(ctx, 2001, owner, 99, 200) + if err != nil { + t.Fatalf("read stories without store: %v", err) + } + if read.Advanced || read.MaxReadID != 0 || read.Peer != owner || read.ViewerID != 2001 { + t.Fatalf("read without store = %+v, want no-op zero boundary for viewer/peer", read) + } +} + +func TestGetStoryReactionsListRequiresChannelInteractionGrant(t *testing.T) { + ctx := context.Background() + store := memory.NewStoryStore() + service := NewService(store) + owner := domain.Peer{Type: domain.PeerTypeChannel, ID: 1001} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 100, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + if _, err := store.SetReaction(ctx, 2001, owner, 1, &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "🔥", + }, 110); err != nil { + t.Fatalf("set reaction: %v", err) + } + + _, err := service.GetStoryReactionsList(ctx, 3001, domain.StoryReactionListRequest{ + Owner: owner, + StoryID: 1, + Limit: 10, + }) + if !errors.Is(err, domain.ErrStoryPeerInvalid) { + t.Fatalf("ungranted channel reaction list err = %v, want ErrStoryPeerInvalid", err) + } + + list, err := service.GetStoryReactionsList(ctx, 3001, domain.StoryReactionListRequest{ + Owner: owner, + StoryID: 1, + Limit: 10, + CanViewOwnerInteractions: true, + }) + if err != nil { + t.Fatalf("granted channel reaction list: %v", err) + } + if list.Count != 1 || len(list.Reactions) != 1 || list.Reactions[0].ViewerID != 2001 { + t.Fatalf("reaction list = %+v, want viewer 2001", list) + } +} + +func TestGetStoryReactionsListRejectsUserPeer(t *testing.T) { + ctx := context.Background() + service := NewService(memory.NewStoryStore()) + _, err := service.GetStoryReactionsList(ctx, 1001, domain.StoryReactionListRequest{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, + StoryID: 1, + Limit: 10, + CanViewOwnerInteractions: true, + }) + if !errors.Is(err, domain.ErrStoryPeerInvalid) { + t.Fatalf("user story reaction list err = %v, want ErrStoryPeerInvalid", err) + } +} + +func TestStoryInteractionListsRejectMalformedOffsets(t *testing.T) { + ctx := context.Background() + service := NewService(nil) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + if _, err := service.GetStoryViewsList(ctx, owner.ID, domain.StoryViewListRequest{ + Owner: owner, + StoryID: 1, + Offset: "bad", + Limit: 10, + }); !errors.Is(err, domain.ErrStoryOffsetInvalid) { + t.Fatalf("bad views offset err = %v, want ErrStoryOffsetInvalid", err) + } + if _, err := service.GetStoryViewsList(ctx, owner.ID, domain.StoryViewListRequest{ + Owner: owner, + StoryID: 1, + Offset: strings.Repeat("9", domain.MaxStoryInteractionOffsetLength+1), + Limit: 10, + }); !errors.Is(err, domain.ErrStoryOffsetInvalid) { + t.Fatalf("oversized views offset err = %v, want ErrStoryOffsetInvalid", err) + } + if _, err := service.GetStoryReactionsList(ctx, 2001, domain.StoryReactionListRequest{ + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + StoryID: 1, + Offset: "1:1700000000:2001", + Limit: 10, + CanViewOwnerInteractions: true, + }); !errors.Is(err, domain.ErrStoryOffsetInvalid) { + t.Fatalf("reaction group offset err = %v, want ErrStoryOffsetInvalid", err) + } + if _, err := service.GetStoryReactionsList(ctx, 2001, domain.StoryReactionListRequest{ + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, + StoryID: 1, + Offset: "1:1700000000:2001", + Limit: 10, + ForwardsFirst: true, + CanViewOwnerInteractions: true, + }); err != nil { + t.Fatalf("reaction forwards-first group offset err = %v, want nil", err) + } +} + +func TestGetStoriesByIDDedupesAndRejectsEmptyIDs(t *testing.T) { + ctx := context.Background() + store := memory.NewStoryStore() + service := NewService(store) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 3, + Date: 100, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + + list, err := service.GetStoriesByID(ctx, owner.ID, owner, []int{3, 3}, 200) + if err != nil { + t.Fatalf("get stories by duplicate ids: %v", err) + } + if len(list.Stories) != 1 || list.Stories[0].ID != 3 { + t.Fatalf("stories = %+v, want one deduped story", list.Stories) + } + + if _, err := service.GetStoriesByID(ctx, owner.ID, owner, nil, 200); !errors.Is(err, domain.ErrStoryIDInvalid) { + t.Fatalf("empty get stories by id err = %v, want ErrStoryIDInvalid", err) + } +} + +func TestIncrementViewsDedupeAndRejectsEmptyIDs(t *testing.T) { + ctx := context.Background() + store := memory.NewStoryStore() + service := NewService(store) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 7, + Date: 100, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + + created, err := service.IncrementViews(ctx, 2001, owner, []int{7, 7}, 200) + if err != nil { + t.Fatalf("increment views duplicate ids: %v", err) + } + if created != 1 { + t.Fatalf("created views = %d, want one deduped view", created) + } + + if _, err := service.IncrementViews(ctx, 2001, owner, nil, 200); !errors.Is(err, domain.ErrStoryIDInvalid) { + t.Fatalf("empty increment views err = %v, want ErrStoryIDInvalid", err) + } +} + +func TestStoryMutationsDedupeIDsBeforeStore(t *testing.T) { + ctx := context.Background() + service := NewService(nil) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + + pinned, err := service.TogglePinned(ctx, owner.ID, owner, []int{3, 3, 4}, true, 100) + if err != nil { + t.Fatalf("toggle pinned: %v", err) + } + if got, want := pinned.IDs, []int{3, 4}; !intSlicesEqual(got, want) { + t.Fatalf("pinned ids = %v, want %v", got, want) + } + + emptyPinned, err := service.TogglePinned(ctx, owner.ID, owner, nil, true, 100) + if err != nil { + t.Fatalf("empty toggle pinned: %v", err) + } + if len(emptyPinned.IDs) != 0 || len(emptyPinned.Stories) != 0 { + t.Fatalf("empty pinned = %+v, want no-op", emptyPinned) + } + + deleted, err := service.DeleteStories(ctx, owner.ID, owner, []int{4, 3, 4}, 101) + if err != nil { + t.Fatalf("delete stories: %v", err) + } + if got, want := deleted.IDs, []int{4, 3}; !intSlicesEqual(got, want) { + t.Fatalf("deleted ids = %v, want %v", got, want) + } + + if err := service.TogglePinnedToTop(ctx, owner.ID, owner, []int{4, 3, 4}); err != nil { + t.Fatalf("toggle pinned to top deduped ids: %v", err) + } + if err := service.TogglePinnedToTop(ctx, owner.ID, owner, []int{1, 2, 3, 4}); !errors.Is(err, domain.ErrStoryIDInvalid) { + t.Fatalf("oversized pinned-to-top err = %v, want ErrStoryIDInvalid", err) + } +} + +func intSlicesEqual(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/app/themes/service.go b/internal/app/themes/service.go new file mode 100644 index 00000000..59877a80 --- /dev/null +++ b/internal/app/themes/service.go @@ -0,0 +1,230 @@ +// Package themes 实现自定义云主题(account.createTheme/updateTheme/getTheme/ +// saveTheme/installTheme)的业务编排:分配 id/access_hash/slug、校验创建者、维护每用户 +// 已安装列表。文件上传(uploadTheme)由 rpc 层直接经 Files 服务完成,不经此服务。 +package themes + +import ( + "context" + "crypto/rand" + "encoding/binary" + "strings" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// 自定义主题 id/access_hash 用 crypto 随机 63 位,刻意区别于静态目录的保留区间 +// (静态 emoji 主题用 92e16/93e16),避免 install/get-by-id 串到静态目录。 +const defaultSlugPrefix = "t-" + +// slug 字母表:小写 + 数字,URL 安全。 +const slugAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + +// Service 提供自定义云主题业务。 +type Service struct { + store store.ThemeStore + now func() time.Time + slugPrefix string +} + +// Option 调整主题服务可选项。 +type Option func(*Service) + +// WithClock 注入时钟(测试用)。 +func WithClock(now func() time.Time) Option { + return func(s *Service) { + if now != nil { + s.now = now + } + } +} + +// WithSlugPrefix 覆盖自动分配 slug 的前缀。 +func WithSlugPrefix(prefix string) Option { + return func(s *Service) { s.slugPrefix = prefix } +} + +// NewService 创建主题服务。 +func NewService(st store.ThemeStore, opts ...Option) *Service { + s := &Service{store: st, now: time.Now, slugPrefix: defaultSlugPrefix} + for _, opt := range opts { + opt(s) + } + return s +} + +func (s *Service) ready() bool { return s != nil && s.store != nil } + +// Create 创建一份新主题。spec.Slug 为空时自动分配;slug 冲突自动重试。 +func (s *Service) Create(ctx context.Context, spec domain.ThemeSpec) (domain.Theme, error) { + if !s.ready() { + return domain.Theme{}, domain.ErrThemeInvalid + } + if spec.CreatorUserID == 0 { + return domain.Theme{}, domain.ErrThemeInvalid + } + userSlug := strings.TrimSpace(spec.Slug) + // 最多重试若干次(自动 slug/极小概率 id 冲突)。 + for attempt := 0; attempt < 8; attempt++ { + t := domain.Theme{ + ID: randInt63(), + AccessHash: randInt63(), + CreatorUserID: spec.CreatorUserID, + Title: spec.Title, + Emoticon: spec.Emoticon, + ForChat: spec.ForChat, + DocumentID: spec.DocumentID, + Settings: spec.Settings, + InstallsCount: 0, + CreatedAt: s.now().Unix(), + } + if userSlug != "" { + t.Slug = userSlug + } else { + t.Slug = s.slugPrefix + randSlug(12) + } + err := s.store.CreateTheme(ctx, t) + switch { + case err == nil: + return t.Clone(), nil + case err == domain.ErrThemeSlugTaken && userSlug != "": + // 用户指定的 slug 已被占用:不重试,直接报错。 + return domain.Theme{}, domain.ErrThemeSlugTaken + case err == domain.ErrThemeSlugTaken: + continue // 自动 slug/id 冲突:换一组重试 + default: + return domain.Theme{}, err + } + } + return domain.Theme{}, domain.ErrThemeSlugTaken +} + +func (s *Service) resolve(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) { + if ref.ID != 0 { + return s.store.GetThemeByID(ctx, ref.ID) + } + if ref.Slug != "" { + return s.store.GetThemeBySlug(ctx, ref.Slug) + } + return domain.Theme{}, false, nil +} + +// Get 按 id 或 slug 解析一份主题。 +func (s *Service) Get(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) { + if !s.ready() { + return domain.Theme{}, false, domain.ErrThemeInvalid + } + return s.resolve(ctx, ref) +} + +// Update 更新一份主题(仅创建者可改);部分字段。 +func (s *Service) Update(ctx context.Context, userID int64, ref domain.ThemeRef, upd domain.ThemeUpdate) (domain.Theme, error) { + if !s.ready() { + return domain.Theme{}, domain.ErrThemeInvalid + } + t, ok, err := s.resolve(ctx, ref) + if err != nil { + return domain.Theme{}, err + } + if !ok { + return domain.Theme{}, domain.ErrThemeNotFound + } + if !t.IsCreator(userID) { + return domain.Theme{}, domain.ErrThemeInvalid + } + if upd.Slug != nil { + t.Slug = strings.TrimSpace(*upd.Slug) + } + if upd.Title != nil { + t.Title = *upd.Title + } + if upd.DocumentID != nil { + t.DocumentID = *upd.DocumentID + } + if upd.Settings != nil { + t.Settings = *upd.Settings + } + if err := s.store.UpdateTheme(ctx, t); err != nil { + return domain.Theme{}, err + } + return t.Clone(), nil +} + +// Save 把主题加入用户已存列表(saveTheme,unsave=false)。 +func (s *Service) Save(ctx context.Context, userID int64, ref domain.ThemeRef) error { + return s.membership(ctx, userID, ref, false, false) +} + +// Unsave 从用户列表移除(saveTheme,unsave=true)。 +func (s *Service) Unsave(ctx context.Context, userID int64, ref domain.ThemeRef) error { + if !s.ready() { + return domain.ErrThemeInvalid + } + t, ok, err := s.resolve(ctx, ref) + if err != nil { + return err + } + if !ok { + return domain.ErrThemeInvalid + } + return s.store.RemoveInstalled(ctx, userID, t.ID) +} + +// Install 应用主题:计数 +1 并加入用户已安装列表。 +func (s *Service) Install(ctx context.Context, userID int64, ref domain.ThemeRef, dark bool) error { + return s.membership(ctx, userID, ref, true, dark) +} + +func (s *Service) membership(ctx context.Context, userID int64, ref domain.ThemeRef, increment, dark bool) error { + if !s.ready() { + return domain.ErrThemeInvalid + } + t, ok, err := s.resolve(ctx, ref) + if err != nil { + return err + } + if !ok { + return domain.ErrThemeInvalid + } + if increment { + if err := s.store.IncrementInstalls(ctx, t.ID); err != nil { + return err + } + } + return s.store.SetInstalled(ctx, userID, t.ID, dark) +} + +// ListInstalled 返回用户已安装/已存主题。 +func (s *Service) ListInstalled(ctx context.Context, userID int64) ([]domain.Theme, error) { + if !s.ready() { + return nil, domain.ErrThemeInvalid + } + return s.store.ListInstalledByUser(ctx, userID) +} + +// ListForUser 返回用户「创建 ∪ 安装」的主题,供 getThemes 跨设备同步。 +func (s *Service) ListForUser(ctx context.Context, userID int64) ([]domain.Theme, error) { + if !s.ready() { + return nil, domain.ErrThemeInvalid + } + return s.store.ListThemesForUser(ctx, userID) +} + +// randInt63 返回一个 crypto 随机的正 63 位整数。 +func randInt63() int64 { + var b [8]byte + _, _ = rand.Read(b[:]) + return int64(binary.BigEndian.Uint64(b[:]) & 0x7fffffffffffffff) +} + +// randSlug 返回 n 个字符的随机 slug 片段。 +func randSlug(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + out := make([]byte, n) + for i := range out { + out[i] = slugAlphabet[int(b[i])%len(slugAlphabet)] + } + return string(out) +} diff --git a/internal/app/themes/service_test.go b/internal/app/themes/service_test.go new file mode 100644 index 00000000..9557dcd7 --- /dev/null +++ b/internal/app/themes/service_test.go @@ -0,0 +1,72 @@ +package themes + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestServiceCreateAutoSlugAndCreatorGuard(t *testing.T) { + ctx := context.Background() + svc := NewService(memory.NewThemeStore()) + + const owner = int64(1001) + a, err := svc.Create(ctx, domain.ThemeSpec{CreatorUserID: owner, Title: "A", DocumentID: 50}) + if err != nil { + t.Fatalf("create a: %v", err) + } + if a.Slug == "" || a.ID == 0 || a.AccessHash == 0 { + t.Fatalf("create a = %+v, want auto slug + ids", a) + } + if !a.IsCreator(owner) { + t.Fatalf("creator mismatch") + } + + // 两次空 slug 创建得到不同的自动 slug。 + b, err := svc.Create(ctx, domain.ThemeSpec{CreatorUserID: owner, Title: "B"}) + if err != nil { + t.Fatalf("create b: %v", err) + } + if b.Slug == a.Slug { + t.Fatalf("auto slugs collided: %q", a.Slug) + } + + // 非创建者不能改。 + if _, err := svc.Update(ctx, 2002, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: strptr("hacked")}); !errors.Is(err, domain.ErrThemeInvalid) { + t.Fatalf("non-creator update err = %v, want ErrThemeInvalid", err) + } + + // 创建者可改 title + document。 + newTitle := "A2" + newDoc := int64(99) + updated, err := svc.Update(ctx, owner, domain.ThemeRef{ID: a.ID}, domain.ThemeUpdate{Title: &newTitle, DocumentID: &newDoc}) + if err != nil { + t.Fatalf("creator update: %v", err) + } + if updated.Title != "A2" || updated.DocumentID != 99 { + t.Fatalf("update result = %+v, want title A2 doc 99", updated) + } + + // install 计数 + 列表。 + if err := svc.Install(ctx, owner, domain.ThemeRef{ID: a.ID}, true); err != nil { + t.Fatalf("install: %v", err) + } + got, ok, _ := svc.Get(ctx, domain.ThemeRef{Slug: a.Slug}) + if !ok || got.InstallsCount != 1 { + t.Fatalf("after install installs = %d ok=%v, want 1", got.InstallsCount, ok) + } + list, _ := svc.ListInstalled(ctx, owner) + if len(list) != 1 || list[0].ID != a.ID { + t.Fatalf("installed list = %+v, want [a]", list) + } + + // 未知引用 → ErrThemeInvalid。 + if err := svc.Install(ctx, owner, domain.ThemeRef{Slug: "nope"}, false); !errors.Is(err, domain.ErrThemeInvalid) { + t.Fatalf("install unknown err = %v, want ErrThemeInvalid", err) + } +} + +func strptr(s string) *string { return &s } diff --git a/internal/app/updates/contiguous_test.go b/internal/app/updates/contiguous_test.go index 0cb82824..b7be9f18 100644 --- a/internal/app/updates/contiguous_test.go +++ b/internal/app/updates/contiguous_test.go @@ -27,7 +27,7 @@ func seedEvent(t *testing.T, events *memory.UpdateEventStore, userID int64, pts } // TestGetDifferenceStopsAtHole:getDifference 只返回从 from 起连续的事件,遇在途空洞即截断, -// State.Pts 取最后连续值;补洞后下次拉取可继续,绝不跳过空洞。 +// State.Pts 取最后连续值;后续事件到齐后下次拉取可继续,绝不跳过空洞。 func TestGetDifferenceStopsAtHole(t *testing.T) { ctx := context.Background() var authKeyID [8]byte @@ -57,7 +57,7 @@ func TestGetDifferenceStopsAtHole(t *testing.T) { t.Fatalf("GetDifference after fill: %v", err) } if len(diff.Events) != 3 || diff.State.Pts != 6 { - t.Fatalf("补洞后 diff = %d events, state.pts %d; want 4,5,6 到 pts=6", len(diff.Events), diff.State.Pts) + t.Fatalf("events after gap filled = %d, state.pts %d; want 4,5,6 到 pts=6", len(diff.Events), diff.State.Pts) } } diff --git a/internal/app/updates/service.go b/internal/app/updates/service.go index 7ef72763..d05af621 100644 --- a/internal/app/updates/service.go +++ b/internal/app/updates/service.go @@ -5,6 +5,8 @@ import ( "sort" "time" + "go.uber.org/zap" + "telesrv/internal/domain" "telesrv/internal/store" ) @@ -13,20 +15,20 @@ import ( type Service struct { states store.UpdateStateStore events store.UpdateEventStore - pts store.PtsAllocator + log *zap.Logger } type dispatchingEventAppender interface { - AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error + AppendAllocatedWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, error) } // ServiceOption 调整 updates 服务的运行时依赖。 type ServiceOption func(*Service) -// WithPtsAllocator 使用外部 pts 分配器推进账号级 pts。 -func WithPtsAllocator(pts store.PtsAllocator) ServiceOption { +// WithLogger 注入 update 状态机日志器,用于追踪 pts 分配、append 失败和 difference gap。 +func WithLogger(log *zap.Logger) ServiceOption { return func(s *Service) { - s.pts = pts + s.log = log } } @@ -37,6 +39,9 @@ func NewService(states store.UpdateStateStore, events store.UpdateEventStore, op for _, opt := range opts { opt(s) } + if s.log == nil { + s.log = zap.NewNop() + } return s } @@ -90,13 +95,31 @@ func (s *Service) CurrentState(ctx context.Context, userID int64) (domain.Update return s.currentState(ctx, userID) } +// AcknowledgeCurrentState 返回账号当前最大连续状态,并把该设备的确认水位推进到此。 +// +// 供 updates.getState 使用:协议语义是客户端宣告「从现在开始同步」,启动期的 +// 离线数据由 getDialogs 快照承载(TDesktop 不持久化 pts,每次启动都走此路径)。 +// 若改为返回设备旧确认水位,客户端会在 getDialogs 最新快照之上再重放历史差分, +// 造成未读重复累计、dialog 预览被旧消息抢占。持久化 pts 的客户端(Android) +// 启动时直接带本地 pts 调 getDifference,不经过 getState,不受影响。 +func (s *Service) AcknowledgeCurrentState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) { + st, err := s.currentState(ctx, userID) + if err != nil { + return domain.UpdateState{}, err + } + if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil { + return domain.UpdateState{}, err + } + return st, nil +} + // getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。 const getDifferenceLimit = 100 // GetDifference 返回当前 user 从 from 状态之后的增量事件。 // // 对齐 MTProto:只返回从 from.Pts 起「连续」的事件(遇空洞即截断),State.Pts 取最后连续值, -// 绝不让客户端跳过在途空洞而丢消息——空洞由并发发送的在途事务造成,提交/补洞后客户端下次拉取即可补齐。 +// 绝不让客户端跳过异常空洞而丢消息;正常写路径在 PG 事务内推进 pts 和 durable event。 // 连续事件填满 limit 时置 Partial(映射 differenceSlice),客户端据返回 State 继续翻页。 func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateDifference, error) { st, err := s.currentState(ctx, userID) @@ -116,11 +139,30 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i if err != nil { return domain.UpdateDifference{}, err } - contiguous := contiguousPrefix(events, from.Pts) + contiguous, gapEvent, expectedPts := contiguousPrefixAndGap(events, from.Pts) last := from.Pts if len(contiguous) > 0 { last = contiguous[len(contiguous)-1].Pts } + if gapEvent != nil { + ptsCount := gapEvent.PtsCount + if ptsCount <= 0 { + ptsCount = 1 + } + s.log.Warn("difference_stopped_at_gap", + zap.String("scope", "user"), + zap.Int64("user_id", userID), + zap.Int("request_pts", from.Pts), + zap.Int("current_pts", st.Pts), + zap.Int("returned_pts", last), + zap.Int("expected_pts", expectedPts), + zap.Int("got_pts", gapEvent.Pts), + zap.Int("got_pts_count", ptsCount), + zap.String("event_type", string(gapEvent.Type)), + zap.Int("events_read", len(events)), + zap.Int("events_returned", len(contiguous)), + ) + } out := st out.Pts = last out.Seq = 0 // seq 恒 0,见 GetState 注释 @@ -160,8 +202,13 @@ func (s *Service) saveConfirmedState(ctx context.Context, authKeyID [8]byte, use // contiguousPrefix 返回从 from 起 pts 严格连续(from+1, from+2, ...)的事件前缀。 // 先按 pts 升序排序以兼容存储返回顺序,遇到空洞即停。 func contiguousPrefix(events []domain.UpdateEvent, from int) []domain.UpdateEvent { + out, _, _ := contiguousPrefixAndGap(events, from) + return out +} + +func contiguousPrefixAndGap(events []domain.UpdateEvent, from int) ([]domain.UpdateEvent, *domain.UpdateEvent, int) { if len(events) == 0 { - return nil + return nil, nil, 0 } sorted := make([]domain.UpdateEvent, len(events)) copy(sorted, events) @@ -173,13 +220,15 @@ func contiguousPrefix(events []domain.UpdateEvent, from int) []domain.UpdateEven if ptsCount <= 0 { ptsCount = 1 } - if event.Pts != cursor+ptsCount { - break + expected := cursor + ptsCount + if event.Pts != expected { + gap := event + return out, &gap, expected } out = append(out, event) cursor = event.Pts } - return out + return out, nil, 0 } // ClearAuthKey 清理某 auth_key 的设备状态。 @@ -232,6 +281,142 @@ func (s *Service) RecordMessageReactions(ctx context.Context, authKeyID [8]byte, }) } +// RecordMessagePoll records a durable marker for message poll state changes +// (vote / close). updateMessagePoll has no pts fields in Layer 225 — same +// bookkeeping shape as RecordMessageReactions. +func (s *Service) RecordMessagePoll(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) { + if userID == 0 { + userID = msg.OwnerUserID + } + date := msg.Date + if date == 0 { + date = int(time.Now().Unix()) + } + return s.recordEventWithoutState(ctx, userID, domain.UpdateEvent{ + Type: domain.UpdateEventMessagePoll, + Date: date, + Message: msg, + Peer: msg.Peer, + PtsCount: 1, + }) +} + +// RecordStory records a story snapshot change for offline difference replay. +func (s *Service) RecordStory(ctx context.Context, authKeyID [8]byte, userID int64, story domain.Story, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + if userID == 0 && story.Owner.Type == domain.PeerTypeUser { + userID = story.Owner.ID + } + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventStory, + Date: story.Date, + Peer: story.Owner, + Story: story, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordStoryFanout records a story visibility change for a user who did not +// initiate the RPC that caused it. It writes durable updates/outbox but does +// not acknowledge any device-local update state. +func (s *Service) RecordStoryFanout(ctx context.Context, userID int64, story domain.Story) (domain.UpdateEvent, domain.UpdateState, error) { + if userID == 0 { + return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStoryPeerInvalid + } + return s.recordEventCore(ctx, [8]byte{}, userID, domain.UpdateEvent{ + Type: domain.UpdateEventStory, + Date: story.Date, + Peer: story.Owner, + Story: story, + PtsCount: 1, + }, true, 0, false) +} + +// RecordReadStories records a read boundary update for multi-device sync. +func (s *Service) RecordReadStories(ctx context.Context, authKeyID [8]byte, userID int64, read domain.StoryReadResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + if userID == 0 { + userID = read.ViewerID + } + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventReadStories, + Date: read.Date, + Peer: read.Peer, + MaxID: read.MaxReadID, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordSentStoryReaction records the current user's story reaction for multi-device sync. +func (s *Service) RecordSentStoryReaction(ctx context.Context, authKeyID [8]byte, userID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + if userID == 0 { + userID = reaction.ViewerID + } + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventSentStoryReaction, + Date: reaction.Date, + Peer: reaction.Peer, + MaxID: reaction.StoryID, + Story: reaction.Story, + Reaction: reaction.Reaction, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordNewStoryReaction records the story owner's notification for a reaction +// sent by another user. It does not advance any owner device confirmation state: +// the owner did not initiate the RPC, but online outbox and offline difference +// must still see the durable event. +func (s *Service) RecordNewStoryReaction(ctx context.Context, authKeyID [8]byte, ownerUserID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + if ownerUserID == 0 && reaction.Story.Owner.Type == domain.PeerTypeUser { + ownerUserID = reaction.Story.Owner.ID + } + if ownerUserID == 0 && reaction.Peer.Type == domain.PeerTypeUser { + ownerUserID = reaction.Peer.ID + } + if ownerUserID == 0 || reaction.ViewerID == 0 || reaction.Reaction == nil { + return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrStoryPeerInvalid + } + return s.recordEventCore(ctx, authKeyID, ownerUserID, domain.UpdateEvent{ + Type: domain.UpdateEventNewStoryReaction, + Date: reaction.Date, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: reaction.ViewerID}, + MaxID: reaction.StoryID, + Story: reaction.Story, + Reaction: reaction.Reaction, + PtsCount: 1, + }, true, excludeSessionID, false) +} + +// RecordQuickReplyMutation records account-local quick reply state changes for +// multi-device sync. Quick-reply TL updates do not carry pts, so outbox appends +// auxiliary pts bookkeeping just like other account settings events. +func (s *Service) RecordQuickReplyMutation(ctx context.Context, authKeyID [8]byte, userID int64, mutation domain.QuickReplyMutation, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + if userID == 0 { + userID = mutation.List.OwnerUserID + } + event := domain.UpdateEvent{ + Date: mutation.Date, + PtsCount: 1, + QuickReplies: append([]domain.QuickReply(nil), mutation.List.QuickReplies...), + QuickReply: mutation.QuickReply, + QuickReplyMessage: mutation.Message, + MessageIDs: append([]int(nil), mutation.MessageIDs...), + MaxID: mutation.ShortcutID, + } + switch mutation.Kind { + case domain.QuickReplyMutationNew: + event.Type = domain.UpdateEventNewQuickReply + case domain.QuickReplyMutationDelete: + event.Type = domain.UpdateEventDeleteQuickReply + case domain.QuickReplyMutationMessage: + event.Type = domain.UpdateEventQuickReplyMessage + case domain.QuickReplyMutationIDs: + event.Type = domain.UpdateEventDeleteQuickReplyMessages + default: + event.Type = domain.UpdateEventQuickReplies + } + return s.recordEvent(ctx, authKeyID, userID, event, true, excludeSessionID) +} + // RecordReadHistory 推进 update 状态并追加一条 read_history_inbox 事件。 func (s *Service) RecordReadHistory(ctx context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { if userID == 0 { @@ -244,10 +429,21 @@ func (s *Service) RecordReadHistory(ctx context.Context, authKeyID [8]byte, user Peer: read.Peer, MaxID: read.MaxID, StillUnreadCount: read.StillUnreadCount, + ChannelPts: read.ChannelPts, PtsCount: 1, }, true, excludeSessionID) } +// RecordChannelState 记录当前账号与某频道成员关系变化(leave/kick), +// 离线设备经 difference 收到 updateChannel 后重拉 channel 状态。 +func (s *Service) RecordChannelState(ctx context.Context, authKeyID [8]byte, userID, channelID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventChannelState, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + PtsCount: 1, + }, true, excludeSessionID) +} + // RecordContactsReset 记录通讯录视角变化,供离线设备通过 updates.getDifference 触发重拉。 func (s *Service) RecordContactsReset(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ @@ -256,20 +452,54 @@ func (s *Service) RecordContactsReset(ctx context.Context, authKeyID [8]byte, us }, true, excludeSessionID) } -// RecordDialogPinned 记录单个会话置顶状态变化。 -func (s *Service) RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { +// RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对 +// 状态,重放时按 peer 重载当前值)。updateDraftMessage 无 pts 字段,走 LacksWirePts +// aux 簿记;topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。 +func (s *Service) RecordDraftMessage(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventDraftMessage, + Peer: peer, + MaxID: topMsgID, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordDialogPinned 记录单个会话置顶状态变化;folderID 是会话所在 folder +// (0 主列表/1 归档),缺失会让离线设备把归档内置顶重放到主列表。 +func (s *Service) RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, folderID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ Type: domain.UpdateEventDialogPinned, Peer: peer, Bool: pinned, + FolderID: folderID, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordPinnedDialogs 记录指定 folder 内置顶顺序变化,并把新顺序持久化给 getDifference/outbox。 +func (s *Service) RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventPinnedDialogs, + Peers: append([]domain.Peer(nil), order...), + FolderID: folderID, + PtsCount: 1, + }, true, excludeSessionID) +} + +// RecordSavedDialogPinned 记录收藏夹单个子会话置顶状态变化。 +func (s *Service) RecordSavedDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventSavedDialogPinned, + Peer: peer, + Bool: pinned, PtsCount: 1, }, true, excludeSessionID) } -// RecordPinnedDialogs 记录置顶会话顺序变化,并把新顺序持久化给 getDifference/outbox。 -func (s *Service) RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { +// RecordPinnedSavedDialogs 记录收藏夹置顶顺序变化,新顺序持久化给 getDifference/outbox。 +func (s *Service) RecordPinnedSavedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ - Type: domain.UpdateEventPinnedDialogs, + Type: domain.UpdateEventPinnedSavedDialogs, Peers: append([]domain.Peer(nil), order...), PtsCount: 1, }, true, excludeSessionID) @@ -295,6 +525,18 @@ func (s *Service) RecordChannelViewForumAsMessages(ctx context.Context, authKeyI }, true, excludeSessionID) } +// RecordChannelDiscussionInbox 记录 forum 话题级已读(updateReadChannelDiscussionInbox), +// 占一个账号 pts(LacksWirePts),供自己其它设备在线同步与离线差分恢复。 +func (s *Service) RecordChannelDiscussionInbox(ctx context.Context, authKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventReadChannelDiscussionInbox, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + TopMsgID: topicID, + MaxID: maxID, + PtsCount: 1, + }, true, excludeSessionID) +} + // RecordPeerSettings 记录 peer settings 变化。 func (s *Service) RecordPeerSettings(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ @@ -305,6 +547,16 @@ func (s *Service) RecordPeerSettings(ctx context.Context, authKeyID [8]byte, use }, true, excludeSessionID) } +// RecordPeerStoryBlocked 记录当前账号 story blocklist 对某个 peer 的可见状态变化。 +func (s *Service) RecordPeerStoryBlocked(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, blocked bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordEvent(ctx, authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventPeerStoryBlocked, + Peer: peer, + Bool: blocked, + PtsCount: 1, + }, true, excludeSessionID) +} + // RecordDialogFilter 记录单个 filter 的创建、更新或删除;folder 为 nil 表示删除。 func (s *Service) RecordDialogFilter(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { var copyFolder *domain.DialogFolder @@ -372,37 +624,48 @@ func (s *Service) recordEventCore(ctx context.Context, authKeyID [8]byte, userID if event.PtsCount == 0 { event.PtsCount = 1 } - pts, err := s.nextPtsN(ctx, userID, event.PtsCount) - if err != nil { - return domain.UpdateEvent{}, domain.UpdateState{}, err - } - st := domain.UpdateState{Pts: pts, Date: date, Seq: 0} event.UserID = userID - event.Pts = st.Pts event.Date = date + st := domain.UpdateState{Date: date, Seq: 0} if s.events != nil { var err error if dispatch { if appender, ok := s.events.(dispatchingEventAppender); ok { - err = appender.AppendWithDispatch(ctx, userID, event, authKeyID, excludeSessionID) + event, err = appender.AppendAllocatedWithDispatch(ctx, userID, event, authKeyID, excludeSessionID) } else { - err = s.events.Append(ctx, userID, event) + event, err = s.events.AppendAllocated(ctx, userID, event) } } else { - err = s.events.Append(ctx, userID, event) + event, err = s.events.AppendAllocated(ctx, userID, event) } if err != nil { - if !dispatch { - _ = s.events.Append(ctx, userID, domain.UpdateEvent{ - UserID: userID, - Type: domain.UpdateEventNoop, - Pts: pts, - PtsCount: event.PtsCount, - Date: date, - }) - } + s.log.Warn("update_event_append_failed", + zap.String("scope", "user"), + zap.Int64("user_id", userID), + zap.Int("pts", event.Pts), + zap.Int("pts_count", event.PtsCount), + zap.String("event_type", string(event.Type)), + zap.Error(err), + zap.Error(ctx.Err()), + ) return domain.UpdateEvent{}, domain.UpdateState{}, err } + st.Pts = event.Pts + s.log.Debug("update_event_appended", + zap.String("scope", "user"), + zap.Int64("user_id", userID), + zap.Int("pts", event.Pts), + zap.Int("pts_count", event.PtsCount), + zap.String("event_type", string(event.Type)), + zap.Bool("dispatch", dispatch), + ) + } else { + current, err := s.currentPts(ctx, userID) + if err != nil { + return domain.UpdateEvent{}, domain.UpdateState{}, err + } + event.Pts = current + event.PtsCount + st.Pts = event.Pts } if saveState && s.states != nil { if err := s.states.Save(ctx, authKeyID, userID, st); err != nil { @@ -413,53 +676,10 @@ func (s *Service) recordEventCore(ctx context.Context, authKeyID [8]byte, userID } // currentPts 供 GetState 报告「当前 pts」。对齐 MTProto:报告最大连续已提交 pts, -// 而非 Redis allocator 的最大已分配值——后者在并发发送在途时会超前于已提交事件, -// 会让首次登录基线越过在途空洞而丢消息。allocator 仅在无 events 存储时兜底。 +// PG 实现中该值由同一事务内的 pts 分配 + durable event 写入共同维护。 func (s *Service) currentPts(ctx context.Context, userID int64) (int, error) { if s.events != nil { return s.events.MaxContiguousPts(ctx, userID) } - if s.pts != nil { - return s.pts.CurrentPts(ctx, userID) - } return 0, nil } - -func (s *Service) nextPts(ctx context.Context, userID int64) (int, error) { - if s.pts != nil { - return s.pts.NextPts(ctx, userID) - } - current, err := s.currentPts(ctx, userID) - if err != nil { - return 0, err - } - return current + 1, nil -} - -func (s *Service) nextPtsN(ctx context.Context, userID int64, count int) (int, error) { - if count <= 0 { - count = 1 - } - if count == 1 { - return s.nextPts(ctx, userID) - } - if s.pts != nil { - if ranges, ok := s.pts.(store.PtsRangeAllocator); ok { - return ranges.NextPtsN(ctx, userID, count) - } - var pts int - var err error - for i := 0; i < count; i++ { - pts, err = s.pts.NextPts(ctx, userID) - if err != nil { - return 0, err - } - } - return pts, nil - } - current, err := s.currentPts(ctx, userID) - if err != nil { - return 0, err - } - return current + count, nil -} diff --git a/internal/app/updates/service_test.go b/internal/app/updates/service_test.go index 051f37a5..e7bcb66c 100644 --- a/internal/app/updates/service_test.go +++ b/internal/app/updates/service_test.go @@ -2,6 +2,7 @@ package updates import ( "context" + "errors" "testing" "telesrv/internal/domain" @@ -77,6 +78,38 @@ func TestRecordReadHistoryFeedsGetDifference(t *testing.T) { } } +func TestRecordChannelReadHistoryKeepsChannelPtsPayload(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 7 + svc := NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + ownerUserID := int64(1000000001) + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 7001} + + event, state, err := svc.RecordReadHistory(ctx, authKeyID, ownerUserID, domain.ReadHistoryResult{ + OwnerUserID: ownerUserID, + Peer: peer, + MaxID: 11, + StillUnreadCount: 3, + ChannelPts: 77, + Changed: true, + }, 0) + if err != nil { + t.Fatalf("RecordReadHistory: %v", err) + } + if event.Pts != 1 || state.Pts != 1 || event.ChannelPts != 77 { + t.Fatalf("event/state = %+v / %+v, want account pts=1 and channel pts payload=77", event, state) + } + + diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if len(diff.Events) != 1 || diff.Events[0].Peer != peer || diff.Events[0].ChannelPts != 77 { + t.Fatalf("diff = %+v, want recorded channel read payload with channel pts=77", diff) + } +} + func TestRecordSettingsEventsFeedGetDifference(t *testing.T) { ctx := context.Background() var authKeyID [8]byte @@ -88,31 +121,34 @@ func TestRecordSettingsEventsFeedGetDifference(t *testing.T) { if _, _, err := svc.RecordContactsReset(ctx, authKeyID, ownerUserID, 0); err != nil { t.Fatalf("RecordContactsReset: %v", err) } - if _, _, err := svc.RecordDialogPinned(ctx, authKeyID, ownerUserID, peer, true, 0); err != nil { + if _, _, err := svc.RecordDialogPinned(ctx, authKeyID, ownerUserID, peer, true, 0, 0); err != nil { t.Fatalf("RecordDialogPinned: %v", err) } order := []domain.Peer{peer} - if _, _, err := svc.RecordPinnedDialogs(ctx, authKeyID, ownerUserID, order, 0); err != nil { + if _, _, err := svc.RecordPinnedDialogs(ctx, authKeyID, ownerUserID, 0, order, 0); err != nil { t.Fatalf("RecordPinnedDialogs: %v", err) } if _, _, err := svc.RecordDialogUnreadMark(ctx, authKeyID, ownerUserID, peer, false, 0); err != nil { t.Fatalf("RecordDialogUnreadMark: %v", err) } settings := domain.PeerSettings{ShareContact: true} - stateEvent, state, err := svc.RecordPeerSettings(ctx, authKeyID, ownerUserID, peer, settings, 0) - if err != nil { + if _, _, err := svc.RecordPeerSettings(ctx, authKeyID, ownerUserID, peer, settings, 0); err != nil { t.Fatalf("RecordPeerSettings: %v", err) } - if stateEvent.Pts != 5 || state.Pts != 5 { - t.Fatalf("last event/state = %+v / %+v, want pts=5", stateEvent, state) + stateEvent, state, err := svc.RecordPeerStoryBlocked(ctx, authKeyID, ownerUserID, peer, true, 0) + if err != nil { + t.Fatalf("RecordPeerStoryBlocked: %v", err) + } + if stateEvent.Pts != 6 || state.Pts != 6 { + t.Fatalf("last event/state = %+v / %+v, want pts=6", stateEvent, state) } diff, err := svc.GetDifference(ctx, authKeyID, ownerUserID, domain.UpdateState{}) if err != nil { t.Fatalf("GetDifference: %v", err) } - if diff.State.Pts != 5 || len(diff.Events) != 5 { - t.Fatalf("diff = %+v, want five settings events", diff) + if diff.State.Pts != 6 || len(diff.Events) != 6 { + t.Fatalf("diff = %+v, want six settings events", diff) } wantTypes := []domain.UpdateEventType{ domain.UpdateEventContactsReset, @@ -120,6 +156,7 @@ func TestRecordSettingsEventsFeedGetDifference(t *testing.T) { domain.UpdateEventPinnedDialogs, domain.UpdateEventDialogUnreadMark, domain.UpdateEventPeerSettings, + domain.UpdateEventPeerStoryBlocked, } for i, typ := range wantTypes { if diff.Events[i].Type != typ || diff.Events[i].Pts != i+1 || diff.Events[i].PtsCount != 1 { @@ -138,6 +175,9 @@ func TestRecordSettingsEventsFeedGetDifference(t *testing.T) { if diff.Events[4].Peer != peer || !diff.Events[4].Settings.ShareContact { t.Fatalf("peer settings event = %+v, want peer and settings", diff.Events[4]) } + if diff.Events[5].Peer != peer || !diff.Events[5].Bool { + t.Fatalf("peer story blocked event = %+v, want peer and blocked=true", diff.Events[5]) + } } func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) { @@ -148,7 +188,7 @@ func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) { svc := NewService(memory.NewUpdateStateStore(), events) peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} - event, state, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, peer, true, 42) + event, state, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, peer, true, 0, 42) if err != nil { t.Fatalf("RecordDialogPinned: %v", err) } @@ -160,6 +200,177 @@ func TestRecordSettingsEventUsesDispatchAppender(t *testing.T) { } } +func TestRecordSettingsEventDispatchFailureDoesNotRecordEvent(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 6 + events := &failingDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()} + svc := NewService(memory.NewUpdateStateStore(), events) + + _, _, err := svc.RecordDialogPinned(ctx, authKeyID, 1000000001, domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, true, 0, 42) + if !errors.Is(err, errDispatchFailed) { + t.Fatalf("RecordDialogPinned err = %v, want dispatch failure", err) + } + diff, err := svc.GetDifference(ctx, authKeyID, 1000000001, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State.Pts != 0 || len(diff.Events) != 0 { + t.Fatalf("diff after dispatch failure = %+v, want no durable event before allocated append commits", diff) + } +} + +func TestRecordPeerStoryBlockedUsesDispatchAppender(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 7 + events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()} + svc := NewService(memory.NewUpdateStateStore(), events) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + + event, state, err := svc.RecordPeerStoryBlocked(ctx, authKeyID, 1000000001, peer, true, 91) + if err != nil { + t.Fatalf("RecordPeerStoryBlocked: %v", err) + } + if event.Pts != 1 || state.Pts != 1 || !event.LacksWirePts() { + t.Fatalf("event/state = %+v / %+v, want first aux pts event", event, state) + } + if !events.dispatched || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 91 || events.event.Type != domain.UpdateEventPeerStoryBlocked || events.event.Peer != peer || !events.event.Bool { + t.Fatalf("dispatch capture = %+v exclude_auth=%v exclude_session=%d dispatched=%v, want peer_story_blocked outbox", events.event, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched) + } +} + +func TestRecordStoryUsesDispatchAppenderExcludeCurrentSession(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{8, 1, 0} + events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()} + svc := NewService(memory.NewUpdateStateStore(), events) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + story := domain.Story{ + Owner: owner, + ID: 3, + Date: 1700000100, + ExpireDate: 1700086500, + Public: true, + Caption: "owner story", + } + + event, state, err := svc.RecordStory(ctx, authKeyID, owner.ID, story, 1234) + if err != nil { + t.Fatalf("RecordStory: %v", err) + } + if event.Type != domain.UpdateEventStory || event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 { + t.Fatalf("event/state = %+v / %+v, want first story pts event", event, state) + } + if !events.dispatched || events.userID != owner.ID || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 1234 { + t.Fatalf("dispatch capture = user %d exclude_auth=%v exclude_session=%d dispatched=%v, want current session excluded", events.userID, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched) + } + if events.event.Type != domain.UpdateEventStory || events.event.Peer != owner || events.event.Story.ID != story.ID { + t.Fatalf("dispatch event = %+v, want story update for owner story", events.event) + } +} + +func TestRecordStoryReadAndSentReactionExcludeCurrentSession(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{8, 1, 4} + events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()} + svc := NewService(memory.NewUpdateStateStore(), events) + viewerID := int64(1000000002) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + story := domain.Story{ + Owner: peer, + ID: 5, + Date: 1700000200, + ExpireDate: 1700086600, + Public: true, + } + + event, state, err := svc.RecordReadStories(ctx, authKeyID, viewerID, domain.StoryReadResult{ + ViewerID: viewerID, + Peer: peer, + MaxReadID: story.ID, + Advanced: true, + Date: 1700000201, + }, 2233) + if err != nil { + t.Fatalf("RecordReadStories: %v", err) + } + if event.Type != domain.UpdateEventReadStories || event.Pts != 1 || state.Pts != 1 { + t.Fatalf("read event/state = %+v / %+v, want first read story pts event", event, state) + } + if !events.dispatched || events.userID != viewerID || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 2233 || events.event.Type != domain.UpdateEventReadStories || events.event.MaxID != story.ID { + t.Fatalf("read dispatch capture = %+v user %d exclude_auth=%v exclude_session=%d dispatched=%v, want current session excluded", events.event, events.userID, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched) + } + + reaction := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "🔥"} + event, state, err = svc.RecordSentStoryReaction(ctx, authKeyID, viewerID, domain.StoryReactionResult{ + ViewerID: viewerID, + Peer: peer, + StoryID: story.ID, + Story: story, + Reaction: reaction, + Changed: true, + Date: 1700000202, + }, 2233) + if err != nil { + t.Fatalf("RecordSentStoryReaction: %v", err) + } + if event.Type != domain.UpdateEventSentStoryReaction || event.Pts != 2 || state.Pts != 2 { + t.Fatalf("reaction event/state = %+v / %+v, want second sent story reaction pts event", event, state) + } + if !events.dispatched || events.userID != viewerID || events.excludeAuthKeyID != authKeyID || events.excludeSessionID != 2233 || events.event.Type != domain.UpdateEventSentStoryReaction || events.event.Reaction == nil || events.event.Reaction.Emoticon != "🔥" { + t.Fatalf("reaction dispatch capture = %+v user %d exclude_auth=%v exclude_session=%d dispatched=%v, want current session excluded", events.event, events.userID, events.excludeAuthKeyID, events.excludeSessionID, events.dispatched) + } +} + +func TestRecordNewStoryReactionDispatchesWithoutSavingDeviceState(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 5 + states := &captureStateStore{} + events := &captureDispatchAppender{UpdateEventStore: memory.NewUpdateEventStore()} + svc := NewService(states, events) + ownerID := int64(1000000001) + viewerID := int64(1000000002) + reaction := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"} + + event, state, err := svc.RecordNewStoryReaction(ctx, authKeyID, 0, domain.StoryReactionResult{ + ViewerID: viewerID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}, + StoryID: 3, + Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}, + ID: 3, + Date: 1700000100, + }, + Reaction: reaction, + Date: 1700000101, + }, 0) + if err != nil { + t.Fatalf("RecordNewStoryReaction: %v", err) + } + if event.Type != domain.UpdateEventNewStoryReaction || event.UserID != ownerID || event.Peer.ID != viewerID || event.Reaction == nil || event.Reaction.Emoticon != "👍" { + t.Fatalf("event = %+v, want owner-side new story reaction from viewer", event) + } + if state.Pts != 1 || state.Seq != 0 { + t.Fatalf("state = %+v, want first account pts", state) + } + if states.saveCount != 0 { + t.Fatalf("state saves = %d, want no device state save for remote owner notification", states.saveCount) + } + if !events.dispatched || events.userID != ownerID || events.event.Type != domain.UpdateEventNewStoryReaction { + t.Fatalf("dispatch capture = %+v user=%d dispatched=%v, want owner outbox event", events.event, events.userID, events.dispatched) + } + + diff, err := svc.GetDifference(ctx, authKeyID, ownerID, domain.UpdateState{}) + if err != nil { + t.Fatalf("GetDifference: %v", err) + } + if diff.State.Pts != 1 || len(diff.Events) != 1 || diff.Events[0].Type != domain.UpdateEventNewStoryReaction { + t.Fatalf("diff = %+v, want one durable new story reaction", diff) + } +} + func TestClearAuthKeyDropsStateAndEvents(t *testing.T) { ctx := context.Background() var authKeyID [8]byte @@ -233,6 +444,52 @@ func TestDeleteMessagesPtsRangeFeedsGetDifference(t *testing.T) { } } +// TestAcknowledgeCurrentStateAdvancesConfirmedWatermark 验证 updates.getState +// 的语义:返回账号当前最新连续 pts(而非设备旧确认水位),并把确认水位推进 +// 到此——TDesktop 不持久化 pts,启动靠 getState+getDialogs 快照对齐,返回旧 +// 水位会诱导其重放快照前差分(未读重复累计、dialog 预览被旧消息抢占)。 +func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) { + ctx := context.Background() + var authKeyID [8]byte + authKeyID[0] = 11 + userID := int64(1000000001) + events := memory.NewUpdateEventStore() + svc := NewService(memory.NewUpdateStateStore(), events) + if err := events.Append(ctx, userID, domain.UpdateEvent{ + UserID: userID, Type: domain.UpdateEventNewMessage, Pts: 1, PtsCount: 1, + Date: 1700000001, Message: domain.Message{ID: 1, OwnerUserID: userID}, + }); err != nil { + t.Fatalf("append: %v", err) + } + // 设备确认水位停在 pts=1 后账号又推进两格。 + if _, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 1}); err != nil { + t.Fatalf("GetDifference: %v", err) + } + for pts := 2; pts <= 3; pts++ { + if err := events.Append(ctx, userID, domain.UpdateEvent{ + UserID: userID, Type: domain.UpdateEventNewMessage, Pts: pts, PtsCount: 1, + Date: 1700000001 + pts, Message: domain.Message{ID: pts, OwnerUserID: userID}, + }); err != nil { + t.Fatalf("append pts=%d: %v", pts, err) + } + } + + st, err := svc.AcknowledgeCurrentState(ctx, authKeyID, userID) + if err != nil { + t.Fatalf("AcknowledgeCurrentState: %v", err) + } + if st.Pts != 3 { + t.Fatalf("acknowledged state pts = %d, want account current 3", st.Pts) + } + confirmed, err := svc.GetState(ctx, authKeyID, userID) + if err != nil { + t.Fatalf("GetState after acknowledge: %v", err) + } + if confirmed.Pts != 3 { + t.Fatalf("confirmed watermark = %d, want advanced to 3", confirmed.Pts) + } +} + type captureDispatchAppender struct { *memory.UpdateEventStore dispatched bool @@ -242,11 +499,74 @@ type captureDispatchAppender struct { excludeSessionID int64 } -func (s *captureDispatchAppender) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error { +func (s *captureDispatchAppender) AppendAllocatedWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, error) { s.dispatched = true s.userID = userID - s.event = event s.excludeAuthKeyID = excludeAuthKeyID s.excludeSessionID = excludeSessionID - return s.UpdateEventStore.Append(ctx, userID, event) + event, err := s.UpdateEventStore.AppendAllocated(ctx, userID, event) + s.event = event + return event, err +} + +var errDispatchFailed = errors.New("dispatch failed") + +type failingDispatchAppender struct { + *memory.UpdateEventStore +} + +func (s *failingDispatchAppender) AppendAllocatedWithDispatch(context.Context, int64, domain.UpdateEvent, [8]byte, int64) (domain.UpdateEvent, error) { + return domain.UpdateEvent{}, errDispatchFailed +} + +type captureStateStore struct { + saveCount int + states map[[16]byte]domain.UpdateState +} + +func (s *captureStateStore) Get(_ context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, bool, error) { + if s.states == nil { + return domain.UpdateState{}, false, nil + } + st, ok := s.states[captureStateKey(authKeyID, userID)] + return st, ok, nil +} + +func (s *captureStateStore) Save(_ context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState) error { + if s.states == nil { + s.states = make(map[[16]byte]domain.UpdateState) + } + s.saveCount++ + s.states[captureStateKey(authKeyID, userID)] = state + return nil +} + +func (s *captureStateStore) Delete(_ context.Context, authKeyID [8]byte, userID int64) error { + if s.states != nil { + delete(s.states, captureStateKey(authKeyID, userID)) + } + return nil +} + +func (s *captureStateStore) DeleteAuthKey(_ context.Context, authKeyID [8]byte) error { + if s.states == nil { + return nil + } + for key := range s.states { + var got [8]byte + copy(got[:], key[:8]) + if got == authKeyID { + delete(s.states, key) + } + } + return nil +} + +func captureStateKey(authKeyID [8]byte, userID int64) [16]byte { + var key [16]byte + copy(key[:8], authKeyID[:]) + for i := 0; i < 8; i++ { + key[8+i] = byte(userID >> (8 * i)) + } + return key } diff --git a/internal/app/userprojection/contact_cache.go b/internal/app/userprojection/contact_cache.go new file mode 100644 index 00000000..a89caf7c --- /dev/null +++ b/internal/app/userprojection/contact_cache.go @@ -0,0 +1,470 @@ +package userprojection + +import ( + "context" + "fmt" + "sync" + "time" + + "golang.org/x/sync/singleflight" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const ( + // DefaultContactProjectionCacheTTL is a safety bound for out-of-band writes. + // Normal correctness relies on write-path invalidation, not natural expiry. + DefaultContactProjectionCacheTTL = 24 * time.Hour + + contactSnapshotMaxViewers = 4096 + contactReverseSnapshotOwnerCap = 16 + contactPersonalPhotoSnapshotCap = 4096 +) + +type contactAccountSnapshot struct { + contacts map[int64]domain.Contact + ordered []domain.Contact + hash int64 + expireAt time.Time +} + +type personalPhotoSnapshot struct { + refs map[int64]domain.ProfilePhotoRef + expireAt time.Time +} + +type contactSnapshotLoadResult struct { + snap contactAccountSnapshot + stored bool +} + +type personalPhotoSnapshotLoadResult struct { + snap personalPhotoSnapshot + stored bool +} + +// CachedContactStore wraps ContactStore with account-level read model snapshots. +// +// Contact data is low-churn and high-read: TDesktop repeatedly asks for the same +// viewer-scoped user projection while switching dialogs. Pair-level short TTL +// caching still lets every RPC plan new SQL for another pair; this cache loads a +// viewer's whole contact projection once, filters it in memory, and relies on +// contact write methods to invalidate the affected account snapshots. +type CachedContactStore struct { + inner store.ContactStore + ttl time.Duration + now func() time.Time + + mu sync.RWMutex + contacts map[int64]contactAccountSnapshot + personalPhotos map[int64]personalPhotoSnapshot + epoch uint64 + sf singleflight.Group +} + +func NewCachedContactStore(inner store.ContactStore, ttl time.Duration) *CachedContactStore { + if inner == nil { + return nil + } + if ttl <= 0 { + ttl = DefaultContactProjectionCacheTTL + } + return &CachedContactStore{ + inner: inner, + ttl: ttl, + now: time.Now, + contacts: make(map[int64]contactAccountSnapshot, 1024), + personalPhotos: make(map[int64]personalPhotoSnapshot, 1024), + } +} + +func (c *CachedContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) { + if userID == 0 { + return domain.ContactList{}, nil + } + snap, err := c.contactSnapshot(ctx, userID) + if err != nil { + return domain.ContactList{}, err + } + return domain.ContactList{Contacts: cloneCachedContacts(snap.ordered), Hash: snap.hash}, nil +} + +func (c *CachedContactStore) Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error) { + if userID == 0 || contactUserID == 0 { + return domain.Contact{}, false, nil + } + got, err := c.GetMany(ctx, userID, []int64{contactUserID}) + if err != nil { + return domain.Contact{}, false, err + } + contact, ok := got[contactUserID] + return contact, ok, nil +} + +func (c *CachedContactStore) GetMany(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) { + out := make(map[int64]domain.Contact, len(contactUserIDs)) + if userID == 0 || len(contactUserIDs) == 0 { + return out, nil + } + snap, err := c.contactSnapshot(ctx, userID) + if err != nil { + return nil, err + } + for _, ownerID := range contactUserIDs { + if ownerID == 0 { + continue + } + if contact, ok := snap.contacts[ownerID]; ok { + out[ownerID] = cloneCachedContact(contact) + } + } + return out, nil +} + +func (c *CachedContactStore) GetReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) { + out := make(map[int64]domain.Contact, len(ownerUserIDs)) + if userID == 0 || len(ownerUserIDs) == 0 { + return out, nil + } + owners := dedupContactIDs(ownerUserIDs) + if len(owners) == 0 { + return out, nil + } + if len(owners) > contactReverseSnapshotOwnerCap { + // Large fan-out should keep using the store's set query until a dedicated + // reverse-contact read model exists; loading hundreds of full contact + // lists would be worse than one batched SQL. + return c.inner.GetReverseContacts(ctx, userID, owners) + } + for _, ownerID := range owners { + snap, err := c.contactSnapshot(ctx, ownerID) + if err != nil { + return nil, err + } + if contact, ok := snap.contacts[userID]; ok { + out[ownerID] = cloneCachedContact(contact) + } + } + return out, nil +} + +func (c *CachedContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { + contact, err := c.inner.Upsert(ctx, userID, input) + if err == nil { + c.InvalidateViewers(userID, input.ContactUserID) + } + return contact, err +} + +func (c *CachedContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) { + contacts, err := c.inner.UpsertMany(ctx, userID, inputs) + if err == nil { + ids := make([]int64, 0, len(inputs)+1) + ids = append(ids, userID) + for _, input := range inputs { + ids = append(ids, input.ContactUserID) + } + c.InvalidateViewers(ids...) + } + return contacts, err +} + +func (c *CachedContactStore) UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) { + contact, found, err := c.inner.UpdateNote(ctx, userID, contactUserID, note, entities) + if err == nil { + c.InvalidateViewers(userID) + } + return contact, found, err +} + +func (c *CachedContactStore) SetCloseFriends(ctx context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) { + res, err := c.inner.SetCloseFriends(ctx, userID, contactUserIDs) + if err == nil { + c.InvalidateViewers(userID) + } + return res, err +} + +func (c *CachedContactStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) { + contact, found, err := c.inner.SetPersonalPhoto(ctx, userID, contactUserID, photoID, date) + if err == nil { + c.InvalidateViewers(userID) + } + return contact, found, err +} + +func (c *CachedContactStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + out := make(map[int64]domain.ProfilePhotoRef, len(contactUserIDs)) + if userID == 0 || len(contactUserIDs) == 0 { + return out, nil + } + snap, err := c.personalPhotoSnapshot(ctx, userID) + if err != nil { + return nil, err + } + for _, ownerID := range contactUserIDs { + if ownerID == 0 { + continue + } + if ref, ok := snap.refs[ownerID]; ok { + out[ownerID] = cloneCachedProfilePhotoRef(ref) + } + } + return out, nil +} + +func (c *CachedContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) { + count, err := c.inner.Delete(ctx, userID, contactUserIDs) + if err == nil { + ids := make([]int64, 0, len(contactUserIDs)+1) + ids = append(ids, userID) + ids = append(ids, contactUserIDs...) + c.InvalidateViewers(ids...) + } + return count, err +} + +func (c *CachedContactStore) Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error) { + changed, err := c.inner.Block(ctx, userID, blockedUserID, date) + if err == nil { + c.InvalidateViewers(userID, blockedUserID) + } + return changed, err +} + +func (c *CachedContactStore) Unblock(ctx context.Context, userID, blockedUserID int64) (bool, error) { + changed, err := c.inner.Unblock(ctx, userID, blockedUserID) + if err == nil { + c.InvalidateViewers(userID, blockedUserID) + } + return changed, err +} + +func (c *CachedContactStore) IsBlocked(ctx context.Context, userID, blockedUserID int64) (bool, error) { + return c.inner.IsBlocked(ctx, userID, blockedUserID) +} + +func (c *CachedContactStore) ListBlocked(ctx context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error) { + return c.inner.ListBlocked(ctx, userID, offset, limit) +} + +func (c *CachedContactStore) contactSnapshot(ctx context.Context, userID int64) (contactAccountSnapshot, error) { + for { + if snap, ok := c.lookupContactSnapshot(userID, c.now()); ok { + return snap, nil + } + v, err, _ := c.sf.Do(fmt.Sprintf("contact:%d", userID), func() (any, error) { + now := c.now() + if snap, ok := c.lookupContactSnapshot(userID, now); ok { + return contactSnapshotLoadResult{snap: snap, stored: true}, nil + } + loadEpoch := c.cacheEpoch() + list, err := c.inner.ListByUser(ctx, userID) + if err != nil { + return contactSnapshotLoadResult{}, err + } + snap := buildContactAccountSnapshot(list, now.Add(c.ttl)) + c.mu.Lock() + stored := c.epoch == loadEpoch + if stored { + if len(c.contacts) >= contactSnapshotMaxViewers { + c.contacts = make(map[int64]contactAccountSnapshot, 1024) + c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024) + } + c.contacts[userID] = snap + } + c.mu.Unlock() + return contactSnapshotLoadResult{snap: snap, stored: stored}, nil + }) + if err != nil { + return contactAccountSnapshot{}, err + } + result := v.(contactSnapshotLoadResult) + if result.stored { + return result.snap, nil + } + if err := ctx.Err(); err != nil { + return contactAccountSnapshot{}, err + } + } +} + +func (c *CachedContactStore) lookupContactSnapshot(userID int64, now time.Time) (contactAccountSnapshot, bool) { + c.mu.RLock() + snap, ok := c.contacts[userID] + c.mu.RUnlock() + if !ok || !snap.expireAt.After(now) { + if ok { + c.InvalidateViewers(userID) + } + return contactAccountSnapshot{}, false + } + return snap, true +} + +func (c *CachedContactStore) personalPhotoSnapshot(ctx context.Context, userID int64) (personalPhotoSnapshot, error) { + for { + if snap, ok := c.lookupPersonalPhotoSnapshot(userID, c.now()); ok { + return snap, nil + } + v, err, _ := c.sf.Do(fmt.Sprintf("contact-photo:%d", userID), func() (any, error) { + now := c.now() + if snap, ok := c.lookupPersonalPhotoSnapshot(userID, now); ok { + return personalPhotoSnapshotLoadResult{snap: snap, stored: true}, nil + } + loadEpoch := c.cacheEpoch() + contacts, err := c.contactSnapshot(ctx, userID) + if err != nil { + return personalPhotoSnapshotLoadResult{}, err + } + ids := make([]int64, 0, len(contacts.contacts)) + for id := range contacts.contacts { + ids = append(ids, id) + } + refs := map[int64]domain.ProfilePhotoRef{} + if len(ids) > 0 { + refs, err = c.inner.PersonalPhotos(ctx, userID, ids) + if err != nil { + return personalPhotoSnapshotLoadResult{}, err + } + } + snap := personalPhotoSnapshot{refs: cloneCachedProfilePhotoRefs(refs), expireAt: now.Add(c.ttl)} + c.mu.Lock() + stored := c.epoch == loadEpoch + if stored { + if len(c.personalPhotos) >= contactPersonalPhotoSnapshotCap { + c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024) + } + c.personalPhotos[userID] = snap + } + c.mu.Unlock() + return personalPhotoSnapshotLoadResult{snap: snap, stored: stored}, nil + }) + if err != nil { + return personalPhotoSnapshot{}, err + } + result := v.(personalPhotoSnapshotLoadResult) + if result.stored { + return result.snap, nil + } + if err := ctx.Err(); err != nil { + return personalPhotoSnapshot{}, err + } + } +} + +func (c *CachedContactStore) lookupPersonalPhotoSnapshot(userID int64, now time.Time) (personalPhotoSnapshot, bool) { + c.mu.RLock() + snap, ok := c.personalPhotos[userID] + c.mu.RUnlock() + if !ok || !snap.expireAt.After(now) { + if ok { + c.InvalidateViewers(userID) + } + return personalPhotoSnapshot{}, false + } + return snap, true +} + +func (c *CachedContactStore) InvalidateViewers(ids ...int64) { + if c == nil || len(ids) == 0 { + return + } + c.mu.Lock() + c.epoch++ + for _, id := range ids { + if id == 0 { + continue + } + delete(c.contacts, id) + delete(c.personalPhotos, id) + } + c.mu.Unlock() +} + +func (c *CachedContactStore) FlushReadModelCache() { + if c == nil { + return + } + c.mu.Lock() + c.epoch++ + c.contacts = make(map[int64]contactAccountSnapshot, 1024) + c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024) + c.mu.Unlock() +} + +func (c *CachedContactStore) cacheEpoch() uint64 { + c.mu.RLock() + epoch := c.epoch + c.mu.RUnlock() + return epoch +} + +func buildContactAccountSnapshot(list domain.ContactList, expireAt time.Time) contactAccountSnapshot { + contacts := make(map[int64]domain.Contact, len(list.Contacts)) + ordered := make([]domain.Contact, 0, len(list.Contacts)) + for _, contact := range list.Contacts { + if contact.User.ID == 0 { + continue + } + clone := cloneCachedContact(contact) + contacts[clone.User.ID] = clone + ordered = append(ordered, clone) + } + return contactAccountSnapshot{contacts: contacts, ordered: ordered, hash: list.Hash, expireAt: expireAt} +} + +func dedupContactIDs(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func cloneCachedContacts(in []domain.Contact) []domain.Contact { + out := make([]domain.Contact, len(in)) + for i := range in { + out[i] = cloneCachedContact(in[i]) + } + return out +} + +func cloneCachedContact(in domain.Contact) domain.Contact { + in.User = cloneCachedUser(in.User) + if in.NoteEntities != nil { + in.NoteEntities = append([]domain.MessageEntity(nil), in.NoteEntities...) + } + return in +} + +func cloneCachedUser(in domain.User) domain.User { + if in.PhotoStripped != nil { + in.PhotoStripped = append([]byte(nil), in.PhotoStripped...) + } + return in +} + +func cloneCachedProfilePhotoRefs(in map[int64]domain.ProfilePhotoRef) map[int64]domain.ProfilePhotoRef { + out := make(map[int64]domain.ProfilePhotoRef, len(in)) + for id, ref := range in { + out[id] = cloneCachedProfilePhotoRef(ref) + } + return out +} + +func cloneCachedProfilePhotoRef(in domain.ProfilePhotoRef) domain.ProfilePhotoRef { + if in.Stripped != nil { + in.Stripped = append([]byte(nil), in.Stripped...) + } + return in +} diff --git a/internal/app/userprojection/contact_cache_test.go b/internal/app/userprojection/contact_cache_test.go new file mode 100644 index 00000000..f272f3a6 --- /dev/null +++ b/internal/app/userprojection/contact_cache_test.go @@ -0,0 +1,386 @@ +package userprojection + +import ( + "context" + "sync" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +type blockingFirstListContactStore struct { + store.ContactStore + started chan struct{} + release chan struct{} + first domain.ContactList + + mu sync.Mutex + firstUsed bool +} + +type blockingFirstPersonalPhotoStore struct { + store.ContactStore + started chan struct{} + release chan struct{} + first map[int64]domain.ProfilePhotoRef + + mu sync.Mutex + firstUsed bool +} + +func (s *blockingFirstListContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) { + s.mu.Lock() + if !s.firstUsed { + s.firstUsed = true + s.mu.Unlock() + close(s.started) + select { + case <-s.release: + case <-ctx.Done(): + return domain.ContactList{}, ctx.Err() + } + return domain.ContactList{Contacts: cloneCachedContacts(s.first.Contacts), Hash: s.first.Hash}, nil + } + s.mu.Unlock() + return s.ContactStore.ListByUser(ctx, userID) +} + +func (s *blockingFirstPersonalPhotoStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + s.mu.Lock() + if !s.firstUsed { + s.firstUsed = true + first := cloneCachedProfilePhotoRefs(s.first) + s.mu.Unlock() + close(s.started) + select { + case <-s.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return first, nil + } + s.mu.Unlock() + return s.ContactStore.PersonalPhotos(ctx, userID, contactUserIDs) +} + +func (s *blockingFirstPersonalPhotoStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) { + return s.ContactStore.SetPersonalPhoto(ctx, userID, contactUserID, photoID, date) +} + +func waitForCacheTestSignal(t *testing.T, ch <-chan struct{}) { + t.Helper() + select { + case <-ch: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for cache test signal") + } +} + +type countingContactStore struct { + store.ContactStore + listCalls int + getManyCalls int + reverseCalls int + personalPhotoCalls int + setPersonalPhotoHit int +} + +func (s *countingContactStore) ListByUser(ctx context.Context, userID int64) (domain.ContactList, error) { + s.listCalls++ + return s.ContactStore.ListByUser(ctx, userID) +} + +func (s *countingContactStore) GetMany(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) { + s.getManyCalls++ + return s.ContactStore.GetMany(ctx, userID, contactUserIDs) +} + +func (s *countingContactStore) GetReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) { + s.reverseCalls++ + return s.ContactStore.GetReverseContacts(ctx, userID, ownerUserIDs) +} + +func (s *countingContactStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + s.personalPhotoCalls++ + return s.ContactStore.PersonalPhotos(ctx, userID, contactUserIDs) +} + +func (s *countingContactStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) { + s.setPersonalPhotoHit++ + return s.ContactStore.SetPersonalPhoto(ctx, userID, contactUserID, photoID, date) +} + +func TestCachedContactStoreCachesProjectionReads(t *testing.T) { + ctx := context.Background() + base := memory.NewContactStore() + if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice", Phone: "111"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + counting := &countingContactStore{ContactStore: base} + cached := NewCachedContactStore(counting, 0) + + first, err := cached.GetMany(ctx, 1, []int64{2, 3}) + if err != nil { + t.Fatalf("get many first: %v", err) + } + if first[2].FirstName != "Alice" { + t.Fatalf("first contact = %+v, want Alice", first[2]) + } + second, err := cached.GetMany(ctx, 1, []int64{2, 3}) + if err != nil { + t.Fatalf("get many second: %v", err) + } + if second[2].FirstName != "Alice" { + t.Fatalf("second contact = %+v, want Alice", second[2]) + } + if counting.listCalls != 1 { + t.Fatalf("ListByUser calls = %d, want 1 account snapshot load", counting.listCalls) + } + if counting.getManyCalls != 0 { + t.Fatalf("GetMany calls = %d, want 0 with account snapshot", counting.getManyCalls) + } + + reverse, err := cached.GetReverseContacts(ctx, 2, []int64{1}) + if err != nil { + t.Fatalf("get reverse: %v", err) + } + if reverse[1].FirstName != "Alice" { + t.Fatalf("reverse contact = %+v, want Alice", reverse[1]) + } + if counting.reverseCalls != 0 { + t.Fatalf("GetReverseContacts calls = %d, want 0 from shared contact cache", counting.reverseCalls) + } +} + +func TestCachedContactStoreInvalidatesAccountSnapshot(t *testing.T) { + ctx := context.Background() + base := memory.NewContactStore() + if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + counting := &countingContactStore{ContactStore: base} + cached := NewCachedContactStore(counting, 0) + + first, err := cached.GetMany(ctx, 1, []int64{2}) + if err != nil { + t.Fatalf("get first: %v", err) + } + if first[2].FirstName != "Alice" { + t.Fatalf("first = %+v, want Alice", first[2]) + } + if _, err := cached.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alicia"}); err != nil { + t.Fatalf("upsert through cache: %v", err) + } + second, err := cached.GetMany(ctx, 1, []int64{2}) + if err != nil { + t.Fatalf("get second: %v", err) + } + if second[2].FirstName != "Alicia" { + t.Fatalf("second = %+v, want Alicia after invalidation", second[2]) + } + if counting.listCalls != 2 { + t.Fatalf("ListByUser calls = %d, want 2 after write invalidation", counting.listCalls) + } +} + +func TestCachedContactStoreExternalInvalidationAndFlush(t *testing.T) { + ctx := context.Background() + base := memory.NewContactStore() + if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + counting := &countingContactStore{ContactStore: base} + cached := NewCachedContactStore(counting, 0) + + if _, err := cached.GetMany(ctx, 1, []int64{2}); err != nil { + t.Fatalf("prime get: %v", err) + } + if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alicia"}); err != nil { + t.Fatalf("direct upsert: %v", err) + } + cached.InvalidateViewers(1) + got, err := cached.GetMany(ctx, 1, []int64{2}) + if err != nil { + t.Fatalf("get after external invalidation: %v", err) + } + if got[2].FirstName != "Alicia" { + t.Fatalf("after invalidation = %+v, want Alicia", got[2]) + } + + if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Ally"}); err != nil { + t.Fatalf("direct upsert 2: %v", err) + } + cached.FlushReadModelCache() + got, err = cached.GetMany(ctx, 1, []int64{2}) + if err != nil { + t.Fatalf("get after flush: %v", err) + } + if got[2].FirstName != "Ally" { + t.Fatalf("after flush = %+v, want Ally", got[2]) + } + if counting.listCalls != 3 { + t.Fatalf("ListByUser calls = %d, want 3 after prime+invalidate+flush", counting.listCalls) + } +} + +func TestCachedContactStoreDoesNotRefillStaleSnapshotAfterInvalidation(t *testing.T) { + ctx := context.Background() + base := memory.NewContactStore() + if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil { + t.Fatalf("seed contact: %v", err) + } + first, err := base.ListByUser(ctx, 1) + if err != nil { + t.Fatalf("snapshot first contact list: %v", err) + } + blocking := &blockingFirstListContactStore{ + ContactStore: base, + started: make(chan struct{}), + release: make(chan struct{}), + first: first, + } + cached := NewCachedContactStore(blocking, 0) + + type readResult struct { + contacts map[int64]domain.Contact + err error + } + resultCh := make(chan readResult, 1) + go func() { + got, err := cached.GetMany(ctx, 1, []int64{2}) + resultCh <- readResult{contacts: got, err: err} + }() + waitForCacheTestSignal(t, blocking.started) + + if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alicia"}); err != nil { + t.Fatalf("update contact while first load is blocked: %v", err) + } + cached.InvalidateViewers(1) + close(blocking.release) + + var result readResult + select { + case result = <-resultCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for contact read") + } + if result.err != nil { + t.Fatalf("contact read: %v", result.err) + } + if result.contacts[2].FirstName != "Alicia" { + t.Fatalf("contact after concurrent invalidation = %+v, want Alicia", result.contacts[2]) + } + + cachedHit, err := cached.GetMany(ctx, 1, []int64{2}) + if err != nil { + t.Fatalf("cached hit after stale load retry: %v", err) + } + if cachedHit[2].FirstName != "Alicia" { + t.Fatalf("cached value after stale load retry = %+v, want Alicia", cachedHit[2]) + } +} + +func TestCachedContactStoreInvalidatesPersonalPhoto(t *testing.T) { + ctx := context.Background() + base := memory.NewContactStore() + if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + if _, found, err := base.SetPersonalPhoto(ctx, 1, 2, 9001, 100); err != nil || !found { + t.Fatalf("set personal photo: %v found=%v", err, found) + } + counting := &countingContactStore{ContactStore: base} + cached := NewCachedContactStore(counting, 0) + + first, err := cached.PersonalPhotos(ctx, 1, []int64{2}) + if err != nil { + t.Fatalf("personal photos first: %v", err) + } + if first[2].PhotoID != 9001 || !first[2].Personal { + t.Fatalf("first personal photo = %+v, want 9001", first[2]) + } + second, err := cached.PersonalPhotos(ctx, 1, []int64{2}) + if err != nil { + t.Fatalf("personal photos second: %v", err) + } + if second[2].PhotoID != 9001 { + t.Fatalf("second personal photo = %+v, want 9001", second[2]) + } + if counting.listCalls != 1 { + t.Fatalf("ListByUser calls = %d, want 1 personal-photo account snapshot load", counting.listCalls) + } + if counting.personalPhotoCalls != 1 { + t.Fatalf("PersonalPhotos calls = %d, want 1", counting.personalPhotoCalls) + } + + if _, found, err := cached.SetPersonalPhoto(ctx, 1, 2, 9002, 101); err != nil || !found { + t.Fatalf("cached set personal photo: %v found=%v", err, found) + } + third, err := cached.PersonalPhotos(ctx, 1, []int64{2}) + if err != nil { + t.Fatalf("personal photos third: %v", err) + } + if third[2].PhotoID != 9002 { + t.Fatalf("third personal photo = %+v, want 9002 after invalidation", third[2]) + } + if counting.personalPhotoCalls != 2 { + t.Fatalf("PersonalPhotos calls after invalidation = %d, want 2", counting.personalPhotoCalls) + } + if counting.listCalls != 2 { + t.Fatalf("ListByUser calls after invalidation = %d, want 2", counting.listCalls) + } +} + +func TestCachedContactStoreDoesNotRefillStalePersonalPhotoAfterInvalidation(t *testing.T) { + ctx := context.Background() + base := memory.NewContactStore() + if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + if _, found, err := base.SetPersonalPhoto(ctx, 1, 2, 9001, 100); err != nil || !found { + t.Fatalf("seed personal photo: %v found=%v", err, found) + } + first, err := base.PersonalPhotos(ctx, 1, []int64{2}) + if err != nil { + t.Fatalf("snapshot first personal photo: %v", err) + } + blocking := &blockingFirstPersonalPhotoStore{ + ContactStore: base, + started: make(chan struct{}), + release: make(chan struct{}), + first: first, + } + cached := NewCachedContactStore(blocking, 0) + + type readResult struct { + refs map[int64]domain.ProfilePhotoRef + err error + } + resultCh := make(chan readResult, 1) + go func() { + refs, err := cached.PersonalPhotos(ctx, 1, []int64{2}) + resultCh <- readResult{refs: refs, err: err} + }() + waitForCacheTestSignal(t, blocking.started) + + if _, found, err := cached.SetPersonalPhoto(ctx, 1, 2, 9002, 101); err != nil || !found { + t.Fatalf("update personal photo while first load is blocked: %v found=%v", err, found) + } + close(blocking.release) + + var result readResult + select { + case result = <-resultCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for personal photo read") + } + if result.err != nil { + t.Fatalf("personal photo read: %v", result.err) + } + if result.refs[2].PhotoID != 9002 { + t.Fatalf("personal photo after concurrent invalidation = %+v, want 9002", result.refs[2]) + } +} diff --git a/internal/app/userprojection/photo_cache.go b/internal/app/userprojection/photo_cache.go new file mode 100644 index 00000000..f16a6753 --- /dev/null +++ b/internal/app/userprojection/photo_cache.go @@ -0,0 +1,143 @@ +package userprojection + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +// DefaultPhotoCacheTTL 是头像投影缓存的兜底有效期;正常正确性依赖写入侧触发 +// read_model_versions/NOTIFY 后显式失效,TTL 只负责覆盖进程外漏通知或手工改库。 +const DefaultPhotoCacheTTL = 10 * time.Second + +const photoCacheMaxEntries = 200000 + +// combinedPhotoProvider 是同时具备 batch 与 kind 两种头像查询能力的底层 provider(postgres +// MediaStore 即满足)。 +type combinedPhotoProvider interface { + ProfilePhotoProvider + ProfilePhotoKindProvider +} + +type photoCacheKey struct { + ownerType domain.PeerType + ownerID int64 + kind domain.ProfilePhotoKind +} + +// photoCacheValue 含负缓存:has=false 表示「已查过、该 owner 无此 kind 头像」, +// 避免无头像 owner 反复打 PG。 +type photoCacheValue struct { + ref domain.ProfilePhotoRef + has bool +} + +// CachedPhotoProvider 给 owner 的当前 profile/fallback 头像查询加一层短 TTL 进程内缓存, +// 由统一缓存原语承载(LRU 单条驱逐 / epoch 守卫 / clone;批量经 GetOrLoadBatch)。 +// projectBatch / ForViewers 对每批 owner 固定打 2 次 CurrentProfilePhotosKind(profile+fallback), +// 且 base user 命中 redis 也不短路——高频「返回用户」的 RPC(getUsers 等)会把这两条头像查询 +// 刷到与 RPC 同频。这里按 (ownerType, ownerID, kind) 缓存结果(含负结果),命中 owner 不进 PG。 +// +// 注意:只缓存 owner-only 的 profile/fallback;personal photo 是 per-viewer 的(contacts.PersonalPhotos), +// 不经此 provider,故无跨 viewer 串号风险。隐私裁剪发生在投影之后、缓存之外,缓存的是 owner 原始 +// 头像 ref 而非投影结果,故不会把某 viewer 的可见性固化给其他 viewer。 +type CachedPhotoProvider struct { + inner combinedPhotoProvider + cache *readmodelcache.Cache[photoCacheKey, photoCacheValue] +} + +// NewCachedPhotoProvider 包装底层 provider;ttl<=0 用 DefaultPhotoCacheTTL。 +func NewCachedPhotoProvider(inner combinedPhotoProvider, ttl time.Duration) *CachedPhotoProvider { + return newCachedPhotoProviderWithClock(inner, ttl, nil) +} + +// newCachedPhotoProviderWithClock 允许注入时钟,仅供测试确定地推进 TTL;now=nil 用真实时钟。 +func newCachedPhotoProviderWithClock(inner combinedPhotoProvider, ttl time.Duration, now func() time.Time) *CachedPhotoProvider { + if inner == nil { + return nil + } + if ttl <= 0 { + ttl = DefaultPhotoCacheTTL + } + return &CachedPhotoProvider{ + inner: inner, + cache: readmodelcache.New[photoCacheKey, photoCacheValue](readmodelcache.Config[photoCacheKey, photoCacheValue]{ + MaxEntries: photoCacheMaxEntries, + TTL: ttl, + Now: now, + Clone: clonePhotoCacheValue, + }), + } +} + +// CurrentProfilePhotosKind 是被缓存的热路径:先按 owner 取缓存,仅未命中的 owner 批量查底层。 +func (c *CachedPhotoProvider) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { + if c == nil { + return nil, nil + } + keys := make([]photoCacheKey, 0, len(ownerIDs)) + for _, id := range ownerIDs { + keys = append(keys, photoCacheKey{ownerType: ownerType, ownerID: id, kind: kind}) + } + values, err := c.cache.GetOrLoadBatch(ctx, keys, + func(photoCacheKey) (int64, bool) { return 0, true }, // 纯 TTL,无版本闸门 + func(ctx context.Context, missing []photoCacheKey) (map[photoCacheKey]photoCacheValue, error) { + missingIDs := make([]int64, len(missing)) + for i, k := range missing { + missingIDs[i] = k.ownerID + } + refs, err := c.inner.CurrentProfilePhotosKind(ctx, ownerType, missingIDs, kind) + if err != nil { + return nil, err + } + out := make(map[photoCacheKey]photoCacheValue, len(missing)) + for _, k := range missing { + ref, has := refs[k.ownerID] + out[k] = photoCacheValue{ref: ref, has: has} + } + return out, nil + }) + if err != nil { + return nil, err + } + out := make(map[int64]domain.ProfilePhotoRef, len(values)) + for key, v := range values { + if v.has { + out[key.ownerID] = v.ref + } + } + return out, nil +} + +// CurrentProfilePhotos 是非 kind 的回退路径——projectBatch 仅在 provider 不实现 +// ProfilePhotoKindProvider 时才走它,而本装饰器实现了 kind 接口,故生产不会命中此方法; +// 直接透传,不缓存,保持行为不变。 +func (c *CachedPhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + return c.inner.CurrentProfilePhotos(ctx, ownerType, ownerIDs) +} + +func (c *CachedPhotoProvider) InvalidateOwner(ownerType domain.PeerType, ownerID int64) { + if c == nil || ownerID == 0 { + return + } + c.cache.Invalidate( + photoCacheKey{ownerType: ownerType, ownerID: ownerID, kind: domain.ProfilePhotoKindProfile}, + photoCacheKey{ownerType: ownerType, ownerID: ownerID, kind: domain.ProfilePhotoKindFallback}, + ) +} + +func (c *CachedPhotoProvider) FlushReadModelCache() { + if c == nil { + return + } + c.cache.Flush() +} + +func clonePhotoCacheValue(v photoCacheValue) photoCacheValue { + if v.ref.Stripped != nil { + v.ref.Stripped = append([]byte(nil), v.ref.Stripped...) + } + return v +} diff --git a/internal/app/userprojection/photo_cache_test.go b/internal/app/userprojection/photo_cache_test.go new file mode 100644 index 00000000..99c681ab --- /dev/null +++ b/internal/app/userprojection/photo_cache_test.go @@ -0,0 +1,221 @@ +package userprojection + +import ( + "context" + "sync" + "testing" + "time" + + "telesrv/internal/domain" +) + +type countingPhotoProvider struct { + kindCalls int + queriedIDs []int64 + refs map[int64]domain.ProfilePhotoRef +} + +type blockingFirstPhotoProvider struct { + started chan struct{} + release chan struct{} + + mu sync.Mutex + firstUsed bool + first map[int64]domain.ProfilePhotoRef + refs map[int64]domain.ProfilePhotoRef +} + +func (p *blockingFirstPhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + return p.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile) +} + +func (p *blockingFirstPhotoProvider) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { + p.mu.Lock() + if !p.firstUsed { + p.firstUsed = true + first := clonePhotoRefs(p.first, ownerIDs) + p.mu.Unlock() + close(p.started) + select { + case <-p.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return first, nil + } + out := clonePhotoRefs(p.refs, ownerIDs) + p.mu.Unlock() + return out, nil +} + +func (p *blockingFirstPhotoProvider) setRef(ownerID int64, ref domain.ProfilePhotoRef) { + p.mu.Lock() + p.refs[ownerID] = ref + p.mu.Unlock() +} + +func clonePhotoRefs(in map[int64]domain.ProfilePhotoRef, ownerIDs []int64) map[int64]domain.ProfilePhotoRef { + out := make(map[int64]domain.ProfilePhotoRef, len(ownerIDs)) + for _, id := range ownerIDs { + ref, ok := in[id] + if !ok { + continue + } + out[id] = cloneCachedProfilePhotoRef(ref) + } + return out +} + +func (p *countingPhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + return p.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile) +} + +func (p *countingPhotoProvider) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { + p.kindCalls++ + p.queriedIDs = append(p.queriedIDs, ownerIDs...) + out := map[int64]domain.ProfilePhotoRef{} + for _, id := range ownerIDs { + if ref, ok := p.refs[id]; ok { + out[id] = ref + } + } + return out, nil +} + +func TestCachedPhotoProviderCachesHitsAndMisses(t *testing.T) { + inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}} + now := time.Unix(1000, 0) + c := newCachedPhotoProviderWithClock(inner, time.Minute, func() time.Time { return now }) + ctx := context.Background() + + // 首次:两 owner 都未命中,查底层一次。 + got, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1, 2}, domain.ProfilePhotoKindProfile) + if err != nil { + t.Fatalf("first: %v", err) + } + if got[1].PhotoID != 111 { + t.Fatalf("owner 1 ref = %+v, want PhotoID 111", got[1]) + } + if _, ok := got[2]; ok { + t.Fatalf("owner 2 should have no photo") + } + if inner.kindCalls != 1 || len(inner.queriedIDs) != 2 { + t.Fatalf("first call: kindCalls=%d queried=%v, want 1 call of 2 ids", inner.kindCalls, inner.queriedIDs) + } + + // 二次(TTL 内):全部命中缓存(含 owner 2 的负结果),不再查底层。 + got, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1, 2}, domain.ProfilePhotoKindProfile) + if err != nil { + t.Fatalf("second: %v", err) + } + if got[1].PhotoID != 111 { + t.Fatalf("cached owner 1 ref = %+v, want PhotoID 111", got[1]) + } + if inner.kindCalls != 1 { + t.Fatalf("second call hit DB: kindCalls=%d, want still 1", inner.kindCalls) + } + + // 不同 kind 是独立缓存键:fallback 应再查一次。 + if _, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindFallback); err != nil { + t.Fatalf("fallback: %v", err) + } + if inner.kindCalls != 2 { + t.Fatalf("fallback kind should query: kindCalls=%d, want 2", inner.kindCalls) + } + + // TTL 过期后重新查底层。 + now = now.Add(2 * time.Minute) + if _, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1, 2}, domain.ProfilePhotoKindProfile); err != nil { + t.Fatalf("after ttl: %v", err) + } + if inner.kindCalls != 3 { + t.Fatalf("after ttl should re-query: kindCalls=%d, want 3", inner.kindCalls) + } +} + +func TestCachedPhotoProviderInvalidatesOwnerAndFlushes(t *testing.T) { + inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}} + now := time.Unix(1000, 0) + c := newCachedPhotoProviderWithClock(inner, time.Minute, func() time.Time { return now }) + ctx := context.Background() + + if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil { + t.Fatalf("prime profile: %v", err) + } + if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindFallback); err != nil { + t.Fatalf("prime fallback: %v", err) + } + inner.refs[1] = domain.ProfilePhotoRef{PhotoID: 222} + c.InvalidateOwner(domain.PeerTypeUser, 1) + + profile, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile) + if err != nil { + t.Fatalf("profile after invalidation: %v", err) + } + if profile[1].PhotoID != 222 { + t.Fatalf("profile after invalidation = %+v, want 222", profile[1]) + } + if _, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindFallback); err != nil { + t.Fatalf("fallback after invalidation: %v", err) + } + if inner.kindCalls != 4 { + t.Fatalf("InvalidateOwner should drop both kinds: kindCalls=%d, want 4", inner.kindCalls) + } + + inner.refs[1] = domain.ProfilePhotoRef{PhotoID: 333} + c.FlushReadModelCache() + profile, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile) + if err != nil { + t.Fatalf("profile after flush: %v", err) + } + if profile[1].PhotoID != 333 { + t.Fatalf("profile after flush = %+v, want 333", profile[1]) + } +} + +func TestCachedPhotoProviderDoesNotRefillStaleSnapshotAfterInvalidation(t *testing.T) { + ctx := context.Background() + inner := &blockingFirstPhotoProvider{ + started: make(chan struct{}), + release: make(chan struct{}), + first: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}, + refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}, + } + c := NewCachedPhotoProvider(inner, time.Minute) + + type readResult struct { + refs map[int64]domain.ProfilePhotoRef + err error + } + resultCh := make(chan readResult, 1) + go func() { + got, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile) + resultCh <- readResult{refs: got, err: err} + }() + waitForCacheTestSignal(t, inner.started) + + inner.setRef(1, domain.ProfilePhotoRef{PhotoID: 222}) + c.InvalidateOwner(domain.PeerTypeUser, 1) + close(inner.release) + + var result readResult + select { + case result = <-resultCh: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for photo read") + } + if result.err != nil { + t.Fatalf("photo read: %v", result.err) + } + if result.refs[1].PhotoID != 222 { + t.Fatalf("photo after concurrent invalidation = %+v, want 222", result.refs[1]) + } + + cachedHit, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile) + if err != nil { + t.Fatalf("cached hit after stale load retry: %v", err) + } + if cachedHit[1].PhotoID != 222 { + t.Fatalf("cached photo after stale load retry = %+v, want 222", cachedHit[1]) + } +} diff --git a/internal/app/userprojection/projection.go b/internal/app/userprojection/projection.go index 76eb0196..054b6fe3 100644 --- a/internal/app/userprojection/projection.go +++ b/internal/app/userprojection/projection.go @@ -3,6 +3,8 @@ package userprojection import ( "context" + "golang.org/x/sync/errgroup" + "telesrv/internal/domain" "telesrv/internal/store" ) @@ -22,6 +24,28 @@ type PrivacyEvaluator interface { CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error) } +// BatchPrivacyEvaluator 批量评估多 owner 对单 viewer 的可见性,消除 projectBatch / fan-out +// 投影里 per-user 3×CanSee 的 N+1。可选:实现了它的 evaluator(privacy.Service)会被 +// projectBatch 优先用批量预取,否则回退逐 CanSee。结果必须与逐 CanSee 字节等价。 +type BatchPrivacyEvaluator interface { + CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64, keys []domain.PrivacyKey) (map[int64]map[domain.PrivacyKey]bool, error) +} + +// MatrixPrivacyEvaluator 批量评估 owners×viewers×keys 的可见性矩阵(一次 ListPrivacyRules + +// 每 owner 一次 GetMany + 内存 Evaluate),供 ForViewers 把 fan-out 跨 viewer 投影的 privacy +// 查询从 O(viewer) 降到 O(owner)。可选:privacy.Service 实现了它,否则 ForViewers 回退逐 CanSee。 +// 结果必须与逐 CanSee 字节等价。 +type MatrixPrivacyEvaluator interface { + CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs []int64, keys []domain.PrivacyKey) (map[int64]map[int64]map[domain.PrivacyKey]bool, error) +} + +// privacyProjectionKeys 是 projectBatch 投影会用到的 privacy key(phone/status/photo)。 +var privacyProjectionKeys = []domain.PrivacyKey{ + domain.PrivacyKeyPhoneNumber, + domain.PrivacyKeyStatusTimestamp, + domain.PrivacyKeyProfilePhoto, +} + // Projector builds the current viewer's user view for RPC response payloads. // It intentionally stays in app/domain types; tg.* conversion remains in rpc. type Projector struct { @@ -77,6 +101,178 @@ func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.Use return projected[0], nil } +// ForViewers 跨多个 viewer 批量投影同一组 owner 用户(fan-out 模板化)。它把 per-viewer 各跑 +// 一遍 ForViewer(=projectBatch) 的成本(O(viewer)×(photos+contacts+privacy) 查询)压成: +// - 一次 profile/fallback 头像批量(跨 viewer 复用) +// - O(owner) 次 GetReverseContacts(改名/电话覆盖,按 owner 反查 viewer) +// - O(owner) 次 GetMany + 一次 ListPrivacyRules(CanSeeMatrix 内做) +// +// 返回 map[viewerID][]domain.User,每个切片与对应 viewer 的 ForViewer(viewer, users) **字节等价, +// 唯一例外是 personal photo overlay**:v1 简化为 fan-out 模板不做 per-viewer personal photo +// (无 O(owner) 反查接口),客户端下次 getChannelDifference/getHistory 会走 projectBatch 完整投影自愈。 +// 调用方传入的 users 不被修改(内部复制)。 +func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users []domain.User) (map[int64][]domain.User, error) { + out := make(map[int64][]domain.User, len(viewerUserIDs)) + if p == nil || len(users) == 0 { + for _, v := range viewerUserIDs { + out[v] = cloneUsers(users) + } + return out, nil + } + viewers := dedupNonZeroInt64(viewerUserIDs) + if len(viewers) == 0 { + for _, v := range viewerUserIDs { + out[v] = cloneUsers(users) + } + return out, nil + } + ids := uniqueUserIDs(users) + + // 三组预取互不依赖(共享头像、反向联系人覆盖、privacy 矩阵),并发执行收敛成一波。 + var ( + profileRefs map[int64]domain.ProfilePhotoRef + fallbackRefs map[int64]domain.ProfilePhotoRef + contactsByViewer map[int64]map[int64]domain.Contact + matrix map[int64]map[int64]map[domain.PrivacyKey]bool + ) + g, gctx := errgroup.WithContext(ctx) + // 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用;personal photo v1 跳过(见 doc)。 + g.Go(func() error { + var err error + profileRefs, fallbackRefs, err = p.batchProfileFallbackPhotos(gctx, ids) + return err + }) + // 2) 改名/电话覆盖:O(owner) 次 GetReverseContacts(owner, viewers) 重组为 [viewer][owner]Contact, + // 与 projectBatch 的 GetMany(viewer, owners) 命中同一条联系人记录(方向对称)。 + g.Go(func() error { + var err error + contactsByViewer, err = p.reverseContactsByViewer(gctx, ids, viewers) + return err + }) + // 3) privacy 可见性矩阵:O(owner) 查询;nil(无 MatrixPrivacyEvaluator)时 applyPrivacy 回退逐 CanSee。 + if me, ok := p.privacy.(MatrixPrivacyEvaluator); ok && p.privacy != nil { + g.Go(func() error { + var err error + matrix, err = me.CanSeeMatrix(gctx, ids, viewers, privacyProjectionKeys) + return err + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + // 4) 逐 viewer 组装,复用与 projectBatch 完全相同的 apply* 链(personalRefs 传 nil)。 + for _, viewer := range viewers { + projected := make([]domain.User, len(users)) + copy(projected, users) + cache := make(map[int64]domain.User, len(projected)) + for i := range projected { + u := projected[i] + if u.ID == 0 { + continue + } + if pj, ok := cache[u.ID]; ok { + projected[i] = pj + continue + } + pj := applyBasePhotos(u, profileRefs, fallbackRefs, nil, viewer) + if viewer != 0 && u.ID != viewer && u.ID != domain.OfficialSystemUserID && !u.Bot { + contact, found := contactsByViewer[viewer][u.ID] + pj = applyContactProjection(pj, contact, found) + var vis map[domain.PrivacyKey]bool + if matrix != nil { + vis = matrix[u.ID][viewer] + } + var perr error + pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, found, vis, profileRefs, fallbackRefs, nil) + if perr != nil { + return nil, perr + } + } + cache[u.ID] = pj + projected[i] = pj + } + out[viewer] = projected + } + return out, nil +} + +// batchProfileFallbackPhotos 取 owner 的 profile/fallback 头像(与 projectBatch 同逻辑),personal +// 头像不取(ForViewers v1 跳过)。photos 为 nil 时返回空 map(applyBasePhotos 视为无头像查询)。 +func (p *Projector) batchProfileFallbackPhotos(ctx context.Context, ids []int64) (profileRefs, fallbackRefs map[int64]domain.ProfilePhotoRef, err error) { + profileRefs = map[int64]domain.ProfilePhotoRef{} + fallbackRefs = map[int64]domain.ProfilePhotoRef{} + if p.photos == nil || len(ids) == 0 { + return profileRefs, fallbackRefs, nil + } + if kindPhotos, ok := p.photos.(ProfilePhotoKindProvider); ok { + refs, err := kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile) + if err != nil { + return nil, nil, err + } + profileRefs = refs + refs, err = kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindFallback) + if err != nil { + return nil, nil, err + } + fallbackRefs = refs + return profileRefs, fallbackRefs, nil + } + refs, err := p.photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids) + if err != nil { + return nil, nil, err + } + return refs, fallbackRefs, nil +} + +// reverseContactsByViewer 以 O(owner) 次 GetReverseContacts(owner, viewers) 取「每个 viewer 对各 +// owner 的联系人记录」并重组为 map[viewer]map[owner]Contact。该记录与 projectBatch 的 +// GetMany(viewer, owners)[owner] 是同一条(contacts 表上 (user_id=viewer, contact_user_id=owner) +// 的同一行,两端 store 均如此),用于 applyContactProjection 的改名/电话覆盖与 isContact 判定。 +func (p *Projector) reverseContactsByViewer(ctx context.Context, ownerIDs, viewers []int64) (map[int64]map[int64]domain.Contact, error) { + out := make(map[int64]map[int64]domain.Contact, len(viewers)) + if p.contacts == nil || len(ownerIDs) == 0 || len(viewers) == 0 { + return out, nil + } + for _, owner := range ownerIDs { + byViewer, err := p.contacts.GetReverseContacts(ctx, owner, viewers) + if err != nil { + return nil, err + } + for viewer, contact := range byViewer { + if out[viewer] == nil { + out[viewer] = make(map[int64]domain.Contact, len(ownerIDs)) + } + out[viewer][owner] = contact + } + } + return out, nil +} + +func cloneUsers(users []domain.User) []domain.User { + if len(users) == 0 { + return nil + } + out := make([]domain.User, len(users)) + copy(out, users) + return out +} + +func dedupNonZeroInt64(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + // WithProfilePhotos enriches users with their current avatar from profile photo storage. // The lookup is best-effort: a storage error keeps the original user list. func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User { @@ -124,7 +320,7 @@ func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID in cache := make(map[int64]domain.User, len(users)) for i := range out { u := out[i] - if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID { + if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot { continue } if projected, ok := cache[u.ID]; ok { @@ -157,40 +353,76 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi out := make([]domain.User, len(users)) copy(out, users) ids := uniqueUserIDs(out) - profileRefs := map[int64]domain.ProfilePhotoRef{} - fallbackRefs := map[int64]domain.ProfilePhotoRef{} - personalRefs := map[int64]domain.ProfilePhotoRef{} + var ( + profileRefs = map[int64]domain.ProfilePhotoRef{} + fallbackRefs = map[int64]domain.ProfilePhotoRef{} + personalRefs = map[int64]domain.ProfilePhotoRef{} + contactsByID map[int64]domain.Contact + visibility map[int64]map[domain.PrivacyKey]bool + ) + // 这些预取查询互不依赖(头像 profile/fallback、联系人 GetMany/PersonalPhotos、privacy 可见性), + // 并发执行把 ~6 次串行 round-trip 收敛成一波;每个 goroutine 只写自己那一个变量,组装循环在 + // Wait 之后串行进行(纯内存、无查询),无数据竞争。 + g, gctx := errgroup.WithContext(ctx) if photos != nil && len(ids) > 0 { if kindPhotos, ok := photos.(ProfilePhotoKindProvider); ok { - refs, err := kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile) - if err != nil { - return nil, err - } - profileRefs = refs - refs, err = kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindFallback) - if err != nil { - return nil, err - } - fallbackRefs = refs + g.Go(func() error { + refs, err := kindPhotos.CurrentProfilePhotosKind(gctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile) + if err != nil { + return err + } + profileRefs = refs + return nil + }) + g.Go(func() error { + refs, err := kindPhotos.CurrentProfilePhotosKind(gctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindFallback) + if err != nil { + return err + } + fallbackRefs = refs + return nil + }) } else { - refs, err := photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids) - if err != nil { - return nil, err - } - profileRefs = refs + g.Go(func() error { + refs, err := photos.CurrentProfilePhotos(gctx, domain.PeerTypeUser, ids) + if err != nil { + return err + } + profileRefs = refs + return nil + }) } } - var contactsByID map[int64]domain.Contact if contacts != nil && viewerUserID != 0 && len(ids) > 0 { - var err error - contactsByID, err = contacts.GetMany(ctx, viewerUserID, ids) + g.Go(func() error { + m, err := contacts.GetMany(gctx, viewerUserID, ids) + if err != nil { + return err + } + contactsByID = m + return nil + }) + g.Go(func() error { + refs, err := contacts.PersonalPhotos(gctx, viewerUserID, ids) + if err != nil { + return err + } + personalRefs = refs + return nil + }) + } + // 批量预取 privacy 可见性(若 evaluator 支持):把 per-user 3×CanSee×2行 的 N+1 降到 + // 一次 ListPrivacyRules + 一次 GetReverseContacts + 内存 Evaluate;nil 时 applyPrivacy 回退逐 CanSee。 + g.Go(func() error { + v, err := prefetchPrivacyVisibility(gctx, privacy, viewerUserID, out) if err != nil { - return nil, err - } - personalRefs, err = contacts.PersonalPhotos(ctx, viewerUserID, ids) - if err != nil { - return nil, err + return err } + visibility = v + return nil + }) + if err := g.Wait(); err != nil { + return nil, err } cache := make(map[int64]domain.User, len(out)) for i := range out { @@ -203,11 +435,13 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi continue } projected := applyBasePhotos(u, profileRefs, fallbackRefs, personalRefs, viewerUserID) - if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID { + // bot 与系统账号豁免联系人/privacy 投影:官方 bot 无 phone/last seen, + // 不参与联系人改名与隐私裁剪。 + if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID && !u.Bot { contact, found := contactsByID[u.ID] projected = applyContactProjection(projected, contact, found) var err error - projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, profileRefs, fallbackRefs, personalRefs) + projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, visibility[u.ID], profileRefs, fallbackRefs, personalRefs) if err != nil { return nil, err } @@ -218,6 +452,32 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi return out, nil } +func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) (map[int64]map[domain.PrivacyKey]bool, error) { + if privacy == nil || viewerUserID == 0 { + return nil, nil + } + batch, ok := privacy.(BatchPrivacyEvaluator) + if !ok { + return nil, nil + } + ids := make([]int64, 0, len(users)) + seen := make(map[int64]struct{}, len(users)) + for _, u := range users { + if u.ID == 0 || u.ID == viewerUserID || u.ID == domain.OfficialSystemUserID || u.Bot { + continue + } + if _, ok := seen[u.ID]; ok { + continue + } + seen[u.ID] = struct{}{} + ids = append(ids, u.ID) + } + if len(ids) == 0 { + return nil, nil + } + return batch.CanSeeBatch(ctx, ids, viewerUserID, privacyProjectionKeys) +} + func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) { contact, found, err := contacts.Get(ctx, viewerUserID, user.ID) if err != nil { @@ -227,11 +487,13 @@ func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID i user.Phone = "" user.Contact = false user.Mutual = false + user.CloseFriend = false return user, nil } projected := user projected.Contact = true projected.Mutual = contact.Mutual || contact.User.Mutual + projected.CloseFriend = contact.CloseFriend || contact.User.CloseFriend if contact.User.Phone != "" { projected.Phone = contact.User.Phone } else { @@ -290,10 +552,12 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool user.Phone = "" user.Contact = false user.Mutual = false + user.CloseFriend = false return user } user.Contact = true user.Mutual = contact.Mutual || contact.User.Mutual + user.CloseFriend = contact.CloseFriend || contact.User.CloseFriend if contact.User.Phone != "" { user.Phone = contact.User.Phone } else { @@ -309,18 +573,26 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool return user } -func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) { +func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) { if privacy == nil { return user, nil } - phoneAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyPhoneNumber) + // vis 为批量预取结果(projectBatch 一次 ListPrivacyRules+GetReverseContacts 算得); + // 为 nil 时回退逐 CanSee,二者结果等价。 + canSee := func(key domain.PrivacyKey) (bool, error) { + if vis != nil { + return vis[key], nil + } + return privacy.CanSee(ctx, user.ID, viewerUserID, key) + } + phoneAllowed, err := canSee(domain.PrivacyKeyPhoneNumber) if err != nil { return domain.User{}, err } if !phoneAllowed && !isContact { user.Phone = "" } - statusAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyStatusTimestamp) + statusAllowed, err := canSee(domain.PrivacyKeyStatusTimestamp) if err != nil { return domain.User{}, err } @@ -338,7 +610,7 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) && user.PhotoID == 0 { return user, nil } - profileAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyProfilePhoto) + profileAllowed, err := canSee(domain.PrivacyKeyProfilePhoto) if err != nil { return domain.User{}, err } @@ -365,6 +637,7 @@ func applyPhotoRef(user *domain.User, ref domain.ProfilePhotoRef) { user.PhotoDCID = ref.DCID user.PhotoStripped = append([]byte(nil), ref.Stripped...) user.PhotoPersonal = ref.Personal + user.PhotoHasVideo = ref.HasVideo } func clearPhoto(user *domain.User) { @@ -372,4 +645,5 @@ func clearPhoto(user *domain.User) { user.PhotoDCID = 0 user.PhotoStripped = nil user.PhotoPersonal = false + user.PhotoHasVideo = false } diff --git a/internal/app/userprojection/projection_test.go b/internal/app/userprojection/projection_test.go index d9fe06b1..f6013c3c 100644 --- a/internal/app/userprojection/projection_test.go +++ b/internal/app/userprojection/projection_test.go @@ -2,6 +2,7 @@ package userprojection import ( "context" + "reflect" "testing" privacyapp "telesrv/internal/app/privacy" @@ -113,6 +114,114 @@ func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) { } } +// TestForViewersEquivalentToForViewer 锁定 fan-out 模板化的核心安全网:ForViewers(viewers, users) +// 的每个 viewer 切片必须与逐 viewer 的 ForViewer(viewer, users) 字节等价(隐私/改名/头像投影 +// 不能因 O(owner) 模板化而漂移泄漏)。**唯一允许的差异是 personal photo overlay**:v1 模板不做 +// per-viewer personal photo,故对「该 viewer 给该 owner 设过 personal photo」的对,比较前 mask 掉 +// 5 个头像字段;其余对做完整字节比较。覆盖:默认规则陌生人/联系人改名+电话/status 隐藏/profile +// 头像隐藏走 fallback/self/bot/系统账号/viewer 自身也作为 owner 出现。 +func TestForViewersEquivalentToForViewer(t *testing.T) { + ctx := context.Background() + const ( + v1 = int64(5001) + v2 = int64(5002) + o1 = int64(5101) // 陌生人,默认规则 + o2 = int64(5102) // v1 的联系人(改名+电话),且 v1 给 o2 设了 personal photo + o3 = int64(5103) // status 隐藏 + o4 = int64(5104) // profile photo 隐藏 → 走 fallback + bot = int64(5105) + ) + viewers := []int64{v1, v2} + + contacts := memory.NewContactStore() + rules := memory.NewPrivacyStore() + privacy := privacyapp.NewService(rules, contacts) + + if _, err := contacts.Upsert(ctx, v1, domain.ContactInput{ContactUserID: o2, Phone: "1111", FirstName: "Alice", LastName: "Friend"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + // v1 给 o2 设 personal photo(仅 v1 视角生效 → ForViewer 会带它,ForViewers v1 跳过 → 该对需 mask)。 + if _, _, err := contacts.SetPersonalPhoto(ctx, v1, o2, 9300, 300); err != nil { + t.Fatalf("set personal photo: %v", err) + } + if _, err := privacy.SetRules(ctx, o3, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil { + t.Fatalf("set o3 status: %v", err) + } + if _, err := privacy.SetRules(ctx, o4, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil { + t.Fatalf("set o4 photo: %v", err) + } + + projector := New( + WithContactStore(contacts), + WithPrivacyEvaluator(privacy), + WithPhotoProvider(fakeProfilePhotos{ + profile: map[int64]domain.ProfilePhotoRef{ + o1: {PhotoID: 9001, DCID: 1, Stripped: []byte{1}}, + o2: {PhotoID: 9002, DCID: 2, Stripped: []byte{2}}, + o3: {PhotoID: 9003, DCID: 3, Stripped: []byte{3}}, + o4: {PhotoID: 9004, DCID: 4, Stripped: []byte{4}}, + v1: {PhotoID: 9005, DCID: 5, Stripped: []byte{5}}, + v2: {PhotoID: 9006, DCID: 6, Stripped: []byte{6}}, + }, + fallback: map[int64]domain.ProfilePhotoRef{ + o4: {PhotoID: 9404, DCID: 4, Stripped: []byte{44}}, + }, + }), + ) + + users := []domain.User{ + {ID: o1, AccessHash: 11, Phone: "15550000001", FirstName: "Stranger", Status: domain.UserStatus{Kind: domain.UserStatusOnline}}, + {ID: o2, AccessHash: 12, Phone: "15550000002", FirstName: "PublicO2", Status: domain.UserStatus{Kind: domain.UserStatusOnline}}, + {ID: o3, AccessHash: 13, Phone: "15550000003", FirstName: "O3", Status: domain.UserStatus{Kind: domain.UserStatusOnline}, LastSeenAt: 123}, + {ID: o4, AccessHash: 14, Phone: "15550000004", FirstName: "O4"}, + {ID: bot, AccessHash: 15, FirstName: "Bot", Bot: true}, + {ID: domain.OfficialSystemUserID, FirstName: "System"}, + {ID: v1, AccessHash: 16, Phone: "15550000016", FirstName: "Viewer1"}, // viewer 自身也作为 owner 出现 + } + + // 哪些 (viewer, owner) 对存在 personal photo —— 比较时需 mask 头像字段(v1 模板有意跳过)。 + personalPairs := map[[2]int64]bool{{v1, o2}: true} + + batch, err := projector.ForViewers(ctx, viewers, users) + if err != nil { + t.Fatalf("ForViewers: %v", err) + } + for _, viewer := range viewers { + want, err := projector.ForViewer(ctx, viewer, users) + if err != nil { + t.Fatalf("ForViewer(%d): %v", viewer, err) + } + got, ok := batch[viewer] + if !ok { + t.Fatalf("ForViewers missing viewer %d", viewer) + } + if len(got) != len(want) { + t.Fatalf("viewer %d len(got)=%d len(want)=%d", viewer, len(got), len(want)) + } + for i := range want { + w, g := want[i], got[i] + if w.ID != g.ID { + t.Fatalf("viewer %d idx %d id mismatch got=%d want=%d", viewer, i, g.ID, w.ID) + } + if personalPairs[[2]int64{viewer, w.ID}] { + maskPhoto(&w) + maskPhoto(&g) + } + if !reflect.DeepEqual(w, g) { + t.Fatalf("viewer %d owner %d: ForViewers != ForViewer\n got=%+v\nwant=%+v", viewer, w.ID, g, w) + } + } + } +} + +func maskPhoto(u *domain.User) { + u.PhotoID = 0 + u.PhotoDCID = 0 + u.PhotoStripped = nil + u.PhotoPersonal = false + u.PhotoHasVideo = false +} + func projectionUser(t *testing.T, users []domain.User, id int64) domain.User { t.Helper() for _, user := range users { diff --git a/internal/app/users/premium_test.go b/internal/app/users/premium_test.go new file mode 100644 index 00000000..4818b6ac --- /dev/null +++ b/internal/app/users/premium_test.go @@ -0,0 +1,145 @@ +package users + +import ( + "context" + "errors" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestGrantPremiumSemantics(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + u, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000101", FirstName: "P"}) + if err != nil { + t.Fatalf("create: %v", err) + } + svc := NewService(store) + + // 首次授予:从 now 起算 3 个月。 + granted, err := svc.GrantPremium(ctx, u.ID, 3) + if err != nil { + t.Fatalf("GrantPremium: %v", err) + } + wantMin := time.Now().AddDate(0, 3, 0).Add(-time.Minute).Unix() + wantMax := time.Now().AddDate(0, 3, 0).Add(time.Minute).Unix() + if int64(granted.PremiumUntil) < wantMin || int64(granted.PremiumUntil) > wantMax { + t.Fatalf("first grant until = %d, want ~now+3mo [%d,%d]", granted.PremiumUntil, wantMin, wantMax) + } + + // 未过期续期:在现有到期时间上累加。 + renewed, err := svc.GrantPremium(ctx, u.ID, 1) + if err != nil { + t.Fatalf("renew: %v", err) + } + base := time.Unix(int64(granted.PremiumUntil), 0) + if got, want := int64(renewed.PremiumUntil), base.AddDate(0, 1, 0).Unix(); got != want { + t.Fatalf("renew until = %d, want %d (accumulated)", got, want) + } + + // 清除。 + cleared, err := svc.GrantPremium(ctx, u.ID, 0) + if err != nil { + t.Fatalf("clear: %v", err) + } + if cleared.PremiumUntil != 0 { + t.Fatalf("cleared until = %d, want 0", cleared.PremiumUntil) + } + + // 已过期重授:从 now 起算而非累加。 + if _, err := store.SetPremiumUntil(ctx, u.ID, int(time.Now().Add(-time.Hour).Unix())); err != nil { + t.Fatalf("seed expired: %v", err) + } + regranted, err := svc.GrantPremium(ctx, u.ID, 3) + if err != nil { + t.Fatalf("regrant: %v", err) + } + if int64(regranted.PremiumUntil) < wantMin { + t.Fatalf("regrant until = %d, want from now (≥%d)", regranted.PremiumUntil, wantMin) + } + + // bot 拒绝授予。 + if _, err := svc.GrantPremium(ctx, domain.BotFatherUserID, 3); !errors.Is(err, domain.ErrPremiumBotUnsupported) { + t.Fatalf("grant bot err = %v, want ErrPremiumBotUnsupported", err) + } +} + +func TestSweepExpiredPremium(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + expired, _ := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000102", FirstName: "E"}) + active, _ := store.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000103", FirstName: "A"}) + now := time.Now().Unix() + if _, err := store.SetPremiumUntil(ctx, expired.ID, int(now-10)); err != nil { + t.Fatalf("seed expired: %v", err) + } + if _, err := store.SetPremiumUntil(ctx, active.ID, int(now+3600)); err != nil { + t.Fatalf("seed active: %v", err) + } + svc := NewService(store) + + swept, err := svc.SweepExpiredPremium(ctx, now, 100) + if err != nil { + t.Fatalf("sweep: %v", err) + } + if len(swept) != 1 || swept[0].ID != expired.ID || swept[0].PremiumUntil != 0 { + t.Fatalf("swept = %+v, want 仅过期用户且 until 清零", swept) + } + // 幂等:第二轮无事可做。 + again, err := svc.SweepExpiredPremium(ctx, now, 100) + if err != nil || len(again) != 0 { + t.Fatalf("second sweep = %+v err %v, want empty", again, err) + } + // 活跃用户不受影响。 + got, _, _ := store.ByID(ctx, active.ID) + if got.PremiumUntil != int(now+3600) { + t.Fatalf("active until = %d, want untouched", got.PremiumUntil) + } +} + +func TestUpdateEmojiStatusPremiumGate(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + u, _ := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000104", FirstName: "S"}) + svc := NewService(store) + + // 非会员设置被拒(PREMIUM_ACCOUNT_REQUIRED)。 + if _, err := svc.UpdateEmojiStatus(ctx, u.ID, 42, 0); !errors.Is(err, domain.ErrPremiumRequired) { + t.Fatalf("non-premium set err = %v, want ErrPremiumRequired", err) + } + + // 会员可设置;到期清理后残值不再下发,但显式清除仍允许。 + if _, err := store.SetPremiumUntil(ctx, u.ID, int(time.Now().Add(time.Hour).Unix())); err != nil { + t.Fatalf("grant: %v", err) + } + set, err := svc.UpdateEmojiStatus(ctx, u.ID, 42, 0) + if err != nil || set.EmojiStatusDocumentID != 42 { + t.Fatalf("premium set = %+v err %v, want document 42", set, err) + } + if _, err := store.SetPremiumUntil(ctx, u.ID, 0); err != nil { + t.Fatalf("downgrade: %v", err) + } + cleared, err := svc.UpdateEmojiStatus(ctx, u.ID, 0, 0) + if err != nil || cleared.EmojiStatusDocumentID != 0 { + t.Fatalf("clear after downgrade = %+v err %v, want cleared", cleared, err) + } +} + +func TestPremiumActiveUsesBaseUser(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + u, _ := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000105", FirstName: "B"}) + svc := NewService(store) + if svc.PremiumActive(ctx, u.ID) { + t.Fatal("non-premium user reported active") + } + if _, err := store.SetPremiumUntil(ctx, u.ID, int(time.Now().Add(time.Hour).Unix())); err != nil { + t.Fatalf("grant: %v", err) + } + if !svc.PremiumActive(ctx, u.ID) { + t.Fatal("premium user reported inactive") + } +} diff --git a/internal/app/users/service.go b/internal/app/users/service.go index ec618a4f..96b9e27f 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strings" + "time" "unicode/utf8" "telesrv/internal/app/userprojection" @@ -20,12 +21,17 @@ type ProfilePhotoProvider = userprojection.ProfilePhotoProvider // Service 提供用户查询。 type Service struct { users store.UserStore + cache store.UserCache contacts store.ContactStore photos ProfilePhotoProvider privacy userprojection.PrivacyEvaluator projector *userprojection.Projector } +type usernameAvailabilityStore interface { + CheckUsername(ctx context.Context, userID int64, username string) (bool, error) +} + // Option 调整用户服务可选依赖。 type Option func(*Service) @@ -34,6 +40,11 @@ func WithPhotoProvider(p ProfilePhotoProvider) Option { return func(s *Service) { s.photos = p } } +// WithBaseUserCache injects a viewer-independent user base cache. +func WithBaseUserCache(c store.UserCache) Option { + return func(s *Service) { s.cache = c } +} + // WithContactStore enables viewer-specific contact name/phone projection. func WithContactStore(c store.ContactStore) Option { return func(s *Service) { s.contacts = c } @@ -45,11 +56,15 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option { } const ( - minUsernameLen = 5 - maxUsernameLen = 32 - maxProfileNameRunes = 64 - maxProfileAboutRunes = 70 - maxBatchUsers = 1000 + minUsernameLen = 5 + maxUsernameLen = 32 + maxProfileNameRunes = 64 + // bio 长度双档,对齐 appConfig about_length_limit_default=70 / + // about_length_limit_premium=140;客户端按 self premium flag 选档, + // 服务端档位必须 ≥ 客户端宣告档位。 + maxProfileAboutRunes = 70 + maxProfileAboutRunesPremium = 140 + maxBatchUsers = 1000 ) // NewService 创建用户服务。 @@ -71,7 +86,7 @@ func (s *Service) loadSelf(ctx context.Context, userID int64) (domain.User, erro if userID == 0 { return domain.User{}, ErrNotAuthorized } - u, found, err := s.users.ByID(ctx, userID) + u, found, err := s.loadBaseUserByID(ctx, userID) if err != nil { return domain.User{}, err } @@ -95,7 +110,7 @@ func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain if currentUserID == 0 { return domain.User{}, false, ErrNotAuthorized } - u, found, err := s.users.ByID(ctx, userID) + u, found, err := s.loadBaseUserByID(ctx, userID) if err != nil { return domain.User{}, false, err } @@ -109,6 +124,14 @@ func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain return u, true, nil } +// AdminUser 返回 viewer 无关的账号基础事实,供管理用例做 dry-run 与审计。 +func (s *Service) AdminUser(ctx context.Context, userID int64) (domain.User, bool, error) { + if userID == 0 { + return domain.User{}, false, nil + } + return s.loadBaseUserByID(ctx, userID) +} + // ByIDs 批量返回指定用户。调用方必须已登录;缺失用户不会出现在结果中。 func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) { if currentUserID == 0 { @@ -132,13 +155,32 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6 break } } - users, err := s.users.ByIDs(ctx, ids) + users, err := s.loadBaseUsersByIDs(ctx, ids) if err != nil { return nil, err } return s.projectUsers(ctx, currentUserID, users) } +// ByIDsForViewers 跨多个 viewer 批量投影同一组 user(fan-out 模板化):base user 只加载一次, +// 隐私/改名/头像投影经 userprojection.ForViewers 压成 O(owner) 查询。返回 map[viewerID][]User, +// 每个切片与 ByIDs(viewer, ids) 字节等价——**唯一例外是 personal photo overlay**(ForViewers v1 +// 跳过,客户端下次 getChannelDifference/getHistory 自愈)。供 channel fan-out 预热每 viewer 投影, +// 把 per-recipient 的 ByIDs(=ForViewer) 折叠成一次跨 viewer 投影。不做 ByIDs 的单 caller 鉴权 +// (viewer 是 fan-out 收件人集合,非 RPC 调用方)。 +func (s *Service) ByIDsForViewers(ctx context.Context, viewerUserIDs []int64, userIDs []int64) (map[int64][]domain.User, error) { + if len(viewerUserIDs) == 0 || len(userIDs) == 0 { + return map[int64][]domain.User{}, nil + } + ids := uniqueUserIDs(userIDs, maxBatchUsers) + base, err := s.loadBaseUsersByIDs(ctx, ids) + if err != nil { + return nil, err + } + // projector 为 nil 时 ForViewers 返回各 viewer 的原始 base 副本(与 projectUsers 的 nil 分支一致)。 + return s.projector.ForViewers(ctx, viewerUserIDs, base) +} + // CheckUsername 校验当前用户是否可以占用 username。 func (s *Service) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) { self, err := s.loadSelf(ctx, userID) @@ -149,11 +191,18 @@ func (s *Service) CheckUsername(ctx context.Context, userID int64, username stri if !validUsername(username) { return false, domain.ErrUsernameInvalid } + return s.checkUsernameAvailable(ctx, self.ID, username) +} + +func (s *Service) checkUsernameAvailable(ctx context.Context, selfID int64, username string) (bool, error) { + if checker, ok := s.users.(usernameAvailabilityStore); ok { + return checker.CheckUsername(ctx, selfID, username) + } u, found, err := s.users.ByUsername(ctx, username) if err != nil { return false, err } - return !found || u.ID == self.ID, nil + return !found || u.ID == selfID, nil } // UpdateUsername 修改当前用户的主 username。空字符串表示删除 username。 @@ -167,11 +216,11 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str if !validUsername(username) { return domain.User{}, domain.ErrUsernameInvalid } - u, found, err := s.users.ByUsername(ctx, username) + ok, err := s.checkUsernameAvailable(ctx, self.ID, username) if err != nil { return domain.User{}, err } - if found && u.ID != self.ID { + if !ok { return domain.User{}, domain.ErrUsernameOccupied } } @@ -182,6 +231,7 @@ func (s *Service) UpdateUsername(ctx context.Context, userID int64, username str if err != nil { return domain.User{}, err } + s.refreshCachedUsers(ctx, u) return u, nil } @@ -206,13 +256,22 @@ func (s *Service) UpdateProfile(ctx context.Context, userID int64, update domain if firstName == "" || utf8.RuneCountInString(firstName) > maxProfileNameRunes || utf8.RuneCountInString(lastName) > maxProfileNameRunes { return domain.User{}, domain.ErrFirstNameInvalid } - if utf8.RuneCountInString(about) > maxProfileAboutRunes { + aboutLimit := maxProfileAboutRunes + if self.PremiumActiveAt(time.Now().Unix()) { + aboutLimit = maxProfileAboutRunesPremium + } + if utf8.RuneCountInString(about) > aboutLimit { return domain.User{}, domain.ErrAboutTooLong } if firstName == self.FirstName && lastName == self.LastName && about == self.About { return self, nil } - return s.users.UpdateProfile(ctx, self.ID, firstName, lastName, about) + u, err := s.users.UpdateProfile(ctx, self.ID, firstName, lastName, about) + if err != nil { + return domain.User{}, err + } + s.refreshCachedUsers(ctx, u) + return u, nil } // UpdateLastSeen records the latest visible account activity time. @@ -223,7 +282,165 @@ func (s *Service) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt i if lastSeenAt <= 0 { return nil } - return s.users.UpdateLastSeen(ctx, userID, lastSeenAt) + if err := s.users.UpdateLastSeen(ctx, userID, lastSeenAt); err != nil { + return err + } + s.dropCachedUsers(ctx, userID) + return nil +} + +// PremiumActive 报告用户当前是否有效会员。走基础用户缓存路径、不做 viewer +// 投影,供限额双档判断(pin 上限、reaction 上限、bio 长度等)低成本调用。 +func (s *Service) PremiumActive(ctx context.Context, userID int64) bool { + if s == nil || userID == 0 { + return false + } + u, found, err := s.loadBaseUserByID(ctx, userID) + if err != nil || !found { + return false + } + return u.PremiumActiveAt(time.Now().Unix()) +} + +// GrantPremium 授予/续期会员:未过期则在现有到期时间上累加 months 个月, +// 已过期或首次则从当前时刻起算(对齐官方续期语义)。months<=0 清除会员。 +// bot 永不可成为会员。 +func (s *Service) GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error) { + if userID == 0 { + return domain.User{}, ErrNotAuthorized + } + u, found, err := s.users.ByID(ctx, userID) + if err != nil { + return domain.User{}, err + } + if !found { + return domain.User{}, domain.ErrUserNotFound + } + if u.Bot { + return domain.User{}, domain.ErrPremiumBotUnsupported + } + until := 0 + if months > 0 { + now := time.Now() + base := now + if u.PremiumUntil > 0 && int64(u.PremiumUntil) > now.Unix() { + base = time.Unix(int64(u.PremiumUntil), 0) + } + until = int(base.AddDate(0, months, 0).Unix()) + } + updated, err := s.users.SetPremiumUntil(ctx, userID, until) + if err != nil { + return domain.User{}, err + } + s.refreshCachedUsers(ctx, updated) + return updated, nil +} + +// SetVerified 设置/取消用户认证标记。认证是账号基础事实,所有 user 投影统一消费该字段。 +func (s *Service) SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error) { + if userID == 0 { + return domain.User{}, ErrNotAuthorized + } + u, found, err := s.users.ByID(ctx, userID) + if err != nil { + return domain.User{}, err + } + if !found { + return domain.User{}, domain.ErrUserNotFound + } + if u.Verified == verified { + return u, nil + } + updated, err := s.users.SetVerified(ctx, userID, verified) + if err != nil { + return domain.User{}, err + } + s.refreshCachedUsers(ctx, updated) + return updated, nil +} + +// SweepExpiredPremium 清理到期会员(store 把过期行清 NULL)并失效用户缓存, +// 返回清理后的用户,供 RPC 层向本人在线 session 推 updateUser。premium 下发 +// 正确性由读取路径即时派生保证,这里只做收尾与通知。 +func (s *Service) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) { + users, err := s.users.SweepExpiredPremium(ctx, now, limit) + if err != nil || len(users) == 0 { + return users, err + } + ids := make([]int64, 0, len(users)) + for _, u := range users { + ids = append(ids, u.ID) + } + s.dropCachedUsers(ctx, ids...) + return users, nil +} + +// UpdateEmojiStatus 更新当前用户 emoji status(premium 专属;documentID=0 清除)。 +// 清除不要求会员(到期降级后客户端仍可显式清掉残留状态)。 +func (s *Service) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + if documentID != 0 && !self.PremiumActiveAt(time.Now().Unix()) { + return domain.User{}, domain.ErrPremiumRequired + } + u, err := s.users.UpdateEmojiStatus(ctx, self.ID, documentID, until) + if err != nil { + return domain.User{}, err + } + s.refreshCachedUsers(ctx, u) + return u, nil +} + +// UpdateBirthday 设置/清除用户生日(account.updateBirthday)。零值 Birthday 表示清除。 +func (s *Service) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + if birthday.IsSet() { + if !domain.ValidBirthday(birthday) { + return domain.User{}, domain.ErrBirthdayInvalid + } + } else { + birthday = domain.Birthday{} // 归一化为清除 + } + u, err := s.users.UpdateBirthday(ctx, self.ID, birthday) + if err != nil { + return domain.User{}, err + } + s.refreshCachedUsers(ctx, u) + return u, nil +} + +// UpdatePersonalChannel 设置/清除资料页个人频道(account.updatePersonalChannel); +// channelID=0 表示清除。频道存在性与「调用者是其成员」由 RPC 层在调用前校验。 +func (s *Service) UpdatePersonalChannel(ctx context.Context, userID int64, channelID int64) (domain.User, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + u, err := s.users.UpdatePersonalChannel(ctx, self.ID, channelID) + if err != nil { + return domain.User{}, err + } + s.refreshCachedUsers(ctx, u) + return u, nil +} + +// UpdateColor updates the user's message accent or profile background color. +func (s *Service) UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) { + self, err := s.loadSelf(ctx, userID) + if err != nil { + return domain.User{}, err + } + u, err := s.users.UpdateColor(ctx, self.ID, forProfile, color) + if err != nil { + return domain.User{}, err + } + s.refreshCachedUsers(ctx, u) + return u, nil } // ResolveUsername 解析 username 到用户;调用方必须已登录。 @@ -239,6 +456,7 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user if err != nil || !found { return u, found, err } + s.putCachedUsers(ctx, u) u, err = s.projectOne(ctx, currentUserID, u) if err != nil { return domain.User{}, false, err @@ -259,6 +477,7 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s if err != nil || !found { return u, found, err } + s.putCachedUsers(ctx, u) u, err = s.projectOne(ctx, currentUserID, u) if err != nil { return domain.User{}, false, err @@ -273,6 +492,107 @@ func (s *Service) projectUsers(ctx context.Context, viewerUserID int64, users [] return s.projector.ForViewer(ctx, viewerUserID, users) } +func (s *Service) loadBaseUserByID(ctx context.Context, userID int64) (domain.User, bool, error) { + users, err := s.loadBaseUsersByIDs(ctx, []int64{userID}) + if err != nil { + return domain.User{}, false, err + } + if len(users) == 0 { + return domain.User{}, false, nil + } + return users[0], true, nil +} + +func (s *Service) loadBaseUsersByIDs(ctx context.Context, userIDs []int64) ([]domain.User, error) { + ids := uniqueUserIDs(userIDs, maxBatchUsers) + if len(ids) == 0 { + return nil, nil + } + loaded := make(map[int64]domain.User, len(ids)) + misses := append([]int64(nil), ids...) + if s.cache != nil { + if cached, err := s.cache.GetByIDs(ctx, ids); err == nil && len(cached) > 0 { + for id, u := range cached { + if u.ID != 0 { + loaded[id] = u + } + } + misses = make([]int64, 0, len(ids)) + for _, id := range ids { + if _, ok := loaded[id]; !ok { + misses = append(misses, id) + } + } + } + } + if len(misses) > 0 { + users, err := s.users.ByIDs(ctx, misses) + if err != nil { + return nil, err + } + for _, u := range users { + if u.ID != 0 { + loaded[u.ID] = u + } + } + s.putCachedUsers(ctx, users...) + } + out := make([]domain.User, 0, len(ids)) + for _, id := range ids { + if u, ok := loaded[id]; ok { + out = append(out, u) + } + } + return out, nil +} + +func (s *Service) refreshCachedUsers(ctx context.Context, users ...domain.User) { + ids := make([]int64, 0, len(users)) + for _, u := range users { + if u.ID != 0 { + ids = append(ids, u.ID) + } + } + s.dropCachedUsers(ctx, ids...) + s.putCachedUsers(ctx, users...) +} + +func (s *Service) putCachedUsers(ctx context.Context, users ...domain.User) { + if s.cache == nil || len(users) == 0 { + return + } + _ = s.cache.PutMany(ctx, users) +} + +func (s *Service) dropCachedUsers(ctx context.Context, userIDs ...int64) { + if s.cache == nil || len(userIDs) == 0 { + return + } + _ = s.cache.Delete(ctx, userIDs) +} + +func uniqueUserIDs(ids []int64, limit int) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + func (s *Service) projectOne(ctx context.Context, viewerUserID int64, user domain.User) (domain.User, error) { if s == nil || s.projector == nil { return user, nil diff --git a/internal/app/users/service_test.go b/internal/app/users/service_test.go index 64c56701..432de6ee 100644 --- a/internal/app/users/service_test.go +++ b/internal/app/users/service_test.go @@ -91,6 +91,62 @@ func TestServiceUpdateProfile(t *testing.T) { } } +func TestServiceUpdateBirthday(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000010", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + svc := NewService(store) + + // 设置带年份的生日。 + u, err := svc.UpdateBirthday(ctx, owner.ID, domain.Birthday{Day: 14, Month: 2, Year: 1990}) + if err != nil { + t.Fatalf("UpdateBirthday: %v", err) + } + if u.Birthday != (domain.Birthday{Day: 14, Month: 2, Year: 1990}) { + t.Fatalf("birthday = %+v, want 14/2/1990", u.Birthday) + } + // 非法月份被拒。 + if _, err := svc.UpdateBirthday(ctx, owner.ID, domain.Birthday{Day: 1, Month: 13}); !errors.Is(err, domain.ErrBirthdayInvalid) { + t.Fatalf("invalid month err = %v, want birthday invalid", err) + } + // 清除(零值)后 IsSet=false。 + u, err = svc.UpdateBirthday(ctx, owner.ID, domain.Birthday{}) + if err != nil { + t.Fatalf("clear birthday: %v", err) + } + if u.Birthday.IsSet() { + t.Fatalf("birthday after clear = %+v, want unset", u.Birthday) + } +} + +func TestServiceUpdatePersonalChannel(t *testing.T) { + ctx := context.Background() + store := memory.NewUserStore() + owner, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000011", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + svc := NewService(store) + + u, err := svc.UpdatePersonalChannel(ctx, owner.ID, 4242) + if err != nil { + t.Fatalf("UpdatePersonalChannel: %v", err) + } + if u.PersonalChannelID != 4242 { + t.Fatalf("personal channel = %d, want 4242", u.PersonalChannelID) + } + u, err = svc.UpdatePersonalChannel(ctx, owner.ID, 0) + if err != nil { + t.Fatalf("clear personal channel: %v", err) + } + if u.PersonalChannelID != 0 { + t.Fatalf("personal channel after clear = %d, want 0", u.PersonalChannelID) + } +} + func TestServiceByIDDoesNotReloadSelf(t *testing.T) { ctx := context.Background() base := memory.NewUserStore() @@ -109,11 +165,175 @@ func TestServiceByIDDoesNotReloadSelf(t *testing.T) { if err != nil || !found || got.ID != target.ID { t.Fatalf("ByID = %+v found %v err %v, want target", got, found, err) } - if store.byIDCalls != 1 { - t.Fatalf("store ByID calls = %d, want 1 target lookup only", store.byIDCalls) + if store.byIDCalls != 0 { + t.Fatalf("store ByID calls = %d, want 0 because service uses batch lookup", store.byIDCalls) } - if store.lastByID != target.ID { - t.Fatalf("last ByID id = %d, want target %d", store.lastByID, target.ID) + if store.byIDsCalls != 1 { + t.Fatalf("store ByIDs calls = %d, want 1 target lookup only", store.byIDsCalls) + } + if len(store.lastByIDs) != 1 || store.lastByIDs[0] != target.ID { + t.Fatalf("last ByIDs ids = %v, want target %d only", store.lastByIDs, target.ID) + } +} + +func TestServiceUsesBaseCacheWithoutCachingViewerOverlay(t *testing.T) { + ctx := context.Background() + base := memory.NewUserStore() + contacts := memory.NewContactStore() + owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Target"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + store := &countingUserStore{UserStore: base} + cache := newMemoryBaseUserCache() + svc := NewService(store, WithBaseUserCache(cache), WithContactStore(contacts)) + + first, found, err := svc.ByID(ctx, owner.ID, target.ID) + if err != nil || !found { + t.Fatalf("first ByID found=%v err=%v", found, err) + } + if first.Contact || first.Phone != "" || first.FirstName != "Target" { + t.Fatalf("first projected user = %+v, want non-contact base projection", first) + } + if store.byIDsCalls != 1 { + t.Fatalf("store ByIDs calls after first read = %d, want 1", store.byIDsCalls) + } + if _, err := contacts.Upsert(ctx, owner.ID, domain.ContactInput{ + ContactUserID: target.ID, + Phone: "15550000002", + FirstName: "Remark", + LastName: "Friend", + }); err != nil { + t.Fatalf("upsert contact: %v", err) + } + second, found, err := svc.ByID(ctx, owner.ID, target.ID) + if err != nil || !found { + t.Fatalf("second ByID found=%v err=%v", found, err) + } + if !second.Contact || second.FirstName != "Remark" || second.LastName != "Friend" || second.Phone != "15550000002" { + t.Fatalf("second projected user = %+v, want fresh contact overlay from cached base", second) + } + if store.byIDsCalls != 1 { + t.Fatalf("store ByIDs calls after cached read = %d, want still 1", store.byIDsCalls) + } +} + +func TestServiceRefreshesBaseCacheAfterProfileUpdate(t *testing.T) { + ctx := context.Background() + base := memory.NewUserStore() + owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Before"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + store := &countingUserStore{UserStore: base} + cache := newMemoryBaseUserCache() + svc := NewService(store, WithBaseUserCache(cache)) + + if _, found, err := svc.ByID(ctx, owner.ID, target.ID); err != nil || !found { + t.Fatalf("prime cache found=%v err=%v", found, err) + } + updated, err := svc.UpdateProfile(ctx, target.ID, domain.UserProfileUpdate{FirstName: "After", HasFirstName: true}) + if err != nil { + t.Fatalf("UpdateProfile: %v", err) + } + if updated.FirstName != "After" { + t.Fatalf("updated first name = %q, want After", updated.FirstName) + } + got, found, err := svc.ByID(ctx, owner.ID, target.ID) + if err != nil || !found { + t.Fatalf("ByID after update found=%v err=%v", found, err) + } + if got.FirstName != "After" { + t.Fatalf("cached user first name = %q, want After", got.FirstName) + } +} + +func TestServiceSetVerifiedRefreshesBaseCache(t *testing.T) { + ctx := context.Background() + base := memory.NewUserStore() + owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000021", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + target, err := base.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000022", FirstName: "Target"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + store := &countingUserStore{UserStore: base} + cache := newMemoryBaseUserCache() + svc := NewService(store, WithBaseUserCache(cache)) + + if _, found, err := svc.ByID(ctx, owner.ID, target.ID); err != nil || !found { + t.Fatalf("prime cache found=%v err=%v", found, err) + } + updated, err := svc.SetVerified(ctx, target.ID, true) + if err != nil { + t.Fatalf("SetVerified: %v", err) + } + if !updated.Verified { + t.Fatalf("updated verified = false, want true") + } + got, found, err := svc.ByID(ctx, owner.ID, target.ID) + if err != nil || !found { + t.Fatalf("ByID after verified found=%v err=%v", found, err) + } + if !got.Verified { + t.Fatalf("cached verified = false, want true") + } + cleared, err := svc.SetVerified(ctx, target.ID, false) + if err != nil { + t.Fatalf("clear verified: %v", err) + } + if cleared.Verified { + t.Fatalf("cleared verified = true, want false") + } +} + +func TestServiceRefreshesBaseCacheAfterColorUpdate(t *testing.T) { + ctx := context.Background() + base := memory.NewUserStore() + owner, err := base.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + store := &countingUserStore{UserStore: base} + cache := newMemoryBaseUserCache() + svc := NewService(store, WithBaseUserCache(cache)) + + if _, found, err := svc.ByID(ctx, owner.ID, owner.ID); err != nil || !found { + t.Fatalf("prime cache found=%v err=%v", found, err) + } + if store.byIDsCalls != 1 { + t.Fatalf("store ByIDs calls after prime = %d, want 1", store.byIDsCalls) + } + updated, err := svc.UpdateColor(ctx, owner.ID, true, domain.PeerColor{ + HasColor: true, + Color: 0, + BackgroundEmojiID: 123456, + }) + if err != nil { + t.Fatalf("UpdateColor: %v", err) + } + if !updated.ProfileColor.HasColor || updated.ProfileColor.Color != 0 || updated.ProfileColor.BackgroundEmojiID != 123456 { + t.Fatalf("updated profile color = %+v, want explicit color=0 bg=123456", updated.ProfileColor) + } + got, found, err := svc.ByID(ctx, owner.ID, owner.ID) + if err != nil || !found { + t.Fatalf("ByID after color update found=%v err=%v", found, err) + } + if store.byIDsCalls != 1 { + t.Fatalf("store ByIDs calls after cached color read = %d, want still 1", store.byIDsCalls) + } + if !got.ProfileColor.HasColor || got.ProfileColor.Color != 0 || got.ProfileColor.BackgroundEmojiID != 123456 { + t.Fatalf("cached profile color = %+v, want explicit color=0 bg=123456", got.ProfileColor) } } @@ -161,8 +381,10 @@ func TestServiceProjectsUsersForViewerContacts(t *testing.T) { type countingUserStore struct { *memory.UserStore - byIDCalls int - lastByID int64 + byIDCalls int + byIDsCalls int + lastByID int64 + lastByIDs []int64 } func (s *countingUserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) { @@ -170,3 +392,43 @@ func (s *countingUserStore) ByID(ctx context.Context, id int64) (domain.User, bo s.lastByID = id return s.UserStore.ByID(ctx, id) } + +func (s *countingUserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, error) { + s.byIDsCalls++ + s.lastByIDs = append([]int64(nil), ids...) + return s.UserStore.ByIDs(ctx, ids) +} + +type memoryBaseUserCache struct { + users map[int64]domain.User +} + +func newMemoryBaseUserCache() *memoryBaseUserCache { + return &memoryBaseUserCache{users: map[int64]domain.User{}} +} + +func (c *memoryBaseUserCache) GetByIDs(_ context.Context, ids []int64) (map[int64]domain.User, error) { + out := make(map[int64]domain.User, len(ids)) + for _, id := range ids { + if u, ok := c.users[id]; ok { + out[id] = u + } + } + return out, nil +} + +func (c *memoryBaseUserCache) PutMany(_ context.Context, users []domain.User) error { + for _, u := range users { + if u.ID != 0 { + c.users[u.ID] = u + } + } + return nil +} + +func (c *memoryBaseUserCache) Delete(_ context.Context, ids []int64) error { + for _, id := range ids { + delete(c.users, id) + } + return nil +} diff --git a/internal/botapi/inline.go b/internal/botapi/inline.go new file mode 100644 index 00000000..610ad490 --- /dev/null +++ b/internal/botapi/inline.go @@ -0,0 +1,294 @@ +package botapi + +import ( + "encoding/json" + "errors" + "net/url" + "strconv" + "strings" + "unicode/utf8" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func inlineResultFromAPI(raw string) (domain.BotInlineResult, error) { + if strings.TrimSpace(raw) == "" { + return domain.BotInlineResult{}, errors.New("RESULT_ID_INVALID") + } + var payload apiInlineResult + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return domain.BotInlineResult{}, errors.New("RESULT_TYPE_INVALID") + } + if payload.ID == "" { + return domain.BotInlineResult{}, errors.New("RESULT_ID_EMPTY") + } + if len(payload.ID) > domain.MaxBotInlineResultIDLen { + return domain.BotInlineResult{}, errors.New("RESULT_ID_INVALID") + } + if payload.Type != "article" { + return domain.BotInlineResult{}, errors.New("RESULT_TYPE_INVALID") + } + if payload.URL != "" && !validBotAPIHTTPSURL(payload.URL) { + return domain.BotInlineResult{}, errors.New("BUTTON_URL_INVALID") + } + message, entities, noWebpage, err := inputTextMessageContentFromAPI(payload) + if err != nil { + return domain.BotInlineResult{}, err + } + markup, err := replyMarkupFromAPI(payload.ReplyMarkup) + if err != nil { + return domain.BotInlineResult{}, err + } + return domain.BotInlineResult{ + ID: payload.ID, + Type: payload.Type, + Title: payload.Title, + Description: payload.Description, + URL: payload.URL, + Message: message, + Entities: entities, + ReplyMarkup: markup, + NoWebpage: noWebpage, + }, nil +} + +func inputTextMessageContentFromAPI(payload apiInlineResult) (string, []domain.MessageEntity, bool, error) { + var content apiInputTextMessageContent + if len(payload.InputMessageContent) > 0 && string(payload.InputMessageContent) != "null" { + if err := json.Unmarshal(payload.InputMessageContent, &content); err != nil { + return "", nil, false, errors.New("MESSAGE_EMPTY") + } + } else if payload.MessageText != "" { + content.MessageText = payload.MessageText + } + if content.ParseMode != "" { + return "", nil, false, errors.New("ENTITY_PARSE_UNSUPPORTED") + } + message := content.MessageText + if message == "" { + return "", nil, false, errors.New("MESSAGE_EMPTY") + } + if utf8.RuneCountInString(message) > domain.MaxMessageTextLength { + return "", nil, false, errors.New("MESSAGE_TOO_LONG") + } + entities, err := messageEntitiesFromAPI(content.Entities) + if err != nil { + return "", nil, false, err + } + noWebpage := content.DisableWebPagePreview || content.LinkPreviewOptions.IsDisabled + return message, entities, noWebpage, nil +} + +func messageEntitiesFromAPI(in []apiMessageEntity) ([]domain.MessageEntity, error) { + if len(in) == 0 { + return nil, nil + } + if len(in) > domain.MaxMessageEntityCount { + return nil, errors.New("ENTITIES_TOO_LONG") + } + out := make([]domain.MessageEntity, 0, len(in)) + for _, entity := range in { + if entity.Offset < 0 || entity.Length <= 0 { + return nil, errors.New("ENTITY_BOUNDS_INVALID") + } + mapped, ok := apiEntityType(entity.Type) + if !ok { + return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") + } + item := domain.MessageEntity{ + Type: mapped, + Offset: entity.Offset, + Length: entity.Length, + URL: entity.URL, + Language: entity.Language, + } + if entity.User != nil { + item.UserID = entity.User.ID + } + if entity.CustomEmojiID != "" { + id, err := strconv.ParseInt(entity.CustomEmojiID, 10, 64) + if err != nil || id <= 0 { + return nil, errors.New("ENTITY_TYPE_UNSUPPORTED") + } + item.DocumentID = id + } + out = append(out, item) + } + return out, nil +} + +func apiEntityType(in string) (domain.MessageEntityType, bool) { + switch in { + case "bold": + return domain.MessageEntityBold, true + case "italic": + return domain.MessageEntityItalic, true + case "underline": + return domain.MessageEntityUnderline, true + case "strikethrough": + return domain.MessageEntityStrike, true + case "code": + return domain.MessageEntityCode, true + case "pre": + return domain.MessageEntityPre, true + case "text_link": + return domain.MessageEntityTextURL, true + case "text_mention": + return domain.MessageEntityMentionName, true + case "spoiler": + return domain.MessageEntitySpoiler, true + case "blockquote": + return domain.MessageEntityBlockquote, true + case "custom_emoji": + return domain.MessageEntityCustomEmoji, true + case "mention": + return domain.MessageEntityMention, true + case "hashtag": + return domain.MessageEntityHashtag, true + case "cashtag": + return domain.MessageEntityCashtag, true + case "bot_command": + return domain.MessageEntityBotCommand, true + case "url": + return domain.MessageEntityURL, true + case "email": + return domain.MessageEntityEmail, true + case "phone_number": + return domain.MessageEntityPhone, true + default: + return "", false + } +} + +func replyMarkupFromAPI(raw json.RawMessage) (*domain.MessageReplyMarkup, error) { + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var payload apiInlineKeyboardMarkup + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, errors.New("BUTTON_INVALID") + } + if len(payload.InlineKeyboard) == 0 { + return nil, nil + } + out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(payload.InlineKeyboard))} + for _, row := range payload.InlineKeyboard { + domainRow := make([]domain.MarkupButton, 0, len(row)) + for _, button := range row { + item, err := markupButtonFromAPI(button) + if err != nil { + return nil, err + } + domainRow = append(domainRow, item) + } + out.Inline = append(out.Inline, domainRow) + } + if err := domain.ValidateReplyMarkup(out); err != nil { + return nil, replyMarkupErrFromDomain(err) + } + if out.IsZero() { + return nil, nil + } + return out, nil +} + +func markupButtonFromAPI(button apiInlineKeyboardButton) (domain.MarkupButton, error) { + if button.URL != "" { + return domain.MarkupButton{Type: domain.MarkupButtonURL, Text: button.Text, URL: button.URL}, nil + } + if button.CallbackData != nil { + if *button.CallbackData == "" || len([]byte(*button.CallbackData)) > domain.MaxCallbackDataLen { + return domain.MarkupButton{}, errors.New("BUTTON_DATA_INVALID") + } + return domain.MarkupButton{Type: domain.MarkupButtonCallback, Text: button.Text, Data: []byte(*button.CallbackData)}, nil + } + return domain.MarkupButton{}, errors.New("BUTTON_INVALID") +} + +func replyMarkupErrFromDomain(err error) error { + switch { + case errors.Is(err, domain.ErrButtonURLInvalid): + return errors.New("BUTTON_URL_INVALID") + case errors.Is(err, domain.ErrButtonDataInvalid): + return errors.New("BUTTON_DATA_INVALID") + default: + return errors.New("BUTTON_INVALID") + } +} + +func preparedPeerTypesFromAPI(values map[string]string) []string { + out := make([]string, 0, 5) + if apiBool(values["allow_user_chats"]) { + out = append(out, store.InlineQueryPeerTypePM) + } + if apiBool(values["allow_bot_chats"]) { + out = append(out, store.InlineQueryPeerTypeBotPM) + } + if apiBool(values["allow_group_chats"]) { + out = append(out, store.InlineQueryPeerTypeChat, store.InlineQueryPeerTypeMegagroup) + } + if apiBool(values["allow_channel_chats"]) { + out = append(out, store.InlineQueryPeerTypeBroadcast) + } + return out +} + +func apiBool(raw string) bool { + v, err := strconv.ParseBool(strings.TrimSpace(raw)) + return err == nil && v +} + +func validBotAPIHTTPSURL(raw string) bool { + if raw == "" || len(raw) > domain.MaxBotInlineWebURLLen || strings.TrimSpace(raw) != raw { + return false + } + parsed, err := url.Parse(raw) + if err != nil || parsed.User != nil || parsed.Host == "" || strings.ToLower(parsed.Scheme) != "https" { + return false + } + return !strings.ContainsAny(parsed.Host, " \t\r\n") +} + +type apiInlineResult struct { + Type string `json:"type"` + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + MessageText string `json:"message_text"` + InputMessageContent json.RawMessage `json:"input_message_content"` + ReplyMarkup json.RawMessage `json:"reply_markup"` +} + +type apiInputTextMessageContent struct { + MessageText string `json:"message_text"` + ParseMode string `json:"parse_mode"` + Entities []apiMessageEntity `json:"entities"` + DisableWebPagePreview bool `json:"disable_web_page_preview"` + LinkPreviewOptions struct { + IsDisabled bool `json:"is_disabled"` + } `json:"link_preview_options"` +} + +type apiMessageEntity struct { + Type string `json:"type"` + Offset int `json:"offset"` + Length int `json:"length"` + URL string `json:"url"` + User *struct { + ID int64 `json:"id"` + } `json:"user"` + Language string `json:"language"` + CustomEmojiID string `json:"custom_emoji_id"` +} + +type apiInlineKeyboardMarkup struct { + InlineKeyboard [][]apiInlineKeyboardButton `json:"inline_keyboard"` +} + +type apiInlineKeyboardButton struct { + Text string `json:"text"` + URL string `json:"url"` + CallbackData *string `json:"callback_data"` +} diff --git a/internal/botapi/server.go b/internal/botapi/server.go new file mode 100644 index 00000000..6c9d7ad4 --- /dev/null +++ b/internal/botapi/server.go @@ -0,0 +1,416 @@ +package botapi + +import ( + "context" + "crypto/rand" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +type BotsService interface { + BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) + SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error) + GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error) + BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) +} + +type UsersService interface { + UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) +} + +type WebAppService interface { + AnswerWebAppQueryFromBotAPI(ctx context.Context, botID int64, webAppQueryID string, result domain.BotInlineResult) (inlineMessageID string, err error) + SavePreparedInlineMessageFromBotAPI(ctx context.Context, botID, userID int64, result domain.BotInlineResult, peerTypes []string) (id string, expireDate int, err error) +} + +func Start(ctx context.Context, addr string, bots BotsService, users UsersService, webapps WebAppService, logger *zap.Logger) (*http.Server, error) { + if strings.TrimSpace(addr) == "" { + return nil, nil + } + if logger == nil { + logger = zap.NewNop() + } + handler := &handler{bots: bots, users: users, webapps: webapps, logger: logger} + srv := &http.Server{ + Addr: addr, + Handler: handler.routes(), + ReadHeaderTimeout: 5 * time.Second, + } + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, err + } + go func() { + logger.Info("Bot API 网关已启用", zap.String("addr", addr)) + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Warn("Bot API 网关退出", zap.Error(err)) + } + }() + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + return srv, nil +} + +type handler struct { + bots BotsService + users UsersService + webapps WebAppService + logger *zap.Logger +} + +func (h *handler) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", h.handle) + return mux +} + +func (h *handler) handle(w http.ResponseWriter, r *http.Request) { + token, method, ok := splitBotPath(r.URL.Path) + if !ok { + writeAPIError(w, http.StatusNotFound, "METHOD_NOT_FOUND") + return + } + botID, ok := h.authenticate(r.Context(), token) + if !ok { + writeAPIError(w, http.StatusUnauthorized, "ACCESS_TOKEN_INVALID") + return + } + switch strings.ToLower(method) { + case "setchatmenubutton": + h.setChatMenuButton(w, r, botID) + case "getchatmenubutton": + h.getChatMenuButton(w, r, botID) + case "setuseremojistatus": + h.setUserEmojiStatus(w, r, botID) + case "answerwebappquery": + h.answerWebAppQuery(w, r, botID) + case "savepreparedinlinemessage": + h.savePreparedInlineMessage(w, r, botID) + case "answershippingquery", "answerprecheckoutquery": + writeAPIError(w, http.StatusNotImplemented, "BLOCKED_DURABLE_QUERY_STATE_MISSING") + default: + writeAPIError(w, http.StatusNotFound, "METHOD_NOT_FOUND") + } +} + +func splitBotPath(path string) (token, method string, ok bool) { + rest := strings.TrimPrefix(path, "/bot") + rest = strings.TrimPrefix(rest, "/") + if rest == "" { + return "", "", false + } + token, method, found := strings.Cut(rest, "/") + if !found || token == "" || method == "" { + return "", "", false + } + return token, method, true +} + +func (h *handler) authenticate(ctx context.Context, token string) (int64, bool) { + if h.bots == nil { + return 0, false + } + botID, secret, ok := domain.ParseBotToken(token) + if !ok { + return 0, false + } + profile, found, err := h.bots.BotInfo(ctx, botID) + if err != nil || !found || profile.TokenSecret != secret { + return 0, false + } + return botID, true +} + +func (h *handler) setChatMenuButton(w http.ResponseWriter, r *http.Request, botID int64) { + if h.bots == nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + button, err := menuButtonFromAPI(values["menu_button"]) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BUTTON_INVALID") + return + } + if _, err := h.bots.SetBotMenuButton(r.Context(), botID, button); err != nil { + writeAPIError(w, http.StatusBadRequest, "BUTTON_INVALID") + return + } + writeAPIOK(w, true) +} + +func (h *handler) getChatMenuButton(w http.ResponseWriter, r *http.Request, botID int64) { + if h.bots == nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + button, err := h.bots.GetBotMenuButton(r.Context(), botID) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + writeAPIOK(w, apiMenuButton(button)) +} + +func (h *handler) setUserEmojiStatus(w http.ResponseWriter, r *http.Request, botID int64) { + if h.bots == nil || h.users == nil { + writeAPIError(w, http.StatusNotImplemented, "BLOCKED_USER_EMOJI_STATUS_SERVICE_MISSING") + return + } + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + userID, err := strconv.ParseInt(values["user_id"], 10, 64) + if err != nil || userID <= 0 { + writeAPIError(w, http.StatusBadRequest, "USER_ID_INVALID") + return + } + allowed, err := h.bots.BotEmojiStatusPermission(r.Context(), botID, userID) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + if !allowed { + writeAPIError(w, http.StatusForbidden, "USER_PERMISSION_DENIED") + return + } + var documentID int64 + if raw := strings.TrimSpace(values["emoji_status_custom_emoji_id"]); raw != "" { + documentID, err = strconv.ParseInt(raw, 10, 64) + if err != nil || documentID < 0 { + writeAPIError(w, http.StatusBadRequest, "EMOJI_STATUS_INVALID") + return + } + } + var until int + if raw := strings.TrimSpace(values["emoji_status_expiration_date"]); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + writeAPIError(w, http.StatusBadRequest, "EMOJI_STATUS_INVALID") + return + } + until = n + } + if _, err := h.users.UpdateEmojiStatus(r.Context(), userID, documentID, until); err != nil { + if errors.Is(err, domain.ErrPremiumRequired) { + writeAPIError(w, http.StatusBadRequest, "PREMIUM_ACCOUNT_REQUIRED") + return + } + writeAPIError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR") + return + } + writeAPIOK(w, true) +} + +func (h *handler) answerWebAppQuery(w http.ResponseWriter, r *http.Request, botID int64) { + if h.webapps == nil { + writeAPIError(w, http.StatusNotImplemented, "BLOCKED_WEBAPP_QUERY_SERVICE_MISSING") + return + } + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + queryID := strings.TrimSpace(values["web_app_query_id"]) + if queryID == "" { + writeAPIError(w, http.StatusBadRequest, "QUERY_ID_INVALID") + return + } + result, err := inlineResultFromAPI(values["result"]) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + inlineID, err := h.webapps.AnswerWebAppQueryFromBotAPI(r.Context(), botID, queryID, result) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + resp := map[string]any{} + if inlineID != "" { + resp["inline_message_id"] = inlineID + } + writeAPIOK(w, resp) +} + +func (h *handler) savePreparedInlineMessage(w http.ResponseWriter, r *http.Request, botID int64) { + if h.webapps == nil { + writeAPIError(w, http.StatusNotImplemented, "BLOCKED_PREPARED_INLINE_SERVICE_MISSING") + return + } + values, err := requestValues(r) + if err != nil { + writeAPIError(w, http.StatusBadRequest, "BAD_REQUEST") + return + } + userID, err := strconv.ParseInt(values["user_id"], 10, 64) + if err != nil || userID <= 0 { + writeAPIError(w, http.StatusBadRequest, "USER_ID_INVALID") + return + } + result, err := inlineResultFromAPI(values["result"]) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + peerTypes := preparedPeerTypesFromAPI(values) + id, expireDate, err := h.webapps.SavePreparedInlineMessageFromBotAPI(r.Context(), botID, userID, result, peerTypes) + if err != nil { + writeAPIError(w, http.StatusBadRequest, apiErrorDescription(err)) + return + } + writeAPIOK(w, map[string]any{"id": id, "expiration_date": expireDate}) +} + +func requestValues(r *http.Request) (map[string]string, error) { + out := map[string]string{} + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + var body map[string]any + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { + return nil, err + } + for k, v := range body { + switch x := v.(type) { + case string: + out[k] = x + default: + b, _ := json.Marshal(x) + out[k] = string(b) + } + } + if _, nested := out["menu_button"]; !nested { + if _, direct := body["type"]; direct { + b, _ := json.Marshal(body) + out["menu_button"] = string(b) + } + } + return out, nil + } + if err := r.ParseForm(); err != nil { + return nil, err + } + for k, v := range r.Form { + if len(v) > 0 { + out[k] = v[0] + } + } + return out, nil +} + +func menuButtonFromAPI(raw string) (domain.BotMenuButton, error) { + if strings.TrimSpace(raw) == "" { + return domain.BotMenuButton{Type: domain.BotMenuButtonDefault}, nil + } + var payload struct { + Type string `json:"type"` + Text string `json:"text"` + WebApp struct { + URL string `json:"url"` + } `json:"web_app"` + } + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return domain.BotMenuButton{}, err + } + switch payload.Type { + case "default": + return domain.BotMenuButton{Type: domain.BotMenuButtonDefault}, nil + case "commands": + return domain.BotMenuButton{Type: domain.BotMenuButtonCommands}, nil + case "web_app": + return domain.BotMenuButton{Type: domain.BotMenuButtonWebView, Text: payload.Text, URL: payload.WebApp.URL}, nil + default: + return domain.BotMenuButton{}, fmt.Errorf("unknown menu button type") + } +} + +func apiMenuButton(button domain.BotMenuButton) map[string]any { + switch button.Type { + case domain.BotMenuButtonCommands: + return map[string]any{"type": "commands"} + case domain.BotMenuButtonWebView: + return map[string]any{ + "type": "web_app", + "text": button.Text, + "web_app": map[string]any{ + "url": button.URL, + }, + } + default: + return map[string]any{"type": "default"} + } +} + +func writeAPIOK(w http.ResponseWriter, result any) { + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "result": result}) +} + +func writeAPIError(w http.ResponseWriter, status int, description string) { + writeJSON(w, status, map[string]any{"ok": false, "error_code": status, "description": description}) +} + +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 apiErrorDescription(err error) string { + if err == nil { + return "" + } + text := strings.ToUpper(err.Error()) + for _, marker := range []string{ + "QUERY_ID_INVALID", + "USER_ID_INVALID", + "RESULT_ID_INVALID", + "RESULT_ID_EMPTY", + "RESULT_TYPE_INVALID", + "MESSAGE_EMPTY", + "MESSAGE_TOO_LONG", + "BUTTON_INVALID", + "BUTTON_DATA_INVALID", + "BUTTON_URL_INVALID", + "BOT_INVALID", + "USER_BOT_REQUIRED", + } { + if strings.Contains(text, marker) { + return marker + } + } + return "BAD_REQUEST" +} + +func randomNonZeroInt64() int64 { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return time.Now().UnixNano() + } + v := int64(binary.LittleEndian.Uint64(b[:]) & 0x7fffffffffffffff) + if v == 0 { + return time.Now().UnixNano() + } + return v +} diff --git a/internal/botapi/server_test.go b/internal/botapi/server_test.go new file mode 100644 index 00000000..b2f57a4f --- /dev/null +++ b/internal/botapi/server_test.go @@ -0,0 +1,198 @@ +package botapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestAnswerWebAppQueryParsesArticleAndCallsService(t *testing.T) { + webapps := &fakeWebAppService{} + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + h := (&handler{bots: bots, webapps: webapps}).routes() + body := `{ + "web_app_query_id": "web-query-1", + "result": { + "type": "article", + "id": "share-1", + "title": "Share", + "description": "from mini app", + "url": "https://example.com/share", + "input_message_content": { + "message_text": "hello mini app", + "disable_web_page_preview": true, + "entities": [{"type": "bold", "offset": 0, "length": 5}] + }, + "reply_markup": { + "inline_keyboard": [[ + {"text": "Open", "url": "https://example.com/open"}, + {"text": "Tap", "callback_data": "cb"} + ]] + } + } + }` + rec := performBotAPIRequest(t, h, bots.profile, "answerWebAppQuery", body) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + var resp apiResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK { + t.Fatalf("response ok = false: %s", rec.Body.String()) + } + if !webapps.answerCalled || webapps.answerBotID != bots.profile.BotUserID || webapps.answerQueryID != "web-query-1" { + t.Fatalf("answer call = %#v", webapps) + } + got := webapps.answerResult + if got.ID != "share-1" || got.Type != "article" || got.Message != "hello mini app" || !got.NoWebpage || got.URL != "https://example.com/share" { + t.Fatalf("result = %#v", got) + } + if len(got.Entities) != 1 || got.Entities[0].Type != domain.MessageEntityBold { + t.Fatalf("entities = %#v", got.Entities) + } + if got.ReplyMarkup == nil || len(got.ReplyMarkup.Inline) != 1 || len(got.ReplyMarkup.Inline[0]) != 2 { + t.Fatalf("reply markup = %#v", got.ReplyMarkup) + } +} + +func TestSavePreparedInlineMessageParsesPeerTypes(t *testing.T) { + webapps := &fakeWebAppService{preparedID: "prepared-1", preparedExpire: 123456} + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + h := (&handler{bots: bots, webapps: webapps}).routes() + body := `{ + "user_id": 2001, + "allow_user_chats": true, + "allow_channel_chats": true, + "result": { + "type": "article", + "id": "prepared-share", + "title": "Prepared", + "input_message_content": {"message_text": "share me"} + } + }` + rec := performBotAPIRequest(t, h, bots.profile, "savePreparedInlineMessage", body) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if !webapps.preparedCalled || webapps.preparedBotID != 1001 || webapps.preparedUserID != 2001 { + t.Fatalf("prepared call = %#v", webapps) + } + wantPeers := []string{store.InlineQueryPeerTypePM, store.InlineQueryPeerTypeBroadcast} + if !reflect.DeepEqual(webapps.preparedPeerTypes, wantPeers) { + t.Fatalf("peer types = %#v, want %#v", webapps.preparedPeerTypes, wantPeers) + } + var resp struct { + OK bool `json:"ok"` + Result struct { + ID string `json:"id"` + ExpirationDate int `json:"expiration_date"` + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if !resp.OK || resp.Result.ID != "prepared-1" || resp.Result.ExpirationDate != 123456 { + t.Fatalf("response = %s", rec.Body.String()) + } +} + +func TestAnswerWebAppQueryRejectsUnsupportedResult(t *testing.T) { + webapps := &fakeWebAppService{} + bots := &fakeBotAPIBots{profile: domain.BotProfile{BotUserID: 1001, TokenSecret: "secret"}} + h := (&handler{bots: bots, webapps: webapps}).routes() + body := `{ + "web_app_query_id": "web-query-1", + "result": {"type": "photo", "id": "bad", "photo_url": "https://example.com/p.jpg"} + }` + rec := performBotAPIRequest(t, h, bots.profile, "answerWebAppQuery", body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if webapps.answerCalled { + t.Fatalf("unsupported result should not call webapp service") + } + var resp apiResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.OK || resp.Description != "RESULT_TYPE_INVALID" { + t.Fatalf("response = %s", rec.Body.String()) + } +} + +func performBotAPIRequest(t *testing.T, h http.Handler, profile domain.BotProfile, method, body string) *httptest.ResponseRecorder { + t.Helper() + token := domain.FormatBotToken(profile.BotUserID, profile.TokenSecret) + req := httptest.NewRequest(http.MethodPost, "/bot"+token+"/"+method, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +type apiResponse struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + Description string `json:"description"` +} + +type fakeBotAPIBots struct { + profile domain.BotProfile +} + +func (f *fakeBotAPIBots) BotInfo(context.Context, int64) (domain.BotProfile, bool, error) { + return f.profile, true, nil +} + +func (f *fakeBotAPIBots) SetBotMenuButton(context.Context, int64, domain.BotMenuButton) (int, error) { + return 0, nil +} + +func (f *fakeBotAPIBots) GetBotMenuButton(context.Context, int64) (domain.BotMenuButton, error) { + return domain.BotMenuButton{Type: domain.BotMenuButtonDefault}, nil +} + +func (f *fakeBotAPIBots) BotEmojiStatusPermission(context.Context, int64, int64) (bool, error) { + return true, nil +} + +type fakeWebAppService struct { + answerCalled bool + answerBotID int64 + answerQueryID string + answerResult domain.BotInlineResult + + preparedCalled bool + preparedBotID int64 + preparedUserID int64 + preparedResult domain.BotInlineResult + preparedPeerTypes []string + preparedID string + preparedExpire int +} + +func (f *fakeWebAppService) AnswerWebAppQueryFromBotAPI(_ context.Context, botID int64, queryID string, result domain.BotInlineResult) (string, error) { + f.answerCalled = true + f.answerBotID = botID + f.answerQueryID = queryID + f.answerResult = result + return "", nil +} + +func (f *fakeWebAppService) SavePreparedInlineMessageFromBotAPI(_ context.Context, botID, userID int64, result domain.BotInlineResult, peerTypes []string) (string, int, error) { + f.preparedCalled = true + f.preparedBotID = botID + f.preparedUserID = userID + f.preparedResult = result + f.preparedPeerTypes = append([]string(nil), peerTypes...) + return f.preparedID, f.preparedExpire, nil +} diff --git a/internal/compat/layerwire/README.md b/internal/compat/layerwire/README.md new file mode 100644 index 00000000..0d298177 --- /dev/null +++ b/internal/compat/layerwire/README.md @@ -0,0 +1,141 @@ +# layerwire 操作手册(多 Layer 向后兼容) + +> 本文是 **怎么操作**(runbook)。**为什么这么设计**见 [`docs/layer-compat-220-227-design.md`](../../../docs/layer-compat-220-227-design.md)。 +> 改这个包前请先读完本文 + 设计文档。所有命令都从 **telesrv 模块根目录**执行。 + +## 这个包是干什么的 + +让 telesrv 同时正确服务 **Layer 220–227** 的客户端,而业务 handler / gotd 永远只跑 canonical(227)、一行不改。 +- **出站**:把 227 对象降级成老客户端能解的 wire 形态(`Transcode`)。 +- **入站**:把老客户端发来的旧构造器升级成 227 请求,再交正常 gotd dispatcher(`UpgradeInbound`)。 + +两个正交维度(**务必分清**): +- **官方层漂移**:构造器在 layer N 的字段/CRC 与 227 不同。真值=官方 TDesktop `api.tl` 各层。**自动从 schema 生成**。 +- **客户端构造器漂移**:某客户端(如 DrKLO Android)手维护的 TL 实际发了个旧 layer 的官方构造器,但它声明的整体 layer 却是新的。真值=该客户端源码(TLRPC.java)。**声明在 `client-drift.tl` / `client_aliases.go`**。 + +## 文件地图 + +| 文件 | 角色 | 谁改 | +|---|---|---| +| `schema/canonical-227.tl` | **embed**,运行期 walker 的 227 字段布局(= gotd `td/_schema/tdesktop.tl` 的副本) | gotd 升级时 re-sync | +| `_schema/layer-2NN.tl` | 历史层官方 schema(从 TDesktop git 抽,**仅生成期用**,下划线=不编译/不 embed) | 升级/下探 floor 时抽取 | +| `schema/client-drift.tl` | **声明式**:客户端发的旧构造器老布局(body 与 227 不同的) | 发现客户端漂移时 +1 行 | +| `client_aliases.go` | 客户端漂移里 **body 与 227 字节一致**的,纯 `老CRC→227CRC` | 发现纯换 CRC 漂移时 +1 条 | +| `tables_gen.go` | **生成产物**(勿手改):官方层降级表 + 入站升级表 + 新类型集 | 跑 `gen` 重生成 | +| `gen/main.go` | 生成器:对拍 schema、证明机械性、产 `tables_gen.go` | 升级逻辑变更时 | +| `layout.go` `walk.go` `tables.go` | 通用解释器(读/丈量/递归转码)| 核心,少动 | +| `fallback.go` | 出站手写兜底(结构性 / 227-only 类型)| CoverageGate 报缺时 | +| `inbound.go` | 入站通用升级引擎 + `fieldConverters` + `driftFieldRenames` | DriftCoverage 报缺时 | + +## 核心命令 + +```bash +# 复核 schema 差异数字(不改文件) +go run ./internal/compat/layerwire/gen -report + +# 重新生成 tables_gen.go(官方层漂移表) +go run ./internal/compat/layerwire/gen -emit internal/compat/layerwire/tables_gen.go + +# 全部护栏(漂移门禁 + 对各历史层真实 schema 对拍 + 性能基准) +go test ./internal/compat/layerwire/ +go test ./internal/compat/layerwire/ -run '^$' -bench . -benchmem # 性能 + +# 改完务必: +gofmt -w internal/compat/layerwire/ && go build ./... && go vet ./internal/... +``` + +--- + +## 操作 1:gotd 升级(canonical layer 上移,例 227 → 230) + +> gotd bump 是显式任务(见 AGENTS.md 铁律 #6)。canonical schema 随之变化,按下列步骤同步。 + +1. **同步 canonical schema**(gotd 的就是实际编出的字节): + ```bash + cp ../td/_schema/tdesktop.tl internal/compat/layerwire/schema/canonical-230.tl + rm internal/compat/layerwire/schema/canonical-227.tl + ``` + 改 `layout.go`:`//go:embed schema/canonical-230.tl`、`const CanonicalLayer = 230`。 +2. **把原 canonical 层并入历史 TO 层**:现在 227/228/229 成了"老层",从 TDesktop git 抽进 `_schema/`(见文末「抽取 api.tl@N」)。 +3. **改生成期常量**:`gen/main.go` 的 `canonicalLayer = 230`。(`supportedFloor` 不变。) +4. **重生成 + 复核**: + ```bash + go run ./internal/compat/layerwire/gen -report # 看 changed/new 数字是否合理 + go run ./internal/compat/layerwire/gen -emit internal/compat/layerwire/tables_gen.go + ``` +5. **跑护栏、按报告 triage**: + ```bash + go test ./internal/compat/layerwire/ + ``` + - `TestCoverageGate` 失败 = 出现了 telesrv 可达但没处理的 227(新 canonical)-only / 结构性类型 → 去 `fallback.go` 加 by-type 兜底或结构性转换,或确认 telesrv 不发就加进 `unemittedAllowlist`(`gate_test.go`,附理由)。 + - 生成器 `-report` 里 "structural" 列出的需手写转换(参照 `fallback.go transcodePollAnswerVoters`)。 +6. `gofmt`/`build`/`vet`/全量 `go test`。真机 220/老层/新层各一台回归。 + +## 操作 2:下探 floor(支持更老客户端,例 220 → 215) + +1. 从 TDesktop git 抽 `layer-215.tl … layer-219.tl` 进 `_schema/`(见文末)。 +2. 改 `supportedFloor`:`layout.go` 的 `SupportedFloor = 215` **和** `gen/main.go` 的 `supportedFloor = 215`(两处都要)。 +3. `go run ... -emit ...` 重生成 → `go test`。 +4. 越老的层结构性差异越多,按 `TestCoverageGate` / 生成器 report triage(同操作 1 第 5 步)。 + +## 操作 3:新增「客户端构造器漂移」(最常见) + +触发:某客户端发的旧构造器导致 `NOT_IMPLEMENTED`(入站)或对端渲染异常;或主动审计客户端源码发现它发旧 CRC。 + +1. **拿到老构造器的精确 TL 定义**: + - 优先看该客户端源码的序列化(DrKLO Android:`TMessagesProj/.../TLRPC.java` 的 `serializeToStream`,按 `writeInt32/writeString/...` 顺序还原字段)。 + - 或它是某旧 layer 官方构造器:`git -C ../tdesktop/tdesktop log -S"#" -- ` 找到所在层,再取该层定义。 +2. **判断 body 是否与 227 字节一致**: + - **一致**(只是 CRC 不同;典型=227 只追加了 flag-gated 可选字段而客户端不设)→ 往 `client_aliases.go clientMethodAliases` 加 `0x<老CRC>: 0x<227CRC>`。 + - **不一致**(缺 flags 整数 / 字段类型变了 / 缺必填字段)→ 往 `schema/client-drift.tl` 加**一行老布局 TL**(用 method 的限定名,结果类型随便填合法值,引擎只按名字匹配 227)。 +3. **跑测试**: + ```bash + go test ./internal/compat/layerwire/ -run TestInbound + ``` + - 绿 = 通用引擎已能自动升级(复制共享字段 + 插 flags=0 + 按 kind 补默认)。**完事**。 + - `TestInboundDriftCoverage` 报 `needs converter A->B` = 有字段类型变更 → 往 `inbound.go fieldConverters` 加一条 `"A->B"`(可复用,参照 `Vector->Vector`)。 + - 报 `field X not defaultable` 或字段**改名** → 往 `inbound.go driftFieldRenames` 加 `"\x00<227字段>": "<老字段>"`(参照 `bots.exportBotToken\x00bot`)。 +4. **绝不**为此写一个新的 `handleLegacyXxx` 解码 handler——那是旧做法,已全删。统一走数据 + 通用引擎。 + +## 操作 4:出站 `TestCoverageGate` 失败 + +说明 telesrv 现在会发某个"经保留字段可达"的 227-only / 结构性类型,但没处理。 +- 该类**有同抽象类的老成员**可降级 → `fallback.go` 加 `newTypeFallbacksByType["<抽象类>"]`(如 `PageBlock→pageBlockUnsupported`)。 +- 是**结构性变更类型**且 telesrv 真发 → `fallback.go structuralTransforms` 加手写转换。 +- **确认 telesrv 不发** → 加进 `gate_test.go unemittedAllowlist`(**必须附理由**,引用出站构造器审计)。 + +--- + +## 护栏:每个测试拦什么 + +| 测试 | 拦截 | +|---|---| +| `TestWalkConsumesCanonicalObjects` | 解释器读不全某个 227 类型(字段布局漏) | +| `TestTranscodeDowngradeValid` | 降级输出对 220..226 **真实 schema** 解析失败/有残留字节 | +| `TestTranscodeChangedTypeNestedInUnchangedContainer` | 「外层 CRC 不变但内含变更类型」被误整段拷贝 | +| `TestCoverageGate` | 出站 227-only/结构性类型无 handler 又不在 allowlist(gotd bump/客户端升级引入新形态时报) | +| `TestInboundDriftCoverage` | `client-drift.tl` 某条目无法自动升级(缺 converter/rename) | +| `TestInboundBodyTransforms` / `TestInboundCRCSwaps` | 入站升级产出不是合法 227 请求 | +| `TestNegotiatedLayerStickyContract` | layer 协商的 `(layer, ok)` 契约(避免缓存驱逐把老客户端误降回 227) | + +**运行期 fail-safe**:出站遇未处理类型 → `Transcode` 返错 → 边界记日志并发 canonical 字节(连接存活,单对象可能渲染异常)。入站遇未覆盖旧 CRC → 落 gotd dispatcher → `NOT_IMPLEMENTED`(须按 AGENTS.md #5 进 compatibility trace + 矩阵)。**护栏的意义就是把这些从"线上撞见"提前到"提交期/测试期发现"。** + +## 抽取 api.tl@N(操作 1/2 用) + +```bash +TD=../tdesktop/tdesktop +APITL=Telegram/SourceFiles/mtproto/scheme/api.tl + +# 找 layer N 的提交(取最后一个写入 "// LAYER N" 的;可能有初版+修订,选最全的) +git -C "$TD" log --oneline -S"// LAYER N" -- "$APITL" + +# 抽取(务必校验文件末尾确是 "// LAYER N") +git -C "$TD" show :"$APITL" > internal/compat/layerwire/_schema/layer-N.tl +tail -1 internal/compat/layerwire/_schema/layer-N.tl # 应为: // LAYER N +``` + +各层→commit 对照见设计文档 §3 表(220..227 的 canonical 抽取点)。`gotd/tl` 解析器能直接吃 TDesktop api.tl,无需改格式。 + +## 稳态心法 + +**喂新 schema(gotd 或更老层)→ 跑 `gen` + `go test` → 护栏吐出短清单 → 人只处理新出现的 fallback / 结构性 / converter / rename。** 不再有"运行时撞 NOT_IMPLEMENTED 再手写 handler"。 diff --git a/internal/compat/layerwire/_schema/layer-220.tl b/internal/compat/layerwire/_schema/layer-220.tl new file mode 100644 index 00000000..8803fc0e --- /dev/null +++ b/internal/compat/layerwire/_schema/layer-220.tl @@ -0,0 +1,2882 @@ +boolFalse#bc799737 = Bool; +boolTrue#997275b5 = Bool; + +true#3fedd339 = True; + +vector#1cb5c415 {t:Type} # [ t ] = Vector t; + +error#c4b9f9bb code:int text:string = Error; + +null#56730bcc = Null; + +inputPeerEmpty#7f3b18ea = InputPeer; +inputPeerSelf#7da07ec9 = InputPeer; +inputPeerChat#35a95cb9 chat_id:long = InputPeer; +inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer; +inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer; +inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer; +inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer; + +inputUserEmpty#b98886cf = InputUser; +inputUserSelf#f7c1b13f = InputUser; +inputUser#f21158c6 user_id:long access_hash:long = InputUser; +inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser; + +inputPhoneContact#6a1dc4be flags:# client_id:long phone:string first_name:string last_name:string note:flags.0?TextWithEntities = InputContact; + +inputFile#f52ff27f id:long parts:int name:string md5_checksum:string = InputFile; +inputFileBig#fa4f0bb5 id:long parts:int name:string = InputFile; +inputFileStoryDocument#62dc8b48 id:InputDocument = InputFile; + +inputMediaEmpty#9664f57f = InputMedia; +inputMediaUploadedPhoto#1e287d04 flags:# spoiler:flags.2?true file:InputFile stickers:flags.0?Vector ttl_seconds:flags.1?int = InputMedia; +inputMediaPhoto#b3ba0635 flags:# spoiler:flags.1?true id:InputPhoto ttl_seconds:flags.0?int = InputMedia; +inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia; +inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia; +inputMediaUploadedDocument#37c9330 flags:# nosound_video:flags.3?true force_file:flags.4?true spoiler:flags.5?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector video_cover:flags.6?InputPhoto video_timestamp:flags.7?int ttl_seconds:flags.1?int = InputMedia; +inputMediaDocument#a8763ab5 flags:# spoiler:flags.2?true id:InputDocument video_cover:flags.3?InputPhoto video_timestamp:flags.4?int ttl_seconds:flags.0?int query:flags.1?string = InputMedia; +inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia; +inputMediaPhotoExternal#e5bbfe1a flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int = InputMedia; +inputMediaDocumentExternal#779600f9 flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int video_cover:flags.2?InputPhoto video_timestamp:flags.3?int = InputMedia; +inputMediaGame#d33f43f3 id:InputGame = InputMedia; +inputMediaInvoice#405fef0d flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:flags.3?string provider_data:DataJSON start_param:flags.1?string extended_media:flags.2?InputMedia = InputMedia; +inputMediaGeoLive#971fa843 flags:# stopped:flags.0?true geo_point:InputGeoPoint heading:flags.2?int period:flags.1?int proximity_notification_radius:flags.3?int = InputMedia; +inputMediaPoll#f94e5f1 flags:# poll:Poll correct_answers:flags.0?Vector solution:flags.1?string solution_entities:flags.1?Vector = InputMedia; +inputMediaDice#e66fbf7b emoticon:string = InputMedia; +inputMediaStory#89fdd778 peer:InputPeer id:int = InputMedia; +inputMediaWebPage#c21b8849 flags:# force_large_media:flags.0?true force_small_media:flags.1?true optional:flags.2?true url:string = InputMedia; +inputMediaPaidMedia#c4103386 flags:# stars_amount:long extended_media:Vector payload:flags.0?string = InputMedia; +inputMediaTodo#9fc55fde todo:TodoList = InputMedia; + +inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; +inputChatUploadedPhoto#bdcdaec0 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.3?VideoSize = InputChatPhoto; +inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; + +inputGeoPointEmpty#e4c123d6 = InputGeoPoint; +inputGeoPoint#48222faf flags:# lat:double long:double accuracy_radius:flags.0?int = InputGeoPoint; + +inputPhotoEmpty#1cd7bf0d = InputPhoto; +inputPhoto#3bb3b94a id:long access_hash:long file_reference:bytes = InputPhoto; + +inputFileLocation#dfdaabe1 volume_id:long local_id:int secret:long file_reference:bytes = InputFileLocation; +inputEncryptedFileLocation#f5235d55 id:long access_hash:long = InputFileLocation; +inputDocumentFileLocation#bad07584 id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputSecureFileLocation#cbc7ee28 id:long access_hash:long = InputFileLocation; +inputTakeoutFileLocation#29be5899 = InputFileLocation; +inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation; +inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation; +inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation; +inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation; + +peerUser#59511722 user_id:long = Peer; +peerChat#36c6019a chat_id:long = Peer; +peerChannel#a2a5371e channel_id:long = Peer; + +storage.fileUnknown#aa963b05 = storage.FileType; +storage.filePartial#40bc6f52 = storage.FileType; +storage.fileJpeg#7efe0e = storage.FileType; +storage.fileGif#cae1aadf = storage.FileType; +storage.filePng#a4f63c0 = storage.FileType; +storage.filePdf#ae1e508d = storage.FileType; +storage.fileMp3#528a0677 = storage.FileType; +storage.fileMov#4b09ebbc = storage.FileType; +storage.fileMp4#b3cea0e4 = storage.FileType; +storage.fileWebp#1081464c = storage.FileType; + +userEmpty#d3bc4b7a id:long = User; +user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User; + +userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; +userProfilePhoto#82d1f706 flags:# has_video:flags.0?true personal:flags.2?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto; + +userStatusEmpty#9d05049 = UserStatus; +userStatusOnline#edb93949 expires:int = UserStatus; +userStatusOffline#8c703f was_online:int = UserStatus; +userStatusRecently#7b197dc8 flags:# by_me:flags.0?true = UserStatus; +userStatusLastWeek#541a1d1a flags:# by_me:flags.0?true = UserStatus; +userStatusLastMonth#65899777 flags:# by_me:flags.0?true = UserStatus; + +chatEmpty#29562865 id:long = Chat; +chat#41cbf256 flags:# creator:flags.0?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; +chatForbidden#6592a1a7 id:long title:string = Chat; +channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat; +channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true id:long access_hash:long title:string until_date:flags.16?int = Chat; + +chatFull#2633421b flags:# can_set_username:flags.7?true has_scheduled:flags.8?true translations_disabled:flags.19?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions reactions_limit:flags.20?int = ChatFull; +channelFull#e4e0b29d flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true participants_hidden:flags2.2?true translations_disabled:flags2.3?true stories_pinned_available:flags2.5?true view_forum_as_messages:flags2.6?true restricted_sponsored:flags2.11?true can_view_revenue:flags2.12?true paid_media_allowed:flags2.14?true can_view_stars_revenue:flags2.15?true paid_reactions_available:flags2.16?true stargifts_available:flags2.19?true paid_messages_available:flags2.20?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions reactions_limit:flags2.13?int stories:flags2.4?PeerStories wallpaper:flags2.7?WallPaper boosts_applied:flags2.8?int boosts_unrestrict:flags2.9?int emojiset:flags2.10?StickerSet bot_verification:flags2.17?BotVerification stargifts_count:flags2.18?int send_paid_messages_stars:flags2.21?long main_tab:flags2.22?ProfileTab = ChatFull; + +chatParticipant#c02d4007 user_id:long inviter_id:long date:int = ChatParticipant; +chatParticipantCreator#e46bcee4 user_id:long = ChatParticipant; +chatParticipantAdmin#a0933f5b user_id:long inviter_id:long date:int = ChatParticipant; + +chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants; +chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants; + +chatPhotoEmpty#37c1011c = ChatPhoto; +chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; + +messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; +message#b92f76cf flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true invert_media:flags.27?true flags2:# offline:flags2.1?true video_processing_pending:flags2.4?true paid_suggested_post_stars:flags2.8?true paid_suggested_post_ton:flags2.9?true id:int from_id:flags.8?Peer from_boosts_applied:flags.29?int peer_id:Peer saved_peer_id:flags.28?Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long via_business_bot_id:flags2.0?long reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int quick_reply_shortcut_id:flags.30?int effect:flags2.2?long factcheck:flags2.3?FactCheck report_delivery_until_date:flags2.5?int paid_message_stars:flags2.6?long suggested_post:flags2.7?SuggestedPost schedule_repeat_period:flags2.10?int = Message; +messageService#7a800e0a flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true reactions_are_possible:flags.9?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer saved_peer_id:flags.28?Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction reactions:flags.20?MessageReactions ttl_period:flags.25?int = Message; + +messageMediaEmpty#3ded6320 = MessageMedia; +messageMediaPhoto#695150d7 flags:# spoiler:flags.3?true photo:flags.0?Photo ttl_seconds:flags.2?int = MessageMedia; +messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia; +messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia; +messageMediaUnsupported#9f84f49e = MessageMedia; +messageMediaDocument#52d8ccd9 flags:# nopremium:flags.3?true spoiler:flags.4?true video:flags.6?true round:flags.7?true voice:flags.8?true document:flags.0?Document alt_documents:flags.5?Vector video_cover:flags.9?Photo video_timestamp:flags.10?int ttl_seconds:flags.2?int = MessageMedia; +messageMediaWebPage#ddf10c3b flags:# force_large_media:flags.0?true force_small_media:flags.1?true manual:flags.3?true safe:flags.4?true webpage:WebPage = MessageMedia; +messageMediaVenue#2ec0533f geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MessageMedia; +messageMediaGame#fdb19008 game:Game = MessageMedia; +messageMediaInvoice#f6a548d3 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string extended_media:flags.4?MessageExtendedMedia = MessageMedia; +messageMediaGeoLive#b940c666 flags:# geo:GeoPoint heading:flags.0?int period:int proximity_notification_radius:flags.1?int = MessageMedia; +messageMediaPoll#4bd6e798 poll:Poll results:PollResults = MessageMedia; +messageMediaDice#3f7ee58b value:int emoticon:string = MessageMedia; +messageMediaStory#68cb6283 flags:# via_mention:flags.1?true peer:Peer id:int story:flags.0?StoryItem = MessageMedia; +messageMediaGiveaway#aa073beb flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.2?true channels:Vector countries_iso2:flags.1?Vector prize_description:flags.3?string quantity:int months:flags.4?int stars:flags.5?long until_date:int = MessageMedia; +messageMediaGiveawayResults#ceaa3ea1 flags:# only_new_subscribers:flags.0?true refunded:flags.2?true channel_id:long additional_peers_count:flags.3?int launch_msg_id:int winners_count:int unclaimed_count:int winners:Vector months:flags.4?int stars:flags.5?long prize_description:flags.1?string until_date:int = MessageMedia; +messageMediaPaidMedia#a8852491 stars_amount:long extended_media:Vector = MessageMedia; +messageMediaToDo#8a53b014 flags:# todo:TodoList completions:flags.0?Vector = MessageMedia; +messageMediaVideoStream#ca5cab89 flags:# rtmp_stream:flags.0?true call:InputGroupCall = MessageMedia; + +messageActionEmpty#b6aef7b0 = MessageAction; +messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction; +messageActionChatEditTitle#b5a1ce5a title:string = MessageAction; +messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction; +messageActionChatDeletePhoto#95e3fbef = MessageAction; +messageActionChatAddUser#15cefd00 users:Vector = MessageAction; +messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction; +messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction; +messageActionChannelCreate#95d2ac92 title:string = MessageAction; +messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction; +messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction; +messageActionPinMessage#94bd38ed = MessageAction; +messageActionHistoryClear#9fbab604 = MessageAction; +messageActionGameScore#92a72876 game_id:long score:int = MessageAction; +messageActionPaymentSentMe#ffa00ccc flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string charge:PaymentCharge subscription_until_date:flags.4?int = MessageAction; +messageActionPaymentSent#c624b16e flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long invoice_slug:flags.0?string subscription_until_date:flags.4?int = MessageAction; +messageActionPhoneCall#80e11a7f flags:# video:flags.2?true call_id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = MessageAction; +messageActionScreenshotTaken#4792929b = MessageAction; +messageActionCustomAction#fae69f56 message:string = MessageAction; +messageActionBotAllowed#c516d679 flags:# attach_menu:flags.1?true from_request:flags.3?true domain:flags.0?string app:flags.2?BotApp = MessageAction; +messageActionSecureValuesSentMe#1b287353 values:Vector credentials:SecureCredentialsEncrypted = MessageAction; +messageActionSecureValuesSent#d95c6154 types:Vector = MessageAction; +messageActionContactSignUp#f3f25f76 = MessageAction; +messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; +messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; +messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; +messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; +messageActionSetChatTheme#b91bbd3a theme:ChatTheme = MessageAction; +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; +messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; +messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; +messageActionGiftPremium#48e91302 flags:# currency:string amount:long days:int crypto_currency:flags.0?string crypto_amount:flags.0?long message:flags.1?TextWithEntities = MessageAction; +messageActionTopicCreate#d999256 flags:# title_missing:flags.1?true title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; +messageActionSuggestProfilePhoto#57de635e photo:Photo = MessageAction; +messageActionRequestedPeer#31518e9b button_id:int peers:Vector = MessageAction; +messageActionSetChatWallPaper#5060a3f4 flags:# same:flags.0?true for_both:flags.1?true wallpaper:WallPaper = MessageAction; +messageActionGiftCode#31c48347 flags:# via_giveaway:flags.0?true unclaimed:flags.5?true boost_peer:flags.1?Peer days:int slug:string currency:flags.2?string amount:flags.2?long crypto_currency:flags.3?string crypto_amount:flags.3?long message:flags.4?TextWithEntities = MessageAction; +messageActionGiveawayLaunch#a80f51e4 flags:# stars:flags.0?long = MessageAction; +messageActionGiveawayResults#87e2f155 flags:# stars:flags.0?true winners_count:int unclaimed_count:int = MessageAction; +messageActionBoostApply#cc02aa6d boosts:int = MessageAction; +messageActionRequestedPeerSentMe#93b31848 button_id:int peers:Vector = MessageAction; +messageActionPaymentRefunded#41b3e202 flags:# peer:Peer currency:string total_amount:long payload:flags.0?bytes charge:PaymentCharge = MessageAction; +messageActionGiftStars#45d5b021 flags:# currency:string amount:long stars:long crypto_currency:flags.0?string crypto_amount:flags.0?long transaction_id:flags.1?string = MessageAction; +messageActionPrizeStars#b00c47a2 flags:# unclaimed:flags.0?true stars:long transaction_id:string boost_peer:Peer giveaway_msg_id:int = MessageAction; +messageActionStarGift#ea2c31d3 flags:# name_hidden:flags.0?true saved:flags.2?true converted:flags.3?true upgraded:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true prepaid_upgrade:flags.13?true upgrade_separate:flags.16?true auction_acquired:flags.17?true gift:StarGift message:flags.1?TextWithEntities convert_stars:flags.4?long upgrade_msg_id:flags.5?int upgrade_stars:flags.8?long from_id:flags.11?Peer peer:flags.12?Peer saved_id:flags.12?long prepaid_upgrade_hash:flags.14?string gift_msg_id:flags.15?int to_id:flags.18?Peer gift_num:flags.19?int = MessageAction; +messageActionStarGiftUnique#95728543 flags:# upgrade:flags.0?true transferred:flags.1?true saved:flags.2?true refunded:flags.5?true prepaid_upgrade:flags.11?true assigned:flags.13?true from_offer:flags.14?true gift:StarGift can_export_at:flags.3?int transfer_stars:flags.4?long from_id:flags.6?Peer peer:flags.7?Peer saved_id:flags.7?long resale_amount:flags.8?StarsAmount can_transfer_at:flags.9?int can_resell_at:flags.10?int drop_original_details_stars:flags.12?long = MessageAction; +messageActionPaidMessagesRefunded#ac1f1fcd count:int stars:long = MessageAction; +messageActionPaidMessagesPrice#84b88578 flags:# broadcast_messages_allowed:flags.0?true stars:long = MessageAction; +messageActionConferenceCall#2ffe2f7a flags:# missed:flags.0?true active:flags.1?true video:flags.4?true call_id:long duration:flags.2?int other_participants:flags.3?Vector = MessageAction; +messageActionTodoCompletions#cc7c5c89 completed:Vector incompleted:Vector = MessageAction; +messageActionTodoAppendTasks#c7edbc83 list:Vector = MessageAction; +messageActionSuggestedPostApproval#ee7a1596 flags:# rejected:flags.0?true balance_too_low:flags.1?true reject_comment:flags.2?string schedule_date:flags.3?int price:flags.4?StarsAmount = MessageAction; +messageActionSuggestedPostSuccess#95ddcf69 price:StarsAmount = MessageAction; +messageActionSuggestedPostRefund#69f916f8 flags:# payer_initiated:flags.0?true = MessageAction; +messageActionGiftTon#a8a3c699 flags:# currency:string amount:long crypto_currency:string crypto_amount:long transaction_id:flags.0?string = MessageAction; +messageActionSuggestBirthday#2c8f2a25 birthday:Birthday = MessageAction; +messageActionStarGiftPurchaseOffer#774278d4 flags:# accepted:flags.0?true declined:flags.1?true gift:StarGift price:StarsAmount expires_at:int = MessageAction; +messageActionStarGiftPurchaseOfferDeclined#73ada76b flags:# expired:flags.0?true gift:StarGift price:StarsAmount = MessageAction; + +dialog#d58a08c6 flags:# pinned:flags.2?true unread_mark:flags.3?true view_forum_as_messages:flags.6?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; +dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; + +photoEmpty#2331b22d id:long = Photo; +photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; + +photoSizeEmpty#e17e23c type:string = PhotoSize; +photoSize#75c78e60 type:string w:int h:int size:int = PhotoSize; +photoCachedSize#21e1ad6 type:string w:int h:int bytes:bytes = PhotoSize; +photoStrippedSize#e0b0bc2e type:string bytes:bytes = PhotoSize; +photoSizeProgressive#fa3efb95 type:string w:int h:int sizes:Vector = PhotoSize; +photoPathSize#d8214d41 type:string bytes:bytes = PhotoSize; + +geoPointEmpty#1117dd5f = GeoPoint; +geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint; + +auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; +auth.sentCodeSuccess#2390fe44 authorization:auth.Authorization = auth.SentCode; +auth.sentCodePaymentRequired#e0955a3c store_product:string phone_code_hash:string support_email_address:string support_email_subject:string currency:string amount:long = auth.SentCode; + +auth.authorization#2ea2c0d4 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int future_auth_token:flags.2?bytes user:User = auth.Authorization; +auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; + +auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; + +inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer; +inputNotifyUsers#193b4417 = InputNotifyPeer; +inputNotifyChats#4a95e84e = InputNotifyPeer; +inputNotifyBroadcasts#b1db7c7e = InputNotifyPeer; +inputNotifyForumTopic#5c467992 peer:InputPeer top_msg_id:int = InputNotifyPeer; + +inputPeerNotifySettings#cacb6ae2 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_sound:flags.8?NotificationSound = InputPeerNotifySettings; + +peerNotifySettings#99622c0c flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int ios_sound:flags.3?NotificationSound android_sound:flags.4?NotificationSound other_sound:flags.5?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_ios_sound:flags.8?NotificationSound stories_android_sound:flags.9?NotificationSound stories_other_sound:flags.10?NotificationSound = PeerNotifySettings; + +peerSettings#f47741f7 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true business_bot_paused:flags.11?true business_bot_can_reply:flags.12?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int business_bot_id:flags.13?long business_bot_manage_url:flags.13?string charge_paid_message_stars:flags.14?long registration_month:flags.15?string phone_country:flags.16?string name_change_date:flags.17?int photo_change_date:flags.18?int = PeerSettings; + +wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; +wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; + +inputReportReasonSpam#58dbcab8 = ReportReason; +inputReportReasonViolence#1e22c78d = ReportReason; +inputReportReasonPornography#2e59d922 = ReportReason; +inputReportReasonChildAbuse#adf44ee3 = ReportReason; +inputReportReasonOther#c1e4a2b1 = ReportReason; +inputReportReasonCopyright#9b89f93a = ReportReason; +inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; +inputReportReasonFake#f5ddd6e7 = ReportReason; +inputReportReasonIllegalDrugs#a8eb2be = ReportReason; +inputReportReasonPersonalDetails#9ec7863d = ReportReason; + +userFull#a02bc13e flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true voice_messages_forbidden:flags.20?true translations_disabled:flags.23?true stories_pinned_available:flags.26?true blocked_my_stories_from:flags.27?true wallpaper_overridden:flags.28?true contact_require_premium:flags.29?true read_dates_private:flags.30?true flags2:# sponsored_enabled:flags2.7?true can_view_revenue:flags2.9?true bot_can_manage_emoji_status:flags2.10?true display_gifts_button:flags2.16?true id:long about:flags.1?string settings:PeerSettings personal_photo:flags.21?Photo profile_photo:flags.2?Photo fallback_photo:flags.22?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme:flags.15?ChatTheme private_forward_name:flags.16?string bot_group_admin_rights:flags.17?ChatAdminRights bot_broadcast_admin_rights:flags.18?ChatAdminRights wallpaper:flags.24?WallPaper stories:flags.25?PeerStories business_work_hours:flags2.0?BusinessWorkHours business_location:flags2.1?BusinessLocation business_greeting_message:flags2.2?BusinessGreetingMessage business_away_message:flags2.3?BusinessAwayMessage business_intro:flags2.4?BusinessIntro birthday:flags2.5?Birthday personal_channel_id:flags2.6?long personal_channel_message:flags2.6?int stargifts_count:flags2.8?int starref_program:flags2.11?StarRefProgram bot_verification:flags2.12?BotVerification send_paid_messages_stars:flags2.14?long disallowed_gifts:flags2.15?DisallowedGiftsSettings stars_rating:flags2.17?StarsRating stars_my_pending_rating:flags2.18?StarsRating stars_my_pending_rating_date:flags2.18?int main_tab:flags2.20?ProfileTab saved_music:flags2.21?Document note:flags2.22?TextWithEntities = UserFull; + +contact#145ade0b user_id:long mutual:Bool = Contact; + +importedContact#c13e3c50 user_id:long client_id:long = ImportedContact; + +contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus; + +contacts.contactsNotModified#b74ba9d2 = contacts.Contacts; +contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts; + +contacts.importedContacts#77d01c3b imported:Vector popular_invites:Vector retry_contacts:Vector users:Vector = contacts.ImportedContacts; + +contacts.blocked#ade1591 blocked:Vector chats:Vector users:Vector = contacts.Blocked; +contacts.blockedSlice#e1664194 count:int blocked:Vector chats:Vector users:Vector = contacts.Blocked; + +messages.dialogs#15ba6c40 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsNotModified#f0e3e596 count:int = messages.Dialogs; + +messages.messages#1d73e7ea messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesSlice#5f206716 flags:# inexact:flags.1?true count:int next_rate:flags.0?int offset_id_offset:flags.2?int search_flood:flags.3?SearchPostsFlood messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.channelMessages#c776ba4e flags:# inexact:flags.1?true pts:int count:int offset_id_offset:flags.2?int messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesNotModified#74535f21 count:int = messages.Messages; + +messages.chats#64ff9fd5 chats:Vector = messages.Chats; +messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; + +messages.chatFull#e5d7d19c full_chat:ChatFull chats:Vector users:Vector = messages.ChatFull; + +messages.affectedHistory#b45c69d1 pts:int pts_count:int offset:int = messages.AffectedHistory; + +inputMessagesFilterEmpty#57e2f66c = MessagesFilter; +inputMessagesFilterPhotos#9609a51c = MessagesFilter; +inputMessagesFilterVideo#9fc00e65 = MessagesFilter; +inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; +inputMessagesFilterDocument#9eddf188 = MessagesFilter; +inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; +inputMessagesFilterGif#ffc86587 = MessagesFilter; +inputMessagesFilterVoice#50f5c392 = MessagesFilter; +inputMessagesFilterMusic#3751b49e = MessagesFilter; +inputMessagesFilterChatPhotos#3a20ecb8 = MessagesFilter; +inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFilter; +inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; +inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; +inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; +inputMessagesFilterGeo#e7026d0d = MessagesFilter; +inputMessagesFilterContacts#e062db83 = MessagesFilter; +inputMessagesFilterPinned#1bb00451 = MessagesFilter; + +updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; +updateMessageID#4e90bfd6 id:int random_id:long = Update; +updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; +updateUserTyping#2a17bf5c flags:# user_id:long top_msg_id:flags.0?int action:SendMessageAction = Update; +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; +updateChatParticipants#7761198 participants:ChatParticipants = Update; +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; +updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector = Update; +updateNewAuthorization#8951abef flags:# unconfirmed:flags.0?true hash:long date:flags.0?int device:flags.0?string location:flags.0?string = Update; +updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; +updateEncryptedChatTyping#1710f156 chat_id:int = Update; +updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; +updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; +updateDcOptions#8e5e9873 dc_options:Vector = Update; +updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; +updateServiceNotification#ebe46819 flags:# popup:flags.0?true invert_media:flags.2?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; +updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; +updateUserPhone#5492a13 user_id:long phone:string = Update; +updateReadHistoryInbox#9e84bc99 flags:# folder_id:flags.0?int peer:Peer top_msg_id:flags.1?int max_id:int still_unread_count:int pts:int pts_count:int = Update; +updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; +updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; +updateReadMessagesContents#f8227181 flags:# messages:Vector pts:int pts_count:int date:flags.0?int = Update; +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; +updateChannel#635b4c09 channel_id:long = Update; +updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; +updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; +updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Update; +updateStickerSets#31c24808 flags:# masks:flags.0?true emojis:flags.1?true = Update; +updateSavedGifs#9375341e = Update; +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; +updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; +updateDraftMessage#edfc111e flags:# peer:Peer top_msg_id:flags.0?int saved_peer_id:flags.1?Peer draft:DraftMessage = Update; +updateReadFeaturedStickers#571d2742 = Update; +updateRecentStickers#9a422c20 = Update; +updateConfig#a229dd06 = Update; +updatePtsChanged#3354678f = Update; +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; +updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; +updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; +updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; +updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; +updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; +updateLangPackTooLong#46560264 lang_code:string = Update; +updateLangPack#56022f4d difference:LangPackDifference = Update; +updateFavedStickers#e511996d = Update; +updateChannelReadMessagesContents#25f324f7 flags:# channel_id:long top_msg_id:flags.0?int saved_peer_id:flags.1?Peer messages:Vector = Update; +updateContactsReset#7084a7be = Update; +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; +updateDialogUnreadMark#b658f23e flags:# unread:flags.0?true peer:DialogPeer saved_peer_id:flags.1?Peer = Update; +updateMessagePoll#aca1657b flags:# poll_id:long poll:flags.0?Poll results:PollResults = Update; +updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; +updateFolderPeers#19360dc0 folder_peers:Vector pts:int pts_count:int = Update; +updatePeerSettings#6a7e7366 peer:Peer settings:PeerSettings = Update; +updatePeerLocated#b4afcfb0 peers:Vector = Update; +updateNewScheduledMessage#39a51dfb message:Message = Update; +updateDeleteScheduledMessages#f2a71983 flags:# peer:Peer messages:Vector sent_messages:flags.0?Vector = Update; +updateTheme#8216fba3 theme:Theme = Update; +updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; +updateLoginToken#564fe691 = Update; +updateMessagePollVote#24f40e77 poll_id:long peer:Peer options:Vector qts:int = Update; +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; +updateDialogFilterOrder#a5d72105 order:Vector = Update; +updateDialogFilters#3504914f = Update; +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; +updatePeerBlocked#ebe07752 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer = Update; +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; +updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChat#f89a6a4e chat_id:long = Update; +updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; +updateGroupCall#9d2216e0 flags:# live_story:flags.2?true peer:flags.1?Peer call:GroupCall = Update; +updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateChannelParticipant#985d3abb flags:# via_chatlist:flags.3?true channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; +updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; +updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#11dfa986 peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int = Update; +updateMessageReactions#1e297bfa flags:# peer:Peer msg_id:int top_msg_id:flags.0?int saved_peer_id:flags.1?Peer reactions:MessageReactions = Update; +updateAttachMenuBots#17b7a20b = Update; +updateWebViewResultSent#1592b79d query_id:long = Update; +updateBotMenuButton#14b85813 bot_id:long button:BotMenuButton = Update; +updateSavedRingtones#74d8be99 = Update; +updateTranscribedAudio#84cd5a flags:# pending:flags.0?true peer:Peer msg_id:int transcription_id:long text:string = Update; +updateReadFeaturedEmojiStickers#fb4c496c = Update; +updateUserEmojiStatus#28373599 user_id:long emoji_status:EmojiStatus = Update; +updateRecentEmojiStatuses#30f443db = Update; +updateRecentReactions#6f7863f4 = Update; +updateMoveStickerSetToTop#86fccf85 flags:# masks:flags.0?true emojis:flags.1?true stickerset:long = Update; +updateMessageExtendedMedia#d5a41724 peer:Peer msg_id:int extended_media:Vector = Update; +updateUser#20529438 user_id:long = Update; +updateAutoSaveSettings#ec05b097 = Update; +updateStory#75b3b798 peer:Peer story:StoryItem = Update; +updateReadStories#f74e932b peer:Peer max_id:int = Update; +updateStoryID#1bf335b9 id:int random_id:long = Update; +updateStoriesStealthMode#2c084dc1 stealth_mode:StoriesStealthMode = Update; +updateSentStoryReaction#7d627683 peer:Peer story_id:int reaction:Reaction = Update; +updateBotChatBoost#904dd49c peer:Peer boost:Boost qts:int = Update; +updateChannelViewForumAsMessages#7b68920 channel_id:long enabled:Bool = Update; +updatePeerWallpaper#ae3f101d flags:# wallpaper_overridden:flags.1?true peer:Peer wallpaper:flags.0?WallPaper = Update; +updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer old_reactions:Vector new_reactions:Vector qts:int = Update; +updateBotMessageReactions#9cb7759 peer:Peer msg_id:int date:int reactions:Vector qts:int = Update; +updateSavedDialogPinned#aeaf9e74 flags:# pinned:flags.0?true peer:DialogPeer = Update; +updatePinnedSavedDialogs#686c85a6 flags:# order:flags.0?Vector = Update; +updateSavedReactionTags#39c67432 = Update; +updateSmsJob#f16269d4 job_id:string = Update; +updateQuickReplies#f9470ab2 quick_replies:Vector = Update; +updateNewQuickReply#f53da717 quick_reply:QuickReply = Update; +updateDeleteQuickReply#53e6f1ec shortcut_id:int = Update; +updateQuickReplyMessage#3e050d0f message:Message = Update; +updateDeleteQuickReplyMessages#566fe7cd shortcut_id:int messages:Vector = Update; +updateBotBusinessConnect#8ae5c97a connection:BotBusinessConnection qts:int = Update; +updateBotNewBusinessMessage#9ddb347c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotEditBusinessMessage#7df587c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotDeleteBusinessMessage#a02a982e connection_id:string peer:Peer messages:Vector qts:int = Update; +updateNewStoryReaction#1824e40b story_id:int peer:Peer reaction:Reaction = Update; +updateStarsBalance#4e80a379 balance:StarsAmount = Update; +updateBusinessBotCallbackQuery#1ea2fda7 flags:# query_id:long user_id:long connection_id:string message:Message reply_to_message:flags.2?Message chat_instance:long data:flags.0?bytes = Update; +updateStarsRevenueStatus#a584b019 peer:Peer status:StarsRevenueStatus = Update; +updateBotPurchasedPaidMedia#283bd312 user_id:long payload:string qts:int = Update; +updatePaidReactionPrivacy#8b725fce private:PaidReactionPrivacy = Update; +updateSentPhoneCode#504aa18f sent_code:auth.SentCode = Update; +updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector next_offset:int = Update; +updateReadMonoForumInbox#77b0e372 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateReadMonoForumOutbox#a4a79376 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateMonoForumNoPaidException#9f812b08 flags:# exception:flags.0?true channel_id:long saved_peer_id:Peer = Update; +updateGroupCallMessage#d8326f0d call:InputGroupCall message:GroupCallMessage = Update; +updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update; +updatePinnedForumTopic#683b2c52 flags:# pinned:flags.0?true peer:Peer topic_id:int = Update; +updatePinnedForumTopics#def143d0 flags:# peer:Peer order:flags.0?Vector = Update; +updateDeleteGroupCallMessages#3e85e92c call:InputGroupCall messages:Vector = Update; +updateStarGiftAuctionState#48e246c2 gift_id:long state:StarGiftAuctionState = Update; +updateStarGiftAuctionUserState#dc58f31e gift_id:long user_state:StarGiftAuctionUserState = Update; + +updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; + +updates.differenceEmpty#5d75a138 date:int seq:int = updates.Difference; +updates.difference#f49ca0 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector state:updates.State = updates.Difference; +updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector intermediate_state:updates.State = updates.Difference; +updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; + +updatesTooLong#e317af7e = Updates; +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShort#78d4dec1 update:Update date:int = Updates; +updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; +updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; +updateShortSentMessage#9015e101 flags:# out:flags.1?true id:int pts:int pts_count:int date:int media:flags.9?MessageMedia entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +photos.photos#8dca6aa5 photos:Vector users:Vector = photos.Photos; +photos.photosSlice#15051f54 count:int photos:Vector users:Vector = photos.Photos; + +photos.photo#20212ca8 photo:Photo users:Vector = photos.Photo; + +upload.file#96a18d5 type:storage.FileType mtime:int bytes:bytes = upload.File; +upload.fileCdnRedirect#f18cda44 dc_id:int file_token:bytes encryption_key:bytes encryption_iv:bytes file_hashes:Vector = upload.File; + +dcOption#18b7a10d flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true this_port_only:flags.5?true id:int ip_address:string port:int secret:flags.10?bytes = DcOption; + +config#cc1a241e flags:# default_p2p_contacts:flags.3?true preload_featured_stickers:flags.4?true revoke_pm_inbox:flags.6?true blocked_mode:flags.8?true force_try_ipv6:flags.14?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector dc_txt_domain_name:string chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int push_chat_period_ms:int push_chat_limit:int edit_time_limit:int revoke_time_limit:int revoke_pm_time_limit:int rating_e_decay:int stickers_recent_limit:int channels_read_media_period:int tmp_sessions:flags.0?int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string autoupdate_url_prefix:flags.7?string gif_search_username:flags.9?string venue_search_username:flags.10?string img_search_username:flags.11?string static_maps_provider:flags.12?string caption_length_max:int message_length_max:int webfile_dc_id:int suggested_lang_code:flags.2?string lang_pack_version:flags.2?int base_lang_pack_version:flags.2?int reactions_default:flags.15?Reaction autologin_token:flags.16?string = Config; + +nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; + +help.appUpdate#ccbbce30 flags:# can_not_skip:flags.0?true id:int version:string text:string entities:Vector document:flags.1?Document url:flags.2?string sticker:flags.3?Document = help.AppUpdate; +help.noAppUpdate#c45a6536 = help.AppUpdate; + +help.inviteText#18cb9f78 message:string = help.InviteText; + +encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; +encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; + +inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; + +encryptedFileEmpty#c21f497e = EncryptedFile; +encryptedFile#a8008cd8 id:long access_hash:long size:long dc_id:int key_fingerprint:int = EncryptedFile; + +inputEncryptedFileEmpty#1837c364 = InputEncryptedFile; +inputEncryptedFileUploaded#64bd0306 id:long parts:int md5_checksum:string key_fingerprint:int = InputEncryptedFile; +inputEncryptedFile#5a17b5e5 id:long access_hash:long = InputEncryptedFile; +inputEncryptedFileBigUploaded#2dc173c8 id:long parts:int key_fingerprint:int = InputEncryptedFile; + +encryptedMessage#ed18c118 random_id:long chat_id:int date:int bytes:bytes file:EncryptedFile = EncryptedMessage; +encryptedMessageService#23734b06 random_id:long chat_id:int date:int bytes:bytes = EncryptedMessage; + +messages.dhConfigNotModified#c0e24635 random:bytes = messages.DhConfig; +messages.dhConfig#2c221edd g:int p:bytes version:int random:bytes = messages.DhConfig; + +messages.sentEncryptedMessage#560f8935 date:int = messages.SentEncryptedMessage; +messages.sentEncryptedFile#9493ff32 date:int file:EncryptedFile = messages.SentEncryptedMessage; + +inputDocumentEmpty#72f0eaae = InputDocument; +inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; + +documentEmpty#36f8c871 id:long = Document; +document#8fd4c4d8 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:long thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; + +help.support#17c6b5f6 phone_number:string user:User = help.Support; + +notifyPeer#9fd40bd8 peer:Peer = NotifyPeer; +notifyUsers#b4c83b4c = NotifyPeer; +notifyChats#c007cec3 = NotifyPeer; +notifyBroadcasts#d612e8ef = NotifyPeer; +notifyForumTopic#226e6308 peer:Peer top_msg_id:int = NotifyPeer; + +sendMessageTypingAction#16bf744e = SendMessageAction; +sendMessageCancelAction#fd5ec8f5 = SendMessageAction; +sendMessageRecordVideoAction#a187d66f = SendMessageAction; +sendMessageUploadVideoAction#e9763aec progress:int = SendMessageAction; +sendMessageRecordAudioAction#d52f73f7 = SendMessageAction; +sendMessageUploadAudioAction#f351d7ab progress:int = SendMessageAction; +sendMessageUploadPhotoAction#d1d34a26 progress:int = SendMessageAction; +sendMessageUploadDocumentAction#aa0cd9e4 progress:int = SendMessageAction; +sendMessageGeoLocationAction#176f8ba1 = SendMessageAction; +sendMessageChooseContactAction#628cbc6f = SendMessageAction; +sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; +sendMessageRecordRoundAction#88f27fbc = SendMessageAction; +sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; +speakingInGroupCallAction#d92c2285 = SendMessageAction; +sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; +sendMessageTextDraftAction#376d975c random_id:long text:TextWithEntities = SendMessageAction; + +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; + +inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; +inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; +inputPrivacyKeyPhoneCall#fabadc5f = InputPrivacyKey; +inputPrivacyKeyPhoneP2P#db9e70d2 = InputPrivacyKey; +inputPrivacyKeyForwards#a4dd4c08 = InputPrivacyKey; +inputPrivacyKeyProfilePhoto#5719bacc = InputPrivacyKey; +inputPrivacyKeyPhoneNumber#352dafa = InputPrivacyKey; +inputPrivacyKeyAddedByPhone#d1219bdd = InputPrivacyKey; +inputPrivacyKeyVoiceMessages#aee69d68 = InputPrivacyKey; +inputPrivacyKeyAbout#3823cc40 = InputPrivacyKey; +inputPrivacyKeyBirthday#d65a11cc = InputPrivacyKey; +inputPrivacyKeyStarGiftsAutoSave#e1732341 = InputPrivacyKey; +inputPrivacyKeyNoPaidMessages#bdc597b4 = InputPrivacyKey; +inputPrivacyKeySavedMusic#4dbe9226 = InputPrivacyKey; + +privacyKeyStatusTimestamp#bc2eab30 = PrivacyKey; +privacyKeyChatInvite#500e6dfa = PrivacyKey; +privacyKeyPhoneCall#3d662b7b = PrivacyKey; +privacyKeyPhoneP2P#39491cc8 = PrivacyKey; +privacyKeyForwards#69ec56a3 = PrivacyKey; +privacyKeyProfilePhoto#96151fed = PrivacyKey; +privacyKeyPhoneNumber#d19ae46d = PrivacyKey; +privacyKeyAddedByPhone#42ffd42b = PrivacyKey; +privacyKeyVoiceMessages#697f414 = PrivacyKey; +privacyKeyAbout#a486b761 = PrivacyKey; +privacyKeyBirthday#2000a518 = PrivacyKey; +privacyKeyStarGiftsAutoSave#2ca4fdf8 = PrivacyKey; +privacyKeyNoPaidMessages#17d348d2 = PrivacyKey; +privacyKeySavedMusic#ff7a571b = PrivacyKey; + +inputPrivacyValueAllowContacts#d09e07b = InputPrivacyRule; +inputPrivacyValueAllowAll#184b35ce = InputPrivacyRule; +inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; +inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; +inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; +inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; +inputPrivacyValueAllowCloseFriends#2f453e49 = InputPrivacyRule; +inputPrivacyValueAllowPremium#77cdc9f1 = InputPrivacyRule; +inputPrivacyValueAllowBots#5a4fcce5 = InputPrivacyRule; +inputPrivacyValueDisallowBots#c4e57915 = InputPrivacyRule; + +privacyValueAllowContacts#fffe1bac = PrivacyRule; +privacyValueAllowAll#65427b82 = PrivacyRule; +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; +privacyValueDisallowContacts#f888fa1a = PrivacyRule; +privacyValueDisallowAll#8b73e763 = PrivacyRule; +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; +privacyValueAllowCloseFriends#f7e8d89b = PrivacyRule; +privacyValueAllowPremium#ece9814b = PrivacyRule; +privacyValueAllowBots#21461b5d = PrivacyRule; +privacyValueDisallowBots#f6a5f82f = PrivacyRule; + +account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; + +accountDaysTTL#b8d0afdf days:int = AccountDaysTTL; + +documentAttributeImageSize#6c37c15c w:int h:int = DocumentAttribute; +documentAttributeAnimated#11b58939 = DocumentAttribute; +documentAttributeSticker#6319d612 flags:# mask:flags.1?true alt:string stickerset:InputStickerSet mask_coords:flags.0?MaskCoords = DocumentAttribute; +documentAttributeVideo#43c57c48 flags:# round_message:flags.0?true supports_streaming:flags.1?true nosound:flags.3?true duration:double w:int h:int preload_prefix_size:flags.2?int video_start_ts:flags.4?double video_codec:flags.5?string = DocumentAttribute; +documentAttributeAudio#9852f9c6 flags:# voice:flags.10?true duration:int title:flags.0?string performer:flags.1?string waveform:flags.2?bytes = DocumentAttribute; +documentAttributeFilename#15590068 file_name:string = DocumentAttribute; +documentAttributeHasStickers#9801d2f7 = DocumentAttribute; +documentAttributeCustomEmoji#fd149899 flags:# free:flags.0?true text_color:flags.1?true alt:string stickerset:InputStickerSet = DocumentAttribute; + +messages.stickersNotModified#f1749a22 = messages.Stickers; +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; + +stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; + +messages.allStickersNotModified#e86602c3 = messages.AllStickers; +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; + +messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; + +webPageEmpty#211a1788 flags:# id:long url:flags.0?string = WebPage; +webPagePending#b0d13e47 flags:# id:long url:flags.0?string date:int = WebPage; +webPage#e89c45b2 flags:# has_large_media:flags.13?true video_cover_photo:flags.14?true id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; + +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true unconfirmed:flags.5?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; + +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; + +account.password#957b50fb flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int login_email_pattern:flags.6?string = account.Password; + +account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; + +account.passwordInputSettings#c23727c9 flags:# new_algo:flags.0?PasswordKdfAlgo new_password_hash:flags.0?bytes hint:flags.0?string email:flags.1?string new_secure_settings:flags.2?SecureSecretSettings = account.PasswordInputSettings; + +auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; + +receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; + +chatInviteExported#a22cbd96 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int subscription_expired:flags.10?int title:flags.8?string subscription_pricing:flags.9?StarsSubscriptionPricing = ExportedChatInvite; +chatInvitePublicJoinRequests#ed107ab7 = ExportedChatInvite; + +chatInviteAlready#5a686d7c chat:Chat = ChatInvite; +chatInvite#5c9d3702 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true verified:flags.7?true scam:flags.8?true fake:flags.9?true can_refulfill_subscription:flags.11?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector color:int subscription_pricing:flags.10?StarsSubscriptionPricing subscription_form_id:flags.12?long bot_verification:flags.13?BotVerification = ChatInvite; +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; + +inputStickerSetEmpty#ffb62b95 = InputStickerSet; +inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; +inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; +inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; +inputStickerSetPremiumGifts#c88b3b02 = InputStickerSet; +inputStickerSetEmojiGenericAnimations#4c4d4ce = InputStickerSet; +inputStickerSetEmojiDefaultStatuses#29d0f5ee = InputStickerSet; +inputStickerSetEmojiDefaultTopicIcons#44c1f8e9 = InputStickerSet; +inputStickerSetEmojiChannelDefaultStatuses#49748553 = InputStickerSet; +inputStickerSetTonGifts#1cf671a0 = InputStickerSet; + +stickerSet#2dd14edc flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true emojis:flags.7?true text_color:flags.9?true channel_emoji_status:flags.10?true creator:flags.11?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int thumb_document_id:flags.8?long count:int hash:int = StickerSet; + +messages.stickerSet#6e153f16 set:StickerSet packs:Vector keywords:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; + +botCommand#c27ac8c7 command:string description:string = BotCommand; + +botInfo#4d8a0299 flags:# has_preview_medias:flags.6?true user_id:flags.0?long description:flags.1?string description_photo:flags.4?Photo description_document:flags.5?Document commands:flags.2?Vector menu_button:flags.3?BotMenuButton privacy_policy_url:flags.7?string app_settings:flags.8?BotAppSettings verifier_settings:flags.9?BotVerifierSettings = BotInfo; + +keyboardButton#a2fa4880 text:string = KeyboardButton; +keyboardButtonUrl#258aff05 text:string url:string = KeyboardButton; +keyboardButtonCallback#35bbdb6b flags:# requires_password:flags.0?true text:string data:bytes = KeyboardButton; +keyboardButtonRequestPhone#b16a6c29 text:string = KeyboardButton; +keyboardButtonRequestGeoLocation#fc796b3f text:string = KeyboardButton; +keyboardButtonSwitchInline#93b9fbb5 flags:# same_peer:flags.0?true text:string query:string peer_types:flags.1?Vector = KeyboardButton; +keyboardButtonGame#50f41ccf text:string = KeyboardButton; +keyboardButtonBuy#afd93fbb text:string = KeyboardButton; +keyboardButtonUrlAuth#10b78d29 flags:# text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +inputKeyboardButtonUrlAuth#d02e7fd4 flags:# request_write_access:flags.0?true text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +keyboardButtonRequestPoll#bbc7515d flags:# quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#e988037b text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#308660c1 text:string user_id:long = KeyboardButton; +keyboardButtonWebView#13767230 text:string url:string = KeyboardButton; +keyboardButtonSimpleWebView#a0c0505c text:string url:string = KeyboardButton; +keyboardButtonRequestPeer#53d7bfd8 text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +inputKeyboardButtonRequestPeer#c9662d05 flags:# name_requested:flags.0?true username_requested:flags.1?true photo_requested:flags.2?true text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +keyboardButtonCopy#75d2698e text:string copy_text:string = KeyboardButton; + +keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; + +replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true persistent:flags.4?true rows:Vector placeholder:flags.3?string = ReplyMarkup; +replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; + +messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; +messageEntityMention#fa04579d offset:int length:int = MessageEntity; +messageEntityHashtag#6f635b0d offset:int length:int = MessageEntity; +messageEntityBotCommand#6cef8ac7 offset:int length:int = MessageEntity; +messageEntityUrl#6ed02538 offset:int length:int = MessageEntity; +messageEntityEmail#64e475c2 offset:int length:int = MessageEntity; +messageEntityBold#bd610bc9 offset:int length:int = MessageEntity; +messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; +messageEntityCode#28a20571 offset:int length:int = MessageEntity; +messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; +messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; +inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; +messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; +messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; +messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; +messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; +messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; +messageEntityCustomEmoji#c8cf05f8 offset:int length:int document_id:long = MessageEntity; +messageEntityBlockquote#f1ccaaac flags:# collapsed:flags.0?true offset:int length:int = MessageEntity; + +inputChannelEmpty#ee8c1e86 = InputChannel; +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; + +contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; + +messageRange#ae30253 min_id:int max_id:int = MessageRange; + +updates.channelDifferenceEmpty#3e11affb flags:# final:flags.0?true pts:int timeout:flags.1?int = updates.ChannelDifference; +updates.channelDifferenceTooLong#a4bcc6fe flags:# final:flags.0?true timeout:flags.1?int dialog:Dialog messages:Vector chats:Vector users:Vector = updates.ChannelDifference; +updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int new_messages:Vector other_updates:Vector chats:Vector users:Vector = updates.ChannelDifference; + +channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; +channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; + +channelParticipant#cb397619 flags:# user_id:long date:int subscription_until_date:flags.0?int = ChannelParticipant; +channelParticipantSelf#4f607bef flags:# via_request:flags.0?true user_id:long inviter_id:long date:int subscription_until_date:flags.1?int = ChannelParticipant; +channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; +channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; +channelParticipantBanned#6df8014e flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights = ChannelParticipant; +channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; + +channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; +channelParticipantsAdmins#b4608969 = ChannelParticipantsFilter; +channelParticipantsKicked#a3b54985 q:string = ChannelParticipantsFilter; +channelParticipantsBots#b0d1865b = ChannelParticipantsFilter; +channelParticipantsBanned#1427a5e1 q:string = ChannelParticipantsFilter; +channelParticipantsSearch#656ac4b q:string = ChannelParticipantsFilter; +channelParticipantsContacts#bb6ae88d q:string = ChannelParticipantsFilter; +channelParticipantsMentions#e04b5ceb flags:# q:flags.0?string top_msg_id:flags.1?int = ChannelParticipantsFilter; + +channels.channelParticipants#9ab0feaf count:int participants:Vector chats:Vector users:Vector = channels.ChannelParticipants; +channels.channelParticipantsNotModified#f0173fe9 = channels.ChannelParticipants; + +channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector users:Vector = channels.ChannelParticipant; + +help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; + +messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; + +inputBotInlineMessageMediaAuto#3380c786 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaGeo#96929a85 flags:# geo_point:InputGeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaVenue#417bbf11 flags:# geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaContact#a6edbffd flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageGame#4b425864 flags:# reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaInvoice#d7e78225 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaWebPage#bddcc510 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true optional:flags.6?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineResult#88bf9319 flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?InputWebDocument content:flags.5?InputWebDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultPhoto#a8d864a7 id:string type:string photo:InputPhoto send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultDocument#fff8fdc4 flags:# id:string type:string title:flags.1?string description:flags.2?string document:InputDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultGame#4fa417f2 id:string short_name:string send_message:InputBotInlineMessage = InputBotInlineResult; + +botInlineMessageMediaAuto#764cf810 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageText#8c7f65e2 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaGeo#51846fd flags:# geo:GeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaVenue#8a86659c flags:# geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaContact#18d1cdc2 flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaInvoice#354a9b09 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument currency:string total_amount:long reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaWebPage#809ad9a6 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true manual:flags.7?true safe:flags.8?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineResult#11965f3a flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?WebDocument content:flags.5?WebDocument send_message:BotInlineMessage = BotInlineResult; +botInlineMediaResult#17db940b flags:# id:string type:string photo:flags.0?Photo document:flags.1?Document title:flags.2?string description:flags.3?string send_message:BotInlineMessage = BotInlineResult; + +messages.botResults#e021f2f6 flags:# gallery:flags.0?true query_id:long next_offset:flags.1?string switch_pm:flags.2?InlineBotSwitchPM switch_webview:flags.3?InlineBotWebView results:Vector cache_time:int users:Vector = messages.BotResults; + +exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; + +messageFwdHeader#4e4df4bb flags:# imported:flags.7?true saved_out:flags.11?true from_id:flags.0?Peer from_name:flags.5?string date:int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int saved_from_id:flags.8?Peer saved_from_name:flags.9?string saved_date:flags.10?int psa_type:flags.6?string = MessageFwdHeader; + +auth.codeTypeSms#72a3158c = auth.CodeType; +auth.codeTypeCall#741cd3e3 = auth.CodeType; +auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; + +auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; +auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; +auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; +auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; +auth.sentCodeTypeEmailCode#f450f59b flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int reset_available_period:flags.3?int reset_pending_date:flags.4?int = auth.SentCodeType; +auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; +auth.sentCodeTypeFirebaseSms#9fd736 flags:# nonce:flags.0?bytes play_integrity_project_id:flags.2?long play_integrity_nonce:flags.2?bytes receipt:flags.1?string push_timeout:flags.1?int length:int = auth.SentCodeType; +auth.sentCodeTypeSmsWord#a416ac81 flags:# beginning:flags.0?string = auth.SentCodeType; +auth.sentCodeTypeSmsPhrase#b37794af flags:# beginning:flags.0?string = auth.SentCodeType; + +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; + +messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; + +inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; + +inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; + +messages.peerDialogs#3371c354 dialogs:Vector messages:Vector chats:Vector users:Vector state:updates.State = messages.PeerDialogs; + +topPeer#edcdc05b peer:Peer rating:double = TopPeer; + +topPeerCategoryBotsPM#ab661b5b = TopPeerCategory; +topPeerCategoryBotsInline#148677e2 = TopPeerCategory; +topPeerCategoryCorrespondents#637b7ed = TopPeerCategory; +topPeerCategoryGroups#bd17a14a = TopPeerCategory; +topPeerCategoryChannels#161d9628 = TopPeerCategory; +topPeerCategoryPhoneCalls#1e76a78c = TopPeerCategory; +topPeerCategoryForwardUsers#a8406ca9 = TopPeerCategory; +topPeerCategoryForwardChats#fbeec0f0 = TopPeerCategory; +topPeerCategoryBotsApp#fd9e7bec = TopPeerCategory; + +topPeerCategoryPeers#fb834291 category:TopPeerCategory count:int peers:Vector = TopPeerCategoryPeers; + +contacts.topPeersNotModified#de266ef5 = contacts.TopPeers; +contacts.topPeers#70b772a8 categories:Vector chats:Vector users:Vector = contacts.TopPeers; +contacts.topPeersDisabled#b52c939d = contacts.TopPeers; + +draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; +draftMessage#96eaa5eb flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo message:string entities:flags.3?Vector media:flags.5?InputMedia date:int effect:flags.7?long suggested_post:flags.8?SuggestedPost = DraftMessage; + +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; +messages.featuredStickers#be382906 flags:# premium:flags.0?true hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; + +messages.recentStickersNotModified#b17f890 = messages.RecentStickers; +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; + +messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; + +messages.stickerSetInstallResultSuccess#38641628 = messages.StickerSetInstallResult; +messages.stickerSetInstallResultArchive#35e410a8 sets:Vector = messages.StickerSetInstallResult; + +stickerSetCovered#6410a5d2 set:StickerSet cover:Document = StickerSetCovered; +stickerSetMultiCovered#3407e51b set:StickerSet covers:Vector = StickerSetCovered; +stickerSetFullCovered#40d13c0e set:StickerSet packs:Vector keywords:Vector documents:Vector = StickerSetCovered; +stickerSetNoCovered#77b15d1c set:StickerSet = StickerSetCovered; + +maskCoords#aed6dbb2 n:int x:double y:double zoom:double = MaskCoords; + +inputStickeredMediaPhoto#4a992157 id:InputPhoto = InputStickeredMedia; +inputStickeredMediaDocument#438865b id:InputDocument = InputStickeredMedia; + +game#bdf9653b flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document = Game; + +inputGameID#32c3e77 id:long access_hash:long = InputGame; +inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; + +highScore#73a379eb pos:int user_id:long score:int = HighScore; + +messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; + +textEmpty#dc3d824f = RichText; +textPlain#744694e0 text:string = RichText; +textBold#6724abc4 text:RichText = RichText; +textItalic#d912a59c text:RichText = RichText; +textUnderline#c12622c4 text:RichText = RichText; +textStrike#9bf8bb95 text:RichText = RichText; +textFixed#6c3f19b9 text:RichText = RichText; +textUrl#3c2884c1 text:RichText url:string webpage_id:long = RichText; +textEmail#de5a0dd6 text:RichText email:string = RichText; +textConcat#7e6260d7 texts:Vector = RichText; +textSubscript#ed6a8504 text:RichText = RichText; +textSuperscript#c7fb5e01 text:RichText = RichText; +textMarked#34b8621 text:RichText = RichText; +textPhone#1ccb966a text:RichText phone:string = RichText; +textImage#81ccf4f document_id:long w:int h:int = RichText; +textAnchor#35553762 text:RichText name:string = RichText; + +pageBlockUnsupported#13567e8a = PageBlock; +pageBlockTitle#70abc3fd text:RichText = PageBlock; +pageBlockSubtitle#8ffa9a1f text:RichText = PageBlock; +pageBlockAuthorDate#baafe5e0 author:RichText published_date:int = PageBlock; +pageBlockHeader#bfd064ec text:RichText = PageBlock; +pageBlockSubheader#f12bb6e1 text:RichText = PageBlock; +pageBlockParagraph#467a0766 text:RichText = PageBlock; +pageBlockPreformatted#c070d93e text:RichText language:string = PageBlock; +pageBlockFooter#48870999 text:RichText = PageBlock; +pageBlockDivider#db20b188 = PageBlock; +pageBlockAnchor#ce0d37b0 name:string = PageBlock; +pageBlockList#e4e88011 items:Vector = PageBlock; +pageBlockBlockquote#263d7c26 text:RichText caption:RichText = PageBlock; +pageBlockPullquote#4f4456d3 text:RichText caption:RichText = PageBlock; +pageBlockPhoto#1759c560 flags:# photo_id:long caption:PageCaption url:flags.0?string webpage_id:flags.0?long = PageBlock; +pageBlockVideo#7c8fe7b6 flags:# autoplay:flags.0?true loop:flags.1?true video_id:long caption:PageCaption = PageBlock; +pageBlockCover#39f23300 cover:PageBlock = PageBlock; +pageBlockEmbed#a8718dc5 flags:# full_width:flags.0?true allow_scrolling:flags.3?true url:flags.1?string html:flags.2?string poster_photo_id:flags.4?long w:flags.5?int h:flags.5?int caption:PageCaption = PageBlock; +pageBlockEmbedPost#f259a80b url:string webpage_id:long author_photo_id:long author:string date:int blocks:Vector caption:PageCaption = PageBlock; +pageBlockCollage#65a0fa4d items:Vector caption:PageCaption = PageBlock; +pageBlockSlideshow#31f9590 items:Vector caption:PageCaption = PageBlock; +pageBlockChannel#ef1751b5 channel:Chat = PageBlock; +pageBlockAudio#804361ea audio_id:long caption:PageCaption = PageBlock; +pageBlockKicker#1e148390 text:RichText = PageBlock; +pageBlockTable#bf4dea82 flags:# bordered:flags.0?true striped:flags.1?true title:RichText rows:Vector = PageBlock; +pageBlockOrderedList#9a8ae1e1 items:Vector = PageBlock; +pageBlockDetails#76768bed flags:# open:flags.0?true blocks:Vector title:RichText = PageBlock; +pageBlockRelatedArticles#16115a96 title:RichText articles:Vector = PageBlock; +pageBlockMap#a44f3ef6 geo:GeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; + +phoneCallDiscardReasonMissed#85e42301 = PhoneCallDiscardReason; +phoneCallDiscardReasonDisconnect#e095c1a0 = PhoneCallDiscardReason; +phoneCallDiscardReasonHangup#57adc690 = PhoneCallDiscardReason; +phoneCallDiscardReasonBusy#faf7e8c9 = PhoneCallDiscardReason; +phoneCallDiscardReasonMigrateConferenceCall#9fbbf1f7 slug:string = PhoneCallDiscardReason; + +dataJSON#7d748d04 data:string = DataJSON; + +labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; + +invoice#49ee584 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true recurring:flags.9?true currency:string prices:Vector max_tip_amount:flags.8?long suggested_tip_amounts:flags.8?Vector terms_url:flags.10?string subscription_period:flags.11?int = Invoice; + +paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; + +postAddress#1e8caaeb street_line1:string street_line2:string city:string state:string country_iso2:string post_code:string = PostAddress; + +paymentRequestedInfo#909c3f94 flags:# name:flags.0?string phone:flags.1?string email:flags.2?string shipping_address:flags.3?PostAddress = PaymentRequestedInfo; + +paymentSavedCredentialsCard#cdc27a1f id:string title:string = PaymentSavedCredentials; + +webDocument#1c570ed1 url:string access_hash:long size:int mime_type:string attributes:Vector = WebDocument; +webDocumentNoProxy#f9c8bcc6 url:string size:int mime_type:string attributes:Vector = WebDocument; + +inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector = InputWebDocument; + +inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation; +inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation; +inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# small:flags.2?true document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation; + +upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; + +payments.paymentForm#a0058751 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON additional_methods:flags.6?Vector saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?Vector users:Vector = payments.PaymentForm; +payments.paymentFormStars#7bf6b15c flags:# form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice users:Vector = payments.PaymentForm; +payments.paymentFormStarGift#b425cfe1 form_id:long invoice:Invoice = payments.PaymentForm; + +payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; + +payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; +payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; + +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; +payments.paymentReceiptStars#dabbf83a flags:# date:int bot_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice currency:string total_amount:long transaction_id:string users:Vector = payments.PaymentReceipt; + +payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; + +inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; +inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsGooglePay#8ac32801 payment_token:DataJSON = InputPaymentCredentials; + +account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; + +shippingOption#b6213cdf id:string title:string prices:Vector = ShippingOption; + +inputStickerSetItem#32da9e9c flags:# document:InputDocument emoji:string mask_coords:flags.0?MaskCoords keywords:flags.1?string = InputStickerSetItem; + +inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; + +phoneCallEmpty#5366c915 id:long = PhoneCall; +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#30535af5 flags:# p2p_allowed:flags.5?true video:flags.6?true conference_supported:flags.8?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int custom_parameters:flags.7?DataJSON = PhoneCall; +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; + +phoneConnection#9cc123c7 flags:# tcp:flags.0?true id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; + +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; + +phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; + +upload.cdnFileReuploadNeeded#eea8e46e request_token:bytes = upload.CdnFile; +upload.cdnFile#a99fca4f bytes:bytes = upload.CdnFile; + +cdnPublicKey#c982eaba dc_id:int public_key:string = CdnPublicKey; + +cdnConfig#5725e40a public_keys:Vector = CdnConfig; + +langPackString#cad181f6 key:string value:string = LangPackString; +langPackStringPluralized#6c47ac9f flags:# key:string zero_value:flags.0?string one_value:flags.1?string two_value:flags.2?string few_value:flags.3?string many_value:flags.4?string other_value:string = LangPackString; +langPackStringDeleted#2979eeb2 key:string = LangPackString; + +langPackDifference#f385c1f6 lang_code:string from_version:int version:int strings:Vector = LangPackDifference; + +langPackLanguage#eeca5ce3 flags:# official:flags.0?true rtl:flags.2?true beta:flags.3?true name:string native_name:string lang_code:string base_lang_code:flags.1?string plural_code:string strings_count:int translated_count:int translations_url:string = LangPackLanguage; + +channelAdminLogEventActionChangeTitle#e6dfb825 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAbout#55188a2e prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsername#6a4afc38 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePhoto#434bd2af prev_photo:Photo new_photo:Photo = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleInvites#1b7907ae new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatures#26ae0971 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionUpdatePinned#e9e82c18 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionEditMessage#709b2405 prev_message:Message new_message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteMessage#42e047bb message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoin#183040d3 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantLeave#f89777f2 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantInvite#e31c34d8 participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleBan#e6d83d7e prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleAdmin#d5676710 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; +channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionDiscardGroupCall#db9f9140 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantMute#f92424d2 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantUnmute#e64429c0 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleGroupCallSetting#56d6a247 join_muted:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByInvite#fe9fc158 flags:# via_chatlist:flags.0?true invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteDelete#5a50fca4 invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePeerColor#5796e780 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeProfilePeerColor#5e477b25 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeWallpaper#31bb5d52 prev_value:WallPaper new_value:WallPaper = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStatus#3ea9feb1 prev_value:EmojiStatus new_value:EmojiStatus = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStickerSet#46d840ab prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatureProfiles#60a79c79 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantSubExtend#64642db3 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAutotranslation#c517f77e new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; + +channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; + +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true sub_extend:flags.18?true = ChannelAdminLogEventsFilter; + +popularContact#5ce14175 client_id:long importers:int = PopularContact; + +messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; + +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; +recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; + +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; + +account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; + +inputMessageID#a676a322 id:int = InputMessage; +inputMessageReplyTo#bad88395 id:int = InputMessage; +inputMessagePinned#86872538 = InputMessage; +inputMessageCallbackQuery#acfa1a7e id:int query_id:long = InputMessage; + +inputDialogPeer#fcaafeb7 peer:InputPeer = InputDialogPeer; +inputDialogPeerFolder#64600527 folder_id:int = InputDialogPeer; + +dialogPeer#e56dbf05 peer:Peer = DialogPeer; +dialogPeerFolder#514519e2 folder_id:int = DialogPeer; + +messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; + +fileHash#f39b035c offset:long limit:int hash:bytes = FileHash; + +inputClientProxy#75588b3f address:string port:int = InputClientProxy; + +help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; +help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; + +inputSecureFileUploaded#3334b0f0 id:long parts:int md5_checksum:string file_hash:bytes secret:bytes = InputSecureFile; +inputSecureFile#5367e5be id:long access_hash:long = InputSecureFile; + +secureFileEmpty#64199744 = SecureFile; +secureFile#7d09c27e id:long access_hash:long size:long dc_id:int date:int file_hash:bytes secret:bytes = SecureFile; + +secureData#8aeabec3 data:bytes data_hash:bytes secret:bytes = SecureData; + +securePlainPhone#7d6099dd phone:string = SecurePlainData; +securePlainEmail#21ec5a5f email:string = SecurePlainData; + +secureValueTypePersonalDetails#9d2a81e3 = SecureValueType; +secureValueTypePassport#3dac6a00 = SecureValueType; +secureValueTypeDriverLicense#6e425c4 = SecureValueType; +secureValueTypeIdentityCard#a0d0744b = SecureValueType; +secureValueTypeInternalPassport#99a48f23 = SecureValueType; +secureValueTypeAddress#cbe31e26 = SecureValueType; +secureValueTypeUtilityBill#fc36954e = SecureValueType; +secureValueTypeBankStatement#89137c0d = SecureValueType; +secureValueTypeRentalAgreement#8b883488 = SecureValueType; +secureValueTypePassportRegistration#99e3806a = SecureValueType; +secureValueTypeTemporaryRegistration#ea02ec33 = SecureValueType; +secureValueTypePhone#b320aadb = SecureValueType; +secureValueTypeEmail#8e3ca7ee = SecureValueType; + +secureValue#187fa0ca flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?SecureFile reverse_side:flags.2?SecureFile selfie:flags.3?SecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData hash:bytes = SecureValue; + +inputSecureValue#db21d0a7 flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?InputSecureFile reverse_side:flags.2?InputSecureFile selfie:flags.3?InputSecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData = InputSecureValue; + +secureValueHash#ed1ecdb0 type:SecureValueType hash:bytes = SecureValueHash; + +secureValueErrorData#e8a40bd9 type:SecureValueType data_hash:bytes field:string text:string = SecureValueError; +secureValueErrorFrontSide#be3dfa type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorReverseSide#868a2aa5 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorSelfie#e537ced6 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFile#7a700873 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFiles#666220e9 type:SecureValueType file_hash:Vector text:string = SecureValueError; +secureValueError#869d758f type:SecureValueType hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFile#a1144770 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFiles#34636dd8 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureCredentialsEncrypted#33f0ea47 data:bytes hash:bytes secret:bytes = SecureCredentialsEncrypted; + +account.authorizationForm#ad2e1cd8 flags:# required_types:Vector values:Vector errors:Vector users:Vector privacy_policy_url:flags.0?string = account.AuthorizationForm; + +account.sentEmailCode#811f854f email_pattern:string length:int = account.SentEmailCode; + +help.deepLinkInfoEmpty#66afa166 = help.DeepLinkInfo; +help.deepLinkInfo#6a4ee832 flags:# update_app:flags.0?true message:string entities:flags.1?Vector = help.DeepLinkInfo; + +savedPhoneContact#1142bd56 phone:string first_name:string last_name:string date:int = SavedContact; + +account.takeout#4dba4501 id:long = account.Takeout; + +passwordKdfAlgoUnknown#d45ab096 = PasswordKdfAlgo; +passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow#3a912d4a salt1:bytes salt2:bytes g:int p:bytes = PasswordKdfAlgo; + +securePasswordKdfAlgoUnknown#4a8537 = SecurePasswordKdfAlgo; +securePasswordKdfAlgoPBKDF2HMACSHA512iter100000#bbf2dda0 salt:bytes = SecurePasswordKdfAlgo; +securePasswordKdfAlgoSHA512#86471d92 salt:bytes = SecurePasswordKdfAlgo; + +secureSecretSettings#1527bcac secure_algo:SecurePasswordKdfAlgo secure_secret:bytes secure_secret_id:long = SecureSecretSettings; + +inputCheckPasswordEmpty#9880f658 = InputCheckPasswordSRP; +inputCheckPasswordSRP#d27ff082 srp_id:long A:bytes M1:bytes = InputCheckPasswordSRP; + +secureRequiredType#829d99da flags:# native_names:flags.0?true selfie_required:flags.1?true translation_required:flags.2?true type:SecureValueType = SecureRequiredType; +secureRequiredTypeOneOf#27477b4 types:Vector = SecureRequiredType; + +help.passportConfigNotModified#bfb9f457 = help.PassportConfig; +help.passportConfig#a098d6af hash:int countries_langs:DataJSON = help.PassportConfig; + +inputAppEvent#1d1b1245 time:double type:string peer:long data:JSONValue = InputAppEvent; + +jsonObjectValue#c0de1bd9 key:string value:JSONValue = JSONObjectValue; + +jsonNull#3f6d7b68 = JSONValue; +jsonBool#c7345e6a value:Bool = JSONValue; +jsonNumber#2be0dfa4 value:double = JSONValue; +jsonString#b71e767a value:string = JSONValue; +jsonArray#f7444763 value:Vector = JSONValue; +jsonObject#99c1d49d value:Vector = JSONValue; + +pageTableCell#34566b6a flags:# header:flags.0?true align_center:flags.3?true align_right:flags.4?true valign_middle:flags.5?true valign_bottom:flags.6?true text:flags.7?RichText colspan:flags.1?int rowspan:flags.2?int = PageTableCell; + +pageTableRow#e0c0c5e5 cells:Vector = PageTableRow; + +pageCaption#6f747657 text:RichText credit:RichText = PageCaption; + +pageListItemText#b92fb6cd text:RichText = PageListItem; +pageListItemBlocks#25e073fc blocks:Vector = PageListItem; + +pageListOrderedItemText#5e068047 num:string text:RichText = PageListOrderedItem; +pageListOrderedItemBlocks#98dd8936 num:string blocks:Vector = PageListOrderedItem; + +pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; + +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; + +help.supportName#8c05f1c9 name:string = help.SupportName; + +help.userInfoEmpty#f3ae2eed = help.UserInfo; +help.userInfo#1eb3758 message:string entities:Vector author:string date:int = help.UserInfo; + +pollAnswer#ff16e2ca text:TextWithEntities option:bytes = PollAnswer; + +poll#58747131 id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true question:TextWithEntities answers:Vector close_period:flags.4?int close_date:flags.5?int = Poll; + +pollAnswerVoters#3b6ddad2 flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:int = PollAnswerVoters; + +pollResults#7adf2420 flags:# min:flags.0?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector = PollResults; + +chatOnlines#f041e250 onlines:int = ChatOnlines; + +statsURL#47a971e0 url:string = StatsURL; + +chatAdminRights#5fb224d5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true pin_messages:flags.7?true add_admins:flags.9?true anonymous:flags.10?true manage_call:flags.11?true other:flags.12?true manage_topics:flags.13?true post_stories:flags.14?true edit_stories:flags.15?true delete_stories:flags.16?true manage_direct_messages:flags.17?true = ChatAdminRights; + +chatBannedRights#9f120418 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true send_polls:flags.8?true change_info:flags.10?true invite_users:flags.15?true pin_messages:flags.17?true manage_topics:flags.18?true send_photos:flags.19?true send_videos:flags.20?true send_roundvideos:flags.21?true send_audios:flags.22?true send_voices:flags.23?true send_docs:flags.24?true send_plain:flags.25?true until_date:int = ChatBannedRights; + +inputWallPaper#e630b979 id:long access_hash:long = InputWallPaper; +inputWallPaperSlug#72091c80 slug:string = InputWallPaper; +inputWallPaperNoFile#967a462e id:long = InputWallPaper; + +account.wallPapersNotModified#1c199183 = account.WallPapers; +account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; + +codeSettings#ad253d78 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true allow_firebase:flags.7?true unknown_number:flags.9?true logout_tokens:flags.6?Vector token:flags.8?string app_sandbox:flags.8?Bool = CodeSettings; + +wallPaperSettings#372efcd0 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int emoticon:flags.7?string = WallPaperSettings; + +autoDownloadSettings#baa57628 flags:# disabled:flags.0?true video_preload_large:flags.1?true audio_preload_next:flags.2?true phonecalls_less_data:flags.3?true stories_preload:flags.4?true photo_size_max:int video_size_max:long file_size_max:long video_upload_maxbitrate:int small_queue_active_operations_max:int large_queue_active_operations_max:int = AutoDownloadSettings; + +account.autoDownloadSettings#63cacf26 low:AutoDownloadSettings medium:AutoDownloadSettings high:AutoDownloadSettings = account.AutoDownloadSettings; + +emojiKeyword#d5b3b9f9 keyword:string emoticons:Vector = EmojiKeyword; +emojiKeywordDeleted#236df622 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordsDifference#5cc761bd lang_code:string from_version:int version:int keywords:Vector = EmojiKeywordsDifference; + +emojiURL#a575739d url:string = EmojiURL; + +emojiLanguage#b3fb5361 lang_code:string = EmojiLanguage; + +folder#ff544e65 flags:# autofill_new_broadcasts:flags.0?true autofill_public_groups:flags.1?true autofill_new_correspondents:flags.2?true id:int title:string photo:flags.3?ChatPhoto = Folder; + +inputFolderPeer#fbd2c296 peer:InputPeer folder_id:int = InputFolderPeer; + +folderPeer#e9baa668 peer:Peer folder_id:int = FolderPeer; + +messages.searchCounter#e844ebff flags:# inexact:flags.1?true filter:MessagesFilter count:int = messages.SearchCounter; + +urlAuthResultRequest#92d33a0e flags:# request_write_access:flags.0?true bot:User domain:string = UrlAuthResult; +urlAuthResultAccepted#8f8c0e4e url:string = UrlAuthResult; +urlAuthResultDefault#a9d6db1f = UrlAuthResult; + +channelLocationEmpty#bfb5ad8b = ChannelLocation; +channelLocation#209b82db geo_point:GeoPoint address:string = ChannelLocation; + +peerLocated#ca461b5d peer:Peer expires:int distance:int = PeerLocated; +peerSelfLocated#f8ec284b expires:int = PeerLocated; + +restrictionReason#d072acb4 platform:string reason:string text:string = RestrictionReason; + +inputTheme#3c5693e9 id:long access_hash:long = InputTheme; +inputThemeSlug#f5890df1 slug:string = InputTheme; + +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; + +account.themesNotModified#f41eb622 = account.Themes; +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; + +auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; +auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; +auth.loginTokenSuccess#390d5c5e authorization:auth.Authorization = auth.LoginToken; + +account.contentSettings#57e28221 flags:# sensitive_enabled:flags.0?true sensitive_can_change:flags.1?true = account.ContentSettings; + +messages.inactiveChats#a927fec5 dates:Vector chats:Vector users:Vector = messages.InactiveChats; + +baseThemeClassic#c3a12462 = BaseTheme; +baseThemeDay#fbd81688 = BaseTheme; +baseThemeNight#b7b31ea8 = BaseTheme; +baseThemeTinted#6d5f77ee = BaseTheme; +baseThemeArctic#5b11125a = BaseTheme; + +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; + +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; + +webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; +webPageAttributeStory#2e94c3e7 flags:# peer:Peer id:int story:flags.0?StoryItem = WebPageAttribute; +webPageAttributeStickerSet#50cc03d3 flags:# emojis:flags.0?true text_color:flags.1?true stickers:Vector = WebPageAttribute; +webPageAttributeUniqueStarGift#cf6f6db8 gift:StarGift = WebPageAttribute; +webPageAttributeStarGiftCollection#31cad303 icons:Vector = WebPageAttribute; +webPageAttributeStarGiftAuction#1c641c2 gift:StarGift end_date:int = WebPageAttribute; + +messages.votesList#4899484e flags:# count:int votes:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.VotesList; + +bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; + +payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; + +dialogFilter#aa472651 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; +dialogFilterDefault#363293ae = DialogFilter; +dialogFilterChatlist#96537bd7 flags:# has_my_invites:flags.26?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; +statsGraphError#bedc9822 error:string = StatsGraph; +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +stats.broadcastStats#396ca5fc period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev reactions_per_post:StatsAbsValueAndPrev views_per_story:StatsAbsValueAndPrev shares_per_story:StatsAbsValueAndPrev reactions_per_story:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph reactions_by_emotion_graph:StatsGraph story_interactions_graph:StatsGraph story_reactions_by_emotion_graph:StatsGraph recent_posts_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; +help.promoData#8a4d87a flags:# proxy:flags.0?true expires:int peer:flags.3?Peer psa_type:flags.1?string psa_message:flags.2?string pending_suggestions:Vector dismissed_suggestions:Vector custom_pending_suggestion:flags.4?PendingSuggestion chats:Vector users:Vector = help.PromoData; + +videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; +videoSizeEmojiMarkup#f85c413c emoji_id:long background_colors:Vector = VideoSize; +videoSizeStickerMarkup#da082fe stickerset:InputStickerSet sticker_id:long background_colors:Vector = VideoSize; + +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#fe41b34f flags:# archive_and_mute_new_noncontact_peers:flags.0?true keep_archived_unmuted:flags.1?true keep_archived_folders:flags.2?true hide_read_marks:flags.3?true new_noncontact_peers_require_premium:flags.4?true display_gifts_button:flags.7?true noncontact_peers_paid_stars:flags.5?long disallowed_gifts:flags.6?DisallowedGiftsSettings = GlobalPrivacySettings; + +help.countryCode#4203c5ef flags:# country_code:string prefixes:flags.0?Vector patterns:flags.1?Vector = help.CountryCode; + +help.country#c3878e23 flags:# hidden:flags.0?true iso2:string default_name:string name:flags.1?string country_codes:Vector = help.Country; + +help.countriesListNotModified#93cc1f32 = help.CountriesList; +help.countriesList#87d0759e countries:Vector hash:int = help.CountriesList; + +messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:flags.2?MessageReplies = MessageViews; + +messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; + +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; + +messageReplyHeader#6917560b flags:# reply_to_scheduled:flags.2?true forum_topic:flags.3?true quote:flags.9?true reply_to_msg_id:flags.4?int reply_to_peer_id:flags.0?Peer reply_from:flags.5?MessageFwdHeader reply_media:flags.8?MessageMedia reply_to_top_id:flags.1?int quote_text:flags.6?string quote_entities:flags.7?Vector quote_offset:flags.10?int todo_item_id:flags.11?int = MessageReplyHeader; +messageReplyStoryHeader#e5af939 peer:Peer story_id:int = MessageReplyHeader; + +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; + +peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; + +stats.messageStats#7fe91c14 views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.MessageStats; + +groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; +groupCall#efb2b617 flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true conference:flags.14?true creator:flags.15?true messages_enabled:flags.17?true can_change_messages_enabled:flags.18?true min:flags.19?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int invite_link:flags.16?string send_paid_messages_stars:flags.20?long default_send_as:flags.21?Peer = GroupCall; + +inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; +inputGroupCallSlug#fe06823f slug:string = InputGroupCall; +inputGroupCallInviteMessage#8c10603f msg_id:int = InputGroupCall; + +groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant; + +phone.groupCall#9e727aad call:GroupCall participants:Vector participants_next_offset:string chats:Vector users:Vector = phone.GroupCall; + +phone.groupParticipants#f47751b6 count:int participants:Vector next_offset:string chats:Vector users:Vector version:int = phone.GroupParticipants; + +inlineQueryPeerTypeSameBotPM#3081ed9d = InlineQueryPeerType; +inlineQueryPeerTypePM#833c0fac = InlineQueryPeerType; +inlineQueryPeerTypeChat#d766c50a = InlineQueryPeerType; +inlineQueryPeerTypeMegagroup#5ec4be43 = InlineQueryPeerType; +inlineQueryPeerTypeBroadcast#6334ee9a = InlineQueryPeerType; +inlineQueryPeerTypeBotPM#e3b2d0c = InlineQueryPeerType; + +messages.historyImport#1662af0b id:long = messages.HistoryImport; + +messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true title:flags.2?string = messages.HistoryImportParsed; + +messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; + +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true via_chatlist:flags.3?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; + +messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; + +messages.exportedChatInvite#1871be50 invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; +messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; + +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; + +messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; + +messages.checkedHistoryImportPeer#a24de717 confirm_text:string = messages.CheckedHistoryImportPeer; + +phone.joinAsPeers#afe5623f peers:Vector chats:Vector users:Vector = phone.JoinAsPeers; + +phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvite; + +groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; + +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; +botCommandScopeUsers#3c4f04d8 = BotCommandScope; +botCommandScopeChats#6fe1a881 = BotCommandScope; +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +chatTheme#c3dffc04 emoticon:string = ChatTheme; +chatThemeUniqueGift#3458f9c8 gift:StarGift theme_settings:Vector = ChatTheme; + +account.chatThemesNotModified#e011e1c4 = account.ChatThemes; +account.chatThemes#be098173 flags:# hash:long themes:Vector chats:Vector users:Vector next_offset:flags.0?string = account.ChatThemes; + +sponsoredMessage#7dbf8673 flags:# recommended:flags.5?true can_report:flags.12?true random_id:bytes url:string title:string message:string entities:flags.1?Vector photo:flags.6?Photo media:flags.14?MessageMedia color:flags.13?PeerColor button_text:string sponsor_info:flags.7?string additional_info:flags.8?string min_display_duration:flags.15?int max_display_duration:flags.15?int = SponsoredMessage; + +messages.sponsoredMessages#ffda656d flags:# posts_between:flags.0?int start_delay:flags.1?int between_delay:flags.2?int messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +messages.sponsoredMessagesEmpty#1839490f = messages.SponsoredMessages; + +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#f496b0c6 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#a3d1cb80 flags:# chosen_order:flags.0?int reaction:Reaction count:int = ReactionCount; + +messageReactions#a339f0b flags:# min:flags.0?true can_see_list:flags.2?true reactions_as_tags:flags.3?true results:Vector recent_reactions:flags.1?Vector top_reactors:flags.4?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true premium:flags.2?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + +messagePeerReaction#8c79b63c flags:# big:flags.0?true unread:flags.1?true my:flags.2?true peer_id:Peer date:int reaction:Reaction = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; + +attachMenuBotIconColor#4576f3f0 name:string color:int = AttachMenuBotIconColor; + +attachMenuBotIcon#b2a7386b flags:# name:string icon:Document colors:flags.0?Vector = AttachMenuBotIcon; + +attachMenuBot#d90d8dfe flags:# inactive:flags.0?true has_settings:flags.1?true request_write_access:flags.2?true show_in_attach_menu:flags.3?true show_in_side_menu:flags.4?true side_menu_disclaimer_needed:flags.5?true bot_id:long short_name:string peer_types:flags.3?Vector icons:Vector = AttachMenuBot; + +attachMenuBotsNotModified#f1d88a5c = AttachMenuBots; +attachMenuBots#3c4301c0 hash:long bots:Vector users:Vector = AttachMenuBots; + +attachMenuBotsBot#93bf667f bot:AttachMenuBot users:Vector = AttachMenuBotsBot; + +webViewResultUrl#4d22ff98 flags:# fullsize:flags.1?true fullscreen:flags.2?true query_id:flags.0?long url:string = WebViewResult; + +webViewMessageSent#c94511c flags:# msg_id:flags.0?InputBotInlineMessageID = WebViewMessageSent; + +botMenuButtonDefault#7533a588 = BotMenuButton; +botMenuButtonCommands#4258c205 = BotMenuButton; +botMenuButton#c7b57ce6 text:string url:string = BotMenuButton; + +account.savedRingtonesNotModified#fbf6e8b1 = account.SavedRingtones; +account.savedRingtones#c1e92cc5 hash:long ringtones:Vector = account.SavedRingtones; + +notificationSoundDefault#97e8bebe = NotificationSound; +notificationSoundNone#6f0c34df = NotificationSound; +notificationSoundLocal#830b9ae4 title:string data:string = NotificationSound; +notificationSoundRingtone#ff6c8049 id:long = NotificationSound; + +account.savedRingtone#b7263f6d = account.SavedRingtone; +account.savedRingtoneConverted#1f307eb7 document:Document = account.SavedRingtone; + +attachMenuPeerTypeSameBotPM#7d6be90e = AttachMenuPeerType; +attachMenuPeerTypeBotPM#c32bfa1a = AttachMenuPeerType; +attachMenuPeerTypePM#f146d31f = AttachMenuPeerType; +attachMenuPeerTypeChat#509113f = AttachMenuPeerType; +attachMenuPeerTypeBroadcast#7bfbdefc = AttachMenuPeerType; + +inputInvoiceMessage#c5b56859 peer:InputPeer msg_id:int = InputInvoice; +inputInvoiceSlug#c326caef slug:string = InputInvoice; +inputInvoicePremiumGiftCode#98986c0d purpose:InputStorePaymentPurpose option:PremiumGiftCodeOption = InputInvoice; +inputInvoiceStars#65f00ce3 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceChatInviteSubscription#34e793f1 hash:string = InputInvoice; +inputInvoiceStarGift#e8625e92 flags:# hide_name:flags.0?true include_upgrade:flags.2?true peer:InputPeer gift_id:long message:flags.1?TextWithEntities = InputInvoice; +inputInvoiceStarGiftUpgrade#4d818d5d flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftTransfer#4a5f5bd9 stargift:InputSavedStarGift to_id:InputPeer = InputInvoice; +inputInvoicePremiumGiftStars#dabab2ef flags:# user_id:InputUser months:int message:flags.0?TextWithEntities = InputInvoice; +inputInvoiceBusinessBotTransferStars#f4997e42 bot:InputUser stars:long = InputInvoice; +inputInvoiceStarGiftResale#c39f5324 flags:# ton:flags.0?true slug:string to_id:InputPeer = InputInvoice; +inputInvoiceStarGiftPrepaidUpgrade#9a0b48b8 peer:InputPeer hash:string = InputInvoice; +inputInvoicePremiumAuthCode#3e77f614 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceStarGiftDropOriginalDetails#923d8d1 stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftAuctionBid#1ecafa10 flags:# hide_name:flags.0?true update_bid:flags.2?true peer:flags.3?InputPeer gift_id:long bid_amount:long message:flags.1?TextWithEntities = InputInvoice; + +payments.exportedInvoice#aed0cbd9 url:string = payments.ExportedInvoice; + +messages.transcribedAudio#cfb9d957 flags:# pending:flags.0?true transcription_id:long text:string trial_remains_num:flags.1?int trial_remains_until_date:flags.1?int = messages.TranscribedAudio; + +help.premiumPromo#5334759c status_text:string status_entities:Vector video_sections:Vector videos:Vector period_options:Vector users:Vector = help.PremiumPromo; + +inputStorePaymentPremiumSubscription#a6751e66 flags:# restore:flags.0?true upgrade:flags.1?true = InputStorePaymentPurpose; +inputStorePaymentGiftPremium#616f7fe8 user_id:InputUser currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentPremiumGiftCode#fb790393 flags:# users:Vector boost_peer:flags.0?InputPeer currency:string amount:long message:flags.1?TextWithEntities = InputStorePaymentPurpose; +inputStorePaymentPremiumGiveaway#160544ca flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsTopup#f9a2a6cb flags:# stars:long currency:string amount:long spend_purpose_peer:flags.0?InputPeer = InputStorePaymentPurpose; +inputStorePaymentStarsGift#1d741ef7 user_id:InputUser stars:long currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsGiveaway#751f08fa flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true stars:long boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long users:int = InputStorePaymentPurpose; +inputStorePaymentAuthCode#9bb2636d flags:# restore:flags.0?true phone_number:string phone_code_hash:string currency:string amount:long = InputStorePaymentPurpose; + +paymentFormMethod#88f8f21b url:string title:string = PaymentFormMethod; + +emojiStatusEmpty#2de11aae = EmojiStatus; +emojiStatus#e7ff068a flags:# document_id:long until:flags.0?int = EmojiStatus; +emojiStatusCollectible#7184603b flags:# collectible_id:long document_id:long title:string slug:string pattern_document_id:long center_color:int edge_color:int pattern_color:int text_color:int until:flags.0?int = EmojiStatus; +inputEmojiStatusCollectible#7141dbf flags:# collectible_id:long until:flags.0?int = EmojiStatus; + +account.emojiStatusesNotModified#d08ce645 = account.EmojiStatuses; +account.emojiStatuses#90c467d1 hash:long statuses:Vector = account.EmojiStatuses; + +reactionEmpty#79f5d419 = Reaction; +reactionEmoji#1b2286b8 emoticon:string = Reaction; +reactionCustomEmoji#8935fc73 document_id:long = Reaction; +reactionPaid#523da4eb = Reaction; + +chatReactionsNone#eafc32bc = ChatReactions; +chatReactionsAll#52928bca flags:# allow_custom:flags.0?true = ChatReactions; +chatReactionsSome#661d4037 reactions:Vector = ChatReactions; + +messages.reactionsNotModified#b06fdbdf = messages.Reactions; +messages.reactions#eafdf716 hash:long reactions:Vector = messages.Reactions; + +emailVerifyPurposeLoginSetup#4345be73 phone_number:string phone_code_hash:string = EmailVerifyPurpose; +emailVerifyPurposeLoginChange#527d22eb = EmailVerifyPurpose; +emailVerifyPurposePassport#bbf51685 = EmailVerifyPurpose; + +emailVerificationCode#922e55a9 code:string = EmailVerification; +emailVerificationGoogle#db909ec2 token:string = EmailVerification; +emailVerificationApple#96d074fd token:string = EmailVerification; + +account.emailVerified#2b96cd1b email:string = account.EmailVerified; +account.emailVerifiedLogin#e1bb0d61 email:string sent_code:auth.SentCode = account.EmailVerified; + +premiumSubscriptionOption#5f2d1df2 flags:# current:flags.1?true can_purchase_upgrade:flags.2?true transaction:flags.3?string months:int currency:string amount:long bot_url:string store_product:flags.0?string = PremiumSubscriptionOption; + +sendAsPeer#b81c7034 flags:# premium_required:flags.0?true peer:Peer = SendAsPeer; + +messageExtendedMediaPreview#ad628cc8 flags:# w:flags.0?int h:flags.0?int thumb:flags.1?PhotoSize video_duration:flags.2?int = MessageExtendedMedia; +messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; + +stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; + +username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; + +forumTopicDeleted#23f109b id:int = ForumTopic; +forumTopic#cdff0eca flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true title_missing:flags.7?true id:int date:int peer:Peer title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; + +messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; + +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + +requestPeerTypeUser#5f3b8a00 flags:# bot:flags.0?Bool premium:flags.1?Bool = RequestPeerType; +requestPeerTypeChat#c9f06e1b flags:# creator:flags.0?true bot_participant:flags.5?true has_username:flags.3?Bool forum:flags.4?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeBroadcast#339bef6c flags:# creator:flags.0?true has_username:flags.3?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; + +emojiListNotModified#481eadfa = EmojiList; +emojiList#7a1e11d1 hash:long document_id:Vector = EmojiList; + +emojiGroup#7a9abda9 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupGreeting#80d26cc7 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupPremium#93bcf34 title:string icon_emoji_id:long = EmojiGroup; + +messages.emojiGroupsNotModified#6fb4ad87 = messages.EmojiGroups; +messages.emojiGroups#881fb94b hash:int groups:Vector = messages.EmojiGroups; + +textWithEntities#751f3146 text:string entities:Vector = TextWithEntities; + +messages.translateResult#33db32f8 result:Vector = messages.TranslatedText; + +autoSaveSettings#c84834ce flags:# photos:flags.0?true videos:flags.1?true video_max_size:flags.2?long = AutoSaveSettings; + +autoSaveException#81602d47 peer:Peer settings:AutoSaveSettings = AutoSaveException; + +account.autoSaveSettings#4c3e069d users_settings:AutoSaveSettings chats_settings:AutoSaveSettings broadcasts_settings:AutoSaveSettings exceptions:Vector chats:Vector users:Vector = account.AutoSaveSettings; + +help.appConfigNotModified#7cde641d = help.AppConfig; +help.appConfig#dd18782e hash:int config:JSONValue = help.AppConfig; + +inputBotAppID#a920bd7a id:long access_hash:long = InputBotApp; +inputBotAppShortName#908c0407 bot_id:InputUser short_name:string = InputBotApp; + +botAppNotModified#5da674b7 = BotApp; +botApp#95fcd1d6 flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document hash:long = BotApp; + +messages.botApp#eb50adf5 flags:# inactive:flags.0?true request_write_access:flags.1?true has_settings:flags.2?true app:BotApp = messages.BotApp; + +inlineBotWebView#b57295d5 text:string url:string = InlineBotWebView; + +readParticipantDate#4a4ff172 user_id:long date:int = ReadParticipantDate; + +inputChatlistDialogFilter#f3e0da33 filter_id:int = InputChatlist; + +exportedChatlistInvite#c5181ac flags:# title:string url:string peers:Vector = ExportedChatlistInvite; + +chatlists.exportedChatlistInvite#10e6e3a6 filter:DialogFilter invite:ExportedChatlistInvite = chatlists.ExportedChatlistInvite; + +chatlists.exportedInvites#10ab6dc7 invites:Vector chats:Vector users:Vector = chatlists.ExportedInvites; + +chatlists.chatlistInviteAlready#fa87f659 filter_id:int missing_peers:Vector already_peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; +chatlists.chatlistInvite#f10ece2f flags:# title_noanimate:flags.1?true title:TextWithEntities emoticon:flags.0?string peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistUpdates#93bd878d missing_peers:Vector chats:Vector users:Vector = chatlists.ChatlistUpdates; + +bots.botInfo#e8a775b0 name:string about:string description:string = bots.BotInfo; + +messagePeerVote#b6cc2d5c peer:Peer option:bytes date:int = MessagePeerVote; +messagePeerVoteInputOption#74cda504 peer:Peer date:int = MessagePeerVote; +messagePeerVoteMultiple#4628f6e6 peer:Peer options:Vector date:int = MessagePeerVote; + +storyViews#8d595cd6 flags:# has_viewers:flags.1?true views_count:int forwards_count:flags.2?int reactions:flags.3?Vector reactions_count:flags.4?int recent_viewers:flags.0?Vector = StoryViews; + +storyItemDeleted#51e6ee4f id:int = StoryItem; +storyItemSkipped#ffadc913 flags:# close_friends:flags.8?true live:flags.9?true id:int date:int expire_date:int = StoryItem; +storyItem#edf164f1 flags:# pinned:flags.5?true public:flags.7?true close_friends:flags.8?true min:flags.9?true noforwards:flags.10?true edited:flags.11?true contacts:flags.12?true selected_contacts:flags.13?true out:flags.16?true id:int date:int from_id:flags.18?Peer fwd_from:flags.17?StoryFwdHeader expire_date:int caption:flags.0?string entities:flags.1?Vector media:MessageMedia media_areas:flags.14?Vector privacy:flags.2?Vector views:flags.3?StoryViews sent_reaction:flags.15?Reaction albums:flags.19?Vector = StoryItem; + +stories.allStoriesNotModified#1158fe3e flags:# state:string stealth_mode:StoriesStealthMode = stories.AllStories; +stories.allStories#6efc5e81 flags:# has_more:flags.0?true count:int state:string peer_stories:Vector chats:Vector users:Vector stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.stories#63c3dd0a flags:# count:int stories:Vector pinned_to_top:flags.0?Vector chats:Vector users:Vector = stories.Stories; + +storyView#b0bdeac5 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true user_id:long date:int reaction:flags.2?Reaction = StoryView; +storyViewPublicForward#9083670b flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true message:Message = StoryView; +storyViewPublicRepost#bd74cf49 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer story:StoryItem = StoryView; + +stories.storyViewsList#59d78fc5 flags:# count:int views_count:int forwards_count:int reactions_count:int views:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryViewsList; + +stories.storyViews#de9eed1d views:Vector users:Vector = stories.StoryViews; + +inputReplyToMessage#869fbe10 flags:# reply_to_msg_id:int top_msg_id:flags.0?int reply_to_peer_id:flags.1?InputPeer quote_text:flags.2?string quote_entities:flags.3?Vector quote_offset:flags.4?int monoforum_peer_id:flags.5?InputPeer todo_item_id:flags.6?int = InputReplyTo; +inputReplyToStory#5881323a peer:InputPeer story_id:int = InputReplyTo; +inputReplyToMonoForum#69d66c45 monoforum_peer_id:InputPeer = InputReplyTo; + +exportedStoryLink#3fc9053b link:string = ExportedStoryLink; + +storiesStealthMode#712e27fd flags:# active_until_date:flags.0?int cooldown_until_date:flags.1?int = StoriesStealthMode; + +mediaAreaCoordinates#cfc9e002 flags:# x:double y:double w:double h:double rotation:double radius:flags.0?double = MediaAreaCoordinates; + +mediaAreaVenue#be82db9c coordinates:MediaAreaCoordinates geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MediaArea; +inputMediaAreaVenue#b282217f coordinates:MediaAreaCoordinates query_id:long result_id:string = MediaArea; +mediaAreaGeoPoint#cad5452d flags:# coordinates:MediaAreaCoordinates geo:GeoPoint address:flags.0?GeoPointAddress = MediaArea; +mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; +mediaAreaChannelPost#770416af coordinates:MediaAreaCoordinates channel_id:long msg_id:int = MediaArea; +inputMediaAreaChannelPost#2271f2bf coordinates:MediaAreaCoordinates channel:InputChannel msg_id:int = MediaArea; +mediaAreaUrl#37381085 coordinates:MediaAreaCoordinates url:string = MediaArea; +mediaAreaWeather#49a6549c coordinates:MediaAreaCoordinates emoji:string temperature_c:double color:int = MediaArea; +mediaAreaStarGift#5787686d coordinates:MediaAreaCoordinates slug:string = MediaArea; + +peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; + +stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; + +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; + +premiumGiftCodeOption#257e962b flags:# users:int months:int store_product:flags.0?string store_quantity:flags.1?int currency:string amount:long = PremiumGiftCodeOption; + +payments.checkedGiftCode#eb983f8f flags:# via_giveaway:flags.2?true from_id:flags.4?Peer giveaway_msg_id:flags.3?int to_id:flags.0?long date:int days:int used_date:flags.1?int chats:Vector users:Vector = payments.CheckedGiftCode; + +payments.giveawayInfo#4367daa0 flags:# participating:flags.0?true preparing_results:flags.3?true start_date:int joined_too_early_date:flags.1?int admin_disallowed_chat_id:flags.2?long disallowed_country:flags.4?string = payments.GiveawayInfo; +payments.giveawayInfoResults#e175e66f flags:# winner:flags.0?true refunded:flags.1?true start_date:int gift_code_slug:flags.3?string stars_prize:flags.4?long finish_date:int winners_count:int activated_count:flags.2?int = payments.GiveawayInfo; + +prepaidGiveaway#b2539d54 id:long months:int quantity:int date:int = PrepaidGiveaway; +prepaidStarsGiveaway#9a9d77e0 id:long stars:long quantity:int boosts:int date:int = PrepaidGiveaway; + +boost#4b3e14d6 flags:# gift:flags.1?true giveaway:flags.2?true unclaimed:flags.3?true id:string user_id:flags.0?long giveaway_msg_id:flags.2?int date:int expires:int used_gift_slug:flags.4?string multiplier:flags.5?int stars:flags.6?long = Boost; + +premium.boostsList#86f8613c flags:# count:int boosts:Vector next_offset:flags.0?string users:Vector = premium.BoostsList; + +myBoost#c448415c flags:# slot:int peer:flags.0?Peer date:int expires:int cooldown_until_date:flags.1?int = MyBoost; + +premium.myBoosts#9ae228e2 my_boosts:Vector chats:Vector users:Vector = premium.MyBoosts; + +premium.boostsStatus#4959427a flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int gift_boosts:flags.4?int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string prepaid_giveaways:flags.3?Vector my_boost_slots:flags.2?Vector = premium.BoostsStatus; + +storyFwdHeader#b826e150 flags:# modified:flags.3?true from:flags.0?Peer from_name:flags.1?string story_id:flags.2?int = StoryFwdHeader; + +postInteractionCountersMessage#e7058e7f msg_id:int views:int forwards:int reactions:int = PostInteractionCounters; +postInteractionCountersStory#8a480e27 story_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +stats.storyStats#50cd067c views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.StoryStats; + +publicForwardMessage#1f2bf4a message:Message = PublicForward; +publicForwardStory#edf3add0 peer:Peer story:StoryItem = PublicForward; + +stats.publicForwards#93037e20 flags:# count:int forwards:Vector next_offset:flags.0?string chats:Vector users:Vector = stats.PublicForwards; + +peerColor#b54b5acf flags:# color:flags.0?int background_emoji_id:flags.1?long = PeerColor; +peerColorCollectible#b9c0639a flags:# collectible_id:long gift_emoji_id:long background_emoji_id:long accent_color:int colors:Vector dark_accent_color:flags.0?int dark_colors:flags.1?Vector = PeerColor; +inputPeerColorCollectible#b8ea86a9 collectible_id:long = PeerColor; + +help.peerColorSet#26219a58 colors:Vector = help.PeerColorSet; +help.peerColorProfileSet#767d61eb palette_colors:Vector bg_colors:Vector story_colors:Vector = help.PeerColorSet; + +help.peerColorOption#adec6ebe flags:# hidden:flags.0?true color_id:int colors:flags.1?help.PeerColorSet dark_colors:flags.2?help.PeerColorSet channel_min_level:flags.3?int group_min_level:flags.4?int = help.PeerColorOption; + +help.peerColorsNotModified#2ba1f5ce = help.PeerColors; +help.peerColors#f8ed08 hash:int colors:Vector = help.PeerColors; + +storyReaction#6090d6d5 peer_id:Peer date:int reaction:Reaction = StoryReaction; +storyReactionPublicForward#bbab2643 message:Message = StoryReaction; +storyReactionPublicRepost#cfcd0f13 peer_id:Peer story:StoryItem = StoryReaction; + +stories.storyReactionsList#aa5f789c flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryReactionsList; + +savedDialog#bd87cb6c flags:# pinned:flags.2?true peer:Peer top_message:int = SavedDialog; +monoForumDialog#64407ea7 flags:# unread_mark:flags.3?true nopaid_messages_exception:flags.4?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_reactions_count:int draft:flags.1?DraftMessage = SavedDialog; + +messages.savedDialogs#f83ae221 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsSlice#44ba9dd9 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsNotModified#c01f6fe8 count:int = messages.SavedDialogs; + +savedReactionTag#cb6ff828 flags:# reaction:Reaction title:flags.0?string count:int = SavedReactionTag; + +messages.savedReactionTagsNotModified#889b59ef = messages.SavedReactionTags; +messages.savedReactionTags#3259950a tags:Vector hash:long = messages.SavedReactionTags; + +outboxReadDate#3bb842ac date:int = OutboxReadDate; + +smsjobs.eligibleToJoin#dc8b44cf terms_url:string monthly_sent_sms:int = smsjobs.EligibilityToJoin; + +smsjobs.status#2aee9191 flags:# allow_international:flags.0?true recent_sent:int recent_since:int recent_remains:int total_sent:int total_since:int last_gift_slug:flags.1?string terms_url:string = smsjobs.Status; + +smsJob#e6a1eeb8 job_id:string phone_number:string text:string = SmsJob; + +businessWeeklyOpen#120b1ab9 start_minute:int end_minute:int = BusinessWeeklyOpen; + +businessWorkHours#8c92b098 flags:# open_now:flags.0?true timezone_id:string weekly_open:Vector = BusinessWorkHours; + +businessLocation#ac5c1af7 flags:# geo_point:flags.0?GeoPoint address:string = BusinessLocation; + +inputBusinessRecipients#6f8b32aa flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = InputBusinessRecipients; + +businessRecipients#21108ff7 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = BusinessRecipients; + +businessAwayMessageScheduleAlways#c9b9e2b9 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleOutsideWorkHours#c3f2f501 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleCustom#cc4d9ecc start_date:int end_date:int = BusinessAwayMessageSchedule; + +inputBusinessGreetingMessage#194cb3b shortcut_id:int recipients:InputBusinessRecipients no_activity_days:int = InputBusinessGreetingMessage; + +businessGreetingMessage#e519abab shortcut_id:int recipients:BusinessRecipients no_activity_days:int = BusinessGreetingMessage; + +inputBusinessAwayMessage#832175e0 flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:InputBusinessRecipients = InputBusinessAwayMessage; + +businessAwayMessage#ef156a5c flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:BusinessRecipients = BusinessAwayMessage; + +timezone#ff9289f5 id:string name:string utc_offset:int = Timezone; + +help.timezonesListNotModified#970708cc = help.TimezonesList; +help.timezonesList#7b74ed71 timezones:Vector hash:int = help.TimezonesList; + +quickReply#697102b shortcut_id:int shortcut:string top_message:int count:int = QuickReply; + +inputQuickReplyShortcut#24596d41 shortcut:string = InputQuickReplyShortcut; +inputQuickReplyShortcutId#1190cf1 shortcut_id:int = InputQuickReplyShortcut; + +messages.quickReplies#c68d6695 quick_replies:Vector messages:Vector chats:Vector users:Vector = messages.QuickReplies; +messages.quickRepliesNotModified#5f91eb5b = messages.QuickReplies; + +connectedBot#cd64636c flags:# bot_id:long recipients:BusinessBotRecipients rights:BusinessBotRights = ConnectedBot; + +account.connectedBots#17d7f87b connected_bots:Vector users:Vector = account.ConnectedBots; + +messages.dialogFilters#2ad93719 flags:# tags_enabled:flags.0?true filters:Vector = messages.DialogFilters; + +birthday#6c8e1e06 flags:# day:int month:int year:flags.0?int = Birthday; + +botBusinessConnection#8f34b2f5 flags:# disabled:flags.1?true connection_id:string user_id:long dc_id:int date:int rights:flags.2?BusinessBotRights = BotBusinessConnection; + +inputBusinessIntro#9c469cd flags:# title:string description:string sticker:flags.0?InputDocument = InputBusinessIntro; + +businessIntro#5a0a066d flags:# title:string description:string sticker:flags.0?Document = BusinessIntro; + +messages.myStickers#faff629d count:int sets:Vector = messages.MyStickers; + +inputCollectibleUsername#e39460a9 username:string = InputCollectible; +inputCollectiblePhone#a2e214a4 phone:string = InputCollectible; + +fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo; + +inputBusinessBotRecipients#c4e5921e flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = InputBusinessBotRecipients; + +businessBotRecipients#b88cf373 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = BusinessBotRecipients; + +contactBirthday#1d998733 contact_id:long birthday:Birthday = ContactBirthday; + +contacts.contactBirthdays#114ff30d contacts:Vector users:Vector = contacts.ContactBirthdays; + +missingInvitee#628c9224 flags:# premium_would_allow_invite:flags.0?true premium_required_for_pm:flags.1?true user_id:long = MissingInvitee; + +messages.invitedUsers#7f5defa6 updates:Updates missing_invitees:Vector = messages.InvitedUsers; + +inputBusinessChatLink#11679fa7 flags:# message:string entities:flags.0?Vector title:flags.1?string = InputBusinessChatLink; + +businessChatLink#b4ae666f flags:# link:string message:string entities:flags.0?Vector title:flags.1?string views:int = BusinessChatLink; + +account.businessChatLinks#ec43a2d1 links:Vector chats:Vector users:Vector = account.BusinessChatLinks; + +account.resolvedBusinessChatLinks#9a23af21 flags:# peer:Peer message:string entities:flags.0?Vector chats:Vector users:Vector = account.ResolvedBusinessChatLinks; + +requestedPeerUser#d62ff46a flags:# user_id:long first_name:flags.0?string last_name:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChat#7307544f flags:# chat_id:long title:flags.0?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChannel#8ba403e4 flags:# channel_id:long title:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +sponsoredMessageReportOption#430d3150 text:string option:bytes = SponsoredMessageReportOption; + +channels.sponsoredMessageReportResultChooseOption#846f9e42 title:string options:Vector = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultAdsHidden#3e3bcf2f = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultReported#ad798849 = channels.SponsoredMessageReportResult; + +reactionNotificationsFromContacts#bac3a61a = ReactionNotificationsFrom; +reactionNotificationsFromAll#4b9e22a0 = ReactionNotificationsFrom; + +reactionsNotifySettings#56e34970 flags:# messages_notify_from:flags.0?ReactionNotificationsFrom stories_notify_from:flags.1?ReactionNotificationsFrom sound:NotificationSound show_previews:Bool = ReactionsNotifySettings; + +availableEffect#93c3e27e flags:# premium_required:flags.2?true id:long emoticon:string static_icon_id:flags.0?long effect_sticker_id:long effect_animation_id:flags.1?long = AvailableEffect; + +messages.availableEffectsNotModified#d1ed9a5b = messages.AvailableEffects; +messages.availableEffects#bddb616e hash:int effects:Vector documents:Vector = messages.AvailableEffects; + +factCheck#b89bfccf flags:# need_check:flags.0?true country:flags.1?string text:flags.1?TextWithEntities hash:long = FactCheck; + +starsTransactionPeerUnsupported#95f2bfe4 = StarsTransactionPeer; +starsTransactionPeerAppStore#b457b375 = StarsTransactionPeer; +starsTransactionPeerPlayMarket#7b560a0b = StarsTransactionPeer; +starsTransactionPeerPremiumBot#250dbaf8 = StarsTransactionPeer; +starsTransactionPeerFragment#e92fd902 = StarsTransactionPeer; +starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; +starsTransactionPeerAds#60682812 = StarsTransactionPeer; +starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; + +starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; + +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true stargift_prepaid_upgrade:flags.25?true stargift_drop_original_details:flags.26?true phonegroup_message:flags.27?true stargift_auction_bid:flags.28?true offer:flags.29?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; + +payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; + +foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; + +stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; + +geoPointAddress#de4c5d93 flags:# country_iso2:string state:flags.0?string city:flags.1?string street:flags.2?string = GeoPointAddress; + +starsRevenueStatus#febe5491 flags:# withdrawal_enabled:flags.0?true current_balance:StarsAmount available_balance:StarsAmount overall_revenue:StarsAmount next_withdrawal_at:flags.1?int = StarsRevenueStatus; + +payments.starsRevenueStats#6c207376 flags:# top_hours_graph:flags.0?StatsGraph revenue_graph:StatsGraph status:StarsRevenueStatus usd_rate:double = payments.StarsRevenueStats; + +payments.starsRevenueWithdrawalUrl#1dab80b7 url:string = payments.StarsRevenueWithdrawalUrl; + +payments.starsRevenueAdsAccountUrl#394e7f21 url:string = payments.StarsRevenueAdsAccountUrl; + +inputStarsTransaction#206ae6d1 flags:# refund:flags.0?true id:string = InputStarsTransaction; + +starsGiftOption#5e0589f1 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsGiftOption; + +bots.popularAppBots#1991b13b flags:# next_offset:flags.0?string users:Vector = bots.PopularAppBots; + +botPreviewMedia#23e91ba3 date:int media:MessageMedia = BotPreviewMedia; + +bots.previewInfo#ca71d64 media:Vector lang_codes:Vector = bots.PreviewInfo; + +starsSubscriptionPricing#5416d58 period:int amount:long = StarsSubscriptionPricing; + +starsSubscription#2e6eab1a flags:# canceled:flags.0?true can_refulfill:flags.1?true missing_balance:flags.2?true bot_canceled:flags.7?true id:string peer:Peer until_date:int pricing:StarsSubscriptionPricing chat_invite_hash:flags.3?string title:flags.4?string photo:flags.5?WebDocument invoice_slug:flags.6?string = StarsSubscription; + +messageReactor#4ba3a95a flags:# top:flags.0?true my:flags.1?true anonymous:flags.2?true peer_id:flags.3?Peer count:int = MessageReactor; + +starsGiveawayOption#94ce852a flags:# extended:flags.0?true default:flags.1?true stars:long yearly_boosts:int store_product:flags.2?string currency:string amount:long winners:Vector = StarsGiveawayOption; + +starsGiveawayWinnersOption#54236209 flags:# default:flags.0?true users:int per_user_stars:long = StarsGiveawayWinnersOption; + +starGift#313a9547 flags:# limited:flags.0?true sold_out:flags.1?true birthday:flags.2?true require_premium:flags.7?true limited_per_user:flags.8?true peer_color_available:flags.10?true auction:flags.11?true id:long sticker:Document stars:long availability_remains:flags.0?int availability_total:flags.0?int availability_resale:flags.4?long convert_stars:long first_sale_date:flags.1?int last_sale_date:flags.1?int upgrade_stars:flags.3?long resell_min_stars:flags.4?long title:flags.5?string released_by:flags.6?Peer per_user_total:flags.8?int per_user_remains:flags.8?int locked_until_date:flags.9?int auction_slug:flags.11?string gifts_per_round:flags.11?int auction_start_date:flags.11?int upgrade_variants:flags.12?int background:flags.13?StarGiftBackground = StarGift; +starGiftUnique#569d64c9 flags:# require_premium:flags.6?true resale_ton_only:flags.7?true theme_available:flags.9?true id:long gift_id:long title:string slug:string num:int owner_id:flags.0?Peer owner_name:flags.1?string owner_address:flags.2?string attributes:Vector availability_issued:int availability_total:int gift_address:flags.3?string resell_amount:flags.4?Vector released_by:flags.5?Peer value_amount:flags.8?long value_currency:flags.8?string value_usd_amount:flags.8?long theme_peer:flags.10?Peer peer_color:flags.11?PeerColor host_id:flags.12?Peer offer_min_stars:flags.13?int = StarGift; + +payments.starGiftsNotModified#a388a368 = payments.StarGifts; +payments.starGifts#2ed82995 hash:int gifts:Vector chats:Vector users:Vector = payments.StarGifts; + +messageReportOption#7903e3d9 text:string option:bytes = MessageReportOption; + +reportResultChooseOption#f0e4e0b6 title:string options:Vector = ReportResult; +reportResultAddComment#6f09ac31 flags:# optional:flags.0?true option:bytes = ReportResult; +reportResultReported#8db33c4b = ReportResult; + +messages.botPreparedInlineMessage#8ecf0511 id:string expire_date:int = messages.BotPreparedInlineMessage; + +messages.preparedInlineMessage#ff57708d query_id:long result:BotInlineResult peer_types:Vector cache_time:int users:Vector = messages.PreparedInlineMessage; + +botAppSettings#c99b1950 flags:# placeholder_path:flags.0?bytes background_color:flags.1?int background_dark_color:flags.2?int header_color:flags.3?int header_dark_color:flags.4?int = BotAppSettings; + +starRefProgram#dd0c66f2 flags:# bot_id:long commission_permille:int duration_months:flags.0?int end_date:flags.1?int daily_revenue_per_user:flags.2?StarsAmount = StarRefProgram; + +connectedBotStarRef#19a13f71 flags:# revoked:flags.1?true url:string date:int bot_id:long commission_permille:int duration_months:flags.0?int participants:long revenue:long = ConnectedBotStarRef; + +payments.connectedStarRefBots#98d5ea1d count:int connected_bots:Vector users:Vector = payments.ConnectedStarRefBots; + +payments.suggestedStarRefBots#b4d5d859 flags:# count:int suggested_bots:Vector users:Vector next_offset:flags.0?string = payments.SuggestedStarRefBots; + +starsAmount#bbb6b4a3 amount:long nanos:int = StarsAmount; +starsTonAmount#74aee3e0 amount:long = StarsAmount; + +messages.foundStickersNotModified#6010c534 flags:# next_offset:flags.0?int = messages.FoundStickers; +messages.foundStickers#82c9e290 flags:# next_offset:flags.0?int hash:long stickers:Vector = messages.FoundStickers; + +botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string = BotVerifierSettings; + +botVerification#f93cd45c bot_id:long icon:long description:string = BotVerification; + +starGiftAttributeModel#39d99013 name:string document:Document rarity_permille:int = StarGiftAttribute; +starGiftAttributePattern#13acff19 name:string document:Document rarity_permille:int = StarGiftAttribute; +starGiftAttributeBackdrop#d93d859c name:string backdrop_id:int center_color:int edge_color:int pattern_color:int text_color:int rarity_permille:int = StarGiftAttribute; +starGiftAttributeOriginalDetails#e0bff26c flags:# sender_id:flags.0?Peer recipient_id:Peer date:int message:flags.1?TextWithEntities = StarGiftAttribute; + +payments.starGiftUpgradePreview#3de1dfed sample_attributes:Vector prices:Vector next_prices:Vector = payments.StarGiftUpgradePreview; + +users.users#62d706b8 users:Vector = users.Users; +users.usersSlice#315a4974 count:int users:Vector = users.Users; + +payments.uniqueStarGift#416c56e8 gift:StarGift chats:Vector users:Vector = payments.UniqueStarGift; + +messages.webPagePreview#8c9a88ac media:MessageMedia chats:Vector users:Vector = messages.WebPagePreview; + +savedStarGift#ead6805e flags:# name_hidden:flags.0?true unsaved:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true pinned_to_top:flags.12?true upgrade_separate:flags.17?true from_id:flags.1?Peer date:int gift:StarGift message:flags.2?TextWithEntities msg_id:flags.3?int saved_id:flags.11?long convert_stars:flags.4?long upgrade_stars:flags.6?long can_export_at:flags.7?int transfer_stars:flags.8?long can_transfer_at:flags.13?int can_resell_at:flags.14?int collection_id:flags.15?Vector prepaid_upgrade_hash:flags.16?string drop_original_details_stars:flags.18?long gift_num:flags.19?int = SavedStarGift; + +payments.savedStarGifts#95f389b1 flags:# count:int chat_notifications_enabled:flags.1?Bool gifts:Vector next_offset:flags.0?string chats:Vector users:Vector = payments.SavedStarGifts; + +inputSavedStarGiftUser#69279795 msg_id:int = InputSavedStarGift; +inputSavedStarGiftChat#f101aa7f peer:InputPeer saved_id:long = InputSavedStarGift; +inputSavedStarGiftSlug#2085c238 slug:string = InputSavedStarGift; + +payments.starGiftWithdrawalUrl#84aa3a9c url:string = payments.StarGiftWithdrawalUrl; + +paidReactionPrivacyDefault#206ad49e = PaidReactionPrivacy; +paidReactionPrivacyAnonymous#1f0c1ad9 = PaidReactionPrivacy; +paidReactionPrivacyPeer#dc6cfcf0 peer:InputPeer = PaidReactionPrivacy; + +account.paidMessagesRevenue#1e109708 stars_amount:long = account.PaidMessagesRevenue; + +requirementToContactEmpty#50a9839 = RequirementToContact; +requirementToContactPremium#e581e4e9 = RequirementToContact; +requirementToContactPaidMessages#b4f67e93 stars_amount:long = RequirementToContact; + +businessBotRights#a0624cf7 flags:# reply:flags.0?true read_messages:flags.1?true delete_sent_messages:flags.2?true delete_received_messages:flags.3?true edit_name:flags.4?true edit_bio:flags.5?true edit_profile_photo:flags.6?true edit_username:flags.7?true view_gifts:flags.8?true sell_gifts:flags.9?true change_gift_settings:flags.10?true transfer_and_upgrade_gifts:flags.11?true transfer_stars:flags.12?true manage_stories:flags.13?true = BusinessBotRights; + +disallowedGiftsSettings#71f276c4 flags:# disallow_unlimited_stargifts:flags.0?true disallow_limited_stargifts:flags.1?true disallow_unique_stargifts:flags.2?true disallow_premium_gifts:flags.3?true disallow_stargifts_from_channels:flags.4?true = DisallowedGiftsSettings; + +sponsoredPeer#c69708d3 flags:# random_id:bytes peer:Peer sponsor_info:flags.0?string additional_info:flags.1?string = SponsoredPeer; + +contacts.sponsoredPeersEmpty#ea32b4b1 = contacts.SponsoredPeers; +contacts.sponsoredPeers#eb032884 peers:Vector chats:Vector users:Vector = contacts.SponsoredPeers; + +starGiftAttributeIdModel#48aaae3c document_id:long = StarGiftAttributeId; +starGiftAttributeIdPattern#4a162433 document_id:long = StarGiftAttributeId; +starGiftAttributeIdBackdrop#1f01c757 backdrop_id:int = StarGiftAttributeId; + +starGiftAttributeCounter#2eb1b658 attribute:StarGiftAttributeId count:int = StarGiftAttributeCounter; + +payments.resaleStarGifts#947a12df flags:# count:int gifts:Vector next_offset:flags.0?string attributes:flags.1?Vector attributes_hash:flags.1?long chats:Vector counters:flags.2?Vector users:Vector = payments.ResaleStarGifts; + +stories.canSendStoryCount#c387c04e count_remains:int = stories.CanSendStoryCount; + +pendingSuggestion#e7e82e12 suggestion:string title:TextWithEntities description:TextWithEntities url:string = PendingSuggestion; + +todoItem#cba9a52f id:int title:TextWithEntities = TodoItem; + +todoList#49b92a26 flags:# others_can_append:flags.0?true others_can_complete:flags.1?true title:TextWithEntities list:Vector = TodoList; + +todoCompletion#221bb5e4 id:int completed_by:Peer date:int = TodoCompletion; + +suggestedPost#e8e37e5 flags:# accepted:flags.1?true rejected:flags.2?true price:flags.3?StarsAmount schedule_date:flags.0?int = SuggestedPost; + +starsRating#1b0e4f07 flags:# level:int current_level_stars:long stars:long next_level_stars:flags.0?long = StarsRating; + +starGiftCollection#9d6b13b0 flags:# collection_id:int title:string icon:flags.0?Document gifts_count:int hash:long = StarGiftCollection; + +payments.starGiftCollectionsNotModified#a0ba4f17 = payments.StarGiftCollections; +payments.starGiftCollections#8a2932f3 collections:Vector = payments.StarGiftCollections; + +storyAlbum#9325705a flags:# album_id:int title:string icon_photo:flags.0?Photo icon_video:flags.1?Document = StoryAlbum; + +stories.albumsNotModified#564edaeb = stories.Albums; +stories.albums#c3987a3a hash:long albums:Vector = stories.Albums; + +searchPostsFlood#3e0b5b6a flags:# query_is_free:flags.0?true total_daily:int remains:int wait_till:flags.1?int stars_amount:long = SearchPostsFlood; + +payments.uniqueStarGiftValueInfo#512fe446 flags:# last_sale_on_fragment:flags.1?true value_is_average:flags.6?true currency:string value:long initial_sale_date:int initial_sale_stars:long initial_sale_price:long last_sale_date:flags.0?int last_sale_price:flags.0?long floor_price:flags.2?long average_price:flags.3?long listed_count:flags.4?int fragment_listed_count:flags.5?int fragment_listed_url:flags.5?string = payments.UniqueStarGiftValueInfo; + +profileTabPosts#b98cd696 = ProfileTab; +profileTabGifts#4d4bd46a = ProfileTab; +profileTabMedia#72c64955 = ProfileTab; +profileTabFiles#ab339c00 = ProfileTab; +profileTabMusic#9f27d26e = ProfileTab; +profileTabVoice#e477092e = ProfileTab; +profileTabLinks#d3656499 = ProfileTab; +profileTabGifs#a2c0f695 = ProfileTab; + +users.savedMusicNotModified#e3878aa4 count:int = users.SavedMusic; +users.savedMusic#34a2f297 count:int documents:Vector = users.SavedMusic; + +account.savedMusicIdsNotModified#4fc81d6e = account.SavedMusicIds; +account.savedMusicIds#998d6636 ids:Vector = account.SavedMusicIds; + +payments.checkCanSendGiftResultOk#374fa7ad = payments.CheckCanSendGiftResult; +payments.checkCanSendGiftResultFail#d5e58274 reason:TextWithEntities = payments.CheckCanSendGiftResult; + +inputChatThemeEmpty#83268483 = InputChatTheme; +inputChatTheme#c93de95c emoticon:string = InputChatTheme; +inputChatThemeUniqueGift#87e5dfe4 slug:string = InputChatTheme; + +starGiftUpgradePrice#99ea331d date:int upgrade_stars:long = StarGiftUpgradePrice; + +groupCallMessage#1a8afc7e flags:# from_admin:flags.1?true id:int from_id:Peer date:int message:TextWithEntities paid_message_stars:flags.0?long = GroupCallMessage; + +groupCallDonor#ee430c85 flags:# top:flags.0?true my:flags.1?true peer_id:flags.3?Peer stars:long = GroupCallDonor; + +phone.groupCallStars#9d1dbd26 total_stars:long top_donors:Vector chats:Vector users:Vector = phone.GroupCallStars; + +recentStory#711d692d flags:# live:flags.0?true max_id:flags.1?int = RecentStory; + +auctionBidLevel#310240cc pos:int amount:long date:int = AuctionBidLevel; + +starGiftAuctionStateNotModified#fe333952 = StarGiftAuctionState; +starGiftAuctionState#771a4e66 version:int start_date:int end_date:int min_bid_amount:long bid_levels:Vector top_bidders:Vector next_round_at:int last_gift_num:int gifts_left:int current_round:int total_rounds:int rounds:Vector = StarGiftAuctionState; +starGiftAuctionStateFinished#972dabbf flags:# start_date:int end_date:int average_price:long listed_count:flags.0?int fragment_listed_count:flags.1?int fragment_listed_url:flags.1?string = StarGiftAuctionState; + +starGiftAuctionUserState#2eeed1c4 flags:# returned:flags.1?true bid_amount:flags.0?long bid_date:flags.0?int min_bid_amount:flags.0?long bid_peer:flags.0?Peer acquired_count:int = StarGiftAuctionUserState; + +payments.starGiftAuctionState#6b39f4ec gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState timeout:int users:Vector chats:Vector = payments.StarGiftAuctionState; + +starGiftAuctionAcquiredGift#42b00348 flags:# name_hidden:flags.0?true peer:Peer date:int bid_amount:long round:int pos:int message:flags.1?TextWithEntities gift_num:flags.2?int = StarGiftAuctionAcquiredGift; + +payments.starGiftAuctionAcquiredGifts#7d5bd1f0 gifts:Vector users:Vector chats:Vector = payments.StarGiftAuctionAcquiredGifts; + +starGiftActiveAuctionState#d31bc45d gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState = StarGiftActiveAuctionState; + +payments.starGiftActiveAuctionsNotModified#db33dad0 = payments.StarGiftActiveAuctions; +payments.starGiftActiveAuctions#aef6abbc auctions:Vector users:Vector chats:Vector = payments.StarGiftActiveAuctions; + +inputStarGiftAuction#2e16c98 gift_id:long = InputStarGiftAuction; +inputStarGiftAuctionSlug#7ab58308 slug:string = InputStarGiftAuction; + +passkey#98613ebf flags:# id:string name:string date:int software_emoji_id:flags.0?long last_usage_date:flags.1?int = Passkey; + +account.passkeys#f8e0aa1c passkeys:Vector = account.Passkeys; + +account.passkeyRegistrationOptions#e16b5ce1 options:DataJSON = account.PasskeyRegistrationOptions; + +auth.passkeyLoginOptions#e2037789 options:DataJSON = auth.PasskeyLoginOptions; + +inputPasskeyResponseRegister#3e63935c client_data:DataJSON attestation_data:bytes = InputPasskeyResponse; +inputPasskeyResponseLogin#c31fc14a client_data:DataJSON authenticator_data:bytes signature:bytes user_handle:string = InputPasskeyResponse; + +inputPasskeyCredentialPublicKey#3c27b78f id:string raw_id:string response:InputPasskeyResponse = InputPasskeyCredential; + +starGiftBackground#aff56398 center_color:int edge_color:int text_color:int = StarGiftBackground; + +starGiftAuctionRound#3aae0528 num:int duration:int = StarGiftAuctionRound; +starGiftAuctionRoundExtendable#aa021e5 num:int duration:int extend_top:int extend_window:int = StarGiftAuctionRound; + +payments.starGiftUpgradeAttributes#46c6e36f attributes:Vector = payments.StarGiftUpgradeAttributes; + +---functions--- + +invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; +invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; +invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; +invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; +invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; +invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; +invokeWithBusinessConnection#dd289f8e {X:Type} connection_id:string query:!X = X; +invokeWithGooglePlayIntegrity#1df92984 {X:Type} nonce:string token:string query:!X = X; +invokeWithApnsSecret#0dae54f8 {X:Type} nonce:string secret:string query:!X = X; +invokeWithReCaptcha#adbb0f94 {X:Type} token:string query:!X = X; + +auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; +auth.signUp#aac7b717 flags:# no_joined_notifications:flags.0?true phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +auth.signIn#8d52a951 flags:# phone_number:string phone_code_hash:string phone_code:flags.0?string email_verification:flags.1?EmailVerification = auth.Authorization; +auth.logOut#3e72ba19 = auth.LoggedOut; +auth.resetAuthorizations#9fab0d1a = Bool; +auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; +auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; +auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; +auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; +auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; +auth.resendCode#cae47523 flags:# phone_number:string phone_code_hash:string reason:flags.0?string = auth.SentCode; +auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; +auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; +auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; +auth.acceptLoginToken#e894ad4d token:bytes = Authorization; +auth.checkRecoveryPassword#d36bf79 code:string = Bool; +auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; +auth.requestFirebaseSms#8e39261e flags:# phone_number:string phone_code_hash:string safety_net_token:flags.0?string play_integrity_token:flags.2?string ios_push_secret:flags.1?string = Bool; +auth.resetLoginEmail#7e960193 phone_number:string phone_code_hash:string = auth.SentCode; +auth.reportMissingCode#cb9deff6 phone_number:string phone_code_hash:string mnc:string = Bool; +auth.checkPaidAuth#56e59f9c phone_number:string phone_code_hash:string form_id:long = auth.SentCode; +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; + +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; +account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; +account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; +account.resetNotifySettings#db7e1747 = Bool; +account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; +account.updateStatus#6628562c offline:Bool = Bool; +account.getWallPapers#7967d36 hash:long = account.WallPapers; +account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; +account.checkUsername#2714d86c username:string = Bool; +account.updateUsername#3e0bdd7c username:string = User; +account.getPrivacy#dadbc950 key:InputPrivacyKey = account.PrivacyRules; +account.setPrivacy#c9f81ce8 key:InputPrivacyKey rules:Vector = account.PrivacyRules; +account.deleteAccount#a2c0cf74 flags:# reason:string password:flags.0?InputCheckPasswordSRP = Bool; +account.getAccountTTL#8fc711d = AccountDaysTTL; +account.setAccountTTL#2442485e ttl:AccountDaysTTL = Bool; +account.sendChangePhoneCode#82574ae5 phone_number:string settings:CodeSettings = auth.SentCode; +account.changePhone#70c32edb phone_number:string phone_code_hash:string phone_code:string = User; +account.updateDeviceLocked#38df3532 period:int = Bool; +account.getAuthorizations#e320c158 = account.Authorizations; +account.resetAuthorization#df77f3bc hash:long = Bool; +account.getPassword#548a30f5 = account.Password; +account.getPasswordSettings#9cd4eaf9 password:InputCheckPasswordSRP = account.PasswordSettings; +account.updatePasswordSettings#a59b102f password:InputCheckPasswordSRP new_settings:account.PasswordInputSettings = Bool; +account.sendConfirmPhoneCode#1b3faa88 hash:string settings:CodeSettings = auth.SentCode; +account.confirmPhone#5f2178c3 phone_code_hash:string phone_code:string = Bool; +account.getTmpPassword#449e0b51 password:InputCheckPasswordSRP period:int = account.TmpPassword; +account.getWebAuthorizations#182e6d6f = account.WebAuthorizations; +account.resetWebAuthorization#2d01b9ef hash:long = Bool; +account.resetWebAuthorizations#682d2594 = Bool; +account.getAllSecureValues#b288bc7d = Vector; +account.getSecureValue#73665bc2 types:Vector = Vector; +account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; +account.deleteSecureValue#b880bc4b types:Vector = Bool; +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; +account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; +account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; +account.sendVerifyEmailCode#98e037bb purpose:EmailVerifyPurpose email:string = account.SentEmailCode; +account.verifyEmail#32da4cf purpose:EmailVerifyPurpose verification:EmailVerification = account.EmailVerified; +account.initTakeoutSession#8ef3eab0 flags:# contacts:flags.0?true message_users:flags.1?true message_chats:flags.2?true message_megagroups:flags.3?true message_channels:flags.4?true files:flags.5?true file_max_size:flags.5?long = account.Takeout; +account.finishTakeoutSession#1d2652ee flags:# success:flags.0?true = Bool; +account.confirmPasswordEmail#8fdf1920 code:string = Bool; +account.resendPasswordEmail#7a7f2a15 = Bool; +account.cancelPasswordEmail#c1cbd5b6 = Bool; +account.getContactSignUpNotification#9f07c728 = Bool; +account.setContactSignUpNotification#cff43f61 silent:Bool = Bool; +account.getNotifyExceptions#53577479 flags:# compare_sound:flags.1?true compare_stories:flags.2?true peer:flags.0?InputNotifyPeer = Updates; +account.getWallPaper#fc8ddbea wallpaper:InputWallPaper = WallPaper; +account.uploadWallPaper#e39a8f03 flags:# for_chat:flags.0?true file:InputFile mime_type:string settings:WallPaperSettings = WallPaper; +account.saveWallPaper#6c5a5b37 wallpaper:InputWallPaper unsave:Bool settings:WallPaperSettings = Bool; +account.installWallPaper#feed5769 wallpaper:InputWallPaper settings:WallPaperSettings = Bool; +account.resetWallPapers#bb3b9804 = Bool; +account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; +account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; +account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; +account.getTheme#3a5869ec format:string theme:InputTheme = Theme; +account.getThemes#7206e458 format:string hash:long = account.Themes; +account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; +account.getContentSettings#8b9b4dae = account.ContentSettings; +account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; +account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; +account.resetPassword#9308ce1b = account.ResetPasswordResult; +account.declinePasswordReset#4c9409f6 = Bool; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# confirmed:flags.3?true hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; +account.getSavedRingtones#e1902288 hash:long = account.SavedRingtones; +account.saveRingtone#3dea5b03 id:InputDocument unsave:Bool = account.SavedRingtone; +account.uploadRingtone#831a83a2 file:InputFile file_name:string mime_type:string = Document; +account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; +account.getDefaultEmojiStatuses#d6753386 hash:long = account.EmojiStatuses; +account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; +account.clearRecentEmojiStatuses#18201aae = Bool; +account.reorderUsernames#ef500eab order:Vector = Bool; +account.toggleUsername#58d6b376 username:string active:Bool = Bool; +account.getDefaultProfilePhotoEmojis#e2750328 hash:long = EmojiList; +account.getDefaultGroupPhotoEmojis#915860ae hash:long = EmojiList; +account.getAutoSaveSettings#adcbbcda = account.AutoSaveSettings; +account.saveAutoSaveSettings#d69b8361 flags:# users:flags.0?true chats:flags.1?true broadcasts:flags.2?true peer:flags.3?InputPeer settings:AutoSaveSettings = Bool; +account.deleteAutoSaveExceptions#53bc0020 = Bool; +account.invalidateSignInCodes#ca8ae8ba codes:Vector = Bool; +account.updateColor#684d214e flags:# for_profile:flags.1?true color:flags.2?PeerColor = Bool; +account.getDefaultBackgroundEmojis#a60ab9ce hash:long = EmojiList; +account.getChannelDefaultEmojiStatuses#7727a7d5 hash:long = account.EmojiStatuses; +account.getChannelRestrictedStatusEmojis#35a9e0d5 hash:long = EmojiList; +account.updateBusinessWorkHours#4b00e066 flags:# business_work_hours:flags.0?BusinessWorkHours = Bool; +account.updateBusinessLocation#9e6b131a flags:# geo_point:flags.1?InputGeoPoint address:flags.0?string = Bool; +account.updateBusinessGreetingMessage#66cdafc4 flags:# message:flags.0?InputBusinessGreetingMessage = Bool; +account.updateBusinessAwayMessage#a26a7fa5 flags:# message:flags.0?InputBusinessAwayMessage = Bool; +account.updateConnectedBot#66a08c7e flags:# deleted:flags.1?true rights:flags.0?BusinessBotRights bot:InputUser recipients:InputBusinessBotRecipients = Updates; +account.getConnectedBots#4ea4c80f = account.ConnectedBots; +account.getBotBusinessConnection#76a86270 connection_id:string = Updates; +account.updateBusinessIntro#a614d034 flags:# intro:flags.0?InputBusinessIntro = Bool; +account.toggleConnectedBotPaused#646e1097 peer:InputPeer paused:Bool = Bool; +account.disablePeerConnectedBot#5e437ed9 peer:InputPeer = Bool; +account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool; +account.createBusinessChatLink#8851e68e link:InputBusinessChatLink = BusinessChatLink; +account.editBusinessChatLink#8c3410af slug:string link:InputBusinessChatLink = BusinessChatLink; +account.deleteBusinessChatLink#60073674 slug:string = Bool; +account.getBusinessChatLinks#6f70dde1 = account.BusinessChatLinks; +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; +account.updatePersonalChannel#d94305e0 channel:InputChannel = Bool; +account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; +account.getReactionsNotifySettings#6dd654c = ReactionsNotifySettings; +account.setReactionsNotifySettings#316ce548 settings:ReactionsNotifySettings = ReactionsNotifySettings; +account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; +account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; +account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; +account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; +account.saveMusic#b26732a9 flags:# unsave:flags.0?true id:InputDocument after_id:flags.1?InputDocument = Bool; +account.getSavedMusicIds#e09d5faf hash:long = account.SavedMusicIds; +account.getUniqueGiftChatThemes#e42ce9c9 offset:string limit:int hash:long = account.ChatThemes; +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; +account.getPasskeys#ea1f0c52 = account.Passkeys; +account.deletePasskey#f5b5563f id:string = Bool; + +users.getUsers#d91a548 id:Vector = Vector; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; +users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; +users.getRequirementsToContact#d89a83a3 id:Vector = Vector; +users.getSavedMusic#788d7fe3 id:InputUser offset:int limit:int hash:long = users.SavedMusic; +users.getSavedMusicByID#7573a4e9 id:InputUser documents:Vector = users.SavedMusic; +users.suggestBirthday#fc533372 id:InputUser birthday:Birthday = Updates; + +contacts.getContactIDs#7adc669d hash:long = Vector; +contacts.getStatuses#c4a353ee = Vector; +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; +contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; +contacts.deleteContacts#96a0e00 id:Vector = Updates; +contacts.deleteByPhones#1013fd9e phones:Vector = Bool; +contacts.block#2e2e8734 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.unblock#b550d328 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.getBlocked#9a868f80 flags:# my_stories_from:flags.0?true offset:int limit:int = contacts.Blocked; +contacts.search#11f812d8 q:string limit:int = contacts.Found; +contacts.resolveUsername#725afbbc flags:# username:string referer:flags.0?string = contacts.ResolvedPeer; +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true bots_app:flags.16?true offset:int limit:int hash:long = contacts.TopPeers; +contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; +contacts.resetSaved#879537f1 = Bool; +contacts.getSaved#82f1e39f = Vector; +contacts.toggleTopPeers#8514bdda enabled:Bool = Bool; +contacts.addContact#d9ba2e54 flags:# add_phone_privacy_exception:flags.0?true id:InputUser first_name:string last_name:string phone:string note:flags.1?TextWithEntities = Updates; +contacts.acceptContact#f831a20f id:InputUser = Updates; +contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; +contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; +contacts.exportContactToken#f8654027 = ExportedContactToken; +contacts.importContactToken#13005788 token:string = User; +contacts.editCloseFriends#ba6705f0 id:Vector = Bool; +contacts.setBlocked#94c65c76 flags:# my_stories_from:flags.0?true id:Vector limit:int = Bool; +contacts.getBirthdays#daeda864 = contacts.ContactBirthdays; +contacts.getSponsoredPeers#b6c8c393 q:string = contacts.SponsoredPeers; +contacts.updateContactNote#139f63fb id:InputUser note:TextWithEntities = Bool; + +messages.getMessages#63c66506 id:Vector = messages.Messages; +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; +messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.search#29ee847a flags:# peer:InputPeer q:string from_id:flags.0?InputPeer saved_peer_id:flags.2?InputPeer saved_reaction:flags.3?Vector top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; +messages.receivedMessages#5a954c0 max_id:int = Vector; +messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; +messages.sendMessage#545cd15a flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.sendMedia#330e77f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.forwardMessages#13704a7c flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true allow_paid_floodskip:flags.19?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer top_msg_id:flags.9?int reply_to:flags.22?InputReplyTo schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long video_timestamp:flags.20?int allow_paid_stars:flags.21?long suggested_post:flags.23?SuggestedPost = Updates; +messages.reportSpam#cf1592db peer:InputPeer = Bool; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; +messages.report#fc78af9b peer:InputPeer id:Vector option:bytes message:string = ReportResult; +messages.getChats#49e9528f id:Vector = messages.Chats; +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; +messages.addChatUser#cbc6d107 chat_id:long user_id:InputUser fwd_limit:int = messages.InvitedUsers; +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; +messages.createChat#92ceddd4 flags:# users:Vector title:string ttl_period:flags.0?int = messages.InvitedUsers; +messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; +messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; +messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; +messages.discardEncryption#f393aea0 flags:# delete_history:flags.0?true chat_id:int = Bool; +messages.setEncryptedTyping#791451ed peer:InputEncryptedChat typing:Bool = Bool; +messages.readEncryptedHistory#7f4b690a peer:InputEncryptedChat max_date:int = Bool; +messages.sendEncrypted#44fa7a15 flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.sendEncryptedFile#5559481d flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes file:InputEncryptedFile = messages.SentEncryptedMessage; +messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.receivedQueue#55a5bb66 max_qts:int = Vector; +messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; +messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; +messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; +messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; +messages.getWebPagePreview#570d6f6f flags:# message:string entities:flags.3?Vector = messages.WebPagePreview; +messages.exportChatInvite#a455de90 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string subscription_pricing:flags.5?StarsSubscriptionPricing = ExportedChatInvite; +messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; +messages.importChatInvite#6c50051c hash:string = Updates; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; +messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; +messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; +messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; +messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; +messages.migrateChat#a2875319 chat_id:long = Updates; +messages.searchGlobal#4bc6589a flags:# broadcasts_only:flags.1?true groups_only:flags.2?true users_only:flags.3?true folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; +messages.reorderStickerSets#78337739 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Bool; +messages.getDocumentByHash#b1f2061f sha256:bytes size:long mime_type:string = Document; +messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; +messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; +messages.setInlineBotResults#bb12a419 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM switch_webview:flags.4?InlineBotWebView = Bool; +messages.sendInlineBotResult#c0cf7646 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to:flags.0?InputReplyTo random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut allow_paid_stars:flags.21?long = Updates; +messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; +messages.editMessage#51e842e1 flags:# no_webpage:flags.1?true invert_media:flags.16?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int schedule_repeat_period:flags.18?int quick_reply_shortcut_id:flags.17?int = Updates; +messages.editInlineBotMessage#83557dba flags:# no_webpage:flags.1?true invert_media:flags.16?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; +messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer; +messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; +messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; +messages.saveDraft#54ae308e flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo peer:InputPeer message:string entities:flags.3?Vector media:flags.5?InputMedia effect:flags.7?long suggested_post:flags.8?SuggestedPost = Bool; +messages.getAllDrafts#6a3f8d65 = Updates; +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; +messages.readFeaturedStickers#5b118126 id:Vector = Bool; +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; +messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; +messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true emojis:flags.1?true offset_id:long limit:int = messages.ArchivedStickers; +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; +messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; +messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; +messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; +messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; +messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; +messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; +messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; +messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?string shipping_options:flags.1?Vector = Bool; +messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; +messages.uploadMedia#14967978 flags:# business_connection_id:flags.0?string peer:InputPeer media:InputMedia = MessageMedia; +messages.sendScreenshotNotification#a1405817 peer:InputPeer reply_to:InputReplyTo random_id:long = Updates; +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; +messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; +messages.getUnreadMentions#f107e790 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readMentions#36e5bf4d flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; +messages.sendMultiMedia#1bf89d74 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long = Updates; +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSplitRanges#1cff7e08 = Vector; +messages.markDialogUnread#8c5006f8 flags:# unread:flags.0?true parent_peer:flags.1?InputPeer peer:InputDialogPeer = Bool; +messages.getDialogUnreadMarks#21202222 flags:# parent_peer:flags.0?InputPeer = Vector; +messages.clearAllDrafts#7e58ee9c = Bool; +messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1?true pm_oneside:flags.2?true peer:InputPeer id:int = Updates; +messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; +messages.getPollResults#73bb643b peer:InputPeer msg_id:int = Updates; +messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; +messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; +messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; +messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; +messages.getEmojiKeywordsDifference#1508b6af lang_code:string from_version:int = EmojiKeywordsDifference; +messages.getEmojiKeywordsLanguages#4e9963b2 lang_codes:Vector = Vector; +messages.getEmojiURL#d5b10c26 lang_code:string = EmojiURL; +messages.getSearchCounters#1bbcf300 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer top_msg_id:flags.0?int filters:Vector = Vector; +messages.requestUrlAuth#198fb446 flags:# peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string = UrlAuthResult; +messages.acceptUrlAuth#b12c7125 flags:# write_allowed:flags.0?true peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string = UrlAuthResult; +messages.hidePeerSettingsBar#4facb138 peer:InputPeer = Bool; +messages.getScheduledHistory#f516760b peer:InputPeer hash:long = messages.Messages; +messages.getScheduledMessages#bdbb0464 peer:InputPeer id:Vector = messages.Messages; +messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; +messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; +messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; +messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; +messages.getDialogFilters#efd48c89 = messages.DialogFilters; +messages.getSuggestedDialogFilters#a29cd42c = Vector; +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; +messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; +messages.unpinAllMessages#62dd747 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; +messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; +messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; +messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:string media:InputMedia = MessageMedia; +messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; +messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; +messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true subscription_expired:flags.3?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; +messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; +messages.setChatTheme#81202c9 peer:InputPeer theme:InputChatTheme = Updates; +messages.getMessageReadParticipants#31c1c44f peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#6aa3f6bd flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#9c7f2f10 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#d30d78d4 flags:# big:flags.1?true add_to_recent:flags.2?true peer:InputPeer msg_id:int reaction:flags.0?Vector = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#864b2581 flags:# peer:InputPeer available_reactions:ChatReactions reactions_limit:flags.0?int paid_enabled:flags.1?Bool = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool; +messages.translateText#63183030 flags:# peer:flags.0?InputPeer id:flags.0?Vector text:flags.1?Vector to_lang:string = messages.TranslatedText; +messages.getUnreadReactions#bd7f90ac flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#9ec44f93 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; +messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; +messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; +messages.toggleBotInAttachMenu#69f59d69 flags:# write_allowed:flags.0?true bot:InputUser enabled:Bool = Bool; +messages.requestWebView#269dc2c1 flags:# from_bot_menu:flags.4?true silent:flags.5?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser url:flags.1?string start_param:flags.3?string theme_params:flags.2?DataJSON platform:string reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = WebViewResult; +messages.prolongWebView#b0d81a83 flags:# silent:flags.5?true peer:InputPeer bot:InputUser query_id:long reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = Bool; +messages.requestSimpleWebView#413a3e73 flags:# from_switch_webview:flags.1?true from_side_menu:flags.2?true compact:flags.7?true fullscreen:flags.8?true bot:InputUser url:flags.3?string start_param:flags.4?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent; +messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates; +messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio; +messages.rateTranscribedAudio#7f1d072f peer:InputPeer msg_id:int transcription_id:long good:Bool = Bool; +messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; +messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; +messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; +messages.reportReaction#3f64c076 peer:InputPeer id:int reaction_peer:InputPeer = Bool; +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; +messages.clearRecentReactions#9dfeefb4 = Bool; +messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; +messages.sendBotRequestedPeer#91b2d060 peer:InputPeer msg_id:int button_id:int requested_peers:Vector = Updates; +messages.getEmojiGroups#7488ce5b hash:int = messages.EmojiGroups; +messages.getEmojiStatusGroups#2ecd56cd hash:int = messages.EmojiGroups; +messages.getEmojiProfilePhotoGroups#21a548f3 hash:int = messages.EmojiGroups; +messages.searchCustomEmoji#2c11c0d7 emoticon:string hash:long = EmojiList; +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; +messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; +messages.requestAppWebView#53618bce flags:# write_allowed:flags.0?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = WebViewResult; +messages.setChatWallPaper#8ffacae1 flags:# for_both:flags.3?true revert:flags.4?true peer:InputPeer wallpaper:flags.0?InputWallPaper settings:flags.2?WallPaperSettings id:flags.1?int = Updates; +messages.searchEmojiStickerSets#92b4494c flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSavedDialogs#1e91fc99 flags:# exclude_pinned:flags.0?true parent_peer:flags.1?InputPeer offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; +messages.getSavedHistory#998ab009 flags:# parent_peer:flags.0?InputPeer peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.deleteSavedHistory#4dc5085f flags:# parent_peer:flags.0?InputPeer peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; +messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedSavedDialogs#8b716587 flags:# force:flags.0?true order:Vector = Bool; +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; +messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; +messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; +messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; +messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; +messages.reorderQuickReplies#60331907 order:Vector = Bool; +messages.checkQuickReplyShortcut#f1d0fbd3 shortcut:string = Bool; +messages.editQuickReplyShortcut#5c003cef shortcut_id:int shortcut:string = Bool; +messages.deleteQuickReplyShortcut#3cc04740 shortcut_id:int = Bool; +messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; +messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; +messages.deleteQuickReplyMessages#e105e910 shortcut_id:int id:Vector = Updates; +messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool; +messages.getMyStickers#d0b5e1fc offset_id:long limit:int = messages.MyStickers; +messages.getEmojiStickerGroups#1dd840f5 hash:int = messages.EmojiGroups; +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; +messages.editFactCheck#589ee75 peer:InputPeer msg_id:int text:TextWithEntities = Updates; +messages.deleteFactCheck#d1da940c peer:InputPeer msg_id:int = Updates; +messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; +messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendPaidReaction#58bbcb50 flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?PaidReactionPrivacy = Updates; +messages.togglePaidReactionPrivacy#435885b5 peer:InputPeer msg_id:int private:PaidReactionPrivacy = Bool; +messages.getPaidReactionPrivacy#472455aa = Updates; +messages.viewSponsoredMessage#269e3643 random_id:bytes = Bool; +messages.clickSponsoredMessage#8235057e flags:# media:flags.0?true fullscreen:flags.1?true random_id:bytes = Bool; +messages.reportSponsoredMessage#12cbf0c4 random_id:bytes option:bytes = channels.SponsoredMessageReportResult; +messages.getSponsoredMessages#3d6ce850 flags:# peer:InputPeer msg_id:flags.0?int = messages.SponsoredMessages; +messages.savePreparedInlineMessage#f21f7f2f flags:# result:InputBotInlineResult user_id:InputUser peer_types:flags.0?Vector = messages.BotPreparedInlineMessage; +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; +messages.searchStickers#29b1c66a flags:# emojis:flags.0?true q:string emoticon:string lang_code:Vector offset:int limit:int hash:long = messages.FoundStickers; +messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = Bool; +messages.getSavedDialogsByID#6f6f9c96 flags:# parent_peer:flags.1?InputPeer ids:Vector = messages.SavedDialogs; +messages.readSavedHistory#ba4a3b5b parent_peer:InputPeer peer:InputPeer max_id:int = Bool; +messages.toggleTodoCompleted#d3e03124 peer:InputPeer msg_id:int completed:Vector incompleted:Vector = Updates; +messages.appendTodoList#21a61057 peer:InputPeer msg_id:int list:Vector = Updates; +messages.toggleSuggestedPostApproval#8107455c flags:# reject:flags.1?true peer:InputPeer msg_id:int schedule_date:flags.0?int reject_comment:flags.2?string = Updates; +messages.getForumTopics#3ba47bff flags:# peer:InputPeer q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; +messages.getForumTopicsByID#af0a4a08 peer:InputPeer topics:Vector = messages.ForumTopics; +messages.editForumTopic#cecc1134 flags:# peer:InputPeer topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; +messages.updatePinnedForumTopic#175df251 peer:InputPeer topic_id:int pinned:Bool = Updates; +messages.reorderPinnedForumTopics#e7841f0 flags:# force:flags.0?true peer:InputPeer order:Vector = Updates; +messages.createForumTopic#2f98c3d5 flags:# title_missing:flags.4?true peer:InputPeer title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; +messages.deleteTopicHistory#d2816f10 peer:InputPeer top_msg_id:int = messages.AffectedHistory; + +updates.getState#edd4882a = updates.State; +updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; +updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; + +photos.updateProfilePhoto#9e82039 flags:# fallback:flags.0?true bot:flags.1?InputUser id:InputPhoto = photos.Photo; +photos.uploadProfilePhoto#388a3b5 flags:# fallback:flags.3?true bot:flags.5?InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.4?VideoSize = photos.Photo; +photos.deletePhotos#87cf7f2f id:Vector = Vector; +photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; +photos.uploadContactProfilePhoto#e14c4a71 flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.5?VideoSize = photos.Photo; + +upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; +upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; +upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool; +upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile; +upload.getCdnFile#395f69da file_token:bytes offset:long limit:int = upload.CdnFile; +upload.reuploadCdnFile#9b2754a8 file_token:bytes request_token:bytes = Vector; +upload.getCdnFileHashes#91dc3f31 file_token:bytes offset:long = Vector; +upload.getFileHashes#9156982a location:InputFileLocation offset:long = Vector; + +help.getConfig#c4f9186b = Config; +help.getNearestDc#1fb33026 = NearestDc; +help.getAppUpdate#522d5a7d source:string = help.AppUpdate; +help.getInviteText#4d392343 = help.InviteText; +help.getSupport#9cdf08cd = help.Support; +help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; +help.getCdnConfig#52029342 = CdnConfig; +help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; +help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; +help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; +help.getAppConfig#61e3f854 hash:int = help.AppConfig; +help.saveAppLog#6f02f748 events:Vector = Bool; +help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; +help.getSupportName#d360e72c = help.SupportName; +help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; +help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; +help.getPromoData#c0977421 = help.PromoData; +help.hidePromoData#1e251c95 peer:InputPeer = Bool; +help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; +help.getPremiumPromo#b81b93d4 = help.PremiumPromo; +help.getPeerColors#da80f42f hash:int = help.PeerColors; +help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors; +help.getTimezonesList#49b30240 hash:int = help.TimezonesList; + +channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; +channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; +channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; +channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; +channels.getChannels#a7f6bbb id:Vector = messages.Chats; +channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true forum:flags.5?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; +channels.editAdmin#d33c8902 channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:string = Updates; +channels.editTitle#566decd0 channel:InputChannel title:string = Updates; +channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; +channels.checkUsername#10e6bd2c channel:InputChannel username:string = Bool; +channels.updateUsername#3514b3de channel:InputChannel username:string = Bool; +channels.joinChannel#24b524c5 channel:InputChannel = Updates; +channels.leaveChannel#f836aa95 channel:InputChannel = Updates; +channels.inviteToChannel#c9e33d54 channel:InputChannel users:Vector = messages.InvitedUsers; +channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; +channels.exportMessageLink#e63fadeb flags:# grouped:flags.0?true thread:flags.1?true channel:InputChannel id:int = ExportedMessageLink; +channels.toggleSignatures#418d549c flags:# signatures_enabled:flags.0?true profiles_enabled:flags.1?true channel:InputChannel = Updates; +channels.getAdminedPublicChannels#f8b036af flags:# by_location:flags.0?true check_limit:flags.1?true for_personal:flags.2?true = messages.Chats; +channels.editBanned#96e6cd81 channel:InputChannel participant:InputPeer banned_rights:ChatBannedRights = Updates; +channels.getAdminLog#33ddf480 flags:# channel:InputChannel q:string events_filter:flags.0?ChannelAdminLogEventsFilter admins:flags.1?Vector max_id:long min_id:long limit:int = channels.AdminLogResults; +channels.setStickers#ea8ca4f9 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = Bool; +channels.deleteHistory#9baa9647 flags:# for_everyone:flags.0?true channel:InputChannel max_id:int = Updates; +channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates; +channels.getLeftChannels#8341ecc0 offset:int = messages.Chats; +channels.getGroupsForDiscussion#f5dad378 = messages.Chats; +channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool; +channels.editCreator#8f38cd1f channel:InputChannel user_id:InputUser password:InputCheckPasswordSRP = Updates; +channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint address:string = Bool; +channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; +channels.getInactiveChannels#11e831ee = messages.InactiveChats; +channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; +channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; +channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; +channels.toggleJoinRequest#4c2985b6 channel:InputChannel enabled:Bool = Updates; +channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; +channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; +channels.deactivateAllUsernames#a245dd3 channel:InputChannel = Bool; +channels.toggleForum#3ff75734 channel:InputChannel enabled:Bool tabs:Bool = Updates; +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; +channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; +channels.updateColor#d8aa3671 flags:# for_profile:flags.1?true channel:InputChannel color:flags.2?int background_emoji_id:flags.0?long = Updates; +channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; +channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; +channels.updateEmojiStatus#f0d3e6a8 channel:InputChannel emoji_status:EmojiStatus = Updates; +channels.setBoostsToUnblockRestrictions#ad399cee channel:InputChannel boosts:int = Updates; +channels.setEmojiStickers#3cd930b7 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.restrictSponsoredMessages#9ae91519 channel:InputChannel restricted:Bool = Updates; +channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; +channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; +channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; +channels.getMessageAuthor#ece2a0e6 channel:InputChannel id:int = User; +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; +channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool; + +bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; +bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; +bots.setBotMenuButton#4504d54f user_id:InputUser button:BotMenuButton = Bool; +bots.getBotMenuButton#9c60eb28 user_id:InputUser = BotMenuButton; +bots.setBotBroadcastDefaultAdminRights#788464e1 admin_rights:ChatAdminRights = Bool; +bots.setBotGroupDefaultAdminRights#925ec9ea admin_rights:ChatAdminRights = Bool; +bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; +bots.getBotInfo#dcd914fd flags:# bot:flags.0?InputUser lang_code:string = bots.BotInfo; +bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = Bool; +bots.toggleUsername#53ca973 bot:InputUser username:string active:Bool = Bool; +bots.canSendMessage#1359f4e6 bot:InputUser = Bool; +bots.allowSendMessage#f132e3ef bot:InputUser = Updates; +bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; +bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots; +bots.addPreviewMedia#17aeb75a bot:InputUser lang_code:string media:InputMedia = BotPreviewMedia; +bots.editPreviewMedia#8525606f bot:InputUser lang_code:string media:InputMedia new_media:InputMedia = BotPreviewMedia; +bots.deletePreviewMedia#2d0135b3 bot:InputUser lang_code:string media:Vector = Bool; +bots.reorderPreviewMedias#b627f3aa bot:InputUser lang_code:string order:Vector = Bool; +bots.getPreviewInfo#423ab3ad bot:InputUser lang_code:string = bots.PreviewInfo; +bots.getPreviewMedias#a2a5594d bot:InputUser = Vector; +bots.updateUserEmojiStatus#ed9f30c5 user_id:InputUser emoji_status:EmojiStatus = Bool; +bots.toggleUserEmojiStatusPermission#6de6392 bot:InputUser enabled:Bool = Bool; +bots.checkDownloadFileParams#50077589 bot:InputUser file_name:string url:string = Bool; +bots.getAdminedBots#b0711d83 = Vector; +bots.updateStarRefProgram#778b5ab3 flags:# bot:InputUser commission_permille:int duration_months:flags.0?int = StarRefProgram; +bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool; +bots.getBotRecommendations#a1b70815 bot:InputUser = users.Users; + +payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; +payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; +payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; +payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; +payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; +payments.getBankCardData#2e79d779 number:string = payments.BankCardData; +payments.exportInvoice#f91b065 invoice_media:InputMedia = payments.ExportedInvoice; +payments.assignAppStoreTransaction#80ed747d receipt:bytes purpose:InputStorePaymentPurpose = Updates; +payments.assignPlayMarketTransaction#dffd50d3 receipt:DataJSON purpose:InputStorePaymentPurpose = Updates; +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; +payments.applyGiftCode#f6e26854 slug:string = Updates; +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; +payments.getStarsTopupOptions#c00ec7d3 = Vector; +payments.getStarsStatus#4ea9b3bf flags:# ton:flags.0?true peer:InputPeer = payments.StarsStatus; +payments.getStarsTransactions#69da4557 flags:# inbound:flags.0?true outbound:flags.1?true ascending:flags.2?true ton:flags.4?true subscription_id:flags.3?string peer:InputPeer offset:string limit:int = payments.StarsStatus; +payments.sendStarsForm#7998c914 form_id:long invoice:InputInvoice = payments.PaymentResult; +payments.refundStarsCharge#25ae8f4a user_id:InputUser charge_id:string = Updates; +payments.getStarsRevenueStats#d91ffad6 flags:# dark:flags.0?true ton:flags.1?true peer:InputPeer = payments.StarsRevenueStats; +payments.getStarsRevenueWithdrawalUrl#2433dc92 flags:# ton:flags.0?true peer:InputPeer amount:flags.1?long password:InputCheckPasswordSRP = payments.StarsRevenueWithdrawalUrl; +payments.getStarsRevenueAdsAccountUrl#d1d7efc5 peer:InputPeer = payments.StarsRevenueAdsAccountUrl; +payments.getStarsTransactionsByID#2dca16b8 flags:# ton:flags.0?true peer:InputPeer id:Vector = payments.StarsStatus; +payments.getStarsGiftOptions#d3c96bc8 flags:# user_id:flags.0?InputUser = Vector; +payments.getStarsSubscriptions#32512c5 flags:# missing_balance:flags.0?true peer:InputPeer offset:string = payments.StarsStatus; +payments.changeStarsSubscription#c7770878 flags:# peer:InputPeer subscription_id:string canceled:flags.0?Bool = Bool; +payments.fulfillStarsSubscription#cc5bebb3 peer:InputPeer subscription_id:string = Bool; +payments.getStarsGiveawayOptions#bd1efd3e = Vector; +payments.getStarGifts#c4563590 hash:int = payments.StarGifts; +payments.saveStarGift#2a2a697c flags:# unsave:flags.0?true stargift:InputSavedStarGift = Bool; +payments.convertStarGift#74bf076b stargift:InputSavedStarGift = Bool; +payments.botCancelStarsSubscription#6dfa0622 flags:# restore:flags.0?true user_id:InputUser charge_id:string = Bool; +payments.getConnectedStarRefBots#5869a553 flags:# peer:InputPeer offset_date:flags.2?int offset_link:flags.2?string limit:int = payments.ConnectedStarRefBots; +payments.getConnectedStarRefBot#b7d998f0 peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.getSuggestedStarRefBots#d6b48f7 flags:# order_by_revenue:flags.0?true order_by_date:flags.1?true peer:InputPeer offset:string limit:int = payments.SuggestedStarRefBots; +payments.connectStarRefBot#7ed5348a peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.editConnectedStarRefBot#e4fca4a3 flags:# revoked:flags.0?true peer:InputPeer link:string = payments.ConnectedStarRefBots; +payments.getStarGiftUpgradePreview#9c9abcb1 gift_id:long = payments.StarGiftUpgradePreview; +payments.upgradeStarGift#aed6e4f5 flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = Updates; +payments.transferStarGift#7f18176a stargift:InputSavedStarGift to_id:InputPeer = Updates; +payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift; +payments.getSavedStarGifts#a319e569 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_unique:flags.4?true sort_by_value:flags.5?true exclude_upgradable:flags.7?true exclude_unupgradable:flags.8?true peer_color_available:flags.9?true exclude_hosted:flags.10?true peer:InputPeer collection_id:flags.6?int offset:string limit:int = payments.SavedStarGifts; +payments.getSavedStarGift#b455a106 stargift:Vector = payments.SavedStarGifts; +payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl; +payments.toggleChatStarGiftNotifications#60eaefa1 flags:# enabled:flags.0?true peer:InputPeer = Bool; +payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = Bool; +payments.canPurchaseStore#4fdc5ea7 purpose:InputStorePaymentPurpose = Bool; +payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector offset:string limit:int = payments.ResaleStarGifts; +payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates; +payments.createStarGiftCollection#1f4a0e87 peer:InputPeer title:string stargift:Vector = StarGiftCollection; +payments.updateStarGiftCollection#4fddbee7 flags:# peer:InputPeer collection_id:int title:flags.0?string delete_stargift:flags.1?Vector add_stargift:flags.2?Vector order:flags.3?Vector = StarGiftCollection; +payments.reorderStarGiftCollections#c32af4cc peer:InputPeer order:Vector = Bool; +payments.deleteStarGiftCollection#ad5648e8 peer:InputPeer collection_id:int = Bool; +payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections; +payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGiftValueInfo; +payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; +payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; +payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; +payments.sendStarGiftOffer#8fb86b41 flags:# peer:InputPeer slug:string price:StarsAmount duration:int random_id:long allow_paid_stars:flags.0?long = Updates; +payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; + +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true emojis:flags.5?true text_color:flags.6?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; +stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; +stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; +stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; +stickers.setStickerSetThumb#a76a5392 flags:# stickerset:InputStickerSet thumb:flags.0?InputDocument thumb_document_id:flags.1?long = messages.StickerSet; +stickers.checkShortName#284b3639 short_name:string = Bool; +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; +stickers.changeSticker#f5537ebc flags:# sticker:InputDocument emoji:flags.0?string mask_coords:flags.1?MaskCoords keywords:flags.2?string = messages.StickerSet; +stickers.renameStickerSet#124b1c00 stickerset:InputStickerSet title:string = messages.StickerSet; +stickers.deleteStickerSet#87704394 stickerset:InputStickerSet = Bool; +stickers.replaceSticker#4696459a sticker:InputDocument new_sticker:InputStickerSetItem = messages.StickerSet; + +phone.getCallConfig#55451fa9 = DataJSON; +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.toggleGroupCallSettings#974392f2 flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool messages_enabled:flags.2?Bool send_paid_messages_stars:flags.3?long = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; +phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; +phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; +phone.getGroupCallStreamRtmpUrl#5af4c73a flags:# live_story:flags.0?true peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; +phone.saveCallLog#41248786 peer:InputPhoneCall file:InputFile = Bool; +phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates; +phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector block:bytes = Updates; +phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates; +phone.inviteConferenceCallParticipant#bcf22685 flags:# video:flags.0?true call:InputGroupCall user_id:InputUser = Updates; +phone.declineConferenceCallInvite#3c479971 msg_id:int = Updates; +phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates; +phone.sendGroupCallMessage#b1d11410 flags:# call:InputGroupCall random_id:long message:TextWithEntities allow_paid_stars:flags.0?long send_as:flags.1?InputPeer = Updates; +phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool; +phone.deleteGroupCallMessages#f64f54f7 flags:# report_spam:flags.0?true call:InputGroupCall messages:Vector = Updates; +phone.deleteGroupCallParticipantMessages#1dbfeca0 flags:# report_spam:flags.0?true call:InputGroupCall participant:InputPeer = Updates; +phone.getGroupCallStars#6f636302 call:InputGroupCall = phone.GroupCallStars; +phone.saveDefaultSendAs#4167add1 call:InputGroupCall send_as:InputPeer = Bool; + +langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; +langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; +langpack.getDifference#cd984aa5 lang_pack:string lang_code:string from_version:int = LangPackDifference; +langpack.getLanguages#42c6978f lang_pack:string = Vector; +langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLanguage; + +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; + +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; +stats.getMessagePublicForwards#5f150144 channel:InputChannel msg_id:int offset:string limit:int = stats.PublicForwards; +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; +stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats; +stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards; + +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = chatlists.ExportedChatlistInvite; +chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool; +chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector = ExportedChatlistInvite; +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; +chatlists.getChatlistUpdates#89419521 chatlist:InputChatlist = chatlists.ChatlistUpdates; +chatlists.joinChatlistUpdates#e089f8f5 chatlist:InputChatlist peers:Vector = Updates; +chatlists.hideChatlistUpdates#66e486fb chatlist:InputChatlist = Bool; +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates; + +stories.canSendStory#30eb63f0 peer:InputPeer = stories.CanSendStoryCount; +stories.sendStory#737fc2ec flags:# pinned:flags.2?true noforwards:flags.4?true fwd_modified:flags.7?true peer:InputPeer media:InputMedia media_areas:flags.5?Vector caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long period:flags.3?int fwd_from_id:flags.6?InputPeer fwd_from_story:flags.6?int albums:flags.8?Vector = Updates; +stories.editStory#b583ba46 flags:# peer:InputPeer id:int media:flags.0?InputMedia media_areas:flags.3?Vector caption:flags.1?string entities:flags.1?Vector privacy_rules:flags.2?Vector = Updates; +stories.deleteStories#ae59db5f peer:InputPeer id:Vector = Vector; +stories.togglePinned#9a75a1ef peer:InputPeer id:Vector pinned:Bool = Vector; +stories.getAllStories#eeb0d625 flags:# next:flags.1?true hidden:flags.2?true state:flags.0?string = stories.AllStories; +stories.getPinnedStories#5821a5dc peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesArchive#b4352016 peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesByID#5774ca74 peer:InputPeer id:Vector = stories.Stories; +stories.toggleAllStoriesHidden#7c2557c4 hidden:Bool = Bool; +stories.readStories#a556dac8 peer:InputPeer max_id:int = Vector; +stories.incrementStoryViews#b2028afb peer:InputPeer id:Vector = Bool; +stories.getStoryViewsList#7ed23c57 flags:# just_contacts:flags.0?true reactions_first:flags.2?true forwards_first:flags.3?true peer:InputPeer q:flags.1?string id:int offset:string limit:int = stories.StoryViewsList; +stories.getStoriesViews#28e16cc8 peer:InputPeer id:Vector = stories.StoryViews; +stories.exportStoryLink#7b8def20 peer:InputPeer id:int = ExportedStoryLink; +stories.report#19d8eb45 peer:InputPeer id:Vector option:bytes message:string = ReportResult; +stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?true = Updates; +stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; +stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; +stories.getAllReadPeerStories#9b5ae7f9 = Updates; +stories.getPeerMaxIDs#78499170 id:Vector = Vector; +stories.getChatsToSend#a56a8b60 = messages.Chats; +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.getStoryReactionsList#b9b2881f flags:# forwards_first:flags.2?true peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = stories.StoryReactionsList; +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; +stories.searchPosts#d1810907 flags:# hashtag:flags.0?string area:flags.1?MediaArea peer:flags.2?InputPeer offset:string limit:int = stories.FoundStories; +stories.createAlbum#a36396e5 peer:InputPeer title:string stories:Vector = StoryAlbum; +stories.updateAlbum#5e5259b6 flags:# peer:InputPeer album_id:int title:flags.0?string delete_stories:flags.1?Vector add_stories:flags.2?Vector order:flags.3?Vector = StoryAlbum; +stories.reorderAlbums#8535fbd9 peer:InputPeer order:Vector = Bool; +stories.deleteAlbum#8d3456d0 peer:InputPeer album_id:int = Bool; +stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums; +stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories; +stories.startLive#d069ccde flags:# pinned:flags.2?true noforwards:flags.4?true rtmp_stream:flags.5?true peer:InputPeer caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long messages_enabled:flags.6?Bool send_paid_messages_stars:flags.7?long = Updates; + +premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; +premium.getMyBoosts#be77b4a = premium.MyBoosts; +premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; +premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus; +premium.getUserBoosts#39854d1f peer:InputPeer user_id:InputUser = premium.BoostsList; + +smsjobs.isEligibleToJoin#edc39d0 = smsjobs.EligibilityToJoin; +smsjobs.join#a74ece2d = Bool; +smsjobs.leave#9898ad73 = Bool; +smsjobs.updateSettings#93fa0bf flags:# allow_international:flags.0?true = Bool; +smsjobs.getStatus#10a698e8 = smsjobs.Status; +smsjobs.getSmsJob#778d902f job_id:string = SmsJob; +smsjobs.finishJob#4f1ebf24 flags:# job_id:string error:flags.0?string = Bool; + +fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo; + +// LAYER 220 diff --git a/internal/compat/layerwire/_schema/layer-221.tl b/internal/compat/layerwire/_schema/layer-221.tl new file mode 100644 index 00000000..a2495076 --- /dev/null +++ b/internal/compat/layerwire/_schema/layer-221.tl @@ -0,0 +1,2892 @@ +boolFalse#bc799737 = Bool; +boolTrue#997275b5 = Bool; + +true#3fedd339 = True; + +vector#1cb5c415 {t:Type} # [ t ] = Vector t; + +error#c4b9f9bb code:int text:string = Error; + +null#56730bcc = Null; + +inputPeerEmpty#7f3b18ea = InputPeer; +inputPeerSelf#7da07ec9 = InputPeer; +inputPeerChat#35a95cb9 chat_id:long = InputPeer; +inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer; +inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer; +inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer; +inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer; + +inputUserEmpty#b98886cf = InputUser; +inputUserSelf#f7c1b13f = InputUser; +inputUser#f21158c6 user_id:long access_hash:long = InputUser; +inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser; + +inputPhoneContact#6a1dc4be flags:# client_id:long phone:string first_name:string last_name:string note:flags.0?TextWithEntities = InputContact; + +inputFile#f52ff27f id:long parts:int name:string md5_checksum:string = InputFile; +inputFileBig#fa4f0bb5 id:long parts:int name:string = InputFile; +inputFileStoryDocument#62dc8b48 id:InputDocument = InputFile; + +inputMediaEmpty#9664f57f = InputMedia; +inputMediaUploadedPhoto#1e287d04 flags:# spoiler:flags.2?true file:InputFile stickers:flags.0?Vector ttl_seconds:flags.1?int = InputMedia; +inputMediaPhoto#b3ba0635 flags:# spoiler:flags.1?true id:InputPhoto ttl_seconds:flags.0?int = InputMedia; +inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia; +inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia; +inputMediaUploadedDocument#37c9330 flags:# nosound_video:flags.3?true force_file:flags.4?true spoiler:flags.5?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector video_cover:flags.6?InputPhoto video_timestamp:flags.7?int ttl_seconds:flags.1?int = InputMedia; +inputMediaDocument#a8763ab5 flags:# spoiler:flags.2?true id:InputDocument video_cover:flags.3?InputPhoto video_timestamp:flags.4?int ttl_seconds:flags.0?int query:flags.1?string = InputMedia; +inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia; +inputMediaPhotoExternal#e5bbfe1a flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int = InputMedia; +inputMediaDocumentExternal#779600f9 flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int video_cover:flags.2?InputPhoto video_timestamp:flags.3?int = InputMedia; +inputMediaGame#d33f43f3 id:InputGame = InputMedia; +inputMediaInvoice#405fef0d flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:flags.3?string provider_data:DataJSON start_param:flags.1?string extended_media:flags.2?InputMedia = InputMedia; +inputMediaGeoLive#971fa843 flags:# stopped:flags.0?true geo_point:InputGeoPoint heading:flags.2?int period:flags.1?int proximity_notification_radius:flags.3?int = InputMedia; +inputMediaPoll#f94e5f1 flags:# poll:Poll correct_answers:flags.0?Vector solution:flags.1?string solution_entities:flags.1?Vector = InputMedia; +inputMediaDice#e66fbf7b emoticon:string = InputMedia; +inputMediaStory#89fdd778 peer:InputPeer id:int = InputMedia; +inputMediaWebPage#c21b8849 flags:# force_large_media:flags.0?true force_small_media:flags.1?true optional:flags.2?true url:string = InputMedia; +inputMediaPaidMedia#c4103386 flags:# stars_amount:long extended_media:Vector payload:flags.0?string = InputMedia; +inputMediaTodo#9fc55fde todo:TodoList = InputMedia; +inputMediaStakeDice#f3a9244a game_hash:string ton_amount:long client_seed:bytes = InputMedia; + +inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; +inputChatUploadedPhoto#bdcdaec0 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.3?VideoSize = InputChatPhoto; +inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; + +inputGeoPointEmpty#e4c123d6 = InputGeoPoint; +inputGeoPoint#48222faf flags:# lat:double long:double accuracy_radius:flags.0?int = InputGeoPoint; + +inputPhotoEmpty#1cd7bf0d = InputPhoto; +inputPhoto#3bb3b94a id:long access_hash:long file_reference:bytes = InputPhoto; + +inputFileLocation#dfdaabe1 volume_id:long local_id:int secret:long file_reference:bytes = InputFileLocation; +inputEncryptedFileLocation#f5235d55 id:long access_hash:long = InputFileLocation; +inputDocumentFileLocation#bad07584 id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputSecureFileLocation#cbc7ee28 id:long access_hash:long = InputFileLocation; +inputTakeoutFileLocation#29be5899 = InputFileLocation; +inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation; +inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation; +inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation; +inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation; + +peerUser#59511722 user_id:long = Peer; +peerChat#36c6019a chat_id:long = Peer; +peerChannel#a2a5371e channel_id:long = Peer; + +storage.fileUnknown#aa963b05 = storage.FileType; +storage.filePartial#40bc6f52 = storage.FileType; +storage.fileJpeg#7efe0e = storage.FileType; +storage.fileGif#cae1aadf = storage.FileType; +storage.filePng#a4f63c0 = storage.FileType; +storage.filePdf#ae1e508d = storage.FileType; +storage.fileMp3#528a0677 = storage.FileType; +storage.fileMov#4b09ebbc = storage.FileType; +storage.fileMp4#b3cea0e4 = storage.FileType; +storage.fileWebp#1081464c = storage.FileType; + +userEmpty#d3bc4b7a id:long = User; +user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User; + +userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; +userProfilePhoto#82d1f706 flags:# has_video:flags.0?true personal:flags.2?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto; + +userStatusEmpty#9d05049 = UserStatus; +userStatusOnline#edb93949 expires:int = UserStatus; +userStatusOffline#8c703f was_online:int = UserStatus; +userStatusRecently#7b197dc8 flags:# by_me:flags.0?true = UserStatus; +userStatusLastWeek#541a1d1a flags:# by_me:flags.0?true = UserStatus; +userStatusLastMonth#65899777 flags:# by_me:flags.0?true = UserStatus; + +chatEmpty#29562865 id:long = Chat; +chat#41cbf256 flags:# creator:flags.0?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; +chatForbidden#6592a1a7 id:long title:string = Chat; +channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat; +channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true id:long access_hash:long title:string until_date:flags.16?int = Chat; + +chatFull#2633421b flags:# can_set_username:flags.7?true has_scheduled:flags.8?true translations_disabled:flags.19?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions reactions_limit:flags.20?int = ChatFull; +channelFull#e4e0b29d flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true participants_hidden:flags2.2?true translations_disabled:flags2.3?true stories_pinned_available:flags2.5?true view_forum_as_messages:flags2.6?true restricted_sponsored:flags2.11?true can_view_revenue:flags2.12?true paid_media_allowed:flags2.14?true can_view_stars_revenue:flags2.15?true paid_reactions_available:flags2.16?true stargifts_available:flags2.19?true paid_messages_available:flags2.20?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions reactions_limit:flags2.13?int stories:flags2.4?PeerStories wallpaper:flags2.7?WallPaper boosts_applied:flags2.8?int boosts_unrestrict:flags2.9?int emojiset:flags2.10?StickerSet bot_verification:flags2.17?BotVerification stargifts_count:flags2.18?int send_paid_messages_stars:flags2.21?long main_tab:flags2.22?ProfileTab = ChatFull; + +chatParticipant#c02d4007 user_id:long inviter_id:long date:int = ChatParticipant; +chatParticipantCreator#e46bcee4 user_id:long = ChatParticipant; +chatParticipantAdmin#a0933f5b user_id:long inviter_id:long date:int = ChatParticipant; + +chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants; +chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants; + +chatPhotoEmpty#37c1011c = ChatPhoto; +chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; + +messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; +message#9cb490e9 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true invert_media:flags.27?true flags2:# offline:flags2.1?true video_processing_pending:flags2.4?true paid_suggested_post_stars:flags2.8?true paid_suggested_post_ton:flags2.9?true id:int from_id:flags.8?Peer from_boosts_applied:flags.29?int peer_id:Peer saved_peer_id:flags.28?Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long via_business_bot_id:flags2.0?long reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int quick_reply_shortcut_id:flags.30?int effect:flags2.2?long factcheck:flags2.3?FactCheck report_delivery_until_date:flags2.5?int paid_message_stars:flags2.6?long suggested_post:flags2.7?SuggestedPost schedule_repeat_period:flags2.10?int summary_from_language:flags2.11?string = Message; +messageService#7a800e0a flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true reactions_are_possible:flags.9?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer saved_peer_id:flags.28?Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction reactions:flags.20?MessageReactions ttl_period:flags.25?int = Message; + +messageMediaEmpty#3ded6320 = MessageMedia; +messageMediaPhoto#695150d7 flags:# spoiler:flags.3?true photo:flags.0?Photo ttl_seconds:flags.2?int = MessageMedia; +messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia; +messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia; +messageMediaUnsupported#9f84f49e = MessageMedia; +messageMediaDocument#52d8ccd9 flags:# nopremium:flags.3?true spoiler:flags.4?true video:flags.6?true round:flags.7?true voice:flags.8?true document:flags.0?Document alt_documents:flags.5?Vector video_cover:flags.9?Photo video_timestamp:flags.10?int ttl_seconds:flags.2?int = MessageMedia; +messageMediaWebPage#ddf10c3b flags:# force_large_media:flags.0?true force_small_media:flags.1?true manual:flags.3?true safe:flags.4?true webpage:WebPage = MessageMedia; +messageMediaVenue#2ec0533f geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MessageMedia; +messageMediaGame#fdb19008 game:Game = MessageMedia; +messageMediaInvoice#f6a548d3 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string extended_media:flags.4?MessageExtendedMedia = MessageMedia; +messageMediaGeoLive#b940c666 flags:# geo:GeoPoint heading:flags.0?int period:int proximity_notification_radius:flags.1?int = MessageMedia; +messageMediaPoll#4bd6e798 poll:Poll results:PollResults = MessageMedia; +messageMediaDice#8cbec07 flags:# value:int emoticon:string game_outcome:flags.0?messages.EmojiGameOutcome = MessageMedia; +messageMediaStory#68cb6283 flags:# via_mention:flags.1?true peer:Peer id:int story:flags.0?StoryItem = MessageMedia; +messageMediaGiveaway#aa073beb flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.2?true channels:Vector countries_iso2:flags.1?Vector prize_description:flags.3?string quantity:int months:flags.4?int stars:flags.5?long until_date:int = MessageMedia; +messageMediaGiveawayResults#ceaa3ea1 flags:# only_new_subscribers:flags.0?true refunded:flags.2?true channel_id:long additional_peers_count:flags.3?int launch_msg_id:int winners_count:int unclaimed_count:int winners:Vector months:flags.4?int stars:flags.5?long prize_description:flags.1?string until_date:int = MessageMedia; +messageMediaPaidMedia#a8852491 stars_amount:long extended_media:Vector = MessageMedia; +messageMediaToDo#8a53b014 flags:# todo:TodoList completions:flags.0?Vector = MessageMedia; +messageMediaVideoStream#ca5cab89 flags:# rtmp_stream:flags.0?true call:InputGroupCall = MessageMedia; + +messageActionEmpty#b6aef7b0 = MessageAction; +messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction; +messageActionChatEditTitle#b5a1ce5a title:string = MessageAction; +messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction; +messageActionChatDeletePhoto#95e3fbef = MessageAction; +messageActionChatAddUser#15cefd00 users:Vector = MessageAction; +messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction; +messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction; +messageActionChannelCreate#95d2ac92 title:string = MessageAction; +messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction; +messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction; +messageActionPinMessage#94bd38ed = MessageAction; +messageActionHistoryClear#9fbab604 = MessageAction; +messageActionGameScore#92a72876 game_id:long score:int = MessageAction; +messageActionPaymentSentMe#ffa00ccc flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string charge:PaymentCharge subscription_until_date:flags.4?int = MessageAction; +messageActionPaymentSent#c624b16e flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long invoice_slug:flags.0?string subscription_until_date:flags.4?int = MessageAction; +messageActionPhoneCall#80e11a7f flags:# video:flags.2?true call_id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = MessageAction; +messageActionScreenshotTaken#4792929b = MessageAction; +messageActionCustomAction#fae69f56 message:string = MessageAction; +messageActionBotAllowed#c516d679 flags:# attach_menu:flags.1?true from_request:flags.3?true domain:flags.0?string app:flags.2?BotApp = MessageAction; +messageActionSecureValuesSentMe#1b287353 values:Vector credentials:SecureCredentialsEncrypted = MessageAction; +messageActionSecureValuesSent#d95c6154 types:Vector = MessageAction; +messageActionContactSignUp#f3f25f76 = MessageAction; +messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; +messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; +messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; +messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; +messageActionSetChatTheme#b91bbd3a theme:ChatTheme = MessageAction; +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; +messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; +messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; +messageActionGiftPremium#48e91302 flags:# currency:string amount:long days:int crypto_currency:flags.0?string crypto_amount:flags.0?long message:flags.1?TextWithEntities = MessageAction; +messageActionTopicCreate#d999256 flags:# title_missing:flags.1?true title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; +messageActionSuggestProfilePhoto#57de635e photo:Photo = MessageAction; +messageActionRequestedPeer#31518e9b button_id:int peers:Vector = MessageAction; +messageActionSetChatWallPaper#5060a3f4 flags:# same:flags.0?true for_both:flags.1?true wallpaper:WallPaper = MessageAction; +messageActionGiftCode#31c48347 flags:# via_giveaway:flags.0?true unclaimed:flags.5?true boost_peer:flags.1?Peer days:int slug:string currency:flags.2?string amount:flags.2?long crypto_currency:flags.3?string crypto_amount:flags.3?long message:flags.4?TextWithEntities = MessageAction; +messageActionGiveawayLaunch#a80f51e4 flags:# stars:flags.0?long = MessageAction; +messageActionGiveawayResults#87e2f155 flags:# stars:flags.0?true winners_count:int unclaimed_count:int = MessageAction; +messageActionBoostApply#cc02aa6d boosts:int = MessageAction; +messageActionRequestedPeerSentMe#93b31848 button_id:int peers:Vector = MessageAction; +messageActionPaymentRefunded#41b3e202 flags:# peer:Peer currency:string total_amount:long payload:flags.0?bytes charge:PaymentCharge = MessageAction; +messageActionGiftStars#45d5b021 flags:# currency:string amount:long stars:long crypto_currency:flags.0?string crypto_amount:flags.0?long transaction_id:flags.1?string = MessageAction; +messageActionPrizeStars#b00c47a2 flags:# unclaimed:flags.0?true stars:long transaction_id:string boost_peer:Peer giveaway_msg_id:int = MessageAction; +messageActionStarGift#ea2c31d3 flags:# name_hidden:flags.0?true saved:flags.2?true converted:flags.3?true upgraded:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true prepaid_upgrade:flags.13?true upgrade_separate:flags.16?true auction_acquired:flags.17?true gift:StarGift message:flags.1?TextWithEntities convert_stars:flags.4?long upgrade_msg_id:flags.5?int upgrade_stars:flags.8?long from_id:flags.11?Peer peer:flags.12?Peer saved_id:flags.12?long prepaid_upgrade_hash:flags.14?string gift_msg_id:flags.15?int to_id:flags.18?Peer gift_num:flags.19?int = MessageAction; +messageActionStarGiftUnique#95728543 flags:# upgrade:flags.0?true transferred:flags.1?true saved:flags.2?true refunded:flags.5?true prepaid_upgrade:flags.11?true assigned:flags.13?true from_offer:flags.14?true gift:StarGift can_export_at:flags.3?int transfer_stars:flags.4?long from_id:flags.6?Peer peer:flags.7?Peer saved_id:flags.7?long resale_amount:flags.8?StarsAmount can_transfer_at:flags.9?int can_resell_at:flags.10?int drop_original_details_stars:flags.12?long = MessageAction; +messageActionPaidMessagesRefunded#ac1f1fcd count:int stars:long = MessageAction; +messageActionPaidMessagesPrice#84b88578 flags:# broadcast_messages_allowed:flags.0?true stars:long = MessageAction; +messageActionConferenceCall#2ffe2f7a flags:# missed:flags.0?true active:flags.1?true video:flags.4?true call_id:long duration:flags.2?int other_participants:flags.3?Vector = MessageAction; +messageActionTodoCompletions#cc7c5c89 completed:Vector incompleted:Vector = MessageAction; +messageActionTodoAppendTasks#c7edbc83 list:Vector = MessageAction; +messageActionSuggestedPostApproval#ee7a1596 flags:# rejected:flags.0?true balance_too_low:flags.1?true reject_comment:flags.2?string schedule_date:flags.3?int price:flags.4?StarsAmount = MessageAction; +messageActionSuggestedPostSuccess#95ddcf69 price:StarsAmount = MessageAction; +messageActionSuggestedPostRefund#69f916f8 flags:# payer_initiated:flags.0?true = MessageAction; +messageActionGiftTon#a8a3c699 flags:# currency:string amount:long crypto_currency:string crypto_amount:long transaction_id:flags.0?string = MessageAction; +messageActionSuggestBirthday#2c8f2a25 birthday:Birthday = MessageAction; +messageActionStarGiftPurchaseOffer#774278d4 flags:# accepted:flags.0?true declined:flags.1?true gift:StarGift price:StarsAmount expires_at:int = MessageAction; +messageActionStarGiftPurchaseOfferDeclined#73ada76b flags:# expired:flags.0?true gift:StarGift price:StarsAmount = MessageAction; + +dialog#d58a08c6 flags:# pinned:flags.2?true unread_mark:flags.3?true view_forum_as_messages:flags.6?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; +dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; + +photoEmpty#2331b22d id:long = Photo; +photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; + +photoSizeEmpty#e17e23c type:string = PhotoSize; +photoSize#75c78e60 type:string w:int h:int size:int = PhotoSize; +photoCachedSize#21e1ad6 type:string w:int h:int bytes:bytes = PhotoSize; +photoStrippedSize#e0b0bc2e type:string bytes:bytes = PhotoSize; +photoSizeProgressive#fa3efb95 type:string w:int h:int sizes:Vector = PhotoSize; +photoPathSize#d8214d41 type:string bytes:bytes = PhotoSize; + +geoPointEmpty#1117dd5f = GeoPoint; +geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint; + +auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; +auth.sentCodeSuccess#2390fe44 authorization:auth.Authorization = auth.SentCode; +auth.sentCodePaymentRequired#e0955a3c store_product:string phone_code_hash:string support_email_address:string support_email_subject:string currency:string amount:long = auth.SentCode; + +auth.authorization#2ea2c0d4 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int future_auth_token:flags.2?bytes user:User = auth.Authorization; +auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; + +auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; + +inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer; +inputNotifyUsers#193b4417 = InputNotifyPeer; +inputNotifyChats#4a95e84e = InputNotifyPeer; +inputNotifyBroadcasts#b1db7c7e = InputNotifyPeer; +inputNotifyForumTopic#5c467992 peer:InputPeer top_msg_id:int = InputNotifyPeer; + +inputPeerNotifySettings#cacb6ae2 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_sound:flags.8?NotificationSound = InputPeerNotifySettings; + +peerNotifySettings#99622c0c flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int ios_sound:flags.3?NotificationSound android_sound:flags.4?NotificationSound other_sound:flags.5?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_ios_sound:flags.8?NotificationSound stories_android_sound:flags.9?NotificationSound stories_other_sound:flags.10?NotificationSound = PeerNotifySettings; + +peerSettings#f47741f7 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true business_bot_paused:flags.11?true business_bot_can_reply:flags.12?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int business_bot_id:flags.13?long business_bot_manage_url:flags.13?string charge_paid_message_stars:flags.14?long registration_month:flags.15?string phone_country:flags.16?string name_change_date:flags.17?int photo_change_date:flags.18?int = PeerSettings; + +wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; +wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; + +inputReportReasonSpam#58dbcab8 = ReportReason; +inputReportReasonViolence#1e22c78d = ReportReason; +inputReportReasonPornography#2e59d922 = ReportReason; +inputReportReasonChildAbuse#adf44ee3 = ReportReason; +inputReportReasonOther#c1e4a2b1 = ReportReason; +inputReportReasonCopyright#9b89f93a = ReportReason; +inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; +inputReportReasonFake#f5ddd6e7 = ReportReason; +inputReportReasonIllegalDrugs#a8eb2be = ReportReason; +inputReportReasonPersonalDetails#9ec7863d = ReportReason; + +userFull#a02bc13e flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true voice_messages_forbidden:flags.20?true translations_disabled:flags.23?true stories_pinned_available:flags.26?true blocked_my_stories_from:flags.27?true wallpaper_overridden:flags.28?true contact_require_premium:flags.29?true read_dates_private:flags.30?true flags2:# sponsored_enabled:flags2.7?true can_view_revenue:flags2.9?true bot_can_manage_emoji_status:flags2.10?true display_gifts_button:flags2.16?true id:long about:flags.1?string settings:PeerSettings personal_photo:flags.21?Photo profile_photo:flags.2?Photo fallback_photo:flags.22?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme:flags.15?ChatTheme private_forward_name:flags.16?string bot_group_admin_rights:flags.17?ChatAdminRights bot_broadcast_admin_rights:flags.18?ChatAdminRights wallpaper:flags.24?WallPaper stories:flags.25?PeerStories business_work_hours:flags2.0?BusinessWorkHours business_location:flags2.1?BusinessLocation business_greeting_message:flags2.2?BusinessGreetingMessage business_away_message:flags2.3?BusinessAwayMessage business_intro:flags2.4?BusinessIntro birthday:flags2.5?Birthday personal_channel_id:flags2.6?long personal_channel_message:flags2.6?int stargifts_count:flags2.8?int starref_program:flags2.11?StarRefProgram bot_verification:flags2.12?BotVerification send_paid_messages_stars:flags2.14?long disallowed_gifts:flags2.15?DisallowedGiftsSettings stars_rating:flags2.17?StarsRating stars_my_pending_rating:flags2.18?StarsRating stars_my_pending_rating_date:flags2.18?int main_tab:flags2.20?ProfileTab saved_music:flags2.21?Document note:flags2.22?TextWithEntities = UserFull; + +contact#145ade0b user_id:long mutual:Bool = Contact; + +importedContact#c13e3c50 user_id:long client_id:long = ImportedContact; + +contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus; + +contacts.contactsNotModified#b74ba9d2 = contacts.Contacts; +contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts; + +contacts.importedContacts#77d01c3b imported:Vector popular_invites:Vector retry_contacts:Vector users:Vector = contacts.ImportedContacts; + +contacts.blocked#ade1591 blocked:Vector chats:Vector users:Vector = contacts.Blocked; +contacts.blockedSlice#e1664194 count:int blocked:Vector chats:Vector users:Vector = contacts.Blocked; + +messages.dialogs#15ba6c40 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsNotModified#f0e3e596 count:int = messages.Dialogs; + +messages.messages#1d73e7ea messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesSlice#5f206716 flags:# inexact:flags.1?true count:int next_rate:flags.0?int offset_id_offset:flags.2?int search_flood:flags.3?SearchPostsFlood messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.channelMessages#c776ba4e flags:# inexact:flags.1?true pts:int count:int offset_id_offset:flags.2?int messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesNotModified#74535f21 count:int = messages.Messages; + +messages.chats#64ff9fd5 chats:Vector = messages.Chats; +messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; + +messages.chatFull#e5d7d19c full_chat:ChatFull chats:Vector users:Vector = messages.ChatFull; + +messages.affectedHistory#b45c69d1 pts:int pts_count:int offset:int = messages.AffectedHistory; + +inputMessagesFilterEmpty#57e2f66c = MessagesFilter; +inputMessagesFilterPhotos#9609a51c = MessagesFilter; +inputMessagesFilterVideo#9fc00e65 = MessagesFilter; +inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; +inputMessagesFilterDocument#9eddf188 = MessagesFilter; +inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; +inputMessagesFilterGif#ffc86587 = MessagesFilter; +inputMessagesFilterVoice#50f5c392 = MessagesFilter; +inputMessagesFilterMusic#3751b49e = MessagesFilter; +inputMessagesFilterChatPhotos#3a20ecb8 = MessagesFilter; +inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFilter; +inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; +inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; +inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; +inputMessagesFilterGeo#e7026d0d = MessagesFilter; +inputMessagesFilterContacts#e062db83 = MessagesFilter; +inputMessagesFilterPinned#1bb00451 = MessagesFilter; + +updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; +updateMessageID#4e90bfd6 id:int random_id:long = Update; +updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; +updateUserTyping#2a17bf5c flags:# user_id:long top_msg_id:flags.0?int action:SendMessageAction = Update; +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; +updateChatParticipants#7761198 participants:ChatParticipants = Update; +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; +updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector = Update; +updateNewAuthorization#8951abef flags:# unconfirmed:flags.0?true hash:long date:flags.0?int device:flags.0?string location:flags.0?string = Update; +updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; +updateEncryptedChatTyping#1710f156 chat_id:int = Update; +updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; +updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; +updateDcOptions#8e5e9873 dc_options:Vector = Update; +updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; +updateServiceNotification#ebe46819 flags:# popup:flags.0?true invert_media:flags.2?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; +updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; +updateUserPhone#5492a13 user_id:long phone:string = Update; +updateReadHistoryInbox#9e84bc99 flags:# folder_id:flags.0?int peer:Peer top_msg_id:flags.1?int max_id:int still_unread_count:int pts:int pts_count:int = Update; +updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; +updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; +updateReadMessagesContents#f8227181 flags:# messages:Vector pts:int pts_count:int date:flags.0?int = Update; +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; +updateChannel#635b4c09 channel_id:long = Update; +updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; +updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; +updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Update; +updateStickerSets#31c24808 flags:# masks:flags.0?true emojis:flags.1?true = Update; +updateSavedGifs#9375341e = Update; +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; +updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; +updateDraftMessage#edfc111e flags:# peer:Peer top_msg_id:flags.0?int saved_peer_id:flags.1?Peer draft:DraftMessage = Update; +updateReadFeaturedStickers#571d2742 = Update; +updateRecentStickers#9a422c20 = Update; +updateConfig#a229dd06 = Update; +updatePtsChanged#3354678f = Update; +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; +updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; +updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; +updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; +updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; +updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; +updateLangPackTooLong#46560264 lang_code:string = Update; +updateLangPack#56022f4d difference:LangPackDifference = Update; +updateFavedStickers#e511996d = Update; +updateChannelReadMessagesContents#25f324f7 flags:# channel_id:long top_msg_id:flags.0?int saved_peer_id:flags.1?Peer messages:Vector = Update; +updateContactsReset#7084a7be = Update; +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; +updateDialogUnreadMark#b658f23e flags:# unread:flags.0?true peer:DialogPeer saved_peer_id:flags.1?Peer = Update; +updateMessagePoll#aca1657b flags:# poll_id:long poll:flags.0?Poll results:PollResults = Update; +updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; +updateFolderPeers#19360dc0 folder_peers:Vector pts:int pts_count:int = Update; +updatePeerSettings#6a7e7366 peer:Peer settings:PeerSettings = Update; +updatePeerLocated#b4afcfb0 peers:Vector = Update; +updateNewScheduledMessage#39a51dfb message:Message = Update; +updateDeleteScheduledMessages#f2a71983 flags:# peer:Peer messages:Vector sent_messages:flags.0?Vector = Update; +updateTheme#8216fba3 theme:Theme = Update; +updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; +updateLoginToken#564fe691 = Update; +updateMessagePollVote#24f40e77 poll_id:long peer:Peer options:Vector qts:int = Update; +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; +updateDialogFilterOrder#a5d72105 order:Vector = Update; +updateDialogFilters#3504914f = Update; +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; +updatePeerBlocked#ebe07752 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer = Update; +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; +updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChat#f89a6a4e chat_id:long = Update; +updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; +updateGroupCall#9d2216e0 flags:# live_story:flags.2?true peer:flags.1?Peer call:GroupCall = Update; +updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateChannelParticipant#985d3abb flags:# via_chatlist:flags.3?true channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; +updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; +updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#11dfa986 peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int = Update; +updateMessageReactions#1e297bfa flags:# peer:Peer msg_id:int top_msg_id:flags.0?int saved_peer_id:flags.1?Peer reactions:MessageReactions = Update; +updateAttachMenuBots#17b7a20b = Update; +updateWebViewResultSent#1592b79d query_id:long = Update; +updateBotMenuButton#14b85813 bot_id:long button:BotMenuButton = Update; +updateSavedRingtones#74d8be99 = Update; +updateTranscribedAudio#84cd5a flags:# pending:flags.0?true peer:Peer msg_id:int transcription_id:long text:string = Update; +updateReadFeaturedEmojiStickers#fb4c496c = Update; +updateUserEmojiStatus#28373599 user_id:long emoji_status:EmojiStatus = Update; +updateRecentEmojiStatuses#30f443db = Update; +updateRecentReactions#6f7863f4 = Update; +updateMoveStickerSetToTop#86fccf85 flags:# masks:flags.0?true emojis:flags.1?true stickerset:long = Update; +updateMessageExtendedMedia#d5a41724 peer:Peer msg_id:int extended_media:Vector = Update; +updateUser#20529438 user_id:long = Update; +updateAutoSaveSettings#ec05b097 = Update; +updateStory#75b3b798 peer:Peer story:StoryItem = Update; +updateReadStories#f74e932b peer:Peer max_id:int = Update; +updateStoryID#1bf335b9 id:int random_id:long = Update; +updateStoriesStealthMode#2c084dc1 stealth_mode:StoriesStealthMode = Update; +updateSentStoryReaction#7d627683 peer:Peer story_id:int reaction:Reaction = Update; +updateBotChatBoost#904dd49c peer:Peer boost:Boost qts:int = Update; +updateChannelViewForumAsMessages#7b68920 channel_id:long enabled:Bool = Update; +updatePeerWallpaper#ae3f101d flags:# wallpaper_overridden:flags.1?true peer:Peer wallpaper:flags.0?WallPaper = Update; +updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer old_reactions:Vector new_reactions:Vector qts:int = Update; +updateBotMessageReactions#9cb7759 peer:Peer msg_id:int date:int reactions:Vector qts:int = Update; +updateSavedDialogPinned#aeaf9e74 flags:# pinned:flags.0?true peer:DialogPeer = Update; +updatePinnedSavedDialogs#686c85a6 flags:# order:flags.0?Vector = Update; +updateSavedReactionTags#39c67432 = Update; +updateSmsJob#f16269d4 job_id:string = Update; +updateQuickReplies#f9470ab2 quick_replies:Vector = Update; +updateNewQuickReply#f53da717 quick_reply:QuickReply = Update; +updateDeleteQuickReply#53e6f1ec shortcut_id:int = Update; +updateQuickReplyMessage#3e050d0f message:Message = Update; +updateDeleteQuickReplyMessages#566fe7cd shortcut_id:int messages:Vector = Update; +updateBotBusinessConnect#8ae5c97a connection:BotBusinessConnection qts:int = Update; +updateBotNewBusinessMessage#9ddb347c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotEditBusinessMessage#7df587c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotDeleteBusinessMessage#a02a982e connection_id:string peer:Peer messages:Vector qts:int = Update; +updateNewStoryReaction#1824e40b story_id:int peer:Peer reaction:Reaction = Update; +updateStarsBalance#4e80a379 balance:StarsAmount = Update; +updateBusinessBotCallbackQuery#1ea2fda7 flags:# query_id:long user_id:long connection_id:string message:Message reply_to_message:flags.2?Message chat_instance:long data:flags.0?bytes = Update; +updateStarsRevenueStatus#a584b019 peer:Peer status:StarsRevenueStatus = Update; +updateBotPurchasedPaidMedia#283bd312 user_id:long payload:string qts:int = Update; +updatePaidReactionPrivacy#8b725fce private:PaidReactionPrivacy = Update; +updateSentPhoneCode#504aa18f sent_code:auth.SentCode = Update; +updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector next_offset:int = Update; +updateReadMonoForumInbox#77b0e372 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateReadMonoForumOutbox#a4a79376 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateMonoForumNoPaidException#9f812b08 flags:# exception:flags.0?true channel_id:long saved_peer_id:Peer = Update; +updateGroupCallMessage#d8326f0d call:InputGroupCall message:GroupCallMessage = Update; +updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update; +updatePinnedForumTopic#683b2c52 flags:# pinned:flags.0?true peer:Peer topic_id:int = Update; +updatePinnedForumTopics#def143d0 flags:# peer:Peer order:flags.0?Vector = Update; +updateDeleteGroupCallMessages#3e85e92c call:InputGroupCall messages:Vector = Update; +updateStarGiftAuctionState#48e246c2 gift_id:long state:StarGiftAuctionState = Update; +updateStarGiftAuctionUserState#dc58f31e gift_id:long user_state:StarGiftAuctionUserState = Update; +updateEmojiGameInfo#fb9c547a info:messages.EmojiGameInfo = Update; + +updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; + +updates.differenceEmpty#5d75a138 date:int seq:int = updates.Difference; +updates.difference#f49ca0 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector state:updates.State = updates.Difference; +updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector intermediate_state:updates.State = updates.Difference; +updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; + +updatesTooLong#e317af7e = Updates; +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShort#78d4dec1 update:Update date:int = Updates; +updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; +updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; +updateShortSentMessage#9015e101 flags:# out:flags.1?true id:int pts:int pts_count:int date:int media:flags.9?MessageMedia entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +photos.photos#8dca6aa5 photos:Vector users:Vector = photos.Photos; +photos.photosSlice#15051f54 count:int photos:Vector users:Vector = photos.Photos; + +photos.photo#20212ca8 photo:Photo users:Vector = photos.Photo; + +upload.file#96a18d5 type:storage.FileType mtime:int bytes:bytes = upload.File; +upload.fileCdnRedirect#f18cda44 dc_id:int file_token:bytes encryption_key:bytes encryption_iv:bytes file_hashes:Vector = upload.File; + +dcOption#18b7a10d flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true this_port_only:flags.5?true id:int ip_address:string port:int secret:flags.10?bytes = DcOption; + +config#cc1a241e flags:# default_p2p_contacts:flags.3?true preload_featured_stickers:flags.4?true revoke_pm_inbox:flags.6?true blocked_mode:flags.8?true force_try_ipv6:flags.14?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector dc_txt_domain_name:string chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int push_chat_period_ms:int push_chat_limit:int edit_time_limit:int revoke_time_limit:int revoke_pm_time_limit:int rating_e_decay:int stickers_recent_limit:int channels_read_media_period:int tmp_sessions:flags.0?int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string autoupdate_url_prefix:flags.7?string gif_search_username:flags.9?string venue_search_username:flags.10?string img_search_username:flags.11?string static_maps_provider:flags.12?string caption_length_max:int message_length_max:int webfile_dc_id:int suggested_lang_code:flags.2?string lang_pack_version:flags.2?int base_lang_pack_version:flags.2?int reactions_default:flags.15?Reaction autologin_token:flags.16?string = Config; + +nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; + +help.appUpdate#ccbbce30 flags:# can_not_skip:flags.0?true id:int version:string text:string entities:Vector document:flags.1?Document url:flags.2?string sticker:flags.3?Document = help.AppUpdate; +help.noAppUpdate#c45a6536 = help.AppUpdate; + +help.inviteText#18cb9f78 message:string = help.InviteText; + +encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; +encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; + +inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; + +encryptedFileEmpty#c21f497e = EncryptedFile; +encryptedFile#a8008cd8 id:long access_hash:long size:long dc_id:int key_fingerprint:int = EncryptedFile; + +inputEncryptedFileEmpty#1837c364 = InputEncryptedFile; +inputEncryptedFileUploaded#64bd0306 id:long parts:int md5_checksum:string key_fingerprint:int = InputEncryptedFile; +inputEncryptedFile#5a17b5e5 id:long access_hash:long = InputEncryptedFile; +inputEncryptedFileBigUploaded#2dc173c8 id:long parts:int key_fingerprint:int = InputEncryptedFile; + +encryptedMessage#ed18c118 random_id:long chat_id:int date:int bytes:bytes file:EncryptedFile = EncryptedMessage; +encryptedMessageService#23734b06 random_id:long chat_id:int date:int bytes:bytes = EncryptedMessage; + +messages.dhConfigNotModified#c0e24635 random:bytes = messages.DhConfig; +messages.dhConfig#2c221edd g:int p:bytes version:int random:bytes = messages.DhConfig; + +messages.sentEncryptedMessage#560f8935 date:int = messages.SentEncryptedMessage; +messages.sentEncryptedFile#9493ff32 date:int file:EncryptedFile = messages.SentEncryptedMessage; + +inputDocumentEmpty#72f0eaae = InputDocument; +inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; + +documentEmpty#36f8c871 id:long = Document; +document#8fd4c4d8 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:long thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; + +help.support#17c6b5f6 phone_number:string user:User = help.Support; + +notifyPeer#9fd40bd8 peer:Peer = NotifyPeer; +notifyUsers#b4c83b4c = NotifyPeer; +notifyChats#c007cec3 = NotifyPeer; +notifyBroadcasts#d612e8ef = NotifyPeer; +notifyForumTopic#226e6308 peer:Peer top_msg_id:int = NotifyPeer; + +sendMessageTypingAction#16bf744e = SendMessageAction; +sendMessageCancelAction#fd5ec8f5 = SendMessageAction; +sendMessageRecordVideoAction#a187d66f = SendMessageAction; +sendMessageUploadVideoAction#e9763aec progress:int = SendMessageAction; +sendMessageRecordAudioAction#d52f73f7 = SendMessageAction; +sendMessageUploadAudioAction#f351d7ab progress:int = SendMessageAction; +sendMessageUploadPhotoAction#d1d34a26 progress:int = SendMessageAction; +sendMessageUploadDocumentAction#aa0cd9e4 progress:int = SendMessageAction; +sendMessageGeoLocationAction#176f8ba1 = SendMessageAction; +sendMessageChooseContactAction#628cbc6f = SendMessageAction; +sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; +sendMessageRecordRoundAction#88f27fbc = SendMessageAction; +sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; +speakingInGroupCallAction#d92c2285 = SendMessageAction; +sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; +sendMessageTextDraftAction#376d975c random_id:long text:TextWithEntities = SendMessageAction; + +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; + +inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; +inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; +inputPrivacyKeyPhoneCall#fabadc5f = InputPrivacyKey; +inputPrivacyKeyPhoneP2P#db9e70d2 = InputPrivacyKey; +inputPrivacyKeyForwards#a4dd4c08 = InputPrivacyKey; +inputPrivacyKeyProfilePhoto#5719bacc = InputPrivacyKey; +inputPrivacyKeyPhoneNumber#352dafa = InputPrivacyKey; +inputPrivacyKeyAddedByPhone#d1219bdd = InputPrivacyKey; +inputPrivacyKeyVoiceMessages#aee69d68 = InputPrivacyKey; +inputPrivacyKeyAbout#3823cc40 = InputPrivacyKey; +inputPrivacyKeyBirthday#d65a11cc = InputPrivacyKey; +inputPrivacyKeyStarGiftsAutoSave#e1732341 = InputPrivacyKey; +inputPrivacyKeyNoPaidMessages#bdc597b4 = InputPrivacyKey; +inputPrivacyKeySavedMusic#4dbe9226 = InputPrivacyKey; + +privacyKeyStatusTimestamp#bc2eab30 = PrivacyKey; +privacyKeyChatInvite#500e6dfa = PrivacyKey; +privacyKeyPhoneCall#3d662b7b = PrivacyKey; +privacyKeyPhoneP2P#39491cc8 = PrivacyKey; +privacyKeyForwards#69ec56a3 = PrivacyKey; +privacyKeyProfilePhoto#96151fed = PrivacyKey; +privacyKeyPhoneNumber#d19ae46d = PrivacyKey; +privacyKeyAddedByPhone#42ffd42b = PrivacyKey; +privacyKeyVoiceMessages#697f414 = PrivacyKey; +privacyKeyAbout#a486b761 = PrivacyKey; +privacyKeyBirthday#2000a518 = PrivacyKey; +privacyKeyStarGiftsAutoSave#2ca4fdf8 = PrivacyKey; +privacyKeyNoPaidMessages#17d348d2 = PrivacyKey; +privacyKeySavedMusic#ff7a571b = PrivacyKey; + +inputPrivacyValueAllowContacts#d09e07b = InputPrivacyRule; +inputPrivacyValueAllowAll#184b35ce = InputPrivacyRule; +inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; +inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; +inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; +inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; +inputPrivacyValueAllowCloseFriends#2f453e49 = InputPrivacyRule; +inputPrivacyValueAllowPremium#77cdc9f1 = InputPrivacyRule; +inputPrivacyValueAllowBots#5a4fcce5 = InputPrivacyRule; +inputPrivacyValueDisallowBots#c4e57915 = InputPrivacyRule; + +privacyValueAllowContacts#fffe1bac = PrivacyRule; +privacyValueAllowAll#65427b82 = PrivacyRule; +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; +privacyValueDisallowContacts#f888fa1a = PrivacyRule; +privacyValueDisallowAll#8b73e763 = PrivacyRule; +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; +privacyValueAllowCloseFriends#f7e8d89b = PrivacyRule; +privacyValueAllowPremium#ece9814b = PrivacyRule; +privacyValueAllowBots#21461b5d = PrivacyRule; +privacyValueDisallowBots#f6a5f82f = PrivacyRule; + +account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; + +accountDaysTTL#b8d0afdf days:int = AccountDaysTTL; + +documentAttributeImageSize#6c37c15c w:int h:int = DocumentAttribute; +documentAttributeAnimated#11b58939 = DocumentAttribute; +documentAttributeSticker#6319d612 flags:# mask:flags.1?true alt:string stickerset:InputStickerSet mask_coords:flags.0?MaskCoords = DocumentAttribute; +documentAttributeVideo#43c57c48 flags:# round_message:flags.0?true supports_streaming:flags.1?true nosound:flags.3?true duration:double w:int h:int preload_prefix_size:flags.2?int video_start_ts:flags.4?double video_codec:flags.5?string = DocumentAttribute; +documentAttributeAudio#9852f9c6 flags:# voice:flags.10?true duration:int title:flags.0?string performer:flags.1?string waveform:flags.2?bytes = DocumentAttribute; +documentAttributeFilename#15590068 file_name:string = DocumentAttribute; +documentAttributeHasStickers#9801d2f7 = DocumentAttribute; +documentAttributeCustomEmoji#fd149899 flags:# free:flags.0?true text_color:flags.1?true alt:string stickerset:InputStickerSet = DocumentAttribute; + +messages.stickersNotModified#f1749a22 = messages.Stickers; +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; + +stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; + +messages.allStickersNotModified#e86602c3 = messages.AllStickers; +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; + +messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; + +webPageEmpty#211a1788 flags:# id:long url:flags.0?string = WebPage; +webPagePending#b0d13e47 flags:# id:long url:flags.0?string date:int = WebPage; +webPage#e89c45b2 flags:# has_large_media:flags.13?true video_cover_photo:flags.14?true id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; + +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true unconfirmed:flags.5?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; + +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; + +account.password#957b50fb flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int login_email_pattern:flags.6?string = account.Password; + +account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; + +account.passwordInputSettings#c23727c9 flags:# new_algo:flags.0?PasswordKdfAlgo new_password_hash:flags.0?bytes hint:flags.0?string email:flags.1?string new_secure_settings:flags.2?SecureSecretSettings = account.PasswordInputSettings; + +auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; + +receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; + +chatInviteExported#a22cbd96 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int subscription_expired:flags.10?int title:flags.8?string subscription_pricing:flags.9?StarsSubscriptionPricing = ExportedChatInvite; +chatInvitePublicJoinRequests#ed107ab7 = ExportedChatInvite; + +chatInviteAlready#5a686d7c chat:Chat = ChatInvite; +chatInvite#5c9d3702 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true verified:flags.7?true scam:flags.8?true fake:flags.9?true can_refulfill_subscription:flags.11?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector color:int subscription_pricing:flags.10?StarsSubscriptionPricing subscription_form_id:flags.12?long bot_verification:flags.13?BotVerification = ChatInvite; +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; + +inputStickerSetEmpty#ffb62b95 = InputStickerSet; +inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; +inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; +inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; +inputStickerSetPremiumGifts#c88b3b02 = InputStickerSet; +inputStickerSetEmojiGenericAnimations#4c4d4ce = InputStickerSet; +inputStickerSetEmojiDefaultStatuses#29d0f5ee = InputStickerSet; +inputStickerSetEmojiDefaultTopicIcons#44c1f8e9 = InputStickerSet; +inputStickerSetEmojiChannelDefaultStatuses#49748553 = InputStickerSet; +inputStickerSetTonGifts#1cf671a0 = InputStickerSet; + +stickerSet#2dd14edc flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true emojis:flags.7?true text_color:flags.9?true channel_emoji_status:flags.10?true creator:flags.11?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int thumb_document_id:flags.8?long count:int hash:int = StickerSet; + +messages.stickerSet#6e153f16 set:StickerSet packs:Vector keywords:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; + +botCommand#c27ac8c7 command:string description:string = BotCommand; + +botInfo#4d8a0299 flags:# has_preview_medias:flags.6?true user_id:flags.0?long description:flags.1?string description_photo:flags.4?Photo description_document:flags.5?Document commands:flags.2?Vector menu_button:flags.3?BotMenuButton privacy_policy_url:flags.7?string app_settings:flags.8?BotAppSettings verifier_settings:flags.9?BotVerifierSettings = BotInfo; + +keyboardButton#a2fa4880 text:string = KeyboardButton; +keyboardButtonUrl#258aff05 text:string url:string = KeyboardButton; +keyboardButtonCallback#35bbdb6b flags:# requires_password:flags.0?true text:string data:bytes = KeyboardButton; +keyboardButtonRequestPhone#b16a6c29 text:string = KeyboardButton; +keyboardButtonRequestGeoLocation#fc796b3f text:string = KeyboardButton; +keyboardButtonSwitchInline#93b9fbb5 flags:# same_peer:flags.0?true text:string query:string peer_types:flags.1?Vector = KeyboardButton; +keyboardButtonGame#50f41ccf text:string = KeyboardButton; +keyboardButtonBuy#afd93fbb text:string = KeyboardButton; +keyboardButtonUrlAuth#10b78d29 flags:# text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +inputKeyboardButtonUrlAuth#d02e7fd4 flags:# request_write_access:flags.0?true text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +keyboardButtonRequestPoll#bbc7515d flags:# quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#e988037b text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#308660c1 text:string user_id:long = KeyboardButton; +keyboardButtonWebView#13767230 text:string url:string = KeyboardButton; +keyboardButtonSimpleWebView#a0c0505c text:string url:string = KeyboardButton; +keyboardButtonRequestPeer#53d7bfd8 text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +inputKeyboardButtonRequestPeer#c9662d05 flags:# name_requested:flags.0?true username_requested:flags.1?true photo_requested:flags.2?true text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +keyboardButtonCopy#75d2698e text:string copy_text:string = KeyboardButton; + +keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; + +replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true persistent:flags.4?true rows:Vector placeholder:flags.3?string = ReplyMarkup; +replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; + +messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; +messageEntityMention#fa04579d offset:int length:int = MessageEntity; +messageEntityHashtag#6f635b0d offset:int length:int = MessageEntity; +messageEntityBotCommand#6cef8ac7 offset:int length:int = MessageEntity; +messageEntityUrl#6ed02538 offset:int length:int = MessageEntity; +messageEntityEmail#64e475c2 offset:int length:int = MessageEntity; +messageEntityBold#bd610bc9 offset:int length:int = MessageEntity; +messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; +messageEntityCode#28a20571 offset:int length:int = MessageEntity; +messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; +messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; +inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; +messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; +messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; +messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; +messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; +messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; +messageEntityCustomEmoji#c8cf05f8 offset:int length:int document_id:long = MessageEntity; +messageEntityBlockquote#f1ccaaac flags:# collapsed:flags.0?true offset:int length:int = MessageEntity; + +inputChannelEmpty#ee8c1e86 = InputChannel; +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; + +contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; + +messageRange#ae30253 min_id:int max_id:int = MessageRange; + +updates.channelDifferenceEmpty#3e11affb flags:# final:flags.0?true pts:int timeout:flags.1?int = updates.ChannelDifference; +updates.channelDifferenceTooLong#a4bcc6fe flags:# final:flags.0?true timeout:flags.1?int dialog:Dialog messages:Vector chats:Vector users:Vector = updates.ChannelDifference; +updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int new_messages:Vector other_updates:Vector chats:Vector users:Vector = updates.ChannelDifference; + +channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; +channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; + +channelParticipant#cb397619 flags:# user_id:long date:int subscription_until_date:flags.0?int = ChannelParticipant; +channelParticipantSelf#4f607bef flags:# via_request:flags.0?true user_id:long inviter_id:long date:int subscription_until_date:flags.1?int = ChannelParticipant; +channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; +channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; +channelParticipantBanned#6df8014e flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights = ChannelParticipant; +channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; + +channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; +channelParticipantsAdmins#b4608969 = ChannelParticipantsFilter; +channelParticipantsKicked#a3b54985 q:string = ChannelParticipantsFilter; +channelParticipantsBots#b0d1865b = ChannelParticipantsFilter; +channelParticipantsBanned#1427a5e1 q:string = ChannelParticipantsFilter; +channelParticipantsSearch#656ac4b q:string = ChannelParticipantsFilter; +channelParticipantsContacts#bb6ae88d q:string = ChannelParticipantsFilter; +channelParticipantsMentions#e04b5ceb flags:# q:flags.0?string top_msg_id:flags.1?int = ChannelParticipantsFilter; + +channels.channelParticipants#9ab0feaf count:int participants:Vector chats:Vector users:Vector = channels.ChannelParticipants; +channels.channelParticipantsNotModified#f0173fe9 = channels.ChannelParticipants; + +channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector users:Vector = channels.ChannelParticipant; + +help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; + +messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; + +inputBotInlineMessageMediaAuto#3380c786 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaGeo#96929a85 flags:# geo_point:InputGeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaVenue#417bbf11 flags:# geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaContact#a6edbffd flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageGame#4b425864 flags:# reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaInvoice#d7e78225 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaWebPage#bddcc510 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true optional:flags.6?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineResult#88bf9319 flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?InputWebDocument content:flags.5?InputWebDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultPhoto#a8d864a7 id:string type:string photo:InputPhoto send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultDocument#fff8fdc4 flags:# id:string type:string title:flags.1?string description:flags.2?string document:InputDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultGame#4fa417f2 id:string short_name:string send_message:InputBotInlineMessage = InputBotInlineResult; + +botInlineMessageMediaAuto#764cf810 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageText#8c7f65e2 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaGeo#51846fd flags:# geo:GeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaVenue#8a86659c flags:# geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaContact#18d1cdc2 flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaInvoice#354a9b09 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument currency:string total_amount:long reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaWebPage#809ad9a6 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true manual:flags.7?true safe:flags.8?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineResult#11965f3a flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?WebDocument content:flags.5?WebDocument send_message:BotInlineMessage = BotInlineResult; +botInlineMediaResult#17db940b flags:# id:string type:string photo:flags.0?Photo document:flags.1?Document title:flags.2?string description:flags.3?string send_message:BotInlineMessage = BotInlineResult; + +messages.botResults#e021f2f6 flags:# gallery:flags.0?true query_id:long next_offset:flags.1?string switch_pm:flags.2?InlineBotSwitchPM switch_webview:flags.3?InlineBotWebView results:Vector cache_time:int users:Vector = messages.BotResults; + +exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; + +messageFwdHeader#4e4df4bb flags:# imported:flags.7?true saved_out:flags.11?true from_id:flags.0?Peer from_name:flags.5?string date:int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int saved_from_id:flags.8?Peer saved_from_name:flags.9?string saved_date:flags.10?int psa_type:flags.6?string = MessageFwdHeader; + +auth.codeTypeSms#72a3158c = auth.CodeType; +auth.codeTypeCall#741cd3e3 = auth.CodeType; +auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; + +auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; +auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; +auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; +auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; +auth.sentCodeTypeEmailCode#f450f59b flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int reset_available_period:flags.3?int reset_pending_date:flags.4?int = auth.SentCodeType; +auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; +auth.sentCodeTypeFirebaseSms#9fd736 flags:# nonce:flags.0?bytes play_integrity_project_id:flags.2?long play_integrity_nonce:flags.2?bytes receipt:flags.1?string push_timeout:flags.1?int length:int = auth.SentCodeType; +auth.sentCodeTypeSmsWord#a416ac81 flags:# beginning:flags.0?string = auth.SentCodeType; +auth.sentCodeTypeSmsPhrase#b37794af flags:# beginning:flags.0?string = auth.SentCodeType; + +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; + +messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; + +inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; + +inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; + +messages.peerDialogs#3371c354 dialogs:Vector messages:Vector chats:Vector users:Vector state:updates.State = messages.PeerDialogs; + +topPeer#edcdc05b peer:Peer rating:double = TopPeer; + +topPeerCategoryBotsPM#ab661b5b = TopPeerCategory; +topPeerCategoryBotsInline#148677e2 = TopPeerCategory; +topPeerCategoryCorrespondents#637b7ed = TopPeerCategory; +topPeerCategoryGroups#bd17a14a = TopPeerCategory; +topPeerCategoryChannels#161d9628 = TopPeerCategory; +topPeerCategoryPhoneCalls#1e76a78c = TopPeerCategory; +topPeerCategoryForwardUsers#a8406ca9 = TopPeerCategory; +topPeerCategoryForwardChats#fbeec0f0 = TopPeerCategory; +topPeerCategoryBotsApp#fd9e7bec = TopPeerCategory; + +topPeerCategoryPeers#fb834291 category:TopPeerCategory count:int peers:Vector = TopPeerCategoryPeers; + +contacts.topPeersNotModified#de266ef5 = contacts.TopPeers; +contacts.topPeers#70b772a8 categories:Vector chats:Vector users:Vector = contacts.TopPeers; +contacts.topPeersDisabled#b52c939d = contacts.TopPeers; + +draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; +draftMessage#96eaa5eb flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo message:string entities:flags.3?Vector media:flags.5?InputMedia date:int effect:flags.7?long suggested_post:flags.8?SuggestedPost = DraftMessage; + +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; +messages.featuredStickers#be382906 flags:# premium:flags.0?true hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; + +messages.recentStickersNotModified#b17f890 = messages.RecentStickers; +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; + +messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; + +messages.stickerSetInstallResultSuccess#38641628 = messages.StickerSetInstallResult; +messages.stickerSetInstallResultArchive#35e410a8 sets:Vector = messages.StickerSetInstallResult; + +stickerSetCovered#6410a5d2 set:StickerSet cover:Document = StickerSetCovered; +stickerSetMultiCovered#3407e51b set:StickerSet covers:Vector = StickerSetCovered; +stickerSetFullCovered#40d13c0e set:StickerSet packs:Vector keywords:Vector documents:Vector = StickerSetCovered; +stickerSetNoCovered#77b15d1c set:StickerSet = StickerSetCovered; + +maskCoords#aed6dbb2 n:int x:double y:double zoom:double = MaskCoords; + +inputStickeredMediaPhoto#4a992157 id:InputPhoto = InputStickeredMedia; +inputStickeredMediaDocument#438865b id:InputDocument = InputStickeredMedia; + +game#bdf9653b flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document = Game; + +inputGameID#32c3e77 id:long access_hash:long = InputGame; +inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; + +highScore#73a379eb pos:int user_id:long score:int = HighScore; + +messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; + +textEmpty#dc3d824f = RichText; +textPlain#744694e0 text:string = RichText; +textBold#6724abc4 text:RichText = RichText; +textItalic#d912a59c text:RichText = RichText; +textUnderline#c12622c4 text:RichText = RichText; +textStrike#9bf8bb95 text:RichText = RichText; +textFixed#6c3f19b9 text:RichText = RichText; +textUrl#3c2884c1 text:RichText url:string webpage_id:long = RichText; +textEmail#de5a0dd6 text:RichText email:string = RichText; +textConcat#7e6260d7 texts:Vector = RichText; +textSubscript#ed6a8504 text:RichText = RichText; +textSuperscript#c7fb5e01 text:RichText = RichText; +textMarked#34b8621 text:RichText = RichText; +textPhone#1ccb966a text:RichText phone:string = RichText; +textImage#81ccf4f document_id:long w:int h:int = RichText; +textAnchor#35553762 text:RichText name:string = RichText; + +pageBlockUnsupported#13567e8a = PageBlock; +pageBlockTitle#70abc3fd text:RichText = PageBlock; +pageBlockSubtitle#8ffa9a1f text:RichText = PageBlock; +pageBlockAuthorDate#baafe5e0 author:RichText published_date:int = PageBlock; +pageBlockHeader#bfd064ec text:RichText = PageBlock; +pageBlockSubheader#f12bb6e1 text:RichText = PageBlock; +pageBlockParagraph#467a0766 text:RichText = PageBlock; +pageBlockPreformatted#c070d93e text:RichText language:string = PageBlock; +pageBlockFooter#48870999 text:RichText = PageBlock; +pageBlockDivider#db20b188 = PageBlock; +pageBlockAnchor#ce0d37b0 name:string = PageBlock; +pageBlockList#e4e88011 items:Vector = PageBlock; +pageBlockBlockquote#263d7c26 text:RichText caption:RichText = PageBlock; +pageBlockPullquote#4f4456d3 text:RichText caption:RichText = PageBlock; +pageBlockPhoto#1759c560 flags:# photo_id:long caption:PageCaption url:flags.0?string webpage_id:flags.0?long = PageBlock; +pageBlockVideo#7c8fe7b6 flags:# autoplay:flags.0?true loop:flags.1?true video_id:long caption:PageCaption = PageBlock; +pageBlockCover#39f23300 cover:PageBlock = PageBlock; +pageBlockEmbed#a8718dc5 flags:# full_width:flags.0?true allow_scrolling:flags.3?true url:flags.1?string html:flags.2?string poster_photo_id:flags.4?long w:flags.5?int h:flags.5?int caption:PageCaption = PageBlock; +pageBlockEmbedPost#f259a80b url:string webpage_id:long author_photo_id:long author:string date:int blocks:Vector caption:PageCaption = PageBlock; +pageBlockCollage#65a0fa4d items:Vector caption:PageCaption = PageBlock; +pageBlockSlideshow#31f9590 items:Vector caption:PageCaption = PageBlock; +pageBlockChannel#ef1751b5 channel:Chat = PageBlock; +pageBlockAudio#804361ea audio_id:long caption:PageCaption = PageBlock; +pageBlockKicker#1e148390 text:RichText = PageBlock; +pageBlockTable#bf4dea82 flags:# bordered:flags.0?true striped:flags.1?true title:RichText rows:Vector = PageBlock; +pageBlockOrderedList#9a8ae1e1 items:Vector = PageBlock; +pageBlockDetails#76768bed flags:# open:flags.0?true blocks:Vector title:RichText = PageBlock; +pageBlockRelatedArticles#16115a96 title:RichText articles:Vector = PageBlock; +pageBlockMap#a44f3ef6 geo:GeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; + +phoneCallDiscardReasonMissed#85e42301 = PhoneCallDiscardReason; +phoneCallDiscardReasonDisconnect#e095c1a0 = PhoneCallDiscardReason; +phoneCallDiscardReasonHangup#57adc690 = PhoneCallDiscardReason; +phoneCallDiscardReasonBusy#faf7e8c9 = PhoneCallDiscardReason; +phoneCallDiscardReasonMigrateConferenceCall#9fbbf1f7 slug:string = PhoneCallDiscardReason; + +dataJSON#7d748d04 data:string = DataJSON; + +labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; + +invoice#49ee584 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true recurring:flags.9?true currency:string prices:Vector max_tip_amount:flags.8?long suggested_tip_amounts:flags.8?Vector terms_url:flags.10?string subscription_period:flags.11?int = Invoice; + +paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; + +postAddress#1e8caaeb street_line1:string street_line2:string city:string state:string country_iso2:string post_code:string = PostAddress; + +paymentRequestedInfo#909c3f94 flags:# name:flags.0?string phone:flags.1?string email:flags.2?string shipping_address:flags.3?PostAddress = PaymentRequestedInfo; + +paymentSavedCredentialsCard#cdc27a1f id:string title:string = PaymentSavedCredentials; + +webDocument#1c570ed1 url:string access_hash:long size:int mime_type:string attributes:Vector = WebDocument; +webDocumentNoProxy#f9c8bcc6 url:string size:int mime_type:string attributes:Vector = WebDocument; + +inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector = InputWebDocument; + +inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation; +inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation; +inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# small:flags.2?true document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation; + +upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; + +payments.paymentForm#a0058751 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON additional_methods:flags.6?Vector saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?Vector users:Vector = payments.PaymentForm; +payments.paymentFormStars#7bf6b15c flags:# form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice users:Vector = payments.PaymentForm; +payments.paymentFormStarGift#b425cfe1 form_id:long invoice:Invoice = payments.PaymentForm; + +payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; + +payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; +payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; + +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; +payments.paymentReceiptStars#dabbf83a flags:# date:int bot_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice currency:string total_amount:long transaction_id:string users:Vector = payments.PaymentReceipt; + +payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; + +inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; +inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsGooglePay#8ac32801 payment_token:DataJSON = InputPaymentCredentials; + +account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; + +shippingOption#b6213cdf id:string title:string prices:Vector = ShippingOption; + +inputStickerSetItem#32da9e9c flags:# document:InputDocument emoji:string mask_coords:flags.0?MaskCoords keywords:flags.1?string = InputStickerSetItem; + +inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; + +phoneCallEmpty#5366c915 id:long = PhoneCall; +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#30535af5 flags:# p2p_allowed:flags.5?true video:flags.6?true conference_supported:flags.8?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int custom_parameters:flags.7?DataJSON = PhoneCall; +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; + +phoneConnection#9cc123c7 flags:# tcp:flags.0?true id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; + +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; + +phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; + +upload.cdnFileReuploadNeeded#eea8e46e request_token:bytes = upload.CdnFile; +upload.cdnFile#a99fca4f bytes:bytes = upload.CdnFile; + +cdnPublicKey#c982eaba dc_id:int public_key:string = CdnPublicKey; + +cdnConfig#5725e40a public_keys:Vector = CdnConfig; + +langPackString#cad181f6 key:string value:string = LangPackString; +langPackStringPluralized#6c47ac9f flags:# key:string zero_value:flags.0?string one_value:flags.1?string two_value:flags.2?string few_value:flags.3?string many_value:flags.4?string other_value:string = LangPackString; +langPackStringDeleted#2979eeb2 key:string = LangPackString; + +langPackDifference#f385c1f6 lang_code:string from_version:int version:int strings:Vector = LangPackDifference; + +langPackLanguage#eeca5ce3 flags:# official:flags.0?true rtl:flags.2?true beta:flags.3?true name:string native_name:string lang_code:string base_lang_code:flags.1?string plural_code:string strings_count:int translated_count:int translations_url:string = LangPackLanguage; + +channelAdminLogEventActionChangeTitle#e6dfb825 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAbout#55188a2e prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsername#6a4afc38 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePhoto#434bd2af prev_photo:Photo new_photo:Photo = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleInvites#1b7907ae new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatures#26ae0971 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionUpdatePinned#e9e82c18 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionEditMessage#709b2405 prev_message:Message new_message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteMessage#42e047bb message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoin#183040d3 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantLeave#f89777f2 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantInvite#e31c34d8 participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleBan#e6d83d7e prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleAdmin#d5676710 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; +channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionDiscardGroupCall#db9f9140 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantMute#f92424d2 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantUnmute#e64429c0 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleGroupCallSetting#56d6a247 join_muted:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByInvite#fe9fc158 flags:# via_chatlist:flags.0?true invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteDelete#5a50fca4 invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePeerColor#5796e780 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeProfilePeerColor#5e477b25 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeWallpaper#31bb5d52 prev_value:WallPaper new_value:WallPaper = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStatus#3ea9feb1 prev_value:EmojiStatus new_value:EmojiStatus = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStickerSet#46d840ab prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatureProfiles#60a79c79 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantSubExtend#64642db3 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAutotranslation#c517f77e new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; + +channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; + +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true sub_extend:flags.18?true = ChannelAdminLogEventsFilter; + +popularContact#5ce14175 client_id:long importers:int = PopularContact; + +messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; + +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; +recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; + +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; + +account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; + +inputMessageID#a676a322 id:int = InputMessage; +inputMessageReplyTo#bad88395 id:int = InputMessage; +inputMessagePinned#86872538 = InputMessage; +inputMessageCallbackQuery#acfa1a7e id:int query_id:long = InputMessage; + +inputDialogPeer#fcaafeb7 peer:InputPeer = InputDialogPeer; +inputDialogPeerFolder#64600527 folder_id:int = InputDialogPeer; + +dialogPeer#e56dbf05 peer:Peer = DialogPeer; +dialogPeerFolder#514519e2 folder_id:int = DialogPeer; + +messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; + +fileHash#f39b035c offset:long limit:int hash:bytes = FileHash; + +inputClientProxy#75588b3f address:string port:int = InputClientProxy; + +help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; +help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; + +inputSecureFileUploaded#3334b0f0 id:long parts:int md5_checksum:string file_hash:bytes secret:bytes = InputSecureFile; +inputSecureFile#5367e5be id:long access_hash:long = InputSecureFile; + +secureFileEmpty#64199744 = SecureFile; +secureFile#7d09c27e id:long access_hash:long size:long dc_id:int date:int file_hash:bytes secret:bytes = SecureFile; + +secureData#8aeabec3 data:bytes data_hash:bytes secret:bytes = SecureData; + +securePlainPhone#7d6099dd phone:string = SecurePlainData; +securePlainEmail#21ec5a5f email:string = SecurePlainData; + +secureValueTypePersonalDetails#9d2a81e3 = SecureValueType; +secureValueTypePassport#3dac6a00 = SecureValueType; +secureValueTypeDriverLicense#6e425c4 = SecureValueType; +secureValueTypeIdentityCard#a0d0744b = SecureValueType; +secureValueTypeInternalPassport#99a48f23 = SecureValueType; +secureValueTypeAddress#cbe31e26 = SecureValueType; +secureValueTypeUtilityBill#fc36954e = SecureValueType; +secureValueTypeBankStatement#89137c0d = SecureValueType; +secureValueTypeRentalAgreement#8b883488 = SecureValueType; +secureValueTypePassportRegistration#99e3806a = SecureValueType; +secureValueTypeTemporaryRegistration#ea02ec33 = SecureValueType; +secureValueTypePhone#b320aadb = SecureValueType; +secureValueTypeEmail#8e3ca7ee = SecureValueType; + +secureValue#187fa0ca flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?SecureFile reverse_side:flags.2?SecureFile selfie:flags.3?SecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData hash:bytes = SecureValue; + +inputSecureValue#db21d0a7 flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?InputSecureFile reverse_side:flags.2?InputSecureFile selfie:flags.3?InputSecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData = InputSecureValue; + +secureValueHash#ed1ecdb0 type:SecureValueType hash:bytes = SecureValueHash; + +secureValueErrorData#e8a40bd9 type:SecureValueType data_hash:bytes field:string text:string = SecureValueError; +secureValueErrorFrontSide#be3dfa type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorReverseSide#868a2aa5 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorSelfie#e537ced6 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFile#7a700873 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFiles#666220e9 type:SecureValueType file_hash:Vector text:string = SecureValueError; +secureValueError#869d758f type:SecureValueType hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFile#a1144770 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFiles#34636dd8 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureCredentialsEncrypted#33f0ea47 data:bytes hash:bytes secret:bytes = SecureCredentialsEncrypted; + +account.authorizationForm#ad2e1cd8 flags:# required_types:Vector values:Vector errors:Vector users:Vector privacy_policy_url:flags.0?string = account.AuthorizationForm; + +account.sentEmailCode#811f854f email_pattern:string length:int = account.SentEmailCode; + +help.deepLinkInfoEmpty#66afa166 = help.DeepLinkInfo; +help.deepLinkInfo#6a4ee832 flags:# update_app:flags.0?true message:string entities:flags.1?Vector = help.DeepLinkInfo; + +savedPhoneContact#1142bd56 phone:string first_name:string last_name:string date:int = SavedContact; + +account.takeout#4dba4501 id:long = account.Takeout; + +passwordKdfAlgoUnknown#d45ab096 = PasswordKdfAlgo; +passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow#3a912d4a salt1:bytes salt2:bytes g:int p:bytes = PasswordKdfAlgo; + +securePasswordKdfAlgoUnknown#4a8537 = SecurePasswordKdfAlgo; +securePasswordKdfAlgoPBKDF2HMACSHA512iter100000#bbf2dda0 salt:bytes = SecurePasswordKdfAlgo; +securePasswordKdfAlgoSHA512#86471d92 salt:bytes = SecurePasswordKdfAlgo; + +secureSecretSettings#1527bcac secure_algo:SecurePasswordKdfAlgo secure_secret:bytes secure_secret_id:long = SecureSecretSettings; + +inputCheckPasswordEmpty#9880f658 = InputCheckPasswordSRP; +inputCheckPasswordSRP#d27ff082 srp_id:long A:bytes M1:bytes = InputCheckPasswordSRP; + +secureRequiredType#829d99da flags:# native_names:flags.0?true selfie_required:flags.1?true translation_required:flags.2?true type:SecureValueType = SecureRequiredType; +secureRequiredTypeOneOf#27477b4 types:Vector = SecureRequiredType; + +help.passportConfigNotModified#bfb9f457 = help.PassportConfig; +help.passportConfig#a098d6af hash:int countries_langs:DataJSON = help.PassportConfig; + +inputAppEvent#1d1b1245 time:double type:string peer:long data:JSONValue = InputAppEvent; + +jsonObjectValue#c0de1bd9 key:string value:JSONValue = JSONObjectValue; + +jsonNull#3f6d7b68 = JSONValue; +jsonBool#c7345e6a value:Bool = JSONValue; +jsonNumber#2be0dfa4 value:double = JSONValue; +jsonString#b71e767a value:string = JSONValue; +jsonArray#f7444763 value:Vector = JSONValue; +jsonObject#99c1d49d value:Vector = JSONValue; + +pageTableCell#34566b6a flags:# header:flags.0?true align_center:flags.3?true align_right:flags.4?true valign_middle:flags.5?true valign_bottom:flags.6?true text:flags.7?RichText colspan:flags.1?int rowspan:flags.2?int = PageTableCell; + +pageTableRow#e0c0c5e5 cells:Vector = PageTableRow; + +pageCaption#6f747657 text:RichText credit:RichText = PageCaption; + +pageListItemText#b92fb6cd text:RichText = PageListItem; +pageListItemBlocks#25e073fc blocks:Vector = PageListItem; + +pageListOrderedItemText#5e068047 num:string text:RichText = PageListOrderedItem; +pageListOrderedItemBlocks#98dd8936 num:string blocks:Vector = PageListOrderedItem; + +pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; + +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; + +help.supportName#8c05f1c9 name:string = help.SupportName; + +help.userInfoEmpty#f3ae2eed = help.UserInfo; +help.userInfo#1eb3758 message:string entities:Vector author:string date:int = help.UserInfo; + +pollAnswer#ff16e2ca text:TextWithEntities option:bytes = PollAnswer; + +poll#58747131 id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true question:TextWithEntities answers:Vector close_period:flags.4?int close_date:flags.5?int = Poll; + +pollAnswerVoters#3b6ddad2 flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:int = PollAnswerVoters; + +pollResults#7adf2420 flags:# min:flags.0?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector = PollResults; + +chatOnlines#f041e250 onlines:int = ChatOnlines; + +statsURL#47a971e0 url:string = StatsURL; + +chatAdminRights#5fb224d5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true pin_messages:flags.7?true add_admins:flags.9?true anonymous:flags.10?true manage_call:flags.11?true other:flags.12?true manage_topics:flags.13?true post_stories:flags.14?true edit_stories:flags.15?true delete_stories:flags.16?true manage_direct_messages:flags.17?true = ChatAdminRights; + +chatBannedRights#9f120418 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true send_polls:flags.8?true change_info:flags.10?true invite_users:flags.15?true pin_messages:flags.17?true manage_topics:flags.18?true send_photos:flags.19?true send_videos:flags.20?true send_roundvideos:flags.21?true send_audios:flags.22?true send_voices:flags.23?true send_docs:flags.24?true send_plain:flags.25?true until_date:int = ChatBannedRights; + +inputWallPaper#e630b979 id:long access_hash:long = InputWallPaper; +inputWallPaperSlug#72091c80 slug:string = InputWallPaper; +inputWallPaperNoFile#967a462e id:long = InputWallPaper; + +account.wallPapersNotModified#1c199183 = account.WallPapers; +account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; + +codeSettings#ad253d78 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true allow_firebase:flags.7?true unknown_number:flags.9?true logout_tokens:flags.6?Vector token:flags.8?string app_sandbox:flags.8?Bool = CodeSettings; + +wallPaperSettings#372efcd0 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int emoticon:flags.7?string = WallPaperSettings; + +autoDownloadSettings#baa57628 flags:# disabled:flags.0?true video_preload_large:flags.1?true audio_preload_next:flags.2?true phonecalls_less_data:flags.3?true stories_preload:flags.4?true photo_size_max:int video_size_max:long file_size_max:long video_upload_maxbitrate:int small_queue_active_operations_max:int large_queue_active_operations_max:int = AutoDownloadSettings; + +account.autoDownloadSettings#63cacf26 low:AutoDownloadSettings medium:AutoDownloadSettings high:AutoDownloadSettings = account.AutoDownloadSettings; + +emojiKeyword#d5b3b9f9 keyword:string emoticons:Vector = EmojiKeyword; +emojiKeywordDeleted#236df622 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordsDifference#5cc761bd lang_code:string from_version:int version:int keywords:Vector = EmojiKeywordsDifference; + +emojiURL#a575739d url:string = EmojiURL; + +emojiLanguage#b3fb5361 lang_code:string = EmojiLanguage; + +folder#ff544e65 flags:# autofill_new_broadcasts:flags.0?true autofill_public_groups:flags.1?true autofill_new_correspondents:flags.2?true id:int title:string photo:flags.3?ChatPhoto = Folder; + +inputFolderPeer#fbd2c296 peer:InputPeer folder_id:int = InputFolderPeer; + +folderPeer#e9baa668 peer:Peer folder_id:int = FolderPeer; + +messages.searchCounter#e844ebff flags:# inexact:flags.1?true filter:MessagesFilter count:int = messages.SearchCounter; + +urlAuthResultRequest#92d33a0e flags:# request_write_access:flags.0?true bot:User domain:string = UrlAuthResult; +urlAuthResultAccepted#8f8c0e4e url:string = UrlAuthResult; +urlAuthResultDefault#a9d6db1f = UrlAuthResult; + +channelLocationEmpty#bfb5ad8b = ChannelLocation; +channelLocation#209b82db geo_point:GeoPoint address:string = ChannelLocation; + +peerLocated#ca461b5d peer:Peer expires:int distance:int = PeerLocated; +peerSelfLocated#f8ec284b expires:int = PeerLocated; + +restrictionReason#d072acb4 platform:string reason:string text:string = RestrictionReason; + +inputTheme#3c5693e9 id:long access_hash:long = InputTheme; +inputThemeSlug#f5890df1 slug:string = InputTheme; + +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; + +account.themesNotModified#f41eb622 = account.Themes; +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; + +auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; +auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; +auth.loginTokenSuccess#390d5c5e authorization:auth.Authorization = auth.LoginToken; + +account.contentSettings#57e28221 flags:# sensitive_enabled:flags.0?true sensitive_can_change:flags.1?true = account.ContentSettings; + +messages.inactiveChats#a927fec5 dates:Vector chats:Vector users:Vector = messages.InactiveChats; + +baseThemeClassic#c3a12462 = BaseTheme; +baseThemeDay#fbd81688 = BaseTheme; +baseThemeNight#b7b31ea8 = BaseTheme; +baseThemeTinted#6d5f77ee = BaseTheme; +baseThemeArctic#5b11125a = BaseTheme; + +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; + +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; + +webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; +webPageAttributeStory#2e94c3e7 flags:# peer:Peer id:int story:flags.0?StoryItem = WebPageAttribute; +webPageAttributeStickerSet#50cc03d3 flags:# emojis:flags.0?true text_color:flags.1?true stickers:Vector = WebPageAttribute; +webPageAttributeUniqueStarGift#cf6f6db8 gift:StarGift = WebPageAttribute; +webPageAttributeStarGiftCollection#31cad303 icons:Vector = WebPageAttribute; +webPageAttributeStarGiftAuction#1c641c2 gift:StarGift end_date:int = WebPageAttribute; + +messages.votesList#4899484e flags:# count:int votes:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.VotesList; + +bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; + +payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; + +dialogFilter#aa472651 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; +dialogFilterDefault#363293ae = DialogFilter; +dialogFilterChatlist#96537bd7 flags:# has_my_invites:flags.26?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; +statsGraphError#bedc9822 error:string = StatsGraph; +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +stats.broadcastStats#396ca5fc period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev reactions_per_post:StatsAbsValueAndPrev views_per_story:StatsAbsValueAndPrev shares_per_story:StatsAbsValueAndPrev reactions_per_story:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph reactions_by_emotion_graph:StatsGraph story_interactions_graph:StatsGraph story_reactions_by_emotion_graph:StatsGraph recent_posts_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; +help.promoData#8a4d87a flags:# proxy:flags.0?true expires:int peer:flags.3?Peer psa_type:flags.1?string psa_message:flags.2?string pending_suggestions:Vector dismissed_suggestions:Vector custom_pending_suggestion:flags.4?PendingSuggestion chats:Vector users:Vector = help.PromoData; + +videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; +videoSizeEmojiMarkup#f85c413c emoji_id:long background_colors:Vector = VideoSize; +videoSizeStickerMarkup#da082fe stickerset:InputStickerSet sticker_id:long background_colors:Vector = VideoSize; + +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#fe41b34f flags:# archive_and_mute_new_noncontact_peers:flags.0?true keep_archived_unmuted:flags.1?true keep_archived_folders:flags.2?true hide_read_marks:flags.3?true new_noncontact_peers_require_premium:flags.4?true display_gifts_button:flags.7?true noncontact_peers_paid_stars:flags.5?long disallowed_gifts:flags.6?DisallowedGiftsSettings = GlobalPrivacySettings; + +help.countryCode#4203c5ef flags:# country_code:string prefixes:flags.0?Vector patterns:flags.1?Vector = help.CountryCode; + +help.country#c3878e23 flags:# hidden:flags.0?true iso2:string default_name:string name:flags.1?string country_codes:Vector = help.Country; + +help.countriesListNotModified#93cc1f32 = help.CountriesList; +help.countriesList#87d0759e countries:Vector hash:int = help.CountriesList; + +messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:flags.2?MessageReplies = MessageViews; + +messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; + +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; + +messageReplyHeader#6917560b flags:# reply_to_scheduled:flags.2?true forum_topic:flags.3?true quote:flags.9?true reply_to_msg_id:flags.4?int reply_to_peer_id:flags.0?Peer reply_from:flags.5?MessageFwdHeader reply_media:flags.8?MessageMedia reply_to_top_id:flags.1?int quote_text:flags.6?string quote_entities:flags.7?Vector quote_offset:flags.10?int todo_item_id:flags.11?int = MessageReplyHeader; +messageReplyStoryHeader#e5af939 peer:Peer story_id:int = MessageReplyHeader; + +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; + +peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; + +stats.messageStats#7fe91c14 views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.MessageStats; + +groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; +groupCall#efb2b617 flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true conference:flags.14?true creator:flags.15?true messages_enabled:flags.17?true can_change_messages_enabled:flags.18?true min:flags.19?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int invite_link:flags.16?string send_paid_messages_stars:flags.20?long default_send_as:flags.21?Peer = GroupCall; + +inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; +inputGroupCallSlug#fe06823f slug:string = InputGroupCall; +inputGroupCallInviteMessage#8c10603f msg_id:int = InputGroupCall; + +groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant; + +phone.groupCall#9e727aad call:GroupCall participants:Vector participants_next_offset:string chats:Vector users:Vector = phone.GroupCall; + +phone.groupParticipants#f47751b6 count:int participants:Vector next_offset:string chats:Vector users:Vector version:int = phone.GroupParticipants; + +inlineQueryPeerTypeSameBotPM#3081ed9d = InlineQueryPeerType; +inlineQueryPeerTypePM#833c0fac = InlineQueryPeerType; +inlineQueryPeerTypeChat#d766c50a = InlineQueryPeerType; +inlineQueryPeerTypeMegagroup#5ec4be43 = InlineQueryPeerType; +inlineQueryPeerTypeBroadcast#6334ee9a = InlineQueryPeerType; +inlineQueryPeerTypeBotPM#e3b2d0c = InlineQueryPeerType; + +messages.historyImport#1662af0b id:long = messages.HistoryImport; + +messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true title:flags.2?string = messages.HistoryImportParsed; + +messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; + +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true via_chatlist:flags.3?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; + +messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; + +messages.exportedChatInvite#1871be50 invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; +messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; + +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; + +messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; + +messages.checkedHistoryImportPeer#a24de717 confirm_text:string = messages.CheckedHistoryImportPeer; + +phone.joinAsPeers#afe5623f peers:Vector chats:Vector users:Vector = phone.JoinAsPeers; + +phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvite; + +groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; + +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; +botCommandScopeUsers#3c4f04d8 = BotCommandScope; +botCommandScopeChats#6fe1a881 = BotCommandScope; +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +chatTheme#c3dffc04 emoticon:string = ChatTheme; +chatThemeUniqueGift#3458f9c8 gift:StarGift theme_settings:Vector = ChatTheme; + +account.chatThemesNotModified#e011e1c4 = account.ChatThemes; +account.chatThemes#be098173 flags:# hash:long themes:Vector chats:Vector users:Vector next_offset:flags.0?string = account.ChatThemes; + +sponsoredMessage#7dbf8673 flags:# recommended:flags.5?true can_report:flags.12?true random_id:bytes url:string title:string message:string entities:flags.1?Vector photo:flags.6?Photo media:flags.14?MessageMedia color:flags.13?PeerColor button_text:string sponsor_info:flags.7?string additional_info:flags.8?string min_display_duration:flags.15?int max_display_duration:flags.15?int = SponsoredMessage; + +messages.sponsoredMessages#ffda656d flags:# posts_between:flags.0?int start_delay:flags.1?int between_delay:flags.2?int messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +messages.sponsoredMessagesEmpty#1839490f = messages.SponsoredMessages; + +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#f496b0c6 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#a3d1cb80 flags:# chosen_order:flags.0?int reaction:Reaction count:int = ReactionCount; + +messageReactions#a339f0b flags:# min:flags.0?true can_see_list:flags.2?true reactions_as_tags:flags.3?true results:Vector recent_reactions:flags.1?Vector top_reactors:flags.4?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true premium:flags.2?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + +messagePeerReaction#8c79b63c flags:# big:flags.0?true unread:flags.1?true my:flags.2?true peer_id:Peer date:int reaction:Reaction = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; + +attachMenuBotIconColor#4576f3f0 name:string color:int = AttachMenuBotIconColor; + +attachMenuBotIcon#b2a7386b flags:# name:string icon:Document colors:flags.0?Vector = AttachMenuBotIcon; + +attachMenuBot#d90d8dfe flags:# inactive:flags.0?true has_settings:flags.1?true request_write_access:flags.2?true show_in_attach_menu:flags.3?true show_in_side_menu:flags.4?true side_menu_disclaimer_needed:flags.5?true bot_id:long short_name:string peer_types:flags.3?Vector icons:Vector = AttachMenuBot; + +attachMenuBotsNotModified#f1d88a5c = AttachMenuBots; +attachMenuBots#3c4301c0 hash:long bots:Vector users:Vector = AttachMenuBots; + +attachMenuBotsBot#93bf667f bot:AttachMenuBot users:Vector = AttachMenuBotsBot; + +webViewResultUrl#4d22ff98 flags:# fullsize:flags.1?true fullscreen:flags.2?true query_id:flags.0?long url:string = WebViewResult; + +webViewMessageSent#c94511c flags:# msg_id:flags.0?InputBotInlineMessageID = WebViewMessageSent; + +botMenuButtonDefault#7533a588 = BotMenuButton; +botMenuButtonCommands#4258c205 = BotMenuButton; +botMenuButton#c7b57ce6 text:string url:string = BotMenuButton; + +account.savedRingtonesNotModified#fbf6e8b1 = account.SavedRingtones; +account.savedRingtones#c1e92cc5 hash:long ringtones:Vector = account.SavedRingtones; + +notificationSoundDefault#97e8bebe = NotificationSound; +notificationSoundNone#6f0c34df = NotificationSound; +notificationSoundLocal#830b9ae4 title:string data:string = NotificationSound; +notificationSoundRingtone#ff6c8049 id:long = NotificationSound; + +account.savedRingtone#b7263f6d = account.SavedRingtone; +account.savedRingtoneConverted#1f307eb7 document:Document = account.SavedRingtone; + +attachMenuPeerTypeSameBotPM#7d6be90e = AttachMenuPeerType; +attachMenuPeerTypeBotPM#c32bfa1a = AttachMenuPeerType; +attachMenuPeerTypePM#f146d31f = AttachMenuPeerType; +attachMenuPeerTypeChat#509113f = AttachMenuPeerType; +attachMenuPeerTypeBroadcast#7bfbdefc = AttachMenuPeerType; + +inputInvoiceMessage#c5b56859 peer:InputPeer msg_id:int = InputInvoice; +inputInvoiceSlug#c326caef slug:string = InputInvoice; +inputInvoicePremiumGiftCode#98986c0d purpose:InputStorePaymentPurpose option:PremiumGiftCodeOption = InputInvoice; +inputInvoiceStars#65f00ce3 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceChatInviteSubscription#34e793f1 hash:string = InputInvoice; +inputInvoiceStarGift#e8625e92 flags:# hide_name:flags.0?true include_upgrade:flags.2?true peer:InputPeer gift_id:long message:flags.1?TextWithEntities = InputInvoice; +inputInvoiceStarGiftUpgrade#4d818d5d flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftTransfer#4a5f5bd9 stargift:InputSavedStarGift to_id:InputPeer = InputInvoice; +inputInvoicePremiumGiftStars#dabab2ef flags:# user_id:InputUser months:int message:flags.0?TextWithEntities = InputInvoice; +inputInvoiceBusinessBotTransferStars#f4997e42 bot:InputUser stars:long = InputInvoice; +inputInvoiceStarGiftResale#c39f5324 flags:# ton:flags.0?true slug:string to_id:InputPeer = InputInvoice; +inputInvoiceStarGiftPrepaidUpgrade#9a0b48b8 peer:InputPeer hash:string = InputInvoice; +inputInvoicePremiumAuthCode#3e77f614 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceStarGiftDropOriginalDetails#923d8d1 stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftAuctionBid#1ecafa10 flags:# hide_name:flags.0?true update_bid:flags.2?true peer:flags.3?InputPeer gift_id:long bid_amount:long message:flags.1?TextWithEntities = InputInvoice; + +payments.exportedInvoice#aed0cbd9 url:string = payments.ExportedInvoice; + +messages.transcribedAudio#cfb9d957 flags:# pending:flags.0?true transcription_id:long text:string trial_remains_num:flags.1?int trial_remains_until_date:flags.1?int = messages.TranscribedAudio; + +help.premiumPromo#5334759c status_text:string status_entities:Vector video_sections:Vector videos:Vector period_options:Vector users:Vector = help.PremiumPromo; + +inputStorePaymentPremiumSubscription#a6751e66 flags:# restore:flags.0?true upgrade:flags.1?true = InputStorePaymentPurpose; +inputStorePaymentGiftPremium#616f7fe8 user_id:InputUser currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentPremiumGiftCode#fb790393 flags:# users:Vector boost_peer:flags.0?InputPeer currency:string amount:long message:flags.1?TextWithEntities = InputStorePaymentPurpose; +inputStorePaymentPremiumGiveaway#160544ca flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsTopup#f9a2a6cb flags:# stars:long currency:string amount:long spend_purpose_peer:flags.0?InputPeer = InputStorePaymentPurpose; +inputStorePaymentStarsGift#1d741ef7 user_id:InputUser stars:long currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsGiveaway#751f08fa flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true stars:long boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long users:int = InputStorePaymentPurpose; +inputStorePaymentAuthCode#9bb2636d flags:# restore:flags.0?true phone_number:string phone_code_hash:string currency:string amount:long = InputStorePaymentPurpose; + +paymentFormMethod#88f8f21b url:string title:string = PaymentFormMethod; + +emojiStatusEmpty#2de11aae = EmojiStatus; +emojiStatus#e7ff068a flags:# document_id:long until:flags.0?int = EmojiStatus; +emojiStatusCollectible#7184603b flags:# collectible_id:long document_id:long title:string slug:string pattern_document_id:long center_color:int edge_color:int pattern_color:int text_color:int until:flags.0?int = EmojiStatus; +inputEmojiStatusCollectible#7141dbf flags:# collectible_id:long until:flags.0?int = EmojiStatus; + +account.emojiStatusesNotModified#d08ce645 = account.EmojiStatuses; +account.emojiStatuses#90c467d1 hash:long statuses:Vector = account.EmojiStatuses; + +reactionEmpty#79f5d419 = Reaction; +reactionEmoji#1b2286b8 emoticon:string = Reaction; +reactionCustomEmoji#8935fc73 document_id:long = Reaction; +reactionPaid#523da4eb = Reaction; + +chatReactionsNone#eafc32bc = ChatReactions; +chatReactionsAll#52928bca flags:# allow_custom:flags.0?true = ChatReactions; +chatReactionsSome#661d4037 reactions:Vector = ChatReactions; + +messages.reactionsNotModified#b06fdbdf = messages.Reactions; +messages.reactions#eafdf716 hash:long reactions:Vector = messages.Reactions; + +emailVerifyPurposeLoginSetup#4345be73 phone_number:string phone_code_hash:string = EmailVerifyPurpose; +emailVerifyPurposeLoginChange#527d22eb = EmailVerifyPurpose; +emailVerifyPurposePassport#bbf51685 = EmailVerifyPurpose; + +emailVerificationCode#922e55a9 code:string = EmailVerification; +emailVerificationGoogle#db909ec2 token:string = EmailVerification; +emailVerificationApple#96d074fd token:string = EmailVerification; + +account.emailVerified#2b96cd1b email:string = account.EmailVerified; +account.emailVerifiedLogin#e1bb0d61 email:string sent_code:auth.SentCode = account.EmailVerified; + +premiumSubscriptionOption#5f2d1df2 flags:# current:flags.1?true can_purchase_upgrade:flags.2?true transaction:flags.3?string months:int currency:string amount:long bot_url:string store_product:flags.0?string = PremiumSubscriptionOption; + +sendAsPeer#b81c7034 flags:# premium_required:flags.0?true peer:Peer = SendAsPeer; + +messageExtendedMediaPreview#ad628cc8 flags:# w:flags.0?int h:flags.0?int thumb:flags.1?PhotoSize video_duration:flags.2?int = MessageExtendedMedia; +messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; + +stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; + +username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; + +forumTopicDeleted#23f109b id:int = ForumTopic; +forumTopic#cdff0eca flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true title_missing:flags.7?true id:int date:int peer:Peer title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; + +messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; + +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + +requestPeerTypeUser#5f3b8a00 flags:# bot:flags.0?Bool premium:flags.1?Bool = RequestPeerType; +requestPeerTypeChat#c9f06e1b flags:# creator:flags.0?true bot_participant:flags.5?true has_username:flags.3?Bool forum:flags.4?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeBroadcast#339bef6c flags:# creator:flags.0?true has_username:flags.3?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; + +emojiListNotModified#481eadfa = EmojiList; +emojiList#7a1e11d1 hash:long document_id:Vector = EmojiList; + +emojiGroup#7a9abda9 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupGreeting#80d26cc7 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupPremium#93bcf34 title:string icon_emoji_id:long = EmojiGroup; + +messages.emojiGroupsNotModified#6fb4ad87 = messages.EmojiGroups; +messages.emojiGroups#881fb94b hash:int groups:Vector = messages.EmojiGroups; + +textWithEntities#751f3146 text:string entities:Vector = TextWithEntities; + +messages.translateResult#33db32f8 result:Vector = messages.TranslatedText; + +autoSaveSettings#c84834ce flags:# photos:flags.0?true videos:flags.1?true video_max_size:flags.2?long = AutoSaveSettings; + +autoSaveException#81602d47 peer:Peer settings:AutoSaveSettings = AutoSaveException; + +account.autoSaveSettings#4c3e069d users_settings:AutoSaveSettings chats_settings:AutoSaveSettings broadcasts_settings:AutoSaveSettings exceptions:Vector chats:Vector users:Vector = account.AutoSaveSettings; + +help.appConfigNotModified#7cde641d = help.AppConfig; +help.appConfig#dd18782e hash:int config:JSONValue = help.AppConfig; + +inputBotAppID#a920bd7a id:long access_hash:long = InputBotApp; +inputBotAppShortName#908c0407 bot_id:InputUser short_name:string = InputBotApp; + +botAppNotModified#5da674b7 = BotApp; +botApp#95fcd1d6 flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document hash:long = BotApp; + +messages.botApp#eb50adf5 flags:# inactive:flags.0?true request_write_access:flags.1?true has_settings:flags.2?true app:BotApp = messages.BotApp; + +inlineBotWebView#b57295d5 text:string url:string = InlineBotWebView; + +readParticipantDate#4a4ff172 user_id:long date:int = ReadParticipantDate; + +inputChatlistDialogFilter#f3e0da33 filter_id:int = InputChatlist; + +exportedChatlistInvite#c5181ac flags:# title:string url:string peers:Vector = ExportedChatlistInvite; + +chatlists.exportedChatlistInvite#10e6e3a6 filter:DialogFilter invite:ExportedChatlistInvite = chatlists.ExportedChatlistInvite; + +chatlists.exportedInvites#10ab6dc7 invites:Vector chats:Vector users:Vector = chatlists.ExportedInvites; + +chatlists.chatlistInviteAlready#fa87f659 filter_id:int missing_peers:Vector already_peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; +chatlists.chatlistInvite#f10ece2f flags:# title_noanimate:flags.1?true title:TextWithEntities emoticon:flags.0?string peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistUpdates#93bd878d missing_peers:Vector chats:Vector users:Vector = chatlists.ChatlistUpdates; + +bots.botInfo#e8a775b0 name:string about:string description:string = bots.BotInfo; + +messagePeerVote#b6cc2d5c peer:Peer option:bytes date:int = MessagePeerVote; +messagePeerVoteInputOption#74cda504 peer:Peer date:int = MessagePeerVote; +messagePeerVoteMultiple#4628f6e6 peer:Peer options:Vector date:int = MessagePeerVote; + +storyViews#8d595cd6 flags:# has_viewers:flags.1?true views_count:int forwards_count:flags.2?int reactions:flags.3?Vector reactions_count:flags.4?int recent_viewers:flags.0?Vector = StoryViews; + +storyItemDeleted#51e6ee4f id:int = StoryItem; +storyItemSkipped#ffadc913 flags:# close_friends:flags.8?true live:flags.9?true id:int date:int expire_date:int = StoryItem; +storyItem#edf164f1 flags:# pinned:flags.5?true public:flags.7?true close_friends:flags.8?true min:flags.9?true noforwards:flags.10?true edited:flags.11?true contacts:flags.12?true selected_contacts:flags.13?true out:flags.16?true id:int date:int from_id:flags.18?Peer fwd_from:flags.17?StoryFwdHeader expire_date:int caption:flags.0?string entities:flags.1?Vector media:MessageMedia media_areas:flags.14?Vector privacy:flags.2?Vector views:flags.3?StoryViews sent_reaction:flags.15?Reaction albums:flags.19?Vector = StoryItem; + +stories.allStoriesNotModified#1158fe3e flags:# state:string stealth_mode:StoriesStealthMode = stories.AllStories; +stories.allStories#6efc5e81 flags:# has_more:flags.0?true count:int state:string peer_stories:Vector chats:Vector users:Vector stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.stories#63c3dd0a flags:# count:int stories:Vector pinned_to_top:flags.0?Vector chats:Vector users:Vector = stories.Stories; + +storyView#b0bdeac5 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true user_id:long date:int reaction:flags.2?Reaction = StoryView; +storyViewPublicForward#9083670b flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true message:Message = StoryView; +storyViewPublicRepost#bd74cf49 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer story:StoryItem = StoryView; + +stories.storyViewsList#59d78fc5 flags:# count:int views_count:int forwards_count:int reactions_count:int views:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryViewsList; + +stories.storyViews#de9eed1d views:Vector users:Vector = stories.StoryViews; + +inputReplyToMessage#869fbe10 flags:# reply_to_msg_id:int top_msg_id:flags.0?int reply_to_peer_id:flags.1?InputPeer quote_text:flags.2?string quote_entities:flags.3?Vector quote_offset:flags.4?int monoforum_peer_id:flags.5?InputPeer todo_item_id:flags.6?int = InputReplyTo; +inputReplyToStory#5881323a peer:InputPeer story_id:int = InputReplyTo; +inputReplyToMonoForum#69d66c45 monoforum_peer_id:InputPeer = InputReplyTo; + +exportedStoryLink#3fc9053b link:string = ExportedStoryLink; + +storiesStealthMode#712e27fd flags:# active_until_date:flags.0?int cooldown_until_date:flags.1?int = StoriesStealthMode; + +mediaAreaCoordinates#cfc9e002 flags:# x:double y:double w:double h:double rotation:double radius:flags.0?double = MediaAreaCoordinates; + +mediaAreaVenue#be82db9c coordinates:MediaAreaCoordinates geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MediaArea; +inputMediaAreaVenue#b282217f coordinates:MediaAreaCoordinates query_id:long result_id:string = MediaArea; +mediaAreaGeoPoint#cad5452d flags:# coordinates:MediaAreaCoordinates geo:GeoPoint address:flags.0?GeoPointAddress = MediaArea; +mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; +mediaAreaChannelPost#770416af coordinates:MediaAreaCoordinates channel_id:long msg_id:int = MediaArea; +inputMediaAreaChannelPost#2271f2bf coordinates:MediaAreaCoordinates channel:InputChannel msg_id:int = MediaArea; +mediaAreaUrl#37381085 coordinates:MediaAreaCoordinates url:string = MediaArea; +mediaAreaWeather#49a6549c coordinates:MediaAreaCoordinates emoji:string temperature_c:double color:int = MediaArea; +mediaAreaStarGift#5787686d coordinates:MediaAreaCoordinates slug:string = MediaArea; + +peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; + +stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; + +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; + +premiumGiftCodeOption#257e962b flags:# users:int months:int store_product:flags.0?string store_quantity:flags.1?int currency:string amount:long = PremiumGiftCodeOption; + +payments.checkedGiftCode#eb983f8f flags:# via_giveaway:flags.2?true from_id:flags.4?Peer giveaway_msg_id:flags.3?int to_id:flags.0?long date:int days:int used_date:flags.1?int chats:Vector users:Vector = payments.CheckedGiftCode; + +payments.giveawayInfo#4367daa0 flags:# participating:flags.0?true preparing_results:flags.3?true start_date:int joined_too_early_date:flags.1?int admin_disallowed_chat_id:flags.2?long disallowed_country:flags.4?string = payments.GiveawayInfo; +payments.giveawayInfoResults#e175e66f flags:# winner:flags.0?true refunded:flags.1?true start_date:int gift_code_slug:flags.3?string stars_prize:flags.4?long finish_date:int winners_count:int activated_count:flags.2?int = payments.GiveawayInfo; + +prepaidGiveaway#b2539d54 id:long months:int quantity:int date:int = PrepaidGiveaway; +prepaidStarsGiveaway#9a9d77e0 id:long stars:long quantity:int boosts:int date:int = PrepaidGiveaway; + +boost#4b3e14d6 flags:# gift:flags.1?true giveaway:flags.2?true unclaimed:flags.3?true id:string user_id:flags.0?long giveaway_msg_id:flags.2?int date:int expires:int used_gift_slug:flags.4?string multiplier:flags.5?int stars:flags.6?long = Boost; + +premium.boostsList#86f8613c flags:# count:int boosts:Vector next_offset:flags.0?string users:Vector = premium.BoostsList; + +myBoost#c448415c flags:# slot:int peer:flags.0?Peer date:int expires:int cooldown_until_date:flags.1?int = MyBoost; + +premium.myBoosts#9ae228e2 my_boosts:Vector chats:Vector users:Vector = premium.MyBoosts; + +premium.boostsStatus#4959427a flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int gift_boosts:flags.4?int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string prepaid_giveaways:flags.3?Vector my_boost_slots:flags.2?Vector = premium.BoostsStatus; + +storyFwdHeader#b826e150 flags:# modified:flags.3?true from:flags.0?Peer from_name:flags.1?string story_id:flags.2?int = StoryFwdHeader; + +postInteractionCountersMessage#e7058e7f msg_id:int views:int forwards:int reactions:int = PostInteractionCounters; +postInteractionCountersStory#8a480e27 story_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +stats.storyStats#50cd067c views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.StoryStats; + +publicForwardMessage#1f2bf4a message:Message = PublicForward; +publicForwardStory#edf3add0 peer:Peer story:StoryItem = PublicForward; + +stats.publicForwards#93037e20 flags:# count:int forwards:Vector next_offset:flags.0?string chats:Vector users:Vector = stats.PublicForwards; + +peerColor#b54b5acf flags:# color:flags.0?int background_emoji_id:flags.1?long = PeerColor; +peerColorCollectible#b9c0639a flags:# collectible_id:long gift_emoji_id:long background_emoji_id:long accent_color:int colors:Vector dark_accent_color:flags.0?int dark_colors:flags.1?Vector = PeerColor; +inputPeerColorCollectible#b8ea86a9 collectible_id:long = PeerColor; + +help.peerColorSet#26219a58 colors:Vector = help.PeerColorSet; +help.peerColorProfileSet#767d61eb palette_colors:Vector bg_colors:Vector story_colors:Vector = help.PeerColorSet; + +help.peerColorOption#adec6ebe flags:# hidden:flags.0?true color_id:int colors:flags.1?help.PeerColorSet dark_colors:flags.2?help.PeerColorSet channel_min_level:flags.3?int group_min_level:flags.4?int = help.PeerColorOption; + +help.peerColorsNotModified#2ba1f5ce = help.PeerColors; +help.peerColors#f8ed08 hash:int colors:Vector = help.PeerColors; + +storyReaction#6090d6d5 peer_id:Peer date:int reaction:Reaction = StoryReaction; +storyReactionPublicForward#bbab2643 message:Message = StoryReaction; +storyReactionPublicRepost#cfcd0f13 peer_id:Peer story:StoryItem = StoryReaction; + +stories.storyReactionsList#aa5f789c flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryReactionsList; + +savedDialog#bd87cb6c flags:# pinned:flags.2?true peer:Peer top_message:int = SavedDialog; +monoForumDialog#64407ea7 flags:# unread_mark:flags.3?true nopaid_messages_exception:flags.4?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_reactions_count:int draft:flags.1?DraftMessage = SavedDialog; + +messages.savedDialogs#f83ae221 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsSlice#44ba9dd9 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsNotModified#c01f6fe8 count:int = messages.SavedDialogs; + +savedReactionTag#cb6ff828 flags:# reaction:Reaction title:flags.0?string count:int = SavedReactionTag; + +messages.savedReactionTagsNotModified#889b59ef = messages.SavedReactionTags; +messages.savedReactionTags#3259950a tags:Vector hash:long = messages.SavedReactionTags; + +outboxReadDate#3bb842ac date:int = OutboxReadDate; + +smsjobs.eligibleToJoin#dc8b44cf terms_url:string monthly_sent_sms:int = smsjobs.EligibilityToJoin; + +smsjobs.status#2aee9191 flags:# allow_international:flags.0?true recent_sent:int recent_since:int recent_remains:int total_sent:int total_since:int last_gift_slug:flags.1?string terms_url:string = smsjobs.Status; + +smsJob#e6a1eeb8 job_id:string phone_number:string text:string = SmsJob; + +businessWeeklyOpen#120b1ab9 start_minute:int end_minute:int = BusinessWeeklyOpen; + +businessWorkHours#8c92b098 flags:# open_now:flags.0?true timezone_id:string weekly_open:Vector = BusinessWorkHours; + +businessLocation#ac5c1af7 flags:# geo_point:flags.0?GeoPoint address:string = BusinessLocation; + +inputBusinessRecipients#6f8b32aa flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = InputBusinessRecipients; + +businessRecipients#21108ff7 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = BusinessRecipients; + +businessAwayMessageScheduleAlways#c9b9e2b9 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleOutsideWorkHours#c3f2f501 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleCustom#cc4d9ecc start_date:int end_date:int = BusinessAwayMessageSchedule; + +inputBusinessGreetingMessage#194cb3b shortcut_id:int recipients:InputBusinessRecipients no_activity_days:int = InputBusinessGreetingMessage; + +businessGreetingMessage#e519abab shortcut_id:int recipients:BusinessRecipients no_activity_days:int = BusinessGreetingMessage; + +inputBusinessAwayMessage#832175e0 flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:InputBusinessRecipients = InputBusinessAwayMessage; + +businessAwayMessage#ef156a5c flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:BusinessRecipients = BusinessAwayMessage; + +timezone#ff9289f5 id:string name:string utc_offset:int = Timezone; + +help.timezonesListNotModified#970708cc = help.TimezonesList; +help.timezonesList#7b74ed71 timezones:Vector hash:int = help.TimezonesList; + +quickReply#697102b shortcut_id:int shortcut:string top_message:int count:int = QuickReply; + +inputQuickReplyShortcut#24596d41 shortcut:string = InputQuickReplyShortcut; +inputQuickReplyShortcutId#1190cf1 shortcut_id:int = InputQuickReplyShortcut; + +messages.quickReplies#c68d6695 quick_replies:Vector messages:Vector chats:Vector users:Vector = messages.QuickReplies; +messages.quickRepliesNotModified#5f91eb5b = messages.QuickReplies; + +connectedBot#cd64636c flags:# bot_id:long recipients:BusinessBotRecipients rights:BusinessBotRights = ConnectedBot; + +account.connectedBots#17d7f87b connected_bots:Vector users:Vector = account.ConnectedBots; + +messages.dialogFilters#2ad93719 flags:# tags_enabled:flags.0?true filters:Vector = messages.DialogFilters; + +birthday#6c8e1e06 flags:# day:int month:int year:flags.0?int = Birthday; + +botBusinessConnection#8f34b2f5 flags:# disabled:flags.1?true connection_id:string user_id:long dc_id:int date:int rights:flags.2?BusinessBotRights = BotBusinessConnection; + +inputBusinessIntro#9c469cd flags:# title:string description:string sticker:flags.0?InputDocument = InputBusinessIntro; + +businessIntro#5a0a066d flags:# title:string description:string sticker:flags.0?Document = BusinessIntro; + +messages.myStickers#faff629d count:int sets:Vector = messages.MyStickers; + +inputCollectibleUsername#e39460a9 username:string = InputCollectible; +inputCollectiblePhone#a2e214a4 phone:string = InputCollectible; + +fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo; + +inputBusinessBotRecipients#c4e5921e flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = InputBusinessBotRecipients; + +businessBotRecipients#b88cf373 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = BusinessBotRecipients; + +contactBirthday#1d998733 contact_id:long birthday:Birthday = ContactBirthday; + +contacts.contactBirthdays#114ff30d contacts:Vector users:Vector = contacts.ContactBirthdays; + +missingInvitee#628c9224 flags:# premium_would_allow_invite:flags.0?true premium_required_for_pm:flags.1?true user_id:long = MissingInvitee; + +messages.invitedUsers#7f5defa6 updates:Updates missing_invitees:Vector = messages.InvitedUsers; + +inputBusinessChatLink#11679fa7 flags:# message:string entities:flags.0?Vector title:flags.1?string = InputBusinessChatLink; + +businessChatLink#b4ae666f flags:# link:string message:string entities:flags.0?Vector title:flags.1?string views:int = BusinessChatLink; + +account.businessChatLinks#ec43a2d1 links:Vector chats:Vector users:Vector = account.BusinessChatLinks; + +account.resolvedBusinessChatLinks#9a23af21 flags:# peer:Peer message:string entities:flags.0?Vector chats:Vector users:Vector = account.ResolvedBusinessChatLinks; + +requestedPeerUser#d62ff46a flags:# user_id:long first_name:flags.0?string last_name:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChat#7307544f flags:# chat_id:long title:flags.0?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChannel#8ba403e4 flags:# channel_id:long title:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +sponsoredMessageReportOption#430d3150 text:string option:bytes = SponsoredMessageReportOption; + +channels.sponsoredMessageReportResultChooseOption#846f9e42 title:string options:Vector = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultAdsHidden#3e3bcf2f = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultReported#ad798849 = channels.SponsoredMessageReportResult; + +reactionNotificationsFromContacts#bac3a61a = ReactionNotificationsFrom; +reactionNotificationsFromAll#4b9e22a0 = ReactionNotificationsFrom; + +reactionsNotifySettings#56e34970 flags:# messages_notify_from:flags.0?ReactionNotificationsFrom stories_notify_from:flags.1?ReactionNotificationsFrom sound:NotificationSound show_previews:Bool = ReactionsNotifySettings; + +availableEffect#93c3e27e flags:# premium_required:flags.2?true id:long emoticon:string static_icon_id:flags.0?long effect_sticker_id:long effect_animation_id:flags.1?long = AvailableEffect; + +messages.availableEffectsNotModified#d1ed9a5b = messages.AvailableEffects; +messages.availableEffects#bddb616e hash:int effects:Vector documents:Vector = messages.AvailableEffects; + +factCheck#b89bfccf flags:# need_check:flags.0?true country:flags.1?string text:flags.1?TextWithEntities hash:long = FactCheck; + +starsTransactionPeerUnsupported#95f2bfe4 = StarsTransactionPeer; +starsTransactionPeerAppStore#b457b375 = StarsTransactionPeer; +starsTransactionPeerPlayMarket#7b560a0b = StarsTransactionPeer; +starsTransactionPeerPremiumBot#250dbaf8 = StarsTransactionPeer; +starsTransactionPeerFragment#e92fd902 = StarsTransactionPeer; +starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; +starsTransactionPeerAds#60682812 = StarsTransactionPeer; +starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; + +starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; + +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true stargift_prepaid_upgrade:flags.25?true stargift_drop_original_details:flags.26?true phonegroup_message:flags.27?true stargift_auction_bid:flags.28?true offer:flags.29?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; + +payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; + +foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; + +stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; + +geoPointAddress#de4c5d93 flags:# country_iso2:string state:flags.0?string city:flags.1?string street:flags.2?string = GeoPointAddress; + +starsRevenueStatus#febe5491 flags:# withdrawal_enabled:flags.0?true current_balance:StarsAmount available_balance:StarsAmount overall_revenue:StarsAmount next_withdrawal_at:flags.1?int = StarsRevenueStatus; + +payments.starsRevenueStats#6c207376 flags:# top_hours_graph:flags.0?StatsGraph revenue_graph:StatsGraph status:StarsRevenueStatus usd_rate:double = payments.StarsRevenueStats; + +payments.starsRevenueWithdrawalUrl#1dab80b7 url:string = payments.StarsRevenueWithdrawalUrl; + +payments.starsRevenueAdsAccountUrl#394e7f21 url:string = payments.StarsRevenueAdsAccountUrl; + +inputStarsTransaction#206ae6d1 flags:# refund:flags.0?true id:string = InputStarsTransaction; + +starsGiftOption#5e0589f1 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsGiftOption; + +bots.popularAppBots#1991b13b flags:# next_offset:flags.0?string users:Vector = bots.PopularAppBots; + +botPreviewMedia#23e91ba3 date:int media:MessageMedia = BotPreviewMedia; + +bots.previewInfo#ca71d64 media:Vector lang_codes:Vector = bots.PreviewInfo; + +starsSubscriptionPricing#5416d58 period:int amount:long = StarsSubscriptionPricing; + +starsSubscription#2e6eab1a flags:# canceled:flags.0?true can_refulfill:flags.1?true missing_balance:flags.2?true bot_canceled:flags.7?true id:string peer:Peer until_date:int pricing:StarsSubscriptionPricing chat_invite_hash:flags.3?string title:flags.4?string photo:flags.5?WebDocument invoice_slug:flags.6?string = StarsSubscription; + +messageReactor#4ba3a95a flags:# top:flags.0?true my:flags.1?true anonymous:flags.2?true peer_id:flags.3?Peer count:int = MessageReactor; + +starsGiveawayOption#94ce852a flags:# extended:flags.0?true default:flags.1?true stars:long yearly_boosts:int store_product:flags.2?string currency:string amount:long winners:Vector = StarsGiveawayOption; + +starsGiveawayWinnersOption#54236209 flags:# default:flags.0?true users:int per_user_stars:long = StarsGiveawayWinnersOption; + +starGift#313a9547 flags:# limited:flags.0?true sold_out:flags.1?true birthday:flags.2?true require_premium:flags.7?true limited_per_user:flags.8?true peer_color_available:flags.10?true auction:flags.11?true id:long sticker:Document stars:long availability_remains:flags.0?int availability_total:flags.0?int availability_resale:flags.4?long convert_stars:long first_sale_date:flags.1?int last_sale_date:flags.1?int upgrade_stars:flags.3?long resell_min_stars:flags.4?long title:flags.5?string released_by:flags.6?Peer per_user_total:flags.8?int per_user_remains:flags.8?int locked_until_date:flags.9?int auction_slug:flags.11?string gifts_per_round:flags.11?int auction_start_date:flags.11?int upgrade_variants:flags.12?int background:flags.13?StarGiftBackground = StarGift; +starGiftUnique#569d64c9 flags:# require_premium:flags.6?true resale_ton_only:flags.7?true theme_available:flags.9?true id:long gift_id:long title:string slug:string num:int owner_id:flags.0?Peer owner_name:flags.1?string owner_address:flags.2?string attributes:Vector availability_issued:int availability_total:int gift_address:flags.3?string resell_amount:flags.4?Vector released_by:flags.5?Peer value_amount:flags.8?long value_currency:flags.8?string value_usd_amount:flags.8?long theme_peer:flags.10?Peer peer_color:flags.11?PeerColor host_id:flags.12?Peer offer_min_stars:flags.13?int = StarGift; + +payments.starGiftsNotModified#a388a368 = payments.StarGifts; +payments.starGifts#2ed82995 hash:int gifts:Vector chats:Vector users:Vector = payments.StarGifts; + +messageReportOption#7903e3d9 text:string option:bytes = MessageReportOption; + +reportResultChooseOption#f0e4e0b6 title:string options:Vector = ReportResult; +reportResultAddComment#6f09ac31 flags:# optional:flags.0?true option:bytes = ReportResult; +reportResultReported#8db33c4b = ReportResult; + +messages.botPreparedInlineMessage#8ecf0511 id:string expire_date:int = messages.BotPreparedInlineMessage; + +messages.preparedInlineMessage#ff57708d query_id:long result:BotInlineResult peer_types:Vector cache_time:int users:Vector = messages.PreparedInlineMessage; + +botAppSettings#c99b1950 flags:# placeholder_path:flags.0?bytes background_color:flags.1?int background_dark_color:flags.2?int header_color:flags.3?int header_dark_color:flags.4?int = BotAppSettings; + +starRefProgram#dd0c66f2 flags:# bot_id:long commission_permille:int duration_months:flags.0?int end_date:flags.1?int daily_revenue_per_user:flags.2?StarsAmount = StarRefProgram; + +connectedBotStarRef#19a13f71 flags:# revoked:flags.1?true url:string date:int bot_id:long commission_permille:int duration_months:flags.0?int participants:long revenue:long = ConnectedBotStarRef; + +payments.connectedStarRefBots#98d5ea1d count:int connected_bots:Vector users:Vector = payments.ConnectedStarRefBots; + +payments.suggestedStarRefBots#b4d5d859 flags:# count:int suggested_bots:Vector users:Vector next_offset:flags.0?string = payments.SuggestedStarRefBots; + +starsAmount#bbb6b4a3 amount:long nanos:int = StarsAmount; +starsTonAmount#74aee3e0 amount:long = StarsAmount; + +messages.foundStickersNotModified#6010c534 flags:# next_offset:flags.0?int = messages.FoundStickers; +messages.foundStickers#82c9e290 flags:# next_offset:flags.0?int hash:long stickers:Vector = messages.FoundStickers; + +botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string = BotVerifierSettings; + +botVerification#f93cd45c bot_id:long icon:long description:string = BotVerification; + +starGiftAttributeModel#39d99013 name:string document:Document rarity_permille:int = StarGiftAttribute; +starGiftAttributePattern#13acff19 name:string document:Document rarity_permille:int = StarGiftAttribute; +starGiftAttributeBackdrop#d93d859c name:string backdrop_id:int center_color:int edge_color:int pattern_color:int text_color:int rarity_permille:int = StarGiftAttribute; +starGiftAttributeOriginalDetails#e0bff26c flags:# sender_id:flags.0?Peer recipient_id:Peer date:int message:flags.1?TextWithEntities = StarGiftAttribute; + +payments.starGiftUpgradePreview#3de1dfed sample_attributes:Vector prices:Vector next_prices:Vector = payments.StarGiftUpgradePreview; + +users.users#62d706b8 users:Vector = users.Users; +users.usersSlice#315a4974 count:int users:Vector = users.Users; + +payments.uniqueStarGift#416c56e8 gift:StarGift chats:Vector users:Vector = payments.UniqueStarGift; + +messages.webPagePreview#8c9a88ac media:MessageMedia chats:Vector users:Vector = messages.WebPagePreview; + +savedStarGift#ead6805e flags:# name_hidden:flags.0?true unsaved:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true pinned_to_top:flags.12?true upgrade_separate:flags.17?true from_id:flags.1?Peer date:int gift:StarGift message:flags.2?TextWithEntities msg_id:flags.3?int saved_id:flags.11?long convert_stars:flags.4?long upgrade_stars:flags.6?long can_export_at:flags.7?int transfer_stars:flags.8?long can_transfer_at:flags.13?int can_resell_at:flags.14?int collection_id:flags.15?Vector prepaid_upgrade_hash:flags.16?string drop_original_details_stars:flags.18?long gift_num:flags.19?int = SavedStarGift; + +payments.savedStarGifts#95f389b1 flags:# count:int chat_notifications_enabled:flags.1?Bool gifts:Vector next_offset:flags.0?string chats:Vector users:Vector = payments.SavedStarGifts; + +inputSavedStarGiftUser#69279795 msg_id:int = InputSavedStarGift; +inputSavedStarGiftChat#f101aa7f peer:InputPeer saved_id:long = InputSavedStarGift; +inputSavedStarGiftSlug#2085c238 slug:string = InputSavedStarGift; + +payments.starGiftWithdrawalUrl#84aa3a9c url:string = payments.StarGiftWithdrawalUrl; + +paidReactionPrivacyDefault#206ad49e = PaidReactionPrivacy; +paidReactionPrivacyAnonymous#1f0c1ad9 = PaidReactionPrivacy; +paidReactionPrivacyPeer#dc6cfcf0 peer:InputPeer = PaidReactionPrivacy; + +account.paidMessagesRevenue#1e109708 stars_amount:long = account.PaidMessagesRevenue; + +requirementToContactEmpty#50a9839 = RequirementToContact; +requirementToContactPremium#e581e4e9 = RequirementToContact; +requirementToContactPaidMessages#b4f67e93 stars_amount:long = RequirementToContact; + +businessBotRights#a0624cf7 flags:# reply:flags.0?true read_messages:flags.1?true delete_sent_messages:flags.2?true delete_received_messages:flags.3?true edit_name:flags.4?true edit_bio:flags.5?true edit_profile_photo:flags.6?true edit_username:flags.7?true view_gifts:flags.8?true sell_gifts:flags.9?true change_gift_settings:flags.10?true transfer_and_upgrade_gifts:flags.11?true transfer_stars:flags.12?true manage_stories:flags.13?true = BusinessBotRights; + +disallowedGiftsSettings#71f276c4 flags:# disallow_unlimited_stargifts:flags.0?true disallow_limited_stargifts:flags.1?true disallow_unique_stargifts:flags.2?true disallow_premium_gifts:flags.3?true disallow_stargifts_from_channels:flags.4?true = DisallowedGiftsSettings; + +sponsoredPeer#c69708d3 flags:# random_id:bytes peer:Peer sponsor_info:flags.0?string additional_info:flags.1?string = SponsoredPeer; + +contacts.sponsoredPeersEmpty#ea32b4b1 = contacts.SponsoredPeers; +contacts.sponsoredPeers#eb032884 peers:Vector chats:Vector users:Vector = contacts.SponsoredPeers; + +starGiftAttributeIdModel#48aaae3c document_id:long = StarGiftAttributeId; +starGiftAttributeIdPattern#4a162433 document_id:long = StarGiftAttributeId; +starGiftAttributeIdBackdrop#1f01c757 backdrop_id:int = StarGiftAttributeId; + +starGiftAttributeCounter#2eb1b658 attribute:StarGiftAttributeId count:int = StarGiftAttributeCounter; + +payments.resaleStarGifts#947a12df flags:# count:int gifts:Vector next_offset:flags.0?string attributes:flags.1?Vector attributes_hash:flags.1?long chats:Vector counters:flags.2?Vector users:Vector = payments.ResaleStarGifts; + +stories.canSendStoryCount#c387c04e count_remains:int = stories.CanSendStoryCount; + +pendingSuggestion#e7e82e12 suggestion:string title:TextWithEntities description:TextWithEntities url:string = PendingSuggestion; + +todoItem#cba9a52f id:int title:TextWithEntities = TodoItem; + +todoList#49b92a26 flags:# others_can_append:flags.0?true others_can_complete:flags.1?true title:TextWithEntities list:Vector = TodoList; + +todoCompletion#221bb5e4 id:int completed_by:Peer date:int = TodoCompletion; + +suggestedPost#e8e37e5 flags:# accepted:flags.1?true rejected:flags.2?true price:flags.3?StarsAmount schedule_date:flags.0?int = SuggestedPost; + +starsRating#1b0e4f07 flags:# level:int current_level_stars:long stars:long next_level_stars:flags.0?long = StarsRating; + +starGiftCollection#9d6b13b0 flags:# collection_id:int title:string icon:flags.0?Document gifts_count:int hash:long = StarGiftCollection; + +payments.starGiftCollectionsNotModified#a0ba4f17 = payments.StarGiftCollections; +payments.starGiftCollections#8a2932f3 collections:Vector = payments.StarGiftCollections; + +storyAlbum#9325705a flags:# album_id:int title:string icon_photo:flags.0?Photo icon_video:flags.1?Document = StoryAlbum; + +stories.albumsNotModified#564edaeb = stories.Albums; +stories.albums#c3987a3a hash:long albums:Vector = stories.Albums; + +searchPostsFlood#3e0b5b6a flags:# query_is_free:flags.0?true total_daily:int remains:int wait_till:flags.1?int stars_amount:long = SearchPostsFlood; + +payments.uniqueStarGiftValueInfo#512fe446 flags:# last_sale_on_fragment:flags.1?true value_is_average:flags.6?true currency:string value:long initial_sale_date:int initial_sale_stars:long initial_sale_price:long last_sale_date:flags.0?int last_sale_price:flags.0?long floor_price:flags.2?long average_price:flags.3?long listed_count:flags.4?int fragment_listed_count:flags.5?int fragment_listed_url:flags.5?string = payments.UniqueStarGiftValueInfo; + +profileTabPosts#b98cd696 = ProfileTab; +profileTabGifts#4d4bd46a = ProfileTab; +profileTabMedia#72c64955 = ProfileTab; +profileTabFiles#ab339c00 = ProfileTab; +profileTabMusic#9f27d26e = ProfileTab; +profileTabVoice#e477092e = ProfileTab; +profileTabLinks#d3656499 = ProfileTab; +profileTabGifs#a2c0f695 = ProfileTab; + +users.savedMusicNotModified#e3878aa4 count:int = users.SavedMusic; +users.savedMusic#34a2f297 count:int documents:Vector = users.SavedMusic; + +account.savedMusicIdsNotModified#4fc81d6e = account.SavedMusicIds; +account.savedMusicIds#998d6636 ids:Vector = account.SavedMusicIds; + +payments.checkCanSendGiftResultOk#374fa7ad = payments.CheckCanSendGiftResult; +payments.checkCanSendGiftResultFail#d5e58274 reason:TextWithEntities = payments.CheckCanSendGiftResult; + +inputChatThemeEmpty#83268483 = InputChatTheme; +inputChatTheme#c93de95c emoticon:string = InputChatTheme; +inputChatThemeUniqueGift#87e5dfe4 slug:string = InputChatTheme; + +starGiftUpgradePrice#99ea331d date:int upgrade_stars:long = StarGiftUpgradePrice; + +groupCallMessage#1a8afc7e flags:# from_admin:flags.1?true id:int from_id:Peer date:int message:TextWithEntities paid_message_stars:flags.0?long = GroupCallMessage; + +groupCallDonor#ee430c85 flags:# top:flags.0?true my:flags.1?true peer_id:flags.3?Peer stars:long = GroupCallDonor; + +phone.groupCallStars#9d1dbd26 total_stars:long top_donors:Vector chats:Vector users:Vector = phone.GroupCallStars; + +recentStory#711d692d flags:# live:flags.0?true max_id:flags.1?int = RecentStory; + +auctionBidLevel#310240cc pos:int amount:long date:int = AuctionBidLevel; + +starGiftAuctionStateNotModified#fe333952 = StarGiftAuctionState; +starGiftAuctionState#771a4e66 version:int start_date:int end_date:int min_bid_amount:long bid_levels:Vector top_bidders:Vector next_round_at:int last_gift_num:int gifts_left:int current_round:int total_rounds:int rounds:Vector = StarGiftAuctionState; +starGiftAuctionStateFinished#972dabbf flags:# start_date:int end_date:int average_price:long listed_count:flags.0?int fragment_listed_count:flags.1?int fragment_listed_url:flags.1?string = StarGiftAuctionState; + +starGiftAuctionUserState#2eeed1c4 flags:# returned:flags.1?true bid_amount:flags.0?long bid_date:flags.0?int min_bid_amount:flags.0?long bid_peer:flags.0?Peer acquired_count:int = StarGiftAuctionUserState; + +payments.starGiftAuctionState#6b39f4ec gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState timeout:int users:Vector chats:Vector = payments.StarGiftAuctionState; + +starGiftAuctionAcquiredGift#42b00348 flags:# name_hidden:flags.0?true peer:Peer date:int bid_amount:long round:int pos:int message:flags.1?TextWithEntities gift_num:flags.2?int = StarGiftAuctionAcquiredGift; + +payments.starGiftAuctionAcquiredGifts#7d5bd1f0 gifts:Vector users:Vector chats:Vector = payments.StarGiftAuctionAcquiredGifts; + +starGiftActiveAuctionState#d31bc45d gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState = StarGiftActiveAuctionState; + +payments.starGiftActiveAuctionsNotModified#db33dad0 = payments.StarGiftActiveAuctions; +payments.starGiftActiveAuctions#aef6abbc auctions:Vector users:Vector chats:Vector = payments.StarGiftActiveAuctions; + +inputStarGiftAuction#2e16c98 gift_id:long = InputStarGiftAuction; +inputStarGiftAuctionSlug#7ab58308 slug:string = InputStarGiftAuction; + +passkey#98613ebf flags:# id:string name:string date:int software_emoji_id:flags.0?long last_usage_date:flags.1?int = Passkey; + +account.passkeys#f8e0aa1c passkeys:Vector = account.Passkeys; + +account.passkeyRegistrationOptions#e16b5ce1 options:DataJSON = account.PasskeyRegistrationOptions; + +auth.passkeyLoginOptions#e2037789 options:DataJSON = auth.PasskeyLoginOptions; + +inputPasskeyResponseRegister#3e63935c client_data:DataJSON attestation_data:bytes = InputPasskeyResponse; +inputPasskeyResponseLogin#c31fc14a client_data:DataJSON authenticator_data:bytes signature:bytes user_handle:string = InputPasskeyResponse; + +inputPasskeyCredentialPublicKey#3c27b78f id:string raw_id:string response:InputPasskeyResponse = InputPasskeyCredential; +inputPasskeyCredentialFirebasePNV#5b1ccb28 pnv_token:string = InputPasskeyCredential; + +starGiftBackground#aff56398 center_color:int edge_color:int text_color:int = StarGiftBackground; + +starGiftAuctionRound#3aae0528 num:int duration:int = StarGiftAuctionRound; +starGiftAuctionRoundExtendable#aa021e5 num:int duration:int extend_top:int extend_window:int = StarGiftAuctionRound; + +payments.starGiftUpgradeAttributes#46c6e36f attributes:Vector = payments.StarGiftUpgradeAttributes; + +messages.emojiGameOutcome#da2ad647 seed:bytes stake_ton_amount:long ton_amount:long = messages.EmojiGameOutcome; + +messages.emojiGameUnavailable#59e65335 = messages.EmojiGameInfo; +messages.emojiGameDiceInfo#44e56023 flags:# game_hash:string prev_stake:long current_streak:int params:Vector plays_left:flags.0?int = messages.EmojiGameInfo; + +---functions--- + +invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; +invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; +invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; +invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; +invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; +invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; +invokeWithBusinessConnection#dd289f8e {X:Type} connection_id:string query:!X = X; +invokeWithGooglePlayIntegrity#1df92984 {X:Type} nonce:string token:string query:!X = X; +invokeWithApnsSecret#0dae54f8 {X:Type} nonce:string secret:string query:!X = X; +invokeWithReCaptcha#adbb0f94 {X:Type} token:string query:!X = X; + +auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; +auth.signUp#aac7b717 flags:# no_joined_notifications:flags.0?true phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +auth.signIn#8d52a951 flags:# phone_number:string phone_code_hash:string phone_code:flags.0?string email_verification:flags.1?EmailVerification = auth.Authorization; +auth.logOut#3e72ba19 = auth.LoggedOut; +auth.resetAuthorizations#9fab0d1a = Bool; +auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; +auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; +auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; +auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; +auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; +auth.resendCode#cae47523 flags:# phone_number:string phone_code_hash:string reason:flags.0?string = auth.SentCode; +auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; +auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; +auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; +auth.acceptLoginToken#e894ad4d token:bytes = Authorization; +auth.checkRecoveryPassword#d36bf79 code:string = Bool; +auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; +auth.requestFirebaseSms#8e39261e flags:# phone_number:string phone_code_hash:string safety_net_token:flags.0?string play_integrity_token:flags.2?string ios_push_secret:flags.1?string = Bool; +auth.resetLoginEmail#7e960193 phone_number:string phone_code_hash:string = auth.SentCode; +auth.reportMissingCode#cb9deff6 phone_number:string phone_code_hash:string mnc:string = Bool; +auth.checkPaidAuth#56e59f9c phone_number:string phone_code_hash:string form_id:long = auth.SentCode; +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; + +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; +account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; +account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; +account.resetNotifySettings#db7e1747 = Bool; +account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; +account.updateStatus#6628562c offline:Bool = Bool; +account.getWallPapers#7967d36 hash:long = account.WallPapers; +account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; +account.checkUsername#2714d86c username:string = Bool; +account.updateUsername#3e0bdd7c username:string = User; +account.getPrivacy#dadbc950 key:InputPrivacyKey = account.PrivacyRules; +account.setPrivacy#c9f81ce8 key:InputPrivacyKey rules:Vector = account.PrivacyRules; +account.deleteAccount#a2c0cf74 flags:# reason:string password:flags.0?InputCheckPasswordSRP = Bool; +account.getAccountTTL#8fc711d = AccountDaysTTL; +account.setAccountTTL#2442485e ttl:AccountDaysTTL = Bool; +account.sendChangePhoneCode#82574ae5 phone_number:string settings:CodeSettings = auth.SentCode; +account.changePhone#70c32edb phone_number:string phone_code_hash:string phone_code:string = User; +account.updateDeviceLocked#38df3532 period:int = Bool; +account.getAuthorizations#e320c158 = account.Authorizations; +account.resetAuthorization#df77f3bc hash:long = Bool; +account.getPassword#548a30f5 = account.Password; +account.getPasswordSettings#9cd4eaf9 password:InputCheckPasswordSRP = account.PasswordSettings; +account.updatePasswordSettings#a59b102f password:InputCheckPasswordSRP new_settings:account.PasswordInputSettings = Bool; +account.sendConfirmPhoneCode#1b3faa88 hash:string settings:CodeSettings = auth.SentCode; +account.confirmPhone#5f2178c3 phone_code_hash:string phone_code:string = Bool; +account.getTmpPassword#449e0b51 password:InputCheckPasswordSRP period:int = account.TmpPassword; +account.getWebAuthorizations#182e6d6f = account.WebAuthorizations; +account.resetWebAuthorization#2d01b9ef hash:long = Bool; +account.resetWebAuthorizations#682d2594 = Bool; +account.getAllSecureValues#b288bc7d = Vector; +account.getSecureValue#73665bc2 types:Vector = Vector; +account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; +account.deleteSecureValue#b880bc4b types:Vector = Bool; +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; +account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; +account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; +account.sendVerifyEmailCode#98e037bb purpose:EmailVerifyPurpose email:string = account.SentEmailCode; +account.verifyEmail#32da4cf purpose:EmailVerifyPurpose verification:EmailVerification = account.EmailVerified; +account.initTakeoutSession#8ef3eab0 flags:# contacts:flags.0?true message_users:flags.1?true message_chats:flags.2?true message_megagroups:flags.3?true message_channels:flags.4?true files:flags.5?true file_max_size:flags.5?long = account.Takeout; +account.finishTakeoutSession#1d2652ee flags:# success:flags.0?true = Bool; +account.confirmPasswordEmail#8fdf1920 code:string = Bool; +account.resendPasswordEmail#7a7f2a15 = Bool; +account.cancelPasswordEmail#c1cbd5b6 = Bool; +account.getContactSignUpNotification#9f07c728 = Bool; +account.setContactSignUpNotification#cff43f61 silent:Bool = Bool; +account.getNotifyExceptions#53577479 flags:# compare_sound:flags.1?true compare_stories:flags.2?true peer:flags.0?InputNotifyPeer = Updates; +account.getWallPaper#fc8ddbea wallpaper:InputWallPaper = WallPaper; +account.uploadWallPaper#e39a8f03 flags:# for_chat:flags.0?true file:InputFile mime_type:string settings:WallPaperSettings = WallPaper; +account.saveWallPaper#6c5a5b37 wallpaper:InputWallPaper unsave:Bool settings:WallPaperSettings = Bool; +account.installWallPaper#feed5769 wallpaper:InputWallPaper settings:WallPaperSettings = Bool; +account.resetWallPapers#bb3b9804 = Bool; +account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; +account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; +account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; +account.getTheme#3a5869ec format:string theme:InputTheme = Theme; +account.getThemes#7206e458 format:string hash:long = account.Themes; +account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; +account.getContentSettings#8b9b4dae = account.ContentSettings; +account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; +account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; +account.resetPassword#9308ce1b = account.ResetPasswordResult; +account.declinePasswordReset#4c9409f6 = Bool; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# confirmed:flags.3?true hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; +account.getSavedRingtones#e1902288 hash:long = account.SavedRingtones; +account.saveRingtone#3dea5b03 id:InputDocument unsave:Bool = account.SavedRingtone; +account.uploadRingtone#831a83a2 file:InputFile file_name:string mime_type:string = Document; +account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; +account.getDefaultEmojiStatuses#d6753386 hash:long = account.EmojiStatuses; +account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; +account.clearRecentEmojiStatuses#18201aae = Bool; +account.reorderUsernames#ef500eab order:Vector = Bool; +account.toggleUsername#58d6b376 username:string active:Bool = Bool; +account.getDefaultProfilePhotoEmojis#e2750328 hash:long = EmojiList; +account.getDefaultGroupPhotoEmojis#915860ae hash:long = EmojiList; +account.getAutoSaveSettings#adcbbcda = account.AutoSaveSettings; +account.saveAutoSaveSettings#d69b8361 flags:# users:flags.0?true chats:flags.1?true broadcasts:flags.2?true peer:flags.3?InputPeer settings:AutoSaveSettings = Bool; +account.deleteAutoSaveExceptions#53bc0020 = Bool; +account.invalidateSignInCodes#ca8ae8ba codes:Vector = Bool; +account.updateColor#684d214e flags:# for_profile:flags.1?true color:flags.2?PeerColor = Bool; +account.getDefaultBackgroundEmojis#a60ab9ce hash:long = EmojiList; +account.getChannelDefaultEmojiStatuses#7727a7d5 hash:long = account.EmojiStatuses; +account.getChannelRestrictedStatusEmojis#35a9e0d5 hash:long = EmojiList; +account.updateBusinessWorkHours#4b00e066 flags:# business_work_hours:flags.0?BusinessWorkHours = Bool; +account.updateBusinessLocation#9e6b131a flags:# geo_point:flags.1?InputGeoPoint address:flags.0?string = Bool; +account.updateBusinessGreetingMessage#66cdafc4 flags:# message:flags.0?InputBusinessGreetingMessage = Bool; +account.updateBusinessAwayMessage#a26a7fa5 flags:# message:flags.0?InputBusinessAwayMessage = Bool; +account.updateConnectedBot#66a08c7e flags:# deleted:flags.1?true rights:flags.0?BusinessBotRights bot:InputUser recipients:InputBusinessBotRecipients = Updates; +account.getConnectedBots#4ea4c80f = account.ConnectedBots; +account.getBotBusinessConnection#76a86270 connection_id:string = Updates; +account.updateBusinessIntro#a614d034 flags:# intro:flags.0?InputBusinessIntro = Bool; +account.toggleConnectedBotPaused#646e1097 peer:InputPeer paused:Bool = Bool; +account.disablePeerConnectedBot#5e437ed9 peer:InputPeer = Bool; +account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool; +account.createBusinessChatLink#8851e68e link:InputBusinessChatLink = BusinessChatLink; +account.editBusinessChatLink#8c3410af slug:string link:InputBusinessChatLink = BusinessChatLink; +account.deleteBusinessChatLink#60073674 slug:string = Bool; +account.getBusinessChatLinks#6f70dde1 = account.BusinessChatLinks; +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; +account.updatePersonalChannel#d94305e0 channel:InputChannel = Bool; +account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; +account.getReactionsNotifySettings#6dd654c = ReactionsNotifySettings; +account.setReactionsNotifySettings#316ce548 settings:ReactionsNotifySettings = ReactionsNotifySettings; +account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; +account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; +account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; +account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; +account.saveMusic#b26732a9 flags:# unsave:flags.0?true id:InputDocument after_id:flags.1?InputDocument = Bool; +account.getSavedMusicIds#e09d5faf hash:long = account.SavedMusicIds; +account.getUniqueGiftChatThemes#e42ce9c9 offset:string limit:int hash:long = account.ChatThemes; +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; +account.getPasskeys#ea1f0c52 = account.Passkeys; +account.deletePasskey#f5b5563f id:string = Bool; + +users.getUsers#d91a548 id:Vector = Vector; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; +users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; +users.getRequirementsToContact#d89a83a3 id:Vector = Vector; +users.getSavedMusic#788d7fe3 id:InputUser offset:int limit:int hash:long = users.SavedMusic; +users.getSavedMusicByID#7573a4e9 id:InputUser documents:Vector = users.SavedMusic; +users.suggestBirthday#fc533372 id:InputUser birthday:Birthday = Updates; + +contacts.getContactIDs#7adc669d hash:long = Vector; +contacts.getStatuses#c4a353ee = Vector; +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; +contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; +contacts.deleteContacts#96a0e00 id:Vector = Updates; +contacts.deleteByPhones#1013fd9e phones:Vector = Bool; +contacts.block#2e2e8734 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.unblock#b550d328 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.getBlocked#9a868f80 flags:# my_stories_from:flags.0?true offset:int limit:int = contacts.Blocked; +contacts.search#11f812d8 q:string limit:int = contacts.Found; +contacts.resolveUsername#725afbbc flags:# username:string referer:flags.0?string = contacts.ResolvedPeer; +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true bots_app:flags.16?true offset:int limit:int hash:long = contacts.TopPeers; +contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; +contacts.resetSaved#879537f1 = Bool; +contacts.getSaved#82f1e39f = Vector; +contacts.toggleTopPeers#8514bdda enabled:Bool = Bool; +contacts.addContact#d9ba2e54 flags:# add_phone_privacy_exception:flags.0?true id:InputUser first_name:string last_name:string phone:string note:flags.1?TextWithEntities = Updates; +contacts.acceptContact#f831a20f id:InputUser = Updates; +contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; +contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; +contacts.exportContactToken#f8654027 = ExportedContactToken; +contacts.importContactToken#13005788 token:string = User; +contacts.editCloseFriends#ba6705f0 id:Vector = Bool; +contacts.setBlocked#94c65c76 flags:# my_stories_from:flags.0?true id:Vector limit:int = Bool; +contacts.getBirthdays#daeda864 = contacts.ContactBirthdays; +contacts.getSponsoredPeers#b6c8c393 q:string = contacts.SponsoredPeers; +contacts.updateContactNote#139f63fb id:InputUser note:TextWithEntities = Bool; + +messages.getMessages#63c66506 id:Vector = messages.Messages; +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; +messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.search#29ee847a flags:# peer:InputPeer q:string from_id:flags.0?InputPeer saved_peer_id:flags.2?InputPeer saved_reaction:flags.3?Vector top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; +messages.receivedMessages#5a954c0 max_id:int = Vector; +messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; +messages.sendMessage#545cd15a flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.sendMedia#330e77f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.forwardMessages#13704a7c flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true allow_paid_floodskip:flags.19?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer top_msg_id:flags.9?int reply_to:flags.22?InputReplyTo schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long video_timestamp:flags.20?int allow_paid_stars:flags.21?long suggested_post:flags.23?SuggestedPost = Updates; +messages.reportSpam#cf1592db peer:InputPeer = Bool; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; +messages.report#fc78af9b peer:InputPeer id:Vector option:bytes message:string = ReportResult; +messages.getChats#49e9528f id:Vector = messages.Chats; +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; +messages.addChatUser#cbc6d107 chat_id:long user_id:InputUser fwd_limit:int = messages.InvitedUsers; +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; +messages.createChat#92ceddd4 flags:# users:Vector title:string ttl_period:flags.0?int = messages.InvitedUsers; +messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; +messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; +messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; +messages.discardEncryption#f393aea0 flags:# delete_history:flags.0?true chat_id:int = Bool; +messages.setEncryptedTyping#791451ed peer:InputEncryptedChat typing:Bool = Bool; +messages.readEncryptedHistory#7f4b690a peer:InputEncryptedChat max_date:int = Bool; +messages.sendEncrypted#44fa7a15 flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.sendEncryptedFile#5559481d flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes file:InputEncryptedFile = messages.SentEncryptedMessage; +messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.receivedQueue#55a5bb66 max_qts:int = Vector; +messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; +messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; +messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; +messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; +messages.getWebPagePreview#570d6f6f flags:# message:string entities:flags.3?Vector = messages.WebPagePreview; +messages.exportChatInvite#a455de90 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string subscription_pricing:flags.5?StarsSubscriptionPricing = ExportedChatInvite; +messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; +messages.importChatInvite#6c50051c hash:string = Updates; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; +messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; +messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; +messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; +messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; +messages.migrateChat#a2875319 chat_id:long = Updates; +messages.searchGlobal#4bc6589a flags:# broadcasts_only:flags.1?true groups_only:flags.2?true users_only:flags.3?true folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; +messages.reorderStickerSets#78337739 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Bool; +messages.getDocumentByHash#b1f2061f sha256:bytes size:long mime_type:string = Document; +messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; +messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; +messages.setInlineBotResults#bb12a419 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM switch_webview:flags.4?InlineBotWebView = Bool; +messages.sendInlineBotResult#c0cf7646 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to:flags.0?InputReplyTo random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut allow_paid_stars:flags.21?long = Updates; +messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; +messages.editMessage#51e842e1 flags:# no_webpage:flags.1?true invert_media:flags.16?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int schedule_repeat_period:flags.18?int quick_reply_shortcut_id:flags.17?int = Updates; +messages.editInlineBotMessage#83557dba flags:# no_webpage:flags.1?true invert_media:flags.16?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; +messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer; +messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; +messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; +messages.saveDraft#54ae308e flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo peer:InputPeer message:string entities:flags.3?Vector media:flags.5?InputMedia effect:flags.7?long suggested_post:flags.8?SuggestedPost = Bool; +messages.getAllDrafts#6a3f8d65 = Updates; +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; +messages.readFeaturedStickers#5b118126 id:Vector = Bool; +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; +messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; +messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true emojis:flags.1?true offset_id:long limit:int = messages.ArchivedStickers; +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; +messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; +messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; +messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; +messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; +messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; +messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; +messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; +messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?string shipping_options:flags.1?Vector = Bool; +messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; +messages.uploadMedia#14967978 flags:# business_connection_id:flags.0?string peer:InputPeer media:InputMedia = MessageMedia; +messages.sendScreenshotNotification#a1405817 peer:InputPeer reply_to:InputReplyTo random_id:long = Updates; +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; +messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; +messages.getUnreadMentions#f107e790 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readMentions#36e5bf4d flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; +messages.sendMultiMedia#1bf89d74 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long = Updates; +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSplitRanges#1cff7e08 = Vector; +messages.markDialogUnread#8c5006f8 flags:# unread:flags.0?true parent_peer:flags.1?InputPeer peer:InputDialogPeer = Bool; +messages.getDialogUnreadMarks#21202222 flags:# parent_peer:flags.0?InputPeer = Vector; +messages.clearAllDrafts#7e58ee9c = Bool; +messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1?true pm_oneside:flags.2?true peer:InputPeer id:int = Updates; +messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; +messages.getPollResults#73bb643b peer:InputPeer msg_id:int = Updates; +messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; +messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; +messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; +messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; +messages.getEmojiKeywordsDifference#1508b6af lang_code:string from_version:int = EmojiKeywordsDifference; +messages.getEmojiKeywordsLanguages#4e9963b2 lang_codes:Vector = Vector; +messages.getEmojiURL#d5b10c26 lang_code:string = EmojiURL; +messages.getSearchCounters#1bbcf300 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer top_msg_id:flags.0?int filters:Vector = Vector; +messages.requestUrlAuth#198fb446 flags:# peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string = UrlAuthResult; +messages.acceptUrlAuth#b12c7125 flags:# write_allowed:flags.0?true peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string = UrlAuthResult; +messages.hidePeerSettingsBar#4facb138 peer:InputPeer = Bool; +messages.getScheduledHistory#f516760b peer:InputPeer hash:long = messages.Messages; +messages.getScheduledMessages#bdbb0464 peer:InputPeer id:Vector = messages.Messages; +messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; +messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; +messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; +messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; +messages.getDialogFilters#efd48c89 = messages.DialogFilters; +messages.getSuggestedDialogFilters#a29cd42c = Vector; +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; +messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; +messages.unpinAllMessages#62dd747 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; +messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; +messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; +messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:string media:InputMedia = MessageMedia; +messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; +messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; +messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true subscription_expired:flags.3?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; +messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; +messages.setChatTheme#81202c9 peer:InputPeer theme:InputChatTheme = Updates; +messages.getMessageReadParticipants#31c1c44f peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#6aa3f6bd flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#9c7f2f10 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#d30d78d4 flags:# big:flags.1?true add_to_recent:flags.2?true peer:InputPeer msg_id:int reaction:flags.0?Vector = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#864b2581 flags:# peer:InputPeer available_reactions:ChatReactions reactions_limit:flags.0?int paid_enabled:flags.1?Bool = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool; +messages.translateText#63183030 flags:# peer:flags.0?InputPeer id:flags.0?Vector text:flags.1?Vector to_lang:string = messages.TranslatedText; +messages.getUnreadReactions#bd7f90ac flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#9ec44f93 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; +messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; +messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; +messages.toggleBotInAttachMenu#69f59d69 flags:# write_allowed:flags.0?true bot:InputUser enabled:Bool = Bool; +messages.requestWebView#269dc2c1 flags:# from_bot_menu:flags.4?true silent:flags.5?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser url:flags.1?string start_param:flags.3?string theme_params:flags.2?DataJSON platform:string reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = WebViewResult; +messages.prolongWebView#b0d81a83 flags:# silent:flags.5?true peer:InputPeer bot:InputUser query_id:long reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = Bool; +messages.requestSimpleWebView#413a3e73 flags:# from_switch_webview:flags.1?true from_side_menu:flags.2?true compact:flags.7?true fullscreen:flags.8?true bot:InputUser url:flags.3?string start_param:flags.4?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent; +messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates; +messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio; +messages.rateTranscribedAudio#7f1d072f peer:InputPeer msg_id:int transcription_id:long good:Bool = Bool; +messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; +messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; +messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; +messages.reportReaction#3f64c076 peer:InputPeer id:int reaction_peer:InputPeer = Bool; +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; +messages.clearRecentReactions#9dfeefb4 = Bool; +messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; +messages.sendBotRequestedPeer#91b2d060 peer:InputPeer msg_id:int button_id:int requested_peers:Vector = Updates; +messages.getEmojiGroups#7488ce5b hash:int = messages.EmojiGroups; +messages.getEmojiStatusGroups#2ecd56cd hash:int = messages.EmojiGroups; +messages.getEmojiProfilePhotoGroups#21a548f3 hash:int = messages.EmojiGroups; +messages.searchCustomEmoji#2c11c0d7 emoticon:string hash:long = EmojiList; +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; +messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; +messages.requestAppWebView#53618bce flags:# write_allowed:flags.0?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = WebViewResult; +messages.setChatWallPaper#8ffacae1 flags:# for_both:flags.3?true revert:flags.4?true peer:InputPeer wallpaper:flags.0?InputWallPaper settings:flags.2?WallPaperSettings id:flags.1?int = Updates; +messages.searchEmojiStickerSets#92b4494c flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSavedDialogs#1e91fc99 flags:# exclude_pinned:flags.0?true parent_peer:flags.1?InputPeer offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; +messages.getSavedHistory#998ab009 flags:# parent_peer:flags.0?InputPeer peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.deleteSavedHistory#4dc5085f flags:# parent_peer:flags.0?InputPeer peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; +messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedSavedDialogs#8b716587 flags:# force:flags.0?true order:Vector = Bool; +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; +messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; +messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; +messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; +messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; +messages.reorderQuickReplies#60331907 order:Vector = Bool; +messages.checkQuickReplyShortcut#f1d0fbd3 shortcut:string = Bool; +messages.editQuickReplyShortcut#5c003cef shortcut_id:int shortcut:string = Bool; +messages.deleteQuickReplyShortcut#3cc04740 shortcut_id:int = Bool; +messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; +messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; +messages.deleteQuickReplyMessages#e105e910 shortcut_id:int id:Vector = Updates; +messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool; +messages.getMyStickers#d0b5e1fc offset_id:long limit:int = messages.MyStickers; +messages.getEmojiStickerGroups#1dd840f5 hash:int = messages.EmojiGroups; +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; +messages.editFactCheck#589ee75 peer:InputPeer msg_id:int text:TextWithEntities = Updates; +messages.deleteFactCheck#d1da940c peer:InputPeer msg_id:int = Updates; +messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; +messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendPaidReaction#58bbcb50 flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?PaidReactionPrivacy = Updates; +messages.togglePaidReactionPrivacy#435885b5 peer:InputPeer msg_id:int private:PaidReactionPrivacy = Bool; +messages.getPaidReactionPrivacy#472455aa = Updates; +messages.viewSponsoredMessage#269e3643 random_id:bytes = Bool; +messages.clickSponsoredMessage#8235057e flags:# media:flags.0?true fullscreen:flags.1?true random_id:bytes = Bool; +messages.reportSponsoredMessage#12cbf0c4 random_id:bytes option:bytes = channels.SponsoredMessageReportResult; +messages.getSponsoredMessages#3d6ce850 flags:# peer:InputPeer msg_id:flags.0?int = messages.SponsoredMessages; +messages.savePreparedInlineMessage#f21f7f2f flags:# result:InputBotInlineResult user_id:InputUser peer_types:flags.0?Vector = messages.BotPreparedInlineMessage; +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; +messages.searchStickers#29b1c66a flags:# emojis:flags.0?true q:string emoticon:string lang_code:Vector offset:int limit:int hash:long = messages.FoundStickers; +messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = Bool; +messages.getSavedDialogsByID#6f6f9c96 flags:# parent_peer:flags.1?InputPeer ids:Vector = messages.SavedDialogs; +messages.readSavedHistory#ba4a3b5b parent_peer:InputPeer peer:InputPeer max_id:int = Bool; +messages.toggleTodoCompleted#d3e03124 peer:InputPeer msg_id:int completed:Vector incompleted:Vector = Updates; +messages.appendTodoList#21a61057 peer:InputPeer msg_id:int list:Vector = Updates; +messages.toggleSuggestedPostApproval#8107455c flags:# reject:flags.1?true peer:InputPeer msg_id:int schedule_date:flags.0?int reject_comment:flags.2?string = Updates; +messages.getForumTopics#3ba47bff flags:# peer:InputPeer q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; +messages.getForumTopicsByID#af0a4a08 peer:InputPeer topics:Vector = messages.ForumTopics; +messages.editForumTopic#cecc1134 flags:# peer:InputPeer topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; +messages.updatePinnedForumTopic#175df251 peer:InputPeer topic_id:int pinned:Bool = Updates; +messages.reorderPinnedForumTopics#e7841f0 flags:# force:flags.0?true peer:InputPeer order:Vector = Updates; +messages.createForumTopic#2f98c3d5 flags:# title_missing:flags.4?true peer:InputPeer title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; +messages.deleteTopicHistory#d2816f10 peer:InputPeer top_msg_id:int = messages.AffectedHistory; +messages.getEmojiGameInfo#fb7e8ca7 = messages.EmojiGameInfo; +messages.summarizeText#9d4104e2 flags:# peer:InputPeer id:int to_lang:flags.0?string = TextWithEntities; + +updates.getState#edd4882a = updates.State; +updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; +updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; + +photos.updateProfilePhoto#9e82039 flags:# fallback:flags.0?true bot:flags.1?InputUser id:InputPhoto = photos.Photo; +photos.uploadProfilePhoto#388a3b5 flags:# fallback:flags.3?true bot:flags.5?InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.4?VideoSize = photos.Photo; +photos.deletePhotos#87cf7f2f id:Vector = Vector; +photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; +photos.uploadContactProfilePhoto#e14c4a71 flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.5?VideoSize = photos.Photo; + +upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; +upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; +upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool; +upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile; +upload.getCdnFile#395f69da file_token:bytes offset:long limit:int = upload.CdnFile; +upload.reuploadCdnFile#9b2754a8 file_token:bytes request_token:bytes = Vector; +upload.getCdnFileHashes#91dc3f31 file_token:bytes offset:long = Vector; +upload.getFileHashes#9156982a location:InputFileLocation offset:long = Vector; + +help.getConfig#c4f9186b = Config; +help.getNearestDc#1fb33026 = NearestDc; +help.getAppUpdate#522d5a7d source:string = help.AppUpdate; +help.getInviteText#4d392343 = help.InviteText; +help.getSupport#9cdf08cd = help.Support; +help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; +help.getCdnConfig#52029342 = CdnConfig; +help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; +help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; +help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; +help.getAppConfig#61e3f854 hash:int = help.AppConfig; +help.saveAppLog#6f02f748 events:Vector = Bool; +help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; +help.getSupportName#d360e72c = help.SupportName; +help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; +help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; +help.getPromoData#c0977421 = help.PromoData; +help.hidePromoData#1e251c95 peer:InputPeer = Bool; +help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; +help.getPremiumPromo#b81b93d4 = help.PremiumPromo; +help.getPeerColors#da80f42f hash:int = help.PeerColors; +help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors; +help.getTimezonesList#49b30240 hash:int = help.TimezonesList; + +channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; +channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; +channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; +channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; +channels.getChannels#a7f6bbb id:Vector = messages.Chats; +channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true forum:flags.5?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; +channels.editAdmin#d33c8902 channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:string = Updates; +channels.editTitle#566decd0 channel:InputChannel title:string = Updates; +channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; +channels.checkUsername#10e6bd2c channel:InputChannel username:string = Bool; +channels.updateUsername#3514b3de channel:InputChannel username:string = Bool; +channels.joinChannel#24b524c5 channel:InputChannel = Updates; +channels.leaveChannel#f836aa95 channel:InputChannel = Updates; +channels.inviteToChannel#c9e33d54 channel:InputChannel users:Vector = messages.InvitedUsers; +channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; +channels.exportMessageLink#e63fadeb flags:# grouped:flags.0?true thread:flags.1?true channel:InputChannel id:int = ExportedMessageLink; +channels.toggleSignatures#418d549c flags:# signatures_enabled:flags.0?true profiles_enabled:flags.1?true channel:InputChannel = Updates; +channels.getAdminedPublicChannels#f8b036af flags:# by_location:flags.0?true check_limit:flags.1?true for_personal:flags.2?true = messages.Chats; +channels.editBanned#96e6cd81 channel:InputChannel participant:InputPeer banned_rights:ChatBannedRights = Updates; +channels.getAdminLog#33ddf480 flags:# channel:InputChannel q:string events_filter:flags.0?ChannelAdminLogEventsFilter admins:flags.1?Vector max_id:long min_id:long limit:int = channels.AdminLogResults; +channels.setStickers#ea8ca4f9 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = Bool; +channels.deleteHistory#9baa9647 flags:# for_everyone:flags.0?true channel:InputChannel max_id:int = Updates; +channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates; +channels.getLeftChannels#8341ecc0 offset:int = messages.Chats; +channels.getGroupsForDiscussion#f5dad378 = messages.Chats; +channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool; +channels.editCreator#8f38cd1f channel:InputChannel user_id:InputUser password:InputCheckPasswordSRP = Updates; +channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint address:string = Bool; +channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; +channels.getInactiveChannels#11e831ee = messages.InactiveChats; +channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; +channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; +channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; +channels.toggleJoinRequest#4c2985b6 channel:InputChannel enabled:Bool = Updates; +channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; +channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; +channels.deactivateAllUsernames#a245dd3 channel:InputChannel = Bool; +channels.toggleForum#3ff75734 channel:InputChannel enabled:Bool tabs:Bool = Updates; +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; +channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; +channels.updateColor#d8aa3671 flags:# for_profile:flags.1?true channel:InputChannel color:flags.2?int background_emoji_id:flags.0?long = Updates; +channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; +channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; +channels.updateEmojiStatus#f0d3e6a8 channel:InputChannel emoji_status:EmojiStatus = Updates; +channels.setBoostsToUnblockRestrictions#ad399cee channel:InputChannel boosts:int = Updates; +channels.setEmojiStickers#3cd930b7 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.restrictSponsoredMessages#9ae91519 channel:InputChannel restricted:Bool = Updates; +channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; +channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; +channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; +channels.getMessageAuthor#ece2a0e6 channel:InputChannel id:int = User; +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; +channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool; + +bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; +bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; +bots.setBotMenuButton#4504d54f user_id:InputUser button:BotMenuButton = Bool; +bots.getBotMenuButton#9c60eb28 user_id:InputUser = BotMenuButton; +bots.setBotBroadcastDefaultAdminRights#788464e1 admin_rights:ChatAdminRights = Bool; +bots.setBotGroupDefaultAdminRights#925ec9ea admin_rights:ChatAdminRights = Bool; +bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; +bots.getBotInfo#dcd914fd flags:# bot:flags.0?InputUser lang_code:string = bots.BotInfo; +bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = Bool; +bots.toggleUsername#53ca973 bot:InputUser username:string active:Bool = Bool; +bots.canSendMessage#1359f4e6 bot:InputUser = Bool; +bots.allowSendMessage#f132e3ef bot:InputUser = Updates; +bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; +bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots; +bots.addPreviewMedia#17aeb75a bot:InputUser lang_code:string media:InputMedia = BotPreviewMedia; +bots.editPreviewMedia#8525606f bot:InputUser lang_code:string media:InputMedia new_media:InputMedia = BotPreviewMedia; +bots.deletePreviewMedia#2d0135b3 bot:InputUser lang_code:string media:Vector = Bool; +bots.reorderPreviewMedias#b627f3aa bot:InputUser lang_code:string order:Vector = Bool; +bots.getPreviewInfo#423ab3ad bot:InputUser lang_code:string = bots.PreviewInfo; +bots.getPreviewMedias#a2a5594d bot:InputUser = Vector; +bots.updateUserEmojiStatus#ed9f30c5 user_id:InputUser emoji_status:EmojiStatus = Bool; +bots.toggleUserEmojiStatusPermission#6de6392 bot:InputUser enabled:Bool = Bool; +bots.checkDownloadFileParams#50077589 bot:InputUser file_name:string url:string = Bool; +bots.getAdminedBots#b0711d83 = Vector; +bots.updateStarRefProgram#778b5ab3 flags:# bot:InputUser commission_permille:int duration_months:flags.0?int = StarRefProgram; +bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool; +bots.getBotRecommendations#a1b70815 bot:InputUser = users.Users; + +payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; +payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; +payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; +payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; +payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; +payments.getBankCardData#2e79d779 number:string = payments.BankCardData; +payments.exportInvoice#f91b065 invoice_media:InputMedia = payments.ExportedInvoice; +payments.assignAppStoreTransaction#80ed747d receipt:bytes purpose:InputStorePaymentPurpose = Updates; +payments.assignPlayMarketTransaction#dffd50d3 receipt:DataJSON purpose:InputStorePaymentPurpose = Updates; +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; +payments.applyGiftCode#f6e26854 slug:string = Updates; +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; +payments.getStarsTopupOptions#c00ec7d3 = Vector; +payments.getStarsStatus#4ea9b3bf flags:# ton:flags.0?true peer:InputPeer = payments.StarsStatus; +payments.getStarsTransactions#69da4557 flags:# inbound:flags.0?true outbound:flags.1?true ascending:flags.2?true ton:flags.4?true subscription_id:flags.3?string peer:InputPeer offset:string limit:int = payments.StarsStatus; +payments.sendStarsForm#7998c914 form_id:long invoice:InputInvoice = payments.PaymentResult; +payments.refundStarsCharge#25ae8f4a user_id:InputUser charge_id:string = Updates; +payments.getStarsRevenueStats#d91ffad6 flags:# dark:flags.0?true ton:flags.1?true peer:InputPeer = payments.StarsRevenueStats; +payments.getStarsRevenueWithdrawalUrl#2433dc92 flags:# ton:flags.0?true peer:InputPeer amount:flags.1?long password:InputCheckPasswordSRP = payments.StarsRevenueWithdrawalUrl; +payments.getStarsRevenueAdsAccountUrl#d1d7efc5 peer:InputPeer = payments.StarsRevenueAdsAccountUrl; +payments.getStarsTransactionsByID#2dca16b8 flags:# ton:flags.0?true peer:InputPeer id:Vector = payments.StarsStatus; +payments.getStarsGiftOptions#d3c96bc8 flags:# user_id:flags.0?InputUser = Vector; +payments.getStarsSubscriptions#32512c5 flags:# missing_balance:flags.0?true peer:InputPeer offset:string = payments.StarsStatus; +payments.changeStarsSubscription#c7770878 flags:# peer:InputPeer subscription_id:string canceled:flags.0?Bool = Bool; +payments.fulfillStarsSubscription#cc5bebb3 peer:InputPeer subscription_id:string = Bool; +payments.getStarsGiveawayOptions#bd1efd3e = Vector; +payments.getStarGifts#c4563590 hash:int = payments.StarGifts; +payments.saveStarGift#2a2a697c flags:# unsave:flags.0?true stargift:InputSavedStarGift = Bool; +payments.convertStarGift#74bf076b stargift:InputSavedStarGift = Bool; +payments.botCancelStarsSubscription#6dfa0622 flags:# restore:flags.0?true user_id:InputUser charge_id:string = Bool; +payments.getConnectedStarRefBots#5869a553 flags:# peer:InputPeer offset_date:flags.2?int offset_link:flags.2?string limit:int = payments.ConnectedStarRefBots; +payments.getConnectedStarRefBot#b7d998f0 peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.getSuggestedStarRefBots#d6b48f7 flags:# order_by_revenue:flags.0?true order_by_date:flags.1?true peer:InputPeer offset:string limit:int = payments.SuggestedStarRefBots; +payments.connectStarRefBot#7ed5348a peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.editConnectedStarRefBot#e4fca4a3 flags:# revoked:flags.0?true peer:InputPeer link:string = payments.ConnectedStarRefBots; +payments.getStarGiftUpgradePreview#9c9abcb1 gift_id:long = payments.StarGiftUpgradePreview; +payments.upgradeStarGift#aed6e4f5 flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = Updates; +payments.transferStarGift#7f18176a stargift:InputSavedStarGift to_id:InputPeer = Updates; +payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift; +payments.getSavedStarGifts#a319e569 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_unique:flags.4?true sort_by_value:flags.5?true exclude_upgradable:flags.7?true exclude_unupgradable:flags.8?true peer_color_available:flags.9?true exclude_hosted:flags.10?true peer:InputPeer collection_id:flags.6?int offset:string limit:int = payments.SavedStarGifts; +payments.getSavedStarGift#b455a106 stargift:Vector = payments.SavedStarGifts; +payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl; +payments.toggleChatStarGiftNotifications#60eaefa1 flags:# enabled:flags.0?true peer:InputPeer = Bool; +payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = Bool; +payments.canPurchaseStore#4fdc5ea7 purpose:InputStorePaymentPurpose = Bool; +payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector offset:string limit:int = payments.ResaleStarGifts; +payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates; +payments.createStarGiftCollection#1f4a0e87 peer:InputPeer title:string stargift:Vector = StarGiftCollection; +payments.updateStarGiftCollection#4fddbee7 flags:# peer:InputPeer collection_id:int title:flags.0?string delete_stargift:flags.1?Vector add_stargift:flags.2?Vector order:flags.3?Vector = StarGiftCollection; +payments.reorderStarGiftCollections#c32af4cc peer:InputPeer order:Vector = Bool; +payments.deleteStarGiftCollection#ad5648e8 peer:InputPeer collection_id:int = Bool; +payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections; +payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGiftValueInfo; +payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; +payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; +payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; +payments.sendStarGiftOffer#8fb86b41 flags:# peer:InputPeer slug:string price:StarsAmount duration:int random_id:long allow_paid_stars:flags.0?long = Updates; +payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; + +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true emojis:flags.5?true text_color:flags.6?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; +stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; +stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; +stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; +stickers.setStickerSetThumb#a76a5392 flags:# stickerset:InputStickerSet thumb:flags.0?InputDocument thumb_document_id:flags.1?long = messages.StickerSet; +stickers.checkShortName#284b3639 short_name:string = Bool; +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; +stickers.changeSticker#f5537ebc flags:# sticker:InputDocument emoji:flags.0?string mask_coords:flags.1?MaskCoords keywords:flags.2?string = messages.StickerSet; +stickers.renameStickerSet#124b1c00 stickerset:InputStickerSet title:string = messages.StickerSet; +stickers.deleteStickerSet#87704394 stickerset:InputStickerSet = Bool; +stickers.replaceSticker#4696459a sticker:InputDocument new_sticker:InputStickerSetItem = messages.StickerSet; + +phone.getCallConfig#55451fa9 = DataJSON; +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.toggleGroupCallSettings#974392f2 flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool messages_enabled:flags.2?Bool send_paid_messages_stars:flags.3?long = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; +phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; +phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; +phone.getGroupCallStreamRtmpUrl#5af4c73a flags:# live_story:flags.0?true peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; +phone.saveCallLog#41248786 peer:InputPhoneCall file:InputFile = Bool; +phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates; +phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector block:bytes = Updates; +phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates; +phone.inviteConferenceCallParticipant#bcf22685 flags:# video:flags.0?true call:InputGroupCall user_id:InputUser = Updates; +phone.declineConferenceCallInvite#3c479971 msg_id:int = Updates; +phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates; +phone.sendGroupCallMessage#b1d11410 flags:# call:InputGroupCall random_id:long message:TextWithEntities allow_paid_stars:flags.0?long send_as:flags.1?InputPeer = Updates; +phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool; +phone.deleteGroupCallMessages#f64f54f7 flags:# report_spam:flags.0?true call:InputGroupCall messages:Vector = Updates; +phone.deleteGroupCallParticipantMessages#1dbfeca0 flags:# report_spam:flags.0?true call:InputGroupCall participant:InputPeer = Updates; +phone.getGroupCallStars#6f636302 call:InputGroupCall = phone.GroupCallStars; +phone.saveDefaultSendAs#4167add1 call:InputGroupCall send_as:InputPeer = Bool; + +langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; +langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; +langpack.getDifference#cd984aa5 lang_pack:string lang_code:string from_version:int = LangPackDifference; +langpack.getLanguages#42c6978f lang_pack:string = Vector; +langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLanguage; + +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; + +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; +stats.getMessagePublicForwards#5f150144 channel:InputChannel msg_id:int offset:string limit:int = stats.PublicForwards; +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; +stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats; +stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards; + +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = chatlists.ExportedChatlistInvite; +chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool; +chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector = ExportedChatlistInvite; +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; +chatlists.getChatlistUpdates#89419521 chatlist:InputChatlist = chatlists.ChatlistUpdates; +chatlists.joinChatlistUpdates#e089f8f5 chatlist:InputChatlist peers:Vector = Updates; +chatlists.hideChatlistUpdates#66e486fb chatlist:InputChatlist = Bool; +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates; + +stories.canSendStory#30eb63f0 peer:InputPeer = stories.CanSendStoryCount; +stories.sendStory#737fc2ec flags:# pinned:flags.2?true noforwards:flags.4?true fwd_modified:flags.7?true peer:InputPeer media:InputMedia media_areas:flags.5?Vector caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long period:flags.3?int fwd_from_id:flags.6?InputPeer fwd_from_story:flags.6?int albums:flags.8?Vector = Updates; +stories.editStory#b583ba46 flags:# peer:InputPeer id:int media:flags.0?InputMedia media_areas:flags.3?Vector caption:flags.1?string entities:flags.1?Vector privacy_rules:flags.2?Vector = Updates; +stories.deleteStories#ae59db5f peer:InputPeer id:Vector = Vector; +stories.togglePinned#9a75a1ef peer:InputPeer id:Vector pinned:Bool = Vector; +stories.getAllStories#eeb0d625 flags:# next:flags.1?true hidden:flags.2?true state:flags.0?string = stories.AllStories; +stories.getPinnedStories#5821a5dc peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesArchive#b4352016 peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesByID#5774ca74 peer:InputPeer id:Vector = stories.Stories; +stories.toggleAllStoriesHidden#7c2557c4 hidden:Bool = Bool; +stories.readStories#a556dac8 peer:InputPeer max_id:int = Vector; +stories.incrementStoryViews#b2028afb peer:InputPeer id:Vector = Bool; +stories.getStoryViewsList#7ed23c57 flags:# just_contacts:flags.0?true reactions_first:flags.2?true forwards_first:flags.3?true peer:InputPeer q:flags.1?string id:int offset:string limit:int = stories.StoryViewsList; +stories.getStoriesViews#28e16cc8 peer:InputPeer id:Vector = stories.StoryViews; +stories.exportStoryLink#7b8def20 peer:InputPeer id:int = ExportedStoryLink; +stories.report#19d8eb45 peer:InputPeer id:Vector option:bytes message:string = ReportResult; +stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?true = Updates; +stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; +stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; +stories.getAllReadPeerStories#9b5ae7f9 = Updates; +stories.getPeerMaxIDs#78499170 id:Vector = Vector; +stories.getChatsToSend#a56a8b60 = messages.Chats; +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.getStoryReactionsList#b9b2881f flags:# forwards_first:flags.2?true peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = stories.StoryReactionsList; +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; +stories.searchPosts#d1810907 flags:# hashtag:flags.0?string area:flags.1?MediaArea peer:flags.2?InputPeer offset:string limit:int = stories.FoundStories; +stories.createAlbum#a36396e5 peer:InputPeer title:string stories:Vector = StoryAlbum; +stories.updateAlbum#5e5259b6 flags:# peer:InputPeer album_id:int title:flags.0?string delete_stories:flags.1?Vector add_stories:flags.2?Vector order:flags.3?Vector = StoryAlbum; +stories.reorderAlbums#8535fbd9 peer:InputPeer order:Vector = Bool; +stories.deleteAlbum#8d3456d0 peer:InputPeer album_id:int = Bool; +stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums; +stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories; +stories.startLive#d069ccde flags:# pinned:flags.2?true noforwards:flags.4?true rtmp_stream:flags.5?true peer:InputPeer caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long messages_enabled:flags.6?Bool send_paid_messages_stars:flags.7?long = Updates; + +premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; +premium.getMyBoosts#be77b4a = premium.MyBoosts; +premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; +premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus; +premium.getUserBoosts#39854d1f peer:InputPeer user_id:InputUser = premium.BoostsList; + +smsjobs.isEligibleToJoin#edc39d0 = smsjobs.EligibilityToJoin; +smsjobs.join#a74ece2d = Bool; +smsjobs.leave#9898ad73 = Bool; +smsjobs.updateSettings#93fa0bf flags:# allow_international:flags.0?true = Bool; +smsjobs.getStatus#10a698e8 = smsjobs.Status; +smsjobs.getSmsJob#778d902f job_id:string = SmsJob; +smsjobs.finishJob#4f1ebf24 flags:# job_id:string error:flags.0?string = Bool; + +fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo; + +// LAYER 221 diff --git a/internal/compat/layerwire/_schema/layer-222.tl b/internal/compat/layerwire/_schema/layer-222.tl new file mode 100644 index 00000000..77968bd2 --- /dev/null +++ b/internal/compat/layerwire/_schema/layer-222.tl @@ -0,0 +1,2906 @@ +boolFalse#bc799737 = Bool; +boolTrue#997275b5 = Bool; + +true#3fedd339 = True; + +vector#1cb5c415 {t:Type} # [ t ] = Vector t; + +error#c4b9f9bb code:int text:string = Error; + +null#56730bcc = Null; + +inputPeerEmpty#7f3b18ea = InputPeer; +inputPeerSelf#7da07ec9 = InputPeer; +inputPeerChat#35a95cb9 chat_id:long = InputPeer; +inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer; +inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer; +inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer; +inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer; + +inputUserEmpty#b98886cf = InputUser; +inputUserSelf#f7c1b13f = InputUser; +inputUser#f21158c6 user_id:long access_hash:long = InputUser; +inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser; + +inputPhoneContact#6a1dc4be flags:# client_id:long phone:string first_name:string last_name:string note:flags.0?TextWithEntities = InputContact; + +inputFile#f52ff27f id:long parts:int name:string md5_checksum:string = InputFile; +inputFileBig#fa4f0bb5 id:long parts:int name:string = InputFile; +inputFileStoryDocument#62dc8b48 id:InputDocument = InputFile; + +inputMediaEmpty#9664f57f = InputMedia; +inputMediaUploadedPhoto#1e287d04 flags:# spoiler:flags.2?true file:InputFile stickers:flags.0?Vector ttl_seconds:flags.1?int = InputMedia; +inputMediaPhoto#b3ba0635 flags:# spoiler:flags.1?true id:InputPhoto ttl_seconds:flags.0?int = InputMedia; +inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia; +inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia; +inputMediaUploadedDocument#37c9330 flags:# nosound_video:flags.3?true force_file:flags.4?true spoiler:flags.5?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector video_cover:flags.6?InputPhoto video_timestamp:flags.7?int ttl_seconds:flags.1?int = InputMedia; +inputMediaDocument#a8763ab5 flags:# spoiler:flags.2?true id:InputDocument video_cover:flags.3?InputPhoto video_timestamp:flags.4?int ttl_seconds:flags.0?int query:flags.1?string = InputMedia; +inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia; +inputMediaPhotoExternal#e5bbfe1a flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int = InputMedia; +inputMediaDocumentExternal#779600f9 flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int video_cover:flags.2?InputPhoto video_timestamp:flags.3?int = InputMedia; +inputMediaGame#d33f43f3 id:InputGame = InputMedia; +inputMediaInvoice#405fef0d flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:flags.3?string provider_data:DataJSON start_param:flags.1?string extended_media:flags.2?InputMedia = InputMedia; +inputMediaGeoLive#971fa843 flags:# stopped:flags.0?true geo_point:InputGeoPoint heading:flags.2?int period:flags.1?int proximity_notification_radius:flags.3?int = InputMedia; +inputMediaPoll#f94e5f1 flags:# poll:Poll correct_answers:flags.0?Vector solution:flags.1?string solution_entities:flags.1?Vector = InputMedia; +inputMediaDice#e66fbf7b emoticon:string = InputMedia; +inputMediaStory#89fdd778 peer:InputPeer id:int = InputMedia; +inputMediaWebPage#c21b8849 flags:# force_large_media:flags.0?true force_small_media:flags.1?true optional:flags.2?true url:string = InputMedia; +inputMediaPaidMedia#c4103386 flags:# stars_amount:long extended_media:Vector payload:flags.0?string = InputMedia; +inputMediaTodo#9fc55fde todo:TodoList = InputMedia; +inputMediaStakeDice#f3a9244a game_hash:string ton_amount:long client_seed:bytes = InputMedia; + +inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; +inputChatUploadedPhoto#bdcdaec0 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.3?VideoSize = InputChatPhoto; +inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; + +inputGeoPointEmpty#e4c123d6 = InputGeoPoint; +inputGeoPoint#48222faf flags:# lat:double long:double accuracy_radius:flags.0?int = InputGeoPoint; + +inputPhotoEmpty#1cd7bf0d = InputPhoto; +inputPhoto#3bb3b94a id:long access_hash:long file_reference:bytes = InputPhoto; + +inputFileLocation#dfdaabe1 volume_id:long local_id:int secret:long file_reference:bytes = InputFileLocation; +inputEncryptedFileLocation#f5235d55 id:long access_hash:long = InputFileLocation; +inputDocumentFileLocation#bad07584 id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputSecureFileLocation#cbc7ee28 id:long access_hash:long = InputFileLocation; +inputTakeoutFileLocation#29be5899 = InputFileLocation; +inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation; +inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation; +inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation; +inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation; + +peerUser#59511722 user_id:long = Peer; +peerChat#36c6019a chat_id:long = Peer; +peerChannel#a2a5371e channel_id:long = Peer; + +storage.fileUnknown#aa963b05 = storage.FileType; +storage.filePartial#40bc6f52 = storage.FileType; +storage.fileJpeg#7efe0e = storage.FileType; +storage.fileGif#cae1aadf = storage.FileType; +storage.filePng#a4f63c0 = storage.FileType; +storage.filePdf#ae1e508d = storage.FileType; +storage.fileMp3#528a0677 = storage.FileType; +storage.fileMov#4b09ebbc = storage.FileType; +storage.fileMp4#b3cea0e4 = storage.FileType; +storage.fileWebp#1081464c = storage.FileType; + +userEmpty#d3bc4b7a id:long = User; +user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true bot_forum_can_manage_topics:flags2.17?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User; + +userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; +userProfilePhoto#82d1f706 flags:# has_video:flags.0?true personal:flags.2?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto; + +userStatusEmpty#9d05049 = UserStatus; +userStatusOnline#edb93949 expires:int = UserStatus; +userStatusOffline#8c703f was_online:int = UserStatus; +userStatusRecently#7b197dc8 flags:# by_me:flags.0?true = UserStatus; +userStatusLastWeek#541a1d1a flags:# by_me:flags.0?true = UserStatus; +userStatusLastMonth#65899777 flags:# by_me:flags.0?true = UserStatus; + +chatEmpty#29562865 id:long = Chat; +chat#41cbf256 flags:# creator:flags.0?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; +chatForbidden#6592a1a7 id:long title:string = Chat; +channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat; +channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true monoforum:flags.10?true id:long access_hash:long title:string until_date:flags.16?int = Chat; + +chatFull#2633421b flags:# can_set_username:flags.7?true has_scheduled:flags.8?true translations_disabled:flags.19?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions reactions_limit:flags.20?int = ChatFull; +channelFull#e4e0b29d flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true participants_hidden:flags2.2?true translations_disabled:flags2.3?true stories_pinned_available:flags2.5?true view_forum_as_messages:flags2.6?true restricted_sponsored:flags2.11?true can_view_revenue:flags2.12?true paid_media_allowed:flags2.14?true can_view_stars_revenue:flags2.15?true paid_reactions_available:flags2.16?true stargifts_available:flags2.19?true paid_messages_available:flags2.20?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions reactions_limit:flags2.13?int stories:flags2.4?PeerStories wallpaper:flags2.7?WallPaper boosts_applied:flags2.8?int boosts_unrestrict:flags2.9?int emojiset:flags2.10?StickerSet bot_verification:flags2.17?BotVerification stargifts_count:flags2.18?int send_paid_messages_stars:flags2.21?long main_tab:flags2.22?ProfileTab = ChatFull; + +chatParticipant#c02d4007 user_id:long inviter_id:long date:int = ChatParticipant; +chatParticipantCreator#e46bcee4 user_id:long = ChatParticipant; +chatParticipantAdmin#a0933f5b user_id:long inviter_id:long date:int = ChatParticipant; + +chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants; +chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants; + +chatPhotoEmpty#37c1011c = ChatPhoto; +chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; + +messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; +message#9cb490e9 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true invert_media:flags.27?true flags2:# offline:flags2.1?true video_processing_pending:flags2.4?true paid_suggested_post_stars:flags2.8?true paid_suggested_post_ton:flags2.9?true id:int from_id:flags.8?Peer from_boosts_applied:flags.29?int peer_id:Peer saved_peer_id:flags.28?Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long via_business_bot_id:flags2.0?long reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int quick_reply_shortcut_id:flags.30?int effect:flags2.2?long factcheck:flags2.3?FactCheck report_delivery_until_date:flags2.5?int paid_message_stars:flags2.6?long suggested_post:flags2.7?SuggestedPost schedule_repeat_period:flags2.10?int summary_from_language:flags2.11?string = Message; +messageService#7a800e0a flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true reactions_are_possible:flags.9?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer saved_peer_id:flags.28?Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction reactions:flags.20?MessageReactions ttl_period:flags.25?int = Message; + +messageMediaEmpty#3ded6320 = MessageMedia; +messageMediaPhoto#695150d7 flags:# spoiler:flags.3?true photo:flags.0?Photo ttl_seconds:flags.2?int = MessageMedia; +messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia; +messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia; +messageMediaUnsupported#9f84f49e = MessageMedia; +messageMediaDocument#52d8ccd9 flags:# nopremium:flags.3?true spoiler:flags.4?true video:flags.6?true round:flags.7?true voice:flags.8?true document:flags.0?Document alt_documents:flags.5?Vector video_cover:flags.9?Photo video_timestamp:flags.10?int ttl_seconds:flags.2?int = MessageMedia; +messageMediaWebPage#ddf10c3b flags:# force_large_media:flags.0?true force_small_media:flags.1?true manual:flags.3?true safe:flags.4?true webpage:WebPage = MessageMedia; +messageMediaVenue#2ec0533f geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MessageMedia; +messageMediaGame#fdb19008 game:Game = MessageMedia; +messageMediaInvoice#f6a548d3 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string extended_media:flags.4?MessageExtendedMedia = MessageMedia; +messageMediaGeoLive#b940c666 flags:# geo:GeoPoint heading:flags.0?int period:int proximity_notification_radius:flags.1?int = MessageMedia; +messageMediaPoll#4bd6e798 poll:Poll results:PollResults = MessageMedia; +messageMediaDice#8cbec07 flags:# value:int emoticon:string game_outcome:flags.0?messages.EmojiGameOutcome = MessageMedia; +messageMediaStory#68cb6283 flags:# via_mention:flags.1?true peer:Peer id:int story:flags.0?StoryItem = MessageMedia; +messageMediaGiveaway#aa073beb flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.2?true channels:Vector countries_iso2:flags.1?Vector prize_description:flags.3?string quantity:int months:flags.4?int stars:flags.5?long until_date:int = MessageMedia; +messageMediaGiveawayResults#ceaa3ea1 flags:# only_new_subscribers:flags.0?true refunded:flags.2?true channel_id:long additional_peers_count:flags.3?int launch_msg_id:int winners_count:int unclaimed_count:int winners:Vector months:flags.4?int stars:flags.5?long prize_description:flags.1?string until_date:int = MessageMedia; +messageMediaPaidMedia#a8852491 stars_amount:long extended_media:Vector = MessageMedia; +messageMediaToDo#8a53b014 flags:# todo:TodoList completions:flags.0?Vector = MessageMedia; +messageMediaVideoStream#ca5cab89 flags:# rtmp_stream:flags.0?true call:InputGroupCall = MessageMedia; + +messageActionEmpty#b6aef7b0 = MessageAction; +messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction; +messageActionChatEditTitle#b5a1ce5a title:string = MessageAction; +messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction; +messageActionChatDeletePhoto#95e3fbef = MessageAction; +messageActionChatAddUser#15cefd00 users:Vector = MessageAction; +messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction; +messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction; +messageActionChannelCreate#95d2ac92 title:string = MessageAction; +messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction; +messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction; +messageActionPinMessage#94bd38ed = MessageAction; +messageActionHistoryClear#9fbab604 = MessageAction; +messageActionGameScore#92a72876 game_id:long score:int = MessageAction; +messageActionPaymentSentMe#ffa00ccc flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string charge:PaymentCharge subscription_until_date:flags.4?int = MessageAction; +messageActionPaymentSent#c624b16e flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long invoice_slug:flags.0?string subscription_until_date:flags.4?int = MessageAction; +messageActionPhoneCall#80e11a7f flags:# video:flags.2?true call_id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = MessageAction; +messageActionScreenshotTaken#4792929b = MessageAction; +messageActionCustomAction#fae69f56 message:string = MessageAction; +messageActionBotAllowed#c516d679 flags:# attach_menu:flags.1?true from_request:flags.3?true domain:flags.0?string app:flags.2?BotApp = MessageAction; +messageActionSecureValuesSentMe#1b287353 values:Vector credentials:SecureCredentialsEncrypted = MessageAction; +messageActionSecureValuesSent#d95c6154 types:Vector = MessageAction; +messageActionContactSignUp#f3f25f76 = MessageAction; +messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; +messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; +messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; +messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; +messageActionSetChatTheme#b91bbd3a theme:ChatTheme = MessageAction; +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; +messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; +messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; +messageActionGiftPremium#48e91302 flags:# currency:string amount:long days:int crypto_currency:flags.0?string crypto_amount:flags.0?long message:flags.1?TextWithEntities = MessageAction; +messageActionTopicCreate#d999256 flags:# title_missing:flags.1?true title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; +messageActionSuggestProfilePhoto#57de635e photo:Photo = MessageAction; +messageActionRequestedPeer#31518e9b button_id:int peers:Vector = MessageAction; +messageActionSetChatWallPaper#5060a3f4 flags:# same:flags.0?true for_both:flags.1?true wallpaper:WallPaper = MessageAction; +messageActionGiftCode#31c48347 flags:# via_giveaway:flags.0?true unclaimed:flags.5?true boost_peer:flags.1?Peer days:int slug:string currency:flags.2?string amount:flags.2?long crypto_currency:flags.3?string crypto_amount:flags.3?long message:flags.4?TextWithEntities = MessageAction; +messageActionGiveawayLaunch#a80f51e4 flags:# stars:flags.0?long = MessageAction; +messageActionGiveawayResults#87e2f155 flags:# stars:flags.0?true winners_count:int unclaimed_count:int = MessageAction; +messageActionBoostApply#cc02aa6d boosts:int = MessageAction; +messageActionRequestedPeerSentMe#93b31848 button_id:int peers:Vector = MessageAction; +messageActionPaymentRefunded#41b3e202 flags:# peer:Peer currency:string total_amount:long payload:flags.0?bytes charge:PaymentCharge = MessageAction; +messageActionGiftStars#45d5b021 flags:# currency:string amount:long stars:long crypto_currency:flags.0?string crypto_amount:flags.0?long transaction_id:flags.1?string = MessageAction; +messageActionPrizeStars#b00c47a2 flags:# unclaimed:flags.0?true stars:long transaction_id:string boost_peer:Peer giveaway_msg_id:int = MessageAction; +messageActionStarGift#ea2c31d3 flags:# name_hidden:flags.0?true saved:flags.2?true converted:flags.3?true upgraded:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true prepaid_upgrade:flags.13?true upgrade_separate:flags.16?true auction_acquired:flags.17?true gift:StarGift message:flags.1?TextWithEntities convert_stars:flags.4?long upgrade_msg_id:flags.5?int upgrade_stars:flags.8?long from_id:flags.11?Peer peer:flags.12?Peer saved_id:flags.12?long prepaid_upgrade_hash:flags.14?string gift_msg_id:flags.15?int to_id:flags.18?Peer gift_num:flags.19?int = MessageAction; +messageActionStarGiftUnique#e6c31522 flags:# upgrade:flags.0?true transferred:flags.1?true saved:flags.2?true refunded:flags.5?true prepaid_upgrade:flags.11?true assigned:flags.13?true from_offer:flags.14?true craft:flags.16?true gift:StarGift can_export_at:flags.3?int transfer_stars:flags.4?long from_id:flags.6?Peer peer:flags.7?Peer saved_id:flags.7?long resale_amount:flags.8?StarsAmount can_transfer_at:flags.9?int can_resell_at:flags.10?int drop_original_details_stars:flags.12?long can_craft_at:flags.15?int = MessageAction; +messageActionPaidMessagesRefunded#ac1f1fcd count:int stars:long = MessageAction; +messageActionPaidMessagesPrice#84b88578 flags:# broadcast_messages_allowed:flags.0?true stars:long = MessageAction; +messageActionConferenceCall#2ffe2f7a flags:# missed:flags.0?true active:flags.1?true video:flags.4?true call_id:long duration:flags.2?int other_participants:flags.3?Vector = MessageAction; +messageActionTodoCompletions#cc7c5c89 completed:Vector incompleted:Vector = MessageAction; +messageActionTodoAppendTasks#c7edbc83 list:Vector = MessageAction; +messageActionSuggestedPostApproval#ee7a1596 flags:# rejected:flags.0?true balance_too_low:flags.1?true reject_comment:flags.2?string schedule_date:flags.3?int price:flags.4?StarsAmount = MessageAction; +messageActionSuggestedPostSuccess#95ddcf69 price:StarsAmount = MessageAction; +messageActionSuggestedPostRefund#69f916f8 flags:# payer_initiated:flags.0?true = MessageAction; +messageActionGiftTon#a8a3c699 flags:# currency:string amount:long crypto_currency:string crypto_amount:long transaction_id:flags.0?string = MessageAction; +messageActionSuggestBirthday#2c8f2a25 birthday:Birthday = MessageAction; +messageActionStarGiftPurchaseOffer#774278d4 flags:# accepted:flags.0?true declined:flags.1?true gift:StarGift price:StarsAmount expires_at:int = MessageAction; +messageActionStarGiftPurchaseOfferDeclined#73ada76b flags:# expired:flags.0?true gift:StarGift price:StarsAmount = MessageAction; +messageActionNewCreatorPending#b07ed085 new_creator_id:long = MessageAction; +messageActionChangeCreator#e188503b new_creator_id:long = MessageAction; + +dialog#d58a08c6 flags:# pinned:flags.2?true unread_mark:flags.3?true view_forum_as_messages:flags.6?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; +dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; + +photoEmpty#2331b22d id:long = Photo; +photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; + +photoSizeEmpty#e17e23c type:string = PhotoSize; +photoSize#75c78e60 type:string w:int h:int size:int = PhotoSize; +photoCachedSize#21e1ad6 type:string w:int h:int bytes:bytes = PhotoSize; +photoStrippedSize#e0b0bc2e type:string bytes:bytes = PhotoSize; +photoSizeProgressive#fa3efb95 type:string w:int h:int sizes:Vector = PhotoSize; +photoPathSize#d8214d41 type:string bytes:bytes = PhotoSize; + +geoPointEmpty#1117dd5f = GeoPoint; +geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint; + +auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; +auth.sentCodeSuccess#2390fe44 authorization:auth.Authorization = auth.SentCode; +auth.sentCodePaymentRequired#e0955a3c store_product:string phone_code_hash:string support_email_address:string support_email_subject:string currency:string amount:long = auth.SentCode; + +auth.authorization#2ea2c0d4 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int future_auth_token:flags.2?bytes user:User = auth.Authorization; +auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; + +auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; + +inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer; +inputNotifyUsers#193b4417 = InputNotifyPeer; +inputNotifyChats#4a95e84e = InputNotifyPeer; +inputNotifyBroadcasts#b1db7c7e = InputNotifyPeer; +inputNotifyForumTopic#5c467992 peer:InputPeer top_msg_id:int = InputNotifyPeer; + +inputPeerNotifySettings#cacb6ae2 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_sound:flags.8?NotificationSound = InputPeerNotifySettings; + +peerNotifySettings#99622c0c flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int ios_sound:flags.3?NotificationSound android_sound:flags.4?NotificationSound other_sound:flags.5?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_ios_sound:flags.8?NotificationSound stories_android_sound:flags.9?NotificationSound stories_other_sound:flags.10?NotificationSound = PeerNotifySettings; + +peerSettings#f47741f7 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true business_bot_paused:flags.11?true business_bot_can_reply:flags.12?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int business_bot_id:flags.13?long business_bot_manage_url:flags.13?string charge_paid_message_stars:flags.14?long registration_month:flags.15?string phone_country:flags.16?string name_change_date:flags.17?int photo_change_date:flags.18?int = PeerSettings; + +wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; +wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; + +inputReportReasonSpam#58dbcab8 = ReportReason; +inputReportReasonViolence#1e22c78d = ReportReason; +inputReportReasonPornography#2e59d922 = ReportReason; +inputReportReasonChildAbuse#adf44ee3 = ReportReason; +inputReportReasonOther#c1e4a2b1 = ReportReason; +inputReportReasonCopyright#9b89f93a = ReportReason; +inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; +inputReportReasonFake#f5ddd6e7 = ReportReason; +inputReportReasonIllegalDrugs#a8eb2be = ReportReason; +inputReportReasonPersonalDetails#9ec7863d = ReportReason; + +userFull#a02bc13e flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true voice_messages_forbidden:flags.20?true translations_disabled:flags.23?true stories_pinned_available:flags.26?true blocked_my_stories_from:flags.27?true wallpaper_overridden:flags.28?true contact_require_premium:flags.29?true read_dates_private:flags.30?true flags2:# sponsored_enabled:flags2.7?true can_view_revenue:flags2.9?true bot_can_manage_emoji_status:flags2.10?true display_gifts_button:flags2.16?true id:long about:flags.1?string settings:PeerSettings personal_photo:flags.21?Photo profile_photo:flags.2?Photo fallback_photo:flags.22?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme:flags.15?ChatTheme private_forward_name:flags.16?string bot_group_admin_rights:flags.17?ChatAdminRights bot_broadcast_admin_rights:flags.18?ChatAdminRights wallpaper:flags.24?WallPaper stories:flags.25?PeerStories business_work_hours:flags2.0?BusinessWorkHours business_location:flags2.1?BusinessLocation business_greeting_message:flags2.2?BusinessGreetingMessage business_away_message:flags2.3?BusinessAwayMessage business_intro:flags2.4?BusinessIntro birthday:flags2.5?Birthday personal_channel_id:flags2.6?long personal_channel_message:flags2.6?int stargifts_count:flags2.8?int starref_program:flags2.11?StarRefProgram bot_verification:flags2.12?BotVerification send_paid_messages_stars:flags2.14?long disallowed_gifts:flags2.15?DisallowedGiftsSettings stars_rating:flags2.17?StarsRating stars_my_pending_rating:flags2.18?StarsRating stars_my_pending_rating_date:flags2.18?int main_tab:flags2.20?ProfileTab saved_music:flags2.21?Document note:flags2.22?TextWithEntities = UserFull; + +contact#145ade0b user_id:long mutual:Bool = Contact; + +importedContact#c13e3c50 user_id:long client_id:long = ImportedContact; + +contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus; + +contacts.contactsNotModified#b74ba9d2 = contacts.Contacts; +contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts; + +contacts.importedContacts#77d01c3b imported:Vector popular_invites:Vector retry_contacts:Vector users:Vector = contacts.ImportedContacts; + +contacts.blocked#ade1591 blocked:Vector chats:Vector users:Vector = contacts.Blocked; +contacts.blockedSlice#e1664194 count:int blocked:Vector chats:Vector users:Vector = contacts.Blocked; + +messages.dialogs#15ba6c40 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsNotModified#f0e3e596 count:int = messages.Dialogs; + +messages.messages#1d73e7ea messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesSlice#5f206716 flags:# inexact:flags.1?true count:int next_rate:flags.0?int offset_id_offset:flags.2?int search_flood:flags.3?SearchPostsFlood messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.channelMessages#c776ba4e flags:# inexact:flags.1?true pts:int count:int offset_id_offset:flags.2?int messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesNotModified#74535f21 count:int = messages.Messages; + +messages.chats#64ff9fd5 chats:Vector = messages.Chats; +messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; + +messages.chatFull#e5d7d19c full_chat:ChatFull chats:Vector users:Vector = messages.ChatFull; + +messages.affectedHistory#b45c69d1 pts:int pts_count:int offset:int = messages.AffectedHistory; + +inputMessagesFilterEmpty#57e2f66c = MessagesFilter; +inputMessagesFilterPhotos#9609a51c = MessagesFilter; +inputMessagesFilterVideo#9fc00e65 = MessagesFilter; +inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; +inputMessagesFilterDocument#9eddf188 = MessagesFilter; +inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; +inputMessagesFilterGif#ffc86587 = MessagesFilter; +inputMessagesFilterVoice#50f5c392 = MessagesFilter; +inputMessagesFilterMusic#3751b49e = MessagesFilter; +inputMessagesFilterChatPhotos#3a20ecb8 = MessagesFilter; +inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFilter; +inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; +inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; +inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; +inputMessagesFilterGeo#e7026d0d = MessagesFilter; +inputMessagesFilterContacts#e062db83 = MessagesFilter; +inputMessagesFilterPinned#1bb00451 = MessagesFilter; + +updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; +updateMessageID#4e90bfd6 id:int random_id:long = Update; +updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; +updateUserTyping#2a17bf5c flags:# user_id:long top_msg_id:flags.0?int action:SendMessageAction = Update; +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; +updateChatParticipants#7761198 participants:ChatParticipants = Update; +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; +updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector = Update; +updateNewAuthorization#8951abef flags:# unconfirmed:flags.0?true hash:long date:flags.0?int device:flags.0?string location:flags.0?string = Update; +updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; +updateEncryptedChatTyping#1710f156 chat_id:int = Update; +updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; +updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; +updateDcOptions#8e5e9873 dc_options:Vector = Update; +updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; +updateServiceNotification#ebe46819 flags:# popup:flags.0?true invert_media:flags.2?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; +updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; +updateUserPhone#5492a13 user_id:long phone:string = Update; +updateReadHistoryInbox#9e84bc99 flags:# folder_id:flags.0?int peer:Peer top_msg_id:flags.1?int max_id:int still_unread_count:int pts:int pts_count:int = Update; +updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; +updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; +updateReadMessagesContents#f8227181 flags:# messages:Vector pts:int pts_count:int date:flags.0?int = Update; +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; +updateChannel#635b4c09 channel_id:long = Update; +updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; +updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; +updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Update; +updateStickerSets#31c24808 flags:# masks:flags.0?true emojis:flags.1?true = Update; +updateSavedGifs#9375341e = Update; +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; +updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; +updateDraftMessage#edfc111e flags:# peer:Peer top_msg_id:flags.0?int saved_peer_id:flags.1?Peer draft:DraftMessage = Update; +updateReadFeaturedStickers#571d2742 = Update; +updateRecentStickers#9a422c20 = Update; +updateConfig#a229dd06 = Update; +updatePtsChanged#3354678f = Update; +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; +updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; +updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; +updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; +updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; +updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; +updateLangPackTooLong#46560264 lang_code:string = Update; +updateLangPack#56022f4d difference:LangPackDifference = Update; +updateFavedStickers#e511996d = Update; +updateChannelReadMessagesContents#25f324f7 flags:# channel_id:long top_msg_id:flags.0?int saved_peer_id:flags.1?Peer messages:Vector = Update; +updateContactsReset#7084a7be = Update; +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; +updateDialogUnreadMark#b658f23e flags:# unread:flags.0?true peer:DialogPeer saved_peer_id:flags.1?Peer = Update; +updateMessagePoll#aca1657b flags:# poll_id:long poll:flags.0?Poll results:PollResults = Update; +updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; +updateFolderPeers#19360dc0 folder_peers:Vector pts:int pts_count:int = Update; +updatePeerSettings#6a7e7366 peer:Peer settings:PeerSettings = Update; +updatePeerLocated#b4afcfb0 peers:Vector = Update; +updateNewScheduledMessage#39a51dfb message:Message = Update; +updateDeleteScheduledMessages#f2a71983 flags:# peer:Peer messages:Vector sent_messages:flags.0?Vector = Update; +updateTheme#8216fba3 theme:Theme = Update; +updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; +updateLoginToken#564fe691 = Update; +updateMessagePollVote#24f40e77 poll_id:long peer:Peer options:Vector qts:int = Update; +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; +updateDialogFilterOrder#a5d72105 order:Vector = Update; +updateDialogFilters#3504914f = Update; +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; +updatePeerBlocked#ebe07752 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer = Update; +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; +updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChat#f89a6a4e chat_id:long = Update; +updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; +updateGroupCall#9d2216e0 flags:# live_story:flags.2?true peer:flags.1?Peer call:GroupCall = Update; +updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateChannelParticipant#985d3abb flags:# via_chatlist:flags.3?true channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; +updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; +updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#11dfa986 peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int = Update; +updateMessageReactions#1e297bfa flags:# peer:Peer msg_id:int top_msg_id:flags.0?int saved_peer_id:flags.1?Peer reactions:MessageReactions = Update; +updateAttachMenuBots#17b7a20b = Update; +updateWebViewResultSent#1592b79d query_id:long = Update; +updateBotMenuButton#14b85813 bot_id:long button:BotMenuButton = Update; +updateSavedRingtones#74d8be99 = Update; +updateTranscribedAudio#84cd5a flags:# pending:flags.0?true peer:Peer msg_id:int transcription_id:long text:string = Update; +updateReadFeaturedEmojiStickers#fb4c496c = Update; +updateUserEmojiStatus#28373599 user_id:long emoji_status:EmojiStatus = Update; +updateRecentEmojiStatuses#30f443db = Update; +updateRecentReactions#6f7863f4 = Update; +updateMoveStickerSetToTop#86fccf85 flags:# masks:flags.0?true emojis:flags.1?true stickerset:long = Update; +updateMessageExtendedMedia#d5a41724 peer:Peer msg_id:int extended_media:Vector = Update; +updateUser#20529438 user_id:long = Update; +updateAutoSaveSettings#ec05b097 = Update; +updateStory#75b3b798 peer:Peer story:StoryItem = Update; +updateReadStories#f74e932b peer:Peer max_id:int = Update; +updateStoryID#1bf335b9 id:int random_id:long = Update; +updateStoriesStealthMode#2c084dc1 stealth_mode:StoriesStealthMode = Update; +updateSentStoryReaction#7d627683 peer:Peer story_id:int reaction:Reaction = Update; +updateBotChatBoost#904dd49c peer:Peer boost:Boost qts:int = Update; +updateChannelViewForumAsMessages#7b68920 channel_id:long enabled:Bool = Update; +updatePeerWallpaper#ae3f101d flags:# wallpaper_overridden:flags.1?true peer:Peer wallpaper:flags.0?WallPaper = Update; +updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer old_reactions:Vector new_reactions:Vector qts:int = Update; +updateBotMessageReactions#9cb7759 peer:Peer msg_id:int date:int reactions:Vector qts:int = Update; +updateSavedDialogPinned#aeaf9e74 flags:# pinned:flags.0?true peer:DialogPeer = Update; +updatePinnedSavedDialogs#686c85a6 flags:# order:flags.0?Vector = Update; +updateSavedReactionTags#39c67432 = Update; +updateSmsJob#f16269d4 job_id:string = Update; +updateQuickReplies#f9470ab2 quick_replies:Vector = Update; +updateNewQuickReply#f53da717 quick_reply:QuickReply = Update; +updateDeleteQuickReply#53e6f1ec shortcut_id:int = Update; +updateQuickReplyMessage#3e050d0f message:Message = Update; +updateDeleteQuickReplyMessages#566fe7cd shortcut_id:int messages:Vector = Update; +updateBotBusinessConnect#8ae5c97a connection:BotBusinessConnection qts:int = Update; +updateBotNewBusinessMessage#9ddb347c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotEditBusinessMessage#7df587c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotDeleteBusinessMessage#a02a982e connection_id:string peer:Peer messages:Vector qts:int = Update; +updateNewStoryReaction#1824e40b story_id:int peer:Peer reaction:Reaction = Update; +updateStarsBalance#4e80a379 balance:StarsAmount = Update; +updateBusinessBotCallbackQuery#1ea2fda7 flags:# query_id:long user_id:long connection_id:string message:Message reply_to_message:flags.2?Message chat_instance:long data:flags.0?bytes = Update; +updateStarsRevenueStatus#a584b019 peer:Peer status:StarsRevenueStatus = Update; +updateBotPurchasedPaidMedia#283bd312 user_id:long payload:string qts:int = Update; +updatePaidReactionPrivacy#8b725fce private:PaidReactionPrivacy = Update; +updateSentPhoneCode#504aa18f sent_code:auth.SentCode = Update; +updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector next_offset:int = Update; +updateReadMonoForumInbox#77b0e372 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateReadMonoForumOutbox#a4a79376 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateMonoForumNoPaidException#9f812b08 flags:# exception:flags.0?true channel_id:long saved_peer_id:Peer = Update; +updateGroupCallMessage#d8326f0d call:InputGroupCall message:GroupCallMessage = Update; +updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update; +updatePinnedForumTopic#683b2c52 flags:# pinned:flags.0?true peer:Peer topic_id:int = Update; +updatePinnedForumTopics#def143d0 flags:# peer:Peer order:flags.0?Vector = Update; +updateDeleteGroupCallMessages#3e85e92c call:InputGroupCall messages:Vector = Update; +updateStarGiftAuctionState#48e246c2 gift_id:long state:StarGiftAuctionState = Update; +updateStarGiftAuctionUserState#dc58f31e gift_id:long user_state:StarGiftAuctionUserState = Update; +updateEmojiGameInfo#fb9c547a info:messages.EmojiGameInfo = Update; +updateStarGiftCraftFail#ac072444 = Update; + +updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; + +updates.differenceEmpty#5d75a138 date:int seq:int = updates.Difference; +updates.difference#f49ca0 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector state:updates.State = updates.Difference; +updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector intermediate_state:updates.State = updates.Difference; +updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; + +updatesTooLong#e317af7e = Updates; +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShort#78d4dec1 update:Update date:int = Updates; +updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; +updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; +updateShortSentMessage#9015e101 flags:# out:flags.1?true id:int pts:int pts_count:int date:int media:flags.9?MessageMedia entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +photos.photos#8dca6aa5 photos:Vector users:Vector = photos.Photos; +photos.photosSlice#15051f54 count:int photos:Vector users:Vector = photos.Photos; + +photos.photo#20212ca8 photo:Photo users:Vector = photos.Photo; + +upload.file#96a18d5 type:storage.FileType mtime:int bytes:bytes = upload.File; +upload.fileCdnRedirect#f18cda44 dc_id:int file_token:bytes encryption_key:bytes encryption_iv:bytes file_hashes:Vector = upload.File; + +dcOption#18b7a10d flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true this_port_only:flags.5?true id:int ip_address:string port:int secret:flags.10?bytes = DcOption; + +config#cc1a241e flags:# default_p2p_contacts:flags.3?true preload_featured_stickers:flags.4?true revoke_pm_inbox:flags.6?true blocked_mode:flags.8?true force_try_ipv6:flags.14?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector dc_txt_domain_name:string chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int push_chat_period_ms:int push_chat_limit:int edit_time_limit:int revoke_time_limit:int revoke_pm_time_limit:int rating_e_decay:int stickers_recent_limit:int channels_read_media_period:int tmp_sessions:flags.0?int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string autoupdate_url_prefix:flags.7?string gif_search_username:flags.9?string venue_search_username:flags.10?string img_search_username:flags.11?string static_maps_provider:flags.12?string caption_length_max:int message_length_max:int webfile_dc_id:int suggested_lang_code:flags.2?string lang_pack_version:flags.2?int base_lang_pack_version:flags.2?int reactions_default:flags.15?Reaction autologin_token:flags.16?string = Config; + +nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; + +help.appUpdate#ccbbce30 flags:# can_not_skip:flags.0?true id:int version:string text:string entities:Vector document:flags.1?Document url:flags.2?string sticker:flags.3?Document = help.AppUpdate; +help.noAppUpdate#c45a6536 = help.AppUpdate; + +help.inviteText#18cb9f78 message:string = help.InviteText; + +encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; +encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; + +inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; + +encryptedFileEmpty#c21f497e = EncryptedFile; +encryptedFile#a8008cd8 id:long access_hash:long size:long dc_id:int key_fingerprint:int = EncryptedFile; + +inputEncryptedFileEmpty#1837c364 = InputEncryptedFile; +inputEncryptedFileUploaded#64bd0306 id:long parts:int md5_checksum:string key_fingerprint:int = InputEncryptedFile; +inputEncryptedFile#5a17b5e5 id:long access_hash:long = InputEncryptedFile; +inputEncryptedFileBigUploaded#2dc173c8 id:long parts:int key_fingerprint:int = InputEncryptedFile; + +encryptedMessage#ed18c118 random_id:long chat_id:int date:int bytes:bytes file:EncryptedFile = EncryptedMessage; +encryptedMessageService#23734b06 random_id:long chat_id:int date:int bytes:bytes = EncryptedMessage; + +messages.dhConfigNotModified#c0e24635 random:bytes = messages.DhConfig; +messages.dhConfig#2c221edd g:int p:bytes version:int random:bytes = messages.DhConfig; + +messages.sentEncryptedMessage#560f8935 date:int = messages.SentEncryptedMessage; +messages.sentEncryptedFile#9493ff32 date:int file:EncryptedFile = messages.SentEncryptedMessage; + +inputDocumentEmpty#72f0eaae = InputDocument; +inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; + +documentEmpty#36f8c871 id:long = Document; +document#8fd4c4d8 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:long thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; + +help.support#17c6b5f6 phone_number:string user:User = help.Support; + +notifyPeer#9fd40bd8 peer:Peer = NotifyPeer; +notifyUsers#b4c83b4c = NotifyPeer; +notifyChats#c007cec3 = NotifyPeer; +notifyBroadcasts#d612e8ef = NotifyPeer; +notifyForumTopic#226e6308 peer:Peer top_msg_id:int = NotifyPeer; + +sendMessageTypingAction#16bf744e = SendMessageAction; +sendMessageCancelAction#fd5ec8f5 = SendMessageAction; +sendMessageRecordVideoAction#a187d66f = SendMessageAction; +sendMessageUploadVideoAction#e9763aec progress:int = SendMessageAction; +sendMessageRecordAudioAction#d52f73f7 = SendMessageAction; +sendMessageUploadAudioAction#f351d7ab progress:int = SendMessageAction; +sendMessageUploadPhotoAction#d1d34a26 progress:int = SendMessageAction; +sendMessageUploadDocumentAction#aa0cd9e4 progress:int = SendMessageAction; +sendMessageGeoLocationAction#176f8ba1 = SendMessageAction; +sendMessageChooseContactAction#628cbc6f = SendMessageAction; +sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; +sendMessageRecordRoundAction#88f27fbc = SendMessageAction; +sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; +speakingInGroupCallAction#d92c2285 = SendMessageAction; +sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; +sendMessageTextDraftAction#376d975c random_id:long text:TextWithEntities = SendMessageAction; + +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; + +inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; +inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; +inputPrivacyKeyPhoneCall#fabadc5f = InputPrivacyKey; +inputPrivacyKeyPhoneP2P#db9e70d2 = InputPrivacyKey; +inputPrivacyKeyForwards#a4dd4c08 = InputPrivacyKey; +inputPrivacyKeyProfilePhoto#5719bacc = InputPrivacyKey; +inputPrivacyKeyPhoneNumber#352dafa = InputPrivacyKey; +inputPrivacyKeyAddedByPhone#d1219bdd = InputPrivacyKey; +inputPrivacyKeyVoiceMessages#aee69d68 = InputPrivacyKey; +inputPrivacyKeyAbout#3823cc40 = InputPrivacyKey; +inputPrivacyKeyBirthday#d65a11cc = InputPrivacyKey; +inputPrivacyKeyStarGiftsAutoSave#e1732341 = InputPrivacyKey; +inputPrivacyKeyNoPaidMessages#bdc597b4 = InputPrivacyKey; +inputPrivacyKeySavedMusic#4dbe9226 = InputPrivacyKey; + +privacyKeyStatusTimestamp#bc2eab30 = PrivacyKey; +privacyKeyChatInvite#500e6dfa = PrivacyKey; +privacyKeyPhoneCall#3d662b7b = PrivacyKey; +privacyKeyPhoneP2P#39491cc8 = PrivacyKey; +privacyKeyForwards#69ec56a3 = PrivacyKey; +privacyKeyProfilePhoto#96151fed = PrivacyKey; +privacyKeyPhoneNumber#d19ae46d = PrivacyKey; +privacyKeyAddedByPhone#42ffd42b = PrivacyKey; +privacyKeyVoiceMessages#697f414 = PrivacyKey; +privacyKeyAbout#a486b761 = PrivacyKey; +privacyKeyBirthday#2000a518 = PrivacyKey; +privacyKeyStarGiftsAutoSave#2ca4fdf8 = PrivacyKey; +privacyKeyNoPaidMessages#17d348d2 = PrivacyKey; +privacyKeySavedMusic#ff7a571b = PrivacyKey; + +inputPrivacyValueAllowContacts#d09e07b = InputPrivacyRule; +inputPrivacyValueAllowAll#184b35ce = InputPrivacyRule; +inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; +inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; +inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; +inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; +inputPrivacyValueAllowCloseFriends#2f453e49 = InputPrivacyRule; +inputPrivacyValueAllowPremium#77cdc9f1 = InputPrivacyRule; +inputPrivacyValueAllowBots#5a4fcce5 = InputPrivacyRule; +inputPrivacyValueDisallowBots#c4e57915 = InputPrivacyRule; + +privacyValueAllowContacts#fffe1bac = PrivacyRule; +privacyValueAllowAll#65427b82 = PrivacyRule; +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; +privacyValueDisallowContacts#f888fa1a = PrivacyRule; +privacyValueDisallowAll#8b73e763 = PrivacyRule; +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; +privacyValueAllowCloseFriends#f7e8d89b = PrivacyRule; +privacyValueAllowPremium#ece9814b = PrivacyRule; +privacyValueAllowBots#21461b5d = PrivacyRule; +privacyValueDisallowBots#f6a5f82f = PrivacyRule; + +account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; + +accountDaysTTL#b8d0afdf days:int = AccountDaysTTL; + +documentAttributeImageSize#6c37c15c w:int h:int = DocumentAttribute; +documentAttributeAnimated#11b58939 = DocumentAttribute; +documentAttributeSticker#6319d612 flags:# mask:flags.1?true alt:string stickerset:InputStickerSet mask_coords:flags.0?MaskCoords = DocumentAttribute; +documentAttributeVideo#43c57c48 flags:# round_message:flags.0?true supports_streaming:flags.1?true nosound:flags.3?true duration:double w:int h:int preload_prefix_size:flags.2?int video_start_ts:flags.4?double video_codec:flags.5?string = DocumentAttribute; +documentAttributeAudio#9852f9c6 flags:# voice:flags.10?true duration:int title:flags.0?string performer:flags.1?string waveform:flags.2?bytes = DocumentAttribute; +documentAttributeFilename#15590068 file_name:string = DocumentAttribute; +documentAttributeHasStickers#9801d2f7 = DocumentAttribute; +documentAttributeCustomEmoji#fd149899 flags:# free:flags.0?true text_color:flags.1?true alt:string stickerset:InputStickerSet = DocumentAttribute; + +messages.stickersNotModified#f1749a22 = messages.Stickers; +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; + +stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; + +messages.allStickersNotModified#e86602c3 = messages.AllStickers; +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; + +messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; + +webPageEmpty#211a1788 flags:# id:long url:flags.0?string = WebPage; +webPagePending#b0d13e47 flags:# id:long url:flags.0?string date:int = WebPage; +webPage#e89c45b2 flags:# has_large_media:flags.13?true video_cover_photo:flags.14?true id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; + +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true unconfirmed:flags.5?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; + +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; + +account.password#957b50fb flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int login_email_pattern:flags.6?string = account.Password; + +account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; + +account.passwordInputSettings#c23727c9 flags:# new_algo:flags.0?PasswordKdfAlgo new_password_hash:flags.0?bytes hint:flags.0?string email:flags.1?string new_secure_settings:flags.2?SecureSecretSettings = account.PasswordInputSettings; + +auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; + +receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; + +chatInviteExported#a22cbd96 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int subscription_expired:flags.10?int title:flags.8?string subscription_pricing:flags.9?StarsSubscriptionPricing = ExportedChatInvite; +chatInvitePublicJoinRequests#ed107ab7 = ExportedChatInvite; + +chatInviteAlready#5a686d7c chat:Chat = ChatInvite; +chatInvite#5c9d3702 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true verified:flags.7?true scam:flags.8?true fake:flags.9?true can_refulfill_subscription:flags.11?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector color:int subscription_pricing:flags.10?StarsSubscriptionPricing subscription_form_id:flags.12?long bot_verification:flags.13?BotVerification = ChatInvite; +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; + +inputStickerSetEmpty#ffb62b95 = InputStickerSet; +inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; +inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; +inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; +inputStickerSetPremiumGifts#c88b3b02 = InputStickerSet; +inputStickerSetEmojiGenericAnimations#4c4d4ce = InputStickerSet; +inputStickerSetEmojiDefaultStatuses#29d0f5ee = InputStickerSet; +inputStickerSetEmojiDefaultTopicIcons#44c1f8e9 = InputStickerSet; +inputStickerSetEmojiChannelDefaultStatuses#49748553 = InputStickerSet; +inputStickerSetTonGifts#1cf671a0 = InputStickerSet; + +stickerSet#2dd14edc flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true emojis:flags.7?true text_color:flags.9?true channel_emoji_status:flags.10?true creator:flags.11?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int thumb_document_id:flags.8?long count:int hash:int = StickerSet; + +messages.stickerSet#6e153f16 set:StickerSet packs:Vector keywords:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; + +botCommand#c27ac8c7 command:string description:string = BotCommand; + +botInfo#4d8a0299 flags:# has_preview_medias:flags.6?true user_id:flags.0?long description:flags.1?string description_photo:flags.4?Photo description_document:flags.5?Document commands:flags.2?Vector menu_button:flags.3?BotMenuButton privacy_policy_url:flags.7?string app_settings:flags.8?BotAppSettings verifier_settings:flags.9?BotVerifierSettings = BotInfo; + +keyboardButton#7d170cff flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrl#d80c25ec flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonCallback#e62bc960 flags:# requires_password:flags.0?true style:flags.10?KeyboardButtonStyle text:string data:bytes = KeyboardButton; +keyboardButtonRequestPhone#417efd8f flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonRequestGeoLocation#aa40f94d flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonSwitchInline#991399fc flags:# same_peer:flags.0?true style:flags.10?KeyboardButtonStyle text:string query:string peer_types:flags.1?Vector = KeyboardButton; +keyboardButtonGame#89c590f9 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonBuy#3fa53905 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrlAuth#f51006f9 flags:# style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +inputKeyboardButtonUrlAuth#68013e72 flags:# request_write_access:flags.0?true style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +keyboardButtonRequestPoll#7a11d782 flags:# style:flags.10?KeyboardButtonStyle quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#7d5e07c7 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#c0fd5d09 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:long = KeyboardButton; +keyboardButtonWebView#e846b1a0 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonSimpleWebView#e15c4370 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonRequestPeer#5b0f15f5 flags:# style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +inputKeyboardButtonRequestPeer#2b78156 flags:# name_requested:flags.0?true username_requested:flags.1?true photo_requested:flags.2?true style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +keyboardButtonCopy#bcc4af10 flags:# style:flags.10?KeyboardButtonStyle text:string copy_text:string = KeyboardButton; + +keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; + +replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true persistent:flags.4?true rows:Vector placeholder:flags.3?string = ReplyMarkup; +replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; + +messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; +messageEntityMention#fa04579d offset:int length:int = MessageEntity; +messageEntityHashtag#6f635b0d offset:int length:int = MessageEntity; +messageEntityBotCommand#6cef8ac7 offset:int length:int = MessageEntity; +messageEntityUrl#6ed02538 offset:int length:int = MessageEntity; +messageEntityEmail#64e475c2 offset:int length:int = MessageEntity; +messageEntityBold#bd610bc9 offset:int length:int = MessageEntity; +messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; +messageEntityCode#28a20571 offset:int length:int = MessageEntity; +messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; +messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; +inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; +messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; +messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; +messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; +messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; +messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; +messageEntityCustomEmoji#c8cf05f8 offset:int length:int document_id:long = MessageEntity; +messageEntityBlockquote#f1ccaaac flags:# collapsed:flags.0?true offset:int length:int = MessageEntity; + +inputChannelEmpty#ee8c1e86 = InputChannel; +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; + +contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; + +messageRange#ae30253 min_id:int max_id:int = MessageRange; + +updates.channelDifferenceEmpty#3e11affb flags:# final:flags.0?true pts:int timeout:flags.1?int = updates.ChannelDifference; +updates.channelDifferenceTooLong#a4bcc6fe flags:# final:flags.0?true timeout:flags.1?int dialog:Dialog messages:Vector chats:Vector users:Vector = updates.ChannelDifference; +updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int new_messages:Vector other_updates:Vector chats:Vector users:Vector = updates.ChannelDifference; + +channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; +channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; + +channelParticipant#cb397619 flags:# user_id:long date:int subscription_until_date:flags.0?int = ChannelParticipant; +channelParticipantSelf#4f607bef flags:# via_request:flags.0?true user_id:long inviter_id:long date:int subscription_until_date:flags.1?int = ChannelParticipant; +channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; +channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; +channelParticipantBanned#6df8014e flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights = ChannelParticipant; +channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; + +channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; +channelParticipantsAdmins#b4608969 = ChannelParticipantsFilter; +channelParticipantsKicked#a3b54985 q:string = ChannelParticipantsFilter; +channelParticipantsBots#b0d1865b = ChannelParticipantsFilter; +channelParticipantsBanned#1427a5e1 q:string = ChannelParticipantsFilter; +channelParticipantsSearch#656ac4b q:string = ChannelParticipantsFilter; +channelParticipantsContacts#bb6ae88d q:string = ChannelParticipantsFilter; +channelParticipantsMentions#e04b5ceb flags:# q:flags.0?string top_msg_id:flags.1?int = ChannelParticipantsFilter; + +channels.channelParticipants#9ab0feaf count:int participants:Vector chats:Vector users:Vector = channels.ChannelParticipants; +channels.channelParticipantsNotModified#f0173fe9 = channels.ChannelParticipants; + +channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector users:Vector = channels.ChannelParticipant; + +help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; + +messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; + +inputBotInlineMessageMediaAuto#3380c786 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaGeo#96929a85 flags:# geo_point:InputGeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaVenue#417bbf11 flags:# geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaContact#a6edbffd flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageGame#4b425864 flags:# reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaInvoice#d7e78225 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaWebPage#bddcc510 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true optional:flags.6?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineResult#88bf9319 flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?InputWebDocument content:flags.5?InputWebDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultPhoto#a8d864a7 id:string type:string photo:InputPhoto send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultDocument#fff8fdc4 flags:# id:string type:string title:flags.1?string description:flags.2?string document:InputDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultGame#4fa417f2 id:string short_name:string send_message:InputBotInlineMessage = InputBotInlineResult; + +botInlineMessageMediaAuto#764cf810 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageText#8c7f65e2 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaGeo#51846fd flags:# geo:GeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaVenue#8a86659c flags:# geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaContact#18d1cdc2 flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaInvoice#354a9b09 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument currency:string total_amount:long reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaWebPage#809ad9a6 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true manual:flags.7?true safe:flags.8?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineResult#11965f3a flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?WebDocument content:flags.5?WebDocument send_message:BotInlineMessage = BotInlineResult; +botInlineMediaResult#17db940b flags:# id:string type:string photo:flags.0?Photo document:flags.1?Document title:flags.2?string description:flags.3?string send_message:BotInlineMessage = BotInlineResult; + +messages.botResults#e021f2f6 flags:# gallery:flags.0?true query_id:long next_offset:flags.1?string switch_pm:flags.2?InlineBotSwitchPM switch_webview:flags.3?InlineBotWebView results:Vector cache_time:int users:Vector = messages.BotResults; + +exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; + +messageFwdHeader#4e4df4bb flags:# imported:flags.7?true saved_out:flags.11?true from_id:flags.0?Peer from_name:flags.5?string date:int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int saved_from_id:flags.8?Peer saved_from_name:flags.9?string saved_date:flags.10?int psa_type:flags.6?string = MessageFwdHeader; + +auth.codeTypeSms#72a3158c = auth.CodeType; +auth.codeTypeCall#741cd3e3 = auth.CodeType; +auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; + +auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; +auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; +auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; +auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; +auth.sentCodeTypeEmailCode#f450f59b flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int reset_available_period:flags.3?int reset_pending_date:flags.4?int = auth.SentCodeType; +auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; +auth.sentCodeTypeFirebaseSms#9fd736 flags:# nonce:flags.0?bytes play_integrity_project_id:flags.2?long play_integrity_nonce:flags.2?bytes receipt:flags.1?string push_timeout:flags.1?int length:int = auth.SentCodeType; +auth.sentCodeTypeSmsWord#a416ac81 flags:# beginning:flags.0?string = auth.SentCodeType; +auth.sentCodeTypeSmsPhrase#b37794af flags:# beginning:flags.0?string = auth.SentCodeType; + +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; + +messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; + +inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; + +inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; + +messages.peerDialogs#3371c354 dialogs:Vector messages:Vector chats:Vector users:Vector state:updates.State = messages.PeerDialogs; + +topPeer#edcdc05b peer:Peer rating:double = TopPeer; + +topPeerCategoryBotsPM#ab661b5b = TopPeerCategory; +topPeerCategoryBotsInline#148677e2 = TopPeerCategory; +topPeerCategoryCorrespondents#637b7ed = TopPeerCategory; +topPeerCategoryGroups#bd17a14a = TopPeerCategory; +topPeerCategoryChannels#161d9628 = TopPeerCategory; +topPeerCategoryPhoneCalls#1e76a78c = TopPeerCategory; +topPeerCategoryForwardUsers#a8406ca9 = TopPeerCategory; +topPeerCategoryForwardChats#fbeec0f0 = TopPeerCategory; +topPeerCategoryBotsApp#fd9e7bec = TopPeerCategory; + +topPeerCategoryPeers#fb834291 category:TopPeerCategory count:int peers:Vector = TopPeerCategoryPeers; + +contacts.topPeersNotModified#de266ef5 = contacts.TopPeers; +contacts.topPeers#70b772a8 categories:Vector chats:Vector users:Vector = contacts.TopPeers; +contacts.topPeersDisabled#b52c939d = contacts.TopPeers; + +draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; +draftMessage#96eaa5eb flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo message:string entities:flags.3?Vector media:flags.5?InputMedia date:int effect:flags.7?long suggested_post:flags.8?SuggestedPost = DraftMessage; + +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; +messages.featuredStickers#be382906 flags:# premium:flags.0?true hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; + +messages.recentStickersNotModified#b17f890 = messages.RecentStickers; +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; + +messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; + +messages.stickerSetInstallResultSuccess#38641628 = messages.StickerSetInstallResult; +messages.stickerSetInstallResultArchive#35e410a8 sets:Vector = messages.StickerSetInstallResult; + +stickerSetCovered#6410a5d2 set:StickerSet cover:Document = StickerSetCovered; +stickerSetMultiCovered#3407e51b set:StickerSet covers:Vector = StickerSetCovered; +stickerSetFullCovered#40d13c0e set:StickerSet packs:Vector keywords:Vector documents:Vector = StickerSetCovered; +stickerSetNoCovered#77b15d1c set:StickerSet = StickerSetCovered; + +maskCoords#aed6dbb2 n:int x:double y:double zoom:double = MaskCoords; + +inputStickeredMediaPhoto#4a992157 id:InputPhoto = InputStickeredMedia; +inputStickeredMediaDocument#438865b id:InputDocument = InputStickeredMedia; + +game#bdf9653b flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document = Game; + +inputGameID#32c3e77 id:long access_hash:long = InputGame; +inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; + +highScore#73a379eb pos:int user_id:long score:int = HighScore; + +messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; + +textEmpty#dc3d824f = RichText; +textPlain#744694e0 text:string = RichText; +textBold#6724abc4 text:RichText = RichText; +textItalic#d912a59c text:RichText = RichText; +textUnderline#c12622c4 text:RichText = RichText; +textStrike#9bf8bb95 text:RichText = RichText; +textFixed#6c3f19b9 text:RichText = RichText; +textUrl#3c2884c1 text:RichText url:string webpage_id:long = RichText; +textEmail#de5a0dd6 text:RichText email:string = RichText; +textConcat#7e6260d7 texts:Vector = RichText; +textSubscript#ed6a8504 text:RichText = RichText; +textSuperscript#c7fb5e01 text:RichText = RichText; +textMarked#34b8621 text:RichText = RichText; +textPhone#1ccb966a text:RichText phone:string = RichText; +textImage#81ccf4f document_id:long w:int h:int = RichText; +textAnchor#35553762 text:RichText name:string = RichText; + +pageBlockUnsupported#13567e8a = PageBlock; +pageBlockTitle#70abc3fd text:RichText = PageBlock; +pageBlockSubtitle#8ffa9a1f text:RichText = PageBlock; +pageBlockAuthorDate#baafe5e0 author:RichText published_date:int = PageBlock; +pageBlockHeader#bfd064ec text:RichText = PageBlock; +pageBlockSubheader#f12bb6e1 text:RichText = PageBlock; +pageBlockParagraph#467a0766 text:RichText = PageBlock; +pageBlockPreformatted#c070d93e text:RichText language:string = PageBlock; +pageBlockFooter#48870999 text:RichText = PageBlock; +pageBlockDivider#db20b188 = PageBlock; +pageBlockAnchor#ce0d37b0 name:string = PageBlock; +pageBlockList#e4e88011 items:Vector = PageBlock; +pageBlockBlockquote#263d7c26 text:RichText caption:RichText = PageBlock; +pageBlockPullquote#4f4456d3 text:RichText caption:RichText = PageBlock; +pageBlockPhoto#1759c560 flags:# photo_id:long caption:PageCaption url:flags.0?string webpage_id:flags.0?long = PageBlock; +pageBlockVideo#7c8fe7b6 flags:# autoplay:flags.0?true loop:flags.1?true video_id:long caption:PageCaption = PageBlock; +pageBlockCover#39f23300 cover:PageBlock = PageBlock; +pageBlockEmbed#a8718dc5 flags:# full_width:flags.0?true allow_scrolling:flags.3?true url:flags.1?string html:flags.2?string poster_photo_id:flags.4?long w:flags.5?int h:flags.5?int caption:PageCaption = PageBlock; +pageBlockEmbedPost#f259a80b url:string webpage_id:long author_photo_id:long author:string date:int blocks:Vector caption:PageCaption = PageBlock; +pageBlockCollage#65a0fa4d items:Vector caption:PageCaption = PageBlock; +pageBlockSlideshow#31f9590 items:Vector caption:PageCaption = PageBlock; +pageBlockChannel#ef1751b5 channel:Chat = PageBlock; +pageBlockAudio#804361ea audio_id:long caption:PageCaption = PageBlock; +pageBlockKicker#1e148390 text:RichText = PageBlock; +pageBlockTable#bf4dea82 flags:# bordered:flags.0?true striped:flags.1?true title:RichText rows:Vector = PageBlock; +pageBlockOrderedList#9a8ae1e1 items:Vector = PageBlock; +pageBlockDetails#76768bed flags:# open:flags.0?true blocks:Vector title:RichText = PageBlock; +pageBlockRelatedArticles#16115a96 title:RichText articles:Vector = PageBlock; +pageBlockMap#a44f3ef6 geo:GeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; + +phoneCallDiscardReasonMissed#85e42301 = PhoneCallDiscardReason; +phoneCallDiscardReasonDisconnect#e095c1a0 = PhoneCallDiscardReason; +phoneCallDiscardReasonHangup#57adc690 = PhoneCallDiscardReason; +phoneCallDiscardReasonBusy#faf7e8c9 = PhoneCallDiscardReason; +phoneCallDiscardReasonMigrateConferenceCall#9fbbf1f7 slug:string = PhoneCallDiscardReason; + +dataJSON#7d748d04 data:string = DataJSON; + +labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; + +invoice#49ee584 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true recurring:flags.9?true currency:string prices:Vector max_tip_amount:flags.8?long suggested_tip_amounts:flags.8?Vector terms_url:flags.10?string subscription_period:flags.11?int = Invoice; + +paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; + +postAddress#1e8caaeb street_line1:string street_line2:string city:string state:string country_iso2:string post_code:string = PostAddress; + +paymentRequestedInfo#909c3f94 flags:# name:flags.0?string phone:flags.1?string email:flags.2?string shipping_address:flags.3?PostAddress = PaymentRequestedInfo; + +paymentSavedCredentialsCard#cdc27a1f id:string title:string = PaymentSavedCredentials; + +webDocument#1c570ed1 url:string access_hash:long size:int mime_type:string attributes:Vector = WebDocument; +webDocumentNoProxy#f9c8bcc6 url:string size:int mime_type:string attributes:Vector = WebDocument; + +inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector = InputWebDocument; + +inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation; +inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation; +inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# small:flags.2?true document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation; + +upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; + +payments.paymentForm#a0058751 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON additional_methods:flags.6?Vector saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?Vector users:Vector = payments.PaymentForm; +payments.paymentFormStars#7bf6b15c flags:# form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice users:Vector = payments.PaymentForm; +payments.paymentFormStarGift#b425cfe1 form_id:long invoice:Invoice = payments.PaymentForm; + +payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; + +payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; +payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; + +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; +payments.paymentReceiptStars#dabbf83a flags:# date:int bot_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice currency:string total_amount:long transaction_id:string users:Vector = payments.PaymentReceipt; + +payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; + +inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; +inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsGooglePay#8ac32801 payment_token:DataJSON = InputPaymentCredentials; + +account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; + +shippingOption#b6213cdf id:string title:string prices:Vector = ShippingOption; + +inputStickerSetItem#32da9e9c flags:# document:InputDocument emoji:string mask_coords:flags.0?MaskCoords keywords:flags.1?string = InputStickerSetItem; + +inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; + +phoneCallEmpty#5366c915 id:long = PhoneCall; +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#30535af5 flags:# p2p_allowed:flags.5?true video:flags.6?true conference_supported:flags.8?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int custom_parameters:flags.7?DataJSON = PhoneCall; +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; + +phoneConnection#9cc123c7 flags:# tcp:flags.0?true id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; + +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; + +phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; + +upload.cdnFileReuploadNeeded#eea8e46e request_token:bytes = upload.CdnFile; +upload.cdnFile#a99fca4f bytes:bytes = upload.CdnFile; + +cdnPublicKey#c982eaba dc_id:int public_key:string = CdnPublicKey; + +cdnConfig#5725e40a public_keys:Vector = CdnConfig; + +langPackString#cad181f6 key:string value:string = LangPackString; +langPackStringPluralized#6c47ac9f flags:# key:string zero_value:flags.0?string one_value:flags.1?string two_value:flags.2?string few_value:flags.3?string many_value:flags.4?string other_value:string = LangPackString; +langPackStringDeleted#2979eeb2 key:string = LangPackString; + +langPackDifference#f385c1f6 lang_code:string from_version:int version:int strings:Vector = LangPackDifference; + +langPackLanguage#eeca5ce3 flags:# official:flags.0?true rtl:flags.2?true beta:flags.3?true name:string native_name:string lang_code:string base_lang_code:flags.1?string plural_code:string strings_count:int translated_count:int translations_url:string = LangPackLanguage; + +channelAdminLogEventActionChangeTitle#e6dfb825 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAbout#55188a2e prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsername#6a4afc38 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePhoto#434bd2af prev_photo:Photo new_photo:Photo = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleInvites#1b7907ae new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatures#26ae0971 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionUpdatePinned#e9e82c18 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionEditMessage#709b2405 prev_message:Message new_message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteMessage#42e047bb message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoin#183040d3 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantLeave#f89777f2 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantInvite#e31c34d8 participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleBan#e6d83d7e prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleAdmin#d5676710 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; +channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionDiscardGroupCall#db9f9140 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantMute#f92424d2 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantUnmute#e64429c0 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleGroupCallSetting#56d6a247 join_muted:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByInvite#fe9fc158 flags:# via_chatlist:flags.0?true invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteDelete#5a50fca4 invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePeerColor#5796e780 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeProfilePeerColor#5e477b25 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeWallpaper#31bb5d52 prev_value:WallPaper new_value:WallPaper = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStatus#3ea9feb1 prev_value:EmojiStatus new_value:EmojiStatus = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStickerSet#46d840ab prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatureProfiles#60a79c79 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantSubExtend#64642db3 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAutotranslation#c517f77e new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; + +channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; + +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true sub_extend:flags.18?true = ChannelAdminLogEventsFilter; + +popularContact#5ce14175 client_id:long importers:int = PopularContact; + +messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; + +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; +recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; + +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; + +account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; + +inputMessageID#a676a322 id:int = InputMessage; +inputMessageReplyTo#bad88395 id:int = InputMessage; +inputMessagePinned#86872538 = InputMessage; +inputMessageCallbackQuery#acfa1a7e id:int query_id:long = InputMessage; + +inputDialogPeer#fcaafeb7 peer:InputPeer = InputDialogPeer; +inputDialogPeerFolder#64600527 folder_id:int = InputDialogPeer; + +dialogPeer#e56dbf05 peer:Peer = DialogPeer; +dialogPeerFolder#514519e2 folder_id:int = DialogPeer; + +messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; + +fileHash#f39b035c offset:long limit:int hash:bytes = FileHash; + +inputClientProxy#75588b3f address:string port:int = InputClientProxy; + +help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; +help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; + +inputSecureFileUploaded#3334b0f0 id:long parts:int md5_checksum:string file_hash:bytes secret:bytes = InputSecureFile; +inputSecureFile#5367e5be id:long access_hash:long = InputSecureFile; + +secureFileEmpty#64199744 = SecureFile; +secureFile#7d09c27e id:long access_hash:long size:long dc_id:int date:int file_hash:bytes secret:bytes = SecureFile; + +secureData#8aeabec3 data:bytes data_hash:bytes secret:bytes = SecureData; + +securePlainPhone#7d6099dd phone:string = SecurePlainData; +securePlainEmail#21ec5a5f email:string = SecurePlainData; + +secureValueTypePersonalDetails#9d2a81e3 = SecureValueType; +secureValueTypePassport#3dac6a00 = SecureValueType; +secureValueTypeDriverLicense#6e425c4 = SecureValueType; +secureValueTypeIdentityCard#a0d0744b = SecureValueType; +secureValueTypeInternalPassport#99a48f23 = SecureValueType; +secureValueTypeAddress#cbe31e26 = SecureValueType; +secureValueTypeUtilityBill#fc36954e = SecureValueType; +secureValueTypeBankStatement#89137c0d = SecureValueType; +secureValueTypeRentalAgreement#8b883488 = SecureValueType; +secureValueTypePassportRegistration#99e3806a = SecureValueType; +secureValueTypeTemporaryRegistration#ea02ec33 = SecureValueType; +secureValueTypePhone#b320aadb = SecureValueType; +secureValueTypeEmail#8e3ca7ee = SecureValueType; + +secureValue#187fa0ca flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?SecureFile reverse_side:flags.2?SecureFile selfie:flags.3?SecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData hash:bytes = SecureValue; + +inputSecureValue#db21d0a7 flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?InputSecureFile reverse_side:flags.2?InputSecureFile selfie:flags.3?InputSecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData = InputSecureValue; + +secureValueHash#ed1ecdb0 type:SecureValueType hash:bytes = SecureValueHash; + +secureValueErrorData#e8a40bd9 type:SecureValueType data_hash:bytes field:string text:string = SecureValueError; +secureValueErrorFrontSide#be3dfa type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorReverseSide#868a2aa5 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorSelfie#e537ced6 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFile#7a700873 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFiles#666220e9 type:SecureValueType file_hash:Vector text:string = SecureValueError; +secureValueError#869d758f type:SecureValueType hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFile#a1144770 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFiles#34636dd8 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureCredentialsEncrypted#33f0ea47 data:bytes hash:bytes secret:bytes = SecureCredentialsEncrypted; + +account.authorizationForm#ad2e1cd8 flags:# required_types:Vector values:Vector errors:Vector users:Vector privacy_policy_url:flags.0?string = account.AuthorizationForm; + +account.sentEmailCode#811f854f email_pattern:string length:int = account.SentEmailCode; + +help.deepLinkInfoEmpty#66afa166 = help.DeepLinkInfo; +help.deepLinkInfo#6a4ee832 flags:# update_app:flags.0?true message:string entities:flags.1?Vector = help.DeepLinkInfo; + +savedPhoneContact#1142bd56 phone:string first_name:string last_name:string date:int = SavedContact; + +account.takeout#4dba4501 id:long = account.Takeout; + +passwordKdfAlgoUnknown#d45ab096 = PasswordKdfAlgo; +passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow#3a912d4a salt1:bytes salt2:bytes g:int p:bytes = PasswordKdfAlgo; + +securePasswordKdfAlgoUnknown#4a8537 = SecurePasswordKdfAlgo; +securePasswordKdfAlgoPBKDF2HMACSHA512iter100000#bbf2dda0 salt:bytes = SecurePasswordKdfAlgo; +securePasswordKdfAlgoSHA512#86471d92 salt:bytes = SecurePasswordKdfAlgo; + +secureSecretSettings#1527bcac secure_algo:SecurePasswordKdfAlgo secure_secret:bytes secure_secret_id:long = SecureSecretSettings; + +inputCheckPasswordEmpty#9880f658 = InputCheckPasswordSRP; +inputCheckPasswordSRP#d27ff082 srp_id:long A:bytes M1:bytes = InputCheckPasswordSRP; + +secureRequiredType#829d99da flags:# native_names:flags.0?true selfie_required:flags.1?true translation_required:flags.2?true type:SecureValueType = SecureRequiredType; +secureRequiredTypeOneOf#27477b4 types:Vector = SecureRequiredType; + +help.passportConfigNotModified#bfb9f457 = help.PassportConfig; +help.passportConfig#a098d6af hash:int countries_langs:DataJSON = help.PassportConfig; + +inputAppEvent#1d1b1245 time:double type:string peer:long data:JSONValue = InputAppEvent; + +jsonObjectValue#c0de1bd9 key:string value:JSONValue = JSONObjectValue; + +jsonNull#3f6d7b68 = JSONValue; +jsonBool#c7345e6a value:Bool = JSONValue; +jsonNumber#2be0dfa4 value:double = JSONValue; +jsonString#b71e767a value:string = JSONValue; +jsonArray#f7444763 value:Vector = JSONValue; +jsonObject#99c1d49d value:Vector = JSONValue; + +pageTableCell#34566b6a flags:# header:flags.0?true align_center:flags.3?true align_right:flags.4?true valign_middle:flags.5?true valign_bottom:flags.6?true text:flags.7?RichText colspan:flags.1?int rowspan:flags.2?int = PageTableCell; + +pageTableRow#e0c0c5e5 cells:Vector = PageTableRow; + +pageCaption#6f747657 text:RichText credit:RichText = PageCaption; + +pageListItemText#b92fb6cd text:RichText = PageListItem; +pageListItemBlocks#25e073fc blocks:Vector = PageListItem; + +pageListOrderedItemText#5e068047 num:string text:RichText = PageListOrderedItem; +pageListOrderedItemBlocks#98dd8936 num:string blocks:Vector = PageListOrderedItem; + +pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; + +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; + +help.supportName#8c05f1c9 name:string = help.SupportName; + +help.userInfoEmpty#f3ae2eed = help.UserInfo; +help.userInfo#1eb3758 message:string entities:Vector author:string date:int = help.UserInfo; + +pollAnswer#ff16e2ca text:TextWithEntities option:bytes = PollAnswer; + +poll#58747131 id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true question:TextWithEntities answers:Vector close_period:flags.4?int close_date:flags.5?int = Poll; + +pollAnswerVoters#3b6ddad2 flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:int = PollAnswerVoters; + +pollResults#7adf2420 flags:# min:flags.0?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector = PollResults; + +chatOnlines#f041e250 onlines:int = ChatOnlines; + +statsURL#47a971e0 url:string = StatsURL; + +chatAdminRights#5fb224d5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true pin_messages:flags.7?true add_admins:flags.9?true anonymous:flags.10?true manage_call:flags.11?true other:flags.12?true manage_topics:flags.13?true post_stories:flags.14?true edit_stories:flags.15?true delete_stories:flags.16?true manage_direct_messages:flags.17?true = ChatAdminRights; + +chatBannedRights#9f120418 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true send_polls:flags.8?true change_info:flags.10?true invite_users:flags.15?true pin_messages:flags.17?true manage_topics:flags.18?true send_photos:flags.19?true send_videos:flags.20?true send_roundvideos:flags.21?true send_audios:flags.22?true send_voices:flags.23?true send_docs:flags.24?true send_plain:flags.25?true until_date:int = ChatBannedRights; + +inputWallPaper#e630b979 id:long access_hash:long = InputWallPaper; +inputWallPaperSlug#72091c80 slug:string = InputWallPaper; +inputWallPaperNoFile#967a462e id:long = InputWallPaper; + +account.wallPapersNotModified#1c199183 = account.WallPapers; +account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; + +codeSettings#ad253d78 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true allow_firebase:flags.7?true unknown_number:flags.9?true logout_tokens:flags.6?Vector token:flags.8?string app_sandbox:flags.8?Bool = CodeSettings; + +wallPaperSettings#372efcd0 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int emoticon:flags.7?string = WallPaperSettings; + +autoDownloadSettings#baa57628 flags:# disabled:flags.0?true video_preload_large:flags.1?true audio_preload_next:flags.2?true phonecalls_less_data:flags.3?true stories_preload:flags.4?true photo_size_max:int video_size_max:long file_size_max:long video_upload_maxbitrate:int small_queue_active_operations_max:int large_queue_active_operations_max:int = AutoDownloadSettings; + +account.autoDownloadSettings#63cacf26 low:AutoDownloadSettings medium:AutoDownloadSettings high:AutoDownloadSettings = account.AutoDownloadSettings; + +emojiKeyword#d5b3b9f9 keyword:string emoticons:Vector = EmojiKeyword; +emojiKeywordDeleted#236df622 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordsDifference#5cc761bd lang_code:string from_version:int version:int keywords:Vector = EmojiKeywordsDifference; + +emojiURL#a575739d url:string = EmojiURL; + +emojiLanguage#b3fb5361 lang_code:string = EmojiLanguage; + +folder#ff544e65 flags:# autofill_new_broadcasts:flags.0?true autofill_public_groups:flags.1?true autofill_new_correspondents:flags.2?true id:int title:string photo:flags.3?ChatPhoto = Folder; + +inputFolderPeer#fbd2c296 peer:InputPeer folder_id:int = InputFolderPeer; + +folderPeer#e9baa668 peer:Peer folder_id:int = FolderPeer; + +messages.searchCounter#e844ebff flags:# inexact:flags.1?true filter:MessagesFilter count:int = messages.SearchCounter; + +urlAuthResultRequest#32fabf1a flags:# request_write_access:flags.0?true request_phone_number:flags.1?true bot:User domain:string browser:flags.2?string platform:flags.2?string ip:flags.2?string region:flags.2?string = UrlAuthResult; +urlAuthResultAccepted#623a8fa0 flags:# url:flags.0?string = UrlAuthResult; +urlAuthResultDefault#a9d6db1f = UrlAuthResult; + +channelLocationEmpty#bfb5ad8b = ChannelLocation; +channelLocation#209b82db geo_point:GeoPoint address:string = ChannelLocation; + +peerLocated#ca461b5d peer:Peer expires:int distance:int = PeerLocated; +peerSelfLocated#f8ec284b expires:int = PeerLocated; + +restrictionReason#d072acb4 platform:string reason:string text:string = RestrictionReason; + +inputTheme#3c5693e9 id:long access_hash:long = InputTheme; +inputThemeSlug#f5890df1 slug:string = InputTheme; + +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; + +account.themesNotModified#f41eb622 = account.Themes; +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; + +auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; +auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; +auth.loginTokenSuccess#390d5c5e authorization:auth.Authorization = auth.LoginToken; + +account.contentSettings#57e28221 flags:# sensitive_enabled:flags.0?true sensitive_can_change:flags.1?true = account.ContentSettings; + +messages.inactiveChats#a927fec5 dates:Vector chats:Vector users:Vector = messages.InactiveChats; + +baseThemeClassic#c3a12462 = BaseTheme; +baseThemeDay#fbd81688 = BaseTheme; +baseThemeNight#b7b31ea8 = BaseTheme; +baseThemeTinted#6d5f77ee = BaseTheme; +baseThemeArctic#5b11125a = BaseTheme; + +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; + +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; + +webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; +webPageAttributeStory#2e94c3e7 flags:# peer:Peer id:int story:flags.0?StoryItem = WebPageAttribute; +webPageAttributeStickerSet#50cc03d3 flags:# emojis:flags.0?true text_color:flags.1?true stickers:Vector = WebPageAttribute; +webPageAttributeUniqueStarGift#cf6f6db8 gift:StarGift = WebPageAttribute; +webPageAttributeStarGiftCollection#31cad303 icons:Vector = WebPageAttribute; +webPageAttributeStarGiftAuction#1c641c2 gift:StarGift end_date:int = WebPageAttribute; + +messages.votesList#4899484e flags:# count:int votes:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.VotesList; + +bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; + +payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; + +dialogFilter#aa472651 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; +dialogFilterDefault#363293ae = DialogFilter; +dialogFilterChatlist#96537bd7 flags:# has_my_invites:flags.26?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; +statsGraphError#bedc9822 error:string = StatsGraph; +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +stats.broadcastStats#396ca5fc period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev reactions_per_post:StatsAbsValueAndPrev views_per_story:StatsAbsValueAndPrev shares_per_story:StatsAbsValueAndPrev reactions_per_story:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph reactions_by_emotion_graph:StatsGraph story_interactions_graph:StatsGraph story_reactions_by_emotion_graph:StatsGraph recent_posts_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; +help.promoData#8a4d87a flags:# proxy:flags.0?true expires:int peer:flags.3?Peer psa_type:flags.1?string psa_message:flags.2?string pending_suggestions:Vector dismissed_suggestions:Vector custom_pending_suggestion:flags.4?PendingSuggestion chats:Vector users:Vector = help.PromoData; + +videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; +videoSizeEmojiMarkup#f85c413c emoji_id:long background_colors:Vector = VideoSize; +videoSizeStickerMarkup#da082fe stickerset:InputStickerSet sticker_id:long background_colors:Vector = VideoSize; + +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#fe41b34f flags:# archive_and_mute_new_noncontact_peers:flags.0?true keep_archived_unmuted:flags.1?true keep_archived_folders:flags.2?true hide_read_marks:flags.3?true new_noncontact_peers_require_premium:flags.4?true display_gifts_button:flags.7?true noncontact_peers_paid_stars:flags.5?long disallowed_gifts:flags.6?DisallowedGiftsSettings = GlobalPrivacySettings; + +help.countryCode#4203c5ef flags:# country_code:string prefixes:flags.0?Vector patterns:flags.1?Vector = help.CountryCode; + +help.country#c3878e23 flags:# hidden:flags.0?true iso2:string default_name:string name:flags.1?string country_codes:Vector = help.Country; + +help.countriesListNotModified#93cc1f32 = help.CountriesList; +help.countriesList#87d0759e countries:Vector hash:int = help.CountriesList; + +messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:flags.2?MessageReplies = MessageViews; + +messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; + +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; + +messageReplyHeader#6917560b flags:# reply_to_scheduled:flags.2?true forum_topic:flags.3?true quote:flags.9?true reply_to_msg_id:flags.4?int reply_to_peer_id:flags.0?Peer reply_from:flags.5?MessageFwdHeader reply_media:flags.8?MessageMedia reply_to_top_id:flags.1?int quote_text:flags.6?string quote_entities:flags.7?Vector quote_offset:flags.10?int todo_item_id:flags.11?int = MessageReplyHeader; +messageReplyStoryHeader#e5af939 peer:Peer story_id:int = MessageReplyHeader; + +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; + +peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; + +stats.messageStats#7fe91c14 views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.MessageStats; + +groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; +groupCall#efb2b617 flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true conference:flags.14?true creator:flags.15?true messages_enabled:flags.17?true can_change_messages_enabled:flags.18?true min:flags.19?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int invite_link:flags.16?string send_paid_messages_stars:flags.20?long default_send_as:flags.21?Peer = GroupCall; + +inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; +inputGroupCallSlug#fe06823f slug:string = InputGroupCall; +inputGroupCallInviteMessage#8c10603f msg_id:int = InputGroupCall; + +groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant; + +phone.groupCall#9e727aad call:GroupCall participants:Vector participants_next_offset:string chats:Vector users:Vector = phone.GroupCall; + +phone.groupParticipants#f47751b6 count:int participants:Vector next_offset:string chats:Vector users:Vector version:int = phone.GroupParticipants; + +inlineQueryPeerTypeSameBotPM#3081ed9d = InlineQueryPeerType; +inlineQueryPeerTypePM#833c0fac = InlineQueryPeerType; +inlineQueryPeerTypeChat#d766c50a = InlineQueryPeerType; +inlineQueryPeerTypeMegagroup#5ec4be43 = InlineQueryPeerType; +inlineQueryPeerTypeBroadcast#6334ee9a = InlineQueryPeerType; +inlineQueryPeerTypeBotPM#e3b2d0c = InlineQueryPeerType; + +messages.historyImport#1662af0b id:long = messages.HistoryImport; + +messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true title:flags.2?string = messages.HistoryImportParsed; + +messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; + +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true via_chatlist:flags.3?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; + +messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; + +messages.exportedChatInvite#1871be50 invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; +messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; + +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; + +messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; + +messages.checkedHistoryImportPeer#a24de717 confirm_text:string = messages.CheckedHistoryImportPeer; + +phone.joinAsPeers#afe5623f peers:Vector chats:Vector users:Vector = phone.JoinAsPeers; + +phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvite; + +groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; + +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; +botCommandScopeUsers#3c4f04d8 = BotCommandScope; +botCommandScopeChats#6fe1a881 = BotCommandScope; +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +chatTheme#c3dffc04 emoticon:string = ChatTheme; +chatThemeUniqueGift#3458f9c8 gift:StarGift theme_settings:Vector = ChatTheme; + +account.chatThemesNotModified#e011e1c4 = account.ChatThemes; +account.chatThemes#be098173 flags:# hash:long themes:Vector chats:Vector users:Vector next_offset:flags.0?string = account.ChatThemes; + +sponsoredMessage#7dbf8673 flags:# recommended:flags.5?true can_report:flags.12?true random_id:bytes url:string title:string message:string entities:flags.1?Vector photo:flags.6?Photo media:flags.14?MessageMedia color:flags.13?PeerColor button_text:string sponsor_info:flags.7?string additional_info:flags.8?string min_display_duration:flags.15?int max_display_duration:flags.15?int = SponsoredMessage; + +messages.sponsoredMessages#ffda656d flags:# posts_between:flags.0?int start_delay:flags.1?int between_delay:flags.2?int messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +messages.sponsoredMessagesEmpty#1839490f = messages.SponsoredMessages; + +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#f496b0c6 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#a3d1cb80 flags:# chosen_order:flags.0?int reaction:Reaction count:int = ReactionCount; + +messageReactions#a339f0b flags:# min:flags.0?true can_see_list:flags.2?true reactions_as_tags:flags.3?true results:Vector recent_reactions:flags.1?Vector top_reactors:flags.4?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true premium:flags.2?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + +messagePeerReaction#8c79b63c flags:# big:flags.0?true unread:flags.1?true my:flags.2?true peer_id:Peer date:int reaction:Reaction = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; + +attachMenuBotIconColor#4576f3f0 name:string color:int = AttachMenuBotIconColor; + +attachMenuBotIcon#b2a7386b flags:# name:string icon:Document colors:flags.0?Vector = AttachMenuBotIcon; + +attachMenuBot#d90d8dfe flags:# inactive:flags.0?true has_settings:flags.1?true request_write_access:flags.2?true show_in_attach_menu:flags.3?true show_in_side_menu:flags.4?true side_menu_disclaimer_needed:flags.5?true bot_id:long short_name:string peer_types:flags.3?Vector icons:Vector = AttachMenuBot; + +attachMenuBotsNotModified#f1d88a5c = AttachMenuBots; +attachMenuBots#3c4301c0 hash:long bots:Vector users:Vector = AttachMenuBots; + +attachMenuBotsBot#93bf667f bot:AttachMenuBot users:Vector = AttachMenuBotsBot; + +webViewResultUrl#4d22ff98 flags:# fullsize:flags.1?true fullscreen:flags.2?true query_id:flags.0?long url:string = WebViewResult; + +webViewMessageSent#c94511c flags:# msg_id:flags.0?InputBotInlineMessageID = WebViewMessageSent; + +botMenuButtonDefault#7533a588 = BotMenuButton; +botMenuButtonCommands#4258c205 = BotMenuButton; +botMenuButton#c7b57ce6 text:string url:string = BotMenuButton; + +account.savedRingtonesNotModified#fbf6e8b1 = account.SavedRingtones; +account.savedRingtones#c1e92cc5 hash:long ringtones:Vector = account.SavedRingtones; + +notificationSoundDefault#97e8bebe = NotificationSound; +notificationSoundNone#6f0c34df = NotificationSound; +notificationSoundLocal#830b9ae4 title:string data:string = NotificationSound; +notificationSoundRingtone#ff6c8049 id:long = NotificationSound; + +account.savedRingtone#b7263f6d = account.SavedRingtone; +account.savedRingtoneConverted#1f307eb7 document:Document = account.SavedRingtone; + +attachMenuPeerTypeSameBotPM#7d6be90e = AttachMenuPeerType; +attachMenuPeerTypeBotPM#c32bfa1a = AttachMenuPeerType; +attachMenuPeerTypePM#f146d31f = AttachMenuPeerType; +attachMenuPeerTypeChat#509113f = AttachMenuPeerType; +attachMenuPeerTypeBroadcast#7bfbdefc = AttachMenuPeerType; + +inputInvoiceMessage#c5b56859 peer:InputPeer msg_id:int = InputInvoice; +inputInvoiceSlug#c326caef slug:string = InputInvoice; +inputInvoicePremiumGiftCode#98986c0d purpose:InputStorePaymentPurpose option:PremiumGiftCodeOption = InputInvoice; +inputInvoiceStars#65f00ce3 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceChatInviteSubscription#34e793f1 hash:string = InputInvoice; +inputInvoiceStarGift#e8625e92 flags:# hide_name:flags.0?true include_upgrade:flags.2?true peer:InputPeer gift_id:long message:flags.1?TextWithEntities = InputInvoice; +inputInvoiceStarGiftUpgrade#4d818d5d flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftTransfer#4a5f5bd9 stargift:InputSavedStarGift to_id:InputPeer = InputInvoice; +inputInvoicePremiumGiftStars#dabab2ef flags:# user_id:InputUser months:int message:flags.0?TextWithEntities = InputInvoice; +inputInvoiceBusinessBotTransferStars#f4997e42 bot:InputUser stars:long = InputInvoice; +inputInvoiceStarGiftResale#c39f5324 flags:# ton:flags.0?true slug:string to_id:InputPeer = InputInvoice; +inputInvoiceStarGiftPrepaidUpgrade#9a0b48b8 peer:InputPeer hash:string = InputInvoice; +inputInvoicePremiumAuthCode#3e77f614 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceStarGiftDropOriginalDetails#923d8d1 stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftAuctionBid#1ecafa10 flags:# hide_name:flags.0?true update_bid:flags.2?true peer:flags.3?InputPeer gift_id:long bid_amount:long message:flags.1?TextWithEntities = InputInvoice; + +payments.exportedInvoice#aed0cbd9 url:string = payments.ExportedInvoice; + +messages.transcribedAudio#cfb9d957 flags:# pending:flags.0?true transcription_id:long text:string trial_remains_num:flags.1?int trial_remains_until_date:flags.1?int = messages.TranscribedAudio; + +help.premiumPromo#5334759c status_text:string status_entities:Vector video_sections:Vector videos:Vector period_options:Vector users:Vector = help.PremiumPromo; + +inputStorePaymentPremiumSubscription#a6751e66 flags:# restore:flags.0?true upgrade:flags.1?true = InputStorePaymentPurpose; +inputStorePaymentGiftPremium#616f7fe8 user_id:InputUser currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentPremiumGiftCode#fb790393 flags:# users:Vector boost_peer:flags.0?InputPeer currency:string amount:long message:flags.1?TextWithEntities = InputStorePaymentPurpose; +inputStorePaymentPremiumGiveaway#160544ca flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsTopup#f9a2a6cb flags:# stars:long currency:string amount:long spend_purpose_peer:flags.0?InputPeer = InputStorePaymentPurpose; +inputStorePaymentStarsGift#1d741ef7 user_id:InputUser stars:long currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsGiveaway#751f08fa flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true stars:long boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long users:int = InputStorePaymentPurpose; +inputStorePaymentAuthCode#9bb2636d flags:# restore:flags.0?true phone_number:string phone_code_hash:string currency:string amount:long = InputStorePaymentPurpose; + +paymentFormMethod#88f8f21b url:string title:string = PaymentFormMethod; + +emojiStatusEmpty#2de11aae = EmojiStatus; +emojiStatus#e7ff068a flags:# document_id:long until:flags.0?int = EmojiStatus; +emojiStatusCollectible#7184603b flags:# collectible_id:long document_id:long title:string slug:string pattern_document_id:long center_color:int edge_color:int pattern_color:int text_color:int until:flags.0?int = EmojiStatus; +inputEmojiStatusCollectible#7141dbf flags:# collectible_id:long until:flags.0?int = EmojiStatus; + +account.emojiStatusesNotModified#d08ce645 = account.EmojiStatuses; +account.emojiStatuses#90c467d1 hash:long statuses:Vector = account.EmojiStatuses; + +reactionEmpty#79f5d419 = Reaction; +reactionEmoji#1b2286b8 emoticon:string = Reaction; +reactionCustomEmoji#8935fc73 document_id:long = Reaction; +reactionPaid#523da4eb = Reaction; + +chatReactionsNone#eafc32bc = ChatReactions; +chatReactionsAll#52928bca flags:# allow_custom:flags.0?true = ChatReactions; +chatReactionsSome#661d4037 reactions:Vector = ChatReactions; + +messages.reactionsNotModified#b06fdbdf = messages.Reactions; +messages.reactions#eafdf716 hash:long reactions:Vector = messages.Reactions; + +emailVerifyPurposeLoginSetup#4345be73 phone_number:string phone_code_hash:string = EmailVerifyPurpose; +emailVerifyPurposeLoginChange#527d22eb = EmailVerifyPurpose; +emailVerifyPurposePassport#bbf51685 = EmailVerifyPurpose; + +emailVerificationCode#922e55a9 code:string = EmailVerification; +emailVerificationGoogle#db909ec2 token:string = EmailVerification; +emailVerificationApple#96d074fd token:string = EmailVerification; + +account.emailVerified#2b96cd1b email:string = account.EmailVerified; +account.emailVerifiedLogin#e1bb0d61 email:string sent_code:auth.SentCode = account.EmailVerified; + +premiumSubscriptionOption#5f2d1df2 flags:# current:flags.1?true can_purchase_upgrade:flags.2?true transaction:flags.3?string months:int currency:string amount:long bot_url:string store_product:flags.0?string = PremiumSubscriptionOption; + +sendAsPeer#b81c7034 flags:# premium_required:flags.0?true peer:Peer = SendAsPeer; + +messageExtendedMediaPreview#ad628cc8 flags:# w:flags.0?int h:flags.0?int thumb:flags.1?PhotoSize video_duration:flags.2?int = MessageExtendedMedia; +messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; + +stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; + +username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; + +forumTopicDeleted#23f109b id:int = ForumTopic; +forumTopic#cdff0eca flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true title_missing:flags.7?true id:int date:int peer:Peer title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; + +messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; + +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + +requestPeerTypeUser#5f3b8a00 flags:# bot:flags.0?Bool premium:flags.1?Bool = RequestPeerType; +requestPeerTypeChat#c9f06e1b flags:# creator:flags.0?true bot_participant:flags.5?true has_username:flags.3?Bool forum:flags.4?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeBroadcast#339bef6c flags:# creator:flags.0?true has_username:flags.3?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; + +emojiListNotModified#481eadfa = EmojiList; +emojiList#7a1e11d1 hash:long document_id:Vector = EmojiList; + +emojiGroup#7a9abda9 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupGreeting#80d26cc7 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupPremium#93bcf34 title:string icon_emoji_id:long = EmojiGroup; + +messages.emojiGroupsNotModified#6fb4ad87 = messages.EmojiGroups; +messages.emojiGroups#881fb94b hash:int groups:Vector = messages.EmojiGroups; + +textWithEntities#751f3146 text:string entities:Vector = TextWithEntities; + +messages.translateResult#33db32f8 result:Vector = messages.TranslatedText; + +autoSaveSettings#c84834ce flags:# photos:flags.0?true videos:flags.1?true video_max_size:flags.2?long = AutoSaveSettings; + +autoSaveException#81602d47 peer:Peer settings:AutoSaveSettings = AutoSaveException; + +account.autoSaveSettings#4c3e069d users_settings:AutoSaveSettings chats_settings:AutoSaveSettings broadcasts_settings:AutoSaveSettings exceptions:Vector chats:Vector users:Vector = account.AutoSaveSettings; + +help.appConfigNotModified#7cde641d = help.AppConfig; +help.appConfig#dd18782e hash:int config:JSONValue = help.AppConfig; + +inputBotAppID#a920bd7a id:long access_hash:long = InputBotApp; +inputBotAppShortName#908c0407 bot_id:InputUser short_name:string = InputBotApp; + +botAppNotModified#5da674b7 = BotApp; +botApp#95fcd1d6 flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document hash:long = BotApp; + +messages.botApp#eb50adf5 flags:# inactive:flags.0?true request_write_access:flags.1?true has_settings:flags.2?true app:BotApp = messages.BotApp; + +inlineBotWebView#b57295d5 text:string url:string = InlineBotWebView; + +readParticipantDate#4a4ff172 user_id:long date:int = ReadParticipantDate; + +inputChatlistDialogFilter#f3e0da33 filter_id:int = InputChatlist; + +exportedChatlistInvite#c5181ac flags:# title:string url:string peers:Vector = ExportedChatlistInvite; + +chatlists.exportedChatlistInvite#10e6e3a6 filter:DialogFilter invite:ExportedChatlistInvite = chatlists.ExportedChatlistInvite; + +chatlists.exportedInvites#10ab6dc7 invites:Vector chats:Vector users:Vector = chatlists.ExportedInvites; + +chatlists.chatlistInviteAlready#fa87f659 filter_id:int missing_peers:Vector already_peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; +chatlists.chatlistInvite#f10ece2f flags:# title_noanimate:flags.1?true title:TextWithEntities emoticon:flags.0?string peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistUpdates#93bd878d missing_peers:Vector chats:Vector users:Vector = chatlists.ChatlistUpdates; + +bots.botInfo#e8a775b0 name:string about:string description:string = bots.BotInfo; + +messagePeerVote#b6cc2d5c peer:Peer option:bytes date:int = MessagePeerVote; +messagePeerVoteInputOption#74cda504 peer:Peer date:int = MessagePeerVote; +messagePeerVoteMultiple#4628f6e6 peer:Peer options:Vector date:int = MessagePeerVote; + +storyViews#8d595cd6 flags:# has_viewers:flags.1?true views_count:int forwards_count:flags.2?int reactions:flags.3?Vector reactions_count:flags.4?int recent_viewers:flags.0?Vector = StoryViews; + +storyItemDeleted#51e6ee4f id:int = StoryItem; +storyItemSkipped#ffadc913 flags:# close_friends:flags.8?true live:flags.9?true id:int date:int expire_date:int = StoryItem; +storyItem#edf164f1 flags:# pinned:flags.5?true public:flags.7?true close_friends:flags.8?true min:flags.9?true noforwards:flags.10?true edited:flags.11?true contacts:flags.12?true selected_contacts:flags.13?true out:flags.16?true id:int date:int from_id:flags.18?Peer fwd_from:flags.17?StoryFwdHeader expire_date:int caption:flags.0?string entities:flags.1?Vector media:MessageMedia media_areas:flags.14?Vector privacy:flags.2?Vector views:flags.3?StoryViews sent_reaction:flags.15?Reaction albums:flags.19?Vector = StoryItem; + +stories.allStoriesNotModified#1158fe3e flags:# state:string stealth_mode:StoriesStealthMode = stories.AllStories; +stories.allStories#6efc5e81 flags:# has_more:flags.0?true count:int state:string peer_stories:Vector chats:Vector users:Vector stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.stories#63c3dd0a flags:# count:int stories:Vector pinned_to_top:flags.0?Vector chats:Vector users:Vector = stories.Stories; + +storyView#b0bdeac5 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true user_id:long date:int reaction:flags.2?Reaction = StoryView; +storyViewPublicForward#9083670b flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true message:Message = StoryView; +storyViewPublicRepost#bd74cf49 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer story:StoryItem = StoryView; + +stories.storyViewsList#59d78fc5 flags:# count:int views_count:int forwards_count:int reactions_count:int views:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryViewsList; + +stories.storyViews#de9eed1d views:Vector users:Vector = stories.StoryViews; + +inputReplyToMessage#869fbe10 flags:# reply_to_msg_id:int top_msg_id:flags.0?int reply_to_peer_id:flags.1?InputPeer quote_text:flags.2?string quote_entities:flags.3?Vector quote_offset:flags.4?int monoforum_peer_id:flags.5?InputPeer todo_item_id:flags.6?int = InputReplyTo; +inputReplyToStory#5881323a peer:InputPeer story_id:int = InputReplyTo; +inputReplyToMonoForum#69d66c45 monoforum_peer_id:InputPeer = InputReplyTo; + +exportedStoryLink#3fc9053b link:string = ExportedStoryLink; + +storiesStealthMode#712e27fd flags:# active_until_date:flags.0?int cooldown_until_date:flags.1?int = StoriesStealthMode; + +mediaAreaCoordinates#cfc9e002 flags:# x:double y:double w:double h:double rotation:double radius:flags.0?double = MediaAreaCoordinates; + +mediaAreaVenue#be82db9c coordinates:MediaAreaCoordinates geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MediaArea; +inputMediaAreaVenue#b282217f coordinates:MediaAreaCoordinates query_id:long result_id:string = MediaArea; +mediaAreaGeoPoint#cad5452d flags:# coordinates:MediaAreaCoordinates geo:GeoPoint address:flags.0?GeoPointAddress = MediaArea; +mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; +mediaAreaChannelPost#770416af coordinates:MediaAreaCoordinates channel_id:long msg_id:int = MediaArea; +inputMediaAreaChannelPost#2271f2bf coordinates:MediaAreaCoordinates channel:InputChannel msg_id:int = MediaArea; +mediaAreaUrl#37381085 coordinates:MediaAreaCoordinates url:string = MediaArea; +mediaAreaWeather#49a6549c coordinates:MediaAreaCoordinates emoji:string temperature_c:double color:int = MediaArea; +mediaAreaStarGift#5787686d coordinates:MediaAreaCoordinates slug:string = MediaArea; + +peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; + +stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; + +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; + +premiumGiftCodeOption#257e962b flags:# users:int months:int store_product:flags.0?string store_quantity:flags.1?int currency:string amount:long = PremiumGiftCodeOption; + +payments.checkedGiftCode#eb983f8f flags:# via_giveaway:flags.2?true from_id:flags.4?Peer giveaway_msg_id:flags.3?int to_id:flags.0?long date:int days:int used_date:flags.1?int chats:Vector users:Vector = payments.CheckedGiftCode; + +payments.giveawayInfo#4367daa0 flags:# participating:flags.0?true preparing_results:flags.3?true start_date:int joined_too_early_date:flags.1?int admin_disallowed_chat_id:flags.2?long disallowed_country:flags.4?string = payments.GiveawayInfo; +payments.giveawayInfoResults#e175e66f flags:# winner:flags.0?true refunded:flags.1?true start_date:int gift_code_slug:flags.3?string stars_prize:flags.4?long finish_date:int winners_count:int activated_count:flags.2?int = payments.GiveawayInfo; + +prepaidGiveaway#b2539d54 id:long months:int quantity:int date:int = PrepaidGiveaway; +prepaidStarsGiveaway#9a9d77e0 id:long stars:long quantity:int boosts:int date:int = PrepaidGiveaway; + +boost#4b3e14d6 flags:# gift:flags.1?true giveaway:flags.2?true unclaimed:flags.3?true id:string user_id:flags.0?long giveaway_msg_id:flags.2?int date:int expires:int used_gift_slug:flags.4?string multiplier:flags.5?int stars:flags.6?long = Boost; + +premium.boostsList#86f8613c flags:# count:int boosts:Vector next_offset:flags.0?string users:Vector = premium.BoostsList; + +myBoost#c448415c flags:# slot:int peer:flags.0?Peer date:int expires:int cooldown_until_date:flags.1?int = MyBoost; + +premium.myBoosts#9ae228e2 my_boosts:Vector chats:Vector users:Vector = premium.MyBoosts; + +premium.boostsStatus#4959427a flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int gift_boosts:flags.4?int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string prepaid_giveaways:flags.3?Vector my_boost_slots:flags.2?Vector = premium.BoostsStatus; + +storyFwdHeader#b826e150 flags:# modified:flags.3?true from:flags.0?Peer from_name:flags.1?string story_id:flags.2?int = StoryFwdHeader; + +postInteractionCountersMessage#e7058e7f msg_id:int views:int forwards:int reactions:int = PostInteractionCounters; +postInteractionCountersStory#8a480e27 story_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +stats.storyStats#50cd067c views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.StoryStats; + +publicForwardMessage#1f2bf4a message:Message = PublicForward; +publicForwardStory#edf3add0 peer:Peer story:StoryItem = PublicForward; + +stats.publicForwards#93037e20 flags:# count:int forwards:Vector next_offset:flags.0?string chats:Vector users:Vector = stats.PublicForwards; + +peerColor#b54b5acf flags:# color:flags.0?int background_emoji_id:flags.1?long = PeerColor; +peerColorCollectible#b9c0639a flags:# collectible_id:long gift_emoji_id:long background_emoji_id:long accent_color:int colors:Vector dark_accent_color:flags.0?int dark_colors:flags.1?Vector = PeerColor; +inputPeerColorCollectible#b8ea86a9 collectible_id:long = PeerColor; + +help.peerColorSet#26219a58 colors:Vector = help.PeerColorSet; +help.peerColorProfileSet#767d61eb palette_colors:Vector bg_colors:Vector story_colors:Vector = help.PeerColorSet; + +help.peerColorOption#adec6ebe flags:# hidden:flags.0?true color_id:int colors:flags.1?help.PeerColorSet dark_colors:flags.2?help.PeerColorSet channel_min_level:flags.3?int group_min_level:flags.4?int = help.PeerColorOption; + +help.peerColorsNotModified#2ba1f5ce = help.PeerColors; +help.peerColors#f8ed08 hash:int colors:Vector = help.PeerColors; + +storyReaction#6090d6d5 peer_id:Peer date:int reaction:Reaction = StoryReaction; +storyReactionPublicForward#bbab2643 message:Message = StoryReaction; +storyReactionPublicRepost#cfcd0f13 peer_id:Peer story:StoryItem = StoryReaction; + +stories.storyReactionsList#aa5f789c flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryReactionsList; + +savedDialog#bd87cb6c flags:# pinned:flags.2?true peer:Peer top_message:int = SavedDialog; +monoForumDialog#64407ea7 flags:# unread_mark:flags.3?true nopaid_messages_exception:flags.4?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_reactions_count:int draft:flags.1?DraftMessage = SavedDialog; + +messages.savedDialogs#f83ae221 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsSlice#44ba9dd9 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsNotModified#c01f6fe8 count:int = messages.SavedDialogs; + +savedReactionTag#cb6ff828 flags:# reaction:Reaction title:flags.0?string count:int = SavedReactionTag; + +messages.savedReactionTagsNotModified#889b59ef = messages.SavedReactionTags; +messages.savedReactionTags#3259950a tags:Vector hash:long = messages.SavedReactionTags; + +outboxReadDate#3bb842ac date:int = OutboxReadDate; + +smsjobs.eligibleToJoin#dc8b44cf terms_url:string monthly_sent_sms:int = smsjobs.EligibilityToJoin; + +smsjobs.status#2aee9191 flags:# allow_international:flags.0?true recent_sent:int recent_since:int recent_remains:int total_sent:int total_since:int last_gift_slug:flags.1?string terms_url:string = smsjobs.Status; + +smsJob#e6a1eeb8 job_id:string phone_number:string text:string = SmsJob; + +businessWeeklyOpen#120b1ab9 start_minute:int end_minute:int = BusinessWeeklyOpen; + +businessWorkHours#8c92b098 flags:# open_now:flags.0?true timezone_id:string weekly_open:Vector = BusinessWorkHours; + +businessLocation#ac5c1af7 flags:# geo_point:flags.0?GeoPoint address:string = BusinessLocation; + +inputBusinessRecipients#6f8b32aa flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = InputBusinessRecipients; + +businessRecipients#21108ff7 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = BusinessRecipients; + +businessAwayMessageScheduleAlways#c9b9e2b9 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleOutsideWorkHours#c3f2f501 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleCustom#cc4d9ecc start_date:int end_date:int = BusinessAwayMessageSchedule; + +inputBusinessGreetingMessage#194cb3b shortcut_id:int recipients:InputBusinessRecipients no_activity_days:int = InputBusinessGreetingMessage; + +businessGreetingMessage#e519abab shortcut_id:int recipients:BusinessRecipients no_activity_days:int = BusinessGreetingMessage; + +inputBusinessAwayMessage#832175e0 flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:InputBusinessRecipients = InputBusinessAwayMessage; + +businessAwayMessage#ef156a5c flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:BusinessRecipients = BusinessAwayMessage; + +timezone#ff9289f5 id:string name:string utc_offset:int = Timezone; + +help.timezonesListNotModified#970708cc = help.TimezonesList; +help.timezonesList#7b74ed71 timezones:Vector hash:int = help.TimezonesList; + +quickReply#697102b shortcut_id:int shortcut:string top_message:int count:int = QuickReply; + +inputQuickReplyShortcut#24596d41 shortcut:string = InputQuickReplyShortcut; +inputQuickReplyShortcutId#1190cf1 shortcut_id:int = InputQuickReplyShortcut; + +messages.quickReplies#c68d6695 quick_replies:Vector messages:Vector chats:Vector users:Vector = messages.QuickReplies; +messages.quickRepliesNotModified#5f91eb5b = messages.QuickReplies; + +connectedBot#cd64636c flags:# bot_id:long recipients:BusinessBotRecipients rights:BusinessBotRights = ConnectedBot; + +account.connectedBots#17d7f87b connected_bots:Vector users:Vector = account.ConnectedBots; + +messages.dialogFilters#2ad93719 flags:# tags_enabled:flags.0?true filters:Vector = messages.DialogFilters; + +birthday#6c8e1e06 flags:# day:int month:int year:flags.0?int = Birthday; + +botBusinessConnection#8f34b2f5 flags:# disabled:flags.1?true connection_id:string user_id:long dc_id:int date:int rights:flags.2?BusinessBotRights = BotBusinessConnection; + +inputBusinessIntro#9c469cd flags:# title:string description:string sticker:flags.0?InputDocument = InputBusinessIntro; + +businessIntro#5a0a066d flags:# title:string description:string sticker:flags.0?Document = BusinessIntro; + +messages.myStickers#faff629d count:int sets:Vector = messages.MyStickers; + +inputCollectibleUsername#e39460a9 username:string = InputCollectible; +inputCollectiblePhone#a2e214a4 phone:string = InputCollectible; + +fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo; + +inputBusinessBotRecipients#c4e5921e flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = InputBusinessBotRecipients; + +businessBotRecipients#b88cf373 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = BusinessBotRecipients; + +contactBirthday#1d998733 contact_id:long birthday:Birthday = ContactBirthday; + +contacts.contactBirthdays#114ff30d contacts:Vector users:Vector = contacts.ContactBirthdays; + +missingInvitee#628c9224 flags:# premium_would_allow_invite:flags.0?true premium_required_for_pm:flags.1?true user_id:long = MissingInvitee; + +messages.invitedUsers#7f5defa6 updates:Updates missing_invitees:Vector = messages.InvitedUsers; + +inputBusinessChatLink#11679fa7 flags:# message:string entities:flags.0?Vector title:flags.1?string = InputBusinessChatLink; + +businessChatLink#b4ae666f flags:# link:string message:string entities:flags.0?Vector title:flags.1?string views:int = BusinessChatLink; + +account.businessChatLinks#ec43a2d1 links:Vector chats:Vector users:Vector = account.BusinessChatLinks; + +account.resolvedBusinessChatLinks#9a23af21 flags:# peer:Peer message:string entities:flags.0?Vector chats:Vector users:Vector = account.ResolvedBusinessChatLinks; + +requestedPeerUser#d62ff46a flags:# user_id:long first_name:flags.0?string last_name:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChat#7307544f flags:# chat_id:long title:flags.0?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChannel#8ba403e4 flags:# channel_id:long title:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +sponsoredMessageReportOption#430d3150 text:string option:bytes = SponsoredMessageReportOption; + +channels.sponsoredMessageReportResultChooseOption#846f9e42 title:string options:Vector = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultAdsHidden#3e3bcf2f = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultReported#ad798849 = channels.SponsoredMessageReportResult; + +reactionNotificationsFromContacts#bac3a61a = ReactionNotificationsFrom; +reactionNotificationsFromAll#4b9e22a0 = ReactionNotificationsFrom; + +reactionsNotifySettings#56e34970 flags:# messages_notify_from:flags.0?ReactionNotificationsFrom stories_notify_from:flags.1?ReactionNotificationsFrom sound:NotificationSound show_previews:Bool = ReactionsNotifySettings; + +availableEffect#93c3e27e flags:# premium_required:flags.2?true id:long emoticon:string static_icon_id:flags.0?long effect_sticker_id:long effect_animation_id:flags.1?long = AvailableEffect; + +messages.availableEffectsNotModified#d1ed9a5b = messages.AvailableEffects; +messages.availableEffects#bddb616e hash:int effects:Vector documents:Vector = messages.AvailableEffects; + +factCheck#b89bfccf flags:# need_check:flags.0?true country:flags.1?string text:flags.1?TextWithEntities hash:long = FactCheck; + +starsTransactionPeerUnsupported#95f2bfe4 = StarsTransactionPeer; +starsTransactionPeerAppStore#b457b375 = StarsTransactionPeer; +starsTransactionPeerPlayMarket#7b560a0b = StarsTransactionPeer; +starsTransactionPeerPremiumBot#250dbaf8 = StarsTransactionPeer; +starsTransactionPeerFragment#e92fd902 = StarsTransactionPeer; +starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; +starsTransactionPeerAds#60682812 = StarsTransactionPeer; +starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; + +starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; + +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true stargift_prepaid_upgrade:flags.25?true stargift_drop_original_details:flags.26?true phonegroup_message:flags.27?true stargift_auction_bid:flags.28?true offer:flags.29?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; + +payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; + +foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; + +stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; + +geoPointAddress#de4c5d93 flags:# country_iso2:string state:flags.0?string city:flags.1?string street:flags.2?string = GeoPointAddress; + +starsRevenueStatus#febe5491 flags:# withdrawal_enabled:flags.0?true current_balance:StarsAmount available_balance:StarsAmount overall_revenue:StarsAmount next_withdrawal_at:flags.1?int = StarsRevenueStatus; + +payments.starsRevenueStats#6c207376 flags:# top_hours_graph:flags.0?StatsGraph revenue_graph:StatsGraph status:StarsRevenueStatus usd_rate:double = payments.StarsRevenueStats; + +payments.starsRevenueWithdrawalUrl#1dab80b7 url:string = payments.StarsRevenueWithdrawalUrl; + +payments.starsRevenueAdsAccountUrl#394e7f21 url:string = payments.StarsRevenueAdsAccountUrl; + +inputStarsTransaction#206ae6d1 flags:# refund:flags.0?true id:string = InputStarsTransaction; + +starsGiftOption#5e0589f1 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsGiftOption; + +bots.popularAppBots#1991b13b flags:# next_offset:flags.0?string users:Vector = bots.PopularAppBots; + +botPreviewMedia#23e91ba3 date:int media:MessageMedia = BotPreviewMedia; + +bots.previewInfo#ca71d64 media:Vector lang_codes:Vector = bots.PreviewInfo; + +starsSubscriptionPricing#5416d58 period:int amount:long = StarsSubscriptionPricing; + +starsSubscription#2e6eab1a flags:# canceled:flags.0?true can_refulfill:flags.1?true missing_balance:flags.2?true bot_canceled:flags.7?true id:string peer:Peer until_date:int pricing:StarsSubscriptionPricing chat_invite_hash:flags.3?string title:flags.4?string photo:flags.5?WebDocument invoice_slug:flags.6?string = StarsSubscription; + +messageReactor#4ba3a95a flags:# top:flags.0?true my:flags.1?true anonymous:flags.2?true peer_id:flags.3?Peer count:int = MessageReactor; + +starsGiveawayOption#94ce852a flags:# extended:flags.0?true default:flags.1?true stars:long yearly_boosts:int store_product:flags.2?string currency:string amount:long winners:Vector = StarsGiveawayOption; + +starsGiveawayWinnersOption#54236209 flags:# default:flags.0?true users:int per_user_stars:long = StarsGiveawayWinnersOption; + +starGift#313a9547 flags:# limited:flags.0?true sold_out:flags.1?true birthday:flags.2?true require_premium:flags.7?true limited_per_user:flags.8?true peer_color_available:flags.10?true auction:flags.11?true id:long sticker:Document stars:long availability_remains:flags.0?int availability_total:flags.0?int availability_resale:flags.4?long convert_stars:long first_sale_date:flags.1?int last_sale_date:flags.1?int upgrade_stars:flags.3?long resell_min_stars:flags.4?long title:flags.5?string released_by:flags.6?Peer per_user_total:flags.8?int per_user_remains:flags.8?int locked_until_date:flags.9?int auction_slug:flags.11?string gifts_per_round:flags.11?int auction_start_date:flags.11?int upgrade_variants:flags.12?int background:flags.13?StarGiftBackground = StarGift; +starGiftUnique#85f0a9cd flags:# require_premium:flags.6?true resale_ton_only:flags.7?true theme_available:flags.9?true burned:flags.14?true crafted:flags.15?true id:long gift_id:long title:string slug:string num:int owner_id:flags.0?Peer owner_name:flags.1?string owner_address:flags.2?string attributes:Vector availability_issued:int availability_total:int gift_address:flags.3?string resell_amount:flags.4?Vector released_by:flags.5?Peer value_amount:flags.8?long value_currency:flags.8?string value_usd_amount:flags.8?long theme_peer:flags.10?Peer peer_color:flags.11?PeerColor host_id:flags.12?Peer offer_min_stars:flags.13?int craft_chance_permille:flags.16?int = StarGift; + +payments.starGiftsNotModified#a388a368 = payments.StarGifts; +payments.starGifts#2ed82995 hash:int gifts:Vector chats:Vector users:Vector = payments.StarGifts; + +messageReportOption#7903e3d9 text:string option:bytes = MessageReportOption; + +reportResultChooseOption#f0e4e0b6 title:string options:Vector = ReportResult; +reportResultAddComment#6f09ac31 flags:# optional:flags.0?true option:bytes = ReportResult; +reportResultReported#8db33c4b = ReportResult; + +messages.botPreparedInlineMessage#8ecf0511 id:string expire_date:int = messages.BotPreparedInlineMessage; + +messages.preparedInlineMessage#ff57708d query_id:long result:BotInlineResult peer_types:Vector cache_time:int users:Vector = messages.PreparedInlineMessage; + +botAppSettings#c99b1950 flags:# placeholder_path:flags.0?bytes background_color:flags.1?int background_dark_color:flags.2?int header_color:flags.3?int header_dark_color:flags.4?int = BotAppSettings; + +starRefProgram#dd0c66f2 flags:# bot_id:long commission_permille:int duration_months:flags.0?int end_date:flags.1?int daily_revenue_per_user:flags.2?StarsAmount = StarRefProgram; + +connectedBotStarRef#19a13f71 flags:# revoked:flags.1?true url:string date:int bot_id:long commission_permille:int duration_months:flags.0?int participants:long revenue:long = ConnectedBotStarRef; + +payments.connectedStarRefBots#98d5ea1d count:int connected_bots:Vector users:Vector = payments.ConnectedStarRefBots; + +payments.suggestedStarRefBots#b4d5d859 flags:# count:int suggested_bots:Vector users:Vector next_offset:flags.0?string = payments.SuggestedStarRefBots; + +starsAmount#bbb6b4a3 amount:long nanos:int = StarsAmount; +starsTonAmount#74aee3e0 amount:long = StarsAmount; + +messages.foundStickersNotModified#6010c534 flags:# next_offset:flags.0?int = messages.FoundStickers; +messages.foundStickers#82c9e290 flags:# next_offset:flags.0?int hash:long stickers:Vector = messages.FoundStickers; + +botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string = BotVerifierSettings; + +botVerification#f93cd45c bot_id:long icon:long description:string = BotVerification; + +starGiftAttributeModel#565251e2 flags:# crafted:flags.0?true name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributePattern#4e7085ea name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeBackdrop#9f2504e4 name:string backdrop_id:int center_color:int edge_color:int pattern_color:int text_color:int rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeOriginalDetails#e0bff26c flags:# sender_id:flags.0?Peer recipient_id:Peer date:int message:flags.1?TextWithEntities = StarGiftAttribute; + +payments.starGiftUpgradePreview#3de1dfed sample_attributes:Vector prices:Vector next_prices:Vector = payments.StarGiftUpgradePreview; + +users.users#62d706b8 users:Vector = users.Users; +users.usersSlice#315a4974 count:int users:Vector = users.Users; + +payments.uniqueStarGift#416c56e8 gift:StarGift chats:Vector users:Vector = payments.UniqueStarGift; + +messages.webPagePreview#8c9a88ac media:MessageMedia chats:Vector users:Vector = messages.WebPagePreview; + +savedStarGift#41df43fc flags:# name_hidden:flags.0?true unsaved:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true pinned_to_top:flags.12?true upgrade_separate:flags.17?true from_id:flags.1?Peer date:int gift:StarGift message:flags.2?TextWithEntities msg_id:flags.3?int saved_id:flags.11?long convert_stars:flags.4?long upgrade_stars:flags.6?long can_export_at:flags.7?int transfer_stars:flags.8?long can_transfer_at:flags.13?int can_resell_at:flags.14?int collection_id:flags.15?Vector prepaid_upgrade_hash:flags.16?string drop_original_details_stars:flags.18?long gift_num:flags.19?int can_craft_at:flags.20?int = SavedStarGift; + +payments.savedStarGifts#95f389b1 flags:# count:int chat_notifications_enabled:flags.1?Bool gifts:Vector next_offset:flags.0?string chats:Vector users:Vector = payments.SavedStarGifts; + +inputSavedStarGiftUser#69279795 msg_id:int = InputSavedStarGift; +inputSavedStarGiftChat#f101aa7f peer:InputPeer saved_id:long = InputSavedStarGift; +inputSavedStarGiftSlug#2085c238 slug:string = InputSavedStarGift; + +payments.starGiftWithdrawalUrl#84aa3a9c url:string = payments.StarGiftWithdrawalUrl; + +paidReactionPrivacyDefault#206ad49e = PaidReactionPrivacy; +paidReactionPrivacyAnonymous#1f0c1ad9 = PaidReactionPrivacy; +paidReactionPrivacyPeer#dc6cfcf0 peer:InputPeer = PaidReactionPrivacy; + +account.paidMessagesRevenue#1e109708 stars_amount:long = account.PaidMessagesRevenue; + +requirementToContactEmpty#50a9839 = RequirementToContact; +requirementToContactPremium#e581e4e9 = RequirementToContact; +requirementToContactPaidMessages#b4f67e93 stars_amount:long = RequirementToContact; + +businessBotRights#a0624cf7 flags:# reply:flags.0?true read_messages:flags.1?true delete_sent_messages:flags.2?true delete_received_messages:flags.3?true edit_name:flags.4?true edit_bio:flags.5?true edit_profile_photo:flags.6?true edit_username:flags.7?true view_gifts:flags.8?true sell_gifts:flags.9?true change_gift_settings:flags.10?true transfer_and_upgrade_gifts:flags.11?true transfer_stars:flags.12?true manage_stories:flags.13?true = BusinessBotRights; + +disallowedGiftsSettings#71f276c4 flags:# disallow_unlimited_stargifts:flags.0?true disallow_limited_stargifts:flags.1?true disallow_unique_stargifts:flags.2?true disallow_premium_gifts:flags.3?true disallow_stargifts_from_channels:flags.4?true = DisallowedGiftsSettings; + +sponsoredPeer#c69708d3 flags:# random_id:bytes peer:Peer sponsor_info:flags.0?string additional_info:flags.1?string = SponsoredPeer; + +contacts.sponsoredPeersEmpty#ea32b4b1 = contacts.SponsoredPeers; +contacts.sponsoredPeers#eb032884 peers:Vector chats:Vector users:Vector = contacts.SponsoredPeers; + +starGiftAttributeIdModel#48aaae3c document_id:long = StarGiftAttributeId; +starGiftAttributeIdPattern#4a162433 document_id:long = StarGiftAttributeId; +starGiftAttributeIdBackdrop#1f01c757 backdrop_id:int = StarGiftAttributeId; + +starGiftAttributeCounter#2eb1b658 attribute:StarGiftAttributeId count:int = StarGiftAttributeCounter; + +payments.resaleStarGifts#947a12df flags:# count:int gifts:Vector next_offset:flags.0?string attributes:flags.1?Vector attributes_hash:flags.1?long chats:Vector counters:flags.2?Vector users:Vector = payments.ResaleStarGifts; + +stories.canSendStoryCount#c387c04e count_remains:int = stories.CanSendStoryCount; + +pendingSuggestion#e7e82e12 suggestion:string title:TextWithEntities description:TextWithEntities url:string = PendingSuggestion; + +todoItem#cba9a52f id:int title:TextWithEntities = TodoItem; + +todoList#49b92a26 flags:# others_can_append:flags.0?true others_can_complete:flags.1?true title:TextWithEntities list:Vector = TodoList; + +todoCompletion#221bb5e4 id:int completed_by:Peer date:int = TodoCompletion; + +suggestedPost#e8e37e5 flags:# accepted:flags.1?true rejected:flags.2?true price:flags.3?StarsAmount schedule_date:flags.0?int = SuggestedPost; + +starsRating#1b0e4f07 flags:# level:int current_level_stars:long stars:long next_level_stars:flags.0?long = StarsRating; + +starGiftCollection#9d6b13b0 flags:# collection_id:int title:string icon:flags.0?Document gifts_count:int hash:long = StarGiftCollection; + +payments.starGiftCollectionsNotModified#a0ba4f17 = payments.StarGiftCollections; +payments.starGiftCollections#8a2932f3 collections:Vector = payments.StarGiftCollections; + +storyAlbum#9325705a flags:# album_id:int title:string icon_photo:flags.0?Photo icon_video:flags.1?Document = StoryAlbum; + +stories.albumsNotModified#564edaeb = stories.Albums; +stories.albums#c3987a3a hash:long albums:Vector = stories.Albums; + +searchPostsFlood#3e0b5b6a flags:# query_is_free:flags.0?true total_daily:int remains:int wait_till:flags.1?int stars_amount:long = SearchPostsFlood; + +payments.uniqueStarGiftValueInfo#512fe446 flags:# last_sale_on_fragment:flags.1?true value_is_average:flags.6?true currency:string value:long initial_sale_date:int initial_sale_stars:long initial_sale_price:long last_sale_date:flags.0?int last_sale_price:flags.0?long floor_price:flags.2?long average_price:flags.3?long listed_count:flags.4?int fragment_listed_count:flags.5?int fragment_listed_url:flags.5?string = payments.UniqueStarGiftValueInfo; + +profileTabPosts#b98cd696 = ProfileTab; +profileTabGifts#4d4bd46a = ProfileTab; +profileTabMedia#72c64955 = ProfileTab; +profileTabFiles#ab339c00 = ProfileTab; +profileTabMusic#9f27d26e = ProfileTab; +profileTabVoice#e477092e = ProfileTab; +profileTabLinks#d3656499 = ProfileTab; +profileTabGifs#a2c0f695 = ProfileTab; + +users.savedMusicNotModified#e3878aa4 count:int = users.SavedMusic; +users.savedMusic#34a2f297 count:int documents:Vector = users.SavedMusic; + +account.savedMusicIdsNotModified#4fc81d6e = account.SavedMusicIds; +account.savedMusicIds#998d6636 ids:Vector = account.SavedMusicIds; + +payments.checkCanSendGiftResultOk#374fa7ad = payments.CheckCanSendGiftResult; +payments.checkCanSendGiftResultFail#d5e58274 reason:TextWithEntities = payments.CheckCanSendGiftResult; + +inputChatThemeEmpty#83268483 = InputChatTheme; +inputChatTheme#c93de95c emoticon:string = InputChatTheme; +inputChatThemeUniqueGift#87e5dfe4 slug:string = InputChatTheme; + +starGiftUpgradePrice#99ea331d date:int upgrade_stars:long = StarGiftUpgradePrice; + +groupCallMessage#1a8afc7e flags:# from_admin:flags.1?true id:int from_id:Peer date:int message:TextWithEntities paid_message_stars:flags.0?long = GroupCallMessage; + +groupCallDonor#ee430c85 flags:# top:flags.0?true my:flags.1?true peer_id:flags.3?Peer stars:long = GroupCallDonor; + +phone.groupCallStars#9d1dbd26 total_stars:long top_donors:Vector chats:Vector users:Vector = phone.GroupCallStars; + +recentStory#711d692d flags:# live:flags.0?true max_id:flags.1?int = RecentStory; + +auctionBidLevel#310240cc pos:int amount:long date:int = AuctionBidLevel; + +starGiftAuctionStateNotModified#fe333952 = StarGiftAuctionState; +starGiftAuctionState#771a4e66 version:int start_date:int end_date:int min_bid_amount:long bid_levels:Vector top_bidders:Vector next_round_at:int last_gift_num:int gifts_left:int current_round:int total_rounds:int rounds:Vector = StarGiftAuctionState; +starGiftAuctionStateFinished#972dabbf flags:# start_date:int end_date:int average_price:long listed_count:flags.0?int fragment_listed_count:flags.1?int fragment_listed_url:flags.1?string = StarGiftAuctionState; + +starGiftAuctionUserState#2eeed1c4 flags:# returned:flags.1?true bid_amount:flags.0?long bid_date:flags.0?int min_bid_amount:flags.0?long bid_peer:flags.0?Peer acquired_count:int = StarGiftAuctionUserState; + +payments.starGiftAuctionState#6b39f4ec gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState timeout:int users:Vector chats:Vector = payments.StarGiftAuctionState; + +starGiftAuctionAcquiredGift#42b00348 flags:# name_hidden:flags.0?true peer:Peer date:int bid_amount:long round:int pos:int message:flags.1?TextWithEntities gift_num:flags.2?int = StarGiftAuctionAcquiredGift; + +payments.starGiftAuctionAcquiredGifts#7d5bd1f0 gifts:Vector users:Vector chats:Vector = payments.StarGiftAuctionAcquiredGifts; + +starGiftActiveAuctionState#d31bc45d gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState = StarGiftActiveAuctionState; + +payments.starGiftActiveAuctionsNotModified#db33dad0 = payments.StarGiftActiveAuctions; +payments.starGiftActiveAuctions#aef6abbc auctions:Vector users:Vector chats:Vector = payments.StarGiftActiveAuctions; + +inputStarGiftAuction#2e16c98 gift_id:long = InputStarGiftAuction; +inputStarGiftAuctionSlug#7ab58308 slug:string = InputStarGiftAuction; + +passkey#98613ebf flags:# id:string name:string date:int software_emoji_id:flags.0?long last_usage_date:flags.1?int = Passkey; + +account.passkeys#f8e0aa1c passkeys:Vector = account.Passkeys; + +account.passkeyRegistrationOptions#e16b5ce1 options:DataJSON = account.PasskeyRegistrationOptions; + +auth.passkeyLoginOptions#e2037789 options:DataJSON = auth.PasskeyLoginOptions; + +inputPasskeyResponseRegister#3e63935c client_data:DataJSON attestation_data:bytes = InputPasskeyResponse; +inputPasskeyResponseLogin#c31fc14a client_data:DataJSON authenticator_data:bytes signature:bytes user_handle:string = InputPasskeyResponse; + +inputPasskeyCredentialPublicKey#3c27b78f id:string raw_id:string response:InputPasskeyResponse = InputPasskeyCredential; +inputPasskeyCredentialFirebasePNV#5b1ccb28 pnv_token:string = InputPasskeyCredential; + +starGiftBackground#aff56398 center_color:int edge_color:int text_color:int = StarGiftBackground; + +starGiftAuctionRound#3aae0528 num:int duration:int = StarGiftAuctionRound; +starGiftAuctionRoundExtendable#aa021e5 num:int duration:int extend_top:int extend_window:int = StarGiftAuctionRound; + +payments.starGiftUpgradeAttributes#46c6e36f attributes:Vector = payments.StarGiftUpgradeAttributes; + +messages.emojiGameOutcome#da2ad647 seed:bytes stake_ton_amount:long ton_amount:long = messages.EmojiGameOutcome; + +messages.emojiGameUnavailable#59e65335 = messages.EmojiGameInfo; +messages.emojiGameDiceInfo#44e56023 flags:# game_hash:string prev_stake:long current_streak:int params:Vector plays_left:flags.0?int = messages.EmojiGameInfo; + +starGiftAttributeRarity#36437737 permille:int = StarGiftAttributeRarity; +starGiftAttributeRarityUncommon#dbce6389 = StarGiftAttributeRarity; +starGiftAttributeRarityRare#f08d516b = StarGiftAttributeRarity; +starGiftAttributeRarityEpic#78fbf3a8 = StarGiftAttributeRarity; +starGiftAttributeRarityLegendary#cef7e7a8 = StarGiftAttributeRarity; + +keyboardButtonStyle#4fdd3430 flags:# bg_primary:flags.0?true bg_danger:flags.1?true bg_success:flags.2?true icon:flags.3?long = KeyboardButtonStyle; + +---functions--- + +invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; +invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; +invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; +invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; +invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; +invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; +invokeWithBusinessConnection#dd289f8e {X:Type} connection_id:string query:!X = X; +invokeWithGooglePlayIntegrity#1df92984 {X:Type} nonce:string token:string query:!X = X; +invokeWithApnsSecret#0dae54f8 {X:Type} nonce:string secret:string query:!X = X; +invokeWithReCaptcha#adbb0f94 {X:Type} token:string query:!X = X; + +auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; +auth.signUp#aac7b717 flags:# no_joined_notifications:flags.0?true phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +auth.signIn#8d52a951 flags:# phone_number:string phone_code_hash:string phone_code:flags.0?string email_verification:flags.1?EmailVerification = auth.Authorization; +auth.logOut#3e72ba19 = auth.LoggedOut; +auth.resetAuthorizations#9fab0d1a = Bool; +auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; +auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; +auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; +auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; +auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; +auth.resendCode#cae47523 flags:# phone_number:string phone_code_hash:string reason:flags.0?string = auth.SentCode; +auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; +auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; +auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; +auth.acceptLoginToken#e894ad4d token:bytes = Authorization; +auth.checkRecoveryPassword#d36bf79 code:string = Bool; +auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; +auth.requestFirebaseSms#8e39261e flags:# phone_number:string phone_code_hash:string safety_net_token:flags.0?string play_integrity_token:flags.2?string ios_push_secret:flags.1?string = Bool; +auth.resetLoginEmail#7e960193 phone_number:string phone_code_hash:string = auth.SentCode; +auth.reportMissingCode#cb9deff6 phone_number:string phone_code_hash:string mnc:string = Bool; +auth.checkPaidAuth#56e59f9c phone_number:string phone_code_hash:string form_id:long = auth.SentCode; +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; + +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; +account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; +account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; +account.resetNotifySettings#db7e1747 = Bool; +account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; +account.updateStatus#6628562c offline:Bool = Bool; +account.getWallPapers#7967d36 hash:long = account.WallPapers; +account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; +account.checkUsername#2714d86c username:string = Bool; +account.updateUsername#3e0bdd7c username:string = User; +account.getPrivacy#dadbc950 key:InputPrivacyKey = account.PrivacyRules; +account.setPrivacy#c9f81ce8 key:InputPrivacyKey rules:Vector = account.PrivacyRules; +account.deleteAccount#a2c0cf74 flags:# reason:string password:flags.0?InputCheckPasswordSRP = Bool; +account.getAccountTTL#8fc711d = AccountDaysTTL; +account.setAccountTTL#2442485e ttl:AccountDaysTTL = Bool; +account.sendChangePhoneCode#82574ae5 phone_number:string settings:CodeSettings = auth.SentCode; +account.changePhone#70c32edb phone_number:string phone_code_hash:string phone_code:string = User; +account.updateDeviceLocked#38df3532 period:int = Bool; +account.getAuthorizations#e320c158 = account.Authorizations; +account.resetAuthorization#df77f3bc hash:long = Bool; +account.getPassword#548a30f5 = account.Password; +account.getPasswordSettings#9cd4eaf9 password:InputCheckPasswordSRP = account.PasswordSettings; +account.updatePasswordSettings#a59b102f password:InputCheckPasswordSRP new_settings:account.PasswordInputSettings = Bool; +account.sendConfirmPhoneCode#1b3faa88 hash:string settings:CodeSettings = auth.SentCode; +account.confirmPhone#5f2178c3 phone_code_hash:string phone_code:string = Bool; +account.getTmpPassword#449e0b51 password:InputCheckPasswordSRP period:int = account.TmpPassword; +account.getWebAuthorizations#182e6d6f = account.WebAuthorizations; +account.resetWebAuthorization#2d01b9ef hash:long = Bool; +account.resetWebAuthorizations#682d2594 = Bool; +account.getAllSecureValues#b288bc7d = Vector; +account.getSecureValue#73665bc2 types:Vector = Vector; +account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; +account.deleteSecureValue#b880bc4b types:Vector = Bool; +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; +account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; +account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; +account.sendVerifyEmailCode#98e037bb purpose:EmailVerifyPurpose email:string = account.SentEmailCode; +account.verifyEmail#32da4cf purpose:EmailVerifyPurpose verification:EmailVerification = account.EmailVerified; +account.initTakeoutSession#8ef3eab0 flags:# contacts:flags.0?true message_users:flags.1?true message_chats:flags.2?true message_megagroups:flags.3?true message_channels:flags.4?true files:flags.5?true file_max_size:flags.5?long = account.Takeout; +account.finishTakeoutSession#1d2652ee flags:# success:flags.0?true = Bool; +account.confirmPasswordEmail#8fdf1920 code:string = Bool; +account.resendPasswordEmail#7a7f2a15 = Bool; +account.cancelPasswordEmail#c1cbd5b6 = Bool; +account.getContactSignUpNotification#9f07c728 = Bool; +account.setContactSignUpNotification#cff43f61 silent:Bool = Bool; +account.getNotifyExceptions#53577479 flags:# compare_sound:flags.1?true compare_stories:flags.2?true peer:flags.0?InputNotifyPeer = Updates; +account.getWallPaper#fc8ddbea wallpaper:InputWallPaper = WallPaper; +account.uploadWallPaper#e39a8f03 flags:# for_chat:flags.0?true file:InputFile mime_type:string settings:WallPaperSettings = WallPaper; +account.saveWallPaper#6c5a5b37 wallpaper:InputWallPaper unsave:Bool settings:WallPaperSettings = Bool; +account.installWallPaper#feed5769 wallpaper:InputWallPaper settings:WallPaperSettings = Bool; +account.resetWallPapers#bb3b9804 = Bool; +account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; +account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; +account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; +account.getTheme#3a5869ec format:string theme:InputTheme = Theme; +account.getThemes#7206e458 format:string hash:long = account.Themes; +account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; +account.getContentSettings#8b9b4dae = account.ContentSettings; +account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; +account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; +account.resetPassword#9308ce1b = account.ResetPasswordResult; +account.declinePasswordReset#4c9409f6 = Bool; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# confirmed:flags.3?true hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; +account.getSavedRingtones#e1902288 hash:long = account.SavedRingtones; +account.saveRingtone#3dea5b03 id:InputDocument unsave:Bool = account.SavedRingtone; +account.uploadRingtone#831a83a2 file:InputFile file_name:string mime_type:string = Document; +account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; +account.getDefaultEmojiStatuses#d6753386 hash:long = account.EmojiStatuses; +account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; +account.clearRecentEmojiStatuses#18201aae = Bool; +account.reorderUsernames#ef500eab order:Vector = Bool; +account.toggleUsername#58d6b376 username:string active:Bool = Bool; +account.getDefaultProfilePhotoEmojis#e2750328 hash:long = EmojiList; +account.getDefaultGroupPhotoEmojis#915860ae hash:long = EmojiList; +account.getAutoSaveSettings#adcbbcda = account.AutoSaveSettings; +account.saveAutoSaveSettings#d69b8361 flags:# users:flags.0?true chats:flags.1?true broadcasts:flags.2?true peer:flags.3?InputPeer settings:AutoSaveSettings = Bool; +account.deleteAutoSaveExceptions#53bc0020 = Bool; +account.invalidateSignInCodes#ca8ae8ba codes:Vector = Bool; +account.updateColor#684d214e flags:# for_profile:flags.1?true color:flags.2?PeerColor = Bool; +account.getDefaultBackgroundEmojis#a60ab9ce hash:long = EmojiList; +account.getChannelDefaultEmojiStatuses#7727a7d5 hash:long = account.EmojiStatuses; +account.getChannelRestrictedStatusEmojis#35a9e0d5 hash:long = EmojiList; +account.updateBusinessWorkHours#4b00e066 flags:# business_work_hours:flags.0?BusinessWorkHours = Bool; +account.updateBusinessLocation#9e6b131a flags:# geo_point:flags.1?InputGeoPoint address:flags.0?string = Bool; +account.updateBusinessGreetingMessage#66cdafc4 flags:# message:flags.0?InputBusinessGreetingMessage = Bool; +account.updateBusinessAwayMessage#a26a7fa5 flags:# message:flags.0?InputBusinessAwayMessage = Bool; +account.updateConnectedBot#66a08c7e flags:# deleted:flags.1?true rights:flags.0?BusinessBotRights bot:InputUser recipients:InputBusinessBotRecipients = Updates; +account.getConnectedBots#4ea4c80f = account.ConnectedBots; +account.getBotBusinessConnection#76a86270 connection_id:string = Updates; +account.updateBusinessIntro#a614d034 flags:# intro:flags.0?InputBusinessIntro = Bool; +account.toggleConnectedBotPaused#646e1097 peer:InputPeer paused:Bool = Bool; +account.disablePeerConnectedBot#5e437ed9 peer:InputPeer = Bool; +account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool; +account.createBusinessChatLink#8851e68e link:InputBusinessChatLink = BusinessChatLink; +account.editBusinessChatLink#8c3410af slug:string link:InputBusinessChatLink = BusinessChatLink; +account.deleteBusinessChatLink#60073674 slug:string = Bool; +account.getBusinessChatLinks#6f70dde1 = account.BusinessChatLinks; +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; +account.updatePersonalChannel#d94305e0 channel:InputChannel = Bool; +account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; +account.getReactionsNotifySettings#6dd654c = ReactionsNotifySettings; +account.setReactionsNotifySettings#316ce548 settings:ReactionsNotifySettings = ReactionsNotifySettings; +account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; +account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; +account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; +account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; +account.saveMusic#b26732a9 flags:# unsave:flags.0?true id:InputDocument after_id:flags.1?InputDocument = Bool; +account.getSavedMusicIds#e09d5faf hash:long = account.SavedMusicIds; +account.getUniqueGiftChatThemes#e42ce9c9 offset:string limit:int hash:long = account.ChatThemes; +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; +account.getPasskeys#ea1f0c52 = account.Passkeys; +account.deletePasskey#f5b5563f id:string = Bool; + +users.getUsers#d91a548 id:Vector = Vector; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; +users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; +users.getRequirementsToContact#d89a83a3 id:Vector = Vector; +users.getSavedMusic#788d7fe3 id:InputUser offset:int limit:int hash:long = users.SavedMusic; +users.getSavedMusicByID#7573a4e9 id:InputUser documents:Vector = users.SavedMusic; +users.suggestBirthday#fc533372 id:InputUser birthday:Birthday = Updates; + +contacts.getContactIDs#7adc669d hash:long = Vector; +contacts.getStatuses#c4a353ee = Vector; +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; +contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; +contacts.deleteContacts#96a0e00 id:Vector = Updates; +contacts.deleteByPhones#1013fd9e phones:Vector = Bool; +contacts.block#2e2e8734 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.unblock#b550d328 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.getBlocked#9a868f80 flags:# my_stories_from:flags.0?true offset:int limit:int = contacts.Blocked; +contacts.search#11f812d8 q:string limit:int = contacts.Found; +contacts.resolveUsername#725afbbc flags:# username:string referer:flags.0?string = contacts.ResolvedPeer; +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true bots_app:flags.16?true offset:int limit:int hash:long = contacts.TopPeers; +contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; +contacts.resetSaved#879537f1 = Bool; +contacts.getSaved#82f1e39f = Vector; +contacts.toggleTopPeers#8514bdda enabled:Bool = Bool; +contacts.addContact#d9ba2e54 flags:# add_phone_privacy_exception:flags.0?true id:InputUser first_name:string last_name:string phone:string note:flags.1?TextWithEntities = Updates; +contacts.acceptContact#f831a20f id:InputUser = Updates; +contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; +contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; +contacts.exportContactToken#f8654027 = ExportedContactToken; +contacts.importContactToken#13005788 token:string = User; +contacts.editCloseFriends#ba6705f0 id:Vector = Bool; +contacts.setBlocked#94c65c76 flags:# my_stories_from:flags.0?true id:Vector limit:int = Bool; +contacts.getBirthdays#daeda864 = contacts.ContactBirthdays; +contacts.getSponsoredPeers#b6c8c393 q:string = contacts.SponsoredPeers; +contacts.updateContactNote#139f63fb id:InputUser note:TextWithEntities = Bool; + +messages.getMessages#63c66506 id:Vector = messages.Messages; +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; +messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.search#29ee847a flags:# peer:InputPeer q:string from_id:flags.0?InputPeer saved_peer_id:flags.2?InputPeer saved_reaction:flags.3?Vector top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; +messages.receivedMessages#5a954c0 max_id:int = Vector; +messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; +messages.sendMessage#545cd15a flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.sendMedia#330e77f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.forwardMessages#13704a7c flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true allow_paid_floodskip:flags.19?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer top_msg_id:flags.9?int reply_to:flags.22?InputReplyTo schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long video_timestamp:flags.20?int allow_paid_stars:flags.21?long suggested_post:flags.23?SuggestedPost = Updates; +messages.reportSpam#cf1592db peer:InputPeer = Bool; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; +messages.report#fc78af9b peer:InputPeer id:Vector option:bytes message:string = ReportResult; +messages.getChats#49e9528f id:Vector = messages.Chats; +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; +messages.addChatUser#cbc6d107 chat_id:long user_id:InputUser fwd_limit:int = messages.InvitedUsers; +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; +messages.createChat#92ceddd4 flags:# users:Vector title:string ttl_period:flags.0?int = messages.InvitedUsers; +messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; +messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; +messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; +messages.discardEncryption#f393aea0 flags:# delete_history:flags.0?true chat_id:int = Bool; +messages.setEncryptedTyping#791451ed peer:InputEncryptedChat typing:Bool = Bool; +messages.readEncryptedHistory#7f4b690a peer:InputEncryptedChat max_date:int = Bool; +messages.sendEncrypted#44fa7a15 flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.sendEncryptedFile#5559481d flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes file:InputEncryptedFile = messages.SentEncryptedMessage; +messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.receivedQueue#55a5bb66 max_qts:int = Vector; +messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; +messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; +messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; +messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; +messages.getWebPagePreview#570d6f6f flags:# message:string entities:flags.3?Vector = messages.WebPagePreview; +messages.exportChatInvite#a455de90 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string subscription_pricing:flags.5?StarsSubscriptionPricing = ExportedChatInvite; +messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; +messages.importChatInvite#6c50051c hash:string = Updates; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; +messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; +messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; +messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; +messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; +messages.migrateChat#a2875319 chat_id:long = Updates; +messages.searchGlobal#4bc6589a flags:# broadcasts_only:flags.1?true groups_only:flags.2?true users_only:flags.3?true folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; +messages.reorderStickerSets#78337739 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Bool; +messages.getDocumentByHash#b1f2061f sha256:bytes size:long mime_type:string = Document; +messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; +messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; +messages.setInlineBotResults#bb12a419 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM switch_webview:flags.4?InlineBotWebView = Bool; +messages.sendInlineBotResult#c0cf7646 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to:flags.0?InputReplyTo random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut allow_paid_stars:flags.21?long = Updates; +messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; +messages.editMessage#51e842e1 flags:# no_webpage:flags.1?true invert_media:flags.16?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int schedule_repeat_period:flags.18?int quick_reply_shortcut_id:flags.17?int = Updates; +messages.editInlineBotMessage#83557dba flags:# no_webpage:flags.1?true invert_media:flags.16?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; +messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer; +messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; +messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; +messages.saveDraft#54ae308e flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo peer:InputPeer message:string entities:flags.3?Vector media:flags.5?InputMedia effect:flags.7?long suggested_post:flags.8?SuggestedPost = Bool; +messages.getAllDrafts#6a3f8d65 = Updates; +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; +messages.readFeaturedStickers#5b118126 id:Vector = Bool; +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; +messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; +messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true emojis:flags.1?true offset_id:long limit:int = messages.ArchivedStickers; +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; +messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; +messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; +messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; +messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; +messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; +messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; +messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; +messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?string shipping_options:flags.1?Vector = Bool; +messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; +messages.uploadMedia#14967978 flags:# business_connection_id:flags.0?string peer:InputPeer media:InputMedia = MessageMedia; +messages.sendScreenshotNotification#a1405817 peer:InputPeer reply_to:InputReplyTo random_id:long = Updates; +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; +messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; +messages.getUnreadMentions#f107e790 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readMentions#36e5bf4d flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; +messages.sendMultiMedia#1bf89d74 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long = Updates; +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSplitRanges#1cff7e08 = Vector; +messages.markDialogUnread#8c5006f8 flags:# unread:flags.0?true parent_peer:flags.1?InputPeer peer:InputDialogPeer = Bool; +messages.getDialogUnreadMarks#21202222 flags:# parent_peer:flags.0?InputPeer = Vector; +messages.clearAllDrafts#7e58ee9c = Bool; +messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1?true pm_oneside:flags.2?true peer:InputPeer id:int = Updates; +messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; +messages.getPollResults#73bb643b peer:InputPeer msg_id:int = Updates; +messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; +messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; +messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; +messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; +messages.getEmojiKeywordsDifference#1508b6af lang_code:string from_version:int = EmojiKeywordsDifference; +messages.getEmojiKeywordsLanguages#4e9963b2 lang_codes:Vector = Vector; +messages.getEmojiURL#d5b10c26 lang_code:string = EmojiURL; +messages.getSearchCounters#1bbcf300 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer top_msg_id:flags.0?int filters:Vector = Vector; +messages.requestUrlAuth#198fb446 flags:# peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string = UrlAuthResult; +messages.acceptUrlAuth#b12c7125 flags:# write_allowed:flags.0?true share_phone_number:flags.3?true peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string = UrlAuthResult; +messages.hidePeerSettingsBar#4facb138 peer:InputPeer = Bool; +messages.getScheduledHistory#f516760b peer:InputPeer hash:long = messages.Messages; +messages.getScheduledMessages#bdbb0464 peer:InputPeer id:Vector = messages.Messages; +messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; +messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; +messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; +messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; +messages.getDialogFilters#efd48c89 = messages.DialogFilters; +messages.getSuggestedDialogFilters#a29cd42c = Vector; +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; +messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; +messages.unpinAllMessages#62dd747 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; +messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; +messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; +messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:string media:InputMedia = MessageMedia; +messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; +messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; +messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true subscription_expired:flags.3?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; +messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; +messages.setChatTheme#81202c9 peer:InputPeer theme:InputChatTheme = Updates; +messages.getMessageReadParticipants#31c1c44f peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#6aa3f6bd flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#9c7f2f10 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#d30d78d4 flags:# big:flags.1?true add_to_recent:flags.2?true peer:InputPeer msg_id:int reaction:flags.0?Vector = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#864b2581 flags:# peer:InputPeer available_reactions:ChatReactions reactions_limit:flags.0?int paid_enabled:flags.1?Bool = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool; +messages.translateText#63183030 flags:# peer:flags.0?InputPeer id:flags.0?Vector text:flags.1?Vector to_lang:string = messages.TranslatedText; +messages.getUnreadReactions#bd7f90ac flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#9ec44f93 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; +messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; +messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; +messages.toggleBotInAttachMenu#69f59d69 flags:# write_allowed:flags.0?true bot:InputUser enabled:Bool = Bool; +messages.requestWebView#269dc2c1 flags:# from_bot_menu:flags.4?true silent:flags.5?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser url:flags.1?string start_param:flags.3?string theme_params:flags.2?DataJSON platform:string reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = WebViewResult; +messages.prolongWebView#b0d81a83 flags:# silent:flags.5?true peer:InputPeer bot:InputUser query_id:long reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = Bool; +messages.requestSimpleWebView#413a3e73 flags:# from_switch_webview:flags.1?true from_side_menu:flags.2?true compact:flags.7?true fullscreen:flags.8?true bot:InputUser url:flags.3?string start_param:flags.4?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent; +messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates; +messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio; +messages.rateTranscribedAudio#7f1d072f peer:InputPeer msg_id:int transcription_id:long good:Bool = Bool; +messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; +messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; +messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; +messages.reportReaction#3f64c076 peer:InputPeer id:int reaction_peer:InputPeer = Bool; +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; +messages.clearRecentReactions#9dfeefb4 = Bool; +messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; +messages.sendBotRequestedPeer#91b2d060 peer:InputPeer msg_id:int button_id:int requested_peers:Vector = Updates; +messages.getEmojiGroups#7488ce5b hash:int = messages.EmojiGroups; +messages.getEmojiStatusGroups#2ecd56cd hash:int = messages.EmojiGroups; +messages.getEmojiProfilePhotoGroups#21a548f3 hash:int = messages.EmojiGroups; +messages.searchCustomEmoji#2c11c0d7 emoticon:string hash:long = EmojiList; +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; +messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; +messages.requestAppWebView#53618bce flags:# write_allowed:flags.0?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = WebViewResult; +messages.setChatWallPaper#8ffacae1 flags:# for_both:flags.3?true revert:flags.4?true peer:InputPeer wallpaper:flags.0?InputWallPaper settings:flags.2?WallPaperSettings id:flags.1?int = Updates; +messages.searchEmojiStickerSets#92b4494c flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSavedDialogs#1e91fc99 flags:# exclude_pinned:flags.0?true parent_peer:flags.1?InputPeer offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; +messages.getSavedHistory#998ab009 flags:# parent_peer:flags.0?InputPeer peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.deleteSavedHistory#4dc5085f flags:# parent_peer:flags.0?InputPeer peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; +messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedSavedDialogs#8b716587 flags:# force:flags.0?true order:Vector = Bool; +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; +messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; +messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; +messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; +messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; +messages.reorderQuickReplies#60331907 order:Vector = Bool; +messages.checkQuickReplyShortcut#f1d0fbd3 shortcut:string = Bool; +messages.editQuickReplyShortcut#5c003cef shortcut_id:int shortcut:string = Bool; +messages.deleteQuickReplyShortcut#3cc04740 shortcut_id:int = Bool; +messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; +messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; +messages.deleteQuickReplyMessages#e105e910 shortcut_id:int id:Vector = Updates; +messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool; +messages.getMyStickers#d0b5e1fc offset_id:long limit:int = messages.MyStickers; +messages.getEmojiStickerGroups#1dd840f5 hash:int = messages.EmojiGroups; +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; +messages.editFactCheck#589ee75 peer:InputPeer msg_id:int text:TextWithEntities = Updates; +messages.deleteFactCheck#d1da940c peer:InputPeer msg_id:int = Updates; +messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; +messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendPaidReaction#58bbcb50 flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?PaidReactionPrivacy = Updates; +messages.togglePaidReactionPrivacy#435885b5 peer:InputPeer msg_id:int private:PaidReactionPrivacy = Bool; +messages.getPaidReactionPrivacy#472455aa = Updates; +messages.viewSponsoredMessage#269e3643 random_id:bytes = Bool; +messages.clickSponsoredMessage#8235057e flags:# media:flags.0?true fullscreen:flags.1?true random_id:bytes = Bool; +messages.reportSponsoredMessage#12cbf0c4 random_id:bytes option:bytes = channels.SponsoredMessageReportResult; +messages.getSponsoredMessages#3d6ce850 flags:# peer:InputPeer msg_id:flags.0?int = messages.SponsoredMessages; +messages.savePreparedInlineMessage#f21f7f2f flags:# result:InputBotInlineResult user_id:InputUser peer_types:flags.0?Vector = messages.BotPreparedInlineMessage; +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; +messages.searchStickers#29b1c66a flags:# emojis:flags.0?true q:string emoticon:string lang_code:Vector offset:int limit:int hash:long = messages.FoundStickers; +messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = Bool; +messages.getSavedDialogsByID#6f6f9c96 flags:# parent_peer:flags.1?InputPeer ids:Vector = messages.SavedDialogs; +messages.readSavedHistory#ba4a3b5b parent_peer:InputPeer peer:InputPeer max_id:int = Bool; +messages.toggleTodoCompleted#d3e03124 peer:InputPeer msg_id:int completed:Vector incompleted:Vector = Updates; +messages.appendTodoList#21a61057 peer:InputPeer msg_id:int list:Vector = Updates; +messages.toggleSuggestedPostApproval#8107455c flags:# reject:flags.1?true peer:InputPeer msg_id:int schedule_date:flags.0?int reject_comment:flags.2?string = Updates; +messages.getForumTopics#3ba47bff flags:# peer:InputPeer q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; +messages.getForumTopicsByID#af0a4a08 peer:InputPeer topics:Vector = messages.ForumTopics; +messages.editForumTopic#cecc1134 flags:# peer:InputPeer topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; +messages.updatePinnedForumTopic#175df251 peer:InputPeer topic_id:int pinned:Bool = Updates; +messages.reorderPinnedForumTopics#e7841f0 flags:# force:flags.0?true peer:InputPeer order:Vector = Updates; +messages.createForumTopic#2f98c3d5 flags:# title_missing:flags.4?true peer:InputPeer title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; +messages.deleteTopicHistory#d2816f10 peer:InputPeer top_msg_id:int = messages.AffectedHistory; +messages.getEmojiGameInfo#fb7e8ca7 = messages.EmojiGameInfo; +messages.summarizeText#9d4104e2 flags:# peer:InputPeer id:int to_lang:flags.0?string = TextWithEntities; + +updates.getState#edd4882a = updates.State; +updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; +updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; + +photos.updateProfilePhoto#9e82039 flags:# fallback:flags.0?true bot:flags.1?InputUser id:InputPhoto = photos.Photo; +photos.uploadProfilePhoto#388a3b5 flags:# fallback:flags.3?true bot:flags.5?InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.4?VideoSize = photos.Photo; +photos.deletePhotos#87cf7f2f id:Vector = Vector; +photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; +photos.uploadContactProfilePhoto#e14c4a71 flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.5?VideoSize = photos.Photo; + +upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; +upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; +upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool; +upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile; +upload.getCdnFile#395f69da file_token:bytes offset:long limit:int = upload.CdnFile; +upload.reuploadCdnFile#9b2754a8 file_token:bytes request_token:bytes = Vector; +upload.getCdnFileHashes#91dc3f31 file_token:bytes offset:long = Vector; +upload.getFileHashes#9156982a location:InputFileLocation offset:long = Vector; + +help.getConfig#c4f9186b = Config; +help.getNearestDc#1fb33026 = NearestDc; +help.getAppUpdate#522d5a7d source:string = help.AppUpdate; +help.getInviteText#4d392343 = help.InviteText; +help.getSupport#9cdf08cd = help.Support; +help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; +help.getCdnConfig#52029342 = CdnConfig; +help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; +help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; +help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; +help.getAppConfig#61e3f854 hash:int = help.AppConfig; +help.saveAppLog#6f02f748 events:Vector = Bool; +help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; +help.getSupportName#d360e72c = help.SupportName; +help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; +help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; +help.getPromoData#c0977421 = help.PromoData; +help.hidePromoData#1e251c95 peer:InputPeer = Bool; +help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; +help.getPremiumPromo#b81b93d4 = help.PremiumPromo; +help.getPeerColors#da80f42f hash:int = help.PeerColors; +help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors; +help.getTimezonesList#49b30240 hash:int = help.TimezonesList; + +channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; +channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; +channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; +channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; +channels.getChannels#a7f6bbb id:Vector = messages.Chats; +channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true forum:flags.5?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; +channels.editAdmin#d33c8902 channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:string = Updates; +channels.editTitle#566decd0 channel:InputChannel title:string = Updates; +channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; +channels.checkUsername#10e6bd2c channel:InputChannel username:string = Bool; +channels.updateUsername#3514b3de channel:InputChannel username:string = Bool; +channels.joinChannel#24b524c5 channel:InputChannel = Updates; +channels.leaveChannel#f836aa95 channel:InputChannel = Updates; +channels.inviteToChannel#c9e33d54 channel:InputChannel users:Vector = messages.InvitedUsers; +channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; +channels.exportMessageLink#e63fadeb flags:# grouped:flags.0?true thread:flags.1?true channel:InputChannel id:int = ExportedMessageLink; +channels.toggleSignatures#418d549c flags:# signatures_enabled:flags.0?true profiles_enabled:flags.1?true channel:InputChannel = Updates; +channels.getAdminedPublicChannels#f8b036af flags:# by_location:flags.0?true check_limit:flags.1?true for_personal:flags.2?true = messages.Chats; +channels.editBanned#96e6cd81 channel:InputChannel participant:InputPeer banned_rights:ChatBannedRights = Updates; +channels.getAdminLog#33ddf480 flags:# channel:InputChannel q:string events_filter:flags.0?ChannelAdminLogEventsFilter admins:flags.1?Vector max_id:long min_id:long limit:int = channels.AdminLogResults; +channels.setStickers#ea8ca4f9 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = Bool; +channels.deleteHistory#9baa9647 flags:# for_everyone:flags.0?true channel:InputChannel max_id:int = Updates; +channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates; +channels.getLeftChannels#8341ecc0 offset:int = messages.Chats; +channels.getGroupsForDiscussion#f5dad378 = messages.Chats; +channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool; +channels.editCreator#8f38cd1f channel:InputChannel user_id:InputUser password:InputCheckPasswordSRP = Updates; +channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint address:string = Bool; +channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; +channels.getInactiveChannels#11e831ee = messages.InactiveChats; +channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; +channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; +channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; +channels.toggleJoinRequest#4c2985b6 channel:InputChannel enabled:Bool = Updates; +channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; +channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; +channels.deactivateAllUsernames#a245dd3 channel:InputChannel = Bool; +channels.toggleForum#3ff75734 channel:InputChannel enabled:Bool tabs:Bool = Updates; +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; +channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; +channels.updateColor#d8aa3671 flags:# for_profile:flags.1?true channel:InputChannel color:flags.2?int background_emoji_id:flags.0?long = Updates; +channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; +channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; +channels.updateEmojiStatus#f0d3e6a8 channel:InputChannel emoji_status:EmojiStatus = Updates; +channels.setBoostsToUnblockRestrictions#ad399cee channel:InputChannel boosts:int = Updates; +channels.setEmojiStickers#3cd930b7 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.restrictSponsoredMessages#9ae91519 channel:InputChannel restricted:Bool = Updates; +channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; +channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; +channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; +channels.getMessageAuthor#ece2a0e6 channel:InputChannel id:int = User; +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; +channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool; +channels.getFutureCreatorAfterLeave#a00918af channel:InputChannel = User; + +bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; +bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; +bots.setBotMenuButton#4504d54f user_id:InputUser button:BotMenuButton = Bool; +bots.getBotMenuButton#9c60eb28 user_id:InputUser = BotMenuButton; +bots.setBotBroadcastDefaultAdminRights#788464e1 admin_rights:ChatAdminRights = Bool; +bots.setBotGroupDefaultAdminRights#925ec9ea admin_rights:ChatAdminRights = Bool; +bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; +bots.getBotInfo#dcd914fd flags:# bot:flags.0?InputUser lang_code:string = bots.BotInfo; +bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = Bool; +bots.toggleUsername#53ca973 bot:InputUser username:string active:Bool = Bool; +bots.canSendMessage#1359f4e6 bot:InputUser = Bool; +bots.allowSendMessage#f132e3ef bot:InputUser = Updates; +bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; +bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots; +bots.addPreviewMedia#17aeb75a bot:InputUser lang_code:string media:InputMedia = BotPreviewMedia; +bots.editPreviewMedia#8525606f bot:InputUser lang_code:string media:InputMedia new_media:InputMedia = BotPreviewMedia; +bots.deletePreviewMedia#2d0135b3 bot:InputUser lang_code:string media:Vector = Bool; +bots.reorderPreviewMedias#b627f3aa bot:InputUser lang_code:string order:Vector = Bool; +bots.getPreviewInfo#423ab3ad bot:InputUser lang_code:string = bots.PreviewInfo; +bots.getPreviewMedias#a2a5594d bot:InputUser = Vector; +bots.updateUserEmojiStatus#ed9f30c5 user_id:InputUser emoji_status:EmojiStatus = Bool; +bots.toggleUserEmojiStatusPermission#6de6392 bot:InputUser enabled:Bool = Bool; +bots.checkDownloadFileParams#50077589 bot:InputUser file_name:string url:string = Bool; +bots.getAdminedBots#b0711d83 = Vector; +bots.updateStarRefProgram#778b5ab3 flags:# bot:InputUser commission_permille:int duration_months:flags.0?int = StarRefProgram; +bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool; +bots.getBotRecommendations#a1b70815 bot:InputUser = users.Users; + +payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; +payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; +payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; +payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; +payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; +payments.getBankCardData#2e79d779 number:string = payments.BankCardData; +payments.exportInvoice#f91b065 invoice_media:InputMedia = payments.ExportedInvoice; +payments.assignAppStoreTransaction#80ed747d receipt:bytes purpose:InputStorePaymentPurpose = Updates; +payments.assignPlayMarketTransaction#dffd50d3 receipt:DataJSON purpose:InputStorePaymentPurpose = Updates; +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; +payments.applyGiftCode#f6e26854 slug:string = Updates; +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; +payments.getStarsTopupOptions#c00ec7d3 = Vector; +payments.getStarsStatus#4ea9b3bf flags:# ton:flags.0?true peer:InputPeer = payments.StarsStatus; +payments.getStarsTransactions#69da4557 flags:# inbound:flags.0?true outbound:flags.1?true ascending:flags.2?true ton:flags.4?true subscription_id:flags.3?string peer:InputPeer offset:string limit:int = payments.StarsStatus; +payments.sendStarsForm#7998c914 form_id:long invoice:InputInvoice = payments.PaymentResult; +payments.refundStarsCharge#25ae8f4a user_id:InputUser charge_id:string = Updates; +payments.getStarsRevenueStats#d91ffad6 flags:# dark:flags.0?true ton:flags.1?true peer:InputPeer = payments.StarsRevenueStats; +payments.getStarsRevenueWithdrawalUrl#2433dc92 flags:# ton:flags.0?true peer:InputPeer amount:flags.1?long password:InputCheckPasswordSRP = payments.StarsRevenueWithdrawalUrl; +payments.getStarsRevenueAdsAccountUrl#d1d7efc5 peer:InputPeer = payments.StarsRevenueAdsAccountUrl; +payments.getStarsTransactionsByID#2dca16b8 flags:# ton:flags.0?true peer:InputPeer id:Vector = payments.StarsStatus; +payments.getStarsGiftOptions#d3c96bc8 flags:# user_id:flags.0?InputUser = Vector; +payments.getStarsSubscriptions#32512c5 flags:# missing_balance:flags.0?true peer:InputPeer offset:string = payments.StarsStatus; +payments.changeStarsSubscription#c7770878 flags:# peer:InputPeer subscription_id:string canceled:flags.0?Bool = Bool; +payments.fulfillStarsSubscription#cc5bebb3 peer:InputPeer subscription_id:string = Bool; +payments.getStarsGiveawayOptions#bd1efd3e = Vector; +payments.getStarGifts#c4563590 hash:int = payments.StarGifts; +payments.saveStarGift#2a2a697c flags:# unsave:flags.0?true stargift:InputSavedStarGift = Bool; +payments.convertStarGift#74bf076b stargift:InputSavedStarGift = Bool; +payments.botCancelStarsSubscription#6dfa0622 flags:# restore:flags.0?true user_id:InputUser charge_id:string = Bool; +payments.getConnectedStarRefBots#5869a553 flags:# peer:InputPeer offset_date:flags.2?int offset_link:flags.2?string limit:int = payments.ConnectedStarRefBots; +payments.getConnectedStarRefBot#b7d998f0 peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.getSuggestedStarRefBots#d6b48f7 flags:# order_by_revenue:flags.0?true order_by_date:flags.1?true peer:InputPeer offset:string limit:int = payments.SuggestedStarRefBots; +payments.connectStarRefBot#7ed5348a peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.editConnectedStarRefBot#e4fca4a3 flags:# revoked:flags.0?true peer:InputPeer link:string = payments.ConnectedStarRefBots; +payments.getStarGiftUpgradePreview#9c9abcb1 gift_id:long = payments.StarGiftUpgradePreview; +payments.upgradeStarGift#aed6e4f5 flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = Updates; +payments.transferStarGift#7f18176a stargift:InputSavedStarGift to_id:InputPeer = Updates; +payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift; +payments.getSavedStarGifts#a319e569 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_unique:flags.4?true sort_by_value:flags.5?true exclude_upgradable:flags.7?true exclude_unupgradable:flags.8?true peer_color_available:flags.9?true exclude_hosted:flags.10?true peer:InputPeer collection_id:flags.6?int offset:string limit:int = payments.SavedStarGifts; +payments.getSavedStarGift#b455a106 stargift:Vector = payments.SavedStarGifts; +payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl; +payments.toggleChatStarGiftNotifications#60eaefa1 flags:# enabled:flags.0?true peer:InputPeer = Bool; +payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = Bool; +payments.canPurchaseStore#4fdc5ea7 purpose:InputStorePaymentPurpose = Bool; +payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true for_craft:flags.4?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector offset:string limit:int = payments.ResaleStarGifts; +payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates; +payments.createStarGiftCollection#1f4a0e87 peer:InputPeer title:string stargift:Vector = StarGiftCollection; +payments.updateStarGiftCollection#4fddbee7 flags:# peer:InputPeer collection_id:int title:flags.0?string delete_stargift:flags.1?Vector add_stargift:flags.2?Vector order:flags.3?Vector = StarGiftCollection; +payments.reorderStarGiftCollections#c32af4cc peer:InputPeer order:Vector = Bool; +payments.deleteStarGiftCollection#ad5648e8 peer:InputPeer collection_id:int = Bool; +payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections; +payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGiftValueInfo; +payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; +payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; +payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; +payments.sendStarGiftOffer#8fb86b41 flags:# peer:InputPeer slug:string price:StarsAmount duration:int random_id:long allow_paid_stars:flags.0?long = Updates; +payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; +payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts; +payments.craftStarGift#b0f9684f stargift:Vector = Updates; + +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true emojis:flags.5?true text_color:flags.6?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; +stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; +stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; +stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; +stickers.setStickerSetThumb#a76a5392 flags:# stickerset:InputStickerSet thumb:flags.0?InputDocument thumb_document_id:flags.1?long = messages.StickerSet; +stickers.checkShortName#284b3639 short_name:string = Bool; +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; +stickers.changeSticker#f5537ebc flags:# sticker:InputDocument emoji:flags.0?string mask_coords:flags.1?MaskCoords keywords:flags.2?string = messages.StickerSet; +stickers.renameStickerSet#124b1c00 stickerset:InputStickerSet title:string = messages.StickerSet; +stickers.deleteStickerSet#87704394 stickerset:InputStickerSet = Bool; +stickers.replaceSticker#4696459a sticker:InputDocument new_sticker:InputStickerSetItem = messages.StickerSet; + +phone.getCallConfig#55451fa9 = DataJSON; +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.toggleGroupCallSettings#974392f2 flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool messages_enabled:flags.2?Bool send_paid_messages_stars:flags.3?long = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; +phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; +phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; +phone.getGroupCallStreamRtmpUrl#5af4c73a flags:# live_story:flags.0?true peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; +phone.saveCallLog#41248786 peer:InputPhoneCall file:InputFile = Bool; +phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates; +phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector block:bytes = Updates; +phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates; +phone.inviteConferenceCallParticipant#bcf22685 flags:# video:flags.0?true call:InputGroupCall user_id:InputUser = Updates; +phone.declineConferenceCallInvite#3c479971 msg_id:int = Updates; +phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates; +phone.sendGroupCallMessage#b1d11410 flags:# call:InputGroupCall random_id:long message:TextWithEntities allow_paid_stars:flags.0?long send_as:flags.1?InputPeer = Updates; +phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool; +phone.deleteGroupCallMessages#f64f54f7 flags:# report_spam:flags.0?true call:InputGroupCall messages:Vector = Updates; +phone.deleteGroupCallParticipantMessages#1dbfeca0 flags:# report_spam:flags.0?true call:InputGroupCall participant:InputPeer = Updates; +phone.getGroupCallStars#6f636302 call:InputGroupCall = phone.GroupCallStars; +phone.saveDefaultSendAs#4167add1 call:InputGroupCall send_as:InputPeer = Bool; + +langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; +langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; +langpack.getDifference#cd984aa5 lang_pack:string lang_code:string from_version:int = LangPackDifference; +langpack.getLanguages#42c6978f lang_pack:string = Vector; +langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLanguage; + +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; + +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; +stats.getMessagePublicForwards#5f150144 channel:InputChannel msg_id:int offset:string limit:int = stats.PublicForwards; +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; +stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats; +stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards; + +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = chatlists.ExportedChatlistInvite; +chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool; +chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector = ExportedChatlistInvite; +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; +chatlists.getChatlistUpdates#89419521 chatlist:InputChatlist = chatlists.ChatlistUpdates; +chatlists.joinChatlistUpdates#e089f8f5 chatlist:InputChatlist peers:Vector = Updates; +chatlists.hideChatlistUpdates#66e486fb chatlist:InputChatlist = Bool; +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates; + +stories.canSendStory#30eb63f0 peer:InputPeer = stories.CanSendStoryCount; +stories.sendStory#737fc2ec flags:# pinned:flags.2?true noforwards:flags.4?true fwd_modified:flags.7?true peer:InputPeer media:InputMedia media_areas:flags.5?Vector caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long period:flags.3?int fwd_from_id:flags.6?InputPeer fwd_from_story:flags.6?int albums:flags.8?Vector = Updates; +stories.editStory#b583ba46 flags:# peer:InputPeer id:int media:flags.0?InputMedia media_areas:flags.3?Vector caption:flags.1?string entities:flags.1?Vector privacy_rules:flags.2?Vector = Updates; +stories.deleteStories#ae59db5f peer:InputPeer id:Vector = Vector; +stories.togglePinned#9a75a1ef peer:InputPeer id:Vector pinned:Bool = Vector; +stories.getAllStories#eeb0d625 flags:# next:flags.1?true hidden:flags.2?true state:flags.0?string = stories.AllStories; +stories.getPinnedStories#5821a5dc peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesArchive#b4352016 peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesByID#5774ca74 peer:InputPeer id:Vector = stories.Stories; +stories.toggleAllStoriesHidden#7c2557c4 hidden:Bool = Bool; +stories.readStories#a556dac8 peer:InputPeer max_id:int = Vector; +stories.incrementStoryViews#b2028afb peer:InputPeer id:Vector = Bool; +stories.getStoryViewsList#7ed23c57 flags:# just_contacts:flags.0?true reactions_first:flags.2?true forwards_first:flags.3?true peer:InputPeer q:flags.1?string id:int offset:string limit:int = stories.StoryViewsList; +stories.getStoriesViews#28e16cc8 peer:InputPeer id:Vector = stories.StoryViews; +stories.exportStoryLink#7b8def20 peer:InputPeer id:int = ExportedStoryLink; +stories.report#19d8eb45 peer:InputPeer id:Vector option:bytes message:string = ReportResult; +stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?true = Updates; +stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; +stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; +stories.getAllReadPeerStories#9b5ae7f9 = Updates; +stories.getPeerMaxIDs#78499170 id:Vector = Vector; +stories.getChatsToSend#a56a8b60 = messages.Chats; +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.getStoryReactionsList#b9b2881f flags:# forwards_first:flags.2?true peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = stories.StoryReactionsList; +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; +stories.searchPosts#d1810907 flags:# hashtag:flags.0?string area:flags.1?MediaArea peer:flags.2?InputPeer offset:string limit:int = stories.FoundStories; +stories.createAlbum#a36396e5 peer:InputPeer title:string stories:Vector = StoryAlbum; +stories.updateAlbum#5e5259b6 flags:# peer:InputPeer album_id:int title:flags.0?string delete_stories:flags.1?Vector add_stories:flags.2?Vector order:flags.3?Vector = StoryAlbum; +stories.reorderAlbums#8535fbd9 peer:InputPeer order:Vector = Bool; +stories.deleteAlbum#8d3456d0 peer:InputPeer album_id:int = Bool; +stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums; +stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories; +stories.startLive#d069ccde flags:# pinned:flags.2?true noforwards:flags.4?true rtmp_stream:flags.5?true peer:InputPeer caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long messages_enabled:flags.6?Bool send_paid_messages_stars:flags.7?long = Updates; + +premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; +premium.getMyBoosts#be77b4a = premium.MyBoosts; +premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; +premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus; +premium.getUserBoosts#39854d1f peer:InputPeer user_id:InputUser = premium.BoostsList; + +smsjobs.isEligibleToJoin#edc39d0 = smsjobs.EligibilityToJoin; +smsjobs.join#a74ece2d = Bool; +smsjobs.leave#9898ad73 = Bool; +smsjobs.updateSettings#93fa0bf flags:# allow_international:flags.0?true = Bool; +smsjobs.getStatus#10a698e8 = smsjobs.Status; +smsjobs.getSmsJob#778d902f job_id:string = SmsJob; +smsjobs.finishJob#4f1ebf24 flags:# job_id:string error:flags.0?string = Bool; + +fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo; + +// LAYER 222 diff --git a/internal/compat/layerwire/_schema/layer-223.tl b/internal/compat/layerwire/_schema/layer-223.tl new file mode 100644 index 00000000..bca9aecc --- /dev/null +++ b/internal/compat/layerwire/_schema/layer-223.tl @@ -0,0 +1,2914 @@ +boolFalse#bc799737 = Bool; +boolTrue#997275b5 = Bool; + +true#3fedd339 = True; + +vector#1cb5c415 {t:Type} # [ t ] = Vector t; + +error#c4b9f9bb code:int text:string = Error; + +null#56730bcc = Null; + +inputPeerEmpty#7f3b18ea = InputPeer; +inputPeerSelf#7da07ec9 = InputPeer; +inputPeerChat#35a95cb9 chat_id:long = InputPeer; +inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer; +inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer; +inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer; +inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer; + +inputUserEmpty#b98886cf = InputUser; +inputUserSelf#f7c1b13f = InputUser; +inputUser#f21158c6 user_id:long access_hash:long = InputUser; +inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser; + +inputPhoneContact#6a1dc4be flags:# client_id:long phone:string first_name:string last_name:string note:flags.0?TextWithEntities = InputContact; + +inputFile#f52ff27f id:long parts:int name:string md5_checksum:string = InputFile; +inputFileBig#fa4f0bb5 id:long parts:int name:string = InputFile; +inputFileStoryDocument#62dc8b48 id:InputDocument = InputFile; + +inputMediaEmpty#9664f57f = InputMedia; +inputMediaUploadedPhoto#1e287d04 flags:# spoiler:flags.2?true file:InputFile stickers:flags.0?Vector ttl_seconds:flags.1?int = InputMedia; +inputMediaPhoto#b3ba0635 flags:# spoiler:flags.1?true id:InputPhoto ttl_seconds:flags.0?int = InputMedia; +inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia; +inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia; +inputMediaUploadedDocument#37c9330 flags:# nosound_video:flags.3?true force_file:flags.4?true spoiler:flags.5?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector video_cover:flags.6?InputPhoto video_timestamp:flags.7?int ttl_seconds:flags.1?int = InputMedia; +inputMediaDocument#a8763ab5 flags:# spoiler:flags.2?true id:InputDocument video_cover:flags.3?InputPhoto video_timestamp:flags.4?int ttl_seconds:flags.0?int query:flags.1?string = InputMedia; +inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia; +inputMediaPhotoExternal#e5bbfe1a flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int = InputMedia; +inputMediaDocumentExternal#779600f9 flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int video_cover:flags.2?InputPhoto video_timestamp:flags.3?int = InputMedia; +inputMediaGame#d33f43f3 id:InputGame = InputMedia; +inputMediaInvoice#405fef0d flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:flags.3?string provider_data:DataJSON start_param:flags.1?string extended_media:flags.2?InputMedia = InputMedia; +inputMediaGeoLive#971fa843 flags:# stopped:flags.0?true geo_point:InputGeoPoint heading:flags.2?int period:flags.1?int proximity_notification_radius:flags.3?int = InputMedia; +inputMediaPoll#f94e5f1 flags:# poll:Poll correct_answers:flags.0?Vector solution:flags.1?string solution_entities:flags.1?Vector = InputMedia; +inputMediaDice#e66fbf7b emoticon:string = InputMedia; +inputMediaStory#89fdd778 peer:InputPeer id:int = InputMedia; +inputMediaWebPage#c21b8849 flags:# force_large_media:flags.0?true force_small_media:flags.1?true optional:flags.2?true url:string = InputMedia; +inputMediaPaidMedia#c4103386 flags:# stars_amount:long extended_media:Vector payload:flags.0?string = InputMedia; +inputMediaTodo#9fc55fde todo:TodoList = InputMedia; +inputMediaStakeDice#f3a9244a game_hash:string ton_amount:long client_seed:bytes = InputMedia; + +inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; +inputChatUploadedPhoto#bdcdaec0 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.3?VideoSize = InputChatPhoto; +inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; + +inputGeoPointEmpty#e4c123d6 = InputGeoPoint; +inputGeoPoint#48222faf flags:# lat:double long:double accuracy_radius:flags.0?int = InputGeoPoint; + +inputPhotoEmpty#1cd7bf0d = InputPhoto; +inputPhoto#3bb3b94a id:long access_hash:long file_reference:bytes = InputPhoto; + +inputFileLocation#dfdaabe1 volume_id:long local_id:int secret:long file_reference:bytes = InputFileLocation; +inputEncryptedFileLocation#f5235d55 id:long access_hash:long = InputFileLocation; +inputDocumentFileLocation#bad07584 id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputSecureFileLocation#cbc7ee28 id:long access_hash:long = InputFileLocation; +inputTakeoutFileLocation#29be5899 = InputFileLocation; +inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation; +inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation; +inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation; +inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation; + +peerUser#59511722 user_id:long = Peer; +peerChat#36c6019a chat_id:long = Peer; +peerChannel#a2a5371e channel_id:long = Peer; + +storage.fileUnknown#aa963b05 = storage.FileType; +storage.filePartial#40bc6f52 = storage.FileType; +storage.fileJpeg#7efe0e = storage.FileType; +storage.fileGif#cae1aadf = storage.FileType; +storage.filePng#a4f63c0 = storage.FileType; +storage.filePdf#ae1e508d = storage.FileType; +storage.fileMp3#528a0677 = storage.FileType; +storage.fileMov#4b09ebbc = storage.FileType; +storage.fileMp4#b3cea0e4 = storage.FileType; +storage.fileWebp#1081464c = storage.FileType; + +userEmpty#d3bc4b7a id:long = User; +user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true bot_forum_can_manage_topics:flags2.17?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User; + +userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; +userProfilePhoto#82d1f706 flags:# has_video:flags.0?true personal:flags.2?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto; + +userStatusEmpty#9d05049 = UserStatus; +userStatusOnline#edb93949 expires:int = UserStatus; +userStatusOffline#8c703f was_online:int = UserStatus; +userStatusRecently#7b197dc8 flags:# by_me:flags.0?true = UserStatus; +userStatusLastWeek#541a1d1a flags:# by_me:flags.0?true = UserStatus; +userStatusLastMonth#65899777 flags:# by_me:flags.0?true = UserStatus; + +chatEmpty#29562865 id:long = Chat; +chat#41cbf256 flags:# creator:flags.0?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; +chatForbidden#6592a1a7 id:long title:string = Chat; +channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat; +channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true monoforum:flags.10?true id:long access_hash:long title:string until_date:flags.16?int = Chat; + +chatFull#2633421b flags:# can_set_username:flags.7?true has_scheduled:flags.8?true translations_disabled:flags.19?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions reactions_limit:flags.20?int = ChatFull; +channelFull#e4e0b29d flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true participants_hidden:flags2.2?true translations_disabled:flags2.3?true stories_pinned_available:flags2.5?true view_forum_as_messages:flags2.6?true restricted_sponsored:flags2.11?true can_view_revenue:flags2.12?true paid_media_allowed:flags2.14?true can_view_stars_revenue:flags2.15?true paid_reactions_available:flags2.16?true stargifts_available:flags2.19?true paid_messages_available:flags2.20?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions reactions_limit:flags2.13?int stories:flags2.4?PeerStories wallpaper:flags2.7?WallPaper boosts_applied:flags2.8?int boosts_unrestrict:flags2.9?int emojiset:flags2.10?StickerSet bot_verification:flags2.17?BotVerification stargifts_count:flags2.18?int send_paid_messages_stars:flags2.21?long main_tab:flags2.22?ProfileTab = ChatFull; + +chatParticipant#38e79fde flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; +chatParticipantCreator#e1f867b8 flags:# user_id:long rank:flags.0?string = ChatParticipant; +chatParticipantAdmin#360d5d2 flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; + +chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants; +chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants; + +chatPhotoEmpty#37c1011c = ChatPhoto; +chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; + +messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; +message#3ae56482 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true invert_media:flags.27?true flags2:# offline:flags2.1?true video_processing_pending:flags2.4?true paid_suggested_post_stars:flags2.8?true paid_suggested_post_ton:flags2.9?true id:int from_id:flags.8?Peer from_boosts_applied:flags.29?int from_rank:flags2.12?string peer_id:Peer saved_peer_id:flags.28?Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long via_business_bot_id:flags2.0?long reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int quick_reply_shortcut_id:flags.30?int effect:flags2.2?long factcheck:flags2.3?FactCheck report_delivery_until_date:flags2.5?int paid_message_stars:flags2.6?long suggested_post:flags2.7?SuggestedPost schedule_repeat_period:flags2.10?int summary_from_language:flags2.11?string = Message; +messageService#7a800e0a flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true reactions_are_possible:flags.9?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer saved_peer_id:flags.28?Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction reactions:flags.20?MessageReactions ttl_period:flags.25?int = Message; + +messageMediaEmpty#3ded6320 = MessageMedia; +messageMediaPhoto#695150d7 flags:# spoiler:flags.3?true photo:flags.0?Photo ttl_seconds:flags.2?int = MessageMedia; +messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia; +messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia; +messageMediaUnsupported#9f84f49e = MessageMedia; +messageMediaDocument#52d8ccd9 flags:# nopremium:flags.3?true spoiler:flags.4?true video:flags.6?true round:flags.7?true voice:flags.8?true document:flags.0?Document alt_documents:flags.5?Vector video_cover:flags.9?Photo video_timestamp:flags.10?int ttl_seconds:flags.2?int = MessageMedia; +messageMediaWebPage#ddf10c3b flags:# force_large_media:flags.0?true force_small_media:flags.1?true manual:flags.3?true safe:flags.4?true webpage:WebPage = MessageMedia; +messageMediaVenue#2ec0533f geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MessageMedia; +messageMediaGame#fdb19008 game:Game = MessageMedia; +messageMediaInvoice#f6a548d3 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string extended_media:flags.4?MessageExtendedMedia = MessageMedia; +messageMediaGeoLive#b940c666 flags:# geo:GeoPoint heading:flags.0?int period:int proximity_notification_radius:flags.1?int = MessageMedia; +messageMediaPoll#4bd6e798 poll:Poll results:PollResults = MessageMedia; +messageMediaDice#8cbec07 flags:# value:int emoticon:string game_outcome:flags.0?messages.EmojiGameOutcome = MessageMedia; +messageMediaStory#68cb6283 flags:# via_mention:flags.1?true peer:Peer id:int story:flags.0?StoryItem = MessageMedia; +messageMediaGiveaway#aa073beb flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.2?true channels:Vector countries_iso2:flags.1?Vector prize_description:flags.3?string quantity:int months:flags.4?int stars:flags.5?long until_date:int = MessageMedia; +messageMediaGiveawayResults#ceaa3ea1 flags:# only_new_subscribers:flags.0?true refunded:flags.2?true channel_id:long additional_peers_count:flags.3?int launch_msg_id:int winners_count:int unclaimed_count:int winners:Vector months:flags.4?int stars:flags.5?long prize_description:flags.1?string until_date:int = MessageMedia; +messageMediaPaidMedia#a8852491 stars_amount:long extended_media:Vector = MessageMedia; +messageMediaToDo#8a53b014 flags:# todo:TodoList completions:flags.0?Vector = MessageMedia; +messageMediaVideoStream#ca5cab89 flags:# rtmp_stream:flags.0?true call:InputGroupCall = MessageMedia; + +messageActionEmpty#b6aef7b0 = MessageAction; +messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction; +messageActionChatEditTitle#b5a1ce5a title:string = MessageAction; +messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction; +messageActionChatDeletePhoto#95e3fbef = MessageAction; +messageActionChatAddUser#15cefd00 users:Vector = MessageAction; +messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction; +messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction; +messageActionChannelCreate#95d2ac92 title:string = MessageAction; +messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction; +messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction; +messageActionPinMessage#94bd38ed = MessageAction; +messageActionHistoryClear#9fbab604 = MessageAction; +messageActionGameScore#92a72876 game_id:long score:int = MessageAction; +messageActionPaymentSentMe#ffa00ccc flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string charge:PaymentCharge subscription_until_date:flags.4?int = MessageAction; +messageActionPaymentSent#c624b16e flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long invoice_slug:flags.0?string subscription_until_date:flags.4?int = MessageAction; +messageActionPhoneCall#80e11a7f flags:# video:flags.2?true call_id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = MessageAction; +messageActionScreenshotTaken#4792929b = MessageAction; +messageActionCustomAction#fae69f56 message:string = MessageAction; +messageActionBotAllowed#c516d679 flags:# attach_menu:flags.1?true from_request:flags.3?true domain:flags.0?string app:flags.2?BotApp = MessageAction; +messageActionSecureValuesSentMe#1b287353 values:Vector credentials:SecureCredentialsEncrypted = MessageAction; +messageActionSecureValuesSent#d95c6154 types:Vector = MessageAction; +messageActionContactSignUp#f3f25f76 = MessageAction; +messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; +messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; +messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; +messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; +messageActionSetChatTheme#b91bbd3a theme:ChatTheme = MessageAction; +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; +messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; +messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; +messageActionGiftPremium#48e91302 flags:# currency:string amount:long days:int crypto_currency:flags.0?string crypto_amount:flags.0?long message:flags.1?TextWithEntities = MessageAction; +messageActionTopicCreate#d999256 flags:# title_missing:flags.1?true title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; +messageActionSuggestProfilePhoto#57de635e photo:Photo = MessageAction; +messageActionRequestedPeer#31518e9b button_id:int peers:Vector = MessageAction; +messageActionSetChatWallPaper#5060a3f4 flags:# same:flags.0?true for_both:flags.1?true wallpaper:WallPaper = MessageAction; +messageActionGiftCode#31c48347 flags:# via_giveaway:flags.0?true unclaimed:flags.5?true boost_peer:flags.1?Peer days:int slug:string currency:flags.2?string amount:flags.2?long crypto_currency:flags.3?string crypto_amount:flags.3?long message:flags.4?TextWithEntities = MessageAction; +messageActionGiveawayLaunch#a80f51e4 flags:# stars:flags.0?long = MessageAction; +messageActionGiveawayResults#87e2f155 flags:# stars:flags.0?true winners_count:int unclaimed_count:int = MessageAction; +messageActionBoostApply#cc02aa6d boosts:int = MessageAction; +messageActionRequestedPeerSentMe#93b31848 button_id:int peers:Vector = MessageAction; +messageActionPaymentRefunded#41b3e202 flags:# peer:Peer currency:string total_amount:long payload:flags.0?bytes charge:PaymentCharge = MessageAction; +messageActionGiftStars#45d5b021 flags:# currency:string amount:long stars:long crypto_currency:flags.0?string crypto_amount:flags.0?long transaction_id:flags.1?string = MessageAction; +messageActionPrizeStars#b00c47a2 flags:# unclaimed:flags.0?true stars:long transaction_id:string boost_peer:Peer giveaway_msg_id:int = MessageAction; +messageActionStarGift#ea2c31d3 flags:# name_hidden:flags.0?true saved:flags.2?true converted:flags.3?true upgraded:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true prepaid_upgrade:flags.13?true upgrade_separate:flags.16?true auction_acquired:flags.17?true gift:StarGift message:flags.1?TextWithEntities convert_stars:flags.4?long upgrade_msg_id:flags.5?int upgrade_stars:flags.8?long from_id:flags.11?Peer peer:flags.12?Peer saved_id:flags.12?long prepaid_upgrade_hash:flags.14?string gift_msg_id:flags.15?int to_id:flags.18?Peer gift_num:flags.19?int = MessageAction; +messageActionStarGiftUnique#e6c31522 flags:# upgrade:flags.0?true transferred:flags.1?true saved:flags.2?true refunded:flags.5?true prepaid_upgrade:flags.11?true assigned:flags.13?true from_offer:flags.14?true craft:flags.16?true gift:StarGift can_export_at:flags.3?int transfer_stars:flags.4?long from_id:flags.6?Peer peer:flags.7?Peer saved_id:flags.7?long resale_amount:flags.8?StarsAmount can_transfer_at:flags.9?int can_resell_at:flags.10?int drop_original_details_stars:flags.12?long can_craft_at:flags.15?int = MessageAction; +messageActionPaidMessagesRefunded#ac1f1fcd count:int stars:long = MessageAction; +messageActionPaidMessagesPrice#84b88578 flags:# broadcast_messages_allowed:flags.0?true stars:long = MessageAction; +messageActionConferenceCall#2ffe2f7a flags:# missed:flags.0?true active:flags.1?true video:flags.4?true call_id:long duration:flags.2?int other_participants:flags.3?Vector = MessageAction; +messageActionTodoCompletions#cc7c5c89 completed:Vector incompleted:Vector = MessageAction; +messageActionTodoAppendTasks#c7edbc83 list:Vector = MessageAction; +messageActionSuggestedPostApproval#ee7a1596 flags:# rejected:flags.0?true balance_too_low:flags.1?true reject_comment:flags.2?string schedule_date:flags.3?int price:flags.4?StarsAmount = MessageAction; +messageActionSuggestedPostSuccess#95ddcf69 price:StarsAmount = MessageAction; +messageActionSuggestedPostRefund#69f916f8 flags:# payer_initiated:flags.0?true = MessageAction; +messageActionGiftTon#a8a3c699 flags:# currency:string amount:long crypto_currency:string crypto_amount:long transaction_id:flags.0?string = MessageAction; +messageActionSuggestBirthday#2c8f2a25 birthday:Birthday = MessageAction; +messageActionStarGiftPurchaseOffer#774278d4 flags:# accepted:flags.0?true declined:flags.1?true gift:StarGift price:StarsAmount expires_at:int = MessageAction; +messageActionStarGiftPurchaseOfferDeclined#73ada76b flags:# expired:flags.0?true gift:StarGift price:StarsAmount = MessageAction; +messageActionNewCreatorPending#b07ed085 new_creator_id:long = MessageAction; +messageActionChangeCreator#e188503b new_creator_id:long = MessageAction; +messageActionNoForwardsToggle#bf7d6572 prev_value:Bool new_value:Bool = MessageAction; +messageActionNoForwardsRequest#3e2793ba flags:# expired:flags.0?true prev_value:Bool new_value:Bool = MessageAction; + +dialog#d58a08c6 flags:# pinned:flags.2?true unread_mark:flags.3?true view_forum_as_messages:flags.6?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; +dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; + +photoEmpty#2331b22d id:long = Photo; +photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; + +photoSizeEmpty#e17e23c type:string = PhotoSize; +photoSize#75c78e60 type:string w:int h:int size:int = PhotoSize; +photoCachedSize#21e1ad6 type:string w:int h:int bytes:bytes = PhotoSize; +photoStrippedSize#e0b0bc2e type:string bytes:bytes = PhotoSize; +photoSizeProgressive#fa3efb95 type:string w:int h:int sizes:Vector = PhotoSize; +photoPathSize#d8214d41 type:string bytes:bytes = PhotoSize; + +geoPointEmpty#1117dd5f = GeoPoint; +geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint; + +auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; +auth.sentCodeSuccess#2390fe44 authorization:auth.Authorization = auth.SentCode; +auth.sentCodePaymentRequired#e0955a3c store_product:string phone_code_hash:string support_email_address:string support_email_subject:string currency:string amount:long = auth.SentCode; + +auth.authorization#2ea2c0d4 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int future_auth_token:flags.2?bytes user:User = auth.Authorization; +auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; + +auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; + +inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer; +inputNotifyUsers#193b4417 = InputNotifyPeer; +inputNotifyChats#4a95e84e = InputNotifyPeer; +inputNotifyBroadcasts#b1db7c7e = InputNotifyPeer; +inputNotifyForumTopic#5c467992 peer:InputPeer top_msg_id:int = InputNotifyPeer; + +inputPeerNotifySettings#cacb6ae2 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_sound:flags.8?NotificationSound = InputPeerNotifySettings; + +peerNotifySettings#99622c0c flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int ios_sound:flags.3?NotificationSound android_sound:flags.4?NotificationSound other_sound:flags.5?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_ios_sound:flags.8?NotificationSound stories_android_sound:flags.9?NotificationSound stories_other_sound:flags.10?NotificationSound = PeerNotifySettings; + +peerSettings#f47741f7 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true business_bot_paused:flags.11?true business_bot_can_reply:flags.12?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int business_bot_id:flags.13?long business_bot_manage_url:flags.13?string charge_paid_message_stars:flags.14?long registration_month:flags.15?string phone_country:flags.16?string name_change_date:flags.17?int photo_change_date:flags.18?int = PeerSettings; + +wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; +wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; + +inputReportReasonSpam#58dbcab8 = ReportReason; +inputReportReasonViolence#1e22c78d = ReportReason; +inputReportReasonPornography#2e59d922 = ReportReason; +inputReportReasonChildAbuse#adf44ee3 = ReportReason; +inputReportReasonOther#c1e4a2b1 = ReportReason; +inputReportReasonCopyright#9b89f93a = ReportReason; +inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; +inputReportReasonFake#f5ddd6e7 = ReportReason; +inputReportReasonIllegalDrugs#a8eb2be = ReportReason; +inputReportReasonPersonalDetails#9ec7863d = ReportReason; + +userFull#a02bc13e flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true voice_messages_forbidden:flags.20?true translations_disabled:flags.23?true stories_pinned_available:flags.26?true blocked_my_stories_from:flags.27?true wallpaper_overridden:flags.28?true contact_require_premium:flags.29?true read_dates_private:flags.30?true flags2:# sponsored_enabled:flags2.7?true can_view_revenue:flags2.9?true bot_can_manage_emoji_status:flags2.10?true display_gifts_button:flags2.16?true noforwards_my_enabled:flags2.23?true noforwards_peer_enabled:flags2.24?true id:long about:flags.1?string settings:PeerSettings personal_photo:flags.21?Photo profile_photo:flags.2?Photo fallback_photo:flags.22?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme:flags.15?ChatTheme private_forward_name:flags.16?string bot_group_admin_rights:flags.17?ChatAdminRights bot_broadcast_admin_rights:flags.18?ChatAdminRights wallpaper:flags.24?WallPaper stories:flags.25?PeerStories business_work_hours:flags2.0?BusinessWorkHours business_location:flags2.1?BusinessLocation business_greeting_message:flags2.2?BusinessGreetingMessage business_away_message:flags2.3?BusinessAwayMessage business_intro:flags2.4?BusinessIntro birthday:flags2.5?Birthday personal_channel_id:flags2.6?long personal_channel_message:flags2.6?int stargifts_count:flags2.8?int starref_program:flags2.11?StarRefProgram bot_verification:flags2.12?BotVerification send_paid_messages_stars:flags2.14?long disallowed_gifts:flags2.15?DisallowedGiftsSettings stars_rating:flags2.17?StarsRating stars_my_pending_rating:flags2.18?StarsRating stars_my_pending_rating_date:flags2.18?int main_tab:flags2.20?ProfileTab saved_music:flags2.21?Document note:flags2.22?TextWithEntities = UserFull; + +contact#145ade0b user_id:long mutual:Bool = Contact; + +importedContact#c13e3c50 user_id:long client_id:long = ImportedContact; + +contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus; + +contacts.contactsNotModified#b74ba9d2 = contacts.Contacts; +contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts; + +contacts.importedContacts#77d01c3b imported:Vector popular_invites:Vector retry_contacts:Vector users:Vector = contacts.ImportedContacts; + +contacts.blocked#ade1591 blocked:Vector chats:Vector users:Vector = contacts.Blocked; +contacts.blockedSlice#e1664194 count:int blocked:Vector chats:Vector users:Vector = contacts.Blocked; + +messages.dialogs#15ba6c40 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsNotModified#f0e3e596 count:int = messages.Dialogs; + +messages.messages#1d73e7ea messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesSlice#5f206716 flags:# inexact:flags.1?true count:int next_rate:flags.0?int offset_id_offset:flags.2?int search_flood:flags.3?SearchPostsFlood messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.channelMessages#c776ba4e flags:# inexact:flags.1?true pts:int count:int offset_id_offset:flags.2?int messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesNotModified#74535f21 count:int = messages.Messages; + +messages.chats#64ff9fd5 chats:Vector = messages.Chats; +messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; + +messages.chatFull#e5d7d19c full_chat:ChatFull chats:Vector users:Vector = messages.ChatFull; + +messages.affectedHistory#b45c69d1 pts:int pts_count:int offset:int = messages.AffectedHistory; + +inputMessagesFilterEmpty#57e2f66c = MessagesFilter; +inputMessagesFilterPhotos#9609a51c = MessagesFilter; +inputMessagesFilterVideo#9fc00e65 = MessagesFilter; +inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; +inputMessagesFilterDocument#9eddf188 = MessagesFilter; +inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; +inputMessagesFilterGif#ffc86587 = MessagesFilter; +inputMessagesFilterVoice#50f5c392 = MessagesFilter; +inputMessagesFilterMusic#3751b49e = MessagesFilter; +inputMessagesFilterChatPhotos#3a20ecb8 = MessagesFilter; +inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFilter; +inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; +inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; +inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; +inputMessagesFilterGeo#e7026d0d = MessagesFilter; +inputMessagesFilterContacts#e062db83 = MessagesFilter; +inputMessagesFilterPinned#1bb00451 = MessagesFilter; + +updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; +updateMessageID#4e90bfd6 id:int random_id:long = Update; +updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; +updateUserTyping#2a17bf5c flags:# user_id:long top_msg_id:flags.0?int action:SendMessageAction = Update; +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; +updateChatParticipants#7761198 participants:ChatParticipants = Update; +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; +updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector = Update; +updateNewAuthorization#8951abef flags:# unconfirmed:flags.0?true hash:long date:flags.0?int device:flags.0?string location:flags.0?string = Update; +updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; +updateEncryptedChatTyping#1710f156 chat_id:int = Update; +updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; +updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; +updateDcOptions#8e5e9873 dc_options:Vector = Update; +updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; +updateServiceNotification#ebe46819 flags:# popup:flags.0?true invert_media:flags.2?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; +updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; +updateUserPhone#5492a13 user_id:long phone:string = Update; +updateReadHistoryInbox#9e84bc99 flags:# folder_id:flags.0?int peer:Peer top_msg_id:flags.1?int max_id:int still_unread_count:int pts:int pts_count:int = Update; +updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; +updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; +updateReadMessagesContents#f8227181 flags:# messages:Vector pts:int pts_count:int date:flags.0?int = Update; +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; +updateChannel#635b4c09 channel_id:long = Update; +updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; +updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; +updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Update; +updateStickerSets#31c24808 flags:# masks:flags.0?true emojis:flags.1?true = Update; +updateSavedGifs#9375341e = Update; +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; +updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; +updateDraftMessage#edfc111e flags:# peer:Peer top_msg_id:flags.0?int saved_peer_id:flags.1?Peer draft:DraftMessage = Update; +updateReadFeaturedStickers#571d2742 = Update; +updateRecentStickers#9a422c20 = Update; +updateConfig#a229dd06 = Update; +updatePtsChanged#3354678f = Update; +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; +updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; +updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; +updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; +updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; +updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; +updateLangPackTooLong#46560264 lang_code:string = Update; +updateLangPack#56022f4d difference:LangPackDifference = Update; +updateFavedStickers#e511996d = Update; +updateChannelReadMessagesContents#25f324f7 flags:# channel_id:long top_msg_id:flags.0?int saved_peer_id:flags.1?Peer messages:Vector = Update; +updateContactsReset#7084a7be = Update; +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; +updateDialogUnreadMark#b658f23e flags:# unread:flags.0?true peer:DialogPeer saved_peer_id:flags.1?Peer = Update; +updateMessagePoll#aca1657b flags:# poll_id:long poll:flags.0?Poll results:PollResults = Update; +updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; +updateFolderPeers#19360dc0 folder_peers:Vector pts:int pts_count:int = Update; +updatePeerSettings#6a7e7366 peer:Peer settings:PeerSettings = Update; +updatePeerLocated#b4afcfb0 peers:Vector = Update; +updateNewScheduledMessage#39a51dfb message:Message = Update; +updateDeleteScheduledMessages#f2a71983 flags:# peer:Peer messages:Vector sent_messages:flags.0?Vector = Update; +updateTheme#8216fba3 theme:Theme = Update; +updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; +updateLoginToken#564fe691 = Update; +updateMessagePollVote#24f40e77 poll_id:long peer:Peer options:Vector qts:int = Update; +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; +updateDialogFilterOrder#a5d72105 order:Vector = Update; +updateDialogFilters#3504914f = Update; +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; +updatePeerBlocked#ebe07752 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer = Update; +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; +updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChat#f89a6a4e chat_id:long = Update; +updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; +updateGroupCall#9d2216e0 flags:# live_story:flags.2?true peer:flags.1?Peer call:GroupCall = Update; +updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateChannelParticipant#985d3abb flags:# via_chatlist:flags.3?true channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; +updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; +updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#11dfa986 peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int = Update; +updateMessageReactions#1e297bfa flags:# peer:Peer msg_id:int top_msg_id:flags.0?int saved_peer_id:flags.1?Peer reactions:MessageReactions = Update; +updateAttachMenuBots#17b7a20b = Update; +updateWebViewResultSent#1592b79d query_id:long = Update; +updateBotMenuButton#14b85813 bot_id:long button:BotMenuButton = Update; +updateSavedRingtones#74d8be99 = Update; +updateTranscribedAudio#84cd5a flags:# pending:flags.0?true peer:Peer msg_id:int transcription_id:long text:string = Update; +updateReadFeaturedEmojiStickers#fb4c496c = Update; +updateUserEmojiStatus#28373599 user_id:long emoji_status:EmojiStatus = Update; +updateRecentEmojiStatuses#30f443db = Update; +updateRecentReactions#6f7863f4 = Update; +updateMoveStickerSetToTop#86fccf85 flags:# masks:flags.0?true emojis:flags.1?true stickerset:long = Update; +updateMessageExtendedMedia#d5a41724 peer:Peer msg_id:int extended_media:Vector = Update; +updateUser#20529438 user_id:long = Update; +updateAutoSaveSettings#ec05b097 = Update; +updateStory#75b3b798 peer:Peer story:StoryItem = Update; +updateReadStories#f74e932b peer:Peer max_id:int = Update; +updateStoryID#1bf335b9 id:int random_id:long = Update; +updateStoriesStealthMode#2c084dc1 stealth_mode:StoriesStealthMode = Update; +updateSentStoryReaction#7d627683 peer:Peer story_id:int reaction:Reaction = Update; +updateBotChatBoost#904dd49c peer:Peer boost:Boost qts:int = Update; +updateChannelViewForumAsMessages#7b68920 channel_id:long enabled:Bool = Update; +updatePeerWallpaper#ae3f101d flags:# wallpaper_overridden:flags.1?true peer:Peer wallpaper:flags.0?WallPaper = Update; +updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer old_reactions:Vector new_reactions:Vector qts:int = Update; +updateBotMessageReactions#9cb7759 peer:Peer msg_id:int date:int reactions:Vector qts:int = Update; +updateSavedDialogPinned#aeaf9e74 flags:# pinned:flags.0?true peer:DialogPeer = Update; +updatePinnedSavedDialogs#686c85a6 flags:# order:flags.0?Vector = Update; +updateSavedReactionTags#39c67432 = Update; +updateSmsJob#f16269d4 job_id:string = Update; +updateQuickReplies#f9470ab2 quick_replies:Vector = Update; +updateNewQuickReply#f53da717 quick_reply:QuickReply = Update; +updateDeleteQuickReply#53e6f1ec shortcut_id:int = Update; +updateQuickReplyMessage#3e050d0f message:Message = Update; +updateDeleteQuickReplyMessages#566fe7cd shortcut_id:int messages:Vector = Update; +updateBotBusinessConnect#8ae5c97a connection:BotBusinessConnection qts:int = Update; +updateBotNewBusinessMessage#9ddb347c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotEditBusinessMessage#7df587c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotDeleteBusinessMessage#a02a982e connection_id:string peer:Peer messages:Vector qts:int = Update; +updateNewStoryReaction#1824e40b story_id:int peer:Peer reaction:Reaction = Update; +updateStarsBalance#4e80a379 balance:StarsAmount = Update; +updateBusinessBotCallbackQuery#1ea2fda7 flags:# query_id:long user_id:long connection_id:string message:Message reply_to_message:flags.2?Message chat_instance:long data:flags.0?bytes = Update; +updateStarsRevenueStatus#a584b019 peer:Peer status:StarsRevenueStatus = Update; +updateBotPurchasedPaidMedia#283bd312 user_id:long payload:string qts:int = Update; +updatePaidReactionPrivacy#8b725fce private:PaidReactionPrivacy = Update; +updateSentPhoneCode#504aa18f sent_code:auth.SentCode = Update; +updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector next_offset:int = Update; +updateReadMonoForumInbox#77b0e372 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateReadMonoForumOutbox#a4a79376 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateMonoForumNoPaidException#9f812b08 flags:# exception:flags.0?true channel_id:long saved_peer_id:Peer = Update; +updateGroupCallMessage#d8326f0d call:InputGroupCall message:GroupCallMessage = Update; +updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update; +updatePinnedForumTopic#683b2c52 flags:# pinned:flags.0?true peer:Peer topic_id:int = Update; +updatePinnedForumTopics#def143d0 flags:# peer:Peer order:flags.0?Vector = Update; +updateDeleteGroupCallMessages#3e85e92c call:InputGroupCall messages:Vector = Update; +updateStarGiftAuctionState#48e246c2 gift_id:long state:StarGiftAuctionState = Update; +updateStarGiftAuctionUserState#dc58f31e gift_id:long user_state:StarGiftAuctionUserState = Update; +updateEmojiGameInfo#fb9c547a info:messages.EmojiGameInfo = Update; +updateStarGiftCraftFail#ac072444 = Update; +updateChatParticipantRank#bd8367b9 chat_id:long user_id:long rank:string version:int = Update; + +updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; + +updates.differenceEmpty#5d75a138 date:int seq:int = updates.Difference; +updates.difference#f49ca0 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector state:updates.State = updates.Difference; +updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector intermediate_state:updates.State = updates.Difference; +updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; + +updatesTooLong#e317af7e = Updates; +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShort#78d4dec1 update:Update date:int = Updates; +updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; +updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; +updateShortSentMessage#9015e101 flags:# out:flags.1?true id:int pts:int pts_count:int date:int media:flags.9?MessageMedia entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +photos.photos#8dca6aa5 photos:Vector users:Vector = photos.Photos; +photos.photosSlice#15051f54 count:int photos:Vector users:Vector = photos.Photos; + +photos.photo#20212ca8 photo:Photo users:Vector = photos.Photo; + +upload.file#96a18d5 type:storage.FileType mtime:int bytes:bytes = upload.File; +upload.fileCdnRedirect#f18cda44 dc_id:int file_token:bytes encryption_key:bytes encryption_iv:bytes file_hashes:Vector = upload.File; + +dcOption#18b7a10d flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true this_port_only:flags.5?true id:int ip_address:string port:int secret:flags.10?bytes = DcOption; + +config#cc1a241e flags:# default_p2p_contacts:flags.3?true preload_featured_stickers:flags.4?true revoke_pm_inbox:flags.6?true blocked_mode:flags.8?true force_try_ipv6:flags.14?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector dc_txt_domain_name:string chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int push_chat_period_ms:int push_chat_limit:int edit_time_limit:int revoke_time_limit:int revoke_pm_time_limit:int rating_e_decay:int stickers_recent_limit:int channels_read_media_period:int tmp_sessions:flags.0?int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string autoupdate_url_prefix:flags.7?string gif_search_username:flags.9?string venue_search_username:flags.10?string img_search_username:flags.11?string static_maps_provider:flags.12?string caption_length_max:int message_length_max:int webfile_dc_id:int suggested_lang_code:flags.2?string lang_pack_version:flags.2?int base_lang_pack_version:flags.2?int reactions_default:flags.15?Reaction autologin_token:flags.16?string = Config; + +nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; + +help.appUpdate#ccbbce30 flags:# can_not_skip:flags.0?true id:int version:string text:string entities:Vector document:flags.1?Document url:flags.2?string sticker:flags.3?Document = help.AppUpdate; +help.noAppUpdate#c45a6536 = help.AppUpdate; + +help.inviteText#18cb9f78 message:string = help.InviteText; + +encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; +encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; + +inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; + +encryptedFileEmpty#c21f497e = EncryptedFile; +encryptedFile#a8008cd8 id:long access_hash:long size:long dc_id:int key_fingerprint:int = EncryptedFile; + +inputEncryptedFileEmpty#1837c364 = InputEncryptedFile; +inputEncryptedFileUploaded#64bd0306 id:long parts:int md5_checksum:string key_fingerprint:int = InputEncryptedFile; +inputEncryptedFile#5a17b5e5 id:long access_hash:long = InputEncryptedFile; +inputEncryptedFileBigUploaded#2dc173c8 id:long parts:int key_fingerprint:int = InputEncryptedFile; + +encryptedMessage#ed18c118 random_id:long chat_id:int date:int bytes:bytes file:EncryptedFile = EncryptedMessage; +encryptedMessageService#23734b06 random_id:long chat_id:int date:int bytes:bytes = EncryptedMessage; + +messages.dhConfigNotModified#c0e24635 random:bytes = messages.DhConfig; +messages.dhConfig#2c221edd g:int p:bytes version:int random:bytes = messages.DhConfig; + +messages.sentEncryptedMessage#560f8935 date:int = messages.SentEncryptedMessage; +messages.sentEncryptedFile#9493ff32 date:int file:EncryptedFile = messages.SentEncryptedMessage; + +inputDocumentEmpty#72f0eaae = InputDocument; +inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; + +documentEmpty#36f8c871 id:long = Document; +document#8fd4c4d8 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:long thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; + +help.support#17c6b5f6 phone_number:string user:User = help.Support; + +notifyPeer#9fd40bd8 peer:Peer = NotifyPeer; +notifyUsers#b4c83b4c = NotifyPeer; +notifyChats#c007cec3 = NotifyPeer; +notifyBroadcasts#d612e8ef = NotifyPeer; +notifyForumTopic#226e6308 peer:Peer top_msg_id:int = NotifyPeer; + +sendMessageTypingAction#16bf744e = SendMessageAction; +sendMessageCancelAction#fd5ec8f5 = SendMessageAction; +sendMessageRecordVideoAction#a187d66f = SendMessageAction; +sendMessageUploadVideoAction#e9763aec progress:int = SendMessageAction; +sendMessageRecordAudioAction#d52f73f7 = SendMessageAction; +sendMessageUploadAudioAction#f351d7ab progress:int = SendMessageAction; +sendMessageUploadPhotoAction#d1d34a26 progress:int = SendMessageAction; +sendMessageUploadDocumentAction#aa0cd9e4 progress:int = SendMessageAction; +sendMessageGeoLocationAction#176f8ba1 = SendMessageAction; +sendMessageChooseContactAction#628cbc6f = SendMessageAction; +sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; +sendMessageRecordRoundAction#88f27fbc = SendMessageAction; +sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; +speakingInGroupCallAction#d92c2285 = SendMessageAction; +sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; +sendMessageTextDraftAction#376d975c random_id:long text:TextWithEntities = SendMessageAction; + +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; + +inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; +inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; +inputPrivacyKeyPhoneCall#fabadc5f = InputPrivacyKey; +inputPrivacyKeyPhoneP2P#db9e70d2 = InputPrivacyKey; +inputPrivacyKeyForwards#a4dd4c08 = InputPrivacyKey; +inputPrivacyKeyProfilePhoto#5719bacc = InputPrivacyKey; +inputPrivacyKeyPhoneNumber#352dafa = InputPrivacyKey; +inputPrivacyKeyAddedByPhone#d1219bdd = InputPrivacyKey; +inputPrivacyKeyVoiceMessages#aee69d68 = InputPrivacyKey; +inputPrivacyKeyAbout#3823cc40 = InputPrivacyKey; +inputPrivacyKeyBirthday#d65a11cc = InputPrivacyKey; +inputPrivacyKeyStarGiftsAutoSave#e1732341 = InputPrivacyKey; +inputPrivacyKeyNoPaidMessages#bdc597b4 = InputPrivacyKey; +inputPrivacyKeySavedMusic#4dbe9226 = InputPrivacyKey; + +privacyKeyStatusTimestamp#bc2eab30 = PrivacyKey; +privacyKeyChatInvite#500e6dfa = PrivacyKey; +privacyKeyPhoneCall#3d662b7b = PrivacyKey; +privacyKeyPhoneP2P#39491cc8 = PrivacyKey; +privacyKeyForwards#69ec56a3 = PrivacyKey; +privacyKeyProfilePhoto#96151fed = PrivacyKey; +privacyKeyPhoneNumber#d19ae46d = PrivacyKey; +privacyKeyAddedByPhone#42ffd42b = PrivacyKey; +privacyKeyVoiceMessages#697f414 = PrivacyKey; +privacyKeyAbout#a486b761 = PrivacyKey; +privacyKeyBirthday#2000a518 = PrivacyKey; +privacyKeyStarGiftsAutoSave#2ca4fdf8 = PrivacyKey; +privacyKeyNoPaidMessages#17d348d2 = PrivacyKey; +privacyKeySavedMusic#ff7a571b = PrivacyKey; + +inputPrivacyValueAllowContacts#d09e07b = InputPrivacyRule; +inputPrivacyValueAllowAll#184b35ce = InputPrivacyRule; +inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; +inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; +inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; +inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; +inputPrivacyValueAllowCloseFriends#2f453e49 = InputPrivacyRule; +inputPrivacyValueAllowPremium#77cdc9f1 = InputPrivacyRule; +inputPrivacyValueAllowBots#5a4fcce5 = InputPrivacyRule; +inputPrivacyValueDisallowBots#c4e57915 = InputPrivacyRule; + +privacyValueAllowContacts#fffe1bac = PrivacyRule; +privacyValueAllowAll#65427b82 = PrivacyRule; +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; +privacyValueDisallowContacts#f888fa1a = PrivacyRule; +privacyValueDisallowAll#8b73e763 = PrivacyRule; +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; +privacyValueAllowCloseFriends#f7e8d89b = PrivacyRule; +privacyValueAllowPremium#ece9814b = PrivacyRule; +privacyValueAllowBots#21461b5d = PrivacyRule; +privacyValueDisallowBots#f6a5f82f = PrivacyRule; + +account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; + +accountDaysTTL#b8d0afdf days:int = AccountDaysTTL; + +documentAttributeImageSize#6c37c15c w:int h:int = DocumentAttribute; +documentAttributeAnimated#11b58939 = DocumentAttribute; +documentAttributeSticker#6319d612 flags:# mask:flags.1?true alt:string stickerset:InputStickerSet mask_coords:flags.0?MaskCoords = DocumentAttribute; +documentAttributeVideo#43c57c48 flags:# round_message:flags.0?true supports_streaming:flags.1?true nosound:flags.3?true duration:double w:int h:int preload_prefix_size:flags.2?int video_start_ts:flags.4?double video_codec:flags.5?string = DocumentAttribute; +documentAttributeAudio#9852f9c6 flags:# voice:flags.10?true duration:int title:flags.0?string performer:flags.1?string waveform:flags.2?bytes = DocumentAttribute; +documentAttributeFilename#15590068 file_name:string = DocumentAttribute; +documentAttributeHasStickers#9801d2f7 = DocumentAttribute; +documentAttributeCustomEmoji#fd149899 flags:# free:flags.0?true text_color:flags.1?true alt:string stickerset:InputStickerSet = DocumentAttribute; + +messages.stickersNotModified#f1749a22 = messages.Stickers; +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; + +stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; + +messages.allStickersNotModified#e86602c3 = messages.AllStickers; +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; + +messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; + +webPageEmpty#211a1788 flags:# id:long url:flags.0?string = WebPage; +webPagePending#b0d13e47 flags:# id:long url:flags.0?string date:int = WebPage; +webPage#e89c45b2 flags:# has_large_media:flags.13?true video_cover_photo:flags.14?true id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; + +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true unconfirmed:flags.5?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; + +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; + +account.password#957b50fb flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int login_email_pattern:flags.6?string = account.Password; + +account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; + +account.passwordInputSettings#c23727c9 flags:# new_algo:flags.0?PasswordKdfAlgo new_password_hash:flags.0?bytes hint:flags.0?string email:flags.1?string new_secure_settings:flags.2?SecureSecretSettings = account.PasswordInputSettings; + +auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; + +receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; + +chatInviteExported#a22cbd96 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int subscription_expired:flags.10?int title:flags.8?string subscription_pricing:flags.9?StarsSubscriptionPricing = ExportedChatInvite; +chatInvitePublicJoinRequests#ed107ab7 = ExportedChatInvite; + +chatInviteAlready#5a686d7c chat:Chat = ChatInvite; +chatInvite#5c9d3702 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true verified:flags.7?true scam:flags.8?true fake:flags.9?true can_refulfill_subscription:flags.11?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector color:int subscription_pricing:flags.10?StarsSubscriptionPricing subscription_form_id:flags.12?long bot_verification:flags.13?BotVerification = ChatInvite; +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; + +inputStickerSetEmpty#ffb62b95 = InputStickerSet; +inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; +inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; +inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; +inputStickerSetPremiumGifts#c88b3b02 = InputStickerSet; +inputStickerSetEmojiGenericAnimations#4c4d4ce = InputStickerSet; +inputStickerSetEmojiDefaultStatuses#29d0f5ee = InputStickerSet; +inputStickerSetEmojiDefaultTopicIcons#44c1f8e9 = InputStickerSet; +inputStickerSetEmojiChannelDefaultStatuses#49748553 = InputStickerSet; +inputStickerSetTonGifts#1cf671a0 = InputStickerSet; + +stickerSet#2dd14edc flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true emojis:flags.7?true text_color:flags.9?true channel_emoji_status:flags.10?true creator:flags.11?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int thumb_document_id:flags.8?long count:int hash:int = StickerSet; + +messages.stickerSet#6e153f16 set:StickerSet packs:Vector keywords:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; + +botCommand#c27ac8c7 command:string description:string = BotCommand; + +botInfo#4d8a0299 flags:# has_preview_medias:flags.6?true user_id:flags.0?long description:flags.1?string description_photo:flags.4?Photo description_document:flags.5?Document commands:flags.2?Vector menu_button:flags.3?BotMenuButton privacy_policy_url:flags.7?string app_settings:flags.8?BotAppSettings verifier_settings:flags.9?BotVerifierSettings = BotInfo; + +keyboardButton#7d170cff flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrl#d80c25ec flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonCallback#e62bc960 flags:# requires_password:flags.0?true style:flags.10?KeyboardButtonStyle text:string data:bytes = KeyboardButton; +keyboardButtonRequestPhone#417efd8f flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonRequestGeoLocation#aa40f94d flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonSwitchInline#991399fc flags:# same_peer:flags.0?true style:flags.10?KeyboardButtonStyle text:string query:string peer_types:flags.1?Vector = KeyboardButton; +keyboardButtonGame#89c590f9 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonBuy#3fa53905 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrlAuth#f51006f9 flags:# style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +inputKeyboardButtonUrlAuth#68013e72 flags:# request_write_access:flags.0?true style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +keyboardButtonRequestPoll#7a11d782 flags:# style:flags.10?KeyboardButtonStyle quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#7d5e07c7 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#c0fd5d09 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:long = KeyboardButton; +keyboardButtonWebView#e846b1a0 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonSimpleWebView#e15c4370 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonRequestPeer#5b0f15f5 flags:# style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +inputKeyboardButtonRequestPeer#2b78156 flags:# name_requested:flags.0?true username_requested:flags.1?true photo_requested:flags.2?true style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +keyboardButtonCopy#bcc4af10 flags:# style:flags.10?KeyboardButtonStyle text:string copy_text:string = KeyboardButton; + +keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; + +replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true persistent:flags.4?true rows:Vector placeholder:flags.3?string = ReplyMarkup; +replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; + +messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; +messageEntityMention#fa04579d offset:int length:int = MessageEntity; +messageEntityHashtag#6f635b0d offset:int length:int = MessageEntity; +messageEntityBotCommand#6cef8ac7 offset:int length:int = MessageEntity; +messageEntityUrl#6ed02538 offset:int length:int = MessageEntity; +messageEntityEmail#64e475c2 offset:int length:int = MessageEntity; +messageEntityBold#bd610bc9 offset:int length:int = MessageEntity; +messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; +messageEntityCode#28a20571 offset:int length:int = MessageEntity; +messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; +messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; +inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; +messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; +messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; +messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; +messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; +messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; +messageEntityCustomEmoji#c8cf05f8 offset:int length:int document_id:long = MessageEntity; +messageEntityBlockquote#f1ccaaac flags:# collapsed:flags.0?true offset:int length:int = MessageEntity; +messageEntityFormattedDate#904ac7c7 flags:# relative:flags.0?true short_time:flags.1?true long_time:flags.2?true short_date:flags.3?true long_date:flags.4?true day_of_week:flags.5?true offset:int length:int date:int = MessageEntity; + +inputChannelEmpty#ee8c1e86 = InputChannel; +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; + +contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; + +messageRange#ae30253 min_id:int max_id:int = MessageRange; + +updates.channelDifferenceEmpty#3e11affb flags:# final:flags.0?true pts:int timeout:flags.1?int = updates.ChannelDifference; +updates.channelDifferenceTooLong#a4bcc6fe flags:# final:flags.0?true timeout:flags.1?int dialog:Dialog messages:Vector chats:Vector users:Vector = updates.ChannelDifference; +updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int new_messages:Vector other_updates:Vector chats:Vector users:Vector = updates.ChannelDifference; + +channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; +channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; + +channelParticipant#1bd54456 flags:# user_id:long date:int subscription_until_date:flags.0?int rank:flags.2?string = ChannelParticipant; +channelParticipantSelf#a9478a1a flags:# via_request:flags.0?true user_id:long inviter_id:long date:int subscription_until_date:flags.1?int rank:flags.2?string = ChannelParticipant; +channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; +channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; +channelParticipantBanned#d5f0ad91 flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights rank:flags.2?string = ChannelParticipant; +channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; + +channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; +channelParticipantsAdmins#b4608969 = ChannelParticipantsFilter; +channelParticipantsKicked#a3b54985 q:string = ChannelParticipantsFilter; +channelParticipantsBots#b0d1865b = ChannelParticipantsFilter; +channelParticipantsBanned#1427a5e1 q:string = ChannelParticipantsFilter; +channelParticipantsSearch#656ac4b q:string = ChannelParticipantsFilter; +channelParticipantsContacts#bb6ae88d q:string = ChannelParticipantsFilter; +channelParticipantsMentions#e04b5ceb flags:# q:flags.0?string top_msg_id:flags.1?int = ChannelParticipantsFilter; + +channels.channelParticipants#9ab0feaf count:int participants:Vector chats:Vector users:Vector = channels.ChannelParticipants; +channels.channelParticipantsNotModified#f0173fe9 = channels.ChannelParticipants; + +channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector users:Vector = channels.ChannelParticipant; + +help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; + +messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; + +inputBotInlineMessageMediaAuto#3380c786 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaGeo#96929a85 flags:# geo_point:InputGeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaVenue#417bbf11 flags:# geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaContact#a6edbffd flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageGame#4b425864 flags:# reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaInvoice#d7e78225 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaWebPage#bddcc510 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true optional:flags.6?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineResult#88bf9319 flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?InputWebDocument content:flags.5?InputWebDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultPhoto#a8d864a7 id:string type:string photo:InputPhoto send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultDocument#fff8fdc4 flags:# id:string type:string title:flags.1?string description:flags.2?string document:InputDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultGame#4fa417f2 id:string short_name:string send_message:InputBotInlineMessage = InputBotInlineResult; + +botInlineMessageMediaAuto#764cf810 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageText#8c7f65e2 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaGeo#51846fd flags:# geo:GeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaVenue#8a86659c flags:# geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaContact#18d1cdc2 flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaInvoice#354a9b09 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument currency:string total_amount:long reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaWebPage#809ad9a6 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true manual:flags.7?true safe:flags.8?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineResult#11965f3a flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?WebDocument content:flags.5?WebDocument send_message:BotInlineMessage = BotInlineResult; +botInlineMediaResult#17db940b flags:# id:string type:string photo:flags.0?Photo document:flags.1?Document title:flags.2?string description:flags.3?string send_message:BotInlineMessage = BotInlineResult; + +messages.botResults#e021f2f6 flags:# gallery:flags.0?true query_id:long next_offset:flags.1?string switch_pm:flags.2?InlineBotSwitchPM switch_webview:flags.3?InlineBotWebView results:Vector cache_time:int users:Vector = messages.BotResults; + +exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; + +messageFwdHeader#4e4df4bb flags:# imported:flags.7?true saved_out:flags.11?true from_id:flags.0?Peer from_name:flags.5?string date:int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int saved_from_id:flags.8?Peer saved_from_name:flags.9?string saved_date:flags.10?int psa_type:flags.6?string = MessageFwdHeader; + +auth.codeTypeSms#72a3158c = auth.CodeType; +auth.codeTypeCall#741cd3e3 = auth.CodeType; +auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; + +auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; +auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; +auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; +auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; +auth.sentCodeTypeEmailCode#f450f59b flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int reset_available_period:flags.3?int reset_pending_date:flags.4?int = auth.SentCodeType; +auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; +auth.sentCodeTypeFirebaseSms#9fd736 flags:# nonce:flags.0?bytes play_integrity_project_id:flags.2?long play_integrity_nonce:flags.2?bytes receipt:flags.1?string push_timeout:flags.1?int length:int = auth.SentCodeType; +auth.sentCodeTypeSmsWord#a416ac81 flags:# beginning:flags.0?string = auth.SentCodeType; +auth.sentCodeTypeSmsPhrase#b37794af flags:# beginning:flags.0?string = auth.SentCodeType; + +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; + +messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; + +inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; + +inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; + +messages.peerDialogs#3371c354 dialogs:Vector messages:Vector chats:Vector users:Vector state:updates.State = messages.PeerDialogs; + +topPeer#edcdc05b peer:Peer rating:double = TopPeer; + +topPeerCategoryBotsPM#ab661b5b = TopPeerCategory; +topPeerCategoryBotsInline#148677e2 = TopPeerCategory; +topPeerCategoryCorrespondents#637b7ed = TopPeerCategory; +topPeerCategoryGroups#bd17a14a = TopPeerCategory; +topPeerCategoryChannels#161d9628 = TopPeerCategory; +topPeerCategoryPhoneCalls#1e76a78c = TopPeerCategory; +topPeerCategoryForwardUsers#a8406ca9 = TopPeerCategory; +topPeerCategoryForwardChats#fbeec0f0 = TopPeerCategory; +topPeerCategoryBotsApp#fd9e7bec = TopPeerCategory; + +topPeerCategoryPeers#fb834291 category:TopPeerCategory count:int peers:Vector = TopPeerCategoryPeers; + +contacts.topPeersNotModified#de266ef5 = contacts.TopPeers; +contacts.topPeers#70b772a8 categories:Vector chats:Vector users:Vector = contacts.TopPeers; +contacts.topPeersDisabled#b52c939d = contacts.TopPeers; + +draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; +draftMessage#96eaa5eb flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo message:string entities:flags.3?Vector media:flags.5?InputMedia date:int effect:flags.7?long suggested_post:flags.8?SuggestedPost = DraftMessage; + +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; +messages.featuredStickers#be382906 flags:# premium:flags.0?true hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; + +messages.recentStickersNotModified#b17f890 = messages.RecentStickers; +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; + +messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; + +messages.stickerSetInstallResultSuccess#38641628 = messages.StickerSetInstallResult; +messages.stickerSetInstallResultArchive#35e410a8 sets:Vector = messages.StickerSetInstallResult; + +stickerSetCovered#6410a5d2 set:StickerSet cover:Document = StickerSetCovered; +stickerSetMultiCovered#3407e51b set:StickerSet covers:Vector = StickerSetCovered; +stickerSetFullCovered#40d13c0e set:StickerSet packs:Vector keywords:Vector documents:Vector = StickerSetCovered; +stickerSetNoCovered#77b15d1c set:StickerSet = StickerSetCovered; + +maskCoords#aed6dbb2 n:int x:double y:double zoom:double = MaskCoords; + +inputStickeredMediaPhoto#4a992157 id:InputPhoto = InputStickeredMedia; +inputStickeredMediaDocument#438865b id:InputDocument = InputStickeredMedia; + +game#bdf9653b flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document = Game; + +inputGameID#32c3e77 id:long access_hash:long = InputGame; +inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; + +highScore#73a379eb pos:int user_id:long score:int = HighScore; + +messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; + +textEmpty#dc3d824f = RichText; +textPlain#744694e0 text:string = RichText; +textBold#6724abc4 text:RichText = RichText; +textItalic#d912a59c text:RichText = RichText; +textUnderline#c12622c4 text:RichText = RichText; +textStrike#9bf8bb95 text:RichText = RichText; +textFixed#6c3f19b9 text:RichText = RichText; +textUrl#3c2884c1 text:RichText url:string webpage_id:long = RichText; +textEmail#de5a0dd6 text:RichText email:string = RichText; +textConcat#7e6260d7 texts:Vector = RichText; +textSubscript#ed6a8504 text:RichText = RichText; +textSuperscript#c7fb5e01 text:RichText = RichText; +textMarked#34b8621 text:RichText = RichText; +textPhone#1ccb966a text:RichText phone:string = RichText; +textImage#81ccf4f document_id:long w:int h:int = RichText; +textAnchor#35553762 text:RichText name:string = RichText; + +pageBlockUnsupported#13567e8a = PageBlock; +pageBlockTitle#70abc3fd text:RichText = PageBlock; +pageBlockSubtitle#8ffa9a1f text:RichText = PageBlock; +pageBlockAuthorDate#baafe5e0 author:RichText published_date:int = PageBlock; +pageBlockHeader#bfd064ec text:RichText = PageBlock; +pageBlockSubheader#f12bb6e1 text:RichText = PageBlock; +pageBlockParagraph#467a0766 text:RichText = PageBlock; +pageBlockPreformatted#c070d93e text:RichText language:string = PageBlock; +pageBlockFooter#48870999 text:RichText = PageBlock; +pageBlockDivider#db20b188 = PageBlock; +pageBlockAnchor#ce0d37b0 name:string = PageBlock; +pageBlockList#e4e88011 items:Vector = PageBlock; +pageBlockBlockquote#263d7c26 text:RichText caption:RichText = PageBlock; +pageBlockPullquote#4f4456d3 text:RichText caption:RichText = PageBlock; +pageBlockPhoto#1759c560 flags:# photo_id:long caption:PageCaption url:flags.0?string webpage_id:flags.0?long = PageBlock; +pageBlockVideo#7c8fe7b6 flags:# autoplay:flags.0?true loop:flags.1?true video_id:long caption:PageCaption = PageBlock; +pageBlockCover#39f23300 cover:PageBlock = PageBlock; +pageBlockEmbed#a8718dc5 flags:# full_width:flags.0?true allow_scrolling:flags.3?true url:flags.1?string html:flags.2?string poster_photo_id:flags.4?long w:flags.5?int h:flags.5?int caption:PageCaption = PageBlock; +pageBlockEmbedPost#f259a80b url:string webpage_id:long author_photo_id:long author:string date:int blocks:Vector caption:PageCaption = PageBlock; +pageBlockCollage#65a0fa4d items:Vector caption:PageCaption = PageBlock; +pageBlockSlideshow#31f9590 items:Vector caption:PageCaption = PageBlock; +pageBlockChannel#ef1751b5 channel:Chat = PageBlock; +pageBlockAudio#804361ea audio_id:long caption:PageCaption = PageBlock; +pageBlockKicker#1e148390 text:RichText = PageBlock; +pageBlockTable#bf4dea82 flags:# bordered:flags.0?true striped:flags.1?true title:RichText rows:Vector = PageBlock; +pageBlockOrderedList#9a8ae1e1 items:Vector = PageBlock; +pageBlockDetails#76768bed flags:# open:flags.0?true blocks:Vector title:RichText = PageBlock; +pageBlockRelatedArticles#16115a96 title:RichText articles:Vector = PageBlock; +pageBlockMap#a44f3ef6 geo:GeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; + +phoneCallDiscardReasonMissed#85e42301 = PhoneCallDiscardReason; +phoneCallDiscardReasonDisconnect#e095c1a0 = PhoneCallDiscardReason; +phoneCallDiscardReasonHangup#57adc690 = PhoneCallDiscardReason; +phoneCallDiscardReasonBusy#faf7e8c9 = PhoneCallDiscardReason; +phoneCallDiscardReasonMigrateConferenceCall#9fbbf1f7 slug:string = PhoneCallDiscardReason; + +dataJSON#7d748d04 data:string = DataJSON; + +labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; + +invoice#49ee584 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true recurring:flags.9?true currency:string prices:Vector max_tip_amount:flags.8?long suggested_tip_amounts:flags.8?Vector terms_url:flags.10?string subscription_period:flags.11?int = Invoice; + +paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; + +postAddress#1e8caaeb street_line1:string street_line2:string city:string state:string country_iso2:string post_code:string = PostAddress; + +paymentRequestedInfo#909c3f94 flags:# name:flags.0?string phone:flags.1?string email:flags.2?string shipping_address:flags.3?PostAddress = PaymentRequestedInfo; + +paymentSavedCredentialsCard#cdc27a1f id:string title:string = PaymentSavedCredentials; + +webDocument#1c570ed1 url:string access_hash:long size:int mime_type:string attributes:Vector = WebDocument; +webDocumentNoProxy#f9c8bcc6 url:string size:int mime_type:string attributes:Vector = WebDocument; + +inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector = InputWebDocument; + +inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation; +inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation; +inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# small:flags.2?true document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation; + +upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; + +payments.paymentForm#a0058751 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON additional_methods:flags.6?Vector saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?Vector users:Vector = payments.PaymentForm; +payments.paymentFormStars#7bf6b15c flags:# form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice users:Vector = payments.PaymentForm; +payments.paymentFormStarGift#b425cfe1 form_id:long invoice:Invoice = payments.PaymentForm; + +payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; + +payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; +payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; + +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; +payments.paymentReceiptStars#dabbf83a flags:# date:int bot_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice currency:string total_amount:long transaction_id:string users:Vector = payments.PaymentReceipt; + +payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; + +inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; +inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsGooglePay#8ac32801 payment_token:DataJSON = InputPaymentCredentials; + +account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; + +shippingOption#b6213cdf id:string title:string prices:Vector = ShippingOption; + +inputStickerSetItem#32da9e9c flags:# document:InputDocument emoji:string mask_coords:flags.0?MaskCoords keywords:flags.1?string = InputStickerSetItem; + +inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; + +phoneCallEmpty#5366c915 id:long = PhoneCall; +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#30535af5 flags:# p2p_allowed:flags.5?true video:flags.6?true conference_supported:flags.8?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int custom_parameters:flags.7?DataJSON = PhoneCall; +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; + +phoneConnection#9cc123c7 flags:# tcp:flags.0?true id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; + +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; + +phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; + +upload.cdnFileReuploadNeeded#eea8e46e request_token:bytes = upload.CdnFile; +upload.cdnFile#a99fca4f bytes:bytes = upload.CdnFile; + +cdnPublicKey#c982eaba dc_id:int public_key:string = CdnPublicKey; + +cdnConfig#5725e40a public_keys:Vector = CdnConfig; + +langPackString#cad181f6 key:string value:string = LangPackString; +langPackStringPluralized#6c47ac9f flags:# key:string zero_value:flags.0?string one_value:flags.1?string two_value:flags.2?string few_value:flags.3?string many_value:flags.4?string other_value:string = LangPackString; +langPackStringDeleted#2979eeb2 key:string = LangPackString; + +langPackDifference#f385c1f6 lang_code:string from_version:int version:int strings:Vector = LangPackDifference; + +langPackLanguage#eeca5ce3 flags:# official:flags.0?true rtl:flags.2?true beta:flags.3?true name:string native_name:string lang_code:string base_lang_code:flags.1?string plural_code:string strings_count:int translated_count:int translations_url:string = LangPackLanguage; + +channelAdminLogEventActionChangeTitle#e6dfb825 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAbout#55188a2e prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsername#6a4afc38 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePhoto#434bd2af prev_photo:Photo new_photo:Photo = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleInvites#1b7907ae new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatures#26ae0971 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionUpdatePinned#e9e82c18 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionEditMessage#709b2405 prev_message:Message new_message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteMessage#42e047bb message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoin#183040d3 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantLeave#f89777f2 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantInvite#e31c34d8 participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleBan#e6d83d7e prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleAdmin#d5676710 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; +channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionDiscardGroupCall#db9f9140 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantMute#f92424d2 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantUnmute#e64429c0 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleGroupCallSetting#56d6a247 join_muted:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByInvite#fe9fc158 flags:# via_chatlist:flags.0?true invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteDelete#5a50fca4 invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePeerColor#5796e780 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeProfilePeerColor#5e477b25 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeWallpaper#31bb5d52 prev_value:WallPaper new_value:WallPaper = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStatus#3ea9feb1 prev_value:EmojiStatus new_value:EmojiStatus = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStickerSet#46d840ab prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatureProfiles#60a79c79 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantSubExtend#64642db3 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAutotranslation#c517f77e new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantEditRank#5806b4ec user_id:long prev_rank:string new_rank:string = ChannelAdminLogEventAction; + +channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; + +channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; + +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true sub_extend:flags.18?true edit_rank:flags.19?true = ChannelAdminLogEventsFilter; + +popularContact#5ce14175 client_id:long importers:int = PopularContact; + +messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; + +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; +recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; + +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; + +account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; + +inputMessageID#a676a322 id:int = InputMessage; +inputMessageReplyTo#bad88395 id:int = InputMessage; +inputMessagePinned#86872538 = InputMessage; +inputMessageCallbackQuery#acfa1a7e id:int query_id:long = InputMessage; + +inputDialogPeer#fcaafeb7 peer:InputPeer = InputDialogPeer; +inputDialogPeerFolder#64600527 folder_id:int = InputDialogPeer; + +dialogPeer#e56dbf05 peer:Peer = DialogPeer; +dialogPeerFolder#514519e2 folder_id:int = DialogPeer; + +messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; + +fileHash#f39b035c offset:long limit:int hash:bytes = FileHash; + +inputClientProxy#75588b3f address:string port:int = InputClientProxy; + +help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; +help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; + +inputSecureFileUploaded#3334b0f0 id:long parts:int md5_checksum:string file_hash:bytes secret:bytes = InputSecureFile; +inputSecureFile#5367e5be id:long access_hash:long = InputSecureFile; + +secureFileEmpty#64199744 = SecureFile; +secureFile#7d09c27e id:long access_hash:long size:long dc_id:int date:int file_hash:bytes secret:bytes = SecureFile; + +secureData#8aeabec3 data:bytes data_hash:bytes secret:bytes = SecureData; + +securePlainPhone#7d6099dd phone:string = SecurePlainData; +securePlainEmail#21ec5a5f email:string = SecurePlainData; + +secureValueTypePersonalDetails#9d2a81e3 = SecureValueType; +secureValueTypePassport#3dac6a00 = SecureValueType; +secureValueTypeDriverLicense#6e425c4 = SecureValueType; +secureValueTypeIdentityCard#a0d0744b = SecureValueType; +secureValueTypeInternalPassport#99a48f23 = SecureValueType; +secureValueTypeAddress#cbe31e26 = SecureValueType; +secureValueTypeUtilityBill#fc36954e = SecureValueType; +secureValueTypeBankStatement#89137c0d = SecureValueType; +secureValueTypeRentalAgreement#8b883488 = SecureValueType; +secureValueTypePassportRegistration#99e3806a = SecureValueType; +secureValueTypeTemporaryRegistration#ea02ec33 = SecureValueType; +secureValueTypePhone#b320aadb = SecureValueType; +secureValueTypeEmail#8e3ca7ee = SecureValueType; + +secureValue#187fa0ca flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?SecureFile reverse_side:flags.2?SecureFile selfie:flags.3?SecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData hash:bytes = SecureValue; + +inputSecureValue#db21d0a7 flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?InputSecureFile reverse_side:flags.2?InputSecureFile selfie:flags.3?InputSecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData = InputSecureValue; + +secureValueHash#ed1ecdb0 type:SecureValueType hash:bytes = SecureValueHash; + +secureValueErrorData#e8a40bd9 type:SecureValueType data_hash:bytes field:string text:string = SecureValueError; +secureValueErrorFrontSide#be3dfa type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorReverseSide#868a2aa5 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorSelfie#e537ced6 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFile#7a700873 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFiles#666220e9 type:SecureValueType file_hash:Vector text:string = SecureValueError; +secureValueError#869d758f type:SecureValueType hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFile#a1144770 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFiles#34636dd8 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureCredentialsEncrypted#33f0ea47 data:bytes hash:bytes secret:bytes = SecureCredentialsEncrypted; + +account.authorizationForm#ad2e1cd8 flags:# required_types:Vector values:Vector errors:Vector users:Vector privacy_policy_url:flags.0?string = account.AuthorizationForm; + +account.sentEmailCode#811f854f email_pattern:string length:int = account.SentEmailCode; + +help.deepLinkInfoEmpty#66afa166 = help.DeepLinkInfo; +help.deepLinkInfo#6a4ee832 flags:# update_app:flags.0?true message:string entities:flags.1?Vector = help.DeepLinkInfo; + +savedPhoneContact#1142bd56 phone:string first_name:string last_name:string date:int = SavedContact; + +account.takeout#4dba4501 id:long = account.Takeout; + +passwordKdfAlgoUnknown#d45ab096 = PasswordKdfAlgo; +passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow#3a912d4a salt1:bytes salt2:bytes g:int p:bytes = PasswordKdfAlgo; + +securePasswordKdfAlgoUnknown#4a8537 = SecurePasswordKdfAlgo; +securePasswordKdfAlgoPBKDF2HMACSHA512iter100000#bbf2dda0 salt:bytes = SecurePasswordKdfAlgo; +securePasswordKdfAlgoSHA512#86471d92 salt:bytes = SecurePasswordKdfAlgo; + +secureSecretSettings#1527bcac secure_algo:SecurePasswordKdfAlgo secure_secret:bytes secure_secret_id:long = SecureSecretSettings; + +inputCheckPasswordEmpty#9880f658 = InputCheckPasswordSRP; +inputCheckPasswordSRP#d27ff082 srp_id:long A:bytes M1:bytes = InputCheckPasswordSRP; + +secureRequiredType#829d99da flags:# native_names:flags.0?true selfie_required:flags.1?true translation_required:flags.2?true type:SecureValueType = SecureRequiredType; +secureRequiredTypeOneOf#27477b4 types:Vector = SecureRequiredType; + +help.passportConfigNotModified#bfb9f457 = help.PassportConfig; +help.passportConfig#a098d6af hash:int countries_langs:DataJSON = help.PassportConfig; + +inputAppEvent#1d1b1245 time:double type:string peer:long data:JSONValue = InputAppEvent; + +jsonObjectValue#c0de1bd9 key:string value:JSONValue = JSONObjectValue; + +jsonNull#3f6d7b68 = JSONValue; +jsonBool#c7345e6a value:Bool = JSONValue; +jsonNumber#2be0dfa4 value:double = JSONValue; +jsonString#b71e767a value:string = JSONValue; +jsonArray#f7444763 value:Vector = JSONValue; +jsonObject#99c1d49d value:Vector = JSONValue; + +pageTableCell#34566b6a flags:# header:flags.0?true align_center:flags.3?true align_right:flags.4?true valign_middle:flags.5?true valign_bottom:flags.6?true text:flags.7?RichText colspan:flags.1?int rowspan:flags.2?int = PageTableCell; + +pageTableRow#e0c0c5e5 cells:Vector = PageTableRow; + +pageCaption#6f747657 text:RichText credit:RichText = PageCaption; + +pageListItemText#b92fb6cd text:RichText = PageListItem; +pageListItemBlocks#25e073fc blocks:Vector = PageListItem; + +pageListOrderedItemText#5e068047 num:string text:RichText = PageListOrderedItem; +pageListOrderedItemBlocks#98dd8936 num:string blocks:Vector = PageListOrderedItem; + +pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; + +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; + +help.supportName#8c05f1c9 name:string = help.SupportName; + +help.userInfoEmpty#f3ae2eed = help.UserInfo; +help.userInfo#1eb3758 message:string entities:Vector author:string date:int = help.UserInfo; + +pollAnswer#ff16e2ca text:TextWithEntities option:bytes = PollAnswer; + +poll#58747131 id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true question:TextWithEntities answers:Vector close_period:flags.4?int close_date:flags.5?int = Poll; + +pollAnswerVoters#3b6ddad2 flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:int = PollAnswerVoters; + +pollResults#7adf2420 flags:# min:flags.0?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector = PollResults; + +chatOnlines#f041e250 onlines:int = ChatOnlines; + +statsURL#47a971e0 url:string = StatsURL; + +chatAdminRights#5fb224d5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true pin_messages:flags.7?true add_admins:flags.9?true anonymous:flags.10?true manage_call:flags.11?true other:flags.12?true manage_topics:flags.13?true post_stories:flags.14?true edit_stories:flags.15?true delete_stories:flags.16?true manage_direct_messages:flags.17?true manage_ranks:flags.18?true = ChatAdminRights; + +chatBannedRights#9f120418 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true send_polls:flags.8?true change_info:flags.10?true invite_users:flags.15?true pin_messages:flags.17?true manage_topics:flags.18?true send_photos:flags.19?true send_videos:flags.20?true send_roundvideos:flags.21?true send_audios:flags.22?true send_voices:flags.23?true send_docs:flags.24?true send_plain:flags.25?true edit_rank:flags.26?true until_date:int = ChatBannedRights; + +inputWallPaper#e630b979 id:long access_hash:long = InputWallPaper; +inputWallPaperSlug#72091c80 slug:string = InputWallPaper; +inputWallPaperNoFile#967a462e id:long = InputWallPaper; + +account.wallPapersNotModified#1c199183 = account.WallPapers; +account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; + +codeSettings#ad253d78 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true allow_firebase:flags.7?true unknown_number:flags.9?true logout_tokens:flags.6?Vector token:flags.8?string app_sandbox:flags.8?Bool = CodeSettings; + +wallPaperSettings#372efcd0 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int emoticon:flags.7?string = WallPaperSettings; + +autoDownloadSettings#baa57628 flags:# disabled:flags.0?true video_preload_large:flags.1?true audio_preload_next:flags.2?true phonecalls_less_data:flags.3?true stories_preload:flags.4?true photo_size_max:int video_size_max:long file_size_max:long video_upload_maxbitrate:int small_queue_active_operations_max:int large_queue_active_operations_max:int = AutoDownloadSettings; + +account.autoDownloadSettings#63cacf26 low:AutoDownloadSettings medium:AutoDownloadSettings high:AutoDownloadSettings = account.AutoDownloadSettings; + +emojiKeyword#d5b3b9f9 keyword:string emoticons:Vector = EmojiKeyword; +emojiKeywordDeleted#236df622 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordsDifference#5cc761bd lang_code:string from_version:int version:int keywords:Vector = EmojiKeywordsDifference; + +emojiURL#a575739d url:string = EmojiURL; + +emojiLanguage#b3fb5361 lang_code:string = EmojiLanguage; + +folder#ff544e65 flags:# autofill_new_broadcasts:flags.0?true autofill_public_groups:flags.1?true autofill_new_correspondents:flags.2?true id:int title:string photo:flags.3?ChatPhoto = Folder; + +inputFolderPeer#fbd2c296 peer:InputPeer folder_id:int = InputFolderPeer; + +folderPeer#e9baa668 peer:Peer folder_id:int = FolderPeer; + +messages.searchCounter#e844ebff flags:# inexact:flags.1?true filter:MessagesFilter count:int = messages.SearchCounter; + +urlAuthResultRequest#f8f8eb1e flags:# request_write_access:flags.0?true request_phone_number:flags.1?true match_codes_first:flags.5?true bot:User domain:string browser:flags.2?string platform:flags.2?string ip:flags.2?string region:flags.2?string match_codes:flags.3?Vector user_id_hint:flags.4?long = UrlAuthResult; +urlAuthResultAccepted#623a8fa0 flags:# url:flags.0?string = UrlAuthResult; +urlAuthResultDefault#a9d6db1f = UrlAuthResult; + +channelLocationEmpty#bfb5ad8b = ChannelLocation; +channelLocation#209b82db geo_point:GeoPoint address:string = ChannelLocation; + +peerLocated#ca461b5d peer:Peer expires:int distance:int = PeerLocated; +peerSelfLocated#f8ec284b expires:int = PeerLocated; + +restrictionReason#d072acb4 platform:string reason:string text:string = RestrictionReason; + +inputTheme#3c5693e9 id:long access_hash:long = InputTheme; +inputThemeSlug#f5890df1 slug:string = InputTheme; + +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; + +account.themesNotModified#f41eb622 = account.Themes; +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; + +auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; +auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; +auth.loginTokenSuccess#390d5c5e authorization:auth.Authorization = auth.LoginToken; + +account.contentSettings#57e28221 flags:# sensitive_enabled:flags.0?true sensitive_can_change:flags.1?true = account.ContentSettings; + +messages.inactiveChats#a927fec5 dates:Vector chats:Vector users:Vector = messages.InactiveChats; + +baseThemeClassic#c3a12462 = BaseTheme; +baseThemeDay#fbd81688 = BaseTheme; +baseThemeNight#b7b31ea8 = BaseTheme; +baseThemeTinted#6d5f77ee = BaseTheme; +baseThemeArctic#5b11125a = BaseTheme; + +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; + +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; + +webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; +webPageAttributeStory#2e94c3e7 flags:# peer:Peer id:int story:flags.0?StoryItem = WebPageAttribute; +webPageAttributeStickerSet#50cc03d3 flags:# emojis:flags.0?true text_color:flags.1?true stickers:Vector = WebPageAttribute; +webPageAttributeUniqueStarGift#cf6f6db8 gift:StarGift = WebPageAttribute; +webPageAttributeStarGiftCollection#31cad303 icons:Vector = WebPageAttribute; +webPageAttributeStarGiftAuction#1c641c2 gift:StarGift end_date:int = WebPageAttribute; + +messages.votesList#4899484e flags:# count:int votes:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.VotesList; + +bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; + +payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; + +dialogFilter#aa472651 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; +dialogFilterDefault#363293ae = DialogFilter; +dialogFilterChatlist#96537bd7 flags:# has_my_invites:flags.26?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; +statsGraphError#bedc9822 error:string = StatsGraph; +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +stats.broadcastStats#396ca5fc period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev reactions_per_post:StatsAbsValueAndPrev views_per_story:StatsAbsValueAndPrev shares_per_story:StatsAbsValueAndPrev reactions_per_story:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph reactions_by_emotion_graph:StatsGraph story_interactions_graph:StatsGraph story_reactions_by_emotion_graph:StatsGraph recent_posts_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; +help.promoData#8a4d87a flags:# proxy:flags.0?true expires:int peer:flags.3?Peer psa_type:flags.1?string psa_message:flags.2?string pending_suggestions:Vector dismissed_suggestions:Vector custom_pending_suggestion:flags.4?PendingSuggestion chats:Vector users:Vector = help.PromoData; + +videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; +videoSizeEmojiMarkup#f85c413c emoji_id:long background_colors:Vector = VideoSize; +videoSizeStickerMarkup#da082fe stickerset:InputStickerSet sticker_id:long background_colors:Vector = VideoSize; + +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#fe41b34f flags:# archive_and_mute_new_noncontact_peers:flags.0?true keep_archived_unmuted:flags.1?true keep_archived_folders:flags.2?true hide_read_marks:flags.3?true new_noncontact_peers_require_premium:flags.4?true display_gifts_button:flags.7?true noncontact_peers_paid_stars:flags.5?long disallowed_gifts:flags.6?DisallowedGiftsSettings = GlobalPrivacySettings; + +help.countryCode#4203c5ef flags:# country_code:string prefixes:flags.0?Vector patterns:flags.1?Vector = help.CountryCode; + +help.country#c3878e23 flags:# hidden:flags.0?true iso2:string default_name:string name:flags.1?string country_codes:Vector = help.Country; + +help.countriesListNotModified#93cc1f32 = help.CountriesList; +help.countriesList#87d0759e countries:Vector hash:int = help.CountriesList; + +messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:flags.2?MessageReplies = MessageViews; + +messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; + +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; + +messageReplyHeader#6917560b flags:# reply_to_scheduled:flags.2?true forum_topic:flags.3?true quote:flags.9?true reply_to_msg_id:flags.4?int reply_to_peer_id:flags.0?Peer reply_from:flags.5?MessageFwdHeader reply_media:flags.8?MessageMedia reply_to_top_id:flags.1?int quote_text:flags.6?string quote_entities:flags.7?Vector quote_offset:flags.10?int todo_item_id:flags.11?int = MessageReplyHeader; +messageReplyStoryHeader#e5af939 peer:Peer story_id:int = MessageReplyHeader; + +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; + +peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; + +stats.messageStats#7fe91c14 views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.MessageStats; + +groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; +groupCall#efb2b617 flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true conference:flags.14?true creator:flags.15?true messages_enabled:flags.17?true can_change_messages_enabled:flags.18?true min:flags.19?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int invite_link:flags.16?string send_paid_messages_stars:flags.20?long default_send_as:flags.21?Peer = GroupCall; + +inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; +inputGroupCallSlug#fe06823f slug:string = InputGroupCall; +inputGroupCallInviteMessage#8c10603f msg_id:int = InputGroupCall; + +groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant; + +phone.groupCall#9e727aad call:GroupCall participants:Vector participants_next_offset:string chats:Vector users:Vector = phone.GroupCall; + +phone.groupParticipants#f47751b6 count:int participants:Vector next_offset:string chats:Vector users:Vector version:int = phone.GroupParticipants; + +inlineQueryPeerTypeSameBotPM#3081ed9d = InlineQueryPeerType; +inlineQueryPeerTypePM#833c0fac = InlineQueryPeerType; +inlineQueryPeerTypeChat#d766c50a = InlineQueryPeerType; +inlineQueryPeerTypeMegagroup#5ec4be43 = InlineQueryPeerType; +inlineQueryPeerTypeBroadcast#6334ee9a = InlineQueryPeerType; +inlineQueryPeerTypeBotPM#e3b2d0c = InlineQueryPeerType; + +messages.historyImport#1662af0b id:long = messages.HistoryImport; + +messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true title:flags.2?string = messages.HistoryImportParsed; + +messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; + +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true via_chatlist:flags.3?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; + +messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; + +messages.exportedChatInvite#1871be50 invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; +messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; + +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; + +messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; + +messages.checkedHistoryImportPeer#a24de717 confirm_text:string = messages.CheckedHistoryImportPeer; + +phone.joinAsPeers#afe5623f peers:Vector chats:Vector users:Vector = phone.JoinAsPeers; + +phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvite; + +groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; + +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; +botCommandScopeUsers#3c4f04d8 = BotCommandScope; +botCommandScopeChats#6fe1a881 = BotCommandScope; +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +chatTheme#c3dffc04 emoticon:string = ChatTheme; +chatThemeUniqueGift#3458f9c8 gift:StarGift theme_settings:Vector = ChatTheme; + +account.chatThemesNotModified#e011e1c4 = account.ChatThemes; +account.chatThemes#be098173 flags:# hash:long themes:Vector chats:Vector users:Vector next_offset:flags.0?string = account.ChatThemes; + +sponsoredMessage#7dbf8673 flags:# recommended:flags.5?true can_report:flags.12?true random_id:bytes url:string title:string message:string entities:flags.1?Vector photo:flags.6?Photo media:flags.14?MessageMedia color:flags.13?PeerColor button_text:string sponsor_info:flags.7?string additional_info:flags.8?string min_display_duration:flags.15?int max_display_duration:flags.15?int = SponsoredMessage; + +messages.sponsoredMessages#ffda656d flags:# posts_between:flags.0?int start_delay:flags.1?int between_delay:flags.2?int messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +messages.sponsoredMessagesEmpty#1839490f = messages.SponsoredMessages; + +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#f496b0c6 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#a3d1cb80 flags:# chosen_order:flags.0?int reaction:Reaction count:int = ReactionCount; + +messageReactions#a339f0b flags:# min:flags.0?true can_see_list:flags.2?true reactions_as_tags:flags.3?true results:Vector recent_reactions:flags.1?Vector top_reactors:flags.4?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true premium:flags.2?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + +messagePeerReaction#8c79b63c flags:# big:flags.0?true unread:flags.1?true my:flags.2?true peer_id:Peer date:int reaction:Reaction = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; + +attachMenuBotIconColor#4576f3f0 name:string color:int = AttachMenuBotIconColor; + +attachMenuBotIcon#b2a7386b flags:# name:string icon:Document colors:flags.0?Vector = AttachMenuBotIcon; + +attachMenuBot#d90d8dfe flags:# inactive:flags.0?true has_settings:flags.1?true request_write_access:flags.2?true show_in_attach_menu:flags.3?true show_in_side_menu:flags.4?true side_menu_disclaimer_needed:flags.5?true bot_id:long short_name:string peer_types:flags.3?Vector icons:Vector = AttachMenuBot; + +attachMenuBotsNotModified#f1d88a5c = AttachMenuBots; +attachMenuBots#3c4301c0 hash:long bots:Vector users:Vector = AttachMenuBots; + +attachMenuBotsBot#93bf667f bot:AttachMenuBot users:Vector = AttachMenuBotsBot; + +webViewResultUrl#4d22ff98 flags:# fullsize:flags.1?true fullscreen:flags.2?true query_id:flags.0?long url:string = WebViewResult; + +webViewMessageSent#c94511c flags:# msg_id:flags.0?InputBotInlineMessageID = WebViewMessageSent; + +botMenuButtonDefault#7533a588 = BotMenuButton; +botMenuButtonCommands#4258c205 = BotMenuButton; +botMenuButton#c7b57ce6 text:string url:string = BotMenuButton; + +account.savedRingtonesNotModified#fbf6e8b1 = account.SavedRingtones; +account.savedRingtones#c1e92cc5 hash:long ringtones:Vector = account.SavedRingtones; + +notificationSoundDefault#97e8bebe = NotificationSound; +notificationSoundNone#6f0c34df = NotificationSound; +notificationSoundLocal#830b9ae4 title:string data:string = NotificationSound; +notificationSoundRingtone#ff6c8049 id:long = NotificationSound; + +account.savedRingtone#b7263f6d = account.SavedRingtone; +account.savedRingtoneConverted#1f307eb7 document:Document = account.SavedRingtone; + +attachMenuPeerTypeSameBotPM#7d6be90e = AttachMenuPeerType; +attachMenuPeerTypeBotPM#c32bfa1a = AttachMenuPeerType; +attachMenuPeerTypePM#f146d31f = AttachMenuPeerType; +attachMenuPeerTypeChat#509113f = AttachMenuPeerType; +attachMenuPeerTypeBroadcast#7bfbdefc = AttachMenuPeerType; + +inputInvoiceMessage#c5b56859 peer:InputPeer msg_id:int = InputInvoice; +inputInvoiceSlug#c326caef slug:string = InputInvoice; +inputInvoicePremiumGiftCode#98986c0d purpose:InputStorePaymentPurpose option:PremiumGiftCodeOption = InputInvoice; +inputInvoiceStars#65f00ce3 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceChatInviteSubscription#34e793f1 hash:string = InputInvoice; +inputInvoiceStarGift#e8625e92 flags:# hide_name:flags.0?true include_upgrade:flags.2?true peer:InputPeer gift_id:long message:flags.1?TextWithEntities = InputInvoice; +inputInvoiceStarGiftUpgrade#4d818d5d flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftTransfer#4a5f5bd9 stargift:InputSavedStarGift to_id:InputPeer = InputInvoice; +inputInvoicePremiumGiftStars#dabab2ef flags:# user_id:InputUser months:int message:flags.0?TextWithEntities = InputInvoice; +inputInvoiceBusinessBotTransferStars#f4997e42 bot:InputUser stars:long = InputInvoice; +inputInvoiceStarGiftResale#c39f5324 flags:# ton:flags.0?true slug:string to_id:InputPeer = InputInvoice; +inputInvoiceStarGiftPrepaidUpgrade#9a0b48b8 peer:InputPeer hash:string = InputInvoice; +inputInvoicePremiumAuthCode#3e77f614 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceStarGiftDropOriginalDetails#923d8d1 stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftAuctionBid#1ecafa10 flags:# hide_name:flags.0?true update_bid:flags.2?true peer:flags.3?InputPeer gift_id:long bid_amount:long message:flags.1?TextWithEntities = InputInvoice; + +payments.exportedInvoice#aed0cbd9 url:string = payments.ExportedInvoice; + +messages.transcribedAudio#cfb9d957 flags:# pending:flags.0?true transcription_id:long text:string trial_remains_num:flags.1?int trial_remains_until_date:flags.1?int = messages.TranscribedAudio; + +help.premiumPromo#5334759c status_text:string status_entities:Vector video_sections:Vector videos:Vector period_options:Vector users:Vector = help.PremiumPromo; + +inputStorePaymentPremiumSubscription#a6751e66 flags:# restore:flags.0?true upgrade:flags.1?true = InputStorePaymentPurpose; +inputStorePaymentGiftPremium#616f7fe8 user_id:InputUser currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentPremiumGiftCode#fb790393 flags:# users:Vector boost_peer:flags.0?InputPeer currency:string amount:long message:flags.1?TextWithEntities = InputStorePaymentPurpose; +inputStorePaymentPremiumGiveaway#160544ca flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsTopup#f9a2a6cb flags:# stars:long currency:string amount:long spend_purpose_peer:flags.0?InputPeer = InputStorePaymentPurpose; +inputStorePaymentStarsGift#1d741ef7 user_id:InputUser stars:long currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsGiveaway#751f08fa flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true stars:long boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long users:int = InputStorePaymentPurpose; +inputStorePaymentAuthCode#9bb2636d flags:# restore:flags.0?true phone_number:string phone_code_hash:string currency:string amount:long = InputStorePaymentPurpose; + +paymentFormMethod#88f8f21b url:string title:string = PaymentFormMethod; + +emojiStatusEmpty#2de11aae = EmojiStatus; +emojiStatus#e7ff068a flags:# document_id:long until:flags.0?int = EmojiStatus; +emojiStatusCollectible#7184603b flags:# collectible_id:long document_id:long title:string slug:string pattern_document_id:long center_color:int edge_color:int pattern_color:int text_color:int until:flags.0?int = EmojiStatus; +inputEmojiStatusCollectible#7141dbf flags:# collectible_id:long until:flags.0?int = EmojiStatus; + +account.emojiStatusesNotModified#d08ce645 = account.EmojiStatuses; +account.emojiStatuses#90c467d1 hash:long statuses:Vector = account.EmojiStatuses; + +reactionEmpty#79f5d419 = Reaction; +reactionEmoji#1b2286b8 emoticon:string = Reaction; +reactionCustomEmoji#8935fc73 document_id:long = Reaction; +reactionPaid#523da4eb = Reaction; + +chatReactionsNone#eafc32bc = ChatReactions; +chatReactionsAll#52928bca flags:# allow_custom:flags.0?true = ChatReactions; +chatReactionsSome#661d4037 reactions:Vector = ChatReactions; + +messages.reactionsNotModified#b06fdbdf = messages.Reactions; +messages.reactions#eafdf716 hash:long reactions:Vector = messages.Reactions; + +emailVerifyPurposeLoginSetup#4345be73 phone_number:string phone_code_hash:string = EmailVerifyPurpose; +emailVerifyPurposeLoginChange#527d22eb = EmailVerifyPurpose; +emailVerifyPurposePassport#bbf51685 = EmailVerifyPurpose; + +emailVerificationCode#922e55a9 code:string = EmailVerification; +emailVerificationGoogle#db909ec2 token:string = EmailVerification; +emailVerificationApple#96d074fd token:string = EmailVerification; + +account.emailVerified#2b96cd1b email:string = account.EmailVerified; +account.emailVerifiedLogin#e1bb0d61 email:string sent_code:auth.SentCode = account.EmailVerified; + +premiumSubscriptionOption#5f2d1df2 flags:# current:flags.1?true can_purchase_upgrade:flags.2?true transaction:flags.3?string months:int currency:string amount:long bot_url:string store_product:flags.0?string = PremiumSubscriptionOption; + +sendAsPeer#b81c7034 flags:# premium_required:flags.0?true peer:Peer = SendAsPeer; + +messageExtendedMediaPreview#ad628cc8 flags:# w:flags.0?int h:flags.0?int thumb:flags.1?PhotoSize video_duration:flags.2?int = MessageExtendedMedia; +messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; + +stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; + +username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; + +forumTopicDeleted#23f109b id:int = ForumTopic; +forumTopic#cdff0eca flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true title_missing:flags.7?true id:int date:int peer:Peer title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; + +messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; + +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + +requestPeerTypeUser#5f3b8a00 flags:# bot:flags.0?Bool premium:flags.1?Bool = RequestPeerType; +requestPeerTypeChat#c9f06e1b flags:# creator:flags.0?true bot_participant:flags.5?true has_username:flags.3?Bool forum:flags.4?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeBroadcast#339bef6c flags:# creator:flags.0?true has_username:flags.3?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; + +emojiListNotModified#481eadfa = EmojiList; +emojiList#7a1e11d1 hash:long document_id:Vector = EmojiList; + +emojiGroup#7a9abda9 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupGreeting#80d26cc7 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupPremium#93bcf34 title:string icon_emoji_id:long = EmojiGroup; + +messages.emojiGroupsNotModified#6fb4ad87 = messages.EmojiGroups; +messages.emojiGroups#881fb94b hash:int groups:Vector = messages.EmojiGroups; + +textWithEntities#751f3146 text:string entities:Vector = TextWithEntities; + +messages.translateResult#33db32f8 result:Vector = messages.TranslatedText; + +autoSaveSettings#c84834ce flags:# photos:flags.0?true videos:flags.1?true video_max_size:flags.2?long = AutoSaveSettings; + +autoSaveException#81602d47 peer:Peer settings:AutoSaveSettings = AutoSaveException; + +account.autoSaveSettings#4c3e069d users_settings:AutoSaveSettings chats_settings:AutoSaveSettings broadcasts_settings:AutoSaveSettings exceptions:Vector chats:Vector users:Vector = account.AutoSaveSettings; + +help.appConfigNotModified#7cde641d = help.AppConfig; +help.appConfig#dd18782e hash:int config:JSONValue = help.AppConfig; + +inputBotAppID#a920bd7a id:long access_hash:long = InputBotApp; +inputBotAppShortName#908c0407 bot_id:InputUser short_name:string = InputBotApp; + +botAppNotModified#5da674b7 = BotApp; +botApp#95fcd1d6 flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document hash:long = BotApp; + +messages.botApp#eb50adf5 flags:# inactive:flags.0?true request_write_access:flags.1?true has_settings:flags.2?true app:BotApp = messages.BotApp; + +inlineBotWebView#b57295d5 text:string url:string = InlineBotWebView; + +readParticipantDate#4a4ff172 user_id:long date:int = ReadParticipantDate; + +inputChatlistDialogFilter#f3e0da33 filter_id:int = InputChatlist; + +exportedChatlistInvite#c5181ac flags:# title:string url:string peers:Vector = ExportedChatlistInvite; + +chatlists.exportedChatlistInvite#10e6e3a6 filter:DialogFilter invite:ExportedChatlistInvite = chatlists.ExportedChatlistInvite; + +chatlists.exportedInvites#10ab6dc7 invites:Vector chats:Vector users:Vector = chatlists.ExportedInvites; + +chatlists.chatlistInviteAlready#fa87f659 filter_id:int missing_peers:Vector already_peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; +chatlists.chatlistInvite#f10ece2f flags:# title_noanimate:flags.1?true title:TextWithEntities emoticon:flags.0?string peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistUpdates#93bd878d missing_peers:Vector chats:Vector users:Vector = chatlists.ChatlistUpdates; + +bots.botInfo#e8a775b0 name:string about:string description:string = bots.BotInfo; + +messagePeerVote#b6cc2d5c peer:Peer option:bytes date:int = MessagePeerVote; +messagePeerVoteInputOption#74cda504 peer:Peer date:int = MessagePeerVote; +messagePeerVoteMultiple#4628f6e6 peer:Peer options:Vector date:int = MessagePeerVote; + +storyViews#8d595cd6 flags:# has_viewers:flags.1?true views_count:int forwards_count:flags.2?int reactions:flags.3?Vector reactions_count:flags.4?int recent_viewers:flags.0?Vector = StoryViews; + +storyItemDeleted#51e6ee4f id:int = StoryItem; +storyItemSkipped#ffadc913 flags:# close_friends:flags.8?true live:flags.9?true id:int date:int expire_date:int = StoryItem; +storyItem#edf164f1 flags:# pinned:flags.5?true public:flags.7?true close_friends:flags.8?true min:flags.9?true noforwards:flags.10?true edited:flags.11?true contacts:flags.12?true selected_contacts:flags.13?true out:flags.16?true id:int date:int from_id:flags.18?Peer fwd_from:flags.17?StoryFwdHeader expire_date:int caption:flags.0?string entities:flags.1?Vector media:MessageMedia media_areas:flags.14?Vector privacy:flags.2?Vector views:flags.3?StoryViews sent_reaction:flags.15?Reaction albums:flags.19?Vector = StoryItem; + +stories.allStoriesNotModified#1158fe3e flags:# state:string stealth_mode:StoriesStealthMode = stories.AllStories; +stories.allStories#6efc5e81 flags:# has_more:flags.0?true count:int state:string peer_stories:Vector chats:Vector users:Vector stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.stories#63c3dd0a flags:# count:int stories:Vector pinned_to_top:flags.0?Vector chats:Vector users:Vector = stories.Stories; + +storyView#b0bdeac5 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true user_id:long date:int reaction:flags.2?Reaction = StoryView; +storyViewPublicForward#9083670b flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true message:Message = StoryView; +storyViewPublicRepost#bd74cf49 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer story:StoryItem = StoryView; + +stories.storyViewsList#59d78fc5 flags:# count:int views_count:int forwards_count:int reactions_count:int views:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryViewsList; + +stories.storyViews#de9eed1d views:Vector users:Vector = stories.StoryViews; + +inputReplyToMessage#869fbe10 flags:# reply_to_msg_id:int top_msg_id:flags.0?int reply_to_peer_id:flags.1?InputPeer quote_text:flags.2?string quote_entities:flags.3?Vector quote_offset:flags.4?int monoforum_peer_id:flags.5?InputPeer todo_item_id:flags.6?int = InputReplyTo; +inputReplyToStory#5881323a peer:InputPeer story_id:int = InputReplyTo; +inputReplyToMonoForum#69d66c45 monoforum_peer_id:InputPeer = InputReplyTo; + +exportedStoryLink#3fc9053b link:string = ExportedStoryLink; + +storiesStealthMode#712e27fd flags:# active_until_date:flags.0?int cooldown_until_date:flags.1?int = StoriesStealthMode; + +mediaAreaCoordinates#cfc9e002 flags:# x:double y:double w:double h:double rotation:double radius:flags.0?double = MediaAreaCoordinates; + +mediaAreaVenue#be82db9c coordinates:MediaAreaCoordinates geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MediaArea; +inputMediaAreaVenue#b282217f coordinates:MediaAreaCoordinates query_id:long result_id:string = MediaArea; +mediaAreaGeoPoint#cad5452d flags:# coordinates:MediaAreaCoordinates geo:GeoPoint address:flags.0?GeoPointAddress = MediaArea; +mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; +mediaAreaChannelPost#770416af coordinates:MediaAreaCoordinates channel_id:long msg_id:int = MediaArea; +inputMediaAreaChannelPost#2271f2bf coordinates:MediaAreaCoordinates channel:InputChannel msg_id:int = MediaArea; +mediaAreaUrl#37381085 coordinates:MediaAreaCoordinates url:string = MediaArea; +mediaAreaWeather#49a6549c coordinates:MediaAreaCoordinates emoji:string temperature_c:double color:int = MediaArea; +mediaAreaStarGift#5787686d coordinates:MediaAreaCoordinates slug:string = MediaArea; + +peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; + +stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; + +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; + +premiumGiftCodeOption#257e962b flags:# users:int months:int store_product:flags.0?string store_quantity:flags.1?int currency:string amount:long = PremiumGiftCodeOption; + +payments.checkedGiftCode#eb983f8f flags:# via_giveaway:flags.2?true from_id:flags.4?Peer giveaway_msg_id:flags.3?int to_id:flags.0?long date:int days:int used_date:flags.1?int chats:Vector users:Vector = payments.CheckedGiftCode; + +payments.giveawayInfo#4367daa0 flags:# participating:flags.0?true preparing_results:flags.3?true start_date:int joined_too_early_date:flags.1?int admin_disallowed_chat_id:flags.2?long disallowed_country:flags.4?string = payments.GiveawayInfo; +payments.giveawayInfoResults#e175e66f flags:# winner:flags.0?true refunded:flags.1?true start_date:int gift_code_slug:flags.3?string stars_prize:flags.4?long finish_date:int winners_count:int activated_count:flags.2?int = payments.GiveawayInfo; + +prepaidGiveaway#b2539d54 id:long months:int quantity:int date:int = PrepaidGiveaway; +prepaidStarsGiveaway#9a9d77e0 id:long stars:long quantity:int boosts:int date:int = PrepaidGiveaway; + +boost#4b3e14d6 flags:# gift:flags.1?true giveaway:flags.2?true unclaimed:flags.3?true id:string user_id:flags.0?long giveaway_msg_id:flags.2?int date:int expires:int used_gift_slug:flags.4?string multiplier:flags.5?int stars:flags.6?long = Boost; + +premium.boostsList#86f8613c flags:# count:int boosts:Vector next_offset:flags.0?string users:Vector = premium.BoostsList; + +myBoost#c448415c flags:# slot:int peer:flags.0?Peer date:int expires:int cooldown_until_date:flags.1?int = MyBoost; + +premium.myBoosts#9ae228e2 my_boosts:Vector chats:Vector users:Vector = premium.MyBoosts; + +premium.boostsStatus#4959427a flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int gift_boosts:flags.4?int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string prepaid_giveaways:flags.3?Vector my_boost_slots:flags.2?Vector = premium.BoostsStatus; + +storyFwdHeader#b826e150 flags:# modified:flags.3?true from:flags.0?Peer from_name:flags.1?string story_id:flags.2?int = StoryFwdHeader; + +postInteractionCountersMessage#e7058e7f msg_id:int views:int forwards:int reactions:int = PostInteractionCounters; +postInteractionCountersStory#8a480e27 story_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +stats.storyStats#50cd067c views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.StoryStats; + +publicForwardMessage#1f2bf4a message:Message = PublicForward; +publicForwardStory#edf3add0 peer:Peer story:StoryItem = PublicForward; + +stats.publicForwards#93037e20 flags:# count:int forwards:Vector next_offset:flags.0?string chats:Vector users:Vector = stats.PublicForwards; + +peerColor#b54b5acf flags:# color:flags.0?int background_emoji_id:flags.1?long = PeerColor; +peerColorCollectible#b9c0639a flags:# collectible_id:long gift_emoji_id:long background_emoji_id:long accent_color:int colors:Vector dark_accent_color:flags.0?int dark_colors:flags.1?Vector = PeerColor; +inputPeerColorCollectible#b8ea86a9 collectible_id:long = PeerColor; + +help.peerColorSet#26219a58 colors:Vector = help.PeerColorSet; +help.peerColorProfileSet#767d61eb palette_colors:Vector bg_colors:Vector story_colors:Vector = help.PeerColorSet; + +help.peerColorOption#adec6ebe flags:# hidden:flags.0?true color_id:int colors:flags.1?help.PeerColorSet dark_colors:flags.2?help.PeerColorSet channel_min_level:flags.3?int group_min_level:flags.4?int = help.PeerColorOption; + +help.peerColorsNotModified#2ba1f5ce = help.PeerColors; +help.peerColors#f8ed08 hash:int colors:Vector = help.PeerColors; + +storyReaction#6090d6d5 peer_id:Peer date:int reaction:Reaction = StoryReaction; +storyReactionPublicForward#bbab2643 message:Message = StoryReaction; +storyReactionPublicRepost#cfcd0f13 peer_id:Peer story:StoryItem = StoryReaction; + +stories.storyReactionsList#aa5f789c flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryReactionsList; + +savedDialog#bd87cb6c flags:# pinned:flags.2?true peer:Peer top_message:int = SavedDialog; +monoForumDialog#64407ea7 flags:# unread_mark:flags.3?true nopaid_messages_exception:flags.4?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_reactions_count:int draft:flags.1?DraftMessage = SavedDialog; + +messages.savedDialogs#f83ae221 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsSlice#44ba9dd9 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsNotModified#c01f6fe8 count:int = messages.SavedDialogs; + +savedReactionTag#cb6ff828 flags:# reaction:Reaction title:flags.0?string count:int = SavedReactionTag; + +messages.savedReactionTagsNotModified#889b59ef = messages.SavedReactionTags; +messages.savedReactionTags#3259950a tags:Vector hash:long = messages.SavedReactionTags; + +outboxReadDate#3bb842ac date:int = OutboxReadDate; + +smsjobs.eligibleToJoin#dc8b44cf terms_url:string monthly_sent_sms:int = smsjobs.EligibilityToJoin; + +smsjobs.status#2aee9191 flags:# allow_international:flags.0?true recent_sent:int recent_since:int recent_remains:int total_sent:int total_since:int last_gift_slug:flags.1?string terms_url:string = smsjobs.Status; + +smsJob#e6a1eeb8 job_id:string phone_number:string text:string = SmsJob; + +businessWeeklyOpen#120b1ab9 start_minute:int end_minute:int = BusinessWeeklyOpen; + +businessWorkHours#8c92b098 flags:# open_now:flags.0?true timezone_id:string weekly_open:Vector = BusinessWorkHours; + +businessLocation#ac5c1af7 flags:# geo_point:flags.0?GeoPoint address:string = BusinessLocation; + +inputBusinessRecipients#6f8b32aa flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = InputBusinessRecipients; + +businessRecipients#21108ff7 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = BusinessRecipients; + +businessAwayMessageScheduleAlways#c9b9e2b9 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleOutsideWorkHours#c3f2f501 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleCustom#cc4d9ecc start_date:int end_date:int = BusinessAwayMessageSchedule; + +inputBusinessGreetingMessage#194cb3b shortcut_id:int recipients:InputBusinessRecipients no_activity_days:int = InputBusinessGreetingMessage; + +businessGreetingMessage#e519abab shortcut_id:int recipients:BusinessRecipients no_activity_days:int = BusinessGreetingMessage; + +inputBusinessAwayMessage#832175e0 flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:InputBusinessRecipients = InputBusinessAwayMessage; + +businessAwayMessage#ef156a5c flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:BusinessRecipients = BusinessAwayMessage; + +timezone#ff9289f5 id:string name:string utc_offset:int = Timezone; + +help.timezonesListNotModified#970708cc = help.TimezonesList; +help.timezonesList#7b74ed71 timezones:Vector hash:int = help.TimezonesList; + +quickReply#697102b shortcut_id:int shortcut:string top_message:int count:int = QuickReply; + +inputQuickReplyShortcut#24596d41 shortcut:string = InputQuickReplyShortcut; +inputQuickReplyShortcutId#1190cf1 shortcut_id:int = InputQuickReplyShortcut; + +messages.quickReplies#c68d6695 quick_replies:Vector messages:Vector chats:Vector users:Vector = messages.QuickReplies; +messages.quickRepliesNotModified#5f91eb5b = messages.QuickReplies; + +connectedBot#cd64636c flags:# bot_id:long recipients:BusinessBotRecipients rights:BusinessBotRights = ConnectedBot; + +account.connectedBots#17d7f87b connected_bots:Vector users:Vector = account.ConnectedBots; + +messages.dialogFilters#2ad93719 flags:# tags_enabled:flags.0?true filters:Vector = messages.DialogFilters; + +birthday#6c8e1e06 flags:# day:int month:int year:flags.0?int = Birthday; + +botBusinessConnection#8f34b2f5 flags:# disabled:flags.1?true connection_id:string user_id:long dc_id:int date:int rights:flags.2?BusinessBotRights = BotBusinessConnection; + +inputBusinessIntro#9c469cd flags:# title:string description:string sticker:flags.0?InputDocument = InputBusinessIntro; + +businessIntro#5a0a066d flags:# title:string description:string sticker:flags.0?Document = BusinessIntro; + +messages.myStickers#faff629d count:int sets:Vector = messages.MyStickers; + +inputCollectibleUsername#e39460a9 username:string = InputCollectible; +inputCollectiblePhone#a2e214a4 phone:string = InputCollectible; + +fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo; + +inputBusinessBotRecipients#c4e5921e flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = InputBusinessBotRecipients; + +businessBotRecipients#b88cf373 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = BusinessBotRecipients; + +contactBirthday#1d998733 contact_id:long birthday:Birthday = ContactBirthday; + +contacts.contactBirthdays#114ff30d contacts:Vector users:Vector = contacts.ContactBirthdays; + +missingInvitee#628c9224 flags:# premium_would_allow_invite:flags.0?true premium_required_for_pm:flags.1?true user_id:long = MissingInvitee; + +messages.invitedUsers#7f5defa6 updates:Updates missing_invitees:Vector = messages.InvitedUsers; + +inputBusinessChatLink#11679fa7 flags:# message:string entities:flags.0?Vector title:flags.1?string = InputBusinessChatLink; + +businessChatLink#b4ae666f flags:# link:string message:string entities:flags.0?Vector title:flags.1?string views:int = BusinessChatLink; + +account.businessChatLinks#ec43a2d1 links:Vector chats:Vector users:Vector = account.BusinessChatLinks; + +account.resolvedBusinessChatLinks#9a23af21 flags:# peer:Peer message:string entities:flags.0?Vector chats:Vector users:Vector = account.ResolvedBusinessChatLinks; + +requestedPeerUser#d62ff46a flags:# user_id:long first_name:flags.0?string last_name:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChat#7307544f flags:# chat_id:long title:flags.0?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChannel#8ba403e4 flags:# channel_id:long title:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +sponsoredMessageReportOption#430d3150 text:string option:bytes = SponsoredMessageReportOption; + +channels.sponsoredMessageReportResultChooseOption#846f9e42 title:string options:Vector = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultAdsHidden#3e3bcf2f = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultReported#ad798849 = channels.SponsoredMessageReportResult; + +reactionNotificationsFromContacts#bac3a61a = ReactionNotificationsFrom; +reactionNotificationsFromAll#4b9e22a0 = ReactionNotificationsFrom; + +reactionsNotifySettings#56e34970 flags:# messages_notify_from:flags.0?ReactionNotificationsFrom stories_notify_from:flags.1?ReactionNotificationsFrom sound:NotificationSound show_previews:Bool = ReactionsNotifySettings; + +availableEffect#93c3e27e flags:# premium_required:flags.2?true id:long emoticon:string static_icon_id:flags.0?long effect_sticker_id:long effect_animation_id:flags.1?long = AvailableEffect; + +messages.availableEffectsNotModified#d1ed9a5b = messages.AvailableEffects; +messages.availableEffects#bddb616e hash:int effects:Vector documents:Vector = messages.AvailableEffects; + +factCheck#b89bfccf flags:# need_check:flags.0?true country:flags.1?string text:flags.1?TextWithEntities hash:long = FactCheck; + +starsTransactionPeerUnsupported#95f2bfe4 = StarsTransactionPeer; +starsTransactionPeerAppStore#b457b375 = StarsTransactionPeer; +starsTransactionPeerPlayMarket#7b560a0b = StarsTransactionPeer; +starsTransactionPeerPremiumBot#250dbaf8 = StarsTransactionPeer; +starsTransactionPeerFragment#e92fd902 = StarsTransactionPeer; +starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; +starsTransactionPeerAds#60682812 = StarsTransactionPeer; +starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; + +starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; + +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true stargift_prepaid_upgrade:flags.25?true stargift_drop_original_details:flags.26?true phonegroup_message:flags.27?true stargift_auction_bid:flags.28?true offer:flags.29?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; + +payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; + +foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; + +stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; + +geoPointAddress#de4c5d93 flags:# country_iso2:string state:flags.0?string city:flags.1?string street:flags.2?string = GeoPointAddress; + +starsRevenueStatus#febe5491 flags:# withdrawal_enabled:flags.0?true current_balance:StarsAmount available_balance:StarsAmount overall_revenue:StarsAmount next_withdrawal_at:flags.1?int = StarsRevenueStatus; + +payments.starsRevenueStats#6c207376 flags:# top_hours_graph:flags.0?StatsGraph revenue_graph:StatsGraph status:StarsRevenueStatus usd_rate:double = payments.StarsRevenueStats; + +payments.starsRevenueWithdrawalUrl#1dab80b7 url:string = payments.StarsRevenueWithdrawalUrl; + +payments.starsRevenueAdsAccountUrl#394e7f21 url:string = payments.StarsRevenueAdsAccountUrl; + +inputStarsTransaction#206ae6d1 flags:# refund:flags.0?true id:string = InputStarsTransaction; + +starsGiftOption#5e0589f1 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsGiftOption; + +bots.popularAppBots#1991b13b flags:# next_offset:flags.0?string users:Vector = bots.PopularAppBots; + +botPreviewMedia#23e91ba3 date:int media:MessageMedia = BotPreviewMedia; + +bots.previewInfo#ca71d64 media:Vector lang_codes:Vector = bots.PreviewInfo; + +starsSubscriptionPricing#5416d58 period:int amount:long = StarsSubscriptionPricing; + +starsSubscription#2e6eab1a flags:# canceled:flags.0?true can_refulfill:flags.1?true missing_balance:flags.2?true bot_canceled:flags.7?true id:string peer:Peer until_date:int pricing:StarsSubscriptionPricing chat_invite_hash:flags.3?string title:flags.4?string photo:flags.5?WebDocument invoice_slug:flags.6?string = StarsSubscription; + +messageReactor#4ba3a95a flags:# top:flags.0?true my:flags.1?true anonymous:flags.2?true peer_id:flags.3?Peer count:int = MessageReactor; + +starsGiveawayOption#94ce852a flags:# extended:flags.0?true default:flags.1?true stars:long yearly_boosts:int store_product:flags.2?string currency:string amount:long winners:Vector = StarsGiveawayOption; + +starsGiveawayWinnersOption#54236209 flags:# default:flags.0?true users:int per_user_stars:long = StarsGiveawayWinnersOption; + +starGift#313a9547 flags:# limited:flags.0?true sold_out:flags.1?true birthday:flags.2?true require_premium:flags.7?true limited_per_user:flags.8?true peer_color_available:flags.10?true auction:flags.11?true id:long sticker:Document stars:long availability_remains:flags.0?int availability_total:flags.0?int availability_resale:flags.4?long convert_stars:long first_sale_date:flags.1?int last_sale_date:flags.1?int upgrade_stars:flags.3?long resell_min_stars:flags.4?long title:flags.5?string released_by:flags.6?Peer per_user_total:flags.8?int per_user_remains:flags.8?int locked_until_date:flags.9?int auction_slug:flags.11?string gifts_per_round:flags.11?int auction_start_date:flags.11?int upgrade_variants:flags.12?int background:flags.13?StarGiftBackground = StarGift; +starGiftUnique#85f0a9cd flags:# require_premium:flags.6?true resale_ton_only:flags.7?true theme_available:flags.9?true burned:flags.14?true crafted:flags.15?true id:long gift_id:long title:string slug:string num:int owner_id:flags.0?Peer owner_name:flags.1?string owner_address:flags.2?string attributes:Vector availability_issued:int availability_total:int gift_address:flags.3?string resell_amount:flags.4?Vector released_by:flags.5?Peer value_amount:flags.8?long value_currency:flags.8?string value_usd_amount:flags.8?long theme_peer:flags.10?Peer peer_color:flags.11?PeerColor host_id:flags.12?Peer offer_min_stars:flags.13?int craft_chance_permille:flags.16?int = StarGift; + +payments.starGiftsNotModified#a388a368 = payments.StarGifts; +payments.starGifts#2ed82995 hash:int gifts:Vector chats:Vector users:Vector = payments.StarGifts; + +messageReportOption#7903e3d9 text:string option:bytes = MessageReportOption; + +reportResultChooseOption#f0e4e0b6 title:string options:Vector = ReportResult; +reportResultAddComment#6f09ac31 flags:# optional:flags.0?true option:bytes = ReportResult; +reportResultReported#8db33c4b = ReportResult; + +messages.botPreparedInlineMessage#8ecf0511 id:string expire_date:int = messages.BotPreparedInlineMessage; + +messages.preparedInlineMessage#ff57708d query_id:long result:BotInlineResult peer_types:Vector cache_time:int users:Vector = messages.PreparedInlineMessage; + +botAppSettings#c99b1950 flags:# placeholder_path:flags.0?bytes background_color:flags.1?int background_dark_color:flags.2?int header_color:flags.3?int header_dark_color:flags.4?int = BotAppSettings; + +starRefProgram#dd0c66f2 flags:# bot_id:long commission_permille:int duration_months:flags.0?int end_date:flags.1?int daily_revenue_per_user:flags.2?StarsAmount = StarRefProgram; + +connectedBotStarRef#19a13f71 flags:# revoked:flags.1?true url:string date:int bot_id:long commission_permille:int duration_months:flags.0?int participants:long revenue:long = ConnectedBotStarRef; + +payments.connectedStarRefBots#98d5ea1d count:int connected_bots:Vector users:Vector = payments.ConnectedStarRefBots; + +payments.suggestedStarRefBots#b4d5d859 flags:# count:int suggested_bots:Vector users:Vector next_offset:flags.0?string = payments.SuggestedStarRefBots; + +starsAmount#bbb6b4a3 amount:long nanos:int = StarsAmount; +starsTonAmount#74aee3e0 amount:long = StarsAmount; + +messages.foundStickersNotModified#6010c534 flags:# next_offset:flags.0?int = messages.FoundStickers; +messages.foundStickers#82c9e290 flags:# next_offset:flags.0?int hash:long stickers:Vector = messages.FoundStickers; + +botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string = BotVerifierSettings; + +botVerification#f93cd45c bot_id:long icon:long description:string = BotVerification; + +starGiftAttributeModel#565251e2 flags:# crafted:flags.0?true name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributePattern#4e7085ea name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeBackdrop#9f2504e4 name:string backdrop_id:int center_color:int edge_color:int pattern_color:int text_color:int rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeOriginalDetails#e0bff26c flags:# sender_id:flags.0?Peer recipient_id:Peer date:int message:flags.1?TextWithEntities = StarGiftAttribute; + +payments.starGiftUpgradePreview#3de1dfed sample_attributes:Vector prices:Vector next_prices:Vector = payments.StarGiftUpgradePreview; + +users.users#62d706b8 users:Vector = users.Users; +users.usersSlice#315a4974 count:int users:Vector = users.Users; + +payments.uniqueStarGift#416c56e8 gift:StarGift chats:Vector users:Vector = payments.UniqueStarGift; + +messages.webPagePreview#8c9a88ac media:MessageMedia chats:Vector users:Vector = messages.WebPagePreview; + +savedStarGift#41df43fc flags:# name_hidden:flags.0?true unsaved:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true pinned_to_top:flags.12?true upgrade_separate:flags.17?true from_id:flags.1?Peer date:int gift:StarGift message:flags.2?TextWithEntities msg_id:flags.3?int saved_id:flags.11?long convert_stars:flags.4?long upgrade_stars:flags.6?long can_export_at:flags.7?int transfer_stars:flags.8?long can_transfer_at:flags.13?int can_resell_at:flags.14?int collection_id:flags.15?Vector prepaid_upgrade_hash:flags.16?string drop_original_details_stars:flags.18?long gift_num:flags.19?int can_craft_at:flags.20?int = SavedStarGift; + +payments.savedStarGifts#95f389b1 flags:# count:int chat_notifications_enabled:flags.1?Bool gifts:Vector next_offset:flags.0?string chats:Vector users:Vector = payments.SavedStarGifts; + +inputSavedStarGiftUser#69279795 msg_id:int = InputSavedStarGift; +inputSavedStarGiftChat#f101aa7f peer:InputPeer saved_id:long = InputSavedStarGift; +inputSavedStarGiftSlug#2085c238 slug:string = InputSavedStarGift; + +payments.starGiftWithdrawalUrl#84aa3a9c url:string = payments.StarGiftWithdrawalUrl; + +paidReactionPrivacyDefault#206ad49e = PaidReactionPrivacy; +paidReactionPrivacyAnonymous#1f0c1ad9 = PaidReactionPrivacy; +paidReactionPrivacyPeer#dc6cfcf0 peer:InputPeer = PaidReactionPrivacy; + +account.paidMessagesRevenue#1e109708 stars_amount:long = account.PaidMessagesRevenue; + +requirementToContactEmpty#50a9839 = RequirementToContact; +requirementToContactPremium#e581e4e9 = RequirementToContact; +requirementToContactPaidMessages#b4f67e93 stars_amount:long = RequirementToContact; + +businessBotRights#a0624cf7 flags:# reply:flags.0?true read_messages:flags.1?true delete_sent_messages:flags.2?true delete_received_messages:flags.3?true edit_name:flags.4?true edit_bio:flags.5?true edit_profile_photo:flags.6?true edit_username:flags.7?true view_gifts:flags.8?true sell_gifts:flags.9?true change_gift_settings:flags.10?true transfer_and_upgrade_gifts:flags.11?true transfer_stars:flags.12?true manage_stories:flags.13?true = BusinessBotRights; + +disallowedGiftsSettings#71f276c4 flags:# disallow_unlimited_stargifts:flags.0?true disallow_limited_stargifts:flags.1?true disallow_unique_stargifts:flags.2?true disallow_premium_gifts:flags.3?true disallow_stargifts_from_channels:flags.4?true = DisallowedGiftsSettings; + +sponsoredPeer#c69708d3 flags:# random_id:bytes peer:Peer sponsor_info:flags.0?string additional_info:flags.1?string = SponsoredPeer; + +contacts.sponsoredPeersEmpty#ea32b4b1 = contacts.SponsoredPeers; +contacts.sponsoredPeers#eb032884 peers:Vector chats:Vector users:Vector = contacts.SponsoredPeers; + +starGiftAttributeIdModel#48aaae3c document_id:long = StarGiftAttributeId; +starGiftAttributeIdPattern#4a162433 document_id:long = StarGiftAttributeId; +starGiftAttributeIdBackdrop#1f01c757 backdrop_id:int = StarGiftAttributeId; + +starGiftAttributeCounter#2eb1b658 attribute:StarGiftAttributeId count:int = StarGiftAttributeCounter; + +payments.resaleStarGifts#947a12df flags:# count:int gifts:Vector next_offset:flags.0?string attributes:flags.1?Vector attributes_hash:flags.1?long chats:Vector counters:flags.2?Vector users:Vector = payments.ResaleStarGifts; + +stories.canSendStoryCount#c387c04e count_remains:int = stories.CanSendStoryCount; + +pendingSuggestion#e7e82e12 suggestion:string title:TextWithEntities description:TextWithEntities url:string = PendingSuggestion; + +todoItem#cba9a52f id:int title:TextWithEntities = TodoItem; + +todoList#49b92a26 flags:# others_can_append:flags.0?true others_can_complete:flags.1?true title:TextWithEntities list:Vector = TodoList; + +todoCompletion#221bb5e4 id:int completed_by:Peer date:int = TodoCompletion; + +suggestedPost#e8e37e5 flags:# accepted:flags.1?true rejected:flags.2?true price:flags.3?StarsAmount schedule_date:flags.0?int = SuggestedPost; + +starsRating#1b0e4f07 flags:# level:int current_level_stars:long stars:long next_level_stars:flags.0?long = StarsRating; + +starGiftCollection#9d6b13b0 flags:# collection_id:int title:string icon:flags.0?Document gifts_count:int hash:long = StarGiftCollection; + +payments.starGiftCollectionsNotModified#a0ba4f17 = payments.StarGiftCollections; +payments.starGiftCollections#8a2932f3 collections:Vector = payments.StarGiftCollections; + +storyAlbum#9325705a flags:# album_id:int title:string icon_photo:flags.0?Photo icon_video:flags.1?Document = StoryAlbum; + +stories.albumsNotModified#564edaeb = stories.Albums; +stories.albums#c3987a3a hash:long albums:Vector = stories.Albums; + +searchPostsFlood#3e0b5b6a flags:# query_is_free:flags.0?true total_daily:int remains:int wait_till:flags.1?int stars_amount:long = SearchPostsFlood; + +payments.uniqueStarGiftValueInfo#512fe446 flags:# last_sale_on_fragment:flags.1?true value_is_average:flags.6?true currency:string value:long initial_sale_date:int initial_sale_stars:long initial_sale_price:long last_sale_date:flags.0?int last_sale_price:flags.0?long floor_price:flags.2?long average_price:flags.3?long listed_count:flags.4?int fragment_listed_count:flags.5?int fragment_listed_url:flags.5?string = payments.UniqueStarGiftValueInfo; + +profileTabPosts#b98cd696 = ProfileTab; +profileTabGifts#4d4bd46a = ProfileTab; +profileTabMedia#72c64955 = ProfileTab; +profileTabFiles#ab339c00 = ProfileTab; +profileTabMusic#9f27d26e = ProfileTab; +profileTabVoice#e477092e = ProfileTab; +profileTabLinks#d3656499 = ProfileTab; +profileTabGifs#a2c0f695 = ProfileTab; + +users.savedMusicNotModified#e3878aa4 count:int = users.SavedMusic; +users.savedMusic#34a2f297 count:int documents:Vector = users.SavedMusic; + +account.savedMusicIdsNotModified#4fc81d6e = account.SavedMusicIds; +account.savedMusicIds#998d6636 ids:Vector = account.SavedMusicIds; + +payments.checkCanSendGiftResultOk#374fa7ad = payments.CheckCanSendGiftResult; +payments.checkCanSendGiftResultFail#d5e58274 reason:TextWithEntities = payments.CheckCanSendGiftResult; + +inputChatThemeEmpty#83268483 = InputChatTheme; +inputChatTheme#c93de95c emoticon:string = InputChatTheme; +inputChatThemeUniqueGift#87e5dfe4 slug:string = InputChatTheme; + +starGiftUpgradePrice#99ea331d date:int upgrade_stars:long = StarGiftUpgradePrice; + +groupCallMessage#1a8afc7e flags:# from_admin:flags.1?true id:int from_id:Peer date:int message:TextWithEntities paid_message_stars:flags.0?long = GroupCallMessage; + +groupCallDonor#ee430c85 flags:# top:flags.0?true my:flags.1?true peer_id:flags.3?Peer stars:long = GroupCallDonor; + +phone.groupCallStars#9d1dbd26 total_stars:long top_donors:Vector chats:Vector users:Vector = phone.GroupCallStars; + +recentStory#711d692d flags:# live:flags.0?true max_id:flags.1?int = RecentStory; + +auctionBidLevel#310240cc pos:int amount:long date:int = AuctionBidLevel; + +starGiftAuctionStateNotModified#fe333952 = StarGiftAuctionState; +starGiftAuctionState#771a4e66 version:int start_date:int end_date:int min_bid_amount:long bid_levels:Vector top_bidders:Vector next_round_at:int last_gift_num:int gifts_left:int current_round:int total_rounds:int rounds:Vector = StarGiftAuctionState; +starGiftAuctionStateFinished#972dabbf flags:# start_date:int end_date:int average_price:long listed_count:flags.0?int fragment_listed_count:flags.1?int fragment_listed_url:flags.1?string = StarGiftAuctionState; + +starGiftAuctionUserState#2eeed1c4 flags:# returned:flags.1?true bid_amount:flags.0?long bid_date:flags.0?int min_bid_amount:flags.0?long bid_peer:flags.0?Peer acquired_count:int = StarGiftAuctionUserState; + +payments.starGiftAuctionState#6b39f4ec gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState timeout:int users:Vector chats:Vector = payments.StarGiftAuctionState; + +starGiftAuctionAcquiredGift#42b00348 flags:# name_hidden:flags.0?true peer:Peer date:int bid_amount:long round:int pos:int message:flags.1?TextWithEntities gift_num:flags.2?int = StarGiftAuctionAcquiredGift; + +payments.starGiftAuctionAcquiredGifts#7d5bd1f0 gifts:Vector users:Vector chats:Vector = payments.StarGiftAuctionAcquiredGifts; + +starGiftActiveAuctionState#d31bc45d gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState = StarGiftActiveAuctionState; + +payments.starGiftActiveAuctionsNotModified#db33dad0 = payments.StarGiftActiveAuctions; +payments.starGiftActiveAuctions#aef6abbc auctions:Vector users:Vector chats:Vector = payments.StarGiftActiveAuctions; + +inputStarGiftAuction#2e16c98 gift_id:long = InputStarGiftAuction; +inputStarGiftAuctionSlug#7ab58308 slug:string = InputStarGiftAuction; + +passkey#98613ebf flags:# id:string name:string date:int software_emoji_id:flags.0?long last_usage_date:flags.1?int = Passkey; + +account.passkeys#f8e0aa1c passkeys:Vector = account.Passkeys; + +account.passkeyRegistrationOptions#e16b5ce1 options:DataJSON = account.PasskeyRegistrationOptions; + +auth.passkeyLoginOptions#e2037789 options:DataJSON = auth.PasskeyLoginOptions; + +inputPasskeyResponseRegister#3e63935c client_data:DataJSON attestation_data:bytes = InputPasskeyResponse; +inputPasskeyResponseLogin#c31fc14a client_data:DataJSON authenticator_data:bytes signature:bytes user_handle:string = InputPasskeyResponse; + +inputPasskeyCredentialPublicKey#3c27b78f id:string raw_id:string response:InputPasskeyResponse = InputPasskeyCredential; +inputPasskeyCredentialFirebasePNV#5b1ccb28 pnv_token:string = InputPasskeyCredential; + +starGiftBackground#aff56398 center_color:int edge_color:int text_color:int = StarGiftBackground; + +starGiftAuctionRound#3aae0528 num:int duration:int = StarGiftAuctionRound; +starGiftAuctionRoundExtendable#aa021e5 num:int duration:int extend_top:int extend_window:int = StarGiftAuctionRound; + +payments.starGiftUpgradeAttributes#46c6e36f attributes:Vector = payments.StarGiftUpgradeAttributes; + +messages.emojiGameOutcome#da2ad647 seed:bytes stake_ton_amount:long ton_amount:long = messages.EmojiGameOutcome; + +messages.emojiGameUnavailable#59e65335 = messages.EmojiGameInfo; +messages.emojiGameDiceInfo#44e56023 flags:# game_hash:string prev_stake:long current_streak:int params:Vector plays_left:flags.0?int = messages.EmojiGameInfo; + +starGiftAttributeRarity#36437737 permille:int = StarGiftAttributeRarity; +starGiftAttributeRarityUncommon#dbce6389 = StarGiftAttributeRarity; +starGiftAttributeRarityRare#f08d516b = StarGiftAttributeRarity; +starGiftAttributeRarityEpic#78fbf3a8 = StarGiftAttributeRarity; +starGiftAttributeRarityLegendary#cef7e7a8 = StarGiftAttributeRarity; + +keyboardButtonStyle#4fdd3430 flags:# bg_primary:flags.0?true bg_danger:flags.1?true bg_success:flags.2?true icon:flags.3?long = KeyboardButtonStyle; + +---functions--- + +invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; +invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; +invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; +invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; +invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; +invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; +invokeWithBusinessConnection#dd289f8e {X:Type} connection_id:string query:!X = X; +invokeWithGooglePlayIntegrity#1df92984 {X:Type} nonce:string token:string query:!X = X; +invokeWithApnsSecret#0dae54f8 {X:Type} nonce:string secret:string query:!X = X; +invokeWithReCaptcha#adbb0f94 {X:Type} token:string query:!X = X; + +auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; +auth.signUp#aac7b717 flags:# no_joined_notifications:flags.0?true phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +auth.signIn#8d52a951 flags:# phone_number:string phone_code_hash:string phone_code:flags.0?string email_verification:flags.1?EmailVerification = auth.Authorization; +auth.logOut#3e72ba19 = auth.LoggedOut; +auth.resetAuthorizations#9fab0d1a = Bool; +auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; +auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; +auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; +auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; +auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; +auth.resendCode#cae47523 flags:# phone_number:string phone_code_hash:string reason:flags.0?string = auth.SentCode; +auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; +auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; +auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; +auth.acceptLoginToken#e894ad4d token:bytes = Authorization; +auth.checkRecoveryPassword#d36bf79 code:string = Bool; +auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; +auth.requestFirebaseSms#8e39261e flags:# phone_number:string phone_code_hash:string safety_net_token:flags.0?string play_integrity_token:flags.2?string ios_push_secret:flags.1?string = Bool; +auth.resetLoginEmail#7e960193 phone_number:string phone_code_hash:string = auth.SentCode; +auth.reportMissingCode#cb9deff6 phone_number:string phone_code_hash:string mnc:string = Bool; +auth.checkPaidAuth#56e59f9c phone_number:string phone_code_hash:string form_id:long = auth.SentCode; +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; + +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; +account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; +account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; +account.resetNotifySettings#db7e1747 = Bool; +account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; +account.updateStatus#6628562c offline:Bool = Bool; +account.getWallPapers#7967d36 hash:long = account.WallPapers; +account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; +account.checkUsername#2714d86c username:string = Bool; +account.updateUsername#3e0bdd7c username:string = User; +account.getPrivacy#dadbc950 key:InputPrivacyKey = account.PrivacyRules; +account.setPrivacy#c9f81ce8 key:InputPrivacyKey rules:Vector = account.PrivacyRules; +account.deleteAccount#a2c0cf74 flags:# reason:string password:flags.0?InputCheckPasswordSRP = Bool; +account.getAccountTTL#8fc711d = AccountDaysTTL; +account.setAccountTTL#2442485e ttl:AccountDaysTTL = Bool; +account.sendChangePhoneCode#82574ae5 phone_number:string settings:CodeSettings = auth.SentCode; +account.changePhone#70c32edb phone_number:string phone_code_hash:string phone_code:string = User; +account.updateDeviceLocked#38df3532 period:int = Bool; +account.getAuthorizations#e320c158 = account.Authorizations; +account.resetAuthorization#df77f3bc hash:long = Bool; +account.getPassword#548a30f5 = account.Password; +account.getPasswordSettings#9cd4eaf9 password:InputCheckPasswordSRP = account.PasswordSettings; +account.updatePasswordSettings#a59b102f password:InputCheckPasswordSRP new_settings:account.PasswordInputSettings = Bool; +account.sendConfirmPhoneCode#1b3faa88 hash:string settings:CodeSettings = auth.SentCode; +account.confirmPhone#5f2178c3 phone_code_hash:string phone_code:string = Bool; +account.getTmpPassword#449e0b51 password:InputCheckPasswordSRP period:int = account.TmpPassword; +account.getWebAuthorizations#182e6d6f = account.WebAuthorizations; +account.resetWebAuthorization#2d01b9ef hash:long = Bool; +account.resetWebAuthorizations#682d2594 = Bool; +account.getAllSecureValues#b288bc7d = Vector; +account.getSecureValue#73665bc2 types:Vector = Vector; +account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; +account.deleteSecureValue#b880bc4b types:Vector = Bool; +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; +account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; +account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; +account.sendVerifyEmailCode#98e037bb purpose:EmailVerifyPurpose email:string = account.SentEmailCode; +account.verifyEmail#32da4cf purpose:EmailVerifyPurpose verification:EmailVerification = account.EmailVerified; +account.initTakeoutSession#8ef3eab0 flags:# contacts:flags.0?true message_users:flags.1?true message_chats:flags.2?true message_megagroups:flags.3?true message_channels:flags.4?true files:flags.5?true file_max_size:flags.5?long = account.Takeout; +account.finishTakeoutSession#1d2652ee flags:# success:flags.0?true = Bool; +account.confirmPasswordEmail#8fdf1920 code:string = Bool; +account.resendPasswordEmail#7a7f2a15 = Bool; +account.cancelPasswordEmail#c1cbd5b6 = Bool; +account.getContactSignUpNotification#9f07c728 = Bool; +account.setContactSignUpNotification#cff43f61 silent:Bool = Bool; +account.getNotifyExceptions#53577479 flags:# compare_sound:flags.1?true compare_stories:flags.2?true peer:flags.0?InputNotifyPeer = Updates; +account.getWallPaper#fc8ddbea wallpaper:InputWallPaper = WallPaper; +account.uploadWallPaper#e39a8f03 flags:# for_chat:flags.0?true file:InputFile mime_type:string settings:WallPaperSettings = WallPaper; +account.saveWallPaper#6c5a5b37 wallpaper:InputWallPaper unsave:Bool settings:WallPaperSettings = Bool; +account.installWallPaper#feed5769 wallpaper:InputWallPaper settings:WallPaperSettings = Bool; +account.resetWallPapers#bb3b9804 = Bool; +account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; +account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; +account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; +account.getTheme#3a5869ec format:string theme:InputTheme = Theme; +account.getThemes#7206e458 format:string hash:long = account.Themes; +account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; +account.getContentSettings#8b9b4dae = account.ContentSettings; +account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; +account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; +account.resetPassword#9308ce1b = account.ResetPasswordResult; +account.declinePasswordReset#4c9409f6 = Bool; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# confirmed:flags.3?true hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; +account.getSavedRingtones#e1902288 hash:long = account.SavedRingtones; +account.saveRingtone#3dea5b03 id:InputDocument unsave:Bool = account.SavedRingtone; +account.uploadRingtone#831a83a2 file:InputFile file_name:string mime_type:string = Document; +account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; +account.getDefaultEmojiStatuses#d6753386 hash:long = account.EmojiStatuses; +account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; +account.clearRecentEmojiStatuses#18201aae = Bool; +account.reorderUsernames#ef500eab order:Vector = Bool; +account.toggleUsername#58d6b376 username:string active:Bool = Bool; +account.getDefaultProfilePhotoEmojis#e2750328 hash:long = EmojiList; +account.getDefaultGroupPhotoEmojis#915860ae hash:long = EmojiList; +account.getAutoSaveSettings#adcbbcda = account.AutoSaveSettings; +account.saveAutoSaveSettings#d69b8361 flags:# users:flags.0?true chats:flags.1?true broadcasts:flags.2?true peer:flags.3?InputPeer settings:AutoSaveSettings = Bool; +account.deleteAutoSaveExceptions#53bc0020 = Bool; +account.invalidateSignInCodes#ca8ae8ba codes:Vector = Bool; +account.updateColor#684d214e flags:# for_profile:flags.1?true color:flags.2?PeerColor = Bool; +account.getDefaultBackgroundEmojis#a60ab9ce hash:long = EmojiList; +account.getChannelDefaultEmojiStatuses#7727a7d5 hash:long = account.EmojiStatuses; +account.getChannelRestrictedStatusEmojis#35a9e0d5 hash:long = EmojiList; +account.updateBusinessWorkHours#4b00e066 flags:# business_work_hours:flags.0?BusinessWorkHours = Bool; +account.updateBusinessLocation#9e6b131a flags:# geo_point:flags.1?InputGeoPoint address:flags.0?string = Bool; +account.updateBusinessGreetingMessage#66cdafc4 flags:# message:flags.0?InputBusinessGreetingMessage = Bool; +account.updateBusinessAwayMessage#a26a7fa5 flags:# message:flags.0?InputBusinessAwayMessage = Bool; +account.updateConnectedBot#66a08c7e flags:# deleted:flags.1?true rights:flags.0?BusinessBotRights bot:InputUser recipients:InputBusinessBotRecipients = Updates; +account.getConnectedBots#4ea4c80f = account.ConnectedBots; +account.getBotBusinessConnection#76a86270 connection_id:string = Updates; +account.updateBusinessIntro#a614d034 flags:# intro:flags.0?InputBusinessIntro = Bool; +account.toggleConnectedBotPaused#646e1097 peer:InputPeer paused:Bool = Bool; +account.disablePeerConnectedBot#5e437ed9 peer:InputPeer = Bool; +account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool; +account.createBusinessChatLink#8851e68e link:InputBusinessChatLink = BusinessChatLink; +account.editBusinessChatLink#8c3410af slug:string link:InputBusinessChatLink = BusinessChatLink; +account.deleteBusinessChatLink#60073674 slug:string = Bool; +account.getBusinessChatLinks#6f70dde1 = account.BusinessChatLinks; +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; +account.updatePersonalChannel#d94305e0 channel:InputChannel = Bool; +account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; +account.getReactionsNotifySettings#6dd654c = ReactionsNotifySettings; +account.setReactionsNotifySettings#316ce548 settings:ReactionsNotifySettings = ReactionsNotifySettings; +account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; +account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; +account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; +account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; +account.saveMusic#b26732a9 flags:# unsave:flags.0?true id:InputDocument after_id:flags.1?InputDocument = Bool; +account.getSavedMusicIds#e09d5faf hash:long = account.SavedMusicIds; +account.getUniqueGiftChatThemes#e42ce9c9 offset:string limit:int hash:long = account.ChatThemes; +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; +account.getPasskeys#ea1f0c52 = account.Passkeys; +account.deletePasskey#f5b5563f id:string = Bool; + +users.getUsers#d91a548 id:Vector = Vector; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; +users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; +users.getRequirementsToContact#d89a83a3 id:Vector = Vector; +users.getSavedMusic#788d7fe3 id:InputUser offset:int limit:int hash:long = users.SavedMusic; +users.getSavedMusicByID#7573a4e9 id:InputUser documents:Vector = users.SavedMusic; +users.suggestBirthday#fc533372 id:InputUser birthday:Birthday = Updates; + +contacts.getContactIDs#7adc669d hash:long = Vector; +contacts.getStatuses#c4a353ee = Vector; +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; +contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; +contacts.deleteContacts#96a0e00 id:Vector = Updates; +contacts.deleteByPhones#1013fd9e phones:Vector = Bool; +contacts.block#2e2e8734 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.unblock#b550d328 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.getBlocked#9a868f80 flags:# my_stories_from:flags.0?true offset:int limit:int = contacts.Blocked; +contacts.search#11f812d8 q:string limit:int = contacts.Found; +contacts.resolveUsername#725afbbc flags:# username:string referer:flags.0?string = contacts.ResolvedPeer; +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true bots_app:flags.16?true offset:int limit:int hash:long = contacts.TopPeers; +contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; +contacts.resetSaved#879537f1 = Bool; +contacts.getSaved#82f1e39f = Vector; +contacts.toggleTopPeers#8514bdda enabled:Bool = Bool; +contacts.addContact#d9ba2e54 flags:# add_phone_privacy_exception:flags.0?true id:InputUser first_name:string last_name:string phone:string note:flags.1?TextWithEntities = Updates; +contacts.acceptContact#f831a20f id:InputUser = Updates; +contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; +contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; +contacts.exportContactToken#f8654027 = ExportedContactToken; +contacts.importContactToken#13005788 token:string = User; +contacts.editCloseFriends#ba6705f0 id:Vector = Bool; +contacts.setBlocked#94c65c76 flags:# my_stories_from:flags.0?true id:Vector limit:int = Bool; +contacts.getBirthdays#daeda864 = contacts.ContactBirthdays; +contacts.getSponsoredPeers#b6c8c393 q:string = contacts.SponsoredPeers; +contacts.updateContactNote#139f63fb id:InputUser note:TextWithEntities = Bool; + +messages.getMessages#63c66506 id:Vector = messages.Messages; +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; +messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.search#29ee847a flags:# peer:InputPeer q:string from_id:flags.0?InputPeer saved_peer_id:flags.2?InputPeer saved_reaction:flags.3?Vector top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; +messages.receivedMessages#5a954c0 max_id:int = Vector; +messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; +messages.sendMessage#545cd15a flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.sendMedia#330e77f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.forwardMessages#13704a7c flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true allow_paid_floodskip:flags.19?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer top_msg_id:flags.9?int reply_to:flags.22?InputReplyTo schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long video_timestamp:flags.20?int allow_paid_stars:flags.21?long suggested_post:flags.23?SuggestedPost = Updates; +messages.reportSpam#cf1592db peer:InputPeer = Bool; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; +messages.report#fc78af9b peer:InputPeer id:Vector option:bytes message:string = ReportResult; +messages.getChats#49e9528f id:Vector = messages.Chats; +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; +messages.addChatUser#cbc6d107 chat_id:long user_id:InputUser fwd_limit:int = messages.InvitedUsers; +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; +messages.createChat#92ceddd4 flags:# users:Vector title:string ttl_period:flags.0?int = messages.InvitedUsers; +messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; +messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; +messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; +messages.discardEncryption#f393aea0 flags:# delete_history:flags.0?true chat_id:int = Bool; +messages.setEncryptedTyping#791451ed peer:InputEncryptedChat typing:Bool = Bool; +messages.readEncryptedHistory#7f4b690a peer:InputEncryptedChat max_date:int = Bool; +messages.sendEncrypted#44fa7a15 flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.sendEncryptedFile#5559481d flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes file:InputEncryptedFile = messages.SentEncryptedMessage; +messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.receivedQueue#55a5bb66 max_qts:int = Vector; +messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; +messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; +messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; +messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; +messages.getWebPagePreview#570d6f6f flags:# message:string entities:flags.3?Vector = messages.WebPagePreview; +messages.exportChatInvite#a455de90 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string subscription_pricing:flags.5?StarsSubscriptionPricing = ExportedChatInvite; +messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; +messages.importChatInvite#6c50051c hash:string = Updates; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; +messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; +messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; +messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; +messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; +messages.migrateChat#a2875319 chat_id:long = Updates; +messages.searchGlobal#4bc6589a flags:# broadcasts_only:flags.1?true groups_only:flags.2?true users_only:flags.3?true folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; +messages.reorderStickerSets#78337739 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Bool; +messages.getDocumentByHash#b1f2061f sha256:bytes size:long mime_type:string = Document; +messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; +messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; +messages.setInlineBotResults#bb12a419 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM switch_webview:flags.4?InlineBotWebView = Bool; +messages.sendInlineBotResult#c0cf7646 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to:flags.0?InputReplyTo random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut allow_paid_stars:flags.21?long = Updates; +messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; +messages.editMessage#51e842e1 flags:# no_webpage:flags.1?true invert_media:flags.16?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int schedule_repeat_period:flags.18?int quick_reply_shortcut_id:flags.17?int = Updates; +messages.editInlineBotMessage#83557dba flags:# no_webpage:flags.1?true invert_media:flags.16?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; +messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer; +messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; +messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; +messages.saveDraft#54ae308e flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo peer:InputPeer message:string entities:flags.3?Vector media:flags.5?InputMedia effect:flags.7?long suggested_post:flags.8?SuggestedPost = Bool; +messages.getAllDrafts#6a3f8d65 = Updates; +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; +messages.readFeaturedStickers#5b118126 id:Vector = Bool; +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; +messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; +messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true emojis:flags.1?true offset_id:long limit:int = messages.ArchivedStickers; +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; +messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; +messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; +messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; +messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; +messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; +messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; +messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; +messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?string shipping_options:flags.1?Vector = Bool; +messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; +messages.uploadMedia#14967978 flags:# business_connection_id:flags.0?string peer:InputPeer media:InputMedia = MessageMedia; +messages.sendScreenshotNotification#a1405817 peer:InputPeer reply_to:InputReplyTo random_id:long = Updates; +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; +messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; +messages.getUnreadMentions#f107e790 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readMentions#36e5bf4d flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; +messages.sendMultiMedia#1bf89d74 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long = Updates; +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSplitRanges#1cff7e08 = Vector; +messages.markDialogUnread#8c5006f8 flags:# unread:flags.0?true parent_peer:flags.1?InputPeer peer:InputDialogPeer = Bool; +messages.getDialogUnreadMarks#21202222 flags:# parent_peer:flags.0?InputPeer = Vector; +messages.clearAllDrafts#7e58ee9c = Bool; +messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1?true pm_oneside:flags.2?true peer:InputPeer id:int = Updates; +messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; +messages.getPollResults#73bb643b peer:InputPeer msg_id:int = Updates; +messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; +messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; +messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; +messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; +messages.getEmojiKeywordsDifference#1508b6af lang_code:string from_version:int = EmojiKeywordsDifference; +messages.getEmojiKeywordsLanguages#4e9963b2 lang_codes:Vector = Vector; +messages.getEmojiURL#d5b10c26 lang_code:string = EmojiURL; +messages.getSearchCounters#1bbcf300 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer top_msg_id:flags.0?int filters:Vector = Vector; +messages.requestUrlAuth#894cc99c flags:# peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string in_app_origin:flags.3?string = UrlAuthResult; +messages.acceptUrlAuth#67a3f0de flags:# write_allowed:flags.0?true share_phone_number:flags.3?true peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string match_code:flags.4?string = UrlAuthResult; +messages.hidePeerSettingsBar#4facb138 peer:InputPeer = Bool; +messages.getScheduledHistory#f516760b peer:InputPeer hash:long = messages.Messages; +messages.getScheduledMessages#bdbb0464 peer:InputPeer id:Vector = messages.Messages; +messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; +messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; +messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; +messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; +messages.getDialogFilters#efd48c89 = messages.DialogFilters; +messages.getSuggestedDialogFilters#a29cd42c = Vector; +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; +messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; +messages.unpinAllMessages#62dd747 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; +messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; +messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; +messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:string media:InputMedia = MessageMedia; +messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; +messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; +messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true subscription_expired:flags.3?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; +messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; +messages.setChatTheme#81202c9 peer:InputPeer theme:InputChatTheme = Updates; +messages.getMessageReadParticipants#31c1c44f peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#6aa3f6bd flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#9c7f2f10 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b2081a35 flags:# peer:InputPeer enabled:Bool request_msg_id:flags.0?int = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#d30d78d4 flags:# big:flags.1?true add_to_recent:flags.2?true peer:InputPeer msg_id:int reaction:flags.0?Vector = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#864b2581 flags:# peer:InputPeer available_reactions:ChatReactions reactions_limit:flags.0?int paid_enabled:flags.1?Bool = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool; +messages.translateText#63183030 flags:# peer:flags.0?InputPeer id:flags.0?Vector text:flags.1?Vector to_lang:string = messages.TranslatedText; +messages.getUnreadReactions#bd7f90ac flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#9ec44f93 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; +messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; +messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; +messages.toggleBotInAttachMenu#69f59d69 flags:# write_allowed:flags.0?true bot:InputUser enabled:Bool = Bool; +messages.requestWebView#269dc2c1 flags:# from_bot_menu:flags.4?true silent:flags.5?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser url:flags.1?string start_param:flags.3?string theme_params:flags.2?DataJSON platform:string reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = WebViewResult; +messages.prolongWebView#b0d81a83 flags:# silent:flags.5?true peer:InputPeer bot:InputUser query_id:long reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = Bool; +messages.requestSimpleWebView#413a3e73 flags:# from_switch_webview:flags.1?true from_side_menu:flags.2?true compact:flags.7?true fullscreen:flags.8?true bot:InputUser url:flags.3?string start_param:flags.4?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent; +messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates; +messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio; +messages.rateTranscribedAudio#7f1d072f peer:InputPeer msg_id:int transcription_id:long good:Bool = Bool; +messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; +messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; +messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; +messages.reportReaction#3f64c076 peer:InputPeer id:int reaction_peer:InputPeer = Bool; +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; +messages.clearRecentReactions#9dfeefb4 = Bool; +messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; +messages.sendBotRequestedPeer#91b2d060 peer:InputPeer msg_id:int button_id:int requested_peers:Vector = Updates; +messages.getEmojiGroups#7488ce5b hash:int = messages.EmojiGroups; +messages.getEmojiStatusGroups#2ecd56cd hash:int = messages.EmojiGroups; +messages.getEmojiProfilePhotoGroups#21a548f3 hash:int = messages.EmojiGroups; +messages.searchCustomEmoji#2c11c0d7 emoticon:string hash:long = EmojiList; +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; +messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; +messages.requestAppWebView#53618bce flags:# write_allowed:flags.0?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = WebViewResult; +messages.setChatWallPaper#8ffacae1 flags:# for_both:flags.3?true revert:flags.4?true peer:InputPeer wallpaper:flags.0?InputWallPaper settings:flags.2?WallPaperSettings id:flags.1?int = Updates; +messages.searchEmojiStickerSets#92b4494c flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSavedDialogs#1e91fc99 flags:# exclude_pinned:flags.0?true parent_peer:flags.1?InputPeer offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; +messages.getSavedHistory#998ab009 flags:# parent_peer:flags.0?InputPeer peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.deleteSavedHistory#4dc5085f flags:# parent_peer:flags.0?InputPeer peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; +messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedSavedDialogs#8b716587 flags:# force:flags.0?true order:Vector = Bool; +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; +messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; +messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; +messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; +messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; +messages.reorderQuickReplies#60331907 order:Vector = Bool; +messages.checkQuickReplyShortcut#f1d0fbd3 shortcut:string = Bool; +messages.editQuickReplyShortcut#5c003cef shortcut_id:int shortcut:string = Bool; +messages.deleteQuickReplyShortcut#3cc04740 shortcut_id:int = Bool; +messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; +messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; +messages.deleteQuickReplyMessages#e105e910 shortcut_id:int id:Vector = Updates; +messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool; +messages.getMyStickers#d0b5e1fc offset_id:long limit:int = messages.MyStickers; +messages.getEmojiStickerGroups#1dd840f5 hash:int = messages.EmojiGroups; +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; +messages.editFactCheck#589ee75 peer:InputPeer msg_id:int text:TextWithEntities = Updates; +messages.deleteFactCheck#d1da940c peer:InputPeer msg_id:int = Updates; +messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; +messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendPaidReaction#58bbcb50 flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?PaidReactionPrivacy = Updates; +messages.togglePaidReactionPrivacy#435885b5 peer:InputPeer msg_id:int private:PaidReactionPrivacy = Bool; +messages.getPaidReactionPrivacy#472455aa = Updates; +messages.viewSponsoredMessage#269e3643 random_id:bytes = Bool; +messages.clickSponsoredMessage#8235057e flags:# media:flags.0?true fullscreen:flags.1?true random_id:bytes = Bool; +messages.reportSponsoredMessage#12cbf0c4 random_id:bytes option:bytes = channels.SponsoredMessageReportResult; +messages.getSponsoredMessages#3d6ce850 flags:# peer:InputPeer msg_id:flags.0?int = messages.SponsoredMessages; +messages.savePreparedInlineMessage#f21f7f2f flags:# result:InputBotInlineResult user_id:InputUser peer_types:flags.0?Vector = messages.BotPreparedInlineMessage; +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; +messages.searchStickers#29b1c66a flags:# emojis:flags.0?true q:string emoticon:string lang_code:Vector offset:int limit:int hash:long = messages.FoundStickers; +messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = Bool; +messages.getSavedDialogsByID#6f6f9c96 flags:# parent_peer:flags.1?InputPeer ids:Vector = messages.SavedDialogs; +messages.readSavedHistory#ba4a3b5b parent_peer:InputPeer peer:InputPeer max_id:int = Bool; +messages.toggleTodoCompleted#d3e03124 peer:InputPeer msg_id:int completed:Vector incompleted:Vector = Updates; +messages.appendTodoList#21a61057 peer:InputPeer msg_id:int list:Vector = Updates; +messages.toggleSuggestedPostApproval#8107455c flags:# reject:flags.1?true peer:InputPeer msg_id:int schedule_date:flags.0?int reject_comment:flags.2?string = Updates; +messages.getForumTopics#3ba47bff flags:# peer:InputPeer q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; +messages.getForumTopicsByID#af0a4a08 peer:InputPeer topics:Vector = messages.ForumTopics; +messages.editForumTopic#cecc1134 flags:# peer:InputPeer topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; +messages.updatePinnedForumTopic#175df251 peer:InputPeer topic_id:int pinned:Bool = Updates; +messages.reorderPinnedForumTopics#e7841f0 flags:# force:flags.0?true peer:InputPeer order:Vector = Updates; +messages.createForumTopic#2f98c3d5 flags:# title_missing:flags.4?true peer:InputPeer title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; +messages.deleteTopicHistory#d2816f10 peer:InputPeer top_msg_id:int = messages.AffectedHistory; +messages.getEmojiGameInfo#fb7e8ca7 = messages.EmojiGameInfo; +messages.summarizeText#9d4104e2 flags:# peer:InputPeer id:int to_lang:flags.0?string = TextWithEntities; +messages.editChatCreator#f743b857 peer:InputPeer user_id:InputUser password:InputCheckPasswordSRP = Updates; +messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User; +messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates; +messages.declineUrlAuth#35436bbc url:string = Bool; +messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool; + +updates.getState#edd4882a = updates.State; +updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; +updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; + +photos.updateProfilePhoto#9e82039 flags:# fallback:flags.0?true bot:flags.1?InputUser id:InputPhoto = photos.Photo; +photos.uploadProfilePhoto#388a3b5 flags:# fallback:flags.3?true bot:flags.5?InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.4?VideoSize = photos.Photo; +photos.deletePhotos#87cf7f2f id:Vector = Vector; +photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; +photos.uploadContactProfilePhoto#e14c4a71 flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.5?VideoSize = photos.Photo; + +upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; +upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; +upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool; +upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile; +upload.getCdnFile#395f69da file_token:bytes offset:long limit:int = upload.CdnFile; +upload.reuploadCdnFile#9b2754a8 file_token:bytes request_token:bytes = Vector; +upload.getCdnFileHashes#91dc3f31 file_token:bytes offset:long = Vector; +upload.getFileHashes#9156982a location:InputFileLocation offset:long = Vector; + +help.getConfig#c4f9186b = Config; +help.getNearestDc#1fb33026 = NearestDc; +help.getAppUpdate#522d5a7d source:string = help.AppUpdate; +help.getInviteText#4d392343 = help.InviteText; +help.getSupport#9cdf08cd = help.Support; +help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; +help.getCdnConfig#52029342 = CdnConfig; +help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; +help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; +help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; +help.getAppConfig#61e3f854 hash:int = help.AppConfig; +help.saveAppLog#6f02f748 events:Vector = Bool; +help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; +help.getSupportName#d360e72c = help.SupportName; +help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; +help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; +help.getPromoData#c0977421 = help.PromoData; +help.hidePromoData#1e251c95 peer:InputPeer = Bool; +help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; +help.getPremiumPromo#b81b93d4 = help.PremiumPromo; +help.getPeerColors#da80f42f hash:int = help.PeerColors; +help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors; +help.getTimezonesList#49b30240 hash:int = help.TimezonesList; + +channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; +channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; +channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; +channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; +channels.getChannels#a7f6bbb id:Vector = messages.Chats; +channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true forum:flags.5?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; +channels.editAdmin#9a98ad68 flags:# channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:flags.0?string = Updates; +channels.editTitle#566decd0 channel:InputChannel title:string = Updates; +channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; +channels.checkUsername#10e6bd2c channel:InputChannel username:string = Bool; +channels.updateUsername#3514b3de channel:InputChannel username:string = Bool; +channels.joinChannel#24b524c5 channel:InputChannel = Updates; +channels.leaveChannel#f836aa95 channel:InputChannel = Updates; +channels.inviteToChannel#c9e33d54 channel:InputChannel users:Vector = messages.InvitedUsers; +channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; +channels.exportMessageLink#e63fadeb flags:# grouped:flags.0?true thread:flags.1?true channel:InputChannel id:int = ExportedMessageLink; +channels.toggleSignatures#418d549c flags:# signatures_enabled:flags.0?true profiles_enabled:flags.1?true channel:InputChannel = Updates; +channels.getAdminedPublicChannels#f8b036af flags:# by_location:flags.0?true check_limit:flags.1?true for_personal:flags.2?true = messages.Chats; +channels.editBanned#96e6cd81 channel:InputChannel participant:InputPeer banned_rights:ChatBannedRights = Updates; +channels.getAdminLog#33ddf480 flags:# channel:InputChannel q:string events_filter:flags.0?ChannelAdminLogEventsFilter admins:flags.1?Vector max_id:long min_id:long limit:int = channels.AdminLogResults; +channels.setStickers#ea8ca4f9 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = Bool; +channels.deleteHistory#9baa9647 flags:# for_everyone:flags.0?true channel:InputChannel max_id:int = Updates; +channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates; +channels.getLeftChannels#8341ecc0 offset:int = messages.Chats; +channels.getGroupsForDiscussion#f5dad378 = messages.Chats; +channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool; +channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint address:string = Bool; +channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; +channels.getInactiveChannels#11e831ee = messages.InactiveChats; +channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; +channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; +channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; +channels.toggleJoinRequest#4c2985b6 channel:InputChannel enabled:Bool = Updates; +channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; +channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; +channels.deactivateAllUsernames#a245dd3 channel:InputChannel = Bool; +channels.toggleForum#3ff75734 channel:InputChannel enabled:Bool tabs:Bool = Updates; +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; +channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; +channels.updateColor#d8aa3671 flags:# for_profile:flags.1?true channel:InputChannel color:flags.2?int background_emoji_id:flags.0?long = Updates; +channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; +channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; +channels.updateEmojiStatus#f0d3e6a8 channel:InputChannel emoji_status:EmojiStatus = Updates; +channels.setBoostsToUnblockRestrictions#ad399cee channel:InputChannel boosts:int = Updates; +channels.setEmojiStickers#3cd930b7 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.restrictSponsoredMessages#9ae91519 channel:InputChannel restricted:Bool = Updates; +channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; +channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; +channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; +channels.getMessageAuthor#ece2a0e6 channel:InputChannel id:int = User; +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; +channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool; + +bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; +bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; +bots.setBotMenuButton#4504d54f user_id:InputUser button:BotMenuButton = Bool; +bots.getBotMenuButton#9c60eb28 user_id:InputUser = BotMenuButton; +bots.setBotBroadcastDefaultAdminRights#788464e1 admin_rights:ChatAdminRights = Bool; +bots.setBotGroupDefaultAdminRights#925ec9ea admin_rights:ChatAdminRights = Bool; +bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; +bots.getBotInfo#dcd914fd flags:# bot:flags.0?InputUser lang_code:string = bots.BotInfo; +bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = Bool; +bots.toggleUsername#53ca973 bot:InputUser username:string active:Bool = Bool; +bots.canSendMessage#1359f4e6 bot:InputUser = Bool; +bots.allowSendMessage#f132e3ef bot:InputUser = Updates; +bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; +bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots; +bots.addPreviewMedia#17aeb75a bot:InputUser lang_code:string media:InputMedia = BotPreviewMedia; +bots.editPreviewMedia#8525606f bot:InputUser lang_code:string media:InputMedia new_media:InputMedia = BotPreviewMedia; +bots.deletePreviewMedia#2d0135b3 bot:InputUser lang_code:string media:Vector = Bool; +bots.reorderPreviewMedias#b627f3aa bot:InputUser lang_code:string order:Vector = Bool; +bots.getPreviewInfo#423ab3ad bot:InputUser lang_code:string = bots.PreviewInfo; +bots.getPreviewMedias#a2a5594d bot:InputUser = Vector; +bots.updateUserEmojiStatus#ed9f30c5 user_id:InputUser emoji_status:EmojiStatus = Bool; +bots.toggleUserEmojiStatusPermission#6de6392 bot:InputUser enabled:Bool = Bool; +bots.checkDownloadFileParams#50077589 bot:InputUser file_name:string url:string = Bool; +bots.getAdminedBots#b0711d83 = Vector; +bots.updateStarRefProgram#778b5ab3 flags:# bot:InputUser commission_permille:int duration_months:flags.0?int = StarRefProgram; +bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool; +bots.getBotRecommendations#a1b70815 bot:InputUser = users.Users; + +payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; +payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; +payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; +payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; +payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; +payments.getBankCardData#2e79d779 number:string = payments.BankCardData; +payments.exportInvoice#f91b065 invoice_media:InputMedia = payments.ExportedInvoice; +payments.assignAppStoreTransaction#80ed747d receipt:bytes purpose:InputStorePaymentPurpose = Updates; +payments.assignPlayMarketTransaction#dffd50d3 receipt:DataJSON purpose:InputStorePaymentPurpose = Updates; +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; +payments.applyGiftCode#f6e26854 slug:string = Updates; +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; +payments.getStarsTopupOptions#c00ec7d3 = Vector; +payments.getStarsStatus#4ea9b3bf flags:# ton:flags.0?true peer:InputPeer = payments.StarsStatus; +payments.getStarsTransactions#69da4557 flags:# inbound:flags.0?true outbound:flags.1?true ascending:flags.2?true ton:flags.4?true subscription_id:flags.3?string peer:InputPeer offset:string limit:int = payments.StarsStatus; +payments.sendStarsForm#7998c914 form_id:long invoice:InputInvoice = payments.PaymentResult; +payments.refundStarsCharge#25ae8f4a user_id:InputUser charge_id:string = Updates; +payments.getStarsRevenueStats#d91ffad6 flags:# dark:flags.0?true ton:flags.1?true peer:InputPeer = payments.StarsRevenueStats; +payments.getStarsRevenueWithdrawalUrl#2433dc92 flags:# ton:flags.0?true peer:InputPeer amount:flags.1?long password:InputCheckPasswordSRP = payments.StarsRevenueWithdrawalUrl; +payments.getStarsRevenueAdsAccountUrl#d1d7efc5 peer:InputPeer = payments.StarsRevenueAdsAccountUrl; +payments.getStarsTransactionsByID#2dca16b8 flags:# ton:flags.0?true peer:InputPeer id:Vector = payments.StarsStatus; +payments.getStarsGiftOptions#d3c96bc8 flags:# user_id:flags.0?InputUser = Vector; +payments.getStarsSubscriptions#32512c5 flags:# missing_balance:flags.0?true peer:InputPeer offset:string = payments.StarsStatus; +payments.changeStarsSubscription#c7770878 flags:# peer:InputPeer subscription_id:string canceled:flags.0?Bool = Bool; +payments.fulfillStarsSubscription#cc5bebb3 peer:InputPeer subscription_id:string = Bool; +payments.getStarsGiveawayOptions#bd1efd3e = Vector; +payments.getStarGifts#c4563590 hash:int = payments.StarGifts; +payments.saveStarGift#2a2a697c flags:# unsave:flags.0?true stargift:InputSavedStarGift = Bool; +payments.convertStarGift#74bf076b stargift:InputSavedStarGift = Bool; +payments.botCancelStarsSubscription#6dfa0622 flags:# restore:flags.0?true user_id:InputUser charge_id:string = Bool; +payments.getConnectedStarRefBots#5869a553 flags:# peer:InputPeer offset_date:flags.2?int offset_link:flags.2?string limit:int = payments.ConnectedStarRefBots; +payments.getConnectedStarRefBot#b7d998f0 peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.getSuggestedStarRefBots#d6b48f7 flags:# order_by_revenue:flags.0?true order_by_date:flags.1?true peer:InputPeer offset:string limit:int = payments.SuggestedStarRefBots; +payments.connectStarRefBot#7ed5348a peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.editConnectedStarRefBot#e4fca4a3 flags:# revoked:flags.0?true peer:InputPeer link:string = payments.ConnectedStarRefBots; +payments.getStarGiftUpgradePreview#9c9abcb1 gift_id:long = payments.StarGiftUpgradePreview; +payments.upgradeStarGift#aed6e4f5 flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = Updates; +payments.transferStarGift#7f18176a stargift:InputSavedStarGift to_id:InputPeer = Updates; +payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift; +payments.getSavedStarGifts#a319e569 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_unique:flags.4?true sort_by_value:flags.5?true exclude_upgradable:flags.7?true exclude_unupgradable:flags.8?true peer_color_available:flags.9?true exclude_hosted:flags.10?true peer:InputPeer collection_id:flags.6?int offset:string limit:int = payments.SavedStarGifts; +payments.getSavedStarGift#b455a106 stargift:Vector = payments.SavedStarGifts; +payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl; +payments.toggleChatStarGiftNotifications#60eaefa1 flags:# enabled:flags.0?true peer:InputPeer = Bool; +payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = Bool; +payments.canPurchaseStore#4fdc5ea7 purpose:InputStorePaymentPurpose = Bool; +payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true for_craft:flags.4?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector offset:string limit:int = payments.ResaleStarGifts; +payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates; +payments.createStarGiftCollection#1f4a0e87 peer:InputPeer title:string stargift:Vector = StarGiftCollection; +payments.updateStarGiftCollection#4fddbee7 flags:# peer:InputPeer collection_id:int title:flags.0?string delete_stargift:flags.1?Vector add_stargift:flags.2?Vector order:flags.3?Vector = StarGiftCollection; +payments.reorderStarGiftCollections#c32af4cc peer:InputPeer order:Vector = Bool; +payments.deleteStarGiftCollection#ad5648e8 peer:InputPeer collection_id:int = Bool; +payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections; +payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGiftValueInfo; +payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; +payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; +payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; +payments.sendStarGiftOffer#8fb86b41 flags:# peer:InputPeer slug:string price:StarsAmount duration:int random_id:long allow_paid_stars:flags.0?long = Updates; +payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; +payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts; +payments.craftStarGift#b0f9684f stargift:Vector = Updates; + +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true emojis:flags.5?true text_color:flags.6?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; +stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; +stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; +stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; +stickers.setStickerSetThumb#a76a5392 flags:# stickerset:InputStickerSet thumb:flags.0?InputDocument thumb_document_id:flags.1?long = messages.StickerSet; +stickers.checkShortName#284b3639 short_name:string = Bool; +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; +stickers.changeSticker#f5537ebc flags:# sticker:InputDocument emoji:flags.0?string mask_coords:flags.1?MaskCoords keywords:flags.2?string = messages.StickerSet; +stickers.renameStickerSet#124b1c00 stickerset:InputStickerSet title:string = messages.StickerSet; +stickers.deleteStickerSet#87704394 stickerset:InputStickerSet = Bool; +stickers.replaceSticker#4696459a sticker:InputDocument new_sticker:InputStickerSetItem = messages.StickerSet; + +phone.getCallConfig#55451fa9 = DataJSON; +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.toggleGroupCallSettings#974392f2 flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool messages_enabled:flags.2?Bool send_paid_messages_stars:flags.3?long = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; +phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; +phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; +phone.getGroupCallStreamRtmpUrl#5af4c73a flags:# live_story:flags.0?true peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; +phone.saveCallLog#41248786 peer:InputPhoneCall file:InputFile = Bool; +phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates; +phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector block:bytes = Updates; +phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates; +phone.inviteConferenceCallParticipant#bcf22685 flags:# video:flags.0?true call:InputGroupCall user_id:InputUser = Updates; +phone.declineConferenceCallInvite#3c479971 msg_id:int = Updates; +phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates; +phone.sendGroupCallMessage#b1d11410 flags:# call:InputGroupCall random_id:long message:TextWithEntities allow_paid_stars:flags.0?long send_as:flags.1?InputPeer = Updates; +phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool; +phone.deleteGroupCallMessages#f64f54f7 flags:# report_spam:flags.0?true call:InputGroupCall messages:Vector = Updates; +phone.deleteGroupCallParticipantMessages#1dbfeca0 flags:# report_spam:flags.0?true call:InputGroupCall participant:InputPeer = Updates; +phone.getGroupCallStars#6f636302 call:InputGroupCall = phone.GroupCallStars; +phone.saveDefaultSendAs#4167add1 call:InputGroupCall send_as:InputPeer = Bool; + +langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; +langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; +langpack.getDifference#cd984aa5 lang_pack:string lang_code:string from_version:int = LangPackDifference; +langpack.getLanguages#42c6978f lang_pack:string = Vector; +langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLanguage; + +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; + +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; +stats.getMessagePublicForwards#5f150144 channel:InputChannel msg_id:int offset:string limit:int = stats.PublicForwards; +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; +stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats; +stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards; + +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = chatlists.ExportedChatlistInvite; +chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool; +chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector = ExportedChatlistInvite; +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; +chatlists.getChatlistUpdates#89419521 chatlist:InputChatlist = chatlists.ChatlistUpdates; +chatlists.joinChatlistUpdates#e089f8f5 chatlist:InputChatlist peers:Vector = Updates; +chatlists.hideChatlistUpdates#66e486fb chatlist:InputChatlist = Bool; +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates; + +stories.canSendStory#30eb63f0 peer:InputPeer = stories.CanSendStoryCount; +stories.sendStory#737fc2ec flags:# pinned:flags.2?true noforwards:flags.4?true fwd_modified:flags.7?true peer:InputPeer media:InputMedia media_areas:flags.5?Vector caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long period:flags.3?int fwd_from_id:flags.6?InputPeer fwd_from_story:flags.6?int albums:flags.8?Vector = Updates; +stories.editStory#b583ba46 flags:# peer:InputPeer id:int media:flags.0?InputMedia media_areas:flags.3?Vector caption:flags.1?string entities:flags.1?Vector privacy_rules:flags.2?Vector = Updates; +stories.deleteStories#ae59db5f peer:InputPeer id:Vector = Vector; +stories.togglePinned#9a75a1ef peer:InputPeer id:Vector pinned:Bool = Vector; +stories.getAllStories#eeb0d625 flags:# next:flags.1?true hidden:flags.2?true state:flags.0?string = stories.AllStories; +stories.getPinnedStories#5821a5dc peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesArchive#b4352016 peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesByID#5774ca74 peer:InputPeer id:Vector = stories.Stories; +stories.toggleAllStoriesHidden#7c2557c4 hidden:Bool = Bool; +stories.readStories#a556dac8 peer:InputPeer max_id:int = Vector; +stories.incrementStoryViews#b2028afb peer:InputPeer id:Vector = Bool; +stories.getStoryViewsList#7ed23c57 flags:# just_contacts:flags.0?true reactions_first:flags.2?true forwards_first:flags.3?true peer:InputPeer q:flags.1?string id:int offset:string limit:int = stories.StoryViewsList; +stories.getStoriesViews#28e16cc8 peer:InputPeer id:Vector = stories.StoryViews; +stories.exportStoryLink#7b8def20 peer:InputPeer id:int = ExportedStoryLink; +stories.report#19d8eb45 peer:InputPeer id:Vector option:bytes message:string = ReportResult; +stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?true = Updates; +stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; +stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; +stories.getAllReadPeerStories#9b5ae7f9 = Updates; +stories.getPeerMaxIDs#78499170 id:Vector = Vector; +stories.getChatsToSend#a56a8b60 = messages.Chats; +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.getStoryReactionsList#b9b2881f flags:# forwards_first:flags.2?true peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = stories.StoryReactionsList; +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; +stories.searchPosts#d1810907 flags:# hashtag:flags.0?string area:flags.1?MediaArea peer:flags.2?InputPeer offset:string limit:int = stories.FoundStories; +stories.createAlbum#a36396e5 peer:InputPeer title:string stories:Vector = StoryAlbum; +stories.updateAlbum#5e5259b6 flags:# peer:InputPeer album_id:int title:flags.0?string delete_stories:flags.1?Vector add_stories:flags.2?Vector order:flags.3?Vector = StoryAlbum; +stories.reorderAlbums#8535fbd9 peer:InputPeer order:Vector = Bool; +stories.deleteAlbum#8d3456d0 peer:InputPeer album_id:int = Bool; +stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums; +stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories; +stories.startLive#d069ccde flags:# pinned:flags.2?true noforwards:flags.4?true rtmp_stream:flags.5?true peer:InputPeer caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long messages_enabled:flags.6?Bool send_paid_messages_stars:flags.7?long = Updates; + +premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; +premium.getMyBoosts#be77b4a = premium.MyBoosts; +premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; +premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus; +premium.getUserBoosts#39854d1f peer:InputPeer user_id:InputUser = premium.BoostsList; + +smsjobs.isEligibleToJoin#edc39d0 = smsjobs.EligibilityToJoin; +smsjobs.join#a74ece2d = Bool; +smsjobs.leave#9898ad73 = Bool; +smsjobs.updateSettings#93fa0bf flags:# allow_international:flags.0?true = Bool; +smsjobs.getStatus#10a698e8 = smsjobs.Status; +smsjobs.getSmsJob#778d902f job_id:string = SmsJob; +smsjobs.finishJob#4f1ebf24 flags:# job_id:string error:flags.0?string = Bool; + +fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo; + +// LAYER 223 diff --git a/internal/compat/layerwire/_schema/layer-224.tl b/internal/compat/layerwire/_schema/layer-224.tl new file mode 100644 index 00000000..ca55a99a --- /dev/null +++ b/internal/compat/layerwire/_schema/layer-224.tl @@ -0,0 +1,2944 @@ +boolFalse#bc799737 = Bool; +boolTrue#997275b5 = Bool; + +true#3fedd339 = True; + +vector#1cb5c415 {t:Type} # [ t ] = Vector t; + +error#c4b9f9bb code:int text:string = Error; + +null#56730bcc = Null; + +inputPeerEmpty#7f3b18ea = InputPeer; +inputPeerSelf#7da07ec9 = InputPeer; +inputPeerChat#35a95cb9 chat_id:long = InputPeer; +inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer; +inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer; +inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer; +inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer; + +inputUserEmpty#b98886cf = InputUser; +inputUserSelf#f7c1b13f = InputUser; +inputUser#f21158c6 user_id:long access_hash:long = InputUser; +inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser; + +inputPhoneContact#6a1dc4be flags:# client_id:long phone:string first_name:string last_name:string note:flags.0?TextWithEntities = InputContact; + +inputFile#f52ff27f id:long parts:int name:string md5_checksum:string = InputFile; +inputFileBig#fa4f0bb5 id:long parts:int name:string = InputFile; +inputFileStoryDocument#62dc8b48 id:InputDocument = InputFile; + +inputMediaEmpty#9664f57f = InputMedia; +inputMediaUploadedPhoto#7d8375da flags:# spoiler:flags.2?true live_photo:flags.3?true file:InputFile stickers:flags.0?Vector ttl_seconds:flags.1?int video:flags.3?InputDocument = InputMedia; +inputMediaPhoto#e3af4434 flags:# spoiler:flags.1?true live_photo:flags.2?true id:InputPhoto ttl_seconds:flags.0?int video:flags.2?InputDocument = InputMedia; +inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia; +inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia; +inputMediaUploadedDocument#37c9330 flags:# nosound_video:flags.3?true force_file:flags.4?true spoiler:flags.5?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector video_cover:flags.6?InputPhoto video_timestamp:flags.7?int ttl_seconds:flags.1?int = InputMedia; +inputMediaDocument#a8763ab5 flags:# spoiler:flags.2?true id:InputDocument video_cover:flags.3?InputPhoto video_timestamp:flags.4?int ttl_seconds:flags.0?int query:flags.1?string = InputMedia; +inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia; +inputMediaPhotoExternal#e5bbfe1a flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int = InputMedia; +inputMediaDocumentExternal#779600f9 flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int video_cover:flags.2?InputPhoto video_timestamp:flags.3?int = InputMedia; +inputMediaGame#d33f43f3 id:InputGame = InputMedia; +inputMediaInvoice#405fef0d flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:flags.3?string provider_data:DataJSON start_param:flags.1?string extended_media:flags.2?InputMedia = InputMedia; +inputMediaGeoLive#971fa843 flags:# stopped:flags.0?true geo_point:InputGeoPoint heading:flags.2?int period:flags.1?int proximity_notification_radius:flags.3?int = InputMedia; +inputMediaPoll#883a4108 flags:# poll:Poll correct_answers:flags.0?Vector attached_media:flags.3?InputMedia solution:flags.1?string solution_entities:flags.1?Vector solution_media:flags.2?InputMedia = InputMedia; +inputMediaDice#e66fbf7b emoticon:string = InputMedia; +inputMediaStory#89fdd778 peer:InputPeer id:int = InputMedia; +inputMediaWebPage#c21b8849 flags:# force_large_media:flags.0?true force_small_media:flags.1?true optional:flags.2?true url:string = InputMedia; +inputMediaPaidMedia#c4103386 flags:# stars_amount:long extended_media:Vector payload:flags.0?string = InputMedia; +inputMediaTodo#9fc55fde todo:TodoList = InputMedia; +inputMediaStakeDice#f3a9244a game_hash:string ton_amount:long client_seed:bytes = InputMedia; + +inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; +inputChatUploadedPhoto#bdcdaec0 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.3?VideoSize = InputChatPhoto; +inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; + +inputGeoPointEmpty#e4c123d6 = InputGeoPoint; +inputGeoPoint#48222faf flags:# lat:double long:double accuracy_radius:flags.0?int = InputGeoPoint; + +inputPhotoEmpty#1cd7bf0d = InputPhoto; +inputPhoto#3bb3b94a id:long access_hash:long file_reference:bytes = InputPhoto; + +inputFileLocation#dfdaabe1 volume_id:long local_id:int secret:long file_reference:bytes = InputFileLocation; +inputEncryptedFileLocation#f5235d55 id:long access_hash:long = InputFileLocation; +inputDocumentFileLocation#bad07584 id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputSecureFileLocation#cbc7ee28 id:long access_hash:long = InputFileLocation; +inputTakeoutFileLocation#29be5899 = InputFileLocation; +inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation; +inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation; +inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation; +inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation; + +peerUser#59511722 user_id:long = Peer; +peerChat#36c6019a chat_id:long = Peer; +peerChannel#a2a5371e channel_id:long = Peer; + +storage.fileUnknown#aa963b05 = storage.FileType; +storage.filePartial#40bc6f52 = storage.FileType; +storage.fileJpeg#7efe0e = storage.FileType; +storage.fileGif#cae1aadf = storage.FileType; +storage.filePng#a4f63c0 = storage.FileType; +storage.filePdf#ae1e508d = storage.FileType; +storage.fileMp3#528a0677 = storage.FileType; +storage.fileMov#4b09ebbc = storage.FileType; +storage.fileMp4#b3cea0e4 = storage.FileType; +storage.fileWebp#1081464c = storage.FileType; + +userEmpty#d3bc4b7a id:long = User; +user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true bot_forum_can_manage_topics:flags2.17?true bot_can_manage_bots:flags2.18?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User; + +userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; +userProfilePhoto#82d1f706 flags:# has_video:flags.0?true personal:flags.2?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto; + +userStatusEmpty#9d05049 = UserStatus; +userStatusOnline#edb93949 expires:int = UserStatus; +userStatusOffline#8c703f was_online:int = UserStatus; +userStatusRecently#7b197dc8 flags:# by_me:flags.0?true = UserStatus; +userStatusLastWeek#541a1d1a flags:# by_me:flags.0?true = UserStatus; +userStatusLastMonth#65899777 flags:# by_me:flags.0?true = UserStatus; + +chatEmpty#29562865 id:long = Chat; +chat#41cbf256 flags:# creator:flags.0?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; +chatForbidden#6592a1a7 id:long title:string = Chat; +channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat; +channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true monoforum:flags.10?true id:long access_hash:long title:string until_date:flags.16?int = Chat; + +chatFull#2633421b flags:# can_set_username:flags.7?true has_scheduled:flags.8?true translations_disabled:flags.19?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions reactions_limit:flags.20?int = ChatFull; +channelFull#e4e0b29d flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true participants_hidden:flags2.2?true translations_disabled:flags2.3?true stories_pinned_available:flags2.5?true view_forum_as_messages:flags2.6?true restricted_sponsored:flags2.11?true can_view_revenue:flags2.12?true paid_media_allowed:flags2.14?true can_view_stars_revenue:flags2.15?true paid_reactions_available:flags2.16?true stargifts_available:flags2.19?true paid_messages_available:flags2.20?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions reactions_limit:flags2.13?int stories:flags2.4?PeerStories wallpaper:flags2.7?WallPaper boosts_applied:flags2.8?int boosts_unrestrict:flags2.9?int emojiset:flags2.10?StickerSet bot_verification:flags2.17?BotVerification stargifts_count:flags2.18?int send_paid_messages_stars:flags2.21?long main_tab:flags2.22?ProfileTab = ChatFull; + +chatParticipant#38e79fde flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; +chatParticipantCreator#e1f867b8 flags:# user_id:long rank:flags.0?string = ChatParticipant; +chatParticipantAdmin#360d5d2 flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; + +chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants; +chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants; + +chatPhotoEmpty#37c1011c = ChatPhoto; +chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; + +messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; +message#3ae56482 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true invert_media:flags.27?true flags2:# offline:flags2.1?true video_processing_pending:flags2.4?true paid_suggested_post_stars:flags2.8?true paid_suggested_post_ton:flags2.9?true id:int from_id:flags.8?Peer from_boosts_applied:flags.29?int from_rank:flags2.12?string peer_id:Peer saved_peer_id:flags.28?Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long via_business_bot_id:flags2.0?long reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int quick_reply_shortcut_id:flags.30?int effect:flags2.2?long factcheck:flags2.3?FactCheck report_delivery_until_date:flags2.5?int paid_message_stars:flags2.6?long suggested_post:flags2.7?SuggestedPost schedule_repeat_period:flags2.10?int summary_from_language:flags2.11?string = Message; +messageService#7a800e0a flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true reactions_are_possible:flags.9?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer saved_peer_id:flags.28?Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction reactions:flags.20?MessageReactions ttl_period:flags.25?int = Message; + +messageMediaEmpty#3ded6320 = MessageMedia; +messageMediaPhoto#e216eb63 flags:# spoiler:flags.3?true live_photo:flags.4?true photo:flags.0?Photo ttl_seconds:flags.2?int video:flags.4?Document = MessageMedia; +messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia; +messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia; +messageMediaUnsupported#9f84f49e = MessageMedia; +messageMediaDocument#52d8ccd9 flags:# nopremium:flags.3?true spoiler:flags.4?true video:flags.6?true round:flags.7?true voice:flags.8?true document:flags.0?Document alt_documents:flags.5?Vector video_cover:flags.9?Photo video_timestamp:flags.10?int ttl_seconds:flags.2?int = MessageMedia; +messageMediaWebPage#ddf10c3b flags:# force_large_media:flags.0?true force_small_media:flags.1?true manual:flags.3?true safe:flags.4?true webpage:WebPage = MessageMedia; +messageMediaVenue#2ec0533f geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MessageMedia; +messageMediaGame#fdb19008 game:Game = MessageMedia; +messageMediaInvoice#f6a548d3 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string extended_media:flags.4?MessageExtendedMedia = MessageMedia; +messageMediaGeoLive#b940c666 flags:# geo:GeoPoint heading:flags.0?int period:int proximity_notification_radius:flags.1?int = MessageMedia; +messageMediaPoll#773f4e66 flags:# poll:Poll results:PollResults attached_media:flags.0?MessageMedia = MessageMedia; +messageMediaDice#8cbec07 flags:# value:int emoticon:string game_outcome:flags.0?messages.EmojiGameOutcome = MessageMedia; +messageMediaStory#68cb6283 flags:# via_mention:flags.1?true peer:Peer id:int story:flags.0?StoryItem = MessageMedia; +messageMediaGiveaway#aa073beb flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.2?true channels:Vector countries_iso2:flags.1?Vector prize_description:flags.3?string quantity:int months:flags.4?int stars:flags.5?long until_date:int = MessageMedia; +messageMediaGiveawayResults#ceaa3ea1 flags:# only_new_subscribers:flags.0?true refunded:flags.2?true channel_id:long additional_peers_count:flags.3?int launch_msg_id:int winners_count:int unclaimed_count:int winners:Vector months:flags.4?int stars:flags.5?long prize_description:flags.1?string until_date:int = MessageMedia; +messageMediaPaidMedia#a8852491 stars_amount:long extended_media:Vector = MessageMedia; +messageMediaToDo#8a53b014 flags:# todo:TodoList completions:flags.0?Vector = MessageMedia; +messageMediaVideoStream#ca5cab89 flags:# rtmp_stream:flags.0?true call:InputGroupCall = MessageMedia; + +messageActionEmpty#b6aef7b0 = MessageAction; +messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction; +messageActionChatEditTitle#b5a1ce5a title:string = MessageAction; +messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction; +messageActionChatDeletePhoto#95e3fbef = MessageAction; +messageActionChatAddUser#15cefd00 users:Vector = MessageAction; +messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction; +messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction; +messageActionChannelCreate#95d2ac92 title:string = MessageAction; +messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction; +messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction; +messageActionPinMessage#94bd38ed = MessageAction; +messageActionHistoryClear#9fbab604 = MessageAction; +messageActionGameScore#92a72876 game_id:long score:int = MessageAction; +messageActionPaymentSentMe#ffa00ccc flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string charge:PaymentCharge subscription_until_date:flags.4?int = MessageAction; +messageActionPaymentSent#c624b16e flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long invoice_slug:flags.0?string subscription_until_date:flags.4?int = MessageAction; +messageActionPhoneCall#80e11a7f flags:# video:flags.2?true call_id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = MessageAction; +messageActionScreenshotTaken#4792929b = MessageAction; +messageActionCustomAction#fae69f56 message:string = MessageAction; +messageActionBotAllowed#c516d679 flags:# attach_menu:flags.1?true from_request:flags.3?true domain:flags.0?string app:flags.2?BotApp = MessageAction; +messageActionSecureValuesSentMe#1b287353 values:Vector credentials:SecureCredentialsEncrypted = MessageAction; +messageActionSecureValuesSent#d95c6154 types:Vector = MessageAction; +messageActionContactSignUp#f3f25f76 = MessageAction; +messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; +messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; +messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; +messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; +messageActionSetChatTheme#b91bbd3a theme:ChatTheme = MessageAction; +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; +messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; +messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; +messageActionGiftPremium#48e91302 flags:# currency:string amount:long days:int crypto_currency:flags.0?string crypto_amount:flags.0?long message:flags.1?TextWithEntities = MessageAction; +messageActionTopicCreate#d999256 flags:# title_missing:flags.1?true title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; +messageActionSuggestProfilePhoto#57de635e photo:Photo = MessageAction; +messageActionRequestedPeer#31518e9b button_id:int peers:Vector = MessageAction; +messageActionSetChatWallPaper#5060a3f4 flags:# same:flags.0?true for_both:flags.1?true wallpaper:WallPaper = MessageAction; +messageActionGiftCode#31c48347 flags:# via_giveaway:flags.0?true unclaimed:flags.5?true boost_peer:flags.1?Peer days:int slug:string currency:flags.2?string amount:flags.2?long crypto_currency:flags.3?string crypto_amount:flags.3?long message:flags.4?TextWithEntities = MessageAction; +messageActionGiveawayLaunch#a80f51e4 flags:# stars:flags.0?long = MessageAction; +messageActionGiveawayResults#87e2f155 flags:# stars:flags.0?true winners_count:int unclaimed_count:int = MessageAction; +messageActionBoostApply#cc02aa6d boosts:int = MessageAction; +messageActionRequestedPeerSentMe#93b31848 button_id:int peers:Vector = MessageAction; +messageActionPaymentRefunded#41b3e202 flags:# peer:Peer currency:string total_amount:long payload:flags.0?bytes charge:PaymentCharge = MessageAction; +messageActionGiftStars#45d5b021 flags:# currency:string amount:long stars:long crypto_currency:flags.0?string crypto_amount:flags.0?long transaction_id:flags.1?string = MessageAction; +messageActionPrizeStars#b00c47a2 flags:# unclaimed:flags.0?true stars:long transaction_id:string boost_peer:Peer giveaway_msg_id:int = MessageAction; +messageActionStarGift#ea2c31d3 flags:# name_hidden:flags.0?true saved:flags.2?true converted:flags.3?true upgraded:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true prepaid_upgrade:flags.13?true upgrade_separate:flags.16?true auction_acquired:flags.17?true gift:StarGift message:flags.1?TextWithEntities convert_stars:flags.4?long upgrade_msg_id:flags.5?int upgrade_stars:flags.8?long from_id:flags.11?Peer peer:flags.12?Peer saved_id:flags.12?long prepaid_upgrade_hash:flags.14?string gift_msg_id:flags.15?int to_id:flags.18?Peer gift_num:flags.19?int = MessageAction; +messageActionStarGiftUnique#e6c31522 flags:# upgrade:flags.0?true transferred:flags.1?true saved:flags.2?true refunded:flags.5?true prepaid_upgrade:flags.11?true assigned:flags.13?true from_offer:flags.14?true craft:flags.16?true gift:StarGift can_export_at:flags.3?int transfer_stars:flags.4?long from_id:flags.6?Peer peer:flags.7?Peer saved_id:flags.7?long resale_amount:flags.8?StarsAmount can_transfer_at:flags.9?int can_resell_at:flags.10?int drop_original_details_stars:flags.12?long can_craft_at:flags.15?int = MessageAction; +messageActionPaidMessagesRefunded#ac1f1fcd count:int stars:long = MessageAction; +messageActionPaidMessagesPrice#84b88578 flags:# broadcast_messages_allowed:flags.0?true stars:long = MessageAction; +messageActionConferenceCall#2ffe2f7a flags:# missed:flags.0?true active:flags.1?true video:flags.4?true call_id:long duration:flags.2?int other_participants:flags.3?Vector = MessageAction; +messageActionTodoCompletions#cc7c5c89 completed:Vector incompleted:Vector = MessageAction; +messageActionTodoAppendTasks#c7edbc83 list:Vector = MessageAction; +messageActionSuggestedPostApproval#ee7a1596 flags:# rejected:flags.0?true balance_too_low:flags.1?true reject_comment:flags.2?string schedule_date:flags.3?int price:flags.4?StarsAmount = MessageAction; +messageActionSuggestedPostSuccess#95ddcf69 price:StarsAmount = MessageAction; +messageActionSuggestedPostRefund#69f916f8 flags:# payer_initiated:flags.0?true = MessageAction; +messageActionGiftTon#a8a3c699 flags:# currency:string amount:long crypto_currency:string crypto_amount:long transaction_id:flags.0?string = MessageAction; +messageActionSuggestBirthday#2c8f2a25 birthday:Birthday = MessageAction; +messageActionStarGiftPurchaseOffer#774278d4 flags:# accepted:flags.0?true declined:flags.1?true gift:StarGift price:StarsAmount expires_at:int = MessageAction; +messageActionStarGiftPurchaseOfferDeclined#73ada76b flags:# expired:flags.0?true gift:StarGift price:StarsAmount = MessageAction; +messageActionNewCreatorPending#b07ed085 new_creator_id:long = MessageAction; +messageActionChangeCreator#e188503b new_creator_id:long = MessageAction; +messageActionNoForwardsToggle#bf7d6572 prev_value:Bool new_value:Bool = MessageAction; +messageActionNoForwardsRequest#3e2793ba flags:# expired:flags.0?true prev_value:Bool new_value:Bool = MessageAction; +messageActionPollAppendAnswer#9da1cd6c answer:PollAnswer = MessageAction; +messageActionPollDeleteAnswer#399674dc answer:PollAnswer = MessageAction; +messageActionManagedBotCreated#16605e3e bot_id:long = MessageAction; + +dialog#fc89f7f3 flags:# pinned:flags.2?true unread_mark:flags.3?true view_forum_as_messages:flags.6?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; +dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; + +photoEmpty#2331b22d id:long = Photo; +photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; + +photoSizeEmpty#e17e23c type:string = PhotoSize; +photoSize#75c78e60 type:string w:int h:int size:int = PhotoSize; +photoCachedSize#21e1ad6 type:string w:int h:int bytes:bytes = PhotoSize; +photoStrippedSize#e0b0bc2e type:string bytes:bytes = PhotoSize; +photoSizeProgressive#fa3efb95 type:string w:int h:int sizes:Vector = PhotoSize; +photoPathSize#d8214d41 type:string bytes:bytes = PhotoSize; + +geoPointEmpty#1117dd5f = GeoPoint; +geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint; + +auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; +auth.sentCodeSuccess#2390fe44 authorization:auth.Authorization = auth.SentCode; +auth.sentCodePaymentRequired#e0955a3c store_product:string phone_code_hash:string support_email_address:string support_email_subject:string currency:string amount:long = auth.SentCode; + +auth.authorization#2ea2c0d4 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int future_auth_token:flags.2?bytes user:User = auth.Authorization; +auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; + +auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; + +inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer; +inputNotifyUsers#193b4417 = InputNotifyPeer; +inputNotifyChats#4a95e84e = InputNotifyPeer; +inputNotifyBroadcasts#b1db7c7e = InputNotifyPeer; +inputNotifyForumTopic#5c467992 peer:InputPeer top_msg_id:int = InputNotifyPeer; + +inputPeerNotifySettings#cacb6ae2 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_sound:flags.8?NotificationSound = InputPeerNotifySettings; + +peerNotifySettings#99622c0c flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int ios_sound:flags.3?NotificationSound android_sound:flags.4?NotificationSound other_sound:flags.5?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_ios_sound:flags.8?NotificationSound stories_android_sound:flags.9?NotificationSound stories_other_sound:flags.10?NotificationSound = PeerNotifySettings; + +peerSettings#f47741f7 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true business_bot_paused:flags.11?true business_bot_can_reply:flags.12?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int business_bot_id:flags.13?long business_bot_manage_url:flags.13?string charge_paid_message_stars:flags.14?long registration_month:flags.15?string phone_country:flags.16?string name_change_date:flags.17?int photo_change_date:flags.18?int = PeerSettings; + +wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; +wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; + +inputReportReasonSpam#58dbcab8 = ReportReason; +inputReportReasonViolence#1e22c78d = ReportReason; +inputReportReasonPornography#2e59d922 = ReportReason; +inputReportReasonChildAbuse#adf44ee3 = ReportReason; +inputReportReasonOther#c1e4a2b1 = ReportReason; +inputReportReasonCopyright#9b89f93a = ReportReason; +inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; +inputReportReasonFake#f5ddd6e7 = ReportReason; +inputReportReasonIllegalDrugs#a8eb2be = ReportReason; +inputReportReasonPersonalDetails#9ec7863d = ReportReason; + +userFull#6cbe645 flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true voice_messages_forbidden:flags.20?true translations_disabled:flags.23?true stories_pinned_available:flags.26?true blocked_my_stories_from:flags.27?true wallpaper_overridden:flags.28?true contact_require_premium:flags.29?true read_dates_private:flags.30?true flags2:# sponsored_enabled:flags2.7?true can_view_revenue:flags2.9?true bot_can_manage_emoji_status:flags2.10?true display_gifts_button:flags2.16?true noforwards_my_enabled:flags2.23?true noforwards_peer_enabled:flags2.24?true unofficial_security_risk:flags2.26?true id:long about:flags.1?string settings:PeerSettings personal_photo:flags.21?Photo profile_photo:flags.2?Photo fallback_photo:flags.22?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme:flags.15?ChatTheme private_forward_name:flags.16?string bot_group_admin_rights:flags.17?ChatAdminRights bot_broadcast_admin_rights:flags.18?ChatAdminRights wallpaper:flags.24?WallPaper stories:flags.25?PeerStories business_work_hours:flags2.0?BusinessWorkHours business_location:flags2.1?BusinessLocation business_greeting_message:flags2.2?BusinessGreetingMessage business_away_message:flags2.3?BusinessAwayMessage business_intro:flags2.4?BusinessIntro birthday:flags2.5?Birthday personal_channel_id:flags2.6?long personal_channel_message:flags2.6?int stargifts_count:flags2.8?int starref_program:flags2.11?StarRefProgram bot_verification:flags2.12?BotVerification send_paid_messages_stars:flags2.14?long disallowed_gifts:flags2.15?DisallowedGiftsSettings stars_rating:flags2.17?StarsRating stars_my_pending_rating:flags2.18?StarsRating stars_my_pending_rating_date:flags2.18?int main_tab:flags2.20?ProfileTab saved_music:flags2.21?Document note:flags2.22?TextWithEntities bot_manager_id:flags2.25?long = UserFull; + +contact#145ade0b user_id:long mutual:Bool = Contact; + +importedContact#c13e3c50 user_id:long client_id:long = ImportedContact; + +contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus; + +contacts.contactsNotModified#b74ba9d2 = contacts.Contacts; +contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts; + +contacts.importedContacts#77d01c3b imported:Vector popular_invites:Vector retry_contacts:Vector users:Vector = contacts.ImportedContacts; + +contacts.blocked#ade1591 blocked:Vector chats:Vector users:Vector = contacts.Blocked; +contacts.blockedSlice#e1664194 count:int blocked:Vector chats:Vector users:Vector = contacts.Blocked; + +messages.dialogs#15ba6c40 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsNotModified#f0e3e596 count:int = messages.Dialogs; + +messages.messages#1d73e7ea messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesSlice#5f206716 flags:# inexact:flags.1?true count:int next_rate:flags.0?int offset_id_offset:flags.2?int search_flood:flags.3?SearchPostsFlood messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.channelMessages#c776ba4e flags:# inexact:flags.1?true pts:int count:int offset_id_offset:flags.2?int messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesNotModified#74535f21 count:int = messages.Messages; + +messages.chats#64ff9fd5 chats:Vector = messages.Chats; +messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; + +messages.chatFull#e5d7d19c full_chat:ChatFull chats:Vector users:Vector = messages.ChatFull; + +messages.affectedHistory#b45c69d1 pts:int pts_count:int offset:int = messages.AffectedHistory; + +inputMessagesFilterEmpty#57e2f66c = MessagesFilter; +inputMessagesFilterPhotos#9609a51c = MessagesFilter; +inputMessagesFilterVideo#9fc00e65 = MessagesFilter; +inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; +inputMessagesFilterDocument#9eddf188 = MessagesFilter; +inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; +inputMessagesFilterGif#ffc86587 = MessagesFilter; +inputMessagesFilterVoice#50f5c392 = MessagesFilter; +inputMessagesFilterMusic#3751b49e = MessagesFilter; +inputMessagesFilterChatPhotos#3a20ecb8 = MessagesFilter; +inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFilter; +inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; +inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; +inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; +inputMessagesFilterGeo#e7026d0d = MessagesFilter; +inputMessagesFilterContacts#e062db83 = MessagesFilter; +inputMessagesFilterPinned#1bb00451 = MessagesFilter; +inputMessagesFilterPoll#fa2bc90a = MessagesFilter; + +updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; +updateMessageID#4e90bfd6 id:int random_id:long = Update; +updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; +updateUserTyping#2a17bf5c flags:# user_id:long top_msg_id:flags.0?int action:SendMessageAction = Update; +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; +updateChatParticipants#7761198 participants:ChatParticipants = Update; +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; +updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector = Update; +updateNewAuthorization#8951abef flags:# unconfirmed:flags.0?true hash:long date:flags.0?int device:flags.0?string location:flags.0?string = Update; +updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; +updateEncryptedChatTyping#1710f156 chat_id:int = Update; +updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; +updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; +updateDcOptions#8e5e9873 dc_options:Vector = Update; +updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; +updateServiceNotification#ebe46819 flags:# popup:flags.0?true invert_media:flags.2?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; +updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; +updateUserPhone#5492a13 user_id:long phone:string = Update; +updateReadHistoryInbox#9e84bc99 flags:# folder_id:flags.0?int peer:Peer top_msg_id:flags.1?int max_id:int still_unread_count:int pts:int pts_count:int = Update; +updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; +updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; +updateReadMessagesContents#f8227181 flags:# messages:Vector pts:int pts_count:int date:flags.0?int = Update; +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; +updateChannel#635b4c09 channel_id:long = Update; +updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; +updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; +updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Update; +updateStickerSets#31c24808 flags:# masks:flags.0?true emojis:flags.1?true = Update; +updateSavedGifs#9375341e = Update; +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; +updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; +updateDraftMessage#edfc111e flags:# peer:Peer top_msg_id:flags.0?int saved_peer_id:flags.1?Peer draft:DraftMessage = Update; +updateReadFeaturedStickers#571d2742 = Update; +updateRecentStickers#9a422c20 = Update; +updateConfig#a229dd06 = Update; +updatePtsChanged#3354678f = Update; +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; +updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; +updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; +updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; +updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; +updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; +updateLangPackTooLong#46560264 lang_code:string = Update; +updateLangPack#56022f4d difference:LangPackDifference = Update; +updateFavedStickers#e511996d = Update; +updateChannelReadMessagesContents#25f324f7 flags:# channel_id:long top_msg_id:flags.0?int saved_peer_id:flags.1?Peer messages:Vector = Update; +updateContactsReset#7084a7be = Update; +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; +updateDialogUnreadMark#b658f23e flags:# unread:flags.0?true peer:DialogPeer saved_peer_id:flags.1?Peer = Update; +updateMessagePoll#d64c522b flags:# peer:flags.1?Peer msg_id:flags.1?int top_msg_id:flags.2?int poll_id:long poll:flags.0?Poll results:PollResults = Update; +updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; +updateFolderPeers#19360dc0 folder_peers:Vector pts:int pts_count:int = Update; +updatePeerSettings#6a7e7366 peer:Peer settings:PeerSettings = Update; +updatePeerLocated#b4afcfb0 peers:Vector = Update; +updateNewScheduledMessage#39a51dfb message:Message = Update; +updateDeleteScheduledMessages#f2a71983 flags:# peer:Peer messages:Vector sent_messages:flags.0?Vector = Update; +updateTheme#8216fba3 theme:Theme = Update; +updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; +updateLoginToken#564fe691 = Update; +updateMessagePollVote#7699f014 poll_id:long peer:Peer options:Vector positions:Vector qts:int = Update; +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; +updateDialogFilterOrder#a5d72105 order:Vector = Update; +updateDialogFilters#3504914f = Update; +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; +updatePeerBlocked#ebe07752 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer = Update; +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; +updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChat#f89a6a4e chat_id:long = Update; +updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; +updateGroupCall#9d2216e0 flags:# live_story:flags.2?true peer:flags.1?Peer call:GroupCall = Update; +updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateChannelParticipant#985d3abb flags:# via_chatlist:flags.3?true channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; +updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; +updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#11dfa986 peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int = Update; +updateMessageReactions#1e297bfa flags:# peer:Peer msg_id:int top_msg_id:flags.0?int saved_peer_id:flags.1?Peer reactions:MessageReactions = Update; +updateAttachMenuBots#17b7a20b = Update; +updateWebViewResultSent#1592b79d query_id:long = Update; +updateBotMenuButton#14b85813 bot_id:long button:BotMenuButton = Update; +updateSavedRingtones#74d8be99 = Update; +updateTranscribedAudio#84cd5a flags:# pending:flags.0?true peer:Peer msg_id:int transcription_id:long text:string = Update; +updateReadFeaturedEmojiStickers#fb4c496c = Update; +updateUserEmojiStatus#28373599 user_id:long emoji_status:EmojiStatus = Update; +updateRecentEmojiStatuses#30f443db = Update; +updateRecentReactions#6f7863f4 = Update; +updateMoveStickerSetToTop#86fccf85 flags:# masks:flags.0?true emojis:flags.1?true stickerset:long = Update; +updateMessageExtendedMedia#d5a41724 peer:Peer msg_id:int extended_media:Vector = Update; +updateUser#20529438 user_id:long = Update; +updateAutoSaveSettings#ec05b097 = Update; +updateStory#75b3b798 peer:Peer story:StoryItem = Update; +updateReadStories#f74e932b peer:Peer max_id:int = Update; +updateStoryID#1bf335b9 id:int random_id:long = Update; +updateStoriesStealthMode#2c084dc1 stealth_mode:StoriesStealthMode = Update; +updateSentStoryReaction#7d627683 peer:Peer story_id:int reaction:Reaction = Update; +updateBotChatBoost#904dd49c peer:Peer boost:Boost qts:int = Update; +updateChannelViewForumAsMessages#7b68920 channel_id:long enabled:Bool = Update; +updatePeerWallpaper#ae3f101d flags:# wallpaper_overridden:flags.1?true peer:Peer wallpaper:flags.0?WallPaper = Update; +updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer old_reactions:Vector new_reactions:Vector qts:int = Update; +updateBotMessageReactions#9cb7759 peer:Peer msg_id:int date:int reactions:Vector qts:int = Update; +updateSavedDialogPinned#aeaf9e74 flags:# pinned:flags.0?true peer:DialogPeer = Update; +updatePinnedSavedDialogs#686c85a6 flags:# order:flags.0?Vector = Update; +updateSavedReactionTags#39c67432 = Update; +updateSmsJob#f16269d4 job_id:string = Update; +updateQuickReplies#f9470ab2 quick_replies:Vector = Update; +updateNewQuickReply#f53da717 quick_reply:QuickReply = Update; +updateDeleteQuickReply#53e6f1ec shortcut_id:int = Update; +updateQuickReplyMessage#3e050d0f message:Message = Update; +updateDeleteQuickReplyMessages#566fe7cd shortcut_id:int messages:Vector = Update; +updateBotBusinessConnect#8ae5c97a connection:BotBusinessConnection qts:int = Update; +updateBotNewBusinessMessage#9ddb347c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotEditBusinessMessage#7df587c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotDeleteBusinessMessage#a02a982e connection_id:string peer:Peer messages:Vector qts:int = Update; +updateNewStoryReaction#1824e40b story_id:int peer:Peer reaction:Reaction = Update; +updateStarsBalance#4e80a379 balance:StarsAmount = Update; +updateBusinessBotCallbackQuery#1ea2fda7 flags:# query_id:long user_id:long connection_id:string message:Message reply_to_message:flags.2?Message chat_instance:long data:flags.0?bytes = Update; +updateStarsRevenueStatus#a584b019 peer:Peer status:StarsRevenueStatus = Update; +updateBotPurchasedPaidMedia#283bd312 user_id:long payload:string qts:int = Update; +updatePaidReactionPrivacy#8b725fce private:PaidReactionPrivacy = Update; +updateSentPhoneCode#504aa18f sent_code:auth.SentCode = Update; +updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector next_offset:int = Update; +updateReadMonoForumInbox#77b0e372 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateReadMonoForumOutbox#a4a79376 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateMonoForumNoPaidException#9f812b08 flags:# exception:flags.0?true channel_id:long saved_peer_id:Peer = Update; +updateGroupCallMessage#d8326f0d call:InputGroupCall message:GroupCallMessage = Update; +updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update; +updatePinnedForumTopic#683b2c52 flags:# pinned:flags.0?true peer:Peer topic_id:int = Update; +updatePinnedForumTopics#def143d0 flags:# peer:Peer order:flags.0?Vector = Update; +updateDeleteGroupCallMessages#3e85e92c call:InputGroupCall messages:Vector = Update; +updateStarGiftAuctionState#48e246c2 gift_id:long state:StarGiftAuctionState = Update; +updateStarGiftAuctionUserState#dc58f31e gift_id:long user_state:StarGiftAuctionUserState = Update; +updateEmojiGameInfo#fb9c547a info:messages.EmojiGameInfo = Update; +updateStarGiftCraftFail#ac072444 = Update; +updateChatParticipantRank#bd8367b9 chat_id:long user_id:long rank:string version:int = Update; +updateManagedBot#4880ed9a user_id:long bot_id:long qts:int = Update; + +updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; + +updates.differenceEmpty#5d75a138 date:int seq:int = updates.Difference; +updates.difference#f49ca0 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector state:updates.State = updates.Difference; +updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector intermediate_state:updates.State = updates.Difference; +updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; + +updatesTooLong#e317af7e = Updates; +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShort#78d4dec1 update:Update date:int = Updates; +updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; +updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; +updateShortSentMessage#9015e101 flags:# out:flags.1?true id:int pts:int pts_count:int date:int media:flags.9?MessageMedia entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +photos.photos#8dca6aa5 photos:Vector users:Vector = photos.Photos; +photos.photosSlice#15051f54 count:int photos:Vector users:Vector = photos.Photos; + +photos.photo#20212ca8 photo:Photo users:Vector = photos.Photo; + +upload.file#96a18d5 type:storage.FileType mtime:int bytes:bytes = upload.File; +upload.fileCdnRedirect#f18cda44 dc_id:int file_token:bytes encryption_key:bytes encryption_iv:bytes file_hashes:Vector = upload.File; + +dcOption#18b7a10d flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true this_port_only:flags.5?true id:int ip_address:string port:int secret:flags.10?bytes = DcOption; + +config#cc1a241e flags:# default_p2p_contacts:flags.3?true preload_featured_stickers:flags.4?true revoke_pm_inbox:flags.6?true blocked_mode:flags.8?true force_try_ipv6:flags.14?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector dc_txt_domain_name:string chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int push_chat_period_ms:int push_chat_limit:int edit_time_limit:int revoke_time_limit:int revoke_pm_time_limit:int rating_e_decay:int stickers_recent_limit:int channels_read_media_period:int tmp_sessions:flags.0?int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string autoupdate_url_prefix:flags.7?string gif_search_username:flags.9?string venue_search_username:flags.10?string img_search_username:flags.11?string static_maps_provider:flags.12?string caption_length_max:int message_length_max:int webfile_dc_id:int suggested_lang_code:flags.2?string lang_pack_version:flags.2?int base_lang_pack_version:flags.2?int reactions_default:flags.15?Reaction autologin_token:flags.16?string = Config; + +nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; + +help.appUpdate#ccbbce30 flags:# can_not_skip:flags.0?true id:int version:string text:string entities:Vector document:flags.1?Document url:flags.2?string sticker:flags.3?Document = help.AppUpdate; +help.noAppUpdate#c45a6536 = help.AppUpdate; + +help.inviteText#18cb9f78 message:string = help.InviteText; + +encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; +encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; + +inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; + +encryptedFileEmpty#c21f497e = EncryptedFile; +encryptedFile#a8008cd8 id:long access_hash:long size:long dc_id:int key_fingerprint:int = EncryptedFile; + +inputEncryptedFileEmpty#1837c364 = InputEncryptedFile; +inputEncryptedFileUploaded#64bd0306 id:long parts:int md5_checksum:string key_fingerprint:int = InputEncryptedFile; +inputEncryptedFile#5a17b5e5 id:long access_hash:long = InputEncryptedFile; +inputEncryptedFileBigUploaded#2dc173c8 id:long parts:int key_fingerprint:int = InputEncryptedFile; + +encryptedMessage#ed18c118 random_id:long chat_id:int date:int bytes:bytes file:EncryptedFile = EncryptedMessage; +encryptedMessageService#23734b06 random_id:long chat_id:int date:int bytes:bytes = EncryptedMessage; + +messages.dhConfigNotModified#c0e24635 random:bytes = messages.DhConfig; +messages.dhConfig#2c221edd g:int p:bytes version:int random:bytes = messages.DhConfig; + +messages.sentEncryptedMessage#560f8935 date:int = messages.SentEncryptedMessage; +messages.sentEncryptedFile#9493ff32 date:int file:EncryptedFile = messages.SentEncryptedMessage; + +inputDocumentEmpty#72f0eaae = InputDocument; +inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; + +documentEmpty#36f8c871 id:long = Document; +document#8fd4c4d8 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:long thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; + +help.support#17c6b5f6 phone_number:string user:User = help.Support; + +notifyPeer#9fd40bd8 peer:Peer = NotifyPeer; +notifyUsers#b4c83b4c = NotifyPeer; +notifyChats#c007cec3 = NotifyPeer; +notifyBroadcasts#d612e8ef = NotifyPeer; +notifyForumTopic#226e6308 peer:Peer top_msg_id:int = NotifyPeer; + +sendMessageTypingAction#16bf744e = SendMessageAction; +sendMessageCancelAction#fd5ec8f5 = SendMessageAction; +sendMessageRecordVideoAction#a187d66f = SendMessageAction; +sendMessageUploadVideoAction#e9763aec progress:int = SendMessageAction; +sendMessageRecordAudioAction#d52f73f7 = SendMessageAction; +sendMessageUploadAudioAction#f351d7ab progress:int = SendMessageAction; +sendMessageUploadPhotoAction#d1d34a26 progress:int = SendMessageAction; +sendMessageUploadDocumentAction#aa0cd9e4 progress:int = SendMessageAction; +sendMessageGeoLocationAction#176f8ba1 = SendMessageAction; +sendMessageChooseContactAction#628cbc6f = SendMessageAction; +sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; +sendMessageRecordRoundAction#88f27fbc = SendMessageAction; +sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; +speakingInGroupCallAction#d92c2285 = SendMessageAction; +sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; +sendMessageTextDraftAction#376d975c random_id:long text:TextWithEntities = SendMessageAction; + +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; + +inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; +inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; +inputPrivacyKeyPhoneCall#fabadc5f = InputPrivacyKey; +inputPrivacyKeyPhoneP2P#db9e70d2 = InputPrivacyKey; +inputPrivacyKeyForwards#a4dd4c08 = InputPrivacyKey; +inputPrivacyKeyProfilePhoto#5719bacc = InputPrivacyKey; +inputPrivacyKeyPhoneNumber#352dafa = InputPrivacyKey; +inputPrivacyKeyAddedByPhone#d1219bdd = InputPrivacyKey; +inputPrivacyKeyVoiceMessages#aee69d68 = InputPrivacyKey; +inputPrivacyKeyAbout#3823cc40 = InputPrivacyKey; +inputPrivacyKeyBirthday#d65a11cc = InputPrivacyKey; +inputPrivacyKeyStarGiftsAutoSave#e1732341 = InputPrivacyKey; +inputPrivacyKeyNoPaidMessages#bdc597b4 = InputPrivacyKey; +inputPrivacyKeySavedMusic#4dbe9226 = InputPrivacyKey; + +privacyKeyStatusTimestamp#bc2eab30 = PrivacyKey; +privacyKeyChatInvite#500e6dfa = PrivacyKey; +privacyKeyPhoneCall#3d662b7b = PrivacyKey; +privacyKeyPhoneP2P#39491cc8 = PrivacyKey; +privacyKeyForwards#69ec56a3 = PrivacyKey; +privacyKeyProfilePhoto#96151fed = PrivacyKey; +privacyKeyPhoneNumber#d19ae46d = PrivacyKey; +privacyKeyAddedByPhone#42ffd42b = PrivacyKey; +privacyKeyVoiceMessages#697f414 = PrivacyKey; +privacyKeyAbout#a486b761 = PrivacyKey; +privacyKeyBirthday#2000a518 = PrivacyKey; +privacyKeyStarGiftsAutoSave#2ca4fdf8 = PrivacyKey; +privacyKeyNoPaidMessages#17d348d2 = PrivacyKey; +privacyKeySavedMusic#ff7a571b = PrivacyKey; + +inputPrivacyValueAllowContacts#d09e07b = InputPrivacyRule; +inputPrivacyValueAllowAll#184b35ce = InputPrivacyRule; +inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; +inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; +inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; +inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; +inputPrivacyValueAllowCloseFriends#2f453e49 = InputPrivacyRule; +inputPrivacyValueAllowPremium#77cdc9f1 = InputPrivacyRule; +inputPrivacyValueAllowBots#5a4fcce5 = InputPrivacyRule; +inputPrivacyValueDisallowBots#c4e57915 = InputPrivacyRule; + +privacyValueAllowContacts#fffe1bac = PrivacyRule; +privacyValueAllowAll#65427b82 = PrivacyRule; +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; +privacyValueDisallowContacts#f888fa1a = PrivacyRule; +privacyValueDisallowAll#8b73e763 = PrivacyRule; +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; +privacyValueAllowCloseFriends#f7e8d89b = PrivacyRule; +privacyValueAllowPremium#ece9814b = PrivacyRule; +privacyValueAllowBots#21461b5d = PrivacyRule; +privacyValueDisallowBots#f6a5f82f = PrivacyRule; + +account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; + +accountDaysTTL#b8d0afdf days:int = AccountDaysTTL; + +documentAttributeImageSize#6c37c15c w:int h:int = DocumentAttribute; +documentAttributeAnimated#11b58939 = DocumentAttribute; +documentAttributeSticker#6319d612 flags:# mask:flags.1?true alt:string stickerset:InputStickerSet mask_coords:flags.0?MaskCoords = DocumentAttribute; +documentAttributeVideo#43c57c48 flags:# round_message:flags.0?true supports_streaming:flags.1?true nosound:flags.3?true duration:double w:int h:int preload_prefix_size:flags.2?int video_start_ts:flags.4?double video_codec:flags.5?string = DocumentAttribute; +documentAttributeAudio#9852f9c6 flags:# voice:flags.10?true duration:int title:flags.0?string performer:flags.1?string waveform:flags.2?bytes = DocumentAttribute; +documentAttributeFilename#15590068 file_name:string = DocumentAttribute; +documentAttributeHasStickers#9801d2f7 = DocumentAttribute; +documentAttributeCustomEmoji#fd149899 flags:# free:flags.0?true text_color:flags.1?true alt:string stickerset:InputStickerSet = DocumentAttribute; + +messages.stickersNotModified#f1749a22 = messages.Stickers; +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; + +stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; + +messages.allStickersNotModified#e86602c3 = messages.AllStickers; +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; + +messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; + +webPageEmpty#211a1788 flags:# id:long url:flags.0?string = WebPage; +webPagePending#b0d13e47 flags:# id:long url:flags.0?string date:int = WebPage; +webPage#e89c45b2 flags:# has_large_media:flags.13?true video_cover_photo:flags.14?true id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; + +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true unconfirmed:flags.5?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; + +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; + +account.password#957b50fb flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int login_email_pattern:flags.6?string = account.Password; + +account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; + +account.passwordInputSettings#c23727c9 flags:# new_algo:flags.0?PasswordKdfAlgo new_password_hash:flags.0?bytes hint:flags.0?string email:flags.1?string new_secure_settings:flags.2?SecureSecretSettings = account.PasswordInputSettings; + +auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; + +receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; + +chatInviteExported#a22cbd96 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int subscription_expired:flags.10?int title:flags.8?string subscription_pricing:flags.9?StarsSubscriptionPricing = ExportedChatInvite; +chatInvitePublicJoinRequests#ed107ab7 = ExportedChatInvite; + +chatInviteAlready#5a686d7c chat:Chat = ChatInvite; +chatInvite#5c9d3702 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true verified:flags.7?true scam:flags.8?true fake:flags.9?true can_refulfill_subscription:flags.11?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector color:int subscription_pricing:flags.10?StarsSubscriptionPricing subscription_form_id:flags.12?long bot_verification:flags.13?BotVerification = ChatInvite; +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; + +inputStickerSetEmpty#ffb62b95 = InputStickerSet; +inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; +inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; +inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; +inputStickerSetPremiumGifts#c88b3b02 = InputStickerSet; +inputStickerSetEmojiGenericAnimations#4c4d4ce = InputStickerSet; +inputStickerSetEmojiDefaultStatuses#29d0f5ee = InputStickerSet; +inputStickerSetEmojiDefaultTopicIcons#44c1f8e9 = InputStickerSet; +inputStickerSetEmojiChannelDefaultStatuses#49748553 = InputStickerSet; +inputStickerSetTonGifts#1cf671a0 = InputStickerSet; + +stickerSet#2dd14edc flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true emojis:flags.7?true text_color:flags.9?true channel_emoji_status:flags.10?true creator:flags.11?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int thumb_document_id:flags.8?long count:int hash:int = StickerSet; + +messages.stickerSet#6e153f16 set:StickerSet packs:Vector keywords:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; + +botCommand#c27ac8c7 command:string description:string = BotCommand; + +botInfo#4d8a0299 flags:# has_preview_medias:flags.6?true user_id:flags.0?long description:flags.1?string description_photo:flags.4?Photo description_document:flags.5?Document commands:flags.2?Vector menu_button:flags.3?BotMenuButton privacy_policy_url:flags.7?string app_settings:flags.8?BotAppSettings verifier_settings:flags.9?BotVerifierSettings = BotInfo; + +keyboardButton#7d170cff flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrl#d80c25ec flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonCallback#e62bc960 flags:# requires_password:flags.0?true style:flags.10?KeyboardButtonStyle text:string data:bytes = KeyboardButton; +keyboardButtonRequestPhone#417efd8f flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonRequestGeoLocation#aa40f94d flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonSwitchInline#991399fc flags:# same_peer:flags.0?true style:flags.10?KeyboardButtonStyle text:string query:string peer_types:flags.1?Vector = KeyboardButton; +keyboardButtonGame#89c590f9 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonBuy#3fa53905 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrlAuth#f51006f9 flags:# style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +inputKeyboardButtonUrlAuth#68013e72 flags:# request_write_access:flags.0?true style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +keyboardButtonRequestPoll#7a11d782 flags:# style:flags.10?KeyboardButtonStyle quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#7d5e07c7 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#c0fd5d09 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:long = KeyboardButton; +keyboardButtonWebView#e846b1a0 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonSimpleWebView#e15c4370 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonRequestPeer#5b0f15f5 flags:# style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +inputKeyboardButtonRequestPeer#2b78156 flags:# name_requested:flags.0?true username_requested:flags.1?true photo_requested:flags.2?true style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +keyboardButtonCopy#bcc4af10 flags:# style:flags.10?KeyboardButtonStyle text:string copy_text:string = KeyboardButton; + +keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; + +replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true persistent:flags.4?true rows:Vector placeholder:flags.3?string = ReplyMarkup; +replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; + +messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; +messageEntityMention#fa04579d offset:int length:int = MessageEntity; +messageEntityHashtag#6f635b0d offset:int length:int = MessageEntity; +messageEntityBotCommand#6cef8ac7 offset:int length:int = MessageEntity; +messageEntityUrl#6ed02538 offset:int length:int = MessageEntity; +messageEntityEmail#64e475c2 offset:int length:int = MessageEntity; +messageEntityBold#bd610bc9 offset:int length:int = MessageEntity; +messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; +messageEntityCode#28a20571 offset:int length:int = MessageEntity; +messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; +messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; +inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; +messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; +messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; +messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; +messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; +messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; +messageEntityCustomEmoji#c8cf05f8 offset:int length:int document_id:long = MessageEntity; +messageEntityBlockquote#f1ccaaac flags:# collapsed:flags.0?true offset:int length:int = MessageEntity; +messageEntityFormattedDate#904ac7c7 flags:# relative:flags.0?true short_time:flags.1?true long_time:flags.2?true short_date:flags.3?true long_date:flags.4?true day_of_week:flags.5?true offset:int length:int date:int = MessageEntity; +messageEntityDiffInsert#71777116 offset:int length:int = MessageEntity; +messageEntityDiffReplace#c6c1e5a7 offset:int length:int old_text:string = MessageEntity; +messageEntityDiffDelete#652c1c5 offset:int length:int = MessageEntity; + +inputChannelEmpty#ee8c1e86 = InputChannel; +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; + +contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; + +messageRange#ae30253 min_id:int max_id:int = MessageRange; + +updates.channelDifferenceEmpty#3e11affb flags:# final:flags.0?true pts:int timeout:flags.1?int = updates.ChannelDifference; +updates.channelDifferenceTooLong#a4bcc6fe flags:# final:flags.0?true timeout:flags.1?int dialog:Dialog messages:Vector chats:Vector users:Vector = updates.ChannelDifference; +updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int new_messages:Vector other_updates:Vector chats:Vector users:Vector = updates.ChannelDifference; + +channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; +channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; + +channelParticipant#1bd54456 flags:# user_id:long date:int subscription_until_date:flags.0?int rank:flags.2?string = ChannelParticipant; +channelParticipantSelf#a9478a1a flags:# via_request:flags.0?true user_id:long inviter_id:long date:int subscription_until_date:flags.1?int rank:flags.2?string = ChannelParticipant; +channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; +channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; +channelParticipantBanned#d5f0ad91 flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights rank:flags.2?string = ChannelParticipant; +channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; + +channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; +channelParticipantsAdmins#b4608969 = ChannelParticipantsFilter; +channelParticipantsKicked#a3b54985 q:string = ChannelParticipantsFilter; +channelParticipantsBots#b0d1865b = ChannelParticipantsFilter; +channelParticipantsBanned#1427a5e1 q:string = ChannelParticipantsFilter; +channelParticipantsSearch#656ac4b q:string = ChannelParticipantsFilter; +channelParticipantsContacts#bb6ae88d q:string = ChannelParticipantsFilter; +channelParticipantsMentions#e04b5ceb flags:# q:flags.0?string top_msg_id:flags.1?int = ChannelParticipantsFilter; + +channels.channelParticipants#9ab0feaf count:int participants:Vector chats:Vector users:Vector = channels.ChannelParticipants; +channels.channelParticipantsNotModified#f0173fe9 = channels.ChannelParticipants; + +channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector users:Vector = channels.ChannelParticipant; + +help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; + +messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; + +inputBotInlineMessageMediaAuto#3380c786 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaGeo#96929a85 flags:# geo_point:InputGeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaVenue#417bbf11 flags:# geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaContact#a6edbffd flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageGame#4b425864 flags:# reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaInvoice#d7e78225 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaWebPage#bddcc510 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true optional:flags.6?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineResult#88bf9319 flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?InputWebDocument content:flags.5?InputWebDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultPhoto#a8d864a7 id:string type:string photo:InputPhoto send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultDocument#fff8fdc4 flags:# id:string type:string title:flags.1?string description:flags.2?string document:InputDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultGame#4fa417f2 id:string short_name:string send_message:InputBotInlineMessage = InputBotInlineResult; + +botInlineMessageMediaAuto#764cf810 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageText#8c7f65e2 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaGeo#51846fd flags:# geo:GeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaVenue#8a86659c flags:# geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaContact#18d1cdc2 flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaInvoice#354a9b09 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument currency:string total_amount:long reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaWebPage#809ad9a6 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true manual:flags.7?true safe:flags.8?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineResult#11965f3a flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?WebDocument content:flags.5?WebDocument send_message:BotInlineMessage = BotInlineResult; +botInlineMediaResult#17db940b flags:# id:string type:string photo:flags.0?Photo document:flags.1?Document title:flags.2?string description:flags.3?string send_message:BotInlineMessage = BotInlineResult; + +messages.botResults#e021f2f6 flags:# gallery:flags.0?true query_id:long next_offset:flags.1?string switch_pm:flags.2?InlineBotSwitchPM switch_webview:flags.3?InlineBotWebView results:Vector cache_time:int users:Vector = messages.BotResults; + +exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; + +messageFwdHeader#4e4df4bb flags:# imported:flags.7?true saved_out:flags.11?true from_id:flags.0?Peer from_name:flags.5?string date:int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int saved_from_id:flags.8?Peer saved_from_name:flags.9?string saved_date:flags.10?int psa_type:flags.6?string = MessageFwdHeader; + +auth.codeTypeSms#72a3158c = auth.CodeType; +auth.codeTypeCall#741cd3e3 = auth.CodeType; +auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; + +auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; +auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; +auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; +auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; +auth.sentCodeTypeEmailCode#f450f59b flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int reset_available_period:flags.3?int reset_pending_date:flags.4?int = auth.SentCodeType; +auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; +auth.sentCodeTypeFirebaseSms#9fd736 flags:# nonce:flags.0?bytes play_integrity_project_id:flags.2?long play_integrity_nonce:flags.2?bytes receipt:flags.1?string push_timeout:flags.1?int length:int = auth.SentCodeType; +auth.sentCodeTypeSmsWord#a416ac81 flags:# beginning:flags.0?string = auth.SentCodeType; +auth.sentCodeTypeSmsPhrase#b37794af flags:# beginning:flags.0?string = auth.SentCodeType; + +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; + +messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; + +inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; + +inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; + +messages.peerDialogs#3371c354 dialogs:Vector messages:Vector chats:Vector users:Vector state:updates.State = messages.PeerDialogs; + +topPeer#edcdc05b peer:Peer rating:double = TopPeer; + +topPeerCategoryBotsPM#ab661b5b = TopPeerCategory; +topPeerCategoryBotsInline#148677e2 = TopPeerCategory; +topPeerCategoryCorrespondents#637b7ed = TopPeerCategory; +topPeerCategoryGroups#bd17a14a = TopPeerCategory; +topPeerCategoryChannels#161d9628 = TopPeerCategory; +topPeerCategoryPhoneCalls#1e76a78c = TopPeerCategory; +topPeerCategoryForwardUsers#a8406ca9 = TopPeerCategory; +topPeerCategoryForwardChats#fbeec0f0 = TopPeerCategory; +topPeerCategoryBotsApp#fd9e7bec = TopPeerCategory; + +topPeerCategoryPeers#fb834291 category:TopPeerCategory count:int peers:Vector = TopPeerCategoryPeers; + +contacts.topPeersNotModified#de266ef5 = contacts.TopPeers; +contacts.topPeers#70b772a8 categories:Vector chats:Vector users:Vector = contacts.TopPeers; +contacts.topPeersDisabled#b52c939d = contacts.TopPeers; + +draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; +draftMessage#96eaa5eb flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo message:string entities:flags.3?Vector media:flags.5?InputMedia date:int effect:flags.7?long suggested_post:flags.8?SuggestedPost = DraftMessage; + +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; +messages.featuredStickers#be382906 flags:# premium:flags.0?true hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; + +messages.recentStickersNotModified#b17f890 = messages.RecentStickers; +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; + +messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; + +messages.stickerSetInstallResultSuccess#38641628 = messages.StickerSetInstallResult; +messages.stickerSetInstallResultArchive#35e410a8 sets:Vector = messages.StickerSetInstallResult; + +stickerSetCovered#6410a5d2 set:StickerSet cover:Document = StickerSetCovered; +stickerSetMultiCovered#3407e51b set:StickerSet covers:Vector = StickerSetCovered; +stickerSetFullCovered#40d13c0e set:StickerSet packs:Vector keywords:Vector documents:Vector = StickerSetCovered; +stickerSetNoCovered#77b15d1c set:StickerSet = StickerSetCovered; + +maskCoords#aed6dbb2 n:int x:double y:double zoom:double = MaskCoords; + +inputStickeredMediaPhoto#4a992157 id:InputPhoto = InputStickeredMedia; +inputStickeredMediaDocument#438865b id:InputDocument = InputStickeredMedia; + +game#bdf9653b flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document = Game; + +inputGameID#32c3e77 id:long access_hash:long = InputGame; +inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; + +highScore#73a379eb pos:int user_id:long score:int = HighScore; + +messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; + +textEmpty#dc3d824f = RichText; +textPlain#744694e0 text:string = RichText; +textBold#6724abc4 text:RichText = RichText; +textItalic#d912a59c text:RichText = RichText; +textUnderline#c12622c4 text:RichText = RichText; +textStrike#9bf8bb95 text:RichText = RichText; +textFixed#6c3f19b9 text:RichText = RichText; +textUrl#3c2884c1 text:RichText url:string webpage_id:long = RichText; +textEmail#de5a0dd6 text:RichText email:string = RichText; +textConcat#7e6260d7 texts:Vector = RichText; +textSubscript#ed6a8504 text:RichText = RichText; +textSuperscript#c7fb5e01 text:RichText = RichText; +textMarked#34b8621 text:RichText = RichText; +textPhone#1ccb966a text:RichText phone:string = RichText; +textImage#81ccf4f document_id:long w:int h:int = RichText; +textAnchor#35553762 text:RichText name:string = RichText; + +pageBlockUnsupported#13567e8a = PageBlock; +pageBlockTitle#70abc3fd text:RichText = PageBlock; +pageBlockSubtitle#8ffa9a1f text:RichText = PageBlock; +pageBlockAuthorDate#baafe5e0 author:RichText published_date:int = PageBlock; +pageBlockHeader#bfd064ec text:RichText = PageBlock; +pageBlockSubheader#f12bb6e1 text:RichText = PageBlock; +pageBlockParagraph#467a0766 text:RichText = PageBlock; +pageBlockPreformatted#c070d93e text:RichText language:string = PageBlock; +pageBlockFooter#48870999 text:RichText = PageBlock; +pageBlockDivider#db20b188 = PageBlock; +pageBlockAnchor#ce0d37b0 name:string = PageBlock; +pageBlockList#e4e88011 items:Vector = PageBlock; +pageBlockBlockquote#263d7c26 text:RichText caption:RichText = PageBlock; +pageBlockPullquote#4f4456d3 text:RichText caption:RichText = PageBlock; +pageBlockPhoto#1759c560 flags:# photo_id:long caption:PageCaption url:flags.0?string webpage_id:flags.0?long = PageBlock; +pageBlockVideo#7c8fe7b6 flags:# autoplay:flags.0?true loop:flags.1?true video_id:long caption:PageCaption = PageBlock; +pageBlockCover#39f23300 cover:PageBlock = PageBlock; +pageBlockEmbed#a8718dc5 flags:# full_width:flags.0?true allow_scrolling:flags.3?true url:flags.1?string html:flags.2?string poster_photo_id:flags.4?long w:flags.5?int h:flags.5?int caption:PageCaption = PageBlock; +pageBlockEmbedPost#f259a80b url:string webpage_id:long author_photo_id:long author:string date:int blocks:Vector caption:PageCaption = PageBlock; +pageBlockCollage#65a0fa4d items:Vector caption:PageCaption = PageBlock; +pageBlockSlideshow#31f9590 items:Vector caption:PageCaption = PageBlock; +pageBlockChannel#ef1751b5 channel:Chat = PageBlock; +pageBlockAudio#804361ea audio_id:long caption:PageCaption = PageBlock; +pageBlockKicker#1e148390 text:RichText = PageBlock; +pageBlockTable#bf4dea82 flags:# bordered:flags.0?true striped:flags.1?true title:RichText rows:Vector = PageBlock; +pageBlockOrderedList#9a8ae1e1 items:Vector = PageBlock; +pageBlockDetails#76768bed flags:# open:flags.0?true blocks:Vector title:RichText = PageBlock; +pageBlockRelatedArticles#16115a96 title:RichText articles:Vector = PageBlock; +pageBlockMap#a44f3ef6 geo:GeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; + +phoneCallDiscardReasonMissed#85e42301 = PhoneCallDiscardReason; +phoneCallDiscardReasonDisconnect#e095c1a0 = PhoneCallDiscardReason; +phoneCallDiscardReasonHangup#57adc690 = PhoneCallDiscardReason; +phoneCallDiscardReasonBusy#faf7e8c9 = PhoneCallDiscardReason; +phoneCallDiscardReasonMigrateConferenceCall#9fbbf1f7 slug:string = PhoneCallDiscardReason; + +dataJSON#7d748d04 data:string = DataJSON; + +labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; + +invoice#49ee584 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true recurring:flags.9?true currency:string prices:Vector max_tip_amount:flags.8?long suggested_tip_amounts:flags.8?Vector terms_url:flags.10?string subscription_period:flags.11?int = Invoice; + +paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; + +postAddress#1e8caaeb street_line1:string street_line2:string city:string state:string country_iso2:string post_code:string = PostAddress; + +paymentRequestedInfo#909c3f94 flags:# name:flags.0?string phone:flags.1?string email:flags.2?string shipping_address:flags.3?PostAddress = PaymentRequestedInfo; + +paymentSavedCredentialsCard#cdc27a1f id:string title:string = PaymentSavedCredentials; + +webDocument#1c570ed1 url:string access_hash:long size:int mime_type:string attributes:Vector = WebDocument; +webDocumentNoProxy#f9c8bcc6 url:string size:int mime_type:string attributes:Vector = WebDocument; + +inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector = InputWebDocument; + +inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation; +inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation; +inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# small:flags.2?true document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation; + +upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; + +payments.paymentForm#a0058751 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON additional_methods:flags.6?Vector saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?Vector users:Vector = payments.PaymentForm; +payments.paymentFormStars#7bf6b15c flags:# form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice users:Vector = payments.PaymentForm; +payments.paymentFormStarGift#b425cfe1 form_id:long invoice:Invoice = payments.PaymentForm; + +payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; + +payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; +payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; + +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; +payments.paymentReceiptStars#dabbf83a flags:# date:int bot_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice currency:string total_amount:long transaction_id:string users:Vector = payments.PaymentReceipt; + +payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; + +inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; +inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsGooglePay#8ac32801 payment_token:DataJSON = InputPaymentCredentials; + +account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; + +shippingOption#b6213cdf id:string title:string prices:Vector = ShippingOption; + +inputStickerSetItem#32da9e9c flags:# document:InputDocument emoji:string mask_coords:flags.0?MaskCoords keywords:flags.1?string = InputStickerSetItem; + +inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; + +phoneCallEmpty#5366c915 id:long = PhoneCall; +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#30535af5 flags:# p2p_allowed:flags.5?true video:flags.6?true conference_supported:flags.8?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int custom_parameters:flags.7?DataJSON = PhoneCall; +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; + +phoneConnection#9cc123c7 flags:# tcp:flags.0?true id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; + +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; + +phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; + +upload.cdnFileReuploadNeeded#eea8e46e request_token:bytes = upload.CdnFile; +upload.cdnFile#a99fca4f bytes:bytes = upload.CdnFile; + +cdnPublicKey#c982eaba dc_id:int public_key:string = CdnPublicKey; + +cdnConfig#5725e40a public_keys:Vector = CdnConfig; + +langPackString#cad181f6 key:string value:string = LangPackString; +langPackStringPluralized#6c47ac9f flags:# key:string zero_value:flags.0?string one_value:flags.1?string two_value:flags.2?string few_value:flags.3?string many_value:flags.4?string other_value:string = LangPackString; +langPackStringDeleted#2979eeb2 key:string = LangPackString; + +langPackDifference#f385c1f6 lang_code:string from_version:int version:int strings:Vector = LangPackDifference; + +langPackLanguage#eeca5ce3 flags:# official:flags.0?true rtl:flags.2?true beta:flags.3?true name:string native_name:string lang_code:string base_lang_code:flags.1?string plural_code:string strings_count:int translated_count:int translations_url:string = LangPackLanguage; + +channelAdminLogEventActionChangeTitle#e6dfb825 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAbout#55188a2e prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsername#6a4afc38 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePhoto#434bd2af prev_photo:Photo new_photo:Photo = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleInvites#1b7907ae new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatures#26ae0971 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionUpdatePinned#e9e82c18 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionEditMessage#709b2405 prev_message:Message new_message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteMessage#42e047bb message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoin#183040d3 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantLeave#f89777f2 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantInvite#e31c34d8 participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleBan#e6d83d7e prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleAdmin#d5676710 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; +channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionDiscardGroupCall#db9f9140 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantMute#f92424d2 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantUnmute#e64429c0 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleGroupCallSetting#56d6a247 join_muted:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByInvite#fe9fc158 flags:# via_chatlist:flags.0?true invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteDelete#5a50fca4 invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePeerColor#5796e780 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeProfilePeerColor#5e477b25 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeWallpaper#31bb5d52 prev_value:WallPaper new_value:WallPaper = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStatus#3ea9feb1 prev_value:EmojiStatus new_value:EmojiStatus = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStickerSet#46d840ab prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatureProfiles#60a79c79 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantSubExtend#64642db3 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAutotranslation#c517f77e new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantEditRank#5806b4ec user_id:long prev_rank:string new_rank:string = ChannelAdminLogEventAction; + +channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; + +channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; + +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true sub_extend:flags.18?true edit_rank:flags.19?true = ChannelAdminLogEventsFilter; + +popularContact#5ce14175 client_id:long importers:int = PopularContact; + +messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; + +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; +recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; + +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; + +account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; + +inputMessageID#a676a322 id:int = InputMessage; +inputMessageReplyTo#bad88395 id:int = InputMessage; +inputMessagePinned#86872538 = InputMessage; +inputMessageCallbackQuery#acfa1a7e id:int query_id:long = InputMessage; + +inputDialogPeer#fcaafeb7 peer:InputPeer = InputDialogPeer; +inputDialogPeerFolder#64600527 folder_id:int = InputDialogPeer; + +dialogPeer#e56dbf05 peer:Peer = DialogPeer; +dialogPeerFolder#514519e2 folder_id:int = DialogPeer; + +messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; + +fileHash#f39b035c offset:long limit:int hash:bytes = FileHash; + +inputClientProxy#75588b3f address:string port:int = InputClientProxy; + +help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; +help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; + +inputSecureFileUploaded#3334b0f0 id:long parts:int md5_checksum:string file_hash:bytes secret:bytes = InputSecureFile; +inputSecureFile#5367e5be id:long access_hash:long = InputSecureFile; + +secureFileEmpty#64199744 = SecureFile; +secureFile#7d09c27e id:long access_hash:long size:long dc_id:int date:int file_hash:bytes secret:bytes = SecureFile; + +secureData#8aeabec3 data:bytes data_hash:bytes secret:bytes = SecureData; + +securePlainPhone#7d6099dd phone:string = SecurePlainData; +securePlainEmail#21ec5a5f email:string = SecurePlainData; + +secureValueTypePersonalDetails#9d2a81e3 = SecureValueType; +secureValueTypePassport#3dac6a00 = SecureValueType; +secureValueTypeDriverLicense#6e425c4 = SecureValueType; +secureValueTypeIdentityCard#a0d0744b = SecureValueType; +secureValueTypeInternalPassport#99a48f23 = SecureValueType; +secureValueTypeAddress#cbe31e26 = SecureValueType; +secureValueTypeUtilityBill#fc36954e = SecureValueType; +secureValueTypeBankStatement#89137c0d = SecureValueType; +secureValueTypeRentalAgreement#8b883488 = SecureValueType; +secureValueTypePassportRegistration#99e3806a = SecureValueType; +secureValueTypeTemporaryRegistration#ea02ec33 = SecureValueType; +secureValueTypePhone#b320aadb = SecureValueType; +secureValueTypeEmail#8e3ca7ee = SecureValueType; + +secureValue#187fa0ca flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?SecureFile reverse_side:flags.2?SecureFile selfie:flags.3?SecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData hash:bytes = SecureValue; + +inputSecureValue#db21d0a7 flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?InputSecureFile reverse_side:flags.2?InputSecureFile selfie:flags.3?InputSecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData = InputSecureValue; + +secureValueHash#ed1ecdb0 type:SecureValueType hash:bytes = SecureValueHash; + +secureValueErrorData#e8a40bd9 type:SecureValueType data_hash:bytes field:string text:string = SecureValueError; +secureValueErrorFrontSide#be3dfa type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorReverseSide#868a2aa5 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorSelfie#e537ced6 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFile#7a700873 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFiles#666220e9 type:SecureValueType file_hash:Vector text:string = SecureValueError; +secureValueError#869d758f type:SecureValueType hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFile#a1144770 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFiles#34636dd8 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureCredentialsEncrypted#33f0ea47 data:bytes hash:bytes secret:bytes = SecureCredentialsEncrypted; + +account.authorizationForm#ad2e1cd8 flags:# required_types:Vector values:Vector errors:Vector users:Vector privacy_policy_url:flags.0?string = account.AuthorizationForm; + +account.sentEmailCode#811f854f email_pattern:string length:int = account.SentEmailCode; + +help.deepLinkInfoEmpty#66afa166 = help.DeepLinkInfo; +help.deepLinkInfo#6a4ee832 flags:# update_app:flags.0?true message:string entities:flags.1?Vector = help.DeepLinkInfo; + +savedPhoneContact#1142bd56 phone:string first_name:string last_name:string date:int = SavedContact; + +account.takeout#4dba4501 id:long = account.Takeout; + +passwordKdfAlgoUnknown#d45ab096 = PasswordKdfAlgo; +passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow#3a912d4a salt1:bytes salt2:bytes g:int p:bytes = PasswordKdfAlgo; + +securePasswordKdfAlgoUnknown#4a8537 = SecurePasswordKdfAlgo; +securePasswordKdfAlgoPBKDF2HMACSHA512iter100000#bbf2dda0 salt:bytes = SecurePasswordKdfAlgo; +securePasswordKdfAlgoSHA512#86471d92 salt:bytes = SecurePasswordKdfAlgo; + +secureSecretSettings#1527bcac secure_algo:SecurePasswordKdfAlgo secure_secret:bytes secure_secret_id:long = SecureSecretSettings; + +inputCheckPasswordEmpty#9880f658 = InputCheckPasswordSRP; +inputCheckPasswordSRP#d27ff082 srp_id:long A:bytes M1:bytes = InputCheckPasswordSRP; + +secureRequiredType#829d99da flags:# native_names:flags.0?true selfie_required:flags.1?true translation_required:flags.2?true type:SecureValueType = SecureRequiredType; +secureRequiredTypeOneOf#27477b4 types:Vector = SecureRequiredType; + +help.passportConfigNotModified#bfb9f457 = help.PassportConfig; +help.passportConfig#a098d6af hash:int countries_langs:DataJSON = help.PassportConfig; + +inputAppEvent#1d1b1245 time:double type:string peer:long data:JSONValue = InputAppEvent; + +jsonObjectValue#c0de1bd9 key:string value:JSONValue = JSONObjectValue; + +jsonNull#3f6d7b68 = JSONValue; +jsonBool#c7345e6a value:Bool = JSONValue; +jsonNumber#2be0dfa4 value:double = JSONValue; +jsonString#b71e767a value:string = JSONValue; +jsonArray#f7444763 value:Vector = JSONValue; +jsonObject#99c1d49d value:Vector = JSONValue; + +pageTableCell#34566b6a flags:# header:flags.0?true align_center:flags.3?true align_right:flags.4?true valign_middle:flags.5?true valign_bottom:flags.6?true text:flags.7?RichText colspan:flags.1?int rowspan:flags.2?int = PageTableCell; + +pageTableRow#e0c0c5e5 cells:Vector = PageTableRow; + +pageCaption#6f747657 text:RichText credit:RichText = PageCaption; + +pageListItemText#b92fb6cd text:RichText = PageListItem; +pageListItemBlocks#25e073fc blocks:Vector = PageListItem; + +pageListOrderedItemText#5e068047 num:string text:RichText = PageListOrderedItem; +pageListOrderedItemBlocks#98dd8936 num:string blocks:Vector = PageListOrderedItem; + +pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; + +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; + +help.supportName#8c05f1c9 name:string = help.SupportName; + +help.userInfoEmpty#f3ae2eed = help.UserInfo; +help.userInfo#1eb3758 message:string entities:Vector author:string date:int = help.UserInfo; + +pollAnswer#4b7d786a flags:# text:TextWithEntities option:bytes media:flags.0?MessageMedia added_by:flags.1?Peer date:flags.1?int = PollAnswer; +inputPollAnswer#199fed96 flags:# text:TextWithEntities media:flags.0?InputMedia = PollAnswer; + +poll#b8425be9 id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true open_answers:flags.6?true revoting_disabled:flags.7?true shuffle_answers:flags.8?true hide_results_until_close:flags.9?true creator:flags.10?true question:TextWithEntities answers:Vector close_period:flags.4?int close_date:flags.5?int hash:long = Poll; + +pollAnswerVoters#3645230a flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:flags.2?int recent_voters:flags.2?Vector = PollAnswerVoters; + +pollResults#ba7bb15e flags:# min:flags.0?true has_unread_votes:flags.6?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector solution_media:flags.5?MessageMedia = PollResults; + +chatOnlines#f041e250 onlines:int = ChatOnlines; + +statsURL#47a971e0 url:string = StatsURL; + +chatAdminRights#5fb224d5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true pin_messages:flags.7?true add_admins:flags.9?true anonymous:flags.10?true manage_call:flags.11?true other:flags.12?true manage_topics:flags.13?true post_stories:flags.14?true edit_stories:flags.15?true delete_stories:flags.16?true manage_direct_messages:flags.17?true manage_ranks:flags.18?true = ChatAdminRights; + +chatBannedRights#9f120418 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true send_polls:flags.8?true change_info:flags.10?true invite_users:flags.15?true pin_messages:flags.17?true manage_topics:flags.18?true send_photos:flags.19?true send_videos:flags.20?true send_roundvideos:flags.21?true send_audios:flags.22?true send_voices:flags.23?true send_docs:flags.24?true send_plain:flags.25?true edit_rank:flags.26?true until_date:int = ChatBannedRights; + +inputWallPaper#e630b979 id:long access_hash:long = InputWallPaper; +inputWallPaperSlug#72091c80 slug:string = InputWallPaper; +inputWallPaperNoFile#967a462e id:long = InputWallPaper; + +account.wallPapersNotModified#1c199183 = account.WallPapers; +account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; + +codeSettings#ad253d78 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true allow_firebase:flags.7?true unknown_number:flags.9?true logout_tokens:flags.6?Vector token:flags.8?string app_sandbox:flags.8?Bool = CodeSettings; + +wallPaperSettings#372efcd0 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int emoticon:flags.7?string = WallPaperSettings; + +autoDownloadSettings#baa57628 flags:# disabled:flags.0?true video_preload_large:flags.1?true audio_preload_next:flags.2?true phonecalls_less_data:flags.3?true stories_preload:flags.4?true photo_size_max:int video_size_max:long file_size_max:long video_upload_maxbitrate:int small_queue_active_operations_max:int large_queue_active_operations_max:int = AutoDownloadSettings; + +account.autoDownloadSettings#63cacf26 low:AutoDownloadSettings medium:AutoDownloadSettings high:AutoDownloadSettings = account.AutoDownloadSettings; + +emojiKeyword#d5b3b9f9 keyword:string emoticons:Vector = EmojiKeyword; +emojiKeywordDeleted#236df622 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordsDifference#5cc761bd lang_code:string from_version:int version:int keywords:Vector = EmojiKeywordsDifference; + +emojiURL#a575739d url:string = EmojiURL; + +emojiLanguage#b3fb5361 lang_code:string = EmojiLanguage; + +folder#ff544e65 flags:# autofill_new_broadcasts:flags.0?true autofill_public_groups:flags.1?true autofill_new_correspondents:flags.2?true id:int title:string photo:flags.3?ChatPhoto = Folder; + +inputFolderPeer#fbd2c296 peer:InputPeer folder_id:int = InputFolderPeer; + +folderPeer#e9baa668 peer:Peer folder_id:int = FolderPeer; + +messages.searchCounter#e844ebff flags:# inexact:flags.1?true filter:MessagesFilter count:int = messages.SearchCounter; + +urlAuthResultRequest#3cd623ec flags:# request_write_access:flags.0?true request_phone_number:flags.1?true match_codes_first:flags.5?true is_app:flags.6?true bot:User domain:string browser:flags.2?string platform:flags.2?string ip:flags.2?string region:flags.2?string match_codes:flags.3?Vector user_id_hint:flags.4?long verified_app_name:flags.7?string = UrlAuthResult; +urlAuthResultAccepted#623a8fa0 flags:# url:flags.0?string = UrlAuthResult; +urlAuthResultDefault#a9d6db1f = UrlAuthResult; + +channelLocationEmpty#bfb5ad8b = ChannelLocation; +channelLocation#209b82db geo_point:GeoPoint address:string = ChannelLocation; + +peerLocated#ca461b5d peer:Peer expires:int distance:int = PeerLocated; +peerSelfLocated#f8ec284b expires:int = PeerLocated; + +restrictionReason#d072acb4 platform:string reason:string text:string = RestrictionReason; + +inputTheme#3c5693e9 id:long access_hash:long = InputTheme; +inputThemeSlug#f5890df1 slug:string = InputTheme; + +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; + +account.themesNotModified#f41eb622 = account.Themes; +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; + +auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; +auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; +auth.loginTokenSuccess#390d5c5e authorization:auth.Authorization = auth.LoginToken; + +account.contentSettings#57e28221 flags:# sensitive_enabled:flags.0?true sensitive_can_change:flags.1?true = account.ContentSettings; + +messages.inactiveChats#a927fec5 dates:Vector chats:Vector users:Vector = messages.InactiveChats; + +baseThemeClassic#c3a12462 = BaseTheme; +baseThemeDay#fbd81688 = BaseTheme; +baseThemeNight#b7b31ea8 = BaseTheme; +baseThemeTinted#6d5f77ee = BaseTheme; +baseThemeArctic#5b11125a = BaseTheme; + +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; + +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; + +webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; +webPageAttributeStory#2e94c3e7 flags:# peer:Peer id:int story:flags.0?StoryItem = WebPageAttribute; +webPageAttributeStickerSet#50cc03d3 flags:# emojis:flags.0?true text_color:flags.1?true stickers:Vector = WebPageAttribute; +webPageAttributeUniqueStarGift#cf6f6db8 gift:StarGift = WebPageAttribute; +webPageAttributeStarGiftCollection#31cad303 icons:Vector = WebPageAttribute; +webPageAttributeStarGiftAuction#1c641c2 gift:StarGift end_date:int = WebPageAttribute; + +messages.votesList#4899484e flags:# count:int votes:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.VotesList; + +bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; + +payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; + +dialogFilter#aa472651 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; +dialogFilterDefault#363293ae = DialogFilter; +dialogFilterChatlist#96537bd7 flags:# has_my_invites:flags.26?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; +statsGraphError#bedc9822 error:string = StatsGraph; +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +stats.broadcastStats#396ca5fc period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev reactions_per_post:StatsAbsValueAndPrev views_per_story:StatsAbsValueAndPrev shares_per_story:StatsAbsValueAndPrev reactions_per_story:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph reactions_by_emotion_graph:StatsGraph story_interactions_graph:StatsGraph story_reactions_by_emotion_graph:StatsGraph recent_posts_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; +help.promoData#8a4d87a flags:# proxy:flags.0?true expires:int peer:flags.3?Peer psa_type:flags.1?string psa_message:flags.2?string pending_suggestions:Vector dismissed_suggestions:Vector custom_pending_suggestion:flags.4?PendingSuggestion chats:Vector users:Vector = help.PromoData; + +videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; +videoSizeEmojiMarkup#f85c413c emoji_id:long background_colors:Vector = VideoSize; +videoSizeStickerMarkup#da082fe stickerset:InputStickerSet sticker_id:long background_colors:Vector = VideoSize; + +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#fe41b34f flags:# archive_and_mute_new_noncontact_peers:flags.0?true keep_archived_unmuted:flags.1?true keep_archived_folders:flags.2?true hide_read_marks:flags.3?true new_noncontact_peers_require_premium:flags.4?true display_gifts_button:flags.7?true noncontact_peers_paid_stars:flags.5?long disallowed_gifts:flags.6?DisallowedGiftsSettings = GlobalPrivacySettings; + +help.countryCode#4203c5ef flags:# country_code:string prefixes:flags.0?Vector patterns:flags.1?Vector = help.CountryCode; + +help.country#c3878e23 flags:# hidden:flags.0?true iso2:string default_name:string name:flags.1?string country_codes:Vector = help.Country; + +help.countriesListNotModified#93cc1f32 = help.CountriesList; +help.countriesList#87d0759e countries:Vector hash:int = help.CountriesList; + +messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:flags.2?MessageReplies = MessageViews; + +messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; + +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; + +messageReplyHeader#1b97dd66 flags:# reply_to_scheduled:flags.2?true forum_topic:flags.3?true quote:flags.9?true reply_to_msg_id:flags.4?int reply_to_peer_id:flags.0?Peer reply_from:flags.5?MessageFwdHeader reply_media:flags.8?MessageMedia reply_to_top_id:flags.1?int quote_text:flags.6?string quote_entities:flags.7?Vector quote_offset:flags.10?int todo_item_id:flags.11?int poll_option:flags.12?bytes = MessageReplyHeader; +messageReplyStoryHeader#e5af939 peer:Peer story_id:int = MessageReplyHeader; + +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; + +peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; + +stats.messageStats#7fe91c14 views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.MessageStats; + +groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; +groupCall#efb2b617 flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true conference:flags.14?true creator:flags.15?true messages_enabled:flags.17?true can_change_messages_enabled:flags.18?true min:flags.19?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int invite_link:flags.16?string send_paid_messages_stars:flags.20?long default_send_as:flags.21?Peer = GroupCall; + +inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; +inputGroupCallSlug#fe06823f slug:string = InputGroupCall; +inputGroupCallInviteMessage#8c10603f msg_id:int = InputGroupCall; + +groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant; + +phone.groupCall#9e727aad call:GroupCall participants:Vector participants_next_offset:string chats:Vector users:Vector = phone.GroupCall; + +phone.groupParticipants#f47751b6 count:int participants:Vector next_offset:string chats:Vector users:Vector version:int = phone.GroupParticipants; + +inlineQueryPeerTypeSameBotPM#3081ed9d = InlineQueryPeerType; +inlineQueryPeerTypePM#833c0fac = InlineQueryPeerType; +inlineQueryPeerTypeChat#d766c50a = InlineQueryPeerType; +inlineQueryPeerTypeMegagroup#5ec4be43 = InlineQueryPeerType; +inlineQueryPeerTypeBroadcast#6334ee9a = InlineQueryPeerType; +inlineQueryPeerTypeBotPM#e3b2d0c = InlineQueryPeerType; + +messages.historyImport#1662af0b id:long = messages.HistoryImport; + +messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true title:flags.2?string = messages.HistoryImportParsed; + +messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; + +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true via_chatlist:flags.3?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; + +messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; + +messages.exportedChatInvite#1871be50 invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; +messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; + +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; + +messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; + +messages.checkedHistoryImportPeer#a24de717 confirm_text:string = messages.CheckedHistoryImportPeer; + +phone.joinAsPeers#afe5623f peers:Vector chats:Vector users:Vector = phone.JoinAsPeers; + +phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvite; + +groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; + +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; +botCommandScopeUsers#3c4f04d8 = BotCommandScope; +botCommandScopeChats#6fe1a881 = BotCommandScope; +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +chatTheme#c3dffc04 emoticon:string = ChatTheme; +chatThemeUniqueGift#3458f9c8 gift:StarGift theme_settings:Vector = ChatTheme; + +account.chatThemesNotModified#e011e1c4 = account.ChatThemes; +account.chatThemes#be098173 flags:# hash:long themes:Vector chats:Vector users:Vector next_offset:flags.0?string = account.ChatThemes; + +sponsoredMessage#7dbf8673 flags:# recommended:flags.5?true can_report:flags.12?true random_id:bytes url:string title:string message:string entities:flags.1?Vector photo:flags.6?Photo media:flags.14?MessageMedia color:flags.13?PeerColor button_text:string sponsor_info:flags.7?string additional_info:flags.8?string min_display_duration:flags.15?int max_display_duration:flags.15?int = SponsoredMessage; + +messages.sponsoredMessages#ffda656d flags:# posts_between:flags.0?int start_delay:flags.1?int between_delay:flags.2?int messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +messages.sponsoredMessagesEmpty#1839490f = messages.SponsoredMessages; + +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#f496b0c6 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#a3d1cb80 flags:# chosen_order:flags.0?int reaction:Reaction count:int = ReactionCount; + +messageReactions#a339f0b flags:# min:flags.0?true can_see_list:flags.2?true reactions_as_tags:flags.3?true results:Vector recent_reactions:flags.1?Vector top_reactors:flags.4?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true premium:flags.2?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + +messagePeerReaction#8c79b63c flags:# big:flags.0?true unread:flags.1?true my:flags.2?true peer_id:Peer date:int reaction:Reaction = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; + +attachMenuBotIconColor#4576f3f0 name:string color:int = AttachMenuBotIconColor; + +attachMenuBotIcon#b2a7386b flags:# name:string icon:Document colors:flags.0?Vector = AttachMenuBotIcon; + +attachMenuBot#d90d8dfe flags:# inactive:flags.0?true has_settings:flags.1?true request_write_access:flags.2?true show_in_attach_menu:flags.3?true show_in_side_menu:flags.4?true side_menu_disclaimer_needed:flags.5?true bot_id:long short_name:string peer_types:flags.3?Vector icons:Vector = AttachMenuBot; + +attachMenuBotsNotModified#f1d88a5c = AttachMenuBots; +attachMenuBots#3c4301c0 hash:long bots:Vector users:Vector = AttachMenuBots; + +attachMenuBotsBot#93bf667f bot:AttachMenuBot users:Vector = AttachMenuBotsBot; + +webViewResultUrl#4d22ff98 flags:# fullsize:flags.1?true fullscreen:flags.2?true query_id:flags.0?long url:string = WebViewResult; + +webViewMessageSent#c94511c flags:# msg_id:flags.0?InputBotInlineMessageID = WebViewMessageSent; + +botMenuButtonDefault#7533a588 = BotMenuButton; +botMenuButtonCommands#4258c205 = BotMenuButton; +botMenuButton#c7b57ce6 text:string url:string = BotMenuButton; + +account.savedRingtonesNotModified#fbf6e8b1 = account.SavedRingtones; +account.savedRingtones#c1e92cc5 hash:long ringtones:Vector = account.SavedRingtones; + +notificationSoundDefault#97e8bebe = NotificationSound; +notificationSoundNone#6f0c34df = NotificationSound; +notificationSoundLocal#830b9ae4 title:string data:string = NotificationSound; +notificationSoundRingtone#ff6c8049 id:long = NotificationSound; + +account.savedRingtone#b7263f6d = account.SavedRingtone; +account.savedRingtoneConverted#1f307eb7 document:Document = account.SavedRingtone; + +attachMenuPeerTypeSameBotPM#7d6be90e = AttachMenuPeerType; +attachMenuPeerTypeBotPM#c32bfa1a = AttachMenuPeerType; +attachMenuPeerTypePM#f146d31f = AttachMenuPeerType; +attachMenuPeerTypeChat#509113f = AttachMenuPeerType; +attachMenuPeerTypeBroadcast#7bfbdefc = AttachMenuPeerType; + +inputInvoiceMessage#c5b56859 peer:InputPeer msg_id:int = InputInvoice; +inputInvoiceSlug#c326caef slug:string = InputInvoice; +inputInvoicePremiumGiftCode#98986c0d purpose:InputStorePaymentPurpose option:PremiumGiftCodeOption = InputInvoice; +inputInvoiceStars#65f00ce3 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceChatInviteSubscription#34e793f1 hash:string = InputInvoice; +inputInvoiceStarGift#e8625e92 flags:# hide_name:flags.0?true include_upgrade:flags.2?true peer:InputPeer gift_id:long message:flags.1?TextWithEntities = InputInvoice; +inputInvoiceStarGiftUpgrade#4d818d5d flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftTransfer#4a5f5bd9 stargift:InputSavedStarGift to_id:InputPeer = InputInvoice; +inputInvoicePremiumGiftStars#dabab2ef flags:# user_id:InputUser months:int message:flags.0?TextWithEntities = InputInvoice; +inputInvoiceBusinessBotTransferStars#f4997e42 bot:InputUser stars:long = InputInvoice; +inputInvoiceStarGiftResale#c39f5324 flags:# ton:flags.0?true slug:string to_id:InputPeer = InputInvoice; +inputInvoiceStarGiftPrepaidUpgrade#9a0b48b8 peer:InputPeer hash:string = InputInvoice; +inputInvoicePremiumAuthCode#3e77f614 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceStarGiftDropOriginalDetails#923d8d1 stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftAuctionBid#1ecafa10 flags:# hide_name:flags.0?true update_bid:flags.2?true peer:flags.3?InputPeer gift_id:long bid_amount:long message:flags.1?TextWithEntities = InputInvoice; + +payments.exportedInvoice#aed0cbd9 url:string = payments.ExportedInvoice; + +messages.transcribedAudio#cfb9d957 flags:# pending:flags.0?true transcription_id:long text:string trial_remains_num:flags.1?int trial_remains_until_date:flags.1?int = messages.TranscribedAudio; + +help.premiumPromo#5334759c status_text:string status_entities:Vector video_sections:Vector videos:Vector period_options:Vector users:Vector = help.PremiumPromo; + +inputStorePaymentPremiumSubscription#a6751e66 flags:# restore:flags.0?true upgrade:flags.1?true = InputStorePaymentPurpose; +inputStorePaymentGiftPremium#616f7fe8 user_id:InputUser currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentPremiumGiftCode#fb790393 flags:# users:Vector boost_peer:flags.0?InputPeer currency:string amount:long message:flags.1?TextWithEntities = InputStorePaymentPurpose; +inputStorePaymentPremiumGiveaway#160544ca flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsTopup#f9a2a6cb flags:# stars:long currency:string amount:long spend_purpose_peer:flags.0?InputPeer = InputStorePaymentPurpose; +inputStorePaymentStarsGift#1d741ef7 user_id:InputUser stars:long currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsGiveaway#751f08fa flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true stars:long boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long users:int = InputStorePaymentPurpose; +inputStorePaymentAuthCode#9bb2636d flags:# restore:flags.0?true phone_number:string phone_code_hash:string currency:string amount:long = InputStorePaymentPurpose; + +paymentFormMethod#88f8f21b url:string title:string = PaymentFormMethod; + +emojiStatusEmpty#2de11aae = EmojiStatus; +emojiStatus#e7ff068a flags:# document_id:long until:flags.0?int = EmojiStatus; +emojiStatusCollectible#7184603b flags:# collectible_id:long document_id:long title:string slug:string pattern_document_id:long center_color:int edge_color:int pattern_color:int text_color:int until:flags.0?int = EmojiStatus; +inputEmojiStatusCollectible#7141dbf flags:# collectible_id:long until:flags.0?int = EmojiStatus; + +account.emojiStatusesNotModified#d08ce645 = account.EmojiStatuses; +account.emojiStatuses#90c467d1 hash:long statuses:Vector = account.EmojiStatuses; + +reactionEmpty#79f5d419 = Reaction; +reactionEmoji#1b2286b8 emoticon:string = Reaction; +reactionCustomEmoji#8935fc73 document_id:long = Reaction; +reactionPaid#523da4eb = Reaction; + +chatReactionsNone#eafc32bc = ChatReactions; +chatReactionsAll#52928bca flags:# allow_custom:flags.0?true = ChatReactions; +chatReactionsSome#661d4037 reactions:Vector = ChatReactions; + +messages.reactionsNotModified#b06fdbdf = messages.Reactions; +messages.reactions#eafdf716 hash:long reactions:Vector = messages.Reactions; + +emailVerifyPurposeLoginSetup#4345be73 phone_number:string phone_code_hash:string = EmailVerifyPurpose; +emailVerifyPurposeLoginChange#527d22eb = EmailVerifyPurpose; +emailVerifyPurposePassport#bbf51685 = EmailVerifyPurpose; + +emailVerificationCode#922e55a9 code:string = EmailVerification; +emailVerificationGoogle#db909ec2 token:string = EmailVerification; +emailVerificationApple#96d074fd token:string = EmailVerification; + +account.emailVerified#2b96cd1b email:string = account.EmailVerified; +account.emailVerifiedLogin#e1bb0d61 email:string sent_code:auth.SentCode = account.EmailVerified; + +premiumSubscriptionOption#5f2d1df2 flags:# current:flags.1?true can_purchase_upgrade:flags.2?true transaction:flags.3?string months:int currency:string amount:long bot_url:string store_product:flags.0?string = PremiumSubscriptionOption; + +sendAsPeer#b81c7034 flags:# premium_required:flags.0?true peer:Peer = SendAsPeer; + +messageExtendedMediaPreview#ad628cc8 flags:# w:flags.0?int h:flags.0?int thumb:flags.1?PhotoSize video_duration:flags.2?int = MessageExtendedMedia; +messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; + +stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; + +username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; + +forumTopicDeleted#23f109b id:int = ForumTopic; +forumTopic#fcdad815 flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true title_missing:flags.7?true id:int date:int peer:Peer title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; + +messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; + +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + +requestPeerTypeUser#5f3b8a00 flags:# bot:flags.0?Bool premium:flags.1?Bool = RequestPeerType; +requestPeerTypeChat#c9f06e1b flags:# creator:flags.0?true bot_participant:flags.5?true has_username:flags.3?Bool forum:flags.4?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeBroadcast#339bef6c flags:# creator:flags.0?true has_username:flags.3?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeCreateBot#3e81e078 flags:# bot_managed:flags.0?true suggested_name:flags.1?string suggested_username:flags.2?string = RequestPeerType; + +emojiListNotModified#481eadfa = EmojiList; +emojiList#7a1e11d1 hash:long document_id:Vector = EmojiList; + +emojiGroup#7a9abda9 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupGreeting#80d26cc7 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupPremium#93bcf34 title:string icon_emoji_id:long = EmojiGroup; + +messages.emojiGroupsNotModified#6fb4ad87 = messages.EmojiGroups; +messages.emojiGroups#881fb94b hash:int groups:Vector = messages.EmojiGroups; + +textWithEntities#751f3146 text:string entities:Vector = TextWithEntities; + +messages.translateResult#33db32f8 result:Vector = messages.TranslatedText; + +autoSaveSettings#c84834ce flags:# photos:flags.0?true videos:flags.1?true video_max_size:flags.2?long = AutoSaveSettings; + +autoSaveException#81602d47 peer:Peer settings:AutoSaveSettings = AutoSaveException; + +account.autoSaveSettings#4c3e069d users_settings:AutoSaveSettings chats_settings:AutoSaveSettings broadcasts_settings:AutoSaveSettings exceptions:Vector chats:Vector users:Vector = account.AutoSaveSettings; + +help.appConfigNotModified#7cde641d = help.AppConfig; +help.appConfig#dd18782e hash:int config:JSONValue = help.AppConfig; + +inputBotAppID#a920bd7a id:long access_hash:long = InputBotApp; +inputBotAppShortName#908c0407 bot_id:InputUser short_name:string = InputBotApp; + +botAppNotModified#5da674b7 = BotApp; +botApp#95fcd1d6 flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document hash:long = BotApp; + +messages.botApp#eb50adf5 flags:# inactive:flags.0?true request_write_access:flags.1?true has_settings:flags.2?true app:BotApp = messages.BotApp; + +inlineBotWebView#b57295d5 text:string url:string = InlineBotWebView; + +readParticipantDate#4a4ff172 user_id:long date:int = ReadParticipantDate; + +inputChatlistDialogFilter#f3e0da33 filter_id:int = InputChatlist; + +exportedChatlistInvite#c5181ac flags:# title:string url:string peers:Vector = ExportedChatlistInvite; + +chatlists.exportedChatlistInvite#10e6e3a6 filter:DialogFilter invite:ExportedChatlistInvite = chatlists.ExportedChatlistInvite; + +chatlists.exportedInvites#10ab6dc7 invites:Vector chats:Vector users:Vector = chatlists.ExportedInvites; + +chatlists.chatlistInviteAlready#fa87f659 filter_id:int missing_peers:Vector already_peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; +chatlists.chatlistInvite#f10ece2f flags:# title_noanimate:flags.1?true title:TextWithEntities emoticon:flags.0?string peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistUpdates#93bd878d missing_peers:Vector chats:Vector users:Vector = chatlists.ChatlistUpdates; + +bots.botInfo#e8a775b0 name:string about:string description:string = bots.BotInfo; + +messagePeerVote#b6cc2d5c peer:Peer option:bytes date:int = MessagePeerVote; +messagePeerVoteInputOption#74cda504 peer:Peer date:int = MessagePeerVote; +messagePeerVoteMultiple#4628f6e6 peer:Peer options:Vector date:int = MessagePeerVote; + +storyViews#8d595cd6 flags:# has_viewers:flags.1?true views_count:int forwards_count:flags.2?int reactions:flags.3?Vector reactions_count:flags.4?int recent_viewers:flags.0?Vector = StoryViews; + +storyItemDeleted#51e6ee4f id:int = StoryItem; +storyItemSkipped#ffadc913 flags:# close_friends:flags.8?true live:flags.9?true id:int date:int expire_date:int = StoryItem; +storyItem#16a4b93c flags:# pinned:flags.5?true public:flags.7?true close_friends:flags.8?true min:flags.9?true noforwards:flags.10?true edited:flags.11?true contacts:flags.12?true selected_contacts:flags.13?true out:flags.16?true id:int date:int from_id:flags.18?Peer fwd_from:flags.17?StoryFwdHeader expire_date:int caption:flags.0?string entities:flags.1?Vector media:MessageMedia media_areas:flags.14?Vector privacy:flags.2?Vector views:flags.3?StoryViews sent_reaction:flags.15?Reaction albums:flags.19?Vector music:flags.20?Document = StoryItem; + +stories.allStoriesNotModified#1158fe3e flags:# state:string stealth_mode:StoriesStealthMode = stories.AllStories; +stories.allStories#6efc5e81 flags:# has_more:flags.0?true count:int state:string peer_stories:Vector chats:Vector users:Vector stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.stories#63c3dd0a flags:# count:int stories:Vector pinned_to_top:flags.0?Vector chats:Vector users:Vector = stories.Stories; + +storyView#b0bdeac5 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true user_id:long date:int reaction:flags.2?Reaction = StoryView; +storyViewPublicForward#9083670b flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true message:Message = StoryView; +storyViewPublicRepost#bd74cf49 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer story:StoryItem = StoryView; + +stories.storyViewsList#59d78fc5 flags:# count:int views_count:int forwards_count:int reactions_count:int views:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryViewsList; + +stories.storyViews#de9eed1d views:Vector users:Vector = stories.StoryViews; + +inputReplyToMessage#3bd4b7c2 flags:# reply_to_msg_id:int top_msg_id:flags.0?int reply_to_peer_id:flags.1?InputPeer quote_text:flags.2?string quote_entities:flags.3?Vector quote_offset:flags.4?int monoforum_peer_id:flags.5?InputPeer todo_item_id:flags.6?int poll_option:flags.7?bytes = InputReplyTo; +inputReplyToStory#5881323a peer:InputPeer story_id:int = InputReplyTo; +inputReplyToMonoForum#69d66c45 monoforum_peer_id:InputPeer = InputReplyTo; + +exportedStoryLink#3fc9053b link:string = ExportedStoryLink; + +storiesStealthMode#712e27fd flags:# active_until_date:flags.0?int cooldown_until_date:flags.1?int = StoriesStealthMode; + +mediaAreaCoordinates#cfc9e002 flags:# x:double y:double w:double h:double rotation:double radius:flags.0?double = MediaAreaCoordinates; + +mediaAreaVenue#be82db9c coordinates:MediaAreaCoordinates geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MediaArea; +inputMediaAreaVenue#b282217f coordinates:MediaAreaCoordinates query_id:long result_id:string = MediaArea; +mediaAreaGeoPoint#cad5452d flags:# coordinates:MediaAreaCoordinates geo:GeoPoint address:flags.0?GeoPointAddress = MediaArea; +mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; +mediaAreaChannelPost#770416af coordinates:MediaAreaCoordinates channel_id:long msg_id:int = MediaArea; +inputMediaAreaChannelPost#2271f2bf coordinates:MediaAreaCoordinates channel:InputChannel msg_id:int = MediaArea; +mediaAreaUrl#37381085 coordinates:MediaAreaCoordinates url:string = MediaArea; +mediaAreaWeather#49a6549c coordinates:MediaAreaCoordinates emoji:string temperature_c:double color:int = MediaArea; +mediaAreaStarGift#5787686d coordinates:MediaAreaCoordinates slug:string = MediaArea; + +peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; + +stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; + +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; + +premiumGiftCodeOption#257e962b flags:# users:int months:int store_product:flags.0?string store_quantity:flags.1?int currency:string amount:long = PremiumGiftCodeOption; + +payments.checkedGiftCode#eb983f8f flags:# via_giveaway:flags.2?true from_id:flags.4?Peer giveaway_msg_id:flags.3?int to_id:flags.0?long date:int days:int used_date:flags.1?int chats:Vector users:Vector = payments.CheckedGiftCode; + +payments.giveawayInfo#4367daa0 flags:# participating:flags.0?true preparing_results:flags.3?true start_date:int joined_too_early_date:flags.1?int admin_disallowed_chat_id:flags.2?long disallowed_country:flags.4?string = payments.GiveawayInfo; +payments.giveawayInfoResults#e175e66f flags:# winner:flags.0?true refunded:flags.1?true start_date:int gift_code_slug:flags.3?string stars_prize:flags.4?long finish_date:int winners_count:int activated_count:flags.2?int = payments.GiveawayInfo; + +prepaidGiveaway#b2539d54 id:long months:int quantity:int date:int = PrepaidGiveaway; +prepaidStarsGiveaway#9a9d77e0 id:long stars:long quantity:int boosts:int date:int = PrepaidGiveaway; + +boost#4b3e14d6 flags:# gift:flags.1?true giveaway:flags.2?true unclaimed:flags.3?true id:string user_id:flags.0?long giveaway_msg_id:flags.2?int date:int expires:int used_gift_slug:flags.4?string multiplier:flags.5?int stars:flags.6?long = Boost; + +premium.boostsList#86f8613c flags:# count:int boosts:Vector next_offset:flags.0?string users:Vector = premium.BoostsList; + +myBoost#c448415c flags:# slot:int peer:flags.0?Peer date:int expires:int cooldown_until_date:flags.1?int = MyBoost; + +premium.myBoosts#9ae228e2 my_boosts:Vector chats:Vector users:Vector = premium.MyBoosts; + +premium.boostsStatus#4959427a flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int gift_boosts:flags.4?int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string prepaid_giveaways:flags.3?Vector my_boost_slots:flags.2?Vector = premium.BoostsStatus; + +storyFwdHeader#b826e150 flags:# modified:flags.3?true from:flags.0?Peer from_name:flags.1?string story_id:flags.2?int = StoryFwdHeader; + +postInteractionCountersMessage#e7058e7f msg_id:int views:int forwards:int reactions:int = PostInteractionCounters; +postInteractionCountersStory#8a480e27 story_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +stats.storyStats#50cd067c views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.StoryStats; + +publicForwardMessage#1f2bf4a message:Message = PublicForward; +publicForwardStory#edf3add0 peer:Peer story:StoryItem = PublicForward; + +stats.publicForwards#93037e20 flags:# count:int forwards:Vector next_offset:flags.0?string chats:Vector users:Vector = stats.PublicForwards; + +peerColor#b54b5acf flags:# color:flags.0?int background_emoji_id:flags.1?long = PeerColor; +peerColorCollectible#b9c0639a flags:# collectible_id:long gift_emoji_id:long background_emoji_id:long accent_color:int colors:Vector dark_accent_color:flags.0?int dark_colors:flags.1?Vector = PeerColor; +inputPeerColorCollectible#b8ea86a9 collectible_id:long = PeerColor; + +help.peerColorSet#26219a58 colors:Vector = help.PeerColorSet; +help.peerColorProfileSet#767d61eb palette_colors:Vector bg_colors:Vector story_colors:Vector = help.PeerColorSet; + +help.peerColorOption#adec6ebe flags:# hidden:flags.0?true color_id:int colors:flags.1?help.PeerColorSet dark_colors:flags.2?help.PeerColorSet channel_min_level:flags.3?int group_min_level:flags.4?int = help.PeerColorOption; + +help.peerColorsNotModified#2ba1f5ce = help.PeerColors; +help.peerColors#f8ed08 hash:int colors:Vector = help.PeerColors; + +storyReaction#6090d6d5 peer_id:Peer date:int reaction:Reaction = StoryReaction; +storyReactionPublicForward#bbab2643 message:Message = StoryReaction; +storyReactionPublicRepost#cfcd0f13 peer_id:Peer story:StoryItem = StoryReaction; + +stories.storyReactionsList#aa5f789c flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryReactionsList; + +savedDialog#bd87cb6c flags:# pinned:flags.2?true peer:Peer top_message:int = SavedDialog; +monoForumDialog#64407ea7 flags:# unread_mark:flags.3?true nopaid_messages_exception:flags.4?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_reactions_count:int draft:flags.1?DraftMessage = SavedDialog; + +messages.savedDialogs#f83ae221 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsSlice#44ba9dd9 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsNotModified#c01f6fe8 count:int = messages.SavedDialogs; + +savedReactionTag#cb6ff828 flags:# reaction:Reaction title:flags.0?string count:int = SavedReactionTag; + +messages.savedReactionTagsNotModified#889b59ef = messages.SavedReactionTags; +messages.savedReactionTags#3259950a tags:Vector hash:long = messages.SavedReactionTags; + +outboxReadDate#3bb842ac date:int = OutboxReadDate; + +smsjobs.eligibleToJoin#dc8b44cf terms_url:string monthly_sent_sms:int = smsjobs.EligibilityToJoin; + +smsjobs.status#2aee9191 flags:# allow_international:flags.0?true recent_sent:int recent_since:int recent_remains:int total_sent:int total_since:int last_gift_slug:flags.1?string terms_url:string = smsjobs.Status; + +smsJob#e6a1eeb8 job_id:string phone_number:string text:string = SmsJob; + +businessWeeklyOpen#120b1ab9 start_minute:int end_minute:int = BusinessWeeklyOpen; + +businessWorkHours#8c92b098 flags:# open_now:flags.0?true timezone_id:string weekly_open:Vector = BusinessWorkHours; + +businessLocation#ac5c1af7 flags:# geo_point:flags.0?GeoPoint address:string = BusinessLocation; + +inputBusinessRecipients#6f8b32aa flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = InputBusinessRecipients; + +businessRecipients#21108ff7 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = BusinessRecipients; + +businessAwayMessageScheduleAlways#c9b9e2b9 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleOutsideWorkHours#c3f2f501 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleCustom#cc4d9ecc start_date:int end_date:int = BusinessAwayMessageSchedule; + +inputBusinessGreetingMessage#194cb3b shortcut_id:int recipients:InputBusinessRecipients no_activity_days:int = InputBusinessGreetingMessage; + +businessGreetingMessage#e519abab shortcut_id:int recipients:BusinessRecipients no_activity_days:int = BusinessGreetingMessage; + +inputBusinessAwayMessage#832175e0 flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:InputBusinessRecipients = InputBusinessAwayMessage; + +businessAwayMessage#ef156a5c flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:BusinessRecipients = BusinessAwayMessage; + +timezone#ff9289f5 id:string name:string utc_offset:int = Timezone; + +help.timezonesListNotModified#970708cc = help.TimezonesList; +help.timezonesList#7b74ed71 timezones:Vector hash:int = help.TimezonesList; + +quickReply#697102b shortcut_id:int shortcut:string top_message:int count:int = QuickReply; + +inputQuickReplyShortcut#24596d41 shortcut:string = InputQuickReplyShortcut; +inputQuickReplyShortcutId#1190cf1 shortcut_id:int = InputQuickReplyShortcut; + +messages.quickReplies#c68d6695 quick_replies:Vector messages:Vector chats:Vector users:Vector = messages.QuickReplies; +messages.quickRepliesNotModified#5f91eb5b = messages.QuickReplies; + +connectedBot#cd64636c flags:# bot_id:long recipients:BusinessBotRecipients rights:BusinessBotRights = ConnectedBot; + +account.connectedBots#17d7f87b connected_bots:Vector users:Vector = account.ConnectedBots; + +messages.dialogFilters#2ad93719 flags:# tags_enabled:flags.0?true filters:Vector = messages.DialogFilters; + +birthday#6c8e1e06 flags:# day:int month:int year:flags.0?int = Birthday; + +botBusinessConnection#8f34b2f5 flags:# disabled:flags.1?true connection_id:string user_id:long dc_id:int date:int rights:flags.2?BusinessBotRights = BotBusinessConnection; + +inputBusinessIntro#9c469cd flags:# title:string description:string sticker:flags.0?InputDocument = InputBusinessIntro; + +businessIntro#5a0a066d flags:# title:string description:string sticker:flags.0?Document = BusinessIntro; + +messages.myStickers#faff629d count:int sets:Vector = messages.MyStickers; + +inputCollectibleUsername#e39460a9 username:string = InputCollectible; +inputCollectiblePhone#a2e214a4 phone:string = InputCollectible; + +fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo; + +inputBusinessBotRecipients#c4e5921e flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = InputBusinessBotRecipients; + +businessBotRecipients#b88cf373 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = BusinessBotRecipients; + +contactBirthday#1d998733 contact_id:long birthday:Birthday = ContactBirthday; + +contacts.contactBirthdays#114ff30d contacts:Vector users:Vector = contacts.ContactBirthdays; + +missingInvitee#628c9224 flags:# premium_would_allow_invite:flags.0?true premium_required_for_pm:flags.1?true user_id:long = MissingInvitee; + +messages.invitedUsers#7f5defa6 updates:Updates missing_invitees:Vector = messages.InvitedUsers; + +inputBusinessChatLink#11679fa7 flags:# message:string entities:flags.0?Vector title:flags.1?string = InputBusinessChatLink; + +businessChatLink#b4ae666f flags:# link:string message:string entities:flags.0?Vector title:flags.1?string views:int = BusinessChatLink; + +account.businessChatLinks#ec43a2d1 links:Vector chats:Vector users:Vector = account.BusinessChatLinks; + +account.resolvedBusinessChatLinks#9a23af21 flags:# peer:Peer message:string entities:flags.0?Vector chats:Vector users:Vector = account.ResolvedBusinessChatLinks; + +requestedPeerUser#d62ff46a flags:# user_id:long first_name:flags.0?string last_name:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChat#7307544f flags:# chat_id:long title:flags.0?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChannel#8ba403e4 flags:# channel_id:long title:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +sponsoredMessageReportOption#430d3150 text:string option:bytes = SponsoredMessageReportOption; + +channels.sponsoredMessageReportResultChooseOption#846f9e42 title:string options:Vector = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultAdsHidden#3e3bcf2f = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultReported#ad798849 = channels.SponsoredMessageReportResult; + +reactionNotificationsFromContacts#bac3a61a = ReactionNotificationsFrom; +reactionNotificationsFromAll#4b9e22a0 = ReactionNotificationsFrom; + +reactionsNotifySettings#71e4ea58 flags:# messages_notify_from:flags.0?ReactionNotificationsFrom stories_notify_from:flags.1?ReactionNotificationsFrom poll_votes_notify_from:flags.2?ReactionNotificationsFrom sound:NotificationSound show_previews:Bool = ReactionsNotifySettings; + +availableEffect#93c3e27e flags:# premium_required:flags.2?true id:long emoticon:string static_icon_id:flags.0?long effect_sticker_id:long effect_animation_id:flags.1?long = AvailableEffect; + +messages.availableEffectsNotModified#d1ed9a5b = messages.AvailableEffects; +messages.availableEffects#bddb616e hash:int effects:Vector documents:Vector = messages.AvailableEffects; + +factCheck#b89bfccf flags:# need_check:flags.0?true country:flags.1?string text:flags.1?TextWithEntities hash:long = FactCheck; + +starsTransactionPeerUnsupported#95f2bfe4 = StarsTransactionPeer; +starsTransactionPeerAppStore#b457b375 = StarsTransactionPeer; +starsTransactionPeerPlayMarket#7b560a0b = StarsTransactionPeer; +starsTransactionPeerPremiumBot#250dbaf8 = StarsTransactionPeer; +starsTransactionPeerFragment#e92fd902 = StarsTransactionPeer; +starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; +starsTransactionPeerAds#60682812 = StarsTransactionPeer; +starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; + +starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; + +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true stargift_prepaid_upgrade:flags.25?true stargift_drop_original_details:flags.26?true phonegroup_message:flags.27?true stargift_auction_bid:flags.28?true offer:flags.29?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; + +payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; + +foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; + +stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; + +geoPointAddress#de4c5d93 flags:# country_iso2:string state:flags.0?string city:flags.1?string street:flags.2?string = GeoPointAddress; + +starsRevenueStatus#febe5491 flags:# withdrawal_enabled:flags.0?true current_balance:StarsAmount available_balance:StarsAmount overall_revenue:StarsAmount next_withdrawal_at:flags.1?int = StarsRevenueStatus; + +payments.starsRevenueStats#6c207376 flags:# top_hours_graph:flags.0?StatsGraph revenue_graph:StatsGraph status:StarsRevenueStatus usd_rate:double = payments.StarsRevenueStats; + +payments.starsRevenueWithdrawalUrl#1dab80b7 url:string = payments.StarsRevenueWithdrawalUrl; + +payments.starsRevenueAdsAccountUrl#394e7f21 url:string = payments.StarsRevenueAdsAccountUrl; + +inputStarsTransaction#206ae6d1 flags:# refund:flags.0?true id:string = InputStarsTransaction; + +starsGiftOption#5e0589f1 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsGiftOption; + +bots.popularAppBots#1991b13b flags:# next_offset:flags.0?string users:Vector = bots.PopularAppBots; + +botPreviewMedia#23e91ba3 date:int media:MessageMedia = BotPreviewMedia; + +bots.previewInfo#ca71d64 media:Vector lang_codes:Vector = bots.PreviewInfo; + +starsSubscriptionPricing#5416d58 period:int amount:long = StarsSubscriptionPricing; + +starsSubscription#2e6eab1a flags:# canceled:flags.0?true can_refulfill:flags.1?true missing_balance:flags.2?true bot_canceled:flags.7?true id:string peer:Peer until_date:int pricing:StarsSubscriptionPricing chat_invite_hash:flags.3?string title:flags.4?string photo:flags.5?WebDocument invoice_slug:flags.6?string = StarsSubscription; + +messageReactor#4ba3a95a flags:# top:flags.0?true my:flags.1?true anonymous:flags.2?true peer_id:flags.3?Peer count:int = MessageReactor; + +starsGiveawayOption#94ce852a flags:# extended:flags.0?true default:flags.1?true stars:long yearly_boosts:int store_product:flags.2?string currency:string amount:long winners:Vector = StarsGiveawayOption; + +starsGiveawayWinnersOption#54236209 flags:# default:flags.0?true users:int per_user_stars:long = StarsGiveawayWinnersOption; + +starGift#313a9547 flags:# limited:flags.0?true sold_out:flags.1?true birthday:flags.2?true require_premium:flags.7?true limited_per_user:flags.8?true peer_color_available:flags.10?true auction:flags.11?true id:long sticker:Document stars:long availability_remains:flags.0?int availability_total:flags.0?int availability_resale:flags.4?long convert_stars:long first_sale_date:flags.1?int last_sale_date:flags.1?int upgrade_stars:flags.3?long resell_min_stars:flags.4?long title:flags.5?string released_by:flags.6?Peer per_user_total:flags.8?int per_user_remains:flags.8?int locked_until_date:flags.9?int auction_slug:flags.11?string gifts_per_round:flags.11?int auction_start_date:flags.11?int upgrade_variants:flags.12?int background:flags.13?StarGiftBackground = StarGift; +starGiftUnique#85f0a9cd flags:# require_premium:flags.6?true resale_ton_only:flags.7?true theme_available:flags.9?true burned:flags.14?true crafted:flags.15?true id:long gift_id:long title:string slug:string num:int owner_id:flags.0?Peer owner_name:flags.1?string owner_address:flags.2?string attributes:Vector availability_issued:int availability_total:int gift_address:flags.3?string resell_amount:flags.4?Vector released_by:flags.5?Peer value_amount:flags.8?long value_currency:flags.8?string value_usd_amount:flags.8?long theme_peer:flags.10?Peer peer_color:flags.11?PeerColor host_id:flags.12?Peer offer_min_stars:flags.13?int craft_chance_permille:flags.16?int = StarGift; + +payments.starGiftsNotModified#a388a368 = payments.StarGifts; +payments.starGifts#2ed82995 hash:int gifts:Vector chats:Vector users:Vector = payments.StarGifts; + +messageReportOption#7903e3d9 text:string option:bytes = MessageReportOption; + +reportResultChooseOption#f0e4e0b6 title:string options:Vector = ReportResult; +reportResultAddComment#6f09ac31 flags:# optional:flags.0?true option:bytes = ReportResult; +reportResultReported#8db33c4b = ReportResult; + +messages.botPreparedInlineMessage#8ecf0511 id:string expire_date:int = messages.BotPreparedInlineMessage; + +messages.preparedInlineMessage#ff57708d query_id:long result:BotInlineResult peer_types:Vector cache_time:int users:Vector = messages.PreparedInlineMessage; + +botAppSettings#c99b1950 flags:# placeholder_path:flags.0?bytes background_color:flags.1?int background_dark_color:flags.2?int header_color:flags.3?int header_dark_color:flags.4?int = BotAppSettings; + +starRefProgram#dd0c66f2 flags:# bot_id:long commission_permille:int duration_months:flags.0?int end_date:flags.1?int daily_revenue_per_user:flags.2?StarsAmount = StarRefProgram; + +connectedBotStarRef#19a13f71 flags:# revoked:flags.1?true url:string date:int bot_id:long commission_permille:int duration_months:flags.0?int participants:long revenue:long = ConnectedBotStarRef; + +payments.connectedStarRefBots#98d5ea1d count:int connected_bots:Vector users:Vector = payments.ConnectedStarRefBots; + +payments.suggestedStarRefBots#b4d5d859 flags:# count:int suggested_bots:Vector users:Vector next_offset:flags.0?string = payments.SuggestedStarRefBots; + +starsAmount#bbb6b4a3 amount:long nanos:int = StarsAmount; +starsTonAmount#74aee3e0 amount:long = StarsAmount; + +messages.foundStickersNotModified#6010c534 flags:# next_offset:flags.0?int = messages.FoundStickers; +messages.foundStickers#82c9e290 flags:# next_offset:flags.0?int hash:long stickers:Vector = messages.FoundStickers; + +botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string = BotVerifierSettings; + +botVerification#f93cd45c bot_id:long icon:long description:string = BotVerification; + +starGiftAttributeModel#565251e2 flags:# crafted:flags.0?true name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributePattern#4e7085ea name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeBackdrop#9f2504e4 name:string backdrop_id:int center_color:int edge_color:int pattern_color:int text_color:int rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeOriginalDetails#e0bff26c flags:# sender_id:flags.0?Peer recipient_id:Peer date:int message:flags.1?TextWithEntities = StarGiftAttribute; + +payments.starGiftUpgradePreview#3de1dfed sample_attributes:Vector prices:Vector next_prices:Vector = payments.StarGiftUpgradePreview; + +users.users#62d706b8 users:Vector = users.Users; +users.usersSlice#315a4974 count:int users:Vector = users.Users; + +payments.uniqueStarGift#416c56e8 gift:StarGift chats:Vector users:Vector = payments.UniqueStarGift; + +messages.webPagePreview#8c9a88ac media:MessageMedia chats:Vector users:Vector = messages.WebPagePreview; + +savedStarGift#41df43fc flags:# name_hidden:flags.0?true unsaved:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true pinned_to_top:flags.12?true upgrade_separate:flags.17?true from_id:flags.1?Peer date:int gift:StarGift message:flags.2?TextWithEntities msg_id:flags.3?int saved_id:flags.11?long convert_stars:flags.4?long upgrade_stars:flags.6?long can_export_at:flags.7?int transfer_stars:flags.8?long can_transfer_at:flags.13?int can_resell_at:flags.14?int collection_id:flags.15?Vector prepaid_upgrade_hash:flags.16?string drop_original_details_stars:flags.18?long gift_num:flags.19?int can_craft_at:flags.20?int = SavedStarGift; + +payments.savedStarGifts#95f389b1 flags:# count:int chat_notifications_enabled:flags.1?Bool gifts:Vector next_offset:flags.0?string chats:Vector users:Vector = payments.SavedStarGifts; + +inputSavedStarGiftUser#69279795 msg_id:int = InputSavedStarGift; +inputSavedStarGiftChat#f101aa7f peer:InputPeer saved_id:long = InputSavedStarGift; +inputSavedStarGiftSlug#2085c238 slug:string = InputSavedStarGift; + +payments.starGiftWithdrawalUrl#84aa3a9c url:string = payments.StarGiftWithdrawalUrl; + +paidReactionPrivacyDefault#206ad49e = PaidReactionPrivacy; +paidReactionPrivacyAnonymous#1f0c1ad9 = PaidReactionPrivacy; +paidReactionPrivacyPeer#dc6cfcf0 peer:InputPeer = PaidReactionPrivacy; + +account.paidMessagesRevenue#1e109708 stars_amount:long = account.PaidMessagesRevenue; + +requirementToContactEmpty#50a9839 = RequirementToContact; +requirementToContactPremium#e581e4e9 = RequirementToContact; +requirementToContactPaidMessages#b4f67e93 stars_amount:long = RequirementToContact; + +businessBotRights#a0624cf7 flags:# reply:flags.0?true read_messages:flags.1?true delete_sent_messages:flags.2?true delete_received_messages:flags.3?true edit_name:flags.4?true edit_bio:flags.5?true edit_profile_photo:flags.6?true edit_username:flags.7?true view_gifts:flags.8?true sell_gifts:flags.9?true change_gift_settings:flags.10?true transfer_and_upgrade_gifts:flags.11?true transfer_stars:flags.12?true manage_stories:flags.13?true = BusinessBotRights; + +disallowedGiftsSettings#71f276c4 flags:# disallow_unlimited_stargifts:flags.0?true disallow_limited_stargifts:flags.1?true disallow_unique_stargifts:flags.2?true disallow_premium_gifts:flags.3?true disallow_stargifts_from_channels:flags.4?true = DisallowedGiftsSettings; + +sponsoredPeer#c69708d3 flags:# random_id:bytes peer:Peer sponsor_info:flags.0?string additional_info:flags.1?string = SponsoredPeer; + +contacts.sponsoredPeersEmpty#ea32b4b1 = contacts.SponsoredPeers; +contacts.sponsoredPeers#eb032884 peers:Vector chats:Vector users:Vector = contacts.SponsoredPeers; + +starGiftAttributeIdModel#48aaae3c document_id:long = StarGiftAttributeId; +starGiftAttributeIdPattern#4a162433 document_id:long = StarGiftAttributeId; +starGiftAttributeIdBackdrop#1f01c757 backdrop_id:int = StarGiftAttributeId; + +starGiftAttributeCounter#2eb1b658 attribute:StarGiftAttributeId count:int = StarGiftAttributeCounter; + +payments.resaleStarGifts#947a12df flags:# count:int gifts:Vector next_offset:flags.0?string attributes:flags.1?Vector attributes_hash:flags.1?long chats:Vector counters:flags.2?Vector users:Vector = payments.ResaleStarGifts; + +stories.canSendStoryCount#c387c04e count_remains:int = stories.CanSendStoryCount; + +pendingSuggestion#e7e82e12 suggestion:string title:TextWithEntities description:TextWithEntities url:string = PendingSuggestion; + +todoItem#cba9a52f id:int title:TextWithEntities = TodoItem; + +todoList#49b92a26 flags:# others_can_append:flags.0?true others_can_complete:flags.1?true title:TextWithEntities list:Vector = TodoList; + +todoCompletion#221bb5e4 id:int completed_by:Peer date:int = TodoCompletion; + +suggestedPost#e8e37e5 flags:# accepted:flags.1?true rejected:flags.2?true price:flags.3?StarsAmount schedule_date:flags.0?int = SuggestedPost; + +starsRating#1b0e4f07 flags:# level:int current_level_stars:long stars:long next_level_stars:flags.0?long = StarsRating; + +starGiftCollection#9d6b13b0 flags:# collection_id:int title:string icon:flags.0?Document gifts_count:int hash:long = StarGiftCollection; + +payments.starGiftCollectionsNotModified#a0ba4f17 = payments.StarGiftCollections; +payments.starGiftCollections#8a2932f3 collections:Vector = payments.StarGiftCollections; + +storyAlbum#9325705a flags:# album_id:int title:string icon_photo:flags.0?Photo icon_video:flags.1?Document = StoryAlbum; + +stories.albumsNotModified#564edaeb = stories.Albums; +stories.albums#c3987a3a hash:long albums:Vector = stories.Albums; + +searchPostsFlood#3e0b5b6a flags:# query_is_free:flags.0?true total_daily:int remains:int wait_till:flags.1?int stars_amount:long = SearchPostsFlood; + +payments.uniqueStarGiftValueInfo#512fe446 flags:# last_sale_on_fragment:flags.1?true value_is_average:flags.6?true currency:string value:long initial_sale_date:int initial_sale_stars:long initial_sale_price:long last_sale_date:flags.0?int last_sale_price:flags.0?long floor_price:flags.2?long average_price:flags.3?long listed_count:flags.4?int fragment_listed_count:flags.5?int fragment_listed_url:flags.5?string = payments.UniqueStarGiftValueInfo; + +profileTabPosts#b98cd696 = ProfileTab; +profileTabGifts#4d4bd46a = ProfileTab; +profileTabMedia#72c64955 = ProfileTab; +profileTabFiles#ab339c00 = ProfileTab; +profileTabMusic#9f27d26e = ProfileTab; +profileTabVoice#e477092e = ProfileTab; +profileTabLinks#d3656499 = ProfileTab; +profileTabGifs#a2c0f695 = ProfileTab; + +users.savedMusicNotModified#e3878aa4 count:int = users.SavedMusic; +users.savedMusic#34a2f297 count:int documents:Vector = users.SavedMusic; + +account.savedMusicIdsNotModified#4fc81d6e = account.SavedMusicIds; +account.savedMusicIds#998d6636 ids:Vector = account.SavedMusicIds; + +payments.checkCanSendGiftResultOk#374fa7ad = payments.CheckCanSendGiftResult; +payments.checkCanSendGiftResultFail#d5e58274 reason:TextWithEntities = payments.CheckCanSendGiftResult; + +inputChatThemeEmpty#83268483 = InputChatTheme; +inputChatTheme#c93de95c emoticon:string = InputChatTheme; +inputChatThemeUniqueGift#87e5dfe4 slug:string = InputChatTheme; + +starGiftUpgradePrice#99ea331d date:int upgrade_stars:long = StarGiftUpgradePrice; + +groupCallMessage#1a8afc7e flags:# from_admin:flags.1?true id:int from_id:Peer date:int message:TextWithEntities paid_message_stars:flags.0?long = GroupCallMessage; + +groupCallDonor#ee430c85 flags:# top:flags.0?true my:flags.1?true peer_id:flags.3?Peer stars:long = GroupCallDonor; + +phone.groupCallStars#9d1dbd26 total_stars:long top_donors:Vector chats:Vector users:Vector = phone.GroupCallStars; + +recentStory#711d692d flags:# live:flags.0?true max_id:flags.1?int = RecentStory; + +auctionBidLevel#310240cc pos:int amount:long date:int = AuctionBidLevel; + +starGiftAuctionStateNotModified#fe333952 = StarGiftAuctionState; +starGiftAuctionState#771a4e66 version:int start_date:int end_date:int min_bid_amount:long bid_levels:Vector top_bidders:Vector next_round_at:int last_gift_num:int gifts_left:int current_round:int total_rounds:int rounds:Vector = StarGiftAuctionState; +starGiftAuctionStateFinished#972dabbf flags:# start_date:int end_date:int average_price:long listed_count:flags.0?int fragment_listed_count:flags.1?int fragment_listed_url:flags.1?string = StarGiftAuctionState; + +starGiftAuctionUserState#2eeed1c4 flags:# returned:flags.1?true bid_amount:flags.0?long bid_date:flags.0?int min_bid_amount:flags.0?long bid_peer:flags.0?Peer acquired_count:int = StarGiftAuctionUserState; + +payments.starGiftAuctionState#6b39f4ec gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState timeout:int users:Vector chats:Vector = payments.StarGiftAuctionState; + +starGiftAuctionAcquiredGift#42b00348 flags:# name_hidden:flags.0?true peer:Peer date:int bid_amount:long round:int pos:int message:flags.1?TextWithEntities gift_num:flags.2?int = StarGiftAuctionAcquiredGift; + +payments.starGiftAuctionAcquiredGifts#7d5bd1f0 gifts:Vector users:Vector chats:Vector = payments.StarGiftAuctionAcquiredGifts; + +starGiftActiveAuctionState#d31bc45d gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState = StarGiftActiveAuctionState; + +payments.starGiftActiveAuctionsNotModified#db33dad0 = payments.StarGiftActiveAuctions; +payments.starGiftActiveAuctions#aef6abbc auctions:Vector users:Vector chats:Vector = payments.StarGiftActiveAuctions; + +inputStarGiftAuction#2e16c98 gift_id:long = InputStarGiftAuction; +inputStarGiftAuctionSlug#7ab58308 slug:string = InputStarGiftAuction; + +passkey#98613ebf flags:# id:string name:string date:int software_emoji_id:flags.0?long last_usage_date:flags.1?int = Passkey; + +account.passkeys#f8e0aa1c passkeys:Vector = account.Passkeys; + +account.passkeyRegistrationOptions#e16b5ce1 options:DataJSON = account.PasskeyRegistrationOptions; + +auth.passkeyLoginOptions#e2037789 options:DataJSON = auth.PasskeyLoginOptions; + +inputPasskeyResponseRegister#3e63935c client_data:DataJSON attestation_data:bytes = InputPasskeyResponse; +inputPasskeyResponseLogin#c31fc14a client_data:DataJSON authenticator_data:bytes signature:bytes user_handle:string = InputPasskeyResponse; + +inputPasskeyCredentialPublicKey#3c27b78f id:string raw_id:string response:InputPasskeyResponse = InputPasskeyCredential; +inputPasskeyCredentialFirebasePNV#5b1ccb28 pnv_token:string = InputPasskeyCredential; + +starGiftBackground#aff56398 center_color:int edge_color:int text_color:int = StarGiftBackground; + +starGiftAuctionRound#3aae0528 num:int duration:int = StarGiftAuctionRound; +starGiftAuctionRoundExtendable#aa021e5 num:int duration:int extend_top:int extend_window:int = StarGiftAuctionRound; + +payments.starGiftUpgradeAttributes#46c6e36f attributes:Vector = payments.StarGiftUpgradeAttributes; + +messages.emojiGameOutcome#da2ad647 seed:bytes stake_ton_amount:long ton_amount:long = messages.EmojiGameOutcome; + +messages.emojiGameUnavailable#59e65335 = messages.EmojiGameInfo; +messages.emojiGameDiceInfo#44e56023 flags:# game_hash:string prev_stake:long current_streak:int params:Vector plays_left:flags.0?int = messages.EmojiGameInfo; + +starGiftAttributeRarity#36437737 permille:int = StarGiftAttributeRarity; +starGiftAttributeRarityUncommon#dbce6389 = StarGiftAttributeRarity; +starGiftAttributeRarityRare#f08d516b = StarGiftAttributeRarity; +starGiftAttributeRarityEpic#78fbf3a8 = StarGiftAttributeRarity; +starGiftAttributeRarityLegendary#cef7e7a8 = StarGiftAttributeRarity; + +keyboardButtonStyle#4fdd3430 flags:# bg_primary:flags.0?true bg_danger:flags.1?true bg_success:flags.2?true icon:flags.3?long = KeyboardButtonStyle; + +inputMessageReadMetric#402b4495 msg_id:int view_id:long time_in_view_ms:int active_time_in_view_ms:int height_to_viewport_ratio_permille:int seen_range_ratio_permille:int = InputMessageReadMetric; + +bots.exportedBotToken#3c60b621 token:string = bots.ExportedBotToken; + +bots.requestedButton#f13bbcd7 webapp_req_id:string = bots.RequestedButton; + +messages.composedMessageWithAI#90d7adfa flags:# result_text:TextWithEntities diff_text:flags.0?TextWithEntities = messages.ComposedMessageWithAI; + +---functions--- + +invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; +invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; +invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; +invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; +invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; +invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; +invokeWithBusinessConnection#dd289f8e {X:Type} connection_id:string query:!X = X; +invokeWithGooglePlayIntegrity#1df92984 {X:Type} nonce:string token:string query:!X = X; +invokeWithApnsSecret#0dae54f8 {X:Type} nonce:string secret:string query:!X = X; +invokeWithReCaptcha#adbb0f94 {X:Type} token:string query:!X = X; + +auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; +auth.signUp#aac7b717 flags:# no_joined_notifications:flags.0?true phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +auth.signIn#8d52a951 flags:# phone_number:string phone_code_hash:string phone_code:flags.0?string email_verification:flags.1?EmailVerification = auth.Authorization; +auth.logOut#3e72ba19 = auth.LoggedOut; +auth.resetAuthorizations#9fab0d1a = Bool; +auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; +auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; +auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; +auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; +auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; +auth.resendCode#cae47523 flags:# phone_number:string phone_code_hash:string reason:flags.0?string = auth.SentCode; +auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; +auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; +auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; +auth.acceptLoginToken#e894ad4d token:bytes = Authorization; +auth.checkRecoveryPassword#d36bf79 code:string = Bool; +auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; +auth.requestFirebaseSms#8e39261e flags:# phone_number:string phone_code_hash:string safety_net_token:flags.0?string play_integrity_token:flags.2?string ios_push_secret:flags.1?string = Bool; +auth.resetLoginEmail#7e960193 phone_number:string phone_code_hash:string = auth.SentCode; +auth.reportMissingCode#cb9deff6 phone_number:string phone_code_hash:string mnc:string = Bool; +auth.checkPaidAuth#56e59f9c phone_number:string phone_code_hash:string form_id:long = auth.SentCode; +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; + +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; +account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; +account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; +account.resetNotifySettings#db7e1747 = Bool; +account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; +account.updateStatus#6628562c offline:Bool = Bool; +account.getWallPapers#7967d36 hash:long = account.WallPapers; +account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; +account.checkUsername#2714d86c username:string = Bool; +account.updateUsername#3e0bdd7c username:string = User; +account.getPrivacy#dadbc950 key:InputPrivacyKey = account.PrivacyRules; +account.setPrivacy#c9f81ce8 key:InputPrivacyKey rules:Vector = account.PrivacyRules; +account.deleteAccount#a2c0cf74 flags:# reason:string password:flags.0?InputCheckPasswordSRP = Bool; +account.getAccountTTL#8fc711d = AccountDaysTTL; +account.setAccountTTL#2442485e ttl:AccountDaysTTL = Bool; +account.sendChangePhoneCode#82574ae5 phone_number:string settings:CodeSettings = auth.SentCode; +account.changePhone#70c32edb phone_number:string phone_code_hash:string phone_code:string = User; +account.updateDeviceLocked#38df3532 period:int = Bool; +account.getAuthorizations#e320c158 = account.Authorizations; +account.resetAuthorization#df77f3bc hash:long = Bool; +account.getPassword#548a30f5 = account.Password; +account.getPasswordSettings#9cd4eaf9 password:InputCheckPasswordSRP = account.PasswordSettings; +account.updatePasswordSettings#a59b102f password:InputCheckPasswordSRP new_settings:account.PasswordInputSettings = Bool; +account.sendConfirmPhoneCode#1b3faa88 hash:string settings:CodeSettings = auth.SentCode; +account.confirmPhone#5f2178c3 phone_code_hash:string phone_code:string = Bool; +account.getTmpPassword#449e0b51 password:InputCheckPasswordSRP period:int = account.TmpPassword; +account.getWebAuthorizations#182e6d6f = account.WebAuthorizations; +account.resetWebAuthorization#2d01b9ef hash:long = Bool; +account.resetWebAuthorizations#682d2594 = Bool; +account.getAllSecureValues#b288bc7d = Vector; +account.getSecureValue#73665bc2 types:Vector = Vector; +account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; +account.deleteSecureValue#b880bc4b types:Vector = Bool; +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; +account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; +account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; +account.sendVerifyEmailCode#98e037bb purpose:EmailVerifyPurpose email:string = account.SentEmailCode; +account.verifyEmail#32da4cf purpose:EmailVerifyPurpose verification:EmailVerification = account.EmailVerified; +account.initTakeoutSession#8ef3eab0 flags:# contacts:flags.0?true message_users:flags.1?true message_chats:flags.2?true message_megagroups:flags.3?true message_channels:flags.4?true files:flags.5?true file_max_size:flags.5?long = account.Takeout; +account.finishTakeoutSession#1d2652ee flags:# success:flags.0?true = Bool; +account.confirmPasswordEmail#8fdf1920 code:string = Bool; +account.resendPasswordEmail#7a7f2a15 = Bool; +account.cancelPasswordEmail#c1cbd5b6 = Bool; +account.getContactSignUpNotification#9f07c728 = Bool; +account.setContactSignUpNotification#cff43f61 silent:Bool = Bool; +account.getNotifyExceptions#53577479 flags:# compare_sound:flags.1?true compare_stories:flags.2?true peer:flags.0?InputNotifyPeer = Updates; +account.getWallPaper#fc8ddbea wallpaper:InputWallPaper = WallPaper; +account.uploadWallPaper#e39a8f03 flags:# for_chat:flags.0?true file:InputFile mime_type:string settings:WallPaperSettings = WallPaper; +account.saveWallPaper#6c5a5b37 wallpaper:InputWallPaper unsave:Bool settings:WallPaperSettings = Bool; +account.installWallPaper#feed5769 wallpaper:InputWallPaper settings:WallPaperSettings = Bool; +account.resetWallPapers#bb3b9804 = Bool; +account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; +account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; +account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; +account.getTheme#3a5869ec format:string theme:InputTheme = Theme; +account.getThemes#7206e458 format:string hash:long = account.Themes; +account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; +account.getContentSettings#8b9b4dae = account.ContentSettings; +account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; +account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; +account.resetPassword#9308ce1b = account.ResetPasswordResult; +account.declinePasswordReset#4c9409f6 = Bool; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# confirmed:flags.3?true hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; +account.getSavedRingtones#e1902288 hash:long = account.SavedRingtones; +account.saveRingtone#3dea5b03 id:InputDocument unsave:Bool = account.SavedRingtone; +account.uploadRingtone#831a83a2 file:InputFile file_name:string mime_type:string = Document; +account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; +account.getDefaultEmojiStatuses#d6753386 hash:long = account.EmojiStatuses; +account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; +account.clearRecentEmojiStatuses#18201aae = Bool; +account.reorderUsernames#ef500eab order:Vector = Bool; +account.toggleUsername#58d6b376 username:string active:Bool = Bool; +account.getDefaultProfilePhotoEmojis#e2750328 hash:long = EmojiList; +account.getDefaultGroupPhotoEmojis#915860ae hash:long = EmojiList; +account.getAutoSaveSettings#adcbbcda = account.AutoSaveSettings; +account.saveAutoSaveSettings#d69b8361 flags:# users:flags.0?true chats:flags.1?true broadcasts:flags.2?true peer:flags.3?InputPeer settings:AutoSaveSettings = Bool; +account.deleteAutoSaveExceptions#53bc0020 = Bool; +account.invalidateSignInCodes#ca8ae8ba codes:Vector = Bool; +account.updateColor#684d214e flags:# for_profile:flags.1?true color:flags.2?PeerColor = Bool; +account.getDefaultBackgroundEmojis#a60ab9ce hash:long = EmojiList; +account.getChannelDefaultEmojiStatuses#7727a7d5 hash:long = account.EmojiStatuses; +account.getChannelRestrictedStatusEmojis#35a9e0d5 hash:long = EmojiList; +account.updateBusinessWorkHours#4b00e066 flags:# business_work_hours:flags.0?BusinessWorkHours = Bool; +account.updateBusinessLocation#9e6b131a flags:# geo_point:flags.1?InputGeoPoint address:flags.0?string = Bool; +account.updateBusinessGreetingMessage#66cdafc4 flags:# message:flags.0?InputBusinessGreetingMessage = Bool; +account.updateBusinessAwayMessage#a26a7fa5 flags:# message:flags.0?InputBusinessAwayMessage = Bool; +account.updateConnectedBot#66a08c7e flags:# deleted:flags.1?true rights:flags.0?BusinessBotRights bot:InputUser recipients:InputBusinessBotRecipients = Updates; +account.getConnectedBots#4ea4c80f = account.ConnectedBots; +account.getBotBusinessConnection#76a86270 connection_id:string = Updates; +account.updateBusinessIntro#a614d034 flags:# intro:flags.0?InputBusinessIntro = Bool; +account.toggleConnectedBotPaused#646e1097 peer:InputPeer paused:Bool = Bool; +account.disablePeerConnectedBot#5e437ed9 peer:InputPeer = Bool; +account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool; +account.createBusinessChatLink#8851e68e link:InputBusinessChatLink = BusinessChatLink; +account.editBusinessChatLink#8c3410af slug:string link:InputBusinessChatLink = BusinessChatLink; +account.deleteBusinessChatLink#60073674 slug:string = Bool; +account.getBusinessChatLinks#6f70dde1 = account.BusinessChatLinks; +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; +account.updatePersonalChannel#d94305e0 channel:InputChannel = Bool; +account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; +account.getReactionsNotifySettings#6dd654c = ReactionsNotifySettings; +account.setReactionsNotifySettings#316ce548 settings:ReactionsNotifySettings = ReactionsNotifySettings; +account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; +account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; +account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; +account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; +account.saveMusic#b26732a9 flags:# unsave:flags.0?true id:InputDocument after_id:flags.1?InputDocument = Bool; +account.getSavedMusicIds#e09d5faf hash:long = account.SavedMusicIds; +account.getUniqueGiftChatThemes#e42ce9c9 offset:string limit:int hash:long = account.ChatThemes; +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; +account.getPasskeys#ea1f0c52 = account.Passkeys; +account.deletePasskey#f5b5563f id:string = Bool; + +users.getUsers#d91a548 id:Vector = Vector; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; +users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; +users.getRequirementsToContact#d89a83a3 id:Vector = Vector; +users.getSavedMusic#788d7fe3 id:InputUser offset:int limit:int hash:long = users.SavedMusic; +users.getSavedMusicByID#7573a4e9 id:InputUser documents:Vector = users.SavedMusic; +users.suggestBirthday#fc533372 id:InputUser birthday:Birthday = Updates; + +contacts.getContactIDs#7adc669d hash:long = Vector; +contacts.getStatuses#c4a353ee = Vector; +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; +contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; +contacts.deleteContacts#96a0e00 id:Vector = Updates; +contacts.deleteByPhones#1013fd9e phones:Vector = Bool; +contacts.block#2e2e8734 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.unblock#b550d328 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.getBlocked#9a868f80 flags:# my_stories_from:flags.0?true offset:int limit:int = contacts.Blocked; +contacts.search#11f812d8 q:string limit:int = contacts.Found; +contacts.resolveUsername#725afbbc flags:# username:string referer:flags.0?string = contacts.ResolvedPeer; +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true bots_app:flags.16?true offset:int limit:int hash:long = contacts.TopPeers; +contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; +contacts.resetSaved#879537f1 = Bool; +contacts.getSaved#82f1e39f = Vector; +contacts.toggleTopPeers#8514bdda enabled:Bool = Bool; +contacts.addContact#d9ba2e54 flags:# add_phone_privacy_exception:flags.0?true id:InputUser first_name:string last_name:string phone:string note:flags.1?TextWithEntities = Updates; +contacts.acceptContact#f831a20f id:InputUser = Updates; +contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; +contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; +contacts.exportContactToken#f8654027 = ExportedContactToken; +contacts.importContactToken#13005788 token:string = User; +contacts.editCloseFriends#ba6705f0 id:Vector = Bool; +contacts.setBlocked#94c65c76 flags:# my_stories_from:flags.0?true id:Vector limit:int = Bool; +contacts.getBirthdays#daeda864 = contacts.ContactBirthdays; +contacts.getSponsoredPeers#b6c8c393 q:string = contacts.SponsoredPeers; +contacts.updateContactNote#139f63fb id:InputUser note:TextWithEntities = Bool; + +messages.getMessages#63c66506 id:Vector = messages.Messages; +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; +messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.search#29ee847a flags:# peer:InputPeer q:string from_id:flags.0?InputPeer saved_peer_id:flags.2?InputPeer saved_reaction:flags.3?Vector top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; +messages.receivedMessages#5a954c0 max_id:int = Vector; +messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; +messages.sendMessage#545cd15a flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.sendMedia#330e77f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.forwardMessages#13704a7c flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true allow_paid_floodskip:flags.19?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer top_msg_id:flags.9?int reply_to:flags.22?InputReplyTo schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long video_timestamp:flags.20?int allow_paid_stars:flags.21?long suggested_post:flags.23?SuggestedPost = Updates; +messages.reportSpam#cf1592db peer:InputPeer = Bool; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; +messages.report#fc78af9b peer:InputPeer id:Vector option:bytes message:string = ReportResult; +messages.getChats#49e9528f id:Vector = messages.Chats; +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; +messages.addChatUser#cbc6d107 chat_id:long user_id:InputUser fwd_limit:int = messages.InvitedUsers; +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; +messages.createChat#92ceddd4 flags:# users:Vector title:string ttl_period:flags.0?int = messages.InvitedUsers; +messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; +messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; +messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; +messages.discardEncryption#f393aea0 flags:# delete_history:flags.0?true chat_id:int = Bool; +messages.setEncryptedTyping#791451ed peer:InputEncryptedChat typing:Bool = Bool; +messages.readEncryptedHistory#7f4b690a peer:InputEncryptedChat max_date:int = Bool; +messages.sendEncrypted#44fa7a15 flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.sendEncryptedFile#5559481d flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes file:InputEncryptedFile = messages.SentEncryptedMessage; +messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.receivedQueue#55a5bb66 max_qts:int = Vector; +messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; +messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; +messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; +messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; +messages.getWebPagePreview#570d6f6f flags:# message:string entities:flags.3?Vector = messages.WebPagePreview; +messages.exportChatInvite#a455de90 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string subscription_pricing:flags.5?StarsSubscriptionPricing = ExportedChatInvite; +messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; +messages.importChatInvite#6c50051c hash:string = Updates; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; +messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; +messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; +messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; +messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; +messages.migrateChat#a2875319 chat_id:long = Updates; +messages.searchGlobal#4bc6589a flags:# broadcasts_only:flags.1?true groups_only:flags.2?true users_only:flags.3?true folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; +messages.reorderStickerSets#78337739 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Bool; +messages.getDocumentByHash#b1f2061f sha256:bytes size:long mime_type:string = Document; +messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; +messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; +messages.setInlineBotResults#bb12a419 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM switch_webview:flags.4?InlineBotWebView = Bool; +messages.sendInlineBotResult#c0cf7646 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to:flags.0?InputReplyTo random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut allow_paid_stars:flags.21?long = Updates; +messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; +messages.editMessage#51e842e1 flags:# no_webpage:flags.1?true invert_media:flags.16?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int schedule_repeat_period:flags.18?int quick_reply_shortcut_id:flags.17?int = Updates; +messages.editInlineBotMessage#83557dba flags:# no_webpage:flags.1?true invert_media:flags.16?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; +messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer; +messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; +messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; +messages.saveDraft#54ae308e flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo peer:InputPeer message:string entities:flags.3?Vector media:flags.5?InputMedia effect:flags.7?long suggested_post:flags.8?SuggestedPost = Bool; +messages.getAllDrafts#6a3f8d65 = Updates; +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; +messages.readFeaturedStickers#5b118126 id:Vector = Bool; +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; +messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; +messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true emojis:flags.1?true offset_id:long limit:int = messages.ArchivedStickers; +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; +messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; +messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; +messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; +messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; +messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; +messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; +messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; +messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?string shipping_options:flags.1?Vector = Bool; +messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; +messages.uploadMedia#14967978 flags:# business_connection_id:flags.0?string peer:InputPeer media:InputMedia = MessageMedia; +messages.sendScreenshotNotification#a1405817 peer:InputPeer reply_to:InputReplyTo random_id:long = Updates; +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; +messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; +messages.getUnreadMentions#f107e790 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readMentions#36e5bf4d flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; +messages.sendMultiMedia#1bf89d74 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long = Updates; +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSplitRanges#1cff7e08 = Vector; +messages.markDialogUnread#8c5006f8 flags:# unread:flags.0?true parent_peer:flags.1?InputPeer peer:InputDialogPeer = Bool; +messages.getDialogUnreadMarks#21202222 flags:# parent_peer:flags.0?InputPeer = Vector; +messages.clearAllDrafts#7e58ee9c = Bool; +messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1?true pm_oneside:flags.2?true peer:InputPeer id:int = Updates; +messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; +messages.getPollResults#eda3e33b peer:InputPeer msg_id:int poll_hash:long = Updates; +messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; +messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; +messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; +messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; +messages.getEmojiKeywordsDifference#1508b6af lang_code:string from_version:int = EmojiKeywordsDifference; +messages.getEmojiKeywordsLanguages#4e9963b2 lang_codes:Vector = Vector; +messages.getEmojiURL#d5b10c26 lang_code:string = EmojiURL; +messages.getSearchCounters#1bbcf300 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer top_msg_id:flags.0?int filters:Vector = Vector; +messages.requestUrlAuth#894cc99c flags:# peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string in_app_origin:flags.3?string = UrlAuthResult; +messages.acceptUrlAuth#67a3f0de flags:# write_allowed:flags.0?true share_phone_number:flags.3?true peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string match_code:flags.4?string = UrlAuthResult; +messages.hidePeerSettingsBar#4facb138 peer:InputPeer = Bool; +messages.getScheduledHistory#f516760b peer:InputPeer hash:long = messages.Messages; +messages.getScheduledMessages#bdbb0464 peer:InputPeer id:Vector = messages.Messages; +messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; +messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; +messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; +messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; +messages.getDialogFilters#efd48c89 = messages.DialogFilters; +messages.getSuggestedDialogFilters#a29cd42c = Vector; +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; +messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; +messages.unpinAllMessages#62dd747 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; +messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; +messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; +messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:string media:InputMedia = MessageMedia; +messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; +messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; +messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true subscription_expired:flags.3?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; +messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; +messages.setChatTheme#81202c9 peer:InputPeer theme:InputChatTheme = Updates; +messages.getMessageReadParticipants#31c1c44f peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#6aa3f6bd flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#9c7f2f10 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b2081a35 flags:# peer:InputPeer enabled:Bool request_msg_id:flags.0?int = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#d30d78d4 flags:# big:flags.1?true add_to_recent:flags.2?true peer:InputPeer msg_id:int reaction:flags.0?Vector = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#864b2581 flags:# peer:InputPeer available_reactions:ChatReactions reactions_limit:flags.0?int paid_enabled:flags.1?Bool = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool; +messages.translateText#a5eec345 flags:# peer:flags.0?InputPeer id:flags.0?Vector text:flags.1?Vector to_lang:string tone:flags.2?string = messages.TranslatedText; +messages.getUnreadReactions#bd7f90ac flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#9ec44f93 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; +messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; +messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; +messages.toggleBotInAttachMenu#69f59d69 flags:# write_allowed:flags.0?true bot:InputUser enabled:Bool = Bool; +messages.requestWebView#269dc2c1 flags:# from_bot_menu:flags.4?true silent:flags.5?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser url:flags.1?string start_param:flags.3?string theme_params:flags.2?DataJSON platform:string reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = WebViewResult; +messages.prolongWebView#b0d81a83 flags:# silent:flags.5?true peer:InputPeer bot:InputUser query_id:long reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = Bool; +messages.requestSimpleWebView#413a3e73 flags:# from_switch_webview:flags.1?true from_side_menu:flags.2?true compact:flags.7?true fullscreen:flags.8?true bot:InputUser url:flags.3?string start_param:flags.4?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent; +messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates; +messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio; +messages.rateTranscribedAudio#7f1d072f peer:InputPeer msg_id:int transcription_id:long good:Bool = Bool; +messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; +messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; +messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; +messages.reportReaction#3f64c076 peer:InputPeer id:int reaction_peer:InputPeer = Bool; +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; +messages.clearRecentReactions#9dfeefb4 = Bool; +messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; +messages.sendBotRequestedPeer#6c5cf2a7 flags:# peer:InputPeer msg_id:flags.0?int webapp_req_id:flags.1?string button_id:int requested_peers:Vector = Updates; +messages.getEmojiGroups#7488ce5b hash:int = messages.EmojiGroups; +messages.getEmojiStatusGroups#2ecd56cd hash:int = messages.EmojiGroups; +messages.getEmojiProfilePhotoGroups#21a548f3 hash:int = messages.EmojiGroups; +messages.searchCustomEmoji#2c11c0d7 emoticon:string hash:long = EmojiList; +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; +messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; +messages.requestAppWebView#53618bce flags:# write_allowed:flags.0?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = WebViewResult; +messages.setChatWallPaper#8ffacae1 flags:# for_both:flags.3?true revert:flags.4?true peer:InputPeer wallpaper:flags.0?InputWallPaper settings:flags.2?WallPaperSettings id:flags.1?int = Updates; +messages.searchEmojiStickerSets#92b4494c flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSavedDialogs#1e91fc99 flags:# exclude_pinned:flags.0?true parent_peer:flags.1?InputPeer offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; +messages.getSavedHistory#998ab009 flags:# parent_peer:flags.0?InputPeer peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.deleteSavedHistory#4dc5085f flags:# parent_peer:flags.0?InputPeer peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; +messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedSavedDialogs#8b716587 flags:# force:flags.0?true order:Vector = Bool; +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; +messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; +messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; +messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; +messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; +messages.reorderQuickReplies#60331907 order:Vector = Bool; +messages.checkQuickReplyShortcut#f1d0fbd3 shortcut:string = Bool; +messages.editQuickReplyShortcut#5c003cef shortcut_id:int shortcut:string = Bool; +messages.deleteQuickReplyShortcut#3cc04740 shortcut_id:int = Bool; +messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; +messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; +messages.deleteQuickReplyMessages#e105e910 shortcut_id:int id:Vector = Updates; +messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool; +messages.getMyStickers#d0b5e1fc offset_id:long limit:int = messages.MyStickers; +messages.getEmojiStickerGroups#1dd840f5 hash:int = messages.EmojiGroups; +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; +messages.editFactCheck#589ee75 peer:InputPeer msg_id:int text:TextWithEntities = Updates; +messages.deleteFactCheck#d1da940c peer:InputPeer msg_id:int = Updates; +messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; +messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendPaidReaction#58bbcb50 flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?PaidReactionPrivacy = Updates; +messages.togglePaidReactionPrivacy#435885b5 peer:InputPeer msg_id:int private:PaidReactionPrivacy = Bool; +messages.getPaidReactionPrivacy#472455aa = Updates; +messages.viewSponsoredMessage#269e3643 random_id:bytes = Bool; +messages.clickSponsoredMessage#8235057e flags:# media:flags.0?true fullscreen:flags.1?true random_id:bytes = Bool; +messages.reportSponsoredMessage#12cbf0c4 random_id:bytes option:bytes = channels.SponsoredMessageReportResult; +messages.getSponsoredMessages#3d6ce850 flags:# peer:InputPeer msg_id:flags.0?int = messages.SponsoredMessages; +messages.savePreparedInlineMessage#f21f7f2f flags:# result:InputBotInlineResult user_id:InputUser peer_types:flags.0?Vector = messages.BotPreparedInlineMessage; +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; +messages.searchStickers#29b1c66a flags:# emojis:flags.0?true q:string emoticon:string lang_code:Vector offset:int limit:int hash:long = messages.FoundStickers; +messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = Bool; +messages.getSavedDialogsByID#6f6f9c96 flags:# parent_peer:flags.1?InputPeer ids:Vector = messages.SavedDialogs; +messages.readSavedHistory#ba4a3b5b parent_peer:InputPeer peer:InputPeer max_id:int = Bool; +messages.toggleTodoCompleted#d3e03124 peer:InputPeer msg_id:int completed:Vector incompleted:Vector = Updates; +messages.appendTodoList#21a61057 peer:InputPeer msg_id:int list:Vector = Updates; +messages.toggleSuggestedPostApproval#8107455c flags:# reject:flags.1?true peer:InputPeer msg_id:int schedule_date:flags.0?int reject_comment:flags.2?string = Updates; +messages.getForumTopics#3ba47bff flags:# peer:InputPeer q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; +messages.getForumTopicsByID#af0a4a08 peer:InputPeer topics:Vector = messages.ForumTopics; +messages.editForumTopic#cecc1134 flags:# peer:InputPeer topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; +messages.updatePinnedForumTopic#175df251 peer:InputPeer topic_id:int pinned:Bool = Updates; +messages.reorderPinnedForumTopics#e7841f0 flags:# force:flags.0?true peer:InputPeer order:Vector = Updates; +messages.createForumTopic#2f98c3d5 flags:# title_missing:flags.4?true peer:InputPeer title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; +messages.deleteTopicHistory#d2816f10 peer:InputPeer top_msg_id:int = messages.AffectedHistory; +messages.getEmojiGameInfo#fb7e8ca7 = messages.EmojiGameInfo; +messages.summarizeText#abbbd346 flags:# peer:InputPeer id:int to_lang:flags.0?string tone:flags.2?string = TextWithEntities; +messages.editChatCreator#f743b857 peer:InputPeer user_id:InputUser password:InputCheckPasswordSRP = Updates; +messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User; +messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates; +messages.declineUrlAuth#35436bbc url:string = Bool; +messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool; +messages.composeMessageWithAI#fd426afe flags:# proofread:flags.0?true emojify:flags.3?true text:TextWithEntities translate_to_lang:flags.1?string change_tone:flags.2?string = messages.ComposedMessageWithAI; +messages.reportReadMetrics#4067c5e6 peer:InputPeer metrics:Vector = Bool; +messages.reportMusicListen#ddbcd819 id:InputDocument listened_duration:int = Bool; +messages.addPollAnswer#19bc4b6d peer:InputPeer msg_id:int answer:PollAnswer = Updates; +messages.deletePollAnswer#ac8505a5 peer:InputPeer msg_id:int option:bytes = Updates; +messages.getUnreadPollVotes#43286cf2 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readPollVotes#1720b4d8 flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; + +updates.getState#edd4882a = updates.State; +updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; +updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; + +photos.updateProfilePhoto#9e82039 flags:# fallback:flags.0?true bot:flags.1?InputUser id:InputPhoto = photos.Photo; +photos.uploadProfilePhoto#388a3b5 flags:# fallback:flags.3?true bot:flags.5?InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.4?VideoSize = photos.Photo; +photos.deletePhotos#87cf7f2f id:Vector = Vector; +photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; +photos.uploadContactProfilePhoto#e14c4a71 flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.5?VideoSize = photos.Photo; + +upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; +upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; +upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool; +upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile; +upload.getCdnFile#395f69da file_token:bytes offset:long limit:int = upload.CdnFile; +upload.reuploadCdnFile#9b2754a8 file_token:bytes request_token:bytes = Vector; +upload.getCdnFileHashes#91dc3f31 file_token:bytes offset:long = Vector; +upload.getFileHashes#9156982a location:InputFileLocation offset:long = Vector; + +help.getConfig#c4f9186b = Config; +help.getNearestDc#1fb33026 = NearestDc; +help.getAppUpdate#522d5a7d source:string = help.AppUpdate; +help.getInviteText#4d392343 = help.InviteText; +help.getSupport#9cdf08cd = help.Support; +help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; +help.getCdnConfig#52029342 = CdnConfig; +help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; +help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; +help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; +help.getAppConfig#61e3f854 hash:int = help.AppConfig; +help.saveAppLog#6f02f748 events:Vector = Bool; +help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; +help.getSupportName#d360e72c = help.SupportName; +help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; +help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; +help.getPromoData#c0977421 = help.PromoData; +help.hidePromoData#1e251c95 peer:InputPeer = Bool; +help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; +help.getPremiumPromo#b81b93d4 = help.PremiumPromo; +help.getPeerColors#da80f42f hash:int = help.PeerColors; +help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors; +help.getTimezonesList#49b30240 hash:int = help.TimezonesList; + +channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; +channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; +channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; +channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; +channels.getChannels#a7f6bbb id:Vector = messages.Chats; +channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true forum:flags.5?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; +channels.editAdmin#9a98ad68 flags:# channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:flags.0?string = Updates; +channels.editTitle#566decd0 channel:InputChannel title:string = Updates; +channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; +channels.checkUsername#10e6bd2c channel:InputChannel username:string = Bool; +channels.updateUsername#3514b3de channel:InputChannel username:string = Bool; +channels.joinChannel#24b524c5 channel:InputChannel = Updates; +channels.leaveChannel#f836aa95 channel:InputChannel = Updates; +channels.inviteToChannel#c9e33d54 channel:InputChannel users:Vector = messages.InvitedUsers; +channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; +channels.exportMessageLink#e63fadeb flags:# grouped:flags.0?true thread:flags.1?true channel:InputChannel id:int = ExportedMessageLink; +channels.toggleSignatures#418d549c flags:# signatures_enabled:flags.0?true profiles_enabled:flags.1?true channel:InputChannel = Updates; +channels.getAdminedPublicChannels#f8b036af flags:# by_location:flags.0?true check_limit:flags.1?true for_personal:flags.2?true = messages.Chats; +channels.editBanned#96e6cd81 channel:InputChannel participant:InputPeer banned_rights:ChatBannedRights = Updates; +channels.getAdminLog#33ddf480 flags:# channel:InputChannel q:string events_filter:flags.0?ChannelAdminLogEventsFilter admins:flags.1?Vector max_id:long min_id:long limit:int = channels.AdminLogResults; +channels.setStickers#ea8ca4f9 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = Bool; +channels.deleteHistory#9baa9647 flags:# for_everyone:flags.0?true channel:InputChannel max_id:int = Updates; +channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates; +channels.getLeftChannels#8341ecc0 offset:int = messages.Chats; +channels.getGroupsForDiscussion#f5dad378 = messages.Chats; +channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool; +channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint address:string = Bool; +channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; +channels.getInactiveChannels#11e831ee = messages.InactiveChats; +channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; +channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; +channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; +channels.toggleJoinRequest#4c2985b6 channel:InputChannel enabled:Bool = Updates; +channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; +channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; +channels.deactivateAllUsernames#a245dd3 channel:InputChannel = Bool; +channels.toggleForum#3ff75734 channel:InputChannel enabled:Bool tabs:Bool = Updates; +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; +channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; +channels.updateColor#d8aa3671 flags:# for_profile:flags.1?true channel:InputChannel color:flags.2?int background_emoji_id:flags.0?long = Updates; +channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; +channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; +channels.updateEmojiStatus#f0d3e6a8 channel:InputChannel emoji_status:EmojiStatus = Updates; +channels.setBoostsToUnblockRestrictions#ad399cee channel:InputChannel boosts:int = Updates; +channels.setEmojiStickers#3cd930b7 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.restrictSponsoredMessages#9ae91519 channel:InputChannel restricted:Bool = Updates; +channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; +channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; +channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; +channels.getMessageAuthor#ece2a0e6 channel:InputChannel id:int = User; +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; +channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool; + +bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; +bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; +bots.setBotMenuButton#4504d54f user_id:InputUser button:BotMenuButton = Bool; +bots.getBotMenuButton#9c60eb28 user_id:InputUser = BotMenuButton; +bots.setBotBroadcastDefaultAdminRights#788464e1 admin_rights:ChatAdminRights = Bool; +bots.setBotGroupDefaultAdminRights#925ec9ea admin_rights:ChatAdminRights = Bool; +bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; +bots.getBotInfo#dcd914fd flags:# bot:flags.0?InputUser lang_code:string = bots.BotInfo; +bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = Bool; +bots.toggleUsername#53ca973 bot:InputUser username:string active:Bool = Bool; +bots.canSendMessage#1359f4e6 bot:InputUser = Bool; +bots.allowSendMessage#f132e3ef bot:InputUser = Updates; +bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; +bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots; +bots.addPreviewMedia#17aeb75a bot:InputUser lang_code:string media:InputMedia = BotPreviewMedia; +bots.editPreviewMedia#8525606f bot:InputUser lang_code:string media:InputMedia new_media:InputMedia = BotPreviewMedia; +bots.deletePreviewMedia#2d0135b3 bot:InputUser lang_code:string media:Vector = Bool; +bots.reorderPreviewMedias#b627f3aa bot:InputUser lang_code:string order:Vector = Bool; +bots.getPreviewInfo#423ab3ad bot:InputUser lang_code:string = bots.PreviewInfo; +bots.getPreviewMedias#a2a5594d bot:InputUser = Vector; +bots.updateUserEmojiStatus#ed9f30c5 user_id:InputUser emoji_status:EmojiStatus = Bool; +bots.toggleUserEmojiStatusPermission#6de6392 bot:InputUser enabled:Bool = Bool; +bots.checkDownloadFileParams#50077589 bot:InputUser file_name:string url:string = Bool; +bots.getAdminedBots#b0711d83 = Vector; +bots.updateStarRefProgram#778b5ab3 flags:# bot:InputUser commission_permille:int duration_months:flags.0?int = StarRefProgram; +bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool; +bots.getBotRecommendations#a1b70815 bot:InputUser = users.Users; +bots.checkUsername#87f2219b username:string = Bool; +bots.createBot#e5b17f2b flags:# via_deeplink:flags.0?true name:string username:string manager_id:InputUser = User; +bots.exportBotToken#bd0d99eb bot:InputUser revoke:Bool = bots.ExportedBotToken; +bots.requestWebViewButton#31a2a35e user_id:InputUser button:KeyboardButton = bots.RequestedButton; +bots.getRequestedWebViewButton#bf25b7f3 bot:InputUser webapp_req_id:string = KeyboardButton; + +payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; +payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; +payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; +payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; +payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; +payments.getBankCardData#2e79d779 number:string = payments.BankCardData; +payments.exportInvoice#f91b065 invoice_media:InputMedia = payments.ExportedInvoice; +payments.assignAppStoreTransaction#80ed747d receipt:bytes purpose:InputStorePaymentPurpose = Updates; +payments.assignPlayMarketTransaction#dffd50d3 receipt:DataJSON purpose:InputStorePaymentPurpose = Updates; +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; +payments.applyGiftCode#f6e26854 slug:string = Updates; +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; +payments.getStarsTopupOptions#c00ec7d3 = Vector; +payments.getStarsStatus#4ea9b3bf flags:# ton:flags.0?true peer:InputPeer = payments.StarsStatus; +payments.getStarsTransactions#69da4557 flags:# inbound:flags.0?true outbound:flags.1?true ascending:flags.2?true ton:flags.4?true subscription_id:flags.3?string peer:InputPeer offset:string limit:int = payments.StarsStatus; +payments.sendStarsForm#7998c914 form_id:long invoice:InputInvoice = payments.PaymentResult; +payments.refundStarsCharge#25ae8f4a user_id:InputUser charge_id:string = Updates; +payments.getStarsRevenueStats#d91ffad6 flags:# dark:flags.0?true ton:flags.1?true peer:InputPeer = payments.StarsRevenueStats; +payments.getStarsRevenueWithdrawalUrl#2433dc92 flags:# ton:flags.0?true peer:InputPeer amount:flags.1?long password:InputCheckPasswordSRP = payments.StarsRevenueWithdrawalUrl; +payments.getStarsRevenueAdsAccountUrl#d1d7efc5 peer:InputPeer = payments.StarsRevenueAdsAccountUrl; +payments.getStarsTransactionsByID#2dca16b8 flags:# ton:flags.0?true peer:InputPeer id:Vector = payments.StarsStatus; +payments.getStarsGiftOptions#d3c96bc8 flags:# user_id:flags.0?InputUser = Vector; +payments.getStarsSubscriptions#32512c5 flags:# missing_balance:flags.0?true peer:InputPeer offset:string = payments.StarsStatus; +payments.changeStarsSubscription#c7770878 flags:# peer:InputPeer subscription_id:string canceled:flags.0?Bool = Bool; +payments.fulfillStarsSubscription#cc5bebb3 peer:InputPeer subscription_id:string = Bool; +payments.getStarsGiveawayOptions#bd1efd3e = Vector; +payments.getStarGifts#c4563590 hash:int = payments.StarGifts; +payments.saveStarGift#2a2a697c flags:# unsave:flags.0?true stargift:InputSavedStarGift = Bool; +payments.convertStarGift#74bf076b stargift:InputSavedStarGift = Bool; +payments.botCancelStarsSubscription#6dfa0622 flags:# restore:flags.0?true user_id:InputUser charge_id:string = Bool; +payments.getConnectedStarRefBots#5869a553 flags:# peer:InputPeer offset_date:flags.2?int offset_link:flags.2?string limit:int = payments.ConnectedStarRefBots; +payments.getConnectedStarRefBot#b7d998f0 peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.getSuggestedStarRefBots#d6b48f7 flags:# order_by_revenue:flags.0?true order_by_date:flags.1?true peer:InputPeer offset:string limit:int = payments.SuggestedStarRefBots; +payments.connectStarRefBot#7ed5348a peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.editConnectedStarRefBot#e4fca4a3 flags:# revoked:flags.0?true peer:InputPeer link:string = payments.ConnectedStarRefBots; +payments.getStarGiftUpgradePreview#9c9abcb1 gift_id:long = payments.StarGiftUpgradePreview; +payments.upgradeStarGift#aed6e4f5 flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = Updates; +payments.transferStarGift#7f18176a stargift:InputSavedStarGift to_id:InputPeer = Updates; +payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift; +payments.getSavedStarGifts#a319e569 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_unique:flags.4?true sort_by_value:flags.5?true exclude_upgradable:flags.7?true exclude_unupgradable:flags.8?true peer_color_available:flags.9?true exclude_hosted:flags.10?true peer:InputPeer collection_id:flags.6?int offset:string limit:int = payments.SavedStarGifts; +payments.getSavedStarGift#b455a106 stargift:Vector = payments.SavedStarGifts; +payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl; +payments.toggleChatStarGiftNotifications#60eaefa1 flags:# enabled:flags.0?true peer:InputPeer = Bool; +payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = Bool; +payments.canPurchaseStore#4fdc5ea7 purpose:InputStorePaymentPurpose = Bool; +payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true for_craft:flags.4?true stars_only:flags.5?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector offset:string limit:int = payments.ResaleStarGifts; +payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates; +payments.createStarGiftCollection#1f4a0e87 peer:InputPeer title:string stargift:Vector = StarGiftCollection; +payments.updateStarGiftCollection#4fddbee7 flags:# peer:InputPeer collection_id:int title:flags.0?string delete_stargift:flags.1?Vector add_stargift:flags.2?Vector order:flags.3?Vector = StarGiftCollection; +payments.reorderStarGiftCollections#c32af4cc peer:InputPeer order:Vector = Bool; +payments.deleteStarGiftCollection#ad5648e8 peer:InputPeer collection_id:int = Bool; +payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections; +payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGiftValueInfo; +payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; +payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; +payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; +payments.sendStarGiftOffer#8fb86b41 flags:# peer:InputPeer slug:string price:StarsAmount duration:int random_id:long allow_paid_stars:flags.0?long = Updates; +payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; +payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts; +payments.craftStarGift#b0f9684f stargift:Vector = Updates; + +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true emojis:flags.5?true text_color:flags.6?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; +stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; +stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; +stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; +stickers.setStickerSetThumb#a76a5392 flags:# stickerset:InputStickerSet thumb:flags.0?InputDocument thumb_document_id:flags.1?long = messages.StickerSet; +stickers.checkShortName#284b3639 short_name:string = Bool; +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; +stickers.changeSticker#f5537ebc flags:# sticker:InputDocument emoji:flags.0?string mask_coords:flags.1?MaskCoords keywords:flags.2?string = messages.StickerSet; +stickers.renameStickerSet#124b1c00 stickerset:InputStickerSet title:string = messages.StickerSet; +stickers.deleteStickerSet#87704394 stickerset:InputStickerSet = Bool; +stickers.replaceSticker#4696459a sticker:InputDocument new_sticker:InputStickerSetItem = messages.StickerSet; + +phone.getCallConfig#55451fa9 = DataJSON; +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.toggleGroupCallSettings#974392f2 flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool messages_enabled:flags.2?Bool send_paid_messages_stars:flags.3?long = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; +phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; +phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; +phone.getGroupCallStreamRtmpUrl#5af4c73a flags:# live_story:flags.0?true peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; +phone.saveCallLog#41248786 peer:InputPhoneCall file:InputFile = Bool; +phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates; +phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector block:bytes = Updates; +phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates; +phone.inviteConferenceCallParticipant#bcf22685 flags:# video:flags.0?true call:InputGroupCall user_id:InputUser = Updates; +phone.declineConferenceCallInvite#3c479971 msg_id:int = Updates; +phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates; +phone.sendGroupCallMessage#b1d11410 flags:# call:InputGroupCall random_id:long message:TextWithEntities allow_paid_stars:flags.0?long send_as:flags.1?InputPeer = Updates; +phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool; +phone.deleteGroupCallMessages#f64f54f7 flags:# report_spam:flags.0?true call:InputGroupCall messages:Vector = Updates; +phone.deleteGroupCallParticipantMessages#1dbfeca0 flags:# report_spam:flags.0?true call:InputGroupCall participant:InputPeer = Updates; +phone.getGroupCallStars#6f636302 call:InputGroupCall = phone.GroupCallStars; +phone.saveDefaultSendAs#4167add1 call:InputGroupCall send_as:InputPeer = Bool; + +langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; +langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; +langpack.getDifference#cd984aa5 lang_pack:string lang_code:string from_version:int = LangPackDifference; +langpack.getLanguages#42c6978f lang_pack:string = Vector; +langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLanguage; + +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; + +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; +stats.getMessagePublicForwards#5f150144 channel:InputChannel msg_id:int offset:string limit:int = stats.PublicForwards; +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; +stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats; +stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards; + +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = chatlists.ExportedChatlistInvite; +chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool; +chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector = ExportedChatlistInvite; +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; +chatlists.getChatlistUpdates#89419521 chatlist:InputChatlist = chatlists.ChatlistUpdates; +chatlists.joinChatlistUpdates#e089f8f5 chatlist:InputChatlist peers:Vector = Updates; +chatlists.hideChatlistUpdates#66e486fb chatlist:InputChatlist = Bool; +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates; + +stories.canSendStory#30eb63f0 peer:InputPeer = stories.CanSendStoryCount; +stories.sendStory#8f9e6898 flags:# pinned:flags.2?true noforwards:flags.4?true fwd_modified:flags.7?true peer:InputPeer media:InputMedia media_areas:flags.5?Vector caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long period:flags.3?int fwd_from_id:flags.6?InputPeer fwd_from_story:flags.6?int albums:flags.8?Vector music:flags.9?InputDocument = Updates; +stories.editStory#2c63a72b flags:# peer:InputPeer id:int media:flags.0?InputMedia media_areas:flags.3?Vector caption:flags.1?string entities:flags.1?Vector privacy_rules:flags.2?Vector music:flags.4?InputDocument = Updates; +stories.deleteStories#ae59db5f peer:InputPeer id:Vector = Vector; +stories.togglePinned#9a75a1ef peer:InputPeer id:Vector pinned:Bool = Vector; +stories.getAllStories#eeb0d625 flags:# next:flags.1?true hidden:flags.2?true state:flags.0?string = stories.AllStories; +stories.getPinnedStories#5821a5dc peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesArchive#b4352016 peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesByID#5774ca74 peer:InputPeer id:Vector = stories.Stories; +stories.toggleAllStoriesHidden#7c2557c4 hidden:Bool = Bool; +stories.readStories#a556dac8 peer:InputPeer max_id:int = Vector; +stories.incrementStoryViews#b2028afb peer:InputPeer id:Vector = Bool; +stories.getStoryViewsList#7ed23c57 flags:# just_contacts:flags.0?true reactions_first:flags.2?true forwards_first:flags.3?true peer:InputPeer q:flags.1?string id:int offset:string limit:int = stories.StoryViewsList; +stories.getStoriesViews#28e16cc8 peer:InputPeer id:Vector = stories.StoryViews; +stories.exportStoryLink#7b8def20 peer:InputPeer id:int = ExportedStoryLink; +stories.report#19d8eb45 peer:InputPeer id:Vector option:bytes message:string = ReportResult; +stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?true = Updates; +stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; +stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; +stories.getAllReadPeerStories#9b5ae7f9 = Updates; +stories.getPeerMaxIDs#78499170 id:Vector = Vector; +stories.getChatsToSend#a56a8b60 = messages.Chats; +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.getStoryReactionsList#b9b2881f flags:# forwards_first:flags.2?true peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = stories.StoryReactionsList; +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; +stories.searchPosts#d1810907 flags:# hashtag:flags.0?string area:flags.1?MediaArea peer:flags.2?InputPeer offset:string limit:int = stories.FoundStories; +stories.createAlbum#a36396e5 peer:InputPeer title:string stories:Vector = StoryAlbum; +stories.updateAlbum#5e5259b6 flags:# peer:InputPeer album_id:int title:flags.0?string delete_stories:flags.1?Vector add_stories:flags.2?Vector order:flags.3?Vector = StoryAlbum; +stories.reorderAlbums#8535fbd9 peer:InputPeer order:Vector = Bool; +stories.deleteAlbum#8d3456d0 peer:InputPeer album_id:int = Bool; +stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums; +stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories; +stories.startLive#d069ccde flags:# pinned:flags.2?true noforwards:flags.4?true rtmp_stream:flags.5?true peer:InputPeer caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long messages_enabled:flags.6?Bool send_paid_messages_stars:flags.7?long = Updates; + +premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; +premium.getMyBoosts#be77b4a = premium.MyBoosts; +premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; +premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus; +premium.getUserBoosts#39854d1f peer:InputPeer user_id:InputUser = premium.BoostsList; + +smsjobs.isEligibleToJoin#edc39d0 = smsjobs.EligibilityToJoin; +smsjobs.join#a74ece2d = Bool; +smsjobs.leave#9898ad73 = Bool; +smsjobs.updateSettings#93fa0bf flags:# allow_international:flags.0?true = Bool; +smsjobs.getStatus#10a698e8 = smsjobs.Status; +smsjobs.getSmsJob#778d902f job_id:string = SmsJob; +smsjobs.finishJob#4f1ebf24 flags:# job_id:string error:flags.0?string = Bool; + +fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo; + +// LAYER 224 diff --git a/internal/compat/layerwire/_schema/layer-225.tl b/internal/compat/layerwire/_schema/layer-225.tl new file mode 100644 index 00000000..24720c36 --- /dev/null +++ b/internal/compat/layerwire/_schema/layer-225.tl @@ -0,0 +1,2977 @@ +boolFalse#bc799737 = Bool; +boolTrue#997275b5 = Bool; + +true#3fedd339 = True; + +vector#1cb5c415 {t:Type} # [ t ] = Vector t; + +error#c4b9f9bb code:int text:string = Error; + +null#56730bcc = Null; + +inputPeerEmpty#7f3b18ea = InputPeer; +inputPeerSelf#7da07ec9 = InputPeer; +inputPeerChat#35a95cb9 chat_id:long = InputPeer; +inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer; +inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer; +inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer; +inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer; + +inputUserEmpty#b98886cf = InputUser; +inputUserSelf#f7c1b13f = InputUser; +inputUser#f21158c6 user_id:long access_hash:long = InputUser; +inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser; + +inputPhoneContact#6a1dc4be flags:# client_id:long phone:string first_name:string last_name:string note:flags.0?TextWithEntities = InputContact; + +inputFile#f52ff27f id:long parts:int name:string md5_checksum:string = InputFile; +inputFileBig#fa4f0bb5 id:long parts:int name:string = InputFile; +inputFileStoryDocument#62dc8b48 id:InputDocument = InputFile; + +inputMediaEmpty#9664f57f = InputMedia; +inputMediaUploadedPhoto#7d8375da flags:# spoiler:flags.2?true live_photo:flags.3?true file:InputFile stickers:flags.0?Vector ttl_seconds:flags.1?int video:flags.3?InputDocument = InputMedia; +inputMediaPhoto#e3af4434 flags:# spoiler:flags.1?true live_photo:flags.2?true id:InputPhoto ttl_seconds:flags.0?int video:flags.2?InputDocument = InputMedia; +inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia; +inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia; +inputMediaUploadedDocument#37c9330 flags:# nosound_video:flags.3?true force_file:flags.4?true spoiler:flags.5?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector video_cover:flags.6?InputPhoto video_timestamp:flags.7?int ttl_seconds:flags.1?int = InputMedia; +inputMediaDocument#a8763ab5 flags:# spoiler:flags.2?true id:InputDocument video_cover:flags.3?InputPhoto video_timestamp:flags.4?int ttl_seconds:flags.0?int query:flags.1?string = InputMedia; +inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia; +inputMediaPhotoExternal#e5bbfe1a flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int = InputMedia; +inputMediaDocumentExternal#779600f9 flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int video_cover:flags.2?InputPhoto video_timestamp:flags.3?int = InputMedia; +inputMediaGame#d33f43f3 id:InputGame = InputMedia; +inputMediaInvoice#405fef0d flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:flags.3?string provider_data:DataJSON start_param:flags.1?string extended_media:flags.2?InputMedia = InputMedia; +inputMediaGeoLive#971fa843 flags:# stopped:flags.0?true geo_point:InputGeoPoint heading:flags.2?int period:flags.1?int proximity_notification_radius:flags.3?int = InputMedia; +inputMediaPoll#883a4108 flags:# poll:Poll correct_answers:flags.0?Vector attached_media:flags.3?InputMedia solution:flags.1?string solution_entities:flags.1?Vector solution_media:flags.2?InputMedia = InputMedia; +inputMediaDice#e66fbf7b emoticon:string = InputMedia; +inputMediaStory#89fdd778 peer:InputPeer id:int = InputMedia; +inputMediaWebPage#c21b8849 flags:# force_large_media:flags.0?true force_small_media:flags.1?true optional:flags.2?true url:string = InputMedia; +inputMediaPaidMedia#c4103386 flags:# stars_amount:long extended_media:Vector payload:flags.0?string = InputMedia; +inputMediaTodo#9fc55fde todo:TodoList = InputMedia; +inputMediaStakeDice#f3a9244a game_hash:string ton_amount:long client_seed:bytes = InputMedia; + +inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; +inputChatUploadedPhoto#bdcdaec0 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.3?VideoSize = InputChatPhoto; +inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; + +inputGeoPointEmpty#e4c123d6 = InputGeoPoint; +inputGeoPoint#48222faf flags:# lat:double long:double accuracy_radius:flags.0?int = InputGeoPoint; + +inputPhotoEmpty#1cd7bf0d = InputPhoto; +inputPhoto#3bb3b94a id:long access_hash:long file_reference:bytes = InputPhoto; + +inputFileLocation#dfdaabe1 volume_id:long local_id:int secret:long file_reference:bytes = InputFileLocation; +inputEncryptedFileLocation#f5235d55 id:long access_hash:long = InputFileLocation; +inputDocumentFileLocation#bad07584 id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputSecureFileLocation#cbc7ee28 id:long access_hash:long = InputFileLocation; +inputTakeoutFileLocation#29be5899 = InputFileLocation; +inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation; +inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation; +inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation; +inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation; + +peerUser#59511722 user_id:long = Peer; +peerChat#36c6019a chat_id:long = Peer; +peerChannel#a2a5371e channel_id:long = Peer; + +storage.fileUnknown#aa963b05 = storage.FileType; +storage.filePartial#40bc6f52 = storage.FileType; +storage.fileJpeg#7efe0e = storage.FileType; +storage.fileGif#cae1aadf = storage.FileType; +storage.filePng#a4f63c0 = storage.FileType; +storage.filePdf#ae1e508d = storage.FileType; +storage.fileMp3#528a0677 = storage.FileType; +storage.fileMov#4b09ebbc = storage.FileType; +storage.fileMp4#b3cea0e4 = storage.FileType; +storage.fileWebp#1081464c = storage.FileType; + +userEmpty#d3bc4b7a id:long = User; +user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true bot_forum_can_manage_topics:flags2.17?true bot_can_manage_bots:flags2.18?true bot_guestchat:flags2.19?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User; + +userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; +userProfilePhoto#82d1f706 flags:# has_video:flags.0?true personal:flags.2?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto; + +userStatusEmpty#9d05049 = UserStatus; +userStatusOnline#edb93949 expires:int = UserStatus; +userStatusOffline#8c703f was_online:int = UserStatus; +userStatusRecently#7b197dc8 flags:# by_me:flags.0?true = UserStatus; +userStatusLastWeek#541a1d1a flags:# by_me:flags.0?true = UserStatus; +userStatusLastMonth#65899777 flags:# by_me:flags.0?true = UserStatus; + +chatEmpty#29562865 id:long = Chat; +chat#41cbf256 flags:# creator:flags.0?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; +chatForbidden#6592a1a7 id:long title:string = Chat; +channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat; +channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true monoforum:flags.10?true id:long access_hash:long title:string until_date:flags.16?int = Chat; + +chatFull#2633421b flags:# can_set_username:flags.7?true has_scheduled:flags.8?true translations_disabled:flags.19?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions reactions_limit:flags.20?int = ChatFull; +channelFull#e4e0b29d flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true participants_hidden:flags2.2?true translations_disabled:flags2.3?true stories_pinned_available:flags2.5?true view_forum_as_messages:flags2.6?true restricted_sponsored:flags2.11?true can_view_revenue:flags2.12?true paid_media_allowed:flags2.14?true can_view_stars_revenue:flags2.15?true paid_reactions_available:flags2.16?true stargifts_available:flags2.19?true paid_messages_available:flags2.20?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions reactions_limit:flags2.13?int stories:flags2.4?PeerStories wallpaper:flags2.7?WallPaper boosts_applied:flags2.8?int boosts_unrestrict:flags2.9?int emojiset:flags2.10?StickerSet bot_verification:flags2.17?BotVerification stargifts_count:flags2.18?int send_paid_messages_stars:flags2.21?long main_tab:flags2.22?ProfileTab = ChatFull; + +chatParticipant#38e79fde flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; +chatParticipantCreator#e1f867b8 flags:# user_id:long rank:flags.0?string = ChatParticipant; +chatParticipantAdmin#360d5d2 flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; + +chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants; +chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants; + +chatPhotoEmpty#37c1011c = ChatPhoto; +chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; + +messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; +message#95ef6f2b flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true invert_media:flags.27?true flags2:# offline:flags2.1?true video_processing_pending:flags2.4?true paid_suggested_post_stars:flags2.8?true paid_suggested_post_ton:flags2.9?true id:int from_id:flags.8?Peer from_boosts_applied:flags.29?int from_rank:flags2.12?string peer_id:Peer saved_peer_id:flags.28?Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long via_business_bot_id:flags2.0?long guestchat_via_from:flags2.19?Peer reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int quick_reply_shortcut_id:flags.30?int effect:flags2.2?long factcheck:flags2.3?FactCheck report_delivery_until_date:flags2.5?int paid_message_stars:flags2.6?long suggested_post:flags2.7?SuggestedPost schedule_repeat_period:flags2.10?int summary_from_language:flags2.11?string = Message; +messageService#7a800e0a flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true reactions_are_possible:flags.9?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer saved_peer_id:flags.28?Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction reactions:flags.20?MessageReactions ttl_period:flags.25?int = Message; + +messageMediaEmpty#3ded6320 = MessageMedia; +messageMediaPhoto#e216eb63 flags:# spoiler:flags.3?true live_photo:flags.4?true photo:flags.0?Photo ttl_seconds:flags.2?int video:flags.4?Document = MessageMedia; +messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia; +messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia; +messageMediaUnsupported#9f84f49e = MessageMedia; +messageMediaDocument#52d8ccd9 flags:# nopremium:flags.3?true spoiler:flags.4?true video:flags.6?true round:flags.7?true voice:flags.8?true document:flags.0?Document alt_documents:flags.5?Vector video_cover:flags.9?Photo video_timestamp:flags.10?int ttl_seconds:flags.2?int = MessageMedia; +messageMediaWebPage#ddf10c3b flags:# force_large_media:flags.0?true force_small_media:flags.1?true manual:flags.3?true safe:flags.4?true webpage:WebPage = MessageMedia; +messageMediaVenue#2ec0533f geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MessageMedia; +messageMediaGame#fdb19008 game:Game = MessageMedia; +messageMediaInvoice#f6a548d3 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string extended_media:flags.4?MessageExtendedMedia = MessageMedia; +messageMediaGeoLive#b940c666 flags:# geo:GeoPoint heading:flags.0?int period:int proximity_notification_radius:flags.1?int = MessageMedia; +messageMediaPoll#773f4e66 flags:# poll:Poll results:PollResults attached_media:flags.0?MessageMedia = MessageMedia; +messageMediaDice#8cbec07 flags:# value:int emoticon:string game_outcome:flags.0?messages.EmojiGameOutcome = MessageMedia; +messageMediaStory#68cb6283 flags:# via_mention:flags.1?true peer:Peer id:int story:flags.0?StoryItem = MessageMedia; +messageMediaGiveaway#aa073beb flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.2?true channels:Vector countries_iso2:flags.1?Vector prize_description:flags.3?string quantity:int months:flags.4?int stars:flags.5?long until_date:int = MessageMedia; +messageMediaGiveawayResults#ceaa3ea1 flags:# only_new_subscribers:flags.0?true refunded:flags.2?true channel_id:long additional_peers_count:flags.3?int launch_msg_id:int winners_count:int unclaimed_count:int winners:Vector months:flags.4?int stars:flags.5?long prize_description:flags.1?string until_date:int = MessageMedia; +messageMediaPaidMedia#a8852491 stars_amount:long extended_media:Vector = MessageMedia; +messageMediaToDo#8a53b014 flags:# todo:TodoList completions:flags.0?Vector = MessageMedia; +messageMediaVideoStream#ca5cab89 flags:# rtmp_stream:flags.0?true call:InputGroupCall = MessageMedia; + +messageActionEmpty#b6aef7b0 = MessageAction; +messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction; +messageActionChatEditTitle#b5a1ce5a title:string = MessageAction; +messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction; +messageActionChatDeletePhoto#95e3fbef = MessageAction; +messageActionChatAddUser#15cefd00 users:Vector = MessageAction; +messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction; +messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction; +messageActionChannelCreate#95d2ac92 title:string = MessageAction; +messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction; +messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction; +messageActionPinMessage#94bd38ed = MessageAction; +messageActionHistoryClear#9fbab604 = MessageAction; +messageActionGameScore#92a72876 game_id:long score:int = MessageAction; +messageActionPaymentSentMe#ffa00ccc flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string charge:PaymentCharge subscription_until_date:flags.4?int = MessageAction; +messageActionPaymentSent#c624b16e flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long invoice_slug:flags.0?string subscription_until_date:flags.4?int = MessageAction; +messageActionPhoneCall#80e11a7f flags:# video:flags.2?true call_id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = MessageAction; +messageActionScreenshotTaken#4792929b = MessageAction; +messageActionCustomAction#fae69f56 message:string = MessageAction; +messageActionBotAllowed#c516d679 flags:# attach_menu:flags.1?true from_request:flags.3?true domain:flags.0?string app:flags.2?BotApp = MessageAction; +messageActionSecureValuesSentMe#1b287353 values:Vector credentials:SecureCredentialsEncrypted = MessageAction; +messageActionSecureValuesSent#d95c6154 types:Vector = MessageAction; +messageActionContactSignUp#f3f25f76 = MessageAction; +messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; +messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; +messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; +messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; +messageActionSetChatTheme#b91bbd3a theme:ChatTheme = MessageAction; +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; +messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; +messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; +messageActionGiftPremium#48e91302 flags:# currency:string amount:long days:int crypto_currency:flags.0?string crypto_amount:flags.0?long message:flags.1?TextWithEntities = MessageAction; +messageActionTopicCreate#d999256 flags:# title_missing:flags.1?true title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; +messageActionSuggestProfilePhoto#57de635e photo:Photo = MessageAction; +messageActionRequestedPeer#31518e9b button_id:int peers:Vector = MessageAction; +messageActionSetChatWallPaper#5060a3f4 flags:# same:flags.0?true for_both:flags.1?true wallpaper:WallPaper = MessageAction; +messageActionGiftCode#31c48347 flags:# via_giveaway:flags.0?true unclaimed:flags.5?true boost_peer:flags.1?Peer days:int slug:string currency:flags.2?string amount:flags.2?long crypto_currency:flags.3?string crypto_amount:flags.3?long message:flags.4?TextWithEntities = MessageAction; +messageActionGiveawayLaunch#a80f51e4 flags:# stars:flags.0?long = MessageAction; +messageActionGiveawayResults#87e2f155 flags:# stars:flags.0?true winners_count:int unclaimed_count:int = MessageAction; +messageActionBoostApply#cc02aa6d boosts:int = MessageAction; +messageActionRequestedPeerSentMe#93b31848 button_id:int peers:Vector = MessageAction; +messageActionPaymentRefunded#41b3e202 flags:# peer:Peer currency:string total_amount:long payload:flags.0?bytes charge:PaymentCharge = MessageAction; +messageActionGiftStars#45d5b021 flags:# currency:string amount:long stars:long crypto_currency:flags.0?string crypto_amount:flags.0?long transaction_id:flags.1?string = MessageAction; +messageActionPrizeStars#b00c47a2 flags:# unclaimed:flags.0?true stars:long transaction_id:string boost_peer:Peer giveaway_msg_id:int = MessageAction; +messageActionStarGift#ea2c31d3 flags:# name_hidden:flags.0?true saved:flags.2?true converted:flags.3?true upgraded:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true prepaid_upgrade:flags.13?true upgrade_separate:flags.16?true auction_acquired:flags.17?true gift:StarGift message:flags.1?TextWithEntities convert_stars:flags.4?long upgrade_msg_id:flags.5?int upgrade_stars:flags.8?long from_id:flags.11?Peer peer:flags.12?Peer saved_id:flags.12?long prepaid_upgrade_hash:flags.14?string gift_msg_id:flags.15?int to_id:flags.18?Peer gift_num:flags.19?int = MessageAction; +messageActionStarGiftUnique#e6c31522 flags:# upgrade:flags.0?true transferred:flags.1?true saved:flags.2?true refunded:flags.5?true prepaid_upgrade:flags.11?true assigned:flags.13?true from_offer:flags.14?true craft:flags.16?true gift:StarGift can_export_at:flags.3?int transfer_stars:flags.4?long from_id:flags.6?Peer peer:flags.7?Peer saved_id:flags.7?long resale_amount:flags.8?StarsAmount can_transfer_at:flags.9?int can_resell_at:flags.10?int drop_original_details_stars:flags.12?long can_craft_at:flags.15?int = MessageAction; +messageActionPaidMessagesRefunded#ac1f1fcd count:int stars:long = MessageAction; +messageActionPaidMessagesPrice#84b88578 flags:# broadcast_messages_allowed:flags.0?true stars:long = MessageAction; +messageActionConferenceCall#2ffe2f7a flags:# missed:flags.0?true active:flags.1?true video:flags.4?true call_id:long duration:flags.2?int other_participants:flags.3?Vector = MessageAction; +messageActionTodoCompletions#cc7c5c89 completed:Vector incompleted:Vector = MessageAction; +messageActionTodoAppendTasks#c7edbc83 list:Vector = MessageAction; +messageActionSuggestedPostApproval#ee7a1596 flags:# rejected:flags.0?true balance_too_low:flags.1?true reject_comment:flags.2?string schedule_date:flags.3?int price:flags.4?StarsAmount = MessageAction; +messageActionSuggestedPostSuccess#95ddcf69 price:StarsAmount = MessageAction; +messageActionSuggestedPostRefund#69f916f8 flags:# payer_initiated:flags.0?true = MessageAction; +messageActionGiftTon#a8a3c699 flags:# currency:string amount:long crypto_currency:string crypto_amount:long transaction_id:flags.0?string = MessageAction; +messageActionSuggestBirthday#2c8f2a25 birthday:Birthday = MessageAction; +messageActionStarGiftPurchaseOffer#774278d4 flags:# accepted:flags.0?true declined:flags.1?true gift:StarGift price:StarsAmount expires_at:int = MessageAction; +messageActionStarGiftPurchaseOfferDeclined#73ada76b flags:# expired:flags.0?true gift:StarGift price:StarsAmount = MessageAction; +messageActionNewCreatorPending#b07ed085 new_creator_id:long = MessageAction; +messageActionChangeCreator#e188503b new_creator_id:long = MessageAction; +messageActionNoForwardsToggle#bf7d6572 prev_value:Bool new_value:Bool = MessageAction; +messageActionNoForwardsRequest#3e2793ba flags:# expired:flags.0?true prev_value:Bool new_value:Bool = MessageAction; +messageActionPollAppendAnswer#9da1cd6c answer:PollAnswer = MessageAction; +messageActionPollDeleteAnswer#399674dc answer:PollAnswer = MessageAction; +messageActionManagedBotCreated#16605e3e bot_id:long = MessageAction; + +dialog#fc89f7f3 flags:# pinned:flags.2?true unread_mark:flags.3?true view_forum_as_messages:flags.6?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; +dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; + +photoEmpty#2331b22d id:long = Photo; +photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; + +photoSizeEmpty#e17e23c type:string = PhotoSize; +photoSize#75c78e60 type:string w:int h:int size:int = PhotoSize; +photoCachedSize#21e1ad6 type:string w:int h:int bytes:bytes = PhotoSize; +photoStrippedSize#e0b0bc2e type:string bytes:bytes = PhotoSize; +photoSizeProgressive#fa3efb95 type:string w:int h:int sizes:Vector = PhotoSize; +photoPathSize#d8214d41 type:string bytes:bytes = PhotoSize; + +geoPointEmpty#1117dd5f = GeoPoint; +geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint; + +auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; +auth.sentCodeSuccess#2390fe44 authorization:auth.Authorization = auth.SentCode; +auth.sentCodePaymentRequired#e0955a3c store_product:string phone_code_hash:string support_email_address:string support_email_subject:string currency:string amount:long = auth.SentCode; + +auth.authorization#2ea2c0d4 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int future_auth_token:flags.2?bytes user:User = auth.Authorization; +auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; + +auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; + +inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer; +inputNotifyUsers#193b4417 = InputNotifyPeer; +inputNotifyChats#4a95e84e = InputNotifyPeer; +inputNotifyBroadcasts#b1db7c7e = InputNotifyPeer; +inputNotifyForumTopic#5c467992 peer:InputPeer top_msg_id:int = InputNotifyPeer; + +inputPeerNotifySettings#cacb6ae2 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_sound:flags.8?NotificationSound = InputPeerNotifySettings; + +peerNotifySettings#99622c0c flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int ios_sound:flags.3?NotificationSound android_sound:flags.4?NotificationSound other_sound:flags.5?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_ios_sound:flags.8?NotificationSound stories_android_sound:flags.9?NotificationSound stories_other_sound:flags.10?NotificationSound = PeerNotifySettings; + +peerSettings#f47741f7 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true business_bot_paused:flags.11?true business_bot_can_reply:flags.12?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int business_bot_id:flags.13?long business_bot_manage_url:flags.13?string charge_paid_message_stars:flags.14?long registration_month:flags.15?string phone_country:flags.16?string name_change_date:flags.17?int photo_change_date:flags.18?int = PeerSettings; + +wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; +wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; + +inputReportReasonSpam#58dbcab8 = ReportReason; +inputReportReasonViolence#1e22c78d = ReportReason; +inputReportReasonPornography#2e59d922 = ReportReason; +inputReportReasonChildAbuse#adf44ee3 = ReportReason; +inputReportReasonOther#c1e4a2b1 = ReportReason; +inputReportReasonCopyright#9b89f93a = ReportReason; +inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; +inputReportReasonFake#f5ddd6e7 = ReportReason; +inputReportReasonIllegalDrugs#a8eb2be = ReportReason; +inputReportReasonPersonalDetails#9ec7863d = ReportReason; + +userFull#6cbe645 flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true voice_messages_forbidden:flags.20?true translations_disabled:flags.23?true stories_pinned_available:flags.26?true blocked_my_stories_from:flags.27?true wallpaper_overridden:flags.28?true contact_require_premium:flags.29?true read_dates_private:flags.30?true flags2:# sponsored_enabled:flags2.7?true can_view_revenue:flags2.9?true bot_can_manage_emoji_status:flags2.10?true display_gifts_button:flags2.16?true noforwards_my_enabled:flags2.23?true noforwards_peer_enabled:flags2.24?true unofficial_security_risk:flags2.26?true id:long about:flags.1?string settings:PeerSettings personal_photo:flags.21?Photo profile_photo:flags.2?Photo fallback_photo:flags.22?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme:flags.15?ChatTheme private_forward_name:flags.16?string bot_group_admin_rights:flags.17?ChatAdminRights bot_broadcast_admin_rights:flags.18?ChatAdminRights wallpaper:flags.24?WallPaper stories:flags.25?PeerStories business_work_hours:flags2.0?BusinessWorkHours business_location:flags2.1?BusinessLocation business_greeting_message:flags2.2?BusinessGreetingMessage business_away_message:flags2.3?BusinessAwayMessage business_intro:flags2.4?BusinessIntro birthday:flags2.5?Birthday personal_channel_id:flags2.6?long personal_channel_message:flags2.6?int stargifts_count:flags2.8?int starref_program:flags2.11?StarRefProgram bot_verification:flags2.12?BotVerification send_paid_messages_stars:flags2.14?long disallowed_gifts:flags2.15?DisallowedGiftsSettings stars_rating:flags2.17?StarsRating stars_my_pending_rating:flags2.18?StarsRating stars_my_pending_rating_date:flags2.18?int main_tab:flags2.20?ProfileTab saved_music:flags2.21?Document note:flags2.22?TextWithEntities bot_manager_id:flags2.25?long = UserFull; + +contact#145ade0b user_id:long mutual:Bool = Contact; + +importedContact#c13e3c50 user_id:long client_id:long = ImportedContact; + +contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus; + +contacts.contactsNotModified#b74ba9d2 = contacts.Contacts; +contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts; + +contacts.importedContacts#77d01c3b imported:Vector popular_invites:Vector retry_contacts:Vector users:Vector = contacts.ImportedContacts; + +contacts.blocked#ade1591 blocked:Vector chats:Vector users:Vector = contacts.Blocked; +contacts.blockedSlice#e1664194 count:int blocked:Vector chats:Vector users:Vector = contacts.Blocked; + +messages.dialogs#15ba6c40 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsNotModified#f0e3e596 count:int = messages.Dialogs; + +messages.messages#1d73e7ea messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesSlice#5f206716 flags:# inexact:flags.1?true count:int next_rate:flags.0?int offset_id_offset:flags.2?int search_flood:flags.3?SearchPostsFlood messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.channelMessages#c776ba4e flags:# inexact:flags.1?true pts:int count:int offset_id_offset:flags.2?int messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesNotModified#74535f21 count:int = messages.Messages; + +messages.chats#64ff9fd5 chats:Vector = messages.Chats; +messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; + +messages.chatFull#e5d7d19c full_chat:ChatFull chats:Vector users:Vector = messages.ChatFull; + +messages.affectedHistory#b45c69d1 pts:int pts_count:int offset:int = messages.AffectedHistory; + +inputMessagesFilterEmpty#57e2f66c = MessagesFilter; +inputMessagesFilterPhotos#9609a51c = MessagesFilter; +inputMessagesFilterVideo#9fc00e65 = MessagesFilter; +inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; +inputMessagesFilterDocument#9eddf188 = MessagesFilter; +inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; +inputMessagesFilterGif#ffc86587 = MessagesFilter; +inputMessagesFilterVoice#50f5c392 = MessagesFilter; +inputMessagesFilterMusic#3751b49e = MessagesFilter; +inputMessagesFilterChatPhotos#3a20ecb8 = MessagesFilter; +inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFilter; +inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; +inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; +inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; +inputMessagesFilterGeo#e7026d0d = MessagesFilter; +inputMessagesFilterContacts#e062db83 = MessagesFilter; +inputMessagesFilterPinned#1bb00451 = MessagesFilter; +inputMessagesFilterPoll#fa2bc90a = MessagesFilter; + +updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; +updateMessageID#4e90bfd6 id:int random_id:long = Update; +updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; +updateUserTyping#2a17bf5c flags:# user_id:long top_msg_id:flags.0?int action:SendMessageAction = Update; +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; +updateChatParticipants#7761198 participants:ChatParticipants = Update; +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; +updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector = Update; +updateNewAuthorization#8951abef flags:# unconfirmed:flags.0?true hash:long date:flags.0?int device:flags.0?string location:flags.0?string = Update; +updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; +updateEncryptedChatTyping#1710f156 chat_id:int = Update; +updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; +updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; +updateDcOptions#8e5e9873 dc_options:Vector = Update; +updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; +updateServiceNotification#ebe46819 flags:# popup:flags.0?true invert_media:flags.2?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; +updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; +updateUserPhone#5492a13 user_id:long phone:string = Update; +updateReadHistoryInbox#9e84bc99 flags:# folder_id:flags.0?int peer:Peer top_msg_id:flags.1?int max_id:int still_unread_count:int pts:int pts_count:int = Update; +updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; +updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; +updateReadMessagesContents#f8227181 flags:# messages:Vector pts:int pts_count:int date:flags.0?int = Update; +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; +updateChannel#635b4c09 channel_id:long = Update; +updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; +updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; +updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Update; +updateStickerSets#31c24808 flags:# masks:flags.0?true emojis:flags.1?true = Update; +updateSavedGifs#9375341e = Update; +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; +updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; +updateDraftMessage#edfc111e flags:# peer:Peer top_msg_id:flags.0?int saved_peer_id:flags.1?Peer draft:DraftMessage = Update; +updateReadFeaturedStickers#571d2742 = Update; +updateRecentStickers#9a422c20 = Update; +updateConfig#a229dd06 = Update; +updatePtsChanged#3354678f = Update; +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; +updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; +updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; +updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; +updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; +updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; +updateLangPackTooLong#46560264 lang_code:string = Update; +updateLangPack#56022f4d difference:LangPackDifference = Update; +updateFavedStickers#e511996d = Update; +updateChannelReadMessagesContents#25f324f7 flags:# channel_id:long top_msg_id:flags.0?int saved_peer_id:flags.1?Peer messages:Vector = Update; +updateContactsReset#7084a7be = Update; +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; +updateDialogUnreadMark#b658f23e flags:# unread:flags.0?true peer:DialogPeer saved_peer_id:flags.1?Peer = Update; +updateMessagePoll#d64c522b flags:# peer:flags.1?Peer msg_id:flags.1?int top_msg_id:flags.2?int poll_id:long poll:flags.0?Poll results:PollResults = Update; +updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; +updateFolderPeers#19360dc0 folder_peers:Vector pts:int pts_count:int = Update; +updatePeerSettings#6a7e7366 peer:Peer settings:PeerSettings = Update; +updatePeerLocated#b4afcfb0 peers:Vector = Update; +updateNewScheduledMessage#39a51dfb message:Message = Update; +updateDeleteScheduledMessages#f2a71983 flags:# peer:Peer messages:Vector sent_messages:flags.0?Vector = Update; +updateTheme#8216fba3 theme:Theme = Update; +updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; +updateLoginToken#564fe691 = Update; +updateMessagePollVote#7699f014 poll_id:long peer:Peer options:Vector positions:Vector qts:int = Update; +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; +updateDialogFilterOrder#a5d72105 order:Vector = Update; +updateDialogFilters#3504914f = Update; +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; +updatePeerBlocked#ebe07752 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer = Update; +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; +updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChat#f89a6a4e chat_id:long = Update; +updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; +updateGroupCall#9d2216e0 flags:# live_story:flags.2?true peer:flags.1?Peer call:GroupCall = Update; +updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateChannelParticipant#985d3abb flags:# via_chatlist:flags.3?true channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; +updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; +updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#11dfa986 peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int = Update; +updateMessageReactions#1e297bfa flags:# peer:Peer msg_id:int top_msg_id:flags.0?int saved_peer_id:flags.1?Peer reactions:MessageReactions = Update; +updateAttachMenuBots#17b7a20b = Update; +updateWebViewResultSent#1592b79d query_id:long = Update; +updateBotMenuButton#14b85813 bot_id:long button:BotMenuButton = Update; +updateSavedRingtones#74d8be99 = Update; +updateTranscribedAudio#84cd5a flags:# pending:flags.0?true peer:Peer msg_id:int transcription_id:long text:string = Update; +updateReadFeaturedEmojiStickers#fb4c496c = Update; +updateUserEmojiStatus#28373599 user_id:long emoji_status:EmojiStatus = Update; +updateRecentEmojiStatuses#30f443db = Update; +updateRecentReactions#6f7863f4 = Update; +updateMoveStickerSetToTop#86fccf85 flags:# masks:flags.0?true emojis:flags.1?true stickerset:long = Update; +updateMessageExtendedMedia#d5a41724 peer:Peer msg_id:int extended_media:Vector = Update; +updateUser#20529438 user_id:long = Update; +updateAutoSaveSettings#ec05b097 = Update; +updateStory#75b3b798 peer:Peer story:StoryItem = Update; +updateReadStories#f74e932b peer:Peer max_id:int = Update; +updateStoryID#1bf335b9 id:int random_id:long = Update; +updateStoriesStealthMode#2c084dc1 stealth_mode:StoriesStealthMode = Update; +updateSentStoryReaction#7d627683 peer:Peer story_id:int reaction:Reaction = Update; +updateBotChatBoost#904dd49c peer:Peer boost:Boost qts:int = Update; +updateChannelViewForumAsMessages#7b68920 channel_id:long enabled:Bool = Update; +updatePeerWallpaper#ae3f101d flags:# wallpaper_overridden:flags.1?true peer:Peer wallpaper:flags.0?WallPaper = Update; +updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer old_reactions:Vector new_reactions:Vector qts:int = Update; +updateBotMessageReactions#9cb7759 peer:Peer msg_id:int date:int reactions:Vector qts:int = Update; +updateSavedDialogPinned#aeaf9e74 flags:# pinned:flags.0?true peer:DialogPeer = Update; +updatePinnedSavedDialogs#686c85a6 flags:# order:flags.0?Vector = Update; +updateSavedReactionTags#39c67432 = Update; +updateSmsJob#f16269d4 job_id:string = Update; +updateQuickReplies#f9470ab2 quick_replies:Vector = Update; +updateNewQuickReply#f53da717 quick_reply:QuickReply = Update; +updateDeleteQuickReply#53e6f1ec shortcut_id:int = Update; +updateQuickReplyMessage#3e050d0f message:Message = Update; +updateDeleteQuickReplyMessages#566fe7cd shortcut_id:int messages:Vector = Update; +updateBotBusinessConnect#8ae5c97a connection:BotBusinessConnection qts:int = Update; +updateBotNewBusinessMessage#9ddb347c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotEditBusinessMessage#7df587c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotDeleteBusinessMessage#a02a982e connection_id:string peer:Peer messages:Vector qts:int = Update; +updateNewStoryReaction#1824e40b story_id:int peer:Peer reaction:Reaction = Update; +updateStarsBalance#4e80a379 balance:StarsAmount = Update; +updateBusinessBotCallbackQuery#1ea2fda7 flags:# query_id:long user_id:long connection_id:string message:Message reply_to_message:flags.2?Message chat_instance:long data:flags.0?bytes = Update; +updateStarsRevenueStatus#a584b019 peer:Peer status:StarsRevenueStatus = Update; +updateBotPurchasedPaidMedia#283bd312 user_id:long payload:string qts:int = Update; +updatePaidReactionPrivacy#8b725fce private:PaidReactionPrivacy = Update; +updateSentPhoneCode#504aa18f sent_code:auth.SentCode = Update; +updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector next_offset:int = Update; +updateReadMonoForumInbox#77b0e372 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateReadMonoForumOutbox#a4a79376 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateMonoForumNoPaidException#9f812b08 flags:# exception:flags.0?true channel_id:long saved_peer_id:Peer = Update; +updateGroupCallMessage#d8326f0d call:InputGroupCall message:GroupCallMessage = Update; +updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update; +updatePinnedForumTopic#683b2c52 flags:# pinned:flags.0?true peer:Peer topic_id:int = Update; +updatePinnedForumTopics#def143d0 flags:# peer:Peer order:flags.0?Vector = Update; +updateDeleteGroupCallMessages#3e85e92c call:InputGroupCall messages:Vector = Update; +updateStarGiftAuctionState#48e246c2 gift_id:long state:StarGiftAuctionState = Update; +updateStarGiftAuctionUserState#dc58f31e gift_id:long user_state:StarGiftAuctionUserState = Update; +updateEmojiGameInfo#fb9c547a info:messages.EmojiGameInfo = Update; +updateStarGiftCraftFail#ac072444 = Update; +updateChatParticipantRank#bd8367b9 chat_id:long user_id:long rank:string version:int = Update; +updateManagedBot#4880ed9a user_id:long bot_id:long qts:int = Update; +updateBotGuestChatQuery#cdd4093d flags:# query_id:long message:Message reference_messages:flags.0?Vector qts:int = Update; +updateAiComposeTones#8c0f91fb = Update; + +updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; + +updates.differenceEmpty#5d75a138 date:int seq:int = updates.Difference; +updates.difference#f49ca0 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector state:updates.State = updates.Difference; +updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector intermediate_state:updates.State = updates.Difference; +updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; + +updatesTooLong#e317af7e = Updates; +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShort#78d4dec1 update:Update date:int = Updates; +updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; +updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; +updateShortSentMessage#9015e101 flags:# out:flags.1?true id:int pts:int pts_count:int date:int media:flags.9?MessageMedia entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +photos.photos#8dca6aa5 photos:Vector users:Vector = photos.Photos; +photos.photosSlice#15051f54 count:int photos:Vector users:Vector = photos.Photos; + +photos.photo#20212ca8 photo:Photo users:Vector = photos.Photo; + +upload.file#96a18d5 type:storage.FileType mtime:int bytes:bytes = upload.File; +upload.fileCdnRedirect#f18cda44 dc_id:int file_token:bytes encryption_key:bytes encryption_iv:bytes file_hashes:Vector = upload.File; + +dcOption#18b7a10d flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true this_port_only:flags.5?true id:int ip_address:string port:int secret:flags.10?bytes = DcOption; + +config#cc1a241e flags:# default_p2p_contacts:flags.3?true preload_featured_stickers:flags.4?true revoke_pm_inbox:flags.6?true blocked_mode:flags.8?true force_try_ipv6:flags.14?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector dc_txt_domain_name:string chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int push_chat_period_ms:int push_chat_limit:int edit_time_limit:int revoke_time_limit:int revoke_pm_time_limit:int rating_e_decay:int stickers_recent_limit:int channels_read_media_period:int tmp_sessions:flags.0?int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string autoupdate_url_prefix:flags.7?string gif_search_username:flags.9?string venue_search_username:flags.10?string img_search_username:flags.11?string static_maps_provider:flags.12?string caption_length_max:int message_length_max:int webfile_dc_id:int suggested_lang_code:flags.2?string lang_pack_version:flags.2?int base_lang_pack_version:flags.2?int reactions_default:flags.15?Reaction autologin_token:flags.16?string = Config; + +nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; + +help.appUpdate#ccbbce30 flags:# can_not_skip:flags.0?true id:int version:string text:string entities:Vector document:flags.1?Document url:flags.2?string sticker:flags.3?Document = help.AppUpdate; +help.noAppUpdate#c45a6536 = help.AppUpdate; + +help.inviteText#18cb9f78 message:string = help.InviteText; + +encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; +encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; + +inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; + +encryptedFileEmpty#c21f497e = EncryptedFile; +encryptedFile#a8008cd8 id:long access_hash:long size:long dc_id:int key_fingerprint:int = EncryptedFile; + +inputEncryptedFileEmpty#1837c364 = InputEncryptedFile; +inputEncryptedFileUploaded#64bd0306 id:long parts:int md5_checksum:string key_fingerprint:int = InputEncryptedFile; +inputEncryptedFile#5a17b5e5 id:long access_hash:long = InputEncryptedFile; +inputEncryptedFileBigUploaded#2dc173c8 id:long parts:int key_fingerprint:int = InputEncryptedFile; + +encryptedMessage#ed18c118 random_id:long chat_id:int date:int bytes:bytes file:EncryptedFile = EncryptedMessage; +encryptedMessageService#23734b06 random_id:long chat_id:int date:int bytes:bytes = EncryptedMessage; + +messages.dhConfigNotModified#c0e24635 random:bytes = messages.DhConfig; +messages.dhConfig#2c221edd g:int p:bytes version:int random:bytes = messages.DhConfig; + +messages.sentEncryptedMessage#560f8935 date:int = messages.SentEncryptedMessage; +messages.sentEncryptedFile#9493ff32 date:int file:EncryptedFile = messages.SentEncryptedMessage; + +inputDocumentEmpty#72f0eaae = InputDocument; +inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; + +documentEmpty#36f8c871 id:long = Document; +document#8fd4c4d8 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:long thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; + +help.support#17c6b5f6 phone_number:string user:User = help.Support; + +notifyPeer#9fd40bd8 peer:Peer = NotifyPeer; +notifyUsers#b4c83b4c = NotifyPeer; +notifyChats#c007cec3 = NotifyPeer; +notifyBroadcasts#d612e8ef = NotifyPeer; +notifyForumTopic#226e6308 peer:Peer top_msg_id:int = NotifyPeer; + +sendMessageTypingAction#16bf744e = SendMessageAction; +sendMessageCancelAction#fd5ec8f5 = SendMessageAction; +sendMessageRecordVideoAction#a187d66f = SendMessageAction; +sendMessageUploadVideoAction#e9763aec progress:int = SendMessageAction; +sendMessageRecordAudioAction#d52f73f7 = SendMessageAction; +sendMessageUploadAudioAction#f351d7ab progress:int = SendMessageAction; +sendMessageUploadPhotoAction#d1d34a26 progress:int = SendMessageAction; +sendMessageUploadDocumentAction#aa0cd9e4 progress:int = SendMessageAction; +sendMessageGeoLocationAction#176f8ba1 = SendMessageAction; +sendMessageChooseContactAction#628cbc6f = SendMessageAction; +sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; +sendMessageRecordRoundAction#88f27fbc = SendMessageAction; +sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; +speakingInGroupCallAction#d92c2285 = SendMessageAction; +sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; +sendMessageTextDraftAction#376d975c random_id:long text:TextWithEntities = SendMessageAction; + +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; + +inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; +inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; +inputPrivacyKeyPhoneCall#fabadc5f = InputPrivacyKey; +inputPrivacyKeyPhoneP2P#db9e70d2 = InputPrivacyKey; +inputPrivacyKeyForwards#a4dd4c08 = InputPrivacyKey; +inputPrivacyKeyProfilePhoto#5719bacc = InputPrivacyKey; +inputPrivacyKeyPhoneNumber#352dafa = InputPrivacyKey; +inputPrivacyKeyAddedByPhone#d1219bdd = InputPrivacyKey; +inputPrivacyKeyVoiceMessages#aee69d68 = InputPrivacyKey; +inputPrivacyKeyAbout#3823cc40 = InputPrivacyKey; +inputPrivacyKeyBirthday#d65a11cc = InputPrivacyKey; +inputPrivacyKeyStarGiftsAutoSave#e1732341 = InputPrivacyKey; +inputPrivacyKeyNoPaidMessages#bdc597b4 = InputPrivacyKey; +inputPrivacyKeySavedMusic#4dbe9226 = InputPrivacyKey; + +privacyKeyStatusTimestamp#bc2eab30 = PrivacyKey; +privacyKeyChatInvite#500e6dfa = PrivacyKey; +privacyKeyPhoneCall#3d662b7b = PrivacyKey; +privacyKeyPhoneP2P#39491cc8 = PrivacyKey; +privacyKeyForwards#69ec56a3 = PrivacyKey; +privacyKeyProfilePhoto#96151fed = PrivacyKey; +privacyKeyPhoneNumber#d19ae46d = PrivacyKey; +privacyKeyAddedByPhone#42ffd42b = PrivacyKey; +privacyKeyVoiceMessages#697f414 = PrivacyKey; +privacyKeyAbout#a486b761 = PrivacyKey; +privacyKeyBirthday#2000a518 = PrivacyKey; +privacyKeyStarGiftsAutoSave#2ca4fdf8 = PrivacyKey; +privacyKeyNoPaidMessages#17d348d2 = PrivacyKey; +privacyKeySavedMusic#ff7a571b = PrivacyKey; + +inputPrivacyValueAllowContacts#d09e07b = InputPrivacyRule; +inputPrivacyValueAllowAll#184b35ce = InputPrivacyRule; +inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; +inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; +inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; +inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; +inputPrivacyValueAllowCloseFriends#2f453e49 = InputPrivacyRule; +inputPrivacyValueAllowPremium#77cdc9f1 = InputPrivacyRule; +inputPrivacyValueAllowBots#5a4fcce5 = InputPrivacyRule; +inputPrivacyValueDisallowBots#c4e57915 = InputPrivacyRule; + +privacyValueAllowContacts#fffe1bac = PrivacyRule; +privacyValueAllowAll#65427b82 = PrivacyRule; +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; +privacyValueDisallowContacts#f888fa1a = PrivacyRule; +privacyValueDisallowAll#8b73e763 = PrivacyRule; +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; +privacyValueAllowCloseFriends#f7e8d89b = PrivacyRule; +privacyValueAllowPremium#ece9814b = PrivacyRule; +privacyValueAllowBots#21461b5d = PrivacyRule; +privacyValueDisallowBots#f6a5f82f = PrivacyRule; + +account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; + +accountDaysTTL#b8d0afdf days:int = AccountDaysTTL; + +documentAttributeImageSize#6c37c15c w:int h:int = DocumentAttribute; +documentAttributeAnimated#11b58939 = DocumentAttribute; +documentAttributeSticker#6319d612 flags:# mask:flags.1?true alt:string stickerset:InputStickerSet mask_coords:flags.0?MaskCoords = DocumentAttribute; +documentAttributeVideo#43c57c48 flags:# round_message:flags.0?true supports_streaming:flags.1?true nosound:flags.3?true duration:double w:int h:int preload_prefix_size:flags.2?int video_start_ts:flags.4?double video_codec:flags.5?string = DocumentAttribute; +documentAttributeAudio#9852f9c6 flags:# voice:flags.10?true duration:int title:flags.0?string performer:flags.1?string waveform:flags.2?bytes = DocumentAttribute; +documentAttributeFilename#15590068 file_name:string = DocumentAttribute; +documentAttributeHasStickers#9801d2f7 = DocumentAttribute; +documentAttributeCustomEmoji#fd149899 flags:# free:flags.0?true text_color:flags.1?true alt:string stickerset:InputStickerSet = DocumentAttribute; + +messages.stickersNotModified#f1749a22 = messages.Stickers; +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; + +stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; + +messages.allStickersNotModified#e86602c3 = messages.AllStickers; +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; + +messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; + +webPageEmpty#211a1788 flags:# id:long url:flags.0?string = WebPage; +webPagePending#b0d13e47 flags:# id:long url:flags.0?string date:int = WebPage; +webPage#e89c45b2 flags:# has_large_media:flags.13?true video_cover_photo:flags.14?true id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; + +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true unconfirmed:flags.5?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; + +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; + +account.password#957b50fb flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int login_email_pattern:flags.6?string = account.Password; + +account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; + +account.passwordInputSettings#c23727c9 flags:# new_algo:flags.0?PasswordKdfAlgo new_password_hash:flags.0?bytes hint:flags.0?string email:flags.1?string new_secure_settings:flags.2?SecureSecretSettings = account.PasswordInputSettings; + +auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; + +receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; + +chatInviteExported#a22cbd96 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int subscription_expired:flags.10?int title:flags.8?string subscription_pricing:flags.9?StarsSubscriptionPricing = ExportedChatInvite; +chatInvitePublicJoinRequests#ed107ab7 = ExportedChatInvite; + +chatInviteAlready#5a686d7c chat:Chat = ChatInvite; +chatInvite#5c9d3702 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true verified:flags.7?true scam:flags.8?true fake:flags.9?true can_refulfill_subscription:flags.11?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector color:int subscription_pricing:flags.10?StarsSubscriptionPricing subscription_form_id:flags.12?long bot_verification:flags.13?BotVerification = ChatInvite; +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; + +inputStickerSetEmpty#ffb62b95 = InputStickerSet; +inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; +inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; +inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; +inputStickerSetPremiumGifts#c88b3b02 = InputStickerSet; +inputStickerSetEmojiGenericAnimations#4c4d4ce = InputStickerSet; +inputStickerSetEmojiDefaultStatuses#29d0f5ee = InputStickerSet; +inputStickerSetEmojiDefaultTopicIcons#44c1f8e9 = InputStickerSet; +inputStickerSetEmojiChannelDefaultStatuses#49748553 = InputStickerSet; +inputStickerSetTonGifts#1cf671a0 = InputStickerSet; + +stickerSet#2dd14edc flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true emojis:flags.7?true text_color:flags.9?true channel_emoji_status:flags.10?true creator:flags.11?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int thumb_document_id:flags.8?long count:int hash:int = StickerSet; + +messages.stickerSet#6e153f16 set:StickerSet packs:Vector keywords:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; + +botCommand#c27ac8c7 command:string description:string = BotCommand; + +botInfo#4d8a0299 flags:# has_preview_medias:flags.6?true user_id:flags.0?long description:flags.1?string description_photo:flags.4?Photo description_document:flags.5?Document commands:flags.2?Vector menu_button:flags.3?BotMenuButton privacy_policy_url:flags.7?string app_settings:flags.8?BotAppSettings verifier_settings:flags.9?BotVerifierSettings = BotInfo; + +keyboardButton#7d170cff flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrl#d80c25ec flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonCallback#e62bc960 flags:# requires_password:flags.0?true style:flags.10?KeyboardButtonStyle text:string data:bytes = KeyboardButton; +keyboardButtonRequestPhone#417efd8f flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonRequestGeoLocation#aa40f94d flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonSwitchInline#991399fc flags:# same_peer:flags.0?true style:flags.10?KeyboardButtonStyle text:string query:string peer_types:flags.1?Vector = KeyboardButton; +keyboardButtonGame#89c590f9 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonBuy#3fa53905 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrlAuth#f51006f9 flags:# style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +inputKeyboardButtonUrlAuth#68013e72 flags:# request_write_access:flags.0?true style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +keyboardButtonRequestPoll#7a11d782 flags:# style:flags.10?KeyboardButtonStyle quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#7d5e07c7 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#c0fd5d09 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:long = KeyboardButton; +keyboardButtonWebView#e846b1a0 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonSimpleWebView#e15c4370 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonRequestPeer#5b0f15f5 flags:# style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +inputKeyboardButtonRequestPeer#2b78156 flags:# name_requested:flags.0?true username_requested:flags.1?true photo_requested:flags.2?true style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +keyboardButtonCopy#bcc4af10 flags:# style:flags.10?KeyboardButtonStyle text:string copy_text:string = KeyboardButton; + +keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; + +replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true persistent:flags.4?true rows:Vector placeholder:flags.3?string = ReplyMarkup; +replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; + +messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; +messageEntityMention#fa04579d offset:int length:int = MessageEntity; +messageEntityHashtag#6f635b0d offset:int length:int = MessageEntity; +messageEntityBotCommand#6cef8ac7 offset:int length:int = MessageEntity; +messageEntityUrl#6ed02538 offset:int length:int = MessageEntity; +messageEntityEmail#64e475c2 offset:int length:int = MessageEntity; +messageEntityBold#bd610bc9 offset:int length:int = MessageEntity; +messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; +messageEntityCode#28a20571 offset:int length:int = MessageEntity; +messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; +messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; +inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; +messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; +messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; +messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; +messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; +messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; +messageEntityCustomEmoji#c8cf05f8 offset:int length:int document_id:long = MessageEntity; +messageEntityBlockquote#f1ccaaac flags:# collapsed:flags.0?true offset:int length:int = MessageEntity; +messageEntityFormattedDate#904ac7c7 flags:# relative:flags.0?true short_time:flags.1?true long_time:flags.2?true short_date:flags.3?true long_date:flags.4?true day_of_week:flags.5?true offset:int length:int date:int = MessageEntity; +messageEntityDiffInsert#71777116 offset:int length:int = MessageEntity; +messageEntityDiffReplace#c6c1e5a7 offset:int length:int old_text:string = MessageEntity; +messageEntityDiffDelete#652c1c5 offset:int length:int = MessageEntity; + +inputChannelEmpty#ee8c1e86 = InputChannel; +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; + +contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; + +messageRange#ae30253 min_id:int max_id:int = MessageRange; + +updates.channelDifferenceEmpty#3e11affb flags:# final:flags.0?true pts:int timeout:flags.1?int = updates.ChannelDifference; +updates.channelDifferenceTooLong#a4bcc6fe flags:# final:flags.0?true timeout:flags.1?int dialog:Dialog messages:Vector chats:Vector users:Vector = updates.ChannelDifference; +updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int new_messages:Vector other_updates:Vector chats:Vector users:Vector = updates.ChannelDifference; + +channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; +channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; + +channelParticipant#1bd54456 flags:# user_id:long date:int subscription_until_date:flags.0?int rank:flags.2?string = ChannelParticipant; +channelParticipantSelf#a9478a1a flags:# via_request:flags.0?true user_id:long inviter_id:long date:int subscription_until_date:flags.1?int rank:flags.2?string = ChannelParticipant; +channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; +channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; +channelParticipantBanned#d5f0ad91 flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights rank:flags.2?string = ChannelParticipant; +channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; + +channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; +channelParticipantsAdmins#b4608969 = ChannelParticipantsFilter; +channelParticipantsKicked#a3b54985 q:string = ChannelParticipantsFilter; +channelParticipantsBots#b0d1865b = ChannelParticipantsFilter; +channelParticipantsBanned#1427a5e1 q:string = ChannelParticipantsFilter; +channelParticipantsSearch#656ac4b q:string = ChannelParticipantsFilter; +channelParticipantsContacts#bb6ae88d q:string = ChannelParticipantsFilter; +channelParticipantsMentions#e04b5ceb flags:# q:flags.0?string top_msg_id:flags.1?int = ChannelParticipantsFilter; + +channels.channelParticipants#9ab0feaf count:int participants:Vector chats:Vector users:Vector = channels.ChannelParticipants; +channels.channelParticipantsNotModified#f0173fe9 = channels.ChannelParticipants; + +channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector users:Vector = channels.ChannelParticipant; + +help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; + +messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; + +inputBotInlineMessageMediaAuto#3380c786 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaGeo#96929a85 flags:# geo_point:InputGeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaVenue#417bbf11 flags:# geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaContact#a6edbffd flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageGame#4b425864 flags:# reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaInvoice#d7e78225 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaWebPage#bddcc510 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true optional:flags.6?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineResult#88bf9319 flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?InputWebDocument content:flags.5?InputWebDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultPhoto#a8d864a7 id:string type:string photo:InputPhoto send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultDocument#fff8fdc4 flags:# id:string type:string title:flags.1?string description:flags.2?string document:InputDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultGame#4fa417f2 id:string short_name:string send_message:InputBotInlineMessage = InputBotInlineResult; + +botInlineMessageMediaAuto#764cf810 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageText#8c7f65e2 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaGeo#51846fd flags:# geo:GeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaVenue#8a86659c flags:# geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaContact#18d1cdc2 flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaInvoice#354a9b09 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument currency:string total_amount:long reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaWebPage#809ad9a6 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true manual:flags.7?true safe:flags.8?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineResult#11965f3a flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?WebDocument content:flags.5?WebDocument send_message:BotInlineMessage = BotInlineResult; +botInlineMediaResult#17db940b flags:# id:string type:string photo:flags.0?Photo document:flags.1?Document title:flags.2?string description:flags.3?string send_message:BotInlineMessage = BotInlineResult; + +messages.botResults#e021f2f6 flags:# gallery:flags.0?true query_id:long next_offset:flags.1?string switch_pm:flags.2?InlineBotSwitchPM switch_webview:flags.3?InlineBotWebView results:Vector cache_time:int users:Vector = messages.BotResults; + +exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; + +messageFwdHeader#4e4df4bb flags:# imported:flags.7?true saved_out:flags.11?true from_id:flags.0?Peer from_name:flags.5?string date:int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int saved_from_id:flags.8?Peer saved_from_name:flags.9?string saved_date:flags.10?int psa_type:flags.6?string = MessageFwdHeader; + +auth.codeTypeSms#72a3158c = auth.CodeType; +auth.codeTypeCall#741cd3e3 = auth.CodeType; +auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; + +auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; +auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; +auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; +auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; +auth.sentCodeTypeEmailCode#f450f59b flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int reset_available_period:flags.3?int reset_pending_date:flags.4?int = auth.SentCodeType; +auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; +auth.sentCodeTypeFirebaseSms#9fd736 flags:# nonce:flags.0?bytes play_integrity_project_id:flags.2?long play_integrity_nonce:flags.2?bytes receipt:flags.1?string push_timeout:flags.1?int length:int = auth.SentCodeType; +auth.sentCodeTypeSmsWord#a416ac81 flags:# beginning:flags.0?string = auth.SentCodeType; +auth.sentCodeTypeSmsPhrase#b37794af flags:# beginning:flags.0?string = auth.SentCodeType; + +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; + +messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; + +inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; + +inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; + +messages.peerDialogs#3371c354 dialogs:Vector messages:Vector chats:Vector users:Vector state:updates.State = messages.PeerDialogs; + +topPeer#edcdc05b peer:Peer rating:double = TopPeer; + +topPeerCategoryBotsPM#ab661b5b = TopPeerCategory; +topPeerCategoryBotsInline#148677e2 = TopPeerCategory; +topPeerCategoryCorrespondents#637b7ed = TopPeerCategory; +topPeerCategoryGroups#bd17a14a = TopPeerCategory; +topPeerCategoryChannels#161d9628 = TopPeerCategory; +topPeerCategoryPhoneCalls#1e76a78c = TopPeerCategory; +topPeerCategoryForwardUsers#a8406ca9 = TopPeerCategory; +topPeerCategoryForwardChats#fbeec0f0 = TopPeerCategory; +topPeerCategoryBotsApp#fd9e7bec = TopPeerCategory; + +topPeerCategoryPeers#fb834291 category:TopPeerCategory count:int peers:Vector = TopPeerCategoryPeers; + +contacts.topPeersNotModified#de266ef5 = contacts.TopPeers; +contacts.topPeers#70b772a8 categories:Vector chats:Vector users:Vector = contacts.TopPeers; +contacts.topPeersDisabled#b52c939d = contacts.TopPeers; + +draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; +draftMessage#96eaa5eb flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo message:string entities:flags.3?Vector media:flags.5?InputMedia date:int effect:flags.7?long suggested_post:flags.8?SuggestedPost = DraftMessage; + +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; +messages.featuredStickers#be382906 flags:# premium:flags.0?true hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; + +messages.recentStickersNotModified#b17f890 = messages.RecentStickers; +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; + +messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; + +messages.stickerSetInstallResultSuccess#38641628 = messages.StickerSetInstallResult; +messages.stickerSetInstallResultArchive#35e410a8 sets:Vector = messages.StickerSetInstallResult; + +stickerSetCovered#6410a5d2 set:StickerSet cover:Document = StickerSetCovered; +stickerSetMultiCovered#3407e51b set:StickerSet covers:Vector = StickerSetCovered; +stickerSetFullCovered#40d13c0e set:StickerSet packs:Vector keywords:Vector documents:Vector = StickerSetCovered; +stickerSetNoCovered#77b15d1c set:StickerSet = StickerSetCovered; + +maskCoords#aed6dbb2 n:int x:double y:double zoom:double = MaskCoords; + +inputStickeredMediaPhoto#4a992157 id:InputPhoto = InputStickeredMedia; +inputStickeredMediaDocument#438865b id:InputDocument = InputStickeredMedia; + +game#bdf9653b flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document = Game; + +inputGameID#32c3e77 id:long access_hash:long = InputGame; +inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; + +highScore#73a379eb pos:int user_id:long score:int = HighScore; + +messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; + +textEmpty#dc3d824f = RichText; +textPlain#744694e0 text:string = RichText; +textBold#6724abc4 text:RichText = RichText; +textItalic#d912a59c text:RichText = RichText; +textUnderline#c12622c4 text:RichText = RichText; +textStrike#9bf8bb95 text:RichText = RichText; +textFixed#6c3f19b9 text:RichText = RichText; +textUrl#3c2884c1 text:RichText url:string webpage_id:long = RichText; +textEmail#de5a0dd6 text:RichText email:string = RichText; +textConcat#7e6260d7 texts:Vector = RichText; +textSubscript#ed6a8504 text:RichText = RichText; +textSuperscript#c7fb5e01 text:RichText = RichText; +textMarked#34b8621 text:RichText = RichText; +textPhone#1ccb966a text:RichText phone:string = RichText; +textImage#81ccf4f document_id:long w:int h:int = RichText; +textAnchor#35553762 text:RichText name:string = RichText; + +pageBlockUnsupported#13567e8a = PageBlock; +pageBlockTitle#70abc3fd text:RichText = PageBlock; +pageBlockSubtitle#8ffa9a1f text:RichText = PageBlock; +pageBlockAuthorDate#baafe5e0 author:RichText published_date:int = PageBlock; +pageBlockHeader#bfd064ec text:RichText = PageBlock; +pageBlockSubheader#f12bb6e1 text:RichText = PageBlock; +pageBlockParagraph#467a0766 text:RichText = PageBlock; +pageBlockPreformatted#c070d93e text:RichText language:string = PageBlock; +pageBlockFooter#48870999 text:RichText = PageBlock; +pageBlockDivider#db20b188 = PageBlock; +pageBlockAnchor#ce0d37b0 name:string = PageBlock; +pageBlockList#e4e88011 items:Vector = PageBlock; +pageBlockBlockquote#263d7c26 text:RichText caption:RichText = PageBlock; +pageBlockPullquote#4f4456d3 text:RichText caption:RichText = PageBlock; +pageBlockPhoto#1759c560 flags:# photo_id:long caption:PageCaption url:flags.0?string webpage_id:flags.0?long = PageBlock; +pageBlockVideo#7c8fe7b6 flags:# autoplay:flags.0?true loop:flags.1?true video_id:long caption:PageCaption = PageBlock; +pageBlockCover#39f23300 cover:PageBlock = PageBlock; +pageBlockEmbed#a8718dc5 flags:# full_width:flags.0?true allow_scrolling:flags.3?true url:flags.1?string html:flags.2?string poster_photo_id:flags.4?long w:flags.5?int h:flags.5?int caption:PageCaption = PageBlock; +pageBlockEmbedPost#f259a80b url:string webpage_id:long author_photo_id:long author:string date:int blocks:Vector caption:PageCaption = PageBlock; +pageBlockCollage#65a0fa4d items:Vector caption:PageCaption = PageBlock; +pageBlockSlideshow#31f9590 items:Vector caption:PageCaption = PageBlock; +pageBlockChannel#ef1751b5 channel:Chat = PageBlock; +pageBlockAudio#804361ea audio_id:long caption:PageCaption = PageBlock; +pageBlockKicker#1e148390 text:RichText = PageBlock; +pageBlockTable#bf4dea82 flags:# bordered:flags.0?true striped:flags.1?true title:RichText rows:Vector = PageBlock; +pageBlockOrderedList#9a8ae1e1 items:Vector = PageBlock; +pageBlockDetails#76768bed flags:# open:flags.0?true blocks:Vector title:RichText = PageBlock; +pageBlockRelatedArticles#16115a96 title:RichText articles:Vector = PageBlock; +pageBlockMap#a44f3ef6 geo:GeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; + +phoneCallDiscardReasonMissed#85e42301 = PhoneCallDiscardReason; +phoneCallDiscardReasonDisconnect#e095c1a0 = PhoneCallDiscardReason; +phoneCallDiscardReasonHangup#57adc690 = PhoneCallDiscardReason; +phoneCallDiscardReasonBusy#faf7e8c9 = PhoneCallDiscardReason; +phoneCallDiscardReasonMigrateConferenceCall#9fbbf1f7 slug:string = PhoneCallDiscardReason; + +dataJSON#7d748d04 data:string = DataJSON; + +labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; + +invoice#49ee584 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true recurring:flags.9?true currency:string prices:Vector max_tip_amount:flags.8?long suggested_tip_amounts:flags.8?Vector terms_url:flags.10?string subscription_period:flags.11?int = Invoice; + +paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; + +postAddress#1e8caaeb street_line1:string street_line2:string city:string state:string country_iso2:string post_code:string = PostAddress; + +paymentRequestedInfo#909c3f94 flags:# name:flags.0?string phone:flags.1?string email:flags.2?string shipping_address:flags.3?PostAddress = PaymentRequestedInfo; + +paymentSavedCredentialsCard#cdc27a1f id:string title:string = PaymentSavedCredentials; + +webDocument#1c570ed1 url:string access_hash:long size:int mime_type:string attributes:Vector = WebDocument; +webDocumentNoProxy#f9c8bcc6 url:string size:int mime_type:string attributes:Vector = WebDocument; + +inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector = InputWebDocument; + +inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation; +inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation; +inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# small:flags.2?true document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation; + +upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; + +payments.paymentForm#a0058751 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON additional_methods:flags.6?Vector saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?Vector users:Vector = payments.PaymentForm; +payments.paymentFormStars#7bf6b15c flags:# form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice users:Vector = payments.PaymentForm; +payments.paymentFormStarGift#b425cfe1 form_id:long invoice:Invoice = payments.PaymentForm; + +payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; + +payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; +payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; + +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; +payments.paymentReceiptStars#dabbf83a flags:# date:int bot_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice currency:string total_amount:long transaction_id:string users:Vector = payments.PaymentReceipt; + +payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; + +inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; +inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsGooglePay#8ac32801 payment_token:DataJSON = InputPaymentCredentials; + +account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; + +shippingOption#b6213cdf id:string title:string prices:Vector = ShippingOption; + +inputStickerSetItem#32da9e9c flags:# document:InputDocument emoji:string mask_coords:flags.0?MaskCoords keywords:flags.1?string = InputStickerSetItem; + +inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; + +phoneCallEmpty#5366c915 id:long = PhoneCall; +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#30535af5 flags:# p2p_allowed:flags.5?true video:flags.6?true conference_supported:flags.8?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int custom_parameters:flags.7?DataJSON = PhoneCall; +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; + +phoneConnection#9cc123c7 flags:# tcp:flags.0?true id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; + +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; + +phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; + +upload.cdnFileReuploadNeeded#eea8e46e request_token:bytes = upload.CdnFile; +upload.cdnFile#a99fca4f bytes:bytes = upload.CdnFile; + +cdnPublicKey#c982eaba dc_id:int public_key:string = CdnPublicKey; + +cdnConfig#5725e40a public_keys:Vector = CdnConfig; + +langPackString#cad181f6 key:string value:string = LangPackString; +langPackStringPluralized#6c47ac9f flags:# key:string zero_value:flags.0?string one_value:flags.1?string two_value:flags.2?string few_value:flags.3?string many_value:flags.4?string other_value:string = LangPackString; +langPackStringDeleted#2979eeb2 key:string = LangPackString; + +langPackDifference#f385c1f6 lang_code:string from_version:int version:int strings:Vector = LangPackDifference; + +langPackLanguage#eeca5ce3 flags:# official:flags.0?true rtl:flags.2?true beta:flags.3?true name:string native_name:string lang_code:string base_lang_code:flags.1?string plural_code:string strings_count:int translated_count:int translations_url:string = LangPackLanguage; + +channelAdminLogEventActionChangeTitle#e6dfb825 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAbout#55188a2e prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsername#6a4afc38 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePhoto#434bd2af prev_photo:Photo new_photo:Photo = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleInvites#1b7907ae new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatures#26ae0971 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionUpdatePinned#e9e82c18 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionEditMessage#709b2405 prev_message:Message new_message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteMessage#42e047bb message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoin#183040d3 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantLeave#f89777f2 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantInvite#e31c34d8 participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleBan#e6d83d7e prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleAdmin#d5676710 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; +channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionDiscardGroupCall#db9f9140 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantMute#f92424d2 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantUnmute#e64429c0 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleGroupCallSetting#56d6a247 join_muted:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByInvite#fe9fc158 flags:# via_chatlist:flags.0?true invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteDelete#5a50fca4 invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePeerColor#5796e780 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeProfilePeerColor#5e477b25 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeWallpaper#31bb5d52 prev_value:WallPaper new_value:WallPaper = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStatus#3ea9feb1 prev_value:EmojiStatus new_value:EmojiStatus = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStickerSet#46d840ab prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatureProfiles#60a79c79 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantSubExtend#64642db3 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAutotranslation#c517f77e new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantEditRank#5806b4ec user_id:long prev_rank:string new_rank:string = ChannelAdminLogEventAction; + +channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; + +channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; + +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true sub_extend:flags.18?true edit_rank:flags.19?true = ChannelAdminLogEventsFilter; + +popularContact#5ce14175 client_id:long importers:int = PopularContact; + +messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; + +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; +recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; + +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; + +account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; + +inputMessageID#a676a322 id:int = InputMessage; +inputMessageReplyTo#bad88395 id:int = InputMessage; +inputMessagePinned#86872538 = InputMessage; +inputMessageCallbackQuery#acfa1a7e id:int query_id:long = InputMessage; + +inputDialogPeer#fcaafeb7 peer:InputPeer = InputDialogPeer; +inputDialogPeerFolder#64600527 folder_id:int = InputDialogPeer; + +dialogPeer#e56dbf05 peer:Peer = DialogPeer; +dialogPeerFolder#514519e2 folder_id:int = DialogPeer; + +messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; + +fileHash#f39b035c offset:long limit:int hash:bytes = FileHash; + +inputClientProxy#75588b3f address:string port:int = InputClientProxy; + +help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; +help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; + +inputSecureFileUploaded#3334b0f0 id:long parts:int md5_checksum:string file_hash:bytes secret:bytes = InputSecureFile; +inputSecureFile#5367e5be id:long access_hash:long = InputSecureFile; + +secureFileEmpty#64199744 = SecureFile; +secureFile#7d09c27e id:long access_hash:long size:long dc_id:int date:int file_hash:bytes secret:bytes = SecureFile; + +secureData#8aeabec3 data:bytes data_hash:bytes secret:bytes = SecureData; + +securePlainPhone#7d6099dd phone:string = SecurePlainData; +securePlainEmail#21ec5a5f email:string = SecurePlainData; + +secureValueTypePersonalDetails#9d2a81e3 = SecureValueType; +secureValueTypePassport#3dac6a00 = SecureValueType; +secureValueTypeDriverLicense#6e425c4 = SecureValueType; +secureValueTypeIdentityCard#a0d0744b = SecureValueType; +secureValueTypeInternalPassport#99a48f23 = SecureValueType; +secureValueTypeAddress#cbe31e26 = SecureValueType; +secureValueTypeUtilityBill#fc36954e = SecureValueType; +secureValueTypeBankStatement#89137c0d = SecureValueType; +secureValueTypeRentalAgreement#8b883488 = SecureValueType; +secureValueTypePassportRegistration#99e3806a = SecureValueType; +secureValueTypeTemporaryRegistration#ea02ec33 = SecureValueType; +secureValueTypePhone#b320aadb = SecureValueType; +secureValueTypeEmail#8e3ca7ee = SecureValueType; + +secureValue#187fa0ca flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?SecureFile reverse_side:flags.2?SecureFile selfie:flags.3?SecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData hash:bytes = SecureValue; + +inputSecureValue#db21d0a7 flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?InputSecureFile reverse_side:flags.2?InputSecureFile selfie:flags.3?InputSecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData = InputSecureValue; + +secureValueHash#ed1ecdb0 type:SecureValueType hash:bytes = SecureValueHash; + +secureValueErrorData#e8a40bd9 type:SecureValueType data_hash:bytes field:string text:string = SecureValueError; +secureValueErrorFrontSide#be3dfa type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorReverseSide#868a2aa5 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorSelfie#e537ced6 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFile#7a700873 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFiles#666220e9 type:SecureValueType file_hash:Vector text:string = SecureValueError; +secureValueError#869d758f type:SecureValueType hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFile#a1144770 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFiles#34636dd8 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureCredentialsEncrypted#33f0ea47 data:bytes hash:bytes secret:bytes = SecureCredentialsEncrypted; + +account.authorizationForm#ad2e1cd8 flags:# required_types:Vector values:Vector errors:Vector users:Vector privacy_policy_url:flags.0?string = account.AuthorizationForm; + +account.sentEmailCode#811f854f email_pattern:string length:int = account.SentEmailCode; + +help.deepLinkInfoEmpty#66afa166 = help.DeepLinkInfo; +help.deepLinkInfo#6a4ee832 flags:# update_app:flags.0?true message:string entities:flags.1?Vector = help.DeepLinkInfo; + +savedPhoneContact#1142bd56 phone:string first_name:string last_name:string date:int = SavedContact; + +account.takeout#4dba4501 id:long = account.Takeout; + +passwordKdfAlgoUnknown#d45ab096 = PasswordKdfAlgo; +passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow#3a912d4a salt1:bytes salt2:bytes g:int p:bytes = PasswordKdfAlgo; + +securePasswordKdfAlgoUnknown#4a8537 = SecurePasswordKdfAlgo; +securePasswordKdfAlgoPBKDF2HMACSHA512iter100000#bbf2dda0 salt:bytes = SecurePasswordKdfAlgo; +securePasswordKdfAlgoSHA512#86471d92 salt:bytes = SecurePasswordKdfAlgo; + +secureSecretSettings#1527bcac secure_algo:SecurePasswordKdfAlgo secure_secret:bytes secure_secret_id:long = SecureSecretSettings; + +inputCheckPasswordEmpty#9880f658 = InputCheckPasswordSRP; +inputCheckPasswordSRP#d27ff082 srp_id:long A:bytes M1:bytes = InputCheckPasswordSRP; + +secureRequiredType#829d99da flags:# native_names:flags.0?true selfie_required:flags.1?true translation_required:flags.2?true type:SecureValueType = SecureRequiredType; +secureRequiredTypeOneOf#27477b4 types:Vector = SecureRequiredType; + +help.passportConfigNotModified#bfb9f457 = help.PassportConfig; +help.passportConfig#a098d6af hash:int countries_langs:DataJSON = help.PassportConfig; + +inputAppEvent#1d1b1245 time:double type:string peer:long data:JSONValue = InputAppEvent; + +jsonObjectValue#c0de1bd9 key:string value:JSONValue = JSONObjectValue; + +jsonNull#3f6d7b68 = JSONValue; +jsonBool#c7345e6a value:Bool = JSONValue; +jsonNumber#2be0dfa4 value:double = JSONValue; +jsonString#b71e767a value:string = JSONValue; +jsonArray#f7444763 value:Vector = JSONValue; +jsonObject#99c1d49d value:Vector = JSONValue; + +pageTableCell#34566b6a flags:# header:flags.0?true align_center:flags.3?true align_right:flags.4?true valign_middle:flags.5?true valign_bottom:flags.6?true text:flags.7?RichText colspan:flags.1?int rowspan:flags.2?int = PageTableCell; + +pageTableRow#e0c0c5e5 cells:Vector = PageTableRow; + +pageCaption#6f747657 text:RichText credit:RichText = PageCaption; + +pageListItemText#b92fb6cd text:RichText = PageListItem; +pageListItemBlocks#25e073fc blocks:Vector = PageListItem; + +pageListOrderedItemText#5e068047 num:string text:RichText = PageListOrderedItem; +pageListOrderedItemBlocks#98dd8936 num:string blocks:Vector = PageListOrderedItem; + +pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; + +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; + +help.supportName#8c05f1c9 name:string = help.SupportName; + +help.userInfoEmpty#f3ae2eed = help.UserInfo; +help.userInfo#1eb3758 message:string entities:Vector author:string date:int = help.UserInfo; + +pollAnswer#4b7d786a flags:# text:TextWithEntities option:bytes media:flags.0?MessageMedia added_by:flags.1?Peer date:flags.1?int = PollAnswer; +inputPollAnswer#199fed96 flags:# text:TextWithEntities media:flags.0?InputMedia = PollAnswer; + +poll#966e2dbf id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true open_answers:flags.6?true revoting_disabled:flags.7?true shuffle_answers:flags.8?true hide_results_until_close:flags.9?true creator:flags.10?true subscribers_only:flags.11?true question:TextWithEntities answers:Vector close_period:flags.4?int close_date:flags.5?int countries_iso2:flags.12?Vector hash:long = Poll; + +pollAnswerVoters#3645230a flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:flags.2?int recent_voters:flags.2?Vector = PollAnswerVoters; + +pollResults#ba7bb15e flags:# min:flags.0?true has_unread_votes:flags.6?true can_view_stats:flags.7?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector solution_media:flags.5?MessageMedia = PollResults; + +chatOnlines#f041e250 onlines:int = ChatOnlines; + +statsURL#47a971e0 url:string = StatsURL; + +chatAdminRights#5fb224d5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true pin_messages:flags.7?true add_admins:flags.9?true anonymous:flags.10?true manage_call:flags.11?true other:flags.12?true manage_topics:flags.13?true post_stories:flags.14?true edit_stories:flags.15?true delete_stories:flags.16?true manage_direct_messages:flags.17?true manage_ranks:flags.18?true = ChatAdminRights; + +chatBannedRights#9f120418 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true send_polls:flags.8?true change_info:flags.10?true invite_users:flags.15?true pin_messages:flags.17?true manage_topics:flags.18?true send_photos:flags.19?true send_videos:flags.20?true send_roundvideos:flags.21?true send_audios:flags.22?true send_voices:flags.23?true send_docs:flags.24?true send_plain:flags.25?true edit_rank:flags.26?true send_reactions:flags.27?true until_date:int = ChatBannedRights; + +inputWallPaper#e630b979 id:long access_hash:long = InputWallPaper; +inputWallPaperSlug#72091c80 slug:string = InputWallPaper; +inputWallPaperNoFile#967a462e id:long = InputWallPaper; + +account.wallPapersNotModified#1c199183 = account.WallPapers; +account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; + +codeSettings#ad253d78 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true allow_firebase:flags.7?true unknown_number:flags.9?true logout_tokens:flags.6?Vector token:flags.8?string app_sandbox:flags.8?Bool = CodeSettings; + +wallPaperSettings#372efcd0 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int emoticon:flags.7?string = WallPaperSettings; + +autoDownloadSettings#baa57628 flags:# disabled:flags.0?true video_preload_large:flags.1?true audio_preload_next:flags.2?true phonecalls_less_data:flags.3?true stories_preload:flags.4?true photo_size_max:int video_size_max:long file_size_max:long video_upload_maxbitrate:int small_queue_active_operations_max:int large_queue_active_operations_max:int = AutoDownloadSettings; + +account.autoDownloadSettings#63cacf26 low:AutoDownloadSettings medium:AutoDownloadSettings high:AutoDownloadSettings = account.AutoDownloadSettings; + +emojiKeyword#d5b3b9f9 keyword:string emoticons:Vector = EmojiKeyword; +emojiKeywordDeleted#236df622 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordsDifference#5cc761bd lang_code:string from_version:int version:int keywords:Vector = EmojiKeywordsDifference; + +emojiURL#a575739d url:string = EmojiURL; + +emojiLanguage#b3fb5361 lang_code:string = EmojiLanguage; + +folder#ff544e65 flags:# autofill_new_broadcasts:flags.0?true autofill_public_groups:flags.1?true autofill_new_correspondents:flags.2?true id:int title:string photo:flags.3?ChatPhoto = Folder; + +inputFolderPeer#fbd2c296 peer:InputPeer folder_id:int = InputFolderPeer; + +folderPeer#e9baa668 peer:Peer folder_id:int = FolderPeer; + +messages.searchCounter#e844ebff flags:# inexact:flags.1?true filter:MessagesFilter count:int = messages.SearchCounter; + +urlAuthResultRequest#3cd623ec flags:# request_write_access:flags.0?true request_phone_number:flags.1?true match_codes_first:flags.5?true is_app:flags.6?true bot:User domain:string browser:flags.2?string platform:flags.2?string ip:flags.2?string region:flags.2?string match_codes:flags.3?Vector user_id_hint:flags.4?long verified_app_name:flags.7?string = UrlAuthResult; +urlAuthResultAccepted#623a8fa0 flags:# url:flags.0?string = UrlAuthResult; +urlAuthResultDefault#a9d6db1f = UrlAuthResult; + +channelLocationEmpty#bfb5ad8b = ChannelLocation; +channelLocation#209b82db geo_point:GeoPoint address:string = ChannelLocation; + +peerLocated#ca461b5d peer:Peer expires:int distance:int = PeerLocated; +peerSelfLocated#f8ec284b expires:int = PeerLocated; + +restrictionReason#d072acb4 platform:string reason:string text:string = RestrictionReason; + +inputTheme#3c5693e9 id:long access_hash:long = InputTheme; +inputThemeSlug#f5890df1 slug:string = InputTheme; + +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; + +account.themesNotModified#f41eb622 = account.Themes; +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; + +auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; +auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; +auth.loginTokenSuccess#390d5c5e authorization:auth.Authorization = auth.LoginToken; + +account.contentSettings#57e28221 flags:# sensitive_enabled:flags.0?true sensitive_can_change:flags.1?true = account.ContentSettings; + +messages.inactiveChats#a927fec5 dates:Vector chats:Vector users:Vector = messages.InactiveChats; + +baseThemeClassic#c3a12462 = BaseTheme; +baseThemeDay#fbd81688 = BaseTheme; +baseThemeNight#b7b31ea8 = BaseTheme; +baseThemeTinted#6d5f77ee = BaseTheme; +baseThemeArctic#5b11125a = BaseTheme; + +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; + +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; + +webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; +webPageAttributeStory#2e94c3e7 flags:# peer:Peer id:int story:flags.0?StoryItem = WebPageAttribute; +webPageAttributeStickerSet#50cc03d3 flags:# emojis:flags.0?true text_color:flags.1?true stickers:Vector = WebPageAttribute; +webPageAttributeUniqueStarGift#cf6f6db8 gift:StarGift = WebPageAttribute; +webPageAttributeStarGiftCollection#31cad303 icons:Vector = WebPageAttribute; +webPageAttributeStarGiftAuction#1c641c2 gift:StarGift end_date:int = WebPageAttribute; +webPageAttributeAiComposeTone#7781fe18 emoji_id:long = WebPageAttribute; + +messages.votesList#4899484e flags:# count:int votes:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.VotesList; + +bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; + +payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; + +dialogFilter#aa472651 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; +dialogFilterDefault#363293ae = DialogFilter; +dialogFilterChatlist#96537bd7 flags:# has_my_invites:flags.26?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; +statsGraphError#bedc9822 error:string = StatsGraph; +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +stats.broadcastStats#396ca5fc period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev reactions_per_post:StatsAbsValueAndPrev views_per_story:StatsAbsValueAndPrev shares_per_story:StatsAbsValueAndPrev reactions_per_story:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph reactions_by_emotion_graph:StatsGraph story_interactions_graph:StatsGraph story_reactions_by_emotion_graph:StatsGraph recent_posts_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; +help.promoData#8a4d87a flags:# proxy:flags.0?true expires:int peer:flags.3?Peer psa_type:flags.1?string psa_message:flags.2?string pending_suggestions:Vector dismissed_suggestions:Vector custom_pending_suggestion:flags.4?PendingSuggestion chats:Vector users:Vector = help.PromoData; + +videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; +videoSizeEmojiMarkup#f85c413c emoji_id:long background_colors:Vector = VideoSize; +videoSizeStickerMarkup#da082fe stickerset:InputStickerSet sticker_id:long background_colors:Vector = VideoSize; + +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#fe41b34f flags:# archive_and_mute_new_noncontact_peers:flags.0?true keep_archived_unmuted:flags.1?true keep_archived_folders:flags.2?true hide_read_marks:flags.3?true new_noncontact_peers_require_premium:flags.4?true display_gifts_button:flags.7?true noncontact_peers_paid_stars:flags.5?long disallowed_gifts:flags.6?DisallowedGiftsSettings = GlobalPrivacySettings; + +help.countryCode#4203c5ef flags:# country_code:string prefixes:flags.0?Vector patterns:flags.1?Vector = help.CountryCode; + +help.country#c3878e23 flags:# hidden:flags.0?true iso2:string default_name:string name:flags.1?string country_codes:Vector = help.Country; + +help.countriesListNotModified#93cc1f32 = help.CountriesList; +help.countriesList#87d0759e countries:Vector hash:int = help.CountriesList; + +messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:flags.2?MessageReplies = MessageViews; + +messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; + +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; + +messageReplyHeader#1b97dd66 flags:# reply_to_scheduled:flags.2?true forum_topic:flags.3?true quote:flags.9?true reply_to_msg_id:flags.4?int reply_to_peer_id:flags.0?Peer reply_from:flags.5?MessageFwdHeader reply_media:flags.8?MessageMedia reply_to_top_id:flags.1?int quote_text:flags.6?string quote_entities:flags.7?Vector quote_offset:flags.10?int todo_item_id:flags.11?int poll_option:flags.12?bytes = MessageReplyHeader; +messageReplyStoryHeader#e5af939 peer:Peer story_id:int = MessageReplyHeader; + +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; + +peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; + +stats.messageStats#7fe91c14 views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.MessageStats; + +groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; +groupCall#efb2b617 flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true conference:flags.14?true creator:flags.15?true messages_enabled:flags.17?true can_change_messages_enabled:flags.18?true min:flags.19?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int invite_link:flags.16?string send_paid_messages_stars:flags.20?long default_send_as:flags.21?Peer = GroupCall; + +inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; +inputGroupCallSlug#fe06823f slug:string = InputGroupCall; +inputGroupCallInviteMessage#8c10603f msg_id:int = InputGroupCall; + +groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant; + +phone.groupCall#9e727aad call:GroupCall participants:Vector participants_next_offset:string chats:Vector users:Vector = phone.GroupCall; + +phone.groupParticipants#f47751b6 count:int participants:Vector next_offset:string chats:Vector users:Vector version:int = phone.GroupParticipants; + +inlineQueryPeerTypeSameBotPM#3081ed9d = InlineQueryPeerType; +inlineQueryPeerTypePM#833c0fac = InlineQueryPeerType; +inlineQueryPeerTypeChat#d766c50a = InlineQueryPeerType; +inlineQueryPeerTypeMegagroup#5ec4be43 = InlineQueryPeerType; +inlineQueryPeerTypeBroadcast#6334ee9a = InlineQueryPeerType; +inlineQueryPeerTypeBotPM#e3b2d0c = InlineQueryPeerType; + +messages.historyImport#1662af0b id:long = messages.HistoryImport; + +messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true title:flags.2?string = messages.HistoryImportParsed; + +messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; + +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true via_chatlist:flags.3?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; + +messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; + +messages.exportedChatInvite#1871be50 invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; +messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; + +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; + +messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; + +messages.checkedHistoryImportPeer#a24de717 confirm_text:string = messages.CheckedHistoryImportPeer; + +phone.joinAsPeers#afe5623f peers:Vector chats:Vector users:Vector = phone.JoinAsPeers; + +phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvite; + +groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; + +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; +botCommandScopeUsers#3c4f04d8 = BotCommandScope; +botCommandScopeChats#6fe1a881 = BotCommandScope; +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +chatTheme#c3dffc04 emoticon:string = ChatTheme; +chatThemeUniqueGift#3458f9c8 gift:StarGift theme_settings:Vector = ChatTheme; + +account.chatThemesNotModified#e011e1c4 = account.ChatThemes; +account.chatThemes#be098173 flags:# hash:long themes:Vector chats:Vector users:Vector next_offset:flags.0?string = account.ChatThemes; + +sponsoredMessage#7dbf8673 flags:# recommended:flags.5?true can_report:flags.12?true random_id:bytes url:string title:string message:string entities:flags.1?Vector photo:flags.6?Photo media:flags.14?MessageMedia color:flags.13?PeerColor button_text:string sponsor_info:flags.7?string additional_info:flags.8?string min_display_duration:flags.15?int max_display_duration:flags.15?int = SponsoredMessage; + +messages.sponsoredMessages#ffda656d flags:# posts_between:flags.0?int start_delay:flags.1?int between_delay:flags.2?int messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +messages.sponsoredMessagesEmpty#1839490f = messages.SponsoredMessages; + +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#f496b0c6 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#a3d1cb80 flags:# chosen_order:flags.0?int reaction:Reaction count:int = ReactionCount; + +messageReactions#a339f0b flags:# min:flags.0?true can_see_list:flags.2?true reactions_as_tags:flags.3?true results:Vector recent_reactions:flags.1?Vector top_reactors:flags.4?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true premium:flags.2?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + +messagePeerReaction#8c79b63c flags:# big:flags.0?true unread:flags.1?true my:flags.2?true peer_id:Peer date:int reaction:Reaction = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; + +attachMenuBotIconColor#4576f3f0 name:string color:int = AttachMenuBotIconColor; + +attachMenuBotIcon#b2a7386b flags:# name:string icon:Document colors:flags.0?Vector = AttachMenuBotIcon; + +attachMenuBot#d90d8dfe flags:# inactive:flags.0?true has_settings:flags.1?true request_write_access:flags.2?true show_in_attach_menu:flags.3?true show_in_side_menu:flags.4?true side_menu_disclaimer_needed:flags.5?true bot_id:long short_name:string peer_types:flags.3?Vector icons:Vector = AttachMenuBot; + +attachMenuBotsNotModified#f1d88a5c = AttachMenuBots; +attachMenuBots#3c4301c0 hash:long bots:Vector users:Vector = AttachMenuBots; + +attachMenuBotsBot#93bf667f bot:AttachMenuBot users:Vector = AttachMenuBotsBot; + +webViewResultUrl#4d22ff98 flags:# fullsize:flags.1?true fullscreen:flags.2?true query_id:flags.0?long url:string = WebViewResult; + +webViewMessageSent#c94511c flags:# msg_id:flags.0?InputBotInlineMessageID = WebViewMessageSent; + +botMenuButtonDefault#7533a588 = BotMenuButton; +botMenuButtonCommands#4258c205 = BotMenuButton; +botMenuButton#c7b57ce6 text:string url:string = BotMenuButton; + +account.savedRingtonesNotModified#fbf6e8b1 = account.SavedRingtones; +account.savedRingtones#c1e92cc5 hash:long ringtones:Vector = account.SavedRingtones; + +notificationSoundDefault#97e8bebe = NotificationSound; +notificationSoundNone#6f0c34df = NotificationSound; +notificationSoundLocal#830b9ae4 title:string data:string = NotificationSound; +notificationSoundRingtone#ff6c8049 id:long = NotificationSound; + +account.savedRingtone#b7263f6d = account.SavedRingtone; +account.savedRingtoneConverted#1f307eb7 document:Document = account.SavedRingtone; + +attachMenuPeerTypeSameBotPM#7d6be90e = AttachMenuPeerType; +attachMenuPeerTypeBotPM#c32bfa1a = AttachMenuPeerType; +attachMenuPeerTypePM#f146d31f = AttachMenuPeerType; +attachMenuPeerTypeChat#509113f = AttachMenuPeerType; +attachMenuPeerTypeBroadcast#7bfbdefc = AttachMenuPeerType; + +inputInvoiceMessage#c5b56859 peer:InputPeer msg_id:int = InputInvoice; +inputInvoiceSlug#c326caef slug:string = InputInvoice; +inputInvoicePremiumGiftCode#98986c0d purpose:InputStorePaymentPurpose option:PremiumGiftCodeOption = InputInvoice; +inputInvoiceStars#65f00ce3 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceChatInviteSubscription#34e793f1 hash:string = InputInvoice; +inputInvoiceStarGift#e8625e92 flags:# hide_name:flags.0?true include_upgrade:flags.2?true peer:InputPeer gift_id:long message:flags.1?TextWithEntities = InputInvoice; +inputInvoiceStarGiftUpgrade#4d818d5d flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftTransfer#4a5f5bd9 stargift:InputSavedStarGift to_id:InputPeer = InputInvoice; +inputInvoicePremiumGiftStars#dabab2ef flags:# user_id:InputUser months:int message:flags.0?TextWithEntities = InputInvoice; +inputInvoiceBusinessBotTransferStars#f4997e42 bot:InputUser stars:long = InputInvoice; +inputInvoiceStarGiftResale#c39f5324 flags:# ton:flags.0?true slug:string to_id:InputPeer = InputInvoice; +inputInvoiceStarGiftPrepaidUpgrade#9a0b48b8 peer:InputPeer hash:string = InputInvoice; +inputInvoicePremiumAuthCode#3e77f614 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceStarGiftDropOriginalDetails#923d8d1 stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftAuctionBid#1ecafa10 flags:# hide_name:flags.0?true update_bid:flags.2?true peer:flags.3?InputPeer gift_id:long bid_amount:long message:flags.1?TextWithEntities = InputInvoice; + +payments.exportedInvoice#aed0cbd9 url:string = payments.ExportedInvoice; + +messages.transcribedAudio#cfb9d957 flags:# pending:flags.0?true transcription_id:long text:string trial_remains_num:flags.1?int trial_remains_until_date:flags.1?int = messages.TranscribedAudio; + +help.premiumPromo#5334759c status_text:string status_entities:Vector video_sections:Vector videos:Vector period_options:Vector users:Vector = help.PremiumPromo; + +inputStorePaymentPremiumSubscription#a6751e66 flags:# restore:flags.0?true upgrade:flags.1?true = InputStorePaymentPurpose; +inputStorePaymentGiftPremium#616f7fe8 user_id:InputUser currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentPremiumGiftCode#fb790393 flags:# users:Vector boost_peer:flags.0?InputPeer currency:string amount:long message:flags.1?TextWithEntities = InputStorePaymentPurpose; +inputStorePaymentPremiumGiveaway#160544ca flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsTopup#f9a2a6cb flags:# stars:long currency:string amount:long spend_purpose_peer:flags.0?InputPeer = InputStorePaymentPurpose; +inputStorePaymentStarsGift#1d741ef7 user_id:InputUser stars:long currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsGiveaway#751f08fa flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true stars:long boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long users:int = InputStorePaymentPurpose; +inputStorePaymentAuthCode#9bb2636d flags:# restore:flags.0?true phone_number:string phone_code_hash:string currency:string amount:long = InputStorePaymentPurpose; + +paymentFormMethod#88f8f21b url:string title:string = PaymentFormMethod; + +emojiStatusEmpty#2de11aae = EmojiStatus; +emojiStatus#e7ff068a flags:# document_id:long until:flags.0?int = EmojiStatus; +emojiStatusCollectible#7184603b flags:# collectible_id:long document_id:long title:string slug:string pattern_document_id:long center_color:int edge_color:int pattern_color:int text_color:int until:flags.0?int = EmojiStatus; +inputEmojiStatusCollectible#7141dbf flags:# collectible_id:long until:flags.0?int = EmojiStatus; + +account.emojiStatusesNotModified#d08ce645 = account.EmojiStatuses; +account.emojiStatuses#90c467d1 hash:long statuses:Vector = account.EmojiStatuses; + +reactionEmpty#79f5d419 = Reaction; +reactionEmoji#1b2286b8 emoticon:string = Reaction; +reactionCustomEmoji#8935fc73 document_id:long = Reaction; +reactionPaid#523da4eb = Reaction; + +chatReactionsNone#eafc32bc = ChatReactions; +chatReactionsAll#52928bca flags:# allow_custom:flags.0?true = ChatReactions; +chatReactionsSome#661d4037 reactions:Vector = ChatReactions; + +messages.reactionsNotModified#b06fdbdf = messages.Reactions; +messages.reactions#eafdf716 hash:long reactions:Vector = messages.Reactions; + +emailVerifyPurposeLoginSetup#4345be73 phone_number:string phone_code_hash:string = EmailVerifyPurpose; +emailVerifyPurposeLoginChange#527d22eb = EmailVerifyPurpose; +emailVerifyPurposePassport#bbf51685 = EmailVerifyPurpose; + +emailVerificationCode#922e55a9 code:string = EmailVerification; +emailVerificationGoogle#db909ec2 token:string = EmailVerification; +emailVerificationApple#96d074fd token:string = EmailVerification; + +account.emailVerified#2b96cd1b email:string = account.EmailVerified; +account.emailVerifiedLogin#e1bb0d61 email:string sent_code:auth.SentCode = account.EmailVerified; + +premiumSubscriptionOption#5f2d1df2 flags:# current:flags.1?true can_purchase_upgrade:flags.2?true transaction:flags.3?string months:int currency:string amount:long bot_url:string store_product:flags.0?string = PremiumSubscriptionOption; + +sendAsPeer#b81c7034 flags:# premium_required:flags.0?true peer:Peer = SendAsPeer; + +messageExtendedMediaPreview#ad628cc8 flags:# w:flags.0?int h:flags.0?int thumb:flags.1?PhotoSize video_duration:flags.2?int = MessageExtendedMedia; +messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; + +stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; + +username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; + +forumTopicDeleted#23f109b id:int = ForumTopic; +forumTopic#fcdad815 flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true title_missing:flags.7?true id:int date:int peer:Peer title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; + +messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; + +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + +requestPeerTypeUser#5f3b8a00 flags:# bot:flags.0?Bool premium:flags.1?Bool = RequestPeerType; +requestPeerTypeChat#c9f06e1b flags:# creator:flags.0?true bot_participant:flags.5?true has_username:flags.3?Bool forum:flags.4?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeBroadcast#339bef6c flags:# creator:flags.0?true has_username:flags.3?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeCreateBot#3e81e078 flags:# bot_managed:flags.0?true suggested_name:flags.1?string suggested_username:flags.2?string = RequestPeerType; + +emojiListNotModified#481eadfa = EmojiList; +emojiList#7a1e11d1 hash:long document_id:Vector = EmojiList; + +emojiGroup#7a9abda9 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupGreeting#80d26cc7 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupPremium#93bcf34 title:string icon_emoji_id:long = EmojiGroup; + +messages.emojiGroupsNotModified#6fb4ad87 = messages.EmojiGroups; +messages.emojiGroups#881fb94b hash:int groups:Vector = messages.EmojiGroups; + +textWithEntities#751f3146 text:string entities:Vector = TextWithEntities; + +messages.translateResult#33db32f8 result:Vector = messages.TranslatedText; + +autoSaveSettings#c84834ce flags:# photos:flags.0?true videos:flags.1?true video_max_size:flags.2?long = AutoSaveSettings; + +autoSaveException#81602d47 peer:Peer settings:AutoSaveSettings = AutoSaveException; + +account.autoSaveSettings#4c3e069d users_settings:AutoSaveSettings chats_settings:AutoSaveSettings broadcasts_settings:AutoSaveSettings exceptions:Vector chats:Vector users:Vector = account.AutoSaveSettings; + +help.appConfigNotModified#7cde641d = help.AppConfig; +help.appConfig#dd18782e hash:int config:JSONValue = help.AppConfig; + +inputBotAppID#a920bd7a id:long access_hash:long = InputBotApp; +inputBotAppShortName#908c0407 bot_id:InputUser short_name:string = InputBotApp; + +botAppNotModified#5da674b7 = BotApp; +botApp#95fcd1d6 flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document hash:long = BotApp; + +messages.botApp#eb50adf5 flags:# inactive:flags.0?true request_write_access:flags.1?true has_settings:flags.2?true app:BotApp = messages.BotApp; + +inlineBotWebView#b57295d5 text:string url:string = InlineBotWebView; + +readParticipantDate#4a4ff172 user_id:long date:int = ReadParticipantDate; + +inputChatlistDialogFilter#f3e0da33 filter_id:int = InputChatlist; + +exportedChatlistInvite#c5181ac flags:# title:string url:string peers:Vector = ExportedChatlistInvite; + +chatlists.exportedChatlistInvite#10e6e3a6 filter:DialogFilter invite:ExportedChatlistInvite = chatlists.ExportedChatlistInvite; + +chatlists.exportedInvites#10ab6dc7 invites:Vector chats:Vector users:Vector = chatlists.ExportedInvites; + +chatlists.chatlistInviteAlready#fa87f659 filter_id:int missing_peers:Vector already_peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; +chatlists.chatlistInvite#f10ece2f flags:# title_noanimate:flags.1?true title:TextWithEntities emoticon:flags.0?string peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistUpdates#93bd878d missing_peers:Vector chats:Vector users:Vector = chatlists.ChatlistUpdates; + +bots.botInfo#e8a775b0 name:string about:string description:string = bots.BotInfo; + +messagePeerVote#b6cc2d5c peer:Peer option:bytes date:int = MessagePeerVote; +messagePeerVoteInputOption#74cda504 peer:Peer date:int = MessagePeerVote; +messagePeerVoteMultiple#4628f6e6 peer:Peer options:Vector date:int = MessagePeerVote; + +storyViews#8d595cd6 flags:# has_viewers:flags.1?true views_count:int forwards_count:flags.2?int reactions:flags.3?Vector reactions_count:flags.4?int recent_viewers:flags.0?Vector = StoryViews; + +storyItemDeleted#51e6ee4f id:int = StoryItem; +storyItemSkipped#ffadc913 flags:# close_friends:flags.8?true live:flags.9?true id:int date:int expire_date:int = StoryItem; +storyItem#16a4b93c flags:# pinned:flags.5?true public:flags.7?true close_friends:flags.8?true min:flags.9?true noforwards:flags.10?true edited:flags.11?true contacts:flags.12?true selected_contacts:flags.13?true out:flags.16?true id:int date:int from_id:flags.18?Peer fwd_from:flags.17?StoryFwdHeader expire_date:int caption:flags.0?string entities:flags.1?Vector media:MessageMedia media_areas:flags.14?Vector privacy:flags.2?Vector views:flags.3?StoryViews sent_reaction:flags.15?Reaction albums:flags.19?Vector music:flags.20?Document = StoryItem; + +stories.allStoriesNotModified#1158fe3e flags:# state:string stealth_mode:StoriesStealthMode = stories.AllStories; +stories.allStories#6efc5e81 flags:# has_more:flags.0?true count:int state:string peer_stories:Vector chats:Vector users:Vector stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.stories#63c3dd0a flags:# count:int stories:Vector pinned_to_top:flags.0?Vector chats:Vector users:Vector = stories.Stories; + +storyView#b0bdeac5 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true user_id:long date:int reaction:flags.2?Reaction = StoryView; +storyViewPublicForward#9083670b flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true message:Message = StoryView; +storyViewPublicRepost#bd74cf49 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer story:StoryItem = StoryView; + +stories.storyViewsList#59d78fc5 flags:# count:int views_count:int forwards_count:int reactions_count:int views:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryViewsList; + +stories.storyViews#de9eed1d views:Vector users:Vector = stories.StoryViews; + +inputReplyToMessage#3bd4b7c2 flags:# reply_to_msg_id:int top_msg_id:flags.0?int reply_to_peer_id:flags.1?InputPeer quote_text:flags.2?string quote_entities:flags.3?Vector quote_offset:flags.4?int monoforum_peer_id:flags.5?InputPeer todo_item_id:flags.6?int poll_option:flags.7?bytes = InputReplyTo; +inputReplyToStory#5881323a peer:InputPeer story_id:int = InputReplyTo; +inputReplyToMonoForum#69d66c45 monoforum_peer_id:InputPeer = InputReplyTo; + +exportedStoryLink#3fc9053b link:string = ExportedStoryLink; + +storiesStealthMode#712e27fd flags:# active_until_date:flags.0?int cooldown_until_date:flags.1?int = StoriesStealthMode; + +mediaAreaCoordinates#cfc9e002 flags:# x:double y:double w:double h:double rotation:double radius:flags.0?double = MediaAreaCoordinates; + +mediaAreaVenue#be82db9c coordinates:MediaAreaCoordinates geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MediaArea; +inputMediaAreaVenue#b282217f coordinates:MediaAreaCoordinates query_id:long result_id:string = MediaArea; +mediaAreaGeoPoint#cad5452d flags:# coordinates:MediaAreaCoordinates geo:GeoPoint address:flags.0?GeoPointAddress = MediaArea; +mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; +mediaAreaChannelPost#770416af coordinates:MediaAreaCoordinates channel_id:long msg_id:int = MediaArea; +inputMediaAreaChannelPost#2271f2bf coordinates:MediaAreaCoordinates channel:InputChannel msg_id:int = MediaArea; +mediaAreaUrl#37381085 coordinates:MediaAreaCoordinates url:string = MediaArea; +mediaAreaWeather#49a6549c coordinates:MediaAreaCoordinates emoji:string temperature_c:double color:int = MediaArea; +mediaAreaStarGift#5787686d coordinates:MediaAreaCoordinates slug:string = MediaArea; + +peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; + +stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; + +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; + +premiumGiftCodeOption#257e962b flags:# users:int months:int store_product:flags.0?string store_quantity:flags.1?int currency:string amount:long = PremiumGiftCodeOption; + +payments.checkedGiftCode#eb983f8f flags:# via_giveaway:flags.2?true from_id:flags.4?Peer giveaway_msg_id:flags.3?int to_id:flags.0?long date:int days:int used_date:flags.1?int chats:Vector users:Vector = payments.CheckedGiftCode; + +payments.giveawayInfo#4367daa0 flags:# participating:flags.0?true preparing_results:flags.3?true start_date:int joined_too_early_date:flags.1?int admin_disallowed_chat_id:flags.2?long disallowed_country:flags.4?string = payments.GiveawayInfo; +payments.giveawayInfoResults#e175e66f flags:# winner:flags.0?true refunded:flags.1?true start_date:int gift_code_slug:flags.3?string stars_prize:flags.4?long finish_date:int winners_count:int activated_count:flags.2?int = payments.GiveawayInfo; + +prepaidGiveaway#b2539d54 id:long months:int quantity:int date:int = PrepaidGiveaway; +prepaidStarsGiveaway#9a9d77e0 id:long stars:long quantity:int boosts:int date:int = PrepaidGiveaway; + +boost#4b3e14d6 flags:# gift:flags.1?true giveaway:flags.2?true unclaimed:flags.3?true id:string user_id:flags.0?long giveaway_msg_id:flags.2?int date:int expires:int used_gift_slug:flags.4?string multiplier:flags.5?int stars:flags.6?long = Boost; + +premium.boostsList#86f8613c flags:# count:int boosts:Vector next_offset:flags.0?string users:Vector = premium.BoostsList; + +myBoost#c448415c flags:# slot:int peer:flags.0?Peer date:int expires:int cooldown_until_date:flags.1?int = MyBoost; + +premium.myBoosts#9ae228e2 my_boosts:Vector chats:Vector users:Vector = premium.MyBoosts; + +premium.boostsStatus#4959427a flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int gift_boosts:flags.4?int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string prepaid_giveaways:flags.3?Vector my_boost_slots:flags.2?Vector = premium.BoostsStatus; + +storyFwdHeader#b826e150 flags:# modified:flags.3?true from:flags.0?Peer from_name:flags.1?string story_id:flags.2?int = StoryFwdHeader; + +postInteractionCountersMessage#e7058e7f msg_id:int views:int forwards:int reactions:int = PostInteractionCounters; +postInteractionCountersStory#8a480e27 story_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +stats.storyStats#50cd067c views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.StoryStats; + +publicForwardMessage#1f2bf4a message:Message = PublicForward; +publicForwardStory#edf3add0 peer:Peer story:StoryItem = PublicForward; + +stats.publicForwards#93037e20 flags:# count:int forwards:Vector next_offset:flags.0?string chats:Vector users:Vector = stats.PublicForwards; + +peerColor#b54b5acf flags:# color:flags.0?int background_emoji_id:flags.1?long = PeerColor; +peerColorCollectible#b9c0639a flags:# collectible_id:long gift_emoji_id:long background_emoji_id:long accent_color:int colors:Vector dark_accent_color:flags.0?int dark_colors:flags.1?Vector = PeerColor; +inputPeerColorCollectible#b8ea86a9 collectible_id:long = PeerColor; + +help.peerColorSet#26219a58 colors:Vector = help.PeerColorSet; +help.peerColorProfileSet#767d61eb palette_colors:Vector bg_colors:Vector story_colors:Vector = help.PeerColorSet; + +help.peerColorOption#adec6ebe flags:# hidden:flags.0?true color_id:int colors:flags.1?help.PeerColorSet dark_colors:flags.2?help.PeerColorSet channel_min_level:flags.3?int group_min_level:flags.4?int = help.PeerColorOption; + +help.peerColorsNotModified#2ba1f5ce = help.PeerColors; +help.peerColors#f8ed08 hash:int colors:Vector = help.PeerColors; + +storyReaction#6090d6d5 peer_id:Peer date:int reaction:Reaction = StoryReaction; +storyReactionPublicForward#bbab2643 message:Message = StoryReaction; +storyReactionPublicRepost#cfcd0f13 peer_id:Peer story:StoryItem = StoryReaction; + +stories.storyReactionsList#aa5f789c flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryReactionsList; + +savedDialog#bd87cb6c flags:# pinned:flags.2?true peer:Peer top_message:int = SavedDialog; +monoForumDialog#64407ea7 flags:# unread_mark:flags.3?true nopaid_messages_exception:flags.4?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_reactions_count:int draft:flags.1?DraftMessage = SavedDialog; + +messages.savedDialogs#f83ae221 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsSlice#44ba9dd9 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsNotModified#c01f6fe8 count:int = messages.SavedDialogs; + +savedReactionTag#cb6ff828 flags:# reaction:Reaction title:flags.0?string count:int = SavedReactionTag; + +messages.savedReactionTagsNotModified#889b59ef = messages.SavedReactionTags; +messages.savedReactionTags#3259950a tags:Vector hash:long = messages.SavedReactionTags; + +outboxReadDate#3bb842ac date:int = OutboxReadDate; + +smsjobs.eligibleToJoin#dc8b44cf terms_url:string monthly_sent_sms:int = smsjobs.EligibilityToJoin; + +smsjobs.status#2aee9191 flags:# allow_international:flags.0?true recent_sent:int recent_since:int recent_remains:int total_sent:int total_since:int last_gift_slug:flags.1?string terms_url:string = smsjobs.Status; + +smsJob#e6a1eeb8 job_id:string phone_number:string text:string = SmsJob; + +businessWeeklyOpen#120b1ab9 start_minute:int end_minute:int = BusinessWeeklyOpen; + +businessWorkHours#8c92b098 flags:# open_now:flags.0?true timezone_id:string weekly_open:Vector = BusinessWorkHours; + +businessLocation#ac5c1af7 flags:# geo_point:flags.0?GeoPoint address:string = BusinessLocation; + +inputBusinessRecipients#6f8b32aa flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = InputBusinessRecipients; + +businessRecipients#21108ff7 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = BusinessRecipients; + +businessAwayMessageScheduleAlways#c9b9e2b9 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleOutsideWorkHours#c3f2f501 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleCustom#cc4d9ecc start_date:int end_date:int = BusinessAwayMessageSchedule; + +inputBusinessGreetingMessage#194cb3b shortcut_id:int recipients:InputBusinessRecipients no_activity_days:int = InputBusinessGreetingMessage; + +businessGreetingMessage#e519abab shortcut_id:int recipients:BusinessRecipients no_activity_days:int = BusinessGreetingMessage; + +inputBusinessAwayMessage#832175e0 flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:InputBusinessRecipients = InputBusinessAwayMessage; + +businessAwayMessage#ef156a5c flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:BusinessRecipients = BusinessAwayMessage; + +timezone#ff9289f5 id:string name:string utc_offset:int = Timezone; + +help.timezonesListNotModified#970708cc = help.TimezonesList; +help.timezonesList#7b74ed71 timezones:Vector hash:int = help.TimezonesList; + +quickReply#697102b shortcut_id:int shortcut:string top_message:int count:int = QuickReply; + +inputQuickReplyShortcut#24596d41 shortcut:string = InputQuickReplyShortcut; +inputQuickReplyShortcutId#1190cf1 shortcut_id:int = InputQuickReplyShortcut; + +messages.quickReplies#c68d6695 quick_replies:Vector messages:Vector chats:Vector users:Vector = messages.QuickReplies; +messages.quickRepliesNotModified#5f91eb5b = messages.QuickReplies; + +connectedBot#cd64636c flags:# bot_id:long recipients:BusinessBotRecipients rights:BusinessBotRights = ConnectedBot; + +account.connectedBots#17d7f87b connected_bots:Vector users:Vector = account.ConnectedBots; + +messages.dialogFilters#2ad93719 flags:# tags_enabled:flags.0?true filters:Vector = messages.DialogFilters; + +birthday#6c8e1e06 flags:# day:int month:int year:flags.0?int = Birthday; + +botBusinessConnection#8f34b2f5 flags:# disabled:flags.1?true connection_id:string user_id:long dc_id:int date:int rights:flags.2?BusinessBotRights = BotBusinessConnection; + +inputBusinessIntro#9c469cd flags:# title:string description:string sticker:flags.0?InputDocument = InputBusinessIntro; + +businessIntro#5a0a066d flags:# title:string description:string sticker:flags.0?Document = BusinessIntro; + +messages.myStickers#faff629d count:int sets:Vector = messages.MyStickers; + +inputCollectibleUsername#e39460a9 username:string = InputCollectible; +inputCollectiblePhone#a2e214a4 phone:string = InputCollectible; + +fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo; + +inputBusinessBotRecipients#c4e5921e flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = InputBusinessBotRecipients; + +businessBotRecipients#b88cf373 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = BusinessBotRecipients; + +contactBirthday#1d998733 contact_id:long birthday:Birthday = ContactBirthday; + +contacts.contactBirthdays#114ff30d contacts:Vector users:Vector = contacts.ContactBirthdays; + +missingInvitee#628c9224 flags:# premium_would_allow_invite:flags.0?true premium_required_for_pm:flags.1?true user_id:long = MissingInvitee; + +messages.invitedUsers#7f5defa6 updates:Updates missing_invitees:Vector = messages.InvitedUsers; + +inputBusinessChatLink#11679fa7 flags:# message:string entities:flags.0?Vector title:flags.1?string = InputBusinessChatLink; + +businessChatLink#b4ae666f flags:# link:string message:string entities:flags.0?Vector title:flags.1?string views:int = BusinessChatLink; + +account.businessChatLinks#ec43a2d1 links:Vector chats:Vector users:Vector = account.BusinessChatLinks; + +account.resolvedBusinessChatLinks#9a23af21 flags:# peer:Peer message:string entities:flags.0?Vector chats:Vector users:Vector = account.ResolvedBusinessChatLinks; + +requestedPeerUser#d62ff46a flags:# user_id:long first_name:flags.0?string last_name:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChat#7307544f flags:# chat_id:long title:flags.0?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChannel#8ba403e4 flags:# channel_id:long title:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +sponsoredMessageReportOption#430d3150 text:string option:bytes = SponsoredMessageReportOption; + +channels.sponsoredMessageReportResultChooseOption#846f9e42 title:string options:Vector = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultAdsHidden#3e3bcf2f = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultReported#ad798849 = channels.SponsoredMessageReportResult; + +reactionNotificationsFromContacts#bac3a61a = ReactionNotificationsFrom; +reactionNotificationsFromAll#4b9e22a0 = ReactionNotificationsFrom; + +reactionsNotifySettings#71e4ea58 flags:# messages_notify_from:flags.0?ReactionNotificationsFrom stories_notify_from:flags.1?ReactionNotificationsFrom poll_votes_notify_from:flags.2?ReactionNotificationsFrom sound:NotificationSound show_previews:Bool = ReactionsNotifySettings; + +availableEffect#93c3e27e flags:# premium_required:flags.2?true id:long emoticon:string static_icon_id:flags.0?long effect_sticker_id:long effect_animation_id:flags.1?long = AvailableEffect; + +messages.availableEffectsNotModified#d1ed9a5b = messages.AvailableEffects; +messages.availableEffects#bddb616e hash:int effects:Vector documents:Vector = messages.AvailableEffects; + +factCheck#b89bfccf flags:# need_check:flags.0?true country:flags.1?string text:flags.1?TextWithEntities hash:long = FactCheck; + +starsTransactionPeerUnsupported#95f2bfe4 = StarsTransactionPeer; +starsTransactionPeerAppStore#b457b375 = StarsTransactionPeer; +starsTransactionPeerPlayMarket#7b560a0b = StarsTransactionPeer; +starsTransactionPeerPremiumBot#250dbaf8 = StarsTransactionPeer; +starsTransactionPeerFragment#e92fd902 = StarsTransactionPeer; +starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; +starsTransactionPeerAds#60682812 = StarsTransactionPeer; +starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; + +starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; + +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true stargift_prepaid_upgrade:flags.25?true stargift_drop_original_details:flags.26?true phonegroup_message:flags.27?true stargift_auction_bid:flags.28?true offer:flags.29?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; + +payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; + +foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; + +stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; + +geoPointAddress#de4c5d93 flags:# country_iso2:string state:flags.0?string city:flags.1?string street:flags.2?string = GeoPointAddress; + +starsRevenueStatus#febe5491 flags:# withdrawal_enabled:flags.0?true current_balance:StarsAmount available_balance:StarsAmount overall_revenue:StarsAmount next_withdrawal_at:flags.1?int = StarsRevenueStatus; + +payments.starsRevenueStats#6c207376 flags:# top_hours_graph:flags.0?StatsGraph revenue_graph:StatsGraph status:StarsRevenueStatus usd_rate:double = payments.StarsRevenueStats; + +payments.starsRevenueWithdrawalUrl#1dab80b7 url:string = payments.StarsRevenueWithdrawalUrl; + +payments.starsRevenueAdsAccountUrl#394e7f21 url:string = payments.StarsRevenueAdsAccountUrl; + +inputStarsTransaction#206ae6d1 flags:# refund:flags.0?true id:string = InputStarsTransaction; + +starsGiftOption#5e0589f1 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsGiftOption; + +bots.popularAppBots#1991b13b flags:# next_offset:flags.0?string users:Vector = bots.PopularAppBots; + +botPreviewMedia#23e91ba3 date:int media:MessageMedia = BotPreviewMedia; + +bots.previewInfo#ca71d64 media:Vector lang_codes:Vector = bots.PreviewInfo; + +starsSubscriptionPricing#5416d58 period:int amount:long = StarsSubscriptionPricing; + +starsSubscription#2e6eab1a flags:# canceled:flags.0?true can_refulfill:flags.1?true missing_balance:flags.2?true bot_canceled:flags.7?true id:string peer:Peer until_date:int pricing:StarsSubscriptionPricing chat_invite_hash:flags.3?string title:flags.4?string photo:flags.5?WebDocument invoice_slug:flags.6?string = StarsSubscription; + +messageReactor#4ba3a95a flags:# top:flags.0?true my:flags.1?true anonymous:flags.2?true peer_id:flags.3?Peer count:int = MessageReactor; + +starsGiveawayOption#94ce852a flags:# extended:flags.0?true default:flags.1?true stars:long yearly_boosts:int store_product:flags.2?string currency:string amount:long winners:Vector = StarsGiveawayOption; + +starsGiveawayWinnersOption#54236209 flags:# default:flags.0?true users:int per_user_stars:long = StarsGiveawayWinnersOption; + +starGift#313a9547 flags:# limited:flags.0?true sold_out:flags.1?true birthday:flags.2?true require_premium:flags.7?true limited_per_user:flags.8?true peer_color_available:flags.10?true auction:flags.11?true id:long sticker:Document stars:long availability_remains:flags.0?int availability_total:flags.0?int availability_resale:flags.4?long convert_stars:long first_sale_date:flags.1?int last_sale_date:flags.1?int upgrade_stars:flags.3?long resell_min_stars:flags.4?long title:flags.5?string released_by:flags.6?Peer per_user_total:flags.8?int per_user_remains:flags.8?int locked_until_date:flags.9?int auction_slug:flags.11?string gifts_per_round:flags.11?int auction_start_date:flags.11?int upgrade_variants:flags.12?int background:flags.13?StarGiftBackground = StarGift; +starGiftUnique#85f0a9cd flags:# require_premium:flags.6?true resale_ton_only:flags.7?true theme_available:flags.9?true burned:flags.14?true crafted:flags.15?true id:long gift_id:long title:string slug:string num:int owner_id:flags.0?Peer owner_name:flags.1?string owner_address:flags.2?string attributes:Vector availability_issued:int availability_total:int gift_address:flags.3?string resell_amount:flags.4?Vector released_by:flags.5?Peer value_amount:flags.8?long value_currency:flags.8?string value_usd_amount:flags.8?long theme_peer:flags.10?Peer peer_color:flags.11?PeerColor host_id:flags.12?Peer offer_min_stars:flags.13?int craft_chance_permille:flags.16?int = StarGift; + +payments.starGiftsNotModified#a388a368 = payments.StarGifts; +payments.starGifts#2ed82995 hash:int gifts:Vector chats:Vector users:Vector = payments.StarGifts; + +messageReportOption#7903e3d9 text:string option:bytes = MessageReportOption; + +reportResultChooseOption#f0e4e0b6 title:string options:Vector = ReportResult; +reportResultAddComment#6f09ac31 flags:# optional:flags.0?true option:bytes = ReportResult; +reportResultReported#8db33c4b = ReportResult; + +messages.botPreparedInlineMessage#8ecf0511 id:string expire_date:int = messages.BotPreparedInlineMessage; + +messages.preparedInlineMessage#ff57708d query_id:long result:BotInlineResult peer_types:Vector cache_time:int users:Vector = messages.PreparedInlineMessage; + +botAppSettings#c99b1950 flags:# placeholder_path:flags.0?bytes background_color:flags.1?int background_dark_color:flags.2?int header_color:flags.3?int header_dark_color:flags.4?int = BotAppSettings; + +starRefProgram#dd0c66f2 flags:# bot_id:long commission_permille:int duration_months:flags.0?int end_date:flags.1?int daily_revenue_per_user:flags.2?StarsAmount = StarRefProgram; + +connectedBotStarRef#19a13f71 flags:# revoked:flags.1?true url:string date:int bot_id:long commission_permille:int duration_months:flags.0?int participants:long revenue:long = ConnectedBotStarRef; + +payments.connectedStarRefBots#98d5ea1d count:int connected_bots:Vector users:Vector = payments.ConnectedStarRefBots; + +payments.suggestedStarRefBots#b4d5d859 flags:# count:int suggested_bots:Vector users:Vector next_offset:flags.0?string = payments.SuggestedStarRefBots; + +starsAmount#bbb6b4a3 amount:long nanos:int = StarsAmount; +starsTonAmount#74aee3e0 amount:long = StarsAmount; + +messages.foundStickersNotModified#6010c534 flags:# next_offset:flags.0?int = messages.FoundStickers; +messages.foundStickers#82c9e290 flags:# next_offset:flags.0?int hash:long stickers:Vector = messages.FoundStickers; + +botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string = BotVerifierSettings; + +botVerification#f93cd45c bot_id:long icon:long description:string = BotVerification; + +starGiftAttributeModel#565251e2 flags:# crafted:flags.0?true name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributePattern#4e7085ea name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeBackdrop#9f2504e4 name:string backdrop_id:int center_color:int edge_color:int pattern_color:int text_color:int rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeOriginalDetails#e0bff26c flags:# sender_id:flags.0?Peer recipient_id:Peer date:int message:flags.1?TextWithEntities = StarGiftAttribute; + +payments.starGiftUpgradePreview#3de1dfed sample_attributes:Vector prices:Vector next_prices:Vector = payments.StarGiftUpgradePreview; + +users.users#62d706b8 users:Vector = users.Users; +users.usersSlice#315a4974 count:int users:Vector = users.Users; + +payments.uniqueStarGift#416c56e8 gift:StarGift chats:Vector users:Vector = payments.UniqueStarGift; + +messages.webPagePreview#8c9a88ac media:MessageMedia chats:Vector users:Vector = messages.WebPagePreview; + +savedStarGift#41df43fc flags:# name_hidden:flags.0?true unsaved:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true pinned_to_top:flags.12?true upgrade_separate:flags.17?true from_id:flags.1?Peer date:int gift:StarGift message:flags.2?TextWithEntities msg_id:flags.3?int saved_id:flags.11?long convert_stars:flags.4?long upgrade_stars:flags.6?long can_export_at:flags.7?int transfer_stars:flags.8?long can_transfer_at:flags.13?int can_resell_at:flags.14?int collection_id:flags.15?Vector prepaid_upgrade_hash:flags.16?string drop_original_details_stars:flags.18?long gift_num:flags.19?int can_craft_at:flags.20?int = SavedStarGift; + +payments.savedStarGifts#95f389b1 flags:# count:int chat_notifications_enabled:flags.1?Bool gifts:Vector next_offset:flags.0?string chats:Vector users:Vector = payments.SavedStarGifts; + +inputSavedStarGiftUser#69279795 msg_id:int = InputSavedStarGift; +inputSavedStarGiftChat#f101aa7f peer:InputPeer saved_id:long = InputSavedStarGift; +inputSavedStarGiftSlug#2085c238 slug:string = InputSavedStarGift; + +payments.starGiftWithdrawalUrl#84aa3a9c url:string = payments.StarGiftWithdrawalUrl; + +paidReactionPrivacyDefault#206ad49e = PaidReactionPrivacy; +paidReactionPrivacyAnonymous#1f0c1ad9 = PaidReactionPrivacy; +paidReactionPrivacyPeer#dc6cfcf0 peer:InputPeer = PaidReactionPrivacy; + +account.paidMessagesRevenue#1e109708 stars_amount:long = account.PaidMessagesRevenue; + +requirementToContactEmpty#50a9839 = RequirementToContact; +requirementToContactPremium#e581e4e9 = RequirementToContact; +requirementToContactPaidMessages#b4f67e93 stars_amount:long = RequirementToContact; + +businessBotRights#a0624cf7 flags:# reply:flags.0?true read_messages:flags.1?true delete_sent_messages:flags.2?true delete_received_messages:flags.3?true edit_name:flags.4?true edit_bio:flags.5?true edit_profile_photo:flags.6?true edit_username:flags.7?true view_gifts:flags.8?true sell_gifts:flags.9?true change_gift_settings:flags.10?true transfer_and_upgrade_gifts:flags.11?true transfer_stars:flags.12?true manage_stories:flags.13?true = BusinessBotRights; + +disallowedGiftsSettings#71f276c4 flags:# disallow_unlimited_stargifts:flags.0?true disallow_limited_stargifts:flags.1?true disallow_unique_stargifts:flags.2?true disallow_premium_gifts:flags.3?true disallow_stargifts_from_channels:flags.4?true = DisallowedGiftsSettings; + +sponsoredPeer#c69708d3 flags:# random_id:bytes peer:Peer sponsor_info:flags.0?string additional_info:flags.1?string = SponsoredPeer; + +contacts.sponsoredPeersEmpty#ea32b4b1 = contacts.SponsoredPeers; +contacts.sponsoredPeers#eb032884 peers:Vector chats:Vector users:Vector = contacts.SponsoredPeers; + +starGiftAttributeIdModel#48aaae3c document_id:long = StarGiftAttributeId; +starGiftAttributeIdPattern#4a162433 document_id:long = StarGiftAttributeId; +starGiftAttributeIdBackdrop#1f01c757 backdrop_id:int = StarGiftAttributeId; + +starGiftAttributeCounter#2eb1b658 attribute:StarGiftAttributeId count:int = StarGiftAttributeCounter; + +payments.resaleStarGifts#947a12df flags:# count:int gifts:Vector next_offset:flags.0?string attributes:flags.1?Vector attributes_hash:flags.1?long chats:Vector counters:flags.2?Vector users:Vector = payments.ResaleStarGifts; + +stories.canSendStoryCount#c387c04e count_remains:int = stories.CanSendStoryCount; + +pendingSuggestion#e7e82e12 suggestion:string title:TextWithEntities description:TextWithEntities url:string = PendingSuggestion; + +todoItem#cba9a52f id:int title:TextWithEntities = TodoItem; + +todoList#49b92a26 flags:# others_can_append:flags.0?true others_can_complete:flags.1?true title:TextWithEntities list:Vector = TodoList; + +todoCompletion#221bb5e4 id:int completed_by:Peer date:int = TodoCompletion; + +suggestedPost#e8e37e5 flags:# accepted:flags.1?true rejected:flags.2?true price:flags.3?StarsAmount schedule_date:flags.0?int = SuggestedPost; + +starsRating#1b0e4f07 flags:# level:int current_level_stars:long stars:long next_level_stars:flags.0?long = StarsRating; + +starGiftCollection#9d6b13b0 flags:# collection_id:int title:string icon:flags.0?Document gifts_count:int hash:long = StarGiftCollection; + +payments.starGiftCollectionsNotModified#a0ba4f17 = payments.StarGiftCollections; +payments.starGiftCollections#8a2932f3 collections:Vector = payments.StarGiftCollections; + +storyAlbum#9325705a flags:# album_id:int title:string icon_photo:flags.0?Photo icon_video:flags.1?Document = StoryAlbum; + +stories.albumsNotModified#564edaeb = stories.Albums; +stories.albums#c3987a3a hash:long albums:Vector = stories.Albums; + +searchPostsFlood#3e0b5b6a flags:# query_is_free:flags.0?true total_daily:int remains:int wait_till:flags.1?int stars_amount:long = SearchPostsFlood; + +payments.uniqueStarGiftValueInfo#512fe446 flags:# last_sale_on_fragment:flags.1?true value_is_average:flags.6?true currency:string value:long initial_sale_date:int initial_sale_stars:long initial_sale_price:long last_sale_date:flags.0?int last_sale_price:flags.0?long floor_price:flags.2?long average_price:flags.3?long listed_count:flags.4?int fragment_listed_count:flags.5?int fragment_listed_url:flags.5?string = payments.UniqueStarGiftValueInfo; + +profileTabPosts#b98cd696 = ProfileTab; +profileTabGifts#4d4bd46a = ProfileTab; +profileTabMedia#72c64955 = ProfileTab; +profileTabFiles#ab339c00 = ProfileTab; +profileTabMusic#9f27d26e = ProfileTab; +profileTabVoice#e477092e = ProfileTab; +profileTabLinks#d3656499 = ProfileTab; +profileTabGifs#a2c0f695 = ProfileTab; + +users.savedMusicNotModified#e3878aa4 count:int = users.SavedMusic; +users.savedMusic#34a2f297 count:int documents:Vector = users.SavedMusic; + +account.savedMusicIdsNotModified#4fc81d6e = account.SavedMusicIds; +account.savedMusicIds#998d6636 ids:Vector = account.SavedMusicIds; + +payments.checkCanSendGiftResultOk#374fa7ad = payments.CheckCanSendGiftResult; +payments.checkCanSendGiftResultFail#d5e58274 reason:TextWithEntities = payments.CheckCanSendGiftResult; + +inputChatThemeEmpty#83268483 = InputChatTheme; +inputChatTheme#c93de95c emoticon:string = InputChatTheme; +inputChatThemeUniqueGift#87e5dfe4 slug:string = InputChatTheme; + +starGiftUpgradePrice#99ea331d date:int upgrade_stars:long = StarGiftUpgradePrice; + +groupCallMessage#1a8afc7e flags:# from_admin:flags.1?true id:int from_id:Peer date:int message:TextWithEntities paid_message_stars:flags.0?long = GroupCallMessage; + +groupCallDonor#ee430c85 flags:# top:flags.0?true my:flags.1?true peer_id:flags.3?Peer stars:long = GroupCallDonor; + +phone.groupCallStars#9d1dbd26 total_stars:long top_donors:Vector chats:Vector users:Vector = phone.GroupCallStars; + +recentStory#711d692d flags:# live:flags.0?true max_id:flags.1?int = RecentStory; + +auctionBidLevel#310240cc pos:int amount:long date:int = AuctionBidLevel; + +starGiftAuctionStateNotModified#fe333952 = StarGiftAuctionState; +starGiftAuctionState#771a4e66 version:int start_date:int end_date:int min_bid_amount:long bid_levels:Vector top_bidders:Vector next_round_at:int last_gift_num:int gifts_left:int current_round:int total_rounds:int rounds:Vector = StarGiftAuctionState; +starGiftAuctionStateFinished#972dabbf flags:# start_date:int end_date:int average_price:long listed_count:flags.0?int fragment_listed_count:flags.1?int fragment_listed_url:flags.1?string = StarGiftAuctionState; + +starGiftAuctionUserState#2eeed1c4 flags:# returned:flags.1?true bid_amount:flags.0?long bid_date:flags.0?int min_bid_amount:flags.0?long bid_peer:flags.0?Peer acquired_count:int = StarGiftAuctionUserState; + +payments.starGiftAuctionState#6b39f4ec gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState timeout:int users:Vector chats:Vector = payments.StarGiftAuctionState; + +starGiftAuctionAcquiredGift#42b00348 flags:# name_hidden:flags.0?true peer:Peer date:int bid_amount:long round:int pos:int message:flags.1?TextWithEntities gift_num:flags.2?int = StarGiftAuctionAcquiredGift; + +payments.starGiftAuctionAcquiredGifts#7d5bd1f0 gifts:Vector users:Vector chats:Vector = payments.StarGiftAuctionAcquiredGifts; + +starGiftActiveAuctionState#d31bc45d gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState = StarGiftActiveAuctionState; + +payments.starGiftActiveAuctionsNotModified#db33dad0 = payments.StarGiftActiveAuctions; +payments.starGiftActiveAuctions#aef6abbc auctions:Vector users:Vector chats:Vector = payments.StarGiftActiveAuctions; + +inputStarGiftAuction#2e16c98 gift_id:long = InputStarGiftAuction; +inputStarGiftAuctionSlug#7ab58308 slug:string = InputStarGiftAuction; + +passkey#98613ebf flags:# id:string name:string date:int software_emoji_id:flags.0?long last_usage_date:flags.1?int = Passkey; + +account.passkeys#f8e0aa1c passkeys:Vector = account.Passkeys; + +account.passkeyRegistrationOptions#e16b5ce1 options:DataJSON = account.PasskeyRegistrationOptions; + +auth.passkeyLoginOptions#e2037789 options:DataJSON = auth.PasskeyLoginOptions; + +inputPasskeyResponseRegister#3e63935c client_data:DataJSON attestation_data:bytes = InputPasskeyResponse; +inputPasskeyResponseLogin#c31fc14a client_data:DataJSON authenticator_data:bytes signature:bytes user_handle:string = InputPasskeyResponse; + +inputPasskeyCredentialPublicKey#3c27b78f id:string raw_id:string response:InputPasskeyResponse = InputPasskeyCredential; +inputPasskeyCredentialFirebasePNV#5b1ccb28 pnv_token:string = InputPasskeyCredential; + +starGiftBackground#aff56398 center_color:int edge_color:int text_color:int = StarGiftBackground; + +starGiftAuctionRound#3aae0528 num:int duration:int = StarGiftAuctionRound; +starGiftAuctionRoundExtendable#aa021e5 num:int duration:int extend_top:int extend_window:int = StarGiftAuctionRound; + +payments.starGiftUpgradeAttributes#46c6e36f attributes:Vector = payments.StarGiftUpgradeAttributes; + +messages.emojiGameOutcome#da2ad647 seed:bytes stake_ton_amount:long ton_amount:long = messages.EmojiGameOutcome; + +messages.emojiGameUnavailable#59e65335 = messages.EmojiGameInfo; +messages.emojiGameDiceInfo#44e56023 flags:# game_hash:string prev_stake:long current_streak:int params:Vector plays_left:flags.0?int = messages.EmojiGameInfo; + +starGiftAttributeRarity#36437737 permille:int = StarGiftAttributeRarity; +starGiftAttributeRarityUncommon#dbce6389 = StarGiftAttributeRarity; +starGiftAttributeRarityRare#f08d516b = StarGiftAttributeRarity; +starGiftAttributeRarityEpic#78fbf3a8 = StarGiftAttributeRarity; +starGiftAttributeRarityLegendary#cef7e7a8 = StarGiftAttributeRarity; + +keyboardButtonStyle#4fdd3430 flags:# bg_primary:flags.0?true bg_danger:flags.1?true bg_success:flags.2?true icon:flags.3?long = KeyboardButtonStyle; + +inputMessageReadMetric#402b4495 msg_id:int view_id:long time_in_view_ms:int active_time_in_view_ms:int height_to_viewport_ratio_permille:int seen_range_ratio_permille:int = InputMessageReadMetric; + +bots.exportedBotToken#3c60b621 token:string = bots.ExportedBotToken; + +bots.requestedButton#f13bbcd7 webapp_req_id:string = bots.RequestedButton; + +messages.composedMessageWithAI#90d7adfa flags:# result_text:TextWithEntities diff_text:flags.0?TextWithEntities = messages.ComposedMessageWithAI; + +stats.pollStats#2999beed votes_graph:StatsGraph = stats.PollStats; + +inputAiComposeToneDefault#1fe9a9bf tone:string = InputAiComposeTone; +inputAiComposeToneID#773c080 id:long access_hash:long = InputAiComposeTone; +inputAiComposeToneSlug#1fa01357 slug:string = InputAiComposeTone; + +aiComposeTone#cff63ea9 flags:# creator:flags.0?true id:long access_hash:long slug:string title:string emoji_id:flags.1?long prompt:flags.4?string installs_count:flags.2?int author_id:flags.3?long example_english:flags.5?AiComposeToneExample = AiComposeTone; +aiComposeToneDefault#9bad6414 tone:string emoji_id:long title:string = AiComposeTone; + +aicompose.tonesNotModified#c1f46103 = aicompose.Tones; +aicompose.tones#6c9d0efe hash:long tones:Vector users:Vector = aicompose.Tones; + +aiComposeToneExample#f1d628ec from:TextWithEntities to:TextWithEntities = AiComposeToneExample; + +bots.accessSettings#dd1fbf93 flags:# restricted:flags.0?true add_users:flags.1?Vector = bots.AccessSettings; + +---functions--- + +invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; +invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; +invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; +invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; +invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; +invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; +invokeWithBusinessConnection#dd289f8e {X:Type} connection_id:string query:!X = X; +invokeWithGooglePlayIntegrity#1df92984 {X:Type} nonce:string token:string query:!X = X; +invokeWithApnsSecret#0dae54f8 {X:Type} nonce:string secret:string query:!X = X; +invokeWithReCaptcha#adbb0f94 {X:Type} token:string query:!X = X; + +auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; +auth.signUp#aac7b717 flags:# no_joined_notifications:flags.0?true phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +auth.signIn#8d52a951 flags:# phone_number:string phone_code_hash:string phone_code:flags.0?string email_verification:flags.1?EmailVerification = auth.Authorization; +auth.logOut#3e72ba19 = auth.LoggedOut; +auth.resetAuthorizations#9fab0d1a = Bool; +auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; +auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; +auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; +auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; +auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; +auth.resendCode#cae47523 flags:# phone_number:string phone_code_hash:string reason:flags.0?string = auth.SentCode; +auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; +auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; +auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; +auth.acceptLoginToken#e894ad4d token:bytes = Authorization; +auth.checkRecoveryPassword#d36bf79 code:string = Bool; +auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; +auth.requestFirebaseSms#8e39261e flags:# phone_number:string phone_code_hash:string safety_net_token:flags.0?string play_integrity_token:flags.2?string ios_push_secret:flags.1?string = Bool; +auth.resetLoginEmail#7e960193 phone_number:string phone_code_hash:string = auth.SentCode; +auth.reportMissingCode#cb9deff6 phone_number:string phone_code_hash:string mnc:string = Bool; +auth.checkPaidAuth#56e59f9c phone_number:string phone_code_hash:string form_id:long = auth.SentCode; +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; + +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; +account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; +account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; +account.resetNotifySettings#db7e1747 = Bool; +account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; +account.updateStatus#6628562c offline:Bool = Bool; +account.getWallPapers#7967d36 hash:long = account.WallPapers; +account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; +account.checkUsername#2714d86c username:string = Bool; +account.updateUsername#3e0bdd7c username:string = User; +account.getPrivacy#dadbc950 key:InputPrivacyKey = account.PrivacyRules; +account.setPrivacy#c9f81ce8 key:InputPrivacyKey rules:Vector = account.PrivacyRules; +account.deleteAccount#a2c0cf74 flags:# reason:string password:flags.0?InputCheckPasswordSRP = Bool; +account.getAccountTTL#8fc711d = AccountDaysTTL; +account.setAccountTTL#2442485e ttl:AccountDaysTTL = Bool; +account.sendChangePhoneCode#82574ae5 phone_number:string settings:CodeSettings = auth.SentCode; +account.changePhone#70c32edb phone_number:string phone_code_hash:string phone_code:string = User; +account.updateDeviceLocked#38df3532 period:int = Bool; +account.getAuthorizations#e320c158 = account.Authorizations; +account.resetAuthorization#df77f3bc hash:long = Bool; +account.getPassword#548a30f5 = account.Password; +account.getPasswordSettings#9cd4eaf9 password:InputCheckPasswordSRP = account.PasswordSettings; +account.updatePasswordSettings#a59b102f password:InputCheckPasswordSRP new_settings:account.PasswordInputSettings = Bool; +account.sendConfirmPhoneCode#1b3faa88 hash:string settings:CodeSettings = auth.SentCode; +account.confirmPhone#5f2178c3 phone_code_hash:string phone_code:string = Bool; +account.getTmpPassword#449e0b51 password:InputCheckPasswordSRP period:int = account.TmpPassword; +account.getWebAuthorizations#182e6d6f = account.WebAuthorizations; +account.resetWebAuthorization#2d01b9ef hash:long = Bool; +account.resetWebAuthorizations#682d2594 = Bool; +account.getAllSecureValues#b288bc7d = Vector; +account.getSecureValue#73665bc2 types:Vector = Vector; +account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; +account.deleteSecureValue#b880bc4b types:Vector = Bool; +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; +account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; +account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; +account.sendVerifyEmailCode#98e037bb purpose:EmailVerifyPurpose email:string = account.SentEmailCode; +account.verifyEmail#32da4cf purpose:EmailVerifyPurpose verification:EmailVerification = account.EmailVerified; +account.initTakeoutSession#8ef3eab0 flags:# contacts:flags.0?true message_users:flags.1?true message_chats:flags.2?true message_megagroups:flags.3?true message_channels:flags.4?true files:flags.5?true file_max_size:flags.5?long = account.Takeout; +account.finishTakeoutSession#1d2652ee flags:# success:flags.0?true = Bool; +account.confirmPasswordEmail#8fdf1920 code:string = Bool; +account.resendPasswordEmail#7a7f2a15 = Bool; +account.cancelPasswordEmail#c1cbd5b6 = Bool; +account.getContactSignUpNotification#9f07c728 = Bool; +account.setContactSignUpNotification#cff43f61 silent:Bool = Bool; +account.getNotifyExceptions#53577479 flags:# compare_sound:flags.1?true compare_stories:flags.2?true peer:flags.0?InputNotifyPeer = Updates; +account.getWallPaper#fc8ddbea wallpaper:InputWallPaper = WallPaper; +account.uploadWallPaper#e39a8f03 flags:# for_chat:flags.0?true file:InputFile mime_type:string settings:WallPaperSettings = WallPaper; +account.saveWallPaper#6c5a5b37 wallpaper:InputWallPaper unsave:Bool settings:WallPaperSettings = Bool; +account.installWallPaper#feed5769 wallpaper:InputWallPaper settings:WallPaperSettings = Bool; +account.resetWallPapers#bb3b9804 = Bool; +account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; +account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; +account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; +account.getTheme#3a5869ec format:string theme:InputTheme = Theme; +account.getThemes#7206e458 format:string hash:long = account.Themes; +account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; +account.getContentSettings#8b9b4dae = account.ContentSettings; +account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; +account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; +account.resetPassword#9308ce1b = account.ResetPasswordResult; +account.declinePasswordReset#4c9409f6 = Bool; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# confirmed:flags.3?true hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; +account.getSavedRingtones#e1902288 hash:long = account.SavedRingtones; +account.saveRingtone#3dea5b03 id:InputDocument unsave:Bool = account.SavedRingtone; +account.uploadRingtone#831a83a2 file:InputFile file_name:string mime_type:string = Document; +account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; +account.getDefaultEmojiStatuses#d6753386 hash:long = account.EmojiStatuses; +account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; +account.clearRecentEmojiStatuses#18201aae = Bool; +account.reorderUsernames#ef500eab order:Vector = Bool; +account.toggleUsername#58d6b376 username:string active:Bool = Bool; +account.getDefaultProfilePhotoEmojis#e2750328 hash:long = EmojiList; +account.getDefaultGroupPhotoEmojis#915860ae hash:long = EmojiList; +account.getAutoSaveSettings#adcbbcda = account.AutoSaveSettings; +account.saveAutoSaveSettings#d69b8361 flags:# users:flags.0?true chats:flags.1?true broadcasts:flags.2?true peer:flags.3?InputPeer settings:AutoSaveSettings = Bool; +account.deleteAutoSaveExceptions#53bc0020 = Bool; +account.invalidateSignInCodes#ca8ae8ba codes:Vector = Bool; +account.updateColor#684d214e flags:# for_profile:flags.1?true color:flags.2?PeerColor = Bool; +account.getDefaultBackgroundEmojis#a60ab9ce hash:long = EmojiList; +account.getChannelDefaultEmojiStatuses#7727a7d5 hash:long = account.EmojiStatuses; +account.getChannelRestrictedStatusEmojis#35a9e0d5 hash:long = EmojiList; +account.updateBusinessWorkHours#4b00e066 flags:# business_work_hours:flags.0?BusinessWorkHours = Bool; +account.updateBusinessLocation#9e6b131a flags:# geo_point:flags.1?InputGeoPoint address:flags.0?string = Bool; +account.updateBusinessGreetingMessage#66cdafc4 flags:# message:flags.0?InputBusinessGreetingMessage = Bool; +account.updateBusinessAwayMessage#a26a7fa5 flags:# message:flags.0?InputBusinessAwayMessage = Bool; +account.updateConnectedBot#66a08c7e flags:# deleted:flags.1?true rights:flags.0?BusinessBotRights bot:InputUser recipients:InputBusinessBotRecipients = Updates; +account.getConnectedBots#4ea4c80f = account.ConnectedBots; +account.getBotBusinessConnection#76a86270 connection_id:string = Updates; +account.updateBusinessIntro#a614d034 flags:# intro:flags.0?InputBusinessIntro = Bool; +account.toggleConnectedBotPaused#646e1097 peer:InputPeer paused:Bool = Bool; +account.disablePeerConnectedBot#5e437ed9 peer:InputPeer = Bool; +account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool; +account.createBusinessChatLink#8851e68e link:InputBusinessChatLink = BusinessChatLink; +account.editBusinessChatLink#8c3410af slug:string link:InputBusinessChatLink = BusinessChatLink; +account.deleteBusinessChatLink#60073674 slug:string = Bool; +account.getBusinessChatLinks#6f70dde1 = account.BusinessChatLinks; +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; +account.updatePersonalChannel#d94305e0 channel:InputChannel = Bool; +account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; +account.getReactionsNotifySettings#6dd654c = ReactionsNotifySettings; +account.setReactionsNotifySettings#316ce548 settings:ReactionsNotifySettings = ReactionsNotifySettings; +account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; +account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; +account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; +account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; +account.saveMusic#b26732a9 flags:# unsave:flags.0?true id:InputDocument after_id:flags.1?InputDocument = Bool; +account.getSavedMusicIds#e09d5faf hash:long = account.SavedMusicIds; +account.getUniqueGiftChatThemes#e42ce9c9 offset:string limit:int hash:long = account.ChatThemes; +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; +account.getPasskeys#ea1f0c52 = account.Passkeys; +account.deletePasskey#f5b5563f id:string = Bool; + +users.getUsers#d91a548 id:Vector = Vector; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; +users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; +users.getRequirementsToContact#d89a83a3 id:Vector = Vector; +users.getSavedMusic#788d7fe3 id:InputUser offset:int limit:int hash:long = users.SavedMusic; +users.getSavedMusicByID#7573a4e9 id:InputUser documents:Vector = users.SavedMusic; +users.suggestBirthday#fc533372 id:InputUser birthday:Birthday = Updates; + +contacts.getContactIDs#7adc669d hash:long = Vector; +contacts.getStatuses#c4a353ee = Vector; +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; +contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; +contacts.deleteContacts#96a0e00 id:Vector = Updates; +contacts.deleteByPhones#1013fd9e phones:Vector = Bool; +contacts.block#2e2e8734 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.unblock#b550d328 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.getBlocked#9a868f80 flags:# my_stories_from:flags.0?true offset:int limit:int = contacts.Blocked; +contacts.search#11f812d8 q:string limit:int = contacts.Found; +contacts.resolveUsername#725afbbc flags:# username:string referer:flags.0?string = contacts.ResolvedPeer; +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true bots_app:flags.16?true offset:int limit:int hash:long = contacts.TopPeers; +contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; +contacts.resetSaved#879537f1 = Bool; +contacts.getSaved#82f1e39f = Vector; +contacts.toggleTopPeers#8514bdda enabled:Bool = Bool; +contacts.addContact#d9ba2e54 flags:# add_phone_privacy_exception:flags.0?true id:InputUser first_name:string last_name:string phone:string note:flags.1?TextWithEntities = Updates; +contacts.acceptContact#f831a20f id:InputUser = Updates; +contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; +contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; +contacts.exportContactToken#f8654027 = ExportedContactToken; +contacts.importContactToken#13005788 token:string = User; +contacts.editCloseFriends#ba6705f0 id:Vector = Bool; +contacts.setBlocked#94c65c76 flags:# my_stories_from:flags.0?true id:Vector limit:int = Bool; +contacts.getBirthdays#daeda864 = contacts.ContactBirthdays; +contacts.getSponsoredPeers#b6c8c393 q:string = contacts.SponsoredPeers; +contacts.updateContactNote#139f63fb id:InputUser note:TextWithEntities = Bool; + +messages.getMessages#63c66506 id:Vector = messages.Messages; +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; +messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.search#29ee847a flags:# peer:InputPeer q:string from_id:flags.0?InputPeer saved_peer_id:flags.2?InputPeer saved_reaction:flags.3?Vector top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; +messages.receivedMessages#5a954c0 max_id:int = Vector; +messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; +messages.sendMessage#545cd15a flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.sendMedia#330e77f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.forwardMessages#13704a7c flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true allow_paid_floodskip:flags.19?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer top_msg_id:flags.9?int reply_to:flags.22?InputReplyTo schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long video_timestamp:flags.20?int allow_paid_stars:flags.21?long suggested_post:flags.23?SuggestedPost = Updates; +messages.reportSpam#cf1592db peer:InputPeer = Bool; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; +messages.report#fc78af9b peer:InputPeer id:Vector option:bytes message:string = ReportResult; +messages.getChats#49e9528f id:Vector = messages.Chats; +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; +messages.addChatUser#cbc6d107 chat_id:long user_id:InputUser fwd_limit:int = messages.InvitedUsers; +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; +messages.createChat#92ceddd4 flags:# users:Vector title:string ttl_period:flags.0?int = messages.InvitedUsers; +messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; +messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; +messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; +messages.discardEncryption#f393aea0 flags:# delete_history:flags.0?true chat_id:int = Bool; +messages.setEncryptedTyping#791451ed peer:InputEncryptedChat typing:Bool = Bool; +messages.readEncryptedHistory#7f4b690a peer:InputEncryptedChat max_date:int = Bool; +messages.sendEncrypted#44fa7a15 flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.sendEncryptedFile#5559481d flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes file:InputEncryptedFile = messages.SentEncryptedMessage; +messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.receivedQueue#55a5bb66 max_qts:int = Vector; +messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; +messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; +messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; +messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; +messages.getWebPagePreview#570d6f6f flags:# message:string entities:flags.3?Vector = messages.WebPagePreview; +messages.exportChatInvite#a455de90 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string subscription_pricing:flags.5?StarsSubscriptionPricing = ExportedChatInvite; +messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; +messages.importChatInvite#6c50051c hash:string = Updates; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; +messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; +messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; +messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; +messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; +messages.migrateChat#a2875319 chat_id:long = Updates; +messages.searchGlobal#4bc6589a flags:# broadcasts_only:flags.1?true groups_only:flags.2?true users_only:flags.3?true folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; +messages.reorderStickerSets#78337739 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Bool; +messages.getDocumentByHash#b1f2061f sha256:bytes size:long mime_type:string = Document; +messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; +messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; +messages.setInlineBotResults#bb12a419 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM switch_webview:flags.4?InlineBotWebView = Bool; +messages.sendInlineBotResult#c0cf7646 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to:flags.0?InputReplyTo random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut allow_paid_stars:flags.21?long = Updates; +messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; +messages.editMessage#51e842e1 flags:# no_webpage:flags.1?true invert_media:flags.16?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int schedule_repeat_period:flags.18?int quick_reply_shortcut_id:flags.17?int = Updates; +messages.editInlineBotMessage#83557dba flags:# no_webpage:flags.1?true invert_media:flags.16?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; +messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer; +messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; +messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; +messages.saveDraft#54ae308e flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo peer:InputPeer message:string entities:flags.3?Vector media:flags.5?InputMedia effect:flags.7?long suggested_post:flags.8?SuggestedPost = Bool; +messages.getAllDrafts#6a3f8d65 = Updates; +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; +messages.readFeaturedStickers#5b118126 id:Vector = Bool; +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; +messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; +messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true emojis:flags.1?true offset_id:long limit:int = messages.ArchivedStickers; +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; +messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; +messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; +messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; +messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; +messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; +messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; +messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; +messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?string shipping_options:flags.1?Vector = Bool; +messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; +messages.uploadMedia#14967978 flags:# business_connection_id:flags.0?string peer:InputPeer media:InputMedia = MessageMedia; +messages.sendScreenshotNotification#a1405817 peer:InputPeer reply_to:InputReplyTo random_id:long = Updates; +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; +messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; +messages.getUnreadMentions#f107e790 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readMentions#36e5bf4d flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; +messages.sendMultiMedia#1bf89d74 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long = Updates; +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSplitRanges#1cff7e08 = Vector; +messages.markDialogUnread#8c5006f8 flags:# unread:flags.0?true parent_peer:flags.1?InputPeer peer:InputDialogPeer = Bool; +messages.getDialogUnreadMarks#21202222 flags:# parent_peer:flags.0?InputPeer = Vector; +messages.clearAllDrafts#7e58ee9c = Bool; +messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1?true pm_oneside:flags.2?true peer:InputPeer id:int = Updates; +messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; +messages.getPollResults#eda3e33b peer:InputPeer msg_id:int poll_hash:long = Updates; +messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; +messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; +messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; +messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; +messages.getEmojiKeywordsDifference#1508b6af lang_code:string from_version:int = EmojiKeywordsDifference; +messages.getEmojiKeywordsLanguages#4e9963b2 lang_codes:Vector = Vector; +messages.getEmojiURL#d5b10c26 lang_code:string = EmojiURL; +messages.getSearchCounters#1bbcf300 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer top_msg_id:flags.0?int filters:Vector = Vector; +messages.requestUrlAuth#894cc99c flags:# peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string in_app_origin:flags.3?string = UrlAuthResult; +messages.acceptUrlAuth#67a3f0de flags:# write_allowed:flags.0?true share_phone_number:flags.3?true peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string match_code:flags.4?string = UrlAuthResult; +messages.hidePeerSettingsBar#4facb138 peer:InputPeer = Bool; +messages.getScheduledHistory#f516760b peer:InputPeer hash:long = messages.Messages; +messages.getScheduledMessages#bdbb0464 peer:InputPeer id:Vector = messages.Messages; +messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; +messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; +messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; +messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; +messages.getDialogFilters#efd48c89 = messages.DialogFilters; +messages.getSuggestedDialogFilters#a29cd42c = Vector; +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; +messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; +messages.unpinAllMessages#62dd747 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; +messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; +messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; +messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:string media:InputMedia = MessageMedia; +messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; +messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; +messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true subscription_expired:flags.3?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; +messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; +messages.setChatTheme#81202c9 peer:InputPeer theme:InputChatTheme = Updates; +messages.getMessageReadParticipants#31c1c44f peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#6aa3f6bd flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#9c7f2f10 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b2081a35 flags:# peer:InputPeer enabled:Bool request_msg_id:flags.0?int = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#d30d78d4 flags:# big:flags.1?true add_to_recent:flags.2?true peer:InputPeer msg_id:int reaction:flags.0?Vector = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#864b2581 flags:# peer:InputPeer available_reactions:ChatReactions reactions_limit:flags.0?int paid_enabled:flags.1?Bool = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool; +messages.translateText#a5eec345 flags:# peer:flags.0?InputPeer id:flags.0?Vector text:flags.1?Vector to_lang:string tone:flags.2?string = messages.TranslatedText; +messages.getUnreadReactions#bd7f90ac flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#9ec44f93 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; +messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; +messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; +messages.toggleBotInAttachMenu#69f59d69 flags:# write_allowed:flags.0?true bot:InputUser enabled:Bool = Bool; +messages.requestWebView#269dc2c1 flags:# from_bot_menu:flags.4?true silent:flags.5?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser url:flags.1?string start_param:flags.3?string theme_params:flags.2?DataJSON platform:string reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = WebViewResult; +messages.prolongWebView#b0d81a83 flags:# silent:flags.5?true peer:InputPeer bot:InputUser query_id:long reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = Bool; +messages.requestSimpleWebView#413a3e73 flags:# from_switch_webview:flags.1?true from_side_menu:flags.2?true compact:flags.7?true fullscreen:flags.8?true bot:InputUser url:flags.3?string start_param:flags.4?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent; +messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates; +messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio; +messages.rateTranscribedAudio#7f1d072f peer:InputPeer msg_id:int transcription_id:long good:Bool = Bool; +messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; +messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; +messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; +messages.reportReaction#3f64c076 peer:InputPeer id:int reaction_peer:InputPeer = Bool; +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; +messages.clearRecentReactions#9dfeefb4 = Bool; +messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; +messages.sendBotRequestedPeer#6c5cf2a7 flags:# peer:InputPeer msg_id:flags.0?int webapp_req_id:flags.1?string button_id:int requested_peers:Vector = Updates; +messages.getEmojiGroups#7488ce5b hash:int = messages.EmojiGroups; +messages.getEmojiStatusGroups#2ecd56cd hash:int = messages.EmojiGroups; +messages.getEmojiProfilePhotoGroups#21a548f3 hash:int = messages.EmojiGroups; +messages.searchCustomEmoji#2c11c0d7 emoticon:string hash:long = EmojiList; +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; +messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; +messages.requestAppWebView#53618bce flags:# write_allowed:flags.0?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = WebViewResult; +messages.setChatWallPaper#8ffacae1 flags:# for_both:flags.3?true revert:flags.4?true peer:InputPeer wallpaper:flags.0?InputWallPaper settings:flags.2?WallPaperSettings id:flags.1?int = Updates; +messages.searchEmojiStickerSets#92b4494c flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSavedDialogs#1e91fc99 flags:# exclude_pinned:flags.0?true parent_peer:flags.1?InputPeer offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; +messages.getSavedHistory#998ab009 flags:# parent_peer:flags.0?InputPeer peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.deleteSavedHistory#4dc5085f flags:# parent_peer:flags.0?InputPeer peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; +messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedSavedDialogs#8b716587 flags:# force:flags.0?true order:Vector = Bool; +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; +messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; +messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; +messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; +messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; +messages.reorderQuickReplies#60331907 order:Vector = Bool; +messages.checkQuickReplyShortcut#f1d0fbd3 shortcut:string = Bool; +messages.editQuickReplyShortcut#5c003cef shortcut_id:int shortcut:string = Bool; +messages.deleteQuickReplyShortcut#3cc04740 shortcut_id:int = Bool; +messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; +messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; +messages.deleteQuickReplyMessages#e105e910 shortcut_id:int id:Vector = Updates; +messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool; +messages.getMyStickers#d0b5e1fc offset_id:long limit:int = messages.MyStickers; +messages.getEmojiStickerGroups#1dd840f5 hash:int = messages.EmojiGroups; +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; +messages.editFactCheck#589ee75 peer:InputPeer msg_id:int text:TextWithEntities = Updates; +messages.deleteFactCheck#d1da940c peer:InputPeer msg_id:int = Updates; +messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; +messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendPaidReaction#58bbcb50 flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?PaidReactionPrivacy = Updates; +messages.togglePaidReactionPrivacy#435885b5 peer:InputPeer msg_id:int private:PaidReactionPrivacy = Bool; +messages.getPaidReactionPrivacy#472455aa = Updates; +messages.viewSponsoredMessage#269e3643 random_id:bytes = Bool; +messages.clickSponsoredMessage#8235057e flags:# media:flags.0?true fullscreen:flags.1?true random_id:bytes = Bool; +messages.reportSponsoredMessage#12cbf0c4 random_id:bytes option:bytes = channels.SponsoredMessageReportResult; +messages.getSponsoredMessages#3d6ce850 flags:# peer:InputPeer msg_id:flags.0?int = messages.SponsoredMessages; +messages.savePreparedInlineMessage#f21f7f2f flags:# result:InputBotInlineResult user_id:InputUser peer_types:flags.0?Vector = messages.BotPreparedInlineMessage; +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; +messages.searchStickers#29b1c66a flags:# emojis:flags.0?true q:string emoticon:string lang_code:Vector offset:int limit:int hash:long = messages.FoundStickers; +messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = Bool; +messages.getSavedDialogsByID#6f6f9c96 flags:# parent_peer:flags.1?InputPeer ids:Vector = messages.SavedDialogs; +messages.readSavedHistory#ba4a3b5b parent_peer:InputPeer peer:InputPeer max_id:int = Bool; +messages.toggleTodoCompleted#d3e03124 peer:InputPeer msg_id:int completed:Vector incompleted:Vector = Updates; +messages.appendTodoList#21a61057 peer:InputPeer msg_id:int list:Vector = Updates; +messages.toggleSuggestedPostApproval#8107455c flags:# reject:flags.1?true peer:InputPeer msg_id:int schedule_date:flags.0?int reject_comment:flags.2?string = Updates; +messages.getForumTopics#3ba47bff flags:# peer:InputPeer q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; +messages.getForumTopicsByID#af0a4a08 peer:InputPeer topics:Vector = messages.ForumTopics; +messages.editForumTopic#cecc1134 flags:# peer:InputPeer topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; +messages.updatePinnedForumTopic#175df251 peer:InputPeer topic_id:int pinned:Bool = Updates; +messages.reorderPinnedForumTopics#e7841f0 flags:# force:flags.0?true peer:InputPeer order:Vector = Updates; +messages.createForumTopic#2f98c3d5 flags:# title_missing:flags.4?true peer:InputPeer title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; +messages.deleteTopicHistory#d2816f10 peer:InputPeer top_msg_id:int = messages.AffectedHistory; +messages.getEmojiGameInfo#fb7e8ca7 = messages.EmojiGameInfo; +messages.summarizeText#abbbd346 flags:# peer:InputPeer id:int to_lang:flags.0?string tone:flags.2?string = TextWithEntities; +messages.editChatCreator#f743b857 peer:InputPeer user_id:InputUser password:InputCheckPasswordSRP = Updates; +messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User; +messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates; +messages.declineUrlAuth#35436bbc url:string = Bool; +messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool; +messages.composeMessageWithAI#daecc589 flags:# proofread:flags.0?true emojify:flags.3?true text:TextWithEntities translate_to_lang:flags.1?string tone:flags.2?InputAiComposeTone = messages.ComposedMessageWithAI; +messages.reportReadMetrics#4067c5e6 peer:InputPeer metrics:Vector = Bool; +messages.reportMusicListen#ddbcd819 id:InputDocument listened_duration:int = Bool; +messages.addPollAnswer#19bc4b6d peer:InputPeer msg_id:int answer:PollAnswer = Updates; +messages.deletePollAnswer#ac8505a5 peer:InputPeer msg_id:int option:bytes = Updates; +messages.getUnreadPollVotes#43286cf2 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readPollVotes#1720b4d8 flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.setBotGuestChatResult#52b08db query_id:long result:InputBotInlineResult = Bool; +messages.deleteParticipantReactions#a0b80cf8 peer:InputPeer participant:InputPeer = Bool; +messages.deleteParticipantReaction#e3b7f82c peer:InputPeer msg_id:int participant:InputPeer = Updates; + +updates.getState#edd4882a = updates.State; +updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; +updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; + +photos.updateProfilePhoto#9e82039 flags:# fallback:flags.0?true bot:flags.1?InputUser id:InputPhoto = photos.Photo; +photos.uploadProfilePhoto#388a3b5 flags:# fallback:flags.3?true bot:flags.5?InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.4?VideoSize = photos.Photo; +photos.deletePhotos#87cf7f2f id:Vector = Vector; +photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; +photos.uploadContactProfilePhoto#e14c4a71 flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.5?VideoSize = photos.Photo; + +upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; +upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; +upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool; +upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile; +upload.getCdnFile#395f69da file_token:bytes offset:long limit:int = upload.CdnFile; +upload.reuploadCdnFile#9b2754a8 file_token:bytes request_token:bytes = Vector; +upload.getCdnFileHashes#91dc3f31 file_token:bytes offset:long = Vector; +upload.getFileHashes#9156982a location:InputFileLocation offset:long = Vector; + +help.getConfig#c4f9186b = Config; +help.getNearestDc#1fb33026 = NearestDc; +help.getAppUpdate#522d5a7d source:string = help.AppUpdate; +help.getInviteText#4d392343 = help.InviteText; +help.getSupport#9cdf08cd = help.Support; +help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; +help.getCdnConfig#52029342 = CdnConfig; +help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; +help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; +help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; +help.getAppConfig#61e3f854 hash:int = help.AppConfig; +help.saveAppLog#6f02f748 events:Vector = Bool; +help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; +help.getSupportName#d360e72c = help.SupportName; +help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; +help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; +help.getPromoData#c0977421 = help.PromoData; +help.hidePromoData#1e251c95 peer:InputPeer = Bool; +help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; +help.getPremiumPromo#b81b93d4 = help.PremiumPromo; +help.getPeerColors#da80f42f hash:int = help.PeerColors; +help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors; +help.getTimezonesList#49b30240 hash:int = help.TimezonesList; + +channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; +channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; +channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; +channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; +channels.getChannels#a7f6bbb id:Vector = messages.Chats; +channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true forum:flags.5?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; +channels.editAdmin#9a98ad68 flags:# channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:flags.0?string = Updates; +channels.editTitle#566decd0 channel:InputChannel title:string = Updates; +channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; +channels.checkUsername#10e6bd2c channel:InputChannel username:string = Bool; +channels.updateUsername#3514b3de channel:InputChannel username:string = Bool; +channels.joinChannel#24b524c5 channel:InputChannel = Updates; +channels.leaveChannel#f836aa95 channel:InputChannel = Updates; +channels.inviteToChannel#c9e33d54 channel:InputChannel users:Vector = messages.InvitedUsers; +channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; +channels.exportMessageLink#e63fadeb flags:# grouped:flags.0?true thread:flags.1?true channel:InputChannel id:int = ExportedMessageLink; +channels.toggleSignatures#418d549c flags:# signatures_enabled:flags.0?true profiles_enabled:flags.1?true channel:InputChannel = Updates; +channels.getAdminedPublicChannels#f8b036af flags:# by_location:flags.0?true check_limit:flags.1?true for_personal:flags.2?true = messages.Chats; +channels.editBanned#96e6cd81 channel:InputChannel participant:InputPeer banned_rights:ChatBannedRights = Updates; +channels.getAdminLog#33ddf480 flags:# channel:InputChannel q:string events_filter:flags.0?ChannelAdminLogEventsFilter admins:flags.1?Vector max_id:long min_id:long limit:int = channels.AdminLogResults; +channels.setStickers#ea8ca4f9 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = Bool; +channels.deleteHistory#9baa9647 flags:# for_everyone:flags.0?true channel:InputChannel max_id:int = Updates; +channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates; +channels.getLeftChannels#8341ecc0 offset:int = messages.Chats; +channels.getGroupsForDiscussion#f5dad378 = messages.Chats; +channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool; +channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint address:string = Bool; +channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; +channels.getInactiveChannels#11e831ee = messages.InactiveChats; +channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; +channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; +channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; +channels.toggleJoinRequest#4c2985b6 channel:InputChannel enabled:Bool = Updates; +channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; +channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; +channels.deactivateAllUsernames#a245dd3 channel:InputChannel = Bool; +channels.toggleForum#3ff75734 channel:InputChannel enabled:Bool tabs:Bool = Updates; +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; +channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; +channels.updateColor#d8aa3671 flags:# for_profile:flags.1?true channel:InputChannel color:flags.2?int background_emoji_id:flags.0?long = Updates; +channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; +channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; +channels.updateEmojiStatus#f0d3e6a8 channel:InputChannel emoji_status:EmojiStatus = Updates; +channels.setBoostsToUnblockRestrictions#ad399cee channel:InputChannel boosts:int = Updates; +channels.setEmojiStickers#3cd930b7 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.restrictSponsoredMessages#9ae91519 channel:InputChannel restricted:Bool = Updates; +channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; +channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; +channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; +channels.getMessageAuthor#ece2a0e6 channel:InputChannel id:int = User; +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; +channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool; + +bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; +bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; +bots.setBotMenuButton#4504d54f user_id:InputUser button:BotMenuButton = Bool; +bots.getBotMenuButton#9c60eb28 user_id:InputUser = BotMenuButton; +bots.setBotBroadcastDefaultAdminRights#788464e1 admin_rights:ChatAdminRights = Bool; +bots.setBotGroupDefaultAdminRights#925ec9ea admin_rights:ChatAdminRights = Bool; +bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; +bots.getBotInfo#dcd914fd flags:# bot:flags.0?InputUser lang_code:string = bots.BotInfo; +bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = Bool; +bots.toggleUsername#53ca973 bot:InputUser username:string active:Bool = Bool; +bots.canSendMessage#1359f4e6 bot:InputUser = Bool; +bots.allowSendMessage#f132e3ef bot:InputUser = Updates; +bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; +bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots; +bots.addPreviewMedia#17aeb75a bot:InputUser lang_code:string media:InputMedia = BotPreviewMedia; +bots.editPreviewMedia#8525606f bot:InputUser lang_code:string media:InputMedia new_media:InputMedia = BotPreviewMedia; +bots.deletePreviewMedia#2d0135b3 bot:InputUser lang_code:string media:Vector = Bool; +bots.reorderPreviewMedias#b627f3aa bot:InputUser lang_code:string order:Vector = Bool; +bots.getPreviewInfo#423ab3ad bot:InputUser lang_code:string = bots.PreviewInfo; +bots.getPreviewMedias#a2a5594d bot:InputUser = Vector; +bots.updateUserEmojiStatus#ed9f30c5 user_id:InputUser emoji_status:EmojiStatus = Bool; +bots.toggleUserEmojiStatusPermission#6de6392 bot:InputUser enabled:Bool = Bool; +bots.checkDownloadFileParams#50077589 bot:InputUser file_name:string url:string = Bool; +bots.getAdminedBots#b0711d83 = Vector; +bots.updateStarRefProgram#778b5ab3 flags:# bot:InputUser commission_permille:int duration_months:flags.0?int = StarRefProgram; +bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool; +bots.getBotRecommendations#a1b70815 bot:InputUser = users.Users; +bots.checkUsername#87f2219b username:string = Bool; +bots.createBot#e5b17f2b flags:# via_deeplink:flags.0?true name:string username:string manager_id:InputUser = User; +bots.exportBotToken#bd0d99eb bot:InputUser revoke:Bool = bots.ExportedBotToken; +bots.requestWebViewButton#31a2a35e user_id:InputUser button:KeyboardButton = bots.RequestedButton; +bots.getRequestedWebViewButton#bf25b7f3 bot:InputUser webapp_req_id:string = KeyboardButton; +bots.getAccessSettings#213853a3 bot:InputUser = bots.AccessSettings; +bots.editAccessSettings#31813cd8 flags:# restricted:flags.0?true bot:InputUser add_users:flags.1?Vector = Bool; + +payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; +payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; +payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; +payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; +payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; +payments.getBankCardData#2e79d779 number:string = payments.BankCardData; +payments.exportInvoice#f91b065 invoice_media:InputMedia = payments.ExportedInvoice; +payments.assignAppStoreTransaction#80ed747d receipt:bytes purpose:InputStorePaymentPurpose = Updates; +payments.assignPlayMarketTransaction#dffd50d3 receipt:DataJSON purpose:InputStorePaymentPurpose = Updates; +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; +payments.applyGiftCode#f6e26854 slug:string = Updates; +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; +payments.getStarsTopupOptions#c00ec7d3 = Vector; +payments.getStarsStatus#4ea9b3bf flags:# ton:flags.0?true peer:InputPeer = payments.StarsStatus; +payments.getStarsTransactions#69da4557 flags:# inbound:flags.0?true outbound:flags.1?true ascending:flags.2?true ton:flags.4?true subscription_id:flags.3?string peer:InputPeer offset:string limit:int = payments.StarsStatus; +payments.sendStarsForm#7998c914 form_id:long invoice:InputInvoice = payments.PaymentResult; +payments.refundStarsCharge#25ae8f4a user_id:InputUser charge_id:string = Updates; +payments.getStarsRevenueStats#d91ffad6 flags:# dark:flags.0?true ton:flags.1?true peer:InputPeer = payments.StarsRevenueStats; +payments.getStarsRevenueWithdrawalUrl#2433dc92 flags:# ton:flags.0?true peer:InputPeer amount:flags.1?long password:InputCheckPasswordSRP = payments.StarsRevenueWithdrawalUrl; +payments.getStarsRevenueAdsAccountUrl#d1d7efc5 peer:InputPeer = payments.StarsRevenueAdsAccountUrl; +payments.getStarsTransactionsByID#2dca16b8 flags:# ton:flags.0?true peer:InputPeer id:Vector = payments.StarsStatus; +payments.getStarsGiftOptions#d3c96bc8 flags:# user_id:flags.0?InputUser = Vector; +payments.getStarsSubscriptions#32512c5 flags:# missing_balance:flags.0?true peer:InputPeer offset:string = payments.StarsStatus; +payments.changeStarsSubscription#c7770878 flags:# peer:InputPeer subscription_id:string canceled:flags.0?Bool = Bool; +payments.fulfillStarsSubscription#cc5bebb3 peer:InputPeer subscription_id:string = Bool; +payments.getStarsGiveawayOptions#bd1efd3e = Vector; +payments.getStarGifts#c4563590 hash:int = payments.StarGifts; +payments.saveStarGift#2a2a697c flags:# unsave:flags.0?true stargift:InputSavedStarGift = Bool; +payments.convertStarGift#74bf076b stargift:InputSavedStarGift = Bool; +payments.botCancelStarsSubscription#6dfa0622 flags:# restore:flags.0?true user_id:InputUser charge_id:string = Bool; +payments.getConnectedStarRefBots#5869a553 flags:# peer:InputPeer offset_date:flags.2?int offset_link:flags.2?string limit:int = payments.ConnectedStarRefBots; +payments.getConnectedStarRefBot#b7d998f0 peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.getSuggestedStarRefBots#d6b48f7 flags:# order_by_revenue:flags.0?true order_by_date:flags.1?true peer:InputPeer offset:string limit:int = payments.SuggestedStarRefBots; +payments.connectStarRefBot#7ed5348a peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.editConnectedStarRefBot#e4fca4a3 flags:# revoked:flags.0?true peer:InputPeer link:string = payments.ConnectedStarRefBots; +payments.getStarGiftUpgradePreview#9c9abcb1 gift_id:long = payments.StarGiftUpgradePreview; +payments.upgradeStarGift#aed6e4f5 flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = Updates; +payments.transferStarGift#7f18176a stargift:InputSavedStarGift to_id:InputPeer = Updates; +payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift; +payments.getSavedStarGifts#a319e569 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_unique:flags.4?true sort_by_value:flags.5?true exclude_upgradable:flags.7?true exclude_unupgradable:flags.8?true peer_color_available:flags.9?true exclude_hosted:flags.10?true peer:InputPeer collection_id:flags.6?int offset:string limit:int = payments.SavedStarGifts; +payments.getSavedStarGift#b455a106 stargift:Vector = payments.SavedStarGifts; +payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl; +payments.toggleChatStarGiftNotifications#60eaefa1 flags:# enabled:flags.0?true peer:InputPeer = Bool; +payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = Bool; +payments.canPurchaseStore#4fdc5ea7 purpose:InputStorePaymentPurpose = Bool; +payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true for_craft:flags.4?true stars_only:flags.5?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector offset:string limit:int = payments.ResaleStarGifts; +payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates; +payments.createStarGiftCollection#1f4a0e87 peer:InputPeer title:string stargift:Vector = StarGiftCollection; +payments.updateStarGiftCollection#4fddbee7 flags:# peer:InputPeer collection_id:int title:flags.0?string delete_stargift:flags.1?Vector add_stargift:flags.2?Vector order:flags.3?Vector = StarGiftCollection; +payments.reorderStarGiftCollections#c32af4cc peer:InputPeer order:Vector = Bool; +payments.deleteStarGiftCollection#ad5648e8 peer:InputPeer collection_id:int = Bool; +payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections; +payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGiftValueInfo; +payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; +payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; +payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; +payments.sendStarGiftOffer#8fb86b41 flags:# peer:InputPeer slug:string price:StarsAmount duration:int random_id:long allow_paid_stars:flags.0?long = Updates; +payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; +payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts; +payments.craftStarGift#b0f9684f stargift:Vector = Updates; + +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true emojis:flags.5?true text_color:flags.6?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; +stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; +stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; +stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; +stickers.setStickerSetThumb#a76a5392 flags:# stickerset:InputStickerSet thumb:flags.0?InputDocument thumb_document_id:flags.1?long = messages.StickerSet; +stickers.checkShortName#284b3639 short_name:string = Bool; +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; +stickers.changeSticker#f5537ebc flags:# sticker:InputDocument emoji:flags.0?string mask_coords:flags.1?MaskCoords keywords:flags.2?string = messages.StickerSet; +stickers.renameStickerSet#124b1c00 stickerset:InputStickerSet title:string = messages.StickerSet; +stickers.deleteStickerSet#87704394 stickerset:InputStickerSet = Bool; +stickers.replaceSticker#4696459a sticker:InputDocument new_sticker:InputStickerSetItem = messages.StickerSet; + +phone.getCallConfig#55451fa9 = DataJSON; +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.toggleGroupCallSettings#974392f2 flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool messages_enabled:flags.2?Bool send_paid_messages_stars:flags.3?long = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; +phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; +phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; +phone.getGroupCallStreamRtmpUrl#5af4c73a flags:# live_story:flags.0?true peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; +phone.saveCallLog#41248786 peer:InputPhoneCall file:InputFile = Bool; +phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates; +phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector block:bytes = Updates; +phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates; +phone.inviteConferenceCallParticipant#bcf22685 flags:# video:flags.0?true call:InputGroupCall user_id:InputUser = Updates; +phone.declineConferenceCallInvite#3c479971 msg_id:int = Updates; +phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates; +phone.sendGroupCallMessage#b1d11410 flags:# call:InputGroupCall random_id:long message:TextWithEntities allow_paid_stars:flags.0?long send_as:flags.1?InputPeer = Updates; +phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool; +phone.deleteGroupCallMessages#f64f54f7 flags:# report_spam:flags.0?true call:InputGroupCall messages:Vector = Updates; +phone.deleteGroupCallParticipantMessages#1dbfeca0 flags:# report_spam:flags.0?true call:InputGroupCall participant:InputPeer = Updates; +phone.getGroupCallStars#6f636302 call:InputGroupCall = phone.GroupCallStars; +phone.saveDefaultSendAs#4167add1 call:InputGroupCall send_as:InputPeer = Bool; + +langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; +langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; +langpack.getDifference#cd984aa5 lang_pack:string lang_code:string from_version:int = LangPackDifference; +langpack.getLanguages#42c6978f lang_pack:string = Vector; +langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLanguage; + +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; + +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; +stats.getMessagePublicForwards#5f150144 channel:InputChannel msg_id:int offset:string limit:int = stats.PublicForwards; +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; +stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats; +stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards; +stats.getPollStats#c27dfa68 flags:# dark:flags.0?true peer:InputPeer msg_id:int = stats.PollStats; + +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = chatlists.ExportedChatlistInvite; +chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool; +chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector = ExportedChatlistInvite; +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; +chatlists.getChatlistUpdates#89419521 chatlist:InputChatlist = chatlists.ChatlistUpdates; +chatlists.joinChatlistUpdates#e089f8f5 chatlist:InputChatlist peers:Vector = Updates; +chatlists.hideChatlistUpdates#66e486fb chatlist:InputChatlist = Bool; +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates; + +stories.canSendStory#30eb63f0 peer:InputPeer = stories.CanSendStoryCount; +stories.sendStory#8f9e6898 flags:# pinned:flags.2?true noforwards:flags.4?true fwd_modified:flags.7?true peer:InputPeer media:InputMedia media_areas:flags.5?Vector caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long period:flags.3?int fwd_from_id:flags.6?InputPeer fwd_from_story:flags.6?int albums:flags.8?Vector music:flags.9?InputDocument = Updates; +stories.editStory#2c63a72b flags:# peer:InputPeer id:int media:flags.0?InputMedia media_areas:flags.3?Vector caption:flags.1?string entities:flags.1?Vector privacy_rules:flags.2?Vector music:flags.4?InputDocument = Updates; +stories.deleteStories#ae59db5f peer:InputPeer id:Vector = Vector; +stories.togglePinned#9a75a1ef peer:InputPeer id:Vector pinned:Bool = Vector; +stories.getAllStories#eeb0d625 flags:# next:flags.1?true hidden:flags.2?true state:flags.0?string = stories.AllStories; +stories.getPinnedStories#5821a5dc peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesArchive#b4352016 peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesByID#5774ca74 peer:InputPeer id:Vector = stories.Stories; +stories.toggleAllStoriesHidden#7c2557c4 hidden:Bool = Bool; +stories.readStories#a556dac8 peer:InputPeer max_id:int = Vector; +stories.incrementStoryViews#b2028afb peer:InputPeer id:Vector = Bool; +stories.getStoryViewsList#7ed23c57 flags:# just_contacts:flags.0?true reactions_first:flags.2?true forwards_first:flags.3?true peer:InputPeer q:flags.1?string id:int offset:string limit:int = stories.StoryViewsList; +stories.getStoriesViews#28e16cc8 peer:InputPeer id:Vector = stories.StoryViews; +stories.exportStoryLink#7b8def20 peer:InputPeer id:int = ExportedStoryLink; +stories.report#19d8eb45 peer:InputPeer id:Vector option:bytes message:string = ReportResult; +stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?true = Updates; +stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; +stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; +stories.getAllReadPeerStories#9b5ae7f9 = Updates; +stories.getPeerMaxIDs#78499170 id:Vector = Vector; +stories.getChatsToSend#a56a8b60 = messages.Chats; +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.getStoryReactionsList#b9b2881f flags:# forwards_first:flags.2?true peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = stories.StoryReactionsList; +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; +stories.searchPosts#d1810907 flags:# hashtag:flags.0?string area:flags.1?MediaArea peer:flags.2?InputPeer offset:string limit:int = stories.FoundStories; +stories.createAlbum#a36396e5 peer:InputPeer title:string stories:Vector = StoryAlbum; +stories.updateAlbum#5e5259b6 flags:# peer:InputPeer album_id:int title:flags.0?string delete_stories:flags.1?Vector add_stories:flags.2?Vector order:flags.3?Vector = StoryAlbum; +stories.reorderAlbums#8535fbd9 peer:InputPeer order:Vector = Bool; +stories.deleteAlbum#8d3456d0 peer:InputPeer album_id:int = Bool; +stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums; +stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories; +stories.startLive#d069ccde flags:# pinned:flags.2?true noforwards:flags.4?true rtmp_stream:flags.5?true peer:InputPeer caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long messages_enabled:flags.6?Bool send_paid_messages_stars:flags.7?long = Updates; + +premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; +premium.getMyBoosts#be77b4a = premium.MyBoosts; +premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; +premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus; +premium.getUserBoosts#39854d1f peer:InputPeer user_id:InputUser = premium.BoostsList; + +smsjobs.isEligibleToJoin#edc39d0 = smsjobs.EligibilityToJoin; +smsjobs.join#a74ece2d = Bool; +smsjobs.leave#9898ad73 = Bool; +smsjobs.updateSettings#93fa0bf flags:# allow_international:flags.0?true = Bool; +smsjobs.getStatus#10a698e8 = smsjobs.Status; +smsjobs.getSmsJob#778d902f job_id:string = SmsJob; +smsjobs.finishJob#4f1ebf24 flags:# job_id:string error:flags.0?string = Bool; + +fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo; + +aicompose.createTone#4aa83913 flags:# display_author:flags.0?true emoji_id:long title:string prompt:string = AiComposeTone; +aicompose.updateTone#903bcf59 flags:# tone:InputAiComposeTone display_author:flags.0?Bool emoji_id:flags.1?long title:flags.2?string prompt:flags.3?string = AiComposeTone; +aicompose.saveTone#1782cbb1 tone:InputAiComposeTone unsave:Bool = Bool; +aicompose.deleteTone#dd39316a tone:InputAiComposeTone = Bool; +aicompose.getTone#b2e8ba03 tone:InputAiComposeTone = aicompose.Tones; +aicompose.getTones#abd59201 hash:long = aicompose.Tones; +aicompose.getToneExample#d1b4ab14 tone:InputAiComposeTone num:int = AiComposeToneExample; + +// LAYER 225 diff --git a/internal/compat/layerwire/_schema/layer-226.tl b/internal/compat/layerwire/_schema/layer-226.tl new file mode 100644 index 00000000..f2f907c7 --- /dev/null +++ b/internal/compat/layerwire/_schema/layer-226.tl @@ -0,0 +1,3002 @@ +boolFalse#bc799737 = Bool; +boolTrue#997275b5 = Bool; + +true#3fedd339 = True; + +vector#1cb5c415 {t:Type} # [ t ] = Vector t; + +error#c4b9f9bb code:int text:string = Error; + +null#56730bcc = Null; + +inputPeerEmpty#7f3b18ea = InputPeer; +inputPeerSelf#7da07ec9 = InputPeer; +inputPeerChat#35a95cb9 chat_id:long = InputPeer; +inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer; +inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer; +inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer; +inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer; + +inputUserEmpty#b98886cf = InputUser; +inputUserSelf#f7c1b13f = InputUser; +inputUser#f21158c6 user_id:long access_hash:long = InputUser; +inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser; + +inputPhoneContact#6a1dc4be flags:# client_id:long phone:string first_name:string last_name:string note:flags.0?TextWithEntities = InputContact; + +inputFile#f52ff27f id:long parts:int name:string md5_checksum:string = InputFile; +inputFileBig#fa4f0bb5 id:long parts:int name:string = InputFile; +inputFileStoryDocument#62dc8b48 id:InputDocument = InputFile; + +inputMediaEmpty#9664f57f = InputMedia; +inputMediaUploadedPhoto#7d8375da flags:# spoiler:flags.2?true live_photo:flags.3?true file:InputFile stickers:flags.0?Vector ttl_seconds:flags.1?int video:flags.3?InputDocument = InputMedia; +inputMediaPhoto#e3af4434 flags:# spoiler:flags.1?true live_photo:flags.2?true id:InputPhoto ttl_seconds:flags.0?int video:flags.2?InputDocument = InputMedia; +inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia; +inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia; +inputMediaUploadedDocument#37c9330 flags:# nosound_video:flags.3?true force_file:flags.4?true spoiler:flags.5?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector video_cover:flags.6?InputPhoto video_timestamp:flags.7?int ttl_seconds:flags.1?int = InputMedia; +inputMediaDocument#a8763ab5 flags:# spoiler:flags.2?true id:InputDocument video_cover:flags.3?InputPhoto video_timestamp:flags.4?int ttl_seconds:flags.0?int query:flags.1?string = InputMedia; +inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia; +inputMediaPhotoExternal#e5bbfe1a flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int = InputMedia; +inputMediaDocumentExternal#779600f9 flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int video_cover:flags.2?InputPhoto video_timestamp:flags.3?int = InputMedia; +inputMediaGame#d33f43f3 id:InputGame = InputMedia; +inputMediaInvoice#405fef0d flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:flags.3?string provider_data:DataJSON start_param:flags.1?string extended_media:flags.2?InputMedia = InputMedia; +inputMediaGeoLive#971fa843 flags:# stopped:flags.0?true geo_point:InputGeoPoint heading:flags.2?int period:flags.1?int proximity_notification_radius:flags.3?int = InputMedia; +inputMediaPoll#883a4108 flags:# poll:Poll correct_answers:flags.0?Vector attached_media:flags.3?InputMedia solution:flags.1?string solution_entities:flags.1?Vector solution_media:flags.2?InputMedia = InputMedia; +inputMediaDice#e66fbf7b emoticon:string = InputMedia; +inputMediaStory#89fdd778 peer:InputPeer id:int = InputMedia; +inputMediaWebPage#c21b8849 flags:# force_large_media:flags.0?true force_small_media:flags.1?true optional:flags.2?true url:string = InputMedia; +inputMediaPaidMedia#c4103386 flags:# stars_amount:long extended_media:Vector payload:flags.0?string = InputMedia; +inputMediaTodo#9fc55fde todo:TodoList = InputMedia; +inputMediaStakeDice#f3a9244a game_hash:string ton_amount:long client_seed:bytes = InputMedia; + +inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; +inputChatUploadedPhoto#bdcdaec0 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.3?VideoSize = InputChatPhoto; +inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; + +inputGeoPointEmpty#e4c123d6 = InputGeoPoint; +inputGeoPoint#48222faf flags:# lat:double long:double accuracy_radius:flags.0?int = InputGeoPoint; + +inputPhotoEmpty#1cd7bf0d = InputPhoto; +inputPhoto#3bb3b94a id:long access_hash:long file_reference:bytes = InputPhoto; + +inputFileLocation#dfdaabe1 volume_id:long local_id:int secret:long file_reference:bytes = InputFileLocation; +inputEncryptedFileLocation#f5235d55 id:long access_hash:long = InputFileLocation; +inputDocumentFileLocation#bad07584 id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputSecureFileLocation#cbc7ee28 id:long access_hash:long = InputFileLocation; +inputTakeoutFileLocation#29be5899 = InputFileLocation; +inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation; +inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation; +inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation; +inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation; + +peerUser#59511722 user_id:long = Peer; +peerChat#36c6019a chat_id:long = Peer; +peerChannel#a2a5371e channel_id:long = Peer; + +storage.fileUnknown#aa963b05 = storage.FileType; +storage.filePartial#40bc6f52 = storage.FileType; +storage.fileJpeg#7efe0e = storage.FileType; +storage.fileGif#cae1aadf = storage.FileType; +storage.filePng#a4f63c0 = storage.FileType; +storage.filePdf#ae1e508d = storage.FileType; +storage.fileMp3#528a0677 = storage.FileType; +storage.fileMov#4b09ebbc = storage.FileType; +storage.fileMp4#b3cea0e4 = storage.FileType; +storage.fileWebp#1081464c = storage.FileType; + +userEmpty#d3bc4b7a id:long = User; +user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true bot_forum_can_manage_topics:flags2.17?true bot_can_manage_bots:flags2.18?true bot_guestchat:flags2.19?true bot_guard:flags2.20?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User; + +userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; +userProfilePhoto#82d1f706 flags:# has_video:flags.0?true personal:flags.2?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto; + +userStatusEmpty#9d05049 = UserStatus; +userStatusOnline#edb93949 expires:int = UserStatus; +userStatusOffline#8c703f was_online:int = UserStatus; +userStatusRecently#7b197dc8 flags:# by_me:flags.0?true = UserStatus; +userStatusLastWeek#541a1d1a flags:# by_me:flags.0?true = UserStatus; +userStatusLastMonth#65899777 flags:# by_me:flags.0?true = UserStatus; + +chatEmpty#29562865 id:long = Chat; +chat#41cbf256 flags:# creator:flags.0?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; +chatForbidden#6592a1a7 id:long title:string = Chat; +channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat; +channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true monoforum:flags.10?true id:long access_hash:long title:string until_date:flags.16?int = Chat; + +chatFull#2633421b flags:# can_set_username:flags.7?true has_scheduled:flags.8?true translations_disabled:flags.19?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions reactions_limit:flags.20?int = ChatFull; +channelFull#a04e8d3a flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true participants_hidden:flags2.2?true translations_disabled:flags2.3?true stories_pinned_available:flags2.5?true view_forum_as_messages:flags2.6?true restricted_sponsored:flags2.11?true can_view_revenue:flags2.12?true paid_media_allowed:flags2.14?true can_view_stars_revenue:flags2.15?true paid_reactions_available:flags2.16?true stargifts_available:flags2.19?true paid_messages_available:flags2.20?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions reactions_limit:flags2.13?int stories:flags2.4?PeerStories wallpaper:flags2.7?WallPaper boosts_applied:flags2.8?int boosts_unrestrict:flags2.9?int emojiset:flags2.10?StickerSet bot_verification:flags2.17?BotVerification stargifts_count:flags2.18?int send_paid_messages_stars:flags2.21?long main_tab:flags2.22?ProfileTab guard_bot_id:flags2.23?long = ChatFull; + +chatParticipant#38e79fde flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; +chatParticipantCreator#e1f867b8 flags:# user_id:long rank:flags.0?string = ChatParticipant; +chatParticipantAdmin#360d5d2 flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; + +chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants; +chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants; + +chatPhotoEmpty#37c1011c = ChatPhoto; +chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; + +messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; +message#95ef6f2b flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true invert_media:flags.27?true flags2:# offline:flags2.1?true video_processing_pending:flags2.4?true paid_suggested_post_stars:flags2.8?true paid_suggested_post_ton:flags2.9?true id:int from_id:flags.8?Peer from_boosts_applied:flags.29?int from_rank:flags2.12?string peer_id:Peer saved_peer_id:flags.28?Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long via_business_bot_id:flags2.0?long guestchat_via_from:flags2.19?Peer reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int quick_reply_shortcut_id:flags.30?int effect:flags2.2?long factcheck:flags2.3?FactCheck report_delivery_until_date:flags2.5?int paid_message_stars:flags2.6?long suggested_post:flags2.7?SuggestedPost schedule_repeat_period:flags2.10?int summary_from_language:flags2.11?string = Message; +messageService#7a800e0a flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true reactions_are_possible:flags.9?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer saved_peer_id:flags.28?Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction reactions:flags.20?MessageReactions ttl_period:flags.25?int = Message; + +messageMediaEmpty#3ded6320 = MessageMedia; +messageMediaPhoto#e216eb63 flags:# spoiler:flags.3?true live_photo:flags.4?true photo:flags.0?Photo ttl_seconds:flags.2?int video:flags.4?Document = MessageMedia; +messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia; +messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia; +messageMediaUnsupported#9f84f49e = MessageMedia; +messageMediaDocument#52d8ccd9 flags:# nopremium:flags.3?true spoiler:flags.4?true video:flags.6?true round:flags.7?true voice:flags.8?true document:flags.0?Document alt_documents:flags.5?Vector video_cover:flags.9?Photo video_timestamp:flags.10?int ttl_seconds:flags.2?int = MessageMedia; +messageMediaWebPage#ddf10c3b flags:# force_large_media:flags.0?true force_small_media:flags.1?true manual:flags.3?true safe:flags.4?true webpage:WebPage = MessageMedia; +messageMediaVenue#2ec0533f geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MessageMedia; +messageMediaGame#fdb19008 game:Game = MessageMedia; +messageMediaInvoice#f6a548d3 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string extended_media:flags.4?MessageExtendedMedia = MessageMedia; +messageMediaGeoLive#b940c666 flags:# geo:GeoPoint heading:flags.0?int period:int proximity_notification_radius:flags.1?int = MessageMedia; +messageMediaPoll#773f4e66 flags:# poll:Poll results:PollResults attached_media:flags.0?MessageMedia = MessageMedia; +messageMediaDice#8cbec07 flags:# value:int emoticon:string game_outcome:flags.0?messages.EmojiGameOutcome = MessageMedia; +messageMediaStory#68cb6283 flags:# via_mention:flags.1?true peer:Peer id:int story:flags.0?StoryItem = MessageMedia; +messageMediaGiveaway#aa073beb flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.2?true channels:Vector countries_iso2:flags.1?Vector prize_description:flags.3?string quantity:int months:flags.4?int stars:flags.5?long until_date:int = MessageMedia; +messageMediaGiveawayResults#ceaa3ea1 flags:# only_new_subscribers:flags.0?true refunded:flags.2?true channel_id:long additional_peers_count:flags.3?int launch_msg_id:int winners_count:int unclaimed_count:int winners:Vector months:flags.4?int stars:flags.5?long prize_description:flags.1?string until_date:int = MessageMedia; +messageMediaPaidMedia#a8852491 stars_amount:long extended_media:Vector = MessageMedia; +messageMediaToDo#8a53b014 flags:# todo:TodoList completions:flags.0?Vector = MessageMedia; +messageMediaVideoStream#ca5cab89 flags:# rtmp_stream:flags.0?true call:InputGroupCall = MessageMedia; + +messageActionEmpty#b6aef7b0 = MessageAction; +messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction; +messageActionChatEditTitle#b5a1ce5a title:string = MessageAction; +messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction; +messageActionChatDeletePhoto#95e3fbef = MessageAction; +messageActionChatAddUser#15cefd00 users:Vector = MessageAction; +messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction; +messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction; +messageActionChannelCreate#95d2ac92 title:string = MessageAction; +messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction; +messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction; +messageActionPinMessage#94bd38ed = MessageAction; +messageActionHistoryClear#9fbab604 = MessageAction; +messageActionGameScore#92a72876 game_id:long score:int = MessageAction; +messageActionPaymentSentMe#ffa00ccc flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string charge:PaymentCharge subscription_until_date:flags.4?int = MessageAction; +messageActionPaymentSent#c624b16e flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long invoice_slug:flags.0?string subscription_until_date:flags.4?int = MessageAction; +messageActionPhoneCall#80e11a7f flags:# video:flags.2?true call_id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = MessageAction; +messageActionScreenshotTaken#4792929b = MessageAction; +messageActionCustomAction#fae69f56 message:string = MessageAction; +messageActionBotAllowed#c516d679 flags:# attach_menu:flags.1?true from_request:flags.3?true domain:flags.0?string app:flags.2?BotApp = MessageAction; +messageActionSecureValuesSentMe#1b287353 values:Vector credentials:SecureCredentialsEncrypted = MessageAction; +messageActionSecureValuesSent#d95c6154 types:Vector = MessageAction; +messageActionContactSignUp#f3f25f76 = MessageAction; +messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; +messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; +messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; +messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; +messageActionSetChatTheme#b91bbd3a theme:ChatTheme = MessageAction; +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; +messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; +messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; +messageActionGiftPremium#48e91302 flags:# currency:string amount:long days:int crypto_currency:flags.0?string crypto_amount:flags.0?long message:flags.1?TextWithEntities = MessageAction; +messageActionTopicCreate#d999256 flags:# title_missing:flags.1?true title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; +messageActionSuggestProfilePhoto#57de635e photo:Photo = MessageAction; +messageActionRequestedPeer#31518e9b button_id:int peers:Vector = MessageAction; +messageActionSetChatWallPaper#5060a3f4 flags:# same:flags.0?true for_both:flags.1?true wallpaper:WallPaper = MessageAction; +messageActionGiftCode#31c48347 flags:# via_giveaway:flags.0?true unclaimed:flags.5?true boost_peer:flags.1?Peer days:int slug:string currency:flags.2?string amount:flags.2?long crypto_currency:flags.3?string crypto_amount:flags.3?long message:flags.4?TextWithEntities = MessageAction; +messageActionGiveawayLaunch#a80f51e4 flags:# stars:flags.0?long = MessageAction; +messageActionGiveawayResults#87e2f155 flags:# stars:flags.0?true winners_count:int unclaimed_count:int = MessageAction; +messageActionBoostApply#cc02aa6d boosts:int = MessageAction; +messageActionRequestedPeerSentMe#93b31848 button_id:int peers:Vector = MessageAction; +messageActionPaymentRefunded#41b3e202 flags:# peer:Peer currency:string total_amount:long payload:flags.0?bytes charge:PaymentCharge = MessageAction; +messageActionGiftStars#45d5b021 flags:# currency:string amount:long stars:long crypto_currency:flags.0?string crypto_amount:flags.0?long transaction_id:flags.1?string = MessageAction; +messageActionPrizeStars#b00c47a2 flags:# unclaimed:flags.0?true stars:long transaction_id:string boost_peer:Peer giveaway_msg_id:int = MessageAction; +messageActionStarGift#ea2c31d3 flags:# name_hidden:flags.0?true saved:flags.2?true converted:flags.3?true upgraded:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true prepaid_upgrade:flags.13?true upgrade_separate:flags.16?true auction_acquired:flags.17?true gift:StarGift message:flags.1?TextWithEntities convert_stars:flags.4?long upgrade_msg_id:flags.5?int upgrade_stars:flags.8?long from_id:flags.11?Peer peer:flags.12?Peer saved_id:flags.12?long prepaid_upgrade_hash:flags.14?string gift_msg_id:flags.15?int to_id:flags.18?Peer gift_num:flags.19?int = MessageAction; +messageActionStarGiftUnique#e6c31522 flags:# upgrade:flags.0?true transferred:flags.1?true saved:flags.2?true refunded:flags.5?true prepaid_upgrade:flags.11?true assigned:flags.13?true from_offer:flags.14?true craft:flags.16?true gift:StarGift can_export_at:flags.3?int transfer_stars:flags.4?long from_id:flags.6?Peer peer:flags.7?Peer saved_id:flags.7?long resale_amount:flags.8?StarsAmount can_transfer_at:flags.9?int can_resell_at:flags.10?int drop_original_details_stars:flags.12?long can_craft_at:flags.15?int = MessageAction; +messageActionPaidMessagesRefunded#ac1f1fcd count:int stars:long = MessageAction; +messageActionPaidMessagesPrice#84b88578 flags:# broadcast_messages_allowed:flags.0?true stars:long = MessageAction; +messageActionConferenceCall#2ffe2f7a flags:# missed:flags.0?true active:flags.1?true video:flags.4?true call_id:long duration:flags.2?int other_participants:flags.3?Vector = MessageAction; +messageActionTodoCompletions#cc7c5c89 completed:Vector incompleted:Vector = MessageAction; +messageActionTodoAppendTasks#c7edbc83 list:Vector = MessageAction; +messageActionSuggestedPostApproval#ee7a1596 flags:# rejected:flags.0?true balance_too_low:flags.1?true reject_comment:flags.2?string schedule_date:flags.3?int price:flags.4?StarsAmount = MessageAction; +messageActionSuggestedPostSuccess#95ddcf69 price:StarsAmount = MessageAction; +messageActionSuggestedPostRefund#69f916f8 flags:# payer_initiated:flags.0?true = MessageAction; +messageActionGiftTon#a8a3c699 flags:# currency:string amount:long crypto_currency:string crypto_amount:long transaction_id:flags.0?string = MessageAction; +messageActionSuggestBirthday#2c8f2a25 birthday:Birthday = MessageAction; +messageActionStarGiftPurchaseOffer#774278d4 flags:# accepted:flags.0?true declined:flags.1?true gift:StarGift price:StarsAmount expires_at:int = MessageAction; +messageActionStarGiftPurchaseOfferDeclined#73ada76b flags:# expired:flags.0?true gift:StarGift price:StarsAmount = MessageAction; +messageActionNewCreatorPending#b07ed085 new_creator_id:long = MessageAction; +messageActionChangeCreator#e188503b new_creator_id:long = MessageAction; +messageActionNoForwardsToggle#bf7d6572 prev_value:Bool new_value:Bool = MessageAction; +messageActionNoForwardsRequest#3e2793ba flags:# expired:flags.0?true prev_value:Bool new_value:Bool = MessageAction; +messageActionPollAppendAnswer#9da1cd6c answer:PollAnswer = MessageAction; +messageActionPollDeleteAnswer#399674dc answer:PollAnswer = MessageAction; +messageActionManagedBotCreated#16605e3e bot_id:long = MessageAction; + +dialog#fc89f7f3 flags:# pinned:flags.2?true unread_mark:flags.3?true view_forum_as_messages:flags.6?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; +dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; + +photoEmpty#2331b22d id:long = Photo; +photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; + +photoSizeEmpty#e17e23c type:string = PhotoSize; +photoSize#75c78e60 type:string w:int h:int size:int = PhotoSize; +photoCachedSize#21e1ad6 type:string w:int h:int bytes:bytes = PhotoSize; +photoStrippedSize#e0b0bc2e type:string bytes:bytes = PhotoSize; +photoSizeProgressive#fa3efb95 type:string w:int h:int sizes:Vector = PhotoSize; +photoPathSize#d8214d41 type:string bytes:bytes = PhotoSize; + +geoPointEmpty#1117dd5f = GeoPoint; +geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint; + +auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; +auth.sentCodeSuccess#2390fe44 authorization:auth.Authorization = auth.SentCode; +auth.sentCodePaymentRequired#f8827ebf store_product:string phone_code_hash:string support_email_address:string support_email_subject:string premium_days:int currency:string amount:long = auth.SentCode; + +auth.authorization#2ea2c0d4 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int future_auth_token:flags.2?bytes user:User = auth.Authorization; +auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; + +auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; + +inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer; +inputNotifyUsers#193b4417 = InputNotifyPeer; +inputNotifyChats#4a95e84e = InputNotifyPeer; +inputNotifyBroadcasts#b1db7c7e = InputNotifyPeer; +inputNotifyForumTopic#5c467992 peer:InputPeer top_msg_id:int = InputNotifyPeer; + +inputPeerNotifySettings#cacb6ae2 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_sound:flags.8?NotificationSound = InputPeerNotifySettings; + +peerNotifySettings#99622c0c flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int ios_sound:flags.3?NotificationSound android_sound:flags.4?NotificationSound other_sound:flags.5?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_ios_sound:flags.8?NotificationSound stories_android_sound:flags.9?NotificationSound stories_other_sound:flags.10?NotificationSound = PeerNotifySettings; + +peerSettings#f47741f7 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true business_bot_paused:flags.11?true business_bot_can_reply:flags.12?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int business_bot_id:flags.13?long business_bot_manage_url:flags.13?string charge_paid_message_stars:flags.14?long registration_month:flags.15?string phone_country:flags.16?string name_change_date:flags.17?int photo_change_date:flags.18?int = PeerSettings; + +wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; +wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; + +inputReportReasonSpam#58dbcab8 = ReportReason; +inputReportReasonViolence#1e22c78d = ReportReason; +inputReportReasonPornography#2e59d922 = ReportReason; +inputReportReasonChildAbuse#adf44ee3 = ReportReason; +inputReportReasonOther#c1e4a2b1 = ReportReason; +inputReportReasonCopyright#9b89f93a = ReportReason; +inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; +inputReportReasonFake#f5ddd6e7 = ReportReason; +inputReportReasonIllegalDrugs#a8eb2be = ReportReason; +inputReportReasonPersonalDetails#9ec7863d = ReportReason; + +userFull#6cbe645 flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true voice_messages_forbidden:flags.20?true translations_disabled:flags.23?true stories_pinned_available:flags.26?true blocked_my_stories_from:flags.27?true wallpaper_overridden:flags.28?true contact_require_premium:flags.29?true read_dates_private:flags.30?true flags2:# sponsored_enabled:flags2.7?true can_view_revenue:flags2.9?true bot_can_manage_emoji_status:flags2.10?true display_gifts_button:flags2.16?true noforwards_my_enabled:flags2.23?true noforwards_peer_enabled:flags2.24?true unofficial_security_risk:flags2.26?true id:long about:flags.1?string settings:PeerSettings personal_photo:flags.21?Photo profile_photo:flags.2?Photo fallback_photo:flags.22?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme:flags.15?ChatTheme private_forward_name:flags.16?string bot_group_admin_rights:flags.17?ChatAdminRights bot_broadcast_admin_rights:flags.18?ChatAdminRights wallpaper:flags.24?WallPaper stories:flags.25?PeerStories business_work_hours:flags2.0?BusinessWorkHours business_location:flags2.1?BusinessLocation business_greeting_message:flags2.2?BusinessGreetingMessage business_away_message:flags2.3?BusinessAwayMessage business_intro:flags2.4?BusinessIntro birthday:flags2.5?Birthday personal_channel_id:flags2.6?long personal_channel_message:flags2.6?int stargifts_count:flags2.8?int starref_program:flags2.11?StarRefProgram bot_verification:flags2.12?BotVerification send_paid_messages_stars:flags2.14?long disallowed_gifts:flags2.15?DisallowedGiftsSettings stars_rating:flags2.17?StarsRating stars_my_pending_rating:flags2.18?StarsRating stars_my_pending_rating_date:flags2.18?int main_tab:flags2.20?ProfileTab saved_music:flags2.21?Document note:flags2.22?TextWithEntities bot_manager_id:flags2.25?long = UserFull; + +contact#145ade0b user_id:long mutual:Bool = Contact; + +importedContact#c13e3c50 user_id:long client_id:long = ImportedContact; + +contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus; + +contacts.contactsNotModified#b74ba9d2 = contacts.Contacts; +contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts; + +contacts.importedContacts#77d01c3b imported:Vector popular_invites:Vector retry_contacts:Vector users:Vector = contacts.ImportedContacts; + +contacts.blocked#ade1591 blocked:Vector chats:Vector users:Vector = contacts.Blocked; +contacts.blockedSlice#e1664194 count:int blocked:Vector chats:Vector users:Vector = contacts.Blocked; + +messages.dialogs#15ba6c40 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsNotModified#f0e3e596 count:int = messages.Dialogs; + +messages.messages#1d73e7ea messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesSlice#5f206716 flags:# inexact:flags.1?true count:int next_rate:flags.0?int offset_id_offset:flags.2?int search_flood:flags.3?SearchPostsFlood messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.channelMessages#c776ba4e flags:# inexact:flags.1?true pts:int count:int offset_id_offset:flags.2?int messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesNotModified#74535f21 count:int = messages.Messages; + +messages.chats#64ff9fd5 chats:Vector = messages.Chats; +messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; + +messages.chatFull#e5d7d19c full_chat:ChatFull chats:Vector users:Vector = messages.ChatFull; + +messages.affectedHistory#b45c69d1 pts:int pts_count:int offset:int = messages.AffectedHistory; + +inputMessagesFilterEmpty#57e2f66c = MessagesFilter; +inputMessagesFilterPhotos#9609a51c = MessagesFilter; +inputMessagesFilterVideo#9fc00e65 = MessagesFilter; +inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; +inputMessagesFilterDocument#9eddf188 = MessagesFilter; +inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; +inputMessagesFilterGif#ffc86587 = MessagesFilter; +inputMessagesFilterVoice#50f5c392 = MessagesFilter; +inputMessagesFilterMusic#3751b49e = MessagesFilter; +inputMessagesFilterChatPhotos#3a20ecb8 = MessagesFilter; +inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFilter; +inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; +inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; +inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; +inputMessagesFilterGeo#e7026d0d = MessagesFilter; +inputMessagesFilterContacts#e062db83 = MessagesFilter; +inputMessagesFilterPinned#1bb00451 = MessagesFilter; +inputMessagesFilterPoll#fa2bc90a = MessagesFilter; + +updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; +updateMessageID#4e90bfd6 id:int random_id:long = Update; +updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; +updateUserTyping#2a17bf5c flags:# user_id:long top_msg_id:flags.0?int action:SendMessageAction = Update; +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; +updateChatParticipants#7761198 participants:ChatParticipants = Update; +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; +updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector = Update; +updateNewAuthorization#8951abef flags:# unconfirmed:flags.0?true hash:long date:flags.0?int device:flags.0?string location:flags.0?string = Update; +updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; +updateEncryptedChatTyping#1710f156 chat_id:int = Update; +updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; +updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; +updateDcOptions#8e5e9873 dc_options:Vector = Update; +updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; +updateServiceNotification#ebe46819 flags:# popup:flags.0?true invert_media:flags.2?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; +updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; +updateUserPhone#5492a13 user_id:long phone:string = Update; +updateReadHistoryInbox#9e84bc99 flags:# folder_id:flags.0?int peer:Peer top_msg_id:flags.1?int max_id:int still_unread_count:int pts:int pts_count:int = Update; +updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; +updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; +updateReadMessagesContents#f8227181 flags:# messages:Vector pts:int pts_count:int date:flags.0?int = Update; +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; +updateChannel#635b4c09 channel_id:long = Update; +updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; +updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; +updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Update; +updateStickerSets#31c24808 flags:# masks:flags.0?true emojis:flags.1?true = Update; +updateSavedGifs#9375341e = Update; +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; +updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; +updateDraftMessage#edfc111e flags:# peer:Peer top_msg_id:flags.0?int saved_peer_id:flags.1?Peer draft:DraftMessage = Update; +updateReadFeaturedStickers#571d2742 = Update; +updateRecentStickers#9a422c20 = Update; +updateConfig#a229dd06 = Update; +updatePtsChanged#3354678f = Update; +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; +updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; +updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; +updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; +updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; +updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; +updateLangPackTooLong#46560264 lang_code:string = Update; +updateLangPack#56022f4d difference:LangPackDifference = Update; +updateFavedStickers#e511996d = Update; +updateChannelReadMessagesContents#25f324f7 flags:# channel_id:long top_msg_id:flags.0?int saved_peer_id:flags.1?Peer messages:Vector = Update; +updateContactsReset#7084a7be = Update; +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; +updateDialogUnreadMark#b658f23e flags:# unread:flags.0?true peer:DialogPeer saved_peer_id:flags.1?Peer = Update; +updateMessagePoll#d64c522b flags:# peer:flags.1?Peer msg_id:flags.1?int top_msg_id:flags.2?int poll_id:long poll:flags.0?Poll results:PollResults = Update; +updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; +updateFolderPeers#19360dc0 folder_peers:Vector pts:int pts_count:int = Update; +updatePeerSettings#6a7e7366 peer:Peer settings:PeerSettings = Update; +updatePeerLocated#b4afcfb0 peers:Vector = Update; +updateNewScheduledMessage#39a51dfb message:Message = Update; +updateDeleteScheduledMessages#f2a71983 flags:# peer:Peer messages:Vector sent_messages:flags.0?Vector = Update; +updateTheme#8216fba3 theme:Theme = Update; +updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; +updateLoginToken#564fe691 = Update; +updateMessagePollVote#7699f014 poll_id:long peer:Peer options:Vector positions:Vector qts:int = Update; +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; +updateDialogFilterOrder#a5d72105 order:Vector = Update; +updateDialogFilters#3504914f = Update; +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; +updatePeerBlocked#ebe07752 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer = Update; +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; +updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChat#f89a6a4e chat_id:long = Update; +updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; +updateGroupCall#9d2216e0 flags:# live_story:flags.2?true peer:flags.1?Peer call:GroupCall = Update; +updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateChannelParticipant#985d3abb flags:# via_chatlist:flags.3?true channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; +updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; +updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#7cb34d79 flags:# peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int query_id:flags.0?long = Update; +updateMessageReactions#1e297bfa flags:# peer:Peer msg_id:int top_msg_id:flags.0?int saved_peer_id:flags.1?Peer reactions:MessageReactions = Update; +updateAttachMenuBots#17b7a20b = Update; +updateWebViewResultSent#1592b79d query_id:long = Update; +updateBotMenuButton#14b85813 bot_id:long button:BotMenuButton = Update; +updateSavedRingtones#74d8be99 = Update; +updateTranscribedAudio#84cd5a flags:# pending:flags.0?true peer:Peer msg_id:int transcription_id:long text:string = Update; +updateReadFeaturedEmojiStickers#fb4c496c = Update; +updateUserEmojiStatus#28373599 user_id:long emoji_status:EmojiStatus = Update; +updateRecentEmojiStatuses#30f443db = Update; +updateRecentReactions#6f7863f4 = Update; +updateMoveStickerSetToTop#86fccf85 flags:# masks:flags.0?true emojis:flags.1?true stickerset:long = Update; +updateMessageExtendedMedia#d5a41724 peer:Peer msg_id:int extended_media:Vector = Update; +updateUser#20529438 user_id:long = Update; +updateAutoSaveSettings#ec05b097 = Update; +updateStory#75b3b798 peer:Peer story:StoryItem = Update; +updateReadStories#f74e932b peer:Peer max_id:int = Update; +updateStoryID#1bf335b9 id:int random_id:long = Update; +updateStoriesStealthMode#2c084dc1 stealth_mode:StoriesStealthMode = Update; +updateSentStoryReaction#7d627683 peer:Peer story_id:int reaction:Reaction = Update; +updateBotChatBoost#904dd49c peer:Peer boost:Boost qts:int = Update; +updateChannelViewForumAsMessages#7b68920 channel_id:long enabled:Bool = Update; +updatePeerWallpaper#ae3f101d flags:# wallpaper_overridden:flags.1?true peer:Peer wallpaper:flags.0?WallPaper = Update; +updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer old_reactions:Vector new_reactions:Vector qts:int = Update; +updateBotMessageReactions#9cb7759 peer:Peer msg_id:int date:int reactions:Vector qts:int = Update; +updateSavedDialogPinned#aeaf9e74 flags:# pinned:flags.0?true peer:DialogPeer = Update; +updatePinnedSavedDialogs#686c85a6 flags:# order:flags.0?Vector = Update; +updateSavedReactionTags#39c67432 = Update; +updateSmsJob#f16269d4 job_id:string = Update; +updateQuickReplies#f9470ab2 quick_replies:Vector = Update; +updateNewQuickReply#f53da717 quick_reply:QuickReply = Update; +updateDeleteQuickReply#53e6f1ec shortcut_id:int = Update; +updateQuickReplyMessage#3e050d0f message:Message = Update; +updateDeleteQuickReplyMessages#566fe7cd shortcut_id:int messages:Vector = Update; +updateBotBusinessConnect#8ae5c97a connection:BotBusinessConnection qts:int = Update; +updateBotNewBusinessMessage#9ddb347c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotEditBusinessMessage#7df587c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotDeleteBusinessMessage#a02a982e connection_id:string peer:Peer messages:Vector qts:int = Update; +updateNewStoryReaction#1824e40b story_id:int peer:Peer reaction:Reaction = Update; +updateStarsBalance#4e80a379 balance:StarsAmount = Update; +updateBusinessBotCallbackQuery#1ea2fda7 flags:# query_id:long user_id:long connection_id:string message:Message reply_to_message:flags.2?Message chat_instance:long data:flags.0?bytes = Update; +updateStarsRevenueStatus#a584b019 peer:Peer status:StarsRevenueStatus = Update; +updateBotPurchasedPaidMedia#283bd312 user_id:long payload:string qts:int = Update; +updatePaidReactionPrivacy#8b725fce private:PaidReactionPrivacy = Update; +updateSentPhoneCode#504aa18f sent_code:auth.SentCode = Update; +updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector next_offset:int = Update; +updateReadMonoForumInbox#77b0e372 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateReadMonoForumOutbox#a4a79376 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateMonoForumNoPaidException#9f812b08 flags:# exception:flags.0?true channel_id:long saved_peer_id:Peer = Update; +updateGroupCallMessage#d8326f0d call:InputGroupCall message:GroupCallMessage = Update; +updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update; +updatePinnedForumTopic#683b2c52 flags:# pinned:flags.0?true peer:Peer topic_id:int = Update; +updatePinnedForumTopics#def143d0 flags:# peer:Peer order:flags.0?Vector = Update; +updateDeleteGroupCallMessages#3e85e92c call:InputGroupCall messages:Vector = Update; +updateStarGiftAuctionState#48e246c2 gift_id:long state:StarGiftAuctionState = Update; +updateStarGiftAuctionUserState#dc58f31e gift_id:long user_state:StarGiftAuctionUserState = Update; +updateEmojiGameInfo#fb9c547a info:messages.EmojiGameInfo = Update; +updateStarGiftCraftFail#ac072444 = Update; +updateChatParticipantRank#bd8367b9 chat_id:long user_id:long rank:string version:int = Update; +updateManagedBot#4880ed9a user_id:long bot_id:long qts:int = Update; +updateBotGuestChatQuery#cdd4093d flags:# query_id:long message:Message reference_messages:flags.0?Vector qts:int = Update; +updateAiComposeTones#8c0f91fb = Update; +updateJoinChatWebViewDecision#bdac7e70 peer:Peer query_id:long result:JoinChatBotResult = Update; +updateNewBotConnection#b22083a6 flags:# confirmed:flags.0?true bot_id:long date:flags.1?int device:flags.1?string location:flags.1?string = Update; +updateWebBrowserSettings#c39a2ade flags:# open_external_browser:flags.0?true display_close_button:flags.1?true = Update; +updateWebBrowserException#140502d1 flags:# delete:flags.1?true open_external_browser:flags.0?Bool exception:WebDomainException = Update; + +updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; + +updates.differenceEmpty#5d75a138 date:int seq:int = updates.Difference; +updates.difference#f49ca0 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector state:updates.State = updates.Difference; +updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector intermediate_state:updates.State = updates.Difference; +updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; + +updatesTooLong#e317af7e = Updates; +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShort#78d4dec1 update:Update date:int = Updates; +updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; +updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; +updateShortSentMessage#9015e101 flags:# out:flags.1?true id:int pts:int pts_count:int date:int media:flags.9?MessageMedia entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +photos.photos#8dca6aa5 photos:Vector users:Vector = photos.Photos; +photos.photosSlice#15051f54 count:int photos:Vector users:Vector = photos.Photos; + +photos.photo#20212ca8 photo:Photo users:Vector = photos.Photo; + +upload.file#96a18d5 type:storage.FileType mtime:int bytes:bytes = upload.File; +upload.fileCdnRedirect#f18cda44 dc_id:int file_token:bytes encryption_key:bytes encryption_iv:bytes file_hashes:Vector = upload.File; + +dcOption#18b7a10d flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true this_port_only:flags.5?true id:int ip_address:string port:int secret:flags.10?bytes = DcOption; + +config#cc1a241e flags:# default_p2p_contacts:flags.3?true preload_featured_stickers:flags.4?true revoke_pm_inbox:flags.6?true blocked_mode:flags.8?true force_try_ipv6:flags.14?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector dc_txt_domain_name:string chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int push_chat_period_ms:int push_chat_limit:int edit_time_limit:int revoke_time_limit:int revoke_pm_time_limit:int rating_e_decay:int stickers_recent_limit:int channels_read_media_period:int tmp_sessions:flags.0?int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string autoupdate_url_prefix:flags.7?string gif_search_username:flags.9?string venue_search_username:flags.10?string img_search_username:flags.11?string static_maps_provider:flags.12?string caption_length_max:int message_length_max:int webfile_dc_id:int suggested_lang_code:flags.2?string lang_pack_version:flags.2?int base_lang_pack_version:flags.2?int reactions_default:flags.15?Reaction autologin_token:flags.16?string = Config; + +nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; + +help.appUpdate#ccbbce30 flags:# can_not_skip:flags.0?true id:int version:string text:string entities:Vector document:flags.1?Document url:flags.2?string sticker:flags.3?Document = help.AppUpdate; +help.noAppUpdate#c45a6536 = help.AppUpdate; + +help.inviteText#18cb9f78 message:string = help.InviteText; + +encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; +encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; + +inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; + +encryptedFileEmpty#c21f497e = EncryptedFile; +encryptedFile#a8008cd8 id:long access_hash:long size:long dc_id:int key_fingerprint:int = EncryptedFile; + +inputEncryptedFileEmpty#1837c364 = InputEncryptedFile; +inputEncryptedFileUploaded#64bd0306 id:long parts:int md5_checksum:string key_fingerprint:int = InputEncryptedFile; +inputEncryptedFile#5a17b5e5 id:long access_hash:long = InputEncryptedFile; +inputEncryptedFileBigUploaded#2dc173c8 id:long parts:int key_fingerprint:int = InputEncryptedFile; + +encryptedMessage#ed18c118 random_id:long chat_id:int date:int bytes:bytes file:EncryptedFile = EncryptedMessage; +encryptedMessageService#23734b06 random_id:long chat_id:int date:int bytes:bytes = EncryptedMessage; + +messages.dhConfigNotModified#c0e24635 random:bytes = messages.DhConfig; +messages.dhConfig#2c221edd g:int p:bytes version:int random:bytes = messages.DhConfig; + +messages.sentEncryptedMessage#560f8935 date:int = messages.SentEncryptedMessage; +messages.sentEncryptedFile#9493ff32 date:int file:EncryptedFile = messages.SentEncryptedMessage; + +inputDocumentEmpty#72f0eaae = InputDocument; +inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; + +documentEmpty#36f8c871 id:long = Document; +document#8fd4c4d8 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:long thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; + +help.support#17c6b5f6 phone_number:string user:User = help.Support; + +notifyPeer#9fd40bd8 peer:Peer = NotifyPeer; +notifyUsers#b4c83b4c = NotifyPeer; +notifyChats#c007cec3 = NotifyPeer; +notifyBroadcasts#d612e8ef = NotifyPeer; +notifyForumTopic#226e6308 peer:Peer top_msg_id:int = NotifyPeer; + +sendMessageTypingAction#16bf744e = SendMessageAction; +sendMessageCancelAction#fd5ec8f5 = SendMessageAction; +sendMessageRecordVideoAction#a187d66f = SendMessageAction; +sendMessageUploadVideoAction#e9763aec progress:int = SendMessageAction; +sendMessageRecordAudioAction#d52f73f7 = SendMessageAction; +sendMessageUploadAudioAction#f351d7ab progress:int = SendMessageAction; +sendMessageUploadPhotoAction#d1d34a26 progress:int = SendMessageAction; +sendMessageUploadDocumentAction#aa0cd9e4 progress:int = SendMessageAction; +sendMessageGeoLocationAction#176f8ba1 = SendMessageAction; +sendMessageChooseContactAction#628cbc6f = SendMessageAction; +sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; +sendMessageRecordRoundAction#88f27fbc = SendMessageAction; +sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; +speakingInGroupCallAction#d92c2285 = SendMessageAction; +sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; +sendMessageTextDraftAction#376d975c random_id:long text:TextWithEntities = SendMessageAction; + +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; + +inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; +inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; +inputPrivacyKeyPhoneCall#fabadc5f = InputPrivacyKey; +inputPrivacyKeyPhoneP2P#db9e70d2 = InputPrivacyKey; +inputPrivacyKeyForwards#a4dd4c08 = InputPrivacyKey; +inputPrivacyKeyProfilePhoto#5719bacc = InputPrivacyKey; +inputPrivacyKeyPhoneNumber#352dafa = InputPrivacyKey; +inputPrivacyKeyAddedByPhone#d1219bdd = InputPrivacyKey; +inputPrivacyKeyVoiceMessages#aee69d68 = InputPrivacyKey; +inputPrivacyKeyAbout#3823cc40 = InputPrivacyKey; +inputPrivacyKeyBirthday#d65a11cc = InputPrivacyKey; +inputPrivacyKeyStarGiftsAutoSave#e1732341 = InputPrivacyKey; +inputPrivacyKeyNoPaidMessages#bdc597b4 = InputPrivacyKey; +inputPrivacyKeySavedMusic#4dbe9226 = InputPrivacyKey; + +privacyKeyStatusTimestamp#bc2eab30 = PrivacyKey; +privacyKeyChatInvite#500e6dfa = PrivacyKey; +privacyKeyPhoneCall#3d662b7b = PrivacyKey; +privacyKeyPhoneP2P#39491cc8 = PrivacyKey; +privacyKeyForwards#69ec56a3 = PrivacyKey; +privacyKeyProfilePhoto#96151fed = PrivacyKey; +privacyKeyPhoneNumber#d19ae46d = PrivacyKey; +privacyKeyAddedByPhone#42ffd42b = PrivacyKey; +privacyKeyVoiceMessages#697f414 = PrivacyKey; +privacyKeyAbout#a486b761 = PrivacyKey; +privacyKeyBirthday#2000a518 = PrivacyKey; +privacyKeyStarGiftsAutoSave#2ca4fdf8 = PrivacyKey; +privacyKeyNoPaidMessages#17d348d2 = PrivacyKey; +privacyKeySavedMusic#ff7a571b = PrivacyKey; + +inputPrivacyValueAllowContacts#d09e07b = InputPrivacyRule; +inputPrivacyValueAllowAll#184b35ce = InputPrivacyRule; +inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; +inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; +inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; +inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; +inputPrivacyValueAllowCloseFriends#2f453e49 = InputPrivacyRule; +inputPrivacyValueAllowPremium#77cdc9f1 = InputPrivacyRule; +inputPrivacyValueAllowBots#5a4fcce5 = InputPrivacyRule; +inputPrivacyValueDisallowBots#c4e57915 = InputPrivacyRule; + +privacyValueAllowContacts#fffe1bac = PrivacyRule; +privacyValueAllowAll#65427b82 = PrivacyRule; +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; +privacyValueDisallowContacts#f888fa1a = PrivacyRule; +privacyValueDisallowAll#8b73e763 = PrivacyRule; +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; +privacyValueAllowCloseFriends#f7e8d89b = PrivacyRule; +privacyValueAllowPremium#ece9814b = PrivacyRule; +privacyValueAllowBots#21461b5d = PrivacyRule; +privacyValueDisallowBots#f6a5f82f = PrivacyRule; + +account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; + +accountDaysTTL#b8d0afdf days:int = AccountDaysTTL; + +documentAttributeImageSize#6c37c15c w:int h:int = DocumentAttribute; +documentAttributeAnimated#11b58939 = DocumentAttribute; +documentAttributeSticker#6319d612 flags:# mask:flags.1?true alt:string stickerset:InputStickerSet mask_coords:flags.0?MaskCoords = DocumentAttribute; +documentAttributeVideo#43c57c48 flags:# round_message:flags.0?true supports_streaming:flags.1?true nosound:flags.3?true duration:double w:int h:int preload_prefix_size:flags.2?int video_start_ts:flags.4?double video_codec:flags.5?string = DocumentAttribute; +documentAttributeAudio#9852f9c6 flags:# voice:flags.10?true duration:int title:flags.0?string performer:flags.1?string waveform:flags.2?bytes = DocumentAttribute; +documentAttributeFilename#15590068 file_name:string = DocumentAttribute; +documentAttributeHasStickers#9801d2f7 = DocumentAttribute; +documentAttributeCustomEmoji#fd149899 flags:# free:flags.0?true text_color:flags.1?true alt:string stickerset:InputStickerSet = DocumentAttribute; + +messages.stickersNotModified#f1749a22 = messages.Stickers; +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; + +stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; + +messages.allStickersNotModified#e86602c3 = messages.AllStickers; +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; + +messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; + +webPageEmpty#211a1788 flags:# id:long url:flags.0?string = WebPage; +webPagePending#b0d13e47 flags:# id:long url:flags.0?string date:int = WebPage; +webPage#e89c45b2 flags:# has_large_media:flags.13?true video_cover_photo:flags.14?true id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; + +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true unconfirmed:flags.5?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; + +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; + +account.password#957b50fb flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int login_email_pattern:flags.6?string = account.Password; + +account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; + +account.passwordInputSettings#c23727c9 flags:# new_algo:flags.0?PasswordKdfAlgo new_password_hash:flags.0?bytes hint:flags.0?string email:flags.1?string new_secure_settings:flags.2?SecureSecretSettings = account.PasswordInputSettings; + +auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; + +receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; + +chatInviteExported#a22cbd96 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int subscription_expired:flags.10?int title:flags.8?string subscription_pricing:flags.9?StarsSubscriptionPricing = ExportedChatInvite; +chatInvitePublicJoinRequests#ed107ab7 = ExportedChatInvite; + +chatInviteAlready#5a686d7c chat:Chat = ChatInvite; +chatInvite#5c9d3702 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true verified:flags.7?true scam:flags.8?true fake:flags.9?true can_refulfill_subscription:flags.11?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector color:int subscription_pricing:flags.10?StarsSubscriptionPricing subscription_form_id:flags.12?long bot_verification:flags.13?BotVerification = ChatInvite; +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; + +inputStickerSetEmpty#ffb62b95 = InputStickerSet; +inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; +inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; +inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; +inputStickerSetPremiumGifts#c88b3b02 = InputStickerSet; +inputStickerSetEmojiGenericAnimations#4c4d4ce = InputStickerSet; +inputStickerSetEmojiDefaultStatuses#29d0f5ee = InputStickerSet; +inputStickerSetEmojiDefaultTopicIcons#44c1f8e9 = InputStickerSet; +inputStickerSetEmojiChannelDefaultStatuses#49748553 = InputStickerSet; +inputStickerSetTonGifts#1cf671a0 = InputStickerSet; + +stickerSet#2dd14edc flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true emojis:flags.7?true text_color:flags.9?true channel_emoji_status:flags.10?true creator:flags.11?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int thumb_document_id:flags.8?long count:int hash:int = StickerSet; + +messages.stickerSet#6e153f16 set:StickerSet packs:Vector keywords:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; + +botCommand#c27ac8c7 command:string description:string = BotCommand; + +botInfo#4d8a0299 flags:# has_preview_medias:flags.6?true user_id:flags.0?long description:flags.1?string description_photo:flags.4?Photo description_document:flags.5?Document commands:flags.2?Vector menu_button:flags.3?BotMenuButton privacy_policy_url:flags.7?string app_settings:flags.8?BotAppSettings verifier_settings:flags.9?BotVerifierSettings = BotInfo; + +keyboardButton#7d170cff flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrl#d80c25ec flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonCallback#e62bc960 flags:# requires_password:flags.0?true style:flags.10?KeyboardButtonStyle text:string data:bytes = KeyboardButton; +keyboardButtonRequestPhone#417efd8f flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonRequestGeoLocation#aa40f94d flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonSwitchInline#991399fc flags:# same_peer:flags.0?true style:flags.10?KeyboardButtonStyle text:string query:string peer_types:flags.1?Vector = KeyboardButton; +keyboardButtonGame#89c590f9 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonBuy#3fa53905 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrlAuth#f51006f9 flags:# style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +inputKeyboardButtonUrlAuth#68013e72 flags:# request_write_access:flags.0?true style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +keyboardButtonRequestPoll#7a11d782 flags:# style:flags.10?KeyboardButtonStyle quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#7d5e07c7 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#c0fd5d09 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:long = KeyboardButton; +keyboardButtonWebView#e846b1a0 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonSimpleWebView#e15c4370 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonRequestPeer#5b0f15f5 flags:# style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +inputKeyboardButtonRequestPeer#2b78156 flags:# name_requested:flags.0?true username_requested:flags.1?true photo_requested:flags.2?true style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +keyboardButtonCopy#bcc4af10 flags:# style:flags.10?KeyboardButtonStyle text:string copy_text:string = KeyboardButton; + +keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; + +replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true persistent:flags.4?true rows:Vector placeholder:flags.3?string = ReplyMarkup; +replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; + +messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; +messageEntityMention#fa04579d offset:int length:int = MessageEntity; +messageEntityHashtag#6f635b0d offset:int length:int = MessageEntity; +messageEntityBotCommand#6cef8ac7 offset:int length:int = MessageEntity; +messageEntityUrl#6ed02538 offset:int length:int = MessageEntity; +messageEntityEmail#64e475c2 offset:int length:int = MessageEntity; +messageEntityBold#bd610bc9 offset:int length:int = MessageEntity; +messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; +messageEntityCode#28a20571 offset:int length:int = MessageEntity; +messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; +messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; +inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; +messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; +messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; +messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; +messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; +messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; +messageEntityCustomEmoji#c8cf05f8 offset:int length:int document_id:long = MessageEntity; +messageEntityBlockquote#f1ccaaac flags:# collapsed:flags.0?true offset:int length:int = MessageEntity; +messageEntityFormattedDate#904ac7c7 flags:# relative:flags.0?true short_time:flags.1?true long_time:flags.2?true short_date:flags.3?true long_date:flags.4?true day_of_week:flags.5?true offset:int length:int date:int = MessageEntity; +messageEntityDiffInsert#71777116 offset:int length:int = MessageEntity; +messageEntityDiffReplace#c6c1e5a7 offset:int length:int old_text:string = MessageEntity; +messageEntityDiffDelete#652c1c5 offset:int length:int = MessageEntity; + +inputChannelEmpty#ee8c1e86 = InputChannel; +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; + +contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; + +messageRange#ae30253 min_id:int max_id:int = MessageRange; + +updates.channelDifferenceEmpty#3e11affb flags:# final:flags.0?true pts:int timeout:flags.1?int = updates.ChannelDifference; +updates.channelDifferenceTooLong#a4bcc6fe flags:# final:flags.0?true timeout:flags.1?int dialog:Dialog messages:Vector chats:Vector users:Vector = updates.ChannelDifference; +updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int new_messages:Vector other_updates:Vector chats:Vector users:Vector = updates.ChannelDifference; + +channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; +channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; + +channelParticipant#1bd54456 flags:# user_id:long date:int subscription_until_date:flags.0?int rank:flags.2?string = ChannelParticipant; +channelParticipantSelf#a9478a1a flags:# via_request:flags.0?true user_id:long inviter_id:long date:int subscription_until_date:flags.1?int rank:flags.2?string = ChannelParticipant; +channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; +channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; +channelParticipantBanned#d5f0ad91 flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights rank:flags.2?string = ChannelParticipant; +channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; + +channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; +channelParticipantsAdmins#b4608969 = ChannelParticipantsFilter; +channelParticipantsKicked#a3b54985 q:string = ChannelParticipantsFilter; +channelParticipantsBots#b0d1865b = ChannelParticipantsFilter; +channelParticipantsBanned#1427a5e1 q:string = ChannelParticipantsFilter; +channelParticipantsSearch#656ac4b q:string = ChannelParticipantsFilter; +channelParticipantsContacts#bb6ae88d q:string = ChannelParticipantsFilter; +channelParticipantsMentions#e04b5ceb flags:# q:flags.0?string top_msg_id:flags.1?int = ChannelParticipantsFilter; + +channels.channelParticipants#9ab0feaf count:int participants:Vector chats:Vector users:Vector = channels.ChannelParticipants; +channels.channelParticipantsNotModified#f0173fe9 = channels.ChannelParticipants; + +channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector users:Vector = channels.ChannelParticipant; + +help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; + +messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; + +inputBotInlineMessageMediaAuto#3380c786 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaGeo#96929a85 flags:# geo_point:InputGeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaVenue#417bbf11 flags:# geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaContact#a6edbffd flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageGame#4b425864 flags:# reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaInvoice#d7e78225 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaWebPage#bddcc510 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true optional:flags.6?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineResult#88bf9319 flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?InputWebDocument content:flags.5?InputWebDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultPhoto#a8d864a7 id:string type:string photo:InputPhoto send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultDocument#fff8fdc4 flags:# id:string type:string title:flags.1?string description:flags.2?string document:InputDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultGame#4fa417f2 id:string short_name:string send_message:InputBotInlineMessage = InputBotInlineResult; + +botInlineMessageMediaAuto#764cf810 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageText#8c7f65e2 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaGeo#51846fd flags:# geo:GeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaVenue#8a86659c flags:# geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaContact#18d1cdc2 flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaInvoice#354a9b09 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument currency:string total_amount:long reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaWebPage#809ad9a6 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true manual:flags.7?true safe:flags.8?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineResult#11965f3a flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?WebDocument content:flags.5?WebDocument send_message:BotInlineMessage = BotInlineResult; +botInlineMediaResult#17db940b flags:# id:string type:string photo:flags.0?Photo document:flags.1?Document title:flags.2?string description:flags.3?string send_message:BotInlineMessage = BotInlineResult; + +messages.botResults#e021f2f6 flags:# gallery:flags.0?true query_id:long next_offset:flags.1?string switch_pm:flags.2?InlineBotSwitchPM switch_webview:flags.3?InlineBotWebView results:Vector cache_time:int users:Vector = messages.BotResults; + +exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; + +messageFwdHeader#4e4df4bb flags:# imported:flags.7?true saved_out:flags.11?true from_id:flags.0?Peer from_name:flags.5?string date:int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int saved_from_id:flags.8?Peer saved_from_name:flags.9?string saved_date:flags.10?int psa_type:flags.6?string = MessageFwdHeader; + +auth.codeTypeSms#72a3158c = auth.CodeType; +auth.codeTypeCall#741cd3e3 = auth.CodeType; +auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; + +auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; +auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; +auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; +auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; +auth.sentCodeTypeEmailCode#f450f59b flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int reset_available_period:flags.3?int reset_pending_date:flags.4?int = auth.SentCodeType; +auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; +auth.sentCodeTypeFirebaseSms#9fd736 flags:# nonce:flags.0?bytes play_integrity_project_id:flags.2?long play_integrity_nonce:flags.2?bytes receipt:flags.1?string push_timeout:flags.1?int length:int = auth.SentCodeType; +auth.sentCodeTypeSmsWord#a416ac81 flags:# beginning:flags.0?string = auth.SentCodeType; +auth.sentCodeTypeSmsPhrase#b37794af flags:# beginning:flags.0?string = auth.SentCodeType; + +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; + +messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; + +inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; + +inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; + +messages.peerDialogs#3371c354 dialogs:Vector messages:Vector chats:Vector users:Vector state:updates.State = messages.PeerDialogs; + +topPeer#edcdc05b peer:Peer rating:double = TopPeer; + +topPeerCategoryBotsPM#ab661b5b = TopPeerCategory; +topPeerCategoryBotsInline#148677e2 = TopPeerCategory; +topPeerCategoryCorrespondents#637b7ed = TopPeerCategory; +topPeerCategoryGroups#bd17a14a = TopPeerCategory; +topPeerCategoryChannels#161d9628 = TopPeerCategory; +topPeerCategoryPhoneCalls#1e76a78c = TopPeerCategory; +topPeerCategoryForwardUsers#a8406ca9 = TopPeerCategory; +topPeerCategoryForwardChats#fbeec0f0 = TopPeerCategory; +topPeerCategoryBotsApp#fd9e7bec = TopPeerCategory; +topPeerCategoryBotsGuestChat#6c24f3dd = TopPeerCategory; + +topPeerCategoryPeers#fb834291 category:TopPeerCategory count:int peers:Vector = TopPeerCategoryPeers; + +contacts.topPeersNotModified#de266ef5 = contacts.TopPeers; +contacts.topPeers#70b772a8 categories:Vector chats:Vector users:Vector = contacts.TopPeers; +contacts.topPeersDisabled#b52c939d = contacts.TopPeers; + +draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; +draftMessage#96eaa5eb flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo message:string entities:flags.3?Vector media:flags.5?InputMedia date:int effect:flags.7?long suggested_post:flags.8?SuggestedPost = DraftMessage; + +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; +messages.featuredStickers#be382906 flags:# premium:flags.0?true hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; + +messages.recentStickersNotModified#b17f890 = messages.RecentStickers; +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; + +messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; + +messages.stickerSetInstallResultSuccess#38641628 = messages.StickerSetInstallResult; +messages.stickerSetInstallResultArchive#35e410a8 sets:Vector = messages.StickerSetInstallResult; + +stickerSetCovered#6410a5d2 set:StickerSet cover:Document = StickerSetCovered; +stickerSetMultiCovered#3407e51b set:StickerSet covers:Vector = StickerSetCovered; +stickerSetFullCovered#40d13c0e set:StickerSet packs:Vector keywords:Vector documents:Vector = StickerSetCovered; +stickerSetNoCovered#77b15d1c set:StickerSet = StickerSetCovered; + +maskCoords#aed6dbb2 n:int x:double y:double zoom:double = MaskCoords; + +inputStickeredMediaPhoto#4a992157 id:InputPhoto = InputStickeredMedia; +inputStickeredMediaDocument#438865b id:InputDocument = InputStickeredMedia; + +game#bdf9653b flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document = Game; + +inputGameID#32c3e77 id:long access_hash:long = InputGame; +inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; + +highScore#73a379eb pos:int user_id:long score:int = HighScore; + +messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; + +textEmpty#dc3d824f = RichText; +textPlain#744694e0 text:string = RichText; +textBold#6724abc4 text:RichText = RichText; +textItalic#d912a59c text:RichText = RichText; +textUnderline#c12622c4 text:RichText = RichText; +textStrike#9bf8bb95 text:RichText = RichText; +textFixed#6c3f19b9 text:RichText = RichText; +textUrl#3c2884c1 text:RichText url:string webpage_id:long = RichText; +textEmail#de5a0dd6 text:RichText email:string = RichText; +textConcat#7e6260d7 texts:Vector = RichText; +textSubscript#ed6a8504 text:RichText = RichText; +textSuperscript#c7fb5e01 text:RichText = RichText; +textMarked#34b8621 text:RichText = RichText; +textPhone#1ccb966a text:RichText phone:string = RichText; +textImage#81ccf4f document_id:long w:int h:int = RichText; +textAnchor#35553762 text:RichText name:string = RichText; + +pageBlockUnsupported#13567e8a = PageBlock; +pageBlockTitle#70abc3fd text:RichText = PageBlock; +pageBlockSubtitle#8ffa9a1f text:RichText = PageBlock; +pageBlockAuthorDate#baafe5e0 author:RichText published_date:int = PageBlock; +pageBlockHeader#bfd064ec text:RichText = PageBlock; +pageBlockSubheader#f12bb6e1 text:RichText = PageBlock; +pageBlockParagraph#467a0766 text:RichText = PageBlock; +pageBlockPreformatted#c070d93e text:RichText language:string = PageBlock; +pageBlockFooter#48870999 text:RichText = PageBlock; +pageBlockDivider#db20b188 = PageBlock; +pageBlockAnchor#ce0d37b0 name:string = PageBlock; +pageBlockList#e4e88011 items:Vector = PageBlock; +pageBlockBlockquote#263d7c26 text:RichText caption:RichText = PageBlock; +pageBlockPullquote#4f4456d3 text:RichText caption:RichText = PageBlock; +pageBlockPhoto#1759c560 flags:# photo_id:long caption:PageCaption url:flags.0?string webpage_id:flags.0?long = PageBlock; +pageBlockVideo#7c8fe7b6 flags:# autoplay:flags.0?true loop:flags.1?true video_id:long caption:PageCaption = PageBlock; +pageBlockCover#39f23300 cover:PageBlock = PageBlock; +pageBlockEmbed#a8718dc5 flags:# full_width:flags.0?true allow_scrolling:flags.3?true url:flags.1?string html:flags.2?string poster_photo_id:flags.4?long w:flags.5?int h:flags.5?int caption:PageCaption = PageBlock; +pageBlockEmbedPost#f259a80b url:string webpage_id:long author_photo_id:long author:string date:int blocks:Vector caption:PageCaption = PageBlock; +pageBlockCollage#65a0fa4d items:Vector caption:PageCaption = PageBlock; +pageBlockSlideshow#31f9590 items:Vector caption:PageCaption = PageBlock; +pageBlockChannel#ef1751b5 channel:Chat = PageBlock; +pageBlockAudio#804361ea audio_id:long caption:PageCaption = PageBlock; +pageBlockKicker#1e148390 text:RichText = PageBlock; +pageBlockTable#bf4dea82 flags:# bordered:flags.0?true striped:flags.1?true title:RichText rows:Vector = PageBlock; +pageBlockOrderedList#9a8ae1e1 items:Vector = PageBlock; +pageBlockDetails#76768bed flags:# open:flags.0?true blocks:Vector title:RichText = PageBlock; +pageBlockRelatedArticles#16115a96 title:RichText articles:Vector = PageBlock; +pageBlockMap#a44f3ef6 geo:GeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; + +phoneCallDiscardReasonMissed#85e42301 = PhoneCallDiscardReason; +phoneCallDiscardReasonDisconnect#e095c1a0 = PhoneCallDiscardReason; +phoneCallDiscardReasonHangup#57adc690 = PhoneCallDiscardReason; +phoneCallDiscardReasonBusy#faf7e8c9 = PhoneCallDiscardReason; +phoneCallDiscardReasonMigrateConferenceCall#9fbbf1f7 slug:string = PhoneCallDiscardReason; + +dataJSON#7d748d04 data:string = DataJSON; + +labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; + +invoice#49ee584 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true recurring:flags.9?true currency:string prices:Vector max_tip_amount:flags.8?long suggested_tip_amounts:flags.8?Vector terms_url:flags.10?string subscription_period:flags.11?int = Invoice; + +paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; + +postAddress#1e8caaeb street_line1:string street_line2:string city:string state:string country_iso2:string post_code:string = PostAddress; + +paymentRequestedInfo#909c3f94 flags:# name:flags.0?string phone:flags.1?string email:flags.2?string shipping_address:flags.3?PostAddress = PaymentRequestedInfo; + +paymentSavedCredentialsCard#cdc27a1f id:string title:string = PaymentSavedCredentials; + +webDocument#1c570ed1 url:string access_hash:long size:int mime_type:string attributes:Vector = WebDocument; +webDocumentNoProxy#f9c8bcc6 url:string size:int mime_type:string attributes:Vector = WebDocument; + +inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector = InputWebDocument; + +inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation; +inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation; +inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# small:flags.2?true document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation; + +upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; + +payments.paymentForm#a0058751 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON additional_methods:flags.6?Vector saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?Vector users:Vector = payments.PaymentForm; +payments.paymentFormStars#7bf6b15c flags:# form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice users:Vector = payments.PaymentForm; +payments.paymentFormStarGift#b425cfe1 form_id:long invoice:Invoice = payments.PaymentForm; + +payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; + +payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; +payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; + +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; +payments.paymentReceiptStars#dabbf83a flags:# date:int bot_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice currency:string total_amount:long transaction_id:string users:Vector = payments.PaymentReceipt; + +payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; + +inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; +inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsGooglePay#8ac32801 payment_token:DataJSON = InputPaymentCredentials; + +account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; + +shippingOption#b6213cdf id:string title:string prices:Vector = ShippingOption; + +inputStickerSetItem#32da9e9c flags:# document:InputDocument emoji:string mask_coords:flags.0?MaskCoords keywords:flags.1?string = InputStickerSetItem; + +inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; + +phoneCallEmpty#5366c915 id:long = PhoneCall; +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#30535af5 flags:# p2p_allowed:flags.5?true video:flags.6?true conference_supported:flags.8?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int custom_parameters:flags.7?DataJSON = PhoneCall; +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; + +phoneConnection#9cc123c7 flags:# tcp:flags.0?true id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; + +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; + +phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; + +upload.cdnFileReuploadNeeded#eea8e46e request_token:bytes = upload.CdnFile; +upload.cdnFile#a99fca4f bytes:bytes = upload.CdnFile; + +cdnPublicKey#c982eaba dc_id:int public_key:string = CdnPublicKey; + +cdnConfig#5725e40a public_keys:Vector = CdnConfig; + +langPackString#cad181f6 key:string value:string = LangPackString; +langPackStringPluralized#6c47ac9f flags:# key:string zero_value:flags.0?string one_value:flags.1?string two_value:flags.2?string few_value:flags.3?string many_value:flags.4?string other_value:string = LangPackString; +langPackStringDeleted#2979eeb2 key:string = LangPackString; + +langPackDifference#f385c1f6 lang_code:string from_version:int version:int strings:Vector = LangPackDifference; + +langPackLanguage#eeca5ce3 flags:# official:flags.0?true rtl:flags.2?true beta:flags.3?true name:string native_name:string lang_code:string base_lang_code:flags.1?string plural_code:string strings_count:int translated_count:int translations_url:string = LangPackLanguage; + +channelAdminLogEventActionChangeTitle#e6dfb825 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAbout#55188a2e prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsername#6a4afc38 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePhoto#434bd2af prev_photo:Photo new_photo:Photo = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleInvites#1b7907ae new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatures#26ae0971 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionUpdatePinned#e9e82c18 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionEditMessage#709b2405 prev_message:Message new_message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteMessage#42e047bb message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoin#183040d3 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantLeave#f89777f2 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantInvite#e31c34d8 participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleBan#e6d83d7e prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleAdmin#d5676710 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; +channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionDiscardGroupCall#db9f9140 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantMute#f92424d2 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantUnmute#e64429c0 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleGroupCallSetting#56d6a247 join_muted:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByInvite#fe9fc158 flags:# via_chatlist:flags.0?true invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteDelete#5a50fca4 invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePeerColor#5796e780 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeProfilePeerColor#5e477b25 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeWallpaper#31bb5d52 prev_value:WallPaper new_value:WallPaper = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStatus#3ea9feb1 prev_value:EmojiStatus new_value:EmojiStatus = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStickerSet#46d840ab prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatureProfiles#60a79c79 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantSubExtend#64642db3 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAutotranslation#c517f77e new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantEditRank#5806b4ec user_id:long prev_rank:string new_rank:string = ChannelAdminLogEventAction; + +channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; + +channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; + +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true sub_extend:flags.18?true edit_rank:flags.19?true = ChannelAdminLogEventsFilter; + +popularContact#5ce14175 client_id:long importers:int = PopularContact; + +messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; + +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; +recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; + +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; + +account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; + +inputMessageID#a676a322 id:int = InputMessage; +inputMessageReplyTo#bad88395 id:int = InputMessage; +inputMessagePinned#86872538 = InputMessage; +inputMessageCallbackQuery#acfa1a7e id:int query_id:long = InputMessage; + +inputDialogPeer#fcaafeb7 peer:InputPeer = InputDialogPeer; +inputDialogPeerFolder#64600527 folder_id:int = InputDialogPeer; + +dialogPeer#e56dbf05 peer:Peer = DialogPeer; +dialogPeerFolder#514519e2 folder_id:int = DialogPeer; + +messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; + +fileHash#f39b035c offset:long limit:int hash:bytes = FileHash; + +inputClientProxy#75588b3f address:string port:int = InputClientProxy; + +help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; +help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; + +inputSecureFileUploaded#3334b0f0 id:long parts:int md5_checksum:string file_hash:bytes secret:bytes = InputSecureFile; +inputSecureFile#5367e5be id:long access_hash:long = InputSecureFile; + +secureFileEmpty#64199744 = SecureFile; +secureFile#7d09c27e id:long access_hash:long size:long dc_id:int date:int file_hash:bytes secret:bytes = SecureFile; + +secureData#8aeabec3 data:bytes data_hash:bytes secret:bytes = SecureData; + +securePlainPhone#7d6099dd phone:string = SecurePlainData; +securePlainEmail#21ec5a5f email:string = SecurePlainData; + +secureValueTypePersonalDetails#9d2a81e3 = SecureValueType; +secureValueTypePassport#3dac6a00 = SecureValueType; +secureValueTypeDriverLicense#6e425c4 = SecureValueType; +secureValueTypeIdentityCard#a0d0744b = SecureValueType; +secureValueTypeInternalPassport#99a48f23 = SecureValueType; +secureValueTypeAddress#cbe31e26 = SecureValueType; +secureValueTypeUtilityBill#fc36954e = SecureValueType; +secureValueTypeBankStatement#89137c0d = SecureValueType; +secureValueTypeRentalAgreement#8b883488 = SecureValueType; +secureValueTypePassportRegistration#99e3806a = SecureValueType; +secureValueTypeTemporaryRegistration#ea02ec33 = SecureValueType; +secureValueTypePhone#b320aadb = SecureValueType; +secureValueTypeEmail#8e3ca7ee = SecureValueType; + +secureValue#187fa0ca flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?SecureFile reverse_side:flags.2?SecureFile selfie:flags.3?SecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData hash:bytes = SecureValue; + +inputSecureValue#db21d0a7 flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?InputSecureFile reverse_side:flags.2?InputSecureFile selfie:flags.3?InputSecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData = InputSecureValue; + +secureValueHash#ed1ecdb0 type:SecureValueType hash:bytes = SecureValueHash; + +secureValueErrorData#e8a40bd9 type:SecureValueType data_hash:bytes field:string text:string = SecureValueError; +secureValueErrorFrontSide#be3dfa type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorReverseSide#868a2aa5 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorSelfie#e537ced6 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFile#7a700873 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFiles#666220e9 type:SecureValueType file_hash:Vector text:string = SecureValueError; +secureValueError#869d758f type:SecureValueType hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFile#a1144770 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFiles#34636dd8 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureCredentialsEncrypted#33f0ea47 data:bytes hash:bytes secret:bytes = SecureCredentialsEncrypted; + +account.authorizationForm#ad2e1cd8 flags:# required_types:Vector values:Vector errors:Vector users:Vector privacy_policy_url:flags.0?string = account.AuthorizationForm; + +account.sentEmailCode#811f854f email_pattern:string length:int = account.SentEmailCode; + +help.deepLinkInfoEmpty#66afa166 = help.DeepLinkInfo; +help.deepLinkInfo#6a4ee832 flags:# update_app:flags.0?true message:string entities:flags.1?Vector = help.DeepLinkInfo; + +savedPhoneContact#1142bd56 phone:string first_name:string last_name:string date:int = SavedContact; + +account.takeout#4dba4501 id:long = account.Takeout; + +passwordKdfAlgoUnknown#d45ab096 = PasswordKdfAlgo; +passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow#3a912d4a salt1:bytes salt2:bytes g:int p:bytes = PasswordKdfAlgo; + +securePasswordKdfAlgoUnknown#4a8537 = SecurePasswordKdfAlgo; +securePasswordKdfAlgoPBKDF2HMACSHA512iter100000#bbf2dda0 salt:bytes = SecurePasswordKdfAlgo; +securePasswordKdfAlgoSHA512#86471d92 salt:bytes = SecurePasswordKdfAlgo; + +secureSecretSettings#1527bcac secure_algo:SecurePasswordKdfAlgo secure_secret:bytes secure_secret_id:long = SecureSecretSettings; + +inputCheckPasswordEmpty#9880f658 = InputCheckPasswordSRP; +inputCheckPasswordSRP#d27ff082 srp_id:long A:bytes M1:bytes = InputCheckPasswordSRP; + +secureRequiredType#829d99da flags:# native_names:flags.0?true selfie_required:flags.1?true translation_required:flags.2?true type:SecureValueType = SecureRequiredType; +secureRequiredTypeOneOf#27477b4 types:Vector = SecureRequiredType; + +help.passportConfigNotModified#bfb9f457 = help.PassportConfig; +help.passportConfig#a098d6af hash:int countries_langs:DataJSON = help.PassportConfig; + +inputAppEvent#1d1b1245 time:double type:string peer:long data:JSONValue = InputAppEvent; + +jsonObjectValue#c0de1bd9 key:string value:JSONValue = JSONObjectValue; + +jsonNull#3f6d7b68 = JSONValue; +jsonBool#c7345e6a value:Bool = JSONValue; +jsonNumber#2be0dfa4 value:double = JSONValue; +jsonString#b71e767a value:string = JSONValue; +jsonArray#f7444763 value:Vector = JSONValue; +jsonObject#99c1d49d value:Vector = JSONValue; + +pageTableCell#34566b6a flags:# header:flags.0?true align_center:flags.3?true align_right:flags.4?true valign_middle:flags.5?true valign_bottom:flags.6?true text:flags.7?RichText colspan:flags.1?int rowspan:flags.2?int = PageTableCell; + +pageTableRow#e0c0c5e5 cells:Vector = PageTableRow; + +pageCaption#6f747657 text:RichText credit:RichText = PageCaption; + +pageListItemText#b92fb6cd text:RichText = PageListItem; +pageListItemBlocks#25e073fc blocks:Vector = PageListItem; + +pageListOrderedItemText#5e068047 num:string text:RichText = PageListOrderedItem; +pageListOrderedItemBlocks#98dd8936 num:string blocks:Vector = PageListOrderedItem; + +pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; + +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; + +help.supportName#8c05f1c9 name:string = help.SupportName; + +help.userInfoEmpty#f3ae2eed = help.UserInfo; +help.userInfo#1eb3758 message:string entities:Vector author:string date:int = help.UserInfo; + +pollAnswer#4b7d786a flags:# text:TextWithEntities option:bytes media:flags.0?MessageMedia added_by:flags.1?Peer date:flags.1?int = PollAnswer; +inputPollAnswer#199fed96 flags:# text:TextWithEntities media:flags.0?InputMedia = PollAnswer; + +poll#966e2dbf id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true open_answers:flags.6?true revoting_disabled:flags.7?true shuffle_answers:flags.8?true hide_results_until_close:flags.9?true creator:flags.10?true subscribers_only:flags.11?true question:TextWithEntities answers:Vector close_period:flags.4?int close_date:flags.5?int countries_iso2:flags.12?Vector hash:long = Poll; + +pollAnswerVoters#3645230a flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:flags.2?int recent_voters:flags.2?Vector = PollAnswerVoters; + +pollResults#ba7bb15e flags:# min:flags.0?true has_unread_votes:flags.6?true can_view_stats:flags.7?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector solution_media:flags.5?MessageMedia = PollResults; + +chatOnlines#f041e250 onlines:int = ChatOnlines; + +statsURL#47a971e0 url:string = StatsURL; + +chatAdminRights#5fb224d5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true pin_messages:flags.7?true add_admins:flags.9?true anonymous:flags.10?true manage_call:flags.11?true other:flags.12?true manage_topics:flags.13?true post_stories:flags.14?true edit_stories:flags.15?true delete_stories:flags.16?true manage_direct_messages:flags.17?true manage_ranks:flags.18?true = ChatAdminRights; + +chatBannedRights#9f120418 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true send_polls:flags.8?true change_info:flags.10?true invite_users:flags.15?true pin_messages:flags.17?true manage_topics:flags.18?true send_photos:flags.19?true send_videos:flags.20?true send_roundvideos:flags.21?true send_audios:flags.22?true send_voices:flags.23?true send_docs:flags.24?true send_plain:flags.25?true edit_rank:flags.26?true send_reactions:flags.27?true until_date:int = ChatBannedRights; + +inputWallPaper#e630b979 id:long access_hash:long = InputWallPaper; +inputWallPaperSlug#72091c80 slug:string = InputWallPaper; +inputWallPaperNoFile#967a462e id:long = InputWallPaper; + +account.wallPapersNotModified#1c199183 = account.WallPapers; +account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; + +codeSettings#ad253d78 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true allow_firebase:flags.7?true unknown_number:flags.9?true logout_tokens:flags.6?Vector token:flags.8?string app_sandbox:flags.8?Bool = CodeSettings; + +wallPaperSettings#372efcd0 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int emoticon:flags.7?string = WallPaperSettings; + +autoDownloadSettings#baa57628 flags:# disabled:flags.0?true video_preload_large:flags.1?true audio_preload_next:flags.2?true phonecalls_less_data:flags.3?true stories_preload:flags.4?true photo_size_max:int video_size_max:long file_size_max:long video_upload_maxbitrate:int small_queue_active_operations_max:int large_queue_active_operations_max:int = AutoDownloadSettings; + +account.autoDownloadSettings#63cacf26 low:AutoDownloadSettings medium:AutoDownloadSettings high:AutoDownloadSettings = account.AutoDownloadSettings; + +emojiKeyword#d5b3b9f9 keyword:string emoticons:Vector = EmojiKeyword; +emojiKeywordDeleted#236df622 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordsDifference#5cc761bd lang_code:string from_version:int version:int keywords:Vector = EmojiKeywordsDifference; + +emojiURL#a575739d url:string = EmojiURL; + +emojiLanguage#b3fb5361 lang_code:string = EmojiLanguage; + +folder#ff544e65 flags:# autofill_new_broadcasts:flags.0?true autofill_public_groups:flags.1?true autofill_new_correspondents:flags.2?true id:int title:string photo:flags.3?ChatPhoto = Folder; + +inputFolderPeer#fbd2c296 peer:InputPeer folder_id:int = InputFolderPeer; + +folderPeer#e9baa668 peer:Peer folder_id:int = FolderPeer; + +messages.searchCounter#e844ebff flags:# inexact:flags.1?true filter:MessagesFilter count:int = messages.SearchCounter; + +urlAuthResultRequest#3cd623ec flags:# request_write_access:flags.0?true request_phone_number:flags.1?true match_codes_first:flags.5?true is_app:flags.6?true bot:User domain:string browser:flags.2?string platform:flags.2?string ip:flags.2?string region:flags.2?string match_codes:flags.3?Vector user_id_hint:flags.4?long verified_app_name:flags.7?string = UrlAuthResult; +urlAuthResultAccepted#623a8fa0 flags:# url:flags.0?string = UrlAuthResult; +urlAuthResultDefault#a9d6db1f = UrlAuthResult; + +channelLocationEmpty#bfb5ad8b = ChannelLocation; +channelLocation#209b82db geo_point:GeoPoint address:string = ChannelLocation; + +peerLocated#ca461b5d peer:Peer expires:int distance:int = PeerLocated; +peerSelfLocated#f8ec284b expires:int = PeerLocated; + +restrictionReason#d072acb4 platform:string reason:string text:string = RestrictionReason; + +inputTheme#3c5693e9 id:long access_hash:long = InputTheme; +inputThemeSlug#f5890df1 slug:string = InputTheme; + +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; + +account.themesNotModified#f41eb622 = account.Themes; +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; + +auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; +auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; +auth.loginTokenSuccess#390d5c5e authorization:auth.Authorization = auth.LoginToken; + +account.contentSettings#57e28221 flags:# sensitive_enabled:flags.0?true sensitive_can_change:flags.1?true = account.ContentSettings; + +messages.inactiveChats#a927fec5 dates:Vector chats:Vector users:Vector = messages.InactiveChats; + +baseThemeClassic#c3a12462 = BaseTheme; +baseThemeDay#fbd81688 = BaseTheme; +baseThemeNight#b7b31ea8 = BaseTheme; +baseThemeTinted#6d5f77ee = BaseTheme; +baseThemeArctic#5b11125a = BaseTheme; + +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; + +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; + +webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; +webPageAttributeStory#2e94c3e7 flags:# peer:Peer id:int story:flags.0?StoryItem = WebPageAttribute; +webPageAttributeStickerSet#50cc03d3 flags:# emojis:flags.0?true text_color:flags.1?true stickers:Vector = WebPageAttribute; +webPageAttributeUniqueStarGift#cf6f6db8 gift:StarGift = WebPageAttribute; +webPageAttributeStarGiftCollection#31cad303 icons:Vector = WebPageAttribute; +webPageAttributeStarGiftAuction#1c641c2 gift:StarGift end_date:int = WebPageAttribute; +webPageAttributeAiComposeTone#7781fe18 emoji_id:long = WebPageAttribute; + +messages.votesList#4899484e flags:# count:int votes:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.VotesList; + +bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; + +payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; + +dialogFilter#aa472651 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; +dialogFilterDefault#363293ae = DialogFilter; +dialogFilterChatlist#96537bd7 flags:# has_my_invites:flags.26?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; +statsGraphError#bedc9822 error:string = StatsGraph; +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +stats.broadcastStats#396ca5fc period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev reactions_per_post:StatsAbsValueAndPrev views_per_story:StatsAbsValueAndPrev shares_per_story:StatsAbsValueAndPrev reactions_per_story:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph reactions_by_emotion_graph:StatsGraph story_interactions_graph:StatsGraph story_reactions_by_emotion_graph:StatsGraph recent_posts_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; +help.promoData#8a4d87a flags:# proxy:flags.0?true expires:int peer:flags.3?Peer psa_type:flags.1?string psa_message:flags.2?string pending_suggestions:Vector dismissed_suggestions:Vector custom_pending_suggestion:flags.4?PendingSuggestion chats:Vector users:Vector = help.PromoData; + +videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; +videoSizeEmojiMarkup#f85c413c emoji_id:long background_colors:Vector = VideoSize; +videoSizeStickerMarkup#da082fe stickerset:InputStickerSet sticker_id:long background_colors:Vector = VideoSize; + +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#fe41b34f flags:# archive_and_mute_new_noncontact_peers:flags.0?true keep_archived_unmuted:flags.1?true keep_archived_folders:flags.2?true hide_read_marks:flags.3?true new_noncontact_peers_require_premium:flags.4?true display_gifts_button:flags.7?true noncontact_peers_paid_stars:flags.5?long disallowed_gifts:flags.6?DisallowedGiftsSettings = GlobalPrivacySettings; + +help.countryCode#4203c5ef flags:# country_code:string prefixes:flags.0?Vector patterns:flags.1?Vector = help.CountryCode; + +help.country#c3878e23 flags:# hidden:flags.0?true iso2:string default_name:string name:flags.1?string country_codes:Vector = help.Country; + +help.countriesListNotModified#93cc1f32 = help.CountriesList; +help.countriesList#87d0759e countries:Vector hash:int = help.CountriesList; + +messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:flags.2?MessageReplies = MessageViews; + +messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; + +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; + +messageReplyHeader#1b97dd66 flags:# reply_to_scheduled:flags.2?true forum_topic:flags.3?true quote:flags.9?true reply_to_msg_id:flags.4?int reply_to_peer_id:flags.0?Peer reply_from:flags.5?MessageFwdHeader reply_media:flags.8?MessageMedia reply_to_top_id:flags.1?int quote_text:flags.6?string quote_entities:flags.7?Vector quote_offset:flags.10?int todo_item_id:flags.11?int poll_option:flags.12?bytes = MessageReplyHeader; +messageReplyStoryHeader#e5af939 peer:Peer story_id:int = MessageReplyHeader; + +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; + +peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; + +stats.messageStats#7fe91c14 views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.MessageStats; + +groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; +groupCall#efb2b617 flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true conference:flags.14?true creator:flags.15?true messages_enabled:flags.17?true can_change_messages_enabled:flags.18?true min:flags.19?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int invite_link:flags.16?string send_paid_messages_stars:flags.20?long default_send_as:flags.21?Peer = GroupCall; + +inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; +inputGroupCallSlug#fe06823f slug:string = InputGroupCall; +inputGroupCallInviteMessage#8c10603f msg_id:int = InputGroupCall; + +groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant; + +phone.groupCall#9e727aad call:GroupCall participants:Vector participants_next_offset:string chats:Vector users:Vector = phone.GroupCall; + +phone.groupParticipants#f47751b6 count:int participants:Vector next_offset:string chats:Vector users:Vector version:int = phone.GroupParticipants; + +inlineQueryPeerTypeSameBotPM#3081ed9d = InlineQueryPeerType; +inlineQueryPeerTypePM#833c0fac = InlineQueryPeerType; +inlineQueryPeerTypeChat#d766c50a = InlineQueryPeerType; +inlineQueryPeerTypeMegagroup#5ec4be43 = InlineQueryPeerType; +inlineQueryPeerTypeBroadcast#6334ee9a = InlineQueryPeerType; +inlineQueryPeerTypeBotPM#e3b2d0c = InlineQueryPeerType; + +messages.historyImport#1662af0b id:long = messages.HistoryImport; + +messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true title:flags.2?string = messages.HistoryImportParsed; + +messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; + +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true via_chatlist:flags.3?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; + +messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; + +messages.exportedChatInvite#1871be50 invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; +messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; + +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; + +messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; + +messages.checkedHistoryImportPeer#a24de717 confirm_text:string = messages.CheckedHistoryImportPeer; + +phone.joinAsPeers#afe5623f peers:Vector chats:Vector users:Vector = phone.JoinAsPeers; + +phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvite; + +groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; + +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; +botCommandScopeUsers#3c4f04d8 = BotCommandScope; +botCommandScopeChats#6fe1a881 = BotCommandScope; +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +chatTheme#c3dffc04 emoticon:string = ChatTheme; +chatThemeUniqueGift#3458f9c8 gift:StarGift theme_settings:Vector = ChatTheme; + +account.chatThemesNotModified#e011e1c4 = account.ChatThemes; +account.chatThemes#be098173 flags:# hash:long themes:Vector chats:Vector users:Vector next_offset:flags.0?string = account.ChatThemes; + +sponsoredMessage#7dbf8673 flags:# recommended:flags.5?true can_report:flags.12?true random_id:bytes url:string title:string message:string entities:flags.1?Vector photo:flags.6?Photo media:flags.14?MessageMedia color:flags.13?PeerColor button_text:string sponsor_info:flags.7?string additional_info:flags.8?string min_display_duration:flags.15?int max_display_duration:flags.15?int = SponsoredMessage; + +messages.sponsoredMessages#ffda656d flags:# posts_between:flags.0?int start_delay:flags.1?int between_delay:flags.2?int messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +messages.sponsoredMessagesEmpty#1839490f = messages.SponsoredMessages; + +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#f496b0c6 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#a3d1cb80 flags:# chosen_order:flags.0?int reaction:Reaction count:int = ReactionCount; + +messageReactions#a339f0b flags:# min:flags.0?true can_see_list:flags.2?true reactions_as_tags:flags.3?true results:Vector recent_reactions:flags.1?Vector top_reactors:flags.4?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true premium:flags.2?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + +messagePeerReaction#8c79b63c flags:# big:flags.0?true unread:flags.1?true my:flags.2?true peer_id:Peer date:int reaction:Reaction = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; + +attachMenuBotIconColor#4576f3f0 name:string color:int = AttachMenuBotIconColor; + +attachMenuBotIcon#b2a7386b flags:# name:string icon:Document colors:flags.0?Vector = AttachMenuBotIcon; + +attachMenuBot#d90d8dfe flags:# inactive:flags.0?true has_settings:flags.1?true request_write_access:flags.2?true show_in_attach_menu:flags.3?true show_in_side_menu:flags.4?true side_menu_disclaimer_needed:flags.5?true bot_id:long short_name:string peer_types:flags.3?Vector icons:Vector = AttachMenuBot; + +attachMenuBotsNotModified#f1d88a5c = AttachMenuBots; +attachMenuBots#3c4301c0 hash:long bots:Vector users:Vector = AttachMenuBots; + +attachMenuBotsBot#93bf667f bot:AttachMenuBot users:Vector = AttachMenuBotsBot; + +webViewResultUrl#4d22ff98 flags:# fullsize:flags.1?true fullscreen:flags.2?true query_id:flags.0?long url:string = WebViewResult; + +webViewMessageSent#c94511c flags:# msg_id:flags.0?InputBotInlineMessageID = WebViewMessageSent; + +botMenuButtonDefault#7533a588 = BotMenuButton; +botMenuButtonCommands#4258c205 = BotMenuButton; +botMenuButton#c7b57ce6 text:string url:string = BotMenuButton; + +account.savedRingtonesNotModified#fbf6e8b1 = account.SavedRingtones; +account.savedRingtones#c1e92cc5 hash:long ringtones:Vector = account.SavedRingtones; + +notificationSoundDefault#97e8bebe = NotificationSound; +notificationSoundNone#6f0c34df = NotificationSound; +notificationSoundLocal#830b9ae4 title:string data:string = NotificationSound; +notificationSoundRingtone#ff6c8049 id:long = NotificationSound; + +account.savedRingtone#b7263f6d = account.SavedRingtone; +account.savedRingtoneConverted#1f307eb7 document:Document = account.SavedRingtone; + +attachMenuPeerTypeSameBotPM#7d6be90e = AttachMenuPeerType; +attachMenuPeerTypeBotPM#c32bfa1a = AttachMenuPeerType; +attachMenuPeerTypePM#f146d31f = AttachMenuPeerType; +attachMenuPeerTypeChat#509113f = AttachMenuPeerType; +attachMenuPeerTypeBroadcast#7bfbdefc = AttachMenuPeerType; + +inputInvoiceMessage#c5b56859 peer:InputPeer msg_id:int = InputInvoice; +inputInvoiceSlug#c326caef slug:string = InputInvoice; +inputInvoicePremiumGiftCode#98986c0d purpose:InputStorePaymentPurpose option:PremiumGiftCodeOption = InputInvoice; +inputInvoiceStars#65f00ce3 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceChatInviteSubscription#34e793f1 hash:string = InputInvoice; +inputInvoiceStarGift#e8625e92 flags:# hide_name:flags.0?true include_upgrade:flags.2?true peer:InputPeer gift_id:long message:flags.1?TextWithEntities = InputInvoice; +inputInvoiceStarGiftUpgrade#4d818d5d flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftTransfer#4a5f5bd9 stargift:InputSavedStarGift to_id:InputPeer = InputInvoice; +inputInvoicePremiumGiftStars#dabab2ef flags:# user_id:InputUser months:int message:flags.0?TextWithEntities = InputInvoice; +inputInvoiceBusinessBotTransferStars#f4997e42 bot:InputUser stars:long = InputInvoice; +inputInvoiceStarGiftResale#c39f5324 flags:# ton:flags.0?true slug:string to_id:InputPeer = InputInvoice; +inputInvoiceStarGiftPrepaidUpgrade#9a0b48b8 peer:InputPeer hash:string = InputInvoice; +inputInvoicePremiumAuthCode#3e77f614 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceStarGiftDropOriginalDetails#923d8d1 stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftAuctionBid#1ecafa10 flags:# hide_name:flags.0?true update_bid:flags.2?true peer:flags.3?InputPeer gift_id:long bid_amount:long message:flags.1?TextWithEntities = InputInvoice; + +payments.exportedInvoice#aed0cbd9 url:string = payments.ExportedInvoice; + +messages.transcribedAudio#cfb9d957 flags:# pending:flags.0?true transcription_id:long text:string trial_remains_num:flags.1?int trial_remains_until_date:flags.1?int = messages.TranscribedAudio; + +help.premiumPromo#5334759c status_text:string status_entities:Vector video_sections:Vector videos:Vector period_options:Vector users:Vector = help.PremiumPromo; + +inputStorePaymentPremiumSubscription#a6751e66 flags:# restore:flags.0?true upgrade:flags.1?true = InputStorePaymentPurpose; +inputStorePaymentGiftPremium#616f7fe8 user_id:InputUser currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentPremiumGiftCode#fb790393 flags:# users:Vector boost_peer:flags.0?InputPeer currency:string amount:long message:flags.1?TextWithEntities = InputStorePaymentPurpose; +inputStorePaymentPremiumGiveaway#160544ca flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsTopup#f9a2a6cb flags:# stars:long currency:string amount:long spend_purpose_peer:flags.0?InputPeer = InputStorePaymentPurpose; +inputStorePaymentStarsGift#1d741ef7 user_id:InputUser stars:long currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsGiveaway#751f08fa flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true stars:long boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long users:int = InputStorePaymentPurpose; +inputStorePaymentAuthCode#3fc18057 flags:# restore:flags.0?true phone_number:string phone_code_hash:string premium_days:int currency:string amount:long = InputStorePaymentPurpose; + +paymentFormMethod#88f8f21b url:string title:string = PaymentFormMethod; + +emojiStatusEmpty#2de11aae = EmojiStatus; +emojiStatus#e7ff068a flags:# document_id:long until:flags.0?int = EmojiStatus; +emojiStatusCollectible#7184603b flags:# collectible_id:long document_id:long title:string slug:string pattern_document_id:long center_color:int edge_color:int pattern_color:int text_color:int until:flags.0?int = EmojiStatus; +inputEmojiStatusCollectible#7141dbf flags:# collectible_id:long until:flags.0?int = EmojiStatus; + +account.emojiStatusesNotModified#d08ce645 = account.EmojiStatuses; +account.emojiStatuses#90c467d1 hash:long statuses:Vector = account.EmojiStatuses; + +reactionEmpty#79f5d419 = Reaction; +reactionEmoji#1b2286b8 emoticon:string = Reaction; +reactionCustomEmoji#8935fc73 document_id:long = Reaction; +reactionPaid#523da4eb = Reaction; + +chatReactionsNone#eafc32bc = ChatReactions; +chatReactionsAll#52928bca flags:# allow_custom:flags.0?true = ChatReactions; +chatReactionsSome#661d4037 reactions:Vector = ChatReactions; + +messages.reactionsNotModified#b06fdbdf = messages.Reactions; +messages.reactions#eafdf716 hash:long reactions:Vector = messages.Reactions; + +emailVerifyPurposeLoginSetup#4345be73 phone_number:string phone_code_hash:string = EmailVerifyPurpose; +emailVerifyPurposeLoginChange#527d22eb = EmailVerifyPurpose; +emailVerifyPurposePassport#bbf51685 = EmailVerifyPurpose; + +emailVerificationCode#922e55a9 code:string = EmailVerification; +emailVerificationGoogle#db909ec2 token:string = EmailVerification; +emailVerificationApple#96d074fd token:string = EmailVerification; + +account.emailVerified#2b96cd1b email:string = account.EmailVerified; +account.emailVerifiedLogin#e1bb0d61 email:string sent_code:auth.SentCode = account.EmailVerified; + +premiumSubscriptionOption#5f2d1df2 flags:# current:flags.1?true can_purchase_upgrade:flags.2?true transaction:flags.3?string months:int currency:string amount:long bot_url:string store_product:flags.0?string = PremiumSubscriptionOption; + +sendAsPeer#b81c7034 flags:# premium_required:flags.0?true peer:Peer = SendAsPeer; + +messageExtendedMediaPreview#ad628cc8 flags:# w:flags.0?int h:flags.0?int thumb:flags.1?PhotoSize video_duration:flags.2?int = MessageExtendedMedia; +messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; + +stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; + +username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; + +forumTopicDeleted#23f109b id:int = ForumTopic; +forumTopic#fcdad815 flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true title_missing:flags.7?true id:int date:int peer:Peer title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; + +messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; + +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + +requestPeerTypeUser#5f3b8a00 flags:# bot:flags.0?Bool premium:flags.1?Bool = RequestPeerType; +requestPeerTypeChat#c9f06e1b flags:# creator:flags.0?true bot_participant:flags.5?true has_username:flags.3?Bool forum:flags.4?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeBroadcast#339bef6c flags:# creator:flags.0?true has_username:flags.3?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeCreateBot#3e81e078 flags:# bot_managed:flags.0?true suggested_name:flags.1?string suggested_username:flags.2?string = RequestPeerType; + +emojiListNotModified#481eadfa = EmojiList; +emojiList#7a1e11d1 hash:long document_id:Vector = EmojiList; + +emojiGroup#7a9abda9 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupGreeting#80d26cc7 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupPremium#93bcf34 title:string icon_emoji_id:long = EmojiGroup; + +messages.emojiGroupsNotModified#6fb4ad87 = messages.EmojiGroups; +messages.emojiGroups#881fb94b hash:int groups:Vector = messages.EmojiGroups; + +textWithEntities#751f3146 text:string entities:Vector = TextWithEntities; + +messages.translateResult#33db32f8 result:Vector = messages.TranslatedText; + +autoSaveSettings#c84834ce flags:# photos:flags.0?true videos:flags.1?true video_max_size:flags.2?long = AutoSaveSettings; + +autoSaveException#81602d47 peer:Peer settings:AutoSaveSettings = AutoSaveException; + +account.autoSaveSettings#4c3e069d users_settings:AutoSaveSettings chats_settings:AutoSaveSettings broadcasts_settings:AutoSaveSettings exceptions:Vector chats:Vector users:Vector = account.AutoSaveSettings; + +help.appConfigNotModified#7cde641d = help.AppConfig; +help.appConfig#dd18782e hash:int config:JSONValue = help.AppConfig; + +inputBotAppID#a920bd7a id:long access_hash:long = InputBotApp; +inputBotAppShortName#908c0407 bot_id:InputUser short_name:string = InputBotApp; + +botAppNotModified#5da674b7 = BotApp; +botApp#95fcd1d6 flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document hash:long = BotApp; + +messages.botApp#eb50adf5 flags:# inactive:flags.0?true request_write_access:flags.1?true has_settings:flags.2?true app:BotApp = messages.BotApp; + +inlineBotWebView#b57295d5 text:string url:string = InlineBotWebView; + +readParticipantDate#4a4ff172 user_id:long date:int = ReadParticipantDate; + +inputChatlistDialogFilter#f3e0da33 filter_id:int = InputChatlist; + +exportedChatlistInvite#c5181ac flags:# title:string url:string peers:Vector = ExportedChatlistInvite; + +chatlists.exportedChatlistInvite#10e6e3a6 filter:DialogFilter invite:ExportedChatlistInvite = chatlists.ExportedChatlistInvite; + +chatlists.exportedInvites#10ab6dc7 invites:Vector chats:Vector users:Vector = chatlists.ExportedInvites; + +chatlists.chatlistInviteAlready#fa87f659 filter_id:int missing_peers:Vector already_peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; +chatlists.chatlistInvite#f10ece2f flags:# title_noanimate:flags.1?true title:TextWithEntities emoticon:flags.0?string peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistUpdates#93bd878d missing_peers:Vector chats:Vector users:Vector = chatlists.ChatlistUpdates; + +bots.botInfo#e8a775b0 name:string about:string description:string = bots.BotInfo; + +messagePeerVote#b6cc2d5c peer:Peer option:bytes date:int = MessagePeerVote; +messagePeerVoteInputOption#74cda504 peer:Peer date:int = MessagePeerVote; +messagePeerVoteMultiple#4628f6e6 peer:Peer options:Vector date:int = MessagePeerVote; + +storyViews#8d595cd6 flags:# has_viewers:flags.1?true views_count:int forwards_count:flags.2?int reactions:flags.3?Vector reactions_count:flags.4?int recent_viewers:flags.0?Vector = StoryViews; + +storyItemDeleted#51e6ee4f id:int = StoryItem; +storyItemSkipped#ffadc913 flags:# close_friends:flags.8?true live:flags.9?true id:int date:int expire_date:int = StoryItem; +storyItem#16a4b93c flags:# pinned:flags.5?true public:flags.7?true close_friends:flags.8?true min:flags.9?true noforwards:flags.10?true edited:flags.11?true contacts:flags.12?true selected_contacts:flags.13?true out:flags.16?true id:int date:int from_id:flags.18?Peer fwd_from:flags.17?StoryFwdHeader expire_date:int caption:flags.0?string entities:flags.1?Vector media:MessageMedia media_areas:flags.14?Vector privacy:flags.2?Vector views:flags.3?StoryViews sent_reaction:flags.15?Reaction albums:flags.19?Vector music:flags.20?Document = StoryItem; + +stories.allStoriesNotModified#1158fe3e flags:# state:string stealth_mode:StoriesStealthMode = stories.AllStories; +stories.allStories#6efc5e81 flags:# has_more:flags.0?true count:int state:string peer_stories:Vector chats:Vector users:Vector stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.stories#63c3dd0a flags:# count:int stories:Vector pinned_to_top:flags.0?Vector chats:Vector users:Vector = stories.Stories; + +storyView#b0bdeac5 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true user_id:long date:int reaction:flags.2?Reaction = StoryView; +storyViewPublicForward#9083670b flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true message:Message = StoryView; +storyViewPublicRepost#bd74cf49 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer story:StoryItem = StoryView; + +stories.storyViewsList#59d78fc5 flags:# count:int views_count:int forwards_count:int reactions_count:int views:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryViewsList; + +stories.storyViews#de9eed1d views:Vector users:Vector = stories.StoryViews; + +inputReplyToMessage#3bd4b7c2 flags:# reply_to_msg_id:int top_msg_id:flags.0?int reply_to_peer_id:flags.1?InputPeer quote_text:flags.2?string quote_entities:flags.3?Vector quote_offset:flags.4?int monoforum_peer_id:flags.5?InputPeer todo_item_id:flags.6?int poll_option:flags.7?bytes = InputReplyTo; +inputReplyToStory#5881323a peer:InputPeer story_id:int = InputReplyTo; +inputReplyToMonoForum#69d66c45 monoforum_peer_id:InputPeer = InputReplyTo; + +exportedStoryLink#3fc9053b link:string = ExportedStoryLink; + +storiesStealthMode#712e27fd flags:# active_until_date:flags.0?int cooldown_until_date:flags.1?int = StoriesStealthMode; + +mediaAreaCoordinates#cfc9e002 flags:# x:double y:double w:double h:double rotation:double radius:flags.0?double = MediaAreaCoordinates; + +mediaAreaVenue#be82db9c coordinates:MediaAreaCoordinates geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MediaArea; +inputMediaAreaVenue#b282217f coordinates:MediaAreaCoordinates query_id:long result_id:string = MediaArea; +mediaAreaGeoPoint#cad5452d flags:# coordinates:MediaAreaCoordinates geo:GeoPoint address:flags.0?GeoPointAddress = MediaArea; +mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; +mediaAreaChannelPost#770416af coordinates:MediaAreaCoordinates channel_id:long msg_id:int = MediaArea; +inputMediaAreaChannelPost#2271f2bf coordinates:MediaAreaCoordinates channel:InputChannel msg_id:int = MediaArea; +mediaAreaUrl#37381085 coordinates:MediaAreaCoordinates url:string = MediaArea; +mediaAreaWeather#49a6549c coordinates:MediaAreaCoordinates emoji:string temperature_c:double color:int = MediaArea; +mediaAreaStarGift#5787686d coordinates:MediaAreaCoordinates slug:string = MediaArea; + +peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; + +stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; + +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; + +premiumGiftCodeOption#257e962b flags:# users:int months:int store_product:flags.0?string store_quantity:flags.1?int currency:string amount:long = PremiumGiftCodeOption; + +payments.checkedGiftCode#eb983f8f flags:# via_giveaway:flags.2?true from_id:flags.4?Peer giveaway_msg_id:flags.3?int to_id:flags.0?long date:int days:int used_date:flags.1?int chats:Vector users:Vector = payments.CheckedGiftCode; + +payments.giveawayInfo#4367daa0 flags:# participating:flags.0?true preparing_results:flags.3?true start_date:int joined_too_early_date:flags.1?int admin_disallowed_chat_id:flags.2?long disallowed_country:flags.4?string = payments.GiveawayInfo; +payments.giveawayInfoResults#e175e66f flags:# winner:flags.0?true refunded:flags.1?true start_date:int gift_code_slug:flags.3?string stars_prize:flags.4?long finish_date:int winners_count:int activated_count:flags.2?int = payments.GiveawayInfo; + +prepaidGiveaway#b2539d54 id:long months:int quantity:int date:int = PrepaidGiveaway; +prepaidStarsGiveaway#9a9d77e0 id:long stars:long quantity:int boosts:int date:int = PrepaidGiveaway; + +boost#4b3e14d6 flags:# gift:flags.1?true giveaway:flags.2?true unclaimed:flags.3?true id:string user_id:flags.0?long giveaway_msg_id:flags.2?int date:int expires:int used_gift_slug:flags.4?string multiplier:flags.5?int stars:flags.6?long = Boost; + +premium.boostsList#86f8613c flags:# count:int boosts:Vector next_offset:flags.0?string users:Vector = premium.BoostsList; + +myBoost#c448415c flags:# slot:int peer:flags.0?Peer date:int expires:int cooldown_until_date:flags.1?int = MyBoost; + +premium.myBoosts#9ae228e2 my_boosts:Vector chats:Vector users:Vector = premium.MyBoosts; + +premium.boostsStatus#4959427a flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int gift_boosts:flags.4?int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string prepaid_giveaways:flags.3?Vector my_boost_slots:flags.2?Vector = premium.BoostsStatus; + +storyFwdHeader#b826e150 flags:# modified:flags.3?true from:flags.0?Peer from_name:flags.1?string story_id:flags.2?int = StoryFwdHeader; + +postInteractionCountersMessage#e7058e7f msg_id:int views:int forwards:int reactions:int = PostInteractionCounters; +postInteractionCountersStory#8a480e27 story_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +stats.storyStats#50cd067c views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.StoryStats; + +publicForwardMessage#1f2bf4a message:Message = PublicForward; +publicForwardStory#edf3add0 peer:Peer story:StoryItem = PublicForward; + +stats.publicForwards#93037e20 flags:# count:int forwards:Vector next_offset:flags.0?string chats:Vector users:Vector = stats.PublicForwards; + +peerColor#b54b5acf flags:# color:flags.0?int background_emoji_id:flags.1?long = PeerColor; +peerColorCollectible#b9c0639a flags:# collectible_id:long gift_emoji_id:long background_emoji_id:long accent_color:int colors:Vector dark_accent_color:flags.0?int dark_colors:flags.1?Vector = PeerColor; +inputPeerColorCollectible#b8ea86a9 collectible_id:long = PeerColor; + +help.peerColorSet#26219a58 colors:Vector = help.PeerColorSet; +help.peerColorProfileSet#767d61eb palette_colors:Vector bg_colors:Vector story_colors:Vector = help.PeerColorSet; + +help.peerColorOption#adec6ebe flags:# hidden:flags.0?true color_id:int colors:flags.1?help.PeerColorSet dark_colors:flags.2?help.PeerColorSet channel_min_level:flags.3?int group_min_level:flags.4?int = help.PeerColorOption; + +help.peerColorsNotModified#2ba1f5ce = help.PeerColors; +help.peerColors#f8ed08 hash:int colors:Vector = help.PeerColors; + +storyReaction#6090d6d5 peer_id:Peer date:int reaction:Reaction = StoryReaction; +storyReactionPublicForward#bbab2643 message:Message = StoryReaction; +storyReactionPublicRepost#cfcd0f13 peer_id:Peer story:StoryItem = StoryReaction; + +stories.storyReactionsList#aa5f789c flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryReactionsList; + +savedDialog#bd87cb6c flags:# pinned:flags.2?true peer:Peer top_message:int = SavedDialog; +monoForumDialog#64407ea7 flags:# unread_mark:flags.3?true nopaid_messages_exception:flags.4?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_reactions_count:int draft:flags.1?DraftMessage = SavedDialog; + +messages.savedDialogs#f83ae221 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsSlice#44ba9dd9 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsNotModified#c01f6fe8 count:int = messages.SavedDialogs; + +savedReactionTag#cb6ff828 flags:# reaction:Reaction title:flags.0?string count:int = SavedReactionTag; + +messages.savedReactionTagsNotModified#889b59ef = messages.SavedReactionTags; +messages.savedReactionTags#3259950a tags:Vector hash:long = messages.SavedReactionTags; + +outboxReadDate#3bb842ac date:int = OutboxReadDate; + +smsjobs.eligibleToJoin#dc8b44cf terms_url:string monthly_sent_sms:int = smsjobs.EligibilityToJoin; + +smsjobs.status#2aee9191 flags:# allow_international:flags.0?true recent_sent:int recent_since:int recent_remains:int total_sent:int total_since:int last_gift_slug:flags.1?string terms_url:string = smsjobs.Status; + +smsJob#e6a1eeb8 job_id:string phone_number:string text:string = SmsJob; + +businessWeeklyOpen#120b1ab9 start_minute:int end_minute:int = BusinessWeeklyOpen; + +businessWorkHours#8c92b098 flags:# open_now:flags.0?true timezone_id:string weekly_open:Vector = BusinessWorkHours; + +businessLocation#ac5c1af7 flags:# geo_point:flags.0?GeoPoint address:string = BusinessLocation; + +inputBusinessRecipients#6f8b32aa flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = InputBusinessRecipients; + +businessRecipients#21108ff7 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = BusinessRecipients; + +businessAwayMessageScheduleAlways#c9b9e2b9 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleOutsideWorkHours#c3f2f501 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleCustom#cc4d9ecc start_date:int end_date:int = BusinessAwayMessageSchedule; + +inputBusinessGreetingMessage#194cb3b shortcut_id:int recipients:InputBusinessRecipients no_activity_days:int = InputBusinessGreetingMessage; + +businessGreetingMessage#e519abab shortcut_id:int recipients:BusinessRecipients no_activity_days:int = BusinessGreetingMessage; + +inputBusinessAwayMessage#832175e0 flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:InputBusinessRecipients = InputBusinessAwayMessage; + +businessAwayMessage#ef156a5c flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:BusinessRecipients = BusinessAwayMessage; + +timezone#ff9289f5 id:string name:string utc_offset:int = Timezone; + +help.timezonesListNotModified#970708cc = help.TimezonesList; +help.timezonesList#7b74ed71 timezones:Vector hash:int = help.TimezonesList; + +quickReply#697102b shortcut_id:int shortcut:string top_message:int count:int = QuickReply; + +inputQuickReplyShortcut#24596d41 shortcut:string = InputQuickReplyShortcut; +inputQuickReplyShortcutId#1190cf1 shortcut_id:int = InputQuickReplyShortcut; + +messages.quickReplies#c68d6695 quick_replies:Vector messages:Vector chats:Vector users:Vector = messages.QuickReplies; +messages.quickRepliesNotModified#5f91eb5b = messages.QuickReplies; + +connectedBot#33ed001 flags:# bot_id:long recipients:BusinessBotRecipients rights:BusinessBotRights device:flags.0?string date:flags.1?int location:flags.2?string = ConnectedBot; + +account.connectedBots#17d7f87b connected_bots:Vector users:Vector = account.ConnectedBots; + +messages.dialogFilters#2ad93719 flags:# tags_enabled:flags.0?true filters:Vector = messages.DialogFilters; + +birthday#6c8e1e06 flags:# day:int month:int year:flags.0?int = Birthday; + +botBusinessConnection#8f34b2f5 flags:# disabled:flags.1?true connection_id:string user_id:long dc_id:int date:int rights:flags.2?BusinessBotRights = BotBusinessConnection; + +inputBusinessIntro#9c469cd flags:# title:string description:string sticker:flags.0?InputDocument = InputBusinessIntro; + +businessIntro#5a0a066d flags:# title:string description:string sticker:flags.0?Document = BusinessIntro; + +messages.myStickers#faff629d count:int sets:Vector = messages.MyStickers; + +inputCollectibleUsername#e39460a9 username:string = InputCollectible; +inputCollectiblePhone#a2e214a4 phone:string = InputCollectible; + +fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo; + +inputBusinessBotRecipients#c4e5921e flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = InputBusinessBotRecipients; + +businessBotRecipients#b88cf373 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = BusinessBotRecipients; + +contactBirthday#1d998733 contact_id:long birthday:Birthday = ContactBirthday; + +contacts.contactBirthdays#114ff30d contacts:Vector users:Vector = contacts.ContactBirthdays; + +missingInvitee#628c9224 flags:# premium_would_allow_invite:flags.0?true premium_required_for_pm:flags.1?true user_id:long = MissingInvitee; + +messages.invitedUsers#7f5defa6 updates:Updates missing_invitees:Vector = messages.InvitedUsers; + +inputBusinessChatLink#11679fa7 flags:# message:string entities:flags.0?Vector title:flags.1?string = InputBusinessChatLink; + +businessChatLink#b4ae666f flags:# link:string message:string entities:flags.0?Vector title:flags.1?string views:int = BusinessChatLink; + +account.businessChatLinks#ec43a2d1 links:Vector chats:Vector users:Vector = account.BusinessChatLinks; + +account.resolvedBusinessChatLinks#9a23af21 flags:# peer:Peer message:string entities:flags.0?Vector chats:Vector users:Vector = account.ResolvedBusinessChatLinks; + +requestedPeerUser#d62ff46a flags:# user_id:long first_name:flags.0?string last_name:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChat#7307544f flags:# chat_id:long title:flags.0?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChannel#8ba403e4 flags:# channel_id:long title:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +sponsoredMessageReportOption#430d3150 text:string option:bytes = SponsoredMessageReportOption; + +channels.sponsoredMessageReportResultChooseOption#846f9e42 title:string options:Vector = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultAdsHidden#3e3bcf2f = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultReported#ad798849 = channels.SponsoredMessageReportResult; + +reactionNotificationsFromContacts#bac3a61a = ReactionNotificationsFrom; +reactionNotificationsFromAll#4b9e22a0 = ReactionNotificationsFrom; + +reactionsNotifySettings#71e4ea58 flags:# messages_notify_from:flags.0?ReactionNotificationsFrom stories_notify_from:flags.1?ReactionNotificationsFrom poll_votes_notify_from:flags.2?ReactionNotificationsFrom sound:NotificationSound show_previews:Bool = ReactionsNotifySettings; + +availableEffect#93c3e27e flags:# premium_required:flags.2?true id:long emoticon:string static_icon_id:flags.0?long effect_sticker_id:long effect_animation_id:flags.1?long = AvailableEffect; + +messages.availableEffectsNotModified#d1ed9a5b = messages.AvailableEffects; +messages.availableEffects#bddb616e hash:int effects:Vector documents:Vector = messages.AvailableEffects; + +factCheck#b89bfccf flags:# need_check:flags.0?true country:flags.1?string text:flags.1?TextWithEntities hash:long = FactCheck; + +starsTransactionPeerUnsupported#95f2bfe4 = StarsTransactionPeer; +starsTransactionPeerAppStore#b457b375 = StarsTransactionPeer; +starsTransactionPeerPlayMarket#7b560a0b = StarsTransactionPeer; +starsTransactionPeerPremiumBot#250dbaf8 = StarsTransactionPeer; +starsTransactionPeerFragment#e92fd902 = StarsTransactionPeer; +starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; +starsTransactionPeerAds#60682812 = StarsTransactionPeer; +starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; + +starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; + +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true stargift_prepaid_upgrade:flags.25?true stargift_drop_original_details:flags.26?true phonegroup_message:flags.27?true stargift_auction_bid:flags.28?true offer:flags.29?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; + +payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; + +foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; + +stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; + +geoPointAddress#de4c5d93 flags:# country_iso2:string state:flags.0?string city:flags.1?string street:flags.2?string = GeoPointAddress; + +starsRevenueStatus#febe5491 flags:# withdrawal_enabled:flags.0?true current_balance:StarsAmount available_balance:StarsAmount overall_revenue:StarsAmount next_withdrawal_at:flags.1?int = StarsRevenueStatus; + +payments.starsRevenueStats#6c207376 flags:# top_hours_graph:flags.0?StatsGraph revenue_graph:StatsGraph status:StarsRevenueStatus usd_rate:double = payments.StarsRevenueStats; + +payments.starsRevenueWithdrawalUrl#1dab80b7 url:string = payments.StarsRevenueWithdrawalUrl; + +payments.starsRevenueAdsAccountUrl#394e7f21 url:string = payments.StarsRevenueAdsAccountUrl; + +inputStarsTransaction#206ae6d1 flags:# refund:flags.0?true id:string = InputStarsTransaction; + +starsGiftOption#5e0589f1 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsGiftOption; + +bots.popularAppBots#1991b13b flags:# next_offset:flags.0?string users:Vector = bots.PopularAppBots; + +botPreviewMedia#23e91ba3 date:int media:MessageMedia = BotPreviewMedia; + +bots.previewInfo#ca71d64 media:Vector lang_codes:Vector = bots.PreviewInfo; + +starsSubscriptionPricing#5416d58 period:int amount:long = StarsSubscriptionPricing; + +starsSubscription#2e6eab1a flags:# canceled:flags.0?true can_refulfill:flags.1?true missing_balance:flags.2?true bot_canceled:flags.7?true id:string peer:Peer until_date:int pricing:StarsSubscriptionPricing chat_invite_hash:flags.3?string title:flags.4?string photo:flags.5?WebDocument invoice_slug:flags.6?string = StarsSubscription; + +messageReactor#4ba3a95a flags:# top:flags.0?true my:flags.1?true anonymous:flags.2?true peer_id:flags.3?Peer count:int = MessageReactor; + +starsGiveawayOption#94ce852a flags:# extended:flags.0?true default:flags.1?true stars:long yearly_boosts:int store_product:flags.2?string currency:string amount:long winners:Vector = StarsGiveawayOption; + +starsGiveawayWinnersOption#54236209 flags:# default:flags.0?true users:int per_user_stars:long = StarsGiveawayWinnersOption; + +starGift#313a9547 flags:# limited:flags.0?true sold_out:flags.1?true birthday:flags.2?true require_premium:flags.7?true limited_per_user:flags.8?true peer_color_available:flags.10?true auction:flags.11?true id:long sticker:Document stars:long availability_remains:flags.0?int availability_total:flags.0?int availability_resale:flags.4?long convert_stars:long first_sale_date:flags.1?int last_sale_date:flags.1?int upgrade_stars:flags.3?long resell_min_stars:flags.4?long title:flags.5?string released_by:flags.6?Peer per_user_total:flags.8?int per_user_remains:flags.8?int locked_until_date:flags.9?int auction_slug:flags.11?string gifts_per_round:flags.11?int auction_start_date:flags.11?int upgrade_variants:flags.12?int background:flags.13?StarGiftBackground = StarGift; +starGiftUnique#85f0a9cd flags:# require_premium:flags.6?true resale_ton_only:flags.7?true theme_available:flags.9?true burned:flags.14?true crafted:flags.15?true id:long gift_id:long title:string slug:string num:int owner_id:flags.0?Peer owner_name:flags.1?string owner_address:flags.2?string attributes:Vector availability_issued:int availability_total:int gift_address:flags.3?string resell_amount:flags.4?Vector released_by:flags.5?Peer value_amount:flags.8?long value_currency:flags.8?string value_usd_amount:flags.8?long theme_peer:flags.10?Peer peer_color:flags.11?PeerColor host_id:flags.12?Peer offer_min_stars:flags.13?int craft_chance_permille:flags.16?int = StarGift; + +payments.starGiftsNotModified#a388a368 = payments.StarGifts; +payments.starGifts#2ed82995 hash:int gifts:Vector chats:Vector users:Vector = payments.StarGifts; + +messageReportOption#7903e3d9 text:string option:bytes = MessageReportOption; + +reportResultChooseOption#f0e4e0b6 title:string options:Vector = ReportResult; +reportResultAddComment#6f09ac31 flags:# optional:flags.0?true option:bytes = ReportResult; +reportResultReported#8db33c4b = ReportResult; + +messages.botPreparedInlineMessage#8ecf0511 id:string expire_date:int = messages.BotPreparedInlineMessage; + +messages.preparedInlineMessage#ff57708d query_id:long result:BotInlineResult peer_types:Vector cache_time:int users:Vector = messages.PreparedInlineMessage; + +botAppSettings#c99b1950 flags:# placeholder_path:flags.0?bytes background_color:flags.1?int background_dark_color:flags.2?int header_color:flags.3?int header_dark_color:flags.4?int = BotAppSettings; + +starRefProgram#dd0c66f2 flags:# bot_id:long commission_permille:int duration_months:flags.0?int end_date:flags.1?int daily_revenue_per_user:flags.2?StarsAmount = StarRefProgram; + +connectedBotStarRef#19a13f71 flags:# revoked:flags.1?true url:string date:int bot_id:long commission_permille:int duration_months:flags.0?int participants:long revenue:long = ConnectedBotStarRef; + +payments.connectedStarRefBots#98d5ea1d count:int connected_bots:Vector users:Vector = payments.ConnectedStarRefBots; + +payments.suggestedStarRefBots#b4d5d859 flags:# count:int suggested_bots:Vector users:Vector next_offset:flags.0?string = payments.SuggestedStarRefBots; + +starsAmount#bbb6b4a3 amount:long nanos:int = StarsAmount; +starsTonAmount#74aee3e0 amount:long = StarsAmount; + +messages.foundStickersNotModified#6010c534 flags:# next_offset:flags.0?int = messages.FoundStickers; +messages.foundStickers#82c9e290 flags:# next_offset:flags.0?int hash:long stickers:Vector = messages.FoundStickers; + +botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string = BotVerifierSettings; + +botVerification#f93cd45c bot_id:long icon:long description:string = BotVerification; + +starGiftAttributeModel#565251e2 flags:# crafted:flags.0?true name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributePattern#4e7085ea name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeBackdrop#9f2504e4 name:string backdrop_id:int center_color:int edge_color:int pattern_color:int text_color:int rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeOriginalDetails#e0bff26c flags:# sender_id:flags.0?Peer recipient_id:Peer date:int message:flags.1?TextWithEntities = StarGiftAttribute; + +payments.starGiftUpgradePreview#3de1dfed sample_attributes:Vector prices:Vector next_prices:Vector = payments.StarGiftUpgradePreview; + +users.users#62d706b8 users:Vector = users.Users; +users.usersSlice#315a4974 count:int users:Vector = users.Users; + +payments.uniqueStarGift#416c56e8 gift:StarGift chats:Vector users:Vector = payments.UniqueStarGift; + +messages.webPagePreview#8c9a88ac media:MessageMedia chats:Vector users:Vector = messages.WebPagePreview; + +savedStarGift#41df43fc flags:# name_hidden:flags.0?true unsaved:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true pinned_to_top:flags.12?true upgrade_separate:flags.17?true from_id:flags.1?Peer date:int gift:StarGift message:flags.2?TextWithEntities msg_id:flags.3?int saved_id:flags.11?long convert_stars:flags.4?long upgrade_stars:flags.6?long can_export_at:flags.7?int transfer_stars:flags.8?long can_transfer_at:flags.13?int can_resell_at:flags.14?int collection_id:flags.15?Vector prepaid_upgrade_hash:flags.16?string drop_original_details_stars:flags.18?long gift_num:flags.19?int can_craft_at:flags.20?int = SavedStarGift; + +payments.savedStarGifts#95f389b1 flags:# count:int chat_notifications_enabled:flags.1?Bool gifts:Vector next_offset:flags.0?string chats:Vector users:Vector = payments.SavedStarGifts; + +inputSavedStarGiftUser#69279795 msg_id:int = InputSavedStarGift; +inputSavedStarGiftChat#f101aa7f peer:InputPeer saved_id:long = InputSavedStarGift; +inputSavedStarGiftSlug#2085c238 slug:string = InputSavedStarGift; + +payments.starGiftWithdrawalUrl#84aa3a9c url:string = payments.StarGiftWithdrawalUrl; + +paidReactionPrivacyDefault#206ad49e = PaidReactionPrivacy; +paidReactionPrivacyAnonymous#1f0c1ad9 = PaidReactionPrivacy; +paidReactionPrivacyPeer#dc6cfcf0 peer:InputPeer = PaidReactionPrivacy; + +account.paidMessagesRevenue#1e109708 stars_amount:long = account.PaidMessagesRevenue; + +requirementToContactEmpty#50a9839 = RequirementToContact; +requirementToContactPremium#e581e4e9 = RequirementToContact; +requirementToContactPaidMessages#b4f67e93 stars_amount:long = RequirementToContact; + +businessBotRights#a0624cf7 flags:# reply:flags.0?true read_messages:flags.1?true delete_sent_messages:flags.2?true delete_received_messages:flags.3?true edit_name:flags.4?true edit_bio:flags.5?true edit_profile_photo:flags.6?true edit_username:flags.7?true view_gifts:flags.8?true sell_gifts:flags.9?true change_gift_settings:flags.10?true transfer_and_upgrade_gifts:flags.11?true transfer_stars:flags.12?true manage_stories:flags.13?true = BusinessBotRights; + +disallowedGiftsSettings#71f276c4 flags:# disallow_unlimited_stargifts:flags.0?true disallow_limited_stargifts:flags.1?true disallow_unique_stargifts:flags.2?true disallow_premium_gifts:flags.3?true disallow_stargifts_from_channels:flags.4?true = DisallowedGiftsSettings; + +sponsoredPeer#c69708d3 flags:# random_id:bytes peer:Peer sponsor_info:flags.0?string additional_info:flags.1?string = SponsoredPeer; + +contacts.sponsoredPeersEmpty#ea32b4b1 = contacts.SponsoredPeers; +contacts.sponsoredPeers#eb032884 peers:Vector chats:Vector users:Vector = contacts.SponsoredPeers; + +starGiftAttributeIdModel#48aaae3c document_id:long = StarGiftAttributeId; +starGiftAttributeIdPattern#4a162433 document_id:long = StarGiftAttributeId; +starGiftAttributeIdBackdrop#1f01c757 backdrop_id:int = StarGiftAttributeId; + +starGiftAttributeCounter#2eb1b658 attribute:StarGiftAttributeId count:int = StarGiftAttributeCounter; + +payments.resaleStarGifts#947a12df flags:# count:int gifts:Vector next_offset:flags.0?string attributes:flags.1?Vector attributes_hash:flags.1?long chats:Vector counters:flags.2?Vector users:Vector = payments.ResaleStarGifts; + +stories.canSendStoryCount#c387c04e count_remains:int = stories.CanSendStoryCount; + +pendingSuggestion#e7e82e12 suggestion:string title:TextWithEntities description:TextWithEntities url:string = PendingSuggestion; + +todoItem#cba9a52f id:int title:TextWithEntities = TodoItem; + +todoList#49b92a26 flags:# others_can_append:flags.0?true others_can_complete:flags.1?true title:TextWithEntities list:Vector = TodoList; + +todoCompletion#221bb5e4 id:int completed_by:Peer date:int = TodoCompletion; + +suggestedPost#e8e37e5 flags:# accepted:flags.1?true rejected:flags.2?true price:flags.3?StarsAmount schedule_date:flags.0?int = SuggestedPost; + +starsRating#1b0e4f07 flags:# level:int current_level_stars:long stars:long next_level_stars:flags.0?long = StarsRating; + +starGiftCollection#9d6b13b0 flags:# collection_id:int title:string icon:flags.0?Document gifts_count:int hash:long = StarGiftCollection; + +payments.starGiftCollectionsNotModified#a0ba4f17 = payments.StarGiftCollections; +payments.starGiftCollections#8a2932f3 collections:Vector = payments.StarGiftCollections; + +storyAlbum#9325705a flags:# album_id:int title:string icon_photo:flags.0?Photo icon_video:flags.1?Document = StoryAlbum; + +stories.albumsNotModified#564edaeb = stories.Albums; +stories.albums#c3987a3a hash:long albums:Vector = stories.Albums; + +searchPostsFlood#3e0b5b6a flags:# query_is_free:flags.0?true total_daily:int remains:int wait_till:flags.1?int stars_amount:long = SearchPostsFlood; + +payments.uniqueStarGiftValueInfo#512fe446 flags:# last_sale_on_fragment:flags.1?true value_is_average:flags.6?true currency:string value:long initial_sale_date:int initial_sale_stars:long initial_sale_price:long last_sale_date:flags.0?int last_sale_price:flags.0?long floor_price:flags.2?long average_price:flags.3?long listed_count:flags.4?int fragment_listed_count:flags.5?int fragment_listed_url:flags.5?string = payments.UniqueStarGiftValueInfo; + +profileTabPosts#b98cd696 = ProfileTab; +profileTabGifts#4d4bd46a = ProfileTab; +profileTabMedia#72c64955 = ProfileTab; +profileTabFiles#ab339c00 = ProfileTab; +profileTabMusic#9f27d26e = ProfileTab; +profileTabVoice#e477092e = ProfileTab; +profileTabLinks#d3656499 = ProfileTab; +profileTabGifs#a2c0f695 = ProfileTab; + +users.savedMusicNotModified#e3878aa4 count:int = users.SavedMusic; +users.savedMusic#34a2f297 count:int documents:Vector = users.SavedMusic; + +account.savedMusicIdsNotModified#4fc81d6e = account.SavedMusicIds; +account.savedMusicIds#998d6636 ids:Vector = account.SavedMusicIds; + +payments.checkCanSendGiftResultOk#374fa7ad = payments.CheckCanSendGiftResult; +payments.checkCanSendGiftResultFail#d5e58274 reason:TextWithEntities = payments.CheckCanSendGiftResult; + +inputChatThemeEmpty#83268483 = InputChatTheme; +inputChatTheme#c93de95c emoticon:string = InputChatTheme; +inputChatThemeUniqueGift#87e5dfe4 slug:string = InputChatTheme; + +starGiftUpgradePrice#99ea331d date:int upgrade_stars:long = StarGiftUpgradePrice; + +groupCallMessage#1a8afc7e flags:# from_admin:flags.1?true id:int from_id:Peer date:int message:TextWithEntities paid_message_stars:flags.0?long = GroupCallMessage; + +groupCallDonor#ee430c85 flags:# top:flags.0?true my:flags.1?true peer_id:flags.3?Peer stars:long = GroupCallDonor; + +phone.groupCallStars#9d1dbd26 total_stars:long top_donors:Vector chats:Vector users:Vector = phone.GroupCallStars; + +recentStory#711d692d flags:# live:flags.0?true max_id:flags.1?int = RecentStory; + +auctionBidLevel#310240cc pos:int amount:long date:int = AuctionBidLevel; + +starGiftAuctionStateNotModified#fe333952 = StarGiftAuctionState; +starGiftAuctionState#771a4e66 version:int start_date:int end_date:int min_bid_amount:long bid_levels:Vector top_bidders:Vector next_round_at:int last_gift_num:int gifts_left:int current_round:int total_rounds:int rounds:Vector = StarGiftAuctionState; +starGiftAuctionStateFinished#972dabbf flags:# start_date:int end_date:int average_price:long listed_count:flags.0?int fragment_listed_count:flags.1?int fragment_listed_url:flags.1?string = StarGiftAuctionState; + +starGiftAuctionUserState#2eeed1c4 flags:# returned:flags.1?true bid_amount:flags.0?long bid_date:flags.0?int min_bid_amount:flags.0?long bid_peer:flags.0?Peer acquired_count:int = StarGiftAuctionUserState; + +payments.starGiftAuctionState#6b39f4ec gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState timeout:int users:Vector chats:Vector = payments.StarGiftAuctionState; + +starGiftAuctionAcquiredGift#42b00348 flags:# name_hidden:flags.0?true peer:Peer date:int bid_amount:long round:int pos:int message:flags.1?TextWithEntities gift_num:flags.2?int = StarGiftAuctionAcquiredGift; + +payments.starGiftAuctionAcquiredGifts#7d5bd1f0 gifts:Vector users:Vector chats:Vector = payments.StarGiftAuctionAcquiredGifts; + +starGiftActiveAuctionState#d31bc45d gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState = StarGiftActiveAuctionState; + +payments.starGiftActiveAuctionsNotModified#db33dad0 = payments.StarGiftActiveAuctions; +payments.starGiftActiveAuctions#aef6abbc auctions:Vector users:Vector chats:Vector = payments.StarGiftActiveAuctions; + +inputStarGiftAuction#2e16c98 gift_id:long = InputStarGiftAuction; +inputStarGiftAuctionSlug#7ab58308 slug:string = InputStarGiftAuction; + +passkey#98613ebf flags:# id:string name:string date:int software_emoji_id:flags.0?long last_usage_date:flags.1?int = Passkey; + +account.passkeys#f8e0aa1c passkeys:Vector = account.Passkeys; + +account.passkeyRegistrationOptions#e16b5ce1 options:DataJSON = account.PasskeyRegistrationOptions; + +auth.passkeyLoginOptions#e2037789 options:DataJSON = auth.PasskeyLoginOptions; + +inputPasskeyResponseRegister#3e63935c client_data:DataJSON attestation_data:bytes = InputPasskeyResponse; +inputPasskeyResponseLogin#c31fc14a client_data:DataJSON authenticator_data:bytes signature:bytes user_handle:string = InputPasskeyResponse; + +inputPasskeyCredentialPublicKey#3c27b78f id:string raw_id:string response:InputPasskeyResponse = InputPasskeyCredential; +inputPasskeyCredentialFirebasePNV#5b1ccb28 pnv_token:string = InputPasskeyCredential; + +starGiftBackground#aff56398 center_color:int edge_color:int text_color:int = StarGiftBackground; + +starGiftAuctionRound#3aae0528 num:int duration:int = StarGiftAuctionRound; +starGiftAuctionRoundExtendable#aa021e5 num:int duration:int extend_top:int extend_window:int = StarGiftAuctionRound; + +payments.starGiftUpgradeAttributes#46c6e36f attributes:Vector = payments.StarGiftUpgradeAttributes; + +messages.emojiGameOutcome#da2ad647 seed:bytes stake_ton_amount:long ton_amount:long = messages.EmojiGameOutcome; + +messages.emojiGameUnavailable#59e65335 = messages.EmojiGameInfo; +messages.emojiGameDiceInfo#44e56023 flags:# game_hash:string prev_stake:long current_streak:int params:Vector plays_left:flags.0?int = messages.EmojiGameInfo; + +starGiftAttributeRarity#36437737 permille:int = StarGiftAttributeRarity; +starGiftAttributeRarityUncommon#dbce6389 = StarGiftAttributeRarity; +starGiftAttributeRarityRare#f08d516b = StarGiftAttributeRarity; +starGiftAttributeRarityEpic#78fbf3a8 = StarGiftAttributeRarity; +starGiftAttributeRarityLegendary#cef7e7a8 = StarGiftAttributeRarity; + +keyboardButtonStyle#4fdd3430 flags:# bg_primary:flags.0?true bg_danger:flags.1?true bg_success:flags.2?true icon:flags.3?long = KeyboardButtonStyle; + +inputMessageReadMetric#402b4495 msg_id:int view_id:long time_in_view_ms:int active_time_in_view_ms:int height_to_viewport_ratio_permille:int seen_range_ratio_permille:int = InputMessageReadMetric; + +bots.exportedBotToken#3c60b621 token:string = bots.ExportedBotToken; + +bots.requestedButton#f13bbcd7 webapp_req_id:string = bots.RequestedButton; + +messages.composedMessageWithAI#90d7adfa flags:# result_text:TextWithEntities diff_text:flags.0?TextWithEntities = messages.ComposedMessageWithAI; + +stats.pollStats#2999beed votes_graph:StatsGraph = stats.PollStats; + +inputAiComposeToneDefault#1fe9a9bf tone:string = InputAiComposeTone; +inputAiComposeToneID#773c080 id:long access_hash:long = InputAiComposeTone; +inputAiComposeToneSlug#1fa01357 slug:string = InputAiComposeTone; + +aiComposeTone#cff63ea9 flags:# creator:flags.0?true id:long access_hash:long slug:string title:string emoji_id:flags.1?long prompt:flags.4?string installs_count:flags.2?int author_id:flags.3?long example_english:flags.5?AiComposeToneExample = AiComposeTone; +aiComposeToneDefault#9bad6414 tone:string emoji_id:long title:string = AiComposeTone; + +aicompose.tonesNotModified#c1f46103 = aicompose.Tones; +aicompose.tones#6c9d0efe hash:long tones:Vector users:Vector = aicompose.Tones; + +aiComposeToneExample#f1d628ec from:TextWithEntities to:TextWithEntities = AiComposeToneExample; + +bots.accessSettings#dd1fbf93 flags:# restricted:flags.0?true add_users:flags.1?Vector = bots.AccessSettings; + +messages.chatInviteJoinResultOk#445663a7 updates:Updates = messages.ChatInviteJoinResult; +messages.chatInviteJoinResultWebView#774bbdf4 bot_id:long url:string query_id:long users:Vector = messages.ChatInviteJoinResult; + +joinChatBotResultApproved#ae152a69 = JoinChatBotResult; +joinChatBotResultDeclined#efa0194 = JoinChatBotResult; +joinChatBotResultQueued#98a3a840 = JoinChatBotResult; +joinChatBotResultWebView#d6e3b813 url:string = JoinChatBotResult; + +webDomainException#933ca597 flags:# domain:string url:string title:string favicon:flags.0?long = WebDomainException; + +account.webBrowserSettingsNotModified#c31c8f4e = account.WebBrowserSettings; +account.webBrowserSettings#79eb8cb3 flags:# open_external_browser:flags.0?true display_close_button:flags.1?true external_exceptions:Vector inapp_exceptions:Vector hash:long = account.WebBrowserSettings; + +---functions--- + +invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; +invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; +invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; +invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; +invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; +invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; +invokeWithBusinessConnection#dd289f8e {X:Type} connection_id:string query:!X = X; +invokeWithGooglePlayIntegrity#1df92984 {X:Type} nonce:string token:string query:!X = X; +invokeWithApnsSecret#0dae54f8 {X:Type} nonce:string secret:string query:!X = X; +invokeWithReCaptcha#adbb0f94 {X:Type} token:string query:!X = X; + +auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; +auth.signUp#aac7b717 flags:# no_joined_notifications:flags.0?true phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +auth.signIn#8d52a951 flags:# phone_number:string phone_code_hash:string phone_code:flags.0?string email_verification:flags.1?EmailVerification = auth.Authorization; +auth.logOut#3e72ba19 = auth.LoggedOut; +auth.resetAuthorizations#9fab0d1a = Bool; +auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; +auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; +auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; +auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; +auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; +auth.resendCode#cae47523 flags:# phone_number:string phone_code_hash:string reason:flags.0?string = auth.SentCode; +auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; +auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; +auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; +auth.acceptLoginToken#e894ad4d token:bytes = Authorization; +auth.checkRecoveryPassword#d36bf79 code:string = Bool; +auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; +auth.requestFirebaseSms#8e39261e flags:# phone_number:string phone_code_hash:string safety_net_token:flags.0?string play_integrity_token:flags.2?string ios_push_secret:flags.1?string = Bool; +auth.resetLoginEmail#7e960193 phone_number:string phone_code_hash:string = auth.SentCode; +auth.reportMissingCode#cb9deff6 phone_number:string phone_code_hash:string mnc:string = Bool; +auth.checkPaidAuth#56e59f9c phone_number:string phone_code_hash:string form_id:long = auth.SentCode; +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; + +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; +account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; +account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; +account.resetNotifySettings#db7e1747 = Bool; +account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; +account.updateStatus#6628562c offline:Bool = Bool; +account.getWallPapers#7967d36 hash:long = account.WallPapers; +account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; +account.checkUsername#2714d86c username:string = Bool; +account.updateUsername#3e0bdd7c username:string = User; +account.getPrivacy#dadbc950 key:InputPrivacyKey = account.PrivacyRules; +account.setPrivacy#c9f81ce8 key:InputPrivacyKey rules:Vector = account.PrivacyRules; +account.deleteAccount#a2c0cf74 flags:# reason:string password:flags.0?InputCheckPasswordSRP = Bool; +account.getAccountTTL#8fc711d = AccountDaysTTL; +account.setAccountTTL#2442485e ttl:AccountDaysTTL = Bool; +account.sendChangePhoneCode#82574ae5 phone_number:string settings:CodeSettings = auth.SentCode; +account.changePhone#70c32edb phone_number:string phone_code_hash:string phone_code:string = User; +account.updateDeviceLocked#38df3532 period:int = Bool; +account.getAuthorizations#e320c158 = account.Authorizations; +account.resetAuthorization#df77f3bc hash:long = Bool; +account.getPassword#548a30f5 = account.Password; +account.getPasswordSettings#9cd4eaf9 password:InputCheckPasswordSRP = account.PasswordSettings; +account.updatePasswordSettings#a59b102f password:InputCheckPasswordSRP new_settings:account.PasswordInputSettings = Bool; +account.sendConfirmPhoneCode#1b3faa88 hash:string settings:CodeSettings = auth.SentCode; +account.confirmPhone#5f2178c3 phone_code_hash:string phone_code:string = Bool; +account.getTmpPassword#449e0b51 password:InputCheckPasswordSRP period:int = account.TmpPassword; +account.getWebAuthorizations#182e6d6f = account.WebAuthorizations; +account.resetWebAuthorization#2d01b9ef hash:long = Bool; +account.resetWebAuthorizations#682d2594 = Bool; +account.getAllSecureValues#b288bc7d = Vector; +account.getSecureValue#73665bc2 types:Vector = Vector; +account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; +account.deleteSecureValue#b880bc4b types:Vector = Bool; +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; +account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; +account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; +account.sendVerifyEmailCode#98e037bb purpose:EmailVerifyPurpose email:string = account.SentEmailCode; +account.verifyEmail#32da4cf purpose:EmailVerifyPurpose verification:EmailVerification = account.EmailVerified; +account.initTakeoutSession#8ef3eab0 flags:# contacts:flags.0?true message_users:flags.1?true message_chats:flags.2?true message_megagroups:flags.3?true message_channels:flags.4?true files:flags.5?true file_max_size:flags.5?long = account.Takeout; +account.finishTakeoutSession#1d2652ee flags:# success:flags.0?true = Bool; +account.confirmPasswordEmail#8fdf1920 code:string = Bool; +account.resendPasswordEmail#7a7f2a15 = Bool; +account.cancelPasswordEmail#c1cbd5b6 = Bool; +account.getContactSignUpNotification#9f07c728 = Bool; +account.setContactSignUpNotification#cff43f61 silent:Bool = Bool; +account.getNotifyExceptions#53577479 flags:# compare_sound:flags.1?true compare_stories:flags.2?true peer:flags.0?InputNotifyPeer = Updates; +account.getWallPaper#fc8ddbea wallpaper:InputWallPaper = WallPaper; +account.uploadWallPaper#e39a8f03 flags:# for_chat:flags.0?true file:InputFile mime_type:string settings:WallPaperSettings = WallPaper; +account.saveWallPaper#6c5a5b37 wallpaper:InputWallPaper unsave:Bool settings:WallPaperSettings = Bool; +account.installWallPaper#feed5769 wallpaper:InputWallPaper settings:WallPaperSettings = Bool; +account.resetWallPapers#bb3b9804 = Bool; +account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; +account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; +account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; +account.getTheme#3a5869ec format:string theme:InputTheme = Theme; +account.getThemes#7206e458 format:string hash:long = account.Themes; +account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; +account.getContentSettings#8b9b4dae = account.ContentSettings; +account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; +account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; +account.resetPassword#9308ce1b = account.ResetPasswordResult; +account.declinePasswordReset#4c9409f6 = Bool; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# confirmed:flags.3?true hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; +account.getSavedRingtones#e1902288 hash:long = account.SavedRingtones; +account.saveRingtone#3dea5b03 id:InputDocument unsave:Bool = account.SavedRingtone; +account.uploadRingtone#831a83a2 file:InputFile file_name:string mime_type:string = Document; +account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; +account.getDefaultEmojiStatuses#d6753386 hash:long = account.EmojiStatuses; +account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; +account.clearRecentEmojiStatuses#18201aae = Bool; +account.reorderUsernames#ef500eab order:Vector = Bool; +account.toggleUsername#58d6b376 username:string active:Bool = Bool; +account.getDefaultProfilePhotoEmojis#e2750328 hash:long = EmojiList; +account.getDefaultGroupPhotoEmojis#915860ae hash:long = EmojiList; +account.getAutoSaveSettings#adcbbcda = account.AutoSaveSettings; +account.saveAutoSaveSettings#d69b8361 flags:# users:flags.0?true chats:flags.1?true broadcasts:flags.2?true peer:flags.3?InputPeer settings:AutoSaveSettings = Bool; +account.deleteAutoSaveExceptions#53bc0020 = Bool; +account.invalidateSignInCodes#ca8ae8ba codes:Vector = Bool; +account.updateColor#684d214e flags:# for_profile:flags.1?true color:flags.2?PeerColor = Bool; +account.getDefaultBackgroundEmojis#a60ab9ce hash:long = EmojiList; +account.getChannelDefaultEmojiStatuses#7727a7d5 hash:long = account.EmojiStatuses; +account.getChannelRestrictedStatusEmojis#35a9e0d5 hash:long = EmojiList; +account.updateBusinessWorkHours#4b00e066 flags:# business_work_hours:flags.0?BusinessWorkHours = Bool; +account.updateBusinessLocation#9e6b131a flags:# geo_point:flags.1?InputGeoPoint address:flags.0?string = Bool; +account.updateBusinessGreetingMessage#66cdafc4 flags:# message:flags.0?InputBusinessGreetingMessage = Bool; +account.updateBusinessAwayMessage#a26a7fa5 flags:# message:flags.0?InputBusinessAwayMessage = Bool; +account.updateConnectedBot#66a08c7e flags:# deleted:flags.1?true rights:flags.0?BusinessBotRights bot:InputUser recipients:InputBusinessBotRecipients = Updates; +account.getConnectedBots#4ea4c80f = account.ConnectedBots; +account.getBotBusinessConnection#76a86270 connection_id:string = Updates; +account.updateBusinessIntro#a614d034 flags:# intro:flags.0?InputBusinessIntro = Bool; +account.toggleConnectedBotPaused#646e1097 peer:InputPeer paused:Bool = Bool; +account.disablePeerConnectedBot#5e437ed9 peer:InputPeer = Bool; +account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool; +account.createBusinessChatLink#8851e68e link:InputBusinessChatLink = BusinessChatLink; +account.editBusinessChatLink#8c3410af slug:string link:InputBusinessChatLink = BusinessChatLink; +account.deleteBusinessChatLink#60073674 slug:string = Bool; +account.getBusinessChatLinks#6f70dde1 = account.BusinessChatLinks; +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; +account.updatePersonalChannel#d94305e0 channel:InputChannel = Bool; +account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; +account.getReactionsNotifySettings#6dd654c = ReactionsNotifySettings; +account.setReactionsNotifySettings#316ce548 settings:ReactionsNotifySettings = ReactionsNotifySettings; +account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; +account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; +account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; +account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; +account.saveMusic#b26732a9 flags:# unsave:flags.0?true id:InputDocument after_id:flags.1?InputDocument = Bool; +account.getSavedMusicIds#e09d5faf hash:long = account.SavedMusicIds; +account.getUniqueGiftChatThemes#e42ce9c9 offset:string limit:int hash:long = account.ChatThemes; +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; +account.getPasskeys#ea1f0c52 = account.Passkeys; +account.deletePasskey#f5b5563f id:string = Bool; +account.confirmBotConnection#67ed1f68 bot_id:InputUser = Bool; +account.getWebBrowserSettings#56655768 hash:long = account.WebBrowserSettings; +account.updateWebBrowserSettings#9adf82fe flags:# open_external_browser:flags.0?true display_close_button:flags.1?true = account.WebBrowserSettings; +account.toggleWebBrowserSettingsException#2d0a0571 flags:# delete:flags.1?true open_external_browser:flags.0?Bool url:string = Bool; +account.deleteWebBrowserSettingsExceptions#86a0765d = account.WebBrowserSettings; + +users.getUsers#d91a548 id:Vector = Vector; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; +users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; +users.getRequirementsToContact#d89a83a3 id:Vector = Vector; +users.getSavedMusic#788d7fe3 id:InputUser offset:int limit:int hash:long = users.SavedMusic; +users.getSavedMusicByID#7573a4e9 id:InputUser documents:Vector = users.SavedMusic; +users.suggestBirthday#fc533372 id:InputUser birthday:Birthday = Updates; + +contacts.getContactIDs#7adc669d hash:long = Vector; +contacts.getStatuses#c4a353ee = Vector; +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; +contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; +contacts.deleteContacts#96a0e00 id:Vector = Updates; +contacts.deleteByPhones#1013fd9e phones:Vector = Bool; +contacts.block#2e2e8734 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.unblock#b550d328 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.getBlocked#9a868f80 flags:# my_stories_from:flags.0?true offset:int limit:int = contacts.Blocked; +contacts.search#11f812d8 q:string limit:int = contacts.Found; +contacts.resolveUsername#725afbbc flags:# username:string referer:flags.0?string = contacts.ResolvedPeer; +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true bots_app:flags.16?true bots_guestchat:flags.17?true offset:int limit:int hash:long = contacts.TopPeers; +contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; +contacts.resetSaved#879537f1 = Bool; +contacts.getSaved#82f1e39f = Vector; +contacts.toggleTopPeers#8514bdda enabled:Bool = Bool; +contacts.addContact#d9ba2e54 flags:# add_phone_privacy_exception:flags.0?true id:InputUser first_name:string last_name:string phone:string note:flags.1?TextWithEntities = Updates; +contacts.acceptContact#f831a20f id:InputUser = Updates; +contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; +contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; +contacts.exportContactToken#f8654027 = ExportedContactToken; +contacts.importContactToken#13005788 token:string = User; +contacts.editCloseFriends#ba6705f0 id:Vector = Bool; +contacts.setBlocked#94c65c76 flags:# my_stories_from:flags.0?true id:Vector limit:int = Bool; +contacts.getBirthdays#daeda864 = contacts.ContactBirthdays; +contacts.getSponsoredPeers#b6c8c393 q:string = contacts.SponsoredPeers; +contacts.updateContactNote#139f63fb id:InputUser note:TextWithEntities = Bool; + +messages.getMessages#63c66506 id:Vector = messages.Messages; +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; +messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.search#29ee847a flags:# peer:InputPeer q:string from_id:flags.0?InputPeer saved_peer_id:flags.2?InputPeer saved_reaction:flags.3?Vector top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; +messages.receivedMessages#5a954c0 max_id:int = Vector; +messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; +messages.sendMessage#545cd15a flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.sendMedia#330e77f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.forwardMessages#13704a7c flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true allow_paid_floodskip:flags.19?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer top_msg_id:flags.9?int reply_to:flags.22?InputReplyTo schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long video_timestamp:flags.20?int allow_paid_stars:flags.21?long suggested_post:flags.23?SuggestedPost = Updates; +messages.reportSpam#cf1592db peer:InputPeer = Bool; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; +messages.report#fc78af9b peer:InputPeer id:Vector option:bytes message:string = ReportResult; +messages.getChats#49e9528f id:Vector = messages.Chats; +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; +messages.addChatUser#cbc6d107 chat_id:long user_id:InputUser fwd_limit:int = messages.InvitedUsers; +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; +messages.createChat#92ceddd4 flags:# users:Vector title:string ttl_period:flags.0?int = messages.InvitedUsers; +messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; +messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; +messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; +messages.discardEncryption#f393aea0 flags:# delete_history:flags.0?true chat_id:int = Bool; +messages.setEncryptedTyping#791451ed peer:InputEncryptedChat typing:Bool = Bool; +messages.readEncryptedHistory#7f4b690a peer:InputEncryptedChat max_date:int = Bool; +messages.sendEncrypted#44fa7a15 flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.sendEncryptedFile#5559481d flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes file:InputEncryptedFile = messages.SentEncryptedMessage; +messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.receivedQueue#55a5bb66 max_qts:int = Vector; +messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; +messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; +messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; +messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; +messages.getWebPagePreview#570d6f6f flags:# message:string entities:flags.3?Vector = messages.WebPagePreview; +messages.exportChatInvite#a455de90 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string subscription_pricing:flags.5?StarsSubscriptionPricing = ExportedChatInvite; +messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; +messages.importChatInvite#de91436e hash:string = messages.ChatInviteJoinResult; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; +messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; +messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; +messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; +messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; +messages.migrateChat#a2875319 chat_id:long = Updates; +messages.searchGlobal#4bc6589a flags:# broadcasts_only:flags.1?true groups_only:flags.2?true users_only:flags.3?true folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; +messages.reorderStickerSets#78337739 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Bool; +messages.getDocumentByHash#b1f2061f sha256:bytes size:long mime_type:string = Document; +messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; +messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; +messages.setInlineBotResults#bb12a419 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM switch_webview:flags.4?InlineBotWebView = Bool; +messages.sendInlineBotResult#c0cf7646 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to:flags.0?InputReplyTo random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut allow_paid_stars:flags.21?long = Updates; +messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; +messages.editMessage#51e842e1 flags:# no_webpage:flags.1?true invert_media:flags.16?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int schedule_repeat_period:flags.18?int quick_reply_shortcut_id:flags.17?int = Updates; +messages.editInlineBotMessage#83557dba flags:# no_webpage:flags.1?true invert_media:flags.16?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector = Bool; +messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer; +messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; +messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; +messages.saveDraft#54ae308e flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo peer:InputPeer message:string entities:flags.3?Vector media:flags.5?InputMedia effect:flags.7?long suggested_post:flags.8?SuggestedPost = Bool; +messages.getAllDrafts#6a3f8d65 = Updates; +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; +messages.readFeaturedStickers#5b118126 id:Vector = Bool; +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; +messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; +messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true emojis:flags.1?true offset_id:long limit:int = messages.ArchivedStickers; +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; +messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; +messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; +messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; +messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; +messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; +messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; +messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; +messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?string shipping_options:flags.1?Vector = Bool; +messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; +messages.uploadMedia#14967978 flags:# business_connection_id:flags.0?string peer:InputPeer media:InputMedia = MessageMedia; +messages.sendScreenshotNotification#a1405817 peer:InputPeer reply_to:InputReplyTo random_id:long = Updates; +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; +messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; +messages.getUnreadMentions#f107e790 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readMentions#36e5bf4d flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; +messages.sendMultiMedia#1bf89d74 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long = Updates; +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSplitRanges#1cff7e08 = Vector; +messages.markDialogUnread#8c5006f8 flags:# unread:flags.0?true parent_peer:flags.1?InputPeer peer:InputDialogPeer = Bool; +messages.getDialogUnreadMarks#21202222 flags:# parent_peer:flags.0?InputPeer = Vector; +messages.clearAllDrafts#7e58ee9c = Bool; +messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1?true pm_oneside:flags.2?true peer:InputPeer id:int = Updates; +messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; +messages.getPollResults#eda3e33b peer:InputPeer msg_id:int poll_hash:long = Updates; +messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; +messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; +messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; +messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; +messages.getEmojiKeywordsDifference#1508b6af lang_code:string from_version:int = EmojiKeywordsDifference; +messages.getEmojiKeywordsLanguages#4e9963b2 lang_codes:Vector = Vector; +messages.getEmojiURL#d5b10c26 lang_code:string = EmojiURL; +messages.getSearchCounters#1bbcf300 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer top_msg_id:flags.0?int filters:Vector = Vector; +messages.requestUrlAuth#894cc99c flags:# peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string in_app_origin:flags.3?string = UrlAuthResult; +messages.acceptUrlAuth#67a3f0de flags:# write_allowed:flags.0?true share_phone_number:flags.3?true peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string match_code:flags.4?string = UrlAuthResult; +messages.hidePeerSettingsBar#4facb138 peer:InputPeer = Bool; +messages.getScheduledHistory#f516760b peer:InputPeer hash:long = messages.Messages; +messages.getScheduledMessages#bdbb0464 peer:InputPeer id:Vector = messages.Messages; +messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; +messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; +messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; +messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; +messages.getDialogFilters#efd48c89 = messages.DialogFilters; +messages.getSuggestedDialogFilters#a29cd42c = Vector; +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; +messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; +messages.unpinAllMessages#62dd747 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; +messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; +messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; +messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:string media:InputMedia = MessageMedia; +messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; +messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; +messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true subscription_expired:flags.3?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; +messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; +messages.setChatTheme#81202c9 peer:InputPeer theme:InputChatTheme = Updates; +messages.getMessageReadParticipants#31c1c44f peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#6aa3f6bd flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#9c7f2f10 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b2081a35 flags:# peer:InputPeer enabled:Bool request_msg_id:flags.0?int = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#d30d78d4 flags:# big:flags.1?true add_to_recent:flags.2?true peer:InputPeer msg_id:int reaction:flags.0?Vector = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#864b2581 flags:# peer:InputPeer available_reactions:ChatReactions reactions_limit:flags.0?int paid_enabled:flags.1?Bool = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool; +messages.translateText#a5eec345 flags:# peer:flags.0?InputPeer id:flags.0?Vector text:flags.1?Vector to_lang:string tone:flags.2?string = messages.TranslatedText; +messages.getUnreadReactions#bd7f90ac flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#9ec44f93 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; +messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; +messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; +messages.toggleBotInAttachMenu#69f59d69 flags:# write_allowed:flags.0?true bot:InputUser enabled:Bool = Bool; +messages.requestWebView#269dc2c1 flags:# from_bot_menu:flags.4?true silent:flags.5?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser url:flags.1?string start_param:flags.3?string theme_params:flags.2?DataJSON platform:string reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = WebViewResult; +messages.prolongWebView#b0d81a83 flags:# silent:flags.5?true peer:InputPeer bot:InputUser query_id:long reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = Bool; +messages.requestSimpleWebView#413a3e73 flags:# from_switch_webview:flags.1?true from_side_menu:flags.2?true compact:flags.7?true fullscreen:flags.8?true bot:InputUser url:flags.3?string start_param:flags.4?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent; +messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates; +messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio; +messages.rateTranscribedAudio#7f1d072f peer:InputPeer msg_id:int transcription_id:long good:Bool = Bool; +messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; +messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; +messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; +messages.reportReaction#3f64c076 peer:InputPeer id:int reaction_peer:InputPeer = Bool; +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; +messages.clearRecentReactions#9dfeefb4 = Bool; +messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; +messages.sendBotRequestedPeer#6c5cf2a7 flags:# peer:InputPeer msg_id:flags.0?int webapp_req_id:flags.1?string button_id:int requested_peers:Vector = Updates; +messages.getEmojiGroups#7488ce5b hash:int = messages.EmojiGroups; +messages.getEmojiStatusGroups#2ecd56cd hash:int = messages.EmojiGroups; +messages.getEmojiProfilePhotoGroups#21a548f3 hash:int = messages.EmojiGroups; +messages.searchCustomEmoji#2c11c0d7 emoticon:string hash:long = EmojiList; +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; +messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; +messages.requestAppWebView#53618bce flags:# write_allowed:flags.0?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = WebViewResult; +messages.setChatWallPaper#8ffacae1 flags:# for_both:flags.3?true revert:flags.4?true peer:InputPeer wallpaper:flags.0?InputWallPaper settings:flags.2?WallPaperSettings id:flags.1?int = Updates; +messages.searchEmojiStickerSets#92b4494c flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSavedDialogs#1e91fc99 flags:# exclude_pinned:flags.0?true parent_peer:flags.1?InputPeer offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; +messages.getSavedHistory#998ab009 flags:# parent_peer:flags.0?InputPeer peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.deleteSavedHistory#4dc5085f flags:# parent_peer:flags.0?InputPeer peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; +messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedSavedDialogs#8b716587 flags:# force:flags.0?true order:Vector = Bool; +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; +messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; +messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; +messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; +messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; +messages.reorderQuickReplies#60331907 order:Vector = Bool; +messages.checkQuickReplyShortcut#f1d0fbd3 shortcut:string = Bool; +messages.editQuickReplyShortcut#5c003cef shortcut_id:int shortcut:string = Bool; +messages.deleteQuickReplyShortcut#3cc04740 shortcut_id:int = Bool; +messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; +messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; +messages.deleteQuickReplyMessages#e105e910 shortcut_id:int id:Vector = Updates; +messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool; +messages.getMyStickers#d0b5e1fc offset_id:long limit:int = messages.MyStickers; +messages.getEmojiStickerGroups#1dd840f5 hash:int = messages.EmojiGroups; +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; +messages.editFactCheck#589ee75 peer:InputPeer msg_id:int text:TextWithEntities = Updates; +messages.deleteFactCheck#d1da940c peer:InputPeer msg_id:int = Updates; +messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; +messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendPaidReaction#58bbcb50 flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?PaidReactionPrivacy = Updates; +messages.togglePaidReactionPrivacy#435885b5 peer:InputPeer msg_id:int private:PaidReactionPrivacy = Bool; +messages.getPaidReactionPrivacy#472455aa = Updates; +messages.viewSponsoredMessage#269e3643 random_id:bytes = Bool; +messages.clickSponsoredMessage#8235057e flags:# media:flags.0?true fullscreen:flags.1?true random_id:bytes = Bool; +messages.reportSponsoredMessage#12cbf0c4 random_id:bytes option:bytes = channels.SponsoredMessageReportResult; +messages.getSponsoredMessages#3d6ce850 flags:# peer:InputPeer msg_id:flags.0?int = messages.SponsoredMessages; +messages.savePreparedInlineMessage#f21f7f2f flags:# result:InputBotInlineResult user_id:InputUser peer_types:flags.0?Vector = messages.BotPreparedInlineMessage; +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; +messages.searchStickers#29b1c66a flags:# emojis:flags.0?true q:string emoticon:string lang_code:Vector offset:int limit:int hash:long = messages.FoundStickers; +messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = Bool; +messages.getSavedDialogsByID#6f6f9c96 flags:# parent_peer:flags.1?InputPeer ids:Vector = messages.SavedDialogs; +messages.readSavedHistory#ba4a3b5b parent_peer:InputPeer peer:InputPeer max_id:int = Bool; +messages.toggleTodoCompleted#d3e03124 peer:InputPeer msg_id:int completed:Vector incompleted:Vector = Updates; +messages.appendTodoList#21a61057 peer:InputPeer msg_id:int list:Vector = Updates; +messages.toggleSuggestedPostApproval#8107455c flags:# reject:flags.1?true peer:InputPeer msg_id:int schedule_date:flags.0?int reject_comment:flags.2?string = Updates; +messages.getForumTopics#3ba47bff flags:# peer:InputPeer q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; +messages.getForumTopicsByID#af0a4a08 peer:InputPeer topics:Vector = messages.ForumTopics; +messages.editForumTopic#cecc1134 flags:# peer:InputPeer topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; +messages.updatePinnedForumTopic#175df251 peer:InputPeer topic_id:int pinned:Bool = Updates; +messages.reorderPinnedForumTopics#e7841f0 flags:# force:flags.0?true peer:InputPeer order:Vector = Updates; +messages.createForumTopic#2f98c3d5 flags:# title_missing:flags.4?true peer:InputPeer title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; +messages.deleteTopicHistory#d2816f10 peer:InputPeer top_msg_id:int = messages.AffectedHistory; +messages.getEmojiGameInfo#fb7e8ca7 = messages.EmojiGameInfo; +messages.summarizeText#abbbd346 flags:# peer:InputPeer id:int to_lang:flags.0?string tone:flags.2?string = TextWithEntities; +messages.editChatCreator#f743b857 peer:InputPeer user_id:InputUser password:InputCheckPasswordSRP = Updates; +messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User; +messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates; +messages.declineUrlAuth#35436bbc url:string = Bool; +messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool; +messages.composeMessageWithAI#daecc589 flags:# proofread:flags.0?true emojify:flags.3?true text:TextWithEntities translate_to_lang:flags.1?string tone:flags.2?InputAiComposeTone = messages.ComposedMessageWithAI; +messages.reportReadMetrics#4067c5e6 peer:InputPeer metrics:Vector = Bool; +messages.reportMusicListen#ddbcd819 id:InputDocument listened_duration:int = Bool; +messages.addPollAnswer#19bc4b6d peer:InputPeer msg_id:int answer:PollAnswer = Updates; +messages.deletePollAnswer#ac8505a5 peer:InputPeer msg_id:int option:bytes = Updates; +messages.getUnreadPollVotes#43286cf2 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readPollVotes#1720b4d8 flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.setBotGuestChatResult#b8f106e3 query_id:long result:InputBotInlineResult = InputBotInlineMessageID; +messages.deleteParticipantReactions#a0b80cf8 peer:InputPeer participant:InputPeer = Bool; +messages.deleteParticipantReaction#e3b7f82c peer:InputPeer msg_id:int participant:InputPeer = Updates; +messages.getPersonalChannelHistory#55fb0996 user_id:InputUser limit:int max_id:int min_id:int hash:long = messages.Messages; + +updates.getState#edd4882a = updates.State; +updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; +updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; + +photos.updateProfilePhoto#9e82039 flags:# fallback:flags.0?true bot:flags.1?InputUser id:InputPhoto = photos.Photo; +photos.uploadProfilePhoto#388a3b5 flags:# fallback:flags.3?true bot:flags.5?InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.4?VideoSize = photos.Photo; +photos.deletePhotos#87cf7f2f id:Vector = Vector; +photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; +photos.uploadContactProfilePhoto#e14c4a71 flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.5?VideoSize = photos.Photo; + +upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; +upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; +upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool; +upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile; +upload.getCdnFile#395f69da file_token:bytes offset:long limit:int = upload.CdnFile; +upload.reuploadCdnFile#9b2754a8 file_token:bytes request_token:bytes = Vector; +upload.getCdnFileHashes#91dc3f31 file_token:bytes offset:long = Vector; +upload.getFileHashes#9156982a location:InputFileLocation offset:long = Vector; + +help.getConfig#c4f9186b = Config; +help.getNearestDc#1fb33026 = NearestDc; +help.getAppUpdate#522d5a7d source:string = help.AppUpdate; +help.getInviteText#4d392343 = help.InviteText; +help.getSupport#9cdf08cd = help.Support; +help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; +help.getCdnConfig#52029342 = CdnConfig; +help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; +help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; +help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; +help.getAppConfig#61e3f854 hash:int = help.AppConfig; +help.saveAppLog#6f02f748 events:Vector = Bool; +help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; +help.getSupportName#d360e72c = help.SupportName; +help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; +help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; +help.getPromoData#c0977421 = help.PromoData; +help.hidePromoData#1e251c95 peer:InputPeer = Bool; +help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; +help.getPremiumPromo#b81b93d4 = help.PremiumPromo; +help.getPeerColors#da80f42f hash:int = help.PeerColors; +help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors; +help.getTimezonesList#49b30240 hash:int = help.TimezonesList; + +channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; +channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; +channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; +channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; +channels.getChannels#a7f6bbb id:Vector = messages.Chats; +channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true forum:flags.5?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; +channels.editAdmin#9a98ad68 flags:# channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:flags.0?string = Updates; +channels.editTitle#566decd0 channel:InputChannel title:string = Updates; +channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; +channels.checkUsername#10e6bd2c channel:InputChannel username:string = Bool; +channels.updateUsername#3514b3de channel:InputChannel username:string = Bool; +channels.joinChannel#7f6a1e22 channel:InputChannel = messages.ChatInviteJoinResult; +channels.leaveChannel#f836aa95 channel:InputChannel = Updates; +channels.inviteToChannel#c9e33d54 channel:InputChannel users:Vector = messages.InvitedUsers; +channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; +channels.exportMessageLink#e63fadeb flags:# grouped:flags.0?true thread:flags.1?true channel:InputChannel id:int = ExportedMessageLink; +channels.toggleSignatures#418d549c flags:# signatures_enabled:flags.0?true profiles_enabled:flags.1?true channel:InputChannel = Updates; +channels.getAdminedPublicChannels#f8b036af flags:# by_location:flags.0?true check_limit:flags.1?true for_personal:flags.2?true = messages.Chats; +channels.editBanned#96e6cd81 channel:InputChannel participant:InputPeer banned_rights:ChatBannedRights = Updates; +channels.getAdminLog#33ddf480 flags:# channel:InputChannel q:string events_filter:flags.0?ChannelAdminLogEventsFilter admins:flags.1?Vector max_id:long min_id:long limit:int = channels.AdminLogResults; +channels.setStickers#ea8ca4f9 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = Bool; +channels.deleteHistory#9baa9647 flags:# for_everyone:flags.0?true channel:InputChannel max_id:int = Updates; +channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates; +channels.getLeftChannels#8341ecc0 offset:int = messages.Chats; +channels.getGroupsForDiscussion#f5dad378 = messages.Chats; +channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool; +channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint address:string = Bool; +channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; +channels.getInactiveChannels#11e831ee = messages.InactiveChats; +channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; +channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; +channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; +channels.toggleJoinRequest#ecc2618 flags:# apply_to_invites:flags.1?true channel:InputChannel enabled:Bool guard_bot:flags.0?InputUser = Updates; +channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; +channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; +channels.deactivateAllUsernames#a245dd3 channel:InputChannel = Bool; +channels.toggleForum#3ff75734 channel:InputChannel enabled:Bool tabs:Bool = Updates; +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; +channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; +channels.updateColor#d8aa3671 flags:# for_profile:flags.1?true channel:InputChannel color:flags.2?int background_emoji_id:flags.0?long = Updates; +channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; +channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; +channels.updateEmojiStatus#f0d3e6a8 channel:InputChannel emoji_status:EmojiStatus = Updates; +channels.setBoostsToUnblockRestrictions#ad399cee channel:InputChannel boosts:int = Updates; +channels.setEmojiStickers#3cd930b7 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.restrictSponsoredMessages#9ae91519 channel:InputChannel restricted:Bool = Updates; +channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; +channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; +channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; +channels.getMessageAuthor#ece2a0e6 channel:InputChannel id:int = User; +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; +channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool; + +bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; +bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; +bots.setBotMenuButton#4504d54f user_id:InputUser button:BotMenuButton = Bool; +bots.getBotMenuButton#9c60eb28 user_id:InputUser = BotMenuButton; +bots.setBotBroadcastDefaultAdminRights#788464e1 admin_rights:ChatAdminRights = Bool; +bots.setBotGroupDefaultAdminRights#925ec9ea admin_rights:ChatAdminRights = Bool; +bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; +bots.getBotInfo#dcd914fd flags:# bot:flags.0?InputUser lang_code:string = bots.BotInfo; +bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = Bool; +bots.toggleUsername#53ca973 bot:InputUser username:string active:Bool = Bool; +bots.canSendMessage#1359f4e6 bot:InputUser = Bool; +bots.allowSendMessage#f132e3ef bot:InputUser = Updates; +bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; +bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots; +bots.addPreviewMedia#17aeb75a bot:InputUser lang_code:string media:InputMedia = BotPreviewMedia; +bots.editPreviewMedia#8525606f bot:InputUser lang_code:string media:InputMedia new_media:InputMedia = BotPreviewMedia; +bots.deletePreviewMedia#2d0135b3 bot:InputUser lang_code:string media:Vector = Bool; +bots.reorderPreviewMedias#b627f3aa bot:InputUser lang_code:string order:Vector = Bool; +bots.getPreviewInfo#423ab3ad bot:InputUser lang_code:string = bots.PreviewInfo; +bots.getPreviewMedias#a2a5594d bot:InputUser = Vector; +bots.updateUserEmojiStatus#ed9f30c5 user_id:InputUser emoji_status:EmojiStatus = Bool; +bots.toggleUserEmojiStatusPermission#6de6392 bot:InputUser enabled:Bool = Bool; +bots.checkDownloadFileParams#50077589 bot:InputUser file_name:string url:string = Bool; +bots.getAdminedBots#b0711d83 = Vector; +bots.updateStarRefProgram#778b5ab3 flags:# bot:InputUser commission_permille:int duration_months:flags.0?int = StarRefProgram; +bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool; +bots.getBotRecommendations#a1b70815 bot:InputUser = users.Users; +bots.checkUsername#87f2219b username:string = Bool; +bots.createBot#e5b17f2b flags:# via_deeplink:flags.0?true name:string username:string manager_id:InputUser = User; +bots.exportBotToken#bd0d99eb bot:InputUser revoke:Bool = bots.ExportedBotToken; +bots.requestWebViewButton#31a2a35e user_id:InputUser button:KeyboardButton = bots.RequestedButton; +bots.getRequestedWebViewButton#bf25b7f3 bot:InputUser webapp_req_id:string = KeyboardButton; +bots.getAccessSettings#213853a3 bot:InputUser = bots.AccessSettings; +bots.editAccessSettings#31813cd8 flags:# restricted:flags.0?true bot:InputUser add_users:flags.1?Vector = Bool; +bots.setJoinChatResults#e71a4810 query_id:long result:JoinChatBotResult = Bool; + +payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; +payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; +payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; +payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; +payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; +payments.getBankCardData#2e79d779 number:string = payments.BankCardData; +payments.exportInvoice#f91b065 invoice_media:InputMedia = payments.ExportedInvoice; +payments.assignAppStoreTransaction#80ed747d receipt:bytes purpose:InputStorePaymentPurpose = Updates; +payments.assignPlayMarketTransaction#dffd50d3 receipt:DataJSON purpose:InputStorePaymentPurpose = Updates; +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; +payments.applyGiftCode#f6e26854 slug:string = Updates; +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; +payments.getStarsTopupOptions#c00ec7d3 = Vector; +payments.getStarsStatus#4ea9b3bf flags:# ton:flags.0?true peer:InputPeer = payments.StarsStatus; +payments.getStarsTransactions#69da4557 flags:# inbound:flags.0?true outbound:flags.1?true ascending:flags.2?true ton:flags.4?true subscription_id:flags.3?string peer:InputPeer offset:string limit:int = payments.StarsStatus; +payments.sendStarsForm#7998c914 form_id:long invoice:InputInvoice = payments.PaymentResult; +payments.refundStarsCharge#25ae8f4a user_id:InputUser charge_id:string = Updates; +payments.getStarsRevenueStats#d91ffad6 flags:# dark:flags.0?true ton:flags.1?true peer:InputPeer = payments.StarsRevenueStats; +payments.getStarsRevenueWithdrawalUrl#2433dc92 flags:# ton:flags.0?true peer:InputPeer amount:flags.1?long password:InputCheckPasswordSRP = payments.StarsRevenueWithdrawalUrl; +payments.getStarsRevenueAdsAccountUrl#d1d7efc5 peer:InputPeer = payments.StarsRevenueAdsAccountUrl; +payments.getStarsTransactionsByID#2dca16b8 flags:# ton:flags.0?true peer:InputPeer id:Vector = payments.StarsStatus; +payments.getStarsGiftOptions#d3c96bc8 flags:# user_id:flags.0?InputUser = Vector; +payments.getStarsSubscriptions#32512c5 flags:# missing_balance:flags.0?true peer:InputPeer offset:string = payments.StarsStatus; +payments.changeStarsSubscription#c7770878 flags:# peer:InputPeer subscription_id:string canceled:flags.0?Bool = Bool; +payments.fulfillStarsSubscription#cc5bebb3 peer:InputPeer subscription_id:string = Bool; +payments.getStarsGiveawayOptions#bd1efd3e = Vector; +payments.getStarGifts#c4563590 hash:int = payments.StarGifts; +payments.saveStarGift#2a2a697c flags:# unsave:flags.0?true stargift:InputSavedStarGift = Bool; +payments.convertStarGift#74bf076b stargift:InputSavedStarGift = Bool; +payments.botCancelStarsSubscription#6dfa0622 flags:# restore:flags.0?true user_id:InputUser charge_id:string = Bool; +payments.getConnectedStarRefBots#5869a553 flags:# peer:InputPeer offset_date:flags.2?int offset_link:flags.2?string limit:int = payments.ConnectedStarRefBots; +payments.getConnectedStarRefBot#b7d998f0 peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.getSuggestedStarRefBots#d6b48f7 flags:# order_by_revenue:flags.0?true order_by_date:flags.1?true peer:InputPeer offset:string limit:int = payments.SuggestedStarRefBots; +payments.connectStarRefBot#7ed5348a peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.editConnectedStarRefBot#e4fca4a3 flags:# revoked:flags.0?true peer:InputPeer link:string = payments.ConnectedStarRefBots; +payments.getStarGiftUpgradePreview#9c9abcb1 gift_id:long = payments.StarGiftUpgradePreview; +payments.upgradeStarGift#aed6e4f5 flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = Updates; +payments.transferStarGift#7f18176a stargift:InputSavedStarGift to_id:InputPeer = Updates; +payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift; +payments.getSavedStarGifts#a319e569 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_unique:flags.4?true sort_by_value:flags.5?true exclude_upgradable:flags.7?true exclude_unupgradable:flags.8?true peer_color_available:flags.9?true exclude_hosted:flags.10?true peer:InputPeer collection_id:flags.6?int offset:string limit:int = payments.SavedStarGifts; +payments.getSavedStarGift#b455a106 stargift:Vector = payments.SavedStarGifts; +payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl; +payments.toggleChatStarGiftNotifications#60eaefa1 flags:# enabled:flags.0?true peer:InputPeer = Bool; +payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = Bool; +payments.canPurchaseStore#4fdc5ea7 purpose:InputStorePaymentPurpose = Bool; +payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true for_craft:flags.4?true stars_only:flags.5?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector offset:string limit:int = payments.ResaleStarGifts; +payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates; +payments.createStarGiftCollection#1f4a0e87 peer:InputPeer title:string stargift:Vector = StarGiftCollection; +payments.updateStarGiftCollection#4fddbee7 flags:# peer:InputPeer collection_id:int title:flags.0?string delete_stargift:flags.1?Vector add_stargift:flags.2?Vector order:flags.3?Vector = StarGiftCollection; +payments.reorderStarGiftCollections#c32af4cc peer:InputPeer order:Vector = Bool; +payments.deleteStarGiftCollection#ad5648e8 peer:InputPeer collection_id:int = Bool; +payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections; +payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGiftValueInfo; +payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; +payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; +payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; +payments.sendStarGiftOffer#8fb86b41 flags:# peer:InputPeer slug:string price:StarsAmount duration:int random_id:long allow_paid_stars:flags.0?long = Updates; +payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; +payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts; +payments.craftStarGift#b0f9684f stargift:Vector = Updates; + +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true emojis:flags.5?true text_color:flags.6?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; +stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; +stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; +stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; +stickers.setStickerSetThumb#a76a5392 flags:# stickerset:InputStickerSet thumb:flags.0?InputDocument thumb_document_id:flags.1?long = messages.StickerSet; +stickers.checkShortName#284b3639 short_name:string = Bool; +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; +stickers.changeSticker#f5537ebc flags:# sticker:InputDocument emoji:flags.0?string mask_coords:flags.1?MaskCoords keywords:flags.2?string = messages.StickerSet; +stickers.renameStickerSet#124b1c00 stickerset:InputStickerSet title:string = messages.StickerSet; +stickers.deleteStickerSet#87704394 stickerset:InputStickerSet = Bool; +stickers.replaceSticker#4696459a sticker:InputDocument new_sticker:InputStickerSetItem = messages.StickerSet; + +phone.getCallConfig#55451fa9 = DataJSON; +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.toggleGroupCallSettings#974392f2 flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool messages_enabled:flags.2?Bool send_paid_messages_stars:flags.3?long = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; +phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; +phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; +phone.getGroupCallStreamRtmpUrl#5af4c73a flags:# live_story:flags.0?true peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; +phone.saveCallLog#41248786 peer:InputPhoneCall file:InputFile = Bool; +phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates; +phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector block:bytes = Updates; +phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates; +phone.inviteConferenceCallParticipant#bcf22685 flags:# video:flags.0?true call:InputGroupCall user_id:InputUser = Updates; +phone.declineConferenceCallInvite#3c479971 msg_id:int = Updates; +phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates; +phone.sendGroupCallMessage#b1d11410 flags:# call:InputGroupCall random_id:long message:TextWithEntities allow_paid_stars:flags.0?long send_as:flags.1?InputPeer = Updates; +phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool; +phone.deleteGroupCallMessages#f64f54f7 flags:# report_spam:flags.0?true call:InputGroupCall messages:Vector = Updates; +phone.deleteGroupCallParticipantMessages#1dbfeca0 flags:# report_spam:flags.0?true call:InputGroupCall participant:InputPeer = Updates; +phone.getGroupCallStars#6f636302 call:InputGroupCall = phone.GroupCallStars; +phone.saveDefaultSendAs#4167add1 call:InputGroupCall send_as:InputPeer = Bool; + +langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; +langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; +langpack.getDifference#cd984aa5 lang_pack:string lang_code:string from_version:int = LangPackDifference; +langpack.getLanguages#42c6978f lang_pack:string = Vector; +langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLanguage; + +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; + +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; +stats.getMessagePublicForwards#5f150144 channel:InputChannel msg_id:int offset:string limit:int = stats.PublicForwards; +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; +stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats; +stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards; +stats.getPollStats#c27dfa68 flags:# dark:flags.0?true peer:InputPeer msg_id:int = stats.PollStats; + +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = chatlists.ExportedChatlistInvite; +chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool; +chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector = ExportedChatlistInvite; +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; +chatlists.getChatlistUpdates#89419521 chatlist:InputChatlist = chatlists.ChatlistUpdates; +chatlists.joinChatlistUpdates#e089f8f5 chatlist:InputChatlist peers:Vector = Updates; +chatlists.hideChatlistUpdates#66e486fb chatlist:InputChatlist = Bool; +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates; + +stories.canSendStory#30eb63f0 peer:InputPeer = stories.CanSendStoryCount; +stories.sendStory#8f9e6898 flags:# pinned:flags.2?true noforwards:flags.4?true fwd_modified:flags.7?true peer:InputPeer media:InputMedia media_areas:flags.5?Vector caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long period:flags.3?int fwd_from_id:flags.6?InputPeer fwd_from_story:flags.6?int albums:flags.8?Vector music:flags.9?InputDocument = Updates; +stories.editStory#2c63a72b flags:# peer:InputPeer id:int media:flags.0?InputMedia media_areas:flags.3?Vector caption:flags.1?string entities:flags.1?Vector privacy_rules:flags.2?Vector music:flags.4?InputDocument = Updates; +stories.deleteStories#ae59db5f peer:InputPeer id:Vector = Vector; +stories.togglePinned#9a75a1ef peer:InputPeer id:Vector pinned:Bool = Vector; +stories.getAllStories#eeb0d625 flags:# next:flags.1?true hidden:flags.2?true state:flags.0?string = stories.AllStories; +stories.getPinnedStories#5821a5dc peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesArchive#b4352016 peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesByID#5774ca74 peer:InputPeer id:Vector = stories.Stories; +stories.toggleAllStoriesHidden#7c2557c4 hidden:Bool = Bool; +stories.readStories#a556dac8 peer:InputPeer max_id:int = Vector; +stories.incrementStoryViews#b2028afb peer:InputPeer id:Vector = Bool; +stories.getStoryViewsList#7ed23c57 flags:# just_contacts:flags.0?true reactions_first:flags.2?true forwards_first:flags.3?true peer:InputPeer q:flags.1?string id:int offset:string limit:int = stories.StoryViewsList; +stories.getStoriesViews#28e16cc8 peer:InputPeer id:Vector = stories.StoryViews; +stories.exportStoryLink#7b8def20 peer:InputPeer id:int = ExportedStoryLink; +stories.report#19d8eb45 peer:InputPeer id:Vector option:bytes message:string = ReportResult; +stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?true = Updates; +stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; +stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; +stories.getAllReadPeerStories#9b5ae7f9 = Updates; +stories.getPeerMaxIDs#78499170 id:Vector = Vector; +stories.getChatsToSend#a56a8b60 = messages.Chats; +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.getStoryReactionsList#b9b2881f flags:# forwards_first:flags.2?true peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = stories.StoryReactionsList; +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; +stories.searchPosts#d1810907 flags:# hashtag:flags.0?string area:flags.1?MediaArea peer:flags.2?InputPeer offset:string limit:int = stories.FoundStories; +stories.createAlbum#a36396e5 peer:InputPeer title:string stories:Vector = StoryAlbum; +stories.updateAlbum#5e5259b6 flags:# peer:InputPeer album_id:int title:flags.0?string delete_stories:flags.1?Vector add_stories:flags.2?Vector order:flags.3?Vector = StoryAlbum; +stories.reorderAlbums#8535fbd9 peer:InputPeer order:Vector = Bool; +stories.deleteAlbum#8d3456d0 peer:InputPeer album_id:int = Bool; +stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums; +stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories; +stories.startLive#d069ccde flags:# pinned:flags.2?true noforwards:flags.4?true rtmp_stream:flags.5?true peer:InputPeer caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long messages_enabled:flags.6?Bool send_paid_messages_stars:flags.7?long = Updates; + +premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; +premium.getMyBoosts#be77b4a = premium.MyBoosts; +premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; +premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus; +premium.getUserBoosts#39854d1f peer:InputPeer user_id:InputUser = premium.BoostsList; + +smsjobs.isEligibleToJoin#edc39d0 = smsjobs.EligibilityToJoin; +smsjobs.join#a74ece2d = Bool; +smsjobs.leave#9898ad73 = Bool; +smsjobs.updateSettings#93fa0bf flags:# allow_international:flags.0?true = Bool; +smsjobs.getStatus#10a698e8 = smsjobs.Status; +smsjobs.getSmsJob#778d902f job_id:string = SmsJob; +smsjobs.finishJob#4f1ebf24 flags:# job_id:string error:flags.0?string = Bool; + +fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo; + +aicompose.createTone#4aa83913 flags:# display_author:flags.0?true emoji_id:long title:string prompt:string = AiComposeTone; +aicompose.updateTone#903bcf59 flags:# tone:InputAiComposeTone display_author:flags.0?Bool emoji_id:flags.1?long title:flags.2?string prompt:flags.3?string = AiComposeTone; +aicompose.saveTone#1782cbb1 tone:InputAiComposeTone unsave:Bool = Bool; +aicompose.deleteTone#dd39316a tone:InputAiComposeTone = Bool; +aicompose.getTone#b2e8ba03 tone:InputAiComposeTone = aicompose.Tones; +aicompose.getTones#abd59201 hash:long = aicompose.Tones; +aicompose.getToneExample#d1b4ab14 tone:InputAiComposeTone num:int = AiComposeToneExample; + +// LAYER 226 diff --git a/internal/compat/layerwire/_schema/layer-227.tl b/internal/compat/layerwire/_schema/layer-227.tl new file mode 100644 index 00000000..2784293c --- /dev/null +++ b/internal/compat/layerwire/_schema/layer-227.tl @@ -0,0 +1,3039 @@ +boolFalse#bc799737 = Bool; +boolTrue#997275b5 = Bool; + +true#3fedd339 = True; + +vector#1cb5c415 {t:Type} # [ t ] = Vector t; + +error#c4b9f9bb code:int text:string = Error; + +null#56730bcc = Null; + +inputPeerEmpty#7f3b18ea = InputPeer; +inputPeerSelf#7da07ec9 = InputPeer; +inputPeerChat#35a95cb9 chat_id:long = InputPeer; +inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer; +inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer; +inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer; +inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer; + +inputUserEmpty#b98886cf = InputUser; +inputUserSelf#f7c1b13f = InputUser; +inputUser#f21158c6 user_id:long access_hash:long = InputUser; +inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser; + +inputPhoneContact#6a1dc4be flags:# client_id:long phone:string first_name:string last_name:string note:flags.0?TextWithEntities = InputContact; + +inputFile#f52ff27f id:long parts:int name:string md5_checksum:string = InputFile; +inputFileBig#fa4f0bb5 id:long parts:int name:string = InputFile; +inputFileStoryDocument#62dc8b48 id:InputDocument = InputFile; + +inputMediaEmpty#9664f57f = InputMedia; +inputMediaUploadedPhoto#7d8375da flags:# spoiler:flags.2?true live_photo:flags.3?true file:InputFile stickers:flags.0?Vector ttl_seconds:flags.1?int video:flags.3?InputDocument = InputMedia; +inputMediaPhoto#e3af4434 flags:# spoiler:flags.1?true live_photo:flags.2?true id:InputPhoto ttl_seconds:flags.0?int video:flags.2?InputDocument = InputMedia; +inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia; +inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia; +inputMediaUploadedDocument#37c9330 flags:# nosound_video:flags.3?true force_file:flags.4?true spoiler:flags.5?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector video_cover:flags.6?InputPhoto video_timestamp:flags.7?int ttl_seconds:flags.1?int = InputMedia; +inputMediaDocument#a8763ab5 flags:# spoiler:flags.2?true id:InputDocument video_cover:flags.3?InputPhoto video_timestamp:flags.4?int ttl_seconds:flags.0?int query:flags.1?string = InputMedia; +inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia; +inputMediaPhotoExternal#e5bbfe1a flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int = InputMedia; +inputMediaDocumentExternal#779600f9 flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int video_cover:flags.2?InputPhoto video_timestamp:flags.3?int = InputMedia; +inputMediaGame#d33f43f3 id:InputGame = InputMedia; +inputMediaInvoice#405fef0d flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:flags.3?string provider_data:DataJSON start_param:flags.1?string extended_media:flags.2?InputMedia = InputMedia; +inputMediaGeoLive#971fa843 flags:# stopped:flags.0?true geo_point:InputGeoPoint heading:flags.2?int period:flags.1?int proximity_notification_radius:flags.3?int = InputMedia; +inputMediaPoll#883a4108 flags:# poll:Poll correct_answers:flags.0?Vector attached_media:flags.3?InputMedia solution:flags.1?string solution_entities:flags.1?Vector solution_media:flags.2?InputMedia = InputMedia; +inputMediaDice#e66fbf7b emoticon:string = InputMedia; +inputMediaStory#89fdd778 peer:InputPeer id:int = InputMedia; +inputMediaWebPage#c21b8849 flags:# force_large_media:flags.0?true force_small_media:flags.1?true optional:flags.2?true url:string = InputMedia; +inputMediaPaidMedia#c4103386 flags:# stars_amount:long extended_media:Vector payload:flags.0?string = InputMedia; +inputMediaTodo#9fc55fde todo:TodoList = InputMedia; +inputMediaStakeDice#f3a9244a game_hash:string ton_amount:long client_seed:bytes = InputMedia; + +inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; +inputChatUploadedPhoto#bdcdaec0 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.3?VideoSize = InputChatPhoto; +inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; + +inputGeoPointEmpty#e4c123d6 = InputGeoPoint; +inputGeoPoint#48222faf flags:# lat:double long:double accuracy_radius:flags.0?int = InputGeoPoint; + +inputPhotoEmpty#1cd7bf0d = InputPhoto; +inputPhoto#3bb3b94a id:long access_hash:long file_reference:bytes = InputPhoto; + +inputFileLocation#dfdaabe1 volume_id:long local_id:int secret:long file_reference:bytes = InputFileLocation; +inputEncryptedFileLocation#f5235d55 id:long access_hash:long = InputFileLocation; +inputDocumentFileLocation#bad07584 id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputSecureFileLocation#cbc7ee28 id:long access_hash:long = InputFileLocation; +inputTakeoutFileLocation#29be5899 = InputFileLocation; +inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; +inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation; +inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation; +inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation; +inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation; + +peerUser#59511722 user_id:long = Peer; +peerChat#36c6019a chat_id:long = Peer; +peerChannel#a2a5371e channel_id:long = Peer; + +storage.fileUnknown#aa963b05 = storage.FileType; +storage.filePartial#40bc6f52 = storage.FileType; +storage.fileJpeg#7efe0e = storage.FileType; +storage.fileGif#cae1aadf = storage.FileType; +storage.filePng#a4f63c0 = storage.FileType; +storage.filePdf#ae1e508d = storage.FileType; +storage.fileMp3#528a0677 = storage.FileType; +storage.fileMov#4b09ebbc = storage.FileType; +storage.fileMp4#b3cea0e4 = storage.FileType; +storage.fileWebp#1081464c = storage.FileType; + +userEmpty#d3bc4b7a id:long = User; +user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true bot_forum_can_manage_topics:flags2.17?true bot_can_manage_bots:flags2.18?true bot_guestchat:flags2.19?true bot_guard:flags2.20?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User; + +userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; +userProfilePhoto#82d1f706 flags:# has_video:flags.0?true personal:flags.2?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto; + +userStatusEmpty#9d05049 = UserStatus; +userStatusOnline#edb93949 expires:int = UserStatus; +userStatusOffline#8c703f was_online:int = UserStatus; +userStatusRecently#7b197dc8 flags:# by_me:flags.0?true = UserStatus; +userStatusLastWeek#541a1d1a flags:# by_me:flags.0?true = UserStatus; +userStatusLastMonth#65899777 flags:# by_me:flags.0?true = UserStatus; + +chatEmpty#29562865 id:long = Chat; +chat#41cbf256 flags:# creator:flags.0?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; +chatForbidden#6592a1a7 id:long title:string = Chat; +channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat; +channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true monoforum:flags.10?true id:long access_hash:long title:string until_date:flags.16?int = Chat; + +chatFull#2633421b flags:# can_set_username:flags.7?true has_scheduled:flags.8?true translations_disabled:flags.19?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions reactions_limit:flags.20?int = ChatFull; +channelFull#a04e8d3a flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true participants_hidden:flags2.2?true translations_disabled:flags2.3?true stories_pinned_available:flags2.5?true view_forum_as_messages:flags2.6?true restricted_sponsored:flags2.11?true can_view_revenue:flags2.12?true paid_media_allowed:flags2.14?true can_view_stars_revenue:flags2.15?true paid_reactions_available:flags2.16?true stargifts_available:flags2.19?true paid_messages_available:flags2.20?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions reactions_limit:flags2.13?int stories:flags2.4?PeerStories wallpaper:flags2.7?WallPaper boosts_applied:flags2.8?int boosts_unrestrict:flags2.9?int emojiset:flags2.10?StickerSet bot_verification:flags2.17?BotVerification stargifts_count:flags2.18?int send_paid_messages_stars:flags2.21?long main_tab:flags2.22?ProfileTab guard_bot_id:flags2.23?long = ChatFull; + +chatParticipant#38e79fde flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; +chatParticipantCreator#e1f867b8 flags:# user_id:long rank:flags.0?string = ChatParticipant; +chatParticipantAdmin#360d5d2 flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; + +chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants; +chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants; + +chatPhotoEmpty#37c1011c = ChatPhoto; +chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; + +messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; +message#7600b9d3 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true invert_media:flags.27?true flags2:# offline:flags2.1?true video_processing_pending:flags2.4?true paid_suggested_post_stars:flags2.8?true paid_suggested_post_ton:flags2.9?true id:int from_id:flags.8?Peer from_boosts_applied:flags.29?int from_rank:flags2.12?string peer_id:Peer saved_peer_id:flags.28?Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long via_business_bot_id:flags2.0?long guestchat_via_from:flags2.19?Peer reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int quick_reply_shortcut_id:flags.30?int effect:flags2.2?long factcheck:flags2.3?FactCheck report_delivery_until_date:flags2.5?int paid_message_stars:flags2.6?long suggested_post:flags2.7?SuggestedPost schedule_repeat_period:flags2.10?int summary_from_language:flags2.11?string rich_message:flags2.13?RichMessage = Message; +messageService#7a800e0a flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true reactions_are_possible:flags.9?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer saved_peer_id:flags.28?Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction reactions:flags.20?MessageReactions ttl_period:flags.25?int = Message; + +messageMediaEmpty#3ded6320 = MessageMedia; +messageMediaPhoto#e216eb63 flags:# spoiler:flags.3?true live_photo:flags.4?true photo:flags.0?Photo ttl_seconds:flags.2?int video:flags.4?Document = MessageMedia; +messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia; +messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia; +messageMediaUnsupported#9f84f49e = MessageMedia; +messageMediaDocument#52d8ccd9 flags:# nopremium:flags.3?true spoiler:flags.4?true video:flags.6?true round:flags.7?true voice:flags.8?true document:flags.0?Document alt_documents:flags.5?Vector video_cover:flags.9?Photo video_timestamp:flags.10?int ttl_seconds:flags.2?int = MessageMedia; +messageMediaWebPage#ddf10c3b flags:# force_large_media:flags.0?true force_small_media:flags.1?true manual:flags.3?true safe:flags.4?true webpage:WebPage = MessageMedia; +messageMediaVenue#2ec0533f geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MessageMedia; +messageMediaGame#fdb19008 game:Game = MessageMedia; +messageMediaInvoice#f6a548d3 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string extended_media:flags.4?MessageExtendedMedia = MessageMedia; +messageMediaGeoLive#b940c666 flags:# geo:GeoPoint heading:flags.0?int period:int proximity_notification_radius:flags.1?int = MessageMedia; +messageMediaPoll#773f4e66 flags:# poll:Poll results:PollResults attached_media:flags.0?MessageMedia = MessageMedia; +messageMediaDice#8cbec07 flags:# value:int emoticon:string game_outcome:flags.0?messages.EmojiGameOutcome = MessageMedia; +messageMediaStory#68cb6283 flags:# via_mention:flags.1?true peer:Peer id:int story:flags.0?StoryItem = MessageMedia; +messageMediaGiveaway#aa073beb flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.2?true channels:Vector countries_iso2:flags.1?Vector prize_description:flags.3?string quantity:int months:flags.4?int stars:flags.5?long until_date:int = MessageMedia; +messageMediaGiveawayResults#ceaa3ea1 flags:# only_new_subscribers:flags.0?true refunded:flags.2?true channel_id:long additional_peers_count:flags.3?int launch_msg_id:int winners_count:int unclaimed_count:int winners:Vector months:flags.4?int stars:flags.5?long prize_description:flags.1?string until_date:int = MessageMedia; +messageMediaPaidMedia#a8852491 stars_amount:long extended_media:Vector = MessageMedia; +messageMediaToDo#8a53b014 flags:# todo:TodoList completions:flags.0?Vector = MessageMedia; +messageMediaVideoStream#ca5cab89 flags:# rtmp_stream:flags.0?true call:InputGroupCall = MessageMedia; + +messageActionEmpty#b6aef7b0 = MessageAction; +messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction; +messageActionChatEditTitle#b5a1ce5a title:string = MessageAction; +messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction; +messageActionChatDeletePhoto#95e3fbef = MessageAction; +messageActionChatAddUser#15cefd00 users:Vector = MessageAction; +messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction; +messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction; +messageActionChannelCreate#95d2ac92 title:string = MessageAction; +messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction; +messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction; +messageActionPinMessage#94bd38ed = MessageAction; +messageActionHistoryClear#9fbab604 = MessageAction; +messageActionGameScore#92a72876 game_id:long score:int = MessageAction; +messageActionPaymentSentMe#ffa00ccc flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string charge:PaymentCharge subscription_until_date:flags.4?int = MessageAction; +messageActionPaymentSent#c624b16e flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long invoice_slug:flags.0?string subscription_until_date:flags.4?int = MessageAction; +messageActionPhoneCall#80e11a7f flags:# video:flags.2?true call_id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = MessageAction; +messageActionScreenshotTaken#4792929b = MessageAction; +messageActionCustomAction#fae69f56 message:string = MessageAction; +messageActionBotAllowed#c516d679 flags:# attach_menu:flags.1?true from_request:flags.3?true domain:flags.0?string app:flags.2?BotApp = MessageAction; +messageActionSecureValuesSentMe#1b287353 values:Vector credentials:SecureCredentialsEncrypted = MessageAction; +messageActionSecureValuesSent#d95c6154 types:Vector = MessageAction; +messageActionContactSignUp#f3f25f76 = MessageAction; +messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; +messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; +messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; +messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; +messageActionSetChatTheme#b91bbd3a theme:ChatTheme = MessageAction; +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; +messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; +messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; +messageActionGiftPremium#48e91302 flags:# currency:string amount:long days:int crypto_currency:flags.0?string crypto_amount:flags.0?long message:flags.1?TextWithEntities = MessageAction; +messageActionTopicCreate#d999256 flags:# title_missing:flags.1?true title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; +messageActionSuggestProfilePhoto#57de635e photo:Photo = MessageAction; +messageActionRequestedPeer#31518e9b button_id:int peers:Vector = MessageAction; +messageActionSetChatWallPaper#5060a3f4 flags:# same:flags.0?true for_both:flags.1?true wallpaper:WallPaper = MessageAction; +messageActionGiftCode#31c48347 flags:# via_giveaway:flags.0?true unclaimed:flags.5?true boost_peer:flags.1?Peer days:int slug:string currency:flags.2?string amount:flags.2?long crypto_currency:flags.3?string crypto_amount:flags.3?long message:flags.4?TextWithEntities = MessageAction; +messageActionGiveawayLaunch#a80f51e4 flags:# stars:flags.0?long = MessageAction; +messageActionGiveawayResults#87e2f155 flags:# stars:flags.0?true winners_count:int unclaimed_count:int = MessageAction; +messageActionBoostApply#cc02aa6d boosts:int = MessageAction; +messageActionRequestedPeerSentMe#93b31848 button_id:int peers:Vector = MessageAction; +messageActionPaymentRefunded#41b3e202 flags:# peer:Peer currency:string total_amount:long payload:flags.0?bytes charge:PaymentCharge = MessageAction; +messageActionGiftStars#45d5b021 flags:# currency:string amount:long stars:long crypto_currency:flags.0?string crypto_amount:flags.0?long transaction_id:flags.1?string = MessageAction; +messageActionPrizeStars#b00c47a2 flags:# unclaimed:flags.0?true stars:long transaction_id:string boost_peer:Peer giveaway_msg_id:int = MessageAction; +messageActionStarGift#ea2c31d3 flags:# name_hidden:flags.0?true saved:flags.2?true converted:flags.3?true upgraded:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true prepaid_upgrade:flags.13?true upgrade_separate:flags.16?true auction_acquired:flags.17?true gift:StarGift message:flags.1?TextWithEntities convert_stars:flags.4?long upgrade_msg_id:flags.5?int upgrade_stars:flags.8?long from_id:flags.11?Peer peer:flags.12?Peer saved_id:flags.12?long prepaid_upgrade_hash:flags.14?string gift_msg_id:flags.15?int to_id:flags.18?Peer gift_num:flags.19?int = MessageAction; +messageActionStarGiftUnique#e6c31522 flags:# upgrade:flags.0?true transferred:flags.1?true saved:flags.2?true refunded:flags.5?true prepaid_upgrade:flags.11?true assigned:flags.13?true from_offer:flags.14?true craft:flags.16?true gift:StarGift can_export_at:flags.3?int transfer_stars:flags.4?long from_id:flags.6?Peer peer:flags.7?Peer saved_id:flags.7?long resale_amount:flags.8?StarsAmount can_transfer_at:flags.9?int can_resell_at:flags.10?int drop_original_details_stars:flags.12?long can_craft_at:flags.15?int = MessageAction; +messageActionPaidMessagesRefunded#ac1f1fcd count:int stars:long = MessageAction; +messageActionPaidMessagesPrice#84b88578 flags:# broadcast_messages_allowed:flags.0?true stars:long = MessageAction; +messageActionConferenceCall#2ffe2f7a flags:# missed:flags.0?true active:flags.1?true video:flags.4?true call_id:long duration:flags.2?int other_participants:flags.3?Vector = MessageAction; +messageActionTodoCompletions#cc7c5c89 completed:Vector incompleted:Vector = MessageAction; +messageActionTodoAppendTasks#c7edbc83 list:Vector = MessageAction; +messageActionSuggestedPostApproval#ee7a1596 flags:# rejected:flags.0?true balance_too_low:flags.1?true reject_comment:flags.2?string schedule_date:flags.3?int price:flags.4?StarsAmount = MessageAction; +messageActionSuggestedPostSuccess#95ddcf69 price:StarsAmount = MessageAction; +messageActionSuggestedPostRefund#69f916f8 flags:# payer_initiated:flags.0?true = MessageAction; +messageActionGiftTon#a8a3c699 flags:# currency:string amount:long crypto_currency:string crypto_amount:long transaction_id:flags.0?string = MessageAction; +messageActionSuggestBirthday#2c8f2a25 birthday:Birthday = MessageAction; +messageActionStarGiftPurchaseOffer#774278d4 flags:# accepted:flags.0?true declined:flags.1?true gift:StarGift price:StarsAmount expires_at:int = MessageAction; +messageActionStarGiftPurchaseOfferDeclined#73ada76b flags:# expired:flags.0?true gift:StarGift price:StarsAmount = MessageAction; +messageActionNewCreatorPending#b07ed085 new_creator_id:long = MessageAction; +messageActionChangeCreator#e188503b new_creator_id:long = MessageAction; +messageActionNoForwardsToggle#bf7d6572 prev_value:Bool new_value:Bool = MessageAction; +messageActionNoForwardsRequest#3e2793ba flags:# expired:flags.0?true prev_value:Bool new_value:Bool = MessageAction; +messageActionPollAppendAnswer#9da1cd6c answer:PollAnswer = MessageAction; +messageActionPollDeleteAnswer#399674dc answer:PollAnswer = MessageAction; +messageActionManagedBotCreated#16605e3e bot_id:long = MessageAction; + +dialog#fc89f7f3 flags:# pinned:flags.2?true unread_mark:flags.3?true view_forum_as_messages:flags.6?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; +dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; + +photoEmpty#2331b22d id:long = Photo; +photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; + +photoSizeEmpty#e17e23c type:string = PhotoSize; +photoSize#75c78e60 type:string w:int h:int size:int = PhotoSize; +photoCachedSize#21e1ad6 type:string w:int h:int bytes:bytes = PhotoSize; +photoStrippedSize#e0b0bc2e type:string bytes:bytes = PhotoSize; +photoSizeProgressive#fa3efb95 type:string w:int h:int sizes:Vector = PhotoSize; +photoPathSize#d8214d41 type:string bytes:bytes = PhotoSize; + +geoPointEmpty#1117dd5f = GeoPoint; +geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint; + +auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; +auth.sentCodeSuccess#2390fe44 authorization:auth.Authorization = auth.SentCode; +auth.sentCodePaymentRequired#f8827ebf store_product:string phone_code_hash:string support_email_address:string support_email_subject:string premium_days:int currency:string amount:long = auth.SentCode; + +auth.authorization#2ea2c0d4 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int future_auth_token:flags.2?bytes user:User = auth.Authorization; +auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; + +auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; + +inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer; +inputNotifyUsers#193b4417 = InputNotifyPeer; +inputNotifyChats#4a95e84e = InputNotifyPeer; +inputNotifyBroadcasts#b1db7c7e = InputNotifyPeer; +inputNotifyForumTopic#5c467992 peer:InputPeer top_msg_id:int = InputNotifyPeer; + +inputPeerNotifySettings#cacb6ae2 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_sound:flags.8?NotificationSound = InputPeerNotifySettings; + +peerNotifySettings#99622c0c flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int ios_sound:flags.3?NotificationSound android_sound:flags.4?NotificationSound other_sound:flags.5?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_ios_sound:flags.8?NotificationSound stories_android_sound:flags.9?NotificationSound stories_other_sound:flags.10?NotificationSound = PeerNotifySettings; + +peerSettings#f47741f7 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true business_bot_paused:flags.11?true business_bot_can_reply:flags.12?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int business_bot_id:flags.13?long business_bot_manage_url:flags.13?string charge_paid_message_stars:flags.14?long registration_month:flags.15?string phone_country:flags.16?string name_change_date:flags.17?int photo_change_date:flags.18?int = PeerSettings; + +wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; +wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; + +inputReportReasonSpam#58dbcab8 = ReportReason; +inputReportReasonViolence#1e22c78d = ReportReason; +inputReportReasonPornography#2e59d922 = ReportReason; +inputReportReasonChildAbuse#adf44ee3 = ReportReason; +inputReportReasonOther#c1e4a2b1 = ReportReason; +inputReportReasonCopyright#9b89f93a = ReportReason; +inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; +inputReportReasonFake#f5ddd6e7 = ReportReason; +inputReportReasonIllegalDrugs#a8eb2be = ReportReason; +inputReportReasonPersonalDetails#9ec7863d = ReportReason; + +userFull#6cbe645 flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true voice_messages_forbidden:flags.20?true translations_disabled:flags.23?true stories_pinned_available:flags.26?true blocked_my_stories_from:flags.27?true wallpaper_overridden:flags.28?true contact_require_premium:flags.29?true read_dates_private:flags.30?true flags2:# sponsored_enabled:flags2.7?true can_view_revenue:flags2.9?true bot_can_manage_emoji_status:flags2.10?true display_gifts_button:flags2.16?true noforwards_my_enabled:flags2.23?true noforwards_peer_enabled:flags2.24?true unofficial_security_risk:flags2.26?true id:long about:flags.1?string settings:PeerSettings personal_photo:flags.21?Photo profile_photo:flags.2?Photo fallback_photo:flags.22?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme:flags.15?ChatTheme private_forward_name:flags.16?string bot_group_admin_rights:flags.17?ChatAdminRights bot_broadcast_admin_rights:flags.18?ChatAdminRights wallpaper:flags.24?WallPaper stories:flags.25?PeerStories business_work_hours:flags2.0?BusinessWorkHours business_location:flags2.1?BusinessLocation business_greeting_message:flags2.2?BusinessGreetingMessage business_away_message:flags2.3?BusinessAwayMessage business_intro:flags2.4?BusinessIntro birthday:flags2.5?Birthday personal_channel_id:flags2.6?long personal_channel_message:flags2.6?int stargifts_count:flags2.8?int starref_program:flags2.11?StarRefProgram bot_verification:flags2.12?BotVerification send_paid_messages_stars:flags2.14?long disallowed_gifts:flags2.15?DisallowedGiftsSettings stars_rating:flags2.17?StarsRating stars_my_pending_rating:flags2.18?StarsRating stars_my_pending_rating_date:flags2.18?int main_tab:flags2.20?ProfileTab saved_music:flags2.21?Document note:flags2.22?TextWithEntities bot_manager_id:flags2.25?long = UserFull; + +contact#145ade0b user_id:long mutual:Bool = Contact; + +importedContact#c13e3c50 user_id:long client_id:long = ImportedContact; + +contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus; + +contacts.contactsNotModified#b74ba9d2 = contacts.Contacts; +contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts; + +contacts.importedContacts#77d01c3b imported:Vector popular_invites:Vector retry_contacts:Vector users:Vector = contacts.ImportedContacts; + +contacts.blocked#ade1591 blocked:Vector chats:Vector users:Vector = contacts.Blocked; +contacts.blockedSlice#e1664194 count:int blocked:Vector chats:Vector users:Vector = contacts.Blocked; + +messages.dialogs#15ba6c40 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; +messages.dialogsNotModified#f0e3e596 count:int = messages.Dialogs; + +messages.messages#1d73e7ea messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesSlice#5f206716 flags:# inexact:flags.1?true count:int next_rate:flags.0?int offset_id_offset:flags.2?int search_flood:flags.3?SearchPostsFlood messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.channelMessages#c776ba4e flags:# inexact:flags.1?true pts:int count:int offset_id_offset:flags.2?int messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; +messages.messagesNotModified#74535f21 count:int = messages.Messages; + +messages.chats#64ff9fd5 chats:Vector = messages.Chats; +messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; + +messages.chatFull#e5d7d19c full_chat:ChatFull chats:Vector users:Vector = messages.ChatFull; + +messages.affectedHistory#b45c69d1 pts:int pts_count:int offset:int = messages.AffectedHistory; + +inputMessagesFilterEmpty#57e2f66c = MessagesFilter; +inputMessagesFilterPhotos#9609a51c = MessagesFilter; +inputMessagesFilterVideo#9fc00e65 = MessagesFilter; +inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; +inputMessagesFilterDocument#9eddf188 = MessagesFilter; +inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; +inputMessagesFilterGif#ffc86587 = MessagesFilter; +inputMessagesFilterVoice#50f5c392 = MessagesFilter; +inputMessagesFilterMusic#3751b49e = MessagesFilter; +inputMessagesFilterChatPhotos#3a20ecb8 = MessagesFilter; +inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFilter; +inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; +inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; +inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; +inputMessagesFilterGeo#e7026d0d = MessagesFilter; +inputMessagesFilterContacts#e062db83 = MessagesFilter; +inputMessagesFilterPinned#1bb00451 = MessagesFilter; +inputMessagesFilterPoll#fa2bc90a = MessagesFilter; + +updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; +updateMessageID#4e90bfd6 id:int random_id:long = Update; +updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; +updateUserTyping#2a17bf5c flags:# user_id:long top_msg_id:flags.0?int action:SendMessageAction = Update; +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; +updateChatParticipants#7761198 participants:ChatParticipants = Update; +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; +updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector = Update; +updateNewAuthorization#8951abef flags:# unconfirmed:flags.0?true hash:long date:flags.0?int device:flags.0?string location:flags.0?string = Update; +updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; +updateEncryptedChatTyping#1710f156 chat_id:int = Update; +updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; +updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; +updateDcOptions#8e5e9873 dc_options:Vector = Update; +updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; +updateServiceNotification#ebe46819 flags:# popup:flags.0?true invert_media:flags.2?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; +updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; +updateUserPhone#5492a13 user_id:long phone:string = Update; +updateReadHistoryInbox#9e84bc99 flags:# folder_id:flags.0?int peer:Peer top_msg_id:flags.1?int max_id:int still_unread_count:int pts:int pts_count:int = Update; +updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; +updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; +updateReadMessagesContents#f8227181 flags:# messages:Vector pts:int pts_count:int date:flags.0?int = Update; +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; +updateChannel#635b4c09 channel_id:long = Update; +updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; +updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; +updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Update; +updateStickerSets#31c24808 flags:# masks:flags.0?true emojis:flags.1?true = Update; +updateSavedGifs#9375341e = Update; +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; +updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; +updateDraftMessage#edfc111e flags:# peer:Peer top_msg_id:flags.0?int saved_peer_id:flags.1?Peer draft:DraftMessage = Update; +updateReadFeaturedStickers#571d2742 = Update; +updateRecentStickers#9a422c20 = Update; +updateConfig#a229dd06 = Update; +updatePtsChanged#3354678f = Update; +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; +updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; +updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; +updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; +updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; +updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; +updateLangPackTooLong#46560264 lang_code:string = Update; +updateLangPack#56022f4d difference:LangPackDifference = Update; +updateFavedStickers#e511996d = Update; +updateChannelReadMessagesContents#25f324f7 flags:# channel_id:long top_msg_id:flags.0?int saved_peer_id:flags.1?Peer messages:Vector = Update; +updateContactsReset#7084a7be = Update; +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; +updateDialogUnreadMark#b658f23e flags:# unread:flags.0?true peer:DialogPeer saved_peer_id:flags.1?Peer = Update; +updateMessagePoll#d64c522b flags:# peer:flags.1?Peer msg_id:flags.1?int top_msg_id:flags.2?int poll_id:long poll:flags.0?Poll results:PollResults = Update; +updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; +updateFolderPeers#19360dc0 folder_peers:Vector pts:int pts_count:int = Update; +updatePeerSettings#6a7e7366 peer:Peer settings:PeerSettings = Update; +updatePeerLocated#b4afcfb0 peers:Vector = Update; +updateNewScheduledMessage#39a51dfb message:Message = Update; +updateDeleteScheduledMessages#f2a71983 flags:# peer:Peer messages:Vector sent_messages:flags.0?Vector = Update; +updateTheme#8216fba3 theme:Theme = Update; +updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; +updateLoginToken#564fe691 = Update; +updateMessagePollVote#7699f014 poll_id:long peer:Peer options:Vector positions:Vector qts:int = Update; +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; +updateDialogFilterOrder#a5d72105 order:Vector = Update; +updateDialogFilters#3504914f = Update; +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; +updatePeerBlocked#ebe07752 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer = Update; +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; +updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; +updateChat#f89a6a4e chat_id:long = Update; +updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; +updateGroupCall#9d2216e0 flags:# live_story:flags.2?true peer:flags.1?Peer call:GroupCall = Update; +updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateChannelParticipant#985d3abb flags:# via_chatlist:flags.3?true channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; +updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; +updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; +updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; +updateBotChatInviteRequester#7cb34d79 flags:# peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int query_id:flags.0?long = Update; +updateMessageReactions#1e297bfa flags:# peer:Peer msg_id:int top_msg_id:flags.0?int saved_peer_id:flags.1?Peer reactions:MessageReactions = Update; +updateAttachMenuBots#17b7a20b = Update; +updateWebViewResultSent#1592b79d query_id:long = Update; +updateBotMenuButton#14b85813 bot_id:long button:BotMenuButton = Update; +updateSavedRingtones#74d8be99 = Update; +updateTranscribedAudio#84cd5a flags:# pending:flags.0?true peer:Peer msg_id:int transcription_id:long text:string = Update; +updateReadFeaturedEmojiStickers#fb4c496c = Update; +updateUserEmojiStatus#28373599 user_id:long emoji_status:EmojiStatus = Update; +updateRecentEmojiStatuses#30f443db = Update; +updateRecentReactions#6f7863f4 = Update; +updateMoveStickerSetToTop#86fccf85 flags:# masks:flags.0?true emojis:flags.1?true stickerset:long = Update; +updateMessageExtendedMedia#d5a41724 peer:Peer msg_id:int extended_media:Vector = Update; +updateUser#20529438 user_id:long = Update; +updateAutoSaveSettings#ec05b097 = Update; +updateStory#75b3b798 peer:Peer story:StoryItem = Update; +updateReadStories#f74e932b peer:Peer max_id:int = Update; +updateStoryID#1bf335b9 id:int random_id:long = Update; +updateStoriesStealthMode#2c084dc1 stealth_mode:StoriesStealthMode = Update; +updateSentStoryReaction#7d627683 peer:Peer story_id:int reaction:Reaction = Update; +updateBotChatBoost#904dd49c peer:Peer boost:Boost qts:int = Update; +updateChannelViewForumAsMessages#7b68920 channel_id:long enabled:Bool = Update; +updatePeerWallpaper#ae3f101d flags:# wallpaper_overridden:flags.1?true peer:Peer wallpaper:flags.0?WallPaper = Update; +updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer old_reactions:Vector new_reactions:Vector qts:int = Update; +updateBotMessageReactions#9cb7759 peer:Peer msg_id:int date:int reactions:Vector qts:int = Update; +updateSavedDialogPinned#aeaf9e74 flags:# pinned:flags.0?true peer:DialogPeer = Update; +updatePinnedSavedDialogs#686c85a6 flags:# order:flags.0?Vector = Update; +updateSavedReactionTags#39c67432 = Update; +updateSmsJob#f16269d4 job_id:string = Update; +updateQuickReplies#f9470ab2 quick_replies:Vector = Update; +updateNewQuickReply#f53da717 quick_reply:QuickReply = Update; +updateDeleteQuickReply#53e6f1ec shortcut_id:int = Update; +updateQuickReplyMessage#3e050d0f message:Message = Update; +updateDeleteQuickReplyMessages#566fe7cd shortcut_id:int messages:Vector = Update; +updateBotBusinessConnect#8ae5c97a connection:BotBusinessConnection qts:int = Update; +updateBotNewBusinessMessage#9ddb347c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotEditBusinessMessage#7df587c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; +updateBotDeleteBusinessMessage#a02a982e connection_id:string peer:Peer messages:Vector qts:int = Update; +updateNewStoryReaction#1824e40b story_id:int peer:Peer reaction:Reaction = Update; +updateStarsBalance#4e80a379 balance:StarsAmount = Update; +updateBusinessBotCallbackQuery#1ea2fda7 flags:# query_id:long user_id:long connection_id:string message:Message reply_to_message:flags.2?Message chat_instance:long data:flags.0?bytes = Update; +updateStarsRevenueStatus#a584b019 peer:Peer status:StarsRevenueStatus = Update; +updateBotPurchasedPaidMedia#283bd312 user_id:long payload:string qts:int = Update; +updatePaidReactionPrivacy#8b725fce private:PaidReactionPrivacy = Update; +updateSentPhoneCode#504aa18f sent_code:auth.SentCode = Update; +updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector next_offset:int = Update; +updateReadMonoForumInbox#77b0e372 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateReadMonoForumOutbox#a4a79376 channel_id:long saved_peer_id:Peer read_max_id:int = Update; +updateMonoForumNoPaidException#9f812b08 flags:# exception:flags.0?true channel_id:long saved_peer_id:Peer = Update; +updateGroupCallMessage#d8326f0d call:InputGroupCall message:GroupCallMessage = Update; +updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update; +updatePinnedForumTopic#683b2c52 flags:# pinned:flags.0?true peer:Peer topic_id:int = Update; +updatePinnedForumTopics#def143d0 flags:# peer:Peer order:flags.0?Vector = Update; +updateDeleteGroupCallMessages#3e85e92c call:InputGroupCall messages:Vector = Update; +updateStarGiftAuctionState#48e246c2 gift_id:long state:StarGiftAuctionState = Update; +updateStarGiftAuctionUserState#dc58f31e gift_id:long user_state:StarGiftAuctionUserState = Update; +updateEmojiGameInfo#fb9c547a info:messages.EmojiGameInfo = Update; +updateStarGiftCraftFail#ac072444 = Update; +updateChatParticipantRank#bd8367b9 chat_id:long user_id:long rank:string version:int = Update; +updateManagedBot#4880ed9a user_id:long bot_id:long qts:int = Update; +updateBotGuestChatQuery#cdd4093d flags:# query_id:long message:Message reference_messages:flags.0?Vector qts:int = Update; +updateAiComposeTones#8c0f91fb = Update; +updateJoinChatWebViewDecision#bdac7e70 peer:Peer query_id:long result:JoinChatBotResult = Update; +updateNewBotConnection#b22083a6 flags:# confirmed:flags.0?true bot_id:long date:flags.1?int device:flags.1?string location:flags.1?string = Update; +updateWebBrowserSettings#c39a2ade flags:# open_external_browser:flags.0?true display_close_button:flags.1?true = Update; +updateWebBrowserException#140502d1 flags:# delete:flags.1?true open_external_browser:flags.0?Bool exception:WebDomainException = Update; + +updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; + +updates.differenceEmpty#5d75a138 date:int seq:int = updates.Difference; +updates.difference#f49ca0 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector state:updates.State = updates.Difference; +updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector intermediate_state:updates.State = updates.Difference; +updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; + +updatesTooLong#e317af7e = Updates; +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; +updateShort#78d4dec1 update:Update date:int = Updates; +updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; +updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; +updateShortSentMessage#9015e101 flags:# out:flags.1?true id:int pts:int pts_count:int date:int media:flags.9?MessageMedia entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +photos.photos#8dca6aa5 photos:Vector users:Vector = photos.Photos; +photos.photosSlice#15051f54 count:int photos:Vector users:Vector = photos.Photos; + +photos.photo#20212ca8 photo:Photo users:Vector = photos.Photo; + +upload.file#96a18d5 type:storage.FileType mtime:int bytes:bytes = upload.File; +upload.fileCdnRedirect#f18cda44 dc_id:int file_token:bytes encryption_key:bytes encryption_iv:bytes file_hashes:Vector = upload.File; + +dcOption#18b7a10d flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true this_port_only:flags.5?true id:int ip_address:string port:int secret:flags.10?bytes = DcOption; + +config#cc1a241e flags:# default_p2p_contacts:flags.3?true preload_featured_stickers:flags.4?true revoke_pm_inbox:flags.6?true blocked_mode:flags.8?true force_try_ipv6:flags.14?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector dc_txt_domain_name:string chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int push_chat_period_ms:int push_chat_limit:int edit_time_limit:int revoke_time_limit:int revoke_pm_time_limit:int rating_e_decay:int stickers_recent_limit:int channels_read_media_period:int tmp_sessions:flags.0?int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string autoupdate_url_prefix:flags.7?string gif_search_username:flags.9?string venue_search_username:flags.10?string img_search_username:flags.11?string static_maps_provider:flags.12?string caption_length_max:int message_length_max:int webfile_dc_id:int suggested_lang_code:flags.2?string lang_pack_version:flags.2?int base_lang_pack_version:flags.2?int reactions_default:flags.15?Reaction autologin_token:flags.16?string = Config; + +nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; + +help.appUpdate#ccbbce30 flags:# can_not_skip:flags.0?true id:int version:string text:string entities:Vector document:flags.1?Document url:flags.2?string sticker:flags.3?Document = help.AppUpdate; +help.noAppUpdate#c45a6536 = help.AppUpdate; + +help.inviteText#18cb9f78 message:string = help.InviteText; + +encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; +encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; + +inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; + +encryptedFileEmpty#c21f497e = EncryptedFile; +encryptedFile#a8008cd8 id:long access_hash:long size:long dc_id:int key_fingerprint:int = EncryptedFile; + +inputEncryptedFileEmpty#1837c364 = InputEncryptedFile; +inputEncryptedFileUploaded#64bd0306 id:long parts:int md5_checksum:string key_fingerprint:int = InputEncryptedFile; +inputEncryptedFile#5a17b5e5 id:long access_hash:long = InputEncryptedFile; +inputEncryptedFileBigUploaded#2dc173c8 id:long parts:int key_fingerprint:int = InputEncryptedFile; + +encryptedMessage#ed18c118 random_id:long chat_id:int date:int bytes:bytes file:EncryptedFile = EncryptedMessage; +encryptedMessageService#23734b06 random_id:long chat_id:int date:int bytes:bytes = EncryptedMessage; + +messages.dhConfigNotModified#c0e24635 random:bytes = messages.DhConfig; +messages.dhConfig#2c221edd g:int p:bytes version:int random:bytes = messages.DhConfig; + +messages.sentEncryptedMessage#560f8935 date:int = messages.SentEncryptedMessage; +messages.sentEncryptedFile#9493ff32 date:int file:EncryptedFile = messages.SentEncryptedMessage; + +inputDocumentEmpty#72f0eaae = InputDocument; +inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; + +documentEmpty#36f8c871 id:long = Document; +document#8fd4c4d8 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:long thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; + +help.support#17c6b5f6 phone_number:string user:User = help.Support; + +notifyPeer#9fd40bd8 peer:Peer = NotifyPeer; +notifyUsers#b4c83b4c = NotifyPeer; +notifyChats#c007cec3 = NotifyPeer; +notifyBroadcasts#d612e8ef = NotifyPeer; +notifyForumTopic#226e6308 peer:Peer top_msg_id:int = NotifyPeer; + +sendMessageTypingAction#16bf744e = SendMessageAction; +sendMessageCancelAction#fd5ec8f5 = SendMessageAction; +sendMessageRecordVideoAction#a187d66f = SendMessageAction; +sendMessageUploadVideoAction#e9763aec progress:int = SendMessageAction; +sendMessageRecordAudioAction#d52f73f7 = SendMessageAction; +sendMessageUploadAudioAction#f351d7ab progress:int = SendMessageAction; +sendMessageUploadPhotoAction#d1d34a26 progress:int = SendMessageAction; +sendMessageUploadDocumentAction#aa0cd9e4 progress:int = SendMessageAction; +sendMessageGeoLocationAction#176f8ba1 = SendMessageAction; +sendMessageChooseContactAction#628cbc6f = SendMessageAction; +sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; +sendMessageRecordRoundAction#88f27fbc = SendMessageAction; +sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; +speakingInGroupCallAction#d92c2285 = SendMessageAction; +sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; +sendMessageTextDraftAction#376d975c random_id:long text:TextWithEntities = SendMessageAction; +inputSendMessageRichMessageDraftAction#e2b23b51 random_id:long rich_message:InputRichMessage = SendMessageAction; +sendMessageRichMessageDraftAction#a2cb24f9 random_id:long rich_message:RichMessage = SendMessageAction; + +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; + +inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; +inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; +inputPrivacyKeyPhoneCall#fabadc5f = InputPrivacyKey; +inputPrivacyKeyPhoneP2P#db9e70d2 = InputPrivacyKey; +inputPrivacyKeyForwards#a4dd4c08 = InputPrivacyKey; +inputPrivacyKeyProfilePhoto#5719bacc = InputPrivacyKey; +inputPrivacyKeyPhoneNumber#352dafa = InputPrivacyKey; +inputPrivacyKeyAddedByPhone#d1219bdd = InputPrivacyKey; +inputPrivacyKeyVoiceMessages#aee69d68 = InputPrivacyKey; +inputPrivacyKeyAbout#3823cc40 = InputPrivacyKey; +inputPrivacyKeyBirthday#d65a11cc = InputPrivacyKey; +inputPrivacyKeyStarGiftsAutoSave#e1732341 = InputPrivacyKey; +inputPrivacyKeyNoPaidMessages#bdc597b4 = InputPrivacyKey; +inputPrivacyKeySavedMusic#4dbe9226 = InputPrivacyKey; + +privacyKeyStatusTimestamp#bc2eab30 = PrivacyKey; +privacyKeyChatInvite#500e6dfa = PrivacyKey; +privacyKeyPhoneCall#3d662b7b = PrivacyKey; +privacyKeyPhoneP2P#39491cc8 = PrivacyKey; +privacyKeyForwards#69ec56a3 = PrivacyKey; +privacyKeyProfilePhoto#96151fed = PrivacyKey; +privacyKeyPhoneNumber#d19ae46d = PrivacyKey; +privacyKeyAddedByPhone#42ffd42b = PrivacyKey; +privacyKeyVoiceMessages#697f414 = PrivacyKey; +privacyKeyAbout#a486b761 = PrivacyKey; +privacyKeyBirthday#2000a518 = PrivacyKey; +privacyKeyStarGiftsAutoSave#2ca4fdf8 = PrivacyKey; +privacyKeyNoPaidMessages#17d348d2 = PrivacyKey; +privacyKeySavedMusic#ff7a571b = PrivacyKey; + +inputPrivacyValueAllowContacts#d09e07b = InputPrivacyRule; +inputPrivacyValueAllowAll#184b35ce = InputPrivacyRule; +inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; +inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; +inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; +inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; +inputPrivacyValueAllowCloseFriends#2f453e49 = InputPrivacyRule; +inputPrivacyValueAllowPremium#77cdc9f1 = InputPrivacyRule; +inputPrivacyValueAllowBots#5a4fcce5 = InputPrivacyRule; +inputPrivacyValueDisallowBots#c4e57915 = InputPrivacyRule; + +privacyValueAllowContacts#fffe1bac = PrivacyRule; +privacyValueAllowAll#65427b82 = PrivacyRule; +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; +privacyValueDisallowContacts#f888fa1a = PrivacyRule; +privacyValueDisallowAll#8b73e763 = PrivacyRule; +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; +privacyValueAllowCloseFriends#f7e8d89b = PrivacyRule; +privacyValueAllowPremium#ece9814b = PrivacyRule; +privacyValueAllowBots#21461b5d = PrivacyRule; +privacyValueDisallowBots#f6a5f82f = PrivacyRule; + +account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; + +accountDaysTTL#b8d0afdf days:int = AccountDaysTTL; + +documentAttributeImageSize#6c37c15c w:int h:int = DocumentAttribute; +documentAttributeAnimated#11b58939 = DocumentAttribute; +documentAttributeSticker#6319d612 flags:# mask:flags.1?true alt:string stickerset:InputStickerSet mask_coords:flags.0?MaskCoords = DocumentAttribute; +documentAttributeVideo#43c57c48 flags:# round_message:flags.0?true supports_streaming:flags.1?true nosound:flags.3?true duration:double w:int h:int preload_prefix_size:flags.2?int video_start_ts:flags.4?double video_codec:flags.5?string = DocumentAttribute; +documentAttributeAudio#9852f9c6 flags:# voice:flags.10?true duration:int title:flags.0?string performer:flags.1?string waveform:flags.2?bytes = DocumentAttribute; +documentAttributeFilename#15590068 file_name:string = DocumentAttribute; +documentAttributeHasStickers#9801d2f7 = DocumentAttribute; +documentAttributeCustomEmoji#fd149899 flags:# free:flags.0?true text_color:flags.1?true alt:string stickerset:InputStickerSet = DocumentAttribute; + +messages.stickersNotModified#f1749a22 = messages.Stickers; +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; + +stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; + +messages.allStickersNotModified#e86602c3 = messages.AllStickers; +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; + +messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; + +webPageEmpty#211a1788 flags:# id:long url:flags.0?string = WebPage; +webPagePending#b0d13e47 flags:# id:long url:flags.0?string date:int = WebPage; +webPage#e89c45b2 flags:# has_large_media:flags.13?true video_cover_photo:flags.14?true id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; + +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true unconfirmed:flags.5?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; + +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; + +account.password#957b50fb flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int login_email_pattern:flags.6?string = account.Password; + +account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; + +account.passwordInputSettings#c23727c9 flags:# new_algo:flags.0?PasswordKdfAlgo new_password_hash:flags.0?bytes hint:flags.0?string email:flags.1?string new_secure_settings:flags.2?SecureSecretSettings = account.PasswordInputSettings; + +auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; + +receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; + +chatInviteExported#a22cbd96 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int subscription_expired:flags.10?int title:flags.8?string subscription_pricing:flags.9?StarsSubscriptionPricing = ExportedChatInvite; +chatInvitePublicJoinRequests#ed107ab7 = ExportedChatInvite; + +chatInviteAlready#5a686d7c chat:Chat = ChatInvite; +chatInvite#5c9d3702 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true verified:flags.7?true scam:flags.8?true fake:flags.9?true can_refulfill_subscription:flags.11?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector color:int subscription_pricing:flags.10?StarsSubscriptionPricing subscription_form_id:flags.12?long bot_verification:flags.13?BotVerification = ChatInvite; +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; + +inputStickerSetEmpty#ffb62b95 = InputStickerSet; +inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; +inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; +inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; +inputStickerSetPremiumGifts#c88b3b02 = InputStickerSet; +inputStickerSetEmojiGenericAnimations#4c4d4ce = InputStickerSet; +inputStickerSetEmojiDefaultStatuses#29d0f5ee = InputStickerSet; +inputStickerSetEmojiDefaultTopicIcons#44c1f8e9 = InputStickerSet; +inputStickerSetEmojiChannelDefaultStatuses#49748553 = InputStickerSet; +inputStickerSetTonGifts#1cf671a0 = InputStickerSet; + +stickerSet#2dd14edc flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true emojis:flags.7?true text_color:flags.9?true channel_emoji_status:flags.10?true creator:flags.11?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int thumb_document_id:flags.8?long count:int hash:int = StickerSet; + +messages.stickerSet#6e153f16 set:StickerSet packs:Vector keywords:Vector documents:Vector = messages.StickerSet; +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; + +botCommand#c27ac8c7 command:string description:string = BotCommand; + +botInfo#4d8a0299 flags:# has_preview_medias:flags.6?true user_id:flags.0?long description:flags.1?string description_photo:flags.4?Photo description_document:flags.5?Document commands:flags.2?Vector menu_button:flags.3?BotMenuButton privacy_policy_url:flags.7?string app_settings:flags.8?BotAppSettings verifier_settings:flags.9?BotVerifierSettings = BotInfo; + +keyboardButton#7d170cff flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrl#d80c25ec flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonCallback#e62bc960 flags:# requires_password:flags.0?true style:flags.10?KeyboardButtonStyle text:string data:bytes = KeyboardButton; +keyboardButtonRequestPhone#417efd8f flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonRequestGeoLocation#aa40f94d flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonSwitchInline#991399fc flags:# same_peer:flags.0?true style:flags.10?KeyboardButtonStyle text:string query:string peer_types:flags.1?Vector = KeyboardButton; +keyboardButtonGame#89c590f9 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonBuy#3fa53905 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; +keyboardButtonUrlAuth#f51006f9 flags:# style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +inputKeyboardButtonUrlAuth#68013e72 flags:# request_write_access:flags.0?true style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +keyboardButtonRequestPoll#7a11d782 flags:# style:flags.10?KeyboardButtonStyle quiz:flags.0?Bool text:string = KeyboardButton; +inputKeyboardButtonUserProfile#7d5e07c7 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:InputUser = KeyboardButton; +keyboardButtonUserProfile#c0fd5d09 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:long = KeyboardButton; +keyboardButtonWebView#e846b1a0 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonSimpleWebView#e15c4370 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; +keyboardButtonRequestPeer#5b0f15f5 flags:# style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +inputKeyboardButtonRequestPeer#2b78156 flags:# name_requested:flags.0?true username_requested:flags.1?true photo_requested:flags.2?true style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; +keyboardButtonCopy#bcc4af10 flags:# style:flags.10?KeyboardButtonStyle text:string copy_text:string = KeyboardButton; + +keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; + +replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true persistent:flags.4?true rows:Vector placeholder:flags.3?string = ReplyMarkup; +replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; + +messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; +messageEntityMention#fa04579d offset:int length:int = MessageEntity; +messageEntityHashtag#6f635b0d offset:int length:int = MessageEntity; +messageEntityBotCommand#6cef8ac7 offset:int length:int = MessageEntity; +messageEntityUrl#6ed02538 offset:int length:int = MessageEntity; +messageEntityEmail#64e475c2 offset:int length:int = MessageEntity; +messageEntityBold#bd610bc9 offset:int length:int = MessageEntity; +messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; +messageEntityCode#28a20571 offset:int length:int = MessageEntity; +messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; +messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; +inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; +messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; +messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; +messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; +messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; +messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; +messageEntityCustomEmoji#c8cf05f8 offset:int length:int document_id:long = MessageEntity; +messageEntityBlockquote#f1ccaaac flags:# collapsed:flags.0?true offset:int length:int = MessageEntity; +messageEntityFormattedDate#904ac7c7 flags:# relative:flags.0?true short_time:flags.1?true long_time:flags.2?true short_date:flags.3?true long_date:flags.4?true day_of_week:flags.5?true offset:int length:int date:int = MessageEntity; +messageEntityDiffInsert#71777116 offset:int length:int = MessageEntity; +messageEntityDiffReplace#c6c1e5a7 offset:int length:int old_text:string = MessageEntity; +messageEntityDiffDelete#652c1c5 offset:int length:int = MessageEntity; + +inputChannelEmpty#ee8c1e86 = InputChannel; +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; + +contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; + +messageRange#ae30253 min_id:int max_id:int = MessageRange; + +updates.channelDifferenceEmpty#3e11affb flags:# final:flags.0?true pts:int timeout:flags.1?int = updates.ChannelDifference; +updates.channelDifferenceTooLong#a4bcc6fe flags:# final:flags.0?true timeout:flags.1?int dialog:Dialog messages:Vector chats:Vector users:Vector = updates.ChannelDifference; +updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int new_messages:Vector other_updates:Vector chats:Vector users:Vector = updates.ChannelDifference; + +channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; +channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; + +channelParticipant#1bd54456 flags:# user_id:long date:int subscription_until_date:flags.0?int rank:flags.2?string = ChannelParticipant; +channelParticipantSelf#a9478a1a flags:# via_request:flags.0?true user_id:long inviter_id:long date:int subscription_until_date:flags.1?int rank:flags.2?string = ChannelParticipant; +channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; +channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; +channelParticipantBanned#d5f0ad91 flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights rank:flags.2?string = ChannelParticipant; +channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; + +channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; +channelParticipantsAdmins#b4608969 = ChannelParticipantsFilter; +channelParticipantsKicked#a3b54985 q:string = ChannelParticipantsFilter; +channelParticipantsBots#b0d1865b = ChannelParticipantsFilter; +channelParticipantsBanned#1427a5e1 q:string = ChannelParticipantsFilter; +channelParticipantsSearch#656ac4b q:string = ChannelParticipantsFilter; +channelParticipantsContacts#bb6ae88d q:string = ChannelParticipantsFilter; +channelParticipantsMentions#e04b5ceb flags:# q:flags.0?string top_msg_id:flags.1?int = ChannelParticipantsFilter; + +channels.channelParticipants#9ab0feaf count:int participants:Vector chats:Vector users:Vector = channels.ChannelParticipants; +channels.channelParticipantsNotModified#f0173fe9 = channels.ChannelParticipants; + +channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector users:Vector = channels.ChannelParticipant; + +help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; + +messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; + +inputBotInlineMessageMediaAuto#3380c786 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaGeo#96929a85 flags:# geo_point:InputGeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaVenue#417bbf11 flags:# geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaContact#a6edbffd flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageGame#4b425864 flags:# reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaInvoice#d7e78225 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageMediaWebPage#bddcc510 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true optional:flags.6?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; +inputBotInlineMessageRichMessage#b43df56c flags:# reply_markup:flags.2?ReplyMarkup rich_message:InputRichMessage = InputBotInlineMessage; + +inputBotInlineResult#88bf9319 flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?InputWebDocument content:flags.5?InputWebDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultPhoto#a8d864a7 id:string type:string photo:InputPhoto send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultDocument#fff8fdc4 flags:# id:string type:string title:flags.1?string description:flags.2?string document:InputDocument send_message:InputBotInlineMessage = InputBotInlineResult; +inputBotInlineResultGame#4fa417f2 id:string short_name:string send_message:InputBotInlineMessage = InputBotInlineResult; + +botInlineMessageMediaAuto#764cf810 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageText#8c7f65e2 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaGeo#51846fd flags:# geo:GeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaVenue#8a86659c flags:# geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaContact#18d1cdc2 flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaInvoice#354a9b09 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument currency:string total_amount:long reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageMediaWebPage#809ad9a6 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true manual:flags.7?true safe:flags.8?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; +botInlineMessageRichMessage#a617e7b flags:# reply_markup:flags.2?ReplyMarkup rich_message:RichMessage = BotInlineMessage; + +botInlineResult#11965f3a flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?WebDocument content:flags.5?WebDocument send_message:BotInlineMessage = BotInlineResult; +botInlineMediaResult#17db940b flags:# id:string type:string photo:flags.0?Photo document:flags.1?Document title:flags.2?string description:flags.3?string send_message:BotInlineMessage = BotInlineResult; + +messages.botResults#e021f2f6 flags:# gallery:flags.0?true query_id:long next_offset:flags.1?string switch_pm:flags.2?InlineBotSwitchPM switch_webview:flags.3?InlineBotWebView results:Vector cache_time:int users:Vector = messages.BotResults; + +exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; + +messageFwdHeader#4e4df4bb flags:# imported:flags.7?true saved_out:flags.11?true from_id:flags.0?Peer from_name:flags.5?string date:int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int saved_from_id:flags.8?Peer saved_from_name:flags.9?string saved_date:flags.10?int psa_type:flags.6?string = MessageFwdHeader; + +auth.codeTypeSms#72a3158c = auth.CodeType; +auth.codeTypeCall#741cd3e3 = auth.CodeType; +auth.codeTypeFlashCall#226ccefb = auth.CodeType; +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; + +auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; +auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; +auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; +auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; +auth.sentCodeTypeEmailCode#f450f59b flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int reset_available_period:flags.3?int reset_pending_date:flags.4?int = auth.SentCodeType; +auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; +auth.sentCodeTypeFirebaseSms#9fd736 flags:# nonce:flags.0?bytes play_integrity_project_id:flags.2?long play_integrity_nonce:flags.2?bytes receipt:flags.1?string push_timeout:flags.1?int length:int = auth.SentCodeType; +auth.sentCodeTypeSmsWord#a416ac81 flags:# beginning:flags.0?string = auth.SentCodeType; +auth.sentCodeTypeSmsPhrase#b37794af flags:# beginning:flags.0?string = auth.SentCodeType; + +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; + +messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; + +inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; + +inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; + +messages.peerDialogs#3371c354 dialogs:Vector messages:Vector chats:Vector users:Vector state:updates.State = messages.PeerDialogs; + +topPeer#edcdc05b peer:Peer rating:double = TopPeer; + +topPeerCategoryBotsPM#ab661b5b = TopPeerCategory; +topPeerCategoryBotsInline#148677e2 = TopPeerCategory; +topPeerCategoryCorrespondents#637b7ed = TopPeerCategory; +topPeerCategoryGroups#bd17a14a = TopPeerCategory; +topPeerCategoryChannels#161d9628 = TopPeerCategory; +topPeerCategoryPhoneCalls#1e76a78c = TopPeerCategory; +topPeerCategoryForwardUsers#a8406ca9 = TopPeerCategory; +topPeerCategoryForwardChats#fbeec0f0 = TopPeerCategory; +topPeerCategoryBotsApp#fd9e7bec = TopPeerCategory; +topPeerCategoryBotsGuestChat#6c24f3dd = TopPeerCategory; + +topPeerCategoryPeers#fb834291 category:TopPeerCategory count:int peers:Vector = TopPeerCategoryPeers; + +contacts.topPeersNotModified#de266ef5 = contacts.TopPeers; +contacts.topPeers#70b772a8 categories:Vector chats:Vector users:Vector = contacts.TopPeers; +contacts.topPeersDisabled#b52c939d = contacts.TopPeers; + +draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; +draftMessage#60fe3294 flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo message:string entities:flags.3?Vector media:flags.5?InputMedia date:int effect:flags.7?long suggested_post:flags.8?SuggestedPost rich_message:flags.9?RichMessage = DraftMessage; + +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; +messages.featuredStickers#be382906 flags:# premium:flags.0?true hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; + +messages.recentStickersNotModified#b17f890 = messages.RecentStickers; +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; + +messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; + +messages.stickerSetInstallResultSuccess#38641628 = messages.StickerSetInstallResult; +messages.stickerSetInstallResultArchive#35e410a8 sets:Vector = messages.StickerSetInstallResult; + +stickerSetCovered#6410a5d2 set:StickerSet cover:Document = StickerSetCovered; +stickerSetMultiCovered#3407e51b set:StickerSet covers:Vector = StickerSetCovered; +stickerSetFullCovered#40d13c0e set:StickerSet packs:Vector keywords:Vector documents:Vector = StickerSetCovered; +stickerSetNoCovered#77b15d1c set:StickerSet = StickerSetCovered; + +maskCoords#aed6dbb2 n:int x:double y:double zoom:double = MaskCoords; + +inputStickeredMediaPhoto#4a992157 id:InputPhoto = InputStickeredMedia; +inputStickeredMediaDocument#438865b id:InputDocument = InputStickeredMedia; + +game#bdf9653b flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document = Game; + +inputGameID#32c3e77 id:long access_hash:long = InputGame; +inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; + +highScore#73a379eb pos:int user_id:long score:int = HighScore; + +messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; + +textEmpty#dc3d824f = RichText; +textPlain#744694e0 text:string = RichText; +textBold#6724abc4 text:RichText = RichText; +textItalic#d912a59c text:RichText = RichText; +textUnderline#c12622c4 text:RichText = RichText; +textStrike#9bf8bb95 text:RichText = RichText; +textFixed#6c3f19b9 text:RichText = RichText; +textUrl#3c2884c1 text:RichText url:string webpage_id:long = RichText; +textEmail#de5a0dd6 text:RichText email:string = RichText; +textConcat#7e6260d7 texts:Vector = RichText; +textSubscript#ed6a8504 text:RichText = RichText; +textSuperscript#c7fb5e01 text:RichText = RichText; +textMarked#34b8621 text:RichText = RichText; +textPhone#1ccb966a text:RichText phone:string = RichText; +textImage#81ccf4f document_id:long w:int h:int = RichText; +textAnchor#35553762 text:RichText name:string = RichText; +textMath#9d2eac97 source:string = RichText; +textCustomEmoji#a26156c0 document_id:long alt:string = RichText; +textSpoiler#4c2a5d62 text:RichText = RichText; +textMention#cd24cf44 text:RichText = RichText; +textHashtag#519524ea text:RichText = RichText; +textBotCommand#2ff29d3 text:RichText = RichText; +textCashtag#7b9e1801 text:RichText = RichText; +textAutoUrl#ac6a83aa text:RichText = RichText; +textAutoEmail#c556a45d text:RichText = RichText; +textAutoPhone#24c26789 text:RichText = RichText; +textBankCard#b956812d text:RichText = RichText; +textMentionName#1a9fbfc text:RichText user_id:long = RichText; +textDate#a5b45e2b flags:# relative:flags.0?true short_time:flags.1?true long_time:flags.2?true short_date:flags.3?true long_date:flags.4?true day_of_week:flags.5?true text:RichText date:int = RichText; + +pageBlockUnsupported#13567e8a = PageBlock; +pageBlockTitle#70abc3fd text:RichText = PageBlock; +pageBlockSubtitle#8ffa9a1f text:RichText = PageBlock; +pageBlockAuthorDate#baafe5e0 author:RichText published_date:int = PageBlock; +pageBlockHeader#bfd064ec text:RichText = PageBlock; +pageBlockSubheader#f12bb6e1 text:RichText = PageBlock; +pageBlockParagraph#467a0766 text:RichText = PageBlock; +pageBlockPreformatted#c070d93e text:RichText language:string = PageBlock; +pageBlockFooter#48870999 text:RichText = PageBlock; +pageBlockDivider#db20b188 = PageBlock; +pageBlockAnchor#ce0d37b0 name:string = PageBlock; +pageBlockList#e4e88011 items:Vector = PageBlock; +pageBlockBlockquote#263d7c26 text:RichText caption:RichText = PageBlock; +pageBlockPullquote#4f4456d3 text:RichText caption:RichText = PageBlock; +pageBlockPhoto#1759c560 flags:# spoiler:flags.1?true photo_id:long caption:PageCaption url:flags.0?string webpage_id:flags.0?long = PageBlock; +pageBlockVideo#7c8fe7b6 flags:# autoplay:flags.0?true loop:flags.1?true spoiler:flags.2?true video_id:long caption:PageCaption = PageBlock; +pageBlockCover#39f23300 cover:PageBlock = PageBlock; +pageBlockEmbed#a8718dc5 flags:# full_width:flags.0?true allow_scrolling:flags.3?true url:flags.1?string html:flags.2?string poster_photo_id:flags.4?long w:flags.5?int h:flags.5?int caption:PageCaption = PageBlock; +pageBlockEmbedPost#f259a80b url:string webpage_id:long author_photo_id:long author:string date:int blocks:Vector caption:PageCaption = PageBlock; +pageBlockCollage#65a0fa4d items:Vector caption:PageCaption = PageBlock; +pageBlockSlideshow#31f9590 items:Vector caption:PageCaption = PageBlock; +pageBlockChannel#ef1751b5 channel:Chat = PageBlock; +pageBlockAudio#804361ea audio_id:long caption:PageCaption = PageBlock; +pageBlockKicker#1e148390 text:RichText = PageBlock; +pageBlockTable#bf4dea82 flags:# bordered:flags.0?true striped:flags.1?true title:RichText rows:Vector = PageBlock; +pageBlockOrderedList#1fd6f6c1 flags:# reversed:flags.2?true items:Vector start:flags.0?int type:flags.1?string = PageBlock; +pageBlockDetails#76768bed flags:# open:flags.0?true blocks:Vector title:RichText = PageBlock; +pageBlockRelatedArticles#16115a96 title:RichText articles:Vector = PageBlock; +pageBlockMap#a44f3ef6 geo:GeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; +pageBlockHeading1#baff072f text:RichText = PageBlock; +pageBlockHeading2#96b2aec text:RichText = PageBlock; +pageBlockHeading3#67e731ad text:RichText = PageBlock; +pageBlockHeading4#b532772b text:RichText = PageBlock; +pageBlockHeading5#dbbe6c6a text:RichText = PageBlock; +pageBlockHeading6#682a41a9 text:RichText = PageBlock; +pageBlockMath#59080c20 source:string = PageBlock; +pageBlockThinking#3c29a3e2 text:RichText = PageBlock; +inputPageBlockMap#574b617f geo:InputGeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; +pageBlockBlockquoteBlocks#e6e47c4 blocks:Vector caption:RichText = PageBlock; + +phoneCallDiscardReasonMissed#85e42301 = PhoneCallDiscardReason; +phoneCallDiscardReasonDisconnect#e095c1a0 = PhoneCallDiscardReason; +phoneCallDiscardReasonHangup#57adc690 = PhoneCallDiscardReason; +phoneCallDiscardReasonBusy#faf7e8c9 = PhoneCallDiscardReason; +phoneCallDiscardReasonMigrateConferenceCall#9fbbf1f7 slug:string = PhoneCallDiscardReason; + +dataJSON#7d748d04 data:string = DataJSON; + +labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; + +invoice#49ee584 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true recurring:flags.9?true currency:string prices:Vector max_tip_amount:flags.8?long suggested_tip_amounts:flags.8?Vector terms_url:flags.10?string subscription_period:flags.11?int = Invoice; + +paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; + +postAddress#1e8caaeb street_line1:string street_line2:string city:string state:string country_iso2:string post_code:string = PostAddress; + +paymentRequestedInfo#909c3f94 flags:# name:flags.0?string phone:flags.1?string email:flags.2?string shipping_address:flags.3?PostAddress = PaymentRequestedInfo; + +paymentSavedCredentialsCard#cdc27a1f id:string title:string = PaymentSavedCredentials; + +webDocument#1c570ed1 url:string access_hash:long size:int mime_type:string attributes:Vector = WebDocument; +webDocumentNoProxy#f9c8bcc6 url:string size:int mime_type:string attributes:Vector = WebDocument; + +inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector = InputWebDocument; + +inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation; +inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation; +inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# small:flags.2?true document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation; + +upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; + +payments.paymentForm#a0058751 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON additional_methods:flags.6?Vector saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?Vector users:Vector = payments.PaymentForm; +payments.paymentFormStars#7bf6b15c flags:# form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice users:Vector = payments.PaymentForm; +payments.paymentFormStarGift#b425cfe1 form_id:long invoice:Invoice = payments.PaymentForm; + +payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; + +payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; +payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; + +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; +payments.paymentReceiptStars#dabbf83a flags:# date:int bot_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice currency:string total_amount:long transaction_id:string users:Vector = payments.PaymentReceipt; + +payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; + +inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; +inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; +inputPaymentCredentialsGooglePay#8ac32801 payment_token:DataJSON = InputPaymentCredentials; + +account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; + +shippingOption#b6213cdf id:string title:string prices:Vector = ShippingOption; + +inputStickerSetItem#32da9e9c flags:# document:InputDocument emoji:string mask_coords:flags.0?MaskCoords keywords:flags.1?string = InputStickerSetItem; + +inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; + +phoneCallEmpty#5366c915 id:long = PhoneCall; +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; +phoneCall#30535af5 flags:# p2p_allowed:flags.5?true video:flags.6?true conference_supported:flags.8?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int custom_parameters:flags.7?DataJSON = PhoneCall; +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; + +phoneConnection#9cc123c7 flags:# tcp:flags.0?true id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; + +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; + +phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; + +upload.cdnFileReuploadNeeded#eea8e46e request_token:bytes = upload.CdnFile; +upload.cdnFile#a99fca4f bytes:bytes = upload.CdnFile; + +cdnPublicKey#c982eaba dc_id:int public_key:string = CdnPublicKey; + +cdnConfig#5725e40a public_keys:Vector = CdnConfig; + +langPackString#cad181f6 key:string value:string = LangPackString; +langPackStringPluralized#6c47ac9f flags:# key:string zero_value:flags.0?string one_value:flags.1?string two_value:flags.2?string few_value:flags.3?string many_value:flags.4?string other_value:string = LangPackString; +langPackStringDeleted#2979eeb2 key:string = LangPackString; + +langPackDifference#f385c1f6 lang_code:string from_version:int version:int strings:Vector = LangPackDifference; + +langPackLanguage#eeca5ce3 flags:# official:flags.0?true rtl:flags.2?true beta:flags.3?true name:string native_name:string lang_code:string base_lang_code:flags.1?string plural_code:string strings_count:int translated_count:int translations_url:string = LangPackLanguage; + +channelAdminLogEventActionChangeTitle#e6dfb825 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAbout#55188a2e prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsername#6a4afc38 prev_value:string new_value:string = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePhoto#434bd2af prev_photo:Photo new_photo:Photo = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleInvites#1b7907ae new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatures#26ae0971 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionUpdatePinned#e9e82c18 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionEditMessage#709b2405 prev_message:Message new_message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteMessage#42e047bb message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoin#183040d3 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantLeave#f89777f2 = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantInvite#e31c34d8 participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleBan#e6d83d7e prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantToggleAdmin#d5676710 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; +channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionDiscardGroupCall#db9f9140 call:InputGroupCall = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantMute#f92424d2 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantUnmute#e64429c0 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleGroupCallSetting#56d6a247 join_muted:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByInvite#fe9fc158 flags:# via_chatlist:flags.0?true invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteDelete#5a50fca4 invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionChangePeerColor#5796e780 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeProfilePeerColor#5e477b25 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeWallpaper#31bb5d52 prev_value:WallPaper new_value:WallPaper = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStatus#3ea9feb1 prev_value:EmojiStatus new_value:EmojiStatus = ChannelAdminLogEventAction; +channelAdminLogEventActionChangeEmojiStickerSet#46d840ab prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleSignatureProfiles#60a79c79 new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantSubExtend#64642db3 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; +channelAdminLogEventActionToggleAutotranslation#c517f77e new_value:Bool = ChannelAdminLogEventAction; +channelAdminLogEventActionParticipantEditRank#5806b4ec user_id:long prev_rank:string new_rank:string = ChannelAdminLogEventAction; + +channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; + +channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; + +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true sub_extend:flags.18?true edit_rank:flags.19?true = ChannelAdminLogEventsFilter; + +popularContact#5ce14175 client_id:long importers:int = PopularContact; + +messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; + +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; +recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; + +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; + +account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; + +inputMessageID#a676a322 id:int = InputMessage; +inputMessageReplyTo#bad88395 id:int = InputMessage; +inputMessagePinned#86872538 = InputMessage; +inputMessageCallbackQuery#acfa1a7e id:int query_id:long = InputMessage; + +inputDialogPeer#fcaafeb7 peer:InputPeer = InputDialogPeer; +inputDialogPeerFolder#64600527 folder_id:int = InputDialogPeer; + +dialogPeer#e56dbf05 peer:Peer = DialogPeer; +dialogPeerFolder#514519e2 folder_id:int = DialogPeer; + +messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; + +fileHash#f39b035c offset:long limit:int hash:bytes = FileHash; + +inputClientProxy#75588b3f address:string port:int = InputClientProxy; + +help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; +help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; + +inputSecureFileUploaded#3334b0f0 id:long parts:int md5_checksum:string file_hash:bytes secret:bytes = InputSecureFile; +inputSecureFile#5367e5be id:long access_hash:long = InputSecureFile; + +secureFileEmpty#64199744 = SecureFile; +secureFile#7d09c27e id:long access_hash:long size:long dc_id:int date:int file_hash:bytes secret:bytes = SecureFile; + +secureData#8aeabec3 data:bytes data_hash:bytes secret:bytes = SecureData; + +securePlainPhone#7d6099dd phone:string = SecurePlainData; +securePlainEmail#21ec5a5f email:string = SecurePlainData; + +secureValueTypePersonalDetails#9d2a81e3 = SecureValueType; +secureValueTypePassport#3dac6a00 = SecureValueType; +secureValueTypeDriverLicense#6e425c4 = SecureValueType; +secureValueTypeIdentityCard#a0d0744b = SecureValueType; +secureValueTypeInternalPassport#99a48f23 = SecureValueType; +secureValueTypeAddress#cbe31e26 = SecureValueType; +secureValueTypeUtilityBill#fc36954e = SecureValueType; +secureValueTypeBankStatement#89137c0d = SecureValueType; +secureValueTypeRentalAgreement#8b883488 = SecureValueType; +secureValueTypePassportRegistration#99e3806a = SecureValueType; +secureValueTypeTemporaryRegistration#ea02ec33 = SecureValueType; +secureValueTypePhone#b320aadb = SecureValueType; +secureValueTypeEmail#8e3ca7ee = SecureValueType; + +secureValue#187fa0ca flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?SecureFile reverse_side:flags.2?SecureFile selfie:flags.3?SecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData hash:bytes = SecureValue; + +inputSecureValue#db21d0a7 flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?InputSecureFile reverse_side:flags.2?InputSecureFile selfie:flags.3?InputSecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData = InputSecureValue; + +secureValueHash#ed1ecdb0 type:SecureValueType hash:bytes = SecureValueHash; + +secureValueErrorData#e8a40bd9 type:SecureValueType data_hash:bytes field:string text:string = SecureValueError; +secureValueErrorFrontSide#be3dfa type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorReverseSide#868a2aa5 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorSelfie#e537ced6 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFile#7a700873 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorFiles#666220e9 type:SecureValueType file_hash:Vector text:string = SecureValueError; +secureValueError#869d758f type:SecureValueType hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFile#a1144770 type:SecureValueType file_hash:bytes text:string = SecureValueError; +secureValueErrorTranslationFiles#34636dd8 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureCredentialsEncrypted#33f0ea47 data:bytes hash:bytes secret:bytes = SecureCredentialsEncrypted; + +account.authorizationForm#ad2e1cd8 flags:# required_types:Vector values:Vector errors:Vector users:Vector privacy_policy_url:flags.0?string = account.AuthorizationForm; + +account.sentEmailCode#811f854f email_pattern:string length:int = account.SentEmailCode; + +help.deepLinkInfoEmpty#66afa166 = help.DeepLinkInfo; +help.deepLinkInfo#6a4ee832 flags:# update_app:flags.0?true message:string entities:flags.1?Vector = help.DeepLinkInfo; + +savedPhoneContact#1142bd56 phone:string first_name:string last_name:string date:int = SavedContact; + +account.takeout#4dba4501 id:long = account.Takeout; + +passwordKdfAlgoUnknown#d45ab096 = PasswordKdfAlgo; +passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow#3a912d4a salt1:bytes salt2:bytes g:int p:bytes = PasswordKdfAlgo; + +securePasswordKdfAlgoUnknown#4a8537 = SecurePasswordKdfAlgo; +securePasswordKdfAlgoPBKDF2HMACSHA512iter100000#bbf2dda0 salt:bytes = SecurePasswordKdfAlgo; +securePasswordKdfAlgoSHA512#86471d92 salt:bytes = SecurePasswordKdfAlgo; + +secureSecretSettings#1527bcac secure_algo:SecurePasswordKdfAlgo secure_secret:bytes secure_secret_id:long = SecureSecretSettings; + +inputCheckPasswordEmpty#9880f658 = InputCheckPasswordSRP; +inputCheckPasswordSRP#d27ff082 srp_id:long A:bytes M1:bytes = InputCheckPasswordSRP; + +secureRequiredType#829d99da flags:# native_names:flags.0?true selfie_required:flags.1?true translation_required:flags.2?true type:SecureValueType = SecureRequiredType; +secureRequiredTypeOneOf#27477b4 types:Vector = SecureRequiredType; + +help.passportConfigNotModified#bfb9f457 = help.PassportConfig; +help.passportConfig#a098d6af hash:int countries_langs:DataJSON = help.PassportConfig; + +inputAppEvent#1d1b1245 time:double type:string peer:long data:JSONValue = InputAppEvent; + +jsonObjectValue#c0de1bd9 key:string value:JSONValue = JSONObjectValue; + +jsonNull#3f6d7b68 = JSONValue; +jsonBool#c7345e6a value:Bool = JSONValue; +jsonNumber#2be0dfa4 value:double = JSONValue; +jsonString#b71e767a value:string = JSONValue; +jsonArray#f7444763 value:Vector = JSONValue; +jsonObject#99c1d49d value:Vector = JSONValue; + +pageTableCell#34566b6a flags:# header:flags.0?true align_center:flags.3?true align_right:flags.4?true valign_middle:flags.5?true valign_bottom:flags.6?true text:flags.7?RichText colspan:flags.1?int rowspan:flags.2?int = PageTableCell; + +pageTableRow#e0c0c5e5 cells:Vector = PageTableRow; + +pageCaption#6f747657 text:RichText credit:RichText = PageCaption; + +pageListItemText#2f58683c flags:# checkbox:flags.0?true checked:flags.1?true text:RichText = PageListItem; +pageListItemBlocks#63ca67aa flags:# checkbox:flags.0?true checked:flags.1?true blocks:Vector = PageListItem; + +pageListOrderedItemText#15031189 flags:# checkbox:flags.0?true checked:flags.1?true num:flags.2?string text:RichText value:flags.3?int type:flags.4?string = PageListOrderedItem; +pageListOrderedItemBlocks#8ff2d5f0 flags:# checkbox:flags.0?true checked:flags.1?true num:flags.2?string blocks:Vector value:flags.3?int type:flags.4?string = PageListOrderedItem; + +pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; + +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; + +help.supportName#8c05f1c9 name:string = help.SupportName; + +help.userInfoEmpty#f3ae2eed = help.UserInfo; +help.userInfo#1eb3758 message:string entities:Vector author:string date:int = help.UserInfo; + +pollAnswer#4b7d786a flags:# text:TextWithEntities option:bytes media:flags.0?MessageMedia added_by:flags.1?Peer date:flags.1?int = PollAnswer; +inputPollAnswer#199fed96 flags:# text:TextWithEntities media:flags.0?InputMedia = PollAnswer; + +poll#966e2dbf id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true open_answers:flags.6?true revoting_disabled:flags.7?true shuffle_answers:flags.8?true hide_results_until_close:flags.9?true creator:flags.10?true subscribers_only:flags.11?true question:TextWithEntities answers:Vector close_period:flags.4?int close_date:flags.5?int countries_iso2:flags.12?Vector hash:long = Poll; + +pollAnswerVoters#3645230a flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:flags.2?int recent_voters:flags.2?Vector = PollAnswerVoters; + +pollResults#ba7bb15e flags:# min:flags.0?true has_unread_votes:flags.6?true can_view_stats:flags.7?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector solution_media:flags.5?MessageMedia = PollResults; + +chatOnlines#f041e250 onlines:int = ChatOnlines; + +statsURL#47a971e0 url:string = StatsURL; + +chatAdminRights#5fb224d5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true pin_messages:flags.7?true add_admins:flags.9?true anonymous:flags.10?true manage_call:flags.11?true other:flags.12?true manage_topics:flags.13?true post_stories:flags.14?true edit_stories:flags.15?true delete_stories:flags.16?true manage_direct_messages:flags.17?true manage_ranks:flags.18?true = ChatAdminRights; + +chatBannedRights#9f120418 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true send_polls:flags.8?true change_info:flags.10?true invite_users:flags.15?true pin_messages:flags.17?true manage_topics:flags.18?true send_photos:flags.19?true send_videos:flags.20?true send_roundvideos:flags.21?true send_audios:flags.22?true send_voices:flags.23?true send_docs:flags.24?true send_plain:flags.25?true edit_rank:flags.26?true send_reactions:flags.27?true until_date:int = ChatBannedRights; + +inputWallPaper#e630b979 id:long access_hash:long = InputWallPaper; +inputWallPaperSlug#72091c80 slug:string = InputWallPaper; +inputWallPaperNoFile#967a462e id:long = InputWallPaper; + +account.wallPapersNotModified#1c199183 = account.WallPapers; +account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; + +codeSettings#ad253d78 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true allow_firebase:flags.7?true unknown_number:flags.9?true logout_tokens:flags.6?Vector token:flags.8?string app_sandbox:flags.8?Bool = CodeSettings; + +wallPaperSettings#372efcd0 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int emoticon:flags.7?string = WallPaperSettings; + +autoDownloadSettings#baa57628 flags:# disabled:flags.0?true video_preload_large:flags.1?true audio_preload_next:flags.2?true phonecalls_less_data:flags.3?true stories_preload:flags.4?true photo_size_max:int video_size_max:long file_size_max:long video_upload_maxbitrate:int small_queue_active_operations_max:int large_queue_active_operations_max:int = AutoDownloadSettings; + +account.autoDownloadSettings#63cacf26 low:AutoDownloadSettings medium:AutoDownloadSettings high:AutoDownloadSettings = account.AutoDownloadSettings; + +emojiKeyword#d5b3b9f9 keyword:string emoticons:Vector = EmojiKeyword; +emojiKeywordDeleted#236df622 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordsDifference#5cc761bd lang_code:string from_version:int version:int keywords:Vector = EmojiKeywordsDifference; + +emojiURL#a575739d url:string = EmojiURL; + +emojiLanguage#b3fb5361 lang_code:string = EmojiLanguage; + +folder#ff544e65 flags:# autofill_new_broadcasts:flags.0?true autofill_public_groups:flags.1?true autofill_new_correspondents:flags.2?true id:int title:string photo:flags.3?ChatPhoto = Folder; + +inputFolderPeer#fbd2c296 peer:InputPeer folder_id:int = InputFolderPeer; + +folderPeer#e9baa668 peer:Peer folder_id:int = FolderPeer; + +messages.searchCounter#e844ebff flags:# inexact:flags.1?true filter:MessagesFilter count:int = messages.SearchCounter; + +urlAuthResultRequest#3cd623ec flags:# request_write_access:flags.0?true request_phone_number:flags.1?true match_codes_first:flags.5?true is_app:flags.6?true bot:User domain:string browser:flags.2?string platform:flags.2?string ip:flags.2?string region:flags.2?string match_codes:flags.3?Vector user_id_hint:flags.4?long verified_app_name:flags.7?string = UrlAuthResult; +urlAuthResultAccepted#623a8fa0 flags:# url:flags.0?string = UrlAuthResult; +urlAuthResultDefault#a9d6db1f = UrlAuthResult; + +channelLocationEmpty#bfb5ad8b = ChannelLocation; +channelLocation#209b82db geo_point:GeoPoint address:string = ChannelLocation; + +peerLocated#ca461b5d peer:Peer expires:int distance:int = PeerLocated; +peerSelfLocated#f8ec284b expires:int = PeerLocated; + +restrictionReason#d072acb4 platform:string reason:string text:string = RestrictionReason; + +inputTheme#3c5693e9 id:long access_hash:long = InputTheme; +inputThemeSlug#f5890df1 slug:string = InputTheme; + +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; + +account.themesNotModified#f41eb622 = account.Themes; +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; + +auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; +auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; +auth.loginTokenSuccess#390d5c5e authorization:auth.Authorization = auth.LoginToken; + +account.contentSettings#57e28221 flags:# sensitive_enabled:flags.0?true sensitive_can_change:flags.1?true = account.ContentSettings; + +messages.inactiveChats#a927fec5 dates:Vector chats:Vector users:Vector = messages.InactiveChats; + +baseThemeClassic#c3a12462 = BaseTheme; +baseThemeDay#fbd81688 = BaseTheme; +baseThemeNight#b7b31ea8 = BaseTheme; +baseThemeTinted#6d5f77ee = BaseTheme; +baseThemeArctic#5b11125a = BaseTheme; + +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; + +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; + +webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; +webPageAttributeStory#2e94c3e7 flags:# peer:Peer id:int story:flags.0?StoryItem = WebPageAttribute; +webPageAttributeStickerSet#50cc03d3 flags:# emojis:flags.0?true text_color:flags.1?true stickers:Vector = WebPageAttribute; +webPageAttributeUniqueStarGift#cf6f6db8 gift:StarGift = WebPageAttribute; +webPageAttributeStarGiftCollection#31cad303 icons:Vector = WebPageAttribute; +webPageAttributeStarGiftAuction#1c641c2 gift:StarGift end_date:int = WebPageAttribute; +webPageAttributeAiComposeTone#7781fe18 emoji_id:long = WebPageAttribute; + +messages.votesList#4899484e flags:# count:int votes:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.VotesList; + +bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; + +payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; + +dialogFilter#aa472651 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; +dialogFilterDefault#363293ae = DialogFilter; +dialogFilterChatlist#96537bd7 flags:# has_my_invites:flags.26?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; +statsGraphError#bedc9822 error:string = StatsGraph; +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +stats.broadcastStats#396ca5fc period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev reactions_per_post:StatsAbsValueAndPrev views_per_story:StatsAbsValueAndPrev shares_per_story:StatsAbsValueAndPrev reactions_per_story:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph reactions_by_emotion_graph:StatsGraph story_interactions_graph:StatsGraph story_reactions_by_emotion_graph:StatsGraph recent_posts_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; +help.promoData#8a4d87a flags:# proxy:flags.0?true expires:int peer:flags.3?Peer psa_type:flags.1?string psa_message:flags.2?string pending_suggestions:Vector dismissed_suggestions:Vector custom_pending_suggestion:flags.4?PendingSuggestion chats:Vector users:Vector = help.PromoData; + +videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; +videoSizeEmojiMarkup#f85c413c emoji_id:long background_colors:Vector = VideoSize; +videoSizeStickerMarkup#da082fe stickerset:InputStickerSet sticker_id:long background_colors:Vector = VideoSize; + +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#fe41b34f flags:# archive_and_mute_new_noncontact_peers:flags.0?true keep_archived_unmuted:flags.1?true keep_archived_folders:flags.2?true hide_read_marks:flags.3?true new_noncontact_peers_require_premium:flags.4?true display_gifts_button:flags.7?true noncontact_peers_paid_stars:flags.5?long disallowed_gifts:flags.6?DisallowedGiftsSettings = GlobalPrivacySettings; + +help.countryCode#4203c5ef flags:# country_code:string prefixes:flags.0?Vector patterns:flags.1?Vector = help.CountryCode; + +help.country#c3878e23 flags:# hidden:flags.0?true iso2:string default_name:string name:flags.1?string country_codes:Vector = help.Country; + +help.countriesListNotModified#93cc1f32 = help.CountriesList; +help.countriesList#87d0759e countries:Vector hash:int = help.CountriesList; + +messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:flags.2?MessageReplies = MessageViews; + +messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; + +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; + +messageReplyHeader#1b97dd66 flags:# reply_to_scheduled:flags.2?true forum_topic:flags.3?true quote:flags.9?true reply_to_ephemeral:flags.13?true reply_to_msg_id:flags.4?int reply_to_peer_id:flags.0?Peer reply_from:flags.5?MessageFwdHeader reply_media:flags.8?MessageMedia reply_to_top_id:flags.1?int quote_text:flags.6?string quote_entities:flags.7?Vector quote_offset:flags.10?int todo_item_id:flags.11?int poll_option:flags.12?bytes = MessageReplyHeader; +messageReplyStoryHeader#e5af939 peer:Peer story_id:int = MessageReplyHeader; + +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; + +peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; + +stats.messageStats#7fe91c14 views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.MessageStats; + +groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; +groupCall#efb2b617 flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true conference:flags.14?true creator:flags.15?true messages_enabled:flags.17?true can_change_messages_enabled:flags.18?true min:flags.19?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int invite_link:flags.16?string send_paid_messages_stars:flags.20?long default_send_as:flags.21?Peer = GroupCall; + +inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; +inputGroupCallSlug#fe06823f slug:string = InputGroupCall; +inputGroupCallInviteMessage#8c10603f msg_id:int = InputGroupCall; + +groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant; + +phone.groupCall#9e727aad call:GroupCall participants:Vector participants_next_offset:string chats:Vector users:Vector = phone.GroupCall; + +phone.groupParticipants#f47751b6 count:int participants:Vector next_offset:string chats:Vector users:Vector version:int = phone.GroupParticipants; + +inlineQueryPeerTypeSameBotPM#3081ed9d = InlineQueryPeerType; +inlineQueryPeerTypePM#833c0fac = InlineQueryPeerType; +inlineQueryPeerTypeChat#d766c50a = InlineQueryPeerType; +inlineQueryPeerTypeMegagroup#5ec4be43 = InlineQueryPeerType; +inlineQueryPeerTypeBroadcast#6334ee9a = InlineQueryPeerType; +inlineQueryPeerTypeBotPM#e3b2d0c = InlineQueryPeerType; + +messages.historyImport#1662af0b id:long = messages.HistoryImport; + +messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true title:flags.2?string = messages.HistoryImportParsed; + +messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; + +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true via_chatlist:flags.3?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; + +messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; + +messages.exportedChatInvite#1871be50 invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; +messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; + +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; + +messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; + +messages.checkedHistoryImportPeer#a24de717 confirm_text:string = messages.CheckedHistoryImportPeer; + +phone.joinAsPeers#afe5623f peers:Vector chats:Vector users:Vector = phone.JoinAsPeers; + +phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvite; + +groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; + +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; +botCommandScopeUsers#3c4f04d8 = BotCommandScope; +botCommandScopeChats#6fe1a881 = BotCommandScope; +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +chatTheme#c3dffc04 emoticon:string = ChatTheme; +chatThemeUniqueGift#3458f9c8 gift:StarGift theme_settings:Vector = ChatTheme; + +account.chatThemesNotModified#e011e1c4 = account.ChatThemes; +account.chatThemes#be098173 flags:# hash:long themes:Vector chats:Vector users:Vector next_offset:flags.0?string = account.ChatThemes; + +sponsoredMessage#7dbf8673 flags:# recommended:flags.5?true can_report:flags.12?true random_id:bytes url:string title:string message:string entities:flags.1?Vector photo:flags.6?Photo media:flags.14?MessageMedia color:flags.13?PeerColor button_text:string sponsor_info:flags.7?string additional_info:flags.8?string min_display_duration:flags.15?int max_display_duration:flags.15?int = SponsoredMessage; + +messages.sponsoredMessages#ffda656d flags:# posts_between:flags.0?int start_delay:flags.1?int between_delay:flags.2?int messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; +messages.sponsoredMessagesEmpty#1839490f = messages.SponsoredMessages; + +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#f496b0c6 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#a3d1cb80 flags:# chosen_order:flags.0?int reaction:Reaction count:int = ReactionCount; + +messageReactions#a339f0b flags:# min:flags.0?true can_see_list:flags.2?true reactions_as_tags:flags.3?true results:Vector recent_reactions:flags.1?Vector top_reactors:flags.4?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true premium:flags.2?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + +messagePeerReaction#8c79b63c flags:# big:flags.0?true unread:flags.1?true my:flags.2?true peer_id:Peer date:int reaction:Reaction = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; + +attachMenuBotIconColor#4576f3f0 name:string color:int = AttachMenuBotIconColor; + +attachMenuBotIcon#b2a7386b flags:# name:string icon:Document colors:flags.0?Vector = AttachMenuBotIcon; + +attachMenuBot#d90d8dfe flags:# inactive:flags.0?true has_settings:flags.1?true request_write_access:flags.2?true show_in_attach_menu:flags.3?true show_in_side_menu:flags.4?true side_menu_disclaimer_needed:flags.5?true bot_id:long short_name:string peer_types:flags.3?Vector icons:Vector = AttachMenuBot; + +attachMenuBotsNotModified#f1d88a5c = AttachMenuBots; +attachMenuBots#3c4301c0 hash:long bots:Vector users:Vector = AttachMenuBots; + +attachMenuBotsBot#93bf667f bot:AttachMenuBot users:Vector = AttachMenuBotsBot; + +webViewResultUrl#4d22ff98 flags:# fullsize:flags.1?true fullscreen:flags.2?true same_origin:flags.3?true query_id:flags.0?long url:string = WebViewResult; + +webViewMessageSent#c94511c flags:# msg_id:flags.0?InputBotInlineMessageID = WebViewMessageSent; + +botMenuButtonDefault#7533a588 = BotMenuButton; +botMenuButtonCommands#4258c205 = BotMenuButton; +botMenuButton#c7b57ce6 text:string url:string = BotMenuButton; + +account.savedRingtonesNotModified#fbf6e8b1 = account.SavedRingtones; +account.savedRingtones#c1e92cc5 hash:long ringtones:Vector = account.SavedRingtones; + +notificationSoundDefault#97e8bebe = NotificationSound; +notificationSoundNone#6f0c34df = NotificationSound; +notificationSoundLocal#830b9ae4 title:string data:string = NotificationSound; +notificationSoundRingtone#ff6c8049 id:long = NotificationSound; + +account.savedRingtone#b7263f6d = account.SavedRingtone; +account.savedRingtoneConverted#1f307eb7 document:Document = account.SavedRingtone; + +attachMenuPeerTypeSameBotPM#7d6be90e = AttachMenuPeerType; +attachMenuPeerTypeBotPM#c32bfa1a = AttachMenuPeerType; +attachMenuPeerTypePM#f146d31f = AttachMenuPeerType; +attachMenuPeerTypeChat#509113f = AttachMenuPeerType; +attachMenuPeerTypeBroadcast#7bfbdefc = AttachMenuPeerType; + +inputInvoiceMessage#c5b56859 peer:InputPeer msg_id:int = InputInvoice; +inputInvoiceSlug#c326caef slug:string = InputInvoice; +inputInvoicePremiumGiftCode#98986c0d purpose:InputStorePaymentPurpose option:PremiumGiftCodeOption = InputInvoice; +inputInvoiceStars#65f00ce3 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceChatInviteSubscription#34e793f1 hash:string = InputInvoice; +inputInvoiceStarGift#e8625e92 flags:# hide_name:flags.0?true include_upgrade:flags.2?true peer:InputPeer gift_id:long message:flags.1?TextWithEntities = InputInvoice; +inputInvoiceStarGiftUpgrade#4d818d5d flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftTransfer#4a5f5bd9 stargift:InputSavedStarGift to_id:InputPeer = InputInvoice; +inputInvoicePremiumGiftStars#dabab2ef flags:# user_id:InputUser months:int message:flags.0?TextWithEntities = InputInvoice; +inputInvoiceBusinessBotTransferStars#f4997e42 bot:InputUser stars:long = InputInvoice; +inputInvoiceStarGiftResale#c39f5324 flags:# ton:flags.0?true slug:string to_id:InputPeer = InputInvoice; +inputInvoiceStarGiftPrepaidUpgrade#9a0b48b8 peer:InputPeer hash:string = InputInvoice; +inputInvoicePremiumAuthCode#3e77f614 purpose:InputStorePaymentPurpose = InputInvoice; +inputInvoiceStarGiftDropOriginalDetails#923d8d1 stargift:InputSavedStarGift = InputInvoice; +inputInvoiceStarGiftAuctionBid#1ecafa10 flags:# hide_name:flags.0?true update_bid:flags.2?true peer:flags.3?InputPeer gift_id:long bid_amount:long message:flags.1?TextWithEntities = InputInvoice; + +payments.exportedInvoice#aed0cbd9 url:string = payments.ExportedInvoice; + +messages.transcribedAudio#cfb9d957 flags:# pending:flags.0?true transcription_id:long text:string trial_remains_num:flags.1?int trial_remains_until_date:flags.1?int = messages.TranscribedAudio; + +help.premiumPromo#5334759c status_text:string status_entities:Vector video_sections:Vector videos:Vector period_options:Vector users:Vector = help.PremiumPromo; + +inputStorePaymentPremiumSubscription#a6751e66 flags:# restore:flags.0?true upgrade:flags.1?true = InputStorePaymentPurpose; +inputStorePaymentGiftPremium#616f7fe8 user_id:InputUser currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentPremiumGiftCode#fb790393 flags:# users:Vector boost_peer:flags.0?InputPeer currency:string amount:long message:flags.1?TextWithEntities = InputStorePaymentPurpose; +inputStorePaymentPremiumGiveaway#160544ca flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsTopup#f9a2a6cb flags:# stars:long currency:string amount:long spend_purpose_peer:flags.0?InputPeer = InputStorePaymentPurpose; +inputStorePaymentStarsGift#1d741ef7 user_id:InputUser stars:long currency:string amount:long = InputStorePaymentPurpose; +inputStorePaymentStarsGiveaway#751f08fa flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true stars:long boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long users:int = InputStorePaymentPurpose; +inputStorePaymentAuthCode#3fc18057 flags:# restore:flags.0?true phone_number:string phone_code_hash:string premium_days:int currency:string amount:long = InputStorePaymentPurpose; + +paymentFormMethod#88f8f21b url:string title:string = PaymentFormMethod; + +emojiStatusEmpty#2de11aae = EmojiStatus; +emojiStatus#e7ff068a flags:# document_id:long until:flags.0?int = EmojiStatus; +emojiStatusCollectible#7184603b flags:# collectible_id:long document_id:long title:string slug:string pattern_document_id:long center_color:int edge_color:int pattern_color:int text_color:int until:flags.0?int = EmojiStatus; +inputEmojiStatusCollectible#7141dbf flags:# collectible_id:long until:flags.0?int = EmojiStatus; + +account.emojiStatusesNotModified#d08ce645 = account.EmojiStatuses; +account.emojiStatuses#90c467d1 hash:long statuses:Vector = account.EmojiStatuses; + +reactionEmpty#79f5d419 = Reaction; +reactionEmoji#1b2286b8 emoticon:string = Reaction; +reactionCustomEmoji#8935fc73 document_id:long = Reaction; +reactionPaid#523da4eb = Reaction; + +chatReactionsNone#eafc32bc = ChatReactions; +chatReactionsAll#52928bca flags:# allow_custom:flags.0?true = ChatReactions; +chatReactionsSome#661d4037 reactions:Vector = ChatReactions; + +messages.reactionsNotModified#b06fdbdf = messages.Reactions; +messages.reactions#eafdf716 hash:long reactions:Vector = messages.Reactions; + +emailVerifyPurposeLoginSetup#4345be73 phone_number:string phone_code_hash:string = EmailVerifyPurpose; +emailVerifyPurposeLoginChange#527d22eb = EmailVerifyPurpose; +emailVerifyPurposePassport#bbf51685 = EmailVerifyPurpose; + +emailVerificationCode#922e55a9 code:string = EmailVerification; +emailVerificationGoogle#db909ec2 token:string = EmailVerification; +emailVerificationApple#96d074fd token:string = EmailVerification; + +account.emailVerified#2b96cd1b email:string = account.EmailVerified; +account.emailVerifiedLogin#e1bb0d61 email:string sent_code:auth.SentCode = account.EmailVerified; + +premiumSubscriptionOption#5f2d1df2 flags:# current:flags.1?true can_purchase_upgrade:flags.2?true transaction:flags.3?string months:int currency:string amount:long bot_url:string store_product:flags.0?string = PremiumSubscriptionOption; + +sendAsPeer#b81c7034 flags:# premium_required:flags.0?true peer:Peer = SendAsPeer; + +messageExtendedMediaPreview#ad628cc8 flags:# w:flags.0?int h:flags.0?int thumb:flags.1?PhotoSize video_duration:flags.2?int = MessageExtendedMedia; +messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; + +stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; + +username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; + +forumTopicDeleted#23f109b id:int = ForumTopic; +forumTopic#fcdad815 flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true title_missing:flags.7?true id:int date:int peer:Peer title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; + +messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; + +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + +requestPeerTypeUser#5f3b8a00 flags:# bot:flags.0?Bool premium:flags.1?Bool = RequestPeerType; +requestPeerTypeChat#c9f06e1b flags:# creator:flags.0?true bot_participant:flags.5?true has_username:flags.3?Bool forum:flags.4?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeBroadcast#339bef6c flags:# creator:flags.0?true has_username:flags.3?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; +requestPeerTypeCreateBot#3e81e078 flags:# bot_managed:flags.0?true suggested_name:flags.1?string suggested_username:flags.2?string = RequestPeerType; + +emojiListNotModified#481eadfa = EmojiList; +emojiList#7a1e11d1 hash:long document_id:Vector = EmojiList; + +emojiGroup#7a9abda9 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupGreeting#80d26cc7 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; +emojiGroupPremium#93bcf34 title:string icon_emoji_id:long = EmojiGroup; + +messages.emojiGroupsNotModified#6fb4ad87 = messages.EmojiGroups; +messages.emojiGroups#881fb94b hash:int groups:Vector = messages.EmojiGroups; + +textWithEntities#751f3146 text:string entities:Vector = TextWithEntities; + +messages.translateResult#33db32f8 result:Vector = messages.TranslatedText; + +autoSaveSettings#c84834ce flags:# photos:flags.0?true videos:flags.1?true video_max_size:flags.2?long = AutoSaveSettings; + +autoSaveException#81602d47 peer:Peer settings:AutoSaveSettings = AutoSaveException; + +account.autoSaveSettings#4c3e069d users_settings:AutoSaveSettings chats_settings:AutoSaveSettings broadcasts_settings:AutoSaveSettings exceptions:Vector chats:Vector users:Vector = account.AutoSaveSettings; + +help.appConfigNotModified#7cde641d = help.AppConfig; +help.appConfig#dd18782e hash:int config:JSONValue = help.AppConfig; + +inputBotAppID#a920bd7a id:long access_hash:long = InputBotApp; +inputBotAppShortName#908c0407 bot_id:InputUser short_name:string = InputBotApp; + +botAppNotModified#5da674b7 = BotApp; +botApp#95fcd1d6 flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document hash:long = BotApp; + +messages.botApp#eb50adf5 flags:# inactive:flags.0?true request_write_access:flags.1?true has_settings:flags.2?true app:BotApp = messages.BotApp; + +inlineBotWebView#b57295d5 text:string url:string = InlineBotWebView; + +readParticipantDate#4a4ff172 user_id:long date:int = ReadParticipantDate; + +inputChatlistDialogFilter#f3e0da33 filter_id:int = InputChatlist; + +exportedChatlistInvite#c5181ac flags:# title:string url:string peers:Vector = ExportedChatlistInvite; + +chatlists.exportedChatlistInvite#10e6e3a6 filter:DialogFilter invite:ExportedChatlistInvite = chatlists.ExportedChatlistInvite; + +chatlists.exportedInvites#10ab6dc7 invites:Vector chats:Vector users:Vector = chatlists.ExportedInvites; + +chatlists.chatlistInviteAlready#fa87f659 filter_id:int missing_peers:Vector already_peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; +chatlists.chatlistInvite#f10ece2f flags:# title_noanimate:flags.1?true title:TextWithEntities emoticon:flags.0?string peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistUpdates#93bd878d missing_peers:Vector chats:Vector users:Vector = chatlists.ChatlistUpdates; + +bots.botInfo#e8a775b0 name:string about:string description:string = bots.BotInfo; + +messagePeerVote#b6cc2d5c peer:Peer option:bytes date:int = MessagePeerVote; +messagePeerVoteInputOption#74cda504 peer:Peer date:int = MessagePeerVote; +messagePeerVoteMultiple#4628f6e6 peer:Peer options:Vector date:int = MessagePeerVote; + +storyViews#8d595cd6 flags:# has_viewers:flags.1?true views_count:int forwards_count:flags.2?int reactions:flags.3?Vector reactions_count:flags.4?int recent_viewers:flags.0?Vector = StoryViews; + +storyItemDeleted#51e6ee4f id:int = StoryItem; +storyItemSkipped#ffadc913 flags:# close_friends:flags.8?true live:flags.9?true id:int date:int expire_date:int = StoryItem; +storyItem#16a4b93c flags:# pinned:flags.5?true public:flags.7?true close_friends:flags.8?true min:flags.9?true noforwards:flags.10?true edited:flags.11?true contacts:flags.12?true selected_contacts:flags.13?true out:flags.16?true id:int date:int from_id:flags.18?Peer fwd_from:flags.17?StoryFwdHeader expire_date:int caption:flags.0?string entities:flags.1?Vector media:MessageMedia media_areas:flags.14?Vector privacy:flags.2?Vector views:flags.3?StoryViews sent_reaction:flags.15?Reaction albums:flags.19?Vector music:flags.20?Document = StoryItem; + +stories.allStoriesNotModified#1158fe3e flags:# state:string stealth_mode:StoriesStealthMode = stories.AllStories; +stories.allStories#6efc5e81 flags:# has_more:flags.0?true count:int state:string peer_stories:Vector chats:Vector users:Vector stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.stories#63c3dd0a flags:# count:int stories:Vector pinned_to_top:flags.0?Vector chats:Vector users:Vector = stories.Stories; + +storyView#b0bdeac5 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true user_id:long date:int reaction:flags.2?Reaction = StoryView; +storyViewPublicForward#9083670b flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true message:Message = StoryView; +storyViewPublicRepost#bd74cf49 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer story:StoryItem = StoryView; + +stories.storyViewsList#59d78fc5 flags:# count:int views_count:int forwards_count:int reactions_count:int views:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryViewsList; + +stories.storyViews#de9eed1d views:Vector users:Vector = stories.StoryViews; + +inputReplyToMessage#3bd4b7c2 flags:# reply_to_msg_id:int top_msg_id:flags.0?int reply_to_peer_id:flags.1?InputPeer quote_text:flags.2?string quote_entities:flags.3?Vector quote_offset:flags.4?int monoforum_peer_id:flags.5?InputPeer todo_item_id:flags.6?int poll_option:flags.7?bytes = InputReplyTo; +inputReplyToStory#5881323a peer:InputPeer story_id:int = InputReplyTo; +inputReplyToMonoForum#69d66c45 monoforum_peer_id:InputPeer = InputReplyTo; + +exportedStoryLink#3fc9053b link:string = ExportedStoryLink; + +storiesStealthMode#712e27fd flags:# active_until_date:flags.0?int cooldown_until_date:flags.1?int = StoriesStealthMode; + +mediaAreaCoordinates#cfc9e002 flags:# x:double y:double w:double h:double rotation:double radius:flags.0?double = MediaAreaCoordinates; + +mediaAreaVenue#be82db9c coordinates:MediaAreaCoordinates geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MediaArea; +inputMediaAreaVenue#b282217f coordinates:MediaAreaCoordinates query_id:long result_id:string = MediaArea; +mediaAreaGeoPoint#cad5452d flags:# coordinates:MediaAreaCoordinates geo:GeoPoint address:flags.0?GeoPointAddress = MediaArea; +mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; +mediaAreaChannelPost#770416af coordinates:MediaAreaCoordinates channel_id:long msg_id:int = MediaArea; +inputMediaAreaChannelPost#2271f2bf coordinates:MediaAreaCoordinates channel:InputChannel msg_id:int = MediaArea; +mediaAreaUrl#37381085 coordinates:MediaAreaCoordinates url:string = MediaArea; +mediaAreaWeather#49a6549c coordinates:MediaAreaCoordinates emoji:string temperature_c:double color:int = MediaArea; +mediaAreaStarGift#5787686d coordinates:MediaAreaCoordinates slug:string = MediaArea; + +peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; + +stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; + +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; + +premiumGiftCodeOption#257e962b flags:# users:int months:int store_product:flags.0?string store_quantity:flags.1?int currency:string amount:long = PremiumGiftCodeOption; + +payments.checkedGiftCode#eb983f8f flags:# via_giveaway:flags.2?true from_id:flags.4?Peer giveaway_msg_id:flags.3?int to_id:flags.0?long date:int days:int used_date:flags.1?int chats:Vector users:Vector = payments.CheckedGiftCode; + +payments.giveawayInfo#4367daa0 flags:# participating:flags.0?true preparing_results:flags.3?true start_date:int joined_too_early_date:flags.1?int admin_disallowed_chat_id:flags.2?long disallowed_country:flags.4?string = payments.GiveawayInfo; +payments.giveawayInfoResults#e175e66f flags:# winner:flags.0?true refunded:flags.1?true start_date:int gift_code_slug:flags.3?string stars_prize:flags.4?long finish_date:int winners_count:int activated_count:flags.2?int = payments.GiveawayInfo; + +prepaidGiveaway#b2539d54 id:long months:int quantity:int date:int = PrepaidGiveaway; +prepaidStarsGiveaway#9a9d77e0 id:long stars:long quantity:int boosts:int date:int = PrepaidGiveaway; + +boost#4b3e14d6 flags:# gift:flags.1?true giveaway:flags.2?true unclaimed:flags.3?true id:string user_id:flags.0?long giveaway_msg_id:flags.2?int date:int expires:int used_gift_slug:flags.4?string multiplier:flags.5?int stars:flags.6?long = Boost; + +premium.boostsList#86f8613c flags:# count:int boosts:Vector next_offset:flags.0?string users:Vector = premium.BoostsList; + +myBoost#c448415c flags:# slot:int peer:flags.0?Peer date:int expires:int cooldown_until_date:flags.1?int = MyBoost; + +premium.myBoosts#9ae228e2 my_boosts:Vector chats:Vector users:Vector = premium.MyBoosts; + +premium.boostsStatus#4959427a flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int gift_boosts:flags.4?int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string prepaid_giveaways:flags.3?Vector my_boost_slots:flags.2?Vector = premium.BoostsStatus; + +storyFwdHeader#b826e150 flags:# modified:flags.3?true from:flags.0?Peer from_name:flags.1?string story_id:flags.2?int = StoryFwdHeader; + +postInteractionCountersMessage#e7058e7f msg_id:int views:int forwards:int reactions:int = PostInteractionCounters; +postInteractionCountersStory#8a480e27 story_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +stats.storyStats#50cd067c views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.StoryStats; + +publicForwardMessage#1f2bf4a message:Message = PublicForward; +publicForwardStory#edf3add0 peer:Peer story:StoryItem = PublicForward; + +stats.publicForwards#93037e20 flags:# count:int forwards:Vector next_offset:flags.0?string chats:Vector users:Vector = stats.PublicForwards; + +peerColor#b54b5acf flags:# color:flags.0?int background_emoji_id:flags.1?long = PeerColor; +peerColorCollectible#b9c0639a flags:# collectible_id:long gift_emoji_id:long background_emoji_id:long accent_color:int colors:Vector dark_accent_color:flags.0?int dark_colors:flags.1?Vector = PeerColor; +inputPeerColorCollectible#b8ea86a9 collectible_id:long = PeerColor; + +help.peerColorSet#26219a58 colors:Vector = help.PeerColorSet; +help.peerColorProfileSet#767d61eb palette_colors:Vector bg_colors:Vector story_colors:Vector = help.PeerColorSet; + +help.peerColorOption#adec6ebe flags:# hidden:flags.0?true color_id:int colors:flags.1?help.PeerColorSet dark_colors:flags.2?help.PeerColorSet channel_min_level:flags.3?int group_min_level:flags.4?int = help.PeerColorOption; + +help.peerColorsNotModified#2ba1f5ce = help.PeerColors; +help.peerColors#f8ed08 hash:int colors:Vector = help.PeerColors; + +storyReaction#6090d6d5 peer_id:Peer date:int reaction:Reaction = StoryReaction; +storyReactionPublicForward#bbab2643 message:Message = StoryReaction; +storyReactionPublicRepost#cfcd0f13 peer_id:Peer story:StoryItem = StoryReaction; + +stories.storyReactionsList#aa5f789c flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryReactionsList; + +savedDialog#bd87cb6c flags:# pinned:flags.2?true peer:Peer top_message:int = SavedDialog; +monoForumDialog#64407ea7 flags:# unread_mark:flags.3?true nopaid_messages_exception:flags.4?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_reactions_count:int draft:flags.1?DraftMessage = SavedDialog; + +messages.savedDialogs#f83ae221 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsSlice#44ba9dd9 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; +messages.savedDialogsNotModified#c01f6fe8 count:int = messages.SavedDialogs; + +savedReactionTag#cb6ff828 flags:# reaction:Reaction title:flags.0?string count:int = SavedReactionTag; + +messages.savedReactionTagsNotModified#889b59ef = messages.SavedReactionTags; +messages.savedReactionTags#3259950a tags:Vector hash:long = messages.SavedReactionTags; + +outboxReadDate#3bb842ac date:int = OutboxReadDate; + +smsjobs.eligibleToJoin#dc8b44cf terms_url:string monthly_sent_sms:int = smsjobs.EligibilityToJoin; + +smsjobs.status#2aee9191 flags:# allow_international:flags.0?true recent_sent:int recent_since:int recent_remains:int total_sent:int total_since:int last_gift_slug:flags.1?string terms_url:string = smsjobs.Status; + +smsJob#e6a1eeb8 job_id:string phone_number:string text:string = SmsJob; + +businessWeeklyOpen#120b1ab9 start_minute:int end_minute:int = BusinessWeeklyOpen; + +businessWorkHours#8c92b098 flags:# open_now:flags.0?true timezone_id:string weekly_open:Vector = BusinessWorkHours; + +businessLocation#ac5c1af7 flags:# geo_point:flags.0?GeoPoint address:string = BusinessLocation; + +inputBusinessRecipients#6f8b32aa flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = InputBusinessRecipients; + +businessRecipients#21108ff7 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = BusinessRecipients; + +businessAwayMessageScheduleAlways#c9b9e2b9 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleOutsideWorkHours#c3f2f501 = BusinessAwayMessageSchedule; +businessAwayMessageScheduleCustom#cc4d9ecc start_date:int end_date:int = BusinessAwayMessageSchedule; + +inputBusinessGreetingMessage#194cb3b shortcut_id:int recipients:InputBusinessRecipients no_activity_days:int = InputBusinessGreetingMessage; + +businessGreetingMessage#e519abab shortcut_id:int recipients:BusinessRecipients no_activity_days:int = BusinessGreetingMessage; + +inputBusinessAwayMessage#832175e0 flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:InputBusinessRecipients = InputBusinessAwayMessage; + +businessAwayMessage#ef156a5c flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:BusinessRecipients = BusinessAwayMessage; + +timezone#ff9289f5 id:string name:string utc_offset:int = Timezone; + +help.timezonesListNotModified#970708cc = help.TimezonesList; +help.timezonesList#7b74ed71 timezones:Vector hash:int = help.TimezonesList; + +quickReply#697102b shortcut_id:int shortcut:string top_message:int count:int = QuickReply; + +inputQuickReplyShortcut#24596d41 shortcut:string = InputQuickReplyShortcut; +inputQuickReplyShortcutId#1190cf1 shortcut_id:int = InputQuickReplyShortcut; + +messages.quickReplies#c68d6695 quick_replies:Vector messages:Vector chats:Vector users:Vector = messages.QuickReplies; +messages.quickRepliesNotModified#5f91eb5b = messages.QuickReplies; + +connectedBot#33ed001 flags:# bot_id:long recipients:BusinessBotRecipients rights:BusinessBotRights device:flags.0?string date:flags.1?int location:flags.2?string = ConnectedBot; + +account.connectedBots#17d7f87b connected_bots:Vector users:Vector = account.ConnectedBots; + +messages.dialogFilters#2ad93719 flags:# tags_enabled:flags.0?true filters:Vector = messages.DialogFilters; + +birthday#6c8e1e06 flags:# day:int month:int year:flags.0?int = Birthday; + +botBusinessConnection#8f34b2f5 flags:# disabled:flags.1?true connection_id:string user_id:long dc_id:int date:int rights:flags.2?BusinessBotRights = BotBusinessConnection; + +inputBusinessIntro#9c469cd flags:# title:string description:string sticker:flags.0?InputDocument = InputBusinessIntro; + +businessIntro#5a0a066d flags:# title:string description:string sticker:flags.0?Document = BusinessIntro; + +messages.myStickers#faff629d count:int sets:Vector = messages.MyStickers; + +inputCollectibleUsername#e39460a9 username:string = InputCollectible; +inputCollectiblePhone#a2e214a4 phone:string = InputCollectible; + +fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo; + +inputBusinessBotRecipients#c4e5921e flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = InputBusinessBotRecipients; + +businessBotRecipients#b88cf373 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = BusinessBotRecipients; + +contactBirthday#1d998733 contact_id:long birthday:Birthday = ContactBirthday; + +contacts.contactBirthdays#114ff30d contacts:Vector users:Vector = contacts.ContactBirthdays; + +missingInvitee#628c9224 flags:# premium_would_allow_invite:flags.0?true premium_required_for_pm:flags.1?true user_id:long = MissingInvitee; + +messages.invitedUsers#7f5defa6 updates:Updates missing_invitees:Vector = messages.InvitedUsers; + +inputBusinessChatLink#11679fa7 flags:# message:string entities:flags.0?Vector title:flags.1?string = InputBusinessChatLink; + +businessChatLink#b4ae666f flags:# link:string message:string entities:flags.0?Vector title:flags.1?string views:int = BusinessChatLink; + +account.businessChatLinks#ec43a2d1 links:Vector chats:Vector users:Vector = account.BusinessChatLinks; + +account.resolvedBusinessChatLinks#9a23af21 flags:# peer:Peer message:string entities:flags.0?Vector chats:Vector users:Vector = account.ResolvedBusinessChatLinks; + +requestedPeerUser#d62ff46a flags:# user_id:long first_name:flags.0?string last_name:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChat#7307544f flags:# chat_id:long title:flags.0?string photo:flags.2?Photo = RequestedPeer; +requestedPeerChannel#8ba403e4 flags:# channel_id:long title:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +sponsoredMessageReportOption#430d3150 text:string option:bytes = SponsoredMessageReportOption; + +channels.sponsoredMessageReportResultChooseOption#846f9e42 title:string options:Vector = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultAdsHidden#3e3bcf2f = channels.SponsoredMessageReportResult; +channels.sponsoredMessageReportResultReported#ad798849 = channels.SponsoredMessageReportResult; + +reactionNotificationsFromContacts#bac3a61a = ReactionNotificationsFrom; +reactionNotificationsFromAll#4b9e22a0 = ReactionNotificationsFrom; + +reactionsNotifySettings#71e4ea58 flags:# messages_notify_from:flags.0?ReactionNotificationsFrom stories_notify_from:flags.1?ReactionNotificationsFrom poll_votes_notify_from:flags.2?ReactionNotificationsFrom sound:NotificationSound show_previews:Bool = ReactionsNotifySettings; + +availableEffect#93c3e27e flags:# premium_required:flags.2?true id:long emoticon:string static_icon_id:flags.0?long effect_sticker_id:long effect_animation_id:flags.1?long = AvailableEffect; + +messages.availableEffectsNotModified#d1ed9a5b = messages.AvailableEffects; +messages.availableEffects#bddb616e hash:int effects:Vector documents:Vector = messages.AvailableEffects; + +factCheck#b89bfccf flags:# need_check:flags.0?true country:flags.1?string text:flags.1?TextWithEntities hash:long = FactCheck; + +starsTransactionPeerUnsupported#95f2bfe4 = StarsTransactionPeer; +starsTransactionPeerAppStore#b457b375 = StarsTransactionPeer; +starsTransactionPeerPlayMarket#7b560a0b = StarsTransactionPeer; +starsTransactionPeerPremiumBot#250dbaf8 = StarsTransactionPeer; +starsTransactionPeerFragment#e92fd902 = StarsTransactionPeer; +starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; +starsTransactionPeerAds#60682812 = StarsTransactionPeer; +starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; + +starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; + +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true stargift_prepaid_upgrade:flags.25?true stargift_drop_original_details:flags.26?true phonegroup_message:flags.27?true stargift_auction_bid:flags.28?true offer:flags.29?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; + +payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; + +foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; + +stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; + +geoPointAddress#de4c5d93 flags:# country_iso2:string state:flags.0?string city:flags.1?string street:flags.2?string = GeoPointAddress; + +starsRevenueStatus#febe5491 flags:# withdrawal_enabled:flags.0?true current_balance:StarsAmount available_balance:StarsAmount overall_revenue:StarsAmount next_withdrawal_at:flags.1?int = StarsRevenueStatus; + +payments.starsRevenueStats#6c207376 flags:# top_hours_graph:flags.0?StatsGraph revenue_graph:StatsGraph status:StarsRevenueStatus usd_rate:double = payments.StarsRevenueStats; + +payments.starsRevenueWithdrawalUrl#1dab80b7 url:string = payments.StarsRevenueWithdrawalUrl; + +payments.starsRevenueAdsAccountUrl#394e7f21 url:string = payments.StarsRevenueAdsAccountUrl; + +inputStarsTransaction#206ae6d1 flags:# refund:flags.0?true id:string = InputStarsTransaction; + +starsGiftOption#5e0589f1 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsGiftOption; + +bots.popularAppBots#1991b13b flags:# next_offset:flags.0?string users:Vector = bots.PopularAppBots; + +botPreviewMedia#23e91ba3 date:int media:MessageMedia = BotPreviewMedia; + +bots.previewInfo#ca71d64 media:Vector lang_codes:Vector = bots.PreviewInfo; + +starsSubscriptionPricing#5416d58 period:int amount:long = StarsSubscriptionPricing; + +starsSubscription#2e6eab1a flags:# canceled:flags.0?true can_refulfill:flags.1?true missing_balance:flags.2?true bot_canceled:flags.7?true id:string peer:Peer until_date:int pricing:StarsSubscriptionPricing chat_invite_hash:flags.3?string title:flags.4?string photo:flags.5?WebDocument invoice_slug:flags.6?string = StarsSubscription; + +messageReactor#4ba3a95a flags:# top:flags.0?true my:flags.1?true anonymous:flags.2?true peer_id:flags.3?Peer count:int = MessageReactor; + +starsGiveawayOption#94ce852a flags:# extended:flags.0?true default:flags.1?true stars:long yearly_boosts:int store_product:flags.2?string currency:string amount:long winners:Vector = StarsGiveawayOption; + +starsGiveawayWinnersOption#54236209 flags:# default:flags.0?true users:int per_user_stars:long = StarsGiveawayWinnersOption; + +starGift#313a9547 flags:# limited:flags.0?true sold_out:flags.1?true birthday:flags.2?true require_premium:flags.7?true limited_per_user:flags.8?true peer_color_available:flags.10?true auction:flags.11?true id:long sticker:Document stars:long availability_remains:flags.0?int availability_total:flags.0?int availability_resale:flags.4?long convert_stars:long first_sale_date:flags.1?int last_sale_date:flags.1?int upgrade_stars:flags.3?long resell_min_stars:flags.4?long title:flags.5?string released_by:flags.6?Peer per_user_total:flags.8?int per_user_remains:flags.8?int locked_until_date:flags.9?int auction_slug:flags.11?string gifts_per_round:flags.11?int auction_start_date:flags.11?int upgrade_variants:flags.12?int background:flags.13?StarGiftBackground = StarGift; +starGiftUnique#85f0a9cd flags:# require_premium:flags.6?true resale_ton_only:flags.7?true theme_available:flags.9?true burned:flags.14?true crafted:flags.15?true id:long gift_id:long title:string slug:string num:int owner_id:flags.0?Peer owner_name:flags.1?string owner_address:flags.2?string attributes:Vector availability_issued:int availability_total:int gift_address:flags.3?string resell_amount:flags.4?Vector released_by:flags.5?Peer value_amount:flags.8?long value_currency:flags.8?string value_usd_amount:flags.8?long theme_peer:flags.10?Peer peer_color:flags.11?PeerColor host_id:flags.12?Peer offer_min_stars:flags.13?int craft_chance_permille:flags.16?int = StarGift; + +payments.starGiftsNotModified#a388a368 = payments.StarGifts; +payments.starGifts#2ed82995 hash:int gifts:Vector chats:Vector users:Vector = payments.StarGifts; + +messageReportOption#7903e3d9 text:string option:bytes = MessageReportOption; + +reportResultChooseOption#f0e4e0b6 title:string options:Vector = ReportResult; +reportResultAddComment#6f09ac31 flags:# optional:flags.0?true option:bytes = ReportResult; +reportResultReported#8db33c4b = ReportResult; + +messages.botPreparedInlineMessage#8ecf0511 id:string expire_date:int = messages.BotPreparedInlineMessage; + +messages.preparedInlineMessage#ff57708d query_id:long result:BotInlineResult peer_types:Vector cache_time:int users:Vector = messages.PreparedInlineMessage; + +botAppSettings#c99b1950 flags:# placeholder_path:flags.0?bytes background_color:flags.1?int background_dark_color:flags.2?int header_color:flags.3?int header_dark_color:flags.4?int = BotAppSettings; + +starRefProgram#dd0c66f2 flags:# bot_id:long commission_permille:int duration_months:flags.0?int end_date:flags.1?int daily_revenue_per_user:flags.2?StarsAmount = StarRefProgram; + +connectedBotStarRef#19a13f71 flags:# revoked:flags.1?true url:string date:int bot_id:long commission_permille:int duration_months:flags.0?int participants:long revenue:long = ConnectedBotStarRef; + +payments.connectedStarRefBots#98d5ea1d count:int connected_bots:Vector users:Vector = payments.ConnectedStarRefBots; + +payments.suggestedStarRefBots#b4d5d859 flags:# count:int suggested_bots:Vector users:Vector next_offset:flags.0?string = payments.SuggestedStarRefBots; + +starsAmount#bbb6b4a3 amount:long nanos:int = StarsAmount; +starsTonAmount#74aee3e0 amount:long = StarsAmount; + +messages.foundStickersNotModified#6010c534 flags:# next_offset:flags.0?int = messages.FoundStickers; +messages.foundStickers#82c9e290 flags:# next_offset:flags.0?int hash:long stickers:Vector = messages.FoundStickers; + +botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string = BotVerifierSettings; + +botVerification#f93cd45c bot_id:long icon:long description:string = BotVerification; + +starGiftAttributeModel#565251e2 flags:# crafted:flags.0?true name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributePattern#4e7085ea name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeBackdrop#9f2504e4 name:string backdrop_id:int center_color:int edge_color:int pattern_color:int text_color:int rarity:StarGiftAttributeRarity = StarGiftAttribute; +starGiftAttributeOriginalDetails#e0bff26c flags:# sender_id:flags.0?Peer recipient_id:Peer date:int message:flags.1?TextWithEntities = StarGiftAttribute; + +payments.starGiftUpgradePreview#3de1dfed sample_attributes:Vector prices:Vector next_prices:Vector = payments.StarGiftUpgradePreview; + +users.users#62d706b8 users:Vector = users.Users; +users.usersSlice#315a4974 count:int users:Vector = users.Users; + +payments.uniqueStarGift#416c56e8 gift:StarGift chats:Vector users:Vector = payments.UniqueStarGift; + +messages.webPagePreview#8c9a88ac media:MessageMedia chats:Vector users:Vector = messages.WebPagePreview; + +savedStarGift#41df43fc flags:# name_hidden:flags.0?true unsaved:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true pinned_to_top:flags.12?true upgrade_separate:flags.17?true from_id:flags.1?Peer date:int gift:StarGift message:flags.2?TextWithEntities msg_id:flags.3?int saved_id:flags.11?long convert_stars:flags.4?long upgrade_stars:flags.6?long can_export_at:flags.7?int transfer_stars:flags.8?long can_transfer_at:flags.13?int can_resell_at:flags.14?int collection_id:flags.15?Vector prepaid_upgrade_hash:flags.16?string drop_original_details_stars:flags.18?long gift_num:flags.19?int can_craft_at:flags.20?int = SavedStarGift; + +payments.savedStarGifts#95f389b1 flags:# count:int chat_notifications_enabled:flags.1?Bool gifts:Vector next_offset:flags.0?string chats:Vector users:Vector = payments.SavedStarGifts; + +inputSavedStarGiftUser#69279795 msg_id:int = InputSavedStarGift; +inputSavedStarGiftChat#f101aa7f peer:InputPeer saved_id:long = InputSavedStarGift; +inputSavedStarGiftSlug#2085c238 slug:string = InputSavedStarGift; + +payments.starGiftWithdrawalUrl#84aa3a9c url:string = payments.StarGiftWithdrawalUrl; + +paidReactionPrivacyDefault#206ad49e = PaidReactionPrivacy; +paidReactionPrivacyAnonymous#1f0c1ad9 = PaidReactionPrivacy; +paidReactionPrivacyPeer#dc6cfcf0 peer:InputPeer = PaidReactionPrivacy; + +account.paidMessagesRevenue#1e109708 stars_amount:long = account.PaidMessagesRevenue; + +requirementToContactEmpty#50a9839 = RequirementToContact; +requirementToContactPremium#e581e4e9 = RequirementToContact; +requirementToContactPaidMessages#b4f67e93 stars_amount:long = RequirementToContact; + +businessBotRights#a0624cf7 flags:# reply:flags.0?true read_messages:flags.1?true delete_sent_messages:flags.2?true delete_received_messages:flags.3?true edit_name:flags.4?true edit_bio:flags.5?true edit_profile_photo:flags.6?true edit_username:flags.7?true view_gifts:flags.8?true sell_gifts:flags.9?true change_gift_settings:flags.10?true transfer_and_upgrade_gifts:flags.11?true transfer_stars:flags.12?true manage_stories:flags.13?true = BusinessBotRights; + +disallowedGiftsSettings#71f276c4 flags:# disallow_unlimited_stargifts:flags.0?true disallow_limited_stargifts:flags.1?true disallow_unique_stargifts:flags.2?true disallow_premium_gifts:flags.3?true disallow_stargifts_from_channels:flags.4?true = DisallowedGiftsSettings; + +sponsoredPeer#c69708d3 flags:# random_id:bytes peer:Peer sponsor_info:flags.0?string additional_info:flags.1?string = SponsoredPeer; + +contacts.sponsoredPeersEmpty#ea32b4b1 = contacts.SponsoredPeers; +contacts.sponsoredPeers#eb032884 peers:Vector chats:Vector users:Vector = contacts.SponsoredPeers; + +starGiftAttributeIdModel#48aaae3c document_id:long = StarGiftAttributeId; +starGiftAttributeIdPattern#4a162433 document_id:long = StarGiftAttributeId; +starGiftAttributeIdBackdrop#1f01c757 backdrop_id:int = StarGiftAttributeId; + +starGiftAttributeCounter#2eb1b658 attribute:StarGiftAttributeId count:int = StarGiftAttributeCounter; + +payments.resaleStarGifts#947a12df flags:# count:int gifts:Vector next_offset:flags.0?string attributes:flags.1?Vector attributes_hash:flags.1?long chats:Vector counters:flags.2?Vector users:Vector = payments.ResaleStarGifts; + +stories.canSendStoryCount#c387c04e count_remains:int = stories.CanSendStoryCount; + +pendingSuggestion#e7e82e12 suggestion:string title:TextWithEntities description:TextWithEntities url:string = PendingSuggestion; + +todoItem#cba9a52f id:int title:TextWithEntities = TodoItem; + +todoList#49b92a26 flags:# others_can_append:flags.0?true others_can_complete:flags.1?true title:TextWithEntities list:Vector = TodoList; + +todoCompletion#221bb5e4 id:int completed_by:Peer date:int = TodoCompletion; + +suggestedPost#e8e37e5 flags:# accepted:flags.1?true rejected:flags.2?true price:flags.3?StarsAmount schedule_date:flags.0?int = SuggestedPost; + +starsRating#1b0e4f07 flags:# level:int current_level_stars:long stars:long next_level_stars:flags.0?long = StarsRating; + +starGiftCollection#9d6b13b0 flags:# collection_id:int title:string icon:flags.0?Document gifts_count:int hash:long = StarGiftCollection; + +payments.starGiftCollectionsNotModified#a0ba4f17 = payments.StarGiftCollections; +payments.starGiftCollections#8a2932f3 collections:Vector = payments.StarGiftCollections; + +storyAlbum#9325705a flags:# album_id:int title:string icon_photo:flags.0?Photo icon_video:flags.1?Document = StoryAlbum; + +stories.albumsNotModified#564edaeb = stories.Albums; +stories.albums#c3987a3a hash:long albums:Vector = stories.Albums; + +searchPostsFlood#3e0b5b6a flags:# query_is_free:flags.0?true total_daily:int remains:int wait_till:flags.1?int stars_amount:long = SearchPostsFlood; + +payments.uniqueStarGiftValueInfo#512fe446 flags:# last_sale_on_fragment:flags.1?true value_is_average:flags.6?true currency:string value:long initial_sale_date:int initial_sale_stars:long initial_sale_price:long last_sale_date:flags.0?int last_sale_price:flags.0?long floor_price:flags.2?long average_price:flags.3?long listed_count:flags.4?int fragment_listed_count:flags.5?int fragment_listed_url:flags.5?string = payments.UniqueStarGiftValueInfo; + +profileTabPosts#b98cd696 = ProfileTab; +profileTabGifts#4d4bd46a = ProfileTab; +profileTabMedia#72c64955 = ProfileTab; +profileTabFiles#ab339c00 = ProfileTab; +profileTabMusic#9f27d26e = ProfileTab; +profileTabVoice#e477092e = ProfileTab; +profileTabLinks#d3656499 = ProfileTab; +profileTabGifs#a2c0f695 = ProfileTab; + +users.savedMusicNotModified#e3878aa4 count:int = users.SavedMusic; +users.savedMusic#34a2f297 count:int documents:Vector = users.SavedMusic; + +account.savedMusicIdsNotModified#4fc81d6e = account.SavedMusicIds; +account.savedMusicIds#998d6636 ids:Vector = account.SavedMusicIds; + +payments.checkCanSendGiftResultOk#374fa7ad = payments.CheckCanSendGiftResult; +payments.checkCanSendGiftResultFail#d5e58274 reason:TextWithEntities = payments.CheckCanSendGiftResult; + +inputChatThemeEmpty#83268483 = InputChatTheme; +inputChatTheme#c93de95c emoticon:string = InputChatTheme; +inputChatThemeUniqueGift#87e5dfe4 slug:string = InputChatTheme; + +starGiftUpgradePrice#99ea331d date:int upgrade_stars:long = StarGiftUpgradePrice; + +groupCallMessage#1a8afc7e flags:# from_admin:flags.1?true id:int from_id:Peer date:int message:TextWithEntities paid_message_stars:flags.0?long = GroupCallMessage; + +groupCallDonor#ee430c85 flags:# top:flags.0?true my:flags.1?true peer_id:flags.3?Peer stars:long = GroupCallDonor; + +phone.groupCallStars#9d1dbd26 total_stars:long top_donors:Vector chats:Vector users:Vector = phone.GroupCallStars; + +recentStory#711d692d flags:# live:flags.0?true max_id:flags.1?int = RecentStory; + +auctionBidLevel#310240cc pos:int amount:long date:int = AuctionBidLevel; + +starGiftAuctionStateNotModified#fe333952 = StarGiftAuctionState; +starGiftAuctionState#771a4e66 version:int start_date:int end_date:int min_bid_amount:long bid_levels:Vector top_bidders:Vector next_round_at:int last_gift_num:int gifts_left:int current_round:int total_rounds:int rounds:Vector = StarGiftAuctionState; +starGiftAuctionStateFinished#972dabbf flags:# start_date:int end_date:int average_price:long listed_count:flags.0?int fragment_listed_count:flags.1?int fragment_listed_url:flags.1?string = StarGiftAuctionState; + +starGiftAuctionUserState#2eeed1c4 flags:# returned:flags.1?true bid_amount:flags.0?long bid_date:flags.0?int min_bid_amount:flags.0?long bid_peer:flags.0?Peer acquired_count:int = StarGiftAuctionUserState; + +payments.starGiftAuctionState#6b39f4ec gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState timeout:int users:Vector chats:Vector = payments.StarGiftAuctionState; + +starGiftAuctionAcquiredGift#42b00348 flags:# name_hidden:flags.0?true peer:Peer date:int bid_amount:long round:int pos:int message:flags.1?TextWithEntities gift_num:flags.2?int = StarGiftAuctionAcquiredGift; + +payments.starGiftAuctionAcquiredGifts#7d5bd1f0 gifts:Vector users:Vector chats:Vector = payments.StarGiftAuctionAcquiredGifts; + +starGiftActiveAuctionState#d31bc45d gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState = StarGiftActiveAuctionState; + +payments.starGiftActiveAuctionsNotModified#db33dad0 = payments.StarGiftActiveAuctions; +payments.starGiftActiveAuctions#aef6abbc auctions:Vector users:Vector chats:Vector = payments.StarGiftActiveAuctions; + +inputStarGiftAuction#2e16c98 gift_id:long = InputStarGiftAuction; +inputStarGiftAuctionSlug#7ab58308 slug:string = InputStarGiftAuction; + +passkey#98613ebf flags:# id:string name:string date:int software_emoji_id:flags.0?long last_usage_date:flags.1?int = Passkey; + +account.passkeys#f8e0aa1c passkeys:Vector = account.Passkeys; + +account.passkeyRegistrationOptions#e16b5ce1 options:DataJSON = account.PasskeyRegistrationOptions; + +auth.passkeyLoginOptions#e2037789 options:DataJSON = auth.PasskeyLoginOptions; + +inputPasskeyResponseRegister#3e63935c client_data:DataJSON attestation_data:bytes = InputPasskeyResponse; +inputPasskeyResponseLogin#c31fc14a client_data:DataJSON authenticator_data:bytes signature:bytes user_handle:string = InputPasskeyResponse; + +inputPasskeyCredentialPublicKey#3c27b78f id:string raw_id:string response:InputPasskeyResponse = InputPasskeyCredential; +inputPasskeyCredentialFirebasePNV#5b1ccb28 pnv_token:string = InputPasskeyCredential; + +starGiftBackground#aff56398 center_color:int edge_color:int text_color:int = StarGiftBackground; + +starGiftAuctionRound#3aae0528 num:int duration:int = StarGiftAuctionRound; +starGiftAuctionRoundExtendable#aa021e5 num:int duration:int extend_top:int extend_window:int = StarGiftAuctionRound; + +payments.starGiftUpgradeAttributes#46c6e36f attributes:Vector = payments.StarGiftUpgradeAttributes; + +messages.emojiGameOutcome#da2ad647 seed:bytes stake_ton_amount:long ton_amount:long = messages.EmojiGameOutcome; + +messages.emojiGameUnavailable#59e65335 = messages.EmojiGameInfo; +messages.emojiGameDiceInfo#44e56023 flags:# game_hash:string prev_stake:long current_streak:int params:Vector plays_left:flags.0?int = messages.EmojiGameInfo; + +starGiftAttributeRarity#36437737 permille:int = StarGiftAttributeRarity; +starGiftAttributeRarityUncommon#dbce6389 = StarGiftAttributeRarity; +starGiftAttributeRarityRare#f08d516b = StarGiftAttributeRarity; +starGiftAttributeRarityEpic#78fbf3a8 = StarGiftAttributeRarity; +starGiftAttributeRarityLegendary#cef7e7a8 = StarGiftAttributeRarity; + +keyboardButtonStyle#4fdd3430 flags:# bg_primary:flags.0?true bg_danger:flags.1?true bg_success:flags.2?true icon:flags.3?long = KeyboardButtonStyle; + +inputMessageReadMetric#402b4495 msg_id:int view_id:long time_in_view_ms:int active_time_in_view_ms:int height_to_viewport_ratio_permille:int seen_range_ratio_permille:int = InputMessageReadMetric; + +bots.exportedBotToken#3c60b621 token:string = bots.ExportedBotToken; + +bots.requestedButton#f13bbcd7 webapp_req_id:string = bots.RequestedButton; + +messages.composedMessageWithAI#90d7adfa flags:# result_text:TextWithEntities diff_text:flags.0?TextWithEntities = messages.ComposedMessageWithAI; + +stats.pollStats#2999beed votes_graph:StatsGraph = stats.PollStats; + +inputAiComposeToneDefault#1fe9a9bf tone:string = InputAiComposeTone; +inputAiComposeToneID#773c080 id:long access_hash:long = InputAiComposeTone; +inputAiComposeToneSlug#1fa01357 slug:string = InputAiComposeTone; + +aiComposeTone#cff63ea9 flags:# creator:flags.0?true id:long access_hash:long slug:string title:string emoji_id:flags.1?long prompt:flags.4?string installs_count:flags.2?int author_id:flags.3?long example_english:flags.5?AiComposeToneExample = AiComposeTone; +aiComposeToneDefault#9bad6414 tone:string emoji_id:long title:string = AiComposeTone; + +aicompose.tonesNotModified#c1f46103 = aicompose.Tones; +aicompose.tones#6c9d0efe hash:long tones:Vector users:Vector = aicompose.Tones; + +aiComposeToneExample#f1d628ec from:TextWithEntities to:TextWithEntities = AiComposeToneExample; + +bots.accessSettings#dd1fbf93 flags:# restricted:flags.0?true add_users:flags.1?Vector = bots.AccessSettings; + +messages.chatInviteJoinResultOk#445663a7 updates:Updates = messages.ChatInviteJoinResult; +messages.chatInviteJoinResultWebView#2f51c337 bot_id:long webview:WebViewResult users:Vector = messages.ChatInviteJoinResult; + +joinChatBotResultApproved#ae152a69 = JoinChatBotResult; +joinChatBotResultDeclined#efa0194 = JoinChatBotResult; +joinChatBotResultQueued#98a3a840 = JoinChatBotResult; +joinChatBotResultWebView#d6e3b813 url:string = JoinChatBotResult; + +webDomainException#933ca597 flags:# domain:string url:string title:string favicon:flags.0?long = WebDomainException; + +account.webBrowserSettingsNotModified#c31c8f4e = account.WebBrowserSettings; +account.webBrowserSettings#79eb8cb3 flags:# open_external_browser:flags.0?true display_close_button:flags.1?true external_exceptions:Vector inapp_exceptions:Vector hash:long = account.WebBrowserSettings; + +inputRichFilePhoto#9b00622b id:string photo:InputPhoto = InputRichFile; +inputRichFileDocument#83281dbd id:string document:InputDocument = InputRichFile; + +inputRichMessage#e4c449fc flags:# rtl:flags.0?true noautolink:flags.1?true blocks:Vector photos:flags.2?Vector documents:flags.3?Vector users:flags.4?Vector = InputRichMessage; +inputRichMessageHTML#dacb836a flags:# rtl:flags.0?true noautolink:flags.1?true html:string files:flags.2?Vector = InputRichMessage; +inputRichMessageMarkdown#4b572c flags:# rtl:flags.0?true noautolink:flags.1?true markdown:string files:flags.2?Vector = InputRichMessage; + +richMessage#baf39d8b flags:# rtl:flags.0?true part:flags.1?true blocks:Vector photos:Vector documents:Vector = RichMessage; + +---functions--- + +invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; +invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; +invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; +invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; +invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; +invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; +invokeWithBusinessConnection#dd289f8e {X:Type} connection_id:string query:!X = X; +invokeWithGooglePlayIntegrity#1df92984 {X:Type} nonce:string token:string query:!X = X; +invokeWithApnsSecret#0dae54f8 {X:Type} nonce:string secret:string query:!X = X; +invokeWithReCaptcha#adbb0f94 {X:Type} token:string query:!X = X; + +auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; +auth.signUp#aac7b717 flags:# no_joined_notifications:flags.0?true phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +auth.signIn#8d52a951 flags:# phone_number:string phone_code_hash:string phone_code:flags.0?string email_verification:flags.1?EmailVerification = auth.Authorization; +auth.logOut#3e72ba19 = auth.LoggedOut; +auth.resetAuthorizations#9fab0d1a = Bool; +auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; +auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; +auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; +auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; +auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; +auth.resendCode#cae47523 flags:# phone_number:string phone_code_hash:string reason:flags.0?string = auth.SentCode; +auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; +auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; +auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; +auth.acceptLoginToken#e894ad4d token:bytes = Authorization; +auth.checkRecoveryPassword#d36bf79 code:string = Bool; +auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; +auth.requestFirebaseSms#8e39261e flags:# phone_number:string phone_code_hash:string safety_net_token:flags.0?string play_integrity_token:flags.2?string ios_push_secret:flags.1?string = Bool; +auth.resetLoginEmail#7e960193 phone_number:string phone_code_hash:string = auth.SentCode; +auth.reportMissingCode#cb9deff6 phone_number:string phone_code_hash:string mnc:string = Bool; +auth.checkPaidAuth#56e59f9c phone_number:string phone_code_hash:string form_id:long = auth.SentCode; +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; + +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; +account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; +account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; +account.resetNotifySettings#db7e1747 = Bool; +account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; +account.updateStatus#6628562c offline:Bool = Bool; +account.getWallPapers#7967d36 hash:long = account.WallPapers; +account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; +account.checkUsername#2714d86c username:string = Bool; +account.updateUsername#3e0bdd7c username:string = User; +account.getPrivacy#dadbc950 key:InputPrivacyKey = account.PrivacyRules; +account.setPrivacy#c9f81ce8 key:InputPrivacyKey rules:Vector = account.PrivacyRules; +account.deleteAccount#a2c0cf74 flags:# reason:string password:flags.0?InputCheckPasswordSRP = Bool; +account.getAccountTTL#8fc711d = AccountDaysTTL; +account.setAccountTTL#2442485e ttl:AccountDaysTTL = Bool; +account.sendChangePhoneCode#82574ae5 phone_number:string settings:CodeSettings = auth.SentCode; +account.changePhone#70c32edb phone_number:string phone_code_hash:string phone_code:string = User; +account.updateDeviceLocked#38df3532 period:int = Bool; +account.getAuthorizations#e320c158 = account.Authorizations; +account.resetAuthorization#df77f3bc hash:long = Bool; +account.getPassword#548a30f5 = account.Password; +account.getPasswordSettings#9cd4eaf9 password:InputCheckPasswordSRP = account.PasswordSettings; +account.updatePasswordSettings#a59b102f password:InputCheckPasswordSRP new_settings:account.PasswordInputSettings = Bool; +account.sendConfirmPhoneCode#1b3faa88 hash:string settings:CodeSettings = auth.SentCode; +account.confirmPhone#5f2178c3 phone_code_hash:string phone_code:string = Bool; +account.getTmpPassword#449e0b51 password:InputCheckPasswordSRP period:int = account.TmpPassword; +account.getWebAuthorizations#182e6d6f = account.WebAuthorizations; +account.resetWebAuthorization#2d01b9ef hash:long = Bool; +account.resetWebAuthorizations#682d2594 = Bool; +account.getAllSecureValues#b288bc7d = Vector; +account.getSecureValue#73665bc2 types:Vector = Vector; +account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; +account.deleteSecureValue#b880bc4b types:Vector = Bool; +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; +account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; +account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; +account.sendVerifyEmailCode#98e037bb purpose:EmailVerifyPurpose email:string = account.SentEmailCode; +account.verifyEmail#32da4cf purpose:EmailVerifyPurpose verification:EmailVerification = account.EmailVerified; +account.initTakeoutSession#8ef3eab0 flags:# contacts:flags.0?true message_users:flags.1?true message_chats:flags.2?true message_megagroups:flags.3?true message_channels:flags.4?true files:flags.5?true file_max_size:flags.5?long = account.Takeout; +account.finishTakeoutSession#1d2652ee flags:# success:flags.0?true = Bool; +account.confirmPasswordEmail#8fdf1920 code:string = Bool; +account.resendPasswordEmail#7a7f2a15 = Bool; +account.cancelPasswordEmail#c1cbd5b6 = Bool; +account.getContactSignUpNotification#9f07c728 = Bool; +account.setContactSignUpNotification#cff43f61 silent:Bool = Bool; +account.getNotifyExceptions#53577479 flags:# compare_sound:flags.1?true compare_stories:flags.2?true peer:flags.0?InputNotifyPeer = Updates; +account.getWallPaper#fc8ddbea wallpaper:InputWallPaper = WallPaper; +account.uploadWallPaper#e39a8f03 flags:# for_chat:flags.0?true file:InputFile mime_type:string settings:WallPaperSettings = WallPaper; +account.saveWallPaper#6c5a5b37 wallpaper:InputWallPaper unsave:Bool settings:WallPaperSettings = Bool; +account.installWallPaper#feed5769 wallpaper:InputWallPaper settings:WallPaperSettings = Bool; +account.resetWallPapers#bb3b9804 = Bool; +account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; +account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; +account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; +account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; +account.getTheme#3a5869ec format:string theme:InputTheme = Theme; +account.getThemes#7206e458 format:string hash:long = account.Themes; +account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; +account.getContentSettings#8b9b4dae = account.ContentSettings; +account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; +account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; +account.resetPassword#9308ce1b = account.ResetPasswordResult; +account.declinePasswordReset#4c9409f6 = Bool; +account.getChatThemes#d638de89 hash:long = account.Themes; +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; +account.changeAuthorizationSettings#40f48462 flags:# confirmed:flags.3?true hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; +account.getSavedRingtones#e1902288 hash:long = account.SavedRingtones; +account.saveRingtone#3dea5b03 id:InputDocument unsave:Bool = account.SavedRingtone; +account.uploadRingtone#831a83a2 file:InputFile file_name:string mime_type:string = Document; +account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; +account.getDefaultEmojiStatuses#d6753386 hash:long = account.EmojiStatuses; +account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; +account.clearRecentEmojiStatuses#18201aae = Bool; +account.reorderUsernames#ef500eab order:Vector = Bool; +account.toggleUsername#58d6b376 username:string active:Bool = Bool; +account.getDefaultProfilePhotoEmojis#e2750328 hash:long = EmojiList; +account.getDefaultGroupPhotoEmojis#915860ae hash:long = EmojiList; +account.getAutoSaveSettings#adcbbcda = account.AutoSaveSettings; +account.saveAutoSaveSettings#d69b8361 flags:# users:flags.0?true chats:flags.1?true broadcasts:flags.2?true peer:flags.3?InputPeer settings:AutoSaveSettings = Bool; +account.deleteAutoSaveExceptions#53bc0020 = Bool; +account.invalidateSignInCodes#ca8ae8ba codes:Vector = Bool; +account.updateColor#684d214e flags:# for_profile:flags.1?true color:flags.2?PeerColor = Bool; +account.getDefaultBackgroundEmojis#a60ab9ce hash:long = EmojiList; +account.getChannelDefaultEmojiStatuses#7727a7d5 hash:long = account.EmojiStatuses; +account.getChannelRestrictedStatusEmojis#35a9e0d5 hash:long = EmojiList; +account.updateBusinessWorkHours#4b00e066 flags:# business_work_hours:flags.0?BusinessWorkHours = Bool; +account.updateBusinessLocation#9e6b131a flags:# geo_point:flags.1?InputGeoPoint address:flags.0?string = Bool; +account.updateBusinessGreetingMessage#66cdafc4 flags:# message:flags.0?InputBusinessGreetingMessage = Bool; +account.updateBusinessAwayMessage#a26a7fa5 flags:# message:flags.0?InputBusinessAwayMessage = Bool; +account.updateConnectedBot#66a08c7e flags:# deleted:flags.1?true rights:flags.0?BusinessBotRights bot:InputUser recipients:InputBusinessBotRecipients = Updates; +account.getConnectedBots#4ea4c80f = account.ConnectedBots; +account.getBotBusinessConnection#76a86270 connection_id:string = Updates; +account.updateBusinessIntro#a614d034 flags:# intro:flags.0?InputBusinessIntro = Bool; +account.toggleConnectedBotPaused#646e1097 peer:InputPeer paused:Bool = Bool; +account.disablePeerConnectedBot#5e437ed9 peer:InputPeer = Bool; +account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool; +account.createBusinessChatLink#8851e68e link:InputBusinessChatLink = BusinessChatLink; +account.editBusinessChatLink#8c3410af slug:string link:InputBusinessChatLink = BusinessChatLink; +account.deleteBusinessChatLink#60073674 slug:string = Bool; +account.getBusinessChatLinks#6f70dde1 = account.BusinessChatLinks; +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; +account.updatePersonalChannel#d94305e0 channel:InputChannel = Bool; +account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; +account.getReactionsNotifySettings#6dd654c = ReactionsNotifySettings; +account.setReactionsNotifySettings#316ce548 settings:ReactionsNotifySettings = ReactionsNotifySettings; +account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; +account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; +account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; +account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; +account.saveMusic#b26732a9 flags:# unsave:flags.0?true id:InputDocument after_id:flags.1?InputDocument = Bool; +account.getSavedMusicIds#e09d5faf hash:long = account.SavedMusicIds; +account.getUniqueGiftChatThemes#e42ce9c9 offset:string limit:int hash:long = account.ChatThemes; +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; +account.getPasskeys#ea1f0c52 = account.Passkeys; +account.deletePasskey#f5b5563f id:string = Bool; +account.confirmBotConnection#67ed1f68 bot_id:InputUser = Bool; +account.getWebBrowserSettings#56655768 hash:long = account.WebBrowserSettings; +account.updateWebBrowserSettings#9adf82fe flags:# open_external_browser:flags.0?true display_close_button:flags.1?true = account.WebBrowserSettings; +account.toggleWebBrowserSettingsException#60ed4229 flags:# delete:flags.1?true open_external_browser:flags.0?Bool url:string = Updates; +account.deleteWebBrowserSettingsExceptions#86a0765d = account.WebBrowserSettings; + +users.getUsers#d91a548 id:Vector = Vector; +users.getFullUser#b60f5918 id:InputUser = users.UserFull; +users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; +users.getRequirementsToContact#d89a83a3 id:Vector = Vector; +users.getSavedMusic#788d7fe3 id:InputUser offset:int limit:int hash:long = users.SavedMusic; +users.getSavedMusicByID#7573a4e9 id:InputUser documents:Vector = users.SavedMusic; +users.suggestBirthday#fc533372 id:InputUser birthday:Birthday = Updates; + +contacts.getContactIDs#7adc669d hash:long = Vector; +contacts.getStatuses#c4a353ee = Vector; +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; +contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; +contacts.deleteContacts#96a0e00 id:Vector = Updates; +contacts.deleteByPhones#1013fd9e phones:Vector = Bool; +contacts.block#2e2e8734 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.unblock#b550d328 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; +contacts.getBlocked#9a868f80 flags:# my_stories_from:flags.0?true offset:int limit:int = contacts.Blocked; +contacts.search#5f58d0f flags:# broadcasts:flags.0?true bots:flags.1?true q:string limit:int = contacts.Found; +contacts.resolveUsername#725afbbc flags:# username:string referer:flags.0?string = contacts.ResolvedPeer; +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true bots_app:flags.16?true bots_guestchat:flags.17?true offset:int limit:int hash:long = contacts.TopPeers; +contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; +contacts.resetSaved#879537f1 = Bool; +contacts.getSaved#82f1e39f = Vector; +contacts.toggleTopPeers#8514bdda enabled:Bool = Bool; +contacts.addContact#d9ba2e54 flags:# add_phone_privacy_exception:flags.0?true id:InputUser first_name:string last_name:string phone:string note:flags.1?TextWithEntities = Updates; +contacts.acceptContact#f831a20f id:InputUser = Updates; +contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; +contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; +contacts.exportContactToken#f8654027 = ExportedContactToken; +contacts.importContactToken#13005788 token:string = User; +contacts.editCloseFriends#ba6705f0 id:Vector = Bool; +contacts.setBlocked#94c65c76 flags:# my_stories_from:flags.0?true id:Vector limit:int = Bool; +contacts.getBirthdays#daeda864 = contacts.ContactBirthdays; +contacts.getSponsoredPeers#b6c8c393 q:string = contacts.SponsoredPeers; +contacts.updateContactNote#139f63fb id:InputUser note:TextWithEntities = Bool; + +messages.getMessages#63c66506 id:Vector = messages.Messages; +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; +messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.search#29ee847a flags:# peer:InputPeer q:string from_id:flags.0?InputPeer saved_peer_id:flags.2?InputPeer saved_reaction:flags.3?Vector top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; +messages.receivedMessages#5a954c0 max_id:int = Vector; +messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; +messages.sendMessage#fef48f62 flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost rich_message:flags.23?InputRichMessage = Updates; +messages.sendMedia#330e77f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; +messages.forwardMessages#13704a7c flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true allow_paid_floodskip:flags.19?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer top_msg_id:flags.9?int reply_to:flags.22?InputReplyTo schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long video_timestamp:flags.20?int allow_paid_stars:flags.21?long suggested_post:flags.23?SuggestedPost = Updates; +messages.reportSpam#cf1592db peer:InputPeer = Bool; +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; +messages.report#fc78af9b peer:InputPeer id:Vector option:bytes message:string = ReportResult; +messages.getChats#49e9528f id:Vector = messages.Chats; +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; +messages.addChatUser#cbc6d107 chat_id:long user_id:InputUser fwd_limit:int = messages.InvitedUsers; +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; +messages.createChat#92ceddd4 flags:# users:Vector title:string ttl_period:flags.0?int = messages.InvitedUsers; +messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; +messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; +messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; +messages.discardEncryption#f393aea0 flags:# delete_history:flags.0?true chat_id:int = Bool; +messages.setEncryptedTyping#791451ed peer:InputEncryptedChat typing:Bool = Bool; +messages.readEncryptedHistory#7f4b690a peer:InputEncryptedChat max_date:int = Bool; +messages.sendEncrypted#44fa7a15 flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.sendEncryptedFile#5559481d flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes file:InputEncryptedFile = messages.SentEncryptedMessage; +messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; +messages.receivedQueue#55a5bb66 max_qts:int = Vector; +messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; +messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; +messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; +messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; +messages.getWebPagePreview#570d6f6f flags:# message:string entities:flags.3?Vector = messages.WebPagePreview; +messages.exportChatInvite#a455de90 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string subscription_pricing:flags.5?StarsSubscriptionPricing = ExportedChatInvite; +messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; +messages.importChatInvite#de91436e hash:string = messages.ChatInviteJoinResult; +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; +messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; +messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; +messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; +messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; +messages.migrateChat#a2875319 chat_id:long = Updates; +messages.searchGlobal#4bc6589a flags:# broadcasts_only:flags.1?true groups_only:flags.2?true users_only:flags.3?true folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; +messages.reorderStickerSets#78337739 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Bool; +messages.getDocumentByHash#b1f2061f sha256:bytes size:long mime_type:string = Document; +messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; +messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; +messages.setInlineBotResults#bb12a419 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM switch_webview:flags.4?InlineBotWebView = Bool; +messages.sendInlineBotResult#c0cf7646 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to:flags.0?InputReplyTo random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut allow_paid_stars:flags.21?long = Updates; +messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; +messages.editMessage#b106e66c flags:# no_webpage:flags.1?true invert_media:flags.16?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int schedule_repeat_period:flags.18?int quick_reply_shortcut_id:flags.17?int rich_message:flags.23?InputRichMessage = Updates; +messages.editInlineBotMessage#a423bb51 flags:# no_webpage:flags.1?true invert_media:flags.16?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector rich_message:flags.23?InputRichMessage = Bool; +messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer; +messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; +messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; +messages.saveDraft#ad0fa15c flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo peer:InputPeer message:string entities:flags.3?Vector media:flags.5?InputMedia effect:flags.7?long suggested_post:flags.8?SuggestedPost rich_message:flags.9?InputRichMessage = Bool; +messages.getAllDrafts#6a3f8d65 = Updates; +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; +messages.readFeaturedStickers#5b118126 id:Vector = Bool; +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; +messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; +messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; +messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true emojis:flags.1?true offset_id:long limit:int = messages.ArchivedStickers; +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; +messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; +messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; +messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; +messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; +messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; +messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; +messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; +messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?string shipping_options:flags.1?Vector = Bool; +messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; +messages.uploadMedia#14967978 flags:# business_connection_id:flags.0?string peer:InputPeer media:InputMedia = MessageMedia; +messages.sendScreenshotNotification#a1405817 peer:InputPeer reply_to:InputReplyTo random_id:long = Updates; +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; +messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; +messages.getUnreadMentions#f107e790 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readMentions#36e5bf4d flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; +messages.sendMultiMedia#1bf89d74 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long = Updates; +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSplitRanges#1cff7e08 = Vector; +messages.markDialogUnread#8c5006f8 flags:# unread:flags.0?true parent_peer:flags.1?InputPeer peer:InputDialogPeer = Bool; +messages.getDialogUnreadMarks#21202222 flags:# parent_peer:flags.0?InputPeer = Vector; +messages.clearAllDrafts#7e58ee9c = Bool; +messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1?true pm_oneside:flags.2?true peer:InputPeer id:int = Updates; +messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; +messages.getPollResults#eda3e33b peer:InputPeer msg_id:int poll_hash:long = Updates; +messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; +messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; +messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; +messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; +messages.getEmojiKeywordsDifference#1508b6af lang_code:string from_version:int = EmojiKeywordsDifference; +messages.getEmojiKeywordsLanguages#4e9963b2 lang_codes:Vector = Vector; +messages.getEmojiURL#d5b10c26 lang_code:string = EmojiURL; +messages.getSearchCounters#1bbcf300 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer top_msg_id:flags.0?int filters:Vector = Vector; +messages.requestUrlAuth#894cc99c flags:# peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string in_app_origin:flags.3?string = UrlAuthResult; +messages.acceptUrlAuth#67a3f0de flags:# write_allowed:flags.0?true share_phone_number:flags.3?true peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string match_code:flags.4?string = UrlAuthResult; +messages.hidePeerSettingsBar#4facb138 peer:InputPeer = Bool; +messages.getScheduledHistory#f516760b peer:InputPeer hash:long = messages.Messages; +messages.getScheduledMessages#bdbb0464 peer:InputPeer id:Vector = messages.Messages; +messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; +messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; +messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; +messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; +messages.getDialogFilters#efd48c89 = messages.DialogFilters; +messages.getSuggestedDialogFilters#a29cd42c = Vector; +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; +messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; +messages.unpinAllMessages#62dd747 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; +messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; +messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; +messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:string media:InputMedia = MessageMedia; +messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; +messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; +messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true subscription_expired:flags.3?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; +messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; +messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; +messages.setChatTheme#81202c9 peer:InputPeer theme:InputChatTheme = Updates; +messages.getMessageReadParticipants#31c1c44f peer:InputPeer msg_id:int = Vector; +messages.getSearchResultsCalendar#6aa3f6bd flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; +messages.getSearchResultsPositions#9c7f2f10 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; +messages.toggleNoForwards#b2081a35 flags:# peer:InputPeer enabled:Bool request_msg_id:flags.0?int = Updates; +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; +messages.sendReaction#d30d78d4 flags:# big:flags.1?true add_to_recent:flags.2?true peer:InputPeer msg_id:int reaction:flags.0?Vector = Updates; +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; +messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = messages.MessageReactionsList; +messages.setChatAvailableReactions#864b2581 flags:# peer:InputPeer available_reactions:ChatReactions reactions_limit:flags.0?int paid_enabled:flags.1?Bool = Updates; +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; +messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool; +messages.translateText#a5eec345 flags:# peer:flags.0?InputPeer id:flags.0?Vector text:flags.1?Vector to_lang:string tone:flags.2?string = messages.TranslatedText; +messages.getUnreadReactions#bd7f90ac flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#9ec44f93 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; +messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; +messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; +messages.toggleBotInAttachMenu#69f59d69 flags:# write_allowed:flags.0?true bot:InputUser enabled:Bool = Bool; +messages.requestWebView#269dc2c1 flags:# from_bot_menu:flags.4?true silent:flags.5?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser url:flags.1?string start_param:flags.3?string theme_params:flags.2?DataJSON platform:string reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = WebViewResult; +messages.prolongWebView#b0d81a83 flags:# silent:flags.5?true peer:InputPeer bot:InputUser query_id:long reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = Bool; +messages.requestSimpleWebView#413a3e73 flags:# from_switch_webview:flags.1?true from_side_menu:flags.2?true compact:flags.7?true fullscreen:flags.8?true bot:InputUser url:flags.3?string start_param:flags.4?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent; +messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates; +messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio; +messages.rateTranscribedAudio#7f1d072f peer:InputPeer msg_id:int transcription_id:long good:Bool = Bool; +messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; +messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; +messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; +messages.reportReaction#3f64c076 peer:InputPeer id:int reaction_peer:InputPeer = Bool; +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; +messages.clearRecentReactions#9dfeefb4 = Bool; +messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; +messages.sendBotRequestedPeer#6c5cf2a7 flags:# peer:InputPeer msg_id:flags.0?int webapp_req_id:flags.1?string button_id:int requested_peers:Vector = Updates; +messages.getEmojiGroups#7488ce5b hash:int = messages.EmojiGroups; +messages.getEmojiStatusGroups#2ecd56cd hash:int = messages.EmojiGroups; +messages.getEmojiProfilePhotoGroups#21a548f3 hash:int = messages.EmojiGroups; +messages.searchCustomEmoji#2c11c0d7 emoticon:string hash:long = EmojiList; +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; +messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; +messages.requestAppWebView#53618bce flags:# write_allowed:flags.0?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = WebViewResult; +messages.setChatWallPaper#8ffacae1 flags:# for_both:flags.3?true revert:flags.4?true peer:InputPeer wallpaper:flags.0?InputWallPaper settings:flags.2?WallPaperSettings id:flags.1?int = Updates; +messages.searchEmojiStickerSets#92b4494c flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; +messages.getSavedDialogs#1e91fc99 flags:# exclude_pinned:flags.0?true parent_peer:flags.1?InputPeer offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; +messages.getSavedHistory#998ab009 flags:# parent_peer:flags.0?InputPeer peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.deleteSavedHistory#4dc5085f flags:# parent_peer:flags.0?InputPeer peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; +messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; +messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; +messages.reorderPinnedSavedDialogs#8b716587 flags:# force:flags.0?true order:Vector = Bool; +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; +messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; +messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; +messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; +messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; +messages.reorderQuickReplies#60331907 order:Vector = Bool; +messages.checkQuickReplyShortcut#f1d0fbd3 shortcut:string = Bool; +messages.editQuickReplyShortcut#5c003cef shortcut_id:int shortcut:string = Bool; +messages.deleteQuickReplyShortcut#3cc04740 shortcut_id:int = Bool; +messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; +messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; +messages.deleteQuickReplyMessages#e105e910 shortcut_id:int id:Vector = Updates; +messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool; +messages.getMyStickers#d0b5e1fc offset_id:long limit:int = messages.MyStickers; +messages.getEmojiStickerGroups#1dd840f5 hash:int = messages.EmojiGroups; +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; +messages.editFactCheck#589ee75 peer:InputPeer msg_id:int text:TextWithEntities = Updates; +messages.deleteFactCheck#d1da940c peer:InputPeer msg_id:int = Updates; +messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; +messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; +messages.sendPaidReaction#58bbcb50 flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?PaidReactionPrivacy = Updates; +messages.togglePaidReactionPrivacy#435885b5 peer:InputPeer msg_id:int private:PaidReactionPrivacy = Bool; +messages.getPaidReactionPrivacy#472455aa = Updates; +messages.viewSponsoredMessage#269e3643 random_id:bytes = Bool; +messages.clickSponsoredMessage#8235057e flags:# media:flags.0?true fullscreen:flags.1?true random_id:bytes = Bool; +messages.reportSponsoredMessage#12cbf0c4 random_id:bytes option:bytes = channels.SponsoredMessageReportResult; +messages.getSponsoredMessages#3d6ce850 flags:# peer:InputPeer msg_id:flags.0?int = messages.SponsoredMessages; +messages.savePreparedInlineMessage#f21f7f2f flags:# result:InputBotInlineResult user_id:InputUser peer_types:flags.0?Vector = messages.BotPreparedInlineMessage; +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; +messages.searchStickers#29b1c66a flags:# emojis:flags.0?true q:string emoticon:string lang_code:Vector offset:int limit:int hash:long = messages.FoundStickers; +messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = Bool; +messages.getSavedDialogsByID#6f6f9c96 flags:# parent_peer:flags.1?InputPeer ids:Vector = messages.SavedDialogs; +messages.readSavedHistory#ba4a3b5b parent_peer:InputPeer peer:InputPeer max_id:int = Bool; +messages.toggleTodoCompleted#d3e03124 peer:InputPeer msg_id:int completed:Vector incompleted:Vector = Updates; +messages.appendTodoList#21a61057 peer:InputPeer msg_id:int list:Vector = Updates; +messages.toggleSuggestedPostApproval#8107455c flags:# reject:flags.1?true peer:InputPeer msg_id:int schedule_date:flags.0?int reject_comment:flags.2?string = Updates; +messages.getForumTopics#3ba47bff flags:# peer:InputPeer q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; +messages.getForumTopicsByID#af0a4a08 peer:InputPeer topics:Vector = messages.ForumTopics; +messages.editForumTopic#cecc1134 flags:# peer:InputPeer topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; +messages.updatePinnedForumTopic#175df251 peer:InputPeer topic_id:int pinned:Bool = Updates; +messages.reorderPinnedForumTopics#e7841f0 flags:# force:flags.0?true peer:InputPeer order:Vector = Updates; +messages.createForumTopic#2f98c3d5 flags:# title_missing:flags.4?true peer:InputPeer title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; +messages.deleteTopicHistory#d2816f10 peer:InputPeer top_msg_id:int = messages.AffectedHistory; +messages.getEmojiGameInfo#fb7e8ca7 = messages.EmojiGameInfo; +messages.summarizeText#abbbd346 flags:# peer:InputPeer id:int to_lang:flags.0?string tone:flags.2?string = TextWithEntities; +messages.editChatCreator#f743b857 peer:InputPeer user_id:InputUser password:InputCheckPasswordSRP = Updates; +messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User; +messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates; +messages.declineUrlAuth#35436bbc url:string = Bool; +messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool; +messages.composeMessageWithAI#daecc589 flags:# proofread:flags.0?true emojify:flags.3?true text:TextWithEntities translate_to_lang:flags.1?string tone:flags.2?InputAiComposeTone = messages.ComposedMessageWithAI; +messages.reportReadMetrics#4067c5e6 peer:InputPeer metrics:Vector = Bool; +messages.reportMusicListen#ddbcd819 id:InputDocument listened_duration:int = Bool; +messages.addPollAnswer#19bc4b6d peer:InputPeer msg_id:int answer:PollAnswer = Updates; +messages.deletePollAnswer#ac8505a5 peer:InputPeer msg_id:int option:bytes = Updates; +messages.getUnreadPollVotes#43286cf2 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readPollVotes#1720b4d8 flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; +messages.setBotGuestChatResult#b8f106e3 query_id:long result:InputBotInlineResult = InputBotInlineMessageID; +messages.deleteParticipantReactions#a0b80cf8 peer:InputPeer participant:InputPeer = Bool; +messages.deleteParticipantReaction#e3b7f82c peer:InputPeer msg_id:int participant:InputPeer = Updates; +messages.getPersonalChannelHistory#55fb0996 user_id:InputUser limit:int max_id:int min_id:int hash:long = messages.Messages; +messages.getRichMessage#501569cf peer:InputPeer id:int = messages.Messages; + +updates.getState#edd4882a = updates.State; +updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; +updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; + +photos.updateProfilePhoto#9e82039 flags:# fallback:flags.0?true bot:flags.1?InputUser id:InputPhoto = photos.Photo; +photos.uploadProfilePhoto#388a3b5 flags:# fallback:flags.3?true bot:flags.5?InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.4?VideoSize = photos.Photo; +photos.deletePhotos#87cf7f2f id:Vector = Vector; +photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; +photos.uploadContactProfilePhoto#e14c4a71 flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.5?VideoSize = photos.Photo; + +upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; +upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; +upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool; +upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile; +upload.getCdnFile#395f69da file_token:bytes offset:long limit:int = upload.CdnFile; +upload.reuploadCdnFile#9b2754a8 file_token:bytes request_token:bytes = Vector; +upload.getCdnFileHashes#91dc3f31 file_token:bytes offset:long = Vector; +upload.getFileHashes#9156982a location:InputFileLocation offset:long = Vector; + +help.getConfig#c4f9186b = Config; +help.getNearestDc#1fb33026 = NearestDc; +help.getAppUpdate#522d5a7d source:string = help.AppUpdate; +help.getInviteText#4d392343 = help.InviteText; +help.getSupport#9cdf08cd = help.Support; +help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; +help.getCdnConfig#52029342 = CdnConfig; +help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; +help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; +help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; +help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; +help.getAppConfig#61e3f854 hash:int = help.AppConfig; +help.saveAppLog#6f02f748 events:Vector = Bool; +help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; +help.getSupportName#d360e72c = help.SupportName; +help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; +help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; +help.getPromoData#c0977421 = help.PromoData; +help.hidePromoData#1e251c95 peer:InputPeer = Bool; +help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool; +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; +help.getPremiumPromo#b81b93d4 = help.PremiumPromo; +help.getPeerColors#da80f42f hash:int = help.PeerColors; +help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors; +help.getTimezonesList#49b30240 hash:int = help.TimezonesList; + +channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; +channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; +channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; +channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; +channels.getChannels#a7f6bbb id:Vector = messages.Chats; +channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true forum:flags.5?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; +channels.editAdmin#9a98ad68 flags:# channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:flags.0?string = Updates; +channels.editTitle#566decd0 channel:InputChannel title:string = Updates; +channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; +channels.checkUsername#10e6bd2c channel:InputChannel username:string = Bool; +channels.updateUsername#3514b3de channel:InputChannel username:string = Bool; +channels.joinChannel#7f6a1e22 channel:InputChannel = messages.ChatInviteJoinResult; +channels.leaveChannel#f836aa95 channel:InputChannel = Updates; +channels.inviteToChannel#c9e33d54 channel:InputChannel users:Vector = messages.InvitedUsers; +channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; +channels.exportMessageLink#e63fadeb flags:# grouped:flags.0?true thread:flags.1?true channel:InputChannel id:int = ExportedMessageLink; +channels.toggleSignatures#418d549c flags:# signatures_enabled:flags.0?true profiles_enabled:flags.1?true channel:InputChannel = Updates; +channels.getAdminedPublicChannels#f8b036af flags:# by_location:flags.0?true check_limit:flags.1?true for_personal:flags.2?true = messages.Chats; +channels.editBanned#96e6cd81 channel:InputChannel participant:InputPeer banned_rights:ChatBannedRights = Updates; +channels.getAdminLog#33ddf480 flags:# channel:InputChannel q:string events_filter:flags.0?ChannelAdminLogEventsFilter admins:flags.1?Vector max_id:long min_id:long limit:int = channels.AdminLogResults; +channels.setStickers#ea8ca4f9 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = Bool; +channels.deleteHistory#9baa9647 flags:# for_everyone:flags.0?true channel:InputChannel max_id:int = Updates; +channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates; +channels.getLeftChannels#8341ecc0 offset:int = messages.Chats; +channels.getGroupsForDiscussion#f5dad378 = messages.Chats; +channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool; +channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint address:string = Bool; +channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; +channels.getInactiveChannels#11e831ee = messages.InactiveChats; +channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; +channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers; +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; +channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; +channels.toggleJoinRequest#ecc2618 flags:# apply_to_invites:flags.1?true channel:InputChannel enabled:Bool guard_bot:flags.0?InputUser = Updates; +channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; +channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; +channels.deactivateAllUsernames#a245dd3 channel:InputChannel = Bool; +channels.toggleForum#3ff75734 channel:InputChannel enabled:Bool tabs:Bool = Updates; +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; +channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; +channels.updateColor#d8aa3671 flags:# for_profile:flags.1?true channel:InputChannel color:flags.2?int background_emoji_id:flags.0?long = Updates; +channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; +channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; +channels.updateEmojiStatus#f0d3e6a8 channel:InputChannel emoji_status:EmojiStatus = Updates; +channels.setBoostsToUnblockRestrictions#ad399cee channel:InputChannel boosts:int = Updates; +channels.setEmojiStickers#3cd930b7 channel:InputChannel stickerset:InputStickerSet = Bool; +channels.restrictSponsoredMessages#9ae91519 channel:InputChannel restricted:Bool = Updates; +channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; +channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; +channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; +channels.getMessageAuthor#ece2a0e6 channel:InputChannel id:int = User; +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; +channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool; + +bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; +bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; +bots.setBotMenuButton#4504d54f user_id:InputUser button:BotMenuButton = Bool; +bots.getBotMenuButton#9c60eb28 user_id:InputUser = BotMenuButton; +bots.setBotBroadcastDefaultAdminRights#788464e1 admin_rights:ChatAdminRights = Bool; +bots.setBotGroupDefaultAdminRights#925ec9ea admin_rights:ChatAdminRights = Bool; +bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; +bots.getBotInfo#dcd914fd flags:# bot:flags.0?InputUser lang_code:string = bots.BotInfo; +bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = Bool; +bots.toggleUsername#53ca973 bot:InputUser username:string active:Bool = Bool; +bots.canSendMessage#1359f4e6 bot:InputUser = Bool; +bots.allowSendMessage#f132e3ef bot:InputUser = Updates; +bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; +bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots; +bots.addPreviewMedia#17aeb75a bot:InputUser lang_code:string media:InputMedia = BotPreviewMedia; +bots.editPreviewMedia#8525606f bot:InputUser lang_code:string media:InputMedia new_media:InputMedia = BotPreviewMedia; +bots.deletePreviewMedia#2d0135b3 bot:InputUser lang_code:string media:Vector = Bool; +bots.reorderPreviewMedias#b627f3aa bot:InputUser lang_code:string order:Vector = Bool; +bots.getPreviewInfo#423ab3ad bot:InputUser lang_code:string = bots.PreviewInfo; +bots.getPreviewMedias#a2a5594d bot:InputUser = Vector; +bots.updateUserEmojiStatus#ed9f30c5 user_id:InputUser emoji_status:EmojiStatus = Bool; +bots.toggleUserEmojiStatusPermission#6de6392 bot:InputUser enabled:Bool = Bool; +bots.checkDownloadFileParams#50077589 bot:InputUser file_name:string url:string = Bool; +bots.getAdminedBots#b0711d83 = Vector; +bots.updateStarRefProgram#778b5ab3 flags:# bot:InputUser commission_permille:int duration_months:flags.0?int = StarRefProgram; +bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool; +bots.getBotRecommendations#a1b70815 bot:InputUser = users.Users; +bots.checkUsername#87f2219b username:string = Bool; +bots.createBot#e5b17f2b flags:# via_deeplink:flags.0?true name:string username:string manager_id:InputUser = User; +bots.exportBotToken#bd0d99eb bot:InputUser revoke:Bool = bots.ExportedBotToken; +bots.requestWebViewButton#31a2a35e user_id:InputUser button:KeyboardButton = bots.RequestedButton; +bots.getRequestedWebViewButton#bf25b7f3 bot:InputUser webapp_req_id:string = KeyboardButton; +bots.getAccessSettings#213853a3 bot:InputUser = bots.AccessSettings; +bots.editAccessSettings#31813cd8 flags:# restricted:flags.0?true bot:InputUser add_users:flags.1?Vector = Bool; +bots.setJoinChatResults#e71a4810 query_id:long result:JoinChatBotResult = Bool; + +payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; +payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; +payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; +payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; +payments.getSavedInfo#227d824b = payments.SavedInfo; +payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; +payments.getBankCardData#2e79d779 number:string = payments.BankCardData; +payments.exportInvoice#f91b065 invoice_media:InputMedia = payments.ExportedInvoice; +payments.assignAppStoreTransaction#80ed747d receipt:bytes purpose:InputStorePaymentPurpose = Updates; +payments.assignPlayMarketTransaction#dffd50d3 receipt:DataJSON purpose:InputStorePaymentPurpose = Updates; +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; +payments.applyGiftCode#f6e26854 slug:string = Updates; +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; +payments.getStarsTopupOptions#c00ec7d3 = Vector; +payments.getStarsStatus#4ea9b3bf flags:# ton:flags.0?true peer:InputPeer = payments.StarsStatus; +payments.getStarsTransactions#69da4557 flags:# inbound:flags.0?true outbound:flags.1?true ascending:flags.2?true ton:flags.4?true subscription_id:flags.3?string peer:InputPeer offset:string limit:int = payments.StarsStatus; +payments.sendStarsForm#7998c914 form_id:long invoice:InputInvoice = payments.PaymentResult; +payments.refundStarsCharge#25ae8f4a user_id:InputUser charge_id:string = Updates; +payments.getStarsRevenueStats#d91ffad6 flags:# dark:flags.0?true ton:flags.1?true peer:InputPeer = payments.StarsRevenueStats; +payments.getStarsRevenueWithdrawalUrl#2433dc92 flags:# ton:flags.0?true peer:InputPeer amount:flags.1?long password:InputCheckPasswordSRP = payments.StarsRevenueWithdrawalUrl; +payments.getStarsRevenueAdsAccountUrl#d1d7efc5 peer:InputPeer = payments.StarsRevenueAdsAccountUrl; +payments.getStarsTransactionsByID#2dca16b8 flags:# ton:flags.0?true peer:InputPeer id:Vector = payments.StarsStatus; +payments.getStarsGiftOptions#d3c96bc8 flags:# user_id:flags.0?InputUser = Vector; +payments.getStarsSubscriptions#32512c5 flags:# missing_balance:flags.0?true peer:InputPeer offset:string = payments.StarsStatus; +payments.changeStarsSubscription#c7770878 flags:# peer:InputPeer subscription_id:string canceled:flags.0?Bool = Bool; +payments.fulfillStarsSubscription#cc5bebb3 peer:InputPeer subscription_id:string = Bool; +payments.getStarsGiveawayOptions#bd1efd3e = Vector; +payments.getStarGifts#c4563590 hash:int = payments.StarGifts; +payments.saveStarGift#2a2a697c flags:# unsave:flags.0?true stargift:InputSavedStarGift = Bool; +payments.convertStarGift#74bf076b stargift:InputSavedStarGift = Bool; +payments.botCancelStarsSubscription#6dfa0622 flags:# restore:flags.0?true user_id:InputUser charge_id:string = Bool; +payments.getConnectedStarRefBots#5869a553 flags:# peer:InputPeer offset_date:flags.2?int offset_link:flags.2?string limit:int = payments.ConnectedStarRefBots; +payments.getConnectedStarRefBot#b7d998f0 peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.getSuggestedStarRefBots#d6b48f7 flags:# order_by_revenue:flags.0?true order_by_date:flags.1?true peer:InputPeer offset:string limit:int = payments.SuggestedStarRefBots; +payments.connectStarRefBot#7ed5348a peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; +payments.editConnectedStarRefBot#e4fca4a3 flags:# revoked:flags.0?true peer:InputPeer link:string = payments.ConnectedStarRefBots; +payments.getStarGiftUpgradePreview#9c9abcb1 gift_id:long = payments.StarGiftUpgradePreview; +payments.upgradeStarGift#aed6e4f5 flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = Updates; +payments.transferStarGift#7f18176a stargift:InputSavedStarGift to_id:InputPeer = Updates; +payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift; +payments.getSavedStarGifts#a319e569 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_unique:flags.4?true sort_by_value:flags.5?true exclude_upgradable:flags.7?true exclude_unupgradable:flags.8?true peer_color_available:flags.9?true exclude_hosted:flags.10?true peer:InputPeer collection_id:flags.6?int offset:string limit:int = payments.SavedStarGifts; +payments.getSavedStarGift#b455a106 stargift:Vector = payments.SavedStarGifts; +payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl; +payments.toggleChatStarGiftNotifications#60eaefa1 flags:# enabled:flags.0?true peer:InputPeer = Bool; +payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = Bool; +payments.canPurchaseStore#4fdc5ea7 purpose:InputStorePaymentPurpose = Bool; +payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true for_craft:flags.4?true stars_only:flags.5?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector offset:string limit:int = payments.ResaleStarGifts; +payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates; +payments.createStarGiftCollection#1f4a0e87 peer:InputPeer title:string stargift:Vector = StarGiftCollection; +payments.updateStarGiftCollection#4fddbee7 flags:# peer:InputPeer collection_id:int title:flags.0?string delete_stargift:flags.1?Vector add_stargift:flags.2?Vector order:flags.3?Vector = StarGiftCollection; +payments.reorderStarGiftCollections#c32af4cc peer:InputPeer order:Vector = Bool; +payments.deleteStarGiftCollection#ad5648e8 peer:InputPeer collection_id:int = Bool; +payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections; +payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGiftValueInfo; +payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; +payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; +payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; +payments.sendStarGiftOffer#8fb86b41 flags:# peer:InputPeer slug:string price:StarsAmount duration:int random_id:long allow_paid_stars:flags.0?long = Updates; +payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; +payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts; +payments.craftStarGift#b0f9684f stargift:Vector = Updates; + +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true emojis:flags.5?true text_color:flags.6?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; +stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; +stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; +stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; +stickers.setStickerSetThumb#a76a5392 flags:# stickerset:InputStickerSet thumb:flags.0?InputDocument thumb_document_id:flags.1?long = messages.StickerSet; +stickers.checkShortName#284b3639 short_name:string = Bool; +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; +stickers.changeSticker#f5537ebc flags:# sticker:InputDocument emoji:flags.0?string mask_coords:flags.1?MaskCoords keywords:flags.2?string = messages.StickerSet; +stickers.renameStickerSet#124b1c00 stickerset:InputStickerSet title:string = messages.StickerSet; +stickers.deleteStickerSet#87704394 stickerset:InputStickerSet = Bool; +stickers.replaceSticker#4696459a sticker:InputDocument new_sticker:InputStickerSetItem = messages.StickerSet; + +phone.getCallConfig#55451fa9 = DataJSON; +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; +phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates; +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; +phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; +phone.toggleGroupCallSettings#974392f2 flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool messages_enabled:flags.2?Bool send_paid_messages_stars:flags.3?long = Updates; +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; +phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; +phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; +phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; +phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; +phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; +phone.getGroupCallStreamRtmpUrl#5af4c73a flags:# live_story:flags.0?true peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; +phone.saveCallLog#41248786 peer:InputPhoneCall file:InputFile = Bool; +phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates; +phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector block:bytes = Updates; +phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates; +phone.inviteConferenceCallParticipant#bcf22685 flags:# video:flags.0?true call:InputGroupCall user_id:InputUser = Updates; +phone.declineConferenceCallInvite#3c479971 msg_id:int = Updates; +phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates; +phone.sendGroupCallMessage#b1d11410 flags:# call:InputGroupCall random_id:long message:TextWithEntities allow_paid_stars:flags.0?long send_as:flags.1?InputPeer = Updates; +phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool; +phone.deleteGroupCallMessages#f64f54f7 flags:# report_spam:flags.0?true call:InputGroupCall messages:Vector = Updates; +phone.deleteGroupCallParticipantMessages#1dbfeca0 flags:# report_spam:flags.0?true call:InputGroupCall participant:InputPeer = Updates; +phone.getGroupCallStars#6f636302 call:InputGroupCall = phone.GroupCallStars; +phone.saveDefaultSendAs#4167add1 call:InputGroupCall send_as:InputPeer = Bool; + +langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; +langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; +langpack.getDifference#cd984aa5 lang_pack:string lang_code:string from_version:int = LangPackDifference; +langpack.getLanguages#42c6978f lang_pack:string = Vector; +langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLanguage; + +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; + +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; +stats.getMessagePublicForwards#5f150144 channel:InputChannel msg_id:int offset:string limit:int = stats.PublicForwards; +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; +stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats; +stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards; +stats.getPollStats#c27dfa68 flags:# dark:flags.0?true peer:InputPeer msg_id:int = stats.PollStats; + +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = chatlists.ExportedChatlistInvite; +chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool; +chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector = ExportedChatlistInvite; +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; +chatlists.getChatlistUpdates#89419521 chatlist:InputChatlist = chatlists.ChatlistUpdates; +chatlists.joinChatlistUpdates#e089f8f5 chatlist:InputChatlist peers:Vector = Updates; +chatlists.hideChatlistUpdates#66e486fb chatlist:InputChatlist = Bool; +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates; + +stories.canSendStory#30eb63f0 peer:InputPeer = stories.CanSendStoryCount; +stories.sendStory#8f9e6898 flags:# pinned:flags.2?true noforwards:flags.4?true fwd_modified:flags.7?true peer:InputPeer media:InputMedia media_areas:flags.5?Vector caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long period:flags.3?int fwd_from_id:flags.6?InputPeer fwd_from_story:flags.6?int albums:flags.8?Vector music:flags.9?InputDocument = Updates; +stories.editStory#2c63a72b flags:# peer:InputPeer id:int media:flags.0?InputMedia media_areas:flags.3?Vector caption:flags.1?string entities:flags.1?Vector privacy_rules:flags.2?Vector music:flags.4?InputDocument = Updates; +stories.deleteStories#ae59db5f peer:InputPeer id:Vector = Vector; +stories.togglePinned#9a75a1ef peer:InputPeer id:Vector pinned:Bool = Vector; +stories.getAllStories#eeb0d625 flags:# next:flags.1?true hidden:flags.2?true state:flags.0?string = stories.AllStories; +stories.getPinnedStories#5821a5dc peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesArchive#b4352016 peer:InputPeer offset_id:int limit:int = stories.Stories; +stories.getStoriesByID#5774ca74 peer:InputPeer id:Vector = stories.Stories; +stories.toggleAllStoriesHidden#7c2557c4 hidden:Bool = Bool; +stories.readStories#a556dac8 peer:InputPeer max_id:int = Vector; +stories.incrementStoryViews#b2028afb peer:InputPeer id:Vector = Bool; +stories.getStoryViewsList#7ed23c57 flags:# just_contacts:flags.0?true reactions_first:flags.2?true forwards_first:flags.3?true peer:InputPeer q:flags.1?string id:int offset:string limit:int = stories.StoryViewsList; +stories.getStoriesViews#28e16cc8 peer:InputPeer id:Vector = stories.StoryViews; +stories.exportStoryLink#7b8def20 peer:InputPeer id:int = ExportedStoryLink; +stories.report#19d8eb45 peer:InputPeer id:Vector option:bytes message:string = ReportResult; +stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?true = Updates; +stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; +stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; +stories.getAllReadPeerStories#9b5ae7f9 = Updates; +stories.getPeerMaxIDs#78499170 id:Vector = Vector; +stories.getChatsToSend#a56a8b60 = messages.Chats; +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; +stories.getStoryReactionsList#b9b2881f flags:# forwards_first:flags.2?true peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = stories.StoryReactionsList; +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; +stories.searchPosts#d1810907 flags:# hashtag:flags.0?string area:flags.1?MediaArea peer:flags.2?InputPeer offset:string limit:int = stories.FoundStories; +stories.createAlbum#a36396e5 peer:InputPeer title:string stories:Vector = StoryAlbum; +stories.updateAlbum#5e5259b6 flags:# peer:InputPeer album_id:int title:flags.0?string delete_stories:flags.1?Vector add_stories:flags.2?Vector order:flags.3?Vector = StoryAlbum; +stories.reorderAlbums#8535fbd9 peer:InputPeer order:Vector = Bool; +stories.deleteAlbum#8d3456d0 peer:InputPeer album_id:int = Bool; +stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums; +stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories; +stories.startLive#d069ccde flags:# pinned:flags.2?true noforwards:flags.4?true rtmp_stream:flags.5?true peer:InputPeer caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long messages_enabled:flags.6?Bool send_paid_messages_stars:flags.7?long = Updates; + +premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; +premium.getMyBoosts#be77b4a = premium.MyBoosts; +premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; +premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus; +premium.getUserBoosts#39854d1f peer:InputPeer user_id:InputUser = premium.BoostsList; + +smsjobs.isEligibleToJoin#edc39d0 = smsjobs.EligibilityToJoin; +smsjobs.join#a74ece2d = Bool; +smsjobs.leave#9898ad73 = Bool; +smsjobs.updateSettings#93fa0bf flags:# allow_international:flags.0?true = Bool; +smsjobs.getStatus#10a698e8 = smsjobs.Status; +smsjobs.getSmsJob#778d902f job_id:string = SmsJob; +smsjobs.finishJob#4f1ebf24 flags:# job_id:string error:flags.0?string = Bool; + +fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo; + +aicompose.createTone#4aa83913 flags:# display_author:flags.0?true emoji_id:long title:string prompt:string = AiComposeTone; +aicompose.updateTone#903bcf59 flags:# tone:InputAiComposeTone display_author:flags.0?Bool emoji_id:flags.1?long title:flags.2?string prompt:flags.3?string = AiComposeTone; +aicompose.saveTone#1782cbb1 tone:InputAiComposeTone unsave:Bool = Bool; +aicompose.deleteTone#dd39316a tone:InputAiComposeTone = Bool; +aicompose.getTone#b2e8ba03 tone:InputAiComposeTone = aicompose.Tones; +aicompose.getTones#abd59201 hash:long = aicompose.Tones; +aicompose.getToneExample#d1b4ab14 tone:InputAiComposeTone num:int = AiComposeToneExample; + +// LAYER 227 diff --git a/internal/compat/layerwire/bench_test.go b/internal/compat/layerwire/bench_test.go new file mode 100644 index 00000000..b2a92b63 --- /dev/null +++ b/internal/compat/layerwire/bench_test.go @@ -0,0 +1,83 @@ +package layerwire + +import ( + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" +) + +func benchEncode(o bin.Encoder) []byte { + var b bin.Buffer + if err := o.Encode(&b); err != nil { + panic(err) + } + return b.Copy() +} + +// BenchmarkTranscodeOutbound measures the outbound seam: the 227 passthrough +// (the overwhelmingly common case) vs a real message downgrade to 220. +func BenchmarkTranscodeOutbound(b *testing.B) { + richMessage := canonicalCorpus()[1] + raw := benchEncode(richMessage) + + b.Run("identity_227", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := Transcode(raw, 227); err != nil { + b.Fatal(err) + } + } + }) + b.Run("downgrade_220_message", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := Transcode(raw, 220); err != nil { + b.Fatal(err) + } + } + }) + + dialogs := benchEncode(canonicalCorpus()[11]) // messages.dialogs + b.Run("downgrade_220_dialogs", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := Transcode(dialogs, 220); err != nil { + b.Fatal(err) + } + } + }) +} + +// BenchmarkUpgradeInbound measures the inbound seam: a 227 client (miss, the +// common case), a CRC-swap drift, and a body-transform drift. +func BenchmarkUpgradeInbound(b *testing.B) { + b.Run("miss_227", func(b *testing.B) { + // A canonical method id that needs no upgrade. + body := benchEncode(&tg.HelpGetConfigRequest{}) + b.ReportAllocs() + for i := 0; i < b.N; i++ { + in := &bin.Buffer{Buf: body} + id, _ := in.PeekID() + if _, ok, _ := UpgradeInbound(id, in); ok { + b.Fatal("unexpected upgrade") + } + } + }) + + // uploadMedia body transform (peer+media -> flags+peer+media). + var um bin.Buffer + um.PutID(0x519bc2b1) + _ = (&tg.InputPeerSelf{}).Encode(&um) + _ = (&tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 10, Parts: 1, Name: "a.jpg"}}).Encode(&um) + umRaw := um.Copy() + b.Run("drift_uploadMedia", func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + in := &bin.Buffer{Buf: append([]byte(nil), umRaw...)} + if _, ok, err := UpgradeInbound(0x519bc2b1, in); !ok || err != nil { + b.Fatal(ok, err) + } + } + }) +} diff --git a/internal/compat/layerwire/client_aliases.go b/internal/compat/layerwire/client_aliases.go new file mode 100644 index 00000000..23b81820 --- /dev/null +++ b/internal/compat/layerwire/client_aliases.go @@ -0,0 +1,36 @@ +package layerwire + +// clientMethodAliases maps method constructor ids emitted by a specific client's +// hand-maintained TL (constructor *drift*, NOT official api.tl layer drift) to +// the canonical (227) id — for the subset whose request body is byte-identical +// to canonical so a 4-byte id swap suffices. +// +// This is the second, hand-maintained half of the inbound compat table; the +// generated inboundMethodUpgrades (tables_gen.go) covers official layer drift +// derived from TDesktop api.tl, which by construction never contains these +// client-private ids. Entries here are sourced from client source (e.g. DrKLO +// TLRPC.java), each verified body-compatible against the canonical layout. +// +// Client-drift constructors whose body differs structurally (a missing flags +// integer, a different field type, or that need business logic such as +// access_hash resolution or a legacy-shaped response) are NOT here — they remain +// dedicated decode handlers in internal/rpc (dispatchCompat), because the body +// cannot be reused as-is and the transform needs more than an id swap. +var clientMethodAliases = map[uint32]uint32{ + // DrKLO Android (post-Layer225) messages.forwardMessages. Wire layout is + // identical to canonical #13704a7c for every flag bit the client can set + // (the only schema delta is flags it never sets), so the body decodes as-is. + 0x41d41ade: 0x13704a7c, + // DrKLO Android channels.inviteToChannel. Body is still + // channel:InputChannel users:Vector = canonical #c9e33d54. + 0x199f3a6c: 0xc9e33d54, + // DrKLO Android updates.getDifference. Old layout only uses flags.0 + // (pts_total_limit); canonical #19c2f763 adds pts_limit(flags.1)/ + // qts_limit(flags.2) which the client leaves clear ⇒ zero wire bytes, so the + // old body decodes byte-for-byte as canonical. + 0x25939651: 0x19c2f763, + // DrKLO Android messages.createChat. Body is byte-identical to canonical + // #92ceddd4; the legacy-shaped response is produced by ClientType==Android + // (createChatNeedsLegacyChat), so no dedicated handler is needed. + 0x0034a818: 0x92ceddd4, +} diff --git a/internal/compat/layerwire/corpus_test.go b/internal/compat/layerwire/corpus_test.go new file mode 100644 index 00000000..c9105c44 --- /dev/null +++ b/internal/compat/layerwire/corpus_test.go @@ -0,0 +1,126 @@ +package layerwire + +import ( + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" +) + +// canonicalCorpus is a diverse set of canonical (gotd, Layer 227) objects shared +// by the walker and transcoder tests. It deliberately exercises changed types +// (message, messageMediaPhoto, keyboardButton*, dialog, channelFull, userFull, +// pollResults/pollAnswerVoters), nested containers, vectors, and multi-flags. +func canonicalCorpus() []bin.Encoder { + photo := &tg.Photo{ + ID: 10, + AccessHash: 11, + FileReference: []byte{1, 2, 3}, + Date: 100, + Sizes: []tg.PhotoSizeClass{ + &tg.PhotoSize{Type: "x", W: 100, H: 100, Size: 2048}, + &tg.PhotoStrippedSize{Type: "i", Bytes: []byte{9, 8, 7}}, + }, + DCID: 2, + } + return []bin.Encoder{ + &tg.Message{ID: 1, PeerID: &tg.PeerUser{UserID: 2}, Date: 100, Message: "hi"}, + &tg.Message{ + Out: true, + ID: 2, + FromID: &tg.PeerUser{UserID: 3}, + PeerID: &tg.PeerChannel{ChannelID: 4}, + Date: 101, + Message: "rich", + Media: &tg.MessageMediaPhoto{Photo: photo, TTLSeconds: 5}, + Entities: []tg.MessageEntityClass{ + &tg.MessageEntityBold{Offset: 0, Length: 2}, + &tg.MessageEntityTextURL{Offset: 0, Length: 2, URL: "https://x"}, + }, + ReplyMarkup: &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{ + {Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButtonCallback{Text: "ok", Data: []byte("d")}, + &tg.KeyboardButtonURL{Text: "go", URL: "https://y"}, + }}, + }}, + ReplyTo: &tg.MessageReplyHeader{ReplyToMsgID: 1}, + FwdFrom: tg.MessageFwdHeader{FromName: "n", Date: 99}, + Views: 7, + Forwards: 2, + Reactions: tg.MessageReactions{Results: []tg.ReactionCount{{Reaction: &tg.ReactionEmoji{Emoticon: "👍"}, Count: 3}}}, + GroupedID: 555, + }, + &tg.MessageService{ + ID: 3, + PeerID: &tg.PeerUser{UserID: 2}, + Date: 102, + Action: &tg.MessageActionChatEditTitle{Title: "t"}, + }, + &tg.Updates{ + Updates: []tg.UpdateClass{ + &tg.UpdateNewMessage{Message: &tg.Message{ID: 9, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "u"}, Pts: 1, PtsCount: 1}, + &tg.UpdateMessageID{ID: 9, RandomID: 123}, + }, + Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}}, + Chats: []tg.ChatClass{&tg.Channel{ID: 4, AccessHash: 6, Title: "C", Photo: &tg.ChatPhotoEmpty{}}}, + Date: 100, + Seq: 1, + }, + &tg.User{ + ID: 2, + AccessHash: 5, + FirstName: "A", + Username: "a", + Photo: &tg.UserProfilePhoto{PhotoID: 7, DCID: 2}, + Status: &tg.UserStatusOnline{Expires: 999}, + }, + &tg.UserFull{ + ID: 2, + About: "hi", + Settings: tg.PeerSettings{}, + NotifySettings: tg.PeerNotifySettings{}, + CommonChatsCount: 0, + }, + &tg.Channel{ID: 4, AccessHash: 6, Title: "C", Megagroup: true, Photo: &tg.ChatPhotoEmpty{}}, + &tg.ChannelFull{ + ID: 4, + About: "about", + ReadInboxMaxID: 1, + ReadOutboxMaxID: 1, + UnreadCount: 0, + ChatPhoto: &tg.PhotoEmpty{ID: 0}, + NotifySettings: tg.PeerNotifySettings{}, + Pts: 1, + }, + &tg.Dialog{ + Peer: &tg.PeerUser{UserID: 2}, + TopMessage: 2, + ReadInboxMaxID: 1, + NotifySettings: tg.PeerNotifySettings{}, + }, + &tg.Poll{ + ID: 1, + Question: tg.TextWithEntities{Text: "q?"}, + Answers: []tg.PollAnswerClass{ + &tg.PollAnswer{Text: tg.TextWithEntities{Text: "a"}, Option: []byte{0}}, + &tg.PollAnswer{Text: tg.TextWithEntities{Text: "b"}, Option: []byte{1}}, + }, + }, + &tg.PollResults{ + Results: []tg.PollAnswerVoters{ + {Option: []byte{0}, Voters: 3, Chosen: true}, + {Option: []byte{1}, Voters: 1}, + }, + TotalVoters: 4, + }, + &tg.MessagesDialogs{ + Dialogs: []tg.DialogClass{&tg.Dialog{Peer: &tg.PeerUser{UserID: 2}, TopMessage: 2, NotifySettings: tg.PeerNotifySettings{}}}, + Messages: []tg.MessageClass{&tg.Message{ID: 2, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "x"}}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}}, + }, + &tg.MessagesMessages{ + Messages: []tg.MessageClass{&tg.Message{ID: 2, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "x"}}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}}, + }, + } +} diff --git a/internal/compat/layerwire/fallback.go b/internal/compat/layerwire/fallback.go new file mode 100644 index 00000000..7c7fc4aa --- /dev/null +++ b/internal/compat/layerwire/fallback.go @@ -0,0 +1,138 @@ +package layerwire + +import "github.com/gotd/td/bin" + +// Hand-written transforms for the message subtree: structural changes that are +// not pure field drops, and 227-only constructors that older clients cannot +// decode. By-abstract-type fallbacks auto-cover future variants of the same +// class (e.g. a new MessageAction added in a later layer still degrades to +// messageActionEmpty). See docs/layer-compat-220-227-design.md §5.2. + +const ( + messageActionEmptyID = 0xb6aef7b0 // messageActionEmpty = MessageAction + messageEntityUnknownID = 0xbb92ba95 // messageEntityUnknown offset:int length:int = MessageEntity + pageBlockUnsupportedID = 0x13567e8a // pageBlockUnsupported = PageBlock + textEmptyID = 0xdc3d824f // textEmpty = RichText +) + +func init() { + structuralTransforms["pollAnswerVoters"] = transcodePollAnswerVoters + + // 227-only constructors degrade to a class member every supported layer has. + // Each target carries no body, so the replacement is a bare id (a new + // variant inside a Vector keeps its slot — no element drop needed). + newTypeFallbacksByType["MessageAction"] = replaceWithBare(messageActionEmptyID) + newTypeFallbacksByType["PageBlock"] = replaceWithBare(pageBlockUnsupportedID) + newTypeFallbacksByType["RichText"] = replaceWithBare(textEmptyID) + newTypeFallbacksByType["MessageEntity"] = fallbackMessageEntity +} + +// replaceWithBare consumes the canonical (227-only) object and emits a +// bodyless constructor id the target layer understands. +func replaceWithBare(id uint32) fallbackFunc { + return func(cl *ctorLayout, in, out *bin.Buffer, layer int) error { + if err := canonical.skipObject(in); err != nil { + return err + } + out.PutID(id) + return nil + } +} + +// peerVectorField is a synthetic Vector layout used to consume the +// canonical recent_voters field. +var peerVectorField = fieldLayout{ + kind: kindVector, + flagBit: -1, + elem: &fieldLayout{kind: kindObject, typeName: "Peer", flagBit: -1}, +} + +// transcodePollAnswerVoters downgrades pollAnswerVoters: canonical (227) made +// voters conditional (flags.2?int) and added recent_voters (flags.2?Vector); +// older layers carry voters as a plain int. The leading CRC is already consumed. +// +// 227: flags:# chosen:flags.0?true correct:flags.1?true option:bytes +// voters:flags.2?int recent_voters:flags.2?Vector +// <=226: flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:int +func transcodePollAnswerVoters(cl *ctorLayout, target uint32, in, out *bin.Buffer, layer int) error { + flags, err := in.Uint32() + if err != nil { + return err + } + option, err := in.Bytes() + if err != nil { + return err + } + var voters int + if flags&(1<<2) != 0 { + if voters, err = in.Int(); err != nil { + return err + } + if err := canonical.skipValue(in, &peerVectorField); err != nil { + return err + } + } + out.PutID(target) + out.PutUint32(flags & 0b11) // retain chosen/correct, clear the moved bit 2 + out.PutBytes(option) + out.PutInt(voters) + return nil +} + +// fallbackMessageEntity replaces any 227-only MessageEntity with +// messageEntityUnknown, preserving offset/length so text positions stay valid. +func fallbackMessageEntity(cl *ctorLayout, in, out *bin.Buffer, layer int) error { + id, err := in.PeekID() + if err != nil { + return err + } + if err := in.ConsumeID(id); err != nil { + return err + } + offset, length, err := canonical.decodeOffsetLength(in, cl) + if err != nil { + return err + } + out.PutID(messageEntityUnknownID) + out.PutInt(offset) + out.PutInt(length) + return nil +} + +// decodeOffsetLength walks a constructor body (no leading CRC) per the canonical +// layout, returning its offset/length int fields and discarding the rest. +func (m *schemaModel) decodeOffsetLength(in *bin.Buffer, cl *ctorLayout) (offset, length int, err error) { + var flags map[string]uint32 + for i := range cl.fields { + f := &cl.fields[i] + if f.isFlags { + v, e := in.Uint32() + if e != nil { + return 0, 0, e + } + if flags == nil { + flags = make(map[string]uint32, 2) + } + flags[f.name] = v + continue + } + if f.conditional() && flags[f.flagName]&(1<server (Input*) type the +// server never emits, so it cannot appear in downgraded output. +func isInbound(cl *ctorLayout) bool { + return strings.HasPrefix(cl.result, "Input") || strings.HasPrefix(cl.name, "input") +} + +// collectReachableTypes returns abstract/bare type names that can appear in +// downgraded output at a layer: those referenced by a *retained* field of a +// constructor that is itself emittable (not a function, not a 227-only type that +// is replaced wholesale, not an inbound Input* type). +func collectReachableTypes(lt *layerTables) map[string]bool { + refs := map[string]bool{} + var addField func(f *fieldLayout) + addField = func(f *fieldLayout) { + switch f.kind { + case kindObject, kindBareObject: + refs[f.typeName] = true + case kindVector, kindVectorBare: + addField(f.elem) + } + } + for crc, cl := range canonical.byCRC { + if cl.isFunc || isInbound(cl) || lt.newTypes[crc] { + continue + } + var keep map[string]bool + if r := lt.rules[crc]; r != nil && r.structural == "" { + keep = r.keep + } + for i := range cl.fields { + f := &cl.fields[i] + if f.isFlags { + continue + } + if keep != nil && !keep[f.name] { + continue // dropped by a mechanical rule + } + addField(f) + } + } + return refs +} + +// unemittedAllowlist is the curated set of reachable-but-unhandled 227-only +// constructors that telesrv does not actually emit (confirmed against the +// outbound-constructor scoping audit, 2026-06-25). They live behind features the +// server lacks (instant-view rich pages, AI compose, managed bots, web-browser +// settings, guest chat, star-gift rarity/craft, join-chat bot results). The gate +// fails if a NEW reachable type appears that is neither handled nor listed here, +// forcing a human to triage on every gotd bump / client upgrade. +var unemittedAllowlist = map[string]bool{ + "aiComposeTone": true, + "aiComposeToneDefault": true, + "aiComposeToneExample": true, + "botInlineMessageRichMessage": true, + "channelAdminLogEventActionParticipantEditRank": true, + "joinChatBotResultApproved": true, + "joinChatBotResultDeclined": true, + "joinChatBotResultQueued": true, + "joinChatBotResultWebView": true, + "messages.chatInviteJoinResultWebView": true, + "messages.emojiGameDiceInfo": true, + "messages.emojiGameUnavailable": true, + "requestPeerTypeCreateBot": true, + "richMessage": true, + "sendMessageRichMessageDraftAction": true, + "starGiftAttributeRarity": true, + "starGiftAttributeRarityEpic": true, + "starGiftAttributeRarityLegendary": true, + "starGiftAttributeRarityRare": true, + "starGiftAttributeRarityUncommon": true, + "topPeerCategoryBotsGuestChat": true, + "updateAiComposeTones": true, + "updateBotGuestChatQuery": true, + "updateChatParticipantRank": true, + "updateEmojiGameInfo": true, + "updateJoinChatWebViewDecision": true, + "updateManagedBot": true, + "updateNewBotConnection": true, + "updateStarGiftCraftFail": true, + "updateWebBrowserException": true, + "updateWebBrowserSettings": true, + "webDomainException": true, + "webPageAttributeAiComposeTone": true, + // Structural changed-types telesrv does not emit (see design Appendix C and + // the scoping audit); their hand transforms are deferred to CI-todo. + "pageListOrderedItemText": true, + "pageListOrderedItemBlocks": true, + "starGiftAttributeModel": true, + "starGiftAttributeBackdrop": true, + "starGiftAttributePattern": true, + "urlAuthResultAccepted": true, + "inputMediaPoll": true, // inbound only +} + +func newTypeHandled(crc uint32, result string) bool { + if newTypeFallbacks[crc] != nil { + return true + } + return newTypeFallbacksByType[result] != nil +} + +// TestCoverageGate is the drift gate. For every supported layer, each 227-only +// or structural constructor that can appear in downgraded output must be either +// handled (fallback / structural transform) or explicitly allowlisted as not +// emitted. A bare failure means new wire shape slipped in unhandled. +func TestCoverageGate(t *testing.T) { + for layer := SupportedFloor; layer < CanonicalLayer; layer++ { + lt := tables[layer] + if lt == nil { + t.Fatalf("no tables for layer %d", layer) + } + reach := collectReachableTypes(lt) + + // Structural rules that are reachable need a registered transform. + for crc, r := range lt.rules { + if r.structural == "" { + continue + } + cl := canonical.byCRC[crc] + reachable := cl != nil && reach[cl.result] && !isInbound(cl) + handled := structuralTransforms[r.structural] != nil + if reachable && !handled && !unemittedAllowlist[nameOf(crc)] { + t.Errorf("layer %d: reachable structural %s (%#08x) has no transform", layer, nameOf(crc), crc) + } + } + + // New constructors reachable through a retained field need a fallback. + var gaps []string + for crc := range lt.newTypes { + cl := canonical.byCRC[crc] + if cl == nil || cl.isFunc || isInbound(cl) { + continue + } + if !reach[cl.result] { + continue + } + if newTypeHandled(crc, cl.result) || unemittedAllowlist[cl.name] { + continue + } + gaps = append(gaps, cl.name) + } + if len(gaps) > 0 { + sort.Strings(gaps) + t.Errorf("layer %d: %d reachable 227-only types lack a fallback or allowlist entry:\n %v", layer, len(gaps), gaps) + } + } +} + +func nameOf(crc uint32) string { + if cl := canonical.byCRC[crc]; cl != nil { + return cl.name + } + return "?" +} diff --git a/internal/compat/layerwire/gen/main.go b/internal/compat/layerwire/gen/main.go new file mode 100644 index 00000000..12817bd7 --- /dev/null +++ b/internal/compat/layerwire/gen/main.go @@ -0,0 +1,450 @@ +// Command layerwire-gen diffs the canonical gotd schema (Layer 227, the bytes +// telesrv actually emits) against historical TDesktop api.tl layers (220..226) +// and classifies every per-constructor change as either MECHANICAL (a pure +// append-only delta that can be downgraded by dropping trailing/optional fields +// and masking flag bits) or STRUCTURAL (field reorder / reinterpretation that +// needs a hand-written transform). +// +// It is the generate-time half of the layer-compat design +// (docs/layer-compat-220-227-design.md). Run from the telesrv module root: +// +// go run ./internal/compat/layerwire/gen -report +// +// This first iteration only prints a report so the numbers can be validated +// against the design doc before any table is emitted. +package main + +import ( + "flag" + "fmt" + "go/format" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/gotd/tl" +) + +// canonicalLayer is the layer telesrv's gotd is pinned to. +const canonicalLayer = 227 + +// supportedFloor is the oldest client layer telesrv aims to serve. +const supportedFloor = 220 + +// spec is a single TL constructor or method with field-level metadata. +type spec struct { + qname string // qualified name, e.g. "messages.dialogs" or "message" + crc uint32 + params []tl.Parameter + isFunc bool +} + +// schema indexes one parsed .tl file by qualified name and by CRC. +type schema struct { + layer int + byName map[string]*spec + byCRC map[uint32]*spec + ordered []*spec +} + +func qualify(d tl.Definition) string { + if len(d.Namespace) == 0 { + return d.Name + } + return strings.Join(d.Namespace, ".") + "." + d.Name +} + +func load(path string) (*schema, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + parsed, err := tl.Parse(f) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + s := &schema{ + layer: parsed.Layer, + byName: make(map[string]*spec), + byCRC: make(map[uint32]*spec), + } + for i := range parsed.Definitions { + sd := parsed.Definitions[i] + d := sd.Definition + sp := &spec{ + qname: qualify(d), + crc: d.ID, + params: d.Params, + isFunc: sd.Category == tl.CategoryFunction, + } + // Skip the implicit vector pseudo-definition if present. + if sp.qname == "vector" { + continue + } + s.byName[sp.qname] = sp + s.byCRC[sp.crc] = sp + s.ordered = append(s.ordered, sp) + } + return s, nil +} + +// classifyResult describes how a changed constructor downgrades from canonical +// (227) to a target layer. +type classifyResult struct { + mechanical bool + drops []string // canonical fields absent at the target layer + reason string // populated when !mechanical +} + +// classifyDowngrade aligns the target params as a subsequence (by name) of the +// canonical params. Success ⇒ mechanical drop of the unmatched canonical fields. +// Any name mismatch, type change, or flag-condition change ⇒ structural. +func classifyDowngrade(from, to *spec) classifyResult { + var drops []string + i, j := 0, 0 + fp, tp := from.params, to.params + for j < len(tp) { + // Advance over canonical fields until we reach the target field name. + for i < len(fp) && fp[i].Name != tp[j].Name { + drops = append(drops, fp[i].Name) + i++ + } + if i == len(fp) { + return classifyResult{reason: fmt.Sprintf("target field %q not found in canonical (reorder/insert)", tp[j].Name)} + } + if reason := compatible(fp[i], tp[j]); reason != "" { + return classifyResult{reason: fmt.Sprintf("field %q: %s", tp[j].Name, reason)} + } + i++ + j++ + } + for ; i < len(fp); i++ { + drops = append(drops, fp[i].Name) + } + return classifyResult{mechanical: true, drops: drops} +} + +// compatible reports "" if a kept field is wire-compatible between canonical and +// target, or a reason string otherwise. +func compatible(f, t tl.Parameter) string { + if f.Flags != t.Flags { + return "flags-int vs field mismatch" + } + if f.Flags { + // Both are `#` flag integers; the name must match because conditional + // fields reference it by name. + if f.Name != t.Name { + return fmt.Sprintf("flags int renamed %q->%q", t.Name, f.Name) + } + return "" + } + // Conditional-ness must match exactly (no flag-bit remap supported yet). + fc, tc := f.Flag != nil, t.Flag != nil + if fc != tc { + return "conditional-ness changed" + } + if fc { + if f.Flag.Name != t.Flag.Name || f.Flag.Index != t.Flag.Index { + return fmt.Sprintf("flag moved %s.%d->%s.%d", t.Flag.Name, t.Flag.Index, f.Flag.Name, f.Flag.Index) + } + } + if f.Type.String() != t.Type.String() { + return fmt.Sprintf("type changed %s->%s", t.Type.String(), f.Type.String()) + } + return "" +} + +type changed struct { + qname string + fromCRC, toCRC uint32 + res classifyResult +} + +// diff compares canonical (from) against a single target layer (to). +type diffResult struct { + layer int + changedTypes []changed + changedMethods []changed + newTypes []string // exist in canonical, absent at target + newMethods []string + removedTypes []string // exist at target, absent in canonical +} + +func diff(from, to *schema) diffResult { + r := diffResult{layer: to.layer} + for _, sp := range from.ordered { + other, ok := to.byName[sp.qname] + if !ok { + if sp.isFunc { + r.newMethods = append(r.newMethods, sp.qname) + } else { + r.newTypes = append(r.newTypes, sp.qname) + } + continue + } + if other.crc == sp.crc { + continue + } + c := changed{qname: sp.qname, fromCRC: sp.crc, toCRC: other.crc, res: classifyDowngrade(sp, other)} + if sp.isFunc { + r.changedMethods = append(r.changedMethods, c) + } else { + r.changedTypes = append(r.changedTypes, c) + } + } + for _, sp := range to.ordered { + if _, ok := from.byName[sp.qname]; !ok { + r.removedTypes = append(r.removedTypes, sp.qname) + } + } + return r +} + +func main() { + var ( + schemaDir = flag.String("schema", "internal/compat/layerwire/_schema", "dir with layer-NNN.tl") + canonical = flag.String("canonical", "internal/compat/layerwire/schema/canonical-227.tl", "gotd canonical 227 schema") + emit = flag.String("emit", "", "write generated tables_gen.go to this path") + _ = flag.Bool("report", true, "print report") + ) + flag.Parse() + + canon, err := load(*canonical) + if err != nil { + fmt.Fprintln(os.Stderr, "load canonical:", err) + os.Exit(1) + } + + if *emit != "" { + if err := emitTables(canon, *schemaDir, *emit); err != nil { + fmt.Fprintln(os.Stderr, "emit:", err) + os.Exit(1) + } + fmt.Printf("wrote %s\n", *emit) + return + } + fmt.Printf("canonical (gotd) layer=%d defs=%d\n", canon.layer, len(canon.ordered)) + + // Per-layer diff + union across the supported range. + unionChangedTypes := map[string]bool{} + unionChangedMethods := map[string]bool{} + unionNewTypes := map[string]bool{} + unionNewMethods := map[string]bool{} + structuralTypes := map[string]string{} // qname -> reason (worst case seen) + + for L := supportedFloor; L < canonicalLayer; L++ { + path := filepath.Join(*schemaDir, fmt.Sprintf("layer-%d.tl", L)) + tgt, err := load(path) + if err != nil { + fmt.Fprintln(os.Stderr, "load", path, ":", err) + os.Exit(1) + } + r := diff(canon, tgt) + mech, struc := 0, 0 + for _, c := range r.changedTypes { + unionChangedTypes[c.qname] = true + if c.res.mechanical { + mech++ + } else { + struc++ + structuralTypes[c.qname] = c.res.reason + } + } + for _, c := range r.changedMethods { + unionChangedMethods[c.qname] = true + } + for _, n := range r.newTypes { + unionNewTypes[n] = true + } + for _, n := range r.newMethods { + unionNewMethods[n] = true + } + fmt.Printf("layer %d: defs=%d changedTypes=%d (mech=%d struc=%d) changedMethods=%d newTypes=%d newMethods=%d removed=%d\n", + L, len(tgt.ordered), len(r.changedTypes), mech, struc, len(r.changedMethods), len(r.newTypes), len(r.newMethods), len(r.removedTypes)) + } + + fmt.Printf("\n=== UNION %d..%d vs %d ===\n", supportedFloor, canonicalLayer-1, canonicalLayer) + fmt.Printf("changed types: %d\n", len(unionChangedTypes)) + fmt.Printf("changed methods: %d\n", len(unionChangedMethods)) + fmt.Printf("new types: %d\n", len(unionNewTypes)) + fmt.Printf("new methods: %d\n", len(unionNewMethods)) + fmt.Printf("structural types (need hand transform): %d\n", len(structuralTypes)) + for _, q := range sortedKeys(structuralTypes) { + fmt.Printf(" - %s : %s\n", q, structuralTypes[q]) + } + + // Detailed 220-vs-227 drop table (matches design doc Appendix A). + fmt.Printf("\n=== 220 vs 227 changed-type drop table ===\n") + tgt220, _ := load(filepath.Join(*schemaDir, "layer-220.tl")) + r := diff(canon, tgt220) + sort.Slice(r.changedTypes, func(a, b int) bool { return r.changedTypes[a].qname < r.changedTypes[b].qname }) + for _, c := range r.changedTypes { + tag := "MECH" + detail := "drop: " + strings.Join(c.res.drops, ", ") + if !c.res.mechanical { + tag = "STRUCT" + detail = c.res.reason + } + fmt.Printf(" [%-6s] %-34s %#08x->%#08x %s\n", tag, c.qname, c.toCRC, c.fromCRC, detail) + } +} + +// emitTables writes the runtime downgrade tables (tables_gen.go) for every +// supported layer: per changed constructor a mechanical keep-list or a +// structural marker, plus the set of canonical CRCs absent at that layer. +func emitTables(canon *schema, schemaDir, outPath string) error { + var b strings.Builder + b.WriteString("// Code generated by ./internal/compat/layerwire/gen; DO NOT EDIT.\n") + b.WriteString("// Source: gotd canonical schema (Layer 227) diffed against TDesktop api.tl@N.\n\n") + b.WriteString("package layerwire\n\n") + b.WriteString("// generatedTables maps a supported client layer to its canonical(227)->layer\n") + b.WriteString("// downgrade table. See docs/layer-compat-220-227-design.md.\n") + b.WriteString("var generatedTables = map[int]layerRaw{\n") + + // inbound 方法升级(扁平:老方法 CRC -> 227 CRC)。老 CRC 本身编码了格式,故无需 layer 维度。 + // 仅收"升级安全"的方法:227 新增字段全为 flag-gated 条件字段(老客户端清零位=零字节, + // 其 body 本就是合法 227 body,换 4 字节 CRC 即可交给 227 handler)。 + inboundUpgrades := map[uint32]uint32{} // oldCRC -> 227CRC + inboundUnsafe := map[string]string{} // qname -> reason + + for L := supportedFloor; L < canonicalLayer; L++ { + tgt, err := load(filepath.Join(schemaDir, fmt.Sprintf("layer-%d.tl", L))) + if err != nil { + return err + } + r := diff(canon, tgt) + for _, c := range r.changedMethods { + canonSpec := canon.byName[c.qname] + if reason := methodUpgradeSafe(canonSpec, c.res); reason == "" { + inboundUpgrades[c.toCRC] = c.fromCRC // client(old) -> canonical(227) + } else if _, done := inboundUpgrades[c.toCRC]; !done { + inboundUnsafe[c.qname] = reason + } + } + fmt.Fprintf(&b, "\t%d: {\n", L) + + sort.Slice(r.changedTypes, func(i, j int) bool { return r.changedTypes[i].fromCRC < r.changedTypes[j].fromCRC }) + b.WriteString("\t\trules: map[uint32]ruleRaw{\n") + for _, c := range r.changedTypes { + canonSpec := canon.byName[c.qname] + if c.res.mechanical { + dropSet := map[string]bool{} + for _, d := range c.res.drops { + dropSet[d] = true + } + var keep []string + for _, p := range canonSpec.params { + if !dropSet[p.Name] { + keep = append(keep, p.Name) + } + } + fmt.Fprintf(&b, "\t\t\t0x%08x: {target: 0x%08x, keep: %s}, // %s\n", c.fromCRC, c.toCRC, goStrSlice(keep), c.qname) + } else { + fmt.Fprintf(&b, "\t\t\t0x%08x: {target: 0x%08x, structural: %q}, // %s\n", c.fromCRC, c.toCRC, c.qname, c.res.reason) + } + } + b.WriteString("\t\t},\n") + + var newCRC []uint32 + for _, q := range r.newTypes { + if sp := canon.byName[q]; sp != nil { + newCRC = append(newCRC, sp.crc) + } + } + sort.Slice(newCRC, func(i, j int) bool { return newCRC[i] < newCRC[j] }) + b.WriteString("\t\tnewTypes: []uint32{") + for i, c := range newCRC { + if i%6 == 0 { + b.WriteString("\n\t\t\t") + } + fmt.Fprintf(&b, "0x%08x, ", c) + } + if len(newCRC) > 0 { + b.WriteString("\n\t\t") + } + b.WriteString("},\n") + b.WriteString("\t},\n") + } + b.WriteString("}\n\n") + + // Flat inbound method CRC upgrade table. + b.WriteString("// inboundMethodUpgrades maps an old client method constructor id to the\n") + b.WriteString("// canonical (227) id. Only upgrade-safe changes (all 227 additions flag-gated)\n") + b.WriteString("// are listed: rewriting the 4-byte id yields a valid 227 request body.\n") + if len(inboundUnsafe) > 0 { + b.WriteString("// NOT upgrade-safe as a pure id swap (declare a body transform in client-drift.tl when needed):\n") + for _, q := range sortedKeys(inboundUnsafe) { + fmt.Fprintf(&b, "// %s: %s\n", q, inboundUnsafe[q]) + } + } + b.WriteString("var inboundMethodUpgrades = map[uint32]uint32{\n") + oldCRCs := make([]uint32, 0, len(inboundUpgrades)) + for old := range inboundUpgrades { + oldCRCs = append(oldCRCs, old) + } + sort.Slice(oldCRCs, func(i, j int) bool { return oldCRCs[i] < oldCRCs[j] }) + for _, old := range oldCRCs { + fmt.Fprintf(&b, "\t0x%08x: 0x%08x, // %s\n", old, inboundUpgrades[old], canon.byCRC[inboundUpgrades[old]].qname) + } + b.WriteString("}\n") + + formatted, err := format.Source([]byte(b.String())) + if err != nil { + _ = os.WriteFile(outPath, []byte(b.String()), 0o644) + return fmt.Errorf("gofmt: %w", err) + } + return os.WriteFile(outPath, formatted, 0o644) +} + +// methodUpgradeSafe reports "" if a layer-N request body for a changed method +// is also a valid 227 body after only swapping the constructor id — i.e. the +// downgrade is mechanical and every 227-only field is flag-gated (a conditional +// field the old client leaves clear ⇒ zero wire bytes). A 227-only non-conditional +// field or an inserted flags integer breaks the byte alignment ⇒ unsafe. +func methodUpgradeSafe(canonSpec *spec, res classifyResult) string { + if !res.mechanical { + return res.reason + } + byName := map[string]tl.Parameter{} + for _, p := range canonSpec.params { + byName[p.Name] = p + } + for _, d := range res.drops { + p, ok := byName[d] + if !ok { + return fmt.Sprintf("dropped field %q not in canonical", d) + } + if p.Flags { + return fmt.Sprintf("227 inserts flags integer %q", d) + } + if p.Flag == nil { + return fmt.Sprintf("227-only field %q is non-conditional", d) + } + } + return "" +} + +func goStrSlice(ss []string) string { + var b strings.Builder + b.WriteString("[]string{") + for i, s := range ss { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(strconv.Quote(s)) + } + b.WriteString("}") + return b.String() +} + +func sortedKeys[V any](m map[string]V) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Strings(ks) + return ks +} diff --git a/internal/compat/layerwire/inbound.go b/internal/compat/layerwire/inbound.go new file mode 100644 index 00000000..1695fc0a --- /dev/null +++ b/internal/compat/layerwire/inbound.go @@ -0,0 +1,260 @@ +package layerwire + +import ( + _ "embed" + "encoding/binary" + "fmt" + + "github.com/gotd/td/bin" +) + +// Canonical ids used to synthesize converted/defaulted values. +const ( + inputUserID = 0xf21158c6 // inputUser user_id:long access_hash:long + inputMessageID = 0xa676a322 // inputMessageID id:int + boolFalseID = 0xbc799737 // boolFalse +) + +//go:embed schema/client-drift.tl +var clientDriftSchema string + +// driftModel holds the declared old-layout of each client-drift constructor. +var driftModel = mustLoadDrift() + +func mustLoadDrift() *schemaModel { + m, err := parseSchemaModel(clientDriftSchema) + if err != nil { + panic("layerwire: parse client-drift schema: " + err.Error()) + } + return m +} + +// driftFieldRenames maps a canonical field that was renamed from the client's +// old constructor: key "\x00" -> old field. +// Pure schema diff cannot recover a rename, so it is declared here (data, not a +// transform). It is the only thing a structural rename needs. +var driftFieldRenames = map[string]string{ + "bots.exportBotToken\x00bot": "bot_id", +} + +// fieldConverter rewrites one field whose wire type changed between the old and +// canonical layout. Keyed by "->"; raw is the old field's +// encoded bytes. Reusable across any method with the same type change. +type fieldConverter func(raw []byte, out *bin.Buffer) error + +var fieldConverters = map[string]fieldConverter{ + // id:Vector -> id:Vector (wrap each int in inputMessageID). + "Vector->Vector": func(raw []byte, out *bin.Buffer) error { + in := &bin.Buffer{Buf: raw} + n, err := in.VectorHeader() + if err != nil { + return err + } + out.PutVectorHeader(n) + for i := 0; i < n; i++ { + v, err := in.Int() + if err != nil { + return err + } + out.PutID(inputMessageID) + out.PutInt(v) + } + return nil + }, + // bot_id:long -> bot:InputUser{user_id, access_hash=0}. + "long->InputUser": func(raw []byte, out *bin.Buffer) error { + in := &bin.Buffer{Buf: raw} + id, err := in.Long() + if err != nil { + return err + } + out.PutID(inputUserID) + out.PutLong(id) + out.PutLong(0) + return nil + }, +} + +// UpgradeInbound converts an old client's inbound request to canonical (227) +// form so the normal gotd dispatcher can handle it. It unifies three data-driven +// sources, all of which require no per-method handler code: +// - inboundMethodUpgrades (generated from api.tl diff): official layer drift. +// - clientMethodAliases (client_aliases.go): body-identical client drift. +// - driftModel (client-drift.tl): body-different client drift, upgraded by the +// generic engine below. +// +// ok=false means no upgrade applies. On ok=true the returned buffer (canonical +// id + body) is what to dispatch. +func UpgradeInbound(id uint32, in *bin.Buffer) (*bin.Buffer, bool, error) { + if newID, ok := UpgradeMethodCRC(id); ok { + if len(in.Buf) < 4 { + return nil, false, fmt.Errorf("layerwire: short inbound buffer for %#08x", id) + } + // Copy rather than rewrite in place: never mutate the caller's buffer + // (matches the body-transform path, which also returns a fresh buffer). + out := &bin.Buffer{Buf: append([]byte(nil), in.Buf...)} + binary.LittleEndian.PutUint32(out.Buf[:4], newID) + return out, true, nil + } + if old := driftModel.byCRC[id]; old != nil { + out, err := upgradeFromDrift(old, in) + if err != nil { + return nil, false, fmt.Errorf("layerwire: upgrade %s (%#08x): %w", old.name, id, err) + } + return out, true, nil + } + return nil, false, nil +} + +// IsClientDrift reports whether id is a client-private constructor (DrKLO +// constructor drift), as opposed to official layer drift from api.tl. +func IsClientDrift(id uint32) bool { + if _, ok := clientMethodAliases[id]; ok { + return true + } + return driftModel.byCRC[id] != nil +} + +// upgradeFromDrift rebuilds a canonical (227) request from an old client-drift +// body, comparing the declared old layout to the canonical layout field by field. +func upgradeFromDrift(old *ctorLayout, in *bin.Buffer) (*bin.Buffer, error) { + target := canonical.byName[old.name] + if target == nil { + return nil, fmt.Errorf("no canonical method %q", old.name) + } + if err := in.ConsumeID(old.crc); err != nil { + return nil, err + } + + // Decode the old body: capture each present field's raw bytes + flag ints. + vals := make(map[string][]byte, len(old.fields)) + present := make(map[string]bool, len(old.fields)) + oldFlags := make(map[string]uint32, 2) + oldByName := make(map[string]*fieldLayout, len(old.fields)) + for i := range old.fields { + f := &old.fields[i] + oldByName[f.name] = f + if f.isFlags { + v, err := in.Uint32() + if err != nil { + return nil, err + } + oldFlags[f.name] = v + continue + } + if f.conditional() && oldFlags[f.flagName]&(1<"+typeSig(nf)] + if conv == nil { + return nil, fmt.Errorf("field %q: no converter %s->%s", nf.name, typeSig(of), typeSig(nf)) + } + if err := conv(vals[oldName], out); err != nil { + return nil, fmt.Errorf("field %q convert: %w", nf.name, err) + } + } else { + out.Put(vals[oldName]) // shared field, identical wire (kindTrue => no bytes) + } + continue + } + // Canonical-only field absent in old. + if nf.conditional() || nf.kind == kindTrue { + continue // optional: leave absent (its flag bit is clear) + } + if err := writeDefault(nf, out); err != nil { + return nil, fmt.Errorf("field %q default: %w", nf.name, err) + } + } + return out, nil +} + +// writeDefault writes the zero value of a required canonical-only field. +func writeDefault(f *fieldLayout, out *bin.Buffer) error { + switch f.kind { + case kindInt: + out.PutInt(0) + case kindLong: + out.PutLong(0) + case kindDouble: + out.PutDouble(0) + case kindInt128: + out.PutInt128(bin.Int128{}) + case kindInt256: + out.PutInt256(bin.Int256{}) + case kindBytes: + out.PutBytes(nil) + case kindString: + out.PutString("") + case kindBool: + out.PutID(boolFalseID) + case kindVector: + out.PutVectorHeader(0) + case kindVectorBare: + out.PutInt(0) + default: + return fmt.Errorf("cannot default kind %d (boxed object needs a transform)", f.kind) + } + return nil +} + +// typeSig is a stable wire-type signature for matching/converter lookup. +func typeSig(f *fieldLayout) string { + switch f.kind { + case kindInt: + return "int" + case kindLong: + return "long" + case kindDouble: + return "double" + case kindInt128: + return "int128" + case kindInt256: + return "int256" + case kindBytes: + return "bytes" + case kindString: + return "string" + case kindBool: + return "Bool" + case kindTrue: + return "true" + case kindVector: + return "Vector<" + typeSig(f.elem) + ">" + case kindVectorBare: + return "vector<" + typeSig(f.elem) + ">" + case kindObject, kindBareObject: + return f.typeName + default: + return fmt.Sprintf("kind%d", f.kind) + } +} diff --git a/internal/compat/layerwire/inbound_test.go b/internal/compat/layerwire/inbound_test.go new file mode 100644 index 00000000..1d4028b2 --- /dev/null +++ b/internal/compat/layerwire/inbound_test.go @@ -0,0 +1,115 @@ +package layerwire + +import "testing" + +// TestInboundUpgradeTableWellFormed checks every inbound upgrade maps an old id +// to a real canonical method id, and that the old id is genuinely historical +// (not already a canonical constructor). +func TestInboundUpgradeTableWellFormed(t *testing.T) { + if len(inboundMethodUpgrades) == 0 { + t.Fatal("inboundMethodUpgrades is empty") + } + for oldID, newID := range inboundMethodUpgrades { + cl := canonical.byCRC[newID] + if cl == nil { + t.Errorf("upgrade target %#08x is not a canonical constructor", newID) + continue + } + if !cl.isFunc { + t.Errorf("upgrade target %s (%#08x) is not a method", cl.name, newID) + } + if oldID == newID { + t.Errorf("%s: old id equals canonical id %#08x", cl.name, oldID) + } + if prev := canonical.byCRC[oldID]; prev != nil { + t.Errorf("old id %#08x collides with canonical %s", oldID, prev.name) + } + } +} + +// TestClientMethodAliasesWellFormed checks every hand-maintained client-drift +// alias maps to a real canonical method, and is reachable via UpgradeMethodCRC. +func TestClientMethodAliasesWellFormed(t *testing.T) { + for oldID, newID := range clientMethodAliases { + cl := canonical.byCRC[newID] + if cl == nil || !cl.isFunc { + t.Errorf("alias target %#08x is not a canonical method", newID) + } + if _, ok := inboundMethodUpgrades[oldID]; ok { + t.Errorf("alias %#08x duplicates a generated upgrade entry", oldID) + } + if got, ok := UpgradeMethodCRC(oldID); !ok || got != newID { + t.Errorf("UpgradeMethodCRC(%#08x) = (%#08x,%v), want (%#08x,true)", oldID, got, ok, newID) + } + } +} + +// TestInboundDriftCoverage is the inbound drift gate: it statically proves every +// client-drift constructor in client-drift.tl can be upgraded to its canonical +// method — shared fields match (or have a converter), canonical-only required +// fields are defaultable, and renamed fields are mapped. Adding a TL line that +// isn't auto-upgradable fails here, telling the author exactly what converter or +// rename to declare (instead of discovering it at runtime). +func TestInboundDriftCoverage(t *testing.T) { + defaultable := map[wireKind]bool{ + kindInt: true, kindLong: true, kindDouble: true, kindInt128: true, kindInt256: true, + kindBytes: true, kindString: true, kindBool: true, kindVector: true, kindVectorBare: true, + } + for crc, old := range driftModel.byCRC { + target := canonical.byName[old.name] + if target == nil { + t.Errorf("drift %s (%#08x): no canonical method of that name", old.name, crc) + continue + } + oldHas := map[string]*fieldLayout{} + for i := range old.fields { + oldHas[old.fields[i].name] = &old.fields[i] + } + for i := range target.fields { + nf := &target.fields[i] + if nf.isFlags { + continue + } + oldName := nf.name + if m, ok := driftFieldRenames[old.name+"\x00"+nf.name]; ok { + oldName = m + } + if of, ok := oldHas[oldName]; ok { + if typeSig(of) != typeSig(nf) && fieldConverters[typeSig(of)+"->"+typeSig(nf)] == nil { + t.Errorf("drift %s: field %q needs converter %s->%s", old.name, nf.name, typeSig(of), typeSig(nf)) + } + continue + } + if nf.conditional() || nf.kind == kindTrue { + continue // optional canonical-only field — left absent + } + if !defaultable[nf.kind] { + t.Errorf("drift %s: canonical-only required field %q (kind %d) is not defaultable; declare a transform", old.name, nf.name, nf.kind) + } + } + } +} + +// TestInboundUpgradeSendMessage validates the full chain for the highest-value +// method: a layer-220 client's messages.sendMessage id upgrades to the 227 id. +func TestInboundUpgradeSendMessage(t *testing.T) { + m220 := loadLayerModel(t, 220) + old, ok := m220.byName["messages.sendMessage"] + if !ok { + t.Fatal("messages.sendMessage missing from layer-220 schema") + } + canon, ok := canonical.byName["messages.sendMessage"] + if !ok { + t.Fatal("messages.sendMessage missing from canonical schema") + } + if old.crc == canon.crc { + t.Skip("sendMessage unchanged 220->227; nothing to upgrade") + } + newID, ok := UpgradeMethodCRC(old.crc) + if !ok { + t.Fatalf("sendMessage@220 (%#08x) not in upgrade table", old.crc) + } + if newID != canon.crc { + t.Fatalf("sendMessage upgrade = %#08x, want canonical %#08x", newID, canon.crc) + } +} diff --git a/internal/compat/layerwire/inbound_transform_test.go b/internal/compat/layerwire/inbound_transform_test.go new file mode 100644 index 00000000..de9cfe65 --- /dev/null +++ b/internal/compat/layerwire/inbound_transform_test.go @@ -0,0 +1,184 @@ +package layerwire + +import ( + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" +) + +// validateMethodRequest asserts that buf holds a single canonical (227) method +// request: its leading id equals wantCRC and the canonical walker consumes every +// byte (proving the rebuilt body matches the 227 layout). +func validateMethodRequest(t *testing.T, buf *bin.Buffer, wantCRC uint32, label string) { + t.Helper() + id, err := (&bin.Buffer{Buf: buf.Buf}).PeekID() + if err != nil { + t.Fatalf("%s: peek id: %v", label, err) + } + if id != wantCRC { + t.Fatalf("%s: id = %#08x, want %#08x", label, id, wantCRC) + } + probe := &bin.Buffer{Buf: append([]byte(nil), buf.Buf...)} + if err := canonical.skipObject(probe); err != nil { + t.Fatalf("%s: result not a valid 227 request: %v", label, err) + } + if probe.Len() != 0 { + t.Fatalf("%s: %d trailing bytes in rebuilt request", label, probe.Len()) + } +} + +func TestInboundBodyTransforms(t *testing.T) { + // uploadMedia: peer + media -> flags + peer + media. + t.Run("uploadMedia", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x519bc2b1) + _ = (&tg.InputPeerSelf{}).Encode(&in) + _ = (&tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 10, Parts: 1, Name: "a.jpg"}}).Encode(&in) + out, ok, err := UpgradeInbound(0x519bc2b1, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, 0x14967978, "uploadMedia") + }) + + t.Run("authSignUp", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x80eee427) + in.PutString("+15550000000") + in.PutString("hash") + in.PutString("First") + in.PutString("Last") + out, ok, err := UpgradeInbound(0x80eee427, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, 0xaac7b717, "authSignUp") + }) + + t.Run("channelsGetMessages", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x93d7b347) + _ = (&tg.InputChannel{ChannelID: 4, AccessHash: 5}).Encode(&in) + in.PutVectorHeader(2) + in.PutInt(11) + in.PutInt(12) + out, ok, err := UpgradeInbound(0x93d7b347, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, 0xad8c9a23, "channelsGetMessages") + }) + + t.Run("botsExportBotToken", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x0063b089) + in.PutLong(777) + in.PutID(0x997275b5) // boolTrue (revoke) + out, ok, err := UpgradeInbound(0x0063b089, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, 0xbd0d99eb, "botsExportBotToken") + }) + + t.Run("accountRegisterDevice", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x637ea878) + in.PutInt(2) + in.PutString("token-blob") + out, ok, err := UpgradeInbound(0x637ea878, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, 0xec86017a, "accountRegisterDevice") + }) + + t.Run("contactsSearch", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x11f812d8) + in.PutString("ngame") + in.PutInt(20) + out, ok, err := UpgradeInbound(0x11f812d8, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, tg.ContactsSearchRequestTypeID, "contactsSearch") + var req tg.ContactsSearchRequest + if err := req.Decode(&bin.Buffer{Buf: append([]byte(nil), out.Buf...)}); err != nil { + t.Fatalf("decode upgraded contacts.search: %v", err) + } + if req.Flags != 0 || req.Q != "ngame" || req.Limit != 20 { + t.Fatalf("upgraded contacts.search = flags:%#x q:%q limit:%d", req.Flags, req.Q, req.Limit) + } + }) + + t.Run("langpackGetLangPack", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x9ab5c58e) + in.PutString("en") + out, ok, err := UpgradeInbound(0x9ab5c58e, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, 0xf2f2330a, "langpackGetLangPack") + }) + + t.Run("langpackGetStrings", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x2e1ee318) + in.PutString("en") + in.PutVectorHeader(2) + in.PutString("key1") + in.PutString("key2") + out, ok, err := UpgradeInbound(0x2e1ee318, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, 0xefea3803, "langpackGetStrings") + }) + + t.Run("langpackGetLanguages", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x800fd57d) + out, ok, err := UpgradeInbound(0x800fd57d, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, 0x42c6978f, "langpackGetLanguages") + }) +} + +// TestInboundCRCSwaps covers the body-compatible client-drift methods that only +// need a 4-byte id swap. +func TestInboundCRCSwaps(t *testing.T) { + t.Run("updatesGetDifference", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x25939651) + in.PutUint32(0) // flags (no pts_total_limit) + in.PutInt(100) // pts + in.PutInt(200) // date + in.PutInt(0) // qts + out, ok, err := UpgradeInbound(0x25939651, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, 0x19c2f763, "updatesGetDifference") + }) + + t.Run("createChat", func(t *testing.T) { + var in bin.Buffer + in.PutID(0x0034a818) + if err := (&tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: 2, AccessHash: 3}}, + Title: "Group", + }).EncodeBare(&in); err != nil { + t.Fatalf("encode createChat body: %v", err) + } + out, ok, err := UpgradeInbound(0x0034a818, &in) + if !ok || err != nil { + t.Fatalf("upgrade: ok=%v err=%v", ok, err) + } + validateMethodRequest(t, out, 0x92ceddd4, "createChat") + }) +} diff --git a/internal/compat/layerwire/layout.go b/internal/compat/layerwire/layout.go new file mode 100644 index 00000000..803e2dbc --- /dev/null +++ b/internal/compat/layerwire/layout.go @@ -0,0 +1,204 @@ +// Package layerwire downgrades canonical (Layer 227, the bytes gotd actually +// emits) TL objects to the wire shape expected by older clients (down to +// Layer 220), and is the runtime half of docs/layer-compat-220-227-design.md. +// +// The package is schema-driven: at init it parses the embedded canonical-227 +// schema into a per-constructor field layout used by a generic walker; the +// generate-time tables (tables_gen.go, produced by ./gen) describe the +// per-layer downgrade rules. Business handlers and gotd are never touched — +// they always produce Layer 227, and transcoding happens only at the edge and +// only when the negotiated client layer is below 227. +package layerwire + +import ( + _ "embed" + "fmt" + "strings" + + "github.com/gotd/tl" +) + +// CanonicalLayer is the layer telesrv's pinned gotd emits. +const CanonicalLayer = 227 + +// SupportedFloor is the oldest client layer the transcoder targets. +const SupportedFloor = 220 + +// vectorTypeID is the boxed Vector constructor id. +const vectorTypeID = 0x1cb5c415 + +//go:embed schema/canonical-227.tl +var canonicalSchema string + +// wireKind is the on-wire representation of a single TL value. +type wireKind uint8 + +const ( + kindInt wireKind = iota // 4 bytes + kindLong // 8 bytes + kindDouble // 8 bytes + kindInt128 // 16 bytes + kindInt256 // 32 bytes + kindBytes // TL bytes (length-prefixed, padded) + kindString // TL string (same wire as bytes) + kindBool // boxed Bool (4-byte CRC) + kindTrue // flag-only pseudo value, 0 bytes + kindVector // boxed Vector (0x1cb5c415 + n + elems) + kindVectorBare // bare vector (n + elems, no id) + kindObject // boxed object (4-byte CRC + body) + kindBareObject // bare object (body only, resolved by typeName) +) + +// fieldLayout is one parameter of a constructor with its decoded wire shape. +type fieldLayout struct { + name string + kind wireKind + isFlags bool // this is a `#` flags integer + flagName string // when conditional: which flags integer gates it + flagBit int // when conditional: bit index; -1 otherwise + elem *fieldLayout // vector element layout + typeName string // object/bareObject: (qualified) referenced type name +} + +func (f fieldLayout) conditional() bool { return f.flagBit >= 0 } + +// ctorLayout is the decoded field layout of a single constructor. +type ctorLayout struct { + crc uint32 + name string // qualified TL name, e.g. "messages.dialogs" / "message" + result string // qualified result (abstract) type name + fields []fieldLayout + isFunc bool +} + +// schemaModel is the parsed canonical schema indexed for the walker. +type schemaModel struct { + byCRC map[uint32]*ctorLayout + byName map[string]*ctorLayout // qualified ctor name -> layout + bareByT map[string]*ctorLayout // bare type name -> its single constructor + ctorsOfT map[string][]*ctorLayout // abstract result type -> constructors +} + +// canonical is the parsed Layer 227 model, built once at init. +var canonical = mustLoadCanonical() + +func mustLoadCanonical() *schemaModel { + m, err := parseSchemaModel(canonicalSchema) + if err != nil { + panic("layerwire: parse canonical schema: " + err.Error()) + } + return m +} + +func qualifyName(ns []string, name string) string { + if len(ns) == 0 { + return name + } + return strings.Join(ns, ".") + "." + name +} + +func qualifyType(t tl.Type) string { + return qualifyName(t.Namespace, t.Name) +} + +func parseSchemaModel(src string) (*schemaModel, error) { + parsed, err := tl.Parse(strings.NewReader(src)) + if err != nil { + return nil, err + } + m := &schemaModel{ + byCRC: make(map[uint32]*ctorLayout), + byName: make(map[string]*ctorLayout), + bareByT: make(map[string]*ctorLayout), + ctorsOfT: make(map[string][]*ctorLayout), + } + for i := range parsed.Definitions { + sd := parsed.Definitions[i] + d := sd.Definition + name := qualifyName(d.Namespace, d.Name) + if name == "vector" { + continue // implicit Vector pseudo-definition + } + cl := &ctorLayout{ + crc: d.ID, + name: name, + result: qualifyType(d.Type), + isFunc: sd.Category == tl.CategoryFunction, + } + for _, p := range d.Params { + fl, err := toFieldLayout(p) + if err != nil { + return nil, fmt.Errorf("%s field %q: %w", name, p.Name, err) + } + cl.fields = append(cl.fields, fl) + } + if prev, ok := m.byCRC[cl.crc]; ok && prev.name != cl.name { + return nil, fmt.Errorf("crc collision %#08x: %s vs %s", cl.crc, prev.name, cl.name) + } + m.byCRC[cl.crc] = cl + m.byName[cl.name] = cl + if !cl.isFunc { + m.ctorsOfT[cl.result] = append(m.ctorsOfT[cl.result], cl) + // A bare type name is the lowercase constructor name itself. + m.bareByT[cl.name] = cl + } + } + return m, nil +} + +func toFieldLayout(p tl.Parameter) (fieldLayout, error) { + if p.Flags { + return fieldLayout{name: p.Name, kind: kindInt, isFlags: true, flagBit: -1}, nil + } + fl := fieldLayout{name: p.Name, flagBit: -1} + if p.Flag != nil { + fl.flagName = p.Flag.Name + fl.flagBit = p.Flag.Index + } + kind, typeName, elem, err := resolveType(p.Type) + if err != nil { + return fieldLayout{}, err + } + fl.kind = kind + fl.typeName = typeName + fl.elem = elem + return fl, nil +} + +func resolveType(t tl.Type) (kind wireKind, typeName string, elem *fieldLayout, err error) { + if t.GenericArg != nil { + ek, etn, eel, eerr := resolveType(*t.GenericArg) + if eerr != nil { + return 0, "", nil, eerr + } + el := &fieldLayout{kind: ek, typeName: etn, elem: eel, flagBit: -1} + if t.Name == "vector" { // bare vector + return kindVectorBare, "", el, nil + } + return kindVector, "", el, nil + } + switch t.Name { + case "int": + return kindInt, "", nil, nil + case "long": + return kindLong, "", nil, nil + case "double": + return kindDouble, "", nil, nil + case "int128": + return kindInt128, "", nil, nil + case "int256": + return kindInt256, "", nil, nil + case "bytes": + return kindBytes, "", nil, nil + case "string": + return kindString, "", nil, nil + case "Bool": + return kindBool, "", nil, nil + case "true": + return kindTrue, "", nil, nil + } + if t.Bare { + return kindBareObject, qualifyType(t), nil, nil + } + return kindObject, qualifyType(t), nil, nil +} diff --git a/internal/compat/layerwire/schema/canonical-227.tl b/internal/compat/layerwire/schema/canonical-227.tl new file mode 100644 index 00000000..a79d0991 --- /dev/null +++ b/internal/compat/layerwire/schema/canonical-227.tl @@ -0,0 +1,4828 @@ +// Code generated by ./cmd/dltl, DO NOT EDIT. +// +// Source: https://raw.githubusercontent.com/telegramdesktop/tdesktop/dev/Telegram/SourceFiles/mtproto/scheme/api.tl +// Layer: 227 +// SHA256: f8fe06d2279657cc36f7d5280a28fa00a176ca6c99b5208038850b40e9aa5518 + +boolFalse#bc799737 = Bool; + +boolTrue#997275b5 = Bool; + +true#3fedd339 = True; + +error#c4b9f9bb code:int text:string = Error; + +null#56730bcc = Null; + +inputPeerEmpty#7f3b18ea = InputPeer; + +inputPeerSelf#7da07ec9 = InputPeer; + +inputPeerChat#35a95cb9 chat_id:long = InputPeer; + +inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer; + +inputPeerChannel#27bcbbfc channel_id:long access_hash:long = InputPeer; + +inputPeerUserFromMessage#a87b0a1c peer:InputPeer msg_id:int user_id:long = InputPeer; + +inputPeerChannelFromMessage#bd2a0840 peer:InputPeer msg_id:int channel_id:long = InputPeer; + +inputUserEmpty#b98886cf = InputUser; + +inputUserSelf#f7c1b13f = InputUser; + +inputUser#f21158c6 user_id:long access_hash:long = InputUser; + +inputUserFromMessage#1da448e2 peer:InputPeer msg_id:int user_id:long = InputUser; + +inputPhoneContact#6a1dc4be flags:# client_id:long phone:string first_name:string last_name:string note:flags.0?TextWithEntities = InputContact; + +inputFile#f52ff27f id:long parts:int name:string md5_checksum:string = InputFile; + +inputFileBig#fa4f0bb5 id:long parts:int name:string = InputFile; + +inputFileStoryDocument#62dc8b48 id:InputDocument = InputFile; + +inputMediaEmpty#9664f57f = InputMedia; + +inputMediaUploadedPhoto#7d8375da flags:# spoiler:flags.2?true live_photo:flags.3?true file:InputFile stickers:flags.0?Vector ttl_seconds:flags.1?int video:flags.3?InputDocument = InputMedia; + +inputMediaPhoto#e3af4434 flags:# spoiler:flags.1?true live_photo:flags.2?true id:InputPhoto ttl_seconds:flags.0?int video:flags.2?InputDocument = InputMedia; + +inputMediaGeoPoint#f9c44144 geo_point:InputGeoPoint = InputMedia; + +inputMediaContact#f8ab7dfb phone_number:string first_name:string last_name:string vcard:string = InputMedia; + +inputMediaUploadedDocument#37c9330 flags:# nosound_video:flags.3?true force_file:flags.4?true spoiler:flags.5?true file:InputFile thumb:flags.2?InputFile mime_type:string attributes:Vector stickers:flags.0?Vector video_cover:flags.6?InputPhoto video_timestamp:flags.7?int ttl_seconds:flags.1?int = InputMedia; + +inputMediaDocument#a8763ab5 flags:# spoiler:flags.2?true id:InputDocument video_cover:flags.3?InputPhoto video_timestamp:flags.4?int ttl_seconds:flags.0?int query:flags.1?string = InputMedia; + +inputMediaVenue#c13d1c11 geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string = InputMedia; + +inputMediaPhotoExternal#e5bbfe1a flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int = InputMedia; + +inputMediaDocumentExternal#779600f9 flags:# spoiler:flags.1?true url:string ttl_seconds:flags.0?int video_cover:flags.2?InputPhoto video_timestamp:flags.3?int = InputMedia; + +inputMediaGame#d33f43f3 id:InputGame = InputMedia; + +inputMediaInvoice#405fef0d flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:flags.3?string provider_data:DataJSON start_param:flags.1?string extended_media:flags.2?InputMedia = InputMedia; + +inputMediaGeoLive#971fa843 flags:# stopped:flags.0?true geo_point:InputGeoPoint heading:flags.2?int period:flags.1?int proximity_notification_radius:flags.3?int = InputMedia; + +inputMediaPoll#883a4108 flags:# poll:Poll correct_answers:flags.0?Vector attached_media:flags.3?InputMedia solution:flags.1?string solution_entities:flags.1?Vector solution_media:flags.2?InputMedia = InputMedia; + +inputMediaDice#e66fbf7b emoticon:string = InputMedia; + +inputMediaStory#89fdd778 peer:InputPeer id:int = InputMedia; + +inputMediaWebPage#c21b8849 flags:# force_large_media:flags.0?true force_small_media:flags.1?true optional:flags.2?true url:string = InputMedia; + +inputMediaPaidMedia#c4103386 flags:# stars_amount:long extended_media:Vector payload:flags.0?string = InputMedia; + +inputMediaTodo#9fc55fde todo:TodoList = InputMedia; + +inputMediaStakeDice#f3a9244a game_hash:string ton_amount:long client_seed:bytes = InputMedia; + +inputChatPhotoEmpty#1ca48f57 = InputChatPhoto; + +inputChatUploadedPhoto#bdcdaec0 flags:# file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.3?VideoSize = InputChatPhoto; + +inputChatPhoto#8953ad37 id:InputPhoto = InputChatPhoto; + +inputGeoPointEmpty#e4c123d6 = InputGeoPoint; + +inputGeoPoint#48222faf flags:# lat:double long:double accuracy_radius:flags.0?int = InputGeoPoint; + +inputPhotoEmpty#1cd7bf0d = InputPhoto; + +inputPhoto#3bb3b94a id:long access_hash:long file_reference:bytes = InputPhoto; + +inputFileLocation#dfdaabe1 volume_id:long local_id:int secret:long file_reference:bytes = InputFileLocation; + +inputEncryptedFileLocation#f5235d55 id:long access_hash:long = InputFileLocation; + +inputDocumentFileLocation#bad07584 id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; + +inputSecureFileLocation#cbc7ee28 id:long access_hash:long = InputFileLocation; + +inputTakeoutFileLocation#29be5899 = InputFileLocation; + +inputPhotoFileLocation#40181ffe id:long access_hash:long file_reference:bytes thumb_size:string = InputFileLocation; + +inputPhotoLegacyFileLocation#d83466f3 id:long access_hash:long file_reference:bytes volume_id:long local_id:int secret:long = InputFileLocation; + +inputPeerPhotoFileLocation#37257e99 flags:# big:flags.0?true peer:InputPeer photo_id:long = InputFileLocation; + +inputStickerSetThumb#9d84f3db stickerset:InputStickerSet thumb_version:int = InputFileLocation; + +inputGroupCallStream#598a92a flags:# call:InputGroupCall time_ms:long scale:int video_channel:flags.0?int video_quality:flags.0?int = InputFileLocation; + +peerUser#59511722 user_id:long = Peer; + +peerChat#36c6019a chat_id:long = Peer; + +peerChannel#a2a5371e channel_id:long = Peer; + +storage.fileUnknown#aa963b05 = storage.FileType; + +storage.filePartial#40bc6f52 = storage.FileType; + +storage.fileJpeg#7efe0e = storage.FileType; + +storage.fileGif#cae1aadf = storage.FileType; + +storage.filePng#a4f63c0 = storage.FileType; + +storage.filePdf#ae1e508d = storage.FileType; + +storage.fileMp3#528a0677 = storage.FileType; + +storage.fileMov#4b09ebbc = storage.FileType; + +storage.fileMp4#b3cea0e4 = storage.FileType; + +storage.fileWebp#1081464c = storage.FileType; + +userEmpty#d3bc4b7a id:long = User; + +user#31774388 flags:# self:flags.10?true contact:flags.11?true mutual_contact:flags.12?true deleted:flags.13?true bot:flags.14?true bot_chat_history:flags.15?true bot_nochats:flags.16?true verified:flags.17?true restricted:flags.18?true min:flags.20?true bot_inline_geo:flags.21?true support:flags.23?true scam:flags.24?true apply_min_photo:flags.25?true fake:flags.26?true bot_attach_menu:flags.27?true premium:flags.28?true attach_menu_enabled:flags.29?true flags2:# bot_can_edit:flags2.1?true close_friend:flags2.2?true stories_hidden:flags2.3?true stories_unavailable:flags2.4?true contact_require_premium:flags2.10?true bot_business:flags2.11?true bot_has_main_app:flags2.13?true bot_forum_view:flags2.16?true bot_forum_can_manage_topics:flags2.17?true bot_can_manage_bots:flags2.18?true bot_guestchat:flags2.19?true bot_guard:flags2.20?true id:long access_hash:flags.0?long first_name:flags.1?string last_name:flags.2?string username:flags.3?string phone:flags.4?string photo:flags.5?UserProfilePhoto status:flags.6?UserStatus bot_info_version:flags.14?int restriction_reason:flags.18?Vector bot_inline_placeholder:flags.19?string lang_code:flags.22?string emoji_status:flags.30?EmojiStatus usernames:flags2.0?Vector stories_max_id:flags2.5?RecentStory color:flags2.8?PeerColor profile_color:flags2.9?PeerColor bot_active_users:flags2.12?int bot_verification_icon:flags2.14?long send_paid_messages_stars:flags2.15?long = User; + +userProfilePhotoEmpty#4f11bae1 = UserProfilePhoto; + +userProfilePhoto#82d1f706 flags:# has_video:flags.0?true personal:flags.2?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = UserProfilePhoto; + +userStatusEmpty#9d05049 = UserStatus; + +userStatusOnline#edb93949 expires:int = UserStatus; + +userStatusOffline#8c703f was_online:int = UserStatus; + +userStatusRecently#7b197dc8 flags:# by_me:flags.0?true = UserStatus; + +userStatusLastWeek#541a1d1a flags:# by_me:flags.0?true = UserStatus; + +userStatusLastMonth#65899777 flags:# by_me:flags.0?true = UserStatus; + +chatEmpty#29562865 id:long = Chat; + +chat#41cbf256 flags:# creator:flags.0?true left:flags.2?true deactivated:flags.5?true call_active:flags.23?true call_not_empty:flags.24?true noforwards:flags.25?true id:long title:string photo:ChatPhoto participants_count:int date:int version:int migrated_to:flags.6?InputChannel admin_rights:flags.14?ChatAdminRights default_banned_rights:flags.18?ChatBannedRights = Chat; + +chatForbidden#6592a1a7 id:long title:string = Chat; + +channel#1c32b11c flags:# creator:flags.0?true left:flags.2?true broadcast:flags.5?true verified:flags.7?true megagroup:flags.8?true restricted:flags.9?true signatures:flags.11?true min:flags.12?true scam:flags.19?true has_link:flags.20?true has_geo:flags.21?true slowmode_enabled:flags.22?true call_active:flags.23?true call_not_empty:flags.24?true fake:flags.25?true gigagroup:flags.26?true noforwards:flags.27?true join_to_send:flags.28?true join_request:flags.29?true forum:flags.30?true flags2:# stories_hidden:flags2.1?true stories_hidden_min:flags2.2?true stories_unavailable:flags2.3?true signature_profiles:flags2.12?true autotranslation:flags2.15?true broadcast_messages_allowed:flags2.16?true monoforum:flags2.17?true forum_tabs:flags2.19?true id:long access_hash:flags.13?long title:string username:flags.6?string photo:ChatPhoto date:int restriction_reason:flags.9?Vector admin_rights:flags.14?ChatAdminRights banned_rights:flags.15?ChatBannedRights default_banned_rights:flags.18?ChatBannedRights participants_count:flags.17?int usernames:flags2.0?Vector stories_max_id:flags2.4?RecentStory color:flags2.7?PeerColor profile_color:flags2.8?PeerColor emoji_status:flags2.9?EmojiStatus level:flags2.10?int subscription_until_date:flags2.11?int bot_verification_icon:flags2.13?long send_paid_messages_stars:flags2.14?long linked_monoforum_id:flags2.18?long = Chat; + +channelForbidden#17d493d5 flags:# broadcast:flags.5?true megagroup:flags.8?true monoforum:flags.10?true id:long access_hash:long title:string until_date:flags.16?int = Chat; + +chatFull#2633421b flags:# can_set_username:flags.7?true has_scheduled:flags.8?true translations_disabled:flags.19?true id:long about:string participants:ChatParticipants chat_photo:flags.2?Photo notify_settings:PeerNotifySettings exported_invite:flags.13?ExportedChatInvite bot_info:flags.3?Vector pinned_msg_id:flags.6?int folder_id:flags.11?int call:flags.12?InputGroupCall ttl_period:flags.14?int groupcall_default_join_as:flags.15?Peer theme_emoticon:flags.16?string requests_pending:flags.17?int recent_requesters:flags.17?Vector available_reactions:flags.18?ChatReactions reactions_limit:flags.20?int = ChatFull; + +channelFull#a04e8d3a flags:# can_view_participants:flags.3?true can_set_username:flags.6?true can_set_stickers:flags.7?true hidden_prehistory:flags.10?true can_set_location:flags.16?true has_scheduled:flags.19?true can_view_stats:flags.20?true blocked:flags.22?true flags2:# can_delete_channel:flags2.0?true antispam:flags2.1?true participants_hidden:flags2.2?true translations_disabled:flags2.3?true stories_pinned_available:flags2.5?true view_forum_as_messages:flags2.6?true restricted_sponsored:flags2.11?true can_view_revenue:flags2.12?true paid_media_allowed:flags2.14?true can_view_stars_revenue:flags2.15?true paid_reactions_available:flags2.16?true stargifts_available:flags2.19?true paid_messages_available:flags2.20?true id:long about:string participants_count:flags.0?int admins_count:flags.1?int kicked_count:flags.2?int banned_count:flags.2?int online_count:flags.13?int read_inbox_max_id:int read_outbox_max_id:int unread_count:int chat_photo:Photo notify_settings:PeerNotifySettings exported_invite:flags.23?ExportedChatInvite bot_info:Vector migrated_from_chat_id:flags.4?long migrated_from_max_id:flags.4?int pinned_msg_id:flags.5?int stickerset:flags.8?StickerSet available_min_id:flags.9?int folder_id:flags.11?int linked_chat_id:flags.14?long location:flags.15?ChannelLocation slowmode_seconds:flags.17?int slowmode_next_send_date:flags.18?int stats_dc:flags.12?int pts:int call:flags.21?InputGroupCall ttl_period:flags.24?int pending_suggestions:flags.25?Vector groupcall_default_join_as:flags.26?Peer theme_emoticon:flags.27?string requests_pending:flags.28?int recent_requesters:flags.28?Vector default_send_as:flags.29?Peer available_reactions:flags.30?ChatReactions reactions_limit:flags2.13?int stories:flags2.4?PeerStories wallpaper:flags2.7?WallPaper boosts_applied:flags2.8?int boosts_unrestrict:flags2.9?int emojiset:flags2.10?StickerSet bot_verification:flags2.17?BotVerification stargifts_count:flags2.18?int send_paid_messages_stars:flags2.21?long main_tab:flags2.22?ProfileTab guard_bot_id:flags2.23?long = ChatFull; + +chatParticipant#38e79fde flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; + +chatParticipantCreator#e1f867b8 flags:# user_id:long rank:flags.0?string = ChatParticipant; + +chatParticipantAdmin#360d5d2 flags:# user_id:long inviter_id:long date:int rank:flags.0?string = ChatParticipant; + +chatParticipantsForbidden#8763d3e1 flags:# chat_id:long self_participant:flags.0?ChatParticipant = ChatParticipants; + +chatParticipants#3cbc93f8 chat_id:long participants:Vector version:int = ChatParticipants; + +chatPhotoEmpty#37c1011c = ChatPhoto; + +chatPhoto#1c6e1c11 flags:# has_video:flags.0?true photo_id:long stripped_thumb:flags.1?bytes dc_id:int = ChatPhoto; + +messageEmpty#90a6ca84 flags:# id:int peer_id:flags.0?Peer = Message; + +message#7600b9d3 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true post:flags.14?true from_scheduled:flags.18?true legacy:flags.19?true edit_hide:flags.21?true pinned:flags.24?true noforwards:flags.26?true invert_media:flags.27?true flags2:# offline:flags2.1?true video_processing_pending:flags2.4?true paid_suggested_post_stars:flags2.8?true paid_suggested_post_ton:flags2.9?true id:int from_id:flags.8?Peer from_boosts_applied:flags.29?int from_rank:flags2.12?string peer_id:Peer saved_peer_id:flags.28?Peer fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long via_business_bot_id:flags2.0?long guestchat_via_from:flags2.19?Peer reply_to:flags.3?MessageReplyHeader date:int message:string media:flags.9?MessageMedia reply_markup:flags.6?ReplyMarkup entities:flags.7?Vector views:flags.10?int forwards:flags.10?int replies:flags.23?MessageReplies edit_date:flags.15?int post_author:flags.16?string grouped_id:flags.17?long reactions:flags.20?MessageReactions restriction_reason:flags.22?Vector ttl_period:flags.25?int quick_reply_shortcut_id:flags.30?int effect:flags2.2?long factcheck:flags2.3?FactCheck report_delivery_until_date:flags2.5?int paid_message_stars:flags2.6?long suggested_post:flags2.7?SuggestedPost schedule_repeat_period:flags2.10?int summary_from_language:flags2.11?string rich_message:flags2.13?RichMessage = Message; + +messageService#7a800e0a flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true reactions_are_possible:flags.9?true silent:flags.13?true post:flags.14?true legacy:flags.19?true id:int from_id:flags.8?Peer peer_id:Peer saved_peer_id:flags.28?Peer reply_to:flags.3?MessageReplyHeader date:int action:MessageAction reactions:flags.20?MessageReactions ttl_period:flags.25?int = Message; + +messageMediaEmpty#3ded6320 = MessageMedia; + +messageMediaPhoto#e216eb63 flags:# spoiler:flags.3?true live_photo:flags.4?true photo:flags.0?Photo ttl_seconds:flags.2?int video:flags.4?Document = MessageMedia; + +messageMediaGeo#56e0d474 geo:GeoPoint = MessageMedia; + +messageMediaContact#70322949 phone_number:string first_name:string last_name:string vcard:string user_id:long = MessageMedia; + +messageMediaUnsupported#9f84f49e = MessageMedia; + +messageMediaDocument#52d8ccd9 flags:# nopremium:flags.3?true spoiler:flags.4?true video:flags.6?true round:flags.7?true voice:flags.8?true document:flags.0?Document alt_documents:flags.5?Vector video_cover:flags.9?Photo video_timestamp:flags.10?int ttl_seconds:flags.2?int = MessageMedia; + +messageMediaWebPage#ddf10c3b flags:# force_large_media:flags.0?true force_small_media:flags.1?true manual:flags.3?true safe:flags.4?true webpage:WebPage = MessageMedia; + +messageMediaVenue#2ec0533f geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MessageMedia; + +messageMediaGame#fdb19008 game:Game = MessageMedia; + +messageMediaInvoice#f6a548d3 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument receipt_msg_id:flags.2?int currency:string total_amount:long start_param:string extended_media:flags.4?MessageExtendedMedia = MessageMedia; + +messageMediaGeoLive#b940c666 flags:# geo:GeoPoint heading:flags.0?int period:int proximity_notification_radius:flags.1?int = MessageMedia; + +messageMediaPoll#773f4e66 flags:# poll:Poll results:PollResults attached_media:flags.0?MessageMedia = MessageMedia; + +messageMediaDice#8cbec07 flags:# value:int emoticon:string game_outcome:flags.0?messages.EmojiGameOutcome = MessageMedia; + +messageMediaStory#68cb6283 flags:# via_mention:flags.1?true peer:Peer id:int story:flags.0?StoryItem = MessageMedia; + +messageMediaGiveaway#aa073beb flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.2?true channels:Vector countries_iso2:flags.1?Vector prize_description:flags.3?string quantity:int months:flags.4?int stars:flags.5?long until_date:int = MessageMedia; + +messageMediaGiveawayResults#ceaa3ea1 flags:# only_new_subscribers:flags.0?true refunded:flags.2?true channel_id:long additional_peers_count:flags.3?int launch_msg_id:int winners_count:int unclaimed_count:int winners:Vector months:flags.4?int stars:flags.5?long prize_description:flags.1?string until_date:int = MessageMedia; + +messageMediaPaidMedia#a8852491 stars_amount:long extended_media:Vector = MessageMedia; + +messageMediaToDo#8a53b014 flags:# todo:TodoList completions:flags.0?Vector = MessageMedia; + +messageMediaVideoStream#ca5cab89 flags:# rtmp_stream:flags.0?true call:InputGroupCall = MessageMedia; + +messageActionEmpty#b6aef7b0 = MessageAction; + +messageActionChatCreate#bd47cbad title:string users:Vector = MessageAction; + +messageActionChatEditTitle#b5a1ce5a title:string = MessageAction; + +messageActionChatEditPhoto#7fcb13a8 photo:Photo = MessageAction; + +messageActionChatDeletePhoto#95e3fbef = MessageAction; + +messageActionChatAddUser#15cefd00 users:Vector = MessageAction; + +messageActionChatDeleteUser#a43f30cc user_id:long = MessageAction; + +messageActionChatJoinedByLink#31224c3 inviter_id:long = MessageAction; + +messageActionChannelCreate#95d2ac92 title:string = MessageAction; + +messageActionChatMigrateTo#e1037f92 channel_id:long = MessageAction; + +messageActionChannelMigrateFrom#ea3948e9 title:string chat_id:long = MessageAction; + +messageActionPinMessage#94bd38ed = MessageAction; + +messageActionHistoryClear#9fbab604 = MessageAction; + +messageActionGameScore#92a72876 game_id:long score:int = MessageAction; + +messageActionPaymentSentMe#ffa00ccc flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string charge:PaymentCharge subscription_until_date:flags.4?int = MessageAction; + +messageActionPaymentSent#c624b16e flags:# recurring_init:flags.2?true recurring_used:flags.3?true currency:string total_amount:long invoice_slug:flags.0?string subscription_until_date:flags.4?int = MessageAction; + +messageActionPhoneCall#80e11a7f flags:# video:flags.2?true call_id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = MessageAction; + +messageActionScreenshotTaken#4792929b = MessageAction; + +messageActionCustomAction#fae69f56 message:string = MessageAction; + +messageActionBotAllowed#c516d679 flags:# attach_menu:flags.1?true from_request:flags.3?true domain:flags.0?string app:flags.2?BotApp = MessageAction; + +messageActionSecureValuesSentMe#1b287353 values:Vector credentials:SecureCredentialsEncrypted = MessageAction; + +messageActionSecureValuesSent#d95c6154 types:Vector = MessageAction; + +messageActionContactSignUp#f3f25f76 = MessageAction; + +messageActionGeoProximityReached#98e0d697 from_id:Peer to_id:Peer distance:int = MessageAction; + +messageActionGroupCall#7a0d7f42 flags:# call:InputGroupCall duration:flags.0?int = MessageAction; + +messageActionInviteToGroupCall#502f92f7 call:InputGroupCall users:Vector = MessageAction; + +messageActionSetMessagesTTL#3c134d7b flags:# period:int auto_setting_from:flags.0?long = MessageAction; + +messageActionGroupCallScheduled#b3a07661 call:InputGroupCall schedule_date:int = MessageAction; + +messageActionSetChatTheme#b91bbd3a theme:ChatTheme = MessageAction; + +messageActionChatJoinedByRequest#ebbca3cb = MessageAction; + +messageActionWebViewDataSentMe#47dd8079 text:string data:string = MessageAction; + +messageActionWebViewDataSent#b4c38cb5 text:string = MessageAction; + +messageActionGiftPremium#48e91302 flags:# currency:string amount:long days:int crypto_currency:flags.0?string crypto_amount:flags.0?long message:flags.1?TextWithEntities = MessageAction; + +messageActionTopicCreate#d999256 flags:# title_missing:flags.1?true title:string icon_color:int icon_emoji_id:flags.0?long = MessageAction; + +messageActionTopicEdit#c0944820 flags:# title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = MessageAction; + +messageActionSuggestProfilePhoto#57de635e photo:Photo = MessageAction; + +messageActionRequestedPeer#31518e9b button_id:int peers:Vector = MessageAction; + +messageActionSetChatWallPaper#5060a3f4 flags:# same:flags.0?true for_both:flags.1?true wallpaper:WallPaper = MessageAction; + +messageActionGiftCode#31c48347 flags:# via_giveaway:flags.0?true unclaimed:flags.5?true boost_peer:flags.1?Peer days:int slug:string currency:flags.2?string amount:flags.2?long crypto_currency:flags.3?string crypto_amount:flags.3?long message:flags.4?TextWithEntities = MessageAction; + +messageActionGiveawayLaunch#a80f51e4 flags:# stars:flags.0?long = MessageAction; + +messageActionGiveawayResults#87e2f155 flags:# stars:flags.0?true winners_count:int unclaimed_count:int = MessageAction; + +messageActionBoostApply#cc02aa6d boosts:int = MessageAction; + +messageActionRequestedPeerSentMe#93b31848 button_id:int peers:Vector = MessageAction; + +messageActionPaymentRefunded#41b3e202 flags:# peer:Peer currency:string total_amount:long payload:flags.0?bytes charge:PaymentCharge = MessageAction; + +messageActionGiftStars#45d5b021 flags:# currency:string amount:long stars:long crypto_currency:flags.0?string crypto_amount:flags.0?long transaction_id:flags.1?string = MessageAction; + +messageActionPrizeStars#b00c47a2 flags:# unclaimed:flags.0?true stars:long transaction_id:string boost_peer:Peer giveaway_msg_id:int = MessageAction; + +messageActionStarGift#ea2c31d3 flags:# name_hidden:flags.0?true saved:flags.2?true converted:flags.3?true upgraded:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true prepaid_upgrade:flags.13?true upgrade_separate:flags.16?true auction_acquired:flags.17?true gift:StarGift message:flags.1?TextWithEntities convert_stars:flags.4?long upgrade_msg_id:flags.5?int upgrade_stars:flags.8?long from_id:flags.11?Peer peer:flags.12?Peer saved_id:flags.12?long prepaid_upgrade_hash:flags.14?string gift_msg_id:flags.15?int to_id:flags.18?Peer gift_num:flags.19?int = MessageAction; + +messageActionStarGiftUnique#e6c31522 flags:# upgrade:flags.0?true transferred:flags.1?true saved:flags.2?true refunded:flags.5?true prepaid_upgrade:flags.11?true assigned:flags.13?true from_offer:flags.14?true craft:flags.16?true gift:StarGift can_export_at:flags.3?int transfer_stars:flags.4?long from_id:flags.6?Peer peer:flags.7?Peer saved_id:flags.7?long resale_amount:flags.8?StarsAmount can_transfer_at:flags.9?int can_resell_at:flags.10?int drop_original_details_stars:flags.12?long can_craft_at:flags.15?int = MessageAction; + +messageActionPaidMessagesRefunded#ac1f1fcd count:int stars:long = MessageAction; + +messageActionPaidMessagesPrice#84b88578 flags:# broadcast_messages_allowed:flags.0?true stars:long = MessageAction; + +messageActionConferenceCall#2ffe2f7a flags:# missed:flags.0?true active:flags.1?true video:flags.4?true call_id:long duration:flags.2?int other_participants:flags.3?Vector = MessageAction; + +messageActionTodoCompletions#cc7c5c89 completed:Vector incompleted:Vector = MessageAction; + +messageActionTodoAppendTasks#c7edbc83 list:Vector = MessageAction; + +messageActionSuggestedPostApproval#ee7a1596 flags:# rejected:flags.0?true balance_too_low:flags.1?true reject_comment:flags.2?string schedule_date:flags.3?int price:flags.4?StarsAmount = MessageAction; + +messageActionSuggestedPostSuccess#95ddcf69 price:StarsAmount = MessageAction; + +messageActionSuggestedPostRefund#69f916f8 flags:# payer_initiated:flags.0?true = MessageAction; + +messageActionGiftTon#a8a3c699 flags:# currency:string amount:long crypto_currency:string crypto_amount:long transaction_id:flags.0?string = MessageAction; + +messageActionSuggestBirthday#2c8f2a25 birthday:Birthday = MessageAction; + +messageActionStarGiftPurchaseOffer#774278d4 flags:# accepted:flags.0?true declined:flags.1?true gift:StarGift price:StarsAmount expires_at:int = MessageAction; + +messageActionStarGiftPurchaseOfferDeclined#73ada76b flags:# expired:flags.0?true gift:StarGift price:StarsAmount = MessageAction; + +messageActionNewCreatorPending#b07ed085 new_creator_id:long = MessageAction; + +messageActionChangeCreator#e188503b new_creator_id:long = MessageAction; + +messageActionNoForwardsToggle#bf7d6572 prev_value:Bool new_value:Bool = MessageAction; + +messageActionNoForwardsRequest#3e2793ba flags:# expired:flags.0?true prev_value:Bool new_value:Bool = MessageAction; + +messageActionPollAppendAnswer#9da1cd6c answer:PollAnswer = MessageAction; + +messageActionPollDeleteAnswer#399674dc answer:PollAnswer = MessageAction; + +messageActionManagedBotCreated#16605e3e bot_id:long = MessageAction; + +dialog#fc89f7f3 flags:# pinned:flags.2?true unread_mark:flags.3?true view_forum_as_messages:flags.6?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int notify_settings:PeerNotifySettings pts:flags.0?int draft:flags.1?DraftMessage folder_id:flags.4?int ttl_period:flags.5?int = Dialog; + +dialogFolder#71bd134c flags:# pinned:flags.2?true folder:Folder peer:Peer top_message:int unread_muted_peers_count:int unread_unmuted_peers_count:int unread_muted_messages_count:int unread_unmuted_messages_count:int = Dialog; + +photoEmpty#2331b22d id:long = Photo; + +photo#fb197a65 flags:# has_stickers:flags.0?true id:long access_hash:long file_reference:bytes date:int sizes:Vector video_sizes:flags.1?Vector dc_id:int = Photo; + +photoSizeEmpty#e17e23c type:string = PhotoSize; + +photoSize#75c78e60 type:string w:int h:int size:int = PhotoSize; + +photoCachedSize#21e1ad6 type:string w:int h:int bytes:bytes = PhotoSize; + +photoStrippedSize#e0b0bc2e type:string bytes:bytes = PhotoSize; + +photoSizeProgressive#fa3efb95 type:string w:int h:int sizes:Vector = PhotoSize; + +photoPathSize#d8214d41 type:string bytes:bytes = PhotoSize; + +geoPointEmpty#1117dd5f = GeoPoint; + +geoPoint#b2a2f663 flags:# long:double lat:double access_hash:long accuracy_radius:flags.0?int = GeoPoint; + +auth.sentCode#5e002502 flags:# type:auth.SentCodeType phone_code_hash:string next_type:flags.1?auth.CodeType timeout:flags.2?int = auth.SentCode; + +auth.sentCodeSuccess#2390fe44 authorization:auth.Authorization = auth.SentCode; + +auth.sentCodePaymentRequired#f8827ebf store_product:string phone_code_hash:string support_email_address:string support_email_subject:string premium_days:int currency:string amount:long = auth.SentCode; + +auth.authorization#2ea2c0d4 flags:# setup_password_required:flags.1?true otherwise_relogin_days:flags.1?int tmp_sessions:flags.0?int future_auth_token:flags.2?bytes user:User = auth.Authorization; + +auth.authorizationSignUpRequired#44747e9a flags:# terms_of_service:flags.0?help.TermsOfService = auth.Authorization; + +auth.exportedAuthorization#b434e2b8 id:long bytes:bytes = auth.ExportedAuthorization; + +inputNotifyPeer#b8bc5b0c peer:InputPeer = InputNotifyPeer; + +inputNotifyUsers#193b4417 = InputNotifyPeer; + +inputNotifyChats#4a95e84e = InputNotifyPeer; + +inputNotifyBroadcasts#b1db7c7e = InputNotifyPeer; + +inputNotifyForumTopic#5c467992 peer:InputPeer top_msg_id:int = InputNotifyPeer; + +inputPeerNotifySettings#cacb6ae2 flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int sound:flags.3?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_sound:flags.8?NotificationSound = InputPeerNotifySettings; + +peerNotifySettings#99622c0c flags:# show_previews:flags.0?Bool silent:flags.1?Bool mute_until:flags.2?int ios_sound:flags.3?NotificationSound android_sound:flags.4?NotificationSound other_sound:flags.5?NotificationSound stories_muted:flags.6?Bool stories_hide_sender:flags.7?Bool stories_ios_sound:flags.8?NotificationSound stories_android_sound:flags.9?NotificationSound stories_other_sound:flags.10?NotificationSound = PeerNotifySettings; + +peerSettings#f47741f7 flags:# report_spam:flags.0?true add_contact:flags.1?true block_contact:flags.2?true share_contact:flags.3?true need_contacts_exception:flags.4?true report_geo:flags.5?true autoarchived:flags.7?true invite_members:flags.8?true request_chat_broadcast:flags.10?true business_bot_paused:flags.11?true business_bot_can_reply:flags.12?true geo_distance:flags.6?int request_chat_title:flags.9?string request_chat_date:flags.9?int business_bot_id:flags.13?long business_bot_manage_url:flags.13?string charge_paid_message_stars:flags.14?long registration_month:flags.15?string phone_country:flags.16?string name_change_date:flags.17?int photo_change_date:flags.18?int = PeerSettings; + +wallPaper#a437c3ed id:long flags:# creator:flags.0?true default:flags.1?true pattern:flags.3?true dark:flags.4?true access_hash:long slug:string document:Document settings:flags.2?WallPaperSettings = WallPaper; + +wallPaperNoFile#e0804116 id:long flags:# default:flags.1?true dark:flags.4?true settings:flags.2?WallPaperSettings = WallPaper; + +inputReportReasonSpam#58dbcab8 = ReportReason; + +inputReportReasonViolence#1e22c78d = ReportReason; + +inputReportReasonPornography#2e59d922 = ReportReason; + +inputReportReasonChildAbuse#adf44ee3 = ReportReason; + +inputReportReasonOther#c1e4a2b1 = ReportReason; + +inputReportReasonCopyright#9b89f93a = ReportReason; + +inputReportReasonGeoIrrelevant#dbd4feed = ReportReason; + +inputReportReasonFake#f5ddd6e7 = ReportReason; + +inputReportReasonIllegalDrugs#a8eb2be = ReportReason; + +inputReportReasonPersonalDetails#9ec7863d = ReportReason; + +userFull#6cbe645 flags:# blocked:flags.0?true phone_calls_available:flags.4?true phone_calls_private:flags.5?true can_pin_message:flags.7?true has_scheduled:flags.12?true video_calls_available:flags.13?true voice_messages_forbidden:flags.20?true translations_disabled:flags.23?true stories_pinned_available:flags.26?true blocked_my_stories_from:flags.27?true wallpaper_overridden:flags.28?true contact_require_premium:flags.29?true read_dates_private:flags.30?true flags2:# sponsored_enabled:flags2.7?true can_view_revenue:flags2.9?true bot_can_manage_emoji_status:flags2.10?true display_gifts_button:flags2.16?true noforwards_my_enabled:flags2.23?true noforwards_peer_enabled:flags2.24?true unofficial_security_risk:flags2.26?true id:long about:flags.1?string settings:PeerSettings personal_photo:flags.21?Photo profile_photo:flags.2?Photo fallback_photo:flags.22?Photo notify_settings:PeerNotifySettings bot_info:flags.3?BotInfo pinned_msg_id:flags.6?int common_chats_count:int folder_id:flags.11?int ttl_period:flags.14?int theme:flags.15?ChatTheme private_forward_name:flags.16?string bot_group_admin_rights:flags.17?ChatAdminRights bot_broadcast_admin_rights:flags.18?ChatAdminRights wallpaper:flags.24?WallPaper stories:flags.25?PeerStories business_work_hours:flags2.0?BusinessWorkHours business_location:flags2.1?BusinessLocation business_greeting_message:flags2.2?BusinessGreetingMessage business_away_message:flags2.3?BusinessAwayMessage business_intro:flags2.4?BusinessIntro birthday:flags2.5?Birthday personal_channel_id:flags2.6?long personal_channel_message:flags2.6?int stargifts_count:flags2.8?int starref_program:flags2.11?StarRefProgram bot_verification:flags2.12?BotVerification send_paid_messages_stars:flags2.14?long disallowed_gifts:flags2.15?DisallowedGiftsSettings stars_rating:flags2.17?StarsRating stars_my_pending_rating:flags2.18?StarsRating stars_my_pending_rating_date:flags2.18?int main_tab:flags2.20?ProfileTab saved_music:flags2.21?Document note:flags2.22?TextWithEntities bot_manager_id:flags2.25?long = UserFull; + +contact#145ade0b user_id:long mutual:Bool = Contact; + +importedContact#c13e3c50 user_id:long client_id:long = ImportedContact; + +contactStatus#16d9703b user_id:long status:UserStatus = ContactStatus; + +contacts.contactsNotModified#b74ba9d2 = contacts.Contacts; + +contacts.contacts#eae87e42 contacts:Vector saved_count:int users:Vector = contacts.Contacts; + +contacts.importedContacts#77d01c3b imported:Vector popular_invites:Vector retry_contacts:Vector users:Vector = contacts.ImportedContacts; + +contacts.blocked#ade1591 blocked:Vector chats:Vector users:Vector = contacts.Blocked; + +contacts.blockedSlice#e1664194 count:int blocked:Vector chats:Vector users:Vector = contacts.Blocked; + +messages.dialogs#15ba6c40 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; + +messages.dialogsSlice#71e094f3 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.Dialogs; + +messages.dialogsNotModified#f0e3e596 count:int = messages.Dialogs; + +messages.messages#1d73e7ea messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; + +messages.messagesSlice#5f206716 flags:# inexact:flags.1?true count:int next_rate:flags.0?int offset_id_offset:flags.2?int search_flood:flags.3?SearchPostsFlood messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; + +messages.channelMessages#c776ba4e flags:# inexact:flags.1?true pts:int count:int offset_id_offset:flags.2?int messages:Vector topics:Vector chats:Vector users:Vector = messages.Messages; + +messages.messagesNotModified#74535f21 count:int = messages.Messages; + +messages.chats#64ff9fd5 chats:Vector = messages.Chats; + +messages.chatsSlice#9cd81144 count:int chats:Vector = messages.Chats; + +messages.chatFull#e5d7d19c full_chat:ChatFull chats:Vector users:Vector = messages.ChatFull; + +messages.affectedHistory#b45c69d1 pts:int pts_count:int offset:int = messages.AffectedHistory; + +inputMessagesFilterEmpty#57e2f66c = MessagesFilter; + +inputMessagesFilterPhotos#9609a51c = MessagesFilter; + +inputMessagesFilterVideo#9fc00e65 = MessagesFilter; + +inputMessagesFilterPhotoVideo#56e9f0e4 = MessagesFilter; + +inputMessagesFilterDocument#9eddf188 = MessagesFilter; + +inputMessagesFilterUrl#7ef0dd87 = MessagesFilter; + +inputMessagesFilterGif#ffc86587 = MessagesFilter; + +inputMessagesFilterVoice#50f5c392 = MessagesFilter; + +inputMessagesFilterMusic#3751b49e = MessagesFilter; + +inputMessagesFilterChatPhotos#3a20ecb8 = MessagesFilter; + +inputMessagesFilterPhoneCalls#80c99768 flags:# missed:flags.0?true = MessagesFilter; + +inputMessagesFilterRoundVoice#7a7c17a4 = MessagesFilter; + +inputMessagesFilterRoundVideo#b549da53 = MessagesFilter; + +inputMessagesFilterMyMentions#c1f8e69a = MessagesFilter; + +inputMessagesFilterGeo#e7026d0d = MessagesFilter; + +inputMessagesFilterContacts#e062db83 = MessagesFilter; + +inputMessagesFilterPinned#1bb00451 = MessagesFilter; + +inputMessagesFilterPoll#fa2bc90a = MessagesFilter; + +updateNewMessage#1f2b0afd message:Message pts:int pts_count:int = Update; + +updateMessageID#4e90bfd6 id:int random_id:long = Update; + +updateDeleteMessages#a20db0e5 messages:Vector pts:int pts_count:int = Update; + +updateUserTyping#2a17bf5c flags:# user_id:long top_msg_id:flags.0?int action:SendMessageAction = Update; + +updateChatUserTyping#83487af0 chat_id:long from_id:Peer action:SendMessageAction = Update; + +updateChatParticipants#7761198 participants:ChatParticipants = Update; + +updateUserStatus#e5bdf8de user_id:long status:UserStatus = Update; + +updateUserName#a7848924 user_id:long first_name:string last_name:string usernames:Vector = Update; + +updateNewAuthorization#8951abef flags:# unconfirmed:flags.0?true hash:long date:flags.0?int device:flags.0?string location:flags.0?string = Update; + +updateNewEncryptedMessage#12bcbd9a message:EncryptedMessage qts:int = Update; + +updateEncryptedChatTyping#1710f156 chat_id:int = Update; + +updateEncryption#b4a2e88d chat:EncryptedChat date:int = Update; + +updateEncryptedMessagesRead#38fe25b7 chat_id:int max_date:int date:int = Update; + +updateChatParticipantAdd#3dda5451 chat_id:long user_id:long inviter_id:long date:int version:int = Update; + +updateChatParticipantDelete#e32f3d77 chat_id:long user_id:long version:int = Update; + +updateDcOptions#8e5e9873 dc_options:Vector = Update; + +updateNotifySettings#bec268ef peer:NotifyPeer notify_settings:PeerNotifySettings = Update; + +updateServiceNotification#ebe46819 flags:# popup:flags.0?true invert_media:flags.2?true inbox_date:flags.1?int type:string message:string media:MessageMedia entities:Vector = Update; + +updatePrivacy#ee3b272a key:PrivacyKey rules:Vector = Update; + +updateUserPhone#5492a13 user_id:long phone:string = Update; + +updateReadHistoryInbox#9e84bc99 flags:# folder_id:flags.0?int peer:Peer top_msg_id:flags.1?int max_id:int still_unread_count:int pts:int pts_count:int = Update; + +updateReadHistoryOutbox#2f2f21bf peer:Peer max_id:int pts:int pts_count:int = Update; + +updateWebPage#7f891213 webpage:WebPage pts:int pts_count:int = Update; + +updateReadMessagesContents#f8227181 flags:# messages:Vector pts:int pts_count:int date:flags.0?int = Update; + +updateChannelTooLong#108d941f flags:# channel_id:long pts:flags.0?int = Update; + +updateChannel#635b4c09 channel_id:long = Update; + +updateNewChannelMessage#62ba04d9 message:Message pts:int pts_count:int = Update; + +updateReadChannelInbox#922e6e10 flags:# folder_id:flags.0?int channel_id:long max_id:int still_unread_count:int pts:int = Update; + +updateDeleteChannelMessages#c32d5b12 channel_id:long messages:Vector pts:int pts_count:int = Update; + +updateChannelMessageViews#f226ac08 channel_id:long id:int views:int = Update; + +updateChatParticipantAdmin#d7ca61a2 chat_id:long user_id:long is_admin:Bool version:int = Update; + +updateNewStickerSet#688a30aa stickerset:messages.StickerSet = Update; + +updateStickerSetsOrder#bb2d201 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Update; + +updateStickerSets#31c24808 flags:# masks:flags.0?true emojis:flags.1?true = Update; + +updateSavedGifs#9375341e = Update; + +updateBotInlineQuery#496f379c flags:# query_id:long user_id:long query:string geo:flags.0?GeoPoint peer_type:flags.1?InlineQueryPeerType offset:string = Update; + +updateBotInlineSend#12f12a07 flags:# user_id:long query:string geo:flags.0?GeoPoint id:string msg_id:flags.1?InputBotInlineMessageID = Update; + +updateEditChannelMessage#1b3f4df7 message:Message pts:int pts_count:int = Update; + +updateBotCallbackQuery#b9cfc48d flags:# query_id:long user_id:long peer:Peer msg_id:int chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; + +updateEditMessage#e40370a3 message:Message pts:int pts_count:int = Update; + +updateInlineBotCallbackQuery#691e9052 flags:# query_id:long user_id:long msg_id:InputBotInlineMessageID chat_instance:long data:flags.0?bytes game_short_name:flags.1?string = Update; + +updateReadChannelOutbox#b75f99a9 channel_id:long max_id:int = Update; + +updateDraftMessage#edfc111e flags:# peer:Peer top_msg_id:flags.0?int saved_peer_id:flags.1?Peer draft:DraftMessage = Update; + +updateReadFeaturedStickers#571d2742 = Update; + +updateRecentStickers#9a422c20 = Update; + +updateConfig#a229dd06 = Update; + +updatePtsChanged#3354678f = Update; + +updateChannelWebPage#2f2ba99f channel_id:long webpage:WebPage pts:int pts_count:int = Update; + +updateDialogPinned#6e6fe51c flags:# pinned:flags.0?true folder_id:flags.1?int peer:DialogPeer = Update; + +updatePinnedDialogs#fa0f3ca2 flags:# folder_id:flags.1?int order:flags.0?Vector = Update; + +updateBotWebhookJSON#8317c0c3 data:DataJSON = Update; + +updateBotWebhookJSONQuery#9b9240a6 query_id:long data:DataJSON timeout:int = Update; + +updateBotShippingQuery#b5aefd7d query_id:long user_id:long payload:bytes shipping_address:PostAddress = Update; + +updateBotPrecheckoutQuery#8caa9a96 flags:# query_id:long user_id:long payload:bytes info:flags.0?PaymentRequestedInfo shipping_option_id:flags.1?string currency:string total_amount:long = Update; + +updatePhoneCall#ab0f6b1e phone_call:PhoneCall = Update; + +updateLangPackTooLong#46560264 lang_code:string = Update; + +updateLangPack#56022f4d difference:LangPackDifference = Update; + +updateFavedStickers#e511996d = Update; + +updateChannelReadMessagesContents#25f324f7 flags:# channel_id:long top_msg_id:flags.0?int saved_peer_id:flags.1?Peer messages:Vector = Update; + +updateContactsReset#7084a7be = Update; + +updateChannelAvailableMessages#b23fc698 channel_id:long available_min_id:int = Update; + +updateDialogUnreadMark#b658f23e flags:# unread:flags.0?true peer:DialogPeer saved_peer_id:flags.1?Peer = Update; + +updateMessagePoll#d64c522b flags:# peer:flags.1?Peer msg_id:flags.1?int top_msg_id:flags.2?int poll_id:long poll:flags.0?Poll results:PollResults = Update; + +updateChatDefaultBannedRights#54c01850 peer:Peer default_banned_rights:ChatBannedRights version:int = Update; + +updateFolderPeers#19360dc0 folder_peers:Vector pts:int pts_count:int = Update; + +updatePeerSettings#6a7e7366 peer:Peer settings:PeerSettings = Update; + +updatePeerLocated#b4afcfb0 peers:Vector = Update; + +updateNewScheduledMessage#39a51dfb message:Message = Update; + +updateDeleteScheduledMessages#f2a71983 flags:# peer:Peer messages:Vector sent_messages:flags.0?Vector = Update; + +updateTheme#8216fba3 theme:Theme = Update; + +updateGeoLiveViewed#871fb939 peer:Peer msg_id:int = Update; + +updateLoginToken#564fe691 = Update; + +updateMessagePollVote#7699f014 poll_id:long peer:Peer options:Vector positions:Vector qts:int = Update; + +updateDialogFilter#26ffde7d flags:# id:int filter:flags.0?DialogFilter = Update; + +updateDialogFilterOrder#a5d72105 order:Vector = Update; + +updateDialogFilters#3504914f = Update; + +updatePhoneCallSignalingData#2661bf09 phone_call_id:long data:bytes = Update; + +updateChannelMessageForwards#d29a27f4 channel_id:long id:int forwards:int = Update; + +updateReadChannelDiscussionInbox#d6b19546 flags:# channel_id:long top_msg_id:int read_max_id:int broadcast_id:flags.0?long broadcast_post:flags.0?int = Update; + +updateReadChannelDiscussionOutbox#695c9e7c channel_id:long top_msg_id:int read_max_id:int = Update; + +updatePeerBlocked#ebe07752 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer = Update; + +updateChannelUserTyping#8c88c923 flags:# channel_id:long top_msg_id:flags.0?int from_id:Peer action:SendMessageAction = Update; + +updatePinnedMessages#ed85eab5 flags:# pinned:flags.0?true peer:Peer messages:Vector pts:int pts_count:int = Update; + +updatePinnedChannelMessages#5bb98608 flags:# pinned:flags.0?true channel_id:long messages:Vector pts:int pts_count:int = Update; + +updateChat#f89a6a4e chat_id:long = Update; + +updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector version:int = Update; + +updateGroupCall#9d2216e0 flags:# live_story:flags.2?true peer:flags.1?Peer call:GroupCall = Update; + +updatePeerHistoryTTL#bb9bb9a5 flags:# peer:Peer ttl_period:flags.0?int = Update; + +updateChatParticipant#d087663a flags:# chat_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChatParticipant new_participant:flags.1?ChatParticipant invite:flags.2?ExportedChatInvite qts:int = Update; + +updateChannelParticipant#985d3abb flags:# via_chatlist:flags.3?true channel_id:long date:int actor_id:long user_id:long prev_participant:flags.0?ChannelParticipant new_participant:flags.1?ChannelParticipant invite:flags.2?ExportedChatInvite qts:int = Update; + +updateBotStopped#c4870a49 user_id:long date:int stopped:Bool qts:int = Update; + +updateGroupCallConnection#b783982 flags:# presentation:flags.0?true params:DataJSON = Update; + +updateBotCommands#4d712f2e peer:Peer bot_id:long commands:Vector = Update; + +updatePendingJoinRequests#7063c3db peer:Peer requests_pending:int recent_requesters:Vector = Update; + +updateBotChatInviteRequester#7cb34d79 flags:# peer:Peer date:int user_id:long about:string invite:ExportedChatInvite qts:int query_id:flags.0?long = Update; + +updateMessageReactions#1e297bfa flags:# peer:Peer msg_id:int top_msg_id:flags.0?int saved_peer_id:flags.1?Peer reactions:MessageReactions = Update; + +updateAttachMenuBots#17b7a20b = Update; + +updateWebViewResultSent#1592b79d query_id:long = Update; + +updateBotMenuButton#14b85813 bot_id:long button:BotMenuButton = Update; + +updateSavedRingtones#74d8be99 = Update; + +updateTranscribedAudio#84cd5a flags:# pending:flags.0?true peer:Peer msg_id:int transcription_id:long text:string = Update; + +updateReadFeaturedEmojiStickers#fb4c496c = Update; + +updateUserEmojiStatus#28373599 user_id:long emoji_status:EmojiStatus = Update; + +updateRecentEmojiStatuses#30f443db = Update; + +updateRecentReactions#6f7863f4 = Update; + +updateMoveStickerSetToTop#86fccf85 flags:# masks:flags.0?true emojis:flags.1?true stickerset:long = Update; + +updateMessageExtendedMedia#d5a41724 peer:Peer msg_id:int extended_media:Vector = Update; + +updateUser#20529438 user_id:long = Update; + +updateAutoSaveSettings#ec05b097 = Update; + +updateStory#75b3b798 peer:Peer story:StoryItem = Update; + +updateReadStories#f74e932b peer:Peer max_id:int = Update; + +updateStoryID#1bf335b9 id:int random_id:long = Update; + +updateStoriesStealthMode#2c084dc1 stealth_mode:StoriesStealthMode = Update; + +updateSentStoryReaction#7d627683 peer:Peer story_id:int reaction:Reaction = Update; + +updateBotChatBoost#904dd49c peer:Peer boost:Boost qts:int = Update; + +updateChannelViewForumAsMessages#7b68920 channel_id:long enabled:Bool = Update; + +updatePeerWallpaper#ae3f101d flags:# wallpaper_overridden:flags.1?true peer:Peer wallpaper:flags.0?WallPaper = Update; + +updateBotMessageReaction#ac21d3ce peer:Peer msg_id:int date:int actor:Peer old_reactions:Vector new_reactions:Vector qts:int = Update; + +updateBotMessageReactions#9cb7759 peer:Peer msg_id:int date:int reactions:Vector qts:int = Update; + +updateSavedDialogPinned#aeaf9e74 flags:# pinned:flags.0?true peer:DialogPeer = Update; + +updatePinnedSavedDialogs#686c85a6 flags:# order:flags.0?Vector = Update; + +updateSavedReactionTags#39c67432 = Update; + +updateSmsJob#f16269d4 job_id:string = Update; + +updateQuickReplies#f9470ab2 quick_replies:Vector = Update; + +updateNewQuickReply#f53da717 quick_reply:QuickReply = Update; + +updateDeleteQuickReply#53e6f1ec shortcut_id:int = Update; + +updateQuickReplyMessage#3e050d0f message:Message = Update; + +updateDeleteQuickReplyMessages#566fe7cd shortcut_id:int messages:Vector = Update; + +updateBotBusinessConnect#8ae5c97a connection:BotBusinessConnection qts:int = Update; + +updateBotNewBusinessMessage#9ddb347c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; + +updateBotEditBusinessMessage#7df587c flags:# connection_id:string message:Message reply_to_message:flags.0?Message qts:int = Update; + +updateBotDeleteBusinessMessage#a02a982e connection_id:string peer:Peer messages:Vector qts:int = Update; + +updateNewStoryReaction#1824e40b story_id:int peer:Peer reaction:Reaction = Update; + +updateStarsBalance#4e80a379 balance:StarsAmount = Update; + +updateBusinessBotCallbackQuery#1ea2fda7 flags:# query_id:long user_id:long connection_id:string message:Message reply_to_message:flags.2?Message chat_instance:long data:flags.0?bytes = Update; + +updateStarsRevenueStatus#a584b019 peer:Peer status:StarsRevenueStatus = Update; + +updateBotPurchasedPaidMedia#283bd312 user_id:long payload:string qts:int = Update; + +updatePaidReactionPrivacy#8b725fce private:PaidReactionPrivacy = Update; + +updateSentPhoneCode#504aa18f sent_code:auth.SentCode = Update; + +updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector next_offset:int = Update; + +updateReadMonoForumInbox#77b0e372 channel_id:long saved_peer_id:Peer read_max_id:int = Update; + +updateReadMonoForumOutbox#a4a79376 channel_id:long saved_peer_id:Peer read_max_id:int = Update; + +updateMonoForumNoPaidException#9f812b08 flags:# exception:flags.0?true channel_id:long saved_peer_id:Peer = Update; + +updateGroupCallMessage#d8326f0d call:InputGroupCall message:GroupCallMessage = Update; + +updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update; + +updatePinnedForumTopic#683b2c52 flags:# pinned:flags.0?true peer:Peer topic_id:int = Update; + +updatePinnedForumTopics#def143d0 flags:# peer:Peer order:flags.0?Vector = Update; + +updateDeleteGroupCallMessages#3e85e92c call:InputGroupCall messages:Vector = Update; + +updateStarGiftAuctionState#48e246c2 gift_id:long state:StarGiftAuctionState = Update; + +updateStarGiftAuctionUserState#dc58f31e gift_id:long user_state:StarGiftAuctionUserState = Update; + +updateEmojiGameInfo#fb9c547a info:messages.EmojiGameInfo = Update; + +updateStarGiftCraftFail#ac072444 = Update; + +updateChatParticipantRank#bd8367b9 chat_id:long user_id:long rank:string version:int = Update; + +updateManagedBot#4880ed9a user_id:long bot_id:long qts:int = Update; + +updateBotGuestChatQuery#cdd4093d flags:# query_id:long message:Message reference_messages:flags.0?Vector qts:int = Update; + +updateAiComposeTones#8c0f91fb = Update; + +updateJoinChatWebViewDecision#bdac7e70 peer:Peer query_id:long result:JoinChatBotResult = Update; + +updateNewBotConnection#b22083a6 flags:# confirmed:flags.0?true bot_id:long date:flags.1?int device:flags.1?string location:flags.1?string = Update; + +updateWebBrowserSettings#c39a2ade flags:# open_external_browser:flags.0?true display_close_button:flags.1?true = Update; + +updateWebBrowserException#140502d1 flags:# delete:flags.1?true open_external_browser:flags.0?Bool exception:WebDomainException = Update; + +updates.state#a56c2a3e pts:int qts:int date:int seq:int unread_count:int = updates.State; + +updates.differenceEmpty#5d75a138 date:int seq:int = updates.Difference; + +updates.difference#f49ca0 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector state:updates.State = updates.Difference; + +updates.differenceSlice#a8fb1981 new_messages:Vector new_encrypted_messages:Vector other_updates:Vector chats:Vector users:Vector intermediate_state:updates.State = updates.Difference; + +updates.differenceTooLong#4afe8f6d pts:int = updates.Difference; + +updatesTooLong#e317af7e = Updates; + +updateShortMessage#313bc7f8 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int user_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +updateShortChatMessage#4d6deea5 flags:# out:flags.1?true mentioned:flags.4?true media_unread:flags.5?true silent:flags.13?true id:int from_id:long chat_id:long message:string pts:int pts_count:int date:int fwd_from:flags.2?MessageFwdHeader via_bot_id:flags.11?long reply_to:flags.3?MessageReplyHeader entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +updateShort#78d4dec1 update:Update date:int = Updates; + +updatesCombined#725b04c3 updates:Vector users:Vector chats:Vector date:int seq_start:int seq:int = Updates; + +updates#74ae4240 updates:Vector users:Vector chats:Vector date:int seq:int = Updates; + +updateShortSentMessage#9015e101 flags:# out:flags.1?true id:int pts:int pts_count:int date:int media:flags.9?MessageMedia entities:flags.7?Vector ttl_period:flags.25?int = Updates; + +photos.photos#8dca6aa5 photos:Vector users:Vector = photos.Photos; + +photos.photosSlice#15051f54 count:int photos:Vector users:Vector = photos.Photos; + +photos.photo#20212ca8 photo:Photo users:Vector = photos.Photo; + +upload.file#96a18d5 type:storage.FileType mtime:int bytes:bytes = upload.File; + +upload.fileCdnRedirect#f18cda44 dc_id:int file_token:bytes encryption_key:bytes encryption_iv:bytes file_hashes:Vector = upload.File; + +dcOption#18b7a10d flags:# ipv6:flags.0?true media_only:flags.1?true tcpo_only:flags.2?true cdn:flags.3?true static:flags.4?true this_port_only:flags.5?true id:int ip_address:string port:int secret:flags.10?bytes = DcOption; + +config#cc1a241e flags:# default_p2p_contacts:flags.3?true preload_featured_stickers:flags.4?true revoke_pm_inbox:flags.6?true blocked_mode:flags.8?true force_try_ipv6:flags.14?true date:int expires:int test_mode:Bool this_dc:int dc_options:Vector dc_txt_domain_name:string chat_size_max:int megagroup_size_max:int forwarded_count_max:int online_update_period_ms:int offline_blur_timeout_ms:int offline_idle_timeout_ms:int online_cloud_timeout_ms:int notify_cloud_delay_ms:int notify_default_delay_ms:int push_chat_period_ms:int push_chat_limit:int edit_time_limit:int revoke_time_limit:int revoke_pm_time_limit:int rating_e_decay:int stickers_recent_limit:int channels_read_media_period:int tmp_sessions:flags.0?int call_receive_timeout_ms:int call_ring_timeout_ms:int call_connect_timeout_ms:int call_packet_timeout_ms:int me_url_prefix:string autoupdate_url_prefix:flags.7?string gif_search_username:flags.9?string venue_search_username:flags.10?string img_search_username:flags.11?string static_maps_provider:flags.12?string caption_length_max:int message_length_max:int webfile_dc_id:int suggested_lang_code:flags.2?string lang_pack_version:flags.2?int base_lang_pack_version:flags.2?int reactions_default:flags.15?Reaction autologin_token:flags.16?string = Config; + +nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc; + +help.appUpdate#ccbbce30 flags:# can_not_skip:flags.0?true id:int version:string text:string entities:Vector document:flags.1?Document url:flags.2?string sticker:flags.3?Document = help.AppUpdate; + +help.noAppUpdate#c45a6536 = help.AppUpdate; + +help.inviteText#18cb9f78 message:string = help.InviteText; + +encryptedChatEmpty#ab7ec0a0 id:int = EncryptedChat; + +encryptedChatWaiting#66b25953 id:int access_hash:long date:int admin_id:long participant_id:long = EncryptedChat; + +encryptedChatRequested#48f1d94c flags:# folder_id:flags.0?int id:int access_hash:long date:int admin_id:long participant_id:long g_a:bytes = EncryptedChat; + +encryptedChat#61f0d4c7 id:int access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long = EncryptedChat; + +encryptedChatDiscarded#1e1c7c45 flags:# history_deleted:flags.0?true id:int = EncryptedChat; + +inputEncryptedChat#f141b5e1 chat_id:int access_hash:long = InputEncryptedChat; + +encryptedFileEmpty#c21f497e = EncryptedFile; + +encryptedFile#a8008cd8 id:long access_hash:long size:long dc_id:int key_fingerprint:int = EncryptedFile; + +inputEncryptedFileEmpty#1837c364 = InputEncryptedFile; + +inputEncryptedFileUploaded#64bd0306 id:long parts:int md5_checksum:string key_fingerprint:int = InputEncryptedFile; + +inputEncryptedFile#5a17b5e5 id:long access_hash:long = InputEncryptedFile; + +inputEncryptedFileBigUploaded#2dc173c8 id:long parts:int key_fingerprint:int = InputEncryptedFile; + +encryptedMessage#ed18c118 random_id:long chat_id:int date:int bytes:bytes file:EncryptedFile = EncryptedMessage; + +encryptedMessageService#23734b06 random_id:long chat_id:int date:int bytes:bytes = EncryptedMessage; + +messages.dhConfigNotModified#c0e24635 random:bytes = messages.DhConfig; + +messages.dhConfig#2c221edd g:int p:bytes version:int random:bytes = messages.DhConfig; + +messages.sentEncryptedMessage#560f8935 date:int = messages.SentEncryptedMessage; + +messages.sentEncryptedFile#9493ff32 date:int file:EncryptedFile = messages.SentEncryptedMessage; + +inputDocumentEmpty#72f0eaae = InputDocument; + +inputDocument#1abfb575 id:long access_hash:long file_reference:bytes = InputDocument; + +documentEmpty#36f8c871 id:long = Document; + +document#8fd4c4d8 flags:# id:long access_hash:long file_reference:bytes date:int mime_type:string size:long thumbs:flags.0?Vector video_thumbs:flags.1?Vector dc_id:int attributes:Vector = Document; + +help.support#17c6b5f6 phone_number:string user:User = help.Support; + +notifyPeer#9fd40bd8 peer:Peer = NotifyPeer; + +notifyUsers#b4c83b4c = NotifyPeer; + +notifyChats#c007cec3 = NotifyPeer; + +notifyBroadcasts#d612e8ef = NotifyPeer; + +notifyForumTopic#226e6308 peer:Peer top_msg_id:int = NotifyPeer; + +sendMessageTypingAction#16bf744e = SendMessageAction; + +sendMessageCancelAction#fd5ec8f5 = SendMessageAction; + +sendMessageRecordVideoAction#a187d66f = SendMessageAction; + +sendMessageUploadVideoAction#e9763aec progress:int = SendMessageAction; + +sendMessageRecordAudioAction#d52f73f7 = SendMessageAction; + +sendMessageUploadAudioAction#f351d7ab progress:int = SendMessageAction; + +sendMessageUploadPhotoAction#d1d34a26 progress:int = SendMessageAction; + +sendMessageUploadDocumentAction#aa0cd9e4 progress:int = SendMessageAction; + +sendMessageGeoLocationAction#176f8ba1 = SendMessageAction; + +sendMessageChooseContactAction#628cbc6f = SendMessageAction; + +sendMessageGamePlayAction#dd6a8f48 = SendMessageAction; + +sendMessageRecordRoundAction#88f27fbc = SendMessageAction; + +sendMessageUploadRoundAction#243e1c66 progress:int = SendMessageAction; + +speakingInGroupCallAction#d92c2285 = SendMessageAction; + +sendMessageHistoryImportAction#dbda9246 progress:int = SendMessageAction; + +sendMessageChooseStickerAction#b05ac6b1 = SendMessageAction; + +sendMessageEmojiInteraction#25972bcb emoticon:string msg_id:int interaction:DataJSON = SendMessageAction; + +sendMessageEmojiInteractionSeen#b665902e emoticon:string = SendMessageAction; + +sendMessageTextDraftAction#376d975c random_id:long text:TextWithEntities = SendMessageAction; + +inputSendMessageRichMessageDraftAction#e2b23b51 random_id:long rich_message:InputRichMessage = SendMessageAction; + +sendMessageRichMessageDraftAction#a2cb24f9 random_id:long rich_message:RichMessage = SendMessageAction; + +contacts.found#b3134d9d my_results:Vector results:Vector chats:Vector users:Vector = contacts.Found; + +inputPrivacyKeyStatusTimestamp#4f96cb18 = InputPrivacyKey; + +inputPrivacyKeyChatInvite#bdfb0426 = InputPrivacyKey; + +inputPrivacyKeyPhoneCall#fabadc5f = InputPrivacyKey; + +inputPrivacyKeyPhoneP2P#db9e70d2 = InputPrivacyKey; + +inputPrivacyKeyForwards#a4dd4c08 = InputPrivacyKey; + +inputPrivacyKeyProfilePhoto#5719bacc = InputPrivacyKey; + +inputPrivacyKeyPhoneNumber#352dafa = InputPrivacyKey; + +inputPrivacyKeyAddedByPhone#d1219bdd = InputPrivacyKey; + +inputPrivacyKeyVoiceMessages#aee69d68 = InputPrivacyKey; + +inputPrivacyKeyAbout#3823cc40 = InputPrivacyKey; + +inputPrivacyKeyBirthday#d65a11cc = InputPrivacyKey; + +inputPrivacyKeyStarGiftsAutoSave#e1732341 = InputPrivacyKey; + +inputPrivacyKeyNoPaidMessages#bdc597b4 = InputPrivacyKey; + +inputPrivacyKeySavedMusic#4dbe9226 = InputPrivacyKey; + +privacyKeyStatusTimestamp#bc2eab30 = PrivacyKey; + +privacyKeyChatInvite#500e6dfa = PrivacyKey; + +privacyKeyPhoneCall#3d662b7b = PrivacyKey; + +privacyKeyPhoneP2P#39491cc8 = PrivacyKey; + +privacyKeyForwards#69ec56a3 = PrivacyKey; + +privacyKeyProfilePhoto#96151fed = PrivacyKey; + +privacyKeyPhoneNumber#d19ae46d = PrivacyKey; + +privacyKeyAddedByPhone#42ffd42b = PrivacyKey; + +privacyKeyVoiceMessages#697f414 = PrivacyKey; + +privacyKeyAbout#a486b761 = PrivacyKey; + +privacyKeyBirthday#2000a518 = PrivacyKey; + +privacyKeyStarGiftsAutoSave#2ca4fdf8 = PrivacyKey; + +privacyKeyNoPaidMessages#17d348d2 = PrivacyKey; + +privacyKeySavedMusic#ff7a571b = PrivacyKey; + +inputPrivacyValueAllowContacts#d09e07b = InputPrivacyRule; + +inputPrivacyValueAllowAll#184b35ce = InputPrivacyRule; + +inputPrivacyValueAllowUsers#131cc67f users:Vector = InputPrivacyRule; + +inputPrivacyValueDisallowContacts#ba52007 = InputPrivacyRule; + +inputPrivacyValueDisallowAll#d66b66c9 = InputPrivacyRule; + +inputPrivacyValueDisallowUsers#90110467 users:Vector = InputPrivacyRule; + +inputPrivacyValueAllowChatParticipants#840649cf chats:Vector = InputPrivacyRule; + +inputPrivacyValueDisallowChatParticipants#e94f0f86 chats:Vector = InputPrivacyRule; + +inputPrivacyValueAllowCloseFriends#2f453e49 = InputPrivacyRule; + +inputPrivacyValueAllowPremium#77cdc9f1 = InputPrivacyRule; + +inputPrivacyValueAllowBots#5a4fcce5 = InputPrivacyRule; + +inputPrivacyValueDisallowBots#c4e57915 = InputPrivacyRule; + +privacyValueAllowContacts#fffe1bac = PrivacyRule; + +privacyValueAllowAll#65427b82 = PrivacyRule; + +privacyValueAllowUsers#b8905fb2 users:Vector = PrivacyRule; + +privacyValueDisallowContacts#f888fa1a = PrivacyRule; + +privacyValueDisallowAll#8b73e763 = PrivacyRule; + +privacyValueDisallowUsers#e4621141 users:Vector = PrivacyRule; + +privacyValueAllowChatParticipants#6b134e8e chats:Vector = PrivacyRule; + +privacyValueDisallowChatParticipants#41c87565 chats:Vector = PrivacyRule; + +privacyValueAllowCloseFriends#f7e8d89b = PrivacyRule; + +privacyValueAllowPremium#ece9814b = PrivacyRule; + +privacyValueAllowBots#21461b5d = PrivacyRule; + +privacyValueDisallowBots#f6a5f82f = PrivacyRule; + +account.privacyRules#50a04e45 rules:Vector chats:Vector users:Vector = account.PrivacyRules; + +accountDaysTTL#b8d0afdf days:int = AccountDaysTTL; + +documentAttributeImageSize#6c37c15c w:int h:int = DocumentAttribute; + +documentAttributeAnimated#11b58939 = DocumentAttribute; + +documentAttributeSticker#6319d612 flags:# mask:flags.1?true alt:string stickerset:InputStickerSet mask_coords:flags.0?MaskCoords = DocumentAttribute; + +documentAttributeVideo#43c57c48 flags:# round_message:flags.0?true supports_streaming:flags.1?true nosound:flags.3?true duration:double w:int h:int preload_prefix_size:flags.2?int video_start_ts:flags.4?double video_codec:flags.5?string = DocumentAttribute; + +documentAttributeAudio#9852f9c6 flags:# voice:flags.10?true duration:int title:flags.0?string performer:flags.1?string waveform:flags.2?bytes = DocumentAttribute; + +documentAttributeFilename#15590068 file_name:string = DocumentAttribute; + +documentAttributeHasStickers#9801d2f7 = DocumentAttribute; + +documentAttributeCustomEmoji#fd149899 flags:# free:flags.0?true text_color:flags.1?true alt:string stickerset:InputStickerSet = DocumentAttribute; + +messages.stickersNotModified#f1749a22 = messages.Stickers; + +messages.stickers#30a6ec7e hash:long stickers:Vector = messages.Stickers; + +stickerPack#12b299d4 emoticon:string documents:Vector = StickerPack; + +messages.allStickersNotModified#e86602c3 = messages.AllStickers; + +messages.allStickers#cdbbcebb hash:long sets:Vector = messages.AllStickers; + +messages.affectedMessages#84d19185 pts:int pts_count:int = messages.AffectedMessages; + +webPageEmpty#211a1788 flags:# id:long url:flags.0?string = WebPage; + +webPagePending#b0d13e47 flags:# id:long url:flags.0?string date:int = WebPage; + +webPage#e89c45b2 flags:# has_large_media:flags.13?true video_cover_photo:flags.14?true id:long url:string display_url:string hash:int type:flags.0?string site_name:flags.1?string title:flags.2?string description:flags.3?string photo:flags.4?Photo embed_url:flags.5?string embed_type:flags.5?string embed_width:flags.6?int embed_height:flags.6?int duration:flags.7?int author:flags.8?string document:flags.9?Document cached_page:flags.10?Page attributes:flags.12?Vector = WebPage; + +webPageNotModified#7311ca11 flags:# cached_page_views:flags.0?int = WebPage; + +authorization#ad01d61d flags:# current:flags.0?true official_app:flags.1?true password_pending:flags.2?true encrypted_requests_disabled:flags.3?true call_requests_disabled:flags.4?true unconfirmed:flags.5?true hash:long device_model:string platform:string system_version:string api_id:int app_name:string app_version:string date_created:int date_active:int ip:string country:string region:string = Authorization; + +account.authorizations#4bff8ea0 authorization_ttl_days:int authorizations:Vector = account.Authorizations; + +account.password#957b50fb flags:# has_recovery:flags.0?true has_secure_values:flags.1?true has_password:flags.2?true current_algo:flags.2?PasswordKdfAlgo srp_B:flags.2?bytes srp_id:flags.2?long hint:flags.3?string email_unconfirmed_pattern:flags.4?string new_algo:PasswordKdfAlgo new_secure_algo:SecurePasswordKdfAlgo secure_random:bytes pending_reset_date:flags.5?int login_email_pattern:flags.6?string = account.Password; + +account.passwordSettings#9a5c33e5 flags:# email:flags.0?string secure_settings:flags.1?SecureSecretSettings = account.PasswordSettings; + +account.passwordInputSettings#c23727c9 flags:# new_algo:flags.0?PasswordKdfAlgo new_password_hash:flags.0?bytes hint:flags.0?string email:flags.1?string new_secure_settings:flags.2?SecureSecretSettings = account.PasswordInputSettings; + +auth.passwordRecovery#137948a5 email_pattern:string = auth.PasswordRecovery; + +receivedNotifyMessage#a384b779 id:int flags:int = ReceivedNotifyMessage; + +chatInviteExported#a22cbd96 flags:# revoked:flags.0?true permanent:flags.5?true request_needed:flags.6?true link:string admin_id:long date:int start_date:flags.4?int expire_date:flags.1?int usage_limit:flags.2?int usage:flags.3?int requested:flags.7?int subscription_expired:flags.10?int title:flags.8?string subscription_pricing:flags.9?StarsSubscriptionPricing = ExportedChatInvite; + +chatInvitePublicJoinRequests#ed107ab7 = ExportedChatInvite; + +chatInviteAlready#5a686d7c chat:Chat = ChatInvite; + +chatInvite#5c9d3702 flags:# channel:flags.0?true broadcast:flags.1?true public:flags.2?true megagroup:flags.3?true request_needed:flags.6?true verified:flags.7?true scam:flags.8?true fake:flags.9?true can_refulfill_subscription:flags.11?true title:string about:flags.5?string photo:Photo participants_count:int participants:flags.4?Vector color:int subscription_pricing:flags.10?StarsSubscriptionPricing subscription_form_id:flags.12?long bot_verification:flags.13?BotVerification = ChatInvite; + +chatInvitePeek#61695cb0 chat:Chat expires:int = ChatInvite; + +inputStickerSetEmpty#ffb62b95 = InputStickerSet; + +inputStickerSetID#9de7a269 id:long access_hash:long = InputStickerSet; + +inputStickerSetShortName#861cc8a0 short_name:string = InputStickerSet; + +inputStickerSetAnimatedEmoji#28703c8 = InputStickerSet; + +inputStickerSetDice#e67f520e emoticon:string = InputStickerSet; + +inputStickerSetAnimatedEmojiAnimations#cde3739 = InputStickerSet; + +inputStickerSetPremiumGifts#c88b3b02 = InputStickerSet; + +inputStickerSetEmojiGenericAnimations#4c4d4ce = InputStickerSet; + +inputStickerSetEmojiDefaultStatuses#29d0f5ee = InputStickerSet; + +inputStickerSetEmojiDefaultTopicIcons#44c1f8e9 = InputStickerSet; + +inputStickerSetEmojiChannelDefaultStatuses#49748553 = InputStickerSet; + +inputStickerSetTonGifts#1cf671a0 = InputStickerSet; + +stickerSet#2dd14edc flags:# archived:flags.1?true official:flags.2?true masks:flags.3?true emojis:flags.7?true text_color:flags.9?true channel_emoji_status:flags.10?true creator:flags.11?true installed_date:flags.0?int id:long access_hash:long title:string short_name:string thumbs:flags.4?Vector thumb_dc_id:flags.4?int thumb_version:flags.4?int thumb_document_id:flags.8?long count:int hash:int = StickerSet; + +messages.stickerSet#6e153f16 set:StickerSet packs:Vector keywords:Vector documents:Vector = messages.StickerSet; + +messages.stickerSetNotModified#d3f924eb = messages.StickerSet; + +botCommand#c27ac8c7 command:string description:string = BotCommand; + +botInfo#4d8a0299 flags:# has_preview_medias:flags.6?true user_id:flags.0?long description:flags.1?string description_photo:flags.4?Photo description_document:flags.5?Document commands:flags.2?Vector menu_button:flags.3?BotMenuButton privacy_policy_url:flags.7?string app_settings:flags.8?BotAppSettings verifier_settings:flags.9?BotVerifierSettings = BotInfo; + +keyboardButton#7d170cff flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; + +keyboardButtonUrl#d80c25ec flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; + +keyboardButtonCallback#e62bc960 flags:# requires_password:flags.0?true style:flags.10?KeyboardButtonStyle text:string data:bytes = KeyboardButton; + +keyboardButtonRequestPhone#417efd8f flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; + +keyboardButtonRequestGeoLocation#aa40f94d flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; + +keyboardButtonSwitchInline#991399fc flags:# same_peer:flags.0?true style:flags.10?KeyboardButtonStyle text:string query:string peer_types:flags.1?Vector = KeyboardButton; + +keyboardButtonGame#89c590f9 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; + +keyboardButtonBuy#3fa53905 flags:# style:flags.10?KeyboardButtonStyle text:string = KeyboardButton; + +keyboardButtonUrlAuth#f51006f9 flags:# style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; + +inputKeyboardButtonUrlAuth#68013e72 flags:# request_write_access:flags.0?true style:flags.10?KeyboardButtonStyle text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; + +keyboardButtonRequestPoll#7a11d782 flags:# style:flags.10?KeyboardButtonStyle quiz:flags.0?Bool text:string = KeyboardButton; + +inputKeyboardButtonUserProfile#7d5e07c7 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:InputUser = KeyboardButton; + +keyboardButtonUserProfile#c0fd5d09 flags:# style:flags.10?KeyboardButtonStyle text:string user_id:long = KeyboardButton; + +keyboardButtonWebView#e846b1a0 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; + +keyboardButtonSimpleWebView#e15c4370 flags:# style:flags.10?KeyboardButtonStyle text:string url:string = KeyboardButton; + +keyboardButtonRequestPeer#5b0f15f5 flags:# style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; + +inputKeyboardButtonRequestPeer#2b78156 flags:# name_requested:flags.0?true username_requested:flags.1?true photo_requested:flags.2?true style:flags.10?KeyboardButtonStyle text:string button_id:int peer_type:RequestPeerType max_quantity:int = KeyboardButton; + +keyboardButtonCopy#bcc4af10 flags:# style:flags.10?KeyboardButtonStyle text:string copy_text:string = KeyboardButton; + +keyboardButtonRow#77608b83 buttons:Vector = KeyboardButtonRow; + +replyKeyboardHide#a03e5b85 flags:# selective:flags.2?true = ReplyMarkup; + +replyKeyboardForceReply#86b40b08 flags:# single_use:flags.1?true selective:flags.2?true placeholder:flags.3?string = ReplyMarkup; + +replyKeyboardMarkup#85dd99d1 flags:# resize:flags.0?true single_use:flags.1?true selective:flags.2?true persistent:flags.4?true rows:Vector placeholder:flags.3?string = ReplyMarkup; + +replyInlineMarkup#48a30254 rows:Vector = ReplyMarkup; + +messageEntityUnknown#bb92ba95 offset:int length:int = MessageEntity; + +messageEntityMention#fa04579d offset:int length:int = MessageEntity; + +messageEntityHashtag#6f635b0d offset:int length:int = MessageEntity; + +messageEntityBotCommand#6cef8ac7 offset:int length:int = MessageEntity; + +messageEntityUrl#6ed02538 offset:int length:int = MessageEntity; + +messageEntityEmail#64e475c2 offset:int length:int = MessageEntity; + +messageEntityBold#bd610bc9 offset:int length:int = MessageEntity; + +messageEntityItalic#826f8b60 offset:int length:int = MessageEntity; + +messageEntityCode#28a20571 offset:int length:int = MessageEntity; + +messageEntityPre#73924be0 offset:int length:int language:string = MessageEntity; + +messageEntityTextUrl#76a6d327 offset:int length:int url:string = MessageEntity; + +messageEntityMentionName#dc7b1140 offset:int length:int user_id:long = MessageEntity; + +inputMessageEntityMentionName#208e68c9 offset:int length:int user_id:InputUser = MessageEntity; + +messageEntityPhone#9b69e34b offset:int length:int = MessageEntity; + +messageEntityCashtag#4c4e743f offset:int length:int = MessageEntity; + +messageEntityUnderline#9c4e7e8b offset:int length:int = MessageEntity; + +messageEntityStrike#bf0693d4 offset:int length:int = MessageEntity; + +messageEntityBankCard#761e6af4 offset:int length:int = MessageEntity; + +messageEntitySpoiler#32ca960f offset:int length:int = MessageEntity; + +messageEntityCustomEmoji#c8cf05f8 offset:int length:int document_id:long = MessageEntity; + +messageEntityBlockquote#f1ccaaac flags:# collapsed:flags.0?true offset:int length:int = MessageEntity; + +messageEntityFormattedDate#904ac7c7 flags:# relative:flags.0?true short_time:flags.1?true long_time:flags.2?true short_date:flags.3?true long_date:flags.4?true day_of_week:flags.5?true offset:int length:int date:int = MessageEntity; + +messageEntityDiffInsert#71777116 offset:int length:int = MessageEntity; + +messageEntityDiffReplace#c6c1e5a7 offset:int length:int old_text:string = MessageEntity; + +messageEntityDiffDelete#652c1c5 offset:int length:int = MessageEntity; + +inputChannelEmpty#ee8c1e86 = InputChannel; + +inputChannel#f35aec28 channel_id:long access_hash:long = InputChannel; + +inputChannelFromMessage#5b934f9d peer:InputPeer msg_id:int channel_id:long = InputChannel; + +contacts.resolvedPeer#7f077ad9 peer:Peer chats:Vector users:Vector = contacts.ResolvedPeer; + +messageRange#ae30253 min_id:int max_id:int = MessageRange; + +updates.channelDifferenceEmpty#3e11affb flags:# final:flags.0?true pts:int timeout:flags.1?int = updates.ChannelDifference; + +updates.channelDifferenceTooLong#a4bcc6fe flags:# final:flags.0?true timeout:flags.1?int dialog:Dialog messages:Vector chats:Vector users:Vector = updates.ChannelDifference; + +updates.channelDifference#2064674e flags:# final:flags.0?true pts:int timeout:flags.1?int new_messages:Vector other_updates:Vector chats:Vector users:Vector = updates.ChannelDifference; + +channelMessagesFilterEmpty#94d42ee7 = ChannelMessagesFilter; + +channelMessagesFilter#cd77d957 flags:# exclude_new_messages:flags.1?true ranges:Vector = ChannelMessagesFilter; + +channelParticipant#1bd54456 flags:# user_id:long date:int subscription_until_date:flags.0?int rank:flags.2?string = ChannelParticipant; + +channelParticipantSelf#a9478a1a flags:# via_request:flags.0?true user_id:long inviter_id:long date:int subscription_until_date:flags.1?int rank:flags.2?string = ChannelParticipant; + +channelParticipantCreator#2fe601d3 flags:# user_id:long admin_rights:ChatAdminRights rank:flags.0?string = ChannelParticipant; + +channelParticipantAdmin#34c3bb53 flags:# can_edit:flags.0?true self:flags.1?true user_id:long inviter_id:flags.1?long promoted_by:long date:int admin_rights:ChatAdminRights rank:flags.2?string = ChannelParticipant; + +channelParticipantBanned#d5f0ad91 flags:# left:flags.0?true peer:Peer kicked_by:long date:int banned_rights:ChatBannedRights rank:flags.2?string = ChannelParticipant; + +channelParticipantLeft#1b03f006 peer:Peer = ChannelParticipant; + +channelParticipantsRecent#de3f3c79 = ChannelParticipantsFilter; + +channelParticipantsAdmins#b4608969 = ChannelParticipantsFilter; + +channelParticipantsKicked#a3b54985 q:string = ChannelParticipantsFilter; + +channelParticipantsBots#b0d1865b = ChannelParticipantsFilter; + +channelParticipantsBanned#1427a5e1 q:string = ChannelParticipantsFilter; + +channelParticipantsSearch#656ac4b q:string = ChannelParticipantsFilter; + +channelParticipantsContacts#bb6ae88d q:string = ChannelParticipantsFilter; + +channelParticipantsMentions#e04b5ceb flags:# q:flags.0?string top_msg_id:flags.1?int = ChannelParticipantsFilter; + +channels.channelParticipants#9ab0feaf count:int participants:Vector chats:Vector users:Vector = channels.ChannelParticipants; + +channels.channelParticipantsNotModified#f0173fe9 = channels.ChannelParticipants; + +channels.channelParticipant#dfb80317 participant:ChannelParticipant chats:Vector users:Vector = channels.ChannelParticipant; + +help.termsOfService#780a0310 flags:# popup:flags.0?true id:DataJSON text:string entities:Vector min_age_confirm:flags.1?int = help.TermsOfService; + +messages.savedGifsNotModified#e8025ca2 = messages.SavedGifs; + +messages.savedGifs#84a02a0d hash:long gifs:Vector = messages.SavedGifs; + +inputBotInlineMessageMediaAuto#3380c786 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineMessageText#3dcd7a87 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineMessageMediaGeo#96929a85 flags:# geo_point:InputGeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineMessageMediaVenue#417bbf11 flags:# geo_point:InputGeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineMessageMediaContact#a6edbffd flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineMessageGame#4b425864 flags:# reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineMessageMediaInvoice#d7e78225 flags:# title:string description:string photo:flags.0?InputWebDocument invoice:Invoice payload:bytes provider:string provider_data:DataJSON reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineMessageMediaWebPage#bddcc510 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true optional:flags.6?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = InputBotInlineMessage; + +inputBotInlineMessageRichMessage#b43df56c flags:# reply_markup:flags.2?ReplyMarkup rich_message:InputRichMessage = InputBotInlineMessage; + +inputBotInlineResult#88bf9319 flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?InputWebDocument content:flags.5?InputWebDocument send_message:InputBotInlineMessage = InputBotInlineResult; + +inputBotInlineResultPhoto#a8d864a7 id:string type:string photo:InputPhoto send_message:InputBotInlineMessage = InputBotInlineResult; + +inputBotInlineResultDocument#fff8fdc4 flags:# id:string type:string title:flags.1?string description:flags.2?string document:InputDocument send_message:InputBotInlineMessage = InputBotInlineResult; + +inputBotInlineResultGame#4fa417f2 id:string short_name:string send_message:InputBotInlineMessage = InputBotInlineResult; + +botInlineMessageMediaAuto#764cf810 flags:# invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineMessageText#8c7f65e2 flags:# no_webpage:flags.0?true invert_media:flags.3?true message:string entities:flags.1?Vector reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineMessageMediaGeo#51846fd flags:# geo:GeoPoint heading:flags.0?int period:flags.1?int proximity_notification_radius:flags.3?int reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineMessageMediaVenue#8a86659c flags:# geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineMessageMediaContact#18d1cdc2 flags:# phone_number:string first_name:string last_name:string vcard:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineMessageMediaInvoice#354a9b09 flags:# shipping_address_requested:flags.1?true test:flags.3?true title:string description:string photo:flags.0?WebDocument currency:string total_amount:long reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineMessageMediaWebPage#809ad9a6 flags:# invert_media:flags.3?true force_large_media:flags.4?true force_small_media:flags.5?true manual:flags.7?true safe:flags.8?true message:string entities:flags.1?Vector url:string reply_markup:flags.2?ReplyMarkup = BotInlineMessage; + +botInlineMessageRichMessage#a617e7b flags:# reply_markup:flags.2?ReplyMarkup rich_message:RichMessage = BotInlineMessage; + +botInlineResult#11965f3a flags:# id:string type:string title:flags.1?string description:flags.2?string url:flags.3?string thumb:flags.4?WebDocument content:flags.5?WebDocument send_message:BotInlineMessage = BotInlineResult; + +botInlineMediaResult#17db940b flags:# id:string type:string photo:flags.0?Photo document:flags.1?Document title:flags.2?string description:flags.3?string send_message:BotInlineMessage = BotInlineResult; + +messages.botResults#e021f2f6 flags:# gallery:flags.0?true query_id:long next_offset:flags.1?string switch_pm:flags.2?InlineBotSwitchPM switch_webview:flags.3?InlineBotWebView results:Vector cache_time:int users:Vector = messages.BotResults; + +exportedMessageLink#5dab1af4 link:string html:string = ExportedMessageLink; + +messageFwdHeader#4e4df4bb flags:# imported:flags.7?true saved_out:flags.11?true from_id:flags.0?Peer from_name:flags.5?string date:int channel_post:flags.2?int post_author:flags.3?string saved_from_peer:flags.4?Peer saved_from_msg_id:flags.4?int saved_from_id:flags.8?Peer saved_from_name:flags.9?string saved_date:flags.10?int psa_type:flags.6?string = MessageFwdHeader; + +auth.codeTypeSms#72a3158c = auth.CodeType; + +auth.codeTypeCall#741cd3e3 = auth.CodeType; + +auth.codeTypeFlashCall#226ccefb = auth.CodeType; + +auth.codeTypeMissedCall#d61ad6ee = auth.CodeType; + +auth.codeTypeFragmentSms#6ed998c = auth.CodeType; + +auth.sentCodeTypeApp#3dbb5986 length:int = auth.SentCodeType; + +auth.sentCodeTypeSms#c000bba2 length:int = auth.SentCodeType; + +auth.sentCodeTypeCall#5353e5a7 length:int = auth.SentCodeType; + +auth.sentCodeTypeFlashCall#ab03c6d9 pattern:string = auth.SentCodeType; + +auth.sentCodeTypeMissedCall#82006484 prefix:string length:int = auth.SentCodeType; + +auth.sentCodeTypeEmailCode#f450f59b flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true email_pattern:string length:int reset_available_period:flags.3?int reset_pending_date:flags.4?int = auth.SentCodeType; + +auth.sentCodeTypeSetUpEmailRequired#a5491dea flags:# apple_signin_allowed:flags.0?true google_signin_allowed:flags.1?true = auth.SentCodeType; + +auth.sentCodeTypeFragmentSms#d9565c39 url:string length:int = auth.SentCodeType; + +auth.sentCodeTypeFirebaseSms#9fd736 flags:# nonce:flags.0?bytes play_integrity_project_id:flags.2?long play_integrity_nonce:flags.2?bytes receipt:flags.1?string push_timeout:flags.1?int length:int = auth.SentCodeType; + +auth.sentCodeTypeSmsWord#a416ac81 flags:# beginning:flags.0?string = auth.SentCodeType; + +auth.sentCodeTypeSmsPhrase#b37794af flags:# beginning:flags.0?string = auth.SentCodeType; + +messages.botCallbackAnswer#36585ea4 flags:# alert:flags.1?true has_url:flags.3?true native_ui:flags.4?true message:flags.0?string url:flags.2?string cache_time:int = messages.BotCallbackAnswer; + +messages.messageEditData#26b5dde6 flags:# caption:flags.0?true = messages.MessageEditData; + +inputBotInlineMessageID#890c3d89 dc_id:int id:long access_hash:long = InputBotInlineMessageID; + +inputBotInlineMessageID64#b6d915d7 dc_id:int owner_id:long id:int access_hash:long = InputBotInlineMessageID; + +inlineBotSwitchPM#3c20629f text:string start_param:string = InlineBotSwitchPM; + +messages.peerDialogs#3371c354 dialogs:Vector messages:Vector chats:Vector users:Vector state:updates.State = messages.PeerDialogs; + +topPeer#edcdc05b peer:Peer rating:double = TopPeer; + +topPeerCategoryBotsPM#ab661b5b = TopPeerCategory; + +topPeerCategoryBotsInline#148677e2 = TopPeerCategory; + +topPeerCategoryCorrespondents#637b7ed = TopPeerCategory; + +topPeerCategoryGroups#bd17a14a = TopPeerCategory; + +topPeerCategoryChannels#161d9628 = TopPeerCategory; + +topPeerCategoryPhoneCalls#1e76a78c = TopPeerCategory; + +topPeerCategoryForwardUsers#a8406ca9 = TopPeerCategory; + +topPeerCategoryForwardChats#fbeec0f0 = TopPeerCategory; + +topPeerCategoryBotsApp#fd9e7bec = TopPeerCategory; + +topPeerCategoryBotsGuestChat#6c24f3dd = TopPeerCategory; + +topPeerCategoryPeers#fb834291 category:TopPeerCategory count:int peers:Vector = TopPeerCategoryPeers; + +contacts.topPeersNotModified#de266ef5 = contacts.TopPeers; + +contacts.topPeers#70b772a8 categories:Vector chats:Vector users:Vector = contacts.TopPeers; + +contacts.topPeersDisabled#b52c939d = contacts.TopPeers; + +draftMessageEmpty#1b0c841a flags:# date:flags.0?int = DraftMessage; + +draftMessage#60fe3294 flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo message:string entities:flags.3?Vector media:flags.5?InputMedia date:int effect:flags.7?long suggested_post:flags.8?SuggestedPost rich_message:flags.9?RichMessage = DraftMessage; + +messages.featuredStickersNotModified#c6dc0c66 count:int = messages.FeaturedStickers; + +messages.featuredStickers#be382906 flags:# premium:flags.0?true hash:long count:int sets:Vector unread:Vector = messages.FeaturedStickers; + +messages.recentStickersNotModified#b17f890 = messages.RecentStickers; + +messages.recentStickers#88d37c56 hash:long packs:Vector stickers:Vector dates:Vector = messages.RecentStickers; + +messages.archivedStickers#4fcba9c8 count:int sets:Vector = messages.ArchivedStickers; + +messages.stickerSetInstallResultSuccess#38641628 = messages.StickerSetInstallResult; + +messages.stickerSetInstallResultArchive#35e410a8 sets:Vector = messages.StickerSetInstallResult; + +stickerSetCovered#6410a5d2 set:StickerSet cover:Document = StickerSetCovered; + +stickerSetMultiCovered#3407e51b set:StickerSet covers:Vector = StickerSetCovered; + +stickerSetFullCovered#40d13c0e set:StickerSet packs:Vector keywords:Vector documents:Vector = StickerSetCovered; + +stickerSetNoCovered#77b15d1c set:StickerSet = StickerSetCovered; + +maskCoords#aed6dbb2 n:int x:double y:double zoom:double = MaskCoords; + +inputStickeredMediaPhoto#4a992157 id:InputPhoto = InputStickeredMedia; + +inputStickeredMediaDocument#438865b id:InputDocument = InputStickeredMedia; + +game#bdf9653b flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document = Game; + +inputGameID#32c3e77 id:long access_hash:long = InputGame; + +inputGameShortName#c331e80a bot_id:InputUser short_name:string = InputGame; + +highScore#73a379eb pos:int user_id:long score:int = HighScore; + +messages.highScores#9a3bfd99 scores:Vector users:Vector = messages.HighScores; + +textEmpty#dc3d824f = RichText; + +textPlain#744694e0 text:string = RichText; + +textBold#6724abc4 text:RichText = RichText; + +textItalic#d912a59c text:RichText = RichText; + +textUnderline#c12622c4 text:RichText = RichText; + +textStrike#9bf8bb95 text:RichText = RichText; + +textFixed#6c3f19b9 text:RichText = RichText; + +textUrl#3c2884c1 text:RichText url:string webpage_id:long = RichText; + +textEmail#de5a0dd6 text:RichText email:string = RichText; + +textConcat#7e6260d7 texts:Vector = RichText; + +textSubscript#ed6a8504 text:RichText = RichText; + +textSuperscript#c7fb5e01 text:RichText = RichText; + +textMarked#34b8621 text:RichText = RichText; + +textPhone#1ccb966a text:RichText phone:string = RichText; + +textImage#81ccf4f document_id:long w:int h:int = RichText; + +textAnchor#35553762 text:RichText name:string = RichText; + +textMath#9d2eac97 source:string = RichText; + +textCustomEmoji#a26156c0 document_id:long alt:string = RichText; + +textSpoiler#4c2a5d62 text:RichText = RichText; + +textMention#cd24cf44 text:RichText = RichText; + +textHashtag#519524ea text:RichText = RichText; + +textBotCommand#2ff29d3 text:RichText = RichText; + +textCashtag#7b9e1801 text:RichText = RichText; + +textAutoUrl#ac6a83aa text:RichText = RichText; + +textAutoEmail#c556a45d text:RichText = RichText; + +textAutoPhone#24c26789 text:RichText = RichText; + +textBankCard#b956812d text:RichText = RichText; + +textMentionName#1a9fbfc text:RichText user_id:long = RichText; + +textDate#a5b45e2b flags:# relative:flags.0?true short_time:flags.1?true long_time:flags.2?true short_date:flags.3?true long_date:flags.4?true day_of_week:flags.5?true text:RichText date:int = RichText; + +pageBlockUnsupported#13567e8a = PageBlock; + +pageBlockTitle#70abc3fd text:RichText = PageBlock; + +pageBlockSubtitle#8ffa9a1f text:RichText = PageBlock; + +pageBlockAuthorDate#baafe5e0 author:RichText published_date:int = PageBlock; + +pageBlockHeader#bfd064ec text:RichText = PageBlock; + +pageBlockSubheader#f12bb6e1 text:RichText = PageBlock; + +pageBlockParagraph#467a0766 text:RichText = PageBlock; + +pageBlockPreformatted#c070d93e text:RichText language:string = PageBlock; + +pageBlockFooter#48870999 text:RichText = PageBlock; + +pageBlockDivider#db20b188 = PageBlock; + +pageBlockAnchor#ce0d37b0 name:string = PageBlock; + +pageBlockList#e4e88011 items:Vector = PageBlock; + +pageBlockBlockquote#263d7c26 text:RichText caption:RichText = PageBlock; + +pageBlockPullquote#4f4456d3 text:RichText caption:RichText = PageBlock; + +pageBlockPhoto#1759c560 flags:# spoiler:flags.1?true photo_id:long caption:PageCaption url:flags.0?string webpage_id:flags.0?long = PageBlock; + +pageBlockVideo#7c8fe7b6 flags:# autoplay:flags.0?true loop:flags.1?true spoiler:flags.2?true video_id:long caption:PageCaption = PageBlock; + +pageBlockCover#39f23300 cover:PageBlock = PageBlock; + +pageBlockEmbed#a8718dc5 flags:# full_width:flags.0?true allow_scrolling:flags.3?true url:flags.1?string html:flags.2?string poster_photo_id:flags.4?long w:flags.5?int h:flags.5?int caption:PageCaption = PageBlock; + +pageBlockEmbedPost#f259a80b url:string webpage_id:long author_photo_id:long author:string date:int blocks:Vector caption:PageCaption = PageBlock; + +pageBlockCollage#65a0fa4d items:Vector caption:PageCaption = PageBlock; + +pageBlockSlideshow#31f9590 items:Vector caption:PageCaption = PageBlock; + +pageBlockChannel#ef1751b5 channel:Chat = PageBlock; + +pageBlockAudio#804361ea audio_id:long caption:PageCaption = PageBlock; + +pageBlockKicker#1e148390 text:RichText = PageBlock; + +pageBlockTable#bf4dea82 flags:# bordered:flags.0?true striped:flags.1?true title:RichText rows:Vector = PageBlock; + +pageBlockOrderedList#1fd6f6c1 flags:# reversed:flags.2?true items:Vector start:flags.0?int type:flags.1?string = PageBlock; + +pageBlockDetails#76768bed flags:# open:flags.0?true blocks:Vector title:RichText = PageBlock; + +pageBlockRelatedArticles#16115a96 title:RichText articles:Vector = PageBlock; + +pageBlockMap#a44f3ef6 geo:GeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; + +pageBlockHeading1#baff072f text:RichText = PageBlock; + +pageBlockHeading2#96b2aec text:RichText = PageBlock; + +pageBlockHeading3#67e731ad text:RichText = PageBlock; + +pageBlockHeading4#b532772b text:RichText = PageBlock; + +pageBlockHeading5#dbbe6c6a text:RichText = PageBlock; + +pageBlockHeading6#682a41a9 text:RichText = PageBlock; + +pageBlockMath#59080c20 source:string = PageBlock; + +pageBlockThinking#3c29a3e2 text:RichText = PageBlock; + +inputPageBlockMap#574b617f geo:InputGeoPoint zoom:int w:int h:int caption:PageCaption = PageBlock; + +pageBlockBlockquoteBlocks#e6e47c4 blocks:Vector caption:RichText = PageBlock; + +phoneCallDiscardReasonMissed#85e42301 = PhoneCallDiscardReason; + +phoneCallDiscardReasonDisconnect#e095c1a0 = PhoneCallDiscardReason; + +phoneCallDiscardReasonHangup#57adc690 = PhoneCallDiscardReason; + +phoneCallDiscardReasonBusy#faf7e8c9 = PhoneCallDiscardReason; + +phoneCallDiscardReasonMigrateConferenceCall#9fbbf1f7 slug:string = PhoneCallDiscardReason; + +dataJSON#7d748d04 data:string = DataJSON; + +labeledPrice#cb296bf8 label:string amount:long = LabeledPrice; + +invoice#49ee584 flags:# test:flags.0?true name_requested:flags.1?true phone_requested:flags.2?true email_requested:flags.3?true shipping_address_requested:flags.4?true flexible:flags.5?true phone_to_provider:flags.6?true email_to_provider:flags.7?true recurring:flags.9?true currency:string prices:Vector max_tip_amount:flags.8?long suggested_tip_amounts:flags.8?Vector terms_url:flags.10?string subscription_period:flags.11?int = Invoice; + +paymentCharge#ea02c27e id:string provider_charge_id:string = PaymentCharge; + +postAddress#1e8caaeb street_line1:string street_line2:string city:string state:string country_iso2:string post_code:string = PostAddress; + +paymentRequestedInfo#909c3f94 flags:# name:flags.0?string phone:flags.1?string email:flags.2?string shipping_address:flags.3?PostAddress = PaymentRequestedInfo; + +paymentSavedCredentialsCard#cdc27a1f id:string title:string = PaymentSavedCredentials; + +webDocument#1c570ed1 url:string access_hash:long size:int mime_type:string attributes:Vector = WebDocument; + +webDocumentNoProxy#f9c8bcc6 url:string size:int mime_type:string attributes:Vector = WebDocument; + +inputWebDocument#9bed434d url:string size:int mime_type:string attributes:Vector = InputWebDocument; + +inputWebFileLocation#c239d686 url:string access_hash:long = InputWebFileLocation; + +inputWebFileGeoPointLocation#9f2221c9 geo_point:InputGeoPoint access_hash:long w:int h:int zoom:int scale:int = InputWebFileLocation; + +inputWebFileAudioAlbumThumbLocation#f46fe924 flags:# small:flags.2?true document:flags.0?InputDocument title:flags.1?string performer:flags.1?string = InputWebFileLocation; + +upload.webFile#21e753bc size:int mime_type:string file_type:storage.FileType mtime:int bytes:bytes = upload.WebFile; + +payments.paymentForm#a0058751 flags:# can_save_credentials:flags.2?true password_missing:flags.3?true form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice provider_id:long url:string native_provider:flags.4?string native_params:flags.4?DataJSON additional_methods:flags.6?Vector saved_info:flags.0?PaymentRequestedInfo saved_credentials:flags.1?Vector users:Vector = payments.PaymentForm; + +payments.paymentFormStars#7bf6b15c flags:# form_id:long bot_id:long title:string description:string photo:flags.5?WebDocument invoice:Invoice users:Vector = payments.PaymentForm; + +payments.paymentFormStarGift#b425cfe1 form_id:long invoice:Invoice = payments.PaymentForm; + +payments.validatedRequestedInfo#d1451883 flags:# id:flags.0?string shipping_options:flags.1?Vector = payments.ValidatedRequestedInfo; + +payments.paymentResult#4e5f810d updates:Updates = payments.PaymentResult; + +payments.paymentVerificationNeeded#d8411139 url:string = payments.PaymentResult; + +payments.paymentReceipt#70c4fe03 flags:# date:int bot_id:long provider_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice info:flags.0?PaymentRequestedInfo shipping:flags.1?ShippingOption tip_amount:flags.3?long currency:string total_amount:long credentials_title:string users:Vector = payments.PaymentReceipt; + +payments.paymentReceiptStars#dabbf83a flags:# date:int bot_id:long title:string description:string photo:flags.2?WebDocument invoice:Invoice currency:string total_amount:long transaction_id:string users:Vector = payments.PaymentReceipt; + +payments.savedInfo#fb8fe43c flags:# has_saved_credentials:flags.1?true saved_info:flags.0?PaymentRequestedInfo = payments.SavedInfo; + +inputPaymentCredentialsSaved#c10eb2cf id:string tmp_password:bytes = InputPaymentCredentials; + +inputPaymentCredentials#3417d728 flags:# save:flags.0?true data:DataJSON = InputPaymentCredentials; + +inputPaymentCredentialsApplePay#aa1c39f payment_data:DataJSON = InputPaymentCredentials; + +inputPaymentCredentialsGooglePay#8ac32801 payment_token:DataJSON = InputPaymentCredentials; + +account.tmpPassword#db64fd34 tmp_password:bytes valid_until:int = account.TmpPassword; + +shippingOption#b6213cdf id:string title:string prices:Vector = ShippingOption; + +inputStickerSetItem#32da9e9c flags:# document:InputDocument emoji:string mask_coords:flags.0?MaskCoords keywords:flags.1?string = InputStickerSetItem; + +inputPhoneCall#1e36fded id:long access_hash:long = InputPhoneCall; + +phoneCallEmpty#5366c915 id:long = PhoneCall; + +phoneCallWaiting#c5226f17 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long protocol:PhoneCallProtocol receive_date:flags.0?int = PhoneCall; + +phoneCallRequested#14b0ed0c flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_hash:bytes protocol:PhoneCallProtocol = PhoneCall; + +phoneCallAccepted#3660c311 flags:# video:flags.6?true id:long access_hash:long date:int admin_id:long participant_id:long g_b:bytes protocol:PhoneCallProtocol = PhoneCall; + +phoneCall#30535af5 flags:# p2p_allowed:flags.5?true video:flags.6?true conference_supported:flags.8?true id:long access_hash:long date:int admin_id:long participant_id:long g_a_or_b:bytes key_fingerprint:long protocol:PhoneCallProtocol connections:Vector start_date:int custom_parameters:flags.7?DataJSON = PhoneCall; + +phoneCallDiscarded#50ca4de1 flags:# need_rating:flags.2?true need_debug:flags.3?true video:flags.6?true id:long reason:flags.0?PhoneCallDiscardReason duration:flags.1?int = PhoneCall; + +phoneConnection#9cc123c7 flags:# tcp:flags.0?true id:long ip:string ipv6:string port:int peer_tag:bytes = PhoneConnection; + +phoneConnectionWebrtc#635fe375 flags:# turn:flags.0?true stun:flags.1?true id:long ip:string ipv6:string port:int username:string password:string = PhoneConnection; + +phoneCallProtocol#fc878fc8 flags:# udp_p2p:flags.0?true udp_reflector:flags.1?true min_layer:int max_layer:int library_versions:Vector = PhoneCallProtocol; + +phone.phoneCall#ec82e140 phone_call:PhoneCall users:Vector = phone.PhoneCall; + +upload.cdnFileReuploadNeeded#eea8e46e request_token:bytes = upload.CdnFile; + +upload.cdnFile#a99fca4f bytes:bytes = upload.CdnFile; + +cdnPublicKey#c982eaba dc_id:int public_key:string = CdnPublicKey; + +cdnConfig#5725e40a public_keys:Vector = CdnConfig; + +langPackString#cad181f6 key:string value:string = LangPackString; + +langPackStringPluralized#6c47ac9f flags:# key:string zero_value:flags.0?string one_value:flags.1?string two_value:flags.2?string few_value:flags.3?string many_value:flags.4?string other_value:string = LangPackString; + +langPackStringDeleted#2979eeb2 key:string = LangPackString; + +langPackDifference#f385c1f6 lang_code:string from_version:int version:int strings:Vector = LangPackDifference; + +langPackLanguage#eeca5ce3 flags:# official:flags.0?true rtl:flags.2?true beta:flags.3?true name:string native_name:string lang_code:string base_lang_code:flags.1?string plural_code:string strings_count:int translated_count:int translations_url:string = LangPackLanguage; + +channelAdminLogEventActionChangeTitle#e6dfb825 prev_value:string new_value:string = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeAbout#55188a2e prev_value:string new_value:string = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeUsername#6a4afc38 prev_value:string new_value:string = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangePhoto#434bd2af prev_photo:Photo new_photo:Photo = ChannelAdminLogEventAction; + +channelAdminLogEventActionToggleInvites#1b7907ae new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEventActionToggleSignatures#26ae0971 new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEventActionUpdatePinned#e9e82c18 message:Message = ChannelAdminLogEventAction; + +channelAdminLogEventActionEditMessage#709b2405 prev_message:Message new_message:Message = ChannelAdminLogEventAction; + +channelAdminLogEventActionDeleteMessage#42e047bb message:Message = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantJoin#183040d3 = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantLeave#f89777f2 = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantInvite#e31c34d8 participant:ChannelParticipant = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantToggleBan#e6d83d7e prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantToggleAdmin#d5676710 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeStickerSet#b1c3caa7 prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; + +channelAdminLogEventActionTogglePreHistoryHidden#5f5c95f1 new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEventActionDefaultBannedRights#2df5fc0a prev_banned_rights:ChatBannedRights new_banned_rights:ChatBannedRights = ChannelAdminLogEventAction; + +channelAdminLogEventActionStopPoll#8f079643 message:Message = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeLinkedChat#50c7ac8 prev_value:long new_value:long = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeLocation#e6b76ae prev_value:ChannelLocation new_value:ChannelLocation = ChannelAdminLogEventAction; + +channelAdminLogEventActionToggleSlowMode#53909779 prev_value:int new_value:int = ChannelAdminLogEventAction; + +channelAdminLogEventActionStartGroupCall#23209745 call:InputGroupCall = ChannelAdminLogEventAction; + +channelAdminLogEventActionDiscardGroupCall#db9f9140 call:InputGroupCall = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantMute#f92424d2 participant:GroupCallParticipant = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantUnmute#e64429c0 participant:GroupCallParticipant = ChannelAdminLogEventAction; + +channelAdminLogEventActionToggleGroupCallSetting#56d6a247 join_muted:Bool = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantJoinByInvite#fe9fc158 flags:# via_chatlist:flags.0?true invite:ExportedChatInvite = ChannelAdminLogEventAction; + +channelAdminLogEventActionExportedInviteDelete#5a50fca4 invite:ExportedChatInvite = ChannelAdminLogEventAction; + +channelAdminLogEventActionExportedInviteRevoke#410a134e invite:ExportedChatInvite = ChannelAdminLogEventAction; + +channelAdminLogEventActionExportedInviteEdit#e90ebb59 prev_invite:ExportedChatInvite new_invite:ExportedChatInvite = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantVolume#3e7f6847 participant:GroupCallParticipant = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeHistoryTTL#6e941a38 prev_value:int new_value:int = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantJoinByRequest#afb6144a invite:ExportedChatInvite approved_by:long = ChannelAdminLogEventAction; + +channelAdminLogEventActionToggleNoForwards#cb2ac766 new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEventActionSendMessage#278f2868 message:Message = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeAvailableReactions#be4e0ef8 prev_value:ChatReactions new_value:ChatReactions = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeUsernames#f04fb3a9 prev_value:Vector new_value:Vector = ChannelAdminLogEventAction; + +channelAdminLogEventActionToggleForum#2cc6383 new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEventActionCreateTopic#58707d28 topic:ForumTopic = ChannelAdminLogEventAction; + +channelAdminLogEventActionEditTopic#f06fe208 prev_topic:ForumTopic new_topic:ForumTopic = ChannelAdminLogEventAction; + +channelAdminLogEventActionDeleteTopic#ae168909 topic:ForumTopic = ChannelAdminLogEventAction; + +channelAdminLogEventActionPinTopic#5d8d353b flags:# prev_topic:flags.0?ForumTopic new_topic:flags.1?ForumTopic = ChannelAdminLogEventAction; + +channelAdminLogEventActionToggleAntiSpam#64f36dfc new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangePeerColor#5796e780 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeProfilePeerColor#5e477b25 prev_value:PeerColor new_value:PeerColor = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeWallpaper#31bb5d52 prev_value:WallPaper new_value:WallPaper = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeEmojiStatus#3ea9feb1 prev_value:EmojiStatus new_value:EmojiStatus = ChannelAdminLogEventAction; + +channelAdminLogEventActionChangeEmojiStickerSet#46d840ab prev_stickerset:InputStickerSet new_stickerset:InputStickerSet = ChannelAdminLogEventAction; + +channelAdminLogEventActionToggleSignatureProfiles#60a79c79 new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantSubExtend#64642db3 prev_participant:ChannelParticipant new_participant:ChannelParticipant = ChannelAdminLogEventAction; + +channelAdminLogEventActionToggleAutotranslation#c517f77e new_value:Bool = ChannelAdminLogEventAction; + +channelAdminLogEventActionParticipantEditRank#5806b4ec user_id:long prev_rank:string new_rank:string = ChannelAdminLogEventAction; + +channelAdminLogEvent#1fad68cd id:long date:int user_id:long action:ChannelAdminLogEventAction = ChannelAdminLogEvent; + +channels.adminLogResults#ed8af74d events:Vector chats:Vector users:Vector = channels.AdminLogResults; + +channelAdminLogEventsFilter#ea107ae4 flags:# join:flags.0?true leave:flags.1?true invite:flags.2?true ban:flags.3?true unban:flags.4?true kick:flags.5?true unkick:flags.6?true promote:flags.7?true demote:flags.8?true info:flags.9?true settings:flags.10?true pinned:flags.11?true edit:flags.12?true delete:flags.13?true group_call:flags.14?true invites:flags.15?true send:flags.16?true forums:flags.17?true sub_extend:flags.18?true edit_rank:flags.19?true = ChannelAdminLogEventsFilter; + +popularContact#5ce14175 client_id:long importers:int = PopularContact; + +messages.favedStickersNotModified#9e8fa6d3 = messages.FavedStickers; + +messages.favedStickers#2cb51097 hash:long packs:Vector stickers:Vector = messages.FavedStickers; + +recentMeUrlUnknown#46e1d13d url:string = RecentMeUrl; + +recentMeUrlUser#b92c09e2 url:string user_id:long = RecentMeUrl; + +recentMeUrlChat#b2da71d2 url:string chat_id:long = RecentMeUrl; + +recentMeUrlChatInvite#eb49081d url:string chat_invite:ChatInvite = RecentMeUrl; + +recentMeUrlStickerSet#bc0a57dc url:string set:StickerSetCovered = RecentMeUrl; + +help.recentMeUrls#e0310d7 urls:Vector chats:Vector users:Vector = help.RecentMeUrls; + +inputSingleMedia#1cc6e91f flags:# media:InputMedia random_id:long message:string entities:flags.0?Vector = InputSingleMedia; + +webAuthorization#a6f8f452 hash:long bot_id:long domain:string browser:string platform:string date_created:int date_active:int ip:string region:string = WebAuthorization; + +account.webAuthorizations#ed56c9fc authorizations:Vector users:Vector = account.WebAuthorizations; + +inputMessageID#a676a322 id:int = InputMessage; + +inputMessageReplyTo#bad88395 id:int = InputMessage; + +inputMessagePinned#86872538 = InputMessage; + +inputMessageCallbackQuery#acfa1a7e id:int query_id:long = InputMessage; + +inputDialogPeer#fcaafeb7 peer:InputPeer = InputDialogPeer; + +inputDialogPeerFolder#64600527 folder_id:int = InputDialogPeer; + +dialogPeer#e56dbf05 peer:Peer = DialogPeer; + +dialogPeerFolder#514519e2 folder_id:int = DialogPeer; + +messages.foundStickerSetsNotModified#d54b65d = messages.FoundStickerSets; + +messages.foundStickerSets#8af09dd2 hash:long sets:Vector = messages.FoundStickerSets; + +fileHash#f39b035c offset:long limit:int hash:bytes = FileHash; + +inputClientProxy#75588b3f address:string port:int = InputClientProxy; + +help.termsOfServiceUpdateEmpty#e3309f7f expires:int = help.TermsOfServiceUpdate; + +help.termsOfServiceUpdate#28ecf961 expires:int terms_of_service:help.TermsOfService = help.TermsOfServiceUpdate; + +inputSecureFileUploaded#3334b0f0 id:long parts:int md5_checksum:string file_hash:bytes secret:bytes = InputSecureFile; + +inputSecureFile#5367e5be id:long access_hash:long = InputSecureFile; + +secureFileEmpty#64199744 = SecureFile; + +secureFile#7d09c27e id:long access_hash:long size:long dc_id:int date:int file_hash:bytes secret:bytes = SecureFile; + +secureData#8aeabec3 data:bytes data_hash:bytes secret:bytes = SecureData; + +securePlainPhone#7d6099dd phone:string = SecurePlainData; + +securePlainEmail#21ec5a5f email:string = SecurePlainData; + +secureValueTypePersonalDetails#9d2a81e3 = SecureValueType; + +secureValueTypePassport#3dac6a00 = SecureValueType; + +secureValueTypeDriverLicense#6e425c4 = SecureValueType; + +secureValueTypeIdentityCard#a0d0744b = SecureValueType; + +secureValueTypeInternalPassport#99a48f23 = SecureValueType; + +secureValueTypeAddress#cbe31e26 = SecureValueType; + +secureValueTypeUtilityBill#fc36954e = SecureValueType; + +secureValueTypeBankStatement#89137c0d = SecureValueType; + +secureValueTypeRentalAgreement#8b883488 = SecureValueType; + +secureValueTypePassportRegistration#99e3806a = SecureValueType; + +secureValueTypeTemporaryRegistration#ea02ec33 = SecureValueType; + +secureValueTypePhone#b320aadb = SecureValueType; + +secureValueTypeEmail#8e3ca7ee = SecureValueType; + +secureValue#187fa0ca flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?SecureFile reverse_side:flags.2?SecureFile selfie:flags.3?SecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData hash:bytes = SecureValue; + +inputSecureValue#db21d0a7 flags:# type:SecureValueType data:flags.0?SecureData front_side:flags.1?InputSecureFile reverse_side:flags.2?InputSecureFile selfie:flags.3?InputSecureFile translation:flags.6?Vector files:flags.4?Vector plain_data:flags.5?SecurePlainData = InputSecureValue; + +secureValueHash#ed1ecdb0 type:SecureValueType hash:bytes = SecureValueHash; + +secureValueErrorData#e8a40bd9 type:SecureValueType data_hash:bytes field:string text:string = SecureValueError; + +secureValueErrorFrontSide#be3dfa type:SecureValueType file_hash:bytes text:string = SecureValueError; + +secureValueErrorReverseSide#868a2aa5 type:SecureValueType file_hash:bytes text:string = SecureValueError; + +secureValueErrorSelfie#e537ced6 type:SecureValueType file_hash:bytes text:string = SecureValueError; + +secureValueErrorFile#7a700873 type:SecureValueType file_hash:bytes text:string = SecureValueError; + +secureValueErrorFiles#666220e9 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureValueError#869d758f type:SecureValueType hash:bytes text:string = SecureValueError; + +secureValueErrorTranslationFile#a1144770 type:SecureValueType file_hash:bytes text:string = SecureValueError; + +secureValueErrorTranslationFiles#34636dd8 type:SecureValueType file_hash:Vector text:string = SecureValueError; + +secureCredentialsEncrypted#33f0ea47 data:bytes hash:bytes secret:bytes = SecureCredentialsEncrypted; + +account.authorizationForm#ad2e1cd8 flags:# required_types:Vector values:Vector errors:Vector users:Vector privacy_policy_url:flags.0?string = account.AuthorizationForm; + +account.sentEmailCode#811f854f email_pattern:string length:int = account.SentEmailCode; + +help.deepLinkInfoEmpty#66afa166 = help.DeepLinkInfo; + +help.deepLinkInfo#6a4ee832 flags:# update_app:flags.0?true message:string entities:flags.1?Vector = help.DeepLinkInfo; + +savedPhoneContact#1142bd56 phone:string first_name:string last_name:string date:int = SavedContact; + +account.takeout#4dba4501 id:long = account.Takeout; + +passwordKdfAlgoUnknown#d45ab096 = PasswordKdfAlgo; + +passwordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow#3a912d4a salt1:bytes salt2:bytes g:int p:bytes = PasswordKdfAlgo; + +securePasswordKdfAlgoUnknown#4a8537 = SecurePasswordKdfAlgo; + +securePasswordKdfAlgoPBKDF2HMACSHA512iter100000#bbf2dda0 salt:bytes = SecurePasswordKdfAlgo; + +securePasswordKdfAlgoSHA512#86471d92 salt:bytes = SecurePasswordKdfAlgo; + +secureSecretSettings#1527bcac secure_algo:SecurePasswordKdfAlgo secure_secret:bytes secure_secret_id:long = SecureSecretSettings; + +inputCheckPasswordEmpty#9880f658 = InputCheckPasswordSRP; + +inputCheckPasswordSRP#d27ff082 srp_id:long A:bytes M1:bytes = InputCheckPasswordSRP; + +secureRequiredType#829d99da flags:# native_names:flags.0?true selfie_required:flags.1?true translation_required:flags.2?true type:SecureValueType = SecureRequiredType; + +secureRequiredTypeOneOf#27477b4 types:Vector = SecureRequiredType; + +help.passportConfigNotModified#bfb9f457 = help.PassportConfig; + +help.passportConfig#a098d6af hash:int countries_langs:DataJSON = help.PassportConfig; + +inputAppEvent#1d1b1245 time:double type:string peer:long data:JSONValue = InputAppEvent; + +jsonObjectValue#c0de1bd9 key:string value:JSONValue = JSONObjectValue; + +jsonNull#3f6d7b68 = JSONValue; + +jsonBool#c7345e6a value:Bool = JSONValue; + +jsonNumber#2be0dfa4 value:double = JSONValue; + +jsonString#b71e767a value:string = JSONValue; + +jsonArray#f7444763 value:Vector = JSONValue; + +jsonObject#99c1d49d value:Vector = JSONValue; + +pageTableCell#34566b6a flags:# header:flags.0?true align_center:flags.3?true align_right:flags.4?true valign_middle:flags.5?true valign_bottom:flags.6?true text:flags.7?RichText colspan:flags.1?int rowspan:flags.2?int = PageTableCell; + +pageTableRow#e0c0c5e5 cells:Vector = PageTableRow; + +pageCaption#6f747657 text:RichText credit:RichText = PageCaption; + +pageListItemText#2f58683c flags:# checkbox:flags.0?true checked:flags.1?true text:RichText = PageListItem; + +pageListItemBlocks#63ca67aa flags:# checkbox:flags.0?true checked:flags.1?true blocks:Vector = PageListItem; + +pageListOrderedItemText#15031189 flags:# checkbox:flags.0?true checked:flags.1?true num:flags.2?string text:RichText value:flags.3?int type:flags.4?string = PageListOrderedItem; + +pageListOrderedItemBlocks#8ff2d5f0 flags:# checkbox:flags.0?true checked:flags.1?true num:flags.2?string blocks:Vector value:flags.3?int type:flags.4?string = PageListOrderedItem; + +pageRelatedArticle#b390dc08 flags:# url:string webpage_id:long title:flags.0?string description:flags.1?string photo_id:flags.2?long author:flags.3?string published_date:flags.4?int = PageRelatedArticle; + +page#98657f0d flags:# part:flags.0?true rtl:flags.1?true v2:flags.2?true url:string blocks:Vector photos:Vector documents:Vector views:flags.3?int = Page; + +help.supportName#8c05f1c9 name:string = help.SupportName; + +help.userInfoEmpty#f3ae2eed = help.UserInfo; + +help.userInfo#1eb3758 message:string entities:Vector author:string date:int = help.UserInfo; + +pollAnswer#4b7d786a flags:# text:TextWithEntities option:bytes media:flags.0?MessageMedia added_by:flags.1?Peer date:flags.1?int = PollAnswer; + +inputPollAnswer#199fed96 flags:# text:TextWithEntities media:flags.0?InputMedia = PollAnswer; + +poll#966e2dbf id:long flags:# closed:flags.0?true public_voters:flags.1?true multiple_choice:flags.2?true quiz:flags.3?true open_answers:flags.6?true revoting_disabled:flags.7?true shuffle_answers:flags.8?true hide_results_until_close:flags.9?true creator:flags.10?true subscribers_only:flags.11?true question:TextWithEntities answers:Vector close_period:flags.4?int close_date:flags.5?int countries_iso2:flags.12?Vector hash:long = Poll; + +pollAnswerVoters#3645230a flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:flags.2?int recent_voters:flags.2?Vector = PollAnswerVoters; + +pollResults#ba7bb15e flags:# min:flags.0?true has_unread_votes:flags.6?true can_view_stats:flags.7?true results:flags.1?Vector total_voters:flags.2?int recent_voters:flags.3?Vector solution:flags.4?string solution_entities:flags.4?Vector solution_media:flags.5?MessageMedia = PollResults; + +chatOnlines#f041e250 onlines:int = ChatOnlines; + +statsURL#47a971e0 url:string = StatsURL; + +chatAdminRights#5fb224d5 flags:# change_info:flags.0?true post_messages:flags.1?true edit_messages:flags.2?true delete_messages:flags.3?true ban_users:flags.4?true invite_users:flags.5?true pin_messages:flags.7?true add_admins:flags.9?true anonymous:flags.10?true manage_call:flags.11?true other:flags.12?true manage_topics:flags.13?true post_stories:flags.14?true edit_stories:flags.15?true delete_stories:flags.16?true manage_direct_messages:flags.17?true manage_ranks:flags.18?true = ChatAdminRights; + +chatBannedRights#9f120418 flags:# view_messages:flags.0?true send_messages:flags.1?true send_media:flags.2?true send_stickers:flags.3?true send_gifs:flags.4?true send_games:flags.5?true send_inline:flags.6?true embed_links:flags.7?true send_polls:flags.8?true change_info:flags.10?true invite_users:flags.15?true pin_messages:flags.17?true manage_topics:flags.18?true send_photos:flags.19?true send_videos:flags.20?true send_roundvideos:flags.21?true send_audios:flags.22?true send_voices:flags.23?true send_docs:flags.24?true send_plain:flags.25?true edit_rank:flags.26?true send_reactions:flags.27?true until_date:int = ChatBannedRights; + +inputWallPaper#e630b979 id:long access_hash:long = InputWallPaper; + +inputWallPaperSlug#72091c80 slug:string = InputWallPaper; + +inputWallPaperNoFile#967a462e id:long = InputWallPaper; + +account.wallPapersNotModified#1c199183 = account.WallPapers; + +account.wallPapers#cdc3858c hash:long wallpapers:Vector = account.WallPapers; + +codeSettings#ad253d78 flags:# allow_flashcall:flags.0?true current_number:flags.1?true allow_app_hash:flags.4?true allow_missed_call:flags.5?true allow_firebase:flags.7?true unknown_number:flags.9?true logout_tokens:flags.6?Vector token:flags.8?string app_sandbox:flags.8?Bool = CodeSettings; + +wallPaperSettings#372efcd0 flags:# blur:flags.1?true motion:flags.2?true background_color:flags.0?int second_background_color:flags.4?int third_background_color:flags.5?int fourth_background_color:flags.6?int intensity:flags.3?int rotation:flags.4?int emoticon:flags.7?string = WallPaperSettings; + +autoDownloadSettings#baa57628 flags:# disabled:flags.0?true video_preload_large:flags.1?true audio_preload_next:flags.2?true phonecalls_less_data:flags.3?true stories_preload:flags.4?true photo_size_max:int video_size_max:long file_size_max:long video_upload_maxbitrate:int small_queue_active_operations_max:int large_queue_active_operations_max:int = AutoDownloadSettings; + +account.autoDownloadSettings#63cacf26 low:AutoDownloadSettings medium:AutoDownloadSettings high:AutoDownloadSettings = account.AutoDownloadSettings; + +emojiKeyword#d5b3b9f9 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordDeleted#236df622 keyword:string emoticons:Vector = EmojiKeyword; + +emojiKeywordsDifference#5cc761bd lang_code:string from_version:int version:int keywords:Vector = EmojiKeywordsDifference; + +emojiURL#a575739d url:string = EmojiURL; + +emojiLanguage#b3fb5361 lang_code:string = EmojiLanguage; + +folder#ff544e65 flags:# autofill_new_broadcasts:flags.0?true autofill_public_groups:flags.1?true autofill_new_correspondents:flags.2?true id:int title:string photo:flags.3?ChatPhoto = Folder; + +inputFolderPeer#fbd2c296 peer:InputPeer folder_id:int = InputFolderPeer; + +folderPeer#e9baa668 peer:Peer folder_id:int = FolderPeer; + +messages.searchCounter#e844ebff flags:# inexact:flags.1?true filter:MessagesFilter count:int = messages.SearchCounter; + +urlAuthResultRequest#3cd623ec flags:# request_write_access:flags.0?true request_phone_number:flags.1?true match_codes_first:flags.5?true is_app:flags.6?true bot:User domain:string browser:flags.2?string platform:flags.2?string ip:flags.2?string region:flags.2?string match_codes:flags.3?Vector user_id_hint:flags.4?long verified_app_name:flags.7?string = UrlAuthResult; + +urlAuthResultAccepted#623a8fa0 flags:# url:flags.0?string = UrlAuthResult; + +urlAuthResultDefault#a9d6db1f = UrlAuthResult; + +channelLocationEmpty#bfb5ad8b = ChannelLocation; + +channelLocation#209b82db geo_point:GeoPoint address:string = ChannelLocation; + +peerLocated#ca461b5d peer:Peer expires:int distance:int = PeerLocated; + +peerSelfLocated#f8ec284b expires:int = PeerLocated; + +restrictionReason#d072acb4 platform:string reason:string text:string = RestrictionReason; + +inputTheme#3c5693e9 id:long access_hash:long = InputTheme; + +inputThemeSlug#f5890df1 slug:string = InputTheme; + +theme#a00e67d6 flags:# creator:flags.0?true default:flags.1?true for_chat:flags.5?true id:long access_hash:long slug:string title:string document:flags.2?Document settings:flags.3?Vector emoticon:flags.6?string installs_count:flags.4?int = Theme; + +account.themesNotModified#f41eb622 = account.Themes; + +account.themes#9a3d8c6d hash:long themes:Vector = account.Themes; + +auth.loginToken#629f1980 expires:int token:bytes = auth.LoginToken; + +auth.loginTokenMigrateTo#68e9916 dc_id:int token:bytes = auth.LoginToken; + +auth.loginTokenSuccess#390d5c5e authorization:auth.Authorization = auth.LoginToken; + +account.contentSettings#57e28221 flags:# sensitive_enabled:flags.0?true sensitive_can_change:flags.1?true = account.ContentSettings; + +messages.inactiveChats#a927fec5 dates:Vector chats:Vector users:Vector = messages.InactiveChats; + +baseThemeClassic#c3a12462 = BaseTheme; + +baseThemeDay#fbd81688 = BaseTheme; + +baseThemeNight#b7b31ea8 = BaseTheme; + +baseThemeTinted#6d5f77ee = BaseTheme; + +baseThemeArctic#5b11125a = BaseTheme; + +inputThemeSettings#8fde504f flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?InputWallPaper wallpaper_settings:flags.1?WallPaperSettings = InputThemeSettings; + +themeSettings#fa58b6d4 flags:# message_colors_animated:flags.2?true base_theme:BaseTheme accent_color:int outbox_accent_color:flags.3?int message_colors:flags.0?Vector wallpaper:flags.1?WallPaper = ThemeSettings; + +webPageAttributeTheme#54b56617 flags:# documents:flags.0?Vector settings:flags.1?ThemeSettings = WebPageAttribute; + +webPageAttributeStory#2e94c3e7 flags:# peer:Peer id:int story:flags.0?StoryItem = WebPageAttribute; + +webPageAttributeStickerSet#50cc03d3 flags:# emojis:flags.0?true text_color:flags.1?true stickers:Vector = WebPageAttribute; + +webPageAttributeUniqueStarGift#cf6f6db8 gift:StarGift = WebPageAttribute; + +webPageAttributeStarGiftCollection#31cad303 icons:Vector = WebPageAttribute; + +webPageAttributeStarGiftAuction#1c641c2 gift:StarGift end_date:int = WebPageAttribute; + +webPageAttributeAiComposeTone#7781fe18 emoji_id:long = WebPageAttribute; + +messages.votesList#4899484e flags:# count:int votes:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.VotesList; + +bankCardOpenUrl#f568028a url:string name:string = BankCardOpenUrl; + +payments.bankCardData#3e24e573 title:string open_urls:Vector = payments.BankCardData; + +dialogFilter#aa472651 flags:# contacts:flags.0?true non_contacts:flags.1?true groups:flags.2?true broadcasts:flags.3?true bots:flags.4?true exclude_muted:flags.11?true exclude_read:flags.12?true exclude_archived:flags.13?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector exclude_peers:Vector = DialogFilter; + +dialogFilterDefault#363293ae = DialogFilter; + +dialogFilterChatlist#96537bd7 flags:# has_my_invites:flags.26?true title_noanimate:flags.28?true id:int title:TextWithEntities emoticon:flags.25?string color:flags.27?int pinned_peers:Vector include_peers:Vector = DialogFilter; + +dialogFilterSuggested#77744d4a filter:DialogFilter description:string = DialogFilterSuggested; + +statsDateRangeDays#b637edaf min_date:int max_date:int = StatsDateRangeDays; + +statsAbsValueAndPrev#cb43acde current:double previous:double = StatsAbsValueAndPrev; + +statsPercentValue#cbce2fe0 part:double total:double = StatsPercentValue; + +statsGraphAsync#4a27eb2d token:string = StatsGraph; + +statsGraphError#bedc9822 error:string = StatsGraph; + +statsGraph#8ea464b6 flags:# json:DataJSON zoom_token:flags.0?string = StatsGraph; + +stats.broadcastStats#396ca5fc period:StatsDateRangeDays followers:StatsAbsValueAndPrev views_per_post:StatsAbsValueAndPrev shares_per_post:StatsAbsValueAndPrev reactions_per_post:StatsAbsValueAndPrev views_per_story:StatsAbsValueAndPrev shares_per_story:StatsAbsValueAndPrev reactions_per_story:StatsAbsValueAndPrev enabled_notifications:StatsPercentValue growth_graph:StatsGraph followers_graph:StatsGraph mute_graph:StatsGraph top_hours_graph:StatsGraph interactions_graph:StatsGraph iv_interactions_graph:StatsGraph views_by_source_graph:StatsGraph new_followers_by_source_graph:StatsGraph languages_graph:StatsGraph reactions_by_emotion_graph:StatsGraph story_interactions_graph:StatsGraph story_reactions_by_emotion_graph:StatsGraph recent_posts_interactions:Vector = stats.BroadcastStats; + +help.promoDataEmpty#98f6ac75 expires:int = help.PromoData; + +help.promoData#8a4d87a flags:# proxy:flags.0?true expires:int peer:flags.3?Peer psa_type:flags.1?string psa_message:flags.2?string pending_suggestions:Vector dismissed_suggestions:Vector custom_pending_suggestion:flags.4?PendingSuggestion chats:Vector users:Vector = help.PromoData; + +videoSize#de33b094 flags:# type:string w:int h:int size:int video_start_ts:flags.0?double = VideoSize; + +videoSizeEmojiMarkup#f85c413c emoji_id:long background_colors:Vector = VideoSize; + +videoSizeStickerMarkup#da082fe stickerset:InputStickerSet sticker_id:long background_colors:Vector = VideoSize; + +statsGroupTopPoster#9d04af9b user_id:long messages:int avg_chars:int = StatsGroupTopPoster; + +statsGroupTopAdmin#d7584c87 user_id:long deleted:int kicked:int banned:int = StatsGroupTopAdmin; + +statsGroupTopInviter#535f779d user_id:long invitations:int = StatsGroupTopInviter; + +stats.megagroupStats#ef7ff916 period:StatsDateRangeDays members:StatsAbsValueAndPrev messages:StatsAbsValueAndPrev viewers:StatsAbsValueAndPrev posters:StatsAbsValueAndPrev growth_graph:StatsGraph members_graph:StatsGraph new_members_by_source_graph:StatsGraph languages_graph:StatsGraph messages_graph:StatsGraph actions_graph:StatsGraph top_hours_graph:StatsGraph weekdays_graph:StatsGraph top_posters:Vector top_admins:Vector top_inviters:Vector users:Vector = stats.MegagroupStats; + +globalPrivacySettings#fe41b34f flags:# archive_and_mute_new_noncontact_peers:flags.0?true keep_archived_unmuted:flags.1?true keep_archived_folders:flags.2?true hide_read_marks:flags.3?true new_noncontact_peers_require_premium:flags.4?true display_gifts_button:flags.7?true noncontact_peers_paid_stars:flags.5?long disallowed_gifts:flags.6?DisallowedGiftsSettings = GlobalPrivacySettings; + +help.countryCode#4203c5ef flags:# country_code:string prefixes:flags.0?Vector patterns:flags.1?Vector = help.CountryCode; + +help.country#c3878e23 flags:# hidden:flags.0?true iso2:string default_name:string name:flags.1?string country_codes:Vector = help.Country; + +help.countriesListNotModified#93cc1f32 = help.CountriesList; + +help.countriesList#87d0759e countries:Vector hash:int = help.CountriesList; + +messageViews#455b853d flags:# views:flags.0?int forwards:flags.1?int replies:flags.2?MessageReplies = MessageViews; + +messages.messageViews#b6c4f543 views:Vector chats:Vector users:Vector = messages.MessageViews; + +messages.discussionMessage#a6341782 flags:# messages:Vector max_id:flags.0?int read_inbox_max_id:flags.1?int read_outbox_max_id:flags.2?int unread_count:int chats:Vector users:Vector = messages.DiscussionMessage; + +messageReplyHeader#1b97dd66 flags:# reply_to_scheduled:flags.2?true forum_topic:flags.3?true quote:flags.9?true reply_to_ephemeral:flags.13?true reply_to_msg_id:flags.4?int reply_to_peer_id:flags.0?Peer reply_from:flags.5?MessageFwdHeader reply_media:flags.8?MessageMedia reply_to_top_id:flags.1?int quote_text:flags.6?string quote_entities:flags.7?Vector quote_offset:flags.10?int todo_item_id:flags.11?int poll_option:flags.12?bytes = MessageReplyHeader; + +messageReplyStoryHeader#e5af939 peer:Peer story_id:int = MessageReplyHeader; + +messageReplies#83d60fc2 flags:# comments:flags.0?true replies:int replies_pts:int recent_repliers:flags.1?Vector channel_id:flags.0?long max_id:flags.2?int read_max_id:flags.3?int = MessageReplies; + +peerBlocked#e8fd8014 peer_id:Peer date:int = PeerBlocked; + +stats.messageStats#7fe91c14 views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.MessageStats; + +groupCallDiscarded#7780bcb4 id:long access_hash:long duration:int = GroupCall; + +groupCall#efb2b617 flags:# join_muted:flags.1?true can_change_join_muted:flags.2?true join_date_asc:flags.6?true schedule_start_subscribed:flags.8?true can_start_video:flags.9?true record_video_active:flags.11?true rtmp_stream:flags.12?true listeners_hidden:flags.13?true conference:flags.14?true creator:flags.15?true messages_enabled:flags.17?true can_change_messages_enabled:flags.18?true min:flags.19?true id:long access_hash:long participants_count:int title:flags.3?string stream_dc_id:flags.4?int record_start_date:flags.5?int schedule_date:flags.7?int unmuted_video_count:flags.10?int unmuted_video_limit:int version:int invite_link:flags.16?string send_paid_messages_stars:flags.20?long default_send_as:flags.21?Peer = GroupCall; + +inputGroupCall#d8aa840f id:long access_hash:long = InputGroupCall; + +inputGroupCallSlug#fe06823f slug:string = InputGroupCall; + +inputGroupCallInviteMessage#8c10603f msg_id:int = InputGroupCall; + +groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant; + +phone.groupCall#9e727aad call:GroupCall participants:Vector participants_next_offset:string chats:Vector users:Vector = phone.GroupCall; + +phone.groupParticipants#f47751b6 count:int participants:Vector next_offset:string chats:Vector users:Vector version:int = phone.GroupParticipants; + +inlineQueryPeerTypeSameBotPM#3081ed9d = InlineQueryPeerType; + +inlineQueryPeerTypePM#833c0fac = InlineQueryPeerType; + +inlineQueryPeerTypeChat#d766c50a = InlineQueryPeerType; + +inlineQueryPeerTypeMegagroup#5ec4be43 = InlineQueryPeerType; + +inlineQueryPeerTypeBroadcast#6334ee9a = InlineQueryPeerType; + +inlineQueryPeerTypeBotPM#e3b2d0c = InlineQueryPeerType; + +messages.historyImport#1662af0b id:long = messages.HistoryImport; + +messages.historyImportParsed#5e0fb7b9 flags:# pm:flags.0?true group:flags.1?true title:flags.2?string = messages.HistoryImportParsed; + +messages.affectedFoundMessages#ef8d3e6c pts:int pts_count:int offset:int messages:Vector = messages.AffectedFoundMessages; + +chatInviteImporter#8c5adfd9 flags:# requested:flags.0?true via_chatlist:flags.3?true user_id:long date:int about:flags.2?string approved_by:flags.1?long = ChatInviteImporter; + +messages.exportedChatInvites#bdc62dcc count:int invites:Vector users:Vector = messages.ExportedChatInvites; + +messages.exportedChatInvite#1871be50 invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.exportedChatInviteReplaced#222600ef invite:ExportedChatInvite new_invite:ExportedChatInvite users:Vector = messages.ExportedChatInvite; + +messages.chatInviteImporters#81b6b00a count:int importers:Vector users:Vector = messages.ChatInviteImporters; + +chatAdminWithInvites#f2ecef23 admin_id:long invites_count:int revoked_invites_count:int = ChatAdminWithInvites; + +messages.chatAdminsWithInvites#b69b72d7 admins:Vector users:Vector = messages.ChatAdminsWithInvites; + +messages.checkedHistoryImportPeer#a24de717 confirm_text:string = messages.CheckedHistoryImportPeer; + +phone.joinAsPeers#afe5623f peers:Vector chats:Vector users:Vector = phone.JoinAsPeers; + +phone.exportedGroupCallInvite#204bd158 link:string = phone.ExportedGroupCallInvite; + +groupCallParticipantVideoSourceGroup#dcb118b7 semantics:string sources:Vector = GroupCallParticipantVideoSourceGroup; + +groupCallParticipantVideo#67753ac8 flags:# paused:flags.0?true endpoint:string source_groups:Vector audio_source:flags.1?int = GroupCallParticipantVideo; + +stickers.suggestedShortName#85fea03f short_name:string = stickers.SuggestedShortName; + +botCommandScopeDefault#2f6cb2ab = BotCommandScope; + +botCommandScopeUsers#3c4f04d8 = BotCommandScope; + +botCommandScopeChats#6fe1a881 = BotCommandScope; + +botCommandScopeChatAdmins#b9aa606a = BotCommandScope; + +botCommandScopePeer#db9d897d peer:InputPeer = BotCommandScope; + +botCommandScopePeerAdmins#3fd863d1 peer:InputPeer = BotCommandScope; + +botCommandScopePeerUser#a1321f3 peer:InputPeer user_id:InputUser = BotCommandScope; + +account.resetPasswordFailedWait#e3779861 retry_date:int = account.ResetPasswordResult; + +account.resetPasswordRequestedWait#e9effc7d until_date:int = account.ResetPasswordResult; + +account.resetPasswordOk#e926d63e = account.ResetPasswordResult; + +chatTheme#c3dffc04 emoticon:string = ChatTheme; + +chatThemeUniqueGift#3458f9c8 gift:StarGift theme_settings:Vector = ChatTheme; + +account.chatThemesNotModified#e011e1c4 = account.ChatThemes; + +account.chatThemes#be098173 flags:# hash:long themes:Vector chats:Vector users:Vector next_offset:flags.0?string = account.ChatThemes; + +sponsoredMessage#7dbf8673 flags:# recommended:flags.5?true can_report:flags.12?true random_id:bytes url:string title:string message:string entities:flags.1?Vector photo:flags.6?Photo media:flags.14?MessageMedia color:flags.13?PeerColor button_text:string sponsor_info:flags.7?string additional_info:flags.8?string min_display_duration:flags.15?int max_display_duration:flags.15?int = SponsoredMessage; + +messages.sponsoredMessages#ffda656d flags:# posts_between:flags.0?int start_delay:flags.1?int between_delay:flags.2?int messages:Vector chats:Vector users:Vector = messages.SponsoredMessages; + +messages.sponsoredMessagesEmpty#1839490f = messages.SponsoredMessages; + +searchResultsCalendarPeriod#c9b0539f date:int min_msg_id:int max_msg_id:int count:int = SearchResultsCalendarPeriod; + +messages.searchResultsCalendar#147ee23c flags:# inexact:flags.0?true count:int min_date:int min_msg_id:int offset_id_offset:flags.1?int periods:Vector messages:Vector chats:Vector users:Vector = messages.SearchResultsCalendar; + +searchResultPosition#7f648b67 msg_id:int date:int offset:int = SearchResultsPosition; + +messages.searchResultsPositions#53b22baf count:int positions:Vector = messages.SearchResultsPositions; + +channels.sendAsPeers#f496b0c6 peers:Vector chats:Vector users:Vector = channels.SendAsPeers; + +users.userFull#3b6d152e full_user:UserFull chats:Vector users:Vector = users.UserFull; + +messages.peerSettings#6880b94d settings:PeerSettings chats:Vector users:Vector = messages.PeerSettings; + +auth.loggedOut#c3a2835f flags:# future_auth_token:flags.0?bytes = auth.LoggedOut; + +reactionCount#a3d1cb80 flags:# chosen_order:flags.0?int reaction:Reaction count:int = ReactionCount; + +messageReactions#a339f0b flags:# min:flags.0?true can_see_list:flags.2?true reactions_as_tags:flags.3?true results:Vector recent_reactions:flags.1?Vector top_reactors:flags.4?Vector = MessageReactions; + +messages.messageReactionsList#31bd492d flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = messages.MessageReactionsList; + +availableReaction#c077ec01 flags:# inactive:flags.0?true premium:flags.2?true reaction:string title:string static_icon:Document appear_animation:Document select_animation:Document activate_animation:Document effect_animation:Document around_animation:flags.1?Document center_icon:flags.1?Document = AvailableReaction; + +messages.availableReactionsNotModified#9f071957 = messages.AvailableReactions; + +messages.availableReactions#768e3aad hash:int reactions:Vector = messages.AvailableReactions; + +messagePeerReaction#8c79b63c flags:# big:flags.0?true unread:flags.1?true my:flags.2?true peer_id:Peer date:int reaction:Reaction = MessagePeerReaction; + +groupCallStreamChannel#80eb48af channel:int scale:int last_timestamp_ms:long = GroupCallStreamChannel; + +phone.groupCallStreamChannels#d0e482b2 channels:Vector = phone.GroupCallStreamChannels; + +phone.groupCallStreamRtmpUrl#2dbf3432 url:string key:string = phone.GroupCallStreamRtmpUrl; + +attachMenuBotIconColor#4576f3f0 name:string color:int = AttachMenuBotIconColor; + +attachMenuBotIcon#b2a7386b flags:# name:string icon:Document colors:flags.0?Vector = AttachMenuBotIcon; + +attachMenuBot#d90d8dfe flags:# inactive:flags.0?true has_settings:flags.1?true request_write_access:flags.2?true show_in_attach_menu:flags.3?true show_in_side_menu:flags.4?true side_menu_disclaimer_needed:flags.5?true bot_id:long short_name:string peer_types:flags.3?Vector icons:Vector = AttachMenuBot; + +attachMenuBotsNotModified#f1d88a5c = AttachMenuBots; + +attachMenuBots#3c4301c0 hash:long bots:Vector users:Vector = AttachMenuBots; + +attachMenuBotsBot#93bf667f bot:AttachMenuBot users:Vector = AttachMenuBotsBot; + +webViewResultUrl#4d22ff98 flags:# fullsize:flags.1?true fullscreen:flags.2?true same_origin:flags.3?true query_id:flags.0?long url:string = WebViewResult; + +webViewMessageSent#c94511c flags:# msg_id:flags.0?InputBotInlineMessageID = WebViewMessageSent; + +botMenuButtonDefault#7533a588 = BotMenuButton; + +botMenuButtonCommands#4258c205 = BotMenuButton; + +botMenuButton#c7b57ce6 text:string url:string = BotMenuButton; + +account.savedRingtonesNotModified#fbf6e8b1 = account.SavedRingtones; + +account.savedRingtones#c1e92cc5 hash:long ringtones:Vector = account.SavedRingtones; + +notificationSoundDefault#97e8bebe = NotificationSound; + +notificationSoundNone#6f0c34df = NotificationSound; + +notificationSoundLocal#830b9ae4 title:string data:string = NotificationSound; + +notificationSoundRingtone#ff6c8049 id:long = NotificationSound; + +account.savedRingtone#b7263f6d = account.SavedRingtone; + +account.savedRingtoneConverted#1f307eb7 document:Document = account.SavedRingtone; + +attachMenuPeerTypeSameBotPM#7d6be90e = AttachMenuPeerType; + +attachMenuPeerTypeBotPM#c32bfa1a = AttachMenuPeerType; + +attachMenuPeerTypePM#f146d31f = AttachMenuPeerType; + +attachMenuPeerTypeChat#509113f = AttachMenuPeerType; + +attachMenuPeerTypeBroadcast#7bfbdefc = AttachMenuPeerType; + +inputInvoiceMessage#c5b56859 peer:InputPeer msg_id:int = InputInvoice; + +inputInvoiceSlug#c326caef slug:string = InputInvoice; + +inputInvoicePremiumGiftCode#98986c0d purpose:InputStorePaymentPurpose option:PremiumGiftCodeOption = InputInvoice; + +inputInvoiceStars#65f00ce3 purpose:InputStorePaymentPurpose = InputInvoice; + +inputInvoiceChatInviteSubscription#34e793f1 hash:string = InputInvoice; + +inputInvoiceStarGift#e8625e92 flags:# hide_name:flags.0?true include_upgrade:flags.2?true peer:InputPeer gift_id:long message:flags.1?TextWithEntities = InputInvoice; + +inputInvoiceStarGiftUpgrade#4d818d5d flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = InputInvoice; + +inputInvoiceStarGiftTransfer#4a5f5bd9 stargift:InputSavedStarGift to_id:InputPeer = InputInvoice; + +inputInvoicePremiumGiftStars#dabab2ef flags:# user_id:InputUser months:int message:flags.0?TextWithEntities = InputInvoice; + +inputInvoiceBusinessBotTransferStars#f4997e42 bot:InputUser stars:long = InputInvoice; + +inputInvoiceStarGiftResale#c39f5324 flags:# ton:flags.0?true slug:string to_id:InputPeer = InputInvoice; + +inputInvoiceStarGiftPrepaidUpgrade#9a0b48b8 peer:InputPeer hash:string = InputInvoice; + +inputInvoicePremiumAuthCode#3e77f614 purpose:InputStorePaymentPurpose = InputInvoice; + +inputInvoiceStarGiftDropOriginalDetails#923d8d1 stargift:InputSavedStarGift = InputInvoice; + +inputInvoiceStarGiftAuctionBid#1ecafa10 flags:# hide_name:flags.0?true update_bid:flags.2?true peer:flags.3?InputPeer gift_id:long bid_amount:long message:flags.1?TextWithEntities = InputInvoice; + +payments.exportedInvoice#aed0cbd9 url:string = payments.ExportedInvoice; + +messages.transcribedAudio#cfb9d957 flags:# pending:flags.0?true transcription_id:long text:string trial_remains_num:flags.1?int trial_remains_until_date:flags.1?int = messages.TranscribedAudio; + +help.premiumPromo#5334759c status_text:string status_entities:Vector video_sections:Vector videos:Vector period_options:Vector users:Vector = help.PremiumPromo; + +inputStorePaymentPremiumSubscription#a6751e66 flags:# restore:flags.0?true upgrade:flags.1?true = InputStorePaymentPurpose; + +inputStorePaymentGiftPremium#616f7fe8 user_id:InputUser currency:string amount:long = InputStorePaymentPurpose; + +inputStorePaymentPremiumGiftCode#fb790393 flags:# users:Vector boost_peer:flags.0?InputPeer currency:string amount:long message:flags.1?TextWithEntities = InputStorePaymentPurpose; + +inputStorePaymentPremiumGiveaway#160544ca flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long = InputStorePaymentPurpose; + +inputStorePaymentStarsTopup#f9a2a6cb flags:# stars:long currency:string amount:long spend_purpose_peer:flags.0?InputPeer = InputStorePaymentPurpose; + +inputStorePaymentStarsGift#1d741ef7 user_id:InputUser stars:long currency:string amount:long = InputStorePaymentPurpose; + +inputStorePaymentStarsGiveaway#751f08fa flags:# only_new_subscribers:flags.0?true winners_are_visible:flags.3?true stars:long boost_peer:InputPeer additional_peers:flags.1?Vector countries_iso2:flags.2?Vector prize_description:flags.4?string random_id:long until_date:int currency:string amount:long users:int = InputStorePaymentPurpose; + +inputStorePaymentAuthCode#3fc18057 flags:# restore:flags.0?true phone_number:string phone_code_hash:string premium_days:int currency:string amount:long = InputStorePaymentPurpose; + +paymentFormMethod#88f8f21b url:string title:string = PaymentFormMethod; + +emojiStatusEmpty#2de11aae = EmojiStatus; + +emojiStatus#e7ff068a flags:# document_id:long until:flags.0?int = EmojiStatus; + +emojiStatusCollectible#7184603b flags:# collectible_id:long document_id:long title:string slug:string pattern_document_id:long center_color:int edge_color:int pattern_color:int text_color:int until:flags.0?int = EmojiStatus; + +inputEmojiStatusCollectible#7141dbf flags:# collectible_id:long until:flags.0?int = EmojiStatus; + +account.emojiStatusesNotModified#d08ce645 = account.EmojiStatuses; + +account.emojiStatuses#90c467d1 hash:long statuses:Vector = account.EmojiStatuses; + +reactionEmpty#79f5d419 = Reaction; + +reactionEmoji#1b2286b8 emoticon:string = Reaction; + +reactionCustomEmoji#8935fc73 document_id:long = Reaction; + +reactionPaid#523da4eb = Reaction; + +chatReactionsNone#eafc32bc = ChatReactions; + +chatReactionsAll#52928bca flags:# allow_custom:flags.0?true = ChatReactions; + +chatReactionsSome#661d4037 reactions:Vector = ChatReactions; + +messages.reactionsNotModified#b06fdbdf = messages.Reactions; + +messages.reactions#eafdf716 hash:long reactions:Vector = messages.Reactions; + +emailVerifyPurposeLoginSetup#4345be73 phone_number:string phone_code_hash:string = EmailVerifyPurpose; + +emailVerifyPurposeLoginChange#527d22eb = EmailVerifyPurpose; + +emailVerifyPurposePassport#bbf51685 = EmailVerifyPurpose; + +emailVerificationCode#922e55a9 code:string = EmailVerification; + +emailVerificationGoogle#db909ec2 token:string = EmailVerification; + +emailVerificationApple#96d074fd token:string = EmailVerification; + +account.emailVerified#2b96cd1b email:string = account.EmailVerified; + +account.emailVerifiedLogin#e1bb0d61 email:string sent_code:auth.SentCode = account.EmailVerified; + +premiumSubscriptionOption#5f2d1df2 flags:# current:flags.1?true can_purchase_upgrade:flags.2?true transaction:flags.3?string months:int currency:string amount:long bot_url:string store_product:flags.0?string = PremiumSubscriptionOption; + +sendAsPeer#b81c7034 flags:# premium_required:flags.0?true peer:Peer = SendAsPeer; + +messageExtendedMediaPreview#ad628cc8 flags:# w:flags.0?int h:flags.0?int thumb:flags.1?PhotoSize video_duration:flags.2?int = MessageExtendedMedia; + +messageExtendedMedia#ee479c64 media:MessageMedia = MessageExtendedMedia; + +stickerKeyword#fcfeb29c document_id:long keyword:Vector = StickerKeyword; + +username#b4073647 flags:# editable:flags.0?true active:flags.1?true username:string = Username; + +forumTopicDeleted#23f109b id:int = ForumTopic; + +forumTopic#fcdad815 flags:# my:flags.1?true closed:flags.2?true pinned:flags.3?true short:flags.5?true hidden:flags.6?true title_missing:flags.7?true id:int date:int peer:Peer title:string icon_color:int icon_emoji_id:flags.0?long top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_mentions_count:int unread_reactions_count:int unread_poll_votes_count:int from_id:Peer notify_settings:PeerNotifySettings draft:flags.4?DraftMessage = ForumTopic; + +messages.forumTopics#367617d3 flags:# order_by_create_date:flags.0?true count:int topics:Vector messages:Vector chats:Vector users:Vector pts:int = messages.ForumTopics; + +defaultHistoryTTL#43b46b20 period:int = DefaultHistoryTTL; + +exportedContactToken#41bf109b url:string expires:int = ExportedContactToken; + +requestPeerTypeUser#5f3b8a00 flags:# bot:flags.0?Bool premium:flags.1?Bool = RequestPeerType; + +requestPeerTypeChat#c9f06e1b flags:# creator:flags.0?true bot_participant:flags.5?true has_username:flags.3?Bool forum:flags.4?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; + +requestPeerTypeBroadcast#339bef6c flags:# creator:flags.0?true has_username:flags.3?Bool user_admin_rights:flags.1?ChatAdminRights bot_admin_rights:flags.2?ChatAdminRights = RequestPeerType; + +requestPeerTypeCreateBot#3e81e078 flags:# bot_managed:flags.0?true suggested_name:flags.1?string suggested_username:flags.2?string = RequestPeerType; + +emojiListNotModified#481eadfa = EmojiList; + +emojiList#7a1e11d1 hash:long document_id:Vector = EmojiList; + +emojiGroup#7a9abda9 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; + +emojiGroupGreeting#80d26cc7 title:string icon_emoji_id:long emoticons:Vector = EmojiGroup; + +emojiGroupPremium#93bcf34 title:string icon_emoji_id:long = EmojiGroup; + +messages.emojiGroupsNotModified#6fb4ad87 = messages.EmojiGroups; + +messages.emojiGroups#881fb94b hash:int groups:Vector = messages.EmojiGroups; + +textWithEntities#751f3146 text:string entities:Vector = TextWithEntities; + +messages.translateResult#33db32f8 result:Vector = messages.TranslatedText; + +autoSaveSettings#c84834ce flags:# photos:flags.0?true videos:flags.1?true video_max_size:flags.2?long = AutoSaveSettings; + +autoSaveException#81602d47 peer:Peer settings:AutoSaveSettings = AutoSaveException; + +account.autoSaveSettings#4c3e069d users_settings:AutoSaveSettings chats_settings:AutoSaveSettings broadcasts_settings:AutoSaveSettings exceptions:Vector chats:Vector users:Vector = account.AutoSaveSettings; + +help.appConfigNotModified#7cde641d = help.AppConfig; + +help.appConfig#dd18782e hash:int config:JSONValue = help.AppConfig; + +inputBotAppID#a920bd7a id:long access_hash:long = InputBotApp; + +inputBotAppShortName#908c0407 bot_id:InputUser short_name:string = InputBotApp; + +botAppNotModified#5da674b7 = BotApp; + +botApp#95fcd1d6 flags:# id:long access_hash:long short_name:string title:string description:string photo:Photo document:flags.0?Document hash:long = BotApp; + +messages.botApp#eb50adf5 flags:# inactive:flags.0?true request_write_access:flags.1?true has_settings:flags.2?true app:BotApp = messages.BotApp; + +inlineBotWebView#b57295d5 text:string url:string = InlineBotWebView; + +readParticipantDate#4a4ff172 user_id:long date:int = ReadParticipantDate; + +inputChatlistDialogFilter#f3e0da33 filter_id:int = InputChatlist; + +exportedChatlistInvite#c5181ac flags:# title:string url:string peers:Vector = ExportedChatlistInvite; + +chatlists.exportedChatlistInvite#10e6e3a6 filter:DialogFilter invite:ExportedChatlistInvite = chatlists.ExportedChatlistInvite; + +chatlists.exportedInvites#10ab6dc7 invites:Vector chats:Vector users:Vector = chatlists.ExportedInvites; + +chatlists.chatlistInviteAlready#fa87f659 filter_id:int missing_peers:Vector already_peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistInvite#f10ece2f flags:# title_noanimate:flags.1?true title:TextWithEntities emoticon:flags.0?string peers:Vector chats:Vector users:Vector = chatlists.ChatlistInvite; + +chatlists.chatlistUpdates#93bd878d missing_peers:Vector chats:Vector users:Vector = chatlists.ChatlistUpdates; + +bots.botInfo#e8a775b0 name:string about:string description:string = bots.BotInfo; + +messagePeerVote#b6cc2d5c peer:Peer option:bytes date:int = MessagePeerVote; + +messagePeerVoteInputOption#74cda504 peer:Peer date:int = MessagePeerVote; + +messagePeerVoteMultiple#4628f6e6 peer:Peer options:Vector date:int = MessagePeerVote; + +storyViews#8d595cd6 flags:# has_viewers:flags.1?true views_count:int forwards_count:flags.2?int reactions:flags.3?Vector reactions_count:flags.4?int recent_viewers:flags.0?Vector = StoryViews; + +storyItemDeleted#51e6ee4f id:int = StoryItem; + +storyItemSkipped#ffadc913 flags:# close_friends:flags.8?true live:flags.9?true id:int date:int expire_date:int = StoryItem; + +storyItem#16a4b93c flags:# pinned:flags.5?true public:flags.7?true close_friends:flags.8?true min:flags.9?true noforwards:flags.10?true edited:flags.11?true contacts:flags.12?true selected_contacts:flags.13?true out:flags.16?true id:int date:int from_id:flags.18?Peer fwd_from:flags.17?StoryFwdHeader expire_date:int caption:flags.0?string entities:flags.1?Vector media:MessageMedia media_areas:flags.14?Vector privacy:flags.2?Vector views:flags.3?StoryViews sent_reaction:flags.15?Reaction albums:flags.19?Vector music:flags.20?Document = StoryItem; + +stories.allStoriesNotModified#1158fe3e flags:# state:string stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.allStories#6efc5e81 flags:# has_more:flags.0?true count:int state:string peer_stories:Vector chats:Vector users:Vector stealth_mode:StoriesStealthMode = stories.AllStories; + +stories.stories#63c3dd0a flags:# count:int stories:Vector pinned_to_top:flags.0?Vector chats:Vector users:Vector = stories.Stories; + +storyView#b0bdeac5 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true user_id:long date:int reaction:flags.2?Reaction = StoryView; + +storyViewPublicForward#9083670b flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true message:Message = StoryView; + +storyViewPublicRepost#bd74cf49 flags:# blocked:flags.0?true blocked_my_stories_from:flags.1?true peer_id:Peer story:StoryItem = StoryView; + +stories.storyViewsList#59d78fc5 flags:# count:int views_count:int forwards_count:int reactions_count:int views:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryViewsList; + +stories.storyViews#de9eed1d views:Vector users:Vector = stories.StoryViews; + +inputReplyToMessage#3bd4b7c2 flags:# reply_to_msg_id:int top_msg_id:flags.0?int reply_to_peer_id:flags.1?InputPeer quote_text:flags.2?string quote_entities:flags.3?Vector quote_offset:flags.4?int monoforum_peer_id:flags.5?InputPeer todo_item_id:flags.6?int poll_option:flags.7?bytes = InputReplyTo; + +inputReplyToStory#5881323a peer:InputPeer story_id:int = InputReplyTo; + +inputReplyToMonoForum#69d66c45 monoforum_peer_id:InputPeer = InputReplyTo; + +exportedStoryLink#3fc9053b link:string = ExportedStoryLink; + +storiesStealthMode#712e27fd flags:# active_until_date:flags.0?int cooldown_until_date:flags.1?int = StoriesStealthMode; + +mediaAreaCoordinates#cfc9e002 flags:# x:double y:double w:double h:double rotation:double radius:flags.0?double = MediaAreaCoordinates; + +mediaAreaVenue#be82db9c coordinates:MediaAreaCoordinates geo:GeoPoint title:string address:string provider:string venue_id:string venue_type:string = MediaArea; + +inputMediaAreaVenue#b282217f coordinates:MediaAreaCoordinates query_id:long result_id:string = MediaArea; + +mediaAreaGeoPoint#cad5452d flags:# coordinates:MediaAreaCoordinates geo:GeoPoint address:flags.0?GeoPointAddress = MediaArea; + +mediaAreaSuggestedReaction#14455871 flags:# dark:flags.0?true flipped:flags.1?true coordinates:MediaAreaCoordinates reaction:Reaction = MediaArea; + +mediaAreaChannelPost#770416af coordinates:MediaAreaCoordinates channel_id:long msg_id:int = MediaArea; + +inputMediaAreaChannelPost#2271f2bf coordinates:MediaAreaCoordinates channel:InputChannel msg_id:int = MediaArea; + +mediaAreaUrl#37381085 coordinates:MediaAreaCoordinates url:string = MediaArea; + +mediaAreaWeather#49a6549c coordinates:MediaAreaCoordinates emoji:string temperature_c:double color:int = MediaArea; + +mediaAreaStarGift#5787686d coordinates:MediaAreaCoordinates slug:string = MediaArea; + +peerStories#9a35e999 flags:# peer:Peer max_read_id:flags.0?int stories:Vector = PeerStories; + +stories.peerStories#cae68768 stories:PeerStories chats:Vector users:Vector = stories.PeerStories; + +messages.webPage#fd5e12bd webpage:WebPage chats:Vector users:Vector = messages.WebPage; + +premiumGiftCodeOption#257e962b flags:# users:int months:int store_product:flags.0?string store_quantity:flags.1?int currency:string amount:long = PremiumGiftCodeOption; + +payments.checkedGiftCode#eb983f8f flags:# via_giveaway:flags.2?true from_id:flags.4?Peer giveaway_msg_id:flags.3?int to_id:flags.0?long date:int days:int used_date:flags.1?int chats:Vector users:Vector = payments.CheckedGiftCode; + +payments.giveawayInfo#4367daa0 flags:# participating:flags.0?true preparing_results:flags.3?true start_date:int joined_too_early_date:flags.1?int admin_disallowed_chat_id:flags.2?long disallowed_country:flags.4?string = payments.GiveawayInfo; + +payments.giveawayInfoResults#e175e66f flags:# winner:flags.0?true refunded:flags.1?true start_date:int gift_code_slug:flags.3?string stars_prize:flags.4?long finish_date:int winners_count:int activated_count:flags.2?int = payments.GiveawayInfo; + +prepaidGiveaway#b2539d54 id:long months:int quantity:int date:int = PrepaidGiveaway; + +prepaidStarsGiveaway#9a9d77e0 id:long stars:long quantity:int boosts:int date:int = PrepaidGiveaway; + +boost#4b3e14d6 flags:# gift:flags.1?true giveaway:flags.2?true unclaimed:flags.3?true id:string user_id:flags.0?long giveaway_msg_id:flags.2?int date:int expires:int used_gift_slug:flags.4?string multiplier:flags.5?int stars:flags.6?long = Boost; + +premium.boostsList#86f8613c flags:# count:int boosts:Vector next_offset:flags.0?string users:Vector = premium.BoostsList; + +myBoost#c448415c flags:# slot:int peer:flags.0?Peer date:int expires:int cooldown_until_date:flags.1?int = MyBoost; + +premium.myBoosts#9ae228e2 my_boosts:Vector chats:Vector users:Vector = premium.MyBoosts; + +premium.boostsStatus#4959427a flags:# my_boost:flags.2?true level:int current_level_boosts:int boosts:int gift_boosts:flags.4?int next_level_boosts:flags.0?int premium_audience:flags.1?StatsPercentValue boost_url:string prepaid_giveaways:flags.3?Vector my_boost_slots:flags.2?Vector = premium.BoostsStatus; + +storyFwdHeader#b826e150 flags:# modified:flags.3?true from:flags.0?Peer from_name:flags.1?string story_id:flags.2?int = StoryFwdHeader; + +postInteractionCountersMessage#e7058e7f msg_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +postInteractionCountersStory#8a480e27 story_id:int views:int forwards:int reactions:int = PostInteractionCounters; + +stats.storyStats#50cd067c views_graph:StatsGraph reactions_by_emotion_graph:StatsGraph = stats.StoryStats; + +publicForwardMessage#1f2bf4a message:Message = PublicForward; + +publicForwardStory#edf3add0 peer:Peer story:StoryItem = PublicForward; + +stats.publicForwards#93037e20 flags:# count:int forwards:Vector next_offset:flags.0?string chats:Vector users:Vector = stats.PublicForwards; + +peerColor#b54b5acf flags:# color:flags.0?int background_emoji_id:flags.1?long = PeerColor; + +peerColorCollectible#b9c0639a flags:# collectible_id:long gift_emoji_id:long background_emoji_id:long accent_color:int colors:Vector dark_accent_color:flags.0?int dark_colors:flags.1?Vector = PeerColor; + +inputPeerColorCollectible#b8ea86a9 collectible_id:long = PeerColor; + +help.peerColorSet#26219a58 colors:Vector = help.PeerColorSet; + +help.peerColorProfileSet#767d61eb palette_colors:Vector bg_colors:Vector story_colors:Vector = help.PeerColorSet; + +help.peerColorOption#adec6ebe flags:# hidden:flags.0?true color_id:int colors:flags.1?help.PeerColorSet dark_colors:flags.2?help.PeerColorSet channel_min_level:flags.3?int group_min_level:flags.4?int = help.PeerColorOption; + +help.peerColorsNotModified#2ba1f5ce = help.PeerColors; + +help.peerColors#f8ed08 hash:int colors:Vector = help.PeerColors; + +storyReaction#6090d6d5 peer_id:Peer date:int reaction:Reaction = StoryReaction; + +storyReactionPublicForward#bbab2643 message:Message = StoryReaction; + +storyReactionPublicRepost#cfcd0f13 peer_id:Peer story:StoryItem = StoryReaction; + +stories.storyReactionsList#aa5f789c flags:# count:int reactions:Vector chats:Vector users:Vector next_offset:flags.0?string = stories.StoryReactionsList; + +savedDialog#bd87cb6c flags:# pinned:flags.2?true peer:Peer top_message:int = SavedDialog; + +monoForumDialog#64407ea7 flags:# unread_mark:flags.3?true nopaid_messages_exception:flags.4?true peer:Peer top_message:int read_inbox_max_id:int read_outbox_max_id:int unread_count:int unread_reactions_count:int draft:flags.1?DraftMessage = SavedDialog; + +messages.savedDialogs#f83ae221 dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; + +messages.savedDialogsSlice#44ba9dd9 count:int dialogs:Vector messages:Vector chats:Vector users:Vector = messages.SavedDialogs; + +messages.savedDialogsNotModified#c01f6fe8 count:int = messages.SavedDialogs; + +savedReactionTag#cb6ff828 flags:# reaction:Reaction title:flags.0?string count:int = SavedReactionTag; + +messages.savedReactionTagsNotModified#889b59ef = messages.SavedReactionTags; + +messages.savedReactionTags#3259950a tags:Vector hash:long = messages.SavedReactionTags; + +outboxReadDate#3bb842ac date:int = OutboxReadDate; + +smsjobs.eligibleToJoin#dc8b44cf terms_url:string monthly_sent_sms:int = smsjobs.EligibilityToJoin; + +smsjobs.status#2aee9191 flags:# allow_international:flags.0?true recent_sent:int recent_since:int recent_remains:int total_sent:int total_since:int last_gift_slug:flags.1?string terms_url:string = smsjobs.Status; + +smsJob#e6a1eeb8 job_id:string phone_number:string text:string = SmsJob; + +businessWeeklyOpen#120b1ab9 start_minute:int end_minute:int = BusinessWeeklyOpen; + +businessWorkHours#8c92b098 flags:# open_now:flags.0?true timezone_id:string weekly_open:Vector = BusinessWorkHours; + +businessLocation#ac5c1af7 flags:# geo_point:flags.0?GeoPoint address:string = BusinessLocation; + +inputBusinessRecipients#6f8b32aa flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = InputBusinessRecipients; + +businessRecipients#21108ff7 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector = BusinessRecipients; + +businessAwayMessageScheduleAlways#c9b9e2b9 = BusinessAwayMessageSchedule; + +businessAwayMessageScheduleOutsideWorkHours#c3f2f501 = BusinessAwayMessageSchedule; + +businessAwayMessageScheduleCustom#cc4d9ecc start_date:int end_date:int = BusinessAwayMessageSchedule; + +inputBusinessGreetingMessage#194cb3b shortcut_id:int recipients:InputBusinessRecipients no_activity_days:int = InputBusinessGreetingMessage; + +businessGreetingMessage#e519abab shortcut_id:int recipients:BusinessRecipients no_activity_days:int = BusinessGreetingMessage; + +inputBusinessAwayMessage#832175e0 flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:InputBusinessRecipients = InputBusinessAwayMessage; + +businessAwayMessage#ef156a5c flags:# offline_only:flags.0?true shortcut_id:int schedule:BusinessAwayMessageSchedule recipients:BusinessRecipients = BusinessAwayMessage; + +timezone#ff9289f5 id:string name:string utc_offset:int = Timezone; + +help.timezonesListNotModified#970708cc = help.TimezonesList; + +help.timezonesList#7b74ed71 timezones:Vector hash:int = help.TimezonesList; + +quickReply#697102b shortcut_id:int shortcut:string top_message:int count:int = QuickReply; + +inputQuickReplyShortcut#24596d41 shortcut:string = InputQuickReplyShortcut; + +inputQuickReplyShortcutId#1190cf1 shortcut_id:int = InputQuickReplyShortcut; + +messages.quickReplies#c68d6695 quick_replies:Vector messages:Vector chats:Vector users:Vector = messages.QuickReplies; + +messages.quickRepliesNotModified#5f91eb5b = messages.QuickReplies; + +connectedBot#33ed001 flags:# bot_id:long recipients:BusinessBotRecipients rights:BusinessBotRights device:flags.0?string date:flags.1?int location:flags.2?string = ConnectedBot; + +account.connectedBots#17d7f87b connected_bots:Vector users:Vector = account.ConnectedBots; + +messages.dialogFilters#2ad93719 flags:# tags_enabled:flags.0?true filters:Vector = messages.DialogFilters; + +birthday#6c8e1e06 flags:# day:int month:int year:flags.0?int = Birthday; + +botBusinessConnection#8f34b2f5 flags:# disabled:flags.1?true connection_id:string user_id:long dc_id:int date:int rights:flags.2?BusinessBotRights = BotBusinessConnection; + +inputBusinessIntro#9c469cd flags:# title:string description:string sticker:flags.0?InputDocument = InputBusinessIntro; + +businessIntro#5a0a066d flags:# title:string description:string sticker:flags.0?Document = BusinessIntro; + +messages.myStickers#faff629d count:int sets:Vector = messages.MyStickers; + +inputCollectibleUsername#e39460a9 username:string = InputCollectible; + +inputCollectiblePhone#a2e214a4 phone:string = InputCollectible; + +fragment.collectibleInfo#6ebdff91 purchase_date:int currency:string amount:long crypto_currency:string crypto_amount:long url:string = fragment.CollectibleInfo; + +inputBusinessBotRecipients#c4e5921e flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = InputBusinessBotRecipients; + +businessBotRecipients#b88cf373 flags:# existing_chats:flags.0?true new_chats:flags.1?true contacts:flags.2?true non_contacts:flags.3?true exclude_selected:flags.5?true users:flags.4?Vector exclude_users:flags.6?Vector = BusinessBotRecipients; + +contactBirthday#1d998733 contact_id:long birthday:Birthday = ContactBirthday; + +contacts.contactBirthdays#114ff30d contacts:Vector users:Vector = contacts.ContactBirthdays; + +missingInvitee#628c9224 flags:# premium_would_allow_invite:flags.0?true premium_required_for_pm:flags.1?true user_id:long = MissingInvitee; + +messages.invitedUsers#7f5defa6 updates:Updates missing_invitees:Vector = messages.InvitedUsers; + +inputBusinessChatLink#11679fa7 flags:# message:string entities:flags.0?Vector title:flags.1?string = InputBusinessChatLink; + +businessChatLink#b4ae666f flags:# link:string message:string entities:flags.0?Vector title:flags.1?string views:int = BusinessChatLink; + +account.businessChatLinks#ec43a2d1 links:Vector chats:Vector users:Vector = account.BusinessChatLinks; + +account.resolvedBusinessChatLinks#9a23af21 flags:# peer:Peer message:string entities:flags.0?Vector chats:Vector users:Vector = account.ResolvedBusinessChatLinks; + +requestedPeerUser#d62ff46a flags:# user_id:long first_name:flags.0?string last_name:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +requestedPeerChat#7307544f flags:# chat_id:long title:flags.0?string photo:flags.2?Photo = RequestedPeer; + +requestedPeerChannel#8ba403e4 flags:# channel_id:long title:flags.0?string username:flags.1?string photo:flags.2?Photo = RequestedPeer; + +sponsoredMessageReportOption#430d3150 text:string option:bytes = SponsoredMessageReportOption; + +channels.sponsoredMessageReportResultChooseOption#846f9e42 title:string options:Vector = channels.SponsoredMessageReportResult; + +channels.sponsoredMessageReportResultAdsHidden#3e3bcf2f = channels.SponsoredMessageReportResult; + +channels.sponsoredMessageReportResultReported#ad798849 = channels.SponsoredMessageReportResult; + +reactionNotificationsFromContacts#bac3a61a = ReactionNotificationsFrom; + +reactionNotificationsFromAll#4b9e22a0 = ReactionNotificationsFrom; + +reactionsNotifySettings#71e4ea58 flags:# messages_notify_from:flags.0?ReactionNotificationsFrom stories_notify_from:flags.1?ReactionNotificationsFrom poll_votes_notify_from:flags.2?ReactionNotificationsFrom sound:NotificationSound show_previews:Bool = ReactionsNotifySettings; + +availableEffect#93c3e27e flags:# premium_required:flags.2?true id:long emoticon:string static_icon_id:flags.0?long effect_sticker_id:long effect_animation_id:flags.1?long = AvailableEffect; + +messages.availableEffectsNotModified#d1ed9a5b = messages.AvailableEffects; + +messages.availableEffects#bddb616e hash:int effects:Vector documents:Vector = messages.AvailableEffects; + +factCheck#b89bfccf flags:# need_check:flags.0?true country:flags.1?string text:flags.1?TextWithEntities hash:long = FactCheck; + +starsTransactionPeerUnsupported#95f2bfe4 = StarsTransactionPeer; + +starsTransactionPeerAppStore#b457b375 = StarsTransactionPeer; + +starsTransactionPeerPlayMarket#7b560a0b = StarsTransactionPeer; + +starsTransactionPeerPremiumBot#250dbaf8 = StarsTransactionPeer; + +starsTransactionPeerFragment#e92fd902 = StarsTransactionPeer; + +starsTransactionPeer#d80da15d peer:Peer = StarsTransactionPeer; + +starsTransactionPeerAds#60682812 = StarsTransactionPeer; + +starsTransactionPeerAPI#f9677aad = StarsTransactionPeer; + +starsTopupOption#bd915c0 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsTopupOption; + +starsTransaction#13659eb0 flags:# refund:flags.3?true pending:flags.4?true failed:flags.6?true gift:flags.10?true reaction:flags.11?true stargift_upgrade:flags.18?true business_transfer:flags.21?true stargift_resale:flags.22?true posts_search:flags.24?true stargift_prepaid_upgrade:flags.25?true stargift_drop_original_details:flags.26?true phonegroup_message:flags.27?true stargift_auction_bid:flags.28?true offer:flags.29?true id:string amount:StarsAmount date:int peer:StarsTransactionPeer title:flags.0?string description:flags.1?string photo:flags.2?WebDocument transaction_date:flags.5?int transaction_url:flags.5?string bot_payload:flags.7?bytes msg_id:flags.8?int extended_media:flags.9?Vector subscription_period:flags.12?int giveaway_post_id:flags.13?int stargift:flags.14?StarGift floodskip_number:flags.15?int starref_commission_permille:flags.16?int starref_peer:flags.17?Peer starref_amount:flags.17?StarsAmount paid_messages:flags.19?int premium_gift_months:flags.20?int ads_proceeds_from_date:flags.23?int ads_proceeds_to_date:flags.23?int = StarsTransaction; + +payments.starsStatus#6c9ce8ed flags:# balance:StarsAmount subscriptions:flags.1?Vector subscriptions_next_offset:flags.2?string subscriptions_missing_balance:flags.4?long history:flags.3?Vector next_offset:flags.0?string chats:Vector users:Vector = payments.StarsStatus; + +foundStory#e87acbc0 peer:Peer story:StoryItem = FoundStory; + +stories.foundStories#e2de7737 flags:# count:int stories:Vector next_offset:flags.0?string chats:Vector users:Vector = stories.FoundStories; + +geoPointAddress#de4c5d93 flags:# country_iso2:string state:flags.0?string city:flags.1?string street:flags.2?string = GeoPointAddress; + +starsRevenueStatus#febe5491 flags:# withdrawal_enabled:flags.0?true current_balance:StarsAmount available_balance:StarsAmount overall_revenue:StarsAmount next_withdrawal_at:flags.1?int = StarsRevenueStatus; + +payments.starsRevenueStats#6c207376 flags:# top_hours_graph:flags.0?StatsGraph revenue_graph:StatsGraph status:StarsRevenueStatus usd_rate:double = payments.StarsRevenueStats; + +payments.starsRevenueWithdrawalUrl#1dab80b7 url:string = payments.StarsRevenueWithdrawalUrl; + +payments.starsRevenueAdsAccountUrl#394e7f21 url:string = payments.StarsRevenueAdsAccountUrl; + +inputStarsTransaction#206ae6d1 flags:# refund:flags.0?true id:string = InputStarsTransaction; + +starsGiftOption#5e0589f1 flags:# extended:flags.1?true stars:long store_product:flags.0?string currency:string amount:long = StarsGiftOption; + +bots.popularAppBots#1991b13b flags:# next_offset:flags.0?string users:Vector = bots.PopularAppBots; + +botPreviewMedia#23e91ba3 date:int media:MessageMedia = BotPreviewMedia; + +bots.previewInfo#ca71d64 media:Vector lang_codes:Vector = bots.PreviewInfo; + +starsSubscriptionPricing#5416d58 period:int amount:long = StarsSubscriptionPricing; + +starsSubscription#2e6eab1a flags:# canceled:flags.0?true can_refulfill:flags.1?true missing_balance:flags.2?true bot_canceled:flags.7?true id:string peer:Peer until_date:int pricing:StarsSubscriptionPricing chat_invite_hash:flags.3?string title:flags.4?string photo:flags.5?WebDocument invoice_slug:flags.6?string = StarsSubscription; + +messageReactor#4ba3a95a flags:# top:flags.0?true my:flags.1?true anonymous:flags.2?true peer_id:flags.3?Peer count:int = MessageReactor; + +starsGiveawayOption#94ce852a flags:# extended:flags.0?true default:flags.1?true stars:long yearly_boosts:int store_product:flags.2?string currency:string amount:long winners:Vector = StarsGiveawayOption; + +starsGiveawayWinnersOption#54236209 flags:# default:flags.0?true users:int per_user_stars:long = StarsGiveawayWinnersOption; + +starGift#313a9547 flags:# limited:flags.0?true sold_out:flags.1?true birthday:flags.2?true require_premium:flags.7?true limited_per_user:flags.8?true peer_color_available:flags.10?true auction:flags.11?true id:long sticker:Document stars:long availability_remains:flags.0?int availability_total:flags.0?int availability_resale:flags.4?long convert_stars:long first_sale_date:flags.1?int last_sale_date:flags.1?int upgrade_stars:flags.3?long resell_min_stars:flags.4?long title:flags.5?string released_by:flags.6?Peer per_user_total:flags.8?int per_user_remains:flags.8?int locked_until_date:flags.9?int auction_slug:flags.11?string gifts_per_round:flags.11?int auction_start_date:flags.11?int upgrade_variants:flags.12?int background:flags.13?StarGiftBackground = StarGift; + +starGiftUnique#85f0a9cd flags:# require_premium:flags.6?true resale_ton_only:flags.7?true theme_available:flags.9?true burned:flags.14?true crafted:flags.15?true id:long gift_id:long title:string slug:string num:int owner_id:flags.0?Peer owner_name:flags.1?string owner_address:flags.2?string attributes:Vector availability_issued:int availability_total:int gift_address:flags.3?string resell_amount:flags.4?Vector released_by:flags.5?Peer value_amount:flags.8?long value_currency:flags.8?string value_usd_amount:flags.8?long theme_peer:flags.10?Peer peer_color:flags.11?PeerColor host_id:flags.12?Peer offer_min_stars:flags.13?int craft_chance_permille:flags.16?int = StarGift; + +payments.starGiftsNotModified#a388a368 = payments.StarGifts; + +payments.starGifts#2ed82995 hash:int gifts:Vector chats:Vector users:Vector = payments.StarGifts; + +messageReportOption#7903e3d9 text:string option:bytes = MessageReportOption; + +reportResultChooseOption#f0e4e0b6 title:string options:Vector = ReportResult; + +reportResultAddComment#6f09ac31 flags:# optional:flags.0?true option:bytes = ReportResult; + +reportResultReported#8db33c4b = ReportResult; + +messages.botPreparedInlineMessage#8ecf0511 id:string expire_date:int = messages.BotPreparedInlineMessage; + +messages.preparedInlineMessage#ff57708d query_id:long result:BotInlineResult peer_types:Vector cache_time:int users:Vector = messages.PreparedInlineMessage; + +botAppSettings#c99b1950 flags:# placeholder_path:flags.0?bytes background_color:flags.1?int background_dark_color:flags.2?int header_color:flags.3?int header_dark_color:flags.4?int = BotAppSettings; + +starRefProgram#dd0c66f2 flags:# bot_id:long commission_permille:int duration_months:flags.0?int end_date:flags.1?int daily_revenue_per_user:flags.2?StarsAmount = StarRefProgram; + +connectedBotStarRef#19a13f71 flags:# revoked:flags.1?true url:string date:int bot_id:long commission_permille:int duration_months:flags.0?int participants:long revenue:long = ConnectedBotStarRef; + +payments.connectedStarRefBots#98d5ea1d count:int connected_bots:Vector users:Vector = payments.ConnectedStarRefBots; + +payments.suggestedStarRefBots#b4d5d859 flags:# count:int suggested_bots:Vector users:Vector next_offset:flags.0?string = payments.SuggestedStarRefBots; + +starsAmount#bbb6b4a3 amount:long nanos:int = StarsAmount; + +starsTonAmount#74aee3e0 amount:long = StarsAmount; + +messages.foundStickersNotModified#6010c534 flags:# next_offset:flags.0?int = messages.FoundStickers; + +messages.foundStickers#82c9e290 flags:# next_offset:flags.0?int hash:long stickers:Vector = messages.FoundStickers; + +botVerifierSettings#b0cd6617 flags:# can_modify_custom_description:flags.1?true icon:long company:string custom_description:flags.0?string = BotVerifierSettings; + +botVerification#f93cd45c bot_id:long icon:long description:string = BotVerification; + +starGiftAttributeModel#565251e2 flags:# crafted:flags.0?true name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; + +starGiftAttributePattern#4e7085ea name:string document:Document rarity:StarGiftAttributeRarity = StarGiftAttribute; + +starGiftAttributeBackdrop#9f2504e4 name:string backdrop_id:int center_color:int edge_color:int pattern_color:int text_color:int rarity:StarGiftAttributeRarity = StarGiftAttribute; + +starGiftAttributeOriginalDetails#e0bff26c flags:# sender_id:flags.0?Peer recipient_id:Peer date:int message:flags.1?TextWithEntities = StarGiftAttribute; + +payments.starGiftUpgradePreview#3de1dfed sample_attributes:Vector prices:Vector next_prices:Vector = payments.StarGiftUpgradePreview; + +users.users#62d706b8 users:Vector = users.Users; + +users.usersSlice#315a4974 count:int users:Vector = users.Users; + +payments.uniqueStarGift#416c56e8 gift:StarGift chats:Vector users:Vector = payments.UniqueStarGift; + +messages.webPagePreview#8c9a88ac media:MessageMedia chats:Vector users:Vector = messages.WebPagePreview; + +savedStarGift#41df43fc flags:# name_hidden:flags.0?true unsaved:flags.5?true refunded:flags.9?true can_upgrade:flags.10?true pinned_to_top:flags.12?true upgrade_separate:flags.17?true from_id:flags.1?Peer date:int gift:StarGift message:flags.2?TextWithEntities msg_id:flags.3?int saved_id:flags.11?long convert_stars:flags.4?long upgrade_stars:flags.6?long can_export_at:flags.7?int transfer_stars:flags.8?long can_transfer_at:flags.13?int can_resell_at:flags.14?int collection_id:flags.15?Vector prepaid_upgrade_hash:flags.16?string drop_original_details_stars:flags.18?long gift_num:flags.19?int can_craft_at:flags.20?int = SavedStarGift; + +payments.savedStarGifts#95f389b1 flags:# count:int chat_notifications_enabled:flags.1?Bool gifts:Vector next_offset:flags.0?string chats:Vector users:Vector = payments.SavedStarGifts; + +inputSavedStarGiftUser#69279795 msg_id:int = InputSavedStarGift; + +inputSavedStarGiftChat#f101aa7f peer:InputPeer saved_id:long = InputSavedStarGift; + +inputSavedStarGiftSlug#2085c238 slug:string = InputSavedStarGift; + +payments.starGiftWithdrawalUrl#84aa3a9c url:string = payments.StarGiftWithdrawalUrl; + +paidReactionPrivacyDefault#206ad49e = PaidReactionPrivacy; + +paidReactionPrivacyAnonymous#1f0c1ad9 = PaidReactionPrivacy; + +paidReactionPrivacyPeer#dc6cfcf0 peer:InputPeer = PaidReactionPrivacy; + +account.paidMessagesRevenue#1e109708 stars_amount:long = account.PaidMessagesRevenue; + +requirementToContactEmpty#50a9839 = RequirementToContact; + +requirementToContactPremium#e581e4e9 = RequirementToContact; + +requirementToContactPaidMessages#b4f67e93 stars_amount:long = RequirementToContact; + +businessBotRights#a0624cf7 flags:# reply:flags.0?true read_messages:flags.1?true delete_sent_messages:flags.2?true delete_received_messages:flags.3?true edit_name:flags.4?true edit_bio:flags.5?true edit_profile_photo:flags.6?true edit_username:flags.7?true view_gifts:flags.8?true sell_gifts:flags.9?true change_gift_settings:flags.10?true transfer_and_upgrade_gifts:flags.11?true transfer_stars:flags.12?true manage_stories:flags.13?true = BusinessBotRights; + +disallowedGiftsSettings#71f276c4 flags:# disallow_unlimited_stargifts:flags.0?true disallow_limited_stargifts:flags.1?true disallow_unique_stargifts:flags.2?true disallow_premium_gifts:flags.3?true disallow_stargifts_from_channels:flags.4?true = DisallowedGiftsSettings; + +sponsoredPeer#c69708d3 flags:# random_id:bytes peer:Peer sponsor_info:flags.0?string additional_info:flags.1?string = SponsoredPeer; + +contacts.sponsoredPeersEmpty#ea32b4b1 = contacts.SponsoredPeers; + +contacts.sponsoredPeers#eb032884 peers:Vector chats:Vector users:Vector = contacts.SponsoredPeers; + +starGiftAttributeIdModel#48aaae3c document_id:long = StarGiftAttributeId; + +starGiftAttributeIdPattern#4a162433 document_id:long = StarGiftAttributeId; + +starGiftAttributeIdBackdrop#1f01c757 backdrop_id:int = StarGiftAttributeId; + +starGiftAttributeCounter#2eb1b658 attribute:StarGiftAttributeId count:int = StarGiftAttributeCounter; + +payments.resaleStarGifts#947a12df flags:# count:int gifts:Vector next_offset:flags.0?string attributes:flags.1?Vector attributes_hash:flags.1?long chats:Vector counters:flags.2?Vector users:Vector = payments.ResaleStarGifts; + +stories.canSendStoryCount#c387c04e count_remains:int = stories.CanSendStoryCount; + +pendingSuggestion#e7e82e12 suggestion:string title:TextWithEntities description:TextWithEntities url:string = PendingSuggestion; + +todoItem#cba9a52f id:int title:TextWithEntities = TodoItem; + +todoList#49b92a26 flags:# others_can_append:flags.0?true others_can_complete:flags.1?true title:TextWithEntities list:Vector = TodoList; + +todoCompletion#221bb5e4 id:int completed_by:Peer date:int = TodoCompletion; + +suggestedPost#e8e37e5 flags:# accepted:flags.1?true rejected:flags.2?true price:flags.3?StarsAmount schedule_date:flags.0?int = SuggestedPost; + +starsRating#1b0e4f07 flags:# level:int current_level_stars:long stars:long next_level_stars:flags.0?long = StarsRating; + +starGiftCollection#9d6b13b0 flags:# collection_id:int title:string icon:flags.0?Document gifts_count:int hash:long = StarGiftCollection; + +payments.starGiftCollectionsNotModified#a0ba4f17 = payments.StarGiftCollections; + +payments.starGiftCollections#8a2932f3 collections:Vector = payments.StarGiftCollections; + +storyAlbum#9325705a flags:# album_id:int title:string icon_photo:flags.0?Photo icon_video:flags.1?Document = StoryAlbum; + +stories.albumsNotModified#564edaeb = stories.Albums; + +stories.albums#c3987a3a hash:long albums:Vector = stories.Albums; + +searchPostsFlood#3e0b5b6a flags:# query_is_free:flags.0?true total_daily:int remains:int wait_till:flags.1?int stars_amount:long = SearchPostsFlood; + +payments.uniqueStarGiftValueInfo#512fe446 flags:# last_sale_on_fragment:flags.1?true value_is_average:flags.6?true currency:string value:long initial_sale_date:int initial_sale_stars:long initial_sale_price:long last_sale_date:flags.0?int last_sale_price:flags.0?long floor_price:flags.2?long average_price:flags.3?long listed_count:flags.4?int fragment_listed_count:flags.5?int fragment_listed_url:flags.5?string = payments.UniqueStarGiftValueInfo; + +profileTabPosts#b98cd696 = ProfileTab; + +profileTabGifts#4d4bd46a = ProfileTab; + +profileTabMedia#72c64955 = ProfileTab; + +profileTabFiles#ab339c00 = ProfileTab; + +profileTabMusic#9f27d26e = ProfileTab; + +profileTabVoice#e477092e = ProfileTab; + +profileTabLinks#d3656499 = ProfileTab; + +profileTabGifs#a2c0f695 = ProfileTab; + +users.savedMusicNotModified#e3878aa4 count:int = users.SavedMusic; + +users.savedMusic#34a2f297 count:int documents:Vector = users.SavedMusic; + +account.savedMusicIdsNotModified#4fc81d6e = account.SavedMusicIds; + +account.savedMusicIds#998d6636 ids:Vector = account.SavedMusicIds; + +payments.checkCanSendGiftResultOk#374fa7ad = payments.CheckCanSendGiftResult; + +payments.checkCanSendGiftResultFail#d5e58274 reason:TextWithEntities = payments.CheckCanSendGiftResult; + +inputChatThemeEmpty#83268483 = InputChatTheme; + +inputChatTheme#c93de95c emoticon:string = InputChatTheme; + +inputChatThemeUniqueGift#87e5dfe4 slug:string = InputChatTheme; + +starGiftUpgradePrice#99ea331d date:int upgrade_stars:long = StarGiftUpgradePrice; + +groupCallMessage#1a8afc7e flags:# from_admin:flags.1?true id:int from_id:Peer date:int message:TextWithEntities paid_message_stars:flags.0?long = GroupCallMessage; + +groupCallDonor#ee430c85 flags:# top:flags.0?true my:flags.1?true peer_id:flags.3?Peer stars:long = GroupCallDonor; + +phone.groupCallStars#9d1dbd26 total_stars:long top_donors:Vector chats:Vector users:Vector = phone.GroupCallStars; + +recentStory#711d692d flags:# live:flags.0?true max_id:flags.1?int = RecentStory; + +auctionBidLevel#310240cc pos:int amount:long date:int = AuctionBidLevel; + +starGiftAuctionStateNotModified#fe333952 = StarGiftAuctionState; + +starGiftAuctionState#771a4e66 version:int start_date:int end_date:int min_bid_amount:long bid_levels:Vector top_bidders:Vector next_round_at:int last_gift_num:int gifts_left:int current_round:int total_rounds:int rounds:Vector = StarGiftAuctionState; + +starGiftAuctionStateFinished#972dabbf flags:# start_date:int end_date:int average_price:long listed_count:flags.0?int fragment_listed_count:flags.1?int fragment_listed_url:flags.1?string = StarGiftAuctionState; + +starGiftAuctionUserState#2eeed1c4 flags:# returned:flags.1?true bid_amount:flags.0?long bid_date:flags.0?int min_bid_amount:flags.0?long bid_peer:flags.0?Peer acquired_count:int = StarGiftAuctionUserState; + +payments.starGiftAuctionState#6b39f4ec gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState timeout:int users:Vector chats:Vector = payments.StarGiftAuctionState; + +starGiftAuctionAcquiredGift#42b00348 flags:# name_hidden:flags.0?true peer:Peer date:int bid_amount:long round:int pos:int message:flags.1?TextWithEntities gift_num:flags.2?int = StarGiftAuctionAcquiredGift; + +payments.starGiftAuctionAcquiredGifts#7d5bd1f0 gifts:Vector users:Vector chats:Vector = payments.StarGiftAuctionAcquiredGifts; + +starGiftActiveAuctionState#d31bc45d gift:StarGift state:StarGiftAuctionState user_state:StarGiftAuctionUserState = StarGiftActiveAuctionState; + +payments.starGiftActiveAuctionsNotModified#db33dad0 = payments.StarGiftActiveAuctions; + +payments.starGiftActiveAuctions#aef6abbc auctions:Vector users:Vector chats:Vector = payments.StarGiftActiveAuctions; + +inputStarGiftAuction#2e16c98 gift_id:long = InputStarGiftAuction; + +inputStarGiftAuctionSlug#7ab58308 slug:string = InputStarGiftAuction; + +passkey#98613ebf flags:# id:string name:string date:int software_emoji_id:flags.0?long last_usage_date:flags.1?int = Passkey; + +account.passkeys#f8e0aa1c passkeys:Vector = account.Passkeys; + +account.passkeyRegistrationOptions#e16b5ce1 options:DataJSON = account.PasskeyRegistrationOptions; + +auth.passkeyLoginOptions#e2037789 options:DataJSON = auth.PasskeyLoginOptions; + +inputPasskeyResponseRegister#3e63935c client_data:DataJSON attestation_data:bytes = InputPasskeyResponse; + +inputPasskeyResponseLogin#c31fc14a client_data:DataJSON authenticator_data:bytes signature:bytes user_handle:string = InputPasskeyResponse; + +inputPasskeyCredentialPublicKey#3c27b78f id:string raw_id:string response:InputPasskeyResponse = InputPasskeyCredential; + +inputPasskeyCredentialFirebasePNV#5b1ccb28 pnv_token:string = InputPasskeyCredential; + +starGiftBackground#aff56398 center_color:int edge_color:int text_color:int = StarGiftBackground; + +starGiftAuctionRound#3aae0528 num:int duration:int = StarGiftAuctionRound; + +starGiftAuctionRoundExtendable#aa021e5 num:int duration:int extend_top:int extend_window:int = StarGiftAuctionRound; + +payments.starGiftUpgradeAttributes#46c6e36f attributes:Vector = payments.StarGiftUpgradeAttributes; + +messages.emojiGameOutcome#da2ad647 seed:bytes stake_ton_amount:long ton_amount:long = messages.EmojiGameOutcome; + +messages.emojiGameUnavailable#59e65335 = messages.EmojiGameInfo; + +messages.emojiGameDiceInfo#44e56023 flags:# game_hash:string prev_stake:long current_streak:int params:Vector plays_left:flags.0?int = messages.EmojiGameInfo; + +starGiftAttributeRarity#36437737 permille:int = StarGiftAttributeRarity; + +starGiftAttributeRarityUncommon#dbce6389 = StarGiftAttributeRarity; + +starGiftAttributeRarityRare#f08d516b = StarGiftAttributeRarity; + +starGiftAttributeRarityEpic#78fbf3a8 = StarGiftAttributeRarity; + +starGiftAttributeRarityLegendary#cef7e7a8 = StarGiftAttributeRarity; + +keyboardButtonStyle#4fdd3430 flags:# bg_primary:flags.0?true bg_danger:flags.1?true bg_success:flags.2?true icon:flags.3?long = KeyboardButtonStyle; + +inputMessageReadMetric#402b4495 msg_id:int view_id:long time_in_view_ms:int active_time_in_view_ms:int height_to_viewport_ratio_permille:int seen_range_ratio_permille:int = InputMessageReadMetric; + +bots.exportedBotToken#3c60b621 token:string = bots.ExportedBotToken; + +bots.requestedButton#f13bbcd7 webapp_req_id:string = bots.RequestedButton; + +messages.composedMessageWithAI#90d7adfa flags:# result_text:TextWithEntities diff_text:flags.0?TextWithEntities = messages.ComposedMessageWithAI; + +stats.pollStats#2999beed votes_graph:StatsGraph = stats.PollStats; + +inputAiComposeToneDefault#1fe9a9bf tone:string = InputAiComposeTone; + +inputAiComposeToneID#773c080 id:long access_hash:long = InputAiComposeTone; + +inputAiComposeToneSlug#1fa01357 slug:string = InputAiComposeTone; + +aiComposeTone#cff63ea9 flags:# creator:flags.0?true id:long access_hash:long slug:string title:string emoji_id:flags.1?long prompt:flags.4?string installs_count:flags.2?int author_id:flags.3?long example_english:flags.5?AiComposeToneExample = AiComposeTone; + +aiComposeToneDefault#9bad6414 tone:string emoji_id:long title:string = AiComposeTone; + +aicompose.tonesNotModified#c1f46103 = aicompose.Tones; + +aicompose.tones#6c9d0efe hash:long tones:Vector users:Vector = aicompose.Tones; + +aiComposeToneExample#f1d628ec from:TextWithEntities to:TextWithEntities = AiComposeToneExample; + +bots.accessSettings#dd1fbf93 flags:# restricted:flags.0?true add_users:flags.1?Vector = bots.AccessSettings; + +messages.chatInviteJoinResultOk#445663a7 updates:Updates = messages.ChatInviteJoinResult; + +messages.chatInviteJoinResultWebView#2f51c337 bot_id:long webview:WebViewResult users:Vector = messages.ChatInviteJoinResult; + +joinChatBotResultApproved#ae152a69 = JoinChatBotResult; + +joinChatBotResultDeclined#efa0194 = JoinChatBotResult; + +joinChatBotResultQueued#98a3a840 = JoinChatBotResult; + +joinChatBotResultWebView#d6e3b813 url:string = JoinChatBotResult; + +webDomainException#933ca597 flags:# domain:string url:string title:string favicon:flags.0?long = WebDomainException; + +account.webBrowserSettingsNotModified#c31c8f4e = account.WebBrowserSettings; + +account.webBrowserSettings#79eb8cb3 flags:# open_external_browser:flags.0?true display_close_button:flags.1?true external_exceptions:Vector inapp_exceptions:Vector hash:long = account.WebBrowserSettings; + +inputRichFilePhoto#9b00622b id:string photo:InputPhoto = InputRichFile; + +inputRichFileDocument#83281dbd id:string document:InputDocument = InputRichFile; + +inputRichMessage#e4c449fc flags:# rtl:flags.0?true noautolink:flags.1?true blocks:Vector photos:flags.2?Vector documents:flags.3?Vector users:flags.4?Vector = InputRichMessage; + +inputRichMessageHTML#dacb836a flags:# rtl:flags.0?true noautolink:flags.1?true html:string files:flags.2?Vector = InputRichMessage; + +inputRichMessageMarkdown#4b572c flags:# rtl:flags.0?true noautolink:flags.1?true markdown:string files:flags.2?Vector = InputRichMessage; + +richMessage#baf39d8b flags:# rtl:flags.0?true part:flags.1?true blocks:Vector photos:Vector documents:Vector = RichMessage; + + +---functions--- + +invokeAfterMsg#cb9f372d {X:Type} msg_id:long query:!X = X; + +invokeAfterMsgs#3dc4b4f0 {X:Type} msg_ids:Vector query:!X = X; + +initConnection#c1cd5ea9 {X:Type} flags:# api_id:int device_model:string system_version:string app_version:string system_lang_code:string lang_pack:string lang_code:string proxy:flags.0?InputClientProxy params:flags.1?JSONValue query:!X = X; + +invokeWithLayer#da9b0d0d {X:Type} layer:int query:!X = X; + +invokeWithoutUpdates#bf9459b7 {X:Type} query:!X = X; + +invokeWithMessagesRange#365275f2 {X:Type} range:MessageRange query:!X = X; + +invokeWithTakeout#aca9fd2e {X:Type} takeout_id:long query:!X = X; + +invokeWithBusinessConnection#dd289f8e {X:Type} connection_id:string query:!X = X; + +invokeWithGooglePlayIntegrity#1df92984 {X:Type} nonce:string token:string query:!X = X; + +invokeWithApnsSecret#dae54f8 {X:Type} nonce:string secret:string query:!X = X; + +invokeWithReCaptcha#adbb0f94 {X:Type} token:string query:!X = X; + +auth.sendCode#a677244f phone_number:string api_id:int api_hash:string settings:CodeSettings = auth.SentCode; + +auth.signUp#aac7b717 flags:# no_joined_notifications:flags.0?true phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; + +auth.signIn#8d52a951 flags:# phone_number:string phone_code_hash:string phone_code:flags.0?string email_verification:flags.1?EmailVerification = auth.Authorization; + +auth.logOut#3e72ba19 = auth.LoggedOut; + +auth.resetAuthorizations#9fab0d1a = Bool; + +auth.exportAuthorization#e5bfffcd dc_id:int = auth.ExportedAuthorization; + +auth.importAuthorization#a57a7dad id:long bytes:bytes = auth.Authorization; + +auth.bindTempAuthKey#cdd42a05 perm_auth_key_id:long nonce:long expires_at:int encrypted_message:bytes = Bool; + +auth.importBotAuthorization#67a3ff2c flags:int api_id:int api_hash:string bot_auth_token:string = auth.Authorization; + +auth.checkPassword#d18b4d16 password:InputCheckPasswordSRP = auth.Authorization; + +auth.requestPasswordRecovery#d897bc66 = auth.PasswordRecovery; + +auth.recoverPassword#37096c70 flags:# code:string new_settings:flags.0?account.PasswordInputSettings = auth.Authorization; + +auth.resendCode#cae47523 flags:# phone_number:string phone_code_hash:string reason:flags.0?string = auth.SentCode; + +auth.cancelCode#1f040578 phone_number:string phone_code_hash:string = Bool; + +auth.dropTempAuthKeys#8e48a188 except_auth_keys:Vector = Bool; + +auth.exportLoginToken#b7e085fe api_id:int api_hash:string except_ids:Vector = auth.LoginToken; + +auth.importLoginToken#95ac5ce4 token:bytes = auth.LoginToken; + +auth.acceptLoginToken#e894ad4d token:bytes = Authorization; + +auth.checkRecoveryPassword#d36bf79 code:string = Bool; + +auth.importWebTokenAuthorization#2db873a9 api_id:int api_hash:string web_auth_token:string = auth.Authorization; + +auth.requestFirebaseSms#8e39261e flags:# phone_number:string phone_code_hash:string safety_net_token:flags.0?string play_integrity_token:flags.2?string ios_push_secret:flags.1?string = Bool; + +auth.resetLoginEmail#7e960193 phone_number:string phone_code_hash:string = auth.SentCode; + +auth.reportMissingCode#cb9deff6 phone_number:string phone_code_hash:string mnc:string = Bool; + +auth.checkPaidAuth#56e59f9c phone_number:string phone_code_hash:string form_id:long = auth.SentCode; + +auth.initPasskeyLogin#518ad0b7 api_id:int api_hash:string = auth.PasskeyLoginOptions; + +auth.finishPasskeyLogin#9857ad07 flags:# credential:InputPasskeyCredential from_dc_id:flags.0?int from_auth_key_id:flags.0?long = auth.Authorization; + +account.registerDevice#ec86017a flags:# no_muted:flags.0?true token_type:int token:string app_sandbox:Bool secret:bytes other_uids:Vector = Bool; + +account.unregisterDevice#6a0d3206 token_type:int token:string other_uids:Vector = Bool; + +account.updateNotifySettings#84be5b93 peer:InputNotifyPeer settings:InputPeerNotifySettings = Bool; + +account.getNotifySettings#12b3ad31 peer:InputNotifyPeer = PeerNotifySettings; + +account.resetNotifySettings#db7e1747 = Bool; + +account.updateProfile#78515775 flags:# first_name:flags.0?string last_name:flags.1?string about:flags.2?string = User; + +account.updateStatus#6628562c offline:Bool = Bool; + +account.getWallPapers#7967d36 hash:long = account.WallPapers; + +account.reportPeer#c5ba3d86 peer:InputPeer reason:ReportReason message:string = Bool; + +account.checkUsername#2714d86c username:string = Bool; + +account.updateUsername#3e0bdd7c username:string = User; + +account.getPrivacy#dadbc950 key:InputPrivacyKey = account.PrivacyRules; + +account.setPrivacy#c9f81ce8 key:InputPrivacyKey rules:Vector = account.PrivacyRules; + +account.deleteAccount#a2c0cf74 flags:# reason:string password:flags.0?InputCheckPasswordSRP = Bool; + +account.getAccountTTL#8fc711d = AccountDaysTTL; + +account.setAccountTTL#2442485e ttl:AccountDaysTTL = Bool; + +account.sendChangePhoneCode#82574ae5 phone_number:string settings:CodeSettings = auth.SentCode; + +account.changePhone#70c32edb phone_number:string phone_code_hash:string phone_code:string = User; + +account.updateDeviceLocked#38df3532 period:int = Bool; + +account.getAuthorizations#e320c158 = account.Authorizations; + +account.resetAuthorization#df77f3bc hash:long = Bool; + +account.getPassword#548a30f5 = account.Password; + +account.getPasswordSettings#9cd4eaf9 password:InputCheckPasswordSRP = account.PasswordSettings; + +account.updatePasswordSettings#a59b102f password:InputCheckPasswordSRP new_settings:account.PasswordInputSettings = Bool; + +account.sendConfirmPhoneCode#1b3faa88 hash:string settings:CodeSettings = auth.SentCode; + +account.confirmPhone#5f2178c3 phone_code_hash:string phone_code:string = Bool; + +account.getTmpPassword#449e0b51 password:InputCheckPasswordSRP period:int = account.TmpPassword; + +account.getWebAuthorizations#182e6d6f = account.WebAuthorizations; + +account.resetWebAuthorization#2d01b9ef hash:long = Bool; + +account.resetWebAuthorizations#682d2594 = Bool; + +account.getAllSecureValues#b288bc7d = Vector; + +account.getSecureValue#73665bc2 types:Vector = Vector; + +account.saveSecureValue#899fe31d value:InputSecureValue secure_secret_id:long = SecureValue; + +account.deleteSecureValue#b880bc4b types:Vector = Bool; + +account.getAuthorizationForm#a929597a bot_id:long scope:string public_key:string = account.AuthorizationForm; + +account.acceptAuthorization#f3ed4c73 bot_id:long scope:string public_key:string value_hashes:Vector credentials:SecureCredentialsEncrypted = Bool; + +account.sendVerifyPhoneCode#a5a356f9 phone_number:string settings:CodeSettings = auth.SentCode; + +account.verifyPhone#4dd3a7f6 phone_number:string phone_code_hash:string phone_code:string = Bool; + +account.sendVerifyEmailCode#98e037bb purpose:EmailVerifyPurpose email:string = account.SentEmailCode; + +account.verifyEmail#32da4cf purpose:EmailVerifyPurpose verification:EmailVerification = account.EmailVerified; + +account.initTakeoutSession#8ef3eab0 flags:# contacts:flags.0?true message_users:flags.1?true message_chats:flags.2?true message_megagroups:flags.3?true message_channels:flags.4?true files:flags.5?true file_max_size:flags.5?long = account.Takeout; + +account.finishTakeoutSession#1d2652ee flags:# success:flags.0?true = Bool; + +account.confirmPasswordEmail#8fdf1920 code:string = Bool; + +account.resendPasswordEmail#7a7f2a15 = Bool; + +account.cancelPasswordEmail#c1cbd5b6 = Bool; + +account.getContactSignUpNotification#9f07c728 = Bool; + +account.setContactSignUpNotification#cff43f61 silent:Bool = Bool; + +account.getNotifyExceptions#53577479 flags:# compare_sound:flags.1?true compare_stories:flags.2?true peer:flags.0?InputNotifyPeer = Updates; + +account.getWallPaper#fc8ddbea wallpaper:InputWallPaper = WallPaper; + +account.uploadWallPaper#e39a8f03 flags:# for_chat:flags.0?true file:InputFile mime_type:string settings:WallPaperSettings = WallPaper; + +account.saveWallPaper#6c5a5b37 wallpaper:InputWallPaper unsave:Bool settings:WallPaperSettings = Bool; + +account.installWallPaper#feed5769 wallpaper:InputWallPaper settings:WallPaperSettings = Bool; + +account.resetWallPapers#bb3b9804 = Bool; + +account.getAutoDownloadSettings#56da0b3f = account.AutoDownloadSettings; + +account.saveAutoDownloadSettings#76f36233 flags:# low:flags.0?true high:flags.1?true settings:AutoDownloadSettings = Bool; + +account.uploadTheme#1c3db333 flags:# file:InputFile thumb:flags.0?InputFile file_name:string mime_type:string = Document; + +account.createTheme#652e4400 flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?Vector = Theme; + +account.updateTheme#2bf40ccc flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?Vector = Theme; + +account.saveTheme#f257106c theme:InputTheme unsave:Bool = Bool; + +account.installTheme#c727bb3b flags:# dark:flags.0?true theme:flags.1?InputTheme format:flags.2?string base_theme:flags.3?BaseTheme = Bool; + +account.getTheme#3a5869ec format:string theme:InputTheme = Theme; + +account.getThemes#7206e458 format:string hash:long = account.Themes; + +account.setContentSettings#b574b16b flags:# sensitive_enabled:flags.0?true = Bool; + +account.getContentSettings#8b9b4dae = account.ContentSettings; + +account.getMultiWallPapers#65ad71dc wallpapers:Vector = Vector; + +account.getGlobalPrivacySettings#eb2b4cf6 = GlobalPrivacySettings; + +account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = GlobalPrivacySettings; + +account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool; + +account.resetPassword#9308ce1b = account.ResetPasswordResult; + +account.declinePasswordReset#4c9409f6 = Bool; + +account.getChatThemes#d638de89 hash:long = account.Themes; + +account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool; + +account.changeAuthorizationSettings#40f48462 flags:# confirmed:flags.3?true hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool; + +account.getSavedRingtones#e1902288 hash:long = account.SavedRingtones; + +account.saveRingtone#3dea5b03 id:InputDocument unsave:Bool = account.SavedRingtone; + +account.uploadRingtone#831a83a2 file:InputFile file_name:string mime_type:string = Document; + +account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool; + +account.getDefaultEmojiStatuses#d6753386 hash:long = account.EmojiStatuses; + +account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses; + +account.clearRecentEmojiStatuses#18201aae = Bool; + +account.reorderUsernames#ef500eab order:Vector = Bool; + +account.toggleUsername#58d6b376 username:string active:Bool = Bool; + +account.getDefaultProfilePhotoEmojis#e2750328 hash:long = EmojiList; + +account.getDefaultGroupPhotoEmojis#915860ae hash:long = EmojiList; + +account.getAutoSaveSettings#adcbbcda = account.AutoSaveSettings; + +account.saveAutoSaveSettings#d69b8361 flags:# users:flags.0?true chats:flags.1?true broadcasts:flags.2?true peer:flags.3?InputPeer settings:AutoSaveSettings = Bool; + +account.deleteAutoSaveExceptions#53bc0020 = Bool; + +account.invalidateSignInCodes#ca8ae8ba codes:Vector = Bool; + +account.updateColor#684d214e flags:# for_profile:flags.1?true color:flags.2?PeerColor = Bool; + +account.getDefaultBackgroundEmojis#a60ab9ce hash:long = EmojiList; + +account.getChannelDefaultEmojiStatuses#7727a7d5 hash:long = account.EmojiStatuses; + +account.getChannelRestrictedStatusEmojis#35a9e0d5 hash:long = EmojiList; + +account.updateBusinessWorkHours#4b00e066 flags:# business_work_hours:flags.0?BusinessWorkHours = Bool; + +account.updateBusinessLocation#9e6b131a flags:# geo_point:flags.1?InputGeoPoint address:flags.0?string = Bool; + +account.updateBusinessGreetingMessage#66cdafc4 flags:# message:flags.0?InputBusinessGreetingMessage = Bool; + +account.updateBusinessAwayMessage#a26a7fa5 flags:# message:flags.0?InputBusinessAwayMessage = Bool; + +account.updateConnectedBot#66a08c7e flags:# deleted:flags.1?true rights:flags.0?BusinessBotRights bot:InputUser recipients:InputBusinessBotRecipients = Updates; + +account.getConnectedBots#4ea4c80f = account.ConnectedBots; + +account.getBotBusinessConnection#76a86270 connection_id:string = Updates; + +account.updateBusinessIntro#a614d034 flags:# intro:flags.0?InputBusinessIntro = Bool; + +account.toggleConnectedBotPaused#646e1097 peer:InputPeer paused:Bool = Bool; + +account.disablePeerConnectedBot#5e437ed9 peer:InputPeer = Bool; + +account.updateBirthday#cc6e0c11 flags:# birthday:flags.0?Birthday = Bool; + +account.createBusinessChatLink#8851e68e link:InputBusinessChatLink = BusinessChatLink; + +account.editBusinessChatLink#8c3410af slug:string link:InputBusinessChatLink = BusinessChatLink; + +account.deleteBusinessChatLink#60073674 slug:string = Bool; + +account.getBusinessChatLinks#6f70dde1 = account.BusinessChatLinks; + +account.resolveBusinessChatLink#5492e5ee slug:string = account.ResolvedBusinessChatLinks; + +account.updatePersonalChannel#d94305e0 channel:InputChannel = Bool; + +account.toggleSponsoredMessages#b9d9a38d enabled:Bool = Bool; + +account.getReactionsNotifySettings#6dd654c = ReactionsNotifySettings; + +account.setReactionsNotifySettings#316ce548 settings:ReactionsNotifySettings = ReactionsNotifySettings; + +account.getCollectibleEmojiStatuses#2e7b4543 hash:long = account.EmojiStatuses; + +account.getPaidMessagesRevenue#19ba4a67 flags:# parent_peer:flags.0?InputPeer user_id:InputUser = account.PaidMessagesRevenue; + +account.toggleNoPaidMessagesException#fe2eda76 flags:# refund_charged:flags.0?true require_payment:flags.2?true parent_peer:flags.1?InputPeer user_id:InputUser = Bool; + +account.setMainProfileTab#5dee78b0 tab:ProfileTab = Bool; + +account.saveMusic#b26732a9 flags:# unsave:flags.0?true id:InputDocument after_id:flags.1?InputDocument = Bool; + +account.getSavedMusicIds#e09d5faf hash:long = account.SavedMusicIds; + +account.getUniqueGiftChatThemes#e42ce9c9 offset:string limit:int hash:long = account.ChatThemes; + +account.initPasskeyRegistration#429547e8 = account.PasskeyRegistrationOptions; + +account.registerPasskey#55b41fd6 credential:InputPasskeyCredential = Passkey; + +account.getPasskeys#ea1f0c52 = account.Passkeys; + +account.deletePasskey#f5b5563f id:string = Bool; + +account.confirmBotConnection#67ed1f68 bot_id:InputUser = Bool; + +account.getWebBrowserSettings#56655768 hash:long = account.WebBrowserSettings; + +account.updateWebBrowserSettings#9adf82fe flags:# open_external_browser:flags.0?true display_close_button:flags.1?true = account.WebBrowserSettings; + +account.toggleWebBrowserSettingsException#60ed4229 flags:# delete:flags.1?true open_external_browser:flags.0?Bool url:string = Updates; + +account.deleteWebBrowserSettingsExceptions#86a0765d = account.WebBrowserSettings; + +users.getUsers#d91a548 id:Vector = Vector; + +users.getFullUser#b60f5918 id:InputUser = users.UserFull; + +users.setSecureValueErrors#90c894b5 id:InputUser errors:Vector = Bool; + +users.getRequirementsToContact#d89a83a3 id:Vector = Vector; + +users.getSavedMusic#788d7fe3 id:InputUser offset:int limit:int hash:long = users.SavedMusic; + +users.getSavedMusicByID#7573a4e9 id:InputUser documents:Vector = users.SavedMusic; + +users.suggestBirthday#fc533372 id:InputUser birthday:Birthday = Updates; + +contacts.getContactIDs#7adc669d hash:long = Vector; + +contacts.getStatuses#c4a353ee = Vector; + +contacts.getContacts#5dd69e12 hash:long = contacts.Contacts; + +contacts.importContacts#2c800be5 contacts:Vector = contacts.ImportedContacts; + +contacts.deleteContacts#96a0e00 id:Vector = Updates; + +contacts.deleteByPhones#1013fd9e phones:Vector = Bool; + +contacts.block#2e2e8734 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; + +contacts.unblock#b550d328 flags:# my_stories_from:flags.0?true id:InputPeer = Bool; + +contacts.getBlocked#9a868f80 flags:# my_stories_from:flags.0?true offset:int limit:int = contacts.Blocked; + +contacts.search#5f58d0f flags:# broadcasts:flags.0?true bots:flags.1?true q:string limit:int = contacts.Found; + +contacts.resolveUsername#725afbbc flags:# username:string referer:flags.0?string = contacts.ResolvedPeer; + +contacts.getTopPeers#973478b6 flags:# correspondents:flags.0?true bots_pm:flags.1?true bots_inline:flags.2?true phone_calls:flags.3?true forward_users:flags.4?true forward_chats:flags.5?true groups:flags.10?true channels:flags.15?true bots_app:flags.16?true bots_guestchat:flags.17?true offset:int limit:int hash:long = contacts.TopPeers; + +contacts.resetTopPeerRating#1ae373ac category:TopPeerCategory peer:InputPeer = Bool; + +contacts.resetSaved#879537f1 = Bool; + +contacts.getSaved#82f1e39f = Vector; + +contacts.toggleTopPeers#8514bdda enabled:Bool = Bool; + +contacts.addContact#d9ba2e54 flags:# add_phone_privacy_exception:flags.0?true id:InputUser first_name:string last_name:string phone:string note:flags.1?TextWithEntities = Updates; + +contacts.acceptContact#f831a20f id:InputUser = Updates; + +contacts.getLocated#d348bc44 flags:# background:flags.1?true geo_point:InputGeoPoint self_expires:flags.0?int = Updates; + +contacts.blockFromReplies#29a8962c flags:# delete_message:flags.0?true delete_history:flags.1?true report_spam:flags.2?true msg_id:int = Updates; + +contacts.resolvePhone#8af94344 phone:string = contacts.ResolvedPeer; + +contacts.exportContactToken#f8654027 = ExportedContactToken; + +contacts.importContactToken#13005788 token:string = User; + +contacts.editCloseFriends#ba6705f0 id:Vector = Bool; + +contacts.setBlocked#94c65c76 flags:# my_stories_from:flags.0?true id:Vector limit:int = Bool; + +contacts.getBirthdays#daeda864 = contacts.ContactBirthdays; + +contacts.getSponsoredPeers#b6c8c393 q:string = contacts.SponsoredPeers; + +contacts.updateContactNote#139f63fb id:InputUser note:TextWithEntities = Bool; + +messages.getMessages#63c66506 id:Vector = messages.Messages; + +messages.getDialogs#a0f4cb4f flags:# exclude_pinned:flags.0?true folder_id:flags.1?int offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.Dialogs; + +messages.getHistory#4423e6c5 peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; + +messages.search#29ee847a flags:# peer:InputPeer q:string from_id:flags.0?InputPeer saved_peer_id:flags.2?InputPeer saved_reaction:flags.3?Vector top_msg_id:flags.1?int filter:MessagesFilter min_date:int max_date:int offset_id:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; + +messages.readHistory#e306d3a peer:InputPeer max_id:int = messages.AffectedMessages; + +messages.deleteHistory#b08f922a flags:# just_clear:flags.0?true revoke:flags.1?true peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; + +messages.deleteMessages#e58e95d2 flags:# revoke:flags.0?true id:Vector = messages.AffectedMessages; + +messages.receivedMessages#5a954c0 max_id:int = Vector; + +messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action:SendMessageAction = Bool; + +messages.sendMessage#fef48f62 flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost rich_message:flags.23?InputRichMessage = Updates; + +messages.sendMedia#330e77f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long suggested_post:flags.22?SuggestedPost = Updates; + +messages.forwardMessages#13704a7c flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true drop_author:flags.11?true drop_media_captions:flags.12?true noforwards:flags.14?true allow_paid_floodskip:flags.19?true from_peer:InputPeer id:Vector random_id:Vector to_peer:InputPeer top_msg_id:flags.9?int reply_to:flags.22?InputReplyTo schedule_date:flags.10?int schedule_repeat_period:flags.24?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long video_timestamp:flags.20?int allow_paid_stars:flags.21?long suggested_post:flags.23?SuggestedPost = Updates; + +messages.reportSpam#cf1592db peer:InputPeer = Bool; + +messages.getPeerSettings#efd9a6a2 peer:InputPeer = messages.PeerSettings; + +messages.report#fc78af9b peer:InputPeer id:Vector option:bytes message:string = ReportResult; + +messages.getChats#49e9528f id:Vector = messages.Chats; + +messages.getFullChat#aeb00b34 chat_id:long = messages.ChatFull; + +messages.editChatTitle#73783ffd chat_id:long title:string = Updates; + +messages.editChatPhoto#35ddd674 chat_id:long photo:InputChatPhoto = Updates; + +messages.addChatUser#cbc6d107 chat_id:long user_id:InputUser fwd_limit:int = messages.InvitedUsers; + +messages.deleteChatUser#a2185cab flags:# revoke_history:flags.0?true chat_id:long user_id:InputUser = Updates; + +messages.createChat#92ceddd4 flags:# users:Vector title:string ttl_period:flags.0?int = messages.InvitedUsers; + +messages.getDhConfig#26cf8950 version:int random_length:int = messages.DhConfig; + +messages.requestEncryption#f64daf43 user_id:InputUser random_id:int g_a:bytes = EncryptedChat; + +messages.acceptEncryption#3dbc0415 peer:InputEncryptedChat g_b:bytes key_fingerprint:long = EncryptedChat; + +messages.discardEncryption#f393aea0 flags:# delete_history:flags.0?true chat_id:int = Bool; + +messages.setEncryptedTyping#791451ed peer:InputEncryptedChat typing:Bool = Bool; + +messages.readEncryptedHistory#7f4b690a peer:InputEncryptedChat max_date:int = Bool; + +messages.sendEncrypted#44fa7a15 flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; + +messages.sendEncryptedFile#5559481d flags:# silent:flags.0?true peer:InputEncryptedChat random_id:long data:bytes file:InputEncryptedFile = messages.SentEncryptedMessage; + +messages.sendEncryptedService#32d439a4 peer:InputEncryptedChat random_id:long data:bytes = messages.SentEncryptedMessage; + +messages.receivedQueue#55a5bb66 max_qts:int = Vector; + +messages.reportEncryptedSpam#4b0c8c0f peer:InputEncryptedChat = Bool; + +messages.readMessageContents#36a73f77 id:Vector = messages.AffectedMessages; + +messages.getStickers#d5a5d3a1 emoticon:string hash:long = messages.Stickers; + +messages.getAllStickers#b8a0a1a8 hash:long = messages.AllStickers; + +messages.getWebPagePreview#570d6f6f flags:# message:string entities:flags.3?Vector = messages.WebPagePreview; + +messages.exportChatInvite#a455de90 flags:# legacy_revoke_permanent:flags.2?true request_needed:flags.3?true peer:InputPeer expire_date:flags.0?int usage_limit:flags.1?int title:flags.4?string subscription_pricing:flags.5?StarsSubscriptionPricing = ExportedChatInvite; + +messages.checkChatInvite#3eadb1bb hash:string = ChatInvite; + +messages.importChatInvite#de91436e hash:string = messages.ChatInviteJoinResult; + +messages.getStickerSet#c8a0ec74 stickerset:InputStickerSet hash:int = messages.StickerSet; + +messages.installStickerSet#c78fe460 stickerset:InputStickerSet archived:Bool = messages.StickerSetInstallResult; + +messages.uninstallStickerSet#f96e55de stickerset:InputStickerSet = Bool; + +messages.startBot#e6df7378 bot:InputUser peer:InputPeer random_id:long start_param:string = Updates; + +messages.getMessagesViews#5784d3e1 peer:InputPeer id:Vector increment:Bool = messages.MessageViews; + +messages.editChatAdmin#a85bd1c2 chat_id:long user_id:InputUser is_admin:Bool = Bool; + +messages.migrateChat#a2875319 chat_id:long = Updates; + +messages.searchGlobal#4bc6589a flags:# broadcasts_only:flags.1?true groups_only:flags.2?true users_only:flags.3?true folder_id:flags.0?int q:string filter:MessagesFilter min_date:int max_date:int offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; + +messages.reorderStickerSets#78337739 flags:# masks:flags.0?true emojis:flags.1?true order:Vector = Bool; + +messages.getDocumentByHash#b1f2061f sha256:bytes size:long mime_type:string = Document; + +messages.getSavedGifs#5cf09635 hash:long = messages.SavedGifs; + +messages.saveGif#327a30cb id:InputDocument unsave:Bool = Bool; + +messages.getInlineBotResults#514e999d flags:# bot:InputUser peer:InputPeer geo_point:flags.0?InputGeoPoint query:string offset:string = messages.BotResults; + +messages.setInlineBotResults#bb12a419 flags:# gallery:flags.0?true private:flags.1?true query_id:long results:Vector cache_time:int next_offset:flags.2?string switch_pm:flags.3?InlineBotSwitchPM switch_webview:flags.4?InlineBotWebView = Bool; + +messages.sendInlineBotResult#c0cf7646 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true hide_via:flags.11?true peer:InputPeer reply_to:flags.0?InputReplyTo random_id:long query_id:long id:string schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut allow_paid_stars:flags.21?long = Updates; + +messages.getMessageEditData#fda68d36 peer:InputPeer id:int = messages.MessageEditData; + +messages.editMessage#b106e66c flags:# no_webpage:flags.1?true invert_media:flags.16?true peer:InputPeer id:int message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector schedule_date:flags.15?int schedule_repeat_period:flags.18?int quick_reply_shortcut_id:flags.17?int rich_message:flags.23?InputRichMessage = Updates; + +messages.editInlineBotMessage#a423bb51 flags:# no_webpage:flags.1?true invert_media:flags.16?true id:InputBotInlineMessageID message:flags.11?string media:flags.14?InputMedia reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector rich_message:flags.23?InputRichMessage = Bool; + +messages.getBotCallbackAnswer#9342ca07 flags:# game:flags.1?true peer:InputPeer msg_id:int data:flags.0?bytes password:flags.2?InputCheckPasswordSRP = messages.BotCallbackAnswer; + +messages.setBotCallbackAnswer#d58f130a flags:# alert:flags.1?true query_id:long message:flags.0?string url:flags.2?string cache_time:int = Bool; + +messages.getPeerDialogs#e470bcfd peers:Vector = messages.PeerDialogs; + +messages.saveDraft#ad0fa15c flags:# no_webpage:flags.1?true invert_media:flags.6?true reply_to:flags.4?InputReplyTo peer:InputPeer message:string entities:flags.3?Vector media:flags.5?InputMedia effect:flags.7?long suggested_post:flags.8?SuggestedPost rich_message:flags.9?InputRichMessage = Bool; + +messages.getAllDrafts#6a3f8d65 = Updates; + +messages.getFeaturedStickers#64780b14 hash:long = messages.FeaturedStickers; + +messages.readFeaturedStickers#5b118126 id:Vector = Bool; + +messages.getRecentStickers#9da9403b flags:# attached:flags.0?true hash:long = messages.RecentStickers; + +messages.saveRecentSticker#392718f8 flags:# attached:flags.0?true id:InputDocument unsave:Bool = Bool; + +messages.clearRecentStickers#8999602d flags:# attached:flags.0?true = Bool; + +messages.getArchivedStickers#57f17692 flags:# masks:flags.0?true emojis:flags.1?true offset_id:long limit:int = messages.ArchivedStickers; + +messages.getMaskStickers#640f82b8 hash:long = messages.AllStickers; + +messages.getAttachedStickers#cc5b67cc media:InputStickeredMedia = Vector; + +messages.setGameScore#8ef8ecc0 flags:# edit_message:flags.0?true force:flags.1?true peer:InputPeer id:int user_id:InputUser score:int = Updates; + +messages.setInlineGameScore#15ad9f64 flags:# edit_message:flags.0?true force:flags.1?true id:InputBotInlineMessageID user_id:InputUser score:int = Bool; + +messages.getGameHighScores#e822649d peer:InputPeer id:int user_id:InputUser = messages.HighScores; + +messages.getInlineGameHighScores#f635e1b id:InputBotInlineMessageID user_id:InputUser = messages.HighScores; + +messages.getCommonChats#e40ca104 user_id:InputUser max_id:long limit:int = messages.Chats; + +messages.getWebPage#8d9692a3 url:string hash:int = messages.WebPage; + +messages.toggleDialogPin#a731e257 flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; + +messages.reorderPinnedDialogs#3b1adf37 flags:# force:flags.0?true folder_id:int order:Vector = Bool; + +messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; + +messages.setBotShippingResults#e5f672fa flags:# query_id:long error:flags.0?string shipping_options:flags.1?Vector = Bool; + +messages.setBotPrecheckoutResults#9c2dd95 flags:# success:flags.1?true query_id:long error:flags.0?string = Bool; + +messages.uploadMedia#14967978 flags:# business_connection_id:flags.0?string peer:InputPeer media:InputMedia = MessageMedia; + +messages.sendScreenshotNotification#a1405817 peer:InputPeer reply_to:InputReplyTo random_id:long = Updates; + +messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; + +messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; + +messages.getUnreadMentions#f107e790 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; + +messages.readMentions#36e5bf4d flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; + +messages.getRecentLocations#702a40e0 peer:InputPeer limit:int hash:long = messages.Messages; + +messages.sendMultiMedia#1bf89d74 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true update_stickersets_order:flags.15?true invert_media:flags.16?true allow_paid_floodskip:flags.19?true peer:InputPeer reply_to:flags.0?InputReplyTo multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer quick_reply_shortcut:flags.17?InputQuickReplyShortcut effect:flags.18?long allow_paid_stars:flags.21?long = Updates; + +messages.uploadEncryptedFile#5057c497 peer:InputEncryptedChat file:InputEncryptedFile = EncryptedFile; + +messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; + +messages.getSplitRanges#1cff7e08 = Vector; + +messages.markDialogUnread#8c5006f8 flags:# unread:flags.0?true parent_peer:flags.1?InputPeer peer:InputDialogPeer = Bool; + +messages.getDialogUnreadMarks#21202222 flags:# parent_peer:flags.0?InputPeer = Vector; + +messages.clearAllDrafts#7e58ee9c = Bool; + +messages.updatePinnedMessage#d2aaf7ec flags:# silent:flags.0?true unpin:flags.1?true pm_oneside:flags.2?true peer:InputPeer id:int = Updates; + +messages.sendVote#10ea6184 peer:InputPeer msg_id:int options:Vector = Updates; + +messages.getPollResults#eda3e33b peer:InputPeer msg_id:int poll_hash:long = Updates; + +messages.getOnlines#6e2be050 peer:InputPeer = ChatOnlines; + +messages.editChatAbout#def60797 peer:InputPeer about:string = Bool; + +messages.editChatDefaultBannedRights#a5866b41 peer:InputPeer banned_rights:ChatBannedRights = Updates; + +messages.getEmojiKeywords#35a0e062 lang_code:string = EmojiKeywordsDifference; + +messages.getEmojiKeywordsDifference#1508b6af lang_code:string from_version:int = EmojiKeywordsDifference; + +messages.getEmojiKeywordsLanguages#4e9963b2 lang_codes:Vector = Vector; + +messages.getEmojiURL#d5b10c26 lang_code:string = EmojiURL; + +messages.getSearchCounters#1bbcf300 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer top_msg_id:flags.0?int filters:Vector = Vector; + +messages.requestUrlAuth#894cc99c flags:# peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string in_app_origin:flags.3?string = UrlAuthResult; + +messages.acceptUrlAuth#67a3f0de flags:# write_allowed:flags.0?true share_phone_number:flags.3?true peer:flags.1?InputPeer msg_id:flags.1?int button_id:flags.1?int url:flags.2?string match_code:flags.4?string = UrlAuthResult; + +messages.hidePeerSettingsBar#4facb138 peer:InputPeer = Bool; + +messages.getScheduledHistory#f516760b peer:InputPeer hash:long = messages.Messages; + +messages.getScheduledMessages#bdbb0464 peer:InputPeer id:Vector = messages.Messages; + +messages.sendScheduledMessages#bd38850a peer:InputPeer id:Vector = Updates; + +messages.deleteScheduledMessages#59ae2b16 peer:InputPeer id:Vector = Updates; + +messages.getPollVotes#b86e380e flags:# peer:InputPeer id:int option:flags.0?bytes offset:flags.1?string limit:int = messages.VotesList; + +messages.toggleStickerSets#b5052fea flags:# uninstall:flags.0?true archive:flags.1?true unarchive:flags.2?true stickersets:Vector = Bool; + +messages.getDialogFilters#efd48c89 = messages.DialogFilters; + +messages.getSuggestedDialogFilters#a29cd42c = Vector; + +messages.updateDialogFilter#1ad4a04a flags:# id:int filter:flags.0?DialogFilter = Bool; + +messages.updateDialogFiltersOrder#c563c1e4 order:Vector = Bool; + +messages.getOldFeaturedStickers#7ed094a1 offset:int limit:int hash:long = messages.FeaturedStickers; + +messages.getReplies#22ddd30c peer:InputPeer msg_id:int offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; + +messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.DiscussionMessage; + +messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; + +messages.unpinAllMessages#62dd747 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; + +messages.deleteChat#5bd0ee50 chat_id:long = Bool; + +messages.deletePhoneCallHistory#f9cbe409 flags:# revoke:flags.0?true = messages.AffectedFoundMessages; + +messages.checkHistoryImport#43fe19f3 import_head:string = messages.HistoryImportParsed; + +messages.initHistoryImport#34090c3b peer:InputPeer file:InputFile media_count:int = messages.HistoryImport; + +messages.uploadImportedMedia#2a862092 peer:InputPeer import_id:long file_name:string media:InputMedia = MessageMedia; + +messages.startHistoryImport#b43df344 peer:InputPeer import_id:long = Bool; + +messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; + +messages.getExportedChatInvite#73746f5c peer:InputPeer link:string = messages.ExportedChatInvite; + +messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; + +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; + +messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; + +messages.getAdminsWithInvites#3920e6ef peer:InputPeer = messages.ChatAdminsWithInvites; + +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true subscription_expired:flags.3?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; + +messages.setHistoryTTL#b80e5fe4 peer:InputPeer period:int = Updates; + +messages.checkHistoryImportPeer#5dc60f03 peer:InputPeer = messages.CheckedHistoryImportPeer; + +messages.setChatTheme#81202c9 peer:InputPeer theme:InputChatTheme = Updates; + +messages.getMessageReadParticipants#31c1c44f peer:InputPeer msg_id:int = Vector; + +messages.getSearchResultsCalendar#6aa3f6bd flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int offset_date:int = messages.SearchResultsCalendar; + +messages.getSearchResultsPositions#9c7f2f10 flags:# peer:InputPeer saved_peer_id:flags.2?InputPeer filter:MessagesFilter offset_id:int limit:int = messages.SearchResultsPositions; + +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; + +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; + +messages.toggleNoForwards#b2081a35 flags:# peer:InputPeer enabled:Bool request_msg_id:flags.0?int = Updates; + +messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; + +messages.sendReaction#d30d78d4 flags:# big:flags.1?true add_to_recent:flags.2?true peer:InputPeer msg_id:int reaction:flags.0?Vector = Updates; + +messages.getMessagesReactions#8bba90e6 peer:InputPeer id:Vector = Updates; + +messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = messages.MessageReactionsList; + +messages.setChatAvailableReactions#864b2581 flags:# peer:InputPeer available_reactions:ChatReactions reactions_limit:flags.0?int paid_enabled:flags.1?Bool = Updates; + +messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; + +messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool; + +messages.translateText#a5eec345 flags:# peer:flags.0?InputPeer id:flags.0?Vector text:flags.1?Vector to_lang:string tone:flags.2?string = messages.TranslatedText; + +messages.getUnreadReactions#bd7f90ac flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; + +messages.readReactions#9ec44f93 flags:# peer:InputPeer top_msg_id:flags.0?int saved_peer_id:flags.1?InputPeer = messages.AffectedHistory; + +messages.searchSentMedia#107e31a0 q:string filter:MessagesFilter limit:int = messages.Messages; + +messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; + +messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; + +messages.toggleBotInAttachMenu#69f59d69 flags:# write_allowed:flags.0?true bot:InputUser enabled:Bool = Bool; + +messages.requestWebView#269dc2c1 flags:# from_bot_menu:flags.4?true silent:flags.5?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser url:flags.1?string start_param:flags.3?string theme_params:flags.2?DataJSON platform:string reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = WebViewResult; + +messages.prolongWebView#b0d81a83 flags:# silent:flags.5?true peer:InputPeer bot:InputUser query_id:long reply_to:flags.0?InputReplyTo send_as:flags.13?InputPeer = Bool; + +messages.requestSimpleWebView#413a3e73 flags:# from_switch_webview:flags.1?true from_side_menu:flags.2?true compact:flags.7?true fullscreen:flags.8?true bot:InputUser url:flags.3?string start_param:flags.4?string theme_params:flags.0?DataJSON platform:string = WebViewResult; + +messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent; + +messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates; + +messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio; + +messages.rateTranscribedAudio#7f1d072f peer:InputPeer msg_id:int transcription_id:long good:Bool = Bool; + +messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; + +messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; + +messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; + +messages.reportReaction#3f64c076 peer:InputPeer id:int reaction_peer:InputPeer = Bool; + +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; + +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; + +messages.clearRecentReactions#9dfeefb4 = Bool; + +messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; + +messages.setDefaultHistoryTTL#9eb51445 period:int = Bool; + +messages.getDefaultHistoryTTL#658b7188 = DefaultHistoryTTL; + +messages.sendBotRequestedPeer#6c5cf2a7 flags:# peer:InputPeer msg_id:flags.0?int webapp_req_id:flags.1?string button_id:int requested_peers:Vector = Updates; + +messages.getEmojiGroups#7488ce5b hash:int = messages.EmojiGroups; + +messages.getEmojiStatusGroups#2ecd56cd hash:int = messages.EmojiGroups; + +messages.getEmojiProfilePhotoGroups#21a548f3 hash:int = messages.EmojiGroups; + +messages.searchCustomEmoji#2c11c0d7 emoticon:string hash:long = EmojiList; + +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; + +messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; + +messages.requestAppWebView#53618bce flags:# write_allowed:flags.0?true compact:flags.7?true fullscreen:flags.8?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = WebViewResult; + +messages.setChatWallPaper#8ffacae1 flags:# for_both:flags.3?true revert:flags.4?true peer:InputPeer wallpaper:flags.0?InputWallPaper settings:flags.2?WallPaperSettings id:flags.1?int = Updates; + +messages.searchEmojiStickerSets#92b4494c flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; + +messages.getSavedDialogs#1e91fc99 flags:# exclude_pinned:flags.0?true parent_peer:flags.1?InputPeer offset_date:int offset_id:int offset_peer:InputPeer limit:int hash:long = messages.SavedDialogs; + +messages.getSavedHistory#998ab009 flags:# parent_peer:flags.0?InputPeer peer:InputPeer offset_id:int offset_date:int add_offset:int limit:int max_id:int min_id:int hash:long = messages.Messages; + +messages.deleteSavedHistory#4dc5085f flags:# parent_peer:flags.0?InputPeer peer:InputPeer max_id:int min_date:flags.2?int max_date:flags.3?int = messages.AffectedHistory; + +messages.getPinnedSavedDialogs#d63d94e0 = messages.SavedDialogs; + +messages.toggleSavedDialogPin#ac81bbde flags:# pinned:flags.0?true peer:InputDialogPeer = Bool; + +messages.reorderPinnedSavedDialogs#8b716587 flags:# force:flags.0?true order:Vector = Bool; + +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; + +messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; + +messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; + +messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; + +messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; + +messages.reorderQuickReplies#60331907 order:Vector = Bool; + +messages.checkQuickReplyShortcut#f1d0fbd3 shortcut:string = Bool; + +messages.editQuickReplyShortcut#5c003cef shortcut_id:int shortcut:string = Bool; + +messages.deleteQuickReplyShortcut#3cc04740 shortcut_id:int = Bool; + +messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; + +messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; + +messages.deleteQuickReplyMessages#e105e910 shortcut_id:int id:Vector = Updates; + +messages.toggleDialogFilterTags#fd2dda49 enabled:Bool = Bool; + +messages.getMyStickers#d0b5e1fc offset_id:long limit:int = messages.MyStickers; + +messages.getEmojiStickerGroups#1dd840f5 hash:int = messages.EmojiGroups; + +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; + +messages.editFactCheck#589ee75 peer:InputPeer msg_id:int text:TextWithEntities = Updates; + +messages.deleteFactCheck#d1da940c peer:InputPeer msg_id:int = Updates; + +messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; + +messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true fullscreen:flags.8?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult; + +messages.sendPaidReaction#58bbcb50 flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?PaidReactionPrivacy = Updates; + +messages.togglePaidReactionPrivacy#435885b5 peer:InputPeer msg_id:int private:PaidReactionPrivacy = Bool; + +messages.getPaidReactionPrivacy#472455aa = Updates; + +messages.viewSponsoredMessage#269e3643 random_id:bytes = Bool; + +messages.clickSponsoredMessage#8235057e flags:# media:flags.0?true fullscreen:flags.1?true random_id:bytes = Bool; + +messages.reportSponsoredMessage#12cbf0c4 random_id:bytes option:bytes = channels.SponsoredMessageReportResult; + +messages.getSponsoredMessages#3d6ce850 flags:# peer:InputPeer msg_id:flags.0?int = messages.SponsoredMessages; + +messages.savePreparedInlineMessage#f21f7f2f flags:# result:InputBotInlineResult user_id:InputUser peer_types:flags.0?Vector = messages.BotPreparedInlineMessage; + +messages.getPreparedInlineMessage#857ebdb8 bot:InputUser id:string = messages.PreparedInlineMessage; + +messages.searchStickers#29b1c66a flags:# emojis:flags.0?true q:string emoticon:string lang_code:Vector offset:int limit:int hash:long = messages.FoundStickers; + +messages.reportMessagesDelivery#5a6d7395 flags:# push:flags.0?true peer:InputPeer id:Vector = Bool; + +messages.getSavedDialogsByID#6f6f9c96 flags:# parent_peer:flags.1?InputPeer ids:Vector = messages.SavedDialogs; + +messages.readSavedHistory#ba4a3b5b parent_peer:InputPeer peer:InputPeer max_id:int = Bool; + +messages.toggleTodoCompleted#d3e03124 peer:InputPeer msg_id:int completed:Vector incompleted:Vector = Updates; + +messages.appendTodoList#21a61057 peer:InputPeer msg_id:int list:Vector = Updates; + +messages.toggleSuggestedPostApproval#8107455c flags:# reject:flags.1?true peer:InputPeer msg_id:int schedule_date:flags.0?int reject_comment:flags.2?string = Updates; + +messages.getForumTopics#3ba47bff flags:# peer:InputPeer q:flags.0?string offset_date:int offset_id:int offset_topic:int limit:int = messages.ForumTopics; + +messages.getForumTopicsByID#af0a4a08 peer:InputPeer topics:Vector = messages.ForumTopics; + +messages.editForumTopic#cecc1134 flags:# peer:InputPeer topic_id:int title:flags.0?string icon_emoji_id:flags.1?long closed:flags.2?Bool hidden:flags.3?Bool = Updates; + +messages.updatePinnedForumTopic#175df251 peer:InputPeer topic_id:int pinned:Bool = Updates; + +messages.reorderPinnedForumTopics#e7841f0 flags:# force:flags.0?true peer:InputPeer order:Vector = Updates; + +messages.createForumTopic#2f98c3d5 flags:# title_missing:flags.4?true peer:InputPeer title:string icon_color:flags.0?int icon_emoji_id:flags.3?long random_id:long send_as:flags.2?InputPeer = Updates; + +messages.deleteTopicHistory#d2816f10 peer:InputPeer top_msg_id:int = messages.AffectedHistory; + +messages.getEmojiGameInfo#fb7e8ca7 = messages.EmojiGameInfo; + +messages.summarizeText#abbbd346 flags:# peer:InputPeer id:int to_lang:flags.0?string tone:flags.2?string = TextWithEntities; + +messages.editChatCreator#f743b857 peer:InputPeer user_id:InputUser password:InputCheckPasswordSRP = Updates; + +messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User; + +messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates; + +messages.declineUrlAuth#35436bbc url:string = Bool; + +messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool; + +messages.composeMessageWithAI#daecc589 flags:# proofread:flags.0?true emojify:flags.3?true text:TextWithEntities translate_to_lang:flags.1?string tone:flags.2?InputAiComposeTone = messages.ComposedMessageWithAI; + +messages.reportReadMetrics#4067c5e6 peer:InputPeer metrics:Vector = Bool; + +messages.reportMusicListen#ddbcd819 id:InputDocument listened_duration:int = Bool; + +messages.addPollAnswer#19bc4b6d peer:InputPeer msg_id:int answer:PollAnswer = Updates; + +messages.deletePollAnswer#ac8505a5 peer:InputPeer msg_id:int option:bytes = Updates; + +messages.getUnreadPollVotes#43286cf2 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; + +messages.readPollVotes#1720b4d8 flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; + +messages.setBotGuestChatResult#b8f106e3 query_id:long result:InputBotInlineResult = InputBotInlineMessageID; + +messages.deleteParticipantReactions#a0b80cf8 peer:InputPeer participant:InputPeer = Bool; + +messages.deleteParticipantReaction#e3b7f82c peer:InputPeer msg_id:int participant:InputPeer = Updates; + +messages.getPersonalChannelHistory#55fb0996 user_id:InputUser limit:int max_id:int min_id:int hash:long = messages.Messages; + +messages.getRichMessage#501569cf peer:InputPeer id:int = messages.Messages; + +updates.getState#edd4882a = updates.State; + +updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; + +updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; + +photos.updateProfilePhoto#9e82039 flags:# fallback:flags.0?true bot:flags.1?InputUser id:InputPhoto = photos.Photo; + +photos.uploadProfilePhoto#388a3b5 flags:# fallback:flags.3?true bot:flags.5?InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.4?VideoSize = photos.Photo; + +photos.deletePhotos#87cf7f2f id:Vector = Vector; + +photos.getUserPhotos#91cd32a8 user_id:InputUser offset:int max_id:long limit:int = photos.Photos; + +photos.uploadContactProfilePhoto#e14c4a71 flags:# suggest:flags.3?true save:flags.4?true user_id:InputUser file:flags.0?InputFile video:flags.1?InputFile video_start_ts:flags.2?double video_emoji_markup:flags.5?VideoSize = photos.Photo; + +upload.saveFilePart#b304a621 file_id:long file_part:int bytes:bytes = Bool; + +upload.getFile#be5335be flags:# precise:flags.0?true cdn_supported:flags.1?true location:InputFileLocation offset:long limit:int = upload.File; + +upload.saveBigFilePart#de7b673d file_id:long file_part:int file_total_parts:int bytes:bytes = Bool; + +upload.getWebFile#24e6818d location:InputWebFileLocation offset:int limit:int = upload.WebFile; + +upload.getCdnFile#395f69da file_token:bytes offset:long limit:int = upload.CdnFile; + +upload.reuploadCdnFile#9b2754a8 file_token:bytes request_token:bytes = Vector; + +upload.getCdnFileHashes#91dc3f31 file_token:bytes offset:long = Vector; + +upload.getFileHashes#9156982a location:InputFileLocation offset:long = Vector; + +help.getConfig#c4f9186b = Config; + +help.getNearestDc#1fb33026 = NearestDc; + +help.getAppUpdate#522d5a7d source:string = help.AppUpdate; + +help.getInviteText#4d392343 = help.InviteText; + +help.getSupport#9cdf08cd = help.Support; + +help.setBotUpdatesStatus#ec22cfcd pending_updates_count:int message:string = Bool; + +help.getCdnConfig#52029342 = CdnConfig; + +help.getRecentMeUrls#3dc0f114 referer:string = help.RecentMeUrls; + +help.getTermsOfServiceUpdate#2ca51fd1 = help.TermsOfServiceUpdate; + +help.acceptTermsOfService#ee72f79a id:DataJSON = Bool; + +help.getDeepLinkInfo#3fedc75f path:string = help.DeepLinkInfo; + +help.getAppConfig#61e3f854 hash:int = help.AppConfig; + +help.saveAppLog#6f02f748 events:Vector = Bool; + +help.getPassportConfig#c661ad08 hash:int = help.PassportConfig; + +help.getSupportName#d360e72c = help.SupportName; + +help.getUserInfo#38a08d3 user_id:InputUser = help.UserInfo; + +help.editUserInfo#66b91b70 user_id:InputUser message:string entities:Vector = help.UserInfo; + +help.getPromoData#c0977421 = help.PromoData; + +help.hidePromoData#1e251c95 peer:InputPeer = Bool; + +help.dismissSuggestion#f50dbaa1 peer:InputPeer suggestion:string = Bool; + +help.getCountriesList#735787a8 lang_code:string hash:int = help.CountriesList; + +help.getPremiumPromo#b81b93d4 = help.PremiumPromo; + +help.getPeerColors#da80f42f hash:int = help.PeerColors; + +help.getPeerProfileColors#abcfa9fd hash:int = help.PeerColors; + +help.getTimezonesList#49b30240 hash:int = help.TimezonesList; + +channels.readHistory#cc104937 channel:InputChannel max_id:int = Bool; + +channels.deleteMessages#84c1fd4e channel:InputChannel id:Vector = messages.AffectedMessages; + +channels.reportSpam#f44a8315 channel:InputChannel participant:InputPeer id:Vector = Bool; + +channels.getMessages#ad8c9a23 channel:InputChannel id:Vector = messages.Messages; + +channels.getParticipants#77ced9d0 channel:InputChannel filter:ChannelParticipantsFilter offset:int limit:int hash:long = channels.ChannelParticipants; + +channels.getParticipant#a0ab6cc6 channel:InputChannel participant:InputPeer = channels.ChannelParticipant; + +channels.getChannels#a7f6bbb id:Vector = messages.Chats; + +channels.getFullChannel#8736a09 channel:InputChannel = messages.ChatFull; + +channels.createChannel#91006707 flags:# broadcast:flags.0?true megagroup:flags.1?true for_import:flags.3?true forum:flags.5?true title:string about:string geo_point:flags.2?InputGeoPoint address:flags.2?string ttl_period:flags.4?int = Updates; + +channels.editAdmin#9a98ad68 flags:# channel:InputChannel user_id:InputUser admin_rights:ChatAdminRights rank:flags.0?string = Updates; + +channels.editTitle#566decd0 channel:InputChannel title:string = Updates; + +channels.editPhoto#f12e57c9 channel:InputChannel photo:InputChatPhoto = Updates; + +channels.checkUsername#10e6bd2c channel:InputChannel username:string = Bool; + +channels.updateUsername#3514b3de channel:InputChannel username:string = Bool; + +channels.joinChannel#7f6a1e22 channel:InputChannel = messages.ChatInviteJoinResult; + +channels.leaveChannel#f836aa95 channel:InputChannel = Updates; + +channels.inviteToChannel#c9e33d54 channel:InputChannel users:Vector = messages.InvitedUsers; + +channels.deleteChannel#c0111fe3 channel:InputChannel = Updates; + +channels.exportMessageLink#e63fadeb flags:# grouped:flags.0?true thread:flags.1?true channel:InputChannel id:int = ExportedMessageLink; + +channels.toggleSignatures#418d549c flags:# signatures_enabled:flags.0?true profiles_enabled:flags.1?true channel:InputChannel = Updates; + +channels.getAdminedPublicChannels#f8b036af flags:# by_location:flags.0?true check_limit:flags.1?true for_personal:flags.2?true = messages.Chats; + +channels.editBanned#96e6cd81 channel:InputChannel participant:InputPeer banned_rights:ChatBannedRights = Updates; + +channels.getAdminLog#33ddf480 flags:# channel:InputChannel q:string events_filter:flags.0?ChannelAdminLogEventsFilter admins:flags.1?Vector max_id:long min_id:long limit:int = channels.AdminLogResults; + +channels.setStickers#ea8ca4f9 channel:InputChannel stickerset:InputStickerSet = Bool; + +channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = Bool; + +channels.deleteHistory#9baa9647 flags:# for_everyone:flags.0?true channel:InputChannel max_id:int = Updates; + +channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates; + +channels.getLeftChannels#8341ecc0 offset:int = messages.Chats; + +channels.getGroupsForDiscussion#f5dad378 = messages.Chats; + +channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool; + +channels.editLocation#58e63f6d channel:InputChannel geo_point:InputGeoPoint address:string = Bool; + +channels.toggleSlowMode#edd49ef0 channel:InputChannel seconds:int = Updates; + +channels.getInactiveChannels#11e831ee = messages.InactiveChats; + +channels.convertToGigagroup#b290c69 channel:InputChannel = Updates; + +channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers; + +channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory; + +channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates; + +channels.toggleJoinRequest#ecc2618 flags:# apply_to_invites:flags.1?true channel:InputChannel enabled:Bool guard_bot:flags.0?InputUser = Updates; + +channels.reorderUsernames#b45ced1d channel:InputChannel order:Vector = Bool; + +channels.toggleUsername#50f24105 channel:InputChannel username:string active:Bool = Bool; + +channels.deactivateAllUsernames#a245dd3 channel:InputChannel = Bool; + +channels.toggleForum#3ff75734 channel:InputChannel enabled:Bool tabs:Bool = Updates; + +channels.toggleAntiSpam#68f3e4eb channel:InputChannel enabled:Bool = Updates; + +channels.reportAntiSpamFalsePositive#a850a693 channel:InputChannel msg_id:int = Bool; + +channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = Updates; + +channels.updateColor#d8aa3671 flags:# for_profile:flags.1?true channel:InputChannel color:flags.2?int background_emoji_id:flags.0?long = Updates; + +channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; + +channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; + +channels.updateEmojiStatus#f0d3e6a8 channel:InputChannel emoji_status:EmojiStatus = Updates; + +channels.setBoostsToUnblockRestrictions#ad399cee channel:InputChannel boosts:int = Updates; + +channels.setEmojiStickers#3cd930b7 channel:InputChannel stickerset:InputStickerSet = Bool; + +channels.restrictSponsoredMessages#9ae91519 channel:InputChannel restricted:Bool = Updates; + +channels.searchPosts#f2c4f24d flags:# hashtag:flags.0?string query:flags.1?string offset_rate:int offset_peer:InputPeer offset_id:int limit:int allow_paid_stars:flags.2?long = messages.Messages; + +channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:flags.0?true channel:InputChannel send_paid_messages_stars:long = Updates; + +channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates; + +channels.getMessageAuthor#ece2a0e6 channel:InputChannel id:int = User; + +channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood; + +channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool; + +bots.sendCustomRequest#aa2769ed custom_method:string params:DataJSON = DataJSON; + +bots.answerWebhookJSONQuery#e6213f4d query_id:long data:DataJSON = Bool; + +bots.setBotCommands#517165a scope:BotCommandScope lang_code:string commands:Vector = Bool; + +bots.resetBotCommands#3d8de0f9 scope:BotCommandScope lang_code:string = Bool; + +bots.getBotCommands#e34c0dd6 scope:BotCommandScope lang_code:string = Vector; + +bots.setBotMenuButton#4504d54f user_id:InputUser button:BotMenuButton = Bool; + +bots.getBotMenuButton#9c60eb28 user_id:InputUser = BotMenuButton; + +bots.setBotBroadcastDefaultAdminRights#788464e1 admin_rights:ChatAdminRights = Bool; + +bots.setBotGroupDefaultAdminRights#925ec9ea admin_rights:ChatAdminRights = Bool; + +bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; + +bots.getBotInfo#dcd914fd flags:# bot:flags.0?InputUser lang_code:string = bots.BotInfo; + +bots.reorderUsernames#9709b1c2 bot:InputUser order:Vector = Bool; + +bots.toggleUsername#53ca973 bot:InputUser username:string active:Bool = Bool; + +bots.canSendMessage#1359f4e6 bot:InputUser = Bool; + +bots.allowSendMessage#f132e3ef bot:InputUser = Updates; + +bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON; + +bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots; + +bots.addPreviewMedia#17aeb75a bot:InputUser lang_code:string media:InputMedia = BotPreviewMedia; + +bots.editPreviewMedia#8525606f bot:InputUser lang_code:string media:InputMedia new_media:InputMedia = BotPreviewMedia; + +bots.deletePreviewMedia#2d0135b3 bot:InputUser lang_code:string media:Vector = Bool; + +bots.reorderPreviewMedias#b627f3aa bot:InputUser lang_code:string order:Vector = Bool; + +bots.getPreviewInfo#423ab3ad bot:InputUser lang_code:string = bots.PreviewInfo; + +bots.getPreviewMedias#a2a5594d bot:InputUser = Vector; + +bots.updateUserEmojiStatus#ed9f30c5 user_id:InputUser emoji_status:EmojiStatus = Bool; + +bots.toggleUserEmojiStatusPermission#6de6392 bot:InputUser enabled:Bool = Bool; + +bots.checkDownloadFileParams#50077589 bot:InputUser file_name:string url:string = Bool; + +bots.getAdminedBots#b0711d83 = Vector; + +bots.updateStarRefProgram#778b5ab3 flags:# bot:InputUser commission_permille:int duration_months:flags.0?int = StarRefProgram; + +bots.setCustomVerification#8b89dfbd flags:# enabled:flags.1?true bot:flags.0?InputUser peer:InputPeer custom_description:flags.2?string = Bool; + +bots.getBotRecommendations#a1b70815 bot:InputUser = users.Users; + +bots.checkUsername#87f2219b username:string = Bool; + +bots.createBot#e5b17f2b flags:# via_deeplink:flags.0?true name:string username:string manager_id:InputUser = User; + +bots.exportBotToken#bd0d99eb bot:InputUser revoke:Bool = bots.ExportedBotToken; + +bots.requestWebViewButton#31a2a35e user_id:InputUser button:KeyboardButton = bots.RequestedButton; + +bots.getRequestedWebViewButton#bf25b7f3 bot:InputUser webapp_req_id:string = KeyboardButton; + +bots.getAccessSettings#213853a3 bot:InputUser = bots.AccessSettings; + +bots.editAccessSettings#31813cd8 flags:# restricted:flags.0?true bot:InputUser add_users:flags.1?Vector = Bool; + +bots.setJoinChatResults#e71a4810 query_id:long result:JoinChatBotResult = Bool; + +payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm; + +payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt; + +payments.validateRequestedInfo#b6c8f12b flags:# save:flags.0?true invoice:InputInvoice info:PaymentRequestedInfo = payments.ValidatedRequestedInfo; + +payments.sendPaymentForm#2d03522f flags:# form_id:long invoice:InputInvoice requested_info_id:flags.0?string shipping_option_id:flags.1?string credentials:InputPaymentCredentials tip_amount:flags.2?long = payments.PaymentResult; + +payments.getSavedInfo#227d824b = payments.SavedInfo; + +payments.clearSavedInfo#d83d70c1 flags:# credentials:flags.0?true info:flags.1?true = Bool; + +payments.getBankCardData#2e79d779 number:string = payments.BankCardData; + +payments.exportInvoice#f91b065 invoice_media:InputMedia = payments.ExportedInvoice; + +payments.assignAppStoreTransaction#80ed747d receipt:bytes purpose:InputStorePaymentPurpose = Updates; + +payments.assignPlayMarketTransaction#dffd50d3 receipt:DataJSON purpose:InputStorePaymentPurpose = Updates; + +payments.getPremiumGiftCodeOptions#2757ba54 flags:# boost_peer:flags.0?InputPeer = Vector; + +payments.checkGiftCode#8e51b4c1 slug:string = payments.CheckedGiftCode; + +payments.applyGiftCode#f6e26854 slug:string = Updates; + +payments.getGiveawayInfo#f4239425 peer:InputPeer msg_id:int = payments.GiveawayInfo; + +payments.launchPrepaidGiveaway#5ff58f20 peer:InputPeer giveaway_id:long purpose:InputStorePaymentPurpose = Updates; + +payments.getStarsTopupOptions#c00ec7d3 = Vector; + +payments.getStarsStatus#4ea9b3bf flags:# ton:flags.0?true peer:InputPeer = payments.StarsStatus; + +payments.getStarsTransactions#69da4557 flags:# inbound:flags.0?true outbound:flags.1?true ascending:flags.2?true ton:flags.4?true subscription_id:flags.3?string peer:InputPeer offset:string limit:int = payments.StarsStatus; + +payments.sendStarsForm#7998c914 form_id:long invoice:InputInvoice = payments.PaymentResult; + +payments.refundStarsCharge#25ae8f4a user_id:InputUser charge_id:string = Updates; + +payments.getStarsRevenueStats#d91ffad6 flags:# dark:flags.0?true ton:flags.1?true peer:InputPeer = payments.StarsRevenueStats; + +payments.getStarsRevenueWithdrawalUrl#2433dc92 flags:# ton:flags.0?true peer:InputPeer amount:flags.1?long password:InputCheckPasswordSRP = payments.StarsRevenueWithdrawalUrl; + +payments.getStarsRevenueAdsAccountUrl#d1d7efc5 peer:InputPeer = payments.StarsRevenueAdsAccountUrl; + +payments.getStarsTransactionsByID#2dca16b8 flags:# ton:flags.0?true peer:InputPeer id:Vector = payments.StarsStatus; + +payments.getStarsGiftOptions#d3c96bc8 flags:# user_id:flags.0?InputUser = Vector; + +payments.getStarsSubscriptions#32512c5 flags:# missing_balance:flags.0?true peer:InputPeer offset:string = payments.StarsStatus; + +payments.changeStarsSubscription#c7770878 flags:# peer:InputPeer subscription_id:string canceled:flags.0?Bool = Bool; + +payments.fulfillStarsSubscription#cc5bebb3 peer:InputPeer subscription_id:string = Bool; + +payments.getStarsGiveawayOptions#bd1efd3e = Vector; + +payments.getStarGifts#c4563590 hash:int = payments.StarGifts; + +payments.saveStarGift#2a2a697c flags:# unsave:flags.0?true stargift:InputSavedStarGift = Bool; + +payments.convertStarGift#74bf076b stargift:InputSavedStarGift = Bool; + +payments.botCancelStarsSubscription#6dfa0622 flags:# restore:flags.0?true user_id:InputUser charge_id:string = Bool; + +payments.getConnectedStarRefBots#5869a553 flags:# peer:InputPeer offset_date:flags.2?int offset_link:flags.2?string limit:int = payments.ConnectedStarRefBots; + +payments.getConnectedStarRefBot#b7d998f0 peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; + +payments.getSuggestedStarRefBots#d6b48f7 flags:# order_by_revenue:flags.0?true order_by_date:flags.1?true peer:InputPeer offset:string limit:int = payments.SuggestedStarRefBots; + +payments.connectStarRefBot#7ed5348a peer:InputPeer bot:InputUser = payments.ConnectedStarRefBots; + +payments.editConnectedStarRefBot#e4fca4a3 flags:# revoked:flags.0?true peer:InputPeer link:string = payments.ConnectedStarRefBots; + +payments.getStarGiftUpgradePreview#9c9abcb1 gift_id:long = payments.StarGiftUpgradePreview; + +payments.upgradeStarGift#aed6e4f5 flags:# keep_original_details:flags.0?true stargift:InputSavedStarGift = Updates; + +payments.transferStarGift#7f18176a stargift:InputSavedStarGift to_id:InputPeer = Updates; + +payments.getUniqueStarGift#a1974d72 slug:string = payments.UniqueStarGift; + +payments.getSavedStarGifts#a319e569 flags:# exclude_unsaved:flags.0?true exclude_saved:flags.1?true exclude_unlimited:flags.2?true exclude_unique:flags.4?true sort_by_value:flags.5?true exclude_upgradable:flags.7?true exclude_unupgradable:flags.8?true peer_color_available:flags.9?true exclude_hosted:flags.10?true peer:InputPeer collection_id:flags.6?int offset:string limit:int = payments.SavedStarGifts; + +payments.getSavedStarGift#b455a106 stargift:Vector = payments.SavedStarGifts; + +payments.getStarGiftWithdrawalUrl#d06e93a8 stargift:InputSavedStarGift password:InputCheckPasswordSRP = payments.StarGiftWithdrawalUrl; + +payments.toggleChatStarGiftNotifications#60eaefa1 flags:# enabled:flags.0?true peer:InputPeer = Bool; + +payments.toggleStarGiftsPinnedToTop#1513e7b0 peer:InputPeer stargift:Vector = Bool; + +payments.canPurchaseStore#4fdc5ea7 purpose:InputStorePaymentPurpose = Bool; + +payments.getResaleStarGifts#7a5fa236 flags:# sort_by_price:flags.1?true sort_by_num:flags.2?true for_craft:flags.4?true stars_only:flags.5?true attributes_hash:flags.0?long gift_id:long attributes:flags.3?Vector offset:string limit:int = payments.ResaleStarGifts; + +payments.updateStarGiftPrice#edbe6ccb stargift:InputSavedStarGift resell_amount:StarsAmount = Updates; + +payments.createStarGiftCollection#1f4a0e87 peer:InputPeer title:string stargift:Vector = StarGiftCollection; + +payments.updateStarGiftCollection#4fddbee7 flags:# peer:InputPeer collection_id:int title:flags.0?string delete_stargift:flags.1?Vector add_stargift:flags.2?Vector order:flags.3?Vector = StarGiftCollection; + +payments.reorderStarGiftCollections#c32af4cc peer:InputPeer order:Vector = Bool; + +payments.deleteStarGiftCollection#ad5648e8 peer:InputPeer collection_id:int = Bool; + +payments.getStarGiftCollections#981b91dd peer:InputPeer hash:long = payments.StarGiftCollections; + +payments.getUniqueStarGiftValueInfo#4365af6b slug:string = payments.UniqueStarGiftValueInfo; + +payments.checkCanSendGift#c0c4edc9 gift_id:long = payments.CheckCanSendGiftResult; + +payments.getStarGiftAuctionState#5c9ff4d6 auction:InputStarGiftAuction version:int = payments.StarGiftAuctionState; + +payments.getStarGiftAuctionAcquiredGifts#6ba2cbec gift_id:long = payments.StarGiftAuctionAcquiredGifts; + +payments.getStarGiftActiveAuctions#a5d0514d hash:long = payments.StarGiftActiveAuctions; + +payments.resolveStarGiftOffer#e9ce781c flags:# decline:flags.0?true offer_msg_id:int = Updates; + +payments.sendStarGiftOffer#8fb86b41 flags:# peer:InputPeer slug:string price:StarsAmount duration:int random_id:long allow_paid_stars:flags.0?long = Updates; + +payments.getStarGiftUpgradeAttributes#6d038b58 gift_id:long = payments.StarGiftUpgradeAttributes; + +payments.getCraftStarGifts#fd05dd00 gift_id:long offset:string limit:int = payments.SavedStarGifts; + +payments.craftStarGift#b0f9684f stargift:Vector = Updates; + +stickers.createStickerSet#9021ab67 flags:# masks:flags.0?true emojis:flags.5?true text_color:flags.6?true user_id:InputUser title:string short_name:string thumb:flags.2?InputDocument stickers:Vector software:flags.3?string = messages.StickerSet; + +stickers.removeStickerFromSet#f7760f51 sticker:InputDocument = messages.StickerSet; + +stickers.changeStickerPosition#ffb6d4ca sticker:InputDocument position:int = messages.StickerSet; + +stickers.addStickerToSet#8653febe stickerset:InputStickerSet sticker:InputStickerSetItem = messages.StickerSet; + +stickers.setStickerSetThumb#a76a5392 flags:# stickerset:InputStickerSet thumb:flags.0?InputDocument thumb_document_id:flags.1?long = messages.StickerSet; + +stickers.checkShortName#284b3639 short_name:string = Bool; + +stickers.suggestShortName#4dafc503 title:string = stickers.SuggestedShortName; + +stickers.changeSticker#f5537ebc flags:# sticker:InputDocument emoji:flags.0?string mask_coords:flags.1?MaskCoords keywords:flags.2?string = messages.StickerSet; + +stickers.renameStickerSet#124b1c00 stickerset:InputStickerSet title:string = messages.StickerSet; + +stickers.deleteStickerSet#87704394 stickerset:InputStickerSet = Bool; + +stickers.replaceSticker#4696459a sticker:InputDocument new_sticker:InputStickerSetItem = messages.StickerSet; + +phone.getCallConfig#55451fa9 = DataJSON; + +phone.requestCall#42ff96ed flags:# video:flags.0?true user_id:InputUser random_id:int g_a_hash:bytes protocol:PhoneCallProtocol = phone.PhoneCall; + +phone.acceptCall#3bd2b4a0 peer:InputPhoneCall g_b:bytes protocol:PhoneCallProtocol = phone.PhoneCall; + +phone.confirmCall#2efe1722 peer:InputPhoneCall g_a:bytes key_fingerprint:long protocol:PhoneCallProtocol = phone.PhoneCall; + +phone.receivedCall#17d54f61 peer:InputPhoneCall = Bool; + +phone.discardCall#b2cbc1c0 flags:# video:flags.0?true peer:InputPhoneCall duration:int reason:PhoneCallDiscardReason connection_id:long = Updates; + +phone.setCallRating#59ead627 flags:# user_initiative:flags.0?true peer:InputPhoneCall rating:int comment:string = Updates; + +phone.saveCallDebug#277add7e peer:InputPhoneCall debug:DataJSON = Bool; + +phone.sendSignalingData#ff7a9383 peer:InputPhoneCall data:bytes = Bool; + +phone.createGroupCall#48cdc6d8 flags:# rtmp_stream:flags.2?true peer:InputPeer random_id:int title:flags.0?string schedule_date:flags.1?int = Updates; + +phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates; + +phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates; + +phone.inviteToGroupCall#7b393160 call:InputGroupCall users:Vector = Updates; + +phone.discardGroupCall#7a777135 call:InputGroupCall = Updates; + +phone.toggleGroupCallSettings#974392f2 flags:# reset_invite_hash:flags.1?true call:InputGroupCall join_muted:flags.0?Bool messages_enabled:flags.2?Bool send_paid_messages_stars:flags.3?long = Updates; + +phone.getGroupCall#41845db call:InputGroupCall limit:int = phone.GroupCall; + +phone.getGroupParticipants#c558d8ab call:InputGroupCall ids:Vector sources:Vector offset:string limit:int = phone.GroupParticipants; + +phone.checkGroupCall#b59cf977 call:InputGroupCall sources:Vector = Vector; + +phone.toggleGroupCallRecord#f128c708 flags:# start:flags.0?true video:flags.2?true call:InputGroupCall title:flags.1?string video_portrait:flags.2?Bool = Updates; + +phone.editGroupCallParticipant#a5273abf flags:# call:InputGroupCall participant:InputPeer muted:flags.0?Bool volume:flags.1?int raise_hand:flags.2?Bool video_stopped:flags.3?Bool video_paused:flags.4?Bool presentation_paused:flags.5?Bool = Updates; + +phone.editGroupCallTitle#1ca6ac0a call:InputGroupCall title:string = Updates; + +phone.getGroupCallJoinAs#ef7c213a peer:InputPeer = phone.JoinAsPeers; + +phone.exportGroupCallInvite#e6aa647f flags:# can_self_unmute:flags.0?true call:InputGroupCall = phone.ExportedGroupCallInvite; + +phone.toggleGroupCallStartSubscription#219c34e6 call:InputGroupCall subscribed:Bool = Updates; + +phone.startScheduledGroupCall#5680e342 call:InputGroupCall = Updates; + +phone.saveDefaultGroupCallJoinAs#575e1f8c peer:InputPeer join_as:InputPeer = Bool; + +phone.joinGroupCallPresentation#cbea6bc4 call:InputGroupCall params:DataJSON = Updates; + +phone.leaveGroupCallPresentation#1c50d144 call:InputGroupCall = Updates; + +phone.getGroupCallStreamChannels#1ab21940 call:InputGroupCall = phone.GroupCallStreamChannels; + +phone.getGroupCallStreamRtmpUrl#5af4c73a flags:# live_story:flags.0?true peer:InputPeer revoke:Bool = phone.GroupCallStreamRtmpUrl; + +phone.saveCallLog#41248786 peer:InputPhoneCall file:InputFile = Bool; + +phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates; + +phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector block:bytes = Updates; + +phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates; + +phone.inviteConferenceCallParticipant#bcf22685 flags:# video:flags.0?true call:InputGroupCall user_id:InputUser = Updates; + +phone.declineConferenceCallInvite#3c479971 msg_id:int = Updates; + +phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates; + +phone.sendGroupCallMessage#b1d11410 flags:# call:InputGroupCall random_id:long message:TextWithEntities allow_paid_stars:flags.0?long send_as:flags.1?InputPeer = Updates; + +phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool; + +phone.deleteGroupCallMessages#f64f54f7 flags:# report_spam:flags.0?true call:InputGroupCall messages:Vector = Updates; + +phone.deleteGroupCallParticipantMessages#1dbfeca0 flags:# report_spam:flags.0?true call:InputGroupCall participant:InputPeer = Updates; + +phone.getGroupCallStars#6f636302 call:InputGroupCall = phone.GroupCallStars; + +phone.saveDefaultSendAs#4167add1 call:InputGroupCall send_as:InputPeer = Bool; + +langpack.getLangPack#f2f2330a lang_pack:string lang_code:string = LangPackDifference; + +langpack.getStrings#efea3803 lang_pack:string lang_code:string keys:Vector = Vector; + +langpack.getDifference#cd984aa5 lang_pack:string lang_code:string from_version:int = LangPackDifference; + +langpack.getLanguages#42c6978f lang_pack:string = Vector; + +langpack.getLanguage#6a596502 lang_pack:string lang_code:string = LangPackLanguage; + +folders.editPeerFolders#6847d0ab folder_peers:Vector = Updates; + +stats.getBroadcastStats#ab42441a flags:# dark:flags.0?true channel:InputChannel = stats.BroadcastStats; + +stats.loadAsyncGraph#621d5fa0 flags:# token:string x:flags.0?long = StatsGraph; + +stats.getMegagroupStats#dcdf8607 flags:# dark:flags.0?true channel:InputChannel = stats.MegagroupStats; + +stats.getMessagePublicForwards#5f150144 channel:InputChannel msg_id:int offset:string limit:int = stats.PublicForwards; + +stats.getMessageStats#b6e0a3f5 flags:# dark:flags.0?true channel:InputChannel msg_id:int = stats.MessageStats; + +stats.getStoryStats#374fef40 flags:# dark:flags.0?true peer:InputPeer id:int = stats.StoryStats; + +stats.getStoryPublicForwards#a6437ef6 peer:InputPeer id:int offset:string limit:int = stats.PublicForwards; + +stats.getPollStats#c27dfa68 flags:# dark:flags.0?true peer:InputPeer msg_id:int = stats.PollStats; + +chatlists.exportChatlistInvite#8472478e chatlist:InputChatlist title:string peers:Vector = chatlists.ExportedChatlistInvite; + +chatlists.deleteExportedInvite#719c5c5e chatlist:InputChatlist slug:string = Bool; + +chatlists.editExportedInvite#653db63d flags:# chatlist:InputChatlist slug:string title:flags.1?string peers:flags.2?Vector = ExportedChatlistInvite; + +chatlists.getExportedInvites#ce03da83 chatlist:InputChatlist = chatlists.ExportedInvites; + +chatlists.checkChatlistInvite#41c10fff slug:string = chatlists.ChatlistInvite; + +chatlists.joinChatlistInvite#a6b1e39a slug:string peers:Vector = Updates; + +chatlists.getChatlistUpdates#89419521 chatlist:InputChatlist = chatlists.ChatlistUpdates; + +chatlists.joinChatlistUpdates#e089f8f5 chatlist:InputChatlist peers:Vector = Updates; + +chatlists.hideChatlistUpdates#66e486fb chatlist:InputChatlist = Bool; + +chatlists.getLeaveChatlistSuggestions#fdbcd714 chatlist:InputChatlist = Vector; + +chatlists.leaveChatlist#74fae13a chatlist:InputChatlist peers:Vector = Updates; + +stories.canSendStory#30eb63f0 peer:InputPeer = stories.CanSendStoryCount; + +stories.sendStory#8f9e6898 flags:# pinned:flags.2?true noforwards:flags.4?true fwd_modified:flags.7?true peer:InputPeer media:InputMedia media_areas:flags.5?Vector caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long period:flags.3?int fwd_from_id:flags.6?InputPeer fwd_from_story:flags.6?int albums:flags.8?Vector music:flags.9?InputDocument = Updates; + +stories.editStory#2c63a72b flags:# peer:InputPeer id:int media:flags.0?InputMedia media_areas:flags.3?Vector caption:flags.1?string entities:flags.1?Vector privacy_rules:flags.2?Vector music:flags.4?InputDocument = Updates; + +stories.deleteStories#ae59db5f peer:InputPeer id:Vector = Vector; + +stories.togglePinned#9a75a1ef peer:InputPeer id:Vector pinned:Bool = Vector; + +stories.getAllStories#eeb0d625 flags:# next:flags.1?true hidden:flags.2?true state:flags.0?string = stories.AllStories; + +stories.getPinnedStories#5821a5dc peer:InputPeer offset_id:int limit:int = stories.Stories; + +stories.getStoriesArchive#b4352016 peer:InputPeer offset_id:int limit:int = stories.Stories; + +stories.getStoriesByID#5774ca74 peer:InputPeer id:Vector = stories.Stories; + +stories.toggleAllStoriesHidden#7c2557c4 hidden:Bool = Bool; + +stories.readStories#a556dac8 peer:InputPeer max_id:int = Vector; + +stories.incrementStoryViews#b2028afb peer:InputPeer id:Vector = Bool; + +stories.getStoryViewsList#7ed23c57 flags:# just_contacts:flags.0?true reactions_first:flags.2?true forwards_first:flags.3?true peer:InputPeer q:flags.1?string id:int offset:string limit:int = stories.StoryViewsList; + +stories.getStoriesViews#28e16cc8 peer:InputPeer id:Vector = stories.StoryViews; + +stories.exportStoryLink#7b8def20 peer:InputPeer id:int = ExportedStoryLink; + +stories.report#19d8eb45 peer:InputPeer id:Vector option:bytes message:string = ReportResult; + +stories.activateStealthMode#57bbd166 flags:# past:flags.0?true future:flags.1?true = Updates; + +stories.sendReaction#7fd736b2 flags:# add_to_recent:flags.0?true peer:InputPeer story_id:int reaction:Reaction = Updates; + +stories.getPeerStories#2c4ada50 peer:InputPeer = stories.PeerStories; + +stories.getAllReadPeerStories#9b5ae7f9 = Updates; + +stories.getPeerMaxIDs#78499170 id:Vector = Vector; + +stories.getChatsToSend#a56a8b60 = messages.Chats; + +stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool; + +stories.getStoryReactionsList#b9b2881f flags:# forwards_first:flags.2?true peer:InputPeer id:int reaction:flags.0?Reaction offset:flags.1?string limit:int = stories.StoryReactionsList; + +stories.togglePinnedToTop#b297e9b peer:InputPeer id:Vector = Bool; + +stories.searchPosts#d1810907 flags:# hashtag:flags.0?string area:flags.1?MediaArea peer:flags.2?InputPeer offset:string limit:int = stories.FoundStories; + +stories.createAlbum#a36396e5 peer:InputPeer title:string stories:Vector = StoryAlbum; + +stories.updateAlbum#5e5259b6 flags:# peer:InputPeer album_id:int title:flags.0?string delete_stories:flags.1?Vector add_stories:flags.2?Vector order:flags.3?Vector = StoryAlbum; + +stories.reorderAlbums#8535fbd9 peer:InputPeer order:Vector = Bool; + +stories.deleteAlbum#8d3456d0 peer:InputPeer album_id:int = Bool; + +stories.getAlbums#25b3eac7 peer:InputPeer hash:long = stories.Albums; + +stories.getAlbumStories#ac806d61 peer:InputPeer album_id:int offset:int limit:int = stories.Stories; + +stories.startLive#d069ccde flags:# pinned:flags.2?true noforwards:flags.4?true rtmp_stream:flags.5?true peer:InputPeer caption:flags.0?string entities:flags.1?Vector privacy_rules:Vector random_id:long messages_enabled:flags.6?Bool send_paid_messages_stars:flags.7?long = Updates; + +premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList; + +premium.getMyBoosts#be77b4a = premium.MyBoosts; + +premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector peer:InputPeer = premium.MyBoosts; + +premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus; + +premium.getUserBoosts#39854d1f peer:InputPeer user_id:InputUser = premium.BoostsList; + +smsjobs.isEligibleToJoin#edc39d0 = smsjobs.EligibilityToJoin; + +smsjobs.join#a74ece2d = Bool; + +smsjobs.leave#9898ad73 = Bool; + +smsjobs.updateSettings#93fa0bf flags:# allow_international:flags.0?true = Bool; + +smsjobs.getStatus#10a698e8 = smsjobs.Status; + +smsjobs.getSmsJob#778d902f job_id:string = SmsJob; + +smsjobs.finishJob#4f1ebf24 flags:# job_id:string error:flags.0?string = Bool; + +fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo; + +aicompose.createTone#4aa83913 flags:# display_author:flags.0?true emoji_id:long title:string prompt:string = AiComposeTone; + +aicompose.updateTone#903bcf59 flags:# tone:InputAiComposeTone display_author:flags.0?Bool emoji_id:flags.1?long title:flags.2?string prompt:flags.3?string = AiComposeTone; + +aicompose.saveTone#1782cbb1 tone:InputAiComposeTone unsave:Bool = Bool; + +aicompose.deleteTone#dd39316a tone:InputAiComposeTone = Bool; + +aicompose.getTone#b2e8ba03 tone:InputAiComposeTone = aicompose.Tones; + +aicompose.getTones#abd59201 hash:long = aicompose.Tones; + +aicompose.getToneExample#d1b4ab14 tone:InputAiComposeTone num:int = AiComposeToneExample; + +// LAYER 227 diff --git a/internal/compat/layerwire/schema/client-drift.tl b/internal/compat/layerwire/schema/client-drift.tl new file mode 100644 index 00000000..54d328c1 --- /dev/null +++ b/internal/compat/layerwire/schema/client-drift.tl @@ -0,0 +1,28 @@ +// Client constructor drift — method constructors emitted by a specific client's +// hand-maintained TL (DrKLO Android, TLRPC.java) that are absent from the +// canonical (227) schema. They are old-layer API versions the client never +// updated; from the server's view they request the SAME api method, only with an +// older wire shape. +// +// The generic inbound upgrader (inbound.go) matches each by qualified name to +// the canonical method and rebuilds a canonical request: copy shared fields, +// write 0 for inserted flags integers, synthesize defaults for new required +// fields, and convert changed field types via the converter registry. So adding +// support for a newly-observed drifted constructor is one line here + a `gen` +// run — never a runtime-discovered hand patch. +// +// Only bodies that differ structurally belong here. Body-identical drift (only +// the constructor id differs) is a plain id swap in clientMethodAliases +// (client_aliases.go). + +---functions--- + +messages.uploadMedia#519bc2b1 peer:InputPeer media:InputMedia = MessageMedia; +auth.signUp#80eee427 phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization; +channels.getMessages#93d7b347 channel:InputChannel id:Vector = messages.Messages; +bots.exportBotToken#0063b089 bot_id:long revoke:Bool = bots.ExportedBotToken; +account.registerDevice#637ea878 token_type:int token:string = Bool; +contacts.search#11f812d8 q:string limit:int = contacts.Found; +langpack.getLangPack#9ab5c58e lang_code:string = LangPackDifference; +langpack.getStrings#2e1ee318 lang_code:string keys:Vector = Vector; +langpack.getLanguages#800fd57d = Vector; diff --git a/internal/compat/layerwire/tables.go b/internal/compat/layerwire/tables.go new file mode 100644 index 00000000..a0b59079 --- /dev/null +++ b/internal/compat/layerwire/tables.go @@ -0,0 +1,350 @@ +package layerwire + +import ( + "fmt" + + "github.com/gotd/td/bin" +) + +// ruleRaw is the generated, compact form of a single changed-constructor +// downgrade (see tables_gen.go). Mechanical rules carry the target CRC plus the +// canonical field names retained at the target layer; structural rules name a +// hand-written transform registered in fallback.go. +type ruleRaw struct { + target uint32 + keep []string + structural string +} + +// layerRaw is the generated downgrade table for one target layer. +type layerRaw struct { + rules map[uint32]ruleRaw + newTypes []uint32 // canonical CRCs that do not exist at this layer +} + +// downgradeRule is the runtime form of ruleRaw with keep as a set. +type downgradeRule struct { + target uint32 + keep map[string]bool + structural string +} + +// layerTables is the runtime downgrade model for one target layer. +type layerTables struct { + rules map[uint32]*downgradeRule + newTypes map[uint32]bool + dirty map[uint32]bool // ctor CRC needs a deep walk + dirtyT map[string]bool // abstract/bare type name reaches a dirty ctor +} + +// tables holds the runtime model per supported layer, built lazily. +var tables = func() map[int]*layerTables { + out := make(map[int]*layerTables, len(generatedTables)) + for layer, raw := range generatedTables { + out[layer] = buildLayerTables(raw) + } + return out +}() + +func buildLayerTables(raw layerRaw) *layerTables { + lt := &layerTables{ + rules: make(map[uint32]*downgradeRule, len(raw.rules)), + newTypes: make(map[uint32]bool, len(raw.newTypes)), + } + for crc, r := range raw.rules { + dr := &downgradeRule{target: r.target, structural: r.structural} + if r.structural == "" { + dr.keep = make(map[string]bool, len(r.keep)) + for _, n := range r.keep { + dr.keep[n] = true + } + } + lt.rules[crc] = dr + } + for _, crc := range raw.newTypes { + lt.newTypes[crc] = true + } + lt.computeDirty() + return lt +} + +// computeDirty marks every constructor (and abstract/bare type) that can +// transitively contain a changed, structural, or layer-absent constructor, so +// the transcoder can byte-copy the ~86% of the type graph that is unaffected. +func (lt *layerTables) computeDirty() { + lt.dirty = make(map[uint32]bool) + lt.dirtyT = make(map[string]bool) + // Seed: rules + new types are themselves dirty. + for crc := range lt.rules { + lt.dirty[crc] = true + } + for crc := range lt.newTypes { + lt.dirty[crc] = true + } + markType := func(name string) { + if name != "" && !lt.dirtyT[name] { + lt.dirtyT[name] = true + } + } + // Seed dirty types from seeded dirty ctors. + for crc := range lt.dirty { + if cl := canonical.byCRC[crc]; cl != nil { + markType(cl.result) + markType(cl.name) // bare reference + } + } + // Fixpoint: a ctor is dirty if any field's type is dirty; a type is dirty + // if any of its constructors is dirty. + for changed := true; changed; { + changed = false + for crc, cl := range canonical.byCRC { + if lt.dirty[crc] { + continue + } + if lt.ctorHasDirtyField(cl) { + lt.dirty[crc] = true + if !lt.dirtyT[cl.result] { + lt.dirtyT[cl.result] = true + } + if !lt.dirtyT[cl.name] { + lt.dirtyT[cl.name] = true + } + changed = true + } + } + } +} + +func (lt *layerTables) ctorHasDirtyField(cl *ctorLayout) bool { + for i := range cl.fields { + if lt.fieldDirty(&cl.fields[i]) { + return true + } + } + return false +} + +func (lt *layerTables) fieldDirty(f *fieldLayout) bool { + switch f.kind { + case kindObject, kindBareObject: + return lt.dirtyT[f.typeName] + case kindVector, kindVectorBare: + return lt.fieldDirty(f.elem) + default: + return false + } +} + +// structuralFunc transforms a changed constructor whose downgrade is not a pure +// field drop. The leading CRC has already been consumed from in; the transform +// reads the canonical body from in and writes the target-layer object (whose +// constructor id is target) to out. +type structuralFunc func(cl *ctorLayout, target uint32, in, out *bin.Buffer, layer int) error + +// fallbackFunc replaces a layer-absent (227-only) constructor with an +// equivalent the target layer understands. The leading CRC is NOT yet consumed. +type fallbackFunc func(cl *ctorLayout, in, out *bin.Buffer, layer int) error + +// structuralTransforms and the newType fallback registries are populated in +// fallback.go. newTypeFallbacks is keyed by canonical CRC (specific override); +// newTypeFallbacksByType is keyed by the canonical abstract result type and +// covers every 227-only constructor of that class (e.g. any new MessageAction). +var ( + structuralTransforms = map[string]structuralFunc{} + newTypeFallbacks = map[uint32]fallbackFunc{} + newTypeFallbacksByType = map[string]fallbackFunc{} +) + +// Transcode downgrades a single canonical (Layer 227) boxed object to the wire +// shape of layer. layer >= CanonicalLayer (or unsupported) returns in verbatim. +// On any transform gap it returns an error so the edge can fall back to sending +// the canonical bytes rather than corrupting the stream. +func Transcode(canonicalBytes []byte, layer int) ([]byte, error) { + if layer >= CanonicalLayer { + return canonicalBytes, nil + } + lt := tables[layer] + if lt == nil { + return canonicalBytes, nil // unsupported floor: best-effort passthrough + } + // Top-level constructors that are not in the canonical tg schema are MTProto + // control/error objects (mt.*, e.g. rpc_error) — layer-invariant, so pass + // them through. A nested unknown id is still a hard error (real gap). + if id, err := (&bin.Buffer{Buf: canonicalBytes}).PeekID(); err != nil || canonical.byCRC[id] == nil { + return canonicalBytes, nil + } + in := &bin.Buffer{Buf: canonicalBytes} + out := &bin.Buffer{} + if err := lt.transcodeObject(in, out, layer); err != nil { + return nil, err + } + if in.Len() != 0 { + return nil, fmt.Errorf("layerwire: %d trailing bytes after transcode to layer %d", in.Len(), layer) + } + return out.Buf, nil +} + +// UpgradeMethodCRC maps an old client's method constructor id to the canonical +// (227) id when the request body is byte-compatible — i.e. swapping the leading +// 4-byte id yields a valid 227 request. It unifies two sources: generated +// official layer drift (inboundMethodUpgrades) and hand-maintained client +// constructor drift (clientMethodAliases). Returns ok=false for unchanged +// methods and for changes that need a real decode (those stay as rpc handlers). +func UpgradeMethodCRC(oldID uint32) (uint32, bool) { + if newID, ok := inboundMethodUpgrades[oldID]; ok { + return newID, true + } + newID, ok := clientMethodAliases[oldID] + return newID, ok +} + +func (lt *layerTables) transcodeObject(in, out *bin.Buffer, layer int) error { + id, err := in.PeekID() + if err != nil { + return err + } + cl, ok := canonical.byCRC[id] + if !ok { + return fmt.Errorf("layerwire: unknown constructor %#08x", id) + } + if rule := lt.rules[id]; rule != nil { + if err := in.ConsumeID(id); err != nil { + return err + } + if rule.structural != "" { + fn := structuralTransforms[rule.structural] + if fn == nil { + return fmt.Errorf("layerwire: no structural transform %q for %s@%d", rule.structural, cl.name, layer) + } + return fn(cl, rule.target, in, out, layer) + } + out.PutID(rule.target) + return lt.transcodeBody(in, out, cl, rule.keep, layer) + } + if lt.newTypes[id] { + fn := newTypeFallbacks[id] + if fn == nil { + fn = newTypeFallbacksByType[cl.result] + } + if fn == nil { + return fmt.Errorf("layerwire: %s (%#08x) absent at layer %d and no fallback", cl.name, id, layer) + } + return fn(cl, in, out, layer) + } + if !lt.dirty[id] { + // Unaffected subtree: byte-for-byte copy. + pre := in.Buf + if err := canonical.skipObject(in); err != nil { + return err + } + out.Put(pre[:len(pre)-len(in.Buf)]) + return nil + } + // Unchanged at this level but a descendant is dirty: keep CRC, recurse. + if err := in.ConsumeID(id); err != nil { + return err + } + out.PutID(id) + return lt.transcodeBody(in, out, cl, nil, layer) +} + +// transcodeBody re-encodes a constructor body. keep==nil means retain every +// field (recursing into dirty descendants); otherwise only the named canonical +// fields are written, flag integers are remasked to the retained bits, and +// dropped fields are read-and-discarded. +func (lt *layerTables) transcodeBody(in, out *bin.Buffer, cl *ctorLayout, keep map[string]bool, layer int) error { + kept := func(name string) bool { return keep == nil || keep[name] } + var flags map[string]uint32 + for i := range cl.fields { + f := &cl.fields[i] + if f.isFlags { + v, err := in.Uint32() + if err != nil { + return fmt.Errorf("%s.%s: %w", cl.name, f.name, err) + } + if flags == nil { + flags = make(map[string]uint32, 2) + } + flags[f.name] = v + if kept(f.name) { + out.PutUint32(v & lt.keptMask(cl, f.name, kept)) + } + continue + } + present := !f.conditional() || flags[f.flagName]&(1<layer +// downgrade table. See docs/layer-compat-220-227-design.md. +var generatedTables = map[int]layerRaw{ + 220: { + rules: map[uint32]ruleRaw{ + 0x02b78156: {target: 0xc9662d05, keep: []string{"flags", "name_requested", "username_requested", "photo_requested", "text", "button_id", "peer_type", "max_quantity"}}, // inputKeyboardButtonRequestPeer + 0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot + 0x0360d5d2: {target: 0xa0933f5b, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipantAdmin + 0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull + 0x08cbec07: {target: 0x3f7ee58b, keep: []string{"value", "emoticon"}}, // messageMediaDice + 0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed + 0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem + 0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader + 0x1bd54456: {target: 0xcb397619, keep: []string{"flags", "user_id", "date", "subscription_until_date"}}, // channelParticipant + 0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList + 0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText + 0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed + 0x38e79fde: {target: 0xc02d4007, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipant + 0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage + 0x3cd623ec: {target: 0x92d33a0e, keep: []string{"flags", "request_write_access", "bot", "domain"}}, // urlAuthResultRequest + 0x3fa53905: {target: 0xafd93fbb, keep: []string{"text"}}, // keyboardButtonBuy + 0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode + 0x417efd8f: {target: 0xb16a6c29, keep: []string{"text"}}, // keyboardButtonRequestPhone + 0x41df43fc: {target: 0xead6805e, keep: []string{"flags", "name_hidden", "unsaved", "refunded", "can_upgrade", "pinned_to_top", "upgrade_separate", "from_id", "date", "gift", "message", "msg_id", "saved_id", "convert_stars", "upgrade_stars", "can_export_at", "transfer_stars", "can_transfer_at", "can_resell_at", "collection_id", "prepaid_upgrade_hash", "drop_original_details_stars", "gift_num"}}, // savedStarGift + 0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer + 0x4e7085ea: {target: 0x13acff19, structural: "starGiftAttributePattern"}, // target field "rarity_permille" not found in canonical (reorder/insert) + 0x565251e2: {target: 0x39d99013, structural: "starGiftAttributeModel"}, // target field "rarity_permille" not found in canonical (reorder/insert) + 0x5b0f15f5: {target: 0x53d7bfd8, keep: []string{"text", "button_id", "peer_type", "max_quantity"}}, // keyboardButtonRequestPeer + 0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage + 0x623a8fa0: {target: 0x8f8c0e4e, structural: "urlAuthResultAccepted"}, // field "url": conditional-ness changed + 0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks + 0x68013e72: {target: 0xd02e7fd4, keep: []string{"flags", "request_write_access", "text", "fwd_text", "url", "bot"}}, // inputKeyboardButtonUrlAuth + 0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings + 0x7600b9d3: {target: 0xb92f76cf, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period"}}, // message + 0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote + 0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll + 0x7a11d782: {target: 0xbbc7515d, keep: []string{"flags", "quiz", "text"}}, // keyboardButtonRequestPoll + 0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester + 0x7d170cff: {target: 0xa2fa4880, keep: []string{"text"}}, // keyboardButton + 0x7d5e07c7: {target: 0xe988037b, keep: []string{"text", "user_id"}}, // inputKeyboardButtonUserProfile + 0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto + 0x85f0a9cd: {target: 0x569d64c9, keep: []string{"flags", "require_premium", "resale_ton_only", "theme_available", "id", "gift_id", "title", "slug", "num", "owner_id", "owner_name", "owner_address", "attributes", "availability_issued", "availability_total", "gift_address", "resell_amount", "released_by", "value_amount", "value_currency", "value_usd_amount", "theme_peer", "peer_color", "host_id", "offer_min_stars"}}, // starGiftUnique + 0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector->Vector + 0x89c590f9: {target: 0x50f41ccf, keep: []string{"text"}}, // keyboardButtonGame + 0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed + 0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll + 0x991399fc: {target: 0x93b9fbb5, keep: []string{"flags", "same_peer", "text", "query", "peer_types"}}, // keyboardButtonSwitchInline + 0x9f2504e4: {target: 0xd93d859c, structural: "starGiftAttributeBackdrop"}, // target field "rarity_permille" not found in canonical (reorder/insert) + 0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull + 0xa9478a1a: {target: 0x4f607bef, keep: []string{"flags", "via_request", "user_id", "inviter_id", "date", "subscription_until_date"}}, // channelParticipantSelf + 0xaa40f94d: {target: 0xfc796b3f, keep: []string{"text"}}, // keyboardButtonRequestGeoLocation + 0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults + 0xbcc4af10: {target: 0x75d2698e, keep: []string{"text", "copy_text"}}, // keyboardButtonCopy + 0xc0fd5d09: {target: 0x308660c1, keep: []string{"text", "user_id"}}, // keyboardButtonUserProfile + 0xd5f0ad91: {target: 0x6df8014e, keep: []string{"flags", "left", "peer", "kicked_by", "date", "banned_rights"}}, // channelParticipantBanned + 0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll + 0xd80c25ec: {target: 0x258aff05, keep: []string{"text", "url"}}, // keyboardButtonUrl + 0xe15c4370: {target: 0xa0c0505c, keep: []string{"text", "url"}}, // keyboardButtonSimpleWebView + 0xe1f867b8: {target: 0xe46bcee4, keep: []string{"user_id"}}, // chatParticipantCreator + 0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto + 0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto + 0xe62bc960: {target: 0x35bbdb6b, keep: []string{"flags", "requires_password", "text", "data"}}, // keyboardButtonCallback + 0xe6c31522: {target: 0x95728543, keep: []string{"flags", "upgrade", "transferred", "saved", "refunded", "prepaid_upgrade", "assigned", "from_offer", "gift", "can_export_at", "transfer_stars", "from_id", "peer", "saved_id", "resale_amount", "can_transfer_at", "can_resell_at", "drop_original_details_stars"}}, // messageActionStarGiftUnique + 0xe846b1a0: {target: 0x13767230, keep: []string{"text", "url"}}, // keyboardButtonWebView + 0xf51006f9: {target: 0x10b78d29, keep: []string{"flags", "text", "fwd_text", "url", "button_id"}}, // keyboardButtonUrlAuth + 0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired + 0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog + 0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic + }, + newTypes: []uint32{ + 0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec, + 0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96, + 0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x36437737, + 0x399674dc, 0x3c29a3e2, 0x3c60b621, 0x3e2793ba, 0x3e81e078, 0x402b4495, + 0x445663a7, 0x44e56023, 0x4880ed9a, 0x4c2a5d62, 0x4fdd3430, 0x519524ea, + 0x574b617f, 0x5806b4ec, 0x59080c20, 0x59e65335, 0x5b1ccb28, 0x67e731ad, + 0x682a41a9, 0x6c24f3dd, 0x6c9d0efe, 0x71777116, 0x7781fe18, 0x78fbf3a8, + 0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x8c0f91fb, 0x904ac7c7, 0x90d7adfa, + 0x933ca597, 0x98a3a840, 0x9b00622b, 0x9bad6414, 0x9d2eac97, 0x9da1cd6c, + 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac072444, 0xac6a83aa, 0xae152a69, + 0xb07ed085, 0xb22083a6, 0xb43df56c, 0xb532772b, 0xb956812d, 0xbaf39d8b, + 0xbaff072f, 0xbd8367b9, 0xbdac7e70, 0xbf7d6572, 0xc1f46103, 0xc31c8f4e, + 0xc39a2ade, 0xc556a45d, 0xc6c1e5a7, 0xcd24cf44, 0xcdd4093d, 0xcef7e7a8, + 0xcff63ea9, 0xd6e3b813, 0xda2ad647, 0xdacb836a, 0xdbbe6c6a, 0xdbce6389, + 0xdd1fbf93, 0xe188503b, 0xe2b23b51, 0xe4c449fc, 0xf08d516b, 0xf13bbcd7, + 0xf1d628ec, 0xf3a9244a, 0xfa2bc90a, 0xfb9c547a, + }, + }, + 221: { + rules: map[uint32]ruleRaw{ + 0x02b78156: {target: 0xc9662d05, keep: []string{"flags", "name_requested", "username_requested", "photo_requested", "text", "button_id", "peer_type", "max_quantity"}}, // inputKeyboardButtonRequestPeer + 0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot + 0x0360d5d2: {target: 0xa0933f5b, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipantAdmin + 0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull + 0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed + 0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem + 0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader + 0x1bd54456: {target: 0xcb397619, keep: []string{"flags", "user_id", "date", "subscription_until_date"}}, // channelParticipant + 0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList + 0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText + 0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed + 0x38e79fde: {target: 0xc02d4007, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipant + 0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage + 0x3cd623ec: {target: 0x92d33a0e, keep: []string{"flags", "request_write_access", "bot", "domain"}}, // urlAuthResultRequest + 0x3fa53905: {target: 0xafd93fbb, keep: []string{"text"}}, // keyboardButtonBuy + 0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode + 0x417efd8f: {target: 0xb16a6c29, keep: []string{"text"}}, // keyboardButtonRequestPhone + 0x41df43fc: {target: 0xead6805e, keep: []string{"flags", "name_hidden", "unsaved", "refunded", "can_upgrade", "pinned_to_top", "upgrade_separate", "from_id", "date", "gift", "message", "msg_id", "saved_id", "convert_stars", "upgrade_stars", "can_export_at", "transfer_stars", "can_transfer_at", "can_resell_at", "collection_id", "prepaid_upgrade_hash", "drop_original_details_stars", "gift_num"}}, // savedStarGift + 0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer + 0x4e7085ea: {target: 0x13acff19, structural: "starGiftAttributePattern"}, // target field "rarity_permille" not found in canonical (reorder/insert) + 0x565251e2: {target: 0x39d99013, structural: "starGiftAttributeModel"}, // target field "rarity_permille" not found in canonical (reorder/insert) + 0x5b0f15f5: {target: 0x53d7bfd8, keep: []string{"text", "button_id", "peer_type", "max_quantity"}}, // keyboardButtonRequestPeer + 0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage + 0x623a8fa0: {target: 0x8f8c0e4e, structural: "urlAuthResultAccepted"}, // field "url": conditional-ness changed + 0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks + 0x68013e72: {target: 0xd02e7fd4, keep: []string{"flags", "request_write_access", "text", "fwd_text", "url", "bot"}}, // inputKeyboardButtonUrlAuth + 0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings + 0x7600b9d3: {target: 0x9cb490e9, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message + 0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote + 0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll + 0x7a11d782: {target: 0xbbc7515d, keep: []string{"flags", "quiz", "text"}}, // keyboardButtonRequestPoll + 0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester + 0x7d170cff: {target: 0xa2fa4880, keep: []string{"text"}}, // keyboardButton + 0x7d5e07c7: {target: 0xe988037b, keep: []string{"text", "user_id"}}, // inputKeyboardButtonUserProfile + 0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto + 0x85f0a9cd: {target: 0x569d64c9, keep: []string{"flags", "require_premium", "resale_ton_only", "theme_available", "id", "gift_id", "title", "slug", "num", "owner_id", "owner_name", "owner_address", "attributes", "availability_issued", "availability_total", "gift_address", "resell_amount", "released_by", "value_amount", "value_currency", "value_usd_amount", "theme_peer", "peer_color", "host_id", "offer_min_stars"}}, // starGiftUnique + 0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector->Vector + 0x89c590f9: {target: 0x50f41ccf, keep: []string{"text"}}, // keyboardButtonGame + 0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed + 0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll + 0x991399fc: {target: 0x93b9fbb5, keep: []string{"flags", "same_peer", "text", "query", "peer_types"}}, // keyboardButtonSwitchInline + 0x9f2504e4: {target: 0xd93d859c, structural: "starGiftAttributeBackdrop"}, // target field "rarity_permille" not found in canonical (reorder/insert) + 0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull + 0xa9478a1a: {target: 0x4f607bef, keep: []string{"flags", "via_request", "user_id", "inviter_id", "date", "subscription_until_date"}}, // channelParticipantSelf + 0xaa40f94d: {target: 0xfc796b3f, keep: []string{"text"}}, // keyboardButtonRequestGeoLocation + 0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults + 0xbcc4af10: {target: 0x75d2698e, keep: []string{"text", "copy_text"}}, // keyboardButtonCopy + 0xc0fd5d09: {target: 0x308660c1, keep: []string{"text", "user_id"}}, // keyboardButtonUserProfile + 0xd5f0ad91: {target: 0x6df8014e, keep: []string{"flags", "left", "peer", "kicked_by", "date", "banned_rights"}}, // channelParticipantBanned + 0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll + 0xd80c25ec: {target: 0x258aff05, keep: []string{"text", "url"}}, // keyboardButtonUrl + 0xe15c4370: {target: 0xa0c0505c, keep: []string{"text", "url"}}, // keyboardButtonSimpleWebView + 0xe1f867b8: {target: 0xe46bcee4, keep: []string{"user_id"}}, // chatParticipantCreator + 0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto + 0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto + 0xe62bc960: {target: 0x35bbdb6b, keep: []string{"flags", "requires_password", "text", "data"}}, // keyboardButtonCallback + 0xe6c31522: {target: 0x95728543, keep: []string{"flags", "upgrade", "transferred", "saved", "refunded", "prepaid_upgrade", "assigned", "from_offer", "gift", "can_export_at", "transfer_stars", "from_id", "peer", "saved_id", "resale_amount", "can_transfer_at", "can_resell_at", "drop_original_details_stars"}}, // messageActionStarGiftUnique + 0xe846b1a0: {target: 0x13767230, keep: []string{"text", "url"}}, // keyboardButtonWebView + 0xf51006f9: {target: 0x10b78d29, keep: []string{"flags", "text", "fwd_text", "url", "button_id"}}, // keyboardButtonUrlAuth + 0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired + 0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog + 0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic + }, + newTypes: []uint32{ + 0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec, + 0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96, + 0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x36437737, + 0x399674dc, 0x3c29a3e2, 0x3c60b621, 0x3e2793ba, 0x3e81e078, 0x402b4495, + 0x445663a7, 0x4880ed9a, 0x4c2a5d62, 0x4fdd3430, 0x519524ea, 0x574b617f, + 0x5806b4ec, 0x59080c20, 0x67e731ad, 0x682a41a9, 0x6c24f3dd, 0x6c9d0efe, + 0x71777116, 0x7781fe18, 0x78fbf3a8, 0x79eb8cb3, 0x7b9e1801, 0x83281dbd, + 0x8c0f91fb, 0x904ac7c7, 0x90d7adfa, 0x933ca597, 0x98a3a840, 0x9b00622b, + 0x9bad6414, 0x9d2eac97, 0x9da1cd6c, 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, + 0xac072444, 0xac6a83aa, 0xae152a69, 0xb07ed085, 0xb22083a6, 0xb43df56c, + 0xb532772b, 0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xbd8367b9, 0xbdac7e70, + 0xbf7d6572, 0xc1f46103, 0xc31c8f4e, 0xc39a2ade, 0xc556a45d, 0xc6c1e5a7, + 0xcd24cf44, 0xcdd4093d, 0xcef7e7a8, 0xcff63ea9, 0xd6e3b813, 0xdacb836a, + 0xdbbe6c6a, 0xdbce6389, 0xdd1fbf93, 0xe188503b, 0xe2b23b51, 0xe4c449fc, + 0xf08d516b, 0xf13bbcd7, 0xf1d628ec, 0xfa2bc90a, + }, + }, + 222: { + rules: map[uint32]ruleRaw{ + 0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot + 0x0360d5d2: {target: 0xa0933f5b, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipantAdmin + 0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull + 0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed + 0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem + 0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader + 0x1bd54456: {target: 0xcb397619, keep: []string{"flags", "user_id", "date", "subscription_until_date"}}, // channelParticipant + 0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList + 0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText + 0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed + 0x38e79fde: {target: 0xc02d4007, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipant + 0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage + 0x3cd623ec: {target: 0x32fabf1a, keep: []string{"flags", "request_write_access", "request_phone_number", "bot", "domain", "browser", "platform", "ip", "region"}}, // urlAuthResultRequest + 0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode + 0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer + 0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage + 0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks + 0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings + 0x7600b9d3: {target: 0x9cb490e9, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message + 0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote + 0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll + 0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester + 0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto + 0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector->Vector + 0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed + 0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll + 0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull + 0xa9478a1a: {target: 0x4f607bef, keep: []string{"flags", "via_request", "user_id", "inviter_id", "date", "subscription_until_date"}}, // channelParticipantSelf + 0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults + 0xd5f0ad91: {target: 0x6df8014e, keep: []string{"flags", "left", "peer", "kicked_by", "date", "banned_rights"}}, // channelParticipantBanned + 0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll + 0xe1f867b8: {target: 0xe46bcee4, keep: []string{"user_id"}}, // chatParticipantCreator + 0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto + 0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto + 0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired + 0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog + 0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic + }, + newTypes: []uint32{ + 0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec, + 0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96, + 0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x399674dc, + 0x3c29a3e2, 0x3c60b621, 0x3e2793ba, 0x3e81e078, 0x402b4495, 0x445663a7, + 0x4880ed9a, 0x4c2a5d62, 0x519524ea, 0x574b617f, 0x5806b4ec, 0x59080c20, + 0x67e731ad, 0x682a41a9, 0x6c24f3dd, 0x6c9d0efe, 0x71777116, 0x7781fe18, + 0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x8c0f91fb, 0x904ac7c7, 0x90d7adfa, + 0x933ca597, 0x98a3a840, 0x9b00622b, 0x9bad6414, 0x9d2eac97, 0x9da1cd6c, + 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac6a83aa, 0xae152a69, 0xb22083a6, + 0xb43df56c, 0xb532772b, 0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xbd8367b9, + 0xbdac7e70, 0xbf7d6572, 0xc1f46103, 0xc31c8f4e, 0xc39a2ade, 0xc556a45d, + 0xc6c1e5a7, 0xcd24cf44, 0xcdd4093d, 0xcff63ea9, 0xd6e3b813, 0xdacb836a, + 0xdbbe6c6a, 0xdd1fbf93, 0xe2b23b51, 0xe4c449fc, 0xf13bbcd7, 0xf1d628ec, + 0xfa2bc90a, + }, + }, + 223: { + rules: map[uint32]ruleRaw{ + 0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot + 0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "noforwards_my_enabled", "noforwards_peer_enabled", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull + 0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed + 0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem + 0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader + 0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList + 0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText + 0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed + 0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage + 0x3cd623ec: {target: 0xf8f8eb1e, keep: []string{"flags", "request_write_access", "request_phone_number", "match_codes_first", "bot", "domain", "browser", "platform", "ip", "region", "match_codes", "user_id_hint"}}, // urlAuthResultRequest + 0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode + 0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer + 0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage + 0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks + 0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings + 0x7600b9d3: {target: 0x3ae56482, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message + 0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote + 0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll + 0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester + 0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto + 0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector->Vector + 0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed + 0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll + 0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull + 0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults + 0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll + 0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto + 0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto + 0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired + 0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog + 0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic + }, + newTypes: []uint32{ + 0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec, + 0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96, + 0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x399674dc, + 0x3c29a3e2, 0x3c60b621, 0x3e81e078, 0x402b4495, 0x445663a7, 0x4880ed9a, + 0x4c2a5d62, 0x519524ea, 0x574b617f, 0x59080c20, 0x67e731ad, 0x682a41a9, + 0x6c24f3dd, 0x6c9d0efe, 0x71777116, 0x7781fe18, 0x79eb8cb3, 0x7b9e1801, + 0x83281dbd, 0x8c0f91fb, 0x90d7adfa, 0x933ca597, 0x98a3a840, 0x9b00622b, + 0x9bad6414, 0x9d2eac97, 0x9da1cd6c, 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, + 0xac6a83aa, 0xae152a69, 0xb22083a6, 0xb43df56c, 0xb532772b, 0xb956812d, + 0xbaf39d8b, 0xbaff072f, 0xbdac7e70, 0xc1f46103, 0xc31c8f4e, 0xc39a2ade, + 0xc556a45d, 0xc6c1e5a7, 0xcd24cf44, 0xcdd4093d, 0xcff63ea9, 0xd6e3b813, + 0xdacb836a, 0xdbbe6c6a, 0xdd1fbf93, 0xe2b23b51, 0xe4c449fc, 0xf13bbcd7, + 0xf1d628ec, 0xfa2bc90a, + }, + }, + 224: { + rules: map[uint32]ruleRaw{ + 0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot + 0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed + 0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList + 0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText + 0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode + 0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage + 0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks + 0x7600b9d3: {target: 0x3ae56482, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message + 0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester + 0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed + 0x966e2dbf: {target: 0xb8425be9, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "open_answers", "revoting_disabled", "shuffle_answers", "hide_results_until_close", "creator", "question", "answers", "close_period", "close_date", "hash"}}, // poll + 0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull + 0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired + }, + newTypes: []uint32{ + 0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0773c080, 0x096b2aec, 0x0a617e7b, + 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x1fa01357, 0x1fe9a9bf, 0x24c26789, + 0x2999beed, 0x2f51c337, 0x3c29a3e2, 0x445663a7, 0x4c2a5d62, 0x519524ea, + 0x574b617f, 0x59080c20, 0x67e731ad, 0x682a41a9, 0x6c24f3dd, 0x6c9d0efe, + 0x7781fe18, 0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x8c0f91fb, 0x933ca597, + 0x98a3a840, 0x9b00622b, 0x9bad6414, 0x9d2eac97, 0xa26156c0, 0xa2cb24f9, + 0xa5b45e2b, 0xac6a83aa, 0xae152a69, 0xb22083a6, 0xb43df56c, 0xb532772b, + 0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xbdac7e70, 0xc1f46103, 0xc31c8f4e, + 0xc39a2ade, 0xc556a45d, 0xcd24cf44, 0xcdd4093d, 0xcff63ea9, 0xd6e3b813, + 0xdacb836a, 0xdbbe6c6a, 0xdd1fbf93, 0xe2b23b51, 0xe4c449fc, 0xf1d628ec, + }, + }, + 225: { + rules: map[uint32]ruleRaw{ + 0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot + 0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed + 0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList + 0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText + 0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode + 0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage + 0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks + 0x7600b9d3: {target: 0x95ef6f2b, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "guestchat_via_from", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message + 0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester + 0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed + 0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull + 0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired + }, + newTypes: []uint32{ + 0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x096b2aec, 0x0a617e7b, 0x0e6e47c4, + 0x0efa0194, 0x140502d1, 0x24c26789, 0x2f51c337, 0x3c29a3e2, 0x445663a7, + 0x4c2a5d62, 0x519524ea, 0x574b617f, 0x59080c20, 0x67e731ad, 0x682a41a9, + 0x6c24f3dd, 0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x933ca597, 0x98a3a840, + 0x9b00622b, 0x9d2eac97, 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac6a83aa, + 0xae152a69, 0xb22083a6, 0xb43df56c, 0xb532772b, 0xb956812d, 0xbaf39d8b, + 0xbaff072f, 0xbdac7e70, 0xc31c8f4e, 0xc39a2ade, 0xc556a45d, 0xcd24cf44, + 0xd6e3b813, 0xdacb836a, 0xdbbe6c6a, 0xe2b23b51, 0xe4c449fc, + }, + }, + 226: { + rules: map[uint32]ruleRaw{ + 0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed + 0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList + 0x2f51c337: {target: 0x774bbdf4, structural: "messages.chatInviteJoinResultWebView"}, // target field "url" not found in canonical (reorder/insert) + 0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText + 0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage + 0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks + 0x7600b9d3: {target: 0x95ef6f2b, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "guestchat_via_from", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message + 0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed + }, + newTypes: []uint32{ + 0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x096b2aec, 0x0a617e7b, 0x0e6e47c4, + 0x24c26789, 0x3c29a3e2, 0x4c2a5d62, 0x519524ea, 0x574b617f, 0x59080c20, + 0x67e731ad, 0x682a41a9, 0x7b9e1801, 0x83281dbd, 0x9b00622b, 0x9d2eac97, + 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac6a83aa, 0xb43df56c, 0xb532772b, + 0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xc556a45d, 0xcd24cf44, 0xdacb836a, + 0xdbbe6c6a, 0xe2b23b51, 0xe4c449fc, + }, + }, +} + +// inboundMethodUpgrades maps an old client method constructor id to the +// canonical (227) id. Only upgrade-safe changes (all 227 additions flag-gated) +// are listed: rewriting the 4-byte id yields a valid 227 request body. +// NOT upgrade-safe as a pure id swap (declare a body transform in client-drift.tl when needed): +// +// channels.editAdmin: field "rank": conditional-ness changed +// channels.toggleJoinRequest: 227 inserts flags integer "flags" +// contacts.search: 227 inserts flags integer "flags" +// messages.composeMessageWithAI: target field "change_tone" not found in canonical (reorder/insert) +// messages.getPollResults: 227-only field "poll_hash" is non-conditional +// messages.sendBotRequestedPeer: field "msg_id": conditional-ness changed +// messages.toggleNoForwards: 227 inserts flags integer "flags" +var inboundMethodUpgrades = map[uint32]uint32{ + 0x052b08db: 0xb8f106e3, // messages.setBotGuestChatResult + 0x198fb446: 0x894cc99c, // messages.requestUrlAuth + 0x24b524c5: 0x7f6a1e22, // channels.joinChannel + 0x2d0a0571: 0x60ed4229, // account.toggleWebBrowserSettingsException + 0x51e842e1: 0xb106e66c, // messages.editMessage + 0x545cd15a: 0xfef48f62, // messages.sendMessage + 0x54ae308e: 0xad0fa15c, // messages.saveDraft + 0x63183030: 0xa5eec345, // messages.translateText + 0x6c50051c: 0xde91436e, // messages.importChatInvite + 0x737fc2ec: 0x8f9e6898, // stories.sendStory + 0x83557dba: 0xa423bb51, // messages.editInlineBotMessage + 0x9d4104e2: 0xabbbd346, // messages.summarizeText + 0xb12c7125: 0x67a3f0de, // messages.acceptUrlAuth + 0xb583ba46: 0x2c63a72b, // stories.editStory +} diff --git a/internal/compat/layerwire/transcode_test.go b/internal/compat/layerwire/transcode_test.go new file mode 100644 index 00000000..abf43408 --- /dev/null +++ b/internal/compat/layerwire/transcode_test.go @@ -0,0 +1,203 @@ +package layerwire + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" +) + +// loadLayerModel parses a vendored historical schema (_schema/layer-N.tl) into a +// schemaModel used as an independent oracle: downgraded bytes must parse cleanly +// against the actual target-layer schema. +func loadLayerModel(t *testing.T, layer int) *schemaModel { + t.Helper() + src, err := os.ReadFile(filepath.Join("_schema", fmt.Sprintf("layer-%d.tl", layer))) + if err != nil { + t.Fatalf("read layer %d schema: %v", layer, err) + } + m, err := parseSchemaModel(string(src)) + if err != nil { + t.Fatalf("parse layer %d schema: %v", layer, err) + } + return m +} + +// TestTranscodeIdentity verifies that targeting the canonical layer (or above) +// is a pure passthrough — the transcoder must never mutate 227 bytes. +func TestTranscodeIdentity(t *testing.T) { + for _, o := range canonicalCorpus() { + raw := mustEncode(t, o) + out, err := Transcode(raw, CanonicalLayer) + if err != nil { + t.Fatalf("%T: identity transcode: %v", o, err) + } + if !bytes.Equal(out, raw) { + t.Errorf("%T: identity transcode changed bytes", o) + } + } +} + +// TestTranscodeDowngradeValid downgrades the corpus to every supported layer and +// asserts the result parses cleanly (full byte consumption) against that layer's +// own schema. This is the core correctness oracle for the transcoder. +func TestTranscodeDowngradeValid(t *testing.T) { + for layer := SupportedFloor; layer < CanonicalLayer; layer++ { + model := loadLayerModel(t, layer) + for _, o := range canonicalCorpus() { + raw := mustEncode(t, o) + out, err := Transcode(raw, layer) + if err != nil { + t.Errorf("layer %d %T: transcode: %v", layer, o, err) + continue + } + b := &bin.Buffer{Buf: append([]byte(nil), out...)} + if err := model.skipObject(b); err != nil { + t.Errorf("layer %d %T: result invalid at target: %v", layer, o, err) + continue + } + if b.Len() != 0 { + t.Errorf("layer %d %T: %d trailing bytes in downgraded output", layer, o, b.Len()) + } + } + } +} + +// TestTranscodeMessageGolden checks that a message downgraded to 220 carries the +// 220 constructor id and is strictly shorter (dropped trailing fields). +func TestTranscodeMessageGolden(t *testing.T) { + const message220CRC = 0xb92f76cf + raw := mustEncode(t, canonicalCorpus()[1]) // the rich message + out, err := Transcode(raw, 220) + if err != nil { + t.Fatalf("transcode message->220: %v", err) + } + b := &bin.Buffer{Buf: append([]byte(nil), out...)} + id, err := b.PeekID() + if err != nil { + t.Fatalf("peek id: %v", err) + } + if id != message220CRC { + t.Fatalf("message@220 id = %#08x, want %#08x", id, message220CRC) + } + if len(out) >= len(raw) { + t.Errorf("downgraded message not shorter: %d >= %d", len(out), len(raw)) + } +} + +// TestTranscodePassthroughNonAPI verifies that a top-level constructor absent +// from the tg schema (an MTProto control object such as rpc_error) passes +// through untouched at any layer. +func TestTranscodePassthroughNonAPI(t *testing.T) { + var b bin.Buffer + b.PutID(0xc4b9f9bb) // rpc_error#c4b9f9bb (mt.*), not a tg API constructor + b.PutInt(420) + b.PutString("FLOOD_WAIT") + raw := b.Copy() + out, err := Transcode(raw, 220) + if err != nil { + t.Fatalf("passthrough transcode: %v", err) + } + if !bytes.Equal(out, raw) { + t.Errorf("non-API object was modified by transcode") + } +} + +// TestTranscodeChangedTypeNestedInUnchangedContainer is the case raised in +// review: an outer constructor whose CRC is IDENTICAL across 227 and the target +// layer (so a naive "same CRC ⇒ copy verbatim" would be wrong) but which nests a +// CHANGED type (message). The dirty closure must mark the outer container dirty +// purely because it can transitively reach a changed type, so the transcoder +// keeps the outer CRC yet recurses and rewrites the inner message to the target. +func TestTranscodeChangedTypeNestedInUnchangedContainer(t *testing.T) { + const ( + message227CRC = 0x7600b9d3 + message220CRC = 0xb92f76cf + ) + // updates#... nests Vector → updateNewMessage → message:Message. + updates := &tg.Updates{ + Updates: []tg.UpdateClass{ + &tg.UpdateNewMessage{ + Message: &tg.Message{ID: 7, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "nested"}, + Pts: 1, PtsCount: 1, + }, + }, + Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}}, + Chats: []tg.ChatClass{}, + Date: 100, Seq: 1, + } + + // Premise of the question: the OUTER container's CRC is unchanged at 220. + m220 := loadLayerModel(t, 220) + if canonical.byName["updates"].crc != m220.byName["updates"].crc { + t.Skip("updates CRC differs 220<->227; premise no longer holds") + } + + raw := mustEncode(t, updates) + out, err := Transcode(raw, 220) + if err != nil { + t.Fatalf("transcode updates->220: %v", err) + } + + // Outer CRC preserved (it really is unchanged). + if id, _ := (&bin.Buffer{Buf: out}).PeekID(); id != canonical.byName["updates"].crc { + t.Fatalf("outer updates id changed to %#08x", id) + } + // Inner message rewritten to the 220 constructor; the 227 one must be gone. + le := func(crc uint32) []byte { return []byte{byte(crc), byte(crc >> 8), byte(crc >> 16), byte(crc >> 24)} } + if bytes.Contains(out, le(message227CRC)) { + t.Errorf("downgraded output still contains the 227 message constructor") + } + if !bytes.Contains(out, le(message220CRC)) { + t.Errorf("downgraded output missing the 220 message constructor") + } + // Rigorous: the whole thing must parse cleanly against the real 220 schema — + // impossible if a 227-only nested constructor leaked through. + b := &bin.Buffer{Buf: append([]byte(nil), out...)} + if err := m220.skipObject(b); err != nil || b.Len() != 0 { + t.Fatalf("downgraded updates invalid at 220 (err=%v left=%d)", err, b.Len()) + } +} + +// TestTranscodePollResults exercises the pollAnswerVoters structural transform. +func TestTranscodePollResults(t *testing.T) { + raw := mustEncode(t, canonicalCorpus()[10]) // PollResults + for layer := SupportedFloor; layer < CanonicalLayer; layer++ { + out, err := Transcode(raw, layer) + if err != nil { + t.Fatalf("layer %d: pollResults transcode: %v", layer, err) + } + model := loadLayerModel(t, layer) + b := &bin.Buffer{Buf: append([]byte(nil), out...)} + if err := model.skipObject(b); err != nil || b.Len() != 0 { + t.Errorf("layer %d: pollResults invalid (err=%v left=%d)", layer, err, b.Len()) + } + } +} + +// TestTranscodePollAnswerVotersAbsentFlag exercises the structural transform's +// flag-bit-2-unset path: a pollAnswerVoters whose voters field is absent in 227 +// (flags.2 clear) must still emit voters:0 (unconditional int) at older layers. +func TestTranscodePollAnswerVotersAbsentFlag(t *testing.T) { + // voters absent (flag bit 2 unset): Voters=0 ⇒ gotd SetFlags leaves flags.2 clear. + pr := &tg.PollResults{ + Results: []tg.PollAnswerVoters{{Option: []byte{0}, Chosen: true}}, + TotalVoters: 0, + } + raw := mustEncode(t, pr) + for layer := SupportedFloor; layer < CanonicalLayer; layer++ { + out, err := Transcode(raw, layer) + if err != nil { + t.Fatalf("layer %d: transcode: %v", layer, err) + } + model := loadLayerModel(t, layer) + b := &bin.Buffer{Buf: append([]byte(nil), out...)} + if err := model.skipObject(b); err != nil || b.Len() != 0 { + t.Errorf("layer %d: voters-absent pollResults invalid (err=%v left=%d)", layer, err, b.Len()) + } + } +} diff --git a/internal/compat/layerwire/walk.go b/internal/compat/layerwire/walk.go new file mode 100644 index 00000000..3d9d7df9 --- /dev/null +++ b/internal/compat/layerwire/walk.go @@ -0,0 +1,116 @@ +package layerwire + +import ( + "fmt" + + "github.com/gotd/td/bin" +) + +// skipObject advances b past one boxed object (CRC + body), resolving the +// constructor from the canonical schema. +func (m *schemaModel) skipObject(b *bin.Buffer) error { + id, err := b.PeekID() + if err != nil { + return err + } + cl, ok := m.byCRC[id] + if !ok { + return fmt.Errorf("layerwire: unknown constructor %#08x", id) + } + if err := b.ConsumeID(id); err != nil { + return err + } + return m.skipCtorBody(b, cl) +} + +// skipCtorBody advances b past a constructor body (no leading CRC), evaluating +// flag integers so conditional fields are read iff present. +func (m *schemaModel) skipCtorBody(b *bin.Buffer, cl *ctorLayout) error { + var flags map[string]uint32 + for i := range cl.fields { + f := &cl.fields[i] + if f.isFlags { + v, err := b.Uint32() + if err != nil { + return fmt.Errorf("%s.%s: %w", cl.name, f.name, err) + } + if flags == nil { + flags = make(map[string]uint32, 2) + } + flags[f.name] = v + continue + } + if f.conditional() && flags[f.flagName]&(1< 0 && size.H > 0 && size.Size > 0 { + out = append(out, &tg.PhotoSize{Type: size.Type, W: size.W, H: size.H, Size: size.Size}) + } + } + } + if len(out) == 0 { + return nil + } + return out +} + +func seedDocumentAttributes(in []appearance.DocumentAttribute) []tg.DocumentAttributeClass { + out := make([]tg.DocumentAttributeClass, 0, len(in)) + for _, attr := range in { + switch attr.Kind { + case "image_size": + out = append(out, &tg.DocumentAttributeImageSize{W: attr.W, H: attr.H}) + case "filename": + if attr.FileName != "" { + out = append(out, &tg.DocumentAttributeFilename{FileName: attr.FileName}) + } + } + } + return out +} + +func seedPeerColorOptions(profile bool) []tg.HelpPeerColorOption { + if profile { + peerColorOptionsCache.profileOnce.Do(func() { + peerColorOptionsCache.profile = buildSeedPeerColorOptions(true) + }) + return clonePeerColorOptions(peerColorOptionsCache.profile) + } + peerColorOptionsCache.regularOnce.Do(func() { + peerColorOptionsCache.regular = buildSeedPeerColorOptions(false) + }) + return clonePeerColorOptions(peerColorOptionsCache.regular) +} + +func buildSeedPeerColorOptions(profile bool) []tg.HelpPeerColorOption { + catalog := appearance.Default() + source := catalog.PeerColors + if profile { + source = catalog.PeerProfileColors + } + out := make([]tg.HelpPeerColorOption, 0, len(source)) + for _, color := range source { + option := tg.HelpPeerColorOption{ColorID: color.ID} + option.SetHidden(color.Hidden) + channelMin := boundedPeerColorMinLevel(color.ChannelMinLevel) + if channelMin > 0 { + option.SetChannelMinLevel(channelMin) + } + groupMin := boundedPeerColorMinLevel(color.GroupMinLevel) + if groupMin == 0 && profile { + groupMin = channelMin + } + if groupMin > 0 { + option.SetGroupMinLevel(groupMin) + } + if colors := seedPeerColorSet(color.Colors); colors != nil { + option.SetColors(colors) + } + if colors := seedPeerColorSet(color.DarkColors); colors != nil { + option.SetDarkColors(colors) + } + out = append(out, option) + } + return out +} + +func clonePeerColorOptions(in []tg.HelpPeerColorOption) []tg.HelpPeerColorOption { + if len(in) == 0 { + return nil + } + out := make([]tg.HelpPeerColorOption, len(in)) + for i := range in { + out[i] = in[i] + if in[i].Colors != nil { + out[i].Colors = clonePeerColorSet(in[i].Colors) + } + if in[i].DarkColors != nil { + out[i].DarkColors = clonePeerColorSet(in[i].DarkColors) + } + } + return out +} + +func clonePeerColorSet(in tg.HelpPeerColorSetClass) tg.HelpPeerColorSetClass { + switch set := in.(type) { + case *tg.HelpPeerColorSet: + return &tg.HelpPeerColorSet{Colors: append([]int(nil), set.Colors...)} + case *tg.HelpPeerColorProfileSet: + return &tg.HelpPeerColorProfileSet{ + PaletteColors: append([]int(nil), set.PaletteColors...), + BgColors: append([]int(nil), set.BgColors...), + StoryColors: append([]int(nil), set.StoryColors...), + } + default: + return in + } +} + +func boundedPeerColorMinLevel(level int) int { + if level <= 0 { + return 0 + } + if level > maxPeerColorBoostLevel { + return maxPeerColorBoostLevel + } + return level +} + +func seedPeerColorID(id int, profile bool) (bool, bool) { + catalog := appearance.Default() + source := catalog.PeerColors + if profile { + source = catalog.PeerProfileColors + } + if len(source) == 0 { + return false, false + } + for _, color := range source { + if color.ID == id { + return true, true + } + } + return false, true +} + +func seedPeerColorSet(in *appearance.ColorSet) tg.HelpPeerColorSetClass { + if in == nil { + return nil + } + if len(in.PaletteColors) > 0 || len(in.BgColors) > 0 || len(in.StoryColors) > 0 { + return &tg.HelpPeerColorProfileSet{ + PaletteColors: append([]int(nil), in.PaletteColors...), + BgColors: append([]int(nil), in.BgColors...), + StoryColors: append([]int(nil), in.StoryColors...), + } + } + if len(in.Colors) > 0 { + return &tg.HelpPeerColorSet{Colors: append([]int(nil), in.Colors...)} + } + return nil +} diff --git a/internal/compat/tdesktop/config.go b/internal/compat/tdesktop/config.go index 5925c6b4..460299d2 100644 --- a/internal/compat/tdesktop/config.go +++ b/internal/compat/tdesktop/config.go @@ -16,9 +16,12 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config { Expires: int(now.Add(time.Hour).Unix()), TestMode: false, ThisDC: dc, - DCOptions: []tg.DCOption{ - {ID: dc, IPAddress: ip, Port: port, Static: true}, - }, + // 不下发 DCOptions:客户端(TDesktop patch / drklo fork)已写死 static DC + // 地址,空列表会让客户端保留它——drklo ConnectionsManager.cpp 的 processConfig + // 在 dc_options 为空时整段跳过 replaceAddresses/saveConfig,既不覆盖也不持久化。 + // 服务端因此无需配置对外可达 IP,换网络/部署只改客户端写死地址即可。ip/port + // 参数暂留,供未来需要显式 advertise 时改回。 + DCOptions: nil, ChatSizeMax: 200, MegagroupSizeMax: 200000, ForwardedCountMax: 100, @@ -31,15 +34,20 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config { PushChatPeriodMs: 60000, PushChatLimit: 2, EditTimeLimit: 172800, - RevokeTimeLimit: 172800, - RevokePmTimeLimit: 172800, + // 官方现行 revoke 三元组:无时限 + 允许撤回对方发来的私聊消息。 + // TDesktop 的私聊 "Also delete for X" 复选框要求 + // revoke_pm_inbox=true 且 revoke_pm_time_limit=0x7FFFFFFF, + // 否则双向删除 UI 永不出现。 + RevokeTimeLimit: 2147483647, + RevokePmTimeLimit: 2147483647, + RevokePmInbox: true, RatingEDecay: 2419200, StickersRecentLimit: 200, CallReceiveTimeoutMs: 20000, CallRingTimeoutMs: 90000, CallConnectTimeoutMs: 30000, CallPacketTimeoutMs: 10000, - MeURLPrefix: "https://t.me/", + MeURLPrefix: "https://telesrv.net/", CaptionLengthMax: 1024, MessageLengthMax: 4096, WebfileDCID: dc, @@ -49,7 +57,8 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config { // NearestDC 构造 help.getNearestDc 返回值。 func NearestDC(dc int) *tg.NearestDC { return &tg.NearestDC{ - Country: "US", + // 默认国家=中国:DrKLO/TDesktop 登录页据此预选区号(+86)。 + Country: "CN", ThisDC: dc, NearestDC: dc, } diff --git a/internal/compat/tdesktop/defaults.go b/internal/compat/tdesktop/defaults.go index 90fc6012..92d83e9d 100644 --- a/internal/compat/tdesktop/defaults.go +++ b/internal/compat/tdesktop/defaults.go @@ -1,15 +1,13 @@ package tdesktop import ( - "crypto/sha256" - "encoding/binary" - "time" - "github.com/gotd/td/tg" + + "telesrv/internal/seed/catalog" ) const ( - appConfigHash = 4 + appConfigHash = 12 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 countriesListHash = 1 timezonesListHash = 1 ) @@ -21,76 +19,149 @@ func AppConfig(hash int) tg.HelpAppConfigClass { } return &tg.HelpAppConfig{ Hash: appConfigHash, - Config: readMarkAppConfig(), + Config: readMarkAppConfig(""), } } -func readMarkAppConfig() *tg.JSONObject { - return &tg.JSONObject{Value: []tg.JSONObjectValue{ +func readMarkAppConfig(mapboxToken string) *tg.JSONObject { + values := []tg.JSONObjectValue{ {Key: "chat_read_mark_size_threshold", Value: &tg.JSONNumber{Value: 50}}, {Key: "chat_read_mark_expire_period", Value: &tg.JSONNumber{Value: 604800}}, {Key: "pm_read_date_expire_period", Value: &tg.JSONNumber{Value: 604800}}, {Key: "quote_length_max", Value: &tg.JSONNumber{Value: 1024}}, {Key: "telegram_antispam_group_size_min", Value: &tg.JSONNumber{Value: 200}}, {Key: "telegram_antispam_user_id", Value: &tg.JSONString{Value: "5434988373"}}, - }} + // premium_purchase_blocked=false:客户端把 star gift「Send a Gift」入口与 + // premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口 + // (详见 app/help/service.go 主配置注释)。这里是无 HelpService 时的最小回退。 + {Key: "premium_purchase_blocked", Value: &tg.JSONBool{Value: false}}, + // stargifts_blocked=false:DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格。 + {Key: "stargifts_blocked", Value: &tg.JSONBool{Value: false}}, + {Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}}, + // dialog_filters_enabled=true:TDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。 + {Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}}, + {Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}}, + {Key: "stories_stealth_past_period", Value: &tg.JSONNumber{Value: 300}}, + {Key: "stories_stealth_cooldown_period", Value: &tg.JSONNumber{Value: 10800}}, + // upload_markup_video=true 即官方默认(emoji/sticker 头像由客户端本地渲染 mp4 后随 + // markup 一起上传)。显式下发是为了把曾收到过 false 的客户端持久化配置洗回默认—— + // 客户端对缺失的 key 会保留本地旧值,仅删除 key 无法恢复。 + {Key: "upload_markup_video", Value: &tg.JSONBool{Value: true}}, + // 单 emoji 消息转 InputMediaDice 的白名单;须与 rpc 层 dice 取值表同步。 + {Key: "emojies_send_dice", Value: &tg.JSONArray{Value: []tg.JSONValueClass{ + &tg.JSONString{Value: "\U0001F3B2"}, // 🎲 + &tg.JSONString{Value: "\U0001F3AF"}, // 🎯 + &tg.JSONString{Value: "\U0001F3C0"}, // 🏀 + &tg.JSONString{Value: "⚽"}, + &tg.JSONString{Value: "⚽️"}, + &tg.JSONString{Value: "\U0001F3B3"}, // 🎳 + &tg.JSONString{Value: "\U0001F3B0"}, // 🎰 + }}}, + } + if mapboxToken != "" { + // TDesktop 位置选点器(WebView+Mapbox GL)与 business 位置设置的地图 token; + // 缺失时选点器地图空白(瓦片直连 api.mapbox.com,不经服务端)。 + values = append(values, tg.JSONObjectValue{Key: "tdesktop_config_map", Value: &tg.JSONObject{Value: []tg.JSONObjectValue{ + {Key: "maps", Value: &tg.JSONString{Value: mapboxToken}}, + {Key: "geo", Value: &tg.JSONString{Value: mapboxToken}}, + {Key: "bmaps", Value: &tg.JSONString{Value: mapboxToken}}, + {Key: "bgeo", Value: &tg.JSONString{Value: mapboxToken}}, + }}}) + } + return &tg.JSONObject{Value: values} } -// TimezonesList returns a small non-empty timezone set for TDesktop business settings preloading. +// fallbackTimezones 是 catalog 未 seed 时的内置最小时区集。 +var fallbackTimezones = []tg.Timezone{ + {ID: "Etc/UTC", Name: "UTC", UtcOffset: 0}, + {ID: "America/New_York", Name: "Eastern Time", UtcOffset: -5 * 60 * 60}, + {ID: "America/Chicago", Name: "Central Time", UtcOffset: -6 * 60 * 60}, + {ID: "America/Denver", Name: "Mountain Time", UtcOffset: -7 * 60 * 60}, + {ID: "America/Los_Angeles", Name: "Pacific Time", UtcOffset: -8 * 60 * 60}, + {ID: "Asia/Shanghai", Name: "China Standard Time", UtcOffset: 8 * 60 * 60}, +} + +// TimezonesList 返回时区目录:优先用 catalog 固化的官方全量(~419 个),未 seed 时回退内置最小集。 func TimezonesList(hash int) tg.HelpTimezonesListClass { - if hash == timezonesListHash { + seeded, h := catalog.Timezones() + src := seeded + if len(src) == 0 { + src, h = nil, timezonesListHash + } + if hash == h { return &tg.HelpTimezonesListNotModified{} } - return &tg.HelpTimezonesList{ - Hash: timezonesListHash, - Timezones: []tg.Timezone{ - {ID: "Etc/UTC", Name: "UTC", UtcOffset: 0}, - {ID: "America/New_York", Name: "Eastern Time", UtcOffset: -5 * 60 * 60}, - {ID: "America/Chicago", Name: "Central Time", UtcOffset: -6 * 60 * 60}, - {ID: "America/Denver", Name: "Mountain Time", UtcOffset: -7 * 60 * 60}, - {ID: "America/Los_Angeles", Name: "Pacific Time", UtcOffset: -8 * 60 * 60}, - {ID: "Asia/Shanghai", Name: "China Standard Time", UtcOffset: 8 * 60 * 60}, - }, + items := fallbackTimezones + if len(src) > 0 { + items = make([]tg.Timezone, 0, len(src)) + for _, t := range src { + items = append(items, tg.Timezone{ID: t.ID, Name: t.Name, UtcOffset: t.UTCOffset}) + } + } + return &tg.HelpTimezonesList{Hash: h, Timezones: items} +} + +// SuggestedDialogFilters 返回新建对话文件夹时的「推荐文件夹」模板(官方固定语义: +// Unread/Personal/Unmuted/Groups/Channels/Bots)。客户端据此一键创建对应规则的文件夹; +// 模板 id 仅占位,用户采纳时客户端会以新 id 调 updateDialogFilter 真正创建。 +func SuggestedDialogFilters() []tg.DialogFilterSuggested { + mk := func(id int, title, desc string, set func(*tg.DialogFilter)) tg.DialogFilterSuggested { + df := &tg.DialogFilter{ + ID: id, + Title: tg.TextWithEntities{Text: title, Entities: []tg.MessageEntityClass{}}, + PinnedPeers: []tg.InputPeerClass{}, + IncludePeers: []tg.InputPeerClass{}, + ExcludePeers: []tg.InputPeerClass{}, + } + set(df) + return tg.DialogFilterSuggested{Filter: df, Description: desc} + } + allTypes := func(d *tg.DialogFilter) { + d.Contacts, d.NonContacts, d.Groups, d.Broadcasts, d.Bots = true, true, true, true, true + } + return []tg.DialogFilterSuggested{ + mk(2, "Unread", "New messages", func(d *tg.DialogFilter) { allTypes(d); d.ExcludeRead = true }), + mk(3, "Personal", "Personal chats", func(d *tg.DialogFilter) { d.Contacts, d.NonContacts = true, true }), + mk(4, "Unmuted", "Unmuted chats", func(d *tg.DialogFilter) { allTypes(d); d.ExcludeMuted = true }), + mk(5, "Groups", "Group chats", func(d *tg.DialogFilter) { d.Groups = true }), + mk(6, "Channels", "Channels only", func(d *tg.DialogFilter) { d.Broadcasts = true }), + mk(7, "Bots", "Bot chats", func(d *tg.DialogFilter) { d.Bots = true }), } } -// CountriesList returns the fallback login country list used when HelpService is absent. +// CountriesList 返回登录页国家区号目录:优先用 catalog 固化的官方全量(~235 国),未 seed +// 时回退内置最小集(US/CN)。生产路径通常经 HelpService.GetCountries → defaultCountries +// 走 catalog;此函数是 HelpService 缺省时的兜底。 func CountriesList(hash int) tg.HelpCountriesListClass { - if hash == countriesListHash { + list := catalog.Countries() + if len(list.Countries) == 0 { + if hash == countriesListHash { + return &tg.HelpCountriesListNotModified{} + } + return &tg.HelpCountriesList{ + Hash: countriesListHash, + Countries: []tg.HelpCountry{ + {ISO2: "US", DefaultName: "United States", CountryCodes: []tg.HelpCountryCode{{CountryCode: "1", Prefixes: []string{""}, Patterns: []string{"XXX XXX XXXX"}}}}, + {ISO2: "CN", DefaultName: "China", CountryCodes: []tg.HelpCountryCode{{CountryCode: "86", Prefixes: []string{""}, Patterns: []string{"XXX XXXX XXXX"}}}}, + }, + } + } + if hash == list.Hash { return &tg.HelpCountriesListNotModified{} } - return &tg.HelpCountriesList{ - Hash: countriesListHash, - Countries: []tg.HelpCountry{ - { - ISO2: "US", - DefaultName: "United States", - CountryCodes: []tg.HelpCountryCode{ - {CountryCode: "1", Prefixes: []string{"1"}}, - }, - }, - { - ISO2: "CN", - DefaultName: "China", - CountryCodes: []tg.HelpCountryCode{ - {CountryCode: "86", Prefixes: []string{"86"}}, - }, - }, - }, - } -} - -// LoginToken returns a short-lived placeholder token for TDesktop's QR-login -// screen. First phase only supports phone login, but TDesktop expects this RPC -// to produce an auth.loginToken while the QR screen is visible. -func LoginToken(now time.Time, authKeyID [8]byte, sessionID int64) *tg.AuthLoginToken { - var seed [len(authKeyID) + 8 + 8]byte - copy(seed[:len(authKeyID)], authKeyID[:]) - binary.LittleEndian.PutUint64(seed[len(authKeyID):], uint64(sessionID)) - binary.LittleEndian.PutUint64(seed[len(authKeyID)+8:], uint64(now.UnixNano())) - token := sha256.Sum256(seed[:]) - return &tg.AuthLoginToken{ - Expires: int(now.Add(30 * time.Second).Unix()), - Token: token[:], + out := &tg.HelpCountriesList{Hash: list.Hash, Countries: make([]tg.HelpCountry, 0, len(list.Countries))} + for _, c := range list.Countries { + item := tg.HelpCountry{ + Hidden: c.Hidden, + ISO2: c.ISO2, + DefaultName: c.DefaultName, + Name: c.Name, + CountryCodes: make([]tg.HelpCountryCode, 0, len(c.CountryCodes)), + } + for _, cc := range c.CountryCodes { + item.CountryCodes = append(item.CountryCodes, tg.HelpCountryCode{CountryCode: cc.CountryCode, Prefixes: cc.Prefixes, Patterns: cc.Patterns}) + } + out.Countries = append(out.Countries, item) } + return out } diff --git a/internal/compat/tdesktop/dialogs.go b/internal/compat/tdesktop/dialogs.go new file mode 100644 index 00000000..cd67bf0b --- /dev/null +++ b/internal/compat/tdesktop/dialogs.go @@ -0,0 +1,196 @@ +package tdesktop + +import "telesrv/internal/domain" + +// ShouldMergePinnedIntoInitialDialogs reports whether a TDesktop main-list +// messages.getDialogs request should carry pinned/archive rows inline. +// +// TDesktop starts the main list with getDialogs(exclude_pinned=true) and loads +// pinned rows through getPinnedDialogs in parallel. If the exclude_pinned result +// is rendered first, the first visible list temporarily lacks pinned/archive +// rows. Keep the workaround scoped to the first main-folder page with no hash so +// pagination and cache probes retain normal Telegram semantics. +func ShouldMergePinnedIntoInitialDialogs(filter domain.DialogFilter) bool { + if !filter.ExcludePinned || filter.Hash != 0 { + return false + } + if filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID { + return false + } + if filter.OffsetID != 0 || filter.OffsetDate != 0 || filter.HasOffsetPeer { + return false + } + return true +} + +// MergeInitialDialogsWithPinned prepends the pinned getPinnedDialogs view to an +// initial getDialogs(exclude_pinned=true) main-list response. The returned list +// remains a normal messages.dialogs/messages.dialogsSlice projection; only the +// first response carries extra pinned/archive rows for TDesktop startup. +func MergeInitialDialogsWithPinned(main, pinned domain.DialogList) domain.DialogList { + if pinned.ArchiveSummary == nil && len(pinned.Dialogs) == 0 { + return main + } + out := main + out.ArchiveSummary = pinned.ArchiveSummary + out.Dialogs = mergeDialogsByPeer(pinned.Dialogs, main.Dialogs) + out.Messages = mergePrivateMessages(pinned.Messages, main.Messages) + out.ChannelMessages = mergeChannelMessages(pinned.ChannelMessages, main.ChannelMessages) + out.Users = mergeUsersByID(pinned.Users, main.Users) + out.Channels = mergeChannelsByID(pinned.Channels, main.Channels) + pinnedEntries := len(pinned.Dialogs) + if pinned.ArchiveSummary != nil { + pinnedEntries++ + } + mainCount := main.Count + if mainCount == 0 { + mainCount = len(main.Dialogs) + } + out.Count = mainCount + pinnedEntries + out.Hash = 0 + return out +} + +func mergeDialogsByPeer(first, second []domain.Dialog) []domain.Dialog { + out := make([]domain.Dialog, 0, len(first)+len(second)) + seen := make(map[domain.Peer]struct{}, len(first)+len(second)) + for _, d := range first { + if d.Peer.ID == 0 { + continue + } + if _, ok := seen[d.Peer]; ok { + continue + } + seen[d.Peer] = struct{}{} + out = append(out, d) + } + for _, d := range second { + if d.Peer.ID == 0 { + continue + } + if _, ok := seen[d.Peer]; ok { + continue + } + seen[d.Peer] = struct{}{} + out = append(out, d) + } + return out +} + +type privateMessageKey struct { + peer domain.Peer + id int +} + +func mergePrivateMessages(first, second []domain.Message) []domain.Message { + out := make([]domain.Message, 0, len(first)+len(second)) + seen := make(map[privateMessageKey]struct{}, len(first)+len(second)) + for _, msg := range first { + key := privateMessageKey{peer: msg.Peer, id: msg.ID} + if key.peer.ID == 0 || key.id == 0 { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, msg) + } + for _, msg := range second { + key := privateMessageKey{peer: msg.Peer, id: msg.ID} + if key.peer.ID == 0 || key.id == 0 { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, msg) + } + return out +} + +type channelMessageKey struct { + channelID int64 + id int +} + +func mergeChannelMessages(first, second []domain.ChannelMessage) []domain.ChannelMessage { + out := make([]domain.ChannelMessage, 0, len(first)+len(second)) + seen := make(map[channelMessageKey]struct{}, len(first)+len(second)) + for _, msg := range first { + key := channelMessageKey{channelID: msg.ChannelID, id: msg.ID} + if key.channelID == 0 || key.id == 0 { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, msg) + } + for _, msg := range second { + key := channelMessageKey{channelID: msg.ChannelID, id: msg.ID} + if key.channelID == 0 || key.id == 0 { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, msg) + } + return out +} + +func mergeUsersByID(first, second []domain.User) []domain.User { + out := make([]domain.User, 0, len(first)+len(second)) + seen := make(map[int64]struct{}, len(first)+len(second)) + for _, user := range first { + if user.ID == 0 { + continue + } + if _, ok := seen[user.ID]; ok { + continue + } + seen[user.ID] = struct{}{} + out = append(out, user) + } + for _, user := range second { + if user.ID == 0 { + continue + } + if _, ok := seen[user.ID]; ok { + continue + } + seen[user.ID] = struct{}{} + out = append(out, user) + } + return out +} + +func mergeChannelsByID(first, second []domain.Channel) []domain.Channel { + out := make([]domain.Channel, 0, len(first)+len(second)) + seen := make(map[int64]struct{}, len(first)+len(second)) + for _, channel := range first { + if channel.ID == 0 { + continue + } + if _, ok := seen[channel.ID]; ok { + continue + } + seen[channel.ID] = struct{}{} + out = append(out, channel) + } + for _, channel := range second { + if channel.ID == 0 { + continue + } + if _, ok := seen[channel.ID]; ok { + continue + } + seen[channel.ID] = struct{}{} + out = append(out, channel) + } + return out +} diff --git a/internal/compat/tdesktop/startup_stubs.go b/internal/compat/tdesktop/startup_stubs.go index c0269ae3..d0fbe99e 100644 --- a/internal/compat/tdesktop/startup_stubs.go +++ b/internal/compat/tdesktop/startup_stubs.go @@ -4,6 +4,9 @@ import ( "time" "github.com/gotd/td/tg" + + "telesrv/internal/seed/appearance" + "telesrv/internal/seed/catalog" ) // NotifySettings returns default per-peer notification settings for empty first-phase accounts. @@ -23,17 +26,6 @@ func NotifySettings() *tg.PeerNotifySettings { return settings } -// ReactionsNotifySettings returns conservative defaults for reaction notifications. -func ReactionsNotifySettings() *tg.ReactionsNotifySettings { - return &tg.ReactionsNotifySettings{ - MessagesNotifyFrom: &tg.ReactionNotificationsFromContacts{}, - StoriesNotifyFrom: &tg.ReactionNotificationsFromContacts{}, - PollVotesNotifyFrom: &tg.ReactionNotificationsFromContacts{}, - Sound: &tg.NotificationSoundDefault{}, - ShowPreviews: true, - } -} - func PrivacyRules(key tg.InputPrivacyKeyClass) *tg.AccountPrivacyRules { var rule tg.PrivacyRuleClass = &tg.PrivacyValueAllowAll{} switch key.(type) { @@ -61,16 +53,176 @@ func Passkeys() *tg.AccountPasskeys { return &tg.AccountPasskeys{Passkeys: []tg.Passkey{}} } -func ContentSettings() *tg.AccountContentSettings { - return &tg.AccountContentSettings{} -} - func GlobalPrivacySettings() *tg.GlobalPrivacySettings { return &tg.GlobalPrivacySettings{} } -func AccountThemes() tg.AccountThemesClass { - return &tg.AccountThemesNotModified{} +const chatThemesHash int64 = 2026062501 +const uniqueGiftChatThemesHash int64 = 2026061201 +const wallPapersHash int64 = 2026062502 +const peerColorsHash = 2026061202 +const peerProfileColorsHash = 2026062801 + +// IsChatThemeEmoticon reports whether token is a known official chat theme emoticon. +func IsChatThemeEmoticon(token string) bool { + for _, ct := range appearance.Default().ChatThemes { + if ct.Emoticon == token { + return true + } + } + return false +} + +func ChatThemes(hash int64) tg.AccountThemesClass { + if hash == chatThemesHash { + return &tg.AccountThemesNotModified{} + } + return &tg.AccountThemes{ + Hash: chatThemesHash, + Themes: chatThemeList(), + } +} + +// chatThemeList 用官方外观 catalog(appearancefetch 导出)的聊天主题。 +func chatThemeList() []tg.Theme { + return catalogChatThemes(appearance.Default().ChatThemes) +} + +// catalogChatThemes 把官方聊天主题转成 tg.Theme。官方每个主题只带 classic+tinted 两组 +// settings,而 DrKLO 选择器丢弃 base-theme 设置 <4 的主题 +// (MediaDataController.generateEmojiPreviewThemes),故扩展到 telesrv 宣告的 5 个 base: +// 浅色 base(classic/day/arctic)复用官方浅色设置,深色 base(tinted/night)复用官方深色设置。 +func catalogChatThemes(cts []appearance.ChatTheme) []tg.Theme { + out := make([]tg.Theme, 0, len(cts)) + for i, ct := range cts { + var light, dark *appearance.ThemeSettings + for j := range ct.Settings { + s := &ct.Settings[j] + if s.BaseTheme == "tinted" || s.BaseTheme == "night" { + if dark == nil { + dark = s + } + } else if light == nil { + light = s + } + } + if light == nil { + light = dark + } + if dark == nil { + dark = light + } + if light == nil { + continue + } + theme := tg.Theme{ID: ct.ID, AccessHash: ct.AccessHash, Slug: ct.Slug, Title: ct.Title} + theme.SetForChat(true) + if ct.Emoticon != "" { + theme.SetEmoticon(ct.Emoticon) + } + theme.SetInstallsCount(1) + settings := make([]tg.ThemeSettings, 0, len(chatThemeBaseThemes)) + for _, b := range chatThemeBaseThemes { + src := light + if b.dark { + src = dark + } + settings = append(settings, catalogThemeSettings(*src, b.make())) + } + theme.SetSettings(settings) + if i == 0 || ct.Default { + theme.SetDefault(true) + } + out = append(out, theme) + } + return out +} + +func catalogThemeSettings(s appearance.ThemeSettings, base tg.BaseThemeClass) tg.ThemeSettings { + ts := tg.ThemeSettings{BaseTheme: base, AccentColor: s.AccentColor} + if s.OutboxAccentColor != 0 { + ts.SetOutboxAccentColor(s.OutboxAccentColor) + } + if len(s.MessageColors) > 0 { + ts.SetMessageColors(append([]int(nil), s.MessageColors...)) + } + if s.Wallpaper.ID != 0 || s.Wallpaper.Document.ID != 0 { + ts.SetWallpaper(seedWallPaper(s.Wallpaper)) + } + return ts +} + +// DefaultThemes serves the built-in chat theme catalog for account.getThemes. +// +// DrKLO's "Color theme" preview strip on the Chat Settings page +// (DefaultThemesPreviewCell) is populated exclusively from account.getThemes +// results flagged is_default=true — it does not consult account.getChatThemes. +// A fresh client has no cached emoji themes and therefore sends getThemes with +// hash 0; returning themesNotModified to that request leaves defaultEmojiThemes +// empty, so the strip stays stuck on its FlickerLoadingView shimmer forever. +// We return the same catalog as ChatThemes with every entry marked default so +// the strip resolves into real preview cards. +func DefaultThemes(hash int64) tg.AccountThemesClass { + if hash == chatThemesHash { + return &tg.AccountThemesNotModified{} + } + return &tg.AccountThemes{ + Hash: chatThemesHash, + Themes: DefaultThemeList(), + } +} + +// DefaultThemeList 返回内置默认主题切片(全标 is_default=true)。account.getThemes +// 把它与用户自定义主题合并(rpc 层),故单独暴露列表。 +func DefaultThemeList() []tg.Theme { + themes := chatThemeList() + for i := range themes { + themes[i].SetDefault(true) + } + return themes +} + +func UniqueGiftChatThemes(hash int64) tg.AccountChatThemesClass { + if hash == uniqueGiftChatThemesHash { + return &tg.AccountChatThemesNotModified{} + } + return &tg.AccountChatThemes{ + Hash: uniqueGiftChatThemesHash, + Themes: []tg.ChatThemeClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +// WallPapers returns the read-only default wallpaper catalog. User wallpaper +// upload/save/install remains outside the current TDesktop compatibility scope. +func WallPapers(hash int64) tg.AccountWallPapersClass { + if hash == wallPapersHash { + return &tg.AccountWallPapersNotModified{} + } + wallpapers := seedWallPapers() + return &tg.AccountWallPapers{ + Hash: wallPapersHash, + Wallpapers: wallpapers, + } +} + +// chatThemeBaseThemes enumerates the base themes every emoji chat theme ships +// settings for. The client matches a chat theme's settings to whichever base +// theme the user currently runs, and DrKLO's theme picker only surfaces a chat +// theme whose settings cover at least four base themes +// (MediaDataController.generateEmojiPreviewThemes drops anything whose +// items.size() < 4, one item per ThemeSettings). dark selects the bubble/accent +// palette used for that base theme. +var chatThemeBaseThemes = []struct { + make func() tg.BaseThemeClass + dark bool +}{ + {func() tg.BaseThemeClass { return &tg.BaseThemeClassic{} }, false}, + {func() tg.BaseThemeClass { return &tg.BaseThemeDay{} }, false}, + {func() tg.BaseThemeClass { return &tg.BaseThemeArctic{} }, false}, + {func() tg.BaseThemeClass { return &tg.BaseThemeTinted{} }, true}, + {func() tg.BaseThemeClass { return &tg.BaseThemeNight{} }, true}, } func AutoDownloadSettings() *tg.AccountAutoDownloadSettings { @@ -101,10 +253,6 @@ func DefaultGroupPhotoEmojis() tg.EmojiListClass { return &tg.EmojiList{Hash: 0, DocumentID: []int64{}} } -func ConnectedBots() *tg.AccountConnectedBots { - return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}} -} - const availableReactionsHash = 20260602 const emptyStickerSetHash = 20260602 @@ -161,10 +309,7 @@ func availableReaction(reaction defaultReaction, index int) tg.AvailableReaction } } -func Stickers() tg.MessagesStickersClass { - return &tg.MessagesStickersNotModified{} -} - +// Stickers 返回空的全量 messages.stickers。禁止回 stickersNotModified:DrKLO 对 func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass { if req != nil && req.Hash == emptyStickerSetHash { return &tg.MessagesStickerSetNotModified{} @@ -206,8 +351,23 @@ func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass } } -func EmojiGroups() tg.MessagesEmojiGroupsClass { - return &tg.MessagesEmojiGroupsNotModified{} +// EmojiGroups 返回 emoji 分类目录(客户端 emoji 面板顶部的主题快捷标签)。优先用 catalog +// 固化的官方分组,未 seed 时回 NotModified(客户端保留本地/无分组)。icon_emoji_id 指向 +// custom-emoji 文档,本地缺该文档时分类图标回退占位,不影响分组与按词搜索。 +func EmojiGroups(hash int) tg.MessagesEmojiGroupsClass { + groups, h := catalog.EmojiGroups() + if len(groups) == 0 || hash == h { + return &tg.MessagesEmojiGroupsNotModified{} + } + out := make([]tg.EmojiGroupClass, 0, len(groups)) + for _, g := range groups { + out = append(out, &tg.EmojiGroup{Title: g.Title, IconEmojiID: g.IconEmojiID, Emoticons: g.Emoticons}) + } + return &tg.MessagesEmojiGroups{Hash: h, Groups: out} +} + +func EmojiStatusGroups() tg.MessagesEmojiGroupsClass { + return &tg.MessagesEmojiGroups{Hash: 0, Groups: []tg.EmojiGroupClass{}} } func EmojiProfilePhotoGroups() tg.MessagesEmojiGroupsClass { @@ -215,11 +375,20 @@ func EmojiProfilePhotoGroups() tg.MessagesEmojiGroupsClass { } func AttachMenuBots() tg.AttachMenuBotsClass { - return &tg.AttachMenuBotsNotModified{} + return &tg.AttachMenuBots{ + Hash: 0, + Bots: []tg.AttachMenuBot{}, + Users: []tg.UserClass{}, + } } func QuickReplies() tg.MessagesQuickRepliesClass { - return &tg.MessagesQuickRepliesNotModified{} + return &tg.MessagesQuickReplies{ + QuickReplies: []tg.QuickReply{}, + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } } func TopPeers() tg.ContactsTopPeersClass { @@ -234,8 +403,93 @@ func BlockedContacts() tg.ContactsBlockedClass { } } -func PeerColors() tg.HelpPeerColorsClass { - return &tg.HelpPeerColorsNotModified{} +type defaultPeerColor struct { + id int + light []int + dark []int + bg []int + story []int + hidden bool +} + +var defaultPeerColors = []defaultPeerColor{ + {id: 0, light: []int{0xcc4d4d}, dark: []int{0xff7a7a}, bg: []int{0xffd6d6, 0xfff0f0}, story: []int{0xff7a7a, 0xf24b4b}}, + {id: 1, light: []int{0xd97829}, dark: []int{0xffa15c}, bg: []int{0xffdfbd, 0xfff2e2}, story: []int{0xffa15c, 0xf07a2b}}, + {id: 2, light: []int{0x8756d9}, dark: []int{0xb38cff}, bg: []int{0xe3d8ff, 0xf4eeff}, story: []int{0xb38cff, 0x8756d9}}, + {id: 3, light: []int{0x3ca66b}, dark: []int{0x74d99b}, bg: []int{0xd5f5df, 0xedfff2}, story: []int{0x74d99b, 0x35a86a}}, + {id: 4, light: []int{0x2d9fc6}, dark: []int{0x6fd8f5}, bg: []int{0xd4f4ff, 0xf0fbff}, story: []int{0x6fd8f5, 0x2d9fc6}}, + {id: 5, light: []int{0x4a86d9}, dark: []int{0x78aef5}, bg: []int{0xd8e8ff, 0xf2f7ff}, story: []int{0x78aef5, 0x4a86d9}}, + {id: 6, light: []int{0xd85b93}, dark: []int{0xff8abb}, bg: []int{0xffd7e8, 0xfff5fb}, story: []int{0xff8abb, 0xd85b93}}, + {id: 7, light: []int{0x5f6a7a, 0x8c96a6}, dark: []int{0x9aa4b4, 0xc0c8d4}, bg: []int{0xe3e7ee, 0xf7f9fc}, story: []int{0x9aa4b4, 0x6f7b8d}}, +} + +// IsPeerColorID reports whether id is in the TDesktop-compatible peer color palette. +func IsPeerColorID(id int) bool { + if found, seeded := seedPeerColorID(id, false); seeded { + return found + } + for _, color := range defaultPeerColors { + if color.id == id { + return true + } + } + return false +} + +// IsPeerProfileColorID reports whether id is in the profile background palette. +func IsPeerProfileColorID(id int) bool { + if found, seeded := seedPeerColorID(id, true); seeded { + return found + } + return IsPeerColorID(id) +} + +func PeerColors(hash int) tg.HelpPeerColorsClass { + if hash == peerColorsHash { + return &tg.HelpPeerColorsNotModified{} + } + colors := seedPeerColorOptions(false) + if len(colors) == 0 { + colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors)) + for _, color := range defaultPeerColors { + option := tg.HelpPeerColorOption{ColorID: color.id} + option.SetColors(&tg.HelpPeerColorSet{Colors: append([]int(nil), color.light...)}) + option.SetDarkColors(&tg.HelpPeerColorSet{Colors: append([]int(nil), color.dark...)}) + if color.hidden { + option.SetHidden(true) + } + colors = append(colors, option) + } + } + return &tg.HelpPeerColors{Hash: peerColorsHash, Colors: colors} +} + +func PeerProfileColors(hash int) tg.HelpPeerColorsClass { + if hash == peerProfileColorsHash { + return &tg.HelpPeerColorsNotModified{} + } + colors := seedPeerColorOptions(true) + if len(colors) == 0 { + colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors)) + for _, color := range defaultPeerColors { + option := tg.HelpPeerColorOption{ColorID: color.id} + option.SetColors(&tg.HelpPeerColorProfileSet{ + PaletteColors: append([]int(nil), color.light...), + BgColors: append([]int(nil), color.bg...), + StoryColors: append([]int(nil), color.story...), + }) + option.SetDarkColors(&tg.HelpPeerColorProfileSet{ + PaletteColors: append([]int(nil), color.dark...), + BgColors: append([]int(nil), color.bg...), + StoryColors: append([]int(nil), color.story...), + }) + if color.hidden { + option.SetHidden(true) + } + colors = append(colors, option) + } + } + return &tg.HelpPeerColors{Hash: peerProfileColorsHash, Colors: colors} } func PromoData(now time.Time) tg.HelpPromoDataClass { @@ -246,17 +500,6 @@ func TermsOfServiceUpdate(now time.Time) tg.HelpTermsOfServiceUpdateClass { return &tg.HelpTermsOfServiceUpdateEmpty{Expires: int(now.Add(24 * time.Hour).Unix())} } -func PremiumPromo() *tg.HelpPremiumPromo { - return &tg.HelpPremiumPromo{} -} - -func AllStories() tg.StoriesAllStoriesClass { - return &tg.StoriesAllStories{ - State: "", - StealthMode: tg.StoriesStealthMode{}, - } -} - func StoriesArchive() *tg.StoriesStories { return &tg.StoriesStories{} } @@ -273,6 +516,14 @@ func StarGiftActiveAuctions() tg.PaymentsStarGiftActiveAuctionsClass { return &tg.PaymentsStarGiftActiveAuctionsNotModified{} } +func StarGifts() tg.PaymentsStarGiftsClass { + return &tg.PaymentsStarGifts{ + Gifts: []tg.StarGiftClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + func SavedStarGifts() *tg.PaymentsSavedStarGifts { return &tg.PaymentsSavedStarGifts{ Gifts: []tg.SavedStarGift{}, @@ -281,6 +532,29 @@ func SavedStarGifts() *tg.PaymentsSavedStarGifts { } } +func StarGiftCollections() tg.PaymentsStarGiftCollectionsClass { + return &tg.PaymentsStarGiftCollections{ + Collections: []tg.StarGiftCollection{}, + } +} + +func StarsRevenueStats(ton bool) *tg.PaymentsStarsRevenueStats { + zeroAmount := tg.StarsAmountClass(&tg.StarsAmount{}) + if ton { + zeroAmount = &tg.StarsTonAmount{} + } + return &tg.PaymentsStarsRevenueStats{ + RevenueGraph: &tg.StatsGraphError{Error: "Not enough data to display."}, + Status: tg.StarsRevenueStatus{ + CurrentBalance: zeroAmount, + AvailableBalance: zeroAmount, + OverallRevenue: zeroAmount, + WithdrawalEnabled: false, + }, + UsdRate: 0.013, + } +} + func AiComposeTones() tg.AicomposeTonesClass { return &tg.AicomposeTonesNotModified{} } diff --git a/internal/compat/tdesktop/startup_stubs_test.go b/internal/compat/tdesktop/startup_stubs_test.go index 740f198c..631336ed 100644 --- a/internal/compat/tdesktop/startup_stubs_test.go +++ b/internal/compat/tdesktop/startup_stubs_test.go @@ -1,6 +1,7 @@ package tdesktop import ( + "math" "testing" "github.com/gotd/td/tg" @@ -32,6 +33,48 @@ func TestTimezonesListIsNonEmptyAndHashable(t *testing.T) { } } +func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) { + got, ok := AppConfig(0).(*tg.HelpAppConfig) + if !ok || got.Hash == 0 { + t.Fatalf("AppConfig(0) = %#v, want modified config with hash", got) + } + values := make(map[string]float64) + if object, ok := got.Config.(*tg.JSONObject); ok && object != nil { + for _, entry := range object.Value { + if number, ok := entry.Value.(*tg.JSONNumber); ok { + values[entry.Key] = number.Value + } + } + } + want := map[string]float64{ + "stories_stealth_future_period": 1500, + "stories_stealth_past_period": 300, + "stories_stealth_cooldown_period": 10800, + } + for key, expected := range want { + if values[key] != expected { + t.Fatalf("AppConfig[%q] = %v, want %v", key, values[key], expected) + } + } + if _, ok := AppConfig(got.Hash).(*tg.HelpAppConfigNotModified); !ok { + t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash)) + } +} + +func TestFallbackAppConfigOmitsMapboxToken(t *testing.T) { + got, ok := AppConfig(0).(*tg.HelpAppConfig) + if !ok { + t.Fatalf("AppConfig(0) = %T, want modified config", got) + } + if object, ok := got.Config.(*tg.JSONObject); ok && object != nil { + for _, entry := range object.Value { + if entry.Key == "tdesktop_config_map" { + t.Fatal("fallback AppConfig leaked tdesktop_config_map without runtime token") + } + } + } +} + func TestAvailableReactionsCatalogIsNonEmptyAndHashable(t *testing.T) { got, ok := AvailableReactions(0).(*tg.MessagesAvailableReactions) if !ok { @@ -66,6 +109,195 @@ func TestAvailableReactionsCatalogIsNonEmptyAndHashable(t *testing.T) { } } +func TestChatThemesCatalogIsNonEmptyHashableAndHasQRBackgroundColors(t *testing.T) { + got, ok := ChatThemes(0).(*tg.AccountThemes) + if !ok { + t.Fatalf("ChatThemes(0) = %T, want modified list", got) + } + if got.Hash == 0 { + t.Fatal("ChatThemes(0).Hash = 0, want stable cache hash") + } + if len(got.Themes) == 0 { + t.Fatal("ChatThemes(0).Themes is empty") + } + for i, theme := range got.Themes { + if !theme.GetForChat() { + t.Fatalf("theme[%d].for_chat = false, want true", i) + } + if emoticon, ok := theme.GetEmoticon(); !ok || emoticon == "" || !IsChatThemeEmoticon(emoticon) { + t.Fatalf("theme[%d].emoticon = %q ok=%v, want supported token", i, emoticon, ok) + } + settings, ok := theme.GetSettings() + // DrKLO only surfaces an emoji chat theme in the picker when it ships + // settings for at least four base themes + // (MediaDataController.generateEmojiPreviewThemes drops items.size() < 4, + // one item per ThemeSettings); fewer leaves the strip stuck on its + // loading shimmer. + if !ok || len(settings) < 4 { + t.Fatalf("theme[%d].settings = %#v ok=%v, want >=4 base-theme settings", i, settings, ok) + } + var hasDay, hasNight bool + for j, setting := range settings { + if setting.BaseTheme == nil { + t.Fatalf("theme[%d].settings[%d].base_theme missing", i, j) + } + switch setting.BaseTheme.(type) { + case *tg.BaseThemeDay: + hasDay = true + case *tg.BaseThemeNight: + hasNight = true + } + // 官方聊天主题用文档型主题背景墙纸(非旧的纯渐变 WallPaperNoFile), + // 这里只要求每组设置带一个墙纸。 + if wallpaper, ok := setting.GetWallpaper(); !ok || wallpaper == nil { + t.Fatalf("theme[%d].settings[%d].wallpaper = %#v ok=%v, want wallpaper", i, j, wallpaper, ok) + } + } + if !hasDay || !hasNight { + t.Fatalf("theme[%d] settings missing day/night coverage (day=%v night=%v)", i, hasDay, hasNight) + } + } + if _, ok := ChatThemes(got.Hash).(*tg.AccountThemesNotModified); !ok { + t.Fatalf("ChatThemes(hash) = %#v, want notModified", ChatThemes(got.Hash)) + } +} + +func TestDefaultThemesAreDefaultFlaggedForPicker(t *testing.T) { + // account.getThemes feeds DrKLO's "Color theme" preview strip + // (DefaultThemesPreviewCell), which only consumes themes flagged + // is_default=true. Returning themesNotModified to a fresh client (hash 0) + // leaves the strip stuck on a loading shimmer, so a fresh request must + // return a populated, default-flagged catalog. + got, ok := DefaultThemes(0).(*tg.AccountThemes) + if !ok { + t.Fatalf("DefaultThemes(0) = %T, want modified list", got) + } + if got.Hash == 0 { + t.Fatal("DefaultThemes(0).Hash = 0, want stable cache hash") + } + if len(got.Themes) == 0 { + t.Fatal("DefaultThemes(0).Themes is empty") + } + for i, theme := range got.Themes { + if !theme.GetDefault() { + t.Fatalf("theme[%d].is_default = false, want true so the picker shows it", i) + } + if !theme.GetForChat() { + t.Fatalf("theme[%d].for_chat = false, want true", i) + } + if settings, ok := theme.GetSettings(); !ok || len(settings) < 4 { + t.Fatalf("theme[%d].settings len=%d ok=%v, want >=4 for the picker", i, len(settings), ok) + } + } + if _, ok := DefaultThemes(got.Hash).(*tg.AccountThemesNotModified); !ok { + t.Fatalf("DefaultThemes(hash) = %#v, want notModified", DefaultThemes(got.Hash)) + } +} + +func TestUniqueGiftChatThemesIsEmptyHashableStub(t *testing.T) { + got, ok := UniqueGiftChatThemes(0).(*tg.AccountChatThemes) + if !ok { + t.Fatalf("UniqueGiftChatThemes(0) = %T, want modified list", got) + } + if got.Hash == 0 { + t.Fatal("UniqueGiftChatThemes(0).Hash = 0, want stable cache hash") + } + if len(got.Themes) != 0 || len(got.Chats) != 0 || len(got.Users) != 0 { + t.Fatalf("UniqueGiftChatThemes(0) = themes %d chats %d users %d, want empty vectors", + len(got.Themes), len(got.Chats), len(got.Users)) + } + if _, ok := UniqueGiftChatThemes(got.Hash).(*tg.AccountChatThemesNotModified); !ok { + t.Fatalf("UniqueGiftChatThemes(hash) = %#v, want notModified", UniqueGiftChatThemes(got.Hash)) + } +} + +func TestWallPapersUsesOrangeFileCatalog(t *testing.T) { + got, ok := WallPapers(0).(*tg.AccountWallPapers) + if !ok { + t.Fatalf("WallPapers(0) = %T, want modified list", got) + } + if got.Hash == 0 { + t.Fatal("WallPapers(0).Hash = 0, want stable cache hash") + } + if len(got.Wallpapers) == 0 { + t.Fatal("WallPapers(0).Wallpapers is empty") + } + wallpaper, ok := got.Wallpapers[0].(*tg.WallPaper) + if !ok { + t.Fatalf("WallPapers(0).Wallpapers[0] = %T, want *tg.WallPaper", got.Wallpapers[0]) + } + if wallpaper.ID == 0 || wallpaper.AccessHash == 0 || wallpaper.Slug == "" { + t.Fatalf("wallpaper identity = id %d hash %d slug %q, want seed ids", wallpaper.ID, wallpaper.AccessHash, wallpaper.Slug) + } + doc, ok := wallpaper.Document.(*tg.Document) + if !ok { + t.Fatalf("wallpaper document = %T, want *tg.Document", wallpaper.Document) + } + if doc.ID == 0 || doc.AccessHash == 0 || doc.Size == 0 || doc.MimeType == "" || doc.DCID != appearanceSeedDCID { + t.Fatalf("wallpaper document = id %d hash %d size %d mime %q dc %d, want downloadable seed document", + doc.ID, doc.AccessHash, doc.Size, doc.MimeType, doc.DCID) + } + if len(doc.Thumbs) == 0 { + t.Fatal("wallpaper document has no thumbnails") + } + if _, ok := doc.Thumbs[0].(*tg.PhotoSize); !ok { + t.Fatalf("wallpaper thumb = %T, want *tg.PhotoSize", doc.Thumbs[0]) + } + if _, ok := WallPapers(got.Hash).(*tg.AccountWallPapersNotModified); !ok { + t.Fatalf("WallPapers(hash) = %#v, want notModified", WallPapers(got.Hash)) + } +} + +func TestLookupWallPaperByIDAndSlug(t *testing.T) { + catalog := WallPapers(0).(*tg.AccountWallPapers) + first := catalog.Wallpapers[0].(*tg.WallPaper) + byID, ok := LookupWallPaper(&tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash}) + if !ok { + t.Fatal("LookupWallPaper(inputWallPaper) = false, want true") + } + if got := byID.(*tg.WallPaper).Slug; got != first.Slug { + t.Fatalf("LookupWallPaper(inputWallPaper).Slug = %q, want %q", got, first.Slug) + } + bySlug, ok := LookupWallPaper(&tg.InputWallPaperSlug{Slug: first.Slug}) + if !ok { + t.Fatal("LookupWallPaper(inputWallPaperSlug) = false, want true") + } + if got := bySlug.(*tg.WallPaper).ID; got != first.ID { + t.Fatalf("LookupWallPaper(inputWallPaperSlug).ID = %d, want %d", got, first.ID) + } + if _, ok := LookupWallPaper(&tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash + 1}); ok { + t.Fatal("LookupWallPaper(wrong access hash) = true, want false") + } + multi, ok := LookupWallPapers([]tg.InputWallPaperClass{ + &tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash}, + &tg.InputWallPaperSlug{Slug: catalog.Wallpapers[1].(*tg.WallPaper).Slug}, + }) + if !ok || len(multi) != 2 { + t.Fatalf("LookupWallPapers = len %d ok %v, want 2 true", len(multi), ok) + } +} + +func TestStarsRevenueStatsIsZeroBalanceCompatStub(t *testing.T) { + got := StarsRevenueStats(false) + if got == nil { + t.Fatal("StarsRevenueStats(false) = nil") + } + if _, ok := got.RevenueGraph.(*tg.StatsGraphError); !ok { + t.Fatalf("RevenueGraph = %T, want *tg.StatsGraphError", got.RevenueGraph) + } + if got.Status.WithdrawalEnabled { + t.Fatal("WithdrawalEnabled = true, want false") + } + if _, ok := got.Status.CurrentBalance.(*tg.StarsAmount); !ok { + t.Fatalf("CurrentBalance = %T, want *tg.StarsAmount", got.Status.CurrentBalance) + } + + ton := StarsRevenueStats(true) + if _, ok := ton.Status.CurrentBalance.(*tg.StarsTonAmount); !ok { + t.Fatalf("TON CurrentBalance = %T, want *tg.StarsTonAmount", ton.Status.CurrentBalance) + } +} + func TestCollectibleEmojiStatusesIsEmptyModifiedList(t *testing.T) { got, ok := CollectibleEmojiStatuses().(*tg.AccountEmojiStatuses) if !ok { @@ -105,13 +337,169 @@ func TestEmojiProfilePhotoGroupsIsEmptyModifiedList(t *testing.T) { } } -func TestConnectedBotsIsEmptyList(t *testing.T) { - got := ConnectedBots() - if got == nil { - t.Fatal("ConnectedBots() = nil") +func TestEmojiStatusGroupsIsEmptyModifiedList(t *testing.T) { + got, ok := EmojiStatusGroups().(*tg.MessagesEmojiGroups) + if !ok { + t.Fatalf("EmojiStatusGroups() = %T, want empty modified list", got) } - if len(got.ConnectedBots) != 0 || len(got.Users) != 0 { - t.Fatalf("ConnectedBots() = bots %d users %d, want empty vectors", len(got.ConnectedBots), len(got.Users)) + if got.Hash != 0 { + t.Fatalf("EmojiStatusGroups().Hash = %d, want 0", got.Hash) + } + if len(got.Groups) != 0 { + t.Fatalf("EmojiStatusGroups().Groups length = %d, want 0", len(got.Groups)) + } +} + +func TestQuickRepliesIsEmptyModifiedList(t *testing.T) { + got, ok := QuickReplies().(*tg.MessagesQuickReplies) + if !ok { + t.Fatalf("QuickReplies() = %T, want empty modified list", got) + } + if len(got.QuickReplies) != 0 || len(got.Messages) != 0 || len(got.Chats) != 0 || len(got.Users) != 0 { + t.Fatalf("QuickReplies() = shortcuts %d messages %d chats %d users %d, want empty vectors", + len(got.QuickReplies), len(got.Messages), len(got.Chats), len(got.Users)) + } +} + +func TestPeerColorsAreNonEmptyHashableAccentSets(t *testing.T) { + got, ok := PeerColors(0).(*tg.HelpPeerColors) + if !ok { + t.Fatalf("PeerColors(0) = %T, want modified colors", got) + } + if got.Hash == 0 || len(got.Colors) == 0 { + t.Fatalf("PeerColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors)) + } + if len(got.Colors) != 21 { + t.Fatalf("PeerColors(0).Colors length = %d, want seed palette count 21", len(got.Colors)) + } + withExplicitColors := 0 + for i, option := range got.Colors { + if !IsPeerColorID(option.ColorID) { + t.Fatalf("PeerColors()[%d].ColorID = %d, want supported id", i, option.ColorID) + } + colors, ok := option.GetColors() + if !ok { + if option.ColorID > 6 { + t.Fatalf("PeerColors()[%d].Colors missing for non-default id %d", i, option.ColorID) + } + continue + } + if _, ok := colors.(*tg.HelpPeerColorSet); !ok { + t.Fatalf("PeerColors()[%d].Colors = %T, want *tg.HelpPeerColorSet", i, colors) + } + withExplicitColors++ + } + if withExplicitColors == 0 { + t.Fatal("PeerColors() has no explicit seed color sets") + } + if _, ok := PeerColors(got.Hash).(*tg.HelpPeerColorsNotModified); !ok { + t.Fatalf("PeerColors(hash) = %#v, want notModified", PeerColors(got.Hash)) + } +} + +func TestPeerProfileColorsAreNonEmptyHashableProfileSets(t *testing.T) { + got, ok := PeerProfileColors(0).(*tg.HelpPeerColors) + if !ok { + t.Fatalf("PeerProfileColors(0) = %T, want modified colors", got) + } + if got.Hash == 0 || len(got.Colors) == 0 { + t.Fatalf("PeerProfileColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors)) + } + if len(got.Colors) != 16 { + t.Fatalf("PeerProfileColors(0).Colors length = %d, want seed profile palette count 16", len(got.Colors)) + } + for i, option := range got.Colors { + if !IsPeerProfileColorID(option.ColorID) { + t.Fatalf("PeerProfileColors()[%d].ColorID = %d, want supported id", i, option.ColorID) + } + groupMin, ok := option.GetGroupMinLevel() + if !ok || groupMin <= 0 || groupMin > maxPeerColorBoostLevel { + t.Fatalf("PeerProfileColors()[%d].group_min_level = %d ok %v, want bounded positive level", i, groupMin, ok) + } + if channelMin, ok := option.GetChannelMinLevel(); ok && (channelMin <= 0 || channelMin > maxPeerColorBoostLevel) { + t.Fatalf("PeerProfileColors()[%d].channel_min_level = %d, want bounded positive level", i, channelMin) + } + colors, ok := option.GetColors() + if !ok { + t.Fatalf("PeerProfileColors()[%d].Colors missing", i) + } + profile, ok := colors.(*tg.HelpPeerColorProfileSet) + if !ok { + t.Fatalf("PeerProfileColors()[%d].Colors = %T, want *tg.HelpPeerColorProfileSet", i, colors) + } + if len(profile.PaletteColors) == 0 || len(profile.BgColors) == 0 || len(profile.StoryColors) != 2 { + t.Fatalf("PeerProfileColors()[%d] = palette %d bg %d story %d, want usable profile palette", + i, len(profile.PaletteColors), len(profile.BgColors), len(profile.StoryColors)) + } + } + if _, ok := PeerProfileColors(got.Hash).(*tg.HelpPeerColorsNotModified); !ok { + t.Fatalf("PeerProfileColors(hash) = %#v, want notModified", PeerProfileColors(got.Hash)) + } +} + +func TestPeerProfileColorsKeepTDesktopBoostFeatureLevelsBounded(t *testing.T) { + got, ok := PeerProfileColors(0).(*tg.HelpPeerColors) + if !ok { + t.Fatalf("PeerProfileColors(0) = %T, want modified colors", got) + } + levels := make([]int, 0, len(got.Colors)) + lowestNonZeroLevel := math.MaxInt + for _, option := range got.Colors { + level, ok := option.GetGroupMinLevel() + if !ok { + level = 0 + } + levels = append(levels, level) + if level != 0 && level < lowestNonZeroLevel { + lowestNonZeroLevel = level + } + } + if lowestNonZeroLevel == math.MaxInt { + t.Fatal("profile color group levels are all zero; TDesktop would aggregate them at MaxInt in BoostBox") + } + maxFeatureLevel := 0 + for _, level := range levels { + if level < lowestNonZeroLevel { + level = lowestNonZeroLevel + } + if level > maxFeatureLevel { + maxFeatureLevel = level + } + } + if maxFeatureLevel <= 0 || maxFeatureLevel > maxPeerColorBoostLevel { + t.Fatalf("TDesktop profile feature max level = %d, want 1..%d", maxFeatureLevel, maxPeerColorBoostLevel) + } +} + +func TestPeerProfileColorsReturnsIndependentOptions(t *testing.T) { + first, ok := PeerProfileColors(0).(*tg.HelpPeerColors) + if !ok { + t.Fatalf("PeerProfileColors(0) first = %T, want modified colors", first) + } + second, ok := PeerProfileColors(0).(*tg.HelpPeerColors) + if !ok { + t.Fatalf("PeerProfileColors(0) second = %T, want modified colors", second) + } + if len(first.Colors) == 0 || len(second.Colors) == 0 { + t.Fatal("PeerProfileColors returned empty colors") + } + first.Colors[0].SetGroupMinLevel(maxPeerColorBoostLevel + 1) + if groupMin, ok := second.Colors[0].GetGroupMinLevel(); !ok || groupMin > maxPeerColorBoostLevel { + t.Fatalf("second PeerProfileColors group_min_level = %d ok %v, want independent bounded copy", groupMin, ok) + } + + firstSet, ok := first.Colors[0].Colors.(*tg.HelpPeerColorProfileSet) + if !ok || len(firstSet.PaletteColors) == 0 { + t.Fatalf("first profile colors = %T %+v, want profile set with palette", first.Colors[0].Colors, first.Colors[0].Colors) + } + secondSet, ok := second.Colors[0].Colors.(*tg.HelpPeerColorProfileSet) + if !ok || len(secondSet.PaletteColors) == 0 { + t.Fatalf("second profile colors = %T %+v, want profile set with palette", second.Colors[0].Colors, second.Colors[0].Colors) + } + original := secondSet.PaletteColors[0] + firstSet.PaletteColors[0] = original + 1 + if secondSet.PaletteColors[0] != original { + t.Fatalf("second profile palette mutated through first response: got %d want %d", secondSet.PaletteColors[0], original) } } diff --git a/internal/config/config.go b/internal/config/config.go index 4e9ab4c0..09a817bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,18 +2,25 @@ package config import ( - "net" + "bufio" + "fmt" "os" "strconv" "strings" "time" ) +const defaultConfigFile = ".env" + // Config 是 telesrv 的运行配置。 type Config struct { // ListenAddr 是 MTProto TCP 监听地址。 // 需与 TDesktop patch 指向的自建 DC 地址/端口一致(记录于 docs/tdesktop-patch-notes.md)。 ListenAddr string + // WebSocketEnable 在同一端口启用 MTProto-over-WebSocket 分流(WebA/telegram-tt)。 + WebSocketEnable bool + // WebSocketAllowedOrigins 是允许浏览器发起 WS upgrade 的页面 origin;"*" 仅用于临时调试。 + WebSocketAllowedOrigins []string // AdvertiseIP 是写入 help.getConfig DCOptions 的对外可达 IP(客户端据此连接本 DC)。 AdvertiseIP string // RSAKeyPath 是 server RSA 私钥的 PEM 路径;不存在时自动生成。 @@ -21,6 +28,24 @@ type Config struct { // DC 是本 server 的 DC ID。 DC int + // DebugAddr 是 net/http/pprof 调试端点监听地址(CPU/heap/goroutine/mutex/block 剖析)。 + // telesrv 是宿主进程、不在 docker 内,docker stats 看不到它,性能定位主要靠此端点。 + // 默认仅绑 127.0.0.1,避免 profile 数据对外暴露;置空关闭。生产需远程抓取时走 SSH 隧道, + // 不要改成 0.0.0.0。 + DebugAddr string + // BotAPIAddr 是最小 HTTP Bot API 网关监听地址;为空关闭。该网关复用 MTProto + // app/store 事实源,不维护独立 bot 状态。 + BotAPIAddr string + // AdminAPIAddr 是 telesrv 进程内管理写 API 监听地址;为空关闭。 + AdminAPIAddr string + // AdminAPIToken 是 Admin API bearer token;开启 AdminAPIAddr 时必须显式配置。 + AdminAPIToken string + // Admin UI 独立进程配置项保留在统一配置中,cmd/telesrv-admin 也按同名 env 读取。 + AdminUIAddr string + AdminUIPassword string + AdminUIToken string + AdminSessionKey string + // PostgresDSN 是业务数据(auth_key / user / authorization 等)持久化的 PostgreSQL 连接串。 // 依赖由 deploy/docker-compose.yml 启动;职责划分见 docs/persistence-layer.md。 PostgresDSN string @@ -38,6 +63,26 @@ type Config struct { // DevAuthCode 是开发固定验证码;生产短信/风控不在当前范围内。 DevAuthCode string + // MapboxToken 是服务端代理地图缩略图(upload.getWebFile)请求 Mapbox Static Images API + // 的 access token;为空则关闭代理、回退确定性占位图。客户端选点器 token 经 appConfig + // `tdesktop_config_map` 下发(同源运行时配置)。 + MapboxToken string + // MapTileCacheDir 是已抓取地图缩略图的磁盘缓存目录(保证分片续传字节一致 + 控制配额消耗)。 + MapTileCacheDir string + // ExternalMediaEnable 控制是否启用外链媒体抓取(inputMediaPhoto/DocumentExternal)。 + // 默认开启:服务端 SSRF 安全抓取用户 URL 并铸造 Photo/Document(含 SSRF 防护/大小/限速)。 + ExternalMediaEnable bool + // ExternalMediaMaxBytes 是单次外链抓取响应体上限;<=0 用默认 10MB。 + ExternalMediaMaxBytes int64 + // ExternalMediaRatePerMin 是全局每分钟外链抓取上限(防放大攻击);<=0 用默认。 + ExternalMediaRatePerMin int + // WebPagePreviewEnable 控制是否启用链接预览抓取(messages.getWebPagePreview / 发送时挂卡片)。 + // 默认开启:服务端 SSRF 安全抓取消息内 URL,解析 OG/Twitter/标题元数据并铸造预览卡片。 + WebPagePreviewEnable bool + // WebPagePreviewMaxBytes 是单次链接预览抓取响应体上限(HTML 与预览图共用);<=0 用默认 5MB。 + WebPagePreviewMaxBytes int64 + // WebPagePreviewRatePerMin 是全局每分钟链接预览抓取上限;<=0 用默认。一次解析最多 2 次上游。 + WebPagePreviewRatePerMin int // LangPackSeedDir 是 TDesktop 语言包 .strings 种子目录。 LangPackSeedDir string // BlobDir 是本地磁盘 blob backend 根目录(媒体文件字节内容)。 @@ -46,6 +91,28 @@ type Config struct { StickerSeedDir string // StickerSeedMaxSets 限制导入的常规贴纸集数量(避免启动时导入过多包),<=0 表示不限。 StickerSeedMaxSets int + // BusinessAIProvider 控制服务端 Business automation 回复生成器。 + // 空值/"echo" 回显触发私聊文本,用于跑通后续 AI provider 链路; + // "template" 使用 quick reply 模板。 + BusinessAIProvider string + // TempKeyResolveCacheMaxEntries 是 Router temp→perm 解析缓存容量。 + TempKeyResolveCacheMaxEntries int + + // ChannelRowCacheMaxEntries 是「共享频道行」进程内缓存容量(channelID→domain.Channel)。 + // 由 channels 表 LISTEN/NOTIFY 触发器实时失效(强一致、零 TTL)。<=0 禁用缓存与监听。 + ChannelRowCacheMaxEntries int + // ChannelMemberCacheMaxEntries 是频道成员/访问态 read-model 缓存容量((channelID,userID)→member)。 + // 由 read_model_versions 统一通知实时失效。<=0 禁用缓存。 + ChannelMemberCacheMaxEntries int + // ChannelDialogCacheMaxEntries 是频道 dialog 读投影缓存容量((viewerUserID,channelID)→dialog)。 + // 由 channel_base/channel_member/dialog_light 统一通知实时失效。<=0 禁用缓存。 + ChannelDialogCacheMaxEntries int + // ChannelBoostCacheMaxEntries 是频道 boost read-model 缓存容量,覆盖当前用户 + // SelfBoostsApplied 与频道总 active boost 数两类投影。写入 channel_boost_slots + // 时精确失效,TTL 兜底自然过期。<=0 禁用缓存。 + ChannelBoostCacheMaxEntries int + // ChannelBoostCacheTTL 是 boost 读投影在未收到写侧通知时的最大陈旧窗口。 + ChannelBoostCacheTTL time.Duration // OutboxWorkers 是并发 claim 的 outbox worker 数。默认 1,保证同一用户 pts update // 在线投递顺序与持久化顺序一致;后续需要吞吐时应改成按 target_user_id 分片的串行 worker。 @@ -60,163 +127,361 @@ type Config struct { OutboxLeaseTimeout time.Duration // OutboundPushTimeout 是 best-effort updates 推送等待 outbound 队列接受的最长时间。 OutboundPushTimeout time.Duration + // SendRateLimit 是账号级发送窗口内允许的消息条数;<=0 表示关闭发送限流。 + SendRateLimit int + // SendRateWindow 是发送限流窗口。 + SendRateWindow time.Duration + // CatchupRateLimit 是 difference 类 catch-up RPC(getChannelDifference / getPeerDialogs) + // 每用户每窗口允许的次数;<=0 关闭(设计 fan-out Phase 2 / §10.3,放开大群 nudge 全速前置)。 + CatchupRateLimit int + // CatchupRateWindow 是 catch-up 限流窗口。 + CatchupRateWindow time.Duration + // ChannelNudgeMaxTargets 是一次 fan-out >cap nudge 的目标上限;<=0 用内置默认。 + ChannelNudgeMaxTargets int // UpdateEventRetention 是 durable update log 保留期;只清理已被水位/state 覆盖的事件。 UpdateEventRetention time.Duration // RetentionInterval 是 retention worker 的运行间隔。 RetentionInterval time.Duration // RetentionBatch 是单次 retention 最多删除的行数。 RetentionBatch int + // UploadPartTTL 是未组装上传分片的保留期。 + UploadPartTTL time.Duration + // UploadPartGCInterval 是 upload_parts GC worker 的运行间隔。 + UploadPartGCInterval time.Duration + // UploadPartGCBatch 是单次 upload_parts GC 最多删除的行数。 + UploadPartGCBatch int + // UploadInFlightMaxBytes 是单用户未组装上传分片的字节上限;<=0 表示不限。 + UploadInFlightMaxBytes int64 + // UploadInFlightMaxParts 是单用户未组装上传分片行数上限;<=0 表示不限。 + UploadInFlightMaxParts int + // UploadInFlightMaxFiles 是单用户未组装 file_id 数上限;<=0 表示不限。 + UploadInFlightMaxFiles int + + // CallRingTimeout 是私聊通话服务端兜底超时(振铃/Accepted 悬挂),与下发给 + // 客户端的 callRingTimeoutMs(compat/tdesktop/config.go,90000ms)同源。 + CallRingTimeout time.Duration + // CallTombstoneTTL 是终态通话 tombstone 保留期(幂等/晚到 RPC 吸收窗口)。 + CallTombstoneTTL time.Duration + // CallMaxActivePerUser 是单用户并发非终态通话上限。 + CallMaxActivePerUser int + // CallSignalingMaxBytes 是 phone.sendSignalingData 单条载荷上限。 + CallSignalingMaxBytes int + // CallSignalingRate 是单通话每秒信令转发上限(超限静默丢弃)。 + CallSignalingRate int + // CallExpiryInterval 是通话超时兜底 dispatcher 的轮询间隔。 + CallExpiryInterval time.Duration + + // PremiumGrantMonths 是新注册账号默认赠送的会员月数;0 关闭赠送。 + // 存量账号的一次性赠送由迁移 0094 backfill,不受该配置影响。 + PremiumGrantMonths int + + // PasskeyRPID 是 passkey(WebAuthn) relying-party id(域名)。服务端据此校验 + // authData.rpIdHash;真机经 Android CredentialManager 时须与托管 assetlinks.json + // 的公网域名一致(详见 docs)。本地/软件 authenticator 验证用任意稳定值即可。 + PasskeyRPID string + // PasskeyAllowedOrigins 是允许的 WebAuthn origin 白名单;为空=不强校验 origin + //(服务端通常不预知 Android apk-key-hash origin)。 + PasskeyAllowedOrigins []string + // StarsStartingGrant 是 Stars 本地账本的起始余额(首读时惰性授予、granted 布尔幂等, + // 新老账号都覆盖、免回填迁移);0 关闭自动授予。 + StarsStartingGrant int64 + // PremiumSweepInterval 是会员到期 sweeper 的轮询间隔。premium 下发正确性 + // 由读取路径即时派生,sweeper 只负责清理过期行并推 updateUser 通知。 + PremiumSweepInterval time.Duration + // PremiumSweepBatch 是单次到期清理的最大行数。 + PremiumSweepBatch int + + // GroupCallCheckTTL 是群通话参与者保活水位的过期阈值(客户端 Connecting 态 + // 4s 一跳;M1 起 SFU liveness reporter 同样刷新该水位)。 + GroupCallCheckTTL time.Duration + // GroupCallSweepInterval 是幽灵参与者 sweeper 的轮询间隔。 + GroupCallSweepInterval time.Duration + // GroupCallMaxParticipants 是单房间参与者上限(演示规模)。 + GroupCallMaxParticipants int + + // TURNEnable 为 false 时私聊通话不下发中继(退回 P1 的 LAN 直连模式)。 + TURNEnable bool + // TURNUDPPort 是内嵌 TURN/STUN 的监听端口(独立于 SFU 端口,两者都要独占 + // 消费各自 socket 的 STUN 流量)。Windows 防火墙需放行。 + TURNUDPPort int + // TURNAdvertiseIP 是写进 phoneConnectionWebrtc 与 relay 分配的客户端可达 + // 地址,默认回落 SFUAdvertiseIP → AdvertiseIP。 + TURNAdvertiseIP string + // TURNSecret 是 TURN REST 凭据 HMAC 密钥;为空则进程级随机(单实例自洽, + // 多实例/外部 coturn 必须显式配置同一值)。 + TURNSecret string + // TURNRelayMinPort/TURNRelayMaxPort 限定 relay 分配端口段(防火墙放行范围)。 + TURNRelayMinPort int + TURNRelayMaxPort int + // CallTURNCredentialTTL 是按通话签发的 TURN 凭据有效期。 + CallTURNCredentialTTL time.Duration + // CallForceRelay 强制 p2p_allowed=false(调试 TURN 中继路径用)。 + CallForceRelay bool + + // SFUEnable 为 false 时群通话只走信令(M0 模式,无媒体)。 + SFUEnable bool + // SFUUDPPort 是内嵌 SFU 的单 UDP 端口(pion ICE UDPMux)。Windows 防火墙需放行。 + SFUUDPPort int + // SFUAdvertiseIP 是写进下行 candidate 的客户端可达地址,默认回落 AdvertiseIP。 + // ⚠ 127.0.0.1 会让真机 ICE 永远连不上且无任何 RPC 错误(纯媒体面静默失败)。 + SFUAdvertiseIP string } -// Load 从环境变量读取配置并填充默认值。第一阶段不做严格校验。 +// Load 从环境变量与可选配置文件读取配置并填充默认值。环境变量优先于配置文件。 func Load() (Config, error) { - advertiseIP := os.Getenv("TELESRV_ADVERTISE_IP") - if advertiseIP == "" { - advertiseIP = defaultAdvertiseIP() + fileEnv, err := loadConfigEnv() + if err != nil { + return Config{}, err } - cfg := Config{ - ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"), - AdvertiseIP: advertiseIP, - RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"), - DC: envIntOr("TELESRV_DC", 2), + envBoolOr := fileEnv.envBoolOr + envOr := fileEnv.envOr + envListOr := fileEnv.envListOr + envIntOr := fileEnv.envIntOr + envInt64Or := fileEnv.envInt64Or + envDurationOr := fileEnv.envDurationOr - PostgresDSN: envOr("TELESRV_POSTGRES_DSN", "postgres://telesrv:telesrv@localhost:5432/telesrv?sslmode=disable"), + cfg := Config{ + ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"), + WebSocketEnable: envBoolOr("TELESRV_WEBSOCKET_ENABLE", true), + WebSocketAllowedOrigins: envListOr("TELESRV_WEBSOCKET_ALLOWED_ORIGINS", []string{ + "http://localhost:1234", + "http://127.0.0.1:1234", + }), + // AdvertiseIP 当前不影响 help.getConfig——getConfig 返回空 DCOptions, + // 客户端使用其写死的 static DC 地址(见 compat/tdesktop/config.go)。 + // 字段与默认值保留,供未来需要显式下发 DC 地址时使用。 + AdvertiseIP: envOr("TELESRV_ADVERTISE_IP", "127.0.0.1"), + RSAKeyPath: envOr("TELESRV_RSA_KEY", "data/server_rsa.pem"), + DC: envIntOr("TELESRV_DC", 2), + DebugAddr: envOr("TELESRV_DEBUG_ADDR", "127.0.0.1:6060"), + BotAPIAddr: envOr("TELESRV_BOT_API_ADDR", ""), + AdminAPIAddr: envOr("TELESRV_ADMIN_API_ADDR", ""), + AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""), + AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2400"), + AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""), + AdminUIToken: envOr("TELESRV_ADMIN_UI_TOKEN", ""), + AdminSessionKey: envOr("TELESRV_ADMIN_SESSION_KEY", ""), + + // 用 127.0.0.1 而非 localhost:localhost 在 Windows 上会先解析到 IPv6 ::1,而 Docker + // Desktop 的端口转发只在 IPv4 监听,IPv6 连接要等 ~1s 超时才回退 IPv4(实测 localhost + // 建连 1.0s vs 127.0.0.1 6ms)。冷连接洪峰下池扩容的新连接各等 1s → pre-handler 惊群卡顿。 + // 生产由 TELESRV_POSTGRES_DSN 覆盖;该默认值仅作用于本地开发。 + PostgresDSN: envOr("TELESRV_POSTGRES_DSN", "postgres://telesrv:telesrv@127.0.0.1:5432/telesrv?sslmode=disable"), PostgresMaxConns: envIntOr("TELESRV_POSTGRES_MAX_CONNS", 50), PostgresMinConns: envIntOr("TELESRV_POSTGRES_MIN_CONNS", 16), - RedisAddr: envOr("TELESRV_REDIS_ADDR", "localhost:6399"), + RedisAddr: envOr("TELESRV_REDIS_ADDR", "127.0.0.1:6399"), // 同理避开 localhost→IPv6 回退延迟 RedisPassword: envOr("TELESRV_REDIS_PASSWORD", ""), RedisDB: envIntOr("TELESRV_REDIS_DB", 0), - DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"), - LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"), - BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"), - StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"), - StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 40), + DevAuthCode: envOr("TELESRV_DEV_AUTH_CODE", "12345"), + LangPackSeedDir: envOr("TELESRV_LANGPACK_SEED_DIR", "data/langpack"), + BlobDir: envOr("TELESRV_BLOB_DIR", "data/blobs"), + StickerSeedDir: envOr("TELESRV_STICKER_SEED_DIR", "data/sticker-seed"), + StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 200), + MapboxToken: envOr("TELESRV_MAPBOX_TOKEN", ""), + MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"), + ExternalMediaEnable: envBoolOr("TELESRV_EXTERNAL_MEDIA_ENABLE", true), + ExternalMediaMaxBytes: int64(envIntOr("TELESRV_EXTERNAL_MEDIA_MAX_BYTES", 10<<20)), + ExternalMediaRatePerMin: envIntOr("TELESRV_EXTERNAL_MEDIA_RATE_PER_MIN", 60), + WebPagePreviewEnable: envBoolOr("TELESRV_WEBPAGE_PREVIEW_ENABLE", true), + WebPagePreviewMaxBytes: int64(envIntOr("TELESRV_WEBPAGE_PREVIEW_MAX_BYTES", 5<<20)), + WebPagePreviewRatePerMin: envIntOr("TELESRV_WEBPAGE_PREVIEW_RATE_PER_MIN", 300), + BusinessAIProvider: envOr("TELESRV_BUSINESS_AI_PROVIDER", "echo"), + TempKeyResolveCacheMaxEntries: envIntOr("TELESRV_TEMP_KEY_CACHE_MAX_ENTRIES", 4096), + ChannelRowCacheMaxEntries: envIntOr("TELESRV_CHANNEL_ROW_CACHE_MAX", 50000), + ChannelMemberCacheMaxEntries: envIntOr("TELESRV_CHANNEL_MEMBER_CACHE_MAX", 100000), + ChannelDialogCacheMaxEntries: envIntOr("TELESRV_CHANNEL_DIALOG_CACHE_MAX", 100000), + ChannelBoostCacheMaxEntries: envIntOr("TELESRV_CHANNEL_BOOST_CACHE_MAX", 100000), + ChannelBoostCacheTTL: envDurationOr("TELESRV_CHANNEL_BOOST_CACHE_TTL", 10*time.Second), - OutboxWorkers: envIntOr("TELESRV_OUTBOX_WORKERS", 1), - OutboxBatch: envIntOr("TELESRV_OUTBOX_BATCH", 100), - OutboxInterval: envDurationOr("TELESRV_OUTBOX_INTERVAL", 200*time.Millisecond), - OutboxLeaseTimeout: envDurationOr("TELESRV_OUTBOX_LEASE_TIMEOUT", 30*time.Second), - OutboundPushTimeout: envDurationOr("TELESRV_OUTBOUND_PUSH_TIMEOUT", 200*time.Millisecond), - UpdateEventRetention: envDurationOr("TELESRV_UPDATE_EVENT_RETENTION", 168*time.Hour), - RetentionInterval: envDurationOr("TELESRV_RETENTION_INTERVAL", time.Hour), - RetentionBatch: envIntOr("TELESRV_RETENTION_BATCH", 10000), + OutboxWorkers: envIntOr("TELESRV_OUTBOX_WORKERS", 1), + OutboxBatch: envIntOr("TELESRV_OUTBOX_BATCH", 100), + OutboxInterval: envDurationOr("TELESRV_OUTBOX_INTERVAL", 200*time.Millisecond), + OutboxLeaseTimeout: envDurationOr("TELESRV_OUTBOX_LEASE_TIMEOUT", 30*time.Second), + OutboundPushTimeout: envDurationOr("TELESRV_OUTBOUND_PUSH_TIMEOUT", 200*time.Millisecond), + SendRateLimit: envIntOr("TELESRV_SEND_RATE_LIMIT", 30), + SendRateWindow: envDurationOr("TELESRV_SEND_RATE_WINDOW", time.Minute), + CatchupRateLimit: envIntOr("TELESRV_CATCHUP_RATE_LIMIT", 0), + CatchupRateWindow: envDurationOr("TELESRV_CATCHUP_RATE_WINDOW", time.Minute), + ChannelNudgeMaxTargets: envIntOr("TELESRV_CHANNEL_NUDGE_MAX_TARGETS", 0), + UpdateEventRetention: envDurationOr("TELESRV_UPDATE_EVENT_RETENTION", 168*time.Hour), + RetentionInterval: envDurationOr("TELESRV_RETENTION_INTERVAL", time.Hour), + RetentionBatch: envIntOr("TELESRV_RETENTION_BATCH", 10000), + UploadPartTTL: envDurationOr("TELESRV_UPLOAD_PART_TTL", 24*time.Hour), + UploadPartGCInterval: envDurationOr("TELESRV_UPLOAD_PART_GC_INTERVAL", 30*time.Minute), + UploadPartGCBatch: envIntOr("TELESRV_UPLOAD_PART_GC_BATCH", 10000), + UploadInFlightMaxBytes: envInt64Or("TELESRV_UPLOAD_INFLIGHT_MAX_BYTES", 4194304000), + UploadInFlightMaxParts: envIntOr("TELESRV_UPLOAD_INFLIGHT_MAX_PARTS", 8000), + UploadInFlightMaxFiles: envIntOr("TELESRV_UPLOAD_INFLIGHT_MAX_FILES", 64), + + CallRingTimeout: envDurationOr("TELESRV_CALL_RING_TIMEOUT", 90*time.Second), + CallTombstoneTTL: envDurationOr("TELESRV_CALL_TOMBSTONE_TTL", 60*time.Second), + CallMaxActivePerUser: envIntOr("TELESRV_CALL_MAX_ACTIVE_PER_USER", 4), + CallSignalingMaxBytes: envIntOr("TELESRV_CALL_SIGNALING_MAX_BYTES", 65536), + CallSignalingRate: envIntOr("TELESRV_CALL_SIGNALING_RATE", 50), + CallExpiryInterval: envDurationOr("TELESRV_CALL_EXPIRY_INTERVAL", time.Second), + + PremiumGrantMonths: envIntOr("TELESRV_PREMIUM_GRANT_MONTHS", 3), + PasskeyRPID: envOr("TELESRV_PASSKEY_RP_ID", "telesrv.net"), + PasskeyAllowedOrigins: envListOr("TELESRV_PASSKEY_ALLOWED_ORIGINS", nil), + StarsStartingGrant: int64(envIntOr("TELESRV_STARS_STARTING_GRANT", 1000)), + PremiumSweepInterval: envDurationOr("TELESRV_PREMIUM_SWEEP_INTERVAL", time.Minute), + PremiumSweepBatch: envIntOr("TELESRV_PREMIUM_SWEEP_BATCH", 500), + + GroupCallCheckTTL: envDurationOr("TELESRV_GROUPCALL_CHECK_TTL", 45*time.Second), + GroupCallSweepInterval: envDurationOr("TELESRV_GROUPCALL_SWEEP_INTERVAL", 10*time.Second), + GroupCallMaxParticipants: envIntOr("TELESRV_GROUPCALL_MAX_PARTICIPANTS", 32), + + TURNEnable: envBoolOr("TELESRV_TURN_ENABLE", true), + TURNUDPPort: envIntOr("TELESRV_TURN_UDP_PORT", 12400), + TURNAdvertiseIP: envOr("TELESRV_TURN_ADVERTISE_IP", ""), + TURNSecret: envOr("TELESRV_TURN_SECRET", ""), + TURNRelayMinPort: envIntOr("TELESRV_TURN_RELAY_MIN_PORT", 12500), + TURNRelayMaxPort: envIntOr("TELESRV_TURN_RELAY_MAX_PORT", 12999), + CallTURNCredentialTTL: envDurationOr("TELESRV_CALL_TURN_CREDENTIAL_TTL", 6*time.Hour), + CallForceRelay: envBoolOr("TELESRV_CALL_FORCE_RELAY", false), + + SFUEnable: envBoolOr("TELESRV_SFU_ENABLE", true), + SFUUDPPort: envIntOr("TELESRV_SFU_UDP_PORT", 12399), + SFUAdvertiseIP: envOr("TELESRV_SFU_ADVERTISE_IP", ""), } return cfg, nil } -func defaultAdvertiseIP() string { - if ip := detectAdvertiseIP(); ip != "" { - return ip - } - return "127.0.0.1" -} +type envSource map[string]string -func detectAdvertiseIP() string { - ifaces, err := net.Interfaces() +func loadConfigEnv() (envSource, error) { + path, explicit := os.LookupEnv("TELESRV_CONFIG") + if !explicit { + path = defaultConfigFile + } + path = strings.TrimSpace(path) + if path == "" { + return nil, nil + } + env, err := readEnvFile(path) if err != nil { - return "" + if !explicit && os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("config file %q: %w", path, err) } - var physical []net.IP - var virtual []net.IP - for _, iface := range ifaces { - if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + return env, nil +} + +func readEnvFile(path string) (envSource, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + + values := make(envSource) + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for lineNo := 1; scanner.Scan(); lineNo++ { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { continue } - addrs, err := iface.Addrs() - if err != nil { - continue + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + key, value, ok := strings.Cut(line, "=") + if !ok { + return nil, fmt.Errorf("line %d: expected KEY=VALUE", lineNo) } - for _, addr := range addrs { - ip := ipFromAddr(addr) - if ip == nil || !isPrivateIPv4(ip) { - continue - } - if likelyVirtualInterface(iface.Name) { - virtual = append(virtual, ip) - continue - } - physical = append(physical, ip) + key = strings.TrimSpace(key) + if !strings.HasPrefix(key, "TELESRV_") || !validEnvKey(key) { + return nil, fmt.Errorf("line %d: unsupported key %q; use TELESRV_* keys", lineNo, key) } + value = strings.TrimSpace(value) + if unquoted, ok := unquoteEnvValue(value); ok { + value = unquoted + } + values[key] = value } - if ip := preferredAdvertiseIP(physical); ip != "" { - return ip + if err := scanner.Err(); err != nil { + return nil, err } - return preferredAdvertiseIP(virtual) + return values, nil } -func ipFromAddr(addr net.Addr) net.IP { - switch v := addr.(type) { - case *net.IPNet: - return v.IP.To4() - case *net.IPAddr: - return v.IP.To4() - default: - return nil - } -} - -func preferredAdvertiseIP(ips []net.IP) string { - for _, ip := range ips { - if v4 := ip.To4(); v4 != nil && v4[0] == 10 { - return v4.String() - } - } - for _, ip := range ips { - if v4 := ip.To4(); v4 != nil && v4[0] == 192 && v4[1] == 168 { - return v4.String() - } - } - for _, ip := range ips { - if v4 := ip.To4(); v4 != nil && v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 { - return v4.String() - } - } - if len(ips) == 0 { - return "" - } - return ips[0].String() -} - -func isPrivateIPv4(ip net.IP) bool { - v4 := ip.To4() - if v4 == nil { +func validEnvKey(key string) bool { + if key == "" { return false } - return v4[0] == 10 || - (v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31) || - (v4[0] == 192 && v4[1] == 168) + for i, r := range key { + if r == '_' || ('A' <= r && r <= 'Z') || (i > 0 && '0' <= r && r <= '9') { + continue + } + return false + } + return true } -func likelyVirtualInterface(name string) bool { - name = strings.ToLower(name) - virtualMarkers := []string{ - "docker", - "hyper-v", - "tailscale", - "virtual", - "virtualbox", - "vethernet", - "vmware", - "wsl", +func unquoteEnvValue(value string) (string, bool) { + if len(value) < 2 { + return "", false } - for _, marker := range virtualMarkers { - if strings.Contains(name, marker) { + quote := value[0] + if quote != '"' && quote != '\'' { + return "", false + } + if value[len(value)-1] != quote { + return "", false + } + if quote == '\'' { + return value[1 : len(value)-1], true + } + unquoted, err := strconv.Unquote(value) + if err != nil { + return value[1 : len(value)-1], true + } + return unquoted, true +} + +func (e envSource) envBoolOr(key string, def bool) bool { + if v := e.envOr(key, ""); v != "" { + switch v { + case "1", "true", "TRUE", "True", "yes", "on": return true + case "0", "false", "FALSE", "False", "no", "off": + return false } } - return false + return def } -func envOr(key, def string) string { +func (e envSource) envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } + if v := e[key]; v != "" { + return v + } return def } -func envIntOr(key string, def int) int { - if v := os.Getenv(key); v != "" { +func (e envSource) envListOr(key string, def []string) []string { + v := e.envOr(key, "") + if v == "" { + return append([]string(nil), def...) + } + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + if len(out) == 0 { + return append([]string(nil), def...) + } + return out +} + +func (e envSource) envIntOr(key string, def int) int { + if v := e.envOr(key, ""); v != "" { if n, err := strconv.Atoi(v); err == nil { return n } @@ -224,9 +489,18 @@ func envIntOr(key string, def int) int { return def } +func (e envSource) envInt64Or(key string, def int64) int64 { + if v := e.envOr(key, ""); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n + } + } + return def +} + // envDurationOr 读取 time.ParseDuration 格式(如 "200ms"、"30s")的时长配置;解析失败回退默认值。 -func envDurationOr(key string, def time.Duration) time.Duration { - if v := os.Getenv(key); v != "" { +func (e envSource) envDurationOr(key string, def time.Duration) time.Duration { + if v := e.envOr(key, ""); v != "" { if d, err := time.ParseDuration(v); err == nil { return d } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0c460ec0..31b74ab3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,49 +1,124 @@ package config import ( - "net" + "os" + "path/filepath" "testing" + "time" ) -func TestLoadUsesExplicitAdvertiseIP(t *testing.T) { - t.Setenv("TELESRV_ADVERTISE_IP", "10.172.61.102") +func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) { + t.Setenv("TELESRV_ADVERTISE_IP", "") cfg, err := Load() if err != nil { t.Fatalf("Load: %v", err) } - if cfg.AdvertiseIP != "10.172.61.102" { + if cfg.AdvertiseIP != "127.0.0.1" { + t.Fatalf("AdvertiseIP = %q, want loopback default", cfg.AdvertiseIP) + } +} + +func TestLoadUsesExplicitAdvertiseIP(t *testing.T) { + t.Setenv("TELESRV_ADVERTISE_IP", "203.0.113.10") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.AdvertiseIP != "203.0.113.10" { t.Fatalf("AdvertiseIP = %q, want explicit env", cfg.AdvertiseIP) } } -func TestPreferredAdvertiseIPPrefersPhysicalLANRanges(t *testing.T) { - got := preferredAdvertiseIP([]net.IP{ - net.IPv4(172, 17, 0, 1), - net.IPv4(192, 168, 1, 20), - net.IPv4(10, 172, 61, 102), - }) - if got != "10.172.61.102" { - t.Fatalf("preferredAdvertiseIP = %q, want 10.172.61.102", got) - } +func TestLoadBusinessAIProvider(t *testing.T) { + t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "echo") - got = preferredAdvertiseIP([]net.IP{ - net.IPv4(172, 17, 0, 1), - net.IPv4(192, 168, 1, 20), - }) - if got != "192.168.1.20" { - t.Fatalf("preferredAdvertiseIP = %q, want 192.168.1.20", got) + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.BusinessAIProvider != "echo" { + t.Fatalf("BusinessAIProvider = %q, want echo", cfg.BusinessAIProvider) } } -func TestPrivateIPv4AndVirtualInterfaceDetection(t *testing.T) { - if !isPrivateIPv4(net.IPv4(10, 172, 61, 102)) { - t.Fatal("10.172.61.102 should be private") +func TestLoadBusinessAIProviderDefaultsToEcho(t *testing.T) { + t.Setenv("TELESRV_BUSINESS_AI_PROVIDER", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) } - if isPrivateIPv4(net.IPv4(8, 8, 8, 8)) { - t.Fatal("8.8.8.8 should not be private") - } - if !likelyVirtualInterface("vEthernet (WSL)") { - t.Fatal("vEthernet (WSL) should be treated as virtual") + if cfg.BusinessAIProvider != "echo" { + t.Fatalf("BusinessAIProvider = %q, want echo", cfg.BusinessAIProvider) + } +} + +func TestLoadReadsEnvStyleConfigFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "telesrv.env") + writeConfigFile(t, path, ` +TELESRV_MAPBOX_TOKEN="file-token" +TELESRV_POSTGRES_MAX_CONNS=77 +TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example +TELESRV_CALL_RING_TIMEOUT=2m +`) + t.Setenv("TELESRV_CONFIG", path) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.MapboxToken != "file-token" { + t.Fatalf("MapboxToken = %q, want file-token", cfg.MapboxToken) + } + if cfg.PostgresMaxConns != 77 { + t.Fatalf("PostgresMaxConns = %d, want 77", cfg.PostgresMaxConns) + } + if got, want := cfg.WebSocketAllowedOrigins, []string{"https://one.example", "https://two.example"}; len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("WebSocketAllowedOrigins = %#v, want %#v", got, want) + } + if cfg.CallRingTimeout != 2*time.Minute { + t.Fatalf("CallRingTimeout = %v, want 2m", cfg.CallRingTimeout) + } +} + +func TestLoadEnvironmentOverridesConfigFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "telesrv.env") + writeConfigFile(t, path, `TELESRV_MAPBOX_TOKEN=file-token`) + t.Setenv("TELESRV_CONFIG", path) + t.Setenv("TELESRV_MAPBOX_TOKEN", "env-token") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.MapboxToken != "env-token" { + t.Fatalf("MapboxToken = %q, want env-token", cfg.MapboxToken) + } +} + +func TestLoadExplicitMissingConfigFileErrors(t *testing.T) { + t.Setenv("TELESRV_CONFIG", filepath.Join(t.TempDir(), "missing.env")) + + if _, err := Load(); err == nil { + t.Fatal("Load succeeded with explicit missing config file, want error") + } +} + +func TestLoadRejectsNonTelesrvConfigKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "telesrv.env") + writeConfigFile(t, path, `MAPBOX_TOKEN=file-token`) + t.Setenv("TELESRV_CONFIG", path) + + if _, err := Load(); err == nil { + t.Fatal("Load succeeded with unsupported config key, want error") + } +} + +func writeConfigFile(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write config file: %v", err) } } diff --git a/internal/domain/account.go b/internal/domain/account.go index 8de71e5c..f66d5634 100644 --- a/internal/domain/account.go +++ b/internal/domain/account.go @@ -1,6 +1,9 @@ package domain -import "errors" +import ( + "errors" + "strings" +) var ( ErrPasswordHashInvalid = errors.New("password hash invalid") @@ -76,7 +79,10 @@ type PasswordSettings struct { Hint string EmailUnconfirmedPattern string RecoveryEmail string - LoginEmailPattern string + // LoginEmail 是已确认的登录邮箱地址(服务端私有,永不直接下发;下发的是掩码后的 + // LoginEmailPattern)。它独立于 2FA 恢复邮箱 RecoveryEmail:账号可只设登录邮箱而无 2FA。 + LoginEmail string + LoginEmailPattern string NewAlgo PasswordKDFAlgo NewSecureAlgo SecurePasswordKDFAlgo SecureRandom []byte @@ -142,3 +148,79 @@ func DefaultAccountReactionSettings() AccountReactionSettings { PaidPrivacy: PaidReactionPrivacy{Kind: PaidReactionPrivacyDefault}, } } + +// DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。 +const DefaultAccountTTLDays = 365 + +// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。 +// DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。 +type GlobalPrivacy struct { + ArchiveAndMuteNewNoncontactPeers bool + KeepArchivedUnmuted bool + KeepArchivedFolders bool + HideReadMarks bool + NewNoncontactPeersRequirePremium bool + DisplayGiftsButton bool + // NoncontactPeersPaidStars:非联系人给本人发消息所需 Stars 数。Stars 账本尚未实现, + // 此处仅做忠实持久化(往返不丢值),不参与计费逻辑。 + NoncontactPeersPaidStars int64 +} + +// AccountSettings 聚合账号级单例设置(每用户一行):全局隐私、账号自毁期限、 +// 敏感内容开关、联系人注册通知静音。对应 account.get/set GlobalPrivacySettings、 +// get/set AccountTTL、get/set ContentSettings、get/set ContactSignUpNotification。 +type AccountSettings struct { + GlobalPrivacy GlobalPrivacy + AccountTTLDays int + SensitiveContentEnabled bool + // ContactSignUpSilent 对应 account.setContactSignUpNotification 的 silent 形参: + // true=联系人注册时不通知本人。getContactSignUpNotification 直接返回该值。 + ContactSignUpSilent bool +} + +// DefaultAccountSettings 是未持久化时的账号设置默认值(与历史回显 stub 行为一致: +// 全局隐私全关、TTL 365 天、敏感内容关、联系人注册通知开启)。 +func DefaultAccountSettings() AccountSettings { + return AccountSettings{ + AccountTTLDays: DefaultAccountTTLDays, + } +} + +// NormalizedTTLDays 返回钳制后的账号自毁期限(0/越界回落默认)。 +func (s AccountSettings) NormalizedTTLDays() int { + if s.AccountTTLDays <= 0 { + return DefaultAccountTTLDays + } + return s.AccountTTLDays +} + +// MaskEmail 把邮箱地址按 Telegram pattern 习惯掩码(首尾各保留一位本地名,如 +// a***z@x.com),用于 account.password.login_email_pattern / auth.sentCodeTypeEmailCode +// 等只能暴露掩码的下发点。空串返回空串。 +func MaskEmail(email string) string { + if email == "" { + return "" + } + at := strings.Index(email, "@") + if at <= 1 { + return email + } + name := email[:at] + return name[:1] + "***" + name[len(name)-1:] + email[at:] +} + +// NormalizePhone 仅保留手机号中的数字(与 users.phone 的存储形态一致)。全部被过滤 +// 掉时返回原串,便于上层做 validPhone 拒绝。auth/account 两域共用同一规则避免漂移。 +func NormalizePhone(phone string) string { + var b strings.Builder + b.Grow(len(phone)) + for _, r := range phone { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + if b.Len() == 0 { + return phone + } + return b.String() +} diff --git a/internal/domain/admin.go b/internal/domain/admin.go new file mode 100644 index 00000000..b3d0beff --- /dev/null +++ b/internal/domain/admin.go @@ -0,0 +1,36 @@ +package domain + +import "time" + +type AdminCommandStatus string + +const ( + AdminCommandRunning AdminCommandStatus = "running" + AdminCommandCompleted AdminCommandStatus = "completed" + AdminCommandFailed AdminCommandStatus = "failed" +) + +type AdminCommand struct { + CommandID string + Actor string + Action string + TargetUserID int64 + TargetPeer Peer + DryRun bool + Reason string + RequestJSON []byte + ResultJSON []byte + Status AdminCommandStatus + Error string + CreatedAt time.Time + CompletedAt *time.Time +} + +type AccountSendRestriction struct { + UserID int64 + Frozen bool + Reason string + Actor string + CommandID string + UpdatedAt time.Time +} diff --git a/internal/domain/authorization.go b/internal/domain/authorization.go index f74bc9d1..b734dce3 100644 --- a/internal/domain/authorization.go +++ b/internal/domain/authorization.go @@ -15,6 +15,9 @@ type Authorization struct { APIID int AppVersion string IP string - CreatedAt time.Time - ActiveAt time.Time + // PasswordPending 表示该 auth_key 已通过短信验证码、但账号开启了两步验证且尚未通过 + // auth.checkPassword。此状态下业务鉴权须视其为未登录,仅允许继续完成两步验证。 + PasswordPending bool + CreatedAt time.Time + ActiveAt time.Time } diff --git a/internal/domain/boost.go b/internal/domain/boost.go new file mode 100644 index 00000000..2191d6d5 --- /dev/null +++ b/internal/domain/boost.go @@ -0,0 +1,291 @@ +package domain + +import ( + "errors" + "fmt" + "sort" +) + +const ( + // DefaultPremiumBoostSlotID is the single base boost slot every active premium + // user owns in the current small-scale implementation. + DefaultPremiumBoostSlotID = 1 + // MaxPremiumBoostSlotsPerApply bounds premium.applyBoost slots before storage. + MaxPremiumBoostSlotsPerApply = 16 + // MaxPremiumBoostsListLimit bounds premium.getBoostsList pages. + MaxPremiumBoostsListLimit = 100 + // MaxPremiumBoostsOffsetBytes bounds opaque boost pagination cursors. + MaxPremiumBoostsOffsetBytes = 128 + // MaxChannelBoostsToUnblockRestrictions matches Layer 225 TDesktop UI bounds. + MaxChannelBoostsToUnblockRestrictions = 8 + // MaxDefaultPremiumBoostLevel bounds the built-in level table so status replies + // never expose unbounded next-level values to clients. + MaxDefaultPremiumBoostLevel = 100 + // DefaultPremiumBoostReassignCooldownSeconds keeps the current dev behavior of + // allowing immediate moves while preserving a single hook for official cooldowns. + DefaultPremiumBoostReassignCooldownSeconds = 0 +) + +var ( + ErrBoostNotModified = errors.New("boost not modified") + + defaultPremiumBoostLevelPolicy = PremiumBoostLevelPolicy{ + Thresholds: linearPremiumBoostThresholds(MaxDefaultPremiumBoostLevel), + } +) + +// PremiumBoostSlot describes one user-owned boost slot without TL types. +type PremiumBoostSlot struct { + UserID int64 + Slot int + Peer Peer + Date int + Expires int + CooldownUntil int + Multiplier int + Gift bool + Giveaway bool + Unclaimed bool + GiveawayMsgID int + UsedGiftSlug string + Stars int64 +} + +// Active reports whether the slot can currently count for a peer. +func (s PremiumBoostSlot) Active(now int) bool { + if s.UserID == 0 || s.Slot <= 0 { + return false + } + return s.Expires == 0 || s.Expires > now +} + +// Assigned reports whether the active slot is currently applied to a peer. +func (s PremiumBoostSlot) Assigned(now int) bool { + return s.Active(now) && s.Peer.ID != 0 +} + +// Weight returns the boost contribution of one active assigned slot. +func (s PremiumBoostSlot) Weight(now int) int { + if !s.Assigned(now) { + return 0 + } + if s.Multiplier <= 0 { + return 1 + } + return s.Multiplier +} + +// PremiumBoostStatus is the domain projection behind premium.boostsStatus. +type PremiumBoostStatus struct { + Peer Peer + Level int + CurrentLevelBoosts int + Boosts int + GiftBoosts int + NextLevelBoosts int + HasNextLevelBoosts bool + PremiumAudiencePart int + PremiumAudienceTotal int + MyBoostSlots []PremiumBoostSlot +} + +// PremiumBoostList is a bounded admin-visible boosts page. +type PremiumBoostList struct { + Count int + Boosts []PremiumBoostSlot + Users []User + NextOffset string +} + +// PremiumMyBoosts is the current user's slot inventory. +type PremiumMyBoosts struct { + Slots []PremiumBoostSlot + Users []User + Channels []Channel +} + +// PremiumBoostLevelPolicy maps cumulative active boosts to channel boost levels. +// Thresholds[n-1] is the minimum boost count required for level n. +type PremiumBoostLevelPolicy struct { + Thresholds []int +} + +// DefaultPremiumBoostLevelPolicy returns the current bounded telesrv level table. +// It is intentionally policy-backed: production deployments can replace the table +// with official thresholds without touching store/RPC state transitions. +func DefaultPremiumBoostLevelPolicy() PremiumBoostLevelPolicy { + return PremiumBoostLevelPolicy{ + Thresholds: append([]int(nil), defaultPremiumBoostLevelPolicy.Thresholds...), + } +} + +// NewPremiumBoostLevelPolicy creates a monotonic threshold policy from raw values. +func NewPremiumBoostLevelPolicy(thresholds []int) PremiumBoostLevelPolicy { + return PremiumBoostLevelPolicy{Thresholds: normalizePremiumBoostThresholds(thresholds)} +} + +// LevelForBoosts returns level bounds for the given active boost count. +func (p PremiumBoostLevelPolicy) LevelForBoosts(boosts int) (level, currentLevelBoosts, nextLevelBoosts int, hasNext bool) { + if boosts < 0 { + boosts = 0 + } + thresholds := p.thresholds() + if len(thresholds) == 0 { + return 0, 0, 0, false + } + level = sort.Search(len(thresholds), func(i int) bool { return thresholds[i] > boosts }) + if level > 0 { + currentLevelBoosts = thresholds[level-1] + } + if level < len(thresholds) { + return level, currentLevelBoosts, thresholds[level], true + } + return len(thresholds), thresholds[len(thresholds)-1], 0, false +} + +func (p PremiumBoostLevelPolicy) thresholds() []int { + if len(p.Thresholds) == 0 { + return defaultPremiumBoostLevelPolicy.Thresholds + } + last := 0 + for _, threshold := range p.Thresholds { + if threshold <= 0 || threshold <= last { + return normalizePremiumBoostThresholds(p.Thresholds) + } + last = threshold + } + return p.Thresholds +} + +func normalizePremiumBoostThresholds(thresholds []int) []int { + if len(thresholds) == 0 { + return nil + } + sorted := make([]int, 0, len(thresholds)) + for _, threshold := range thresholds { + if threshold > 0 { + sorted = append(sorted, threshold) + } + } + sort.Ints(sorted) + out := sorted[:0] + last := 0 + for _, threshold := range sorted { + if threshold == last { + continue + } + out = append(out, threshold) + last = threshold + } + return append([]int(nil), out...) +} + +func linearPremiumBoostThresholds(maxLevel int) []int { + if maxLevel <= 0 { + return nil + } + out := make([]int, maxLevel) + for i := range out { + out[i] = i + 1 + } + return out +} + +// PremiumBoostStatusForCount returns the bounded default level projection. +func PremiumBoostStatusForCount(peer Peer, boosts int, my []PremiumBoostSlot) PremiumBoostStatus { + return PremiumBoostStatusForPolicy(peer, boosts, my, defaultPremiumBoostLevelPolicy) +} + +// PremiumBoostStatusForPolicy returns a status projection from an explicit level policy. +func PremiumBoostStatusForPolicy(peer Peer, boosts int, my []PremiumBoostSlot, policy PremiumBoostLevelPolicy) PremiumBoostStatus { + if boosts < 0 { + boosts = 0 + } + level, current, next, hasNext := policy.LevelForBoosts(boosts) + return PremiumBoostStatus{ + Peer: peer, + Level: level, + CurrentLevelBoosts: current, + Boosts: boosts, + NextLevelBoosts: next, + HasNextLevelBoosts: hasNext, + MyBoostSlots: append([]PremiumBoostSlot(nil), my...), + } +} + +// ApplyPremiumBoostSlot applies one user-owned slot to a peer and enforces shared +// lifecycle rules for all store implementations. +func ApplyPremiumBoostSlot(slot PremiumBoostSlot, userID int64, slotID int, peer Peer, now, premiumUntil, cooldownSeconds int) (PremiumBoostSlot, bool, error) { + if userID == 0 || slotID <= 0 || peer.Type != PeerTypeChannel || peer.ID == 0 || now < 0 { + return PremiumBoostSlot{}, false, ErrChannelInvalid + } + if premiumUntil <= now { + return PremiumBoostSlot{}, false, ErrPremiumRequired + } + if cooldownSeconds < 0 { + cooldownSeconds = 0 + } + if slot.UserID == 0 || slot.Slot == 0 || !slot.Active(now) { + slot = PremiumBoostSlot{ + UserID: userID, + Slot: slotID, + Multiplier: 1, + } + } + if slot.UserID != userID || slot.Slot != slotID { + return PremiumBoostSlot{}, false, ErrChannelInvalid + } + if slot.Multiplier <= 0 { + slot.Multiplier = 1 + } + if slot.Assigned(now) && slot.Peer == peer { + if slot.Expires != 0 && slot.Expires != premiumUntil { + slot.Expires = premiumUntil + return slot, true, nil + } + return slot, false, ErrBoostNotModified + } + if slot.Assigned(now) && slot.Peer != peer && slot.CooldownUntil > now { + return slot, false, NewPremiumBoostFloodWaitError(slot.CooldownUntil - now) + } + wasAssigned := slot.Assigned(now) + slot.Peer = peer + slot.Date = now + slot.Expires = premiumUntil + if wasAssigned && cooldownSeconds > 0 { + slot.CooldownUntil = now + cooldownSeconds + } else if slot.CooldownUntil <= now { + slot.CooldownUntil = 0 + } + return slot, true, nil +} + +// PremiumBoostFloodWaitError carries remaining seconds for boost reassign cooldowns. +type PremiumBoostFloodWaitError struct { + Seconds int +} + +func (e PremiumBoostFloodWaitError) Error() string { + if e.Seconds <= 0 { + return "premium boost flood wait" + } + return fmt.Sprintf("premium boost flood wait %d seconds", e.Seconds) +} + +func NewPremiumBoostFloodWaitError(seconds int) error { + if seconds <= 0 { + seconds = 1 + } + return PremiumBoostFloodWaitError{Seconds: seconds} +} + +func PremiumBoostFloodWaitSeconds(err error) (int, bool) { + var wait PremiumBoostFloodWaitError + if !errors.As(err, &wait) { + return 0, false + } + if wait.Seconds <= 0 { + return 1, true + } + return wait.Seconds, true +} diff --git a/internal/domain/boost_test.go b/internal/domain/boost_test.go new file mode 100644 index 00000000..716644fc --- /dev/null +++ b/internal/domain/boost_test.go @@ -0,0 +1,93 @@ +package domain + +import ( + "errors" + "testing" +) + +func TestPremiumBoostStatusDefaultPolicyIsBoundedLinear(t *testing.T) { + peer := Peer{Type: PeerTypeChannel, ID: 100} + + empty := PremiumBoostStatusForCount(peer, 0, nil) + if empty.Level != 0 || empty.CurrentLevelBoosts != 0 || empty.Boosts != 0 || empty.NextLevelBoosts != 1 || !empty.HasNextLevelBoosts { + t.Fatalf("empty status = %+v, want level 0 next 1", empty) + } + + one := PremiumBoostStatusForCount(peer, 1, nil) + if one.Level != 1 || one.CurrentLevelBoosts != 1 || one.Boosts != 1 || one.NextLevelBoosts != 2 || !one.HasNextLevelBoosts { + t.Fatalf("one boost status = %+v, want level 1 next 2", one) + } + + max := PremiumBoostStatusForCount(peer, MaxDefaultPremiumBoostLevel, nil) + if max.Level != MaxDefaultPremiumBoostLevel || max.CurrentLevelBoosts != MaxDefaultPremiumBoostLevel || max.HasNextLevelBoosts { + t.Fatalf("max status = %+v, want capped max level without next", max) + } + + over := PremiumBoostStatusForCount(peer, MaxDefaultPremiumBoostLevel+50, nil) + if over.Level != MaxDefaultPremiumBoostLevel || over.CurrentLevelBoosts != MaxDefaultPremiumBoostLevel || over.HasNextLevelBoosts { + t.Fatalf("over max status = %+v, want capped max level without next", over) + } +} + +func TestPremiumBoostStatusCustomPolicy(t *testing.T) { + policy := NewPremiumBoostLevelPolicy([]int{6, 1, 3, 3, 0}) + peer := Peer{Type: PeerTypeChannel, ID: 100} + + status := PremiumBoostStatusForPolicy(peer, 4, nil, policy) + if status.Level != 2 || status.CurrentLevelBoosts != 3 || status.NextLevelBoosts != 6 || !status.HasNextLevelBoosts { + t.Fatalf("custom policy status = %+v, want level 2 current 3 next 6", status) + } + + status = PremiumBoostStatusForPolicy(peer, 6, nil, policy) + if status.Level != 3 || status.CurrentLevelBoosts != 6 || status.HasNextLevelBoosts { + t.Fatalf("custom max status = %+v, want level 3 without next", status) + } +} + +func TestApplyPremiumBoostSlotLifecycle(t *testing.T) { + peer := Peer{Type: PeerTypeChannel, ID: 100} + slot, changed, err := ApplyPremiumBoostSlot(PremiumBoostSlot{}, 10, DefaultPremiumBoostSlotID, peer, 1000, 2000, 0) + if err != nil || !changed { + t.Fatalf("apply free slot changed=%v err=%v", changed, err) + } + if slot.UserID != 10 || slot.Slot != DefaultPremiumBoostSlotID || slot.Peer != peer || slot.Date != 1000 || slot.Expires != 2000 || slot.Multiplier != 1 { + t.Fatalf("applied slot = %+v", slot) + } + + _, changed, err = ApplyPremiumBoostSlot(slot, 10, DefaultPremiumBoostSlotID, peer, 1001, 2000, 0) + if !errors.Is(err, ErrBoostNotModified) || changed { + t.Fatalf("reapply same peer changed=%v err=%v, want ErrBoostNotModified", changed, err) + } + + extended, changed, err := ApplyPremiumBoostSlot(slot, 10, DefaultPremiumBoostSlotID, peer, 1002, 3000, 0) + if err != nil || !changed || extended.Expires != 3000 || extended.Date != slot.Date { + t.Fatalf("extend same peer slot=%+v changed=%v err=%v", extended, changed, err) + } + + shortened, changed, err := ApplyPremiumBoostSlot(extended, 10, DefaultPremiumBoostSlotID, peer, 1003, 2500, 0) + if err != nil || !changed || shortened.Expires != 2500 || shortened.Date != slot.Date { + t.Fatalf("refresh same peer expiry slot=%+v changed=%v err=%v", shortened, changed, err) + } +} + +func TestApplyPremiumBoostSlotCooldown(t *testing.T) { + first := Peer{Type: PeerTypeChannel, ID: 100} + second := Peer{Type: PeerTypeChannel, ID: 200} + slot := PremiumBoostSlot{ + UserID: 10, + Slot: DefaultPremiumBoostSlotID, + Peer: first, + Date: 1000, + Expires: 3000, + CooldownUntil: 1500, + Multiplier: 1, + } + + _, changed, err := ApplyPremiumBoostSlot(slot, 10, DefaultPremiumBoostSlotID, second, 1200, 3000, 0) + if changed || err == nil { + t.Fatalf("cooldown apply changed=%v err=%v, want flood wait", changed, err) + } + if seconds, ok := PremiumBoostFloodWaitSeconds(err); !ok || seconds != 300 { + t.Fatalf("cooldown err = %v seconds=%d ok=%v, want 300", err, seconds, ok) + } +} diff --git a/internal/domain/bot.go b/internal/domain/bot.go new file mode 100644 index 00000000..3e2aedbd --- /dev/null +++ b/internal/domain/bot.go @@ -0,0 +1,313 @@ +package domain + +import ( + "errors" + "strconv" + "strings" + "time" +) + +const ( + // MaxBotsPerOwner 是每个用户可创建的 bot 上限(对齐官方 appConfig bots_create_limit_default)。 + MaxBotsPerOwner = 20 + // BotTokenSecretLength 是 bot token 随机段长度(官方格式 :<35 位>)。 + BotTokenSecretLength = 35 + // BotUsernameSuffix 是 bot username 的强制后缀(大小写不敏感)。 + BotUsernameSuffix = "bot" + // MaxBotNameLength 是 bot 显示名(first_name)上限。 + MaxBotNameLength = 64 + // MaxBotInlinePlaceholderLen 是 inline mode placeholder 上限(bots.inline_placeholder)。 + MaxBotInlinePlaceholderLen = 128 + // MaxBotAppShortNameLen 是 bot app short_name 上限。 + MaxBotAppShortNameLen = 64 + // MaxBotAppTitleLen 是 bot app title 上限。 + MaxBotAppTitleLen = 128 + // MaxBotAppDescriptionLen 是 bot app description 上限。 + MaxBotAppDescriptionLen = 512 + // MaxBotAppURLLen 是 bot app / attach menu webview URL 上限。 + MaxBotAppURLLen = 512 + // MaxBotAttachMenuPeerTypes 限制 attach menu peer_types 数量,避免无界响应。 + MaxBotAttachMenuPeerTypes = 8 + // MaxBotAttachMenuIcons 限制 attach menu icon 变体数量。 + MaxBotAttachMenuIcons = 8 + // MaxBotPreviewMedia 是单 bot main mini app preview media 上限。 + MaxBotPreviewMedia = 20 + // MaxBotRequestedPeerQuantity 限制 request peer 一次选择数量。 + MaxBotRequestedPeerQuantity = 10 + // MaxBotCustomMethodLen 限制 WebView custom method 名称长度。 + MaxBotCustomMethodLen = 128 + // MaxBotCustomMethodPayloadLen 限制 WebView custom method JSON 载荷长度。 + MaxBotCustomMethodPayloadLen = 4096 +) + +// bot 业务错误。 +var ( + ErrBotTokenInvalid = errors.New("bot token invalid") + ErrBotsTooMany = errors.New("bot create limit exceeded") + ErrBotNameInvalid = errors.New("bot name invalid") + ErrBotUsernameInvalid = errors.New("bot username invalid") + ErrBotNotFound = errors.New("bot not found") + // ErrBotCommandInvalid 表示命令名/描述非法(bots.setBotCommands)。 + ErrBotCommandInvalid = errors.New("bot command invalid") + // ErrBotInfoInvalid 表示 setBotInfo 的 name/about/description 越界。 + ErrBotInfoInvalid = errors.New("bot info invalid") + // ErrBotMenuButtonInvalid 表示 menu button 文本/URL 非法。 + ErrBotMenuButtonInvalid = errors.New("bot menu button invalid") + // ErrBotInlinePlaceholderInvalid 表示 inline placeholder 为空以外的文本越界。 + ErrBotInlinePlaceholderInvalid = errors.New("bot inline placeholder invalid") + // ErrBotAppInvalid 表示 bot app 元数据、URL 或 access hash 非法。 + ErrBotAppInvalid = errors.New("bot app invalid") + // ErrBotAppShortNameInvalid 表示 bot app short_name 非法。 + ErrBotAppShortNameInvalid = errors.New("bot app short name invalid") + // ErrBotAttachMenuInvalid 表示 attach menu catalog 或用户状态非法。 + ErrBotAttachMenuInvalid = errors.New("bot attach menu invalid") + // ErrBotRequestedButtonInvalid 表示 request-peer button 上下文非法或已过期。 + ErrBotRequestedButtonInvalid = errors.New("bot requested button invalid") + // ErrBotDownloadParamsInvalid 表示 mini app download 参数未通过安全策略。 + ErrBotDownloadParamsInvalid = errors.New("bot download params invalid") + // ErrBotCustomMethodUnavailable 表示没有可完成 WebView custom method 的 bot 侧回答。 + ErrBotCustomMethodUnavailable = errors.New("bot custom method unavailable") + // ErrBotSessionsNotRevoked 表示 token 已轮换但撤销已登录 session 失败 + //(token 不可回滚,调用方须告知用户重试以确保旧 session 终止)。 + ErrBotSessionsNotRevoked = errors.New("bot sessions not revoked") +) + +const ( + // MaxBotCommands 是单个 bot 的命令上限(default scope)。 + MaxBotCommands = 100 + // MaxBotCommandLen 是命令名长度上限(不含前导 /)。 + MaxBotCommandLen = 32 + // MaxBotCommandDescriptionLen 是命令描述长度上限。 + MaxBotCommandDescriptionLen = 256 + // MaxBotAboutLen 是 bot about(users.about)长度上限。 + MaxBotAboutLen = 120 + // MaxBotDescriptionLen 是 bot description("What can this bot do?")长度上限。 + MaxBotDescriptionLen = 512 + // MaxBotMenuButtonTextLen / MaxBotMenuButtonURLLen 是菜单按钮文本/URL 上限。 + MaxBotMenuButtonTextLen = 64 + MaxBotMenuButtonURLLen = 512 +) + +// BotCommand 是 bot 的一条命令(botInfo.commands 元素)。 +type BotCommand struct { + Command string `json:"command"` + Description string `json:"description"` +} + +// BotMenuButtonType 标识菜单按钮类型。 +type BotMenuButtonType int + +const ( + // BotMenuButtonDefault 是默认(不显示特殊按钮,客户端按 commands 是否非空决定 '/' 圆钮)。 + BotMenuButtonDefault BotMenuButtonType = 0 + // BotMenuButtonCommands 显式让客户端展示命令菜单按钮。 + BotMenuButtonCommands BotMenuButtonType = 1 + // BotMenuButtonWebView 是带文本+URL 的 web view 按钮。 + BotMenuButtonWebView BotMenuButtonType = 2 +) + +// BotMenuButton 是 bot 的菜单按钮(per-bot 全局;per-user 维度 P2 不做)。 +type BotMenuButton struct { + Type BotMenuButtonType + Text string + URL string +} + +// BotInfoUpdate 是 bots.setBotInfo 的部分更新(name→users.first_name、 +// about→users.about、description→bots.description)。各 SetXxx 为 false 时不动该字段。 +type BotInfoUpdate struct { + SetName bool + Name string + SetAbout bool + About string + SetDescription bool + Description string +} + +// BotApp 是 mini app catalog 的一项。一个 bot 可拥有多个 app,ID/access_hash +// 在创建后稳定,short_name 在同一 bot 下唯一。 +type BotApp struct { + ID int64 + BotUserID int64 + ShortName string + Title string + Description string + URL string + PhotoID int64 + DocumentID int64 + AccessHash int64 + Hash int64 + Inactive bool + RequestWriteAccess bool + HasSettings bool + Main bool +} + +// BotAppSettings 是 BotInfo.app_settings 的协议中立表示。 +type BotAppSettings struct { + PlaceholderPath []byte + BackgroundColor int + BackgroundDarkColor int + HeaderColor int + HeaderDarkColor int + HasBackgroundColor bool + HasBackgroundDark bool + HasHeaderColor bool + HasHeaderDarkColor bool +} + +// BotAppPreviewMedia 是 main mini app preview media 的持久快照。 +type BotAppPreviewMedia struct { + ID int64 + BotUserID int64 + AppID int64 + Position int + PhotoID int64 + DocumentID int64 +} + +// BotAttachMenuIconColor 描述 attach menu icon 的主题色覆盖。 +type BotAttachMenuIconColor struct { + Name string + Color int +} + +// BotAttachMenuIcon 描述 attach menu 的平台 icon 文档引用。 +type BotAttachMenuIcon struct { + Name string + DocumentID int64 + Colors []BotAttachMenuIconColor +} + +// BotAttachMenuBot 是全局 attach/side menu catalog 的一项。 +type BotAttachMenuBot struct { + BotUserID int64 + AppID int64 + ShortName string + Inactive bool + HasSettings bool + RequestWriteAccess bool + ShowInAttachMenu bool + ShowInSideMenu bool + SideMenuDisclaimerNeeded bool + PeerTypes []string + Icons []BotAttachMenuIcon +} + +// BotAttachMenuState 是用户对某个 attach menu bot 的启用与写权限状态。 +type BotAttachMenuState struct { + UserID int64 + BotUserID int64 + Enabled bool + WriteAllowed bool +} + +// BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。 +type BotRequestedWebViewButton struct { + WebAppReqID string + BotUserID int64 + UserID int64 + ButtonID int + Text string + PeerType string + MaxQuantity int + CreatedAt time.Time + ExpiresAt time.Time +} + +// BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答 +// registry 时只记录查询并显式返回 blocked 错误,避免假成功。 +type BotWebViewCustomMethodQuery struct { + ID string + BotUserID int64 + UserID int64 + CustomMethod string + ParamsJSON string + CreatedAt time.Time + ExpiresAt time.Time +} + +// ValidBotCommandName 校验命令名:1-32 位小写字母/数字/下划线(不含前导 /)。 +func ValidBotCommandName(cmd string) bool { + if len(cmd) == 0 || len(cmd) > MaxBotCommandLen { + return false + } + for _, r := range cmd { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_': + default: + return false + } + } + return true +} + +// BotProfile 是 bots 表一行:bot 账号的元数据与 token。 +// 显示名/username/about 复用 users 行,不在此重复。 +type BotProfile struct { + BotUserID int64 + OwnerUserID int64 + TokenSecret string + Description string + Commands []BotCommand + ChatHistory bool // privacy mode 关闭 = true(bot_chat_history flag) + Nochats bool // true = 不允许加群(bot_nochats flag) + InlineGeo bool + InlinePlaceholder string + MenuButton BotMenuButton + HasMainApp bool + HasAttachMenu bool + HasPreviewMedias bool + AppSettings *BotAppSettings +} + +// BotChatState 是内置 bot(当前仅 BotFather)与某用户的对话状态机持久态。 +type BotChatState struct { + BotUserID int64 + UserID int64 + // Command 是进行中的主命令(newbot/token/revoke)。 + Command string `json:"command"` + // Step 是主命令内的当前步骤(name/username/choose)。 + Step string `json:"step"` + // Draft 暂存跨步骤的中间输入(如 newbot 已收的 name)。 + Draft map[string]string `json:"draft,omitempty"` +} + +// FormatBotToken 拼装完整 bot token(:)。 +func FormatBotToken(botUserID int64, secret string) string { + return strconv.FormatInt(botUserID, 10) + ":" + secret +} + +// ParseBotToken 拆解完整 bot token。格式非法时 ok=false(不区分具体原因, +// 调用方统一回 ACCESS_TOKEN_INVALID,避免泄漏存在性)。 +func ParseBotToken(token string) (botUserID int64, secret string, ok bool) { + idPart, secret, found := strings.Cut(token, ":") + if !found || idPart == "" || secret == "" { + return 0, "", false + } + id, err := strconv.ParseInt(idPart, 10, 64) + if err != nil || id <= 0 { + return 0, "", false + } + return id, secret, true +} + +// ValidBotUsername 校验 bot username:5-32 位、字母开头、[A-Za-z0-9_]、 +// 以 bot 结尾(大小写不敏感)。BotFather 等种子账号不经此校验。 +func ValidBotUsername(username string) bool { + if len(username) < 5 || len(username) > 32 { + return false + } + for i, r := range username { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9', r == '_': + if i == 0 { + return false + } + default: + return false + } + } + return strings.HasSuffix(strings.ToLower(username), BotUsernameSuffix) +} diff --git a/internal/domain/bot_inline.go b/internal/domain/bot_inline.go new file mode 100644 index 00000000..ba67cbfd --- /dev/null +++ b/internal/domain/bot_inline.go @@ -0,0 +1,63 @@ +package domain + +const ( + MaxBotInlineResults = 50 + MaxBotInlineResultIDLen = 64 + MaxBotInlineNextOffsetLen = 64 + MaxBotInlineSwitchTextLen = 256 + MaxBotInlineWebURLLen = 2048 + MaxBotInlineWebMimeLen = 128 + MaxBotInlineWebSize = 20 * 1024 * 1024 + MaxBotPreparedInlineIDLen = 128 +) + +type BotInlineResult struct { + ID string + Type string + Title string + Description string + URL string + Thumb *BotInlineWebDocument + Content *BotInlineWebDocument + Message string + Entities []MessageEntity + ReplyMarkup *MessageReplyMarkup + NoWebpage bool + MediaAuto bool + Media *MessageMedia +} + +type BotInlineWebDocument struct { + URL string + AccessHash int64 + Size int + MimeType string + Attributes []DocumentAttribute +} + +type BotInlineResults struct { + QueryID int64 + BotUserID int64 + UserID int64 + Peer Peer + Query string + Geo *MessageGeoPoint + Gallery bool + Private bool + Results []BotInlineResult + CacheTime int + NextOffset string + SwitchPM *BotInlineSwitchPM + SwitchWeb *BotInlineSwitchWebView + PeerTypes []string +} + +type BotInlineSwitchPM struct { + Text string + StartParam string +} + +type BotInlineSwitchWebView struct { + Text string + URL string +} diff --git a/internal/domain/business.go b/internal/domain/business.go new file mode 100644 index 00000000..de56d787 --- /dev/null +++ b/internal/domain/business.go @@ -0,0 +1,302 @@ +package domain + +import ( + "errors" + "strings" + "unicode/utf8" +) + +const ( + MaxBusinessWorkHourIntervals = 28 + MaxBusinessRecipientUsers = 100 + MaxBusinessChatLinks = 100 + MaxBusinessChatLinkMessage = MaxMessageTextLength + MaxBusinessChatLinkTitle = 64 + MaxQuickReplies = 100 + MaxQuickReplyMessages = 20 + MaxQuickReplyShortcutLength = 32 + BusinessAwayCooldownSeconds = 24 * 60 * 60 +) + +var ( + ErrBusinessProfileInvalid = errors.New("business profile invalid") + ErrBusinessChatLinkInvalid = errors.New("business chat link invalid") + ErrBusinessChatLinkNotFound = errors.New("business chat link not found") + ErrBusinessChatLinksTooMuch = errors.New("business chat links too much") + ErrBusinessRecipientsEmpty = errors.New("business recipients empty") + ErrBotBusinessMissing = errors.New("bot business missing") + ErrBotNotConnectedYet = errors.New("bot not connected yet") + ErrBotAlreadyDisabled = errors.New("bot already disabled") + ErrShortcutInvalid = errors.New("quick reply shortcut invalid") + ErrShortcutOccupied = errors.New("quick reply shortcut occupied") + ErrQuickRepliesTooMuch = errors.New("quick replies too much") +) + +type BusinessProfile struct { + UserID int64 + WorkHours *BusinessWorkHours + Location *BusinessLocation + Intro *BusinessIntro + Greeting *BusinessGreetingMessage + Away *BusinessAwayMessage + UpdatedAtUnix int64 +} + +type BusinessWeeklyOpen struct { + StartMinute int + EndMinute int +} + +type BusinessWorkHours struct { + TimezoneID string + WeeklyOpen []BusinessWeeklyOpen + OpenNow bool +} + +type BusinessLocation struct { + Address string + Geo *GeoPoint +} + +type GeoPoint struct { + Lat float64 + Long float64 +} + +type BusinessIntro struct { + Title string + Description string + StickerDocumentID int64 +} + +type BusinessRecipients struct { + ExistingChats bool + NewChats bool + Contacts bool + NonContacts bool + ExcludeSelected bool + Users []int64 +} + +type BusinessBotRights struct { + Reply bool + ReadMessages bool + DeleteSentMessages bool + DeleteReceivedMessages bool + EditName bool + EditBio bool + EditProfilePhoto bool + EditUsername bool + ViewGifts bool + SellGifts bool + ChangeGiftSettings bool + TransferAndUpgradeGifts bool + TransferStars bool + ManageStories bool +} + +type BusinessBotRecipients struct { + ExistingChats bool + NewChats bool + Contacts bool + NonContacts bool + ExcludeSelected bool + Users []int64 + ExcludeUsers []int64 +} + +type ConnectedBusinessBot struct { + OwnerUserID int64 + BotUserID int64 + Recipients BusinessBotRecipients + Rights BusinessBotRights + CreatedAtUnix int64 + UpdatedAtUnix int64 +} + +type ConnectedBusinessBotPeerState struct { + OwnerUserID int64 + PeerUserID int64 + Paused bool + Disabled bool + UpdatedAtUnix int64 +} + +type BusinessGreetingMessage struct { + ShortcutID int + Recipients BusinessRecipients + NoActivityDays int +} + +type BusinessAwayScheduleKind string + +const ( + BusinessAwayScheduleAlways BusinessAwayScheduleKind = "always" + BusinessAwayScheduleOutsideWorkHours BusinessAwayScheduleKind = "outside_work_hours" + BusinessAwayScheduleCustom BusinessAwayScheduleKind = "custom" +) + +type BusinessAwaySchedule struct { + Kind BusinessAwayScheduleKind + StartDate int + EndDate int +} + +type BusinessAwayMessage struct { + ShortcutID int + Schedule BusinessAwaySchedule + Recipients BusinessRecipients + OfflineOnly bool +} + +type BusinessAutomationKind string + +const ( + BusinessAutomationGreeting BusinessAutomationKind = "greeting" + BusinessAutomationAway BusinessAutomationKind = "away" + BusinessAutomationAI BusinessAutomationKind = "ai" +) + +type BusinessAutomationDelivery struct { + OwnerUserID int64 + PeerUserID int64 + Kind BusinessAutomationKind + TriggerMessageID int + ShortcutID int + SentAt int +} + +type BusinessChatLinkInput struct { + Message string + Entities []MessageEntity + Title string +} + +type BusinessChatLink struct { + OwnerUserID int64 + Slug string + Link string + Message string + Entities []MessageEntity + Title string + Views int + CreatedAt int64 + UpdatedAt int64 +} + +type QuickReply struct { + OwnerUserID int64 + ID int + Shortcut string + TopMessage int + Count int + SortOrder int + CreatedAt int64 + UpdatedAt int64 +} + +type QuickReplyMessage struct { + OwnerUserID int64 + ShortcutID int + ID int + RandomID int64 + Date int + Message string + Entities []MessageEntity +} + +type QuickReplyList struct { + OwnerUserID int64 + QuickReplies []QuickReply + Messages []QuickReplyMessage + Hash int64 +} + +type QuickReplyMessages struct { + OwnerUserID int64 + ShortcutID int + Messages []QuickReplyMessage + Count int + Hash int64 +} + +type QuickReplyMutationKind string + +const ( + QuickReplyMutationList QuickReplyMutationKind = "list" + QuickReplyMutationNew QuickReplyMutationKind = "new" + QuickReplyMutationDelete QuickReplyMutationKind = "delete" + QuickReplyMutationMessage QuickReplyMutationKind = "message" + QuickReplyMutationIDs QuickReplyMutationKind = "ids" +) + +type QuickReplyMutation struct { + Kind QuickReplyMutationKind + List QuickReplyList + QuickReply QuickReply + ShortcutID int + Message QuickReplyMessage + MessageIDs []int + Date int + Pts int + PtsCount int +} + +func NormalizeBusinessChatLinkInput(in BusinessChatLinkInput) (BusinessChatLinkInput, error) { + in.Message = strings.TrimSpace(in.Message) + in.Title = strings.TrimSpace(in.Title) + if in.Message == "" || utf8.RuneCountInString(in.Message) > MaxBusinessChatLinkMessage { + return BusinessChatLinkInput{}, ErrBusinessChatLinkInvalid + } + if utf8.RuneCountInString(in.Title) > MaxBusinessChatLinkTitle { + return BusinessChatLinkInput{}, ErrBusinessChatLinkInvalid + } + if len(in.Entities) > MaxMessageEntityCount { + return BusinessChatLinkInput{}, ErrBusinessChatLinkInvalid + } + in.Entities = append([]MessageEntity(nil), in.Entities...) + return in, nil +} + +func NormalizeQuickReplyShortcut(shortcut string) (string, error) { + shortcut = strings.TrimSpace(shortcut) + if shortcut == "" || strings.ContainsAny(shortcut, "\r\n\t") || utf8.RuneCountInString(shortcut) > MaxQuickReplyShortcutLength { + return "", ErrShortcutInvalid + } + return shortcut, nil +} + +func BusinessBotRecipientsMatch(recipients BusinessBotRecipients, existingChat, isContact bool, userID int64) bool { + selected := false + if existingChat && recipients.ExistingChats { + selected = true + } + if !existingChat && recipients.NewChats { + selected = true + } + if isContact && recipients.Contacts { + selected = true + } + if !isContact && recipients.NonContacts { + selected = true + } + for _, id := range recipients.Users { + if id == userID { + selected = true + break + } + } + for _, id := range recipients.ExcludeUsers { + if id == userID { + if recipients.ExcludeSelected { + selected = true + break + } + return false + } + } + if recipients.ExcludeSelected { + return !selected + } + return selected +} diff --git a/internal/domain/channel.go b/internal/domain/channel.go index de32e173..98fd7e8f 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -1,5 +1,11 @@ package domain +import ( + "strconv" + "strings" + "unicode/utf8" +) + const ( // MaxChannelDifferenceLimit limits a single updates.getChannelDifference page. MaxChannelDifferenceLimit = 100 @@ -14,9 +20,18 @@ const ( // MaxChannelInviteUsers limits a single inviteToChannel/createChat member batch. MaxChannelInviteUsers = 200 // MaxChannelRealtimeFanout caps best-effort realtime channel pushes until a presence/subscription index exists. - MaxChannelRealtimeFanout = 500 + MaxChannelRealtimeFanout = 2000 // MaxSynchronousChannelDialogFanout bounds per-member channel_dialogs writes in the send transaction. MaxSynchronousChannelDialogFanout = 1000 + // MaxDialogUnreadCount 钳制对话未读消息 COUNT 的上界(设计 fan-out epic Phase 2 / P1-v)。 + // 广播/大群走动态 COUNT(*),积压巨大时既下发天文数字角标、又让 nudge 风暴下每个被 nudge + // 成员的 getChannelDifference/getPeerDialogs 触发 O(积压) 扫描打爆 PG。store 层一律把未读 + // COUNT 用 LIMIT 子查询(PG)/提前 break(memory) 钳到本上界——既限定扫描工作量也限定下发数值。 + // 只影响角标显示数,不影响 read 水位/pts 真值与 hasUnread(EXISTS) 判定。 + // 取 99(角标显示上限“99+”惯例):上界即扫描上界,越低单次未读 COUNT 扫描越浅—— + // 相比旧值 1000,积压 >99 的成员每次未读 COUNT(大群读路径 + fan-out 写 + forum 话题) + // 扫描行数约降一个数量级。值可按客户端角标显示习惯调整。 + MaxDialogUnreadCount = 99 // MaxChannelTypingFanout caps transient typing fanout. MaxChannelTypingFanout = MaxChannelRealtimeFanout // MaxChannelAdminRankLength limits custom admin rank text. @@ -31,6 +46,9 @@ const ( MaxChannelPendingJoinRecentRequesters = 5 // MaxAdminedPublicChannels limits channels.getAdminedPublicChannels payload size. MaxAdminedPublicChannels = 200 + // MaxSendAsChannels bounds the broadcast channels offered as channels.getSendAs candidates + // (the user's own channels they can post groups as). + MaxSendAsChannels = 100 // MaxChannelAdminLogLimit limits one channels.getAdminLog page. MaxChannelAdminLogLimit = 100 // MaxChannelAdminLogAdmins limits admin filter fan-in. @@ -47,12 +65,36 @@ const ( MaxPublicChannelSearchLimit = 50 // MaxPublicChannelSearchQueryLength bounds public channel peer search strings. MaxPublicChannelSearchQueryLength = 256 - // MaxChannelReactionItems limits one channel reaction policy payload. - MaxChannelReactionItems = 64 + // MaxChannelReactionTypes bounds how many distinct reaction types a chat may + // allow via chatReactionsSome. DrKLO sends "enable all standard reactions" on + // a broadcast channel as an explicit chatReactionsSome list (megagroups use + // chatReactionsAll instead), so this MUST stay >= the advertised available + // reactions catalog (messages.getAvailableReactions, currently ~74 entries) or + // that legitimate "select all" payload is wrongly rejected with LIMIT_INVALID. + // 100 leaves headroom for catalog growth while still bounding abusive payloads. + MaxChannelReactionTypes = 100 + // MaxChannelReactionsLimit bounds the per-message distinct-reaction cap carried + // by messages.setChatAvailableReactions.reactions_limit. Clients constrain it to + // appConfig reactions_uniq_max (11); this wider server bound only rejects abuse + // and must never be smaller than the reaction type count to keep the cap sane. + MaxChannelReactionsLimit = 100 // MaxChannelReactionEmoticonLength limits one emoji reaction string. MaxChannelReactionEmoticonLength = 32 - // MaxChannelMessageReactionsPerUser limits one user's reactions on one channel message. + // MaxChannelMessageReactionsPerUser bounds the accepted sendReaction vector before trimming. MaxChannelMessageReactionsPerUser = 16 + // MaxMessageReactionsPerUser is the effective per-user cap on one message, matching the + // advertised appConfig reactions_user_max_default. + // Longer vectors keep the newest entries: official clients are told to drop older + // reactions, so the tail of the vector wins. + MaxMessageReactionsPerUser = 1 + // MaxMessageReactionsPerUserPremium 是会员档每用户上限,对齐 appConfig + // reactions_user_max_premium(官方值 3)。客户端按 self premium flag 选档, + // 服务端档位必须 ≥ 客户端宣告档位,否则 premium 用户的多 reaction 会被 + // 静默裁剪、双端视图错乱。 + MaxMessageReactionsPerUserPremium = 3 + // DefaultMessageReactionsUniqMax caps distinct reaction emojis on one message, matching the + // advertised appConfig reactions_uniq_max. ChannelReactionPolicy.Limit overrides it per chat. + DefaultMessageReactionsUniqMax = 11 // MaxChannelMessageReactionRecent limits recent reactors embedded in messageReactions. MaxChannelMessageReactionRecent = 3 // MaxChannelMessageReactionListLimit limits one messages.getMessageReactionsList page. @@ -99,6 +141,9 @@ const ( MaxChannelForumTopicTitleLength = 128 // DefaultForumTopicIconColor is Telegram Desktop's General-topic fallback blue. DefaultForumTopicIconColor = 0x6FB9F0 + // ForumGeneralTopicID 是 General 话题的固定 id(Telegram 协议;客户端 readDiscussion General 用 msg_id=1)。 + // General 内消息 reply_to_top_id=1,开 forum 前的历史消息 reply_to_top_id=0,二者都归 General。 + ForumGeneralTopicID = 1 // MaxChannelReadMentionsBatch limits one messages.readMentions clearing batch. MaxChannelReadMentionsBatch = 1000 // MaxChannelReadReactionsBatch limits one messages.readReactions clearing batch. @@ -144,12 +189,19 @@ type ChannelAdminRights struct { PostMessages bool EditMessages bool DeleteMessages bool + PostStories bool + EditStories bool + DeleteStories bool BanUsers bool InviteUsers bool PinMessages bool AddAdmins bool ManageCall bool Anonymous bool + ManageRanks bool + // ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的 + // 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。 + ManageDirectMessages bool } // ChannelBannedRights is a domain-only representation of Telegram banned rights. @@ -166,6 +218,7 @@ type ChannelBannedRights struct { ChangeInfo bool InviteUsers bool PinMessages bool + EditRank bool UntilDate int } @@ -189,6 +242,83 @@ type ChannelReactionPolicy struct { PaidEnabled bool } +// AllowsReaction reports whether the policy accepts one message reaction. +// The zero policy (Default) behaves like chatReactionsAll without allow_custom: +// ordinary emoji reactions are accepted, custom emoji reactions require an +// explicit chatReactionsSome entry or chatReactionsAll.allow_custom. +func (p ChannelReactionPolicy) AllowsReaction(reaction MessageReaction) bool { + if !reaction.Valid() { + return false + } + switch p.Type { + case ChannelReactionPolicyNone: + return false + case ChannelReactionPolicySome: + switch reaction.Type { + case MessageReactionEmoji: + for _, emoticon := range p.Emoticons { + if strings.TrimSpace(emoticon) == reaction.Value() { + return true + } + } + case MessageReactionCustomEmoji: + for _, documentID := range p.CustomEmojiIDs { + if documentID == reaction.DocumentID { + return true + } + } + } + return false + case ChannelReactionPolicyAll: + if reaction.Type == MessageReactionCustomEmoji { + return p.AllowCustom + } + return reaction.Type == MessageReactionEmoji + default: + return reaction.Type == MessageReactionEmoji + } +} + +// UniqueReactionsLimit returns the per-message distinct emoji cap for the chat: +// channelFull.reactions_limit when set, otherwise the appConfig reactions_uniq_max default. +func (p ChannelReactionPolicy) UniqueReactionsLimit() int { + if p.Limit > 0 { + return p.Limit + } + return DefaultMessageReactionsUniqMax +} + +// MessageReactionsUserMax 返回 viewer 的每用户 reaction 上限档位。 +func MessageReactionsUserMax(premium bool) int { + if premium { + return MaxMessageReactionsPerUserPremium + } + return MaxMessageReactionsPerUser +} + +// NormalizeReactionsPerUserMax 把请求携带的档位约束到合法区间: +// <=0(旧调用方未填)回退默认档,上限封顶 premium 档。 +func NormalizeReactionsPerUserMax(perUserMax int) int { + if perUserMax <= 0 { + return MaxMessageReactionsPerUser + } + if perUserMax > MaxMessageReactionsPerUserPremium { + return MaxMessageReactionsPerUserPremium + } + return perUserMax +} + +// TrimMessageReactionsToUserMax enforces the per-user reaction cap by keeping the newest +// entries (clients append new reactions at the end of the vector and drop older ones). +// perUserMax 经 NormalizeReactionsPerUserMax 约束;premium viewer 传 premium 档。 +func TrimMessageReactionsToUserMax(reactions []MessageReaction, perUserMax int) []MessageReaction { + perUserMax = NormalizeReactionsPerUserMax(perUserMax) + if len(reactions) <= perUserMax { + return reactions + } + return reactions[len(reactions)-perUserMax:] +} + // ChannelPeerColor is a domain-only representation of PeerColor. type ChannelPeerColor struct { HasColor bool @@ -220,6 +350,7 @@ type Channel struct { Title string About string Username string + Verified bool Broadcast bool Megagroup bool Forum bool @@ -235,29 +366,52 @@ type Channel struct { PreHistoryHidden bool ParticipantsHidden bool AntiSpam bool - LinkedChatID int64 - SlowmodeSeconds int - DefaultBannedRights ChannelBannedRights - ReactionPolicy ChannelReactionPolicy - Color ChannelPeerColor - ProfileColor ChannelPeerColor - EmojiStatus ChannelEmojiStatus - ParticipantsCount int - AdminsCount int - KickedCount int - BannedCount int - TopMessageID int - PinnedMessageID int - Pts int - TTLPeriod int - Date int - Deleted bool + // HasLink 表示频道/群存在未撤销的导出邀请链接。Android send-as 入口会用 + // megagroup && (public || has_geo || has_link) 判定是否拉取候选列表。 + HasLink bool + LinkedChatID int64 + // Monoforum 标记本频道是「频道私信(Direct Messages)」的 monoforum 虚拟频道。 + // LinkedMonoforumID:母频道指向其 monoforum;monoforum 反向指向母频道(双向)。 + Monoforum bool + LinkedMonoforumID int64 + SlowmodeSeconds int + BoostsUnrestrict int + DefaultBannedRights ChannelBannedRights + ReactionPolicy ChannelReactionPolicy + Color ChannelPeerColor + ProfileColor ChannelPeerColor + EmojiStatus ChannelEmojiStatus + Wallpaper *Wallpaper + ParticipantsCount int + AdminsCount int + KickedCount int + BannedCount int + TopMessageID int + PinnedMessageID int + Pts int + TTLPeriod int + Date int + Deleted bool + // 活跃群通话关联(channel.call_active/call_not_empty flag 与 channelFull.call + // 的数据源;客户端拉 dialogs/getFullChannel 时凭它重建 banner)。 + ActiveCallID int64 + ActiveCallAccessHash int64 + ActiveCallNotEmpty bool // 当前头像(反范式存于 channels 表)。PhotoID==0 表示无头像。 PhotoID int64 PhotoDCID int PhotoStripped []byte } +// MembersListAdminOnly 表示该频道的成员/订阅者列表仅管理员可查看。广播频道(非 +// megagroup)的订阅者列表在官方语义里恒为管理员专属——普通订阅者看不到 Subscribers/ +// Administrators/Channel Settings 等行,也不能枚举订阅者;此外成员被隐藏 +// (ParticipantsHidden) 时对非管理员同样不可见。超级群(megagroup)默认成员可见。 +// 该判定只看频道本身,是否放行还需叠加 viewer 是否管理员。 +func (c Channel) MembersListAdminOnly() bool { + return c.ParticipantsHidden || (c.Broadcast && !c.Megagroup) +} + // ChannelMember is one user's channel membership and read state. type ChannelMember struct { ChannelID int64 @@ -295,6 +449,7 @@ type ChannelDialog struct { PinnedOrder int UnreadMark bool ViewForumAsMessages bool + HasScheduled bool DefaultSendAs *Peer } @@ -307,9 +462,32 @@ const ( ChannelActionChatAddUser ChannelMessageActionType = "chat_add_user" ChannelActionChatDelete ChannelMessageActionType = "chat_delete_user" ChannelActionChatJoined ChannelMessageActionType = "chat_joined" - ChannelActionEditTitle ChannelMessageActionType = "chat_edit_title" - ChannelActionTopicCreate ChannelMessageActionType = "topic_create" - ChannelActionTopicEdit ChannelMessageActionType = "topic_edit" + // ChannelActionChatJoinedByLink 是经邀请链接加入的服务消息, + // 渲染为 "X joined the group via invite link"。 + ChannelActionChatJoinedByLink ChannelMessageActionType = "chat_joined_by_link" + ChannelActionEditTitle ChannelMessageActionType = "chat_edit_title" + ChannelActionTopicCreate ChannelMessageActionType = "topic_create" + ChannelActionTopicEdit ChannelMessageActionType = "topic_edit" + // ChannelActionTodoCompletions / ChannelActionTodoAppendTasks 映射 + // messageActionTodoCompletions / messageActionTodoAppendTasks:超级群 + // checklist 协作生成的服务消息,经 reply_to 指向原 checklist 消息。 + ChannelActionTodoCompletions ChannelMessageActionType = "todo_completions" + ChannelActionTodoAppendTasks ChannelMessageActionType = "todo_append_tasks" + // ChannelActionGroupCall 映射 messageActionGroupCall:started(CallDuration=0) + // 与 ended(CallDuration>0)共用同一构造器,官方语义即如此。 + ChannelActionGroupCall ChannelMessageActionType = "group_call" + // ChannelActionInviteToGroupCall 映射 messageActionInviteToGroupCall(被邀请 + // 者通过频道消息收到可点击的入会卡片,UserIDs 为受邀人)。 + ChannelActionInviteToGroupCall ChannelMessageActionType = "invite_to_group_call" + // ChannelActionBoostApply 映射 messageActionBoostApply。 + ChannelActionBoostApply ChannelMessageActionType = "boost_apply" + // ChannelActionPaidMessagesPrice 映射 messageActionPaidMessagesPrice: + // 广播频道 Direct Messages 开关/价格变更的服务消息。 + ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price" + // ChannelActionStarGift 映射 messageActionStarGift:频道礼物的 admin-log 快照。 + ChannelActionStarGift ChannelMessageActionType = "star_gift" + // ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper:频道外观页设置 wallpaper。 + ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper" ) // ChannelMessageAction describes a service action without depending on tg.*. @@ -323,6 +501,25 @@ type ChannelMessageAction struct { Closed *bool Hidden *bool UserIDs []int64 + // InviterUserID 仅 chat_joined_by_link 使用(messageActionChatJoinedByLink.inviter_id)。 + InviterUserID int64 + // CallID/CallAccessHash/CallDuration 仅 group_call / invite_to_group_call 使用。 + CallID int64 + CallAccessHash int64 + CallDuration int + // Boosts 仅 boost_apply 服务消息使用。 + Boosts int + // BroadcastMessagesAllowed/Stars 仅 paid_messages_price 服务消息使用。 + BroadcastMessagesAllowed bool + Stars int64 + // Completed/Incompleted/TodoItems 仅 todo_* 服务消息使用。 + Completed []int + Incompleted []int + TodoItems []MessageTodoItem + // StarGift 仅 star_gift 服务消息使用。 + StarGift *MessageStarGiftAction + // Wallpaper 仅 set_chat_wallpaper 服务消息使用。 + Wallpaper *Wallpaper } // ChannelMessage is a single stored message in a channel/supergroup. @@ -333,38 +530,107 @@ type ChannelMessage struct { SenderUserID int64 From Peer SendAs *Peer - Date int - EditDate int - Post bool - Silent bool - NoForwards bool - Body string - Entities []MessageEntity - ReplyTo *MessageReply - Forward *MessageForward - Discussion *ChannelDiscussionRef - Replies *ChannelMessageReplies - Reactions *ChannelMessageReactions - Action *ChannelMessageAction - Media *MessageMedia - Pinned bool - Mentioned bool - MediaUnread bool - Pts int - Deleted bool + // SavedPeer 是 monoforum 私信子会话分组键(按订阅者分组);普通频道消息为零值。 + SavedPeer Peer + Date int + EditDate int + Post bool + Silent bool + NoForwards bool + Body string + Entities []MessageEntity + ReplyTo *MessageReply + Forward *MessageForward + ViaBotID int64 + // GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。 + GroupedID int64 + ReplyMarkup *MessageReplyMarkup + Discussion *ChannelDiscussionRef + Replies *ChannelMessageReplies + Reactions *ChannelMessageReactions + Action *ChannelMessageAction + Media *MessageMedia + // FromBoostsApplied 是发送时的 sender boost 数快照(message.from_boosts_applied)。 + FromBoostsApplied int + TTLPeriod int + ExpiresAt int + Pinned bool + Mentioned bool + MediaUnread bool + // Views 是 broadcast post 的浏览数聚合;PostAuthor 是 signatures + // 开启时的作者展示名快照。 + ViewsCount int + PostAuthor string + Pts int + Deleted bool } // MessageReactionType identifies one stored reaction constructor without depending on TL types. type MessageReactionType string const ( - MessageReactionEmoji MessageReactionType = "emoji" + MessageReactionEmoji MessageReactionType = "emoji" + MessageReactionCustomEmoji MessageReactionType = "custom_emoji" ) // MessageReaction describes one supported message reaction value. type MessageReaction struct { - Type MessageReactionType - Emoticon string + Type MessageReactionType + Emoticon string + DocumentID int64 +} + +// Value returns the canonical persisted value for a reaction constructor. +func (r MessageReaction) Value() string { + switch r.Type { + case MessageReactionEmoji: + return strings.TrimSpace(r.Emoticon) + case MessageReactionCustomEmoji: + if r.DocumentID <= 0 { + return "" + } + return strconv.FormatInt(r.DocumentID, 10) + default: + return "" + } +} + +// Key returns a stable identity key for de-duplication and aggregation. +func (r MessageReaction) Key() string { + return string(r.Type) + "\x00" + r.Value() +} + +// Valid reports whether a reaction can be persisted as a message reaction. +func (r MessageReaction) Valid() bool { + switch r.Type { + case MessageReactionEmoji: + value := r.Value() + return value != "" && utf8.RuneCountInString(value) <= MaxChannelReactionEmoticonLength + case MessageReactionCustomEmoji: + return r.DocumentID > 0 + default: + return false + } +} + +// MessageReactionFromValue rebuilds a domain reaction from the persisted +// reaction_type/reaction_value pair used by stores. +func MessageReactionFromValue(reactionType MessageReactionType, value string) (MessageReaction, bool) { + switch reactionType { + case MessageReactionEmoji: + value = strings.TrimSpace(value) + reaction := MessageReaction{Type: MessageReactionEmoji, Emoticon: value} + return reaction, reaction.Valid() + case MessageReactionCustomEmoji: + documentID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) + if err != nil { + return MessageReaction{}, false + } + reaction := MessageReaction{Type: MessageReactionCustomEmoji, DocumentID: documentID} + return reaction, reaction.Valid() + default: + return MessageReaction{}, false + } } // ChannelMessageReactionCount is an aggregated reaction counter for one message. @@ -393,6 +659,9 @@ type ChannelMessageReactions struct { CanSeeList bool Results []ChannelMessageReactionCount Recent []ChannelMessagePeerReaction + // Paid 是付费 reaction(Stars)聚合(nil = 无);读路径从 channel_message_paid_reactions + // 填充、tg 转换注入 ReactionPaid 计数 + top reactors。与普通 reaction 分表存储。 + Paid *ChannelMessagePaidReactions } // SetChannelMessageReactionsRequest replaces the current user's reactions for one message. @@ -404,6 +673,9 @@ type SetChannelMessageReactionsRequest struct { Big bool AddToRecent bool Date int + // ReactionsPerUserMax 是 viewer 的每用户上限档位(premium 双档); + // 0 表示未填,store 侧按默认档裁剪。 + ReactionsPerUserMax int } // ChannelMessageReactionsResult describes one reaction update. @@ -516,7 +788,11 @@ const ( ChannelUpdateDeleteMessages ChannelUpdateEventType = "delete_channel_messages" ChannelUpdateParticipant ChannelUpdateEventType = "channel_participant" ChannelUpdatePinnedMessages ChannelUpdateEventType = "pinned_channel_messages" - ChannelUpdateNoop ChannelUpdateEventType = "noop" + // ChannelUpdateWebPage 映射 updateChannelWebPage:频道消息的 pending 链接预览异步解析后 + // 就地替换为已解析卡片(按 webPage id 关联,不标记「已编辑」)。与 edit_channel_message + // 同构(携频道 pts + 消息快照),difference/fan-out 复用,仅 tg 投影构造器不同。 + ChannelUpdateWebPage ChannelUpdateEventType = "channel_web_page" + ChannelUpdateNoop ChannelUpdateEventType = "noop" ) // ChannelUpdateEvent is the channel-scoped durable update log entry. @@ -582,6 +858,7 @@ const ( ChannelAdminLogParticipantLeave ChannelAdminLogEventType = "participant_leave" ChannelAdminLogParticipantPromote ChannelAdminLogEventType = "participant_promote" ChannelAdminLogParticipantDemote ChannelAdminLogEventType = "participant_demote" + ChannelAdminLogParticipantEditRank ChannelAdminLogEventType = "participant_edit_rank" ChannelAdminLogParticipantBan ChannelAdminLogEventType = "participant_ban" ChannelAdminLogParticipantUnban ChannelAdminLogEventType = "participant_unban" ChannelAdminLogParticipantKick ChannelAdminLogEventType = "participant_kick" @@ -664,9 +941,14 @@ type ChannelAdminLogResult struct { // ChannelView contains channel data personalized for a viewer. type ChannelView struct { - Channel Channel - Self ChannelMember - Dialog ChannelDialog + Channel Channel + Self ChannelMember + Dialog ChannelDialog + SelfBoostsApplied int + ExportedInvite *ChannelInvite + // Forbidden 表示当前 viewer 被踢/被禁止查看:查询响应必须呈现 + // channelForbidden 形态而不是省略,客户端靠它感知自己已离开会话。 + Forbidden bool } // ChannelParticipantList is a paged participant response. @@ -730,7 +1012,7 @@ type ChannelRecommendationsRequest struct { Limit int } -// ChannelRecommendationsResult contains public broadcast channel recommendations and total count. +// ChannelRecommendationsResult contains public broadcast recommendations and a bounded count hint. type ChannelRecommendationsResult struct { Count int Channels []Channel @@ -876,6 +1158,23 @@ type EditChannelTitleResult struct { Recipients []int64 } +// SetChannelWallpaperRequest sets or clears a channel wallpaper and may emit a service message. +type SetChannelWallpaperRequest struct { + UserID int64 + ChannelID int64 + Wallpaper *Wallpaper + Date int +} + +// SetChannelWallpaperResult describes a channel wallpaper change. +type SetChannelWallpaperResult struct { + Channel Channel + Message ChannelMessage + Event ChannelUpdateEvent + Recipients []int64 + Changed bool +} + // EditChannelAboutRequest modifies a channel/supergroup description. type EditChannelAboutRequest struct { UserID int64 @@ -904,6 +1203,16 @@ type EditChannelAdminResult struct { Date int } +// EditChannelMemberRankRequest sets or clears a participant's member tag (rank) +// without touching their role or admin rights. +type EditChannelMemberRankRequest struct { + UserID int64 + ChannelID int64 + MemberID int64 + Rank string + Date int +} + // EditChannelBannedRequest modifies a participant's banned rights. type EditChannelBannedRequest struct { UserID int64 @@ -929,6 +1238,10 @@ type EditChannelBannedResult struct { Event ChannelUpdateEvent Recipients []int64 Date int + // Message/ServiceEvent 是 megagroup 踢人产生的 "X removed Y" 服务 + // 消息及其 channel pts 事件;纯禁言/解禁不生成。 + Message ChannelMessage + ServiceEvent ChannelUpdateEvent } // DeleteChannelRequest deletes a channel/supergroup. Only the creator may do this. @@ -949,24 +1262,88 @@ type UpdateChannelUsernameRequest struct { type DeleteChannelResult struct { Channel Channel Recipients []int64 + // LinkedMonoforum 是随母广播频道一并软删的关联 monoforum(频道私信容器)。删父频道必须连带删它, + // 否则会留下 monoforum=true 但 linked_monoforum_id 指向已删父频道的孤儿(客户端渲染会崩)。 + // 仅删带 Direct Messages 的母频道时非 nil;普通频道为 nil。 + LinkedMonoforum *Channel } // SendChannelMessageRequest sends one channel/supergroup message. type SendChannelMessageRequest struct { - UserID int64 - ChannelID int64 - RandomID int64 - Message string - Entities []MessageEntity - Media *MessageMedia - MentionUserIDs []int64 - Silent bool - NoForwards bool - ReplyTo *MessageReply - Forward *MessageForward - SendAs *Peer - Action *ChannelMessageAction - Date int + UserID int64 + ChannelID int64 + RandomID int64 + Message string + Entities []MessageEntity + Media *MessageMedia + MentionUserIDs []int64 + SkipDeliveryUserIDs []int64 + // SkipRecipientLookup lets high-level realtime fan-out use the online member + // read model instead of forcing store.SendChannelMessage to synchronously + // return an active-member recipient list after commit. + SkipRecipientLookup bool + // PostAuthor 是 signatures 开启的 broadcast post 上快照的作者展示名。 + PostAuthor string + Silent bool + NoForwards bool + ReplyTo *MessageReply + Forward *MessageForward + ViaBotID int64 + // GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。 + GroupedID int64 + ReplyMarkup *MessageReplyMarkup + SendAs *Peer + Action *ChannelMessageAction + Date int + TTLPeriod int +} + +// SendMonoforumMessageRequest 向频道私信(monoforum)发送一条消息。MonoforumID 是 monoforum +// 虚拟频道 id;SavedPeer 是订阅者子会话分组键(订阅者发=自己,管理员回复=目标订阅者); +// SenderUserID 是实际发件人。发件权限(订阅者身份/管理员)在 RPC 层校验,store 只校验 monoforum 存在。 +type SendMonoforumMessageRequest struct { + MonoforumID int64 + SenderUserID int64 + SavedPeer Peer + RandomID int64 + Message string + Entities []MessageEntity + Date int +} + +// MonoforumHistoryFilter 按订阅者子会话拉取 monoforum 私信历史。 +type MonoforumHistoryFilter struct { + MonoforumID int64 + SavedPeer Peer + Limit int + OffsetID int +} + +// MonoforumDialog 是频道私信(monoforum)中一个订阅者子会话的摘要。读水位/未读由后续 P0 +// 切片补齐,当前置 0。 +type MonoforumDialog struct { + SavedPeer Peer + TopMessageID int + TopMessageDate int + UnreadCount int + ReadInboxMaxID int + ReadOutboxMaxID int +} + +// MonoforumDialogList 是 monoforum 订阅者子会话列表(按各子会话 top 消息 id 倒序)。 +type MonoforumDialogList struct { + MonoforumID int64 + Channel Channel + Dialogs []MonoforumDialog + Messages []ChannelMessage + Count int +} + +// MonoforumDialogsFilter 分页拉取 monoforum 订阅者子会话列表(按 top 消息 id 倒序 seek)。 +type MonoforumDialogsFilter struct { + MonoforumID int64 + Limit int + OffsetID int } // SaveChannelDefaultSendAsRequest stores the current user's default send-as peer for a channel dialog. @@ -985,9 +1362,13 @@ type ChannelMessageViewsRequest struct { Date int } -// ChannelMessageViewsResult returns current view counters by visible message id. +// ChannelMessageViewsResult returns current view counters and lightweight reply +// counters by visible message id. type ChannelMessageViewsResult struct { - Views map[int]int + Channel Channel + Views map[int]int + Replies map[int]*ChannelMessageReplies + Peers []Peer } // ReadChannelMessageContentsRequest marks channel/supergroup content-read hints for one viewer. @@ -1002,6 +1383,10 @@ type ReadChannelMessageContentsResult struct { Channel Channel Messages []ChannelMessage ClearedUnreadReactionMessageIDs []int + // ClearedUnreadMentionMessageIDs 是本次内容已读翻转为已读的 mention + // 消息;客户端视口看到 @ 消息即发 readMessageContents,服务端必须 + // 同步清除否则角标在下一次 getDialogs 复活。 + ClearedUnreadMentionMessageIDs []int } // GetChannelMessageAuthorRequest resolves the original user author of one channel message. @@ -1018,6 +1403,13 @@ type GetChannelMessageAuthorResult struct { SenderUserID int64 } +// ChannelPaidMessagesPriceResult describes a paid/direct-message price setting change. +type ChannelPaidMessagesPriceResult struct { + Channel Channel + ServiceMessage *SendChannelMessageResult + ServiceMessages []SendChannelMessageResult +} + // SendChannelMessageResult describes a channel message send. type SendChannelMessageResult struct { Channel Channel @@ -1026,6 +1418,13 @@ type SendChannelMessageResult struct { Recipients []int64 Duplicate bool Discussion *SendChannelDiscussionResult + // MentionUserIDs 是本条消息解析出的被 @ 成员;在线 fanout 按它为 + // 每个接收者投影 message.mentioned/media_unread。 + MentionUserIDs []int64 + // SkipDeliveryUserIDs 是本条消息按 bot privacy 规则被排除投递的成员(隐私 bot 对 + // 命令/@/回复以外的消息不可见)。Recipients 已扣除它们;在线 fanout 还须据此跳过这些 + // 成员的直接推送,否则在线 bot 仍能实时收到群里全部消息(持久 history/difference 已隐藏)。 + SkipDeliveryUserIDs []int64 } // SendChannelDiscussionResult describes the discussion megagroup root created for a broadcast post. @@ -1034,6 +1433,9 @@ type SendChannelDiscussionResult struct { Message ChannelMessage Event ChannelUpdateEvent Recipients []int64 + // MentionUserIDs 透传 post 的 @ 目标:讨论组联动消息的实时推送也要 + // 按接收者投影 mentioned/media_unread。 + MentionUserIDs []int64 } // CreateChannelForumTopicRequest creates one forum topic root service message. @@ -1121,21 +1523,44 @@ type DeleteChannelForumTopicHistoryRequest struct { } // EditChannelMessageRequest edits one text message in a channel/supergroup. +// Media 非 nil 时整体替换消息媒体快照(当前唯一调用方是 live location 续报/停止)。 type EditChannelMessageRequest struct { UserID int64 ChannelID int64 ID int Message string Entities []MessageEntity - EditDate int + Media *MessageMedia + // SetReplyMarkup 置位时替换 reply_markup(ReplyMarkup 为 nil/空 = 清空键盘)。 + SetReplyMarkup bool + ReplyMarkup *MessageReplyMarkup + // ViaBotEditBotID 非零时要求目标消息 via_bot_id 匹配对应 bot。 + ViaBotEditBotID int64 + // AllowTodoParticipantMutation 允许非作者普通成员在 checklist 的 + // others_can_* 授权下仅替换 todo media 快照;不放开普通文本/媒体编辑。 + AllowTodoParticipantMutation bool + // TodoServiceAction 非 nil 时,编辑同事务追加一条 reply 到原 checklist + // 的 todo 服务消息,并生成独立 channel pts。 + TodoServiceAction *ChannelMessageAction + // MentionUserIDs 是编辑后文本解析出的 @ 目标:新增者补未读提及、 + // 被移除者清除(reply 隐式提及不受影响)。 + MentionUserIDs []int64 + EditDate int + // WebPageResolve 置位时为服务端内部「频道链接预览就地替换」:仅换 media(不碰 body/ + // entities/edit_date),生成 ChannelUpdateWebPage 而非 edit_channel_message。幂等守卫: + // 仅当目标当前 media 仍是 ID==ExpectedWebPageID 的 pending 链接预览才替换。 + WebPageResolve bool + ExpectedWebPageID int64 } // EditChannelMessageResult describes one channel edit update. type EditChannelMessageResult struct { - Channel Channel - Message ChannelMessage - Event ChannelUpdateEvent - Recipients []int64 + Channel Channel + Message ChannelMessage + Event ChannelUpdateEvent + ServiceMessage ChannelMessage + ServiceEvent ChannelUpdateEvent + Recipients []int64 } // DeleteChannelMessagesRequest deletes a bounded set of channel/supergroup messages. @@ -1152,6 +1577,16 @@ type DeleteChannelMessagesResult struct { Event ChannelUpdateEvent DeletedIDs []int Recipients []int64 + // DiscussionDeletes 是被删 broadcast post 在 linked 讨论组里的转发根 + // 级联删除(官方删 post 同时删除讨论根)。 + DiscussionDeletes []ChannelCascadeDelete +} + +// ChannelCascadeDelete 描述一次跨频道的级联删除(如讨论根随 post 删除)。 +type ChannelCascadeDelete struct { + Channel Channel + Event ChannelUpdateEvent + Recipients []int64 } // DeleteChannelHistoryRequest clears or deletes a bounded channel/supergroup history page. @@ -1192,12 +1627,23 @@ type UpdateChannelPinnedMessageRequest struct { } // UpdateChannelPinnedMessageResult describes one pinned-message update. +// 多置顶模型:pin/unpin 互不影响其它置顶,Event.MessageIDs 仅含本次操作的 +// 消息(unpinAll 时为本批全部清除的 id)。 type UpdateChannelPinnedMessageResult struct { - Channel Channel - Event ChannelUpdateEvent + Channel Channel + Event ChannelUpdateEvent + // UnpinEvent 携带单置顶替换时旧置顶的 unpin 事件(无则 Pts=0)。 + UnpinEvent ChannelUpdateEvent Recipients []int64 } +// UnpinAllChannelMessagesRequest clears every pinned message in one channel. +type UnpinAllChannelMessagesRequest struct { + UserID int64 + ChannelID int64 + Date int +} + // ChannelInvite is an exported invite link without TL dependencies. type ChannelInvite struct { ChannelID int64 @@ -1379,6 +1825,7 @@ type ChannelHistoryFilter struct { Query string SenderUserID int64 PinnedOnly bool + MusicOnly bool OffsetID int OffsetDate int AddOffset int @@ -1406,6 +1853,7 @@ type ChannelGlobalSearchRequest struct { Query string BroadcastsOnly bool GroupsOnly bool + MusicOnly bool HasFolderID bool FolderID int OffsetRate int @@ -1523,8 +1971,32 @@ type ReadChannelHistoryResult struct { StillUnreadCount int Changed bool Pts int - Dialog ChannelDialog - OutboxUpdates []ChannelReadOutboxUpdate + // Forum 标记该频道是否为话题群。RPC 层据此在频道级 readHistory 后顺带推进 + // General(topic 1) 的话题级已读水位(General 消息即频道根历史,被频道级已读覆盖)。 + Forum bool + Dialog ChannelDialog + OutboxUpdates []ChannelReadOutboxUpdate +} + +// ReadChannelTopicHistoryRequest 推进 forum 单个话题的 per-viewer 已读水位(messages.readDiscussion)。 +// TopicID = 话题根消息 id(General=1)。不碰频道级 channel_members.read_inbox_max_id。 +type ReadChannelTopicHistoryRequest struct { + UserID int64 + ChannelID int64 + TopicID int + MaxID int + Date int +} + +// ReadChannelTopicHistoryResult 描述一次 per-topic 已读推进结果。OutboxUpdates 每条 UserID 是 +// 该话题内待收已读回执的发送者,MaxID 为其消息被读到的水位;话题 id 即 Result.TopicID。 +type ReadChannelTopicHistoryResult struct { + Channel Channel + TopicID int + MaxID int + Changed bool + Pts int + OutboxUpdates []ChannelReadOutboxUpdate } // ChannelReadOutboxUpdate advances one sender's channel outbox read watermark. diff --git a/internal/domain/channel_errors.go b/internal/domain/channel_errors.go index c2789a3f..9ece8f49 100644 --- a/internal/domain/channel_errors.go +++ b/internal/domain/channel_errors.go @@ -33,6 +33,10 @@ var ( ErrUsersTooMuch = errors.New("users too much") ErrUserAlreadyParticipant = errors.New("user already participant") ErrUserKicked = errors.New("user kicked") + ErrUserNotParticipant = errors.New("user not participant") + ErrBotGroupsBlocked = errors.New("bot groups blocked") + ErrReactionInvalid = errors.New("reaction invalid") + ErrReactionsTooMany = errors.New("reactions too many") ) // SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation. diff --git a/internal/domain/contact.go b/internal/domain/contact.go index 778ffbff..052acba7 100644 --- a/internal/domain/contact.go +++ b/internal/domain/contact.go @@ -12,6 +12,7 @@ type Contact struct { Note string NoteEntities []MessageEntity Mutual bool + CloseFriend bool } // ContactList 是通讯录查询结果。 @@ -20,6 +21,12 @@ type ContactList struct { Hash int64 } +// CloseFriendsEditResult describes a full close-friends list replacement. +type CloseFriendsEditResult struct { + AddedUserIDs []int64 + RemovedUserIDs []int64 +} + // BlockedContact is one owner-visible blocked peer. type BlockedContact struct { User User @@ -74,4 +81,8 @@ type PeerSettings struct { ShareContact bool NeedContactsException bool HiddenPeerSettingsBar bool + BusinessBotID int64 + BusinessBotManageURL string + BusinessBotPaused bool + BusinessBotCanReply bool } diff --git a/internal/domain/dialog.go b/internal/domain/dialog.go index 6c93c43e..ae5a516f 100644 --- a/internal/domain/dialog.go +++ b/internal/domain/dialog.go @@ -6,6 +6,9 @@ type PeerType string const ( PeerTypeUser PeerType = "user" PeerTypeChannel PeerType = "channel" + // PeerTypeFolder 仅用于 dialog 置顶事件中表达 dialogPeerFolder + // (archive folder 行本身被置顶/取消置顶),ID 为 folder_id。 + PeerTypeFolder PeerType = "folder" ) const ( @@ -23,14 +26,44 @@ const ( MaxDialogFolderTitleRunes = 64 // MaxDialogDraftsPerUser bounds messages.getAllDrafts / clearAllDrafts work. MaxDialogDraftsPerUser = 1000 + // MaxPinnedDialogsMainFolder/MaxPinnedDialogsArchiveFolder 对齐 TDesktop + // 内置默认限额(appConfig dialogs_pinned_limit_default=5 / + // dialogs_folder_pinned_limit_default=100);超限返回 + // PINNED_DIALOGS_TOO_MUCH,服务端兜底防止客户端截断后两端漂移。 + MaxPinnedDialogsMainFolder = 5 + MaxPinnedDialogsArchiveFolder = 100 + // premium 档对齐 appConfig dialogs_pinned_limit_premium=10 / + // dialogs_folder_pinned_limit_premium=200。服务端档位必须 ≥ 客户端宣告 + // 档位,否则 premium 用户 pin 第 6 个会话会被拒、UI 回滚。 + MaxPinnedDialogsMainFolderPremium = 10 + MaxPinnedDialogsArchiveFolderPremium = 200 ) +// PinnedDialogsLimit 返回 folder 维度的置顶上限档位(premium 双档)。 +func PinnedDialogsLimit(folderID int, premium bool) int { + if folderID == DialogArchiveFolderID { + if premium { + return MaxPinnedDialogsArchiveFolderPremium + } + return MaxPinnedDialogsArchiveFolder + } + if premium { + return MaxPinnedDialogsMainFolderPremium + } + return MaxPinnedDialogsMainFolder +} + // Peer 是业务层 peer 值对象,不依赖 TL 类型。 type Peer struct { Type PeerType ID int64 } +// IsSelfUser reports whether this peer is the current user's own user peer. +func (p Peer) IsSelfUser(userID int64) bool { + return userID != 0 && p.Type == PeerTypeUser && p.ID == userID +} + // Dialog 是账号的一条会话摘要。 type Dialog struct { Peer Peer @@ -43,12 +76,21 @@ type Dialog struct { UnreadCount int UnreadMentions int UnreadReactions int + TTLPeriod int + ThemeEmoticon string + HasScheduled bool Pinned bool PinnedOrder int UnreadMark bool ViewForumAsMessages bool PeerSettingsBarHidden bool - Draft *DialogDraft + // Pts 是 channel peer 当前 channel pts;客户端用 dialog.pts 初始化本地 + // channel 序列并决定 getChannelDifference 起点,channel dialog 必填。 + Pts int + Draft *DialogDraft + // NotifySettings 是该会话的 per-peer 通知设置(nil=未配置,投影时回落默认)。 + // 由读路径批量装配(非 dialog store 持久字段),见 rpc.withDialogNotifySettings。 + NotifySettings *PeerNotifySettings } // DialogDraftWebPage stores a draft link preview without depending on TL input media types. @@ -85,6 +127,21 @@ func (d DialogDraft) Empty() bool { d.Effect == 0 } +// DialogArchiveSummary 聚合归档(folder_id=1)状态,供主列表 getDialogs +// 第一页输出 dialogFolder 条目:TDesktop 依赖该条目发现 archive 的存在 +// (新登录设备没有任何 update 可重放,缺少它归档会话将彻底不可见)。 +type DialogArchiveSummary struct { + // TopPeer/TopMessage 是归档内最新会话及其 top 消息(dialogFolder.peer/top_message)。 + TopPeer Peer + TopMessage int + // UnreadPeersCount 是归档内有未读(或手动标记未读)的会话数; + // UnreadMessagesCount 是归档未读消息总数。当前未接 per-peer mute + // 状态,全部计入 unmuted 桶。 + UnreadPeersCount int + UnreadMessagesCount int + Pinned bool +} + // DialogList 是 dialogs 查询结果。 type DialogList struct { Dialogs []Dialog @@ -95,6 +152,16 @@ type DialogList struct { State UpdateState Hash int64 Count int + // ArchiveSummary 非 nil 时,主列表响应头部追加 dialogFolder 条目。 + ArchiveSummary *DialogArchiveSummary +} + +// DialogHashCheck reports whether a cached stable dialog list hash is known. +type DialogHashCheck struct { + Known bool + Matched bool + Hash int64 + Count int } // DialogFilter 是会话列表查询条件。 diff --git a/internal/domain/groupcall.go b/internal/domain/groupcall.go new file mode 100644 index 00000000..82711d8a --- /dev/null +++ b/internal/domain/groupcall.go @@ -0,0 +1,131 @@ +package domain + +import "errors" + +// 群通话(超级群语音聊天)领域模型。信令真值在 GroupCallStore(memory/postgres +// 双实现),版本协议要求 version 必须持久化:客户端忽略 version 小于本地缓存的 +// updateGroupCallParticipants,重启回卷会让整个房间静默失联。 + +// GroupCallState 是群通话状态。 +type GroupCallState string + +const ( + GroupCallStateActive GroupCallState = "active" + GroupCallStateDiscarded GroupCallState = "discarded" +) + +// 群通话业务错误;rpc 层映射为 GROUPCALL_* RPC_ERROR。 +var ( + ErrGroupCallInvalid = errors.New("group call invalid") + ErrGroupCallDiscarded = errors.New("group call already discarded") + ErrGroupCallAlreadyStarted = errors.New("group call already started") + ErrGroupCallSSRCDuplicate = errors.New("group call ssrc duplicate") + ErrGroupCallNotJoined = errors.New("group call participant missing") +) + +// GroupCall 是一场群通话的权威态。 +type GroupCall struct { + ID int64 + AccessHash int64 + ChannelID int64 + CreatorUserID int64 + State GroupCallState + Title string + JoinMuted bool + // Version 是参与者协议版本:所有参与者变更事务内 +1,单调且持久。 + Version int + ParticipantsCount int + CreatedAt int + DiscardedAt int + Duration int + // StartedMsgID 是 messageActionGroupCall(started) 的频道消息 id(discard 时 + // 客户端用它定位起始服务消息,当前仅记录)。 + StartedMsgID int +} + +// Active 报告通话是否仍在进行。 +func (c GroupCall) Active() bool { + return c.State == GroupCallStateActive +} + +// GroupCallParticipant 是房间内一名参与者。 +type GroupCallParticipant struct { + CallID int64 + UserID int64 + // SSRC 是客户端在 join JSON 里自报的 audio ssrc(uint32 值域,存 int64 防符号坑)。 + SSRC int64 + JoinDate int + ActiveDate int + Muted bool + // MutedByAdmin=true 时 can_self_unmute=false(管理员禁言/默认静音策略)。 + MutedByAdmin bool + // VolumeByAdmin 是管理员设定的全局音量(1..20000),0=未设。 + VolumeByAdmin int + // RaiseHandRating 非零表示举手中,值单调递增用于排序。 + RaiseHandRating int64 + // VideoJSON / PresentationJSON 是 tg.GroupCallParticipantVideo 的原始 JSON + // 快照(M3/M4 启用;M0/M1 仅透明保存 self-edit,不转发)。 + VideoJSON []byte + PresentationJSON []byte + Left bool + // LastCheckDate 是 checkGroupCall 保活水位。注意:客户端只在 Connecting 态 + // 发 checkGroupCall(媒体连通后心跳停止),掉线判定必须与 SFU 媒体面活性 + // 取双过期(见 sweeper),绝不能单凭此字段。 + LastCheckDate int +} + +// CreateGroupCallRequest 创建群通话。 +type CreateGroupCallRequest struct { + ChannelID int64 + CreatorUserID int64 + RandomID int64 + Title string + Now int +} + +// JoinGroupCallRequest 加入/重进群通话(rejoin 同主键换新 ssrc)。 +type JoinGroupCallRequest struct { + CallID int64 + UserID int64 + SSRC int64 + Muted bool + IsAdmin bool + // VideoJSON 是本次 join 铸造的视频内部状态(endpoint+源组+active);rejoin + // 整体替换并**清空旧 PresentationJSON**(客户端主连接 rejoin 后会重发 + // joinGroupCallPresentation,旧屏幕登记必须作废)。 + VideoJSON []byte + Now int +} + +// GroupCallMutation 是一次参与者维度变更的结果:变更后的 call 行(含新 version) +// 与受影响的参与者行(推送 updateGroupCallParticipants 用)。 +type GroupCallMutation struct { + Call GroupCall + Participant GroupCallParticipant +} + +// GroupCallParticipantUpdate 是 editGroupCallParticipant 的字段级更新(nil=不动)。 +type GroupCallParticipantUpdate struct { + Muted *bool + MutedByAdmin *bool + VolumeByAdmin *int + RaiseHandRating *int64 + VideoJSON *[]byte + PresentationJSON *[]byte + Now int +} + +// GroupCallParticipantOverride 是 per-viewer 视图覆盖(setter→target): +// 本地静音/本地音量,仅 setter 自己可见,不进全房间 version。 +type GroupCallParticipantOverride struct { + MutedByYou bool + Volume int // 0=未设 +} + +// GroupCallParticipantPage 是 getGroupParticipants 的分页结果。 +type GroupCallParticipantPage struct { + Count int + Participants []GroupCallParticipant + NextOffset string + Version int +} diff --git a/internal/domain/media.go b/internal/domain/media.go index a204c53a..38993a01 100644 --- a/internal/domain/media.go +++ b/internal/domain/media.go @@ -29,14 +29,40 @@ type FileBlob struct { MimeType string `json:"mime_type,omitempty"` } -// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片(落 PG,组装后清理)。 +// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片元数据。 +// 分片字节不进 PG;PG 只记录 object_key/size/hash,组装后清理 metadata 与临时对象。 type UploadPart struct { OwnerUserID int64 FileID int64 Part int TotalParts int // big file 已知总数;small file 为 0 Big bool - Bytes []byte + Backend MediaBackend + ObjectKey string + Size int64 + SHA256 []byte +} + +// UploadPartUsage 描述某用户当前尚未组装的上传分片占用。 +type UploadPartUsage struct { + Bytes int64 + Parts int + Files int +} + +// UploadPartSlot 描述某个 (owner,file_id,part) 槽位的当前状态,用于判断重试覆盖的配额增量。 +type UploadPartSlot struct { + ExistingBytes int64 + ObjectKey string + FileParts int + Found bool +} + +// UploadPartQuota 限制某用户 in-flight 上传分片占用;字段 <=0 表示该维度不限制。 +type UploadPartQuota struct { + MaxBytes int64 + MaxParts int + MaxFiles int } // UploadedFileRef 引用一个客户端已通过 upload.saveFilePart(Big) 上传完毕的文件。 @@ -87,22 +113,36 @@ const ( PhotoSizeKindPath PhotoSizeKind = "path" // PhotoSizeKindProgressive → photoSizeProgressive(渐进式 jpeg 多段大小)。 PhotoSizeKindProgressive PhotoSizeKind = "progressive" + // PhotoSizeKindVideo → photo.video_sizes 里的 videoSize(animated profile video)。 + PhotoSizeKindVideo PhotoSizeKind = "video" + // PhotoSizeKindVideoEmojiMarkup → photo.video_sizes 里的 videoSizeEmojiMarkup。 + PhotoSizeKindVideoEmojiMarkup PhotoSizeKind = "video_emoji_markup" + // PhotoSizeKindVideoStickerMarkup → photo.video_sizes 里的 videoSizeStickerMarkup。 + PhotoSizeKindVideoStickerMarkup PhotoSizeKind = "video_sticker_markup" ) // PhotoSize 描述照片/缩略图的一种渲染尺寸。 type PhotoSize struct { - Kind PhotoSizeKind `json:"kind"` - Type string `json:"type"` - W int `json:"w,omitempty"` - H int `json:"h,omitempty"` - Size int `json:"size,omitempty"` - Bytes []byte `json:"bytes,omitempty"` // stripped/cached/path 内联内容 - Sizes []int `json:"sizes,omitempty"` // progressive + Kind PhotoSizeKind `json:"kind"` + Type string `json:"type"` + W int `json:"w,omitempty"` + H int `json:"h,omitempty"` + Size int `json:"size,omitempty"` + Bytes []byte `json:"bytes,omitempty"` // stripped/cached/path 内联内容 + Sizes []int `json:"sizes,omitempty"` // progressive + VideoStartTs float64 `json:"video_start_ts,omitempty"` + EmojiID int64 `json:"emoji_id,omitempty"` + BackgroundColors []int `json:"background_colors,omitempty"` + StickerSetID int64 `json:"sticker_set_id,omitempty"` + StickerSetAccessHash int64 `json:"sticker_set_access_hash,omitempty"` + StickerSetShortName string `json:"sticker_set_short_name,omitempty"` + StickerSetSystemKey string `json:"sticker_set_system_key,omitempty"` + StickerID int64 `json:"sticker_id,omitempty"` } // Downloadable 表示该尺寸需要客户端通过 upload.getFile 拉取(而非内联字节)。 func (s PhotoSize) Downloadable() bool { - return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive + return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive || s.Kind == PhotoSizeKindVideo } // DocumentAttributeKind 标识 TL DocumentAttribute 变体。 @@ -175,6 +215,39 @@ func (d Document) StickerSetRef() (id, accessHash int64, ok bool) { return 0, 0, false } +// IsMusic reports whether the document is a Telegram profile/shared-music song: +// documentAttributeAudio with voice=false. MIME type is intentionally not used +// as the source of truth because clients key their UI off the TL attribute. +func (d Document) IsMusic() bool { + for _, attr := range d.Attributes { + if attr.Kind == DocAttrAudio && !attr.Voice { + return true + } + } + return false +} + +// IsSticker reports whether the document carries a sticker attribute +// (static / animated / video stickers all use documentAttributeSticker). +func (d Document) IsSticker() bool { + for _, attr := range d.Attributes { + if attr.Kind == DocAttrSticker { + return true + } + } + return false +} + +// IsGif reports whether the document is a savable GIF (documentAttributeAnimated). +func (d Document) IsGif() bool { + for _, attr := range d.Attributes { + if attr.Kind == DocAttrAnimated { + return true + } + } + return false +} + // Photo 是已存储的 Telegram 照片(头像或图片消息)。 type Photo struct { ID int64 `json:"id"` @@ -193,19 +266,261 @@ const ( MessageMediaKindNone MessageMediaKind = "" MessageMediaKindPhoto MessageMediaKind = "photo" MessageMediaKindDocument MessageMediaKind = "document" + MessageMediaKindContact MessageMediaKind = "contact" + MessageMediaKindService MessageMediaKind = "service" + MessageMediaKindGeo MessageMediaKind = "geo" + MessageMediaKindVenue MessageMediaKind = "venue" + MessageMediaKindDice MessageMediaKind = "dice" + MessageMediaKindPoll MessageMediaKind = "poll" + MessageMediaKindGeoLive MessageMediaKind = "geo_live" + MessageMediaKindTodo MessageMediaKind = "todo" + MessageMediaKindStory MessageMediaKind = "story" + MessageMediaKindWebPage MessageMediaKind = "web_page" ) +// MessageTodoItem 是清单中的一项(id 为列表内唯一正整数,客户端分配)。 +type MessageTodoItem struct { + ID int `json:"id"` + Title string `json:"title"` + Entities []MessageEntity `json:"entities,omitempty"` +} + +// MessageTodoCompletion 记录某项被谁在何时勾选完成。 +type MessageTodoCompletion struct { + ID int `json:"id"` + CompletedBy int64 `json:"completed_by"` + Date int `json:"date"` +} + +// MessageTodo 是待办清单(messageMediaToDo)。append/toggle 经 editMessage 媒体替换链路 +// 整体更新快照(与 live location 同模式)。 +type MessageTodo struct { + OthersCanAppend bool `json:"others_can_append,omitempty"` + OthersCanComplete bool `json:"others_can_complete,omitempty"` + Title string `json:"title"` + TitleEntities []MessageEntity `json:"title_entities,omitempty"` + Items []MessageTodoItem `json:"items"` + Completions []MessageTodoCompletion `json:"completions,omitempty"` +} + +// MessageGeoPoint 是消息中携带的地理坐标点(messageMediaGeo/Venue 共用)。 +// AccessHash 在发送时随机生成,客户端会原样带回 upload.getWebFile 地图缩略请求。 +type MessageGeoPoint struct { + Lat float64 `json:"lat"` + Long float64 `json:"long"` + AccessHash int64 `json:"access_hash,omitempty"` + AccuracyRadius int `json:"accuracy_radius,omitempty"` +} + +// MessageVenue 是带场所信息的位置(messageMediaVenue)。 +type MessageVenue struct { + Geo MessageGeoPoint `json:"geo"` + Title string `json:"title"` + Address string `json:"address,omitempty"` + Provider string `json:"provider,omitempty"` + VenueID string `json:"venue_id,omitempty"` + VenueType string `json:"venue_type,omitempty"` +} + +// MessageDice 是互动 emoji(messageMediaDice)。Value 在发送落库时由服务端 +// 一次定值,此后对所有 viewer 与转发快照保持不变。 +type MessageDice struct { + Emoticon string `json:"emoticon"` + Value int `json:"value"` +} + +// MessageGeoLive 是实时位置(messageMediaGeoLive)。客户端按 message.date + Period +// 自行计算过期;停止共享 = editMessage 把 Period 改为已逝时长(立即过期),服务端无后台任务。 +type MessageGeoLive struct { + Geo MessageGeoPoint `json:"geo"` + Heading int `json:"heading,omitempty"` + Period int `json:"period"` + ProximityNotificationRadius int `json:"proximity_notification_radius,omitempty"` +} + +// MessageStory is a story shared as messageMediaStory. Peer+ID are the stable +// reference; Story is the viewer-visible snapshot captured when the message was +// sent so history/difference can replay without re-resolving the source story. +type MessageStory struct { + Peer Peer `json:"peer"` + ID int `json:"id"` + ViaMention bool `json:"via_mention,omitempty"` + Story *Story `json:"story,omitempty"` +} + +// MessageWebPageState 区分链接预览的三种 TL 形态:pending(webPagePending, +// 已知链接但预览尚未异步抓取完成)、done(webPage,已解析的预览卡片)、 +// empty(webPageEmpty,已解析但该链接无可展示内容)。 +type MessageWebPageState string + +const ( + MessageWebPageStatePending MessageWebPageState = "pending" + MessageWebPageStateDone MessageWebPageState = "done" + MessageWebPageStateEmpty MessageWebPageState = "empty" +) + +// MessageWebPage 是以 messageMediaWebPage 形式挂载的链接预览。URL 是稳定引用, +// 其余字段是服务端在解析时捕获的快照,使 history/difference 无需重新抓取即可回放 +// (与 MessageStory 同模式)。State 决定投影为 webPagePending / webPage / webPageEmpty。 +// +// ForceLargeMedia/ForceSmallMedia/Manual/Safe 是 messageMediaWebPage 外层 wrapper +// 的标志位;HasLargeMedia 是 webPage 本体的标志位。ID 必须在 pending→done 切换前后 +// 保持稳定,客户端按 webPage id 关联占位与解析结果。 +type MessageWebPage struct { + State MessageWebPageState `json:"state"` + ID int64 `json:"id"` + URL string `json:"url"` + DisplayURL string `json:"display_url,omitempty"` + Hash int `json:"hash,omitempty"` + Date int `json:"date,omitempty"` + Type string `json:"type,omitempty"` + SiteName string `json:"site_name,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + Photo *Photo `json:"photo,omitempty"` + + ForceLargeMedia bool `json:"force_large_media,omitempty"` + ForceSmallMedia bool `json:"force_small_media,omitempty"` + Manual bool `json:"manual,omitempty"` + Safe bool `json:"safe,omitempty"` + HasLargeMedia bool `json:"has_large_media,omitempty"` +} + +// MessageServiceActionKind 标识私聊服务消息动作。 +type MessageServiceActionKind string + +const ( + MessageServiceActionSuggestProfilePhoto MessageServiceActionKind = "suggest_profile_photo" + // MessageServiceActionPinMessage 映射 messageActionPinMessage:非 + // pm_oneside 私聊置顶生成的服务消息,被置顶消息经 reply_to 指向。 + MessageServiceActionPinMessage MessageServiceActionKind = "pin_message" + // MessageServiceActionPhoneCall 映射 messageActionPhoneCall:私聊通话 + // 结束(含 missed 超时)后落历史的通话条目,sender 恒为主叫。 + MessageServiceActionPhoneCall MessageServiceActionKind = "phone_call" + // MessageServiceActionBotAllowed 映射 messageActionBotAllowed:用户授权 + // bot 后在 bot 私聊中留下的服务消息。 + MessageServiceActionBotAllowed MessageServiceActionKind = "bot_allowed" + // MessageServiceActionWebViewDataSent 映射 messageActionWebViewDataSent* + //:simple webview 把 data 回传给 bot 后在私聊中留下的服务消息。 + MessageServiceActionWebViewDataSent MessageServiceActionKind = "web_view_data_sent" + // MessageServiceActionRequestedPeer 映射 messageActionRequestedPeer*:用户 + // 响应 bot 的 request-peer 按钮后,把所选 peer 作为可恢复服务消息发给 bot。 + MessageServiceActionRequestedPeer MessageServiceActionKind = "requested_peer" + // MessageServiceActionSetChatTheme 映射 messageActionSetChatTheme: + // 私聊双方共享的 chat theme token 变更。 + MessageServiceActionSetChatTheme MessageServiceActionKind = "set_chat_theme" + // MessageServiceActionStarGift 映射 messageActionStarGift:收到一份 Star 礼物。 + // 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。 + MessageServiceActionStarGift MessageServiceActionKind = "star_gift" +) + +// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。 +// Reason 取值同 PhoneCallDiscardReason;Duration 仅通话真正建立后非零。 +type MessagePhoneCallAction struct { + CallID int64 `json:"call_id"` + Reason string `json:"reason,omitempty"` + Duration int `json:"duration,omitempty"` + Video bool `json:"video,omitempty"` +} + +// MessageBotAllowedAction 是 messageActionBotAllowed 的协议中立载荷。 +type MessageBotAllowedAction struct { + AttachMenu bool `json:"attach_menu,omitempty"` + FromRequest bool `json:"from_request,omitempty"` + Domain string `json:"domain,omitempty"` +} + +const ( + MaxWebViewDataButtonTextLen = MaxBotMenuButtonTextLen + MaxWebViewDataPayloadLen = MaxMessageTextLength +) + +// MessageWebViewDataAction 是 messageActionWebViewDataSent* 的协议中立载荷。 +type MessageWebViewDataAction struct { + ButtonText string `json:"button_text"` + Data string `json:"data,omitempty"` +} + +// MessageRequestedPeerAction 是 messageActionRequestedPeer* 的协议中立载荷。 +type MessageRequestedPeerAction struct { + ButtonID int `json:"button_id"` + Peers []Peer `json:"peers"` +} + +// MessageServiceAction 是私聊服务消息动作的协议中立表示。 +type MessageServiceAction struct { + Kind MessageServiceActionKind `json:"kind"` + Photo *Photo `json:"photo,omitempty"` + Call *MessagePhoneCallAction `json:"call,omitempty"` + BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"` + WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"` + RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"` + ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"` + StarGift *MessageStarGiftAction `json:"star_gift,omitempty"` +} + +// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价) +// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方;NameHidden 时下发不暴露 from。 +type MessageStarGiftAction struct { + GiftID int64 `json:"gift_id"` + Stars int64 `json:"stars"` + ConvertStars int64 `json:"convert_stars,omitempty"` + Title string `json:"title,omitempty"` + Sticker *Document `json:"sticker,omitempty"` + Message string `json:"message,omitempty"` + FromUserID int64 `json:"from_user_id,omitempty"` + PeerUserID int64 `json:"peer_user_id,omitempty"` + PeerChannelID int64 `json:"peer_channel_id,omitempty"` + SavedID int64 `json:"saved_id,omitempty"` + NameHidden bool `json:"name_hidden,omitempty"` + Saved bool `json:"saved,omitempty"` + Converted bool `json:"converted,omitempty"` +} + // MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。 type MessageMedia struct { - Kind MessageMediaKind `json:"kind"` - Photo *Photo `json:"photo,omitempty"` - Document *Document `json:"document,omitempty"` - Spoiler bool `json:"spoiler,omitempty"` - TTLSeconds int `json:"ttl_seconds,omitempty"` - Nopremium bool `json:"nopremium,omitempty"` - Voice bool `json:"voice,omitempty"` - Round bool `json:"round,omitempty"` - Video bool `json:"video,omitempty"` + Kind MessageMediaKind `json:"kind"` + Photo *Photo `json:"photo,omitempty"` + Document *Document `json:"document,omitempty"` + Contact *MessageContact `json:"contact,omitempty"` + ServiceAction *MessageServiceAction `json:"service_action,omitempty"` + Geo *MessageGeoPoint `json:"geo,omitempty"` + Venue *MessageVenue `json:"venue,omitempty"` + Dice *MessageDice `json:"dice,omitempty"` + Poll *MessagePoll `json:"poll,omitempty"` + GeoLive *MessageGeoLive `json:"geo_live,omitempty"` + Todo *MessageTodo `json:"todo,omitempty"` + Story *MessageStory `json:"story,omitempty"` + WebPage *MessageWebPage `json:"web_page,omitempty"` + Spoiler bool `json:"spoiler,omitempty"` + TTLSeconds int `json:"ttl_seconds,omitempty"` + Nopremium bool `json:"nopremium,omitempty"` + Voice bool `json:"voice,omitempty"` + Round bool `json:"round,omitempty"` + Video bool `json:"video,omitempty"` + // InvertMedia 映射 message.invert_media:媒体(典型为链接预览)渲染在文本上方。 + // 存于媒体快照而非消息行,避免新增消息表列;读时投影为 tg.Message.invert_media。 + InvertMedia bool `json:"invert_media,omitempty"` +} + +// IsMusic reports whether the message media is a music document rather than a +// voice note, video, or generic file. +func (m *MessageMedia) IsMusic() bool { + return m != nil && + m.Kind == MessageMediaKindDocument && + m.Document != nil && + !m.Voice && + m.Document.IsMusic() +} + +// MessageContact is a shared contact card attached to a message. +type MessageContact struct { + PhoneNumber string `json:"phone_number"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Vcard string `json:"vcard,omitempty"` + UserID int64 `json:"user_id,omitempty"` } // IsZero 表示无媒体(用于落库时跳过空快照、转换时回退 MessageMediaEmpty)。 @@ -213,12 +528,27 @@ func (m *MessageMedia) IsZero() bool { return m == nil || m.Kind == MessageMediaKindNone } +// HasUnreadPayload 表示该媒体是否参与 media_unread("未听")状态。 +// 只有 voice/round 才有此语义:客户端只为它们渲染未听标记并上报 +// readMessageContents;photo/document 置位只会留下永不清除的脏状态。 +func (m *MessageMedia) HasUnreadPayload() bool { + if m.IsZero() || m.Kind != MessageMediaKindDocument { + return false + } + return m.Voice || m.Round +} + // StickerPack 是 emoji→文档 id 的映射条目(messages.stickerSet.packs)。 type StickerPack struct { Emoticon string `json:"emoticon"` DocumentIDs []int64 `json:"document_ids"` } +// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses +// 对应的系统集标识:premium 用户 emoji status 选择器的"默认状态"主体集 +// (messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。 +const StickerSetSystemKeyEmojiDefaultStatuses = "emoji_default_statuses" + // StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。 type StickerSetKind string @@ -273,6 +603,7 @@ type ProfilePhotoRef struct { DCID int Stripped []byte // photoStrippedSize 内联缩略图,可空 Personal bool // true 表示 viewer 私有联系人头像 + HasVideo bool // true 表示 photo.video_sizes 非空,需输出 userProfilePhoto.has_video } // StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。 @@ -285,6 +616,17 @@ func StrippedFromSizes(sizes []PhotoSize) []byte { return nil } +// PhotoHasVideo 从照片尺寸快照推导 UserProfilePhoto.has_video。 +func PhotoHasVideo(sizes []PhotoSize) bool { + for _, s := range sizes { + switch s.Kind { + case PhotoSizeKindVideo, PhotoSizeKindVideoEmojiMarkup, PhotoSizeKindVideoStickerMarkup: + return true + } + } + return false +} + // StickerSetRefKind 标识 InputStickerSet 的解析方式。 type StickerSetRefKind string @@ -319,6 +661,24 @@ type AvailableReaction struct { Order int `json:"order,omitempty"` } +// AvailableEffect 是一条消息发送特效(messages.getAvailableEffects)。引用三个文档: +// 选择器静态图标 / 气泡上的特效贴纸 / 全屏特效动画(后两者可为 0)。属全局静态目录, +// 服务端 seed 后常驻内存。 +type AvailableEffect 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"` + Order int `json:"order,omitempty"` +} + +// DocumentIDs 收集该 effect 引用的全部文档 id(去零去重)。 +func (e AvailableEffect) DocumentIDs() []int64 { + return dedupNonZeroIDs(e.StaticIconID, e.EffectStickerID, e.EffectAnimationID) +} + // DocumentIDs 收集该 reaction 引用的全部文档 id(去零去重,便于批量加载)。 func (r AvailableReaction) DocumentIDs() []int64 { raw := []int64{ @@ -339,3 +699,20 @@ func (r AvailableReaction) DocumentIDs() []int64 { } return out } + +// dedupNonZeroIDs 返回去零去重后的 id 列表(保序)。 +func dedupNonZeroIDs(ids ...int64) []int64 { + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} diff --git a/internal/domain/media_category.go b/internal/domain/media_category.go new file mode 100644 index 00000000..6fcb41fa --- /dev/null +++ b/internal/domain/media_category.go @@ -0,0 +1,157 @@ +package domain + +// MediaCategory 是共享媒体标签页的基础类别。一条消息可同时属于多个类别 +// (例如带链接的图片 = Photo + URL)。落库为媒体索引表的 category 列(SMALLINT)。 +// +// 这里只存"基础类别";客户端的复合过滤器(PhotoVideo / RoundVoice)在查询期映射为 +// 多个基础类别的并集(见 MediaCategoriesForFilter 的调用方)。 +type MediaCategory int16 + +const ( + MediaCategoryNone MediaCategory = 0 + MediaCategoryPhoto MediaCategory = 1 // media.kind=photo + MediaCategoryVideo MediaCategory = 2 // document 含 video 属性且非 round + MediaCategoryGif MediaCategory = 3 // document 含 animated 属性 + MediaCategoryFile MediaCategory = 4 // 通用文档(无 video/audio/animated/sticker 属性) + MediaCategoryMusic MediaCategory = 5 // document 含 audio 属性且 voice=false + MediaCategoryVoice MediaCategory = 6 // document 含 audio 属性且 voice=true + MediaCategoryRoundVideo MediaCategory = 7 // document 含 video 属性且 round_message=true(视频消息) + MediaCategoryURL MediaCategory = 8 // 文本含 url/text_url/email 实体或 messageMediaWebPage + MediaCategoryPoll MediaCategory = 9 // media.kind=poll +) + +// MediaCategoryCounts 是共享媒体索引按基础类别聚合出的精确计数。 +type MediaCategoryCounts map[MediaCategory]int + +// CountAny 返回给定基础类别并集的计数。当前复合 filter 只由互斥类别组成 +// (PhotoVideo=Photo+Video, RoundVoice=Voice+RoundVideo),因此可直接相加。 +func (c MediaCategoryCounts) CountAny(categories []MediaCategory) int { + if len(categories) == 0 { + return 0 + } + seen := make(map[MediaCategory]struct{}, len(categories)) + total := 0 + for _, category := range categories { + if category == MediaCategoryNone { + continue + } + if _, ok := seen[category]; ok { + continue + } + seen[category] = struct{}{} + total += c[category] + } + return total +} + +// MediaSearchRequest 是共享媒体标签页分页查询的入参(messages.search 媒体过滤分支)。 +// Categories 是该标签页映射到的基础类别并集(PhotoVideo→[Photo,Video]、RoundVoice→[Voice,RoundVideo])。 +// 分页对齐历史语义:OffsetID 为游标(返回 id 严格小于它)、AddOffset 为额外偏移、MaxID/MinID 为闭区间。 +type MediaSearchRequest struct { + Categories []MediaCategory + OffsetID int + AddOffset int + Limit int + MaxID int + MinID int + KnownCount int + HasKnownCount bool +} + +// ClassifyMediaCategories 返回一条消息所属的全部共享媒体类别(可为空:无媒体且无链接, +// 或贴纸/地理/联系人等不进任何媒体标签页的载荷)。是媒体索引的唯一分类真值,写路径据此 +// 维护索引、迁移回填须与其语义一致。 +func ClassifyMediaCategories(media *MessageMedia, entities []MessageEntity) []MediaCategory { + cats := make([]MediaCategory, 0, 2) + add := func(category MediaCategory) { + if category == MediaCategoryNone { + return + } + for _, existing := range cats { + if existing == category { + return + } + } + cats = append(cats, category) + } + if media != nil { + switch media.Kind { + case MessageMediaKindPhoto: + add(MediaCategoryPhoto) + case MessageMediaKindDocument: + if c, ok := classifyDocumentCategory(media.Document); ok { + add(c) + } + case MessageMediaKindPoll: + add(MediaCategoryPoll) + case MessageMediaKindWebPage: + add(MediaCategoryURL) + } + } + if hasURLEntity(entities) { + add(MediaCategoryURL) + } + return cats +} + +// classifyDocumentCategory 按 TL DocumentAttribute 判定一个文档落入哪个媒体类别。 +// 客户端的标签页 UI 以 TL 属性(而非 MIME)为准,故这里也只看属性。优先级: +// sticker(不入任何标签页)> animated(GIF) > audio(music/voice) > video(video/round) > 通用文件。 +func classifyDocumentCategory(doc *Document) (MediaCategory, bool) { + if doc == nil { + return MediaCategoryNone, false + } + var ( + hasSticker bool + hasAnimated bool + hasAudio bool + audioVoice bool + hasVideo bool + videoRound bool + ) + for _, a := range doc.Attributes { + switch a.Kind { + case DocAttrSticker, DocAttrCustomEmoji: + hasSticker = true + case DocAttrAnimated: + hasAnimated = true + case DocAttrAudio: + hasAudio = true + if a.Voice { + audioVoice = true + } + case DocAttrVideo: + hasVideo = true + if a.RoundMessage { + videoRound = true + } + } + } + switch { + case hasSticker: + return MediaCategoryNone, false // 贴纸/自定义 emoji 不出现在共享媒体 + case hasAnimated: + return MediaCategoryGif, true + case hasAudio: + if audioVoice { + return MediaCategoryVoice, true + } + return MediaCategoryMusic, true + case hasVideo: + if videoRound { + return MediaCategoryRoundVideo, true + } + return MediaCategoryVideo, true + default: + return MediaCategoryFile, true + } +} + +func hasURLEntity(entities []MessageEntity) bool { + for _, e := range entities { + if e.Type == MessageEntityURL || e.Type == MessageEntityTextURL || e.Type == MessageEntityEmail { + return true + } + } + return false +} diff --git a/internal/domain/media_category_test.go b/internal/domain/media_category_test.go new file mode 100644 index 00000000..25e88275 --- /dev/null +++ b/internal/domain/media_category_test.go @@ -0,0 +1,51 @@ +package domain + +import ( + "reflect" + "testing" +) + +func TestClassifyMediaCategories(t *testing.T) { + doc := func(attrs ...DocumentAttribute) *MessageMedia { + return &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{Attributes: attrs}} + } + urlEnt := []MessageEntity{{Type: MessageEntityURL}} + textURLEnt := []MessageEntity{{Type: MessageEntityTextURL}} + emailEnt := []MessageEntity{{Type: MessageEntityEmail}} + + cases := []struct { + name string + media *MessageMedia + entities []MessageEntity + want []MediaCategory + }{ + {"nil media no entities", nil, nil, []MediaCategory{}}, + {"photo", &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{}}, nil, []MediaCategory{MediaCategoryPhoto}}, + {"poll", &MessageMedia{Kind: MessageMediaKindPoll}, nil, []MediaCategory{MediaCategoryPoll}}, + {"video", doc(DocumentAttribute{Kind: DocAttrVideo}), nil, []MediaCategory{MediaCategoryVideo}}, + {"round video note", doc(DocumentAttribute{Kind: DocAttrVideo, RoundMessage: true}), nil, []MediaCategory{MediaCategoryRoundVideo}}, + {"gif animation", doc(DocumentAttribute{Kind: DocAttrAnimated}), nil, []MediaCategory{MediaCategoryGif}}, + {"music", doc(DocumentAttribute{Kind: DocAttrAudio}), nil, []MediaCategory{MediaCategoryMusic}}, + {"voice", doc(DocumentAttribute{Kind: DocAttrAudio, Voice: true}), nil, []MediaCategory{MediaCategoryVoice}}, + {"generic file", doc(DocumentAttribute{Kind: DocAttrFilename, FileName: "x.pdf"}), nil, []MediaCategory{MediaCategoryFile}}, + {"sticker excluded", doc(DocumentAttribute{Kind: DocAttrSticker}), nil, []MediaCategory{}}, + {"animated sticker excluded", doc(DocumentAttribute{Kind: DocAttrSticker}, DocumentAttribute{Kind: DocAttrAnimated}), nil, []MediaCategory{}}, + {"photo with url", &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{}}, urlEnt, []MediaCategory{MediaCategoryPhoto, MediaCategoryURL}}, + {"text-only url", nil, urlEnt, []MediaCategory{MediaCategoryURL}}, + {"text-only text_url", nil, textURLEnt, []MediaCategory{MediaCategoryURL}}, + {"text-only email", nil, emailEnt, []MediaCategory{MediaCategoryURL}}, + {"webpage media", &MessageMedia{Kind: MessageMediaKindWebPage, WebPage: &MessageWebPage{State: MessageWebPageStateDone, ID: 1, URL: "https://example.test"}}, nil, []MediaCategory{MediaCategoryURL}}, + {"webpage media with url entity deduped", &MessageMedia{Kind: MessageMediaKindWebPage, WebPage: &MessageWebPage{State: MessageWebPageStateDone, ID: 1, URL: "https://example.test"}}, urlEnt, []MediaCategory{MediaCategoryURL}}, + {"file with link", doc(DocumentAttribute{Kind: DocAttrFilename}), urlEnt, []MediaCategory{MediaCategoryFile, MediaCategoryURL}}, + {"geo not indexed", &MessageMedia{Kind: MessageMediaKindGeo}, nil, []MediaCategory{}}, + {"contact not indexed", &MessageMedia{Kind: MessageMediaKindContact}, nil, []MediaCategory{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ClassifyMediaCategories(tc.media, tc.entities) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("ClassifyMediaCategories = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/domain/media_errors.go b/internal/domain/media_errors.go index 003ed2e8..edead76f 100644 --- a/internal/domain/media_errors.go +++ b/internal/domain/media_errors.go @@ -2,15 +2,13 @@ package domain import "errors" -// 媒体 / 文件相关业务错误。rpc 层据此映射为对应 rpc_error(见 internal/rpc/errors.go)。 +// 文件分片上传与媒体相关业务错误。rpc 层据此映射为对应 rpc_error(见 internal/rpc/errors.go): +// 文件分片(part invalid/parts invalid/part too big)、上传配额、照片与文档。 var ( - ErrFilePartInvalid = errors.New("file part invalid") - ErrFilePartsInvalid = errors.New("file parts invalid") - ErrFilePartTooBig = errors.New("file part too big") - ErrFileReference = errors.New("file reference invalid") - ErrMediaInvalid = errors.New("media invalid") - ErrMediaEmpty = errors.New("media empty") - ErrPhotoInvalid = errors.New("photo invalid") - ErrStickersetInvalid = errors.New("stickerset invalid") - ErrDocumentInvalid = errors.New("document invalid") + ErrFilePartInvalid = errors.New("file part invalid") + ErrFilePartsInvalid = errors.New("file parts invalid") + ErrFilePartTooBig = errors.New("file part too big") + ErrUploadQuotaExceeded = errors.New("upload quota exceeded") + ErrPhotoInvalid = errors.New("photo invalid") + ErrDocumentInvalid = errors.New("document invalid") ) diff --git a/internal/domain/message.go b/internal/domain/message.go index fdb02809..7a1c5cab 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -4,7 +4,25 @@ package domain type MessageEntityType string const ( - MessageEntityBold MessageEntityType = "bold" + MessageEntityBold MessageEntityType = "bold" + MessageEntityItalic MessageEntityType = "italic" + MessageEntityUnderline MessageEntityType = "underline" + MessageEntityStrike MessageEntityType = "strike" + MessageEntityCode MessageEntityType = "code" + MessageEntityPre MessageEntityType = "pre" + MessageEntityTextURL MessageEntityType = "text_url" + MessageEntityMentionName MessageEntityType = "mention_name" + MessageEntitySpoiler MessageEntityType = "spoiler" + MessageEntityBlockquote MessageEntityType = "blockquote" + MessageEntityCustomEmoji MessageEntityType = "custom_emoji" + MessageEntityMention MessageEntityType = "mention" + MessageEntityHashtag MessageEntityType = "hashtag" + MessageEntityCashtag MessageEntityType = "cashtag" + MessageEntityBotCommand MessageEntityType = "bot_command" + MessageEntityURL MessageEntityType = "url" + MessageEntityEmail MessageEntityType = "email" + MessageEntityPhone MessageEntityType = "phone" + MessageEntityBankCard MessageEntityType = "bank_card" ) const ( @@ -55,7 +73,11 @@ func ValidateMessageReplyBounds(reply *MessageReply) error { if reply.TopMessageID < 0 || reply.TopMessageID > MaxMessageBoxID { return ErrReplyMessageIDInvalid } - if reply.MessageID == 0 && reply.TopMessageID == 0 { + if reply.StoryID < 0 || reply.StoryID > MaxStoryID { + return ErrReplyMessageIDInvalid + } + // story 回复(StoryID>0)不携带 MessageID/TopMessageID;普通回复至少有其一。 + if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 { return ErrReplyMessageIDInvalid } if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset { @@ -69,6 +91,16 @@ type MessageEntity struct { Type MessageEntityType Offset int Length int + // URL 仅 text_url 使用。 + URL string + // UserID 仅 mention_name 使用。 + UserID int64 + // Language 仅 pre 使用。 + Language string + // DocumentID 仅 custom_emoji 使用。 + DocumentID int64 + // Collapsed 仅 blockquote 使用。 + Collapsed bool } // Message 是账号视角下的一条私聊消息。 @@ -90,9 +122,56 @@ type Message struct { Forward *MessageForward Reactions *ChannelMessageReactions Pts int + TTLPeriod int + ExpiresAt int Media *MessageMedia MediaUnread bool ReactionUnread bool + ViaBotID int64 + // GroupedID 是相册分组 id:同一次 sendMultiMedia 的各条消息共享同一非零值, + // 客户端据此把它们渲染成一个相册组。非相册消息恒 0。双盒持同一值。 + GroupedID int64 + // Effect 是消息特效 id(message.effect,flags2.2?long):私聊 1-1 专属动画特效 + // (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。 + // 转发不携带特效(新消息恒 0)。仅私聊;群/频道不渲染。 + Effect int64 + // ReplyMarkup 是 bot 消息携带的 inline keyboard 快照(P3)。仅 bot 出站消息可 + // 非空;普通用户消息恒 nil(发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。 + ReplyMarkup *MessageReplyMarkup + // RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。 + RichMessage *MessageRichMessage + // Pinned 是 owner 视角的置顶标志(官方私聊多置顶语义:双方各自 + // 的 box 行独立持有,非 pm_oneside 操作两侧同步翻转)。 + Pinned bool + // SavedPeer 是 Saved Messages 分会话分组键(message.saved_peer_id)。 + // 仅 self-chat box 行非零:直发笔记 = self;转发进收藏夹 = 源会话 peer; + // 存量回填兜底 hidden author 占位 user 2666000。非 self-chat 行恒零值。 + SavedPeer Peer +} + +// MessageRichMessage 是 Layer 227 富文本消息(richMessage)的协议中立快照:一组 IV +// PageBlock(Blocks)+ 内嵌已解析的 Photos/Documents。 +// +// Blocks 存 gotd TL 序列化后的 []tg.PageBlockClass 不透明字节——PageBlock 体系庞大且 +// input(inputRichMessage.blocks) 与 output(richMessage.blocks) 同构、原样透传,故不在 +// domain 逐类型建模;rpc 层负责 tg.PageBlock 向量 ↔ bytes 的序列化(domain 不依赖 tg)。 +// 与 message media 同理,Photos/Documents 存已解析快照(含 viewer 无关的 access_hash), +// 投影复用 tgPhoto/tgDocument。Phase 1 仅支持 inputRichMessage(blocks 形态),不解析 +// HTML/Markdown 变体。 +// +// 已知局限:Blocks 是 gotd 线格式不透明字节,跨 gotd 版本(PageBlock 构造器变更)可能 +// 失效——富文本消息为全新实验特性、无存量数据,Phase 1 接受该耦合。 +type MessageRichMessage struct { + Rtl bool `json:"rtl,omitempty"` + Part bool `json:"part,omitempty"` + Blocks []byte `json:"blocks,omitempty"` + Photos []Photo `json:"photos,omitempty"` + Documents []Document `json:"documents,omitempty"` +} + +// IsZero 表示无富文本载荷(落库时跳过空快照、投影时不下发 rich_message)。 +func (m *MessageRichMessage) IsZero() bool { + return m == nil || (len(m.Blocks) == 0 && len(m.Photos) == 0 && len(m.Documents) == 0) } // MessageReply describes a message reply/thread header without depending on TL types. @@ -104,6 +183,9 @@ type MessageReply struct { QuoteText string QuoteEntities []MessageEntity QuoteOffset int + // StoryID > 0 表示这是一条对 story 的回复(评论):MessageID 为 0,Peer 为 story 作者, + // 投影为 messageReplyStoryHeader 而非普通 messageReplyHeader。 + StoryID int } // MessageForward 描述一条转发消息的原始作者信息。 @@ -126,17 +208,24 @@ type MessageList struct { // MessageFilter 描述历史/搜索查询条件。 type MessageFilter struct { - HasPeer bool - Peer Peer - Query string - OffsetID int - OffsetDate int - AddOffset int - Limit int - MaxID int - MinID int - Hash int64 + HasPeer bool + Peer Peer + Query string + OffsetID int + OffsetDate int + AddOffset int + Limit int + MaxID int + MinID int + Hash int64 + // PinnedOnly 仅返回置顶消息(messages.search filterPinned 与 + // userFull.pinned_msg_id 的查询路径)。 + PinnedOnly bool + MusicOnly bool NeedTotalCount bool + // SavedPeer 非零时仅返回 self-chat 中该 saved 子会话的消息 + // (messages.getSavedHistory);Peer 必须同时是 self。 + SavedPeer Peer } // SendPrivateTextRequest 是私聊文本/媒体发送命令。 @@ -155,6 +244,19 @@ type SendPrivateTextRequest struct { OriginAuthKeyID [8]byte OriginSessionID int64 RecipientBlocked bool + TTLPeriod int + ViaBotID int64 + // GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。 + GroupedID int64 + // Effect 消息特效 id(私聊专属,0 表无特效;调用方已对 catalog 校验过合法性)。 + Effect int64 + // BusinessAutomationKind is internal app-layer metadata used to suppress + // recursive greeting/away automation for server-generated replies. + BusinessAutomationKind BusinessAutomationKind + // ReplyMarkup 是 bot 出站消息的 inline keyboard 快照(P3);普通用户发送恒 nil。 + ReplyMarkup *MessageReplyMarkup + // RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。 + RichMessage *MessageRichMessage } // SendPrivateTextResult 描述一次私聊文本发送的双端结果。 @@ -166,6 +268,26 @@ type SendPrivateTextResult struct { Duplicate bool } +// SetPrivateChatThemeRequest changes the shared theme token for a private dialog. +type SetPrivateChatThemeRequest struct { + OwnerUserID int64 + Peer Peer + Emoticon string + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 + RecipientBlocked bool +} + +// SetPrivateChatThemeResult describes the state change and optional service message. +type SetPrivateChatThemeResult struct { + OwnerUserID int64 + Peer Peer + Emoticon string + Changed bool + Send SendPrivateTextResult +} + // SetPrivateMessageReactionsRequest replaces the current user's reactions for one private message. type SetPrivateMessageReactionsRequest struct { UserID int64 @@ -175,6 +297,9 @@ type SetPrivateMessageReactionsRequest struct { Big bool AddToRecent bool Date int + // ReactionsPerUserMax 是 viewer 的每用户上限档位(premium 双档); + // 0 表示未填,store 侧按默认档裁剪。 + ReactionsPerUserMax int } // PrivateMessageReactionsRequest fetches reaction summaries for exact private message ids. @@ -205,6 +330,7 @@ type ForwardPrivateMessagesRequest struct { OriginAuthKeyID [8]byte OriginSessionID int64 RecipientBlocked bool + TTLPeriod int } // ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。 @@ -233,6 +359,7 @@ type ReadHistoryResult struct { Peer Peer MaxID int StillUnreadCount int + ChannelPts int Changed bool InboxEvent UpdateEvent OutboxChanged bool @@ -254,6 +381,10 @@ type ReadMessageContentsResult struct { OwnerUserID int64 MessageIDs []int Event UpdateEvent + // SenderEvents 是对端发送者的内容已读回执:reader 听完 voice/round 后, + // 每个原 sender 收到一条 updateReadMessagesContents,messages 用 sender + // 自己视角的 box id。 + SenderEvents []UpdateEvent } // OutboxReadDateRequest 是 messages.getOutboxReadDate 查询。 @@ -263,16 +394,35 @@ type OutboxReadDateRequest struct { ID int } -// EditMessageRequest 是账号视角下编辑一条已发送私聊文本消息的命令。 +// EditMessageRequest 是账号视角下编辑一条已发送私聊消息的命令。 +// Media 非 nil 时整体替换消息媒体快照(当前唯一调用方是 live location 续报/停止, +// rpc 层负责限定媒体种类);nil 表示纯文本编辑。 type EditMessageRequest struct { OwnerUserID int64 Peer Peer ID int Message string Entities []MessageEntity + Media *MessageMedia EditDate int OriginAuthKeyID [8]byte OriginSessionID int64 + // SetReplyMarkup 置位时替换 reply_markup(ReplyMarkup 为 nil/空 = 清空键盘); + // 未置位则保留原 markup。仅 bot 编辑自己消息时由 RPC 层置位(P3)。 + SetReplyMarkup bool + ReplyMarkup *MessageReplyMarkup + // ViaBotEditBotID 非零时允许对应 bot 编辑经由它发送的 inline 私聊消息。 + ViaBotEditBotID int64 + // AllowTodoParticipantMutation 允许 checklist 参与者在 others_can_* 授权下通过 + // edit 事件替换 todo 媒体快照。仅 RPC todo handler 设置;store 仍会限制为 + // todo->todo 且正文/entities/markup 不变,避免普通消息越权编辑。 + AllowTodoParticipantMutation bool + // WebPageResolve 置位时为服务端内部「链接预览就地替换」:仅替换 media(不碰 body/ + // entities/edit_date,故不标记「已编辑」),生成 UpdateEventWebPage 而非 edit_message。 + // 幂等守卫:仅当目标当前 media 仍是 ID==ExpectedWebPageID 的 pending 链接预览才替换, + // 否则返回 ErrMessageNotModified(消息已删/已改/已解析)。 + WebPageResolve bool + ExpectedWebPageID int64 } // EditedMessageForUser 描述一次编辑对某个 owner 视角造成的影响。 @@ -315,9 +465,13 @@ type DeleteMessagesRequest struct { // DeleteHistoryRequest 是账号视角下清空某个 peer 历史的命令。 type DeleteHistoryRequest struct { - OwnerUserID int64 - Peer Peer - MaxID int + OwnerUserID int64 + Peer Peer + MaxID int + // MinDate/MaxDate 是"按日期删除"的闭区间(unix 秒,0 表示不限); + // 客户端先本地销毁再发请求,服务端静默忽略会造成删除复活。 + MinDate int + MaxDate int JustClear bool Revoke bool Date int @@ -358,3 +512,151 @@ func (r DeleteMessagesResult) Changed() bool { } return false } + +// PinPrivateMessageRequest 是 messages.updatePinnedMessage 的私聊命令 +// (含 Saved Messages = self peer)。 +type PinPrivateMessageRequest struct { + OwnerUserID int64 + Peer Peer + MessageID int + Pinned bool + // PmOneside 仅置顶在本侧(官方私聊置顶框"同时为对方置顶"未勾选时), + // 不向对端翻转、不生成服务消息。unpin 无此语义,恒双侧清除。 + PmOneside bool + Silent bool + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// PinnedMessagesForUser 描述置顶状态变化对某个 owner 视角的影响。 +type PinnedMessagesForUser struct { + UserID int64 + Peer Peer + MessageIDs []int + Pinned bool + Event UpdateEvent +} + +// MaxUnpinAllBatch 限制单次 unpinAllMessages 实际清除的置顶数量; +// 超出部分由客户端按 affectedHistory.Offset>0 续发清除,单条 +// updatePinnedMessages 的 messages 向量随之有界。 +const MaxUnpinAllBatch = 1000 + +// PinPrivateMessageResult 描述私聊置顶/取消置顶的 owner 维度结果。 +// Updated 为空表示状态未变化(幂等 no-op,不烧 pts)。 +type PinPrivateMessageResult struct { + OwnerUserID int64 + Updated []PinnedMessagesForUser + // Offset 非 0 表示 unpinAll 还有剩余批次待清。 + Offset int +} + +// Self 返回当前请求账号的置顶结果。 +func (r PinPrivateMessageResult) Self() PinnedMessagesForUser { + for _, item := range r.Updated { + if item.UserID == r.OwnerUserID { + return item + } + } + return PinnedMessagesForUser{UserID: r.OwnerUserID} +} + +// Changed 表示本次操作是否实际翻转了任何 owner 视角的置顶状态。 +func (r PinPrivateMessageResult) Changed() bool { + for _, item := range r.Updated { + if len(item.MessageIDs) > 0 { + return true + } + } + return false +} + +// UnpinAllPrivateMessagesRequest 是 messages.unpinAllMessages 的私聊命令。 +type UnpinAllPrivateMessagesRequest struct { + OwnerUserID int64 + Peer Peer + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// ScheduledMessage is a pending account-owned message that has not entered +// normal peer history yet. +type ScheduledMessage struct { + OwnerUserID int64 + ID int + Peer Peer + RandomID int64 + Message string + Entities []MessageEntity + Media *MessageMedia + Silent bool + NoForwards bool + ReplyTo *MessageReply + Forward *MessageForward + SendAs *Peer + ScheduleDate int + ScheduleRepeatPeriod int + CreatedAt int + UpdatedAt int + State string + SentMessageID int +} + +// ScheduledMessageList is a bounded scheduled queue page. +type ScheduledMessageList struct { + Messages []ScheduledMessage + Count int + Hash int64 +} + +// ScheduleMessageRequest creates one scheduled message. +type ScheduleMessageRequest struct { + OwnerUserID int64 + Peer Peer + RandomID int64 + Message string + Entities []MessageEntity + Media *MessageMedia + Silent bool + NoForwards bool + ReplyTo *MessageReply + Forward *MessageForward + SendAs *Peer + ScheduleDate int + ScheduleRepeatPeriod int + Date int +} + +// EditScheduledMessageRequest updates one pending scheduled message before it +// enters normal history. +type EditScheduledMessageRequest struct { + OwnerUserID int64 + Peer Peer + ID int + SetMessage bool + Message string + Entities []MessageEntity + ScheduleDate int + Date int +} + +// ScheduledMessageFilter selects scheduled messages for one owner/peer. +type ScheduledMessageFilter struct { + OwnerUserID int64 + Peer Peer + IDs []int + Limit int + Hash int64 +} + +// ScheduledMessageClaim describes a due/manual scheduled dispatch claim. +type ScheduledMessageClaim struct { + OwnerUserID int64 + Peer Peer + IDs []int + Now int + Limit int + LeaseUntil int +} diff --git a/internal/domain/message_errors.go b/internal/domain/message_errors.go index 6686e2ce..8a1e49f7 100644 --- a/internal/domain/message_errors.go +++ b/internal/domain/message_errors.go @@ -4,9 +4,16 @@ import "errors" var ( ErrMessageIDInvalid = errors.New("message id invalid") + ErrMessageEmpty = errors.New("message empty") ErrMessageAuthorRequired = errors.New("message author required") ErrMessageNotModified = errors.New("message not modified") ErrMessageNotReadYet = errors.New("message not read yet") ErrReplyMessageIDInvalid = errors.New("reply message id invalid") ErrChatForwardsRestricted = errors.New("chat forwards restricted") + // ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH:收藏夹子会话置顶 + // 数量达到 MaxPinnedSavedDialogs 上限。 + ErrPinnedSavedDialogsTooMuch = errors.New("pinned saved dialogs too much") + // ErrPinnedDialogsTooMuch 映射 PINNED_DIALOGS_TOO_MUCH:目标 folder 内 + // 置顶会话数量达到上限。 + ErrPinnedDialogsTooMuch = errors.New("pinned dialogs too much") ) diff --git a/internal/domain/message_markup.go b/internal/domain/message_markup.go new file mode 100644 index 00000000..d25093a9 --- /dev/null +++ b/internal/domain/message_markup.go @@ -0,0 +1,150 @@ +package domain + +import ( + "errors" + "net/url" + "strings" + "unicode/utf8" +) + +const ( + // MaxMarkupRows 限制 inline keyboard 行数。 + MaxMarkupRows = 100 + // MaxMarkupButtonsPerRow 限制单行按钮数。 + MaxMarkupButtonsPerRow = 8 + // MaxMarkupButtonsTotal 限制 inline keyboard 总按钮数(对齐官方约 100)。 + MaxMarkupButtonsTotal = 100 + // MaxCallbackDataLen 是 callback 按钮 data 的字节上限(对齐 Bot API 1-64)。 + MaxCallbackDataLen = 64 + // MaxMarkupButtonTextLen 是按钮文本长度上限(rune 计数)。 + MaxMarkupButtonTextLen = 256 + // MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。 + MaxBotCallbackAnswerLen = 200 + // MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64)。 + MaxStartParamLen = 64 +) + +// markup / callback 业务错误。 +var ( + // ErrButtonDataInvalid 表示 callback data 越界(>64 字节)。 + ErrButtonDataInvalid = errors.New("button data invalid") + // ErrButtonInvalid 表示键盘结构非法(行/按钮数超限、文本空/过长)。 + ErrButtonInvalid = errors.New("button invalid") + // ErrButtonURLInvalid 表示 url 按钮的链接非法(非 https)。 + ErrButtonURLInvalid = errors.New("button url invalid") + // ErrButtonTypeInvalid 表示按钮类型 P3 不支持(webview/game/url_auth/request_* 等)。 + ErrButtonTypeInvalid = errors.New("button type invalid") + // ErrStartParamInvalid 表示 startBot 的 start_param 越界。 + ErrStartParamInvalid = errors.New("start param invalid") +) + +// MarkupButtonType 标识 P3 支持的 inline 按钮类型。 +type MarkupButtonType string + +const ( + // MarkupButtonCallback 是 keyboardButtonCallback(点击触发 getBotCallbackAnswer)。 + MarkupButtonCallback MarkupButtonType = "callback" + // MarkupButtonURL 是 keyboardButtonUrl(点击打开链接)。 + MarkupButtonURL MarkupButtonType = "url" +) + +// MarkupButton 是一颗 inline keyboard 按钮(P3 仅 callback/url)。 +type MarkupButton struct { + Type MarkupButtonType `json:"type"` + Text string `json:"text"` + // Data 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位)。json 自动 base64 + // 编解码,保证经 JSONB 列字节级 round-trip(updateBotCallbackQuery.data 须原样)。 + Data []byte `json:"data,omitempty"` + // URL 仅 url 使用。 + URL string `json:"url,omitempty"` + // RequiresPassword 仅 callback 使用(keyboardButtonCallback.requires_password, + // 2FA SRP 校验 P3 stub)。 + RequiresPassword bool `json:"requires_password,omitempty"` +} + +// MessageReplyMarkup 是消息携带的 inline keyboard 快照(P3 仅 ReplyInlineMarkup)。 +type MessageReplyMarkup struct { + Inline [][]MarkupButton `json:"inline,omitempty"` +} + +// IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。 +func (m *MessageReplyMarkup) IsZero() bool { + if m == nil { + return true + } + for _, row := range m.Inline { + if len(row) > 0 { + return false + } + } + return true +} + +// ValidateReplyMarkup 校验 inline keyboard 结构与各按钮,校验须先于落库(I9)。 +// 空 markup 合法(视为清空/无键盘)。 +func ValidateReplyMarkup(m *MessageReplyMarkup) error { + if m == nil { + return nil + } + if len(m.Inline) > MaxMarkupRows { + return ErrButtonInvalid + } + total := 0 + for _, row := range m.Inline { + if len(row) > MaxMarkupButtonsPerRow { + return ErrButtonInvalid + } + total += len(row) + if total > MaxMarkupButtonsTotal { + return ErrButtonInvalid + } + for i := range row { + if err := validateMarkupButton(row[i]); err != nil { + return err + } + } + } + return nil +} + +func validateMarkupButton(b MarkupButton) error { + text := strings.TrimSpace(b.Text) + if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen { + return ErrButtonInvalid + } + switch b.Type { + case MarkupButtonCallback: + if len(b.Data) > MaxCallbackDataLen { + return ErrButtonDataInvalid + } + case MarkupButtonURL: + if err := validateButtonURL(b.URL); err != nil { + return err + } + default: + // webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。 + return ErrButtonTypeInvalid + } + return nil +} + +func validateButtonURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" || len(raw) > MaxBotMenuButtonURLLen { + return ErrButtonURLInvalid + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" { + return ErrButtonURLInvalid + } + return nil +} + +// BotCallbackAnswer 是 bot 对一次 callback query 的应答(setBotCallbackAnswer → +// 解挂等待中的 getBotCallbackAnswer)。 +type BotCallbackAnswer struct { + Alert bool + Message string + URL string + CacheTime int +} diff --git a/internal/domain/message_markup_test.go b/internal/domain/message_markup_test.go new file mode 100644 index 00000000..d9d69ffb --- /dev/null +++ b/internal/domain/message_markup_test.go @@ -0,0 +1,77 @@ +package domain + +import ( + "errors" + "strings" + "testing" +) + +func cb(text string, data []byte) MarkupButton { + return MarkupButton{Type: MarkupButtonCallback, Text: text, Data: data} +} + +func TestValidateReplyMarkup(t *testing.T) { + tests := []struct { + name string + m *MessageReplyMarkup + want error + }{ + {"nil ok", nil, nil}, + {"empty ok", &MessageReplyMarkup{}, nil}, + {"callback ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("ok", []byte("d"))}}}, nil}, + {"callback 64B ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("ok", make([]byte, 64))}}}, nil}, + {"callback 65B bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("ok", make([]byte, 65))}}}, ErrButtonDataInvalid}, + {"empty text bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("", []byte("d"))}}}, ErrButtonInvalid}, + {"url https ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "https://example.com/x"}}}}, nil}, + {"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid}, + {"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid}, + {"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid}, + {"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "webview", Text: "x"}}}}, ErrButtonTypeInvalid}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ValidateReplyMarkup(tt.m); !errors.Is(err, tt.want) { + t.Fatalf("ValidateReplyMarkup = %v, want %v", err, tt.want) + } + }) + } +} + +func TestValidateReplyMarkupLimits(t *testing.T) { + // 行数上限。 + tooManyRows := &MessageReplyMarkup{Inline: make([][]MarkupButton, MaxMarkupRows+1)} + for i := range tooManyRows.Inline { + tooManyRows.Inline[i] = []MarkupButton{cb("x", []byte("d"))} + } + if err := ValidateReplyMarkup(tooManyRows); !errors.Is(err, ErrButtonInvalid) { + t.Fatalf("rows over limit = %v, want ErrButtonInvalid", err) + } + // 单行按钮数上限。 + wideRow := make([]MarkupButton, MaxMarkupButtonsPerRow+1) + for i := range wideRow { + wideRow[i] = cb("x", []byte("d")) + } + if err := ValidateReplyMarkup(&MessageReplyMarkup{Inline: [][]MarkupButton{wideRow}}); !errors.Is(err, ErrButtonInvalid) { + t.Fatalf("row width over limit = %v, want ErrButtonInvalid", err) + } + // 文本长度上限。 + longText := strings.Repeat("a", MaxMarkupButtonTextLen+1) + if err := ValidateReplyMarkup(&MessageReplyMarkup{Inline: [][]MarkupButton{{cb(longText, []byte("d"))}}}); !errors.Is(err, ErrButtonInvalid) { + t.Fatalf("text over limit = %v, want ErrButtonInvalid", err) + } +} + +func TestMessageReplyMarkupIsZero(t *testing.T) { + if !(*MessageReplyMarkup)(nil).IsZero() { + t.Fatal("nil markup must be zero") + } + if !(&MessageReplyMarkup{}).IsZero() { + t.Fatal("empty markup must be zero") + } + if !(&MessageReplyMarkup{Inline: [][]MarkupButton{{}}}).IsZero() { + t.Fatal("markup with only empty rows must be zero") + } + if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() { + t.Fatal("markup with a button must not be zero") + } +} diff --git a/internal/domain/notify.go b/internal/domain/notify.go new file mode 100644 index 00000000..409dfe3a --- /dev/null +++ b/internal/domain/notify.go @@ -0,0 +1,70 @@ +package domain + +// NotifyScopeKind 区分通知设置的作用域:具体 peer / 三类全局默认(私聊/群/频道)。 +type NotifyScopeKind string + +const ( + NotifyScopePeer NotifyScopeKind = "peer" + NotifyScopeUsers NotifyScopeKind = "users" + NotifyScopeChats NotifyScopeKind = "chats" + NotifyScopeBroadcasts NotifyScopeKind = "broadcasts" +) + +// NotifyScope 唯一标识一条通知设置:peer 作用域用 Peer(+TopicID 区分 forum 话题, +// 0=整 peer),三类默认作用域 Peer/TopicID 为空。 +type NotifyScope struct { + Kind NotifyScopeKind + Peer Peer + TopicID int +} + +// PeerNotifySettings 是 peerNotifySettings 的业务层表达。各字段为指针=可选: +// nil 表示该项未设置(客户端按所属类别默认继承)。声音字段恒为默认,v1 不建模 +// 自定义铃声(custom sound 罕见,留后续)。 +type PeerNotifySettings struct { + ShowPreviews *bool + Silent *bool + MuteUntil *int + StoriesMuted *bool + StoriesHideSender *bool +} + +// IsZero 报告该设置是否全部未设置(无需持久化/可视为继承默认)。 +func (s PeerNotifySettings) IsZero() bool { + return s.ShowPreviews == nil && s.Silent == nil && s.MuteUntil == nil && + s.StoriesMuted == nil && s.StoriesHideSender == nil +} + +// NotifyException 是一个 peer(可含 forum 话题)的非默认通知设置,供 +// account.getNotifyExceptions 列出"有自定义通知设置的会话"全局索引。 +type NotifyException struct { + Peer Peer + TopicID int + Settings PeerNotifySettings +} + +// Clone 深拷贝指针字段,供内存 store 隔离。 +func (s PeerNotifySettings) Clone() PeerNotifySettings { + out := PeerNotifySettings{} + if s.ShowPreviews != nil { + v := *s.ShowPreviews + out.ShowPreviews = &v + } + if s.Silent != nil { + v := *s.Silent + out.Silent = &v + } + if s.MuteUntil != nil { + v := *s.MuteUntil + out.MuteUntil = &v + } + if s.StoriesMuted != nil { + v := *s.StoriesMuted + out.StoriesMuted = &v + } + if s.StoriesHideSender != nil { + v := *s.StoriesHideSender + out.StoriesHideSender = &v + } + return out +} diff --git a/internal/domain/paid_reaction.go b/internal/domain/paid_reaction.go new file mode 100644 index 00000000..210ec08e --- /dev/null +++ b/internal/domain/paid_reaction.go @@ -0,0 +1,46 @@ +package domain + +// 频道帖子付费 reaction(messages.sendPaidReaction):用户花 Stars 为一条频道消息「点赞」, +// 星数在 (channel,message,user) 上累计;消息展示 ReactionPaid 总星数 + top reactors 排行。 +// 与 Stars 账本(stars.go)配合:rpc 先 Debit 再在此累计。 + +const ( + // MaxPaidReactionStarsPerRequest 是单次 sendPaidReaction 的星数上限(对齐官方 stars_paid_reaction_amount_max)。 + MaxPaidReactionStarsPerRequest = 10000 + // MaxPaidReactionTopReactors 是 top reactors 排行展示条数。 + MaxPaidReactionTopReactors = 3 +) + +// PaidReactor 是某 reactor 对一条消息累计投入的付费 reaction 星数。 +type PaidReactor struct { + UserID int64 + Stars int64 + Anonymous bool + My bool // 是否为当前 viewer(投影时按视角置位) +} + +// ChannelMessagePaidReactions 是一条频道消息的付费 reaction 聚合(携带在消息上 / reaction 更新里)。 +type ChannelMessagePaidReactions struct { + TotalStars int64 // 全体 reactor 投入星数之和 + MyStars int64 // 当前 viewer 投入的星数(0 = 未投) + MyAnonymous bool // 当前 viewer 是否匿名投入 + TopReactors []PaidReactor // 按 Stars DESC,含当前 viewer +} + +// SendChannelPaidReactionRequest 为一条频道消息增投付费 reaction 星数。 +type SendChannelPaidReactionRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Stars int64 + Anonymous bool // 隐私:是否匿名投入 + Date int +} + +// ChannelMessagePaidReactionResult 是增投后的结果,供 rpc 投影与扇出。 +type ChannelMessagePaidReactionResult struct { + Channel Channel + Message ChannelMessage + Paid ChannelMessagePaidReactions + Recipients []int64 +} diff --git a/internal/domain/passkey.go b/internal/domain/passkey.go new file mode 100644 index 00000000..854cfc17 --- /dev/null +++ b/internal/domain/passkey.go @@ -0,0 +1,50 @@ +package domain + +import "errors" + +// Passkey 相关错误。 +var ( + ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid") + ErrPasskeyNotFound = errors.New("passkey not found") + ErrPasskeyInvalid = errors.New("passkey invalid") + ErrPasskeyUserHandleInvalid = errors.New("passkey user handle invalid") +) + +// PasskeyChallengePurpose 区分注册挑战与登录挑战。 +type PasskeyChallengePurpose string + +const ( + PasskeyChallengeLogin PasskeyChallengePurpose = "login" + PasskeyChallengeRegister PasskeyChallengePurpose = "register" +) + +// PasskeyChallenge 是一次性 WebAuthn 挑战的元数据(短 TTL、用后即焚)。 +// 注册挑战绑定发起用户;登录挑战(discoverable)UserID=0,用户由 user_handle 反查。 +type PasskeyChallenge struct { + Purpose PasskeyChallengePurpose + UserID int64 + ExpiresAt int64 // unix 秒 +} + +// PasskeyCredential 是一条已注册的 passkey 凭据(WebAuthn 公钥)。 +type PasskeyCredential struct { + CredentialID []byte // 原始 credential id 字节(对外以 base64url 暴露) + UserID int64 + PublicKey []byte // COSE 公钥原始字节 + SignCount uint32 + AAGUID []byte + Name string + Transports []string + CreatedAt int64 // unix 秒 + LastUsedAt int64 // unix 秒;0 表示从未用于登录 +} + +// Clone 深拷贝(切片字段),避免内存 store 暴露内部引用。 +func (c PasskeyCredential) Clone() PasskeyCredential { + out := c + out.CredentialID = append([]byte(nil), c.CredentialID...) + out.PublicKey = append([]byte(nil), c.PublicKey...) + out.AAGUID = append([]byte(nil), c.AAGUID...) + out.Transports = append([]string(nil), c.Transports...) + return out +} diff --git a/internal/domain/phone.go b/internal/domain/phone.go new file mode 100644 index 00000000..ce4c2915 --- /dev/null +++ b/internal/domain/phone.go @@ -0,0 +1,141 @@ +package domain + +// PhoneCallState 是私聊 1:1 通话信令状态机的服务端状态。 +// 状态迁移、TL 视角映射与推送策略见 docs 与 internal/app/phone。 +type PhoneCallState string + +const ( + // PhoneCallStateRequested:requestCall 已受理,已向被叫推 phoneCallRequested。 + PhoneCallStateRequested PhoneCallState = "requested" + // PhoneCallStateRinging:至少一台被叫设备上报 receivedCall(主叫据 receive_date 切振铃 UI)。 + PhoneCallStateRinging PhoneCallState = "ringing" + // PhoneCallStateAccepted:被叫 acceptCall 已落 g_b,等主叫 confirmCall。 + PhoneCallStateAccepted PhoneCallState = "accepted" + // PhoneCallStateConfirmed:主叫 confirmCall 完成密钥交换,通话进行中。 + PhoneCallStateConfirmed PhoneCallState = "confirmed" + // PhoneCallStateDiscarded:终态。作为 tombstone 保留一段时间吸收晚到 RPC 后回收。 + PhoneCallStateDiscarded PhoneCallState = "discarded" +) + +// PhoneCallDiscardReason 是挂断原因,与 TL phoneCallDiscardReason* 一一对应。 +type PhoneCallDiscardReason string + +const ( + PhoneCallDiscardReasonMissed PhoneCallDiscardReason = "missed" + PhoneCallDiscardReasonDisconnect PhoneCallDiscardReason = "disconnect" + PhoneCallDiscardReasonHangup PhoneCallDiscardReason = "hangup" + PhoneCallDiscardReasonBusy PhoneCallDiscardReason = "busy" + // PhoneCallDiscardReasonMigrateConference 是升级为 conference call 的迁移挂断; + // 当前不支持 conference,仅原样回显 reason。 + PhoneCallDiscardReasonMigrateConference PhoneCallDiscardReason = "migrate_conference" +) + +// PhoneCallProtocol 是 tgcalls 协议协商参数(TL phoneCallProtocol)。 +type PhoneCallProtocol struct { + UDPP2P bool + UDPReflector bool + MinLayer int + MaxLayer int + LibraryVersions []string +} + +// SessionRef 标识一台具体设备连接(信令定向推送 fast-path 的锚点,允许失效)。 +type SessionRef struct { + RawAuthKeyID [8]byte + SessionID int64 +} + +// Zero 报告锚点是否未记录。 +func (s SessionRef) Zero() bool { + return s == SessionRef{} +} + +// PhoneCallConnection 是下发给双方的 WebRTC STUN/TURN 服务条目 +// (TL phoneConnectionWebrtc)。requestCall 受理时签发,全生命周期稳定。 +type PhoneCallConnection struct { + ID int64 + IP string + Port int + Username string + Password string + Stun bool + Turn bool +} + +// PhoneCallRequest 是 phone.requestCall 受理入参(隐私/拉黑等校验在 rpc 层先行完成)。 +type PhoneCallRequest struct { + CalleeID int64 + RandomID int64 + GAHash []byte + Video bool + Protocol PhoneCallProtocol + CallerDevice SessionRef + // PrivacyP2P 是 phone_p2p 隐私的双向 AND(rpc 层算定;强制 relay 时置 false)。 + PrivacyP2P bool + // Connections 是为本通话签发的 STUN/TURN 列表(可空:TURN 未启用时退回 + // 纯信令交换 host candidates 的 LAN 直连)。 + Connections []PhoneCallConnection +} + +// PhoneCall 是一通私聊通话的服务端权威态。密钥材料(GAHash/GB/GA)只存活于 +// 进程内 registry,随 tombstone 回收销毁,绝不落任何持久化存储。 +type PhoneCall struct { + ID int64 + AccessHash int64 + // AdminID 是主叫,ParticipantID 是被叫;与 TL phoneCall 字段同名,全生命周期不变。 + AdminID int64 + ParticipantID int64 + Video bool + State PhoneCallState + + Date int // requestCall 受理时刻(unix 秒) + ReceiveDate int // 首台被叫设备 receivedCall 时刻;0 表示尚未触达 + StartDate int // confirmCall 完成时刻 + DiscardedAt int // 进入终态时刻(tombstone GC 依据) + + GAHash []byte // 32B,requestCall 携带的承诺 + GB []byte // 256B,acceptCall 携带 + GA []byte // 256B,confirmCall 揭示(服务端核验 SHA256(GA)==GAHash) + KeyFingerprint int64 // E2E 指纹,服务端无法验证,仅转发 + + // Protocol 是协商结果(accept 时计算);CallerProtocol/CalleeProtocol 保留原始两份。 + Protocol PhoneCallProtocol + CallerProtocol PhoneCallProtocol + CalleeProtocol PhoneCallProtocol + + P2PAllowed bool + DiscardReason PhoneCallDiscardReason + Duration int + + // PrivacyP2P 与 Connections 自 PhoneCallRequest 原样保留(见其注释)。 + PrivacyP2P bool + Connections []PhoneCallConnection + + RandomID int64 // 主叫侧幂等去重键的一半(callerID+RandomID) + + // 设备锚点:requestCall / acceptCall 的来源设备,仅作定向推送提示。 + CallerDevice SessionRef + CalleeDevice SessionRef +} + +// Terminal 报告通话是否已进入终态。 +func (c PhoneCall) Terminal() bool { + return c.State == PhoneCallStateDiscarded +} + +// PeerOf 返回 userID 在本通话中的对端;userID 不是参与者时返回 0。 +func (c PhoneCall) PeerOf(userID int64) int64 { + switch userID { + case c.AdminID: + return c.ParticipantID + case c.ParticipantID: + return c.AdminID + default: + return 0 + } +} + +// HasParticipant 报告 userID 是否为本通话参与者。 +func (c PhoneCall) HasParticipant(userID int64) bool { + return userID != 0 && (userID == c.AdminID || userID == c.ParticipantID) +} diff --git a/internal/domain/poll.go b/internal/domain/poll.go new file mode 100644 index 00000000..0f832c75 --- /dev/null +++ b/internal/domain/poll.go @@ -0,0 +1,325 @@ +package domain + +import ( + "bytes" + "errors" +) + +// 本文件定义投票(poll)的业务对象与共享语义: +// +// - MessagePoll 是随消息 media JSONB 落库的「渲染安全定义快照」——不含 quiz 正确答案与 +// solution 等服务端机密;Closed 仅是发送时刻快照,读路径必须用 polls 权威态覆盖。 +// - PollDefinition 是 polls 权威表行(含机密与可变 closed),投票校验与结果门控只信它。 +// - 投票校验(ValidatePollVote)与结果门控(ResolvePollResults)是纯函数,由 memory 与 +// postgres 两个 store 共用,杜绝双实现行为漂移。 +// +// 转发不复制 poll:转发快照携带同一 poll id,投票/关闭全局聚合(与官方一致)。 + +const ( + // MaxPollQuestionLength 与官方 poll question 上限一致。 + MaxPollQuestionLength = 255 + // MinPollAnswers / MaxPollAnswers 是答案个数边界(官方 2..10)。 + MinPollAnswers = 2 + MaxPollAnswers = 10 + // MaxPollAnswerTextLength 是单个答案文本上限(官方 100)。 + MaxPollAnswerTextLength = 100 + // MaxPollSolutionLength 是 quiz 解释文本上限(官方 200)。 + MaxPollSolutionLength = 200 + // MinPollClosePeriod / MaxPollClosePeriod 是自动关闭倒计时边界(官方 5..600 秒)。 + MinPollClosePeriod = 5 + MaxPollClosePeriod = 600 + // MaxPollRecentVoters 是 recent_voters 截断(与 reaction recent 同款,官方 3)。 + MaxPollRecentVoters = 3 + // MaxPollVotesPageLimit 是 messages.getPollVotes 单页上限。 + MaxPollVotesPageLimit = 50 +) + +// 投票链路业务错误;rpc 层映射为对应 RPC error 文本。 +var ( + ErrPollInvalid = errors.New("poll invalid") + ErrPollNotFound = errors.New("poll not found") + ErrPollClosed = errors.New("poll closed") + ErrPollOptionInvalid = errors.New("poll option invalid") + ErrPollRevoteNotAllowed = errors.New("poll revote not allowed") + ErrPollNotCreator = errors.New("poll can only be closed by creator") + ErrPollNotPublic = errors.New("poll voters are not public") +) + +// MessagePollAnswer 是一个候选答案(文本 + option 字节键 + 可选配图)。 +// Media 仅允许 photo/document 快照(rpc 层限制输入类型)。 +type MessagePollAnswer struct { + Text string `json:"text"` + Entities []MessageEntity `json:"entities,omitempty"` + Option []byte `json:"option"` + Media *MessageMedia `json:"media,omitempty"` +} + +// MessagePoll 是消息 media 上的 poll 定义快照(渲染安全,不含机密)。 +type MessagePoll struct { + ID int64 `json:"id"` + Question string `json:"question"` + QuestionEntities []MessageEntity `json:"question_entities,omitempty"` + Answers []MessagePollAnswer `json:"answers"` + // Closed 是发送时刻快照;读路径以 polls 权威态覆盖(转发副本/关闭后历史一致性靠它)。 + Closed bool `json:"closed,omitempty"` + PublicVoters bool `json:"public_voters,omitempty"` + MultipleChoice bool `json:"multiple_choice,omitempty"` + Quiz bool `json:"quiz,omitempty"` + RevotingDisabled bool `json:"revoting_disabled,omitempty"` + ShuffleAnswers bool `json:"shuffle_answers,omitempty"` + HideResultsUntilClose bool `json:"hide_results_until_close,omitempty"` + ClosePeriod int `json:"close_period,omitempty"` + CloseDate int `json:"close_date,omitempty"` + + // AttachedMedia 是 poll 题干配图(messageMediaPoll.attached_media),photo/document 快照。 + AttachedMedia *MessageMedia `json:"attached_media,omitempty"` + + // Results 在读路径按 viewer 填充(含 chosen/correct/solution 门控),不落库。 + Results *MessagePollResults `json:"-"` +} + +// MessagePollAnswerVoters 是一个答案的聚合结果(已按 viewer 门控)。 +type MessagePollAnswerVoters struct { + Option []byte + Voters int + Chosen bool + Correct bool +} + +// MessagePollResults 是按 viewer 解析后的聚合结果。 +type MessagePollResults struct { + TotalVoters int + Voters []MessagePollAnswerVoters // 与 MessagePoll.Answers 同序 + RecentVoters []int64 // 仅 public_voters 填充,截断 MaxPollRecentVoters + ViewerVoted bool + Solution string // 仅 quiz 且 (viewer 已投 || 已关闭) 下发 + SolutionEntities []MessageEntity +} + +// PollDefinition 是 polls 权威表行:可变状态(closed)+ 服务端机密 + 校验所需选项集。 +type PollDefinition struct { + ID int64 + CreatorUserID int64 + Options [][]byte // 合法选项集合(与答案顺序一致) + PublicVoters bool + MultipleChoice bool + Quiz bool + RevotingDisabled bool + HideResultsUntilClose bool + Closed bool + ClosePeriod int + CloseDate int + CorrectOptions [][]byte + Solution string + SolutionEntities []MessageEntity +} + +// ClosedAt 返回 now 时刻 poll 是否应视为已关闭(显式关闭或 close_date 已过)。 +func (d PollDefinition) ClosedAt(now int) bool { + return d.Closed || (d.CloseDate > 0 && now >= d.CloseDate) +} + +// HasOption 判断 option 是否在合法选项集内。 +func (d PollDefinition) HasOption(option []byte) bool { + for _, candidate := range d.Options { + if bytes.Equal(candidate, option) { + return true + } + } + return false +} + +// PollVote 是一个用户的投票行。Options 保留客户端提交顺序。 +type PollVote struct { + PollID int64 + UserID int64 + Options [][]byte + Date int +} + +// PollAggregates 是 store 层产出的原始聚合,交由 ResolvePollResults 做 viewer 门控。 +// Counts 的 key 是 string(option)。 +type PollAggregates struct { + Counts map[string]int + TotalVoters int + RecentVoters []int64 // 按 vote_date DESC 截断 MaxPollRecentVoters;仅 public_voters 需要 + ViewerOptions [][]byte // viewer 自己的投票(nil = 未投) +} + +// ValidatePollVote 校验一次投票提交;existing 是 viewer 当前投票(nil=未投),options 为空表示撤票。 +// memory 与 postgres store 必须共用本函数,保持双实现一致。 +func ValidatePollVote(def PollDefinition, existing [][]byte, options [][]byte, now int) error { + if def.ID == 0 { + return ErrPollNotFound + } + if def.ClosedAt(now) { + return ErrPollClosed + } + if def.Quiz || def.RevotingDisabled { + // quiz / revoting_disabled:不可撤票、不可改票。 + if len(existing) > 0 || len(options) == 0 { + return ErrPollRevoteNotAllowed + } + } + if def.Quiz { + if len(options) != 1 { + return ErrPollOptionInvalid + } + } else if len(options) > 1 && !def.MultipleChoice { + return ErrPollOptionInvalid + } + if len(options) > len(def.Options) { + return ErrPollOptionInvalid + } + seen := make(map[string]struct{}, len(options)) + for _, option := range options { + if !def.HasOption(option) { + return ErrPollOptionInvalid + } + key := string(option) + if _, dup := seen[key]; dup { + return ErrPollOptionInvalid + } + seen[key] = struct{}{} + } + return nil +} + +// ResolvePollResults 把原始聚合解析成 viewer 视角结果:chosen 标 viewer 自己的选项; +// correct/solution 仅 quiz 且 (viewer 已投 || 已关闭) 揭示; +// hide_results_until_close 在关闭前对非创建者隐藏计数(防恶意客户端绕过 UI)。 +func ResolvePollResults(def PollDefinition, agg PollAggregates, viewerUserID int64, now int) MessagePollResults { + closed := def.ClosedAt(now) + viewerVoted := len(agg.ViewerOptions) > 0 + reveal := def.Quiz && (viewerVoted || closed) + hideCounts := def.HideResultsUntilClose && !closed && viewerUserID != def.CreatorUserID + chosen := make(map[string]struct{}, len(agg.ViewerOptions)) + for _, option := range agg.ViewerOptions { + chosen[string(option)] = struct{}{} + } + correct := make(map[string]struct{}, len(def.CorrectOptions)) + if reveal { + for _, option := range def.CorrectOptions { + correct[string(option)] = struct{}{} + } + } + out := MessagePollResults{ + TotalVoters: agg.TotalVoters, + Voters: make([]MessagePollAnswerVoters, 0, len(def.Options)), + ViewerVoted: viewerVoted, + } + for _, option := range def.Options { + key := string(option) + _, isChosen := chosen[key] + _, isCorrect := correct[key] + voters := agg.Counts[key] + if hideCounts { + voters = 0 + } + out.Voters = append(out.Voters, MessagePollAnswerVoters{ + Option: option, + Voters: voters, + Chosen: isChosen, + Correct: isCorrect, + }) + } + if def.PublicVoters && !hideCounts && len(agg.RecentVoters) > 0 { + recent := agg.RecentVoters + if len(recent) > MaxPollRecentVoters { + recent = recent[:MaxPollRecentVoters] + } + out.RecentVoters = append([]int64(nil), recent...) + } + if reveal { + out.Solution = def.Solution + out.SolutionEntities = append([]MessageEntity(nil), def.SolutionEntities...) + } + return out +} + +// ApplyPollState 把权威态与解析结果写回 media 上的定义快照(读路径 enrichment 终点)。 +func ApplyPollState(poll *MessagePoll, def PollDefinition, results MessagePollResults, now int) { + if poll == nil { + return + } + poll.Closed = def.ClosedAt(now) + poll.Results = &results +} + +// VotePrivateMessagePollRequest 是私聊消息投票请求(msg id 为 viewer box id)。 +type VotePrivateMessagePollRequest struct { + UserID int64 + Peer Peer + MessageID int + Options [][]byte // 空 = 撤票 + Date int +} + +// PrivateMessagePollResult 返回两个 owner 视角的消息(media 已按各自 owner enrich)。 +type PrivateMessagePollResult struct { + PollID int64 + Messages []Message +} + +// ClosePrivateMessagePollRequest 关闭私聊消息上的 poll(仅 poll 创建者)。 +type ClosePrivateMessagePollRequest struct { + UserID int64 + Peer Peer + MessageID int + Date int +} + +// VoteChannelMessagePollRequest 是频道/超级群消息投票请求。 +type VoteChannelMessagePollRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Options [][]byte + Date int +} + +// ChannelMessagePollResult 返回投票者视角消息与建议推送收件人。 +type ChannelMessagePollResult struct { + PollID int64 + Channel Channel + Message ChannelMessage + Recipients []int64 +} + +// ChannelPollFanoutViews 是频道 poll fan-out 的批量 per-viewer enrich 结果(消除逐 viewer GetMessages +// 的 N+1):viewer-invariant 聚合(counts/total/recent)只算一次 + 批量 viewerOptions + 批量成员可见性, +// 每 viewer 用同一 ResolvePollResults 合成,与逐 viewer 路径字节同源。 +// - Found:poll 消息存在且确为 poll(否则视为无可投影)。 +// - Message:基础消息快照(未 per-viewer enrich),供 app 层叠加 bot 历史可见性过滤。 +// - Polls:key 存在=已评估该 viewer;值为 nil=该 viewer 不可见(非成员/pre-history 隐藏); +// 非 nil=该 viewer 视角已 enrich 的 poll。bot 历史过滤由 app 层在此基础上叠加(置 nil)。 +type ChannelPollFanoutViews struct { + Found bool + Message ChannelMessage + Polls map[int64]*MessagePoll +} + +// CloseChannelMessagePollRequest 关闭频道消息上的 poll(仅 poll 创建者)。 +type CloseChannelMessagePollRequest struct { + UserID int64 + ChannelID int64 + MessageID int + Date int +} + +// PollVotesListRequest 是 messages.getPollVotes 的分页请求。 +type PollVotesListRequest struct { + PollID int64 + Option []byte // 可选:仅列出投了该选项的人 + OffsetDate int // 0 = 第一页;翻页用上页末行 (date,user_id) + OffsetUserID int64 + Limit int +} + +// PollVotesList 是投票人分页结果。 +type PollVotesList struct { + Count int // 满足过滤条件的总数 + Votes []PollVote + // HasMore 为 true 时 rpc 层用末行 (Date,UserID) 编码 next_offset。 + HasMore bool +} diff --git a/internal/domain/premium_test.go b/internal/domain/premium_test.go new file mode 100644 index 00000000..d699fdaf --- /dev/null +++ b/internal/domain/premium_test.go @@ -0,0 +1,93 @@ +package domain + +import ( + "testing" + "time" +) + +func TestPremiumActiveAt(t *testing.T) { + now := time.Now().Unix() + cases := []struct { + name string + user User + want bool + }{ + {"zero until = 非会员", User{}, false}, + {"future until = 会员", User{PremiumUntil: int(now + 3600)}, true}, + {"past until = 已到期", User{PremiumUntil: int(now - 1)}, false}, + {"恰好 now = 已到期(边界闭区间外)", User{PremiumUntil: int(now)}, false}, + {"bot 永不会员(双保险)", User{Bot: true, BotInfoVersion: 1, PremiumUntil: int(now + 3600)}, false}, + } + for _, tc := range cases { + if got := tc.user.PremiumActiveAt(now); got != tc.want { + t.Errorf("%s: PremiumActiveAt = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestEmojiStatusActiveAt(t *testing.T) { + now := time.Now().Unix() + premium := int(now + 3600) + cases := []struct { + name string + user User + want bool + }{ + {"未设置", User{PremiumUntil: premium}, false}, + {"永久状态", User{PremiumUntil: premium, EmojiStatusDocumentID: 7}, true}, + {"未过期状态", User{PremiumUntil: premium, EmojiStatusDocumentID: 7, EmojiStatusUntil: int(now + 60)}, true}, + {"已过期状态", User{PremiumUntil: premium, EmojiStatusDocumentID: 7, EmojiStatusUntil: int(now - 60)}, false}, + {"会员到期后残值不下发", User{PremiumUntil: int(now - 1), EmojiStatusDocumentID: 7}, false}, + } + for _, tc := range cases { + if got := tc.user.EmojiStatusActiveAt(now); got != tc.want { + t.Errorf("%s: EmojiStatusActiveAt = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestTrimMessageReactionsToUserMaxTiers(t *testing.T) { + reactions := []MessageReaction{ + {Type: MessageReactionEmoji, Emoticon: "👍"}, + {Type: MessageReactionEmoji, Emoticon: "❤"}, + {Type: MessageReactionEmoji, Emoticon: "🔥"}, + {Type: MessageReactionEmoji, Emoticon: "🎉"}, + } + // 默认档(含 0 = 旧调用方未填)裁到 1,保尾部最新。 + for _, max := range []int{0, -1, MaxMessageReactionsPerUser} { + got := TrimMessageReactionsToUserMax(reactions, max) + if len(got) != 1 || got[0].Emoticon != "🎉" { + t.Fatalf("default tier (max=%d) = %+v, want [🎉]", max, got) + } + } + // premium 档裁到 3,保尾部最新。 + got := TrimMessageReactionsToUserMax(reactions, MessageReactionsUserMax(true)) + if len(got) != 3 || got[0].Emoticon != "❤" || got[2].Emoticon != "🎉" { + t.Fatalf("premium tier = %+v, want [❤ 🔥 🎉]", got) + } + // 超出 premium 档的请求值被封顶。 + if got := TrimMessageReactionsToUserMax(reactions, 99); len(got) != MaxMessageReactionsPerUserPremium { + t.Fatalf("over-cap tier = %d entries, want %d", len(got), MaxMessageReactionsPerUserPremium) + } + if MessageReactionsUserMax(false) != MaxMessageReactionsPerUser { + t.Fatalf("MessageReactionsUserMax(false) = %d", MessageReactionsUserMax(false)) + } +} + +func TestPinnedDialogsLimitTiers(t *testing.T) { + cases := []struct { + folderID int + premium bool + want int + }{ + {DialogMainFolderID, false, MaxPinnedDialogsMainFolder}, + {DialogMainFolderID, true, MaxPinnedDialogsMainFolderPremium}, + {DialogArchiveFolderID, false, MaxPinnedDialogsArchiveFolder}, + {DialogArchiveFolderID, true, MaxPinnedDialogsArchiveFolderPremium}, + } + for _, tc := range cases { + if got := PinnedDialogsLimit(tc.folderID, tc.premium); got != tc.want { + t.Errorf("PinnedDialogsLimit(%d, %v) = %d, want %d", tc.folderID, tc.premium, got, tc.want) + } + } +} diff --git a/internal/domain/saved_dialog.go b/internal/domain/saved_dialog.go new file mode 100644 index 00000000..81f219b0 --- /dev/null +++ b/internal/domain/saved_dialog.go @@ -0,0 +1,87 @@ +package domain + +// SavedHiddenAuthorUserID 是官方 hidden-author 收藏夹子会话的占位 user id +// (TDesktop kSavedHiddenAuthorId / TDLib HIDDEN_AUTHOR_DIALOG_ID)。 +// 运行时新写入永远能确定源会话,不产生该值;仅存量回填中 +// 「fwd 头只有 from_name、源会话已不可知」的消息归入此子会话。 +const SavedHiddenAuthorUserID int64 = 2666000 + +// MaxPinnedSavedDialogs 是收藏夹子会话置顶上限(官方 premium 上限同级)。 +const MaxPinnedSavedDialogs = 100 + +// MaxSavedDialogsLimit 是 messages.getSavedDialogs 单页上限。 +const MaxSavedDialogsLimit = 100 + +// SavedPeerForSelfChat 计算 self-chat 新消息的 saved 子会话分组键,与 +// TDLib SavedMessagesTopicId 语义对齐:转发带源会话(saved_from_peer)→ +// 源会话;fwd 头仅 from_name 且源会话不可知 → hidden author 占位(防御 +// 分支,运行时转发总是带源会话);其余(直发、drop_author)→ self。 +func SavedPeerForSelfChat(selfUserID int64, forward *MessageForward) Peer { + if forward != nil { + if forward.SavedFrom.ID != 0 { + return forward.SavedFrom + } + if forward.From.ID == 0 && forward.FromName != "" { + return Peer{Type: PeerTypeUser, ID: SavedHiddenAuthorUserID} + } + } + return Peer{Type: PeerTypeUser, ID: selfUserID} +} + +// SavedDialog 是收藏夹的一个子会话(savedDialog TL 构造器的业务形态)。 +type SavedDialog struct { + Peer Peer + // TopMessage 是该子会话当前最新可见消息的 owner 视角 box id。 + TopMessage int + Pinned bool +} + +// SavedDialogList 是 getSavedDialogs/getPinnedSavedDialogs/getSavedDialogsByID +// 的业务层结果。Messages 与 Dialogs 一一对应(top message 全量行)。 +type SavedDialogList struct { + Dialogs []SavedDialog + Messages []Message + Users []User + Channels []Channel + // Count 是过滤口径下(含/不含 pinned)的子会话总数。 + Count int + // Full 为 true 表示本页已含过滤口径下的全部剩余子会话 + // (映射 messages.savedDialogs;false 映射 savedDialogsSlice)。 + Full bool +} + +// SavedDialogsFilter 描述 getSavedDialogs 分页条件。 +type SavedDialogsFilter struct { + ExcludePinned bool + // OffsetID 是上一页最后一个子会话的 top message box id;0 或 >= MaxMessageBoxID + // 视为从最新开始(TDesktop 首页传 0,DrKLO Android 首页传 int32 max)。 + OffsetID int + // OffsetDate/OffsetPeer 仅作合法性校验;box id 单调于时间,分页统一按 + // top box id 严格降序推进。 + OffsetDate int + OffsetPeer Peer + Limit int +} + +// DeleteSavedHistoryRequest 是 messages.deleteSavedHistory 的业务命令: +// 删除 self-chat 中一个 saved 子会话的消息。self-chat 单侧,无 revoke 语义。 +type DeleteSavedHistoryRequest struct { + OwnerUserID int64 + // SavedPeer 是要清空的子会话分组键。 + SavedPeer Peer + MaxID int + MinDate int + MaxDate int + Date int + OriginAuthKeyID [8]byte + OriginSessionID int64 +} + +// DeleteSavedHistoryResult 描述一次 saved 子会话删除批次。 +type DeleteSavedHistoryResult struct { + // MessageIDs 是本批被删除的 owner 视角 box id。 + MessageIDs []int + Event UpdateEvent + // More 为 true 表示仍有剩余批次,客户端按 affectedHistory.offset 续发。 + More bool +} diff --git a/internal/domain/saved_music.go b/internal/domain/saved_music.go new file mode 100644 index 00000000..0dcd0e6b --- /dev/null +++ b/internal/domain/saved_music.go @@ -0,0 +1,23 @@ +package domain + +const ( + // MaxSavedMusicItems bounds one account's profile music list in the current + // small-scale compatibility phase. + MaxSavedMusicItems = 1000 +) + +// SaveMusicRequest mutates the current user's ordered profile music list. +type SaveMusicRequest struct { + UserID int64 + Document Document + Unsave bool + AfterDocumentID int64 + Date int +} + +// SavedMusicList is an ordered page of music documents pinned to a user profile. +type SavedMusicList struct { + UserID int64 + Documents []Document + Count int +} diff --git a/internal/domain/secretchat.go b/internal/domain/secretchat.go new file mode 100644 index 00000000..ed30644c --- /dev/null +++ b/internal/domain/secretchat.go @@ -0,0 +1,188 @@ +package domain + +import "errors" + +// SecretChatState 是私聊端对端加密(Secret Chat / EncryptedChat)握手状态机的 +// 服务端权威态。服务端是盲中继:g_a/g_b/key_fingerprint/加密 bytes 全部不透明 +// 存储与原样转发,永不接触 DH 共享密钥与明文。设计见 docs/secret-chat-module.md。 +type SecretChatState string + +const ( + // SecretChatStateRequested:requestEncryption 已受理、等接受方 acceptEncryption。 + // 同一服务端态对发起方投影为 encryptedChatWaiting、对接受方投影为 encryptedChatRequested。 + SecretChatStateRequested SecretChatState = "requested" + // SecretChatStateNormal:acceptEncryption 完成 g_b/key_fingerprint 落库,握手成型。 + SecretChatStateNormal SecretChatState = "normal" + // SecretChatStateDiscarded:终态,任一方 discardEncryption 触发。 + SecretChatStateDiscarded SecretChatState = "discarded" +) + +// Secret chat 存储层错误(memory/postgres 双实现共用,行为契约由 storetest 钉死)。 +var ( + // ErrSecretChatNotFound:chat_id 不存在 → CHAT_ID_INVALID / ENCRYPTION_ID_INVALID。 + ErrSecretChatNotFound = errors.New("secretchat: not found") + // ErrSecretChatAlreadyAccepted:accept 一个已成型的密聊 → ENCRYPTION_ALREADY_ACCEPTED。 + ErrSecretChatAlreadyAccepted = errors.New("secretchat: already accepted") + // ErrSecretChatAlreadyDeclined:accept 一个已销毁的密聊 → ENCRYPTION_ALREADY_DECLINED。 + ErrSecretChatAlreadyDeclined = errors.New("secretchat: already declined") + // ErrSecretChatIDConflict:chat_id 主键撞键(计数器回退);调用方按 AtLeast 重分配重试。 + ErrSecretChatIDConflict = errors.New("secretchat: chat id conflict") +) + +// SecretChat 是一通私聊密聊的服务端权威态(durable,跨重启存活)。 +// 字段命名对齐 TL encryptedChat*;ID 是 int32 量级,access_hash/admin_id/ +// participant_id/key_fingerprint 是 int64。绑定维度是设备级(perm auth_key 的 int64 值)。 +type SecretChat struct { + // ID 是 chat_id,全局单调 int32 序列;双方共享同一 id。 + ID int + // AdminAccessHash / ParticipantAccessHash 双视角不同(TL "check sum depending on user ID")。 + AdminAccessHash int64 + ParticipantAccessHash int64 + // AdminUserID 是发起方,ParticipantUserID 是接受方;据此判角色。 + AdminUserID int64 + ParticipantUserID int64 + // AdminAuthKeyID / ParticipantAuthKeyID 是绑定设备的 perm auth_key(authKeyIDToInt64 小端值)。 + // ParticipantAuthKeyID 在 accept 前为 0(建链前邀请对 participant 账号级可见)。 + AdminAuthKeyID int64 + ParticipantAuthKeyID int64 + + State SecretChatState + + // GA 是 requestEncryption 携带的发起方公钥(盲存,左补零 256B)。 + // GB 是 acceptEncryption 携带的接受方公钥(accept 后落库)。 + // 投影 GAOrB:对 admin 视角是 GB,对 participant 视角是 GA(TL 注释钉死)。 + GA []byte + GB []byte + // KeyFingerprint 是接受方算出的共享密钥指纹,服务端原样 int64 中继、绝不重算。 + KeyFingerprint int64 + + // Layer 是密聊内层 layer 快照(不解析);FolderID 透传 requested 归档意图。 + Layer int + FolderID int + // HistoryDeleted 是 discard 时是否要求对端删整个会话历史。 + HistoryDeleted bool + + // RandomID 是 requestEncryption 的 int32 幂等键(同发起设备同 random_id 返同 chat)。 + RandomID int32 + // Date 是受理时刻(unix 秒)。 + Date int +} + +// Terminal 报告密聊是否已销毁。 +func (c SecretChat) Terminal() bool { + return c.State == SecretChatStateDiscarded +} + +// HasParticipant 报告 userID 是否为本密聊的发起方或接受方。 +func (c SecretChat) HasParticipant(userID int64) bool { + return userID != 0 && (userID == c.AdminUserID || userID == c.ParticipantUserID) +} + +// IsAdmin 报告 userID 是否为发起方。 +func (c SecretChat) IsAdmin(userID int64) bool { + return userID != 0 && userID == c.AdminUserID +} + +// PeerOf 返回 userID 在本密聊中的对端;userID 不是参与者时返回 0。 +func (c SecretChat) PeerOf(userID int64) int64 { + switch userID { + case c.AdminUserID: + return c.ParticipantUserID + case c.ParticipantUserID: + return c.AdminUserID + default: + return 0 + } +} + +// PeerAuthKeyOf 返回 userID 的对端绑定设备 perm auth_key(int64);非参与者或对端 +// 未绑定返回 0。admin 的对端是 participant 设备,反之亦然。 +func (c SecretChat) PeerAuthKeyOf(userID int64) int64 { + switch userID { + case c.AdminUserID: + return c.ParticipantAuthKeyID + case c.ParticipantUserID: + return c.AdminAuthKeyID + default: + return 0 + } +} + +// AccessHashFor 返回 userID 视角的 access_hash(双方不同);非参与者返回 0。 +func (c SecretChat) AccessHashFor(userID int64) int64 { + switch userID { + case c.AdminUserID: + return c.AdminAccessHash + case c.ParticipantUserID: + return c.ParticipantAccessHash + default: + return 0 + } +} + +// EncryptedFileRef 是密聊文件的服务端快照(P2,盲中继:内容是加密 bytes,不解密)。 +type EncryptedFileRef struct { + ID int64 + AccessHash int64 + Size int64 + DCID int + KeyFingerprint int // int32 量级 +} + +// SecretChatMessage 是密聊 qts 投递队列里的一条不透明加密消息(updateNewEncryptedMessage +// 的载荷)。Bytes 是客户端加密的 DecryptedMessage,服务端盲存盲转、永不解密。 +// 按接收方设备(ReceiverAuthKeyID)的 qts 序列投递。 +type SecretChatMessage struct { + ReceiverAuthKeyID int64 + Qts int + ReceiverUserID int64 + ChatID int + RandomID int64 + Date int + IsService bool + Bytes []byte + File *EncryptedFileRef +} + +// EncryptedStateEventType 区分无 qts 的密聊状态事件种类。 +type EncryptedStateEventType int + +const ( + // EncryptedStateEventEncryption:updateEncryption(握手态变化);投递时按 secret_chats + // 权威态重建(不固化快照)。 + EncryptedStateEventEncryption EncryptedStateEventType = 1 + // EncryptedStateEventRead:updateEncryptedMessagesRead(已读回执),携 MaxDate。 + EncryptedStateEventRead EncryptedStateEventType = 2 +) + +// EncryptedStateEvent 是无 qts 的 durable 密聊状态事件(离线 getDifference 补偿)。 +// TargetAuthKeyID=0 表示账号级(所有设备可见),非 0 表示绑定设备定向。 +type EncryptedStateEvent struct { + ID int64 + TargetUserID int64 + TargetAuthKeyID int64 + ChatID int + Type EncryptedStateEventType + MaxDate int + Date int +} + +// SecretMessageDelivery 是 sendEncrypted* 要投递给对端设备的一条加密消息(rpc 层组装, +// service 分配接收设备 qts 并落库)。 +type SecretMessageDelivery struct { + RandomID int64 + Bytes []byte + IsService bool + File *EncryptedFileRef + Date int +} + +// SecretChatRequest 是 requestEncryption 受理入参(隐私/拉黑/self/bot 校验在 rpc 层先行)。 +type SecretChatRequest struct { + AdminUserID int64 + AdminAuthKeyID int64 + ParticipantUserID int64 + RandomID int32 + GA []byte + Date int +} diff --git a/internal/domain/star_gift.go b/internal/domain/star_gift.go new file mode 100644 index 00000000..7df41c3b --- /dev/null +++ b/internal/domain/star_gift.go @@ -0,0 +1,115 @@ +package domain + +import ( + "encoding/base64" + "errors" + "strconv" +) + +// Star gift(payments.sendStarsForm + inputInvoiceStarGift)领域模型。目录是从已 seed 的 +// animated_emoji 合成的静态集合(StarGift);peer 收到的礼物实例落 peer_star_gifts(SavedStarGift)。 +// 与 Stars 账本配合:发礼 Debit、转换回 Stars 时 Credit。 + +// StarGift 是一个可购买礼物目录项(合成、非用户持有)。 +type StarGift struct { + ID int64 + Stars int64 // 购买价(Stars) + ConvertStars int64 // 收礼人可转换回的 Stars(v1 = Stars,全额) + Title string // 可选标题 + Sticker Document // 礼物贴纸快照(tg 投影必须是带 sticker 属性的有效 Document,否则客户端丢弃) +} + +// SavedStarGift 是一条已收到的礼物实例(peer_star_gifts 一行)。 +type SavedStarGift struct { + ID int64 + Owner Peer // 收礼 peer(user/channel) + FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露) + GiftID int64 // → StarGift.ID + MsgID int // 用户礼物的私聊 msg_id;频道礼物不进历史,固定为 0 + SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id;用户礼物为 0 + Date int // 收到时刻 Unix 秒 + NameHidden bool // 送礼人请求隐藏姓名 + Unsaved bool // 未展示在个人资料(saveStarGift 切换) + Converted bool // 已转换回 Stars(终态,从列表排除) + ConvertStars int64 // 转换可退回的 Stars + Message string // 附言(可选) +} + +// SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。 +// 用户礼物使用 inputSavedStarGiftUser.msg_id;频道礼物使用 inputSavedStarGiftChat.peer + saved_id。 +type SavedStarGiftRef struct { + Owner Peer + MsgID int + SavedID int64 +} + +// Valid reports whether the reference has the identity required by its owner kind. +func (r SavedStarGiftRef) Valid() bool { + switch r.Owner.Type { + case PeerTypeUser: + return r.Owner.ID != 0 && r.MsgID > 0 + case PeerTypeChannel: + return r.Owner.ID != 0 && r.SavedID > 0 + default: + return false + } +} + +// SavedStarGiftPage 是一页已收到礼物 + keyset 分页游标。 +type SavedStarGiftPage struct { + Gifts []SavedStarGift + NextOffset string // 空 = 无更多页(末页必须省略,客户端据此停止翻页) + Count int // 总数(未转换、按 excludeUnsaved 过滤后) +} + +// Star gift 边界常量。 +const ( + // MaxSavedStarGiftsLimit 是 getSavedStarGifts 单页上限。 + MaxSavedStarGiftsLimit = 100 + // MaxStarGiftMessageRunes 限制附言长度(对齐 stargifts_message_length_max 量级)。 + MaxStarGiftMessageRunes = 255 + // MaxStarGiftsOffsetBytes 是 keyset 游标字符串长度上限。 + MaxStarGiftsOffsetBytes = 64 +) + +// Star gift 哨兵错误(rpc 层 errors.Is 映射为 tgerr)。 +var ( + // ErrStarGiftInvalid 表示礼物 id 不在目录里。 + ErrStarGiftInvalid = errors.New("stargift: invalid gift id") + // ErrStarGiftNotFound 表示找不到该已收到礼物实例。 + ErrStarGiftNotFound = errors.New("stargift: saved gift not found") + // ErrStarGiftAlreadyConverted 表示礼物已转换回 Stars(不可重复转换)。 + ErrStarGiftAlreadyConverted = errors.New("stargift: already converted") +) + +// StarGiftCatalogHash 由目录的 (gift_id, stars) 折叠出稳定 hash,供 getStarGifts NotModified。 +func StarGiftCatalogHash(catalog []StarGift) int { + var h uint64 + for _, g := range catalog { + h ^= uint64(g.ID) + h = h*0x4f25 + uint64(g.ID) + h = h*0x4f25 + uint64(g.Stars) + } + return int(h & 0x7fffffff) +} + +// EncodeStarGiftCursor / DecodeStarGiftCursor 是 saved gifts keyset 游标(最后一条实例 id)。 +func EncodeStarGiftCursor(id int64) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) +} + +// DecodeStarGiftCursor 反解游标;无法解析(含空串)返回 ok=false(调用方从首页开始)。 +func DecodeStarGiftCursor(s string) (int64, bool) { + if s == "" { + return 0, false + } + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return 0, false + } + id, err := strconv.ParseInt(string(raw), 10, 64) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} diff --git a/internal/domain/stars.go b/internal/domain/stars.go new file mode 100644 index 00000000..f0672e52 --- /dev/null +++ b/internal/domain/stars.go @@ -0,0 +1,92 @@ +package domain + +import ( + "encoding/base64" + "errors" + "strconv" +) + +// Stars 本地账本领域模型(无 TL 类型,镜像 boost.go 风格)。本实现是本地账本、 +// 非真实支付:余额为整数 Stars(线上 Nanos 恒 0),借记原子、永不为负。 + +// StarsBalance 是一个账号的当前可用 Stars 余额。 +type StarsBalance struct { + UserID int64 + Balance int64 // 当前可花费 Stars,恒 >= 0 + Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫) +} + +// StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。 +type StarsTransactionReason string + +const ( + StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予 + StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造) + StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费 + StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取 + StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁 + StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整 +) + +// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。 +type StarsTransaction struct { + ID int64 // 单调递增账本 id(keyset 游标) + UserID int64 // 账本归属 + Peer Peer // 对手方(grant/topup 等无对手时为零 Peer) + Amount int64 // 带符号金额 + Date int // Unix 秒 + Reason StarsTransactionReason + Title string // 可选,投影到 tg.StarsTransaction.Title + Description string // 可选,投影到 tg.StarsTransaction.Description +} + +// IsCredit 报告该流水是否为入账(贷记),投影到 tg.StarsTransaction.Refund。 +func (t StarsTransaction) IsCredit() bool { return t.Amount > 0 } + +// StarsTransactionPage 是一页账本流水 + 当前余额 + 分页游标 + 对手方用户富化集合。 +type StarsTransactionPage struct { + Balance int64 + Transactions []StarsTransaction + NextOffset string // 空表示无更多页(DrKLO 据此停止翻页,勿在末页给非空值) + Users []User // History 中提到的对手方用户,供 tg Users 富化 +} + +// Stars 账本边界常量。 +const ( + // DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。 + DefaultStarsStartingGrant = 1000 + // MaxStarsTransactionsLimit 是 getStarsTransactions 单页上限。 + MaxStarsTransactionsLimit = 100 + // MaxStarsTransactionsOffsetBytes 是 keyset 游标字符串长度上限。 + MaxStarsTransactionsOffsetBytes = 64 +) + +// Stars 账本哨兵错误(rpc 层 errors.Is 匹配后映射为 tgerr,仿 ErrPremiumRequired)。 +var ( + // ErrStarsInsufficient 表示余额不足以完成借记(映射 BALANCE_TOO_LOW)。 + ErrStarsInsufficient = errors.New("stars: insufficient balance") + // ErrStarsInvalidAmount 表示金额非法(<=0)。 + ErrStarsInvalidAmount = errors.New("stars: invalid amount") +) + +// EncodeStarsCursor 把 keyset 游标(最后一条流水 id)编码为客户端不透明字符串。 +func EncodeStarsCursor(id int64) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) +} + +// DecodeStarsCursor 反解 EncodeStarsCursor;无法解析(含空串)时返回 ok=false, +// 调用方应据此从首页开始(客户端只会回传我们给过的游标,畸形仅作兜底)。 +func DecodeStarsCursor(s string) (int64, bool) { + if s == "" { + return 0, false + } + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return 0, false + } + id, err := strconv.ParseInt(string(raw), 10, 64) + if err != nil || id <= 0 { + return 0, false + } + return id, true +} diff --git a/internal/domain/sticker_collection.go b/internal/domain/sticker_collection.go new file mode 100644 index 00000000..523e6b7e --- /dev/null +++ b/internal/domain/sticker_collection.go @@ -0,0 +1,46 @@ +package domain + +import "errors" + +// ErrStickerInvalid 表示输入文档不是合法的贴纸/GIF(faveSticker/saveGif 等)。 +var ErrStickerInvalid = errors.New("sticker document invalid") + +// StickerCollectionKind 区分一个用户的几类个人贴纸集合。 +type StickerCollectionKind string + +const ( + // StickerCollectionFaved 收藏的贴纸(messages.faveSticker / getFavedStickers)。 + StickerCollectionFaved StickerCollectionKind = "faved" + // StickerCollectionRecent 最近使用的贴纸(saveRecentSticker / getRecentStickers)。 + StickerCollectionRecent StickerCollectionKind = "recent" + // StickerCollectionRecentAttached attach 菜单里最近用于媒体的贴纸(attached=true)。 + StickerCollectionRecentAttached StickerCollectionKind = "recent_attached" + // StickerCollectionGif 保存的 GIF(messages.saveGif / getSavedGifs)。 + StickerCollectionGif StickerCollectionKind = "gif" +) + +// 各集合容量上界(最新在前,超出截断最旧)。仅为限制存储增长,非严格 Telegram 配额; +// faved 不做 premium 分档(用户决策范围外),统一一个上界。 +const ( + MaxFavedStickers = 100 + MaxRecentStickers = 30 + MaxSavedGifs = 200 +) + +// MaxStickerCollectionItems 返回某类集合的容量上界。 +func MaxStickerCollectionItems(kind StickerCollectionKind) int { + switch kind { + case StickerCollectionFaved: + return MaxFavedStickers + case StickerCollectionGif: + return MaxSavedGifs + default: + return MaxRecentStickers + } +} + +// StickerCollectionItem 是集合内一项:文档 id + 入列时间(recent 的 used_at / faved 的收藏时刻)。 +type StickerCollectionItem struct { + DocumentID int64 + Date int +} diff --git a/internal/domain/story.go b/internal/domain/story.go new file mode 100644 index 00000000..865d88c1 --- /dev/null +++ b/internal/domain/story.go @@ -0,0 +1,557 @@ +package domain + +import ( + "errors" + "fmt" + "hash/fnv" + "strconv" + "strings" +) + +const ( + // MaxStoryID keeps story ids inside TL int / PostgreSQL int4 bounds. + MaxStoryID = MaxMessageBoxID + // MaxStoryIDs limits exact-id story RPCs and view increments. + MaxStoryIDs = 200 + // MaxStoryListLimit bounds one active/archive/pinned story page. + MaxStoryListLimit = 100 + // MaxStoryInteractionListLimit bounds one story viewer/reaction page. + MaxStoryInteractionListLimit = 100 + // MaxStoryInteractionOffsetLength bounds server-generated owner interaction + // cursors before they reach store-side parsing or query predicates. + MaxStoryInteractionOffsetLength = 64 + // MaxStoryAlbumOffset bounds stories.getAlbumStories count-offset paging + // before a future album store can turn malformed requests into deep OFFSET + // work. DrKLO sends loadedObjects.size(), so normal UI paging stays small. + MaxStoryAlbumOffset = 10000 + // MaxStoryPinnedToTop bounds stories.togglePinnedToTop. It matches the + // client default stories_pinned_to_top_count_max used by TDesktop/DrKLO. + MaxStoryPinnedToTop = 3 + // MaxStoryPrivacyFanoutTargets bounds one privacy-change fanout candidate set. + MaxStoryPrivacyFanoutTargets = 5000 + // MaxStorySendAsChannels bounds stories.getChatsToSend channel candidates. + MaxStorySendAsChannels = 200 + // MaxStoryMediaAreas bounds one story's overlay/click target vector. + MaxStoryMediaAreas = 16 + // MaxStoryMediaAreaURLLength bounds mediaAreaUrl click targets stored in + // one story snapshot. + MaxStoryMediaAreaURLLength = 2048 + // MaxStoryStarGiftSlugLength bounds collectible gift slugs stored in one + // story snapshot. TDesktop/DrKLO treat the slug as a deep-link path token. + MaxStoryStarGiftSlugLength = 255 + // MaxStoryGeoAddressPartLength bounds optional mediaAreaGeoPoint address + // labels stored in one story snapshot. + MaxStoryGeoAddressPartLength = 256 + // MaxStoryWeatherEmojiLength bounds mediaAreaWeather emoji labels stored in + // one story snapshot. + MaxStoryWeatherEmojiLength = 32 + // DefaultStoryPeriod is the Layer 225 sendStory period when the optional + // period flag is absent. + DefaultStoryPeriod = 86400 + // MaxStoryViewQueryLength bounds q in stories.getStoryViewsList before it + // reaches store-side LIKE/search predicates. + MaxStoryViewQueryLength = 128 + // DefaultStoryCanSendRemaining is the development-stage story quota returned + // by canSendStory until production limits are modeled. + DefaultStoryCanSendRemaining = 100 +) + +var ( + ErrStoryIDInvalid = errors.New("story id invalid") + ErrStoryNotFound = errors.New("story not found") + ErrStoryPeerInvalid = errors.New("story peer invalid") + ErrStoryNotModified = errors.New("story not modified") + ErrStoryOffsetInvalid = errors.New("story offset invalid") + ErrStoryPeriodInvalid = errors.New("story period invalid") +) + +// ValidateStoryInteractionOffset validates stories.getStoryViewsList and +// stories.getStoryReactionsList keyset cursors without depending on TL types. +func ValidateStoryInteractionOffset(offset string, reactionsOnly bool) error { + return validateStoryInteractionOffset(offset, reactionsOnly, false) +} + +// ValidateStoryReactionInteractionOffset validates stories.getStoryReactionsList +// cursors. When forwardsFirst is set, reaction-list pages may legitimately use +// group 1 for ordinary reaction rows after public repost rows. +func ValidateStoryReactionInteractionOffset(offset string, forwardsFirst bool) error { + return validateStoryInteractionOffset(offset, true, forwardsFirst) +} + +func validateStoryInteractionOffset(offset string, reactionsOnly, forwardsFirst bool) error { + if offset == "" { + return nil + } + if len(offset) > MaxStoryInteractionOffsetLength { + return ErrStoryOffsetInvalid + } + parts := strings.Split(offset, ":") + if len(parts) != 3 && len(parts) != 4 { + return ErrStoryOffsetInvalid + } + group, err1 := strconv.Atoi(parts[0]) + date, err2 := strconv.Atoi(parts[1]) + viewerID, err3 := strconv.ParseInt(parts[2], 10, 64) + var messageID int + var err4 error + if len(parts) == 4 { + messageID, err4 = strconv.Atoi(parts[3]) + } + reactionGroupInvalid := reactionsOnly && group != 0 && !(forwardsFirst && group == 1) + if err1 != nil || err2 != nil || err3 != nil || err4 != nil || + group < 0 || group > 1 || reactionGroupInvalid || + date <= 0 || viewerID == 0 || messageID < 0 || messageID > MaxMessageBoxID { + return ErrStoryOffsetInvalid + } + return nil +} + +// Story is a protocol-neutral Telegram story snapshot owned by one peer. +type Story struct { + Owner Peer + ID int + RandomID int64 + Date int + ExpireDate int + Deleted bool + Pinned bool + PinnedToTopOrder int + Public bool + CloseFriends bool + Contacts bool + SelectedContacts bool + NoForwards bool + Edited bool + Out bool + PrivacyRules []PrivacyRule + AllowUserIDs []int64 + DisallowUserIDs []int64 + Caption string + Entities []MessageEntity + Media *MessageMedia + MediaAreas []StoryMediaArea + Forward *StoryForward + Views StoryViews + SentReaction *MessageReaction +} + +// Active reports whether the story belongs in the active strip at now. +func (s Story) Active(now int) bool { + return !s.Deleted && s.ExpireDate > now +} + +// Interactable reports whether a viewer may create new view/reaction state. +// Expired profile-pinned stories remain visible from profiles, but ordinary +// expired stories must not accept stale interaction writes. +func (s Story) Interactable(now int) bool { + return !s.Deleted && (s.ExpireDate > now || s.Pinned) +} + +// VisibleTo reports story visibility with no relationship facts. +func (s Story) VisibleTo(viewerUserID int64) bool { + return s.VisibleToWithFacts(viewerUserID, false, false) +} + +// VisibleToWithFacts reports story visibility using already-loaded viewer facts. +func (s Story) VisibleToWithFacts(viewerUserID int64, viewerIsContact, viewerCloseFriend bool) bool { + return s.VisibleToWithStoryFacts(viewerUserID, viewerIsContact, viewerCloseFriend, false) +} + +// VisibleToWithStoryFacts reports story visibility using already-loaded viewer +// facts, including owner-side story blocklist membership. +func (s Story) VisibleToWithStoryFacts(viewerUserID int64, viewerIsContact, viewerCloseFriend, viewerStoryBlocked bool) bool { + if viewerUserID == 0 { + return false + } + if s.Owner.Type == PeerTypeUser && s.Owner.ID == viewerUserID { + return true + } + if viewerStoryBlocked { + return false + } + if int64InSet(s.DisallowUserIDs, viewerUserID) { + return false + } + if int64InSet(s.AllowUserIDs, viewerUserID) { + return true + } + if s.Public { + return true + } + if s.Contacts && viewerIsContact { + return true + } + if s.CloseFriends && viewerCloseFriend { + return true + } + return false +} + +func int64InSet(ids []int64, needle int64) bool { + for _, id := range ids { + if id == needle { + return true + } + } + return false +} + +// StoryForward contains the protocol-neutral source of a reposted story. +type StoryForward struct { + // Source is the durable server-side source story owner used for counters + // and owner interaction lists. From is the client-visible clickable peer + // and may be empty when forwards privacy requires a from_name-only header. + Source Peer + From Peer + FromName string + StoryID int + Modified bool +} + +// StoryViews is the owner-visible aggregate of story views and reactions. +type StoryViews struct { + ViewsCount int + ForwardsCount int + ReactionsCount int + Reactions []ChannelMessageReactionCount + RecentViewers []int64 + HasViewers bool +} + +// StoryView is one viewer's durable view/reaction row. +type StoryView struct { + Owner Peer + StoryID int + ViewerID int64 + Date int + Reaction *MessageReaction + // Repost is set for public repost interactions. It is protocol-neutral and + // converted by rpc to storyViewPublicRepost. + Repost *Story + PublicForward *StoryPublicForward + Blocked bool + BlockedMyStoriesFrom bool +} + +// StoryPublicForward is a public channel/supergroup message that shared a story +// via messageMediaStory. It is converted by rpc to storyViewPublicForward or +// storyReactionPublicForward. +type StoryPublicForward struct { + Message ChannelMessage +} + +// StoryViewListRequest pages owner-visible story viewers. +type StoryViewListRequest struct { + ViewerUserID int64 + Owner Peer + StoryID int + Offset string + Limit int + Query string + JustContacts bool + ReactionsFirst bool + ForwardsFirst bool +} + +// StoryViewList is a bounded page for stories.getStoryViewsList. +type StoryViewList struct { + Count int + ViewsCount int + ForwardsCount int + ReactionsCount int + Views []StoryView + NextOffset string +} + +// StoryReactionListRequest pages peers that reacted to one story. +type StoryReactionListRequest struct { + ViewerUserID int64 + Owner Peer + StoryID int + Reaction *MessageReaction + Offset string + Limit int + ForwardsFirst bool + CanViewOwnerInteractions bool +} + +// StoryReactionList is a bounded page for stories.getStoryReactionsList. +type StoryReactionList struct { + Count int + Reactions []StoryView + NextOffset string +} + +// StoryMessageForwardListRequest pages public channel/supergroup messages that +// shared one source story as messageMediaStory. +type StoryMessageForwardListRequest struct { + ViewerUserID int64 + Owner Peer + StoryID int + Offset string + Limit int + ReactionsFirst bool + ForwardsFirst bool +} + +// StoryMessageForwardList is a bounded page of public message forwards. +type StoryMessageForwardList struct { + Count int + Forwards []StoryView + NextOffset string +} + +// StoryPublicForwardListRequest pages public repost stories for one source story. +type StoryPublicForwardListRequest struct { + ViewerUserID int64 + Owner Peer + StoryID int + Offset string + Limit int +} + +// StoryPublicForwardList is a bounded page of public repost story forwards. +type StoryPublicForwardList struct { + Count int + Forwards []StoryView + NextOffset string +} + +// PeerStories is the story list for one owner peer from one viewer's perspective. +type PeerStories struct { + Peer Peer + MaxReadID int + Stories []Story + Users []User + Channels []Channel +} + +// StoryList is a bounded story page. +type StoryList struct { + Count int + State string + HasMore bool + // Hidden reports that this list is the viewer's hidden story source list. + Hidden bool + Stories []Story + // PinnedToTop contains the owner-visible top-pinned story IDs in display + // order for stories.stories.pinned_to_top. + PinnedToTop []int + Peers []PeerStories + Users []User + Channels []Channel +} + +// StoryListCursor is the opaque getAllStories seek position after one peer. +type StoryListCursor struct { + Set bool + Date int + Peer Peer +} + +// StoryListDigest summarizes a complete viewer-scoped active story source list. +type StoryListDigest struct { + Count int + Hash uint64 +} + +// DigestStoryPeerList returns a deterministic digest for an already ordered +// complete active/hidden story peer list. +func DigestStoryPeerList(peers []PeerStories) StoryListDigest { + h := fnv.New64a() + fmt.Fprintf(h, "story-list|count=%d|", len(peers)) + for _, peer := range peers { + fmt.Fprintf(h, "p=%s:%d:%d:%d|", peer.Peer.Type, peer.Peer.ID, peer.MaxReadID, len(peer.Stories)) + for _, story := range peer.Stories { + fmt.Fprintf( + h, + "s=%d:%d:%d:%t:%t:%d:%t:%t:%t:%t:%t:%t:%d:%d:%#v:%#v:%#v:%#v:%#v:%#v:%#v:%#v|", + story.ID, + story.Date, + story.ExpireDate, + story.Deleted, + story.Pinned, + story.PinnedToTopOrder, + story.Public, + story.CloseFriends, + story.Contacts, + story.SelectedContacts, + story.Edited, + story.Out, + story.Views.ViewsCount, + story.Views.ReactionsCount, + story.SentReaction, + story.Caption, + story.Entities, + story.Media, + story.MediaAreas, + story.Forward, + story.PrivacyRules, + story.AllowUserIDs, + ) + fmt.Fprintf(h, "disallow=%#v|noforwards=%t|", story.DisallowUserIDs, story.NoForwards) + } + } + return StoryListDigest{Count: len(peers), Hash: h.Sum64()} +} + +// StoryReadState is the per viewer+owner read boundary. +type StoryReadState struct { + ViewerID int64 + Peer Peer + MaxReadID int + Date int +} + +// RecentStory is the domain counterpart of TL recentStory. +type RecentStory struct { + Peer Peer + MaxID int + Live bool +} + +// PeerStoryProjection is the per-viewer story summary embedded into peer objects. +type PeerStoryProjection struct { + Peer Peer + Recent RecentStory + Hidden bool +} + +// StoryReadResult describes a readStories mutation. +type StoryReadResult struct { + ViewerID int64 + Peer Peer + MaxReadID int + Advanced bool + Date int +} + +// StoryReactionResult describes one story reaction mutation. +type StoryReactionResult struct { + ViewerID int64 + Peer Peer + StoryID int + Reaction *MessageReaction + Story Story + Changed bool + Date int +} + +// StoryMediaAreaKind identifies a protocol-neutral story overlay area. +type StoryMediaAreaKind string + +const ( + StoryMediaAreaSuggestedReaction StoryMediaAreaKind = "suggested_reaction" + StoryMediaAreaURL StoryMediaAreaKind = "url" + StoryMediaAreaGeoPoint StoryMediaAreaKind = "geo_point" + StoryMediaAreaVenue StoryMediaAreaKind = "venue" + StoryMediaAreaWeather StoryMediaAreaKind = "weather" + StoryMediaAreaChannelPost StoryMediaAreaKind = "channel_post" + StoryMediaAreaStarGift StoryMediaAreaKind = "star_gift" +) + +// StoryMediaAreaCoordinates is a percentage-based story media overlay box. +type StoryMediaAreaCoordinates struct { + X float64 + Y float64 + W float64 + H float64 + Rotation float64 + Radius float64 + HasRadius bool +} + +// StoryGeoPointAddress is the protocol-neutral counterpart of geoPointAddress. +type StoryGeoPointAddress struct { + CountryISO2 string + State string + City string + Street string +} + +// StoryMediaArea is a protocol-neutral media area stored with a story snapshot. +type StoryMediaArea struct { + Kind StoryMediaAreaKind + Coordinates StoryMediaAreaCoordinates + Dark bool + Flipped bool + Reaction *MessageReaction + URL string + Geo *MessageGeoPoint + GeoAddress *StoryGeoPointAddress + Venue *MessageVenue + WeatherEmoji string + TemperatureC float64 + Color int + ChannelID int64 + MsgID int + StarGiftSlug string +} + +// StoryCreateRequest creates one owner story with a store-assigned monotonic ID. +type StoryCreateRequest struct { + Owner Peer + RandomID int64 + Date int + Period int + Pinned bool + Public bool + CloseFriends bool + Contacts bool + SelectedContacts bool + NoForwards bool + PrivacyRules []PrivacyRule + AllowUserIDs []int64 + DisallowUserIDs []int64 + Caption string + Entities []MessageEntity + Media *MessageMedia + MediaAreas []StoryMediaArea + Forward *StoryForward +} + +// StoryCreateResult describes a sendStory mutation. +type StoryCreateResult struct { + Story Story + Duplicate bool +} + +// StoryEditRequest applies a partial story edit. +type StoryEditRequest struct { + Owner Peer + ID int + Media *MessageMedia + UpdateMedia bool + Caption string + Entities []MessageEntity + UpdateCaption bool + Public bool + CloseFriends bool + Contacts bool + SelectedContacts bool + PrivacyRules []PrivacyRule + AllowUserIDs []int64 + DisallowUserIDs []int64 + UpdatePrivacy bool + MediaAreas []StoryMediaArea + UpdateMediaAreas bool +} + +// StoryEditResult describes a story edit and its previous visible facts. +type StoryEditResult struct { + Story Story + Previous Story +} + +// StoryMutationResult describes story mutations that may emit updateStory. +type StoryMutationResult struct { + Peer Peer + IDs []int + Stories []Story + Previous []Story +} + +// UpsertStoryRequest inserts or replaces a story snapshot. +type UpsertStoryRequest struct { + Story Story +} diff --git a/internal/domain/system.go b/internal/domain/system.go index 1951ac3e..ef9c31ed 100644 --- a/internal/domain/system.go +++ b/internal/domain/system.go @@ -3,6 +3,11 @@ package domain const ( // OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。 OfficialSystemUserID int64 = 777000 + + // BotFatherUserID 是内置 BotFather 账号,与官方 @BotFather 同 ID。 + BotFatherUserID int64 = 93372553 + // BotFatherAccessHash 固定不变;与迁移 0090 的种子行双写,必须保持一致。 + BotFatherAccessHash int64 = 7421896403922962293 ) // OfficialSystemUser 返回第一阶段内置的官方系统账号。 @@ -17,3 +22,47 @@ func OfficialSystemUser() User { Support: true, } } + +// BotFatherUser 返回内置 BotFather 账号。username 不以 bot 结尾属种子例外(与官方一致)。 +func BotFatherUser() User { + return User{ + ID: BotFatherUserID, + AccessHash: BotFatherAccessHash, + FirstName: "BotFather", + Username: "BotFather", + Verified: true, + Bot: true, + BotInfoVersion: 1, + } +} + +// SystemUserByID 返回内置系统账号;非系统账号返回 ok=false。 +// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。 +func SystemUserByID(id int64) (User, bool) { + switch id { + case OfficialSystemUserID: + return OfficialSystemUser(), true + case BotFatherUserID: + return BotFatherUser(), true + } + return User{}, false +} + +func IsSystemUserID(id int64) bool { + _, ok := SystemUserByID(id) + return ok +} + +func SystemUserByPhone(phone string) (User, bool) { + phone = NormalizePhone(phone) + for _, id := range []int64{OfficialSystemUserID, BotFatherUserID} { + u, ok := SystemUserByID(id) + if !ok || u.Phone == "" { + continue + } + if NormalizePhone(u.Phone) == phone { + return u, true + } + } + return User{}, false +} diff --git a/internal/domain/themes.go b/internal/domain/themes.go new file mode 100644 index 00000000..f42088a4 --- /dev/null +++ b/internal/domain/themes.go @@ -0,0 +1,185 @@ +package domain + +import "errors" + +// 自定义云主题(account.createTheme 等)相关错误。 +var ( + ErrThemeNotFound = errors.New("theme not found") + ErrThemeInvalid = errors.New("theme invalid") + ErrThemeSlugTaken = errors.New("theme slug taken") + ErrThemeFormatInvalid = errors.New("theme format invalid") +) + +// ThemeBaseKind 对应 TL baseTheme 的五个变体(classic/day/night/tinted/arctic)。 +// 客户端按当前 base theme 选取匹配的 ThemeSettings。 +type ThemeBaseKind string + +const ( + ThemeBaseClassic ThemeBaseKind = "classic" + ThemeBaseDay ThemeBaseKind = "day" + ThemeBaseNight ThemeBaseKind = "night" + ThemeBaseTinted ThemeBaseKind = "tinted" + ThemeBaseArctic ThemeBaseKind = "arctic" +) + +// ThemeWallpaperSpec 是一份主题设置里的纯渐变墙纸(对应 wallPaperNoFile + wallPaperSettings)。 +// 自定义云主题只用纯色渐变(无下载),与静态目录一致。 +type ThemeWallpaperSpec struct { + BackgroundColors []int `json:"background_colors,omitempty"` // 最多 4 个 + Intensity int `json:"intensity,omitempty"` + Rotation int `json:"rotation,omitempty"` + Blur bool `json:"blur,omitempty"` + Motion bool `json:"motion,omitempty"` + Emoticon string `json:"emoticon,omitempty"` + Dark bool `json:"dark,omitempty"` +} + +// WallpaperSettings 是频道/私聊 wallpaper 的 domain-only 设置。Has* 字段保留 TL +// conditional flag 语义,避免黑色(0)这类合法取值在持久化时丢失。 +type WallpaperSettings struct { + Blur bool `json:"blur,omitempty"` + Motion bool `json:"motion,omitempty"` + HasBackgroundColor bool `json:"has_background_color,omitempty"` + BackgroundColor int `json:"background_color,omitempty"` + HasSecondBackgroundColor bool `json:"has_second_background_color,omitempty"` + SecondBackgroundColor int `json:"second_background_color,omitempty"` + HasThirdBackgroundColor bool `json:"has_third_background_color,omitempty"` + ThirdBackgroundColor int `json:"third_background_color,omitempty"` + HasFourthBackgroundColor bool `json:"has_fourth_background_color,omitempty"` + FourthBackgroundColor int `json:"fourth_background_color,omitempty"` + HasIntensity bool `json:"has_intensity,omitempty"` + Intensity int `json:"intensity,omitempty"` + HasRotation bool `json:"has_rotation,omitempty"` + Rotation int `json:"rotation,omitempty"` + HasEmoticon bool `json:"has_emoticon,omitempty"` + Emoticon string `json:"emoticon,omitempty"` +} + +// Empty reports whether no explicit wallpaper rendering settings are present. +func (s WallpaperSettings) Empty() bool { + return !s.Blur && + !s.Motion && + !s.HasBackgroundColor && + !s.HasSecondBackgroundColor && + !s.HasThirdBackgroundColor && + !s.HasFourthBackgroundColor && + !s.HasIntensity && + !s.HasRotation && + !s.HasEmoticon +} + +// Wallpaper 是频道/私聊当前 wallpaper 的 domain-only 描述。NoFile=true 对应 +// wallPaperNoFile;否则 ID+AccessHash/Slug 引用 catalog/document wallpaper。 +type Wallpaper struct { + ID int64 `json:"id,omitempty"` + AccessHash int64 `json:"access_hash,omitempty"` + Slug string `json:"slug,omitempty"` + NoFile bool `json:"no_file,omitempty"` + Default bool `json:"default,omitempty"` + Pattern bool `json:"pattern,omitempty"` + Dark bool `json:"dark,omitempty"` + Settings WallpaperSettings `json:"settings,omitempty"` +} + +// CloneWallpaperPtr returns a detached wallpaper pointer. +func CloneWallpaperPtr(in *Wallpaper) *Wallpaper { + if in == nil { + return nil + } + out := *in + return &out +} + +// WallpaperEqual compares optional wallpapers by value. +func WallpaperEqual(a, b *Wallpaper) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +// ThemeSettingsSpec 是一个 base theme 下的配色(对应 themeSettings/inputThemeSettings)。 +// BaseTheme 与 AccentColor 必填(TL 编码器要求 base_theme 非空)。 +type ThemeSettingsSpec struct { + BaseTheme ThemeBaseKind `json:"base_theme"` + AccentColor int `json:"accent_color"` + OutboxAccentColor int `json:"outbox_accent_color,omitempty"` + HasOutboxAccent bool `json:"has_outbox_accent,omitempty"` + MessageColors []int `json:"message_colors,omitempty"` + MessageColorsAnimated bool `json:"message_colors_animated,omitempty"` + Wallpaper *ThemeWallpaperSpec `json:"wallpaper,omitempty"` +} + +// Theme 是一份持久化的自定义云主题。document_id 软引用 documents(无硬外键), +// settings 仅 accent 主题非空;完整 .attheme 主题靠 DocumentID 下载。 +type Theme struct { + ID int64 + AccessHash int64 + CreatorUserID int64 + Slug string + Title string + Emoticon string + ForChat bool + DocumentID int64 + Settings []ThemeSettingsSpec + InstallsCount int + CreatedAt int64 // unix 秒 +} + +// Clone 深拷贝(切片字段),避免内存 store 暴露内部引用。 +func (t Theme) Clone() Theme { + out := t + out.Settings = cloneThemeSettings(t.Settings) + return out +} + +func cloneThemeSettings(in []ThemeSettingsSpec) []ThemeSettingsSpec { + if in == nil { + return nil + } + out := make([]ThemeSettingsSpec, len(in)) + for i, s := range in { + out[i] = s + out[i].MessageColors = append([]int(nil), s.MessageColors...) + if s.Wallpaper != nil { + wp := *s.Wallpaper + wp.BackgroundColors = append([]int(nil), s.Wallpaper.BackgroundColors...) + out[i].Wallpaper = &wp + } + } + return out +} + +// IsCreator 报告 userID 是否为该主题的创建者(决定客户端再次上传走 create 还是 update)。 +func (t Theme) IsCreator(userID int64) bool { + return t.CreatorUserID != 0 && t.CreatorUserID == userID +} + +// ThemeRef 引用一份主题:按 id+access_hash(inputTheme)或 slug(inputThemeSlug,深链)。 +type ThemeRef struct { + ID int64 + AccessHash int64 + Slug string +} + +// IsZero 报告该引用是否为空(既无 id 也无 slug)。 +func (r ThemeRef) IsZero() bool { return r.ID == 0 && r.Slug == "" } + +// ThemeSpec 是 createTheme 的输入。Slug 为空表示由服务端自动分配。 +type ThemeSpec struct { + CreatorUserID int64 + Slug string + Title string + Emoticon string + ForChat bool + DocumentID int64 + Settings []ThemeSettingsSpec +} + +// ThemeUpdate 是 updateTheme 的部分更新;nil 字段表示不改。 +type ThemeUpdate struct { + Slug *string + Title *string + DocumentID *int64 + Settings *[]ThemeSettingsSpec +} diff --git a/internal/domain/update_event.go b/internal/domain/update_event.go index 7ee4f437..dcf07de6 100644 --- a/internal/domain/update_event.go +++ b/internal/domain/update_event.go @@ -4,25 +4,65 @@ package domain type UpdateEventType string const ( - UpdateEventNewMessage UpdateEventType = "new_message" - UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox" - UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox" - UpdateEventReadMessageContents UpdateEventType = "read_message_contents" - UpdateEventEditMessage UpdateEventType = "edit_message" - UpdateEventMessageReactions UpdateEventType = "message_reactions" - UpdateEventContactsReset UpdateEventType = "contacts_reset" - UpdateEventDialogPinned UpdateEventType = "dialog_pinned" - UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs" - UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark" - UpdateEventPeerSettings UpdateEventType = "peer_settings" - UpdateEventDeleteMessages UpdateEventType = "delete_messages" - UpdateEventDialogFilter UpdateEventType = "dialog_filter" - UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order" - UpdateEventDialogFilters UpdateEventType = "dialog_filters" - UpdateEventFolderPeers UpdateEventType = "folder_peers" - UpdateEventChannelAvailable UpdateEventType = "channel_available_messages" - UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages" - UpdateEventNoop UpdateEventType = "noop" + UpdateEventNewMessage UpdateEventType = "new_message" + UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox" + UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox" + // forum 话题级已读:messages.readDiscussion 推进 per-topic 水位后下发。 + UpdateEventReadChannelDiscussionInbox UpdateEventType = "read_channel_discussion_inbox" + UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox" + UpdateEventReadMessageContents UpdateEventType = "read_message_contents" + UpdateEventEditMessage UpdateEventType = "edit_message" + // UpdateEventWebPage 映射 updateWebPage:异步解析完成后把消息里的 pending 链接预览 + // 占位就地替换为已解析卡片。携带账号 pts(非 LacksWirePts),消息快照经 box JOIN 重建, + // 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。 + UpdateEventWebPage UpdateEventType = "web_page" + UpdateEventMessageReactions UpdateEventType = "message_reactions" + // UpdateEventMessagePoll 映射 updateMessagePoll(投票/关闭后 poll 状态变化; + // Message 为该 owner 视角消息,media 在 difference 重放时按 viewer 重新 enrich)。 + // 与 reaction 同款:占账号 pts 但 TL 构造器无 pts,见 LacksWirePts。 + UpdateEventMessagePoll UpdateEventType = "message_poll" + UpdateEventContactsReset UpdateEventType = "contacts_reset" + UpdateEventDialogPinned UpdateEventType = "dialog_pinned" + UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs" + UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark" + UpdateEventPeerSettings UpdateEventType = "peer_settings" + UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked" + UpdateEventDeleteMessages UpdateEventType = "delete_messages" + // UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消 + // 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。 + // TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。 + UpdateEventPinnedMessages UpdateEventType = "pinned_messages" + UpdateEventDialogFilter UpdateEventType = "dialog_filter" + UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order" + UpdateEventDialogFilters UpdateEventType = "dialog_filters" + UpdateEventFolderPeers UpdateEventType = "folder_peers" + UpdateEventChannelAvailable UpdateEventType = "channel_available_messages" + UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages" + UpdateEventStory UpdateEventType = "story" + UpdateEventReadStories UpdateEventType = "read_stories" + UpdateEventSentStoryReaction UpdateEventType = "sent_story_reaction" + UpdateEventNewStoryReaction UpdateEventType = "new_story_reaction" + UpdateEventQuickReplies UpdateEventType = "quick_replies" + UpdateEventNewQuickReply UpdateEventType = "new_quick_reply" + UpdateEventDeleteQuickReply UpdateEventType = "delete_quick_reply" + UpdateEventQuickReplyMessage UpdateEventType = "quick_reply_message" + UpdateEventDeleteQuickReplyMessages UpdateEventType = "delete_quick_reply_messages" + // UpdateEventChannelState 表示当前账号与某频道的成员关系发生变化 + // (leave/kick),离线设备经 difference 收到 updateChannel 后重新拉取 + // channel 状态并移除会话。 + UpdateEventChannelState UpdateEventType = "channel_state" + // UpdateEventDraftMessage 映射 updateDraftMessage(云草稿变更;Peer 为会话, + // MaxID 复用为 forum top_msg_id)。事件只是"该会话草稿变过"的标记——草稿是 + // 绝对状态而非增量,difference/outbox 重放时按 Peer 重载当前草稿填充 Draft + // 字段(已删则下发 draftMessageEmpty),不在事件行里固化内容快照。 + UpdateEventDraftMessage UpdateEventType = "draft_message" + // UpdateEventSavedDialogPinned 映射 updateSavedDialogPinned(收藏夹 + // 子会话置顶翻转;Peer 为子会话分组键,Bool 为 pinned)。 + UpdateEventSavedDialogPinned UpdateEventType = "saved_dialog_pinned" + // UpdateEventPinnedSavedDialogs 映射 updatePinnedSavedDialogs + // (收藏夹置顶顺序整表,Peers 为新顺序)。 + UpdateEventPinnedSavedDialogs UpdateEventType = "pinned_saved_dialogs" + UpdateEventNoop UpdateEventType = "noop" ) // UpdateEvent 是账号视角的增量事件,按 user_id + pts 顺序持久化。 @@ -33,6 +73,7 @@ type UpdateEvent struct { PtsCount int Date int Message Message + Story Story Peer Peer Peers []Peer Bool bool @@ -40,13 +81,73 @@ type UpdateEvent struct { MessageIDs []int MaxID int StillUnreadCount int - Users []User - Channels []Channel - FilterID int - DialogFilter *DialogFolder - FilterOrder []int - FolderPeers []FolderPeerUpdate - TagsEnabled bool + ChannelPts int + // TopMsgID 仅 forum per-topic 已读事件(read_channel_discussion_*)使用:承载话题 id + // (General=1),与 MaxID(=read_max_id) 一起映射 updateReadChannelDiscussionInbox/Outbox。 + TopMsgID int + Users []User + Channels []Channel + FilterID int + // FolderID 是 read 类事件发生时该会话所在的物理 folder(0 主列表/1 归档), + // 填入 updateReadChannelInbox.folder_id。 + FolderID int + DialogFilter *DialogFolder + // Draft 仅 draft_message 事件使用:不持久化,difference/outbox 重放时按 + // Peer(+MaxID=top_msg_id) 重载当前草稿填充;nil 表示草稿已删(下发 empty)。 + Draft *DialogDraft + FilterOrder []int + FolderPeers []FolderPeerUpdate + TagsEnabled bool + Reaction *MessageReaction + QuickReplies []QuickReply + QuickReply QuickReply + QuickReplyMessage QuickReplyMessage +} + +// LacksWirePts 表示该事件占用了账号 pts,但它对应的 TL update 构造器没有 +// 账号 pts 字段(reaction、channel 已读、dialog/folder/settings 状态类)。 +// 在线投递这类事件必须附带显式 pts 簿记,否则客户端水位与服务端错位, +// 下一条真正带 pts 的更新会被判为空洞。 +func (e UpdateEvent) LacksWirePts() bool { + switch e.Type { + case UpdateEventMessageReactions, + UpdateEventMessagePoll, + UpdateEventDraftMessage, + UpdateEventChannelState, + UpdateEventContactsReset, + UpdateEventDialogPinned, + UpdateEventPinnedDialogs, + UpdateEventSavedDialogPinned, + UpdateEventPinnedSavedDialogs, + UpdateEventDialogUnreadMark, + UpdateEventPeerSettings, + UpdateEventPeerStoryBlocked, + UpdateEventDialogFilter, + UpdateEventDialogFilterOrder, + UpdateEventDialogFilters, + UpdateEventChannelAvailable, + UpdateEventChannelViewForum, + UpdateEventStory, + UpdateEventReadStories, + UpdateEventSentStoryReaction, + UpdateEventNewStoryReaction, + UpdateEventQuickReplies, + UpdateEventNewQuickReply, + UpdateEventDeleteQuickReply, + UpdateEventQuickReplyMessage, + UpdateEventDeleteQuickReplyMessages: + // updateFolderPeers 自带 pts/pts_count,不在此列。 + return true + case UpdateEventReadChannelDiscussionInbox, UpdateEventReadChannelDiscussionOutbox: + // forum 话题已读映射 updateReadChannelDiscussionInbox/Outbox,无账号 pts 字段。 + return true + case UpdateEventReadHistoryInbox, UpdateEventReadHistoryOutbox: + // channel peer 映射 updateReadChannelInbox/Outbox,无账号 pts 字段; + // 私聊形态自带 pts/pts_count。 + return e.Peer.Type == PeerTypeChannel + default: + return false + } } // UpdateDifference 是 updates.getDifference 的业务层结果。 @@ -63,4 +164,5 @@ type UpdateDifference struct { type ChannelDifferenceNudge struct { ChannelID int64 Pts int + Channel *ChannelView } diff --git a/internal/domain/user.go b/internal/domain/user.go index 1b049ead..7070f084 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -6,6 +6,19 @@ package domain // 777000 等兼容系统账号低于该区间,业务注册用户从这里开始递增。 const UserIDSequenceBase int64 = 1780243200 +// PeerColor is a domain-only representation of Telegram peerColor. +// HasColor preserves explicit color=0, which is distinct from "color unset". +type PeerColor struct { + HasColor bool + Color int + BackgroundEmojiID int64 +} + +// Empty reports whether no explicit color/profile color state is set. +func (c PeerColor) Empty() bool { + return !c.HasColor && c.BackgroundEmojiID == 0 +} + // User 是一个账号。第一阶段仅保留登录链路必须字段; // access_hash 为任何 InputUser 校验所必须,不可省。 type User struct { @@ -21,15 +34,53 @@ type User struct { Support bool Contact bool Mutual bool + CloseFriend bool + // Bot 标识 bot 账号;置位时 BotInfoVersion 必须 ≥1(TDesktop 只认 + // user TL 是否携带 bot_info_version 字段,且与 bot flag 共用 bit14)。 + Bot bool + BotInfoVersion int + // PremiumUntil 是会员到期 Unix 秒;0 表示非会员。premium 状态的唯一权威 + // 来源是该字段,由读取路径经 PremiumActiveAt 即时派生——到期后无需等 + // 后台 sweeper 翻转即停止下发 premium(sweeper 只负责清理与通知推送)。 + PremiumUntil int + // EmojiStatusDocumentID / EmojiStatusUntil 是用户自定义 emoji status + //(premium 专属,account.updateEmojiStatus)。DocumentID==0 表示未设置; + // Until==0 表示永久。 + EmojiStatusDocumentID int64 + EmojiStatusUntil int + // Birthday 是用户公开生日(account.updateBirthday)。零值表示未设置。 + Birthday Birthday + // PersonalChannelID 是资料页展示的「个人频道」(account.updatePersonalChannel); + // 0 表示未设置。资料投影时按它取频道对象与最新一帖。 + PersonalChannelID int64 + Color PeerColor + ProfileColor PeerColor // Profile photo fields are filled by app-layer user projection. PhotoID==0 表示无头像。 PhotoID int64 PhotoDCID int PhotoStripped []byte PhotoPersonal bool + PhotoHasVideo bool LastSeenAt int Status UserStatus } +// PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。 +// bot 永不为会员(官方语义;授予路径同样排除 bot,这里是双保险)。 +func (u User) PremiumActiveAt(now int64) bool { + return !u.Bot && u.PremiumUntil > 0 && int64(u.PremiumUntil) > now +} + +// EmojiStatusActiveAt 报告用户在 now(Unix 秒)时刻是否有生效的 emoji status +// (已设置且未过期;Until==0 表示永久)。emoji status 是 premium 专属,到期 +// 降级后即便列仍有残值也不再下发。 +func (u User) EmojiStatusActiveAt(now int64) bool { + if !u.PremiumActiveAt(now) || u.EmojiStatusDocumentID == 0 { + return false + } + return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now +} + // UserStatusKind is a protocol-neutral account presence state. type UserStatusKind int @@ -53,6 +104,29 @@ type UserStatus struct { WasOnline int } +// Birthday 是用户公开生日。Day/Month 为 0 表示未设置;Year 为 0 表示只填了月日不含年份。 +type Birthday struct { + Day int + Month int + Year int +} + +// IsSet 报告生日是否已设置(必须有合法月日)。 +func (b Birthday) IsSet() bool { + return b.Day != 0 && b.Month != 0 +} + +// ValidBirthday 校验月/日(年份可选)是否在合法范围内。清除生日传零值即可(IsSet 为 false)。 +func ValidBirthday(b Birthday) bool { + if b.Month < 1 || b.Month > 12 || b.Day < 1 || b.Day > 31 { + return false + } + if b.Year != 0 && (b.Year < 1900 || b.Year > 2100) { + return false + } + return true +} + // UserProfileUpdate 描述 account.updateProfile 的可选字段更新。 type UserProfileUpdate struct { FirstName string diff --git a/internal/domain/user_errors.go b/internal/domain/user_errors.go index 8930b15d..0d35b866 100644 --- a/internal/domain/user_errors.go +++ b/internal/domain/user_errors.go @@ -9,4 +9,12 @@ var ( ErrPhoneNotOccupied = errors.New("phone not occupied") ErrFirstNameInvalid = errors.New("first name invalid") ErrAboutTooLong = errors.New("about too long") + ErrUserNotFound = errors.New("user not found") + ErrUserSendRestricted = errors.New("user send restricted") + // ErrPremiumRequired 表示该操作仅限有效会员(PREMIUM_ACCOUNT_REQUIRED)。 + ErrPremiumRequired = errors.New("premium account required") + // ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。 + ErrPremiumBotUnsupported = errors.New("bot accounts cannot be premium") + // ErrBirthdayInvalid 表示生日的月/日/年不在合法范围(BIRTHDAY_INVALID)。 + ErrBirthdayInvalid = errors.New("birthday invalid") ) diff --git a/internal/domain/webpage.go b/internal/domain/webpage.go new file mode 100644 index 00000000..30a02386 --- /dev/null +++ b/internal/domain/webpage.go @@ -0,0 +1,75 @@ +package domain + +import ( + "hash/fnv" + "net/url" + "strings" +) + +// NormalizeWebPageURL 把用户消息里的链接规范化为去重用的稳定形态:仅接受 http/https、 +// 拒绝带 userinfo 的 URL(SSRF/伪装防御)、小写 scheme 与 host、去掉默认端口与 fragment, +// 保留 path 与 query 原样。返回规范化 URL 与是否可预览(不可预览返回 false)。 +func NormalizeWebPageURL(raw string) (string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", false + } + u, err := url.Parse(raw) + if err != nil { + return "", false + } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return "", false + } + if u.User != nil { + return "", false + } + host := strings.ToLower(u.Host) + if host == "" { + return "", false + } + // 去掉与 scheme 对应的默认端口,避免 example.com 与 example.com:80 哈希不同。 + if h, port, ok := splitHostPort(host); ok { + if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") { + host = h + } + } + out := scheme + "://" + host + u.EscapedPath() + if u.RawQuery != "" { + out += "?" + u.RawQuery + } + return out, true +} + +// splitHostPort 拆分 host:port;无端口时 ok=false。不用 net.SplitHostPort 以免 IPv6 +// 字面量裸 host(无端口)被当作错误。 +func splitHostPort(host string) (string, string, bool) { + // IPv6 字面量形如 [::1]:443;只在末尾 ] 之后找端口。 + idx := strings.LastIndexByte(host, ':') + if idx < 0 { + return host, "", false + } + if close := strings.LastIndexByte(host, ']'); close >= 0 && idx < close { + return host, "", false + } + return host[:idx], host[idx+1:], true +} + +// IsPendingWebPageMedia 报告 media 是否为 ID==id 的 pending 链接预览占位(异步解析的 +// 幂等守卫:仅这种状态才允许被解析结果就地替换)。 +func IsPendingWebPageMedia(m *MessageMedia, id int64) bool { + return m != nil && + m.Kind == MessageMediaKindWebPage && + m.WebPage != nil && + m.WebPage.State == MessageWebPageStatePending && + m.WebPage.ID == id +} + +// WebPageURLHash 计算规范化 URL 的稳定 63-bit 哈希,同时用作 web_pages 行的主键与 +// webPage id(保证 pending 占位与 done 解析携带同一 id)。FNV-1a/64 取正。 +func WebPageURLHash(normalized string) int64 { + h := fnv.New64a() + _, _ = h.Write([]byte(normalized)) + return int64(h.Sum64() & 0x7fffffffffffffff) +} diff --git a/internal/domain/webpage_test.go b/internal/domain/webpage_test.go new file mode 100644 index 00000000..1140fbb7 --- /dev/null +++ b/internal/domain/webpage_test.go @@ -0,0 +1,58 @@ +package domain + +import "testing" + +func TestNormalizeWebPageURL(t *testing.T) { + cases := []struct { + name string + in string + want string + ok bool + }{ + {"lowercase-host-drop-fragment", "https://Example.COM/Path?q=1#frag", "https://example.com/Path?q=1", true}, + {"strip-default-http-port", "http://example.com:80/x", "http://example.com/x", true}, + {"strip-default-https-port", "https://example.com:443", "https://example.com", true}, + {"keep-nondefault-port", "http://example.com:8080/x", "http://example.com:8080/x", true}, + {"keep-query-case", "https://e.com/a?B=C", "https://e.com/a?B=C", true}, + {"reject-scheme", "ftp://example.com/x", "", false}, + {"reject-userinfo", "https://user:pass@example.com/x", "", false}, + {"reject-no-host", "https:///path", "", false}, + {"reject-plain-text", "not a url", "", false}, + {"reject-empty", " ", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := NormalizeWebPageURL(tc.in) + if ok != tc.ok || got != tc.want { + t.Fatalf("NormalizeWebPageURL(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestWebPageURLHashStableAndPositive(t *testing.T) { + const u = "https://example.com/article" + h1 := WebPageURLHash(u) + h2 := WebPageURLHash(u) + if h1 != h2 { + t.Fatalf("hash not deterministic: %d != %d", h1, h2) + } + if h1 < 0 { + t.Fatalf("hash must be non-negative (used as TL id), got %d", h1) + } + if WebPageURLHash("https://other.example/x") == h1 { + t.Fatalf("distinct URLs collided") + } +} + +// TestNormalizeThenHashDedupes 验证规范化后等价的 URL 哈希一致(去重键稳定)。 +func TestNormalizeThenHashDedupes(t *testing.T) { + a, _ := NormalizeWebPageURL("https://Example.com:443/x") + b, _ := NormalizeWebPageURL("https://example.com/x") + if a != b { + t.Fatalf("normalized forms differ: %q vs %q", a, b) + } + if WebPageURLHash(a) != WebPageURLHash(b) { + t.Fatalf("equivalent URLs hashed differently") + } +} diff --git a/internal/loadtest/media_download_load_test.go b/internal/loadtest/media_download_load_test.go new file mode 100644 index 00000000..8e893d15 --- /dev/null +++ b/internal/loadtest/media_download_load_test.go @@ -0,0 +1,245 @@ +package loadtest + +import ( + "context" + "fmt" + "io" + "os" + "sort" + "sync" + "sync/atomic" + "testing" + "time" + + filesapp "telesrv/internal/app/files" + "telesrv/internal/domain" + "telesrv/internal/store/postgres" +) + +// TestMediaDownloadRangeBaseline 用真实 PostgreSQL 元数据 + localfs blob backend +// 压测 upload.getFile 背后的 files.Service.GetFile range 读路径。默认跳过; +// 设置 TELESRV_MEDIA_DOWNLOAD_LOAD=1 和 TELESRV_TEST_POSTGRES_DSN 后运行。 +func TestMediaDownloadRangeBaseline(t *testing.T) { + if os.Getenv("TELESRV_MEDIA_DOWNLOAD_LOAD") != "1" { + t.Skip("set TELESRV_MEDIA_DOWNLOAD_LOAD=1 to run media download baseline") + } + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set TELESRV_TEST_POSTGRES_DSN to run media download baseline") + } + + ctx := context.Background() + if err := postgres.Migrate(dsn); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := postgres.Open(ctx, dsn, postgres.WithMaxConns(envInt("TELESRV_MEDIA_LOAD_POOL_CONNS", 16))) + if err != nil { + t.Fatalf("open postgres: %v", err) + } + t.Cleanup(pool.Close) + + blobDir := os.Getenv("TELESRV_MEDIA_LOAD_BLOB_DIR") + if blobDir == "" { + blobDir = t.TempDir() + } + blobs, err := filesapp.NewLocalFS(blobDir) + if err != nil { + t.Fatalf("new localfs: %v", err) + } + mediaStore := postgres.NewMediaStore(pool) + svc := filesapp.NewService(mediaStore, blobs, 2, filesapp.WithVideoThumbnailer(nil)) + + runID := time.Now().UnixNano() + bodyKey := fmt.Sprintf("loadtest:doc:%d", runID) + thumbKey := fmt.Sprintf("loadtest:doc:%d:m", runID) + bodyBytes := int64(envInt("TELESRV_MEDIA_LOAD_BLOB_BYTES", 64<<20)) + if bodyBytes < 1<<20 { + bodyBytes = 1 << 20 + } + chunkSize := envInt("TELESRV_MEDIA_LOAD_CHUNK_BYTES", 512<<10) + if chunkSize < 1 { + chunkSize = 512 << 10 + } + thumbBytes := envInt("TELESRV_MEDIA_LOAD_THUMB_BYTES", 96<<10) + if thumbBytes < 1 { + thumbBytes = 96 << 10 + } + + bodyObject, bodySize, bodySHA, err := blobs.PutReader(ctx, newPatternReader(bodyBytes)) + if err != nil { + t.Fatalf("put body blob: %v", err) + } + thumbObject, thumbSize, thumbSHA, err := blobs.PutReader(ctx, newPatternReader(int64(thumbBytes))) + if err != nil { + t.Fatalf("put thumb blob: %v", err) + } + if err := mediaStore.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: bodyKey, + Backend: domain.MediaBackend(blobs.Name()), + ObjectKey: bodyObject, + Size: bodySize, + SHA256: bodySHA, + MimeType: "video/mp4", + }); err != nil { + t.Fatalf("put body file blob: %v", err) + } + if err := mediaStore.PutFileBlob(ctx, domain.FileBlob{ + LocationKey: thumbKey, + Backend: domain.MediaBackend(blobs.Name()), + ObjectKey: thumbObject, + Size: thumbSize, + SHA256: thumbSHA, + MimeType: "image/jpeg", + }); err != nil { + t.Fatalf("put thumb file blob: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), "DELETE FROM file_blobs WHERE location_key IN ($1, $2)", bodyKey, thumbKey) + }) + + concurrency := envInt("TELESRV_MEDIA_LOAD_CONCURRENCY", 16) + if concurrency < 1 { + concurrency = 1 + } + requests := envInt("TELESRV_MEDIA_LOAD_REQUESTS", 2000) + if requests < 1 { + requests = 1 + } + thumbRequests := envInt("TELESRV_MEDIA_LOAD_THUMB_REQUESTS", 500) + if thumbRequests < 0 { + thumbRequests = 0 + } + + bodyResult := runGetFileLoad(ctx, t, svc, bodyKey, bodyBytes, chunkSize, concurrency, requests) + t.Logf("media body range load: requests=%d concurrency=%d chunk=%d blob=%d wall=%s throughput=%.1f req/s %.1f MiB/s p50=%s p95=%s p99=%s errors=%d", + requests, + concurrency, + chunkSize, + bodyBytes, + bodyResult.wall, + float64(requests)/bodyResult.wall.Seconds(), + float64(bodyResult.bytes)/(1024*1024)/bodyResult.wall.Seconds(), + bodyResult.p50, + bodyResult.p95, + bodyResult.p99, + bodyResult.errors, + ) + if bodyResult.errors > 0 { + t.Fatalf("body range load errors=%d", bodyResult.errors) + } + + if thumbRequests > 0 { + thumbResult := runGetFileLoad(ctx, t, svc, thumbKey, thumbSize, int(thumbSize), max(1, concurrency/2), thumbRequests) + t.Logf("media thumb load: requests=%d concurrency=%d bytes=%d wall=%s throughput=%.1f req/s p50=%s p95=%s p99=%s errors=%d", + thumbRequests, + max(1, concurrency/2), + thumbSize, + thumbResult.wall, + float64(thumbRequests)/thumbResult.wall.Seconds(), + thumbResult.p50, + thumbResult.p95, + thumbResult.p99, + thumbResult.errors, + ) + if thumbResult.errors > 0 { + t.Fatalf("thumb load errors=%d", thumbResult.errors) + } + } +} + +type getFileLoadResult struct { + wall time.Duration + bytes int64 + errors int64 + p50 time.Duration + p95 time.Duration + p99 time.Duration +} + +func runGetFileLoad(ctx context.Context, t *testing.T, svc *filesapp.Service, locationKey string, totalSize int64, chunkSize, concurrency, requests int) getFileLoadResult { + t.Helper() + perWorkerLat := make([][]time.Duration, concurrency) + var counter atomic.Int64 + var readBytes atomic.Int64 + var errs atomic.Int64 + start := time.Now() + var wg sync.WaitGroup + for w := 0; w < concurrency; w++ { + wg.Add(1) + go func(worker int) { + defer wg.Done() + lat := make([]time.Duration, 0, requests/concurrency+1) + for { + n := int(counter.Add(1)) + if n > requests { + break + } + offset := int64((n - 1) * chunkSize) + if totalSize > 0 { + offset %= totalSize + } + t0 := time.Now() + chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{ + LocationKey: locationKey, + Offset: offset, + Limit: chunkSize, + }) + lat = append(lat, time.Since(t0)) + if err != nil || !ok { + errs.Add(1) + continue + } + readBytes.Add(int64(len(chunk.Bytes))) + } + perWorkerLat[worker] = lat + }(w) + } + wg.Wait() + wall := time.Since(start) + latencies := flattenDurations(perWorkerLat) + sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) + return getFileLoadResult{ + wall: wall, + bytes: readBytes.Load(), + errors: errs.Load(), + p50: percentile(latencies, 50), + p95: percentile(latencies, 95), + p99: percentile(latencies, 99), + } +} + +func flattenDurations(values [][]time.Duration) []time.Duration { + var total int + for _, v := range values { + total += len(v) + } + out := make([]time.Duration, 0, total) + for _, v := range values { + out = append(out, v...) + } + return out +} + +type patternReader struct { + remaining int64 + next byte +} + +func newPatternReader(n int64) *patternReader { + return &patternReader{remaining: n, next: 17} +} + +func (r *patternReader) Read(p []byte) (int, error) { + if r.remaining <= 0 { + return 0, io.EOF + } + if int64(len(p)) > r.remaining { + p = p[:r.remaining] + } + for i := range p { + p[i] = r.next + r.next += 31 + } + r.remaining -= int64(len(p)) + return len(p), nil +} diff --git a/internal/loadtest/send_load_test.go b/internal/loadtest/send_load_test.go index 6d836b1f..d909fa40 100644 --- a/internal/loadtest/send_load_test.go +++ b/internal/loadtest/send_load_test.go @@ -87,14 +87,13 @@ func TestMessageSendBaseline(t *testing.T) { } t.Cleanup(func() { _ = rdb.Close() }) - // 装配与 main.go 一致的消息热路径:Redis 分配器 + PG 消息存储 + transactional outbox。 + // 装配与 main.go 一致的消息热路径:Redis box_id 分配器 + PG 消息存储 + transactional outbox。 userStore := postgres.NewUserStore(pool) updateEventStore := postgres.NewUpdateEventStore(pool) dispatchOutboxStore := postgres.NewDispatchOutboxStore(pool, postgres.WithLeaseTimeout(leaseTimeout)) dialogStore := postgres.NewDialogStore(pool) - ptsAllocator := redisstore.NewPtsAllocator(rdb, updateEventStore) boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool)) - messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator)) + messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator)) svc := messageapp.NewService(messageStore, dialogStore) // 创建独立的测试用户池;用随机 salt 隔离历史残留,结束按 FK 依赖序清理。 @@ -359,7 +358,7 @@ func seedUsers(t *testing.T, ctx context.Context, store *postgres.UserStore, n i } // cleanup 按 FK 依赖序删除测试数据:outbox→events→boxes→private_messages→dialogs→users, -// 再清 Redis pts/box_id 计数。message_boxes.from_user_id 为 ON DELETE RESTRICT,必须先删盒子。 +// 再清 Redis box_id 计数。message_boxes.from_user_id 为 ON DELETE RESTRICT,必须先删盒子。 // cleanup 在断言之后运行,出错只告警不影响已得结果。 func cleanup(t *testing.T, pool *pgxpool.Pool, rdb *redis.Client, ids []int64) { t.Helper() @@ -377,12 +376,9 @@ func cleanup(t *testing.T, pool *pgxpool.Pool, rdb *redis.Client, ids []int64) { t.Logf("cleanup %q: %v", sql, err) } } - keys := make([]string, 0, len(ids)*2) + keys := make([]string, 0, len(ids)) for _, id := range ids { - keys = append(keys, - fmt.Sprintf("counter:pts:{%d}", id), - fmt.Sprintf("counter:box_id:{%d}", id), - ) + keys = append(keys, fmt.Sprintf("counter:box_id:{%d}", id)) } if err := rdb.Del(ctx, keys...).Err(); err != nil { t.Logf("cleanup redis counters: %v", err) diff --git a/internal/mtprotoedge/bot_callback_e2e_test.go b/internal/mtprotoedge/bot_callback_e2e_test.go new file mode 100644 index 00000000..18a63282 --- /dev/null +++ b/internal/mtprotoedge/bot_callback_e2e_test.go @@ -0,0 +1,428 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "fmt" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/log/logzap" + "github.com/gotd/td/clock" + "github.com/gotd/td/exchange" + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/dcs" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" + + "telesrv/internal/app/account" + "telesrv/internal/app/auth" + botsapp "telesrv/internal/app/bots" + "telesrv/internal/app/contacts" + "telesrv/internal/app/dialogs" + "telesrv/internal/app/help" + "telesrv/internal/app/langpack" + messageapp "telesrv/internal/app/messages" + "telesrv/internal/app/updates" + "telesrv/internal/app/users" + "telesrv/internal/rpc" + "telesrv/internal/store/memory" +) + +// botCallbackEnv 搭建一套内存 server,供 P3 callback / startBot / markup e2e 复用。 +type botCallbackEnv struct { + rsaKey *rsa.PrivateKey + addr *net.TCPAddr + bots *botsapp.Service + newCli func(*session.StorageMemory, telegram.UpdateHandler) *telegram.Client + newCliH func(*session.StorageMemory) *telegram.Client +} + +func newBotCallbackEnv(t *testing.T, ctx context.Context) *botCallbackEnv { + t.Helper() + const dc = 2 + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tcpAddr := ln.Addr().(*net.TCPAddr) + + userStore := memory.NewUserStore() + authzStore := memory.NewAuthorizationStore() + authKeyStore := memory.NewAuthKeyStore() + helpStore := memory.NewHelpStore() + langPackStore := memory.NewLangPackStore() + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + botStore := memory.NewBotStore(userStore) + botsService := botsapp.NewService(userStore, botStore, messageStore, + botsapp.WithLogger(zaptest.NewLogger(t).Named("bots"))) + activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions")) + deps := rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, + memory.NewTempAuthKeyBindingStore(), "12345", auth.WithBotLogin(botStore)), + Account: account.NewService(memory.NewPasswordStore()), + Help: help.NewService(helpStore, helpStore), + Users: users.NewService(userStore), + Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + Contacts: contacts.NewService(memory.NewContactStore()), + Dialogs: dialogs.NewService(dialogStore), + Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithBotResponder(botsService)), + Bots: botsService, + LangPack: langpack.NewService(langPackStore), + Sessions: activeSessions, + } + router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) + botsService.SetRouterHooks(router) + srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router, ActiveSessions: activeSessions}) + go func() { _ = srv.Serve(ctx, ln) }() + + newCli := func(storage *session.StorageMemory, handler telegram.UpdateHandler) *telegram.Client { + if handler == nil { + handler = telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }) + } + return telegram.NewClient(1, "hash", telegram.Options{ + PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, + Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), + DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, + Logger: logzap.New(zaptest.NewLogger(t).Named("client")), + SessionStorage: storage, + UpdateHandler: handler, + }) + } + return &botCallbackEnv{ + rsaKey: rsaKey, addr: tcpAddr, bots: botsService, + newCli: newCli, + newCliH: func(s *session.StorageMemory) *telegram.Client { return newCli(s, nil) }, + } +} + +func registerOwner(t *testing.T, ctx context.Context, env *botCallbackEnv, phone, name string) (tg.User, *session.StorageMemory) { + t.Helper() + var owner tg.User + storage := &session.StorageMemory{} + client := env.newCliH(storage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}}) + if err != nil { + return err + } + hash := sent.(*tg.AuthSentCode).PhoneCodeHash + if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: phone, PhoneCodeHash: hash, PhoneCode: "12345"}); err != nil { + return err + } + res, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{PhoneNumber: phone, PhoneCodeHash: hash, FirstName: name}) + if err != nil { + return err + } + owner = *res.(*tg.AuthAuthorization).User.(*tg.User) + return nil + }); err != nil { + t.Fatalf("owner signUp: %v", err) + } + return owner, storage +} + +func historyMessageList(history tg.MessagesMessagesClass) []tg.MessageClass { + switch v := history.(type) { + case *tg.MessagesMessages: + return v.Messages + case *tg.MessagesMessagesSlice: + return v.Messages + case *tg.MessagesChannelMessages: + return v.Messages + default: + return nil + } +} + +func updatesFromClass(u tg.UpdatesClass) []tg.UpdateClass { + switch v := u.(type) { + case *tg.Updates: + return v.Updates + case *tg.UpdatesCombined: + return v.Updates + case *tg.UpdateShort: + return []tg.UpdateClass{v.Update} + default: + return nil + } +} + +// TestBotInlineKeyboardCallbackFlow 验证 P3 callback 全链路(真实 gotd 双客户端并发): +// bot 发带 inline callback markup 的消息 → owner getHistory 见 markup → owner +// getBotCallbackAnswer 挂起 → bot 经 updateBotCallbackQuery 收到 query → setBotCallbackAnswer +// → owner 收到 answer;并验证 bot 不应答 → BOT_RESPONSE_TIMEOUT;data 字节级保真。 +func TestBotInlineKeyboardCallbackFlow(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + env := newBotCallbackEnv(t, ctx) + owner, ownerStorage := registerOwner(t, ctx, env, "+15550004001", "Owner") + botUser, botToken, err := env.bots.CreateBot(context.Background(), owner.ID, "CB Bot", "cb_test_bot") + if err != nil { + t.Fatalf("create bot: %v", err) + } + + // 含 0x00 与高位字节的 callback data:验证字节级 round-trip(I2)。 + callbackData := []byte{0x00, 0x01, 0xFF, 0x80, 'a', 'b'} + const noAnswerText = "ignore" + + botOnline := make(chan struct{}) + botStop := make(chan struct{}) + botErr := make(chan error, 1) + + // bot 在后台保持在线,update handler 把收到的 callback query 转交应答 goroutine。 + cbCh := make(chan *tg.UpdateBotCallbackQuery, 8) + botHandler := telegram.UpdateHandlerFunc(func(_ context.Context, u tg.UpdatesClass) error { + for _, upd := range updatesFromClass(u) { + if q, ok := upd.(*tg.UpdateBotCallbackQuery); ok { + select { + case cbCh <- q: + default: + } + } + } + return nil + }) + go func() { + botClient := env.newCli(&session.StorageMemory{}, botHandler) + botErr <- botClient.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(botClient) + if _, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{APIID: 1, APIHash: "hash", BotAuthToken: botToken}); err != nil { + return err + } + // 裸 RPC 置 receivesUpdates,使 push 可达(热恢复同步修复后语义)。 + if _, err := raw.UpdatesGetState(ctx); err != nil { + return err + } + // 解析 owner 资料拿到 bot 视角的 access_hash(access_hash=0 跳过校验, + // 与既有 bot e2e 同款),再发带 inline callback+url keyboard 的消息。 + got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{UserID: owner.ID}}) + if err != nil { + return err + } + if len(got) != 1 { + // 该回调由 go func() 启动并经 botErr channel 上报;在此 goroutine 直接 + // t.Fatalf 会在错误的 goroutine 上 Goexit(go1.26 vet testinggoroutine 亦报), + // 故返回 error 让测试主 goroutine 经 botErr 失败。 + return fmt.Errorf("bot getUsers(owner) = %d, want 1", len(got)) + } + ownerSeen := got[0].(*tg.User) + markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{ + &tg.KeyboardButtonCallback{Text: "Press", Data: callbackData}, + &tg.KeyboardButtonURL{Text: "Site", URL: "https://example.com/x"}, + }}}} + req := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: ownerSeen.ID, AccessHash: ownerSeen.AccessHash}, + Message: "tap a button", + RandomID: 778801, + } + req.SetReplyMarkup(markup) + if _, err := raw.MessagesSendMessage(ctx, req); err != nil { + return err + } + // 应答 goroutine:除 noAnswerText 外,对每个 query 回 setBotCallbackAnswer。 + go func() { + for { + select { + case q := <-cbCh: + data, _ := q.GetData() + if string(data) == noAnswerText { + continue // 制造超时分支 + } + _, _ = raw.MessagesSetBotCallbackAnswer(ctx, &tg.MessagesSetBotCallbackAnswerRequest{ + QueryID: q.QueryID, + Alert: true, + Message: "ok:" + string(data), + }) + case <-botStop: + return + } + } + }() + close(botOnline) + select { + case <-botStop: + case <-ctx.Done(): + } + return nil + }) + }() + + select { + case <-botOnline: + case err := <-botErr: + t.Fatalf("bot client exited early: %v", err) + case <-ctx.Done(): + t.Fatal("bot did not come online") + } + + ownerClient := env.newCliH(ownerStorage) + if err := ownerClient.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(ownerClient) + // owner 解析 bot 拿到自己视角的 access_hash(公开 username 冷启动解析)。 + resolved, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: "cb_test_bot"}) + if err != nil { + return err + } + seenBot := resolved.Users[0].(*tg.User) + botPeer := &tg.InputPeerUser{UserID: seenBot.ID, AccessHash: seenBot.AccessHash} + _ = botUser + + // 轮询 getHistory 直到 bot 的 markup 消息到达。 + var msgID int + deadline := time.Now().Add(15 * time.Second) + for { + h, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: botPeer, Limit: 5}) + if err != nil { + return err + } + for _, m := range historyMessageList(h) { + msg, ok := m.(*tg.Message) + if !ok { + continue + } + rm, ok := msg.GetReplyMarkup() + if !ok { + continue + } + inline, ok := rm.(*tg.ReplyInlineMarkup) + if !ok || len(inline.Rows) != 1 || len(inline.Rows[0].Buttons) != 2 { + t.Fatalf("unexpected markup shape: %#v", rm) + } + cbBtn, ok := inline.Rows[0].Buttons[0].(*tg.KeyboardButtonCallback) + if !ok { + t.Fatalf("first button not callback: %#v", inline.Rows[0].Buttons[0]) + } + if string(cbBtn.Data) != string(callbackData) { + t.Fatalf("callback data round-trip mismatch: got %v want %v", cbBtn.Data, callbackData) + } + if _, ok := inline.Rows[0].Buttons[1].(*tg.KeyboardButtonURL); !ok { + t.Fatalf("second button not url: %#v", inline.Rows[0].Buttons[1]) + } + msgID = msg.ID + } + if msgID != 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("markup message did not arrive in owner history") + } + time.Sleep(50 * time.Millisecond) + } + + // 按下 callback 按钮:getBotCallbackAnswer 挂起直到 bot 应答。 + ans, err := raw.MessagesGetBotCallbackAnswer(ctx, &tg.MessagesGetBotCallbackAnswerRequest{ + Peer: botPeer, + MsgID: msgID, + Data: callbackData, + }) + if err != nil { + return err + } + if !ans.Alert || ans.Message != "ok:"+string(callbackData) { + t.Fatalf("callback answer = alert:%v msg:%q, want alert:true msg:%q", ans.Alert, ans.Message, "ok:"+string(callbackData)) + } + + // 超时分支:bot 收到但不应答(noAnswerText)→ BOT_RESPONSE_TIMEOUT。 + // 用短 ctx 触发客户端侧超时,避免等满 25s 服务端窗口。 + toCtx, toCancel := context.WithTimeout(ctx, 3*time.Second) + defer toCancel() + _, err = raw.MessagesGetBotCallbackAnswer(toCtx, &tg.MessagesGetBotCallbackAnswerRequest{ + Peer: botPeer, + MsgID: msgID, + Data: []byte(noAnswerText), + }) + if err == nil { + t.Fatal("expected timeout error for unanswered callback") + } + return nil + }); err != nil { + t.Fatalf("owner callback flow: %v", err) + } + + close(botStop) + select { + case <-botErr: + case <-time.After(5 * time.Second): + } +} + +// TestBotStartBotFlow 验证 messages.startBot 产生可见 "/start " 消息(I7)。 +func TestBotStartBotFlow(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + env := newBotCallbackEnv(t, ctx) + owner, ownerStorage := registerOwner(t, ctx, env, "+15550004002", "Owner") + _, botToken, err := env.bots.CreateBot(context.Background(), owner.ID, "Start Bot", "start_test_bot") + if err != nil { + t.Fatalf("create bot: %v", err) + } + + // owner 调 startBot(bot, payload)。先经公开 username 解析 bot 拿 access_hash。 + ownerClient := env.newCliH(ownerStorage) + if err := ownerClient.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(ownerClient) + resolved, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: "start_test_bot"}) + if err != nil { + return err + } + seenBot := resolved.Users[0].(*tg.User) + _, err = raw.MessagesStartBot(ctx, &tg.MessagesStartBotRequest{ + Bot: &tg.InputUser{UserID: seenBot.ID, AccessHash: seenBot.AccessHash}, + Peer: &tg.InputPeerUser{UserID: seenBot.ID, AccessHash: seenBot.AccessHash}, + RandomID: 994401, + StartParam: "ref123", + }) + return err + }); err != nil { + t.Fatalf("owner startBot: %v", err) + } + + // bot 登录后 getHistory(peer=owner) 应见到 "/start ref123"。 + botClient := env.newCliH(&session.StorageMemory{}) + if err := botClient.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(botClient) + if _, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{APIID: 1, APIHash: "hash", BotAuthToken: botToken}); err != nil { + return err + } + got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{UserID: owner.ID}}) + if err != nil { + return err + } + ownerSeen := got[0].(*tg.User) + h, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: ownerSeen.ID, AccessHash: ownerSeen.AccessHash}, + Limit: 5, + }) + if err != nil { + return err + } + found := false + var bodies []string + for _, m := range historyMessageList(h) { + if msg, ok := m.(*tg.Message); ok { + bodies = append(bodies, msg.Message) + if msg.Message == "/start ref123" { + found = true + } + } + } + if !found { + t.Fatalf("bot did not receive '/start ref123' message; history bodies=%v", bodies) + } + return nil + }); err != nil { + t.Fatalf("bot startBot receive: %v", err) + } +} diff --git a/internal/mtprotoedge/bot_e2e_test.go b/internal/mtprotoedge/bot_e2e_test.go new file mode 100644 index 00000000..99fb5939 --- /dev/null +++ b/internal/mtprotoedge/bot_e2e_test.go @@ -0,0 +1,835 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "net" + "regexp" + "strings" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/log/logzap" + "github.com/gotd/td/clock" + "github.com/gotd/td/exchange" + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/dcs" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "github.com/gotd/td/transport" + + "telesrv/internal/app/account" + "telesrv/internal/app/auth" + botsapp "telesrv/internal/app/bots" + "telesrv/internal/app/contacts" + "telesrv/internal/app/dialogs" + "telesrv/internal/app/help" + "telesrv/internal/app/langpack" + messageapp "telesrv/internal/app/messages" + "telesrv/internal/app/updates" + "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/rpc" + "telesrv/internal/store/memory" +) + +var botTokenRe = regexp.MustCompile(`(\d+):([A-Za-z0-9_-]{35})`) + +// TestBotFatherCreateAndBotLoginFlow 是 bot 主链路端到端验证: +// TestBotManagementRPCFlow 验证 P2 bots.* 管理 RPC 端到端: +// bot 自己调 setBotCommands/setBotInfo/setBotMenuButton 后 getFullUser 反映新值且 +// bot_info_version 单调递增;owner 视角 getFullUser(bot) 带 bot_can_edit 且 owner 可 +// 经 bot:InputUser 代改 bot name。 +func TestBotManagementRPCFlow(t *testing.T) { + const ( + dc = 2 + code = "12345" + ) + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tcpAddr := ln.Addr().(*net.TCPAddr) + + userStore := memory.NewUserStore() + authzStore := memory.NewAuthorizationStore() + authKeyStore := memory.NewAuthKeyStore() + helpStore := memory.NewHelpStore() + langPackStore := memory.NewLangPackStore() + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + botStore := memory.NewBotStore(userStore) + botsService := botsapp.NewService(userStore, botStore, messageStore, + botsapp.WithLogger(zaptest.NewLogger(t).Named("bots"))) + activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions")) + deps := rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, + memory.NewTempAuthKeyBindingStore(), code, auth.WithBotLogin(botStore)), + Account: account.NewService(memory.NewPasswordStore()), + Help: help.NewService(helpStore, helpStore), + Users: users.NewService(userStore), + Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + Contacts: contacts.NewService(memory.NewContactStore()), + Dialogs: dialogs.NewService(dialogStore), + Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithBotResponder(botsService)), + Bots: botsService, + LangPack: langpack.NewService(langPackStore), + Sessions: activeSessions, + } + router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) + botsService.SetRouterHooks(router) + srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router, ActiveSessions: activeSessions}) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + newClient := func(storage *session.StorageMemory) *telegram.Client { + opts := telegram.Options{ + PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, + Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), + DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, + Logger: logzap.New(zaptest.NewLogger(t).Named("client")), + SessionStorage: storage, + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + } + return telegram.NewClient(1, "hash", opts) + } + + // owner 注册。 + ownerStorage := &session.StorageMemory{} + var owner tg.User + { + client := newClient(ownerStorage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: "+15550003001", APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}}) + if err != nil { + return err + } + hash := sent.(*tg.AuthSentCode).PhoneCodeHash + if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: "+15550003001", PhoneCodeHash: hash, PhoneCode: code}); err != nil { + return err + } + res, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{PhoneNumber: "+15550003001", PhoneCodeHash: hash, FirstName: "Owner"}) + if err != nil { + return err + } + owner = *res.(*tg.AuthAuthorization).User.(*tg.User) + return nil + }); err != nil { + t.Fatalf("owner signUp: %v", err) + } + } + + // 直接经 service 建 bot(绕过 BotFather 对话,聚焦管理 RPC)。 + botUser, botToken, err := botsService.CreateBot(context.Background(), owner.ID, "Mgmt Bot", "mgmt_test_bot") + if err != nil { + t.Fatalf("create bot: %v", err) + } + + getFullSelfBotInfo := func(raw *tg.Client) (tg.BotInfo, *tg.User) { + t.Helper() + full, err := raw.UsersGetFullUser(ctx, &tg.InputUserSelf{}) + if err != nil { + t.Fatalf("getFullUser self: %v", err) + } + bi, ok := full.FullUser.GetBotInfo() + if !ok { + t.Fatalf("self userFull lacks bot_info") + } + return bi, full.Users[0].(*tg.User) + } + + // bot 登录并调管理 RPC。 + botStorage := &session.StorageMemory{} + var verAfterCommands int + { + client := newClient(botStorage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + if _, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{APIID: 1, APIHash: "hash", BotAuthToken: botToken}); err != nil { + return err + } + _, self0 := getFullSelfBotInfo(raw) + v0, _ := self0.GetBotInfoVersion() + + // setBotCommands(default scope)。 + ok, err := raw.BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{ + Scope: &tg.BotCommandScopeDefault{}, + LangCode: "", + Commands: []tg.BotCommand{{Command: "start", Description: "begin"}, {Command: "help", Description: "show help"}}, + }) + if err != nil || !ok { + t.Fatalf("setBotCommands = %v,%v", ok, err) + } + bi, self1 := getFullSelfBotInfo(raw) + cmds, _ := bi.GetCommands() + if len(cmds) != 2 || cmds[0].Command != "start" { + t.Fatalf("bot_info commands = %+v, want [start,help]", cmds) + } + v1, _ := self1.GetBotInfoVersion() + if v1 <= v0 { + t.Fatalf("bot_info_version not bumped after setBotCommands: %d -> %d", v0, v1) + } + verAfterCommands = v1 + + // getBotCommands 回读。 + got, err := raw.BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{Scope: &tg.BotCommandScopeDefault{}}) + if err != nil || len(got) != 2 { + t.Fatalf("getBotCommands = %+v, %v", got, err) + } + + // setBotMenuButton(webview)。 + if ok, err := raw.BotsSetBotMenuButton(ctx, &tg.BotsSetBotMenuButtonRequest{ + UserID: &tg.InputUserSelf{}, + Button: &tg.BotMenuButton{Text: "Open", URL: "https://example.com/app"}, + }); err != nil || !ok { + t.Fatalf("setBotMenuButton = %v,%v", ok, err) + } + bi2, _ := getFullSelfBotInfo(raw) + mb, _ := bi2.GetMenuButton() + if btn, ok := mb.(*tg.BotMenuButton); !ok || btn.URL != "https://example.com/app" { + t.Fatalf("menu button = %#v, want webview", mb) + } + + // setBotInfo(description) by bot self(不带 bot 参数);Description 是 flag.1,须 SetDescription 置位。 + infoReq := &tg.BotsSetBotInfoRequest{LangCode: ""} + infoReq.SetDescription("what I do") + if ok, err := raw.BotsSetBotInfo(ctx, infoReq); err != nil || !ok { + t.Fatalf("setBotInfo(description) = %v,%v", ok, err) + } + info, err := raw.BotsGetBotInfo(ctx, &tg.BotsGetBotInfoRequest{LangCode: ""}) + if err != nil || info.Description != "what I do" { + t.Fatalf("getBotInfo = %#v, %v", info, err) + } + + // bot self 不应带 bot_can_edit(owner 视角才有)。 + _, selfU := getFullSelfBotInfo(raw) + if selfU.GetBotCanEdit() { + t.Fatalf("bot self carries bot_can_edit, want only owner view") + } + return nil + }); err != nil { + t.Fatalf("bot management flow: %v", err) + } + } + + // owner 视角:getFullUser(bot) 带 bot_can_edit + commands;owner 代改 name。 + { + client := newClient(ownerStorage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + resolved, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: "mgmt_test_bot"}) + if err != nil { + return err + } + seen := resolved.Users[0].(*tg.User) + botInput := &tg.InputUser{UserID: seen.ID, AccessHash: seen.AccessHash} + full, err := raw.UsersGetFullUser(ctx, botInput) + if err != nil { + return err + } + ownerSeesBot := full.Users[0].(*tg.User) + if !ownerSeesBot.GetBotCanEdit() { + t.Fatalf("owner view bot_can_edit = false, want true") + } + if bi, ok := full.FullUser.GetBotInfo(); ok { + if cmds, _ := bi.GetCommands(); len(cmds) != 2 { + t.Fatalf("owner view bot commands = %d, want 2", len(cmds)) + } + } + // owner 代改 bot name(带 bot:InputUser)。 + req := &tg.BotsSetBotInfoRequest{LangCode: ""} + req.SetBot(botInput) + req.SetName("Owner Renamed") + if ok, err := raw.BotsSetBotInfo(ctx, req); err != nil || !ok { + t.Fatalf("owner setBotInfo(name) = %v,%v", ok, err) + } + got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{botInput}) + if err != nil || len(got) != 1 { + t.Fatalf("getUsers(bot) = %+v, %v", got, err) + } + if u := got[0].(*tg.User); u.FirstName != "Owner Renamed" { + t.Fatalf("bot first_name = %q, want 'Owner Renamed'", u.FirstName) + } + return nil + }); err != nil { + t.Fatalf("owner management flow: %v", err) + } + } + + _ = botUser + _ = verAfterCommands + cancel() + if err := <-serveErr; err != nil { + t.Errorf("serve: %v", err) + } +} + +// 用户注册 → resolveUsername(BotFather) → /newbot 对话拿 token → +// 外部客户端 auth.importBotAuthorization 登录为 bot → +// 用户与 bot 互发消息 → 错误 token 拿 ACCESS_TOKEN_INVALID。 +func TestBotFatherCreateAndBotLoginFlow(t *testing.T) { + const ( + dc = 2 + code = "12345" + ) + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tcpAddr := ln.Addr().(*net.TCPAddr) + + userStore := memory.NewUserStore() + authzStore := memory.NewAuthorizationStore() + authKeyStore := memory.NewAuthKeyStore() + helpStore := memory.NewHelpStore() + langPackStore := memory.NewLangPackStore() + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + botStore := memory.NewBotStore(userStore) + botsService := botsapp.NewService(userStore, botStore, messageStore, + botsapp.WithLogger(zaptest.NewLogger(t).Named("bots"))) + activeSessions := NewSessionManager(zaptest.NewLogger(t).Named("sessions")) + deps := rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, + memory.NewTempAuthKeyBindingStore(), code, auth.WithBotLogin(botStore)), + Account: account.NewService(memory.NewPasswordStore()), + Help: help.NewService(helpStore, helpStore), + Users: users.NewService(userStore), + Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + Contacts: contacts.NewService(memory.NewContactStore()), + Dialogs: dialogs.NewService(dialogStore), + Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithBotResponder(botsService)), + Bots: botsService, + LangPack: langpack.NewService(langPackStore), + Sessions: activeSessions, + } + router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) + botsService.SetRouterHooks(router) + srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router, ActiveSessions: activeSessions}) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + newClient := func(storage *session.StorageMemory) *telegram.Client { + opts := telegram.Options{ + PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, + Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), + DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, + Logger: logzap.New(zaptest.NewLogger(t).Named("client")), + SessionStorage: storage, + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + } + return telegram.NewClient(1, "hash", opts) + } + + messagesOf := func(history tg.MessagesMessagesClass) []tg.MessageClass { + t.Helper() + switch v := history.(type) { + case *tg.MessagesMessages: + return v.Messages + case *tg.MessagesMessagesSlice: + return v.Messages + default: + t.Fatalf("history = %T %+v, want messages", history, history) + return nil + } + } + + // 1) owner 注册。 + ownerStorage := &session.StorageMemory{} + var owner tg.User + { + client := newClient(ownerStorage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{ + PhoneNumber: "+15550002001", APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}, + }) + if err != nil { + return err + } + hash := sent.(*tg.AuthSentCode).PhoneCodeHash + if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{ + PhoneNumber: "+15550002001", PhoneCodeHash: hash, PhoneCode: code, + }); err != nil { + return err + } + res, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{ + PhoneNumber: "+15550002001", PhoneCodeHash: hash, FirstName: "Owner", + }) + if err != nil { + return err + } + owner = *res.(*tg.AuthAuthorization).User.(*tg.User) + return nil + }); err != nil { + t.Fatalf("owner signUp: %v", err) + } + } + + // 2) owner 与 BotFather 对话创建 bot,拿 token。 + var ( + botToken string + botUsername = "owner_e2e_bot" + botUserID int64 + ) + { + client := newClient(ownerStorage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + resolved, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: "BotFather"}) + if err != nil { + return err + } + if len(resolved.Users) != 1 { + t.Fatalf("resolve BotFather users = %d, want 1", len(resolved.Users)) + } + botFather := resolved.Users[0].(*tg.User) + if botFather.ID != domain.BotFatherUserID || !botFather.Bot { + t.Fatalf("resolved BotFather = %+v, want bot flag with id %d", botFather, domain.BotFatherUserID) + } + if v, ok := botFather.GetBotInfoVersion(); !ok || v < 1 { + t.Fatalf("BotFather bot_info_version = %d,%v, want >=1", v, ok) + } + if _, hasStatus := botFather.GetStatus(); hasStatus { + t.Fatalf("BotFather carries status %+v, want none for bots", botFather.Status) + } + peer := &tg.InputPeerUser{UserID: botFather.ID, AccessHash: botFather.AccessHash} + + // userFull.bot_info 必须存在且 user_id 匹配(TDesktop P0 兼容点)。 + full, err := raw.UsersGetFullUser(ctx, &tg.InputUser{UserID: botFather.ID, AccessHash: botFather.AccessHash}) + if err != nil { + return err + } + botInfo, ok := full.FullUser.GetBotInfo() + if !ok { + t.Fatalf("BotFather userFull lacks bot_info") + } + if id, ok := botInfo.GetUserID(); !ok || id != domain.BotFatherUserID { + t.Fatalf("bot_info.user_id = %d,%v, want %d", id, ok, domain.BotFatherUserID) + } + if cmds, ok := botInfo.GetCommands(); !ok || len(cmds) == 0 { + t.Fatalf("BotFather bot_info commands empty, want seeded commands") + } + + randomID := int64(31001) + // BotFather 回复异步到达(go routine),轮询历史顶部直到出现新的 incoming 回复。 + sendAndReply := func(text string) string { + t.Helper() + randomID++ + beforeTop := 0 + if h, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 1}); err == nil { + if m := messagesOf(h); len(m) > 0 { + if msg, ok := m[0].(*tg.Message); ok { + beforeTop = msg.ID + } + } + } + if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: peer, Message: text, RandomID: randomID, + }); err != nil { + t.Fatalf("send %q: %v", text, err) + } + deadline := time.Now().Add(10 * time.Second) + for { + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 5}) + if err != nil { + t.Fatalf("history after %q: %v", text, err) + } + msgs := messagesOf(history) + if len(msgs) > 0 { + if top, ok := msgs[0].(*tg.Message); ok && !top.Out && top.ID > beforeTop { + return top.Message + } + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for BotFather reply to %q", text) + } + time.Sleep(50 * time.Millisecond) + } + } + + if reply := sendAndReply("/newbot"); !strings.Contains(reply, "choose a name") { + t.Fatalf("/newbot reply = %q", reply) + } + if reply := sendAndReply("Owner E2E Bot"); !strings.Contains(reply, "username") { + t.Fatalf("name reply = %q", reply) + } + reply := sendAndReply(botUsername) + match := botTokenRe.FindStringSubmatch(reply) + if match == nil { + t.Fatalf("done reply = %q, want token", reply) + } + botToken = match[0] + + // 新 bot 可被 resolve,且带 bot flags。 + resolvedBot, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: botUsername}) + if err != nil { + return err + } + created := resolvedBot.Users[0].(*tg.User) + if !created.Bot { + t.Fatalf("created bot user lacks bot flag: %+v", created) + } + if v, ok := created.GetBotInfoVersion(); !ok || v < 1 { + t.Fatalf("created bot bot_info_version = %d,%v, want >=1", v, ok) + } + botUserID = created.ID + return nil + }); err != nil { + t.Fatalf("newbot flow: %v", err) + } + } + + // 3) 错误 token 必须拿 ACCESS_TOKEN_INVALID。 + { + client := newClient(&session.StorageMemory{}) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + _, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{ + APIID: 1, APIHash: "hash", BotAuthToken: "12345:notarealtokennotarealtokennotareal_", + }) + if !tgerr.Is(err, "ACCESS_TOKEN_INVALID") { + t.Fatalf("bad token err = %v, want ACCESS_TOKEN_INVALID", err) + } + return nil + }); err != nil { + t.Fatalf("bad token flow: %v", err) + } + } + + // 4) bot 客户端凭 token 登录;self 必须是 bot;userFull.bot_info 自洽。 + botStorage := &session.StorageMemory{} + { + client := newClient(botStorage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + res, err := raw.AuthImportBotAuthorization(ctx, &tg.AuthImportBotAuthorizationRequest{ + APIID: 1, APIHash: "hash", BotAuthToken: botToken, + }) + if err != nil { + return err + } + authz, ok := res.(*tg.AuthAuthorization) + if !ok { + t.Fatalf("importBotAuthorization = %T, want *tg.AuthAuthorization", res) + } + self := authz.User.(*tg.User) + if self.ID != botUserID || !self.Bot || !self.Self { + t.Fatalf("bot self = %+v, want self bot id %d", self, botUserID) + } + if v, ok := self.GetBotInfoVersion(); !ok || v < 1 { + t.Fatalf("bot self bot_info_version = %d,%v, want >=1", v, ok) + } + // 登录态生效:getUsers(self) 与 getState 正常。 + got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUserSelf{}}) + if err != nil { + return err + } + if len(got) != 1 || got[0].(*tg.User).ID != botUserID { + t.Fatalf("bot getUsers(self) = %+v, want id %d", got, botUserID) + } + if _, err := raw.UpdatesGetState(ctx); err != nil { + return err + } + full, err := raw.UsersGetFullUser(ctx, &tg.InputUserSelf{}) + if err != nil { + return err + } + botInfo, ok := full.FullUser.GetBotInfo() + if !ok { + t.Fatalf("bot self userFull lacks bot_info") + } + if id, ok := botInfo.GetUserID(); !ok || id != botUserID { + t.Fatalf("bot self bot_info.user_id = %d,%v, want %d", id, ok, botUserID) + } + return nil + }); err != nil { + t.Fatalf("bot login flow: %v", err) + } + } + + // 5) owner 给 bot 发消息。 + { + client := newClient(ownerStorage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + resolvedBot, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: botUsername}) + if err != nil { + return err + } + created := resolvedBot.Users[0].(*tg.User) + if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: created.ID, AccessHash: created.AccessHash}, + Message: "hi bot", + RandomID: 41001, + }); err != nil { + return err + } + return nil + }); err != nil { + t.Fatalf("owner -> bot send: %v", err) + } + } + + // 6) bot 读到消息并回复。 + { + client := newClient(botStorage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + // access_hash=0 跳过校验(与现有 users.getUsers 语义一致),bot 据此拿 owner 资料。 + got, err := raw.UsersGetUsers(ctx, []tg.InputUserClass{&tg.InputUser{UserID: owner.ID}}) + if err != nil { + return err + } + if len(got) != 1 { + t.Fatalf("bot getUsers(owner) = %d users, want 1", len(got)) + } + ownerSeen := got[0].(*tg.User) + peer := &tg.InputPeerUser{UserID: ownerSeen.ID, AccessHash: ownerSeen.AccessHash} + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 5}) + if err != nil { + return err + } + msgs := messagesOf(history) + if len(msgs) == 0 { + t.Fatalf("bot history empty, want incoming message") + } + top, ok := msgs[0].(*tg.Message) + if !ok || top.Message != "hi bot" || top.Out { + t.Fatalf("bot latest = %#v, want incoming 'hi bot'", msgs[0]) + } + if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: peer, + Message: "hello human", + RandomID: 51001, + }); err != nil { + return err + } + + // P2: bot 自管理(bot-only RPC):setBotCommands / setBotMenuButton。 + ok2, err := raw.BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{ + Scope: &tg.BotCommandScopeDefault{}, + LangCode: "", + Commands: []tg.BotCommand{ + {Command: "start", Description: "start the bot"}, + {Command: "ping", Description: "check liveness"}, + }, + }) + if err != nil || !ok2 { + t.Fatalf("setBotCommands = %v err=%v, want true", ok2, err) + } + cmds, err := raw.BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{ + Scope: &tg.BotCommandScopeDefault{}, LangCode: "", + }) + if err != nil || len(cmds) != 2 || cmds[0].Command != "start" { + t.Fatalf("getBotCommands = %+v err=%v, want [start ping]", cmds, err) + } + okBtn, err := raw.BotsSetBotMenuButton(ctx, &tg.BotsSetBotMenuButtonRequest{ + UserID: &tg.InputUserEmpty{}, + Button: &tg.BotMenuButton{Text: "Open", URL: "https://example.test/app"}, + }) + if err != nil || !okBtn { + t.Fatalf("setBotMenuButton = %v err=%v, want true", okBtn, err) + } + gotBtn, err := raw.BotsGetBotMenuButton(ctx, &tg.InputUserEmpty{}) + if err != nil { + return err + } + if web, ok := gotBtn.(*tg.BotMenuButton); !ok || web.URL != "https://example.test/app" { + t.Fatalf("getBotMenuButton = %#v, want webview button", gotBtn) + } + return nil + }); err != nil { + t.Fatalf("bot read/reply: %v", err) + } + } + + // 7) owner 看到 bot 回复。 + { + client := newClient(ownerStorage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + resolvedBot, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: botUsername}) + if err != nil { + return err + } + created := resolvedBot.Users[0].(*tg.User) + history, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: created.ID, AccessHash: created.AccessHash}, + Limit: 5, + }) + if err != nil { + return err + } + msgs := messagesOf(history) + if len(msgs) == 0 { + t.Fatalf("owner history with bot empty") + } + top, ok := msgs[0].(*tg.Message) + if !ok || top.Message != "hello human" || top.Out { + t.Fatalf("owner latest = %#v, want incoming 'hello human'", msgs[0]) + } + + // P2: owner 视角的 bot 元数据闭环。 + // (a) bot 设置命令+菜单按钮后 bot_info_version 已 bump(>1)。 + if v, ok := created.GetBotInfoVersion(); !ok || v <= 1 { + t.Fatalf("bot_info_version = %d,%v after metadata changes, want > 1", v, ok) + } + // (b) getFullUser:bot_info 带新命令与 webview 菜单按钮;owner 看到 bot_can_edit。 + full, err := raw.UsersGetFullUser(ctx, &tg.InputUser{UserID: created.ID, AccessHash: created.AccessHash}) + if err != nil { + return err + } + botInfo, ok := full.FullUser.GetBotInfo() + if !ok { + t.Fatal("owner getFullUser(bot) lacks bot_info") + } + if cmds, ok := botInfo.GetCommands(); !ok || len(cmds) != 2 || cmds[1].Command != "ping" { + t.Fatalf("bot_info commands = %+v,%v, want [start ping]", cmds, ok) + } + if btn, ok := botInfo.GetMenuButton(); !ok { + t.Fatal("bot_info lacks menu_button") + } else if web, isWeb := btn.(*tg.BotMenuButton); !isWeb || web.URL != "https://example.test/app" { + t.Fatalf("menu_button = %#v, want webview", btn) + } + fullUser := full.Users[0].(*tg.User) + if !fullUser.BotCanEdit { + t.Fatalf("owner getFullUser(bot) user lacks bot_can_edit: %+v", fullUser) + } + // (c) owner 经 bot 参数代设 setBotInfo(about+description)→ getFullUser 反映。 + setReq := &tg.BotsSetBotInfoRequest{LangCode: ""} + setReq.SetBot(&tg.InputUser{UserID: created.ID, AccessHash: created.AccessHash}) + setReq.SetAbout("e2e about") + setReq.SetDescription("e2e description") + if okSet, err := raw.BotsSetBotInfo(ctx, setReq); err != nil || !okSet { + t.Fatalf("owner setBotInfo = %v err=%v, want true", okSet, err) + } + full2, err := raw.UsersGetFullUser(ctx, &tg.InputUser{UserID: created.ID, AccessHash: created.AccessHash}) + if err != nil { + return err + } + if full2.FullUser.About != "e2e about" { + t.Fatalf("about after setBotInfo = %q, want 'e2e about'", full2.FullUser.About) + } + if bi2, ok := full2.FullUser.GetBotInfo(); !ok { + t.Fatal("getFullUser after setBotInfo lacks bot_info") + } else if desc, _ := bi2.GetDescription(); desc != "e2e description" { + t.Fatalf("description = %q, want 'e2e description'", desc) + } + // (d) owner 对非自己的 bot(BotFather)代设 → BOT_INVALID。 + badReq := &tg.BotsSetBotInfoRequest{LangCode: ""} + badReq.SetBot(&tg.InputUser{UserID: domain.BotFatherUserID, AccessHash: domain.BotFatherAccessHash}) + badReq.SetAbout("nope") + if _, err := raw.BotsSetBotInfo(ctx, badReq); !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("setBotInfo on BotFather err = %v, want BOT_INVALID", err) + } + // (e) 非 bot 用户调 bot-only RPC → USER_BOT_REQUIRED。 + if _, err := raw.BotsGetBotCommands(ctx, &tg.BotsGetBotCommandsRequest{ + Scope: &tg.BotCommandScopeDefault{}, LangCode: "", + }); !tgerr.Is(err, "USER_BOT_REQUIRED") { + t.Fatalf("owner getBotCommands err = %v, want USER_BOT_REQUIRED", err) + } + return nil + }); err != nil { + t.Fatalf("owner read bot reply: %v", err) + } + } + + // 8) owner 走 BotFather /revoke:旧 token 失效 + bot 全部 authorization 被撤销 + //(已登录 session 失效闭环;bot 凭旧 auth_key 重连后将得 401)。 + { + if auths, err := authzStore.ListByUser(ctx, botUserID); err != nil || len(auths) == 0 { + t.Fatalf("bot authorizations before revoke = %d err=%v, want >=1", len(auths), err) + } + client := newClient(ownerStorage) + if err := client.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(client) + resolved, err := raw.ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{Username: "BotFather"}) + if err != nil { + return err + } + bf := resolved.Users[0].(*tg.User) + peer := &tg.InputPeerUser{UserID: bf.ID, AccessHash: bf.AccessHash} + randomID := int64(61001) + // 等到 BotFather 对该条的回复出现再发下一条——回复异步处理,裸 sleep 在 + // 负载下会乱序(见 P2 审查的 sleep flaky 项)。轮询顶部 incoming 回复。 + sendAwait := func(text string) { + t.Helper() + before := 0 + if h, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 1}); err == nil { + if m := messagesOf(h); len(m) > 0 { + if msg, ok := m[0].(*tg.Message); ok { + before = msg.ID + } + } + } + randomID++ + if _, err := raw.MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: peer, Message: text, RandomID: randomID, + }); err != nil { + t.Fatalf("send %q: %v", text, err) + } + deadline := time.Now().Add(10 * time.Second) + for { + h, err := raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 3}) + if err != nil { + t.Fatalf("history after %q: %v", text, err) + } + msgs := messagesOf(h) + if len(msgs) > 0 { + if top, ok := msgs[0].(*tg.Message); ok && !top.Out && top.ID > before { + return + } + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for BotFather reply to %q", text) + } + time.Sleep(40 * time.Millisecond) + } + } + sendAwait("/revoke") + sendAwait("@" + botUsername) + // revoke 在 BotFather goroutine 内执行;轮询 authorization 清空。 + deadline := time.Now().Add(10 * time.Second) + for { + auths, err := authzStore.ListByUser(ctx, botUserID) + if err != nil { + return err + } + if len(auths) == 0 { + return nil + } + if time.Now().After(deadline) { + t.Fatalf("bot authorizations not revoked: %d rows remain", len(auths)) + } + time.Sleep(50 * time.Millisecond) + } + }); err != nil { + t.Fatalf("revoke flow: %v", err) + } + } + + cancel() + if err := <-serveErr; err != nil { + t.Errorf("serve: %v", err) + } +} diff --git a/internal/mtprotoedge/conn.go b/internal/mtprotoedge/conn.go index d1180fc9..6eced5e6 100644 --- a/internal/mtprotoedge/conn.go +++ b/internal/mtprotoedge/conn.go @@ -10,6 +10,8 @@ import ( "github.com/gotd/td/crypto" "github.com/gotd/td/proto" "github.com/gotd/td/transport" + + "telesrv/internal/compat/layerwire" ) // Conn 是一个已识别 session 的客户端连接,持有向其加密发送消息所需的全部上下文。 @@ -67,8 +69,36 @@ type Conn struct { userID atomic.Int64 userIDResolved atomic.Bool receivesUpdates atomic.Bool + // membershipsSynced 表示该连接的 channel membership 推送路由(byMemberChannel) + // 已成功建立。它与 receivesUpdates 共同构成「session 完全就绪」:membership + // 同步失败时保持 false,让置位短路放行、下一条 RPC 重试同步,避免 + // 「已置位但 channel 路由缺失」的 session 静默漏收超级群推送。 + membershipsSynced atomic.Bool + // keyDestroyed 标记本连接的 auth_key 已被 destroy_auth_key 删除。serveConn 对已建立 + // 连接复用缓存密钥跳过每帧 AuthKeyStore 回查;置位后强制回落到 Get→AuthKeyNotFound, + // 维持「destroy_auth_key 发起连接下一帧自然失效」契约。只由 destroy_auth_key 处理器置位。 + keyDestroyed atomic.Bool + // lastSessionSaveUnix 是上次把本连接 session 持久化到 SessionStore 的 unix 秒,用于把 + // 每帧 Save 去抖到固定间隔——session 持久化是软状态(生产无热读路径,仅观测/未来用)。 + // 只由单连接的读循环 goroutine 访问。 + lastSessionSaveUnix atomic.Int64 + // clientLayer 是本连接协商的 TL layer(invokeWithLayer/initConnection),由 handleRPC + // 在每次 Dispatch 后从 RPC 注册表刷新。出站(rpc_result/push)按此把 227 对象降级给老客户端; + // 0 表示尚未协商,按 canonical(227) 处理=不降级。 + clientLayer atomic.Int32 } +// ClientLayer 返回连接协商的 TL layer;未协商时返回 canonical layer(227,不降级)。 +func (c *Conn) ClientLayer() int { + if l := c.clientLayer.Load(); l != 0 { + return int(l) + } + return layerwire.CanonicalLayer +} + +// SetClientLayer 记录连接协商的 TL layer。 +func (c *Conn) SetClientLayer(layer int) { c.clientLayer.Store(int32(layer)) } + // AuthKeyID 返回连接的 auth_key_id。 func (c *Conn) AuthKeyID() [8]byte { return c.authKeyID } diff --git a/internal/mtprotoedge/conn_mgmt_fixes_test.go b/internal/mtprotoedge/conn_mgmt_fixes_test.go new file mode 100644 index 00000000..fc545fd8 --- /dev/null +++ b/internal/mtprotoedge/conn_mgmt_fixes_test.go @@ -0,0 +1,72 @@ +package mtprotoedge + +import ( + "context" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +// TestPushSkipsConnReboundToOtherUser 锁定跨账号投递窗口的修复:pushToUserWithSender 在锁外 +// 发送前复查 c.userID。模拟「收集 conns 后、send 前」连接被并发换绑(atomic userID 改了但 +// byUser 索引尚未更新)的窗口,验证本属于 userA 的 update 不会投递到已易主为 userB 的连接。 +func TestPushSkipsConnReboundToOtherUser(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + const userA, userB = int64(100), int64(200) + mk := func(sid int64, authKey byte) *Conn { + c := &Conn{ + sessionID: sid, + authKeyID: [8]byte{authKey}, + outbound: make(chan outboundOp, 4), + outboundControl: make(chan outboundOp, 4), + outboundStop: make(chan struct{}), + } + c.userID.Store(userA) + c.userIDResolved.Store(true) + c.receivesUpdates.Store(true) + sm.Register(c) + return c + } + stale := mk(1, 1) // 注册为 userA 后被换绑到 userB + live := mk(2, 2) // 始终 userA + + // 直接改 atomic userID、不动 byUser 索引:复现锁释放后到 send 前的换绑窗口。 + stale.userID.Store(userB) + + // 走 best-effort 推送:与普通推送共用 pushToUserWithSender(含 userID 复查),但只入队 + // 不等 op.done,故无需起 outbound actor 即可观察「投递 vs 跳过」(op 进 buffered c.outbound)。 + if _, err := sm.PushToUserExceptSessionBestEffort(context.Background(), userA, 0, proto.MessageFromServer, &tg.UpdatesTooLong{}, time.Second); err != nil { + t.Fatalf("push: %v", err) + } + if n := len(stale.outbound); n != 0 { + t.Fatalf("rebound conn received %d ops, want 0 (must skip cross-account delivery)", n) + } + if n := len(live.outbound); n != 1 { + t.Fatalf("live conn received %d ops, want 1", n) + } +} + +// TestRegisterEvictsAtSessionCap 锁定单 auth_key 的 session 数上限:超出 maxSessionsPerAuthKey +// 的新 session 注册会驱逐一个现有 session,防对抗客户端用海量 session_id 撑爆索引。 +func TestRegisterEvictsAtSessionCap(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + authKey := [8]byte{7} + for i := 1; i <= maxSessionsPerAuthKey+5; i++ { + // 裸 Conn(无 outbound/rpc 通道):被驱逐时 Close() 为安全 no-op。 + sm.Register(&Conn{sessionID: int64(i), authKeyID: authKey}) + } + sm.mu.RLock() + perKey := len(sm.byAuthKey[authKey]) + total := len(sm.bySession) + sm.mu.RUnlock() + if perKey != maxSessionsPerAuthKey { + t.Fatalf("sessions for auth key = %d, want cap %d", perKey, maxSessionsPerAuthKey) + } + if total != maxSessionsPerAuthKey { + t.Fatalf("total online = %d, want %d", total, maxSessionsPerAuthKey) + } +} diff --git a/internal/mtprotoedge/destroy_auth_key.go b/internal/mtprotoedge/destroy_auth_key.go index 98545ca2..9862927a 100644 --- a/internal/mtprotoedge/destroy_auth_key.go +++ b/internal/mtprotoedge/destroy_auth_key.go @@ -9,6 +9,7 @@ import ( const ( destroyAuthKeyRequestTypeID = 0xd1435160 destroyAuthKeyOkTypeID = 0xf660e1d4 + destroyAuthKeyFailTypeID = 0xea109b13 ) type destroyAuthKeyRequest struct{} @@ -31,3 +32,10 @@ func (*destroyAuthKeyOk) Encode(b *bin.Buffer) error { b.PutID(destroyAuthKeyOkTypeID) return nil } + +type destroyAuthKeyFail struct{} + +func (*destroyAuthKeyFail) Encode(b *bin.Buffer) error { + b.PutID(destroyAuthKeyFailTypeID) + return nil +} diff --git a/internal/mtprotoedge/e2e_test.go b/internal/mtprotoedge/e2e_test.go index 0d572dce..56062e98 100644 --- a/internal/mtprotoedge/e2e_test.go +++ b/internal/mtprotoedge/e2e_test.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap/zaptest" + "github.com/gotd/log/logzap" "github.com/gotd/td/clock" "github.com/gotd/td/exchange" "github.com/gotd/td/session" @@ -61,7 +62,7 @@ func TestTelegramClientEndToEnd(t *testing.T) { PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, - Logger: zaptest.NewLogger(t).Named("client"), + Logger: logzap.New(zaptest.NewLogger(t).Named("client")), SessionStorage: &session.StorageMemory{}, UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), } @@ -75,8 +76,9 @@ func TestTelegramClientEndToEnd(t *testing.T) { if cfg.ThisDC != dc { t.Errorf("config.ThisDC = %d, want %d", cfg.ThisDC, dc) } - if len(cfg.DCOptions) == 0 { - t.Error("config.DCOptions is empty") + // 不下发 DCOptions:客户端使用自己的 DCList / 写死 static 地址。 + if len(cfg.DCOptions) != 0 { + t.Errorf("config.DCOptions = %+v, want empty", cfg.DCOptions) } return nil }); err != nil { diff --git a/internal/mtprotoedge/encrypted.go b/internal/mtprotoedge/encrypted.go index c3f24ff9..8efd4236 100644 --- a/internal/mtprotoedge/encrypted.go +++ b/internal/mtprotoedge/encrypted.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "errors" "fmt" + "io" "math" "time" @@ -19,6 +20,8 @@ import ( "github.com/gotd/td/tgerr" "github.com/gotd/td/transport" + "telesrv/internal/compat/layerwire" + "telesrv/internal/observability/dbtrace" "telesrv/internal/store" ) @@ -69,22 +72,34 @@ const ( // handleEncrypted 解密加密消息,按需注册连接,处理服务消息并分发明文 payload。 // 返回(可能新建/更新的)当前连接对象,供 serveConn 维护生命周期。 -func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, keyData store.AuthKeyData, b *bin.Buffer) (*Conn, error) { - key := crypto.AuthKey{Value: crypto.Key(keyData.Value), ID: keyData.ID} +// fetchedKey 非 nil 表示本帧的 auth key 是刚从 AuthKeyStore 查出的(首帧/换 auth key/被销毁 +// 后回落);为 nil 表示走快路径——serveConn 判定 current 仍持同一未销毁的 auth key,直接复用 +// current.key/current.salt 解密,既不回查 AuthKeyStore 也不重建 store.AuthKeyData。 +func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *connState, current *Conn, fetchedKey *store.AuthKeyData, b *bin.Buffer) (*Conn, error) { + var key crypto.AuthKey + var serverSalt int64 + if fetchedKey != nil { + key = crypto.AuthKey{Value: crypto.Key(fetchedKey.Value), ID: fetchedKey.ID} + serverSalt = fetchedKey.ServerSalt + } else { + // 快路径:复用已建立连接缓存的密钥与盐(同一 auth key 的后续帧,含同连接换 session)。 + key = current.key + serverSalt = current.salt + } data, err := s.cipher.DecryptFromBuffer(key, b) if err != nil { return current, fmt.Errorf("decrypt: %w", err) } - if data.Salt != keyData.ServerSalt { + if data.Salt != serverSalt { c := current temp := false if c == nil || c.sessionID != data.SessionID { - c = s.newConn(tc, key, data.SessionID, keyData.ServerSalt) + c = s.newConn(tc, key, data.SessionID, serverSalt) temp = true } - err := s.sendBadServerSalt(ctx, c, data.MessageID, data.SeqNo, keyData.ServerSalt) + err := s.sendBadServerSalt(ctx, c, data.MessageID, data.SeqNo, serverSalt) if temp { c.Close() } @@ -100,18 +115,11 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con s.conns.Unregister(current) current.Close() } - current = s.newConn(tc, key, data.SessionID, keyData.ServerSalt) + current = s.newConn(tc, key, data.SessionID, serverSalt) s.conns.Register(current) } - if err := s.sessions.Save(ctx, store.SessionData{ - ID: data.SessionID, - AuthKeyID: key.ID, - Salt: keyData.ServerSalt, - LastSeen: s.clock.Now().Unix(), - }); err != nil { - return current, fmt.Errorf("save session: %w", err) - } + s.maybePersistSession(ctx, current, data.SessionID, key.ID, serverSalt) body := data.Data() typeID, err := (&bin.Buffer{Buf: body}).PeekID() @@ -133,11 +141,9 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con content := clientMessageNeedsAck(typeID) if record, ok := cs.seenRecord(data.MessageID); ok { - s.log.Debug("Duplicate msg_id; re-ack only", zap.Int64("msg_id", data.MessageID)) - if resent, err := current.ResendByRequest(ctx, data.MessageID); err != nil { + s.log.Debug("Duplicate msg_id; replay cached result if available", zap.Int64("msg_id", data.MessageID)) + if err := s.replayRPCResultByRequest(ctx, current, data.MessageID); err != nil { return current, err - } else if resent { - s.log.Debug("Resent cached rpc_result for duplicate msg_id", zap.Int64("msg_id", data.MessageID)) } if !record.content { return current, nil @@ -175,6 +181,34 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con return current, nil } +// sessionSaveMinInterval 是单连接持久化 session 记录的最小间隔。把原本「每帧一次 Redis SET」 +// 去抖到固定间隔——session 是软状态(生产无热读路径),只需周期刷新 last_seen/续 TTL。 +const sessionSaveMinInterval = 30 * time.Second + +// maybePersistSession 按 sessionSaveMinInterval 去抖持久化 session,失败只告警不断连。 +// 原实现每帧同步 Save 且失败即断连:N 连接×帧率的 Redis 写放大 + Redis 抖动级联断连。 +func (s *Server) maybePersistSession(ctx context.Context, c *Conn, sessionID int64, authKeyID [8]byte, salt int64) { + if c == nil { + return + } + now := s.clock.Now().Unix() + if last := c.lastSessionSaveUnix.Load(); last != 0 && now-last < int64(sessionSaveMinInterval/time.Second) { + return + } + c.lastSessionSaveUnix.Store(now) + if err := s.sessions.Save(ctx, store.SessionData{ + ID: sessionID, + AuthKeyID: authKeyID, + Salt: salt, + LastSeen: now, + }); err != nil { + s.log.Warn("Persist session failed (non-fatal)", + zap.Int64("session_id", sessionID), + zap.Error(err), + ) + } +} + func sendQuickAckIfRequested(ctx context.Context, tc transport.Conn, key crypto.AuthKey, data *crypto.EncryptedMessageData) error { q, ok := tc.(quickAckTransport) if !ok || !q.ConsumeQuickAckRequested() { @@ -236,6 +270,9 @@ func (s *Server) dispatch(ctx context.Context, cs *connState, c *Conn, msgID int } content := clientMessageNeedsAck(typeID) if record, ok := cs.seenRecord(m.ID); ok { + if err := s.replayRPCResultByRequest(ctx, c, m.ID); err != nil { + return err + } if record.content { *acks = append(*acks, m.ID) } @@ -360,6 +397,19 @@ func (s *Server) dispatch(ctx context.Context, cs *connState, c *Conn, msgID int } ackContent() s.log.Debug("Received destroy_auth_key", zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:]))) + // 真正销毁:删密钥库记录(每帧回查,删除后该 key 的入站帧立即失效)并主动 + // 断开同 key 的其他连接——出站推送用连接持有的密钥副本加密、不回查密钥库, + // 不断开的话被销毁 key 的空闲连接仍能持续收到推送。发起连接除外:响应要 + // 先送达,它的下一帧会因密钥缺失自然断开。授权(authorizations)不在此清理, + // destroy_auth_key 是 PFS 密钥轮换的清理动作,不等于登出。 + if err := s.authKeys.Delete(ctx, c.authKeyID); err != nil { + s.log.Warn("Delete auth key failed", zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])), zap.Error(err)) + return c.SendAsync(ctx, proto.MessageServerResponse, &destroyAuthKeyFail{}) + } + // 标记密钥已销毁:发起连接被 CloseSessionsForRawAuthKeyExcept 排除(响应需先送达), + // 它下一帧不能再走 serveConn 的密钥复用快路径,须回落到 Get→AuthKeyNotFound 自然失效。 + c.keyDestroyed.Store(true) + s.conns.CloseSessionsForRawAuthKeyExcept(c.authKeyID, c.sessionID) return c.SendAsync(ctx, proto.MessageServerResponse, &destroyAuthKeyOk{}) default: @@ -389,6 +439,15 @@ func mergeStateInfo(primary, fallback []byte) []byte { func (s *Server) enqueueRPC(ctx context.Context, c *Conn, msgID int64, body []byte) error { id, _ := (&bin.Buffer{Buf: body}).PeekID() method := s.typeName(id) + if cached, ok := s.cachedRPCResult(c, msgID); ok { + s.log.Info("RPC duplicate replay from session cache", + zap.String("method", method), + zap.Int64("msg_id", msgID), + zap.String("auth_key_id", hex.EncodeToString(c.authKeyID[:])), + zap.Int64("session_id", c.sessionID), + ) + return c.SendEncoded(ctx, proto.MessageServerResponse, cached) + } err := c.enqueueInboundRPC(ctx, inboundRPC{ method: method, size: len(body), @@ -431,10 +490,18 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, b *bin.Buf return nil } + ctx, dbStats := dbtrace.WithStats(ctx) start := s.clock.Now() result, err := s.rpc.Dispatch(ctx, c.authKeyID, c.sessionID, b) dur := s.clock.Now().Sub(start) s.metrics.RPCHandled(method, dur, err) + // 刷新本连接协商 layer(invokeWithLayer/initConnection 已被 Dispatch 处理并登记), + // 供 rpc_result 与后续 push 出站降级使用。仅在确实观测到 layer 时更新——缓存被驱逐 + // 时 NegotiatedLayer 返回 ok=false,此时必须保留连接已记住的 layer,绝不覆盖成默认值, + // 否则长连接老客户端的条目被驱逐后会被误降回 227。 + if layer, ok := s.rpc.NegotiatedLayer(c.authKeyID, c.sessionID); ok { + c.SetClientLayer(layer) + } fields := []zap.Field{ zap.String("method", method), @@ -449,6 +516,7 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, b *bin.Buf if userID := c.UserID(); userID != 0 { fields = append(fields, zap.Int64("user_id", userID)) } + fields = dbtrace.AppendZapFields(fields, "", dbStats.Snapshot()) if err != nil { var rpcErr *tgerr.Error @@ -472,14 +540,72 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, b *bin.Buf // sendResult 把 RPC 结果包成 rpc_result 并加密回发。 func (s *Server) sendResult(ctx context.Context, c *Conn, reqMsgID int64, result bin.Encoder) error { + encoded, err := s.encodeRPCResult(c, reqMsgID, result) + if err != nil { + return err + } + s.storeRPCResult(c, reqMsgID, encoded) + return c.SendEncoded(ctx, proto.MessageServerResponse, encoded) +} + +// encodeRPCResult 编码 rpc_result。proto.Result.Result 是裸 boxed 对象字节,故在包入 +// rpc_result 之前对其按连接协商 layer 降级(layer==227 直通,零开销)。降级失败 fail-safe: +// 记日志并发送 canonical 字节——宁可老客户端对个别长尾对象渲染异常,也不让连接/流崩。 +func (s *Server) encodeRPCResult(c *Conn, reqMsgID int64, result bin.Encoder) (*encodedOutboundMessage, error) { var buf bin.Buffer if err := result.Encode(&buf); err != nil { - return fmt.Errorf("encode rpc result: %w", err) + return nil, fmt.Errorf("encode rpc result: %w", err) } - return c.Send(ctx, proto.MessageServerResponse, &proto.Result{ + inner := buf.Raw() + if layer := c.ClientLayer(); layer < layerwire.CanonicalLayer { + if down, err := layerwire.Transcode(inner, layer); err != nil { + s.log.Warn("layerwire downgrade failed; sending canonical rpc_result", + zap.Int("layer", layer), zap.Int64("req_msg_id", reqMsgID), zap.Error(err)) + } else { + inner = down + } + } + encoded, err := encodeOutboundMessage(&proto.Result{ RequestMessageID: reqMsgID, - Result: buf.Raw(), + Result: inner, }) + if err != nil { + return nil, err + } + return encoded, nil +} + +func (s *Server) cachedRPCResult(c *Conn, reqMsgID int64) (*encodedOutboundMessage, bool) { + if s == nil || s.rpcResults == nil || c == nil { + return nil, false + } + return s.rpcResults.Get(c.authKeyID, c.sessionID, reqMsgID) +} + +func (s *Server) replayRPCResultByRequest(ctx context.Context, c *Conn, reqMsgID int64) error { + if c == nil { + return nil + } + if resent, err := c.ResendByRequest(ctx, reqMsgID); err != nil { + return err + } else if resent { + s.log.Debug("Resent connection cached rpc_result for duplicate msg_id", zap.Int64("msg_id", reqMsgID)) + return nil + } + if cached, ok := s.cachedRPCResult(c, reqMsgID); ok { + if err := c.SendEncoded(ctx, proto.MessageServerResponse, cached); err != nil { + return err + } + s.log.Debug("Resent session cached rpc_result for duplicate msg_id", zap.Int64("msg_id", reqMsgID)) + } + return nil +} + +func (s *Server) storeRPCResult(c *Conn, reqMsgID int64, encoded *encodedOutboundMessage) { + if s == nil || s.rpcResults == nil || c == nil { + return + } + s.rpcResults.Put(c.authKeyID, c.sessionID, reqMsgID, encoded) } // sendPong 回复 mt.PingRequest / mt.PingDelayDisconnectRequest。 @@ -515,14 +641,25 @@ func (s *Server) sendFutureSalts(ctx context.Context, c *Conn, reqMsgID int64, n } // sendNewSessionCreated 在连接首个加密消息后通知客户端新 session 已建立。 +// unique_id 必须每个 server session 实例独立:客户端按 unique_id 去重, +// 复用同一值会让断线重连后的 new_session_created 被吞掉,错过的差分补拉 +// (Android 收到后才调 getDifference)随之丢失。 func (s *Server) sendNewSessionCreated(ctx context.Context, c *Conn, firstMsgID int64) error { return c.SendAsync(ctx, proto.MessageFromServer, &mt.NewSessionCreated{ FirstMsgID: firstMsgID, - UniqueID: s.sessionUID, + UniqueID: s.newServerSessionUID(), ServerSalt: c.salt, }) } +func (s *Server) newServerSessionUID() int64 { + var b [8]byte + if _, err := io.ReadFull(s.rand, b[:]); err == nil { + return int64(binary.LittleEndian.Uint64(b[:])) + } + return s.clock.Now().UnixNano() +} + // sendAck 确认收到客户端 content-related 消息。 func (s *Server) sendAck(ctx context.Context, c *Conn, ids ...int64) error { return c.SendAsync(ctx, proto.MessageFromServer, &mt.MsgsAck{MsgIDs: ids}) @@ -640,7 +777,11 @@ func validateClientContainerEnvelope(msgID int64, seqNo int32, typeID uint32) in func clientMessageAllowsEitherSeqParity(typeID uint32) bool { switch typeID { - case mt.PingDelayDisconnectRequestTypeID: + case mt.PingDelayDisconnectRequestTypeID, + // get_future_salts 的 seqno 奇偶在客户端间不一致:部分客户端按内容消息发奇数, + // gotd 按服务消息发偶数。两者都合法(官方服务器都接受),故不在此卡奇偶,避免 + // 误判 bad_msg 触发客户端重连风暴。ack/content 行为仍由 clientMessageNeedsAck 决定。 + mt.GetFutureSaltsRequestTypeID: return true default: return false diff --git a/internal/mtprotoedge/encrypted_test.go b/internal/mtprotoedge/encrypted_test.go index 35195cb6..caafb0eb 100644 --- a/internal/mtprotoedge/encrypted_test.go +++ b/internal/mtprotoedge/encrypted_test.go @@ -301,6 +301,34 @@ func TestPingDelayDisconnectEvenSeqAccepted(t *testing.T) { } } +func TestPingDelayDisconnectPongUsesEvenSeqNo(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, reqMsgID, 0, &mt.PingDelayDisconnectRequest{ + PingID: 11, + DisconnectDelay: 10, + }) + + for i := 0; i < 4; i++ { + data, id, _ := readServerMessage(t, conn, cipher, auth.AuthKey) + if id == mt.BadMsgNotificationTypeID { + t.Fatal("ping_delay_disconnect produced bad_msg_notification") + } + if id != mt.PongTypeID { + continue + } + if data.SeqNo%2 != 0 { + t.Fatalf("pong seq_no = %d, want even non-content-related seq_no", data.SeqNo) + } + return + } + t.Fatal("pong was not returned") +} + func TestPingDelayDisconnectOddSeqAccepted(t *testing.T) { const dc = 2 addr, pub, _ := startTestServer(t, Options{DC: dc}) diff --git a/internal/mtprotoedge/exchange.go b/internal/mtprotoedge/exchange.go index ac99fe20..0e6a3584 100644 --- a/internal/mtprotoedge/exchange.go +++ b/internal/mtprotoedge/exchange.go @@ -4,11 +4,11 @@ import ( "context" "errors" "fmt" - "strings" "sync" "go.uber.org/zap" + "github.com/gotd/log/logzap" "github.com/gotd/td/bin" "github.com/gotd/td/crypto" "github.com/gotd/td/exchange" @@ -42,20 +42,53 @@ func (s *Server) handleExchange(ctx context.Context, conn transport.Conn, first buffered := newBufferedConn(conn) buffered.push(first) + // 给整个密钥交换设总时长上界。HandshakeIdleTimeout 只约束单次读 idle,对一个持续发包的 + // 客户端无效——若客户端陷入「ResPQ→nonce 失步→重发 req_pq」的握手重启死循环,无界的 + // serverExchange 会对每个 req_pq 盲回 ResPQ、永不收敛地空转刷日志/占 CPU。超时即放弃本次 + // 握手并断开,客户端重连发起全新握手(无残留相位差)即恢复。 + runCtx := ctx + if s.handshakeMaxDur > 0 { + var cancel context.CancelFunc + runCtx, cancel = context.WithTimeout(ctx, s.handshakeMaxDur) + defer cancel() + } + start := s.clock.Now() res, err := exchange.NewExchanger(buffered, s.dc). WithClock(s.clock). WithRand(s.rand). - WithLogger(s.log.Named("exchange")). + WithLogger(logzap.New(s.log.Named("exchange"))). Server(s.key). - Run(ctx) + Run(runCtx) if err != nil { - if isEncryptedFrameDuringExchange(err) { - replay := buffered.lastFrame() - if replay != nil { - s.log.Debug("Key exchange interrupted by encrypted frame; replaying as existing session") - return replay, nil + // gotd v0.158:握手中读到非零 auth_key_id 帧(客户端用既有 auth key 而非重新交换) + // 经类型化 UnexpectedEncryptedError 暴露并随附原始帧(旧版仅靠错误文案匹配,升级后失效)。 + // 把该帧当既有会话首帧 replay,切勿回 -404——TDesktop 会判定 temp key 被销毁、丢弃并 + // 重跑密钥交换,引发重连/重交换风暴。 + var encErr *exchange.UnexpectedEncryptedError + if errors.As(err, &encErr) { + replay := encErr.Frame + if len(replay) == 0 { + if lf := buffered.lastFrame(); lf != nil { + replay = lf.Buf + } } + if len(replay) > 0 { + s.log.Debug("Key exchange interrupted by encrypted frame; replaying as existing session") + return &bin.Buffer{Buf: replay}, nil + } + } + // req_pq 帧数超界(客户端握手重启死循环):瞬断,促客户端重连发起全新握手。 + if errors.Is(err, errTooManyHandshakeReqPQ) { + s.log.Info("Key exchange aborted: too many req_pq retries (client handshake restart loop)", + zap.Int("max", maxHandshakeReqPQ)) + return nil, err + } + // 仅本握手的总时长上界到点(ctx 自身未取消):放弃并断开,促客户端重连。 + if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil { + s.log.Info("Key exchange aborted: exceeded max duration (possible client req_pq restart loop)", + zap.Duration("max", s.handshakeMaxDur)) + return nil, err } var exErr *exchange.ServerExchangeError if errors.As(err, &exErr) { @@ -67,7 +100,7 @@ func (s *Server) handleExchange(ctx context.Context, conn transport.Conn, first s.metrics.HandshakeDone(s.clock.Now().Sub(start)) s.log.Info("Key exchange completed", - zap.Object("auth_key", res.Key), + zap.Int64("auth_key_id", res.Key.IntID()), zap.Int64("server_salt", res.ServerSalt), zap.Duration("dur", s.clock.Now().Sub(start)), ) @@ -75,11 +108,6 @@ func (s *Server) handleExchange(ctx context.Context, conn transport.Conn, first return nil, s.authKeys.Save(ctx, authKeyData(res.Key, res.ServerSalt, s.clock.Now().Unix())) } -func isEncryptedFrameDuringExchange(err error) bool { - msg := err.Error() - return strings.Contains(msg, "unexpected auth_key_id") && strings.Contains(msg, "plaintext message") -} - // authKeyData 把握手结果转换为 store 记录。 func authKeyData(key crypto.AuthKey, salt, createdAt int64) store.AuthKeyData { return store.AuthKeyData{ @@ -103,14 +131,27 @@ func (s *Server) sendProtoError(ctx context.Context, conn transport.Conn, code i return nil } +// maxHandshakeReqPQ 是一次密钥交换内允许的 req_pq(_multi) 帧数上界。正常握手只发 1 个 +// req_pq(含个别客户端的「fake+真」也就 2 个);客户端因 nonce 失步陷入「收到 ResPQ→立刻 +// 重启握手换 nonce 重发 req_pq」死循环时,会在同一连接上无限发 req_pq,而委托给 gotd 的 +// serverExchange 会对每个都盲回 ResPQ、永不收敛(见 docs/client-compat-notes.md 的握手风暴)。 +// 超过此上界即在 telesrv 传输层瞬断该连接,促客户端重连发起全新握手(无残留相位差)即恢复。 +// 留足余量(8)容纳少量正常重连重启。它与 HandshakeMaxDuration 总时长上界互补(按次/按时)。 +const maxHandshakeReqPQ = 8 + +// errTooManyHandshakeReqPQ 表示一次握手内 req_pq 帧数超过 maxHandshakeReqPQ(疑似客户端握手 +// 重启死循环)。从 bufferedConn.Recv 抛出,经 serverExchange 透传回 handleExchange 断开连接。 +var errTooManyHandshakeReqPQ = errors.New("too many req_pq frames in one handshake (client restart loop)") + // bufferedConn 包装 transport.Conn,可把已读取的帧重新交给后续 Recv。 // // 用于密钥交换:serveConn 已读首帧用于 peek auth_key_id,再 push 回来交给 exchange。 type bufferedConn struct { transport.Conn - mu sync.Mutex - pending []bin.Buffer - last bin.Buffer + mu sync.Mutex + pending []bin.Buffer + last bin.Buffer + reqPQCount int // 本次握手已见 req_pq(_multi) 帧数;只在握手期访问(Recv 单 goroutine) } func newBufferedConn(conn transport.Conn) *bufferedConn { @@ -146,27 +187,46 @@ func (c *bufferedConn) Recv(ctx context.Context, b *bin.Buffer) error { if isUnencryptedMsgsAckFrame(b) { continue } + // req_pq 计数上界:仅在握手期生效(bufferedConn 只用于密钥交换),且 payload id 探测 + // 与上面的 msgs_ack 跳过同量级开销,不碰加密消息热路径。超界即瞬断,止住握手死循环。 + if isUnencryptedReqPQFrame(b) { + c.reqPQCount++ + if c.reqPQCount > maxHandshakeReqPQ { + return errTooManyHandshakeReqPQ + } + } return nil } } -func isUnencryptedMsgsAckFrame(frame *bin.Buffer) bool { +// unencryptedPayloadID 返回未加密消息(auth_key_id==0)内层 TL payload 的 type id。 +// 非未加密消息 / 解码失败时 ok=false。 +func unencryptedPayloadID(frame *bin.Buffer) (uint32, bool) { authKeyID, err := peekAuthKeyID(frame) if err != nil || authKeyID != emptyAuthKeyID { - return false + return 0, false } - var msg proto.UnencryptedMessage - copy := &bin.Buffer{Buf: frame.Copy()} - if err := msg.Decode(copy); err != nil { - return false + cp := &bin.Buffer{Buf: frame.Copy()} + if err := msg.Decode(cp); err != nil { + return 0, false } payload := &bin.Buffer{Buf: msg.MessageData} id, err := payload.PeekID() if err != nil { - return false + return 0, false } - return id == mt.MsgsAckTypeID + return id, true +} + +func isUnencryptedMsgsAckFrame(frame *bin.Buffer) bool { + id, ok := unencryptedPayloadID(frame) + return ok && id == mt.MsgsAckTypeID +} + +func isUnencryptedReqPQFrame(frame *bin.Buffer) bool { + id, ok := unencryptedPayloadID(frame) + return ok && (id == mt.ReqPqRequestTypeID || id == mt.ReqPqMultiRequestTypeID) } func (c *bufferedConn) lastFrame() *bin.Buffer { diff --git a/internal/mtprotoedge/exchange_test.go b/internal/mtprotoedge/exchange_test.go index 3cfb410f..9a5215a6 100644 --- a/internal/mtprotoedge/exchange_test.go +++ b/internal/mtprotoedge/exchange_test.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap/zaptest" + "github.com/gotd/log/logzap" "github.com/gotd/td/bin" "github.com/gotd/td/exchange" "github.com/gotd/td/mt" @@ -64,7 +65,7 @@ func TestKeyExchange(t *testing.T) { defer ec() res, err := exchange.NewExchanger(conn, dc). WithRand(rand.Reader). - WithLogger(zaptest.NewLogger(t).Named("client")). + WithLogger(logzap.New(zaptest.NewLogger(t).Named("client"))). Client([]exchange.PublicKey{pub}). Run(exchCtx) if err != nil { @@ -144,7 +145,7 @@ func TestKeyExchangeIgnoresUnencryptedMsgsAck(t *testing.T) { defer ec() res, err := exchange.NewExchanger(&ackingExchangeConn{Conn: conn, t: t}, dc). WithRand(rand.Reader). - WithLogger(zaptest.NewLogger(t).Named("client")). + WithLogger(logzap.New(zaptest.NewLogger(t).Named("client"))). Client([]exchange.PublicKey{pub}). Run(exchCtx) if err != nil { diff --git a/internal/mtprotoedge/flush_identity_test.go b/internal/mtprotoedge/flush_identity_test.go new file mode 100644 index 00000000..9ba62632 --- /dev/null +++ b/internal/mtprotoedge/flush_identity_test.go @@ -0,0 +1,66 @@ +package mtprotoedge + +import ( + "context" + "testing" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +// TestRunFlushDiscardsBatchOnIdentitySwitch 验证排空进行中连接易主(登出/换号致 +// c.userID != owner)时,属于旧账号的暂存被丢弃、不发给新账号、也不回排进 pending。 +// 这是对抗审查发现的 P0:在飞 batch 逃过 bind/unbind 的 pending 清理。 +func TestRunFlushDiscardsBatchOnIdentitySwitch(t *testing.T) { + sm := NewSessionManager(nil) + raw := [8]byte{7} + const sessionID = int64(99) + key := sessionKey{authKeyID: raw, sessionID: sessionID} + c := &Conn{ + sessionID: sessionID, + authKeyID: raw, + outbound: make(chan outboundOp, 4), + outboundControl: make(chan outboundOp, 4), + outboundStop: make(chan struct{}), + } + sm.Register(c) + sm.BindUserForAuthKey(raw, sessionID, 100) // 当前账号 A=100 + + // session 未就绪:两条推送进 pending。 + for i := 0; i < 2; i++ { + if err := sm.PushToSessionForAuthKey(context.Background(), raw, sessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}); err != nil { + t.Fatalf("queue pending: %v", err) + } + } + sm.mu.RLock() + pending := len(sm.pending[key]) + sm.mu.RUnlock() + if pending != 2 { + t.Fatalf("pending = %d, want 2", pending) + } + + // 模拟「排空已启动(flushing=true,owner=旧账号 A),但运行到时连接已换号成 B」。 + sm.mu.Lock() + sm.flushing[key] = true + sm.mu.Unlock() + c.userID.Store(200) // 换号后的新账号 B + + sm.runFlush(c, key, 100, 0) // owner=旧账号 A,当前 userID=B → 必须丢弃 + + if n := len(c.outbound); n != 0 { + t.Fatalf("sent %d pushes to new owner, want 0 (discarded)", n) + } + sm.mu.RLock() + pendingAfter := len(sm.pending[key]) + flushing := sm.flushing[key] + sm.mu.RUnlock() + if pendingAfter != 0 { + t.Fatalf("pending = %d after identity switch, want 0 (discarded, not requeued)", pendingAfter) + } + if flushing { + t.Fatal("flushing flag not cleared after discard") + } + if c.receivesUpdates.Load() { + t.Fatal("receivesUpdates set despite identity switch") + } +} diff --git a/internal/mtprotoedge/handshake_reqpq_cap_test.go b/internal/mtprotoedge/handshake_reqpq_cap_test.go new file mode 100644 index 00000000..1a7c575f --- /dev/null +++ b/internal/mtprotoedge/handshake_reqpq_cap_test.go @@ -0,0 +1,61 @@ +package mtprotoedge + +import ( + "context" + "errors" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/transport" +) + +// reqPQConn 是只会不断返回同一个 req_pq_multi 帧的假 transport.Conn,用于驱动 bufferedConn +// 的 req_pq 计数上界。除 Recv 外的方法不会被 bufferedConn.Recv 调用。 +type reqPQConn struct { + transport.Conn + frame []byte +} + +func (c *reqPQConn) Recv(_ context.Context, b *bin.Buffer) error { + b.ResetTo(append([]byte(nil), c.frame...)) + return nil +} + +func buildReqPQFrame(t *testing.T) []byte { + t.Helper() + var payload bin.Buffer + if err := (&mt.ReqPqMultiRequest{}).Encode(&payload); err != nil { + t.Fatalf("encode req_pq_multi: %v", err) + } + msg := proto.UnencryptedMessage{MessageID: 1, MessageData: payload.Raw()} + var frame bin.Buffer + if err := msg.Encode(&frame); err != nil { + t.Fatalf("encode unencrypted message: %v", err) + } + return append([]byte(nil), frame.Raw()...) +} + +// TestBufferedConnReqPQCapAborts 锁定握手 req_pq 计数上界:连续 req_pq 超过 maxHandshakeReqPQ +// 后,bufferedConn.Recv 返回 errTooManyHandshakeReqPQ,止住握手重启死循环(不依赖 20s 总超时)。 +func TestBufferedConnReqPQCapAborts(t *testing.T) { + frame := buildReqPQFrame(t) + bc := newBufferedConn(&reqPQConn{frame: frame}) + + ctx := context.Background() + var b bin.Buffer + // 前 maxHandshakeReqPQ 个 req_pq 正常返回(容纳「fake+真」与少量正常重连重启)。 + for i := 0; i < maxHandshakeReqPQ; i++ { + if err := bc.Recv(ctx, &b); err != nil { + t.Fatalf("req_pq %d: unexpected err %v (cap should not trip yet)", i+1, err) + } + if !isUnencryptedReqPQFrame(&b) { + t.Fatalf("frame %d not recognized as req_pq", i+1) + } + } + // 第 maxHandshakeReqPQ+1 个触发上界,瞬断。 + if err := bc.Recv(ctx, &b); !errors.Is(err, errTooManyHandshakeReqPQ) { + t.Fatalf("after cap: err = %v, want errTooManyHandshakeReqPQ", err) + } +} diff --git a/internal/mtprotoedge/helpers_test.go b/internal/mtprotoedge/helpers_test.go index f75521af..4b976f6a 100644 --- a/internal/mtprotoedge/helpers_test.go +++ b/internal/mtprotoedge/helpers_test.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap/zaptest" + "github.com/gotd/log/logzap" "github.com/gotd/td/bin" "github.com/gotd/td/crypto" "github.com/gotd/td/exchange" @@ -61,21 +62,13 @@ func startTestServer(t *testing.T, opts Options) (addr string, pub exchange.Publ // 返回连接、握手结果与 client 端 cipher。连接通过 t.Cleanup 自动关闭。 func dialHandshake(t *testing.T, addr string, dc int, pub exchange.PublicKey) (transport.Conn, exchange.ClientExchangeResult, crypto.Cipher) { t.Helper() - raw, err := net.Dial("tcp", addr) - if err != nil { - t.Fatalf("dial: %v", err) - } - conn, err := transport.Intermediate.Handshake(raw) - if err != nil { - t.Fatalf("transport handshake: %v", err) - } - t.Cleanup(func() { _ = conn.Close() }) + conn := dialTransportOnly(t, addr) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() auth, err := exchange.NewExchanger(conn, dc). WithRand(rand.Reader). - WithLogger(zaptest.NewLogger(t).Named("client")). + WithLogger(logzap.New(zaptest.NewLogger(t).Named("client"))). Client([]exchange.PublicKey{pub}). Run(ctx) if err != nil { @@ -84,6 +77,21 @@ func dialHandshake(t *testing.T, addr string, dc int, pub exchange.PublicKey) (t return conn, auth, crypto.NewClientCipher(rand.Reader) } +func dialTransportOnly(t *testing.T, addr string) transport.Conn { + t.Helper() + raw, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + conn, err := transport.Intermediate.Handshake(raw) + if err != nil { + _ = raw.Close() + t.Fatalf("transport handshake: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + // sendEncrypted 用 client cipher 加密并发送一条带 msgID 的消息。 func sendEncrypted(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, msgID int64, msg bin.Encoder) { t.Helper() diff --git a/internal/mtprotoedge/layer_downgrade_test.go b/internal/mtprotoedge/layer_downgrade_test.go new file mode 100644 index 00000000..e258c3a6 --- /dev/null +++ b/internal/mtprotoedge/layer_downgrade_test.go @@ -0,0 +1,65 @@ +package mtprotoedge + +import ( + "bytes" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" +) + +// TestConnDowngradedClone verifies the outbound seam downgrades a canonical +// (227) object to the connection's negotiated layer, is a no-op for 227, and +// — critically for push fan-out — never mutates the shared input message (one +// pre-encoded update is reused across many connections of differing layers). +func TestConnDowngradedClone(t *testing.T) { + const ( + message227CRC = 0x7600b9d3 + message220CRC = 0xb92f76cf + ) + msg := &tg.Message{ + ID: 2, + FromID: &tg.PeerUser{UserID: 3}, + PeerID: &tg.PeerUser{UserID: 3}, + Date: 1, + Message: "hi", + } + + // layer 220: returns a NEW message rewritten to the 220 constructor id, + // leaving the shared input untouched (227). + enc, err := encodeOutboundMessage(msg) + if err != nil { + t.Fatalf("encode: %v", err) + } + c := &Conn{metrics: NopMetrics{}} + c.SetClientLayer(220) + out := c.downgradedClone(enc) + + if id, _ := (&bin.Buffer{Buf: out.body}).PeekID(); id != message220CRC { + t.Fatalf("downgraded id = %#08x, want %#08x", id, message220CRC) + } + if out.typeID != message220CRC { + t.Fatalf("downgraded typeID = %#08x, want %#08x", out.typeID, message220CRC) + } + // Input must be unmodified — this is what makes shared push fan-out safe. + if id, _ := (&bin.Buffer{Buf: enc.body}).PeekID(); id != message227CRC { + t.Fatalf("input message was mutated: id now %#08x, want 227 %#08x", id, message227CRC) + } + + // Two connections sharing one pre-encoded message get independent results. + encShared, _ := encodeOutboundMessage(msg) + c220 := &Conn{metrics: NopMetrics{}} + c220.SetClientLayer(220) + c227 := &Conn{metrics: NopMetrics{}} // ClientLayer() defaults to 227 + out220 := c220.downgradedClone(encShared) + out227 := c227.downgradedClone(encShared) + if id, _ := (&bin.Buffer{Buf: out220.body}).PeekID(); id != message220CRC { + t.Fatalf("shared->220 id = %#08x, want %#08x", id, message220CRC) + } + if out227 != encShared { + t.Errorf("227 connection should pass the shared message through unchanged (same pointer)") + } + if !bytes.Equal(encShared.body, out227.body) { + t.Errorf("227 passthrough altered bytes") + } +} diff --git a/internal/mtprotoedge/login_e2e_test.go b/internal/mtprotoedge/login_e2e_test.go index dc991848..27d54486 100644 --- a/internal/mtprotoedge/login_e2e_test.go +++ b/internal/mtprotoedge/login_e2e_test.go @@ -4,12 +4,14 @@ import ( "context" "crypto/rand" "crypto/rsa" + "fmt" "net" "testing" "time" "go.uber.org/zap/zaptest" + "github.com/gotd/log/logzap" "github.com/gotd/td/clock" "github.com/gotd/td/exchange" "github.com/gotd/td/session" @@ -56,9 +58,12 @@ func TestLoginRegisterFlow(t *testing.T) { authzStore := memory.NewAuthorizationStore() authKeyStore := memory.NewAuthKeyStore() helpStore := memory.NewHelpStore() + // seed hash 必须高于 help service 的代码默认 hash(低于默认值的 store 行会被 + // 视为陈旧 seed 残留而被默认 config 覆盖),否则断言拿到的是默认 config。 + const seedAppConfigHash = 1_000_000 if err := helpStore.UpsertAppConfig(context.Background(), domain.AppConfig{ Client: "tdesktop", - Hash: 4, + Hash: seedAppConfigHash, JSON: []byte(`{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373"}`), }); err != nil { t.Fatalf("seed app config: %v", err) @@ -99,7 +104,7 @@ func TestLoginRegisterFlow(t *testing.T) { PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, - Logger: zaptest.NewLogger(t).Named("client"), + Logger: logzap.New(zaptest.NewLogger(t).Named("client")), SessionStorage: &session.StorageMemory{}, UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), } @@ -180,8 +185,10 @@ func TestLoginRegisterFlow(t *testing.T) { if err != nil { return err } - if cfg, ok := appConfig.(*tg.HelpAppConfig); !ok || cfg.Hash != 4 { - t.Fatalf("help.getAppConfig = %T %+v, want hash=4 config", appConfig, appConfig) + // 注意:client.Run 回调里 t.Fatalf 只会杀当前 goroutine、测试主协程 + // 会等到 ctx 超时——断言失败用 return fmt.Errorf 让 Run 立即返回。 + if cfg, ok := appConfig.(*tg.HelpAppConfig); !ok || cfg.Hash != seedAppConfigHash { + return fmt.Errorf("help.getAppConfig = %T %+v, want seeded hash=%d config", appConfig, appConfig, seedAppConfigHash) } countriesRes, err := raw.HelpGetCountriesList(ctx, &tg.HelpGetCountriesListRequest{LangCode: "en"}) if err != nil { @@ -319,7 +326,7 @@ func TestPrivateMessageRoundTripFlow(t *testing.T) { PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, - Logger: zaptest.NewLogger(t).Named("client"), + Logger: logzap.New(zaptest.NewLogger(t).Named("client")), SessionStorage: storage, UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), } diff --git a/internal/mtprotoedge/login_email_e2e_test.go b/internal/mtprotoedge/login_email_e2e_test.go new file mode 100644 index 00000000..9e01dd73 --- /dev/null +++ b/internal/mtprotoedge/login_email_e2e_test.go @@ -0,0 +1,236 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "fmt" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/log/logzap" + "github.com/gotd/td/clock" + "github.com/gotd/td/exchange" + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/dcs" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" + + "telesrv/internal/app/account" + "telesrv/internal/app/auth" + "telesrv/internal/app/contacts" + "telesrv/internal/app/dialogs" + "telesrv/internal/app/help" + "telesrv/internal/app/updates" + "telesrv/internal/app/users" + "telesrv/internal/rpc" + "telesrv/internal/store/memory" +) + +// TestLoginEmailEndToEnd 端到端验证登录邮箱:设备 A 注册并设置登录邮箱(loginChange), +// 一个全新设备 B 调 sendCode 收到 sentCodeTypeEmailCode,凭任意邮箱验证码经 signIn +// (email_verification) 完成登录。 +func TestLoginEmailEndToEnd(t *testing.T) { + const ( + dc = 2 + phone = "+8613800138777" + wantPhone = "8613800138777" + code = "12345" + email = "owner@example.com" + wantMask = "o***r@example.com" + ) + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tcpAddr := ln.Addr().(*net.TCPAddr) + + userStore := memory.NewUserStore() + authzStore := memory.NewAuthorizationStore() + authKeyStore := memory.NewAuthKeyStore() + passwordStore := memory.NewPasswordStore() + helpStore := memory.NewHelpStore() + + deps := rpc.Deps{ + Auth: auth.NewService(userStore, authzStore, memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code, auth.WithPasswords(passwordStore)), + Account: account.NewService(passwordStore, account.WithUsers(userStore)), + Help: help.NewService(helpStore, helpStore), + Users: users.NewService(userStore), + Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + + Contacts: contacts.NewService(memory.NewContactStore()), + Dialogs: dialogs.NewService(memory.NewDialogStore()), + } + router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) + srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router}) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + // 关停顺序:cancel 后等 Serve 真正返回(含其末尾日志)再让测试结束,避免 + // 服务端 goroutine 在测试完成后写 zaptest logger 触发 panic。 + defer func() { + cancel() + <-serveErr + }() + + newClient := func() *telegram.Client { + return telegram.NewClient(1, "hash", telegram.Options{ + PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, + Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), + DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, + Logger: logzap.New(zaptest.NewLogger(t).Named("client")), + SessionStorage: &session.StorageMemory{}, // 每个 client 独立 session = 独立 auth key = 独立"设备" + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + }) + } + + // 设备 A:注册并设置登录邮箱。client.Run 回调里断言失败一律 return error(回调跑在 + // 独立 goroutine,t.Fatalf 只会杀该 goroutine 并让测试空等到超时)。 + deviceA := newClient() + if err := deviceA.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(deviceA) + + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}}) + if err != nil { + return err + } + hash := sent.(*tg.AuthSentCode).PhoneCodeHash + if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: phone, PhoneCodeHash: hash, PhoneCode: code}); err != nil { + return err + } + if _, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{PhoneNumber: phone, PhoneCodeHash: hash, FirstName: "Owner"}); err != nil { + return err + } + + // 设置登录邮箱(loginChange,已登录)。 + sentEmail, err := raw.AccountSendVerifyEmailCode(ctx, &tg.AccountSendVerifyEmailCodeRequest{ + Purpose: &tg.EmailVerifyPurposeLoginChange{}, + Email: email, + }) + if err != nil { + return err + } + if sentEmail.EmailPattern != wantMask { + return fmt.Errorf("sentEmailCode pattern = %q, want %q", sentEmail.EmailPattern, wantMask) + } + verified, err := raw.AccountVerifyEmail(ctx, &tg.AccountVerifyEmailRequest{ + Purpose: &tg.EmailVerifyPurposeLoginChange{}, + Verification: &tg.EmailVerificationCode{Code: "whatever"}, + }) + if err != nil { + return err + } + ev, ok := verified.(*tg.AccountEmailVerified) + if !ok { + return fmt.Errorf("verifyEmail result = %T, want *tg.AccountEmailVerified", verified) + } + if ev.Email != email { + return fmt.Errorf("verifyEmail email = %q, want %q", ev.Email, email) + } + + // getPassword 下发登录邮箱掩码。 + pwd, err := raw.AccountGetPassword(ctx) + if err != nil { + return err + } + gotMask, ok := pwd.GetLoginEmailPattern() + if !ok || gotMask != wantMask { + return fmt.Errorf("getPassword login_email_pattern = %q ok=%v, want %q", gotMask, ok, wantMask) + } + return nil + }); err != nil { + t.Fatalf("device A: %v", err) + } + + // 设备 B(全新 auth key):sendCode 应改投邮箱,凭任意邮箱码登录。 + deviceB := newClient() + if err := deviceB.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(deviceB) + + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}}) + if err != nil { + return err + } + sentCode, ok := sent.(*tg.AuthSentCode) + if !ok { + return fmt.Errorf("sendCode result = %T, want *tg.AuthSentCode", sent) + } + emailType, ok := sentCode.Type.(*tg.AuthSentCodeTypeEmailCode) + if !ok { + return fmt.Errorf("sendCode type = %T, want *tg.AuthSentCodeTypeEmailCode (login email should switch delivery)", sentCode.Type) + } + if emailType.EmailPattern != wantMask { + return fmt.Errorf("sentCodeTypeEmailCode pattern = %q, want %q", emailType.EmailPattern, wantMask) + } + + signInRes, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{ + PhoneNumber: phone, + PhoneCodeHash: sentCode.PhoneCodeHash, + EmailVerification: &tg.EmailVerificationCode{Code: "any-email-code"}, + }) + if err != nil { + return err + } + authz, ok := signInRes.(*tg.AuthAuthorization) + if !ok { + return fmt.Errorf("signIn result = %T, want *tg.AuthAuthorization", signInRes) + } + self, ok := authz.User.(*tg.User) + if !ok || !self.Self || self.Phone != wantPhone { + return fmt.Errorf("signIn user = %+v, want self phone=%s", authz.User, wantPhone) + } + return nil + }); err != nil { + t.Fatalf("device B: %v", err) + } + + // 设备 C:无法访问登录邮箱 → resetLoginEmail 清除登录邮箱、改回手机验证码登录。 + deviceC := newClient() + if err := deviceC.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(deviceC) + + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}}) + if err != nil { + return err + } + sentCode := sent.(*tg.AuthSentCode) + if _, ok := sentCode.Type.(*tg.AuthSentCodeTypeEmailCode); !ok { + return fmt.Errorf("pre-reset sendCode type = %T, want email code", sentCode.Type) + } + + // 重置登录邮箱:返回一个新的手机验证码 sentCode(sentCodeTypeApp)。 + resetRes, err := raw.AuthResetLoginEmail(ctx, &tg.AuthResetLoginEmailRequest{PhoneNumber: phone, PhoneCodeHash: sentCode.PhoneCodeHash}) + if err != nil { + return err + } + resetSent, ok := resetRes.(*tg.AuthSentCode) + if !ok { + return fmt.Errorf("resetLoginEmail result = %T, want *tg.AuthSentCode", resetRes) + } + if _, ok := resetSent.Type.(*tg.AuthSentCodeTypeApp); !ok { + return fmt.Errorf("resetLoginEmail sentCode type = %T, want *tg.AuthSentCodeTypeApp (back to phone)", resetSent.Type) + } + + // 用手机验证码完成登录。 + signInRes, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: phone, PhoneCodeHash: resetSent.PhoneCodeHash, PhoneCode: code}) + if err != nil { + return err + } + if _, ok := signInRes.(*tg.AuthAuthorization); !ok { + return fmt.Errorf("post-reset signIn result = %T, want *tg.AuthAuthorization", signInRes) + } + return nil + }); err != nil { + t.Fatalf("device C: %v", err) + } +} diff --git a/internal/mtprotoedge/new_session_uid_test.go b/internal/mtprotoedge/new_session_uid_test.go new file mode 100644 index 00000000..ca742ecb --- /dev/null +++ b/internal/mtprotoedge/new_session_uid_test.go @@ -0,0 +1,45 @@ +package mtprotoedge + +import ( + "testing" + "time" + + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +// TestNewSessionCreatedUniqueIDPerSession 验证两次 session 建立收到的 +// new_session_created.unique_id 互不相同。客户端按 unique_id 去重,复用同一值 +// 会让断线重连后的 new_session_created 被吞掉,依赖它触发的差分补拉随之丢失。 +func TestNewSessionCreatedUniqueIDPerSession(t *testing.T) { + const dc = 2 + addr, pub, _ := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + firstMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncryptedWithSeq(t, conn, cipher, auth, firstMsgID, 1, &tg.HelpGetConfigRequest{}) + first := collectReplies(t, conn, cipher, auth.AuthKey, mt.NewSessionCreatedTypeID) + var created1 mt.NewSessionCreated + if err := created1.Decode(mustHave(t, first, mt.NewSessionCreatedTypeID, "first new_session_created")); err != nil { + t.Fatalf("decode first new_session_created: %v", err) + } + + nextSessionID := auth.SessionID + 1 + if nextSessionID == 0 { + nextSessionID++ + } + secondMsgID := clientMsgID.New(proto.MessageFromClient) + body := encodeClientMessageBodyForTest(t, &tg.HelpGetConfigRequest{}) + sendEncryptedWithSessionSaltAndSeq(t, conn, cipher, auth, nextSessionID, auth.ServerSalt, secondMsgID, 1, body) + second := collectReplies(t, conn, cipher, auth.AuthKey, mt.NewSessionCreatedTypeID) + var created2 mt.NewSessionCreated + if err := created2.Decode(mustHave(t, second, mt.NewSessionCreatedTypeID, "second new_session_created")); err != nil { + t.Fatalf("decode second new_session_created: %v", err) + } + + if created1.UniqueID == created2.UniqueID { + t.Fatalf("new_session_created.unique_id reused across sessions: %d", created1.UniqueID) + } +} diff --git a/internal/mtprotoedge/outbound.go b/internal/mtprotoedge/outbound.go index 841d5c60..a748cbfe 100644 --- a/internal/mtprotoedge/outbound.go +++ b/internal/mtprotoedge/outbound.go @@ -14,6 +14,8 @@ import ( "github.com/gotd/td/crypto" "github.com/gotd/td/mt" "github.com/gotd/td/proto" + + "telesrv/internal/compat/layerwire" ) var ( @@ -117,6 +119,16 @@ func (c *Conn) Close() { }) } +// ForceClose 停止连接并关闭底层 transport。 +// 仅用于授权撤销 / destroy_auth_key 这类“必须让对端立即断线”的路径;普通生命周期仍由 +// serveConn 统一关闭 transport,避免正常 push/索引清理把长连接误伤成硬断。 +func (c *Conn) ForceClose() { + if c.transport != nil { + _ = c.transport.Close() + } + c.Close() +} + // Send 加密并发送一条 server 消息。 func (c *Conn) Send(ctx context.Context, t proto.MessageType, msg bin.Encoder) error { return c.send(ctx, t, msg, false) @@ -424,7 +436,9 @@ func (c *Conn) handleOutboundSend(state *outboundState, op outboundOp) { if err == nil { err = c.writeFrame(op.ctx, frame) } - if err == nil && frameNeedsAck(frame.typeID) { + if err == nil && frame != nil && frameNeedsAck(frame.typeID) { + // 写成功后才提交 content seq_no 递增(peekSeqNo 已按当前计数算好本帧 seq_no)。 + c.commitContentSeqNo() if dropped := state.add(frame); dropped > 0 { for i := 0; i < dropped; i++ { c.metrics.OutboundDropped("tracked_queue_overflow") @@ -502,17 +516,55 @@ func (c *Conn) buildFrame(t proto.MessageType, msg bin.Encoder, encoded *encoded return nil, err } } + // 出站统一在此按本连接协商 layer 降级: + // - push fan-out 用 onceEncodedOutbound 把更新编码一次(canonical)再 SendEncoded 给多条 + // 连接共享,故必须在此**逐连接**降级,且**绝不改共享 encoded**(downgradedClone 拷贝)。 + // - rpc_result 的内层对象已在 encodeRPCResult 按 layer 降级,其 mt.* 外壳在此为顶层直通(no-op)。 + // - 控制消息(mt.*)顶层直通。layer>=227 整条零开销。 + encoded = c.downgradedClone(encoded) content := frameNeedsAck(encoded.typeID) msgID := c.msgID.New(t) return &outboundFrame{ msgID: msgID, - seqNo: c.nextSeqNo(content), + seqNo: c.peekSeqNo(content), typeID: encoded.typeID, body: encoded.body, reqMsgID: encoded.reqMsgID, }, nil } +// downgradedClone 返回按本连接协商 layer 降级后的消息,**绝不修改入参**——push fan-out +// 多条连接共享同一 encoded,逐连接降级必须各自拷贝,否则会污染其他连接的字节。 +// layer>=227 或 Transcode 直通(mt.* / 无变化)时原样返回入参,零拷贝。降级失败 fail-safe: +// 返回 canonical 并计 metrics(宁可老客户端对个别长尾对象渲染异常,也不让连接/流崩)。 +func (c *Conn) downgradedClone(encoded *encodedOutboundMessage) *encodedOutboundMessage { + if encoded == nil { + return nil + } + if c.ClientLayer() >= layerwire.CanonicalLayer { + return encoded + } + down, err := layerwire.Transcode(encoded.body, c.ClientLayer()) + if err != nil { + c.metrics.OutboundDropped("layerwire_downgrade_failed") + return encoded + } + if sameBacking(down, encoded.body) { + return encoded // 直通:未变(mt.*/顶层未知),无需拷贝或重算 typeID + } + out := &encodedOutboundMessage{body: down, typeID: encoded.typeID, reqMsgID: encoded.reqMsgID} + if id, e := (&bin.Buffer{Buf: down}).PeekID(); e == nil { + out.typeID = id + } + return out +} + +// sameBacking reports whether a and b share the same backing array (Transcode +// returns its input unchanged for passthrough cases). +func sameBacking(a, b []byte) bool { + return len(a) == len(b) && (len(a) == 0 || &a[0] == &b[0]) +} + func encodeOutboundMessage(msg bin.Encoder) (*encodedOutboundMessage, error) { if msg == nil { return nil, errors.New("nil outbound message") @@ -532,15 +584,22 @@ func encodeOutboundMessage(msg bin.Encoder) (*encodedOutboundMessage, error) { }, nil } -func (c *Conn) nextSeqNo(content bool) int32 { +// peekSeqNo 计算本帧的 seq_no,但不提交 content 计数递增——递增延到 writeFrame 成功后 +// (commitContentSeqNo)。这样写失败(超时/连接关)但连接存活时,下一条 content 帧会复用 +// 同一 seq_no 而非留下间隙,避免严格校验的客户端把间隙误判为丢帧。只由 outbound actor 调用。 +func (c *Conn) peekSeqNo(content bool) int32 { seqNo := c.sentContentMessages * 2 if content { seqNo++ - c.sentContentMessages++ } return seqNo } +// commitContentSeqNo 在一条 content 帧成功写出后提交 seq_no 递增。只由 outbound actor 调用。 +func (c *Conn) commitContentSeqNo() { + c.sentContentMessages++ +} + func (c *Conn) writeFrame(ctx context.Context, frame *outboundFrame) error { if ctx == nil { ctx = context.Background() @@ -628,6 +687,8 @@ func growBinBufferLen(b *bin.Buffer, n int) { func frameNeedsAck(typeID uint32) bool { switch typeID { case mt.MsgsAckTypeID, + mt.PongTypeID, + mt.FutureSaltsTypeID, mt.BadMsgNotificationTypeID, mt.BadServerSaltTypeID, mt.MsgsStateInfoTypeID, diff --git a/internal/mtprotoedge/outbound_test.go b/internal/mtprotoedge/outbound_test.go index 5f62ea7a..38466b93 100644 --- a/internal/mtprotoedge/outbound_test.go +++ b/internal/mtprotoedge/outbound_test.go @@ -104,6 +104,26 @@ func TestOutboundActorSerializesConcurrentSends(t *testing.T) { } } +func TestFrameNeedsAckServiceExceptions(t *testing.T) { + cases := []struct { + name string + id uint32 + want bool + }{ + {name: "pong", id: mt.PongTypeID, want: false}, + {name: "future_salts", id: mt.FutureSaltsTypeID, want: false}, + {name: "msgs_ack", id: mt.MsgsAckTypeID, want: false}, + {name: "updatesTooLong", id: tg.UpdatesTooLongTypeID, want: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := frameNeedsAck(tc.id); got != tc.want { + t.Fatalf("frameNeedsAck(%s) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} + func TestOutboundResendAndAckState(t *testing.T) { const dc = 2 addr, pub, srv := startTestServer(t, Options{DC: dc}) diff --git a/internal/mtprotoedge/passkey_e2e_test.go b/internal/mtprotoedge/passkey_e2e_test.go new file mode 100644 index 00000000..e73b3bea --- /dev/null +++ b/internal/mtprotoedge/passkey_e2e_test.go @@ -0,0 +1,297 @@ +package mtprotoedge + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/log/logzap" + "github.com/gotd/td/clock" + "github.com/gotd/td/exchange" + "github.com/gotd/td/session" + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/dcs" + "github.com/gotd/td/tg" + "github.com/gotd/td/transport" + + "telesrv/internal/app/account" + "telesrv/internal/app/auth" + "telesrv/internal/app/contacts" + "telesrv/internal/app/dialogs" + "telesrv/internal/app/help" + passkeyapp "telesrv/internal/app/passkey" + "telesrv/internal/app/updates" + "telesrv/internal/app/users" + "telesrv/internal/rpc" + "telesrv/internal/store/memory" + "telesrv/internal/webauthn/webauthntest" +) + +const passkeyTestOrigin = "android:apk-key-hash:e2e-test" + +// publicKeyOptions 提取 DataJSON(顶层 publicKey)里的 challenge / rpId / user.id。 +type publicKeyOptions struct { + PublicKey struct { + Challenge string `json:"challenge"` + RPID string `json:"rpId"` + RP struct { + ID string `json:"id"` + } `json:"rp"` + User struct { + ID string `json:"id"` + } `json:"user"` + } `json:"publicKey"` +} + +func parsePasskeyOptions(t *testing.T, data string) publicKeyOptions { + t.Helper() + var opts publicKeyOptions + if err := json.Unmarshal([]byte(data), &opts); err != nil { + t.Fatalf("parse passkey options %q: %v", data, err) + } + return opts +} + +func decodeB64URL(t *testing.T, s string) []byte { + t.Helper() + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + if b, err = base64.URLEncoding.DecodeString(s); err != nil { + t.Fatalf("base64url decode %q: %v", s, err) + } + } + return b +} + +// TestPasskeyEndToEnd 端到端验证 passkey:设备 A(已登录)注册 passkey,设备 B(全新 +// auth key)经 initPasskeyLogin/finishPasskeyLogin 用软件 authenticator 的断言登录;再 +// getPasskeys/deletePasskey 往返。整条链路验签真实(软件 authenticator 用真私钥签名)。 +func TestPasskeyEndToEnd(t *testing.T) { + const ( + dc = 2 + phone = "+8613800139000" + wantPhone = "8613800139000" + code = "12345" + rpID = "telesrv.test" + ) + + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("gen rsa: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + tcpAddr := ln.Addr().(*net.TCPAddr) + + userStore := memory.NewUserStore() + authKeyStore := memory.NewAuthKeyStore() + helpStore := memory.NewHelpStore() + passkeyService := passkeyapp.NewService(memory.NewPasskeyStore(), memory.NewPasskeyChallengeStore(), rpID, dc) + + deps := rpc.Deps{ + Auth: auth.NewService(userStore, memory.NewAuthorizationStore(), memory.NewCodeStore(), authKeyStore, memory.NewTempAuthKeyBindingStore(), code), + Account: account.NewService(memory.NewPasswordStore(), account.WithUsers(userStore)), + Help: help.NewService(helpStore, helpStore), + Users: users.NewService(userStore), + Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + + Contacts: contacts.NewService(memory.NewContactStore()), + Dialogs: dialogs.NewService(memory.NewDialogStore()), + Passkey: passkeyService, + } + router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) + srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router}) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + defer func() { + cancel() + <-serveErr + }() + + newClient := func() *telegram.Client { + return telegram.NewClient(1, "hash", telegram.Options{ + PublicKeys: []exchange.PublicKey{{RSA: &rsaKey.PublicKey}}, + Resolver: dcs.Plain(dcs.PlainOptions{Protocol: transport.Intermediate}), + DCList: dcs.List{Options: []tg.DCOption{{ID: dc, IPAddress: tcpAddr.IP.String(), Port: tcpAddr.Port, Static: true}}}, + Logger: logzap.New(zaptest.NewLogger(t).Named("client")), + SessionStorage: &session.StorageMemory{}, + UpdateHandler: telegram.UpdateHandlerFunc(func(context.Context, tg.UpdatesClass) error { return nil }), + }) + } + + authn, err := webauthntest.New() + if err != nil { + t.Fatalf("new authenticator: %v", err) + } + var userHandle string + + // 设备 A:注册 + 注册 passkey + getPasskeys。 + deviceA := newClient() + if err := deviceA.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(deviceA) + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}}) + if err != nil { + return err + } + hash := sent.(*tg.AuthSentCode).PhoneCodeHash + if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: phone, PhoneCodeHash: hash, PhoneCode: code}); err != nil { + return err + } + if _, err := raw.AuthSignUp(ctx, &tg.AuthSignUpRequest{PhoneNumber: phone, PhoneCodeHash: hash, FirstName: "Pass"}); err != nil { + return err + } + + // 1) 注册选项 → 软件 authenticator 造 attestation。 + regOpts, err := raw.AccountInitPasskeyRegistration(ctx) + if err != nil { + return err + } + opts := parsePasskeyOptions(t, regOpts.Options.Data) + if opts.PublicKey.RP.ID != rpID { + return fmt.Errorf("registration rp.id = %q, want %q", opts.PublicKey.RP.ID, rpID) + } + userHandle = string(decodeB64URL(t, opts.PublicKey.User.ID)) // "2:" + challenge := decodeB64URL(t, opts.PublicKey.Challenge) + clientData, attObj, err := authn.Register(rpID, passkeyTestOrigin, challenge) + if err != nil { + return err + } + credB64 := authn.CredentialIDB64() + cred := &tg.InputPasskeyCredentialPublicKey{ + ID: credB64, + RawID: credB64, + Response: &tg.InputPasskeyResponseRegister{ + ClientData: tg.DataJSON{Data: string(clientData)}, + AttestationData: attObj, + }, + } + pk, err := raw.AccountRegisterPasskey(ctx, cred) + if err != nil { + return err + } + if pk.ID != credB64 { + return fmt.Errorf("registered passkey id = %q, want %q", pk.ID, credB64) + } + + // 2) getPasskeys 应含该 passkey。 + list, err := raw.AccountGetPasskeys(ctx) + if err != nil { + return err + } + if len(list.Passkeys) != 1 || list.Passkeys[0].ID != credB64 { + return fmt.Errorf("getPasskeys = %+v, want 1 passkey id=%q", list.Passkeys, credB64) + } + return nil + }); err != nil { + t.Fatalf("device A: %v", err) + } + + // 设备 B:全新 auth key,用 passkey 登录。 + deviceB := newClient() + if err := deviceB.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(deviceB) + loginOpts, err := raw.AuthInitPasskeyLogin(ctx, &tg.AuthInitPasskeyLoginRequest{APIID: 1, APIHash: "hash"}) + if err != nil { + return err + } + opts := parsePasskeyOptions(t, loginOpts.Options.Data) + if opts.PublicKey.RPID != rpID { + return fmt.Errorf("login rpId = %q, want %q", opts.PublicKey.RPID, rpID) + } + challenge := decodeB64URL(t, opts.PublicKey.Challenge) + clientData, authData, sig, err := authn.Assert(rpID, passkeyTestOrigin, challenge) + if err != nil { + return err + } + credB64 := authn.CredentialIDB64() + res, err := raw.AuthFinishPasskeyLogin(ctx, &tg.AuthFinishPasskeyLoginRequest{ + Credential: &tg.InputPasskeyCredentialPublicKey{ + ID: credB64, + RawID: credB64, + Response: &tg.InputPasskeyResponseLogin{ + ClientData: tg.DataJSON{Data: string(clientData)}, + AuthenticatorData: authData, + Signature: sig, + UserHandle: userHandle, + }, + }, + }) + if err != nil { + return err + } + authz, ok := res.(*tg.AuthAuthorization) + if !ok { + return fmt.Errorf("finishPasskeyLogin result = %T, want *tg.AuthAuthorization", res) + } + self, ok := authz.User.(*tg.User) + if !ok || !self.Self || self.Phone != wantPhone { + return fmt.Errorf("passkey login user = %+v, want self phone=%s", authz.User, wantPhone) + } + + // 第二次断言(计数器递增)仍应通过。 + loginOpts2, err := raw.AuthInitPasskeyLogin(ctx, &tg.AuthInitPasskeyLoginRequest{APIID: 1, APIHash: "hash"}) + if err != nil { + return err + } + ch2 := decodeB64URL(t, parsePasskeyOptions(t, loginOpts2.Options.Data).PublicKey.Challenge) + cd2, ad2, sig2, err := authn.Assert(rpID, passkeyTestOrigin, ch2) + if err != nil { + return err + } + if _, err := raw.AuthFinishPasskeyLogin(ctx, &tg.AuthFinishPasskeyLoginRequest{ + Credential: &tg.InputPasskeyCredentialPublicKey{ + ID: credB64, RawID: credB64, + Response: &tg.InputPasskeyResponseLogin{ + ClientData: tg.DataJSON{Data: string(cd2)}, AuthenticatorData: ad2, Signature: sig2, UserHandle: userHandle, + }, + }, + }); err != nil { + return fmt.Errorf("second passkey login: %w", err) + } + return nil + }); err != nil { + t.Fatalf("device B: %v", err) + } + + // 设备 A 再次连接:删除 passkey → getPasskeys 空。 + deviceC := newClient() + if err := deviceC.Run(ctx, func(ctx context.Context) error { + raw := tg.NewClient(deviceC) + sent, err := raw.AuthSendCode(ctx, &tg.AuthSendCodeRequest{PhoneNumber: phone, APIID: 1, APIHash: "hash", Settings: tg.CodeSettings{}}) + if err != nil { + return err + } + if _, err := raw.AuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: phone, PhoneCodeHash: sent.(*tg.AuthSentCode).PhoneCodeHash, PhoneCode: code}); err != nil { + return err + } + deleted, err := raw.AccountDeletePasskey(ctx, authn.CredentialIDB64()) + if err != nil { + return err + } + if !deleted { + return fmt.Errorf("deletePasskey returned false") + } + list, err := raw.AccountGetPasskeys(ctx) + if err != nil { + return err + } + if len(list.Passkeys) != 0 { + return fmt.Errorf("getPasskeys after delete = %+v, want empty", list.Passkeys) + } + return nil + }); err != nil { + t.Fatalf("device C: %v", err) + } +} diff --git a/internal/mtprotoedge/pending_flush_test.go b/internal/mtprotoedge/pending_flush_test.go new file mode 100644 index 00000000..9149ad49 --- /dev/null +++ b/internal/mtprotoedge/pending_flush_test.go @@ -0,0 +1,56 @@ +package mtprotoedge + +import ( + "context" + "testing" + "time" + + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +// TestSetReceivesUpdatesFlushesPendingBeforeActivation 验证置位时先排空暂存推送 +// 再激活 receivesUpdates:客户端必须真实收到暂存消息,且激活最终完成。 +// 排空失败时 receivesUpdates 保持 false、暂存回排,由下一次置位重试。 +func TestSetReceivesUpdatesFlushesPendingBeforeActivation(t *testing.T) { + const dc = 2 + addr, pub, srv := startTestServer(t, Options{DC: dc}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + sendEncryptedWithSeq(t, conn, cipher, auth, clientMsgID.New(proto.MessageFromClient), 1, &tg.HelpGetConfigRequest{}) + collectReplies(t, conn, cipher, auth.AuthKey, mt.MsgsAckTypeID) + + raw := auth.AuthKey.ID + ctx := context.Background() + + // 完全就绪还要求 membership 路由建立(ReceivesUpdatesForAuthKey 的另一半条件)。 + srv.Conns().BindUserForAuthKey(raw, auth.SessionID, 100) + srv.Conns().SetSessionChannelMemberships(raw, auth.SessionID, 100, nil) + + // 未就绪:推送进 pending 而非直发。 + for i := 0; i < 2; i++ { + if err := srv.Conns().PushToSessionForAuthKey(ctx, raw, auth.SessionID, proto.MessageFromServer, &tg.UpdatesTooLong{}); err != nil { + t.Fatalf("queue pending push: %v", err) + } + } + if srv.Conns().ReceivesUpdatesForAuthKey(raw, auth.SessionID) { + t.Fatal("session ready before activation") + } + + srv.Conns().SetReceivesUpdatesForAuthKey(raw, auth.SessionID, true) + + // 客户端应收到暂存的 updatesTooLong(flush 直发)。 + replies := collectReplies(t, conn, cipher, auth.AuthKey, tg.UpdatesTooLongTypeID) + mustHave(t, replies, tg.UpdatesTooLongTypeID, "flushed pending push") + + // 排空完成后 receivesUpdates 才置位(异步,轮询等待)。 + deadline := time.Now().Add(5 * time.Second) + for !srv.Conns().ReceivesUpdatesForAuthKey(raw, auth.SessionID) { + if time.Now().After(deadline) { + t.Fatal("session never became ready after flush") + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/internal/mtprotoedge/rpc_result_cache.go b/internal/mtprotoedge/rpc_result_cache.go new file mode 100644 index 00000000..6e8ed756 --- /dev/null +++ b/internal/mtprotoedge/rpc_result_cache.go @@ -0,0 +1,156 @@ +package mtprotoedge + +import ( + "container/list" + "sync" + "time" +) + +const ( + rpcResultCacheTTL = 3 * time.Minute + rpcResultCacheMaxEntries = 4096 + rpcResultCacheMaxBytes = 64 << 20 +) + +type rpcResultCacheKey struct { + authKeyID [8]byte + sessionID int64 + reqMsgID int64 +} + +type rpcResultCacheEntry struct { + key rpcResultCacheKey + encoded *encodedOutboundMessage + size int + expiresAt time.Time +} + +type rpcResultCache struct { + mu sync.Mutex + now func() time.Time + ttl time.Duration + maxEntries int + maxBytes int + bytes int + order *list.List + byKey map[rpcResultCacheKey]*list.Element +} + +func newRPCResultCache(now func() time.Time) *rpcResultCache { + if now == nil { + now = time.Now + } + return &rpcResultCache{ + now: now, + ttl: rpcResultCacheTTL, + maxEntries: rpcResultCacheMaxEntries, + maxBytes: rpcResultCacheMaxBytes, + order: list.New(), + byKey: make(map[rpcResultCacheKey]*list.Element), + } +} + +func (c *rpcResultCache) Get(authKeyID [8]byte, sessionID, reqMsgID int64) (*encodedOutboundMessage, bool) { + if c == nil || reqMsgID == 0 { + return nil, false + } + key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: reqMsgID} + now := c.now() + + c.mu.Lock() + defer c.mu.Unlock() + + elem, ok := c.byKey[key] + if !ok { + return nil, false + } + entry := elem.Value.(*rpcResultCacheEntry) + if !entry.expiresAt.After(now) { + c.removeElement(elem) + return nil, false + } + return cloneEncodedOutboundMessage(entry.encoded), true +} + +func (c *rpcResultCache) Put(authKeyID [8]byte, sessionID, reqMsgID int64, encoded *encodedOutboundMessage) { + if c == nil || reqMsgID == 0 || encoded == nil { + return + } + copied := cloneEncodedOutboundMessage(encoded) + if copied == nil { + return + } + size := len(copied.body) + if c.maxBytes > 0 && size > c.maxBytes { + return + } + + key := rpcResultCacheKey{authKeyID: authKeyID, sessionID: sessionID, reqMsgID: reqMsgID} + now := c.now() + + c.mu.Lock() + defer c.mu.Unlock() + + c.expireLocked(now) + if elem, ok := c.byKey[key]; ok { + c.removeElement(elem) + } + entry := &rpcResultCacheEntry{ + key: key, + encoded: copied, + size: size, + expiresAt: now.Add(c.ttl), + } + elem := c.order.PushBack(entry) + c.byKey[key] = elem + c.bytes += size + c.trimLocked() +} + +func (c *rpcResultCache) expireLocked(now time.Time) { + for elem := c.order.Front(); elem != nil; { + next := elem.Next() + entry := elem.Value.(*rpcResultCacheEntry) + if entry.expiresAt.After(now) { + return + } + c.removeElement(elem) + elem = next + } +} + +func (c *rpcResultCache) trimLocked() { + for c.order.Len() > 0 { + tooManyEntries := c.maxEntries > 0 && c.order.Len() > c.maxEntries + tooManyBytes := c.maxBytes > 0 && c.bytes > c.maxBytes + if !tooManyEntries && !tooManyBytes { + return + } + c.removeElement(c.order.Front()) + } +} + +func (c *rpcResultCache) removeElement(elem *list.Element) { + if elem == nil { + return + } + entry := elem.Value.(*rpcResultCacheEntry) + delete(c.byKey, entry.key) + c.bytes -= entry.size + if c.bytes < 0 { + c.bytes = 0 + } + c.order.Remove(elem) +} + +func cloneEncodedOutboundMessage(src *encodedOutboundMessage) *encodedOutboundMessage { + if src == nil { + return nil + } + body := append([]byte(nil), src.body...) + return &encodedOutboundMessage{ + body: body, + typeID: src.typeID, + reqMsgID: src.reqMsgID, + } +} diff --git a/internal/mtprotoedge/rpc_test.go b/internal/mtprotoedge/rpc_test.go index cd089b88..24cf00db 100644 --- a/internal/mtprotoedge/rpc_test.go +++ b/internal/mtprotoedge/rpc_test.go @@ -2,6 +2,7 @@ package mtprotoedge import ( "context" + "sync/atomic" "testing" "time" @@ -59,11 +60,9 @@ func TestRPCGetConfig(t *testing.T) { if cfg.ThisDC != dc { t.Fatalf("config.ThisDC = %d, want %d", cfg.ThisDC, dc) } - if len(cfg.DCOptions) != 1 { - t.Fatalf("config.DCOptions count = %d, want 1", len(cfg.DCOptions)) - } - if got := cfg.DCOptions[0]; got.ID != dc || got.IPAddress != advIP || got.Port != advPort { - t.Fatalf("DCOption = %+v, want id=%d ip=%s port=%d", got, dc, advIP, advPort) + // 不下发 DCOptions:客户端使用写死的 static DC 地址(空列表令其保留本地地址)。 + if len(cfg.DCOptions) != 0 { + t.Fatalf("config.DCOptions = %+v, want empty (client uses pinned static address)", cfg.DCOptions) } } @@ -107,6 +106,52 @@ func TestInboundRPCQueueFullReturnsFloodWait(t *testing.T) { close(handler.release) } +func TestDuplicateRPCResultAcrossReconnectUsesSessionCache(t *testing.T) { + const dc = 2 + handler := &countingConfigRPC{} + addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: handler}) + conn, auth, cipher := dialHandshake(t, addr, dc, pub) + + clientMsgID := proto.NewMessageIDGen(time.Now) + reqMsgID := clientMsgID.New(proto.MessageFromClient) + sendEncrypted(t, conn, cipher, auth, reqMsgID, &tg.HelpGetConfigRequest{}) + + first := readRPCResultForRequest(t, conn, cipher, auth.AuthKey, reqMsgID) + if calls := handler.calls.Load(); calls != 1 { + t.Fatalf("handler calls after first request = %d, want 1", calls) + } + var firstConfig tg.Config + if err := firstConfig.Decode(&bin.Buffer{Buf: first.Result}); err != nil { + t.Fatalf("decode first config: %v", err) + } + if firstConfig.ThisDC != dc { + t.Fatalf("first config.ThisDC = %d, want %d", firstConfig.ThisDC, dc) + } + + _ = conn.Close() + replayConn := dialTransportOnly(t, addr) + sendEncrypted(t, replayConn, cipher, auth, reqMsgID, &tg.HelpGetConfigRequest{}) + + second := readRPCResultForRequest(t, replayConn, cipher, auth.AuthKey, reqMsgID) + if calls := handler.calls.Load(); calls != 1 { + t.Fatalf("handler calls after replay = %d, want 1", calls) + } + if string(second.Result) != string(first.Result) { + t.Fatalf("replayed rpc_result payload changed") + } +} + +type countingConfigRPC struct { + calls atomic.Int32 +} + +func (h *countingConfigRPC) Dispatch(context.Context, [8]byte, int64, *bin.Buffer) (bin.Encoder, error) { + h.calls.Add(1) + return &tg.Config{ThisDC: 2}, nil +} + +func (h *countingConfigRPC) NegotiatedLayer([8]byte, int64) (int, bool) { return 227, true } + type blockingRPC struct { started chan struct{} release chan struct{} @@ -125,6 +170,8 @@ func (h *blockingRPC) Dispatch(ctx context.Context, _ [8]byte, _ int64, _ *bin.B } } +func (h *blockingRPC) NegotiatedLayer([8]byte, int64) (int, bool) { return 227, true } + func readRPCResultForRequest(t *testing.T, conn transport.Conn, cipher crypto.Cipher, key crypto.AuthKey, reqMsgID int64) proto.Result { t.Helper() for i := 0; i < 12; i++ { diff --git a/internal/mtprotoedge/same_port_mux.go b/internal/mtprotoedge/same_port_mux.go new file mode 100644 index 00000000..d4341bf0 --- /dev/null +++ b/internal/mtprotoedge/same_port_mux.go @@ -0,0 +1,294 @@ +package mtprotoedge + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// samePortMuxBacklog 是 tcp/http 两个子 listener 的握手缓冲深度,吸收接入突发。 +const samePortMuxBacklog = 1024 + +// websocketAllowedPaths 是允许升级为 WebSocket 的本地路径白名单。 +// +// telegram-tt(WebA)按 `/apiws{_test}{_premium}` 拼 URL,故四种组合都要放行; +// 其中 `/apiws_test_premium` 是「测试服 + 会员」组合,缺它会让会员账号在测试服 404。 +var websocketAllowedPaths = map[string]struct{}{ + "/apiws": {}, + "/apiws_test": {}, + "/apiws_premium": {}, + "/apiws_test_premium": {}, +} + +// samePortMux 在同一个 listener 上把「HTTP(WebSocket 升级请求)」与「裸 MTProto TCP」 +// 两类连接拆开:每条新连接只窥探前 4 字节即可判定走向。每条连接的窥探都在各自的 +// goroutine 里完成(带 sniffTimeout 上界),慢连接只占用自己的 goroutine,绝不阻塞其他 +// 连接的接入与分流——这避免了固定 worker 池被 slow-loris 占满导致的接入饥饿。 +type samePortMux struct { + base net.Listener + addr net.Addr + sniffTimeout time.Duration + + tcp *samePortMuxListener + http *samePortMuxListener + + closed chan struct{} + once sync.Once +} + +func newSamePortMux(base net.Listener, sniffTimeout time.Duration) *samePortMux { + if sniffTimeout <= 0 { + sniffTimeout = 5 * time.Second + } + m := &samePortMux{ + base: base, + addr: base.Addr(), + sniffTimeout: sniffTimeout, + closed: make(chan struct{}), + } + m.tcp = newSamePortMuxListener(m.addr, m.closed) + m.http = newSamePortMuxListener(m.addr, m.closed) + return m +} + +// TCP 返回裸 MTProto TCP 连接的 listener(连接已窥探,前 4 字节会被回放)。 +func (m *samePortMux) TCP() net.Listener { + return m.tcp +} + +// HTTP 返回 WebSocket 升级请求的 listener,交给 http.Server.Serve。 +func (m *samePortMux) HTTP() net.Listener { + return m.http +} + +func (m *samePortMux) Serve(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + <-ctx.Done() + _ = m.Close() + }() + + // 每条连接一个窥探 goroutine:wg 让 Serve 在退出前等待在途窥探把连接交接完成。 + var wg sync.WaitGroup + defer wg.Wait() + + for { + conn, err := m.base.Accept() + if err != nil { + if ctx.Err() != nil || isSamePortMuxClosed(m.closed) || isNetClosed(err) { + return nil + } + return err + } + wg.Add(1) + go func() { + defer wg.Done() + m.dispatch(ctx, conn) + }() + } +} + +func (m *samePortMux) Close() error { + m.once.Do(func() { + close(m.closed) + _ = m.tcp.Close() + _ = m.http.Close() + _ = m.base.Close() + }) + return nil +} + +// dispatch 窥探单条连接的前 4 字节并把它交给 tcp 或 http 子 listener。窥探带 sniffTimeout +// 读上界,慢/半开连接最多占用本 goroutine sniffTimeout 后即被回收。 +func (m *samePortMux) dispatch(ctx context.Context, conn net.Conn) { + var header [4]byte + if err := conn.SetReadDeadline(time.Now().Add(m.sniffTimeout)); err != nil { + _ = conn.Close() + return + } + if _, err := io.ReadFull(conn, header[:]); err != nil { + _ = conn.Close() + return + } + if err := conn.SetReadDeadline(time.Time{}); err != nil { + _ = conn.Close() + return + } + + wrapped := &prefixedNetConn{ + Conn: conn, + reader: io.MultiReader(bytes.NewReader(header[:]), conn), + } + + target := m.tcp + if isHTTPHeaderPrefix(header) { + target = m.http + } + if !target.deliver(ctx, wrapped) { + _ = conn.Close() + } +} + +// isHTTPHeaderPrefix 判断前 4 字节是否是 HTTP 请求行起始。 +// +// 这里只认 GET/POST/HEAD/OPTI,与 gotd generateInit 排除的前缀集合「严格对齐」:合法的 +// obfuscated2 init 头被保证不会以这四个前缀开头(见 mtproxy/obfuscated2/keys_util.go), +// 故裸 MTProto 永不会被误判为 HTTP。刻意不扩展到 PUT/DELETE 等其他方法——generateInit +// 并未排除它们,扩展白名单反而会让随机 init 偶发(2^-32)被误分流。真实 WebSocket 升级一律 +// 是 GET,浏览器不会用其他方法,因此当前集合既安全又完备。 +func isHTTPHeaderPrefix(header [4]byte) bool { + switch string(header[:]) { + case "GET ", "POST", "HEAD", "OPTI": + return true + default: + return false + } +} + +func websocketRouteHandler(handler http.Handler, allowedOrigins []string) http.Handler { + origins := websocketOriginSet(allowedOrigins) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := websocketAllowedPaths[r.URL.Path]; !ok { + http.NotFound(w, r) + return + } + // gotd 的 WebsocketListener 把 websocket.Accept 的 AcceptOptions 写死且不带 + // InsecureSkipVerify/OriginPatterns,coder/websocket 因此会对「Origin.Host != Host」 + // 的握手返回 403。浏览器(WebA/telegram-tt)发起的 ws 连接必然带 Origin(=页面来源, + // ≠ 本服务监听地址),握手会被无条件拒绝;而不带 Origin 的非浏览器客户端(gotd 测试 + // 客户端)却能通过——故单测全绿、真浏览器全挂。 + // + // 白名单确认后再把 Origin 改写成与 Host 同源,让 Accept 放行且无需 fork gotd。 + // 无 Origin 的非浏览器客户端允许通过;浏览器来源必须显式配置,"*" 仅用于临时调试。 + if !websocketOriginAllowed(origins, r.Header.Get("Origin")) { + http.Error(w, "websocket origin forbidden", http.StatusForbidden) + return + } + if r.Header.Get("Origin") != "" { + r.Header.Set("Origin", "http://"+r.Host) + } + handler.ServeHTTP(w, r) + }) +} + +func websocketOriginSet(origins []string) map[string]struct{} { + out := make(map[string]struct{}, len(origins)) + for _, origin := range origins { + origin = strings.TrimRight(strings.TrimSpace(origin), "/") + if origin != "" { + out[strings.ToLower(origin)] = struct{}{} + } + } + return out +} + +func websocketOriginAllowed(allowed map[string]struct{}, origin string) bool { + origin = strings.TrimRight(strings.TrimSpace(origin), "/") + if origin == "" { + return true + } + if _, ok := allowed["*"]; ok { + return true + } + _, ok := allowed[strings.ToLower(origin)] + return ok +} + +func minDuration(a, b time.Duration) time.Duration { + if a <= 0 { + return b + } + if b <= 0 || a < b { + return a + } + return b +} + +func isNetClosed(err error) bool { + return errors.Is(err, net.ErrClosed) +} + +func isSamePortMuxClosed(ch <-chan struct{}) bool { + select { + case <-ch: + return true + default: + return false + } +} + +// prefixedNetConn 把被窥探掉的前缀字节回放在数据流最前面,使下游(去混淆/codec 探测/ +// http.Server)看到完整原始字节流。 +type prefixedNetConn struct { + reader io.Reader + net.Conn +} + +func (p *prefixedNetConn) Read(b []byte) (int, error) { + return p.reader.Read(b) +} + +// samePortMuxListener 是一个内存 listener:dispatch 把分流后的连接投递进来,下游 +// (serveMixed 的 accept 循环 / http.Server) 从这里 Accept。 +type samePortMuxListener struct { + addr net.Addr + ch chan net.Conn + closed chan struct{} + once sync.Once +} + +func newSamePortMuxListener(addr net.Addr, parentClosed <-chan struct{}) *samePortMuxListener { + closed := make(chan struct{}) + l := &samePortMuxListener{ + addr: addr, + ch: make(chan net.Conn, samePortMuxBacklog), + closed: closed, + } + go func() { + select { + case <-parentClosed: + _ = l.Close() + case <-closed: + } + }() + return l +} + +func (l *samePortMuxListener) Accept() (net.Conn, error) { + select { + case <-l.closed: + return nil, net.ErrClosed + case conn := <-l.ch: + return conn, nil + } +} + +func (l *samePortMuxListener) Close() error { + l.once.Do(func() { + close(l.closed) + }) + return nil +} + +func (l *samePortMuxListener) Addr() net.Addr { + return l.addr +} + +func (l *samePortMuxListener) deliver(ctx context.Context, conn net.Conn) bool { + select { + case <-l.closed: + return false + case l.ch <- conn: + return true + case <-ctx.Done(): + return false + } +} diff --git a/internal/mtprotoedge/server.go b/internal/mtprotoedge/server.go index 90781a39..1c27020b 100644 --- a/internal/mtprotoedge/server.go +++ b/internal/mtprotoedge/server.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net" + "net/http" "sync" "time" @@ -33,6 +34,12 @@ import ( // 返回 *tgerr.Error 时连接层将其转为 rpc_error 回发;其他 error 视为连接级故障。 type RPCHandler interface { Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int64, b *bin.Buffer) (bin.Encoder, error) + // NegotiatedLayer returns the TL layer the session negotiated via + // invokeWithLayer and whether one was ever observed. Used to downgrade + // outbound objects for clients compiled on an older layer. ok=false means + // unknown (cold/evicted) — the caller must keep the connection's last-known + // layer rather than overwrite it. + NegotiatedLayer(authKeyID [8]byte, sessionID int64) (int, bool) } // Options 配置 Server。 @@ -44,11 +51,24 @@ type Options struct { // ObfuscatedTCP 先按 MTProto TCP obfuscation 解包,再自动探测 codec。 // Telegram Desktop 的 tcpo_only endpoint 会走这个 64 字节前缀流程。 ObfuscatedTCP bool + // WebSocket 在同一个 listener 上接受 MTProto over WebSocket(/apiws*)。 + // 开启后仅在连接建立时读取前 4 字节做 HTTP/TCP 分流;MTProto TCP + // 后续仍走原 ObfuscatedTCP + codec 热路径。 + WebSocket bool + // WebSocketAllowedOrigins 是允许浏览器发起 WebSocket upgrade 的页面 origin。 + // 空列表表示只接受无 Origin 的非浏览器客户端;"*" 表示允许所有来源(仅调试)。 + WebSocketAllowedOrigins []string // ReadTimeout 单次读取超时。默认 5m。 ReadTimeout time.Duration // HandshakeIdleTimeout 是连接「建立 session 前」(握手 + 首个加密消息之前)的读超时, // 比 ReadTimeout 短,用于快速回收握手后静默的半开 / 异常连接。默认 60s。 HandshakeIdleTimeout time.Duration + // HandshakeMaxDuration 是单次密钥交换(serverExchange)的总时长上界。HandshakeIdleTimeout + // 只约束「单次读 idle」,对一个持续发包的客户端无效——若客户端陷入「收到 ResPQ→nonce 失步 + // →重发 req_pq」的握手重启死循环(见 docs/client-compat-notes.md),无界的 serverExchange 会 + // 对每个 req_pq 盲回 ResPQ、永不收敛地空转刷日志/占 CPU。本上界给整个握手设总预算,超时即 + // 放弃并断开,客户端重连发起全新握手(无残留相位差)即恢复。正常握手 <1s。默认 20s。 + HandshakeMaxDuration time.Duration // WriteTimeout 单次写入超时。默认 30s。 WriteTimeout time.Duration // RPCMaxInflight 是单连接同时处理的 RPC 上限。默认 32。 @@ -88,6 +108,9 @@ func (o *Options) setDefaults() { if o.HandshakeIdleTimeout == 0 { o.HandshakeIdleTimeout = 60 * time.Second } + if o.HandshakeMaxDuration == 0 { + o.HandshakeMaxDuration = 20 * time.Second + } if o.WriteTimeout == 0 { o.WriteTimeout = 30 * time.Second } @@ -129,8 +152,11 @@ type Server struct { log *zap.Logger codec func() transport.Codec obfuscated bool + websocket bool + websocketOrigins []string readTimeout time.Duration handshakeTimeout time.Duration + handshakeMaxDur time.Duration writeTimeout time.Duration rpcInflight int rpcQueueSize int @@ -148,8 +174,7 @@ type Server struct { rand io.Reader types *tmap.Map - // sessionUID 是本进程 server session 唯一标识,写入 new_session_created。 - sessionUID int64 + rpcResults *rpcResultCache // onFrame 是测试钩子:收到一帧时回调其字节数;生产为 nil。 onFrame func(n int) @@ -166,8 +191,11 @@ func New(opts Options) *Server { log: opts.Logger, codec: opts.Codec, obfuscated: opts.ObfuscatedTCP, + websocket: opts.WebSocket, + websocketOrigins: append([]string(nil), opts.WebSocketAllowedOrigins...), readTimeout: opts.ReadTimeout, handshakeTimeout: opts.HandshakeIdleTimeout, + handshakeMaxDur: opts.HandshakeMaxDuration, writeTimeout: opts.WriteTimeout, rpcInflight: opts.RPCMaxInflight, rpcQueueSize: opts.RPCQueueSize, @@ -183,7 +211,7 @@ func New(opts Options) *Server { clock: opts.Clock, rand: opts.Rand, types: tmap.New(tg.TypesMap(), mt.TypesMap(), proto.TypesMap()), - sessionUID: opts.Clock.Now().UnixNano(), + rpcResults: newRPCResultCache(opts.Clock.Now), } } @@ -214,49 +242,192 @@ func (s *Server) newConn(tc transport.Conn, key crypto.AuthKey, sessionID, salt // Serve 在 ln 上运行 MTProto 连接循环,直到 ctx 取消或发生不可恢复错误。 // ctx 取消时优雅退出:关闭 listener 并等待在途连接处理结束。 func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + if s.websocket { + return s.serveMixed(ctx, ln) + } + return s.serveTCP(ctx, ln) +} + +func (s *Server) serveTCP(ctx context.Context, ln net.Listener) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - transportListener := ln - if s.obfuscated { - transportListener = transport.ObfuscatedListener(ln) - } - l := newCompatTransportListener(s.codec, transportListener) s.log.Info("Serving", zap.String("addr", ln.Addr().String()), zap.Int("dc", s.dc), zap.Bool("obfuscated_tcp", s.obfuscated)) defer s.log.Info("Stopped") - // ctx 取消时关闭 listener,解除 Accept 阻塞。 + return s.acceptLoop(ctx, ln, s.obfuscated) +} + +func (s *Server) serveMixed(ctx context.Context, ln net.Listener) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // 嗅探(读首 4 字节做 HTTP/TCP 分流)的读超时必须对齐「建立 session 前」的读超时 + // s.handshakeTimeout(默认 60s)——与 serveDetectedConn/serveConn 的 pre-session 读 + // deadline 完全一致。一条尚未发出首帧的连接正处于「pre-session idle」状态:合法 MTProto + // 客户端(如 DrKLO)会预开「暖」连接、在有请求前并不立即发送 obfuscated2 init。此前用 + // minDuration(5s,...) 把嗅探压到 5s,比非 mux 路径激进 12 倍,会把这些暖连接在 5s 误杀, + // 触发客户端 6s 重连风暴并误判「后端不健康」回退到外部 DNS。per-conn goroutine 模型已消解 + // slow-loris 接入饥饿,故嗅探用满 handshakeTimeout 是安全的。 + mux := newSamePortMux(ln, s.handshakeTimeout) + wsLn, wsHandler := transport.WebsocketListener(ln.Addr()) + + httpServer := &http.Server{ + Handler: websocketRouteHandler(wsHandler, s.websocketOrigins), + ReadHeaderTimeout: minDuration(10*time.Second, s.handshakeTimeout), + BaseContext: func(net.Listener) context.Context { + return ctx + }, + } + + s.log.Info("Serving", + zap.String("addr", ln.Addr().String()), + zap.Int("dc", s.dc), + zap.Bool("obfuscated_tcp", s.obfuscated), + zap.Bool("websocket", true), + zap.Strings("websocket_origins", s.websocketOrigins), + ) + defer s.log.Info("Stopped") + go func() { <-ctx.Done() - _ = l.Close() + _ = mux.Close() + _ = httpServer.Close() + _ = wsLn.Close() + }() + + errCh := make(chan error, 4) + var wg sync.WaitGroup + wg.Add(4) + // 分流器:窥探前 4 字节把 HTTP(WebSocket 升级) 与裸 MTProto TCP 拆开。 + go func() { + defer wg.Done() + errCh <- mux.Serve(ctx) + }() + // 裸 MTProto TCP:每条连接在自己的 goroutine 里完成去混淆 + codec 探测。 + go func() { + defer wg.Done() + errCh <- s.acceptLoop(ctx, mux.TCP(), s.obfuscated) + }() + // WebSocket:gotd 升级处理器已剥离 obfuscated2 并补回 codec tag,这里只需探测 codec。 + go func() { + defer wg.Done() + errCh <- s.acceptLoop(ctx, wsLn, false) + }() + go func() { + defer wg.Done() + if err := httpServer.Serve(mux.HTTP()); err != nil { + if errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) { + errCh <- nil + return + } + errCh <- fmt.Errorf("websocket http serve: %w", err) + return + } + errCh <- nil + }() + + var firstErr error + for i := 0; i < 4; i++ { + if err := <-errCh; err != nil && firstErr == nil { + firstErr = err + cancel() + } + } + cancel() + _ = mux.Close() + _ = httpServer.Close() + _ = wsLn.Close() + wg.Wait() + return firstErr +} + +// acceptLoop 接受裸连接,并为每条连接单独起 goroutine 完成「去混淆 + codec 探测 + +// serveConn」。探测在 accept 循环之外、带握手超时进行——慢/半开/坏 init 的客户端只占用 +// 自己的 goroutine,绝不阻塞其他连接的接入;单条连接的握手失败也只关闭该连接,不会拖垮 +// 整个监听循环。obfuscated 为 true 时先走 obfuscated2 去混淆(裸 MTProto TCP);WebSocket +// 连接传 false(gotd 升级处理器已完成去混淆)。 +func (s *Server) acceptLoop(ctx context.Context, ln net.Listener, obfuscated bool) error { + go func() { + <-ctx.Done() + _ = ln.Close() }() var wg sync.WaitGroup defer wg.Wait() for { - conn, err := l.Accept() + raw, err := ln.Accept() if err != nil { if ctx.Err() != nil || errors.Is(err, net.ErrClosed) { return nil } - if s.obfuscated && isClientDisconnect(err) { - s.log.Debug("Ignoring failed obfuscated accept", zap.Error(err)) - continue - } return fmt.Errorf("accept: %w", err) } wg.Add(1) go func() { defer wg.Done() - if err := s.serveConn(ctx, conn); err != nil && !isClientDisconnect(err) { - s.log.Info("Connection closed with error", zap.Error(err)) - } + s.serveDetectedConn(ctx, raw, obfuscated) }() } } +// serveDetectedConn 把一条裸连接提升为 transport.Conn(去混淆 + codec 探测)后运行 MTProto +// 连接循环。提升过程的读取放在本 goroutine、且受握手读超时约束,而非塞在 accept 循环里, +// 这样慢连接不会阻塞其他连接接入,去混淆/codec 握手本身也有时间上界。 +func (s *Server) serveDetectedConn(ctx context.Context, raw net.Conn, obfuscated bool) { + // 握手读超时只覆盖去混淆 + codec 探测这一小段;用真实墙钟时间(SetReadDeadline 语义), + // 不走可能被测试注入的逻辑 clock。 + if err := raw.SetReadDeadline(time.Now().Add(s.handshakeTimeout)); err != nil { + _ = raw.Close() + return + } + + // 探测阶段若 ctx 取消,主动关闭 raw 解除阻塞读取——否则去混淆读会一直挂到握手超时, + // 把半开连接拖进优雅退出的等待里。探测结束即停掉该 watcher,连接服务期由 serveConn + // 自己的 ctx watcher 接管。 + promoted := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = raw.Close() + case <-promoted: + } + }() + + conn, err := s.promoteConn(raw, obfuscated) + close(promoted) + if err != nil { + // 去混淆/codec 探测失败(读超时、客户端中途断开、坏 init 等)只影响这一条连接, + // 记 debug 即可。 + if !isClientDisconnect(err) { + s.log.Debug("Transport handshake failed", zap.Error(err)) + } + _ = raw.Close() + return + } + // 探测完成,撤掉握手读超时;后续每帧读写由 serveConn / 传输层各自管理超时。 + if err := raw.SetReadDeadline(time.Time{}); err != nil { + _ = conn.Close() + return + } + if err := s.serveConn(ctx, conn); err != nil && !isClientDisconnect(err) { + s.log.Info("Connection closed with error", zap.Error(err)) + } +} + +// promoteConn 复用与 listener 组合完全一致的「obfuscated2 去混淆 + codec 探测」管线,但针对 +// 单条连接,使其可在 accept 循环之外执行。obfuscated 对 WebSocket 连接必须为 false(gotd +// 升级处理器已剥离 obfuscated2 并补回 codec tag)。 +func (s *Server) promoteConn(raw net.Conn, obfuscated bool) (transport.Conn, error) { + var ln net.Listener = newSingleConnListener(raw) + if obfuscated { + ln = transport.ObfuscatedListener(ln) + } + return newCompatTransportListener(s.codec, ln).Accept() +} + // serveConn 处理单个传输连接:读帧并按 auth_key_id 分流。 // // - auth_key_id == 0:未加密的密钥交换起始消息,执行握手并落地 auth key。 @@ -322,18 +493,26 @@ func (s *Server) serveConn(ctx context.Context, conn transport.Conn) (err error) continue } - data, found, err := s.authKeys.Get(ctx, authKeyID) - if err != nil { - return fmt.Errorf("lookup auth key: %w", err) - } - if !found { - if err := s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound); err != nil { - return err + // 已建立连接复用缓存密钥走快路径(fetchedKey=nil):避开每帧回查 AuthKeyStore—— + // 这是 mtprotoedge 层最热的库访问点。密钥材料创建后不可变;销毁(destroy_auth_key)/ + // 撤销由 SessionManager 主动 Close 连接保证失效,不依赖此被动回查。仅 destroy_auth_key + // 的发起连接置 keyDestroyed,使其下一帧回落到 Get→AuthKeyNotFound,维持原契约。 + var fetchedKey *store.AuthKeyData + if current == nil || current.authKeyID != authKeyID || current.keyDestroyed.Load() { + d, found, err := s.authKeys.Get(ctx, authKeyID) + if err != nil { + return fmt.Errorf("lookup auth key: %w", err) } - continue + if !found { + if err := s.sendProtoError(ctx, conn, codec.CodeAuthKeyNotFound); err != nil { + return err + } + continue + } + fetchedKey = &d } - current, err = s.handleEncrypted(ctx, conn, cs, current, data, &b) + current, err = s.handleEncrypted(ctx, conn, cs, current, fetchedKey, &b) if err != nil { return err } diff --git a/internal/mtprotoedge/server_test.go b/internal/mtprotoedge/server_test.go index 3de7e6bf..4fa899ed 100644 --- a/internal/mtprotoedge/server_test.go +++ b/internal/mtprotoedge/server_test.go @@ -1,9 +1,16 @@ package mtprotoedge import ( + "bufio" + "bytes" "context" "crypto/rand" + "encoding/base64" + "errors" + "fmt" "net" + "net/http" + "strings" "testing" "time" @@ -13,6 +20,7 @@ import ( "github.com/gotd/td/mtproxy" "github.com/gotd/td/mtproxy/obfuscator" "github.com/gotd/td/proto/codec" + "github.com/gotd/td/telegram/dcs" "github.com/gotd/td/transport" ) @@ -223,3 +231,395 @@ func TestServerAcceptObfuscatedAbridgedQuickAckFrame(t *testing.T) { t.Fatal("server did not stop after ctx cancel") } } + +func TestServerSamePortWebSocketAndObfuscatedTCP(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + frames := make(chan int, 2) + srv := New(Options{Logger: zaptest.NewLogger(t), ObfuscatedTCP: true, WebSocket: true}) + srv.onFrame = func(n int) { + select { + case frames <- n: + default: + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + var wsPayload bin.Buffer + wsPayload.PutInt32(0x11223344) + wsPayload.PutInt32(0x55667788) + + wsResolver := dcs.Websocket(dcs.WebsocketOptions{}) + wsConn, err := wsResolver.Primary(context.Background(), 2, dcs.List{ + Domains: map[int]string{ + 2: "ws://" + ln.Addr().String() + "/apiws", + }, + }) + if err != nil { + t.Fatalf("websocket dial: %v", err) + } + sendCtx, sc := context.WithTimeout(context.Background(), 5*time.Second) + if err := wsConn.Send(sendCtx, &wsPayload); err != nil { + sc() + t.Fatalf("websocket send: %v", err) + } + sc() + expectFrameLen(t, frames, wsPayload.Len()) + _ = wsConn.Close() + + raw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("tcp dial: %v", err) + } + obfs := obfuscator.Obfuscated2(rand.Reader, raw) + if err := obfs.Handshake((codec.Abridged{}).ObfuscatedTag(), 2, mtproxy.Secret{}); err != nil { + t.Fatalf("tcp obfuscated handshake: %v", err) + } + tcpConn, err := transport.NewProtocol(func() transport.Codec { + return transport.Abridged.CodecNoHeader() + }).Handshake(obfs) + if err != nil { + t.Fatalf("tcp transport handshake: %v", err) + } + + var tcpPayload bin.Buffer + tcpPayload.PutInt32(0x12345678) + tcpPayload.PutInt32(0x0badf00d) + sendCtx, sc = context.WithTimeout(context.Background(), 5*time.Second) + if err := tcpConn.Send(sendCtx, &tcpPayload); err != nil { + sc() + t.Fatalf("tcp send: %v", err) + } + sc() + expectFrameLen(t, frames, tcpPayload.Len()) + _ = tcpConn.Close() + + cancel() + select { + case err := <-serveErr: + if err != nil { + t.Fatalf("serve returned error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not stop after ctx cancel") + } +} + +func TestSamePortWebSocketTransportRoundTrip(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mux := newSamePortMux(ln, 5*time.Second) + wsLn, wsHandler := transport.WebsocketListener(ln.Addr()) + httpServer := &http.Server{ + Handler: websocketRouteHandler(wsHandler, []string{"http://localhost:1234"}), + ReadHeaderTimeout: 5 * time.Second, + } + + serveErr := make(chan error, 2) + go func() { serveErr <- mux.Serve(ctx) }() + go func() { + err := httpServer.Serve(mux.HTTP()) + if err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) { + serveErr <- err + return + } + serveErr <- nil + }() + defer func() { + cancel() + _ = mux.Close() + _ = httpServer.Close() + _ = wsLn.Close() + for i := 0; i < 2; i++ { + select { + case err := <-serveErr: + if err != nil { + t.Fatalf("serve returned error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("same-port websocket transport did not stop") + } + } + }() + + serverDone := make(chan error, 1) + go func() { + l := newCompatTransportListener(nil, wsLn) + defer func() { _ = l.Close() }() + + conn, err := l.Accept() + if err != nil { + serverDone <- err + return + } + defer func() { _ = conn.Close() }() + + recvCtx, rc := context.WithTimeout(ctx, 5*time.Second) + defer rc() + var got bin.Buffer + if err := conn.Recv(recvCtx, &got); err != nil { + serverDone <- err + return + } + + var reply bin.Buffer + reply.PutInt32(0x10203040) + reply.PutInt32(0x50607080) + sendCtx, sc := context.WithTimeout(ctx, 5*time.Second) + defer sc() + if err := conn.Send(sendCtx, &reply); err != nil { + serverDone <- err + return + } + serverDone <- nil + }() + + wsResolver := dcs.Websocket(dcs.WebsocketOptions{}) + wsConn, err := wsResolver.Primary(context.Background(), 2, dcs.List{ + Domains: map[int]string{ + 2: "ws://" + ln.Addr().String() + "/apiws", + }, + }) + if err != nil { + t.Fatalf("websocket dial: %v", err) + } + defer func() { _ = wsConn.Close() }() + + var request bin.Buffer + request.PutInt32(0x11223344) + request.PutInt32(0x55667788) + sendCtx, sc := context.WithTimeout(context.Background(), 5*time.Second) + if err := wsConn.Send(sendCtx, &request); err != nil { + sc() + t.Fatalf("websocket send: %v", err) + } + sc() + + var want bin.Buffer + want.PutInt32(0x10203040) + want.PutInt32(0x50607080) + recvCtx, rc := context.WithTimeout(context.Background(), 5*time.Second) + var got bin.Buffer + if err := wsConn.Recv(recvCtx, &got); err != nil { + rc() + t.Fatalf("websocket recv: %v", err) + } + rc() + if !bytes.Equal(got.Raw(), want.Raw()) { + t.Fatalf("websocket recv = %x, want %x", got.Raw(), want.Raw()) + } + + select { + case err := <-serverDone: + if err != nil { + t.Fatalf("server transport: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("server transport did not finish") + } +} + +// TestSamePortMuxIdleConnNotReapedBeforeHandshakeTimeout 回归:WebSocket 同端口复用的嗅探 +// 读超时(读首 4 字节做 HTTP/TCP 分流)必须对齐 HandshakeIdleTimeout,而不是旧的硬上限 5s。 +// 合法 MTProto 客户端(DrKLO)会预开「暖」连接、在有请求前并不立即发 obfuscated2 init;旧实现 +// 用 minDuration(5s, handshakeTimeout) 把嗅探压到 5s,比非 mux 路径(serveDetectedConn 用满 +// handshakeTimeout)激进 12 倍,会把这些暖连接在 5s 误杀,触发 DrKLO 6s 重连风暴 + EPOLLRDHUP +// + 误判后端不健康回退外部 DNS(见 docs/client-compat-notes.md)。 +// +// HandshakeIdleTimeout 必须 >5s 才能暴露旧的截断:连一条裸 TCP、不发任何字节,本地用 6s 读 +// deadline。新实现下连接在 8s 嗅探超时前一直存活,故本地 Read 因自身 deadline 超时(net timeout); +// 旧实现下服务端 5s 即 FIN,本地 Read 会在 6s 前拿到 EOF/reset —— 据此判定回归。 +func TestSamePortMuxIdleConnNotReapedBeforeHandshakeTimeout(t *testing.T) { + addr, _, _ := startTestServer(t, Options{ + WebSocket: true, + ObfuscatedTCP: true, + HandshakeIdleTimeout: 8 * time.Second, // 必须 >5s 才能区分「对齐 handshakeTimeout」与旧的 5s 截断 + }) + + raw, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = raw.Close() }() + + // 不发任何字节,模拟客户端预开、暂未发首帧的暖连接。 + if err := raw.SetReadDeadline(time.Now().Add(6 * time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + n, err := raw.Read(make([]byte, 1)) + if err == nil { + t.Fatalf("unexpected %d bytes on idle pre-handshake conn (server should send nothing)", n) + } + // 只有「本地读 deadline 超时」才说明连接在 6s 时仍存活(嗅探超时已对齐 8s)。 + // 任何由对端关闭导致的 EOF/reset 都意味着服务端在 handshakeTimeout 前过早回收了连接。 + var nerr net.Error + if errors.As(err, &nerr) && nerr.Timeout() { + return + } + t.Fatalf("server closed idle pre-handshake conn before handshake idle timeout (err=%v); "+ + "same-port mux sniff timeout must align with HandshakeIdleTimeout, not a 5s cap", err) +} + +func expectFrameLen(t *testing.T, frames <-chan int, want int) { + t.Helper() + select { + case n := <-frames: + if n != want { + t.Fatalf("received frame len = %d, want %d", n, want) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not receive frame in time") + } +} + +// TestWebSocketRouteHandlerChecksBrowserOrigin 回归保护 Origin 修复:浏览器发起的 WS 升级 +// 必带 Origin(≠ Host),白名单来源需要改写 Origin 通过 coder/websocket,同名单外来源必须 403。 +func TestWebSocketRouteHandlerChecksBrowserOrigin(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer func() { _ = ln.Close() }() + + _, wsHandler := transport.WebsocketListener(ln.Addr()) + httpServer := &http.Server{Handler: websocketRouteHandler(wsHandler, []string{"http://localhost:1234"})} + go func() { _ = httpServer.Serve(ln) }() + defer func() { _ = httpServer.Close() }() + + host := ln.Addr().String() + + // 白名单跨源升级:Origin 指向页面来源(端口/主机不同于 Host)。 + status := wsUpgradeStatus(t, host, "/apiws", "http://localhost:1234") + if !strings.Contains(status, "101") { + t.Fatalf("allowed cross-origin /apiws upgrade: got status %q, want 101 Switching Protocols", status) + } + + status = wsUpgradeStatus(t, host, "/apiws", "http://evil.example") + if !strings.Contains(status, "403") { + t.Fatalf("disallowed origin: got status %q, want 403", status) + } + + // 非白名单路径必须 404,不得升级。 + status = wsUpgradeStatus(t, host, "/nope", "http://localhost:1234") + if !strings.Contains(status, "404") { + t.Fatalf("disallowed path: got status %q, want 404", status) + } +} + +// wsUpgradeStatus 用裸连接发一个合法的 WebSocket 升级请求并返回状态行。 +func wsUpgradeStatus(t *testing.T, host, path, origin string) string { + t.Helper() + conn, err := net.Dial("tcp", host) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + + var keyBytes [16]byte + if _, err := rand.Read(keyBytes[:]); err != nil { + t.Fatalf("rand: %v", err) + } + key := base64.StdEncoding.EncodeToString(keyBytes[:]) + req := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"+ + "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: binary\r\nOrigin: %s\r\n\r\n", + path, host, key, origin) + + if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatalf("set deadline: %v", err) + } + if _, err := conn.Write([]byte(req)); err != nil { + t.Fatalf("write upgrade: %v", err) + } + statusLine, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + t.Fatalf("read status: %v", err) + } + return statusLine +} + +// TestServerObfuscatedTCPNotBlockedByStalledClient 回归保护「去 worker 池 / 握手移出 accept +// 循环」的修复:一个发了几字节就挂起的连接,过去会卡死串行 accept 循环、阻塞所有后续接入; +// 现在它只占用自己的 goroutine,正常客户端仍能在握手超时内被服务。 +func TestServerObfuscatedTCPNotBlockedByStalledClient(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + frames := make(chan int, 1) + srv := New(Options{Logger: zaptest.NewLogger(t), ObfuscatedTCP: true, WebSocket: true}) + srv.onFrame = func(n int) { + select { + case frames <- n: + default: + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + serveErr := make(chan error, 1) + go func() { serveErr <- srv.Serve(ctx, ln) }() + + // 挂起连接:发 4 个非 HTTP 字节通过分流(路由到 TCP),但不补满 obfuscated2 的 64 字节 + // init,使其卡在去混淆读取上。修复前这会拖死整个 TCP accept 循环。 + stalled, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("stalled dial: %v", err) + } + defer func() { _ = stalled.Close() }() + if _, err := stalled.Write([]byte{0x01, 0x02, 0x03, 0x04}); err != nil { + t.Fatalf("stalled write: %v", err) + } + + // 正常的 obfuscated abridged 客户端:应当照常被服务(onFrame 触发)。 + raw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("tcp dial: %v", err) + } + obfs := obfuscator.Obfuscated2(rand.Reader, raw) + if err := obfs.Handshake((codec.Abridged{}).ObfuscatedTag(), 2, mtproxy.Secret{}); err != nil { + t.Fatalf("tcp obfuscated handshake: %v", err) + } + tcpConn, err := transport.NewProtocol(func() transport.Codec { + return transport.Abridged.CodecNoHeader() + }).Handshake(obfs) + if err != nil { + t.Fatalf("tcp transport handshake: %v", err) + } + + var payload bin.Buffer + payload.PutInt32(0x12345678) + payload.PutInt32(0x0badf00d) + sendCtx, sc := context.WithTimeout(context.Background(), 5*time.Second) + if err := tcpConn.Send(sendCtx, &payload); err != nil { + sc() + t.Fatalf("tcp send: %v", err) + } + sc() + expectFrameLen(t, frames, payload.Len()) + _ = tcpConn.Close() + + cancel() + select { + case err := <-serveErr: + if err != nil { + t.Fatalf("serve returned error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not stop after ctx cancel") + } +} diff --git a/internal/mtprotoedge/session_manager.go b/internal/mtprotoedge/session_manager.go index 13758c2f..0c14532c 100644 --- a/internal/mtprotoedge/session_manager.go +++ b/internal/mtprotoedge/session_manager.go @@ -21,11 +21,34 @@ var ErrSessionAmbiguous = errors.New("session id is shared by multiple auth keys const ( maxPendingPushesPerSession = 32 + // maxFlushAttempts / flushRetryBackoff:排空暂存推送时 c.Send 失败(出站拥塞 5s 超时 + // 或瞬时错误)后的退避重试上界。连接真死时 serveConn 会 Unregister 清理状态、提前止损; + // 这里只为「连接存活但出站暂时拥塞」做有限重试。用尽仍失败则置位激活并接受 getDifference + // 兜底——避免 idle 客户端(只发 ping、不触发置位重试)永久停在未激活态而静默断流。 + maxFlushAttempts = 5 + flushRetryBackoff = 2 * time.Second // pendingPushMaxAge:session 注册后迟迟不调 updates.getState(receivesUpdates 恒 false)时, // 其暂存的主动推送最长保留时长。超过即丢整批并不再囤——正常 TDesktop 登录后秒级就会 - // getState 建立同步基线;长期不 ready 多为异常/对抗连接。丢弃不丢消息:getDifference 以 - // user_update_events durable log 兜底补齐。 + // getState 建立同步基线;长期不 ready 多为异常/对抗连接。 + // + // 不变量:只有 durable update(写 user_update_events)才会进 pending。transient update + // (typing/presence,不写 durable log)经 PushToUserTransient* 在未就绪时直接跳过、不入队, + // 因此本队列被老化/溢出/重试耗尽丢弃时,丢的一定是 durable 条目——getDifference 以 + // user_update_events 兜底补齐,丢弃不丢数据。 pendingPushMaxAge = 60 * time.Second + // maxSessionsPerAuthKey:单个 raw auth_key 允许同时在线的 session 上限。telesrv 单 DC, + // 一个客户端的全部连接(主连接 + 并发下载/上传)共享同一 auth_key、各用独立 session_id, + // 故此上限须高于真实客户端单设备的并发连接峰值,否则会误杀活跃下载/主连接: + // - TDesktop:kMaxMediaDcCount=0x10,单 DC 最多 16 路下载 + 16 路上传 + 1 主 ≈ 33; + // - DrKLO:DOWNLOAD_CONNECTIONS_COUNT=2 + UPLOAD_CONNECTIONS_COUNT=4 + 主/push ≈ 10。 + // 叠加重连 churn(旧 session 在 readTimeout 内滞留)峰值约 ~70,故设 256(~3.5x 余量)。 + // 它只防「单 auth_key 累积海量连接」的病态(使 CloseSessionsForRawAuthKey/pushToUser 遍历 + // 退化 O(N)),超限驱逐的也只是同一设备凭据自身的连接,不会误伤别的账号。 + maxSessionsPerAuthKey = 256 + // maxChannelIndexPerSession:单 session 在 channel 路由索引(interest / membership)中 + // 登记的 channel 数上限。membership 源于真实成员关系(大账号可能很多),interest 受客户端 + // 直接控制;两者都设一个宽松上界防内存放大,超出即截断并记日志。 + maxChannelIndexPerSession = 8192 ) type queuedPush struct { @@ -53,12 +76,14 @@ type SessionManager struct { bySession map[sessionKey]*Conn bySessionID map[int64]map[[8]byte]*Conn // sessionID → raw authKeyID → Conn,用于兼容旧 API 的唯一性检查 byAuthKey map[[8]byte]map[int64]*Conn // raw authKeyID → sessionID → Conn + byBusinessAuthKey map[[8]byte]map[sessionKey]*Conn byUser map[int64]map[sessionKey]*Conn byChannel map[int64]map[sessionKey]int64 // channelID → session → userID,用于频道 active-viewer 临时推送 bySessionChannels map[sessionKey]map[int64]struct{} byMemberChannel map[int64]map[sessionKey]int64 // channelID → session → userID,用于已上线成员持久 update 推送 bySessionMembers map[sessionKey]map[int64]struct{} pending map[sessionKey][]queuedPush // updates-ready 前暂存的主动推送 + flushing map[sessionKey]bool // 置位时暂存正在排空的 session;排空完成前推送继续进 pending 保序 lifecycle SessionLifecycleObserver log *zap.Logger @@ -73,12 +98,14 @@ func NewSessionManager(log *zap.Logger) *SessionManager { bySession: make(map[sessionKey]*Conn), bySessionID: make(map[int64]map[[8]byte]*Conn), byAuthKey: make(map[[8]byte]map[int64]*Conn), + byBusinessAuthKey: make(map[[8]byte]map[sessionKey]*Conn), byUser: make(map[int64]map[sessionKey]*Conn), byChannel: make(map[int64]map[sessionKey]int64), bySessionChannels: make(map[sessionKey]map[int64]struct{}), byMemberChannel: make(map[int64]map[sessionKey]int64), bySessionMembers: make(map[sessionKey]map[int64]struct{}), pending: make(map[sessionKey][]queuedPush), + flushing: make(map[sessionKey]bool), log: log, } } @@ -96,13 +123,30 @@ func (m *SessionManager) Register(c *Conn) { key := connSessionKey(c) var replaced *Conn + var evicted *Conn if old, ok := m.bySession[key]; ok && old != c { replaced = old m.removeLocked(old, false) + } else if existing := m.byAuthKey[c.authKeyID]; len(existing) >= maxSessionsPerAuthKey { + // 同 raw auth_key 的 session 数达上限且本次是新 session:驱逐一个现有 session 让位, + // 防对抗客户端用海量 session_id 撑爆索引。驱逐对象与新连接同属一个设备凭据, + // 触顶基本是该凭据自身异常。被驱逐连接的 serveConn 会在下一帧因 actor 已关而退出。 + for _, ec := range existing { + evicted = ec + m.removeLocked(ec, true) + break + } + m.log.Debug("Evicted oldest session for auth key at cap", + zap.String("auth_key_id", sessionKeyLog(c.authKeyID)), + zap.Int("cap", maxSessionsPerAuthKey), + ) } m.bySession[key] = c addSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID, c) addConnIndex(m.byAuthKey, c.authKeyID, c.sessionID, c) + if businessAuthKeyID, resolved := c.BusinessAuthKeyID(); resolved { + addBusinessAuthKeyIndex(m.byBusinessAuthKey, businessAuthKeyID, key, c) + } if uid := c.userID.Load(); uid != 0 { c.userIDResolved.Store(true) addUserIndex(m.byUser, uid, key, c) @@ -117,9 +161,14 @@ func (m *SessionManager) Register(c *Conn) { if replaced != nil { replaced.Close() } + if evicted != nil { + evicted.Close() + } } // Unregister 注销一个连接(仅当它仍是当前注册的同一对象,避免误删重连后的新连接)。 +// 观察者对未登录连接(userID=0)也回调:业务层据此清理按 session 维度的缓存条目, +// 否则未登录连接的元数据只能等容量上限驱逐。 func (m *SessionManager) Unregister(c *Conn) { m.mu.Lock() var ( @@ -131,8 +180,8 @@ func (m *SessionManager) Unregister(c *Conn) { offlineUser = m.removeLocked(c, true) if offlineUser != 0 { lastForUser = len(m.byUser[offlineUser]) == 0 - observer = m.lifecycle } + observer = m.lifecycle m.log.Debug("Session unregistered", zap.String("auth_key_id", sessionKeyLog(c.authKeyID)), zap.Int64("session_id", c.sessionID), @@ -140,7 +189,7 @@ func (m *SessionManager) Unregister(c *Conn) { ) } m.mu.Unlock() - if observer != nil && offlineUser != 0 { + if observer != nil { observer.SessionOffline(c.authKeyID, c.sessionID, offlineUser, lastForUser) } } @@ -232,6 +281,11 @@ func (m *SessionManager) bindUserLocked(c *Conn, key sessionKey, userID int64) { if old != userID { m.clearChannelInterestsLocked(key) m.clearChannelMembershipsLocked(key) + c.membershipsSynced.Store(false) + // 身份变化即丢弃暂存推送:它们属于前一个账号,flush 给新账号是跨账号泄露。 + // 同时取消进行中的排空(runFlush 还另有 owner 校验做批内兜底)。 + delete(m.pending, key) + delete(m.flushing, key) } } c.userIDResolved.Store(true) @@ -240,6 +294,9 @@ func (m *SessionManager) bindUserLocked(c *Conn, key sessionKey, userID int64) { } else { m.clearChannelInterestsLocked(key) m.clearChannelMembershipsLocked(key) + c.membershipsSynced.Store(false) + delete(m.pending, key) + delete(m.flushing, key) } } @@ -327,13 +384,20 @@ func (m *SessionManager) bindAuthKeyLocked(c *Conn, key sessionKey, authKeyID [8 oldAuthKeyID, resolved := c.BusinessAuthKeyID() changed := !resolved || oldAuthKeyID != authKeyID oldUserID := c.userID.Load() + if resolved { + removeBusinessAuthKeyIndex(m.byBusinessAuthKey, oldAuthKeyID, key) + } c.SetBusinessAuthKeyID(authKeyID) + addBusinessAuthKeyIndex(m.byBusinessAuthKey, authKeyID, key, c) if changed { if oldUserID != 0 { removeUserIndex(m.byUser, oldUserID, key) } m.clearChannelInterestsLocked(key) m.clearChannelMembershipsLocked(key) + c.membershipsSynced.Store(false) + delete(m.pending, key) + delete(m.flushing, key) c.userID.Store(0) c.userIDResolved.Store(false) } @@ -362,12 +426,87 @@ func (m *SessionManager) AuthKeyIDForSession(rawAuthKeyID [8]byte, sessionID int return c.BusinessAuthKeyID() } +// CloseSessionsForBusinessAuthKey 强制断开指定业务 auth_key 的全部活跃连接, +// 供授权撤销(被踢设备)使用:出站推送用连接持有的密钥加密、不回查密钥库, +// 不断开的话被撤销的设备会继续收到推送直至自然断线;perm-key 连接的授权 +// 缓存也只有断开重连才会重新回查授权表。这里必须关闭底层 transport, +// 让 WebSocket/TCP 对端马上看到断线,而不是只从在线索引摘除。 +func (m *SessionManager) CloseSessionsForBusinessAuthKey(authKeyID [8]byte) int { + type offlineEvent struct { + key sessionKey + userID int64 + last bool + } + m.mu.Lock() + var conns []*Conn + var events []offlineEvent + for key, c := range m.businessAuthKeyCandidatesLocked(authKeyID) { + if !connUsesBusinessAuthKey(c, authKeyID) { + continue + } + uid := m.removeLocked(c, true) + conns = append(conns, c) + events = append(events, offlineEvent{key: key, userID: uid, last: uid != 0 && len(m.byUser[uid]) == 0}) + } + observer := m.lifecycle + if len(conns) > 0 { + m.log.Debug("Force close sessions for revoked auth key", + zap.String("auth_key_id", sessionKeyLog(authKeyID)), + zap.Int("closed", len(conns)), + ) + } + m.mu.Unlock() + for _, c := range conns { + c.ForceClose() + } + if observer != nil { + for _, e := range events { + observer.SessionOffline(e.key.authKeyID, e.key.sessionID, e.userID, e.last) + } + } + return len(conns) +} + +// CloseSessionsForRawAuthKeyExcept 强制断开指定 raw auth_key 的活跃连接,可排除 +// 一个 session(destroy_auth_key 的发起连接:响应要先送达,它的密钥已删,下一帧 +// 自然失效)。出站推送不回查密钥库,必须主动断开底层 transport 才能让销毁立即生效。 +func (m *SessionManager) CloseSessionsForRawAuthKeyExcept(authKeyID [8]byte, exceptSessionID int64) int { + type offlineEvent struct { + key sessionKey + userID int64 + last bool + } + m.mu.Lock() + var conns []*Conn + var events []offlineEvent + for sessionID, c := range m.byAuthKey[authKeyID] { + if sessionID == exceptSessionID { + continue + } + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + uid := m.removeLocked(c, true) + conns = append(conns, c) + events = append(events, offlineEvent{key: key, userID: uid, last: uid != 0 && len(m.byUser[uid]) == 0}) + } + observer := m.lifecycle + m.mu.Unlock() + for _, c := range conns { + c.ForceClose() + } + if observer != nil { + for _, e := range events { + observer.SessionOffline(e.key.authKeyID, e.key.sessionID, e.userID, e.last) + } + } + return len(conns) +} + // UnbindAuthKey 清理某业务 auth_key 下所有活跃连接的登录用户缓存。 func (m *SessionManager) UnbindAuthKey(authKeyID [8]byte) int { m.mu.Lock() defer m.mu.Unlock() count := 0 - for key, c := range m.bySession { + for key, c := range m.businessAuthKeyCandidatesLocked(authKeyID) { if !connUsesBusinessAuthKey(c, authKeyID) { continue } @@ -376,6 +515,10 @@ func (m *SessionManager) UnbindAuthKey(authKeyID [8]byte) int { } m.clearChannelInterestsLocked(key) m.clearChannelMembershipsLocked(key) + c.membershipsSynced.Store(false) + // 授权解除后暂存推送属于已登出的账号,不能等下一个登录者置位时 flush 出去。 + delete(m.pending, key) + delete(m.flushing, key) c.userIDResolved.Store(true) count++ } @@ -396,19 +539,146 @@ func (m *SessionManager) SetReceivesUpdates(sessionID int64, receives bool) { m.mu.Unlock() return } - c.receivesUpdates.Store(receives) - if !receives { - m.clearChannelInterestsLocked(key) - m.clearChannelMembershipsLocked(key) - } - pending := m.takePendingLocked(key, receives) + owner, start := m.setReceivesUpdatesLocked(c, key, receives) m.mu.Unlock() - if len(pending) > 0 { - go m.flushPending(key, pending) + if start { + go m.runFlush(c, key, owner, 0) } } +// setReceivesUpdatesLocked 是置位/复位的共同内核,调用方须持有 m.mu。 +// 置位且有暂存时不立即置 receivesUpdates:标记 flushing 并返回该批暂存所属的 userID, +// 交由 runFlush 排空后原子置位,期间新到推送继续进 pending,保证暂存与实时推送的 +// 相对顺序(否则实时直发可能先于更早 pts 的暂存条目落线)。返回的 owner 让 runFlush +// 能识别排空期间的身份切换(登出/换号),丢弃属于旧账号的剩余暂存而不发给新账号。 +func (m *SessionManager) setReceivesUpdatesLocked(c *Conn, key sessionKey, receives bool) (int64, bool) { + if !receives { + c.receivesUpdates.Store(false) + m.clearChannelInterestsLocked(key) + m.clearChannelMembershipsLocked(key) + c.membershipsSynced.Store(false) + // 取消进行中的排空激活:runFlush 在置位前会复查该标志,标志已删则放弃置位, + // 避免把刚置 false 的开关翻回 true。 + delete(m.flushing, key) + return 0, false + } + if c.receivesUpdates.Load() || m.flushing[key] { + // 已就绪,或已有排空协程在跑(完成时会自行取走新增暂存并置位)。 + return 0, false + } + if len(m.pending[key]) == 0 { + c.receivesUpdates.Store(true) + return 0, false + } + m.flushing[key] = true + return c.userID.Load(), true +} + +// runFlush 把暂存推送按序直发到连接,排空(含排空期间新增)后才置位 receivesUpdates。 +// 直发用 c.Send 绕过 ready 检查——此刻必然未就绪,走 PushToSessionForAuthKey 会被 +// 重新暂存形成死循环。三类终止: +// - 身份切换(登出/换号致 c.userID != owner):丢弃剩余暂存与回排数据,不发给新账号; +// - 发送失败:回排剩余并退避重试,attempt 用尽则置位激活、靠 getDifference 兜底, +// 避免 idle 客户端永久停在未激活态; +// - 排空完毕:原子置位 receivesUpdates。 +func (m *SessionManager) runFlush(c *Conn, key sessionKey, owner int64, attempt int) { + for { + m.mu.Lock() + if cur, ok := m.bySession[key]; !ok || cur != c || !m.flushing[key] { + // 连接已换代(removeLocked 已清 flushing)或激活被取消(SetReceivesUpdates(false))。 + m.mu.Unlock() + return + } + if c.userID.Load() != owner { + // 排空期间发生登出/换号:剩余暂存属于旧账号,丢弃且不得发给新账号。 + delete(m.pending, key) + delete(m.flushing, key) + m.mu.Unlock() + return + } + batch := m.takePendingLocked(key, true) + if len(batch) == 0 { + c.receivesUpdates.Store(true) + delete(m.flushing, key) + m.mu.Unlock() + return + } + m.mu.Unlock() + + for i, item := range batch { + // 每条发送前复查身份:登出/换号后 batch 的剩余条目不能继续发到已易主的连接。 + if c.userID.Load() != owner { + m.mu.Lock() + delete(m.pending, key) + delete(m.flushing, key) + m.mu.Unlock() + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err := c.Send(ctx, item.t, item.msg) + cancel() + if err == nil { + continue + } + m.mu.Lock() + if cur, ok := m.bySession[key]; !ok || cur != c || !m.flushing[key] || c.userID.Load() != owner { + // 连接换代/取消/易主:剩余 batch 不属于当前连接当前账号,丢弃。 + if c.userID.Load() != owner { + delete(m.pending, key) + delete(m.flushing, key) + } + m.mu.Unlock() + return + } + rest := append(append([]queuedPush(nil), batch[i:]...), m.pending[key]...) + if len(rest) > maxPendingPushesPerSession { + // 与 queueLocked 溢出策略一致:丢最旧留最新,让 pts 空洞集中在最前端, + // flush 首条即触发客户端 gap 检测,恢复路径最短。 + rest = rest[len(rest)-maxPendingPushesPerSession:] + } + m.pending[key] = rest + if attempt+1 >= maxFlushAttempts { + // 重试用尽:置位激活避免 idle 客户端永久断流;剩余暂存中的 durable 更新 + // 由客户端后续 pts 空洞触发 getDifference 补齐。 + c.receivesUpdates.Store(true) + delete(m.flushing, key) + m.mu.Unlock() + m.log.Debug("Flush gave up after retries; activated with getDifference fallback", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", key.sessionID), + zap.Int("requeued", len(rest)), + ) + return + } + m.mu.Unlock() + m.log.Debug("Flush pending push failed; backoff retry", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", key.sessionID), + zap.Int("attempt", attempt+1), + zap.Int("requeued", len(rest)), + zap.Error(err), + ) + time.AfterFunc(flushRetryBackoff*time.Duration(attempt+1), func() { + m.runFlush(c, key, owner, attempt+1) + }) + return + } + // 本批发完,循环回去 re-take 排空期间新增的暂存。 + } +} + +// ReceivesUpdatesForAuthKey 报告指定 raw auth_key_id + session_id 的连接是否已完全就绪: +// 既接收主动 updates,channel membership 推送路由也已成功建立。无活跃连接时返回 false。 +// 返回 false 会让按 RPC 置位的短路放行,下一条 RPC 重试 membership 同步—— +// 否则同步失败的 session 会以「已置位但 byMemberChannel 缺失」的状态静默漏收超级群推送。 +func (m *SessionManager) ReceivesUpdatesForAuthKey(authKeyID [8]byte, sessionID int64) bool { + m.mu.RLock() + c, ok := m.bySession[sessionKey{authKeyID: authKeyID, sessionID: sessionID}] + m.mu.RUnlock() + return ok && c.receivesUpdates.Load() && c.membershipsSynced.Load() +} + // SetReceivesUpdatesForAuthKey 标记指定 raw auth_key_id + session_id 是否接收主动 updates。 func (m *SessionManager) SetReceivesUpdatesForAuthKey(authKeyID [8]byte, sessionID int64, receives bool) { m.mu.Lock() @@ -418,16 +688,11 @@ func (m *SessionManager) SetReceivesUpdatesForAuthKey(authKeyID [8]byte, session m.mu.Unlock() return } - c.receivesUpdates.Store(receives) - if !receives { - m.clearChannelInterestsLocked(key) - m.clearChannelMembershipsLocked(key) - } - pending := m.takePendingLocked(key, receives) + owner, start := m.setReceivesUpdatesLocked(c, key, receives) m.mu.Unlock() - if len(pending) > 0 { - go m.flushPending(key, pending) + if start { + go m.runFlush(c, key, owner, 0) } } @@ -470,6 +735,22 @@ func (m *SessionManager) PushToSessionForAuthKey(ctx context.Context, authKeyID return c.Send(ctx, t, msg) } +// PushToSessionForAuthKeyImmediate 向指定 raw auth_key_id + session_id 立即推送一条消息。 +// +// 它不等待该 session 进入 updates-ready,也不写 pending 队列。仅用于登录前的握手信号 +// (例如 updateLoginToken):这类消息本身就是让客户端继续完成登录的触发器,若走普通 +// durable update 队列会卡在客户端尚未调用 updates.getState 的阶段。 +func (m *SessionManager) PushToSessionForAuthKeyImmediate(ctx context.Context, authKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error { + m.mu.RLock() + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + c, ok := m.bySession[key] + m.mu.RUnlock() + if !ok { + return ErrSessionNotFound + } + return c.SendBestEffort(ctx, t, msg, 2*time.Second) +} + // PushToUser 向某 user 所有活跃连接推送,返回已发送或已暂存的连接数。 // 发送在释放锁后进行,避免持锁阻塞于网络 IO。 func (m *SessionManager) PushToUser(ctx context.Context, userID int64, t proto.MessageType, msg bin.Encoder) (int, error) { @@ -487,9 +768,14 @@ func (m *SessionManager) PushToUserExceptAuthKeySession(ctx context.Context, use return m.pushToUser(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, msg) } -func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { +// PushToUserAuthKey 把 msg 定向投递给【绑定到 businessAuthKeyID 这台具体设备】且属于 +// userID 的就绪连接(密聊设备级投递的锚点)。索引走 byBusinessAuthKey(经 +// businessAuthKeyCandidatesLocked,兼容 temp-key/PFS 连接),不是 byAuthKey(raw 索引会 +// 漏 temp-key 设备)。未就绪连接跳过、不进 pending——密聊消息 durable 在 qts 队列, +// 离线设备靠 getDifference 补回(在线推送只是加速器)。c.userID 复查防跨账号泄露。 +func (m *SessionManager) PushToUserAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg bin.Encoder) (int, error) { getEncoded := onceEncodedOutbound(msg) - return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, msg, func(c *Conn) error { + return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, false, func(c *Conn) error { if c.outbound == nil || c.outboundControl == nil { return ErrConnClosed } @@ -501,6 +787,88 @@ func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAu }) } +// PushToUserAuthKeyTransient 是 PushToUserAuthKey 的 transient(typing)best-effort 版本。 +func (m *SessionManager) PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { + getEncoded := onceEncodedOutbound(msg) + return m.pushToBusinessAuthKey(ctx, userID, businessAuthKeyID, true, func(c *Conn) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + encoded, err := getEncoded() + if err != nil { + return err + } + return c.SendBestEffortEncoded(ctx, t, encoded, timeout) + }) +} + +func (m *SessionManager) pushToBusinessAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, transient bool, send func(*Conn) error) (int, error) { + m.mu.Lock() + candidates := m.businessAuthKeyCandidatesLocked(businessAuthKeyID) + conns := make([]*Conn, 0, len(candidates)) + for _, c := range candidates { + if c.userID.Load() != userID { + continue + } + if !c.receivesUpdates.Load() { + // 未就绪:密聊消息靠 getDifference 补,typing 直接丢——都不进 pending。 + continue + } + conns = append(conns, c) + } + m.mu.Unlock() + _ = transient + var firstErr error + sent := 0 + for _, c := range conns { + // 锁外发送前复查身份,防收集后并发换绑导致跨账号泄露。 + if c.userID.Load() != userID { + continue + } + if err := send(c); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + sent++ + } + return sent, firstErr +} + +func (m *SessionManager) pushToUser(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + getEncoded := onceEncodedOutbound(msg) + return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, msg, true, func(c *Conn) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + encoded, err := getEncoded() + if err != nil { + return err + } + return c.SendEncoded(ctx, t, encoded) + }) +} + +// PushToUserTransientExceptAuthKeySession 推送 transient(短命、不写 durable log)update, +// 如 typing / presence。与普通推送的关键区别:session 未就绪(receivesUpdates=false)时直接 +// 跳过该连接、不进 pending——transient 数据 getDifference 无法补,就绪后由 getState 快照 / +// 下一次状态变化重建,囤积过期 transient 既无意义又会被 pending 的老化/溢出/重试耗尽误当 +// 「durable 兜底」丢弃。走 best-effort 发送,不阻塞调用方。 +func (m *SessionManager) PushToUserTransientExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { + getEncoded := onceEncodedOutbound(msg) + return m.pushToUserWithSender(ctx, userID, &excludeAuthKeyID, excludeSessionID, t, msg, false, func(c *Conn) error { + if c.outbound == nil || c.outboundControl == nil { + return ErrConnClosed + } + encoded, err := getEncoded() + if err != nil { + return err + } + return c.SendBestEffortEncoded(ctx, t, encoded, timeout) + }) +} + func (m *SessionManager) PushToUserExceptSessionBestEffort(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { return m.pushToUserBestEffort(ctx, userID, nil, excludeSessionID, t, msg, timeout) } @@ -511,7 +879,7 @@ func (m *SessionManager) PushToUserExceptAuthKeySessionBestEffort(ctx context.Co func (m *SessionManager) pushToUserBestEffort(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) { getEncoded := onceEncodedOutbound(msg) - return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, msg, func(c *Conn) error { + return m.pushToUserWithSender(ctx, userID, excludeAuthKeyID, excludeSessionID, t, msg, true, func(c *Conn) error { if c.outbound == nil || c.outboundControl == nil { return ErrConnClosed } @@ -536,17 +904,41 @@ func onceEncodedOutbound(msg bin.Encoder) func() (*encodedOutboundMessage, error } } -func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, send func(*Conn) error) (int, error) { +func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, excludeAuthKeyID *[8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, queueWhenNotReady bool, send func(*Conn) error) (int, error) { m.mu.Lock() - conns := make([]*Conn, 0, len(m.byUser[userID])) + total := len(m.byUser[userID]) + conns := make([]*Conn, 0, total) queued := 0 + dropped := 0 + excluded := 0 + skipped := 0 for key, c := range m.byUser[userID] { if shouldExcludeSession(c, excludeAuthKeyID, excludeSessionID) { + excluded++ continue } if !c.receivesUpdates.Load() { - m.queueLocked(key, t, msg) - queued++ + if !queueWhenNotReady { + // transient(typing/presence):未就绪即丢,不进 pending。这些 update 不写 + // durable log,getDifference 无法补;就绪后由 getState 快照/下次状态变化重建。 + skipped++ + continue + } + if m.queueLocked(key, t, msg) { + queued++ + m.log.Debug("Push queued (session not updates-ready)", + zap.Int64("user_id", userID), + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", key.sessionID), + ) + } else { + dropped++ + m.log.Debug("Push dropped (stale pending; durable log covers)", + zap.Int64("user_id", userID), + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", key.sessionID), + ) + } continue } conns = append(conns, c) @@ -556,13 +948,43 @@ func (m *SessionManager) pushToUserWithSender(ctx context.Context, userID int64, var firstErr error sent := 0 for _, c := range conns { + // 锁外发送前复查身份:收集 conns 到此刻之间,连接可能被并发换绑(登出/换号, + // bindUserLocked 的 c.userID.Swap)。不复查会把本属于 userID 的 update 投递到 + // 已易主的连接,构成跨账号泄露。与 AddUserChannelMembership 的同款防御一致。 + if c.userID.Load() != userID { + continue + } if err := send(c); err != nil { if firstErr == nil { firstErr = err } + m.log.Debug("Push to conn failed", + zap.Int64("user_id", userID), + zap.String("auth_key_id", sessionKeyLog(c.authKeyID)), + zap.Int64("session_id", c.sessionID), + zap.Error(err), + ) continue } sent++ + m.log.Debug("Push to conn ok", + zap.Int64("user_id", userID), + zap.String("auth_key_id", sessionKeyLog(c.authKeyID)), + zap.Int64("session_id", c.sessionID), + ) + } + if total == 0 { + m.log.Debug("Push to user: no active conns", zap.Int64("user_id", userID)) + } else if excluded > 0 || queued > 0 || dropped > 0 || skipped > 0 || sent < len(conns) { + m.log.Debug("Push to user summary", + zap.Int64("user_id", userID), + zap.Int("conns", total), + zap.Int("sent", sent), + zap.Int("queued", queued), + zap.Int("dropped", dropped), + zap.Int("skipped_transient", skipped), + zap.Int("excluded", excluded), + ) } return sent + queued, firstErr } @@ -574,31 +996,6 @@ func (m *SessionManager) Online() int { return len(m.bySession) } -// OnlineUserIDs returns a bounded snapshot of users that currently have active -// sessions. Callers still need to verify business visibility before pushing. -func (m *SessionManager) OnlineUserIDs(limit int) []int64 { - m.mu.RLock() - defer m.mu.RUnlock() - if len(m.byUser) == 0 { - return nil - } - capHint := len(m.byUser) - if limit > 0 && capHint > limit { - capHint = limit - } - ids := make([]int64, 0, capHint) - for userID, conns := range m.byUser { - if userID == 0 || len(conns) == 0 { - continue - } - ids = append(ids, userID) - if limit > 0 && len(ids) >= limit { - break - } - } - return ids -} - // IsUserOnline returns whether userID has at least one active connection. func (m *SessionManager) IsUserOnline(userID int64) bool { if userID == 0 { @@ -693,10 +1090,12 @@ func (m *SessionManager) SetSessionChannelMemberships(rawAuthKeyID [8]byte, sess return } m.clearChannelMembershipsLocked(key) + c.membershipsSynced.Store(false) if userID == 0 || c.userID.Load() != userID { return } m.trackChannelIndexLocked(m.byMemberChannel, m.bySessionMembers, key, userID, channelIDs) + c.membershipsSynced.Store(true) } // AddUserChannelMembership adds channelID to every live session for userID. @@ -735,6 +1134,47 @@ func (m *SessionManager) OnlineChannelMemberUserIDs(channelID int64, limit int) return m.onlineChannelUsers(m.byMemberChannel, channelID, limit) } +// OnlineChannelMemberUserIDsExcluding 返回频道在线成员中不在 exclude 集合内的 user id, +// 用于 >cap 在线成员的 UpdateChannelTooLong nudge(P0-8):完整 payload 已投递给 exclude +// 集合(cap 内成员),其余在线成员只发廉价 nudge 促其 getChannelDifference。单次 RLock 快照; +// 由调用方用「已收完整 payload 的 recipients」构造 exclude,使同一 user 不会既收 payload 又收 +// nudge——天然规避两次独立 cap 调用的边界双投/漏投(设计 §8-D3/D32)。limit 防一次无界 nudge 风暴。 +// 不做 PG active 复核:byMemberChannel 已在 join/leave/kick 维护;nudge 廉价且幂等,对刚离开成员 +// 的多余 nudge 无害(其 getChannelDifference 自带访问校验)。 +func (m *SessionManager) OnlineChannelMemberUserIDsExcluding(channelID int64, exclude map[int64]struct{}, limit int) []int64 { + if channelID == 0 { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + sessions := m.byMemberChannel[channelID] + if len(sessions) == 0 { + return nil + } + out := make([]int64, 0) + seen := make(map[int64]struct{}, len(sessions)) + for key, userID := range sessions { + if userID == 0 { + continue + } + if _, ok := exclude[userID]; ok { + continue + } + if _, ok := m.bySession[key]; !ok { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + out = append(out, userID) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + func (m *SessionManager) onlineChannelUsers(index map[int64]map[sessionKey]int64, channelID int64, limit int) []int64 { if channelID == 0 { return nil @@ -771,6 +1211,9 @@ func (m *SessionManager) removeLocked(c *Conn, dropPending bool) int64 { delete(m.bySession, key) removeSessionIDIndex(m.bySessionID, c.sessionID, c.authKeyID) removeConnIndex(m.byAuthKey, c.authKeyID, c.sessionID) + if businessAuthKeyID, resolved := c.BusinessAuthKeyID(); resolved { + removeBusinessAuthKeyIndex(m.byBusinessAuthKey, businessAuthKeyID, key) + } uid := c.userID.Load() if uid != 0 { removeUserIndex(m.byUser, uid, key) @@ -780,9 +1223,26 @@ func (m *SessionManager) removeLocked(c *Conn, dropPending bool) int64 { if dropPending { delete(m.pending, key) } + delete(m.flushing, key) return uid } +func (m *SessionManager) businessAuthKeyCandidatesLocked(authKeyID [8]byte) map[sessionKey]*Conn { + out := make(map[sessionKey]*Conn, len(m.byBusinessAuthKey[authKeyID])+len(m.byAuthKey[authKeyID])) + for key, c := range m.byBusinessAuthKey[authKeyID] { + if cur := m.bySession[key]; cur == c { + out[key] = c + } + } + for sessionID, c := range m.byAuthKey[authKeyID] { + key := sessionKey{authKeyID: authKeyID, sessionID: sessionID} + if cur := m.bySession[key]; cur == c { + out[key] = c + } + } + return out +} + func (m *SessionManager) clearChannelInterestsLocked(key sessionKey) { m.clearChannelIndexLocked(m.byChannel, m.bySessionChannels, key) } @@ -797,10 +1257,17 @@ func (m *SessionManager) trackChannelIndexLocked(index map[int64]map[sessionKey] channels = make(map[int64]struct{}, len(channelIDs)) reverse[key] = channels } + truncated := 0 for _, channelID := range channelIDs { if channelID == 0 { continue } + if _, exists := channels[channelID]; !exists && len(channels) >= maxChannelIndexPerSession { + // 达 per-session 上限:丢弃多出的 channel 登记(仅影响该 channel 的实时/成员 + // 推送路由,durable update 仍由 getDifference/getChannelDifference 兜底)。 + truncated++ + continue + } channels[channelID] = struct{}{} sessions := index[channelID] if sessions == nil { @@ -809,6 +1276,14 @@ func (m *SessionManager) trackChannelIndexLocked(index map[int64]map[sessionKey] } sessions[key] = userID } + if truncated > 0 { + m.log.Warn("Channel index truncated for session at per-session cap", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", key.sessionID), + zap.Int("cap", maxChannelIndexPerSession), + zap.Int("truncated", truncated), + ) + } } func (m *SessionManager) clearChannelIndexLocked(index map[int64]map[sessionKey]int64, reverse map[sessionKey]map[int64]struct{}, key sessionKey) { @@ -858,12 +1333,34 @@ func (m *SessionManager) takePendingLocked(key sessionKey, ready bool) []queuedP if !ready || len(m.pending[key]) == 0 { return nil } - pending := append([]queuedPush(nil), m.pending[key]...) + q := m.pending[key] delete(m.pending, key) + // 取出时过滤超龄条目:暂存只为弥合「注册到就绪」的窗口,迟迟未就绪期间 + // 囤下的过时 update(含 transient 类)不应在多分钟后原样下发;durable 事件 + // 由 user_update_events + getDifference 兜底,丢弃不丢数据。 + now := time.Now() + pending := make([]queuedPush, 0, len(q)) + dropped := 0 + for _, item := range q { + if now.Sub(item.at) > pendingPushMaxAge { + dropped++ + continue + } + pending = append(pending, item) + } + if dropped > 0 { + m.log.Debug("Drop stale pending pushes on take", + zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), + zap.Int64("session_id", key.sessionID), + zap.Int("dropped", dropped), + ) + } return pending } -func (m *SessionManager) queueLocked(key sessionKey, t proto.MessageType, msg bin.Encoder) { +// queueLocked 暂存一条主动推送,返回是否实际入队——stale 丢批分支会连同当前 +// 这条一起丢弃,调用方据此区分 queued/dropped 计数,避免投递日志失真。 +func (m *SessionManager) queueLocked(key sessionKey, t proto.MessageType, msg bin.Encoder) bool { q := m.pending[key] // 过期保护:最早一条暂存已超过 pendingPushMaxAge(session 迟迟未 ready)时,丢整批并 // 不再囤这条,记 trace。避免「登录后从不 getState」的连接长期占用 pending 内存。 @@ -874,31 +1371,17 @@ func (m *SessionManager) queueLocked(key sessionKey, t proto.MessageType, msg bi zap.Int("dropped", len(q)), ) delete(m.pending, key) - return + return false } push := queuedPush{t: t, msg: msg, at: time.Now()} if len(q) >= maxPendingPushesPerSession { copy(q, q[1:]) q[len(q)-1] = push m.pending[key] = q - return + return true } m.pending[key] = append(q, push) -} - -func (m *SessionManager) flushPending(key sessionKey, pending []queuedPush) { - for _, item := range pending { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - err := m.PushToSessionForAuthKey(ctx, key.authKeyID, key.sessionID, item.t, item.msg) - cancel() - if err != nil { - m.log.Debug("Flush pending push failed", - zap.String("auth_key_id", sessionKeyLog(key.authKeyID)), - zap.Int64("session_id", key.sessionID), - zap.Error(err), - ) - } - } + return true } func (m *SessionManager) uniqueSessionLocked(sessionID int64) (*Conn, sessionKey, bool, bool) { @@ -923,6 +1406,47 @@ func (m *SessionManager) dropPendingBySessionLocked(sessionID int64) { } } +// RunPendingSweeper 周期回收长期滞留的 pending 暂存:被动老化(queueLocked/takePendingLocked) +// 只在「有新推送」或「就绪后取出」时触发,对「已注册但迟迟不调 getState、又恰好没有新推送、 +// 也不断连(持续 ping 保活)」的连接无法回收其超龄 pending。本 sweeper 给出一个主动兜底, +// 与 pendingPushMaxAge 阈值一致,仅丢整批超龄、不触碰正在排空(flushing)的 session。 +func (m *SessionManager) RunPendingSweeper(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = time.Minute + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + m.sweepStalePending() + } +} + +func (m *SessionManager) sweepStalePending() { + now := time.Now() + m.mu.Lock() + defer m.mu.Unlock() + dropped := 0 + for key, q := range m.pending { + if m.flushing[key] { + // 排空协程拥有该批,回收交给 runFlush,避免与其竞态。 + continue + } + if len(q) == 0 || now.Sub(q[0].at) <= pendingPushMaxAge { + continue + } + delete(m.pending, key) + dropped++ + } + if dropped > 0 { + m.log.Debug("Swept stale pending sessions", zap.Int("dropped_sessions", dropped)) + } +} + func addConnIndex[K comparable](idx map[K]map[int64]*Conn, key K, sessionID int64, c *Conn) { set := idx[key] if set == nil { @@ -941,6 +1465,24 @@ func removeConnIndex[K comparable](idx map[K]map[int64]*Conn, key K, sessionID i } } +func addBusinessAuthKeyIndex(idx map[[8]byte]map[sessionKey]*Conn, authKeyID [8]byte, key sessionKey, c *Conn) { + set := idx[authKeyID] + if set == nil { + set = make(map[sessionKey]*Conn) + idx[authKeyID] = set + } + set[key] = c +} + +func removeBusinessAuthKeyIndex(idx map[[8]byte]map[sessionKey]*Conn, authKeyID [8]byte, key sessionKey) { + if set := idx[authKeyID]; set != nil { + delete(set, key) + if len(set) == 0 { + delete(idx, authKeyID) + } + } +} + func addSessionIDIndex(idx map[int64]map[[8]byte]*Conn, sessionID int64, authKeyID [8]byte, c *Conn) { set := idx[sessionID] if set == nil { diff --git a/internal/mtprotoedge/session_manager_test.go b/internal/mtprotoedge/session_manager_test.go index 5660273f..c815b1a1 100644 --- a/internal/mtprotoedge/session_manager_test.go +++ b/internal/mtprotoedge/session_manager_test.go @@ -23,6 +23,23 @@ func (e *countingOutboundEncoder) Encode(b *bin.Buffer) error { return (&tg.UpdatesTooLong{}).Encode(b) } +type closeCountingTransport struct { + closes int +} + +func (t *closeCountingTransport) Send(context.Context, *bin.Buffer) error { + return errors.New("test transport send") +} + +func (t *closeCountingTransport) Recv(context.Context, *bin.Buffer) error { + return errors.New("test transport recv") +} + +func (t *closeCountingTransport) Close() error { + t.closes++ + return nil +} + // TestSessionManagerRegistry 验证注册表的注册/注销/查找语义(不涉及网络发送)。 func TestSessionManagerRegistry(t *testing.T) { sm := NewSessionManager(zaptest.NewLogger(t)) @@ -146,6 +163,81 @@ func TestSessionManagerScopesSameSessionIDByAuthKey(t *testing.T) { } } +func TestSessionManagerCloseSessionsForBusinessAuthKeyClosesBoundTempAndRaw(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + rawTemp := [8]byte{1} + perm := [8]byte{9} + otherRaw := [8]byte{2} + otherPerm := [8]byte{8} + tempTransport := &closeCountingTransport{} + permTransport := &closeCountingTransport{} + otherTransport := &closeCountingTransport{} + cTemp := &Conn{sessionID: 11, authKeyID: rawTemp, transport: tempTransport} + cPerm := &Conn{sessionID: 12, authKeyID: perm, transport: permTransport} + cOther := &Conn{sessionID: 13, authKeyID: otherRaw} + cOther.transport = otherTransport + + sm.Register(cTemp) + sm.Register(cPerm) + sm.Register(cOther) + sm.BindAuthKeyForSession(rawTemp, 11, perm) + sm.BindAuthKeyForSession(perm, 12, perm) + sm.BindAuthKeyForSession(otherRaw, 13, otherPerm) + sm.BindUserForAuthKey(rawTemp, 11, 100) + sm.BindUserForAuthKey(perm, 12, 100) + sm.BindUserForAuthKey(otherRaw, 13, 200) + + if closed := sm.CloseSessionsForBusinessAuthKey(perm); closed != 2 { + t.Fatalf("closed sessions = %d, want 2", closed) + } + if tempTransport.closes != 1 || permTransport.closes != 1 { + t.Fatalf("transport closes temp=%d perm=%d, want 1/1", tempTransport.closes, permTransport.closes) + } + if otherTransport.closes != 0 { + t.Fatalf("other transport closes = %d, want 0", otherTransport.closes) + } + if got := sm.Online(); got != 1 { + t.Fatalf("online after close = %d, want 1", got) + } + if _, ok := sm.AuthKeyIDForSession(rawTemp, 11); ok { + t.Fatal("temp session still indexed after business auth key close") + } + if _, ok := sm.AuthKeyIDForSession(perm, 12); ok { + t.Fatal("raw perm session still indexed after business auth key close") + } + if userID, ok := sm.UserIDForAuthKey(otherRaw, 13); !ok || userID != 200 { + t.Fatalf("other session user = %d ok %v, want 200/true", userID, ok) + } + if closed := sm.CloseSessionsForBusinessAuthKey(perm); closed != 0 { + t.Fatalf("second close = %d, want 0", closed) + } +} + +func TestSessionManagerBusinessAuthKeyIndexTracksRebind(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + raw := [8]byte{1} + oldPerm := [8]byte{7} + newPerm := [8]byte{8} + c := &Conn{sessionID: 21, authKeyID: raw} + + sm.Register(c) + sm.BindAuthKeyForSession(raw, 21, oldPerm) + sm.BindAuthKeyForSession(raw, 21, newPerm) + + if closed := sm.CloseSessionsForBusinessAuthKey(oldPerm); closed != 0 { + t.Fatalf("close old business auth key = %d, want 0", closed) + } + if got := sm.Online(); got != 1 { + t.Fatalf("online after closing old key = %d, want 1", got) + } + if closed := sm.CloseSessionsForBusinessAuthKey(newPerm); closed != 1 { + t.Fatalf("close new business auth key = %d, want 1", closed) + } + if got := sm.Online(); got != 0 { + t.Fatalf("online after closing new key = %d, want 0", got) + } +} + func TestSessionManagerChannelInterestIndex(t *testing.T) { sm := NewSessionManager(zaptest.NewLogger(t)) raw := [8]byte{1, 2, 3} @@ -257,6 +349,40 @@ func TestSessionManagerClearsChannelIndexesOnAuthAndReadinessChanges(t *testing. assertCleared("after unbind auth key") } +func TestPushToSessionForAuthKeyImmediateBypassesReadinessQueue(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + raw := [8]byte{1, 2, 3} + c := &Conn{ + sessionID: 42, + authKeyID: raw, + outbound: make(chan outboundOp, 1), + outboundControl: make(chan outboundOp, 1), + outboundStop: make(chan struct{}), + } + sm.Register(c) + + msg := &tg.UpdateShort{Update: &tg.UpdateLoginToken{}, Date: 1700000000} + if err := sm.PushToSessionForAuthKeyImmediate(context.Background(), raw, 42, proto.MessageFromServer, msg); err != nil { + t.Fatalf("immediate push: %v", err) + } + + select { + case op := <-c.outbound: + if op.msg != msg { + t.Fatalf("enqueued msg = %T, want original update", op.msg) + } + case <-time.After(time.Second): + t.Fatal("immediate push was not enqueued") + } + + sm.mu.RLock() + pending := len(sm.pending[sessionKey{authKeyID: raw, sessionID: 42}]) + sm.mu.RUnlock() + if pending != 0 { + t.Fatalf("pending pushes = %d, want 0", pending) + } +} + // TestSessionManagerPush 验证主动推送端到端:两个 client 连接握手并建立 session 后, // server 经 PushToSession / PushToUser 主动向其推送,client 收到。 func TestSessionManagerPush(t *testing.T) { diff --git a/internal/mtprotoedge/session_ready_test.go b/internal/mtprotoedge/session_ready_test.go new file mode 100644 index 00000000..a326121c --- /dev/null +++ b/internal/mtprotoedge/session_ready_test.go @@ -0,0 +1,49 @@ +package mtprotoedge + +import ( + "testing" + + "go.uber.org/zap/zaptest" +) + +// TestReceivesUpdatesForAuthKeyRequiresMembershipSync 验证「完全就绪」查询同时要求 +// receivesUpdates 与 channel membership 路由建立成功。membership 同步失败时该查询 +// 必须返回 false,让按 RPC 置位的短路放行重试——否则该 session 会以「已置位但 +// byMemberChannel 缺失」的状态静默漏收超级群推送,且 channel 维度没有 pts 缺口 +// 信号可供客户端自愈。 +func TestReceivesUpdatesForAuthKeyRequiresMembershipSync(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + raw := [8]byte{1, 2, 3} + c := &Conn{sessionID: 42, authKeyID: raw} + sm.Register(c) + sm.BindUserForAuthKey(raw, 42, 100) + + sm.SetReceivesUpdatesForAuthKey(raw, 42, true) + if sm.ReceivesUpdatesForAuthKey(raw, 42) { + t.Fatal("ready before membership sync — a failed sync would never be retried") + } + + sm.SetSessionChannelMemberships(raw, 42, 100, []int64{7}) + if !sm.ReceivesUpdatesForAuthKey(raw, 42) { + t.Fatal("not ready after successful membership sync") + } + + // 没有任何频道的账号:空列表的成功同步同样算就绪。 + sm.SetSessionChannelMemberships(raw, 42, 100, nil) + if !sm.ReceivesUpdatesForAuthKey(raw, 42) { + t.Fatal("not ready after successful empty membership sync") + } + + // userID 与连接当前绑定不一致(换号竞态)时不算就绪,等正确身份重试。 + sm.SetSessionChannelMemberships(raw, 42, 999, []int64{7}) + if sm.ReceivesUpdatesForAuthKey(raw, 42) { + t.Fatal("ready after membership sync for mismatched user") + } + sm.SetSessionChannelMemberships(raw, 42, 100, []int64{7}) + + // 登出清除就绪标志。 + sm.BindUserForAuthKey(raw, 42, 0) + if sm.ReceivesUpdatesForAuthKey(raw, 42) { + t.Fatal("still ready after user unbind") + } +} diff --git a/internal/mtprotoedge/transient_push_test.go b/internal/mtprotoedge/transient_push_test.go new file mode 100644 index 00000000..1298ee69 --- /dev/null +++ b/internal/mtprotoedge/transient_push_test.go @@ -0,0 +1,53 @@ +package mtprotoedge + +import ( + "context" + "testing" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" +) + +// TestPushTransientSkipsNotReadySession 锁定不变量:transient 推送(typing/presence)对 +// 未就绪 session 直接跳过、不进 pending;而普通 durable 推送会进 pending。回归 transient +// updates 与 durable 共用 pending 队列、被老化/溢出/重试耗尽误丢且 getDifference 无法补的问题。 +func TestPushTransientSkipsNotReadySession(t *testing.T) { + sm := NewSessionManager(zaptest.NewLogger(t)) + const userID = int64(100) + c := &Conn{ + sessionID: 7, + authKeyID: [8]byte{7}, + outbound: make(chan outboundOp, 4), + outboundControl: make(chan outboundOp, 4), + outboundStop: make(chan struct{}), + } + c.userID.Store(userID) + c.userIDResolved.Store(true) + // receivesUpdates 保持 false:session 未就绪(尚未 getState 建立同步基线)。 + sm.Register(c) + key := connSessionKey(c) + + // transient:未就绪 → 跳过、不入队。 + if _, err := sm.PushToUserTransientExceptAuthKeySession(context.Background(), userID, [8]byte{}, 0, proto.MessageFromServer, &tg.UpdatesTooLong{}, 0); err != nil { + t.Fatalf("transient push: %v", err) + } + sm.mu.RLock() + n := len(sm.pending[key]) + sm.mu.RUnlock() + if n != 0 { + t.Fatalf("transient push queued %d pending, want 0 (must skip not-ready session)", n) + } + + // durable(普通):未就绪 → 入 pending(就绪后排空,丢弃时由 getDifference 兜底)。 + if _, err := sm.PushToUserExceptSession(context.Background(), userID, 0, proto.MessageFromServer, &tg.UpdatesTooLong{}); err != nil { + t.Fatalf("durable push: %v", err) + } + sm.mu.RLock() + n = len(sm.pending[key]) + sm.mu.RUnlock() + if n != 1 { + t.Fatalf("durable push queued %d pending, want 1", n) + } +} diff --git a/internal/mtprotoedge/transport_compat.go b/internal/mtprotoedge/transport_compat.go index 9ac2fc54..2d518873 100644 --- a/internal/mtprotoedge/transport_compat.go +++ b/internal/mtprotoedge/transport_compat.go @@ -45,6 +45,38 @@ func newCompatTransportListener(codec func() transport.Codec, listener net.Liste return &compatTransportListener{listener: listener} } +// singleConnListener 是一个只产出一条「已接受」连接、随后阻塞到关闭的 net.Listener。 +// 它让单条裸连接可以走 listener 形态的去混淆/codec 管线(ObfuscatedListener + +// compatTransportListener),从而把这部分阻塞读取从 accept 循环挪到每连接 goroutine。 +type singleConnListener struct { + addr net.Addr + ch chan net.Conn + once sync.Once +} + +func newSingleConnListener(c net.Conn) *singleConnListener { + ch := make(chan net.Conn, 1) + ch <- c + return &singleConnListener{addr: c.LocalAddr(), ch: ch} +} + +func (l *singleConnListener) Accept() (net.Conn, error) { + c, ok := <-l.ch + if !ok { + return nil, net.ErrClosed + } + return c, nil +} + +func (l *singleConnListener) Close() error { + l.once.Do(func() { close(l.ch) }) + return nil +} + +func (l *singleConnListener) Addr() net.Addr { + return l.addr +} + func (l *compatTransportListener) Accept() (_ transport.Conn, rErr error) { conn, err := l.listener.Accept() if err != nil { @@ -409,9 +441,9 @@ func validateOutgoingCompatMessage(b *bin.Buffer) error { } func writeCompatPacket(w io.Writer, header, payload []byte) error { - packet := make([]byte, len(header)+len(payload)) - copy(packet, header) - copy(packet[len(header):], payload) + packet := make([]byte, 0, len(header)+len(payload)) + packet = append(packet, header...) + packet = append(packet, payload...) return writeAll(w, packet) } diff --git a/internal/observability/dbtrace/dbtrace.go b/internal/observability/dbtrace/dbtrace.go new file mode 100644 index 00000000..6159eb74 --- /dev/null +++ b/internal/observability/dbtrace/dbtrace.go @@ -0,0 +1,109 @@ +package dbtrace + +import ( + "context" + "sync/atomic" + "time" + + "go.uber.org/zap" +) + +type contextKey struct{} + +// Stats tracks database work done under one request context. +type Stats struct { + queries atomic.Int64 + errors atomic.Int64 + nanos atomic.Int64 +} + +// Snapshot is an immutable view of request-scoped database work. +type Snapshot struct { + Queries int64 + Errors int64 + Duration time.Duration +} + +// WithStats installs a fresh Stats collector into ctx. +func WithStats(ctx context.Context) (context.Context, *Stats) { + stats := &Stats{} + return context.WithValue(ctx, contextKey{}, stats), stats +} + +// FromContext returns the request-scoped Stats collector when one is present. +func FromContext(ctx context.Context) (*Stats, bool) { + stats, ok := ctx.Value(contextKey{}).(*Stats) + return stats, ok && stats != nil +} + +// SnapshotFromContext returns a zero snapshot when ctx has no Stats collector. +func SnapshotFromContext(ctx context.Context) Snapshot { + stats, ok := FromContext(ctx) + if !ok { + return Snapshot{} + } + return stats.Snapshot() +} + +// Add records one database operation. +func (s *Stats) Add(d time.Duration, err error) { + if s == nil { + return + } + s.queries.Add(1) + if err != nil { + s.errors.Add(1) + } + if d > 0 { + s.nanos.Add(d.Nanoseconds()) + } +} + +// Snapshot returns the current counters. +func (s *Stats) Snapshot() Snapshot { + if s == nil { + return Snapshot{} + } + return Snapshot{ + Queries: s.queries.Load(), + Errors: s.errors.Load(), + Duration: time.Duration(s.nanos.Load()), + } +} + +// Sub returns the work added after before. Negative values are clamped so that +// logging stays sane if a future collector resets counters. +func (s Snapshot) Sub(before Snapshot) Snapshot { + after := Snapshot{ + Queries: s.Queries - before.Queries, + Errors: s.Errors - before.Errors, + Duration: s.Duration - before.Duration, + } + if after.Queries < 0 { + after.Queries = 0 + } + if after.Errors < 0 { + after.Errors = 0 + } + if after.Duration < 0 { + after.Duration = 0 + } + return after +} + +// Empty reports whether there is database work worth logging. +func (s Snapshot) Empty() bool { + return s.Queries == 0 && s.Errors == 0 +} + +// AppendZapFields adds db_* fields to an existing zap field slice. +func AppendZapFields(fields []zap.Field, prefix string, snap Snapshot) []zap.Field { + if snap.Empty() { + return fields + } + return append(fields, + zap.Int64(prefix+"db_queries", snap.Queries), + zap.Duration(prefix+"db_time", snap.Duration), + zap.Int64(prefix+"db_errors", snap.Errors), + ) +} diff --git a/internal/observability/dbtrace/dbtrace_test.go b/internal/observability/dbtrace/dbtrace_test.go new file mode 100644 index 00000000..4dcb473f --- /dev/null +++ b/internal/observability/dbtrace/dbtrace_test.go @@ -0,0 +1,35 @@ +package dbtrace + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestStatsSnapshotAndDelta(t *testing.T) { + ctx, stats := WithStats(context.Background()) + if got, ok := FromContext(ctx); !ok || got != stats { + t.Fatalf("FromContext() = (%v, %v), want installed stats", got, ok) + } + + stats.Add(10*time.Millisecond, nil) + before := stats.Snapshot() + stats.Add(5*time.Millisecond, errors.New("boom")) + + snap := stats.Snapshot() + if snap.Queries != 2 || snap.Errors != 1 || snap.Duration != 15*time.Millisecond { + t.Fatalf("Snapshot() = %+v, want 2 queries, 1 error, 15ms", snap) + } + + delta := snap.Sub(before) + if delta.Queries != 1 || delta.Errors != 1 || delta.Duration != 5*time.Millisecond { + t.Fatalf("delta = %+v, want 1 query, 1 error, 5ms", delta) + } +} + +func TestSnapshotFromContextWithoutStats(t *testing.T) { + if snap := SnapshotFromContext(context.Background()); !snap.Empty() { + t.Fatalf("SnapshotFromContext() = %+v, want empty", snap) + } +} diff --git a/internal/readmodelcache/cache.go b/internal/readmodelcache/cache.go new file mode 100644 index 00000000..c318dc7a --- /dev/null +++ b/internal/readmodelcache/cache.go @@ -0,0 +1,459 @@ +// Package readmodelcache provides telesrv 统一的进程内 read-model 缓存原语。 +// +// 它是 ~21 个手搓 read-model 缓存(map + mutex + TTL + 整表 flush + 偶尔 singleflight + +// 偶尔 epoch)收敛后的唯一实现。它**不是**通用响应缓存:Telegram 响应对 viewer 敏感, +// 可缓存单元仍是 docs/read-model-architecture.md 定义的 per-viewer 事实/投影。本类型只是 +// 机制——键、TTL、版本 token 仍是各缓存自己的策略。 +// +// 它替每个使用方免费保证(正是 2026-06-17 缓存审计发现的整类 bug): +// - 有界:LRU 单条驱逐,绝不整表 flush(避免 thundering-herd 重载悬崖); +// - 一致:跨越一次失效的 load 不会把陈旧值写回(epoch 守卫)——曾钉死版本脊与 +// ChannelMemberCache 的 lost-update; +// - 去重:同键并发 miss 收敛为一次 load(singleflight); +// - 可选版本闸门:仅当 storedHash == currentHash 时复用缓存项。 +// +// epoch 刻意是**进程内**的:它只关掉进程内 load-vs-invalidate 竞态。跨实例新鲜度由 +// NOTIFY 监听器(以及尚未落地的 durable 失效层)负责,本原语不改变跨进程契约。 +package readmodelcache + +import ( + "container/list" + "context" + "fmt" + "strconv" + "sync" + "time" + + "golang.org/x/sync/singleflight" +) + +// Config 配置一个 Cache 实例。MaxEntries 是强制的:不允许构造无界缓存。 +type Config[K comparable, V any] struct { + // MaxEntries 是 LRU 上界。<=0 时 New 返回 nil(等价"禁用缓存",沿用各处 + // New*Cache(max<=0)->nil 的惯例;所有方法对 nil 安全,退化为直接 load)。 + MaxEntries int + // TTL 仅作安全兜底(漏掉的带外写)。0 = 纯事件驱动,无时间过期。 + TTL time.Duration + // Clone 在 store 与返回两个边界上对值做深拷贝,隔离调用方与缓存项的别名突变。 + // nil = 值本身 copy-safe(标量/扁平结构),不拷贝。 + Clone func(V) V + // KeyString 生成 singleflight 键;nil 时默认 fmt.Sprint(K)。仅当 K 的 fmt 表示 + // 有歧义(可能两个不同键打印相同)时才需要自定义。 + KeyString func(K) string + // Now 注入时钟,仅用于 TTL 过期判断;nil 时默认 time.Now。生产一律留空, + // 测试可注入假时钟以确定地推进 TTL。 + Now func() time.Time +} + +type lruEntry[K comparable, V any] struct { + key K + value V + hash int64 + expireAt time.Time // 零值 = 不过期 +} + +// Cache 是泛型 read-model 缓存。零值不可用,必须经 New 构造。nil *Cache 合法: +// 所有方法对 nil 安全,GetOrLoad 退化为直接调用 load。 +type Cache[K comparable, V any] struct { + mu sync.Mutex + ll *list.List // LRU 顺序,Front=最近使用 + items map[K]*list.Element + cap int + ttl time.Duration + epoch uint64 + sf singleflight.Group + clone func(V) V + keyString func(K) string + now func() time.Time +} + +// New 构造一个 Cache。MaxEntries<=0 时返回 nil(禁用缓存,沿用既有惯例)。 +func New[K comparable, V any](cfg Config[K, V]) *Cache[K, V] { + if cfg.MaxEntries <= 0 { + return nil + } + keyString := cfg.KeyString + if keyString == nil { + keyString = func(k K) string { return fmt.Sprint(k) } + } + now := cfg.Now + if now == nil { + now = time.Now + } + return &Cache[K, V]{ + ll: list.New(), + items: make(map[K]*list.Element, initialMapHint(cfg.MaxEntries)), + cap: cfg.MaxEntries, + ttl: cfg.TTL, + clone: cfg.Clone, + keyString: keyString, + now: now, + } +} + +func initialMapHint(max int) int { + if max < 1024 { + return max + } + return 1024 +} + +type loadResult[V any] struct { + value V + stored bool +} + +// GetOrLoad 命中即返回,否则经 singleflight 调 load。load 受 epoch 守卫: +// 在 load 前快照 epoch,若 load 期间发生过任何 Invalidate/Flush,则**拒绝**把这次 +// load 的结果写回缓存,并重查/重载以取最新值(避免 lost-update 把陈旧值钉进缓存)。 +func (c *Cache[K, V]) GetOrLoad(ctx context.Context, key K, load func() (V, error)) (V, error) { + return c.getOrLoad(ctx, key, 0, false, load) +} + +// GetOrLoadVersioned 在 GetOrLoad 基础上加版本闸门:仅当缓存项的 storedHash == +// currentHash 时复用,否则重载。currentHash==0 表示"版本未知/绕过版本闸门" +// (与既有 snap.hash==currentHash 检查的语义一致)。 +func (c *Cache[K, V]) GetOrLoadVersioned(ctx context.Context, key K, currentHash int64, load func() (V, error)) (V, error) { + return c.getOrLoad(ctx, key, currentHash, true, load) +} + +func (c *Cache[K, V]) getOrLoad(ctx context.Context, key K, currentHash int64, versioned bool, load func() (V, error)) (V, error) { + if c == nil { + return load() + } + for { + if v, ok := c.lookup(key, currentHash, versioned); ok { + return v, nil + } + res, err, _ := c.sf.Do(c.singleflightKey(key, currentHash, versioned), func() (any, error) { + // 进入 singleflight 后再查一次:可能有并发者刚写入。 + if v, ok := c.lookup(key, currentHash, versioned); ok { + return loadResult[V]{value: v, stored: true}, nil + } + loadEpoch := c.cacheEpoch() + v, err := load() + if err != nil { + return loadResult[V]{}, err + } + stored := c.storeIfEpoch(key, v, currentHash, loadEpoch) + return loadResult[V]{value: c.cloneValue(v), stored: stored}, nil + }) + if err != nil { + var zero V + return zero, err + } + result := res.(loadResult[V]) + if result.stored { + return result.value, nil + } + // store 被 epoch 守卫拒绝(load 期间发生过失效):本次 load 的值可能已陈旧。 + // 重查缓存——若有更新值(warm/后续 load)直接用,否则重新 load 取 DB 最新态。 + if err := ctx.Err(); err != nil { + var zero V + return zero, err + } + } +} + +func (c *Cache[K, V]) lookup(key K, currentHash int64, versioned bool) (V, bool) { + var zero V + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.items[key] + if !ok { + return zero, false + } + ent := el.Value.(*lruEntry[K, V]) + if c.expired(ent) { + // 被动 TTL 过期:纯 per-key 删除,**不**自增 epoch——否则会误杀此刻在飞的 + // 不相关 load(epoch 是全局的),造成 thrash。 + c.removeElement(el) + return zero, false + } + if versioned && currentHash != 0 && ent.hash != currentHash { + return zero, false + } + c.ll.MoveToFront(el) + return c.cloneValue(ent.value), true +} + +// storeIfEpoch 仅在 epoch 未变(load 期间无失效)时写入,返回是否写入。 +func (c *Cache[K, V]) storeIfEpoch(key K, v V, hash int64, loadEpoch uint64) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.epoch != loadEpoch { + return false + } + c.storeLocked(key, v, hash) + return true +} + +func (c *Cache[K, V]) storeLocked(key K, v V, hash int64) { + if el, ok := c.items[key]; ok { + ent := el.Value.(*lruEntry[K, V]) + ent.value = c.cloneValue(v) + ent.hash = hash + ent.expireAt = c.expireAt() + c.ll.MoveToFront(el) + return + } + ent := &lruEntry[K, V]{key: key, value: c.cloneValue(v), hash: hash, expireAt: c.expireAt()} + c.items[key] = c.ll.PushFront(ent) + if c.ll.Len() > c.cap { + c.evictOldest() + } +} + +// Store 把一个已在手的值写入缓存(warm-from-list 路径)。不自增 epoch:它不是失效, +// 不应取消其它在飞 load。 +func (c *Cache[K, V]) Store(key K, v V) { + if c == nil { + return + } + c.mu.Lock() + c.storeLocked(key, v, 0) + c.mu.Unlock() +} + +// LoadEpoch 在「外部构建值再写回」模式下,于构建前快照 epoch;之后用 StoreIfEpoch 写回。 +// 适用于值在缓存之外构建(需要 ctx、多次往返)、无法套进 GetOrLoad 的调用方(如 RPC 投影)。 +func (c *Cache[K, V]) LoadEpoch() uint64 { + if c == nil { + return 0 + } + return c.cacheEpoch() +} + +// StoreIfEpoch 仅在 epoch 自 loadEpoch 以来未变(构建期间没有失效)时写回外部构建的值。 +// 与 LoadEpoch 配对,把 GetOrLoad 内建的 epoch 守卫开放给外部构建路径。 +func (c *Cache[K, V]) StoreIfEpoch(key K, v V, loadEpoch uint64) { + if c == nil { + return + } + c.storeIfEpoch(key, v, 0, loadEpoch) +} + +// StoreVersioned 同 Store,但带版本 hash(供版本闸门缓存的 warm 路径使用)。 +func (c *Cache[K, V]) StoreVersioned(key K, hash int64, v V) { + if c == nil { + return + } + c.mu.Lock() + c.storeLocked(key, v, hash) + c.mu.Unlock() +} + +// GetOrLoadBatch 一趟解析多个键:per-key 命中(TTL + 可选版本闸门)→ 把所有 miss 一次性 +// 批量 load → per-key epoch 守卫写回。服务那些「按键查缓存、把 miss 合批打一次后端」的缓存 +// (dialog-peer / privacy / photo / bot profile),避免单键 GetOrLoad 把一次批量 DB 退化成 N 次。 +// +// - versionOf(key) 返回 (hash, cacheable):cacheable=false 表示该键绕过缓存(永远重载、不写回, +// 如 dialog-peer 中 version 缺失的 peer);hash!=0 启用版本闸门(仅当 stored hash 匹配才复用); +// hash==0 且 cacheable 表示纯 TTL 缓存(无版本,如 photo/privacy/bot)。 +// - loadMissing 必须为它收到的**每个** key 返回一个值(含「查过但不存在」的负缓存哨兵), +// 这样负结果也会被缓存,杜绝无结果键反复打后端。 +// +// 若一次失效在批量 load 期间到达(epoch 变更),整趟重试,确保 pre-invalidation 的批量数据 +// 不会遮蔽这次失效(比各缓存原先「静默拒绝写回但仍返回旧批数据」更强;ctx 取消兜底防自旋)。 +func (c *Cache[K, V]) GetOrLoadBatch( + ctx context.Context, + keys []K, + versionOf func(K) (hash int64, cacheable bool), + loadMissing func(context.Context, []K) (map[K]V, error), +) (map[K]V, error) { + if len(keys) == 0 { + return map[K]V{}, nil + } + if c == nil { + return loadMissing(ctx, dedupeKeys(keys)) + } + for { + out := make(map[K]V, len(keys)) + loadEpoch := c.cacheEpoch() + // 在查阶段就把每个 miss 的 (hash, cacheable) 快照下来,写回时复用同一份——绝不在写回 + // 时重算 versionOf:否则一个版本在查与写之间变更的 key,会把按旧 hash 加载的值以新 hash + // 戳入缓存,随后被当作新版数据命中(stale-as-fresh,旁路版本闸门)。 + missing := make([]batchMiss[K], 0, len(keys)) + seen := make(map[K]struct{}, len(keys)) + for _, key := range keys { + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + hash, cacheable := versionOf(key) + if cacheable { + if v, ok := c.lookup(key, hash, hash != 0); ok { + out[key] = v + continue + } + } + missing = append(missing, batchMiss[K]{key: key, hash: hash, cacheable: cacheable}) + } + if len(missing) == 0 { + return out, nil + } + missingKeys := make([]K, len(missing)) + for i := range missing { + missingKeys[i] = missing[i].key + } + loaded, err := loadMissing(ctx, missingKeys) + if err != nil { + return nil, err + } + if c.cacheEpoch() != loadEpoch { + // 失效在批量 load 期间到达:重试整趟,避免用 pre-invalidation 数据遮蔽它。 + if err := ctx.Err(); err != nil { + return nil, err + } + continue + } + for _, m := range missing { + v, ok := loaded[m.key] + if !ok { + continue + } + out[m.key] = v + if m.cacheable { + c.storeIfEpoch(m.key, v, m.hash, loadEpoch) + } + } + return out, nil + } +} + +type batchMiss[K comparable] struct { + key K + hash int64 + cacheable bool +} + +func dedupeKeys[K comparable](keys []K) []K { + seen := make(map[K]struct{}, len(keys)) + out := make([]K, 0, len(keys)) + for _, key := range keys { + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + return out +} + +// Peek 直接读,不触发 load、不算 LRU touch(warm/测试路径)。过期项报 miss 但不删除 +// (保持 Peek 无副作用)。 +func (c *Cache[K, V]) Peek(key K) (V, bool) { + var zero V + if c == nil { + return zero, false + } + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.items[key] + if !ok { + return zero, false + } + ent := el.Value.(*lruEntry[K, V]) + if c.expired(ent) { + return zero, false + } + return c.cloneValue(ent.value), true +} + +// Invalidate 删除指定键并自增 epoch(关掉任何此刻在飞 load 的写回)。 +func (c *Cache[K, V]) Invalidate(keys ...K) { + if c == nil || len(keys) == 0 { + return + } + c.mu.Lock() + c.epoch++ + for _, key := range keys { + if el, ok := c.items[key]; ok { + c.removeElement(el) + } + } + c.mu.Unlock() +} + +// InvalidateWhere 删除所有满足 pred 的键并自增 epoch(viewer/channel 维度扇出失效, +// 取代各缓存手写的 deleteChannel/InvalidateViewer 遍历)。 +func (c *Cache[K, V]) InvalidateWhere(pred func(K) bool) { + if c == nil || pred == nil { + return + } + c.mu.Lock() + c.epoch++ + for key, el := range c.items { + if pred(key) { + c.removeElement(el) + } + } + c.mu.Unlock() +} + +// Flush 清空缓存并自增 epoch(监听器断线重连兜底)。 +func (c *Cache[K, V]) Flush() { + if c == nil { + return + } + c.mu.Lock() + c.epoch++ + c.ll.Init() + c.items = make(map[K]*list.Element, initialMapHint(c.cap)) + c.mu.Unlock() +} + +// Len 返回当前缓存项数(测试/指标用)。 +func (c *Cache[K, V]) Len() int { + if c == nil { + return 0 + } + c.mu.Lock() + n := c.ll.Len() + c.mu.Unlock() + return n +} + +func (c *Cache[K, V]) cacheEpoch() uint64 { + c.mu.Lock() + e := c.epoch + c.mu.Unlock() + return e +} + +func (c *Cache[K, V]) expired(ent *lruEntry[K, V]) bool { + return c.ttl > 0 && !ent.expireAt.IsZero() && !ent.expireAt.After(c.now()) +} + +func (c *Cache[K, V]) expireAt() time.Time { + if c.ttl <= 0 { + return time.Time{} + } + return c.now().Add(c.ttl) +} + +func (c *Cache[K, V]) evictOldest() { + if el := c.ll.Back(); el != nil { + c.removeElement(el) + } +} + +func (c *Cache[K, V]) removeElement(el *list.Element) { + c.ll.Remove(el) + delete(c.items, el.Value.(*lruEntry[K, V]).key) +} + +func (c *Cache[K, V]) cloneValue(v V) V { + if c.clone == nil { + return v + } + return c.clone(v) +} + +func (c *Cache[K, V]) singleflightKey(key K, hash int64, versioned bool) string { + s := c.keyString(key) + if versioned && hash != 0 { + s += "@" + strconv.FormatInt(hash, 10) + } + return s +} diff --git a/internal/readmodelcache/cache_test.go b/internal/readmodelcache/cache_test.go new file mode 100644 index 00000000..7dc4a3af --- /dev/null +++ b/internal/readmodelcache/cache_test.go @@ -0,0 +1,626 @@ +package readmodelcache + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" +) + +// fakeClock 让 TTL 行为可确定地推进,无需 sleep。 +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func newFakeClock() *fakeClock { return &fakeClock{t: time.Unix(1_700_000_000, 0)} } + +func (f *fakeClock) now() time.Time { + f.mu.Lock() + defer f.mu.Unlock() + return f.t +} + +func (f *fakeClock) advance(d time.Duration) { + f.mu.Lock() + f.t = f.t.Add(d) + f.mu.Unlock() +} + +func TestGetOrLoadSingleflightsConcurrentMiss(t *testing.T) { + ctx := context.Background() + c := New[int, string](Config[int, string]{MaxEntries: 16}) + + var calls atomic.Int32 + release := make(chan struct{}) + started := make(chan struct{}) + var once sync.Once + load := func() (string, error) { + calls.Add(1) + once.Do(func() { close(started) }) + <-release + return "v", nil + } + + const goroutines = 16 + var wg sync.WaitGroup + wg.Add(goroutines) + errs := make(chan error, goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + v, err := c.GetOrLoad(ctx, 42, load) + if err != nil { + errs <- err + return + } + if v != "v" { + errs <- fmt.Errorf("value = %q, want v", v) + } + }() + } + <-started + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + close(errs) + for err := range errs { + t.Fatal(err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("load called %d times, want 1 (singleflight)", got) + } + // 命中:不再 load。 + if v, err := c.GetOrLoad(ctx, 42, func() (string, error) { return "miss", nil }); err != nil || v != "v" { + t.Fatalf("cached GetOrLoad = %q,%v want v,", v, err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("cache hit re-loaded: calls=%d", got) + } +} + +// TestEpochGuardRejectsStaleWriteback 证明 epoch 守卫堵住 lost-update:一次锁外 load +// 期间到达的 Invalidate 不得被这次 load 的(已陈旧)结果覆盖;在飞读者最终拿到的是 +// 失效后重载的新值,且缓存未被陈旧值污染。 +func TestEpochGuardRejectsStaleWriteback(t *testing.T) { + ctx := context.Background() + c := New[int, string](Config[int, string]{MaxEntries: 16}) + + var seq atomic.Int32 // 第 1 次 load 返回 stale,失效后第 2 次返回 fresh + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + load := func() (string, error) { + n := seq.Add(1) + if n == 1 { + once.Do(func() { close(started) }) + <-release // 第一次 load 阻塞,模拟跨越一次失效 + return "stale", nil + } + return "fresh", nil + } + + resCh := make(chan string, 1) + errCh := make(chan error, 1) + go func() { + v, err := c.GetOrLoad(ctx, 7, load) + if err != nil { + errCh <- err + return + } + resCh <- v + }() + + <-started + // 失效在 load 期间到达(此刻缓存里还没有 key 7,delete 是 no-op,但 epoch++)。 + c.Invalidate(7) + close(release) // 放行第一次 load:它返回 "stale",epoch 守卫必须拒绝写回。 + + select { + case err := <-errCh: + t.Fatalf("GetOrLoad: %v", err) + case v := <-resCh: + if v != "fresh" { + t.Fatalf("in-flight reader got %q, want fresh (stale must not win)", v) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for in-flight reader") + } + + // 缓存现在应持有 fresh,且后续读不再 load。 + callsBefore := seq.Load() + if v, err := c.GetOrLoad(ctx, 7, load); err != nil || v != "fresh" { + t.Fatalf("post-race GetOrLoad = %q,%v want fresh,", v, err) + } + if seq.Load() != callsBefore { + t.Fatalf("cache poisoned/evicted: reloaded (seq %d -> %d)", callsBefore, seq.Load()) + } +} + +func TestLRUEvictsOldestNotWholeMap(t *testing.T) { + c := New[int, int](Config[int, int]{MaxEntries: 3}) + for i := 1; i <= 3; i++ { + mustLoad(t, c, i, i*10) + } + // 插入第 4 个:应只驱逐最旧的(key 1),而不是整表清空。 + mustLoad(t, c, 4, 40) + if c.Len() != 3 { + t.Fatalf("Len = %d, want 3 (single-entry eviction, not whole-map flush)", c.Len()) + } + if _, ok := c.Peek(1); ok { + t.Fatal("key 1 should have been evicted as oldest") + } + for _, k := range []int{2, 3, 4} { + if v, ok := c.Peek(k); !ok || v != k*10 { + t.Fatalf("key %d evicted/lost (whole-map flush?): v=%d ok=%v", k, v, ok) + } + } +} + +func TestLRUTouchOnGet(t *testing.T) { + ctx := context.Background() + c := New[int, int](Config[int, int]{MaxEntries: 2}) + mustLoad(t, c, 1, 10) + mustLoad(t, c, 2, 20) + // 触碰 key 1 使其变最近使用。 + if v, err := c.GetOrLoad(ctx, 1, failLoad[int](t)); err != nil || v != 10 { + t.Fatalf("touch GetOrLoad(1) = %d,%v", v, err) + } + // 插入 key 3:应驱逐 key 2(最久未用),保留 key 1。 + mustLoad(t, c, 3, 30) + if _, ok := c.Peek(2); ok { + t.Fatal("key 2 should have been evicted (LRU), key 1 was touched") + } + if _, ok := c.Peek(1); !ok { + t.Fatal("key 1 was touched and must survive") + } +} + +func TestVersionGateReloadsOnHashChange(t *testing.T) { + ctx := context.Background() + c := New[int, string](Config[int, string]{MaxEntries: 16}) + + var calls atomic.Int32 + load := func(tag string) func() (string, error) { + return func() (string, error) { calls.Add(1); return tag, nil } + } + // hash 100 装入。 + if v, _ := c.GetOrLoadVersioned(ctx, 1, 100, load("h100")); v != "h100" { + t.Fatalf("first load = %q", v) + } + // 同 hash:命中,不 load。 + if v, _ := c.GetOrLoadVersioned(ctx, 1, 100, failLoad[string](t)); v != "h100" { + t.Fatalf("same-hash hit = %q", v) + } + if calls.Load() != 1 { + t.Fatalf("same-hash re-loaded: calls=%d", calls.Load()) + } + // hash 改变:miss,重载。 + if v, _ := c.GetOrLoadVersioned(ctx, 1, 200, load("h200")); v != "h200" { + t.Fatalf("hash-change reload = %q", v) + } + if calls.Load() != 2 { + t.Fatalf("hash change should reload once more: calls=%d", calls.Load()) + } +} + +func TestTTLExpiryIsPerKeyAndDoesNotBumpEpoch(t *testing.T) { + ctx := context.Background() + clock := newFakeClock() + c := New[int, int](Config[int, int]{MaxEntries: 16, TTL: time.Minute}) + c.now = clock.now + + mustLoad(t, c, 1, 10) + epochBefore := c.cacheEpoch() + + clock.advance(2 * time.Minute) // 越过 TTL + + var reloaded atomic.Int32 + v, err := c.GetOrLoad(ctx, 1, func() (int, error) { reloaded.Add(1); return 11, nil }) + if err != nil || v != 11 { + t.Fatalf("expired GetOrLoad = %d,%v want 11", v, err) + } + if reloaded.Load() != 1 { + t.Fatal("expired entry should have reloaded") + } + if c.cacheEpoch() != epochBefore { + t.Fatalf("TTL expiry bumped epoch %d -> %d (would thrash in-flight loads)", epochBefore, c.cacheEpoch()) + } +} + +func TestInvalidateWhereFanout(t *testing.T) { + ctx := context.Background() + type key struct{ channel, user int64 } + c := New[key, int](Config[key, int]{MaxEntries: 64}) + put := func(ch, u int64) { + if _, err := c.GetOrLoad(ctx, key{ch, u}, func() (int, error) { return 1, nil }); err != nil { + t.Fatal(err) + } + } + put(10, 1) + put(10, 2) + put(20, 1) + epochBefore := c.cacheEpoch() + + c.InvalidateWhere(func(k key) bool { return k.channel == 10 }) + + if _, ok := c.Peek(key{10, 1}); ok { + t.Fatal("channel 10 user 1 should be invalidated") + } + if _, ok := c.Peek(key{10, 2}); ok { + t.Fatal("channel 10 user 2 should be invalidated") + } + if _, ok := c.Peek(key{20, 1}); !ok { + t.Fatal("channel 20 must survive a channel-10 fanout invalidate") + } + if c.cacheEpoch() == epochBefore { + t.Fatal("InvalidateWhere must bump epoch") + } +} + +func TestCloneIsolatesCallerFromCache(t *testing.T) { + ctx := context.Background() + c := New[int, []int](Config[int, []int]{ + MaxEntries: 16, + Clone: func(v []int) []int { return append([]int(nil), v...) }, + }) + orig := []int{1, 2, 3} + got, err := c.GetOrLoad(ctx, 1, func() ([]int, error) { return orig, nil }) + if err != nil { + t.Fatal(err) + } + got[0] = 999 // 突变返回值 + orig[1] = 888 // 突变 load 来源 + again, _ := c.Peek(1) + if again[0] != 1 || again[1] != 2 || again[2] != 3 { + t.Fatalf("cache entry mutated through aliasing: %v", again) + } +} + +func TestFlushClearsAndBumpsEpoch(t *testing.T) { + ctx := context.Background() + c := New[int, int](Config[int, int]{MaxEntries: 16}) + for i := 0; i < 5; i++ { + mustLoad(t, c, i, i) + } + epochBefore := c.cacheEpoch() + c.Flush() + if c.Len() != 0 { + t.Fatalf("Len after flush = %d, want 0", c.Len()) + } + if c.cacheEpoch() == epochBefore { + t.Fatal("Flush must bump epoch") + } + _ = ctx +} + +func TestNilCacheBypassesToLoad(t *testing.T) { + ctx := context.Background() + var c *Cache[int, string] // New 在 MaxEntries<=0 时返回 nil + if got := New[int, string](Config[int, string]{MaxEntries: 0}); got != nil { + t.Fatalf("New(MaxEntries:0) = %v, want nil", got) + } + var calls atomic.Int32 + v, err := c.GetOrLoad(ctx, 1, func() (string, error) { calls.Add(1); return "x", nil }) + if err != nil || v != "x" { + t.Fatalf("nil cache GetOrLoad = %q,%v", v, err) + } + if calls.Load() != 1 { + t.Fatalf("nil cache should call load exactly once: %d", calls.Load()) + } + // 其它方法对 nil 安全。 + c.Invalidate(1) + c.InvalidateWhere(func(int) bool { return true }) + c.Flush() + c.Store(1, "y") + if _, ok := c.Peek(1); ok { + t.Fatal("nil cache Peek must miss") + } + if c.Len() != 0 { + t.Fatal("nil cache Len must be 0") + } +} + +func TestStoreIfEpochRejectsStaleExternalBuild(t *testing.T) { + // 外部构建模式:LoadEpoch 快照 → 构建期间发生失效 → StoreIfEpoch 必须拒绝写回。 + c := New[int, string](Config[int, string]{MaxEntries: 16}) + loadEpoch := c.LoadEpoch() + c.Invalidate(7) // 构建期间的失效(epoch++) + c.StoreIfEpoch(7, "stale", loadEpoch) + if _, ok := c.Peek(7); ok { + t.Fatal("StoreIfEpoch 在 epoch 变更后必须拒绝写回") + } + // 无并发失效时,StoreIfEpoch 正常写入。 + fresh := c.LoadEpoch() + c.StoreIfEpoch(7, "fresh", fresh) + if v, ok := c.Peek(7); !ok || v != "fresh" { + t.Fatalf("StoreIfEpoch 在 epoch 未变时应写入: %q ok=%v", v, ok) + } +} + +// batchVal 模拟带负缓存的值(found=false 表示「查过但不存在」)。 +type batchVal struct { + n int + found bool +} + +func TestGetOrLoadBatchCachesHitsMissesAndNegatives(t *testing.T) { + ctx := context.Background() + c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64}) + noVersion := func(int) (int64, bool) { return 0, true } + + var loadCalls atomic.Int32 + var lastMissing atomic.Int32 + load := func(_ context.Context, missing []int) (map[int]batchVal, error) { + loadCalls.Add(1) + lastMissing.Store(int32(len(missing))) + out := make(map[int]batchVal, len(missing)) + for _, k := range missing { + // 偶数存在,奇数为负结果(仍须返回,以便缓存负结果)。 + out[k] = batchVal{n: k * 10, found: k%2 == 0} + } + return out, nil + } + + // 首次:全 miss,一次批量 load。 + got, err := c.GetOrLoadBatch(ctx, []int{1, 2, 3, 4}, noVersion, load) + if err != nil { + t.Fatal(err) + } + if got[2].n != 20 || !got[2].found || got[3].found { + t.Fatalf("batch result wrong: %+v", got) + } + if loadCalls.Load() != 1 || lastMissing.Load() != 4 { + t.Fatalf("first batch: calls=%d missing=%d, want 1/4", loadCalls.Load(), lastMissing.Load()) + } + + // 再查(含一个新键 6):仅 6 是 miss,负结果(1/3)也已缓存不再 load。 + got2, err := c.GetOrLoadBatch(ctx, []int{1, 2, 3, 4, 6}, noVersion, load) + if err != nil { + t.Fatal(err) + } + if loadCalls.Load() != 2 || lastMissing.Load() != 1 { + t.Fatalf("second batch should load only key 6: calls=%d missing=%d", loadCalls.Load(), lastMissing.Load()) + } + if got2[6].n != 60 || !got2[6].found { + t.Fatalf("key 6 = %+v, want 60/found", got2[6]) + } +} + +func TestGetOrLoadBatchVersionGateReloadsOnHashChange(t *testing.T) { + ctx := context.Background() + c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64}) + var hash atomic.Int64 + hash.Store(100) + versionOf := func(int) (int64, bool) { return hash.Load(), true } + + var loadCalls atomic.Int32 + load := func(_ context.Context, missing []int) (map[int]batchVal, error) { + loadCalls.Add(1) + out := make(map[int]batchVal, len(missing)) + for _, k := range missing { + out[k] = batchVal{n: int(hash.Load()), found: true} + } + return out, nil + } + if _, err := c.GetOrLoadBatch(ctx, []int{1}, versionOf, load); err != nil { + t.Fatal(err) + } + // 同 hash:命中,不 load。 + if _, err := c.GetOrLoadBatch(ctx, []int{1}, versionOf, load); err != nil { + t.Fatal(err) + } + if loadCalls.Load() != 1 { + t.Fatalf("same-hash batch re-loaded: calls=%d", loadCalls.Load()) + } + // hash 改变:版本闸门 miss,重载。 + hash.Store(200) + got, err := c.GetOrLoadBatch(ctx, []int{1}, versionOf, load) + if err != nil { + t.Fatal(err) + } + if loadCalls.Load() != 2 || got[1].n != 200 { + t.Fatalf("hash change should reload: calls=%d got=%+v", loadCalls.Load(), got[1]) + } +} + +// TestGetOrLoadBatchStoresUnderLookupHashNotStoreHash 钉住版本闸门修复:写回必须用查阶段 +// 快照的 hash,而非写回时重算的 hash。这里在 loadMissing(恰好在查与写之间执行)里把版本 +// 从 100 改成 200——若写回误用新 hash 200,则按版本 100 加载的数据会被当作 200 的新数据命中。 +func TestGetOrLoadBatchStoresUnderLookupHashNotStoreHash(t *testing.T) { + ctx := context.Background() + c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64}) + var gen atomic.Int64 + gen.Store(100) + versionOf := func(int) (int64, bool) { return gen.Load(), true } + + var loadCalls atomic.Int32 + load := func(_ context.Context, missing []int) (map[int]batchVal, error) { + loadCalls.Add(1) + gen.Store(200) // 版本在「查(@100,miss)」与「写回」之间变更 + out := make(map[int]batchVal, len(missing)) + for _, k := range missing { + out[k] = batchVal{n: k, found: true} + } + return out, nil + } + + // 首批:查@100 miss → load(把 gen 改到 200)→ 写回须用快照的 100,而非 200。 + if _, err := c.GetOrLoadBatch(ctx, []int{1}, versionOf, load); err != nil { + t.Fatal(err) + } + if loadCalls.Load() != 1 { + t.Fatalf("first batch load calls = %d, want 1", loadCalls.Load()) + } + // 现在 currentHash=200。若 entry 误以 200 戳入(bug),这次会命中陈旧数据、不重载; + // 修复后 entry 是 100 != 200 → miss → 重载。 + if _, err := c.GetOrLoadBatch(ctx, []int{1}, versionOf, load); err != nil { + t.Fatal(err) + } + if loadCalls.Load() != 2 { + t.Fatalf("version changed during first load; second read must reload (loadCalls=%d, want 2) — stale-as-fresh version-gate bypass", loadCalls.Load()) + } +} + +func TestGetOrLoadBatchBypassesNonCacheable(t *testing.T) { + ctx := context.Background() + c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64}) + // key 7 不可缓存(version 缺失),其余纯 TTL 缓存。 + versionOf := func(k int) (int64, bool) { return 0, k != 7 } + var loadCalls atomic.Int32 + load := func(_ context.Context, missing []int) (map[int]batchVal, error) { + loadCalls.Add(1) + out := make(map[int]batchVal, len(missing)) + for _, k := range missing { + out[k] = batchVal{n: k, found: true} + } + return out, nil + } + if _, err := c.GetOrLoadBatch(ctx, []int{7, 8}, versionOf, load); err != nil { + t.Fatal(err) + } + // 7 不可缓存故未写回;8 已缓存。再查应只为 7 load。 + got, err := c.GetOrLoadBatch(ctx, []int{7, 8}, versionOf, load) + if err != nil { + t.Fatal(err) + } + if loadCalls.Load() != 2 { + t.Fatalf("non-cacheable key 7 should reload every time: calls=%d", loadCalls.Load()) + } + if _, ok := c.Peek(7); ok { + t.Fatal("non-cacheable key 7 must not be stored") + } + if got[8].n != 8 { + t.Fatalf("key 8 = %+v", got[8]) + } +} + +func TestGetOrLoadBatchRetriesOnEpochChangeDuringLoad(t *testing.T) { + ctx := context.Background() + c := New[int, batchVal](Config[int, batchVal]{MaxEntries: 64}) + noVersion := func(int) (int64, bool) { return 0, true } + + var seq atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + load := func(_ context.Context, missing []int) (map[int]batchVal, error) { + n := seq.Add(1) + out := make(map[int]batchVal, len(missing)) + if n == 1 { + once.Do(func() { close(started) }) + <-release // 第一趟批量 load 阻塞,期间发生失效 + for _, k := range missing { + out[k] = batchVal{n: k, found: true} // 旧批数据 + } + return out, nil + } + for _, k := range missing { + out[k] = batchVal{n: k + 1000, found: true} // 失效后重载的新数据 + } + return out, nil + } + + resCh := make(chan map[int]batchVal, 1) + go func() { + got, _ := c.GetOrLoadBatch(ctx, []int{1}, noVersion, load) + resCh <- got + }() + <-started + c.Invalidate(1) // 批量 load 期间失效 → epoch++ + close(release) + + got := <-resCh + if got[1].n != 1001 { + t.Fatalf("epoch retry should return reloaded data: got %+v, want 1001", got[1]) + } + // 缓存里应是重载后的新值,且不再 load。 + callsBefore := seq.Load() + if v, ok := c.Peek(1); !ok || v.n != 1001 { + t.Fatalf("cache should hold reloaded value: %+v ok=%v", v, ok) + } + if seq.Load() != callsBefore { + t.Fatalf("post-retry read hit loader: seq %d", seq.Load()) + } +} + +func TestStoreWarmFromListDoesNotBumpEpoch(t *testing.T) { + c := New[int, int](Config[int, int]{MaxEntries: 16}) + epochBefore := c.cacheEpoch() + c.Store(1, 10) + if c.cacheEpoch() != epochBefore { + t.Fatal("Store (warm) must not bump epoch") + } + if v, ok := c.Peek(1); !ok || v != 10 { + t.Fatalf("Store then Peek = %d,%v", v, ok) + } +} + +func TestLoadErrorNotCached(t *testing.T) { + ctx := context.Background() + c := New[int, int](Config[int, int]{MaxEntries: 16}) + wantErr := fmt.Errorf("boom") + if _, err := c.GetOrLoad(ctx, 1, func() (int, error) { return 0, wantErr }); err != wantErr { + t.Fatalf("err = %v, want boom", err) + } + if _, ok := c.Peek(1); ok { + t.Fatal("failed load must not be cached") + } + // 之后成功 load 应生效。 + if v, err := c.GetOrLoad(ctx, 1, func() (int, error) { return 5, nil }); err != nil || v != 5 { + t.Fatalf("retry GetOrLoad = %d,%v", v, err) + } +} + +// TestConcurrentAccessNoPanic 在 -race 下能抓数据竞争;无 -race 时验证无 panic/死锁。 +func TestConcurrentAccessNoPanic(t *testing.T) { + ctx := context.Background() + c := New[int, int](Config[int, int]{MaxEntries: 64, TTL: time.Millisecond}) + var wg sync.WaitGroup + for g := 0; g < 24; g++ { + wg.Add(1) + go func(g int) { + defer wg.Done() + for i := 0; i < 500; i++ { + k := (g + i) % 128 + switch i % 5 { + case 0: + _, _ = c.GetOrLoad(ctx, k, func() (int, error) { return k, nil }) + case 1: + _, _ = c.GetOrLoadVersioned(ctx, k, int64(i%7+1), func() (int, error) { return k, nil }) + case 2: + c.Invalidate(k) + case 3: + c.InvalidateWhere(func(x int) bool { return x%3 == 0 }) + case 4: + c.Store(k, k) + } + } + }(g) + } + wg.Wait() +} + +// --- helpers --- + +func mustLoad[K comparable, V comparable](t *testing.T, c *Cache[K, V], key K, val V) { + t.Helper() + v, err := c.GetOrLoad(context.Background(), key, func() (V, error) { return val, nil }) + if err != nil { + t.Fatalf("GetOrLoad(%v): %v", key, err) + } + if v != val { + t.Fatalf("GetOrLoad(%v) = %v, want %v", key, v, val) + } +} + +func failLoad[V any](t *testing.T) func() (V, error) { + return func() (V, error) { + t.Helper() + t.Fatal("load should not have been called (expected cache hit)") + var zero V + return zero, nil + } +} diff --git a/internal/rpc/account.go b/internal/rpc/account.go index d3faa2d9..d9bfc6d8 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -3,16 +3,14 @@ package rpc import ( "context" "errors" + "strings" - "github.com/gotd/td/bin" "github.com/gotd/td/tg" "telesrv/internal/compat/tdesktop" "telesrv/internal/domain" ) -const legacyAccountRegisterDeviceTypeID uint32 = 0x637ea878 - // registerAccount 注册 account.* RPC handler。 func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountRegisterDevice(func(ctx context.Context, req *tg.AccountRegisterDeviceRequest) (bool, error) { @@ -24,29 +22,12 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountCheckUsername(r.onAccountCheckUsername) d.OnAccountUpdateProfile(r.onAccountUpdateProfile) d.OnAccountUpdateUsername(r.onAccountUpdateUsername) - d.OnAccountGetPassword(func(ctx context.Context) (*tg.AccountPassword, error) { - if r.deps.Account == nil { - return tgPassword(domain.PasswordSettings{SecureRandom: []byte("telesrv-tdesktop-dev-secure-rand")}), nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - settings, err := r.deps.Account.GetPassword(ctx, userID) - if err != nil { - return nil, internalErr() - } - return tgPassword(settings), nil - }) - d.OnAccountGetNotifySettings(func(ctx context.Context, peer tg.InputNotifyPeerClass) (*tg.PeerNotifySettings, error) { - return tdesktop.NotifySettings(), nil - }) - d.OnAccountUpdateNotifySettings(func(ctx context.Context, req *tg.AccountUpdateNotifySettingsRequest) (bool, error) { - return true, nil - }) - d.OnAccountResetNotifySettings(func(ctx context.Context) (bool, error) { - return true, nil - }) + d.OnAccountUpdateBirthday(r.onAccountUpdateBirthday) + d.OnAccountUpdatePersonalChannel(r.onAccountUpdatePersonalChannel) + d.OnAccountGetPassword(r.onAccountGetPassword) + d.OnAccountGetNotifySettings(r.onAccountGetNotifySettings) + d.OnAccountUpdateNotifySettings(r.onAccountUpdateNotifySettings) + d.OnAccountResetNotifySettings(r.onAccountResetNotifySettings) d.OnAccountGetPrivacy(r.onAccountGetPrivacy) d.OnAccountSetPrivacy(r.onAccountSetPrivacy) d.OnAccountGetAuthorizations(r.onAccountGetAuthorizations) @@ -56,28 +37,120 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountConfirmPasswordEmail(r.onAccountConfirmPasswordEmail) d.OnAccountResendPasswordEmail(r.onAccountResendPasswordEmail) d.OnAccountCancelPasswordEmail(r.onAccountCancelPasswordEmail) - d.OnAccountGetDefaultEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { - return tdesktop.DefaultEmojiStatuses(), nil - }) + d.OnAccountSendVerifyEmailCode(r.onAccountSendVerifyEmailCode) + d.OnAccountVerifyEmail(r.onAccountVerifyEmail) + d.OnAccountGetDefaultEmojiStatuses(r.onAccountGetDefaultEmojiStatuses) d.OnAccountGetCollectibleEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { return tdesktop.CollectibleEmojiStatuses(), nil }) d.OnAccountGetDefaultGroupPhotoEmojis(func(ctx context.Context, hash int64) (tg.EmojiListClass, error) { return tdesktop.DefaultGroupPhotoEmojis(), nil }) - d.OnAccountGetConnectedBots(func(ctx context.Context) (*tg.AccountConnectedBots, error) { - return tdesktop.ConnectedBots(), nil + d.OnAccountGetConnectedBots(r.onAccountGetConnectedBots) + d.OnAccountUpdateBusinessWorkHours(r.onAccountUpdateBusinessWorkHours) + d.OnAccountUpdateBusinessLocation(r.onAccountUpdateBusinessLocation) + d.OnAccountUpdateBusinessIntro(r.onAccountUpdateBusinessIntro) + d.OnAccountUpdateBusinessGreetingMessage(r.onAccountUpdateBusinessGreetingMessage) + d.OnAccountUpdateBusinessAwayMessage(r.onAccountUpdateBusinessAwayMessage) + d.OnAccountGetBusinessChatLinks(r.onAccountGetBusinessChatLinks) + d.OnAccountCreateBusinessChatLink(r.onAccountCreateBusinessChatLink) + d.OnAccountEditBusinessChatLink(r.onAccountEditBusinessChatLink) + d.OnAccountDeleteBusinessChatLink(r.onAccountDeleteBusinessChatLink) + d.OnAccountResolveBusinessChatLink(r.onAccountResolveBusinessChatLink) + d.OnAccountUpdateConnectedBot(r.onAccountUpdateConnectedBot) + d.OnAccountGetBotBusinessConnection(func(ctx context.Context, connectionID string) (tg.UpdatesClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return nil, tgerr400("BOT_BUSINESS_MISSING") }) + d.OnAccountToggleConnectedBotPaused(r.onAccountToggleConnectedBotPaused) + d.OnAccountDisablePeerConnectedBot(r.onAccountDisablePeerConnectedBot) d.OnAccountGetReactionsNotifySettings(r.onAccountGetReactionsNotifySettings) d.OnAccountSetReactionsNotifySettings(r.onAccountSetReactionsNotifySettings) - d.OnAccountGetContactSignUpNotification(func(ctx context.Context) (bool, error) { - return false, nil + d.OnAccountGetContactSignUpNotification(r.onAccountGetContactSignUpNotification) + d.OnAccountSetContactSignUpNotification(r.onAccountSetContactSignUpNotification) + d.OnAccountGetThemes(r.onAccountGetThemes) + d.OnAccountGetChatThemes(func(ctx context.Context, hash int64) (tg.AccountThemesClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return tdesktop.ChatThemes(hash), nil }) - d.OnAccountSetContactSignUpNotification(func(ctx context.Context, silent bool) (bool, error) { + // 自定义云主题(Create a New Theme 全链路):upload→create→update→save→install→get。 + d.OnAccountUploadTheme(r.onAccountUploadTheme) + d.OnAccountCreateTheme(r.onAccountCreateTheme) + d.OnAccountUpdateTheme(r.onAccountUpdateTheme) + d.OnAccountSaveTheme(r.onAccountSaveTheme) + d.OnAccountInstallTheme(r.onAccountInstallTheme) + d.OnAccountGetTheme(r.onAccountGetTheme) + d.OnAccountGetWallPapers(func(ctx context.Context, hash int64) (tg.AccountWallPapersClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return tdesktop.WallPapers(hash), nil + }) + d.OnAccountGetWallPaper(func(ctx context.Context, wallpaper tg.InputWallPaperClass) (tg.WallPaperClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + found, ok := tdesktop.LookupWallPaper(wallpaper) + if !ok { + return nil, tgerr400("WALLPAPER_INVALID") + } + return found, nil + }) + d.OnAccountGetMultiWallPapers(func(ctx context.Context, wallpapers []tg.InputWallPaperClass) ([]tg.WallPaperClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if len(wallpapers) > 100 { + return nil, tgerr400("WALLPAPER_INVALID") + } + found, ok := tdesktop.LookupWallPapers(wallpapers) + if !ok { + return nil, tgerr400("WALLPAPER_INVALID") + } + return found, nil + }) + d.OnAccountSaveWallPaper(func(ctx context.Context, req *tg.AccountSaveWallPaperRequest) (bool, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return false, internalErr() + } + if req == nil { + return false, tgerr400("WALLPAPER_INVALID") + } + if _, ok := tdesktop.LookupWallPaper(req.Wallpaper); !ok { + return false, tgerr400("WALLPAPER_INVALID") + } return true, nil }) - d.OnAccountGetThemes(func(ctx context.Context, req *tg.AccountGetThemesRequest) (tg.AccountThemesClass, error) { - return tdesktop.AccountThemes(), nil + d.OnAccountInstallWallPaper(func(ctx context.Context, req *tg.AccountInstallWallPaperRequest) (bool, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return false, internalErr() + } + if req == nil { + return false, tgerr400("WALLPAPER_INVALID") + } + if _, ok := tdesktop.LookupWallPaper(req.Wallpaper); !ok { + return false, tgerr400("WALLPAPER_INVALID") + } + return true, nil + }) + d.OnAccountResetWallPapers(func(ctx context.Context) (bool, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return false, internalErr() + } + return true, nil + }) + d.OnAccountGetUniqueGiftChatThemes(func(ctx context.Context, req *tg.AccountGetUniqueGiftChatThemesRequest) (tg.AccountChatThemesClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if req == nil { + req = &tg.AccountGetUniqueGiftChatThemesRequest{} + } + return tdesktop.UniqueGiftChatThemes(req.Hash), nil }) d.OnAccountGetRecentEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { return &tg.AccountEmojiStatuses{Hash: 0, Statuses: []tg.EmojiStatusClass{}}, nil @@ -85,36 +158,24 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountClearRecentEmojiStatuses(func(ctx context.Context) (bool, error) { return true, nil }) - d.OnAccountUpdateEmojiStatus(func(ctx context.Context, emojistatus tg.EmojiStatusClass) (bool, error) { - return true, nil - }) - d.OnAccountGetDefaultProfilePhotoEmojis(func(ctx context.Context, hash int64) (tg.EmojiListClass, error) { - return tdesktop.DefaultGroupPhotoEmojis(), nil - }) - d.OnAccountGetDefaultBackgroundEmojis(func(ctx context.Context, hash int64) (tg.EmojiListClass, error) { - return tdesktop.DefaultGroupPhotoEmojis(), nil - }) + d.OnAccountUpdateEmojiStatus(r.onAccountUpdateEmojiStatus) + d.OnAccountUpdateColor(r.onAccountUpdateColor) + d.OnAccountGetDefaultProfilePhotoEmojis(r.onAccountGetDefaultProfilePhotoEmojis) + d.OnAccountGetDefaultBackgroundEmojis(r.onAccountGetDefaultBackgroundEmojis) d.OnAccountGetChannelDefaultEmojiStatuses(func(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { return &tg.AccountEmojiStatuses{Hash: 0, Statuses: []tg.EmojiStatusClass{}}, nil }) d.OnAccountGetChannelRestrictedStatusEmojis(func(ctx context.Context, hash int64) (tg.EmojiListClass, error) { return tdesktop.DefaultGroupPhotoEmojis(), nil }) - d.OnAccountSetContentSettings(func(ctx context.Context, req *tg.AccountSetContentSettingsRequest) (bool, error) { - return true, nil - }) - d.OnAccountGetContentSettings(func(ctx context.Context) (*tg.AccountContentSettings, error) { - return tdesktop.ContentSettings(), nil - }) - d.OnAccountGetGlobalPrivacySettings(func(ctx context.Context) (*tg.GlobalPrivacySettings, error) { - return tdesktop.GlobalPrivacySettings(), nil - }) - d.OnAccountSetGlobalPrivacySettings(func(ctx context.Context, settings tg.GlobalPrivacySettings) (*tg.GlobalPrivacySettings, error) { - return &settings, nil - }) - d.OnAccountGetPasskeys(func(ctx context.Context) (*tg.AccountPasskeys, error) { - return tdesktop.Passkeys(), nil - }) + d.OnAccountSetContentSettings(r.onAccountSetContentSettings) + d.OnAccountGetContentSettings(r.onAccountGetContentSettings) + d.OnAccountGetGlobalPrivacySettings(r.onAccountGetGlobalPrivacySettings) + d.OnAccountSetGlobalPrivacySettings(r.onAccountSetGlobalPrivacySettings) + d.OnAccountGetPasskeys(r.onAccountGetPasskeys) + d.OnAccountInitPasskeyRegistration(r.onAccountInitPasskeyRegistration) + d.OnAccountRegisterPasskey(r.onAccountRegisterPasskey) + d.OnAccountDeletePasskey(r.onAccountDeletePasskey) d.OnAccountGetWebAuthorizations(func(ctx context.Context) (*tg.AccountWebAuthorizations, error) { return tdesktop.WebAuthorizations(), nil }) @@ -124,21 +185,21 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountResetWebAuthorizations(func(ctx context.Context) (bool, error) { return true, nil }) - d.OnAccountGetNotifyExceptions(func(ctx context.Context, req *tg.AccountGetNotifyExceptionsRequest) (tg.UpdatesClass, error) { - return &tg.Updates{Updates: []tg.UpdateClass{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: int(r.clock.Now().Unix())}, nil + // account.getWebBrowserSettings:telesrv 不接入网页浏览器(web bot)集成,返回空设置 + // (无内置浏览器例外、不强制外部浏览器)。Android 启动时会拉取,缺它会反复 500 + // NOT_IMPLEMENTED。空结构 Hash=0,客户端按默认(内置浏览器、无例外)渲染。 + d.OnAccountGetWebBrowserSettings(func(ctx context.Context, hash int64) (tg.AccountWebBrowserSettingsClass, error) { + return &tg.AccountWebBrowserSettings{}, nil }) + d.OnAccountGetNotifyExceptions(r.onAccountGetNotifyExceptions) d.OnAccountGetAutoDownloadSettings(func(ctx context.Context) (*tg.AccountAutoDownloadSettings, error) { return tdesktop.AutoDownloadSettings(), nil }) d.OnAccountSaveAutoDownloadSettings(func(ctx context.Context, req *tg.AccountSaveAutoDownloadSettingsRequest) (bool, error) { return true, nil }) - d.OnAccountGetSavedMusicIDs(func(ctx context.Context, hash int64) (tg.AccountSavedMusicIDsClass, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - return &tg.AccountSavedMusicIDs{IDs: []int64{}}, nil - }) + d.OnAccountSaveMusic(r.onAccountSaveMusic) + d.OnAccountGetSavedMusicIDs(r.onAccountGetSavedMusicIDs) d.OnAccountGetSavedRingtones(func(ctx context.Context, hash int64) (tg.AccountSavedRingtonesClass, error) { if _, _, err := r.currentUserID(ctx); err != nil { return nil, internalErr() @@ -146,9 +207,7 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { return &tg.AccountSavedRingtones{Hash: 0, Ringtones: []tg.DocumentClass{}}, nil }) d.OnAccountGetAccountTTL(r.onAccountGetAccountTTL) - d.OnAccountSetAccountTTL(func(ctx context.Context, ttl tg.AccountDaysTTL) (bool, error) { - return true, nil - }) + d.OnAccountSetAccountTTL(r.onAccountSetAccountTTL) d.OnAccountSetAuthorizationTTL(func(ctx context.Context, authorizationttldays int) (bool, error) { return true, nil }) @@ -160,6 +219,100 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) { d.OnAccountUpdateStatus(r.onAccountUpdateStatus) } +func (r *Router) onAccountGetPassword(ctx context.Context) (*tg.AccountPassword, error) { + if r.deps.Account == nil { + return tgPassword(domain.PasswordSettings{SecureRandom: []byte("telesrv-tdesktop-dev-secure-rand")}), nil + } + userID, _, _, err := r.currentOrPendingPasswordUserID(ctx) + if err != nil { + return nil, internalErr() + } + settings, err := r.deps.Account.GetPassword(ctx, userID) + if err != nil { + return nil, internalErr() + } + return tgPassword(settings), nil +} + +func (r *Router) onAccountSaveMusic(ctx context.Context, req *tg.AccountSaveMusicRequest) (bool, error) { + if req == nil { + return false, documentInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + doc, err := r.musicDocumentFromInput(ctx, req.ID) + if err != nil { + return false, err + } + var afterID int64 + if after, ok := req.GetAfterID(); ok { + afterDoc, err := r.musicDocumentFromInput(ctx, after) + if err != nil { + return false, err + } + afterID = afterDoc.ID + } + if r.deps.Account == nil { + return true, nil + } + ok, err := r.deps.Account.SaveMusic(ctx, userID, domain.SaveMusicRequest{ + UserID: userID, + Document: doc, + Unsave: req.Unsave, + AfterDocumentID: afterID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return false, savedMusicErr(err) + } + if ok { + r.invalidateRPCProjectionForUser(userID) + } + return ok, nil +} + +func (r *Router) onAccountGetSavedMusicIDs(ctx context.Context, hash int64) (tg.AccountSavedMusicIDsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Account == nil { + return &tg.AccountSavedMusicIDs{IDs: []int64{}}, nil + } + ids, err := r.deps.Account.ListSavedMusicIDs(ctx, userID, domain.MaxSavedMusicItems) + if err != nil { + return nil, internalErr() + } + if hash != 0 && int64(tdesktopCountHash(ids)) == hash { + return &tg.AccountSavedMusicIDsNotModified{}, nil + } + return &tg.AccountSavedMusicIDs{IDs: ids}, nil +} + +func (r *Router) musicDocumentFromInput(ctx context.Context, input tg.InputDocumentClass) (domain.Document, error) { + in, ok := input.(*tg.InputDocument) + if !ok || in.ID == 0 || r.deps.Files == nil { + return domain.Document{}, documentInvalidErr() + } + doc, found, err := r.deps.Files.GetDocument(ctx, in.ID) + if err != nil { + return domain.Document{}, internalErr() + } + if !found || doc.AccessHash != in.AccessHash || !doc.IsMusic() { + return domain.Document{}, documentInvalidErr() + } + return doc, nil +} + +func savedMusicErr(err error) error { + if errors.Is(err, domain.ErrDocumentInvalid) { + return documentInvalidErr() + } + return internalErr() +} + func (r *Router) onAccountGetAuthorizations(ctx context.Context) (*tg.AccountAuthorizations, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -195,9 +348,10 @@ func (r *Router) onAccountResetAuthorization(ctx context.Context, hash int64) (b if !found { return true, nil } - r.invalidateAuthUserCache(deleted.AuthKeyID) - r.unbindAuthKey(deleted.AuthKeyID) + r.revokeAuthKeySessions(deleted.AuthKeyID) _ = r.clearAuthKeyState(ctx, deleted.AuthKeyID) + // P1 修复:撤销该会话销毁其 auth_key,级联 discard 该设备绑定的活跃密聊并通知对端。 + r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(deleted.AuthKeyID), userID) return true, nil } @@ -213,23 +367,6 @@ func (r *Router) onAccountGetPasswordSettings(ctx context.Context, password tg.I return tgPasswordSettings(settings), nil } -func (r *Router) handleLegacyAccountRegisterDevice(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { - if err := b.ConsumeID(legacyAccountRegisterDeviceTypeID); err != nil { - return nil, err - } - if _, err := b.Int(); err != nil { - return nil, err - } - if _, err := b.String(); err != nil { - return nil, err - } - _, _, err := r.currentUserID(ctx) - if err != nil { - return nil, err - } - return &tg.BoolTrue{}, nil -} - func (r *Router) onAccountUpdatePasswordSettings(ctx context.Context, req *tg.AccountUpdatePasswordSettingsRequest) (bool, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -278,6 +415,178 @@ func (r *Router) onAccountCancelPasswordEmail(ctx context.Context) (bool, error) return true, nil } +// onAccountSendVerifyEmailCode 处理 account.sendVerifyEmailCode:为登录邮箱的设置/变更 +// 发送验证码。开发环境不真正发邮件、验证码任意,故此处直接持久化待确认的登录邮箱地址, +// 由后续 verifyEmail 做确认回显。loginChange 走已登录用户,loginSetup 走登录流程中的手机号。 +func (r *Router) onAccountSendVerifyEmailCode(ctx context.Context, req *tg.AccountSendVerifyEmailCodeRequest) (*tg.AccountSentEmailCode, error) { + if r.deps.Account == nil { + return nil, internalErr() + } + email := strings.TrimSpace(req.Email) + if email == "" || !strings.Contains(email, "@") { + return nil, emailInvalidErr() + } + switch p := req.Purpose.(type) { + case *tg.EmailVerifyPurposeLoginChange: + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, authKeyUnregisteredErr() + } + if err := r.deps.Account.SetLoginEmail(ctx, userID, email); err != nil { + return nil, passwordErr(err) + } + case *tg.EmailVerifyPurposeLoginSetup: + if err := r.deps.Account.SetLoginEmailByPhone(ctx, p.PhoneNumber, email); err != nil { + return nil, passwordErr(err) + } + default: + return nil, emailInvalidErr() + } + return &tg.AccountSentEmailCode{EmailPattern: domain.MaskEmail(email), Length: devCodeLength}, nil +} + +// onAccountVerifyEmail 处理 account.verifyEmail:确认登录邮箱(验证码任意非空即通过)。 +// loginChange(已登录)返回 emailVerified{email};loginSetup(登录流程中)返回 +// emailVerifiedLogin{email, sent_code},其中 sent_code 是供客户端继续手机登录的新验证码。 +func (r *Router) onAccountVerifyEmail(ctx context.Context, req *tg.AccountVerifyEmailRequest) (tg.AccountEmailVerifiedClass, error) { + if r.deps.Account == nil { + return nil, internalErr() + } + if strings.TrimSpace(emailVerificationCode(req.Verification)) == "" { + return nil, emailCodeInvalidErr() + } + switch p := req.Purpose.(type) { + case *tg.EmailVerifyPurposeLoginChange: + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, authKeyUnregisteredErr() + } + email, found, err := r.deps.Account.LoginEmail(ctx, userID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, emailCodeInvalidErr() + } + return &tg.AccountEmailVerified{Email: email}, nil + case *tg.EmailVerifyPurposeLoginSetup: + email, found, err := r.deps.Account.LoginEmailByPhone(ctx, p.PhoneNumber) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, emailCodeInvalidErr() + } + if r.deps.Auth == nil { + return nil, internalErr() + } + hash, err := r.deps.Auth.SendCode(ctx, p.PhoneNumber) + if err != nil { + return nil, internalErr() + } + return &tg.AccountEmailVerifiedLogin{Email: email, SentCode: tgSentCode(hash)}, nil + default: + return nil, emailInvalidErr() + } +} + +// onAccountGetPasskeys 列出当前用户的 passkey(管理页)。 +func (r *Router) onAccountGetPasskeys(ctx context.Context) (*tg.AccountPasskeys, error) { + if r.deps.Passkey == nil { + return &tg.AccountPasskeys{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + creds, err := r.deps.Passkey.List(ctx, userID) + if err != nil { + return nil, internalErr() + } + return &tg.AccountPasskeys{Passkeys: tgPasskeys(creds)}, nil +} + +// onAccountInitPasskeyRegistration 为已登录用户生成 passkey 注册选项(creation options)。 +func (r *Router) onAccountInitPasskeyRegistration(ctx context.Context) (*tg.AccountPasskeyRegistrationOptions, error) { + if r.deps.Passkey == nil { + return nil, internalErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, authKeyUnregisteredErr() + } + options, err := r.deps.Passkey.InitRegistration(ctx, userID, r.passkeyDisplayName(ctx, userID)) + if err != nil { + return nil, internalErr() + } + return &tg.AccountPasskeyRegistrationOptions{Options: tg.DataJSON{Data: string(options)}}, nil +} + +// onAccountRegisterPasskey 验证注册 attestation 并持久化凭据。 +func (r *Router) onAccountRegisterPasskey(ctx context.Context, credential tg.InputPasskeyCredentialClass) (*tg.Passkey, error) { + if r.deps.Passkey == nil { + return nil, internalErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, authKeyUnregisteredErr() + } + credID, reg, ok := passkeyRegisterFromCredential(credential) + if !ok { + return nil, passkeyErr(domain.ErrPasskeyInvalid) + } + cred, err := r.deps.Passkey.Register(ctx, userID, credID, []byte(reg.ClientData.Data), reg.AttestationData, "") + if err != nil { + return nil, passkeyErr(err) + } + pk := tgPasskey(cred) + return &pk, nil +} + +// onAccountDeletePasskey 删除当前用户的某个 passkey。 +func (r *Router) onAccountDeletePasskey(ctx context.Context, id string) (bool, error) { + if r.deps.Passkey == nil { + return false, internalErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + credID, ok := decodePasskeyID(id) + if !ok { + return false, passkeyErr(domain.ErrPasskeyInvalid) + } + deleted, err := r.deps.Passkey.Delete(ctx, userID, credID) + if err != nil { + return false, internalErr() + } + return deleted, nil +} + +// passkeyDisplayName 取用户显示名(authenticator UI 用),失败回退空串。 +func (r *Router) passkeyDisplayName(ctx context.Context, userID int64) string { + if r.deps.Users == nil { + return "" + } + u, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return "" + } + return strings.TrimSpace(u.FirstName + " " + u.LastName) +} + func (r *Router) onAccountGetPrivacy(ctx context.Context, key tg.InputPrivacyKeyClass) (*tg.AccountPrivacyRules, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -321,6 +630,7 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv if err != nil { return nil, err } + r.invalidateRPCProjectionForUser(userID) r.pushUserUpdates(ctx, userID, &tg.Updates{ Updates: []tg.UpdateClass{&tg.UpdatePrivacy{ Key: tgPrivacyKey(saved.Key), @@ -333,10 +643,115 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv } func (r *Router) onAccountGetAccountTTL(ctx context.Context) (*tg.AccountDaysTTL, error) { - if _, _, err := r.currentUserID(ctx); err != nil { + userID, _, err := r.currentUserID(ctx) + if err != nil { return nil, internalErr() } - return &tg.AccountDaysTTL{Days: 365}, nil + settings, err := r.cachedAccountSettings(ctx, userID) + if err != nil { + return nil, internalErr() + } + return &tg.AccountDaysTTL{Days: settings.NormalizedTTLDays()}, nil +} + +func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysTTL) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if ttl.Days <= 0 { + return false, tgerr400("TTL_DAYS_INVALID") + } + if svc, ok := r.accountSettingsSvc(); ok { + if _, err := svc.SetAccountTTL(ctx, userID, ttl.Days); err != nil { + return false, internalErr() + } + r.accountSettings.Delete(userID) + } + return true, nil +} + +func (r *Router) onAccountGetGlobalPrivacySettings(ctx context.Context) (*tg.GlobalPrivacySettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + settings, err := r.cachedAccountSettings(ctx, userID) + if err != nil { + return nil, internalErr() + } + return tgGlobalPrivacySettings(settings.GlobalPrivacy), nil +} + +func (r *Router) onAccountSetGlobalPrivacySettings(ctx context.Context, settings tg.GlobalPrivacySettings) (*tg.GlobalPrivacySettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if svc, ok := r.accountSettingsSvc(); ok { + saved, err := svc.SetGlobalPrivacy(ctx, userID, domainGlobalPrivacy(settings)) + if err != nil { + return nil, internalErr() + } + r.accountSettings.Delete(userID) + return tgGlobalPrivacySettings(saved.GlobalPrivacy), nil + } + return &settings, nil +} + +func (r *Router) onAccountGetContentSettings(ctx context.Context) (*tg.AccountContentSettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + settings, err := r.cachedAccountSettings(ctx, userID) + if err != nil { + return nil, internalErr() + } + return tgContentSettings(settings.SensitiveContentEnabled), nil +} + +func (r *Router) onAccountSetContentSettings(ctx context.Context, req *tg.AccountSetContentSettingsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req == nil { + return false, inputRequestInvalidErr() + } + if svc, ok := r.accountSettingsSvc(); ok { + if _, err := svc.SetSensitiveContent(ctx, userID, req.SensitiveEnabled); err != nil { + return false, internalErr() + } + r.accountSettings.Delete(userID) + } + return true, nil +} + +func (r *Router) onAccountGetContactSignUpNotification(ctx context.Context) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + settings, err := r.cachedAccountSettings(ctx, userID) + if err != nil { + return false, internalErr() + } + return settings.ContactSignUpSilent, nil +} + +func (r *Router) onAccountSetContactSignUpNotification(ctx context.Context, silent bool) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if svc, ok := r.accountSettingsSvc(); ok { + if _, err := svc.SetContactSignUpSilent(ctx, userID, silent); err != nil { + return false, internalErr() + } + r.accountSettings.Delete(userID) + } + return true, nil } func (r *Router) onAccountResetPassword(ctx context.Context) (tg.AccountResetPasswordResultClass, error) { @@ -376,7 +791,7 @@ func (r *Router) onAccountUpdateStatus(ctx context.Context, offline bool) (bool, if !authorized || userID == 0 { return true, nil } - status := r.setPresenceFromContext(ctx, userID, offline) + status, _ := r.setPresenceFromContext(ctx, userID, offline, presencePersistSync) r.pushUserStatus(ctx, userID, status) return true, nil } @@ -406,7 +821,9 @@ func (r *Router) tgAccountPrivacyRules(ctx context.Context, viewerUserID int64, } return &tg.AccountPrivacyRules{ Rules: tgPrivacyRules(rules.Rules), - Users: tgUsers(users), + // viewer 可能把自己(inputUserSelf)写进隐私名单,须带 self 标志,否则下发的 + // self=false user 会被 DrKLO putUsers 覆盖账号缓存。 + Users: tgUsersForViewer(viewerUserID, users), Chats: []tg.ChatClass{}, }, nil } @@ -414,6 +831,9 @@ func (r *Router) tgAccountPrivacyRules(ctx context.Context, viewerUserID int64, func (r *Router) domainPrivacyRulesFromInput(ctx context.Context, userID int64, in []tg.InputPrivacyRuleClass) ([]domain.PrivacyRule, error) { out := make([]domain.PrivacyRule, 0, len(in)) for _, rule := range in { + if inputPrivacyRuleClassNil(rule) { + return nil, privacyValueInvalidErr() + } switch v := rule.(type) { case *tg.InputPrivacyValueAllowContacts: out = append(out, domain.PrivacyRule{Kind: domain.PrivacyRuleAllowContacts}) @@ -454,10 +874,49 @@ func (r *Router) domainPrivacyRulesFromInput(ctx context.Context, userID int64, return out, nil } +func inputPrivacyRuleClassNil(rule tg.InputPrivacyRuleClass) bool { + switch typed := rule.(type) { + case nil: + return true + case *tg.InputPrivacyValueAllowContacts: + return typed == nil + case *tg.InputPrivacyValueAllowAll: + return typed == nil + case *tg.InputPrivacyValueAllowUsers: + return typed == nil + case *tg.InputPrivacyValueDisallowContacts: + return typed == nil + case *tg.InputPrivacyValueDisallowAll: + return typed == nil + case *tg.InputPrivacyValueDisallowUsers: + return typed == nil + case *tg.InputPrivacyValueAllowChatParticipants: + return typed == nil + case *tg.InputPrivacyValueDisallowChatParticipants: + return typed == nil + case *tg.InputPrivacyValueAllowCloseFriends: + return typed == nil + case *tg.InputPrivacyValueAllowPremium: + return typed == nil + case *tg.InputPrivacyValueAllowBots: + return typed == nil + case *tg.InputPrivacyValueDisallowBots: + return typed == nil + default: + return false + } +} + func (r *Router) privacyUserIDsFromInput(ctx context.Context, currentUserID int64, inputs []tg.InputUserClass) ([]int64, error) { out := make([]int64, 0, len(inputs)) seen := make(map[int64]struct{}, len(inputs)) for _, input := range inputs { + if inputUserClassNil(input) { + return nil, userIDInvalidErr() + } + if r.deps.Users == nil { + return nil, userIDInvalidErr() + } u, found, err := r.userFromInput(ctx, currentUserID, input) if err != nil { return nil, internalErr() @@ -474,6 +933,23 @@ func (r *Router) privacyUserIDsFromInput(ctx context.Context, currentUserID int6 return out, nil } +func inputUserClassNil(input tg.InputUserClass) bool { + switch typed := input.(type) { + case nil: + return true + case *tg.InputUserEmpty: + return typed == nil + case *tg.InputUserSelf: + return typed == nil + case *tg.InputUser: + return typed == nil + case *tg.InputUserFromMessage: + return typed == nil + default: + return false + } +} + func domainPrivacyKeyFromInput(key tg.InputPrivacyKeyClass) (domain.PrivacyKey, bool) { switch key.(type) { case *tg.InputPrivacyKeyStatusTimestamp: @@ -611,6 +1087,62 @@ type accountReactionSettingsService interface { SetReactionsNotifySettings(ctx context.Context, userID int64, settings domain.ReactionsNotifySettings) (domain.AccountReactionSettings, error) } +// accountSettingsService 是账号级单例设置(全局隐私/TTL/敏感内容/注册通知)持久化的 +// 可选扩展,由 *app/account.Service 实现。未接通时各 handler 回落历史回显默认。 +type accountSettingsService interface { + GetAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) + SetGlobalPrivacy(ctx context.Context, userID int64, privacy domain.GlobalPrivacy) (domain.AccountSettings, error) + SetAccountTTL(ctx context.Context, userID int64, days int) (domain.AccountSettings, error) + SetSensitiveContent(ctx context.Context, userID int64, enabled bool) (domain.AccountSettings, error) + SetContactSignUpSilent(ctx context.Context, userID int64, silent bool) (domain.AccountSettings, error) +} + +// accountSettingsSvc 取账号设置服务(未接通返回 nil)。 +func (r *Router) accountSettingsSvc() (accountSettingsService, bool) { + svc, ok := r.deps.Account.(accountSettingsService) + return svc, ok +} + +func tgGlobalPrivacySettings(gp domain.GlobalPrivacy) *tg.GlobalPrivacySettings { + out := &tg.GlobalPrivacySettings{ + ArchiveAndMuteNewNoncontactPeers: gp.ArchiveAndMuteNewNoncontactPeers, + KeepArchivedUnmuted: gp.KeepArchivedUnmuted, + KeepArchivedFolders: gp.KeepArchivedFolders, + HideReadMarks: gp.HideReadMarks, + NewNoncontactPeersRequirePremium: gp.NewNoncontactPeersRequirePremium, + DisplayGiftsButton: gp.DisplayGiftsButton, + } + if gp.NoncontactPeersPaidStars > 0 { + out.SetNoncontactPeersPaidStars(gp.NoncontactPeersPaidStars) + } + return out +} + +func domainGlobalPrivacy(settings tg.GlobalPrivacySettings) domain.GlobalPrivacy { + gp := domain.GlobalPrivacy{ + ArchiveAndMuteNewNoncontactPeers: settings.ArchiveAndMuteNewNoncontactPeers, + KeepArchivedUnmuted: settings.KeepArchivedUnmuted, + KeepArchivedFolders: settings.KeepArchivedFolders, + HideReadMarks: settings.HideReadMarks, + NewNoncontactPeersRequirePremium: settings.NewNoncontactPeersRequirePremium, + DisplayGiftsButton: settings.DisplayGiftsButton, + } + if v, ok := settings.GetNoncontactPeersPaidStars(); ok && v > 0 { + gp.NoncontactPeersPaidStars = v + } + return gp +} + +// tgContentSettings 把敏感内容开关投影为 account.contentSettings。 +// SensitiveCanChange 恒置位:telesrv 不做地区年龄门控,客户端始终可切换 +// (直接赋值 flag-bool 字段,EncodeBare 自动 SetFlags)。 +func tgContentSettings(enabled bool) *tg.AccountContentSettings { + return &tg.AccountContentSettings{ + SensitiveEnabled: enabled, + SensitiveCanChange: true, + } +} + func (r *Router) onAccountGetReactionsNotifySettings(ctx context.Context) (*tg.ReactionsNotifySettings, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -720,6 +1252,7 @@ func (r *Router) onAccountUpdateProfile(ctx context.Context, req *tg.AccountUpda if err != nil { return nil, profileErr(err) } + r.invalidateRPCProjectionForUser(u.ID) r.pushUsernameUpdate(ctx, u) return r.tgSelfUser(u), nil } @@ -753,10 +1286,213 @@ func (r *Router) onAccountUpdateUsername(ctx context.Context, username string) ( if err != nil { return nil, usernameErr(err) } + r.invalidateRPCProjectionForUser(u.ID) r.pushUsernameUpdate(ctx, u) return r.tgSelfUser(u), nil } +// onAccountUpdateBirthday 持久化资料页生日(account.updateBirthday)。birthday 缺省即清除; +// 月/日/年非法返回 BIRTHDAY_INVALID。生日落在 userFull(按隐私 PrivacyKeyBirthday 对外裁剪), +// 故只需失效本人 userFull 投影缓存,客户端重拉 getFullUser 即见最新值。 +func (r *Router) onAccountUpdateBirthday(ctx context.Context, req *tg.AccountUpdateBirthdayRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.deps.Users.(UserIdentityService) + if !ok { + return false, internalErr() + } + var birthday domain.Birthday + if b, ok := req.GetBirthday(); ok { + birthday = domain.Birthday{Day: b.Day, Month: b.Month} + if year, ok := b.GetYear(); ok { + birthday.Year = year + } + } + u, err := svc.UpdateBirthday(ctx, userID, birthday) + if err != nil { + if errors.Is(err, domain.ErrBirthdayInvalid) { + return false, birthdayInvalidErr() + } + return false, internalErr() + } + r.invalidateRPCProjectionForUser(u.ID) + return true, nil +} + +// onAccountUpdatePersonalChannel 设置/清除资料页「个人频道」(account.updatePersonalChannel)。 +// inputChannelEmpty 清除;否则解析频道并要求调用者为其创建者/管理员(与官方 picker 取 +// getAdminedPublicChannels 一致)。频道与最新一帖在 getFullUser 时按 id 投影进 userFull + chats。 +func (r *Router) onAccountUpdatePersonalChannel(ctx context.Context, channel tg.InputChannelClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.deps.Users.(UserIdentityService) + if !ok { + return false, internalErr() + } + var channelID int64 + switch channel.(type) { + case *tg.InputChannelEmpty: + channelID = 0 + default: + if r.deps.Channels == nil { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + view, err := r.channelFullReadView(ctx, userID, channel) + if err != nil { + return false, err + } + if !channelMemberIsAdmin(view.Self) { + return false, channelInvalidErr(domain.ErrChannelAdminRequired) + } + channelID = view.Channel.ID + } + u, err := svc.UpdatePersonalChannel(ctx, userID, channelID) + if err != nil { + return false, internalErr() + } + r.invalidateRPCProjectionForUser(u.ID) + return true, nil +} + +// onAccountUpdateEmojiStatus 持久化用户自定义 emoji status(premium 专属)。 +// emojiStatusEmpty 与未支持的 collectible 类型按清除处理(collectible 依赖 +// Stars 礼物模型,范围外,记兼容矩阵);变更经 updateUserEmojiStatus 推给 +// 本人全部在线 session(self user 对象同时携带最新 emoji_status 字段)。 +func (r *Router) onAccountUpdateEmojiStatus(ctx context.Context, status tg.EmojiStatusClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.deps.Users.(UserPremiumService) + if !ok { + return true, nil // 服务未接通(精简测试装配)时保持旧 stub 语义 + } + var documentID int64 + var until int + if s, ok := status.(*tg.EmojiStatus); ok { + documentID = s.DocumentID + if v, ok := s.GetUntil(); ok { + until = v + } + } + u, err := svc.UpdateEmojiStatus(ctx, userID, documentID, until) + if err != nil { + if errors.Is(err, domain.ErrPremiumRequired) { + return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED") + } + return false, internalErr() + } + r.invalidateRPCProjectionForUser(u.ID) + r.pushUserUpdates(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUserEmojiStatus{ + UserID: u.ID, + EmojiStatus: tgUserEmojiStatus(u, r.clock.Now().Unix()), + }}, + Users: []tg.UserClass{r.tgSelfUser(u)}, + Date: int(r.clock.Now().Unix()), + }) + return true, nil +} + +// onAccountUpdateColor 持久化当前用户的消息 accent 或资料页背景色。 +// 普通 peerColor 可清除(color flag absent)、可显式设置 color=0;collectible +// 颜色依赖礼物资产模型,当前阶段按范围外能力拒绝并记录在兼容矩阵。 +func (r *Router) onAccountUpdateColor(ctx context.Context, req *tg.AccountUpdateColorRequest) (bool, error) { + if req == nil { + return false, inputConstructorInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + color, err := domainPeerColorFromAccountUpdate(req) + if err != nil { + return false, err + } + svc, ok := r.deps.Users.(UserColorService) + if !ok { + return true, nil // 精简测试装配或旧 wiring 未接入时保持兼容 stub 语义。 + } + u, err := svc.UpdateColor(ctx, userID, req.GetForProfile(), color) + if err != nil { + return false, internalErr() + } + r.invalidateRPCProjectionForUser(u.ID) + r.pushUserUpdates(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: u.ID}}, + Users: []tg.UserClass{r.tgSelfUser(u)}, + Date: int(r.clock.Now().Unix()), + }) + return true, nil +} + +func domainPeerColorFromAccountUpdate(req *tg.AccountUpdateColorRequest) (domain.PeerColor, error) { + input, ok := req.GetColor() + if !ok { + return domain.PeerColor{}, nil + } + switch color := input.(type) { + case *tg.PeerColor: + value, hasColor := color.GetColor() + if hasColor && !accountUpdateColorIDAllowed(req.GetForProfile(), value) { + return domain.PeerColor{}, colorInvalidErr() + } + backgroundEmojiID, _ := color.GetBackgroundEmojiID() + if backgroundEmojiID < 0 { + return domain.PeerColor{}, documentInvalidErr() + } + return domain.PeerColor{ + HasColor: hasColor, + Color: value, + BackgroundEmojiID: backgroundEmojiID, + }, nil + case *tg.InputPeerColorCollectible, *tg.PeerColorCollectible: + return domain.PeerColor{}, colorInvalidErr() + default: + return domain.PeerColor{}, inputConstructorInvalidErr() + } +} + +func accountUpdateColorIDAllowed(forProfile bool, colorID int) bool { + if forProfile { + return tdesktop.IsPeerProfileColorID(colorID) + } + return tdesktop.IsPeerColorID(colorID) +} + +// onAccountGetDefaultEmojiStatuses 返回默认 emoji status 列表,与 +// inputStickerSetEmojiDefaultStatuses 系统集同源同序(选择器主体由该系统集 +// 经 messages.getStickerSet 填充,这里是顶部"默认状态"行)。资源未合成时回退 +// 空 stub,与其它 sticker 资源 RPC 的降级路径一致。 +func (r *Router) onAccountGetDefaultEmojiStatuses(ctx context.Context, hash int64) (tg.AccountEmojiStatusesClass, error) { + if r.deps.Files == nil { + return tdesktop.DefaultEmojiStatuses(), nil + } + set, _, found, err := r.deps.Files.ResolveStickerSet(ctx, domain.StickerSetRef{ + Kind: domain.StickerSetRefBySystem, + SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses, + }) + if err != nil { + return nil, internalErr() + } + if !found || len(set.DocumentIDs) == 0 { + return tdesktop.DefaultEmojiStatuses(), nil + } + catalogHash := mediaCatalogHash(set.DocumentIDs) + if hash == catalogHash { + return &tg.AccountEmojiStatusesNotModified{}, nil + } + statuses := make([]tg.EmojiStatusClass, 0, len(set.DocumentIDs)) + for _, id := range set.DocumentIDs { + statuses = append(statuses, &tg.EmojiStatus{DocumentID: id}) + } + return &tg.AccountEmojiStatuses{Hash: catalogHash, Statuses: statuses}, nil +} + func (r *Router) pushUsernameUpdate(ctx context.Context, u domain.User) { if u.ID == 0 { return @@ -800,6 +1536,126 @@ func tgAuthorization(a domain.Authorization, currentAuthKeyID [8]byte, now int) } } +func (r *Router) onAccountGetDefaultProfilePhotoEmojis(ctx context.Context, hash int64) (tg.EmojiListClass, error) { + if r.deps.Files == nil { + return tdesktop.DefaultGroupPhotoEmojis(), nil + } + const maxDefaultProfilePhotoEmojiIDs = 64 + ids, err := r.defaultProfilePhotoEmojiDocumentIDs(ctx, maxDefaultProfilePhotoEmojiIDs) + if err != nil { + return nil, internalErr() + } + listHash := emojiDocumentIDListHash(ids) + if hash != 0 && hash == listHash { + return &tg.EmojiListNotModified{}, nil + } + return &tg.EmojiList{Hash: listHash, DocumentID: ids}, nil +} + +func (r *Router) onAccountGetDefaultBackgroundEmojis(ctx context.Context, hash int64) (tg.EmojiListClass, error) { + if r.deps.Files == nil { + return tdesktop.DefaultGroupPhotoEmojis(), nil + } + ids, err := defaultBackgroundEmojiDocumentIDs(ctx, r.deps.Files) + if err != nil { + return nil, internalErr() + } + if len(ids) == 0 { + return tdesktop.DefaultGroupPhotoEmojis(), nil + } + listHash := emojiDocumentIDListHash(ids) + if hash != 0 && hash == listHash { + return &tg.EmojiListNotModified{}, nil + } + return &tg.EmojiList{Hash: listHash, DocumentID: ids}, nil +} + +func defaultBackgroundEmojiDocumentIDs(ctx context.Context, files FilesService) ([]int64, error) { + set, _, found, err := files.ResolveStickerSet(ctx, domain.StickerSetRef{ + Kind: domain.StickerSetRefByShortName, + ShortName: "StatusPack", + }) + if err != nil || !found { + return nil, err + } + return uniquePositiveDocumentIDs(set.DocumentIDs, 0), nil +} + +func (r *Router) defaultProfilePhotoEmojiDocumentIDs(ctx context.Context, limit int) ([]int64, error) { + ids, err := defaultProfilePhotoEmojiDocumentIDsFromKind(ctx, r.deps.Files, domain.StickerSetKindEmoji, limit, nil) + if err != nil || len(ids) > 0 { + return ids, err + } + return defaultProfilePhotoEmojiDocumentIDsFromKind(ctx, r.deps.Files, domain.StickerSetKindSystem, limit, func(set domain.StickerSet) bool { + return set.SystemKey == "animated_emoji" + }) +} + +func defaultProfilePhotoEmojiDocumentIDsFromKind( + ctx context.Context, + files FilesService, + kind domain.StickerSetKind, + limit int, + allow func(domain.StickerSet) bool, +) ([]int64, error) { + sets, err := files.ListStickerSets(ctx, kind) + if err != nil { + return nil, err + } + seen := make(map[int64]struct{}, limit) + ids := make([]int64, 0, limit) + for _, set := range sets { + if allow != nil && !allow(set) { + continue + } + for _, id := range set.DocumentIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + if len(ids) >= limit { + break + } + } + if len(ids) >= limit { + break + } + } + return ids, nil +} + +func uniquePositiveDocumentIDs(ids []int64, limit int) []int64 { + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +func emojiDocumentIDListHash(ids []int64) int64 { + var h uint64 = 1469598103934665603 + for _, id := range ids { + h ^= uint64(id) + h *= 1099511628211 + } + return int64(h & 0x7fffffffffffffff) +} + func usernameErr(err error) error { switch { case errors.Is(err, domain.ErrUsernameInvalid): diff --git a/internal/rpc/account_business.go b/internal/rpc/account_business.go new file mode 100644 index 00000000..a725ee68 --- /dev/null +++ b/internal/rpc/account_business.go @@ -0,0 +1,566 @@ +package rpc + +import ( + "context" + "errors" + "strings" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) accountBusinessAutomation() (AccountBusinessAutomationService, bool) { + if r.deps.Account == nil { + return nil, false + } + svc, ok := r.deps.Account.(AccountBusinessAutomationService) + return svc, ok +} + +func (r *Router) onAccountUpdateBusinessWorkHours(ctx context.Context, req *tg.AccountUpdateBusinessWorkHoursRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, premiumAccountRequiredErr() + } + hours, err := domainBusinessWorkHours(req) + if err != nil { + return false, err + } + if _, err := svc.UpdateBusinessWorkHours(ctx, userID, hours); err != nil { + return false, businessAutomationErr(err) + } + r.invalidateRPCProjectionForUser(userID) + return true, nil +} + +func (r *Router) onAccountUpdateBusinessLocation(ctx context.Context, req *tg.AccountUpdateBusinessLocationRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, premiumAccountRequiredErr() + } + location, err := domainBusinessLocation(req) + if err != nil { + return false, err + } + if _, err := svc.UpdateBusinessLocation(ctx, userID, location); err != nil { + return false, businessAutomationErr(err) + } + r.invalidateRPCProjectionForUser(userID) + return true, nil +} + +func (r *Router) onAccountUpdateBusinessIntro(ctx context.Context, req *tg.AccountUpdateBusinessIntroRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, premiumAccountRequiredErr() + } + intro, err := domainBusinessIntro(req) + if err != nil { + return false, err + } + if _, err := svc.UpdateBusinessIntro(ctx, userID, intro); err != nil { + return false, businessAutomationErr(err) + } + r.invalidateRPCProjectionForUser(userID) + return true, nil +} + +func (r *Router) onAccountUpdateBusinessGreetingMessage(ctx context.Context, req *tg.AccountUpdateBusinessGreetingMessageRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, premiumAccountRequiredErr() + } + greeting, err := r.domainBusinessGreeting(ctx, userID, req) + if err != nil { + return false, err + } + if _, err := svc.UpdateBusinessGreetingMessage(ctx, userID, greeting); err != nil { + return false, businessAutomationErr(err) + } + r.invalidateRPCProjectionForUser(userID) + return true, nil +} + +func (r *Router) onAccountUpdateBusinessAwayMessage(ctx context.Context, req *tg.AccountUpdateBusinessAwayMessageRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, premiumAccountRequiredErr() + } + away, err := r.domainBusinessAway(ctx, userID, req) + if err != nil { + return false, err + } + if _, err := svc.UpdateBusinessAwayMessage(ctx, userID, away); err != nil { + return false, businessAutomationErr(err) + } + r.invalidateRPCProjectionForUser(userID) + return true, nil +} + +func (r *Router) onAccountGetBusinessChatLinks(ctx context.Context) (*tg.AccountBusinessChatLinks, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return &tg.AccountBusinessChatLinks{ + Links: []tg.BusinessChatLink{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil + } + links, err := svc.ListBusinessChatLinks(ctx, userID) + if err != nil { + return nil, internalErr() + } + return &tg.AccountBusinessChatLinks{ + Links: tgBusinessChatLinks(links), + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onAccountCreateBusinessChatLink(ctx context.Context, link tg.InputBusinessChatLink) (*tg.BusinessChatLink, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return nil, premiumAccountRequiredErr() + } + input, err := domainBusinessChatLinkInput(link) + if err != nil { + return nil, err + } + created, err := svc.CreateBusinessChatLink(ctx, userID, input) + if err != nil { + return nil, businessAutomationErr(err) + } + out := tgBusinessChatLink(created) + return &out, nil +} + +func (r *Router) onAccountEditBusinessChatLink(ctx context.Context, req *tg.AccountEditBusinessChatLinkRequest) (*tg.BusinessChatLink, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req == nil || strings.TrimSpace(req.Slug) == "" { + return nil, chatlinkSlugEmptyErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return nil, premiumAccountRequiredErr() + } + input, err := domainBusinessChatLinkInput(req.Link) + if err != nil { + return nil, err + } + updated, err := svc.EditBusinessChatLink(ctx, userID, req.Slug, input) + if err != nil { + return nil, businessAutomationErr(err) + } + out := tgBusinessChatLink(updated) + return &out, nil +} + +func (r *Router) onAccountDeleteBusinessChatLink(ctx context.Context, slug string) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if strings.TrimSpace(slug) == "" { + return false, chatlinkSlugEmptyErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, chatlinkSlugExpiredErr() + } + deleted, err := svc.DeleteBusinessChatLink(ctx, userID, slug) + if err != nil { + return false, businessAutomationErr(err) + } + if !deleted { + return false, chatlinkSlugExpiredErr() + } + return true, nil +} + +func (r *Router) onAccountResolveBusinessChatLink(ctx context.Context, slug string) (*tg.AccountResolvedBusinessChatLinks, error) { + if strings.TrimSpace(slug) == "" { + return nil, chatlinkSlugEmptyErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return nil, chatlinkSlugExpiredErr() + } + link, found, err := svc.ResolveBusinessChatLink(ctx, slug, true) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, chatlinkSlugExpiredErr() + } + viewerID, _, _ := r.currentUserID(ctx) + users := []tg.UserClass{} + if r.deps.Users != nil { + user, found, err := r.deps.Users.ByID(ctx, viewerID, link.OwnerUserID) + if err != nil { + return nil, internalErr() + } + if found { + if viewerID != 0 && user.ID == viewerID { + users = append(users, r.tgSelfUser(user)) + } else { + users = append(users, r.tgUser(user)) + } + } + } + return &tg.AccountResolvedBusinessChatLinks{ + Peer: &tg.PeerUser{UserID: link.OwnerUserID}, + Message: link.Message, + Entities: tgMessageEntities(link.Entities), + Chats: []tg.ChatClass{}, + Users: users, + }, nil +} + +func (r *Router) onAccountGetConnectedBots(ctx context.Context) (*tg.AccountConnectedBots, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}}, nil + } + bot, found, err := svc.GetConnectedBusinessBot(ctx, userID) + if err != nil { + return nil, internalErr() + } + if !found || bot.BotUserID == 0 { + return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}}, nil + } + botUser, found, err := r.connectedBusinessBotByID(ctx, userID, bot.BotUserID) + if err != nil { + return nil, err + } + if !found { + return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}}, nil + } + return &tg.AccountConnectedBots{ + ConnectedBots: []tg.ConnectedBot{tgConnectedBot(bot)}, + Users: []tg.UserClass{r.tgUser(botUser)}, + }, nil +} + +func (r *Router) onAccountUpdateConnectedBot(ctx context.Context, req *tg.AccountUpdateConnectedBotRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return nil, premiumAccountRequiredErr() + } + botUser, found, err := r.connectedBusinessBotFromInput(ctx, userID, req.Bot) + if err != nil { + return nil, err + } + if !found { + return nil, botBusinessMissingErr() + } + if req.Deleted { + if _, err := svc.DeleteConnectedBusinessBot(ctx, userID, botUser.ID); err != nil { + return nil, businessAutomationErr(err) + } + r.invalidateRPCProjectionForViewer(userID) + return r.connectedBusinessBotEmptyUpdates(botUser), nil + } + recipients, err := r.domainBusinessBotRecipients(ctx, userID, req.Recipients) + if err != nil { + return nil, err + } + saved, err := svc.SaveConnectedBusinessBot(ctx, userID, domain.ConnectedBusinessBot{ + BotUserID: botUser.ID, + Recipients: recipients, + Rights: domainBusinessBotRightsForUpdate(req), + }) + if err != nil { + return nil, businessAutomationErr(err) + } + r.invalidateRPCProjectionForViewer(userID) + return r.connectedBusinessBotEmptyUpdates(botUser, tgConnectedBot(saved)), nil +} + +func (r *Router) onAccountToggleConnectedBotPaused(ctx context.Context, req *tg.AccountToggleConnectedBotPausedRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + if peer.Type != domain.PeerTypeUser || peer.ID == userID { + return false, peerIDInvalidErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, botBusinessMissingErr() + } + if _, err := svc.SetConnectedBusinessBotPaused(ctx, userID, peer.ID, req.Paused); err != nil { + return false, businessAutomationErr(err) + } + settings, err := r.connectedBusinessBotPeerSettings(ctx, userID, peer, domain.PeerSettings{}) + if err != nil { + return false, err + } + if err := r.recordConnectedBusinessPeerSettings(ctx, userID, peer, settings); err != nil { + return false, internalErr() + } + r.invalidateRPCProjectionForPeer(userID, peer) + return true, nil +} + +func (r *Router) onAccountDisablePeerConnectedBot(ctx context.Context, input tg.InputPeerClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return false, err + } + if peer.Type != domain.PeerTypeUser || peer.ID == userID { + return false, peerIDInvalidErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, botBusinessMissingErr() + } + if _, err := svc.DisableConnectedBusinessBotForPeer(ctx, userID, peer.ID); err != nil { + return false, businessAutomationErr(err) + } + if err := r.recordConnectedBusinessPeerSettings(ctx, userID, peer, domain.PeerSettings{}); err != nil { + return false, internalErr() + } + r.invalidateRPCProjectionForPeer(userID, peer) + return true, nil +} + +func (r *Router) connectedBusinessBotByID(ctx context.Context, currentUserID, botUserID int64) (domain.User, bool, error) { + if r.deps.Users == nil || botUserID == 0 { + return domain.User{}, false, nil + } + u, found, err := r.deps.Users.ByID(ctx, currentUserID, botUserID) + if err != nil { + return domain.User{}, false, internalErr() + } + if !found || !connectedBusinessBotUsable(u) { + return domain.User{}, false, nil + } + return u, true, nil +} + +func (r *Router) connectedBusinessBotFromInput(ctx context.Context, currentUserID int64, input tg.InputUserClass) (domain.User, bool, error) { + if r.deps.Users == nil { + return domain.User{}, false, internalErr() + } + u, found, err := r.userFromInput(ctx, currentUserID, input) + if err != nil { + return domain.User{}, false, internalErr() + } + if !found || !connectedBusinessBotUsable(u) { + return domain.User{}, false, nil + } + return u, true, nil +} + +func connectedBusinessBotUsable(u domain.User) bool { + return u.Bot && u.ID != 0 && u.ID != domain.BotFatherUserID +} + +func (r *Router) connectedBusinessBotEmptyUpdates(botUser domain.User, bots ...tg.ConnectedBot) *tg.Updates { + users := []tg.UserClass{} + if botUser.ID != 0 { + users = append(users, r.tgUser(botUser)) + } + return &tg.Updates{ + Updates: []tg.UpdateClass{}, + Users: users, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + } +} + +func (r *Router) connectedBusinessBotPeerSettings(ctx context.Context, ownerUserID int64, peer domain.Peer, settings domain.PeerSettings) (domain.PeerSettings, error) { + if peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == ownerUserID { + return settings, nil + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return settings, nil + } + bot, found, err := svc.GetConnectedBusinessBot(ctx, ownerUserID) + if err != nil { + return domain.PeerSettings{}, internalErr() + } + if !found || bot.BotUserID == 0 { + return settings, nil + } + state, stateFound, err := svc.GetConnectedBusinessBotPeerState(ctx, ownerUserID, peer.ID) + if err != nil { + return domain.PeerSettings{}, internalErr() + } + if stateFound && state.Disabled { + return settings, nil + } + existingChat, isContact := r.connectedBusinessPeerFacts(ctx, ownerUserID, peer.ID) + if !domain.BusinessBotRecipientsMatch(bot.Recipients, existingChat, isContact, peer.ID) { + return settings, nil + } + settings.BusinessBotID = bot.BotUserID + settings.BusinessBotPaused = stateFound && state.Paused + settings.BusinessBotCanReply = bot.Rights.Reply && !settings.BusinessBotPaused + if botUser, found, err := r.connectedBusinessBotByID(ctx, ownerUserID, bot.BotUserID); err != nil { + return domain.PeerSettings{}, err + } else if found { + settings.BusinessBotManageURL = connectedBusinessBotManageURL(botUser) + } + if settings.BusinessBotManageURL == "" { + settings.BusinessBotManageURL = "telesrv://business-bot" + } + return settings, nil +} + +func (r *Router) connectedBusinessPeerFacts(ctx context.Context, ownerUserID, peerUserID int64) (existingChat, isContact bool) { + if r.deps.Dialogs != nil { + list, err := r.deps.Dialogs.GetPeerDialogs(ctx, ownerUserID, []domain.Peer{{Type: domain.PeerTypeUser, ID: peerUserID}}) + if err == nil && len(list.Dialogs) > 0 && list.Dialogs[0].TopMessage > 0 { + existingChat = true + } + } + if r.deps.Contacts != nil { + settings, err := r.deps.Contacts.GetPeerSettings(ctx, ownerUserID, domain.Peer{Type: domain.PeerTypeUser, ID: peerUserID}) + isContact = err == nil && !settings.AddContact + } + return existingChat, isContact +} + +func connectedBusinessBotManageURL(bot domain.User) string { + if bot.Username == "" { + return "" + } + return "https://telesrv.net/" + bot.Username +} + +func (r *Router) recordConnectedBusinessPeerSettings(ctx context.Context, userID int64, peer domain.Peer, settings domain.PeerSettings) error { + if r.deps.Updates == nil || userID == 0 { + return nil + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, sessionID) + if err != nil { + return err + } + if sessionID != 0 { + r.bookkeepAuxPtsForCurrentSession(ctx, event) + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event)) + return nil +} + +func premiumAccountRequiredErr() error { return tgerr400("PREMIUM_ACCOUNT_REQUIRED") } + +func botBusinessMissingErr() error { return tgerr400("BOT_BUSINESS_MISSING") } + +func botNotConnectedYetErr() error { return tgerr400("BOT_NOT_CONNECTED_YET") } + +func botAlreadyDisabledErr() error { return tgerr400("BOT_ALREADY_DISABLED") } + +func chatlinkSlugEmptyErr() error { return tgerr400("CHATLINK_SLUG_EMPTY") } + +func chatlinkSlugExpiredErr() error { return tgerr400("CHATLINK_SLUG_EXPIRED") } + +func businessAutomationErr(err error) error { + switch { + case errors.Is(err, domain.ErrPremiumRequired): + return premiumAccountRequiredErr() + case errors.Is(err, domain.ErrBusinessProfileInvalid): + return inputRequestInvalidErr() + case errors.Is(err, domain.ErrBusinessRecipientsEmpty): + return tgerr400("BUSINESS_RECIPIENTS_EMPTY") + case errors.Is(err, domain.ErrBotBusinessMissing): + return botBusinessMissingErr() + case errors.Is(err, domain.ErrBotNotConnectedYet): + return botNotConnectedYetErr() + case errors.Is(err, domain.ErrBotAlreadyDisabled): + return botAlreadyDisabledErr() + case errors.Is(err, domain.ErrBusinessChatLinkInvalid): + return inputRequestInvalidErr() + case errors.Is(err, domain.ErrBusinessChatLinkNotFound): + return chatlinkSlugExpiredErr() + case errors.Is(err, domain.ErrBusinessChatLinksTooMuch): + return tgerr400("CHATLINKS_TOO_MUCH") + case errors.Is(err, domain.ErrShortcutInvalid): + return shortcutInvalidErr() + case errors.Is(err, domain.ErrShortcutOccupied): + return tgerr400("SHORTCUT_OCCUPIED") + case errors.Is(err, domain.ErrQuickRepliesTooMuch): + return tgerr400("SHORTCUTS_TOO_MUCH") + default: + return internalErr() + } +} + +func (r *Router) applyBusinessProfileToUserFull(ctx context.Context, full *tg.UserFull, profile domain.BusinessProfile) { + if full == nil { + return + } + if hours, ok := tgBusinessWorkHours(profile.WorkHours); ok { + full.SetBusinessWorkHours(hours) + } + if location, ok := tgBusinessLocation(profile.Location); ok { + full.SetBusinessLocation(location) + } + if intro, ok := r.tgBusinessIntro(ctx, profile.Intro); ok { + full.SetBusinessIntro(intro) + } + if greeting, ok := tgBusinessGreeting(profile.Greeting); ok { + full.SetBusinessGreetingMessage(greeting) + } + if away, ok := tgBusinessAway(profile.Away); ok { + full.SetBusinessAwayMessage(away) + } +} diff --git a/internal/rpc/account_color_rpc_test.go b/internal/rpc/account_color_rpc_test.go new file mode 100644 index 00000000..9bef1e1b --- /dev/null +++ b/internal/rpc/account_color_rpc_test.go @@ -0,0 +1,147 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestAccountUpdateColorPersistsExplicitZeroAndPushesSelfUser(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550003201", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + sessions := &captureSessions{} + router := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + color := &tg.PeerColor{} + color.SetColor(0) + color.SetBackgroundEmojiID(123456) + req := &tg.AccountUpdateColorRequest{} + req.SetColor(color) + ok, err := router.onAccountUpdateColor(WithUserID(ctx, owner.ID), req) + if err != nil || !ok { + t.Fatalf("update color = ok %v err %v, want true/nil", ok, err) + } + + saved, found, err := userStore.ByID(ctx, owner.ID) + if err != nil || !found { + t.Fatalf("load saved user found=%v err=%v", found, err) + } + assertDomainPeerColor(t, saved.Color, true, 0, 123456) + assertTgUserPeerColor(t, tgSelfUser(saved).GetColor, true, 0, 123456) + + snap := sessions.snapshot() + if snap.userID != owner.ID { + t.Fatalf("push user = %d, want %d", snap.userID, owner.ID) + } + updates, ok := snap.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", snap.message) + } + if len(updates.Users) != 1 { + t.Fatalf("pushed users = %d, want 1 self user", len(updates.Users)) + } + pushedUser, ok := updates.Users[0].(*tg.User) + if !ok { + t.Fatalf("pushed user = %T, want *tg.User", updates.Users[0]) + } + assertTgUserPeerColor(t, pushedUser.GetColor, true, 0, 123456) +} + +func TestAccountUpdateColorProfileSetAndClear(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550003202", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + router := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + }, zaptest.NewLogger(t), clock.System) + + color := &tg.PeerColor{} + color.SetColor(3) + color.SetBackgroundEmojiID(777) + req := &tg.AccountUpdateColorRequest{} + req.SetForProfile(true) + req.SetColor(color) + if ok, err := router.onAccountUpdateColor(WithUserID(ctx, owner.ID), req); err != nil || !ok { + t.Fatalf("update profile color = ok %v err %v, want true/nil", ok, err) + } + saved, _, _ := userStore.ByID(ctx, owner.ID) + assertDomainPeerColor(t, saved.ProfileColor, true, 3, 777) + assertTgUserPeerColor(t, tgSelfUser(saved).GetProfileColor, true, 3, 777) + + clear := &tg.AccountUpdateColorRequest{} + clear.SetForProfile(true) + if ok, err := router.onAccountUpdateColor(WithUserID(ctx, owner.ID), clear); err != nil || !ok { + t.Fatalf("clear profile color = ok %v err %v, want true/nil", ok, err) + } + saved, _, _ = userStore.ByID(ctx, owner.ID) + if !saved.ProfileColor.Empty() { + t.Fatalf("profile color after clear = %+v, want empty", saved.ProfileColor) + } + if _, ok := tgSelfUser(saved).GetProfileColor(); ok { + t.Fatal("self user profile_color after clear must be absent") + } +} + +func TestAccountUpdateColorRejectsUnsupportedInputs(t *testing.T) { + router := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), 1000000001) + + invalid := &tg.PeerColor{} + invalid.SetColor(99) + req := &tg.AccountUpdateColorRequest{} + req.SetColor(invalid) + if ok, err := router.onAccountUpdateColor(ctx, req); ok || !tgerr.Is(err, "COLOR_INVALID") { + t.Fatalf("invalid color = ok %v err %v, want COLOR_INVALID", ok, err) + } + + collectible := &tg.AccountUpdateColorRequest{} + collectible.SetColor(&tg.InputPeerColorCollectible{CollectibleID: 42}) + if ok, err := router.onAccountUpdateColor(ctx, collectible); ok || !tgerr.Is(err, "COLOR_INVALID") { + t.Fatalf("collectible color = ok %v err %v, want COLOR_INVALID", ok, err) + } +} + +func assertDomainPeerColor(t *testing.T, got domain.PeerColor, hasColor bool, color int, backgroundEmojiID int64) { + t.Helper() + if got.HasColor != hasColor || got.Color != color || got.BackgroundEmojiID != backgroundEmojiID { + t.Fatalf("domain peer color = %+v, want has=%v color=%d bg=%d", got, hasColor, color, backgroundEmojiID) + } +} + +func assertTgUserPeerColor(t *testing.T, get func() (tg.PeerColorClass, bool), hasColor bool, color int, backgroundEmojiID int64) { + t.Helper() + got, ok := get() + if !ok { + t.Fatal("tg user missing peer color") + } + peerColor, ok := got.(*tg.PeerColor) + if !ok { + t.Fatalf("tg peer color = %T, want *tg.PeerColor", got) + } + gotColor, gotHasColor := peerColor.GetColor() + if gotHasColor != hasColor || gotColor != color { + t.Fatalf("tg peer color id = %d has=%v, want %d has=%v", gotColor, gotHasColor, color, hasColor) + } + gotBackgroundEmojiID, gotHasBackgroundEmojiID := peerColor.GetBackgroundEmojiID() + if gotBackgroundEmojiID != backgroundEmojiID || gotHasBackgroundEmojiID != (backgroundEmojiID != 0) { + t.Fatalf("tg peer color bg = %d has=%v, want %d", gotBackgroundEmojiID, gotHasBackgroundEmojiID, backgroundEmojiID) + } +} diff --git a/internal/rpc/account_notify.go b/internal/rpc/account_notify.go new file mode 100644 index 00000000..3ce780c6 --- /dev/null +++ b/internal/rpc/account_notify.go @@ -0,0 +1,313 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// accountNotifySettingsService 是 per-scope 通知设置持久化的可选扩展,由 +// *app/account.Service 实现。未接通时各 handler 回落历史默认(tdesktop.NotifySettings)。 +type accountNotifySettingsService interface { + GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, error) + SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error + ResetNotifySettings(ctx context.Context, ownerUserID int64) error + PeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) + AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) + ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error) +} + +func (r *Router) accountNotifySvc() (accountNotifySettingsService, bool) { + svc, ok := r.deps.Account.(accountNotifySettingsService) + return svc, ok +} + +// notifyScopeFromInput 把 InputNotifyPeer 解析为业务作用域。peer/forumTopic 需解析 +// 出 domain.Peer(不校验访问权限——通知偏好是请求者自己对某 peer 的设置)。 +func (r *Router) notifyScopeFromInput(userID int64, in tg.InputNotifyPeerClass) (domain.NotifyScope, bool) { + switch p := in.(type) { + case *tg.InputNotifyUsers: + return domain.NotifyScope{Kind: domain.NotifyScopeUsers}, true + case *tg.InputNotifyChats: + return domain.NotifyScope{Kind: domain.NotifyScopeChats}, true + case *tg.InputNotifyBroadcasts: + return domain.NotifyScope{Kind: domain.NotifyScopeBroadcasts}, true + case *tg.InputNotifyPeer: + peer, ok := r.domainPeerFromInputPeer(userID, p.Peer) + if !ok { + return domain.NotifyScope{}, false + } + return domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: peer}, true + case *tg.InputNotifyForumTopic: + peer, ok := r.domainPeerFromInputPeer(userID, p.Peer) + if !ok { + return domain.NotifyScope{}, false + } + return domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: peer, TopicID: p.TopMsgID}, true + default: + return domain.NotifyScope{}, false + } +} + +func (r *Router) onAccountGetNotifySettings(ctx context.Context, peer tg.InputNotifyPeerClass) (*tg.PeerNotifySettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + scope, ok := r.notifyScopeFromInput(userID, peer) + svc, hasSvc := r.accountNotifySvc() + if !ok || !hasSvc { + return tdesktop.NotifySettings(), nil + } + settings, err := svc.GetNotifySettings(ctx, userID, scope) + if err != nil { + return nil, internalErr() + } + return tgPeerNotifySettings(&settings), nil +} + +func (r *Router) onAccountUpdateNotifySettings(ctx context.Context, req *tg.AccountUpdateNotifySettingsRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + scope, ok := r.notifyScopeFromInput(userID, req.Peer) + if !ok { + return false, peerIDInvalidErr() + } + settings := domainPeerNotifySettings(req.Settings) + if svc, ok := r.accountNotifySvc(); ok { + if err := svc.SaveNotifySettings(ctx, userID, scope, settings); err != nil { + return false, internalErr() + } + r.notifySettings.Delete(userID) + } + // 推 updateNotifySettings 给本人其它在线设备(多设备静音同步)。 + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateNotifySettings{ + Peer: tgNotifyPeer(scope), + NotifySettings: *tgPeerNotifySettings(&settings), + }}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + }) + return true, nil +} + +func (r *Router) onAccountResetNotifySettings(ctx context.Context) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if svc, ok := r.accountNotifySvc(); ok { + if err := svc.ResetNotifySettings(ctx, userID); err != nil { + return false, internalErr() + } + r.notifySettings.Delete(userID) + } + return true, nil +} + +// onAccountGetNotifyExceptions 返回"有自定义通知设置的会话"全局索引:每条异常一个 +// updateNotifySettings,附带被引用 peer 的 Users/Chats。默认(无 compare 标志)只返 +// 消息级异常(mute/silent/show_previews);compare_stories 额外纳入仅 story 维度异常。 +func (r *Router) onAccountGetNotifyExceptions(ctx context.Context, req *tg.AccountGetNotifyExceptionsRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + empty := &tg.Updates{Updates: []tg.UpdateClass{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: int(r.clock.Now().Unix())} + svc, ok := r.accountNotifySvc() + if !ok { + return empty, nil + } + exceptions, err := svc.ListNotifyExceptions(ctx, userID) + if err != nil { + return nil, internalErr() + } + compareStories := req != nil && req.CompareStories + var filterPeer *domain.Peer + if req != nil { + if p, ok := req.GetPeer(); ok { + if scope, ok := r.notifyScopeFromInput(userID, p); ok && scope.Kind == domain.NotifyScopePeer { + fp := scope.Peer + filterPeer = &fp + } + } + } + updates := make([]tg.UpdateClass, 0, len(exceptions)) + userIDs := make([]int64, 0) + channelIDs := make([]int64, 0) + for _, ex := range exceptions { + if filterPeer != nil && ex.Peer != *filterPeer { + continue + } + if !notifyExceptionQualifies(ex.Settings, compareStories) { + continue + } + scope := domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: ex.Peer, TopicID: ex.TopicID} + s := ex.Settings + updates = append(updates, &tg.UpdateNotifySettings{ + Peer: tgNotifyPeer(scope), + NotifySettings: *tgPeerNotifySettings(&s), + }) + switch ex.Peer.Type { + case domain.PeerTypeUser: + userIDs = append(userIDs, ex.Peer.ID) + case domain.PeerTypeChannel: + channelIDs = append(channelIDs, ex.Peer.ID) + } + } + if len(updates) == 0 { + return empty, nil + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, userID, userIDs), + Chats: r.tgChatsForChannelIDs(ctx, userID, channelIDs), + Date: int(r.clock.Now().Unix()), + }, nil +} + +// notifyExceptionQualifies 判定一条异常是否纳入 getNotifyExceptions 结果。 +// 自定义铃声未建模故 compare_sound 无额外效果。 +func notifyExceptionQualifies(s domain.PeerNotifySettings, compareStories bool) bool { + if s.MuteUntil != nil || s.Silent != nil || s.ShowPreviews != nil { + return true + } + if compareStories && (s.StoriesMuted != nil || s.StoriesHideSender != nil) { + return true + } + return false +} + +// tgChatsForChannelIDs 把一组 channel id 解析为 tg.Chat(无权限/已失效者跳过)。 +// perf:一次批量 GetChannels,而非逐个 GetChannel(N+1)。 +func (r *Router) tgChatsForChannelIDs(ctx context.Context, viewerUserID int64, channelIDs []int64) []tg.ChatClass { + out := make([]tg.ChatClass, 0, len(channelIDs)) + if r.deps.Channels == nil || len(channelIDs) == 0 { + return out + } + views, err := r.deps.Channels.GetChannels(ctx, viewerUserID, channelIDs) + if err != nil { + return out + } + for _, view := range views { + out = append(out, tgChannelChatForView(viewerUserID, view)) + } + return out +} + +// tgPeerNotifySettings 把存储的 per-peer 设置叠加在默认通知设置上:未设置的字段 +// 保留默认(声音=default、show_previews=true 等),已设置字段覆盖。nil=纯默认, +// 与历史 tdesktop.NotifySettings() 行为一致。 +func tgPeerNotifySettings(s *domain.PeerNotifySettings) *tg.PeerNotifySettings { + out := tdesktop.NotifySettings() + if s == nil { + return out + } + if s.ShowPreviews != nil { + out.SetShowPreviews(*s.ShowPreviews) + } + if s.Silent != nil { + out.SetSilent(*s.Silent) + } + if s.MuteUntil != nil { + out.SetMuteUntil(*s.MuteUntil) + } + if s.StoriesMuted != nil { + out.SetStoriesMuted(*s.StoriesMuted) + } + if s.StoriesHideSender != nil { + out.SetStoriesHideSender(*s.StoriesHideSender) + } + return out +} + +func domainPeerNotifySettings(in tg.InputPeerNotifySettings) domain.PeerNotifySettings { + out := domain.PeerNotifySettings{} + if v, ok := in.GetShowPreviews(); ok { + out.ShowPreviews = &v + } + if v, ok := in.GetSilent(); ok { + out.Silent = &v + } + if v, ok := in.GetMuteUntil(); ok { + out.MuteUntil = &v + } + if v, ok := in.GetStoriesMuted(); ok { + out.StoriesMuted = &v + } + if v, ok := in.GetStoriesHideSender(); ok { + out.StoriesHideSender = &v + } + return out +} + +func tgNotifyPeer(scope domain.NotifyScope) tg.NotifyPeerClass { + switch scope.Kind { + case domain.NotifyScopeUsers: + return &tg.NotifyUsers{} + case domain.NotifyScopeChats: + return &tg.NotifyChats{} + case domain.NotifyScopeBroadcasts: + return &tg.NotifyBroadcasts{} + case domain.NotifyScopePeer: + peer := tgPeer(scope.Peer) + if scope.TopicID != 0 { + return &tg.NotifyForumTopic{Peer: peer, TopMsgID: scope.TopicID} + } + return &tg.NotifyPeer{Peer: peer} + default: + return &tg.NotifyUsers{} + } +} + +// withDialogNotifySettings 装配 dialog 列表的 per-peer 通知设置,让静音状态在列表正确 +// 显示且跨重启恢复。perf:从 per-user notify 缓存读取(命中即 0 PG),而非每次 getDialogs +// 都查 notify_settings——绝大多数用户没有任何自定义静音,缓存命中后零数据库开销。 +func (r *Router) withDialogNotifySettings(ctx context.Context, viewerUserID int64, list domain.DialogList) domain.DialogList { + if len(list.Dialogs) == 0 { + return list + } + settings := r.userNotifySettings(ctx, viewerUserID) + if len(settings) == 0 { + return list + } + for i := range list.Dialogs { + if s, ok := settings[list.Dialogs[i].Peer]; ok { + sc := s.Clone() + list.Dialogs[i].NotifySettings = &sc + } + } + return list +} + +// applyNotifySettingsToUserFull 在缓存后 overlay userFull 的 notify_settings(避免 +// 投影缓存使 notify 状态陈旧)。perf:从 per-user notify 缓存读取,不再每次 getFullUser +// 单 peer 查 PG。 +func (r *Router) applyNotifySettingsToUserFull(ctx context.Context, viewerUserID, ownerUserID int64, full *tg.UserFull) { + settings := r.userNotifySettings(ctx, viewerUserID) + s, ok := settings[domain.Peer{Type: domain.PeerTypeUser, ID: ownerUserID}] + if !ok || s.IsZero() { + return + } + full.NotifySettings = *tgPeerNotifySettings(&s) +} + +// applyNotifySettingsToChannelFull 在缓存后 overlay channelFull 的 notify_settings。 +func (r *Router) applyNotifySettingsToChannelFull(ctx context.Context, viewerUserID, channelID int64, full *tg.ChannelFull) { + settings := r.userNotifySettings(ctx, viewerUserID) + s, ok := settings[domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}] + if !ok || s.IsZero() { + return + } + full.NotifySettings = *tgPeerNotifySettings(&s) +} diff --git a/internal/rpc/account_notify_rpc_test.go b/internal/rpc/account_notify_rpc_test.go new file mode 100644 index 00000000..3e27ef24 --- /dev/null +++ b/internal/rpc/account_notify_rpc_test.go @@ -0,0 +1,289 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appaccount "telesrv/internal/app/account" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func notifyRouter(t *testing.T) (*Router, *captureSessions) { + t.Helper() + passwordStore := memory.NewPasswordStore() + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Account: appaccount.NewService(passwordStore, appaccount.WithNotifySettings(passwordStore)), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + return r, sessions +} + +// TestNotifySettingsRoundTripAndDialogProjection 回归:get/update/reset NotifySettings +// 此前是回显 stub(不持久化),mute 重启即丢、dialog 列表不反映静音。本测试验证 +// per-peer 持久化往返 + dialog 列表投影出 mute + updateNotifySettings 推送 + reset。 +func TestNotifySettingsRoundTripAndDialogProjection(t *testing.T) { + r, sessions := notifyRouter(t) + const viewer = int64(1000000001) + const peerID = int64(555) + ctx := WithUserID(context.Background(), viewer) + peerInput := &tg.InputNotifyPeer{Peer: &tg.InputPeerUser{UserID: peerID}} + + // 默认:未配置 → mute_until=0(不静音)。 + def, err := r.onAccountGetNotifySettings(ctx, peerInput) + if err != nil { + t.Fatalf("get default: %v", err) + } + if mu, _ := def.GetMuteUntil(); mu != 0 { + t.Fatalf("default mute_until = %d, want 0", mu) + } + + // mute 该 peer。 + in := tg.InputPeerNotifySettings{} + in.SetMuteUntil(2000000000) + in.SetSilent(true) + if ok, err := r.onAccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{Peer: peerInput, Settings: in}); err != nil || !ok { + t.Fatalf("update notify = ok %v err %v", ok, err) + } + + // 推送 updateNotifySettings。 + snap := sessions.snapshot() + updates, ok := snap.message.(*tg.Updates) + if !ok || len(updates.Updates) == 0 { + t.Fatalf("pushed message = %#v, want *tg.Updates with updates", snap.message) + } + upd, ok := updates.Updates[0].(*tg.UpdateNotifySettings) + if !ok { + t.Fatalf("pushed update = %T, want *tg.UpdateNotifySettings", updates.Updates[0]) + } + if np, ok := upd.Peer.(*tg.NotifyPeer); !ok { + t.Fatalf("pushed notify peer = %T, want *tg.NotifyPeer", upd.Peer) + } else if pu, ok := np.Peer.(*tg.PeerUser); !ok || pu.UserID != peerID { + t.Fatalf("pushed notify peer = %#v, want user %d", np.Peer, peerID) + } + + // get 读回 mute。 + got, err := r.onAccountGetNotifySettings(ctx, peerInput) + if err != nil { + t.Fatalf("get after mute: %v", err) + } + if mu, _ := got.GetMuteUntil(); mu != 2000000000 { + t.Fatalf("mute_until after mute = %d, want 2000000000", mu) + } + if silent, ok := got.GetSilent(); !ok || !silent { + t.Fatalf("silent after mute = %v ok %v, want true", silent, ok) + } + + // dialog 列表投影出 mute(跨重启恢复的关键)。 + list := domain.DialogList{Dialogs: []domain.Dialog{ + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, TopMessage: 1}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 999}, TopMessage: 1}, // 未静音对照 + }} + out, ok := r.tgMessagesDialogs(ctx, viewer, list).(*tg.MessagesDialogs) + if !ok { + t.Fatalf("dialogs projection type = %T", r.tgMessagesDialogs(ctx, viewer, list)) + } + muted := dialogByPeerUser(t, out.Dialogs, peerID) + if mu, _ := muted.NotifySettings.GetMuteUntil(); mu != 2000000000 { + t.Fatalf("dialog mute_until = %d, want 2000000000(列表未反映静音)", mu) + } + unmuted := dialogByPeerUser(t, out.Dialogs, 999) + if mu, _ := unmuted.NotifySettings.GetMuteUntil(); mu != 0 { + t.Fatalf("unmuted dialog mute_until = %d, want 0", mu) + } + + // reset → 回默认。 + if ok, err := r.onAccountResetNotifySettings(ctx); err != nil || !ok { + t.Fatalf("reset = ok %v err %v", ok, err) + } + after, err := r.onAccountGetNotifySettings(ctx, peerInput) + if err != nil { + t.Fatalf("get after reset: %v", err) + } + if mu, _ := after.GetMuteUntil(); mu != 0 { + t.Fatalf("mute_until after reset = %d, want 0", mu) + } +} + +// TestNotifySettingsCategoryDefaultScope 验证 inputNotifyUsers 等类别默认作用域独立持久化。 +func TestNotifySettingsCategoryDefaultScope(t *testing.T) { + r, _ := notifyRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + in := tg.InputPeerNotifySettings{} + in.SetMuteUntil(123456) + if ok, err := r.onAccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{Peer: &tg.InputNotifyUsers{}, Settings: in}); err != nil || !ok { + t.Fatalf("update users-default = ok %v err %v", ok, err) + } + // users 默认有值,chats 默认仍为默认(作用域隔离)。 + usersGot, err := r.onAccountGetNotifySettings(ctx, &tg.InputNotifyUsers{}) + if err != nil { + t.Fatalf("get users-default: %v", err) + } + if mu, _ := usersGot.GetMuteUntil(); mu != 123456 { + t.Fatalf("users-default mute_until = %d, want 123456", mu) + } + chatsGot, err := r.onAccountGetNotifySettings(ctx, &tg.InputNotifyChats{}) + if err != nil { + t.Fatalf("get chats-default: %v", err) + } + if mu, _ := chatsGot.GetMuteUntil(); mu != 0 { + t.Fatalf("chats-default mute_until = %d, want 0(作用域应隔离)", mu) + } +} + +// TestGetNotifyExceptions 验证 getNotifyExceptions 列出 per-peer 非默认设置: +// mute 多个 peer → 出现在异常列表;unmute → 退出;compare_stories 过滤 story-only。 +func TestGetNotifyExceptions(t *testing.T) { + r, _ := notifyRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + mute := func(peer tg.InputPeerClass, set func(*tg.InputPeerNotifySettings)) { + in := tg.InputPeerNotifySettings{} + set(&in) + if ok, err := r.onAccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{Peer: &tg.InputNotifyPeer{Peer: peer}, Settings: in}); err != nil || !ok { + t.Fatalf("update notify = ok %v err %v", ok, err) + } + } + + // mute 一个 user + 一个 channel。 + mute(&tg.InputPeerUser{UserID: 555}, func(in *tg.InputPeerNotifySettings) { in.SetMuteUntil(2000000000) }) + mute(&tg.InputPeerChannel{ChannelID: 777}, func(in *tg.InputPeerNotifySettings) { in.SetSilent(true) }) + + ex := notifyExceptions(t, r, ctx, &tg.AccountGetNotifyExceptionsRequest{}) + if len(ex) != 2 { + t.Fatalf("exceptions = %d, want 2", len(ex)) + } + if !exceptionsHaveUser(ex, 555) || !exceptionsHaveChannel(ex, 777) { + t.Fatalf("exceptions missing expected peers: %#v", ex) + } + + // unmute user 555(发空设置→清空)→ 退出异常列表。 + mute(&tg.InputPeerUser{UserID: 555}, func(in *tg.InputPeerNotifySettings) {}) + ex = notifyExceptions(t, r, ctx, &tg.AccountGetNotifyExceptionsRequest{}) + if len(ex) != 1 || !exceptionsHaveChannel(ex, 777) { + t.Fatalf("after unmute exceptions = %#v, want only channel 777", ex) + } + + // story-only 异常:默认不计入,compare_stories 计入。 + mute(&tg.InputPeerUser{UserID: 888}, func(in *tg.InputPeerNotifySettings) { in.SetStoriesMuted(true) }) + if got := notifyExceptions(t, r, ctx, &tg.AccountGetNotifyExceptionsRequest{}); len(got) != 1 { + t.Fatalf("default exceptions = %d, want 1 (story-only excluded)", len(got)) + } + withStories := &tg.AccountGetNotifyExceptionsRequest{} + withStories.CompareStories = true + if got := notifyExceptions(t, r, ctx, withStories); len(got) != 2 || !exceptionsHaveUser(got, 888) { + t.Fatalf("compare_stories exceptions = %#v, want story-only 888 included", got) + } +} + +func notifyExceptions(t *testing.T, r *Router, ctx context.Context, req *tg.AccountGetNotifyExceptionsRequest) []*tg.UpdateNotifySettings { + t.Helper() + out, err := r.onAccountGetNotifyExceptions(ctx, req) + if err != nil { + t.Fatalf("getNotifyExceptions: %v", err) + } + upd, ok := out.(*tg.Updates) + if !ok { + t.Fatalf("response = %T, want *tg.Updates", out) + } + res := make([]*tg.UpdateNotifySettings, 0, len(upd.Updates)) + for _, u := range upd.Updates { + if uns, ok := u.(*tg.UpdateNotifySettings); ok { + res = append(res, uns) + } + } + return res +} + +func exceptionsHaveUser(ex []*tg.UpdateNotifySettings, userID int64) bool { + for _, e := range ex { + if np, ok := e.Peer.(*tg.NotifyPeer); ok { + if pu, ok := np.Peer.(*tg.PeerUser); ok && pu.UserID == userID { + return true + } + } + } + return false +} + +func exceptionsHaveChannel(ex []*tg.UpdateNotifySettings, channelID int64) bool { + for _, e := range ex { + if np, ok := e.Peer.(*tg.NotifyPeer); ok { + if pc, ok := np.Peer.(*tg.PeerChannel); ok && pc.ChannelID == channelID { + return true + } + } + } + return false +} + +// countingNotifyService 包 *appaccount.Service 计数 AllPeerNotifySettings 调用, +// 验证 per-user notify 缓存短路了热路径查询。 +type countingNotifyService struct { + *appaccount.Service + allCalls int +} + +func (s *countingNotifyService) AllPeerNotifySettings(ctx context.Context, userID int64) (map[domain.Peer]domain.PeerNotifySettings, error) { + s.allCalls++ + return s.Service.AllPeerNotifySettings(ctx, userID) +} + +// TestNotifySettingsDialogProjectionCached 回归 P2-1:dialog 投影从 per-user notify +// 缓存读取——重复 getDialogs 只加载一次(命中 0 PG),update/reset 失效后重载。 +func TestNotifySettingsDialogProjectionCached(t *testing.T) { + passwordStore := memory.NewPasswordStore() + svc := &countingNotifyService{Service: appaccount.NewService(passwordStore, appaccount.WithNotifySettings(passwordStore))} + r := New(Config{}, Deps{Account: svc, Sessions: &captureSessions{}}, zaptest.NewLogger(t), clock.System) + const viewer = int64(1000000001) + ctx := WithUserID(context.Background(), viewer) + + in := tg.InputPeerNotifySettings{} + in.SetMuteUntil(2000000000) + if ok, err := r.onAccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{Peer: &tg.InputNotifyPeer{Peer: &tg.InputPeerUser{UserID: 555}}, Settings: in}); err != nil || !ok { + t.Fatalf("update notify: ok %v err %v", ok, err) + } + + list := domain.DialogList{Dialogs: []domain.Dialog{{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 555}, TopMessage: 1}}} + for i := 0; i < 3; i++ { + out := r.tgMessagesDialogs(ctx, viewer, list).(*tg.MessagesDialogs) + dlg := dialogByPeerUser(t, out.Dialogs, 555) + if mu, _ := dlg.NotifySettings.GetMuteUntil(); mu != 2000000000 { + t.Fatalf("iter %d dialog mute = %d, want 2000000000", i, mu) + } + } + if svc.allCalls != 1 { + t.Fatalf("AllPeerNotifySettings calls = %d, want 1 (后两次 getDialogs 应命中缓存)", svc.allCalls) + } + + // update 失效缓存 → 下次 getDialogs 重载。 + in2 := tg.InputPeerNotifySettings{} + in2.SetMuteUntil(2100000000) + if _, err := r.onAccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{Peer: &tg.InputNotifyPeer{Peer: &tg.InputPeerUser{UserID: 555}}, Settings: in2}); err != nil { + t.Fatalf("re-update notify: %v", err) + } + r.tgMessagesDialogs(ctx, viewer, list) + if svc.allCalls != 2 { + t.Fatalf("after invalidation calls = %d, want 2 (缓存失效应重载)", svc.allCalls) + } +} + +func dialogByPeerUser(t *testing.T, dialogs []tg.DialogClass, userID int64) *tg.Dialog { + t.Helper() + for _, d := range dialogs { + dlg, ok := d.(*tg.Dialog) + if !ok { + continue + } + if pu, ok := dlg.Peer.(*tg.PeerUser); ok && pu.UserID == userID { + return dlg + } + } + t.Fatalf("dialog for user %d not found", userID) + return nil +} diff --git a/internal/rpc/account_profile_emoji_test.go b/internal/rpc/account_profile_emoji_test.go new file mode 100644 index 00000000..46b2eb28 --- /dev/null +++ b/internal/rpc/account_profile_emoji_test.go @@ -0,0 +1,120 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" +) + +func TestAccountGetDefaultProfilePhotoEmojisUsesSeededEmojiSets(t *testing.T) { + files := &fakeFiles{sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindEmoji: { + {DocumentIDs: []int64{1001, 0, 1002, 1001}}, + {DocumentIDs: []int64{1003}}, + }, + domain.StickerSetKindSystem: { + {SystemKey: "animated_emoji", DocumentIDs: []int64{2001}}, + }, + }} + r := New(Config{}, Deps{Files: files}, zaptest.NewLogger(t), clock.System) + + got, err := r.onAccountGetDefaultProfilePhotoEmojis(context.Background(), 0) + if err != nil { + t.Fatalf("get default profile photo emojis: %v", err) + } + list, ok := got.(*tg.EmojiList) + if !ok { + t.Fatalf("default profile photo emojis = %T, want *tg.EmojiList", got) + } + if len(list.DocumentID) != 3 || list.DocumentID[0] != 1001 || list.DocumentID[1] != 1002 || list.DocumentID[2] != 1003 { + t.Fatalf("document ids = %v, want deduped seeded emoji ids", list.DocumentID) + } + if list.Hash == 0 { + t.Fatal("emoji list hash = 0, want stable non-zero hash") + } + + cached, err := r.onAccountGetDefaultProfilePhotoEmojis(context.Background(), list.Hash) + if err != nil { + t.Fatalf("get cached default profile photo emojis: %v", err) + } + if _, ok := cached.(*tg.EmojiListNotModified); !ok { + t.Fatalf("cached default profile photo emojis = %T, want notModified", cached) + } +} + +func TestAccountGetDefaultProfilePhotoEmojisFallsBackToSystemAnimatedEmoji(t *testing.T) { + files := &fakeFiles{sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindSystem: { + {SystemKey: "dice:🎲", DocumentIDs: []int64{3001}}, + {SystemKey: "animated_emoji", DocumentIDs: []int64{4001, 4002, 4001}}, + {SystemKey: "emoji_generic_animations", DocumentIDs: []int64{5001}}, + }, + }} + r := New(Config{}, Deps{Files: files}, zaptest.NewLogger(t), clock.System) + + got, err := r.onAccountGetDefaultProfilePhotoEmojis(context.Background(), 0) + if err != nil { + t.Fatalf("get default profile photo emojis: %v", err) + } + list, ok := got.(*tg.EmojiList) + if !ok { + t.Fatalf("default profile photo emojis = %T, want *tg.EmojiList", got) + } + if len(list.DocumentID) != 2 || list.DocumentID[0] != 4001 || list.DocumentID[1] != 4002 { + t.Fatalf("document ids = %v, want animated_emoji fallback only", list.DocumentID) + } +} + +func TestAccountGetDefaultBackgroundEmojisUsesStatusPack(t *testing.T) { + files := &fakeFiles{sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindEmoji: { + {ShortName: "OtherPack", DocumentIDs: []int64{9001}}, + {ShortName: "StatusPack", DocumentIDs: []int64{1001, 0, 1002, 1001}}, + }, + }} + r := New(Config{}, Deps{Files: files}, zaptest.NewLogger(t), clock.System) + + got, err := r.onAccountGetDefaultBackgroundEmojis(context.Background(), 0) + if err != nil { + t.Fatalf("get default background emojis: %v", err) + } + list, ok := got.(*tg.EmojiList) + if !ok { + t.Fatalf("default background emojis = %T, want *tg.EmojiList", got) + } + if len(list.DocumentID) != 2 || list.DocumentID[0] != 1001 || list.DocumentID[1] != 1002 { + t.Fatalf("document ids = %v, want deduped StatusPack ids", list.DocumentID) + } + if list.Hash == 0 { + t.Fatal("emoji list hash = 0, want stable non-zero hash") + } + + cached, err := r.onAccountGetDefaultBackgroundEmojis(context.Background(), list.Hash) + if err != nil { + t.Fatalf("get cached default background emojis: %v", err) + } + if _, ok := cached.(*tg.EmojiListNotModified); !ok { + t.Fatalf("cached default background emojis = %T, want notModified", cached) + } +} + +func TestAccountGetDefaultBackgroundEmojisFallsBackWhenStatusPackMissing(t *testing.T) { + r := New(Config{}, Deps{Files: &fakeFiles{}}, zaptest.NewLogger(t), clock.System) + + got, err := r.onAccountGetDefaultBackgroundEmojis(context.Background(), 0) + if err != nil { + t.Fatalf("get default background emojis without StatusPack: %v", err) + } + list, ok := got.(*tg.EmojiList) + if !ok { + t.Fatalf("fallback default background emojis = %T, want *tg.EmojiList", got) + } + if list.Hash != 0 || len(list.DocumentID) != 0 { + t.Fatalf("fallback list = hash %d ids %v, want empty compat stub", list.Hash, list.DocumentID) + } +} diff --git a/internal/rpc/account_settings_cache.go b/internal/rpc/account_settings_cache.go new file mode 100644 index 00000000..250188cb --- /dev/null +++ b/internal/rpc/account_settings_cache.go @@ -0,0 +1,57 @@ +package rpc + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +const ( + accountSettingsCacheMaxEntries = 4096 + // accountSettingsCacheTTL 兜底跨实例失效;同实例 Set 即时失效。设置页连续调 + // getGlobalPrivacy/getAccountTTL/getContentSettings/getContactSignUp 时只查一次 PG。 + accountSettingsCacheTTL = 60 * time.Second +) + +// accountSettingsCache 缓存 userID→AccountSettings,避免设置页 4 个 get handler 各查 +// 一次同一行(N+1)。AccountSettings 全值类型,无需深拷贝。 +type accountSettingsCache struct { + cache *readmodelcache.Cache[int64, domain.AccountSettings] +} + +func newAccountSettingsCache(now func() time.Time) *accountSettingsCache { + return &accountSettingsCache{ + cache: readmodelcache.New[int64, domain.AccountSettings](readmodelcache.Config[int64, domain.AccountSettings]{ + MaxEntries: accountSettingsCacheMaxEntries, + TTL: accountSettingsCacheTTL, + Now: now, + }), + } +} + +func (c *accountSettingsCache) getOrLoad(ctx context.Context, userID int64, load func() (domain.AccountSettings, error)) (domain.AccountSettings, error) { + if c == nil || userID == 0 { + return load() + } + return c.cache.GetOrLoad(ctx, userID, load) +} + +func (c *accountSettingsCache) Delete(userID int64) { + if c == nil || userID == 0 { + return + } + c.cache.Invalidate(userID) +} + +// cachedAccountSettings 取(缓存的)账号单例设置;服务未接通返回默认。 +func (r *Router) cachedAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) { + svc, ok := r.accountSettingsSvc() + if !ok { + return domain.DefaultAccountSettings(), nil + } + return r.accountSettings.getOrLoad(ctx, userID, func() (domain.AccountSettings, error) { + return svc.GetAccountSettings(ctx, userID) + }) +} diff --git a/internal/rpc/account_settings_rpc_test.go b/internal/rpc/account_settings_rpc_test.go new file mode 100644 index 00000000..628825b5 --- /dev/null +++ b/internal/rpc/account_settings_rpc_test.go @@ -0,0 +1,138 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + appaccount "telesrv/internal/app/account" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// accountSettingsRouter 装配一个接通账号设置持久化(内存)的 Router。 +func accountSettingsRouter(t *testing.T) *Router { + t.Helper() + passwordStore := memory.NewPasswordStore() + return New(Config{}, Deps{ + Account: appaccount.NewService(passwordStore, appaccount.WithAccountSettings(passwordStore)), + }, zaptest.NewLogger(t), clock.System) +} + +// TestAccountSettingsRoundTrip 回归:globalPrivacy/accountTTL/contentSettings/ +// contactSignUpNotification 此前是硬编码回显 stub(set 不持久化)。本测试验证 +// set→get 真往返:写入后读回与写入一致。 +func TestAccountSettingsRoundTrip(t *testing.T) { + r := accountSettingsRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + // 默认(未持久化):globalPrivacy 全关、TTL 365、sensitive 关但可切换、注册通知不静音。 + gp, err := r.onAccountGetGlobalPrivacySettings(ctx) + if err != nil { + t.Fatalf("get global privacy: %v", err) + } + if gp.ArchiveAndMuteNewNoncontactPeers || gp.HideReadMarks { + t.Fatalf("default global privacy must be all-off, got %+v", gp) + } + if ttl, err := r.onAccountGetAccountTTL(ctx); err != nil || ttl.Days != domain.DefaultAccountTTLDays { + t.Fatalf("default ttl = %v err %v, want %d", ttl, err, domain.DefaultAccountTTLDays) + } + cs, err := r.onAccountGetContentSettings(ctx) + if err != nil || cs.SensitiveEnabled || !cs.SensitiveCanChange { + t.Fatalf("default content settings = %+v err %v, want sensitive off + can_change on", cs, err) + } + if silent, err := r.onAccountGetContactSignUpNotification(ctx); err != nil || silent { + t.Fatalf("default contact signup silent = %v err %v, want false", silent, err) + } + + // 写 globalPrivacy(含 paid stars 可选字段)→ 读回一致。 + in := tg.GlobalPrivacySettings{ + ArchiveAndMuteNewNoncontactPeers: true, + HideReadMarks: true, + NewNoncontactPeersRequirePremium: true, + } + in.SetNoncontactPeersPaidStars(50) + saved, err := r.onAccountSetGlobalPrivacySettings(ctx, in) + if err != nil { + t.Fatalf("set global privacy: %v", err) + } + assertGlobalPrivacy(t, saved, in) + got, err := r.onAccountGetGlobalPrivacySettings(ctx) + if err != nil { + t.Fatalf("re-get global privacy: %v", err) + } + assertGlobalPrivacy(t, got, in) + + // 写 TTL → 读回。 + if _, err := r.onAccountSetAccountTTL(ctx, tg.AccountDaysTTL{Days: 30}); err != nil { + t.Fatalf("set ttl: %v", err) + } + if ttl, err := r.onAccountGetAccountTTL(ctx); err != nil || ttl.Days != 30 { + t.Fatalf("ttl after set = %v err %v, want 30", ttl, err) + } + // TTL=0 非法。 + if ok, err := r.onAccountSetAccountTTL(ctx, tg.AccountDaysTTL{Days: 0}); ok || !tgerr.Is(err, "TTL_DAYS_INVALID") { + t.Fatalf("ttl=0 = ok %v err %v, want TTL_DAYS_INVALID", ok, err) + } + + // 写 sensitive content → 读回。 + if ok, err := r.onAccountSetContentSettings(ctx, &tg.AccountSetContentSettingsRequest{SensitiveEnabled: true}); err != nil || !ok { + t.Fatalf("set content settings: ok %v err %v", ok, err) + } + if cs, err := r.onAccountGetContentSettings(ctx); err != nil || !cs.SensitiveEnabled || !cs.SensitiveCanChange { + t.Fatalf("content settings after set = %+v err %v, want sensitive on + can_change on", cs, err) + } + + // 写 contact signup silent → 读回。 + if ok, err := r.onAccountSetContactSignUpNotification(ctx, true); err != nil || !ok { + t.Fatalf("set contact signup silent: ok %v err %v", ok, err) + } + if silent, err := r.onAccountGetContactSignUpNotification(ctx); err != nil || !silent { + t.Fatalf("contact signup silent after set = %v err %v, want true", silent, err) + } + + // 互不干扰:写完 contactSignUp 后 globalPrivacy 仍是之前写入的值。 + if got, err := r.onAccountGetGlobalPrivacySettings(ctx); err != nil || !got.ArchiveAndMuteNewNoncontactPeers { + t.Fatalf("global privacy must survive other settings writes, got %+v err %v", got, err) + } +} + +// TestAccountSettingsNotWiredFallsBack 验证未接通持久化服务时各 handler 回落默认、不报错。 +func TestAccountSettingsNotWiredFallsBack(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), 1000000001) + + if _, err := r.onAccountGetGlobalPrivacySettings(ctx); err != nil { + t.Fatalf("get global privacy (unwired): %v", err) + } + if ttl, err := r.onAccountGetAccountTTL(ctx); err != nil || ttl.Days != domain.DefaultAccountTTLDays { + t.Fatalf("get ttl (unwired) = %v err %v", ttl, err) + } + if ok, err := r.onAccountSetGlobalPrivacySettings(ctx, tg.GlobalPrivacySettings{HideReadMarks: true}); err != nil || ok == nil { + t.Fatalf("set global privacy (unwired) must echo, ok %v err %v", ok, err) + } + if ok, err := r.onAccountSetContentSettings(ctx, &tg.AccountSetContentSettingsRequest{SensitiveEnabled: true}); err != nil || !ok { + t.Fatalf("set content settings (unwired): ok %v err %v", ok, err) + } +} + +func assertGlobalPrivacy(t *testing.T, got *tg.GlobalPrivacySettings, want tg.GlobalPrivacySettings) { + t.Helper() + if got.ArchiveAndMuteNewNoncontactPeers != want.ArchiveAndMuteNewNoncontactPeers || + got.KeepArchivedUnmuted != want.KeepArchivedUnmuted || + got.KeepArchivedFolders != want.KeepArchivedFolders || + got.HideReadMarks != want.HideReadMarks || + got.NewNoncontactPeersRequirePremium != want.NewNoncontactPeersRequirePremium || + got.DisplayGiftsButton != want.DisplayGiftsButton { + t.Fatalf("global privacy bools = %+v, want %+v", got, want) + } + wantStars, _ := want.GetNoncontactPeersPaidStars() + gotStars, _ := got.GetNoncontactPeersPaidStars() + if gotStars != wantStars { + t.Fatalf("noncontact paid stars = %d, want %d", gotStars, wantStars) + } +} diff --git a/internal/rpc/account_themes.go b/internal/rpc/account_themes.go new file mode 100644 index 00000000..90d47fac --- /dev/null +++ b/internal/rpc/account_themes.go @@ -0,0 +1,265 @@ +package rpc + +import ( + "context" + "strings" + + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +// onAccountGetThemes 返回内置默认主题(emoji 预览条用)+ 当前用户「创建 ∪ 安装」的 +// 自定义云主题(跨设备同步:换设备登录同账号即可看到自建主题)。哈希按整体主题集合计算, +// 集合变化即驱动客户端重取。 +// +// 按 req.Format 分流(这正是官方服务端的做法): +// - Android(format="android")把 account.getThemes 里 is_default 的主题当作 emoji +// 预览条素材,直接从 settings 渲染,不需要 document(见 DrKLO +// Theme.loadRemoteThemes / MediaDataController.generateEmojiPreviewThemes)。 +// - TDesktop(format="tdesktop")把 account.getThemes 的结果当作「云主题」网格,点击 +// 应用时要求主题携带 .tdesktop-theme document,否则弹 lng_theme_no_desktop +// 「doesn't include a version for Telegram Desktop」(见 data_cloud_themes.cpp +// CloudThemes::showPreview:documentId==0 且非 creator → 报错)。 +// +// 我们合成的 emoji 聊天主题只有 settings、没有 document,只适合 Android 的预览条。因此对 +// tdesktop 不下发这些默认主题,只返回用户自建/安装(带 document)的云主题——对全新账号即 +// 空网格,与官方 TDesktop 行为一致;TDesktop 的 emoji 聊天主题走 account.getChatThemes +// 的「单聊主题选择器」(基于 settings,无需 document)。 +func (r *Router) onAccountGetThemes(ctx context.Context, req *tg.AccountGetThemesRequest) (tg.AccountThemesClass, error) { + var themes []tg.Theme + format := "" + if req != nil { + format = req.GetFormat() + } + if !strings.EqualFold(format, "tdesktop") { + themes = tdesktop.DefaultThemeList() + } + if r.deps.Themes != nil { + if userID, _, err := r.currentUserID(ctx); err == nil && userID != 0 { + if userThemes, err := r.deps.Themes.ListForUser(ctx, userID); err == nil { + for _, t := range userThemes { + themes = append(themes, *r.tgTheme(ctx, t, userID)) + } + } + } + } + hash := themesListHash(themes) + if req != nil && req.GetHash() == hash { + return &tg.AccountThemesNotModified{}, nil + } + return &tg.AccountThemes{Hash: hash, Themes: themes}, nil +} + +// onAccountUploadTheme 把客户端上传的 .attheme 文件落成可下载的 Document 并返回。 +// 它不创建主题实体——客户端随后用返回的 Document 调 createTheme/updateTheme。 +func (r *Router) onAccountUploadTheme(ctx context.Context, req *tg.AccountUploadThemeRequest) (tg.DocumentClass, error) { + if req == nil { + return nil, tgerr400("THEME_FILE_INVALID") + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, authKeyUnregisteredErr() + } + if r.deps.Files == nil { + return nil, notImplementedErr() + } + if req.File == nil { + return nil, tgerr400("THEME_FILE_INVALID") + } + if !strings.HasPrefix(req.MimeType, "application/x-tgtheme-") { + return nil, tgerr400("THEME_MIME_INVALID") + } + ref, ok := uploadedFileRef(userID, req.File) + if !ok { + return nil, tgerr400("THEME_FILE_INVALID") + } + spec := domain.DocumentSpec{ + MimeType: req.MimeType, + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: req.FileName}}, + } + if thumb, ok := req.GetThumb(); ok { + if tref, ok2 := uploadedFileRef(userID, thumb); ok2 { + spec.Thumb = &tref + } + } + doc, err := r.deps.Files.CreateDocumentFromUpload(ctx, ref, spec) + if err != nil { + return nil, mediaUploadErr(err) + } + return tgDocument(doc), nil +} + +// onAccountCreateTheme 创建一份新的自定义云主题。空 slug 由服务端自动分配;返回的 +// tg.Theme 设 creator=true,使客户端下次重传走 updateTheme 而非再次 create。 +func (r *Router) onAccountCreateTheme(ctx context.Context, req *tg.AccountCreateThemeRequest) (*tg.Theme, error) { + if req == nil { + return nil, themeInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, authKeyUnregisteredErr() + } + if r.deps.Themes == nil { + return nil, notImplementedErr() + } + spec := domain.ThemeSpec{ + CreatorUserID: userID, + Slug: req.Slug, + Title: req.Title, + } + if doc, ok := req.GetDocument(); ok { + if d, ok2 := doc.(*tg.InputDocument); ok2 { + spec.DocumentID = d.ID + } + } + if settings, ok := req.GetSettings(); ok { + spec.Settings = domainThemeSettingsFromInput(settings) + } + t, err := r.deps.Themes.Create(ctx, spec) + if err != nil { + return nil, themeErr(err) + } + r.invalidateRPCProjectionForUser(userID) + return r.tgTheme(ctx, t, userID), nil +} + +// onAccountUpdateTheme 更新创建者自己的主题(部分字段)。 +func (r *Router) onAccountUpdateTheme(ctx context.Context, req *tg.AccountUpdateThemeRequest) (*tg.Theme, error) { + if req == nil { + return nil, themeInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, authKeyUnregisteredErr() + } + if r.deps.Themes == nil { + return nil, notImplementedErr() + } + ref, ok := themeRefFromInput(req.Theme) + if !ok { + return nil, themeInvalidErr() + } + var upd domain.ThemeUpdate + if v, ok := req.GetSlug(); ok { + upd.Slug = &v + } + if v, ok := req.GetTitle(); ok { + upd.Title = &v + } + if doc, ok := req.GetDocument(); ok { + if d, ok2 := doc.(*tg.InputDocument); ok2 { + id := d.ID + upd.DocumentID = &id + } + } + if settings, ok := req.GetSettings(); ok { + s := domainThemeSettingsFromInput(settings) + upd.Settings = &s + } + t, err := r.deps.Themes.Update(ctx, userID, ref, upd) + if err != nil { + return nil, themeErr(err) + } + r.invalidateRPCProjectionForUser(userID) + return r.tgTheme(ctx, t, userID), nil +} + +// onAccountSaveTheme 把主题加入/移出用户的已存列表。 +func (r *Router) onAccountSaveTheme(ctx context.Context, req *tg.AccountSaveThemeRequest) (bool, error) { + if req == nil { + return false, themeInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if userID == 0 { + return false, authKeyUnregisteredErr() + } + if r.deps.Themes == nil { + return false, notImplementedErr() + } + ref, ok := themeRefFromInput(req.Theme) + if !ok { + return false, themeInvalidErr() + } + if req.Unsave { + err = r.deps.Themes.Unsave(ctx, userID, ref) + } else { + err = r.deps.Themes.Save(ctx, userID, ref) + } + if err != nil { + return false, themeErr(err) + } + r.invalidateRPCProjectionForUser(userID) + return true, nil +} + +// onAccountInstallTheme 应用主题(计数 +1,加入已安装列表)。无 theme 引用时为 +// 「安装本地/基础主题」的 no-op。 +func (r *Router) onAccountInstallTheme(ctx context.Context, req *tg.AccountInstallThemeRequest) (bool, error) { + if req == nil { + return false, themeInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if userID == 0 { + return false, authKeyUnregisteredErr() + } + if r.deps.Themes == nil { + return false, notImplementedErr() + } + dark := req.GetDark() + theme, ok := req.GetTheme() + if !ok { + return true, nil // 无 theme 引用:基础主题 no-op 安装 + } + ref, ok := themeRefFromInput(theme) + if !ok { + return false, themeInvalidErr() + } + if err := r.deps.Themes.Install(ctx, userID, ref, dark); err != nil { + return false, themeErr(err) + } + r.invalidateRPCProjectionForUser(userID) + return true, nil +} + +// onAccountGetTheme 按 id 或 slug(深链 telesrv.net/addtheme/)取主题。 +func (r *Router) onAccountGetTheme(ctx context.Context, req *tg.AccountGetThemeRequest) (*tg.Theme, error) { + if req == nil { + return nil, themeInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Themes == nil { + return nil, notImplementedErr() + } + ref, ok := themeRefFromInput(req.Theme) + if !ok { + return nil, themeInvalidErr() + } + t, ok, err := r.deps.Themes.Get(ctx, ref) + if err != nil { + return nil, themeErr(err) + } + if !ok { + return nil, themeInvalidErr() + } + return r.tgTheme(ctx, t, userID), nil +} diff --git a/internal/rpc/account_themes_compat.go b/internal/rpc/account_themes_compat.go new file mode 100644 index 00000000..b2c34e00 --- /dev/null +++ b/internal/rpc/account_themes_compat.go @@ -0,0 +1,183 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" +) + +// DrKLO 12.8.1(Layer 227)发出的 theme 方法构造器比 gotd v0.158 的 schema 更新: +// gotd 的 ServerDispatcher 按构造器 id 匹配,这些「新」id 匹配不上而落到 fallback。 +// 字段语义与 gotd 请求结构相同(仅 createTheme/updateTheme 的 settings 在线为单个对象、 +// gotd 为 Vector;getTheme 多一个被忽略的 document_id),故这里按 DrKLO 字段序手动解码、 +// 复用现有 handler。uploadTheme(0x1c3db333)/saveTheme(0xf257106c) 构造器与 gotd 一致, +// 无需 compat。 +const ( + legacyCreateThemeID = 0x8432c21f + legacyUpdateThemeID = 0x5cb367d5 + legacyInstallThemeID = 0x7ae43737 + legacyGetThemeID = 0x8d9d742b +) + +// tryLegacyThemeRPC 在 fallback 中尝试处理 DrKLO 的新版 theme 构造器。 +// handled=false 表示不是这些构造器,调用方继续原 fallback 流程。 +func (r *Router) tryLegacyThemeRPC(ctx context.Context, b *bin.Buffer) (enc bin.Encoder, handled bool, err error) { + id, perr := b.PeekID() + if perr != nil { + return nil, false, nil + } + switch id { + case legacyCreateThemeID: + enc, err = r.legacyCreateTheme(ctx, b) + case legacyUpdateThemeID: + enc, err = r.legacyUpdateTheme(ctx, b) + case legacyInstallThemeID: + enc, err = r.legacyInstallTheme(ctx, b) + case legacyGetThemeID: + enc, err = r.legacyGetTheme(ctx, b) + default: + return nil, false, nil + } + return enc, true, err +} + +func boolEncoder(v bool) bin.Encoder { + if v { + return &tg.BoolTrue{} + } + return &tg.BoolFalse{} +} + +func (r *Router) legacyCreateTheme(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { + if _, err := b.ID(); err != nil { + return nil, inputConstructorInvalidErr() + } + flags, err := b.Int32() + if err != nil { + return nil, inputConstructorInvalidErr() + } + slug, err := b.String() + if err != nil { + return nil, inputConstructorInvalidErr() + } + title, err := b.String() + if err != nil { + return nil, inputConstructorInvalidErr() + } + req := &tg.AccountCreateThemeRequest{Slug: slug, Title: title} + if flags&(1<<2) != 0 { + doc, err := tg.DecodeInputDocument(b) + if err != nil { + return nil, inputConstructorInvalidErr() + } + req.SetDocument(doc) + } + if flags&(1<<3) != 0 { + var s tg.InputThemeSettings + if err := s.Decode(b); err != nil { + return nil, inputConstructorInvalidErr() + } + req.SetSettings([]tg.InputThemeSettings{s}) + } + return r.onAccountCreateTheme(ctx, req) +} + +func (r *Router) legacyUpdateTheme(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { + if _, err := b.ID(); err != nil { + return nil, inputConstructorInvalidErr() + } + flags, err := b.Int32() + if err != nil { + return nil, inputConstructorInvalidErr() + } + format, err := b.String() + if err != nil { + return nil, inputConstructorInvalidErr() + } + theme, err := tg.DecodeInputTheme(b) + if err != nil { + return nil, inputConstructorInvalidErr() + } + req := &tg.AccountUpdateThemeRequest{Format: format, Theme: theme} + if flags&(1<<0) != 0 { + slug, err := b.String() + if err != nil { + return nil, inputConstructorInvalidErr() + } + req.SetSlug(slug) + } + if flags&(1<<1) != 0 { + title, err := b.String() + if err != nil { + return nil, inputConstructorInvalidErr() + } + req.SetTitle(title) + } + if flags&(1<<2) != 0 { + doc, err := tg.DecodeInputDocument(b) + if err != nil { + return nil, inputConstructorInvalidErr() + } + req.SetDocument(doc) + } + if flags&(1<<3) != 0 { + var s tg.InputThemeSettings + if err := s.Decode(b); err != nil { + return nil, inputConstructorInvalidErr() + } + req.SetSettings([]tg.InputThemeSettings{s}) + } + return r.onAccountUpdateTheme(ctx, req) +} + +func (r *Router) legacyInstallTheme(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { + if _, err := b.ID(); err != nil { + return nil, inputConstructorInvalidErr() + } + flags, err := b.Int32() + if err != nil { + return nil, inputConstructorInvalidErr() + } + req := &tg.AccountInstallThemeRequest{} + if flags&(1<<0) != 0 { + req.SetDark(true) + } + // bit1 同时门控 format 与 theme(DrKLO 布局)。 + if flags&(1<<1) != 0 { + format, err := b.String() + if err != nil { + return nil, inputConstructorInvalidErr() + } + theme, err := tg.DecodeInputTheme(b) + if err != nil { + return nil, inputConstructorInvalidErr() + } + req.SetFormat(format) + req.SetTheme(theme) + } + ok, err := r.onAccountInstallTheme(ctx, req) + if err != nil { + return nil, err + } + return boolEncoder(ok), nil +} + +func (r *Router) legacyGetTheme(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { + if _, err := b.ID(); err != nil { + return nil, inputConstructorInvalidErr() + } + format, err := b.String() + if err != nil { + return nil, inputConstructorInvalidErr() + } + theme, err := tg.DecodeInputTheme(b) + if err != nil { + return nil, inputConstructorInvalidErr() + } + if _, err := b.Long(); err != nil { // document_id:被服务端忽略 + return nil, inputConstructorInvalidErr() + } + req := &tg.AccountGetThemeRequest{Format: format, Theme: theme} + return r.onAccountGetTheme(ctx, req) +} diff --git a/internal/rpc/account_themes_compat_test.go b/internal/rpc/account_themes_compat_test.go new file mode 100644 index 00000000..058541cd --- /dev/null +++ b/internal/rpc/account_themes_compat_test.go @@ -0,0 +1,90 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// TestLegacyThemeWireDecode 验证按 DrKLO 12.8.1 的 theme 构造器(比 gotd schema 新) +// 手写解码后能正确复用现有 handler。直接构造 DrKLO 的 wire 字节喂给 fallback compat 层。 +func TestLegacyThemeWireDecode(t *testing.T) { + const userID = 1000010 + ctx := WithUserID(context.Background(), userID) + files := &fakeFiles{docs: map[int64]domain.Document{ + 777: {ID: 777, AccessHash: 7, DCID: 2, MimeType: "application/x-tgtheme-android", Size: 4096}, + }} + r := newThemeRouter(t, files) + + // createTheme 0x8432c21f:flags(=4,document) + slug + title + InputDocument。 + var cb bin.Buffer + cb.PutID(legacyCreateThemeID) + cb.PutInt32(1 << 2) // document present + cb.PutString("") // empty slug → auto + cb.PutString("Legacy Theme") + (&tg.InputDocument{ID: 777, AccessHash: 7}).Encode(&cb) + + enc, handled, err := r.tryLegacyThemeRPC(ctx, &cb) + if !handled || err != nil { + t.Fatalf("createTheme legacy = handled %v err %v", handled, err) + } + th, ok := enc.(*tg.Theme) + if !ok { + t.Fatalf("createTheme legacy result = %T, want *tg.Theme", enc) + } + if th.Slug == "" || !th.GetCreator() { + t.Fatalf("created theme = slug %q creator %v, want auto slug + creator", th.Slug, th.GetCreator()) + } + if doc, ok := th.GetDocument(); !ok { + t.Fatalf("created theme missing document") + } else if d, _ := doc.(*tg.Document); d == nil || d.ID != 777 { + t.Fatalf("created theme document = %#v, want id 777", doc) + } + mustEncodeTheme(t, th) + slug := th.Slug + + // getTheme 0x8d9d742b:format + InputThemeSlug + document_id(被忽略)。 + var gb bin.Buffer + gb.PutID(legacyGetThemeID) + gb.PutString("android") + (&tg.InputThemeSlug{Slug: slug}).Encode(&gb) + gb.PutLong(12345) // document_id ignored + + enc, handled, err = r.tryLegacyThemeRPC(ctx, &gb) + if !handled || err != nil { + t.Fatalf("getTheme legacy = handled %v err %v", handled, err) + } + got, ok := enc.(*tg.Theme) + if !ok || got.Slug != slug { + t.Fatalf("getTheme legacy result = %#v, want theme slug %q", enc, slug) + } + if _, ok := got.GetDocument(); !ok { + t.Fatalf("getTheme by slug missing document → client ThemeNotSupported") + } + + // installTheme 0x7ae43737:flags(dark@bit0 + bit1 gates format+theme)。 + var ib bin.Buffer + ib.PutID(legacyInstallThemeID) + ib.PutInt32((1 << 0) | (1 << 1)) // dark + has format/theme + ib.PutString("android") + (&tg.InputThemeSlug{Slug: slug}).Encode(&ib) + + enc, handled, err = r.tryLegacyThemeRPC(ctx, &ib) + if !handled || err != nil { + t.Fatalf("installTheme legacy = handled %v err %v", handled, err) + } + if _, ok := enc.(*tg.BoolTrue); !ok { + t.Fatalf("installTheme legacy result = %T, want *tg.BoolTrue", enc) + } + + // 非 theme 构造器 → 不处理。 + var ob bin.Buffer + ob.PutID(0x12345678) + if _, handled, _ := r.tryLegacyThemeRPC(ctx, &ob); handled { + t.Fatalf("unrelated ctor should not be handled") + } +} diff --git a/internal/rpc/account_themes_rpc_test.go b/internal/rpc/account_themes_rpc_test.go new file mode 100644 index 00000000..aa8b5287 --- /dev/null +++ b/internal/rpc/account_themes_rpc_test.go @@ -0,0 +1,264 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + themesapp "telesrv/internal/app/themes" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func newThemeRouter(t *testing.T, files *fakeFiles) *Router { + t.Helper() + return New(Config{}, Deps{ + Files: files, + Themes: themesapp.NewService(memory.NewThemeStore()), + }, zaptest.NewLogger(t), clock.System) +} + +func mustEncodeTheme(t *testing.T, th *tg.Theme) { + t.Helper() + var b bin.Buffer + if err := th.Encode(&b); err != nil { + t.Fatalf("theme encode failed (likely nil base_theme): %v", err) + } +} + +// TestAccountUploadThemeReturnsDocument 验证 uploadTheme 把上传文件落成可下载 Document 返回, +// 且校验 mime 前缀。 +func TestAccountUploadThemeReturnsDocument(t *testing.T) { + ctx := WithUserID(context.Background(), 1000001) + r := newThemeRouter(t, &fakeFiles{}) + + req := &tg.AccountUploadThemeRequest{FileName: "theme.attheme", MimeType: "application/x-tgtheme-android"} + req.File = &tg.InputFile{ID: 42, Parts: 1, Name: "theme.attheme"} + got, err := r.onAccountUploadTheme(ctx, req) + if err != nil { + t.Fatalf("uploadTheme err = %v", err) + } + doc, ok := got.(*tg.Document) + if !ok { + t.Fatalf("uploadTheme = %T, want *tg.Document", got) + } + if doc.ID == 0 { + t.Fatalf("uploaded document id = 0, want non-zero downloadable id") + } + + // 非主题 mime 前缀拒绝。 + bad := &tg.AccountUploadThemeRequest{FileName: "x", MimeType: "image/png"} + bad.File = &tg.InputFile{ID: 7, Parts: 1, Name: "x"} + if _, err := r.onAccountUploadTheme(ctx, bad); !tgerr.Is(err, "THEME_MIME_INVALID") { + t.Fatalf("bad mime err = %v, want THEME_MIME_INVALID", err) + } +} + +// TestAccountCreateThemeFullFlow 验证 createTheme 自动分配 slug、设 creator=true、回填 document, +// getTheme 按 slug 取回带 document;saveTheme/installTheme 返回 true。 +func TestAccountCreateThemeFullFlow(t *testing.T) { + const userID = 1000002 + ctx := WithUserID(context.Background(), userID) + files := &fakeFiles{docs: map[int64]domain.Document{ + 555: {ID: 555, AccessHash: 5, DCID: 2, MimeType: "application/x-tgtheme-android", Size: 1234}, + }} + r := newThemeRouter(t, files) + + create := &tg.AccountCreateThemeRequest{Slug: "", Title: "My Theme"} + create.SetDocument(&tg.InputDocument{ID: 555, AccessHash: 5}) + th, err := r.onAccountCreateTheme(ctx, create) + if err != nil { + t.Fatalf("createTheme err = %v", err) + } + if th.ID == 0 || th.AccessHash == 0 { + t.Fatalf("created theme id/access_hash = %d/%d, want non-zero", th.ID, th.AccessHash) + } + if !th.GetCreator() { + t.Fatalf("created theme creator = false, want true (so client routes re-upload to updateTheme)") + } + if th.Slug == "" { + t.Fatalf("created theme slug empty, want auto-assigned slug") + } + if doc, ok := th.GetDocument(); !ok { + t.Fatalf("created theme has no document, want document (deep-link addtheme needs it)") + } else if d, ok := doc.(*tg.Document); !ok || d.ID != 555 { + t.Fatalf("created theme document = %#v, want *tg.Document id 555", doc) + } + mustEncodeTheme(t, th) + + // getTheme by slug 取回,仍带 document。 + get := &tg.AccountGetThemeRequest{Format: "android"} + get.Theme = &tg.InputThemeSlug{Slug: th.Slug} + got, err := r.onAccountGetTheme(ctx, get) + if err != nil { + t.Fatalf("getTheme by slug err = %v", err) + } + if got.ID != th.ID { + t.Fatalf("getTheme id = %d, want %d", got.ID, th.ID) + } + if _, ok := got.GetDocument(); !ok { + t.Fatalf("getTheme by slug missing document → client would show ThemeNotSupported") + } + mustEncodeTheme(t, got) + + // saveTheme + installTheme by id+access_hash。 + save := &tg.AccountSaveThemeRequest{Theme: &tg.InputTheme{ID: th.ID, AccessHash: th.AccessHash}, Unsave: false} + if ok, err := r.onAccountSaveTheme(ctx, save); err != nil || !ok { + t.Fatalf("saveTheme = %v/%v, want true/nil", ok, err) + } + inst := &tg.AccountInstallThemeRequest{} + inst.SetTheme(&tg.InputTheme{ID: th.ID, AccessHash: th.AccessHash}) + inst.SetDark(true) + if ok, err := r.onAccountInstallTheme(ctx, inst); err != nil || !ok { + t.Fatalf("installTheme = %v/%v, want true/nil", ok, err) + } + + // 未知主题引用 → THEME_INVALID。 + bad := &tg.AccountGetThemeRequest{Format: "android", Theme: &tg.InputThemeSlug{Slug: "does-not-exist"}} + if _, err := r.onAccountGetTheme(ctx, bad); !tgerr.Is(err, "THEME_INVALID") { + t.Fatalf("getTheme unknown slug err = %v, want THEME_INVALID", err) + } +} + +// TestAccountGetThemesIncludesUserThemes 验证 getThemes 跨设备同步:返回内置默认主题 +// (is_default=true,emoji 预览条用)+ 当前用户创建的自定义主题(is_default=false,creator=true); +// hash 稳定→NotModified,集合变化→重取。 +func TestAccountGetThemesIncludesUserThemes(t *testing.T) { + const userID = 1000004 + ctx := WithUserID(context.Background(), userID) + files := &fakeFiles{docs: map[int64]domain.Document{ + 321: {ID: 321, AccessHash: 3, DCID: 2, MimeType: "application/x-tgtheme-android", Size: 100}, + }} + r := newThemeRouter(t, files) + + // 初始:仅默认主题。 + first, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Format: "android", Hash: 0}) + if err != nil { + t.Fatalf("getThemes initial err = %v", err) + } + base, ok := first.(*tg.AccountThemes) + if !ok { + t.Fatalf("getThemes initial = %T, want *tg.AccountThemes", first) + } + defaultCount := len(base.Themes) + if defaultCount == 0 { + t.Fatalf("getThemes returned no default themes") + } + // 同 hash 回传 → NotModified。 + if again, _ := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Hash: base.Hash}); func() bool { + _, ok := again.(*tg.AccountThemesNotModified) + return !ok + }() { + t.Fatalf("getThemes with matching hash should be NotModified") + } + + // 创建一个自定义主题。 + create := &tg.AccountCreateThemeRequest{Title: "Synced"} + create.SetDocument(&tg.InputDocument{ID: 321, AccessHash: 3}) + created, err := r.onAccountCreateTheme(ctx, create) + if err != nil { + t.Fatalf("createTheme err = %v", err) + } + + // 现在 getThemes 必须包含该自定义主题,且 hash 变化。 + second, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Hash: base.Hash}) + if err != nil { + t.Fatalf("getThemes after create err = %v", err) + } + merged, ok := second.(*tg.AccountThemes) + if !ok { + t.Fatalf("getThemes after create = %T, want *tg.AccountThemes (hash changed)", second) + } + if len(merged.Themes) != defaultCount+1 { + t.Fatalf("getThemes themes = %d, want %d (defaults + 1 custom)", len(merged.Themes), defaultCount+1) + } + var foundCustom bool + for _, th := range merged.Themes { + if th.ID == created.ID { + foundCustom = true + if th.GetDefault() { + t.Fatalf("custom theme is_default = true, want false (must not pollute emoji strip)") + } + if !th.GetCreator() { + t.Fatalf("custom theme creator = false, want true for owner") + } + } + } + if !foundCustom { + t.Fatalf("getThemes did not include the user's created theme id %d", created.ID) + } +} + +// TestAccountGetThemesTDesktopExcludesDocumentlessDefaults 验证:对 format="tdesktop" +// 不下发无 document 的合成 emoji 默认主题(否则 TDesktop 云主题网格点击会弹 +// lng_theme_no_desktop),而 format="android" 仍下发它们供 emoji 预览条使用。 +func TestAccountGetThemesTDesktopExcludesDocumentlessDefaults(t *testing.T) { + const userID = 1000007 + ctx := WithUserID(context.Background(), userID) + r := newThemeRouter(t, &fakeFiles{}) + + android, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Format: "android", Hash: 0}) + if err != nil { + t.Fatalf("getThemes android err = %v", err) + } + a, ok := android.(*tg.AccountThemes) + if !ok || len(a.Themes) == 0 { + t.Fatalf("getThemes android = %#v, want non-empty default themes", android) + } + + desktop, err := r.onAccountGetThemes(ctx, &tg.AccountGetThemesRequest{Format: "tdesktop", Hash: 0}) + if err != nil { + t.Fatalf("getThemes tdesktop err = %v", err) + } + switch d := desktop.(type) { + case *tg.AccountThemes: + for _, th := range d.Themes { + if _, hasDoc := th.GetDocument(); !hasDoc { + t.Fatalf("tdesktop getThemes returned documentless theme id=%d emoticon=%q, want only document-backed cloud themes", th.ID, func() string { e, _ := th.GetEmoticon(); return e }()) + } + } + case *tg.AccountThemesNotModified: + // 空集合(全新账号无自建主题)对应的稳定 hash 与 0 不同,首个 hash=0 不会命中, + // 故这里若返回 NotModified 说明哈希实现异常。 + t.Fatalf("tdesktop getThemes with hash=0 = NotModified, want themes") + default: + t.Fatalf("tdesktop getThemes = %T, want *tg.AccountThemes", desktop) + } +} + +// TestAccountCreateThemeAccentSettingsEncode 验证带 settings 的 accent 主题往返且 base_theme 非空可编码。 +func TestAccountCreateThemeAccentSettingsEncode(t *testing.T) { + ctx := WithUserID(context.Background(), 1000003) + r := newThemeRouter(t, &fakeFiles{}) + + settings := tg.InputThemeSettings{BaseTheme: &tg.BaseThemeDay{}, AccentColor: 0x3997d3} + settings.SetMessageColors([]int{0xd4f1ff, 0xb9e4ff}) + wp := tg.WallPaperSettings{} + wp.SetBackgroundColor(0xd4f1ff) + wp.SetSecondBackgroundColor(0xb9e4ff) + settings.SetWallpaperSettings(wp) + settings.SetWallpaper(&tg.InputWallPaperNoFile{}) + + create := &tg.AccountCreateThemeRequest{Title: "Accent"} + create.SetSettings([]tg.InputThemeSettings{settings}) + th, err := r.onAccountCreateTheme(ctx, create) + if err != nil { + t.Fatalf("createTheme accent err = %v", err) + } + out, ok := th.GetSettings() + if !ok || len(out) != 1 { + t.Fatalf("created accent theme settings = %#v ok=%v, want 1", out, ok) + } + if _, isDay := out[0].BaseTheme.(*tg.BaseThemeDay); !isDay { + t.Fatalf("settings[0].base_theme = %T, want BaseThemeDay", out[0].BaseTheme) + } + if out[0].AccentColor != 0x3997d3 { + t.Fatalf("accent color = %#x, want 0x3997d3", out[0].AccentColor) + } + mustEncodeTheme(t, th) +} diff --git a/internal/rpc/admin_hooks.go b/internal/rpc/admin_hooks.go new file mode 100644 index 00000000..48560ba2 --- /dev/null +++ b/internal/rpc/admin_hooks.go @@ -0,0 +1,33 @@ +package rpc + +import ( + "context" + + "telesrv/internal/domain" +) + +// RevokeAuthorizationAuthKey is the domain-only hook used by the internal Admin API +// after the auth service has removed the durable authorization/auth_key rows. +func (r *Router) RevokeAuthorizationAuthKey(ctx context.Context, authKeyID [8]byte, userID int64) error { + if r == nil || authKeyID == ([8]byte{}) { + return nil + } + r.revokeAuthKeySessions(authKeyID) + if err := r.clearAuthKeyState(ctx, authKeyID); err != nil { + return err + } + if userID != 0 { + r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(authKeyID), userID) + } + return nil +} + +// NotifyChannelChanged is the domain-only hook used by the internal Admin API +// after a channel/supergroup base fact changed. +func (r *Router) NotifyChannelChanged(ctx context.Context, ch domain.Channel) error { + if r == nil || ch.ID == 0 { + return nil + } + r.channelStateMutationUpdates(ctx, ch.CreatorUserID, ch) + return nil +} diff --git a/internal/rpc/android_compat_test.go b/internal/rpc/android_compat_test.go new file mode 100644 index 00000000..1e389e89 --- /dev/null +++ b/internal/rpc/android_compat_test.go @@ -0,0 +1,284 @@ +package rpc + +import ( + "context" + "strings" + "testing" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + + botsapp "telesrv/internal/app/bots" + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestLegacyAndroidMessagesCreateChatCreatesMegagroupWithTTL(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550001071", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550001072", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + const ttlPeriod = 7 * 24 * 60 * 60 + var in bin.Buffer + in.PutID(0x0034a818) + if err := (&tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Android Group", + TTLPeriod: ttlPeriod, + }).EncodeBare(&in); err != nil { + t.Fatalf("encode legacy createChat body: %v", err) + } + + enc, err := r.Dispatch(WithUserID(androidClientContext(), owner.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch legacy createChat: %v", err) + } + invited, ok := enc.(*tg.MessagesInvitedUsers) + if !ok { + t.Fatalf("response = %T, want messages.invitedUsers", enc) + } + updates, ok := invited.Updates.(*tg.Updates) + if !ok || len(updates.Chats) < 2 { + t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates) + } + legacy, ok := updates.Chats[0].(*tg.Chat) + if !ok || !legacy.Deactivated { + t.Fatalf("first chat = %#v, want migrated legacy chat", updates.Chats[0]) + } + channel, ok := updates.Chats[1].(*tg.Channel) + if !ok || !channel.Megagroup || !channel.Creator { + t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1]) + } + view, err := channelService.GetChannel(ctx, owner.ID, channel.ID) + if err != nil { + t.Fatalf("get created channel: %v", err) + } + if !view.Channel.Megagroup || view.Channel.Broadcast { + t.Fatalf("created channel = %+v, want megagroup only", view.Channel) + } + if view.Channel.TTLPeriod != ttlPeriod { + t.Fatalf("ttl_period = %d, want %d", view.Channel.TTLPeriod, ttlPeriod) + } +} + +func TestLegacyAndroidAuthSignUpAllowedBeforeAuthorization(t *testing.T) { + authKeyID := [8]byte{0x31, 0xd3, 0x36, 0xc1, 0x7a, 0x1a, 0x33, 0x48} + auth := &captureAuthService{ + signUpUser: domain.User{ + ID: 1000004242, + AccessHash: 424242, + Phone: "15550004242", + FirstName: "Android", + LastName: "Signup", + }, + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{Auth: auth}, zaptest.NewLogger(t), clock.System) + + var in bin.Buffer + in.PutID(0x80eee427) + in.PutString("+15550004242") + in.PutString("phone-code-hash") + in.PutString("Android") + in.PutString("Signup") + + enc, err := r.Dispatch(androidClientContext(), authKeyID, 777, &in) + if err != nil { + t.Fatalf("dispatch legacy auth.signUp: %v", err) + } + // Routed through the unified layerwire inbound upgrade + the normal gotd + // dispatcher, which boxes a class result (auth.Authorization) as *...Box. + box, ok := enc.(*tg.AuthAuthorizationBox) + if !ok { + t.Fatalf("response = %T, want *tg.AuthAuthorizationBox", enc) + } + authorization, ok := box.Authorization.(*tg.AuthAuthorization) + if !ok { + t.Fatalf("authorization = %T, want auth.authorization", box.Authorization) + } + user, ok := authorization.User.(*tg.User) + if !ok || user.ID != auth.signUpUser.ID { + t.Fatalf("authorization user = %T %+v, want user %d", authorization.User, authorization.User, auth.signUpUser.ID) + } + if auth.signUpPhone != "+15550004242" || + auth.signUpHash != "phone-code-hash" || + auth.signUpFirstName != "Android" || + auth.signUpLastName != "Signup" { + t.Fatalf("signup args = phone %q hash %q first %q last %q", auth.signUpPhone, auth.signUpHash, auth.signUpFirstName, auth.signUpLastName) + } + if auth.signUpAuth.AuthKeyID != authKeyID { + t.Fatalf("signup auth key = %x, want %x", auth.signUpAuth.AuthKeyID, authKeyID) + } +} + +func TestModernAndroidChannelsInviteToChannelDispatch(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550001091", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 92, Phone: "15550001092", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Android Channel", + About: "invite compat", + Broadcast: true, + Date: 1700000091, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + var in bin.Buffer + // DrKLO channels.inviteToChannel#199f3a6c — now handled by the unified + // layerwire client-alias table (pure id swap), not a dedicated handler. + in.PutID(0x199f3a6c) + if err := (&tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + }).EncodeBare(&in); err != nil { + t.Fatalf("encode modern inviteToChannel body: %v", err) + } + + enc, err := r.Dispatch(WithUserID(androidClientContext(), owner.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch modern inviteToChannel: %v", err) + } + invited, ok := enc.(*tg.MessagesInvitedUsers) + if !ok { + t.Fatalf("response = %T, want messages.invitedUsers", enc) + } + if invited.Updates == nil || len(invited.MissingInvitees) != 0 { + t.Fatalf("invited users = %+v, want updates and no missing users", invited) + } + member, err := channelService.GetParticipant(ctx, owner.ID, created.Channel.ID, friend.ID) + if err != nil { + t.Fatalf("get invited participant: %v", err) + } + if member.UserID != friend.ID || member.Status != domain.ChannelMemberActive { + t.Fatalf("participant = %+v, want active friend", member) + } + view, err := channelService.GetChannel(ctx, owner.ID, created.Channel.ID) + if err != nil { + t.Fatalf("get channel: %v", err) + } + if view.Channel.ParticipantsCount != 2 { + t.Fatalf("participants_count = %d, want 2", view.Channel.ParticipantsCount) + } +} + +func TestLegacyAndroidBotsExportBotTokenDispatch(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + botStore := memory.NewBotStore(userStore) + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + botService := botsapp.NewService(userStore, botStore, messageStore) + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93, Phone: "15550001093", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + bot, token, err := botService.CreateBot(ctx, owner.ID, "Android Export", "android_export_bot") + if err != nil { + t.Fatalf("create bot: %v", err) + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Bots: botService, + }, zaptest.NewLogger(t), clock.System) + + var in bin.Buffer + in.PutID(0x0063b089) + in.PutLong(bot.ID) + if err := (&tg.BoolFalse{}).Encode(&in); err != nil { + t.Fatalf("encode revoke bool: %v", err) + } + + enc, err := r.Dispatch(WithUserID(androidClientContext(), owner.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch legacy exportBotToken: %v", err) + } + exported, ok := enc.(*tg.BotsExportedBotToken) + if !ok { + t.Fatalf("response = %T, want bots.exportedBotToken", enc) + } + if exported.Token != token { + t.Fatalf("token = %q, want %q", exported.Token, token) + } +} + +func TestMessagesCreateChatRejectsNegativeTTLPeriod(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550001081", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550001082", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + _, err = r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Bad TTL", + TTLPeriod: -1, + }) + if err == nil || !strings.Contains(err.Error(), "TTL_PERIOD_INVALID") { + t.Fatalf("createChat err = %v, want TTL_PERIOD_INVALID", err) + } +} + +func TestStoriesCanSendStoryDispatchReturnsPositiveRemainingCount(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + var in bin.Buffer + if err := (&tg.StoriesCanSendStoryRequest{Peer: &tg.InputPeerSelf{}}).Encode(&in); err != nil { + t.Fatalf("encode canSendStory: %v", err) + } + enc, err := r.Dispatch(WithUserID(androidClientContext(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch canSendStory: %v", err) + } + count, ok := enc.(*tg.StoriesCanSendStoryCount) + if !ok { + t.Fatalf("response = %T, want stories.canSendStoryCount", enc) + } + if count.CountRemains <= 0 { + t.Fatalf("count_remains = %d, want positive", count.CountRemains) + } +} diff --git a/internal/rpc/auth.go b/internal/rpc/auth.go index f9e57f85..22858c13 100644 --- a/internal/rpc/auth.go +++ b/internal/rpc/auth.go @@ -13,7 +13,7 @@ import ( "github.com/gotd/td/proto" "github.com/gotd/td/tg" - "telesrv/internal/compat/tdesktop" + "telesrv/internal/app/auth" "telesrv/internal/domain" ) @@ -37,20 +37,21 @@ func (r *Router) registerAuth(d *tg.ServerDispatcher) { d.OnAuthDropTempAuthKeys(func(ctx context.Context, exceptauthkeys []int64) (bool, error) { return true, nil }) - d.OnAuthInitPasskeyLogin(func(ctx context.Context, req *tg.AuthInitPasskeyLoginRequest) (*tg.AuthPasskeyLoginOptions, error) { - return &tg.AuthPasskeyLoginOptions{Options: tg.DataJSON{Data: "{}"}}, nil - }) + d.OnAuthInitPasskeyLogin(r.onAuthInitPasskeyLogin) + d.OnAuthFinishPasskeyLogin(r.onAuthFinishPasskeyLogin) d.OnAuthSendCode(r.onAuthSendCode) d.OnAuthResendCode(r.onAuthResendCode) d.OnAuthCancelCode(r.onAuthCancelCode) d.OnAuthSignIn(r.onAuthSignIn) d.OnAuthSignUp(r.onAuthSignUp) + d.OnAuthImportBotAuthorization(r.onAuthImportBotAuthorization) d.OnAuthLogOut(r.onAuthLogOut) d.OnAuthResetAuthorizations(r.onAuthResetAuthorizations) d.OnAuthCheckPassword(r.onAuthCheckPassword) d.OnAuthRequestPasswordRecovery(r.onAuthRequestPasswordRecovery) d.OnAuthRecoverPassword(r.onAuthRecoverPassword) d.OnAuthCheckRecoveryPassword(r.onAuthCheckRecoveryPassword) + d.OnAuthResetLoginEmail(r.onAuthResetLoginEmail) } // onAuthBindTempAuthKey 记录 TDesktop 的 PFS temp→perm auth key 绑定。 @@ -72,6 +73,11 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp }); err != nil { return false, bindTempAuthKeyErr(err) } + // temp key (re)bind 后立即作废其 temp→perm 解析缓存,确保下一帧按新绑定重新解析, + // 不被 TTL 内的旧 perm 缓存命中(防跨账号串号)。 + if id != ([8]byte{}) { + r.tempKeyResolveCache.Delete(id) + } if r.deps.Sessions != nil { if scoped, ok := r.scopedSessions(); ok { rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx) @@ -84,32 +90,185 @@ func (r *Router) onAuthBindTempAuthKey(ctx context.Context, req *tg.AuthBindTemp return true, nil } -// onAuthExportLoginToken 给 TDesktop QR 登录页返回一个短期占位 token。 -func (r *Router) onAuthExportLoginToken(ctx context.Context, _ *tg.AuthExportLoginTokenRequest) (tg.AuthLoginTokenClass, error) { - id, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - return tdesktop.LoginToken(r.clock.Now(), id, sessionID), nil -} - -func (r *Router) onAuthImportLoginToken(ctx context.Context, token []byte) (tg.AuthLoginTokenClass, error) { - id, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - return tdesktop.LoginToken(r.clock.Now(), id, sessionID), nil -} - -func (r *Router) onAuthAcceptLoginToken(ctx context.Context, token []byte) (*tg.Authorization, error) { - return nil, authTokenInvalidErr() -} - -// onAuthSendCode 处理 auth.sendCode:生成 phone_code_hash 并返回 sentCode。 -func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest) (tg.AuthSentCodeClass, error) { - hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber) +// onAuthExportLoginToken 给 QR 登录请求方返回短期 token;扫码端接受后,同一目标 +// session 后续 export 会升级为 auth.loginTokenSuccess。 +func (r *Router) onAuthExportLoginToken(ctx context.Context, req *tg.AuthExportLoginTokenRequest) (tg.AuthLoginTokenClass, error) { + target, ok := loginTokenTargetFromContext(ctx) + if !ok { + return nil, internalErr() + } + authz := r.authzFromCtx(ctx) + result, err := r.loginTokens.export(r.clock.Now(), target, authz, req.ExceptIDs) if err != nil { return nil, internalErr() } + if result.accepted { + return r.authLoginTokenSuccess(ctx, result.acceptedAuth) + } + return &tg.AuthLoginToken{Expires: int(result.expires.Unix()), Token: result.token}, nil +} + +func (r *Router) onAuthImportLoginToken(ctx context.Context, token []byte) (tg.AuthLoginTokenClass, error) { + result, err := r.loginTokens.lookup(r.clock.Now(), token) + if err != nil { + return nil, err + } + if result.accepted { + return r.authLoginTokenSuccess(ctx, result.acceptedAuth) + } + return &tg.AuthLoginToken{Expires: int(result.expires.Unix()), Token: result.token}, nil +} + +func (r *Router) onAuthAcceptLoginToken(ctx context.Context, token []byte) (*tg.Authorization, error) { + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !ok || userID == 0 { + return nil, authKeyUnregisteredErr() + } + if r.deps.Auth == nil { + return nil, internalErr() + } + now := r.clock.Now() + accept, err := r.loginTokens.beginAccept(now, token, userID) + if err != nil { + return nil, err + } + scannerAuthKeyID, _ := AuthKeyIDFrom(ctx) + if scannerAuthKeyID != ([8]byte{}) && scannerAuthKeyID == accept.authz.AuthKeyID { + r.loginTokens.failAccept(token) + return nil, authTokenExceptionErr() + } + authz := accept.authz + authz.UserID = userID + authz.PasswordPending = false + if err := r.clearAuthKeyState(ctx, authz.AuthKeyID); err != nil { + r.loginTokens.failAccept(token) + return nil, internalErr() + } + bound, err := r.deps.Auth.AcceptLoginToken(ctx, authz, userID) + if err != nil { + r.loginTokens.failAccept(token) + if errors.Is(err, auth.ErrSystemUserLoginForbidden) { + return nil, authKeyUnregisteredErr() + } + return nil, internalErr() + } + if bound.UserID == 0 { + bound.UserID = userID + } + r.loginTokens.finishAccept(now, token, userID, bound) + r.invalidateAuthUserCache(bound.AuthKeyID) + r.setAuthUserCache(bound.AuthKeyID, userID, true) + r.bindLoginTokenTarget(accept.target, userID) + r.pushLoginTokenAccepted(ctx, accept.target) + out := tgAuthorization(bound, scannerAuthKeyID, int(now.Unix())) + return &out, nil +} + +func loginTokenTargetFromContext(ctx context.Context) (loginTokenTarget, bool) { + authKeyID, _ := AuthKeyIDFrom(ctx) + rawAuthKeyID, _ := RawAuthKeyIDFrom(ctx) + if rawAuthKeyID == ([8]byte{}) { + rawAuthKeyID = authKeyID + } + sessionID, _ := SessionIDFrom(ctx) + return loginTokenTarget{rawAuthKeyID: rawAuthKeyID, authKeyID: authKeyID, sessionID: sessionID}, true +} + +func (r *Router) authLoginTokenSuccess(ctx context.Context, a domain.Authorization) (tg.AuthLoginTokenClass, error) { + if r.deps.Users == nil || a.UserID == 0 { + return nil, internalErr() + } + u, err := r.deps.Users.Self(ctx, a.UserID) + if err != nil { + return nil, internalErr() + } + return &tg.AuthLoginTokenSuccess{ + Authorization: &tg.AuthAuthorization{User: r.tgSelfUser(u)}, + }, nil +} + +func (r *Router) bindLoginTokenTarget(target loginTokenTarget, userID int64) { + if r.deps.Sessions == nil || target.sessionID == 0 { + return + } + if scoped, ok := r.scopedSessions(); ok && target.rawAuthKeyID != ([8]byte{}) { + scoped.BindAuthKeyForSession(target.rawAuthKeyID, target.sessionID, target.authKeyID) + scoped.BindUserForAuthKey(target.rawAuthKeyID, target.sessionID, userID) + r.announceSessionOnline(loginTokenTargetContext(target, userID), userID) + return + } + r.deps.Sessions.BindAuthKey(target.sessionID, target.authKeyID) + r.deps.Sessions.BindUser(target.sessionID, userID) + r.announceSessionOnline(loginTokenTargetContext(target, userID), userID) +} + +func loginTokenTargetContext(target loginTokenTarget, userID int64) context.Context { + ctx := context.Background() + ctx = WithRawAuthKeyID(ctx, target.rawAuthKeyID) + ctx = WithAuthKeyID(ctx, target.authKeyID) + ctx = WithSessionID(ctx, target.sessionID) + ctx = WithUserID(ctx, userID) + return ctx +} + +func (r *Router) pushLoginTokenAccepted(ctx context.Context, target loginTokenTarget) { + if r.deps.Sessions == nil || target.sessionID == 0 { + return + } + updates := &tg.UpdateShort{ + Update: &tg.UpdateLoginToken{}, + Date: int(r.clock.Now().Unix()), + } + if immediate, ok := r.deps.Sessions.(ScopedImmediateSessionPusher); ok && target.rawAuthKeyID != ([8]byte{}) { + if err := immediate.PushToSessionForAuthKeyImmediate(ctx, target.rawAuthKeyID, target.sessionID, proto.MessageFromServer, updates); err != nil { + r.log.Debug("push login token accepted immediate", zap.Int64("session_id", target.sessionID), zap.Error(err)) + } + return + } + if scoped, ok := r.scopedSessions(); ok && target.rawAuthKeyID != ([8]byte{}) { + if err := scoped.PushToSessionForAuthKey(ctx, target.rawAuthKeyID, target.sessionID, proto.MessageFromServer, updates); err != nil { + r.log.Debug("push login token accepted", zap.Int64("session_id", target.sessionID), zap.Error(err)) + } + return + } + if err := r.deps.Sessions.PushToSession(ctx, target.sessionID, proto.MessageFromServer, updates); err != nil { + r.log.Debug("push login token accepted", zap.Int64("session_id", target.sessionID), zap.Error(err)) + } +} + +// onAuthSendCode 处理 auth.sendCode:生成 phone_code_hash 并返回 sentCode。 +// 若该手机号账号设置了登录邮箱,验证码改投递到邮箱,返回 sentCodeTypeEmailCode +// (客户端据此进入"输入邮箱验证码"界面,随后用 auth.signIn 的 email_verification 完成登录)。 +func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest) (tg.AuthSentCodeClass, error) { + hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber) + if err != nil { + if errors.Is(err, auth.ErrPhoneNumberInvalid) || + errors.Is(err, auth.ErrSystemUserLoginForbidden) { + return nil, phoneNumberInvalidErr() + } + return nil, internalErr() + } + if pattern, ok := r.loginEmailPattern(ctx, req.PhoneNumber); ok { + return tgEmailSentCode(hash, pattern), nil + } return tgSentCode(hash), nil } +// loginEmailPattern 返回该手机号账号已确认登录邮箱的掩码,不存在则 ok=false。 +func (r *Router) loginEmailPattern(ctx context.Context, phone string) (string, bool) { + if r.deps.Account == nil { + return "", false + } + email, found, err := r.deps.Account.LoginEmailByPhone(ctx, phone) + if err != nil || !found || email == "" { + return "", false + } + return domain.MaskEmail(email), true +} + func tgSentCode(hash string) tg.AuthSentCodeClass { return &tg.AuthSentCode{ Type: &tg.AuthSentCodeTypeApp{Length: devCodeLength}, @@ -117,18 +276,46 @@ func tgSentCode(hash string) tg.AuthSentCodeClass { } } +func tgEmailSentCode(hash, emailPattern string) tg.AuthSentCodeClass { + codeType := &tg.AuthSentCodeTypeEmailCode{ + EmailPattern: emailPattern, + Length: devCodeLength, + } + // reset_available_period=0 表示可立即调用 auth.resetLoginEmail(开发环境无等待期), + // 让客户端的"无法访问邮箱?"逃生入口可用。 + codeType.SetResetAvailablePeriod(0) + return &tg.AuthSentCode{ + Type: codeType, + PhoneCodeHash: hash, + } +} + // onAuthSignIn 处理 auth.signIn:校验验证码;用户不存在时返回 SignUpRequired。 +// 带 email_verification 时走登录邮箱路径(验证码来自邮箱而非短信)。 func (r *Router) onAuthSignIn(ctx context.Context, req *tg.AuthSignInRequest) (tg.AuthAuthorizationClass, error) { - u, loginMessage, needSignUp, err := r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode) + var ( + u domain.User + loginMessage domain.Message + needSignUp bool + err error + ) + if verification, ok := req.GetEmailVerification(); ok { + u, loginMessage, needSignUp, err = r.deps.Auth.SignInWithEmail(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, emailVerificationCode(verification)) + } else { + u, loginMessage, needSignUp, err = r.deps.Auth.SignIn(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.PhoneCode) + } if err != nil { if errors.Is(err, domain.ErrSessionPasswordNeeded) && u.ID != 0 { if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil { return nil, internalErr() } + // 两步验证未完成:绝不能把 auth_key/session 标记为已登录,否则客户端忽略 + // SESSION_PASSWORD_NEEDED、直接调用业务 RPC 即可绕过 2FA。失效缓存并把 session + // 置为未授权,让后续鉴权重新读到 password_pending 并拒绝;待 checkPassword 通过后再授权。 if id, ok := AuthKeyIDFrom(ctx); ok { - r.setAuthUserCache(id, u.ID, true) + r.invalidateAuthUserCache(id) } - r.bindSessionUser(ctx, u.ID) + r.bindSessionUser(ctx, 0) } return nil, signInErr(err) } @@ -173,24 +360,35 @@ func (r *Router) onAuthResetAuthorizations(ctx context.Context) (bool, error) { return false, internalErr() } for _, a := range deleted { - r.invalidateAuthUserCache(a.AuthKeyID) - r.unbindAuthKey(a.AuthKeyID) + r.revokeAuthKeySessions(a.AuthKeyID) _ = r.clearAuthKeyState(ctx, a.AuthKeyID) + // P1 修复:撤销其它会话同样销毁其 auth_key,级联 discard 该设备绑定的活跃密聊并通知对端。 + r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(a.AuthKeyID), userID) } return true, nil } func (r *Router) onAuthCheckPassword(ctx context.Context, password tg.InputCheckPasswordSRPClass) (tg.AuthAuthorizationClass, error) { - userID, _, err := r.currentUserID(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, authorized, pending, err := r.currentOrPendingPasswordUserID(ctx) if err != nil { return nil, internalErr() } + if !pending && (!authorized || userID == 0) { + return nil, passwordHashInvalidErr() + } if r.deps.Account == nil { return nil, passwordHashInvalidErr() } if err := r.deps.Account.CheckPassword(ctx, userID, domainPasswordCheck(password)); err != nil { return nil, passwordErr(err) } + // 两步验证通过:清除 password_pending 并把 auth_key/session 提升为完全授权。 + if pending { + if err := r.completePendingPasswordSignIn(ctx, authKeyID, userID); err != nil { + return nil, internalErr() + } + } u, err := r.deps.Users.Self(ctx, userID) if err != nil { return nil, internalErr() @@ -199,7 +397,7 @@ func (r *Router) onAuthCheckPassword(ctx context.Context, password tg.InputCheck } func (r *Router) onAuthRequestPasswordRecovery(ctx context.Context) (*tg.AuthPasswordRecovery, error) { - userID, _, err := r.currentUserID(ctx) + userID, _, _, err := r.currentOrPendingPasswordUserID(ctx) if err != nil { return nil, internalErr() } @@ -211,7 +409,8 @@ func (r *Router) onAuthRequestPasswordRecovery(ctx context.Context) (*tg.AuthPas } func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverPasswordRequest) (tg.AuthAuthorizationClass, error) { - userID, _, err := r.currentUserID(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, pending, err := r.currentOrPendingPasswordUserID(ctx) if err != nil { return nil, internalErr() } @@ -226,6 +425,11 @@ func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverP if err := r.deps.Account.RecoverPassword(ctx, userID, req.Code, input); err != nil { return nil, passwordErr(err) } + if pending { + if err := r.completePendingPasswordSignIn(ctx, authKeyID, userID); err != nil { + return nil, internalErr() + } + } u, err := r.deps.Users.Self(ctx, userID) if err != nil { return nil, internalErr() @@ -234,7 +438,7 @@ func (r *Router) onAuthRecoverPassword(ctx context.Context, req *tg.AuthRecoverP } func (r *Router) onAuthCheckRecoveryPassword(ctx context.Context, code string) (bool, error) { - userID, _, err := r.currentUserID(ctx) + userID, _, _, err := r.currentOrPendingPasswordUserID(ctx) if err != nil { return false, internalErr() } @@ -244,6 +448,143 @@ func (r *Router) onAuthCheckRecoveryPassword(ctx context.Context, code string) ( return true, nil } +// currentOrPendingPasswordUserID returns the fully authorized user when present; +// otherwise it allows the narrow 2FA login continuation path to locate the user +// attached to a password_pending auth key. The pending identity must not be used +// by general business RPCs. +func (r *Router) currentOrPendingPasswordUserID(ctx context.Context) (userID int64, authorized bool, passwordPending bool, err error) { + userID, authorized, err = r.currentUserID(ctx) + if err != nil || authorized { + return userID, authorized, false, err + } + if r.deps.Auth == nil { + return userID, authorized, false, nil + } + authKeyID, ok := AuthKeyIDFrom(ctx) + if !ok { + return userID, authorized, false, nil + } + pendingUserID, pending, err := r.deps.Auth.PendingPasswordUserID(ctx, authKeyID) + if err != nil { + return 0, false, false, err + } + if !pending || pendingUserID == 0 { + return userID, authorized, false, nil + } + return pendingUserID, false, true, nil +} + +func (r *Router) completePendingPasswordSignIn(ctx context.Context, authKeyID [8]byte, userID int64) error { + if r.deps.Auth == nil { + return nil + } + if err := r.deps.Auth.CompletePasswordSignIn(ctx, authKeyID); err != nil { + return err + } + r.invalidateAuthUserCache(authKeyID) + r.setAuthUserCache(authKeyID, userID, true) + r.bindSessionUser(ctx, userID) + return nil +} + +// onAuthResetLoginEmail 处理 auth.resetLoginEmail:用户登录设备时无法访问登录邮箱时 +// 清除登录邮箱,改回手机验证码登录,返回一个新的手机 sentCode 供其继续。 +func (r *Router) onAuthResetLoginEmail(ctx context.Context, req *tg.AuthResetLoginEmailRequest) (tg.AuthSentCodeClass, error) { + if r.deps.Account == nil || r.deps.Auth == nil { + return nil, internalErr() + } + if err := r.deps.Account.ClearLoginEmailByPhone(ctx, req.PhoneNumber); err != nil { + return nil, internalErr() + } + hash, err := r.deps.Auth.SendCode(ctx, req.PhoneNumber) + if err != nil { + if errors.Is(err, auth.ErrPhoneNumberInvalid) || + errors.Is(err, auth.ErrSystemUserLoginForbidden) { + return nil, phoneNumberInvalidErr() + } + return nil, internalErr() + } + return tgSentCode(hash), nil +} + +// emailVerificationCode 从 emailVerification 取出可校验的字符串(验证码 / Google·Apple +// 令牌)。开发环境一律按"任意非空即通过"处理,故三者等价取值。 +// onAuthInitPasskeyLogin 处理 auth.initPasskeyLogin:生成一次性断言挑战(discoverable), +// 以 DataJSON(顶层含 publicKey)返回。免授权(登录前)。 +func (r *Router) onAuthInitPasskeyLogin(ctx context.Context, req *tg.AuthInitPasskeyLoginRequest) (*tg.AuthPasskeyLoginOptions, error) { + if r.deps.Passkey == nil { + return &tg.AuthPasskeyLoginOptions{Options: tg.DataJSON{Data: "{}"}}, nil + } + options, err := r.deps.Passkey.InitLogin(ctx) + if err != nil { + return nil, internalErr() + } + return &tg.AuthPasskeyLoginOptions{Options: tg.DataJSON{Data: string(options)}}, nil +} + +// onAuthFinishPasskeyLogin 处理 auth.finishPasskeyLogin:验证登录断言并绑定 auth_key。 +// 收尾与 signIn 同构(清水位→授权缓存→session 绑定);passkey 是强因子,直接完全授权 +// (不走 SESSION_PASSWORD_NEEDED)。FromDCID/FromAuthKeyID 为多 DC 重路由用,本单 DC 忽略。 +func (r *Router) onAuthFinishPasskeyLogin(ctx context.Context, req *tg.AuthFinishPasskeyLoginRequest) (tg.AuthAuthorizationClass, error) { + if r.deps.Passkey == nil || r.deps.Auth == nil { + return nil, internalErr() + } + credID, login, ok := passkeyLoginFromCredential(req.Credential) + if !ok { + return nil, passkeyErr(domain.ErrPasskeyInvalid) + } + userID, err := r.deps.Passkey.FinishLogin(ctx, credID, []byte(login.ClientData.Data), login.AuthenticatorData, login.Signature, login.UserHandle) + if err != nil { + return nil, passkeyErr(err) + } + u, err := r.deps.Auth.BindVerifiedLogin(ctx, r.authzFromCtx(ctx), userID) + if err != nil { + return nil, passkeyErr(err) + } + if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil { + return nil, internalErr() + } + if id, ok := AuthKeyIDFrom(ctx); ok { + r.setAuthUserCache(id, u.ID, true) + } + r.bindSessionUser(ctx, u.ID) + return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil +} + +func emailVerificationCode(v tg.EmailVerificationClass) string { + switch e := v.(type) { + case *tg.EmailVerificationCode: + return e.Code + case *tg.EmailVerificationGoogle: + return e.Token + case *tg.EmailVerificationApple: + return e.Token + } + return "" +} + +// onAuthImportBotAuthorization 处理 auth.importBotAuthorization:bot 程序凭 token +// 登录为 bot 账号。api_id/api_hash 与现有 sendCode 行为一致不校验(无 app 注册表)。 +// 收尾与 signIn 同构(清水位→授权缓存→session 绑定),但不写登录消息、不推 +// signIn 服务通知——那是手机登录语义。 +func (r *Router) onAuthImportBotAuthorization(ctx context.Context, req *tg.AuthImportBotAuthorizationRequest) (tg.AuthAuthorizationClass, error) { + if r.deps.Auth == nil { + return nil, accessTokenInvalidErr() + } + u, err := r.deps.Auth.SignInBot(ctx, r.authzFromCtx(ctx), req.BotAuthToken) + if err != nil { + return nil, importBotAuthorizationErr(err) + } + if err := r.clearAuthKeyStateOnUserChange(ctx, u.ID); err != nil { + return nil, internalErr() + } + if id, ok := AuthKeyIDFrom(ctx); ok { + r.setAuthUserCache(id, u.ID, true) + } + r.bindSessionUser(ctx, u.ID) + return &tg.AuthAuthorization{User: r.tgSelfUser(u)}, nil +} + // onAuthSignUp 处理 auth.signUp:创建用户并绑定授权。 func (r *Router) onAuthSignUp(ctx context.Context, req *tg.AuthSignUpRequest) (tg.AuthAuthorizationClass, error) { u, loginMessage, err := r.deps.Auth.SignUp(ctx, r.authzFromCtx(ctx), req.PhoneNumber, req.PhoneCodeHash, req.FirstName, req.LastName) @@ -270,9 +611,21 @@ func (r *Router) onAuthLogOut(ctx context.Context) (*tg.AuthLoggedOut, error) { } r.invalidateAuthUserCache(id) r.unbindAuthKey(id) - if userErr == nil && authorized && userID != 0 { - status := r.setPresenceFromContext(ctx, userID, true) + // bot 登出不广播 offline(bot 无 presence 语义,与登录路径对称)。 + if userErr == nil && authorized && userID != 0 && !r.userIsBot(ctx, userID) { + status, _ := r.setPresenceFromContext(ctx, userID, true, presencePersistSync) r.pushUserStatus(ctx, userID, status) + // 登出后主动清掉本 session 的 presence 条目:连接通常不断开(客户端回登录页), + // 上面 unbindAuthKey 已把连接 userID 清 0,TCP 真正断开时 SessionOffline 因 userID=0 + // 提前返回、不再清 presence,条目会以 offline 态滞留泄露。这里随登出一并清除。 + if key, ok := presenceSessionKeyFromContext(ctx); ok { + r.presence.clearSession(key) + } + } + // P1 修复:登出销毁本设备 perm auth_key 后,级联 discard 其绑定的活跃密聊并通知对端 + //(否则对端继续往死 auth_key 投递成静默死链)。best-effort,不阻断登出。 + if userErr == nil && userID != 0 { + r.discardSecretChatsForAuthKey(ctx, businessAuthKeyInt64(id), userID) } if err := r.clearAuthKeyState(ctx, id); err != nil { return nil, internalErr() @@ -324,6 +677,38 @@ func (r *Router) unbindAuthKey(authKeyID [8]byte) { r.deps.Sessions.UnbindAuthKey(authKeyID) } +// revokeAuthKeySessions 是授权撤销(被踢设备)的完整失效闭环:清 Router 授权缓存、 +// 清 temp→perm 短缓存、强制断开在线连接、再兜底解绑。断开不可省略——出站推送用 +// 连接持有的密钥加密、不回查授权表,perm-key 连接的授权缓存也只有重连才会重新回查; +// 不断开的话被踢设备仍能持续收到推送并以缓存身份继续发请求。重连后回查 store 即得 +// 未授权(401)。 +// +// 顺序关键:先 CloseSessionsForBusinessAuthKey 再 unbindAuthKey。Close 内部 removeLocked +// 读取连接当前 userID 生成 SessionOffline 事件(驱动 presence 清理与 offline 广播); +// 若先 unbind 把 userID 清成 0,事件就退化为 userID=0,被踢设备的 presence 条目不被 +// 清理、好友侧最长一个在线 TTL 仍显示其在线。Close 已把连接移出索引,随后的 unbind +// 对未实现 SessionTerminator 的 Sessions 才有意义(生产实现走 Close 即可,unbind 是 no-op)。 +func (r *Router) revokeAuthKeySessions(authKeyID [8]byte) { + r.invalidateAuthUserCache(authKeyID) + rawTempAuthKeyIDs := r.invalidateTempAuthKeyCacheForPerm(authKeyID) + if terminator, ok := r.deps.Sessions.(SessionTerminator); ok { + terminator.CloseSessionsForBusinessAuthKey(authKeyID) + } + if terminator, ok := r.deps.Sessions.(RawSessionTerminator); ok { + for _, rawAuthKeyID := range rawTempAuthKeyIDs { + if rawAuthKeyID == authKeyID { + continue + } + terminator.CloseSessionsForRawAuthKeyExcept(rawAuthKeyID, 0) + } + } + r.unbindAuthKey(authKeyID) +} + +func (r *Router) invalidateTempAuthKeyCacheForPerm(authKeyID [8]byte) [][8]byte { + return r.tempKeyResolveCache.DeleteByPerm(authKeyID) +} + func (r *Router) pushSignInServiceNotificationToOthers(ctx context.Context, u domain.User) { if r.deps.Sessions == nil || u.ID == 0 { return diff --git a/internal/rpc/auth_gate.go b/internal/rpc/auth_gate.go new file mode 100644 index 00000000..e1ca9078 --- /dev/null +++ b/internal/rpc/auth_gate.go @@ -0,0 +1,59 @@ +package rpc + +import "github.com/gotd/td/tg" + +// rpcAllowedWithoutAuthorization returns true for methods that are valid before +// an auth key is bound to a user. Everything else must fail with +// AUTH_KEY_UNREGISTERED so stale Web/desktop sessions fall back to login. +// +// Inbound layer/client-drift upgrades run before this gate (see router.dispatch), +// so ids here are always canonical (227) — old client constructor ids never reach +// this check. +func rpcAllowedWithoutAuthorization(id uint32) bool { + switch id { + case tg.AuthBindTempAuthKeyRequestTypeID, + tg.AuthExportLoginTokenRequestTypeID, + tg.AuthImportLoginTokenRequestTypeID, + tg.AuthAcceptLoginTokenRequestTypeID, + tg.AuthInitPasskeyLoginRequestTypeID, + tg.AuthFinishPasskeyLoginRequestTypeID, + tg.AuthDropTempAuthKeysRequestTypeID, + tg.AuthSendCodeRequestTypeID, + tg.AuthResendCodeRequestTypeID, + tg.AuthCancelCodeRequestTypeID, + tg.AuthSignInRequestTypeID, + tg.AuthSignUpRequestTypeID, + tg.AuthImportBotAuthorizationRequestTypeID, + tg.AuthCheckPasswordRequestTypeID, + tg.AuthRequestPasswordRecoveryRequestTypeID, + tg.AuthRecoverPasswordRequestTypeID, + tg.AuthCheckRecoveryPasswordRequestTypeID, + tg.AuthRequestFirebaseSMSRequestTypeID, + tg.AuthReportMissingCodeRequestTypeID, + tg.AuthResetLoginEmailRequestTypeID, + tg.AccountGetPasswordRequestTypeID, + // 登录邮箱 setup(emailVerifyPurposeLoginSetup)发生在登录流程中、尚未鉴权, + // 故这两个 account.* 方法必须放行 pre-auth;loginChange 分支内部仍校验 userID。 + tg.AccountSendVerifyEmailCodeRequestTypeID, + tg.AccountVerifyEmailRequestTypeID, + tg.HelpGetConfigRequestTypeID, + tg.HelpGetNearestDCRequestTypeID, + tg.HelpGetInviteTextRequestTypeID, + tg.HelpGetAppConfigRequestTypeID, + tg.HelpGetCountriesListRequestTypeID, + tg.HelpGetTimezonesListRequestTypeID, + tg.HelpGetPeerColorsRequestTypeID, + tg.HelpGetPeerProfileColorsRequestTypeID, + tg.HelpGetPromoDataRequestTypeID, + tg.HelpGetTermsOfServiceUpdateRequestTypeID, + tg.HelpGetPremiumPromoRequestTypeID, + tg.LangpackGetLanguagesRequestTypeID, + tg.LangpackGetLanguageRequestTypeID, + tg.LangpackGetLangPackRequestTypeID, + tg.LangpackGetDifferenceRequestTypeID, + tg.LangpackGetStringsRequestTypeID: + return true + default: + return false + } +} diff --git a/internal/rpc/auth_password_pending_test.go b/internal/rpc/auth_password_pending_test.go new file mode 100644 index 00000000..fe47b96e --- /dev/null +++ b/internal/rpc/auth_password_pending_test.go @@ -0,0 +1,103 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appaccount "telesrv/internal/app/account" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestAccountGetPasswordUsesPendingPasswordUser(t *testing.T) { + ctx := pendingPasswordContext() + const userID int64 = 42 + + passwords := memory.NewPasswordStore() + if err := passwords.Save(ctx, userID, domain.PasswordSettings{ + HasPassword: true, + Hint: "q1", + SRPID: 99, + SRPVerifier: []byte{1}, + }); err != nil { + t.Fatalf("save password: %v", err) + } + + router := New(Config{}, Deps{ + Auth: &captureAuthService{ + pendingPasswordUserID: userID, + pendingPassword: true, + }, + Account: appaccount.NewService(passwords), + }, zaptest.NewLogger(t), clock.System) + + got, err := router.onAccountGetPassword(ctx) + if err != nil { + t.Fatalf("account.getPassword: %v", err) + } + if !got.HasPassword || got.Hint != "q1" || got.SRPID == 0 || len(got.SRPB) == 0 || got.CurrentAlgo == nil { + t.Fatalf("password challenge = %+v, want pending user's SRP challenge", got) + } +} + +func TestAuthRecoverPasswordCompletesPendingSignIn(t *testing.T) { + ctx := pendingPasswordContext() + authKeyID, _ := AuthKeyIDFrom(ctx) + const userID int64 = 42 + + passwords := memory.NewPasswordStore() + if err := passwords.Save(ctx, userID, domain.PasswordSettings{ + HasPassword: true, + Hint: "q1", + SRPID: 99, + SRPVerifier: []byte{1}, + RecoveryEmail: "alice@example.com", + }); err != nil { + t.Fatalf("save password: %v", err) + } + + auth := &captureAuthService{ + pendingPasswordUserID: userID, + pendingPassword: true, + } + sessions := &captureSessions{} + router := New(Config{}, Deps{ + Auth: auth, + Account: appaccount.NewService(passwords), + Users: staticUsersService{user: domain.User{ID: userID, AccessHash: 7, Phone: "15550000042", FirstName: "Alice"}}, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + if _, err := router.onAuthRequestPasswordRecovery(ctx); err != nil { + t.Fatalf("auth.requestPasswordRecovery: %v", err) + } + if _, err := router.onAuthRecoverPassword(ctx, &tg.AuthRecoverPasswordRequest{Code: "12345"}); err != nil { + t.Fatalf("auth.recoverPassword: %v", err) + } + if auth.completePasswordCount != 1 || auth.completedPasswordKey != authKeyID { + t.Fatalf("CompletePasswordSignIn count=%d key=%x, want one call for %x", auth.completePasswordCount, auth.completedPasswordKey, authKeyID) + } + if snap := sessions.snapshot(); snap.userID != userID || !snap.userResolved { + t.Fatalf("session user = %d resolved=%v, want %d resolved", snap.userID, snap.userResolved, userID) + } + cleared, _, err := passwords.GetByUser(ctx, userID) + if err != nil { + t.Fatalf("get recovered password: %v", err) + } + if cleared.HasPassword { + t.Fatalf("password still enabled after recover: %+v", cleared) + } +} + +func pendingPasswordContext() context.Context { + authKeyID := [8]byte{1, 2, 3, 4, 5, 6, 7, 8} + ctx := context.Background() + ctx = WithAuthKeyID(ctx, authKeyID) + ctx = WithRawAuthKeyID(ctx, authKeyID) + ctx = WithSessionID(ctx, 77) + return ctx +} diff --git a/internal/rpc/auth_qr_login_test.go b/internal/rpc/auth_qr_login_test.go new file mode 100644 index 00000000..38c79e75 --- /dev/null +++ b/internal/rpc/auth_qr_login_test.go @@ -0,0 +1,196 @@ +package rpc + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" +) + +func TestAuthLoginTokenAcceptedByAndroidBindsTargetSession(t *testing.T) { + const ( + targetSession = int64(101) + scannerSession = int64(202) + scannerUserID = int64(1000000001) + ) + targetRawAuthKeyID := [8]byte{0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80} + targetAuthKeyID := [8]byte{0x81, 0x71, 0x61, 0x51, 0x41, 0x31, 0x21, 0x11} + scannerRawAuthKeyID := [8]byte{0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99} + scannerAuthKeyID := [8]byte{0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22} + + auth := &captureAuthService{} + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + users := mapUsersService{users: map[int64]domain.User{ + scannerUserID: {ID: scannerUserID, FirstName: "Alice", Phone: "15550001001"}, + }} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Auth: auth, + Sessions: sessions, + Users: users, + }, zaptest.NewLogger(t), clock.System) + + targetCtx := WithClientInfo( + WithLayer( + WithSessionID( + WithAuthKeyID( + WithRawAuthKeyID(context.Background(), targetRawAuthKeyID), + targetAuthKeyID, + ), + targetSession, + ), + currentClientLayer, + ), + ClientInfo{ + APIID: 2040, + DeviceModel: "WebA", + SystemVersion: "Chrome", + AppVersion: "1.0", + LangPack: "tdesktop", + }, + ) + exported, err := r.onAuthExportLoginToken(targetCtx, &tg.AuthExportLoginTokenRequest{APIID: 2040, APIHash: "hash"}) + if err != nil { + t.Fatalf("export login token: %v", err) + } + loginToken, ok := exported.(*tg.AuthLoginToken) + if !ok { + t.Fatalf("export type = %T, want *tg.AuthLoginToken", exported) + } + if len(loginToken.Token) != loginTokenBytes { + t.Fatalf("token length = %d, want %d", len(loginToken.Token), loginTokenBytes) + } + + unauthorizedScannerCtx := WithSessionID( + WithAuthKeyID( + WithRawAuthKeyID(context.Background(), scannerRawAuthKeyID), + scannerAuthKeyID, + ), + scannerSession, + ) + if _, err := r.onAuthAcceptLoginToken(unauthorizedScannerCtx, loginToken.Token); !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") { + t.Fatalf("unauthorized accept err = %v, want AUTH_KEY_UNREGISTERED", err) + } + + scannerCtx := WithUserID(unauthorizedScannerCtx, scannerUserID) + authorization, err := r.onAuthAcceptLoginToken(scannerCtx, loginToken.Token) + if err != nil { + t.Fatalf("accept login token: %v", err) + } + if authorization == nil { + t.Fatal("accept login token returned nil authorization") + } + if auth.acceptedUserID != scannerUserID { + t.Fatalf("accepted user id = %d, want %d", auth.acceptedUserID, scannerUserID) + } + if auth.acceptedAuth.AuthKeyID != targetAuthKeyID { + t.Fatalf("accepted auth key = %x, want %x", auth.acceptedAuth.AuthKeyID, targetAuthKeyID) + } + if auth.acceptedAuth.DeviceModel != "WebA" { + t.Fatalf("accepted device = %q, want WebA", auth.acceptedAuth.DeviceModel) + } + if authorization.Hash == 0 { + t.Fatal("authorization hash is zero") + } + + snap := sessions.snapshot() + if sessions.scopedAuthKeyID != targetRawAuthKeyID { + t.Fatalf("scoped raw auth key = %x, want %x", sessions.scopedAuthKeyID, targetRawAuthKeyID) + } + if snap.sessionID != targetSession || snap.userID != scannerUserID || !snap.userResolved { + t.Fatalf("target session snapshot = %+v, want session/user/resolved %d/%d/true", snap, targetSession, scannerUserID) + } + if snap.messageType != proto.MessageFromServer { + t.Fatalf("push message type = %v, want MessageFromServer", snap.messageType) + } + if !sessions.immediatePush { + t.Fatal("login token update was not pushed through the immediate pre-auth path") + } + short, ok := snap.message.(*tg.UpdateShort) + if !ok { + t.Fatalf("push message = %T, want *tg.UpdateShort", snap.message) + } + if _, ok := short.Update.(*tg.UpdateLoginToken); !ok { + t.Fatalf("pushed update = %T, want *tg.UpdateLoginToken", short.Update) + } + + success, err := r.onAuthExportLoginToken(targetCtx, &tg.AuthExportLoginTokenRequest{APIID: 2040, APIHash: "hash"}) + if err != nil { + t.Fatalf("export after accept: %v", err) + } + loginSuccess, ok := success.(*tg.AuthLoginTokenSuccess) + if !ok { + t.Fatalf("export after accept type = %T, want *tg.AuthLoginTokenSuccess", success) + } + authz, ok := loginSuccess.Authorization.(*tg.AuthAuthorization) + if !ok { + t.Fatalf("success authorization = %T, want *tg.AuthAuthorization", loginSuccess.Authorization) + } + user, ok := authz.User.(*tg.User) + if !ok { + t.Fatalf("success user = %T, want *tg.User", authz.User) + } + if user.ID != scannerUserID { + t.Fatalf("success user id = %d, want %d", user.ID, scannerUserID) + } + + if _, err := r.onAuthAcceptLoginToken(scannerCtx, loginToken.Token); !tgerr.Is(err, "AUTH_TOKEN_ALREADY_ACCEPTED") { + t.Fatalf("duplicate accept err = %v, want AUTH_TOKEN_ALREADY_ACCEPTED", err) + } +} + +func TestAuthLoginTokenExpires(t *testing.T) { + now := time.Unix(1700000000, 0) + reg := newLoginTokenRegistry() + target := loginTokenTarget{ + rawAuthKeyID: [8]byte{1}, + authKeyID: [8]byte{2}, + sessionID: 3, + } + exported, err := reg.export(now, target, domain.Authorization{AuthKeyID: target.authKeyID}, nil) + if err != nil { + t.Fatalf("export: %v", err) + } + if _, err := reg.beginAccept(now.Add(loginTokenTTL+time.Second), exported.token, 1000000001); !tgerr.Is(err, "AUTH_TOKEN_EXPIRED") { + t.Fatalf("expired accept err = %v, want AUTH_TOKEN_EXPIRED", err) + } +} + +func TestLoginTokenRegistryCapacityBounded(t *testing.T) { + now := time.Unix(1700000000, 0) + reg := newLoginTokenRegistry() + targets := make([]loginTokenTarget, 0, loginTokenMaxRecords) + var last loginTokenExport + for i := 0; i < loginTokenMaxRecords; i++ { + target := loginTokenTarget{rawAuthKeyID: [8]byte{byte(i), 1}, authKeyID: [8]byte{byte(i), 2}, sessionID: int64(i + 1)} + targets = append(targets, target) + exported, err := reg.export(now, target, domain.Authorization{AuthKeyID: target.authKeyID}, nil) + if err != nil { + t.Fatalf("export %d: %v", i, err) + } + last = exported + } + again, err := reg.export(now, targets[len(targets)-1], domain.Authorization{AuthKeyID: targets[len(targets)-1].authKeyID}, nil) + if err != nil { + t.Fatalf("export existing target: %v", err) + } + if !bytes.Equal(again.token, last.token) { + t.Fatal("existing target token rotated while registry was full") + } + if _, err := reg.export(now, loginTokenTarget{rawAuthKeyID: [8]byte{0xfe}, authKeyID: [8]byte{0xfd}, sessionID: 999999}, domain.Authorization{AuthKeyID: [8]byte{0xfd}}, nil); err != nil { + t.Fatalf("export over capacity: %v", err) + } + reg.mu.Lock() + got := len(reg.byToken) + reg.mu.Unlock() + if got > loginTokenMaxRecords { + t.Fatalf("registry size = %d, want <= %d", got, loginTokenMaxRecords) + } +} diff --git a/internal/rpc/bots.go b/internal/rpc/bots.go new file mode 100644 index 00000000..51ea5c9d --- /dev/null +++ b/internal/rpc/bots.go @@ -0,0 +1,389 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// registerBots 注册 bots.* RPC handler。 +// +// 权限语义(官方访问矩阵): +// - setBotCommands/getBotCommands/resetBotCommands/setBotMenuButton/getBotMenuButton: +// 仅 bot 自己(调用者必须是 bot 账号)。 +// - setBotInfo/getBotInfo:owner 经 bot:InputUser 代设,或 bot 自己(不带 bot 参数)。 +// +// P2 范围:仅 default scope、单语言(非 default scope 与非空 lang_code 一律接受 +// 但不存储,避免覆盖全局——见各 handler 的 isDefaultBotCommandScope/lang_code 闸门); +// menu button 为 per-bot 全局(per-user 维度记 todo)。元数据写入由 service 在事务内 +// bump bot_info_version;命令变更后 bots_hooks.PushBotCommandsChanged 给在线相关用户 +// 推 updateBotCommands(扇出封顶 100,无 pts),离线/超界用户靠 version bump 在下次 +// getFullUser 重拉兜底。 +func (r *Router) registerBots(d *tg.ServerDispatcher) { + d.OnBotsSendCustomRequest(r.onBotsSendCustomRequest) + d.OnBotsAnswerWebhookJSONQuery(r.onBotsAnswerWebhookJSONQuery) + d.OnBotsSetBotBroadcastDefaultAdminRights(r.onBotsSetBotBroadcastDefaultAdminRights) + d.OnBotsSetBotGroupDefaultAdminRights(r.onBotsSetBotGroupDefaultAdminRights) + d.OnBotsSetBotCommands(r.onBotsSetBotCommands) + d.OnBotsResetBotCommands(r.onBotsResetBotCommands) + d.OnBotsGetBotCommands(r.onBotsGetBotCommands) + d.OnBotsSetBotInfo(r.onBotsSetBotInfo) + d.OnBotsGetBotInfo(r.onBotsGetBotInfo) + d.OnBotsSetBotMenuButton(r.onBotsSetBotMenuButton) + d.OnBotsGetBotMenuButton(r.onBotsGetBotMenuButton) + d.OnBotsReorderUsernames(r.onBotsReorderUsernames) + d.OnBotsToggleUsername(r.onBotsToggleUsername) + d.OnBotsCanSendMessage(r.onBotsCanSendMessage) + d.OnBotsAllowSendMessage(r.onBotsAllowSendMessage) + d.OnBotsInvokeWebViewCustomMethod(r.onBotsInvokeWebViewCustomMethod) + d.OnBotsGetPopularAppBots(r.onBotsGetPopularAppBots) + d.OnBotsAddPreviewMedia(r.onBotsAddPreviewMedia) + d.OnBotsEditPreviewMedia(r.onBotsEditPreviewMedia) + d.OnBotsDeletePreviewMedia(r.onBotsDeletePreviewMedia) + d.OnBotsReorderPreviewMedias(r.onBotsReorderPreviewMedias) + d.OnBotsGetPreviewInfo(r.onBotsGetPreviewInfo) + d.OnBotsGetPreviewMedias(r.onBotsGetPreviewMedias) + d.OnBotsUpdateUserEmojiStatus(r.onBotsUpdateUserEmojiStatus) + d.OnBotsToggleUserEmojiStatusPermission(r.onBotsToggleUserEmojiStatusPermission) + d.OnBotsCheckDownloadFileParams(r.onBotsCheckDownloadFileParams) + d.OnBotsGetAdminedBots(r.onBotsGetAdminedBots) + d.OnBotsUpdateStarRefProgram(r.onBotsUpdateStarRefProgram) + d.OnBotsSetCustomVerification(r.onBotsSetCustomVerification) + d.OnBotsGetBotRecommendations(r.onBotsGetBotRecommendations) + d.OnBotsCheckUsername(r.onBotsCheckUsername) + d.OnBotsCreateBot(r.onBotsCreateBot) + d.OnBotsExportBotToken(r.onBotsExportBotToken) + d.OnBotsRequestWebViewButton(r.onBotsRequestWebViewButton) + d.OnBotsGetRequestedWebViewButton(r.onBotsGetRequestedWebViewButton) + d.OnBotsGetAccessSettings(r.onBotsGetAccessSettings) + d.OnBotsEditAccessSettings(r.onBotsEditAccessSettings) + // P3:startBot 深链 + inline callback 闭环。 + d.OnMessagesStartBot(r.onMessagesStartBot) + d.OnMessagesGetBotCallbackAnswer(r.onMessagesGetBotCallbackAnswer) + d.OnMessagesSetBotCallbackAnswer(r.onMessagesSetBotCallbackAnswer) + d.OnMessagesGetInlineBotResults(r.onMessagesGetInlineBotResults) + d.OnMessagesSetInlineBotResults(r.onMessagesSetInlineBotResults) + d.OnMessagesSendInlineBotResult(r.onMessagesSendInlineBotResult) + d.OnMessagesSavePreparedInlineMessage(r.onMessagesSavePreparedInlineMessage) + d.OnMessagesEditInlineBotMessage(r.onMessagesEditInlineBotMessage) + d.OnMessagesSetBotShippingResults(r.onMessagesSetBotShippingResults) + d.OnMessagesSetBotPrecheckoutResults(r.onMessagesSetBotPrecheckoutResults) +} + +// callerBotID 校验调用者本身是 bot 账号,返回其 user_id(bot-only RPC 用)。 +func (r *Router) callerBotID(ctx context.Context) (int64, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, internalErr() + } + if r.deps.Bots == nil || userID == 0 { + return 0, userBotRequiredErr() + } + if _, found, err := r.deps.Bots.BotInfo(ctx, userID); err != nil { + return 0, internalErr() + } else if !found { + return 0, userBotRequiredErr() + } + return userID, nil +} + +func (r *Router) onBotsGetAdminedBots(ctx context.Context) ([]tg.UserClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Bots == nil { + return []tg.UserClass{}, nil + } + bots, err := r.deps.Bots.ListOwnedBots(ctx, userID) + if err != nil { + return nil, internalErr() + } + return r.tgUsersForViewer(userID, bots), nil +} + +func (r *Router) onBotsCheckUsername(ctx context.Context, username string) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Bots == nil { + return false, internalErr() + } + ok, err := r.deps.Bots.CheckUsername(ctx, userID, username) + if err != nil { + return false, botUsernameErr(err) + } + return ok, nil +} + +func (r *Router) onBotsCreateBot(ctx context.Context, req *tg.BotsCreateBotRequest) (tg.UserClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Bots == nil { + return nil, internalErr() + } + if err := r.validateBotManager(ctx, userID, req.ManagerID); err != nil { + return nil, err + } + u, _, err := r.deps.Bots.CreateBot(ctx, userID, req.Name, req.Username) + if err != nil { + return nil, createBotErr(err) + } + return r.tgUser(u), nil +} + +func (r *Router) validateBotManager(ctx context.Context, currentUserID int64, manager tg.InputUserClass) error { + if manager == nil || r.deps.Bots == nil { + return managerPermissionMissingErr() + } + u, found, err := r.userFromInput(ctx, currentUserID, manager) + if err != nil || !found { + return managerPermissionMissingErr() + } + if _, found, err := r.deps.Bots.BotInfo(ctx, u.ID); err != nil { + return internalErr() + } else if !found { + return managerPermissionMissingErr() + } + return nil +} + +func (r *Router) onBotsExportBotToken(ctx context.Context, req *tg.BotsExportBotTokenRequest) (*tg.BotsExportedBotToken, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Bots == nil { + return nil, internalErr() + } + target, found, err := r.userFromInput(ctx, userID, req.Bot) + if err != nil || !found { + return nil, botInvalidErr() + } + token, err := r.deps.Bots.ExportBotToken(ctx, userID, target.ID, req.Revoke) + if err != nil { + if errors.Is(err, domain.ErrBotSessionsNotRevoked) && token != "" { + return &tg.BotsExportedBotToken{Token: token}, nil + } + return nil, exportBotTokenErr(err) + } + return &tg.BotsExportedBotToken{Token: token}, nil +} + +// isDefaultCommandsTarget 报告请求是否落在 P2 唯一持久化的桶(default scope + +// 空 lang_code)。非 default scope 或非空 lang_code 一律接受但不存储——否则会 +// 用某语言/某 scope 的命令覆盖唯一的全局 default 桶(reset 还会误清全局)。 +func isDefaultCommandsTarget(scope tg.BotCommandScopeClass, langCode string) bool { + return isDefaultBotCommandScope(scope) && langCode == "" +} + +func (r *Router) onBotsSetBotCommands(ctx context.Context, req *tg.BotsSetBotCommandsRequest) (bool, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return false, err + } + // P2 仅持久化 default scope + 空 lang_code;其它接受但不存储(记 todo),避免 + // 覆盖全局桶,也避免客户端因 error 重试。 + if !isDefaultCommandsTarget(req.Scope, req.LangCode) { + return true, nil + } + if _, err := r.deps.Bots.SetBotCommands(ctx, botID, domainBotCommands(req.Commands)); err != nil { + return false, setBotCommandsErr(err) + } + r.invalidateChannelFullBotInfoCache() + r.invalidateRPCProjectionForUser(botID) + return true, nil +} + +func (r *Router) onBotsResetBotCommands(ctx context.Context, req *tg.BotsResetBotCommandsRequest) (bool, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return false, err + } + if !isDefaultCommandsTarget(req.Scope, req.LangCode) { + return true, nil + } + if _, err := r.deps.Bots.SetBotCommands(ctx, botID, nil); err != nil { + return false, setBotCommandsErr(err) + } + r.invalidateChannelFullBotInfoCache() + r.invalidateRPCProjectionForUser(botID) + return true, nil +} + +func (r *Router) onBotsGetBotCommands(ctx context.Context, req *tg.BotsGetBotCommandsRequest) ([]tg.BotCommand, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return nil, err + } + if !isDefaultCommandsTarget(req.Scope, req.LangCode) { + return []tg.BotCommand{}, nil + } + commands, err := r.deps.Bots.GetBotCommands(ctx, botID) + if err != nil { + return nil, internalErr() + } + return tgBotCommands(commands), nil +} + +// resolveBotInfoTarget 解析 setBotInfo/getBotInfo 的目标 bot:带 bot 参数→owner 校验 +// 该 bot;不带→调用者本身须为 bot。 +func (r *Router) resolveBotInfoTarget(ctx context.Context, bot tg.InputUserClass, hasBot bool) (int64, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, internalErr() + } + if r.deps.Bots == nil { + return 0, userBotInvalidErr() + } + if hasBot && bot != nil { + target, found, err := r.userFromInput(ctx, userID, bot) + if err != nil || !found { + return 0, botInvalidErr() + } + owns, err := r.deps.Bots.OwnsBot(ctx, userID, target.ID) + if err != nil { + return 0, internalErr() + } + if !owns { + return 0, botInvalidErr() + } + return target.ID, nil + } + // 无 bot 参数:调用者必须是 bot 自己。 + if _, found, err := r.deps.Bots.BotInfo(ctx, userID); err != nil { + return 0, internalErr() + } else if !found { + return 0, userBotInvalidErr() + } + return userID, nil +} + +func (r *Router) onBotsSetBotInfo(ctx context.Context, req *tg.BotsSetBotInfoRequest) (bool, error) { + bot, hasBot := req.GetBot() + botID, err := r.resolveBotInfoTarget(ctx, bot, hasBot) + if err != nil { + return false, err + } + // 非空 lang_code:本地化 name/about/description 接受但不存储(否则写穿全局列)。 + if req.LangCode != "" { + return true, nil + } + var upd domain.BotInfoUpdate + if name, ok := req.GetName(); ok { + upd.SetName, upd.Name = true, name + } + if about, ok := req.GetAbout(); ok { + upd.SetAbout, upd.About = true, about + } + if description, ok := req.GetDescription(); ok { + upd.SetDescription, upd.Description = true, description + } + if !upd.SetName && !upd.SetAbout && !upd.SetDescription { + return true, nil + } + if _, err := r.deps.Bots.SetBotInfo(ctx, botID, upd); err != nil { + return false, setBotInfoErr(err) + } + r.invalidateChannelFullBotInfoCache() + r.invalidateRPCProjectionForUser(botID) + return true, nil +} + +func (r *Router) onBotsGetBotInfo(ctx context.Context, req *tg.BotsGetBotInfoRequest) (*tg.BotsBotInfo, error) { + bot, hasBot := req.GetBot() + botID, err := r.resolveBotInfoTarget(ctx, bot, hasBot) + if err != nil { + return nil, err + } + name, about, description, err := r.deps.Bots.GetBotInfo(ctx, botID) + if err != nil { + return nil, internalErr() + } + return &tg.BotsBotInfo{Name: name, About: about, Description: description}, nil +} + +func (r *Router) onBotsSetBotMenuButton(ctx context.Context, req *tg.BotsSetBotMenuButtonRequest) (bool, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return false, err + } + button, err := domainBotMenuButton(req.Button) + if err != nil { + return false, err + } + if _, err := r.deps.Bots.SetBotMenuButton(ctx, botID, button); err != nil { + return false, setBotMenuButtonErr(err) + } + r.invalidateChannelFullBotInfoCache() + r.invalidateRPCProjectionForUser(botID) + return true, nil +} + +func (r *Router) onBotsGetBotMenuButton(ctx context.Context, userid tg.InputUserClass) (tg.BotMenuButtonClass, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return nil, err + } + button, err := r.deps.Bots.GetBotMenuButton(ctx, botID) + if err != nil { + return nil, internalErr() + } + return tgBotMenuButton(button), nil +} + +// --- tg ↔ domain 转换 --- + +func isDefaultBotCommandScope(scope tg.BotCommandScopeClass) bool { + if scope == nil { + return true + } + _, ok := scope.(*tg.BotCommandScopeDefault) + return ok +} + +func domainBotCommands(in []tg.BotCommand) []domain.BotCommand { + out := make([]domain.BotCommand, 0, len(in)) + for _, c := range in { + out = append(out, domain.BotCommand{Command: c.Command, Description: c.Description}) + } + return out +} + +func tgBotCommands(in []domain.BotCommand) []tg.BotCommand { + out := make([]tg.BotCommand, 0, len(in)) + for _, c := range in { + out = append(out, tg.BotCommand{Command: c.Command, Description: c.Description}) + } + return out +} + +func domainBotMenuButton(in tg.BotMenuButtonClass) (domain.BotMenuButton, error) { + switch v := in.(type) { + case *tg.BotMenuButtonDefault: + return domain.BotMenuButton{Type: domain.BotMenuButtonDefault}, nil + case *tg.BotMenuButtonCommands: + return domain.BotMenuButton{Type: domain.BotMenuButtonCommands}, nil + case *tg.BotMenuButton: + return domain.BotMenuButton{Type: domain.BotMenuButtonWebView, Text: v.Text, URL: v.URL}, nil + default: + return domain.BotMenuButton{}, botMenuButtonInvalidErr() + } +} + +func tgBotMenuButton(b domain.BotMenuButton) tg.BotMenuButtonClass { + switch b.Type { + case domain.BotMenuButtonCommands: + return &tg.BotMenuButtonCommands{} + case domain.BotMenuButtonWebView: + return &tg.BotMenuButton{Text: b.Text, URL: b.URL} + default: + return &tg.BotMenuButtonDefault{} + } +} diff --git a/internal/rpc/bots_callback.go b/internal/rpc/bots_callback.go new file mode 100644 index 00000000..304a8806 --- /dev/null +++ b/internal/rpc/bots_callback.go @@ -0,0 +1,123 @@ +package rpc + +import ( + "context" + "time" + "unicode/utf8" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + + "telesrv/internal/domain" +) + +// botCallbackTimeout 是 getBotCallbackAnswer 的挂起上限:bot 未在窗口内 +// setBotCallbackAnswer 即回 BOT_RESPONSE_TIMEOUT(不快速失败,给 bot 上线追答的窗口)。 +const botCallbackTimeout = 25 * time.Second + +func botResponseTimeoutErr() error { return tgerr.New(502, "BOT_RESPONSE_TIMEOUT") } +func dataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") } + +// onMessagesGetBotCallbackAnswer 处理 inline callback 按钮点击:把 updateBotCallbackQuery +// 推给 bot,挂起等待 bot 的 setBotCallbackAnswer,或超时回 BOT_RESPONSE_TIMEOUT。 +func (r *Router) onMessagesGetBotCallbackAnswer(ctx context.Context, req *tg.MessagesGetBotCallbackAnswerRequest) (*tg.MessagesBotCallbackAnswer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + // callback 按钮只存在于 bot 的私聊消息。peer 必须是 bot 用户。 + if peer.Type != domain.PeerTypeUser || !r.userIsBot(ctx, peer.ID) { + return nil, dataInvalidErr() + } + botUserID := peer.ID + // game 按钮(getBotCallbackAnswer.game)P3 不支持:返回空答案(客户端不弹任何东西), + // 不挂起、不推送(避免给 bot 投递无法处理的 game query)。 + if req.Game { + return &tg.MessagesBotCallbackAnswer{}, nil + } + data, hasData := req.GetData() + if !hasData { + return nil, dataInvalidErr() + } + // 校验目标消息存在于请求者自己的盒、且对端正是该 bot。 + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.MsgID) + if err != nil { + return nil, internalErr() + } + if !ok || msg.Peer != peer { + return nil, messageIDInvalidErr() + } + + queryID, pending := r.callbacks.register(botUserID, userID) + defer r.callbacks.deregister(queryID) + + // updateBotCallbackQuery 是 ephemeral(无 pts/qts,不进 getDifference):仅在线推给 + // bot;bot 离线则投递 0,但仍走超时窗口(I5,给 bot 上线追答机会)。 + // MsgID 透传请求者侧的 box id(P3 不做 bot 侧 box id 翻译——bot 侧消息编辑后移,记 todo)。 + update := &tg.UpdateBotCallbackQuery{ + QueryID: queryID, + UserID: userID, + Peer: &tg.PeerUser{UserID: userID}, + MsgID: req.MsgID, + ChatInstance: chatInstanceFor(botUserID, userID), + } + update.SetData(data) + r.pushUserMessage(ctx, botUserID, "push bot callback query", &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: int(r.clock.Now().Unix()), + }) + + waitCtx, cancel := context.WithTimeout(ctx, botCallbackTimeout) + defer cancel() + select { + case ans := <-pending.ch: + return tgBotCallbackAnswer(ans), nil + case <-waitCtx.Done(): + return nil, botResponseTimeoutErr() + } +} + +// onMessagesSetBotCallbackAnswer 是 bot 对一次 callback query 的应答:解挂等待中的 +// getBotCallbackAnswer。仅属主 bot 可解挂(callerBotID==pending.botUserID,I6)。 +func (r *Router) onMessagesSetBotCallbackAnswer(ctx context.Context, req *tg.MessagesSetBotCallbackAnswerRequest) (bool, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return false, err + } + ans := domain.BotCallbackAnswer{Alert: req.Alert, CacheTime: req.CacheTime} + if msg, ok := req.GetMessage(); ok { + if utf8.RuneCountInString(msg) > domain.MaxBotCallbackAnswerLen { + return false, messageTooLongErr() + } + ans.Message = msg + } + if url, ok := req.GetURL(); ok { + ans.URL = url + } + // resolve 返回是否投递成功;未注册/超时/非属主一律 false。对 bot 而言答案是否 + // 被等待者接收无关紧要(官方恒返回 true),但非属主必须拒绝投递(防钓鱼弹窗)。 + r.callbacks.resolve(botID, req.QueryID, ans) + return true, nil +} + +func tgBotCallbackAnswer(ans domain.BotCallbackAnswer) *tg.MessagesBotCallbackAnswer { + out := &tg.MessagesBotCallbackAnswer{Alert: ans.Alert, CacheTime: ans.CacheTime} + if ans.Message != "" { + out.SetMessage(ans.Message) + } + if ans.URL != "" { + out.SetURL(ans.URL) + out.HasURL = true + } + return out +} diff --git a/internal/rpc/bots_errors.go b/internal/rpc/bots_errors.go new file mode 100644 index 00000000..abed13cd --- /dev/null +++ b/internal/rpc/bots_errors.go @@ -0,0 +1,89 @@ +package rpc + +import ( + "errors" + + "github.com/gotd/td/tgerr" + + "telesrv/internal/domain" +) + +// bots.* 管理 RPC 的错误码(与 errors.go 同惯例,独立成文件便于 bot 模块维护)。 +func userBotRequiredErr() error { return tgerr.New(400, "USER_BOT_REQUIRED") } +func userBotInvalidErr() error { return tgerr.New(400, "USER_BOT_INVALID") } +func botInvalidErr() error { return tgerr.New(400, "BOT_INVALID") } +func botAppBotInvalidErr() error { return tgerr.New(400, "BOT_APP_BOT_INVALID") } +func botAppInvalidErr() error { return tgerr.New(400, "BOT_APP_INVALID") } +func botAppShortNameInvalidErr() error { + return tgerr.New(400, "BOT_APP_SHORTNAME_INVALID") +} +func botCommandInvalidErr() error { return tgerr.New(400, "BOT_COMMAND_INVALID") } +func botMenuButtonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") } +func botCreateLimitExceededErr() error { + return tgerr.New(400, "BOT_CREATE_LIMIT_EXCEEDED") +} +func managerPermissionMissingErr() error { return tgerr.New(400, "MANAGER_PERMISSION_MISSING") } +func methodInvalidErr() error { return tgerr.New(400, "METHOD_INVALID") } +func rightsNotModifiedErr() error { return tgerr.New(400, "RIGHTS_NOT_MODIFIED") } +func botVerifierForbiddenErr() error { return tgerr.New(403, "BOT_VERIFIER_FORBIDDEN") } +func userPermissionDeniedErr() error { return tgerr.New(403, "USER_PERMISSION_DENIED") } + +func setBotCommandsErr(err error) error { + if errors.Is(err, domain.ErrBotCommandInvalid) { + return botCommandInvalidErr() + } + if errors.Is(err, domain.ErrBotNotFound) { + return userBotRequiredErr() + } + return internalErr() +} + +func setBotInfoErr(err error) error { + if errors.Is(err, domain.ErrBotInfoInvalid) || errors.Is(err, domain.ErrBotNotFound) { + return botInvalidErr() + } + return internalErr() +} + +func setBotMenuButtonErr(err error) error { + if errors.Is(err, domain.ErrBotMenuButtonInvalid) { + return botMenuButtonInvalidErr() + } + if errors.Is(err, domain.ErrBotNotFound) { + return userBotRequiredErr() + } + return internalErr() +} + +func botUsernameErr(err error) error { + switch { + case errors.Is(err, domain.ErrBotUsernameInvalid): + return usernameInvalidErr() + case errors.Is(err, domain.ErrUsernameOccupied): + return usernameOccupiedErr() + default: + return internalErr() + } +} + +func createBotErr(err error) error { + switch { + case errors.Is(err, domain.ErrBotUsernameInvalid): + return usernameInvalidErr() + case errors.Is(err, domain.ErrUsernameOccupied): + return usernameOccupiedErr() + case errors.Is(err, domain.ErrBotsTooMany): + return botCreateLimitExceededErr() + case errors.Is(err, domain.ErrBotNameInvalid): + return firstNameInvalidErr() + default: + return internalErr() + } +} + +func exportBotTokenErr(err error) error { + if errors.Is(err, domain.ErrBotNotFound) { + return botInvalidErr() + } + return internalErr() +} diff --git a/internal/rpc/bots_group_rpc_test.go b/internal/rpc/bots_group_rpc_test.go new file mode 100644 index 00000000..889fdfe5 --- /dev/null +++ b/internal/rpc/bots_group_rpc_test.go @@ -0,0 +1,267 @@ +package rpc + +import ( + "context" + "strings" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + botsapp "telesrv/internal/app/bots" + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestGroupBotRPCShape(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + botStore := memory.NewBotStore(users) + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + bots := botsapp.NewService(users, botStore, messages) + channelStore := memory.NewChannelStore() + baseChannels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots)) + channels := &countingBotParticipantsChannelsService{Service: baseChannels} + + owner, err := users.Create(ctx, domain.User{AccessHash: 6201, Phone: "15550006201", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 6202, Phone: "15550006202", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + bot, _, err := bots.CreateBot(ctx, owner.ID, "Group Bot", "group_shape_bot") + if err != nil { + t.Fatalf("create bot: %v", err) + } + if _, err := bots.SetJoinGroups(ctx, bot.ID, false); err != nil { + t.Fatalf("disable join groups: %v", err) + } + + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(users), + Channels: channels, + Bots: bots, + }, zaptest.NewLogger(t), clock.System) + ownerCtx := WithUserID(ctx, owner.ID) + created, err := r.onMessagesCreateChat(ownerCtx, &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Bot Group RPC", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + + blockedUser := r.tgUser(bot) + if !blockedUser.BotNochats { + t.Fatalf("tg user bot_nochats = false, want true before invite") + } + if _, err := r.onChannelsInviteToChannel(ownerCtx, &tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Users: []tg.InputUserClass{&tg.InputUser{UserID: bot.ID, AccessHash: bot.AccessHash}}, + }); err == nil || !strings.Contains(err.Error(), "BOT_GROUPS_BLOCKED") { + t.Fatalf("invite blocked bot err = %v, want BOT_GROUPS_BLOCKED", err) + } + + if _, err := bots.SetJoinGroups(ctx, bot.ID, true); err != nil { + t.Fatalf("enable join groups: %v", err) + } + if _, err := bots.SetBotCommands(ctx, bot.ID, []domain.BotCommand{{Command: "status", Description: "Show status"}}); err != nil { + t.Fatalf("set bot commands: %v", err) + } + if _, err := r.onChannelsInviteToChannel(ownerCtx, &tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Users: []tg.InputUserClass{&tg.InputUser{UserID: bot.ID, AccessHash: bot.AccessHash}}, + }); err != nil { + t.Fatalf("invite allowed bot: %v", err) + } + + participants, err := r.onChannelsGetParticipants(ownerCtx, &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsBots{}, + Limit: 20, + }) + if err != nil { + t.Fatalf("get bot participants: %v", err) + } + list := participants.(*tg.ChannelsChannelParticipants) + if list.Count != 1 || len(list.Participants) != 1 { + t.Fatalf("bot participants = count %d len %d, want one", list.Count, len(list.Participants)) + } + if len(list.Users) != 1 { + t.Fatalf("bot participants users = %d, want one", len(list.Users)) + } + listBot := list.Users[0].(*tg.User) + if !listBot.Bot || listBot.ID != bot.ID || listBot.BotNochats { + t.Fatalf("participants user = %+v, want allowed bot", listBot) + } + + channels.botParticipantCalls = 0 + full, err := r.onChannelsGetFullChannel(ownerCtx, &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel: %v", err) + } + channelFull := full.FullChat.(*tg.ChannelFull) + if len(channelFull.BotInfo) != 1 || channelFull.BotInfo[0].UserID != bot.ID { + t.Fatalf("channel full bot_info = %+v, want bot %d", channelFull.BotInfo, bot.ID) + } + if len(channelFull.BotInfo[0].Commands) != 1 || channelFull.BotInfo[0].Commands[0].Command != "status" { + t.Fatalf("channel full bot commands = %+v, want status", channelFull.BotInfo[0].Commands) + } + foundUser := false + for _, u := range full.Users { + if got, ok := u.(*tg.User); ok && got.ID == bot.ID && got.Bot { + foundUser = true + } + } + if !foundUser { + t.Fatalf("full channel users = %+v, want bot user", full.Users) + } + if _, err := r.onChannelsGetFullChannel(ownerCtx, &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}); err != nil { + t.Fatalf("get full channel cached: %v", err) + } + if channels.botParticipantCalls != 1 { + t.Fatalf("full channel bot participants calls = %d, want 1", channels.botParticipantCalls) + } +} + +func TestFullChannelBotInfoCacheCachesEmptyResult(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + botStore := memory.NewBotStore(users) + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + bots := botsapp.NewService(users, botStore, messages) + channelStore := memory.NewChannelStore() + baseChannels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots)) + channels := &countingBotParticipantsChannelsService{Service: baseChannels} + + owner, err := users.Create(ctx, domain.User{AccessHash: 6221, Phone: "15550006221", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 6222, Phone: "15550006222", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(users), + Channels: channels, + Bots: bots, + }, zaptest.NewLogger(t), clock.System) + ownerCtx := WithUserID(ctx, owner.ID) + created, err := r.onMessagesCreateChat(ownerCtx, &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Empty Bot Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + + channels.botParticipantCalls = 0 + first, err := r.onChannelsGetFullChannel(ownerCtx, &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get first full channel: %v", err) + } + if got := len(first.FullChat.(*tg.ChannelFull).BotInfo); got != 0 { + t.Fatalf("first full channel bot_info len = %d, want 0", got) + } + second, err := r.onChannelsGetFullChannel(ownerCtx, &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get second full channel: %v", err) + } + if got := len(second.FullChat.(*tg.ChannelFull).BotInfo); got != 0 { + t.Fatalf("second full channel bot_info len = %d, want 0", got) + } + if channels.botParticipantCalls != 1 { + t.Fatalf("empty full channel bot participants calls = %d, want 1", channels.botParticipantCalls) + } +} + +func TestTGUsersForIDsUsesBatchBotProfiles(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + botStore := memory.NewBotStore(users) + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + baseBots := botsapp.NewService(users, botStore, messages) + bots := &countingBatchBotsService{Service: baseBots} + + owner, err := users.Create(ctx, domain.User{AccessHash: 6211, Phone: "15550006211", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + botA, _, err := baseBots.CreateBot(ctx, owner.ID, "Batch Bot A", "batcha_bot") + if err != nil { + t.Fatalf("create bot A: %v", err) + } + botB, _, err := baseBots.CreateBot(ctx, owner.ID, "Batch Bot B", "batchb_bot") + if err != nil { + t.Fatalf("create bot B: %v", err) + } + if _, err := baseBots.SetJoinGroups(ctx, botA.ID, false); err != nil { + t.Fatalf("disable bot A groups: %v", err) + } + if _, err := baseBots.SetInlinePlaceholder(ctx, botB.ID, "Search B"); err != nil { + t.Fatalf("set bot B inline placeholder: %v", err) + } + + r := New(Config{}, Deps{ + Users: appusers.NewService(users), + Bots: bots, + }, zaptest.NewLogger(t), clock.System) + got := r.tgUsersForIDs(ctx, owner.ID, []int64{botA.ID, botB.ID}) + if bots.batchCalls != 1 { + t.Fatalf("BotInfos calls = %d, want 1", bots.batchCalls) + } + if bots.singleCalls != 0 { + t.Fatalf("BotInfo calls = %d, want 0", bots.singleCalls) + } + byID := make(map[int64]*tg.User) + for _, item := range got { + if u, ok := item.(*tg.User); ok { + byID[u.ID] = u + } + } + if u := byID[botA.ID]; u == nil || !u.BotNochats { + t.Fatalf("bot A user = %+v, want bot_nochats", u) + } + if u := byID[botB.ID]; u == nil || u.BotInlinePlaceholder != "Search B" { + t.Fatalf("bot B user = %+v, want inline placeholder", u) + } +} + +type countingBatchBotsService struct { + *botsapp.Service + singleCalls int + batchCalls int +} + +func (s *countingBatchBotsService) BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) { + s.singleCalls++ + return s.Service.BotInfo(ctx, botUserID) +} + +func (s *countingBatchBotsService) BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) { + s.batchCalls++ + return s.Service.BotInfos(ctx, botUserIDs) +} + +type countingBotParticipantsChannelsService struct { + *appchannels.Service + botParticipantCalls int +} + +func (s *countingBotParticipantsChannelsService) GetParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { + if filter.Kind == domain.ChannelParticipantsBots { + s.botParticipantCalls++ + } + return s.Service.GetParticipants(ctx, userID, channelID, filter, offset, limit) +} diff --git a/internal/rpc/bots_hooks.go b/internal/rpc/bots_hooks.go new file mode 100644 index 00000000..fb3d4026 --- /dev/null +++ b/internal/rpc/bots_hooks.go @@ -0,0 +1,102 @@ +package rpc + +import ( + "context" + "time" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// 本文件实现 app/bots 的 RouterHooks 回调:token revoke 后的 session 失效闭环, +// 以及命令变更后的 updateBotCommands 在线推送。Router 创建后经 +// botsService.SetRouterHooks(router) 装配(见 cmd/telesrv/main.go)。 + +// maxBotCommandsPushPeers 限制单次命令变更的推送扇出(bot 的最近 dialog peer 数)。 +// 超出的离线/长尾用户靠 bot_info_version bump 在下次 getFullUser 时拿到新命令。 +const maxBotCommandsPushPeers = 100 + +// RevokeBotSessions 撤销 bot 的全部已登录 session:删除全部 authorization 行并 +// 强制断开在线连接(与 account.resetAuthorization 被踢闭环同款顺序)。 +func (r *Router) RevokeBotSessions(ctx context.Context, botUserID int64) error { + if r.deps.Auth == nil || botUserID == 0 { + return nil + } + deleted, err := r.deps.Auth.ResetAuthorizations(ctx, botUserID, [8]byte{}) + if err != nil { + return err + } + for _, a := range deleted { + r.revokeAuthKeySessions(a.AuthKeyID) + if err := r.clearAuthKeyState(ctx, a.AuthKeyID); err != nil { + r.log.Warn("revoke bot sessions: clear auth key state", + zap.Int64("bot_user_id", botUserID), zap.Error(err)) + } + } + if len(deleted) > 0 { + r.log.Info("revoked bot sessions", zap.Int64("bot_user_id", botUserID), zap.Int("count", len(deleted))) + } + return nil +} + +// PushBotCommandsChanged 给「与该 bot 有私聊 dialog 且在线」的用户推 +// updateBotCommands(peer = 该 bot 的 user peer,对齐 TDesktop/DrKLO 消费语义)。 +// updateBotCommands 无 pts/qts,不进 getDifference——离线用户由随写库一起完成的 +// bot_info_version bump 兜底(下次 getFullUser 重拉命令)。 +// +// fire-and-forget:在独立 goroutine 内执行(脱离已返回的 setBotCommands RPC ctx), +// 否则 GetDialogs + 最多 maxBotCommandsPushPeers 次 best-effort 推送会把 RPC 响应 +// 拖到拥塞超时之和、并跨用户占住 BotFather 条带锁。推送是纯通知,丢失靠 version +// bump 兜底,不需保证投达。扇出有界:只取 bot dialog 列表前 maxBotCommandsPushPeers +// 个 user peer,再按在线快照过滤;超界部分走版本兜底(有意取舍)。 +func (r *Router) PushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand) { + if r.deps.Dialogs == nil || r.deps.Sessions == nil || botUserID == 0 { + return + } + // 拷贝命令切片:调用方(service)可能复用底层数组。 + cmds := append([]domain.BotCommand(nil), commands...) + go r.pushBotCommandsChanged(context.WithoutCancel(ctx), botUserID, cmds) +} + +func (r *Router) pushBotCommandsChanged(ctx context.Context, botUserID int64, commands []domain.BotCommand) { + defer func() { + if rec := recover(); rec != nil { + r.log.Error("push bot commands panicked", zap.Int64("bot_user_id", botUserID), zap.Any("panic", rec)) + } + }() + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + list, err := r.deps.Dialogs.GetDialogs(ctx, botUserID, domain.DialogFilter{Limit: maxBotCommandsPushPeers}) + if err != nil { + r.log.Warn("push bot commands: list bot dialogs", zap.Int64("bot_user_id", botUserID), zap.Error(err)) + return + } + candidates := make([]int64, 0, len(list.Dialogs)) + for _, dialog := range list.Dialogs { + if dialog.Peer.Type == domain.PeerTypeUser && dialog.Peer.ID != 0 && dialog.Peer.ID != botUserID { + candidates = append(candidates, dialog.Peer.ID) + } + } + if len(candidates) == 0 { + return + } + if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok { + candidates = provider.OnlineUserIDsForCandidates(candidates, maxBotCommandsPushPeers) + } + if len(candidates) == 0 { + return + } + update := &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateBotCommands{ + Peer: &tg.PeerUser{UserID: botUserID}, + BotID: botUserID, + Commands: tgBotCommands(commands), + }}, + Date: int(r.clock.Now().Unix()), + } + for _, userID := range candidates { + r.pushUserMessage(ctx, userID, "push bot commands", update) + } +} diff --git a/internal/rpc/bots_inline.go b/internal/rpc/bots_inline.go new file mode 100644 index 00000000..4589b0bb --- /dev/null +++ b/internal/rpc/bots_inline.go @@ -0,0 +1,1200 @@ +package rpc + +import ( + "context" + "net/url" + "strings" + "time" + "unicode/utf8" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const ( + botInlineQueryTTL = 25 * time.Second + botInlineCacheMaxTTL = 5 * time.Minute + botInlineCacheMaxEntries = 256 +) + +func botInlineDisabledErr() error { return tgerr.New(400, "BOT_INLINE_DISABLED") } +func botInlineGeoNotAllowedErr() error { return tgerr.New(400, "BOT_INLINE_GEO_NOT_ALLOWED") } +func botWebviewDisabledErr() error { return tgerr.New(400, "BOT_WEBVIEW_DISABLED") } +func queryIDInvalidErr() error { return tgerr.New(400, "QUERY_ID_INVALID") } +func queryIDEmptyErr() error { return tgerr.New(400, "QUERY_ID_EMPTY") } +func resultIDEmptyErr() error { return tgerr.New(400, "RESULT_ID_EMPTY") } +func resultIDInvalidErr() error { return tgerr.New(400, "RESULT_ID_INVALID") } +func resultIDDuplicateErr() error { return tgerr.New(400, "RESULT_ID_DUPLICATE") } +func resultTypeInvalidErr() error { return tgerr.New(400, "RESULT_TYPE_INVALID") } +func resultsTooMuchErr() error { return tgerr.New(400, "RESULTS_TOO_MUCH") } +func sendMessageTypeInvalidErr() error { return tgerr.New(400, "SEND_MESSAGE_TYPE_INVALID") } +func nextOffsetInvalidErr() error { return tgerr.New(400, "NEXT_OFFSET_INVALID") } +func startParamEmptyErr() error { return tgerr.New(400, "START_PARAM_EMPTY") } +func switchPmTextEmptyErr() error { return tgerr.New(400, "SWITCH_PM_TEXT_EMPTY") } +func switchWebviewInvalidErr() error { return tgerr.New(400, "SWITCH_WEBVIEW_URL_INVALID") } +func inlineResultExpiredErr() error { return tgerr.New(400, "INLINE_RESULT_EXPIRED") } +func webDocumentInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_INVALID") } +func webDocumentMimeInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_MIME_INVALID") } +func webDocumentSizeTooBigErr() error { return tgerr.New(400, "WEBDOCUMENT_SIZE_TOO_BIG") } +func webDocumentURLEmptyErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_EMPTY") } +func webDocumentURLInvalidErr() error { return tgerr.New(400, "WEBDOCUMENT_URL_INVALID") } + +func (r *Router) onMessagesGetInlineBotResults(ctx context.Context, req *tg.MessagesGetInlineBotResultsRequest) (*tg.MessagesBotResults, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 || r.deps.Bots == nil { + return nil, botInvalidErr() + } + if len(req.Offset) > domain.MaxBotInlineNextOffsetLen { + return nil, nextOffsetInvalidErr() + } + bot, found, err := r.userFromInput(ctx, userID, req.Bot) + if err != nil { + return nil, internalErr() + } + if !found || !bot.Bot { + return nil, botInvalidErr() + } + profile, found, err := r.deps.Bots.BotInfo(ctx, bot.ID) + if err != nil { + return nil, internalErr() + } + if !found || profile.InlinePlaceholder == "" { + return nil, botInlineDisabledErr() + } + var queryGeo *domain.MessageGeoPoint + if geo, ok := req.GetGeoPointAsNotEmpty(); ok { + if !profile.InlineGeo { + return nil, botInlineGeoNotAllowedErr() + } + queryGeo, err = domainGeoPointFromInput(geo) + if err != nil { + return nil, err + } + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + cacheKey := inlineCacheKey{ + botUserID: bot.ID, + userID: userID, + peer: peer, + query: req.Query, + offset: req.Offset, + } + if queryGeo != nil { + cacheKey.hasGeo = true + cacheKey.geoLat = queryGeo.Lat + cacheKey.geoLong = queryGeo.Long + cacheKey.geoAccuracy = queryGeo.AccuracyRadius + } + now := r.clock.Now() + if cached, ok := r.inlines.cachedContext(ctx, now, cacheKey); ok { + results := r.inlines.registerCachedContext(ctx, now, bot.ID, userID, peer, cached) + return r.tgBotInlineResults(ctx, userID, results), nil + } + queryID, pending := r.inlines.registerWithCacheKeyContext(ctx, now, bot.ID, userID, peer, cacheKey) + defer r.inlines.deregisterIfUnansweredContext(ctx, queryID) + + peerType := tgInlineQueryPeerType(ctx, r, userID, bot.ID, peer) + update := &tg.UpdateBotInlineQuery{ + QueryID: queryID, + UserID: userID, + Query: req.Query, + Offset: req.Offset, + PeerType: peerType, + } + if queryGeo != nil { + update.SetGeo(tgGeoPoint(*queryGeo)) + } + r.pushUserMessage(ctx, bot.ID, "push bot inline query", &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: int(now.Unix()), + }) + push := store.BotInlineQueryPush{ + QueryID: queryID, + BotUserID: bot.ID, + UserID: userID, + Query: req.Query, + Offset: req.Offset, + PeerType: storeInlineQueryPeerType(peerType), + Date: int(now.Unix()), + } + if queryGeo != nil { + geo := *queryGeo + push.Geo = &geo + } + r.publishInlineBotQuery(ctx, push) + + waitCtx, cancel := context.WithTimeout(ctx, botInlineQueryTTL) + defer cancel() + results, err := r.awaitInlineBotResults(waitCtx, pending, userID, queryID) + if err != nil { + return nil, err + } + return r.tgBotInlineResults(ctx, userID, results), nil +} + +func (r *Router) onMessagesSetInlineBotResults(ctx context.Context, req *tg.MessagesSetInlineBotResultsRequest) (bool, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return false, err + } + results, err := r.domainInlineResultsFromTG(ctx, botID, req) + if err != nil { + return false, err + } + if !r.inlines.resolveContext(ctx, r.clock.Now(), botID, req.QueryID, results) { + return false, queryIDInvalidErr() + } + return true, nil +} + +func (r *Router) onMessagesSendInlineBotResult(ctx context.Context, req *tg.MessagesSendInlineBotResultRequest) (tg.UpdatesClass, error) { + if req.QueryID == 0 { + return nil, queryIDEmptyErr() + } + if req.ID == "" { + return nil, resultIDEmptyErr() + } + if req.RandomID == 0 { + return nil, randomIDEmptyErr() + } + if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { + return nil, scheduleDateInvalidErr() + } + if req.QuickReplyShortcut != nil { + return nil, shortcutInvalidErr() + } + if req.AllowPaidStars < 0 { + return nil, starsAmountInvalidErr() + } + if req.AllowPaidStars > 0 { + return nil, paymentUnsupportedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + if err := r.checkSendRateLimit(ctx, userID, 1); err != nil { + return nil, err + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel { + return nil, peerIDInvalidErr() + } + results, result, ok := r.inlines.resultForSendContext(ctx, r.clock.Now(), userID, req.QueryID, req.ID) + if !ok { + return nil, inlineResultExpiredErr() + } + if !r.inlineResultsAllowPeer(ctx, userID, results, peer) { + return nil, peerIDInvalidErr() + } + media := cloneInlineMedia(result.Media) + if result.MediaAuto { + media, err = r.domainInlineExternalContentMedia(ctx, result) + if err != nil { + return nil, err + } + } + r.resolveInlineContactMediaForSender(ctx, userID, media) + updates, duplicate, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{ + randomID: req.RandomID, + message: result.Message, + entities: tgMessageEntities(result.Entities), + media: media, + silent: req.Silent, + replyToInput: req.ReplyTo, + sendAsInput: req.SendAs, + clearDraft: req.ClearDraft, + replyMarkup: result.ReplyMarkup, + viaBotID: results.BotUserID, + }) + if err != nil { + return nil, err + } + if !duplicate { + r.pushInlineBotSendFeedback(ctx, userID, results, result, updates) + r.inlines.consumeContext(ctx, req.QueryID) + } + return updates, nil +} + +func (r *Router) inlineResultsAllowPeer(ctx context.Context, userID int64, results domain.BotInlineResults, peer domain.Peer) bool { + if results.Peer.Type != "" || results.Peer.ID != 0 { + return results.Peer == peer + } + if len(results.PeerTypes) == 0 { + return true + } + actual := storeInlineQueryPeerType(tgInlineQueryPeerType(ctx, r, userID, results.BotUserID, peer)) + if actual == "" { + return false + } + for _, allowed := range results.PeerTypes { + if allowed == actual { + return true + } + } + return false +} + +func (r *Router) awaitInlineBotResults(ctx context.Context, pending *pendingInlineQuery, userID, queryID int64) (domain.BotInlineResults, error) { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case results := <-pending.ch: + return results, nil + case <-ticker.C: + if results, ok := r.inlines.resultsForQueryContext(ctx, r.clock.Now(), userID, queryID); ok { + return results, nil + } + case <-ctx.Done(): + return domain.BotInlineResults{}, botResponseTimeoutErr() + } + } +} + +func (r *Router) domainInlineResultsFromTG(ctx context.Context, botID int64, req *tg.MessagesSetInlineBotResultsRequest) (domain.BotInlineResults, error) { + if len(req.Results) > domain.MaxBotInlineResults { + return domain.BotInlineResults{}, resultsTooMuchErr() + } + if len(req.NextOffset) > domain.MaxBotInlineNextOffsetLen { + return domain.BotInlineResults{}, nextOffsetInvalidErr() + } + switchPM, err := domainInlineSwitchPM(req) + if err != nil { + return domain.BotInlineResults{}, err + } + switchWeb, err := domainInlineSwitchWebView(req) + if err != nil { + return domain.BotInlineResults{}, err + } + seen := make(map[string]struct{}, len(req.Results)) + out := domain.BotInlineResults{ + BotUserID: botID, + QueryID: req.QueryID, + Gallery: req.Gallery, + Private: req.Private, + CacheTime: req.CacheTime, + NextOffset: req.NextOffset, + SwitchPM: switchPM, + SwitchWeb: switchWeb, + Results: make([]domain.BotInlineResult, 0, len(req.Results)), + } + for _, raw := range req.Results { + item, err := r.domainInlineResultFromTG(ctx, botID, raw) + if err != nil { + return domain.BotInlineResults{}, err + } + if _, ok := seen[item.ID]; ok { + return domain.BotInlineResults{}, resultIDDuplicateErr() + } + seen[item.ID] = struct{}{} + out.Results = append(out.Results, item) + } + return out, nil +} + +func domainInlineSwitchWebView(req *tg.MessagesSetInlineBotResultsRequest) (*domain.BotInlineSwitchWebView, error) { + switchWeb, ok := req.GetSwitchWebview() + if !ok { + return nil, nil + } + if strings.TrimSpace(switchWeb.Text) == "" || utf8.RuneCountInString(switchWeb.Text) > domain.MaxBotInlineSwitchTextLen { + return nil, buttonInvalidErr() + } + if err := validateInlineWebURL(switchWeb.URL, true); err != nil { + return nil, switchWebviewInvalidErr() + } + return &domain.BotInlineSwitchWebView{ + Text: switchWeb.Text, + URL: switchWeb.URL, + }, nil +} + +func domainInlineSwitchPM(req *tg.MessagesSetInlineBotResultsRequest) (*domain.BotInlineSwitchPM, error) { + switchPM, ok := req.GetSwitchPm() + if !ok { + return nil, nil + } + if switchPM.Text == "" { + return nil, switchPmTextEmptyErr() + } + if utf8.RuneCountInString(switchPM.Text) > domain.MaxBotInlineSwitchTextLen { + return nil, buttonInvalidErr() + } + if switchPM.StartParam == "" { + return nil, startParamEmptyErr() + } + if !validInlineStartParam(switchPM.StartParam) { + return nil, startParamInvalidErr() + } + return &domain.BotInlineSwitchPM{ + Text: switchPM.Text, + StartParam: switchPM.StartParam, + }, nil +} + +func validInlineStartParam(in string) bool { + if utf8.RuneCountInString(in) > domain.MaxStartParamLen { + return false + } + for _, r := range in { + if r >= 'a' && r <= 'z' { + continue + } + if r >= 'A' && r <= 'Z' { + continue + } + if r >= '0' && r <= '9' { + continue + } + if r == '_' || r == '-' { + continue + } + return false + } + return true +} + +func (r *Router) domainInlineResultFromTG(ctx context.Context, botID int64, raw tg.InputBotInlineResultClass) (domain.BotInlineResult, error) { + switch result := raw.(type) { + case *tg.InputBotInlineResult: + if result.ID == "" { + return domain.BotInlineResult{}, resultIDEmptyErr() + } + if len(result.ID) > domain.MaxBotInlineResultIDLen { + return domain.BotInlineResult{}, resultIDInvalidErr() + } + if result.Type == "" { + return domain.BotInlineResult{}, resultTypeInvalidErr() + } + switch msg := result.SendMessage.(type) { + case *tg.InputBotInlineMessageText: + if result.Type != "article" { + return domain.BotInlineResult{}, resultTypeInvalidErr() + } + resultURL, thumb, content, err := domainInlineResultWebPreview(result) + if err != nil { + return domain.BotInlineResult{}, err + } + message, entities, markup, noWebpage, err := domainInlineTextMessage(botID, msg) + if err != nil { + return domain.BotInlineResult{}, err + } + return domain.BotInlineResult{ + ID: result.ID, + Type: result.Type, + Title: result.Title, + Description: result.Description, + URL: resultURL, + Thumb: thumb, + Content: content, + Message: message, + Entities: entities, + ReplyMarkup: markup, + NoWebpage: noWebpage, + }, nil + case *tg.InputBotInlineMessageMediaAuto: + if !validInlineExternalContentResultType(result.Type) { + return domain.BotInlineResult{}, resultTypeInvalidErr() + } + resultURL, thumb, content, err := domainInlineResultWebPreview(result) + if err != nil { + return domain.BotInlineResult{}, err + } + if content == nil { + return domain.BotInlineResult{}, webDocumentInvalidErr() + } + if !inlineExternalContentMimeAllowed(result.Type, content.MimeType) { + return domain.BotInlineResult{}, mediaInvalidErr() + } + message, entities, markup, err := domainInlineMediaAutoMessage(botID, msg) + if err != nil { + return domain.BotInlineResult{}, err + } + return domain.BotInlineResult{ + ID: result.ID, + Type: result.Type, + Title: result.Title, + Description: result.Description, + URL: resultURL, + Thumb: thumb, + Content: content, + Message: message, + Entities: entities, + ReplyMarkup: markup, + MediaAuto: true, + }, nil + case *tg.InputBotInlineMessageMediaGeo: + if result.Type != "geo" { + return domain.BotInlineResult{}, resultTypeInvalidErr() + } + if inlineResultHasWebPreview(result) { + return domain.BotInlineResult{}, webDocumentInvalidErr() + } + media, markup, err := domainInlineGeoMessage(msg) + if err != nil { + return domain.BotInlineResult{}, err + } + return domain.BotInlineResult{ + ID: result.ID, + Type: result.Type, + Title: result.Title, + Description: result.Description, + ReplyMarkup: markup, + Media: media, + }, nil + case *tg.InputBotInlineMessageMediaVenue: + if result.Type != "venue" { + return domain.BotInlineResult{}, resultTypeInvalidErr() + } + if inlineResultHasWebPreview(result) { + return domain.BotInlineResult{}, webDocumentInvalidErr() + } + media, markup, err := domainInlineVenueMessage(msg) + if err != nil { + return domain.BotInlineResult{}, err + } + return domain.BotInlineResult{ + ID: result.ID, + Type: result.Type, + Title: result.Title, + Description: result.Description, + ReplyMarkup: markup, + Media: media, + }, nil + case *tg.InputBotInlineMessageMediaContact: + if result.Type != "contact" { + return domain.BotInlineResult{}, resultTypeInvalidErr() + } + if inlineResultHasWebPreview(result) { + return domain.BotInlineResult{}, webDocumentInvalidErr() + } + media, markup, err := domainInlineContactMessage(msg) + if err != nil { + return domain.BotInlineResult{}, err + } + return domain.BotInlineResult{ + ID: result.ID, + Type: result.Type, + Title: result.Title, + Description: result.Description, + ReplyMarkup: markup, + Media: media, + }, nil + default: + return domain.BotInlineResult{}, sendMessageTypeInvalidErr() + } + case *tg.InputBotInlineResultPhoto: + if result.ID == "" { + return domain.BotInlineResult{}, resultIDEmptyErr() + } + if len(result.ID) > domain.MaxBotInlineResultIDLen { + return domain.BotInlineResult{}, resultIDInvalidErr() + } + if result.Type != "photo" { + return domain.BotInlineResult{}, resultTypeInvalidErr() + } + media, err := r.domainInlinePhotoMedia(ctx, result.Photo) + if err != nil { + return domain.BotInlineResult{}, err + } + message, entities, markup, err := domainInlineMediaAutoMessage(botID, result.SendMessage) + if err != nil { + return domain.BotInlineResult{}, err + } + return domain.BotInlineResult{ + ID: result.ID, + Type: result.Type, + Message: message, + Entities: entities, + ReplyMarkup: markup, + Media: media, + }, nil + case *tg.InputBotInlineResultDocument: + if result.ID == "" { + return domain.BotInlineResult{}, resultIDEmptyErr() + } + if len(result.ID) > domain.MaxBotInlineResultIDLen { + return domain.BotInlineResult{}, resultIDInvalidErr() + } + if !validInlineDocumentResultType(result.Type) { + return domain.BotInlineResult{}, resultTypeInvalidErr() + } + media, err := r.domainInlineDocumentMedia(ctx, result.Document) + if err != nil { + return domain.BotInlineResult{}, err + } + message, entities, markup, err := domainInlineMediaAutoMessage(botID, result.SendMessage) + if err != nil { + return domain.BotInlineResult{}, err + } + return domain.BotInlineResult{ + ID: result.ID, + Type: result.Type, + Title: result.Title, + Description: result.Description, + Message: message, + Entities: entities, + ReplyMarkup: markup, + Media: media, + }, nil + default: + return domain.BotInlineResult{}, resultTypeInvalidErr() + } +} + +func domainInlineResultWebPreview(result *tg.InputBotInlineResult) (string, *domain.BotInlineWebDocument, *domain.BotInlineWebDocument, error) { + var resultURL string + if v, ok := result.GetURL(); ok { + if err := validateInlineWebURL(v, false); err != nil { + return "", nil, nil, err + } + resultURL = v + } + var thumb *domain.BotInlineWebDocument + if raw, ok := result.GetThumb(); ok { + var err error + thumb, err = domainInlineWebDocument(raw) + if err != nil { + return "", nil, nil, err + } + } + var content *domain.BotInlineWebDocument + if raw, ok := result.GetContent(); ok { + var err error + content, err = domainInlineWebDocument(raw) + if err != nil { + return "", nil, nil, err + } + } + return resultURL, thumb, content, nil +} + +func inlineResultHasWebPreview(result *tg.InputBotInlineResult) bool { + if _, ok := result.GetURL(); ok { + return true + } + if _, ok := result.GetThumb(); ok { + return true + } + if _, ok := result.GetContent(); ok { + return true + } + return false +} + +func domainInlineWebDocument(input tg.InputWebDocument) (*domain.BotInlineWebDocument, error) { + if err := validateInlineWebURL(input.URL, true); err != nil { + return nil, err + } + if input.Size <= 0 { + return nil, webDocumentInvalidErr() + } + if input.Size > domain.MaxBotInlineWebSize { + return nil, webDocumentSizeTooBigErr() + } + if !validInlineWebMime(input.MimeType) { + return nil, webDocumentMimeInvalidErr() + } + return &domain.BotInlineWebDocument{ + URL: input.URL, + AccessHash: randomNonZeroInt64(), + Size: input.Size, + MimeType: input.MimeType, + Attributes: domainDocumentAttributes(input.Attributes), + }, nil +} + +func validateInlineWebURL(raw string, emptyIsURLSpecific bool) error { + if raw == "" { + if emptyIsURLSpecific { + return webDocumentURLEmptyErr() + } + return webDocumentURLInvalidErr() + } + if len(raw) > domain.MaxBotInlineWebURLLen || strings.TrimSpace(raw) != raw { + return webDocumentURLInvalidErr() + } + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" || u.User != nil { + return webDocumentURLInvalidErr() + } + if strings.ToLower(u.Scheme) != "https" { + return webDocumentURLInvalidErr() + } + if strings.ContainsAny(u.Host, " \t\r\n") { + return webDocumentURLInvalidErr() + } + return nil +} + +func validInlineWebMime(in string) bool { + if in == "" || len(in) > domain.MaxBotInlineWebMimeLen || strings.TrimSpace(in) != in { + return false + } + slash := strings.IndexByte(in, '/') + if slash <= 0 || slash == len(in)-1 { + return false + } + for _, r := range in { + if r <= 32 || r == 127 { + return false + } + } + return true +} + +func domainInlineTextMessage(botID int64, msg *tg.InputBotInlineMessageText) (string, []domain.MessageEntity, *domain.MessageReplyMarkup, bool, error) { + if msg.Message == "" { + return "", nil, nil, false, messageEmptyErr() + } + if utf8.RuneCountInString(msg.Message) > maxSendMessageTextLength { + return "", nil, nil, false, messageTooLongErr() + } + if len(msg.Entities) > maxMessageEntityCount { + return "", nil, nil, false, entitiesTooLongErr() + } + markup, err := domainReplyMarkupForSender(msg.ReplyMarkup, true) + if err != nil { + return "", nil, nil, false, replyMarkupErr(err) + } + return msg.Message, domainMessageEntitiesForViewer(botID, msg.Entities), markup, msg.NoWebpage, nil +} + +func domainInlineMediaAutoMessage(botID int64, raw tg.InputBotInlineMessageClass) (string, []domain.MessageEntity, *domain.MessageReplyMarkup, error) { + msg, ok := raw.(*tg.InputBotInlineMessageMediaAuto) + if !ok { + return "", nil, nil, sendMessageTypeInvalidErr() + } + if utf8.RuneCountInString(msg.Message) > maxSendMessageTextLength { + return "", nil, nil, messageTooLongErr() + } + if len(msg.Entities) > maxMessageEntityCount { + return "", nil, nil, entitiesTooLongErr() + } + markup, err := domainReplyMarkupForSender(msg.ReplyMarkup, true) + if err != nil { + return "", nil, nil, replyMarkupErr(err) + } + return msg.Message, domainMessageEntitiesForViewer(botID, msg.Entities), markup, nil +} + +func domainInlineGeoMessage(msg *tg.InputBotInlineMessageMediaGeo) (*domain.MessageMedia, *domain.MessageReplyMarkup, error) { + if _, ok := msg.GetHeading(); ok { + return nil, nil, mediaInvalidErr() + } + if _, ok := msg.GetPeriod(); ok { + return nil, nil, mediaInvalidErr() + } + if _, ok := msg.GetProximityNotificationRadius(); ok { + return nil, nil, mediaInvalidErr() + } + geo, err := domainGeoPointFromInput(msg.GeoPoint) + if err != nil { + return nil, nil, err + } + markup, err := domainReplyMarkupForSender(msg.ReplyMarkup, true) + if err != nil { + return nil, nil, replyMarkupErr(err) + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: geo}, markup, nil +} + +func domainInlineVenueMessage(msg *tg.InputBotInlineMessageMediaVenue) (*domain.MessageMedia, *domain.MessageReplyMarkup, error) { + geo, err := domainGeoPointFromInput(msg.GeoPoint) + if err != nil { + return nil, nil, err + } + if strings.TrimSpace(msg.Title) == "" { + return nil, nil, mediaEmptyErr() + } + if utf8.RuneCountInString(msg.Title) > maxVenueTitleLength || + utf8.RuneCountInString(msg.Address) > maxVenueAddressLength || + utf8.RuneCountInString(msg.Provider) > maxVenueProviderLength || + utf8.RuneCountInString(msg.VenueID) > maxVenueIDLength || + utf8.RuneCountInString(msg.VenueType) > maxVenueIDLength { + return nil, nil, mediaInvalidErr() + } + markup, err := domainReplyMarkupForSender(msg.ReplyMarkup, true) + if err != nil { + return nil, nil, replyMarkupErr(err) + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{ + Geo: *geo, + Title: msg.Title, + Address: msg.Address, + Provider: msg.Provider, + VenueID: msg.VenueID, + VenueType: msg.VenueType, + }}, markup, nil +} + +func domainInlineContactMessage(msg *tg.InputBotInlineMessageMediaContact) (*domain.MessageMedia, *domain.MessageReplyMarkup, error) { + if !validContactInput(msg.PhoneNumber, msg.FirstName, msg.LastName, "", 0) || utf8.RuneCountInString(msg.Vcard) > maxContactVcardLength { + return nil, nil, mediaInvalidErr() + } + if strings.TrimSpace(msg.PhoneNumber) == "" && strings.TrimSpace(msg.FirstName) == "" && strings.TrimSpace(msg.LastName) == "" && strings.TrimSpace(msg.Vcard) == "" { + return nil, nil, mediaEmptyErr() + } + markup, err := domainReplyMarkupForSender(msg.ReplyMarkup, true) + if err != nil { + return nil, nil, replyMarkupErr(err) + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{ + PhoneNumber: msg.PhoneNumber, + FirstName: msg.FirstName, + LastName: msg.LastName, + Vcard: msg.Vcard, + }}, markup, nil +} + +func (r *Router) resolveInlineContactMediaForSender(ctx context.Context, userID int64, media *domain.MessageMedia) { + if media == nil || media.Kind != domain.MessageMediaKindContact || media.Contact == nil || media.Contact.UserID != 0 { + return + } + media.Contact.UserID = r.messageContactUserID(ctx, userID, media.Contact.PhoneNumber) +} + +func (r *Router) domainInlinePhotoMedia(ctx context.Context, input tg.InputPhotoClass) (*domain.MessageMedia, error) { + if r.deps.Files == nil { + return nil, mediaInvalidErr() + } + photoID, ok := inputPhotoID(input) + if !ok { + return nil, photoInvalidErr() + } + photo, found, err := r.deps.Files.GetPhoto(ctx, photoID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, photoInvalidErr() + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo}, nil +} + +func (r *Router) domainInlineDocumentMedia(ctx context.Context, input tg.InputDocumentClass) (*domain.MessageMedia, error) { + if r.deps.Files == nil { + return nil, mediaInvalidErr() + } + docIDs, ok := inputDocumentCandidateIDs(input) + if !ok { + return nil, mediaInvalidErr() + } + for _, docID := range docIDs { + doc, found, err := r.deps.Files.GetDocument(ctx, docID) + if err != nil { + return nil, internalErr() + } + if found { + return messageMediaFromDocument(doc, false, 0), nil + } + } + return nil, mediaInvalidErr() +} + +func validInlineDocumentResultType(t string) bool { + switch t { + case "file", "gif", "video", "audio", "voice", "sticker": + return true + default: + return false + } +} + +func validInlineExternalContentResultType(t string) bool { + return t == "photo" || validInlineDocumentResultType(t) +} + +func (r *Router) domainInlineExternalContentMedia(ctx context.Context, result domain.BotInlineResult) (*domain.MessageMedia, error) { + if r.deps.Files == nil || result.Content == nil { + return nil, mediaInvalidErr() + } + document, data, mime, err := r.registeredInlineWebDocumentBytes(ctx, result.Content.URL, result.Content.AccessHash) + if err != nil { + return nil, mediaInvalidErr() + } + document.MimeType = mime + document.Size = len(data) + if !inlineExternalContentMimeAllowed(result.Type, mime) { + return nil, mediaInvalidErr() + } + if result.Type == "photo" { + photo, err := r.deps.Files.CreatePhotoFromBytes(ctx, data) + if err != nil { + return nil, photoInvalidErr() + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo}, nil + } + spec := inlineExternalContentDocumentSpec(result.Type, document) + doc, err := r.deps.Files.CreateDocumentFromBytes(ctx, data, spec) + if err != nil { + return nil, documentInvalidErr() + } + return messageMediaFromDocument(doc, false, 0), nil +} + +func inlineExternalContentMimeAllowed(resultType, mime string) bool { + mime = strings.ToLower(strings.TrimSpace(mime)) + if !validInlineWebMime(mime) { + return false + } + switch resultType { + case "photo": + return strings.HasPrefix(mime, "image/") && mime != "image/gif" + case "gif": + return mime == "image/gif" || mime == "video/mp4" || mime == "image/webp" + case "video": + return strings.HasPrefix(mime, "video/") + case "audio", "voice": + return strings.HasPrefix(mime, "audio/") + case "sticker": + return strings.HasPrefix(mime, "image/") || strings.HasPrefix(mime, "application/x-tg") + case "file": + return true + default: + return false + } +} + +func inlineExternalContentDocumentSpec(resultType string, document domain.BotInlineWebDocument) domain.DocumentSpec { + mime := strings.ToLower(strings.TrimSpace(document.MimeType)) + attrs := append([]domain.DocumentAttribute(nil), document.Attributes...) + w, h := inlineExternalContentDimensions(attrs) + switch resultType { + case "gif": + attrs = ensureDocumentAttribute(attrs, domain.DocumentAttribute{Kind: domain.DocAttrAnimated}) + attrs = ensureImageSizeAttribute(attrs, w, h) + case "video": + attrs = ensureVideoAttribute(attrs, w, h) + case "audio": + attrs = ensureAudioAttribute(attrs, false) + case "voice": + attrs = ensureAudioAttribute(attrs, true) + case "sticker": + attrs = ensureImageSizeAttribute(attrs, w, h) + attrs = ensureDocumentAttribute(attrs, domain.DocumentAttribute{Kind: domain.DocAttrSticker}) + } + attrs = ensureFilenameAttribute(attrs, inlineExternalContentFilename(resultType, mime)) + return domain.DocumentSpec{MimeType: mime, Attributes: attrs} +} + +func inlineExternalContentDimensions(attrs []domain.DocumentAttribute) (int, int) { + for _, attr := range attrs { + if (attr.Kind == domain.DocAttrImageSize || attr.Kind == domain.DocAttrVideo) && attr.W > 0 && attr.H > 0 { + return attr.W, attr.H + } + } + return 0, 0 +} + +func ensureImageSizeAttribute(attrs []domain.DocumentAttribute, w, h int) []domain.DocumentAttribute { + if hasDocumentAttribute(attrs, domain.DocAttrImageSize) || w <= 0 || h <= 0 { + return attrs + } + return append(attrs, domain.DocumentAttribute{Kind: domain.DocAttrImageSize, W: w, H: h}) +} + +func ensureVideoAttribute(attrs []domain.DocumentAttribute, w, h int) []domain.DocumentAttribute { + if hasDocumentAttribute(attrs, domain.DocAttrVideo) { + return attrs + } + return append(attrs, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: w, H: h, SupportsStreaming: true}) +} + +func ensureAudioAttribute(attrs []domain.DocumentAttribute, voice bool) []domain.DocumentAttribute { + for i := range attrs { + if attrs[i].Kind == domain.DocAttrAudio { + if voice { + attrs[i].Voice = true + } + return attrs + } + } + return append(attrs, domain.DocumentAttribute{Kind: domain.DocAttrAudio, Voice: voice}) +} + +func ensureFilenameAttribute(attrs []domain.DocumentAttribute, name string) []domain.DocumentAttribute { + for i := range attrs { + if attrs[i].Kind == domain.DocAttrFilename { + if strings.TrimSpace(attrs[i].FileName) == "" { + attrs[i].FileName = name + } + return attrs + } + } + return append(attrs, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: name}) +} + +func ensureDocumentAttribute(attrs []domain.DocumentAttribute, attr domain.DocumentAttribute) []domain.DocumentAttribute { + if hasDocumentAttribute(attrs, attr.Kind) { + return attrs + } + return append(attrs, attr) +} + +func hasDocumentAttribute(attrs []domain.DocumentAttribute, kind domain.DocumentAttributeKind) bool { + for _, attr := range attrs { + if attr.Kind == kind { + return true + } + } + return false +} + +func inlineExternalContentFilename(resultType, mime string) string { + switch resultType { + case "gif": + if mime == "video/mp4" { + return "animation.mp4" + } + return "animation.gif" + case "video": + return "video.mp4" + case "audio": + return "audio.mp3" + case "voice": + return "audio.ogg" + case "sticker": + return "sticker.webp" + default: + if slash := strings.LastIndexByte(mime, '/'); slash >= 0 && slash < len(mime)-1 { + ext := mime[slash+1:] + if ext == "jpeg" { + ext = "jpg" + } + if validInlineFilenameExt(ext) { + return "file." + ext + } + } + return "file" + } +} + +func validInlineFilenameExt(ext string) bool { + if ext == "" || len(ext) > 16 { + return false + } + for _, r := range ext { + if r >= 'a' && r <= 'z' { + continue + } + if r >= '0' && r <= '9' { + continue + } + return false + } + return true +} + +func (r *Router) tgBotInlineResults(ctx context.Context, viewerUserID int64, in domain.BotInlineResults) *tg.MessagesBotResults { + out := &tg.MessagesBotResults{ + Gallery: in.Gallery, + QueryID: in.QueryID, + Results: make([]tg.BotInlineResultClass, 0, len(in.Results)), + CacheTime: in.CacheTime, + } + if in.NextOffset != "" { + out.SetNextOffset(in.NextOffset) + } + if in.SwitchPM != nil { + out.SetSwitchPm(tg.InlineBotSwitchPM{ + Text: in.SwitchPM.Text, + StartParam: in.SwitchPM.StartParam, + }) + } + if in.SwitchWeb != nil { + out.SetSwitchWebview(tg.InlineBotWebView{ + Text: in.SwitchWeb.Text, + URL: in.SwitchWeb.URL, + }) + } + for _, item := range in.Results { + out.Results = append(out.Results, tgBotInlineResult(item)) + } + if r.deps.Users != nil && in.BotUserID != 0 { + if u, found, err := r.deps.Users.ByID(ctx, viewerUserID, in.BotUserID); err == nil && found { + out.Users = append(out.Users, r.tgUser(u)) + } + } + return out +} + +func tgBotInlineResult(in domain.BotInlineResult) tg.BotInlineResultClass { + if in.Media != nil && !in.Media.IsZero() { + msg := tgBotInlineMediaMessage(in) + out := &tg.BotInlineResult{ + ID: in.ID, + Type: in.Type, + Title: in.Title, + Description: in.Description, + SendMessage: msg, + } + if in.Media.Kind == domain.MessageMediaKindGeo || + in.Media.Kind == domain.MessageMediaKindVenue || + in.Media.Kind == domain.MessageMediaKindContact { + return out + } + mediaMsg := &tg.BotInlineMessageMediaAuto{ + Message: in.Message, + Entities: tgMessageEntities(in.Entities), + } + if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil { + mediaMsg.SetReplyMarkup(markup) + } + mediaOut := &tg.BotInlineMediaResult{ + ID: in.ID, + Type: in.Type, + Title: in.Title, + Description: in.Description, + SendMessage: mediaMsg, + } + if in.Media.Kind == domain.MessageMediaKindPhoto && in.Media.Photo != nil { + mediaOut.SetPhoto(tgPhoto(*in.Media.Photo)) + } + if in.Media.Kind == domain.MessageMediaKindDocument && in.Media.Document != nil { + mediaOut.SetDocument(tgDocument(*in.Media.Document)) + } + return mediaOut + } + if in.MediaAuto { + msg := &tg.BotInlineMessageMediaAuto{ + Message: in.Message, + Entities: tgMessageEntities(in.Entities), + } + if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil { + msg.SetReplyMarkup(markup) + } + out := &tg.BotInlineResult{ + ID: in.ID, + Type: in.Type, + Title: in.Title, + Description: in.Description, + SendMessage: msg, + } + if in.URL != "" { + out.SetURL(in.URL) + } + if in.Thumb != nil { + out.SetThumb(tgInlineWebDocument(*in.Thumb)) + } + if in.Content != nil { + out.SetContent(tgInlineWebDocument(*in.Content)) + } + return out + } + msg := &tg.BotInlineMessageText{ + NoWebpage: in.NoWebpage, + Message: in.Message, + Entities: tgMessageEntities(in.Entities), + } + if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil { + msg.SetReplyMarkup(markup) + } + out := &tg.BotInlineResult{ + ID: in.ID, + Type: in.Type, + Title: in.Title, + Description: in.Description, + SendMessage: msg, + } + if in.URL != "" { + out.SetURL(in.URL) + } + if in.Thumb != nil { + out.SetThumb(tgInlineWebDocument(*in.Thumb)) + } + if in.Content != nil { + out.SetContent(tgInlineWebDocument(*in.Content)) + } + return out +} + +func tgInlineWebDocument(in domain.BotInlineWebDocument) tg.WebDocumentClass { + return &tg.WebDocument{ + URL: in.URL, + AccessHash: in.AccessHash, + Size: in.Size, + MimeType: in.MimeType, + Attributes: tgDocumentAttributes(in.Attributes), + } +} + +func tgBotInlineMediaMessage(in domain.BotInlineResult) tg.BotInlineMessageClass { + switch { + case in.Media.Kind == domain.MessageMediaKindGeo && in.Media.Geo != nil: + msg := &tg.BotInlineMessageMediaGeo{Geo: tgGeoPoint(*in.Media.Geo)} + if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil { + msg.SetReplyMarkup(markup) + } + return msg + case in.Media.Kind == domain.MessageMediaKindVenue && in.Media.Venue != nil: + msg := &tg.BotInlineMessageMediaVenue{ + Geo: tgGeoPoint(in.Media.Venue.Geo), + Title: in.Media.Venue.Title, + Address: in.Media.Venue.Address, + Provider: in.Media.Venue.Provider, + VenueID: in.Media.Venue.VenueID, + VenueType: in.Media.Venue.VenueType, + } + if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil { + msg.SetReplyMarkup(markup) + } + return msg + case in.Media.Kind == domain.MessageMediaKindContact && in.Media.Contact != nil: + msg := &tg.BotInlineMessageMediaContact{ + PhoneNumber: in.Media.Contact.PhoneNumber, + FirstName: in.Media.Contact.FirstName, + LastName: in.Media.Contact.LastName, + Vcard: in.Media.Contact.Vcard, + } + if markup := tgReplyMarkup(in.ReplyMarkup); markup != nil { + msg.SetReplyMarkup(markup) + } + return msg + default: + return &tg.BotInlineMessageMediaAuto{ + Message: in.Message, + Entities: tgMessageEntities(in.Entities), + } + } +} + +func tgInlineQueryPeerType(ctx context.Context, r *Router, userID, botID int64, peer domain.Peer) tg.InlineQueryPeerTypeClass { + switch peer.Type { + case domain.PeerTypeUser: + if peer.ID == botID { + return &tg.InlineQueryPeerTypeSameBotPM{} + } + if r.userIsBot(ctx, peer.ID) { + return &tg.InlineQueryPeerTypeBotPM{} + } + return &tg.InlineQueryPeerTypePM{} + case domain.PeerTypeChannel: + if r.deps.Channels != nil { + // 只读 Megagroup 标志:走轻量 ResolveChannel,省 dialog/读态/boost 查询。 + if view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID); err == nil && !view.Channel.Megagroup { + return &tg.InlineQueryPeerTypeBroadcast{} + } + } + return &tg.InlineQueryPeerTypeMegagroup{} + default: + return &tg.InlineQueryPeerTypePM{} + } +} diff --git a/internal/rpc/bots_inline_message_id.go b/internal/rpc/bots_inline_message_id.go new file mode 100644 index 00000000..d0899ebf --- /dev/null +++ b/internal/rpc/bots_inline_message_id.go @@ -0,0 +1,191 @@ +package rpc + +import ( + "context" + "crypto/sha256" + "encoding/binary" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func inlineMessageAccessHash(botID int64, msg domain.Message) int64 { + var buf [32]byte + binary.LittleEndian.PutUint64(buf[0:8], uint64(botID)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.OwnerUserID)) + binary.LittleEndian.PutUint64(buf[16:24], uint64(int64(msg.ID))) + binary.LittleEndian.PutUint64(buf[24:32], uint64(msg.UID)) + sum := sha256.Sum256(buf[:]) + v := int64(binary.LittleEndian.Uint64(sum[:8])) + if v == 0 { + return 1 + } + return v +} + +func inlineChannelMessageAccessHash(botID int64, msg domain.ChannelMessage) int64 { + var buf [40]byte + binary.LittleEndian.PutUint64(buf[0:8], uint64(botID)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.ChannelID)) + binary.LittleEndian.PutUint64(buf[16:24], uint64(int64(msg.ID))) + binary.LittleEndian.PutUint64(buf[24:32], uint64(msg.RandomID)) + binary.LittleEndian.PutUint64(buf[32:40], uint64(msg.SenderUserID)) + sum := sha256.Sum256(buf[:]) + v := int64(binary.LittleEndian.Uint64(sum[:8])) + if v == 0 { + return 1 + } + return v +} + +func (r *Router) inputInlineMessageIDForPrivateMessage(botID int64, msg domain.Message) tg.InputBotInlineMessageIDClass { + if botID == 0 || msg.OwnerUserID == 0 || msg.ID <= 0 || msg.UID == 0 || msg.ViaBotID != botID || msg.Peer.Type != domain.PeerTypeUser { + return nil + } + return &tg.InputBotInlineMessageID64{ + DCID: r.cfg.DC, + OwnerID: msg.OwnerUserID, + ID: msg.ID, + AccessHash: inlineMessageAccessHash(botID, msg), + } +} + +func (r *Router) inputInlineMessageIDForChannelMessage(botID int64, msg domain.ChannelMessage) tg.InputBotInlineMessageIDClass { + if botID == 0 || msg.ChannelID == 0 || msg.ID <= 0 || msg.RandomID == 0 || msg.SenderUserID == 0 || msg.ViaBotID != botID { + return nil + } + return &tg.InputBotInlineMessageID64{ + DCID: r.cfg.DC, + OwnerID: msg.ChannelID, + ID: msg.ID, + AccessHash: inlineChannelMessageAccessHash(botID, msg), + } +} + +func (r *Router) privateMessageFromInlineID(ctx context.Context, botID int64, id tg.InputBotInlineMessageIDClass) (domain.Message, bool, error) { + if err := validateInputBotInlineMessageID(id); err != nil { + return domain.Message{}, false, err + } + in, ok := id.(*tg.InputBotInlineMessageID64) + if !ok { + return domain.Message{}, false, nil + } + if in.DCID != 0 && in.DCID != r.cfg.DC { + return domain.Message{}, false, nil + } + msg, found, err := r.lookupOwnerMessage(ctx, in.OwnerID, in.ID) + if err != nil || !found { + return domain.Message{}, false, err + } + if msg.Peer.Type != domain.PeerTypeUser || msg.ViaBotID != botID { + return domain.Message{}, false, nil + } + if inlineMessageAccessHash(botID, msg) != in.AccessHash { + return domain.Message{}, false, nil + } + return msg, true, nil +} + +func (r *Router) channelMessageFromInlineID(ctx context.Context, botID int64, id tg.InputBotInlineMessageIDClass) (domain.Channel, domain.ChannelMessage, bool, error) { + if err := validateInputBotInlineMessageID(id); err != nil { + return domain.Channel{}, domain.ChannelMessage{}, false, err + } + in, ok := id.(*tg.InputBotInlineMessageID64) + if !ok { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + if in.DCID != 0 && in.DCID != r.cfg.DC { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + if in.OwnerID <= 0 || r.deps.Channels == nil { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + channel, msg, found, err := r.deps.Channels.GetInlineBotMessage(ctx, botID, in.OwnerID, in.ID) + if err != nil || !found { + return domain.Channel{}, domain.ChannelMessage{}, false, err + } + if inlineChannelMessageAccessHash(botID, msg) != in.AccessHash { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + return channel, msg, true, nil +} + +func (r *Router) privateInlineMessageIDFromSendUpdates(ctx context.Context, botID, userID int64, updates tg.UpdatesClass) tg.InputBotInlineMessageIDClass { + box, ok := updates.(*tg.Updates) + if !ok { + return nil + } + for _, update := range box.Updates { + newMessage, ok := update.(*tg.UpdateNewMessage) + if !ok { + continue + } + msg, ok := newMessage.Message.(*tg.Message) + if !ok || msg.ID <= 0 { + continue + } + domainMsg, found, err := r.lookupOwnerMessage(ctx, userID, msg.ID) + if err != nil || !found { + continue + } + if id := r.inputInlineMessageIDForPrivateMessage(botID, domainMsg); id != nil { + return id + } + } + return nil +} + +func (r *Router) channelInlineMessageIDFromSendUpdates(ctx context.Context, botID, userID int64, updates tg.UpdatesClass) tg.InputBotInlineMessageIDClass { + box, ok := updates.(*tg.Updates) + if !ok || r.deps.Channels == nil { + return nil + } + for _, update := range box.Updates { + newMessage, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + msg, ok := newMessage.Message.(*tg.Message) + if !ok || msg.ID <= 0 { + continue + } + peer, ok := msg.PeerID.(*tg.PeerChannel) + if !ok || peer.ChannelID == 0 { + continue + } + history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ChannelID, []int{msg.ID}) + if err != nil || len(history.Messages) != 1 { + continue + } + if id := r.inputInlineMessageIDForChannelMessage(botID, history.Messages[0]); id != nil { + return id + } + } + return nil +} + +func (r *Router) pushInlineBotSendFeedback(ctx context.Context, userID int64, results domain.BotInlineResults, result domain.BotInlineResult, updates tg.UpdatesClass) { + if results.BotUserID == 0 || result.ID == "" { + return + } + update := &tg.UpdateBotInlineSend{ + UserID: userID, + Query: results.Query, + ID: result.ID, + } + if results.Geo != nil { + update.SetGeo(tgGeoPoint(*results.Geo)) + } + if result.ReplyMarkup != nil && !result.ReplyMarkup.IsZero() { + if msgID := r.privateInlineMessageIDFromSendUpdates(ctx, results.BotUserID, userID, updates); msgID != nil { + update.SetMsgID(msgID) + } else if msgID := r.channelInlineMessageIDFromSendUpdates(ctx, results.BotUserID, userID, updates); msgID != nil { + update.SetMsgID(msgID) + } + } + r.pushUserMessage(ctx, results.BotUserID, "push bot inline send", &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: int(r.clock.Now().Unix()), + }) +} diff --git a/internal/rpc/bots_inline_rpc_test.go b/internal/rpc/bots_inline_rpc_test.go new file mode 100644 index 00000000..efba7077 --- /dev/null +++ b/internal/rpc/bots_inline_rpc_test.go @@ -0,0 +1,2346 @@ +package rpc + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + botsapp "telesrv/internal/app/bots" + appchannels "telesrv/internal/app/channels" + appmessages "telesrv/internal/app/messages" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +type inlineBotRPCTestFixture struct { + router *Router + bots *botsapp.Service + channels *appchannels.Service + files *fakeFiles + owner domain.User + peer domain.User + bot domain.User + photo domain.Photo + document domain.Document +} + +func newInlineBotRPCTestFixture(t *testing.T) inlineBotRPCTestFixture { + t.Helper() + ctx := context.Background() + users := memory.NewUserStore() + botStore := memory.NewBotStore(users) + dialogs := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogs) + bots := botsapp.NewService(users, botStore, messageStore) + messages := appmessages.NewService(messageStore, dialogs) + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots)) + files := &fakeFiles{ + docs: map[int64]domain.Document{}, + photos: map[int64]domain.Photo{}, + } + photo := files.putPhoto(domain.Photo{ + ID: 8101, + AccessHash: 810101, + DCID: 2, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 640, H: 480, Size: 1234}}, + }) + document := domain.Document{ + ID: 8201, + AccessHash: 820101, + DCID: 2, + MimeType: "application/pdf", + Size: 4567, + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "inline.pdf"}}, + Thumbs: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "m", W: 160, H: 120, Size: 321}}, + } + files.docs[document.ID] = document + owner, err := users.Create(ctx, domain.User{AccessHash: 7101, Phone: "15550007101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + peer, err := users.Create(ctx, domain.User{AccessHash: 7102, Phone: "15550007102", FirstName: "Peer"}) + if err != nil { + t.Fatalf("create peer: %v", err) + } + bot, _, err := bots.CreateBot(ctx, owner.ID, "Inline Bot", "inline_shape_bot") + if err != nil { + t.Fatalf("create bot: %v", err) + } + if _, err := bots.SetInlinePlaceholder(ctx, bot.ID, "Search inline"); err != nil { + t.Fatalf("set inline placeholder: %v", err) + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(users), + Bots: bots, + Messages: messages, + Channels: channels, + Files: files, + }, zaptest.NewLogger(t), clock.System) + return inlineBotRPCTestFixture{router: r, bots: bots, channels: channels, files: files, owner: owner, peer: peer, bot: bot, photo: photo, document: document} +} + +func TestInlineBotArticleTextPrivateRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + type getResult struct { + res *tg.MessagesBotResults + err error + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "hello", + }) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + &tg.InputBotInlineResult{ + ID: "article-1", + Type: "article", + Title: "Echo", + Description: "Echo text", + SendMessage: &tg.InputBotInlineMessageText{Message: "inline hello"}, + }, + }, + }); err != nil || !ok { + t.Fatalf("set inline results = %v,%v, want true,nil", ok, err) + } + + var got getResult + select { + case got = <-gotCh: + case <-time.After(2 * time.Second): + t.Fatal("get inline results did not resolve") + } + if got.err != nil { + t.Fatalf("get inline results: %v", got.err) + } + if got.res.QueryID != queryID || len(got.res.Results) != 1 { + t.Fatalf("inline results = query %d len %d, want query %d len 1", got.res.QueryID, len(got.res.Results), queryID) + } + if len(got.res.Users) != 1 { + t.Fatalf("inline result users = %d, want bot user", len(got.res.Users)) + } + botUser, ok := got.res.Users[0].(*tg.User) + if !ok || botUser.ID != f.bot.ID || !botUser.Bot { + t.Fatalf("inline result user = %#v, want bot user %d", got.res.Users[0], f.bot.ID) + } + if placeholder, ok := botUser.GetBotInlinePlaceholder(); !ok || placeholder != "Search inline" { + t.Fatalf("bot inline placeholder = %q,%v, want Search inline,true", placeholder, ok) + } + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 9901, + QueryID: queryID, + ID: "article-1", + }) + if err != nil { + t.Fatalf("send inline bot result: %v", err) + } + sent := messageFromUpdates(t, updatesClass) + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + if sent.Message != "inline hello" { + t.Fatalf("sent message = %q, want inline hello", sent.Message) + } + + historyList, err := f.router.deps.Messages.GetHistory(ownerCtx, f.owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get history: %v", err) + } + history := tgMessagesMessages(f.owner.ID, f.router.enrichMessageList(ownerCtx, f.owner.ID, historyList)) + historyMessages, ok := history.(*tg.MessagesMessages) + if !ok || len(historyMessages.Messages) != 1 { + t.Fatalf("history = %T len %d, want messagesMessages len 1", history, len(messagesFromClass(history))) + } + histMsg, ok := historyMessages.Messages[0].(*tg.Message) + if !ok { + t.Fatalf("history message = %T, want *tg.Message", historyMessages.Messages[0]) + } + if via, ok := histMsg.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("history via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + foundBot := false + for _, u := range historyMessages.Users { + if user, ok := u.(*tg.User); ok && user.ID == f.bot.ID && user.Bot { + foundBot = true + } + } + if !foundBot { + t.Fatalf("history users missing via bot: %+v", historyMessages.Users) + } +} + +func TestInlineBotSendKeepsQueryPeerBinding(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + queryID, _ := f.router.inlines.register(f.router.clock.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + Results: []tg.InputBotInlineResultClass{ + inlineArticleResult("article-locked", "locked"), + }, + }); err != nil || !ok { + t.Fatalf("set inline results = %v,%v, want true,nil", ok, err) + } + if _, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.owner), + RandomID: 9902, + QueryID: queryID, + ID: "article-locked", + }); !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("send inline to different peer err = %v, want PEER_ID_INVALID", err) + } +} + +func TestInlineBotArticleTextExternalThumbRoundTripAndCache(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + type getResult struct { + res *tg.MessagesBotResults + err error + } + req := &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "preview", + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + inlineArticleResultWithWebThumb("article-web", "preview text"), + }, + }); err != nil || !ok { + t.Fatalf("set inline results = %v,%v, want true,nil", ok, err) + } + + var got getResult + select { + case got = <-gotCh: + case <-time.After(2 * time.Second): + t.Fatal("get inline results did not resolve") + } + if got.err != nil { + t.Fatalf("get inline results: %v", got.err) + } + assertInlineArticleWebPreview(t, got.res, queryID) + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 9911, + QueryID: queryID, + ID: "article-web", + }) + if err != nil { + t.Fatalf("send inline bot result: %v", err) + } + sent := messageFromUpdates(t, updatesClass) + if sent.Message != "preview text" { + t.Fatalf("sent message = %q, want preview text", sent.Message) + } + if sent.Media != nil { + t.Fatalf("sent media = %T, want nil for text article", sent.Media) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + + cached, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req) + if err != nil { + t.Fatalf("cached get inline results: %v", err) + } + if cached.QueryID == queryID { + t.Fatalf("cached query_id reused %d, want fresh id", cached.QueryID) + } + assertInlineArticleWebPreview(t, cached, cached.QueryID) + if f.router.inlines.unansweredSize() != 0 { + t.Fatalf("cache hit left unanswered pending queries = %d, want 0", f.router.inlines.unansweredSize()) + } +} + +func TestInlineBotArticleTextExternalContentWebFileDownload(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + oldFetch := fetchInlineWebFile + fetches := 0 + fetchInlineWebFile = func(_ context.Context, document domain.BotInlineWebDocument) ([]byte, string, error) { + fetches++ + if document.URL != "https://example.test/content.png" || document.AccessHash == 0 { + t.Fatalf("fetch document = %+v", document) + } + return []byte("abcdef"), "image/png; charset=binary", nil + } + defer func() { fetchInlineWebFile = oldFetch }() + + type getResult struct { + res *tg.MessagesBotResults + err error + } + req := &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "content", + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + inlineArticleResultWithWebContent("article-content", "content text"), + }, + }); err != nil || !ok { + t.Fatalf("set inline results = %v,%v, want true,nil", ok, err) + } + + var got getResult + select { + case got = <-gotCh: + case <-time.After(2 * time.Second): + t.Fatal("get inline results did not resolve") + } + if got.err != nil { + t.Fatalf("get inline results: %v", got.err) + } + result, ok := got.res.Results[0].(*tg.BotInlineResult) + if !ok { + t.Fatalf("inline result = %T, want *tg.BotInlineResult", got.res.Results[0]) + } + rawContent, ok := result.GetContent() + if !ok { + t.Fatal("inline result missing content") + } + content, ok := rawContent.(*tg.WebDocument) + if !ok { + t.Fatalf("inline content = %T, want *tg.WebDocument", rawContent) + } + if content.URL != "https://example.test/content.png" || content.AccessHash == 0 || content.MimeType != "image/png" { + t.Fatalf("content = %+v", content) + } + file, err := f.router.onUploadGetWebFile(ownerCtx, &tg.UploadGetWebFileRequest{ + Location: &tg.InputWebFileLocation{URL: content.URL, AccessHash: content.AccessHash}, + Offset: 2, + Limit: 3, + }) + if err != nil { + t.Fatalf("upload.getWebFile content: %v", err) + } + if string(file.Bytes) != "cde" || file.Size != 6 || file.MimeType != "image/png" { + t.Fatalf("webfile = size %d mime %q bytes %q", file.Size, file.MimeType, file.Bytes) + } + file, err = f.router.onUploadGetWebFile(ownerCtx, &tg.UploadGetWebFileRequest{ + Location: &tg.InputWebFileLocation{URL: content.URL, AccessHash: content.AccessHash}, + Offset: 0, + Limit: 2, + }) + if err != nil { + t.Fatalf("upload.getWebFile cached content: %v", err) + } + if string(file.Bytes) != "ab" || fetches != 1 { + t.Fatalf("cached webfile bytes=%q fetches=%d, want ab and one fetch", file.Bytes, fetches) + } + if _, err := f.router.onUploadGetWebFile(ownerCtx, &tg.UploadGetWebFileRequest{ + Location: &tg.InputWebFileLocation{URL: content.URL, AccessHash: content.AccessHash + 1}, + Offset: 0, + Limit: 2, + }); err == nil || !strings.Contains(err.Error(), "LOCATION_INVALID") { + t.Fatalf("bad access_hash err = %v, want LOCATION_INVALID", err) + } + if _, err := f.router.onUploadGetWebFile(ownerCtx, &tg.UploadGetWebFileRequest{ + Location: &tg.InputWebFileLocation{URL: "https://example.test/other.png", AccessHash: content.AccessHash}, + Offset: 0, + Limit: 2, + }); err == nil || !strings.Contains(err.Error(), "LOCATION_INVALID") { + t.Fatalf("bad url err = %v, want LOCATION_INVALID", err) + } +} + +func TestInlineBotExternalPhotoContentMediaAutoRoundTripAndCache(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + oldFetch := fetchInlineWebFile + fetches := 0 + body := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4} + fetchInlineWebFile = func(_ context.Context, document domain.BotInlineWebDocument) ([]byte, string, error) { + fetches++ + if document.URL != "https://example.test/photo.png" || document.AccessHash == 0 { + t.Fatalf("fetch document = %+v", document) + } + return append([]byte(nil), body...), "image/png; charset=binary", nil + } + defer func() { fetchInlineWebFile = oldFetch }() + + type getResult struct { + res *tg.MessagesBotResults + err error + } + req := &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "external-photo", + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + inlineExternalPhotoContentResult("external-photo", "external photo caption"), + }, + }); err != nil || !ok { + t.Fatalf("set external photo result = %v,%v, want true,nil", ok, err) + } + + var got getResult + select { + case got = <-gotCh: + case <-time.After(2 * time.Second): + t.Fatal("external photo get inline results did not resolve") + } + if got.err != nil { + t.Fatalf("get external photo results: %v", got.err) + } + result, ok := got.res.Results[0].(*tg.BotInlineResult) + if !ok { + t.Fatalf("external photo result = %T, want *tg.BotInlineResult", got.res.Results[0]) + } + if _, ok := result.SendMessage.(*tg.BotInlineMessageMediaAuto); !ok { + t.Fatalf("external photo send_message = %T, want mediaAuto", result.SendMessage) + } + if content, ok := result.GetContent(); !ok { + t.Fatal("external photo result missing content") + } else if doc, ok := content.(*tg.WebDocument); !ok || doc.URL != "https://example.test/photo.png" || doc.AccessHash == 0 { + t.Fatalf("external photo content = %#v", content) + } + + send := func(queryID int64, randomID int64) *tg.Message { + t.Helper() + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: randomID, + QueryID: queryID, + ID: "external-photo", + }) + if err != nil { + t.Fatalf("send external photo result: %v", err) + } + sent := messageFromUpdates(t, updatesClass) + if sent.Message != "external photo caption" { + t.Fatalf("sent external photo caption = %q", sent.Message) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent external photo via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + if media, ok := sent.Media.(*tg.MessageMediaPhoto); !ok { + t.Fatalf("sent external media = %T, want *tg.MessageMediaPhoto", sent.Media) + } else if photo, ok := media.Photo.(*tg.Photo); !ok || photo.ID == 0 || photo.ID == f.photo.ID { + t.Fatalf("sent external photo = %#v, want generated photo", media.Photo) + } + return sent + } + send(queryID, 9921) + + cached, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req) + if err != nil { + t.Fatalf("cached external photo get: %v", err) + } + if cached.QueryID == queryID { + t.Fatalf("cached external photo query_id reused %d", queryID) + } + send(cached.QueryID, 9922) + if fetches != 1 { + t.Fatalf("external photo fetches = %d, want one fetch reused by cached send", fetches) + } + + historyList, err := f.router.deps.Messages.GetHistory(ownerCtx, f.owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("external photo history: %v", err) + } + history := tgMessagesMessages(f.owner.ID, f.router.enrichMessageList(ownerCtx, f.owner.ID, historyList)) + messages := messagesFromClass(history) + if len(messages) < 2 { + t.Fatalf("external photo history len = %d, want at least two messages", len(messages)) + } + histMsg, ok := messages[0].(*tg.Message) + if !ok { + t.Fatalf("external photo history message = %T, want *tg.Message", messages[0]) + } + if _, ok := histMsg.Media.(*tg.MessageMediaPhoto); !ok { + t.Fatalf("external photo history media = %T, want *tg.MessageMediaPhoto", histMsg.Media) + } + if via, ok := histMsg.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("external photo history via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } +} + +func TestInlineBotExternalContentSendFetchFailureDoesNotSend(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + oldFetch := fetchInlineWebFile + fetchInlineWebFile = func(context.Context, domain.BotInlineWebDocument) ([]byte, string, error) { + return nil, "", errors.New("fetch failed") + } + defer func() { fetchInlineWebFile = oldFetch }() + + queryID, _ := f.router.inlines.register(f.router.clock.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + Results: []tg.InputBotInlineResultClass{ + inlineExternalPhotoContentResult("external-photo", "caption"), + }, + }); err != nil || !ok { + t.Fatalf("set external photo result = %v,%v, want true,nil", ok, err) + } + + if _, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 9923, + QueryID: queryID, + ID: "external-photo", + }); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("send failed external photo err = %v, want MEDIA_INVALID", err) + } + historyList, err := f.router.deps.Messages.GetHistory(ownerCtx, f.owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("failed external photo history: %v", err) + } + if len(historyList.Messages) != 0 { + t.Fatalf("failed external photo sent %d messages, want none", len(historyList.Messages)) + } +} + +func TestInlineBotArticleTextExternalThumbValidation(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + queryID, _ := f.router.inlines.register(f.router.clock.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}) + defer f.router.inlines.consume(queryID) + botCtx := WithUserID(ctx, f.bot.ID) + + validThumb := func() tg.InputWebDocument { + return tg.InputWebDocument{ + URL: "https://example.test/thumb.jpg", + Size: 1234, + MimeType: "image/jpeg", + Attributes: []tg.DocumentAttributeClass{ + &tg.DocumentAttributeImageSize{W: 96, H: 96}, + }, + } + } + cases := []struct { + name string + result tg.InputBotInlineResultClass + want string + }{ + { + name: "empty thumb url", + result: func() tg.InputBotInlineResultClass { + result := inlineArticleResultWithWebThumb("bad-empty-url", "text") + thumb := validThumb() + thumb.URL = "" + result.SetThumb(thumb) + return result + }(), + want: "WEBDOCUMENT_URL_EMPTY", + }, + { + name: "http thumb url", + result: func() tg.InputBotInlineResultClass { + result := inlineArticleResultWithWebThumb("bad-http-url", "text") + thumb := validThumb() + thumb.URL = "http://example.test/thumb.jpg" + result.SetThumb(thumb) + return result + }(), + want: "WEBDOCUMENT_URL_INVALID", + }, + { + name: "bad mime", + result: func() tg.InputBotInlineResultClass { + result := inlineArticleResultWithWebThumb("bad-mime", "text") + thumb := validThumb() + thumb.MimeType = "image" + result.SetThumb(thumb) + return result + }(), + want: "WEBDOCUMENT_MIME_INVALID", + }, + { + name: "too big", + result: func() tg.InputBotInlineResultClass { + result := inlineArticleResultWithWebThumb("too-big", "text") + thumb := validThumb() + thumb.Size = domain.MaxBotInlineWebSize + 1 + result.SetThumb(thumb) + return result + }(), + want: "WEBDOCUMENT_SIZE_TOO_BIG", + }, + { + name: "content too big", + result: func() tg.InputBotInlineResultClass { + result := inlineArticleResultWithWebThumb("content-too-big", "text") + result.SetContent(tg.InputWebDocument{ + URL: "https://example.test/content.png", + Size: domain.MaxBotInlineWebSize + 1, + MimeType: "image/png", + }) + return result + }(), + want: "WEBDOCUMENT_SIZE_TOO_BIG", + }, + { + name: "content bad mime", + result: func() tg.InputBotInlineResultClass { + result := inlineArticleResultWithWebThumb("content-bad-mime", "text") + result.SetContent(tg.InputWebDocument{ + URL: "https://example.test/content.png", + Size: 512, + MimeType: "image", + }) + return result + }(), + want: "WEBDOCUMENT_MIME_INVALID", + }, + { + name: "media auto content mime mismatch", + result: func() tg.InputBotInlineResultClass { + result := inlineArticleResultWithWebThumb("content", "text") + result.Type = "photo" + result.SendMessage = &tg.InputBotInlineMessageMediaAuto{} + result.SetContent(tg.InputWebDocument{ + URL: "https://example.test/content.html", + Size: 512, + MimeType: "text/html", + }) + return result + }(), + want: "MEDIA_INVALID", + }, + { + name: "geo thumb unsupported", + result: func() tg.InputBotInlineResultClass { + result := inlineGeoResult("geo-thumb", 39.9, 116.3).(*tg.InputBotInlineResult) + result.SetThumb(validThumb()) + return result + }(), + want: "WEBDOCUMENT_INVALID", + }, + { + name: "result url invalid", + result: func() tg.InputBotInlineResultClass { + result := inlineArticleResultWithWebThumb("bad-result-url", "text") + result.SetURL("ftp://example.test/result") + return result + }(), + want: "WEBDOCUMENT_URL_INVALID", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + Results: []tg.InputBotInlineResultClass{ + tc.result, + }, + }); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("set inline results err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestInlineBotArticleTextChannelRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + sessions := &captureSessions{} + f.router.deps.Sessions = sessions + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + created, err := f.channels.CreateMegagroupFromCreateChat(ctx, f.owner.ID, domain.CreateChannelRequest{ + Title: "Inline Group", + MemberUserIDs: []int64{f.peer.ID}, + Date: 1700001000, + }) + if err != nil { + t.Fatalf("create inline group: %v", err) + } + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + type getResult struct { + res *tg.MessagesBotResults + err error + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: peer, + Query: "group", + }) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + inlineArticleResultWithCallback("group-article", "inline group hello", "Open", []byte{0x00, 0xff, 0x42}), + }, + }); err != nil || !ok { + t.Fatalf("set channel inline results = %v,%v, want true,nil", ok, err) + } + + select { + case got := <-gotCh: + if got.err != nil { + t.Fatalf("get channel inline results: %v", got.err) + } + if got.res.QueryID != queryID || len(got.res.Results) != 1 { + t.Fatalf("channel inline results = query %d len %d, want query %d len 1", got.res.QueryID, len(got.res.Results), queryID) + } + case <-time.After(2 * time.Second): + t.Fatal("channel get inline results did not resolve") + } + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: peer, + RandomID: 9902, + QueryID: queryID, + ID: "group-article", + }) + if err != nil { + t.Fatalf("send channel inline bot result: %v", err) + } + sent, full := channelMessageFromUpdates(t, updatesClass) + if sent.Message != "inline group hello" { + t.Fatalf("channel inline message = %q, want inline group hello", sent.Message) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("channel send via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + assertTGInlineReplyMarkup(t, sent, "Open", []byte{0x00, 0xff, 0x42}) + assertUpdatesContainBotUser(t, full, f.bot.ID) + + historyList, err := f.router.deps.Channels.GetHistory(ownerCtx, f.owner.ID, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel history: %v", err) + } + history := tgChannelHistoryMessages(f.owner.ID, f.router.enrichChannelHistory(ownerCtx, f.owner.ID, historyList)) + channelHistory, ok := history.(*tg.MessagesChannelMessages) + if !ok || len(channelHistory.Messages) == 0 { + t.Fatalf("channel history = %T len %d, want channel messages", history, len(channelMessagesFromClass(history))) + } + histMsg, ok := channelHistory.Messages[0].(*tg.Message) + if !ok { + t.Fatalf("channel history message = %T, want *tg.Message", channelHistory.Messages[0]) + } + if via, ok := histMsg.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("channel history via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + assertTGInlineReplyMarkup(t, histMsg, "Open", []byte{0x00, 0xff, 0x42}) + assertChannelMessagesContainBotUser(t, channelHistory, f.bot.ID) + + diff, err := f.router.deps.Channels.GetDifference(ownerCtx, f.owner.ID, domain.ChannelDifferenceRequest{ + UserID: f.owner.ID, + ChannelID: created.Channel.ID, + Pts: created.Channel.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel difference: %v", err) + } + if len(diff.NewMessages) != 1 { + t.Fatalf("channel difference messages = %+v, want one new message", diff.NewMessages) + } + assertDomainInlineReplyMarkup(t, diff.NewMessages[0].ReplyMarkup, "Open", []byte{0x00, 0xff, 0x42}) + + feedback := inlineSendFeedbackFromSessions(t, sessions, f.bot.ID) + msgID, ok := feedback.GetMsgID() + if !ok { + t.Fatal("channel inline send feedback missing msg_id") + } + msgID64, ok := msgID.(*tg.InputBotInlineMessageID64) + if !ok || msgID64.OwnerID != created.Channel.ID || msgID64.ID != sent.ID { + t.Fatalf("channel inline msg_id = %#v, want id64 owner channel %d msg %d", msgID, created.Channel.ID, sent.ID) + } + badID := *msgID64 + badID.AccessHash++ + badEdit := &tg.MessagesEditInlineBotMessageRequest{ID: &badID} + badEdit.SetMessage("bad edit") + if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, badEdit); ok || !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("bad channel inline edit = %v,%v, want false,MESSAGE_ID_INVALID", ok, err) + } + editReq := &tg.MessagesEditInlineBotMessageRequest{ID: msgID} + editReq.SetMessage("inline group edited") + editReq.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{ + Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "Done", Data: []byte("v2")}}, + }}}) + if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, editReq); err != nil || !ok { + t.Fatalf("channel inline edit = %v,%v, want true,nil", ok, err) + } + editedHistory, err := f.router.deps.Channels.GetHistory(ownerCtx, f.owner.ID, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + Limit: 1, + }) + if err != nil || len(editedHistory.Messages) != 1 { + t.Fatalf("edited channel history len = %d err=%v, want 1,nil", len(editedHistory.Messages), err) + } + if editedHistory.Messages[0].Body != "inline group edited" || editedHistory.Messages[0].ViaBotID != f.bot.ID { + t.Fatalf("edited channel message = %+v, want edited via bot", editedHistory.Messages[0]) + } + assertDomainInlineReplyMarkup(t, editedHistory.Messages[0].ReplyMarkup, "Done", []byte("v2")) + editDiff, err := f.router.deps.Channels.GetDifference(ownerCtx, f.owner.ID, domain.ChannelDifferenceRequest{ + UserID: f.owner.ID, + ChannelID: created.Channel.ID, + Pts: diff.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel edit difference: %v", err) + } + if len(editDiff.OtherUpdates) != 1 || editDiff.OtherUpdates[0].Type != domain.ChannelUpdateEditMessage { + t.Fatalf("channel edit difference updates = %+v, want one edit", editDiff.OtherUpdates) + } + assertDomainInlineReplyMarkup(t, editDiff.OtherUpdates[0].Message.ReplyMarkup, "Done", []byte("v2")) +} + +func TestInlineBotChannelInlineEditRewritesMedia(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + sessions := &captureSessions{} + f.router.deps.Sessions = sessions + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + created, err := f.channels.CreateMegagroupFromCreateChat(ctx, f.owner.ID, domain.CreateChannelRequest{ + Title: "Inline Media Edit Group", + MemberUserIDs: []int64{f.peer.ID}, + Date: 1700002300, + }) + if err != nil { + t.Fatalf("create inline media edit group: %v", err) + } + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + queryID, _ := f.router.inlines.registerWithCacheKey(f.router.clock.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}, inlineCacheKey{query: "channel-media-edit"}) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + Results: []tg.InputBotInlineResultClass{ + inlineArticleResultWithCallback("channel-media-edit-1", "channel caption before media edit", "Open", []byte("channel-media-v1")), + }, + }); err != nil || !ok { + t.Fatalf("set channel media edit inline result = %v,%v, want true,nil", ok, err) + } + if _, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: peer, + RandomID: 9907, + QueryID: queryID, + ID: "channel-media-edit-1", + }); err != nil { + t.Fatalf("send channel media edit inline result: %v", err) + } + msgID, ok := inlineSendFeedbackFromSessions(t, sessions, f.bot.ID).GetMsgID() + if !ok { + t.Fatal("channel inline media edit feedback missing msg_id") + } + + editReq := &tg.MessagesEditInlineBotMessageRequest{ID: msgID} + editReq.SetMedia(&tg.InputMediaGeoPoint{GeoPoint: &tg.InputGeoPoint{Lat: 40.7128, Long: -74.0060}}) + if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, editReq); err != nil || !ok { + t.Fatalf("channel inline media edit = %v,%v, want true,nil", ok, err) + } + + history, err := f.router.deps.Channels.GetHistory(ownerCtx, f.owner.ID, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + Limit: 1, + }) + if err != nil || len(history.Messages) != 1 { + t.Fatalf("channel media edit history len = %d err=%v, want 1,nil", len(history.Messages), err) + } + msg := history.Messages[0] + if msg.Body != "channel caption before media edit" || msg.ViaBotID != f.bot.ID { + t.Fatalf("channel media edited message = %+v, want original caption via bot", msg) + } + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindGeo || msg.Media.Geo == nil { + t.Fatalf("channel media edited media = %+v, want geo", msg.Media) + } + assertDomainInlineReplyMarkup(t, msg.ReplyMarkup, "Open", []byte("channel-media-v1")) + + diff, err := f.router.deps.Channels.GetDifference(ownerCtx, f.owner.ID, domain.ChannelDifferenceRequest{ + UserID: f.owner.ID, + ChannelID: created.Channel.ID, + Pts: created.Channel.Pts + 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel media edit difference: %v", err) + } + if len(diff.OtherUpdates) != 1 || diff.OtherUpdates[0].Type != domain.ChannelUpdateEditMessage { + t.Fatalf("channel media edit difference updates = %+v, want one edit", diff.OtherUpdates) + } + if diff.OtherUpdates[0].Message.Media == nil || diff.OtherUpdates[0].Message.Media.Kind != domain.MessageMediaKindGeo { + t.Fatalf("channel media edit difference message = %+v, want geo media", diff.OtherUpdates[0].Message) + } + assertDomainInlineReplyMarkup(t, diff.OtherUpdates[0].Message.ReplyMarkup, "Open", []byte("channel-media-v1")) +} + +func TestInlineBotPhotoMediaPrivateRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + type getResult struct { + res *tg.MessagesBotResults + err error + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "photo", + }) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + inlinePhotoResult("photo-1", f.photo, "inline photo caption"), + }, + }); err != nil || !ok { + t.Fatalf("set inline photo result = %v,%v, want true,nil", ok, err) + } + + var got getResult + select { + case got = <-gotCh: + case <-time.After(2 * time.Second): + t.Fatal("photo get inline results did not resolve") + } + if got.err != nil { + t.Fatalf("get inline photo results: %v", got.err) + } + mediaResult, ok := got.res.Results[0].(*tg.BotInlineMediaResult) + if !ok { + t.Fatalf("inline photo result = %T, want *tg.BotInlineMediaResult", got.res.Results[0]) + } + if photo, ok := mediaResult.Photo.(*tg.Photo); !ok || photo.ID != f.photo.ID { + t.Fatalf("inline result photo = %#v, want photo %d", mediaResult.Photo, f.photo.ID) + } + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 9903, + QueryID: queryID, + ID: "photo-1", + }) + if err != nil { + t.Fatalf("send inline photo result: %v", err) + } + sent := messageFromUpdates(t, updatesClass) + if sent.Message != "inline photo caption" { + t.Fatalf("sent photo caption = %q, want inline photo caption", sent.Message) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent photo via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + photoMedia, ok := sent.Media.(*tg.MessageMediaPhoto) + if !ok { + t.Fatalf("sent media = %T, want *tg.MessageMediaPhoto", sent.Media) + } + if photo, ok := photoMedia.Photo.(*tg.Photo); !ok || photo.ID != f.photo.ID { + t.Fatalf("sent media photo = %#v, want photo %d", photoMedia.Photo, f.photo.ID) + } +} + +func TestInlineBotDocumentMediaChannelRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + created, err := f.channels.CreateMegagroupFromCreateChat(ctx, f.owner.ID, domain.CreateChannelRequest{ + Title: "Inline Document Group", + MemberUserIDs: []int64{f.peer.ID}, + Date: 1700002000, + }) + if err != nil { + t.Fatalf("create inline document group: %v", err) + } + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + type getResult struct { + res *tg.MessagesBotResults + err error + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: peer, + Query: "document", + }) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + inlineDocumentResult("doc-1", f.document, "inline document caption"), + }, + }); err != nil || !ok { + t.Fatalf("set inline document result = %v,%v, want true,nil", ok, err) + } + + select { + case got := <-gotCh: + if got.err != nil { + t.Fatalf("get inline document results: %v", got.err) + } + mediaResult, ok := got.res.Results[0].(*tg.BotInlineMediaResult) + if !ok { + t.Fatalf("inline document result = %T, want *tg.BotInlineMediaResult", got.res.Results[0]) + } + if doc, ok := mediaResult.Document.(*tg.Document); !ok || doc.ID != f.document.ID { + t.Fatalf("inline result document = %#v, want document %d", mediaResult.Document, f.document.ID) + } + case <-time.After(2 * time.Second): + t.Fatal("document get inline results did not resolve") + } + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: peer, + RandomID: 9904, + QueryID: queryID, + ID: "doc-1", + }) + if err != nil { + t.Fatalf("send inline document result: %v", err) + } + sent, full := channelMessageFromUpdates(t, updatesClass) + if sent.Message != "inline document caption" { + t.Fatalf("sent document caption = %q, want inline document caption", sent.Message) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent document via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + docMedia, ok := sent.Media.(*tg.MessageMediaDocument) + if !ok { + t.Fatalf("sent channel media = %T, want *tg.MessageMediaDocument", sent.Media) + } + if doc, ok := docMedia.Document.(*tg.Document); !ok || doc.ID != f.document.ID { + t.Fatalf("sent channel document = %#v, want document %d", docMedia.Document, f.document.ID) + } + assertUpdatesContainBotUser(t, full, f.bot.ID) + + historyList, err := f.router.deps.Channels.GetHistory(ownerCtx, f.owner.ID, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel document history: %v", err) + } + history := tgChannelHistoryMessages(f.owner.ID, f.router.enrichChannelHistory(ownerCtx, f.owner.ID, historyList)) + channelHistory, ok := history.(*tg.MessagesChannelMessages) + if !ok || len(channelHistory.Messages) == 0 { + t.Fatalf("channel document history = %T len %d, want channel messages", history, len(channelMessagesFromClass(history))) + } + histMsg, ok := channelHistory.Messages[0].(*tg.Message) + if !ok { + t.Fatalf("channel document history message = %T, want *tg.Message", channelHistory.Messages[0]) + } + if _, ok := histMsg.Media.(*tg.MessageMediaDocument); !ok { + t.Fatalf("channel document history media = %T, want *tg.MessageMediaDocument", histMsg.Media) + } + if via, ok := histMsg.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("channel document history via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } +} + +func TestInlineBotContactMediaPrivateRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + type getResult struct { + res *tg.MessagesBotResults + err error + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "contact", + }) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + inlineContactResult("contact-1", f.peer.Phone, "Peer", "Shared", "BEGIN:VCARD\nFN:Peer Shared\nEND:VCARD"), + }, + }); err != nil || !ok { + t.Fatalf("set inline contact result = %v,%v, want true,nil", ok, err) + } + + var got getResult + select { + case got = <-gotCh: + case <-time.After(2 * time.Second): + t.Fatal("contact get inline results did not resolve") + } + if got.err != nil { + t.Fatalf("get inline contact results: %v", got.err) + } + result, ok := got.res.Results[0].(*tg.BotInlineResult) + if !ok { + t.Fatalf("inline contact result = %T, want *tg.BotInlineResult", got.res.Results[0]) + } + contactMsg, ok := result.SendMessage.(*tg.BotInlineMessageMediaContact) + if !ok { + t.Fatalf("inline contact send_message = %T, want *tg.BotInlineMessageMediaContact", result.SendMessage) + } + if contactMsg.PhoneNumber != f.peer.Phone || contactMsg.FirstName != "Peer" || contactMsg.LastName != "Shared" { + t.Fatalf("inline contact message = %+v, want peer contact", contactMsg) + } + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 9905, + QueryID: queryID, + ID: "contact-1", + }) + if err != nil { + t.Fatalf("send inline contact result: %v", err) + } + sent := messageFromUpdates(t, updatesClass) + media, ok := sent.Media.(*tg.MessageMediaContact) + if !ok { + t.Fatalf("sent contact media = %T, want *tg.MessageMediaContact", sent.Media) + } + if media.PhoneNumber != f.peer.Phone || media.UserID != f.peer.ID || media.Vcard == "" { + t.Fatalf("sent contact media = %+v, want peer contact with user_id", media) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent contact via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } +} + +func TestInlineBotContactMediaChannelRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + created, err := f.channels.CreateMegagroupFromCreateChat(ctx, f.owner.ID, domain.CreateChannelRequest{ + Title: "Inline Contact Group", + MemberUserIDs: []int64{f.peer.ID}, + Date: 1700002200, + }) + if err != nil { + t.Fatalf("create inline contact group: %v", err) + } + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + type getResult struct { + res *tg.MessagesBotResults + err error + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: peer, + Query: "contact", + }) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + inlineContactResultWithCallback("contact-channel", f.peer.Phone, "Peer", "Shared", []byte("contact-data")), + }, + }); err != nil || !ok { + t.Fatalf("set channel inline contact result = %v,%v, want true,nil", ok, err) + } + + select { + case got := <-gotCh: + if got.err != nil { + t.Fatalf("get channel inline contact results: %v", got.err) + } + result, ok := got.res.Results[0].(*tg.BotInlineResult) + if !ok { + t.Fatalf("channel inline contact result = %T, want *tg.BotInlineResult", got.res.Results[0]) + } + msg, ok := result.SendMessage.(*tg.BotInlineMessageMediaContact) + if !ok { + t.Fatalf("channel inline contact send_message = %T, want contact", result.SendMessage) + } + if markup, ok := msg.GetReplyMarkup(); !ok || markup == nil { + t.Fatalf("channel inline contact missing reply markup") + } + case <-time.After(2 * time.Second): + t.Fatal("channel contact get inline results did not resolve") + } + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: peer, + RandomID: 9906, + QueryID: queryID, + ID: "contact-channel", + }) + if err != nil { + t.Fatalf("send channel inline contact result: %v", err) + } + sent, full := channelMessageFromUpdates(t, updatesClass) + media, ok := sent.Media.(*tg.MessageMediaContact) + if !ok { + t.Fatalf("sent channel contact media = %T, want *tg.MessageMediaContact", sent.Media) + } + if media.PhoneNumber != f.peer.Phone || media.UserID != f.peer.ID { + t.Fatalf("sent channel contact media = %+v, want peer contact with user_id", media) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent channel contact via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + assertTGInlineReplyMarkup(t, sent, "Contact", []byte("contact-data")) + assertUpdatesContainBotUser(t, full, f.bot.ID) + + historyList, err := f.router.deps.Channels.GetHistory(ownerCtx, f.owner.ID, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel contact history: %v", err) + } + history := tgChannelHistoryMessages(f.owner.ID, f.router.enrichChannelHistory(ownerCtx, f.owner.ID, historyList)) + channelHistory, ok := history.(*tg.MessagesChannelMessages) + if !ok || len(channelHistory.Messages) == 0 { + t.Fatalf("channel contact history = %T len %d, want channel messages", history, len(channelMessagesFromClass(history))) + } + histMsg, ok := channelHistory.Messages[0].(*tg.Message) + if !ok { + t.Fatalf("channel contact history message = %T, want *tg.Message", channelHistory.Messages[0]) + } + if _, ok := histMsg.Media.(*tg.MessageMediaContact); !ok { + t.Fatalf("channel contact history media = %T, want *tg.MessageMediaContact", histMsg.Media) + } + assertTGInlineReplyMarkup(t, histMsg, "Contact", []byte("contact-data")) + + diff, err := f.router.deps.Channels.GetDifference(ownerCtx, f.owner.ID, domain.ChannelDifferenceRequest{ + UserID: f.owner.ID, + ChannelID: created.Channel.ID, + Pts: created.Channel.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel contact difference: %v", err) + } + if len(diff.NewMessages) != 1 || diff.NewMessages[0].Media == nil || diff.NewMessages[0].Media.Kind != domain.MessageMediaKindContact { + t.Fatalf("channel contact difference messages = %+v, want one contact", diff.NewMessages) + } + assertDomainInlineReplyMarkup(t, diff.NewMessages[0].ReplyMarkup, "Contact", []byte("contact-data")) +} + +func TestInlineBotContactValidation(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + botCtx := WithUserID(ctx, f.bot.ID) + + queryID, _ := f.router.inlines.register(f.router.clock.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}) + defer f.router.inlines.consume(queryID) + + longVcard := strings.Repeat("x", maxContactVcardLength+1) + withThumb := inlineContactResult("contact-thumb", f.peer.Phone, "Peer", "Shared", "") + withThumb.SetThumb(tg.InputWebDocument{URL: "https://example.test/thumb.jpg", Size: 128, MimeType: "image/jpeg"}) + cases := []struct { + name string + result tg.InputBotInlineResultClass + want string + }{ + { + name: "type mismatch", + result: func() tg.InputBotInlineResultClass { + r := inlineContactResult("bad-type", f.peer.Phone, "Peer", "", "") + r.Type = "article" + return r + }(), + want: "RESULT_TYPE_INVALID", + }, + { + name: "empty contact", + result: inlineContactResult("empty-contact", "", "", "", ""), + want: "MEDIA_EMPTY", + }, + { + name: "vcard too long", + result: inlineContactResult("long-vcard", f.peer.Phone, "Peer", "", longVcard), + want: "MEDIA_INVALID", + }, + { + name: "web preview unsupported", + result: withThumb, + want: "WEBDOCUMENT_INVALID", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + Results: []tg.InputBotInlineResultClass{ + tc.result, + }, + }); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("set inline contact err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestInlineBotGeoMediaPrivateRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + req := &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "geo", + } + req.SetGeoPoint(&tg.InputGeoPoint{Lat: 39.9042, Long: 116.4074}) + if _, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req); err == nil || !strings.Contains(err.Error(), "BOT_INLINE_GEO_NOT_ALLOWED") { + t.Fatalf("inline geo disabled err = %v, want BOT_INLINE_GEO_NOT_ALLOWED", err) + } + if _, err := f.bots.SetInlineGeo(ctx, f.bot.ID, true); err != nil { + t.Fatalf("enable inline geo: %v", err) + } + + type getResult struct { + res *tg.MessagesBotResults + err error + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + inlineGeoResult("geo-1", 31.2304, 121.4737), + }, + }); err != nil || !ok { + t.Fatalf("set inline geo result = %v,%v, want true,nil", ok, err) + } + + var got getResult + select { + case got = <-gotCh: + case <-time.After(2 * time.Second): + t.Fatal("geo get inline results did not resolve") + } + if got.err != nil { + t.Fatalf("get inline geo results: %v", got.err) + } + if len(got.res.Users) != 1 { + t.Fatalf("geo inline users = %d, want bot user", len(got.res.Users)) + } + botUser, ok := got.res.Users[0].(*tg.User) + if !ok || !botUser.BotInlineGeo { + t.Fatalf("geo inline bot user = %#v, want bot_inline_geo", got.res.Users[0]) + } + result, ok := got.res.Results[0].(*tg.BotInlineResult) + if !ok { + t.Fatalf("geo inline result = %T, want *tg.BotInlineResult", got.res.Results[0]) + } + if _, ok := result.SendMessage.(*tg.BotInlineMessageMediaGeo); !ok { + t.Fatalf("geo send_message = %T, want *tg.BotInlineMessageMediaGeo", result.SendMessage) + } + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 9905, + QueryID: queryID, + ID: "geo-1", + }) + if err != nil { + t.Fatalf("send inline geo result: %v", err) + } + sent := messageFromUpdates(t, updatesClass) + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent geo via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + media, ok := sent.Media.(*tg.MessageMediaGeo) + if !ok { + t.Fatalf("sent geo media = %T, want *tg.MessageMediaGeo", sent.Media) + } + point, ok := media.Geo.(*tg.GeoPoint) + if !ok || point.Lat != 31.2304 || point.Long != 121.4737 { + t.Fatalf("sent geo point = %#v, want 31.2304,121.4737", media.Geo) + } +} + +func TestInlineBotVenueMediaChannelRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + if _, err := f.bots.SetInlineGeo(ctx, f.bot.ID, true); err != nil { + t.Fatalf("enable inline geo: %v", err) + } + created, err := f.channels.CreateMegagroupFromCreateChat(ctx, f.owner.ID, domain.CreateChannelRequest{ + Title: "Inline Venue Group", + MemberUserIDs: []int64{f.peer.ID}, + Date: 1700003000, + }) + if err != nil { + t.Fatalf("create inline venue group: %v", err) + } + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + req := &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: peer, + Query: "venue", + } + req.SetGeoPoint(&tg.InputGeoPoint{Lat: 39.9042, Long: 116.4074}) + + type getResult struct { + res *tg.MessagesBotResults + err error + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + inlineVenueResult("venue-1", "Cafe Inline", 30.2672, -97.7431), + }, + }); err != nil || !ok { + t.Fatalf("set inline venue result = %v,%v, want true,nil", ok, err) + } + + select { + case got := <-gotCh: + if got.err != nil { + t.Fatalf("get inline venue results: %v", got.err) + } + result, ok := got.res.Results[0].(*tg.BotInlineResult) + if !ok { + t.Fatalf("venue inline result = %T, want *tg.BotInlineResult", got.res.Results[0]) + } + if _, ok := result.SendMessage.(*tg.BotInlineMessageMediaVenue); !ok { + t.Fatalf("venue send_message = %T, want *tg.BotInlineMessageMediaVenue", result.SendMessage) + } + case <-time.After(2 * time.Second): + t.Fatal("venue get inline results did not resolve") + } + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: peer, + RandomID: 9906, + QueryID: queryID, + ID: "venue-1", + }) + if err != nil { + t.Fatalf("send inline venue result: %v", err) + } + sent, full := channelMessageFromUpdates(t, updatesClass) + venueMedia, ok := sent.Media.(*tg.MessageMediaVenue) + if !ok { + t.Fatalf("sent venue media = %T, want *tg.MessageMediaVenue", sent.Media) + } + if venueMedia.Title != "Cafe Inline" { + t.Fatalf("sent venue title = %q, want Cafe Inline", venueMedia.Title) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent venue via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + assertUpdatesContainBotUser(t, full, f.bot.ID) + + historyList, err := f.router.deps.Channels.GetHistory(ownerCtx, f.owner.ID, domain.ChannelHistoryFilter{ + ChannelID: created.Channel.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel venue history: %v", err) + } + history := tgChannelHistoryMessages(f.owner.ID, f.router.enrichChannelHistory(ownerCtx, f.owner.ID, historyList)) + channelHistory, ok := history.(*tg.MessagesChannelMessages) + if !ok || len(channelHistory.Messages) == 0 { + t.Fatalf("channel venue history = %T len %d, want channel messages", history, len(channelMessagesFromClass(history))) + } + histMsg, ok := channelHistory.Messages[0].(*tg.Message) + if !ok { + t.Fatalf("channel venue history message = %T, want *tg.Message", channelHistory.Messages[0]) + } + if _, ok := histMsg.Media.(*tg.MessageMediaVenue); !ok { + t.Fatalf("channel venue history media = %T, want *tg.MessageMediaVenue", histMsg.Media) + } + if via, ok := histMsg.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("channel venue history via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } +} + +func TestInlineBotSwitchPMRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + type getResult struct { + res *tg.MessagesBotResults + err error + } + gotCh := make(chan getResult, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "connect", + }) + gotCh <- getResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + req := &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{}, + } + req.SetSwitchPm(tg.InlineBotSwitchPM{Text: "Connect account", StartParam: "connect_123"}) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, req); err != nil || !ok { + t.Fatalf("set switch_pm inline results = %v,%v, want true,nil", ok, err) + } + + var got getResult + select { + case got = <-gotCh: + case <-time.After(2 * time.Second): + t.Fatal("switch_pm get inline results did not resolve") + } + if got.err != nil { + t.Fatalf("get switch_pm inline results: %v", got.err) + } + if got.res.QueryID != queryID { + t.Fatalf("switch_pm query_id = %d, want %d", got.res.QueryID, queryID) + } + if len(got.res.Results) != 0 { + t.Fatalf("switch_pm results len = %d, want 0", len(got.res.Results)) + } + switchPM, ok := got.res.GetSwitchPm() + if !ok { + t.Fatal("switch_pm missing in bot results") + } + if switchPM.Text != "Connect account" || switchPM.StartParam != "connect_123" { + t.Fatalf("switch_pm = %#v, want text/start_param", switchPM) + } + if len(got.res.Users) != 1 { + t.Fatalf("switch_pm users = %d, want bot user", len(got.res.Users)) + } +} + +func TestInlineBotServerCacheHitUsesFreshQueryID(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + req := &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "cached", + } + + gotCh := make(chan struct { + res *tg.MessagesBotResults + err error + }, 1) + go func() { + res, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req) + gotCh <- struct { + res *tg.MessagesBotResults + err error + }{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 60, + NextOffset: "page2", + Results: []tg.InputBotInlineResultClass{inlineArticleResult("cached-1", "cached hello")}, + }); err != nil || !ok { + t.Fatalf("set cached inline results = %v,%v, want true,nil", ok, err) + } + select { + case got := <-gotCh: + if got.err != nil { + t.Fatalf("first cached get: %v", got.err) + } + if got.res.QueryID != queryID { + t.Fatalf("first query_id = %d, want %d", got.res.QueryID, queryID) + } + if next, ok := got.res.GetNextOffset(); !ok || next != "page2" { + t.Fatalf("first next_offset = %q,%v, want page2,true", next, ok) + } + case <-time.After(2 * time.Second): + t.Fatal("first cached get did not resolve") + } + if f.router.inlines.unansweredSize() != 0 { + t.Fatalf("unanswered inline queries after first answer = %d, want 0", f.router.inlines.unansweredSize()) + } + + fastCtx, cancel := context.WithTimeout(ownerCtx, 200*time.Millisecond) + defer cancel() + cached, err := f.router.onMessagesGetInlineBotResults(fastCtx, req) + if err != nil { + t.Fatalf("cached get: %v", err) + } + if cached.QueryID == queryID { + t.Fatalf("cached query_id reused old value %d", queryID) + } + if f.router.inlines.unansweredSize() != 0 { + t.Fatalf("unanswered inline queries after cache hit = %d, want 0", f.router.inlines.unansweredSize()) + } + if next, ok := cached.GetNextOffset(); !ok || next != "page2" { + t.Fatalf("cached next_offset = %q,%v, want page2,true", next, ok) + } + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 9910, + QueryID: cached.QueryID, + ID: "cached-1", + }) + if err != nil { + t.Fatalf("send cached inline result: %v", err) + } + sent := messageFromUpdates(t, updatesClass) + if sent.Message != "cached hello" { + t.Fatalf("cached sent message = %q, want cached hello", sent.Message) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("cached via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } +} + +func TestInlineBotServerCacheSkipsPrivateAndZeroCache(t *testing.T) { + for _, tc := range []struct { + name string + private bool + cacheTime int + }{ + {name: "private", private: true, cacheTime: 60}, + {name: "zero cache", private: false, cacheTime: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + req := &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: tc.name, + } + + firstCh := make(chan error, 1) + go func() { + _, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req) + firstCh <- err + }() + firstID := waitInlineBotQuery(t, f.router) + setReq := &tg.MessagesSetInlineBotResultsRequest{ + Private: tc.private, + QueryID: firstID, + CacheTime: tc.cacheTime, + Results: []tg.InputBotInlineResultClass{inlineArticleResult("miss-1", "first")}, + } + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, setReq); err != nil || !ok { + t.Fatalf("set first no-cache results = %v,%v, want true,nil", ok, err) + } + select { + case err := <-firstCh: + if err != nil { + t.Fatalf("first no-cache get: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("first no-cache get did not resolve") + } + + secondCh := make(chan error, 1) + go func() { + _, err := f.router.onMessagesGetInlineBotResults(ownerCtx, req) + secondCh <- err + }() + secondID := waitInlineBotQuery(t, f.router) + if secondID == firstID { + t.Fatalf("second query reused first id %d", firstID) + } + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: secondID, + CacheTime: 1, + Results: []tg.InputBotInlineResultClass{inlineArticleResult("miss-2", "second")}, + }); err != nil || !ok { + t.Fatalf("set second no-cache results = %v,%v, want true,nil", ok, err) + } + select { + case err := <-secondCh: + if err != nil { + t.Fatalf("second no-cache get: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("second no-cache get did not resolve") + } + }) + } +} + +func TestInlineBotServerCacheSeparatesOffsets(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + firstReq := &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "pages", + } + firstCh := make(chan error, 1) + go func() { + _, err := f.router.onMessagesGetInlineBotResults(ownerCtx, firstReq) + firstCh <- err + }() + firstID := waitInlineBotQuery(t, f.router) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: firstID, + CacheTime: 60, + NextOffset: "page2", + Results: []tg.InputBotInlineResultClass{inlineArticleResult("page-1", "page one")}, + }); err != nil || !ok { + t.Fatalf("set first page results = %v,%v, want true,nil", ok, err) + } + select { + case err := <-firstCh: + if err != nil { + t.Fatalf("first page get: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("first page get did not resolve") + } + + secondReq := &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "pages", + Offset: "page2", + } + secondCh := make(chan error, 1) + go func() { + _, err := f.router.onMessagesGetInlineBotResults(ownerCtx, secondReq) + secondCh <- err + }() + secondID := waitInlineBotQuery(t, f.router) + if secondID == firstID { + t.Fatalf("second page reused first id %d", firstID) + } + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: secondID, + CacheTime: 60, + Results: []tg.InputBotInlineResultClass{inlineArticleResult("page-2", "page two")}, + }); err != nil || !ok { + t.Fatalf("set second page results = %v,%v, want true,nil", ok, err) + } + select { + case err := <-secondCh: + if err != nil { + t.Fatalf("second page get: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("second page get did not resolve") + } +} + +func TestInlineBotValidation(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + queryID, _ := f.router.inlines.register(time.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}) + + if _, err := f.router.onMessagesSetInlineBotResults(WithUserID(ctx, f.owner.ID), &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + }); err == nil || !strings.Contains(err.Error(), "USER_BOT_REQUIRED") { + t.Fatalf("non-bot set inline err = %v, want USER_BOT_REQUIRED", err) + } + + if _, err := f.router.onMessagesSetInlineBotResults(WithUserID(ctx, f.bot.ID), &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + Results: []tg.InputBotInlineResultClass{ + inlineArticleResult("dup", "one"), + inlineArticleResult("dup", "two"), + }, + }); err == nil || !strings.Contains(err.Error(), "RESULT_ID_DUPLICATE") { + t.Fatalf("duplicate inline result err = %v, want RESULT_ID_DUPLICATE", err) + } + + disabledBot, _, err := f.bots.CreateBot(ctx, f.owner.ID, "Disabled Inline Bot", "disabled_inline_bot") + if err != nil { + t.Fatalf("create disabled bot: %v", err) + } + if _, err := f.router.onMessagesGetInlineBotResults(WithUserID(ctx, f.owner.ID), &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(disabledBot), + Peer: inputPeerUser(f.peer), + Query: "hello", + }); err == nil || !strings.Contains(err.Error(), "BOT_INLINE_DISABLED") { + t.Fatalf("disabled inline err = %v, want BOT_INLINE_DISABLED", err) + } + + if _, err := f.bots.SetInlineGeo(ctx, f.bot.ID, true); err != nil { + t.Fatalf("enable inline geo: %v", err) + } + badGeoReq := &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(f.bot), + Peer: inputPeerUser(f.peer), + Query: "bad geo", + } + badGeoReq.SetGeoPoint(&tg.InputGeoPoint{Lat: 91, Long: 0}) + if _, err := f.router.onMessagesGetInlineBotResults(WithUserID(ctx, f.owner.ID), badGeoReq); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("bad inline geo query err = %v, want MEDIA_INVALID", err) + } + + for _, tc := range []struct { + name string + result tg.InputBotInlineResultClass + want string + }{ + {name: "geo live fields", result: inlineGeoResultWithPeriod("geo-live", 1, 2), want: "MEDIA_INVALID"}, + {name: "empty venue title", result: inlineVenueResult("venue-empty", "", 1, 2), want: "MEDIA_EMPTY"}, + } { + t.Run(tc.name, func(t *testing.T) { + queryID, _ := f.router.inlines.register(time.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}) + if _, err := f.router.onMessagesSetInlineBotResults(WithUserID(ctx, f.bot.ID), &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + Results: []tg.InputBotInlineResultClass{ + tc.result, + }, + }); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("inline media validation err = %v, want %s", err, tc.want) + } + }) + } + + switchPMCases := []struct { + name string + pm tg.InlineBotSwitchPM + want string + }{ + {name: "empty text", pm: tg.InlineBotSwitchPM{Text: "", StartParam: "start"}, want: "SWITCH_PM_TEXT_EMPTY"}, + {name: "empty start", pm: tg.InlineBotSwitchPM{Text: "Start", StartParam: ""}, want: "START_PARAM_EMPTY"}, + {name: "invalid start", pm: tg.InlineBotSwitchPM{Text: "Start", StartParam: "bad space"}, want: "START_PARAM_INVALID"}, + {name: "long start", pm: tg.InlineBotSwitchPM{Text: "Start", StartParam: strings.Repeat("a", domain.MaxStartParamLen+1)}, want: "START_PARAM_INVALID"}, + {name: "long text", pm: tg.InlineBotSwitchPM{Text: strings.Repeat("x", domain.MaxBotInlineSwitchTextLen+1), StartParam: "start"}, want: "BUTTON_INVALID"}, + } + for _, tc := range switchPMCases { + t.Run(tc.name, func(t *testing.T) { + queryID, _ := f.router.inlines.register(time.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}) + req := &tg.MessagesSetInlineBotResultsRequest{QueryID: queryID} + req.SetSwitchPm(tc.pm) + if _, err := f.router.onMessagesSetInlineBotResults(WithUserID(ctx, f.bot.ID), req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("switch_pm err = %v, want %s", err, tc.want) + } + }) + } + + webviewQueryID, _ := f.router.inlines.register(time.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}) + webviewReq := &tg.MessagesSetInlineBotResultsRequest{QueryID: webviewQueryID} + webviewReq.SetSwitchWebview(tg.InlineBotWebView{Text: "Open", URL: "https://example.test/app"}) + if ok, err := f.router.onMessagesSetInlineBotResults(WithUserID(ctx, f.bot.ID), webviewReq); err != nil || !ok { + t.Fatalf("switch_webview set = %v,%v, want true,nil", ok, err) + } + results, ok := f.router.inlines.resultsForQueryContext(ctx, time.Now(), f.owner.ID, webviewQueryID) + if !ok || results.SwitchWeb == nil || results.SwitchWeb.Text != "Open" || results.SwitchWeb.URL != "https://example.test/app" { + t.Fatalf("switch_webview results = %+v ok=%v", results.SwitchWeb, ok) + } + tgResults := f.router.tgBotInlineResults(ctx, f.owner.ID, results) + gotSwitchWeb, ok := tgResults.GetSwitchWebview() + if !ok || gotSwitchWeb.Text != "Open" || gotSwitchWeb.URL != "https://example.test/app" { + t.Fatalf("tg switch_webview = %+v,%v", gotSwitchWeb, ok) + } + + for _, tc := range []struct { + name string + web tg.InlineBotWebView + want string + }{ + {name: "empty text", web: tg.InlineBotWebView{Text: "", URL: "https://example.test/app"}, want: "BUTTON_INVALID"}, + {name: "bad url", web: tg.InlineBotWebView{Text: "Open", URL: "http://example.test/app"}, want: "SWITCH_WEBVIEW_URL_INVALID"}, + } { + t.Run("switch_webview "+tc.name, func(t *testing.T) { + queryID, _ := f.router.inlines.register(time.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}) + req := &tg.MessagesSetInlineBotResultsRequest{QueryID: queryID} + req.SetSwitchWebview(tc.web) + if _, err := f.router.onMessagesSetInlineBotResults(WithUserID(ctx, f.bot.ID), req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("switch_webview err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestMessagesRequestSimpleWebViewURLOnly(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + req := &tg.MessagesRequestSimpleWebViewRequest{ + Bot: inputUser(f.bot), + Platform: "tdesktop", + } + req.SetFromSwitchWebview(true) + req.SetURL("https://example.test/app") + req.SetThemeParams(tg.DataJSON{Data: "{}"}) + + got, err := f.router.onMessagesRequestSimpleWebView(WithUserID(ctx, f.owner.ID), req) + if err != nil { + t.Fatalf("request simple webview: %v", err) + } + if got.URL != "https://example.test/app" { + t.Fatalf("webview url = %q, want request url", got.URL) + } + if _, ok := got.GetQueryID(); ok { + t.Fatal("webview query_id set, want URL-only result") + } + + req.SetURL("http://example.test/app") + if _, err := f.router.onMessagesRequestSimpleWebView(WithUserID(ctx, f.owner.ID), req); err == nil || !strings.Contains(err.Error(), "URL_INVALID") { + t.Fatalf("bad webview url err = %v, want URL_INVALID", err) + } + req.SetURL("https://example.test/app") + req.Bot = inputUser(f.owner) + if _, err := f.router.onMessagesRequestSimpleWebView(WithUserID(ctx, f.owner.ID), req); err == nil || !strings.Contains(err.Error(), "BOT_INVALID") { + t.Fatalf("non-bot webview err = %v, want BOT_INVALID", err) + } +} + +func channelMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) (*tg.Message, *tg.Updates) { + t.Helper() + full, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, update := range full.Updates { + if newMessage, ok := update.(*tg.UpdateNewChannelMessage); ok { + if msg, ok := newMessage.Message.(*tg.Message); ok { + return msg, full + } + } + } + t.Fatalf("updates missing new channel message: %+v", full.Updates) + return nil, nil +} + +func assertUpdatesContainBotUser(t *testing.T, updates *tg.Updates, botID int64) { + t.Helper() + for _, u := range updates.Users { + if user, ok := u.(*tg.User); ok && user.ID == botID && user.Bot { + return + } + } + t.Fatalf("updates users missing via bot %d: %+v", botID, updates.Users) +} + +func assertChannelMessagesContainBotUser(t *testing.T, messages *tg.MessagesChannelMessages, botID int64) { + t.Helper() + for _, u := range messages.Users { + if user, ok := u.(*tg.User); ok && user.ID == botID && user.Bot { + return + } + } + t.Fatalf("channel messages users missing via bot %d: %+v", botID, messages.Users) +} + +func assertTGInlineReplyMarkup(t *testing.T, msg *tg.Message, wantText string, wantData []byte) { + t.Helper() + markupClass, ok := msg.GetReplyMarkup() + if !ok { + t.Fatalf("message %d missing reply_markup", msg.ID) + } + markup, ok := markupClass.(*tg.ReplyInlineMarkup) + if !ok { + t.Fatalf("reply_markup = %T, want *tg.ReplyInlineMarkup", markupClass) + } + if len(markup.Rows) != 1 || len(markup.Rows[0].Buttons) != 1 { + t.Fatalf("reply_markup rows = %+v, want one callback button", markup.Rows) + } + button, ok := markup.Rows[0].Buttons[0].(*tg.KeyboardButtonCallback) + if !ok { + t.Fatalf("reply_markup button = %T, want callback", markup.Rows[0].Buttons[0]) + } + if button.Text != wantText || !bytes.Equal(button.Data, wantData) { + t.Fatalf("reply_markup button = %q/%v, want %q/%v", button.Text, button.Data, wantText, wantData) + } +} + +func assertDomainInlineReplyMarkup(t *testing.T, markup *domain.MessageReplyMarkup, wantText string, wantData []byte) { + t.Helper() + if markup == nil || len(markup.Inline) != 1 || len(markup.Inline[0]) != 1 { + t.Fatalf("domain reply markup = %+v, want one callback button", markup) + } + button := markup.Inline[0][0] + if button.Type != domain.MarkupButtonCallback || button.Text != wantText || !bytes.Equal(button.Data, wantData) { + t.Fatalf("domain reply markup button = %+v, want callback %q/%v", button, wantText, wantData) + } +} + +func waitInlineBotQuery(t *testing.T, r *Router) int64 { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + r.inlines.mu.Lock() + for queryID, pending := range r.inlines.pending { + if pending.results == nil { + r.inlines.mu.Unlock() + return queryID + } + } + r.inlines.mu.Unlock() + time.Sleep(5 * time.Millisecond) + } + t.Fatal("inline query was not registered") + return 0 +} + +func inputPeerUser(user domain.User) *tg.InputPeerUser { + return &tg.InputPeerUser{UserID: user.ID, AccessHash: user.AccessHash} +} + +func inlineArticleResult(id, message string) tg.InputBotInlineResultClass { + return &tg.InputBotInlineResult{ + ID: id, + Type: "article", + Title: id, + SendMessage: &tg.InputBotInlineMessageText{Message: message}, + } +} + +func inlineArticleResultWithCallback(id, message, button string, data []byte) tg.InputBotInlineResultClass { + result := inlineArticleResult(id, message).(*tg.InputBotInlineResult) + msg := result.SendMessage.(*tg.InputBotInlineMessageText) + msg.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{ + Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: button, Data: data}}, + }}}) + return result +} + +func inlineArticleResultWithWebThumb(id, message string) *tg.InputBotInlineResult { + result := &tg.InputBotInlineResult{ + ID: id, + Type: "article", + Title: "Preview", + Description: "External preview", + SendMessage: &tg.InputBotInlineMessageText{Message: message}, + } + result.SetURL("https://example.test/result") + result.SetThumb(tg.InputWebDocument{ + URL: "https://example.test/thumb.jpg", + Size: 1234, + MimeType: "image/jpeg", + Attributes: []tg.DocumentAttributeClass{ + &tg.DocumentAttributeImageSize{W: 96, H: 96}, + }, + }) + return result +} + +func inlineArticleResultWithWebContent(id, message string) *tg.InputBotInlineResult { + result := inlineArticleResultWithWebThumb(id, message) + result.SetContent(tg.InputWebDocument{ + URL: "https://example.test/content.png", + Size: 4096, + MimeType: "image/png", + Attributes: []tg.DocumentAttributeClass{ + &tg.DocumentAttributeImageSize{W: 320, H: 200}, + }, + }) + return result +} + +func inlineExternalPhotoContentResult(id, caption string) *tg.InputBotInlineResult { + result := &tg.InputBotInlineResult{ + ID: id, + Type: "photo", + Title: "External Photo", + Description: "External photo content", + SendMessage: &tg.InputBotInlineMessageMediaAuto{ + Message: caption, + }, + } + result.SetThumb(tg.InputWebDocument{ + URL: "https://example.test/photo-thumb.jpg", + Size: 512, + MimeType: "image/jpeg", + Attributes: []tg.DocumentAttributeClass{ + &tg.DocumentAttributeImageSize{W: 90, H: 90}, + }, + }) + result.SetContent(tg.InputWebDocument{ + URL: "https://example.test/photo.png", + Size: 4096, + MimeType: "image/png", + Attributes: []tg.DocumentAttributeClass{ + &tg.DocumentAttributeImageSize{W: 320, H: 200}, + }, + }) + return result +} + +func assertInlineArticleWebPreview(t *testing.T, res *tg.MessagesBotResults, queryID int64) { + t.Helper() + if res.QueryID != queryID || len(res.Results) != 1 { + t.Fatalf("inline results = query %d len %d, want query %d len 1", res.QueryID, len(res.Results), queryID) + } + result, ok := res.Results[0].(*tg.BotInlineResult) + if !ok { + t.Fatalf("inline result = %T, want *tg.BotInlineResult", res.Results[0]) + } + if result.ID != "article-web" || result.Type != "article" { + t.Fatalf("inline result id/type = %q/%q, want article-web/article", result.ID, result.Type) + } + if gotURL, ok := result.GetURL(); !ok || gotURL != "https://example.test/result" { + t.Fatalf("inline result url = %q,%v, want https://example.test/result,true", gotURL, ok) + } + rawThumb, ok := result.GetThumb() + if !ok { + t.Fatal("inline result missing thumb") + } + thumb, ok := rawThumb.(*tg.WebDocument) + if !ok { + t.Fatalf("inline result thumb = %T, want *tg.WebDocument", rawThumb) + } + if thumb.URL != "https://example.test/thumb.jpg" || thumb.AccessHash == 0 || thumb.Size != 1234 || thumb.MimeType != "image/jpeg" { + t.Fatalf("thumb = url %q size %d mime %q", thumb.URL, thumb.Size, thumb.MimeType) + } + if len(thumb.Attributes) != 1 { + t.Fatalf("thumb attributes = %d, want 1", len(thumb.Attributes)) + } + size, ok := thumb.Attributes[0].(*tg.DocumentAttributeImageSize) + if !ok || size.W != 96 || size.H != 96 { + t.Fatalf("thumb size attr = %#v, want 96x96", thumb.Attributes[0]) + } + msg, ok := result.SendMessage.(*tg.BotInlineMessageText) + if !ok || msg.Message != "preview text" { + t.Fatalf("inline send message = %T/%#v, want text preview text", result.SendMessage, result.SendMessage) + } +} + +func inlinePhotoResult(id string, photo domain.Photo, caption string) tg.InputBotInlineResultClass { + return &tg.InputBotInlineResultPhoto{ + ID: id, + Type: "photo", + Photo: &tg.InputPhoto{ID: photo.ID, AccessHash: photo.AccessHash}, + SendMessage: &tg.InputBotInlineMessageMediaAuto{ + Message: caption, + }, + } +} + +func inlineDocumentResult(id string, doc domain.Document, caption string) tg.InputBotInlineResultClass { + return &tg.InputBotInlineResultDocument{ + ID: id, + Type: "file", + Title: id, + Description: doc.MimeType, + Document: &tg.InputDocument{ID: doc.ID, AccessHash: doc.AccessHash}, + SendMessage: &tg.InputBotInlineMessageMediaAuto{ + Message: caption, + }, + } +} + +func inlineGeoResult(id string, lat, long float64) tg.InputBotInlineResultClass { + return &tg.InputBotInlineResult{ + ID: id, + Type: "geo", + Title: id, + Description: "Location", + SendMessage: &tg.InputBotInlineMessageMediaGeo{ + GeoPoint: &tg.InputGeoPoint{Lat: lat, Long: long}, + }, + } +} + +func inlineGeoResultWithPeriod(id string, lat, long float64) tg.InputBotInlineResultClass { + msg := &tg.InputBotInlineMessageMediaGeo{GeoPoint: &tg.InputGeoPoint{Lat: lat, Long: long}} + msg.SetPeriod(60) + return &tg.InputBotInlineResult{ + ID: id, + Type: "geo", + Title: id, + SendMessage: msg, + } +} + +func inlineVenueResult(id, title string, lat, long float64) tg.InputBotInlineResultClass { + return &tg.InputBotInlineResult{ + ID: id, + Type: "venue", + Title: title, + Description: "Venue", + SendMessage: &tg.InputBotInlineMessageMediaVenue{ + GeoPoint: &tg.InputGeoPoint{Lat: lat, Long: long}, + Title: title, + Address: "Inline Street", + Provider: "gplaces", + VenueID: "venue-id", + VenueType: "cafe", + }, + } +} + +func inlineContactResult(id, phone, first, last, vcard string) *tg.InputBotInlineResult { + return &tg.InputBotInlineResult{ + ID: id, + Type: "contact", + Title: first, + Description: phone, + SendMessage: &tg.InputBotInlineMessageMediaContact{ + PhoneNumber: phone, + FirstName: first, + LastName: last, + Vcard: vcard, + }, + } +} + +func inlineContactResultWithCallback(id, phone, first, last string, data []byte) tg.InputBotInlineResultClass { + result := inlineContactResult(id, phone, first, last, "BEGIN:VCARD\nFN:"+first+" "+last+"\nEND:VCARD") + msg := result.SendMessage.(*tg.InputBotInlineMessageMediaContact) + msg.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{ + Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "Contact", Data: data}}, + }}}) + return result +} + +func messageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message { + t.Helper() + full, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, update := range full.Updates { + if newMessage, ok := update.(*tg.UpdateNewMessage); ok { + if msg, ok := newMessage.Message.(*tg.Message); ok { + return msg + } + } + } + t.Fatalf("updates missing new message: %+v", full.Updates) + return nil +} + +func messagesFromClass(in tg.MessagesMessagesClass) []tg.MessageClass { + if out, ok := in.(*tg.MessagesMessages); ok { + return out.Messages + } + return nil +} + +func channelMessagesFromClass(in tg.MessagesMessagesClass) []tg.MessageClass { + if out, ok := in.(*tg.MessagesChannelMessages); ok { + return out.Messages + } + return nil +} diff --git a/internal/rpc/bots_longtail.go b/internal/rpc/bots_longtail.go new file mode 100644 index 00000000..998862c5 --- /dev/null +++ b/internal/rpc/bots_longtail.go @@ -0,0 +1,862 @@ +package rpc + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "hash/fnv" + "net" + "net/http" + "net/url" + "path/filepath" + "strings" + "time" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) resolveBotUserForViewer(ctx context.Context, viewerID int64, bot tg.InputUserClass) (domain.User, error) { + if bot == nil || r.deps.Bots == nil { + return domain.User{}, botInvalidErr() + } + u, found, err := r.userFromInput(ctx, viewerID, bot) + if err != nil || !found { + return domain.User{}, botInvalidErr() + } + if _, found, err := r.deps.Bots.BotInfo(ctx, u.ID); err != nil { + return domain.User{}, internalErr() + } else if !found { + return domain.User{}, botInvalidErr() + } + return u, nil +} + +func (r *Router) resolveOwnedBotUser(ctx context.Context, ownerID int64, bot tg.InputUserClass) (domain.User, error) { + u, err := r.resolveBotUserForViewer(ctx, ownerID, bot) + if err != nil { + return domain.User{}, err + } + owns, err := r.deps.Bots.OwnsBot(ctx, ownerID, u.ID) + if err != nil { + return domain.User{}, internalErr() + } + if !owns { + return domain.User{}, botInvalidErr() + } + return u, nil +} + +func (r *Router) onBotsSendCustomRequest(ctx context.Context, req *tg.BotsSendCustomRequestRequest) (*tg.DataJSON, error) { + if _, err := r.callerBotID(ctx); err != nil { + return nil, err + } + return nil, methodInvalidErr() +} + +func (r *Router) onBotsAnswerWebhookJSONQuery(ctx context.Context, req *tg.BotsAnswerWebhookJSONQueryRequest) (bool, error) { + if _, err := r.callerBotID(ctx); err != nil { + return false, err + } + return false, queryIDInvalidErr() +} + +func (r *Router) onBotsSetBotBroadcastDefaultAdminRights(ctx context.Context, _ tg.ChatAdminRights) (bool, error) { + if _, err := r.callerBotID(ctx); err != nil { + return false, err + } + return false, rightsNotModifiedErr() +} + +func (r *Router) onBotsSetBotGroupDefaultAdminRights(ctx context.Context, _ tg.ChatAdminRights) (bool, error) { + if _, err := r.callerBotID(ctx); err != nil { + return false, err + } + return false, rightsNotModifiedErr() +} + +func (r *Router) onBotsReorderUsernames(ctx context.Context, req *tg.BotsReorderUsernamesRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil { + return false, err + } + return false, usernameNotModifiedErr() +} + +func (r *Router) onBotsToggleUsername(ctx context.Context, req *tg.BotsToggleUsernameRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil { + return false, err + } + return false, usernameNotModifiedErr() +} + +func (r *Router) onBotsCanSendMessage(ctx context.Context, bot tg.InputUserClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + botUser, err := r.resolveBotUserForViewer(ctx, userID, bot) + if err != nil { + return false, err + } + allowed, err := r.deps.Bots.CanSendMessage(ctx, userID, botUser.ID) + if err != nil { + return false, internalErr() + } + return allowed, nil +} + +func (r *Router) onBotsAllowSendMessage(ctx context.Context, bot tg.InputUserClass) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + botUser, err := r.resolveBotUserForViewer(ctx, userID, bot) + if err != nil { + return nil, err + } + if _, err := r.deps.Bots.AllowSendMessage(ctx, userID, botUser.ID, true); err != nil { + return nil, botInvalidErr() + } + res, err := r.sendBotAllowedServiceMessage(ctx, userID, botUser.ID) + if err != nil { + return nil, internalErr() + } + if res.Duplicate { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage) + chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage) + return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, chats), nil +} + +func (r *Router) sendBotAllowedServiceMessage(ctx context.Context, userID, botUserID int64) (domain.SendPrivateTextResult, error) { + return r.sendBotAllowedServiceMessageWith(ctx, userID, botUserID, domain.MessageBotAllowedAction{FromRequest: true}) +} + +func (r *Router) sendBotAllowedServiceMessageWith(ctx context.Context, userID, botUserID int64, action domain.MessageBotAllowedAction) (domain.SendPrivateTextResult, error) { + if r.deps.Messages == nil { + return domain.SendPrivateTextResult{}, botInvalidErr() + } + recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUserID) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + return r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: botUserID, + RandomID: botAllowedServiceMessageRandomID(userID, botUserID, action), + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionBotAllowed, + BotAllowed: &action, + }, + }, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + RecipientBlocked: recipientBlocked, + }) +} + +func botAllowedServiceMessageRandomID(userID, botUserID int64, action domain.MessageBotAllowedAction) int64 { + var raw [16]byte + binary.LittleEndian.PutUint64(raw[:8], uint64(userID)) + binary.LittleEndian.PutUint64(raw[8:], uint64(botUserID)) + h := fnv.New64a() + _, _ = h.Write(raw[:]) + if action.AttachMenu { + _, _ = h.Write([]byte("attach_menu")) + } + if action.FromRequest { + _, _ = h.Write([]byte("from_request")) + } + if action.Domain != "" { + _, _ = h.Write([]byte(action.Domain)) + } + id := int64(h.Sum64() & ((uint64(1) << 63) - 1)) + if id == 0 { + return -1 + } + return -id +} + +func (r *Router) onBotsInvokeWebViewCustomMethod(ctx context.Context, req *tg.BotsInvokeWebViewCustomMethodRequest) (*tg.DataJSON, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot) + if err != nil { + return nil, err + } + params := req.Params.Data + if !json.Valid([]byte(params)) { + return nil, methodInvalidErr() + } + if _, err := r.deps.Bots.PutWebViewCustomMethodQuery(ctx, bot.ID, userID, req.CustomMethod, params); err != nil { + return nil, methodInvalidErr() + } + return nil, methodInvalidErr() +} + +func (r *Router) onBotsGetPopularAppBots(ctx context.Context, req *tg.BotsGetPopularAppBotsRequest) (*tg.BotsPopularAppBots, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return &tg.BotsPopularAppBots{Users: []tg.UserClass{}}, nil +} + +func (r *Router) onBotsAddPreviewMedia(ctx context.Context, req *tg.BotsAddPreviewMediaRequest) (*tg.BotPreviewMedia, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot) + if err != nil { + return nil, err + } + app, err := r.mainBotAppForOwner(ctx, bot.ID) + if err != nil { + return nil, err + } + media, err := r.botPreviewMediaFromInput(ctx, userID, req.Media) + if err != nil { + return nil, err + } + media.BotUserID = bot.ID + media.AppID = app.ID + out, _, err := r.deps.Bots.UpsertBotAppPreviewMedia(ctx, media) + if err != nil { + return nil, botAppInvalidErr() + } + return r.tgBotPreviewMedia(ctx, out), nil +} + +func (r *Router) onBotsEditPreviewMedia(ctx context.Context, req *tg.BotsEditPreviewMediaRequest) (*tg.BotPreviewMedia, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot) + if err != nil { + return nil, err + } + app, err := r.mainBotAppForOwner(ctx, bot.ID) + if err != nil { + return nil, err + } + current, err := r.botPreviewMediaIDFromInput(ctx, userID, bot.ID, app.ID, req.Media) + if err != nil { + return nil, err + } + next, err := r.botPreviewMediaFromInput(ctx, userID, req.NewMedia) + if err != nil { + return nil, err + } + next.ID = current.ID + next.Position = current.Position + next.BotUserID = bot.ID + next.AppID = app.ID + out, _, err := r.deps.Bots.UpsertBotAppPreviewMedia(ctx, next) + if err != nil { + return nil, botAppInvalidErr() + } + return r.tgBotPreviewMedia(ctx, out), nil +} + +func (r *Router) onBotsDeletePreviewMedia(ctx context.Context, req *tg.BotsDeletePreviewMediaRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot) + if err != nil { + return false, err + } + app, err := r.mainBotAppForOwner(ctx, bot.ID) + if err != nil { + return false, err + } + if len(req.Media) == 0 || len(req.Media) > domain.MaxBotPreviewMedia { + return false, mediaInvalidErr() + } + for _, input := range req.Media { + current, err := r.botPreviewMediaIDFromInput(ctx, userID, bot.ID, app.ID, input) + if err != nil { + return false, err + } + if _, err := r.deps.Bots.DeleteBotAppPreviewMedia(ctx, bot.ID, app.ID, current.ID); err != nil { + return false, botAppInvalidErr() + } + } + return true, nil +} + +func (r *Router) onBotsReorderPreviewMedias(ctx context.Context, req *tg.BotsReorderPreviewMediasRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + bot, err := r.resolveOwnedBotUser(ctx, userID, req.Bot) + if err != nil { + return false, err + } + app, err := r.mainBotAppForOwner(ctx, bot.ID) + if err != nil { + return false, err + } + if len(req.Order) == 0 || len(req.Order) > domain.MaxBotPreviewMedia { + return false, mediaInvalidErr() + } + ids := make([]int64, 0, len(req.Order)) + seen := map[int64]bool{} + for _, media := range req.Order { + current, err := r.botPreviewMediaIDFromInput(ctx, userID, bot.ID, app.ID, media) + if err != nil { + return false, err + } + if seen[current.ID] { + return false, mediaInvalidErr() + } + seen[current.ID] = true + ids = append(ids, current.ID) + } + if _, err := r.deps.Bots.ReorderBotAppPreviewMedia(ctx, bot.ID, app.ID, ids); err != nil { + return false, botAppInvalidErr() + } + return true, nil +} + +func (r *Router) onBotsGetPreviewInfo(ctx context.Context, req *tg.BotsGetPreviewInfoRequest) (*tg.BotsPreviewInfo, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot) + if err != nil { + return nil, err + } + app, found, err := r.deps.Bots.GetMainBotApp(ctx, bot.ID) + if err != nil { + return nil, internalErr() + } + if !found { + return &tg.BotsPreviewInfo{Media: []tg.BotPreviewMedia{}, LangCodes: []string{}}, nil + } + media, err := r.botPreviewMedias(ctx, bot.ID, app.ID) + if err != nil { + return nil, err + } + langs := []string{} + if len(media) > 0 { + langs = append(langs, "") + } + return &tg.BotsPreviewInfo{Media: media, LangCodes: langs}, nil +} + +func (r *Router) onBotsGetPreviewMedias(ctx context.Context, bot tg.InputUserClass) ([]tg.BotPreviewMedia, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + botUser, err := r.resolveBotUserForViewer(ctx, userID, bot) + if err != nil { + return nil, err + } + app, found, err := r.deps.Bots.GetMainBotApp(ctx, botUser.ID) + if err != nil { + return nil, internalErr() + } + if !found { + return []tg.BotPreviewMedia{}, nil + } + return r.botPreviewMedias(ctx, botUser.ID, app.ID) +} + +func (r *Router) onBotsUpdateUserEmojiStatus(ctx context.Context, req *tg.BotsUpdateUserEmojiStatusRequest) (bool, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return false, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req.UserID == nil || req.EmojiStatus == nil { + return false, userIDInvalidErr() + } + if r.deps.Bots == nil || r.deps.Users == nil { + return false, userPermissionDeniedErr() + } + target, found, err := r.userFromInput(ctx, userID, req.UserID) + if err != nil { + return false, internalErr() + } + if !found { + return false, userIDInvalidErr() + } + allowed, err := r.deps.Bots.BotEmojiStatusPermission(ctx, botID, target.ID) + if err != nil { + return false, internalErr() + } + if !allowed { + return false, userPermissionDeniedErr() + } + documentID, until, err := botEmojiStatusFromTG(req.EmojiStatus) + if err != nil { + return false, err + } + svc, ok := r.deps.Users.(UserPremiumService) + if !ok { + return false, userPermissionDeniedErr() + } + u, err := svc.UpdateEmojiStatus(ctx, target.ID, documentID, until) + if err != nil { + if errors.Is(err, domain.ErrPremiumRequired) { + return false, tgerr400("PREMIUM_ACCOUNT_REQUIRED") + } + return false, internalErr() + } + r.invalidateRPCProjectionForUser(u.ID) + r.pushUserUpdates(ctx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUserEmojiStatus{ + UserID: u.ID, + EmojiStatus: tgUserEmojiStatus(u, r.clock.Now().Unix()), + }}, + Users: []tg.UserClass{r.tgUser(u)}, + Date: int(r.clock.Now().Unix()), + }) + return true, nil +} + +func (r *Router) onBotsToggleUserEmojiStatusPermission(ctx context.Context, req *tg.BotsToggleUserEmojiStatusPermissionRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Bots == nil { + return false, botInvalidErr() + } + bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot) + if err != nil { + return false, err + } + if err := r.deps.Bots.SetBotEmojiStatusPermission(ctx, bot.ID, userID, req.Enabled); err != nil { + return false, botInvalidErr() + } + return true, nil +} + +func (r *Router) onBotsCheckDownloadFileParams(ctx context.Context, req *tg.BotsCheckDownloadFileParamsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if _, err := r.resolveBotUserForViewer(ctx, userID, req.Bot); err != nil { + return false, err + } + return checkBotDownloadFileParams(ctx, req.FileName, req.URL), nil +} + +func (r *Router) onBotsUpdateStarRefProgram(ctx context.Context, req *tg.BotsUpdateStarRefProgramRequest) (*tg.StarRefProgram, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil { + return nil, err + } + return nil, botInvalidErr() +} + +func (r *Router) onBotsSetCustomVerification(ctx context.Context, req *tg.BotsSetCustomVerificationRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if bot, ok := req.GetBot(); ok { + if _, err := r.resolveOwnedBotUser(ctx, userID, bot); err != nil { + return false, err + } + } else if _, err := r.callerBotID(ctx); err != nil { + return false, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + return false, botVerifierForbiddenErr() +} + +func (r *Router) onBotsGetBotRecommendations(ctx context.Context, _ tg.InputUserClass) (tg.UsersUsersClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return &tg.UsersUsers{Users: []tg.UserClass{}}, nil +} + +func (r *Router) onBotsRequestWebViewButton(ctx context.Context, req *tg.BotsRequestWebViewButtonRequest) (*tg.BotsRequestedButton, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.UserID == nil || req.Button == nil { + return nil, buttonDataInvalidErr() + } + if r.deps.Bots == nil { + return nil, buttonDataInvalidErr() + } + if _, found, err := r.userFromInput(ctx, userID, req.UserID); err != nil || !found { + return nil, userIDInvalidErr() + } + button, err := domainRequestedButtonFromTG(botID, req.UserID, req.Button) + if err != nil { + return nil, err + } + target, found, err := r.userFromInput(ctx, userID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, userIDInvalidErr() + } + button.UserID = target.ID + saved, err := r.deps.Bots.SaveRequestedWebViewButton(ctx, button) + if err != nil { + return nil, buttonDataInvalidErr() + } + return &tg.BotsRequestedButton{WebappReqID: saved.WebAppReqID}, nil +} + +func (r *Router) onBotsGetRequestedWebViewButton(ctx context.Context, req *tg.BotsGetRequestedWebViewButtonRequest) (tg.KeyboardButtonClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + bot, err := r.resolveBotUserForViewer(ctx, userID, req.Bot) + if err != nil { + return nil, err + } + button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, bot.ID, userID, req.WebappReqID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, buttonDataInvalidErr() + } + return tgKeyboardButtonRequestPeer(button), nil +} + +func botEmojiStatusFromTG(status tg.EmojiStatusClass) (documentID int64, until int, err error) { + switch s := status.(type) { + case *tg.EmojiStatusEmpty: + return 0, 0, nil + case *tg.EmojiStatus: + if s.DocumentID < 0 { + return 0, 0, tgerr400("EMOJI_STATUS_INVALID") + } + if v, ok := s.GetUntil(); ok { + until = v + } + return s.DocumentID, until, nil + case *tg.EmojiStatusCollectible: + if s.DocumentID < 0 { + return 0, 0, tgerr400("EMOJI_STATUS_INVALID") + } + if v, ok := s.GetUntil(); ok { + until = v + } + return s.DocumentID, until, nil + default: + return 0, 0, tgerr400("EMOJI_STATUS_INVALID") + } +} + +func (r *Router) mainBotAppForOwner(ctx context.Context, botUserID int64) (domain.BotApp, error) { + app, found, err := r.deps.Bots.GetMainBotApp(ctx, botUserID) + if err != nil { + return domain.BotApp{}, internalErr() + } + if !found { + return domain.BotApp{}, botAppInvalidErr() + } + return app, nil +} + +func (r *Router) botPreviewMediaFromInput(ctx context.Context, userID int64, input tg.InputMediaClass) (domain.BotAppPreviewMedia, error) { + media, err := r.resolveInputMedia(ctx, userID, input) + if err != nil { + return domain.BotAppPreviewMedia{}, err + } + if media == nil { + return domain.BotAppPreviewMedia{}, mediaInvalidErr() + } + switch media.Kind { + case domain.MessageMediaKindPhoto: + if media.Photo == nil || media.Photo.ID == 0 { + return domain.BotAppPreviewMedia{}, mediaInvalidErr() + } + return domain.BotAppPreviewMedia{PhotoID: media.Photo.ID}, nil + case domain.MessageMediaKindDocument: + if media.Document == nil || media.Document.ID == 0 { + return domain.BotAppPreviewMedia{}, mediaInvalidErr() + } + return domain.BotAppPreviewMedia{DocumentID: media.Document.ID}, nil + default: + return domain.BotAppPreviewMedia{}, mediaTypeInvalidErr() + } +} + +func (r *Router) botPreviewMediaIDFromInput(ctx context.Context, userID, botUserID, appID int64, input tg.InputMediaClass) (domain.BotAppPreviewMedia, error) { + target, err := r.botPreviewMediaFromInput(ctx, userID, input) + if err != nil { + return domain.BotAppPreviewMedia{}, err + } + items, err := r.deps.Bots.ListBotAppPreviewMedia(ctx, botUserID, appID) + if err != nil { + return domain.BotAppPreviewMedia{}, internalErr() + } + for _, item := range items { + if target.PhotoID != 0 && item.PhotoID == target.PhotoID { + return item, nil + } + if target.DocumentID != 0 && item.DocumentID == target.DocumentID { + return item, nil + } + } + return domain.BotAppPreviewMedia{}, mediaInvalidErr() +} + +func (r *Router) botPreviewMedias(ctx context.Context, botUserID, appID int64) ([]tg.BotPreviewMedia, error) { + items, err := r.deps.Bots.ListBotAppPreviewMedia(ctx, botUserID, appID) + if err != nil { + return nil, internalErr() + } + out := make([]tg.BotPreviewMedia, 0, len(items)) + for _, item := range items { + converted := r.tgBotPreviewMedia(ctx, item) + if converted != nil { + out = append(out, *converted) + } + } + return out, nil +} + +func (r *Router) tgBotPreviewMedia(ctx context.Context, item domain.BotAppPreviewMedia) *tg.BotPreviewMedia { + out := &tg.BotPreviewMedia{Date: int(r.clock.Now().Unix())} + switch { + case item.PhotoID != 0: + var photo tg.PhotoClass = &tg.PhotoEmpty{ID: item.PhotoID} + if r.deps.Files != nil { + if p, found, err := r.deps.Files.GetPhoto(ctx, item.PhotoID); err == nil && found { + photo = tgPhoto(p) + } + } + out.Media = &tg.MessageMediaPhoto{Photo: photo} + case item.DocumentID != 0: + var doc tg.DocumentClass = &tg.DocumentEmpty{ID: item.DocumentID} + if r.deps.Files != nil { + if d, found, err := r.deps.Files.GetDocument(ctx, item.DocumentID); err == nil && found { + doc = tgDocument(d) + } + } + out.Media = &tg.MessageMediaDocument{Document: doc} + default: + out.Media = &tg.MessageMediaEmpty{} + } + return out +} + +func domainRequestedButtonFromTG(botUserID int64, _ tg.InputUserClass, button tg.KeyboardButtonClass) (domain.BotRequestedWebViewButton, error) { + var out domain.BotRequestedWebViewButton + out.BotUserID = botUserID + switch b := button.(type) { + case *tg.InputKeyboardButtonRequestPeer: + out.ButtonID = b.ButtonID + out.Text = strings.TrimSpace(b.Text) + out.PeerType = requestPeerTypeName(b.PeerType) + out.MaxQuantity = b.MaxQuantity + case *tg.KeyboardButtonRequestPeer: + out.ButtonID = b.ButtonID + out.Text = strings.TrimSpace(b.Text) + out.PeerType = requestPeerTypeName(b.PeerType) + out.MaxQuantity = b.MaxQuantity + default: + return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() + } + if out.ButtonID == 0 || out.Text == "" || out.PeerType == "" { + return domain.BotRequestedWebViewButton{}, buttonDataInvalidErr() + } + return out, nil +} + +func requestPeerTypeName(peerType tg.RequestPeerTypeClass) string { + switch peerType.(type) { + case *tg.RequestPeerTypeUser: + return "user" + case *tg.RequestPeerTypeChat: + return "chat" + case *tg.RequestPeerTypeBroadcast: + return "broadcast" + default: + return "" + } +} + +func tgKeyboardButtonRequestPeer(button domain.BotRequestedWebViewButton) tg.KeyboardButtonClass { + return &tg.KeyboardButtonRequestPeer{ + Text: button.Text, + ButtonID: button.ButtonID, + PeerType: tgRequestPeerType(button.PeerType), + MaxQuantity: button.MaxQuantity, + } +} + +func tgRequestPeerType(kind string) tg.RequestPeerTypeClass { + switch kind { + case "chat": + return &tg.RequestPeerTypeChat{} + case "broadcast": + return &tg.RequestPeerTypeBroadcast{} + default: + return &tg.RequestPeerTypeUser{} + } +} + +const maxBotDownloadBytes = 25 << 20 + +func checkBotDownloadFileParams(ctx context.Context, fileName, rawURL string) bool { + fileName = strings.TrimSpace(fileName) + if fileName == "" || len(fileName) > 128 || filepath.Base(fileName) != fileName { + return false + } + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || len(rawURL) > domain.MaxBotAppURLLen { + return false + } + if !downloadURLHostAllowed(ctx, parsed) || !downloadExtensionAllowed(fileName, parsed.Path) { + return false + } + reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodHead, parsed.String(), nil) + if err != nil { + return false + } + client := &http.Client{ + Timeout: 3 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 || req.URL == nil || req.URL.Scheme != "https" || !downloadURLHostAllowed(req.Context(), req.URL) { + return http.ErrUseLastResponse + } + return nil + }, + } + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + return false + } + if resp.ContentLength > maxBotDownloadBytes { + return false + } + if ct := strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])); ct != "" && !downloadMIMEAllowed(ct) { + return false + } + return true +} + +func downloadURLHostAllowed(ctx context.Context, u *url.URL) bool { + host := u.Hostname() + if host == "" || strings.EqualFold(host, "localhost") { + return false + } + if ip := net.ParseIP(host); ip != nil { + return publicDownloadIP(ip) + } + lookupCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond) + defer cancel() + addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) + if err != nil || len(addrs) == 0 { + return false + } + for _, addr := range addrs { + if !publicDownloadIP(addr.IP) { + return false + } + } + return true +} + +func publicDownloadIP(ip net.IP) bool { + if ip == nil { + return false + } + return !(ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast()) +} + +func downloadExtensionAllowed(fileName, urlPath string) bool { + ext := strings.ToLower(filepath.Ext(fileName)) + if ext == "" { + ext = strings.ToLower(filepath.Ext(urlPath)) + } + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".pdf", ".txt", ".json", ".zip": + return true + default: + return false + } +} + +func downloadMIMEAllowed(mime string) bool { + if strings.HasPrefix(mime, "image/") || strings.HasPrefix(mime, "video/") { + return true + } + switch mime { + case "application/pdf", "application/json", "application/zip", "application/octet-stream", "text/plain": + return true + default: + return false + } +} + +func (r *Router) onBotsGetAccessSettings(ctx context.Context, bot tg.InputUserClass) (*tg.BotsAccessSettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.resolveBotUserForViewer(ctx, userID, bot); err != nil { + return nil, err + } + return &tg.BotsAccessSettings{}, nil +} + +func (r *Router) onBotsEditAccessSettings(ctx context.Context, req *tg.BotsEditAccessSettingsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if _, err := r.resolveOwnedBotUser(ctx, userID, req.Bot); err != nil { + return false, err + } + return false, botInvalidErr() +} diff --git a/internal/rpc/bots_longtail_rpc_test.go b/internal/rpc/bots_longtail_rpc_test.go new file mode 100644 index 00000000..9c3187ce --- /dev/null +++ b/internal/rpc/bots_longtail_rpc_test.go @@ -0,0 +1,316 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + + "telesrv/internal/domain" +) + +func TestBotsLongtailReadStubsReturnEmptyFacts(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + userCtx := WithUserID(ctx, f.owner.ID) + + can, err := f.router.onBotsCanSendMessage(userCtx, inputUser(f.bot)) + if err != nil || can { + t.Fatalf("canSendMessage = %v,%v, want false,nil", can, err) + } + + popular, err := f.router.onBotsGetPopularAppBots(userCtx, &tg.BotsGetPopularAppBotsRequest{Limit: 20}) + if err != nil { + t.Fatalf("getPopularAppBots: %v", err) + } + if next, ok := popular.GetNextOffset(); ok || next != "" || len(popular.Users) != 0 { + t.Fatalf("popular app bots = %+v, want empty without next offset", popular) + } + + info, err := f.router.onBotsGetPreviewInfo(userCtx, &tg.BotsGetPreviewInfoRequest{ + Bot: inputUser(f.bot), + LangCode: "en", + }) + if err != nil { + t.Fatalf("getPreviewInfo: %v", err) + } + if len(info.Media) != 0 || len(info.LangCodes) != 0 { + t.Fatalf("preview info = %+v, want empty", info) + } + + medias, err := f.router.onBotsGetPreviewMedias(userCtx, inputUser(f.bot)) + if err != nil { + t.Fatalf("getPreviewMedias: %v", err) + } + if len(medias) != 0 { + t.Fatalf("preview medias len = %d, want 0", len(medias)) + } + + access, err := f.router.onBotsGetAccessSettings(userCtx, inputUser(f.bot)) + if err != nil { + t.Fatalf("getAccessSettings: %v", err) + } + if access.GetRestricted() { + t.Fatalf("access settings = %+v, want unrestricted empty", access) + } + + recs, err := f.router.onBotsGetBotRecommendations(userCtx, inputUser(f.bot)) + if err != nil { + t.Fatalf("getBotRecommendations: %v", err) + } + if got := len(recs.(*tg.UsersUsers).Users); got != 0 { + t.Fatalf("recommendations len = %d, want 0", got) + } + + recs, err = f.router.onBotsGetBotRecommendations(userCtx, inputUser(f.owner)) + if err != nil { + t.Fatalf("getBotRecommendations non-bot target: %v", err) + } + if got := len(recs.(*tg.UsersUsers).Users); got != 0 { + t.Fatalf("recommendations for non-bot target len = %d, want 0", got) + } +} + +func TestBotsWriteAccessAllowSendMessageRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + userCtx := WithUserID(ctx, f.owner.ID) + + can, err := f.router.onBotsCanSendMessage(userCtx, inputUser(f.bot)) + if err != nil || can { + t.Fatalf("can before allow = %v,%v, want false,nil", can, err) + } + updatesClass, err := f.router.onBotsAllowSendMessage(userCtx, inputUser(f.bot)) + if err != nil { + t.Fatalf("allowSendMessage: %v", err) + } + updates := updatesClass.(*tg.Updates) + if len(updates.Updates) != 1 { + t.Fatalf("allow updates len = %d, want one service message", len(updates.Updates)) + } + newMessage, ok := updates.Updates[0].(*tg.UpdateNewMessage) + if !ok { + t.Fatalf("allow update = %T, want UpdateNewMessage", updates.Updates[0]) + } + service, ok := newMessage.Message.(*tg.MessageService) + if !ok { + t.Fatalf("allow message = %T, want MessageService", newMessage.Message) + } + action, ok := service.Action.(*tg.MessageActionBotAllowed) + if !ok || !action.FromRequest { + t.Fatalf("allow action = %+v (%T), want messageActionBotAllowed from_request", service.Action, service.Action) + } + if service.PeerID.(*tg.PeerUser).UserID != f.bot.ID || service.FromID.(*tg.PeerUser).UserID != f.owner.ID { + t.Fatalf("allow service peer/from = %+v/%+v, want bot/user", service.PeerID, service.FromID) + } + + can, err = f.router.onBotsCanSendMessage(userCtx, inputUser(f.bot)) + if err != nil || !can { + t.Fatalf("can after allow = %v,%v, want true,nil", can, err) + } + repeat, err := f.router.onBotsAllowSendMessage(userCtx, inputUser(f.bot)) + if err != nil { + t.Fatalf("repeat allowSendMessage: %v", err) + } + if got := len(repeat.(*tg.Updates).Updates); got != 0 { + t.Fatalf("repeat allow updates len = %d, want empty", got) + } + history, err := f.router.deps.Messages.GetHistory(ctx, f.owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.bot.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("history: %v", err) + } + serviceMessages := 0 + for _, msg := range history.Messages { + if msg.Media != nil && msg.Media.ServiceAction != nil && msg.Media.ServiceAction.Kind == domain.MessageServiceActionBotAllowed { + serviceMessages++ + } + } + if serviceMessages != 1 { + t.Fatalf("bot allowed service messages = %d, want 1", serviceMessages) + } +} + +func TestBotsLongtailRejectsMissingState(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + userCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + if _, err := f.router.onBotsInvokeWebViewCustomMethod(userCtx, &tg.BotsInvokeWebViewCustomMethodRequest{ + Bot: inputUser(f.bot), + CustomMethod: "unsupported", + Params: tg.DataJSON{Data: "{}"}, + }); !tgerr.Is(err, "METHOD_INVALID") { + t.Fatalf("invoke custom method err = %v, want METHOD_INVALID", err) + } + if _, err := f.router.onBotsAddPreviewMedia(userCtx, &tg.BotsAddPreviewMediaRequest{ + Bot: inputUser(f.bot), + Media: &tg.InputMediaEmpty{}, + }); !tgerr.Is(err, "BOT_APP_INVALID") { + t.Fatalf("add preview media err = %v, want BOT_APP_INVALID", err) + } + if ok, err := f.router.onBotsDeletePreviewMedia(userCtx, &tg.BotsDeletePreviewMediaRequest{ + Bot: inputUser(f.bot), + Media: []tg.InputMediaClass{&tg.InputMediaEmpty{}}, + }); ok || !tgerr.Is(err, "BOT_APP_INVALID") { + t.Fatalf("delete preview media = %v,%v, want false,BOT_APP_INVALID", ok, err) + } + if _, err := f.router.onBotsGetRequestedWebViewButton(userCtx, &tg.BotsGetRequestedWebViewButtonRequest{ + Bot: inputUser(f.bot), + WebappReqID: "req", + }); !tgerr.Is(err, "BUTTON_DATA_INVALID") { + t.Fatalf("get requested button err = %v, want BUTTON_DATA_INVALID", err) + } + if ok, err := f.router.onBotsCheckDownloadFileParams(userCtx, &tg.BotsCheckDownloadFileParamsRequest{ + Bot: inputUser(f.bot), + FileName: "file.txt", + URL: "https://127.0.0.1/file.txt", + }); ok || err != nil { + t.Fatalf("check download params = %v,%v, want false,nil", ok, err) + } + if ok, err := f.router.onBotsUpdateUserEmojiStatus(botCtx, &tg.BotsUpdateUserEmojiStatusRequest{ + UserID: inputUser(f.owner), + EmojiStatus: &tg.EmojiStatusEmpty{}, + }); ok || !tgerr.Is(err, "USER_PERMISSION_DENIED") { + t.Fatalf("update emoji status = %v,%v, want false,USER_PERMISSION_DENIED", ok, err) + } + if ok, err := f.router.onBotsToggleUserEmojiStatusPermission(userCtx, &tg.BotsToggleUserEmojiStatusPermissionRequest{ + Bot: inputUser(f.bot), + Enabled: true, + }); !ok || err != nil { + t.Fatalf("toggle emoji permission = %v,%v, want true,nil", ok, err) + } + if ok, err := f.router.onBotsUpdateUserEmojiStatus(botCtx, &tg.BotsUpdateUserEmojiStatusRequest{ + UserID: inputUser(f.owner), + EmojiStatus: &tg.EmojiStatusEmpty{}, + }); !ok || err != nil { + t.Fatalf("update emoji status after permission = %v,%v, want true,nil", ok, err) + } + if _, err := f.router.onBotsSendCustomRequest(botCtx, &tg.BotsSendCustomRequestRequest{ + CustomMethod: "unsupported", + Params: tg.DataJSON{Data: "{}"}, + }); !tgerr.Is(err, "METHOD_INVALID") { + t.Fatalf("send custom request err = %v, want METHOD_INVALID", err) + } + if ok, err := f.router.onBotsAnswerWebhookJSONQuery(botCtx, &tg.BotsAnswerWebhookJSONQueryRequest{ + QueryID: 1, + Data: tg.DataJSON{Data: "{}"}, + }); ok || !tgerr.Is(err, "QUERY_ID_INVALID") { + t.Fatalf("answer webhook json query = %v,%v, want false,QUERY_ID_INVALID", ok, err) + } +} + +func TestBotsLongtailCommercialAndSettingsStubs(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + userCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + if ok, err := f.router.onBotsSetBotBroadcastDefaultAdminRights(botCtx, tg.ChatAdminRights{}); ok || !tgerr.Is(err, "RIGHTS_NOT_MODIFIED") { + t.Fatalf("broadcast default rights = %v,%v, want false,RIGHTS_NOT_MODIFIED", ok, err) + } + if ok, err := f.router.onBotsSetBotGroupDefaultAdminRights(botCtx, tg.ChatAdminRights{}); ok || !tgerr.Is(err, "RIGHTS_NOT_MODIFIED") { + t.Fatalf("group default rights = %v,%v, want false,RIGHTS_NOT_MODIFIED", ok, err) + } + if ok, err := f.router.onBotsReorderUsernames(userCtx, &tg.BotsReorderUsernamesRequest{ + Bot: inputUser(f.bot), + Order: []string{f.bot.Username}, + }); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") { + t.Fatalf("reorder usernames = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err) + } + if ok, err := f.router.onBotsToggleUsername(userCtx, &tg.BotsToggleUsernameRequest{ + Bot: inputUser(f.bot), + Username: f.bot.Username, + Active: true, + }); ok || !tgerr.Is(err, "USERNAME_NOT_MODIFIED") { + t.Fatalf("toggle username = %v,%v, want false,USERNAME_NOT_MODIFIED", ok, err) + } + if _, err := f.router.onBotsUpdateStarRefProgram(userCtx, &tg.BotsUpdateStarRefProgramRequest{ + Bot: inputUser(f.bot), + CommissionPermille: 100, + }); !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("update star ref program err = %v, want BOT_INVALID", err) + } + if ok, err := f.router.onBotsSetCustomVerification(userCtx, func() *tg.BotsSetCustomVerificationRequest { + req := &tg.BotsSetCustomVerificationRequest{Peer: inputPeerUser(f.peer)} + req.SetBot(inputUser(f.bot)) + req.SetEnabled(true) + return req + }()); ok || !tgerr.Is(err, "BOT_VERIFIER_FORBIDDEN") { + t.Fatalf("set custom verification = %v,%v, want false,BOT_VERIFIER_FORBIDDEN", ok, err) + } + if ok, err := f.router.onBotsEditAccessSettings(userCtx, &tg.BotsEditAccessSettingsRequest{ + Bot: inputUser(f.bot), + }); ok || !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("edit access settings = %v,%v, want false,BOT_INVALID", ok, err) + } + if _, err := f.router.onBotsRequestWebViewButton(botCtx, &tg.BotsRequestWebViewButtonRequest{ + UserID: inputUser(f.owner), + Button: &tg.KeyboardButtonSimpleWebView{ + Text: "Open", + URL: "https://example.com/app", + }, + }); !tgerr.Is(err, "BUTTON_DATA_INVALID") { + t.Fatalf("request webview button err = %v, want BUTTON_DATA_INVALID", err) + } +} + +func TestBotsLongtailExplicitStubsAreRegistered(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + userCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + userRequests := []struct { + name string + req bin.Encoder + want string + }{ + {name: "invokeWebViewCustomMethod", req: &tg.BotsInvokeWebViewCustomMethodRequest{Bot: inputUser(f.bot), CustomMethod: "x", Params: tg.DataJSON{Data: "{}"}}, want: "METHOD_INVALID"}, + {name: "addPreviewMedia", req: &tg.BotsAddPreviewMediaRequest{Bot: inputUser(f.bot), Media: &tg.InputMediaEmpty{}}, want: "BOT_APP_INVALID"}, + {name: "getRequestedWebViewButton", req: &tg.BotsGetRequestedWebViewButtonRequest{Bot: inputUser(f.bot), WebappReqID: "req"}, want: "BUTTON_DATA_INVALID"}, + {name: "reorderUsernames", req: &tg.BotsReorderUsernamesRequest{Bot: inputUser(f.bot), Order: []string{f.bot.Username}}, want: "USERNAME_NOT_MODIFIED"}, + {name: "toggleUsername", req: &tg.BotsToggleUsernameRequest{Bot: inputUser(f.bot), Username: f.bot.Username, Active: true}, want: "USERNAME_NOT_MODIFIED"}, + {name: "editAccessSettings", req: &tg.BotsEditAccessSettingsRequest{Bot: inputUser(f.bot)}, want: "BOT_INVALID"}, + } + for _, tt := range userRequests { + t.Run(tt.name, func(t *testing.T) { + var in bin.Buffer + if err := tt.req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := f.router.Dispatch(userCtx, [8]byte{}, 0, &in); !tgerr.Is(err, tt.want) { + t.Fatalf("dispatch err = %v, want %s", err, tt.want) + } + }) + } + + botRequests := []struct { + name string + req bin.Encoder + want string + }{ + {name: "sendCustomRequest", req: &tg.BotsSendCustomRequestRequest{CustomMethod: "x", Params: tg.DataJSON{Data: "{}"}}, want: "METHOD_INVALID"}, + {name: "answerWebhookJSONQuery", req: &tg.BotsAnswerWebhookJSONQueryRequest{QueryID: 1, Data: tg.DataJSON{Data: "{}"}}, want: "QUERY_ID_INVALID"}, + {name: "setBotBroadcastDefaultAdminRights", req: &tg.BotsSetBotBroadcastDefaultAdminRightsRequest{AdminRights: tg.ChatAdminRights{}}, want: "RIGHTS_NOT_MODIFIED"}, + {name: "setBotGroupDefaultAdminRights", req: &tg.BotsSetBotGroupDefaultAdminRightsRequest{AdminRights: tg.ChatAdminRights{}}, want: "RIGHTS_NOT_MODIFIED"}, + {name: "updateUserEmojiStatus", req: &tg.BotsUpdateUserEmojiStatusRequest{UserID: inputUser(f.owner), EmojiStatus: &tg.EmojiStatusEmpty{}}, want: "USER_PERMISSION_DENIED"}, + } + for _, tt := range botRequests { + t.Run(tt.name, func(t *testing.T) { + var in bin.Buffer + if err := tt.req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := f.router.Dispatch(botCtx, [8]byte{}, 0, &in); !tgerr.Is(err, tt.want) { + t.Fatalf("dispatch err = %v, want %s", err, tt.want) + } + }) + } +} diff --git a/internal/rpc/bots_rpc_test.go b/internal/rpc/bots_rpc_test.go new file mode 100644 index 00000000..ff192de5 --- /dev/null +++ b/internal/rpc/bots_rpc_test.go @@ -0,0 +1,142 @@ +package rpc + +import ( + "context" + "strconv" + "strings" + "testing" + + "go.uber.org/zap/zaptest" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + + botsapp "telesrv/internal/app/bots" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func newBotRPCTestRouter(t *testing.T) (*Router, *botsapp.Service, *memory.UserStore, domain.User, domain.User) { + t.Helper() + ctx := context.Background() + users := memory.NewUserStore() + botStore := memory.NewBotStore(users) + dialogs := memory.NewDialogStore() + messages := memory.NewMessageStore(dialogs) + svc := botsapp.NewService(users, botStore, messages) + owner, err := users.Create(ctx, domain.User{AccessHash: 5101, Phone: "15550005101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + manager, _, err := svc.CreateBot(ctx, owner.ID, "Manager Bot", "manager_test_bot") + if err != nil { + t.Fatalf("create manager bot: %v", err) + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(users), + Bots: svc, + }, zaptest.NewLogger(t), clock.System) + return r, svc, users, owner, manager +} + +func TestBotsManagedCreateAndTokenRPCs(t *testing.T) { + ctx := context.Background() + r, svc, _, owner, manager := newBotRPCTestRouter(t) + ownerCtx := WithUserID(ctx, owner.ID) + + if ok, err := r.onBotsCheckUsername(ownerCtx, "fresh_rpc_bot"); err != nil || !ok { + t.Fatalf("check free username = %v,%v, want true,nil", ok, err) + } + + createdClass, err := r.onBotsCreateBot(ownerCtx, &tg.BotsCreateBotRequest{ + Name: "Created Bot", + Username: "fresh_rpc_bot", + ManagerID: &tg.InputUser{UserID: manager.ID, AccessHash: manager.AccessHash}, + }) + if err != nil { + t.Fatalf("create bot: %v", err) + } + created := createdClass.(*tg.User) + version, hasVersion := created.GetBotInfoVersion() + if !created.Bot || !hasVersion || version < 1 || created.Username != "fresh_rpc_bot" { + t.Fatalf("created user = %+v, want bot with version and username", created) + } + + if ok, err := r.onBotsCheckUsername(ownerCtx, "fresh_rpc_bot"); err != nil || ok { + t.Fatalf("check occupied username = %v,%v, want false,nil", ok, err) + } + + admined, err := r.onBotsGetAdminedBots(ownerCtx) + if err != nil { + t.Fatalf("get admined bots: %v", err) + } + if len(admined) != 2 { + t.Fatalf("admined bots len = %d, want manager+created", len(admined)) + } + + token, err := r.onBotsExportBotToken(ownerCtx, &tg.BotsExportBotTokenRequest{ + Bot: &tg.InputUser{UserID: created.ID, AccessHash: created.AccessHash}, + Revoke: false, + }) + if err != nil { + t.Fatalf("export token: %v", err) + } + if !strings.HasPrefix(token.Token, "1") || !strings.Contains(token.Token, ":") { + t.Fatalf("token = %q, want :", token.Token) + } + if !strings.HasPrefix(token.Token, strconv.FormatInt(created.ID, 10)+":") { + t.Fatalf("token = %q, want bot id prefix %d", token.Token, created.ID) + } + + rotated, err := r.onBotsExportBotToken(ownerCtx, &tg.BotsExportBotTokenRequest{ + Bot: &tg.InputUser{UserID: created.ID, AccessHash: created.AccessHash}, + Revoke: true, + }) + if err != nil { + t.Fatalf("export revoked token: %v", err) + } + if rotated.Token == token.Token { + t.Fatalf("revoke kept token %q", token.Token) + } + profile, found, err := svc.BotInfo(ctx, created.ID) + if err != nil || !found { + t.Fatalf("bot info: found=%v err=%v", found, err) + } + if domain.FormatBotToken(created.ID, profile.TokenSecret) != rotated.Token { + t.Fatalf("stored token = %q, exported %q", domain.FormatBotToken(created.ID, profile.TokenSecret), rotated.Token) + } +} + +func TestBotsCreateBotRPCErrorMapping(t *testing.T) { + ctx := context.Background() + r, _, users, owner, _ := newBotRPCTestRouter(t) + ownerCtx := WithUserID(ctx, owner.ID) + + if _, err := r.onBotsCreateBot(ownerCtx, &tg.BotsCreateBotRequest{ + Name: "Bad Manager", + Username: "bad_manager_bot", + ManagerID: &tg.InputUserSelf{}, + }); err == nil || !strings.Contains(err.Error(), "MANAGER_PERMISSION_MISSING") { + t.Fatalf("bad manager err = %v, want MANAGER_PERMISSION_MISSING", err) + } + + if _, err := r.onBotsCreateBot(ownerCtx, &tg.BotsCreateBotRequest{ + Name: "Bad Username", + Username: "notvalid", + ManagerID: &tg.InputUser{UserID: domain.BotFatherUserID}, + }); err == nil || !strings.Contains(err.Error(), "USERNAME_INVALID") { + t.Fatalf("bad username err = %v, want USERNAME_INVALID", err) + } + + other, err := users.Create(ctx, domain.User{AccessHash: 5102, Phone: "15550005102", FirstName: "Other"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + if _, err := r.onBotsExportBotToken(WithUserID(ctx, other.ID), &tg.BotsExportBotTokenRequest{ + Bot: &tg.InputUser{UserID: domain.BotFatherUserID}, + Revoke: false, + }); err == nil || !strings.Contains(err.Error(), "BOT_INVALID") { + t.Fatalf("non-owned export err = %v, want BOT_INVALID", err) + } +} diff --git a/internal/rpc/bots_startbot.go b/internal/rpc/bots_startbot.go new file mode 100644 index 00000000..2f14a350 --- /dev/null +++ b/internal/rpc/bots_startbot.go @@ -0,0 +1,64 @@ +package rpc + +import ( + "context" + "unicode/utf8" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + + "telesrv/internal/domain" +) + +func startParamInvalidErr() error { return tgerr.New(400, "START_PARAM_INVALID") } + +// onMessagesStartBot 处理 messages.startBot(深链 telesrv.net/bot?start=payload 与「启动 bot」 +// 入口)。语义:向 bot 发送一条可见的 "/start" 或 "/start " 普通私聊消息,走标准 +// SendPrivateText 双盒+outbox,返回真实 Updates(I7)。bot 经此收到 start_param。 +// P3 仅私聊启动;peer 为群(加 bot 进群)后移(P4,群内 bot)。 +func (r *Router) onMessagesStartBot(ctx context.Context, req *tg.MessagesStartBotRequest) (tg.UpdatesClass, error) { + if req.RandomID == 0 { + return nil, randomIDEmptyErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + if utf8.RuneCountInString(req.StartParam) > domain.MaxStartParamLen { + return nil, startParamInvalidErr() + } + bot, found, err := r.userFromInput(ctx, userID, req.Bot) + if err != nil { + return nil, botInvalidErr() + } + if !found || !bot.Bot { + return nil, botInvalidErr() + } + // P3 仅私聊启动:peer 必须解析为该 bot(深链 start 的 peer=bot)。群启动(加 bot + // 进群)属 P4 群内 bot,此处拒绝而非静默改写语义。 + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeUser || peer.ID != bot.ID { + return nil, peerIDInvalidErr() + } + if err := r.checkSendRateLimit(ctx, userID, 1); err != nil { + return nil, err + } + body := "/start" + if req.StartParam != "" { + body += " " + req.StartParam + } + updates, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{ + randomID: req.RandomID, + message: body, + }) + if err != nil { + return nil, err + } + return updates, nil +} diff --git a/internal/rpc/callback_registry.go b/internal/rpc/callback_registry.go new file mode 100644 index 00000000..436fc6bd --- /dev/null +++ b/internal/rpc/callback_registry.go @@ -0,0 +1,127 @@ +package rpc + +import ( + "crypto/rand" + "encoding/binary" + "hash/fnv" + "sync" + "sync/atomic" + + "telesrv/internal/domain" +) + +// callbackRegistry 是 bot callback query 的进程内挂起表:messages.getBotCallbackAnswer +// 注册一个 (query_id → chan),把 updateBotCallbackQuery 推给 bot 后阻塞等待;bot 经 +// messages.setBotCallbackAnswer 用同一 query_id 解挂。单实例可行;多实例需共享通道 +// (getBotCallbackAnswer 与 setBotCallbackAnswer 落不同实例则等不到 → 超时),记架构 todo。 +type callbackRegistry struct { + mu sync.Mutex + pending map[int64]*pendingCallback +} + +type pendingCallback struct { + ch chan domain.BotCallbackAnswer + done chan struct{} // deregister 时关闭:唤醒超时退出的等待者,消除「答案投递到已离开 select 的 ch」竞态 + botUserID int64 + userID int64 +} + +func newCallbackRegistry() *callbackRegistry { + return &callbackRegistry{pending: make(map[int64]*pendingCallback)} +} + +// register 登记一次挂起的 callback,返回全局唯一 query_id 与接收通道。调用方必须 +// defer deregister(queryID),无论是否收到答案(超时三件套之一,防 goroutine/表泄漏)。 +func (c *callbackRegistry) register(botUserID, userID int64) (int64, *pendingCallback) { + p := &pendingCallback{ + ch: make(chan domain.BotCallbackAnswer, 1), + done: make(chan struct{}), + botUserID: botUserID, + userID: userID, + } + c.mu.Lock() + defer c.mu.Unlock() + var queryID int64 + for { + queryID = randomNonZeroInt64() + if _, exists := c.pending[queryID]; !exists { + break + } + } + c.pending[queryID] = p + return queryID, p +} + +// deregister 移除挂起条目并关闭 done(超时/解挂后必调,幂等)。关闭 done 让仍在 +// select 的等待者立即醒来,避免 resolve 把答案投递到一个等待者已离开的 ch(TOCTOU)。 +func (c *callbackRegistry) deregister(queryID int64) { + c.mu.Lock() + if p, ok := c.pending[queryID]; ok { + delete(c.pending, queryID) + close(p.done) + } + c.mu.Unlock() +} + +// size 返回当前挂起条目数(测试用:断言超时/解挂后归零、无泄漏)。 +func (c *callbackRegistry) size() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.pending) +} + +// resolve 把 bot 的答案投递给等待者。鉴权:仅该 query 的属主 bot 可解挂(callerBotID +// 必须等于注册时的 botUserID,I6)。返回是否成功投递(query 未注册/已超时/非属主 → false)。 +func (c *callbackRegistry) resolve(callerBotID, queryID int64, ans domain.BotCallbackAnswer) bool { + c.mu.Lock() + p, ok := c.pending[queryID] + if !ok || p.botUserID != callerBotID { + c.mu.Unlock() + return false + } + delete(c.pending, queryID) + c.mu.Unlock() + // ch 有 1 容量缓冲,非阻塞投递;等待者已超时退出时缓冲被 GC,不阻塞。 + select { + case p.ch <- ans: + default: + } + return true +} + +// randomNonZeroInt64 取密码学随机非零 int64。register 在持锁下调用,故此处禁止 +// 无限重试——熵源异常时退化为单调序列兜底(query_id 只需进程内唯一,register 的 +// 撞键复核会再保证唯一性),绝不卡住整个 registry。 +func randomNonZeroInt64() int64 { + var buf [8]byte + for i := 0; i < 8; i++ { + if _, err := rand.Read(buf[:]); err != nil { + break + } + if v := int64(binary.LittleEndian.Uint64(buf[:])); v != 0 { + return v + } + } + if v := callbackFallbackSeq.Add(1); v != 0 { + return v + } + return 1 +} + +// callbackFallbackSeq 是熵源失败时的单调兜底序列(极罕见路径)。 +var callbackFallbackSeq atomic.Int64 + +// chatInstanceFor 为 (bot,user) 私聊派生稳定的 chat_instance(同一对话多次 callback +// 间恒定,I8)。当前用确定性 hash 派生(持久化记 todo)。 +func chatInstanceFor(botUserID, userID int64) int64 { + h := fnv.New64a() + var buf [16]byte + binary.LittleEndian.PutUint64(buf[0:8], uint64(botUserID)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(userID)) + _, _ = h.Write(buf[:]) + v := int64(h.Sum64()) + if v == 0 { + v = 1 + } + return v +} diff --git a/internal/rpc/callback_registry_test.go b/internal/rpc/callback_registry_test.go new file mode 100644 index 00000000..d501fc09 --- /dev/null +++ b/internal/rpc/callback_registry_test.go @@ -0,0 +1,77 @@ +package rpc + +import ( + "testing" + + "telesrv/internal/domain" +) + +func TestCallbackRegistryResolveDelivers(t *testing.T) { + reg := newCallbackRegistry() + queryID, p := reg.register(100, 200) + if queryID == 0 { + t.Fatal("query id must be non-zero") + } + want := domain.BotCallbackAnswer{Alert: true, Message: "hi"} + if !reg.resolve(100, queryID, want) { + t.Fatal("resolve by owner bot must succeed") + } + select { + case got := <-p.ch: + if got != want { + t.Fatalf("delivered %+v, want %+v", got, want) + } + default: + t.Fatal("answer not delivered to channel") + } + // 解挂后条目已删除:再次 resolve 失败、registry 计数归零。 + if reg.resolve(100, queryID, want) { + t.Fatal("second resolve must fail (already deregistered)") + } + if n := reg.size(); n != 0 { + t.Fatalf("registry size = %d, want 0 after resolve", n) + } +} + +func TestCallbackRegistryNonOwnerRejected(t *testing.T) { + reg := newCallbackRegistry() + queryID, p := reg.register(100, 200) + // 非属主 bot(999)不得解挂(I6)。 + if reg.resolve(999, queryID, domain.BotCallbackAnswer{Message: "evil"}) { + t.Fatal("non-owner resolve must be rejected") + } + select { + case <-p.ch: + t.Fatal("non-owner answer must not be delivered") + default: + } + if n := reg.size(); n != 1 { + t.Fatalf("registry size = %d, want 1 (entry retained after rejected resolve)", n) + } + reg.deregister(queryID) + if n := reg.size(); n != 0 { + t.Fatalf("registry size = %d, want 0 after deregister", n) + } +} + +func TestCallbackRegistryUnknownQuery(t *testing.T) { + reg := newCallbackRegistry() + if reg.resolve(100, 12345, domain.BotCallbackAnswer{}) { + t.Fatal("resolve of unregistered query must fail") + } +} + +func TestCallbackRegistryUniqueQueryIDs(t *testing.T) { + reg := newCallbackRegistry() + seen := make(map[int64]struct{}) + for i := 0; i < 1000; i++ { + q, _ := reg.register(1, 2) + if _, dup := seen[q]; dup { + t.Fatalf("duplicate query id %d", q) + } + seen[q] = struct{}{} + } + if n := reg.size(); n != 1000 { + t.Fatalf("registry size = %d, want 1000", n) + } +} diff --git a/internal/rpc/channel_can_view_participants_test.go b/internal/rpc/channel_can_view_participants_test.go new file mode 100644 index 00000000..5e33e3b4 --- /dev/null +++ b/internal/rpc/channel_can_view_participants_test.go @@ -0,0 +1,46 @@ +package rpc + +import ( + "testing" + + "telesrv/internal/domain" +) + +// TestChannelFullCanViewParticipants 锁定:广播频道的订阅者列表仅管理员可见(官方语义), +// 非管理员订阅者拿到 can_view_participants=false——否则 DrKLO Profile 会冒出 +// Subscribers/Administrators/Channel Settings 三行。超级群成员可见(隐藏成员时仅管理员)。 +func TestChannelFullCanViewParticipants(t *testing.T) { + broadcast := domain.Channel{ID: 1, Broadcast: true} + megagroup := domain.Channel{ID: 2, Megagroup: true} + megagroupHidden := domain.Channel{ID: 3, Megagroup: true, ParticipantsHidden: true} + + cases := []struct { + name string + ch domain.Channel + role domain.ChannelMemberRole + want bool + }{ + {"broadcast-subscriber", broadcast, domain.ChannelRoleMember, false}, + {"broadcast-admin", broadcast, domain.ChannelRoleAdmin, true}, + {"broadcast-creator", broadcast, domain.ChannelRoleCreator, true}, + {"megagroup-member", megagroup, domain.ChannelRoleMember, true}, + {"megagroup-admin", megagroup, domain.ChannelRoleAdmin, true}, + {"megagroup-hidden-member", megagroupHidden, domain.ChannelRoleMember, false}, + {"megagroup-hidden-admin", megagroupHidden, domain.ChannelRoleAdmin, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + view := domain.ChannelView{ + Channel: tc.ch, + Self: domain.ChannelMember{ + ChannelID: tc.ch.ID, + Role: tc.role, + Status: domain.ChannelMemberActive, + }, + } + if got := tgChannelFull(view).CanViewParticipants; got != tc.want { + t.Fatalf("CanViewParticipants = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/rpc/channel_fanout_classifier_test.go b/internal/rpc/channel_fanout_classifier_test.go new file mode 100644 index 00000000..d5346ad3 --- /dev/null +++ b/internal/rpc/channel_fanout_classifier_test.go @@ -0,0 +1,173 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// channelUpdateWirePts 返回一条 channel update 的 channel pts(若该 TL update 携带 channel pts)。 +// 只有携带 channel pts 的 update 会推进客户端 channel PtsWaiter,是设计 §5 classifier 第一类 +// 「channel-payload-pts durable」的判据;其余(participant=qts、channel state=无 pts)不携带。 +func channelUpdateWirePts(u tg.UpdateClass) (int, bool) { + switch v := u.(type) { + case *tg.UpdateNewChannelMessage: + return v.Pts, true + case *tg.UpdateEditChannelMessage: + return v.Pts, true + case *tg.UpdateDeleteChannelMessages: + return v.Pts, true + case *tg.UpdatePinnedChannelMessages: + return v.Pts, true + default: + return 0, false + } +} + +// TestChannelUpdateEventTypePtsClassification 固化设计 §5 classifier 的基础轴:哪些 +// domain.ChannelUpdateEventType 经 tgChannelUpdate 产出「带 channel pts、会推进客户端 channel +// PtsWaiter」的真实 payload(→可进 Phase 0 异步 durable fan-out worker),哪些不带 channel pts +// (participant=qts / noop → 必须排除出 ChannelFanoutJob,走专用同步路径)。 +// +// 新增 ChannelUpdateEventType 时必须在此表显式分类:忘记会触发完整性断言失败,强制人为裁决, +// 防止把无 pts / transient 事件误塞进 (channel_id, pts) durable worker(设计 §2.1/§8-D6)。 +func TestChannelUpdateEventTypePtsClassification(t *testing.T) { + const viewer = int64(9001) + msg := domain.ChannelMessage{ChannelID: 1, ID: 10, SenderUserID: 2, Date: 1, Body: "hi"} + + cases := []struct { + name string + eventType domain.ChannelUpdateEventType + event domain.ChannelUpdateEvent + wantCarriesPts bool // 是否产出带 channel pts 的 wire update(= 可进 durable worker) + }{ + { + name: "new_message", + eventType: domain.ChannelUpdateNewMessage, + event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateNewMessage, ChannelID: 1, Pts: 5, PtsCount: 1, Message: msg}, + wantCarriesPts: true, + }, + { + name: "edit_message", + eventType: domain.ChannelUpdateEditMessage, + event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateEditMessage, ChannelID: 1, Pts: 6, PtsCount: 1, Message: msg}, + wantCarriesPts: true, + }, + { + name: "delete_messages", + eventType: domain.ChannelUpdateDeleteMessages, + event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateDeleteMessages, ChannelID: 1, Pts: 7, PtsCount: 1, MessageIDs: []int{10}}, + wantCarriesPts: true, + }, + { + name: "pinned_messages", + eventType: domain.ChannelUpdatePinnedMessages, + event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdatePinnedMessages, ChannelID: 1, Pts: 8, PtsCount: 1, MessageIDs: []int{10}, Pinned: true}, + wantCarriesPts: true, + }, + { + // participant 是 qts/no-channel-pts:必须排除出 durable worker,走 participant 专用路径 + // (账号级 channel state / getFullChannel / getParticipants 兜底)。设计 §8-D6。 + name: "participant", + eventType: domain.ChannelUpdateParticipant, + event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateParticipant, ChannelID: 1, Participant: domain.ChannelMember{UserID: 2}}, + wantCarriesPts: false, + }, + { + name: "noop", + eventType: domain.ChannelUpdateNoop, + event: domain.ChannelUpdateEvent{Type: domain.ChannelUpdateNoop, ChannelID: 1}, + wantCarriesPts: false, + }, + } + + // 完整性:本表必须覆盖全部已知 ChannelUpdateEventType。新增类型未分类 → 失败。 + allKnownTypes := map[domain.ChannelUpdateEventType]struct{}{ + domain.ChannelUpdateNewMessage: {}, + domain.ChannelUpdateEditMessage: {}, + domain.ChannelUpdateDeleteMessages: {}, + domain.ChannelUpdateParticipant: {}, + domain.ChannelUpdatePinnedMessages: {}, + domain.ChannelUpdateNoop: {}, + } + covered := make(map[domain.ChannelUpdateEventType]struct{}, len(cases)) + for _, tc := range cases { + covered[tc.eventType] = struct{}{} + } + for et := range allKnownTypes { + if _, ok := covered[et]; !ok { + t.Fatalf("ChannelUpdateEventType %q 未在 classifier 固化表分类——新增类型必须显式裁决 pts 归类", et) + } + } + if len(covered) != len(allKnownTypes) { + t.Fatalf("classifier 表覆盖 %d 类型,已知 %d 类型;新增类型须同步 allKnownTypes + 本表", len(covered), len(allKnownTypes)) + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + update := tgChannelUpdate(viewer, tc.event) + if tc.eventType == domain.ChannelUpdateNoop { + if update != nil { + t.Fatalf("noop event produced update %#v, want nil", update) + } + return + } + if update == nil { + t.Fatalf("event %q produced nil update", tc.eventType) + } + pts, carries := channelUpdateWirePts(update) + if carries != tc.wantCarriesPts { + t.Fatalf("event %q carriesChannelPts=%v (%T), want %v", tc.eventType, carries, update, tc.wantCarriesPts) + } + if tc.wantCarriesPts && pts != tc.event.Pts { + t.Fatalf("event %q wire pts=%d, want %d", tc.eventType, pts, tc.event.Pts) + } + }) + } +} + +// TestChannelFanoutEntranceClassificationLocked 把设计 §5 的「channel-scoped 主动投递入口」分类 +// 与当前 dispatch 方式固化为测试期边界(防回归 + 文档化为何某些带 channel pts 的入口仍同步)。 +// +// 本表是人工维护的清单(不自动反射调用图),与 docs/channel-fanout-async-design.md §5 一致。 +// 新增/改动 channel-scoped 主动投递入口时必须同步本表并在 review 中复核分类: +// +// 已异步(durable channel-payload-pts → 进 enqueueChannelFanout* 异步 worker): +// send / sendMedia / forward / forum-topic-msg / discussion 联动 / edit / geolive-edit / todo / +// bot-inline-edit / delete / deleteParticipantHistory / pin(updatePinnedMessage) / unpinAll / +// TTL-expiry-delete。 +// 刻意保持同步且锁定(混合容器 / MustDeliver 失去 difference 恢复面 / 冷起手,详见下表 reason): +// createChannel / importChatInvite / inviteToChannel / createChat(invite) / joinChannel / +// leaveChannel / editBanned(kick) / hideChatJoinRequest / hideAllChatJoinRequests / +// editTitle / group-call started/ended/invite 服务消息。 +// 无 channel pts,排除出 durable worker(participant / state / TTL-set / forum-pin / read-outbox / +// group-call version / typing);viewer-only 无 durable event(poll / reaction)——Phase 4 才迁移。 +// +// 此处只断言一组「不变量级」的分类事实,作为编译期/测试期护栏。 +func TestChannelFanoutEntranceClassificationLocked(t *testing.T) { + type entrance struct { + name string + carriesChanPts bool + asyncFanout bool // 当前是否走异步 durable worker + reason string // 若带 pts 却仍同步,记录原因(mixed-container / MustDeliver / 冷起手) + } + // 仅列「带 channel pts」入口(无 pts 的 participant/state/typing 由上文事件类型表与设计排除规则覆盖)。 + entrances := []entrance{ + {name: "send/sendMedia/forward/forum-msg/discussion/edit/geolive/todo/bot-inline/delete/pin/unpinAll/deleteParticipantHistory/expiry-delete", carriesChanPts: true, asyncFanout: true}, + {name: "editTitle", carriesChanPts: true, asyncFanout: true, reason: "已拆容器:无 pts UpdateChannel 同步发(无 difference 恢复面),带 pts 标题服务消息(broadcast+megagroup 均产 pts)走异步 fan-out(有 difference 恢复面+nudge 兜底)"}, + {name: "createChannel", carriesChanPts: true, asyncFanout: false, reason: "冷起手:recipients=创建者+少量初始受邀,无大群放大/无延迟收益,RPC result 与首 push 紧耦合"}, + {name: "importChatInvite/inviteToChannel/createChat-invite/joinChannel/hideChatJoinRequest/hideAllChatJoinRequests", carriesChanPts: true, asyncFanout: false, reason: "经 channelOperationUpdates 混合容器(pts 服务消息 + 无 pts UpdateChannel;broadcast 仅 UpdateChannel),且新成员 MustDeliver 失去 difference 恢复面——须先拆容器+no-fold 才能异步"}, + {name: "leaveChannel/editBanned(kick)", carriesChanPts: true, asyncFanout: false, reason: "离开/被踢者在 recipients 内且已失去 channel difference 恢复面(MustDeliver),可丢弃队列无 no-fold 通道;participant 部分无 pts"}, + {name: "groupCallServiceMessage", carriesChanPts: true, asyncFanout: false, reason: "经 channelOperationUpdates 混合容器;version 信令须留特殊路径,只能拆出服务消息再异步"}, + } + for _, e := range entrances { + if !e.carriesChanPts { + t.Fatalf("entrance %q 误入本表(本表仅列带 channel pts 入口)", e.name) + } + if !e.asyncFanout && e.reason == "" { + t.Fatalf("entrance %q 带 channel pts 却仍同步,必须记录保持同步的原因(mixed-container/MustDeliver/冷起手)", e.name) + } + } +} diff --git a/internal/rpc/channel_fanout_dispatcher.go b/internal/rpc/channel_fanout_dispatcher.go new file mode 100644 index 00000000..2faf4353 --- /dev/null +++ b/internal/rpc/channel_fanout_dispatcher.go @@ -0,0 +1,409 @@ +package rpc + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// channel fan-out 异步化(设计 docs/channel-fanout-async-design.md Phase 0 / §9 v1 最小实现)。 +// +// 目标:把频道 payload fan-out 移出发送者 RPC 同步路径——发送者只等事务 + 自己视角 echo +// (rpc_result),其余在线成员的投递交给本 dispatcher 的后台 worker。 +// +// v1(单实例)关键取舍: +// - durable 真值仍是 channel_update_events;本 dispatcher 只做 best-effort 在线加速, +// 丢失由客户端 getChannelDifference 兜底(设计决策 1)。 +// - 按 channelID 哈希到固定分片,使同一 channel 串行处理(FIFO → 单调 pts),满足 +// DrKLO Android ~1.5s 乱序窗口与 TDesktop PtsWaiter 的连续性期望(设计 §10.2)。 +// - 单实例 + 无 durable 重投队列 + 同 channel 串行 → 无乱序、无自重复,故 v1 不需要 +// per-session at-most-once 双水位(那是 Phase 3 跨实例的事,设计 §9/§10.1)。 +// - 有界队列满时丢弃当前 job 并告警:被丢 recipient 会在该 channel 下一条成功投递的 +// pts 跳变时经 getChannelDifference 收敛(设计约束 B)。 + +const ( + defaultChannelFanoutShards = 64 + defaultChannelFanoutBuffer = 2048 +) + +// channelFanoutBuilder 按 viewer 构建该 viewer 视角的 channel updates。与同步 +// channelUpdatesBuilder 不同,它接受 worker 提供的后台 ctx(请求 ctx 在 fan-out 异步 +// 执行时已被取消),且实现不得从 ctx 读取 viewer/auth 派生数据(viewerUserID 显式传入)。 +type channelFanoutBuilder func(ctx context.Context, viewerUserID int64) *tg.Updates + +// channelFanoutJob 是一条频道 payload fan-out 任务。Pts 仅用于日志/折叠语义;真值仍是 +// channel_update_events,worker 只做在线投递。originAuthKeyID 是业务视角 auth key +// (与 SessionManager.shouldExcludeSession 的比较侧一致),用于显式排除发起设备——异步 +// 执行时请求 ctx 已失效,不能再靠 ctx 派生排除。 +type channelFanoutJob struct { + scope channelFanoutScope + originUserID int64 + channelID int64 + pts int + recipients []int64 + originAuthKeyID [8]byte + originSessionID int64 + prefetch channelFanoutPrefetch + build channelFanoutBuilder +} + +// channelFanoutPrefetch 在 worker 解析出最终 recipient 集合后、逐 viewer build 之前调用一次, +// 用于跨全部 recipient 一次性预热每 viewer 的用户投影(fan-out 模板化,O(owner))。可选:为 nil +// 时 build 仍逐 viewer 解析(行为不变)。在 worker goroutine 内串行执行,与 build 共享同一 +// viewerPeerCache,无跨 goroutine 竞态。 +type channelFanoutPrefetch func(ctx context.Context, viewers []int64) + +// channelFanoutDispatcher 把频道 payload fan-out 移出发送者 RPC,按 channelID 分片串行处理。 +type channelFanoutDispatcher struct { + r *Router + log *zap.Logger + shards []chan channelFanoutJob + started atomic.Bool + dropped atomic.Int64 +} + +// enqueueChannelFanout 把一条 channel-payload-pts 的 fan-out 投入异步 dispatcher。 +// 从请求 ctx 抓取发起设备的业务 auth key + session_id 显式带入 job,使异步 worker 仍能 +// 排除发起设备回显(请求 ctx 异步时已失效)。仅用于会推进客户端 channel PtsWaiter 的真实 +// payload(新消息/编辑/删除/pin);reaction/poll(viewer-only 零 pts)、participant/TTL/ +// channel state(无 channel pts)、typing(transient)不走此路径(设计 §2.1/§5 分类)。 +func (r *Router) enqueueChannelFanout(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, pts int, recipients []int64, build channelFanoutBuilder) { + r.enqueueChannelFanoutWithPrefetch(ctx, scope, originUserID, channelID, pts, recipients, nil, build) +} + +// enqueueChannelFanoutWithPrefetch 同 enqueueChannelFanout,但额外带一个跨 viewer 用户投影预热钩子 +// (fan-out 模板化把每 recipient 的逐 viewer 投影折叠成一次 O(owner) 投影;见 prefetchChannelFanoutUsers)。 +func (r *Router) enqueueChannelFanoutWithPrefetch(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, pts int, recipients []int64, prefetch channelFanoutPrefetch, build channelFanoutBuilder) { + if r.channelFanout == nil || build == nil { + return + } + originAuthKeyID, _ := AuthKeyIDFrom(ctx) + originSessionID, _ := SessionIDFrom(ctx) + r.channelFanout.Enqueue(ctx, channelFanoutJob{ + scope: scope, + originUserID: originUserID, + channelID: channelID, + pts: pts, + recipients: recipients, + originAuthKeyID: originAuthKeyID, + originSessionID: originSessionID, + prefetch: prefetch, + build: build, + }) +} + +// RunChannelFanout 启动频道 fan-out 后台 worker,由 main 与其它 dispatcher 一同 go 起。 +// 阻塞到 ctx 取消;未调用前 fan-out 同步执行(行为同旧版)。 +func (r *Router) RunChannelFanout(ctx context.Context) { + r.channelFanout.Run(ctx) +} + +func newChannelFanoutDispatcher(r *Router, shards, buffer int) *channelFanoutDispatcher { + if shards <= 0 { + shards = defaultChannelFanoutShards + } + if buffer <= 0 { + buffer = defaultChannelFanoutBuffer + } + d := &channelFanoutDispatcher{r: r, log: r.log.Named("channel-fanout"), shards: make([]chan channelFanoutJob, shards)} + for i := range d.shards { + d.shards[i] = make(chan channelFanoutJob, buffer) + } + return d +} + +// Run 启动 worker:每分片一个 goroutine,保证同 channel 串行。阻塞到 ctx 取消。 +// 未调用 Run 前 Enqueue 回退为同步执行(保持测试/未装配场景行为不变)。 +func (d *channelFanoutDispatcher) Run(ctx context.Context) { + if d == nil || !d.started.CompareAndSwap(false, true) { + return + } + var wg sync.WaitGroup + for i := range d.shards { + wg.Add(1) + ch := d.shards[i] + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case job := <-ch: + d.r.runChannelFanoutJob(ctx, job) + } + } + }() + } + wg.Wait() +} + +func (d *channelFanoutDispatcher) shardIndex(channelID int64) int { + n := int64(len(d.shards)) + idx := channelID % n + if idx < 0 { + idx += n + } + return int(idx) +} + +// Enqueue 投递一条 fan-out 任务。dispatcher 未启动时同步执行(用请求 ctx,保持旧行为); +// 已启动时投入对应分片,满则丢弃 + 告警(该 channel 下一条消息的 pts 跳变会经 +// getChannelDifference 兜底)。 +func (d *channelFanoutDispatcher) Enqueue(reqCtx context.Context, job channelFanoutJob) { + if d == nil || job.build == nil { + return + } + if !d.started.Load() { + d.r.runChannelFanoutJob(reqCtx, job) + return + } + shard := d.shards[d.shardIndex(job.channelID)] + select { + case shard <- job: + default: + d.dropped.Add(1) + d.log.Warn("channel fanout queue full, dropped realtime push (recovered via next pts gap / getChannelDifference)", + zap.Int64("channel_id", job.channelID), zap.Int("pts", job.pts)) + } +} + +// runChannelFanoutJob 执行一条 fan-out:与同步 pushChannelUpdatesWithScope 等价,区别是 +// build 接受 ctx、且排除发起设备用 job 显式携带的 (originAuthKeyID, originSessionID) +// 叠加到 ctx 后复用 pushUserUpdates,而非依赖已失效的请求 ctx。 +func (r *Router) runChannelFanoutJob(ctx context.Context, job channelFanoutJob) { + if r.deps.Sessions == nil || job.build == nil { + return + } + pushCtx := WithSessionID(WithAuthKeyID(ctx, job.originAuthKeyID), job.originSessionID) + recipients := r.channelFanoutRecipients(ctx, job.scope, job.channelID, job.recipients) + // 预热跨 viewer 用户投影(fan-out 模板化):在逐 viewer build 之前一次性算好每 recipient 的 + // 投影并预热共享 cache,使 build 只命中缓存、不再 O(viewer) 逐个 ForViewer。覆盖 recipients + + // 兜底 origin(无在线 recipient 时 build 会回退给 origin)。失败/未实现时静默退化为逐 viewer。 + if job.prefetch != nil { + viewers := recipients + if job.originUserID != 0 { + viewers = append(append(make([]int64, 0, len(recipients)+1), recipients...), job.originUserID) + } + job.prefetch(ctx, viewers) + } + seen := make(map[int64]struct{}, len(recipients)) + pushed := false + for _, userID := range recipients { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + updates := job.build(ctx, userID) + if updates == nil { + continue + } + r.pushUserUpdates(pushCtx, userID, updates) + pushed = true + } + if !pushed && job.originUserID != 0 { + seen[job.originUserID] = struct{}{} + if updates := job.build(ctx, job.originUserID); updates != nil { + r.pushUserUpdates(pushCtx, job.originUserID, updates) + } + } + // P0-8:完整 payload 受 MaxChannelRealtimeFanout 封顶,超出 cap 的在线成员既收不到 + // payload 也收不到任何东西(频道纯拉模型不会自发轮询)。给这些「已在线但未收完整 payload」 + // 的成员发廉价 UpdateChannelTooLong{pts} nudge,促其 getChannelDifference 收敛。 + // 仅对会推进客户端 channel PtsWaiter 的真实 payload(members scope + 带 channel pts)做。 + if job.scope == channelFanoutMembers && job.pts > 0 { + r.nudgeBeyondCapChannelMembers(pushCtx, job.channelID, job.pts, seen) + } +} + +// prefetchChannelFanoutUsers 跨全部 recipient 一次性投影 owner 用户(fan-out 模板化,O(owner)), +// 把结果按 viewer 预热进共享 cache;之后每 viewer 的 build 只命中缓存,不再逐 viewer ForViewer。 +// ownerIDs 由调用方从消息/事件 peer refs 收集。deps.Users 未实现 BatchViewerUsersResolver 或解析 +// 失败时静默跳过——build 回退逐 viewer 解析,行为不变,仅退化为旧的 O(viewer) 成本。 +func (r *Router) prefetchChannelFanoutUsers(ctx context.Context, cache *viewerPeerCache, viewers, ownerIDs []int64) { + if cache == nil || len(viewers) == 0 || len(ownerIDs) == 0 || r.deps.Users == nil { + return + } + resolver, ok := r.deps.Users.(BatchViewerUsersResolver) + if !ok { + return + } + byViewer, err := resolver.ByIDsForViewers(ctx, viewers, ownerIDs) + if err != nil { + r.log.Warn("channel fanout user prefetch failed; falling back to per-viewer projection", + zap.Int("viewers", len(viewers)), zap.Int("owners", len(ownerIDs)), zap.Error(err)) + return + } + for viewer, users := range byViewer { + cache.primeUsers(viewer, users) + } +} + +// channelMessageFanoutOwnerIDs 收集一条频道消息 fan-out 会下发到 Users 数组里的全部 owner 用户 id +// (sender/from/send_as/forward/via_bot/reply/contact/poll 等 peer refs,与 +// channelMessagesUpdatesWithPeerCache 的收集口径一致),用于预热跨 viewer 投影。 +func channelMessageFanoutOwnerIDs(res domain.SendChannelMessageResult, extraUserIDs []int64) []int64 { + return channelMessagesFanoutOwnerIDs([]domain.SendChannelMessageResult{res}, extraUserIDs) +} + +// channelMessagesFanoutOwnerIDs 同上,但取多条结果(批量转发汇成一个 job)的 owner id 并集。 +func channelMessagesFanoutOwnerIDs(results []domain.SendChannelMessageResult, extraUserIDs []int64) []int64 { + userIDs := make(map[int64]struct{}, len(results)+len(extraUserIDs)+4) + channelIDs := make(map[int64]struct{}) + for _, id := range extraUserIDs { + if id != 0 { + userIDs[id] = struct{}{} + } + } + for _, res := range results { + collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs) + collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs) + } + return peerIDMapKeys(userIDs) +} + +// enqueueChannelMessageFanout 异步 fan-out 单条频道消息并预热跨 viewer 投影(「频道里出现一条新消息」 +// 类事件的常见形态:发送/转发单条/讨论组联动/forum topic 消息)。语义与 enqueueChannelFanout 一致, +// 仅多了把每 viewer 投影一次性算好预热进共享 cache(O(owner)),不改变投递/排除/nudge 行为。 +func (r *Router) enqueueChannelMessageFanout(ctx context.Context, originUserID int64, res domain.SendChannelMessageResult, extraUserIDs []int64) { + fanoutCache := newViewerPeerCache(r) + ownerIDs := channelMessageFanoutOwnerIDs(res, extraUserIDs) + skip := skipDeliverySet(res.SkipDeliveryUserIDs) + r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients, + func(bgCtx context.Context, viewers []int64) { + r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) + }, + func(bgCtx context.Context, viewerUserID int64) *tg.Updates { + // privacy bot 在 send 时被 SkipDeliveryUserIDs 排除(命令/@/回复以外的消息不可见)。 + // channelFanoutRecipients 按「在线活跃成员」重算 recipients 会把它加回,故在线 fanout + // 必须在此跳过它的直接推送,否则在线 bot 仍能实时收到群里全部消息(持久 history/ + // difference 已正确隐藏,仅此直接推送泄漏内容)。nudge 安全:bot 落在 seen 里不被 nudge, + // 即便 nudge 也会被 filterBotChannelDifference 过滤掉隐藏消息。 + if _, skipped := skip[viewerUserID]; skipped { + return nil + } + return r.channelMessageUpdatesWithPeerCache(bgCtx, viewerUserID, res, 0, fanoutCache) + }) +} + +// skipDeliverySet 把 SkipDeliveryUserIDs 切片转成查找集合(nil 表示无排除)。 +func skipDeliverySet(ids []int64) map[int64]struct{} { + if len(ids) == 0 { + return nil + } + set := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id != 0 { + set[id] = struct{}{} + } + } + return set +} + +// channelEditMessageFanoutOwnerIDs 收集一条频道编辑 fan-out 会下发到 Users 数组里的全部 owner 用户 id。 +// 严格镜像 channelEditMessageUpdates 的两容器 + pts 门控收集口径(Event/Message 仅 Event.Pts!=0 时收, +// ServiceEvent/ServiceMessage 仅 ServiceEvent.Pts!=0 时收,对应 todo 编辑的服务消息第二容器),使预热 +// owner 集与 build 实际下发的 Users 集恰好一致——多收只会无害多预热,但镜像门控让等价测试最紧。 +func channelEditMessageFanoutOwnerIDs(res domain.EditChannelMessageResult) []int64 { + userIDs := make(map[int64]struct{}, 4) + channelIDs := make(map[int64]struct{}) + if res.Event.Pts != 0 { + collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs) + collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs) + } + if res.ServiceEvent.Pts != 0 { + collectChannelUpdatePeerRefs(res.ServiceEvent, res.Channel.ID, userIDs, channelIDs) + collectChannelMessagePeerRefs(res.ServiceMessage, res.Channel.ID, userIDs, channelIDs) + } + return peerIDMapKeys(userIDs) +} + +// enqueueChannelEditMessageFanout 异步 fan-out 一条频道编辑并预热跨 viewer 投影(editMessage/geolive/ +// todo/bot-inline edit 的共同形态)。语义与原 enqueueChannelFanout(channelEditMessageUpdates) 一致, +// 仅多了把每 viewer 投影一次性算好预热进共享 cache(O(owner))。注意 edit 不做 per-viewer mention +// overlay(EditChannelMessageResult 不带 MentionUserIDs,编辑新增 @ 走 durable channel_unread_mentions +// 经 getChannelDifference 自愈),故各 viewer 的 mentioned/media_unread 字节恒等,预热不影响等价。 +// +// nudge pts 取两容器较大值:edit 可产两条带 pts 事件(Event=编辑本身、ServiceEvent=如 todo 完成的 +// "X completed Y" 服务消息),ServiceEvent.Pts 在后分配恒更大;某些编辑只产 ServiceEvent(Event.Pts==0)。 +// nudge 须带 channel 当前最高 pts 才能让 >cap 在线成员的 getChannelDifference 拉齐到末尾——用 Event.Pts +// 会在 Event.Pts==0 时漏发 nudge、或低于真实 pts。max() 在三种形态(仅 Event/仅 ServiceEvent/两者)都正确。 +func (r *Router) enqueueChannelEditMessageFanout(ctx context.Context, originUserID int64, res domain.EditChannelMessageResult) { + fanoutCache := newViewerPeerCache(r) + ownerIDs := channelEditMessageFanoutOwnerIDs(res) + nudgePts := max(res.Event.Pts, res.ServiceEvent.Pts) + r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, nudgePts, res.Recipients, + func(bgCtx context.Context, viewers []int64) { + r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) + }, + func(bgCtx context.Context, viewerUserID int64) *tg.Updates { + return r.channelEditMessageUpdatesWithPeerCache(bgCtx, viewerUserID, res, fanoutCache) + }) +} + +// enqueueChannelMessagesFanout 同 enqueueChannelMessageFanout,但把多条结果汇成一个 job(批量转发: +// 一个 Updates 内含多条 UpdateNewChannelMessage),peer refs 取全部结果并集预热。channelID/pts/ +// recipients 由调用方按批量语义给定(pts 取最后一条;recipients 受大群截断口径影响)。 +func (r *Router) enqueueChannelMessagesFanout(ctx context.Context, originUserID, channelID int64, pts int, recipients []int64, results []domain.SendChannelMessageResult, extraUserIDs []int64) { + fanoutCache := newViewerPeerCache(r) + ownerIDs := channelMessagesFanoutOwnerIDs(results, extraUserIDs) + r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, channelID, pts, recipients, + func(bgCtx context.Context, viewers []int64) { + r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) + }, + func(bgCtx context.Context, viewerUserID int64) *tg.Updates { + return r.channelMessagesUpdatesWithPeerCache(bgCtx, viewerUserID, results, nil, false, extraUserIDs, fanoutCache) + }) +} + +// defaultChannelNudgeMaxTargets 限一次 fan-out 的 nudge 上限:nudge 是 O(1)/人廉价 push,但 nudge 被 +// 消费后客户端会 getChannelDifference(DrKLO 对未加载频道还会先 getPeerDialogs,设计 §10.3), +// 大群高频下可能放大。可经 Config.ChannelNudgeMaxTargets 覆盖;客户端侧由 difference/getPeerDialogs +// 的 FLOOD_WAIT(Phase 2)兜底(见 checkCatchupRateLimit)。 +const defaultChannelNudgeMaxTargets = 50000 + +// channelNudgeMaxTargets 返回生效的 nudge 上限(Config 覆盖,否则默认)。 +func (r *Router) channelNudgeMaxTargets() int { + if r.cfg.ChannelNudgeMaxTargets > 0 { + return r.cfg.ChannelNudgeMaxTargets + } + return defaultChannelNudgeMaxTargets +} + +// nudgeBeyondCapChannelMembers 给频道在线成员中未收到完整 payload(不在 delivered 内)的成员发 +// UpdateChannelTooLong{pts}。nudge 必须带 pts(flags&1)——DrKLO 对不带 pts 的 tooLong 不触发 +// getChannelDifference(设计 §10.3)。走 pushUserUpdates(best-effort、未就绪入 pending、非 +// transient),符合设计 §决策4 的 nudge 投递可靠性要求。SessionManager 未实现 ChannelNudgeProvider +// 时(测试/未装配)静默跳过,不影响完整 payload 投递。 +func (r *Router) nudgeBeyondCapChannelMembers(ctx context.Context, channelID int64, pts int, delivered map[int64]struct{}) { + provider, ok := r.deps.Sessions.(ChannelNudgeProvider) + if !ok || channelID == 0 || pts <= 0 { + return + } + targets := provider.OnlineChannelMemberUserIDsExcluding(channelID, delivered, r.channelNudgeMaxTargets()) + if len(targets) == 0 { + return + } + date := int(r.clock.Now().Unix()) + for _, userID := range targets { + if userID == 0 { + continue + } + tooLong := &tg.UpdateChannelTooLong{ChannelID: channelID} + tooLong.SetPts(pts) + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{tooLong}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: date, + Seq: 0, + }) + } +} diff --git a/internal/rpc/channel_fanout_dispatcher_test.go b/internal/rpc/channel_fanout_dispatcher_test.go new file mode 100644 index 00000000..1cc0cd5d --- /dev/null +++ b/internal/rpc/channel_fanout_dispatcher_test.go @@ -0,0 +1,319 @@ +package rpc + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" +) + +func fanoutTestJob(recipients []int64, originUser, originSession int64, built map[int64]bool) channelFanoutJob { + return channelFanoutJob{ + scope: channelFanoutMembers, + originUserID: originUser, + channelID: 1001, + pts: 5, + recipients: recipients, + originSessionID: originSession, + build: func(_ context.Context, viewerUserID int64) *tg.Updates { + if built != nil { + built[viewerUserID] = true + } + return &tg.Updates{Updates: []tg.UpdateClass{&tg.UpdateChannelTooLong{ChannelID: 1001}}, Date: 1} + }, + } +} + +func fanoutHasID(ids []int64, want int64) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} + +// TestChannelFanoutDispatcherSyncFallback:dispatcher 未启动时 Enqueue 同步执行—— +// 保持测试/未装配场景行为不变,recipients 立即被推送、发起 session 作为 exclude 透传。 +// deps.Channels=nil 时 channelFanoutRecipients 直接返回 explicit recipients。 +func TestChannelFanoutDispatcherSyncFallback(t *testing.T) { + cs := &captureSessions{} + r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System) + built := map[int64]bool{} + r.channelFanout.Enqueue(context.Background(), fanoutTestJob([]int64{2001, 2002}, 0, 99, built)) + + pushed := cs.pushedUserIDs() + if len(pushed) != 2 || !fanoutHasID(pushed, 2001) || !fanoutHasID(pushed, 2002) { + t.Fatalf("sync fallback pushed = %v, want [2001 2002]", pushed) + } + if got := cs.snapshot().sessionID; got != 99 { + t.Fatalf("exclude session = %d, want 99 (origin session passed explicitly, not via request ctx)", got) + } + if !built[2001] || !built[2002] { + t.Fatalf("build not invoked per viewer: %v", built) + } +} + +// TestChannelFanoutDispatcherDeliversAsync:dispatcher 启动后 Enqueue 异步投递, +// recipients 最终被 worker 推送(不阻塞 Enqueue 调用方)。 +func TestChannelFanoutDispatcherDeliversAsync(t *testing.T) { + cs := &captureSessions{} + r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go r.RunChannelFanout(ctx) + for i := 0; i < 200 && !r.channelFanout.started.Load(); i++ { + time.Sleep(time.Millisecond) + } + if !r.channelFanout.started.Load() { + t.Fatal("dispatcher did not start") + } + + // built map 跨 goroutine,只断言 mutex 保护的 pushedUserIDs,不读 built。 + r.channelFanout.Enqueue(context.Background(), fanoutTestJob([]int64{3001}, 0, 7, nil)) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if fanoutHasID(cs.pushedUserIDs(), 3001) { + break + } + time.Sleep(2 * time.Millisecond) + } + if !fanoutHasID(cs.pushedUserIDs(), 3001) { + t.Fatalf("async fan-out did not deliver to 3001: %v", cs.pushedUserIDs()) + } + if got := cs.snapshot().sessionID; got != 7 { + t.Fatalf("exclude session = %d, want 7 (origin carried into job, not lost on bg ctx)", got) + } +} + +// TestChannelFanoutDispatcherInvokesPrefetch:worker 在逐 viewer build 之前调用一次 prefetch, +// 且传入「解析后的 recipients + 兜底 origin」——这是 fan-out 跨 viewer 投影预热(O(owner))的入口。 +func TestChannelFanoutDispatcherInvokesPrefetch(t *testing.T) { + cs := &captureSessions{} + r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System) + + var gotViewers []int64 + job := fanoutTestJob([]int64{2001, 2002}, 5, 99, nil) + job.prefetch = func(_ context.Context, viewers []int64) { + gotViewers = append([]int64(nil), viewers...) + } + // deps.Channels=nil → channelFanoutRecipients 返回 explicit recipients=[2001 2002];origin=5 兜底追加。 + r.channelFanout.Enqueue(context.Background(), job) + + want := map[int64]bool{2001: true, 2002: true, 5: true} + if len(gotViewers) != len(want) { + t.Fatalf("prefetch viewers = %v, want recipients+origin %v", gotViewers, want) + } + for _, v := range gotViewers { + if !want[v] { + t.Fatalf("prefetch viewers = %v, unexpected %d (want recipients+origin)", gotViewers, v) + } + } +} + +// editFanoutTestResult 构造一条覆盖两容器的 EditChannelMessageResult:主容器(Event/Message)带 +// sender A + reply B,服务消息容器(ServiceEvent/ServiceMessage)带 sender C + Action.UserIDs=[D]。 +func editFanoutTestResult(eventPts, servicePts int) domain.EditChannelMessageResult { + res := domain.EditChannelMessageResult{ + Channel: domain.Channel{ID: 1001}, + Recipients: []int64{3001, 3002}, + } + res.Event = domain.ChannelUpdateEvent{Pts: eventPts, SenderUserID: 2001, Message: domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2001}} + res.Message = domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2001, ReplyTo: &domain.MessageReply{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2002}}} + res.ServiceEvent = domain.ChannelUpdateEvent{Pts: servicePts, SenderUserID: 2003, Message: domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2003}} + res.ServiceMessage = domain.ChannelMessage{ChannelID: 1001, SenderUserID: 2003, Action: &domain.ChannelMessageAction{Type: domain.ChannelActionTodoCompletions, UserIDs: []int64{2004}}} + return res +} + +func ownerIDSet(ids []int64) map[int64]bool { + out := make(map[int64]bool, len(ids)) + for _, id := range ids { + out[id] = true + } + return out +} + +// TestChannelEditMessageFanoutOwnerIDsCoversBothContainers:编辑预热的 owner-id 收集必须并集两个 +// 容器(主消息 + 服务消息),否则服务消息的 sender/Action.UserIDs 漏出缓存预热(edit 相对 send +// 路径唯一新增的等价面,见 editVerify)。 +func TestChannelEditMessageFanoutOwnerIDsCoversBothContainers(t *testing.T) { + got := ownerIDSet(channelEditMessageFanoutOwnerIDs(editFanoutTestResult(5, 6))) + for _, want := range []int64{2001, 2002, 2003, 2004} { + if !got[want] { + t.Fatalf("owner ids %v missing %d (both containers must be unioned)", got, want) + } + } +} + +// TestChannelEditMessageFanoutOwnerIDsGating:owner-id 收集必须严格镜像 builder 的 pts 门控—— +// ServiceEvent.Pts==0 时不收服务消息容器;Event.Pts==0 时不收主容器。保证预热集与 build 下发的 +// Users 集恰好一致(多收无害但破坏等价测试紧致性)。 +func TestChannelEditMessageFanoutOwnerIDsGating(t *testing.T) { + // 仅主容器(服务消息 pts=0)。 + noService := ownerIDSet(channelEditMessageFanoutOwnerIDs(editFanoutTestResult(5, 0))) + if !noService[2001] || !noService[2002] { + t.Fatalf("event-only owner ids %v should contain 2001/2002", noService) + } + if noService[2003] || noService[2004] { + t.Fatalf("event-only owner ids %v must not contain service-container ids 2003/2004", noService) + } + // 两容器都无 pts → 空。 + if ids := channelEditMessageFanoutOwnerIDs(editFanoutTestResult(0, 0)); len(ids) != 0 { + t.Fatalf("no-pts owner ids = %v, want empty", ids) + } +} + +// prefetchRecordingUsersService 在 mapUsersService 基础上实现 BatchViewerUsersResolver 并记录 +// ByIDsForViewers 收到的 (viewers, ownerIDs),用于断言 edit fan-out 用正确 owner 集预热。 +type prefetchRecordingUsersService struct { + mapUsersService + mu sync.Mutex + gotViewers []int64 + gotOwnerIDs []int64 + forViewerCall int +} + +func (s *prefetchRecordingUsersService) ByIDsForViewers(_ context.Context, viewerUserIDs, userIDs []int64) (map[int64][]domain.User, error) { + s.mu.Lock() + s.forViewerCall++ + s.gotViewers = append([]int64(nil), viewerUserIDs...) + s.gotOwnerIDs = append([]int64(nil), userIDs...) + s.mu.Unlock() + out := make(map[int64][]domain.User, len(viewerUserIDs)) + for _, v := range viewerUserIDs { + out[v] = nil + } + return out, nil +} + +// TestChannelEditMessageFanoutInvokesPrefetch:enqueueChannelEditMessageFanout 在逐 viewer build +// 前用「channelEditMessageFanoutOwnerIDs(res) + recipients+origin」预热(dispatcher 未启动→同步 +// 回退,prefetch 同步执行)。锁定 edit 路径接入了 O(owner) 预热而非逐 viewer 投影。 +func TestChannelEditMessageFanoutInvokesPrefetch(t *testing.T) { + users := &prefetchRecordingUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{}}} + cs := &captureSessions{} + r := New(Config{}, Deps{Sessions: cs, Users: users}, zaptest.NewLogger(t), clock.System) + + res := editFanoutTestResult(5, 6) + r.enqueueChannelEditMessageFanout(context.Background(), 5, res) + + if users.forViewerCall != 1 { + t.Fatalf("ByIDsForViewers called %d times, want 1 (prefetch must run once before per-viewer build)", users.forViewerCall) + } + gotViewers := ownerIDSet(users.gotViewers) + for _, want := range []int64{3001, 3002, 5} { + if !gotViewers[want] { + t.Fatalf("prefetch viewers %v missing %d (recipients+origin)", users.gotViewers, want) + } + } + gotOwners := ownerIDSet(users.gotOwnerIDs) + for _, want := range []int64{2001, 2002, 2003, 2004} { + if !gotOwners[want] { + t.Fatalf("prefetch owner ids %v missing %d (must equal channelEditMessageFanoutOwnerIDs)", users.gotOwnerIDs, want) + } + } +} + +// nudgeSessions 在 captureSessions 基础上实现 ChannelNudgeProvider 并按 user 记录最近一次推送, +// 用于断言 >cap 在线成员收到带 pts 的 UpdateChannelTooLong nudge。 +type nudgeSessions struct { + *captureSessions + online []int64 + mu sync.Mutex + byUser map[int64]bin.Encoder +} + +func newNudgeSessions(online []int64) *nudgeSessions { + return &nudgeSessions{captureSessions: &captureSessions{}, online: online, byUser: map[int64]bin.Encoder{}} +} + +func (s *nudgeSessions) PushToUserExceptSession(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + s.mu.Lock() + s.byUser[userID] = msg + s.mu.Unlock() + return s.captureSessions.PushToUserExceptSession(ctx, userID, excludeSessionID, t, msg) +} + +func (s *nudgeSessions) OnlineChannelMemberUserIDsExcluding(_ int64, exclude map[int64]struct{}, limit int) []int64 { + out := make([]int64, 0, len(s.online)) + for _, id := range s.online { + if _, ok := exclude[id]; ok { + continue + } + out = append(out, id) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +func (s *nudgeSessions) msgFor(userID int64) bin.Encoder { + s.mu.Lock() + defer s.mu.Unlock() + return s.byUser[userID] +} + +// TestChannelFanoutDispatcherNudgesBeyondCapMembers(P0-8):完整 payload 投递给 cap 内 +// recipients 后,cap 外在线成员收到带 pts 的 UpdateChannelTooLong nudge;cap 内成员不重复 nudge。 +func TestChannelFanoutDispatcherNudgesBeyondCapMembers(t *testing.T) { + cs := newNudgeSessions([]int64{2001, 2002, 2003}) + r := New(Config{}, Deps{Sessions: cs}, zaptest.NewLogger(t), clock.System) + // deps.Channels=nil → channelFanoutRecipients 返回 explicit recipients=[2001](收完整 payload)。 + // 2002/2003 是 cap 外在线成员(OnlineChannelMemberUserIDsExcluding 排除 2001 后返回)。 + r.channelFanout.Enqueue(context.Background(), fanoutTestJob([]int64{2001}, 0, 99, nil)) + + pushed := cs.pushedUserIDs() + for _, want := range []int64{2001, 2002, 2003} { + if !fanoutHasID(pushed, want) { + t.Fatalf("user %d not pushed: %v", want, pushed) + } + } + // 2002/2003 必须是带 pts 的 UpdateChannelTooLong(DrKLO 对不带 pts 的 tooLong 不触发 difference)。 + for _, uid := range []int64{2002, 2003} { + ups, ok := cs.msgFor(uid).(*tg.Updates) + if !ok || len(ups.Updates) != 1 { + t.Fatalf("nudge to %d not single-update *tg.Updates: %#v", uid, cs.msgFor(uid)) + } + tl, ok := ups.Updates[0].(*tg.UpdateChannelTooLong) + if !ok { + t.Fatalf("nudge to %d not UpdateChannelTooLong: %#v", uid, ups.Updates[0]) + } + if p, ok := tl.GetPts(); !ok || p != 5 { + t.Fatalf("nudge to %d pts=%d ok=%v, want 5 (must carry pts)", uid, p, ok) + } + } +} + +// TestChannelEditMessageFanoutNudgePtsUsesMaxContainer:edit 可只产服务消息容器(Event.Pts==0、 +// ServiceEvent.Pts!=0,如纯 todo 完成)。此时 >cap 在线成员的 nudge 必须带 ServiceEvent.Pts(两容器 +// 较大值),否则用 Event.Pts==0 会被 job.pts>0 门控吞掉 nudge、beyond-cap 成员错过 getChannelDifference。 +func TestChannelEditMessageFanoutNudgePtsUsesMaxContainer(t *testing.T) { + cs := newNudgeSessions([]int64{3001, 4001}) // 4001 是 cap 外在线成员(不在 recipients) + r := New(Config{}, Deps{Sessions: cs, Users: mapUsersService{users: map[int64]domain.User{}}}, zaptest.NewLogger(t), clock.System) + + // 仅服务消息容器有 pts:Event.Pts=0, ServiceEvent.Pts=11。deps.Channels=nil → recipients=[3001 3002]。 + res := editFanoutTestResult(0, 11) + r.enqueueChannelEditMessageFanout(context.Background(), 0, res) + + ups, ok := cs.msgFor(4001).(*tg.Updates) + if !ok || len(ups.Updates) != 1 { + t.Fatalf("nudge to 4001 not single-update *tg.Updates: %#v", cs.msgFor(4001)) + } + tl, ok := ups.Updates[0].(*tg.UpdateChannelTooLong) + if !ok { + t.Fatalf("nudge to 4001 not UpdateChannelTooLong: %#v", ups.Updates[0]) + } + if p, ok := tl.GetPts(); !ok || p != 11 { + t.Fatalf("nudge pts=%d ok=%v, want 11 (max(Event=0, Service=11))", p, ok) + } +} diff --git a/internal/rpc/channel_fanout_privacy_bot_rpc_test.go b/internal/rpc/channel_fanout_privacy_bot_rpc_test.go new file mode 100644 index 00000000..3da5deea --- /dev/null +++ b/internal/rpc/channel_fanout_privacy_bot_rpc_test.go @@ -0,0 +1,81 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// privacyBotResolver 让指定 user 成为 bot_chat_history=false 的隐私 bot(命令/@/回复以外的群消息不可见)。 +type privacyBotResolver map[int64]bool + +func (p privacyBotResolver) BotInfo(_ context.Context, botUserID int64) (domain.BotProfile, bool, error) { + if _, ok := p[botUserID]; !ok { + return domain.BotProfile{}, false, nil + } + return domain.BotProfile{BotUserID: botUserID, ChatHistory: false}, true, nil +} + +// TestChannelMessageFanoutSkipsPrivacyBotOnlinePush 回归:在线 privacy bot 不得经实时 fanout 收到群里 +// 的纯文本消息。修复前 channelFanoutRecipients 按「在线活跃成员」重算 recipients,会把 send 时已按 +// SkipDeliveryUserIDs 排除的 bot 加回 → 在线 bot 实时收到全部群聊内容(持久 history/difference 已正确 +// 隐藏,仅此直接推送泄漏内容)。修复后 fanout build 对被 skip 的 viewer 返回 nil,不推送。 +func TestChannelMessageFanoutSkipsPrivacyBotOnlinePush(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + bots := privacyBotResolver{1003: true} + channelService := appchannels.NewService(channelStore, appchannels.WithBotProfileResolver(bots)) + created, err := channelService.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Privacy", + MemberUserIDs: []int64{1002, 1003}, + Date: 10, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + + // 纯文本消息:privacy bot 1003 不可见 → SendMessage 把它放进 SkipDeliveryUserIDs、排除出 Recipients。 + res, err := channelService.SendMessage(ctx, 1001, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 2001, + Message: "plain group chatter", + Date: 11, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if !fanoutHasID(res.SkipDeliveryUserIDs, 1003) { + t.Fatalf("res.SkipDeliveryUserIDs = %v, want privacy bot 1003 excluded at send", res.SkipDeliveryUserIDs) + } + if fanoutHasID(res.Recipients, 1003) { + t.Fatalf("res.Recipients = %v, privacy bot 1003 must not be a delivery recipient", res.Recipients) + } + + // 两成员都在线;channelFanoutRecipients 据此把 1003 当在线活跃成员加回 recipients。 + sessions := &captureSessions{ + channelMembers: map[int64][]int64{created.Channel.ID: {1002, 1003}}, + } + r := New(Config{}, Deps{Channels: channelService, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + + // 复核前置:修复前后 channelFanoutRecipients 都会把 1003 列进 recipients(在线活跃成员), + // 漏洞/修复的差异在 build 是否对它返回 nil。 + got := r.channelFanoutRecipients(ctx, channelFanoutMembers, created.Channel.ID, res.Recipients) + if !fanoutHasID(got, 1003) { + t.Fatalf("channelFanoutRecipients = %v, 期望在线活跃成员 1003 仍被列入(否则测不到 build 跳过)", got) + } + + r.enqueueChannelMessageFanout(ctx, 1001, res, nil) + pushed := sessions.pushedUserIDs() + if !fanoutHasID(pushed, 1002) { + t.Fatalf("fanout pushed = %v, want human member 1002 to receive online push", pushed) + } + if fanoutHasID(pushed, 1003) { + t.Fatalf("fanout pushed = %v, online privacy bot 1003 must NOT receive plain-message push (privacy leak)", pushed) + } +} diff --git a/internal/rpc/channel_full_bot_cache.go b/internal/rpc/channel_full_bot_cache.go new file mode 100644 index 00000000..d40fe6f0 --- /dev/null +++ b/internal/rpc/channel_full_bot_cache.go @@ -0,0 +1,162 @@ +package rpc + +import ( + "context" + "fmt" + "time" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +const ( + channelFullBotInfoCacheTTL = 30 * time.Minute + channelFullBotInfoCacheMaxEntries = 4096 +) + +type channelFullBotInfoCacheKey struct { + viewerUserID int64 + channelID int64 +} + +// channelFullBotInfoCache 收敛到 projectionCache[K,V](epoch 守卫 / LRU / TTL / clone 由原语承载)。 +// 它走 Router 级 singleflight + 外部构建再写回(LoadEpoch→loadChannelFullBotInfo→StoreIfEpoch)。 +type channelFullBotInfoCache struct { + *projectionCache[channelFullBotInfoCacheKey, channelFullBotInfoResult] +} + +type channelFullBotInfoResult struct { + userIDs []int64 + botInfos []tg.BotInfo +} + +func newChannelFullBotInfoCache(clock func() time.Time) *channelFullBotInfoCache { + return &channelFullBotInfoCache{ + newProjectionCache[channelFullBotInfoCacheKey, channelFullBotInfoResult](channelFullBotInfoCacheMaxEntries, channelFullBotInfoCacheTTL, clock, cloneChannelFullBotInfoResult), + } +} + +func cloneChannelFullBotInfoResult(in channelFullBotInfoResult) channelFullBotInfoResult { + return channelFullBotInfoResult{ + userIDs: cloneInt64s(in.userIDs), + botInfos: cloneBotInfos(in.botInfos), + } +} + +func channelFullBotInfoSingleflightKey(viewerUserID, channelID int64) string { + return fmt.Sprintf("%d:%d", viewerUserID, channelID) +} + +func (c *channelFullBotInfoCache) Lookup(viewerUserID, channelID int64) (channelFullBotInfoResult, bool) { + if c == nil || viewerUserID == 0 || channelID == 0 { + return channelFullBotInfoResult{}, false + } + return c.lookup(channelFullBotInfoCacheKey{viewerUserID: viewerUserID, channelID: channelID}) +} + +// StoreIfEpoch 仅在 epoch 未变(构建期间没有失效)时写入。 +func (c *channelFullBotInfoCache) StoreIfEpoch(viewerUserID, channelID int64, value channelFullBotInfoResult, loadEpoch uint64) { + if c == nil || viewerUserID == 0 || channelID == 0 { + return + } + c.storeIfEpoch(channelFullBotInfoCacheKey{viewerUserID: viewerUserID, channelID: channelID}, value, loadEpoch) +} + +func (c *channelFullBotInfoCache) DeleteChannel(channelID int64) { + if c == nil || channelID == 0 { + return + } + c.deleteWhere(func(k channelFullBotInfoCacheKey) bool { return k.channelID == channelID }) +} + +func (r *Router) channelFullBotInfo(ctx context.Context, viewerUserID, channelID int64) channelFullBotInfoResult { + if r.deps.Channels == nil || viewerUserID == 0 || channelID == 0 { + return channelFullBotInfoResult{} + } + if cached, ok := r.channelFullBotCache.Lookup(viewerUserID, channelID); ok { + return cached + } + key := channelFullBotInfoSingleflightKey(viewerUserID, channelID) + value, _, _ := r.channelFullBotSF.Do(key, func() (any, error) { + if cached, ok := r.channelFullBotCache.Lookup(viewerUserID, channelID); ok { + return cached, nil + } + loadEpoch := r.channelFullBotCache.LoadEpoch() + result := r.loadChannelFullBotInfo(ctx, viewerUserID, channelID) + r.channelFullBotCache.StoreIfEpoch(viewerUserID, channelID, result, loadEpoch) + return result, nil + }) + if result, ok := value.(channelFullBotInfoResult); ok { + return result + } + return channelFullBotInfoResult{} +} + +func (r *Router) loadChannelFullBotInfo(ctx context.Context, viewerUserID, channelID int64) channelFullBotInfoResult { + list, err := r.deps.Channels.GetParticipants(ctx, viewerUserID, channelID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBots}, 0, domain.MaxChannelParticipantsLimit) + if err != nil { + return channelFullBotInfoResult{} + } + userIDs := make([]int64, 0, len(list.Participants)) + seen := make(map[int64]struct{}, len(list.Participants)) + for _, member := range list.Participants { + if member.UserID == 0 { + continue + } + if _, ok := seen[member.UserID]; ok { + continue + } + seen[member.UserID] = struct{}{} + userIDs = append(userIDs, member.UserID) + } + return channelFullBotInfoResult{ + userIDs: userIDs, + botInfos: r.tgBotInfos(ctx, userIDs), + } +} + +func (r *Router) invalidateChannelFullBotInfoCacheForChannel(channelID int64) { + if r.channelFullBotCache != nil { + r.channelFullBotCache.DeleteChannel(channelID) + } + r.invalidateRPCProjectionForChannel(channelID) +} + +func (r *Router) invalidateChannelFullBotInfoCache() { + if r.channelFullBotCache != nil { + r.channelFullBotCache.Clear() + } + if r.channelFullProjectionCache != nil { + r.channelFullProjectionCache.Clear() + } +} + +func (r *Router) InvalidateChannelFullBotInfoReadModel(channelID int64) { + r.invalidateChannelFullBotInfoCacheForChannel(channelID) +} + +func (r *Router) FlushChannelFullBotInfoReadModel() { + r.invalidateChannelFullBotInfoCache() +} + +func cloneInt64s(in []int64) []int64 { + if len(in) == 0 { + return nil + } + out := make([]int64, len(in)) + copy(out, in) + return out +} + +func cloneBotInfos(in []tg.BotInfo) []tg.BotInfo { + if len(in) == 0 { + return nil + } + out := make([]tg.BotInfo, len(in)) + copy(out, in) + for i := range out { + out[i].Commands = append([]tg.BotCommand(nil), out[i].Commands...) + } + return out +} diff --git a/internal/rpc/channels.go b/internal/rpc/channels.go deleted file mode 100644 index 3228f80c..00000000 --- a/internal/rpc/channels.go +++ /dev/null @@ -1,4014 +0,0 @@ -package rpc - -import ( - "context" - "errors" - "fmt" - "sort" - "strconv" - "strings" - "unicode/utf8" - - "github.com/gotd/td/tg" - "github.com/gotd/td/tgerr" - "go.uber.org/zap" - - "telesrv/internal/domain" -) - -const ( - maxChannelTitleLength = 128 - maxChannelAboutLength = 255 - maxChannelUsernameOrder = 32 - maxChannelReportMessageIDs = 100 - maxChannelSearchPostsLimit = 50 - maxChannelSearchPostsQuery = 256 - maxChannelPaidMessageStars = 10000 - maxChannelBoostsToUnblockRestrictions = 8 - maxChatAvailableReactions = 64 - maxChatInviteListLimit = 100 - maxChatInviteLinkLength = 256 - maxChatInviteSearchLength = 256 -) - -// registerChannels 注册超级群/频道相关 RPC。messages.createChat 在这里注册, -// 因为 telesrv 将普通群创建直接实现为 megagroup。 -func (r *Router) registerChannels(d *tg.ServerDispatcher) { - d.OnMessagesCreateChat(r.onMessagesCreateChat) - d.OnMessagesMigrateChat(r.onMessagesMigrateChat) - d.OnMessagesGetChats(r.onMessagesGetChats) - d.OnMessagesGetFullChat(r.onMessagesGetFullChat) - d.OnMessagesAddChatUser(r.onMessagesAddChatUser) - d.OnMessagesDeleteChatUser(r.onMessagesDeleteChatUser) - d.OnMessagesEditChatTitle(r.onMessagesEditChatTitle) - d.OnMessagesEditChatPhoto(r.onMessagesEditChatPhoto) - d.OnMessagesEditChatAdmin(r.onMessagesEditChatAdmin) - d.OnMessagesEditChatAbout(r.onMessagesEditChatAbout) - d.OnMessagesEditChatDefaultBannedRights(r.onMessagesEditChatDefaultBannedRights) - d.OnMessagesEditChatCreator(r.onMessagesEditChatCreator) - d.OnMessagesEditChatParticipantRank(r.onMessagesEditChatParticipantRank) - d.OnMessagesSetChatTheme(r.onMessagesSetChatTheme) - d.OnMessagesToggleNoForwards(r.onMessagesToggleNoForwards) - d.OnMessagesSetChatAvailableReactions(r.onMessagesSetChatAvailableReactions) - d.OnChannelsCreateChannel(r.onChannelsCreateChannel) - d.OnChannelsGetChannels(r.onChannelsGetChannels) - d.OnChannelsGetFullChannel(r.onChannelsGetFullChannel) - d.OnChannelsGetParticipants(r.onChannelsGetParticipants) - d.OnChannelsGetParticipant(r.onChannelsGetParticipant) - d.OnChannelsGetSendAs(r.onChannelsGetSendAs) - d.OnChannelsCheckUsername(r.onChannelsCheckUsername) - d.OnChannelsUpdateUsername(r.onChannelsUpdateUsername) - d.OnChannelsGetAdminedPublicChannels(r.onChannelsGetAdminedPublicChannels) - d.OnChannelsExportMessageLink(r.onChannelsExportMessageLink) - d.OnChannelsToggleSignatures(r.onChannelsToggleSignatures) - d.OnChannelsTogglePreHistoryHidden(r.onChannelsTogglePreHistoryHidden) - d.OnChannelsToggleSlowMode(r.onChannelsToggleSlowMode) - d.OnChannelsSetStickers(r.onChannelsSetStickers) - d.OnChannelsSetEmojiStickers(r.onChannelsSetEmojiStickers) - d.OnChannelsReorderUsernames(r.onChannelsReorderUsernames) - d.OnChannelsToggleUsername(r.onChannelsToggleUsername) - d.OnChannelsDeactivateAllUsernames(r.onChannelsDeactivateAllUsernames) - d.OnChannelsUpdateColor(r.onChannelsUpdateColor) - d.OnChannelsUpdateEmojiStatus(r.onChannelsUpdateEmojiStatus) - d.OnChannelsReadMessageContents(r.onChannelsReadMessageContents) - d.OnChannelsReportSpam(r.onChannelsReportSpam) - d.OnChannelsGetLeftChannels(r.onChannelsGetLeftChannels) - d.OnChannelsGetInactiveChannels(r.onChannelsGetInactiveChannels) - d.OnChannelsGetGroupsForDiscussion(r.onChannelsGetGroupsForDiscussion) - d.OnChannelsSetDiscussionGroup(r.onChannelsSetDiscussionGroup) - d.OnChannelsEditLocation(r.onChannelsEditLocation) - d.OnChannelsConvertToGigagroup(r.onChannelsConvertToGigagroup) - d.OnChannelsDeleteParticipantHistory(r.onChannelsDeleteParticipantHistory) - d.OnChannelsToggleJoinToSend(r.onChannelsToggleJoinToSend) - d.OnChannelsToggleJoinRequest(r.onChannelsToggleJoinRequest) - d.OnChannelsToggleForum(r.onChannelsToggleForum) - d.OnChannelsToggleAntiSpam(r.onChannelsToggleAntiSpam) - d.OnChannelsReportAntiSpamFalsePositive(r.onChannelsReportAntiSpamFalsePositive) - d.OnChannelsToggleParticipantsHidden(r.onChannelsToggleParticipantsHidden) - d.OnChannelsToggleViewForumAsMessages(r.onChannelsToggleViewForumAsMessages) - d.OnChannelsGetChannelRecommendations(r.onChannelsGetChannelRecommendations) - d.OnChannelsSetBoostsToUnblockRestrictions(r.onChannelsSetBoostsToUnblockRestrictions) - d.OnChannelsRestrictSponsoredMessages(r.onChannelsRestrictSponsoredMessages) - d.OnChannelsSearchPosts(r.onChannelsSearchPosts) - d.OnChannelsUpdatePaidMessagesPrice(r.onChannelsUpdatePaidMessagesPrice) - d.OnChannelsToggleAutotranslation(r.onChannelsToggleAutotranslation) - d.OnChannelsGetMessageAuthor(r.onChannelsGetMessageAuthor) - d.OnChannelsCheckSearchPostsFlood(r.onChannelsCheckSearchPostsFlood) - d.OnChannelsSetMainProfileTab(r.onChannelsSetMainProfileTab) - d.OnChannelsInviteToChannel(r.onChannelsInviteToChannel) - d.OnChannelsJoinChannel(r.onChannelsJoinChannel) - d.OnChannelsLeaveChannel(r.onChannelsLeaveChannel) - d.OnChannelsEditAdmin(r.onChannelsEditAdmin) - d.OnChannelsEditBanned(r.onChannelsEditBanned) - d.OnChannelsEditTitle(r.onChannelsEditTitle) - d.OnChannelsEditPhoto(r.onChannelsEditPhoto) - d.OnChannelsDeleteChannel(r.onChannelsDeleteChannel) - d.OnChannelsGetAdminLog(r.onChannelsGetAdminLog) - d.OnChannelsReadHistory(r.onChannelsReadHistory) - d.OnChannelsGetMessages(r.onChannelsGetMessages) - d.OnChannelsDeleteMessages(r.onChannelsDeleteMessages) - d.OnChannelsDeleteHistory(r.onChannelsDeleteHistory) - d.OnMessagesUpdatePinnedMessage(r.onMessagesUpdatePinnedMessage) - d.OnMessagesUnpinAllMessages(r.onMessagesUnpinAllMessages) - d.OnMessagesExportChatInvite(r.onMessagesExportChatInvite) - d.OnMessagesCheckChatInvite(r.onMessagesCheckChatInvite) - d.OnMessagesImportChatInvite(r.onMessagesImportChatInvite) - d.OnMessagesGetExportedChatInvites(r.onMessagesGetExportedChatInvites) - d.OnMessagesGetExportedChatInvite(r.onMessagesGetExportedChatInvite) - d.OnMessagesEditExportedChatInvite(r.onMessagesEditExportedChatInvite) - d.OnMessagesDeleteRevokedExportedChatInvites(r.onMessagesDeleteRevokedExportedChatInvites) - d.OnMessagesDeleteExportedChatInvite(r.onMessagesDeleteExportedChatInvite) - d.OnMessagesGetAdminsWithInvites(r.onMessagesGetAdminsWithInvites) - d.OnMessagesGetChatInviteImporters(r.onMessagesGetChatInviteImporters) - d.OnMessagesHideChatJoinRequest(r.onMessagesHideChatJoinRequest) - d.OnMessagesHideAllChatJoinRequests(r.onMessagesHideAllChatJoinRequests) - d.OnUpdatesGetChannelDifference(r.onUpdatesGetChannelDifference) -} - -func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreateChatRequest) (*tg.MessagesInvitedUsers, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - if !validChannelTitle(req.Title) || len(req.Users) > domain.MaxChannelInviteUsers { - return nil, channelInvalidErr(domain.ErrChannelTitleInvalid) - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - memberIDs, err := r.userIDsFromInputUsers(ctx, userID, req.Users) - if err != nil { - return nil, err - } - memberIDs = createChatInviteMemberIDs(memberIDs, userID) - date := int(r.clock.Now().Unix()) - r.log.Debug("messages.createChat resolved users", - zap.Int("input_users", len(req.Users)), - zap.Int("member_ids", len(memberIDs)), - zap.Int64s("member_user_ids", memberIDs), - ) - if len(memberIDs) == 0 { - return nil, usersTooFewErr() - } - createRes, err := r.deps.Channels.CreateMegagroupFromCreateChat(ctx, userID, domain.CreateChannelRequest{ - CreatorUserID: userID, - Title: req.Title, - TTLPeriod: req.TTLPeriod, - Date: date, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - r.addOnlineChannelMemberships(createRes.Channel.ID, channelMemberUserIDs(createRes.Members)...) - - responseRes := createRes - var inviteRes domain.CreateChannelResult - if len(memberIDs) > 0 { - inviteRes, err = r.deps.Channels.InviteToChannel(ctx, userID, createRes.Channel.ID, memberIDs, date) - if err != nil { - return nil, channelInviteErr(err) - } - r.addOnlineChannelMemberships(inviteRes.Channel.ID, channelMemberUserIDs(inviteRes.Members)...) - responseRes.Channel = inviteRes.Channel - responseRes.Members = mergeChannelMembers(createRes.Members, inviteRes.Members) - responseRes.Recipients = uniqueRecipientIDs(append(append([]int64{}, createRes.Recipients...), inviteRes.Recipients...)) - } - - updates := r.channelOperationUpdates(ctx, userID, responseRes) - if tdesktopCreateChatNeedsLegacyChat(ctx) { - updates = r.tdesktopCreateChatUpdates(ctx, userID, responseRes) - } - if inviteRes.Event.Pts != 0 { - inviteUpdates := r.channelOperationUpdates(ctx, userID, inviteRes) - if inviteUpdates != nil { - updates.Updates = append(updates.Updates, inviteUpdates.Updates...) - } - } - if inviteRes.Event.Pts != 0 { - r.pushChannelExplicitUpdates(ctx, userID, inviteRes.Channel.ID, memberIDs, func(viewerUserID int64) *tg.Updates { - return r.channelOperationUpdates(ctx, viewerUserID, inviteRes) - }) - } - return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil -} - -func (r *Router) onMessagesMigrateChat(ctx context.Context, chatID int64) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - if chatID <= 0 { - return nil, channelInvalidErr(domain.ErrChannelInvalid) - } - userID, view, err := r.channelChangeInfoView(ctx, &tg.InputChannel{ChannelID: chatID}) - if err != nil { - return nil, err - } - if !view.Channel.Megagroup { - return nil, channelInvalidErr(domain.ErrChannelInvalid) - } - return r.channelStateUpdates(userID, view.Channel), nil -} - -func (r *Router) onMessagesGetChats(ctx context.Context, ids []int64) (tg.MessagesChatsClass, error) { - if len(ids) > maxGetMessagesIDs { - return nil, limitInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - chats := make([]tg.ChatClass, 0, len(ids)) - if r.deps.Channels != nil { - for _, id := range ids { - if id <= 0 { - continue - } - view, err := r.deps.Channels.GetChannel(ctx, userID, id) - if err != nil { - if isChannelNotFound(err) { - continue - } - return nil, channelInvalidErr(err) - } - chats = append(chats, tgChannelChat(userID, view.Channel, &view.Self)) - } - } - return &tg.MessagesChats{Chats: chats}, nil -} - -func (r *Router) onMessagesGetFullChat(ctx context.Context, chatID int64) (*tg.MessagesChatFull, error) { - if chatID <= 0 { - return nil, channelInvalidErr(domain.ErrChannelInvalid) - } - return r.onChannelsGetFullChannel(ctx, &tg.InputChannel{ChannelID: chatID}) -} - -func (r *Router) onMessagesAddChatUser(ctx context.Context, req *tg.MessagesAddChatUserRequest) (*tg.MessagesInvitedUsers, error) { - if req.ChatID <= 0 || req.UserID == nil { - return nil, peerIDInvalidErr() - } - return r.onChannelsInviteToChannel(ctx, &tg.ChannelsInviteToChannelRequest{ - Channel: &tg.InputChannel{ChannelID: req.ChatID}, - Users: []tg.InputUserClass{req.UserID}, - }) -} - -func (r *Router) onMessagesDeleteChatUser(ctx context.Context, req *tg.MessagesDeleteChatUserRequest) (tg.UpdatesClass, error) { - if req.ChatID <= 0 || req.UserID == nil { - return nil, peerIDInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - target, found, err := r.userFromInput(ctx, userID, req.UserID) - if err != nil { - return nil, internalErr() - } - if !found || target.ID == 0 { - return nil, peerIDInvalidErr() - } - if target.ID == userID { - return r.onChannelsLeaveChannel(ctx, &tg.InputChannel{ChannelID: req.ChatID}) - } - return r.onChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{ - Channel: &tg.InputChannel{ChannelID: req.ChatID}, - Participant: &tg.InputPeerUser{UserID: target.ID, AccessHash: target.AccessHash}, - BannedRights: tg.ChatBannedRights{ - ViewMessages: true, - UntilDate: 0, - }, - }) -} - -func (r *Router) onMessagesEditChatTitle(ctx context.Context, req *tg.MessagesEditChatTitleRequest) (tg.UpdatesClass, error) { - if req.ChatID <= 0 { - return nil, channelInvalidErr(domain.ErrChannelInvalid) - } - return r.onChannelsEditTitle(ctx, &tg.ChannelsEditTitleRequest{ - Channel: &tg.InputChannel{ChannelID: req.ChatID}, - Title: req.Title, - }) -} - -func (r *Router) onMessagesEditChatPhoto(ctx context.Context, req *tg.MessagesEditChatPhotoRequest) (tg.UpdatesClass, error) { - if req.ChatID <= 0 || req.Photo == nil { - return nil, channelInvalidErr(domain.ErrChannelInvalid) - } - return r.onChannelsEditPhoto(ctx, &tg.ChannelsEditPhotoRequest{ - Channel: &tg.InputChannel{ChannelID: req.ChatID}, - Photo: req.Photo, - }) -} - -func (r *Router) onMessagesEditChatAdmin(ctx context.Context, req *tg.MessagesEditChatAdminRequest) (bool, error) { - if req.ChatID <= 0 || req.UserID == nil { - return false, peerIDInvalidErr() - } - rights := tg.ChatAdminRights{} - if req.IsAdmin { - rights = legacyBasicGroupAdminRights() - } - _, err := r.onChannelsEditAdmin(ctx, &tg.ChannelsEditAdminRequest{ - Channel: &tg.InputChannel{ChannelID: req.ChatID}, - UserID: req.UserID, - AdminRights: rights, - }) - if err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEditChatAboutRequest) (bool, error) { - if r.deps.Channels == nil { - return false, notImplementedErr() - } - if utf8.RuneCountInString(req.About) > maxChannelAboutLength { - return false, channelInvalidErr(domain.ErrChannelInvalid) - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) - if err != nil { - return false, err - } - channel, err := r.deps.Channels.EditAbout(ctx, userID, domain.EditChannelAboutRequest{ - UserID: userID, - ChannelID: channelID, - About: req.About, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return false, channelAdminErr(err) - } - r.pushChannelStateToMembers(ctx, userID, channel) - return true, nil -} - -func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req *tg.MessagesEditChatDefaultBannedRightsRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { - return nil, peerIDInvalidErr() - } - channel, err := r.deps.Channels.EditDefaultBannedRights(ctx, userID, domain.EditChannelDefaultBannedRightsRequest{ - UserID: userID, - ChannelID: peer.ID, - BannedRights: domainChannelBannedRights(req.BannedRights), - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelStateUpdates(userID, channel) - r.pushChannelStateToMembers(ctx, userID, channel) - return updates, nil -} - -func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.MessagesEditChatCreatorRequest) (tg.UpdatesClass, error) { - if req.UserID == nil { - return nil, peerIDInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if _, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err != nil { - return nil, err - } - if _, found, err := r.userFromInput(ctx, userID, req.UserID); err != nil { - return nil, internalErr() - } else if !found { - return nil, peerIDInvalidErr() - } - return nil, tgerr.New(400, "PASSWORD_HASH_INVALID") -} - -func (r *Router) onMessagesEditChatParticipantRank(ctx context.Context, req *tg.MessagesEditChatParticipantRankRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - if len(req.Rank) > domain.MaxChannelAdminRankLength { - return nil, channelInvalidErr(domain.ErrChannelInvalid) - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - participant, ok := r.domainPeerFromInputPeer(userID, req.Participant) - if !ok || participant.Type != domain.PeerTypeUser || participant.ID == 0 { - return nil, peerIDInvalidErr() - } - member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, participant.ID) - if err != nil { - return nil, channelAdminErr(err) - } - return r.onChannelsEditAdmin(ctx, &tg.ChannelsEditAdminRequest{ - Channel: &tg.InputChannel{ChannelID: channelID}, - UserID: &tg.InputUser{UserID: participant.ID}, - AdminRights: tgChatAdminRights(member.AdminRights), - Rank: req.Rank, - }) -} - -func (r *Router) onMessagesSetChatTheme(ctx context.Context, req *tg.MessagesSetChatThemeRequest) (tg.UpdatesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err == nil { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - view, err := r.deps.Channels.GetChannel(ctx, userID, channelID) - if err != nil { - return nil, channelInvalidErr(err) - } - return r.channelStateUpdates(userID, view.Channel), nil - } else if _, ok := channelIDFromLegacyInputPeer(userID, req.Peer); ok { - return nil, err - } - peer, ok := r.domainPeerFromInputPeer(userID, req.Peer) - if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { - return nil, peerIDInvalidErr() - } - return tgEmptyUpdates(int(r.clock.Now().Unix())), nil -} - -func (r *Router) onMessagesToggleNoForwards(ctx context.Context, req *tg.MessagesToggleNoForwardsRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetNoForwards(ctx, userID, channelID, req.Enabled) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelStateUpdates(userID, channel) - r.pushChannelStateToMembers(ctx, userID, channel) - return updates, nil -} - -func (r *Router) onMessagesSetChatAvailableReactions(ctx context.Context, req *tg.MessagesSetChatAvailableReactionsRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - policy, err := domainChannelReactionPolicy(req) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetAvailableReactions(ctx, userID, channelID, policy) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelStateUpdates(userID, channel) - r.pushChannelStateToMembers(ctx, userID, channel) - return updates, nil -} - -func (r *Router) onChannelsCreateChannel(ctx context.Context, req *tg.ChannelsCreateChannelRequest) (tg.UpdatesClass, error) { - if err := validateChannelsCreateChannelOptions(req); err != nil { - return nil, err - } - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - if !validChannelTitle(req.Title) || utf8.RuneCountInString(req.About) > maxChannelAboutLength { - return nil, channelInvalidErr(domain.ErrChannelTitleInvalid) - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - res, err := r.deps.Channels.CreateChannel(ctx, userID, domain.CreateChannelRequest{ - CreatorUserID: userID, - Title: req.Title, - About: req.About, - Broadcast: req.Broadcast, - Megagroup: req.Megagroup, - Forum: req.Forum, - ForumTabs: req.Forum, - TTLPeriod: req.TTLPeriod, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelInvalidErr(err) - } - r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) - updates := r.channelOperationUpdates(ctx, userID, res) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelOperationUpdates(ctx, viewerUserID, res) - }) - return updates, nil -} - -func validateChannelsCreateChannelOptions(req *tg.ChannelsCreateChannelRequest) error { - if req == nil { - return inputRequestInvalidErr() - } - if req.ForImport { - return chatInvalidErr() - } - if req.GeoPoint != nil || req.Address != "" { - return addressInvalidErr() - } - if req.TTLPeriod < 0 { - return ttlPeriodInvalidErr() - } - return nil -} - -func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChannelClass) (tg.MessagesChatsClass, error) { - if len(ids) > maxGetMessagesIDs { - return nil, limitInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - chats := make([]tg.ChatClass, 0, len(ids)) - for _, input := range ids { - ref, ok := inputChannelRef(input) - if !ok || ref.ID == 0 || r.deps.Channels == nil { - continue - } - view, err := r.deps.Channels.GetChannel(ctx, userID, ref.ID) - if err != nil { - if isChannelNotFound(err) { - continue - } - return nil, internalErr() - } - if !inputChannelAccessHashMatches(ref, view.Channel) { - continue - } - chats = append(chats, tgChannelChat(userID, view.Channel, &view.Self)) - } - return &tg.MessagesChats{Chats: chats}, nil -} - -func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputChannelClass) (*tg.MessagesChatFull, error) { - if r.deps.Channels == nil { - return &tg.MessagesChatFull{}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - _, view, err := r.channelView(ctx, input) - if err != nil { - return nil, err - } - full := tgChannelFull(view) - userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID} - if canViewChannelJoinRequests(view.Self) { - userIDs = r.applyPendingJoinRequestsToFullChannel(ctx, full, view.Channel.ID, userIDs) - } - r.trackChannelInterest(ctx, userID, view.Channel.ID) - return &tg.MessagesChatFull{ - FullChat: full, - Chats: []tg.ChatClass{tgChannelChat(userID, view.Channel, &view.Self)}, - Users: r.tgUsersForIDs(ctx, userID, userIDs), - }, nil -} - -func (r *Router) onChannelsGetSendAs(ctx context.Context, req *tg.ChannelsGetSendAsRequest) (*tg.ChannelsSendAsPeers, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if userID == 0 { - return nil, peerIDInvalidErr() - } - peer, ok := r.domainPeerFromInputPeer(userID, req.Peer) - if !ok || peer.ID == 0 { - return nil, peerIDInvalidErr() - } - chats := []tg.ChatClass(nil) - peers := []tg.SendAsPeer{{Peer: &tg.PeerUser{UserID: userID}}} - if peer.Type == domain.PeerTypeChannel { - if r.deps.Channels == nil { - return &tg.ChannelsSendAsPeers{}, nil - } - view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) - if err != nil { - return nil, channelInvalidErr(err) - } - if ref, ok := inputPeerChannelRef(req.Peer); ok { - if ref.ID != view.Channel.ID || (ref.CheckAccessHash && !inputChannelAccessHashMatches(ref, view.Channel)) { - return nil, channelInvalidErr(domain.ErrChannelPrivate) - } - } - chats = []tg.ChatClass{tgChannelChat(userID, view.Channel, &view.Self)} - if canCurrentChannelSendAs(view) { - peers = append(peers, tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}}) - } - } - return &tg.ChannelsSendAsPeers{ - Peers: peers, - Chats: chats, - Users: r.tgUsersForIDs(ctx, userID, []int64{userID}), - }, nil -} - -func (r *Router) onChannelsCheckUsername(ctx context.Context, req *tg.ChannelsCheckUsernameRequest) (bool, error) { - if r.deps.Channels == nil { - return false, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return false, err - } - okUsername, err := r.deps.Channels.CheckUsername(ctx, userID, channelID, req.Username) - if err != nil { - return false, channelUsernameErr(err) - } - return okUsername, nil -} - -func (r *Router) onChannelsUpdateUsername(ctx context.Context, req *tg.ChannelsUpdateUsernameRequest) (bool, error) { - if r.deps.Channels == nil { - return false, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return false, err - } - channel, err := r.deps.Channels.UpdateUsername(ctx, userID, domain.UpdateChannelUsernameRequest{ - UserID: userID, - ChannelID: channelID, - Username: req.Username, - }) - if err != nil { - return false, channelUsernameErr(err) - } - r.pushChannelStateToMembers(ctx, userID, channel) - return true, nil -} - -func (r *Router) onChannelsGetAdminedPublicChannels(ctx context.Context, req *tg.ChannelsGetAdminedPublicChannelsRequest) (tg.MessagesChatsClass, error) { - if r.deps.Channels == nil { - return &tg.MessagesChats{}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.ByLocation { - return &tg.MessagesChats{}, nil - } - channels, err := r.deps.Channels.ListAdminedPublicChannels(ctx, userID) - if err != nil { - return nil, internalErr() - } - return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil -} - -func (r *Router) onChannelsExportMessageLink(ctx context.Context, req *tg.ChannelsExportMessageLinkRequest) (*tg.ExportedMessageLink, error) { - if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - userID, view, err := r.channelView(ctx, req.Channel) - if err != nil { - return nil, err - } - history, err := r.deps.Channels.GetMessages(ctx, userID, view.Channel.ID, []int{req.ID}) - if err != nil { - return nil, channelInvalidErr(err) - } - if len(history.Messages) != 1 || history.Messages[0].ID != req.ID { - return nil, messageIDInvalidErr() - } - link := "" - if view.Channel.Username != "" { - link = "https://t.me/" + view.Channel.Username + "/" + strconv.Itoa(req.ID) - } else { - link = "https://t.me/c/" + strconv.FormatInt(view.Channel.ID, 10) + "/" + strconv.Itoa(req.ID) - } - if req.Thread { - if rootID := channelMessageThreadRootID(history.Messages[0]); rootID > 0 && rootID != req.ID { - link += "?thread=" + strconv.Itoa(rootID) - } - } - return &tg.ExportedMessageLink{Link: link, HTML: ""}, nil -} - -func channelMessageThreadRootID(msg domain.ChannelMessage) int { - if msg.ReplyTo == nil { - return 0 - } - if msg.ReplyTo.TopMessageID > 0 { - return msg.ReplyTo.TopMessageID - } - return msg.ReplyTo.MessageID -} - -func (r *Router) onChannelsToggleSignatures(ctx context.Context, req *tg.ChannelsToggleSignaturesRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetSignatures(ctx, userID, channelID, req.SignaturesEnabled) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelStateUpdates(userID, channel) - r.pushChannelStateToMembers(ctx, userID, channel) - return updates, nil -} - -func (r *Router) onChannelsTogglePreHistoryHidden(ctx context.Context, req *tg.ChannelsTogglePreHistoryHiddenRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - viewerUserID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, viewerUserID, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetPreHistoryHidden(ctx, viewerUserID, channelID, req.Enabled) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelStateUpdates(viewerUserID, channel) - r.pushChannelStateToMembers(ctx, viewerUserID, channel) - return updates, nil -} - -func (r *Router) onChannelsToggleSlowMode(ctx context.Context, req *tg.ChannelsToggleSlowModeRequest) (tg.UpdatesClass, error) { - if !domain.ValidChannelSlowModeSeconds(req.Seconds) { - return nil, secondsInvalidErr() - } - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - viewerUserID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, viewerUserID, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetSlowMode(ctx, viewerUserID, channelID, req.Seconds) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelStateUpdates(viewerUserID, channel) - r.pushChannelStateToMembers(ctx, viewerUserID, channel) - return updates, nil -} - -func (r *Router) onChannelsSetStickers(ctx context.Context, req *tg.ChannelsSetStickersRequest) (bool, error) { - _, view, err := r.channelChangeInfoView(ctx, req.Channel) - if err != nil { - return false, err - } - if !view.Channel.Megagroup || view.Channel.Broadcast { - return false, channelInvalidErr(domain.ErrChannelInvalid) - } - if err := validateEmptyChannelStickerSet(req.Stickerset); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onChannelsSetEmojiStickers(ctx context.Context, req *tg.ChannelsSetEmojiStickersRequest) (bool, error) { - _, view, err := r.channelChangeInfoView(ctx, req.Channel) - if err != nil { - return false, err - } - if !view.Channel.Megagroup || view.Channel.Broadcast { - return false, channelInvalidErr(domain.ErrChannelInvalid) - } - if err := validateEmptyChannelStickerSet(req.Stickerset); err != nil { - return false, err - } - return true, nil -} - -func validateEmptyChannelStickerSet(stickerset tg.InputStickerSetClass) error { - if _, ok := stickerset.(*tg.InputStickerSetEmpty); ok { - return nil - } - return stickersetInvalidErr() -} - -func (r *Router) onChannelsReorderUsernames(ctx context.Context, req *tg.ChannelsReorderUsernamesRequest) (bool, error) { - if len(req.Order) > maxChannelUsernameOrder { - return false, limitInvalidErr() - } - if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onChannelsToggleUsername(ctx context.Context, req *tg.ChannelsToggleUsernameRequest) (bool, error) { - if req.Username != "" && !validChannelManagementUsername(req.Username) { - return false, usernameInvalidErr() - } - if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onChannelsDeactivateAllUsernames(ctx context.Context, input tg.InputChannelClass) (bool, error) { - if _, _, err := r.channelChangeInfoView(ctx, input); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onChannelsUpdateColor(ctx context.Context, req *tg.ChannelsUpdateColorRequest) (tg.UpdatesClass, error) { - viewerUserID, view, err := r.channelChangeInfoView(ctx, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetColor(ctx, viewerUserID, view.Channel.ID, req.ForProfile, domainPeerColorFromChannelUpdate(req)) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelStateUpdates(viewerUserID, channel) - r.pushChannelStateToMembers(ctx, viewerUserID, channel) - return updates, nil -} - -func (r *Router) onChannelsUpdateEmojiStatus(ctx context.Context, req *tg.ChannelsUpdateEmojiStatusRequest) (tg.UpdatesClass, error) { - viewerUserID, view, err := r.channelChangeInfoView(ctx, req.Channel) - if err != nil { - return nil, err - } - status, err := domainChannelEmojiStatus(req.EmojiStatus) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetEmojiStatus(ctx, viewerUserID, view.Channel.ID, status) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelStateUpdates(viewerUserID, channel) - r.pushChannelStateToMembers(ctx, viewerUserID, channel) - return updates, nil -} - -func (r *Router) onChannelsReadMessageContents(ctx context.Context, req *tg.ChannelsReadMessageContentsRequest) (bool, error) { - if r.deps.Channels == nil { - return false, notImplementedErr() - } - if req == nil { - return false, inputRequestInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return false, err - } - read, err := r.deps.Channels.ReadMessageContents(ctx, userID, domain.ReadChannelMessageContentsRequest{ - UserID: userID, - ChannelID: channelID, - IDs: req.ID, - }) - if err != nil { - if errors.Is(err, domain.ErrMessageIDInvalid) { - return false, messageIDInvalidErr() - } - return false, channelInvalidErr(err) - } - if ids := readChannelMessageContentIDs(read.Messages); len(ids) > 0 { - r.pushUserUpdates(ctx, userID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateChannelReadMessagesContents{ - ChannelID: read.Channel.ID, - Messages: ids, - }}, - Users: []tg.UserClass{}, - Chats: []tg.ChatClass{tgChannelChat(userID, read.Channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - }) - } - if len(read.ClearedUnreadReactionMessageIDs) > 0 { - r.pushUserUpdates(ctx, userID, r.channelMessagesReactionsUpdates(ctx, userID, domain.ChannelMessageReactionsResult{ - Channel: read.Channel, - Messages: read.Messages, - }, read.ClearedUnreadReactionMessageIDs)) - } - return true, nil -} - -func readChannelMessageContentIDs(messages []domain.ChannelMessage) []int { - if len(messages) == 0 { - return nil - } - ids := make([]int, 0, len(messages)) - seen := make(map[int]struct{}, len(messages)) - for _, msg := range messages { - if msg.ID <= 0 { - continue - } - if _, ok := seen[msg.ID]; ok { - continue - } - seen[msg.ID] = struct{}{} - ids = append(ids, msg.ID) - } - sort.Ints(ids) - return ids -} - -func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsReportSpamRequest) (bool, error) { - if len(req.ID) > maxChannelReportMessageIDs { - return false, limitInvalidErr() - } - for _, id := range req.ID { - if id <= 0 || id > domain.MaxMessageBoxID { - return false, messageIDInvalidErr() - } - } - if _, _, err := r.channelView(ctx, req.Channel); err != nil { - return false, err - } - if peer, ok := r.domainPeerFromInputPeer(0, req.Participant); !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { - return false, peerIDInvalidErr() - } - return true, nil -} - -func (r *Router) onChannelsGetLeftChannels(ctx context.Context, offset int) (tg.MessagesChatsClass, error) { - if offset < 0 || offset > domain.MaxLeftChannelsOffset { - return nil, limitInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, err - } - if r.deps.Channels == nil { - return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil - } - list, err := r.deps.Channels.LeftChannels(ctx, userID, offset, domain.MaxLeftChannelsLimit) - if err != nil { - return nil, channelInvalidErr(err) - } - chats := make([]tg.ChatClass, 0, len(list.Channels)) - for _, item := range list.Channels { - chats = append(chats, tgChannelChat(userID, item.Channel, &item.Self)) - } - if len(chats) == 0 && list.Count > 0 { - return &tg.MessagesChatsSlice{Count: list.Count, Chats: chats}, nil - } - if offset+len(chats) < list.Count { - return &tg.MessagesChatsSlice{Count: list.Count, Chats: chats}, nil - } - return &tg.MessagesChats{Chats: chats}, nil -} - -func (r *Router) onChannelsGetInactiveChannels(ctx context.Context) (*tg.MessagesInactiveChats, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, err - } - if r.deps.Channels == nil { - return &tg.MessagesInactiveChats{Dates: []int{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil - } - list, err := r.deps.Channels.InactiveChannels(ctx, userID, domain.MaxInactiveChannelsLimit) - if err != nil { - return nil, channelInvalidErr(err) - } - dates := make([]int, 0, len(list.Channels)) - chats := make([]tg.ChatClass, 0, len(list.Channels)) - for i, channel := range list.Channels { - date := channel.Date - if i < len(list.Dialogs) && list.Dialogs[i].TopMessageDate > 0 { - date = list.Dialogs[i].TopMessageDate - } - dates = append(dates, date) - chats = append(chats, tgChannelChat(userID, channel, nil)) - } - return &tg.MessagesInactiveChats{Dates: dates, Chats: chats, Users: []tg.UserClass{}}, nil -} - -func (r *Router) onChannelsGetGroupsForDiscussion(ctx context.Context) (tg.MessagesChatsClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if r.deps.Channels == nil { - return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil - } - channels, err := r.deps.Channels.DiscussionGroups(ctx, userID, domain.MaxDiscussionGroupsLimit) - if err != nil { - return nil, channelInvalidErr(err) - } - return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil -} - -func (r *Router) onChannelsSetDiscussionGroup(ctx context.Context, req *tg.ChannelsSetDiscussionGroupRequest) (bool, error) { - if r.deps.Channels == nil { - return false, notImplementedErr() - } - if req == nil { - return false, channelInvalidErr(domain.ErrChannelInvalid) - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - broadcastID, err := r.optionalChannelIDFromInput(ctx, userID, req.Broadcast) - if err != nil { - return false, channelDiscussionErr(err) - } - groupID, err := r.optionalChannelIDFromInput(ctx, userID, req.Group) - if err != nil { - return false, channelDiscussionErr(err) - } - res, err := r.deps.Channels.SetDiscussionGroup(ctx, userID, broadcastID, groupID) - if err != nil { - return false, channelDiscussionErr(err) - } - for _, channel := range res.Channels { - r.pushChannelStateToMembers(ctx, userID, channel) - } - return true, nil -} - -func (r *Router) onChannelsEditLocation(ctx context.Context, req *tg.ChannelsEditLocationRequest) (bool, error) { - if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onChannelsConvertToGigagroup(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { - userID, view, err := r.channelChangeInfoView(ctx, input) - if err != nil { - return nil, err - } - return r.channelStateUpdates(userID, view.Channel), nil -} - -func (r *Router) onChannelsDeleteParticipantHistory(ctx context.Context, req *tg.ChannelsDeleteParticipantHistoryRequest) (*tg.MessagesAffectedHistory, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - peer, ok := r.domainPeerFromInputPeer(0, req.Participant) - if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { - return nil, peerIDInvalidErr() - } - res, err := r.deps.Channels.DeleteParticipantHistory(ctx, userID, domain.DeleteChannelParticipantHistoryRequest{ - UserID: userID, - ChannelID: channelID, - ParticipantUserID: peer.ID, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelDeleteErr(err) - } - if res.Event.Pts != 0 { - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) - }) - } - return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil -} - -func (r *Router) onChannelsToggleJoinToSend(ctx context.Context, req *tg.ChannelsToggleJoinToSendRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetJoinToSend(ctx, userID, channelID, req.Enabled) - if err != nil { - return nil, channelAdminErr(err) - } - r.pushChannelStateToMembers(ctx, userID, channel) - return r.channelStateUpdates(userID, channel), nil -} - -func (r *Router) onChannelsToggleJoinRequest(ctx context.Context, req *tg.ChannelsToggleJoinRequestRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetJoinRequest(ctx, userID, channelID, req.Enabled) - if err != nil { - return nil, channelAdminErr(err) - } - r.pushChannelStateToMembers(ctx, userID, channel) - return r.channelStateUpdates(userID, channel), nil -} - -func (r *Router) onChannelsToggleForum(ctx context.Context, req *tg.ChannelsToggleForumRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetForum(ctx, userID, channelID, req.Enabled, req.Tabs) - if err != nil { - return nil, channelAdminErr(err) - } - r.pushChannelStateToMembers(ctx, userID, channel) - return r.channelStateUpdates(userID, channel), nil -} - -func (r *Router) onChannelsToggleAntiSpam(ctx context.Context, req *tg.ChannelsToggleAntiSpamRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetAntiSpam(ctx, userID, channelID, req.Enabled) - if err != nil { - return nil, channelAdminErr(err) - } - r.pushChannelStateToMembers(ctx, userID, channel) - return r.channelStateUpdates(userID, channel), nil -} - -func (r *Router) onChannelsReportAntiSpamFalsePositive(ctx context.Context, req *tg.ChannelsReportAntiSpamFalsePositiveRequest) (bool, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return false, messageIDInvalidErr() - } - if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onChannelsToggleParticipantsHidden(ctx context.Context, req *tg.ChannelsToggleParticipantsHiddenRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetParticipantsHidden(ctx, userID, channelID, req.Enabled) - if err != nil { - return nil, channelAdminErr(err) - } - r.pushChannelStateToMembers(ctx, userID, channel) - return r.channelStateUpdates(userID, channel), nil -} - -func (r *Router) onChannelsToggleViewForumAsMessages(ctx context.Context, req *tg.ChannelsToggleViewForumAsMessagesRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - changed, err := r.deps.Channels.SetViewForumAsMessages(ctx, userID, channelID, req.Enabled) - if err != nil { - return nil, channelInvalidErr(err) - } - if !changed { - return tgEmptyUpdates(int(r.clock.Now().Unix())), nil - } - event := domain.UpdateEvent{ - Type: domain.UpdateEventChannelViewForum, - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, - Bool: req.Enabled, - PtsCount: 1, - Date: int(r.clock.Now().Unix()), - } - if r.deps.Updates != nil { - authKeyID, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - event, _, err = r.deps.Updates.RecordChannelViewForumAsMessages(ctx, authKeyID, userID, channelID, req.Enabled, sessionID) - if err != nil { - return nil, internalErr() - } - } - out := tgUpdateForOutboxEvent(event) - if out == nil { - out = tgEmptyUpdates(event.Date) - } - r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out) - return out, nil -} - -func (r *Router) onChannelsGetChannelRecommendations(ctx context.Context, req *tg.ChannelsGetChannelRecommendationsRequest) (tg.MessagesChatsClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if r.deps.Channels == nil { - return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil - } - sourceChannelID := int64(0) - if req != nil { - if input, ok := req.GetChannel(); ok { - source, err := r.publicRecommendationSourceChannel(ctx, userID, input) - if err != nil { - return nil, err - } - sourceChannelID = source - } - } - res, err := r.deps.Channels.ChannelRecommendations(ctx, userID, domain.ChannelRecommendationsRequest{ - UserID: userID, - SourceChannelID: sourceChannelID, - Limit: domain.DefaultChannelRecommendationsLimit, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - chats := tgChannels(userID, res.Channels) - if res.Count > len(chats) { - return &tg.MessagesChatsSlice{Count: res.Count, Chats: chats}, nil - } - return &tg.MessagesChats{Chats: chats}, nil -} - -func (r *Router) publicRecommendationSourceChannel(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) { - ref, ok := inputChannelRef(input) - if !ok { - return 0, channelInvalidErr(domain.ErrChannelInvalid) - } - channel, err := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID) - if err != nil { - return 0, channelInvalidErr(err) - } - if !inputChannelAccessHashMatches(ref, channel) { - return 0, channelInvalidErr(domain.ErrChannelPrivate) - } - if channel.Deleted || !channel.Broadcast || channel.Megagroup || channel.Username == "" { - return 0, channelInvalidErr(domain.ErrChannelInvalid) - } - return channel.ID, nil -} - -func (r *Router) onChannelsSetBoostsToUnblockRestrictions(ctx context.Context, req *tg.ChannelsSetBoostsToUnblockRestrictionsRequest) (tg.UpdatesClass, error) { - if req == nil { - return nil, inputRequestInvalidErr() - } - if req.Boosts < 0 || req.Boosts > maxChannelBoostsToUnblockRestrictions { - return nil, limitInvalidErr() - } - return r.channelStateCompatUpdate(ctx, req.Channel) -} - -func (r *Router) onChannelsRestrictSponsoredMessages(ctx context.Context, req *tg.ChannelsRestrictSponsoredMessagesRequest) (tg.UpdatesClass, error) { - if req == nil { - return nil, inputRequestInvalidErr() - } - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, view, err := r.channelChangeInfoView(ctx, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetRestrictedSponsored(ctx, userID, view.Channel.ID, req.Restricted) - if err != nil { - return nil, channelAdminErr(err) - } - r.pushChannelStateToMembers(ctx, userID, channel) - return r.channelStateUpdates(userID, channel), nil -} - -func (r *Router) onChannelsSearchPosts(ctx context.Context, req *tg.ChannelsSearchPostsRequest) (tg.MessagesMessagesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if err := validateChannelSearchPostsRequest(req); err != nil { - return nil, err - } - offsetChannelID, err := r.searchPostsOffsetChannelID(ctx, userID, req.OffsetPeer) - if err != nil { - return nil, err - } - if r.deps.Channels == nil { - return &tg.MessagesMessages{Messages: []tg.MessageClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil - } - hashtag, query := channelSearchPostsTerms(req) - history, err := r.deps.Channels.SearchPosts(ctx, userID, domain.ChannelSearchPostsRequest{ - Hashtag: hashtag, - Query: query, - OffsetRate: req.OffsetRate, - OffsetChannelID: offsetChannelID, - OffsetID: req.OffsetID, - Limit: req.Limit, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - history = r.enrichChannelHistory(ctx, userID, history) - return tgChannelSearchPostsMessages(userID, history), nil -} - -func validateChannelSearchPostsRequest(req *tg.ChannelsSearchPostsRequest) error { - if req == nil { - return inputRequestInvalidErr() - } - if req.Limit < 0 || req.Limit > maxChannelSearchPostsLimit { - return limitInvalidErr() - } - if req.OffsetRate < 0 { - return limitInvalidErr() - } - if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID { - return messageIDInvalidErr() - } - if req.AllowPaidStars < 0 { - return limitInvalidErr() - } - hashtag, hasHashtag, query, hasQuery := channelSearchPostsTermsWithFlags(req) - if hasHashtag == hasQuery { - return searchQueryEmptyErr() - } - if hasHashtag { - if strings.TrimSpace(hashtag) == "" { - return searchQueryEmptyErr() - } - if strings.Contains(hashtag, "#") || utf8.RuneCountInString(hashtag) > maxChannelSearchPostsQuery { - return limitInvalidErr() - } - } - if hasQuery { - if strings.TrimSpace(query) == "" { - return searchQueryEmptyErr() - } - if utf8.RuneCountInString(query) > maxChannelSearchPostsQuery { - return limitInvalidErr() - } - } - return nil -} - -func channelSearchPostsTerms(req *tg.ChannelsSearchPostsRequest) (hashtag, query string) { - hashtag, _, query, _ = channelSearchPostsTermsWithFlags(req) - return strings.TrimSpace(hashtag), strings.TrimSpace(query) -} - -func channelSearchPostsTermsWithFlags(req *tg.ChannelsSearchPostsRequest) (hashtag string, hasHashtag bool, query string, hasQuery bool) { - hashtag, hasHashtag = req.GetHashtag() - if !hasHashtag && req.Hashtag != "" { - hashtag, hasHashtag = req.Hashtag, true - } - query, hasQuery = req.GetQuery() - if !hasQuery && req.Query != "" { - query, hasQuery = req.Query, true - } - return hashtag, hasHashtag, query, hasQuery -} - -func (r *Router) validateSearchPostsOffsetPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) error { - _, err := r.searchPostsOffsetChannelID(ctx, userID, peer) - return err -} - -func (r *Router) searchPostsOffsetChannelID(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) { - if peer == nil { - return 0, nil - } - if _, ok := peer.(*tg.InputPeerEmpty); ok { - return 0, nil - } - out, ok := r.domainPeerFromInputPeer(userID, peer) - if !ok || out.ID == 0 { - return 0, peerIDInvalidErr() - } - if out.Type != domain.PeerTypeChannel { - return 0, peerIDInvalidErr() - } - ref, ok := inputPeerChannelRef(peer) - if !ok || !ref.CheckAccessHash || r.deps.Channels == nil { - return out.ID, nil - } - view, err := r.deps.Channels.GetChannel(ctx, userID, out.ID) - if err == nil { - if !inputChannelAccessHashMatches(ref, view.Channel) { - return 0, channelInvalidErr(domain.ErrChannelPrivate) - } - return out.ID, nil - } - if !errors.Is(err, domain.ErrChannelPrivate) { - return 0, channelInvalidErr(err) - } - channel, joinErr := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID) - if joinErr != nil || channel.Username == "" || !inputChannelAccessHashMatches(ref, channel) { - return 0, channelInvalidErr(err) - } - return out.ID, nil -} - -func tgChannelSearchPostsMessages(viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass { - messages := make([]tg.MessageClass, 0, len(history.Messages)) - for _, msg := range history.Messages { - if item := tgChannelMessage(viewerUserID, msg); item != nil { - messages = append(messages, item) - } - } - chats := tgChannels(viewerUserID, history.Channels) - users := tgUsers(history.Users) - if history.Count > len(messages) { - out := &tg.MessagesMessagesSlice{ - Count: history.Count, - Messages: messages, - Topics: []tg.ForumTopicClass{}, - Chats: chats, - Users: users, - } - if len(history.Messages) > 0 { - out.SetNextRate(history.Messages[len(history.Messages)-1].Date) - } - out.SetSearchFlood(tg.SearchPostsFlood{QueryIsFree: true, TotalDaily: 100, Remains: 100, StarsAmount: 0}) - return out - } - return &tg.MessagesMessages{Messages: messages, Topics: []tg.ForumTopicClass{}, Chats: chats, Users: users} -} - -func (r *Router) onChannelsUpdatePaidMessagesPrice(ctx context.Context, req *tg.ChannelsUpdatePaidMessagesPriceRequest) (tg.UpdatesClass, error) { - if req == nil { - return nil, inputRequestInvalidErr() - } - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, view, err := r.channelChangeInfoView(ctx, req.Channel) - if err != nil { - return nil, err - } - if err := validateChannelPaidMessagesPriceRequest(req, view.Channel); err != nil { - return nil, err - } - stars := req.SendPaidMessagesStars - if stars < 0 { - stars = 0 - } - channel, err := r.deps.Channels.SetPaidMessagesPrice(ctx, userID, view.Channel.ID, stars, req.BroadcastMessagesAllowed) - if err != nil { - return nil, channelAdminErr(err) - } - r.pushChannelStateToMembers(ctx, userID, channel) - return r.channelStateUpdates(userID, channel), nil -} - -func validateChannelPaidMessagesPriceRequest(req *tg.ChannelsUpdatePaidMessagesPriceRequest, channel domain.Channel) error { - stars := req.SendPaidMessagesStars - if stars == -1 && channel.Broadcast && !req.BroadcastMessagesAllowed { - return nil - } - if stars < 0 || stars > maxChannelPaidMessageStars { - return starsAmountInvalidErr() - } - return nil -} - -func (r *Router) onChannelsToggleAutotranslation(ctx context.Context, req *tg.ChannelsToggleAutotranslationRequest) (tg.UpdatesClass, error) { - if req == nil { - return nil, inputRequestInvalidErr() - } - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, view, err := r.channelChangeInfoView(ctx, req.Channel) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetAutotranslation(ctx, userID, view.Channel.ID, req.Enabled) - if err != nil { - return nil, channelAdminErr(err) - } - r.pushChannelStateToMembers(ctx, userID, channel) - return r.channelStateUpdates(userID, channel), nil -} - -func (r *Router) onChannelsGetMessageAuthor(ctx context.Context, req *tg.ChannelsGetMessageAuthorRequest) (tg.UserClass, error) { - userID, view, err := r.channelView(ctx, req.Channel) - if err != nil { - return nil, err - } - author, err := r.deps.Channels.GetMessageAuthor(ctx, userID, domain.GetChannelMessageAuthorRequest{ - UserID: userID, - ChannelID: view.Channel.ID, - ID: req.ID, - }) - if err != nil { - if errors.Is(err, domain.ErrMessageIDInvalid) { - return nil, messageIDInvalidErr() - } - return nil, channelInvalidErr(err) - } - users := r.tgUsersForIDs(ctx, userID, []int64{author.SenderUserID}) - if len(users) == 0 { - return nil, peerIDInvalidErr() - } - return users[0], nil -} - -func (r *Router) onChannelsCheckSearchPostsFlood(ctx context.Context, req *tg.ChannelsCheckSearchPostsFloodRequest) (*tg.SearchPostsFlood, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - if err := validateChannelCheckSearchPostsFloodRequest(req); err != nil { - return nil, err - } - return &tg.SearchPostsFlood{QueryIsFree: true, TotalDaily: 100, Remains: 100, StarsAmount: 0}, nil -} - -func validateChannelCheckSearchPostsFloodRequest(req *tg.ChannelsCheckSearchPostsFloodRequest) error { - if req == nil { - return inputRequestInvalidErr() - } - query, hasQuery := req.GetQuery() - if !hasQuery && req.Query != "" { - query, hasQuery = req.Query, true - } - if !hasQuery || strings.TrimSpace(query) == "" { - return searchQueryEmptyErr() - } - if utf8.RuneCountInString(query) > maxChannelSearchPostsQuery { - return limitInvalidErr() - } - return nil -} - -func (r *Router) onChannelsSetMainProfileTab(ctx context.Context, req *tg.ChannelsSetMainProfileTabRequest) (bool, error) { - if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.ChannelsGetParticipantsRequest) (tg.ChannelsChannelParticipantsClass, error) { - if r.deps.Channels == nil { - return &tg.ChannelsChannelParticipants{}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - filter := domainChannelParticipantsFilter(req.Filter) - if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength { - return nil, limitInvalidErr() - } - list, err := r.deps.Channels.GetParticipants(ctx, userID, channelID, filter, req.Offset, req.Limit) - if err != nil { - return nil, channelInvalidErr(err) - } - if req.Hash != 0 && list.Hash == req.Hash { - return &tg.ChannelsChannelParticipantsNotModified{}, nil - } - participants := make([]tg.ChannelParticipantClass, 0, len(list.Participants)) - userIDs := make([]int64, 0, len(list.Participants)) - for _, member := range list.Participants { - participants = append(participants, tgChannelParticipant(userID, member)) - userIDs = append(userIDs, member.UserID) - } - users := r.tgUsers(list.Users) - if len(users) == 0 { - users = r.tgUsersForIDs(ctx, userID, userIDs) - } - r.log.Debug("channels.getParticipants result", - zap.Int64("channel_id", channelID), - zap.String("filter", string(filter.Kind)), - zap.Int("count", list.Count), - zap.Int("participants", len(participants)), - zap.Int("users", len(users)), - ) - return &tg.ChannelsChannelParticipants{ - Count: list.Count, - Participants: participants, - Chats: []tg.ChatClass{}, - Users: users, - }, nil -} - -func (r *Router) onChannelsGetParticipant(ctx context.Context, req *tg.ChannelsGetParticipantRequest) (*tg.ChannelsChannelParticipant, error) { - if r.deps.Channels == nil { - return &tg.ChannelsChannelParticipant{}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - peer, ok := r.domainPeerFromInputPeer(userID, req.Participant) - if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { - return nil, peerIDInvalidErr() - } - member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, peer.ID) - if err != nil { - return nil, channelInvalidErr(err) - } - return &tg.ChannelsChannelParticipant{ - Participant: tgChannelParticipant(userID, member), - Users: r.tgUsersForIDs(ctx, userID, []int64{member.UserID}), - }, nil -} - -func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.ChannelsInviteToChannelRequest) (*tg.MessagesInvitedUsers, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - if len(req.Users) == 0 || len(req.Users) > domain.MaxChannelInviteUsers { - return nil, limitInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - userIDs, err := r.userIDsFromInputUsers(ctx, userID, req.Users) - if err != nil { - return nil, err - } - res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, int(r.clock.Now().Unix())) - if err != nil { - return nil, channelInviteErr(err) - } - r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) - updates := r.channelOperationUpdates(ctx, userID, res) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelOperationUpdates(ctx, viewerUserID, res) - }) - return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil -} - -func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - ref, ok := inputChannelRef(input) - if !ok { - return nil, channelInvalidErr(domain.ErrChannelInvalid) - } - if ref.CheckAccessHash { - channel, err := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID) - if err != nil { - return nil, channelInvalidErr(err) - } - if !inputChannelAccessHashMatches(ref, channel) { - return nil, channelInvalidErr(domain.ErrChannelPrivate) - } - } - res, err := r.deps.Channels.JoinChannel(ctx, userID, ref.ID, int(r.clock.Now().Unix())) - if err != nil { - if errors.Is(err, domain.ErrInviteRequestSent) && res.Channel.ID != 0 { - r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) - } - return nil, channelInviteErr(err) - } - r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) - updates := r.channelOperationUpdates(ctx, userID, res) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelOperationUpdates(ctx, viewerUserID, res) - }) - return updates, nil -} - -func (r *Router) onChannelsLeaveChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, input) - if err != nil { - return nil, err - } - res, err := r.deps.Channels.LeaveChannel(ctx, userID, channelID, int(r.clock.Now().Unix())) - if err != nil { - return nil, channelInvalidErr(err) - } - r.removeOnlineChannelMemberships(res.Channel.ID, userID) - updates := r.channelOperationUpdates(ctx, userID, res) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelOperationUpdates(ctx, viewerUserID, res) - }) - return updates, nil -} - -func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTitleRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - if !validChannelTitle(req.Title) { - return nil, channelInvalidErr(domain.ErrChannelTitleInvalid) - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - res, err := r.deps.Channels.EditTitle(ctx, userID, domain.EditChannelTitleRequest{ - UserID: userID, - ChannelID: channelID, - Title: req.Title, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelTitleUpdates(ctx, userID, res) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelTitleUpdates(ctx, viewerUserID, res) - }) - return updates, nil -} - -func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAdminRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - target, found, err := r.userFromInput(ctx, userID, req.UserID) - if err != nil { - return nil, internalErr() - } - if !found || target.ID == 0 { - return nil, peerIDInvalidErr() - } - res, err := r.deps.Channels.EditAdmin(ctx, userID, domain.EditChannelAdminRequest{ - UserID: userID, - ChannelID: channelID, - MemberID: target.ID, - AdminRights: domainChannelAdminRights(req.AdminRights), - Rank: req.Rank, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelAdminErr(err) - } - if res.Participant.Status == domain.ChannelMemberActive { - r.addOnlineChannelMemberships(res.Channel.ID, res.Participant.UserID) - } else { - r.removeOnlineChannelMemberships(res.Channel.ID, res.Participant.UserID) - } - updates := r.channelParticipantUpdates(ctx, userID, userID, res.Channel, res.Previous, res.Participant, res.Date) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelParticipantUpdates(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Date) - }) - return updates, nil -} - -func (r *Router) onChannelsEditBanned(ctx context.Context, req *tg.ChannelsEditBannedRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - participant, ok := r.domainPeerFromInputPeer(userID, req.Participant) - if !ok || participant.Type != domain.PeerTypeUser || participant.ID == 0 { - return nil, peerIDInvalidErr() - } - res, err := r.deps.Channels.EditBanned(ctx, userID, domain.EditChannelBannedRequest{ - UserID: userID, - ChannelID: channelID, - Participant: participant, - BannedRights: domainChannelBannedRights(req.BannedRights), - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelParticipantUpdates(ctx, userID, userID, res.Channel, res.Previous, res.Participant, res.Date) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelParticipantUpdates(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Date) - }) - return updates, nil -} - -func (r *Router) onChannelsEditPhoto(ctx context.Context, req *tg.ChannelsEditPhotoRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - if req.Photo == nil { - return nil, photoInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - photo, err := r.resolveInputChatPhoto(ctx, userID, req.Photo) - if err != nil { - return nil, err - } - channel, err := r.deps.Channels.SetPhoto(ctx, userID, channelID, photo) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelStateUpdates(userID, channel) - r.pushChannelStateToMembers(ctx, userID, channel) - return updates, nil -} - -func (r *Router) onChannelsDeleteChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, input) - if err != nil { - return nil, err - } - res, err := r.deps.Channels.DeleteChannel(ctx, userID, domain.DeleteChannelRequest{ - UserID: userID, - ChannelID: channelID, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelStateUpdates(userID, res.Channel) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelStateUpdates(viewerUserID, res.Channel) - }) - r.removeOnlineChannelMembershipsForOnlineMembers(res.Channel.ID) - return updates, nil -} - -func (r *Router) onChannelsGetAdminLog(ctx context.Context, req *tg.ChannelsGetAdminLogRequest) (*tg.ChannelsAdminLogResults, error) { - if r.deps.Channels == nil { - return &tg.ChannelsAdminLogResults{}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - adminIDs := []int64(nil) - if admins, ok := req.GetAdmins(); ok && len(admins) > 0 { - if len(admins) > domain.MaxChannelAdminLogAdmins { - return nil, limitInvalidErr() - } - adminIDs, err = r.userIDsFromInputUsers(ctx, userID, admins) - if err != nil { - return nil, err - } - } - res, err := r.deps.Channels.ListAdminLog(ctx, userID, domain.ChannelAdminLogRequest{ - UserID: userID, - ChannelID: channelID, - Query: req.Q, - AdminUserIDs: adminIDs, - MaxID: req.MaxID, - MinID: req.MinID, - Limit: req.Limit, - Filter: domainChannelAdminLogFilter(req), - }) - if err != nil { - return nil, channelAdminErr(err) - } - return &tg.ChannelsAdminLogResults{ - Events: tgChannelAdminLogEvents(userID, res.Events), - Chats: []tg.ChatClass{tgChannelChat(userID, res.Channel, nil)}, - Users: r.channelAdminLogUsers(ctx, userID, res.Events), - }, nil -} - -func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsReadHistoryRequest) (bool, error) { - if r.deps.Channels == nil { - return true, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return false, err - } - read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{ - UserID: userID, - ChannelID: channelID, - MaxID: req.MaxID, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return false, channelInvalidErr(err) - } - if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil { - return false, err - } - r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates) - return true, nil -} - -func (r *Router) onChannelsGetMessages(ctx context.Context, req *tg.ChannelsGetMessagesRequest) (tg.MessagesMessagesClass, error) { - if len(req.ID) > domain.MaxGetMessageIDs { - return nil, limitInvalidErr() - } - if r.deps.Channels == nil || len(req.ID) == 0 { - return &tg.MessagesMessages{}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - ids := make([]int, 0, len(req.ID)) - for _, input := range req.ID { - id, ok := inputMessageBoxID(input) - if !ok || id <= 0 || id > domain.MaxMessageBoxID { - continue - } - ids = append(ids, id) - } - if len(ids) == 0 { - return &tg.MessagesMessages{}, nil - } - history, err := r.deps.Channels.GetMessages(ctx, userID, channelID, ids) - if err != nil { - return nil, channelInvalidErr(err) - } - history = r.enrichChannelHistory(ctx, userID, history) - byID := make(map[int]domain.ChannelMessage, len(history.Messages)) - for _, msg := range history.Messages { - byID[msg.ID] = msg - } - messages := make([]tg.MessageClass, 0, len(ids)) - for _, id := range ids { - if msg, ok := byID[id]; ok { - messages = append(messages, tgChannelMessage(userID, msg)) - } else { - messages = append(messages, &tg.MessageEmpty{ID: id}) - } - } - return &tg.MessagesMessages{ - Messages: messages, - Chats: tgChannels(userID, []domain.Channel{history.Channel}), - Users: r.tgUsers(history.Users), - }, nil -} - -func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsDeleteMessagesRequest) (*tg.MessagesAffectedMessages, error) { - if len(req.ID) == 0 { - return &tg.MessagesAffectedMessages{PtsCount: 0}, nil - } - if len(req.ID) > domain.MaxDeleteMessageIDs { - return nil, limitInvalidErr() - } - for _, id := range req.ID { - if id <= 0 || id > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - } - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - res, err := r.deps.Channels.DeleteMessages(ctx, userID, domain.DeleteChannelMessagesRequest{ - UserID: userID, - ChannelID: channelID, - IDs: append([]int(nil), req.ID...), - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelDeleteErr(err) - } - if res.Event.Pts != 0 { - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) - }) - return &tg.MessagesAffectedMessages{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount}, nil - } - return &tg.MessagesAffectedMessages{Pts: res.Channel.Pts, PtsCount: 0}, nil -} - -func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDeleteHistoryRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - res, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{ - UserID: userID, - ChannelID: channelID, - MaxID: req.MaxID, - ForEveryone: req.GetForEveryone(), - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelDeleteErr(err) - } - if res.Event.Pts == 0 { - event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID) - updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID) - r.pushUserUpdates(ctx, userID, updates) - return updates, nil - } - updates := r.channelDeleteMessagesUpdates(userID, res.Channel, res.Event) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) - }) - return updates, nil -} - -func (r *Router) onMessagesUpdatePinnedMessage(ctx context.Context, req *tg.MessagesUpdatePinnedMessageRequest) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { - return nil, peerIDInvalidErr() - } - if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - res, err := r.deps.Channels.UpdatePinnedMessage(ctx, userID, domain.UpdateChannelPinnedMessageRequest{ - UserID: userID, - ChannelID: peer.ID, - MessageID: req.ID, - Pinned: !req.Unpin, - Silent: req.Silent, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelAdminErr(err) - } - updates := r.channelPinnedUpdates(userID, res) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelPinnedUpdates(viewerUserID, res) - }) - return updates, nil -} - -func (r *Router) onMessagesUnpinAllMessages(ctx context.Context, req *tg.MessagesUnpinAllMessagesRequest) (*tg.MessagesAffectedHistory, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID <= 0 || topMsgID > domain.MaxMessageBoxID) { - return nil, messageIDInvalidErr() - } - if savedPeer, ok := req.GetSavedPeerID(); ok && savedPeer != nil { - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, savedPeer); err != nil { - return nil, err - } - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { - return &tg.MessagesAffectedHistory{Offset: 0}, nil - } - view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) - if err != nil { - return nil, channelAdminErr(err) - } - if topMsgID, ok := req.GetTopMsgID(); ok && topMsgID > 0 { - return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil - } - if _, ok := req.GetSavedPeerID(); ok { - return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil - } - if view.Channel.PinnedMessageID == 0 { - return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil - } - res, err := r.deps.Channels.UpdatePinnedMessage(ctx, userID, domain.UpdateChannelPinnedMessageRequest{ - UserID: userID, - ChannelID: peer.ID, - MessageID: view.Channel.PinnedMessageID, - Pinned: false, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - if errors.Is(err, domain.ErrChannelNotModified) { - return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil - } - return nil, channelAdminErr(err) - } - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelPinnedUpdates(viewerUserID, res) - }) - return &tg.MessagesAffectedHistory{ - Pts: res.Event.Pts, - PtsCount: res.Event.PtsCount, - Offset: 0, - }, nil -} - -func (r *Router) onMessagesExportChatInvite(ctx context.Context, req *tg.MessagesExportChatInviteRequest) (tg.ExportedChatInviteClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { - return nil, peerIDInvalidErr() - } - if req.UsageLimit < 0 || req.ExpireDate < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength { - return nil, limitInvalidErr() - } - res, err := r.deps.Channels.ExportInvite(ctx, userID, domain.ExportChannelInviteRequest{ - UserID: userID, - ChannelID: peer.ID, - Title: req.Title, - RequestNeeded: req.RequestNeeded, - ExpireDate: req.ExpireDate, - UsageLimit: req.UsageLimit, - LegacyRevokePermanent: req.LegacyRevokePermanent, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelInviteErr(err) - } - return tgExportedChannelInvite(res.Invite), nil -} - -func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg.ChatInviteClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - res, err := r.deps.Channels.CheckInvite(ctx, userID, hash, int(r.clock.Now().Unix())) - if err != nil { - return nil, channelInviteErr(err) - } - if res.Already { - return &tg.ChatInviteAlready{Chat: tgChannelChat(userID, res.Channel, &res.Self)}, nil - } - return &tg.ChatInvite{ - Channel: true, - Broadcast: res.Channel.Broadcast, - Megagroup: res.Channel.Megagroup, - Public: res.Channel.Username != "", - RequestNeeded: res.Invite.RequestNeeded, - Title: res.Channel.Title, - About: res.Channel.About, - Photo: &tg.PhotoEmpty{}, - ParticipantsCount: res.Channel.ParticipantsCount, - }, nil -} - -func (r *Router) onMessagesImportChatInvite(ctx context.Context, hash string) (tg.UpdatesClass, error) { - if r.deps.Channels == nil { - return nil, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - res, err := r.deps.Channels.ImportInvite(ctx, userID, domain.ImportChannelInviteRequest{ - UserID: userID, - Hash: hash, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - if errors.Is(err, domain.ErrInviteRequestSent) && res.Channel.ID != 0 { - r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) - } - return nil, channelInviteErr(err) - } - r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) - updates := r.channelOperationUpdates(ctx, userID, res) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelOperationUpdates(ctx, viewerUserID, res) - }) - return updates, nil -} - -func (r *Router) onMessagesGetExportedChatInvites(ctx context.Context, req *tg.MessagesGetExportedChatInvitesRequest) (*tg.MessagesExportedChatInvites, error) { - userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) - if err != nil { - return nil, err - } - if req.Limit < 0 || req.Limit > maxChatInviteListLimit || len(req.OffsetLink) > maxChatInviteLinkLength { - return nil, limitInvalidErr() - } - adminID := userID - if !inputUserIsEmpty(req.AdminID) { - admins, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.AdminID}) - if err != nil { - return nil, err - } - if len(admins) > 0 { - adminID = admins[0] - } - } - offsetHash := "" - if req.OffsetLink != "" { - offsetHash, err = channelInviteHashFromLink(req.OffsetLink) - if err != nil { - return nil, err - } - } - list, err := r.deps.Channels.ListExportedInvites(ctx, userID, domain.ChannelInviteListRequest{ - UserID: userID, - ChannelID: view.Channel.ID, - AdminUserID: adminID, - Revoked: req.Revoked, - OffsetDate: req.OffsetDate, - OffsetHash: offsetHash, - Limit: req.Limit, - }) - if err != nil { - return nil, channelInviteErr(err) - } - userIDs := []int64{adminID} - invites := make([]tg.ExportedChatInviteClass, 0, len(list.Invites)) - for _, invite := range list.Invites { - invites = append(invites, tgExportedChannelInvite(invite)) - userIDs = append(userIDs, invite.AdminUserID) - } - return &tg.MessagesExportedChatInvites{ - Count: list.Count, - Invites: invites, - Users: r.tgUsersForIDs(ctx, userID, userIDs), - }, nil -} - -func (r *Router) onMessagesGetExportedChatInvite(ctx context.Context, req *tg.MessagesGetExportedChatInviteRequest) (tg.MessagesExportedChatInviteClass, error) { - userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) - if err != nil { - return nil, err - } - hash, err := channelInviteHashFromLink(req.Link) - if err != nil { - return nil, err - } - invite, err := r.deps.Channels.GetExportedInvite(ctx, userID, domain.GetChannelInviteRequest{ - UserID: userID, - ChannelID: view.Channel.ID, - Hash: hash, - }) - if err != nil { - return nil, channelInviteErr(err) - } - return &tg.MessagesExportedChatInvite{ - Invite: tgExportedChannelInvite(invite), - Users: r.tgUsersForIDs(ctx, userID, []int64{invite.AdminUserID}), - }, nil -} - -func (r *Router) onMessagesEditExportedChatInvite(ctx context.Context, req *tg.MessagesEditExportedChatInviteRequest) (tg.MessagesExportedChatInviteClass, error) { - userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) - if err != nil { - return nil, err - } - hash, err := channelInviteHashFromLink(req.Link) - if err != nil { - return nil, err - } - if req.ExpireDate < 0 || req.UsageLimit < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength { - return nil, limitInvalidErr() - } - expireDate, hasExpireDate := req.GetExpireDate() - usageLimit, hasUsageLimit := req.GetUsageLimit() - requestNeeded, hasRequestNeeded := req.GetRequestNeeded() - title, hasTitle := req.GetTitle() - edited, err := r.deps.Channels.EditExportedInvite(ctx, userID, domain.EditChannelInviteRequest{ - UserID: userID, - ChannelID: view.Channel.ID, - Hash: hash, - Revoked: req.Revoked, - HasExpireDate: hasExpireDate, - ExpireDate: expireDate, - HasUsageLimit: hasUsageLimit, - UsageLimit: usageLimit, - HasRequestNeeded: hasRequestNeeded, - RequestNeeded: requestNeeded, - HasTitle: hasTitle, - Title: title, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelInviteErr(err) - } - users := r.tgUsersForIDs(ctx, userID, []int64{edited.Invite.AdminUserID}) - if edited.NewInvite != nil { - return &tg.MessagesExportedChatInviteReplaced{ - Invite: tgExportedChannelInvite(edited.Invite), - NewInvite: tgExportedChannelInvite(*edited.NewInvite), - Users: users, - }, nil - } - return &tg.MessagesExportedChatInvite{Invite: tgExportedChannelInvite(edited.Invite), Users: users}, nil -} - -func (r *Router) onMessagesDeleteRevokedExportedChatInvites(ctx context.Context, req *tg.MessagesDeleteRevokedExportedChatInvitesRequest) (bool, error) { - userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) - if err != nil { - return false, err - } - adminID := userID - if !inputUserIsEmpty(req.AdminID) { - admins, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.AdminID}) - if err != nil { - return false, err - } - if len(admins) > 0 { - adminID = admins[0] - } - } - if err := r.deps.Channels.DeleteRevokedExportedInvites(ctx, userID, domain.DeleteRevokedChannelInvitesRequest{ - UserID: userID, - ChannelID: view.Channel.ID, - AdminUserID: adminID, - Limit: domain.MaxChannelHideJoinRequests, - }); err != nil { - return false, channelInviteErr(err) - } - return true, nil -} - -func (r *Router) onMessagesDeleteExportedChatInvite(ctx context.Context, req *tg.MessagesDeleteExportedChatInviteRequest) (bool, error) { - userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) - if err != nil { - return false, err - } - hash, err := channelInviteHashFromLink(req.Link) - if err != nil { - return false, err - } - if err := r.deps.Channels.DeleteExportedInvite(ctx, userID, domain.DeleteChannelInviteRequest{ - UserID: userID, - ChannelID: view.Channel.ID, - Hash: hash, - }); err != nil { - return false, channelInviteErr(err) - } - return true, nil -} - -func (r *Router) onMessagesGetAdminsWithInvites(ctx context.Context, peer tg.InputPeerClass) (*tg.MessagesChatAdminsWithInvites, error) { - userID, view, err := r.inviteManagementChannelView(ctx, peer) - if err != nil { - return nil, err - } - counts, err := r.deps.Channels.ListAdminsWithInvites(ctx, userID, view.Channel.ID) - if err != nil { - return nil, channelInviteErr(err) - } - admins := make([]tg.ChatAdminWithInvites, 0, len(counts)) - userIDs := make([]int64, 0, len(counts)) - for _, count := range counts { - admins = append(admins, tg.ChatAdminWithInvites{ - AdminID: count.AdminUserID, - InvitesCount: count.InvitesCount, - RevokedInvitesCount: count.RevokedInvitesCount, - }) - userIDs = append(userIDs, count.AdminUserID) - } - return &tg.MessagesChatAdminsWithInvites{ - Admins: admins, - Users: r.tgUsersForIDs(ctx, userID, userIDs), - }, nil -} - -func (r *Router) onMessagesGetChatInviteImporters(ctx context.Context, req *tg.MessagesGetChatInviteImportersRequest) (*tg.MessagesChatInviteImporters, error) { - userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) - if err != nil { - return nil, err - } - link, hasLink := req.GetLink() - query, hasQuery := req.GetQ() - if hasLink && hasQuery && strings.TrimSpace(link) != "" && strings.TrimSpace(query) != "" { - return nil, tgerr400("SEARCH_WITH_LINK_NOT_SUPPORTED") - } - if req.Limit < 0 || req.Limit > maxChatInviteListLimit || len(link) > maxChatInviteLinkLength || len(query) > maxChatInviteSearchLength { - return nil, limitInvalidErr() - } - hash := "" - if strings.TrimSpace(link) != "" { - hash, err = channelInviteHashFromLink(link) - if err != nil { - return nil, err - } - } - offsetUserID := int64(0) - if !inputUserIsEmpty(req.OffsetUser) { - ids, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.OffsetUser}) - if err != nil { - return nil, err - } - if len(ids) > 0 { - offsetUserID = ids[0] - } - } - list, err := r.deps.Channels.ListInviteImporters(ctx, userID, domain.ChannelInviteImportersRequest{ - UserID: userID, - ChannelID: view.Channel.ID, - Hash: hash, - Requested: req.Requested, - Query: query, - OffsetDate: req.OffsetDate, - OffsetUserID: offsetUserID, - Limit: req.Limit, - }) - if err != nil { - return nil, channelInviteErr(err) - } - importers := make([]tg.ChatInviteImporter, 0, len(list.Importers)) - userIDs := make([]int64, 0, len(list.Importers)) - for _, importer := range list.Importers { - tgImporter := tg.ChatInviteImporter{ - UserID: importer.UserID, - Date: importer.Date, - } - if importer.Requested { - tgImporter.SetRequested(true) - } - if importer.ApprovedBy != 0 { - tgImporter.SetApprovedBy(importer.ApprovedBy) - userIDs = append(userIDs, importer.ApprovedBy) - } - importers = append(importers, tgImporter) - userIDs = append(userIDs, importer.UserID) - } - return &tg.MessagesChatInviteImporters{ - Count: list.Count, - Importers: importers, - Users: r.tgUsersForIDs(ctx, userID, userIDs), - }, nil -} - -func (r *Router) onMessagesHideChatJoinRequest(ctx context.Context, req *tg.MessagesHideChatJoinRequestRequest) (tg.UpdatesClass, error) { - userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) - if err != nil { - return nil, err - } - targets, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.UserID}) - if err != nil { - return nil, err - } - if len(targets) == 0 { - return nil, userIDInvalidErr() - } - res, err := r.deps.Channels.HideChatJoinRequest(ctx, userID, domain.HideChannelJoinRequestRequest{ - UserID: userID, - ChannelID: view.Channel.ID, - TargetUserID: targets[0], - Approved: req.Approved, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelInviteErr(err) - } - r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) - updates := r.channelOperationUpdates(ctx, userID, res) - r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelOperationUpdates(ctx, viewerUserID, res) - }) - r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) - return updates, nil -} - -func (r *Router) onMessagesHideAllChatJoinRequests(ctx context.Context, req *tg.MessagesHideAllChatJoinRequestsRequest) (tg.UpdatesClass, error) { - userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) - if err != nil { - return nil, err - } - link, hasLink := req.GetLink() - if hasLink && len(link) > maxChatInviteLinkLength { - return nil, limitInvalidErr() - } - hash := "" - if hasLink && strings.TrimSpace(link) != "" { - hash, err = channelInviteHashFromLink(link) - if err != nil { - return nil, err - } - } - res, err := r.deps.Channels.HideAllChatJoinRequests(ctx, userID, domain.HideChannelJoinRequestsRequest{ - UserID: userID, - ChannelID: view.Channel.ID, - Hash: hash, - Approved: req.Approved, - Limit: domain.MaxChannelHideJoinRequests, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelInviteErr(err) - } - r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) - updates := r.channelOperationUpdates(ctx, userID, res) - r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelOperationUpdates(ctx, viewerUserID, res) - }) - r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) - return updates, nil -} - -func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.UpdatesGetChannelDifferenceRequest) (tg.UpdatesChannelDifferenceClass, error) { - if r.deps.Channels == nil { - return &tg.UpdatesChannelDifferenceEmpty{Final: true, Pts: req.Pts, Timeout: 30}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) - if err != nil { - return nil, err - } - r.trackChannelInterest(ctx, userID, channelID) - diff, err := r.deps.Channels.GetDifference(ctx, userID, domain.ChannelDifferenceRequest{ - UserID: userID, - ChannelID: channelID, - Pts: req.Pts, - Limit: req.Limit, - Force: req.Force, - }) - if err != nil { - if errors.Is(err, domain.ErrPersistentTimestamp) { - return nil, persistentTimestampInvalidErr() - } - return nil, channelInvalidErr(err) - } - diff = r.enrichChannelDifference(ctx, userID, diff) - return tgChannelDifference(userID, diff), nil -} - -func (r *Router) channelOperationUpdates(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult) *tg.Updates { - users := make([]int64, 0, len(res.Members)+1) - users = append(users, res.Channel.CreatorUserID) - for _, member := range res.Members { - users = append(users, member.UserID) - } - updates := make([]tg.UpdateClass, 0, 2) - if res.Event.Pts != 0 { - if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { - updates = append(updates, update) - } - } - if res.Channel.ID != 0 { - updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID}) - } - return &tg.Updates{ - Updates: updates, - Users: r.tgUsersForIDs(ctx, viewerUserID, users), - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, channelMemberForUser(res.Members, viewerUserID))}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func (r *Router) tdesktopCreateChatUpdates(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult) *tg.Updates { - updates := r.channelOperationUpdates(ctx, viewerUserID, res) - if updates == nil { - return updates - } - self := channelMemberForUser(res.Members, viewerUserID) - legacy := tgMigratedLegacyChat(viewerUserID, res.Channel, self) - if legacy == nil { - return updates - } - chats := make([]tg.ChatClass, 0, len(updates.Chats)+1) - chats = append(chats, legacy) - chats = append(chats, updates.Chats...) - updates.Chats = chats - return updates -} - -func tdesktopCreateChatNeedsLegacyChat(ctx context.Context) bool { - ci, ok := ClientInfoFrom(ctx) - if !ok { - _, hasSession := SessionIDFrom(ctx) - return hasSession - } - device := strings.ToLower(ci.DeviceModel) - return ci.LangPack == "tdesktop" || strings.Contains(device, "desktop") -} - -func channelMemberForUser(members []domain.ChannelMember, userID int64) *domain.ChannelMember { - for i := range members { - if members[i].UserID == userID { - return &members[i] - } - } - return nil -} - -func createChatInviteMemberIDs(ids []int64, selfUserID int64) []int64 { - if len(ids) == 0 { - return nil - } - out := make([]int64, 0, len(ids)) - seen := make(map[int64]struct{}, len(ids)) - for _, id := range ids { - if id == 0 || id == selfUserID { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} - -func mergeChannelMembers(a, b []domain.ChannelMember) []domain.ChannelMember { - if len(a) == 0 { - return append([]domain.ChannelMember(nil), b...) - } - if len(b) == 0 { - return append([]domain.ChannelMember(nil), a...) - } - out := make([]domain.ChannelMember, 0, len(a)+len(b)) - seen := make(map[int64]struct{}, len(a)+len(b)) - appendOne := func(member domain.ChannelMember) { - if member.UserID == 0 { - return - } - if _, ok := seen[member.UserID]; ok { - return - } - seen[member.UserID] = struct{}{} - out = append(out, member) - } - for _, member := range a { - appendOne(member) - } - for _, member := range b { - appendOne(member) - } - return out -} - -func (r *Router) channelSelfForViewer(ctx context.Context, userID, channelID int64, members []domain.ChannelMember) *domain.ChannelMember { - if self := channelMemberForUser(members, userID); self != nil { - return self - } - if r.deps.Channels == nil || userID == 0 || channelID == 0 { - return nil - } - self, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, userID) - if err != nil { - return nil - } - return &self -} - -func canViewChannelJoinRequests(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || - (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) -} - -func (r *Router) applyPendingJoinRequestsToFullChannel(ctx context.Context, full *tg.ChannelFull, channelID int64, userIDs []int64) []int64 { - if r.deps.Channels == nil || full == nil || channelID == 0 { - return userIDs - } - pending, err := r.deps.Channels.PendingJoinRequests(ctx, channelID, domain.MaxChannelPendingJoinRecentRequesters) - if err != nil || pending.Count <= 0 { - return userIDs - } - full.SetRequestsPending(pending.Count) - full.SetRecentRequesters(pending.RecentRequesters) - return append(userIDs, pending.RecentRequesters...) -} - -func (r *Router) pendingJoinRequestsUpdates(ctx context.Context, viewerUserID int64, channel domain.Channel) *tg.Updates { - if r.deps.Channels == nil || channel.ID == 0 { - return nil - } - pending, err := r.deps.Channels.PendingJoinRequests(ctx, channel.ID, domain.MaxChannelPendingJoinRecentRequesters) - if err != nil { - return nil - } - return &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdatePendingJoinRequests{ - Peer: &tg.PeerChannel{ChannelID: channel.ID}, - RequestsPending: pending.Count, - RecentRequesters: pending.RecentRequesters, - }}, - Users: r.tgUsersForIDs(ctx, viewerUserID, pending.RecentRequesters), - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func (r *Router) appendPendingJoinRequestsUpdate(ctx context.Context, viewerUserID int64, updates *tg.Updates, channel domain.Channel) { - if updates == nil { - return - } - pending := r.pendingJoinRequestsUpdates(ctx, viewerUserID, channel) - if pending == nil { - return - } - updates.Updates = append(updates.Updates, pending.Updates...) - updates.Users = append(updates.Users, pending.Users...) -} - -func (r *Router) pushPendingJoinRequestsToAdmins(ctx context.Context, channel domain.Channel) { - if r.deps.Channels == nil || r.deps.Sessions == nil || channel.ID == 0 { - return - } - adminIDs, err := r.deps.Channels.InviteAdminMemberIDs(ctx, channel.ID, domain.MaxChannelRealtimeFanout) - if err != nil || len(adminIDs) == 0 { - adminIDs = []int64{channel.CreatorUserID} - } - seen := make(map[int64]struct{}, len(adminIDs)) - for _, adminID := range adminIDs { - if adminID == 0 { - continue - } - if _, ok := seen[adminID]; ok { - continue - } - seen[adminID] = struct{}{} - updates := r.pendingJoinRequestsUpdates(ctx, adminID, channel) - if updates == nil { - continue - } - r.pushUserUpdates(ctx, adminID, updates) - } -} - -func (r *Router) channelTitleUpdates(ctx context.Context, viewerUserID int64, res domain.EditChannelTitleResult) *tg.Updates { - updates := []tg.UpdateClass{&tg.UpdateChannel{ChannelID: res.Channel.ID}} - if res.Event.Pts != 0 { - if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { - updates = append(updates, update) - } - } - return &tg.Updates{ - Updates: updates, - Users: r.tgUsersForIDs(ctx, viewerUserID, []int64{res.Message.SenderUserID}), - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func (r *Router) channelParticipantUpdates(ctx context.Context, viewerUserID, actorUserID int64, channel domain.Channel, previous, participant domain.ChannelMember, date int) *tg.Updates { - update := &tg.UpdateChannelParticipant{ - ChannelID: channel.ID, - Date: date, - ActorID: actorUserID, - UserID: participant.UserID, - } - if update.ActorID == 0 { - update.ActorID = viewerUserID - } - if previous.UserID != 0 { - update.SetPrevParticipant(tgChannelParticipantForUpdate(viewerUserID, previous)) - } - if participant.UserID != 0 { - update.SetNewParticipant(tgChannelParticipantForUpdate(viewerUserID, participant)) - } - return &tg.Updates{ - Updates: []tg.UpdateClass{update, &tg.UpdateChannel{ChannelID: channel.ID}}, - Users: r.tgUsersForIDs(ctx, viewerUserID, []int64{participant.UserID, participant.InviterUserID, previous.UserID, previous.InviterUserID, update.ActorID}), - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func (r *Router) channelStateUpdates(viewerUserID int64, channel domain.Channel) *tg.Updates { - return &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateChannel{ChannelID: channel.ID}}, - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func (r *Router) channelPinnedUpdates(viewerUserID int64, res domain.UpdateChannelPinnedMessageResult) *tg.Updates { - updates := []tg.UpdateClass(nil) - if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { - updates = append(updates, update) - } - updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID}) - return &tg.Updates{ - Updates: updates, - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func (r *Router) channelMessageUpdates(ctx context.Context, viewerUserID int64, res domain.SendChannelMessageResult, randomID int64) *tg.Updates { - randomIDs := []int64(nil) - includeMessageIDs := randomID != 0 - if includeMessageIDs { - randomIDs = []int64{randomID} - } - return r.channelMessagesUpdates(ctx, viewerUserID, []domain.SendChannelMessageResult{res}, randomIDs, includeMessageIDs, nil) -} - -func (r *Router) pushChannelDiscussionUpdate(ctx context.Context, originUserID int64, discussion *domain.SendChannelDiscussionResult) { - if discussion == nil || discussion.Channel.ID == 0 || discussion.Event.Pts == 0 { - return - } - res := domain.SendChannelMessageResult{ - Channel: discussion.Channel, - Message: discussion.Message, - Event: discussion.Event, - Recipients: discussion.Recipients, - } - r.pushChannelUpdates(ctx, originUserID, discussion.Channel.ID, discussion.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelMessageUpdates(ctx, viewerUserID, res, 0) - }) -} - -func (r *Router) channelMessagesUpdates(ctx context.Context, viewerUserID int64, results []domain.SendChannelMessageResult, randomIDs []int64, includeMessageIDs bool, extraUserIDs []int64) *tg.Updates { - updates := make([]tg.UpdateClass, 0, len(results)*2) - userIDs := make([]int64, 0, len(results)+len(extraUserIDs)) - userIDs = append(userIDs, extraUserIDs...) - extraChannelIDs := make([]int64, 0, len(results)) - var channel domain.Channel - date := 0 - for i, res := range results { - if res.Channel.ID != 0 { - channel = res.Channel - } - if includeMessageIDs && res.Message.ID != 0 && i < len(randomIDs) && randomIDs[i] != 0 { - updates = append(updates, &tg.UpdateMessageID{ID: res.Message.ID, RandomID: randomIDs[i]}) - } - if res.Event.Pts != 0 { - if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { - updates = append(updates, update) - } - } - if res.Message.SenderUserID != 0 { - userIDs = append(userIDs, res.Message.SenderUserID) - } - if res.Message.SendAs != nil { - switch res.Message.SendAs.Type { - case domain.PeerTypeUser: - userIDs = append(userIDs, res.Message.SendAs.ID) - case domain.PeerTypeChannel: - extraChannelIDs = append(extraChannelIDs, res.Message.SendAs.ID) - } - } - if res.Message.Forward != nil && res.Message.Forward.From.Type == domain.PeerTypeChannel { - extraChannelIDs = append(extraChannelIDs, res.Message.Forward.From.ID) - } - if res.Message.ReplyTo != nil && res.Message.ReplyTo.Peer.Type == domain.PeerTypeChannel { - extraChannelIDs = append(extraChannelIDs, res.Message.ReplyTo.Peer.ID) - } - if date == 0 { - date = res.Event.Date - } - if date == 0 { - date = res.Message.Date - } - } - chats := []tg.ChatClass(nil) - if channel.ID != 0 { - chats = []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)} - } - chats = append(chats, r.tgChannelsForIDs(ctx, viewerUserID, extraChannelIDs, channel.ID)...) - if date == 0 { - date = int(r.clock.Now().Unix()) - } - return &tg.Updates{ - Updates: updates, - Users: r.tgUsersForIDs(ctx, viewerUserID, userIDs), - Chats: chats, - Date: date, - Seq: 0, - } -} - -func (r *Router) tgChannelsForIDs(ctx context.Context, viewerUserID int64, ids []int64, skipIDs ...int64) []tg.ChatClass { - if r.deps.Channels == nil || len(ids) == 0 { - return nil - } - skip := make(map[int64]struct{}, len(skipIDs)) - for _, id := range skipIDs { - if id != 0 { - skip[id] = struct{}{} - } - } - seen := make(map[int64]struct{}, len(ids)) - chats := make([]tg.ChatClass, 0, len(ids)) - for _, id := range ids { - if id == 0 { - continue - } - if _, ok := skip[id]; ok { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - view, err := r.deps.Channels.GetChannel(ctx, viewerUserID, id) - if err != nil || view.Channel.ID == 0 { - continue - } - chats = append(chats, tgChannelChat(viewerUserID, view.Channel, &view.Self)) - } - return chats -} - -func (r *Router) channelEditMessageUpdates(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult) *tg.Updates { - updates := make([]tg.UpdateClass, 0, 1) - if res.Event.Pts != 0 { - if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { - updates = append(updates, update) - } - } - return &tg.Updates{ - Updates: updates, - Users: r.tgUsersForIDs(ctx, viewerUserID, []int64{res.Message.SenderUserID}), - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func (r *Router) channelDeleteMessagesUpdates(viewerUserID int64, channel domain.Channel, event domain.ChannelUpdateEvent) *tg.Updates { - updates := make([]tg.UpdateClass, 0, 1) - if update := tgChannelUpdate(viewerUserID, event); update != nil { - updates = append(updates, update) - } - return &tg.Updates{ - Updates: updates, - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func (r *Router) channelAvailableMessagesUpdates(viewerUserID int64, channel domain.Channel, availableMinID int) *tg.Updates { - updates := make([]tg.UpdateClass, 0, 1) - if channel.ID != 0 && availableMinID > 0 { - updates = append(updates, &tg.UpdateChannelAvailableMessages{ - ChannelID: channel.ID, - AvailableMinID: availableMinID, - }) - } - return &tg.Updates{ - Updates: updates, - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -type channelUpdatesBuilder func(viewerUserID int64) *tg.Updates - -type channelFanoutScope int - -const ( - channelFanoutMembers channelFanoutScope = iota - channelFanoutViewers - channelFanoutExplicit -) - -func (r *Router) pushChannelReadOutboxUpdates(ctx context.Context, channelID int64, updates []domain.ChannelReadOutboxUpdate) { - if r.deps.Sessions == nil || channelID == 0 || len(updates) == 0 { - return - } - seen := make(map[int64]int, len(updates)) - for _, update := range updates { - if update.UserID == 0 || update.MaxID <= 0 { - continue - } - if seen[update.UserID] < update.MaxID { - seen[update.UserID] = update.MaxID - } - } - date := int(r.clock.Now().Unix()) - for userID, maxID := range seen { - r.pushUserUpdates(ctx, userID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateReadChannelOutbox{ChannelID: channelID, MaxID: maxID}}, - Date: date, - Seq: 0, - }) - } -} - -func (r *Router) recordChannelAvailableMessages(ctx context.Context, userID, channelID int64, availableMinID int) domain.UpdateEvent { - event := domain.UpdateEvent{ - UserID: userID, - Type: domain.UpdateEventChannelAvailable, - Date: int(r.clock.Now().Unix()), - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, - MaxID: availableMinID, - PtsCount: 1, - } - if r.deps.Updates == nil || userID == 0 || channelID == 0 || availableMinID <= 0 { - return event - } - authKeyID, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - recorded, _, err := r.deps.Updates.RecordChannelAvailableMessages(ctx, authKeyID, userID, channelID, availableMinID, sessionID) - if err != nil { - return event - } - return recorded -} - -func (r *Router) recordChannelReadInbox(ctx context.Context, userID int64, read domain.ReadChannelHistoryResult) (domain.UpdateEvent, error) { - if !read.Changed || read.ChannelID == 0 { - return domain.UpdateEvent{}, nil - } - date := int(r.clock.Now().Unix()) - event := domain.UpdateEvent{ - UserID: userID, - Type: domain.UpdateEventReadHistoryInbox, - Date: date, - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: read.ChannelID}, - MaxID: read.MaxID, - StillUnreadCount: read.StillUnreadCount, - Pts: read.Pts, - PtsCount: 1, - } - recordedEvent := event - if r.deps.Updates != nil { - authKeyID, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - recorded, _, err := r.deps.Updates.RecordReadHistory(ctx, authKeyID, userID, domain.ReadHistoryResult{ - OwnerUserID: userID, - Peer: event.Peer, - MaxID: read.MaxID, - StillUnreadCount: read.StillUnreadCount, - Changed: read.Changed, - }, sessionID) - if err != nil { - return domain.UpdateEvent{}, internalErr() - } - recordedEvent = recorded - } - r.pushReadHistoryEvent(ctx, userID, event) - return recordedEvent, nil -} - -func (r *Router) pushChannelUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { - r.pushChannelUpdatesWithScope(ctx, channelFanoutMembers, originUserID, channelID, recipients, build) -} - -func (r *Router) pushChannelViewerUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { - r.pushChannelUpdatesWithScope(ctx, channelFanoutViewers, originUserID, channelID, recipients, build) -} - -func (r *Router) pushChannelExplicitUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { - r.pushChannelUpdatesWithScope(ctx, channelFanoutExplicit, originUserID, channelID, recipients, build) -} - -func (r *Router) pushChannelUpdatesWithScope(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { - if r.deps.Sessions == nil || build == nil { - return - } - recipients = r.channelFanoutRecipients(ctx, scope, channelID, recipients) - seen := make(map[int64]struct{}, len(recipients)) - pushed := false - for _, userID := range recipients { - if userID == 0 { - continue - } - if _, ok := seen[userID]; ok { - continue - } - seen[userID] = struct{}{} - updates := build(userID) - if updates == nil { - continue - } - r.pushUserUpdates(ctx, userID, updates) - pushed = true - } - if !pushed && originUserID != 0 { - updates := build(originUserID) - if updates == nil { - return - } - r.pushUserUpdates(ctx, originUserID, updates) - } -} - -func (r *Router) channelFanoutRecipients(ctx context.Context, scope channelFanoutScope, channelID int64, explicit []int64) []int64 { - if channelID == 0 || r.deps.Channels == nil || r.deps.Sessions == nil { - return uniqueRecipientIDs(explicit) - } - if scope == channelFanoutExplicit { - return uniqueRecipientIDs(explicit) - } - provider, ok := r.deps.Sessions.(OnlineUserProvider) - if !ok { - return uniqueRecipientIDs(explicit) - } - var online []int64 - switch scope { - case channelFanoutMembers: - online = provider.OnlineChannelMemberUserIDs(channelID, 0) - case channelFanoutViewers: - online = provider.OnlineChannelUserIDs(channelID, 0) - } - if len(online) == 0 { - return uniqueRecipientIDs(explicit) - } - active, err := r.deps.Channels.FilterActiveMemberIDs(ctx, channelID, online) - if err != nil { - return uniqueRecipientIDs(explicit) - } - if len(active) == 0 && len(explicit) == 0 { - return nil - } - out := uniqueRecipientIDs(active) - seen := make(map[int64]struct{}, len(out)+len(explicit)) - for _, userID := range active { - if userID == 0 { - continue - } - seen[userID] = struct{}{} - } - // Keep operation-specific recipients as a fallback: leave/kick/delete flows - // may need to notify a user who is no longer an active member after commit. - for _, userID := range explicit { - if userID == 0 { - continue - } - if _, ok := seen[userID]; ok { - continue - } - seen[userID] = struct{}{} - out = append(out, userID) - } - return out -} - -func uniqueRecipientIDs(ids []int64) []int64 { - if len(ids) == 0 { - return nil - } - out := make([]int64, 0, len(ids)) - seen := make(map[int64]struct{}, len(ids)) - for _, userID := range ids { - if userID == 0 { - continue - } - if _, ok := seen[userID]; ok { - continue - } - seen[userID] = struct{}{} - out = append(out, userID) - } - return out -} - -func (r *Router) pushChannelStateToMembers(ctx context.Context, originUserID int64, channel domain.Channel) { - if r.deps.Channels == nil || channel.ID == 0 { - return - } - r.pushChannelUpdates(ctx, originUserID, channel.ID, []int64{originUserID}, func(viewerUserID int64) *tg.Updates { - return r.channelStateUpdates(viewerUserID, channel) - }) -} - -func (r *Router) tgUsersForIDs(ctx context.Context, currentUserID int64, ids []int64) []tg.UserClass { - if r.deps.Users == nil || len(ids) == 0 { - return nil - } - unique := make([]int64, 0, len(ids)) - seen := make(map[int64]struct{}, len(ids)) - for _, id := range ids { - if id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - unique = append(unique, id) - } - users, err := r.deps.Users.ByIDs(ctx, currentUserID, unique) - if err != nil { - // 批量解析失败(通常是 DB 故障)不静默丢弃:批量语义下无部分结果,记日志便于排查。 - // update 仍以空 users 列表推送,客户端用本地缓存或后续 getUser 补齐,不致命。 - // 不降级逐个查询以免 DB 抖动时把一次失败放大成 N 次查询。 - r.log.Warn("batch resolve users for channel update failed", - zap.Int("count", len(unique)), zap.Error(err)) - return nil - } - byID := make(map[int64]domain.User, len(users)) - for _, u := range users { - if u.ID != 0 { - byID[u.ID] = u - } - } - out := make([]tg.UserClass, 0, len(byID)) - for _, id := range unique { - u, ok := byID[id] - if !ok { - continue - } - if id == currentUserID { - out = append(out, r.tgSelfUser(u)) - continue - } - out = append(out, r.tgUser(u)) - } - return out -} - -func tgExportedChannelInvite(invite domain.ChannelInvite) tg.ExportedChatInviteClass { - out := &tg.ChatInviteExported{ - Revoked: invite.Revoked, - Permanent: invite.Permanent, - RequestNeeded: invite.RequestNeeded, - Link: "https://t.me/+" + invite.Hash, - AdminID: invite.AdminUserID, - Date: invite.Date, - } - if invite.Title != "" { - out.SetTitle(invite.Title) - } - if invite.ExpireDate > 0 { - out.SetExpireDate(invite.ExpireDate) - } - if invite.UsageLimit > 0 { - out.SetUsageLimit(invite.UsageLimit) - } - if invite.UsageCount > 0 { - out.SetUsage(invite.UsageCount) - } - if invite.RequestedCount > 0 { - out.SetRequested(invite.RequestedCount) - } - return out -} - -func chatInviteExportedStub(adminUserID int64, link, title string, revoked, requestNeeded bool, expireDate, usageLimit, usageCount, date int) tg.ExportedChatInviteClass { - out := &tg.ChatInviteExported{ - Revoked: revoked, - RequestNeeded: requestNeeded, - Link: strings.TrimSpace(link), - AdminID: adminUserID, - Date: date, - } - if title != "" { - out.SetTitle(title) - } - if expireDate > 0 { - out.SetExpireDate(expireDate) - } - if usageLimit > 0 { - out.SetUsageLimit(usageLimit) - } - if usageCount > 0 { - out.SetUsage(usageCount) - } - return out -} - -func (r *Router) inviteManagementChannelView(ctx context.Context, peer tg.InputPeerClass) (int64, domain.ChannelView, error) { - ref, ok := inviteManagementChannelRef(peer) - if !ok { - return 0, domain.ChannelView{}, peerIDInvalidErr() - } - input := &tg.InputChannel{ChannelID: ref.ID} - if ref.CheckAccessHash { - input.AccessHash = ref.AccessHash - } - userID, view, err := r.channelView(ctx, input) - if err != nil { - return 0, domain.ChannelView{}, err - } - if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin { - return 0, domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED") - } - return userID, view, nil -} - -func inviteManagementChannelRef(peer tg.InputPeerClass) (channelInputRef, bool) { - switch p := peer.(type) { - case *tg.InputPeerChannel: - return channelInputRef{ - ID: p.ChannelID, - AccessHash: p.AccessHash, - CheckAccessHash: p.AccessHash != 0, - }, p.ChannelID > 0 - case *tg.InputPeerChannelFromMessage: - return channelInputRef{ID: p.ChannelID}, p.ChannelID > 0 - case *tg.InputPeerChat: - return channelInputRef{ID: p.ChatID}, p.ChatID > 0 - default: - return channelInputRef{}, false - } -} - -func validateChatInviteLink(link string) error { - link = strings.TrimSpace(link) - if link == "" { - return tgerr400("INVITE_HASH_EMPTY") - } - if len(link) > maxChatInviteLinkLength { - return limitInvalidErr() - } - return nil -} - -func channelInviteHashFromLink(link string) (string, error) { - if err := validateChatInviteLink(link); err != nil { - return "", err - } - link = strings.TrimSpace(link) - link = strings.TrimPrefix(link, "tg://join?invite=") - if strings.Contains(link, "://") { - if idx := strings.LastIndex(link, "/+"); idx >= 0 { - link = link[idx+2:] - } else if idx := strings.LastIndex(link, "/joinchat/"); idx >= 0 { - link = link[idx+10:] - } else if idx := strings.LastIndex(link, "/"); idx >= 0 { - link = link[idx+1:] - } - } - link = strings.TrimPrefix(link, "+") - link = strings.TrimSpace(link) - if link == "" { - return "", tgerr400("INVITE_HASH_EMPTY") - } - if len(link) > maxChatInviteLinkLength { - return "", limitInvalidErr() - } - return link, nil -} - -func inputUserIsEmpty(input tg.InputUserClass) bool { - switch input.(type) { - case nil, *tg.InputUserEmpty: - return true - default: - return false - } -} - -func (r *Router) userIDsFromInputUsers(ctx context.Context, currentUserID int64, inputs []tg.InputUserClass) ([]int64, error) { - out := make([]int64, 0, len(inputs)) - seen := make(map[int64]struct{}, len(inputs)) - for _, input := range inputs { - u, found, err := r.userFromInput(ctx, currentUserID, input) - if err != nil { - return nil, internalErr() - } - if !found || u.ID == 0 { - return nil, peerIDInvalidErr() - } - if _, ok := seen[u.ID]; ok { - continue - } - seen[u.ID] = struct{}{} - out = append(out, u.ID) - } - return out, nil -} - -func domainChannelAdminLogFilter(req *tg.ChannelsGetAdminLogRequest) domain.ChannelAdminLogFilter { - filter, ok := req.GetEventsFilter() - if !ok { - return domain.ChannelAdminLogFilter{} - } - return domain.ChannelAdminLogFilter{ - Join: filter.GetJoin(), - Leave: filter.GetLeave(), - Invite: filter.GetInvite(), - Ban: filter.GetBan(), - Unban: filter.GetUnban(), - Kick: filter.GetKick(), - Unkick: filter.GetUnkick(), - Promote: filter.GetPromote(), - Demote: filter.GetDemote(), - Info: filter.GetInfo(), - Settings: filter.GetSettings(), - Pinned: filter.GetPinned(), - Edit: filter.GetEdit(), - Delete: filter.GetDelete(), - Send: filter.GetSend(), - Invites: filter.GetInvites(), - Forums: filter.GetForums(), - SubExtend: filter.GetSubExtend(), - EditRank: filter.GetEditRank(), - } -} - -func (r *Router) channelAdminLogUsers(ctx context.Context, currentUserID int64, events []domain.ChannelAdminLogEvent) []tg.UserClass { - if r.deps.Users == nil || len(events) == 0 { - return nil - } - ids := make(map[int64]struct{}, len(events)) - add := func(id int64) { - if id != 0 { - ids[id] = struct{}{} - } - } - addMember := func(member *domain.ChannelMember) { - if member != nil { - add(member.UserID) - add(member.InviterUserID) - } - } - addMessage := func(msg *domain.ChannelMessage) { - if msg != nil { - add(msg.SenderUserID) - if msg.From.Type == domain.PeerTypeUser { - add(msg.From.ID) - } - } - } - for _, event := range events { - add(event.UserID) - addMember(event.PrevParticipant) - addMember(event.NewParticipant) - addMember(event.Participant) - addMessage(event.Message) - addMessage(event.PrevMessage) - addMessage(event.NewMessage) - } - out := make([]tg.UserClass, 0, len(ids)) - userIDs := make([]int64, 0, len(ids)) - for id := range ids { - userIDs = append(userIDs, id) - } - sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] }) - for _, id := range userIDs { - u, found, err := r.deps.Users.ByID(ctx, currentUserID, id) - if err == nil && found { - out = append(out, r.tgUser(u)) - } - } - return out -} - -type channelInputRef struct { - ID int64 - AccessHash int64 - CheckAccessHash bool -} - -func inputChannelRef(input tg.InputChannelClass) (channelInputRef, bool) { - switch channel := input.(type) { - case *tg.InputChannel: - return channelInputRef{ - ID: channel.ChannelID, - AccessHash: channel.AccessHash, - CheckAccessHash: channel.AccessHash != 0, - }, channel.ChannelID > 0 - case *tg.InputChannelFromMessage: - return channelInputRef{ID: channel.ChannelID}, channel.ChannelID > 0 - default: - return channelInputRef{}, false - } -} - -func inputChannelID(input tg.InputChannelClass) (int64, bool) { - ref, ok := inputChannelRef(input) - return ref.ID, ok -} - -func inputChannelAccessHashMatches(ref channelInputRef, channel domain.Channel) bool { - return !ref.CheckAccessHash || ref.AccessHash == channel.AccessHash -} - -func (r *Router) optionalChannelIDFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) { - switch input.(type) { - case nil, *tg.InputChannelEmpty: - return 0, nil - default: - return r.channelIDFromInput(ctx, userID, input) - } -} - -func (r *Router) channelIDFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) { - ref, ok := inputChannelRef(input) - if !ok { - return 0, channelInvalidErr(domain.ErrChannelInvalid) - } - if !ref.CheckAccessHash || r.deps.Channels == nil { - return ref.ID, nil - } - view, err := r.deps.Channels.GetChannel(ctx, userID, ref.ID) - if err != nil { - return 0, channelInvalidErr(err) - } - if !inputChannelAccessHashMatches(ref, view.Channel) { - return 0, channelInvalidErr(domain.ErrChannelPrivate) - } - return ref.ID, nil -} - -func validChannelTitle(title string) bool { - n := utf8.RuneCountInString(title) - return n > 0 && n <= maxChannelTitleLength -} - -func (r *Router) channelView(ctx context.Context, input tg.InputChannelClass) (int64, domain.ChannelView, error) { - if r.deps.Channels == nil { - return 0, domain.ChannelView{}, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return 0, domain.ChannelView{}, internalErr() - } - ref, ok := inputChannelRef(input) - if !ok { - return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelInvalid) - } - view, err := r.deps.Channels.GetChannel(ctx, userID, ref.ID) - if err != nil { - return 0, domain.ChannelView{}, channelInvalidErr(err) - } - if !inputChannelAccessHashMatches(ref, view.Channel) { - return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelPrivate) - } - return userID, view, nil -} - -func (r *Router) channelChangeInfoView(ctx context.Context, input tg.InputChannelClass) (int64, domain.ChannelView, error) { - if r.deps.Channels == nil { - return 0, domain.ChannelView{}, notImplementedErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return 0, domain.ChannelView{}, internalErr() - } - ref, ok := inputChannelRef(input) - if !ok { - return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelInvalid) - } - view, err := r.deps.Channels.GetChannelForChangeInfo(ctx, userID, ref.ID) - if err != nil { - return 0, domain.ChannelView{}, channelAdminErr(err) - } - if !inputChannelAccessHashMatches(ref, view.Channel) { - return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelPrivate) - } - return userID, view, nil -} - -func (r *Router) channelStateCompatUpdate(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { - userID, view, err := r.channelChangeInfoView(ctx, input) - if err != nil { - return nil, err - } - updates := r.channelStateUpdates(userID, view.Channel) - r.pushChannelStateToMembers(ctx, userID, view.Channel) - return updates, nil -} - -func tgEmptyUpdates(date int) *tg.Updates { - return &tg.Updates{ - Updates: []tg.UpdateClass{}, - Users: []tg.UserClass{}, - Chats: []tg.ChatClass{}, - Date: date, - Seq: 0, - } -} - -func validChannelSlowModeSeconds(seconds int) bool { - return domain.ValidChannelSlowModeSeconds(seconds) -} - -func validChannelManagementUsername(username string) bool { - username = strings.TrimSpace(strings.TrimPrefix(username, "@")) - if len(username) < 5 || len(username) > 32 { - return false - } - for i := 0; i < len(username); i++ { - c := username[i] - switch { - case i == 0 && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')): - case i > 0 && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'): - default: - return false - } - } - return true -} - -func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsRequest) (domain.ChannelReactionPolicy, error) { - if req == nil || req.AvailableReactions == nil { - return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") - } - if req.ReactionsLimit < 0 || req.ReactionsLimit > domain.MaxChannelReactionItems { - return domain.ChannelReactionPolicy{}, limitInvalidErr() - } - policy := domain.ChannelReactionPolicy{ - Limit: req.ReactionsLimit, - PaidEnabled: req.PaidEnabled, - } - switch reactions := req.AvailableReactions.(type) { - case *tg.ChatReactionsNone: - policy.Type = domain.ChannelReactionPolicyNone - case *tg.ChatReactionsAll: - policy.Type = domain.ChannelReactionPolicyAll - policy.AllowCustom = reactions.AllowCustom - case *tg.ChatReactionsSome: - if len(reactions.Reactions) > domain.MaxChannelReactionItems { - return domain.ChannelReactionPolicy{}, limitInvalidErr() - } - policy.Type = domain.ChannelReactionPolicySome - for _, reaction := range reactions.Reactions { - switch value := reaction.(type) { - case *tg.ReactionEmoji: - if strings.TrimSpace(value.Emoticon) == "" || utf8.RuneCountInString(value.Emoticon) > domain.MaxChannelReactionEmoticonLength { - return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") - } - policy.Emoticons = append(policy.Emoticons, value.Emoticon) - case *tg.ReactionCustomEmoji: - if value.DocumentID <= 0 { - return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") - } - policy.CustomEmojiIDs = append(policy.CustomEmojiIDs, value.DocumentID) - default: - return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") - } - } - default: - return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") - } - return policy, nil -} - -func domainPeerColorFromChannelUpdate(req *tg.ChannelsUpdateColorRequest) domain.ChannelPeerColor { - if req == nil { - return domain.ChannelPeerColor{} - } - color, hasColor := req.GetColor() - backgroundEmojiID, hasBackground := req.GetBackgroundEmojiID() - out := domain.ChannelPeerColor{HasColor: hasColor, Color: color} - if hasBackground { - out.BackgroundEmojiID = backgroundEmojiID - } - return out -} - -func domainChannelEmojiStatus(status tg.EmojiStatusClass) (domain.ChannelEmojiStatus, error) { - switch s := status.(type) { - case *tg.EmojiStatusEmpty: - return domain.ChannelEmojiStatus{}, nil - case *tg.EmojiStatus: - if s.DocumentID <= 0 { - return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") - } - until, _ := s.GetUntil() - if until < 0 { - return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") - } - return domain.ChannelEmojiStatus{DocumentID: s.DocumentID, Until: until}, nil - case *tg.EmojiStatusCollectible: - if s.DocumentID <= 0 { - return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") - } - until, _ := s.GetUntil() - if until < 0 { - return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") - } - return domain.ChannelEmojiStatus{DocumentID: s.DocumentID, Until: until}, nil - case *tg.InputEmojiStatusCollectible: - return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") - default: - return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") - } -} - -func domainChannelParticipantsFilter(filter tg.ChannelParticipantsFilterClass) domain.ChannelParticipantsFilter { - switch f := filter.(type) { - case *tg.ChannelParticipantsAdmins: - return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins} - case *tg.ChannelParticipantsKicked: - return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsKicked, Query: f.Q} - case *tg.ChannelParticipantsBanned: - return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBanned, Query: f.Q} - case *tg.ChannelParticipantsSearch: - return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsSearch, Query: f.Q} - case *tg.ChannelParticipantsBots: - return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBots} - case *tg.ChannelParticipantsContacts: - return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsContacts, Query: f.Q} - case *tg.ChannelParticipantsMentions: - return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsMentions, Query: f.Q} - default: - return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent} - } -} - -func legacyBasicGroupAdminRights() tg.ChatAdminRights { - return tg.ChatAdminRights{ - ChangeInfo: true, - DeleteMessages: true, - BanUsers: true, - InviteUsers: true, - PinMessages: true, - Other: true, - } -} - -func channelIDFromLegacyInputPeer(userID int64, peer tg.InputPeerClass) (int64, bool) { - switch p := peer.(type) { - case *tg.InputPeerChannel: - return p.ChannelID, p.ChannelID > 0 - case *tg.InputPeerChat: - return p.ChatID, p.ChatID > 0 - case *tg.InputPeerChannelFromMessage: - return p.ChannelID, p.ChannelID > 0 - default: - return 0, false - } -} - -func (r *Router) channelIDFromLegacyInputPeerChecked(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) { - channelID, ok := channelIDFromLegacyInputPeer(userID, peer) - if !ok { - return 0, peerIDInvalidErr() - } - if err := r.validateInputPeerChannelAccess(ctx, userID, peer, channelID); err != nil { - return 0, err - } - return channelID, nil -} - -func isChannelNotFound(err error) bool { - return errors.Is(err, domain.ErrChannelInvalid) || - errors.Is(err, domain.ErrChannelPrivate) || - errors.Is(err, domain.ErrChannelUserBanned) -} - -func channelInvalidErr(err error) error { - switch { - case errors.Is(err, domain.ErrChannelTitleInvalid): - return tgerr400("CHAT_TITLE_EMPTY") - case errors.Is(err, domain.ErrChannelInvalid): - return tgerr400("CHANNEL_INVALID") - case errors.Is(err, domain.ErrChannelPrivate): - return tgerr400("CHANNEL_PRIVATE") - case errors.Is(err, domain.ErrChannelUserBanned): - return tgerr400("USER_BANNED_IN_CHANNEL") - case errors.Is(err, domain.ErrChannelWriteForbidden): - return tgerr400("CHAT_WRITE_FORBIDDEN") - case errors.Is(err, domain.ErrChannelAdminRequired): - return tgerr400("CHAT_ADMIN_REQUIRED") - case errors.Is(err, domain.ErrUserAlreadyParticipant): - return tgerr400("USER_ALREADY_PARTICIPANT") - case errors.Is(err, domain.ErrReplyMessageIDInvalid): - return replyMessageIDInvalidErr() - default: - if seconds, ok := domain.SlowModeWaitSeconds(err); ok { - return tgerr.New(420, fmt.Sprintf("SLOWMODE_WAIT_%d", seconds)) - } - return internalErr() - } -} - -func channelDeleteErr(err error) error { - switch { - case errors.Is(err, domain.ErrMessageIDInvalid): - return messageIDInvalidErr() - case errors.Is(err, domain.ErrMessageAuthorRequired): - return messageAuthorRequiredErr() - case errors.Is(err, domain.ErrChannelAdminRequired): - return tgerr400("CHAT_ADMIN_REQUIRED") - default: - return channelInvalidErr(err) - } -} - -func channelAdminErr(err error) error { - switch { - case errors.Is(err, domain.ErrChannelNotModified): - return tgerr400("CHAT_NOT_MODIFIED") - case errors.Is(err, domain.ErrChatPublicRequired): - return tgerr400("CHAT_PUBLIC_REQUIRED") - case errors.Is(err, domain.ErrChatDiscussionUnallowed): - return tgerr400("CHAT_DISCUSSION_UNALLOWED") - case errors.Is(err, domain.ErrChannelRightForbidden): - return tgerr.New(403, "RIGHT_FORBIDDEN") - case errors.Is(err, domain.ErrChannelUserCreator): - return tgerr400("USER_CREATOR") - case errors.Is(err, domain.ErrMessageIDInvalid): - return messageIDInvalidErr() - default: - return channelInvalidErr(err) - } -} - -func channelDiscussionErr(err error) error { - switch { - case errors.Is(err, domain.ErrLinkNotModified): - return tgerr400("LINK_NOT_MODIFIED") - case errors.Is(err, domain.ErrBroadcastIDInvalid): - return tgerr400("BROADCAST_ID_INVALID") - case errors.Is(err, domain.ErrMegagroupIDInvalid): - return tgerr400("MEGAGROUP_ID_INVALID") - case errors.Is(err, domain.ErrMegagroupPrehistoryHidden): - return tgerr400("MEGAGROUP_PREHISTORY_HIDDEN") - default: - return channelAdminErr(err) - } -} - -func channelInviteErr(err error) error { - switch { - case errors.Is(err, domain.ErrInviteHashEmpty): - return tgerr400("INVITE_HASH_EMPTY") - case errors.Is(err, domain.ErrInviteHashInvalid): - return tgerr400("INVITE_HASH_INVALID") - case errors.Is(err, domain.ErrInviteHashExpired): - return tgerr.New(406, "INVITE_HASH_EXPIRED") - case errors.Is(err, domain.ErrInvitePermanent): - return tgerr400("CHAT_INVITE_PERMANENT") - case errors.Is(err, domain.ErrInviteRevokedMissing): - return tgerr400("INVITE_REVOKED_MISSING") - case errors.Is(err, domain.ErrInviteRequestSent): - return tgerr400("INVITE_REQUEST_SENT") - case errors.Is(err, domain.ErrHideRequesterMissing): - return tgerr400("HIDE_REQUESTER_MISSING") - case errors.Is(err, domain.ErrUsersTooMuch): - return tgerr400("USERS_TOO_MUCH") - case errors.Is(err, domain.ErrUserAlreadyParticipant): - return tgerr400("USER_ALREADY_PARTICIPANT") - case errors.Is(err, domain.ErrUserKicked): - return tgerr400("USER_KICKED") - default: - return channelInvalidErr(err) - } -} - -func channelUsernameErr(err error) error { - switch { - case errors.Is(err, domain.ErrUsernameInvalid): - return usernameInvalidErr() - case errors.Is(err, domain.ErrUsernameOccupied): - return usernameOccupiedErr() - case errors.Is(err, domain.ErrChannelNotModified): - return usernameNotModifiedErr() - default: - return channelAdminErr(err) - } -} - -func tgerr400(message string) error { - return tgerr.New(400, message) -} diff --git a/internal/rpc/channels_admin_log_users_rpc_test.go b/internal/rpc/channels_admin_log_users_rpc_test.go new file mode 100644 index 00000000..c5003505 --- /dev/null +++ b/internal/rpc/channels_admin_log_users_rpc_test.go @@ -0,0 +1,57 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "telesrv/internal/domain" + "testing" +) + +func TestChannelAdminLogUsersUsesSingleBatchLookup(t *testing.T) { + users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{ + 3: {ID: 3, FirstName: "Actor"}, + 4: {ID: 4, FirstName: "Participant"}, + 5: {ID: 5, FirstName: "Sender"}, + }}} + r := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System) + + got := r.channelAdminLogUsers(context.Background(), 1, []domain.ChannelAdminLogEvent{ + { + UserID: 3, + Participant: &domain.ChannelMember{ + UserID: 4, + InviterUserID: 3, + }, + Message: &domain.ChannelMessage{ + SenderUserID: 5, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 4}, + }, + }, + { + UserID: 5, + PrevParticipant: &domain.ChannelMember{UserID: 4}, + }, + }) + if users.byIDsCalls != 1 || users.byIDCalls != 0 { + t.Fatalf("user lookups byIDs=%d byID=%d, want one ByIDs and no ByID", users.byIDsCalls, users.byIDCalls) + } + if len(users.lastByIDs) != 3 || users.lastByIDs[0] != 3 || users.lastByIDs[1] != 4 || users.lastByIDs[2] != 5 { + t.Fatalf("ByIDs ids = %+v, want [3 4 5]", users.lastByIDs) + } + ids := gotUserIDs(got) + if len(ids) != 3 || ids[0] != 3 || ids[1] != 4 || ids[2] != 5 { + t.Fatalf("admin log users = %+v, want users 3/4/5", got) + } +} + +func gotUserIDs(users []tg.UserClass) []int64 { + out := make([]int64, 0, len(users)) + for _, item := range users { + if user, ok := item.(*tg.User); ok { + out = append(out, user.ID) + } + } + return out +} diff --git a/internal/rpc/channels_constants.go b/internal/rpc/channels_constants.go new file mode 100644 index 00000000..62f3c760 --- /dev/null +++ b/internal/rpc/channels_constants.go @@ -0,0 +1,21 @@ +package rpc + +const ( + maxChannelTitleLength = 128 + maxChannelAboutLength = 255 + maxChannelUsernameOrder = 32 + maxChannelReportMessageIDs = 100 + maxChannelSearchPostsLimit = 50 + maxChannelSearchPostsQuery = 256 + maxChannelPaidMessageStars = 10000 + maxChannelBoostsToUnblockRestrictions = 8 + maxChatInviteListLimit = 100 + maxChatInviteLinkLength = 256 + maxChatInviteSearchLength = 256 +) + +const ( + channelFanoutMembers channelFanoutScope = iota + channelFanoutViewers + channelFanoutExplicit +) diff --git a/internal/rpc/channels_core.go b/internal/rpc/channels_core.go new file mode 100644 index 00000000..6cfa7a25 --- /dev/null +++ b/internal/rpc/channels_core.go @@ -0,0 +1,278 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onChannelsCreateChannel(ctx context.Context, req *tg.ChannelsCreateChannelRequest) (tg.UpdatesClass, error) { + if err := validateChannelsCreateChannelOptions(req); err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if !validChannelTitle(req.Title) || utf8.RuneCountInString(req.About) > maxChannelAboutLength { + return nil, channelInvalidErr(domain.ErrChannelTitleInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + res, err := r.deps.Channels.CreateChannel(ctx, userID, domain.CreateChannelRequest{ + CreatorUserID: userID, + Title: req.Title, + About: req.About, + Broadcast: req.Broadcast, + Megagroup: req.Megagroup, + Forum: req.Forum, + ForumTabs: req.Forum, + TTLPeriod: req.TTLPeriod, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + return updates, nil +} + +func validateChannelsCreateChannelOptions(req *tg.ChannelsCreateChannelRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if req.ForImport { + return chatInvalidErr() + } + if req.GeoPoint != nil || req.Address != "" { + return addressInvalidErr() + } + if req.TTLPeriod < 0 { + return ttlPeriodInvalidErr() + } + return nil +} + +func (r *Router) onChannelsGetChannels(ctx context.Context, ids []tg.InputChannelClass) (tg.MessagesChatsClass, error) { + if len(ids) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + refs := make([]channelInputRef, 0, len(ids)) + channelIDs := make([]int64, 0, len(ids)) + for _, input := range ids { + ref, ok := inputChannelRef(input) + if !ok || ref.ID == 0 || r.deps.Channels == nil { + continue + } + refs = append(refs, ref) + channelIDs = append(channelIDs, ref.ID) + } + if len(channelIDs) == 0 || r.deps.Channels == nil { + return &tg.MessagesChats{}, nil + } + views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs) + if err != nil { + return nil, internalErr() + } + byID := make(map[int64]domain.ChannelView, len(views)) + for _, view := range views { + byID[view.Channel.ID] = view + } + chats := make([]tg.ChatClass, 0, len(refs)) + for _, ref := range refs { + view, ok := byID[ref.ID] + if !ok || !inputChannelAccessHashMatches(ref, view.Channel) { + continue + } + chats = append(chats, tgChannelChatForView(userID, view)) + } + r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats) + return &tg.MessagesChats{Chats: chats}, nil +} + +func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputChannelClass) (*tg.MessagesChatFull, error) { + if r.deps.Channels == nil { + return &tg.MessagesChatFull{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + ref, ok := inputChannelRef(input) + if !ok { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + loadEpoch := r.channelFullProjectionCache.LoadEpoch() + if cached, ok := r.channelFullProjectionCache.Lookup(userID, ref.ID); ok { + if !inputChannelAccessHashMatches(ref, domain.Channel{ID: ref.ID, AccessHash: cached.accessHash}) { + return nil, channelInvalidErr(domain.ErrChannelPrivate) + } + full := cached.full + r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full) + r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full) + r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full) + chats := append([]tg.ChatClass(nil), cached.chats...) + r.trackChannelInterest(ctx, userID, ref.ID) + r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats) + return &tg.MessagesChatFull{ + FullChat: &full, + Chats: chats, + Users: r.tgUsersForIDs(ctx, userID, cached.userIDs), + }, nil + } + view, err := r.channelFullReadView(ctx, userID, input) + if err != nil { + return nil, err + } + full := tgChannelFull(view) + r.applyStarGiftsCountToChannelFull(ctx, view.Channel.ID, full) + userIDs := []int64{view.Channel.CreatorUserID, view.Self.UserID} + // 注:Bots 过滤实际会返回群内 bot(TestGroupBotRPCShape 覆盖),这里据此富化 full.BotInfo。 + // (此前审计误判为死代码,已由单测纠正——勿删。) + botInfo := r.channelFullBotInfo(ctx, userID, view.Channel.ID) + userIDs = append(userIDs, botInfo.userIDs...) + full.BotInfo = append(full.BotInfo, botInfo.botInfos...) + if canViewChannelJoinRequests(view.Self) { + userIDs = r.applyPendingJoinRequestsToFullChannel(ctx, full, view.Channel.ID, userIDs) + } + r.trackChannelInterest(ctx, userID, view.Channel.ID) + chats := []tg.ChatClass{tgChannelChatForView(userID, view)} + if mono, ok := r.linkedMonoforumForChannelState(ctx, userID, view.Channel); ok { + chats = appendUniqueTGChats(chats, tgChannelChat(userID, mono, nil)) + } + // 当前频道默认已由 tgChannelFull 处理;外部频道默认(以自己拥有的别的频道身份发言)需在此投影并 + // 带上该频道对象,否则客户端拿不到默认 chip。 + r.applyForeignDefaultSendAsToFull(ctx, userID, view, full, &chats) + r.channelFullProjectionCache.StoreIfEpoch(userID, view.Channel.ID, channelFullProjection{ + accessHash: view.Channel.AccessHash, + full: *full, + chats: append([]tg.ChatClass(nil), chats...), + userIDs: userIDs, + }, loadEpoch) + r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, view.Channel.ID, full) + r.applyNotifySettingsToChannelFull(ctx, userID, view.Channel.ID, full) + r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats) + return &tg.MessagesChatFull{ + FullChat: full, + Chats: chats, + Users: r.tgUsersForIDs(ctx, userID, userIDs), + }, nil +} + +func (r *Router) applyStarGiftsCountToChannelFull(ctx context.Context, channelID int64, full *tg.ChannelFull) { + if r.deps.Gifts == nil || channelID == 0 || full == nil { + return + } + n, err := r.deps.Gifts.CountSaved(ctx, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) + if err == nil && n > 0 { + full.SetStargiftsCount(n) + } +} + +type channelReadModelResolver interface { + GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) +} + +func (r *Router) channelFullReadView(ctx context.Context, userID int64, input tg.InputChannelClass) (domain.ChannelView, error) { + ref, ok := inputChannelRef(input) + if !ok { + return domain.ChannelView{}, channelInvalidErr(domain.ErrChannelInvalid) + } + var ( + view domain.ChannelView + err error + ) + if cached, ok := r.deps.Channels.(channelReadModelResolver); ok { + view, err = cached.GetChannelReadModel(ctx, userID, ref.ID) + } else { + view, err = r.deps.Channels.GetChannel(ctx, userID, ref.ID) + } + if err != nil { + return domain.ChannelView{}, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return domain.ChannelView{}, channelInvalidErr(domain.ErrChannelPrivate) + } + return view, nil +} + +func (r *Router) onChannelsGetSendAs(ctx context.Context, req *tg.ChannelsGetSendAsRequest) (*tg.ChannelsSendAsPeers, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + peer, ok := r.domainPeerFromInputPeer(userID, req.Peer) + if !ok || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + chats := []tg.ChatClass(nil) + peers := []tg.SendAsPeer{{Peer: &tg.PeerUser{UserID: userID}}} + if peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return &tg.ChannelsSendAsPeers{}, nil + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + if ref, ok := inputPeerChannelRef(req.Peer); ok { + if ref.ID != view.Channel.ID || (ref.CheckAccessHash && !inputChannelAccessHashMatches(ref, view.Channel)) { + return nil, channelInvalidErr(domain.ErrChannelPrivate) + } + } + chats = []tg.ChatClass{tgChannelChatForView(userID, view)} + // 以「当前频道/群本身」发言:广播频道自帖、匿名管理员等(canCurrentChannelSendAs 判定)。 + if canCurrentChannelSendAs(view) { + peers = append(peers, tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}}) + } + // 以「用户自己拥有的其它广播频道」身份在本群发言。非本群关联频道的个人频道需会员 + // (premium_required,对齐官方:仅本群的 linked 讨论频道免会员),客户端据此置灰/引导开会员, + // 服务端在发送侧用 PremiumActiveAt 兜底门控。 + if owned, err := r.deps.Channels.ListSendAsChannels(ctx, userID); err == nil && len(owned) > 0 { + extras := make([]domain.Channel, 0, len(owned)) + for _, ch := range owned { + if ch.ID == 0 || ch.ID == view.Channel.ID { + continue + } + sendAs := tg.SendAsPeer{Peer: &tg.PeerChannel{ChannelID: ch.ID}} + if ch.ID != view.Channel.LinkedChatID { + sendAs.PremiumRequired = true + } + peers = append(peers, sendAs) + extras = append(extras, ch) + } + chats = append(chats, tgChannels(userID, extras)...) + } + } + return &tg.ChannelsSendAsPeers{ + Peers: peers, + Chats: chats, + Users: r.tgUsersForIDs(ctx, userID, []int64{userID}), + }, nil +} + +func (r *Router) applyPendingJoinRequestsToFullChannel(ctx context.Context, full *tg.ChannelFull, channelID int64, userIDs []int64) []int64 { + if r.deps.Channels == nil || full == nil || channelID == 0 { + return userIDs + } + pending, err := r.deps.Channels.PendingJoinRequests(ctx, channelID, domain.MaxChannelPendingJoinRecentRequesters) + if err != nil || pending.Count <= 0 { + return userIDs + } + full.SetRequestsPending(pending.Count) + full.SetRecentRequesters(pending.RecentRequesters) + return append(userIDs, pending.RecentRequesters...) +} diff --git a/internal/rpc/channels_delete_monoforum_test.go b/internal/rpc/channels_delete_monoforum_test.go new file mode 100644 index 00000000..d9ba1bb4 --- /dev/null +++ b/internal/rpc/channels_delete_monoforum_test.go @@ -0,0 +1,147 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestChannelsDeleteChannelCascadesMonoforumForbiddenRPC 锁定 Risk A 的 RPC 层契约:删除开启了 +// Direct Messages 的母广播频道时,响应与推送都必须为母频道 **和** 其关联 monoforum 各下发一条 +// ChannelForbidden 墓碑,且删后 getDialogs 两者都不再返回。否则在线客户端会留着 mono 会话 +// (isMonoforum=true 但 link 不可解析)继续渲染崩溃。 +func TestChannelsDeleteChannelCascadesMonoforumForbiddenRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550004041", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + + channelStore := memory.NewChannelStore() + bc, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, Title: "DM Broadcast", Broadcast: true, Date: 1_700_000_900, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + parentID := bc.Channel.ID + enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, parentID, 0, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + if monoID == 0 { + t.Fatalf("no monoforum created") + } + + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + getDialogChannelIDs := func() map[int64]struct{} { + t.Helper() + req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 50} + var b bin.Buffer + if err := req.Encode(&b); err != nil { + t.Fatalf("encode get dialogs: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch get dialogs: %v", err) + } + box, ok := enc.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("dialogs response = %T, want box", enc) + } + ids := map[int64]struct{}{} + switch d := box.Dialogs.(type) { + case *tg.MessagesDialogs: + for _, ch := range d.Chats { + if c, ok := ch.(*tg.Channel); ok { + ids[c.ID] = struct{}{} + } + } + case *tg.MessagesDialogsSlice: + for _, ch := range d.Chats { + if c, ok := ch.(*tg.Channel); ok { + ids[c.ID] = struct{}{} + } + } + default: + t.Fatalf("dialogs = %T, want messages.dialogs(Slice)", box.Dialogs) + } + return ids + } + + before := getDialogChannelIDs() + if _, ok := before[parentID]; !ok { + t.Fatalf("before delete: parent %d missing from dialogs %v", parentID, before) + } + if _, ok := before[monoID]; !ok { + t.Fatalf("before delete: monoforum %d missing from dialogs %v", monoID, before) + } + + deleted, err := r.onChannelsDeleteChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ + ChannelID: parentID, + AccessHash: bc.Channel.AccessHash, + }) + if err != nil { + t.Fatalf("delete channel: %v", err) + } + + forbidden := func(updates *tg.Updates) map[int64]struct{} { + ids := map[int64]struct{}{} + for _, ch := range updates.Chats { + if f, ok := ch.(*tg.ChannelForbidden); ok { + ids[f.ID] = struct{}{} + } + } + return ids + } + + respUpdates, ok := deleted.(*tg.Updates) + if !ok { + t.Fatalf("delete response = %T, want *tg.Updates", deleted) + } + respForbidden := forbidden(respUpdates) + if _, ok := respForbidden[parentID]; !ok { + t.Fatalf("delete response missing ChannelForbidden for parent %d: %+v", parentID, respUpdates.Chats) + } + if _, ok := respForbidden[monoID]; !ok { + t.Fatalf("delete response missing ChannelForbidden for monoforum %d: %+v", monoID, respUpdates.Chats) + } + + // 推送给接收方(含 owner 本人)的更新同样要带 mono 墓碑。 + pushed := sessions.snapshot() + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", pushed.message) + } + pushedForbidden := forbidden(pushedUpdates) + if _, ok := pushedForbidden[monoID]; !ok { + t.Fatalf("pushed update missing ChannelForbidden for monoforum %d: %+v", monoID, pushedUpdates.Chats) + } + + after := getDialogChannelIDs() + if _, ok := after[parentID]; ok { + t.Fatalf("after delete: parent %d still in dialogs %v", parentID, after) + } + if _, ok := after[monoID]; ok { + t.Fatalf("after delete: monoforum %d still in dialogs %v", monoID, after) + } +} diff --git a/internal/rpc/channels_dialogs.go b/internal/rpc/channels_dialogs.go new file mode 100644 index 00000000..b704d719 --- /dev/null +++ b/internal/rpc/channels_dialogs.go @@ -0,0 +1,75 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func (r *Router) onChannelsGetLeftChannels(ctx context.Context, offset int) (tg.MessagesChatsClass, error) { + if offset < 0 || offset > domain.MaxLeftChannelsOffset { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil + } + list, err := r.deps.Channels.LeftChannels(ctx, userID, offset, domain.MaxLeftChannelsLimit) + if err != nil { + return nil, channelInvalidErr(err) + } + chats := make([]tg.ChatClass, 0, len(list.Channels)) + for _, item := range list.Channels { + chats = append(chats, tgChannelChat(userID, item.Channel, &item.Self)) + } + if len(chats) == 0 && list.Count > 0 { + return &tg.MessagesChatsSlice{Count: list.Count, Chats: chats}, nil + } + if offset+len(chats) < list.Count { + return &tg.MessagesChatsSlice{Count: list.Count, Chats: chats}, nil + } + return &tg.MessagesChats{Chats: chats}, nil +} + +func (r *Router) onChannelsGetInactiveChannels(ctx context.Context) (*tg.MessagesInactiveChats, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return &tg.MessagesInactiveChats{Dates: []int{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil + } + list, err := r.deps.Channels.InactiveChannels(ctx, userID, domain.MaxInactiveChannelsLimit) + if err != nil { + return nil, channelInvalidErr(err) + } + dates := make([]int, 0, len(list.Channels)) + chats := make([]tg.ChatClass, 0, len(list.Channels)) + for i, channel := range list.Channels { + date := channel.Date + if i < len(list.Dialogs) && list.Dialogs[i].TopMessageDate > 0 { + date = list.Dialogs[i].TopMessageDate + } + dates = append(dates, date) + chats = append(chats, tgChannelChatMin(userID, channel)) + } + return &tg.MessagesInactiveChats{Dates: dates, Chats: chats, Users: []tg.UserClass{}}, nil +} + +func (r *Router) onChannelsGetGroupsForDiscussion(ctx context.Context) (tg.MessagesChatsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Channels == nil { + return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil + } + channels, err := r.deps.Channels.DiscussionGroups(ctx, userID, domain.MaxDiscussionGroupsLimit) + if err != nil { + return nil, channelInvalidErr(err) + } + return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil +} diff --git a/internal/rpc/channels_dialogs_rpc_test.go b/internal/rpc/channels_dialogs_rpc_test.go new file mode 100644 index 00000000..33e0b224 --- /dev/null +++ b/internal/rpc/channels_dialogs_rpc_test.go @@ -0,0 +1,605 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strconv" + "strings" + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestChannelDialogCarriesChannelPts(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550001151", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Pts Group", + Megagroup: true, + Date: 1000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := channelSvc.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 7001, + Message: "pts probe", + Date: 1100, + }) + if err != nil { + t.Fatalf("send message: %v", err) + } + wantPts := sent.Event.Pts + if wantPts <= 0 { + t.Fatalf("sent channel pts = %d, want > 0", wantPts) + } + + dispatch := func(req bin.Encoder) bin.Encoder { + t.Helper() + var b bin.Buffer + if err := req.Encode(&b); err != nil { + t.Fatalf("encode request: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + return enc + } + + got := dispatch(&tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}) + box, ok := got.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("dialogs response = %T, want box", got) + } + dialogs, ok := box.Dialogs.(*tg.MessagesDialogs) + if !ok || len(dialogs.Dialogs) != 1 { + t.Fatalf("dialogs = %T %+v, want one channel dialog", box.Dialogs, box.Dialogs) + } + dialog, ok := dialogs.Dialogs[0].(*tg.Dialog) + if !ok { + t.Fatalf("dialog = %T, want *tg.Dialog", dialogs.Dialogs[0]) + } + pts, ok := dialog.GetPts() + if !ok || pts != wantPts { + t.Fatalf("dialog pts = %d (set=%v), want %d: clients seed channel difference state from dialog.pts", pts, ok, wantPts) + } + + peerResp := dispatch(&tg.MessagesGetPeerDialogsRequest{ + Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ + ChannelID: created.Channel.ID, + AccessHash: created.Channel.AccessHash, + }}}, + }) + peerDialogs, ok := peerResp.(*tg.MessagesPeerDialogs) + if !ok || len(peerDialogs.Dialogs) != 1 { + t.Fatalf("peer dialogs = %T %+v, want one dialog", peerResp, peerResp) + } + peerDialog, ok := peerDialogs.Dialogs[0].(*tg.Dialog) + if !ok { + t.Fatalf("peer dialog = %T, want *tg.Dialog", peerDialogs.Dialogs[0]) + } + if pts, ok := peerDialog.GetPts(); !ok || pts != wantPts { + t.Fatalf("peer dialog pts = %d (set=%v), want %d", pts, ok, wantPts) + } +} + +func TestChannelsGetInactiveChannelsReturnsLeastActiveRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550001041", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + heir, err := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550001042", FirstName: "Heir"}) + if err != nil { + t.Fatalf("create heir: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + createAndSend := func(title string, createDate, msgDate int, memberIDs ...int64) int64 { + t.Helper() + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: title, + Megagroup: true, + MemberUserIDs: memberIDs, + Date: createDate, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + if _, err := channelSvc.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: int64(msgDate), + Message: title + " message", + Date: msgDate, + }); err != nil { + t.Fatalf("send %s: %v", title, err) + } + return created.Channel.ID + } + + oldID := createAndSend("Old inactive", 1000, 1100) + midID := createAndSend("Middle inactive", 1000, 1200) + newID := createAndSend("New inactive", 1000, 1300) + leftID := createAndSend("Left inactive", 1000, 900, heir.ID) + if _, err := channelSvc.LeaveChannel(ctx, owner.ID, leftID, 1400); err != nil { + t.Fatalf("leave channel: %v", err) + } + + got, err := r.onChannelsGetInactiveChannels(WithUserID(ctx, owner.ID)) + if err != nil { + t.Fatalf("get inactive channels: %v", err) + } + if len(got.Dates) != 3 || len(got.Chats) != 3 || len(got.Users) != 0 { + t.Fatalf("inactive chats = %+v, want three active channel chats and no users", got) + } + wantIDs := []int64{oldID, midID, newID} + wantDates := []int{1100, 1200, 1300} + for i, chat := range got.Chats { + channel, ok := chat.(*tg.Channel) + if !ok { + t.Fatalf("chat %d = %T, want *tg.Channel", i, chat) + } + if channel.ID != wantIDs[i] || got.Dates[i] != wantDates[i] { + t.Fatalf("inactive item %d = id %d date %d, want id %d date %d", i, channel.ID, got.Dates[i], wantIDs[i], wantDates[i]) + } + } +} + +func TestChannelsGetChannelRecommendationsRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550001051", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + other, err := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550001052", FirstName: "Other"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + createPublicBroadcast := func(creator domain.User, title, username string, date int) domain.Channel { + t.Helper() + created, err := channelSvc.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + Title: title, + Broadcast: true, + Date: date, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + channel, err := channelSvc.UpdateUsername(ctx, creator.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: created.Channel.ID, + Username: username, + }) + if err != nil { + t.Fatalf("set username for %s: %v", title, err) + } + return channel + } + + source := createPublicBroadcast(owner, "Source Recommendations", "source_recs", 1000) + for i := 0; i < 12; i++ { + createPublicBroadcast(owner, "Candidate "+strconv.Itoa(i), "rec"+strconv.Itoa(i)+"public", 2000+i) + } + groupCreated, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Public Group", + Megagroup: true, + Date: 3000, + }) + if err != nil { + t.Fatalf("create public group: %v", err) + } + group, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: groupCreated.Channel.ID, + Username: "group_recs", + }) + if err != nil { + t.Fatalf("set group username: %v", err) + } + + recommendationsReq := func(channel domain.Channel) *tg.ChannelsGetChannelRecommendationsRequest { + req := &tg.ChannelsGetChannelRecommendationsRequest{} + req.SetChannel(&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + return req + } + + got, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq(source)) + if err != nil { + t.Fatalf("get recommendations by source: %v", err) + } + slice, ok := got.(*tg.MessagesChatsSlice) + if !ok { + t.Fatalf("recommendations = %T %+v, want messages.chatsSlice", got, got) + } + if slice.Count != 12 || len(slice.Chats) != domain.DefaultChannelRecommendationsLimit { + t.Fatalf("recommendations count=%d len=%d, want count 12 len %d", slice.Count, len(slice.Chats), domain.DefaultChannelRecommendationsLimit) + } + for _, chat := range slice.Chats { + channel, ok := chat.(*tg.Channel) + if !ok { + t.Fatalf("recommendation chat = %T, want channel", chat) + } + if channel.ID == source.ID || !channel.Broadcast || channel.Megagroup || channel.Username == "" { + t.Fatalf("recommendation channel = %+v, want public broadcast excluding source", channel) + } + } + + if _, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq(group)); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { + t.Fatalf("megagroup recommendations err = %v, want CHANNEL_INVALID", err) + } + + globalA := createPublicBroadcast(other, "Global A", "global_recs_a", 5000) + globalB := createPublicBroadcast(other, "Global B", "global_recs_b", 5100) + global, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), &tg.ChannelsGetChannelRecommendationsRequest{}) + if err != nil { + t.Fatalf("get global recommendations: %v", err) + } + box, ok := global.(*tg.MessagesChats) + if !ok { + t.Fatalf("global recommendations = %T %+v, want messages.chats", global, global) + } + if len(box.Chats) != 2 { + t.Fatalf("global recommendations len=%d chats=%+v, want two channels", len(box.Chats), box.Chats) + } + wantIDs := []int64{globalB.ID, globalA.ID} + for i, chat := range box.Chats { + channel, ok := chat.(*tg.Channel) + if !ok { + t.Fatalf("global chat %d = %T, want channel", i, chat) + } + if channel.ID != wantIDs[i] { + t.Fatalf("global chat %d id=%d, want %d", i, channel.ID, wantIDs[i]) + } + } +} + +func TestChannelsGetLeftChannelsRPCReturnsLeftFlagAndSafePaging(t *testing.T) { + ctx := context.Background() + const ( + ownerID int64 = 1000000901 + memberID int64 = 1000000902 + ) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{Channels: channelService}, zaptest.NewLogger(t), clock.System) + older, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Older Left", + MemberUserIDs: []int64{memberID}, + Date: 1700000900, + }) + if err != nil { + t.Fatalf("create older megagroup: %v", err) + } + newer, err := channelService.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Newer Left Broadcast", + Broadcast: true, + MemberUserIDs: []int64{memberID}, + Date: 1700000901, + }) + if err != nil { + t.Fatalf("create newer broadcast: %v", err) + } + if _, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Active Excluded", + MemberUserIDs: []int64{memberID}, + Date: 1700000902, + }); err != nil { + t.Fatalf("create active megagroup: %v", err) + } + if _, err := channelService.LeaveChannel(ctx, memberID, older.Channel.ID, 1700000903); err != nil { + t.Fatalf("leave older channel: %v", err) + } + if _, err := channelService.LeaveChannel(ctx, memberID, newer.Channel.ID, 1700000904); err != nil { + t.Fatalf("leave newer channel: %v", err) + } + + got, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), 0) + if err != nil { + t.Fatalf("get left channels: %v", err) + } + chats, ok := got.(*tg.MessagesChats) + if !ok || len(chats.Chats) != 2 { + t.Fatalf("left channels = %T %+v, want final messages.chats with two chats", got, got) + } + first, ok := chats.Chats[0].(*tg.Channel) + if !ok || first.ID != newer.Channel.ID || !first.Left { + t.Fatalf("first left channel = %+v (%T), want newest with left flag", chats.Chats[0], chats.Chats[0]) + } + second, ok := chats.Chats[1].(*tg.Channel) + if !ok || second.ID != older.Channel.ID || !second.Left { + t.Fatalf("second left channel = %+v (%T), want older with left flag", chats.Chats[1], chats.Chats[1]) + } + + empty, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), 2) + if err != nil { + t.Fatalf("get empty left channels page: %v", err) + } + emptySlice, ok := empty.(*tg.MessagesChatsSlice) + if !ok || emptySlice.Count != 2 || len(emptySlice.Chats) != 0 { + t.Fatalf("empty left page = %T %+v, want empty slice with full count", empty, empty) + } + if _, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), domain.MaxLeftChannelsOffset+1); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("huge offset err = %v, want LIMIT_INVALID", err) + } +} + +func TestChannelsDiscussionGroupRPCPersistsFullChannelLink(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1000000911 + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{Channels: channelService}, zaptest.NewLogger(t), clock.System) + broadcast, err := channelService.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Discussion Broadcast", + Broadcast: true, + Date: 1700000910, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + group, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Discussion Group", + Date: 1700000911, + }) + if err != nil { + t.Fatalf("create group: %v", err) + } + inputBroadcast := &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash} + inputGroup := &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash} + + candidates, err := r.onChannelsGetGroupsForDiscussion(WithUserID(ctx, ownerID)) + if err != nil { + t.Fatalf("get groups for discussion: %v", err) + } + candidateChats := candidates.(*tg.MessagesChats).Chats + if len(candidateChats) != 1 || candidateChats[0].(*tg.Channel).ID != group.Channel.ID { + t.Fatalf("discussion candidates = %+v, want the creator megagroup", candidateChats) + } + if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: inputBroadcast, + Group: inputGroup, + }); err != nil || !ok { + t.Fatalf("set discussion group = ok %v err %v, want true", ok, err) + } + fullBroadcast, err := r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputBroadcast) + if err != nil { + t.Fatalf("get full broadcast: %v", err) + } + linkedID, ok := fullBroadcast.FullChat.(*tg.ChannelFull).GetLinkedChatID() + if !ok || linkedID != group.Channel.ID { + t.Fatalf("broadcast linked_chat_id = %d ok %v, want group %d", linkedID, ok, group.Channel.ID) + } + fullGroup, err := r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputGroup) + if err != nil { + t.Fatalf("get full group: %v", err) + } + groupLinkedID, ok := fullGroup.FullChat.(*tg.ChannelFull).GetLinkedChatID() + if !ok || groupLinkedID != broadcast.Channel.ID { + t.Fatalf("group linked_chat_id = %d ok %v, want broadcast %d", groupLinkedID, ok, broadcast.Channel.ID) + } + gotChannel := fullBroadcast.Chats[0].(*tg.Channel) + if !gotChannel.GetHasLink() { + t.Fatalf("broadcast channel = %+v, want has_link", gotChannel) + } + if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: &tg.InputChannelEmpty{}, + Group: inputGroup, + }); err != nil || !ok { + t.Fatalf("unlink discussion group from group side = ok %v err %v, want true", ok, err) + } + fullBroadcast, err = r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputBroadcast) + if err != nil { + t.Fatalf("get full broadcast after unlink: %v", err) + } + if linkedID, ok := fullBroadcast.FullChat.(*tg.ChannelFull).GetLinkedChatID(); ok || linkedID != 0 { + t.Fatalf("broadcast linked_chat_id after unlink = %d ok %v, want unset", linkedID, ok) + } + if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: &tg.InputChannelEmpty{}, + Group: inputGroup, + }); err == nil || !strings.Contains(err.Error(), "LINK_NOT_MODIFIED") { + t.Fatalf("repeat unlink err = %v, want LINK_NOT_MODIFIED", err) + } + if _, err := channelService.SetPreHistoryHidden(ctx, ownerID, group.Channel.ID, true); err != nil { + t.Fatalf("hide group prehistory: %v", err) + } + if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: inputBroadcast, + Group: inputGroup, + }); err == nil || !strings.Contains(err.Error(), "MEGAGROUP_PREHISTORY_HIDDEN") { + t.Fatalf("hidden group link err = %v, want MEGAGROUP_PREHISTORY_HIDDEN", err) + } +} + +func TestChannelDiscussionRepliesRPCUsesLinkedMegagroup(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550002911", FirstName: "Owner"}) + member, _ := userStore.Create(ctx, domain.User{AccessHash: 92, Phone: "15550002912", FirstName: "Member"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), clock.System) + broadcast, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Discussion Source", + Broadcast: true, + Date: 1700002911, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + group, err := channelService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Discussion Replies", + MemberUserIDs: []int64{member.ID}, + Date: 1700002912, + }) + if err != nil { + t.Fatalf("create group: %v", err) + } + inputBroadcast := &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash} + inputGroup := &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash} + if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, owner.ID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: inputBroadcast, + Group: inputGroup, + }); err != nil || !ok { + t.Fatalf("set discussion group = ok %v err %v, want true", ok, err) + } + + postUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + Message: "channel post", + RandomID: 2911001, + }) + if err != nil { + t.Fatalf("send broadcast post: %v", err) + } + post := postUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if !post.Post { + t.Fatalf("broadcast post = %#v, want channel post", post) + } + discussion, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + MsgID: post.ID, + }) + if err != nil { + t.Fatalf("get discussion message: %v", err) + } + if len(discussion.Messages) != 1 || len(discussion.Chats) != 2 { + t.Fatalf("discussion = %+v, want linked root message with source and group chats", discussion) + } + root, ok := discussion.Messages[0].(*tg.Message) + if !ok { + t.Fatalf("discussion root = %T, want message", discussion.Messages[0]) + } + if peer, ok := root.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != group.Channel.ID { + t.Fatalf("discussion root peer = %#v, want linked group %d", root.PeerID, group.Channel.ID) + } + if from, ok := root.FromID.(*tg.PeerChannel); !ok || from.ChannelID != broadcast.Channel.ID { + t.Fatalf("discussion root from = %#v, want source channel %d", root.FromID, broadcast.Channel.ID) + } + fwd, ok := root.GetFwdFrom() + if !ok { + t.Fatalf("discussion root fwd_from missing") + } + if channelPost, ok := fwd.GetChannelPost(); !ok || channelPost != post.ID { + t.Fatalf("discussion root channel_post = %d ok %v, want %d", channelPost, ok, post.ID) + } + if savedMsgID, ok := fwd.GetSavedFromMsgID(); !ok || savedMsgID != post.ID { + t.Fatalf("discussion root saved_from_msg_id = %d ok %v, want %d", savedMsgID, ok, post.ID) + } + if savedPeer, ok := fwd.GetSavedFromPeer(); !ok { + t.Fatalf("discussion root saved_from_peer missing") + } else if savedChannel, ok := savedPeer.(*tg.PeerChannel); !ok || savedChannel.ChannelID != broadcast.Channel.ID { + t.Fatalf("discussion root saved_from_peer = %#v, want source channel %d", savedPeer, broadcast.Channel.ID) + } + + replyTo := &tg.InputReplyToMessage{ReplyToMsgID: root.ID} + replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), func() *tg.MessagesSendMessageRequest { + req := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, + Message: "discussion reply", + RandomID: 2911002, + } + req.SetReplyTo(replyTo) + return req + }()) + if err != nil { + t.Fatalf("send discussion reply: %v", err) + } + comment := replyUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + replies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + MsgID: post.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("get replies: %v", err) + } + replyMessages, replyChats, _ := searchMessagesPayload(t, replies) + if len(replyMessages) != 1 || len(replyChats) != 2 { + t.Fatalf("get replies = %T %+v, want one linked group reply with both channel contexts", replies, replies) + } + gotReply := replyMessages[0].(*tg.Message) + if gotReply.ID != comment.ID || gotReply.Message != "discussion reply" { + t.Fatalf("reply message = %#v, want comment id %d", gotReply, comment.ID) + } + if peer, ok := gotReply.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != group.Channel.ID { + t.Fatalf("reply peer = %#v, want linked group %d", gotReply.PeerID, group.Channel.ID) + } + header, ok := gotReply.ReplyTo.(*tg.MessageReplyHeader) + if !ok { + t.Fatalf("reply header = %#v, want messageReplyHeader", gotReply.ReplyTo) + } + topID, topOK := header.GetReplyToTopID() + if header.ReplyToMsgID != root.ID || !topOK || topID != root.ID { + t.Fatalf("reply header = %#v, want msg/top %d", header, root.ID) + } + views, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + ID: []int{post.ID}, + }) + if err != nil { + t.Fatalf("get message views: %v", err) + } + replyInfo, ok := views.Views[0].GetReplies() + if !ok || !replyInfo.Comments || replyInfo.Replies != 1 { + t.Fatalf("message views replies = %+v ok %v, want one comment", replyInfo, ok) + } + if channelID, ok := replyInfo.GetChannelID(); !ok || channelID != group.Channel.ID { + t.Fatalf("message views channel_id = %d ok %v, want %d", channelID, ok, group.Channel.ID) + } + if maxID, ok := replyInfo.GetMaxID(); !ok || maxID != comment.ID { + t.Fatalf("message views max_id = %d ok %v, want %d", maxID, ok, comment.ID) + } + if ok, err := r.onMessagesReadDiscussion(WithUserID(ctx, owner.ID), &tg.MessagesReadDiscussionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + MsgID: post.ID, + ReadMaxID: comment.ID, + }); err != nil || !ok { + t.Fatalf("read discussion = ok %v err %v, want true", ok, err) + } + afterRead, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + MsgID: post.ID, + }) + if err != nil { + t.Fatalf("get discussion after read: %v", err) + } + if afterRead.ReadInboxMaxID != comment.ID || afterRead.UnreadCount != 0 { + t.Fatalf("discussion after read = %+v, want read inbox %d and no unread", afterRead, comment.ID) + } +} diff --git a/internal/rpc/channels_invite_errors_rpc_test.go b/internal/rpc/channels_invite_errors_rpc_test.go new file mode 100644 index 00000000..bab696c0 --- /dev/null +++ b/internal/rpc/channels_invite_errors_rpc_test.go @@ -0,0 +1,148 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strings" + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestImportChatInviteErrorsRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 58, Phone: "15550002258", FirstName: "Owner"}) + first, _ := userStore.Create(ctx, domain.User{AccessHash: 59, Phone: "15550002259", FirstName: "First"}) + second, _ := userStore.Create(ctx, domain.User{AccessHash: 60, Phone: "15550002260", FirstName: "Second"}) + channelStore := memory.NewChannelStore() + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "RPC Import Errors", + Megagroup: true, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + inputChannel := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + requestInvite, err := r.onMessagesExportChatInvite(WithUserID(ctx, owner.ID), &tg.MessagesExportChatInviteRequest{ + Peer: input, + Title: "approval", + RequestNeeded: true, + }) + if err != nil { + t.Fatalf("export request-needed invite: %v", err) + } + requestHash := strings.TrimPrefix(requestInvite.(*tg.ChatInviteExported).Link, "https://telesrv.net/+") + if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, first.ID), requestHash); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") { + t.Fatalf("import request-needed err = %v, want INVITE_REQUEST_SENT", err) + } + pushedPending := sessions.snapshot() + if pushedPending.userID != owner.ID || pushedPending.messageType != proto.MessageFromServer { + t.Fatalf("pending request push = %+v, want owner server update", pushedPending) + } + pushedPendingUpdates, ok := pushedPending.message.(*tg.Updates) + if !ok || len(pushedPendingUpdates.Updates) != 1 { + t.Fatalf("pending request push message = %T %+v, want one update", pushedPending.message, pushedPending.message) + } + pushedPendingUpdate, ok := pushedPendingUpdates.Updates[0].(*tg.UpdatePendingJoinRequests) + if !ok || pushedPendingUpdate.RequestsPending != 1 || len(pushedPendingUpdate.RecentRequesters) != 1 || pushedPendingUpdate.RecentRequesters[0] != first.ID { + t.Fatalf("pending request update = %+v, want first requester", pushedPendingUpdates.Updates[0]) + } + fullAfterPending, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), inputChannel) + if err != nil { + t.Fatalf("get full channel after pending request: %v", err) + } + fullPending := fullAfterPending.FullChat.(*tg.ChannelFull) + requestsPending, ok := fullPending.GetRequestsPending() + recentRequesters, recentOK := fullPending.GetRecentRequesters() + if !ok || requestsPending != 1 || !recentOK || len(recentRequesters) != 1 || recentRequesters[0] != first.ID { + t.Fatalf("full pending = count %d ok %v recent %+v ok %v, want first requester", requestsPending, ok, recentRequesters, recentOK) + } + pendingReq := &tg.MessagesGetChatInviteImportersRequest{ + Requested: true, + Peer: input, + Limit: 10, + } + pendingReq.SetLink(requestInvite.(*tg.ChatInviteExported).Link) + pending, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq) + if err != nil { + t.Fatalf("get pending invite importers: %v", err) + } + if pending.Count != 1 || len(pending.Importers) != 1 || pending.Importers[0].UserID != first.ID || !pending.Importers[0].Requested { + t.Fatalf("pending importers = %+v, want first pending request", pending) + } + limitedInvite, err := r.onMessagesExportChatInvite(WithUserID(ctx, owner.ID), &tg.MessagesExportChatInviteRequest{ + Peer: input, + Title: "one", + UsageLimit: 1, + }) + if err != nil { + t.Fatalf("export limited invite: %v", err) + } + limitedHash := strings.TrimPrefix(limitedInvite.(*tg.ChatInviteExported).Link, "https://telesrv.net/+") + if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, first.ID), limitedHash); err != nil { + t.Fatalf("first import limited invite: %v", err) + } + pendingAfterJoin, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq) + if err != nil { + t.Fatalf("get pending invite importers after join: %v", err) + } + if pendingAfterJoin.Count != 0 || len(pendingAfterJoin.Importers) != 0 { + t.Fatalf("pending importers after alternate invite join = %+v, want cleared", pendingAfterJoin) + } + if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, second.ID), limitedHash); err == nil || !strings.Contains(err.Error(), "USERS_TOO_MUCH") { + t.Fatalf("second import limited err = %v, want USERS_TOO_MUCH", err) + } + if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, second.ID), requestHash); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") { + t.Fatalf("second import request-needed err = %v, want INVITE_REQUEST_SENT", err) + } + pendingSecond, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq) + if err != nil { + t.Fatalf("get second pending invite importers: %v", err) + } + if pendingSecond.Count != 1 || len(pendingSecond.Importers) != 1 || pendingSecond.Importers[0].UserID != second.ID || !pendingSecond.Importers[0].Requested { + t.Fatalf("second pending importers = %+v, want second pending request", pendingSecond) + } + approved, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{ + Approved: true, + Peer: input, + UserID: &tg.InputUser{UserID: second.ID, AccessHash: second.AccessHash}, + }) + if err != nil { + t.Fatalf("approve chat join request: %v", err) + } + if updates := approved.(*tg.Updates); len(updates.Chats) != 1 || len(updates.Updates) == 0 { + t.Fatalf("approve join request updates = %+v, want channel updates", updates) + } + approvedUpdates := approved.(*tg.Updates) + var pendingCleared *tg.UpdatePendingJoinRequests + for _, update := range approvedUpdates.Updates { + if pending, ok := update.(*tg.UpdatePendingJoinRequests); ok { + pendingCleared = pending + break + } + } + if pendingCleared == nil || pendingCleared.RequestsPending != 0 || len(pendingCleared.RecentRequesters) != 0 { + t.Fatalf("approve pending update = %+v, want cleared pending requests", pendingCleared) + } + if _, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{ + Approved: true, + Peer: input, + UserID: &tg.InputUser{UserID: second.ID, AccessHash: second.AccessHash}, + }); err == nil || !strings.Contains(err.Error(), "HIDE_REQUESTER_MISSING") { + t.Fatalf("approve missing join request err = %v, want HIDE_REQUESTER_MISSING", err) + } +} diff --git a/internal/rpc/channels_invites.go b/internal/rpc/channels_invites.go new file mode 100644 index 00000000..1586635e --- /dev/null +++ b/internal/rpc/channels_invites.go @@ -0,0 +1,447 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "strings" + "telesrv/internal/domain" +) + +func (r *Router) onMessagesExportChatInvite(ctx context.Context, req *tg.MessagesExportChatInviteRequest) (tg.ExportedChatInviteClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + if req.UsageLimit < 0 || req.ExpireDate < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength { + return nil, limitInvalidErr() + } + res, err := r.deps.Channels.ExportInvite(ctx, userID, domain.ExportChannelInviteRequest{ + UserID: userID, + ChannelID: peer.ID, + Title: req.Title, + RequestNeeded: req.RequestNeeded, + ExpireDate: req.ExpireDate, + UsageLimit: req.UsageLimit, + LegacyRevokePermanent: req.LegacyRevokePermanent, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInviteErr(err) + } + r.invalidateRPCProjectionForChannel(res.Channel.ID) + return tgExportedChannelInvite(res.Invite), nil +} + +func (r *Router) onMessagesCheckChatInvite(ctx context.Context, hash string) (tg.ChatInviteClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + res, err := r.deps.Channels.CheckInvite(ctx, userID, hash, int(r.clock.Now().Unix())) + if err != nil { + return nil, channelInviteErr(err) + } + if res.Already { + return &tg.ChatInviteAlready{Chat: tgChannelChat(userID, res.Channel, &res.Self)}, nil + } + return &tg.ChatInvite{ + Channel: true, + Broadcast: res.Channel.Broadcast, + Megagroup: res.Channel.Megagroup, + Public: res.Channel.Username != "", + RequestNeeded: res.Invite.RequestNeeded, + Title: res.Channel.Title, + About: res.Channel.About, + Photo: &tg.PhotoEmpty{}, + ParticipantsCount: res.Channel.ParticipantsCount, + }, nil +} + +func (r *Router) onMessagesImportChatInvite(ctx context.Context, hash string) (tg.MessagesChatInviteJoinResultClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + res, err := r.deps.Channels.ImportInvite(ctx, userID, domain.ImportChannelInviteRequest{ + UserID: userID, + Hash: hash, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + if errors.Is(err, domain.ErrInviteRequestSent) && res.Channel.ID != 0 { + r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) + } + return nil, channelInviteErr(err) + } + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + // Layer 227:messages.importChatInvite 返回 messages.ChatInviteJoinResult; + // 正常加入即 chatInviteJoinResultOk 包裹本次操作的 updates。 + return &tg.MessagesChatInviteJoinResultOk{Updates: updates}, nil +} + +func (r *Router) onMessagesGetExportedChatInvites(ctx context.Context, req *tg.MessagesGetExportedChatInvitesRequest) (*tg.MessagesExportedChatInvites, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + if req.Limit < 0 || req.Limit > maxChatInviteListLimit || len(req.OffsetLink) > maxChatInviteLinkLength { + return nil, limitInvalidErr() + } + adminID := userID + if !inputUserIsEmpty(req.AdminID) { + admins, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.AdminID}) + if err != nil { + return nil, err + } + if len(admins) > 0 { + adminID = admins[0] + } + } + offsetHash := "" + if req.OffsetLink != "" { + offsetHash, err = channelInviteHashFromLink(req.OffsetLink) + if err != nil { + return nil, err + } + } + list, err := r.deps.Channels.ListExportedInvites(ctx, userID, domain.ChannelInviteListRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + AdminUserID: adminID, + Revoked: req.Revoked, + OffsetDate: req.OffsetDate, + OffsetHash: offsetHash, + Limit: req.Limit, + }) + if err != nil { + return nil, channelInviteErr(err) + } + userIDs := []int64{adminID} + invites := make([]tg.ExportedChatInviteClass, 0, len(list.Invites)) + for _, invite := range list.Invites { + invites = append(invites, tgExportedChannelInvite(invite)) + userIDs = append(userIDs, invite.AdminUserID) + } + return &tg.MessagesExportedChatInvites{ + Count: list.Count, + Invites: invites, + Users: r.tgUsersForIDs(ctx, userID, userIDs), + }, nil +} + +func (r *Router) onMessagesGetExportedChatInvite(ctx context.Context, req *tg.MessagesGetExportedChatInviteRequest) (tg.MessagesExportedChatInviteClass, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + hash, err := channelInviteHashFromLink(req.Link) + if err != nil { + return nil, err + } + invite, err := r.deps.Channels.GetExportedInvite(ctx, userID, domain.GetChannelInviteRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + Hash: hash, + }) + if err != nil { + return nil, channelInviteErr(err) + } + return &tg.MessagesExportedChatInvite{ + Invite: tgExportedChannelInvite(invite), + Users: r.tgUsersForIDs(ctx, userID, []int64{invite.AdminUserID}), + }, nil +} + +func (r *Router) onMessagesEditExportedChatInvite(ctx context.Context, req *tg.MessagesEditExportedChatInviteRequest) (tg.MessagesExportedChatInviteClass, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + hash, err := channelInviteHashFromLink(req.Link) + if err != nil { + return nil, err + } + if req.ExpireDate < 0 || req.UsageLimit < 0 || len(req.Title) > domain.MaxChannelInviteTitleLength { + return nil, limitInvalidErr() + } + expireDate, hasExpireDate := req.GetExpireDate() + usageLimit, hasUsageLimit := req.GetUsageLimit() + requestNeeded, hasRequestNeeded := req.GetRequestNeeded() + title, hasTitle := req.GetTitle() + edited, err := r.deps.Channels.EditExportedInvite(ctx, userID, domain.EditChannelInviteRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + Hash: hash, + Revoked: req.Revoked, + HasExpireDate: hasExpireDate, + ExpireDate: expireDate, + HasUsageLimit: hasUsageLimit, + UsageLimit: usageLimit, + HasRequestNeeded: hasRequestNeeded, + RequestNeeded: requestNeeded, + HasTitle: hasTitle, + Title: title, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInviteErr(err) + } + r.invalidateRPCProjectionForChannel(view.Channel.ID) + users := r.tgUsersForIDs(ctx, userID, []int64{edited.Invite.AdminUserID}) + if edited.NewInvite != nil { + return &tg.MessagesExportedChatInviteReplaced{ + Invite: tgExportedChannelInvite(edited.Invite), + NewInvite: tgExportedChannelInvite(*edited.NewInvite), + Users: users, + }, nil + } + return &tg.MessagesExportedChatInvite{Invite: tgExportedChannelInvite(edited.Invite), Users: users}, nil +} + +func (r *Router) onMessagesDeleteRevokedExportedChatInvites(ctx context.Context, req *tg.MessagesDeleteRevokedExportedChatInvitesRequest) (bool, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return false, err + } + adminID := userID + if !inputUserIsEmpty(req.AdminID) { + admins, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.AdminID}) + if err != nil { + return false, err + } + if len(admins) > 0 { + adminID = admins[0] + } + } + if err := r.deps.Channels.DeleteRevokedExportedInvites(ctx, userID, domain.DeleteRevokedChannelInvitesRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + AdminUserID: adminID, + Limit: domain.MaxChannelHideJoinRequests, + }); err != nil { + return false, channelInviteErr(err) + } + r.invalidateRPCProjectionForChannel(view.Channel.ID) + return true, nil +} + +func (r *Router) onMessagesDeleteExportedChatInvite(ctx context.Context, req *tg.MessagesDeleteExportedChatInviteRequest) (bool, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return false, err + } + hash, err := channelInviteHashFromLink(req.Link) + if err != nil { + return false, err + } + if err := r.deps.Channels.DeleteExportedInvite(ctx, userID, domain.DeleteChannelInviteRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + Hash: hash, + }); err != nil { + return false, channelInviteErr(err) + } + r.invalidateRPCProjectionForChannel(view.Channel.ID) + return true, nil +} + +func (r *Router) onMessagesGetChatInviteImporters(ctx context.Context, req *tg.MessagesGetChatInviteImportersRequest) (*tg.MessagesChatInviteImporters, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + link, hasLink := req.GetLink() + query, hasQuery := req.GetQ() + if hasLink && hasQuery && strings.TrimSpace(link) != "" && strings.TrimSpace(query) != "" { + return nil, tgerr400("SEARCH_WITH_LINK_NOT_SUPPORTED") + } + if req.Limit < 0 || req.Limit > maxChatInviteListLimit || len(link) > maxChatInviteLinkLength || len(query) > maxChatInviteSearchLength { + return nil, limitInvalidErr() + } + hash := "" + if strings.TrimSpace(link) != "" { + hash, err = channelInviteHashFromLink(link) + if err != nil { + return nil, err + } + } + offsetUserID := int64(0) + if !inputUserIsEmpty(req.OffsetUser) { + ids, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.OffsetUser}) + if err != nil { + return nil, err + } + if len(ids) > 0 { + offsetUserID = ids[0] + } + } + list, err := r.deps.Channels.ListInviteImporters(ctx, userID, domain.ChannelInviteImportersRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + Hash: hash, + Requested: req.Requested, + Query: query, + OffsetDate: req.OffsetDate, + OffsetUserID: offsetUserID, + Limit: req.Limit, + }) + if err != nil { + return nil, channelInviteErr(err) + } + importers := make([]tg.ChatInviteImporter, 0, len(list.Importers)) + userIDs := make([]int64, 0, len(list.Importers)) + for _, importer := range list.Importers { + tgImporter := tg.ChatInviteImporter{ + UserID: importer.UserID, + Date: importer.Date, + } + if importer.Requested { + tgImporter.SetRequested(true) + } + if importer.ApprovedBy != 0 { + tgImporter.SetApprovedBy(importer.ApprovedBy) + userIDs = append(userIDs, importer.ApprovedBy) + } + importers = append(importers, tgImporter) + userIDs = append(userIDs, importer.UserID) + } + return &tg.MessagesChatInviteImporters{ + Count: list.Count, + Importers: importers, + Users: r.tgUsersForIDs(ctx, userID, userIDs), + }, nil +} + +func createChatInviteMemberIDs(ids []int64, selfUserID int64) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 || id == selfUserID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func tgExportedChannelInvite(invite domain.ChannelInvite) tg.ExportedChatInviteClass { + out := &tg.ChatInviteExported{ + Revoked: invite.Revoked, + Permanent: invite.Permanent, + RequestNeeded: invite.RequestNeeded, + Link: "https://telesrv.net/+" + invite.Hash, + AdminID: invite.AdminUserID, + Date: invite.Date, + } + if invite.Title != "" { + out.SetTitle(invite.Title) + } + if invite.ExpireDate > 0 { + out.SetExpireDate(invite.ExpireDate) + } + if invite.UsageLimit > 0 { + out.SetUsageLimit(invite.UsageLimit) + } + if invite.UsageCount > 0 { + out.SetUsage(invite.UsageCount) + } + if invite.RequestedCount > 0 { + out.SetRequested(invite.RequestedCount) + } + return out +} + +func validateChatInviteLink(link string) error { + link = strings.TrimSpace(link) + if link == "" { + return tgerr400("INVITE_HASH_EMPTY") + } + if len(link) > maxChatInviteLinkLength { + return limitInvalidErr() + } + return nil +} + +func channelInviteHashFromLink(link string) (string, error) { + if err := validateChatInviteLink(link); err != nil { + return "", err + } + link = strings.TrimSpace(link) + link = strings.TrimPrefix(link, "tg://join?invite=") + if strings.Contains(link, "://") { + if idx := strings.LastIndex(link, "/+"); idx >= 0 { + link = link[idx+2:] + } else if idx := strings.LastIndex(link, "/joinchat/"); idx >= 0 { + link = link[idx+10:] + } else if idx := strings.LastIndex(link, "/"); idx >= 0 { + link = link[idx+1:] + } + } + link = strings.TrimPrefix(link, "+") + link = strings.TrimSpace(link) + if link == "" { + return "", tgerr400("INVITE_HASH_EMPTY") + } + if len(link) > maxChatInviteLinkLength { + return "", limitInvalidErr() + } + return link, nil +} + +func channelInviteErr(err error) error { + switch { + case errors.Is(err, domain.ErrInviteHashEmpty): + return tgerr400("INVITE_HASH_EMPTY") + case errors.Is(err, domain.ErrInviteHashInvalid): + return tgerr400("INVITE_HASH_INVALID") + case errors.Is(err, domain.ErrInviteHashExpired): + return tgerr.New(406, "INVITE_HASH_EXPIRED") + case errors.Is(err, domain.ErrInvitePermanent): + return tgerr400("CHAT_INVITE_PERMANENT") + case errors.Is(err, domain.ErrInviteRevokedMissing): + return tgerr400("INVITE_REVOKED_MISSING") + case errors.Is(err, domain.ErrInviteRequestSent): + return tgerr400("INVITE_REQUEST_SENT") + case errors.Is(err, domain.ErrHideRequesterMissing): + return tgerr400("HIDE_REQUESTER_MISSING") + case errors.Is(err, domain.ErrUsersTooMuch): + return tgerr400("USERS_TOO_MUCH") + case errors.Is(err, domain.ErrUserAlreadyParticipant): + return tgerr400("USER_ALREADY_PARTICIPANT") + case errors.Is(err, domain.ErrUserKicked): + return tgerr400("USER_KICKED") + case errors.Is(err, domain.ErrBotGroupsBlocked): + return tgerr400("BOT_GROUPS_BLOCKED") + default: + return channelInvalidErr(err) + } +} diff --git a/internal/rpc/channels_invites_members_rpc_test.go b/internal/rpc/channels_invites_members_rpc_test.go new file mode 100644 index 00000000..d288731f --- /dev/null +++ b/internal/rpc/channels_invites_members_rpc_test.go @@ -0,0 +1,856 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strings" + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestChannelParticipantsSearchQueryIsBounded(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002111", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002112", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Participants RPC Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + + _, err = r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsSearch{Q: strings.Repeat("x", domain.MaxChannelParticipantsQueryLength+1)}, + Limit: 20, + }) + if err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("get participants long query err = %v, want LIMIT_INVALID", err) + } +} + +func TestChannelsGetParticipantsUsesSingleBatchUserLookup(t *testing.T) { + ctx := context.Background() + owner := domain.User{ID: 1, AccessHash: 101, Phone: "15550002131", FirstName: "Owner"} + first := domain.User{ID: 2, AccessHash: 102, Phone: "15550002132", FirstName: "First"} + second := domain.User{ID: 3, AccessHash: 103, Phone: "15550002133", FirstName: "Second"} + users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{ + owner.ID: owner, + first.ID: first, + second.ID: second, + }}} + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: users, + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{ + &tg.InputUser{UserID: first.ID, AccessHash: first.AccessHash}, + &tg.InputUser{UserID: second.ID, AccessHash: second.AccessHash}, + }, + Title: "Participants Batch Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + users.byIDCalls = 0 + users.byIDsCalls = 0 + users.lastByIDs = nil + + got, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 20, + }) + if err != nil { + t.Fatalf("get participants: %v", err) + } + list := got.(*tg.ChannelsChannelParticipants) + if users.byIDsCalls != 1 || users.byIDCalls != 0 { + t.Fatalf("user lookups byIDs=%d byID=%d, want one ByIDs and no ByID", users.byIDsCalls, users.byIDCalls) + } + if len(users.lastByIDs) != len(list.Participants) { + t.Fatalf("ByIDs ids = %+v, participants=%d", users.lastByIDs, len(list.Participants)) + } + seen := make(map[int64]struct{}, len(users.lastByIDs)) + for _, id := range users.lastByIDs { + seen[id] = struct{}{} + } + for _, want := range []int64{owner.ID, first.ID, second.ID} { + if _, ok := seen[want]; !ok { + t.Fatalf("ByIDs ids = %+v, missing %d", users.lastByIDs, want) + } + } + if len(list.Users) != 3 { + t.Fatalf("users = %+v, want three projected users", list.Users) + } +} + +func TestChannelsGetParticipantsValidatesHashAfterParticipantAccessCheck(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002141", FirstName: "Owner"}) + member, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002142", FirstName: "Member"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Participants Access", + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700002100, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + counting := &countingChannelsService{Service: channelService} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: counting, + }, zaptest.NewLogger(t), clock.System) + + got, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 20, + }) + if err != nil { + t.Fatalf("get participants: %v", err) + } + if _, ok := got.(*tg.ChannelsChannelParticipants); !ok { + t.Fatalf("participants = %T, want *tg.ChannelsChannelParticipants", got) + } + if counting.resolveChannelCalls != 0 || counting.getChannelCalls != 0 { + t.Fatalf("participant access calls ResolveChannel=%d GetChannel=%d, want no pre-resolve/full get", counting.resolveChannelCalls, counting.getChannelCalls) + } + + _, err = r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash + 1}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 20, + }) + if err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } +} + +func TestChannelsGetParticipantsHidesAnonymousAdminFromRegularMember(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002161", FirstName: "Owner"}) + anonymousAdmin, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002162", FirstName: "Hidden"}) + regular, _ := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550002163", FirstName: "Regular"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{ + &tg.InputUser{UserID: anonymousAdmin.ID, AccessHash: anonymousAdmin.AccessHash}, + &tg.InputUser{UserID: regular.ID, AccessHash: regular.AccessHash}, + }, + Title: "Anonymous Admin RPC Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: anonymousAdmin.ID, AccessHash: anonymousAdmin.AccessHash}, + AdminRights: tg.ChatAdminRights{ + Anonymous: true, + ChangeInfo: true, + }, + }); err != nil { + t.Fatalf("edit anonymous admin: %v", err) + } + + adminsForRegular, err := r.onChannelsGetParticipants(WithUserID(ctx, regular.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsAdmins{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("regular get admins: %v", err) + } + regularAdminsPage := adminsForRegular.(*tg.ChannelsChannelParticipants) + if tgParticipantListHasUser(regularAdminsPage.Participants, anonymousAdmin.ID) || tgUserListHasUser(regularAdminsPage.Users, anonymousAdmin.ID) { + t.Fatalf("regular admins page leaks anonymous admin: participants=%+v users=%+v", regularAdminsPage.Participants, regularAdminsPage.Users) + } + if !tgParticipantListHasUser(regularAdminsPage.Participants, owner.ID) { + t.Fatalf("regular admins page = %+v, want creator still visible", regularAdminsPage.Participants) + } + + recentForRegular, err := r.onChannelsGetParticipants(WithUserID(ctx, regular.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("regular get recent: %v", err) + } + regularRecentPage := recentForRegular.(*tg.ChannelsChannelParticipants) + if tgParticipantListHasUser(regularRecentPage.Participants, anonymousAdmin.ID) || tgUserListHasUser(regularRecentPage.Users, anonymousAdmin.ID) { + t.Fatalf("regular recent page leaks anonymous admin: participants=%+v users=%+v", regularRecentPage.Participants, regularRecentPage.Users) + } + if regularRecentPage.Count != 2 { + t.Fatalf("regular recent count = %d, want visible member count 2", regularRecentPage.Count) + } + + adminsForOwner, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsAdmins{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("owner get admins: %v", err) + } + if !tgParticipantListHasUser(adminsForOwner.(*tg.ChannelsChannelParticipants).Participants, anonymousAdmin.ID) { + t.Fatalf("owner admins page = %+v, want anonymous admin visible to admins", adminsForOwner.(*tg.ChannelsChannelParticipants).Participants) + } +} + +func tgParticipantListHasUser(participants []tg.ChannelParticipantClass, userID int64) bool { + for _, participant := range participants { + for _, id := range channelParticipantUserRefs(participant) { + if id == userID { + return true + } + } + } + return false +} + +func tgUserListHasUser(users []tg.UserClass, userID int64) bool { + for _, user := range users { + if u, ok := user.(*tg.User); ok && u.ID == userID { + return true + } + } + return false +} + +// TestChannelCreateHasPermanentInviteLink 复刻 DrKLO 建频道后的真实调用序列: +// ChannelCreateActivity.generateLink() 发 getExportedChatInvites(admin=self, limit=1) +// 后对 invites.get(0) 直接取值,空列表即 IndexOutOfBounds 闪退——服务端必须保证 +// 创建者的永久主链接随创建即存在,且重复列出不会重复生成。 +func TestChannelCreateHasPermanentInviteLink(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Broadcast: true, + Title: "Crash Repro Channel", + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + + inviteList, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, owner.ID), &tg.MessagesGetExportedChatInvitesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + AdminID: &tg.InputUserSelf{}, + Limit: 1, + }) + if err != nil { + t.Fatalf("get exported invites after create: %v", err) + } + if inviteList.Count < 1 || len(inviteList.Invites) < 1 { + t.Fatalf("exported invites after create = %+v, want at least the permanent link (DrKLO 直接取 invites[0])", inviteList) + } + invite, ok := inviteList.Invites[0].(*tg.ChatInviteExported) + if !ok || !invite.Permanent || invite.Revoked || invite.AdminID != owner.ID || !strings.HasPrefix(invite.Link, "https://telesrv.net/+") { + t.Fatalf("invites[0] = %#v, want creator's non-revoked permanent link", inviteList.Invites[0]) + } + + // 幂等:再次列出仍只有同一条主链接,不重复生成。 + again, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, owner.ID), &tg.MessagesGetExportedChatInvitesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + AdminID: &tg.InputUserSelf{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get exported invites again: %v", err) + } + if again.Count != 1 || len(again.Invites) != 1 { + t.Fatalf("second list = %+v, want exactly one permanent link", again) + } + if link := again.Invites[0].(*tg.ChatInviteExported).Link; link != invite.Link { + t.Fatalf("second list link = %q, want stable %q", link, invite.Link) + } + + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel: %v", err) + } + fullInviteRaw, ok := full.FullChat.(*tg.ChannelFull).GetExportedInvite() + if !ok { + t.Fatalf("channelFull.exported_invite missing, want creator permanent link for DrKLO group settings") + } + fullInvite := fullInviteRaw.(*tg.ChatInviteExported) + if fullInvite.Link != invite.Link || !fullInvite.Permanent || fullInvite.Revoked { + t.Fatalf("channelFull.exported_invite = %#v, want active permanent link %q", fullInvite, invite.Link) + } + + replacedRaw, err := r.onMessagesExportChatInvite(WithUserID(ctx, owner.ID), &tg.MessagesExportChatInviteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + LegacyRevokePermanent: true, + }) + if err != nil { + t.Fatalf("replace permanent invite: %v", err) + } + replaced := replacedRaw.(*tg.ChatInviteExported) + if replaced.Link == invite.Link || !replaced.Permanent || replaced.Revoked { + t.Fatalf("replaced permanent invite = %#v, want a fresh active permanent link", replaced) + } + refreshedFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel after replacing invite: %v", err) + } + refreshedRaw, ok := refreshedFull.FullChat.(*tg.ChannelFull).GetExportedInvite() + if !ok { + t.Fatalf("channelFull.exported_invite missing after replacing permanent link") + } + refreshedInvite := refreshedRaw.(*tg.ChatInviteExported) + if refreshedInvite.Link != replaced.Link || !refreshedInvite.Permanent || refreshedInvite.Revoked { + t.Fatalf("channelFull.exported_invite after replace = %#v, want fresh active permanent link %q", refreshedInvite, replaced.Link) + } +} + +func TestChannelAdminPinInviteRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550002201", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550002202", FirstName: "Friend"}) + joiner, _ := userStore.Create(ctx, domain.User{AccessHash: 53, Phone: "15550002203", FirstName: "Joiner"}) + invited, _ := userStore.Create(ctx, domain.User{AccessHash: 54, Phone: "15550002204", FirstName: "Invited"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC Admin Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + createdChannel, err := channelStore.GetChannelByID(ctx, channel.ID) + if err != nil { + t.Fatalf("get created channel: %v", err) + } + initialChannelPts := createdChannel.Pts + + selfParticipant, err := r.onChannelsGetParticipant(WithUserID(ctx, friend.ID), &tg.ChannelsGetParticipantRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Participant: &tg.InputPeerSelf{}, + }) + if err != nil { + t.Fatalf("get self regular participant: %v", err) + } + if _, ok := selfParticipant.Participant.(*tg.ChannelParticipantSelf); !ok { + t.Fatalf("self regular participant = %T, want channelParticipantSelf", selfParticipant.Participant) + } + recentForFriend, err := r.onChannelsGetParticipants(WithUserID(ctx, friend.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get recent participants for regular self: %v", err) + } + foundSelf := false + for _, participant := range recentForFriend.(*tg.ChannelsChannelParticipants).Participants { + if _, ok := participant.(*tg.ChannelParticipantSelf); ok { + foundSelf = true + break + } + } + if !foundSelf { + t.Fatalf("recent participants = %+v, want current regular member as channelParticipantSelf", recentForFriend.(*tg.ChannelsChannelParticipants).Participants) + } + + adminUpdates, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + AdminRights: tg.ChatAdminRights{ + ChangeInfo: true, + InviteUsers: true, + PinMessages: true, + }, + Rank: "ops", + }) + if err != nil { + t.Fatalf("edit admin: %v", err) + } + if updates := adminUpdates.(*tg.Updates); len(updates.Updates) != 2 { + t.Fatalf("admin updates = %+v, want participant update and channel refresh", updates.Updates) + } else if _, ok := updates.Updates[0].(*tg.UpdateChannelParticipant); !ok { + t.Fatalf("admin update[0] = %T, want updateChannelParticipant", updates.Updates[0]) + } else if _, ok := updates.Updates[1].(*tg.UpdateChannel); !ok { + t.Fatalf("admin update[1] = %T, want updateChannel", updates.Updates[1]) + } + adminDiff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: initialChannelPts, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel difference after admin: %v", err) + } + adminEmptyDiff, ok := adminDiff.(*tg.UpdatesChannelDifferenceEmpty) + if !ok || !adminEmptyDiff.Final || adminEmptyDiff.Pts != initialChannelPts { + t.Fatalf("admin diff = %T %+v, want empty difference at unchanged pts %d", adminDiff, adminDiff, initialChannelPts) + } + admins, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsAdmins{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get admin participants: %v", err) + } + if list := admins.(*tg.ChannelsChannelParticipants); len(list.Participants) != 2 { + t.Fatalf("admin participants = %+v, want creator and promoted admin", list.Participants) + } + + titleUpdates, err := r.onChannelsEditTitle(WithUserID(ctx, friend.ID), &tg.ChannelsEditTitleRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Title: "RPC Admin Group 2", + }) + if err != nil { + t.Fatalf("edit title: %v", err) + } + titleContainer := titleUpdates.(*tg.Updates) + if len(titleContainer.Updates) < 2 { + t.Fatalf("title updates = %+v, want channel + service message", titleContainer.Updates) + } + titleMsg, ok := titleContainer.Updates[1].(*tg.UpdateNewChannelMessage) + if !ok { + t.Fatalf("title update[1] = %T, want updateNewChannelMessage", titleContainer.Updates[1]) + } + if action := titleMsg.Message.(*tg.MessageService).Action; action.(*tg.MessageActionChatEditTitle).Title != "RPC Admin Group 2" { + t.Fatalf("title action = %#v, want new title", action) + } + + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "pin me", + RandomID: 123, + }) + if err != nil { + t.Fatalf("send for pin: %v", err) + } + msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID + pinUpdates, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, friend.ID), &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: msgID, + }) + if err != nil { + t.Fatalf("pin message: %v", err) + } + pinned, ok := pinUpdates.(*tg.Updates).Updates[0].(*tg.UpdatePinnedChannelMessages) + if !ok || !pinned.Pinned || pinned.Messages[0] != msgID { + t.Fatalf("pin update = %#v, want pinned channel message id=%d", pinUpdates.(*tg.Updates).Updates[0], msgID) + } + + invitedUsers, err := r.onChannelsInviteToChannel(WithUserID(ctx, friend.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Users: []tg.InputUserClass{&tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}}, + }) + if err != nil { + t.Fatalf("invite to channel: %v", err) + } + if invitedUsers.Updates == nil || len(invitedUsers.MissingInvitees) != 0 { + t.Fatalf("invited users = %+v, want updates and no missing users", invitedUsers) + } + inviteUpdates, ok := invitedUsers.Updates.(*tg.Updates) + if !ok || len(inviteUpdates.Chats) == 0 { + t.Fatalf("invite updates = %T %+v, want channel chat", invitedUsers.Updates, invitedUsers.Updates) + } + assertDefaultBannedRightsAllowsSend(t, inviteUpdates.Chats[0]) + + invite, err := r.onMessagesExportChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesExportChatInviteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Title: "join", + }) + if err != nil { + t.Fatalf("export invite: %v", err) + } + exported := invite.(*tg.ChatInviteExported) + hash := strings.TrimPrefix(exported.Link, "https://telesrv.net/+") + checked, err := r.onMessagesCheckChatInvite(WithUserID(ctx, joiner.ID), hash) + if err != nil { + t.Fatalf("check invite: %v", err) + } + if preview, ok := checked.(*tg.ChatInvite); !ok || !preview.Megagroup || preview.Title != "RPC Admin Group 2" { + t.Fatalf("invite preview = %#v, want megagroup title", checked) + } + imported, err := r.onMessagesImportChatInvite(WithUserID(ctx, joiner.ID), hash) + if err != nil { + t.Fatalf("import invite: %v", err) + } + importOk, ok := imported.(*tg.MessagesChatInviteJoinResultOk) + if !ok { + t.Fatalf("import result = %T, want *tg.MessagesChatInviteJoinResultOk", imported) + } + importUpdates := importOk.Updates.(*tg.Updates) + if len(importUpdates.Chats) != 1 || len(importUpdates.Updates) != 2 { + t.Fatalf("import updates = %+v, want chat, join service update, and channel refresh", importUpdates) + } + assertDefaultBannedRightsAllowsSend(t, importUpdates.Chats[0]) + if _, ok := importUpdates.Updates[0].(*tg.UpdateNewChannelMessage); !ok { + t.Fatalf("import first update = %T, want join service update", importUpdates.Updates[0]) + } else if refresh, ok := importUpdates.Updates[1].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID { + t.Fatalf("import second update = %#v, want channel refresh", importUpdates.Updates[1]) + } + inviteList, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInvitesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + AdminID: &tg.InputUserSelf{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get exported invites: %v", err) + } + // 官方语义:管理员列出自己的有效链接时永久主链接必有(首页自愈生成), + // 因此列表 = 主链接 + 显式导出的 "join" 链接。 + if inviteList.Count != 2 || len(inviteList.Invites) != 2 || len(inviteList.Users) == 0 { + t.Fatalf("exported invite list = %+v, want permanent link plus exported invite", inviteList) + } + var listedInvite, permanentInvite *tg.ChatInviteExported + for _, raw := range inviteList.Invites { + invite, ok := raw.(*tg.ChatInviteExported) + if !ok { + t.Fatalf("invite = %T, want *tg.ChatInviteExported", raw) + } + if invite.Permanent { + permanentInvite = invite + } + if invite.Link == exported.Link { + listedInvite = invite + } + } + if permanentInvite == nil || permanentInvite.Revoked || permanentInvite.AdminID != friend.ID { + t.Fatalf("exported invite list = %+v, want non-revoked permanent link owned by admin", inviteList.Invites) + } + if listedInvite == nil { + t.Fatalf("exported invite list = %+v, missing explicitly exported link %q", inviteList.Invites, exported.Link) + } + listedUsage, listedUsageOK := listedInvite.GetUsage() + listedTitle, listedTitleOK := listedInvite.GetTitle() + if !listedUsageOK || listedUsage != 1 || !listedTitleOK || listedTitle != "join" { + t.Fatalf("listed invite = %#v, want exported link with one import", listedInvite) + } + if _, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInvitesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + AdminID: &tg.InputUserSelf{}, + Limit: 101, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("get exported invites high limit err = %v, want LIMIT_INVALID", err) + } + inviteDetails, err := r.onMessagesGetExportedChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInviteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Link: exported.Link, + }) + if err != nil { + t.Fatalf("get exported invite: %v", err) + } + if details := inviteDetails.(*tg.MessagesExportedChatInvite); details.Invite == nil || len(details.Users) == 0 { + t.Fatalf("exported invite details = %+v, want invite plus user context", inviteDetails) + } + editInviteReq := &tg.MessagesEditExportedChatInviteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Link: exported.Link, + } + editInviteReq.SetTitle("ops link") + editedInvite, err := r.onMessagesEditExportedChatInvite(WithUserID(ctx, friend.ID), editInviteReq) + if err != nil { + t.Fatalf("edit exported invite: %v", err) + } + if edited := editedInvite.(*tg.MessagesExportedChatInvite); edited.Invite == nil || len(edited.Users) == 0 { + t.Fatalf("edited invite = %+v, want invite plus user context", editedInvite) + } else if got, ok := edited.Invite.(*tg.ChatInviteExported).GetTitle(); !ok || got != "ops link" { + t.Fatalf("edited invite title = %q, want ops link", got) + } + adminsWithInvites, err := r.onMessagesGetAdminsWithInvites(WithUserID(ctx, friend.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get admins with invites: %v", err) + } + // 创建者随建自动持有主链接(1 条);friend 为显式导出的 "join" + 列表自愈 + // 生成的主链接(2 条)。 + adminInvites := map[int64]int{} + for _, admin := range adminsWithInvites.Admins { + adminInvites[admin.AdminID] = admin.InvitesCount + } + if len(adminsWithInvites.Admins) != 2 || adminInvites[owner.ID] != 1 || adminInvites[friend.ID] != 2 || len(adminsWithInvites.Users) == 0 { + t.Fatalf("admins with invites = %+v, want creator permanent link plus friend's two links", adminsWithInvites) + } + importers, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, friend.ID), &tg.MessagesGetChatInviteImportersRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get invite importers: %v", err) + } + if importers.Count != 1 || len(importers.Importers) != 1 || importers.Importers[0].UserID != joiner.ID || len(importers.Users) == 0 { + t.Fatalf("invite importers = %+v, want joined importer", importers) + } + importersSearchReq := &tg.MessagesGetChatInviteImportersRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + } + importersSearchReq.SetLink(exported.Link) + importersSearchReq.SetQ("bob") + if _, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, friend.ID), importersSearchReq); err == nil || !strings.Contains(err.Error(), "SEARCH_WITH_LINK_NOT_SUPPORTED") { + t.Fatalf("get invite importers q+link err = %v, want SEARCH_WITH_LINK_NOT_SUPPORTED", err) + } + if _, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, friend.ID), &tg.MessagesHideChatJoinRequestRequest{ + Approved: true, + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + }); err == nil || !strings.Contains(err.Error(), "HIDE_REQUESTER_MISSING") { + t.Fatalf("hide chat join request without pending err = %v, want HIDE_REQUESTER_MISSING", err) + } + if updates, err := r.onMessagesHideAllChatJoinRequests(WithUserID(ctx, friend.ID), &tg.MessagesHideAllChatJoinRequestsRequest{ + Approved: false, + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }); err != nil { + t.Fatalf("hide all chat join requests: %v", err) + } else if _, ok := updates.(*tg.Updates); !ok { + t.Fatalf("hide all chat join requests updates = %T, want *tg.Updates", updates) + } + if ok, err := r.onMessagesDeleteExportedChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesDeleteExportedChatInviteRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Link: exported.Link, + }); err != nil || !ok { + t.Fatalf("delete exported invite ok=%v err=%v, want true nil", ok, err) + } + if ok, err := r.onMessagesDeleteRevokedExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesDeleteRevokedExportedChatInvitesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + AdminID: &tg.InputUserSelf{}, + }); err != nil || !ok { + t.Fatalf("delete revoked invites ok=%v err=%v, want true nil", ok, err) + } + adminLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 20, + }) + if err != nil { + t.Fatalf("get admin log: %v", err) + } + if len(adminLog.Events) < 5 || len(adminLog.Chats) != 1 || len(adminLog.Users) < 3 { + t.Fatalf("admin log = %+v, want events plus chat/users", adminLog) + } + tooManyAdmins := make([]tg.InputUserClass, domain.MaxChannelAdminLogAdmins+1) + for i := range tooManyAdmins { + tooManyAdmins[i] = &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash} + } + tooManyAdminsReq := &tg.ChannelsGetAdminLogRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 1, + } + tooManyAdminsReq.SetAdmins(tooManyAdmins) + if _, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), tooManyAdminsReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("get admin log too many admins err = %v, want LIMIT_INVALID", err) + } + pinnedFilter := tg.ChannelAdminLogEventsFilter{} + pinnedFilter.SetPinned(true) + pinnedReq := &tg.ChannelsGetAdminLogRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + } + pinnedReq.SetEventsFilter(pinnedFilter) + pinnedLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), pinnedReq) + if err != nil { + t.Fatalf("get pinned admin log: %v", err) + } + if len(pinnedLog.Events) != 1 { + t.Fatalf("pinned admin log events = %+v, want one", pinnedLog.Events) + } + if _, ok := pinnedLog.Events[0].Action.(*tg.ChannelAdminLogEventActionUpdatePinned); !ok { + t.Fatalf("pinned admin log action = %T, want updatePinned", pinnedLog.Events[0].Action) + } + unpinnedAll, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), &tg.MessagesUnpinAllMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err != nil { + t.Fatalf("unpin all messages: %v", err) + } + if unpinnedAll.Pts == 0 || unpinnedAll.PtsCount != 1 || unpinnedAll.Offset != 0 { + t.Fatalf("unpin all affected history = %+v, want one channel pts event", unpinnedAll) + } + afterUnpin, err := r.deps.Channels.GetChannel(ctx, friend.ID, channel.ID) + if err != nil { + t.Fatalf("get channel after unpin: %v", err) + } + if afterUnpin.Channel.PinnedMessageID != 0 { + t.Fatalf("pinned message after unpin all = %d, want 0", afterUnpin.Channel.PinnedMessageID) + } + unpinnedAgain, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), &tg.MessagesUnpinAllMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err != nil { + t.Fatalf("unpin all messages again: %v", err) + } + if unpinnedAgain.Pts != afterUnpin.Channel.Pts || unpinnedAgain.PtsCount != 0 || unpinnedAgain.Offset != 0 { + t.Fatalf("unpin all no-op affected history = %+v, want current pts with zero pts_count", unpinnedAgain) + } + invalidTopicUnpin := &tg.MessagesUnpinAllMessagesRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + } + invalidTopicUnpin.SetTopMsgID(domain.MaxMessageBoxID + 1) + if _, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), invalidTopicUnpin); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("unpin all invalid top msg err = %v, want MESSAGE_ID_INVALID", err) + } +} + +func TestChannelEditBannedKickNotifiesKickedViewer(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 58, Phone: "15550002258", FirstName: "Owner"}) + kicked, _ := userStore.Create(ctx, domain.User{AccessHash: 59, Phone: "15550002259", FirstName: "Kicked"}) + channelStore := memory.NewChannelStore() + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}}, + Title: "Kick Notify", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + // 被踢者视角的推送 chats 必须是完整(非 min)投影并带 left: + // 客户端只对非 min channel 应用 left/banned_rights。 + kickedView := r.channelParticipantUpdates(ctx, kicked.ID, owner.ID, domain.Channel{ID: channel.ID, AccessHash: channel.AccessHash, Title: channel.Title, Megagroup: true}, + domain.ChannelMember{ChannelID: channel.ID, UserID: kicked.ID, Status: domain.ChannelMemberActive}, + domain.ChannelMember{ChannelID: channel.ID, UserID: kicked.ID, Status: domain.ChannelMemberKicked, BannedRights: domain.ChannelBannedRights{ViewMessages: true}}, + 1700000000) + if len(kickedView.Chats) != 1 { + t.Fatalf("kicked view chats = %+v, want one chat", kickedView.Chats) + } + kickedChat, ok := kickedView.Chats[0].(*tg.Channel) + if !ok { + t.Fatalf("kicked chat = %#v, want *tg.Channel", kickedView.Chats[0]) + } + if kickedChat.Min { + t.Fatalf("kicked chat = %#v, must not be min: min objects do not apply membership state", kickedChat) + } + if _, hasBanned := kickedChat.GetBannedRights(); !hasBanned { + t.Fatalf("kicked chat = %#v, want banned_rights so the viewer learns the kick", kickedChat) + } + adminView := r.channelParticipantUpdates(ctx, owner.ID, owner.ID, domain.Channel{ID: channel.ID, AccessHash: channel.AccessHash, Title: channel.Title, Megagroup: true}, + domain.ChannelMember{ChannelID: channel.ID, UserID: kicked.ID, Status: domain.ChannelMemberActive}, + domain.ChannelMember{ChannelID: channel.ID, UserID: kicked.ID, Status: domain.ChannelMemberKicked}, + 1700000000) + if adminChat, ok := adminView.Chats[0].(*tg.Channel); !ok || !adminChat.Min { + t.Fatalf("admin-side chat = %#v, want min channel that preserves local rights", adminView.Chats[0]) + } + + if _, err := r.onChannelsEditBanned(WithUserID(ctx, owner.ID), &tg.ChannelsEditBannedRequest{ + Channel: input, + Participant: &tg.InputPeerUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}, + BannedRights: tg.ChatBannedRights{ViewMessages: true}, + }); err != nil { + t.Fatalf("kick member: %v", err) + } + + // 被踢后 channels.getChannels 必须返回 channelForbidden 而不是省略。 + got, err := r.onChannelsGetChannels(WithUserID(ctx, kicked.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("kicked getChannels: %v", err) + } + chats, ok := got.(*tg.MessagesChats) + if !ok || len(chats.Chats) != 1 { + t.Fatalf("kicked getChannels = %T %+v, want one channelForbidden", got, got) + } + forbidden, ok := chats.Chats[0].(*tg.ChannelForbidden) + if !ok || forbidden.ID != channel.ID || forbidden.AccessHash != channel.AccessHash || !forbidden.Megagroup { + t.Fatalf("kicked chat = %#v, want channelForbidden tombstone", chats.Chats[0]) + } +} + +func TestChannelInviteKickedMemberRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 55, Phone: "15550002255", FirstName: "Owner"}) + helper, _ := userStore.Create(ctx, domain.User{AccessHash: 56, Phone: "15550002256", FirstName: "Helper"}) + kicked, _ := userStore.Create(ctx, domain.User{AccessHash: 57, Phone: "15550002257", FirstName: "Kicked"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{ + &tg.InputUser{UserID: helper.ID, AccessHash: helper.AccessHash}, + &tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}, + }, + Title: "RPC Invite Kicked", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + if _, err := r.onChannelsEditBanned(WithUserID(ctx, owner.ID), &tg.ChannelsEditBannedRequest{ + Channel: input, + Participant: &tg.InputPeerUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}, + BannedRights: tg.ChatBannedRights{ + ViewMessages: true, + }, + }); err != nil { + t.Fatalf("kick member: %v", err) + } + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, helper.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: input, + Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}}, + }); err == nil || !strings.Contains(err.Error(), "USER_KICKED") { + t.Fatalf("helper invite kicked err = %v, want USER_KICKED", err) + } + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: input, + Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}}, + }); err != nil { + t.Fatalf("owner restore kicked invite: %v", err) + } + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: input, + Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}}, + }); err == nil || !strings.Contains(err.Error(), "USER_ALREADY_PARTICIPANT") { + t.Fatalf("duplicate invite err = %v, want USER_ALREADY_PARTICIPANT", err) + } +} diff --git a/internal/rpc/channels_join_dialog_rpc_test.go b/internal/rpc/channels_join_dialog_rpc_test.go new file mode 100644 index 00000000..45918e19 --- /dev/null +++ b/internal/rpc/channels_join_dialog_rpc_test.go @@ -0,0 +1,67 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestBroadcastSelfJoinParticipantCarriesSelfInviter pins the fix for "joined a broadcast channel but +// no dialog appears". A broadcast join emits no service message; the official client materializes the +// chat-list entry by generating a LOCAL "you joined this channel" service message, which it only does +// when channel->inviter is a valid user — set from channels.getParticipant(self).inviter_id. Real TG +// returns inviter_id == user_id (self) for a self-join. telesrv previously left InviterUserID==0, so +// the client never generated the joined message and the channel stayed out of the list. +func TestBroadcastSelfJoinParticipantCarriesSelfInviter(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002161", FirstName: "Owner"}) + joiner, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002162", FirstName: "Joiner"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Self Join Broadcast", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + + if _, err := r.onChannelsJoinChannel(WithUserID(ctx, joiner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}); err != nil { + t.Fatalf("self join broadcast: %v", err) + } + + res, err := r.onChannelsGetParticipant(WithUserID(ctx, joiner.ID), &tg.ChannelsGetParticipantRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Participant: &tg.InputPeerSelf{}, + }) + if err != nil { + t.Fatalf("get participant self: %v", err) + } + self, ok := res.Participant.(*tg.ChannelParticipantSelf) + if !ok { + t.Fatalf("self participant = %T, want *tg.ChannelParticipantSelf", res.Participant) + } + if self.UserID != joiner.ID { + t.Fatalf("participant user_id = %d, want %d", self.UserID, joiner.ID) + } + if self.InviterID != joiner.ID { + t.Fatalf("participant inviter_id = %d, want self %d (so the client generates the joined message)", self.InviterID, joiner.ID) + } + if self.Date == 0 { + t.Fatalf("participant date = 0, want the join date") + } +} diff --git a/internal/rpc/channels_leave_rpc_test.go b/internal/rpc/channels_leave_rpc_test.go new file mode 100644 index 00000000..6d984ddf --- /dev/null +++ b/internal/rpc/channels_leave_rpc_test.go @@ -0,0 +1,132 @@ +package rpc + +import ( + "context" + "testing" + "time" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestMessagesGetFutureChatCreatorAfterLeaveAndCreatorLeaveTransfers(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + admin, err := userStore.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Admin"}) + if err != nil { + t.Fatalf("create admin: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 9103, Phone: "15550009103", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009100, 0)}) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "leave owner transfer", + Megagroup: true, + MemberUserIDs: []int64{admin.ID, member.ID}, + Date: 1700009100, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := channelService.EditAdmin(ctx, owner.ID, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: created.Channel.ID, + MemberID: admin.ID, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + AddAdmins: true, + }, + Date: 1700009101, + }); err != nil { + t.Fatalf("promote admin: %v", err) + } + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + inputChannel := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + future, err := r.onMessagesGetFutureChatCreatorAfterLeave(WithUserID(ctx, owner.ID), peer) + if err != nil { + t.Fatalf("get future creator: %v", err) + } + if user, ok := future.(*tg.User); !ok || user.ID != admin.ID { + t.Fatalf("future creator = %T %+v, want admin user %d", future, future, admin.ID) + } + + if _, err := r.onChannelsLeaveChannel(WithUserID(ctx, owner.ID), inputChannel); err != nil { + t.Fatalf("creator leaves: %v", err) + } + view, err := channelService.GetChannel(ctx, admin.ID, created.Channel.ID) + if err != nil { + t.Fatalf("get channel after leave: %v", err) + } + if view.Channel.CreatorUserID != admin.ID || view.Self.Role != domain.ChannelRoleCreator { + t.Fatalf("channel after leave = %+v self=%+v, want admin as creator", view.Channel, view.Self) + } + oldOwner, err := channelService.GetParticipant(ctx, admin.ID, created.Channel.ID, owner.ID) + if err != nil { + t.Fatalf("get old owner after leave: %v", err) + } + if oldOwner.Status != domain.ChannelMemberLeft || oldOwner.Role == domain.ChannelRoleCreator { + t.Fatalf("old owner after leave = %+v, want left non-creator", oldOwner) + } + if _, err := r.onChannelsJoinChannel(WithUserID(ctx, owner.ID), inputChannel); err != nil { + t.Fatalf("old owner rejoins: %v", err) + } + rejoined, err := channelService.GetParticipant(ctx, admin.ID, created.Channel.ID, owner.ID) + if err != nil { + t.Fatalf("get old owner after rejoin: %v", err) + } + if rejoined.Role != domain.ChannelRoleMember || rejoined.Status != domain.ChannelMemberActive || rejoined.Rank != "" { + t.Fatalf("rejoined old owner = %+v, want active plain member", rejoined) + } +} + +func TestMessagesGetFutureChatCreatorAfterLeaveNoCandidate(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 9111, Phone: "15550009111", FirstName: "Solo"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700009120, 0)}) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "solo owner", + Megagroup: true, + Date: 1700009120, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + inputChannel := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + if _, err := r.onMessagesGetFutureChatCreatorAfterLeave(WithUserID(ctx, owner.ID), peer); err == nil || !tgerr.Is(err, "USER_NOT_PARTICIPANT") { + t.Fatalf("future creator err = %v, want USER_NOT_PARTICIPANT", err) + } + if _, err := r.onChannelsLeaveChannel(WithUserID(ctx, owner.ID), inputChannel); err == nil || !tgerr.Is(err, "USER_CREATOR") { + t.Fatalf("creator leave err = %v, want USER_CREATOR", err) + } +} diff --git a/internal/rpc/channels_legacy_chat.go b/internal/rpc/channels_legacy_chat.go new file mode 100644 index 00000000..09416e64 --- /dev/null +++ b/internal/rpc/channels_legacy_chat.go @@ -0,0 +1,601 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap" + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreateChatRequest) (*tg.MessagesInvitedUsers, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if !validChannelTitle(req.Title) || len(req.Users) > domain.MaxChannelInviteUsers { + return nil, channelInvalidErr(domain.ErrChannelTitleInvalid) + } + if req.TTLPeriod < 0 { + return nil, ttlPeriodInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + memberIDs, err := r.userIDsFromInputUsers(ctx, userID, req.Users) + if err != nil { + return nil, err + } + memberIDs = createChatInviteMemberIDs(memberIDs, userID) + date := int(r.clock.Now().Unix()) + r.log.Debug("messages.createChat resolved users", + zap.Int("input_users", len(req.Users)), + zap.Int("member_ids", len(memberIDs)), + zap.Int64s("member_user_ids", memberIDs), + ) + if len(memberIDs) == 0 { + return nil, usersTooFewErr() + } + createRes, err := r.deps.Channels.CreateMegagroupFromCreateChat(ctx, userID, domain.CreateChannelRequest{ + CreatorUserID: userID, + Title: req.Title, + TTLPeriod: req.TTLPeriod, + Date: date, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + r.addOnlineChannelMemberships(createRes.Channel.ID, channelMemberUserIDs(createRes.Members)...) + + responseRes := createRes + var inviteRes domain.CreateChannelResult + if len(memberIDs) > 0 { + inviteRes, err = r.deps.Channels.InviteToChannel(ctx, userID, createRes.Channel.ID, memberIDs, date) + if err != nil { + return nil, channelInviteErr(err) + } + r.addOnlineChannelMemberships(inviteRes.Channel.ID, channelMemberUserIDs(inviteRes.Members)...) + responseRes.Channel = inviteRes.Channel + responseRes.Members = mergeChannelMembers(createRes.Members, inviteRes.Members) + responseRes.Recipients = uniqueRecipientIDs(append(append([]int64{}, createRes.Recipients...), inviteRes.Recipients...)) + } + + cache := newViewerPeerCache(r) + updates := r.channelOperationUpdatesWithPeerCache(ctx, userID, responseRes, cache) + if createChatNeedsLegacyChat(ctx) { + updates = r.tdesktopCreateChatUpdatesWithPeerCache(ctx, userID, responseRes, cache) + } + if inviteRes.Event.Pts != 0 { + inviteUpdates := r.channelOperationUpdatesWithPeerCache(ctx, userID, inviteRes, cache) + if inviteUpdates != nil { + updates.Updates = append(updates.Updates, inviteUpdates.Updates...) + } + } + if inviteRes.Event.Pts != 0 { + r.pushChannelExplicitUpdates(ctx, userID, inviteRes.Channel.ID, memberIDs, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, inviteRes, cache) + }) + } + return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil +} + +func (r *Router) onMessagesMigrateChat(ctx context.Context, chatID int64) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if chatID <= 0 { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + userID, view, err := r.channelChangeInfoView(ctx, &tg.InputChannel{ChannelID: chatID}) + if err != nil { + return nil, err + } + if !view.Channel.Megagroup { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + return r.channelStateUpdates(userID, view.Channel), nil +} + +func (r *Router) onMessagesGetChats(ctx context.Context, ids []int64) (tg.MessagesChatsClass, error) { + if len(ids) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + chats := make([]tg.ChatClass, 0, len(ids)) + if r.deps.Channels != nil { + unique := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + views, err := r.deps.Channels.GetChannels(ctx, userID, unique) + if err != nil { + return nil, channelInvalidErr(err) + } + byID := make(map[int64]domain.ChannelView, len(views)) + for _, view := range views { + if view.Channel.ID != 0 { + byID[view.Channel.ID] = view + } + } + for _, id := range ids { + if id <= 0 { + continue + } + if view, ok := byID[id]; ok { + chats = append(chats, tgChannelChatForView(userID, view)) + } + } + } + return &tg.MessagesChats{Chats: chats}, nil +} + +func (r *Router) onMessagesGetFullChat(ctx context.Context, chatID int64) (*tg.MessagesChatFull, error) { + if chatID <= 0 { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + return r.onChannelsGetFullChannel(ctx, &tg.InputChannel{ChannelID: chatID}) +} + +func (r *Router) onMessagesAddChatUser(ctx context.Context, req *tg.MessagesAddChatUserRequest) (*tg.MessagesInvitedUsers, error) { + if req.ChatID <= 0 || req.UserID == nil { + return nil, peerIDInvalidErr() + } + return r.onChannelsInviteToChannel(ctx, &tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: req.ChatID}, + Users: []tg.InputUserClass{req.UserID}, + }) +} + +func (r *Router) onMessagesDeleteChatUser(ctx context.Context, req *tg.MessagesDeleteChatUserRequest) (tg.UpdatesClass, error) { + if req.ChatID <= 0 || req.UserID == nil { + return nil, peerIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + target, found, err := r.userFromInput(ctx, userID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found || target.ID == 0 { + return nil, peerIDInvalidErr() + } + if target.ID == userID { + return r.onChannelsLeaveChannel(ctx, &tg.InputChannel{ChannelID: req.ChatID}) + } + return r.onChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{ + Channel: &tg.InputChannel{ChannelID: req.ChatID}, + Participant: &tg.InputPeerUser{UserID: target.ID, AccessHash: target.AccessHash}, + BannedRights: tg.ChatBannedRights{ + ViewMessages: true, + UntilDate: 0, + }, + }) +} + +func (r *Router) onMessagesEditChatTitle(ctx context.Context, req *tg.MessagesEditChatTitleRequest) (tg.UpdatesClass, error) { + if req.ChatID <= 0 { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + return r.onChannelsEditTitle(ctx, &tg.ChannelsEditTitleRequest{ + Channel: &tg.InputChannel{ChannelID: req.ChatID}, + Title: req.Title, + }) +} + +func (r *Router) onMessagesEditChatPhoto(ctx context.Context, req *tg.MessagesEditChatPhotoRequest) (tg.UpdatesClass, error) { + if req.ChatID <= 0 || req.Photo == nil { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + return r.onChannelsEditPhoto(ctx, &tg.ChannelsEditPhotoRequest{ + Channel: &tg.InputChannel{ChannelID: req.ChatID}, + Photo: req.Photo, + }) +} + +func (r *Router) onMessagesEditChatAdmin(ctx context.Context, req *tg.MessagesEditChatAdminRequest) (bool, error) { + if req.ChatID <= 0 || req.UserID == nil { + return false, peerIDInvalidErr() + } + rights := tg.ChatAdminRights{} + if req.IsAdmin { + rights = legacyBasicGroupAdminRights() + } + _, err := r.onChannelsEditAdmin(ctx, &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: req.ChatID}, + UserID: req.UserID, + AdminRights: rights, + }) + if err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesEditChatAbout(ctx context.Context, req *tg.MessagesEditChatAboutRequest) (bool, error) { + if r.deps.Channels == nil { + return false, notImplementedErr() + } + if utf8.RuneCountInString(req.About) > maxChannelAboutLength { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) + if err != nil { + return false, err + } + channel, err := r.deps.Channels.EditAbout(ctx, userID, domain.EditChannelAboutRequest{ + UserID: userID, + ChannelID: channelID, + About: req.About, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return false, channelAdminErr(err) + } + r.pushChannelStateToMembers(ctx, userID, channel) + return true, nil +} + +func (r *Router) onMessagesEditChatDefaultBannedRights(ctx context.Context, req *tg.MessagesEditChatDefaultBannedRightsRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + channel, err := r.deps.Channels.EditDefaultBannedRights(ctx, userID, domain.EditChannelDefaultBannedRightsRequest{ + UserID: userID, + ChannelID: peer.ID, + BannedRights: domainChannelBannedRights(req.BannedRights), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + return r.channelStateMutationUpdates(ctx, userID, channel), nil +} + +func (r *Router) onMessagesEditChatCreator(ctx context.Context, req *tg.MessagesEditChatCreatorRequest) (tg.UpdatesClass, error) { + if req.UserID == nil { + return nil, peerIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err != nil { + return nil, err + } + if _, found, err := r.userFromInput(ctx, userID, req.UserID); err != nil { + return nil, internalErr() + } else if !found { + return nil, peerIDInvalidErr() + } + return nil, tgerr.New(400, "PASSWORD_HASH_INVALID") +} + +func (r *Router) onMessagesGetFutureChatCreatorAfterLeave(ctx context.Context, peer tg.InputPeerClass) (tg.UserClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + resolved, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return nil, err + } + if resolved.Type != domain.PeerTypeChannel || resolved.ID == 0 { + return nil, peerIDInvalidErr() + } + member, err := r.deps.Channels.FutureCreatorAfterLeave(ctx, userID, resolved.ID) + if err != nil { + return nil, channelAdminErr(err) + } + if r.deps.Users == nil { + return nil, userIDInvalidErr() + } + user, found, err := r.deps.Users.ByID(ctx, userID, member.UserID) + if err != nil { + return nil, internalErr() + } + if !found || user.Bot { + return nil, tgerr400("USER_NOT_PARTICIPANT") + } + users := r.tgUsersForViewer(userID, []domain.User{user}) + if len(users) == 0 { + return nil, userIDInvalidErr() + } + return users[0], nil +} + +func (r *Router) onMessagesEditChatParticipantRank(ctx context.Context, req *tg.MessagesEditChatParticipantRankRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if len(req.Rank) > domain.MaxChannelAdminRankLength { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + participant, ok := r.domainPeerFromInputPeer(userID, req.Participant) + if !ok || participant.Type != domain.PeerTypeUser || participant.ID == 0 { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.EditMemberRank(ctx, userID, domain.EditChannelMemberRankRequest{ + UserID: userID, + ChannelID: channelID, + MemberID: participant.ID, + Rank: req.Rank, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + cache := newViewerPeerCache(r) + updates := r.channelParticipantUpdatesWithPeerCache(ctx, userID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache) + }) + return updates, nil +} + +func (r *Router) onMessagesSetChatTheme(ctx context.Context, req *tg.MessagesSetChatThemeRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer); err == nil { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + view, err := r.deps.Channels.GetChannel(ctx, userID, channelID) + if err != nil { + return nil, channelInvalidErr(err) + } + return r.channelStateUpdates(userID, view.Channel), nil + } else if _, ok := channelIDFromLegacyInputPeer(userID, req.Peer); ok { + return nil, err + } + peer, ok := r.domainPeerFromInputPeer(userID, req.Peer) + if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + emoticon, err := chatThemeEmoticonFromInput(req.Theme) + if err != nil { + return nil, err + } + if r.deps.Messages == nil { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + recipientBlocked, err := r.peerBlocksUser(ctx, userID, peer.ID) + if err != nil { + return nil, err + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + res, err := r.deps.Messages.SetChatTheme(ctx, userID, domain.SetPrivateChatThemeRequest{ + OwnerUserID: userID, + Peer: peer, + Emoticon: emoticon, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + RecipientBlocked: recipientBlocked, + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, peerIDInvalidErr() + } + return nil, internalErr() + } + if res.Changed { + r.invalidateRPCProjectionForPeer(userID, peer) + r.invalidateRPCProjectionForPeer(peer.ID, domain.Peer{Type: domain.PeerTypeUser, ID: userID}) + } + if !res.Changed || res.Send.SenderMessage.ID == 0 { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + return tgPrivateMessageUpdates( + res.Send.SenderEvent, + res.Send.SenderMessage, + 0, + false, + r.usersForMessageUpdate(ctx, userID, res.Send.SenderMessage), + []tg.ChatClass{}, + ), nil +} + +func chatThemeEmoticonFromInput(theme tg.InputChatThemeClass) (string, error) { + switch value := theme.(type) { + case *tg.InputChatThemeEmpty: + return "", nil + case *tg.InputChatTheme: + if value.Emoticon == "" { + return "", nil + } + if !tdesktop.IsChatThemeEmoticon(value.Emoticon) { + return "", themeInvalidErr() + } + return value.Emoticon, nil + case *tg.InputChatThemeUniqueGift: + return "", themeInvalidErr() + default: + return "", inputConstructorInvalidErr() + } +} + +func (r *Router) onMessagesSetChatWallPaper(ctx context.Context, req *tg.MessagesSetChatWallPaperRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputConstructorInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + wallpaper, err := domainWallpaperFromSetChatWallPaper(req) + if err != nil { + return nil, err + } + switch peer.Type { + case domain.PeerTypeUser: + if peer.ID == 0 { + return nil, peerIDInvalidErr() + } + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + case domain.PeerTypeChannel: + if r.deps.Channels == nil || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.SetWallpaper(ctx, userID, domain.SetChannelWallpaperRequest{ + UserID: userID, + ChannelID: peer.ID, + Wallpaper: wallpaper, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + r.invalidateRPCProjectionForChannel(res.Channel.ID) + if res.Changed && res.Event.Pts != 0 { + r.enqueueChannelWallpaperFanout(ctx, userID, res) + } else if res.Changed { + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelWallpaperUpdatesWithPeerCache(ctx, viewerUserID, res, nil) + }) + } + return r.channelWallpaperUpdatesWithPeerCache(ctx, userID, res, nil), nil + default: + return nil, peerIDInvalidErr() + } +} + +func (r *Router) channelWallpaperUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.SetChannelWallpaperResult, cache *viewerPeerCache) *tg.Updates { + update := tgUpdatePeerWallpaper(domain.Peer{Type: domain.PeerTypeChannel, ID: res.Channel.ID}, res.Channel.Wallpaper) + if res.Event.Pts != 0 { + sendRes := domain.SendChannelMessageResult{ + Channel: res.Channel, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + } + updates := r.channelMessageUpdatesWithPeerCache(ctx, viewerUserID, sendRes, 0, cache) + updates.Updates = append(updates.Updates, update) + return updates + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) enqueueChannelWallpaperFanout(ctx context.Context, originUserID int64, res domain.SetChannelWallpaperResult) { + sendRes := domain.SendChannelMessageResult{ + Channel: res.Channel, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + } + fanoutCache := newViewerPeerCache(r) + ownerIDs := channelMessageFanoutOwnerIDs(sendRes, nil) + r.enqueueChannelFanoutWithPrefetch(ctx, channelFanoutMembers, originUserID, res.Channel.ID, res.Event.Pts, res.Recipients, + func(bgCtx context.Context, viewers []int64) { + r.prefetchChannelFanoutUsers(bgCtx, fanoutCache, viewers, ownerIDs) + }, + func(bgCtx context.Context, viewerUserID int64) *tg.Updates { + return r.channelWallpaperUpdatesWithPeerCache(bgCtx, viewerUserID, res, fanoutCache) + }) +} + +func (r *Router) onMessagesToggleNoForwards(ctx context.Context, req *tg.MessagesToggleNoForwardsRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetNoForwards(ctx, userID, channelID, req.Enabled) + if err != nil { + return nil, channelAdminErr(err) + } + return r.channelStateMutationUpdates(ctx, userID, channel), nil +} + +func (r *Router) onMessagesSetChatAvailableReactions(ctx context.Context, req *tg.MessagesSetChatAvailableReactionsRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromLegacyInputPeerChecked(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + policy, err := domainChannelReactionPolicy(req) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetAvailableReactions(ctx, userID, channelID, policy) + if err != nil { + return nil, channelAdminErr(err) + } + return r.channelStateMutationUpdates(ctx, userID, channel), nil +} + +func legacyBasicGroupAdminRights() tg.ChatAdminRights { + return tg.ChatAdminRights{ + ChangeInfo: true, + DeleteMessages: true, + BanUsers: true, + InviteUsers: true, + PinMessages: true, + Other: true, + } +} diff --git a/internal/rpc/channels_legacy_settings_rpc_test.go b/internal/rpc/channels_legacy_settings_rpc_test.go new file mode 100644 index 00000000..0425e905 --- /dev/null +++ b/internal/rpc/channels_legacy_settings_rpc_test.go @@ -0,0 +1,176 @@ +package rpc + +import ( + "context" + "fmt" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestLegacyChannelSettingsRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550002181", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550002182", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Legacy Settings Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + themeUpdates, err := r.onMessagesSetChatTheme(WithUserID(ctx, owner.ID), &tg.MessagesSetChatThemeRequest{Peer: peer}) + if err != nil { + t.Fatalf("set chat theme channel peer: %v", err) + } + if len(themeUpdates.(*tg.Updates).Chats) != 1 { + t.Fatalf("set chat theme updates = %+v, want channel context", themeUpdates) + } + privateTheme, err := r.onMessagesSetChatTheme(WithUserID(ctx, owner.ID), &tg.MessagesSetChatThemeRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Theme: &tg.InputChatThemeEmpty{}, + }) + if err != nil { + t.Fatalf("set chat theme private peer: %v", err) + } + if len(privateTheme.(*tg.Updates).Updates) != 0 { + t.Fatalf("private set chat theme updates = %+v, want empty compat ack", privateTheme) + } + + reactionUpdates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: peer, + AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}}, + ReactionsLimit: 8, + }) + if err != nil { + t.Fatalf("set available reactions: %v", err) + } + if len(reactionUpdates.(*tg.Updates).Chats) != 1 { + t.Fatalf("set reactions updates = %+v, want channel state update", reactionUpdates) + } + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel after reactions: %v", err) + } + fullChannel := full.FullChat.(*tg.ChannelFull) + reactions, ok := fullChannel.GetAvailableReactions() + if !ok { + t.Fatalf("full channel reactions missing after set") + } + some, ok := reactions.(*tg.ChatReactionsSome) + if !ok || len(some.Reactions) != 1 { + t.Fatalf("full channel reactions = %#v, want one explicit reaction", reactions) + } + if fullChannel.ReactionsLimit != 8 { + t.Fatalf("full channel reactions limit = %d, want 8", fullChannel.ReactionsLimit) + } + if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: peer, + AvailableReactions: &tg.ChatReactionsSome{Reactions: make([]tg.ReactionClass, domain.MaxChannelReactionTypes+1)}, + }); err == nil { + t.Fatalf("set too many reactions err = nil, want limit error") + } + + noForwards, err := r.onMessagesToggleNoForwards(WithUserID(ctx, owner.ID), &tg.MessagesToggleNoForwardsRequest{ + Peer: peer, + Enabled: true, + }) + if err != nil { + t.Fatalf("toggle noforwards: %v", err) + } + if got := noForwards.(*tg.Updates).Chats[0].(*tg.Channel); !got.Noforwards { + t.Fatalf("noforwards channel = %+v, want enabled", got) + } + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: peer, + Message: "protected content", + RandomID: 8181, + }) + if err != nil { + t.Fatalf("send protected message: %v", err) + } + msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if !msg.Noforwards { + t.Fatalf("protected channel message = %+v, want noforwards inherited", msg) + } +} + +// TestBroadcastChannelAcceptsFullReactionCatalog 复现并锁定真机 bug:广播频道开启 +// reactions 时,DrKLO 把「启用全部标准 reaction」发成显式 chatReactionsSome 列表 +// (megagroup 走 chatReactionsAll),列表长度等于 getAvailableReactions 目录大小 +// (当前 ~74)。此前 MaxChannelReactionItems=64 把它误判成 LIMIT_INVALID。修复后 +// 任何不超过 MaxChannelReactionTypes 的列表都必须被接受。 +func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550002191", FirstName: "Owner"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Broadcast Reactions", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + if channel.Megagroup { + t.Fatalf("created channel = %+v, want broadcast (not megagroup)", channel) + } + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + // 用一个明显超过旧上限(64)的目录大小,证明修复生效。每个 emoticon 取互不相同 + // 的合法短串即可(验证只关心非空且 rune 数 <= MaxChannelReactionEmoticonLength)。 + const catalogSize = 74 + if catalogSize <= 64 || catalogSize > domain.MaxChannelReactionTypes { + t.Fatalf("test catalog size %d must exceed the old 64 cap and stay within %d", + catalogSize, domain.MaxChannelReactionTypes) + } + reactions := make([]tg.ReactionClass, 0, catalogSize) + for i := 0; i < catalogSize; i++ { + reactions = append(reactions, &tg.ReactionEmoji{Emoticon: fmt.Sprintf("r%02d", i)}) + } + updates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: peer, + AvailableReactions: &tg.ChatReactionsSome{Reactions: reactions}, + ReactionsLimit: 11, + }) + if err != nil { + t.Fatalf("set full-catalog reactions on broadcast channel: %v", err) + } + if len(updates.(*tg.Updates).Chats) != 1 { + t.Fatalf("set reactions updates = %+v, want channel state update", updates) + } + + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel after reactions: %v", err) + } + fullChannel := full.FullChat.(*tg.ChannelFull) + stored, ok := fullChannel.GetAvailableReactions() + if !ok { + t.Fatalf("full channel reactions missing after set") + } + some, ok := stored.(*tg.ChatReactionsSome) + if !ok || len(some.Reactions) != catalogSize { + t.Fatalf("full channel reactions = %#v, want %d explicit reactions", stored, catalogSize) + } +} diff --git a/internal/rpc/channels_member_rank_rpc_test.go b/internal/rpc/channels_member_rank_rpc_test.go new file mode 100644 index 00000000..ffc95f77 --- /dev/null +++ b/internal/rpc/channels_member_rank_rpc_test.go @@ -0,0 +1,401 @@ +package rpc + +import ( + "context" + "strings" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestChannelMemberRankRPC 覆盖成员 Tag(rank-only 编辑)的权限矩阵: +// actor ∈ {creator, manage_ranks admin, 普通成员} × target ∈ {自己, 普通成员, +// 自己提拔的 admin, 他人提拔的 admin, creator} × 群级 edit_rank 开关 {开, 关}, +// 以及 editAdmin 撤管清 rank 的旧语义回归。 +func TestChannelMemberRankRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550002401", FirstName: "Owner"}) + tagger, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550002402", FirstName: "Tagger"}) + plain, _ := userStore.Create(ctx, domain.User{AccessHash: 73, Phone: "15550002403", FirstName: "Plain"}) + other, _ := userStore.Create(ctx, domain.User{AccessHash: 74, Phone: "15550002404", FirstName: "Other"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{ + &tg.InputUser{UserID: tagger.ID, AccessHash: tagger.AccessHash}, + &tg.InputUser{UserID: plain.ID, AccessHash: plain.AccessHash}, + &tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash}, + }, + Title: "Member Tag Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + inputChannel := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + editRank := func(actorID int64, participant tg.InputPeerClass, rank string) (tg.UpdatesClass, error) { + return r.onMessagesEditChatParticipantRank(WithUserID(ctx, actorID), &tg.MessagesEditChatParticipantRankRequest{ + Peer: peer, + Participant: participant, + Rank: rank, + }) + } + getParticipant := func(viewerID, targetID int64) tg.ChannelParticipantClass { + t.Helper() + res, err := r.onChannelsGetParticipant(WithUserID(ctx, viewerID), &tg.ChannelsGetParticipantRequest{ + Channel: inputChannel, + Participant: &tg.InputPeerUser{UserID: targetID}, + }) + if err != nil { + t.Fatalf("get participant %d: %v", targetID, err) + } + return res.Participant + } + + // creator 给普通成员设 tag:角色不变、rank 持久、推送 updateChannelParticipant。 + rankUpdates, err := editRank(owner.ID, &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash}, "navigator") + if err != nil { + t.Fatalf("creator set member tag: %v", err) + } + participantUpdate := tg.UpdateClass(nil) + for _, update := range rankUpdates.(*tg.Updates).Updates { + if _, ok := update.(*tg.UpdateChannelParticipant); ok { + participantUpdate = update + break + } + } + if participantUpdate == nil { + t.Fatalf("rank updates = %+v, want updateChannelParticipant", rankUpdates.(*tg.Updates).Updates) + } + newParticipant, ok := participantUpdate.(*tg.UpdateChannelParticipant).GetNewParticipant() + if !ok { + t.Fatalf("rank update missing new participant: %+v", participantUpdate) + } + taggedMember, ok := newParticipant.(*tg.ChannelParticipant) + if !ok { + t.Fatalf("tagged member = %T, want plain channelParticipant (role must not change)", newParticipant) + } + if rank, _ := taggedMember.GetRank(); rank != "navigator" { + t.Fatalf("tagged member rank = %q, want navigator", rank) + } + if got, ok := getParticipant(owner.ID, plain.ID).(*tg.ChannelParticipant); !ok { + t.Fatalf("plain participant after tag = %T, want channelParticipant", getParticipant(owner.ID, plain.ID)) + } else if rank, _ := got.GetRank(); rank != "navigator" { + t.Fatalf("plain participant rank = %q, want navigator", rank) + } + + // admins filter 是两端消息徽章的数据源:必须返回管理员 + 带 tag 的普通成员。 + adminsRes, err := r.onChannelsGetParticipants(WithUserID(ctx, other.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: inputChannel, + Filter: &tg.ChannelParticipantsAdmins{}, + Limit: 100, + }) + if err != nil { + t.Fatalf("get admins participants: %v", err) + } + badgeRanks := map[int64]string{} + for _, participant := range adminsRes.(*tg.ChannelsChannelParticipants).Participants { + switch p := participant.(type) { + case *tg.ChannelParticipantCreator: + badgeRanks[p.UserID], _ = p.GetRank() + case *tg.ChannelParticipantAdmin: + badgeRanks[p.UserID], _ = p.GetRank() + case *tg.ChannelParticipant: + rank, _ := p.GetRank() + badgeRanks[p.UserID] = rank + } + } + if len(badgeRanks) != 2 { + t.Fatalf("admins filter participants = %+v, want creator + tagged plain member", badgeRanks) + } + if badgeRanks[plain.ID] != "navigator" { + t.Fatalf("admins filter plain rank = %q, want navigator (message badge source)", badgeRanks[plain.ID]) + } + if _, ok := badgeRanks[other.ID]; ok { + t.Fatalf("admins filter must not contain untagged plain member: %+v", badgeRanks) + } + + // 普通成员改自己(开关默认开,inputPeerSelf 路径)。 + if _, err := editRank(plain.ID, &tg.InputPeerSelf{}, "pilot"); err != nil { + t.Fatalf("plain member edits own tag: %v", err) + } + if self, ok := getParticipant(plain.ID, plain.ID).(*tg.ChannelParticipantSelf); !ok { + t.Fatalf("self participant = %T, want channelParticipantSelf", getParticipant(plain.ID, plain.ID)) + } else if rank, _ := self.GetRank(); rank != "pilot" { + t.Fatalf("self rank = %q, want pilot", rank) + } + + // 普通成员改别人 → CHAT_ADMIN_REQUIRED。 + if _, err := editRank(plain.ID, &tg.InputPeerUser{UserID: other.ID, AccessHash: other.AccessHash}, "x"); err == nil || !strings.Contains(err.Error(), "CHAT_ADMIN_REQUIRED") { + t.Fatalf("plain member edits other tag err = %v, want CHAT_ADMIN_REQUIRED", err) + } + + // creator 授予 tagger 管理 Tags 的管理员权限;manage_ranks 必须持久化并下发。 + if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ + Channel: inputChannel, + UserID: &tg.InputUser{UserID: tagger.ID, AccessHash: tagger.AccessHash}, + AdminRights: tg.ChatAdminRights{ + ChangeInfo: true, + AddAdmins: true, + ManageRanks: true, + }, + }); err != nil { + t.Fatalf("promote tagger with manage_ranks: %v", err) + } + taggerAdmin, ok := getParticipant(owner.ID, tagger.ID).(*tg.ChannelParticipantAdmin) + if !ok { + t.Fatalf("tagger participant = %T, want channelParticipantAdmin", getParticipant(owner.ID, tagger.ID)) + } + if !taggerAdmin.AdminRights.ManageRanks { + t.Fatalf("tagger admin rights = %+v, want manage_ranks=true round-trip", taggerAdmin.AdminRights) + } + chats, err := r.onChannelsGetChannels(WithUserID(ctx, tagger.ID), []tg.InputChannelClass{inputChannel}) + if err != nil { + t.Fatalf("get channels as tagger: %v", err) + } + taggerChat := chats.GetChats()[0].(*tg.Channel) + if rights, ok := taggerChat.GetAdminRights(); !ok || !rights.ManageRanks { + t.Fatalf("tagger chat admin rights = %+v, want manage_ranks=true", taggerChat) + } + + // manage_ranks admin 改普通成员 → OK。 + if _, err := editRank(tagger.ID, &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash}, "lookout"); err != nil { + t.Fatalf("manage_ranks admin edits plain member: %v", err) + } + // manage_ranks admin 改 creator → USER_CREATOR。 + if _, err := editRank(tagger.ID, &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, "boss"); err == nil || !strings.Contains(err.Error(), "USER_CREATOR") { + t.Fatalf("admin edits creator tag err = %v, want USER_CREATOR", err) + } + // manage_ranks admin 改自己 → OK。 + if _, err := editRank(tagger.ID, &tg.InputPeerSelf{}, "chief"); err != nil { + t.Fatalf("admin edits own tag: %v", err) + } + + // tagger 提拔 other(promoted_by=tagger)后可改其 tag;creator 重新提拔后不可。 + if _, err := r.onChannelsEditAdmin(WithUserID(ctx, tagger.ID), &tg.ChannelsEditAdminRequest{ + Channel: inputChannel, + UserID: &tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash}, + AdminRights: tg.ChatAdminRights{ChangeInfo: true}, + }); err != nil { + t.Fatalf("tagger promotes other: %v", err) + } + if _, err := editRank(tagger.ID, &tg.InputPeerUser{UserID: other.ID, AccessHash: other.AccessHash}, "scout"); err != nil { + t.Fatalf("admin edits tag of admin they promoted: %v", err) + } + if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ + Channel: inputChannel, + UserID: &tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash}, + AdminRights: tg.ChatAdminRights{ChangeInfo: true}, + Rank: "scout", + }); err != nil { + t.Fatalf("creator re-promotes other: %v", err) + } + if _, err := editRank(tagger.ID, &tg.InputPeerUser{UserID: other.ID, AccessHash: other.AccessHash}, "spy"); err == nil || !strings.Contains(err.Error(), "RIGHT_FORBIDDEN") { + t.Fatalf("admin edits tag of admin promoted by creator err = %v, want RIGHT_FORBIDDEN", err) + } + + // 关闭群级 Member Tags 开关(default_banned_rights.edit_rank=true)。 + if _, err := r.onMessagesEditChatDefaultBannedRights(WithUserID(ctx, owner.ID), &tg.MessagesEditChatDefaultBannedRightsRequest{ + Peer: peer, + BannedRights: tg.ChatBannedRights{EditRank: true}, + }); err != nil { + t.Fatalf("disable member tags switch: %v", err) + } + chatsAfterSwitch, err := r.onChannelsGetChannels(WithUserID(ctx, plain.ID), []tg.InputChannelClass{inputChannel}) + if err != nil { + t.Fatalf("get channels after switch: %v", err) + } + switchedChat := chatsAfterSwitch.GetChats()[0].(*tg.Channel) + if rights, ok := switchedChat.GetDefaultBannedRights(); !ok || !rights.EditRank { + t.Fatalf("default banned rights after switch = %+v, want edit_rank=true round-trip", switchedChat) + } + // 开关关闭后:普通成员改自己 → RIGHT_FORBIDDEN;admin/creator 改自己仍可。 + if _, err := editRank(plain.ID, &tg.InputPeerSelf{}, "pilot2"); err == nil || !strings.Contains(err.Error(), "RIGHT_FORBIDDEN") { + t.Fatalf("plain member edits own tag with switch off err = %v, want RIGHT_FORBIDDEN", err) + } + if _, err := editRank(tagger.ID, &tg.InputPeerSelf{}, "chief2"); err != nil { + t.Fatalf("admin edits own tag with switch off: %v", err) + } + if _, err := editRank(owner.ID, &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, "captain"); err != nil { + t.Fatalf("creator edits own tag: %v", err) + } + if got, ok := getParticipant(owner.ID, owner.ID).(*tg.ChannelParticipantCreator); !ok { + t.Fatalf("creator participant = %T, want channelParticipantCreator", getParticipant(owner.ID, owner.ID)) + } else if rank, _ := got.GetRank(); rank != "captain" { + t.Fatalf("creator rank = %q, want captain", rank) + } + + // rank 超长 → 拒绝。 + if _, err := editRank(owner.ID, &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash}, strings.Repeat("r", domain.MaxChannelAdminRankLength+1)); err == nil { + t.Fatalf("over-length rank accepted, want error") + } + + // editAdmin 撤管仍清空 rank(旧语义回归)。 + if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ + Channel: inputChannel, + UserID: &tg.InputUser{UserID: other.ID, AccessHash: other.AccessHash}, + AdminRights: tg.ChatAdminRights{}, + }); err != nil { + t.Fatalf("demote other: %v", err) + } + demoted, ok := getParticipant(owner.ID, other.ID).(*tg.ChannelParticipant) + if !ok { + t.Fatalf("demoted participant = %T, want channelParticipant", getParticipant(owner.ID, other.ID)) + } + if rank, has := demoted.GetRank(); has && rank != "" { + t.Fatalf("demoted rank = %q, want cleared", rank) + } + + // admin log:rank 编辑必须记 participant_edit_rank,且 edit_rank filter 可检索。 + filter := tg.ChannelAdminLogEventsFilter{} + filter.SetEditRank(true) + logReq := &tg.ChannelsGetAdminLogRequest{Channel: inputChannel, Limit: 50} + logReq.SetEventsFilter(filter) + adminLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), logReq) + if err != nil { + t.Fatalf("get admin log: %v", err) + } + if len(adminLog.Events) == 0 { + t.Fatalf("admin log with edit_rank filter is empty, want participant_edit_rank events") + } + + // 重进是全新 participant:admin(带 manage_ranks 与 tag)退群重进后是普通 + // 成员、无 admin rights、无 rank,且不再出现在 admins filter(徽章数据源)。 + if _, err := r.onChannelsLeaveChannel(WithUserID(ctx, tagger.ID), inputChannel); err != nil { + t.Fatalf("tagger leaves: %v", err) + } + if _, err := r.onChannelsJoinChannel(WithUserID(ctx, tagger.ID), inputChannel); err != nil { + t.Fatalf("tagger rejoins: %v", err) + } + rejoined, ok := getParticipant(owner.ID, tagger.ID).(*tg.ChannelParticipant) + if !ok { + t.Fatalf("rejoined tagger = %T, want plain channelParticipant", getParticipant(owner.ID, tagger.ID)) + } + if rank, has := rejoined.GetRank(); has && rank != "" { + t.Fatalf("rejoined tagger rank = %q, want cleared", rank) + } + adminsAfterRejoin, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: inputChannel, + Filter: &tg.ChannelParticipantsAdmins{}, + Limit: 100, + }) + if err != nil { + t.Fatalf("get admins after rejoin: %v", err) + } + for _, p := range adminsAfterRejoin.(*tg.ChannelsChannelParticipants).Participants { + if p, ok := p.(*tg.ChannelParticipantAdmin); ok && p.UserID == tagger.ID { + t.Fatalf("rejoined tagger still listed as admin in admins filter") + } + } + + // kick→解禁→重进:tag 不复活。 + if _, err := r.onChannelsEditBanned(WithUserID(ctx, owner.ID), &tg.ChannelsEditBannedRequest{ + Channel: inputChannel, + Participant: &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash}, + BannedRights: tg.ChatBannedRights{ViewMessages: true}, + }); err != nil { + t.Fatalf("kick plain: %v", err) + } + if _, err := r.onChannelsEditBanned(WithUserID(ctx, owner.ID), &tg.ChannelsEditBannedRequest{ + Channel: inputChannel, + Participant: &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash}, + BannedRights: tg.ChatBannedRights{}, + }); err != nil { + t.Fatalf("unban plain: %v", err) + } + if _, err := r.onChannelsJoinChannel(WithUserID(ctx, plain.ID), inputChannel); err != nil { + t.Fatalf("plain rejoins after unban: %v", err) + } + if got := getParticipant(owner.ID, plain.ID); true { + if p, ok := got.(*tg.ChannelParticipant); !ok { + t.Fatalf("unbanned plain participant = %T, want plain channelParticipant", got) + } else if rank, has := p.GetRank(); has && rank != "" { + t.Fatalf("unbanned plain rank = %q, want cleared", rank) + } + } + + // creator 离开会把 owner 交给其他活跃成员;旧 creator 重进是普通成员, + // 不保留 creator tag。 + if _, err := r.onChannelsLeaveChannel(WithUserID(ctx, owner.ID), inputChannel); err != nil { + t.Fatalf("creator leaves: %v", err) + } + newCreator, ok := getParticipant(tagger.ID, tagger.ID).(*tg.ChannelParticipantCreator) + if !ok { + t.Fatalf("future creator = %T, want channelParticipantCreator", getParticipant(tagger.ID, tagger.ID)) + } + if rank, has := newCreator.GetRank(); has && rank != "" { + t.Fatalf("future creator rank = %q, want empty", rank) + } + if _, err := r.onChannelsJoinChannel(WithUserID(ctx, owner.ID), inputChannel); err != nil { + t.Fatalf("creator rejoins: %v", err) + } + creatorBack, ok := getParticipant(tagger.ID, owner.ID).(*tg.ChannelParticipant) + if !ok { + t.Fatalf("rejoined old creator = %T, want plain channelParticipant", getParticipant(tagger.ID, owner.ID)) + } + if rank, has := creatorBack.GetRank(); has && rank != "" { + t.Fatalf("rejoined old creator rank = %q, want cleared", rank) + } + + // 成员 Tag 是 megagroup 专属:broadcast 频道上任何路径(含 creator/self) + // 一律 MEGAGROUP_ID_INVALID,broadcast 的 admins filter 才能保持纯管理员列表。 + bcCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Broadcast: true, + Title: "Member Tag Broadcast", + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + bcChannel := bcCreated.(*tg.Updates).Chats[0].(*tg.Channel) + bcInput := &tg.InputChannel{ChannelID: bcChannel.ID, AccessHash: bcChannel.AccessHash} + bcPeer := &tg.InputPeerChannel{ChannelID: bcChannel.ID, AccessHash: bcChannel.AccessHash} + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: bcInput, + Users: []tg.InputUserClass{&tg.InputUser{UserID: plain.ID, AccessHash: plain.AccessHash}}, + }); err != nil { + t.Fatalf("invite to broadcast: %v", err) + } + bcEditRank := func(actorID int64, participant tg.InputPeerClass, rank string) error { + _, err := r.onMessagesEditChatParticipantRank(WithUserID(ctx, actorID), &tg.MessagesEditChatParticipantRankRequest{ + Peer: bcPeer, + Participant: participant, + Rank: rank, + }) + return err + } + for name, attempt := range map[string]func() error{ + "creator tags subscriber": func() error { + return bcEditRank(owner.ID, &tg.InputPeerUser{UserID: plain.ID, AccessHash: plain.AccessHash}, "vip") + }, + "subscriber tags self": func() error { return bcEditRank(plain.ID, &tg.InputPeerSelf{}, "fan") }, + "creator tags self": func() error { return bcEditRank(owner.ID, &tg.InputPeerSelf{}, "boss") }, + } { + if err := attempt(); err == nil || !strings.Contains(err.Error(), "MEGAGROUP_ID_INVALID") { + t.Fatalf("%s on broadcast err = %v, want MEGAGROUP_ID_INVALID", name, err) + } + } + for _, event := range adminLog.Events { + action, ok := event.Action.(*tg.ChannelAdminLogEventActionParticipantEditRank) + if !ok { + t.Fatalf("admin log action = %T, want channelAdminLogEventActionParticipantEditRank", event.Action) + } + if action.UserID == 0 { + t.Fatalf("admin log edit rank action missing user: %+v", action) + } + } + latest := adminLog.Events[0].Action.(*tg.ChannelAdminLogEventActionParticipantEditRank) + if latest.UserID != owner.ID || latest.NewRank != "captain" { + t.Fatalf("latest edit rank action = %+v, want owner captain", latest) + } +} diff --git a/internal/rpc/channels_members.go b/internal/rpc/channels_members.go new file mode 100644 index 00000000..738c8037 --- /dev/null +++ b/internal/rpc/channels_members.go @@ -0,0 +1,830 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap" + "sort" + "strings" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onChannelsGetAdminedPublicChannels(ctx context.Context, req *tg.ChannelsGetAdminedPublicChannelsRequest) (tg.MessagesChatsClass, error) { + if r.deps.Channels == nil { + return &tg.MessagesChats{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.ByLocation { + return &tg.MessagesChats{}, nil + } + channels, err := r.deps.Channels.ListAdminedPublicChannels(ctx, userID) + if err != nil { + return nil, internalErr() + } + return &tg.MessagesChats{Chats: tgChannels(userID, channels)}, nil +} + +func (r *Router) onChannelsDeleteParticipantHistory(ctx context.Context, req *tg.ChannelsDeleteParticipantHistoryRequest) (*tg.MessagesAffectedHistory, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + peer, ok := r.domainPeerFromInputPeer(0, req.Participant) + if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.DeleteParticipantHistory(ctx, userID, domain.DeleteChannelParticipantHistoryRequest{ + UserID: userID, + ChannelID: channelID, + ParticipantUserID: peer.ID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelDeleteErr(err) + } + if res.Event.Pts != 0 { + // deleteParticipantHistory fan-out 异步化(设计 Phase 0),与已异步的 messages.deleteMessages + // 频道分支对齐。builder 纯 viewer 无关(仅 delete update + ChatMin,无 Users),无需预热。 + r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { + return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) + }) + } + return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil +} + +func (r *Router) onChannelsToggleJoinToSend(ctx context.Context, req *tg.ChannelsToggleJoinToSendRequest) (tg.UpdatesClass, error) { + return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) { + return r.deps.Channels.SetJoinToSend(ctx, userID, channelID, req.Enabled) + }) +} + +func (r *Router) onChannelsToggleJoinRequest(ctx context.Context, req *tg.ChannelsToggleJoinRequestRequest) (tg.UpdatesClass, error) { + return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) { + return r.deps.Channels.SetJoinRequest(ctx, userID, channelID, req.Enabled) + }) +} + +func (r *Router) onChannelsToggleParticipantsHidden(ctx context.Context, req *tg.ChannelsToggleParticipantsHiddenRequest) (tg.UpdatesClass, error) { + return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) { + return r.deps.Channels.SetParticipantsHidden(ctx, userID, channelID, req.Enabled) + }) +} + +func (r *Router) onChannelsGetMessageAuthor(ctx context.Context, req *tg.ChannelsGetMessageAuthorRequest) (tg.UserClass, error) { + userID, view, err := r.channelView(ctx, req.Channel) + if err != nil { + return nil, err + } + author, err := r.deps.Channels.GetMessageAuthor(ctx, userID, domain.GetChannelMessageAuthorRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + ID: req.ID, + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, messageIDInvalidErr() + } + return nil, channelInvalidErr(err) + } + users := r.tgUsersForIDs(ctx, userID, []int64{author.SenderUserID}) + if len(users) == 0 { + return nil, peerIDInvalidErr() + } + return users[0], nil +} + +func (r *Router) onChannelsGetParticipants(ctx context.Context, req *tg.ChannelsGetParticipantsRequest) (tg.ChannelsChannelParticipantsClass, error) { + if r.deps.Channels == nil { + return &tg.ChannelsChannelParticipants{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + ref, ok := inputChannelRef(req.Channel) + if !ok { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + filter := domainChannelParticipantsFilter(req.Filter) + if utf8.RuneCountInString(filter.Query) > domain.MaxChannelParticipantsQueryLength { + return nil, limitInvalidErr() + } + list, err := r.deps.Channels.GetParticipants(ctx, userID, ref.ID, filter, req.Offset, req.Limit) + if err != nil { + return nil, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, list.Channel) { + return nil, channelInvalidErr(domain.ErrChannelPrivate) + } + if req.Hash != 0 && list.Hash == req.Hash { + return &tg.ChannelsChannelParticipantsNotModified{}, nil + } + participants := make([]tg.ChannelParticipantClass, 0, len(list.Participants)) + userIDs := make([]int64, 0, len(list.Participants)) + seenUserIDs := make(map[int64]struct{}, len(list.Participants)*2) + addUserID := func(id int64) { + if id == 0 { + return + } + if _, ok := seenUserIDs[id]; ok { + return + } + seenUserIDs[id] = struct{}{} + userIDs = append(userIDs, id) + } + for _, member := range list.Participants { + participant := tgChannelParticipant(userID, member) + participants = append(participants, participant) + for _, id := range channelParticipantUserRefs(participant) { + addUserID(id) + } + } + users := r.tgUsers(list.Users) + if len(users) == 0 { + users = r.tgUsersForIDs(ctx, userID, userIDs) + } else { + present := make(map[int64]struct{}, len(users)) + for _, item := range users { + if u, ok := item.(*tg.User); ok { + present[u.ID] = struct{}{} + } + } + missing := make([]int64, 0, len(userIDs)) + for _, id := range userIDs { + if _, ok := present[id]; !ok { + missing = append(missing, id) + } + } + if len(missing) > 0 { + users = append(users, r.tgUsersForIDs(ctx, userID, missing)...) + } + } + r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, nil) + r.log.Debug("channels.getParticipants result", + zap.Int64("channel_id", ref.ID), + zap.String("filter", string(filter.Kind)), + zap.Int("count", list.Count), + zap.Int("participants", len(participants)), + zap.Int("users", len(users)), + ) + return &tg.ChannelsChannelParticipants{ + Count: list.Count, + Participants: participants, + Chats: []tg.ChatClass{}, + Users: users, + }, nil +} + +func (r *Router) onChannelsGetParticipant(ctx context.Context, req *tg.ChannelsGetParticipantRequest) (*tg.ChannelsChannelParticipant, error) { + if r.deps.Channels == nil { + return &tg.ChannelsChannelParticipant{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + peer, ok := r.domainPeerFromInputPeer(userID, req.Participant) + if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + participant := tgChannelParticipant(userID, member) + users := r.tgUsersForIDs(ctx, userID, channelParticipantUserRefs(participant)) + r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, nil) + return &tg.ChannelsChannelParticipant{ + Participant: participant, + Users: users, + }, nil +} + +func channelParticipantUserRefs(participant tg.ChannelParticipantClass) []int64 { + ids := make([]int64, 0, 3) + add := func(id int64) { + if id == 0 { + return + } + for _, existing := range ids { + if existing == id { + return + } + } + ids = append(ids, id) + } + addPeer := func(peer tg.PeerClass) { + if p, ok := peer.(*tg.PeerUser); ok { + add(p.UserID) + } + } + switch p := participant.(type) { + case *tg.ChannelParticipantCreator: + add(p.UserID) + case *tg.ChannelParticipantAdmin: + add(p.UserID) + add(p.PromotedBy) + if inviterID, ok := p.GetInviterID(); ok { + add(inviterID) + } + case *tg.ChannelParticipantSelf: + add(p.UserID) + add(p.InviterID) + case *tg.ChannelParticipant: + add(p.UserID) + case *tg.ChannelParticipantLeft: + addPeer(p.Peer) + case *tg.ChannelParticipantBanned: + addPeer(p.Peer) + add(p.KickedBy) + } + return ids +} + +func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.ChannelsInviteToChannelRequest) (*tg.MessagesInvitedUsers, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if len(req.Users) == 0 || len(req.Users) > domain.MaxChannelInviteUsers { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + userIDs, err := r.userIDsFromInputUsers(ctx, userID, req.Users) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, int(r.clock.Now().Unix())) + if err != nil { + return nil, channelInviteErr(err) + } + r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID) + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + cache := newViewerPeerCache(r) + updates := r.channelOperationUpdatesWithPeerCache(ctx, userID, res, cache) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, cache) + }) + return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil +} + +func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChannelClass) (tg.MessagesChatInviteJoinResultClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + ref, ok := inputChannelRef(input) + if !ok { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + if ref.CheckAccessHash { + channel, err := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, channel) { + return nil, channelInvalidErr(domain.ErrChannelPrivate) + } + } + res, err := r.deps.Channels.JoinChannel(ctx, userID, ref.ID, int(r.clock.Now().Unix())) + if err != nil { + if errors.Is(err, domain.ErrInviteRequestSent) && res.Channel.ID != 0 { + r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) + } + return nil, channelInviteErr(err) + } + r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID) + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + // Layer 227:channels.joinChannel 返回 messages.ChatInviteJoinResult; + // 正常加入即 chatInviteJoinResultOk 包裹本次操作的 updates。 + return &tg.MessagesChatInviteJoinResultOk{Updates: updates}, nil +} + +func (r *Router) onChannelsLeaveChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, input) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.LeaveChannel(ctx, userID, channelID, int(r.clock.Now().Unix())) + if err != nil { + return nil, channelAdminErr(err) + } + r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID) + r.removeOnlineChannelMemberships(res.Channel.ID, userID) + r.recordChannelStateForUser(ctx, userID, res.Channel.ID, true) + updates := r.channelOperationUpdates(ctx, userID, res) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + return updates, nil +} + +// recordChannelStateForUser 给该账号写一条 durable channel 状态事件: +// 离线设备经 difference 收到 updateChannel 后重拉 channel,发现 left/ +// forbidden 并移除会话;excludeCurrent 时当前 session 由 RPC 响应承担。 +func (r *Router) recordChannelStateForUser(ctx context.Context, userID, channelID int64, excludeCurrent bool) { + if r.deps.Updates == nil || userID == 0 || channelID == 0 { + return + } + authKeyID := [8]byte{} + excludeSessionID := int64(0) + if excludeCurrent { + authKeyID, _ = AuthKeyIDFrom(ctx) + excludeSessionID, _ = SessionIDFrom(ctx) + } + event, _, err := r.deps.Updates.RecordChannelState(ctx, authKeyID, userID, channelID, excludeSessionID) + if err != nil { + return + } + if excludeCurrent { + r.bookkeepAuxPtsForCurrentSession(ctx, event) + } +} + +func (r *Router) onChannelsEditAdmin(ctx context.Context, req *tg.ChannelsEditAdminRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + target, found, err := r.userFromInput(ctx, userID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found || target.ID == 0 { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.EditAdmin(ctx, userID, domain.EditChannelAdminRequest{ + UserID: userID, + ChannelID: channelID, + MemberID: target.ID, + AdminRights: domainChannelAdminRights(req.AdminRights), + Rank: req.Rank, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID) + if res.Participant.Status == domain.ChannelMemberActive { + r.addOnlineChannelMemberships(res.Channel.ID, res.Participant.UserID) + } else { + r.removeOnlineChannelMemberships(res.Channel.ID, res.Participant.UserID) + } + cache := newViewerPeerCache(r) + updates := r.channelParticipantUpdatesWithPeerCache(ctx, userID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache) + }) + return updates, nil +} + +func (r *Router) onChannelsEditBanned(ctx context.Context, req *tg.ChannelsEditBannedRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + participant, ok := r.domainPeerFromInputPeer(userID, req.Participant) + if !ok || participant.Type != domain.PeerTypeUser || participant.ID == 0 { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.EditBanned(ctx, userID, domain.EditChannelBannedRequest{ + UserID: userID, + ChannelID: channelID, + Participant: participant, + BannedRights: domainChannelBannedRights(req.BannedRights), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID) + if res.Participant.Status == domain.ChannelMemberKicked && res.Previous.Status == domain.ChannelMemberActive { + r.recordChannelStateForUser(ctx, res.Participant.UserID, res.Channel.ID, false) + } + cache := newViewerPeerCache(r) + build := func(viewerUserID int64) *tg.Updates { + updates := r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, userID, res.Channel, res.Previous, res.Participant, res.Date, cache) + if updates != nil && res.ServiceEvent.Pts != 0 { + // megagroup 踢人服务消息占 channel pts,必须先于 participant + // update 应用,让成员面板/人数与消息流一起收敛。 + if update := tgChannelUpdate(viewerUserID, res.ServiceEvent); update != nil { + updates.Updates = append([]tg.UpdateClass{update}, updates.Updates...) + } + } + return updates + } + updates := build(userID) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, build) + return updates, nil +} + +func (r *Router) onChannelsGetAdminLog(ctx context.Context, req *tg.ChannelsGetAdminLogRequest) (*tg.ChannelsAdminLogResults, error) { + if r.deps.Channels == nil { + return &tg.ChannelsAdminLogResults{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + adminIDs := []int64(nil) + if admins, ok := req.GetAdmins(); ok && len(admins) > 0 { + if len(admins) > domain.MaxChannelAdminLogAdmins { + return nil, limitInvalidErr() + } + adminIDs, err = r.userIDsFromInputUsers(ctx, userID, admins) + if err != nil { + return nil, err + } + } + res, err := r.deps.Channels.ListAdminLog(ctx, userID, domain.ChannelAdminLogRequest{ + UserID: userID, + ChannelID: channelID, + Query: req.Q, + AdminUserIDs: adminIDs, + MaxID: req.MaxID, + MinID: req.MinID, + Limit: req.Limit, + Filter: domainChannelAdminLogFilter(req), + }) + if err != nil { + return nil, channelAdminErr(err) + } + events := tgChannelAdminLogEvents(userID, res.Events) + chats := []tg.ChatClass{tgChannelChatMin(userID, res.Channel)} + users := r.channelAdminLogUsers(ctx, userID, res.Events) + r.applyStoryMaxIDsToPeerObjects(ctx, userID, users, chats) + return &tg.ChannelsAdminLogResults{ + Events: events, + Chats: chats, + Users: users, + }, nil +} + +func (r *Router) onMessagesGetAdminsWithInvites(ctx context.Context, peer tg.InputPeerClass) (*tg.MessagesChatAdminsWithInvites, error) { + userID, view, err := r.inviteManagementChannelView(ctx, peer) + if err != nil { + return nil, err + } + counts, err := r.deps.Channels.ListAdminsWithInvites(ctx, userID, view.Channel.ID) + if err != nil { + return nil, channelInviteErr(err) + } + admins := make([]tg.ChatAdminWithInvites, 0, len(counts)) + userIDs := make([]int64, 0, len(counts)) + for _, count := range counts { + admins = append(admins, tg.ChatAdminWithInvites{ + AdminID: count.AdminUserID, + InvitesCount: count.InvitesCount, + RevokedInvitesCount: count.RevokedInvitesCount, + }) + userIDs = append(userIDs, count.AdminUserID) + } + return &tg.MessagesChatAdminsWithInvites{ + Admins: admins, + Users: r.tgUsersForIDs(ctx, userID, userIDs), + }, nil +} + +func (r *Router) onMessagesHideChatJoinRequest(ctx context.Context, req *tg.MessagesHideChatJoinRequestRequest) (tg.UpdatesClass, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + targets, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.UserID}) + if err != nil { + return nil, err + } + if len(targets) == 0 { + return nil, userIDInvalidErr() + } + res, err := r.deps.Channels.HideChatJoinRequest(ctx, userID, domain.HideChannelJoinRequestRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + TargetUserID: targets[0], + Approved: req.Approved, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInviteErr(err) + } + r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID) + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) + return updates, nil +} + +func (r *Router) onMessagesHideAllChatJoinRequests(ctx context.Context, req *tg.MessagesHideAllChatJoinRequestsRequest) (tg.UpdatesClass, error) { + userID, view, err := r.inviteManagementChannelView(ctx, req.Peer) + if err != nil { + return nil, err + } + link, hasLink := req.GetLink() + if hasLink && len(link) > maxChatInviteLinkLength { + return nil, limitInvalidErr() + } + hash := "" + if hasLink && strings.TrimSpace(link) != "" { + hash, err = channelInviteHashFromLink(link) + if err != nil { + return nil, err + } + } + res, err := r.deps.Channels.HideAllChatJoinRequests(ctx, userID, domain.HideChannelJoinRequestsRequest{ + UserID: userID, + ChannelID: view.Channel.ID, + Hash: hash, + Approved: req.Approved, + Limit: domain.MaxChannelHideJoinRequests, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInviteErr(err) + } + r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID) + r.addOnlineChannelMemberships(res.Channel.ID, channelMemberUserIDs(res.Members)...) + updates := r.channelOperationUpdates(ctx, userID, res) + r.appendPendingJoinRequestsUpdate(ctx, userID, updates, res.Channel) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, res) + }) + r.pushPendingJoinRequestsToAdmins(ctx, res.Channel) + return updates, nil +} + +func canViewChannelJoinRequests(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) +} + +func (r *Router) pendingJoinRequestsUpdates(ctx context.Context, viewerUserID int64, channel domain.Channel) *tg.Updates { + if r.deps.Channels == nil || channel.ID == 0 { + return nil + } + pending, err := r.deps.Channels.PendingJoinRequests(ctx, channel.ID, domain.MaxChannelPendingJoinRecentRequesters) + if err != nil { + return nil + } + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePendingJoinRequests{ + Peer: &tg.PeerChannel{ChannelID: channel.ID}, + RequestsPending: pending.Count, + RecentRequesters: pending.RecentRequesters, + }}, + Users: r.tgUsersForIDs(ctx, viewerUserID, pending.RecentRequesters), + Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) appendPendingJoinRequestsUpdate(ctx context.Context, viewerUserID int64, updates *tg.Updates, channel domain.Channel) { + if updates == nil { + return + } + pending := r.pendingJoinRequestsUpdates(ctx, viewerUserID, channel) + if pending == nil { + return + } + updates.Updates = append(updates.Updates, pending.Updates...) + updates.Users = append(updates.Users, pending.Users...) +} + +func (r *Router) pushPendingJoinRequestsToAdmins(ctx context.Context, channel domain.Channel) { + if r.deps.Channels == nil || r.deps.Sessions == nil || channel.ID == 0 { + return + } + adminIDs, err := r.deps.Channels.InviteAdminMemberIDs(ctx, channel.ID, domain.MaxChannelRealtimeFanout) + if err != nil || len(adminIDs) == 0 { + adminIDs = []int64{channel.CreatorUserID} + } + seen := make(map[int64]struct{}, len(adminIDs)) + for _, adminID := range adminIDs { + if adminID == 0 { + continue + } + if _, ok := seen[adminID]; ok { + continue + } + seen[adminID] = struct{}{} + updates := r.pendingJoinRequestsUpdates(ctx, adminID, channel) + if updates == nil { + continue + } + r.pushUserUpdates(ctx, adminID, updates) + } +} + +func (r *Router) channelParticipantUpdates(ctx context.Context, viewerUserID, actorUserID int64, channel domain.Channel, previous, participant domain.ChannelMember, date int) *tg.Updates { + return r.channelParticipantUpdatesWithPeerCache(ctx, viewerUserID, actorUserID, channel, previous, participant, date, newViewerPeerCache(r)) +} + +func (r *Router) channelParticipantUpdatesWithPeerCache(ctx context.Context, viewerUserID, actorUserID int64, channel domain.Channel, previous, participant domain.ChannelMember, date int, cache *viewerPeerCache) *tg.Updates { + if cache == nil { + cache = newViewerPeerCache(r) + } + update := &tg.UpdateChannelParticipant{ + ChannelID: channel.ID, + Date: date, + ActorID: actorUserID, + UserID: participant.UserID, + } + if update.ActorID == 0 { + update.ActorID = viewerUserID + } + if previous.UserID != 0 { + update.SetPrevParticipant(tgChannelParticipantForUpdate(viewerUserID, previous)) + } + if participant.UserID != 0 { + update.SetNewParticipant(tgChannelParticipantForUpdate(viewerUserID, participant)) + } + // 当事成员必须收到完整 channel 投影:被踢/被封禁状态只有通过非 min + // 对象的 left/banned_rights 才会被客户端应用,min 形态会让被踢者 + // 永远不知道自己已离开会话。其它接收者用 min 保护各自本地权限。 + var chat tg.ChatClass + if viewerUserID != 0 && viewerUserID == participant.UserID { + self := participant + chat = tgChannelChat(viewerUserID, channel, &self) + } else { + chat = tgChannelChatMin(viewerUserID, channel) + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update, &tg.UpdateChannel{ChannelID: channel.ID}}, + Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, []int64{participant.UserID, participant.InviterUserID, previous.UserID, previous.InviterUserID, update.ActorID})), + Chats: []tg.ChatClass{chat}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func domainChannelAdminLogFilter(req *tg.ChannelsGetAdminLogRequest) domain.ChannelAdminLogFilter { + filter, ok := req.GetEventsFilter() + if !ok { + return domain.ChannelAdminLogFilter{} + } + return domain.ChannelAdminLogFilter{ + Join: filter.GetJoin(), + Leave: filter.GetLeave(), + Invite: filter.GetInvite(), + Ban: filter.GetBan(), + Unban: filter.GetUnban(), + Kick: filter.GetKick(), + Unkick: filter.GetUnkick(), + Promote: filter.GetPromote(), + Demote: filter.GetDemote(), + Info: filter.GetInfo(), + Settings: filter.GetSettings(), + Pinned: filter.GetPinned(), + Edit: filter.GetEdit(), + Delete: filter.GetDelete(), + Send: filter.GetSend(), + Invites: filter.GetInvites(), + Forums: filter.GetForums(), + SubExtend: filter.GetSubExtend(), + EditRank: filter.GetEditRank(), + } +} + +func (r *Router) channelAdminLogUsers(ctx context.Context, currentUserID int64, events []domain.ChannelAdminLogEvent) []tg.UserClass { + if r.deps.Users == nil || len(events) == 0 { + return nil + } + ids := make(map[int64]struct{}, len(events)) + add := func(id int64) { + if id != 0 { + ids[id] = struct{}{} + } + } + addMember := func(member *domain.ChannelMember) { + if member != nil { + add(member.UserID) + add(member.InviterUserID) + } + } + addMessage := func(msg *domain.ChannelMessage) { + if msg != nil { + add(msg.SenderUserID) + if msg.From.Type == domain.PeerTypeUser { + add(msg.From.ID) + } + } + } + for _, event := range events { + add(event.UserID) + addMember(event.PrevParticipant) + addMember(event.NewParticipant) + addMember(event.Participant) + addMessage(event.Message) + addMessage(event.PrevMessage) + addMessage(event.NewMessage) + } + userIDs := make([]int64, 0, len(ids)) + for id := range ids { + userIDs = append(userIDs, id) + } + sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] }) + return r.tgUsersForIDs(ctx, currentUserID, userIDs) +} + +func domainChannelParticipantsFilter(filter tg.ChannelParticipantsFilterClass) domain.ChannelParticipantsFilter { + switch f := filter.(type) { + case *tg.ChannelParticipantsAdmins: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins} + case *tg.ChannelParticipantsKicked: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsKicked, Query: f.Q} + case *tg.ChannelParticipantsBanned: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBanned, Query: f.Q} + case *tg.ChannelParticipantsSearch: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsSearch, Query: f.Q} + case *tg.ChannelParticipantsBots: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsBots} + case *tg.ChannelParticipantsContacts: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsContacts, Query: f.Q} + case *tg.ChannelParticipantsMentions: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsMentions, Query: f.Q} + default: + return domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent} + } +} + +func channelAdminErr(err error) error { + switch { + case errors.Is(err, domain.ErrChannelNotModified): + return tgerr400("CHAT_NOT_MODIFIED") + case errors.Is(err, domain.ErrChatPublicRequired): + return tgerr400("CHAT_PUBLIC_REQUIRED") + case errors.Is(err, domain.ErrChatDiscussionUnallowed): + return tgerr400("CHAT_DISCUSSION_UNALLOWED") + case errors.Is(err, domain.ErrChannelRightForbidden): + return tgerr.New(403, "RIGHT_FORBIDDEN") + case errors.Is(err, domain.ErrChannelUserCreator): + return tgerr400("USER_CREATOR") + case errors.Is(err, domain.ErrUserNotParticipant): + return tgerr400("USER_NOT_PARTICIPANT") + case errors.Is(err, domain.ErrMegagroupIDInvalid): + return tgerr400("MEGAGROUP_ID_INVALID") + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + default: + return channelInvalidErr(err) + } +} diff --git a/internal/rpc/channels_messages.go b/internal/rpc/channels_messages.go new file mode 100644 index 00000000..79658080 --- /dev/null +++ b/internal/rpc/channels_messages.go @@ -0,0 +1,408 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "sort" + "strconv" + "strings" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onChannelsExportMessageLink(ctx context.Context, req *tg.ChannelsExportMessageLinkRequest) (*tg.ExportedMessageLink, error) { + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + userID, view, err := r.channelView(ctx, req.Channel) + if err != nil { + return nil, err + } + history, err := r.deps.Channels.GetMessages(ctx, userID, view.Channel.ID, []int{req.ID}) + if err != nil { + return nil, channelInvalidErr(err) + } + if len(history.Messages) != 1 || history.Messages[0].ID != req.ID { + return nil, messageIDInvalidErr() + } + link := "" + if view.Channel.Username != "" { + link = "https://telesrv.net/" + view.Channel.Username + "/" + strconv.Itoa(req.ID) + } else { + link = "https://telesrv.net/c/" + strconv.FormatInt(view.Channel.ID, 10) + "/" + strconv.Itoa(req.ID) + } + if req.Thread { + if rootID := channelMessageThreadRootID(history.Messages[0]); rootID > 0 && rootID != req.ID { + link += "?thread=" + strconv.Itoa(rootID) + } + } + return &tg.ExportedMessageLink{Link: link, HTML: ""}, nil +} + +func channelMessageThreadRootID(msg domain.ChannelMessage) int { + if msg.ReplyTo == nil { + return 0 + } + if msg.ReplyTo.TopMessageID > 0 { + return msg.ReplyTo.TopMessageID + } + return msg.ReplyTo.MessageID +} + +func readChannelMessageContentIDs(messages []domain.ChannelMessage) []int { + if len(messages) == 0 { + return nil + } + ids := make([]int, 0, len(messages)) + seen := make(map[int]struct{}, len(messages)) + for _, msg := range messages { + if msg.ID <= 0 { + continue + } + if _, ok := seen[msg.ID]; ok { + continue + } + seen[msg.ID] = struct{}{} + ids = append(ids, msg.ID) + } + sort.Ints(ids) + return ids +} + +func (r *Router) onChannelsSearchPosts(ctx context.Context, req *tg.ChannelsSearchPostsRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateChannelSearchPostsRequest(req); err != nil { + return nil, err + } + offsetChannelID, err := r.searchPostsOffsetChannelID(ctx, userID, req.OffsetPeer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return &tg.MessagesMessages{Messages: []tg.MessageClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil + } + hashtag, query := channelSearchPostsTerms(req) + history, err := r.deps.Channels.SearchPosts(ctx, userID, domain.ChannelSearchPostsRequest{ + Hashtag: hashtag, + Query: query, + OffsetRate: req.OffsetRate, + OffsetChannelID: offsetChannelID, + OffsetID: req.OffsetID, + Limit: req.Limit, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + history = r.enrichChannelHistory(ctx, userID, history) + return tgChannelSearchPostsMessages(userID, history), nil +} + +func validateChannelSearchPostsRequest(req *tg.ChannelsSearchPostsRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if req.Limit < 0 || req.Limit > maxChannelSearchPostsLimit { + return limitInvalidErr() + } + if req.OffsetRate < 0 { + return limitInvalidErr() + } + if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID { + return messageIDInvalidErr() + } + if req.AllowPaidStars < 0 { + return limitInvalidErr() + } + hashtag, hasHashtag, query, hasQuery := channelSearchPostsTermsWithFlags(req) + if hasHashtag == hasQuery { + return searchQueryEmptyErr() + } + if hasHashtag { + if strings.TrimSpace(hashtag) == "" { + return searchQueryEmptyErr() + } + if strings.Contains(hashtag, "#") || utf8.RuneCountInString(hashtag) > maxChannelSearchPostsQuery { + return limitInvalidErr() + } + } + if hasQuery { + if strings.TrimSpace(query) == "" { + return searchQueryEmptyErr() + } + if utf8.RuneCountInString(query) > maxChannelSearchPostsQuery { + return limitInvalidErr() + } + } + return nil +} + +func channelSearchPostsTerms(req *tg.ChannelsSearchPostsRequest) (hashtag, query string) { + hashtag, _, query, _ = channelSearchPostsTermsWithFlags(req) + return strings.TrimSpace(hashtag), strings.TrimSpace(query) +} + +func channelSearchPostsTermsWithFlags(req *tg.ChannelsSearchPostsRequest) (hashtag string, hasHashtag bool, query string, hasQuery bool) { + hashtag, hasHashtag = req.GetHashtag() + if !hasHashtag && req.Hashtag != "" { + hashtag, hasHashtag = req.Hashtag, true + } + query, hasQuery = req.GetQuery() + if !hasQuery && req.Query != "" { + query, hasQuery = req.Query, true + } + return hashtag, hasHashtag, query, hasQuery +} + +func (r *Router) searchPostsOffsetChannelID(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) { + if peer == nil { + return 0, nil + } + if _, ok := peer.(*tg.InputPeerEmpty); ok { + return 0, nil + } + out, ok := r.domainPeerFromInputPeer(userID, peer) + if !ok || out.ID == 0 { + return 0, peerIDInvalidErr() + } + if out.Type != domain.PeerTypeChannel { + return 0, peerIDInvalidErr() + } + ref, ok := inputPeerChannelRef(peer) + if !ok || !ref.CheckAccessHash || r.deps.Channels == nil { + return out.ID, nil + } + view, err := r.deps.Channels.GetChannel(ctx, userID, out.ID) + if err == nil { + if !inputChannelAccessHashMatches(ref, view.Channel) { + return 0, channelInvalidErr(domain.ErrChannelPrivate) + } + return out.ID, nil + } + if !errors.Is(err, domain.ErrChannelPrivate) { + return 0, channelInvalidErr(err) + } + channel, joinErr := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID) + if joinErr != nil || channel.Username == "" || !inputChannelAccessHashMatches(ref, channel) { + return 0, channelInvalidErr(err) + } + return out.ID, nil +} + +func tgChannelSearchPostsMessages(viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass { + messages := make([]tg.MessageClass, 0, len(history.Messages)) + for _, msg := range history.Messages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + chats := tgChannels(viewerUserID, history.Channels) + users := tgUsersForViewer(viewerUserID, history.Users) // viewer 自己的帖子作者须带 self 标志 + if history.Count > len(messages) { + out := &tg.MessagesMessagesSlice{ + Count: history.Count, + Messages: messages, + Topics: []tg.ForumTopicClass{}, + Chats: chats, + Users: users, + } + if len(history.Messages) > 0 { + out.SetNextRate(history.Messages[len(history.Messages)-1].Date) + } + out.SetSearchFlood(tg.SearchPostsFlood{QueryIsFree: true, TotalDaily: 100, Remains: 100, StarsAmount: 0}) + return out + } + return &tg.MessagesMessages{Messages: messages, Topics: []tg.ForumTopicClass{}, Chats: chats, Users: users} +} + +func (r *Router) onChannelsCheckSearchPostsFlood(ctx context.Context, req *tg.ChannelsCheckSearchPostsFloodRequest) (*tg.SearchPostsFlood, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if err := validateChannelCheckSearchPostsFloodRequest(req); err != nil { + return nil, err + } + return &tg.SearchPostsFlood{QueryIsFree: true, TotalDaily: 100, Remains: 100, StarsAmount: 0}, nil +} + +func validateChannelCheckSearchPostsFloodRequest(req *tg.ChannelsCheckSearchPostsFloodRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + query, hasQuery := req.GetQuery() + if !hasQuery && req.Query != "" { + query, hasQuery = req.Query, true + } + if !hasQuery || strings.TrimSpace(query) == "" { + return searchQueryEmptyErr() + } + if utf8.RuneCountInString(query) > maxChannelSearchPostsQuery { + return limitInvalidErr() + } + return nil +} + +func (r *Router) onChannelsGetMessages(ctx context.Context, req *tg.ChannelsGetMessagesRequest) (tg.MessagesMessagesClass, error) { + if len(req.ID) > domain.MaxGetMessageIDs { + return nil, limitInvalidErr() + } + if r.deps.Channels == nil || len(req.ID) == 0 { + return &tg.MessagesMessages{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + ids := make([]int, 0, len(req.ID)) + for _, input := range req.ID { + id, ok := inputMessageBoxID(input) + if !ok || id <= 0 || id > domain.MaxMessageBoxID { + continue + } + ids = append(ids, id) + } + if len(ids) == 0 { + return &tg.MessagesMessages{}, nil + } + history, err := r.deps.Channels.GetMessages(ctx, userID, channelID, ids) + if err != nil { + return nil, channelInvalidErr(err) + } + history = r.enrichChannelHistory(ctx, userID, history) + byID := make(map[int]domain.ChannelMessage, len(history.Messages)) + for _, msg := range history.Messages { + byID[msg.ID] = msg + } + messages := make([]tg.MessageClass, 0, len(ids)) + for _, id := range ids { + if msg, ok := byID[id]; ok { + messages = append(messages, tgChannelMessage(userID, msg)) + } else { + messages = append(messages, &tg.MessageEmpty{ID: id}) + } + } + return &tg.MessagesMessages{ + Messages: messages, + Chats: tgChannels(userID, []domain.Channel{history.Channel}), + Users: r.tgUsersForViewer(userID, history.Users), // viewer 补拉自己的消息(含置顶)须带 self + }, nil +} + +func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsDeleteMessagesRequest) (*tg.MessagesAffectedMessages, error) { + if len(req.ID) == 0 { + return &tg.MessagesAffectedMessages{PtsCount: 0}, nil + } + if len(req.ID) > domain.MaxDeleteMessageIDs { + return nil, limitInvalidErr() + } + for _, id := range req.ID { + if id <= 0 || id > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + } + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.DeleteMessages(ctx, userID, domain.DeleteChannelMessagesRequest{ + UserID: userID, + ChannelID: channelID, + IDs: append([]int(nil), req.ID...), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelDeleteErr(err) + } + if res.Event.Pts != 0 { + // 删除 fan-out 异步化(设计 Phase 0)。channelDeleteMessagesUpdates 是纯 CPU 构建 + // (不碰 PG、不取 ctx),async 无竞态;同 channel 串行保 pts 单调。 + r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { + return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) + }) + // 被删 broadcast post 的讨论组转发根级联删除同样要让讨论组成员收敛。 + for _, cascade := range res.DiscussionDeletes { + cascade := cascade + r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, cascade.Channel.ID, cascade.Event.Pts, cascade.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { + return r.channelDeleteMessagesUpdates(viewerUserID, cascade.Channel, cascade.Event) + }) + } + return &tg.MessagesAffectedMessages{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount}, nil + } + return &tg.MessagesAffectedMessages{Pts: res.Channel.Pts, PtsCount: 0}, nil +} + +func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDeleteHistoryRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + res, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{ + UserID: userID, + ChannelID: channelID, + MaxID: req.MaxID, + ForEveryone: req.GetForEveryone(), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelDeleteErr(err) + } + if res.Event.Pts == 0 { + event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID) + updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID) + updates.Updates = appendAuxPtsBookkeeping(updates.Updates, event) + r.pushUserUpdates(ctx, userID, updates) + return updates, nil + } + pushBatch := func(batch domain.DeleteChannelHistoryResult) *tg.Updates { + out := r.channelDeleteMessagesUpdates(userID, batch.Channel, batch.Event) + // 每批 fan-out 异步化;批次按 pts 递增顺序入同一 channel 分片 → FIFO 保单调。 + r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, batch.Channel.ID, batch.Event.Pts, batch.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { + return r.channelDeleteMessagesUpdates(viewerUserID, batch.Channel, batch.Event) + }) + return out + } + updates := pushBatch(res) + // channels.deleteHistory 返回 Updates,TL 层没有 affectedHistory.offset + // 续删协议,客户端只发一次请求;超过单批上限的剩余历史必须由服务端 + // 在本次请求内删完,否则"清空"后旧消息会残留。每批独立事务推进 pts + // 并立即推送,中途失败时已删批次保持有效,返回最后成功批次的结果。 + for res.Offset != 0 && ctx.Err() == nil { + next, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{ + UserID: userID, + ChannelID: channelID, + MaxID: req.MaxID, + ForEveryone: req.GetForEveryone(), + Date: int(r.clock.Now().Unix()), + }) + if err != nil || next.Event.Pts == 0 { + break + } + res = next + updates = pushBatch(res) + } + return updates, nil +} diff --git a/internal/rpc/channels_messages_rpc_test.go b/internal/rpc/channels_messages_rpc_test.go new file mode 100644 index 00000000..d7d20f36 --- /dev/null +++ b/internal/rpc/channels_messages_rpc_test.go @@ -0,0 +1,937 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strconv" + "strings" + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appupdates "telesrv/internal/app/updates" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" + "time" +) + +func TestChannelsDeleteChannelReturnsForbiddenChatAndHidesDialogRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550001031", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Delete Me", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := invited.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + getDialogs := func(userID int64) *tg.MessagesDialogs { + t.Helper() + req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20} + var b bin.Buffer + if err := req.Encode(&b); err != nil { + t.Fatalf("encode get dialogs: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch get dialogs: %v", err) + } + box, ok := enc.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("dialogs response = %T, want box", enc) + } + dialogs, ok := box.Dialogs.(*tg.MessagesDialogs) + if !ok { + t.Fatalf("dialogs = %T %+v, want messages.dialogs", box.Dialogs, box.Dialogs) + } + return dialogs + } + if got := getDialogs(owner.ID); len(got.Dialogs) != 1 || len(got.Chats) != 1 { + t.Fatalf("dialogs before delete = %+v, want one channel dialog", got) + } + + deleted, err := r.onChannelsDeleteChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ + ChannelID: channel.ID, + AccessHash: channel.AccessHash, + }) + if err != nil { + t.Fatalf("delete channel: %v", err) + } + updates, ok := deleted.(*tg.Updates) + if !ok || len(updates.Updates) != 1 || len(updates.Chats) != 1 { + t.Fatalf("delete response = %T %+v, want updateChannel + channelForbidden", deleted, deleted) + } + if update, ok := updates.Updates[0].(*tg.UpdateChannel); !ok || update.ChannelID != channel.ID { + t.Fatalf("delete update = %#v, want updateChannel %d", updates.Updates[0], channel.ID) + } + forbidden, ok := updates.Chats[0].(*tg.ChannelForbidden) + if !ok || forbidden.ID != channel.ID || forbidden.AccessHash != channel.AccessHash || forbidden.Title != channel.Title || !forbidden.Megagroup { + t.Fatalf("delete chat = %#v, want channelForbidden tombstone", updates.Chats[0]) + } + + pushed := sessions.snapshot() + if pushed.messageType != proto.MessageFromServer || pushed.userID == 0 { + t.Fatalf("push snapshot = %+v, want server update to a channel member", pushed) + } + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok || len(pushedUpdates.Chats) != 1 { + t.Fatalf("pushed update = %T %+v, want channelForbidden chat", pushed.message, pushed.message) + } + if pushedForbidden, ok := pushedUpdates.Chats[0].(*tg.ChannelForbidden); !ok || pushedForbidden.ID != channel.ID { + t.Fatalf("pushed chat = %#v, want channelForbidden %d", pushedUpdates.Chats[0], channel.ID) + } + if got := getDialogs(owner.ID); len(got.Dialogs) != 0 || len(got.Chats) != 0 { + t.Fatalf("owner dialogs after delete = %+v, want hidden deleted channel", got) + } + if got := getDialogs(friend.ID); len(got.Dialogs) != 0 || len(got.Chats) != 0 { + t.Fatalf("friend dialogs after delete = %+v, want hidden deleted channel", got) + } +} + +func TestChannelGetMessagesReturnsSparseIDs(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002101", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002102", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Sessions: &captureSessions{}, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Sparse RPC Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + var firstID, lastID int + for i := 0; i < 5; i++ { + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "sparse-" + strconv.Itoa(i), + RandomID: int64(100 + i), + }) + if err != nil { + t.Fatalf("send channel message %d: %v", i, err) + } + msg := updates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if i == 0 { + firstID = msg.ID + } + lastID = msg.ID + } + + got, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []tg.InputMessageClass{ + &tg.InputMessageID{ID: firstID}, + &tg.InputMessageID{ID: lastID}, + }, + }) + if err != nil { + t.Fatalf("get sparse channel messages: %v", err) + } + messages := got.(*tg.MessagesMessages).Messages + if len(messages) != 2 { + t.Fatalf("messages len = %d, want 2", len(messages)) + } + first := messages[0].(*tg.Message) + last := messages[1].(*tg.Message) + if first.ID != firstID || first.Message != "sparse-0" || last.ID != lastID || last.Message != "sparse-4" { + t.Fatalf("sparse messages = %#v %#v, want first and last exact ids", first, last) + } +} + +func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002141", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002142", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Updates: updateSvc, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Clear Channel", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "clear me", + RandomID: 401, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MaxID: msg.ID, + }) + if err != nil { + t.Fatalf("delete channel history local: %v", err) + } + updates, ok := cleared.(*tg.Updates) + if !ok || len(updates.Updates) != 2 { + t.Fatalf("clear response = %T %+v, want available update plus pts bookkeeping", cleared, cleared) + } + available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages) + if !ok || available.ChannelID != channel.ID || available.AvailableMinID != msg.ID { + t.Fatalf("clear update = %#v, want updateChannelAvailableMessages channel=%d min=%d", updates.Updates[0], channel.ID, msg.ID) + } + // updateChannelAvailableMessages 不带账号 pts,事件占用的 pts 槽位 + // 必须用空 updateDeleteMessages 显式同步给客户端。 + bookkeeping, ok := updates.Updates[1].(*tg.UpdateDeleteMessages) + if !ok || len(bookkeeping.Messages) != 0 || bookkeeping.Pts <= 0 || bookkeeping.PtsCount != 1 { + t.Fatalf("clear bookkeeping = %#v, want empty updateDeleteMessages carrying the account pts step", updates.Updates[1]) + } + pushed := sessions.snapshot() + if pushed.userID != owner.ID { + t.Fatalf("pushed user = %d, want owner %d", pushed.userID, owner.ID) + } + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok || len(pushedUpdates.Updates) != 2 { + t.Fatalf("pushed clear update = %T %+v, want available update plus pts bookkeeping", pushed.message, pushed.message) + } + if _, ok := pushedUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages); !ok { + t.Fatalf("pushed update[0] = %T, want updateChannelAvailableMessages", pushedUpdates.Updates[0]) + } + diff, err := r.onUpdatesGetDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference: %v", err) + } + full, ok := diff.(*tg.UpdatesDifference) + if !ok || len(full.OtherUpdates) != 1 { + t.Fatalf("difference = %T %+v, want one other update", diff, diff) + } + if diffUpdate, ok := full.OtherUpdates[0].(*tg.UpdateChannelAvailableMessages); !ok || diffUpdate.ChannelID != channel.ID || diffUpdate.AvailableMinID != msg.ID { + t.Fatalf("difference update = %#v, want updateChannelAvailableMessages", full.OtherUpdates[0]) + } + + stale, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MaxID: msg.ID - 1, + }) + if err != nil { + t.Fatalf("stale delete channel history local: %v", err) + } + staleUpdates, ok := stale.(*tg.Updates) + if !ok || len(staleUpdates.Updates) != 2 { + t.Fatalf("stale clear response = %T %+v, want monotonic update plus pts bookkeeping", stale, stale) + } + staleAvailable, ok := staleUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages) + if !ok || staleAvailable.ChannelID != channel.ID || staleAvailable.AvailableMinID != msg.ID { + t.Fatalf("stale clear update = %#v, want monotonic updateChannelAvailableMessages channel=%d min=%d", staleUpdates.Updates[0], channel.ID, msg.ID) + } +} + +func TestChannelDeleteRejectsInvalidMessageIDsRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 43, Phone: "15550002143", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 44, Phone: "15550002144", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Invalid Delete IDs", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + for _, maxID := range []int{-1, domain.MaxMessageBoxID + 1} { + if _, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{Channel: input, MaxID: maxID}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("channels.deleteHistory max_id=%d err = %v, want MESSAGE_ID_INVALID", maxID, err) + } + if _, err := r.onMessagesDeleteHistory(WithUserID(ctx, owner.ID), &tg.MessagesDeleteHistoryRequest{Peer: peer, MaxID: maxID}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.deleteHistory channel max_id=%d err = %v, want MESSAGE_ID_INVALID", maxID, err) + } + } + if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{Channel: input, ID: []int{domain.MaxMessageBoxID + 1}}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("channels.deleteMessages huge id err = %v, want MESSAGE_ID_INVALID", err) + } + tooMany := make([]int, domain.MaxDeleteMessageIDs+1) + for i := range tooMany { + tooMany[i] = i + 1 + } + if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{Channel: input, ID: tooMany}); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("channels.deleteMessages too many ids err = %v, want LIMIT_INVALID", err) + } +} + +func TestChannelsDeleteHistoryForEveryoneDrainsBatchesAndKeepsDialogVisible(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 45, Phone: "15550002145", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 46, Phone: "15550002146", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Drain Clear", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + total := domain.MaxDeleteHistoryBatch + 1 + for i := 0; i < total; i++ { + if _, err := channelStore.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channel.ID, + RandomID: int64(50_000 + i), + Message: "drain", + Date: 1_700_000_400 + i, + }); err != nil { + t.Fatalf("seed channel message %d: %v", i, err) + } + } + pushedBefore := len(sessions.pushedUserIDs()) + + clearReq := &tg.ChannelsDeleteHistoryRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + } + clearReq.SetForEveryone(true) + cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), clearReq) + if err != nil { + t.Fatalf("delete channel history for everyone: %v", err) + } + updates, ok := cleared.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("clear response = %T %+v, want final delete batch updates", cleared, cleared) + } + // 最后一批删除 invite 服务消息与剩余文本;id=1 建群服务消息保留为 + // 会话兜底 top message,不进入删除批次。 + deleteUpdate, ok := updates.Updates[0].(*tg.UpdateDeleteChannelMessages) + if !ok || deleteUpdate.ChannelID != channel.ID || deleteUpdate.PtsCount != len(deleteUpdate.Messages) || len(deleteUpdate.Messages) == 0 { + t.Fatalf("final batch update = %#v, want trailing delete batch sparing the creation service message", updates.Updates[0]) + } + for _, id := range deleteUpdate.Messages { + if id <= 1 { + t.Fatalf("final batch deleted id %d, creation service message must be spared", id) + } + } + // 两批(1000+1)删除,每批向 owner 和 friend 各推送一次。 + if pushedAfter := len(sessions.pushedUserIDs()); pushedAfter-pushedBefore != 4 { + t.Fatalf("pushed fanout count = %d, want 4 (two batches to both members)", pushedAfter-pushedBefore) + } + history, err := channelStore.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ChannelID: channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("list history after clear: %v", err) + } + if len(history.Messages) != 1 || history.Messages[0].ID != 1 || history.Messages[0].Action == nil { + t.Fatalf("history after clear = %+v, want only create service message", history.Messages) + } + getDialogs := func(userID int64) *tg.MessagesDialogs { + t.Helper() + req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20} + var b bin.Buffer + if err := req.Encode(&b); err != nil { + t.Fatalf("encode get dialogs: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch get dialogs: %v", err) + } + dialogs, ok := enc.(*tg.MessagesDialogsBox).Dialogs.(*tg.MessagesDialogs) + if !ok { + t.Fatalf("dialogs response = %T, want messages.dialogs", enc) + } + return dialogs + } + for _, viewer := range []int64{owner.ID, friend.ID} { + got := getDialogs(viewer) + if len(got.Dialogs) != 1 || len(got.Chats) != 1 { + t.Fatalf("dialogs for %d after clear = %+v, want channel dialog kept visible", viewer, got) + } + dialog := got.Dialogs[0].(*tg.Dialog) + if dialog.TopMessage != 1 { + t.Fatalf("dialog top for %d = %d, want create service message 1", viewer, dialog.TopMessage) + } + if len(got.Messages) != 1 { + t.Fatalf("dialog top messages for %d = %+v, want create service message attached", viewer, got.Messages) + } + if _, ok := got.Messages[0].(*tg.MessageService); !ok { + t.Fatalf("dialog top message for %d = %T, want messageService", viewer, got.Messages[0]) + } + } + + againReq := &tg.ChannelsDeleteHistoryRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + } + againReq.SetForEveryone(true) + again, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), againReq) + if err != nil { + t.Fatalf("repeat delete channel history: %v", err) + } + if repeat, ok := again.(*tg.Updates); !ok || len(repeat.Updates) != 0 { + t.Fatalf("repeat clear response = %T %+v, want empty idempotent updates", again, again) + } +} + +func TestBroadcastChannelPostRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550002171", FirstName: "Owner"}) + member, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550002172", FirstName: "Member"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "RPC Broadcast", + About: "news", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + if !channel.Broadcast || channel.Megagroup { + t.Fatalf("channel flags = broadcast:%v megagroup:%v, want broadcast only", channel.Broadcast, channel.Megagroup) + } + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Users: []tg.InputUserClass{&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}}, + }); err != nil { + t.Fatalf("invite member to broadcast: %v", err) + } + posted, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "broadcast post", + RandomID: 7071, + }) + if err != nil { + t.Fatalf("owner send broadcast post: %v", err) + } + postUpdate := posted.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage) + postMsg := postUpdate.Message.(*tg.Message) + if !postMsg.Post || postMsg.FromID != nil || postMsg.Message != "broadcast post" { + t.Fatalf("broadcast post message = %#v, want post without from_id", postMsg) + } + if _, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "member post", + RandomID: 7072, + }); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") { + t.Fatalf("member broadcast send err = %v, want CHAT_WRITE_FORBIDDEN", err) + } + if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}, + AdminRights: tg.ChatAdminRights{ChangeInfo: true}, + }); err != nil { + t.Fatalf("promote member without post_messages: %v", err) + } + if _, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "admin without post right", + RandomID: 7073, + }); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") { + t.Fatalf("admin without post right send err = %v, want CHAT_WRITE_FORBIDDEN", err) + } + if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}, + AdminRights: tg.ChatAdminRights{PostMessages: true}, + }); err != nil { + t.Fatalf("grant member post_messages: %v", err) + } + adminPosted, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "admin post", + RandomID: 7074, + }) + if err != nil { + t.Fatalf("admin send broadcast post: %v", err) + } + adminPostMsg := adminPosted.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if !adminPostMsg.Post || adminPostMsg.FromID != nil || adminPostMsg.Message != "admin post" { + t.Fatalf("admin broadcast post message = %#v, want post without from_id", adminPostMsg) + } + diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, member.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("member channel difference: %v", err) + } + fullDiff := diff.(*tg.UpdatesChannelDifference) + foundPost := false + for _, msg := range fullDiff.NewMessages { + if item, ok := msg.(*tg.Message); ok && item.Message == "broadcast post" { + foundPost = item.Post && item.FromID == nil + } + } + if !foundPost { + t.Fatalf("diff new messages = %+v, want broadcast post without from_id", fullDiff.NewMessages) + } +} + +func TestChannelSendMessageRPCResolvesReplyHeader(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002031", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002032", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC Reply Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + rootUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "root", + RandomID: 3001, + }) + if err != nil { + t.Fatalf("send root: %v", err) + } + rootMsg := rootUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + replyTo := &tg.InputReplyToMessage{ReplyToMsgID: rootMsg.ID} + replyTo.SetQuoteText("root") + replyTo.SetQuoteOffset(0) + replyReq := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "reply", + RandomID: 3002, + } + replyReq.SetReplyTo(replyTo) + replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, friend.ID), replyReq) + if err != nil { + t.Fatalf("send reply: %v", err) + } + replyMsg := replyUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + header, ok := replyMsg.ReplyTo.(*tg.MessageReplyHeader) + if !ok { + t.Fatalf("reply header = %#v, want messageReplyHeader", replyMsg.ReplyTo) + } + topID, topOK := header.GetReplyToTopID() + quoteText, quoteOK := header.GetQuoteText() + if header.ReplyToMsgID != rootMsg.ID || !topOK || topID != rootMsg.ID || !quoteOK || quoteText != "root" { + t.Fatalf("reply header = %#v, want msg/top %d and quote", header, rootMsg.ID) + } + badReq := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "bad", + RandomID: 3003, + } + badReq.SetReplyTo(&tg.InputReplyToMessage{ReplyToMsgID: 999}) + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), badReq); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("bad reply err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } +} + +func TestChannelBoostUnlocksDefaultSendRestrictionRPC(t *testing.T) { + f := newRPCChannelFixture(t) + r := f.router + owner := f.user(9101, "15550009101", "Owner") + member := f.user(9102, "15550009102", "Member") + channel := f.createLegacyMegagroup(owner, "Boost Gate", member) + ownerCtx := f.userCtx(owner) + memberCtx := f.userCtx(member) + + if _, err := r.onMessagesEditChatDefaultBannedRights(ownerCtx, &tg.MessagesEditChatDefaultBannedRightsRequest{ + Peer: inputPeerChannel(channel), + BannedRights: tg.ChatBannedRights{ + SendMessages: true, + }, + }); err != nil { + t.Fatalf("edit default banned rights: %v", err) + } + if _, err := r.onMessagesSendMessage(memberCtx, &tg.MessagesSendMessageRequest{ + Peer: inputPeerChannel(channel), + Message: "blocked", + RandomID: 9102001, + }); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") { + t.Fatalf("member send before boost err = %v, want CHAT_WRITE_FORBIDDEN", err) + } + if _, err := r.onChannelsSetBoostsToUnblockRestrictions(ownerCtx, &tg.ChannelsSetBoostsToUnblockRestrictionsRequest{ + Channel: inputChannel(channel), + Boosts: 1, + }); err != nil { + t.Fatalf("set boosts to unblock: %v", err) + } + if _, err := f.users.SetPremiumUntil(f.ctx, member.ID, int(time.Now().Add(time.Hour).Unix())); err != nil { + t.Fatalf("grant member premium: %v", err) + } + applyReq := &tg.PremiumApplyBoostRequest{Peer: inputPeerChannel(channel)} + applyReq.SetSlots([]int{domain.DefaultPremiumBoostSlotID}) + if _, err := r.onPremiumApplyBoost(memberCtx, applyReq); err != nil { + t.Fatalf("apply member boost: %v", err) + } + full, err := r.onChannelsGetFullChannel(memberCtx, inputChannel(channel)) + if err != nil { + t.Fatalf("get full channel after boost: %v", err) + } + channelFull := full.FullChat.(*tg.ChannelFull) + if boosts, ok := channelFull.GetBoostsApplied(); !ok || boosts != 1 { + t.Fatalf("full channel boosts_applied = %d ok %v, want 1", boosts, ok) + } + if threshold, ok := channelFull.GetBoostsUnrestrict(); !ok || threshold != 1 { + t.Fatalf("full channel boosts_unrestrict = %d ok %v, want 1", threshold, ok) + } + sent, err := r.onMessagesSendMessage(memberCtx, &tg.MessagesSendMessageRequest{ + Peer: inputPeerChannel(channel), + Message: "boosted ok", + RandomID: 9102002, + }) + if err != nil { + t.Fatalf("member send after boost: %v", err) + } + msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if applied, ok := msg.GetFromBoostsApplied(); !ok || applied != 1 { + t.Fatalf("message from_boosts_applied = %d ok %v, want 1", applied, ok) + } +} + +func TestChannelSendAsCurrentChannelRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550002111", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550002112", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Send As Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + sendAs := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "as channel", + RandomID: 501, + SendAs: sendAs, + }) + if err != nil { + t.Fatalf("send as current channel: %v", err) + } + sentUpdates := sent.(*tg.Updates) + sentMsg := sentUpdates.Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := sentMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("send_as message from = %#v, want channel %d", sentMsg.FromID, channel.ID) + } + + history, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []tg.InputMessageClass{&tg.InputMessageID{ID: sentMsg.ID}}, + }) + if err != nil { + t.Fatalf("get send_as history: %v", err) + } + historyMsg := history.(*tg.MessagesMessages).Messages[0].(*tg.Message) + if from, ok := historyMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("history send_as from = %#v, want channel %d", historyMsg.FromID, channel.ID) + } + + forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{sentMsg.ID}, + RandomID: []int64{502}, + SendAs: sendAs, + }) + if err != nil { + t.Fatalf("forward as current channel: %v", err) + } + forwardMsg := forwarded.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := forwardMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("forward send_as from = %#v, want channel %d", forwardMsg.FromID, channel.ID) + } + + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: "bad private send_as", + RandomID: 503, + SendAs: sendAs, + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("private send_as err = %v, want SEND_AS_PEER_INVALID", err) + } + if _, err := r.onMessagesSendMessage(WithUserID(ctx, friend.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "bad member send_as", + RandomID: 504, + SendAs: sendAs, + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("member send_as err = %v, want SEND_AS_PEER_INVALID", err) + } + if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ToPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + ID: []int{sentMsg.ID}, + RandomID: []int64{505}, + SendAs: sendAs, + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("private forward send_as err = %v, want SEND_AS_PEER_INVALID", err) + } +} + +func TestChannelsSearchPostsReturnsPublicPostsWithSeekPaging(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91001, Phone: "15550091001", FirstName: "Owner"}) + viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 91002, Phone: "15550091002", FirstName: "Viewer"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), clock.System) + public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Public Search", + Broadcast: true, + Date: 1700010000, + }) + if err != nil { + t.Fatalf("create public channel: %v", err) + } + if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: public.Channel.ID, + Username: "public_search_posts", + }); err != nil { + t.Fatalf("publish channel username: %v", err) + } + private, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Private Search", + Broadcast: true, + Date: 1700010001, + }) + if err != nil { + t.Fatalf("create private channel: %v", err) + } + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: public.Channel.ID, + RandomID: 101, + Message: "launch alpha #ops", + Date: 1700010010, + }); err != nil { + t.Fatalf("send public first: %v", err) + } + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: public.Channel.ID, + RandomID: 102, + Message: "launch beta #ops", + Date: 1700010020, + }); err != nil { + t.Fatalf("send public second: %v", err) + } + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: private.Channel.ID, + RandomID: 103, + Message: "launch private #ops", + Date: 1700010030, + }); err != nil { + t.Fatalf("send private: %v", err) + } + + req := &tg.ChannelsSearchPostsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 1, + } + req.SetQuery("launch") + got, err := r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), req) + if err != nil { + t.Fatalf("channels.searchPosts first page: %v", err) + } + slice, ok := got.(*tg.MessagesMessagesSlice) + if !ok { + t.Fatalf("first page = %T %+v, want messagesSlice", got, got) + } + if slice.Count <= len(slice.Messages) { + t.Fatalf("first page count = %d messages=%d, want more page", slice.Count, len(slice.Messages)) + } + if nextRate, ok := slice.GetNextRate(); !ok || nextRate != 1700010020 { + t.Fatalf("first page next_rate = %d ok %v, want newest message date", nextRate, ok) + } + if flood, ok := slice.GetSearchFlood(); !ok || !flood.QueryIsFree { + t.Fatalf("first page search_flood = %+v ok %v, want free flood state", flood, ok) + } + messages, chats, users := searchMessagesPayload(t, got) + if len(messages) != 1 || len(chats) != 1 || len(users) != 1 { + t.Fatalf("first page payload messages=%d chats=%d users=%d, want 1/1/1", len(messages), len(chats), len(users)) + } + first := messages[0].(*tg.Message) + if first.Message != "launch beta #ops" { + t.Fatalf("first result message = %q, want newest public hit", first.Message) + } + if peer, ok := first.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != public.Channel.ID { + t.Fatalf("first result peer = %#v, want public channel %d", first.PeerID, public.Channel.ID) + } + + page2 := &tg.ChannelsSearchPostsRequest{ + OffsetRate: slice.NextRate, + OffsetPeer: &tg.InputPeerChannel{ + ChannelID: public.Channel.ID, + AccessHash: public.Channel.AccessHash, + }, + OffsetID: first.ID, + Limit: 10, + } + page2.SetQuery("launch") + got, err = r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), page2) + if err != nil { + t.Fatalf("channels.searchPosts second page: %v", err) + } + messages, chats, _ = searchMessagesPayload(t, got) + if len(messages) != 1 || len(chats) != 1 { + t.Fatalf("second page payload messages=%d chats=%d, want only older public hit", len(messages), len(chats)) + } + if msg := messages[0].(*tg.Message); msg.Message != "launch alpha #ops" { + t.Fatalf("second result message = %q, want older public hit", msg.Message) + } + + hashtagReq := &tg.ChannelsSearchPostsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 10, + } + hashtagReq.SetHashtag("ops") + got, err = r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), hashtagReq) + if err != nil { + t.Fatalf("channels.searchPosts hashtag: %v", err) + } + messages, _, _ = searchMessagesPayload(t, got) + if len(messages) != 2 { + t.Fatalf("hashtag results = %d, want two public hits only", len(messages)) + } + for _, item := range messages { + if strings.Contains(item.(*tg.Message).Message, "private") { + t.Fatalf("hashtag leaked private message: %#v", item) + } + } +} + +func TestChannelsSearchPostsValidatesStubBounds(t *testing.T) { + const userID = int64(1000000001) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + + valid := &tg.ChannelsSearchPostsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + } + valid.SetQuery("launch") + got, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), valid) + if err != nil { + t.Fatalf("channels.searchPosts valid stub: %v", err) + } + if page, ok := got.(*tg.MessagesMessages); !ok || len(page.Messages) != 0 || len(page.Chats) != 0 || len(page.Users) != 0 { + t.Fatalf("channels.searchPosts = %T %+v, want empty messages", got, got) + } + + both := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1} + both.SetQuery("launch") + both.SetHashtag("launch") + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), both); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") { + t.Fatalf("channels.searchPosts query+hashtag err = %v, want SEARCH_QUERY_EMPTY", err) + } + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1}); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") { + t.Fatalf("channels.searchPosts empty query err = %v, want SEARCH_QUERY_EMPTY", err) + } + + huge := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1} + huge.SetQuery(strings.Repeat("x", maxChannelSearchPostsQuery+1)) + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), huge); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("channels.searchPosts huge query err = %v, want LIMIT_INVALID", err) + } + + badOffset := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1, OffsetID: domain.MaxMessageBoxID + 1} + badOffset.SetHashtag("launch") + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badOffset); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("channels.searchPosts huge offset_id err = %v, want MESSAGE_ID_INVALID", err) + } + + badStars := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1, AllowPaidStars: -1} + badStars.SetQuery("launch") + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badStars); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("channels.searchPosts negative stars err = %v, want LIMIT_INVALID", err) + } + + badPeer := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerUser{UserID: 42}, Limit: 1} + badPeer.SetQuery("launch") + if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badPeer); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") { + t.Fatalf("channels.searchPosts user offset peer err = %v, want PEER_ID_INVALID", err) + } + + floodReq := &tg.ChannelsCheckSearchPostsFloodRequest{} + floodReq.SetQuery("launch") + flood, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), floodReq) + if err != nil { + t.Fatalf("channels.checkSearchPostsFlood: %v", err) + } + if !flood.QueryIsFree || flood.Remains <= 0 || flood.TotalDaily <= 0 { + t.Fatalf("channels.checkSearchPostsFlood = %+v, want free quota", flood) + } + + if _, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), &tg.ChannelsCheckSearchPostsFloodRequest{}); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") { + t.Fatalf("channels.checkSearchPostsFlood empty query err = %v, want SEARCH_QUERY_EMPTY", err) + } + floodHuge := &tg.ChannelsCheckSearchPostsFloodRequest{} + floodHuge.SetQuery(strings.Repeat("x", maxChannelSearchPostsQuery+1)) + if _, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), floodHuge); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("channels.checkSearchPostsFlood huge query err = %v, want LIMIT_INVALID", err) + } +} diff --git a/internal/rpc/channels_multi_pin_android_rpc_test.go b/internal/rpc/channels_multi_pin_android_rpc_test.go new file mode 100644 index 00000000..95fda8c0 --- /dev/null +++ b/internal/rpc/channels_multi_pin_android_rpc_test.go @@ -0,0 +1,229 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestChannelMultiPinAndroidOpenAndJump 模拟 DrKLO Android 超级群多置顶消费链路: +// 打开聊天 → messages.search(filterPinned, limit=40, offset_id=0) 全量拉置顶列表; +// 点置顶栏跳最旧 pin → messages.getHistory(offset_id=pin, add_offset=-count/2, limit=count) +// AROUND 加载,响应必须包含锚点消息本身,否则客户端弹 MessageNotFound 放弃跳转; +// 本地缺对象 → channels.getMessages 精确补拉,messageEmpty 会被客户端丢弃。 +func TestChannelMultiPinAndroidOpenAndJump(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550001161", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550001162", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "MultiPin Android", + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700001000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID := created.Channel.ID + + const total = 30 + ids := make([]int, 0, total) + for i := 0; i < total; i++ { + sent, err := channelSvc.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: channelID, + RandomID: int64(961000 + i), + Message: "msg", + Date: 1700001001 + i, + }) + if err != nil { + t.Fatalf("send %d: %v", i, err) + } + ids = append(ids, sent.Message.ID) + } + // 三条置顶:早期、中间、最新(Android 置顶栏循环跳转需要全部三条都可跳)。 + pins := []int{ids[4], ids[14], ids[27]} + for _, id := range pins { + if _, err := channelSvc.UpdatePinnedMessage(ctx, owner.ID, domain.UpdateChannelPinnedMessageRequest{ + ChannelID: channelID, + MessageID: id, + Pinned: true, + Date: 1700001100, + }); err != nil { + t.Fatalf("pin %d: %v", id, err) + } + } + + memberView, err := channelSvc.GetChannel(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("member get channel: %v", err) + } + peer := &tg.InputPeerChannel{ChannelID: channelID, AccessHash: memberView.Channel.AccessHash} + dispatch := func(req bin.Encoder) bin.Encoder { + t.Helper() + var b bin.Buffer + if err := req.Encode(&b); err != nil { + t.Fatalf("encode request: %v", err) + } + enc, err := r.Dispatch(WithUserID(androidClientContext(), member.ID), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + return enc + } + + // ① 打开聊天:MediaDataController.loadPinnedMessages → messages.search filterPinned。 + searchEnc := dispatch(&tg.MessagesSearchRequest{ + Peer: peer, + Q: "", + Filter: &tg.InputMessagesFilterPinned{}, + Limit: 40, + }) + if box, ok := searchEnc.(*tg.MessagesMessagesBox); ok { + searchEnc = box.Messages + } + channelMessages, ok := searchEnc.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("pinned search response = %T, want messages.channelMessages", searchEnc) + } + if len(channelMessages.Messages) != len(pins) { + t.Fatalf("pinned search messages = %d, want %d", len(channelMessages.Messages), len(pins)) + } + if channelMessages.Count != len(pins) { + t.Fatalf("pinned search count = %d, want %d", channelMessages.Count, len(pins)) + } + wantDesc := []int{pins[2], pins[1], pins[0]} + for i, raw := range channelMessages.Messages { + msg, ok := raw.(*tg.Message) + if !ok { + // TL_messageService / TL_messageEmpty 会被 Android loadPinnedMessages 直接跳过。 + t.Fatalf("pinned search message[%d] = %T, want *tg.Message", i, raw) + } + if msg.ID != wantDesc[i] { + t.Fatalf("pinned search order[%d] = %d, want %d (id desc)", i, msg.ID, wantDesc[i]) + } + if !msg.Pinned { + t.Fatalf("pinned search message %d lacks pinned flag", msg.ID) + } + } + + // ② 点置顶栏跳最旧 pin:scrollToMessageId → getHistory AROUND(手机 count=20)。 + const aroundCount = 20 + histEnc := dispatch(&tg.MessagesGetHistoryRequest{ + Peer: peer, + OffsetID: pins[0], + AddOffset: -aroundCount / 2, + Limit: aroundCount, + }) + histMessages, _, _ := searchMessagesPayload(t, histEnc) + if len(histMessages) == 0 || len(histMessages) > aroundCount { + t.Fatalf("around history size = %d, want 1..%d (超出 count 时 Android 会丢最新一条)", len(histMessages), aroundCount) + } + anchorFound := false + lastID := int(^uint(0) >> 1) + for _, raw := range histMessages { + if _, isEmpty := raw.(*tg.MessageEmpty); isEmpty { + t.Fatalf("around history contains messageEmpty") + } + id := raw.GetID() + if id >= lastID { + t.Fatalf("around history not id-desc: %d then %d", lastID, id) + } + lastID = id + if id == pins[0] { + anchorFound = true + } + } + if !anchorFound { + // ChatActivity postponedScroll 在响应缺锚点时直接 MessageNotFound 放弃跳转。 + t.Fatalf("around history lacks anchor %d: jump shows MessageNotFound on Android", pins[0]) + } + + // ③ 本地缺对象补拉:MessagesStorage.loadChatInfo → channels.getMessages。 + // DrKLO 发的是 pre-InputMessage 构造器 #93d7b347(id:Vector), + // 该请求 500 会让客户端把这批 pin 按「已取消置顶」从本地缓存删除。 + var legacy bin.Buffer + legacy.PutID(0x93d7b347) + if err := (&tg.InputChannel{ChannelID: channelID, AccessHash: memberView.Channel.AccessHash}).Encode(&legacy); err != nil { + t.Fatalf("encode legacy input channel: %v", err) + } + legacy.PutVectorHeader(len(pins)) + for _, id := range pins { + legacy.PutInt(id) + } + getEnc, err := r.Dispatch(WithUserID(androidClientContext(), member.ID), [8]byte{}, 0, &legacy) + if err != nil { + t.Fatalf("dispatch legacy channels.getMessages#93d7b347: %v", err) + } + getMessages, _, _ := searchMessagesPayload(t, getEnc) + if len(getMessages) != len(pins) { + t.Fatalf("legacy channels.getMessages size = %d, want %d", len(getMessages), len(pins)) + } + for i, raw := range getMessages { + msg, ok := raw.(*tg.Message) + if !ok { + t.Fatalf("legacy channels.getMessages[%d] = %T, want *tg.Message (messageEmpty 会被客户端丢弃)", i, raw) + } + if !msg.Pinned { + t.Fatalf("legacy channels.getMessages message %d lacks pinned flag", msg.ID) + } + } + // 新构造器(TDesktop 路径)必须与 legacy 返回一致的消息集合。 + getIDs := make([]tg.InputMessageClass, 0, len(pins)) + for _, id := range pins { + getIDs = append(getIDs, &tg.InputMessageID{ID: id}) + } + modernEnc := dispatch(&tg.ChannelsGetMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: channelID, AccessHash: memberView.Channel.AccessHash}, + ID: getIDs, + }) + modernMessages, _, _ := searchMessagesPayload(t, modernEnc) + if len(modernMessages) != len(getMessages) { + t.Fatalf("modern channels.getMessages size = %d, want %d (legacy/modern must match)", len(modernMessages), len(getMessages)) + } + for i := range modernMessages { + if modernMessages[i].GetID() != getMessages[i].GetID() { + t.Fatalf("modern/legacy mismatch at %d: %d != %d", i, modernMessages[i].GetID(), getMessages[i].GetID()) + } + } + + // ④ chatFull 降级缓存:pinned_msg_id 必须是最新置顶(Android 以它判断是否重拉列表)。 + fullEnc := dispatch(&tg.ChannelsGetFullChannelRequest{ + Channel: &tg.InputChannel{ChannelID: channelID, AccessHash: memberView.Channel.AccessHash}, + }) + full, ok := fullEnc.(*tg.MessagesChatFull) + if !ok { + t.Fatalf("getFullChannel response = %T, want messages.chatFull", fullEnc) + } + channelFull, ok := full.FullChat.(*tg.ChannelFull) + if !ok { + t.Fatalf("full chat = %T, want channelFull", full.FullChat) + } + if pinnedID, _ := channelFull.GetPinnedMsgID(); pinnedID != pins[2] { + t.Fatalf("channelFull pinned_msg_id = %d, want latest pin %d", pinnedID, pins[2]) + } +} diff --git a/internal/rpc/channels_passive_stubs_rpc_test.go b/internal/rpc/channels_passive_stubs_rpc_test.go new file mode 100644 index 00000000..c3739c2d --- /dev/null +++ b/internal/rpc/channels_passive_stubs_rpc_test.go @@ -0,0 +1,1278 @@ +package rpc + +import ( + "github.com/gotd/td/tg" + "strings" + "telesrv/internal/domain" + "testing" + "time" +) + +func TestTDesktopPassiveChannelStubs(t *testing.T) { + f := newRPCChannelFixture(t) + r := f.router + owner := f.user(41, "15550002101", "Owner") + friend := f.user(42, "15550002102", "Friend") + invited := f.user(43, "15550002103", "Invited") + ownerCtx := f.userCtx(owner) + friendCtx := f.userCtx(friend) + channel := f.createLegacyMegagroup(owner, "RPC Passive Group", friend) + + sendAs, err := r.onChannelsGetSendAs(ownerCtx, &tg.ChannelsGetSendAsRequest{ + Peer: inputPeerChannel(channel), + }) + if err != nil { + t.Fatalf("get send as: %v", err) + } + if len(sendAs.Peers) != 2 || len(sendAs.Users) != 1 || len(sendAs.Chats) != 1 { + t.Fatalf("send as = %+v, want self + current channel peers with current channel context", sendAs) + } + if peer, ok := sendAs.Peers[0].Peer.(*tg.PeerUser); !ok || peer.UserID != owner.ID { + t.Fatalf("send as peer = %#v, want owner user", sendAs.Peers[0].Peer) + } + if peer, ok := sendAs.Peers[1].Peer.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { + t.Fatalf("send as peer[1] = %#v, want current channel", sendAs.Peers[1].Peer) + } + friendSendAs, err := r.onChannelsGetSendAs(friendCtx, &tg.ChannelsGetSendAsRequest{ + Peer: inputPeerChannel(channel), + }) + if err != nil { + t.Fatalf("friend get send as: %v", err) + } + if len(friendSendAs.Peers) != 1 { + t.Fatalf("friend send as peers = %+v, want only self", friendSendAs.Peers) + } + if ok, err := r.onMessagesSaveDefaultSendAs(ownerCtx, &tg.MessagesSaveDefaultSendAsRequest{ + Peer: inputPeerChannel(channel), + SendAs: &tg.InputPeerSelf{}, + }); err != nil || !ok { + t.Fatalf("save default send as self = ok %v err %v, want true", ok, err) + } + if ok, err := r.onMessagesSaveDefaultSendAs(ownerCtx, &tg.MessagesSaveDefaultSendAsRequest{ + Peer: inputPeerChannel(channel), + SendAs: inputPeerChannel(channel), + }); err != nil || !ok { + t.Fatalf("save default send as channel = ok %v err %v, want true", ok, err) + } + fullWithDefault, err := r.onChannelsGetFullChannel(ownerCtx, inputChannel(channel)) + if err != nil { + t.Fatalf("get full channel with default send as: %v", err) + } + channelFull := fullWithDefault.FullChat.(*tg.ChannelFull) + defaultSendAs, ok := channelFull.GetDefaultSendAs() + if !ok { + t.Fatalf("full channel default_send_as missing") + } + if peer, ok := defaultSendAs.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { + t.Fatalf("full channel default_send_as = %#v, want current channel %d", defaultSendAs, channel.ID) + } + sentWithDefault, err := r.onMessagesSendMessage(ownerCtx, &tg.MessagesSendMessageRequest{ + Peer: inputPeerChannel(channel), + Message: "default send as fallback", + RandomID: 80102, + }) + if err != nil { + t.Fatalf("send message with default send as: %v", err) + } + defaultMsg := sentWithDefault.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := defaultMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("default send_as message from = %#v, want channel %d", defaultMsg.FromID, channel.ID) + } + forwardedWithDefault, err := r.onMessagesForwardMessages(ownerCtx, &tg.MessagesForwardMessagesRequest{ + FromPeer: inputPeerChannel(channel), + ToPeer: inputPeerChannel(channel), + ID: []int{defaultMsg.ID}, + RandomID: []int64{80103}, + }) + if err != nil { + t.Fatalf("forward with default send as: %v", err) + } + defaultForward := forwardedWithDefault.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := defaultForward.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("default forward send_as from = %#v, want channel %d", defaultForward.FromID, channel.ID) + } + if ok, err := r.onMessagesSaveDefaultSendAs(ownerCtx, &tg.MessagesSaveDefaultSendAsRequest{ + Peer: inputPeerChannel(channel), + SendAs: &tg.InputPeerSelf{}, + }); err != nil || !ok { + t.Fatalf("clear default send as self = ok %v err %v, want true", ok, err) + } + fullAfterClear, err := r.onChannelsGetFullChannel(ownerCtx, inputChannel(channel)) + if err != nil { + t.Fatalf("get full channel after clearing default send as: %v", err) + } + if _, ok := fullAfterClear.FullChat.(*tg.ChannelFull).GetDefaultSendAs(); ok { + t.Fatalf("full channel default_send_as still set after saving self") + } + if _, err := r.onMessagesSaveDefaultSendAs(ownerCtx, &tg.MessagesSaveDefaultSendAsRequest{ + Peer: inputPeerChannel(channel), + SendAs: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("save default send as friend err = %v, want SEND_AS_PEER_INVALID", err) + } + + sponsored, err := r.onMessagesGetSponsoredMessages(ownerCtx, &tg.MessagesGetSponsoredMessagesRequest{ + Peer: inputPeerChannel(channel), + }) + if err != nil { + t.Fatalf("get sponsored messages: %v", err) + } + if _, ok := sponsored.(*tg.MessagesSponsoredMessagesEmpty); !ok { + t.Fatalf("sponsored = %T, want empty", sponsored) + } + historyTTL, err := r.onMessagesGetDefaultHistoryTTL(ownerCtx) + if err != nil { + t.Fatalf("get default history ttl: %v", err) + } + if historyTTL.Period != 0 { + t.Fatalf("default history ttl = %+v, want disabled", historyTTL) + } + accountTTL, err := r.onAccountGetAccountTTL(ownerCtx) + if err != nil { + t.Fatalf("get account ttl: %v", err) + } + if accountTTL.Days <= 0 { + t.Fatalf("account ttl = %+v, want positive days", accountTTL) + } + preview, err := r.onMessagesGetWebPagePreview(ownerCtx, &tg.MessagesGetWebPagePreviewRequest{ + Message: "https://example.com", + }) + if err != nil { + t.Fatalf("messages.getWebPagePreview: %v", err) + } + if _, ok := preview.Media.(*tg.MessageMediaEmpty); !ok || len(preview.Chats) != 0 || len(preview.Users) != 0 { + t.Fatalf("messages.getWebPagePreview = %+v, want empty media preview", preview) + } + if _, err := r.onMessagesGetWebPagePreview(ownerCtx, &tg.MessagesGetWebPagePreviewRequest{}); err == nil || !strings.Contains(err.Error(), "MESSAGE_EMPTY") { + t.Fatalf("messages.getWebPagePreview empty err = %v, want MESSAGE_EMPTY", err) + } + if media, err := r.onMessagesUploadMedia(ownerCtx, &tg.MessagesUploadMediaRequest{ + Peer: inputPeerChannel(channel), + Media: &tg.InputMediaEmpty{}, + }); err != nil { + t.Fatalf("messages.uploadMedia empty: %v", err) + } else if _, ok := media.(*tg.MessageMediaEmpty); !ok { + t.Fatalf("messages.uploadMedia empty = %#v, want messageMediaEmpty", media) + } + if _, err := r.onMessagesUploadMedia(ownerCtx, &tg.MessagesUploadMediaRequest{ + Peer: inputPeerChannel(channel), + Media: &tg.InputMediaUploadedPhoto{}, + }); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("messages.uploadMedia unsupported err = %v, want MEDIA_INVALID", err) + } + if updates, err := r.onMessagesSendMedia(ownerCtx, &tg.MessagesSendMediaRequest{ + Peer: inputPeerChannel(channel), + Media: &tg.InputMediaWebPage{URL: "https://example.com"}, + Message: "https://example.com", + RandomID: 91001, + }); err != nil { + t.Fatalf("messages.sendMedia webpage-as-text: %v", err) + } else if len(updates.(*tg.Updates).Updates) == 0 { + t.Fatalf("messages.sendMedia webpage-as-text = %+v, want channel message updates", updates) + } + if _, err := r.onMessagesSendMedia(ownerCtx, &tg.MessagesSendMediaRequest{ + Peer: inputPeerChannel(channel), + Media: &tg.InputMediaUploadedPhoto{}, + Message: "photo", + RandomID: 91002, + }); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("messages.sendMedia unsupported err = %v, want MEDIA_INVALID", err) + } + if _, err := r.onMessagesSendMultiMedia(ownerCtx, &tg.MessagesSendMultiMediaRequest{ + Peer: inputPeerChannel(channel), + MultiMedia: []tg.InputSingleMedia{{ + Media: &tg.InputMediaUploadedPhoto{}, + RandomID: 91003, + Message: "photo", + }}, + }); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("messages.sendMultiMedia unsupported err = %v, want MEDIA_INVALID", err) + } + savedHistoryReq := &tg.MessagesGetSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: 20, + } + savedHistoryReq.SetParentPeer(inputPeerChannel(channel)) + savedHistory, err := r.onMessagesGetSavedHistory(ownerCtx, savedHistoryReq) + if err != nil { + t.Fatalf("messages.getSavedHistory: %v", err) + } + if len(savedHistory.(*tg.MessagesMessages).Messages) != 0 || len(savedHistory.(*tg.MessagesMessages).Chats) != 1 { + t.Fatalf("messages.getSavedHistory = %+v, want empty with parent channel context", savedHistory) + } + badSavedParent := &tg.MessagesGetSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: 20, + } + badSavedParent.SetParentPeer(inputPeerChannelWithHash(channel, channel.AccessHash+1)) + if _, err := r.onMessagesGetSavedHistory(ownerCtx, badSavedParent); err == nil || !strings.Contains(err.Error(), "PARENT_PEER_INVALID") { + t.Fatalf("messages.getSavedHistory bad parent err = %v, want PARENT_PEER_INVALID", err) + } + if _, err := r.onMessagesGetSavedHistory(ownerCtx, &tg.MessagesGetSavedHistoryRequest{ + Peer: inputPeerChannelWithHash(channel, channel.AccessHash+1), + Limit: 20, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("messages.getSavedHistory bad peer err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesGetSavedHistory(ownerCtx, &tg.MessagesGetSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: maxSearchResultsLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.getSavedHistory limit err = %v, want LIMIT_INVALID", err) + } + if ok, err := r.onMessagesReadSavedHistory(ownerCtx, &tg.MessagesReadSavedHistoryRequest{ + ParentPeer: inputPeerChannel(channel), + Peer: &tg.InputPeerSelf{}, + MaxID: 0, + }); err != nil || !ok { + t.Fatalf("messages.readSavedHistory = ok %v err %v, want true", ok, err) + } + if _, err := r.onMessagesReadSavedHistory(ownerCtx, &tg.MessagesReadSavedHistoryRequest{ + ParentPeer: &tg.InputPeerSelf{}, + Peer: &tg.InputPeerSelf{}, + }); err == nil || !strings.Contains(err.Error(), "PARENT_PEER_INVALID") { + t.Fatalf("messages.readSavedHistory bad parent err = %v, want PARENT_PEER_INVALID", err) + } + if _, err := r.onMessagesReadSavedHistory(ownerCtx, &tg.MessagesReadSavedHistoryRequest{ + ParentPeer: inputPeerChannel(channel), + Peer: &tg.InputPeerSelf{}, + MaxID: domain.MaxMessageBoxID + 1, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.readSavedHistory max_id err = %v, want MESSAGE_ID_INVALID", err) + } + deleteSavedReq := &tg.MessagesDeleteSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + } + deleteSavedReq.SetParentPeer(inputPeerChannel(channel)) + deletedSaved, err := r.onMessagesDeleteSavedHistory(ownerCtx, deleteSavedReq) + if err != nil { + t.Fatalf("messages.deleteSavedHistory: %v", err) + } + if deletedSaved.Offset != 0 || deletedSaved.PtsCount != 0 { + t.Fatalf("messages.deleteSavedHistory = %+v, want empty affected history", deletedSaved) + } + badDeleteDate := &tg.MessagesDeleteSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + } + badDeleteDate.SetMinDate(-1) + if _, err := r.onMessagesDeleteSavedHistory(ownerCtx, badDeleteDate); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.deleteSavedHistory min_date err = %v, want LIMIT_INVALID", err) + } + scheduledHistory, err := r.onMessagesGetScheduledHistory(ownerCtx, &tg.MessagesGetScheduledHistoryRequest{ + Peer: inputPeerChannel(channel), + }) + if err != nil { + t.Fatalf("messages.getScheduledHistory: %v", err) + } + if len(scheduledHistory.(*tg.MessagesMessages).Messages) != 0 || len(scheduledHistory.(*tg.MessagesMessages).Chats) != 1 { + t.Fatalf("messages.getScheduledHistory = %+v, want empty with channel context", scheduledHistory) + } + if _, err := r.onMessagesGetScheduledHistory(ownerCtx, &tg.MessagesGetScheduledHistoryRequest{ + Peer: inputPeerChannelWithHash(channel, channel.AccessHash+1), + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("messages.getScheduledHistory bad hash err = %v, want CHANNEL_PRIVATE", err) + } + scheduled, err := r.onMessagesGetScheduledMessages(ownerCtx, &tg.MessagesGetScheduledMessagesRequest{ + Peer: inputPeerChannel(channel), + ID: []int{1}, + }) + if err != nil { + t.Fatalf("messages.getScheduledMessages: %v", err) + } + if len(scheduled.(*tg.MessagesMessages).Messages) != 0 || len(scheduled.(*tg.MessagesMessages).Chats) != 1 { + t.Fatalf("messages.getScheduledMessages = %+v, want empty with channel context", scheduled) + } + if _, err := r.onMessagesGetScheduledMessages(ownerCtx, &tg.MessagesGetScheduledMessagesRequest{ + Peer: inputPeerChannel(channel), + ID: make([]int, maxGetMessagesIDs+1), + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.getScheduledMessages id cap err = %v, want LIMIT_INVALID", err) + } + deletedScheduled, err := r.onMessagesDeleteScheduledMessages(ownerCtx, &tg.MessagesDeleteScheduledMessagesRequest{ + Peer: inputPeerChannel(channel), + ID: []int{7, 8}, + }) + if err != nil { + t.Fatalf("messages.deleteScheduledMessages: %v", err) + } + if got := deletedScheduled.(*tg.Updates).Updates; len(got) != 1 { + t.Fatalf("messages.deleteScheduledMessages updates = %+v, want one update", got) + } else if update, ok := got[0].(*tg.UpdateDeleteScheduledMessages); !ok || len(update.Messages) != 2 || update.Messages[0] != 7 { + t.Fatalf("messages.deleteScheduledMessages update = %#v, want requested ids", got[0]) + } + if _, err := r.onMessagesSendScheduledMessages(ownerCtx, &tg.MessagesSendScheduledMessagesRequest{ + Peer: inputPeerChannel(channel), + ID: []int{7}, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.sendScheduledMessages err = %v, want MESSAGE_ID_INVALID", err) + } + if _, err := r.onMessagesCreateForumTopic(ownerCtx, &tg.MessagesCreateForumTopicRequest{ + Peer: inputPeerChannel(channel), + Title: "Topic", + RandomID: 91004, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.createForumTopic err = %v, want CHANNEL_FORUM_MISSING", err) + } + if _, err := r.onMessagesCreateForumTopic(ownerCtx, &tg.MessagesCreateForumTopicRequest{ + Peer: inputPeerChannel(channel), + RandomID: 91005, + }); err == nil || !strings.Contains(err.Error(), "TOPIC_TITLE_EMPTY") { + t.Fatalf("messages.createForumTopic empty title err = %v, want TOPIC_TITLE_EMPTY", err) + } + if _, err := r.onMessagesEditForumTopic(ownerCtx, &tg.MessagesEditForumTopicRequest{ + Peer: inputPeerChannel(channel), + TopicID: 1, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.editForumTopic err = %v, want CHANNEL_FORUM_MISSING", err) + } + if _, err := r.onMessagesUpdatePinnedForumTopic(ownerCtx, &tg.MessagesUpdatePinnedForumTopicRequest{ + Peer: inputPeerChannel(channel), + TopicID: 1, + Pinned: true, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.updatePinnedForumTopic err = %v, want CHANNEL_FORUM_MISSING", err) + } + if _, err := r.onMessagesReorderPinnedForumTopics(ownerCtx, &tg.MessagesReorderPinnedForumTopicsRequest{ + Peer: inputPeerChannel(channel), + Order: []int{1, 2}, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.reorderPinnedForumTopics err = %v, want CHANNEL_FORUM_MISSING", err) + } + if _, err := r.onMessagesDeleteTopicHistory(ownerCtx, &tg.MessagesDeleteTopicHistoryRequest{ + Peer: inputPeerChannel(channel), + TopMsgID: 1, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.deleteTopicHistory err = %v, want CHANNEL_FORUM_MISSING", err) + } + chats, err := r.onMessagesGetChats(ownerCtx, []int64{channel.ID}) + if err != nil { + t.Fatalf("messages.getChats legacy wrapper: %v", err) + } + if len(chats.(*tg.MessagesChats).Chats) != 1 { + t.Fatalf("messages.getChats = %+v, want current megagroup channel", chats) + } + migrated, err := r.onMessagesMigrateChat(ownerCtx, channel.ID) + if err != nil { + t.Fatalf("messages.migrateChat legacy mapping: %v", err) + } + migratedUpdates := migrated.(*tg.Updates) + if len(migratedUpdates.Chats) != 1 || len(migratedUpdates.Updates) != 1 { + t.Fatalf("messages.migrateChat = %+v, want updateChannel with current megagroup", migratedUpdates) + } + if _, err := r.onMessagesMigrateChat(friendCtx, channel.ID); err == nil { + t.Fatalf("messages.migrateChat by non-admin = nil err, want CHAT_ADMIN_REQUIRED") + } + full, err := r.onMessagesGetFullChat(ownerCtx, channel.ID) + if err != nil { + t.Fatalf("messages.getFullChat legacy wrapper: %v", err) + } + if got := full.FullChat.(*tg.ChannelFull).ID; got != channel.ID { + t.Fatalf("messages.getFullChat id = %d, want %d", got, channel.ID) + } + viewedID := defaultMsg.ID + views, err := r.onMessagesGetMessagesViews(ownerCtx, &tg.MessagesGetMessagesViewsRequest{ + Peer: inputPeerChannel(channel), + ID: []int{viewedID}, + }) + if err != nil { + t.Fatalf("messages.getMessagesViews: %v", err) + } + if len(views.Views) != 1 || len(views.Chats) != 1 { + t.Fatalf("messages.getMessagesViews = %+v, want one view with channel context", views) + } + if got, ok := views.Views[0].GetViews(); !ok || got != 0 { + t.Fatalf("messages.getMessagesViews views = %d ok %v, want explicit zero before increment", got, ok) + } + incremented, err := r.onMessagesGetMessagesViews(ownerCtx, &tg.MessagesGetMessagesViewsRequest{ + Peer: inputPeerChannel(channel), + ID: []int{viewedID}, + Increment: true, + }) + if err != nil { + t.Fatalf("messages.getMessagesViews increment: %v", err) + } + if got, ok := incrementalViewCount(incremented); !ok || got != 1 { + t.Fatalf("messages.getMessagesViews increment count = %d ok %v, want 1", got, ok) + } + repeated, err := r.onMessagesGetMessagesViews(ownerCtx, &tg.MessagesGetMessagesViewsRequest{ + Peer: inputPeerChannel(channel), + ID: []int{viewedID}, + Increment: true, + }) + if err != nil { + t.Fatalf("messages.getMessagesViews repeat increment: %v", err) + } + if got, ok := incrementalViewCount(repeated); !ok || got != 1 { + t.Fatalf("messages.getMessagesViews repeated count = %d ok %v, want still 1", got, ok) + } + friendIncremented, err := r.onMessagesGetMessagesViews(friendCtx, &tg.MessagesGetMessagesViewsRequest{ + Peer: inputPeerChannel(channel), + ID: []int{viewedID}, + Increment: true, + }) + if err != nil { + t.Fatalf("messages.getMessagesViews friend increment: %v", err) + } + if got, ok := incrementalViewCount(friendIncremented); !ok || got != 2 { + t.Fatalf("messages.getMessagesViews friend count = %d ok %v, want 2", got, ok) + } + if _, err := r.onMessagesReadMessageContents(ownerCtx, []int{1}); err != nil { + t.Fatalf("messages.readMessageContents: %v", err) + } + mentions, err := r.onMessagesGetUnreadMentions(ownerCtx, &tg.MessagesGetUnreadMentionsRequest{ + Peer: inputPeerChannel(channel), + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadMentions: %v", err) + } + _, mentionChats, _ := searchMessagesPayload(t, mentions) + if len(mentionChats) != 1 { + t.Fatalf("messages.getUnreadMentions = %+v, want channel context", mentions) + } + if _, err := r.onMessagesReadMentions(ownerCtx, &tg.MessagesReadMentionsRequest{ + Peer: inputPeerChannel(channel), + }); err != nil { + t.Fatalf("messages.readMentions: %v", err) + } + if ok, err := r.onMessagesReportSpam(ownerCtx, inputPeerChannel(channel)); err != nil || !ok { + t.Fatalf("messages.reportSpam = ok %v err %v, want true nil", ok, err) + } + reportOptions, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{ + Peer: inputPeerChannel(channel), + ID: []int{1}, + }) + if err != nil { + t.Fatalf("messages.report options: %v", err) + } + if choices, ok := reportOptions.(*tg.ReportResultChooseOption); !ok || len(choices.Options) == 0 { + t.Fatalf("messages.report options = %#v, want chooseOption", reportOptions) + } + reportComment, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{ + Peer: inputPeerChannel(channel), + ID: []int{1}, + Option: []byte("other"), + }) + if err != nil { + t.Fatalf("messages.report other: %v", err) + } + if _, ok := reportComment.(*tg.ReportResultAddComment); !ok { + t.Fatalf("messages.report other = %#v, want addComment", reportComment) + } + reported, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{ + Peer: inputPeerChannel(channel), + ID: []int{1}, + Option: []byte("spam"), + }) + if err != nil { + t.Fatalf("messages.report spam: %v", err) + } + if _, ok := reported.(*tg.ReportResultReported); !ok { + t.Fatalf("messages.report spam = %#v, want reported", reported) + } + if _, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{ + Peer: inputPeerChannel(channel), + ID: []int{1}, + Option: []byte("bogus"), + }); err == nil || !strings.Contains(err.Error(), "OPTION_INVALID") { + t.Fatalf("messages.report invalid option err = %v, want OPTION_INVALID", err) + } + if ok, err := r.onMessagesReportReaction(ownerCtx, &tg.MessagesReportReactionRequest{ + Peer: inputPeerChannel(channel), + ID: 1, + ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + }); err != nil || !ok { + t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err) + } + if ok, err := r.onMessagesReportMessagesDelivery(ownerCtx, &tg.MessagesReportMessagesDeliveryRequest{ + Peer: inputPeerChannel(channel), + ID: []int{1}, + }); err != nil || !ok { + t.Fatalf("messages.reportMessagesDelivery = ok %v err %v, want true nil", ok, err) + } + if ok, err := r.onMessagesReportReadMetrics(ownerCtx, &tg.MessagesReportReadMetricsRequest{ + Peer: inputPeerChannel(channel), + Metrics: []tg.InputMessageReadMetric{{ + MsgID: 1, + ViewID: 99, + TimeInViewMs: 10, + ActiveTimeInViewMs: 10, + HeightToViewportRatioPermille: 1000, + SeenRangeRatioPermille: 1000, + }}, + }); err != nil || !ok { + t.Fatalf("messages.reportReadMetrics = ok %v err %v, want true nil", ok, err) + } + if ok, err := r.onMessagesReportMusicListen(ownerCtx, &tg.MessagesReportMusicListenRequest{ + ID: &tg.InputDocument{ID: 1, AccessHash: 2}, + ListenedDuration: 1, + }); err != nil || !ok { + t.Fatalf("messages.reportMusicListen = ok %v err %v, want true nil", ok, err) + } + sponsoredReport, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{ + RandomID: []byte("ad"), + Option: []byte("spam"), + }) + if err != nil { + t.Fatalf("messages.reportSponsoredMessage: %v", err) + } + if _, ok := sponsoredReport.(*tg.ChannelsSponsoredMessageReportResultReported); !ok { + t.Fatalf("messages.reportSponsoredMessage = %#v, want reported", sponsoredReport) + } + sendReactionReq := &tg.MessagesSendReactionRequest{ + Peer: inputPeerChannel(channel), + MsgID: viewedID, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, + } + sendReactionReq.SetReaction(sendReactionReq.Reaction) + sendReactionReq.SetAddToRecent(true) + if updates, err := r.onMessagesSendReaction(ownerCtx, sendReactionReq); err != nil { + t.Fatalf("messages.sendReaction: %v", err) + } else if got := updates.(*tg.Updates).Updates; len(got) != 1 { + t.Fatalf("messages.sendReaction updates = %+v, want one reaction update", got) + } else if update, ok := got[0].(*tg.UpdateMessageReactions); !ok || update.MsgID != viewedID || len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 || update.Reactions.Results[0].ChosenOrder != 1 { + t.Fatalf("messages.sendReaction update = %#v, want one chosen reaction", got[0]) + } + tooManyReactionsReq := &tg.MessagesSendReactionRequest{ + Peer: inputPeerChannel(channel), + MsgID: viewedID, + Reaction: make([]tg.ReactionClass, maxReactionVector+1), + } + tooManyReactionsReq.SetReaction(tooManyReactionsReq.Reaction) + if _, err := r.onMessagesSendReaction(ownerCtx, tooManyReactionsReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.sendReaction huge reaction vector err = %v, want LIMIT_INVALID", err) + } + reactionUpdates, err := r.onMessagesGetMessagesReactions(ownerCtx, &tg.MessagesGetMessagesReactionsRequest{ + Peer: inputPeerChannel(channel), + ID: []int{viewedID}, + }) + if err != nil { + t.Fatalf("messages.getMessagesReactions: %v", err) + } + if got := reactionUpdates.(*tg.Updates).Updates; len(got) != 1 { + t.Fatalf("messages.getMessagesReactions updates = %+v, want one reaction update", got) + } else if update, ok := got[0].(*tg.UpdateMessageReactions); !ok || update.MsgID != viewedID || len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 { + t.Fatalf("messages.getMessagesReactions update = %#v, want one reaction", got[0]) + } + reactionList, err := r.onMessagesGetMessageReactionsList(ownerCtx, &tg.MessagesGetMessageReactionsListRequest{ + Peer: inputPeerChannel(channel), + ID: viewedID, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getMessageReactionsList: %v", err) + } + if reactionList.Count != 1 || len(reactionList.Reactions) != 1 || len(reactionList.Chats) != 1 || len(reactionList.Users) != 1 { + t.Fatalf("messages.getMessageReactionsList = %+v, want one reaction with channel context", reactionList) + } + if peer, ok := reactionList.Reactions[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != owner.ID || !reactionList.Reactions[0].My { + t.Fatalf("messages.getMessageReactionsList reaction = %+v, want current user reaction", reactionList.Reactions[0]) + } + friendReactionReq := &tg.MessagesSendReactionRequest{ + Peer: inputPeerChannel(channel), + MsgID: viewedID, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, + } + friendReactionReq.SetReaction(friendReactionReq.Reaction) + if _, err := r.onMessagesSendReaction(friendCtx, friendReactionReq); err != nil { + t.Fatalf("messages.sendReaction by friend: %v", err) + } + unreadReactions, err := r.onMessagesGetUnreadReactions(ownerCtx, &tg.MessagesGetUnreadReactionsRequest{ + Peer: inputPeerChannel(channel), + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadReactions: %v", err) + } + unreadMessages, unreadChats, unreadUsers := searchMessagesPayload(t, unreadReactions) + if len(unreadMessages) != 1 || len(unreadChats) != 1 || len(unreadUsers) == 0 { + t.Fatalf("messages.getUnreadReactions = %+v, want one unread reaction with channel context", unreadReactions) + } + unreadMessage, ok := unreadMessages[0].(*tg.Message) + if !ok || unreadMessage.ID != viewedID { + t.Fatalf("messages.getUnreadReactions message = %#v, want message %d", unreadMessages[0], viewedID) + } + reactions, ok := unreadMessage.GetReactions() + hasUnreadRecent := false + for _, recent := range reactions.RecentReactions { + if recent.Unread { + hasUnreadRecent = true + break + } + } + if !ok || !hasUnreadRecent { + t.Fatalf("messages.getUnreadReactions reactions = %+v ok %v, want unread recent reaction", reactions, ok) + } + if _, err := r.onMessagesReadReactions(ownerCtx, &tg.MessagesReadReactionsRequest{ + Peer: inputPeerChannel(channel), + }); err != nil { + t.Fatalf("messages.readReactions: %v", err) + } + unreadAfterRead, err := r.onMessagesGetUnreadReactions(ownerCtx, &tg.MessagesGetUnreadReactionsRequest{ + Peer: inputPeerChannel(channel), + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadReactions after read: %v", err) + } + unreadAfterReadMessages, _, _ := searchMessagesPayload(t, unreadAfterRead) + if len(unreadAfterReadMessages) != 0 { + t.Fatalf("messages.getUnreadReactions after read = %+v, want empty messages", unreadAfterRead) + } + common, err := r.onMessagesGetCommonChats(ownerCtx, &tg.MessagesGetCommonChatsRequest{ + UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Limit: 40, + }) + if err != nil { + t.Fatalf("messages.getCommonChats: %v", err) + } + commonChats := common.(*tg.MessagesChats).Chats + if len(commonChats) != 1 || commonChats[0].(*tg.Channel).ID != channel.ID { + t.Fatalf("messages.getCommonChats = %+v, want current shared megagroup", common) + } + commonNext, err := r.onMessagesGetCommonChats(ownerCtx, &tg.MessagesGetCommonChatsRequest{ + UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + MaxID: channel.ID, + Limit: 40, + }) + if err != nil { + t.Fatalf("messages.getCommonChats next page: %v", err) + } + if len(commonNext.(*tg.MessagesChats).Chats) != 0 { + t.Fatalf("messages.getCommonChats next page = %+v, want empty after max id", commonNext) + } + fullFriend, err := r.onUsersGetFullUser(ownerCtx, &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}) + if err != nil { + t.Fatalf("users.getFullUser friend: %v", err) + } + if fullFriend.FullUser.CommonChatsCount != 1 { + t.Fatalf("users.getFullUser commonChatsCount = %d, want 1", fullFriend.FullUser.CommonChatsCount) + } + if _, err := r.onMessagesGetCommonChats(ownerCtx, &tg.MessagesGetCommonChatsRequest{ + UserID: &tg.InputUserSelf{}, + Limit: 1, + }); err == nil || !strings.Contains(err.Error(), "USER_ID_INVALID") { + t.Fatalf("messages.getCommonChats self err = %v, want USER_ID_INVALID", err) + } + if _, err := r.onMessagesGetAttachedStickers(ownerCtx, nil); err == nil || !strings.Contains(err.Error(), "MEDIA_EMPTY") { + t.Fatalf("messages.getAttachedStickers nil err = %v, want MEDIA_EMPTY", err) + } + attached, err := r.onMessagesGetAttachedStickers(ownerCtx, &tg.InputStickeredMediaDocument{ + ID: &tg.InputDocument{ID: 1, AccessHash: 2}, + }) + if err != nil || len(attached) != 0 { + t.Fatalf("messages.getAttachedStickers = %+v err=%v, want empty", attached, err) + } + customEmojiDocs, err := r.onMessagesGetCustomEmojiDocuments(ownerCtx, []int64{1, 2}) + if err != nil || len(customEmojiDocs) != 0 { + t.Fatalf("messages.getCustomEmojiDocuments = %+v err=%v, want empty", customEmojiDocs, err) + } + if _, err := r.onMessagesGetCustomEmojiDocuments(ownerCtx, make([]int64, maxEmojiDocuments+1)); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.getCustomEmojiDocuments too many err = %v, want LIMIT_INVALID", err) + } + stickerSets, err := r.onMessagesSearchStickerSets(ownerCtx, &tg.MessagesSearchStickerSetsRequest{Q: "cat"}) + if err != nil { + t.Fatalf("messages.searchStickerSets: %v", err) + } + if found, ok := stickerSets.(*tg.MessagesFoundStickerSets); !ok || len(found.Sets) != 0 { + t.Fatalf("messages.searchStickerSets = %#v, want empty found sets", stickerSets) + } + stickers, err := r.onMessagesSearchStickers(ownerCtx, &tg.MessagesSearchStickersRequest{ + Q: "cat", + LangCode: []string{"en"}, + Limit: 50, + }) + if err != nil { + t.Fatalf("messages.searchStickers: %v", err) + } + if found, ok := stickers.(*tg.MessagesFoundStickers); !ok || len(found.Stickers) != 0 { + t.Fatalf("messages.searchStickers = %#v, want empty found stickers", stickers) + } + if _, err := r.onMessagesSearchStickers(ownerCtx, &tg.MessagesSearchStickersRequest{ + Limit: maxSearchResultsLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.searchStickers huge limit err = %v, want LIMIT_INVALID", err) + } + // emoji 关键词已从官方 seed(catalog),返回真实词典而非空 stub。 + keywords, err := r.onMessagesGetEmojiKeywords(ownerCtx, "en") + if err != nil || keywords.LangCode != "en" || keywords.Version == 0 || len(keywords.Keywords) == 0 { + t.Fatalf("messages.getEmojiKeywords lang=%q version=%d count=%d err=%v, want seeded en keywords", + keywords.LangCode, keywords.Version, len(keywords.Keywords), err) + } + // 客户端版本(7)落后于固化版本 → 下发全量,Version 推进到固化版本。 + keywordsDiff, err := r.onMessagesGetEmojiKeywordsDifference(ownerCtx, &tg.MessagesGetEmojiKeywordsDifferenceRequest{ + LangCode: "en", + FromVersion: 7, + }) + if err != nil || keywordsDiff.FromVersion != 7 || keywordsDiff.Version <= 7 || len(keywordsDiff.Keywords) == 0 { + t.Fatalf("messages.getEmojiKeywordsDifference from=%d version=%d count=%d err=%v, want full diff to seeded version", + keywordsDiff.FromVersion, keywordsDiff.Version, len(keywordsDiff.Keywords), err) + } + extended, err := r.onMessagesGetExtendedMedia(ownerCtx, &tg.MessagesGetExtendedMediaRequest{ + Peer: inputPeerChannel(channel), + ID: []int{1}, + }) + if err != nil { + t.Fatalf("messages.getExtendedMedia: %v", err) + } + if len(extended.(*tg.Updates).Updates) != 0 { + t.Fatalf("messages.getExtendedMedia = %+v, want empty updates", extended) + } + topReactions, err := r.onMessagesGetTopReactions(ownerCtx, &tg.MessagesGetTopReactionsRequest{Limit: 3}) + if err != nil { + t.Fatalf("messages.getTopReactions: %v", err) + } + topPage, ok := topReactions.(*tg.MessagesReactions) + if !ok || topPage.Hash == 0 || len(topPage.Reactions) != 3 { + t.Fatalf("messages.getTopReactions = %#v, want three hashable reactions", topReactions) + } + if emoji, ok := topPage.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" { + t.Fatalf("messages.getTopReactions first reaction = %#v, want account top thumb", topPage.Reactions[0]) + } + if emoji, ok := topPage.Reactions[1].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\u2764\ufe0f" { + t.Fatalf("messages.getTopReactions fallback reaction = %#v, want catalog heart", topPage.Reactions[1]) + } + topNotModified, err := r.onMessagesGetTopReactions(ownerCtx, &tg.MessagesGetTopReactionsRequest{Limit: 3, Hash: topPage.Hash}) + if err != nil { + t.Fatalf("messages.getTopReactions hash: %v", err) + } + if _, ok := topNotModified.(*tg.MessagesReactionsNotModified); !ok { + t.Fatalf("messages.getTopReactions hash = %#v, want notModified", topNotModified) + } + recentReactions, err := r.onMessagesGetRecentReactions(ownerCtx, &tg.MessagesGetRecentReactionsRequest{Limit: 40}) + if err != nil { + t.Fatalf("messages.getRecentReactions: %v", err) + } + recentPage, ok := recentReactions.(*tg.MessagesReactions) + if !ok || recentPage.Hash == 0 || len(recentPage.Reactions) != 1 { + t.Fatalf("messages.getRecentReactions = %#v, want one reaction with non-zero hash", recentReactions) + } + if emoji, ok := recentPage.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" { + t.Fatalf("messages.getRecentReactions reaction = %#v, want thumb emoji", recentPage.Reactions[0]) + } + recentNotModified, err := r.onMessagesGetRecentReactions(ownerCtx, &tg.MessagesGetRecentReactionsRequest{Limit: 40, Hash: recentPage.Hash}) + if err != nil { + t.Fatalf("messages.getRecentReactions hash: %v", err) + } + if _, ok := recentNotModified.(*tg.MessagesReactionsNotModified); !ok { + t.Fatalf("messages.getRecentReactions hash = %#v, want notModified", recentNotModified) + } + if ok, err := r.onMessagesClearRecentReactions(ownerCtx); err != nil || !ok { + t.Fatalf("messages.clearRecentReactions = ok %v err %v, want true nil", ok, err) + } + recentAfterClear, err := r.onMessagesGetRecentReactions(ownerCtx, &tg.MessagesGetRecentReactionsRequest{Limit: 40, Hash: recentPage.Hash}) + if err != nil { + t.Fatalf("messages.getRecentReactions after clear: %v", err) + } + clearedPage, ok := recentAfterClear.(*tg.MessagesReactions) + if !ok || clearedPage.Hash != 0 || len(clearedPage.Reactions) != 0 { + t.Fatalf("messages.getRecentReactions after clear = %#v, want empty page hash 0", recentAfterClear) + } + savedTagsReq := &tg.MessagesGetSavedReactionTagsRequest{ + Peer: inputPeerChannel(channel), + } + savedTagsReq.SetPeer(savedTagsReq.Peer) + savedTags, err := r.onMessagesGetSavedReactionTags(ownerCtx, savedTagsReq) + if err != nil { + t.Fatalf("messages.getSavedReactionTags: %v", err) + } + if got := savedTags.(*tg.MessagesSavedReactionTags).Tags; len(got) != 0 { + t.Fatalf("messages.getSavedReactionTags = %+v, want empty", got) + } + staleEmptyTags, err := r.onMessagesGetSavedReactionTags(ownerCtx, &tg.MessagesGetSavedReactionTagsRequest{Hash: 123}) + if err != nil { + t.Fatalf("messages.getSavedReactionTags stale empty hash: %v", err) + } + staleEmptyPage, ok := staleEmptyTags.(*tg.MessagesSavedReactionTags) + if !ok || staleEmptyPage.Hash != 0 || len(staleEmptyPage.Tags) != 0 { + t.Fatalf("messages.getSavedReactionTags stale empty hash = %#v, want empty page hash 0", staleEmptyTags) + } + updateTagReq := &tg.MessagesUpdateSavedReactionTagRequest{Reaction: &tg.ReactionEmoji{Emoticon: "ok"}} + updateTagReq.SetTitle("Work") + if ok, err := r.onMessagesUpdateSavedReactionTag(ownerCtx, updateTagReq); err != nil || !ok { + t.Fatalf("messages.updateSavedReactionTag = ok %v err %v, want true nil", ok, err) + } + globalTags, err := r.onMessagesGetSavedReactionTags(ownerCtx, &tg.MessagesGetSavedReactionTagsRequest{}) + if err != nil { + t.Fatalf("messages.getSavedReactionTags global: %v", err) + } + globalPage, ok := globalTags.(*tg.MessagesSavedReactionTags) + if !ok || globalPage.Hash == 0 || len(globalPage.Tags) != 1 { + t.Fatalf("messages.getSavedReactionTags global = %#v, want one hashable tag", globalTags) + } + if emoji, ok := globalPage.Tags[0].Reaction.(*tg.ReactionEmoji); !ok || emoji.Emoticon != "ok" || globalPage.Tags[0].Title != "Work" || globalPage.Tags[0].Count != 0 { + t.Fatalf("messages.getSavedReactionTags tag = %+v, want ok/Work/count0", globalPage.Tags[0]) + } + globalNotModified, err := r.onMessagesGetSavedReactionTags(ownerCtx, &tg.MessagesGetSavedReactionTagsRequest{Hash: globalPage.Hash}) + if err != nil { + t.Fatalf("messages.getSavedReactionTags hash: %v", err) + } + if _, ok := globalNotModified.(*tg.MessagesSavedReactionTagsNotModified); !ok { + t.Fatalf("messages.getSavedReactionTags hash = %#v, want notModified", globalNotModified) + } + peerTagsAfterUpdate, err := r.onMessagesGetSavedReactionTags(ownerCtx, savedTagsReq) + if err != nil { + t.Fatalf("messages.getSavedReactionTags peer after update: %v", err) + } + if got := peerTagsAfterUpdate.(*tg.MessagesSavedReactionTags).Tags; len(got) != 0 { + t.Fatalf("messages.getSavedReactionTags peer after update = %+v, want empty until saved-message tag store exists", got) + } + longTagReq := &tg.MessagesUpdateSavedReactionTagRequest{Reaction: &tg.ReactionEmoji{Emoticon: "ok"}} + longTagReq.SetTitle("abcdefghijklmnop") + if _, err := r.onMessagesUpdateSavedReactionTag(ownerCtx, longTagReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.updateSavedReactionTag long title err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onMessagesUpdateSavedReactionTag(ownerCtx, &tg.MessagesUpdateSavedReactionTagRequest{ + Reaction: &tg.ReactionCustomEmoji{DocumentID: 1}, + }); err == nil || !strings.Contains(err.Error(), "REACTION_INVALID") { + t.Fatalf("messages.updateSavedReactionTag custom emoji err = %v, want REACTION_INVALID", err) + } + tagReactions, err := r.onMessagesGetDefaultTagReactions(ownerCtx, 0) + if err != nil { + t.Fatalf("messages.getDefaultTagReactions: %v", err) + } + if got := tagReactions.(*tg.MessagesReactions).Reactions; len(got) != 0 { + t.Fatalf("messages.getDefaultTagReactions = %+v, want empty", got) + } + // poll 链路已是真实现:对非 poll 消息一律 MESSAGE_ID_INVALID(与官方一致)。 + if _, err := r.onMessagesSendVote(ownerCtx, &tg.MessagesSendVoteRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + Options: [][]byte{[]byte("a")}, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.sendVote err = %v, want MESSAGE_ID_INVALID for non-poll message", err) + } + if _, err := r.onMessagesSendVote(ownerCtx, &tg.MessagesSendVoteRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + Options: make([][]byte, maxPollVoteOptions+1), + }); err == nil || !strings.Contains(err.Error(), "OPTIONS_TOO_MUCH") { + t.Fatalf("messages.sendVote too many options err = %v, want OPTIONS_TOO_MUCH", err) + } + if _, err := r.onMessagesGetPollResults(ownerCtx, &tg.MessagesGetPollResultsRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.getPollResults err = %v, want MESSAGE_ID_INVALID for non-poll message", err) + } + pollVotesReq := &tg.MessagesGetPollVotesRequest{ + Peer: inputPeerChannel(channel), + ID: 1, + Option: []byte("a"), + Limit: 10, + } + pollVotesReq.SetOption(pollVotesReq.Option) + if _, err := r.onMessagesGetPollVotes(ownerCtx, pollVotesReq); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.getPollVotes err = %v, want MESSAGE_ID_INVALID for non-poll message", err) + } + if _, err := r.onMessagesGetPollVotes(ownerCtx, &tg.MessagesGetPollVotesRequest{ + Peer: inputPeerChannel(channel), + ID: 1, + Limit: maxSearchResultsLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.getPollVotes huge limit err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onMessagesAddPollAnswer(ownerCtx, &tg.MessagesAddPollAnswerRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + Answer: &tg.InputPollAnswer{Text: tg.TextWithEntities{Text: "extra"}}, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.addPollAnswer err = %v, want MESSAGE_ID_INVALID without poll store", err) + } + if _, err := r.onMessagesDeletePollAnswer(ownerCtx, &tg.MessagesDeletePollAnswerRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + Option: []byte("a"), + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.deletePollAnswer err = %v, want MESSAGE_ID_INVALID without poll store", err) + } + unreadPollVotes, err := r.onMessagesGetUnreadPollVotes(ownerCtx, &tg.MessagesGetUnreadPollVotesRequest{ + Peer: inputPeerChannel(channel), + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadPollVotes: %v", err) + } + if len(unreadPollVotes.(*tg.MessagesMessages).Messages) != 0 { + t.Fatalf("messages.getUnreadPollVotes = %+v, want empty messages", unreadPollVotes) + } + if _, err := r.onMessagesReadPollVotes(ownerCtx, &tg.MessagesReadPollVotesRequest{ + Peer: inputPeerChannel(channel), + }); err != nil { + t.Fatalf("messages.readPollVotes: %v", err) + } + if _, err := r.onMessagesAppendTodoList(ownerCtx, &tg.MessagesAppendTodoListRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + }); err == nil || !strings.Contains(err.Error(), "TODO_NOT_MODIFIED") { + t.Fatalf("messages.appendTodoList empty err = %v, want TODO_NOT_MODIFIED", err) + } + if _, err := r.onMessagesAppendTodoList(ownerCtx, &tg.MessagesAppendTodoListRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + List: []tg.TodoItem{{Title: tg.TextWithEntities{Text: "item"}}}, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.appendTodoList err = %v, want MESSAGE_ID_INVALID without todo store", err) + } + if _, err := r.onMessagesToggleTodoCompleted(ownerCtx, &tg.MessagesToggleTodoCompletedRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + }); err == nil || !strings.Contains(err.Error(), "TODO_NOT_MODIFIED") { + t.Fatalf("messages.toggleTodoCompleted empty err = %v, want TODO_NOT_MODIFIED", err) + } + if _, err := r.onMessagesToggleTodoCompleted(ownerCtx, &tg.MessagesToggleTodoCompletedRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + Completed: []int{1}, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.toggleTodoCompleted err = %v, want MESSAGE_ID_INVALID without todo store", err) + } + counters, err := r.onMessagesGetSearchCounters(ownerCtx, &tg.MessagesGetSearchCountersRequest{ + Peer: inputPeerChannel(channel), + Filters: []tg.MessagesFilterClass{&tg.InputMessagesFilterEmpty{}}, + }) + if err != nil { + t.Fatalf("messages.getSearchCounters: %v", err) + } + if len(counters) != 1 || counters[0].Count != 0 { + t.Fatalf("messages.getSearchCounters = %+v, want one zero counter", counters) + } + calendar, err := r.onMessagesGetSearchResultsCalendar(ownerCtx, &tg.MessagesGetSearchResultsCalendarRequest{ + Peer: inputPeerChannel(channel), + Filter: &tg.InputMessagesFilterPhotos{}, + OffsetID: 7, + OffsetDate: 1700000000, + }) + if err != nil { + t.Fatalf("messages.getSearchResultsCalendar: %v", err) + } + if calendar.Count != 0 || calendar.MinDate != 1700000000 || calendar.MinMsgID != 7 || len(calendar.Messages) != 0 { + t.Fatalf("messages.getSearchResultsCalendar = %+v, want empty stable-offset result", calendar) + } + positions, err := r.onMessagesGetSearchResultsPositions(ownerCtx, &tg.MessagesGetSearchResultsPositionsRequest{ + Peer: inputPeerChannel(channel), + Filter: &tg.InputMessagesFilterPhotos{}, + OffsetID: 7, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getSearchResultsPositions: %v", err) + } + if positions.Count != 0 || len(positions.Positions) != 0 { + t.Fatalf("messages.getSearchResultsPositions = %+v, want empty positions", positions) + } + if _, err := r.onMessagesGetSearchResultsPositions(ownerCtx, &tg.MessagesGetSearchResultsPositionsRequest{ + Peer: inputPeerChannel(channel), + Filter: &tg.InputMessagesFilterPhotos{}, + OffsetID: 7, + Limit: maxSearchResultsLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("messages.getSearchResultsPositions huge limit err = %v, want LIMIT_INVALID", err) + } + replies, err := r.onMessagesGetReplies(ownerCtx, &tg.MessagesGetRepliesRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getReplies: %v", err) + } + _, replyChats, _ := searchMessagesPayload(t, replies) + if len(replyChats) != 1 { + t.Fatalf("messages.getReplies = %+v, want channel context", replies) + } + discussion, err := r.onMessagesGetDiscussionMessage(ownerCtx, &tg.MessagesGetDiscussionMessageRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + }) + if err != nil { + t.Fatalf("messages.getDiscussionMessage: %v", err) + } + if len(discussion.Messages) != 1 || len(discussion.Chats) != 1 || discussion.UnreadCount != 0 { + t.Fatalf("messages.getDiscussionMessage = %+v, want root message with channel context", discussion) + } + if _, ok := discussion.Messages[0].(*tg.MessageService); !ok { + t.Fatalf("messages.getDiscussionMessage message = %T, want service root", discussion.Messages[0]) + } + if _, err := r.onMessagesReadDiscussion(ownerCtx, &tg.MessagesReadDiscussionRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + ReadMaxID: 1, + }); err != nil { + t.Fatalf("messages.readDiscussion err = %v, want nil", err) + } + if _, err := r.onMessagesGetDiscussionMessage(ownerCtx, &tg.MessagesGetDiscussionMessageRequest{ + Peer: inputPeerChannelWithHash(channel, channel.AccessHash+1), + MsgID: 1, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("messages.getDiscussionMessage bad hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesReadDiscussion(ownerCtx, &tg.MessagesReadDiscussionRequest{ + Peer: inputPeerChannel(channel), + MsgID: 1, + ReadMaxID: domain.MaxMessageBoxID + 1, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("messages.readDiscussion huge read_max_id err = %v, want MESSAGE_ID_INVALID", err) + } + if _, err := r.onMessagesGetForumTopics(ownerCtx, &tg.MessagesGetForumTopicsRequest{ + Peer: inputPeerChannel(channel), + Limit: 10, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.getForumTopics non-forum err = %v, want CHANNEL_FORUM_MISSING", err) + } + if _, err := r.onMessagesGetForumTopicsByID(ownerCtx, &tg.MessagesGetForumTopicsByIDRequest{ + Peer: inputPeerChannel(channel), + Topics: []int{1}, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { + t.Fatalf("messages.getForumTopicsByID non-forum err = %v, want CHANNEL_FORUM_MISSING", err) + } + onlines, err := r.onMessagesGetOnlines(ownerCtx, inputPeerChannel(channel)) + if err != nil { + t.Fatalf("messages.getOnlines: %v", err) + } + if onlines.Onlines != 1 { + t.Fatalf("messages.getOnlines = %+v, want count 1", onlines) + } + stats, err := r.onStatsGetMegagroupStats(ownerCtx, &tg.StatsGetMegagroupStatsRequest{ + Channel: inputChannel(channel), + }) + if err != nil { + t.Fatalf("stats.getMegagroupStats: %v", err) + } + if stats.GrowthGraph == nil || stats.MembersGraph == nil { + t.Fatalf("megagroup stats = %+v, want non-nil graph stubs", stats) + } + if _, err := r.onStatsGetBroadcastStats(ownerCtx, &tg.StatsGetBroadcastStatsRequest{ + Channel: inputChannel(channel), + }); err == nil || !strings.Contains(err.Error(), "BROADCAST_REQUIRED") { + t.Fatalf("stats.getBroadcastStats on megagroup err = %v, want BROADCAST_REQUIRED", err) + } + messageStats, err := r.onStatsGetMessageStats(ownerCtx, &tg.StatsGetMessageStatsRequest{ + Channel: inputChannel(channel), + MsgID: 1, + }) + if err != nil { + t.Fatalf("stats.getMessageStats: %v", err) + } + if messageStats.ViewsGraph == nil || messageStats.ReactionsByEmotionGraph == nil { + t.Fatalf("message stats = %+v, want non-nil graph stubs", messageStats) + } + forwards, err := r.onStatsGetMessagePublicForwards(ownerCtx, &tg.StatsGetMessagePublicForwardsRequest{ + Channel: inputChannel(channel), + MsgID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("stats.getMessagePublicForwards: %v", err) + } + if forwards.Count != 0 || len(forwards.Forwards) != 0 { + t.Fatalf("message public forwards = %+v, want empty", forwards) + } + if _, err := r.onStatsGetMessagePublicForwards(ownerCtx, &tg.StatsGetMessagePublicForwardsRequest{ + Channel: inputChannel(channel), + MsgID: 1, + Limit: maxStatsPublicForwardsLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("stats.getMessagePublicForwards huge limit err = %v, want LIMIT_INVALID", err) + } + graph, err := r.onStatsLoadAsyncGraph(ownerCtx, &tg.StatsLoadAsyncGraphRequest{Token: "stale"}) + if err != nil { + t.Fatalf("stats.loadAsyncGraph: %v", err) + } + if _, ok := graph.(*tg.StatsGraphError); !ok { + t.Fatalf("stats.loadAsyncGraph = %T, want statsGraphError", graph) + } + boostStatus, err := r.onPremiumGetBoostsStatus(ownerCtx, inputPeerChannel(channel)) + if err != nil { + t.Fatalf("premium.getBoostsStatus: %v", err) + } + if boostStatus.Level != 0 || boostStatus.CurrentLevelBoosts != 0 || boostStatus.Boosts != 0 { + t.Fatalf("premium.getBoostsStatus = level %d current %d boosts %d, want empty real state", boostStatus.Level, boostStatus.CurrentLevelBoosts, boostStatus.Boosts) + } + if boostStatus.BoostURL == "" { + t.Fatalf("premium.getBoostsStatus missing boost_url") + } + if next, ok := boostStatus.GetNextLevelBoosts(); !ok || next <= boostStatus.CurrentLevelBoosts { + t.Fatalf("premium.getBoostsStatus real NextLevelBoosts set=%v val=%d, want > CurrentLevelBoosts %d", ok, next, boostStatus.CurrentLevelBoosts) + } + boostStatusTDesktop, err := r.onPremiumGetBoostsStatus(WithClientInfo(ownerCtx, ClientInfo{ + Type: ClientTypeTDesktop, + AppVersion: "6.8.4 x64", + }), inputPeerChannel(channel)) + if err != nil { + t.Fatalf("premium.getBoostsStatus TDesktop: %v", err) + } + if boostStatusTDesktop.Level != boostStatus.Level || boostStatusTDesktop.CurrentLevelBoosts != boostStatus.CurrentLevelBoosts || boostStatusTDesktop.Boosts != boostStatus.Boosts { + t.Fatalf("premium.getBoostsStatus TDesktop = %+v, want same real status as generic client %+v", boostStatusTDesktop, boostStatus) + } + boosts, err := r.onPremiumGetBoostsList(ownerCtx, &tg.PremiumGetBoostsListRequest{ + Peer: inputPeerChannel(channel), + Limit: 10, + }) + if err != nil { + t.Fatalf("premium.getBoostsList: %v", err) + } + if boosts.Count != 0 || len(boosts.Boosts) != 0 || len(boosts.Users) != 0 { + t.Fatalf("premium.getBoostsList = %+v, want empty", boosts) + } + if _, err := r.onPremiumGetBoostsList(ownerCtx, &tg.PremiumGetBoostsListRequest{ + Peer: inputPeerChannel(channel), + Limit: domain.MaxPremiumBoostsListLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("premium.getBoostsList huge limit err = %v, want LIMIT_INVALID", err) + } + if _, err := f.users.SetPremiumUntil(f.ctx, owner.ID, int(time.Now().Add(time.Hour).Unix())); err != nil { + t.Fatalf("grant owner premium: %v", err) + } + myBoosts, err := r.onPremiumGetMyBoosts(ownerCtx) + if err != nil { + t.Fatalf("premium.getMyBoosts: %v", err) + } + if len(myBoosts.MyBoosts) != 1 || myBoosts.MyBoosts[0].Slot != domain.DefaultPremiumBoostSlotID || myBoosts.MyBoosts[0].Date != 0 || myBoosts.MyBoosts[0].Expires != 0 { + t.Fatalf("premium.getMyBoosts = %+v, want one free base slot", myBoosts) + } + if _, ok := myBoosts.MyBoosts[0].GetPeer(); ok || len(myBoosts.Chats) != 0 || len(myBoosts.Users) != 0 { + t.Fatalf("premium.getMyBoosts free slot refs = %+v, want no peer refs", myBoosts) + } + applyReq := &tg.PremiumApplyBoostRequest{ + Peer: inputPeerChannel(channel), + } + applyReq.SetSlots([]int{1}) + applied, err := r.onPremiumApplyBoost(ownerCtx, applyReq) + if err != nil { + t.Fatalf("premium.applyBoost: %v", err) + } + if len(applied.MyBoosts) != 1 { + t.Fatalf("premium.applyBoost = %+v, want assigned base slot", applied) + } + appliedPeer, ok := applied.MyBoosts[0].GetPeer() + if !ok { + t.Fatalf("premium.applyBoost assigned slot missing peer: %+v", applied.MyBoosts[0]) + } + if peer, ok := appliedPeer.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { + t.Fatalf("premium.applyBoost peer = %#v, want channel %d", appliedPeer, channel.ID) + } + boostStatusAfterApply, err := r.onPremiumGetBoostsStatus(ownerCtx, inputPeerChannel(channel)) + if err != nil { + t.Fatalf("premium.getBoostsStatus after apply: %v", err) + } + if boostStatusAfterApply.Boosts != 1 || !boostStatusAfterApply.MyBoost { + t.Fatalf("premium.getBoostsStatus after apply = %+v, want one self boost", boostStatusAfterApply) + } + if _, err := r.onPremiumApplyBoost(ownerCtx, applyReq); err == nil || !strings.Contains(err.Error(), "BOOST_NOT_MODIFIED") { + t.Fatalf("premium.applyBoost same peer err = %v, want BOOST_NOT_MODIFIED", err) + } + if _, err := r.onPremiumApplyBoost(ownerCtx, &tg.PremiumApplyBoostRequest{ + Peer: inputPeerChannel(channel), + }); err == nil || !strings.Contains(err.Error(), "BOOSTS_EMPTY") { + t.Fatalf("premium.applyBoost missing slots err = %v, want BOOSTS_EMPTY", err) + } + badSlotReq := &tg.PremiumApplyBoostRequest{Peer: inputPeerChannel(channel)} + badSlotReq.SetSlots([]int{2}) + if _, err := r.onPremiumApplyBoost(ownerCtx, badSlotReq); err == nil || !strings.Contains(err.Error(), "SLOTS_INVALID") { + t.Fatalf("premium.applyBoost unsupported slot err = %v, want SLOTS_INVALID", err) + } + userBoosts, err := r.onPremiumGetUserBoosts(ownerCtx, &tg.PremiumGetUserBoostsRequest{ + Peer: inputPeerChannel(channel), + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + }) + if err != nil { + t.Fatalf("premium.getUserBoosts: %v", err) + } + if userBoosts.Count != 0 || len(userBoosts.Boosts) != 0 { + t.Fatalf("premium.getUserBoosts invited = %+v, want empty", userBoosts) + } + ownerBoosts, err := r.onPremiumGetUserBoosts(ownerCtx, &tg.PremiumGetUserBoostsRequest{ + Peer: inputPeerChannel(channel), + UserID: inputUser(owner), + }) + if err != nil { + t.Fatalf("premium.getUserBoosts owner: %v", err) + } + if ownerBoosts.Count != 1 || len(ownerBoosts.Boosts) != 1 { + t.Fatalf("premium.getUserBoosts owner = %+v, want one boost", ownerBoosts) + } + if boostedList, err := r.onPremiumGetBoostsList(ownerCtx, &tg.PremiumGetBoostsListRequest{ + Peer: inputPeerChannel(channel), + Limit: 10, + }); err != nil { + t.Fatalf("premium.getBoostsList after apply: %v", err) + } else if boostedList.Count != 1 || len(boostedList.Boosts) != 1 || len(boostedList.Users) != 1 { + t.Fatalf("premium.getBoostsList after apply = %+v, want one boost and user", boostedList) + } + if _, err := r.onMessagesAddChatUser(ownerCtx, &tg.MessagesAddChatUserRequest{ + ChatID: channel.ID, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + }); err != nil { + t.Fatalf("messages.addChatUser legacy wrapper: %v", err) + } + if _, err := r.onMessagesEditChatPhoto(ownerCtx, &tg.MessagesEditChatPhotoRequest{ + ChatID: channel.ID, + Photo: &tg.InputChatPhotoEmpty{}, + }); err != nil { + t.Fatalf("messages.editChatPhoto legacy wrapper: %v", err) + } + if _, err := r.onChannelsEditPhoto(ownerCtx, &tg.ChannelsEditPhotoRequest{ + Channel: inputChannel(channel), + Photo: &tg.InputChatUploadedPhoto{}, + }); err == nil || !strings.Contains(err.Error(), "PHOTO_INVALID") { + t.Fatalf("channels.editPhoto uploaded photo err = %v, want PHOTO_INVALID", err) + } + if _, err := r.onChannelsEditPhoto(ownerCtx, &tg.ChannelsEditPhotoRequest{ + Channel: inputChannel(channel), + Photo: &tg.InputChatPhoto{ID: &tg.InputPhoto{ID: 1, AccessHash: 2, FileReference: []byte{3}}}, + }); err == nil || !strings.Contains(err.Error(), "PHOTO_INVALID") { + t.Fatalf("channels.editPhoto existing photo err = %v, want PHOTO_INVALID", err) + } + if ok, err := r.onMessagesEditChatAdmin(ownerCtx, &tg.MessagesEditChatAdminRequest{ + ChatID: channel.ID, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + IsAdmin: true, + }); err != nil || !ok { + t.Fatalf("messages.editChatAdmin legacy wrapper = %v, %v", ok, err) + } + if _, err := r.onMessagesEditChatParticipantRank(ownerCtx, &tg.MessagesEditChatParticipantRankRequest{ + Peer: &tg.InputPeerChat{ChatID: channel.ID}, + Participant: &tg.InputPeerUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + Rank: "ops", + }); err != nil { + t.Fatalf("messages.editChatParticipantRank legacy wrapper: %v", err) + } + if ok, err := r.onMessagesEditChatAbout(ownerCtx, &tg.MessagesEditChatAboutRequest{ + Peer: &tg.InputPeerChat{ChatID: channel.ID}, + About: "legacy about", + }); err != nil || !ok { + t.Fatalf("messages.editChatAbout legacy wrapper = %v, %v", ok, err) + } + fullAfterAbout, err := r.onMessagesGetFullChat(ownerCtx, channel.ID) + if err != nil { + t.Fatalf("messages.getFullChat after editChatAbout: %v", err) + } + if got := fullAfterAbout.FullChat.(*tg.ChannelFull).About; got != "legacy about" { + t.Fatalf("full chat about = %q, want legacy about", got) + } + if _, err := r.onMessagesEditChatCreator(ownerCtx, &tg.MessagesEditChatCreatorRequest{ + Peer: &tg.InputPeerChat{ChatID: channel.ID}, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + }); err == nil { + t.Fatalf("messages.editChatCreator = nil error, want explicit password/2FA error") + } + permUpdates, err := r.onMessagesEditChatDefaultBannedRights(ownerCtx, &tg.MessagesEditChatDefaultBannedRightsRequest{ + Peer: inputPeerChannel(channel), + BannedRights: tg.ChatBannedRights{ + SendMessages: true, + }, + }) + if err != nil { + t.Fatalf("messages.editChatDefaultBannedRights: %v", err) + } + if len(permUpdates.(*tg.Updates).Updates) == 0 { + t.Fatalf("messages.editChatDefaultBannedRights updates = %+v, want updateChannel", permUpdates) + } + if _, err := r.onMessagesDeleteChatUser(ownerCtx, &tg.MessagesDeleteChatUserRequest{ + ChatID: channel.ID, + UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, + }); err != nil { + t.Fatalf("messages.deleteChatUser legacy wrapper: %v", err) + } +} diff --git a/internal/rpc/channels_public_preview_rpc_test.go b/internal/rpc/channels_public_preview_rpc_test.go new file mode 100644 index 00000000..d78c0429 --- /dev/null +++ b/internal/rpc/channels_public_preview_rpc_test.go @@ -0,0 +1,199 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 92001, Phone: "15550092001", FirstName: "Owner"}) + viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 92002, Phone: "15550092002", FirstName: "Viewer"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + dialogService := appdialogs.NewService(memory.NewDialogStore(), channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Dialogs: dialogService, + }, zaptest.NewLogger(t), clock.System) + public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Public Preview RPC", + Broadcast: true, + Date: 1700010100, + }) + if err != nil { + t.Fatalf("create public channel: %v", err) + } + if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: public.Channel.ID, + Username: "public_preview_rpc", + }); err != nil { + t.Fatalf("publish channel username: %v", err) + } + sent, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: public.Channel.ID, + RandomID: 201, + Message: "public preview rpc post", + Date: 1700010110, + }) + if err != nil { + t.Fatalf("send public post: %v", err) + } + input := &tg.InputChannel{ChannelID: public.Channel.ID, AccessHash: public.Channel.AccessHash} + peer := &tg.InputPeerChannel{ChannelID: public.Channel.ID, AccessHash: public.Channel.AccessHash} + + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, viewer.ID), input) + if err != nil { + t.Fatalf("non-member getFullChannel public preview: %v", err) + } + if len(full.Chats) != 1 { + t.Fatalf("full chats = %d, want one channel", len(full.Chats)) + } + chat, ok := full.Chats[0].(*tg.Channel) + if !ok || !chat.Left || chat.ID != public.Channel.ID { + t.Fatalf("full channel chat = %T %+v, want left public channel", full.Chats[0], full.Chats[0]) + } + channelFull, ok := full.FullChat.(*tg.ChannelFull) + if !ok || channelFull.ID != public.Channel.ID || channelFull.UnreadCount != 0 { + t.Fatalf("full chat = %T %+v, want channel full without unread", full.FullChat, full.FullChat) + } + + chats, err := r.onChannelsGetChannels(WithUserID(ctx, viewer.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("non-member getChannels public preview: %v", err) + } + if len(chats.(*tg.MessagesChats).Chats) != 1 { + t.Fatalf("getChannels chats = %d, want one public preview channel", len(chats.(*tg.MessagesChats).Chats)) + } + listed, ok := chats.(*tg.MessagesChats).Chats[0].(*tg.Channel) + if !ok || !listed.Left || listed.ID != public.Channel.ID { + t.Fatalf("getChannels chat = %T %+v, want left public channel", chats.(*tg.MessagesChats).Chats[0], chats.(*tg.MessagesChats).Chats[0]) + } + + sendAs, err := r.onChannelsGetSendAs(WithUserID(ctx, viewer.ID), &tg.ChannelsGetSendAsRequest{Peer: peer}) + if err != nil { + t.Fatalf("non-member getSendAs public preview: %v", err) + } + if len(sendAs.Peers) != 1 { + t.Fatalf("sendAs peers = %+v, want only current user peer", sendAs.Peers) + } + if len(sendAs.Chats) != 1 { + t.Fatalf("sendAs chats = %d, want public channel chat", len(sendAs.Chats)) + } + + historyReq := &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 10} + var in bin.Buffer + if err := historyReq.Encode(&in); err != nil { + t.Fatalf("encode getHistory: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch getHistory public preview: %v", err) + } + box, ok := enc.(*tg.MessagesMessagesBox) + if !ok { + t.Fatalf("getHistory response = %T, want boxed messages", enc) + } + history, ok := box.Messages.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("boxed getHistory = %T, want channel messages", box.Messages) + } + foundPost := false + for _, item := range history.Messages { + if msg, ok := item.(*tg.Message); ok && msg.Message == "public preview rpc post" { + foundPost = true + } + } + if !foundPost { + t.Fatalf("history messages = %+v, want public preview post", history.Messages) + } + if len(history.Chats) != 1 { + t.Fatalf("history chats = %d, want public channel chat", len(history.Chats)) + } + historyChat, ok := history.Chats[0].(*tg.Channel) + if !ok || !historyChat.Left || historyChat.ID != public.Channel.ID { + t.Fatalf("history chat = %T %+v, want left public channel", history.Chats[0], history.Chats[0]) + } + + diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, viewer.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: input, + Pts: public.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("non-member getChannelDifference public preview: %v", err) + } + fullDiff, ok := diff.(*tg.UpdatesChannelDifference) + if !ok || !fullDiff.Final || fullDiff.Pts != sent.Event.Pts || len(fullDiff.NewMessages) != 1 { + t.Fatalf("channel difference = %T %+v, want one public preview message at current pts", diff, diff) + } + diffMsg, ok := fullDiff.NewMessages[0].(*tg.Message) + if !ok || diffMsg.Message != "public preview rpc post" { + t.Fatalf("channel difference message = %T %+v, want public preview rpc post", fullDiff.NewMessages[0], fullDiff.NewMessages[0]) + } + if len(fullDiff.Chats) != 1 { + t.Fatalf("channel difference chats = %d, want public channel chat", len(fullDiff.Chats)) + } + diffChat, ok := fullDiff.Chats[0].(*tg.Channel) + if !ok || !diffChat.Left || diffChat.ID != public.Channel.ID { + t.Fatalf("channel difference chat = %T %+v, want left public channel", fullDiff.Chats[0], fullDiff.Chats[0]) + } + + domainPeers, err := r.dialogPeersFromInput(WithUserID(ctx, viewer.ID), viewer.ID, []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: peer}}) + if err != nil { + t.Fatalf("dialog peer conversion public preview: %v", err) + } + if len(domainPeers) != 1 || domainPeers[0].Type != domain.PeerTypeChannel || domainPeers[0].ID != public.Channel.ID { + t.Fatalf("domain peers = %+v, want public channel peer", domainPeers) + } + directPeerDialogs, err := dialogService.GetPeerDialogs(ctx, viewer.ID, domainPeers) + if err != nil { + t.Fatalf("dialog service public preview: %v", err) + } + if len(directPeerDialogs.Dialogs) != 1 || len(directPeerDialogs.ChannelMessages) != 1 || len(directPeerDialogs.Channels) != 1 { + t.Fatalf("direct peer dialogs = %+v, want one dialog/message/channel", directPeerDialogs) + } + + peerDialogsReq := &tg.MessagesGetPeerDialogsRequest{ + Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: peer}}, + } + var peerDialogsIn bin.Buffer + if err := peerDialogsReq.Encode(&peerDialogsIn); err != nil { + t.Fatalf("encode getPeerDialogs: %v", err) + } + peerDialogsEnc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &peerDialogsIn) + if err != nil { + t.Fatalf("dispatch getPeerDialogs public preview: %v", err) + } + peerDialogs, ok := peerDialogsEnc.(*tg.MessagesPeerDialogs) + if !ok { + t.Fatalf("getPeerDialogs response = %T, want peer dialogs", peerDialogsEnc) + } + if len(peerDialogs.Dialogs) != 1 || len(peerDialogs.Messages) != 1 || len(peerDialogs.Chats) != 1 { + t.Fatalf("peer dialogs = %+v, want one dialog/message/channel", peerDialogs) + } + tgDialog, ok := peerDialogs.Dialogs[0].(*tg.Dialog) + if !ok || tgDialog.TopMessage <= 0 || tgDialog.UnreadCount != 0 { + t.Fatalf("peer dialog = %T %+v, want read-only public preview dialog", peerDialogs.Dialogs[0], peerDialogs.Dialogs[0]) + } + tgMessage, ok := peerDialogs.Messages[0].(*tg.Message) + if !ok || tgMessage.Message != "public preview rpc post" { + t.Fatalf("peer dialog message = %T %+v, want public preview rpc post", peerDialogs.Messages[0], peerDialogs.Messages[0]) + } + peerDialogChat, ok := peerDialogs.Chats[0].(*tg.Channel) + if !ok || !peerDialogChat.Left || peerDialogChat.ID != public.Channel.ID { + t.Fatalf("peer dialog chat = %T %+v, want left public channel", peerDialogs.Chats[0], peerDialogs.Chats[0]) + } +} diff --git a/internal/rpc/channels_read_reactions.go b/internal/rpc/channels_read_reactions.go new file mode 100644 index 00000000..10542538 --- /dev/null +++ b/internal/rpc/channels_read_reactions.go @@ -0,0 +1,130 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func (r *Router) onChannelsReadMessageContents(ctx context.Context, req *tg.ChannelsReadMessageContentsRequest) (bool, error) { + if r.deps.Channels == nil { + return false, notImplementedErr() + } + if req == nil { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return false, err + } + read, err := r.deps.Channels.ReadMessageContents(ctx, userID, domain.ReadChannelMessageContentsRequest{ + UserID: userID, + ChannelID: channelID, + IDs: req.ID, + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return false, messageIDInvalidErr() + } + return false, channelInvalidErr(err) + } + if ids := readChannelMessageContentIDs(read.Messages); len(ids) > 0 { + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateChannelReadMessagesContents{ + ChannelID: read.Channel.ID, + Messages: ids, + }}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{tgChannelChatMin(userID, read.Channel)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) + } + if len(read.ClearedUnreadReactionMessageIDs) > 0 { + r.pushUserUpdates(ctx, userID, r.channelMessagesReactionsUpdates(ctx, userID, domain.ChannelMessageReactionsResult{ + Channel: read.Channel, + Messages: read.Messages, + }, read.ClearedUnreadReactionMessageIDs)) + } + return true, nil +} + +func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsReadHistoryRequest) (bool, error) { + if r.deps.Channels == nil { + return true, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return false, err + } + read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{ + UserID: userID, + ChannelID: channelID, + MaxID: req.MaxID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return false, channelInvalidErr(err) + } + if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil { + return false, err + } + r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates) + r.advanceForumGeneralReadAfterChannelRead(ctx, userID, read) + return true, nil +} + +func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsRequest) (domain.ChannelReactionPolicy, error) { + if req == nil || req.AvailableReactions == nil { + return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") + } + if req.ReactionsLimit < 0 || req.ReactionsLimit > domain.MaxChannelReactionsLimit { + return domain.ChannelReactionPolicy{}, limitInvalidErr() + } + policy := domain.ChannelReactionPolicy{ + Limit: req.ReactionsLimit, + PaidEnabled: req.PaidEnabled, + } + switch reactions := req.AvailableReactions.(type) { + case *tg.ChatReactionsNone: + policy.Type = domain.ChannelReactionPolicyNone + case *tg.ChatReactionsAll: + policy.Type = domain.ChannelReactionPolicyAll + policy.AllowCustom = reactions.AllowCustom + case *tg.ChatReactionsSome: + if len(reactions.Reactions) > domain.MaxChannelReactionTypes { + return domain.ChannelReactionPolicy{}, limitInvalidErr() + } + policy.Type = domain.ChannelReactionPolicySome + seen := make(map[string]struct{}, len(reactions.Reactions)) + for _, reaction := range reactions.Reactions { + parsed, err := domainMessageReactionFromTL(reaction) + if err != nil { + return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") + } + key := parsed.Key() + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + switch parsed.Type { + case domain.MessageReactionEmoji: + policy.Emoticons = append(policy.Emoticons, parsed.Emoticon) + case domain.MessageReactionCustomEmoji: + policy.CustomEmojiIDs = append(policy.CustomEmojiIDs, parsed.DocumentID) + } + } + default: + return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") + } + return policy, nil +} diff --git a/internal/rpc/channels_register.go b/internal/rpc/channels_register.go new file mode 100644 index 00000000..888581ad --- /dev/null +++ b/internal/rpc/channels_register.go @@ -0,0 +1,101 @@ +package rpc + +import ( + "github.com/gotd/td/tg" +) + +// registerChannels 注册超级群/频道相关 RPC。messages.createChat 在这里注册, +// 因为 telesrv 将普通群创建直接实现为 megagroup。 +func (r *Router) registerChannels(d *tg.ServerDispatcher) { + d.OnMessagesCreateChat(r.onMessagesCreateChat) + d.OnMessagesMigrateChat(r.onMessagesMigrateChat) + d.OnMessagesGetChats(r.onMessagesGetChats) + d.OnMessagesGetFullChat(r.onMessagesGetFullChat) + d.OnMessagesAddChatUser(r.onMessagesAddChatUser) + d.OnMessagesDeleteChatUser(r.onMessagesDeleteChatUser) + d.OnMessagesEditChatTitle(r.onMessagesEditChatTitle) + d.OnMessagesEditChatPhoto(r.onMessagesEditChatPhoto) + d.OnMessagesEditChatAdmin(r.onMessagesEditChatAdmin) + d.OnMessagesEditChatAbout(r.onMessagesEditChatAbout) + d.OnMessagesEditChatDefaultBannedRights(r.onMessagesEditChatDefaultBannedRights) + d.OnMessagesEditChatCreator(r.onMessagesEditChatCreator) + d.OnMessagesGetFutureChatCreatorAfterLeave(r.onMessagesGetFutureChatCreatorAfterLeave) + d.OnMessagesEditChatParticipantRank(r.onMessagesEditChatParticipantRank) + d.OnMessagesSetChatTheme(r.onMessagesSetChatTheme) + d.OnMessagesSetChatWallPaper(r.onMessagesSetChatWallPaper) + d.OnMessagesToggleNoForwards(r.onMessagesToggleNoForwards) + d.OnMessagesSetChatAvailableReactions(r.onMessagesSetChatAvailableReactions) + d.OnChannelsCreateChannel(r.onChannelsCreateChannel) + d.OnChannelsGetChannels(r.onChannelsGetChannels) + d.OnChannelsGetFullChannel(r.onChannelsGetFullChannel) + d.OnChannelsGetParticipants(r.onChannelsGetParticipants) + d.OnChannelsGetParticipant(r.onChannelsGetParticipant) + d.OnChannelsGetSendAs(r.onChannelsGetSendAs) + d.OnChannelsCheckUsername(r.onChannelsCheckUsername) + d.OnChannelsUpdateUsername(r.onChannelsUpdateUsername) + d.OnChannelsGetAdminedPublicChannels(r.onChannelsGetAdminedPublicChannels) + d.OnChannelsExportMessageLink(r.onChannelsExportMessageLink) + d.OnChannelsToggleSignatures(r.onChannelsToggleSignatures) + d.OnChannelsTogglePreHistoryHidden(r.onChannelsTogglePreHistoryHidden) + d.OnChannelsToggleSlowMode(r.onChannelsToggleSlowMode) + d.OnChannelsSetStickers(r.onChannelsSetStickers) + d.OnChannelsSetEmojiStickers(r.onChannelsSetEmojiStickers) + d.OnChannelsReorderUsernames(r.onChannelsReorderUsernames) + d.OnChannelsToggleUsername(r.onChannelsToggleUsername) + d.OnChannelsDeactivateAllUsernames(r.onChannelsDeactivateAllUsernames) + d.OnChannelsUpdateColor(r.onChannelsUpdateColor) + d.OnChannelsUpdateEmojiStatus(r.onChannelsUpdateEmojiStatus) + d.OnChannelsReadMessageContents(r.onChannelsReadMessageContents) + d.OnChannelsReportSpam(r.onChannelsReportSpam) + d.OnChannelsGetLeftChannels(r.onChannelsGetLeftChannels) + d.OnChannelsGetInactiveChannels(r.onChannelsGetInactiveChannels) + d.OnChannelsGetGroupsForDiscussion(r.onChannelsGetGroupsForDiscussion) + d.OnChannelsSetDiscussionGroup(r.onChannelsSetDiscussionGroup) + d.OnChannelsEditLocation(r.onChannelsEditLocation) + d.OnChannelsConvertToGigagroup(r.onChannelsConvertToGigagroup) + d.OnChannelsDeleteParticipantHistory(r.onChannelsDeleteParticipantHistory) + d.OnChannelsToggleJoinToSend(r.onChannelsToggleJoinToSend) + d.OnChannelsToggleJoinRequest(r.onChannelsToggleJoinRequest) + d.OnChannelsToggleForum(r.onChannelsToggleForum) + d.OnChannelsToggleAntiSpam(r.onChannelsToggleAntiSpam) + d.OnChannelsReportAntiSpamFalsePositive(r.onChannelsReportAntiSpamFalsePositive) + d.OnChannelsToggleParticipantsHidden(r.onChannelsToggleParticipantsHidden) + d.OnChannelsToggleViewForumAsMessages(r.onChannelsToggleViewForumAsMessages) + d.OnChannelsGetChannelRecommendations(r.onChannelsGetChannelRecommendations) + d.OnChannelsSetBoostsToUnblockRestrictions(r.onChannelsSetBoostsToUnblockRestrictions) + d.OnChannelsRestrictSponsoredMessages(r.onChannelsRestrictSponsoredMessages) + d.OnChannelsSearchPosts(r.onChannelsSearchPosts) + d.OnChannelsUpdatePaidMessagesPrice(r.onChannelsUpdatePaidMessagesPrice) + d.OnChannelsToggleAutotranslation(r.onChannelsToggleAutotranslation) + d.OnChannelsGetMessageAuthor(r.onChannelsGetMessageAuthor) + d.OnChannelsCheckSearchPostsFlood(r.onChannelsCheckSearchPostsFlood) + d.OnChannelsSetMainProfileTab(r.onChannelsSetMainProfileTab) + d.OnChannelsInviteToChannel(r.onChannelsInviteToChannel) + d.OnChannelsJoinChannel(r.onChannelsJoinChannel) + d.OnChannelsLeaveChannel(r.onChannelsLeaveChannel) + d.OnChannelsEditAdmin(r.onChannelsEditAdmin) + d.OnChannelsEditBanned(r.onChannelsEditBanned) + d.OnChannelsEditTitle(r.onChannelsEditTitle) + d.OnChannelsEditPhoto(r.onChannelsEditPhoto) + d.OnChannelsDeleteChannel(r.onChannelsDeleteChannel) + d.OnChannelsGetAdminLog(r.onChannelsGetAdminLog) + d.OnChannelsReadHistory(r.onChannelsReadHistory) + d.OnChannelsGetMessages(r.onChannelsGetMessages) + d.OnChannelsDeleteMessages(r.onChannelsDeleteMessages) + d.OnChannelsDeleteHistory(r.onChannelsDeleteHistory) + d.OnMessagesUpdatePinnedMessage(r.onMessagesUpdatePinnedMessage) + d.OnMessagesUnpinAllMessages(r.onMessagesUnpinAllMessages) + d.OnMessagesExportChatInvite(r.onMessagesExportChatInvite) + d.OnMessagesCheckChatInvite(r.onMessagesCheckChatInvite) + d.OnMessagesImportChatInvite(r.onMessagesImportChatInvite) + d.OnMessagesGetExportedChatInvites(r.onMessagesGetExportedChatInvites) + d.OnMessagesGetExportedChatInvite(r.onMessagesGetExportedChatInvite) + d.OnMessagesEditExportedChatInvite(r.onMessagesEditExportedChatInvite) + d.OnMessagesDeleteRevokedExportedChatInvites(r.onMessagesDeleteRevokedExportedChatInvites) + d.OnMessagesDeleteExportedChatInvite(r.onMessagesDeleteExportedChatInvite) + d.OnMessagesGetAdminsWithInvites(r.onMessagesGetAdminsWithInvites) + d.OnMessagesGetChatInviteImporters(r.onMessagesGetChatInviteImporters) + d.OnMessagesHideChatJoinRequest(r.onMessagesHideChatJoinRequest) + d.OnMessagesHideAllChatJoinRequests(r.onMessagesHideAllChatJoinRequests) + d.OnUpdatesGetChannelDifference(r.onUpdatesGetChannelDifference) +} diff --git a/internal/rpc/channels_send_as_foreign_rpc_test.go b/internal/rpc/channels_send_as_foreign_rpc_test.go new file mode 100644 index 00000000..1ba1fa46 --- /dev/null +++ b/internal/rpc/channels_send_as_foreign_rpc_test.go @@ -0,0 +1,245 @@ +package rpc + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// createBroadcastChannelRPC creates a broadcast channel owned by ownerID and returns its tg.Channel. +func createBroadcastChannelRPC(t *testing.T, r *Router, ownerID int64, title string) *tg.Channel { + t.Helper() + created, err := r.onChannelsCreateChannel(WithUserID(context.Background(), ownerID), &tg.ChannelsCreateChannelRequest{ + Title: title, + About: "owned channel", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create broadcast %q: %v", title, err) + } + channel, ok := created.(*tg.Updates).Chats[0].(*tg.Channel) + if !ok || !channel.Broadcast { + t.Fatalf("create broadcast %q result = %#v, want broadcast channel", title, created) + } + return channel +} + +func findSendAsChannel(peers []tg.SendAsPeer, channelID int64) (tg.SendAsPeer, bool) { + for _, p := range peers { + if ch, ok := p.Peer.(*tg.PeerChannel); ok && ch.ChannelID == channelID { + return p, true + } + } + return tg.SendAsPeer{}, false +} + +func chatsContainChannel(chats []tg.ChatClass, channelID int64) bool { + for _, c := range chats { + switch ch := c.(type) { + case *tg.Channel: + if ch.ID == channelID { + return true + } + case *tg.ChannelForbidden: + if ch.ID == channelID { + return true + } + } + } + return false +} + +// TestChannelSendAsForeignChannelRPC verifies a premium user can post in another supergroup as one +// of their own broadcast channels: getSendAs lists the owned channel (flagged premium_required), the +// send path stamps from_id to that channel and ships it in Chats, a non-owner is rejected, and the +// default round-trips into channelFull.default_send_as. +func TestChannelSendAsForeignChannelRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + premiumUntil := int(time.Now().Add(72 * time.Hour).Unix()) + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002161", FirstName: "Owner", PremiumUntil: premiumUntil}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002162", FirstName: "Friend", PremiumUntil: premiumUntil}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + ownerCtx := WithUserID(ctx, owner.ID) + friendCtx := WithUserID(ctx, friend.ID) + + // Target supergroup G (owner creator, friend member) and an owned broadcast channel C. + created, err := r.onMessagesCreateChat(ownerCtx, &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Foreign Send As Group", + }) + if err != nil { + t.Fatalf("create group: %v", err) + } + group := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + owned := createBroadcastChannelRPC(t, r, owner.ID, "Owner Channel") + + // getSendAs(G) by owner: self + G + owned channel; owned channel carries premium_required and is in Chats. + sendAs, err := r.onChannelsGetSendAs(ownerCtx, &tg.ChannelsGetSendAsRequest{Peer: inputPeerChannel(group)}) + if err != nil { + t.Fatalf("owner get send as: %v", err) + } + if _, ok := findSendAsChannel(sendAs.Peers, group.ID); !ok { + t.Fatalf("send as peers %+v missing current group %d", sendAs.Peers, group.ID) + } + ownedPeer, ok := findSendAsChannel(sendAs.Peers, owned.ID) + if !ok { + t.Fatalf("send as peers %+v missing owned channel %d", sendAs.Peers, owned.ID) + } + if !ownedPeer.PremiumRequired { + t.Fatalf("owned foreign channel candidate premium_required = false, want true") + } + if grpPeer, _ := findSendAsChannel(sendAs.Peers, group.ID); grpPeer.PremiumRequired { + t.Fatalf("current group candidate premium_required = true, want false") + } + if !chatsContainChannel(sendAs.Chats, owned.ID) { + t.Fatalf("send as chats %+v missing owned channel object %d", sendAs.Chats, owned.ID) + } + + // Owner (premium) sends in G as the owned channel: from_id projected to the channel, channel in Chats. + sent, err := r.onMessagesSendMessage(ownerCtx, &tg.MessagesSendMessageRequest{ + Peer: inputPeerChannel(group), + Message: "as my channel", + RandomID: 6101, + SendAs: inputPeerChannel(owned), + }) + if err != nil { + t.Fatalf("send as owned foreign channel: %v", err) + } + sentUpdates := sent.(*tg.Updates) + sentMsg := sentUpdates.Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := sentMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != owned.ID { + t.Fatalf("send_as message from = %#v, want owned channel %d", sentMsg.FromID, owned.ID) + } + // The sender still sees their own send-as message as outgoing (out derives from SenderUserID, not from_id). + if !sentMsg.Out { + t.Fatalf("send_as message out = false for sender, want true") + } + if !chatsContainChannel(sentUpdates.Chats, owned.ID) { + t.Fatalf("send_as echo chats %+v missing owned channel %d", sentUpdates.Chats, owned.ID) + } + + // A different member who does not own the channel cannot post as it. + if _, err := r.onMessagesSendMessage(friendCtx, &tg.MessagesSendMessageRequest{ + Peer: inputPeerChannel(group), + Message: "not my channel", + RandomID: 6102, + SendAs: inputPeerChannel(owned), + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("non-owner send_as err = %v, want SEND_AS_PEER_INVALID", err) + } + + // saveDefaultSendAs(G, ownedChannel) by owner round-trips into channelFull.default_send_as + Chats. + if ok, err := r.onMessagesSaveDefaultSendAs(ownerCtx, &tg.MessagesSaveDefaultSendAsRequest{ + Peer: inputPeerChannel(group), + SendAs: inputPeerChannel(owned), + }); err != nil || !ok { + t.Fatalf("save default send as owned channel = ok %v err %v, want true", ok, err) + } + full, err := r.onChannelsGetFullChannel(ownerCtx, inputChannel(group)) + if err != nil { + t.Fatalf("get full channel after default send as: %v", err) + } + channelFull := full.FullChat.(*tg.ChannelFull) + def, ok := channelFull.GetDefaultSendAs() + if !ok { + t.Fatalf("channelFull default_send_as missing after saving owned channel") + } + if peer, ok := def.(*tg.PeerChannel); !ok || peer.ChannelID != owned.ID { + t.Fatalf("channelFull default_send_as = %#v, want owned channel %d", def, owned.ID) + } + if !chatsContainChannel(full.Chats, owned.ID) { + t.Fatalf("getFullChannel chats %+v missing owned default send-as channel %d", full.Chats, owned.ID) + } + + // With the default stored, a send carrying no explicit send_as posts as the owned channel. + defaultSent, err := r.onMessagesSendMessage(ownerCtx, &tg.MessagesSendMessageRequest{ + Peer: inputPeerChannel(group), + Message: "default channel", + RandomID: 6103, + }) + if err != nil { + t.Fatalf("send with stored default send_as: %v", err) + } + defaultMsg := defaultSent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := defaultMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != owned.ID { + t.Fatalf("stored-default send_as from = %#v, want owned channel %d", defaultMsg.FromID, owned.ID) + } +} + +// TestChannelSendAsForeignChannelPremiumGateRPC verifies that posting as an owned personal channel in +// another supergroup is gated on Premium: a non-premium owner is rejected with SEND_AS_PEER_INVALID, +// and the same owner succeeds once Premium is active. +func TestChannelSendAsForeignChannelPremiumGateRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + // creator of the group is premium so the group exists with a member; the send-as actor is non-premium. + host, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550002171", FirstName: "Host", PremiumUntil: int(time.Now().Add(72 * time.Hour).Unix())}) + actor, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550002172", FirstName: "Actor"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + hostCtx := WithUserID(ctx, host.ID) + actorCtx := WithUserID(ctx, actor.ID) + + created, err := r.onMessagesCreateChat(hostCtx, &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: actor.ID, AccessHash: actor.AccessHash}}, + Title: "Premium Gate Group", + }) + if err != nil { + t.Fatalf("create group: %v", err) + } + group := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + actorChannel := createBroadcastChannelRPC(t, r, actor.ID, "Actor Channel") + + // Non-premium actor cannot post as their own channel in the group. + if _, err := r.onMessagesSendMessage(actorCtx, &tg.MessagesSendMessageRequest{ + Peer: inputPeerChannel(group), + Message: "non-premium send as", + RandomID: 7201, + SendAs: inputPeerChannel(actorChannel), + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("non-premium send_as err = %v, want SEND_AS_PEER_INVALID", err) + } + // And cannot persist it as the default. + if _, err := r.onMessagesSaveDefaultSendAs(actorCtx, &tg.MessagesSaveDefaultSendAsRequest{ + Peer: inputPeerChannel(group), + SendAs: inputPeerChannel(actorChannel), + }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { + t.Fatalf("non-premium save default send_as err = %v, want SEND_AS_PEER_INVALID", err) + } + + // Grant Premium and the same send succeeds. + if _, err := userStore.SetPremiumUntil(ctx, actor.ID, int(time.Now().Add(72*time.Hour).Unix())); err != nil { + t.Fatalf("grant premium: %v", err) + } + sent, err := r.onMessagesSendMessage(actorCtx, &tg.MessagesSendMessageRequest{ + Peer: inputPeerChannel(group), + Message: "premium send as", + RandomID: 7202, + SendAs: inputPeerChannel(actorChannel), + }) + if err != nil { + t.Fatalf("premium send_as: %v", err) + } + sentMsg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) + if from, ok := sentMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != actorChannel.ID { + t.Fatalf("premium send_as from = %#v, want actor channel %d", sentMsg.FromID, actorChannel.ID) + } +} diff --git a/internal/rpc/channels_settings.go b/internal/rpc/channels_settings.go new file mode 100644 index 00000000..a4d0cf06 --- /dev/null +++ b/internal/rpc/channels_settings.go @@ -0,0 +1,473 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "strings" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onChannelsCheckUsername(ctx context.Context, req *tg.ChannelsCheckUsernameRequest) (bool, error) { + if r.deps.Channels == nil { + return false, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return false, err + } + okUsername, err := r.deps.Channels.CheckUsername(ctx, userID, channelID, req.Username) + if err != nil { + return false, channelUsernameErr(err) + } + return okUsername, nil +} + +func (r *Router) onChannelsUpdateUsername(ctx context.Context, req *tg.ChannelsUpdateUsernameRequest) (bool, error) { + if r.deps.Channels == nil { + return false, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return false, err + } + channel, err := r.deps.Channels.UpdateUsername(ctx, userID, domain.UpdateChannelUsernameRequest{ + UserID: userID, + ChannelID: channelID, + Username: req.Username, + }) + if err != nil { + return false, channelUsernameErr(err) + } + r.invalidateRPCProjectionForChannel(channel.ID) + r.pushChannelStateToMembers(ctx, userID, channel) + return true, nil +} + +func (r *Router) onChannelsToggleSignatures(ctx context.Context, req *tg.ChannelsToggleSignaturesRequest) (tg.UpdatesClass, error) { + return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) { + return r.deps.Channels.SetSignatures(ctx, userID, channelID, req.SignaturesEnabled) + }) +} + +func (r *Router) onChannelsTogglePreHistoryHidden(ctx context.Context, req *tg.ChannelsTogglePreHistoryHiddenRequest) (tg.UpdatesClass, error) { + return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) { + return r.deps.Channels.SetPreHistoryHidden(ctx, userID, channelID, req.Enabled) + }) +} + +func (r *Router) onChannelsToggleSlowMode(ctx context.Context, req *tg.ChannelsToggleSlowModeRequest) (tg.UpdatesClass, error) { + if !domain.ValidChannelSlowModeSeconds(req.Seconds) { + return nil, secondsInvalidErr() + } + return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) { + return r.deps.Channels.SetSlowMode(ctx, userID, channelID, req.Seconds) + }) +} + +func (r *Router) onChannelsSetStickers(ctx context.Context, req *tg.ChannelsSetStickersRequest) (bool, error) { + _, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return false, err + } + if !view.Channel.Megagroup || view.Channel.Broadcast { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + if err := validateEmptyChannelStickerSet(req.Stickerset); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsSetEmojiStickers(ctx context.Context, req *tg.ChannelsSetEmojiStickersRequest) (bool, error) { + _, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return false, err + } + if !view.Channel.Megagroup || view.Channel.Broadcast { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + if err := validateEmptyChannelStickerSet(req.Stickerset); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsReorderUsernames(ctx context.Context, req *tg.ChannelsReorderUsernamesRequest) (bool, error) { + if len(req.Order) > maxChannelUsernameOrder { + return false, limitInvalidErr() + } + if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsToggleUsername(ctx context.Context, req *tg.ChannelsToggleUsernameRequest) (bool, error) { + if req.Username != "" && !validChannelManagementUsername(req.Username) { + return false, usernameInvalidErr() + } + if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsDeactivateAllUsernames(ctx context.Context, input tg.InputChannelClass) (bool, error) { + if _, _, err := r.channelChangeInfoView(ctx, input); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsUpdateColor(ctx context.Context, req *tg.ChannelsUpdateColorRequest) (tg.UpdatesClass, error) { + return r.applyChannelChangeInfoMutation(ctx, req.Channel, func(ctx context.Context, userID int64, view domain.ChannelView) (domain.Channel, error) { + return r.deps.Channels.SetColor(ctx, userID, view.Channel.ID, req.ForProfile, domainPeerColorFromChannelUpdate(req)) + }) +} + +func (r *Router) onChannelsUpdateEmojiStatus(ctx context.Context, req *tg.ChannelsUpdateEmojiStatusRequest) (tg.UpdatesClass, error) { + viewerUserID, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return nil, err + } + status, err := domainChannelEmojiStatus(req.EmojiStatus) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetEmojiStatus(ctx, viewerUserID, view.Channel.ID, status) + if err != nil { + return nil, channelAdminErr(err) + } + return r.channelStateMutationUpdates(ctx, viewerUserID, channel), nil +} + +func (r *Router) onChannelsSetDiscussionGroup(ctx context.Context, req *tg.ChannelsSetDiscussionGroupRequest) (bool, error) { + if r.deps.Channels == nil { + return false, notImplementedErr() + } + if req == nil { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + broadcastID, err := r.optionalChannelIDFromInput(ctx, userID, req.Broadcast) + if err != nil { + return false, channelDiscussionErr(err) + } + groupID, err := r.optionalChannelIDFromInput(ctx, userID, req.Group) + if err != nil { + return false, channelDiscussionErr(err) + } + res, err := r.deps.Channels.SetDiscussionGroup(ctx, userID, broadcastID, groupID) + if err != nil { + return false, channelDiscussionErr(err) + } + for _, channel := range res.Channels { + r.invalidateRPCProjectionForChannel(channel.ID) + r.pushChannelStateToMembers(ctx, userID, channel) + } + return true, nil +} + +func (r *Router) onChannelsEditLocation(ctx context.Context, req *tg.ChannelsEditLocationRequest) (bool, error) { + if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsConvertToGigagroup(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { + userID, view, err := r.channelChangeInfoView(ctx, input) + if err != nil { + return nil, err + } + return r.channelStateUpdates(userID, view.Channel), nil +} + +func (r *Router) onChannelsToggleAntiSpam(ctx context.Context, req *tg.ChannelsToggleAntiSpamRequest) (tg.UpdatesClass, error) { + return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) { + return r.deps.Channels.SetAntiSpam(ctx, userID, channelID, req.Enabled) + }) +} + +func (r *Router) onChannelsReportAntiSpamFalsePositive(ctx context.Context, req *tg.ChannelsReportAntiSpamFalsePositiveRequest) (bool, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsGetChannelRecommendations(ctx context.Context, req *tg.ChannelsGetChannelRecommendationsRequest) (tg.MessagesChatsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Channels == nil { + return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil + } + sourceChannelID := int64(0) + if req != nil { + if input, ok := req.GetChannel(); ok { + source, err := r.publicRecommendationSourceChannel(ctx, userID, input) + if err != nil { + return nil, err + } + sourceChannelID = source + } + } + res, err := r.deps.Channels.ChannelRecommendations(ctx, userID, domain.ChannelRecommendationsRequest{ + UserID: userID, + SourceChannelID: sourceChannelID, + Limit: domain.DefaultChannelRecommendationsLimit, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + chats := tgChannels(userID, res.Channels) + if res.Count > len(chats) { + return &tg.MessagesChatsSlice{Count: res.Count, Chats: chats}, nil + } + return &tg.MessagesChats{Chats: chats}, nil +} + +func (r *Router) onChannelsSetBoostsToUnblockRestrictions(ctx context.Context, req *tg.ChannelsSetBoostsToUnblockRestrictionsRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if req.Boosts < 0 || req.Boosts > maxChannelBoostsToUnblockRestrictions { + return nil, limitInvalidErr() + } + return r.applyChannelChangeInfoMutation(ctx, req.Channel, func(ctx context.Context, userID int64, view domain.ChannelView) (domain.Channel, error) { + return r.deps.Channels.SetBoostsToUnblockRestrictions(ctx, userID, view.Channel.ID, req.Boosts) + }) +} + +func (r *Router) onChannelsRestrictSponsoredMessages(ctx context.Context, req *tg.ChannelsRestrictSponsoredMessagesRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + return r.applyChannelChangeInfoMutation(ctx, req.Channel, func(ctx context.Context, userID int64, view domain.ChannelView) (domain.Channel, error) { + return r.deps.Channels.SetRestrictedSponsored(ctx, userID, view.Channel.ID, req.Restricted) + }) +} + +func (r *Router) onChannelsUpdatePaidMessagesPrice(ctx context.Context, req *tg.ChannelsUpdatePaidMessagesPriceRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + userID, view, err := r.channelChangeInfoView(ctx, req.Channel) + if err != nil { + return nil, err + } + if err := validateChannelPaidMessagesPriceRequest(req, view.Channel); err != nil { + return nil, err + } + stars := req.SendPaidMessagesStars + if stars < 0 { + stars = 0 + } + res, err := r.deps.Channels.SetPaidMessagesPrice(ctx, userID, view.Channel.ID, stars, req.BroadcastMessagesAllowed) + if err != nil { + return nil, channelAdminErr(err) + } + return r.channelPaidMessagesPriceUpdates(ctx, userID, res), nil +} + +func validateChannelPaidMessagesPriceRequest(req *tg.ChannelsUpdatePaidMessagesPriceRequest, channel domain.Channel) error { + stars := req.SendPaidMessagesStars + if stars == -1 && channel.Broadcast && !req.BroadcastMessagesAllowed { + return nil + } + if stars < 0 || stars > maxChannelPaidMessageStars { + return starsAmountInvalidErr() + } + return nil +} + +func (r *Router) onChannelsToggleAutotranslation(ctx context.Context, req *tg.ChannelsToggleAutotranslationRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + return r.applyChannelChangeInfoMutation(ctx, req.Channel, func(ctx context.Context, userID int64, view domain.ChannelView) (domain.Channel, error) { + return r.deps.Channels.SetAutotranslation(ctx, userID, view.Channel.ID, req.Enabled) + }) +} + +func (r *Router) onChannelsEditTitle(ctx context.Context, req *tg.ChannelsEditTitleRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if !validChannelTitle(req.Title) { + return nil, channelInvalidErr(domain.ErrChannelTitleInvalid) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.EditTitle(ctx, userID, domain.EditChannelTitleRequest{ + UserID: userID, + ChannelID: channelID, + Title: req.Title, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + r.invalidateRPCProjectionForChannel(res.Channel.ID) + updates := r.channelTitleUpdates(ctx, userID, res) + // 混合容器拆分(设计 fan-out epic):channelTitleUpdates 同时含①无 pts UpdateChannel(频道元数据 + // 刷新,无 channel difference 恢复面)②带 pts 改名服务消息(broadcast+megagroup 均产 pts,有 + // difference 恢复面)。①必须同步发(不能进可丢弃队列,否则永久漏人数/元数据刷新),但它无 Users + // 投影、廉价;②走异步 fan-out(含 owner 预热 + >cap nudge,丢弃由 getChannelDifference 兜底),把 + // per-viewer 服务消息投影移出改名者 RPC 路径。操作者本设备仍由上面的 RPC result 即时回显完整容器。 + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelStateUpdates(viewerUserID, res.Channel) + }) + if res.Event.Pts != 0 { + r.enqueueChannelMessageFanout(ctx, userID, domain.SendChannelMessageResult{ + Channel: res.Channel, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + }, nil) + } + return updates, nil +} + +func (r *Router) onChannelsEditPhoto(ctx context.Context, req *tg.ChannelsEditPhotoRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if req.Photo == nil { + return nil, photoInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + photo, err := r.resolveInputChatPhoto(ctx, userID, req.Photo) + if err != nil { + return nil, err + } + channel, err := r.deps.Channels.SetPhoto(ctx, userID, channelID, photo) + if err != nil { + return nil, channelAdminErr(err) + } + return r.channelStateMutationUpdates(ctx, userID, channel), nil +} + +func (r *Router) channelTitleUpdates(ctx context.Context, viewerUserID int64, res domain.EditChannelTitleResult) *tg.Updates { + updates := []tg.UpdateClass{&tg.UpdateChannel{ChannelID: res.Channel.ID}} + if res.Event.Pts != 0 { + if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { + updates = append(updates, update) + } + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, viewerUserID, []int64{res.Message.SenderUserID}), + Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func validChannelTitle(title string) bool { + n := utf8.RuneCountInString(title) + return n > 0 && n <= maxChannelTitleLength +} + +func validChannelManagementUsername(username string) bool { + username = strings.TrimSpace(strings.TrimPrefix(username, "@")) + if len(username) < 5 || len(username) > 32 { + return false + } + for i := 0; i < len(username); i++ { + c := username[i] + switch { + case i == 0 && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')): + case i > 0 && ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'): + default: + return false + } + } + return true +} + +func domainPeerColorFromChannelUpdate(req *tg.ChannelsUpdateColorRequest) domain.ChannelPeerColor { + if req == nil { + return domain.ChannelPeerColor{} + } + color, hasColor := req.GetColor() + backgroundEmojiID, hasBackground := req.GetBackgroundEmojiID() + out := domain.ChannelPeerColor{HasColor: hasColor, Color: color} + if hasBackground { + out.BackgroundEmojiID = backgroundEmojiID + } + return out +} + +func domainChannelEmojiStatus(status tg.EmojiStatusClass) (domain.ChannelEmojiStatus, error) { + switch s := status.(type) { + case *tg.EmojiStatusEmpty: + return domain.ChannelEmojiStatus{}, nil + case *tg.EmojiStatus: + if s.DocumentID <= 0 { + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } + until, _ := s.GetUntil() + if until < 0 { + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } + return domain.ChannelEmojiStatus{DocumentID: s.DocumentID, Until: until}, nil + case *tg.EmojiStatusCollectible: + if s.DocumentID <= 0 { + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } + until, _ := s.GetUntil() + if until < 0 { + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } + return domain.ChannelEmojiStatus{DocumentID: s.DocumentID, Until: until}, nil + case *tg.InputEmojiStatusCollectible: + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + default: + return domain.ChannelEmojiStatus{}, tgerr400("EMOJI_STATUS_INVALID") + } +} + +func channelUsernameErr(err error) error { + switch { + case errors.Is(err, domain.ErrUsernameInvalid): + return usernameInvalidErr() + case errors.Is(err, domain.ErrUsernameOccupied): + return usernameOccupiedErr() + case errors.Is(err, domain.ErrChannelNotModified): + return usernameNotModifiedErr() + default: + return channelAdminErr(err) + } +} diff --git a/internal/rpc/channels_settings_rpc_test.go b/internal/rpc/channels_settings_rpc_test.go new file mode 100644 index 00000000..ad61e325 --- /dev/null +++ b/internal/rpc/channels_settings_rpc_test.go @@ -0,0 +1,1530 @@ +package rpc + +import ( + "context" + "strconv" + "strings" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + "telesrv/internal/app/readmodel" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +type fakeRPCReadModelVersions struct { + hashes map[store.ReadModelKey]int64 +} + +func (f *fakeRPCReadModelVersions) ReadModelHash(_ context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) { + hash := f.hashes[store.ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}] + return hash, hash != 0, nil +} + +func (f *fakeRPCReadModelVersions) ReadModelHashes(_ context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) { + out := make(map[store.ReadModelKey]int64, len(keys)) + for _, key := range keys { + if hash := f.hashes[key]; hash != 0 { + out[key] = hash + } + } + return out, nil +} + +func TestChannelInputAccessHashIsValidatedRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550001102", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Hash Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := invited.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + badHash := channel.AccessHash + 1 + if badHash == 0 { + badHash = 1 + } + + if _, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: badHash}); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("get full bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, + Message: "bad hash", + RandomID: 991, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("send bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesGetPeerSettings(WithUserID(ctx, owner.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("peer settings bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), &tg.MessagesToggleDialogPinRequest{ + Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}}, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("toggle pin bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onFoldersEditPeerFolders(WithUserID(ctx, owner.ID), []tg.InputFolderPeer{{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, + FolderID: domain.DialogArchiveFolderID, + }}); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("edit peer folder bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: badHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: 0, + Limit: 10, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("difference bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + chats, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{&tg.InputChannel{ChannelID: channel.ID, AccessHash: badHash}}) + if err != nil { + t.Fatalf("get channels bad access_hash: %v", err) + } + if got := len(chats.(*tg.MessagesChats).Chats); got != 0 { + t.Fatalf("get channels bad access_hash chats = %d, want 0", got) + } + mixedChats, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{ + &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + &tg.InputChannel{ChannelID: channel.ID, AccessHash: badHash}, + &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + }) + if err != nil { + t.Fatalf("get channels mixed access_hash: %v", err) + } + if got := len(mixedChats.(*tg.MessagesChats).Chats); got != 2 { + t.Fatalf("get channels mixed access_hash chats = %d, want two good refs", got) + } + if _, err := r.dialogFilterFromRequest(WithUserID(ctx, owner.ID), owner.ID, &tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, + Limit: 20, + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("get dialogs offset bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + if _, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, + Message: "draft", + }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("save draft bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } + filterReq := &tg.MessagesUpdateDialogFilterRequest{ID: domain.DialogCustomFolderMinID} + filterReq.SetFilter(&tg.DialogFilter{ + ID: domain.DialogCustomFolderMinID, + Title: tg.TextWithEntities{Text: "Channels"}, + IncludePeers: []tg.InputPeerClass{ + &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, + }, + }) + if ok, err := r.onMessagesUpdateDialogFilter(WithUserID(ctx, owner.ID), filterReq); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") || ok { + t.Fatalf("update dialog filter bad access_hash = %v, %v; want CHANNEL_PRIVATE", ok, err) + } + reply := &tg.InputReplyToMessage{ReplyToMsgID: 1} + reply.SetReplyToPeerID(&tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}) + sendReq := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "bad reply hash", + RandomID: 992, + } + sendReq.SetReplyTo(reply) + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), sendReq); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("send reply_to_peer bad access_hash err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } +} + +func TestChannelsGetChannelsUsesBatchServiceRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550003101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + first, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Batch One", + Broadcast: true, + Date: 1700001300, + }) + if err != nil { + t.Fatalf("create first channel: %v", err) + } + second, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Batch Two", + Megagroup: true, + Date: 1700001310, + }) + if err != nil { + t.Fatalf("create second channel: %v", err) + } + counting := &countingChannelsService{Service: channelService} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: counting, + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + chats, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{ + &tg.InputChannel{ChannelID: first.Channel.ID, AccessHash: first.Channel.AccessHash}, + &tg.InputChannel{ChannelID: second.Channel.ID, AccessHash: second.Channel.AccessHash}, + }) + if err != nil { + t.Fatalf("get channels batch: %v", err) + } + if got := len(chats.(*tg.MessagesChats).Chats); got != 2 { + t.Fatalf("get channels returned %d chats, want 2", got) + } + if counting.getChannelsCalls != 1 || counting.getChannelCalls != 0 { + t.Fatalf("channel service calls: GetChannels=%d GetChannel=%d, want one batch call only", counting.getChannelsCalls, counting.getChannelCalls) + } +} + +func TestMessagesGetChatsUsesBatchServiceRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550004101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + first, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Legacy Batch One", + Broadcast: true, + Date: 1700001400, + }) + if err != nil { + t.Fatalf("create first channel: %v", err) + } + second, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Legacy Batch Two", + Megagroup: true, + Date: 1700001410, + }) + if err != nil { + t.Fatalf("create second channel: %v", err) + } + counting := &countingChannelsService{Service: channelService} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: counting, + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + chats, err := r.onMessagesGetChats(WithUserID(ctx, owner.ID), []int64{ + first.Channel.ID, + second.Channel.ID, + first.Channel.ID, + 0, + }) + if err != nil { + t.Fatalf("messages.getChats batch: %v", err) + } + got := chats.(*tg.MessagesChats).Chats + if len(got) != 3 { + t.Fatalf("messages.getChats returned %d chats, want duplicate-preserving 3", len(got)) + } + if got[0].(*tg.Channel).ID != first.Channel.ID || got[1].(*tg.Channel).ID != second.Channel.ID || got[2].(*tg.Channel).ID != first.Channel.ID { + t.Fatalf("messages.getChats order = %+v, want first, second, first", got) + } + if counting.getChannelsCalls != 1 || counting.getChannelCalls != 0 { + t.Fatalf("channel service calls: GetChannels=%d GetChannel=%d, want one batch call only", counting.getChannelsCalls, counting.getChannelCalls) + } +} + +func TestMessagesGetPeerSettingsUsesResolveChannelForAccessCheck(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550005101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Peer Settings Channel", + Broadcast: true, + Date: 1700001500, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + counting := &countingChannelsService{Service: channelService} + r := New(Config{}, Deps{Channels: counting}, zaptest.NewLogger(t), clock.System) + input := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + if _, err := r.onMessagesGetPeerSettings(WithUserID(ctx, owner.ID), input); err != nil { + t.Fatalf("messages.getPeerSettings: %v", err) + } + if counting.resolveChannelCalls != 1 || counting.getChannelCalls != 0 { + t.Fatalf("channel access check calls: ResolveChannel=%d GetChannel=%d, want one resolve and no full get", counting.resolveChannelCalls, counting.getChannelCalls) + } + bad := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash + 1} + if _, err := r.onMessagesGetPeerSettings(WithUserID(ctx, owner.ID), bad); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { + t.Fatalf("bad access_hash err = %v, want CHANNEL_PRIVATE", err) + } +} + +func TestChannelsGetFullChannelUsesChannelReadModel(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550006101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + creator := appchannels.NewService(channelStore) + created, err := creator.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Full Read Model", + Megagroup: true, + Date: 1700004200, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + versions := &fakeRPCReadModelVersions{hashes: map[store.ReadModelKey]int64{ + {Model: readmodel.ModelChannelBase, OwnerUserID: 0, PeerType: peer.Type, PeerID: peer.ID}: 101, + {Model: readmodel.ModelChannelMember, OwnerUserID: owner.ID, PeerType: peer.Type, PeerID: peer.ID}: 202, + {Model: readmodel.ModelDialogLight, OwnerUserID: owner.ID, PeerType: peer.Type, PeerID: peer.ID}: 303, + }} + counting := &countingChannelsService{Service: appchannels.NewService(channelStore, appchannels.WithReadModelVersions(versions))} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: counting, + }, zaptest.NewLogger(t), clock.System) + input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + if _, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input); err != nil { + t.Fatalf("first getFullChannel: %v", err) + } + if _, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input); err != nil { + t.Fatalf("second getFullChannel: %v", err) + } + if counting.getChannelReadModelCalls != 1 || counting.getChannelCalls != 0 { + t.Fatalf("channel calls readModel/GetChannel = %d/%d, want 1/0 after projection cache hit", counting.getChannelReadModelCalls, counting.getChannelCalls) + } +} + +type countingChannelsService struct { + *appchannels.Service + getChannelCalls int + getChannelReadModelCalls int + resolveChannelCalls int + getChannelsCalls int +} + +func (s *countingChannelsService) GetChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) { + s.getChannelCalls++ + return s.Service.GetChannel(ctx, userID, channelID) +} + +func (s *countingChannelsService) GetChannelReadModel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) { + s.getChannelReadModelCalls++ + return s.Service.GetChannelReadModel(ctx, userID, channelID) +} + +func (s *countingChannelsService) ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) { + s.resolveChannelCalls++ + return s.Service.ResolveChannel(ctx, userID, channelID) +} + +func (s *countingChannelsService) GetChannels(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) { + s.getChannelsCalls++ + return s.Service.GetChannels(ctx, userID, channelIDs) +} + +func TestChannelsCreateChannelUnsupportedOptionsReturnExplicitErrors(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 73, Phone: "15550002173", FirstName: "Owner"}) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(memory.NewChannelStore()), + }, zaptest.NewLogger(t), clock.System) + + tests := []struct { + name string + req *tg.ChannelsCreateChannelRequest + want string + }{ + { + name: "history import", + req: &tg.ChannelsCreateChannelRequest{ + Title: "Import Group", + Megagroup: true, + ForImport: true, + }, + want: "CHAT_INVALID", + }, + { + name: "geogroup", + req: &tg.ChannelsCreateChannelRequest{ + Title: "Geo Group", + Megagroup: true, + GeoPoint: &tg.InputGeoPoint{Lat: 1.2, Long: 3.4}, + Address: "somewhere", + }, + want: "ADDRESS_INVALID", + }, + { + name: "address without geo", + req: &tg.ChannelsCreateChannelRequest{ + Title: "Bad Geo Group", + Megagroup: true, + Address: "somewhere", + }, + want: "ADDRESS_INVALID", + }, + { + name: "negative ttl", + req: &tg.ChannelsCreateChannelRequest{ + Title: "Bad TTL Group", + Megagroup: true, + TTLPeriod: -1, + }, + want: "TTL_PERIOD_INVALID", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("create channel err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestChannelsGetFullChannelCanSetUsernameOnlyForCreator(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 56, Phone: "15550002211", FirstName: "Owner"}) + member, _ := userStore.Create(ctx, domain.User{AccessHash: 57, Phone: "15550002212", FirstName: "Member"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + + tests := []struct { + name string + req *tg.ChannelsCreateChannelRequest + }{ + { + name: "broadcast", + req: &tg.ChannelsCreateChannelRequest{ + Title: "Editable Broadcast", + About: "public link editable", + Broadcast: true, + }, + }, + { + name: "megagroup", + req: &tg.ChannelsCreateChannelRequest{ + Title: "Editable Megagroup", + About: "public link editable", + Megagroup: true, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), tc.req) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + ownerFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("owner get full channel: %v", err) + } + ownerChannelFull := ownerFull.FullChat.(*tg.ChannelFull) + if !ownerChannelFull.CanSetUsername || !ownerChannelFull.CanDeleteChannel { + t.Fatalf("owner full flags can_set_username=%v can_delete_channel=%v, want both true", ownerChannelFull.CanSetUsername, ownerChannelFull.CanDeleteChannel) + } + + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: input, + Users: []tg.InputUserClass{&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}}, + }); err != nil { + t.Fatalf("invite member: %v", err) + } + memberFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, member.ID), input) + if err != nil { + t.Fatalf("member get full channel: %v", err) + } + memberChannelFull := memberFull.FullChat.(*tg.ChannelFull) + if memberChannelFull.CanSetUsername || memberChannelFull.CanDeleteChannel { + t.Fatalf("member full flags can_set_username=%v can_delete_channel=%v, want both false", memberChannelFull.CanSetUsername, memberChannelFull.CanDeleteChannel) + } + }) + } +} + +func TestChannelUsernameAndManagementRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"}) + requester, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002302", FirstName: "Requester"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Public Team", + About: "public", + Megagroup: true, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "channel management seed", + RandomID: 991, + }) + if err != nil { + t.Fatalf("send seed message: %v", err) + } + msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID + + okUsername, err := r.onChannelsCheckUsername(WithUserID(ctx, owner.ID), &tg.ChannelsCheckUsernameRequest{ + Channel: input, + Username: "public_team", + }) + if err != nil || !okUsername { + t.Fatalf("check username = ok %v err %v, want true", okUsername, err) + } + updated, err := r.onChannelsUpdateUsername(WithUserID(ctx, owner.ID), &tg.ChannelsUpdateUsernameRequest{ + Channel: input, + Username: "public_team", + }) + if err != nil || !updated { + t.Fatalf("update username = %v err %v, want true", updated, err) + } + admined, err := r.onChannelsGetAdminedPublicChannels(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminedPublicChannelsRequest{}) + if err != nil { + t.Fatalf("get admined public channels: %v", err) + } + adminedChats := admined.(*tg.MessagesChats).Chats + if len(adminedChats) != 1 || adminedChats[0].(*tg.Channel).Username != "public_team" { + t.Fatalf("admined chats = %+v, want public channel with username", adminedChats) + } + + signatures, err := r.onChannelsToggleSignatures(WithUserID(ctx, owner.ID), &tg.ChannelsToggleSignaturesRequest{ + Channel: input, + SignaturesEnabled: true, + }) + if err != nil { + t.Fatalf("toggle signatures: %v", err) + } + if signed := signatures.(*tg.Updates).Chats[0].(*tg.Channel); !signed.Signatures { + t.Fatalf("signed channel = %+v, want signatures enabled", signed) + } + if _, err := r.onChannelsToggleSlowMode(WithUserID(ctx, owner.ID), &tg.ChannelsToggleSlowModeRequest{Channel: input, Seconds: 7}); err == nil { + t.Fatalf("toggle slow mode invalid seconds err = nil, want SECONDS_INVALID") + } + slowUpdates, err := r.onChannelsToggleSlowMode(WithUserID(ctx, owner.ID), &tg.ChannelsToggleSlowModeRequest{Channel: input, Seconds: 30}) + if err != nil { + t.Fatalf("toggle slow mode valid: %v", err) + } + if slowChannel := slowUpdates.(*tg.Updates).Chats[0].(*tg.Channel); !slowChannel.SlowmodeEnabled { + t.Fatalf("slow mode channel = %+v, want slowmode enabled", slowChannel) + } + if ok, err := r.onChannelsSetStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetStickersRequest{Channel: input, Stickerset: &tg.InputStickerSetEmpty{}}); err != nil || !ok { + t.Fatalf("set stickers = ok %v err %v, want true", ok, err) + } + if _, err := r.onChannelsSetStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetStickersRequest{ + Channel: input, + Stickerset: &tg.InputStickerSetID{ID: 1, AccessHash: 2}, + }); err == nil || !strings.Contains(err.Error(), "STICKERSET_INVALID") { + t.Fatalf("set stickers non-empty err = %v, want STICKERSET_INVALID", err) + } + if ok, err := r.onChannelsReorderUsernames(WithUserID(ctx, owner.ID), &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"public_team"}}); err != nil || !ok { + t.Fatalf("reorder usernames = ok %v err %v, want true", ok, err) + } + if _, err := r.onChannelsTogglePreHistoryHidden(WithUserID(ctx, owner.ID), &tg.ChannelsTogglePreHistoryHiddenRequest{Channel: input, Enabled: true}); err != nil { + t.Fatalf("toggle prehistory hidden: %v", err) + } + fullAfterSettings, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("get full channel after settings: %v", err) + } + fullSettings := fullAfterSettings.FullChat.(*tg.ChannelFull) + if !fullSettings.HiddenPrehistory || fullSettings.SlowmodeSeconds != 30 { + t.Fatalf("full settings = %+v, want hidden prehistory and slowmode=30", fullSettings) + } + joinToSendUpdates, err := r.onChannelsToggleJoinToSend(WithUserID(ctx, owner.ID), &tg.ChannelsToggleJoinToSendRequest{Channel: input, Enabled: true}) + if err != nil { + t.Fatalf("toggle join-to-send: %v", err) + } + if joinToSendChannel := joinToSendUpdates.(*tg.Updates).Chats[0].(*tg.Channel); !joinToSendChannel.GetJoinToSend() { + t.Fatalf("join-to-send channel = %+v, want join_to_send flag", joinToSendChannel) + } + joinRequestUpdates, err := r.onChannelsToggleJoinRequest(WithUserID(ctx, owner.ID), &tg.ChannelsToggleJoinRequestRequest{Channel: input, Enabled: true}) + if err != nil { + t.Fatalf("toggle join request: %v", err) + } + if joinRequestChannel := joinRequestUpdates.(*tg.Updates).Chats[0].(*tg.Channel); !joinRequestChannel.GetJoinRequest() { + t.Fatalf("join-request channel = %+v, want join_request flag", joinRequestChannel) + } + chatsWithJoinFlags, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channels after join settings: %v", err) + } + listedJoinChannel := chatsWithJoinFlags.(*tg.MessagesChats).Chats[0].(*tg.Channel) + if !listedJoinChannel.GetJoinToSend() || !listedJoinChannel.GetJoinRequest() { + t.Fatalf("listed channel join flags = %+v, want both flags", listedJoinChannel) + } + if _, err := r.onChannelsJoinChannel(WithUserID(ctx, requester.ID), input); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") { + t.Fatalf("public join request err = %v, want INVITE_REQUEST_SENT", err) + } + pendingPublic, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), &tg.MessagesGetChatInviteImportersRequest{ + Requested: true, + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get public pending join request: %v", err) + } + if pendingPublic.Count != 1 || len(pendingPublic.Importers) != 1 || pendingPublic.Importers[0].UserID != requester.ID || !pendingPublic.Importers[0].Requested { + t.Fatalf("public pending importers = %+v, want requester pending", pendingPublic) + } + fullWithPublicPending, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("get full channel with public pending request: %v", err) + } + fullPublicPending := fullWithPublicPending.FullChat.(*tg.ChannelFull) + publicPendingCount, ok := fullPublicPending.GetRequestsPending() + publicRecent, recentOK := fullPublicPending.GetRecentRequesters() + if !ok || publicPendingCount != 1 || !recentOK || len(publicRecent) != 1 || publicRecent[0] != requester.ID { + t.Fatalf("public full pending = count %d ok %v recent %+v ok %v, want requester", publicPendingCount, ok, publicRecent, recentOK) + } + approvedPublic, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{ + Approved: true, + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + UserID: &tg.InputUser{UserID: requester.ID, AccessHash: requester.AccessHash}, + }) + if err != nil { + t.Fatalf("approve public join request: %v", err) + } + var publicPendingCleared *tg.UpdatePendingJoinRequests + for _, update := range approvedPublic.(*tg.Updates).Updates { + if pending, ok := update.(*tg.UpdatePendingJoinRequests); ok { + publicPendingCleared = pending + break + } + } + if publicPendingCleared == nil || publicPendingCleared.RequestsPending != 0 || len(publicPendingCleared.RecentRequesters) != 0 { + t.Fatalf("public approve pending update = %+v, want cleared pending requests", publicPendingCleared) + } + colorReq := &tg.ChannelsUpdateColorRequest{Channel: input} + colorReq.SetForProfile(true) + colorReq.SetColor(1) + colorReq.SetBackgroundEmojiID(9001) + colorUpdates, err := r.onChannelsUpdateColor(WithUserID(ctx, owner.ID), colorReq) + if err != nil { + t.Fatalf("update color: %v", err) + } + colorChannel := colorUpdates.(*tg.Updates).Chats[0].(*tg.Channel) + profileColor, ok := colorChannel.GetProfileColor() + if !ok { + t.Fatalf("update color channel = %+v, want profile color", colorChannel) + } + if peerColor := profileColor.(*tg.PeerColor); peerColor.Color != 1 || peerColor.BackgroundEmojiID != 9001 { + t.Fatalf("profile color = %+v, want color/background", peerColor) + } + status := &tg.EmojiStatus{DocumentID: 9101} + status.SetUntil(1700000100) + statusUpdates, err := r.onChannelsUpdateEmojiStatus(WithUserID(ctx, owner.ID), &tg.ChannelsUpdateEmojiStatusRequest{Channel: input, EmojiStatus: status}) + if err != nil { + t.Fatalf("update emoji status: %v", err) + } + statusChannel := statusUpdates.(*tg.Updates).Chats[0].(*tg.Channel) + emojiStatus, ok := statusChannel.GetEmojiStatus() + if !ok { + t.Fatalf("update emoji status channel = %+v, want emoji status", statusChannel) + } + if got := emojiStatus.(*tg.EmojiStatus); got.DocumentID != 9101 || got.Until != 1700000100 { + t.Fatalf("emoji status = %+v, want document/until", got) + } + chatsWithAppearance, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channels after appearance settings: %v", err) + } + persistedAppearance := chatsWithAppearance.(*tg.MessagesChats).Chats[0].(*tg.Channel) + if _, ok := persistedAppearance.GetProfileColor(); !ok { + t.Fatalf("persisted channel appearance = %+v, want profile color", persistedAppearance) + } + if _, ok := persistedAppearance.GetEmojiStatus(); !ok { + t.Fatalf("persisted channel appearance = %+v, want emoji status", persistedAppearance) + } + link, err := r.onChannelsExportMessageLink(WithUserID(ctx, owner.ID), &tg.ChannelsExportMessageLinkRequest{Channel: input, ID: msgID}) + if err != nil { + t.Fatalf("export message link: %v", err) + } + if !strings.Contains(link.Link, "public_team") || !strings.Contains(link.Link, strconv.Itoa(msgID)) { + t.Fatalf("exported link = %+v, want username/message id", link) + } + replyToSeed := &tg.InputReplyToMessage{ReplyToMsgID: msgID} + replyReq := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "thread reply", + RandomID: 992, + } + replyReq.SetReplyTo(replyToSeed) + replySent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), replyReq) + if err != nil { + t.Fatalf("send reply for export link: %v", err) + } + replyMsgID := replySent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID + threadLink, err := r.onChannelsExportMessageLink(WithUserID(ctx, owner.ID), &tg.ChannelsExportMessageLinkRequest{Thread: true, Channel: input, ID: replyMsgID}) + if err != nil { + t.Fatalf("export thread message link: %v", err) + } + if !strings.Contains(threadLink.Link, strconv.Itoa(replyMsgID)) || !strings.Contains(threadLink.Link, "?thread="+strconv.Itoa(msgID)) { + t.Fatalf("thread exported link = %+v, want reply id and thread root %d", threadLink, msgID) + } + if _, err := r.onChannelsExportMessageLink(WithUserID(ctx, owner.ID), &tg.ChannelsExportMessageLinkRequest{Channel: input, ID: replyMsgID + 10000}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("export missing message link err = %v, want MESSAGE_ID_INVALID", err) + } + if ok, err := r.onChannelsReadMessageContents(WithUserID(ctx, owner.ID), &tg.ChannelsReadMessageContentsRequest{Channel: input, ID: []int{msgID}}); err != nil || !ok { + t.Fatalf("read message contents = ok %v err %v, want true", ok, err) + } + author, err := r.onChannelsGetMessageAuthor(WithUserID(ctx, owner.ID), &tg.ChannelsGetMessageAuthorRequest{Channel: input, ID: msgID}) + if err != nil { + t.Fatalf("get message author: %v", err) + } + if author.(*tg.User).ID != owner.ID { + t.Fatalf("message author = %+v, want owner", author) + } + if _, err := r.onChannelsGetMessageAuthor(WithUserID(ctx, owner.ID), &tg.ChannelsGetMessageAuthorRequest{Channel: input, ID: msgID + 10000}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { + t.Fatalf("get missing message author err = %v, want MESSAGE_ID_INVALID", err) + } + if ok, err := r.onChannelsReportSpam(WithUserID(ctx, owner.ID), &tg.ChannelsReportSpamRequest{ + Channel: input, + Participant: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + ID: []int{msgID}, + }); err != nil || !ok { + t.Fatalf("report spam = ok %v err %v, want true", ok, err) + } + if _, err := r.onChannelsGetLeftChannels(WithUserID(ctx, owner.ID), 0); err != nil { + t.Fatalf("get left channels: %v", err) + } + if _, err := r.onChannelsGetInactiveChannels(WithUserID(ctx, owner.ID)); err != nil { + t.Fatalf("get inactive channels: %v", err) + } + if _, err := r.onChannelsGetGroupsForDiscussion(WithUserID(ctx, owner.ID)); err != nil { + t.Fatalf("get groups for discussion: %v", err) + } + if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, owner.ID), &tg.ChannelsSetDiscussionGroupRequest{Broadcast: input, Group: &tg.InputChannelEmpty{}}); err == nil || !strings.Contains(err.Error(), "BROADCAST_ID_INVALID") { + t.Fatalf("set discussion group on megagroup err = %v, want BROADCAST_ID_INVALID", err) + } + if ok, err := r.onChannelsEditLocation(WithUserID(ctx, owner.ID), &tg.ChannelsEditLocationRequest{Channel: input}); err != nil || !ok { + t.Fatalf("edit location = ok %v err %v, want true", ok, err) + } + if _, err := r.onChannelsConvertToGigagroup(WithUserID(ctx, owner.ID), input); err != nil { + t.Fatalf("convert to gigagroup: %v", err) + } + // 建群服务消息(id=1)被保留为会话兜底 top message,不随创建者 + // 历史一起删除;可删的是 seed 文本与 thread reply 两条。 + if affected, err := r.onChannelsDeleteParticipantHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteParticipantHistoryRequest{Channel: input, Participant: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}}); err != nil || affected.PtsCount != 2 || affected.Pts == 0 || affected.Offset != 0 { + t.Fatalf("delete participant history = %+v err %v, want one bounded delete update for owner text and reply messages", affected, err) + } + if updates, err := r.onChannelsToggleParticipantsHidden(WithUserID(ctx, owner.ID), &tg.ChannelsToggleParticipantsHiddenRequest{Channel: input, Enabled: true}); err != nil { + t.Fatalf("toggle participants hidden: %v", err) + } else if len(updates.(*tg.Updates).Chats) != 1 { + t.Fatalf("toggle participants hidden updates = %+v, want channel chat", updates) + } + fullHidden, err := r.onChannelsGetFullChannel(WithUserID(ctx, requester.ID), input) + if err != nil { + t.Fatalf("requester get full after participants hidden: %v", err) + } + fullChannel := fullHidden.FullChat.(*tg.ChannelFull) + if hidden := fullChannel.GetParticipantsHidden(); !hidden || fullChannel.CanViewParticipants { + t.Fatalf("requester full channel = %+v, want participants_hidden and can_view_participants=false", fullChannel) + } + hiddenMembers, err := r.onChannelsGetParticipants(WithUserID(ctx, requester.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: input, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("requester get participants hidden: %v", err) + } + if page := hiddenMembers.(*tg.ChannelsChannelParticipants); len(page.Participants) != 0 || page.Count == 0 { + t.Fatalf("hidden participants page = %+v, want empty page with aggregate count", page) + } + viewAsMessagesUpdates, err := r.onChannelsToggleViewForumAsMessages(WithUserID(ctx, owner.ID), &tg.ChannelsToggleViewForumAsMessagesRequest{Channel: input, Enabled: true}) + if err != nil { + t.Fatalf("toggle forum as messages: %v", err) + } + viewAsMessagesUpdate, ok := viewAsMessagesUpdates.(*tg.Updates).Updates[0].(*tg.UpdateChannelViewForumAsMessages) + if !ok || viewAsMessagesUpdate.ChannelID != channel.ID || !viewAsMessagesUpdate.Enabled { + t.Fatalf("toggle forum as messages updates = %+v, want updateChannelViewForumAsMessages", viewAsMessagesUpdates) + } + fullWithViewAsMessages, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("full channel after view as messages: %v", err) + } + if full := fullWithViewAsMessages.FullChat.(*tg.ChannelFull); !full.ViewForumAsMessages { + t.Fatalf("full channel view_forum_as_messages = false, want true") + } + forumUpdates, err := r.onChannelsToggleForum(WithUserID(ctx, owner.ID), &tg.ChannelsToggleForumRequest{Channel: input, Enabled: true, Tabs: true}) + if err != nil { + t.Fatalf("toggle forum: %v", err) + } + if len(forumUpdates.(*tg.Updates).Chats) != 1 { + t.Fatalf("toggle forum updates = %+v, want channel chat", forumUpdates) + } + forumChat, ok := forumUpdates.(*tg.Updates).Chats[0].(*tg.Channel) + if !ok || !forumChat.Forum || !forumChat.ForumTabs { + t.Fatalf("toggle forum chat = %+v, want forum+forum_tabs", forumUpdates.(*tg.Updates).Chats[0]) + } + forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + forumTopics, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getForumTopics after toggle: %v", err) + } + if forumTopics.Count != 1 || len(forumTopics.Topics) != 1 || len(forumTopics.Chats) != 1 || forumTopics.Pts == 0 { + t.Fatalf("messages.getForumTopics after toggle = %+v, want general topic with channel context", forumTopics) + } + generalTopic, ok := forumTopics.Topics[0].(*tg.ForumTopic) + if !ok || generalTopic.ID != forumGeneralTopicID || generalTopic.Title != "General" { + t.Fatalf("forum topic = %+v, want General topic", forumTopics.Topics[0]) + } + forumTopicsByID, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{ + Peer: forumPeer, + Topics: []int{forumGeneralTopicID}, + }) + if err != nil { + t.Fatalf("messages.getForumTopicsByID after toggle: %v", err) + } + if forumTopicsByID.Count != 1 || len(forumTopicsByID.Topics) != 1 { + t.Fatalf("messages.getForumTopicsByID after toggle = %+v, want General topic", forumTopicsByID) + } + createdForumTopic, err := r.onMessagesCreateForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesCreateForumTopicRequest{ + Peer: forumPeer, + Title: "Ops", + IconColor: domain.DefaultForumTopicIconColor, + RandomID: 17803001, + }) + if err != nil { + t.Fatalf("messages.createForumTopic after toggle: %v", err) + } + var topicID int + for _, update := range createdForumTopic.(*tg.Updates).Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + msg, ok := newMsg.Message.(*tg.MessageService) + if !ok { + continue + } + action, ok := msg.Action.(*tg.MessageActionTopicCreate) + if ok && action.Title == "Ops" { + topicID = msg.ID + break + } + } + if topicID == 0 { + t.Fatalf("messages.createForumTopic updates = %+v, want topic root service message", createdForumTopic) + } + forumTopicsWithCreated, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getForumTopics with created topic: %v", err) + } + if forumTopicsWithCreated.Count != 2 || len(forumTopicsWithCreated.Topics) != 2 || len(forumTopicsWithCreated.Messages) == 0 { + t.Fatalf("messages.getForumTopics with created topic = %+v, want General + created topic + root message", forumTopicsWithCreated) + } + // 回归:官方 web(telegram-tt 首屏 loadTopics)发 limit=500,超单页上限。服务端必须钳到上限正常返回, + // 而非报 LIMIT_INVALID(此前 web 端每次 getForumTopics 恒失败)。客户端据返回的 count 用 offset 翻页。 + forumTopicsLargeLimit, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 500, + }) + if err != nil { + t.Fatalf("messages.getForumTopics limit=500 should clamp to page size, got err: %v", err) + } + if forumTopicsLargeLimit.Count != 2 || len(forumTopicsLargeLimit.Topics) != 2 { + t.Fatalf("messages.getForumTopics limit=500 = %+v, want clamped result (General + created topic)", forumTopicsLargeLimit) + } + var foundCreated bool + for _, item := range forumTopicsWithCreated.Topics { + topic, ok := item.(*tg.ForumTopic) + if ok && topic.ID == topicID && topic.Title == "Ops" && topic.TopMessage == topicID { + foundCreated = true + break + } + } + if !foundCreated { + t.Fatalf("messages.getForumTopics topics = %+v, want created topic id %d", forumTopicsWithCreated.Topics, topicID) + } + forumTopicsByID, err = r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{ + Peer: forumPeer, + Topics: []int{forumGeneralTopicID, topicID}, + }) + if err != nil { + t.Fatalf("messages.getForumTopicsByID with created topic: %v", err) + } + if forumTopicsByID.Count != 2 || len(forumTopicsByID.Topics) != 2 || len(forumTopicsByID.Messages) == 0 { + t.Fatalf("messages.getForumTopicsByID with created topic = %+v, want General + created topic", forumTopicsByID) + } + topicReply := &tg.InputReplyToMessage{ReplyToMsgID: 0} + topicReply.SetTopMsgID(topicID) + topicSendReq := &tg.MessagesSendMessageRequest{ + Peer: forumPeer, + Message: "topic body", + RandomID: 17803002, + } + topicSendReq.SetReplyTo(topicReply) + topicSent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), topicSendReq) + if err != nil { + t.Fatalf("messages.sendMessage topic-only reply: %v", err) + } + var topicMsg *tg.Message + for _, update := range topicSent.(*tg.Updates).Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + if msg, ok := newMsg.Message.(*tg.Message); ok && msg.Message == "topic body" { + topicMsg = msg + break + } + } + if topicMsg == nil { + t.Fatalf("messages.sendMessage topic updates = %+v, want topic message", topicSent) + } + header, ok := topicMsg.ReplyTo.(*tg.MessageReplyHeader) + if !ok || !header.ForumTopic || header.ReplyToMsgID != 0 { + t.Fatalf("topic reply header = %#v, want forum topic without reply_to_msg_id", topicMsg.ReplyTo) + } + if topID, ok := header.GetReplyToTopID(); !ok || topID != topicID { + t.Fatalf("topic reply top id = %d ok %v, want %d", topID, ok, topicID) + } + topicReplies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{ + Peer: forumPeer, + MsgID: topicID, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getReplies forum topic: %v", err) + } + topicReplyPage, ok := topicReplies.(*tg.MessagesChannelMessages) + if !ok || topicReplyPage.Pts == 0 || len(topicReplyPage.Messages) != 1 || len(topicReplyPage.Topics) != 1 { + t.Fatalf("messages.getReplies forum topic = %T %+v, want channelMessages with one topic", topicReplies, topicReplies) + } + if got := topicReplyPage.Messages[0].(*tg.Message); got.ID != topicMsg.ID || got.Message != "topic body" { + t.Fatalf("topic reply message = %#v, want id %d", got, topicMsg.ID) + } + if topic, ok := topicReplyPage.Topics[0].(*tg.ForumTopic); !ok || !topic.Short || topic.ID != topicID || topic.TopMessage != topicMsg.ID { + t.Fatalf("topic reply page topic = %#v, want short topic with updated top message %d", topicReplyPage.Topics[0], topicMsg.ID) + } + forumTopicsAfterMessage, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getForumTopics after topic message: %v", err) + } + var foundTopicTop bool + for _, item := range forumTopicsAfterMessage.Topics { + topic, ok := item.(*tg.ForumTopic) + if ok && topic.ID == topicID && topic.TopMessage == topicMsg.ID { + foundTopicTop = true + break + } + } + if !foundTopicTop { + t.Fatalf("messages.getForumTopics after topic message topics = %+v, want top message %d", forumTopicsAfterMessage.Topics, topicMsg.ID) + } + forwardToTopicReq := &tg.MessagesForwardMessagesRequest{ + FromPeer: forumPeer, + ToPeer: forumPeer, + ID: []int{topicMsg.ID}, + RandomID: []int64{17803003}, + } + forwardToTopicReq.SetTopMsgID(topicID) + forwardToTopic, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), forwardToTopicReq) + if err != nil { + t.Fatalf("messages.forwardMessages to forum topic: %v", err) + } + var forwardedTopicMsg *tg.Message + for _, update := range forwardToTopic.(*tg.Updates).Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + if msg, ok := newMsg.Message.(*tg.Message); ok && msg.Message == "topic body" && msg.ID != topicMsg.ID { + forwardedTopicMsg = msg + break + } + } + if forwardedTopicMsg == nil { + t.Fatalf("messages.forwardMessages to topic updates = %+v, want forwarded topic message", forwardToTopic) + } + if _, ok := forwardedTopicMsg.GetFwdFrom(); !ok { + t.Fatalf("forwarded topic message = %#v, want fwd header", forwardedTopicMsg) + } + forwardReply, ok := forwardedTopicMsg.ReplyTo.(*tg.MessageReplyHeader) + if !ok || !forwardReply.ForumTopic || forwardReply.ReplyToMsgID != 0 { + t.Fatalf("forward topic reply header = %#v, want forum topic without reply_to_msg_id", forwardedTopicMsg.ReplyTo) + } + if topID, ok := forwardReply.GetReplyToTopID(); !ok || topID != topicID { + t.Fatalf("forward topic reply top id = %d ok %v, want %d", topID, ok, topicID) + } + forumTopicsAfterForward, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getForumTopics after forward: %v", err) + } + var foundForwardTop bool + for _, item := range forumTopicsAfterForward.Topics { + topic, ok := item.(*tg.ForumTopic) + if ok && topic.ID == topicID && topic.TopMessage == forwardedTopicMsg.ID { + foundForwardTop = true + break + } + } + if !foundForwardTop { + t.Fatalf("messages.getForumTopics after forward topics = %+v, want top message %d", forumTopicsAfterForward.Topics, forwardedTopicMsg.ID) + } + invalidForwardTop := &tg.MessagesForwardMessagesRequest{ + FromPeer: forumPeer, + ToPeer: forumPeer, + ID: []int{topicMsg.ID}, + RandomID: []int64{17803004}, + } + invalidForwardTop.SetTopMsgID(domain.MaxMessageBoxID + 1) + if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), invalidForwardTop); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("messages.forwardMessages huge top_msg_id err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } + editTopicReq := &tg.MessagesEditForumTopicRequest{Peer: forumPeer, TopicID: topicID} + editTopicReq.SetTitle("Ops 2") + editTopicReq.SetClosed(true) + editedForumTopic, err := r.onMessagesEditForumTopic(WithUserID(ctx, owner.ID), editTopicReq) + if err != nil { + t.Fatalf("messages.editForumTopic: %v", err) + } + var editedRootID int + for _, update := range editedForumTopic.(*tg.Updates).Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + msg, ok := newMsg.Message.(*tg.MessageService) + if !ok { + continue + } + action, ok := msg.Action.(*tg.MessageActionTopicEdit) + if ok && action.Title == "Ops 2" { + if closed, closedOK := action.GetClosed(); closedOK && closed { + editedRootID = msg.ID + break + } + } + } + if editedRootID == 0 { + t.Fatalf("messages.editForumTopic updates = %+v, want topic edit service message", editedForumTopic) + } + pinnedTopic, err := r.onMessagesUpdatePinnedForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesUpdatePinnedForumTopicRequest{ + Peer: forumPeer, + TopicID: topicID, + Pinned: true, + }) + if err != nil { + t.Fatalf("messages.updatePinnedForumTopic: %v", err) + } + if got := pinnedTopic.(*tg.Updates).Updates; len(got) != 1 { + t.Fatalf("messages.updatePinnedForumTopic updates = %+v, want one update", got) + } else if update, ok := got[0].(*tg.UpdatePinnedForumTopic); !ok || update.TopicID != topicID || !update.GetPinned() { + t.Fatalf("messages.updatePinnedForumTopic update = %+v, want pinned topic", got[0]) + } + reorderedTopics, err := r.onMessagesReorderPinnedForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesReorderPinnedForumTopicsRequest{ + Peer: forumPeer, + Order: []int{topicID}, + Force: true, + }) + if err != nil { + t.Fatalf("messages.reorderPinnedForumTopics: %v", err) + } + if got := reorderedTopics.(*tg.Updates).Updates; len(got) != 1 { + t.Fatalf("messages.reorderPinnedForumTopics updates = %+v, want one update", got) + } else if update, ok := got[0].(*tg.UpdatePinnedForumTopics); !ok || len(update.Order) != 1 || update.Order[0] != topicID { + t.Fatalf("messages.reorderPinnedForumTopics update = %+v, want order", got[0]) + } + deletedTopic, err := r.onMessagesDeleteTopicHistory(WithUserID(ctx, owner.ID), &tg.MessagesDeleteTopicHistoryRequest{ + Peer: forumPeer, + TopMsgID: topicID, + }) + if err != nil { + t.Fatalf("messages.deleteTopicHistory: %v", err) + } + if deletedTopic.Pts == 0 || deletedTopic.PtsCount == 0 || deletedTopic.Offset != 0 { + t.Fatalf("messages.deleteTopicHistory = %+v, want affected history with final offset", deletedTopic) + } + forumTopicsAfterDelete, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getForumTopics after delete topic: %v", err) + } + if forumTopicsAfterDelete.Count != 1 || len(forumTopicsAfterDelete.Topics) != 1 { + t.Fatalf("messages.getForumTopics after delete topic = %+v, want General only", forumTopicsAfterDelete) + } + antiSpamUpdates, err := r.onChannelsToggleAntiSpam(WithUserID(ctx, owner.ID), &tg.ChannelsToggleAntiSpamRequest{Channel: input, Enabled: true}) + if err != nil { + t.Fatalf("toggle antispam: %v", err) + } + if len(antiSpamUpdates.(*tg.Updates).Chats) != 1 { + t.Fatalf("toggle antispam updates = %+v, want channel chat", antiSpamUpdates) + } + fullWithAntiSpam, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("full channel after antispam: %v", err) + } + if full := fullWithAntiSpam.FullChat.(*tg.ChannelFull); !full.GetAntispam() { + t.Fatalf("full channel antispam = false, want true") + } + settingsFilter := tg.ChannelAdminLogEventsFilter{} + settingsFilter.SetSettings(true) + antiSpamLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{ + Channel: input, + EventsFilter: settingsFilter, + Limit: 10, + }) + if err != nil { + t.Fatalf("admin log after antispam: %v", err) + } + foundAntiSpamLog := false + foundForumLog := false + for _, event := range antiSpamLog.Events { + if action, ok := event.Action.(*tg.ChannelAdminLogEventActionToggleAntiSpam); ok && action.NewValue { + foundAntiSpamLog = true + } + if action, ok := event.Action.(*tg.ChannelAdminLogEventActionToggleForum); ok && action.NewValue { + foundForumLog = true + } + } + if !foundAntiSpamLog || !foundForumLog { + t.Fatalf("admin log events = %+v, want toggle antispam and toggle forum actions", antiSpamLog.Events) + } + channelUpdateStubs := []struct { + name string + call func() (tg.UpdatesClass, error) + }{ + {"set boosts", func() (tg.UpdatesClass, error) { + return r.onChannelsSetBoostsToUnblockRestrictions(WithUserID(ctx, owner.ID), &tg.ChannelsSetBoostsToUnblockRestrictionsRequest{Channel: input, Boosts: 1}) + }}, + {"restrict sponsored", func() (tg.UpdatesClass, error) { + return r.onChannelsRestrictSponsoredMessages(WithUserID(ctx, owner.ID), &tg.ChannelsRestrictSponsoredMessagesRequest{Channel: input, Restricted: true}) + }}, + {"update paid messages", func() (tg.UpdatesClass, error) { + return r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: input, SendPaidMessagesStars: 7}) + }}, + {"toggle autotranslation", func() (tg.UpdatesClass, error) { + return r.onChannelsToggleAutotranslation(WithUserID(ctx, owner.ID), &tg.ChannelsToggleAutotranslationRequest{Channel: input, Enabled: true}) + }}, + } + for _, item := range channelUpdateStubs { + updates, err := item.call() + if err != nil { + t.Fatalf("%s: %v", item.name, err) + } + if len(updates.(*tg.Updates).Chats) != 1 { + t.Fatalf("%s updates = %+v, want channel chat", item.name, updates) + } + } + fullAfterPaidSettings, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("full channel after paid/settings toggles: %v", err) + } + fullPaidSettings := fullAfterPaidSettings.FullChat.(*tg.ChannelFull) + if !fullPaidSettings.GetRestrictedSponsored() { + t.Fatalf("full channel restricted_sponsored = false, want true") + } + if stars, ok := fullPaidSettings.GetSendPaidMessagesStars(); !fullPaidSettings.GetPaidMessagesAvailable() || !ok || stars != 7 { + t.Fatalf("full channel paid messages = available %v stars %d ok %v, want available true stars 7", fullPaidSettings.GetPaidMessagesAvailable(), stars, ok) + } + if boosts, ok := fullPaidSettings.GetBoostsUnrestrict(); !ok || boosts != 1 { + t.Fatalf("full channel boosts_unrestrict = %d ok %v, want 1", boosts, ok) + } + chatsAfterAutotranslation, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channels after autotranslation: %v", err) + } + channelAfterAutotranslation := chatsAfterAutotranslation.(*tg.MessagesChats).Chats[0].(*tg.Channel) + if !channelAfterAutotranslation.GetAutotranslation() { + t.Fatalf("channel autotranslation = false, want true") + } + autoLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{ + Channel: input, + EventsFilter: settingsFilter, + Limit: 10, + }) + if err != nil { + t.Fatalf("admin log after autotranslation: %v", err) + } + foundAutoLog := false + for _, event := range autoLog.Events { + if action, ok := event.Action.(*tg.ChannelAdminLogEventActionToggleAutotranslation); ok && action.NewValue { + foundAutoLog = true + break + } + } + if !foundAutoLog { + t.Fatalf("admin log events = %+v, want toggle autotranslation action", autoLog.Events) + } + if _, err := r.onChannelsSetBoostsToUnblockRestrictions(WithUserID(ctx, owner.ID), &tg.ChannelsSetBoostsToUnblockRestrictionsRequest{Channel: input, Boosts: maxChannelBoostsToUnblockRestrictions + 1}); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("set boosts over max err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: input, SendPaidMessagesStars: -1}); err == nil || !strings.Contains(err.Error(), "STARS_AMOUNT_INVALID") { + t.Fatalf("update paid messages negative supergroup err = %v, want STARS_AMOUNT_INVALID", err) + } + if _, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: input, SendPaidMessagesStars: maxChannelPaidMessageStars + 1}); err == nil || !strings.Contains(err.Error(), "STARS_AMOUNT_INVALID") { + t.Fatalf("update paid messages over max err = %v, want STARS_AMOUNT_INVALID", err) + } + broadcastCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Broadcast Paid", + About: "broadcast direct messages", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + broadcast := broadcastCreated.(*tg.Updates).Chats[0].(*tg.Channel) + broadcastInput := &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash} + disabledUpdates, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: broadcastInput, SendPaidMessagesStars: -1}) + if err != nil { + t.Fatalf("update paid messages broadcast disable: %v", err) + } + if len(disabledUpdates.(*tg.Updates).Chats) != 1 { + t.Fatalf("disable paid messages chats = %d, want parent only", len(disabledUpdates.(*tg.Updates).Chats)) + } + if _, ok := disabledUpdates.(*tg.Updates).Chats[0].(*tg.Channel).GetLinkedMonoforumID(); ok { + t.Fatalf("disable paid messages projected linked_monoforum_id, want hidden") + } + freeUpdates, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{ + BroadcastMessagesAllowed: true, + Channel: broadcastInput, + SendPaidMessagesStars: 0, + }) + if err != nil { + t.Fatalf("update paid messages broadcast free: %v", err) + } + freeChats := freeUpdates.(*tg.Updates).Chats + if len(freeChats) != 2 { + t.Fatalf("enable free direct messages chats = %d, want parent + monoforum", len(freeChats)) + } + freeParent := freeChats[0].(*tg.Channel) + monoID, ok := freeParent.GetLinkedMonoforumID() + if !ok || monoID == 0 { + t.Fatalf("enable free direct messages parent linked_monoforum_id = %d ok %v, want monoforum id", monoID, ok) + } + if stars, ok := freeParent.GetSendPaidMessagesStars(); !ok || stars != 0 { + t.Fatalf("free parent send_paid_messages_stars = %d ok %v, want 0 true", stars, ok) + } + monoChat, ok := freeChats[1].(*tg.Channel) + if !ok || monoChat.ID != monoID || !monoChat.GetMonoforum() || monoChat.GetMin() { + t.Fatalf("enable free direct messages linked chat = %T %+v, want full monoforum %d", freeChats[1], freeChats[1], monoID) + } + if stars, ok := monoChat.GetSendPaidMessagesStars(); !ok || stars != 0 { + t.Fatalf("free monoforum send_paid_messages_stars = %d ok %v, want 0 true", stars, ok) + } + if parentID, ok := monoChat.GetLinkedMonoforumID(); !ok || parentID != broadcast.ID { + t.Fatalf("free monoforum linked_monoforum_id = %d ok %v, want parent %d", parentID, ok, broadcast.ID) + } + if monoChat.Broadcast || !monoChat.Megagroup { + t.Fatalf("free monoforum kind = broadcast:%v megagroup:%v, want megagroup monoforum", monoChat.Broadcast, monoChat.Megagroup) + } + // 母广播频道收到 paid_messages_price(渲染 "Channel enabled Direct Messages");monoforum 首次 + // 创建则收到 channelCreate 创建消息(渲染 "Direct messages were enabled in this channel."), + // 不再收 paid_messages_price——在 megagroup 里那会渲染成错误的"消息免费/设价"文案。 + freeServices := paidMessagesPriceServicePeers(t, freeUpdates) + if price := freeServices[broadcast.ID]; price == nil || !price.BroadcastMessagesAllowed || price.Stars != 0 { + t.Fatalf("free parent direct messages action = %+v, want enabled stars=0", price) + } + if _, ok := freeServices[monoID]; ok { + t.Fatalf("free monoforum got a paid_messages_price service, want only the channelCreate creation message") + } + if _, ok := monoforumCreationServicePeers(t, freeUpdates)[monoID]; !ok { + t.Fatalf("free enable did not deliver monoforum creation service for %d", monoID) + } + paidUpdates, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{ + BroadcastMessagesAllowed: true, + Channel: broadcastInput, + SendPaidMessagesStars: maxChannelPaidMessageStars, + }) + if err != nil { + t.Fatalf("update paid messages broadcast max: %v", err) + } + paidChats := paidUpdates.(*tg.Updates).Chats + if len(paidChats) != 2 { + t.Fatalf("enable paid messages chats = %d, want parent + monoforum", len(paidChats)) + } + paidParent := paidChats[0].(*tg.Channel) + paidMonoID, ok := paidParent.GetLinkedMonoforumID() + if !ok || paidMonoID != monoID { + t.Fatalf("enable paid messages parent linked_monoforum_id = %d ok %v, want stable monoforum id %d", paidMonoID, ok, monoID) + } + monoChat, ok = paidChats[1].(*tg.Channel) + if !ok || monoChat.ID != monoID || !monoChat.GetMonoforum() || monoChat.GetMin() { + t.Fatalf("enable paid messages linked chat = %T %+v, want full monoforum %d", paidChats[1], paidChats[1], monoID) + } + if stars, ok := monoChat.GetSendPaidMessagesStars(); !ok || stars != maxChannelPaidMessageStars { + t.Fatalf("paid monoforum send_paid_messages_stars = %d ok %v, want %d true", stars, ok, maxChannelPaidMessageStars) + } + if parentID, ok := monoChat.GetLinkedMonoforumID(); !ok || parentID != broadcast.ID { + t.Fatalf("monoforum linked_monoforum_id = %d ok %v, want parent %d", parentID, ok, broadcast.ID) + } + if monoChat.Broadcast || !monoChat.Megagroup { + t.Fatalf("paid monoforum kind = broadcast:%v megagroup:%v, want megagroup monoforum", monoChat.Broadcast, monoChat.Megagroup) + } + // 价格变更只进母频道;monoforum 已存在,不再收任何服务消息(仍作为引用 chat 被同批下发)。 + paidServices := paidMessagesPriceServicePeers(t, paidUpdates) + if price := paidServices[broadcast.ID]; price == nil || !price.BroadcastMessagesAllowed || price.Stars != maxChannelPaidMessageStars { + t.Fatalf("paid parent direct messages action = %+v, want enabled stars=%d", price, maxChannelPaidMessageStars) + } + if _, ok := paidServices[monoID]; ok { + t.Fatalf("paid monoforum got a paid_messages_price service on re-enable, want none") + } + disabledAfterEnableUpdates, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: broadcastInput, SendPaidMessagesStars: -1}) + if err != nil { + t.Fatalf("disable direct messages after enable: %v", err) + } + disabledAfterEnableChats := disabledAfterEnableUpdates.(*tg.Updates).Chats + // 关闭后只下发母频道(linked_monoforum_id 被隐藏 → 客户端缓存里 monoforum 翻出 MonoforumDisabled + // 状态显示停用页脚);monoforum 自身不再随关闭重新下发(无新服务消息,且 broadcast_messages_allowed + // =false 时 linkedMonoforumForChannelState 也不再附带它)。 + if len(disabledAfterEnableChats) != 1 { + t.Fatalf("disable direct messages chats = %d, want parent only", len(disabledAfterEnableChats)) + } + disabledParent := disabledAfterEnableChats[0].(*tg.Channel) + if disabledParent.GetBroadcastMessagesAllowed() { + t.Fatalf("disable direct messages parent broadcast_messages_allowed = true, want false") + } + if _, ok := disabledParent.GetLinkedMonoforumID(); ok { + t.Fatalf("disable direct messages parent projected linked_monoforum_id, want hidden") + } + // 关闭只进母频道;monoforum 不再收服务消息(停用页脚靠客户端从隐藏的 linked_monoforum_id 派生)。 + disabledServices := paidMessagesPriceServicePeers(t, disabledAfterEnableUpdates) + if price := disabledServices[broadcast.ID]; price == nil || price.BroadcastMessagesAllowed || price.Stars != 0 { + t.Fatalf("disable parent direct messages action = %+v, want disabled stars=0", price) + } + if _, ok := disabledServices[monoID]; ok { + t.Fatalf("disable monoforum got a paid_messages_price service, want none") + } + broadcastChats, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{broadcastInput}) + if err != nil { + t.Fatalf("get broadcast after paid messages: %v", err) + } + broadcastAfterPaid := broadcastChats.(*tg.MessagesChats).Chats[0].(*tg.Channel) + if broadcastAfterPaid.GetBroadcastMessagesAllowed() { + t.Fatalf("broadcast_messages_allowed = true after disable, want false") + } + if _, ok := broadcastAfterPaid.GetLinkedMonoforumID(); ok { + t.Fatalf("broadcast after disable projected linked_monoforum_id, want hidden") + } + _, err = r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{ + BroadcastMessagesAllowed: true, + Channel: broadcastInput, + SendPaidMessagesStars: maxChannelPaidMessageStars, + }) + if err != nil { + t.Fatalf("re-enable direct messages before full channel: %v", err) + } + fullBroadcast, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), broadcastInput) + if err != nil { + t.Fatalf("get full broadcast after paid messages: %v", err) + } + if len(fullBroadcast.Chats) != 2 { + t.Fatalf("full broadcast chats = %d, want parent + monoforum", len(fullBroadcast.Chats)) + } + fullParent := fullBroadcast.Chats[0].(*tg.Channel) + fullMonoID, ok := fullParent.GetLinkedMonoforumID() + if !ok || fullMonoID != monoID { + t.Fatalf("full parent linked_monoforum_id = %d ok %v, want %d", fullMonoID, ok, monoID) + } + fullMono := fullBroadcast.Chats[1].(*tg.Channel) + if fullMono.ID != monoID || !fullMono.GetMonoforum() || fullMono.GetMin() { + t.Fatalf("full monoforum chat = %+v, want full monoforum %d", fullMono, monoID) + } + if fullMono.Broadcast || !fullMono.Megagroup { + t.Fatalf("full monoforum kind = broadcast:%v megagroup:%v, want megagroup monoforum", fullMono.Broadcast, fullMono.Megagroup) + } + cachedFullBroadcast, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), broadcastInput) + if err != nil { + t.Fatalf("get cached full broadcast after paid messages: %v", err) + } + if len(cachedFullBroadcast.Chats) != 2 { + t.Fatalf("cached full broadcast chats = %d, want parent + monoforum", len(cachedFullBroadcast.Chats)) + } + if _, err := r.onChannelsSetStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetStickersRequest{Channel: broadcastInput, Stickerset: &tg.InputStickerSetEmpty{}}); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { + t.Fatalf("set stickers broadcast err = %v, want CHANNEL_INVALID", err) + } + if ok, err := r.onChannelsReportAntiSpamFalsePositive(WithUserID(ctx, owner.ID), &tg.ChannelsReportAntiSpamFalsePositiveRequest{Channel: input, MsgID: msgID}); err != nil || !ok { + t.Fatalf("report antispam false positive = ok %v err %v, want true", ok, err) + } + recommendationsReq := &tg.ChannelsGetChannelRecommendationsRequest{} + recommendationsReq.SetChannel(input) + if _, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { + t.Fatalf("megagroup channel recommendations err = %v, want CHANNEL_INVALID", err) + } + if ok, err := r.onChannelsSetEmojiStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetEmojiStickersRequest{Channel: input, Stickerset: &tg.InputStickerSetEmpty{}}); err != nil || !ok { + t.Fatalf("set emoji stickers = ok %v err %v, want true", ok, err) + } + if _, err := r.onChannelsSetEmojiStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetEmojiStickersRequest{ + Channel: input, + Stickerset: &tg.InputStickerSetShortName{ShortName: "custom_emoji"}, + }); err == nil || !strings.Contains(err.Error(), "STICKERSET_INVALID") { + t.Fatalf("set emoji stickers non-empty err = %v, want STICKERSET_INVALID", err) + } + if _, err := r.onChannelsSetEmojiStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetEmojiStickersRequest{Channel: broadcastInput, Stickerset: &tg.InputStickerSetEmpty{}}); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { + t.Fatalf("set emoji stickers broadcast err = %v, want CHANNEL_INVALID", err) + } + searchPostsReq := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 10} + searchPostsReq.SetHashtag("ops") + if posts, err := r.onChannelsSearchPosts(WithUserID(ctx, owner.ID), searchPostsReq); err != nil || len(posts.(*tg.MessagesMessages).Messages) != 0 { + t.Fatalf("search posts = %+v err %v, want empty", posts, err) + } + floodReq := &tg.ChannelsCheckSearchPostsFloodRequest{} + floodReq.SetQuery("ops") + if flood, err := r.onChannelsCheckSearchPostsFlood(WithUserID(ctx, owner.ID), floodReq); err != nil || !flood.QueryIsFree { + t.Fatalf("check search posts flood = %+v err %v, want free", flood, err) + } + if ok, err := r.onChannelsSetMainProfileTab(WithUserID(ctx, owner.ID), &tg.ChannelsSetMainProfileTabRequest{Channel: input}); err != nil || !ok { + t.Fatalf("set main profile tab = ok %v err %v, want true", ok, err) + } + if ok, err := r.onChannelsToggleUsername(WithUserID(ctx, owner.ID), &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "public_team", Active: false}); err != nil || !ok { + t.Fatalf("toggle username = ok %v err %v, want true", ok, err) + } + if ok, err := r.onChannelsDeactivateAllUsernames(WithUserID(ctx, owner.ID), input); err != nil || !ok { + t.Fatalf("deactivate usernames = ok %v err %v, want true", ok, err) + } + stillPublic, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channel after deactivate: %v", err) + } + if got := stillPublic.(*tg.MessagesChats).Chats[0].(*tg.Channel); got.Username != "public_team" { + t.Fatalf("channel after fragment username stubs = %+v, want primary username preserved", got) + } + if ok, err := r.onChannelsUpdateUsername(WithUserID(ctx, owner.ID), &tg.ChannelsUpdateUsernameRequest{Channel: input, Username: ""}); err != nil || !ok { + t.Fatalf("clear username = ok %v err %v, want true", ok, err) + } + after, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channel after clear: %v", err) + } + if got := after.(*tg.MessagesChats).Chats[0].(*tg.Channel); got.Username != "" || len(got.Usernames) != 0 { + t.Fatalf("channel after clear = %+v, want username cleared", got) + } +} + +func TestChannelsGetChannelsRejectsHugeVector(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + ids := make([]tg.InputChannelClass, maxGetMessagesIDs+1) + for i := range ids { + ids[i] = &tg.InputChannel{ChannelID: int64(i + 1)} + } + if _, err := r.onChannelsGetChannels(context.Background(), ids); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("channels.getChannels huge vector err = %v, want LIMIT_INVALID", err) + } +} + +func paidMessagesPriceServicePeers(t *testing.T, updates tg.UpdatesClass) map[int64]*tg.MessageActionPaidMessagesPrice { + t.Helper() + box, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + out := make(map[int64]*tg.MessageActionPaidMessagesPrice) + for _, update := range box.Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + service, ok := newMsg.Message.(*tg.MessageService) + if !ok { + continue + } + peer, ok := service.PeerID.(*tg.PeerChannel) + if !ok { + continue + } + action, ok := service.Action.(*tg.MessageActionPaidMessagesPrice) + if !ok { + continue + } + out[peer.ChannelID] = action + } + return out +} + +// monoforumCreationServicePeers 收集 updates 里 messageActionChannelCreate 服务消息的 peer 集合。 +// monoforum 首次开启时收到一条创建消息(TDesktop 渲染 "Direct messages were enabled in this +// channel."),这是它在直连会话里唯一的服务消息;开关/价格变更不再往 mono 追加服务消息。 +func monoforumCreationServicePeers(t *testing.T, updates tg.UpdatesClass) map[int64]struct{} { + t.Helper() + box, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + out := make(map[int64]struct{}) + for _, update := range box.Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + service, ok := newMsg.Message.(*tg.MessageService) + if !ok { + continue + } + peer, ok := service.PeerID.(*tg.PeerChannel) + if !ok { + continue + } + if _, ok := service.Action.(*tg.MessageActionChannelCreate); !ok { + continue + } + out[peer.ChannelID] = struct{}{} + } + return out +} diff --git a/internal/rpc/channels_state_mutation.go b/internal/rpc/channels_state_mutation.go new file mode 100644 index 00000000..665be41a --- /dev/null +++ b/internal/rpc/channels_state_mutation.go @@ -0,0 +1,84 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +type channelStateMutation func(ctx context.Context, userID, channelID int64) (domain.Channel, error) + +func (r *Router) applyChannelAdminStateMutation(ctx context.Context, input tg.InputChannelClass, mutate channelStateMutation) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, input) + if err != nil { + return nil, err + } + channel, err := mutate(ctx, userID, channelID) + if err != nil { + return nil, channelAdminErr(err) + } + return r.channelStateMutationUpdates(ctx, userID, channel), nil +} + +type channelChangeInfoMutation func(ctx context.Context, userID int64, view domain.ChannelView) (domain.Channel, error) + +func (r *Router) applyChannelChangeInfoMutation(ctx context.Context, input tg.InputChannelClass, mutate channelChangeInfoMutation) (tg.UpdatesClass, error) { + userID, view, err := r.channelChangeInfoView(ctx, input) + if err != nil { + return nil, err + } + channel, err := mutate(ctx, userID, view) + if err != nil { + return nil, channelAdminErr(err) + } + return r.channelStateMutationUpdates(ctx, userID, channel), nil +} + +func (r *Router) channelStateMutationUpdates(ctx context.Context, userID int64, channel domain.Channel) tg.UpdatesClass { + r.invalidateRPCProjectionForChannel(channel.ID) + if channel.LinkedMonoforumID != 0 { + r.invalidateRPCProjectionForChannel(channel.LinkedMonoforumID) + } + mono, includeMono := r.linkedMonoforumForChannelState(ctx, userID, channel) + r.pushChannelStateToMembersWithLinkedMonoforum(ctx, userID, channel, mono, includeMono) + return r.channelStateUpdatesWithLinkedMonoforum(userID, channel, mono, includeMono) +} + +func (r *Router) channelPaidMessagesPriceUpdates(ctx context.Context, userID int64, res domain.ChannelPaidMessagesPriceResult) tg.UpdatesClass { + state := r.channelStateMutationUpdates(ctx, userID, res.Channel) + services := res.ServiceMessages + if len(services) == 0 && res.ServiceMessage != nil { + services = []domain.SendChannelMessageResult{*res.ServiceMessage} + } + if len(services) == 0 { + return state + } + service := r.channelMessagesUpdatesWithPeerCache(ctx, userID, services, nil, false, nil, newViewerPeerCache(r)) + if updates, ok := state.(*tg.Updates); ok { + return mergeUpdates(updates, service) + } + return state +} + +func mergeUpdates(a, b *tg.Updates) *tg.Updates { + if a == nil { + return b + } + if b == nil { + return a + } + a.Updates = append(a.Updates, b.Updates...) + a.Users = appendUniqueTGUsers(a.Users, b.Users...) + a.Chats = appendUniqueTGChats(a.Chats, b.Chats...) + if b.Date > a.Date { + a.Date = b.Date + } + return a +} diff --git a/internal/rpc/channels_stubs.go b/internal/rpc/channels_stubs.go new file mode 100644 index 00000000..375d1a59 --- /dev/null +++ b/internal/rpc/channels_stubs.go @@ -0,0 +1,715 @@ +package rpc + +import ( + "context" + "errors" + "fmt" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap" + "telesrv/internal/domain" +) + +func validateEmptyChannelStickerSet(stickerset tg.InputStickerSetClass) error { + if _, ok := stickerset.(*tg.InputStickerSetEmpty); ok { + return nil + } + return stickersetInvalidErr() +} + +func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsReportSpamRequest) (bool, error) { + if len(req.ID) > maxChannelReportMessageIDs { + return false, limitInvalidErr() + } + for _, id := range req.ID { + if id <= 0 || id > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + } + if _, _, err := r.channelView(ctx, req.Channel); err != nil { + return false, err + } + if peer, ok := r.domainPeerFromInputPeer(0, req.Participant); !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return false, peerIDInvalidErr() + } + return true, nil +} + +func (r *Router) publicRecommendationSourceChannel(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) { + ref, ok := inputChannelRef(input) + if !ok { + return 0, channelInvalidErr(domain.ErrChannelInvalid) + } + channel, err := r.deps.Channels.GetJoinableChannel(ctx, userID, ref.ID) + if err != nil { + return 0, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, channel) { + return 0, channelInvalidErr(domain.ErrChannelPrivate) + } + if channel.Deleted || !channel.Broadcast || channel.Megagroup || channel.Username == "" { + return 0, channelInvalidErr(domain.ErrChannelInvalid) + } + return channel.ID, nil +} + +func (r *Router) onChannelsSetMainProfileTab(ctx context.Context, req *tg.ChannelsSetMainProfileTabRequest) (bool, error) { + if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onChannelsDeleteChannel(ctx context.Context, input tg.InputChannelClass) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, input) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.DeleteChannel(ctx, userID, domain.DeleteChannelRequest{ + UserID: userID, + ChannelID: channelID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + r.invalidateChannelFullBotInfoCacheForChannel(res.Channel.ID) + updates := r.channelStateUpdates(userID, res.Channel) + // 母频道随带删的 monoforum 必须同样以 ChannelForbidden 墓碑下发,否则在线客户端会留着 mono 会话 + // (isMonoforum=true 但 link 已不可解析)继续渲染崩溃。对操作者本设备(updates)与所有接收方都补一条。 + if mono := res.LinkedMonoforum; mono != nil { + r.invalidateChannelFullBotInfoCacheForChannel(mono.ID) + appendChannelStateUpdates(updates, r.channelStateUpdates(userID, *mono)) + } + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + upd := r.channelStateUpdates(viewerUserID, res.Channel) + if mono := res.LinkedMonoforum; mono != nil { + appendChannelStateUpdates(upd, r.channelStateUpdates(viewerUserID, *mono)) + } + return upd + }) + r.removeOnlineChannelMembershipsForOnlineMembers(res.Channel.ID) + if mono := res.LinkedMonoforum; mono != nil { + r.removeOnlineChannelMembershipsForOnlineMembers(mono.ID) + } + return updates, nil +} + +func (r *Router) onMessagesUnpinAllMessages(ctx context.Context, req *tg.MessagesUnpinAllMessagesRequest) (*tg.MessagesAffectedHistory, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID <= 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, messageIDInvalidErr() + } + if savedPeer, ok := req.GetSavedPeerID(); ok && savedPeer != nil { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, savedPeer); err != nil { + return nil, err + } + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + // 私聊置顶按账号 pts 记账;返回 0 会让 Android 误判 pts 空洞多拉 + // 一轮 difference。 + authKeyID, _ := AuthKeyIDFrom(ctx) + if peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + if topMsgID, ok := req.GetTopMsgID(); ok && topMsgID > 0 { + // 私聊无 forum topic 维度,按无置顶可清处理。 + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + if _, ok := req.GetSavedPeerID(); ok { + // Saved Messages 子会话置顶维度未实现,避免误清全局置顶。 + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + return r.unpinAllPrivateMessages(ctx, userID, peer) + } + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil { + return nil, channelAdminErr(err) + } + if topMsgID, ok := req.GetTopMsgID(); ok && topMsgID > 0 { + return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil + } + if _, ok := req.GetSavedPeerID(); ok { + return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil + } + res, err := r.deps.Channels.UnpinAllMessages(ctx, userID, domain.UnpinAllChannelMessagesRequest{ + UserID: userID, + ChannelID: peer.ID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + if errors.Is(err, domain.ErrChannelNotModified) { + return &tg.MessagesAffectedHistory{Pts: view.Channel.Pts, Offset: 0}, nil + } + return nil, channelAdminErr(err) + } + r.invalidateRPCProjectionForChannel(res.Channel.ID) + r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { + return r.channelPinnedUpdates(viewerUserID, res) + }) + return &tg.MessagesAffectedHistory{ + Pts: res.Event.Pts, + PtsCount: res.Event.PtsCount, + Offset: 0, + }, nil +} + +// createChatNeedsLegacyChat decides whether messages.createChat returns the +// legacy chat-shaped response. Old DrKLO/TDesktop clients (and pre-initConnection +// sessions) expect it; modern clients get the canonical InvitedUsers response. +// The old createChat#34a818 id now upgrades via layerwire to the canonical id, +// and android client metadata is applied during that upgrade, so keying on +// ClientType (below) is sufficient — no per-request ctx flag is needed. +func createChatNeedsLegacyChat(ctx context.Context) bool { + if _, ok := ClientInfoFrom(ctx); !ok { + _, hasSession := SessionIDFrom(ctx) + return hasSession + } + switch ClientTypeFrom(ctx) { + case ClientTypeTDesktop, ClientTypeAndroid: + return true + default: + return false + } +} + +func channelMemberForUser(members []domain.ChannelMember, userID int64) *domain.ChannelMember { + for i := range members { + if members[i].UserID == userID { + return &members[i] + } + } + return nil +} + +func mergeChannelMembers(a, b []domain.ChannelMember) []domain.ChannelMember { + if len(a) == 0 { + return append([]domain.ChannelMember(nil), b...) + } + if len(b) == 0 { + return append([]domain.ChannelMember(nil), a...) + } + out := make([]domain.ChannelMember, 0, len(a)+len(b)) + seen := make(map[int64]struct{}, len(a)+len(b)) + appendOne := func(member domain.ChannelMember) { + if member.UserID == 0 { + return + } + if _, ok := seen[member.UserID]; ok { + return + } + seen[member.UserID] = struct{}{} + out = append(out, member) + } + for _, member := range a { + appendOne(member) + } + for _, member := range b { + appendOne(member) + } + return out +} + +func peerIDsExcept(ids []int64, skipIDs ...int64) []int64 { + unique := uniquePeerIDs(ids) + if len(unique) == 0 || len(skipIDs) == 0 { + return unique + } + skip := make(map[int64]struct{}, len(skipIDs)) + for _, id := range skipIDs { + if id != 0 { + skip[id] = struct{}{} + } + } + out := unique[:0] + for _, id := range unique { + if _, ok := skip[id]; ok { + continue + } + out = append(out, id) + } + return out +} + +type channelFanoutScope int + +func (r *Router) recordChannelAvailableMessages(ctx context.Context, userID, channelID int64, availableMinID int) domain.UpdateEvent { + event := domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventChannelAvailable, + Date: int(r.clock.Now().Unix()), + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + MaxID: availableMinID, + PtsCount: 1, + } + if r.deps.Updates == nil || userID == 0 || channelID == 0 || availableMinID <= 0 { + return event + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + recorded, _, err := r.deps.Updates.RecordChannelAvailableMessages(ctx, authKeyID, userID, channelID, availableMinID, sessionID) + if err != nil { + return event + } + return recorded +} + +func (r *Router) recordChannelReadInbox(ctx context.Context, userID int64, read domain.ReadChannelHistoryResult) (domain.UpdateEvent, error) { + if !read.Changed || read.ChannelID == 0 { + return domain.UpdateEvent{}, nil + } + date := int(r.clock.Now().Unix()) + event := domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventReadHistoryInbox, + Date: date, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: read.ChannelID}, + MaxID: read.MaxID, + StillUnreadCount: read.StillUnreadCount, + ChannelPts: read.Pts, + FolderID: read.Dialog.FolderID, + PtsCount: 1, + } + // event.Pts 是账号 pts 槽位,只能来自真实 durable 记录;channel pts + // 永远只放 ChannelPts,混填会让 pts 簿记把 channel 序列当账号序列。 + recordedEvent := event + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + recorded, _, err := r.deps.Updates.RecordReadHistory(ctx, authKeyID, userID, domain.ReadHistoryResult{ + OwnerUserID: userID, + Peer: event.Peer, + MaxID: read.MaxID, + StillUnreadCount: read.StillUnreadCount, + ChannelPts: read.Pts, + Changed: read.Changed, + }, sessionID) + if err != nil { + return domain.UpdateEvent{}, internalErr() + } + recordedEvent = recorded + } + r.pushCurrentReadHistoryEvent(ctx, recordedEvent) + r.pushReadHistoryEvent(ctx, userID, recordedEvent) + return recordedEvent, nil +} + +func (r *Router) channelFanoutRecipients(ctx context.Context, scope channelFanoutScope, channelID int64, explicit []int64) []int64 { + if channelID == 0 || r.deps.Channels == nil || r.deps.Sessions == nil { + return uniqueRecipientIDs(explicit) + } + if scope == channelFanoutExplicit { + return uniqueRecipientIDs(explicit) + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return uniqueRecipientIDs(explicit) + } + // 实时推送按 MaxChannelRealtimeFanout 封顶:每个收件人都要逐 viewer 重建 payload + // 并批量解析 users,放开会把高频操作(如 reaction)放大成 O(全部在线成员) 的逐条推送。 + var online []int64 + switch scope { + case channelFanoutMembers: + online = provider.OnlineChannelMemberUserIDs(channelID, domain.MaxChannelRealtimeFanout) + case channelFanoutViewers: + online = provider.OnlineChannelUserIDs(channelID, domain.MaxChannelRealtimeFanout) + } + if len(online) == 0 { + return uniqueRecipientIDs(explicit) + } + active, err := r.deps.Channels.FilterActiveMemberIDs(ctx, channelID, online) + if err != nil { + return uniqueRecipientIDs(explicit) + } + if len(active) == 0 && len(explicit) == 0 { + return nil + } + if len(active) > domain.MaxChannelRealtimeFanout { + active = active[:domain.MaxChannelRealtimeFanout] + } + out := uniqueRecipientIDs(active) + seen := make(map[int64]struct{}, len(out)+len(explicit)) + for _, userID := range active { + if userID == 0 { + continue + } + seen[userID] = struct{}{} + } + // Keep operation-specific recipients as a fallback: leave/kick/delete flows + // may need to notify a user who is no longer an active member after commit. + for _, userID := range explicit { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + out = append(out, userID) + } + return out +} + +func uniqueRecipientIDs(ids []int64) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, userID := range ids { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + out = append(out, userID) + } + return out +} + +func (r *Router) pushChannelStateToMembers(ctx context.Context, originUserID int64, channel domain.Channel) { + r.pushChannelStateToMembersWithLinkedMonoforum(ctx, originUserID, channel, domain.Channel{}, false) +} + +func (r *Router) pushChannelStateToMembersWithLinkedMonoforum(ctx context.Context, originUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool) { + if r.deps.Channels == nil || channel.ID == 0 { + return + } + r.pushChannelUpdates(ctx, originUserID, channel.ID, []int64{originUserID}, func(viewerUserID int64) *tg.Updates { + return r.channelStateUpdatesWithLinkedMonoforum(viewerUserID, channel, mono, includeMono) + }) +} + +func (r *Router) tgUsersForIDs(ctx context.Context, currentUserID int64, ids []int64) []tg.UserClass { + if r.deps.Users == nil || len(ids) == 0 { + return nil + } + unique := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + users, err := r.deps.Users.ByIDs(ctx, currentUserID, unique) + if err != nil { + // 批量解析失败(通常是 DB 故障)不静默丢弃:批量语义下无部分结果,记日志便于排查。 + // update 仍以空 users 列表推送,客户端用本地缓存或后续 getUser 补齐,不致命。 + // 不降级逐个查询以免 DB 抖动时把一次失败放大成 N 次查询。 + r.log.Warn("batch resolve users for channel update failed", + zap.Int("count", len(unique)), zap.Error(err)) + return nil + } + byID := make(map[int64]domain.User, len(users)) + for _, u := range users { + if u.ID != 0 { + byID[u.ID] = u + } + } + out := make([]tg.UserClass, 0, len(byID)) + for _, id := range unique { + u, ok := byID[id] + if !ok { + continue + } + u = r.withUserPresence(u) + if id == currentUserID { + out = append(out, tgSelfUser(u)) + continue + } + out = append(out, tgUser(u)) + } + r.withBotProfileFlagsForUsers(ctx, out) + return out +} + +func (r *Router) inviteManagementChannelView(ctx context.Context, peer tg.InputPeerClass) (int64, domain.ChannelView, error) { + ref, ok := inviteManagementChannelRef(peer) + if !ok { + return 0, domain.ChannelView{}, peerIDInvalidErr() + } + input := &tg.InputChannel{ChannelID: ref.ID} + if ref.CheckAccessHash { + input.AccessHash = ref.AccessHash + } + userID, view, err := r.channelView(ctx, input) + if err != nil { + return 0, domain.ChannelView{}, err + } + if view.Self.Role != domain.ChannelRoleCreator && view.Self.Role != domain.ChannelRoleAdmin { + return 0, domain.ChannelView{}, tgerr400("CHAT_ADMIN_REQUIRED") + } + return userID, view, nil +} + +func inviteManagementChannelRef(peer tg.InputPeerClass) (channelInputRef, bool) { + switch p := peer.(type) { + case *tg.InputPeerChannel: + if p == nil { + return channelInputRef{}, false + } + return channelInputRef{ + ID: p.ChannelID, + AccessHash: p.AccessHash, + CheckAccessHash: p.AccessHash != 0, + }, p.ChannelID > 0 + case *tg.InputPeerChannelFromMessage: + if p == nil { + return channelInputRef{}, false + } + return channelInputRef{ID: p.ChannelID}, p.ChannelID > 0 + case *tg.InputPeerChat: + if p == nil { + return channelInputRef{}, false + } + return channelInputRef{ID: p.ChatID}, p.ChatID > 0 + default: + return channelInputRef{}, false + } +} + +func inputUserIsEmpty(input tg.InputUserClass) bool { + switch input.(type) { + case nil, *tg.InputUserEmpty: + return true + default: + return false + } +} + +func (r *Router) userIDsFromInputUsers(ctx context.Context, currentUserID int64, inputs []tg.InputUserClass) ([]int64, error) { + out := make([]int64, 0, len(inputs)) + seen := make(map[int64]struct{}, len(inputs)) + for _, input := range inputs { + u, found, err := r.userFromInput(ctx, currentUserID, input) + if err != nil { + return nil, internalErr() + } + if !found || u.ID == 0 { + return nil, peerIDInvalidErr() + } + if _, ok := seen[u.ID]; ok { + continue + } + seen[u.ID] = struct{}{} + out = append(out, u.ID) + } + return out, nil +} + +type channelInputRef struct { + ID int64 + AccessHash int64 + CheckAccessHash bool +} + +func inputChannelRef(input tg.InputChannelClass) (channelInputRef, bool) { + switch channel := input.(type) { + case *tg.InputChannel: + return channelInputRef{ + ID: channel.ChannelID, + AccessHash: channel.AccessHash, + CheckAccessHash: channel.AccessHash != 0, + }, channel.ChannelID > 0 + case *tg.InputChannelFromMessage: + return channelInputRef{ID: channel.ChannelID}, channel.ChannelID > 0 + default: + return channelInputRef{}, false + } +} + +func inputChannelAccessHashMatches(ref channelInputRef, channel domain.Channel) bool { + return !ref.CheckAccessHash || ref.AccessHash == channel.AccessHash +} + +func (r *Router) optionalChannelIDFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) { + switch input.(type) { + case nil, *tg.InputChannelEmpty: + return 0, nil + default: + return r.channelIDFromInput(ctx, userID, input) + } +} + +func (r *Router) channelIDFromInput(ctx context.Context, userID int64, input tg.InputChannelClass) (int64, error) { + ref, ok := inputChannelRef(input) + if !ok { + return 0, channelInvalidErr(domain.ErrChannelInvalid) + } + if !ref.CheckAccessHash || r.deps.Channels == nil { + return ref.ID, nil + } + // 只校验 access_hash + 返回 ID:走轻量 ResolveChannel(省 dialog/读态/boost 这 3 条查询)。 + // 本函数被 23 个频道 RPC 复用,其中 getChannelDifference 被客户端按打开频道每秒轮询——是 + // 完整 GetChannel 投影放大的主源头。返回契约不变(仅 ID + 越权校验)。 + view, err := r.deps.Channels.ResolveChannel(ctx, userID, ref.ID) + if err != nil { + return 0, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return 0, channelInvalidErr(domain.ErrChannelPrivate) + } + return ref.ID, nil +} + +func (r *Router) channelView(ctx context.Context, input tg.InputChannelClass) (int64, domain.ChannelView, error) { + if r.deps.Channels == nil { + return 0, domain.ChannelView{}, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, domain.ChannelView{}, internalErr() + } + ref, ok := inputChannelRef(input) + if !ok { + return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelInvalid) + } + view, err := r.deps.Channels.GetChannel(ctx, userID, ref.ID) + if err != nil { + return 0, domain.ChannelView{}, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelPrivate) + } + return userID, view, nil +} + +func (r *Router) channelChangeInfoView(ctx context.Context, input tg.InputChannelClass) (int64, domain.ChannelView, error) { + if r.deps.Channels == nil { + return 0, domain.ChannelView{}, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, domain.ChannelView{}, internalErr() + } + ref, ok := inputChannelRef(input) + if !ok { + return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelInvalid) + } + view, err := r.deps.Channels.GetChannelForChangeInfo(ctx, userID, ref.ID) + if err != nil { + return 0, domain.ChannelView{}, channelAdminErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return 0, domain.ChannelView{}, channelInvalidErr(domain.ErrChannelPrivate) + } + return userID, view, nil +} + +func channelIDFromLegacyInputPeer(userID int64, peer tg.InputPeerClass) (int64, bool) { + switch p := peer.(type) { + case *tg.InputPeerChannel: + if p == nil { + return 0, false + } + return p.ChannelID, p.ChannelID > 0 + case *tg.InputPeerChat: + if p == nil { + return 0, false + } + return p.ChatID, p.ChatID > 0 + case *tg.InputPeerChannelFromMessage: + if p == nil { + return 0, false + } + return p.ChannelID, p.ChannelID > 0 + default: + return 0, false + } +} + +func (r *Router) channelIDFromLegacyInputPeerChecked(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) { + channelID, ok := channelIDFromLegacyInputPeer(userID, peer) + if !ok { + return 0, peerIDInvalidErr() + } + if err := r.validateInputPeerChannelAccess(ctx, userID, peer, channelID); err != nil { + return 0, err + } + return channelID, nil +} + +func channelInvalidErr(err error) error { + switch { + case errors.Is(err, domain.ErrUserSendRestricted): + return frozenMethodInvalidErr() + case errors.Is(err, domain.ErrChannelTitleInvalid): + return tgerr400("CHAT_TITLE_EMPTY") + case errors.Is(err, domain.ErrChannelInvalid): + return tgerr400("CHANNEL_INVALID") + case errors.Is(err, domain.ErrChannelPrivate): + return tgerr400("CHANNEL_PRIVATE") + case errors.Is(err, domain.ErrChannelUserBanned): + return tgerr400("USER_BANNED_IN_CHANNEL") + case errors.Is(err, domain.ErrChannelWriteForbidden): + return tgerr400("CHAT_WRITE_FORBIDDEN") + case errors.Is(err, domain.ErrChannelAdminRequired): + return tgerr400("CHAT_ADMIN_REQUIRED") + case errors.Is(err, domain.ErrUserAlreadyParticipant): + return tgerr400("USER_ALREADY_PARTICIPANT") + case errors.Is(err, domain.ErrReplyMessageIDInvalid): + return replyMessageIDInvalidErr() + default: + if seconds, ok := domain.SlowModeWaitSeconds(err); ok { + return tgerr.New(420, fmt.Sprintf("SLOWMODE_WAIT_%d", seconds)) + } + return internalErr() + } +} + +func channelDeleteErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrMessageAuthorRequired): + return messageAuthorRequiredErr() + case errors.Is(err, domain.ErrChannelAdminRequired): + // 官方对 channels.deleteMessages 的越权删除返回该错误码。 + return tgerr400("MESSAGE_DELETE_FORBIDDEN") + default: + return channelInvalidErr(err) + } +} + +func channelDiscussionErr(err error) error { + switch { + case errors.Is(err, domain.ErrLinkNotModified): + return tgerr400("LINK_NOT_MODIFIED") + case errors.Is(err, domain.ErrBroadcastIDInvalid): + return tgerr400("BROADCAST_ID_INVALID") + case errors.Is(err, domain.ErrMegagroupIDInvalid): + return tgerr400("MEGAGROUP_ID_INVALID") + case errors.Is(err, domain.ErrMegagroupPrehistoryHidden): + return tgerr400("MEGAGROUP_PREHISTORY_HIDDEN") + default: + return channelAdminErr(err) + } +} + +func tgerr400(message string) error { + return tgerr.New(400, message) +} diff --git a/internal/rpc/channels_topics.go b/internal/rpc/channels_topics.go new file mode 100644 index 00000000..723f0b20 --- /dev/null +++ b/internal/rpc/channels_topics.go @@ -0,0 +1,114 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func (r *Router) onChannelsToggleForum(ctx context.Context, req *tg.ChannelsToggleForumRequest) (tg.UpdatesClass, error) { + return r.applyChannelAdminStateMutation(ctx, req.Channel, func(ctx context.Context, userID, channelID int64) (domain.Channel, error) { + return r.deps.Channels.SetForum(ctx, userID, channelID, req.Enabled, req.Tabs) + }) +} + +func (r *Router) onChannelsToggleViewForumAsMessages(ctx context.Context, req *tg.ChannelsToggleViewForumAsMessagesRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + changed, err := r.deps.Channels.SetViewForumAsMessages(ctx, userID, channelID, req.Enabled) + if err != nil { + return nil, channelInvalidErr(err) + } + if !changed { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + r.invalidateRPCProjectionForPeer(userID, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) + event := domain.UpdateEvent{ + Type: domain.UpdateEventChannelViewForum, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + Bool: req.Enabled, + PtsCount: 1, + Date: int(r.clock.Now().Unix()), + } + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err = r.deps.Updates.RecordChannelViewForumAsMessages(ctx, authKeyID, userID, channelID, req.Enabled, sessionID) + if err != nil { + return nil, internalErr() + } + } + out := tgUpdateForOutboxEvent(event) + if out == nil { + out = tgEmptyUpdates(event.Date) + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out) + return out, nil +} + +func (r *Router) onMessagesUpdatePinnedMessage(ctx context.Context, req *tg.MessagesUpdatePinnedMessageRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if peer.Type == domain.PeerTypeUser && peer.ID != 0 { + return r.updatePrivatePinnedMessage(ctx, userID, peer, req) + } + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.UpdatePinnedMessage(ctx, userID, domain.UpdateChannelPinnedMessageRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.ID, + Pinned: !req.Unpin, + Silent: req.Silent, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelAdminErr(err) + } + r.invalidateRPCProjectionForChannel(res.Channel.ID) + updates := r.channelPinnedUpdates(userID, res) + // pin fan-out 异步化(设计 Phase 0),与已异步的 unpinAll(channels_stubs.go) 对齐。 + // builder 无 Users 数组(仅 pinned update + ChatMin),无需 owner 预热。pin 的真实变更由 + // UpdatePinnedChannelMessages{pts} 承载、可经 getChannelDifference 兜底,bundled 的无 pts + // UpdateChannel 对 pin 冗余(pts payload 已含变更),丢弃无害——与 unpinAll 取舍一致。 + r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { + return r.channelPinnedUpdates(viewerUserID, res) + }) + return updates, nil +} + +func (r *Router) channelPinnedUpdates(viewerUserID int64, res domain.UpdateChannelPinnedMessageResult) *tg.Updates { + updates := []tg.UpdateClass(nil) + if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { + updates = append(updates, update) + } + updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID}) + return &tg.Updates{ + Updates: updates, + Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} diff --git a/internal/rpc/channels_updates.go b/internal/rpc/channels_updates.go new file mode 100644 index 00000000..74a48a44 --- /dev/null +++ b/internal/rpc/channels_updates.go @@ -0,0 +1,404 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +func (r *Router) onUpdatesGetChannelDifference(ctx context.Context, req *tg.UpdatesGetChannelDifferenceRequest) (tg.UpdatesChannelDifferenceClass, error) { + if r.deps.Channels == nil { + return &tg.UpdatesChannelDifferenceEmpty{Final: true, Pts: req.Pts, Timeout: 30}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + // difference 类 catch-up FLOOD_WAIT(设计 Phase 2 / §10.3):nudge 被消费后客户端会触发 + // getChannelDifference,大群 nudge 全速前需限速防风暴。未配置阈值时不限速。 + if err := r.checkCatchupRateLimit(ctx, userID, channelDifferenceRateLimitKeyPrefix); err != nil { + return nil, err + } + channelID, err := r.channelIDFromInput(ctx, userID, req.Channel) + if err != nil { + return nil, err + } + r.trackChannelInterest(ctx, userID, channelID) + diff, err := r.deps.Channels.GetDifference(ctx, userID, domain.ChannelDifferenceRequest{ + UserID: userID, + ChannelID: channelID, + Pts: req.Pts, + Limit: req.Limit, + Force: req.Force, + }) + if err != nil { + if errors.Is(err, domain.ErrPersistentTimestamp) { + return nil, persistentTimestampInvalidErr() + } + return nil, channelInvalidErr(err) + } + diff = r.enrichChannelDifference(ctx, userID, diff) + return tgChannelDifference(userID, diff), nil +} + +func (r *Router) channelOperationUpdates(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult) *tg.Updates { + return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, newViewerPeerCache(r)) +} + +func (r *Router) channelOperationUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult, cache *viewerPeerCache) *tg.Updates { + if cache == nil { + cache = newViewerPeerCache(r) + } + userIDs := make(map[int64]struct{}, len(res.Members)+4) + channelIDs := make(map[int64]struct{}) + if res.Channel.CreatorUserID != 0 { + userIDs[res.Channel.CreatorUserID] = struct{}{} + } + collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs) + collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs) + for _, member := range res.Members { + if member.UserID != 0 { + userIDs[member.UserID] = struct{}{} + } + if member.InviterUserID != 0 { + userIDs[member.InviterUserID] = struct{}{} + } + } + updates := make([]tg.UpdateClass, 0, 2) + if res.Event.Pts != 0 { + if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { + updates = append(updates, update) + } + } + if res.Channel.ID != 0 { + updates = append(updates, &tg.UpdateChannel{ChannelID: res.Channel.ID}) + } + chats := []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, channelMemberForUser(res.Members, viewerUserID))} + chats = append(chats, tgChannels(viewerUserID, cache.channelsForIDs(ctx, viewerUserID, peerIDsExcept(peerIDMapKeys(channelIDs), res.Channel.ID)))...) + return &tg.Updates{ + Updates: updates, + Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))), + Chats: chats, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) tdesktopCreateChatUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.CreateChannelResult, cache *viewerPeerCache) *tg.Updates { + updates := r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, cache) + if updates == nil { + return updates + } + self := channelMemberForUser(res.Members, viewerUserID) + legacy := tgMigratedLegacyChat(viewerUserID, res.Channel, self) + if legacy == nil { + return updates + } + chats := make([]tg.ChatClass, 0, len(updates.Chats)+1) + chats = append(chats, legacy) + chats = append(chats, updates.Chats...) + updates.Chats = chats + return updates +} + +func (r *Router) channelStateUpdates(viewerUserID int64, channel domain.Channel) *tg.Updates { + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateChannel{ChannelID: channel.ID}}, + Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +// appendChannelStateUpdates 把 extra 的 update/chat 合并进 dst(chats 按 id 去重)。用于在一次响应里 +// 叠加关联频道的状态更新,例如随母频道删除一并下发的 monoforum ChannelForbidden 墓碑。 +func appendChannelStateUpdates(dst *tg.Updates, extra *tg.Updates) { + if dst == nil || extra == nil { + return + } + dst.Updates = append(dst.Updates, extra.Updates...) + for _, ch := range extra.Chats { + dst.Chats = appendUniqueTGChats(dst.Chats, ch) + } +} + +func (r *Router) channelStateUpdatesWithLinkedMonoforum(viewerUserID int64, channel domain.Channel, mono domain.Channel, includeMono bool) *tg.Updates { + updates := r.channelStateUpdates(viewerUserID, channel) + // 母广播频道有/曾有关联 monoforum 时,按完整(非 min)形态下发。关闭 Direct Messages 时 + // linked_monoforum_id 在投影里被隐藏,只有完整频道对象才能覆盖客户端缓存里旧的 linked_monoforum_id, + // 触发 applyMonoforumLinkedId(parent,0) → monoforum 的 MonoforumDisabled → 「已停用私信」停用页脚; + // min 母频道不覆盖缓存字段,页脚出不来。与官方 disable(parent 完整下发 + 隐藏 linked id)一致。 + // 普通频道(无 linked monoforum)仍走 min,不受影响。 + if channel.LinkedMonoforumID != 0 { + updates.Chats = []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)} + } + if includeMono { + updates.Chats = appendUniqueTGChats(updates.Chats, tgChannelChat(viewerUserID, mono, nil)) + } + return updates +} + +func (r *Router) linkedMonoforumForChannelState(ctx context.Context, userID int64, channel domain.Channel) (domain.Channel, bool) { + if r.deps.Channels == nil || userID == 0 || !channel.BroadcastMessagesAllowed || channel.LinkedMonoforumID == 0 { + return domain.Channel{}, false + } + mono, err := r.deps.Channels.GetJoinableChannel(ctx, userID, channel.LinkedMonoforumID) + if err != nil || !mono.Monoforum || mono.LinkedMonoforumID != channel.ID { + return domain.Channel{}, false + } + return mono, true +} + +func (r *Router) channelMessageUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.SendChannelMessageResult, randomID int64, cache *viewerPeerCache) *tg.Updates { + randomIDs := []int64(nil) + includeMessageIDs := randomID != 0 + if includeMessageIDs { + randomIDs = []int64{randomID} + } + return r.channelMessagesUpdatesWithPeerCache(ctx, viewerUserID, []domain.SendChannelMessageResult{res}, randomIDs, includeMessageIDs, nil, cache) +} + +func (r *Router) pushChannelDiscussionUpdate(ctx context.Context, originUserID int64, discussion *domain.SendChannelDiscussionResult) { + if discussion == nil || discussion.Channel.ID == 0 || discussion.Event.Pts == 0 { + return + } + res := domain.SendChannelMessageResult{ + Channel: discussion.Channel, + Message: discussion.Message, + Event: discussion.Event, + Recipients: discussion.Recipients, + MentionUserIDs: discussion.MentionUserIDs, + } + // 讨论组联动(broadcast↔linked megagroup)的第二轮 fan-out 也异步化 + 跨 viewer 投影预热 + // (设计 Phase 0/Phase 1)。cache 仅被本 fan-out 闭包/预热使用、由单分片 worker 串行执行, + // 无跨 goroutine 竞态。 + r.enqueueChannelMessageFanout(ctx, originUserID, res, nil) +} + +func (r *Router) channelMessagesUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, results []domain.SendChannelMessageResult, randomIDs []int64, includeMessageIDs bool, extraUserIDs []int64, cache *viewerPeerCache) *tg.Updates { + if cache == nil { + cache = newViewerPeerCache(r) + } + updates := make([]tg.UpdateClass, 0, len(results)*2) + userIDs := make(map[int64]struct{}, len(results)+len(extraUserIDs)) + for _, id := range extraUserIDs { + if id != 0 { + userIDs[id] = struct{}{} + } + } + channelIDs := make(map[int64]struct{}, len(results)) + var channel domain.Channel + date := 0 + for i, res := range results { + if res.Channel.ID != 0 { + channel = res.Channel + } + if includeMessageIDs && res.Message.ID != 0 && i < len(randomIDs) && randomIDs[i] != 0 { + updates = append(updates, &tg.UpdateMessageID{ID: res.Message.ID, RandomID: randomIDs[i]}) + } + if res.Event.Pts != 0 { + event := projectChannelMentionForViewer(res.Event, res.MentionUserIDs, viewerUserID) + if update := tgChannelUpdate(viewerUserID, event); update != nil { + updates = append(updates, update) + } + } + collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs) + collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs) + if date == 0 { + date = res.Event.Date + } + if date == 0 { + date = res.Message.Date + } + } + chats := []tg.ChatClass(nil) + if channel.ID != 0 { + chats = []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)} + } + chats = append(chats, tgChannels(viewerUserID, cache.channelsForIDs(ctx, viewerUserID, peerIDsExcept(peerIDMapKeys(channelIDs), channel.ID)))...) + if date == 0 { + date = int(r.clock.Now().Unix()) + } + return &tg.Updates{ + Updates: updates, + Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))), + Chats: chats, + Date: date, + Seq: 0, + } +} + +func (r *Router) channelEditMessageUpdates(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult) *tg.Updates { + return r.channelEditMessageUpdatesWithPeerCache(ctx, viewerUserID, res, newViewerPeerCache(r)) +} + +func (r *Router) channelEditMessageUpdatesWithPeerCache(ctx context.Context, viewerUserID int64, res domain.EditChannelMessageResult, cache *viewerPeerCache) *tg.Updates { + if cache == nil { + cache = newViewerPeerCache(r) + } + updates := make([]tg.UpdateClass, 0, 2) + userIDs := make(map[int64]struct{}, 2) + channelIDs := make(map[int64]struct{}) + if res.Event.Pts != 0 { + if update := tgChannelUpdate(viewerUserID, res.Event); update != nil { + updates = append(updates, update) + } + collectChannelUpdatePeerRefs(res.Event, res.Channel.ID, userIDs, channelIDs) + collectChannelMessagePeerRefs(res.Message, res.Channel.ID, userIDs, channelIDs) + } + if res.ServiceEvent.Pts != 0 { + if update := tgChannelUpdate(viewerUserID, res.ServiceEvent); update != nil { + updates = append(updates, update) + } + collectChannelUpdatePeerRefs(res.ServiceEvent, res.Channel.ID, userIDs, channelIDs) + collectChannelMessagePeerRefs(res.ServiceMessage, res.Channel.ID, userIDs, channelIDs) + } + chats := []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)} + chats = append(chats, tgChannels(viewerUserID, cache.channelsForIDs(ctx, viewerUserID, peerIDsExcept(peerIDMapKeys(channelIDs), res.Channel.ID)))...) + return &tg.Updates{ + Updates: updates, + Users: tgUsersForViewer(viewerUserID, cache.usersForIDs(ctx, viewerUserID, peerIDMapKeys(userIDs))), + Chats: chats, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) channelDeleteMessagesUpdates(viewerUserID int64, channel domain.Channel, event domain.ChannelUpdateEvent) *tg.Updates { + updates := make([]tg.UpdateClass, 0, 1) + if update := tgChannelUpdate(viewerUserID, event); update != nil { + updates = append(updates, update) + } + return &tg.Updates{ + Updates: updates, + Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) channelAvailableMessagesUpdates(viewerUserID int64, channel domain.Channel, availableMinID int) *tg.Updates { + updates := make([]tg.UpdateClass, 0, 1) + if channel.ID != 0 && availableMinID > 0 { + updates = append(updates, &tg.UpdateChannelAvailableMessages{ + ChannelID: channel.ID, + AvailableMinID: availableMinID, + }) + } + return &tg.Updates{ + Updates: updates, + Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +// projectChannelMentionForViewer 把全局消息快照按接收者投影 viewer-specific +// 的 mentioned/media_unread:被 @ 的在线成员必须在实时推送里就看到 @ 角标, +// 不能等到离线差量或重开会话才出现。 +func projectChannelMentionForViewer(event domain.ChannelUpdateEvent, mentionUserIDs []int64, viewerUserID int64) domain.ChannelUpdateEvent { + if viewerUserID == 0 || event.Message.ID == 0 || viewerUserID == event.Message.SenderUserID { + return event + } + for _, id := range mentionUserIDs { + if id != viewerUserID { + continue + } + // 客户端的未读提及判定要求 mentioned 与 media_unread 同时置位, + // 与消息是否含媒体无关。 + event.Message.Mentioned = true + event.Message.MediaUnread = true + return event + } + return event +} + +type channelUpdatesBuilder func(viewerUserID int64) *tg.Updates + +func (r *Router) pushChannelReadOutboxUpdates(ctx context.Context, channelID int64, updates []domain.ChannelReadOutboxUpdate) { + if r.deps.Sessions == nil || channelID == 0 || len(updates) == 0 { + return + } + seen := make(map[int64]int, len(updates)) + for _, update := range updates { + if update.UserID == 0 || update.MaxID <= 0 { + continue + } + if seen[update.UserID] < update.MaxID { + seen[update.UserID] = update.MaxID + } + } + date := int(r.clock.Now().Unix()) + for userID, maxID := range seen { + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateReadChannelOutbox{ChannelID: channelID, MaxID: maxID}}, + Date: date, + Seq: 0, + }) + } +} + +func (r *Router) pushChannelUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { + r.pushChannelUpdatesWithScope(ctx, channelFanoutMembers, originUserID, channelID, recipients, build) +} + +func (r *Router) pushChannelViewerUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { + r.pushChannelUpdatesWithScope(ctx, channelFanoutViewers, originUserID, channelID, recipients, build) +} + +func (r *Router) pushChannelExplicitUpdates(ctx context.Context, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { + r.pushChannelUpdatesWithScope(ctx, channelFanoutExplicit, originUserID, channelID, recipients, build) +} + +func (r *Router) pushChannelUpdatesWithScope(ctx context.Context, scope channelFanoutScope, originUserID, channelID int64, recipients []int64, build channelUpdatesBuilder) { + if r.deps.Sessions == nil || build == nil { + return + } + explicit := recipients + recipients = r.channelFanoutRecipients(ctx, scope, channelID, recipients) + r.log.Debug("push channel updates fanout", + zap.Int64("channel_id", channelID), + zap.Int("scope", int(scope)), + zap.Int64("origin_user_id", originUserID), + zap.Int64s("explicit", explicit), + zap.Int64s("recipients", recipients), + ) + seen := make(map[int64]struct{}, len(recipients)) + pushed := false + for _, userID := range recipients { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + updates := build(userID) + if updates == nil { + continue + } + r.pushUserUpdates(ctx, userID, updates) + pushed = true + } + if !pushed && originUserID != 0 { + updates := build(originUserID) + if updates == nil { + return + } + r.pushUserUpdates(ctx, originUserID, updates) + } +} + +func tgEmptyUpdates(date int) *tg.Updates { + return &tg.Updates{ + Updates: []tg.UpdateClass{}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: date, + Seq: 0, + } +} diff --git a/internal/rpc/channels_updates_rpc_test.go b/internal/rpc/channels_updates_rpc_test.go new file mode 100644 index 00000000..748a9477 --- /dev/null +++ b/internal/rpc/channels_updates_rpc_test.go @@ -0,0 +1,748 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strings" + appchannels "telesrv/internal/app/channels" + appupdates "telesrv/internal/app/updates" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestChannelRealtimeRecipientsPreferOnlineMembers(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ + Title: "Fanout", + MemberUserIDs: []int64{1002, 1999}, + Date: 10, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sessions := &captureSessions{ + onlineUserIDs: []int64{1999, 3000}, + channelViewers: map[int64][]int64{created.Channel.ID: {1999}}, + channelMembers: map[int64][]int64{created.Channel.ID: {1999, 3000}}, + } + r := New(Config{}, Deps{ + Channels: channelService, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + contains := func(items []int64, want int64) bool { + for _, item := range items { + if item == want { + return true + } + } + return false + } + + got := r.channelFanoutRecipients(ctx, channelFanoutMembers, created.Channel.ID, []int64{1002}) + if !contains(got, 1999) { + t.Fatalf("recipients = %v, want online active member 1999", got) + } + if contains(got, 3000) { + t.Fatalf("recipients = %v, non-member online user leaked", got) + } + if !contains(got, 1002) { + t.Fatalf("recipients = %v, want explicit fallback recipient 1002", got) + } + onlines, err := r.onMessagesGetOnlines(WithUserID(ctx, 1001), &tg.InputPeerChannel{ + ChannelID: created.Channel.ID, + AccessHash: created.Channel.AccessHash, + }) + if err != nil { + t.Fatalf("messages.getOnlines: %v", err) + } + if onlines.Onlines != 2 { + t.Fatalf("messages.getOnlines = %d, want caller plus online active member", onlines.Onlines) + } +} + +func TestChannelSendHistoryAndDifferenceRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002001", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002002", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + + var authKeyID [8]byte + authKeyID[0] = 9 + sendCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), authKeyID), 77) + sent, err := r.onMessagesSendMessage(sendCtx, &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "hello channel", + RandomID: 99, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + sendUpdates := sent.(*tg.Updates) + if id, ok := sendUpdates.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != 3 || id.RandomID != 99 { + t.Fatalf("message id update = %#v, want id=3 random_id=99", sendUpdates.Updates[0]) + } + newMsg, ok := sendUpdates.Updates[1].(*tg.UpdateNewChannelMessage) + if !ok || newMsg.Pts != 3 || newMsg.PtsCount != 1 { + t.Fatalf("new channel update = %#v, want pts=3", sendUpdates.Updates[1]) + } + msg := newMsg.Message.(*tg.Message) + if msg.PeerID.(*tg.PeerChannel).ChannelID != channel.ID || msg.Message != "hello channel" || !msg.Out { + t.Fatalf("channel message = %#v, want outgoing channel text", msg) + } + pushed := sessions.snapshot() + if pushed.userID != friend.ID || pushed.sessionID != 77 || pushed.messageType != proto.MessageFromServer { + t.Fatalf("pushed channel update = user %d exclude session %d type %v, want friend/exclude/from_server", pushed.userID, pushed.sessionID, pushed.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { + t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) + } + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok || len(pushedUpdates.Updates) != 1 { + t.Fatalf("pushed channel update = %T %+v, want one updates container without updateMessageID", pushed.message, pushed.message) + } + pushedNew, ok := pushedUpdates.Updates[0].(*tg.UpdateNewChannelMessage) + if !ok { + t.Fatalf("pushed update[0] = %T, want updateNewChannelMessage", pushedUpdates.Updates[0]) + } + pushedMsg := pushedNew.Message.(*tg.Message) + if pushedMsg.Out || pushedMsg.Message != "hello channel" { + t.Fatalf("pushed message = %#v, want incoming channel text for friend", pushedMsg) + } + + history, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []tg.InputMessageClass{&tg.InputMessageID{ID: msg.ID}}, + }) + if err != nil { + t.Fatalf("get channel messages: %v", err) + } + messages := history.(*tg.MessagesMessages) + got := messages.Messages[0].(*tg.Message) + if got.Message != "hello channel" || got.Out { + t.Fatalf("history message = %#v, want incoming text for friend", got) + } + + contentAuthKeyID := [8]byte{0x44} + contentCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, friend.ID), contentAuthKeyID), 88) + if ok, err := r.onChannelsReadMessageContents(contentCtx, &tg.ChannelsReadMessageContentsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{msg.ID}, + }); err != nil || !ok { + t.Fatalf("channels.readMessageContents = ok %v err %v, want true", ok, err) + } + contentPush := sessions.snapshot() + if contentPush.userID != friend.ID || contentPush.sessionID != 88 || contentPush.messageType != proto.MessageFromServer { + t.Fatalf("content-read push = user %d exclude session %d type %v, want friend/exclude/from_server", contentPush.userID, contentPush.sessionID, contentPush.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != contentAuthKeyID { + t.Fatalf("content-read exclude auth_key_id = %x, want %x", gotAuthKeyID, contentAuthKeyID) + } + contentUpdates, ok := contentPush.message.(*tg.Updates) + if !ok || len(contentUpdates.Updates) != 1 { + t.Fatalf("content-read pushed message = %T %+v, want one update", contentPush.message, contentPush.message) + } + contentRead, ok := contentUpdates.Updates[0].(*tg.UpdateChannelReadMessagesContents) + if !ok || contentRead.ChannelID != channel.ID || len(contentRead.Messages) != 1 || contentRead.Messages[0] != msg.ID { + t.Fatalf("content-read update = %#v, want channel %d msg %d", contentUpdates.Updates[0], channel.ID, msg.ID) + } + + diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: newMsg.Pts - 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel difference: %v", err) + } + fullDiff, ok := diff.(*tg.UpdatesChannelDifference) + if !ok || fullDiff.Pts != newMsg.Pts || len(fullDiff.NewMessages) != 1 { + t.Fatalf("diff = %T %+v, want one new message at pts=%d", diff, diff, newMsg.Pts) + } + if _, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: fullDiff.Pts + 1, + Limit: 10, + }); err == nil || !strings.Contains(err.Error(), "PERSISTENT_TIMESTAMP_INVALID") { + t.Fatalf("future channel pts err = %v, want PERSISTENT_TIMESTAMP_INVALID", err) + } + + readOK, err := r.onChannelsReadHistory(WithUserID(ctx, friend.ID), &tg.ChannelsReadHistoryRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MaxID: msg.ID, + }) + if err != nil || !readOK { + t.Fatalf("channels.readHistory = %v err %v, want true", readOK, err) + } + readPush := sessions.snapshot() + if readPush.userID != owner.ID || readPush.messageType != proto.MessageFromServer { + t.Fatalf("read outbox push = user %d type %v, want owner/from_server", readPush.userID, readPush.messageType) + } + readPushUpdates, ok := readPush.message.(*tg.Updates) + if !ok || len(readPushUpdates.Updates) != 1 { + t.Fatalf("read outbox pushed message = %T %+v, want one update", readPush.message, readPush.message) + } + readOutbox, ok := readPushUpdates.Updates[0].(*tg.UpdateReadChannelOutbox) + if !ok || readOutbox.ChannelID != channel.ID || readOutbox.MaxID != msg.ID { + t.Fatalf("read outbox update = %#v, want channel %d max %d", readPushUpdates.Updates[0], channel.ID, msg.ID) + } + fullAfterRead, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel after read: %v", err) + } + fullChannel := fullAfterRead.FullChat.(*tg.ChannelFull) + if fullChannel.ReadOutboxMaxID != msg.ID { + t.Fatalf("full channel read_outbox = %d, want %d", fullChannel.ReadOutboxMaxID, msg.ID) + } + readers, err := r.onMessagesGetMessageReadParticipants(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageReadParticipantsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: msg.ID, + }) + if err != nil { + t.Fatalf("get message read participants: %v", err) + } + if len(readers) != 1 || readers[0].UserID != friend.ID || readers[0].Date == 0 { + t.Fatalf("read participants = %+v, want friend read date", readers) + } + + editReq := &tg.MessagesEditMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: msg.ID, + Message: "edited channel", + } + editReq.SetMessage("edited channel") + edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq) + if err != nil { + t.Fatalf("edit channel message: %v", err) + } + editUpdates := edited.(*tg.Updates) + edit, ok := editUpdates.Updates[0].(*tg.UpdateEditChannelMessage) + if !ok || edit.Pts != newMsg.Pts+1 || edit.PtsCount != 1 { + t.Fatalf("edit update = %#v, want updateEditChannelMessage pts=%d", editUpdates.Updates[0], newMsg.Pts+1) + } + if edit.Message.(*tg.Message).Message != "edited channel" { + t.Fatalf("edited message = %#v, want edited text", edit.Message) + } + editData, err := r.onMessagesGetMessageEditData(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageEditDataRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: msg.ID, + }) + if err != nil { + t.Fatalf("get channel edit data: %v", err) + } + if editData.GetCaption() { + t.Fatalf("channel edit data caption = true, want false for text-only message") + } + + forwardReplyTo := &tg.InputReplyToMessage{ReplyToMsgID: msg.ID} + forwardReplyTo.SetQuoteText("channel") + forwardReq := &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{msg.ID}, + RandomID: []int64{100}, + } + forwardReq.SetReplyTo(forwardReplyTo) + forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, friend.ID), forwardReq) + if err != nil { + t.Fatalf("forward channel message: %v", err) + } + forwardUpdates := forwarded.(*tg.Updates) + if id, ok := forwardUpdates.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != msg.ID+1 || id.RandomID != 100 { + t.Fatalf("forward id update = %#v, want id=%d", forwardUpdates.Updates[0], msg.ID+1) + } + forwardNew, ok := forwardUpdates.Updates[1].(*tg.UpdateNewChannelMessage) + if !ok || forwardNew.Pts != edit.Pts+1 || forwardNew.PtsCount != 1 { + t.Fatalf("forward new update = %#v, want pts=%d", forwardUpdates.Updates[1], edit.Pts+1) + } + forwardMsg := forwardNew.Message.(*tg.Message) + if forwardMsg.Message != "edited channel" || forwardMsg.FwdFrom.FromID == nil { + t.Fatalf("forward message = %#v, want fwd header and edited body", forwardMsg) + } + if header, ok := forwardMsg.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != msg.ID { + t.Fatalf("forward reply header = %#v, want reply to channel message %d", forwardMsg.ReplyTo, msg.ID) + } + + deleted, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{msg.ID, forwardMsg.ID}, + }) + if err != nil { + t.Fatalf("delete channel messages: %v", err) + } + if deleted.Pts != forwardNew.Pts+2 || deleted.PtsCount != 2 { + t.Fatalf("delete affected = %+v, want pts=%d count=2", deleted, forwardNew.Pts+2) + } + + diff, err = r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: newMsg.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel difference after edit/delete: %v", err) + } + fullDiff, ok = diff.(*tg.UpdatesChannelDifference) + if !ok || fullDiff.Pts != deleted.Pts || len(fullDiff.NewMessages) != 1 || len(fullDiff.OtherUpdates) != 3 { + t.Fatalf("diff after edit/delete = %T %+v, want forward message id mapping plus edit/delete updates", diff, diff) + } + // 差量首条 other update 是请求者自己消息的 updateMessageID:断线后 + // 经差量对账本地 pending,避免重复气泡。 + if mapping, ok := fullDiff.OtherUpdates[0].(*tg.UpdateMessageID); !ok || mapping.RandomID == 0 || mapping.ID == 0 { + t.Fatalf("diff other[0] = %#v, want updateMessageID for own forwarded message", fullDiff.OtherUpdates[0]) + } +} + +func TestChannelsReadMessageContentsClearsUnreadReactionAndPushesUpdate(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002141", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002142", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Reaction Read", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "owner message", + RandomID: 21041, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + msgID := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message).ID + req := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MsgID: msgID, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f525"}}, + } + req.SetReaction(req.Reaction) + if _, err := r.onMessagesSendReaction(WithUserID(ctx, friend.ID), req); err != nil { + t.Fatalf("friend send reaction: %v", err) + } + unread, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get unread reactions: %v", err) + } + unreadMessages, _, _ := searchMessagesPayload(t, unread) + if len(unreadMessages) != 1 { + t.Fatalf("unread reactions = %+v, want one message", unread) + } + + contentAuthKeyID := [8]byte{0x66} + contentCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), contentAuthKeyID), 99) + if ok, err := r.onChannelsReadMessageContents(contentCtx, &tg.ChannelsReadMessageContentsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{msgID}, + }); err != nil || !ok { + t.Fatalf("channels.readMessageContents = ok %v err %v, want true", ok, err) + } + pushed := sessions.snapshot() + if pushed.userID != owner.ID || pushed.sessionID != 99 || pushed.messageType != proto.MessageFromServer { + t.Fatalf("reaction read push = user %d session %d type %v, want owner/exclude/from_server", pushed.userID, pushed.sessionID, pushed.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != contentAuthKeyID { + t.Fatalf("reaction read exclude auth_key_id = %x, want %x", gotAuthKeyID, contentAuthKeyID) + } + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok || len(pushedUpdates.Updates) != 1 { + t.Fatalf("reaction read push = %T %+v, want one updateMessageReactions", pushed.message, pushed.message) + } + reactionUpdate, ok := pushedUpdates.Updates[0].(*tg.UpdateMessageReactions) + if !ok || reactionUpdate.MsgID != msgID { + t.Fatalf("reaction read update = %#v, want updateMessageReactions for %d", pushedUpdates.Updates[0], msgID) + } + for _, recent := range reactionUpdate.Reactions.RecentReactions { + if recent.Unread { + t.Fatalf("reaction read update recent = %+v, want unread cleared", reactionUpdate.Reactions.RecentReactions) + } + } + unreadAfter, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get unread reactions after read contents: %v", err) + } + unreadAfterMessages, _, _ := searchMessagesPayload(t, unreadAfter) + if len(unreadAfterMessages) != 0 { + t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter) + } +} + +func TestChannelReadHistoryProducesReadChannelInboxDifference(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002131", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002132", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + updates := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Updates: updates, + Sessions: &captureSessions{}, + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Read Channel Inbox", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "read me", + RandomID: 301, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + newUpdate := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage) + msg := newUpdate.Message.(*tg.Message) + readOK, err := r.onChannelsReadHistory(WithUserID(ctx, friend.ID), &tg.ChannelsReadHistoryRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + MaxID: msg.ID, + }) + if err != nil { + t.Fatalf("channels.readHistory: %v", err) + } + if !readOK { + t.Fatalf("channels.readHistory = false, want true") + } + diff, err := r.onUpdatesGetDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("updates.getDifference: %v", err) + } + full, ok := diff.(*tg.UpdatesDifference) + if !ok || len(full.OtherUpdates) != 1 { + t.Fatalf("difference = %T %+v, want one read channel inbox update", diff, diff) + } + read, ok := full.OtherUpdates[0].(*tg.UpdateReadChannelInbox) + if !ok || read.ChannelID != channel.ID || read.MaxID != msg.ID || read.StillUnreadCount != 0 { + t.Fatalf("difference update = %#v, want updateReadChannelInbox channel %d max %d", full.OtherUpdates[0], channel.ID, msg.ID) + } + if read.Pts != newUpdate.Pts { + t.Fatalf("difference channel read pts = %d, want channel pts %d", read.Pts, newUpdate.Pts) + } + if len(full.Chats) != 1 { + t.Fatalf("difference chats = %d, want channel context", len(full.Chats)) + } +} + +func TestChannelDifferenceTooLongCarriesDialogPts(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002161", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002162", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC TooLong Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + sourceCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "RPC TooLong Source", + About: "forward source", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create source channel: %v", err) + } + sourceChannel := sourceCreated.(*tg.Updates).Chats[0].(*tg.Channel) + if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ + Channel: &tg.InputChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash}, + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + }); err != nil { + t.Fatalf("invite friend to source channel: %v", err) + } + sourceSent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash}, + Message: "forward source", + RandomID: 7000, + }) + if err != nil { + t.Fatalf("send source channel message: %v", err) + } + sourceMsgID := sourceSent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID + if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash}, + ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: []int{sourceMsgID}, + RandomID: []int64{7001}, + }); err != nil { + t.Fatalf("forward source channel to target channel: %v", err) + } + for i := 0; i < 12; i++ { + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "too long page", + RandomID: int64(i + 1), + }); err != nil { + t.Fatalf("send channel message %d: %v", i, err) + } + } + diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelMessagesFilterEmpty{}, + Pts: 0, + Limit: 3, + }) + if err != nil { + t.Fatalf("channel difference: %v", err) + } + tooLong, ok := diff.(*tg.UpdatesChannelDifferenceTooLong) + if !ok { + t.Fatalf("diff = %T %+v, want channelDifferenceTooLong", diff, diff) + } + dialog, ok := tooLong.Dialog.(*tg.Dialog) + if !ok { + t.Fatalf("tooLong dialog = %T, want dialog", tooLong.Dialog) + } + pts, ok := dialog.GetPts() + if !ok || pts == 0 { + t.Fatalf("tooLong dialog pts = %d ok=%v, want current channel pts", pts, ok) + } + if len(tooLong.Messages) == 0 || len(tooLong.Messages) > domain.MaxChannelDifferenceTooLongMessages { + t.Fatalf("tooLong messages = %d, want bounded latest snapshot", len(tooLong.Messages)) + } + if len(tooLong.Chats) == 0 || tooLong.Chats[0].(*tg.Channel).ID != channel.ID { + t.Fatalf("tooLong chats = %+v, want source channel context", tooLong.Chats) + } + hasSourceChannel := false + for _, chat := range tooLong.Chats { + if ch, ok := chat.(*tg.Channel); ok && ch.ID == sourceChannel.ID { + hasSourceChannel = true + break + } + } + if !hasSourceChannel { + t.Fatalf("tooLong chats = %+v, want forwarded source channel context %d", tooLong.Chats, sourceChannel.ID) + } + hasOwnerUser := false + for _, user := range tooLong.Users { + if u, ok := user.(*tg.User); ok && u.ID == owner.ID { + hasOwnerUser = true + break + } + } + if !hasOwnerUser { + t.Fatalf("tooLong users = %+v, want sender user context", tooLong.Users) + } +} + +func TestChannelUnreadMentionsRPCUsesMentionState(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner", Username: "owner_mention"}) + member, _ := userStore.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Mentioned", Username: "mention_friend"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), clock.System) + created, err := channelService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Mention RPC", + MemberUserIDs: []int64{member.ID}, + Date: 1700009101, + }) + if err != nil { + t.Fatalf("create megagroup: %v", err) + } + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: peer, + Message: "hello @mention_friend", + RandomID: 9102001, + }); err != nil { + t.Fatalf("send mention: %v", err) + } + mentions, err := r.onMessagesGetUnreadMentions(WithUserID(ctx, member.ID), &tg.MessagesGetUnreadMentionsRequest{ + Peer: peer, + OffsetID: 1, + AddOffset: -10, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadMentions: %v", err) + } + mentionMessages, _, _ := searchMessagesPayload(t, mentions) + if len(mentionMessages) != 1 { + t.Fatalf("messages.getUnreadMentions = %T %+v, want one unread mention", mentions, mentions) + } + if msg := mentionMessages[0].(*tg.Message); msg.Message != "hello @mention_friend" { + t.Fatalf("mention message = %#v, want sent mention", msg) + } + read, err := r.onMessagesReadMentions(WithUserID(ctx, member.ID), &tg.MessagesReadMentionsRequest{Peer: peer}) + if err != nil { + t.Fatalf("messages.readMentions: %v", err) + } + if read.Pts <= 0 || read.PtsCount != 0 || read.Offset != 0 { + t.Fatalf("messages.readMentions = %+v, want current channel pts and no offset", read) + } + mentions, err = r.onMessagesGetUnreadMentions(WithUserID(ctx, member.ID), &tg.MessagesGetUnreadMentionsRequest{ + Peer: peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("messages.getUnreadMentions after read: %v", err) + } + mentionMessages, _, _ = searchMessagesPayload(t, mentions) + if got := len(mentionMessages); got != 0 { + t.Fatalf("unread mentions after read = %d, want 0", got) + } +} + +func TestChannelDifferenceIncludesExtraForwardSourceChannel(t *testing.T) { + channel := domain.Channel{ID: 2000000100, AccessHash: 9010, Title: "Megagroup", Megagroup: true, Date: 1700000000, Pts: 3} + source := domain.Channel{ID: 2000000101, AccessHash: 9011, Title: "Source", Broadcast: true, Date: 1700000000} + got, ok := tgChannelDifference(1000000001, domain.ChannelDifference{ + Channel: channel, + NewMessages: []domain.ChannelMessage{{ + ChannelID: channel.ID, + ID: 3, + SenderUserID: 1000000002, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + Date: 1700000103, + Body: "forwarded", + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000}, + Pts: 3, + }}, + Users: []domain.User{{ID: 1000000002, AccessHash: 42, FirstName: "Bob"}}, + Channels: []domain.Channel{source}, + Pts: 3, + Final: true, + Timeout: 30, + }).(*tg.UpdatesChannelDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesChannelDifference", got) + } + if len(got.Users) != 1 || len(got.Chats) != 2 { + t.Fatalf("difference users/chats = %d/%d, want 1/2", len(got.Users), len(got.Chats)) + } + if ch, ok := got.Chats[1].(*tg.Channel); !ok || ch.ID != source.ID { + t.Fatalf("extra chat = %#v, want source channel", got.Chats[1]) + } +} + +func TestChannelReadHistoryWithReliableDispatchPushesCurrentSessionReadUpdate(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 10 + updates := &captureUpdates{ + state: domain.UpdateState{Pts: 900, Date: 1700000102, Seq: 3}, + reliableDispatch: true, + } + sessions := &captureSessions{} + r := New(Config{}, Deps{Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + ctx := WithSessionID(WithAuthKeyID(context.Background(), authKeyID), 77) + + recorded, err := r.recordChannelReadInbox(ctx, 1000000001, domain.ReadChannelHistoryResult{ + ChannelID: 12345, + MaxID: 27, + StillUnreadCount: 2, + Changed: true, + Pts: 42, + }) + if err != nil { + t.Fatalf("record channel read inbox: %v", err) + } + if recorded.Pts != 900 || recorded.ChannelPts != 42 || updates.excludeSessionID != 77 { + t.Fatalf("recorded event pts/channel_pts/session = %d/%d/%d, want durable pts 900, channel pts 42 and exclude session 77", recorded.Pts, recorded.ChannelPts, updates.excludeSessionID) + } + snap := sessions.snapshot() + if snap.sessionID != 77 || snap.messageType != proto.MessageFromServer { + t.Fatalf("current-session push target = session %d type %v, want session 77 server message", snap.sessionID, snap.messageType) + } + updatesMsg, ok := snap.message.(*tg.Updates) + if !ok || len(updatesMsg.Updates) != 2 { + t.Fatalf("current-session push = %T %+v, want read update plus account pts bookkeeping", snap.message, snap.message) + } + update, ok := updatesMsg.Updates[0].(*tg.UpdateReadChannelInbox) + if !ok { + t.Fatalf("current-session update = %T, want *tg.UpdateReadChannelInbox", updatesMsg.Updates[0]) + } + if update.ChannelID != 12345 || update.Pts != 42 || update.MaxID != 27 || update.StillUnreadCount != 2 { + t.Fatalf("current-session channel read update = %+v, want channel=12345 pts=42 max=27 still=2", update) + } + bookkeeping, ok := updatesMsg.Updates[1].(*tg.UpdateDeleteMessages) + if !ok || len(bookkeeping.Messages) != 0 || bookkeeping.Pts != 900 || bookkeeping.PtsCount != 1 { + t.Fatalf("current-session bookkeeping = %#v, want empty updateDeleteMessages at account pts 900", updatesMsg.Updates[1]) + } +} + +func TestProjectChannelMentionForViewer(t *testing.T) { + event := domain.ChannelUpdateEvent{ + ChannelID: 500, + Pts: 7, + PtsCount: 1, + Message: domain.ChannelMessage{ + ChannelID: 500, + ID: 42, + SenderUserID: 1001, + Body: "hi @bob", + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 9}}, + }, + } + mentioned := projectChannelMentionForViewer(event, []int64{1002}, 1002) + if !mentioned.Message.Mentioned || !mentioned.Message.MediaUnread { + t.Fatalf("mentioned viewer = %+v, want mentioned+media_unread set in realtime push", mentioned.Message) + } + other := projectChannelMentionForViewer(event, []int64{1002}, 1003) + if other.Message.Mentioned || other.Message.MediaUnread { + t.Fatalf("other viewer = %+v, must not inherit mention flags", other.Message) + } + sender := projectChannelMentionForViewer(event, []int64{1001}, 1001) + if sender.Message.Mentioned { + t.Fatalf("sender = %+v, must not be marked mentioned by own message", sender.Message) + } + if event.Message.Mentioned { + t.Fatalf("source event mutated: projection must copy, not alias") + } +} diff --git a/internal/rpc/chat_automation_rpc_test.go b/internal/rpc/chat_automation_rpc_test.go new file mode 100644 index 00000000..4216f4e2 --- /dev/null +++ b/internal/rpc/chat_automation_rpc_test.go @@ -0,0 +1,263 @@ +package rpc + +import ( + "context" + "strings" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + accountapp "telesrv/internal/app/account" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestQuickReplyRPCSaveListAndDeleteMessage(t *testing.T) { + const userID int64 = 1000000001 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), [8]byte{1}), 77) + r, updates := newChatAutomationTestRouter(t) + + got, err := r.onMessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerSelf{}, + Message: "Saved template", + RandomID: 12345, + QuickReplyShortcut: &tg.InputQuickReplyShortcut{Shortcut: "hello"}, + }) + if err != nil { + t.Fatalf("onMessagesSendMessage quick reply: %v", err) + } + result, ok := got.(*tg.Updates) + if !ok { + t.Fatalf("result type = %T, want *tg.Updates", got) + } + var messageID int + var shortcutID int + var sawNew bool + var sawMessageID bool + for _, update := range result.Updates { + switch u := update.(type) { + case *tg.UpdateMessageID: + sawMessageID = true + messageID = u.ID + if u.RandomID != 12345 { + t.Fatalf("UpdateMessageID random_id = %d", u.RandomID) + } + case *tg.UpdateNewQuickReply: + sawNew = true + shortcutID = u.QuickReply.ShortcutID + if u.QuickReply.Shortcut != "hello" { + t.Fatalf("UpdateNewQuickReply shortcut = %q", u.QuickReply.Shortcut) + } + } + } + if !sawMessageID || !sawNew || messageID == 0 || shortcutID == 0 { + t.Fatalf("updates = %#v, want updateMessageID and updateNewQuickReply", result.Updates) + } + if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventNewQuickReply { + t.Fatalf("recorded events = %+v", updates.events) + } + + list, err := r.onMessagesGetQuickReplies(ctx, 0) + if err != nil { + t.Fatalf("onMessagesGetQuickReplies: %v", err) + } + replies, ok := list.(*tg.MessagesQuickReplies) + if !ok || len(replies.QuickReplies) != 1 || len(replies.Messages) != 1 { + t.Fatalf("quick replies = %#v", list) + } + + deleted, err := r.onMessagesDeleteQuickReplyMessages(ctx, &tg.MessagesDeleteQuickReplyMessagesRequest{ + ShortcutID: shortcutID, + ID: []int{messageID}, + }) + if err != nil { + t.Fatalf("onMessagesDeleteQuickReplyMessages: %v", err) + } + deleteUpdates, ok := deleted.(*tg.Updates) + if !ok { + t.Fatalf("delete result type = %T", deleted) + } + var sawDelete bool + for _, update := range deleteUpdates.Updates { + if u, ok := update.(*tg.UpdateDeleteQuickReplyMessages); ok { + sawDelete = true + if u.ShortcutID != shortcutID || len(u.Messages) != 1 || u.Messages[0] != messageID { + t.Fatalf("delete update = %+v", u) + } + } + } + if !sawDelete { + t.Fatalf("delete updates = %#v, want updateDeleteQuickReplyMessages", deleteUpdates.Updates) + } +} + +func TestBusinessChatLinkRPCs(t *testing.T) { + const userID int64 = 1000000002 + ctx := WithUserID(context.Background(), userID) + r, _ := newChatAutomationTestRouter(t) + + created, err := r.onAccountCreateBusinessChatLink(ctx, tg.InputBusinessChatLink{ + Message: "Prefilled message", + Title: "Support", + }) + if err != nil { + t.Fatalf("onAccountCreateBusinessChatLink: %v", err) + } + if created.Link == "" || created.Message != "Prefilled message" { + t.Fatalf("created link = %+v", created) + } + slug := strings.TrimPrefix(created.Link, "https://telesrv.net/m/") + if slug == created.Link || slug == "" { + t.Fatalf("created link URL = %q, want telesrv.net/m slug", created.Link) + } + list, err := r.onAccountGetBusinessChatLinks(ctx) + if err != nil || len(list.Links) != 1 { + t.Fatalf("onAccountGetBusinessChatLinks len=%d err=%v", len(list.Links), err) + } + resolved, err := r.onAccountResolveBusinessChatLink(ctx, slug) + if err != nil { + t.Fatalf("onAccountResolveBusinessChatLink: %v", err) + } + peer, ok := resolved.Peer.(*tg.PeerUser) + if !ok || peer.UserID != userID || resolved.Message != "Prefilled message" { + t.Fatalf("resolved = %+v", resolved) + } + list, err = r.onAccountGetBusinessChatLinks(ctx) + if err != nil || len(list.Links) != 1 || list.Links[0].Views != 1 { + t.Fatalf("post-resolve links = %+v err=%v", list, err) + } + if deleted, err := r.onAccountDeleteBusinessChatLink(ctx, slug); err != nil || !deleted { + t.Fatalf("onAccountDeleteBusinessChatLink deleted=%v err=%v", deleted, err) + } +} + +func TestConnectedBusinessBotRPCFlow(t *testing.T) { + const ownerID int64 = 1000000010 + const peerID int64 = 1000000011 + const botID int64 = 1000000012 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), ownerID), [8]byte{2}), 88) + store := memory.NewPasswordStore() + updates := &captureUpdates{state: domain.UpdateState{Pts: 20, Date: 1700000000}} + users := mapUsersService{users: map[int64]domain.User{ + ownerID: {ID: ownerID, AccessHash: 101, FirstName: "Bob"}, + peerID: {ID: peerID, AccessHash: 102, FirstName: "Alice"}, + botID: {ID: botID, AccessHash: 103, FirstName: "Echo", Username: "echo_test_bot", Bot: true, BotInfoVersion: 1}, + }} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Account: accountapp.NewService(store, accountapp.WithBusinessAutomation(store)), + Users: users, + Updates: updates, + }, zaptest.NewLogger(t), clock.System) + + updateReq := &tg.AccountUpdateConnectedBotRequest{ + Bot: &tg.InputUser{UserID: botID, AccessHash: 103}, + Recipients: tg.InputBusinessBotRecipients{ExcludeSelected: true}, + } + updateReq.SetRights(tg.BusinessBotRights{Reply: true}) + if _, err := r.onAccountUpdateConnectedBot(ctx, updateReq); err != nil { + t.Fatalf("onAccountUpdateConnectedBot: %v", err) + } + connected, err := r.onAccountGetConnectedBots(ctx) + if err != nil { + t.Fatalf("onAccountGetConnectedBots: %v", err) + } + if len(connected.ConnectedBots) != 1 || connected.ConnectedBots[0].BotID != botID || !connected.ConnectedBots[0].Rights.Reply { + t.Fatalf("connected bots = %+v", connected.ConnectedBots) + } + botUser, ok := connected.Users[0].(*tg.User) + if !ok || !botUser.Bot || !botUser.BotBusiness { + t.Fatalf("connected bot user = %#v, want bot_business", connected.Users[0]) + } + + peerSettings, err := r.onMessagesGetPeerSettings(ctx, &tg.InputPeerUser{UserID: peerID, AccessHash: 102}) + if err != nil { + t.Fatalf("onMessagesGetPeerSettings: %v", err) + } + if peerSettings.Settings.BusinessBotID != botID || !peerSettings.Settings.BusinessBotCanReply || peerSettings.Settings.BusinessBotPaused { + t.Fatalf("peer settings before pause = %+v", peerSettings.Settings) + } + + if ok, err := r.onAccountToggleConnectedBotPaused(ctx, &tg.AccountToggleConnectedBotPausedRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 102}, + Paused: true, + }); err != nil || !ok { + t.Fatalf("onAccountToggleConnectedBotPaused = %v,%v", ok, err) + } + peerSettings, err = r.onMessagesGetPeerSettings(ctx, &tg.InputPeerUser{UserID: peerID, AccessHash: 102}) + if err != nil { + t.Fatalf("onMessagesGetPeerSettings paused: %v", err) + } + if peerSettings.Settings.BusinessBotID != botID || !peerSettings.Settings.BusinessBotPaused || peerSettings.Settings.BusinessBotCanReply { + t.Fatalf("peer settings paused = %+v", peerSettings.Settings) + } + + if ok, err := r.onAccountDisablePeerConnectedBot(ctx, &tg.InputPeerUser{UserID: peerID, AccessHash: 102}); err != nil || !ok { + t.Fatalf("onAccountDisablePeerConnectedBot = %v,%v", ok, err) + } + peerSettings, err = r.onMessagesGetPeerSettings(ctx, &tg.InputPeerUser{UserID: peerID, AccessHash: 102}) + if err != nil { + t.Fatalf("onMessagesGetPeerSettings disabled: %v", err) + } + if peerSettings.Settings.BusinessBotID != 0 || peerSettings.Settings.BusinessBotCanReply || peerSettings.Settings.BusinessBotPaused { + t.Fatalf("peer settings disabled = %+v", peerSettings.Settings) + } +} + +func TestConnectedBusinessBotDefaultsMissingRightsToReply(t *testing.T) { + const ownerID int64 = 1000000110 + const peerID int64 = 1000000111 + const botID int64 = 1000000112 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), ownerID), [8]byte{3}), 89) + store := memory.NewPasswordStore() + users := mapUsersService{users: map[int64]domain.User{ + ownerID: {ID: ownerID, AccessHash: 101, FirstName: "Bob"}, + peerID: {ID: peerID, AccessHash: 102, FirstName: "Alice"}, + botID: {ID: botID, AccessHash: 103, FirstName: "Echo", Username: "echo_default_bot", Bot: true, BotInfoVersion: 1}, + }} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Account: accountapp.NewService(store, accountapp.WithBusinessAutomation(store)), + Users: users, + }, zaptest.NewLogger(t), clock.System) + + if _, err := r.onAccountUpdateConnectedBot(ctx, &tg.AccountUpdateConnectedBotRequest{ + Bot: &tg.InputUser{UserID: botID, AccessHash: 103}, + Recipients: tg.InputBusinessBotRecipients{ExcludeSelected: true}, + }); err != nil { + t.Fatalf("onAccountUpdateConnectedBot missing rights: %v", err) + } + connected, err := r.onAccountGetConnectedBots(ctx) + if err != nil { + t.Fatalf("onAccountGetConnectedBots: %v", err) + } + if len(connected.ConnectedBots) != 1 || !connected.ConnectedBots[0].Rights.Reply { + t.Fatalf("missing rights connected bots = %+v, want reply default", connected.ConnectedBots) + } + + explicitEmpty := &tg.AccountUpdateConnectedBotRequest{ + Bot: &tg.InputUser{UserID: botID, AccessHash: 103}, + Recipients: tg.InputBusinessBotRecipients{ExcludeSelected: true}, + } + explicitEmpty.SetRights(tg.BusinessBotRights{}) + if _, err := r.onAccountUpdateConnectedBot(ctx, explicitEmpty); err != nil { + t.Fatalf("onAccountUpdateConnectedBot explicit empty rights: %v", err) + } + connected, err = r.onAccountGetConnectedBots(ctx) + if err != nil { + t.Fatalf("onAccountGetConnectedBots explicit empty: %v", err) + } + if len(connected.ConnectedBots) != 1 || connected.ConnectedBots[0].Rights.Reply { + t.Fatalf("explicit empty rights connected bots = %+v, want reply disabled", connected.ConnectedBots) + } +} + +func newChatAutomationTestRouter(t *testing.T) (*Router, *captureUpdates) { + t.Helper() + store := memory.NewPasswordStore() + updates := &captureUpdates{state: domain.UpdateState{Pts: 10, Date: 1700000000}} + return New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Account: accountapp.NewService(store, accountapp.WithBusinessAutomation(store)), + Updates: updates, + }, zaptest.NewLogger(t), clock.System), updates +} diff --git a/internal/rpc/compat.go b/internal/rpc/compat.go index f43e6a25..f870e228 100644 --- a/internal/rpc/compat.go +++ b/internal/rpc/compat.go @@ -1,56 +1,21 @@ package rpc -import ( - "context" - "fmt" - "time" +import "context" - "go.uber.org/zap" - - "github.com/gotd/td/bin" -) - -// dispatchCompat handles explicitly allowlisted legacy TL constructors that are -// still emitted by supported clients but are absent from gotd's pinned schema. -func (r *Router) dispatchCompat(ctx context.Context, b *bin.Buffer, id uint32) (bin.Encoder, bool, error) { - start := time.Now() - var ( - enc bin.Encoder - name string - err error - ) - - switch id { - case legacyAccountRegisterDeviceTypeID: - name = "account.registerDevice#637ea878" - enc, err = r.handleLegacyAccountRegisterDevice(ctx, b) - case legacyUpdatesGetDifferenceTypeID: - name = "updates.getDifference#25939651" - enc, err = r.handleLegacyUpdatesGetDifference(ctx, b) - case legacyLangpackGetLangPackTypeID: - name = "langpack.getLangPack#9ab5c58e" - enc, err = r.handleLegacyLangpackGetLangPack(ctx, b) - case legacyLangpackGetStringsTypeID: - name = "langpack.getStrings#2e1ee318" - enc, err = r.handleLegacyLangpackGetStrings(ctx, b) - case legacyLangpackGetLanguagesTypeID: - name = "langpack.getLanguages#800fd57d" - enc, err = r.handleLegacyLangpackGetLanguages(ctx, b) - default: - return nil, false, nil +// withAndroidCompatMetadata 为「客户端构造器漂移」请求**仅在当前 ctx 内**兜底 client 层/类型。 +// 这类请求多来自未完整走 initConnection 的 DrKLO Android;当 layer/ClientType 仍未知时 +// 按 android 处理,使下游(createChat 的 legacy 响应、langpack 的 lang_pack 派生)行为正确。 +// +// 关键:**绝不把这个兜底值写回持久缓存**(不调 rememberClientLayer/rememberClientInfo)。 +// 缓存是 invokeWithLayer/initConnection 的权威产物;条目被驱逐时该兜底会拿不到真实值而误判 +// 227/android,若写回就把长连接老客户端的真实 layer/类型永久覆盖(与 NegotiatedLayer 的 +// 「驱逐时不覆盖」契约矛盾)。出站 layer 由 Conn.clientLayer 承载(非覆盖),与本兜底无关。 +func (r *Router) withAndroidCompatMetadata(ctx context.Context) context.Context { + if LayerFrom(ctx) == 0 { + ctx = WithLayer(ctx, currentClientLayer) } - - fields := append([]zap.Field{ - zap.String("method", name), - zap.String("type_id", fmt.Sprintf("%#x", id)), - zap.Bool("compat", true), - zap.Duration("dur", time.Since(start)), - }, r.contextLogFields(ctx)...) - if err != nil { - fields = append(fields, zap.Error(err)) - r.log.Info("RPC compat handled", fields...) - } else { - r.log.Debug("RPC compat handled", fields...) + if ClientTypeFrom(ctx) == ClientTypeUnknown { + ctx = WithClientInfo(ctx, ClientInfo{LangPack: string(ClientTypeAndroid), Type: ClientTypeAndroid}) } - return enc, true, err + return ctx } diff --git a/internal/rpc/contacts.go b/internal/rpc/contacts.go index 25afe230..78983d22 100644 --- a/internal/rpc/contacts.go +++ b/internal/rpc/contacts.go @@ -3,6 +3,7 @@ package rpc import ( "context" "errors" + "sort" "strings" "unicode/utf8" @@ -22,6 +23,8 @@ const ( maxContactNoteLength = 4096 maxContactSearchQLen = 256 maxContactSearchLimit = 50 + maxCloseFriendsCount = 5000 + maxContactSetBlocked = 5000 ) // registerContacts 注册 contacts.* RPC handler。 @@ -33,8 +36,10 @@ func (r *Router) registerContacts(d *tg.ServerDispatcher) { d.OnContactsAddContact(r.onContactsAddContact) d.OnContactsAcceptContact(r.onContactsAcceptContact) d.OnContactsDeleteContacts(r.onContactsDeleteContacts) + d.OnContactsEditCloseFriends(r.onContactsEditCloseFriends) d.OnContactsBlock(r.onContactsBlock) d.OnContactsUnblock(r.onContactsUnblock) + d.OnContactsSetBlocked(r.onContactsSetBlocked) d.OnContactsUpdateContactNote(r.onContactsUpdateContactNote) d.OnContactsSearch(r.onContactsSearch) d.OnContactsResolveUsername(r.onContactsResolveUsername) @@ -57,6 +62,173 @@ func (r *Router) registerContacts(d *tg.ServerDispatcher) { }) } +func (r *Router) onContactsEditCloseFriends(ctx context.Context, id []int64) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if len(id) > maxCloseFriendsCount { + return false, limitInvalidErr() + } + if r.deps.Contacts == nil { + return true, nil + } + result, err := r.deps.Contacts.EditCloseFriends(ctx, userID, id) + if err != nil { + return false, contactErr(err) + } + if err := r.fanoutCloseFriendStoryChanges(ctx, userID, result); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) fanoutCloseFriendStoryChanges(ctx context.Context, ownerID int64, result domain.CloseFriendsEditResult) error { + if ownerID == 0 || r.deps.Stories == nil || r.deps.Updates == nil { + return nil + } + if len(result.AddedUserIDs) == 0 && len(result.RemovedUserIDs) == 0 { + return nil + } + candidateIDs := make([]int64, 0, len(result.AddedUserIDs)+len(result.RemovedUserIDs)) + candidateIDs = append(candidateIDs, result.AddedUserIDs...) + candidateIDs = append(candidateIDs, result.RemovedUserIDs...) + blockedFacts, err := r.storyBlockedFactsForUsers(ctx, ownerID, candidateIDs) + if err != nil { + return err + } + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID} + list, err := r.deps.Stories.ListOwnerActiveStories(ctx, ownerID, owner, int(r.clock.Now().Unix()), domain.MaxStoryListLimit) + if err != nil { + return storyErr(err) + } + for _, story := range list.Stories { + if !story.CloseFriends { + continue + } + story = storyFanoutSnapshot(story) + for _, userID := range result.AddedUserIDs { + if blockedFacts[userID] { + continue + } + if story.VisibleToWithFacts(userID, true, true) { + if err := r.recordStoryFanout(ctx, userID, story); err != nil { + return err + } + } + } + for _, userID := range result.RemovedUserIDs { + if blockedFacts[userID] || story.VisibleToWithFacts(userID, true, false) { + continue + } + deleted := story + deleted.Deleted = true + if err := r.recordStoryFanout(ctx, userID, deleted); err != nil { + return err + } + } + } + return nil +} + +func (r *Router) storyBlockedFactsForUsers(ctx context.Context, ownerID int64, userIDs []int64) (map[int64]bool, error) { + out := make(map[int64]bool) + if ownerID == 0 || r.deps.Contacts == nil { + return out, nil + } + seen := make(map[int64]struct{}, len(userIDs)) + for _, userID := range userIDs { + if userID == 0 || userID == ownerID { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + blocked, err := r.deps.Contacts.IsBlocked(ctx, ownerID, userID) + if err != nil { + return nil, internalErr() + } + if blocked { + out[userID] = true + } + } + return out, nil +} + +func (r *Router) fanoutStoryBlocklistChange(ctx context.Context, ownerID, viewerID int64, blocked bool) error { + if ownerID == 0 || viewerID == 0 || ownerID == viewerID || r.deps.Stories == nil || r.deps.Updates == nil { + return nil + } + facts, err := r.storyViewerFactsForOwner(ctx, ownerID, viewerID) + if err != nil { + return err + } + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID} + now := int(r.clock.Now().Unix()) + list, err := r.deps.Stories.ListOwnerActiveStories(ctx, ownerID, owner, now, domain.MaxStoryListLimit) + if err != nil { + return storyErr(err) + } + for _, story := range list.Stories { + if !story.Active(now) { + continue + } + beforeVisible := story.VisibleToWithStoryFacts(viewerID, facts.isContact, facts.closeFriend, !blocked) + afterVisible := story.VisibleToWithStoryFacts(viewerID, facts.isContact, facts.closeFriend, blocked) + switch { + case afterVisible && !beforeVisible: + if err := r.recordStoryFanout(ctx, viewerID, storyFanoutSnapshot(story)); err != nil { + return err + } + case beforeVisible && !afterVisible: + deleted := storyFanoutSnapshot(story) + deleted.Deleted = true + if err := r.recordStoryFanout(ctx, viewerID, deleted); err != nil { + return err + } + } + } + return nil +} + +func (r *Router) storyViewerFactsForOwner(ctx context.Context, ownerID, viewerID int64) (storyPrivacyFanoutFacts, error) { + if ownerID == 0 || viewerID == 0 || r.deps.Contacts == nil { + return storyPrivacyFanoutFacts{}, nil + } + list, _, err := r.deps.Contacts.GetContacts(ctx, ownerID, 0) + if err != nil { + return storyPrivacyFanoutFacts{}, internalErr() + } + for _, contact := range list.Contacts { + if contact.User.ID != viewerID { + continue + } + return storyPrivacyFanoutFacts{ + isContact: true, + closeFriend: contact.CloseFriend || contact.User.CloseFriend, + }, nil + } + return storyPrivacyFanoutFacts{}, nil +} + +func (r *Router) recordStoryFanout(ctx context.Context, userID int64, story domain.Story) error { + if r.deps.Updates == nil || userID == 0 { + return nil + } + if _, _, err := r.deps.Updates.RecordStoryFanout(ctx, userID, story); err != nil { + return internalErr() + } + return nil +} + +func storyFanoutSnapshot(story domain.Story) domain.Story { + story.Out = false + story.Views = domain.StoryViews{} + story.SentReaction = nil + return story +} + func (r *Router) onContactsBlock(ctx context.Context, req *tg.ContactsBlockRequest) (bool, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -69,12 +241,25 @@ func (r *Router) onContactsBlock(ctx context.Context, req *tg.ContactsBlockReque if r.deps.Contacts == nil { return true, nil } + wasBlocked, err := r.deps.Contacts.IsBlocked(ctx, userID, peer.ID) + if err != nil { + return false, contactErr(err) + } if _, err := r.deps.Contacts.BlockContact(ctx, userID, peer.ID, int(r.clock.Now().Unix())); err != nil { return false, contactErr(err) } + if !wasBlocked { + if err := r.recordPeerStoryBlocked(ctx, userID, peer, true); err != nil { + return false, internalErr() + } + if err := r.fanoutStoryBlocklistChange(ctx, userID, peer.ID, true); err != nil { + return false, err + } + } if settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer); err == nil { _ = r.recordPeerSettings(ctx, userID, peer, settings) } + r.invalidateRPCProjectionForViewer(userID) return true, nil } @@ -90,15 +275,137 @@ func (r *Router) onContactsUnblock(ctx context.Context, req *tg.ContactsUnblockR if r.deps.Contacts == nil { return true, nil } + wasBlocked, err := r.deps.Contacts.IsBlocked(ctx, userID, peer.ID) + if err != nil { + return false, contactErr(err) + } if _, err := r.deps.Contacts.UnblockContact(ctx, userID, peer.ID); err != nil { return false, contactErr(err) } + if wasBlocked { + if err := r.recordPeerStoryBlocked(ctx, userID, peer, false); err != nil { + return false, internalErr() + } + if err := r.fanoutStoryBlocklistChange(ctx, userID, peer.ID, false); err != nil { + return false, err + } + } if settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer); err == nil { _ = r.recordPeerSettings(ctx, userID, peer, settings) } + r.invalidateRPCProjectionForViewer(userID) return true, nil } +func (r *Router) onContactsSetBlocked(ctx context.Context, req *tg.ContactsSetBlockedRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if len(req.ID) > maxContactSetBlocked || req.Limit < 0 || req.Limit > maxContactSetBlocked { + return false, limitInvalidErr() + } + if r.deps.Contacts == nil { + return true, nil + } + desired := make(map[int64]domain.Peer, len(req.ID)) + desiredOrder := make([]domain.Peer, 0, len(req.ID)) + for _, input := range req.ID { + peer, ok := r.domainPeerFromInputPeer(userID, input) + if !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID { + return false, userIDInvalidErr() + } + if _, ok := desired[peer.ID]; ok { + continue + } + desired[peer.ID] = peer + desiredOrder = append(desiredOrder, peer) + } + current, err := r.currentBlockedUserIDs(ctx, userID) + if err != nil { + return false, err + } + now := int(r.clock.Now().Unix()) + for _, peer := range desiredOrder { + if current[peer.ID] { + continue + } + changed, err := r.deps.Contacts.BlockContact(ctx, userID, peer.ID, now) + if err != nil { + return false, contactErr(err) + } + if changed { + if err := r.applyContactBlocklistChange(ctx, userID, peer, true); err != nil { + return false, err + } + } + } + removeIDs := make([]int64, 0, len(current)) + for id := range current { + if _, ok := desired[id]; ok { + continue + } + removeIDs = append(removeIDs, id) + } + sort.Slice(removeIDs, func(i, j int) bool { return removeIDs[i] < removeIDs[j] }) + for _, id := range removeIDs { + peer := domain.Peer{Type: domain.PeerTypeUser, ID: id} + changed, err := r.deps.Contacts.UnblockContact(ctx, userID, id) + if err != nil { + return false, contactErr(err) + } + if changed { + if err := r.applyContactBlocklistChange(ctx, userID, peer, false); err != nil { + return false, err + } + } + } + r.invalidateRPCProjectionForViewer(userID) + return true, nil +} + +func (r *Router) currentBlockedUserIDs(ctx context.Context, userID int64) (map[int64]bool, error) { + out := make(map[int64]bool) + if r.deps.Contacts == nil || userID == 0 { + return out, nil + } + offset := 0 + for { + list, err := r.deps.Contacts.GetBlocked(ctx, userID, offset, 100) + if err != nil { + return nil, internalErr() + } + if list.Count > maxContactSetBlocked { + return nil, limitInvalidErr() + } + for _, item := range list.Blocked { + if item.User.ID != 0 { + out[item.User.ID] = true + } + } + offset += len(list.Blocked) + if len(list.Blocked) == 0 || offset >= list.Count { + break + } + } + return out, nil +} + +func (r *Router) applyContactBlocklistChange(ctx context.Context, userID int64, peer domain.Peer, blocked bool) error { + if err := r.recordPeerStoryBlocked(ctx, userID, peer, blocked); err != nil { + return internalErr() + } + if err := r.fanoutStoryBlocklistChange(ctx, userID, peer.ID, blocked); err != nil { + return err + } + if r.deps.Contacts != nil { + if settings, err := r.deps.Contacts.GetPeerSettings(ctx, userID, peer); err == nil { + _ = r.recordPeerSettings(ctx, userID, peer, settings) + } + } + return nil +} + func (r *Router) onContactsGetBlocked(ctx context.Context, req *tg.ContactsGetBlockedRequest) (tg.ContactsBlockedClass, error) { userID, _, err := r.currentUserID(ctx) if err != nil { @@ -147,7 +454,7 @@ func (r *Router) onContactsGetContacts(ctx context.Context, hash int64) (tg.Cont if notModified { return &tg.ContactsContactsNotModified{}, nil } - return tgContacts(r.withContactListPresence(list)), nil + return r.tgContacts(ctx, userID, list), nil } func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus, error) { @@ -270,6 +577,7 @@ func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputP for _, contact := range res.Contacts { out.Users = append(out.Users, r.tgUser(contact.User)) } + r.applyStoryMaxIDsToPeerObjects(ctx, userID, out.Users, nil) out.RetryContacts = append(out.RetryContacts, res.RetryContacts...) for _, contact := range res.Contacts { peer := domain.Peer{Type: domain.PeerTypeUser, ID: contact.User.ID} @@ -293,6 +601,7 @@ func (r *Router) onContactsImportContacts(ctx context.Context, input []tg.InputP r.log.Warn("contacts.importContacts record contacts reset failed", append(r.contextLogFields(ctx), zap.Error(err), zap.Int("contacts", len(items)))...) return nil, internalErr() } + r.invalidateRPCProjectionForViewer(userID) r.pushContactsReset(ctx, userID) return out, nil } @@ -356,6 +665,7 @@ func (r *Router) onContactsAddContact(ctx context.Context, req *tg.ContactsAddCo return nil, err } } + r.invalidateRPCProjectionForViewer(userID) r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates) return updates, nil } @@ -407,6 +717,7 @@ func (r *Router) onContactsAcceptContact(ctx context.Context, id tg.InputUserCla return nil, err } + r.invalidateRPCProjectionForViewer(userID) r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates) return updates, nil } @@ -467,6 +778,7 @@ func (r *Router) onContactsDeleteContacts(ctx context.Context, ids []tg.InputUse return nil, internalErr() } } + r.invalidateRPCProjectionForViewer(userID) out := &tg.Updates{Updates: updates, Users: users, Date: int(r.clock.Now().Unix())} r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, out) return out, nil @@ -496,6 +808,7 @@ func (r *Router) onContactsUpdateContactNote(ctx context.Context, req *tg.Contac if err := r.recordContactsReset(ctx, userID); err != nil { return false, internalErr() } + r.invalidateRPCProjectionForViewer(userID) r.pushContactsReset(ctx, userID) return true, nil } @@ -538,7 +851,7 @@ func (r *Router) onContactsSearch(ctx context.Context, req *tg.ContactsSearchReq res.MyChannelResults = channelRes.MyResults res.ChannelResults = channelRes.Results } - return tgContactsFound(userID, r.withUserSearchPresence(res)), nil + return r.tgContactsFound(ctx, userID, r.withUserSearchPresence(res)), nil } func (r *Router) onContactsResolveUsername(ctx context.Context, req *tg.ContactsResolveUsernameRequest) (*tg.ContactsResolvedPeer, error) { @@ -552,7 +865,7 @@ func (r *Router) onContactsResolveUsername(ctx context.Context, req *tg.Contacts return nil, usernameErr(err) } if found { - return r.tgResolvedUserPeer(userID, u), nil + return r.tgResolvedUserPeerWithStories(ctx, userID, u), nil } } if r.deps.Channels != nil { @@ -561,7 +874,11 @@ func (r *Router) onContactsResolveUsername(ctx context.Context, req *tg.Contacts return nil, usernameErr(err) } if found { - return tgResolvedChannelPeer(userID, ch), nil + view, err := r.deps.Channels.ResolveChannel(ctx, userID, ch.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + return r.tgResolvedChannelPeerWithStories(ctx, userID, view), nil } } return nil, usernameNotOccupiedErr() @@ -586,7 +903,7 @@ func (r *Router) onContactsResolvePhone(ctx context.Context, phone string) (*tg. if !found { return nil, phoneNotOccupiedErr() } - return r.tgResolvedUserPeer(userID, u), nil + return r.tgResolvedUserPeerWithStories(ctx, userID, u), nil } func (r *Router) tgResolvedUserPeer(currentUserID int64, u domain.User) *tg.ContactsResolvedPeer { @@ -602,10 +919,10 @@ func (r *Router) tgResolvedUserPeer(currentUserID int64, u domain.User) *tg.Cont } } -func tgResolvedChannelPeer(currentUserID int64, ch domain.Channel) *tg.ContactsResolvedPeer { +func tgResolvedChannelPeer(currentUserID int64, view domain.ChannelView) *tg.ContactsResolvedPeer { return &tg.ContactsResolvedPeer{ - Peer: &tg.PeerChannel{ChannelID: ch.ID}, - Chats: []tg.ChatClass{tgChannelChat(currentUserID, ch, nil)}, + Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}, + Chats: []tg.ChatClass{tgChannelChatForView(currentUserID, view)}, } } @@ -643,7 +960,7 @@ func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, p } } users = append(users, r.tgUser(peerUser)) - return &tg.Updates{ + out := &tg.Updates{ Updates: []tg.UpdateClass{ &tg.UpdatePeerSettings{ Peer: &tg.PeerUser{UserID: peerUser.ID}, @@ -654,6 +971,8 @@ func (r *Router) contactPeerSettingsUpdates(ctx context.Context, userID int64, p Date: int(r.clock.Now().Unix()), Seq: 0, } + r.applyStoryMaxIDsToPeerObjects(ctx, userID, out.Users, nil) + return out } func (r *Router) recordAcceptedContactTargetUpdates(ctx context.Context, userID, targetUserID int64) error { @@ -706,7 +1025,10 @@ func (r *Router) recordContactsResetForUser(ctx context.Context, authKeyID [8]by if r.deps.Updates == nil || userID == 0 { return nil } - _, _, err := r.deps.Updates.RecordContactsReset(ctx, authKeyID, userID, excludeSessionID) + event, _, err := r.deps.Updates.RecordContactsReset(ctx, authKeyID, userID, excludeSessionID) + if err == nil && excludeSessionID != 0 { + r.bookkeepAuxPtsForCurrentSession(ctx, event) + } return err } @@ -716,11 +1038,27 @@ func (r *Router) recordPeerSettings(ctx context.Context, userID int64, peer doma return r.recordPeerSettingsForUser(ctx, authKeyID, userID, peer, settings, sessionID) } +func (r *Router) recordPeerStoryBlocked(ctx context.Context, userID int64, peer domain.Peer, blocked bool) error { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + if r.deps.Updates == nil || userID == 0 { + return nil + } + event, _, err := r.deps.Updates.RecordPeerStoryBlocked(ctx, authKeyID, userID, peer, blocked, sessionID) + if err == nil && sessionID != 0 { + r.bookkeepAuxPtsForCurrentSession(ctx, event) + } + return err +} + func (r *Router) recordPeerSettingsForUser(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) error { if r.deps.Updates == nil || userID == 0 { return nil } - _, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, excludeSessionID) + event, _, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, settings, excludeSessionID) + if err == nil && excludeSessionID != 0 { + r.bookkeepAuxPtsForCurrentSession(ctx, event) + } return err } @@ -748,12 +1086,23 @@ func tgPeerSettings(settings domain.PeerSettings) tg.PeerSettings { if settings.HiddenPeerSettingsBar { return tg.PeerSettings{} } - return tg.PeerSettings{ + out := tg.PeerSettings{ AddContact: settings.AddContact, BlockContact: settings.BlockContact, ShareContact: settings.ShareContact, NeedContactsException: settings.NeedContactsException, } + if settings.BusinessBotID != 0 { + out.SetBusinessBotID(settings.BusinessBotID) + out.SetBusinessBotManageURL(settings.BusinessBotManageURL) + if settings.BusinessBotPaused { + out.SetBusinessBotPaused(true) + } + if settings.BusinessBotCanReply { + out.SetBusinessBotCanReply(true) + } + } + return out } func contactErr(err error) error { diff --git a/internal/rpc/contacts_users_projection_test.go b/internal/rpc/contacts_users_projection_test.go new file mode 100644 index 00000000..9a1edd14 --- /dev/null +++ b/internal/rpc/contacts_users_projection_test.go @@ -0,0 +1,17 @@ +package rpc + +import ( + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "telesrv/internal/domain" + "testing" +) + +func TestRouterTGUserUsesPersistedLastSeen(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + got := r.tgUser(domain.User{ID: 1000000002, FirstName: "Bob", LastSeenAt: 1700000000}) + if status, ok := got.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != 1700000000 { + t.Fatalf("status = %#v, want userStatusOffline was_online=1700000000", got.Status) + } +} diff --git a/internal/rpc/contacts_users_rpc_test.go b/internal/rpc/contacts_users_rpc_test.go new file mode 100644 index 00000000..9d949578 --- /dev/null +++ b/internal/rpc/contacts_users_rpc_test.go @@ -0,0 +1,1298 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "reflect" + "strings" + appchannels "telesrv/internal/app/channels" + appcontacts "telesrv/internal/app/contacts" + appstories "telesrv/internal/app/stories" + appupdates "telesrv/internal/app/updates" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" + "time" +) + +func TestContactsSearchFindsUsers(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + owner, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Searchable", LastName: "Friend", Username: "search_friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(memory.NewContactStore(), users), + }, zaptest.NewLogger(t), clock.System) + + var in bin.Buffer + if err := (&tg.ContactsSearchRequest{Q: "@search", Limit: 20}).Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.ContactsFound) + if !ok { + t.Fatalf("result type = %T, want *tg.ContactsFound", enc) + } + if len(box.Results) != 1 || len(box.Users) != 1 { + t.Fatalf("search result sizes = results %d users %d, want 1/1", len(box.Results), len(box.Users)) + } + peer, ok := box.Results[0].(*tg.PeerUser) + if !ok || peer.UserID != friend.ID { + t.Fatalf("peer = %T %+v, want friend", box.Results[0], box.Results[0]) + } +} + +func TestContactsEditCloseFriendsProjectsUserFlag(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contactsStore := memory.NewContactStore() + owner, err := users.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 12, Phone: "15550001002", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "Saved"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contactsStore, users), + }, zaptest.NewLogger(t), clock.System) + + var edit bin.Buffer + if err := (&tg.ContactsEditCloseFriendsRequest{ID: []int64{friend.ID, friend.ID, owner.ID, 0}}).Encode(&edit); err != nil { + t.Fatalf("encode edit close friends: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &edit) + if err != nil { + t.Fatalf("dispatch edit close friends: %v", err) + } + box, ok := enc.(*tg.BoolBox) + if !ok { + t.Fatalf("edit close friends result = %T, want BoolBox", enc) + } + if _, ok := box.Bool.(*tg.BoolTrue); !ok { + t.Fatalf("edit close friends bool = %T, want BoolTrue", box.Bool) + } + + var get bin.Buffer + if err := (&tg.ContactsGetContactsRequest{}).Encode(&get); err != nil { + t.Fatalf("encode get contacts: %v", err) + } + got, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &get) + if err != nil { + t.Fatalf("dispatch get contacts: %v", err) + } + contactsBox, ok := got.(*tg.ContactsContactsBox) + if !ok { + t.Fatalf("contacts result = %T %+v, want ContactsContactsBox", got, got) + } + list, ok := contactsBox.Contacts.(*tg.ContactsContacts) + if !ok || len(list.Users) != 1 { + t.Fatalf("contacts result = %T %+v, want one contact user", got, got) + } + user, ok := list.Users[0].(*tg.User) + if !ok || !user.CloseFriend { + t.Fatalf("contact user = %T %+v, want close_friend", list.Users[0], list.Users[0]) + } +} + +func TestContactsEditCloseFriendsFanoutsCloseFriendStories(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contactsStore := memory.NewContactStore() + storyStore := memory.NewStoryStore() + stateStore := memory.NewUpdateStateStore() + updateStore := memory.NewUpdateEventStore() + owner, err := users.Create(ctx, domain.User{AccessHash: 21, Phone: "15550002001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002002", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "Friend"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + for _, story := range []domain.Story{ + { + Owner: ownerPeer, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + CloseFriends: true, + Out: true, + Views: domain.StoryViews{ViewsCount: 1, HasViewers: true, RecentViewers: []int64{friend.ID}}, + }, + { + Owner: ownerPeer, + ID: 2, + Date: 1700000001, + ExpireDate: 1700003600, + CloseFriends: true, + AllowUserIDs: []int64{friend.ID}, + }, + { + Owner: ownerPeer, + ID: 3, + Date: 1700000002, + ExpireDate: 1700000005, + CloseFriends: true, + }, + } { + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story %d: %v", story.ID, err) + } + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contactsStore, users), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(stateStore, updateStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + ownerAuth := [8]byte{1, 2, 3} + var add bin.Buffer + if err := (&tg.ContactsEditCloseFriendsRequest{ID: []int64{friend.ID}}).Encode(&add); err != nil { + t.Fatalf("encode add close friend: %v", err) + } + ownerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuth), 44) + if _, err := r.Dispatch(ownerCtx, ownerAuth, 44, &add); err != nil { + t.Fatalf("dispatch add close friend: %v", err) + } + if _, found, err := stateStore.Get(ctx, ownerAuth, friend.ID); err != nil || found { + t.Fatalf("owner auth state for friend found=%v err=%v, want absent", found, err) + } + + var clear bin.Buffer + if err := (&tg.ContactsEditCloseFriendsRequest{ID: []int64{}}).Encode(&clear); err != nil { + t.Fatalf("encode clear close friends: %v", err) + } + if _, err := r.Dispatch(ownerCtx, ownerAuth, 44, &clear); err != nil { + t.Fatalf("dispatch clear close friends: %v", err) + } + + diff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, friend.ID), [8]byte{9}), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get friend difference: %v", err) + } + got, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", diff) + } + if got.State.Pts != 3 || len(got.OtherUpdates) != 3 { + t.Fatalf("difference pts/updates = %d/%d, want 3/3", got.State.Pts, len(got.OtherUpdates)) + } + for i, update := range got.OtherUpdates[:2] { + storyUpdate, ok := update.(*tg.UpdateStory) + if !ok { + t.Fatalf("add update %d = %T, want *tg.UpdateStory", i, update) + } + item, ok := storyUpdate.Story.(*tg.StoryItem) + if !ok { + t.Fatalf("add story %d = %T, want *tg.StoryItem", i, storyUpdate.Story) + } + if _, ok := item.GetViews(); item.Out || ok { + t.Fatalf("add story %d = %+v, want viewer fanout without out/views", i, item) + } + } + last, ok := got.OtherUpdates[2].(*tg.UpdateStory) + if !ok { + t.Fatalf("remove update = %T, want *tg.UpdateStory", got.OtherUpdates[2]) + } + deleted, ok := last.Story.(*tg.StoryItemDeleted) + if !ok { + t.Fatalf("remove story = %T, want *tg.StoryItemDeleted", last.Story) + } + if deleted.ID != 1 { + t.Fatalf("deleted story id = %d, want only story 1 removed", deleted.ID) + } +} + +func TestContactsBlockUnblockFanoutsStoryVisibilityChanges(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contactsStore := memory.NewContactStore() + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + owner, err := users.Create(ctx, domain.User{AccessHash: 31, Phone: "15550003001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := users.Create(ctx, domain.User{AccessHash: 32, Phone: "15550003002", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: viewer.ID, FirstName: "Viewer"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + for _, story := range []domain.Story{ + { + Owner: ownerPeer, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + Out: true, + Views: domain.StoryViews{ViewsCount: 3, HasViewers: true, RecentViewers: []int64{viewer.ID}}, + }, + { + Owner: ownerPeer, + ID: 2, + Date: 1700000001, + ExpireDate: 1700003600, + SelectedContacts: true, + AllowUserIDs: []int64{viewer.ID}, + }, + { + Owner: ownerPeer, + ID: 3, + Date: 1700000002, + ExpireDate: 1700003600, + Contacts: true, + }, + { + Owner: ownerPeer, + ID: 4, + Date: 1700000003, + ExpireDate: 1700003600, + CloseFriends: true, + }, + { + Owner: ownerPeer, + ID: 5, + Date: 1700000004, + ExpireDate: 1700003600, + Public: true, + DisallowUserIDs: []int64{viewer.ID}, + }, + { + Owner: ownerPeer, + ID: 6, + Date: 1700000005, + ExpireDate: 1700000006, + Public: true, + }, + } { + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story %d: %v", story.ID, err) + } + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contactsStore, users), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + ownerAuth := [8]byte{7, 7, 1} + ownerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuth), 51) + if ok, err := r.onContactsBlock(ownerCtx, &tg.ContactsBlockRequest{ + MyStoriesFrom: true, + ID: &tg.InputPeerUser{UserID: viewer.ID, AccessHash: viewer.AccessHash}, + }); err != nil || !ok { + t.Fatalf("contacts.block = %v, %v", ok, err) + } + ownerBlockDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, owner.ID), [8]byte{7, 7, 2}), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get owner block difference: %v", err) + } + ownerBlockUpdates, ok := ownerBlockDiff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("owner block difference = %T, want *tg.UpdatesDifference", ownerBlockDiff) + } + if ownerBlockUpdates.State.Pts != 2 || len(ownerBlockUpdates.OtherUpdates) != 2 { + t.Fatalf("owner block difference pts/updates = %d/%d, want 2/2", ownerBlockUpdates.State.Pts, len(ownerBlockUpdates.OtherUpdates)) + } + ownerBlocked, ok := ownerBlockUpdates.OtherUpdates[0].(*tg.UpdatePeerBlocked) + if !ok || !ownerBlocked.Blocked || !ownerBlocked.BlockedMyStoriesFrom { + t.Fatalf("owner block update[0] = %+v (%T), want story block", ownerBlockUpdates.OtherUpdates[0], ownerBlockUpdates.OtherUpdates[0]) + } + if peer, ok := ownerBlocked.PeerID.(*tg.PeerUser); !ok || peer.UserID != viewer.ID { + t.Fatalf("owner block peer = %#v, want viewer", ownerBlocked.PeerID) + } + if _, ok := ownerBlockUpdates.OtherUpdates[1].(*tg.UpdatePeerSettings); !ok { + t.Fatalf("owner block update[1] = %T, want peer settings", ownerBlockUpdates.OtherUpdates[1]) + } + blockDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, viewer.ID), [8]byte{3, 1}), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get viewer block difference: %v", err) + } + blockUpdates, ok := blockDiff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("block difference = %T, want *tg.UpdatesDifference", blockDiff) + } + if blockUpdates.State.Pts != 3 || len(blockUpdates.OtherUpdates) != 3 { + t.Fatalf("block difference pts/updates = %d/%d, want 3/3", blockUpdates.State.Pts, len(blockUpdates.OtherUpdates)) + } + for i, update := range blockUpdates.OtherUpdates { + storyUpdate, ok := update.(*tg.UpdateStory) + if !ok { + t.Fatalf("block update %d = %T, want *tg.UpdateStory", i, update) + } + if peer, ok := storyUpdate.Peer.(*tg.PeerUser); !ok || peer.UserID != owner.ID { + t.Fatalf("block update %d peer = %#v, want owner peer", i, storyUpdate.Peer) + } + deleted, ok := storyUpdate.Story.(*tg.StoryItemDeleted) + if !ok { + t.Fatalf("block story %d = %T, want *tg.StoryItemDeleted", i, storyUpdate.Story) + } + if deleted.ID != i+1 { + t.Fatalf("block deleted story id = %d, want %d", deleted.ID, i+1) + } + } + + if ok, err := r.onContactsUnblock(ownerCtx, &tg.ContactsUnblockRequest{ + MyStoriesFrom: true, + ID: &tg.InputPeerUser{UserID: viewer.ID, AccessHash: viewer.AccessHash}, + }); err != nil || !ok { + t.Fatalf("contacts.unblock = %v, %v", ok, err) + } + ownerUnblockDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, owner.ID), [8]byte{7, 7, 3}), &tg.UpdatesGetDifferenceRequest{Pts: ownerBlockUpdates.State.Pts}) + if err != nil { + t.Fatalf("get owner unblock difference: %v", err) + } + ownerUnblockUpdates, ok := ownerUnblockDiff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("owner unblock difference = %T, want *tg.UpdatesDifference", ownerUnblockDiff) + } + if ownerUnblockUpdates.State.Pts != 4 || len(ownerUnblockUpdates.OtherUpdates) != 2 { + t.Fatalf("owner unblock difference pts/updates = %d/%d, want 4/2", ownerUnblockUpdates.State.Pts, len(ownerUnblockUpdates.OtherUpdates)) + } + ownerUnblocked, ok := ownerUnblockUpdates.OtherUpdates[0].(*tg.UpdatePeerBlocked) + if !ok || ownerUnblocked.Blocked || !ownerUnblocked.BlockedMyStoriesFrom { + t.Fatalf("owner unblock update[0] = %+v (%T), want story unblock", ownerUnblockUpdates.OtherUpdates[0], ownerUnblockUpdates.OtherUpdates[0]) + } + if peer, ok := ownerUnblocked.PeerID.(*tg.PeerUser); !ok || peer.UserID != viewer.ID { + t.Fatalf("owner unblock peer = %#v, want viewer", ownerUnblocked.PeerID) + } + unblockDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, viewer.ID), [8]byte{3, 2}), &tg.UpdatesGetDifferenceRequest{Pts: blockUpdates.State.Pts}) + if err != nil { + t.Fatalf("get viewer unblock difference: %v", err) + } + unblockUpdates, ok := unblockDiff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("unblock difference = %T, want *tg.UpdatesDifference", unblockDiff) + } + if unblockUpdates.State.Pts != 6 || len(unblockUpdates.OtherUpdates) != 3 { + t.Fatalf("unblock difference pts/updates = %d/%d, want 6/3", unblockUpdates.State.Pts, len(unblockUpdates.OtherUpdates)) + } + for i, update := range unblockUpdates.OtherUpdates { + storyUpdate, ok := update.(*tg.UpdateStory) + if !ok { + t.Fatalf("unblock update %d = %T, want *tg.UpdateStory", i, update) + } + item, ok := storyUpdate.Story.(*tg.StoryItem) + if !ok { + t.Fatalf("unblock story %d = %T, want *tg.StoryItem", i, storyUpdate.Story) + } + if item.ID != i+1 { + t.Fatalf("unblock story id = %d, want %d", item.ID, i+1) + } + if _, ok := item.GetViews(); item.Out || ok { + t.Fatalf("unblock story %d = %+v, want viewer fanout without out/views", i, item) + } + } +} + +func TestContactsSetBlockedReplacesStoryBlocklistFanouts(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contactsStore := memory.NewContactStore() + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + owner, err := users.Create(ctx, domain.User{AccessHash: 41, Phone: "15550004001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + oldViewer, err := users.Create(ctx, domain.User{AccessHash: 42, Phone: "15550004002", FirstName: "Old"}) + if err != nil { + t.Fatalf("create old viewer: %v", err) + } + newViewer, err := users.Create(ctx, domain.User{AccessHash: 43, Phone: "15550004003", FirstName: "New"}) + if err != nil { + t.Fatalf("create new viewer: %v", err) + } + for _, viewer := range []domain.User{oldViewer, newViewer} { + if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: viewer.ID, FirstName: viewer.FirstName}); err != nil { + t.Fatalf("upsert contact %d: %v", viewer.ID, err) + } + } + if _, err := contactsStore.Block(ctx, owner.ID, oldViewer.ID, 1700000000); err != nil { + t.Fatalf("pre-block old viewer: %v", err) + } + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + Out: true, + Views: domain.StoryViews{ViewsCount: 2, HasViewers: true, RecentViewers: []int64{oldViewer.ID, newViewer.ID}}, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contactsStore, users), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + ownerAuth := [8]byte{8, 8, 1} + ownerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuth), 61) + ok, err := r.onContactsSetBlocked(ownerCtx, &tg.ContactsSetBlockedRequest{ + MyStoriesFrom: true, + ID: []tg.InputPeerClass{ + &tg.InputPeerUser{UserID: newViewer.ID, AccessHash: newViewer.AccessHash}, + &tg.InputPeerUser{UserID: newViewer.ID, AccessHash: newViewer.AccessHash}, + }, + Limit: 2, + }) + if err != nil || !ok { + t.Fatalf("contacts.setBlocked = %v, %v", ok, err) + } + + blockedList, err := r.onContactsGetBlocked(WithUserID(ctx, owner.ID), &tg.ContactsGetBlockedRequest{MyStoriesFrom: true, Limit: 10}) + if err != nil { + t.Fatalf("contacts.getBlocked: %v", err) + } + blocked, ok := blockedList.(*tg.ContactsBlocked) + if !ok || len(blocked.Blocked) != 1 { + t.Fatalf("blocked list = %T %+v, want one blocked peer", blockedList, blockedList) + } + if peer, ok := blocked.Blocked[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != newViewer.ID { + t.Fatalf("blocked peer = %#v, want new viewer", blocked.Blocked[0].PeerID) + } + + ownerDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, owner.ID), [8]byte{8, 8, 2}), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("owner difference: %v", err) + } + ownerUpdates, ok := ownerDiff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("owner difference = %T, want *tg.UpdatesDifference", ownerDiff) + } + if ownerUpdates.State.Pts != 4 || len(ownerUpdates.OtherUpdates) != 4 { + t.Fatalf("owner difference pts/updates = %d/%d, want 4/4", ownerUpdates.State.Pts, len(ownerUpdates.OtherUpdates)) + } + blockedUpdate, ok := ownerUpdates.OtherUpdates[0].(*tg.UpdatePeerBlocked) + if !ok || !blockedUpdate.Blocked || !blockedUpdate.BlockedMyStoriesFrom { + t.Fatalf("owner update[0] = %+v (%T), want new viewer story block", ownerUpdates.OtherUpdates[0], ownerUpdates.OtherUpdates[0]) + } + if peer, ok := blockedUpdate.PeerID.(*tg.PeerUser); !ok || peer.UserID != newViewer.ID { + t.Fatalf("owner block peer = %#v, want new viewer", blockedUpdate.PeerID) + } + unblockedUpdate, ok := ownerUpdates.OtherUpdates[2].(*tg.UpdatePeerBlocked) + if !ok || unblockedUpdate.Blocked || !unblockedUpdate.BlockedMyStoriesFrom { + t.Fatalf("owner update[2] = %+v (%T), want old viewer story unblock", ownerUpdates.OtherUpdates[2], ownerUpdates.OtherUpdates[2]) + } + if peer, ok := unblockedUpdate.PeerID.(*tg.PeerUser); !ok || peer.UserID != oldViewer.ID { + t.Fatalf("owner unblock peer = %#v, want old viewer", unblockedUpdate.PeerID) + } + + oldDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, oldViewer.ID), [8]byte{8, 8, 3}), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("old viewer difference: %v", err) + } + oldUpdates, ok := oldDiff.(*tg.UpdatesDifference) + if !ok || oldUpdates.State.Pts != 1 || len(oldUpdates.OtherUpdates) != 1 { + t.Fatalf("old viewer difference = %T %+v, want one restored story", oldDiff, oldDiff) + } + oldStory, ok := oldUpdates.OtherUpdates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("old viewer update = %T, want updateStory", oldUpdates.OtherUpdates[0]) + } + if item, ok := oldStory.Story.(*tg.StoryItem); !ok || item.ID != 1 || item.Out { + t.Fatalf("old viewer story = %+v (%T), want visible non-owner story", oldStory.Story, oldStory.Story) + } + + newDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, newViewer.ID), [8]byte{8, 8, 4}), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("new viewer difference: %v", err) + } + newUpdates, ok := newDiff.(*tg.UpdatesDifference) + if !ok || newUpdates.State.Pts != 1 || len(newUpdates.OtherUpdates) != 1 { + t.Fatalf("new viewer difference = %T %+v, want one deleted story", newDiff, newDiff) + } + newStory, ok := newUpdates.OtherUpdates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("new viewer update = %T, want updateStory", newUpdates.OtherUpdates[0]) + } + if deleted, ok := newStory.Story.(*tg.StoryItemDeleted); !ok || deleted.ID != 1 { + t.Fatalf("new viewer story = %+v (%T), want deleted story 1", newStory.Story, newStory.Story) + } +} + +func TestContactsSearchFindsPublicChannels(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000011", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550000012", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + stranger, err := userStore.Create(ctx, domain.User{AccessHash: 13, Phone: "15550000013", FirstName: "Stranger"}) + if err != nil { + t.Fatalf("create stranger: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "CU Public RPC", + Megagroup: true, + MemberUserIDs: []int64{viewer.ID}, + Date: 1700000012, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + public, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: created.Channel.ID, + Username: "cu_public_rpc", + }) + if err != nil { + t.Fatalf("set channel username: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Contacts: appcontacts.NewService(memory.NewContactStore(), userStore), + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + var in bin.Buffer + if err := (&tg.ContactsSearchRequest{Q: "CU Public", Limit: 20}).Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.ContactsFound) + if !ok { + t.Fatalf("result type = %T, want *tg.ContactsFound", enc) + } + if len(box.MyResults) != 1 || len(box.Chats) != 1 { + t.Fatalf("search result sizes = my %d chats %d, want 1/1", len(box.MyResults), len(box.Chats)) + } + peer, ok := box.MyResults[0].(*tg.PeerChannel) + if !ok || peer.ChannelID != public.ID { + t.Fatalf("peer = %T %+v, want public channel", box.MyResults[0], box.MyResults[0]) + } + chat, ok := box.Chats[0].(*tg.Channel) + if !ok || chat.ID != public.ID || chat.Username != "cu_public_rpc" { + t.Fatalf("chat = %T %+v, want public channel chat", box.Chats[0], box.Chats[0]) + } + if chat.Left { + t.Fatalf("member search chat left = true, want active member channel") + } + + var strangerIn bin.Buffer + if err := (&tg.ContactsSearchRequest{Q: "CU Public", Limit: 20}).Encode(&strangerIn); err != nil { + t.Fatalf("encode stranger request: %v", err) + } + strangerEnc, err := r.Dispatch(WithUserID(ctx, stranger.ID), [8]byte{}, 0, &strangerIn) + if err != nil { + t.Fatalf("dispatch stranger: %v", err) + } + strangerBox, ok := strangerEnc.(*tg.ContactsFound) + if !ok { + t.Fatalf("stranger result type = %T, want *tg.ContactsFound", strangerEnc) + } + if len(strangerBox.Results) != 1 || len(strangerBox.Chats) != 1 { + t.Fatalf("stranger search sizes = results %d chats %d, want 1/1", len(strangerBox.Results), len(strangerBox.Chats)) + } + strangerPeer, ok := strangerBox.Results[0].(*tg.PeerChannel) + if !ok || strangerPeer.ChannelID != public.ID { + t.Fatalf("stranger peer = %T %+v, want public channel", strangerBox.Results[0], strangerBox.Results[0]) + } + strangerChat, ok := strangerBox.Chats[0].(*tg.Channel) + if !ok || !strangerChat.Left || strangerChat.ID != public.ID { + t.Fatalf("stranger chat = %T %+v, want left public channel", strangerBox.Chats[0], strangerBox.Chats[0]) + } + + resolved, err := r.onContactsResolveUsername(WithUserID(ctx, viewer.ID), &tg.ContactsResolveUsernameRequest{Username: "@CU_PUBLIC_RPC"}) + if err != nil { + t.Fatalf("resolve username: %v", err) + } + resolvedPeer, ok := resolved.Peer.(*tg.PeerChannel) + if !ok || resolvedPeer.ChannelID != public.ID || len(resolved.Chats) != 1 { + t.Fatalf("resolved channel = %+v, want peer + chat", resolved) + } + resolvedChat, ok := resolved.Chats[0].(*tg.Channel) + if !ok || resolvedChat.ID != public.ID || resolvedChat.Username != "cu_public_rpc" || resolvedChat.AccessHash != public.AccessHash { + t.Fatalf("resolved chat = %T %+v, want full public channel projection", resolved.Chats[0], resolved.Chats[0]) + } + if resolvedChat.Left { + t.Fatalf("member resolved chat left = true, want active member channel") + } + + strangerResolved, err := r.onContactsResolveUsername(WithUserID(ctx, stranger.ID), &tg.ContactsResolveUsernameRequest{Username: "@CU_PUBLIC_RPC"}) + if err != nil { + t.Fatalf("resolve username stranger: %v", err) + } + strangerResolvedPeer, ok := strangerResolved.Peer.(*tg.PeerChannel) + if !ok || strangerResolvedPeer.ChannelID != public.ID || len(strangerResolved.Chats) != 1 { + t.Fatalf("stranger resolved channel = %+v, want peer + chat", strangerResolved) + } + strangerResolvedChat, ok := strangerResolved.Chats[0].(*tg.Channel) + if !ok || strangerResolvedChat.ID != public.ID || strangerResolvedChat.Username != "cu_public_rpc" || strangerResolvedChat.AccessHash != public.AccessHash { + t.Fatalf("stranger resolved chat = %T %+v, want full public channel projection", strangerResolved.Chats[0], strangerResolved.Chats[0]) + } + if !strangerResolvedChat.Left { + t.Fatalf("stranger resolved chat left = false, want public preview channel") + } +} + +func TestUsernameRPCLifecycle(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Other", Username: "taken_name"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System) + reqCtx := WithUserID(ctx, owner.ID) + + ok, err := r.onAccountCheckUsername(reqCtx, "taken_name") + if err != nil || ok { + t.Fatalf("check occupied = ok %v err %v, want false/nil", ok, err) + } + user, err := r.onAccountUpdateUsername(reqCtx, "owner_name") + if err != nil { + t.Fatalf("update username: %v", err) + } + self, ok := user.(*tg.User) + if !ok || self.Username != "owner_name" || len(self.Usernames) != 1 || !self.Usernames[0].Active { + t.Fatalf("updated user = %T %+v, want self with active username", user, user) + } + + resolved, err := r.onContactsResolveUsername(reqCtx, &tg.ContactsResolveUsernameRequest{Username: "@OWNER_NAME"}) + if err != nil { + t.Fatalf("resolve username: %v", err) + } + peer, ok := resolved.Peer.(*tg.PeerUser) + if !ok || peer.UserID != owner.ID || len(resolved.Users) != 1 { + t.Fatalf("resolved username = %+v, want owner peer", resolved) + } + + resolvedPhone, err := r.onContactsResolvePhone(reqCtx, "+15550000002") + if err != nil { + t.Fatalf("resolve phone: %v", err) + } + phonePeer, ok := resolvedPhone.Peer.(*tg.PeerUser) + if !ok || phonePeer.UserID != other.ID { + t.Fatalf("resolved phone peer = %+v, want other", resolvedPhone.Peer) + } +} + +func TestAccountUpdateProfileRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System) + req := &tg.AccountUpdateProfileRequest{} + req.SetFirstName("Updated") + req.SetLastName("User") + req.SetAbout("profile bio") + + user, err := r.onAccountUpdateProfile(WithUserID(ctx, owner.ID), req) + if err != nil { + t.Fatalf("update profile: %v", err) + } + self, ok := user.(*tg.User) + if !ok || self.FirstName != "Updated" || self.LastName != "User" { + t.Fatalf("updated user = %T %+v, want updated self user", user, user) + } + full, err := r.onUsersGetFullUser(WithUserID(ctx, owner.ID), &tg.InputUserSelf{}) + if err != nil { + t.Fatalf("get full user: %v", err) + } + if full.FullUser.About != "profile bio" { + t.Fatalf("full about = %q, want profile bio", full.FullUser.About) + } +} + +func TestUsersSavedMusicStubsValidateInput(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550000002", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System) + reqCtx := WithUserID(ctx, owner.ID) + + got, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ + ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Offset: 0, + Limit: 20, + }) + if err != nil { + t.Fatalf("users.getSavedMusic: %v", err) + } + music, ok := got.(*tg.UsersSavedMusic) + if !ok || music.Count != 0 || len(music.Documents) != 0 { + t.Fatalf("saved music = %T %+v, want empty users.savedMusic", got, got) + } + if _, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ + ID: &tg.InputUserSelf{}, + Limit: maxSavedMusicLimit + 1, + }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("large saved music limit err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ + ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash + 1}, + Limit: 20, + }); err == nil || !strings.Contains(err.Error(), "USER_ID_INVALID") { + t.Fatalf("bad saved music user err = %v, want USER_ID_INVALID", err) + } +} + +func TestUsersGetRequirementsToContactReturnsEmptyRequirements(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550000002", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System) + reqCtx := WithUserID(ctx, owner.ID) + + got, err := r.onUsersGetRequirementsToContact(reqCtx, []tg.InputUserClass{ + &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + &tg.InputUserSelf{}, + }) + if err != nil { + t.Fatalf("users.getRequirementsToContact: %v", err) + } + if len(got) != 2 { + t.Fatalf("requirements len = %d, want 2", len(got)) + } + for i, req := range got { + if _, ok := req.(*tg.RequirementToContactEmpty); !ok { + t.Fatalf("requirement %d = %T, want requirementToContactEmpty", i, req) + } + } + got, err = r.onUsersGetRequirementsToContact(reqCtx, []tg.InputUserClass{ + &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash + 1}, + }) + if err != nil { + t.Fatalf("bad requirements user should degrade to empty requirement: %v", err) + } + if len(got) != 1 { + t.Fatalf("bad requirements len = %d, want 1", len(got)) + } + if _, ok := got[0].(*tg.RequirementToContactEmpty); !ok { + t.Fatalf("bad requirements result = %T, want requirementToContactEmpty", got[0]) + } +} + +func TestTGUserIncludesRecentlyStatusForTypingEligibility(t *testing.T) { + got := tgUser(domain.User{ID: 1000000002, FirstName: "Bob"}) + if _, ok := got.Status.(*tg.UserStatusRecently); !ok { + t.Fatalf("status = %T, want *tg.UserStatusRecently", got.Status) + } + + online := tgUser(domain.User{ + ID: 1000000002, + FirstName: "Bob", + Status: domain.UserStatus{Kind: domain.UserStatusOnline, Expires: 1700000300}, + }) + if status, ok := online.Status.(*tg.UserStatusOnline); !ok || status.Expires != 1700000300 { + t.Fatalf("online status = %#v, want userStatusOnline expires=1700000300", online.Status) + } + + offline := tgUser(domain.User{ + ID: 1000000002, + FirstName: "Bob", + Status: domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: 1700000000}, + }) + if status, ok := offline.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != 1700000000 { + t.Fatalf("offline status = %#v, want userStatusOffline was_online=1700000000", offline.Status) + } +} + +func TestAccountUpdateStatusPushesPresenceToOnlineContacts(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: bob}}, + }); err != nil { + t.Fatalf("save contacts: %v", err) + } + sessions := &captureSessions{onlineUserIDs: []int64{bob.ID}} + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + callCtx := WithSessionID(WithUserID(ctx, alice.ID), 77) + + ok, err := r.onAccountUpdateStatus(callCtx, false) + if err != nil || !ok { + t.Fatalf("account.updateStatus online = %v, %v", ok, err) + } + gotPushes := sessions.pushedUserIDs() + if !reflect.DeepEqual(gotPushes, []int64{alice.ID, bob.ID}) { + t.Fatalf("pushed users = %+v, want self and online contact", gotPushes) + } + update := pushedUserStatus(t, sessions.snapshot().message) + if update.UserID != alice.ID { + t.Fatalf("status user = %d, want alice", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { + t.Fatalf("status = %#v, want online with future expires", update.Status) + } + + ok, err = r.onAccountUpdateStatus(callCtx, true) + if err != nil || !ok { + t.Fatalf("account.updateStatus offline = %v, %v", ok, err) + } + update = pushedUserStatus(t, sessions.snapshot().message) + if update.UserID != alice.ID { + t.Fatalf("offline status user = %d, want alice", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOffline); !ok || status.WasOnline == 0 { + t.Fatalf("status = %#v, want offline with was_online", update.Status) + } +} + +func TestAccountUpdateStatusPersistsLastSeen(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + const now = 1700000200 + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + + ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, alice.ID), 77), false) + if err != nil || !ok { + t.Fatalf("account.updateStatus online = %v, %v", ok, err) + } + stored, found, err := userStore.ByID(ctx, alice.ID) + if err != nil || !found { + t.Fatalf("load alice found=%v err=%v", found, err) + } + if stored.LastSeenAt != now { + t.Fatalf("last_seen_at after online = %d, want %d", stored.LastSeenAt, now) + } +} + +func TestContactsStatusesUsesPersistedLastSeen(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + const lastSeen = 1700000000 + if err := userStore.UpdateLastSeen(ctx, bob.ID, lastSeen); err != nil { + t.Fatalf("update last seen: %v", err) + } + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: domain.User{ID: bob.ID, FirstName: "Old Bob", Contact: true}}}, + }); err != nil { + t.Fatalf("save contacts: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts, userStore), + Users: appusers.NewService(userStore), + }, zaptest.NewLogger(t), clock.System) + + statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("contacts.getStatuses: %v", err) + } + if len(statuses) != 1 || statuses[0].UserID != bob.ID { + t.Fatalf("statuses = %+v, want bob only", statuses) + } + if status, ok := statuses[0].Status.(*tg.UserStatusOffline); !ok || status.WasOnline != lastSeen { + t.Fatalf("status = %#v, want userStatusOffline was_online=%d", statuses[0].Status, lastSeen) + } +} + +func TestContactsStatusesUsePresenceAndContactsKeepStableProjection(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: bob}}, + }); err != nil { + t.Fatalf("save contacts: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts), + }, zaptest.NewLogger(t), clock.System) + + if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false); err != nil || !ok { + t.Fatalf("bob account.updateStatus = %v, %v", ok, err) + } + statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("contacts.getStatuses: %v", err) + } + if len(statuses) != 1 || statuses[0].UserID != bob.ID { + t.Fatalf("statuses = %+v, want bob only", statuses) + } + if _, ok := statuses[0].Status.(*tg.UserStatusOnline); !ok { + t.Fatalf("contacts.getStatuses status = %T, want online", statuses[0].Status) + } + + contactsRes, err := r.onContactsGetContacts(WithUserID(ctx, alice.ID), 0) + if err != nil { + t.Fatalf("contacts.getContacts: %v", err) + } + full, ok := contactsRes.(*tg.ContactsContacts) + if !ok || len(full.Users) != 1 { + t.Fatalf("contacts result = %T %+v, want one full contacts response", contactsRes, contactsRes) + } + user, ok := full.Users[0].(*tg.User) + if !ok || user.ID != bob.ID { + t.Fatalf("contacts user = %T %+v, want bob", full.Users[0], full.Users[0]) + } + if _, ok := user.Status.(*tg.UserStatusRecently); !ok { + t.Fatalf("contacts user status = %T, want stable non-presence projection", user.Status) + } +} + +func TestContactsAcceptContactReturnsSettingsAndReset(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + contactsStore := memory.NewContactStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice", LastName: "A"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob", LastName: "B"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + contactsSvc := appcontacts.NewService(contactsStore, userStore) + if _, err := contactsSvc.AddContact(ctx, alice.ID, domain.ContactInput{ + ContactUserID: bob.ID, + Phone: bob.Phone, + FirstName: "Bobby", + LastName: "Remark", + }); err != nil { + t.Fatalf("alice add bob: %v", err) + } + updatesSvc := &captureUpdates{state: domain.UpdateState{Pts: 10, Date: 1700000400}} + r := New(Config{}, Deps{ + Contacts: contactsSvc, + Users: appusers.NewService(userStore, appusers.WithContactStore(contactsStore)), + Updates: updatesSvc, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000400, 0)}) + + out, err := r.onContactsAcceptContact(WithUserID(ctx, alice.ID), &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash}) + if err != nil { + t.Fatalf("contacts.acceptContact: %v", err) + } + got, ok := out.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", out) + } + if len(got.Updates) != 2 { + t.Fatalf("updates = %+v, want peer settings + contacts reset", got.Updates) + } + settings, ok := got.Updates[0].(*tg.UpdatePeerSettings) + if !ok { + t.Fatalf("update[0] = %T, want UpdatePeerSettings", got.Updates[0]) + } + if settings.Settings.ShareContact || settings.Settings.AddContact { + t.Fatalf("peer settings = %+v, want share/add false", settings.Settings) + } + if _, ok := got.Updates[1].(*tg.UpdateContactsReset); !ok { + t.Fatalf("update[1] = %T, want UpdateContactsReset", got.Updates[1]) + } + if len(updatesSvc.events) != 4 { + t.Fatalf("recorded events = %+v, want current peer/reset and target peer/reset", updatesSvc.events) + } + if updatesSvc.events[0].UserID != alice.ID || updatesSvc.events[0].Settings.ShareContact { + t.Fatalf("current peer settings event = %+v, want alice share=false", updatesSvc.events[0]) + } + if updatesSvc.events[2].UserID != bob.ID || updatesSvc.events[2].Settings.ShareContact { + t.Fatalf("target peer settings event = %+v, want bob share=false", updatesSvc.events[2]) + } + reverse, found, err := contactsStore.Get(ctx, bob.ID, alice.ID) + if err != nil || !found { + t.Fatalf("bob contact alice found=%v err=%v", found, err) + } + if reverse.Phone != alice.Phone || !reverse.Mutual { + t.Fatalf("bob contact alice = %+v, want shared phone and mutual", reverse) + } +} + +func TestContactsStatusesUsesOnlineSessionFallback(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: bob}}, + }); err != nil { + t.Fatalf("save contacts: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts), + Sessions: &captureSessions{onlineUserIDs: []int64{bob.ID}}, + }, zaptest.NewLogger(t), clock.System) + + statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("contacts.getStatuses: %v", err) + } + if len(statuses) != 1 || statuses[0].UserID != bob.ID { + t.Fatalf("statuses = %+v, want bob only", statuses) + } + if status, ok := statuses[0].Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { + t.Fatalf("fallback status = %#v, want online from active session", statuses[0].Status) + } +} + +func TestContactsStatusesHonorsExplicitOffline(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: bob}}, + }); err != nil { + t.Fatalf("save contacts: %v", err) + } + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts), + Sessions: &captureSessions{onlineUserIDs: []int64{bob.ID}}, + }, zaptest.NewLogger(t), clock.System) + if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), true); err != nil || !ok { + t.Fatalf("bob account.updateStatus offline = %v, %v", ok, err) + } + + statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("contacts.getStatuses: %v", err) + } + if len(statuses) != 1 || statuses[0].UserID != bob.ID { + t.Fatalf("statuses = %+v, want bob only", statuses) + } + if status, ok := statuses[0].Status.(*tg.UserStatusOffline); !ok || status.WasOnline == 0 { + t.Fatalf("status = %#v, want explicit offline", statuses[0].Status) + } +} + +func TestSessionOfflinePersistsLastSeenAndPushes(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + contacts := memory.NewContactStore() + if err := contacts.SaveList(ctx, bob.ID, domain.ContactList{Contacts: []domain.Contact{{User: alice}}}); err != nil { + t.Fatalf("save bob contacts: %v", err) + } + const now = 1700000300 + sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}} + r := New(Config{}, Deps{ + Contacts: appcontacts.NewService(contacts, userStore), + Users: appusers.NewService(userStore), + Sessions: sessions, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + + // offline 广播经去抖宽限后异步执行;缩短宽限并轮询等待。 + grace := offlineAnnounceGrace + offlineAnnounceGrace = 10 * time.Millisecond + defer func() { offlineAnnounceGrace = grace }() + + r.SessionOffline([8]byte{1, 2, 3}, 22, bob.ID, true) + + deadline := time.Now().Add(5 * time.Second) + for len(sessions.pushedUserIDs()) < 2 { + if time.Now().After(deadline) { + t.Fatalf("offline announce never fired; pushed users = %+v", sessions.pushedUserIDs()) + } + time.Sleep(5 * time.Millisecond) + } + + stored, found, err := userStore.ByID(ctx, bob.ID) + if err != nil || !found { + t.Fatalf("load bob found=%v err=%v", found, err) + } + if stored.LastSeenAt != now { + t.Fatalf("last_seen_at = %d, want %d", stored.LastSeenAt, now) + } + if got := sessions.pushedUserIDs(); !reflect.DeepEqual(got, []int64{bob.ID, alice.ID}) { + t.Fatalf("pushed users = %+v, want bob self and online contact alice", got) + } + update := pushedUserStatus(t, sessions.snapshot().message) + if update.UserID != bob.ID { + t.Fatalf("status user = %d, want bob", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != now { + t.Fatalf("status = %#v, want offline was_online=%d", update.Status, now) + } +} + +// TestSessionOfflineSkipsAnnounceWhenUserReconnects 验证去抖宽限内用户重新上线时 +// 跳过 offline 广播——移动端重连/换 session 是「先断旧再建新」,没有去抖会对 +// 全部好友广播一轮 offline→online 抖动。 +func TestSessionOfflineSkipsAnnounceWhenUserReconnects(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + const now = 1700000300 + // bob 在 IsUserOnline 名单里:模拟宽限期内已重新上线。 + sessions := &captureSessions{onlineUserIDs: []int64{bob.ID}} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Sessions: sessions, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + + grace := offlineAnnounceGrace + offlineAnnounceGrace = 10 * time.Millisecond + defer func() { offlineAnnounceGrace = grace }() + + r.SessionOffline([8]byte{1, 2, 3}, 22, bob.ID, true) + time.Sleep(20 * offlineAnnounceGrace) + + if got := sessions.pushedUserIDs(); len(got) != 0 { + t.Fatalf("pushed users = %+v, want none (user reconnected within grace)", got) + } + stored, found, err := userStore.ByID(ctx, bob.ID) + if err != nil || !found { + t.Fatalf("load bob found=%v err=%v", found, err) + } + if stored.LastSeenAt != 0 { + t.Fatalf("last_seen_at = %d, want untouched 0", stored.LastSeenAt) + } +} + +func TestAccountUpdateStatusKeepsUserOnlineUntilAllSessionsOffline(t *testing.T) { + const userID = int64(1000000001) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + session1 := WithSessionID(WithUserID(context.Background(), userID), 1) + session2 := WithSessionID(WithUserID(context.Background(), userID), 2) + + if ok, err := r.onAccountUpdateStatus(session1, false); err != nil || !ok { + t.Fatalf("session1 online = %v, %v", ok, err) + } + if ok, err := r.onAccountUpdateStatus(session2, false); err != nil || !ok { + t.Fatalf("session2 online = %v, %v", ok, err) + } + if ok, err := r.onAccountUpdateStatus(session1, true); err != nil || !ok { + t.Fatalf("session1 offline = %v, %v", ok, err) + } + if status := r.userPresenceStatus(userID); status.Kind != domain.UserStatusOnline { + t.Fatalf("aggregate after one offline = %+v, want online", status) + } + if ok, err := r.onAccountUpdateStatus(session2, true); err != nil || !ok { + t.Fatalf("session2 offline = %v, %v", ok, err) + } + if status := r.userPresenceStatus(userID); status.Kind != domain.UserStatusOffline || status.WasOnline == 0 { + t.Fatalf("aggregate after all offline = %+v, want offline", status) + } +} + +func TestContactsBlockGetBlockedAndUnblockRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009001", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550009002", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Contacts: appcontacts.NewService(memory.NewContactStore(), userStore), + }, zaptest.NewLogger(t), clock.System) + + ok, err := r.onContactsBlock(WithUserID(ctx, bob.ID), &tg.ContactsBlockRequest{ + ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, + }) + if err != nil || !ok { + t.Fatalf("contacts.block = %v, %v", ok, err) + } + blocked, err := r.onContactsGetBlocked(WithUserID(ctx, bob.ID), &tg.ContactsGetBlockedRequest{Limit: 10}) + if err != nil { + t.Fatalf("contacts.getBlocked: %v", err) + } + full, ok := blocked.(*tg.ContactsBlocked) + if !ok || len(full.Blocked) != 1 || len(full.Users) != 1 { + t.Fatalf("blocked = %T %+v, want one blocked user", blocked, blocked) + } + if peer, ok := full.Blocked[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != alice.ID { + t.Fatalf("blocked peer = %#v, want alice", full.Blocked[0].PeerID) + } + if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID { + t.Fatalf("blocked user = %#v, want alice", full.Users[0]) + } + + ok, err = r.onContactsUnblock(WithUserID(ctx, bob.ID), &tg.ContactsUnblockRequest{ + ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, + }) + if err != nil || !ok { + t.Fatalf("contacts.unblock = %v, %v", ok, err) + } + blocked, err = r.onContactsGetBlocked(WithUserID(ctx, bob.ID), &tg.ContactsGetBlockedRequest{Limit: 10}) + if err != nil { + t.Fatalf("contacts.getBlocked after unblock: %v", err) + } + if full, ok := blocked.(*tg.ContactsBlocked); !ok || len(full.Blocked) != 0 { + t.Fatalf("blocked after unblock = %T %+v, want empty contacts.blocked", blocked, blocked) + } +} diff --git a/internal/rpc/context.go b/internal/rpc/context.go index e140eb33..3f244682 100644 --- a/internal/rpc/context.go +++ b/internal/rpc/context.go @@ -1,6 +1,10 @@ package rpc -import "context" +import ( + "context" + "regexp" + "strings" +) type ctxKey int @@ -11,6 +15,19 @@ const ( authKeyIDKey sessionIDKey userIDKey + invokeWithoutUpdatesKey +) + +const currentClientLayer = 227 + +var androidSDKVersionRE = regexp.MustCompile(`\bsdk\s+\d+\b`) + +type ClientType string + +const ( + ClientTypeUnknown ClientType = "unknown" + ClientTypeTDesktop ClientType = "tdesktop" + ClientTypeAndroid ClientType = "android" ) // ClientInfo 是 initConnection 携带的客户端信息。 @@ -22,6 +39,7 @@ type ClientInfo struct { SystemLangCode string LangPack string LangCode string + Type ClientType } // WithLayer 在 ctx 注入客户端 layer(来自 invokeWithLayer)。 @@ -39,6 +57,7 @@ func LayerFrom(ctx context.Context) int { // WithClientInfo 在 ctx 注入客户端信息(来自 initConnection)。 func WithClientInfo(ctx context.Context, info ClientInfo) context.Context { + info = normalizeClientInfo(info) return context.WithValue(ctx, clientInfoKey, info) } @@ -48,6 +67,54 @@ func ClientInfoFrom(ctx context.Context) (ClientInfo, bool) { return v, ok } +func ClientTypeFrom(ctx context.Context) ClientType { + if info, ok := ClientInfoFrom(ctx); ok { + return info.ClientType() + } + return ClientTypeUnknown +} + +func normalizeClientInfo(info ClientInfo) ClientInfo { + if !knownClientType(info.Type) { + info.Type = detectClientType(info) + } + return info +} + +func (info ClientInfo) ClientType() ClientType { + if knownClientType(info.Type) { + return info.Type + } + return detectClientType(info) +} + +func knownClientType(t ClientType) bool { + switch t { + case ClientTypeTDesktop, ClientTypeAndroid: + return true + default: + return false + } +} + +func detectClientType(info ClientInfo) ClientType { + if strings.EqualFold(info.LangPack, string(ClientTypeAndroid)) { + return ClientTypeAndroid + } + if strings.EqualFold(info.LangPack, string(ClientTypeTDesktop)) { + return ClientTypeTDesktop + } + client := strings.ToLower(info.DeviceModel + " " + info.SystemVersion + " " + info.AppVersion) + switch { + case strings.Contains(client, "android"), androidSDKVersionRE.MatchString(client): + return ClientTypeAndroid + case strings.Contains(client, "tdesktop"), strings.Contains(client, "desktop"): + return ClientTypeTDesktop + default: + return ClientTypeUnknown + } +} + // WithRawAuthKeyID 在 ctx 注入连接实际使用的 auth_key_id。 func WithRawAuthKeyID(ctx context.Context, id [8]byte) context.Context { return context.WithValue(ctx, rawAuthKeyIDKey, id) @@ -94,3 +161,14 @@ func UserIDFrom(ctx context.Context) (int64, bool) { } return v, true } + +// withInvokeWithoutUpdates 标记当前请求被 invokeWithoutUpdates 包装: +// 客户端声明该 session 不接收主动 updates(media/temp 连接的请求一律带此包装)。 +func withInvokeWithoutUpdates(ctx context.Context) context.Context { + return context.WithValue(ctx, invokeWithoutUpdatesKey, true) +} + +func invokeWithoutUpdatesFrom(ctx context.Context) bool { + v, _ := ctx.Value(invokeWithoutUpdatesKey).(bool) + return v +} diff --git a/internal/rpc/convert.go b/internal/rpc/convert.go deleted file mode 100644 index 79268dfe..00000000 --- a/internal/rpc/convert.go +++ /dev/null @@ -1,2038 +0,0 @@ -package rpc - -import ( - "context" - "encoding/json" - "sort" - - "github.com/gotd/td/tg" - - "telesrv/internal/compat/tdesktop" - "telesrv/internal/domain" -) - -// authzFromCtx 从连接上下文组装一条待绑定的设备授权(UserID 由业务层填充)。 -func (r *Router) authzFromCtx(ctx context.Context) domain.Authorization { - id, _ := AuthKeyIDFrom(ctx) - a := domain.Authorization{AuthKeyID: id, Layer: LayerFrom(ctx)} - if ci, ok := ClientInfoFrom(ctx); ok { - a.DeviceModel = ci.DeviceModel - a.SystemVersion = ci.SystemVersion - a.AppVersion = ci.AppVersion - a.APIID = ci.APIID - } - return a -} - -// currentUserID 返回当前连接已登录的 user_id。 -// -// 优先使用 active session 缓存;若新连接尚未绑定但 auth_key 已授权,则只在这里 -// 查询一次授权表并回填 session,避免各业务 service 每个 RPC 重复 authKey→userID。 -func (r *Router) currentUserID(ctx context.Context) (int64, bool, error) { - if userID, ok := UserIDFrom(ctx); ok { - return userID, true, nil - } - if r.deps.Sessions != nil { - if sessionID, ok := SessionIDFrom(ctx); ok { - if scoped, ok := r.scopedSessions(); ok { - if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { - if userID, resolved := scoped.UserIDResolvedForAuthKey(rawAuthKeyID, sessionID); resolved { - return userID, userID != 0, nil - } - } - } else if userID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved { - return userID, userID != 0, nil - } - } - } - if r.deps.Auth == nil { - return 0, false, nil - } - authKeyID, ok := AuthKeyIDFrom(ctx) - if !ok { - return 0, false, nil - } - userID, found, err := r.deps.Auth.UserID(ctx, authKeyID) - if err != nil || !found { - if err == nil && !found { - r.bindSessionUser(ctx, 0) - } - return 0, found, err - } - r.bindSessionUser(ctx, userID) - return userID, true, nil -} - -// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 Encode 自动 SetFlags)。 -func tgSelfUser(u domain.User) *tg.User { - out := &tg.User{ - ID: u.ID, - AccessHash: u.AccessHash, - FirstName: u.FirstName, - LastName: u.LastName, - Username: u.Username, - Phone: u.Phone, - Self: true, - Verified: u.Verified, - Support: u.Support, - Contact: u.Contact, - MutualContact: u.Mutual, - Usernames: tgUsernames(u.Username), - Status: tgUserStatus(u.Status), - } - if photo := tgUserProfilePhoto(u); photo != nil { - out.Photo = photo - } - return out -} - -func tgUser(u domain.User) *tg.User { - out := &tg.User{ - ID: u.ID, - AccessHash: u.AccessHash, - FirstName: u.FirstName, - LastName: u.LastName, - Username: u.Username, - Phone: u.Phone, - Verified: u.Verified, - Support: u.Support, - Contact: u.Contact, - MutualContact: u.Mutual, - Usernames: tgUsernames(u.Username), - Status: tgUserStatus(u.Status), - } - if photo := tgUserProfilePhoto(u); photo != nil { - out.Photo = photo - } - return out -} - -// tgUserProfilePhoto 由 domain.User 反范式头像字段构造 UserProfilePhoto;无头像返回 nil(Encode 时为 empty)。 -func tgUserProfilePhoto(u domain.User) tg.UserProfilePhotoClass { - if u.PhotoID == 0 { - return nil - } - photo := &tg.UserProfilePhoto{PhotoID: u.PhotoID, DCID: u.PhotoDCID, Personal: u.PhotoPersonal} - if len(u.PhotoStripped) > 0 { - photo.SetStrippedThumb(u.PhotoStripped) - } - return photo -} - -func tgUserStatus(status domain.UserStatus) tg.UserStatusClass { - switch status.Kind { - case domain.UserStatusOnline: - if status.Expires > 0 { - return &tg.UserStatusOnline{Expires: status.Expires} - } - case domain.UserStatusOffline: - if status.WasOnline > 0 { - return &tg.UserStatusOffline{WasOnline: status.WasOnline} - } - case domain.UserStatusLastWeek: - return &tg.UserStatusLastWeek{} - case domain.UserStatusLastMonth: - return &tg.UserStatusLastMonth{} - case domain.UserStatusEmpty: - return &tg.UserStatusEmpty{} - case domain.UserStatusRecently, domain.UserStatusUnknown: - return &tg.UserStatusRecently{} - } - return &tg.UserStatusRecently{} -} - -func tgUsernames(username string) []tg.Username { - if username == "" { - return nil - } - return []tg.Username{{Editable: true, Active: true, Username: username}} -} - -func tgUpdateState(st domain.UpdateState) tg.UpdatesState { - return tg.UpdatesState{Pts: st.Pts, Qts: st.Qts, Date: st.Date, Seq: st.Seq} -} - -func tgContacts(list domain.ContactList) tg.ContactsContactsClass { - out := &tg.ContactsContacts{ - Contacts: make([]tg.Contact, 0, len(list.Contacts)), - Users: make([]tg.UserClass, 0, len(list.Contacts)), - SavedCount: len(list.Contacts), - } - for _, c := range list.Contacts { - out.Contacts = append(out.Contacts, tg.Contact{UserID: c.User.ID, Mutual: c.Mutual}) - out.Users = append(out.Users, tgUser(c.User)) - } - return out -} - -func tgContactsFound(viewerUserID int64, res domain.UserSearchResult) *tg.ContactsFound { - out := &tg.ContactsFound{ - MyResults: make([]tg.PeerClass, 0, len(res.MyResults)+len(res.MyChannelResults)), - Results: make([]tg.PeerClass, 0, len(res.Results)+len(res.ChannelResults)), - Chats: make([]tg.ChatClass, 0, len(res.MyChannelResults)+len(res.ChannelResults)), - Users: make([]tg.UserClass, 0, len(res.MyResults)+len(res.Results)), - } - seen := make(map[int64]struct{}, len(res.MyResults)+len(res.Results)) - appendUser := func(u domain.User) { - if u.ID == 0 { - return - } - if _, ok := seen[u.ID]; ok { - return - } - seen[u.ID] = struct{}{} - out.Users = append(out.Users, tgUser(u)) - } - seenChannels := make(map[int64]struct{}, len(res.MyChannelResults)+len(res.ChannelResults)) - appendChannel := func(ch domain.Channel, self *domain.ChannelMember) { - if ch.ID == 0 { - return - } - if _, ok := seenChannels[ch.ID]; ok { - return - } - seenChannels[ch.ID] = struct{}{} - out.Chats = append(out.Chats, tgChannelChat(viewerUserID, ch, self)) - } - for _, u := range res.MyResults { - out.MyResults = append(out.MyResults, &tg.PeerUser{UserID: u.ID}) - appendUser(u) - } - for _, ch := range res.MyChannelResults { - out.MyResults = append(out.MyResults, &tg.PeerChannel{ChannelID: ch.ID}) - appendChannel(ch, nil) - } - for _, u := range res.Results { - out.Results = append(out.Results, &tg.PeerUser{UserID: u.ID}) - appendUser(u) - } - for _, ch := range res.ChannelResults { - out.Results = append(out.Results, &tg.PeerChannel{ChannelID: ch.ID}) - appendChannel(ch, &domain.ChannelMember{ChannelID: ch.ID, UserID: viewerUserID, Status: domain.ChannelMemberLeft}) - } - return out -} - -func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDialogsClass { - dialogs := make([]tg.DialogClass, 0, len(list.Dialogs)) - for _, d := range list.Dialogs { - if dialog := tgDialog(d); dialog != nil { - dialogs = append(dialogs, dialog) - } - } - messages := make([]tg.MessageClass, 0, len(list.Messages)) - for _, msg := range list.Messages { - if item := tgMessage(msg); item != nil { - messages = append(messages, item) - } - } - for _, msg := range list.ChannelMessages { - if item := tgChannelMessage(viewerUserID, msg); item != nil { - messages = append(messages, item) - } - } - users := tgUsersForViewer(viewerUserID, list.Users) - chats := tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs) - if list.Count > len(dialogs) { - return &tg.MessagesDialogsSlice{ - Count: list.Count, - Dialogs: dialogs, - Messages: messages, - Chats: chats, - Users: users, - } - } - return &tg.MessagesDialogs{ - Dialogs: dialogs, - Messages: messages, - Chats: chats, - Users: users, - } -} - -func tgMessagesMessages(viewerUserID int64, list domain.MessageList) tg.MessagesMessagesClass { - messages := make([]tg.MessageClass, 0, len(list.Messages)) - for _, msg := range list.Messages { - if item := tgMessage(msg); item != nil { - messages = append(messages, item) - } - } - users := tgUsersForViewer(viewerUserID, list.Users) - if list.Count > len(messages) { - return &tg.MessagesMessagesSlice{ - Count: list.Count, - Messages: messages, - Users: users, - } - } - return &tg.MessagesMessages{ - Messages: messages, - Users: users, - } -} - -func tgUsers(users []domain.User) []tg.UserClass { - out := make([]tg.UserClass, 0, len(users)) - for _, u := range users { - out = append(out, tgUser(u)) - } - return out -} - -func tgUsersForViewer(viewerUserID int64, users []domain.User) []tg.UserClass { - out := make([]tg.UserClass, 0, len(users)) - for _, u := range users { - if viewerUserID != 0 && u.ID == viewerUserID { - out = append(out, tgSelfUser(u)) - continue - } - out = append(out, tgUser(u)) - } - return out -} - -func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateState) *tg.MessagesPeerDialogs { - out := &tg.MessagesPeerDialogs{ - Dialogs: make([]tg.DialogClass, 0, len(list.Dialogs)), - Messages: make([]tg.MessageClass, 0, len(list.Messages)+len(list.ChannelMessages)), - Users: make([]tg.UserClass, 0, len(list.Users)), - Chats: make([]tg.ChatClass, 0, len(list.Channels)), - State: tgUpdateState(st), - } - for _, d := range list.Dialogs { - if dialog := tgDialog(d); dialog != nil { - out.Dialogs = append(out.Dialogs, dialog) - } - } - for _, msg := range list.Messages { - if item := tgMessage(msg); item != nil { - out.Messages = append(out.Messages, item) - } - } - for _, msg := range list.ChannelMessages { - if item := tgChannelMessage(viewerUserID, msg); item != nil { - out.Messages = append(out.Messages, item) - } - } - for _, u := range list.Users { - if viewerUserID != 0 && u.ID == viewerUserID { - out.Users = append(out.Users, tgSelfUser(u)) - } else { - out.Users = append(out.Users, tgUser(u)) - } - } - out.Chats = append(out.Chats, tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)...) - return out -} - -func tgUpdatesDifference(diff domain.UpdateDifference) tg.UpdatesDifferenceClass { - out := &tg.UpdatesDifference{ - NewMessages: make([]tg.MessageClass, 0, len(diff.Events)), - OtherUpdates: make([]tg.UpdateClass, 0, len(diff.Events)), - Users: make([]tg.UserClass, 0, 1), - Chats: make([]tg.ChatClass, 0, 1), - } - seenUsers := make(map[int64]struct{}) - seenChats := make(map[int64]struct{}) - for _, event := range diff.Events { - addUsers(out, seenUsers, event.Users) - addChannels(out, seenChats, event.UserID, event.Channels) - switch event.Type { - case domain.UpdateEventNewMessage: - if msg := tgMessage(event.Message); msg != nil { - out.NewMessages = append(out.NewMessages, msg) - addMessageUsers(out, seenUsers, event.Message) - } - case domain.UpdateEventReadHistoryInbox: - if update := tgReadHistoryInboxUpdate(event); update != nil { - out.OtherUpdates = append(out.OtherUpdates, update) - } - case domain.UpdateEventReadHistoryOutbox: - if update := tgReadHistoryOutboxUpdate(event); update != nil { - out.OtherUpdates = append(out.OtherUpdates, update) - } - case domain.UpdateEventMessageReactions: - if msg := tgMessage(event.Message); msg != nil { - out.NewMessages = append(out.NewMessages, msg) - addMessageUsers(out, seenUsers, event.Message) - } - if update := tgOtherUpdateFromEvent(event); update != nil { - out.OtherUpdates = append(out.OtherUpdates, update) - } - default: - if update := tgOtherUpdateFromEvent(event); update != nil { - out.OtherUpdates = append(out.OtherUpdates, update) - } - } - } - for _, nudge := range diff.ChannelNudges { - if nudge.ChannelID == 0 { - continue - } - update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID} - if nudge.Pts > 0 { - update.SetPts(nudge.Pts) - } - out.OtherUpdates = append(out.OtherUpdates, update) - } - // Partial:连续事件被 limit 截断、后面还有 → updates.differenceSlice,客户端据 IntermediateState 续拉。 - if diff.Partial { - return &tg.UpdatesDifferenceSlice{ - NewMessages: out.NewMessages, - OtherUpdates: out.OtherUpdates, - Chats: out.Chats, - Users: out.Users, - IntermediateState: tgUpdateState(diff.State), - } - } - out.State = tgUpdateState(diff.State) - return out -} - -func tgChannelDifference(viewerUserID int64, diff domain.ChannelDifference) tg.UpdatesChannelDifferenceClass { - if diff.TooLong { - messages := make([]tg.MessageClass, 0, len(diff.NewMessages)) - for _, msg := range diff.NewMessages { - if item := tgChannelMessage(viewerUserID, msg); item != nil { - messages = append(messages, item) - } - } - dialog := tgDialog(domain.Dialog{ - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: diff.Channel.ID}, - TopMessage: diff.Dialog.TopMessageID, - TopMessageDate: diff.Dialog.TopMessageDate, - ReadInboxMaxID: diff.Dialog.ReadInboxMaxID, - ReadOutboxMaxID: diff.Dialog.ReadOutboxMaxID, - UnreadCount: diff.Dialog.UnreadCount, - UnreadMark: diff.Dialog.UnreadMark, - FolderID: diff.Dialog.FolderID, - }) - if dialog != nil { - dialog.SetPts(diff.Pts) - } - return &tg.UpdatesChannelDifferenceTooLong{ - Final: diff.Final, - Timeout: diff.Timeout, - Dialog: dialog, - Messages: messages, - Chats: tgChannelDifferenceChats(viewerUserID, diff), - Users: tgUsers(diff.Users), - } - } - if len(diff.Events) == 0 && len(diff.NewMessages) == 0 && len(diff.OtherUpdates) == 0 { - return &tg.UpdatesChannelDifferenceEmpty{ - Final: diff.Final, - Pts: diff.Pts, - Timeout: diff.Timeout, - } - } - messages := make([]tg.MessageClass, 0, len(diff.NewMessages)) - for _, msg := range diff.NewMessages { - if item := tgChannelMessage(viewerUserID, msg); item != nil { - messages = append(messages, item) - } - } - updates := make([]tg.UpdateClass, 0, len(diff.OtherUpdates)) - for _, event := range diff.OtherUpdates { - if update := tgChannelUpdate(viewerUserID, event); update != nil { - updates = append(updates, update) - } - } - chats := tgChannelDifferenceChats(viewerUserID, diff) - users := tgUsers(diff.Users) - return &tg.UpdatesChannelDifference{ - Final: diff.Final, - Pts: diff.Pts, - Timeout: diff.Timeout, - NewMessages: messages, - OtherUpdates: updates, - Chats: chats, - Users: users, - } -} - -func tgChannelDifferenceChats(viewerUserID int64, diff domain.ChannelDifference) []tg.ChatClass { - return tgChannelChatsWithPrimarySelf(viewerUserID, diff.Channel, diff.Channels, diff.Self) -} - -func tgChannelChatsWithPrimarySelf(viewerUserID int64, primary domain.Channel, extras []domain.Channel, primarySelf domain.ChannelMember) []tg.ChatClass { - channels := make([]domain.Channel, 0, len(extras)+1) - channels = append(channels, primary) - channels = append(channels, extras...) - out := make([]tg.ChatClass, 0, len(channels)) - seen := make(map[int64]struct{}, len(channels)) - for _, ch := range channels { - if ch.ID == 0 { - continue - } - if _, ok := seen[ch.ID]; ok { - continue - } - seen[ch.ID] = struct{}{} - var self *domain.ChannelMember - if ch.ID == primary.ID && primarySelf.ChannelID == ch.ID && primarySelf.UserID == viewerUserID { - self = &primarySelf - } - out = append(out, tgChannelChat(viewerUserID, ch, self)) - } - return out -} - -func tgChannelUpdate(viewerUserID int64, event domain.ChannelUpdateEvent) tg.UpdateClass { - switch event.Type { - case domain.ChannelUpdateNewMessage: - msg := tgChannelMessage(viewerUserID, event.Message) - if msg == nil { - return nil - } - return &tg.UpdateNewChannelMessage{Message: msg, Pts: event.Pts, PtsCount: event.PtsCount} - case domain.ChannelUpdateEditMessage: - msg := tgChannelMessage(viewerUserID, event.Message) - if msg == nil { - return nil - } - return &tg.UpdateEditChannelMessage{Message: msg, Pts: event.Pts, PtsCount: event.PtsCount} - case domain.ChannelUpdateDeleteMessages: - if len(event.MessageIDs) == 0 { - return nil - } - return &tg.UpdateDeleteChannelMessages{ - ChannelID: event.ChannelID, - Messages: append([]int(nil), event.MessageIDs...), - Pts: event.Pts, - PtsCount: event.PtsCount, - } - case domain.ChannelUpdatePinnedMessages: - if len(event.MessageIDs) == 0 { - return nil - } - return &tg.UpdatePinnedChannelMessages{ - Pinned: event.Pinned, - ChannelID: event.ChannelID, - Messages: append([]int(nil), event.MessageIDs...), - Pts: event.Pts, - PtsCount: event.PtsCount, - } - case domain.ChannelUpdateParticipant: - userID := event.Participant.UserID - if userID == 0 { - userID = event.Previous.UserID - } - if userID == 0 { - return nil - } - actorID := event.SenderUserID - if actorID == 0 { - actorID = userID - } - update := &tg.UpdateChannelParticipant{ - ChannelID: event.ChannelID, - Date: event.Date, - ActorID: actorID, - UserID: userID, - } - if event.Previous.UserID != 0 { - update.SetPrevParticipant(tgChannelParticipantForUpdate(viewerUserID, event.Previous)) - } - if event.Participant.UserID != 0 { - update.SetNewParticipant(tgChannelParticipantForUpdate(viewerUserID, event.Participant)) - } - return update - default: - return nil - } -} - -func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass { - switch event.Type { - case domain.UpdateEventContactsReset: - return &tg.UpdateContactsReset{} - case domain.UpdateEventDialogPinned: - peer := tgDialogPeer(event.Peer) - if peer == nil { - return nil - } - return &tg.UpdateDialogPinned{Pinned: event.Bool, Peer: peer} - case domain.UpdateEventPinnedDialogs: - update := &tg.UpdatePinnedDialogs{} - if len(event.Peers) > 0 { - update.Order = tgDialogPeers(event.Peers) - } - return update - case domain.UpdateEventDialogUnreadMark: - peer := tgDialogPeer(event.Peer) - if peer == nil { - return nil - } - return &tg.UpdateDialogUnreadMark{Unread: event.Bool, Peer: peer} - case domain.UpdateEventChannelViewForum: - if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { - return nil - } - return &tg.UpdateChannelViewForumAsMessages{ChannelID: event.Peer.ID, Enabled: event.Bool} - case domain.UpdateEventPeerSettings: - peer := tgPeer(event.Peer) - if peer == nil { - return nil - } - return &tg.UpdatePeerSettings{Peer: peer, Settings: tgPeerSettings(event.Settings)} - case domain.UpdateEventDeleteMessages: - if len(event.MessageIDs) == 0 { - return nil - } - return &tg.UpdateDeleteMessages{ - Messages: append([]int(nil), event.MessageIDs...), - Pts: event.Pts, - PtsCount: event.PtsCount, - } - case domain.UpdateEventReadHistoryOutbox: - return tgReadHistoryOutbox(event) - case domain.UpdateEventReadMessageContents: - if len(event.MessageIDs) == 0 { - return nil - } - return &tg.UpdateReadMessagesContents{ - Messages: append([]int(nil), event.MessageIDs...), - Pts: event.Pts, - PtsCount: event.PtsCount, - } - case domain.UpdateEventEditMessage: - msg := tgMessage(event.Message) - if msg == nil { - return nil - } - return &tg.UpdateEditMessage{ - Message: msg, - Pts: event.Pts, - PtsCount: event.PtsCount, - } - case domain.UpdateEventMessageReactions: - if event.Message.ID <= 0 || event.Message.ID > domain.MaxMessageBoxID { - return nil - } - peer := event.Message.Peer - if peer.Type == "" || peer.ID == 0 { - peer = event.Peer - } - outPeer := tgPeer(peer) - if outPeer == nil { - return nil - } - reactions := event.Message.Reactions - if reactions == nil { - empty := domain.ChannelMessageReactions{CanSeeList: true, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}} - reactions = &empty - } - converted := tgMessageReactions(event.UserID, reactions) - if converted == nil { - converted = &tg.MessageReactions{Results: []tg.ReactionCount{}} - } - return &tg.UpdateMessageReactions{ - Peer: outPeer, - MsgID: event.Message.ID, - Reactions: *converted, - } - case domain.UpdateEventDialogFilter: - update := &tg.UpdateDialogFilter{ID: event.FilterID} - if event.DialogFilter != nil { - update.SetFilter(tgDialogFilter(*event.DialogFilter)) - } - return update - case domain.UpdateEventDialogFilterOrder: - return &tg.UpdateDialogFilterOrder{Order: append([]int(nil), event.FilterOrder...)} - case domain.UpdateEventDialogFilters: - return &tg.UpdateDialogFilters{} - case domain.UpdateEventFolderPeers: - if len(event.FolderPeers) == 0 { - return nil - } - return &tg.UpdateFolderPeers{ - FolderPeers: tgFolderPeers(event.FolderPeers), - Pts: event.Pts, - PtsCount: event.PtsCount, - } - case domain.UpdateEventChannelAvailable: - if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 || event.MaxID <= 0 { - return nil - } - return &tg.UpdateChannelAvailableMessages{ - ChannelID: event.Peer.ID, - AvailableMinID: event.MaxID, - } - default: - return nil - } -} - -func addUsers(out *tg.UpdatesDifference, seen map[int64]struct{}, users []domain.User) { - for _, u := range users { - if u.ID == 0 { - continue - } - if _, ok := seen[u.ID]; ok { - continue - } - seen[u.ID] = struct{}{} - out.Users = append(out.Users, tgUser(u)) - } -} - -func addChannels(out *tg.UpdatesDifference, seen map[int64]struct{}, viewerUserID int64, channels []domain.Channel) { - for _, ch := range channels { - if ch.ID == 0 { - continue - } - if _, ok := seen[ch.ID]; ok { - continue - } - seen[ch.ID] = struct{}{} - out.Chats = append(out.Chats, tgChannelChat(viewerUserID, ch, nil)) - } -} - -func addMessageUsers(out *tg.UpdatesDifference, seen map[int64]struct{}, msg domain.Message) { - for _, peer := range []domain.Peer{msg.From, msg.Peer} { - if peer.Type != domain.PeerTypeUser { - continue - } - if _, ok := seen[peer.ID]; ok { - continue - } - seen[peer.ID] = struct{}{} - switch peer.ID { - case domain.OfficialSystemUserID: - out.Users = append(out.Users, tgUser(domain.OfficialSystemUser())) - } - } -} - -func tgDialog(d domain.Dialog) *tg.Dialog { - peer := tgPeer(d.Peer) - if peer == nil { - return nil - } - out := &tg.Dialog{ - Pinned: d.Pinned, - UnreadMark: d.UnreadMark, - ViewForumAsMessages: d.ViewForumAsMessages, - Peer: peer, - TopMessage: d.TopMessage, - ReadInboxMaxID: d.ReadInboxMaxID, - ReadOutboxMaxID: d.ReadOutboxMaxID, - UnreadCount: d.UnreadCount, - UnreadMentionsCount: d.UnreadMentions, - UnreadReactionsCount: d.UnreadReactions, - NotifySettings: *tdesktop.NotifySettings(), - } - if d.FolderID != domain.DialogMainFolderID { - out.SetFolderID(d.FolderID) - } - if d.Draft != nil { - out.SetDraft(tgDialogDraft(*d.Draft)) - } - return out -} - -func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass { - if d.Empty() { - out := &tg.DraftMessageEmpty{} - out.SetDate(d.Date) - return out - } - out := &tg.DraftMessage{ - NoWebpage: d.NoWebpage, - InvertMedia: d.InvertMedia, - ReplyTo: tgDraftReplyTo(d), - Message: d.Message, - Entities: tgMessageEntities(d.Entities), - Media: tgDraftWebPage(d.WebPage), - Date: d.Date, - Effect: d.Effect, - } - return out -} - -func tgDraftReplyTo(d domain.DialogDraft) tg.InputReplyToClass { - if d.ReplyTo == nil { - return nil - } - reply := &tg.InputReplyToMessage{ReplyToMsgID: d.ReplyTo.MessageID} - if d.ReplyTo.TopMessageID > 0 { - reply.SetTopMsgID(d.ReplyTo.TopMessageID) - } - if d.ReplyTo.QuoteText != "" { - reply.SetQuoteText(d.ReplyTo.QuoteText) - } - if len(d.ReplyTo.QuoteEntities) > 0 { - reply.SetQuoteEntities(tgMessageEntities(d.ReplyTo.QuoteEntities)) - } - if d.ReplyTo.QuoteOffset > 0 { - reply.SetQuoteOffset(d.ReplyTo.QuoteOffset) - } - return reply -} - -func tgDraftWebPage(webpage *domain.DialogDraftWebPage) tg.InputMediaClass { - if webpage == nil || webpage.URL == "" { - return nil - } - return &tg.InputMediaWebPage{ - ForceLargeMedia: webpage.ForceLargeMedia, - ForceSmallMedia: webpage.ForceSmallMedia, - Optional: webpage.Optional, - URL: webpage.URL, - } -} - -func tgDialogPeer(p domain.Peer) tg.DialogPeerClass { - peer := tgPeer(p) - if peer == nil { - return nil - } - return &tg.DialogPeer{Peer: peer} -} - -func tgDialogPeers(peers []domain.Peer) []tg.DialogPeerClass { - out := make([]tg.DialogPeerClass, 0, len(peers)) - for _, peer := range peers { - item := tgDialogPeer(peer) - if item != nil { - out = append(out, item) - } - } - return out -} - -func tgDialogFilters(list domain.DialogFolderList) *tg.MessagesDialogFilters { - filters := make([]tg.DialogFilterClass, 0, len(list.Folders)+1) - filters = append(filters, &tg.DialogFilterDefault{}) - for _, folder := range list.Folders { - if item := tgDialogFilter(folder); item != nil { - filters = append(filters, item) - } - } - return &tg.MessagesDialogFilters{TagsEnabled: list.TagsEnabled, Filters: filters} -} - -func tgDialogFilter(folder domain.DialogFolder) tg.DialogFilterClass { - title := tg.TextWithEntities{Text: folder.Title, Entities: tgMessageEntities(folder.TitleEntities)} - if folder.IsChatlist { - out := &tg.DialogFilterChatlist{ - TitleNoanimate: folder.TitleNoanimate, - ID: folder.ID, - Title: title, - PinnedPeers: tgDialogFolderInputPeers(folder.PinnedPeers), - IncludePeers: tgDialogFolderInputPeers(folder.IncludePeers), - } - if folder.HasEmoticon { - out.SetEmoticon(folder.Emoticon) - } - if folder.HasColor { - out.SetColor(folder.Color) - } - return out - } - out := &tg.DialogFilter{ - Contacts: folder.Contacts, - NonContacts: folder.NonContacts, - Groups: folder.Groups, - Broadcasts: folder.Broadcasts, - Bots: folder.Bots, - ExcludeMuted: folder.ExcludeMuted, - ExcludeRead: folder.ExcludeRead, - ExcludeArchived: folder.ExcludeArchived, - TitleNoanimate: folder.TitleNoanimate, - ID: folder.ID, - Title: title, - PinnedPeers: tgDialogFolderInputPeers(folder.PinnedPeers), - IncludePeers: tgDialogFolderInputPeers(folder.IncludePeers), - ExcludePeers: tgDialogFolderInputPeers(folder.ExcludePeers), - } - if folder.HasEmoticon { - out.SetEmoticon(folder.Emoticon) - } - if folder.HasColor { - out.SetColor(folder.Color) - } - return out -} - -func tgDialogFolderInputPeers(peers []domain.DialogFolderPeer) []tg.InputPeerClass { - if len(peers) == 0 { - return nil - } - out := make([]tg.InputPeerClass, 0, len(peers)) - for _, item := range peers { - switch item.Peer.Type { - case domain.PeerTypeUser: - out = append(out, &tg.InputPeerUser{UserID: item.Peer.ID, AccessHash: item.AccessHash}) - case domain.PeerTypeChannel: - out = append(out, &tg.InputPeerChannel{ChannelID: item.Peer.ID, AccessHash: item.AccessHash}) - } - } - return out -} - -func tgFolderPeers(peers []domain.FolderPeerUpdate) []tg.FolderPeer { - out := make([]tg.FolderPeer, 0, len(peers)) - for _, item := range peers { - peer := tgPeer(item.Peer) - if peer == nil { - continue - } - out = append(out, tg.FolderPeer{Peer: peer, FolderID: item.FolderID}) - } - return out -} - -func tgMessage(m domain.Message) tg.MessageClass { - peer := tgPeer(m.Peer) - if peer == nil || m.ID == 0 { - return nil - } - msg := &tg.Message{ - Out: m.Out, - MediaUnread: m.MediaUnread, - ID: m.ID, - PeerID: peer, - Date: m.Date, - Message: m.Body, - Entities: tgMessageEntities(m.Entities), - } - if m.EditDate != 0 { - msg.SetEditDate(m.EditDate) - } - if m.Silent { - msg.SetSilent(true) - } - if m.NoForwards { - msg.SetNoforwards(true) - } - if reply := tgMessageReplyHeader(m); reply != nil { - msg.SetReplyTo(reply) - } - if fwd := tgMessageFwdHeader(m.Forward); fwd != nil { - msg.SetFwdFrom(*fwd) - } - if from := tgPeer(m.From); from != nil { - msg.FromID = from - } - if !m.Media.IsZero() { - msg.SetMedia(tgMessageMedia(m.Media)) - } - if reactions := tgMessageReactions(m.OwnerUserID, m.Reactions); reactions != nil { - msg.SetReactions(*reactions) - } - return msg -} - -func tgChannelHistoryMessages(viewerUserID int64, list domain.ChannelHistory) tg.MessagesMessagesClass { - messages := make([]tg.MessageClass, 0, len(list.Messages)) - for _, msg := range list.Messages { - if item := tgChannelMessage(viewerUserID, msg); item != nil { - messages = append(messages, item) - } - } - topics := make([]tg.ForumTopicClass, 0, len(list.Topics)) - for _, topic := range list.Topics { - item := tgForumTopicFromDomain(viewerUserID, topic) - item.SetShort(true) - topics = append(topics, item) - } - users := tgUsers(list.Users) - chats := tgChannelChatsWithPrimarySelf(viewerUserID, list.Channel, list.Channels, list.Self) - if list.Channel.ID != 0 { - return &tg.MessagesChannelMessages{ - Pts: list.Channel.Pts, - Count: list.Count, - Messages: messages, - Topics: topics, - Chats: chats, - Users: users, - } - } - if list.Count > len(messages) { - return &tg.MessagesMessagesSlice{ - Count: list.Count, - Messages: messages, - Topics: topics, - Chats: chats, - Users: users, - } - } - return &tg.MessagesMessages{Messages: messages, Topics: topics, Chats: chats, Users: users} -} - -func tgMessagesDiscussionMessage(viewerUserID int64, in domain.ChannelDiscussionMessage) *tg.MessagesDiscussionMessage { - messages := make([]tg.MessageClass, 0, len(in.Messages)) - for _, msg := range in.Messages { - if item := tgChannelMessage(viewerUserID, msg); item != nil { - messages = append(messages, item) - } - } - channels := make([]domain.Channel, 0, 2+len(in.Channels)) - if in.PostChannel.ID != 0 { - channels = append(channels, in.PostChannel) - } - if in.DiscussionChannel.ID != 0 && in.DiscussionChannel.ID != in.PostChannel.ID { - channels = append(channels, in.DiscussionChannel) - } - channels = append(channels, in.Channels...) - out := &tg.MessagesDiscussionMessage{ - Messages: messages, - Chats: tgChannels(viewerUserID, channels), - Users: tgUsers(in.Users), - UnreadCount: in.UnreadCount, - } - if in.MaxID > 0 { - out.SetMaxID(in.MaxID) - } - if in.ReadInboxMaxID > 0 { - out.SetReadInboxMaxID(in.ReadInboxMaxID) - } - if in.ReadOutboxMaxID > 0 { - out.SetReadOutboxMaxID(in.ReadOutboxMaxID) - } - return out -} - -func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageClass { - if m.ID == 0 || m.ChannelID == 0 { - return nil - } - peer := &tg.PeerChannel{ChannelID: m.ChannelID} - outgoing := m.SenderUserID == viewerUserID && viewerUserID != 0 && m.From.Type != domain.PeerTypeChannel - from := tg.PeerClass(nil) - if !m.Post && m.SendAs != nil && m.SendAs.ID != 0 { - from = tgPeer(*m.SendAs) - } - if !m.Post && from == nil && m.From.ID != 0 { - from = tgPeer(m.From) - } - if from == nil && m.SenderUserID != 0 && !m.Post { - from = &tg.PeerUser{UserID: m.SenderUserID} - } - if m.Action != nil { - msg := &tg.MessageService{ - Out: outgoing, - Silent: m.Silent, - Post: m.Post, - ID: m.ID, - FromID: from, - PeerID: peer, - Date: m.Date, - Action: tgChannelMessageAction(*m.Action), - } - if msg.Action == nil { - msg.Action = &tg.MessageActionEmpty{} - } - return msg - } - msg := &tg.Message{ - Out: outgoing, - Silent: m.Silent, - Post: m.Post, - Noforwards: m.NoForwards, - Mentioned: m.Mentioned, - MediaUnread: m.MediaUnread, - ID: m.ID, - FromID: from, - PeerID: peer, - Date: m.Date, - Message: m.Body, - Entities: tgMessageEntities(m.Entities), - } - if m.Pinned { - msg.SetPinned(true) - } - if m.EditDate != 0 { - msg.SetEditDate(m.EditDate) - } - if reply := tgMessageReplyHeader(domain.Message{ - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: m.ChannelID}, - ReplyTo: m.ReplyTo, - }); reply != nil { - msg.SetReplyTo(reply) - } - if fwd := tgMessageFwdHeader(m.Forward); fwd != nil { - msg.SetFwdFrom(*fwd) - } - if replies := tgChannelMessageReplies(m.Replies); replies != nil { - msg.SetReplies(*replies) - } - if reactions := tgMessageReactions(viewerUserID, m.Reactions); reactions != nil { - msg.SetReactions(*reactions) - } - if !m.Media.IsZero() { - msg.SetMedia(tgMessageMedia(m.Media)) - } - return msg -} - -func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageActionClass { - switch action.Type { - case domain.ChannelActionCreate: - return &tg.MessageActionChannelCreate{Title: action.Title} - case domain.ChannelActionChatAddUser, domain.ChannelActionChatJoined: - return &tg.MessageActionChatAddUser{Users: append([]int64(nil), action.UserIDs...)} - case domain.ChannelActionChatDelete: - var userID int64 - if len(action.UserIDs) > 0 { - userID = action.UserIDs[0] - } - return &tg.MessageActionChatDeleteUser{UserID: userID} - case domain.ChannelActionEditTitle: - return &tg.MessageActionChatEditTitle{Title: action.Title} - case domain.ChannelActionTopicCreate: - return &tg.MessageActionTopicCreate{ - TitleMissing: action.TitleMissing, - Title: action.Title, - IconColor: action.IconColor, - IconEmojiID: action.IconEmojiID, - } - case domain.ChannelActionTopicEdit: - out := &tg.MessageActionTopicEdit{} - if action.Title != "" { - out.SetTitle(action.Title) - } - if action.IconEmojiIDSet { - out.SetIconEmojiID(action.IconEmojiID) - } - if action.Closed != nil { - out.SetClosed(*action.Closed) - } - if action.Hidden != nil { - out.SetHidden(*action.Hidden) - } - return out - default: - return nil - } -} - -func tgMessageReplyHeader(m domain.Message) tg.MessageReplyHeaderClass { - if m.ReplyTo == nil || (m.ReplyTo.MessageID <= 0 && m.ReplyTo.TopMessageID <= 0) { - return nil - } - header := &tg.MessageReplyHeader{} - if m.ReplyTo.ForumTopic { - header.SetForumTopic(true) - } - if m.ReplyTo.MessageID > 0 { - header.SetReplyToMsgID(m.ReplyTo.MessageID) - } - if m.ReplyTo.TopMessageID > 0 { - header.SetReplyToTopID(m.ReplyTo.TopMessageID) - } - if m.ReplyTo.Peer.ID != 0 && m.ReplyTo.Peer != m.Peer { - if peer := tgPeer(m.ReplyTo.Peer); peer != nil { - header.SetReplyToPeerID(peer) - } - } - if m.ReplyTo.QuoteText != "" { - header.SetQuote(true) - header.SetQuoteText(m.ReplyTo.QuoteText) - header.SetQuoteEntities(tgMessageEntities(m.ReplyTo.QuoteEntities)) - header.SetQuoteOffset(m.ReplyTo.QuoteOffset) - } - return header -} - -func tgMessageFwdHeader(fwd *domain.MessageForward) *tg.MessageFwdHeader { - if fwd == nil || (fwd.Date == 0 && fwd.From.ID == 0 && fwd.FromName == "" && fwd.ChannelPost == 0 && fwd.SavedFrom.ID == 0 && fwd.SavedFromMsgID == 0) { - return nil - } - header := &tg.MessageFwdHeader{Date: fwd.Date} - if peer := tgPeer(fwd.From); peer != nil { - header.SetFromID(peer) - } - if fwd.FromName != "" { - header.SetFromName(fwd.FromName) - } - if fwd.ChannelPost > 0 { - header.SetChannelPost(fwd.ChannelPost) - } - if peer := tgPeer(fwd.SavedFrom); peer != nil { - header.SetSavedFromPeer(peer) - } - if fwd.SavedFromMsgID > 0 { - header.SetSavedFromMsgID(fwd.SavedFromMsgID) - } - return header -} - -func tgChannelMessageReplies(in *domain.ChannelMessageReplies) *tg.MessageReplies { - if in == nil { - return nil - } - out := &tg.MessageReplies{ - Comments: in.Comments, - Replies: in.Replies, - RepliesPts: in.RepliesPts, - } - if len(in.RecentRepliers) > 0 { - repliers := make([]tg.PeerClass, 0, len(in.RecentRepliers)) - for _, peer := range in.RecentRepliers { - if converted := tgPeer(peer); converted != nil { - repliers = append(repliers, converted) - } - } - if len(repliers) > 0 { - out.SetRecentRepliers(repliers) - } - } - if in.ChannelID != 0 { - out.SetChannelID(in.ChannelID) - } - if in.MaxID > 0 { - out.SetMaxID(in.MaxID) - } - if in.ReadMaxID > 0 { - out.SetReadMaxID(in.ReadMaxID) - } - return out -} - -func tgMessageReactions(viewerUserID int64, in *domain.ChannelMessageReactions) *tg.MessageReactions { - if in == nil { - return nil - } - out := &tg.MessageReactions{ - Results: make([]tg.ReactionCount, 0, len(in.Results)), - } - if in.CanSeeList { - out.SetCanSeeList(true) - } - for _, item := range in.Results { - reaction := tgMessageReaction(item.Reaction) - if reaction == nil || item.Count <= 0 { - continue - } - count := tg.ReactionCount{ - Reaction: reaction, - Count: item.Count, - } - if item.ChosenOrder > 0 { - count.SetChosenOrder(item.ChosenOrder) - } - out.Results = append(out.Results, count) - } - if len(in.Recent) > 0 { - recent := make([]tg.MessagePeerReaction, 0, len(in.Recent)) - for _, item := range in.Recent { - if converted := tgMessagePeerReaction(viewerUserID, item); converted != nil { - recent = append(recent, *converted) - } - } - if len(recent) > 0 { - out.SetRecentReactions(recent) - } - } - if out.Results == nil { - out.Results = []tg.ReactionCount{} - } - return out -} - -func tgMessagePeerReaction(viewerUserID int64, in domain.ChannelMessagePeerReaction) *tg.MessagePeerReaction { - reaction := tgMessageReaction(in.Reaction) - if reaction == nil || in.UserID == 0 { - return nil - } - out := &tg.MessagePeerReaction{ - PeerID: &tg.PeerUser{UserID: in.UserID}, - Date: in.Date, - Reaction: reaction, - } - if in.Big { - out.SetBig(true) - } - if in.Unread && in.SenderUserID == viewerUserID { - out.SetUnread(true) - } - if in.My || in.UserID == viewerUserID { - out.SetMy(true) - } - return out -} - -func tgMessageReaction(in domain.MessageReaction) tg.ReactionClass { - switch in.Type { - case domain.MessageReactionEmoji: - if in.Emoticon == "" { - return nil - } - return &tg.ReactionEmoji{Emoticon: in.Emoticon} - default: - return nil - } -} - -func tgMessageEntities(entities []domain.MessageEntity) []tg.MessageEntityClass { - if len(entities) == 0 { - return nil - } - out := make([]tg.MessageEntityClass, 0, len(entities)) - for _, entity := range entities { - switch entity.Type { - case domain.MessageEntityBold: - out = append(out, &tg.MessageEntityBold{Offset: entity.Offset, Length: entity.Length}) - } - } - return out -} - -func domainMessageEntities(entities []tg.MessageEntityClass) []domain.MessageEntity { - if len(entities) == 0 { - return nil - } - out := make([]domain.MessageEntity, 0, len(entities)) - for _, entity := range entities { - switch e := entity.(type) { - case *tg.MessageEntityBold: - out = append(out, domain.MessageEntity{ - Type: domain.MessageEntityBold, - Offset: e.Offset, - Length: e.Length, - }) - } - } - return out -} - -func tgPeer(p domain.Peer) tg.PeerClass { - switch p.Type { - case domain.PeerTypeUser: - return &tg.PeerUser{UserID: p.ID} - case domain.PeerTypeChannel: - return &tg.PeerChannel{ChannelID: p.ID} - default: - return nil - } -} - -func tgChannels(viewerUserID int64, channels []domain.Channel) []tg.ChatClass { - out := make([]tg.ChatClass, 0, len(channels)) - seen := make(map[int64]struct{}, len(channels)) - for _, ch := range channels { - if ch.ID == 0 { - continue - } - if _, ok := seen[ch.ID]; ok { - continue - } - seen[ch.ID] = struct{}{} - out = append(out, tgChannelChat(viewerUserID, ch, nil)) - } - return out -} - -func tgChannelsForDialogs(viewerUserID int64, channels []domain.Channel, dialogs []domain.Dialog) []tg.ChatClass { - left := make(map[int64]struct{}) - for _, dialog := range dialogs { - if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 && dialog.ChannelLeft { - left[dialog.Peer.ID] = struct{}{} - } - } - out := make([]tg.ChatClass, 0, len(channels)) - seen := make(map[int64]struct{}, len(channels)) - for _, ch := range channels { - if ch.ID == 0 { - continue - } - if _, ok := seen[ch.ID]; ok { - continue - } - seen[ch.ID] = struct{}{} - var self *domain.ChannelMember - if _, ok := left[ch.ID]; ok { - self = &domain.ChannelMember{ - ChannelID: ch.ID, - UserID: viewerUserID, - Status: domain.ChannelMemberLeft, - } - } - out = append(out, tgChannelChat(viewerUserID, ch, self)) - } - return out -} - -func tgChannelChat(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) tg.ChatClass { - if ch.Deleted { - return tgChannelForbidden(ch) - } - return tgChannel(viewerUserID, ch, self) -} - -func tgMigratedLegacyChat(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) *tg.Chat { - if ch.ID == 0 || ch.Deleted { - return nil - } - out := &tg.Chat{ - Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0, - Deactivated: true, - Noforwards: ch.NoForwards, - ID: ch.ID, - Title: ch.Title, - Photo: tgChatPhoto(ch), - ParticipantsCount: ch.ParticipantsCount, - Date: ch.Date, - Version: ch.Pts, - } - out.SetMigratedTo(&tg.InputChannel{ChannelID: ch.ID, AccessHash: ch.AccessHash}) - if self != nil { - if self.Status == domain.ChannelMemberLeft { - out.Left = true - } - switch self.Role { - case domain.ChannelRoleCreator: - out.Creator = true - out.SetAdminRights(tgChatAdminRights(self.AdminRights)) - case domain.ChannelRoleAdmin: - out.SetAdminRights(tgChatAdminRights(self.AdminRights)) - } - } - if !zeroChannelBannedRights(ch.DefaultBannedRights) { - out.SetDefaultBannedRights(tgChatBannedRights(ch.DefaultBannedRights)) - } - return out -} - -func tgChannelForbidden(ch domain.Channel) *tg.ChannelForbidden { - return &tg.ChannelForbidden{ - Broadcast: ch.Broadcast, - Megagroup: ch.Megagroup, - ID: ch.ID, - AccessHash: ch.AccessHash, - Title: ch.Title, - } -} - -func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) *tg.Channel { - out := &tg.Channel{ - Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0, - Broadcast: ch.Broadcast, - Megagroup: ch.Megagroup, - Forum: ch.Forum, - ForumTabs: ch.ForumTabs, - Noforwards: ch.NoForwards, - Signatures: ch.Signatures, - ID: ch.ID, - Title: ch.Title, - Photo: tgChatPhoto(ch), - Date: ch.Date, - } - out.SetAccessHash(ch.AccessHash) - out.SetJoinToSend(ch.JoinToSend) - out.SetJoinRequest(ch.JoinRequest) - out.SetAutotranslation(ch.Autotranslation) - out.SetBroadcastMessagesAllowed(ch.BroadcastMessagesAllowed) - if ch.SendPaidMessagesStars > 0 || ch.BroadcastMessagesAllowed { - out.SetSendPaidMessagesStars(ch.SendPaidMessagesStars) - } - if ch.LinkedChatID != 0 { - out.SetHasLink(true) - } - if ch.Username != "" { - out.SetUsername(ch.Username) - out.SetUsernames(tgUsernames(ch.Username)) - } - if color := tgPeerColor(ch.Color); color != nil { - out.SetColor(color) - } - if profileColor := tgPeerColor(ch.ProfileColor); profileColor != nil { - out.SetProfileColor(profileColor) - } - if status := tgChannelEmojiStatus(ch.EmojiStatus); status != nil { - out.SetEmojiStatus(status) - } - if ch.SlowmodeSeconds > 0 { - out.SetSlowmodeEnabled(true) - } - if ch.ParticipantsCount > 0 { - out.SetParticipantsCount(ch.ParticipantsCount) - } - if !zeroChannelBannedRights(ch.DefaultBannedRights) { - out.SetDefaultBannedRights(tgChatBannedRights(ch.DefaultBannedRights)) - } - if self != nil { - if self.Status == domain.ChannelMemberLeft { - out.Left = true - } - switch self.Role { - case domain.ChannelRoleCreator: - out.Creator = true - out.SetAdminRights(tgChatAdminRights(self.AdminRights)) - case domain.ChannelRoleAdmin: - out.SetAdminRights(tgChatAdminRights(self.AdminRights)) - } - if !zeroChannelBannedRights(self.BannedRights) { - out.SetBannedRights(tgChatBannedRights(self.BannedRights)) - } - } - return out -} - -func tgChannelFull(view domain.ChannelView) *tg.ChannelFull { - ch := view.Channel - full := &tg.ChannelFull{ - CanViewParticipants: !ch.ParticipantsHidden || channelMemberIsAdmin(view.Self), - CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator, - ID: ch.ID, - About: ch.About, - ReadInboxMaxID: view.Dialog.ReadInboxMaxID, - ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID, - UnreadCount: view.Dialog.UnreadCount, - ChatPhoto: tgChannelChatPhotoFull(ch), - NotifySettings: *tdesktop.NotifySettings(), - Pts: ch.Pts, - } - if ch.ParticipantsCount > 0 { - full.SetParticipantsCount(ch.ParticipantsCount) - } - if ch.AdminsCount > 0 { - full.SetAdminsCount(ch.AdminsCount) - } - if ch.KickedCount > 0 { - full.SetKickedCount(ch.KickedCount) - } - if ch.BannedCount > 0 { - full.SetBannedCount(ch.BannedCount) - } - if view.Dialog.FolderID != domain.DialogMainFolderID { - full.SetFolderID(view.Dialog.FolderID) - } - if ch.TTLPeriod > 0 { - full.SetTTLPeriod(ch.TTLPeriod) - } - if ch.PreHistoryHidden { - full.SetHiddenPrehistory(true) - } - if ch.ParticipantsHidden { - full.SetParticipantsHidden(true) - } - if ch.AntiSpam { - full.SetAntispam(true) - } - if ch.RestrictedSponsored { - full.SetRestrictedSponsored(true) - } - if ch.SendPaidMessagesStars > 0 || ch.BroadcastMessagesAllowed { - full.SetPaidMessagesAvailable(true) - full.SetSendPaidMessagesStars(ch.SendPaidMessagesStars) - } - if view.Dialog.ViewForumAsMessages { - full.SetViewForumAsMessages(true) - } - if ch.LinkedChatID != 0 { - full.SetLinkedChatID(ch.LinkedChatID) - } - if defaultSendAs := validDefaultSendAsPeer(view); defaultSendAs != nil { - full.SetDefaultSendAs(tgPeer(*defaultSendAs)) - } - if ch.SlowmodeSeconds > 0 { - full.SetSlowmodeSeconds(ch.SlowmodeSeconds) - if view.Self.SlowmodeLastSendDate > 0 { - full.SetSlowmodeNextSendDate(view.Self.SlowmodeLastSendDate + ch.SlowmodeSeconds) - } - } - if ch.PinnedMessageID > 0 { - full.SetPinnedMsgID(ch.PinnedMessageID) - } - if reactions := tgChannelReactionPolicy(ch.ReactionPolicy); reactions != nil { - full.SetAvailableReactions(reactions) - } - if ch.ReactionPolicy.Limit > 0 { - full.SetReactionsLimit(ch.ReactionPolicy.Limit) - } - if ch.ReactionPolicy.PaidEnabled { - full.SetPaidReactionsAvailable(true) - } - return full -} - -func channelMemberIsAdmin(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin -} - -func tgChannelReactionPolicy(policy domain.ChannelReactionPolicy) tg.ChatReactionsClass { - switch policy.Type { - case domain.ChannelReactionPolicyNone: - return &tg.ChatReactionsNone{} - case domain.ChannelReactionPolicyAll: - out := &tg.ChatReactionsAll{} - if policy.AllowCustom { - out.SetAllowCustom(true) - } - return out - case domain.ChannelReactionPolicySome: - reactions := make([]tg.ReactionClass, 0, len(policy.Emoticons)+len(policy.CustomEmojiIDs)) - for _, emoticon := range policy.Emoticons { - if emoticon == "" { - continue - } - reactions = append(reactions, &tg.ReactionEmoji{Emoticon: emoticon}) - } - for _, id := range policy.CustomEmojiIDs { - if id <= 0 { - continue - } - reactions = append(reactions, &tg.ReactionCustomEmoji{DocumentID: id}) - } - return &tg.ChatReactionsSome{Reactions: reactions} - default: - return nil - } -} - -func tgPeerColor(color domain.ChannelPeerColor) tg.PeerColorClass { - if color.Empty() { - return nil - } - out := &tg.PeerColor{} - if color.HasColor { - out.SetColor(color.Color) - } - if color.BackgroundEmojiID != 0 { - out.SetBackgroundEmojiID(color.BackgroundEmojiID) - } - return out -} - -func tgChannelEmojiStatus(status domain.ChannelEmojiStatus) tg.EmojiStatusClass { - if status.Empty() { - return nil - } - out := &tg.EmojiStatus{DocumentID: status.DocumentID} - if status.Until > 0 { - out.SetUntil(status.Until) - } - return out -} - -func tgChannelParticipant(selfUserID int64, member domain.ChannelMember) tg.ChannelParticipantClass { - switch member.Role { - case domain.ChannelRoleCreator: - out := &tg.ChannelParticipantCreator{ - UserID: member.UserID, - AdminRights: tgChatAdminRights(member.AdminRights), - } - if member.Rank != "" { - out.SetRank(member.Rank) - } - return out - case domain.ChannelRoleAdmin: - out := &tg.ChannelParticipantAdmin{ - Self: member.UserID == selfUserID, - UserID: member.UserID, - PromotedBy: member.InviterUserID, - Date: member.JoinedAt, - AdminRights: tgChatAdminRights(member.AdminRights), - } - if member.InviterUserID != 0 { - out.SetInviterID(member.InviterUserID) - } - if member.Rank != "" { - out.SetRank(member.Rank) - } - return out - default: - if member.Status != domain.ChannelMemberActive { - return &tg.ChannelParticipantLeft{Peer: &tg.PeerUser{UserID: member.UserID}} - } - if member.UserID == selfUserID { - out := &tg.ChannelParticipantSelf{ - UserID: member.UserID, - InviterID: member.InviterUserID, - Date: member.JoinedAt, - } - if member.Rank != "" { - out.SetRank(member.Rank) - } - return out - } - out := &tg.ChannelParticipant{UserID: member.UserID, Date: member.JoinedAt} - if member.Rank != "" { - out.SetRank(member.Rank) - } - return out - } -} - -func tgChannelParticipantForUpdate(selfUserID int64, member domain.ChannelMember) tg.ChannelParticipantClass { - if member.UserID == 0 { - return nil - } - if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages { - return &tg.ChannelParticipantBanned{ - Left: member.Status != domain.ChannelMemberActive, - Peer: &tg.PeerUser{UserID: member.UserID}, - KickedBy: member.InviterUserID, - Date: member.LeftAt, - BannedRights: tgChatBannedRights(member.BannedRights), - } - } - if member.Status == domain.ChannelMemberLeft { - return &tg.ChannelParticipantLeft{Peer: &tg.PeerUser{UserID: member.UserID}} - } - return tgChannelParticipant(selfUserID, member) -} - -func tgChannelAdminLogEvents(viewerUserID int64, events []domain.ChannelAdminLogEvent) []tg.ChannelAdminLogEvent { - out := make([]tg.ChannelAdminLogEvent, 0, len(events)) - for _, event := range events { - if item, ok := tgChannelAdminLogEvent(viewerUserID, event); ok { - out = append(out, item) - } - } - return out -} - -func tgChannelAdminLogEvent(viewerUserID int64, event domain.ChannelAdminLogEvent) (tg.ChannelAdminLogEvent, bool) { - action := tg.ChannelAdminLogEventActionClass(nil) - switch event.Type { - case domain.ChannelAdminLogChangeTitle: - action = &tg.ChannelAdminLogEventActionChangeTitle{PrevValue: event.PrevString, NewValue: event.NewString} - case domain.ChannelAdminLogChangeUsername: - action = &tg.ChannelAdminLogEventActionChangeUsername{PrevValue: event.PrevString, NewValue: event.NewString} - case domain.ChannelAdminLogChangeLinkedChat: - action = &tg.ChannelAdminLogEventActionChangeLinkedChat{PrevValue: int64(event.PrevInt), NewValue: int64(event.NewInt)} - case domain.ChannelAdminLogToggleSignatures: - action = &tg.ChannelAdminLogEventActionToggleSignatures{NewValue: event.NewBool} - case domain.ChannelAdminLogTogglePreHistoryHidden: - action = &tg.ChannelAdminLogEventActionTogglePreHistoryHidden{NewValue: event.NewBool} - case domain.ChannelAdminLogToggleForum: - action = &tg.ChannelAdminLogEventActionToggleForum{NewValue: event.NewBool} - case domain.ChannelAdminLogToggleAutotranslation: - action = &tg.ChannelAdminLogEventActionToggleAutotranslation{NewValue: event.NewBool} - case domain.ChannelAdminLogToggleAntiSpam: - action = &tg.ChannelAdminLogEventActionToggleAntiSpam{NewValue: event.NewBool} - case domain.ChannelAdminLogToggleSlowMode: - action = &tg.ChannelAdminLogEventActionToggleSlowMode{PrevValue: event.PrevInt, NewValue: event.NewInt} - case domain.ChannelAdminLogParticipantJoin: - action = &tg.ChannelAdminLogEventActionParticipantJoin{} - case domain.ChannelAdminLogParticipantLeave: - action = &tg.ChannelAdminLogEventActionParticipantLeave{} - case domain.ChannelAdminLogParticipantInvite: - if event.Participant == nil { - return tg.ChannelAdminLogEvent{}, false - } - action = &tg.ChannelAdminLogEventActionParticipantInvite{ - Participant: tgChannelParticipantForUpdate(viewerUserID, *event.Participant), - } - case domain.ChannelAdminLogParticipantPromote, domain.ChannelAdminLogParticipantDemote: - if event.PrevParticipant == nil || event.NewParticipant == nil { - return tg.ChannelAdminLogEvent{}, false - } - action = &tg.ChannelAdminLogEventActionParticipantToggleAdmin{ - PrevParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.PrevParticipant), - NewParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.NewParticipant), - } - case domain.ChannelAdminLogParticipantBan, domain.ChannelAdminLogParticipantUnban, domain.ChannelAdminLogParticipantKick, domain.ChannelAdminLogParticipantUnkick: - if event.PrevParticipant == nil || event.NewParticipant == nil { - return tg.ChannelAdminLogEvent{}, false - } - action = &tg.ChannelAdminLogEventActionParticipantToggleBan{ - PrevParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.PrevParticipant), - NewParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.NewParticipant), - } - case domain.ChannelAdminLogUpdatePinned: - action = &tg.ChannelAdminLogEventActionUpdatePinned{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)} - case domain.ChannelAdminLogSendMessage: - action = &tg.ChannelAdminLogEventActionSendMessage{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)} - case domain.ChannelAdminLogEditMessage: - action = &tg.ChannelAdminLogEventActionEditMessage{ - PrevMessage: tgAdminLogMessage(viewerUserID, event.ChannelID, event.PrevMessage), - NewMessage: tgAdminLogMessage(viewerUserID, event.ChannelID, event.NewMessage), - } - case domain.ChannelAdminLogDeleteMessage: - action = &tg.ChannelAdminLogEventActionDeleteMessage{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)} - default: - return tg.ChannelAdminLogEvent{}, false - } - if action == nil { - return tg.ChannelAdminLogEvent{}, false - } - return tg.ChannelAdminLogEvent{ - ID: event.ID, - Date: event.Date, - UserID: event.UserID, - Action: action, - }, true -} - -func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage) tg.MessageClass { - if msg == nil || msg.ID == 0 { - out := &tg.MessageEmpty{ID: 0} - out.SetPeerID(&tg.PeerChannel{ChannelID: channelID}) - return out - } - return tgChannelMessage(viewerUserID, *msg) -} - -func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights { - return tg.ChatAdminRights{ - ChangeInfo: rights.ChangeInfo, - PostMessages: rights.PostMessages, - EditMessages: rights.EditMessages, - DeleteMessages: rights.DeleteMessages, - BanUsers: rights.BanUsers, - InviteUsers: rights.InviteUsers, - PinMessages: rights.PinMessages, - AddAdmins: rights.AddAdmins, - Anonymous: rights.Anonymous, - ManageCall: rights.ManageCall, - Other: true, - } -} - -func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights { - return domain.ChannelAdminRights{ - ChangeInfo: rights.ChangeInfo, - PostMessages: rights.PostMessages, - EditMessages: rights.EditMessages, - DeleteMessages: rights.DeleteMessages, - BanUsers: rights.BanUsers, - InviteUsers: rights.InviteUsers, - PinMessages: rights.PinMessages, - AddAdmins: rights.AddAdmins, - Anonymous: rights.Anonymous, - ManageCall: rights.ManageCall, - } -} - -func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights { - return tg.ChatBannedRights{ - ViewMessages: rights.ViewMessages, - SendMessages: rights.SendMessages, - SendMedia: rights.SendMedia, - SendStickers: rights.SendStickers, - SendGifs: rights.SendGifs, - SendGames: rights.SendGames, - SendInline: rights.SendInline, - EmbedLinks: rights.EmbedLinks, - SendPolls: rights.SendPolls, - ChangeInfo: rights.ChangeInfo, - InviteUsers: rights.InviteUsers, - PinMessages: rights.PinMessages, - UntilDate: rights.UntilDate, - } -} - -func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedRights { - return domain.ChannelBannedRights{ - ViewMessages: rights.ViewMessages, - SendMessages: rights.SendMessages, - SendMedia: rights.SendMedia, - SendStickers: rights.SendStickers, - SendGifs: rights.SendGifs, - SendGames: rights.SendGames, - SendInline: rights.SendInline, - EmbedLinks: rights.EmbedLinks, - SendPolls: rights.SendPolls, - ChangeInfo: rights.ChangeInfo, - InviteUsers: rights.InviteUsers, - PinMessages: rights.PinMessages, - UntilDate: rights.UntilDate, - } -} - -func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool { - return rights == domain.ChannelBannedRights{} -} - -func tgLangPackDifference(pack domain.LangPack) *tg.LangPackDifference { - return &tg.LangPackDifference{ - LangCode: pack.LangCode, - FromVersion: pack.FromVersion, - Version: pack.Version, - Strings: tgLangPackStrings(pack.Strings), - } -} - -func tgLangPackStrings(items []domain.LangPackString) []tg.LangPackStringClass { - out := make([]tg.LangPackStringClass, 0, len(items)) - for _, item := range items { - if item.Deleted { - out = append(out, &tg.LangPackStringDeleted{Key: item.Key}) - continue - } - if item.Pluralized { - out = append(out, &tg.LangPackStringPluralized{ - Key: item.Key, - ZeroValue: item.ZeroValue, - OneValue: item.OneValue, - TwoValue: item.TwoValue, - FewValue: item.FewValue, - ManyValue: item.ManyValue, - OtherValue: item.OtherValue, - }) - continue - } - out = append(out, &tg.LangPackString{Key: item.Key, Value: item.Value}) - } - return out -} - -func tgPassword(settings domain.PasswordSettings) *tg.AccountPassword { - if len(settings.SecureRandom) == 0 { - settings.SecureRandom = []byte("telesrv-tdesktop-dev-secure-rand") - } - out := &tg.AccountPassword{ - HasRecovery: settings.HasRecovery, - HasSecureValues: settings.HasSecureValues, - HasPassword: settings.HasPassword, - Hint: settings.Hint, - EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern, - NewAlgo: tgPasswordAlgo(settings.NewAlgo), - NewSecureAlgo: tgSecurePasswordAlgo(settings.NewSecureAlgo), - SecureRandom: settings.SecureRandom, - LoginEmailPattern: settings.LoginEmailPattern, - } - if settings.HasPassword && settings.CurrentAlgo != nil { - out.CurrentAlgo = tgPasswordAlgo(*settings.CurrentAlgo) - out.SRPB = append([]byte(nil), settings.SRPB...) - out.SRPID = settings.SRPID - } - if settings.PendingResetDate != 0 { - out.PendingResetDate = settings.PendingResetDate - } - return out -} - -func tgPasswordAlgo(algo domain.PasswordKDFAlgo) tg.PasswordKdfAlgoClass { - if len(algo.P) == 0 || algo.G == 0 { - return &tg.PasswordKdfAlgoUnknown{} - } - return &tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow{ - Salt1: append([]byte(nil), algo.Salt1...), - Salt2: append([]byte(nil), algo.Salt2...), - G: algo.G, - P: append([]byte(nil), algo.P...), - } -} - -func domainPasswordAlgo(in tg.PasswordKdfAlgoClass) (*domain.PasswordKDFAlgo, bool) { - if algo, ok := in.(*tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow); ok { - return &domain.PasswordKDFAlgo{ - Salt1: append([]byte(nil), algo.Salt1...), - Salt2: append([]byte(nil), algo.Salt2...), - G: algo.G, - P: append([]byte(nil), algo.P...), - }, true - } - return nil, false -} - -func tgSecurePasswordAlgo(algo domain.SecurePasswordKDFAlgo) tg.SecurePasswordKdfAlgoClass { - if algo.Kind == "pbkdf2_hmac_sha512_iter100000" { - return &tg.SecurePasswordKdfAlgoPBKDF2HMACSHA512iter100000{Salt: append([]byte(nil), algo.Salt...)} - } - return &tg.SecurePasswordKdfAlgoUnknown{} -} - -func domainPasswordCheck(in tg.InputCheckPasswordSRPClass) domain.PasswordCheck { - if srp, ok := in.(*tg.InputCheckPasswordSRP); ok { - return domain.PasswordCheck{ - SRPID: srp.SRPID, - A: append([]byte(nil), srp.A...), - M1: append([]byte(nil), srp.M1...), - } - } - return domain.PasswordCheck{Empty: true} -} - -func domainPasswordInputSettings(in tg.AccountPasswordInputSettings) (domain.PasswordInputSettings, error) { - out := domain.PasswordInputSettings{} - if algo, ok := in.GetNewAlgo(); ok { - domainAlgo, ok := domainPasswordAlgo(algo) - if !ok { - return out, passwordHashInvalidErr() - } - out.NewAlgo = domainAlgo - out.NewPasswordHash = append([]byte(nil), in.NewPasswordHash...) - out.Hint = in.Hint - out.HasHint = true - } - if email, ok := in.GetEmail(); ok { - out.Email = email - out.HasEmail = true - } - return out, nil -} - -func tgPasswordSettings(settings domain.PrivatePasswordSettings) *tg.AccountPasswordSettings { - out := &tg.AccountPasswordSettings{} - if settings.Email != "" { - out.Email = settings.Email - } - return out -} - -func tgCountriesList(list domain.CountriesList) tg.HelpCountriesListClass { - out := &tg.HelpCountriesList{ - Hash: list.Hash, - Countries: make([]tg.HelpCountry, 0, len(list.Countries)), - } - for _, country := range list.Countries { - item := tg.HelpCountry{ - Hidden: country.Hidden, - ISO2: country.ISO2, - DefaultName: country.DefaultName, - Name: country.Name, - CountryCodes: make([]tg.HelpCountryCode, 0, len(country.CountryCodes)), - } - for _, code := range country.CountryCodes { - item.CountryCodes = append(item.CountryCodes, tg.HelpCountryCode{ - CountryCode: code.CountryCode, - Prefixes: code.Prefixes, - Patterns: code.Patterns, - }) - } - out.Countries = append(out.Countries, item) - } - return out -} - -func tgJSONValue(data []byte) tg.JSONValueClass { - if len(data) == 0 { - return &tg.JSONObject{} - } - var v any - if err := json.Unmarshal(data, &v); err != nil { - return &tg.JSONObject{} - } - return tgJSON(v) -} - -func tgJSON(v any) tg.JSONValueClass { - switch x := v.(type) { - case nil: - return &tg.JSONNull{} - case bool: - return &tg.JSONBool{Value: x} - case float64: - return &tg.JSONNumber{Value: x} - case string: - return &tg.JSONString{Value: x} - case []any: - arr := &tg.JSONArray{Value: make([]tg.JSONValueClass, 0, len(x))} - for _, item := range x { - arr.Value = append(arr.Value, tgJSON(item)) - } - return arr - case map[string]any: - obj := &tg.JSONObject{Value: make([]tg.JSONObjectValue, 0, len(x))} - keys := make([]string, 0, len(x)) - for key := range x { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - obj.Value = append(obj.Value, tg.JSONObjectValue{Key: key, Value: tgJSON(x[key])}) - } - return obj - default: - return &tg.JSONString{Value: ""} - } -} diff --git a/internal/rpc/convert_auth.go b/internal/rpc/convert_auth.go new file mode 100644 index 00000000..0c77e3a7 --- /dev/null +++ b/internal/rpc/convert_auth.go @@ -0,0 +1,77 @@ +package rpc + +import ( + "context" + "telesrv/internal/domain" +) + +// authzFromCtx 从连接上下文组装一条待绑定的设备授权(UserID 由业务层填充)。 +func (r *Router) authzFromCtx(ctx context.Context) domain.Authorization { + id, _ := AuthKeyIDFrom(ctx) + a := domain.Authorization{AuthKeyID: id, Layer: LayerFrom(ctx)} + if ci, ok := ClientInfoFrom(ctx); ok { + a.DeviceModel = ci.DeviceModel + a.Platform = string(ci.ClientType()) + a.SystemVersion = ci.SystemVersion + a.AppVersion = ci.AppVersion + a.APIID = ci.APIID + } + return a +} + +// currentUserID 返回当前连接已登录的 user_id。 +// +// 优先使用 active session 缓存;若新连接尚未绑定但 auth_key 已授权,则只在这里 +// 查询一次授权表并回填 session,避免各业务 service 每个 RPC 重复 authKey→userID。 +func (r *Router) currentUserID(ctx context.Context) (int64, bool, error) { + if userID, ok := UserIDFrom(ctx); ok { + return userID, true, nil + } + if r.deps.Sessions != nil { + if sessionID, ok := SessionIDFrom(ctx); ok { + if scoped, ok := r.scopedSessions(); ok { + if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { + if userID, resolved := scoped.UserIDResolvedForAuthKey(rawAuthKeyID, sessionID); resolved { + if userID == 0 { + if authKeyID, ok := AuthKeyIDFrom(ctx); ok { + if cachedUserID, ok := r.positiveCachedAuthUser(authKeyID); ok { + scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, cachedUserID) + r.announceSessionOnline(ctx, cachedUserID) + return cachedUserID, true, nil + } + } + } + return userID, userID != 0, nil + } + } + } else if userID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved { + if userID == 0 { + if authKeyID, ok := AuthKeyIDFrom(ctx); ok { + if cachedUserID, ok := r.positiveCachedAuthUser(authKeyID); ok { + r.deps.Sessions.BindUser(sessionID, cachedUserID) + r.announceSessionOnline(ctx, cachedUserID) + return cachedUserID, true, nil + } + } + } + return userID, userID != 0, nil + } + } + } + if r.deps.Auth == nil { + return 0, false, nil + } + authKeyID, ok := AuthKeyIDFrom(ctx) + if !ok { + return 0, false, nil + } + userID, found, err := r.lookupAuthUser(ctx, authKeyID) + if err != nil || !found { + if err == nil && !found { + r.bindSessionUser(ctx, 0) + } + return 0, found, err + } + r.bindSessionUser(ctx, userID) + return userID, true, nil +} diff --git a/internal/rpc/convert_business.go b/internal/rpc/convert_business.go new file mode 100644 index 00000000..e86bbb2b --- /dev/null +++ b/internal/rpc/convert_business.go @@ -0,0 +1,460 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func domainBusinessWorkHours(req *tg.AccountUpdateBusinessWorkHoursRequest) (*domain.BusinessWorkHours, error) { + if req == nil { + return nil, nil + } + hours, ok := req.GetBusinessWorkHours() + if !ok { + return nil, nil + } + out := &domain.BusinessWorkHours{ + TimezoneID: hours.TimezoneID, + WeeklyOpen: make([]domain.BusinessWeeklyOpen, 0, len(hours.WeeklyOpen)), + } + for _, item := range hours.WeeklyOpen { + out.WeeklyOpen = append(out.WeeklyOpen, domain.BusinessWeeklyOpen{ + StartMinute: item.StartMinute, + EndMinute: item.EndMinute, + }) + } + return out, nil +} + +func tgBusinessWorkHours(in *domain.BusinessWorkHours) (tg.BusinessWorkHours, bool) { + if in == nil { + return tg.BusinessWorkHours{}, false + } + out := tg.BusinessWorkHours{ + TimezoneID: in.TimezoneID, + WeeklyOpen: make([]tg.BusinessWeeklyOpen, 0, len(in.WeeklyOpen)), + } + for _, item := range in.WeeklyOpen { + out.WeeklyOpen = append(out.WeeklyOpen, tg.BusinessWeeklyOpen{ + StartMinute: item.StartMinute, + EndMinute: item.EndMinute, + }) + } + if in.OpenNow { + out.SetOpenNow(true) + } + return out, true +} + +func domainBusinessLocation(req *tg.AccountUpdateBusinessLocationRequest) (*domain.BusinessLocation, error) { + if req == nil { + return nil, nil + } + address, addressSet := req.GetAddress() + geo, geoSet := req.GetGeoPoint() + if !addressSet && !geoSet { + return nil, nil + } + out := &domain.BusinessLocation{Address: address} + if geoSet { + switch point := geo.(type) { + case *tg.InputGeoPoint: + out.Geo = &domain.GeoPoint{Lat: point.Lat, Long: point.Long} + case *tg.InputGeoPointEmpty, nil: + default: + return nil, inputConstructorInvalidErr() + } + } + return out, nil +} + +func tgBusinessLocation(in *domain.BusinessLocation) (tg.BusinessLocation, bool) { + if in == nil { + return tg.BusinessLocation{}, false + } + out := tg.BusinessLocation{Address: in.Address} + if in.Geo != nil { + out.SetGeoPoint(&tg.GeoPoint{Lat: in.Geo.Lat, Long: in.Geo.Long, AccessHash: 1}) + } + return out, true +} + +func domainBusinessIntro(req *tg.AccountUpdateBusinessIntroRequest) (*domain.BusinessIntro, error) { + if req == nil { + return nil, nil + } + intro, ok := req.GetIntro() + if !ok { + return nil, nil + } + out := &domain.BusinessIntro{ + Title: intro.Title, + Description: intro.Description, + } + if sticker, ok := intro.GetSticker(); ok { + doc, ok := sticker.(*tg.InputDocument) + if !ok || doc.ID == 0 { + return nil, documentInvalidErr() + } + out.StickerDocumentID = doc.ID + } + return out, nil +} + +func (r *Router) tgBusinessIntro(ctx context.Context, in *domain.BusinessIntro) (tg.BusinessIntro, bool) { + if in == nil { + return tg.BusinessIntro{}, false + } + out := tg.BusinessIntro{Title: in.Title, Description: in.Description} + if in.StickerDocumentID != 0 && r.deps.Files != nil { + if doc, found, err := r.deps.Files.GetDocument(ctx, in.StickerDocumentID); err == nil && found { + out.SetSticker(tgDocument(doc)) + } + } + return out, true +} + +func (r *Router) domainBusinessGreeting(ctx context.Context, currentUserID int64, req *tg.AccountUpdateBusinessGreetingMessageRequest) (*domain.BusinessGreetingMessage, error) { + if req == nil { + return nil, nil + } + message, ok := req.GetMessage() + if !ok { + return nil, nil + } + recipients, err := r.domainBusinessRecipients(ctx, currentUserID, message.Recipients) + if err != nil { + return nil, err + } + return &domain.BusinessGreetingMessage{ + ShortcutID: message.ShortcutID, + Recipients: recipients, + NoActivityDays: message.NoActivityDays, + }, nil +} + +func tgBusinessGreeting(in *domain.BusinessGreetingMessage) (tg.BusinessGreetingMessage, bool) { + if in == nil { + return tg.BusinessGreetingMessage{}, false + } + return tg.BusinessGreetingMessage{ + ShortcutID: in.ShortcutID, + Recipients: tgBusinessRecipients(in.Recipients), + NoActivityDays: in.NoActivityDays, + }, true +} + +func (r *Router) domainBusinessAway(ctx context.Context, currentUserID int64, req *tg.AccountUpdateBusinessAwayMessageRequest) (*domain.BusinessAwayMessage, error) { + if req == nil { + return nil, nil + } + message, ok := req.GetMessage() + if !ok { + return nil, nil + } + schedule, err := domainBusinessAwaySchedule(message.Schedule) + if err != nil { + return nil, err + } + recipients, err := r.domainBusinessRecipients(ctx, currentUserID, message.Recipients) + if err != nil { + return nil, err + } + return &domain.BusinessAwayMessage{ + ShortcutID: message.ShortcutID, + Schedule: schedule, + Recipients: recipients, + OfflineOnly: message.OfflineOnly, + }, nil +} + +func tgBusinessAway(in *domain.BusinessAwayMessage) (tg.BusinessAwayMessage, bool) { + if in == nil { + return tg.BusinessAwayMessage{}, false + } + out := tg.BusinessAwayMessage{ + ShortcutID: in.ShortcutID, + Schedule: tgBusinessAwaySchedule(in.Schedule), + Recipients: tgBusinessRecipients(in.Recipients), + } + if in.OfflineOnly { + out.SetOfflineOnly(true) + } + return out, true +} + +func (r *Router) domainBusinessRecipients(ctx context.Context, currentUserID int64, in tg.InputBusinessRecipients) (domain.BusinessRecipients, error) { + out := domain.BusinessRecipients{ + ExistingChats: in.ExistingChats, + NewChats: in.NewChats, + Contacts: in.Contacts, + NonContacts: in.NonContacts, + ExcludeSelected: in.ExcludeSelected, + } + if len(in.Users) == 0 { + return out, nil + } + seen := make(map[int64]struct{}, len(in.Users)) + for _, input := range in.Users { + user, found, err := r.userFromInput(ctx, currentUserID, input) + if err != nil { + return domain.BusinessRecipients{}, internalErr() + } + if !found || user.ID == 0 { + return domain.BusinessRecipients{}, userIDInvalidErr() + } + if _, ok := seen[user.ID]; ok { + continue + } + seen[user.ID] = struct{}{} + out.Users = append(out.Users, user.ID) + } + return out, nil +} + +func tgBusinessRecipients(in domain.BusinessRecipients) tg.BusinessRecipients { + out := tg.BusinessRecipients{ + ExistingChats: in.ExistingChats, + NewChats: in.NewChats, + Contacts: in.Contacts, + NonContacts: in.NonContacts, + ExcludeSelected: in.ExcludeSelected, + } + if len(in.Users) > 0 { + out.SetUsers(append([]int64(nil), in.Users...)) + } + return out +} + +func (r *Router) domainBusinessBotRecipients(ctx context.Context, currentUserID int64, in tg.InputBusinessBotRecipients) (domain.BusinessBotRecipients, error) { + users, err := r.businessBotRecipientUserIDs(ctx, currentUserID, in.Users) + if err != nil { + return domain.BusinessBotRecipients{}, err + } + excludeUsers, err := r.businessBotRecipientUserIDs(ctx, currentUserID, in.ExcludeUsers) + if err != nil { + return domain.BusinessBotRecipients{}, err + } + return domain.BusinessBotRecipients{ + ExistingChats: in.ExistingChats, + NewChats: in.NewChats, + Contacts: in.Contacts, + NonContacts: in.NonContacts, + ExcludeSelected: in.ExcludeSelected, + Users: users, + ExcludeUsers: excludeUsers, + }, nil +} + +func (r *Router) businessBotRecipientUserIDs(ctx context.Context, currentUserID int64, inputs []tg.InputUserClass) ([]int64, error) { + if len(inputs) == 0 { + return nil, nil + } + out := make([]int64, 0, len(inputs)) + seen := make(map[int64]struct{}, len(inputs)) + for _, input := range inputs { + user, found, err := r.userFromInput(ctx, currentUserID, input) + if err != nil { + return nil, internalErr() + } + if !found || user.ID == 0 { + return nil, userIDInvalidErr() + } + if _, ok := seen[user.ID]; ok { + continue + } + seen[user.ID] = struct{}{} + out = append(out, user.ID) + } + return out, nil +} + +func tgBusinessBotRecipients(in domain.BusinessBotRecipients) tg.BusinessBotRecipients { + out := tg.BusinessBotRecipients{ + ExistingChats: in.ExistingChats, + NewChats: in.NewChats, + Contacts: in.Contacts, + NonContacts: in.NonContacts, + ExcludeSelected: in.ExcludeSelected, + } + if len(in.Users) > 0 { + out.SetUsers(append([]int64(nil), in.Users...)) + } + if len(in.ExcludeUsers) > 0 { + out.SetExcludeUsers(append([]int64(nil), in.ExcludeUsers...)) + } + return out +} + +func domainBusinessBotRights(in tg.BusinessBotRights) domain.BusinessBotRights { + return domain.BusinessBotRights{ + Reply: in.Reply, + ReadMessages: in.ReadMessages, + DeleteSentMessages: in.DeleteSentMessages, + DeleteReceivedMessages: in.DeleteReceivedMessages, + EditName: in.EditName, + EditBio: in.EditBio, + EditProfilePhoto: in.EditProfilePhoto, + EditUsername: in.EditUsername, + ViewGifts: in.ViewGifts, + SellGifts: in.SellGifts, + ChangeGiftSettings: in.ChangeGiftSettings, + TransferAndUpgradeGifts: in.TransferAndUpgradeGifts, + TransferStars: in.TransferStars, + ManageStories: in.ManageStories, + } +} + +func domainBusinessBotRightsForUpdate(req *tg.AccountUpdateConnectedBotRequest) domain.BusinessBotRights { + if rights, ok := req.GetRights(); ok { + return domainBusinessBotRights(rights) + } + return domain.BusinessBotRights{Reply: true} +} + +func tgBusinessBotRights(in domain.BusinessBotRights) tg.BusinessBotRights { + return tg.BusinessBotRights{ + Reply: in.Reply, + ReadMessages: in.ReadMessages, + DeleteSentMessages: in.DeleteSentMessages, + DeleteReceivedMessages: in.DeleteReceivedMessages, + EditName: in.EditName, + EditBio: in.EditBio, + EditProfilePhoto: in.EditProfilePhoto, + EditUsername: in.EditUsername, + ViewGifts: in.ViewGifts, + SellGifts: in.SellGifts, + ChangeGiftSettings: in.ChangeGiftSettings, + TransferAndUpgradeGifts: in.TransferAndUpgradeGifts, + TransferStars: in.TransferStars, + ManageStories: in.ManageStories, + } +} + +func tgConnectedBot(in domain.ConnectedBusinessBot) tg.ConnectedBot { + return tg.ConnectedBot{ + BotID: in.BotUserID, + Recipients: tgBusinessBotRecipients(in.Recipients), + Rights: tgBusinessBotRights(in.Rights), + } +} + +func domainBusinessAwaySchedule(in tg.BusinessAwayMessageScheduleClass) (domain.BusinessAwaySchedule, error) { + switch schedule := in.(type) { + case *tg.BusinessAwayMessageScheduleAlways: + return domain.BusinessAwaySchedule{Kind: domain.BusinessAwayScheduleAlways}, nil + case *tg.BusinessAwayMessageScheduleOutsideWorkHours: + return domain.BusinessAwaySchedule{Kind: domain.BusinessAwayScheduleOutsideWorkHours}, nil + case *tg.BusinessAwayMessageScheduleCustom: + return domain.BusinessAwaySchedule{ + Kind: domain.BusinessAwayScheduleCustom, + StartDate: schedule.StartDate, + EndDate: schedule.EndDate, + }, nil + default: + return domain.BusinessAwaySchedule{}, inputConstructorInvalidErr() + } +} + +func tgBusinessAwaySchedule(in domain.BusinessAwaySchedule) tg.BusinessAwayMessageScheduleClass { + switch in.Kind { + case domain.BusinessAwayScheduleOutsideWorkHours: + return &tg.BusinessAwayMessageScheduleOutsideWorkHours{} + case domain.BusinessAwayScheduleCustom: + return &tg.BusinessAwayMessageScheduleCustom{StartDate: in.StartDate, EndDate: in.EndDate} + default: + return &tg.BusinessAwayMessageScheduleAlways{} + } +} + +func domainBusinessChatLinkInput(in tg.InputBusinessChatLink) (domain.BusinessChatLinkInput, error) { + entities, _ := in.GetEntities() + title, _ := in.GetTitle() + return domain.BusinessChatLinkInput{ + Message: in.Message, + Entities: domainMessageEntities(entities), + Title: title, + }, nil +} + +func tgBusinessChatLink(in domain.BusinessChatLink) tg.BusinessChatLink { + return tg.BusinessChatLink{ + Link: in.Link, + Message: in.Message, + Entities: tgMessageEntities(in.Entities), + Title: in.Title, + Views: in.Views, + } +} + +func tgBusinessChatLinks(in []domain.BusinessChatLink) []tg.BusinessChatLink { + out := make([]tg.BusinessChatLink, 0, len(in)) + for _, item := range in { + out = append(out, tgBusinessChatLink(item)) + } + return out +} + +func tgQuickReply(in domain.QuickReply) tg.QuickReply { + return tg.QuickReply{ + ShortcutID: in.ID, + Shortcut: in.Shortcut, + TopMessage: in.TopMessage, + Count: in.Count, + } +} + +func tgQuickReplies(in []domain.QuickReply) []tg.QuickReply { + out := make([]tg.QuickReply, 0, len(in)) + for _, item := range in { + out = append(out, tgQuickReply(item)) + } + return out +} + +func tgQuickReplyMessage(in domain.QuickReplyMessage) tg.MessageClass { + if in.ID <= 0 { + return nil + } + out := &tg.Message{ + Out: true, + ID: in.ID, + FromID: &tg.PeerUser{UserID: in.OwnerUserID}, + PeerID: &tg.PeerUser{UserID: in.OwnerUserID}, + Date: in.Date, + Message: in.Message, + Entities: tgMessageEntities(in.Entities), + } + out.SetQuickReplyShortcutID(in.ShortcutID) + return out +} + +func tgQuickReplyMessages(in []domain.QuickReplyMessage) []tg.MessageClass { + out := make([]tg.MessageClass, 0, len(in)) + for _, item := range in { + if msg := tgQuickReplyMessage(item); msg != nil { + out = append(out, msg) + } + } + return out +} + +func tgMessagesQuickReplies(in domain.QuickReplyList) *tg.MessagesQuickReplies { + return &tg.MessagesQuickReplies{ + QuickReplies: tgQuickReplies(in.QuickReplies), + Messages: tgQuickReplyMessages(in.Messages), + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +func tgMessagesQuickReplyMessages(in domain.QuickReplyMessages) *tg.MessagesMessages { + return &tg.MessagesMessages{ + Messages: tgQuickReplyMessages(in.Messages), + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go new file mode 100644 index 00000000..308a72a8 --- /dev/null +++ b/internal/rpc/convert_channels_core.go @@ -0,0 +1,850 @@ +package rpc + +import ( + "github.com/gotd/td/tg" + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +const defaultChatBannedRightsUntilDate = 2147483647 + +func tgChannelChatsWithPrimarySelf(viewerUserID int64, primary domain.Channel, extras []domain.Channel, primarySelf domain.ChannelMember) []tg.ChatClass { + channels := make([]domain.Channel, 0, len(extras)+1) + channels = append(channels, primary) + channels = append(channels, extras...) + out := make([]tg.ChatClass, 0, len(channels)) + seen := make(map[int64]struct{}, len(channels)) + for _, ch := range channels { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + var self *domain.ChannelMember + if ch.ID == primary.ID && primarySelf.ChannelID == ch.ID && primarySelf.UserID == viewerUserID { + self = &primarySelf + } + out = append(out, tgChannelChat(viewerUserID, ch, self)) + } + return out +} + +func tgChannelHistoryMessages(viewerUserID int64, list domain.ChannelHistory) tg.MessagesMessagesClass { + messages := make([]tg.MessageClass, 0, len(list.Messages)) + for _, msg := range list.Messages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + topics := make([]tg.ForumTopicClass, 0, len(list.Topics)) + for _, topic := range list.Topics { + item := tgForumTopicFromDomain(viewerUserID, topic) + item.SetShort(true) + topics = append(topics, item) + } + // viewer 自己发过言时会出现在 history.Users 里;必须经 viewer 分支打 self 标志, + // 否则 DrKLO putUsers 会用 self=false 覆盖账号缓存,Saved Messages 退化为普通自聊。 + users := tgUsersForViewer(viewerUserID, list.Users) + chats := tgChannelChatsWithPrimarySelf(viewerUserID, list.Channel, list.Channels, list.Self) + if list.Channel.ID != 0 { + return &tg.MessagesChannelMessages{ + Pts: list.Channel.Pts, + Count: list.Count, + Messages: messages, + Topics: topics, + Chats: chats, + Users: users, + } + } + if list.Count > len(messages) { + return &tg.MessagesMessagesSlice{ + Count: list.Count, + Messages: messages, + Topics: topics, + Chats: chats, + Users: users, + } + } + return &tg.MessagesMessages{Messages: messages, Topics: topics, Chats: chats, Users: users} +} + +func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageClass { + if m.ID == 0 || m.ChannelID == 0 { + return nil + } + peer := &tg.PeerChannel{ChannelID: m.ChannelID} + outgoing := m.SenderUserID == viewerUserID && viewerUserID != 0 && m.From.Type != domain.PeerTypeChannel + from := tg.PeerClass(nil) + if !m.Post && m.SendAs != nil && m.SendAs.ID != 0 { + from = tgPeer(*m.SendAs) + } + if !m.Post && from == nil && m.From.ID != 0 { + from = tgPeer(m.From) + } + if from == nil && m.SenderUserID != 0 && !m.Post { + from = &tg.PeerUser{UserID: m.SenderUserID} + } + if m.Action != nil && m.Action.Type == domain.ChannelActionCreate { + // messageActionChannelCreate 是频道级「创建」事件,不带发送者(对齐官方)。否则在 monoforum + // 管理视图里,这条 from=管理员 的创建消息会被客户端按发送者归成一个虚假的「管理员」子会话, + // 点开 monoforum 就进了那个人的个人资料而非私信管理视图。out 仍保留(管理员触发,= viewer)。 + from = nil + } + if m.Action != nil { + msg := &tg.MessageService{ + Out: outgoing, + Silent: m.Silent, + Post: m.Post, + ID: m.ID, + FromID: from, + PeerID: peer, + Date: m.Date, + Action: tgChannelMessageAction(*m.Action), + } + if msg.Action == nil { + msg.Action = &tg.MessageActionEmpty{} + } + if reply := tgMessageReplyHeader(domain.Message{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: m.ChannelID}, + ReplyTo: m.ReplyTo, + }); reply != nil { + msg.SetReplyTo(reply) + } + return msg + } + msg := &tg.Message{ + Out: outgoing, + Silent: m.Silent, + Post: m.Post, + Noforwards: m.NoForwards, + Mentioned: m.Mentioned, + MediaUnread: m.MediaUnread, + ID: m.ID, + FromID: from, + PeerID: peer, + Date: m.Date, + Message: m.Body, + Entities: tgMessageEntities(m.Entities), + } + if m.SavedPeer.ID != 0 { + // 频道私信(monoforum):saved_peer_id 让客户端把消息归入对应订阅者子会话。 + msg.SetSavedPeerID(tgPeer(m.SavedPeer)) + } + if m.Pinned { + msg.SetPinned(true) + } + if m.EditDate != 0 { + msg.SetEditDate(m.EditDate) + } + if reply := tgMessageReplyHeader(domain.Message{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: m.ChannelID}, + ReplyTo: m.ReplyTo, + }); reply != nil { + msg.SetReplyTo(reply) + } + if fwd := tgMessageFwdHeader(m.Forward); fwd != nil { + msg.SetFwdFrom(*fwd) + } + if m.ViaBotID != 0 { + msg.SetViaBotID(m.ViaBotID) + } + if m.GroupedID != 0 { + msg.SetGroupedID(m.GroupedID) + } + if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { + msg.SetReplyMarkup(markup) + } + if replies := tgChannelMessageReplies(m.Replies); replies != nil { + msg.SetReplies(*replies) + } + if reactions := tgMessageReactions(viewerUserID, m.Reactions); reactions != nil { + msg.SetReactions(*reactions) + } + if !m.Media.IsZero() { + msg.SetMedia(tgMessageMedia(m.Media)) + if m.Media.InvertMedia { + msg.SetInvertMedia(true) + } + } + if m.TTLPeriod > 0 { + msg.SetTTLPeriod(m.TTLPeriod) + } + if m.FromBoostsApplied > 0 { + msg.SetFromBoostsApplied(m.FromBoostsApplied) + } + if m.Post { + // 官方频道 post 自带 views 计数器(初始 1);signatures 开启时附作者签名。 + views := m.ViewsCount + if views < 1 { + views = 1 + } + msg.SetViews(views) + if m.PostAuthor != "" { + msg.SetPostAuthor(m.PostAuthor) + } + } + return msg +} + +func tgChannelMessageAction(action domain.ChannelMessageAction) tg.MessageActionClass { + switch action.Type { + case domain.ChannelActionCreate: + return &tg.MessageActionChannelCreate{Title: action.Title} + case domain.ChannelActionChatAddUser, domain.ChannelActionChatJoined: + return &tg.MessageActionChatAddUser{Users: append([]int64(nil), action.UserIDs...)} + case domain.ChannelActionChatJoinedByLink: + return &tg.MessageActionChatJoinedByLink{InviterID: action.InviterUserID} + case domain.ChannelActionChatDelete: + var userID int64 + if len(action.UserIDs) > 0 { + userID = action.UserIDs[0] + } + return &tg.MessageActionChatDeleteUser{UserID: userID} + case domain.ChannelActionEditTitle: + return &tg.MessageActionChatEditTitle{Title: action.Title} + case domain.ChannelActionTopicCreate: + return &tg.MessageActionTopicCreate{ + TitleMissing: action.TitleMissing, + Title: action.Title, + IconColor: action.IconColor, + IconEmojiID: action.IconEmojiID, + } + case domain.ChannelActionTopicEdit: + out := &tg.MessageActionTopicEdit{} + if action.Title != "" { + out.SetTitle(action.Title) + } + if action.IconEmojiIDSet { + out.SetIconEmojiID(action.IconEmojiID) + } + if action.Closed != nil { + out.SetClosed(*action.Closed) + } + if action.Hidden != nil { + out.SetHidden(*action.Hidden) + } + return out + case domain.ChannelActionTodoCompletions: + return &tg.MessageActionTodoCompletions{ + Completed: append([]int(nil), action.Completed...), + Incompleted: append([]int(nil), action.Incompleted...), + } + case domain.ChannelActionTodoAppendTasks: + return &tg.MessageActionTodoAppendTasks{ + List: tgTodoItems(action.TodoItems), + } + case domain.ChannelActionGroupCall: + // started(无 duration)与 ended(带 duration)共用 messageActionGroupCall。 + out := &tg.MessageActionGroupCall{ + Call: &tg.InputGroupCall{ID: action.CallID, AccessHash: action.CallAccessHash}, + } + if action.CallDuration > 0 { + out.SetDuration(action.CallDuration) + } + return out + case domain.ChannelActionInviteToGroupCall: + return &tg.MessageActionInviteToGroupCall{ + Call: &tg.InputGroupCall{ID: action.CallID, AccessHash: action.CallAccessHash}, + Users: append([]int64(nil), action.UserIDs...), + } + case domain.ChannelActionBoostApply: + return &tg.MessageActionBoostApply{Boosts: action.Boosts} + case domain.ChannelActionPaidMessagesPrice: + return &tg.MessageActionPaidMessagesPrice{ + BroadcastMessagesAllowed: action.BroadcastMessagesAllowed, + Stars: action.Stars, + } + case domain.ChannelActionStarGift: + return tgMessageActionStarGift(action.StarGift) + case domain.ChannelActionSetChatWallpaper: + if wallpaper := tgWallpaper(action.Wallpaper); wallpaper != nil { + return &tg.MessageActionSetChatWallPaper{Wallpaper: wallpaper} + } + return nil + default: + return nil + } +} + +func tgChannelMessageReplies(in *domain.ChannelMessageReplies) *tg.MessageReplies { + if in == nil { + return nil + } + out := &tg.MessageReplies{ + Comments: in.Comments, + Replies: in.Replies, + RepliesPts: in.RepliesPts, + } + if len(in.RecentRepliers) > 0 { + repliers := make([]tg.PeerClass, 0, len(in.RecentRepliers)) + for _, peer := range in.RecentRepliers { + if converted := tgPeer(peer); converted != nil { + repliers = append(repliers, converted) + } + } + if len(repliers) > 0 { + out.SetRecentRepliers(repliers) + } + } + if in.ChannelID != 0 { + out.SetChannelID(in.ChannelID) + } + if in.MaxID > 0 { + out.SetMaxID(in.MaxID) + } + if in.ReadMaxID > 0 { + out.SetReadMaxID(in.ReadMaxID) + } + return out +} + +func tgChannels(viewerUserID int64, channels []domain.Channel) []tg.ChatClass { + out := make([]tg.ChatClass, 0, len(channels)) + seen := make(map[int64]struct{}, len(channels)) + for _, ch := range channels { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + out = append(out, tgChannelChatMin(viewerUserID, ch)) + } + return out +} + +func tgChannelChat(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) tg.ChatClass { + if ch.Deleted { + return tgChannelForbidden(ch) + } + return tgChannel(viewerUserID, ch, self) +} + +// tgChannelChatMin 是消息类 update/伴随 chats 的 min channel 形态: +// 客户端对 min 对象不应用 admin/banned rights 与 left/creator,避免不带 +// 接收者 member 投影的推送把本地权限状态清掉。 +func tgChannelChatMin(viewerUserID int64, ch domain.Channel) tg.ChatClass { + if ch.Deleted { + return tgChannelForbidden(ch) + } + out := tgChannel(viewerUserID, ch, nil) + out.Min = true + out.Creator = false + return out +} + +// tgChannelChatForView 把按 viewer 投影后的 ChannelView 转成查询响应 chat: +// 被踢/被禁止查看的 viewer 收到 channelForbidden,借此感知自己已离开会话。 +func tgChannelChatForView(viewerUserID int64, view domain.ChannelView) tg.ChatClass { + if view.Forbidden { + return tgChannelForbidden(view.Channel) + } + self := view.Self + return tgChannelChat(viewerUserID, view.Channel, &self) +} + +func tgChannelForbidden(ch domain.Channel) *tg.ChannelForbidden { + return &tg.ChannelForbidden{ + Broadcast: ch.Broadcast, + Megagroup: ch.Megagroup, + ID: ch.ID, + AccessHash: ch.AccessHash, + Title: ch.Title, + } +} + +func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) *tg.Channel { + out := &tg.Channel{ + Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0, + Verified: ch.Verified, + Broadcast: ch.Broadcast, + Megagroup: ch.Megagroup, + Forum: ch.Forum, + ForumTabs: ch.ForumTabs, + Noforwards: ch.NoForwards, + Signatures: ch.Signatures, + ID: ch.ID, + Title: ch.Title, + Photo: tgChatPhoto(ch), + Date: ch.Date, + } + out.SetAccessHash(ch.AccessHash) + out.SetJoinToSend(ch.JoinToSend) + out.SetJoinRequest(ch.JoinRequest) + out.SetAutotranslation(ch.Autotranslation) + // broadcast_messages_allowed 只属于广播母频道。monoforum 内部用同名字段镜像 DM 启用状态(见下), + // 但它是 megagroup,绝不能把该 flag 投影出去(官方 monoforum 对象也不带它)。 + if ch.Broadcast { + out.SetBroadcastMessagesAllowed(ch.BroadcastMessagesAllowed) + } + if ch.SendPaidMessagesStars > 0 || ch.BroadcastMessagesAllowed || ch.Monoforum { + out.SetSendPaidMessagesStars(ch.SendPaidMessagesStars) + } + if ch.HasLink || ch.LinkedChatID != 0 { + out.SetHasLink(true) + } + if ch.Monoforum { + out.SetMonoforum(true) + } + // linked_monoforum_id 仅在 DM 启用时下发(母频道与 monoforum 都用 BroadcastMessagesAllowed 表示 + // DM 启用状态,monoforum 由 SetPaidMessagesPrice 镜像母频道)。关闭 Direct Messages 时**双方都**隐藏 + // 该 id:母频道隐藏触发 monoforum 的 MonoforumDisabled;monoforum 也必须隐藏,否则打开 monoforum + // 重新拉取时 mono 仍带 link → setMonoforumLink(parent) 会把 MonoforumDisabled 清掉、停用页脚消失。 + // 内部关联行仍保留以便重新开启复用。 + if ch.LinkedMonoforumID != 0 && ch.BroadcastMessagesAllowed { + out.SetLinkedMonoforumID(ch.LinkedMonoforumID) + } + if ch.Username != "" { + out.SetUsername(ch.Username) + out.SetUsernames(tgUsernames(ch.Username)) + } + if color := tgPeerColor(ch.Color); color != nil { + out.SetColor(color) + } + if profileColor := tgPeerColor(ch.ProfileColor); profileColor != nil { + out.SetProfileColor(profileColor) + } + if status := tgChannelEmojiStatus(ch.EmojiStatus); status != nil { + out.SetEmojiStatus(status) + } + if ch.SlowmodeSeconds > 0 { + out.SetSlowmodeEnabled(true) + } + if ch.ParticipantsCount > 0 { + out.SetParticipantsCount(ch.ParticipantsCount) + } + out.SetDefaultBannedRights(tgDefaultChatBannedRights(ch.DefaultBannedRights)) + // 群通话 banner 数据源:call_active/call_not_empty flag(Android 对 flag 依赖 + // 更重,call_not_empty 翻转时还会经 pushChannelStateToMembers 补推)。 + out.CallActive = ch.ActiveCallID != 0 + out.CallNotEmpty = ch.ActiveCallNotEmpty + if ch.Monoforum { + // Monoforum(频道私信容器)绝不能在自身对象上带 Creator/admin_rights。TDesktop 的 + // NeedAboutGroup 对 megagroup 看 amCreator() 决定是否画 "You created a group" 群聊空状态, + // 且订阅数/Leave/Channel 等 chrome 也 key off amCreator/asMegagroup —— 一旦 mono 自身 + // Creator=true 就会被渲染成普通群。Direct-Messages 容器身份(本地 MonoforumAdmin 标志)由 + // 客户端从母频道 canAccessMonoforum(amCreator 或 manage_direct_messages)派生,与 mono 自身 + // 的 Creator/admin 无关。服务端的私信发送鉴权走母频道 membership,不依赖此 flag。 + out.Creator = false + } else if self != nil { + if self.Status == domain.ChannelMemberLeft { + out.Left = true + } + switch self.Role { + case domain.ChannelRoleCreator: + out.Creator = true + out.SetAdminRights(tgChatAdminRights(self.AdminRights)) + case domain.ChannelRoleAdmin: + out.SetAdminRights(tgChatAdminRights(self.AdminRights)) + } + if !zeroChannelBannedRights(self.BannedRights) { + out.SetBannedRights(tgChatBannedRights(self.BannedRights)) + } + } + return out +} + +func tgChannelFull(view domain.ChannelView) *tg.ChannelFull { + ch := view.Channel + full := &tg.ChannelFull{ + // 广播频道的订阅者列表仅管理员可见(官方语义):非管理员订阅者拿到 + // can_view_participants=false,Profile 的 Subscribers/Administrators/Channel Settings + // 三行随之隐藏。megagroup 成员可见(隐藏成员时仅管理员)。 + CanViewParticipants: channelMemberIsAdmin(view.Self) || !ch.MembersListAdminOnly(), + CanSetUsername: view.Self.Role == domain.ChannelRoleCreator, + CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator, + ID: ch.ID, + About: ch.About, + ReadInboxMaxID: view.Dialog.ReadInboxMaxID, + ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID, + UnreadCount: view.Dialog.UnreadCount, + ChatPhoto: tgChannelChatPhotoFull(ch), + NotifySettings: *tdesktop.NotifySettings(), + Pts: ch.Pts, + } + if ch.ParticipantsCount > 0 { + full.SetParticipantsCount(ch.ParticipantsCount) + } + if ch.ActiveCallID != 0 { + // 入会面板/banner 点击的入口:客户端拿它调 phone.getGroupCall。 + full.SetCall(&tg.InputGroupCall{ID: ch.ActiveCallID, AccessHash: ch.ActiveCallAccessHash}) + } + if view.Self.AvailableMinID > 0 { + // 客户端用它裁剪本地缓存的入群前/已清空历史(clearUpTill)。 + full.SetAvailableMinID(view.Self.AvailableMinID) + } + if view.ExportedInvite != nil && !view.ExportedInvite.Revoked { + full.SetExportedInvite(tgExportedChannelInvite(*view.ExportedInvite)) + } + if ch.AdminsCount > 0 { + full.SetAdminsCount(ch.AdminsCount) + } + if ch.KickedCount > 0 { + full.SetKickedCount(ch.KickedCount) + } + if ch.BannedCount > 0 { + full.SetBannedCount(ch.BannedCount) + } + if view.Dialog.FolderID != domain.DialogMainFolderID { + full.SetFolderID(view.Dialog.FolderID) + } + if ch.TTLPeriod > 0 { + full.SetTTLPeriod(ch.TTLPeriod) + } + if view.Dialog.HasScheduled { + full.SetHasScheduled(true) + } + if ch.PreHistoryHidden { + full.SetHiddenPrehistory(true) + } + if ch.ParticipantsHidden { + full.SetParticipantsHidden(true) + } + if ch.AntiSpam { + full.SetAntispam(true) + } + if ch.RestrictedSponsored { + full.SetRestrictedSponsored(true) + } + if ch.SendPaidMessagesStars > 0 || ch.BroadcastMessagesAllowed { + full.SetPaidMessagesAvailable(true) + full.SetSendPaidMessagesStars(ch.SendPaidMessagesStars) + } + if view.Dialog.ViewForumAsMessages { + full.SetViewForumAsMessages(true) + } + if ch.LinkedChatID != 0 { + full.SetLinkedChatID(ch.LinkedChatID) + } + if defaultSendAs := validDefaultSendAsPeer(view); defaultSendAs != nil { + full.SetDefaultSendAs(tgPeer(*defaultSendAs)) + } + if ch.SlowmodeSeconds > 0 { + full.SetSlowmodeSeconds(ch.SlowmodeSeconds) + if view.Self.SlowmodeLastSendDate > 0 { + full.SetSlowmodeNextSendDate(view.Self.SlowmodeLastSendDate + ch.SlowmodeSeconds) + } + } + if view.SelfBoostsApplied > 0 { + full.SetBoostsApplied(view.SelfBoostsApplied) + } + if ch.BoostsUnrestrict > 0 { + full.SetBoostsUnrestrict(ch.BoostsUnrestrict) + } + if ch.PinnedMessageID > 0 { + full.SetPinnedMsgID(ch.PinnedMessageID) + } + if wallpaper := tgWallpaper(ch.Wallpaper); wallpaper != nil { + full.SetWallpaper(wallpaper) + } + if reactions := tgChannelReactionPolicy(ch.ReactionPolicy); reactions != nil { + full.SetAvailableReactions(reactions) + } + if ch.ReactionPolicy.Limit > 0 { + full.SetReactionsLimit(ch.ReactionPolicy.Limit) + } + // 付费 reaction(Stars)是广播频道默认能力——官方语义下 channelFull.paid_reactions_available + // 对广播频道恒真,客户端据此显示星按钮;megagroup 不支持。与 store 侧 + // AddChannelMessagePaidReaction 仅广播频道的门槛一致。显式 PaidEnabled 也保留。 + if (ch.Broadcast && !ch.Megagroup) || ch.ReactionPolicy.PaidEnabled { + full.SetPaidReactionsAvailable(true) + } + if ch.Broadcast && !ch.Megagroup { + full.SetStargiftsAvailable(true) + } + return full +} + +func channelMemberIsAdmin(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin +} + +func tgChannelReactionPolicy(policy domain.ChannelReactionPolicy) tg.ChatReactionsClass { + switch policy.Type { + case domain.ChannelReactionPolicyNone: + return &tg.ChatReactionsNone{} + case domain.ChannelReactionPolicyAll: + out := &tg.ChatReactionsAll{} + if policy.AllowCustom { + out.SetAllowCustom(true) + } + return out + case domain.ChannelReactionPolicySome: + reactions := make([]tg.ReactionClass, 0, len(policy.Emoticons)+len(policy.CustomEmojiIDs)) + for _, emoticon := range policy.Emoticons { + if emoticon == "" { + continue + } + reactions = append(reactions, &tg.ReactionEmoji{Emoticon: emoticon}) + } + for _, id := range policy.CustomEmojiIDs { + if id <= 0 { + continue + } + reactions = append(reactions, &tg.ReactionCustomEmoji{DocumentID: id}) + } + return &tg.ChatReactionsSome{Reactions: reactions} + default: + return nil + } +} + +func tgPeerColor(color domain.ChannelPeerColor) tg.PeerColorClass { + if color.Empty() { + return nil + } + out := &tg.PeerColor{} + if color.HasColor { + out.SetColor(color.Color) + } + if color.BackgroundEmojiID != 0 { + out.SetBackgroundEmojiID(color.BackgroundEmojiID) + } + return out +} + +func tgChannelParticipant(selfUserID int64, member domain.ChannelMember) tg.ChannelParticipantClass { + switch member.Role { + case domain.ChannelRoleCreator: + out := &tg.ChannelParticipantCreator{ + UserID: member.UserID, + AdminRights: tgChatAdminRights(member.AdminRights), + } + if member.Rank != "" { + out.SetRank(member.Rank) + } + return out + case domain.ChannelRoleAdmin: + out := &tg.ChannelParticipantAdmin{ + Self: member.UserID == selfUserID, + UserID: member.UserID, + PromotedBy: member.InviterUserID, + Date: member.JoinedAt, + AdminRights: tgChatAdminRights(member.AdminRights), + } + if member.InviterUserID != 0 { + out.SetInviterID(member.InviterUserID) + } + if member.Rank != "" { + out.SetRank(member.Rank) + } + return out + default: + if member.Status != domain.ChannelMemberActive { + return &tg.ChannelParticipantLeft{Peer: &tg.PeerUser{UserID: member.UserID}} + } + if member.UserID == selfUserID { + out := &tg.ChannelParticipantSelf{ + UserID: member.UserID, + InviterID: member.InviterUserID, + Date: member.JoinedAt, + } + if member.Rank != "" { + out.SetRank(member.Rank) + } + return out + } + out := &tg.ChannelParticipant{UserID: member.UserID, Date: member.JoinedAt} + if member.Rank != "" { + out.SetRank(member.Rank) + } + return out + } +} + +func tgChannelAdminLogEvents(viewerUserID int64, events []domain.ChannelAdminLogEvent) []tg.ChannelAdminLogEvent { + out := make([]tg.ChannelAdminLogEvent, 0, len(events)) + for _, event := range events { + if item, ok := tgChannelAdminLogEvent(viewerUserID, event); ok { + out = append(out, item) + } + } + return out +} + +func tgChannelAdminLogEvent(viewerUserID int64, event domain.ChannelAdminLogEvent) (tg.ChannelAdminLogEvent, bool) { + action := tg.ChannelAdminLogEventActionClass(nil) + switch event.Type { + case domain.ChannelAdminLogChangeTitle: + action = &tg.ChannelAdminLogEventActionChangeTitle{PrevValue: event.PrevString, NewValue: event.NewString} + case domain.ChannelAdminLogChangeUsername: + action = &tg.ChannelAdminLogEventActionChangeUsername{PrevValue: event.PrevString, NewValue: event.NewString} + case domain.ChannelAdminLogChangeLinkedChat: + action = &tg.ChannelAdminLogEventActionChangeLinkedChat{PrevValue: int64(event.PrevInt), NewValue: int64(event.NewInt)} + case domain.ChannelAdminLogToggleSignatures: + action = &tg.ChannelAdminLogEventActionToggleSignatures{NewValue: event.NewBool} + case domain.ChannelAdminLogTogglePreHistoryHidden: + action = &tg.ChannelAdminLogEventActionTogglePreHistoryHidden{NewValue: event.NewBool} + case domain.ChannelAdminLogToggleForum: + action = &tg.ChannelAdminLogEventActionToggleForum{NewValue: event.NewBool} + case domain.ChannelAdminLogToggleAutotranslation: + action = &tg.ChannelAdminLogEventActionToggleAutotranslation{NewValue: event.NewBool} + case domain.ChannelAdminLogToggleAntiSpam: + action = &tg.ChannelAdminLogEventActionToggleAntiSpam{NewValue: event.NewBool} + case domain.ChannelAdminLogToggleSlowMode: + action = &tg.ChannelAdminLogEventActionToggleSlowMode{PrevValue: event.PrevInt, NewValue: event.NewInt} + case domain.ChannelAdminLogParticipantJoin: + action = &tg.ChannelAdminLogEventActionParticipantJoin{} + case domain.ChannelAdminLogParticipantLeave: + action = &tg.ChannelAdminLogEventActionParticipantLeave{} + case domain.ChannelAdminLogParticipantInvite: + if event.Participant == nil { + return tg.ChannelAdminLogEvent{}, false + } + action = &tg.ChannelAdminLogEventActionParticipantInvite{ + Participant: tgChannelParticipantForUpdate(viewerUserID, *event.Participant), + } + case domain.ChannelAdminLogParticipantPromote, domain.ChannelAdminLogParticipantDemote: + if event.PrevParticipant == nil || event.NewParticipant == nil { + return tg.ChannelAdminLogEvent{}, false + } + action = &tg.ChannelAdminLogEventActionParticipantToggleAdmin{ + PrevParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.PrevParticipant), + NewParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.NewParticipant), + } + case domain.ChannelAdminLogParticipantEditRank: + if event.Participant == nil { + return tg.ChannelAdminLogEvent{}, false + } + action = &tg.ChannelAdminLogEventActionParticipantEditRank{ + UserID: event.Participant.UserID, + PrevRank: event.PrevString, + NewRank: event.NewString, + } + case domain.ChannelAdminLogParticipantBan, domain.ChannelAdminLogParticipantUnban, domain.ChannelAdminLogParticipantKick, domain.ChannelAdminLogParticipantUnkick: + if event.PrevParticipant == nil || event.NewParticipant == nil { + return tg.ChannelAdminLogEvent{}, false + } + action = &tg.ChannelAdminLogEventActionParticipantToggleBan{ + PrevParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.PrevParticipant), + NewParticipant: tgChannelParticipantForUpdate(viewerUserID, *event.NewParticipant), + } + case domain.ChannelAdminLogUpdatePinned: + action = &tg.ChannelAdminLogEventActionUpdatePinned{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)} + case domain.ChannelAdminLogSendMessage: + action = &tg.ChannelAdminLogEventActionSendMessage{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)} + case domain.ChannelAdminLogEditMessage: + action = &tg.ChannelAdminLogEventActionEditMessage{ + PrevMessage: tgAdminLogMessage(viewerUserID, event.ChannelID, event.PrevMessage), + NewMessage: tgAdminLogMessage(viewerUserID, event.ChannelID, event.NewMessage), + } + case domain.ChannelAdminLogDeleteMessage: + action = &tg.ChannelAdminLogEventActionDeleteMessage{Message: tgAdminLogMessage(viewerUserID, event.ChannelID, event.Message)} + default: + return tg.ChannelAdminLogEvent{}, false + } + if action == nil { + return tg.ChannelAdminLogEvent{}, false + } + return tg.ChannelAdminLogEvent{ + ID: event.ID, + Date: event.Date, + UserID: event.UserID, + Action: action, + }, true +} + +func tgAdminLogMessage(viewerUserID, channelID int64, msg *domain.ChannelMessage) tg.MessageClass { + if msg == nil || msg.ID == 0 { + out := &tg.MessageEmpty{ID: 0} + out.SetPeerID(&tg.PeerChannel{ChannelID: channelID}) + return out + } + return tgChannelMessage(viewerUserID, *msg) +} + +func tgChatAdminRights(rights domain.ChannelAdminRights) tg.ChatAdminRights { + return tg.ChatAdminRights{ + ChangeInfo: rights.ChangeInfo, + PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, + DeleteMessages: rights.DeleteMessages, + PostStories: rights.PostStories, + EditStories: rights.EditStories, + DeleteStories: rights.DeleteStories, + BanUsers: rights.BanUsers, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + AddAdmins: rights.AddAdmins, + Anonymous: rights.Anonymous, + ManageCall: rights.ManageCall, + Other: true, + ManageRanks: rights.ManageRanks, + // manage_direct_messages(flags.17):客户端据此在母频道上判定 canAccessMonoforum, + // 从而为关联 monoforum 派生 MonoforumAdmin(Direct-Messages 容器渲染所需)。 + ManageDirectMessages: rights.ManageDirectMessages, + } +} + +func domainChannelAdminRights(rights tg.ChatAdminRights) domain.ChannelAdminRights { + return domain.ChannelAdminRights{ + ChangeInfo: rights.ChangeInfo, + PostMessages: rights.PostMessages, + EditMessages: rights.EditMessages, + DeleteMessages: rights.DeleteMessages, + PostStories: rights.PostStories, + EditStories: rights.EditStories, + DeleteStories: rights.DeleteStories, + BanUsers: rights.BanUsers, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + AddAdmins: rights.AddAdmins, + Anonymous: rights.Anonymous, + ManageCall: rights.ManageCall, + ManageRanks: rights.ManageRanks, + ManageDirectMessages: rights.ManageDirectMessages, + } +} + +func tgChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights { + return tg.ChatBannedRights{ + ViewMessages: rights.ViewMessages, + SendMessages: rights.SendMessages, + SendMedia: rights.SendMedia, + SendStickers: rights.SendStickers, + SendGifs: rights.SendGifs, + SendGames: rights.SendGames, + SendInline: rights.SendInline, + EmbedLinks: rights.EmbedLinks, + SendPolls: rights.SendPolls, + ChangeInfo: rights.ChangeInfo, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + EditRank: rights.EditRank, + UntilDate: rights.UntilDate, + } +} + +func tgDefaultChatBannedRights(rights domain.ChannelBannedRights) tg.ChatBannedRights { + out := tgChatBannedRights(rights) + if out.UntilDate == 0 { + out.UntilDate = defaultChatBannedRightsUntilDate + } + return out +} + +func domainChannelBannedRights(rights tg.ChatBannedRights) domain.ChannelBannedRights { + return domain.ChannelBannedRights{ + ViewMessages: rights.ViewMessages, + SendMessages: rights.SendMessages, + SendMedia: rights.SendMedia, + SendStickers: rights.SendStickers, + SendGifs: rights.SendGifs, + SendGames: rights.SendGames, + SendInline: rights.SendInline, + EmbedLinks: rights.EmbedLinks, + SendPolls: rights.SendPolls, + ChangeInfo: rights.ChangeInfo, + InviteUsers: rights.InviteUsers, + PinMessages: rights.PinMessages, + EditRank: rights.EditRank, + UntilDate: rights.UntilDate, + } +} + +func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool { + return rights == domain.ChannelBannedRights{} +} diff --git a/internal/rpc/convert_channels_core_test.go b/internal/rpc/convert_channels_core_test.go new file mode 100644 index 00000000..6032ad47 --- /dev/null +++ b/internal/rpc/convert_channels_core_test.go @@ -0,0 +1,69 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func TestTGChannelHasLinkFromInviteProjection(t *testing.T) { + channel := domain.Channel{ + ID: 1001, + AccessHash: 42, + Title: "private group with link", + Megagroup: true, + HasLink: true, + Date: 1700000100, + } + self := &domain.ChannelMember{ + ChannelID: channel.ID, + UserID: 10, + Status: domain.ChannelMemberActive, + Role: domain.ChannelRoleCreator, + } + got := tgChannel(10, channel, self) + if !got.GetHasLink() { + t.Fatalf("tgChannel.has_link = false, want true for linked private megagroup") + } +} + +func TestTGChannelFullIncludesExportedInvite(t *testing.T) { + view := domain.ChannelView{ + Channel: domain.Channel{ + ID: 1002, + AccessHash: 43, + Title: "private group", + Megagroup: true, + Date: 1700000100, + }, + Self: domain.ChannelMember{ + ChannelID: 1002, + UserID: 10, + Status: domain.ChannelMemberActive, + Role: domain.ChannelRoleCreator, + }, + ExportedInvite: &domain.ChannelInvite{ + ChannelID: 1002, + InviteID: 77, + Hash: "abc123", + AdminUserID: 10, + Permanent: true, + Date: 1700000111, + }, + } + + full := tgChannelFull(view) + rawInvite, ok := full.GetExportedInvite() + if !ok { + t.Fatalf("channelFull.exported_invite missing") + } + invite, ok := rawInvite.(*tg.ChatInviteExported) + if !ok { + t.Fatalf("channelFull.exported_invite = %T, want *tg.ChatInviteExported", rawInvite) + } + if !invite.Permanent || invite.Revoked || invite.AdminID != 10 || invite.Link != "https://telesrv.net/+abc123" { + t.Fatalf("channelFull.exported_invite = %#v, want active permanent invite", invite) + } +} diff --git a/internal/rpc/convert_channels_monoforum_test.go b/internal/rpc/convert_channels_monoforum_test.go new file mode 100644 index 00000000..33a81f4e --- /dev/null +++ b/internal/rpc/convert_channels_monoforum_test.go @@ -0,0 +1,113 @@ +package rpc + +import ( + "testing" + + "telesrv/internal/domain" +) + +// TestTgChannelMonoforumSuppressesCreatorChrome 锁定频道私信(monoforum)渲染修复的核心不变量: +// monoforum 虚拟频道在 TDesktop 必须呈现为 Direct-Messages 容器而非普通群。真机 bug 的根因是 +// mono 自身被投影了 Creator=true —— TDesktop 的 NeedAboutGroup 对 megagroup 看 amCreator() 决定 +// 画 "You created a group" 群聊空状态,Leave/订阅数 chrome 也 key off amCreator/asMegagroup。 +// DM 容器身份(本地 MonoforumAdmin 标志)由客户端从母频道 canAccessMonoforum 派生,与 mono 自身无关。 +func TestTgChannelMonoforumSuppressesCreatorChrome(t *testing.T) { + const owner = int64(1001) + const parentID = int64(5001) + const monoID = int64(5002) + + mono := domain.Channel{ + ID: monoID, CreatorUserID: owner, Title: "Parent", + Megagroup: true, Monoforum: true, LinkedMonoforumID: parentID, + // monoforum 镜像母频道 DM 启用状态;启用时才下发 linked_monoforum_id。 + BroadcastMessagesAllowed: true, + } + // 即便带 creator 角色的 synthetic self(admin 预览),mono 自身对象也必须 Creator=false / 无 admin_rights。 + syntheticSelf := &domain.ChannelMember{ChannelID: monoID, UserID: owner, Status: domain.ChannelMemberActive, Role: domain.ChannelRoleCreator} + monoTg := tgChannel(owner, mono, syntheticSelf) + if !monoTg.Megagroup || !monoTg.Monoforum { + t.Fatalf("mono megagroup=%v monoforum=%v, want both true", monoTg.Megagroup, monoTg.Monoforum) + } + if monoTg.Creator { + t.Fatalf("mono Creator=true, want false (Creator=true paints the wrong group chrome)") + } + if _, ok := monoTg.GetAdminRights(); ok { + t.Fatalf("mono must carry no admin_rights (admin status derived client-side from parent)") + } + if monoTg.Left { + t.Fatalf("mono Left=true, want false") + } + if id, ok := monoTg.GetLinkedMonoforumID(); !ok || id != parentID { + t.Fatalf("mono linked_monoforum_id = %d ok=%v, want parent %d", id, ok, parentID) + } + + // 母广播频道对 owner 投影:broadcast-only + Creator=true(canAccessMonoforum 走 amCreator 旁路) + // + broadcast_messages_allowed + 反向 linked_monoforum_id。三者共同让客户端派生 MonoforumAdmin。 + parent := domain.Channel{ + ID: parentID, CreatorUserID: owner, Title: "Parent", + Broadcast: true, BroadcastMessagesAllowed: true, LinkedMonoforumID: monoID, + } + parentTg := tgChannel(owner, parent, nil) + if !parentTg.Broadcast || parentTg.Megagroup || parentTg.Monoforum { + t.Fatalf("parent broadcast=%v mega=%v mono=%v, want broadcast only", parentTg.Broadcast, parentTg.Megagroup, parentTg.Monoforum) + } + if !parentTg.Creator { + t.Fatalf("parent Creator=false for owner viewer, want true (canAccessMonoforum via amCreator)") + } + if !parentTg.BroadcastMessagesAllowed { + t.Fatalf("parent broadcast_messages_allowed=false, want true") + } + if id, ok := parentTg.GetLinkedMonoforumID(); !ok || id != monoID { + t.Fatalf("parent linked_monoforum_id = %d ok=%v, want mono %d", id, ok, monoID) + } + + // 普通频道(Monoforum=false)不受影响:creator self 仍投影 Creator=true。 + normal := domain.Channel{ID: 6001, CreatorUserID: owner, Title: "Normal", Megagroup: true} + normalTg := tgChannel(owner, normal, &domain.ChannelMember{ChannelID: 6001, UserID: owner, Status: domain.ChannelMemberActive, Role: domain.ChannelRoleCreator}) + if !normalTg.Creator { + t.Fatalf("normal megagroup creator suppressed; monoforum guard leaked to non-monoforum channels") + } +} + +// TestChannelAdminRightsManageDirectMessagesRoundTrip 证明新映射的 manage_direct_messages(flags.17) +// 双向无损,且对未设置该权限的普通管理员保持惰性(不污染线格式)。 +func TestChannelAdminRightsManageDirectMessagesRoundTrip(t *testing.T) { + tgRights := tgChatAdminRights(domain.ChannelAdminRights{ManageDirectMessages: true, PostMessages: true}) + if !tgRights.ManageDirectMessages { + t.Fatalf("tg ManageDirectMessages=false, want true") + } + if back := domainChannelAdminRights(tgRights); !back.ManageDirectMessages { + t.Fatalf("round-trip dropped ManageDirectMessages") + } + if plain := tgChatAdminRights(domain.ChannelAdminRights{PinMessages: true}); plain.ManageDirectMessages { + t.Fatalf("normal admin rights wrongly set ManageDirectMessages (not inert)") + } +} + +// TestTgChannelMonoforumDisabledHidesLink 锁定关闭 Direct Messages 时的投影:母频道与 monoforum +// **双方都**隐藏 linked_monoforum_id(都以 BroadcastMessagesAllowed 表示 DM 启用,关闭时为 false)。 +// monoforum 必须也隐藏 —— 否则用户打开 monoforum 重新拉取频道对象时,mono 仍带 link → +// setMonoforumLink(parent) 触发 link&&monoforumDisabled 分支把 MonoforumDisabled 清掉,「已停用私信」 +// 停用页脚消失。monoforum 自身绝不投影 broadcast_messages_allowed(它是 megagroup)。 +func TestTgChannelMonoforumDisabledHidesLink(t *testing.T) { + const owner = int64(1001) + const parentID = int64(5001) + const monoID = int64(5002) + + parent := domain.Channel{ID: parentID, CreatorUserID: owner, Broadcast: true, LinkedMonoforumID: monoID, BroadcastMessagesAllowed: false} + if _, ok := tgChannel(owner, parent, nil).GetLinkedMonoforumID(); ok { + t.Fatalf("disabled parent projected linked_monoforum_id, want hidden") + } + + mono := domain.Channel{ID: monoID, CreatorUserID: owner, Megagroup: true, Monoforum: true, LinkedMonoforumID: parentID, BroadcastMessagesAllowed: false} + monoTg := tgChannel(owner, mono, nil) + if _, ok := monoTg.GetLinkedMonoforumID(); ok { + t.Fatalf("disabled monoforum projected linked_monoforum_id, want hidden (else opening it clears MonoforumDisabled → footer disappears)") + } + if !monoTg.Monoforum || !monoTg.Megagroup { + t.Fatalf("disabled mono monoforum=%v mega=%v, want both still true", monoTg.Monoforum, monoTg.Megagroup) + } + if monoTg.BroadcastMessagesAllowed { + t.Fatalf("monoforum leaked broadcast_messages_allowed, want never projected (it is a megagroup)") + } +} diff --git a/internal/rpc/convert_dialogs.go b/internal/rpc/convert_dialogs.go new file mode 100644 index 00000000..848c59ff --- /dev/null +++ b/internal/rpc/convert_dialogs.go @@ -0,0 +1,329 @@ +package rpc + +import ( + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func tgMessagesDialogs(viewerUserID int64, list domain.DialogList) tg.MessagesDialogsClass { + dialogs := make([]tg.DialogClass, 0, len(list.Dialogs)+1) + // dialogFolder 条目排在最前:TDesktop 据它发现 archive folder 并渲染 + // 归档行的未读徽章(Folder::applyDialog / MainList::updateCloudUnread)。 + if folder := tgDialogFolder(list.ArchiveSummary); folder != nil { + dialogs = append(dialogs, folder) + } + for _, d := range list.Dialogs { + if dialog := tgDialog(d); dialog != nil { + dialogs = append(dialogs, dialog) + } + } + messages := make([]tg.MessageClass, 0, len(list.Messages)) + for _, msg := range list.Messages { + if item := tgMessage(msg); item != nil { + messages = append(messages, item) + } + } + for _, msg := range list.ChannelMessages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + users := tgUsersForViewer(viewerUserID, list.Users) + chats := tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs) + if list.Count > len(dialogs) { + return &tg.MessagesDialogsSlice{ + Count: list.Count, + Dialogs: dialogs, + Messages: messages, + Chats: chats, + Users: users, + } + } + return &tg.MessagesDialogs{ + Dialogs: dialogs, + Messages: messages, + Chats: chats, + Users: users, + } +} + +func tgPeerDialogs(viewerUserID int64, list domain.DialogList, st domain.UpdateState) *tg.MessagesPeerDialogs { + out := &tg.MessagesPeerDialogs{ + Dialogs: make([]tg.DialogClass, 0, len(list.Dialogs)+1), + Messages: make([]tg.MessageClass, 0, len(list.Messages)+len(list.ChannelMessages)), + Users: make([]tg.UserClass, 0, len(list.Users)), + Chats: make([]tg.ChatClass, 0, len(list.Channels)), + State: tgUpdateState(st), + } + // getPinnedDialogs(folder_id=0) 必须带 dialogFolder 条目:DrKLO 主列表 + // getDialogs 一律 exclude_pinned,archive 行只能从 pinned 响应发现 + // (fetchFolderInLoadedPinnedDialogs,且要求 top_message/peer 非零)。 + if folder := tgDialogFolder(list.ArchiveSummary); folder != nil { + out.Dialogs = append(out.Dialogs, folder) + } + for _, d := range list.Dialogs { + if dialog := tgDialog(d); dialog != nil { + out.Dialogs = append(out.Dialogs, dialog) + } + } + for _, msg := range list.Messages { + if item := tgMessage(msg); item != nil { + out.Messages = append(out.Messages, item) + } + } + for _, msg := range list.ChannelMessages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + out.Messages = append(out.Messages, item) + } + } + for _, u := range list.Users { + if viewerUserID != 0 && u.ID == viewerUserID { + out.Users = append(out.Users, tgSelfUser(u)) + } else { + out.Users = append(out.Users, tgUser(u)) + } + } + out.Chats = append(out.Chats, tgChannelsForDialogs(viewerUserID, list.Channels, list.Dialogs)...) + return out +} + +// tgDialogFolder 把归档摘要转成 dialogFolder#71bd134c 条目。当前未接 +// per-peer mute 状态,未读计数全部归入 unmuted 桶(TDesktop 用 unmuted +// 桶渲染亮色徽章,muted 桶渲染灰色,全归 unmuted 只影响徽章颜色不丢计数)。 +func tgDialogFolder(summary *domain.DialogArchiveSummary) tg.DialogClass { + if summary == nil { + return nil + } + peer := tgPeer(summary.TopPeer) + if peer == nil { + return nil + } + return &tg.DialogFolder{ + Pinned: summary.Pinned, + Folder: tg.Folder{ + ID: domain.DialogArchiveFolderID, + Title: "Archived Chats", + }, + Peer: peer, + TopMessage: summary.TopMessage, + UnreadUnmutedPeersCount: summary.UnreadPeersCount, + UnreadUnmutedMessagesCount: summary.UnreadMessagesCount, + } +} + +func tgDialog(d domain.Dialog) *tg.Dialog { + peer := tgPeer(d.Peer) + if peer == nil { + return nil + } + out := &tg.Dialog{ + Pinned: d.Pinned, + UnreadMark: d.UnreadMark, + ViewForumAsMessages: d.ViewForumAsMessages, + Peer: peer, + TopMessage: d.TopMessage, + ReadInboxMaxID: d.ReadInboxMaxID, + ReadOutboxMaxID: d.ReadOutboxMaxID, + UnreadCount: d.UnreadCount, + UnreadMentionsCount: d.UnreadMentions, + UnreadReactionsCount: d.UnreadReactions, + NotifySettings: *tgPeerNotifySettings(d.NotifySettings), + } + if d.FolderID != domain.DialogMainFolderID { + out.SetFolderID(d.FolderID) + } + if d.TTLPeriod > 0 { + out.SetTTLPeriod(d.TTLPeriod) + } + if d.Peer.Type == domain.PeerTypeChannel && d.Pts > 0 { + // 客户端用 dialog.pts 初始化 channel 本地序列;缺失会让 + // getChannelDifference 起点失效(冷启动 gap 不被发现)。 + out.SetPts(d.Pts) + } + if d.Draft != nil { + out.SetDraft(tgDialogDraft(*d.Draft)) + } + return out +} + +func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass { + if d.Empty() { + out := &tg.DraftMessageEmpty{} + out.SetDate(d.Date) + return out + } + out := &tg.DraftMessage{ + NoWebpage: d.NoWebpage, + InvertMedia: d.InvertMedia, + ReplyTo: tgDraftReplyTo(d), + Message: d.Message, + Entities: tgMessageEntities(d.Entities), + Media: tgDraftWebPage(d.WebPage), + Date: d.Date, + Effect: d.Effect, + } + return out +} + +func tgDraftReplyTo(d domain.DialogDraft) tg.InputReplyToClass { + if d.ReplyTo == nil { + return nil + } + reply := &tg.InputReplyToMessage{ReplyToMsgID: d.ReplyTo.MessageID} + if d.ReplyTo.TopMessageID > 0 { + reply.SetTopMsgID(d.ReplyTo.TopMessageID) + } + if d.ReplyTo.QuoteText != "" { + reply.SetQuoteText(d.ReplyTo.QuoteText) + } + if len(d.ReplyTo.QuoteEntities) > 0 { + reply.SetQuoteEntities(tgMessageEntities(d.ReplyTo.QuoteEntities)) + } + if d.ReplyTo.QuoteOffset > 0 { + reply.SetQuoteOffset(d.ReplyTo.QuoteOffset) + } + return reply +} + +func tgDraftWebPage(webpage *domain.DialogDraftWebPage) tg.InputMediaClass { + if webpage == nil || webpage.URL == "" { + return nil + } + return &tg.InputMediaWebPage{ + ForceLargeMedia: webpage.ForceLargeMedia, + ForceSmallMedia: webpage.ForceSmallMedia, + Optional: webpage.Optional, + URL: webpage.URL, + } +} + +func tgDialogPeer(p domain.Peer) tg.DialogPeerClass { + peer := tgPeer(p) + if peer == nil { + return nil + } + return &tg.DialogPeer{Peer: peer} +} + +func tgDialogPeers(peers []domain.Peer) []tg.DialogPeerClass { + out := make([]tg.DialogPeerClass, 0, len(peers)) + for _, peer := range peers { + item := tgDialogPeer(peer) + if item != nil { + out = append(out, item) + } + } + return out +} + +func tgDialogFilters(list domain.DialogFolderList) *tg.MessagesDialogFilters { + filters := make([]tg.DialogFilterClass, 0, len(list.Folders)+1) + filters = append(filters, &tg.DialogFilterDefault{}) + for _, folder := range list.Folders { + if item := tgDialogFilter(folder); item != nil { + filters = append(filters, item) + } + } + return &tg.MessagesDialogFilters{TagsEnabled: list.TagsEnabled, Filters: filters} +} + +func tgDialogFilter(folder domain.DialogFolder) tg.DialogFilterClass { + title := tg.TextWithEntities{Text: folder.Title, Entities: tgMessageEntities(folder.TitleEntities)} + if folder.IsChatlist { + out := &tg.DialogFilterChatlist{ + TitleNoanimate: folder.TitleNoanimate, + ID: folder.ID, + Title: title, + PinnedPeers: tgDialogFolderInputPeers(folder.PinnedPeers), + IncludePeers: tgDialogFolderInputPeers(folder.IncludePeers), + } + if folder.HasEmoticon { + out.SetEmoticon(folder.Emoticon) + } + if folder.HasColor { + out.SetColor(folder.Color) + } + return out + } + out := &tg.DialogFilter{ + Contacts: folder.Contacts, + NonContacts: folder.NonContacts, + Groups: folder.Groups, + Broadcasts: folder.Broadcasts, + Bots: folder.Bots, + ExcludeMuted: folder.ExcludeMuted, + ExcludeRead: folder.ExcludeRead, + ExcludeArchived: folder.ExcludeArchived, + TitleNoanimate: folder.TitleNoanimate, + ID: folder.ID, + Title: title, + PinnedPeers: tgDialogFolderInputPeers(folder.PinnedPeers), + IncludePeers: tgDialogFolderInputPeers(folder.IncludePeers), + ExcludePeers: tgDialogFolderInputPeers(folder.ExcludePeers), + } + if folder.HasEmoticon { + out.SetEmoticon(folder.Emoticon) + } + if folder.HasColor { + out.SetColor(folder.Color) + } + return out +} + +func tgDialogFolderInputPeers(peers []domain.DialogFolderPeer) []tg.InputPeerClass { + if len(peers) == 0 { + return nil + } + out := make([]tg.InputPeerClass, 0, len(peers)) + for _, item := range peers { + switch item.Peer.Type { + case domain.PeerTypeUser: + out = append(out, &tg.InputPeerUser{UserID: item.Peer.ID, AccessHash: item.AccessHash}) + case domain.PeerTypeChannel: + out = append(out, &tg.InputPeerChannel{ChannelID: item.Peer.ID, AccessHash: item.AccessHash}) + } + } + return out +} + +func tgFolderPeers(peers []domain.FolderPeerUpdate) []tg.FolderPeer { + out := make([]tg.FolderPeer, 0, len(peers)) + for _, item := range peers { + peer := tgPeer(item.Peer) + if peer == nil { + continue + } + out = append(out, tg.FolderPeer{Peer: peer, FolderID: item.FolderID}) + } + return out +} + +func tgChannelsForDialogs(viewerUserID int64, channels []domain.Channel, dialogs []domain.Dialog) []tg.ChatClass { + left := make(map[int64]struct{}) + for _, dialog := range dialogs { + if dialog.Peer.Type == domain.PeerTypeChannel && dialog.Peer.ID != 0 && dialog.ChannelLeft { + left[dialog.Peer.ID] = struct{}{} + } + } + out := make([]tg.ChatClass, 0, len(channels)) + seen := make(map[int64]struct{}, len(channels)) + for _, ch := range channels { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + var self *domain.ChannelMember + if _, ok := left[ch.ID]; ok { + self = &domain.ChannelMember{ + ChannelID: ch.ID, + UserID: viewerUserID, + Status: domain.ChannelMemberLeft, + } + } + out = append(out, tgChannelChat(viewerUserID, ch, self)) + } + return out +} diff --git a/internal/rpc/convert_encrypted.go b/internal/rpc/convert_encrypted.go new file mode 100644 index 00000000..6a9d2555 --- /dev/null +++ b/internal/rpc/convert_encrypted.go @@ -0,0 +1,116 @@ +package rpc + +import ( + "encoding/binary" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 密聊(Secret Chat)domain ↔ tg 投影。服务端是盲中继:g_a/g_b/key_fingerprint +// 原样回放。chat 双视角不对称(见 docs/secret-chat-module.md §3.6): +// - access_hash 双方不同(按 viewer 取 admin/participant 列)。 +// - GAOrB 对 admin 视角是 g_b、对 participant 视角是 g_a(TL 注释钉死)。 +// - pending 态:admin 看 encryptedChatWaiting(无 g_a),participant 看 +// encryptedChatRequested(携 g_a)。 + +// deviceAuthKeyBytes 把绑定维度的 int64 auth_key_id 转回 [8]byte(用于 SessionManager +// 按业务 auth_key 定向投递)。与 businessAuthKeyInt64 互逆(小端)。 +func deviceAuthKeyBytes(id int64) [8]byte { + var b [8]byte + binary.LittleEndian.PutUint64(b[:], uint64(id)) + return b +} + +// tgEncryptedMessage 把 qts 队列里的不透明加密消息投影为 TL EncryptedMessage(Service)。 +// 盲中继:Bytes 原样回放,不解密。 +func tgEncryptedMessage(m domain.SecretChatMessage) tg.EncryptedMessageClass { + if m.IsService { + return &tg.EncryptedMessageService{ + RandomID: m.RandomID, + ChatID: m.ChatID, + Date: m.Date, + Bytes: append([]byte(nil), m.Bytes...), + } + } + return &tg.EncryptedMessage{ + RandomID: m.RandomID, + ChatID: m.ChatID, + Date: m.Date, + Bytes: append([]byte(nil), m.Bytes...), + File: tgEncryptedFile(m.File), + } +} + +// tgEncryptedFile 把密聊文件快照投影为 TL EncryptedFile(Empty)。 +func tgEncryptedFile(ref *domain.EncryptedFileRef) tg.EncryptedFileClass { + if ref == nil || ref.ID == 0 { + return &tg.EncryptedFileEmpty{} + } + return &tg.EncryptedFile{ + ID: ref.ID, + AccessHash: ref.AccessHash, + Size: ref.Size, + DCID: ref.DCID, + KeyFingerprint: ref.KeyFingerprint, + } +} + +// businessAuthKeyInt64 把 ctx 业务视角 auth_key_id([8]byte)转成 DB/绑定维度的 +// int64。必须与 store/postgres authKeyIDToInt64 同源(小端,MTProto 即 SHA1 低 64 位), +// 否则密聊绑定键与 update_states/authorizations 对不上。一致性见 +// convert_encrypted_test.go。 +func businessAuthKeyInt64(id [8]byte) int64 { + return int64(binary.LittleEndian.Uint64(id[:])) +} + +// tgEncryptedChatForViewer 把密聊投影为 viewerID 视角的 EncryptedChatClass。 +func tgEncryptedChatForViewer(chat domain.SecretChat, viewerID int64) tg.EncryptedChatClass { + if chat.Terminal() { + return &tg.EncryptedChatDiscarded{ + HistoryDeleted: chat.HistoryDeleted, + ID: chat.ID, + } + } + accessHash := chat.AccessHashFor(viewerID) + if chat.State == domain.SecretChatStateNormal { + // GAOrB:admin 看 g_b、participant 看 g_a。 + gaOrB := chat.GA + if chat.IsAdmin(viewerID) { + gaOrB = chat.GB + } + return &tg.EncryptedChat{ + ID: chat.ID, + AccessHash: accessHash, + Date: chat.Date, + AdminID: chat.AdminUserID, + ParticipantID: chat.ParticipantUserID, + GAOrB: append([]byte(nil), gaOrB...), + KeyFingerprint: chat.KeyFingerprint, + } + } + // requested 态:admin 视角 waiting(无 g_a),participant 视角 requested(携 g_a)。 + if chat.IsAdmin(viewerID) { + return &tg.EncryptedChatWaiting{ + ID: chat.ID, + AccessHash: accessHash, + Date: chat.Date, + AdminID: chat.AdminUserID, + ParticipantID: chat.ParticipantUserID, + } + } + requested := &tg.EncryptedChatRequested{ + ID: chat.ID, + AccessHash: accessHash, + Date: chat.Date, + AdminID: chat.AdminUserID, + ParticipantID: chat.ParticipantUserID, + GA: append([]byte(nil), chat.GA...), + } + if chat.FolderID != 0 { + requested.FolderID = chat.FolderID + requested.Flags.Set(0) + } + return requested +} diff --git a/internal/rpc/convert_encrypted_test.go b/internal/rpc/convert_encrypted_test.go new file mode 100644 index 00000000..d60c35c0 --- /dev/null +++ b/internal/rpc/convert_encrypted_test.go @@ -0,0 +1,98 @@ +package rpc + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// TestBusinessAuthKeyInt64 钉死 [8]byte→int64 与 store/postgres authKeyIDToInt64 +// 同源(小端,MTProto 即 SHA1 低 64 位)。两者漂移会让密聊绑定键与 +// update_states/authorizations 对不上。 +func TestBusinessAuthKeyInt64(t *testing.T) { + cases := [][8]byte{ + {1, 0, 0, 0, 0, 0, 0, 0}, + {0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, + {0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04}, + } + for _, id := range cases { + want := int64(binary.LittleEndian.Uint64(id[:])) + if got := businessAuthKeyInt64(id); got != want { + t.Fatalf("businessAuthKeyInt64(%v) = %d, want %d (little-endian, 同 authKeyIDToInt64)", id, got, want) + } + } +} + +func baseChat() domain.SecretChat { + return domain.SecretChat{ + ID: 7, + AdminUserID: 1, + ParticipantUserID: 2, + AdminAccessHash: 111, + ParticipantAccessHash: 222, + GA: []byte{0x0a, 0x0b}, + GB: []byte{0x0c, 0x0d}, + KeyFingerprint: 99, + Date: 1000, + } +} + +func TestEncryptedChatForViewerRequested(t *testing.T) { + chat := baseChat() + chat.State = domain.SecretChatStateRequested + + // admin 视角:encryptedChatWaiting(无 g_a),access_hash=admin。 + adminView, ok := tgEncryptedChatForViewer(chat, 1).(*tg.EncryptedChatWaiting) + if !ok { + t.Fatalf("admin view type = %T, want *tg.EncryptedChatWaiting", tgEncryptedChatForViewer(chat, 1)) + } + if adminView.ID != 7 || adminView.AccessHash != 111 || adminView.AdminID != 1 || adminView.ParticipantID != 2 { + t.Fatalf("admin waiting view = %+v", adminView) + } + + // participant 视角:encryptedChatRequested(携 g_a),access_hash=participant。 + partView, ok := tgEncryptedChatForViewer(chat, 2).(*tg.EncryptedChatRequested) + if !ok { + t.Fatalf("participant view type = %T, want *tg.EncryptedChatRequested", tgEncryptedChatForViewer(chat, 2)) + } + if partView.AccessHash != 222 || !bytes.Equal(partView.GA, chat.GA) { + t.Fatalf("participant requested view = %+v", partView) + } +} + +func TestEncryptedChatForViewerNormal(t *testing.T) { + chat := baseChat() + chat.State = domain.SecretChatStateNormal + + // admin 视角:GAOrB = g_b。 + adminView, ok := tgEncryptedChatForViewer(chat, 1).(*tg.EncryptedChat) + if !ok { + t.Fatalf("admin view type = %T, want *tg.EncryptedChat", tgEncryptedChatForViewer(chat, 1)) + } + if adminView.AccessHash != 111 || !bytes.Equal(adminView.GAOrB, chat.GB) || adminView.KeyFingerprint != 99 { + t.Fatalf("admin normal view = %+v (GAOrB must be g_b)", adminView) + } + + // participant 视角:GAOrB = g_a。 + partView := tgEncryptedChatForViewer(chat, 2).(*tg.EncryptedChat) + if partView.AccessHash != 222 || !bytes.Equal(partView.GAOrB, chat.GA) { + t.Fatalf("participant normal view = %+v (GAOrB must be g_a)", partView) + } +} + +func TestEncryptedChatForViewerDiscarded(t *testing.T) { + chat := baseChat() + chat.State = domain.SecretChatStateDiscarded + chat.HistoryDeleted = true + view, ok := tgEncryptedChatForViewer(chat, 1).(*tg.EncryptedChatDiscarded) + if !ok { + t.Fatalf("discarded view type = %T", tgEncryptedChatForViewer(chat, 1)) + } + if view.ID != 7 || !view.HistoryDeleted { + t.Fatalf("discarded view = %+v", view) + } +} diff --git a/internal/rpc/convert_markup.go b/internal/rpc/convert_markup.go new file mode 100644 index 00000000..bc963e74 --- /dev/null +++ b/internal/rpc/convert_markup.go @@ -0,0 +1,125 @@ +package rpc + +import ( + "errors" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + + "telesrv/internal/domain" +) + +// P3 reply_markup 错误码(对齐官方)。 +func buttonDataInvalidErr() error { return tgerr.New(400, "BUTTON_DATA_INVALID") } +func buttonInvalidErr() error { return tgerr.New(400, "BUTTON_INVALID") } +func buttonURLInvalidErr() error { return tgerr.New(400, "BUTTON_URL_INVALID") } + +// replyMarkupErr 把 domain 校验错误映射为客户端错误码。 +func replyMarkupErr(err error) error { + switch { + case errors.Is(err, domain.ErrButtonDataInvalid): + return buttonDataInvalidErr() + case errors.Is(err, domain.ErrButtonURLInvalid): + return buttonURLInvalidErr() + case errors.Is(err, domain.ErrButtonInvalid), errors.Is(err, domain.ErrButtonTypeInvalid): + return buttonInvalidErr() + default: + return replyMarkupInvalidErr() + } +} + +// domainReplyMarkupForSender 解析入站 reply_markup。P3 语义: +// - 仅 bot 账号下发的 markup 被接受;非 bot 一律丢弃(返回 nil,不报错——对齐 +// 官方「普通用户 markup 无效」,I1)。 +// - 仅 ReplyInlineMarkup 被处理;reply keyboard 家族(自定义键盘/隐藏/强制回复) +// P3 不支持,静默丢弃(不报错,避免破坏 bot 发送;记 P4)。 +// - inline 行内按钮仅 callback / url;其它按钮类型(webview/game/url_auth/ +// request_* 等)→ ErrButtonTypeInvalid(拒绝整条发送,绝不半实现下发)。 +// - data≤64B、行/按钮上限、url https 由 domain.ValidateReplyMarkup 校验。 +func domainReplyMarkupForSender(markup tg.ReplyMarkupClass, senderIsBot bool) (*domain.MessageReplyMarkup, error) { + if markup == nil || !senderIsBot { + return nil, nil + } + inline, ok := markup.(*tg.ReplyInlineMarkup) + if !ok { + // reply keyboard / hide / force-reply:P3 不支持,丢弃。 + return nil, nil + } + parsed, err := domainInlineMarkup(inline) + if err != nil { + return nil, err + } + if parsed.IsZero() { + return nil, nil + } + if err := domain.ValidateReplyMarkup(parsed); err != nil { + return nil, err + } + return parsed, nil +} + +func domainInlineMarkup(inline *tg.ReplyInlineMarkup) (*domain.MessageReplyMarkup, error) { + out := &domain.MessageReplyMarkup{Inline: make([][]domain.MarkupButton, 0, len(inline.Rows))} + for _, row := range inline.Rows { + domainRow := make([]domain.MarkupButton, 0, len(row.Buttons)) + for _, btn := range row.Buttons { + db, err := domainMarkupButton(btn) + if err != nil { + return nil, err + } + domainRow = append(domainRow, db) + } + out.Inline = append(out.Inline, domainRow) + } + return out, nil +} + +func domainMarkupButton(btn tg.KeyboardButtonClass) (domain.MarkupButton, error) { + switch b := btn.(type) { + case *tg.KeyboardButtonCallback: + return domain.MarkupButton{ + Type: domain.MarkupButtonCallback, + Text: b.Text, + Data: append([]byte(nil), b.Data...), + RequiresPassword: b.RequiresPassword, + }, nil + case *tg.KeyboardButtonURL: + return domain.MarkupButton{ + Type: domain.MarkupButtonURL, + Text: b.Text, + URL: b.URL, + }, nil + default: + // webview/game/url_auth/request_*/switch_inline/buy 等 P3 未实现按钮类型。 + return domain.MarkupButton{}, domain.ErrButtonTypeInvalid + } +} + +// tgReplyMarkup 把存储的 inline keyboard 快照还原为 tg.ReplyInlineMarkup。 +func tgReplyMarkup(m *domain.MessageReplyMarkup) tg.ReplyMarkupClass { + if m.IsZero() { + return nil + } + rows := make([]tg.KeyboardButtonRow, 0, len(m.Inline)) + for _, row := range m.Inline { + buttons := make([]tg.KeyboardButtonClass, 0, len(row)) + for _, btn := range row { + buttons = append(buttons, tgMarkupButton(btn)) + } + rows = append(rows, tg.KeyboardButtonRow{Buttons: buttons}) + } + return &tg.ReplyInlineMarkup{Rows: rows} +} + +func tgMarkupButton(btn domain.MarkupButton) tg.KeyboardButtonClass { + switch btn.Type { + case domain.MarkupButtonURL: + return &tg.KeyboardButtonURL{Text: btn.Text, URL: btn.URL} + default: // callback + out := &tg.KeyboardButtonCallback{Text: btn.Text, Data: btn.Data} + if btn.RequiresPassword { + out.SetRequiresPassword(true) + } + return out + } +} diff --git a/internal/rpc/convert_media.go b/internal/rpc/convert_media.go index c4c71d08..6b4a9bb0 100644 --- a/internal/rpc/convert_media.go +++ b/internal/rpc/convert_media.go @@ -1,6 +1,8 @@ package rpc import ( + "strings" + "github.com/gotd/td/tg" "telesrv/internal/domain" @@ -39,11 +41,167 @@ func tgMessageMedia(m *domain.MessageMedia) tg.MessageMediaClass { out.TTLSeconds = m.TTLSeconds } return out + case domain.MessageMediaKindContact: + if m.Contact == nil { + return &tg.MessageMediaEmpty{} + } + return &tg.MessageMediaContact{ + PhoneNumber: m.Contact.PhoneNumber, + FirstName: m.Contact.FirstName, + LastName: m.Contact.LastName, + Vcard: m.Contact.Vcard, + UserID: m.Contact.UserID, + } + case domain.MessageMediaKindGeo: + if m.Geo == nil { + return &tg.MessageMediaEmpty{} + } + return &tg.MessageMediaGeo{Geo: tgGeoPoint(*m.Geo)} + case domain.MessageMediaKindVenue: + if m.Venue == nil { + return &tg.MessageMediaEmpty{} + } + return &tg.MessageMediaVenue{ + Geo: tgGeoPoint(m.Venue.Geo), + Title: m.Venue.Title, + Address: m.Venue.Address, + Provider: m.Venue.Provider, + VenueID: m.Venue.VenueID, + VenueType: m.Venue.VenueType, + } + case domain.MessageMediaKindDice: + if m.Dice == nil { + return &tg.MessageMediaEmpty{} + } + return &tg.MessageMediaDice{Value: m.Dice.Value, Emoticon: m.Dice.Emoticon} + case domain.MessageMediaKindPoll: + if m.Poll == nil { + return &tg.MessageMediaEmpty{} + } + out := &tg.MessageMediaPoll{Poll: tgPoll(*m.Poll), Results: tgPollResults(*m.Poll)} + if m.Poll.AttachedMedia != nil { + out.SetAttachedMedia(tgMessageMedia(m.Poll.AttachedMedia)) + } + return out + case domain.MessageMediaKindTodo: + if m.Todo == nil { + return &tg.MessageMediaEmpty{} + } + return tgTodoMedia(*m.Todo) + case domain.MessageMediaKindGeoLive: + if m.GeoLive == nil { + return &tg.MessageMediaEmpty{} + } + out := &tg.MessageMediaGeoLive{ + Geo: tgGeoPoint(m.GeoLive.Geo), + Period: m.GeoLive.Period, + } + if m.GeoLive.Heading > 0 { + out.SetHeading(m.GeoLive.Heading) + } + if m.GeoLive.ProximityNotificationRadius > 0 { + out.SetProximityNotificationRadius(m.GeoLive.ProximityNotificationRadius) + } + return out + case domain.MessageMediaKindStory: + if m.Story == nil { + return &tg.MessageMediaEmpty{} + } + peer := tgPeer(m.Story.Peer) + if peer == nil { + return &tg.MessageMediaEmpty{} + } + out := &tg.MessageMediaStory{ + ViaMention: m.Story.ViaMention, + Peer: peer, + ID: m.Story.ID, + } + if m.Story.Story != nil { + out.SetStory(tgStoryItem(*m.Story.Story)) + } + return out + case domain.MessageMediaKindWebPage: + if m.WebPage == nil { + return &tg.MessageMediaEmpty{} + } + return tgWebPageMedia(*m.WebPage) default: return &tg.MessageMediaEmpty{} } } +// tgWebPageMedia 把链接预览快照转成 messageMediaWebPage:外层 wrapper 携带 +// force_large/force_small/manual/safe 标志,内层按 state 投影为 +// webPagePending / webPage / webPageEmpty。 +func tgWebPageMedia(w domain.MessageWebPage) tg.MessageMediaClass { + return &tg.MessageMediaWebPage{ + ForceLargeMedia: w.ForceLargeMedia, + ForceSmallMedia: w.ForceSmallMedia, + Manual: w.Manual, + Safe: w.Safe, + Webpage: tgWebPage(w), + } +} + +// tgWebPage 按链接预览快照的 state 投影出对应的 tg.WebPageClass。done 形态必须 +// 始终填齐非可选的 id/url/display_url/hash,否则客户端不渲染卡片。 +func tgWebPage(w domain.MessageWebPage) tg.WebPageClass { + switch w.State { + case domain.MessageWebPageStateDone: + page := &tg.WebPage{ + ID: w.ID, + URL: w.URL, + DisplayURL: w.DisplayURL, + Hash: w.Hash, + HasLargeMedia: w.HasLargeMedia, + } + if w.Type != "" { + page.SetType(w.Type) + } + if w.SiteName != "" { + page.SetSiteName(w.SiteName) + } + if w.Title != "" { + page.SetTitle(w.Title) + } + if w.Description != "" { + page.SetDescription(w.Description) + } + if w.Author != "" { + page.SetAuthor(w.Author) + } + if w.Photo != nil { + if photo := tgPhoto(*w.Photo); photo != nil { + if _, empty := photo.(*tg.PhotoEmpty); !empty { + page.SetPhoto(photo) + } + } + } + return page + case domain.MessageWebPageStateEmpty: + page := &tg.WebPageEmpty{ID: w.ID} + if w.URL != "" { + page.SetURL(w.URL) + } + return page + default: // pending:webPagePending{id,date},url 可选但客户端需它展示待解析链接。 + page := &tg.WebPagePending{ID: w.ID, Date: w.Date} + if w.URL != "" { + page.SetURL(w.URL) + } + return page + } +} + +// tgGeoPoint 把 domain 坐标点转成 tg.GeoPoint。 +func tgGeoPoint(g domain.MessageGeoPoint) tg.GeoPointClass { + out := &tg.GeoPoint{Lat: g.Lat, Long: g.Long, AccessHash: g.AccessHash} + if g.AccuracyRadius > 0 { + out.SetAccuracyRadius(g.AccuracyRadius) + } + return out +} + // tgChatPhoto 由 domain.Channel 反范式头像字段构造 ChatPhoto(频道/群头像缩略)。 func tgChatPhoto(ch domain.Channel) tg.ChatPhotoClass { if ch.PhotoID == 0 { @@ -81,12 +239,17 @@ func tgPhoto(p domain.Photo) tg.PhotoClass { if p.ID == 0 { return &tg.PhotoEmpty{} } + sizes := tgPhotoSizes(p.Sizes) + if len(sizes) == 0 { + return &tg.PhotoEmpty{} + } return &tg.Photo{ ID: p.ID, AccessHash: p.AccessHash, FileReference: p.FileReference, Date: p.Date, - Sizes: tgPhotoSizes(p.Sizes), + Sizes: sizes, + VideoSizes: tgPhotoVideoSizes(p.Sizes), DCID: p.DCID, HasStickers: p.HasStickers, } @@ -145,6 +308,9 @@ func tgPhotoSizes(sizes []domain.PhotoSize) []tg.PhotoSizeClass { } out := make([]tg.PhotoSizeClass, 0, len(sizes)) for _, s := range sizes { + if isPhotoVideoSize(s.Kind) { + continue + } out = append(out, tgPhotoSize(s)) } return compactPhotoSizeClasses(out) @@ -167,6 +333,71 @@ func tgPhotoSize(s domain.PhotoSize) tg.PhotoSizeClass { } } +func tgPhotoVideoSizes(sizes []domain.PhotoSize) []tg.VideoSizeClass { + if len(sizes) == 0 { + return nil + } + out := make([]tg.VideoSizeClass, 0, len(sizes)) + for _, s := range sizes { + switch s.Kind { + case domain.PhotoSizeKindVideo: + v := &tg.VideoSize{Type: s.Type, W: s.W, H: s.H, Size: s.Size} + if s.VideoStartTs != 0 { + v.SetVideoStartTs(s.VideoStartTs) + } + out = append(out, v) + case domain.PhotoSizeKindVideoEmojiMarkup: + out = append(out, &tg.VideoSizeEmojiMarkup{ + EmojiID: s.EmojiID, + BackgroundColors: append([]int(nil), s.BackgroundColors...), + }) + case domain.PhotoSizeKindVideoStickerMarkup: + out = append(out, &tg.VideoSizeStickerMarkup{ + Stickerset: tgInputStickerSetFromPhotoSize(s), + StickerID: s.StickerID, + BackgroundColors: append([]int(nil), s.BackgroundColors...), + }) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func tgInputStickerSetFromPhotoSize(s domain.PhotoSize) tg.InputStickerSetClass { + if s.StickerSetID != 0 { + return &tg.InputStickerSetID{ID: s.StickerSetID, AccessHash: s.StickerSetAccessHash} + } + if s.StickerSetShortName != "" { + return &tg.InputStickerSetShortName{ShortName: s.StickerSetShortName} + } + if input, ok := tgInputStickerSetFromSystemKey(s.StickerSetSystemKey); ok { + return input + } + return &tg.InputStickerSetEmpty{} +} + +func tgInputStickerSetFromSystemKey(systemKey string) (tg.InputStickerSetClass, bool) { + switch systemKey { + case "animated_emoji": + return &tg.InputStickerSetAnimatedEmoji{}, true + case "animated_emoji_animations": + return &tg.InputStickerSetAnimatedEmojiAnimations{}, true + case "emoji_generic_animations": + return &tg.InputStickerSetEmojiGenericAnimations{}, true + default: + if strings.HasPrefix(systemKey, "dice:") { + return &tg.InputStickerSetDice{Emoticon: strings.TrimPrefix(systemKey, "dice:")}, true + } + return nil, false + } +} + +func isPhotoVideoSize(kind domain.PhotoSizeKind) bool { + return kind == domain.PhotoSizeKindVideo || kind == domain.PhotoSizeKindVideoEmojiMarkup || kind == domain.PhotoSizeKindVideoStickerMarkup +} + func compactPhotoSizeClasses(in []tg.PhotoSizeClass) []tg.PhotoSizeClass { out := in[:0] for _, s := range in { @@ -281,6 +512,64 @@ func reactionDocumentIDs(reactions []domain.AvailableReaction) []int64 { return out } +// tgAvailableEffects 构造 messages.availableEffects:effects 引用文档 id,文档对象在 +// 独立 Documents 数组(去重),docByID 由 handler 预加载。 +func tgAvailableEffects(effects []domain.AvailableEffect, docByID map[int64]domain.Document, hash int) *tg.MessagesAvailableEffects { + out := &tg.MessagesAvailableEffects{ + Hash: hash, + Effects: make([]tg.AvailableEffect, 0, len(effects)), + Documents: []tg.DocumentClass{}, + } + seenDoc := make(map[int64]struct{}) + addDoc := func(id int64) { + if id == 0 { + return + } + if _, ok := seenDoc[id]; ok { + return + } + if d, ok := docByID[id]; ok { + out.Documents = append(out.Documents, tgDocument(d)) + seenDoc[id] = struct{}{} + } + } + for _, e := range effects { + eff := tg.AvailableEffect{ + ID: e.ID, + Emoticon: e.Emoticon, + EffectStickerID: e.EffectStickerID, + PremiumRequired: e.PremiumRequired, + } + if e.StaticIconID != 0 { + eff.SetStaticIconID(e.StaticIconID) + } + if e.EffectAnimationID != 0 { + eff.SetEffectAnimationID(e.EffectAnimationID) + } + out.Effects = append(out.Effects, eff) + addDoc(e.StaticIconID) + addDoc(e.EffectStickerID) + addDoc(e.EffectAnimationID) + } + return out +} + +// effectDocumentIDs 收集一组 effect 引用的全部文档 id(用于批量预加载)。 +func effectDocumentIDs(effects []domain.AvailableEffect) []int64 { + seen := make(map[int64]struct{}) + out := make([]int64, 0, len(effects)*3) + for _, e := range effects { + for _, id := range e.DocumentIDs() { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + } + return out +} + // ---- sticker sets ---- func tgStickerSet(set domain.StickerSet) tg.StickerSet { @@ -367,6 +656,8 @@ func stickerSetRefFromInput(input tg.InputStickerSetClass) (domain.StickerSetRef return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "animated_emoji_animations"}, true case *tg.InputStickerSetEmojiGenericAnimations: return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "emoji_generic_animations"}, true + case *tg.InputStickerSetEmojiDefaultStatuses: + return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses}, true case *tg.InputStickerSetDice: return domain.StickerSetRef{Kind: domain.StickerSetRefBySystem, SystemKey: "dice:" + in.Emoticon}, true default: diff --git a/internal/rpc/convert_media_webpage_test.go b/internal/rpc/convert_media_webpage_test.go new file mode 100644 index 00000000..efa0b32b --- /dev/null +++ b/internal/rpc/convert_media_webpage_test.go @@ -0,0 +1,167 @@ +package rpc + +import ( + "encoding/json" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// jsonRoundTripMedia 复刻 postgres 媒体 codec(json.Marshal/Unmarshal)以验证 +// 新增的 web_page 字段经 JSONB 列字节级 round-trip 后 Kind 与载荷不丢失。 +func jsonRoundTripMedia(t *testing.T, m *domain.MessageMedia) *domain.MessageMedia { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal media: %v", err) + } + var out domain.MessageMedia + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("unmarshal media: %v", err) + } + if out.IsZero() { + t.Fatalf("web_page media decoded as zero (Kind lost): %s", raw) + } + return &out +} + +// TestTgMessageMediaWebPageDone 验证 done 形态投影为 messageMediaWebPage{webPage}, +// 字段齐全且能二进制编码(捕获非可选 id/url/display_url/hash 缺失)。 +func TestTgMessageMediaWebPageDone(t *testing.T) { + src := &domain.MessageMedia{ + Kind: domain.MessageMediaKindWebPage, + WebPage: &domain.MessageWebPage{ + State: domain.MessageWebPageStateDone, + ID: 0x1234abcd, + URL: "https://example.com/article", + DisplayURL: "example.com", + Hash: 42, + Type: "article", + SiteName: "Example", + Title: "An Example Article", + Description: "A description of the article.", + Author: "Jane Doe", + ForceLargeMedia: true, + HasLargeMedia: true, + Photo: &domain.Photo{ + ID: 0x99, + AccessHash: 0x55, + DCID: 2, + Sizes: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindDefault, Type: "x", W: 1280, H: 720, Size: 4096}, + }, + }, + }, + } + + got := tgMessageMedia(jsonRoundTripMedia(t, src)) + wrap, ok := got.(*tg.MessageMediaWebPage) + if !ok { + t.Fatalf("tgMessageMedia = %T, want *tg.MessageMediaWebPage", got) + } + if !wrap.ForceLargeMedia { + t.Errorf("ForceLargeMedia = false, want true") + } + page, ok := wrap.Webpage.(*tg.WebPage) + if !ok { + t.Fatalf("Webpage = %T, want *tg.WebPage", wrap.Webpage) + } + if page.ID != src.WebPage.ID || page.URL != src.WebPage.URL || page.DisplayURL != src.WebPage.DisplayURL || page.Hash != src.WebPage.Hash { + t.Errorf("non-optional fields mismatch: %+v", page) + } + if !page.HasLargeMedia { + t.Errorf("HasLargeMedia = false, want true") + } + if v, ok := page.GetSiteName(); !ok || v != "Example" { + t.Errorf("SiteName = %q (ok=%v), want Example", v, ok) + } + if v, ok := page.GetTitle(); !ok || v != "An Example Article" { + t.Errorf("Title = %q (ok=%v)", v, ok) + } + if v, ok := page.GetAuthor(); !ok || v != "Jane Doe" { + t.Errorf("Author = %q (ok=%v)", v, ok) + } + if photo, ok := page.GetPhoto(); !ok { + t.Errorf("GetPhoto ok=false, want embedded photo") + } else if _, isReal := photo.(*tg.Photo); !isReal { + t.Errorf("GetPhoto = %T, want *tg.Photo", photo) + } + + // 二进制编码确认非可选字段齐全(messageMediaWebPage.EncodeBare 会在 webpage + // 为 nil 或缺非可选字段时报错)。 + var buf bin.Buffer + if err := wrap.Encode(&buf); err != nil { + t.Fatalf("encode messageMediaWebPage: %v", err) + } +} + +// TestTgMessageMediaWebPagePending 验证 pending 形态投影为 webPagePending{id,url,date}。 +func TestTgMessageMediaWebPagePending(t *testing.T) { + src := &domain.MessageMedia{ + Kind: domain.MessageMediaKindWebPage, + WebPage: &domain.MessageWebPage{ + State: domain.MessageWebPageStatePending, + ID: 777, + URL: "https://pending.example/x", + Date: 1700000000, + }, + } + got := tgMessageMedia(jsonRoundTripMedia(t, src)) + wrap, ok := got.(*tg.MessageMediaWebPage) + if !ok { + t.Fatalf("tgMessageMedia = %T, want *tg.MessageMediaWebPage", got) + } + pending, ok := wrap.Webpage.(*tg.WebPagePending) + if !ok { + t.Fatalf("Webpage = %T, want *tg.WebPagePending", wrap.Webpage) + } + if pending.ID != 777 || pending.Date != 1700000000 { + t.Errorf("pending = %+v, want id=777 date=1700000000", pending) + } + if v, ok := pending.GetURL(); !ok || v != src.WebPage.URL { + t.Errorf("pending URL = %q (ok=%v)", v, ok) + } + var buf bin.Buffer + if err := wrap.Encode(&buf); err != nil { + t.Fatalf("encode pending: %v", err) + } +} + +// TestTgMessageMediaWebPageEmpty 验证 empty 形态投影为 webPageEmpty{id}。 +func TestTgMessageMediaWebPageEmpty(t *testing.T) { + src := &domain.MessageMedia{ + Kind: domain.MessageMediaKindWebPage, + WebPage: &domain.MessageWebPage{ + State: domain.MessageWebPageStateEmpty, + ID: 555, + URL: "https://empty.example/y", + }, + } + got := tgMessageMedia(jsonRoundTripMedia(t, src)) + wrap, ok := got.(*tg.MessageMediaWebPage) + if !ok { + t.Fatalf("tgMessageMedia = %T, want *tg.MessageMediaWebPage", got) + } + empty, ok := wrap.Webpage.(*tg.WebPageEmpty) + if !ok { + t.Fatalf("Webpage = %T, want *tg.WebPageEmpty", wrap.Webpage) + } + if empty.ID != 555 { + t.Errorf("empty.ID = %d, want 555", empty.ID) + } + var buf bin.Buffer + if err := wrap.Encode(&buf); err != nil { + t.Fatalf("encode empty: %v", err) + } +} + +// TestTgMessageMediaWebPageNilGuards 验证 nil WebPage 不会 panic 且回退空媒体。 +func TestTgMessageMediaWebPageNilGuards(t *testing.T) { + got := tgMessageMedia(&domain.MessageMedia{Kind: domain.MessageMediaKindWebPage}) + if _, ok := got.(*tg.MessageMediaEmpty); !ok { + t.Fatalf("nil WebPage = %T, want *tg.MessageMediaEmpty", got) + } +} diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go new file mode 100644 index 00000000..26ec5af5 --- /dev/null +++ b/internal/rpc/convert_messages.go @@ -0,0 +1,570 @@ +package rpc + +import ( + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func tgMessagesMessages(viewerUserID int64, list domain.MessageList) tg.MessagesMessagesClass { + messages := make([]tg.MessageClass, 0, len(list.Messages)) + for _, msg := range list.Messages { + if item := tgMessage(msg); item != nil { + messages = append(messages, item) + } + } + users := tgUsersForViewer(viewerUserID, list.Users) + if list.Count > len(messages) { + return &tg.MessagesMessagesSlice{ + Count: list.Count, + Messages: messages, + Users: users, + } + } + return &tg.MessagesMessages{ + Messages: messages, + Users: users, + } +} + +func tgMessage(m domain.Message) tg.MessageClass { + peer := tgPeer(m.Peer) + if peer == nil || m.ID == 0 { + return nil + } + if action := tgMessageServiceAction(m); action != nil { + msg := &tg.MessageService{ + Out: m.Out, + MediaUnread: m.MediaUnread, + Silent: m.Silent, + ID: m.ID, + PeerID: peer, + Date: m.Date, + Action: action, + } + if from := tgPeer(m.From); from != nil { + msg.FromID = from + } + if reply := tgMessageReplyHeader(m); reply != nil { + msg.SetReplyTo(reply) + } + if m.TTLPeriod > 0 { + msg.SetTTLPeriod(m.TTLPeriod) + } + if reactions := tgMessageReactions(m.OwnerUserID, m.Reactions); reactions != nil { + msg.SetReactions(*reactions) + } + return msg + } + msg := &tg.Message{ + Out: m.Out, + MediaUnread: m.MediaUnread, + ID: m.ID, + PeerID: peer, + Date: m.Date, + Message: m.Body, + Entities: tgMessageEntities(m.Entities), + } + if m.EditDate != 0 { + msg.SetEditDate(m.EditDate) + } + if m.Silent { + msg.SetSilent(true) + } + if m.NoForwards { + msg.SetNoforwards(true) + } + if m.Pinned { + msg.SetPinned(true) + } + if reply := tgMessageReplyHeader(m); reply != nil { + msg.SetReplyTo(reply) + } + if fwd := tgMessageFwdHeader(m.Forward); fwd != nil { + msg.SetFwdFrom(*fwd) + } + // self-chat 消息恒带 saved_peer_id(收藏夹子会话分组键):TDesktop 不做 + // 本地 fwd 推导,缺失会把转发消息错归 My Notes。 + if m.SavedPeer.ID != 0 { + if saved := tgPeer(m.SavedPeer); saved != nil { + msg.SetSavedPeerID(saved) + } + } + if from := tgPeer(m.From); from != nil { + msg.FromID = from + } + if m.ViaBotID != 0 { + msg.SetViaBotID(m.ViaBotID) + } + if m.GroupedID != 0 { + msg.SetGroupedID(m.GroupedID) + } + // 消息特效(私聊专属):非零则下发,客户端据此播放一次动画。 + if m.Effect != 0 { + msg.SetEffect(m.Effect) + } + if !m.Media.IsZero() { + msg.SetMedia(tgMessageMedia(m.Media)) + // invert_media 是 message 级标志,但存于媒体快照(免消息表列):仅当有媒体时投影。 + if m.Media.InvertMedia { + msg.SetInvertMedia(true) + } + } + // reply_markup(bot inline keyboard):仅普通 tg.Message 携带(service 消息不带)。 + if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { + msg.SetReplyMarkup(markup) + } + // rich_message(Layer 227 富文本消息):best-effort 投影;blocks 解码失败则略过 + // (tgMessage 无 error 返回,corrupt blob 不应拖垮整条消息投影)。 + if rich, err := tgRichMessage(m.RichMessage); err == nil && rich != nil { + msg.SetRichMessage(*rich) + } + if m.TTLPeriod > 0 { + msg.SetTTLPeriod(m.TTLPeriod) + } + if reactions := tgMessageReactions(m.OwnerUserID, m.Reactions); reactions != nil { + msg.SetReactions(*reactions) + } + return msg +} + +func tgMessageServiceAction(msg domain.Message) tg.MessageActionClass { + m := msg.Media + if m == nil || m.Kind != domain.MessageMediaKindService || m.ServiceAction == nil { + return nil + } + switch m.ServiceAction.Kind { + case domain.MessageServiceActionSuggestProfilePhoto: + if m.ServiceAction.Photo == nil || m.ServiceAction.Photo.ID == 0 { + return &tg.MessageActionEmpty{} + } + return &tg.MessageActionSuggestProfilePhoto{Photo: tgPhoto(*m.ServiceAction.Photo)} + case domain.MessageServiceActionPinMessage: + return &tg.MessageActionPinMessage{} + case domain.MessageServiceActionSetChatTheme: + return &tg.MessageActionSetChatTheme{ + Theme: &tg.ChatTheme{Emoticon: m.ServiceAction.ChatThemeEmoticon}, + } + case domain.MessageServiceActionPhoneCall: + if m.ServiceAction.Call == nil { + return &tg.MessageActionEmpty{} + } + action := &tg.MessageActionPhoneCall{ + Video: m.ServiceAction.Call.Video, + CallID: m.ServiceAction.Call.CallID, + } + if reason := tgPhoneCallDiscardReason(domain.PhoneCallDiscardReason(m.ServiceAction.Call.Reason)); reason != nil { + action.SetReason(reason) + } + if m.ServiceAction.Call.Duration > 0 { + action.SetDuration(m.ServiceAction.Call.Duration) + } + return action + case domain.MessageServiceActionBotAllowed: + allowed := m.ServiceAction.BotAllowed + if allowed == nil { + return &tg.MessageActionBotAllowed{} + } + return &tg.MessageActionBotAllowed{ + AttachMenu: allowed.AttachMenu, + FromRequest: allowed.FromRequest, + Domain: allowed.Domain, + } + case domain.MessageServiceActionWebViewDataSent: + data := m.ServiceAction.WebViewData + if data == nil { + return &tg.MessageActionEmpty{} + } + if msg.Out { + return &tg.MessageActionWebViewDataSent{Text: data.ButtonText} + } + return &tg.MessageActionWebViewDataSentMe{ + Text: data.ButtonText, + Data: data.Data, + } + case domain.MessageServiceActionRequestedPeer: + shared := m.ServiceAction.RequestedPeer + if shared == nil { + return &tg.MessageActionEmpty{} + } + if msg.Out { + return &tg.MessageActionRequestedPeerSentMe{ + ButtonID: shared.ButtonID, + Peers: tgRequestedPeers(shared.Peers), + } + } + return &tg.MessageActionRequestedPeer{ + ButtonID: shared.ButtonID, + Peers: tgPeerList(shared.Peers), + } + case domain.MessageServiceActionStarGift: + return tgMessageActionStarGift(m.ServiceAction.StarGift) + default: + return &tg.MessageActionEmpty{} + } +} + +func tgPeerList(peers []domain.Peer) []tg.PeerClass { + out := make([]tg.PeerClass, 0, len(peers)) + for _, peer := range peers { + if converted := tgPeer(peer); converted != nil { + out = append(out, converted) + } + } + return out +} + +func tgRequestedPeers(peers []domain.Peer) []tg.RequestedPeerClass { + out := make([]tg.RequestedPeerClass, 0, len(peers)) + for _, peer := range peers { + switch peer.Type { + case domain.PeerTypeUser: + out = append(out, &tg.RequestedPeerUser{UserID: peer.ID}) + case domain.PeerTypeChannel: + out = append(out, &tg.RequestedPeerChannel{ChannelID: peer.ID}) + } + } + return out +} + +func tgMessagesDiscussionMessage(viewerUserID int64, in domain.ChannelDiscussionMessage) *tg.MessagesDiscussionMessage { + messages := make([]tg.MessageClass, 0, len(in.Messages)) + for _, msg := range in.Messages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + channels := make([]domain.Channel, 0, 2+len(in.Channels)) + if in.PostChannel.ID != 0 { + channels = append(channels, in.PostChannel) + } + if in.DiscussionChannel.ID != 0 && in.DiscussionChannel.ID != in.PostChannel.ID { + channels = append(channels, in.DiscussionChannel) + } + channels = append(channels, in.Channels...) + out := &tg.MessagesDiscussionMessage{ + Messages: messages, + Chats: tgChannels(viewerUserID, channels), + Users: tgUsers(in.Users), + UnreadCount: in.UnreadCount, + } + if in.MaxID > 0 { + out.SetMaxID(in.MaxID) + } + if in.ReadInboxMaxID > 0 { + out.SetReadInboxMaxID(in.ReadInboxMaxID) + } + if in.ReadOutboxMaxID > 0 { + out.SetReadOutboxMaxID(in.ReadOutboxMaxID) + } + return out +} + +func tgMessageReplyHeader(m domain.Message) tg.MessageReplyHeaderClass { + if m.ReplyTo == nil { + return nil + } + // story 回复(评论)投影为独立的 messageReplyStoryHeader(peer=story 作者 + story_id)。 + if m.ReplyTo.StoryID > 0 { + peer := tgPeer(m.ReplyTo.Peer) + if peer == nil { + return nil + } + return &tg.MessageReplyStoryHeader{Peer: peer, StoryID: m.ReplyTo.StoryID} + } + if m.ReplyTo.MessageID <= 0 && m.ReplyTo.TopMessageID <= 0 { + return nil + } + header := &tg.MessageReplyHeader{} + if m.ReplyTo.ForumTopic { + header.SetForumTopic(true) + } + if m.ReplyTo.MessageID > 0 { + header.SetReplyToMsgID(m.ReplyTo.MessageID) + } + if m.ReplyTo.TopMessageID > 0 { + header.SetReplyToTopID(m.ReplyTo.TopMessageID) + } + if m.ReplyTo.Peer.ID != 0 && m.ReplyTo.Peer != m.Peer { + if peer := tgPeer(m.ReplyTo.Peer); peer != nil { + header.SetReplyToPeerID(peer) + } + } + if m.ReplyTo.QuoteText != "" { + header.SetQuote(true) + header.SetQuoteText(m.ReplyTo.QuoteText) + header.SetQuoteEntities(tgMessageEntities(m.ReplyTo.QuoteEntities)) + header.SetQuoteOffset(m.ReplyTo.QuoteOffset) + } + return header +} + +func tgMessageFwdHeader(fwd *domain.MessageForward) *tg.MessageFwdHeader { + if fwd == nil || (fwd.Date == 0 && fwd.From.ID == 0 && fwd.FromName == "" && fwd.ChannelPost == 0 && fwd.SavedFrom.ID == 0 && fwd.SavedFromMsgID == 0) { + return nil + } + header := &tg.MessageFwdHeader{Date: fwd.Date} + if peer := tgPeer(fwd.From); peer != nil { + header.SetFromID(peer) + } + if fwd.FromName != "" { + header.SetFromName(fwd.FromName) + } + if fwd.ChannelPost > 0 { + header.SetChannelPost(fwd.ChannelPost) + } + if peer := tgPeer(fwd.SavedFrom); peer != nil { + header.SetSavedFromPeer(peer) + } + if fwd.SavedFromMsgID > 0 { + header.SetSavedFromMsgID(fwd.SavedFromMsgID) + } + return header +} + +func tgMessageReactions(viewerUserID int64, in *domain.ChannelMessageReactions) *tg.MessageReactions { + if in == nil { + return nil + } + out := &tg.MessageReactions{ + Results: make([]tg.ReactionCount, 0, len(in.Results)), + } + if in.CanSeeList { + out.SetCanSeeList(true) + } + for _, item := range in.Results { + reaction := tgMessageReaction(item.Reaction) + if reaction == nil || item.Count <= 0 { + continue + } + count := tg.ReactionCount{ + Reaction: reaction, + Count: item.Count, + } + if item.ChosenOrder > 0 { + count.SetChosenOrder(item.ChosenOrder) + } + out.Results = append(out.Results, count) + } + if len(in.Recent) > 0 { + recent := make([]tg.MessagePeerReaction, 0, len(in.Recent)) + for _, item := range in.Recent { + if converted := tgMessagePeerReaction(viewerUserID, item); converted != nil { + recent = append(recent, *converted) + } + } + if len(recent) > 0 { + out.SetRecentReactions(recent) + } + } + // 付费 reaction:注入 ReactionPaid 计数 + top reactors(My/chosen 由 in.Paid 的视角数据驱动, + // 调用方对他人视角已抹除 My/MyStars)。统一在此注入,覆盖所有频道消息读路径。 + if in.Paid != nil { + injectPaidReaction(out, *in.Paid) + } + if out.Results == nil { + out.Results = []tg.ReactionCount{} + } + return out +} + +func tgMessagePeerReaction(viewerUserID int64, in domain.ChannelMessagePeerReaction) *tg.MessagePeerReaction { + reaction := tgMessageReaction(in.Reaction) + if reaction == nil || in.UserID == 0 { + return nil + } + out := &tg.MessagePeerReaction{ + PeerID: &tg.PeerUser{UserID: in.UserID}, + Date: in.Date, + Reaction: reaction, + } + if in.Big { + out.SetBig(true) + } + if in.Unread && in.SenderUserID == viewerUserID { + out.SetUnread(true) + } + if in.My || in.UserID == viewerUserID { + out.SetMy(true) + } + return out +} + +func tgMessageReaction(in domain.MessageReaction) tg.ReactionClass { + switch in.Type { + case domain.MessageReactionEmoji: + if in.Emoticon == "" { + return nil + } + return &tg.ReactionEmoji{Emoticon: in.Emoticon} + case domain.MessageReactionCustomEmoji: + if in.DocumentID <= 0 { + return nil + } + return &tg.ReactionCustomEmoji{DocumentID: in.DocumentID} + default: + return nil + } +} + +func tgMessageEntities(entities []domain.MessageEntity) []tg.MessageEntityClass { + if len(entities) == 0 { + return nil + } + out := make([]tg.MessageEntityClass, 0, len(entities)) + for _, entity := range entities { + switch entity.Type { + case domain.MessageEntityBold: + out = append(out, &tg.MessageEntityBold{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityItalic: + out = append(out, &tg.MessageEntityItalic{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityUnderline: + out = append(out, &tg.MessageEntityUnderline{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityStrike: + out = append(out, &tg.MessageEntityStrike{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityCode: + out = append(out, &tg.MessageEntityCode{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityPre: + out = append(out, &tg.MessageEntityPre{Offset: entity.Offset, Length: entity.Length, Language: entity.Language}) + case domain.MessageEntityTextURL: + out = append(out, &tg.MessageEntityTextURL{Offset: entity.Offset, Length: entity.Length, URL: entity.URL}) + case domain.MessageEntityMentionName: + out = append(out, &tg.MessageEntityMentionName{Offset: entity.Offset, Length: entity.Length, UserID: entity.UserID}) + case domain.MessageEntitySpoiler: + out = append(out, &tg.MessageEntitySpoiler{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityBlockquote: + out = append(out, &tg.MessageEntityBlockquote{Offset: entity.Offset, Length: entity.Length, Collapsed: entity.Collapsed}) + case domain.MessageEntityCustomEmoji: + out = append(out, &tg.MessageEntityCustomEmoji{Offset: entity.Offset, Length: entity.Length, DocumentID: entity.DocumentID}) + case domain.MessageEntityMention: + out = append(out, &tg.MessageEntityMention{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityHashtag: + out = append(out, &tg.MessageEntityHashtag{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityCashtag: + out = append(out, &tg.MessageEntityCashtag{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityBotCommand: + out = append(out, &tg.MessageEntityBotCommand{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityURL: + out = append(out, &tg.MessageEntityURL{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityEmail: + out = append(out, &tg.MessageEntityEmail{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityPhone: + out = append(out, &tg.MessageEntityPhone{Offset: entity.Offset, Length: entity.Length}) + case domain.MessageEntityBankCard: + out = append(out, &tg.MessageEntityBankCard{Offset: entity.Offset, Length: entity.Length}) + } + } + return out +} + +func domainMessageEntities(entities []tg.MessageEntityClass) []domain.MessageEntity { + return domainMessageEntitiesForViewer(0, entities) +} + +// domainMessageEntitiesForViewer 把客户端实体转成 domain 形态并原样保留语义 +// 字段;inputMessageEntityMentionName 携带 InputUser,inputUserSelf 解析为 +// viewerUserID(viewer 未知时该实体丢弃而不是落成 user_id=0)。 +func domainMessageEntitiesForViewer(viewerUserID int64, entities []tg.MessageEntityClass) []domain.MessageEntity { + if len(entities) == 0 { + return nil + } + out := make([]domain.MessageEntity, 0, len(entities)) + for _, entity := range entities { + switch e := entity.(type) { + case *tg.MessageEntityBold: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityBold, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityItalic: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityItalic, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityUnderline: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityUnderline, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityStrike: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityStrike, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityCode: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityCode, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityPre: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityPre, Offset: e.Offset, Length: e.Length, Language: e.Language}) + case *tg.MessageEntityTextURL: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityTextURL, Offset: e.Offset, Length: e.Length, URL: e.URL}) + case *tg.MessageEntityMentionName: + if e.UserID != 0 { + out = append(out, domain.MessageEntity{Type: domain.MessageEntityMentionName, Offset: e.Offset, Length: e.Length, UserID: e.UserID}) + } + case *tg.InputMessageEntityMentionName: + userID := int64(0) + switch input := e.UserID.(type) { + case *tg.InputUser: + userID = input.UserID + case *tg.InputUserSelf: + userID = viewerUserID + } + if userID != 0 { + out = append(out, domain.MessageEntity{Type: domain.MessageEntityMentionName, Offset: e.Offset, Length: e.Length, UserID: userID}) + } + case *tg.MessageEntitySpoiler: + out = append(out, domain.MessageEntity{Type: domain.MessageEntitySpoiler, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityBlockquote: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityBlockquote, Offset: e.Offset, Length: e.Length, Collapsed: e.Collapsed}) + case *tg.MessageEntityCustomEmoji: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityCustomEmoji, Offset: e.Offset, Length: e.Length, DocumentID: e.DocumentID}) + case *tg.MessageEntityMention: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityMention, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityHashtag: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityHashtag, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityCashtag: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityCashtag, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityBotCommand: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityBotCommand, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityURL: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityURL, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityEmail: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityEmail, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityPhone: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityPhone, Offset: e.Offset, Length: e.Length}) + case *tg.MessageEntityBankCard: + out = append(out, domain.MessageEntity{Type: domain.MessageEntityBankCard, Offset: e.Offset, Length: e.Length}) + } + } + return out +} + +func tgPeer(p domain.Peer) tg.PeerClass { + switch p.Type { + case domain.PeerTypeUser: + return &tg.PeerUser{UserID: p.ID} + case domain.PeerTypeChannel: + return &tg.PeerChannel{ChannelID: p.ID} + default: + return nil + } +} + +func tgMigratedLegacyChat(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember) *tg.Chat { + if ch.ID == 0 || ch.Deleted { + return nil + } + out := &tg.Chat{ + Creator: ch.CreatorUserID == viewerUserID && viewerUserID != 0, + Deactivated: true, + Noforwards: ch.NoForwards, + ID: ch.ID, + Title: ch.Title, + Photo: tgChatPhoto(ch), + ParticipantsCount: ch.ParticipantsCount, + Date: ch.Date, + Version: ch.Pts, + } + out.SetMigratedTo(&tg.InputChannel{ChannelID: ch.ID, AccessHash: ch.AccessHash}) + if self != nil { + if self.Status == domain.ChannelMemberLeft { + out.Left = true + } + switch self.Role { + case domain.ChannelRoleCreator: + out.Creator = true + out.SetAdminRights(tgChatAdminRights(self.AdminRights)) + case domain.ChannelRoleAdmin: + out.SetAdminRights(tgChatAdminRights(self.AdminRights)) + } + } + out.SetDefaultBannedRights(tgDefaultChatBannedRights(ch.DefaultBannedRights)) + return out +} diff --git a/internal/rpc/convert_misc.go b/internal/rpc/convert_misc.go new file mode 100644 index 00000000..15d1d330 --- /dev/null +++ b/internal/rpc/convert_misc.go @@ -0,0 +1,195 @@ +package rpc + +import ( + "encoding/json" + "github.com/gotd/td/tg" + "sort" + "telesrv/internal/domain" +) + +func tgLangPackStrings(items []domain.LangPackString) []tg.LangPackStringClass { + out := make([]tg.LangPackStringClass, 0, len(items)) + for _, item := range items { + if item.Deleted { + out = append(out, &tg.LangPackStringDeleted{Key: item.Key}) + continue + } + if item.Pluralized { + out = append(out, &tg.LangPackStringPluralized{ + Key: item.Key, + ZeroValue: item.ZeroValue, + OneValue: item.OneValue, + TwoValue: item.TwoValue, + FewValue: item.FewValue, + ManyValue: item.ManyValue, + OtherValue: item.OtherValue, + }) + continue + } + out = append(out, &tg.LangPackString{Key: item.Key, Value: item.Value}) + } + return out +} + +func tgPassword(settings domain.PasswordSettings) *tg.AccountPassword { + if len(settings.SecureRandom) == 0 { + settings.SecureRandom = []byte("telesrv-tdesktop-dev-secure-rand") + } + out := &tg.AccountPassword{ + HasRecovery: settings.HasRecovery, + HasSecureValues: settings.HasSecureValues, + HasPassword: settings.HasPassword, + Hint: settings.Hint, + EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern, + NewAlgo: tgPasswordAlgo(settings.NewAlgo), + NewSecureAlgo: tgSecurePasswordAlgo(settings.NewSecureAlgo), + SecureRandom: settings.SecureRandom, + LoginEmailPattern: settings.LoginEmailPattern, + } + if settings.HasPassword && settings.CurrentAlgo != nil { + out.CurrentAlgo = tgPasswordAlgo(*settings.CurrentAlgo) + out.SRPB = append([]byte(nil), settings.SRPB...) + out.SRPID = settings.SRPID + } + if settings.PendingResetDate != 0 { + out.PendingResetDate = settings.PendingResetDate + } + return out +} + +func tgPasswordAlgo(algo domain.PasswordKDFAlgo) tg.PasswordKdfAlgoClass { + if len(algo.P) == 0 || algo.G == 0 { + return &tg.PasswordKdfAlgoUnknown{} + } + return &tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow{ + Salt1: append([]byte(nil), algo.Salt1...), + Salt2: append([]byte(nil), algo.Salt2...), + G: algo.G, + P: append([]byte(nil), algo.P...), + } +} + +func domainPasswordAlgo(in tg.PasswordKdfAlgoClass) (*domain.PasswordKDFAlgo, bool) { + if algo, ok := in.(*tg.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow); ok { + return &domain.PasswordKDFAlgo{ + Salt1: append([]byte(nil), algo.Salt1...), + Salt2: append([]byte(nil), algo.Salt2...), + G: algo.G, + P: append([]byte(nil), algo.P...), + }, true + } + return nil, false +} + +func tgSecurePasswordAlgo(algo domain.SecurePasswordKDFAlgo) tg.SecurePasswordKdfAlgoClass { + if algo.Kind == "pbkdf2_hmac_sha512_iter100000" { + return &tg.SecurePasswordKdfAlgoPBKDF2HMACSHA512iter100000{Salt: append([]byte(nil), algo.Salt...)} + } + return &tg.SecurePasswordKdfAlgoUnknown{} +} + +func domainPasswordCheck(in tg.InputCheckPasswordSRPClass) domain.PasswordCheck { + if srp, ok := in.(*tg.InputCheckPasswordSRP); ok { + return domain.PasswordCheck{ + SRPID: srp.SRPID, + A: append([]byte(nil), srp.A...), + M1: append([]byte(nil), srp.M1...), + } + } + return domain.PasswordCheck{Empty: true} +} + +func domainPasswordInputSettings(in tg.AccountPasswordInputSettings) (domain.PasswordInputSettings, error) { + out := domain.PasswordInputSettings{} + if algo, ok := in.GetNewAlgo(); ok { + domainAlgo, ok := domainPasswordAlgo(algo) + if !ok { + return out, passwordHashInvalidErr() + } + out.NewAlgo = domainAlgo + out.NewPasswordHash = append([]byte(nil), in.NewPasswordHash...) + out.Hint = in.Hint + out.HasHint = true + } + if email, ok := in.GetEmail(); ok { + out.Email = email + out.HasEmail = true + } + return out, nil +} + +func tgPasswordSettings(settings domain.PrivatePasswordSettings) *tg.AccountPasswordSettings { + out := &tg.AccountPasswordSettings{} + if settings.Email != "" { + out.Email = settings.Email + } + return out +} + +func tgCountriesList(list domain.CountriesList) tg.HelpCountriesListClass { + out := &tg.HelpCountriesList{ + Hash: list.Hash, + Countries: make([]tg.HelpCountry, 0, len(list.Countries)), + } + for _, country := range list.Countries { + item := tg.HelpCountry{ + Hidden: country.Hidden, + ISO2: country.ISO2, + DefaultName: country.DefaultName, + Name: country.Name, + CountryCodes: make([]tg.HelpCountryCode, 0, len(country.CountryCodes)), + } + for _, code := range country.CountryCodes { + item.CountryCodes = append(item.CountryCodes, tg.HelpCountryCode{ + CountryCode: code.CountryCode, + Prefixes: code.Prefixes, + Patterns: code.Patterns, + }) + } + out.Countries = append(out.Countries, item) + } + return out +} + +func tgJSONValue(data []byte) tg.JSONValueClass { + if len(data) == 0 { + return &tg.JSONObject{} + } + var v any + if err := json.Unmarshal(data, &v); err != nil { + return &tg.JSONObject{} + } + return tgJSON(v) +} + +func tgJSON(v any) tg.JSONValueClass { + switch x := v.(type) { + case nil: + return &tg.JSONNull{} + case bool: + return &tg.JSONBool{Value: x} + case float64: + return &tg.JSONNumber{Value: x} + case string: + return &tg.JSONString{Value: x} + case []any: + arr := &tg.JSONArray{Value: make([]tg.JSONValueClass, 0, len(x))} + for _, item := range x { + arr.Value = append(arr.Value, tgJSON(item)) + } + return arr + case map[string]any: + obj := &tg.JSONObject{Value: make([]tg.JSONObjectValue, 0, len(x))} + keys := make([]string, 0, len(x)) + for key := range x { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + obj.Value = append(obj.Value, tg.JSONObjectValue{Key: key, Value: tgJSON(x[key])}) + } + return obj + default: + return &tg.JSONString{Value: ""} + } +} diff --git a/internal/rpc/convert_passkey.go b/internal/rpc/convert_passkey.go new file mode 100644 index 00000000..4c114d15 --- /dev/null +++ b/internal/rpc/convert_passkey.go @@ -0,0 +1,82 @@ +package rpc + +import ( + "encoding/base64" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// decodePasskeyID 容忍有/无填充地 base64url 解码 credential id(取第一个可解的非空值)。 +func decodePasskeyID(vals ...string) ([]byte, bool) { + for _, v := range vals { + if v == "" { + continue + } + if b, err := base64.RawURLEncoding.DecodeString(v); err == nil && len(b) > 0 { + return b, true + } + if b, err := base64.URLEncoding.DecodeString(v); err == nil && len(b) > 0 { + return b, true + } + } + return nil, false +} + +func passkeyPublicKeyCredential(cred tg.InputPasskeyCredentialClass) (*tg.InputPasskeyCredentialPublicKey, bool) { + pk, ok := cred.(*tg.InputPasskeyCredentialPublicKey) + return pk, ok +} + +// passkeyLoginFromCredential 从 inputPasskeyCredentialPublicKey 提取登录断言字段。 +func passkeyLoginFromCredential(cred tg.InputPasskeyCredentialClass) (credID []byte, login *tg.InputPasskeyResponseLogin, ok bool) { + pk, ok := passkeyPublicKeyCredential(cred) + if !ok { + return nil, nil, false + } + login, ok = pk.Response.(*tg.InputPasskeyResponseLogin) + if !ok { + return nil, nil, false + } + credID, ok = decodePasskeyID(pk.RawID, pk.ID) + if !ok { + return nil, nil, false + } + return credID, login, true +} + +// passkeyRegisterFromCredential 从 inputPasskeyCredentialPublicKey 提取注册 attestation 字段。 +func passkeyRegisterFromCredential(cred tg.InputPasskeyCredentialClass) (credID []byte, reg *tg.InputPasskeyResponseRegister, ok bool) { + pk, ok := passkeyPublicKeyCredential(cred) + if !ok { + return nil, nil, false + } + reg, ok = pk.Response.(*tg.InputPasskeyResponseRegister) + if !ok { + return nil, nil, false + } + credID, ok = decodePasskeyID(pk.RawID, pk.ID) + if !ok { + return nil, nil, false + } + return credID, reg, true +} + +// tgPasskey 把领域凭据投影为 tg.Passkey(ID 用 base64url)。 +func tgPasskey(c domain.PasskeyCredential) tg.Passkey { + return tg.Passkey{ + ID: base64.RawURLEncoding.EncodeToString(c.CredentialID), + Name: c.Name, + Date: int(c.CreatedAt), + LastUsageDate: int(c.LastUsedAt), + } +} + +func tgPasskeys(creds []domain.PasskeyCredential) []tg.Passkey { + out := make([]tg.Passkey, 0, len(creds)) + for _, c := range creds { + out = append(out, tgPasskey(c)) + } + return out +} diff --git a/internal/rpc/convert_phone.go b/internal/rpc/convert_phone.go new file mode 100644 index 00000000..1616f0e4 --- /dev/null +++ b/internal/rpc/convert_phone.go @@ -0,0 +1,190 @@ +package rpc + +import ( + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// tgPhoneCallProtocol 把协商参数转回 TL。 +func tgPhoneCallProtocol(p domain.PhoneCallProtocol) tg.PhoneCallProtocol { + return tg.PhoneCallProtocol{ + UDPP2P: p.UDPP2P, + UDPReflector: p.UDPReflector, + MinLayer: p.MinLayer, + MaxLayer: p.MaxLayer, + LibraryVersions: p.LibraryVersions, + } +} + +func phoneCallProtocolFromTL(p tg.PhoneCallProtocol) domain.PhoneCallProtocol { + return domain.PhoneCallProtocol{ + UDPP2P: p.UDPP2P, + UDPReflector: p.UDPReflector, + MinLayer: p.MinLayer, + MaxLayer: p.MaxLayer, + LibraryVersions: p.LibraryVersions, + } +} + +// tgPhoneCallForViewer 按观察者视角把服务端状态映射为 TL phoneCall* 构造器。 +// 这是客户端硬契约:access_hash / admin_id / participant_id 全生命周期一致; +// Confirmed 态主叫看到的 g_a_or_b 是对方的 g_b、被叫看到的是 g_a。 +func tgPhoneCallForViewer(call domain.PhoneCall, viewerID int64) tg.PhoneCallClass { + switch call.State { + case domain.PhoneCallStateRequested, domain.PhoneCallStateRinging: + if viewerID == call.ParticipantID { + return &tg.PhoneCallRequested{ + Video: call.Video, + ID: call.ID, + AccessHash: call.AccessHash, + Date: call.Date, + AdminID: call.AdminID, + ParticipantID: call.ParticipantID, + GAHash: call.GAHash, + Protocol: tgPhoneCallProtocol(call.CallerProtocol), + } + } + return phoneCallWaitingView(call) + case domain.PhoneCallStateAccepted: + if viewerID == call.AdminID { + return &tg.PhoneCallAccepted{ + Video: call.Video, + ID: call.ID, + AccessHash: call.AccessHash, + Date: call.Date, + AdminID: call.AdminID, + ParticipantID: call.ParticipantID, + GB: call.GB, + Protocol: tgPhoneCallProtocol(call.Protocol), + } + } + return phoneCallWaitingView(call) + case domain.PhoneCallStateConfirmed: + gaOrB := call.GA // 被叫视角:拿主叫揭示的 g_a 验承诺、算 AuthKey + if viewerID == call.AdminID { + gaOrB = call.GB // 主叫视角:拿被叫的 g_b + } + out := &tg.PhoneCall{ + P2PAllowed: call.P2PAllowed, + Video: call.Video, + ID: call.ID, + AccessHash: call.AccessHash, + Date: call.Date, + AdminID: call.AdminID, + ParticipantID: call.ParticipantID, + GAOrB: gaOrB, + KeyFingerprint: call.KeyFingerprint, + Protocol: tgPhoneCallProtocol(call.Protocol), + Connections: tgPhoneConnections(call.Connections), + StartDate: call.StartDate, + } + return out + case domain.PhoneCallStateDiscarded: + return tgPhoneCallDiscarded(call) + default: + return &tg.PhoneCallEmpty{ID: call.ID} + } +} + +// tgPhoneConnections 把签发的 STUN/TURN 条目转为 TL phoneConnectionWebrtc。 +// 永远返回非 nil(gotd 对 nil vector 编码与空 vector 相同,但保持显式)。 +// 不下发 legacy phoneConnection(Telegram 自有 reflector 协议):现代 tgcalls +// 走 WebRTC ICE,只消费 webrtc 条目。 +func tgPhoneConnections(conns []domain.PhoneCallConnection) []tg.PhoneConnectionClass { + out := make([]tg.PhoneConnectionClass, 0, len(conns)) + for _, c := range conns { + out = append(out, &tg.PhoneConnectionWebrtc{ + Turn: c.Turn, + Stun: c.Stun, + ID: c.ID, + IP: c.IP, + Ipv6: "", + Port: c.Port, + Username: c.Username, + Password: c.Password, + }) + } + return out +} + +func phoneCallWaitingView(call domain.PhoneCall) *tg.PhoneCallWaiting { + out := &tg.PhoneCallWaiting{ + Video: call.Video, + ID: call.ID, + AccessHash: call.AccessHash, + Date: call.Date, + AdminID: call.AdminID, + ParticipantID: call.ParticipantID, + Protocol: tgPhoneCallProtocol(call.Protocol), + } + if call.ReceiveDate > 0 { + // 主叫据 receive_date != 0 从「等待」切「对方振铃中」并换用 90s ring 定时器。 + out.SetReceiveDate(call.ReceiveDate) + } + return out +} + +// tgPhoneCallDiscarded 构造终态视图。need_rating/need_debug 恒不置位: +// 客户端便不弹评分框、不上传 tgcalls debug 数据。 +func tgPhoneCallDiscarded(call domain.PhoneCall) *tg.PhoneCallDiscarded { + out := &tg.PhoneCallDiscarded{ + Video: call.Video, + ID: call.ID, + } + if reason := tgPhoneCallDiscardReason(call.DiscardReason); reason != nil { + out.SetReason(reason) + } + if call.Duration > 0 { + out.SetDuration(call.Duration) + } + return out +} + +// tgPhoneCallStopRinging 构造「被叫其它设备停振铃」的合成终态。 +// +// 硬契约(勿改回推 phoneCallAccepted):TDesktop incoming 侧收到 phoneCallAccepted +// 会 finish(Failed) 并回发 discardCall、DrKLO 会按主叫流程发 confirmCall——任一都会 +// 杀死刚建立的通话。无 busy/migrate reason 的 phoneCallDiscarded 才会让两端走 +// EndedByOtherDevice / 停止前台服务且不回发 RPC。本构造不改服务端状态、不进 tombstone。 +func tgPhoneCallStopRinging(call domain.PhoneCall) *tg.PhoneCallDiscarded { + return &tg.PhoneCallDiscarded{ + Video: call.Video, + ID: call.ID, + } +} + +func tgPhoneCallDiscardReason(r domain.PhoneCallDiscardReason) tg.PhoneCallDiscardReasonClass { + switch r { + case domain.PhoneCallDiscardReasonMissed: + return &tg.PhoneCallDiscardReasonMissed{} + case domain.PhoneCallDiscardReasonDisconnect: + return &tg.PhoneCallDiscardReasonDisconnect{} + case domain.PhoneCallDiscardReasonHangup: + return &tg.PhoneCallDiscardReasonHangup{} + case domain.PhoneCallDiscardReasonBusy: + return &tg.PhoneCallDiscardReasonBusy{} + case domain.PhoneCallDiscardReasonMigrateConference: + return &tg.PhoneCallDiscardReasonMigrateConferenceCall{} + default: + return nil + } +} + +func phoneCallDiscardReasonFromTL(r tg.PhoneCallDiscardReasonClass) domain.PhoneCallDiscardReason { + switch r.(type) { + case *tg.PhoneCallDiscardReasonMissed: + return domain.PhoneCallDiscardReasonMissed + case *tg.PhoneCallDiscardReasonDisconnect: + return domain.PhoneCallDiscardReasonDisconnect + case *tg.PhoneCallDiscardReasonBusy: + return domain.PhoneCallDiscardReasonBusy + case *tg.PhoneCallDiscardReasonMigrateConferenceCall: + return domain.PhoneCallDiscardReasonMigrateConference + case *tg.PhoneCallDiscardReasonHangup: + return domain.PhoneCallDiscardReasonHangup + default: + // 含 nil(客户端未带 reason):按 hangup 处理。 + return domain.PhoneCallDiscardReasonHangup + } +} diff --git a/internal/rpc/convert_phone_group.go b/internal/rpc/convert_phone_group.go new file mode 100644 index 00000000..188f4dc7 --- /dev/null +++ b/internal/rpc/convert_phone_group.go @@ -0,0 +1,339 @@ +package rpc + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" + "telesrv/internal/sfu" +) + +// ---- TL 转换 ---- + +// groupCallUnmutedVideoLimit 是同时开视频的参与者上限(官方经 appConfig +// groupcall_video_participants_max 下发,缺省 30)。 +// ⚠ unmuted_video_limit 是 TL 非可选 int:留 0 会让两端的视频/屏幕共享闸门 +// `activeVideoSendersCount() >= limit` 恒真——「You can't share your screen in +// this chat」即此(TDesktop emitShareScreenError / DrKLO ChatObject.canStreamVideo)。 +const groupCallUnmutedVideoLimit = 30 + +// tgGroupCall 把 call 行转为 TL groupCall。 +func tgGroupCall(call domain.GroupCall, viewerUserID int64, canManage bool) tg.GroupCallClass { + if !call.Active() { + return &tg.GroupCallDiscarded{ID: call.ID, AccessHash: call.AccessHash, Duration: call.Duration} + } + out := &tg.GroupCall{ + JoinMuted: call.JoinMuted, + CanChangeJoinMuted: canManage, + Creator: call.CreatorUserID == viewerUserID && viewerUserID != 0, + // can_start_video:TDesktop 不读;DrKLO 用它喂入会前 dummy self 行的 + // video_joined。RTC 通话一律放行。 + CanStartVideo: true, + ID: call.ID, + AccessHash: call.AccessHash, + ParticipantsCount: call.ParticipantsCount, + UnmutedVideoLimit: groupCallUnmutedVideoLimit, + Version: call.Version, + } + if call.Title != "" { + out.SetTitle(call.Title) + } + return out +} + +// tgGroupCallParticipant 按 viewer 视角转换参与者行(Self flag per-viewer)。 +func tgGroupCallParticipant(p domain.GroupCallParticipant, viewerUserID int64) tg.GroupCallParticipant { + out := tg.GroupCallParticipant{ + Muted: p.Muted, + Left: p.Left, + CanSelfUnmute: !p.MutedByAdmin, + Self: p.UserID == viewerUserID, + Peer: &tg.PeerUser{UserID: p.UserID}, + Date: p.JoinDate, + Source: int(int32(uint32(p.SSRC))), // uint32 按位转 int32(join JSON 同款语义) + } + if p.Left { + // left 行不再携带 can_self_unmute 语义。 + out.CanSelfUnmute = false + } else { + // video_joined:RTC 入会一律置位。self 行缺它会让 TDesktop 置 + // _videoIsWorking=false 并强制关掉本端摄像头/屏幕共享。 + out.VideoJoined = true + } + if p.ActiveDate > 0 { + out.SetActiveDate(p.ActiveDate) + } + if p.VolumeByAdmin > 0 { + out.VolumeByAdmin = true + out.SetVolume(p.VolumeByAdmin) + } + if p.RaiseHandRating > 0 { + out.SetRaiseHandRating(p.RaiseHandRating) + } + // 视频/屏幕共享:Active 且未离会才输出字段(video_stopped ⇒ 字段消失; + // paused ⇒ 字段保留但置 paused flag)。min 行(overrides 推送)不会走到 + // 这里改写 videoParams——applyParticipantOverride 只动 muted/volume。 + if !p.Left { + if st, ok := decodeVideoState(p.VideoJSON); ok && st.Active { + out.SetVideo(*tgParticipantVideo(st, false)) + } + if st, ok := decodeVideoState(p.PresentationJSON); ok && st.Active { + out.SetPresentation(*tgParticipantVideo(st, true)) + } + } + return out +} + +func tgGroupCallParticipants(rows []domain.GroupCallParticipant, viewerUserID int64) []tg.GroupCallParticipant { + out := make([]tg.GroupCallParticipant, 0, len(rows)) + for _, p := range rows { + out = append(out, tgGroupCallParticipant(p, viewerUserID)) + } + return out +} + +// applyParticipantOverride 把 setter 的 per-viewer 覆盖(本地静音/音量)叠加到推给 +// 该 setter 的 participant 行上,并置 min flag——min 告诉客户端「本行的部分字段 +// 不可全信,按本地状态保留」,防止 setter 其它设备用此行覆盖本地 muted/volume。 +func applyParticipantOverride(p tg.GroupCallParticipant, ov domain.GroupCallParticipantOverride) tg.GroupCallParticipant { + p.SetMin(true) + if ov.MutedByYou { + p.SetMutedByYou(true) + } + if ov.Volume > 0 { + p.SetVolume(ov.Volume) + } + return p +} + +// inputGroupCallRef 解析 InputGroupCallClass 的三个变体。 +// slug / inviteMessage 变体属 conference 路径(范围外),返回 GROUPCALL_INVALID +// 而非类型断言 panic——TDesktop conference 迁移会真实发出 inputGroupCallSlug。 +func inputGroupCallRef(call tg.InputGroupCallClass) (callID, accessHash int64, err error) { + switch v := call.(type) { + case *tg.InputGroupCall: + return v.ID, v.AccessHash, nil + default: + return 0, 0, groupCallInvalidErr() + } +} + +// ---- join JSON(上行)---- + +// groupCallJoinPayload 是 phone.joinGroupCall.params 的上行 JSON +// (tgcalls GroupJoinPayloadInternal::serialize 的产物,逐字段对应)。 +type groupCallJoinPayload struct { + SSRC int32 `json:"ssrc"` + Ufrag string `json:"ufrag"` + Pwd string `json:"pwd"` + Fingerprints []groupCallFingerprint `json:"fingerprints"` + SsrcGroups []groupCallSsrcGroup `json:"ssrc-groups,omitempty"` +} + +type groupCallFingerprint struct { + Hash string `json:"hash"` + Fingerprint string `json:"fingerprint"` + Setup string `json:"setup"` +} + +type groupCallSsrcGroup struct { + Semantics string `json:"semantics"` + Sources []int64 `json:"sources"` +} + +// parseGroupCallJoinPayload 解析并校验上行 JSON;解析必须容忍多余字段。 +// ssrc-groups(视频 simulcast/RTX 源组)无论摄像头开关都会随主 join 携带, +// 必须存档:等 video_stopped=false 时原样回放进 participant.video。 +func parseGroupCallJoinPayload(data string) (sfu.ClientOffer, int64, error) { + var payload groupCallJoinPayload + if err := json.Unmarshal([]byte(data), &payload); err != nil { + return sfu.ClientOffer{}, 0, fmt.Errorf("parse join payload: %w", err) + } + if payload.Ufrag == "" || payload.Pwd == "" || payload.SSRC == 0 { + return sfu.ClientOffer{}, 0, fmt.Errorf("join payload missing ssrc/ufrag/pwd") + } + offer := sfu.ClientOffer{ + // ssrc 是把 uint32 按位转 int32 的有符号值,还原为 uint32。 + AudioSSRC: uint32(payload.SSRC), + Ufrag: payload.Ufrag, + Pwd: payload.Pwd, + } + for _, fp := range payload.Fingerprints { + if fp.Hash == "sha-256" { + offer.FingerprintSHA256 = fp.Fingerprint + break + } + } + if offer.FingerprintSHA256 == "" { + return sfu.ClientOffer{}, 0, fmt.Errorf("join payload missing sha-256 fingerprint") + } + for _, g := range payload.SsrcGroups { + sg := sfu.SsrcGroup{Semantics: g.Semantics, Sources: make([]uint32, 0, len(g.Sources))} + for _, src := range g.Sources { + // sources 同样是 int32 位重解释。 + sg.Sources = append(sg.Sources, uint32(int32(src))) + } + offer.SsrcGroups = append(offer.SsrcGroups, sg) + } + return offer, int64(offer.AudioSSRC), nil +} + +// ---- participant 视频内部状态(video_json / presentation_json 列)---- + +// participantVideoState 是存进 video_json/presentation_json 的内部快照(非 wire +// 格式):endpoint 由服务端铸造且必须与 join 响应 video.endpoint 逐字节一致; +// source_groups 是上行 ssrc-groups 的原样存档;Active 决定 TL 字段是否出现。 +type participantVideoState struct { + Endpoint string `json:"endpoint"` + SourceGroups []groupCallSsrcGroup `json:"source_groups,omitempty"` + Active bool `json:"active"` + Paused bool `json:"paused,omitempty"` + // AudioSource 仅 presentation:屏幕实例自己的音频 ssrc(int32 位重解释存原值)。 + AudioSource int64 `json:"audio_source,omitempty"` +} + +func encodeVideoState(st participantVideoState) []byte { + out, err := json.Marshal(st) + if err != nil { + return nil + } + return out +} + +func decodeVideoState(raw []byte) (participantVideoState, bool) { + if len(raw) == 0 { + return participantVideoState{}, false + } + var st participantVideoState + if err := json.Unmarshal(raw, &st); err != nil || st.Endpoint == "" { + return participantVideoState{}, false + } + return st, true +} + +// tgParticipantVideo 把内部状态转为 TL groupCallParticipantVideo(Active 才输出)。 +// withAudioSource 仅 presentation 置 true(DrKLO 用 audio_source 做屏幕 mySource, +// 缺失会退化取首个视频 ssrc 致 checkGroupCall 死循环重建)。 +func tgParticipantVideo(st participantVideoState, withAudioSource bool) *tg.GroupCallParticipantVideo { + out := &tg.GroupCallParticipantVideo{ + Paused: st.Paused, + Endpoint: st.Endpoint, + } + for _, g := range st.SourceGroups { + sg := tg.GroupCallParticipantVideoSourceGroup{Semantics: g.Semantics} + for _, src := range g.Sources { + sg.Sources = append(sg.Sources, int(int32(uint32(src)))) + } + out.SourceGroups = append(out.SourceGroups, sg) + } + if withAudioSource && st.AudioSource != 0 { + out.SetAudioSource(int(int32(uint32(st.AudioSource)))) + } + return out +} + +// groupCallSsrcGroupsFromOffer 把解析后的源组转回存档形态(保持 int32 位重解释 +// 的有符号写法,与上行 JSON 一致)。 +func groupCallSsrcGroupsFromOffer(offer sfu.ClientOffer) []groupCallSsrcGroup { + out := make([]groupCallSsrcGroup, 0, len(offer.SsrcGroups)) + for _, g := range offer.SsrcGroups { + sg := groupCallSsrcGroup{Semantics: g.Semantics, Sources: make([]int64, 0, len(g.Sources))} + for _, src := range g.Sources { + sg.Sources = append(sg.Sources, int64(int32(src))) + } + out = append(out, sg) + } + return out +} + +// groupCallEndpointID 铸造 camera/presentation 的 endpoint 串:call 内唯一 +// (ssrc 有活跃唯一索引兜底)、同 participant 两路互异、rejoin 换 ssrc 自然换串。 +func groupCallEndpointID(kind sfu.EndpointKind, audioSSRC uint32) string { + if kind == sfu.EndpointPresentation { + return fmt.Sprintf("presentation-%d", audioSSRC) + } + return fmt.Sprintf("audio-%d", audioSSRC) +} + +// ---- 下行 JSON(updateGroupCallConnection.params)---- + +// videoRtcpFbs 是视频 codec 的反馈能力表(与客户端本地 addDefaultFeedbackParams +// 一致:goog-remb/transport-cc/ccm fir/nack/nack pli)。 +func videoRtcpFbs() []map[string]any { + return []map[string]any{ + {"type": "goog-remb"}, + {"type": "transport-cc"}, + {"type": "ccm", "subtype": "fir"}, + {"type": "nack"}, + {"type": "nack", "subtype": "pli"}, + } +} + +// buildGroupCallConnectionParams 组装下行 transport JSON(契约 schema 逐字段照抄, +// 解析方为 tgcalls GroupJoinPayloadInternal/GroupNetworkManager)。要点: +// - transport.fingerprints.setup="active":SFU 是 DTLS 主动握手方; +// - candidates 全部字段都是字符串(数值也要写成字符串); +// - video 节即使纯收看也必须存在(缺失则客户端不建任何视频通道); +// - video.endpoint 是**本参与者自己**的视频 endpoint 标识,必须与日后写进其 +// participant.video.endpoint 的串逐字节一致(客户端靠它做自我过滤); +// - payload-types 照抄客户端本地确定性分配(VP8=100/rtx101、VP9=102/rtx103, +// PT 由客户端自配,本表主要给其它平台客户端消费);不含 H264 ⇒ 全网 VP8 缺省; +// - rtp-hdrexts 照抄客户端硬编码 id 表(1=audio-level、2=abs-send-time、 +// 3=transport-wide-cc、13=video-orientation)。 +func buildGroupCallConnectionParams(answer sfu.ServerAnswer, endpoint string) (string, error) { + candidates := make([]map[string]any, 0, len(answer.Candidates)) + for i, c := range answer.Candidates { + candidates = append(candidates, map[string]any{ + "component": "1", + "protocol": c.Protocol, + "port": strconv.Itoa(c.Port), + "ip": c.IP, + "type": c.Type, + "priority": "2130706431", + "foundation": "1", + "generation": "0", + "network": "1", + "id": fmt.Sprintf("c%d", i+1), + }) + } + params := map[string]any{ + "transport": map[string]any{ + "ufrag": answer.Ufrag, + "pwd": answer.Pwd, + "fingerprints": []map[string]any{{ + "hash": "sha-256", + "fingerprint": answer.FingerprintSHA256, + "setup": "active", + }}, + "candidates": candidates, + }, + "video": map[string]any{ + "endpoint": endpoint, + "payload-types": []map[string]any{ + {"id": 100, "name": "VP8", "clockrate": 90000, "channels": 0, + "parameters": map[string]any{}, "rtcp-fbs": videoRtcpFbs()}, + {"id": 101, "name": "rtx", "clockrate": 90000, "channels": 0, + "parameters": map[string]any{"apt": "100"}, "rtcp-fbs": []map[string]any{}}, + {"id": 102, "name": "VP9", "clockrate": 90000, "channels": 0, + "parameters": map[string]any{}, "rtcp-fbs": videoRtcpFbs()}, + {"id": 103, "name": "rtx", "clockrate": 90000, "channels": 0, + "parameters": map[string]any{"apt": "102"}, "rtcp-fbs": []map[string]any{}}, + }, + "rtp-hdrexts": []map[string]any{ + {"id": 1, "uri": "urn:ietf:params:rtp-hdrext:ssrc-audio-level"}, + {"id": 2, "uri": "http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time"}, + {"id": 3, "uri": "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"}, + {"id": 13, "uri": "urn:3gpp:video-orientation"}, + }, + "server_sources": []int64{}, + }, + } + out, err := json.Marshal(params) + if err != nil { + return "", fmt.Errorf("marshal connection params: %w", err) + } + return string(out), nil +} diff --git a/internal/rpc/convert_polls.go b/internal/rpc/convert_polls.go new file mode 100644 index 00000000..3cf3aa9f --- /dev/null +++ b/internal/rpc/convert_polls.go @@ -0,0 +1,336 @@ +package rpc + +import ( + "context" + "strconv" + "strings" + "unicode/utf8" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 本文件集中 poll 的 tg↔domain 转换与 updateMessagePoll 组装。 +// 定义快照(MessagePoll)不含机密;机密只进 PollDefinition(polls 权威表)。 + +// resolvePollAuxMedia 解析 poll 配图(答案图/题干图):只允许引用或上传 photo/document, +// 其余 InputMedia(含嵌套 poll/todo——会先建孤儿权威行)显式拒绝。 +func (r *Router) resolvePollAuxMedia(ctx context.Context, userID int64, input tg.InputMediaClass) (*domain.MessageMedia, error) { + switch input.(type) { + case *tg.InputMediaPhoto, *tg.InputMediaDocument, *tg.InputMediaUploadedPhoto, *tg.InputMediaUploadedDocument: + default: + return nil, mediaInvalidErr() + } + media, err := r.resolveInputMedia(ctx, userID, input) + if err != nil { + return nil, err + } + if media == nil || (media.Kind != domain.MessageMediaKindPhoto && media.Kind != domain.MessageMediaKindDocument) { + return nil, mediaInvalidErr() + } + return media, nil +} + +// domainPollFromInputMedia 校验 InputMediaPoll 并产出 (渲染快照, 权威定义)。 +// pollID 由调用方分配;now 用于 close_period→close_date 折算。 +func (r *Router) domainPollFromInputMedia(ctx context.Context, in *tg.InputMediaPoll, creatorUserID, pollID int64, now int) (*domain.MessagePoll, domain.PollDefinition, error) { + var zero domain.PollDefinition + poll := in.Poll + if poll.SubscribersOnly || len(poll.CountriesISO2) > 0 { + // subscribers-only / 国家限定是新版商业能力,当前无业务模型。 + return nil, zero, mediaInvalidErr() + } + // open_answers(开放式答案,允许他人添加选项)在 TDesktop 创建面板默认勾选 + //(kDefaultPollCreateFlags),不能拒;其依赖的 addPollAnswer/deletePollAnswer + // 未接入,故静默剥离不回 echo——客户端不渲染"添加答案"入口,行为自洽(矩阵 todo)。 + question := poll.Question.Text + if strings.TrimSpace(question) == "" { + return nil, zero, mediaEmptyErr() + } + if utf8.RuneCountInString(question) > domain.MaxPollQuestionLength { + return nil, zero, mediaInvalidErr() + } + if len(poll.Question.Entities) > maxMessageEntityCount { + return nil, zero, limitInvalidErr() + } + if len(poll.Answers) < domain.MinPollAnswers { + return nil, zero, optionInvalidErr() + } + if len(poll.Answers) > domain.MaxPollAnswers { + return nil, zero, optionsTooMuchErr() + } + snapshot := &domain.MessagePoll{ + ID: pollID, + Question: question, + QuestionEntities: domainMessageEntities(poll.Question.Entities), + PublicVoters: poll.PublicVoters, + MultipleChoice: poll.MultipleChoice, + Quiz: poll.Quiz, + RevotingDisabled: poll.RevotingDisabled, + ShuffleAnswers: poll.ShuffleAnswers, + HideResultsUntilClose: poll.HideResultsUntilClose, + } + def := domain.PollDefinition{ + ID: pollID, + CreatorUserID: creatorUserID, + PublicVoters: poll.PublicVoters, + MultipleChoice: poll.MultipleChoice, + Quiz: poll.Quiz, + RevotingDisabled: poll.RevotingDisabled, + HideResultsUntilClose: poll.HideResultsUntilClose, + } + // 答案两种形态:pollAnswer 自带 option 键(DrKLO Android 路径); + // inputPollAnswer 无 option(TDesktop 创建路径)——option 键由服务端分配。 + seen := make(map[string]struct{}, len(poll.Answers)) + pendingOption := make([]bool, 0, len(poll.Answers)) + for _, answerClass := range poll.Answers { + var text tg.TextWithEntities + var option []byte + var answerMedia *domain.MessageMedia + switch answer := answerClass.(type) { + case *tg.PollAnswer: + if answer.Media != nil { + // pollAnswer.media 是 MessageMedia(服务端输出形态),不是合法输入。 + return nil, zero, mediaInvalidErr() + } + if len(answer.Option) == 0 || len(answer.Option) > maxPollOptionBytes { + return nil, zero, pollOptionInvalidErr() + } + text = answer.Text + option = append([]byte(nil), answer.Option...) + case *tg.InputPollAnswer: + // 答案配图(TDesktop 创建面板可给每个答案附 photo/document)。 + if input, ok := answer.GetMedia(); ok && input != nil { + media, err := r.resolvePollAuxMedia(ctx, creatorUserID, input) + if err != nil { + return nil, zero, err + } + answerMedia = media + } + text = answer.Text + default: + return nil, zero, pollAnswerInvalidErr() + } + if strings.TrimSpace(text.Text) == "" || utf8.RuneCountInString(text.Text) > domain.MaxPollAnswerTextLength { + return nil, zero, pollAnswerInvalidErr() + } + if len(text.Entities) > maxMessageEntityCount { + return nil, zero, limitInvalidErr() + } + if option != nil { + key := string(option) + if _, dup := seen[key]; dup { + return nil, zero, pollOptionInvalidErr() + } + seen[key] = struct{}{} + } + pendingOption = append(pendingOption, option == nil) + snapshot.Answers = append(snapshot.Answers, domain.MessagePollAnswer{ + Text: text.Text, + Entities: domainMessageEntities(text.Entities), + Option: option, + Media: answerMedia, + }) + } + // 为 inputPollAnswer 分配服务端 option 键:取未被显式键占用的最小单字节。 + next := 0 + for i := range snapshot.Answers { + if !pendingOption[i] { + continue + } + for next <= 0xFF { + key := []byte{byte(next)} + next++ + if _, taken := seen[string(key)]; !taken { + seen[string(key)] = struct{}{} + snapshot.Answers[i].Option = key + break + } + } + if snapshot.Answers[i].Option == nil { + return nil, zero, pollOptionInvalidErr() + } + } + for _, answer := range snapshot.Answers { + def.Options = append(def.Options, answer.Option) + } + // quiz 机密:correct_answers(Layer 224+ 为答案下标)必须恰好 1 个且指向合法选项; + // solution 仅 quiz 可带。 + correct, hasCorrect := in.GetCorrectAnswers() + solution, hasSolution := in.GetSolution() + if poll.Quiz { + if poll.MultipleChoice { + return nil, zero, mediaInvalidErr() + } + if !hasCorrect || len(correct) != 1 { + return nil, zero, quizCorrectAnswersInvalidErr() + } + for _, index := range correct { + if index < 0 || index >= len(def.Options) { + return nil, zero, quizCorrectAnswersInvalidErr() + } + def.CorrectOptions = append(def.CorrectOptions, append([]byte(nil), def.Options[index]...)) + } + if hasSolution { + if strings.TrimSpace(solution) == "" || utf8.RuneCountInString(solution) > domain.MaxPollSolutionLength { + return nil, zero, mediaInvalidErr() + } + entities, _ := in.GetSolutionEntities() + if len(entities) > maxMessageEntityCount { + return nil, zero, limitInvalidErr() + } + def.Solution = solution + def.SolutionEntities = domainMessageEntities(entities) + } + } else if hasCorrect || hasSolution { + return nil, zero, mediaInvalidErr() + } + if _, ok := in.GetSolutionMedia(); ok { + // quiz 解释配图依赖 polls 权威表扩列(机密侧),当前未接入(矩阵 todo)。 + return nil, zero, mediaInvalidErr() + } + if input, ok := in.GetAttachedMedia(); ok && input != nil { + // poll 题干配图(messageMediaPoll.attached_media),随快照落库。 + media, err := r.resolvePollAuxMedia(ctx, creatorUserID, input) + if err != nil { + return nil, zero, err + } + snapshot.AttachedMedia = media + } + // close_period / close_date 互斥;period 折算出 close_date 供服务端到点判定。 + closePeriod, hasPeriod := poll.GetClosePeriod() + closeDate, hasDate := poll.GetCloseDate() + if hasPeriod && hasDate { + return nil, zero, mediaInvalidErr() + } + if hasPeriod { + if closePeriod < domain.MinPollClosePeriod || closePeriod > domain.MaxPollClosePeriod { + return nil, zero, mediaInvalidErr() + } + snapshot.ClosePeriod = closePeriod + snapshot.CloseDate = now + closePeriod + def.ClosePeriod = closePeriod + def.CloseDate = now + closePeriod + } else if hasDate { + if closeDate <= now || closeDate > now+domain.MaxPollClosePeriod+60 { + return nil, zero, mediaInvalidErr() + } + snapshot.CloseDate = closeDate + def.CloseDate = closeDate + } + return snapshot, def, nil +} + +// tgPoll 把定义快照转成 tg.Poll(Closed/Results 由读路径 enrichment 决定)。 +func tgPoll(p domain.MessagePoll) tg.Poll { + out := tg.Poll{ + ID: p.ID, + Closed: p.Closed, + PublicVoters: p.PublicVoters, + MultipleChoice: p.MultipleChoice, + Quiz: p.Quiz, + RevotingDisabled: p.RevotingDisabled, + ShuffleAnswers: p.ShuffleAnswers, + HideResultsUntilClose: p.HideResultsUntilClose, + Question: tgTextWithEntities(p.Question, p.QuestionEntities), + Answers: make([]tg.PollAnswerClass, 0, len(p.Answers)), + } + for _, answer := range p.Answers { + item := &tg.PollAnswer{ + Text: tgTextWithEntities(answer.Text, answer.Entities), + Option: answer.Option, + } + if answer.Media != nil { + item.SetMedia(tgMessageMedia(answer.Media)) + } + out.Answers = append(out.Answers, item) + } + if p.ClosePeriod > 0 { + out.SetClosePeriod(p.ClosePeriod) + } + if p.CloseDate > 0 { + out.SetCloseDate(p.CloseDate) + } + return out +} + +// tgPollResults 输出 viewer 视角结果;未 enrich(Results==nil)时回退 min(客户端保留本地缓存)。 +func tgPollResults(p domain.MessagePoll) tg.PollResults { + out := tg.PollResults{} + results := p.Results + if results == nil { + out.Min = true + return out + } + voters := make([]tg.PollAnswerVoters, 0, len(results.Voters)) + for _, item := range results.Voters { + entry := tg.PollAnswerVoters{Chosen: item.Chosen, Correct: item.Correct, Option: item.Option} + entry.SetVoters(item.Voters) + voters = append(voters, entry) + } + out.SetResults(voters) + out.SetTotalVoters(results.TotalVoters) + if len(results.RecentVoters) > 0 { + peers := make([]tg.PeerClass, 0, len(results.RecentVoters)) + for _, userID := range results.RecentVoters { + peers = append(peers, &tg.PeerUser{UserID: userID}) + } + out.SetRecentVoters(peers) + } + if results.Solution != "" { + out.SetSolution(results.Solution) + out.SetSolutionEntities(tgMessageEntities(results.SolutionEntities)) + } + return out +} + +func tgTextWithEntities(text string, entities []domain.MessageEntity) tg.TextWithEntities { + converted := tgMessageEntities(entities) + if converted == nil { + converted = []tg.MessageEntityClass{} + } + return tg.TextWithEntities{Text: text, Entities: converted} +} + +// tgUpdateMessagePoll 组装一条 updateMessagePoll(poll 总是内联,避免客户端缓存缺失)。 +func tgUpdateMessagePoll(peer domain.Peer, msgID int, poll *domain.MessagePoll) *tg.UpdateMessagePoll { + if poll == nil { + return nil + } + update := &tg.UpdateMessagePoll{ + PollID: poll.ID, + Results: tgPollResults(*poll), + } + update.SetPoll(tgPoll(*poll)) + if tgp := tgPeer(peer); tgp != nil && msgID > 0 { + update.SetPeer(tgp) + update.SetMsgID(msgID) + } + return update +} + +// pollVotesNextOffset 编解码 getPollVotes 翻页 token(",")。 +func pollVotesNextOffset(votes []domain.PollVote) string { + if len(votes) == 0 { + return "" + } + last := votes[len(votes)-1] + return strconv.Itoa(last.Date) + "," + strconv.FormatInt(last.UserID, 10) +} + +func decodePollVotesOffset(offset string) (date int, userID int64, ok bool) { + if offset == "" { + return 0, 0, true + } + parts := strings.SplitN(offset, ",", 2) + if len(parts) != 2 { + return 0, 0, false + } + d, err1 := strconv.Atoi(parts[0]) + u, err2 := strconv.ParseInt(parts[1], 10, 64) + if err1 != nil || err2 != nil || d < 0 || u < 0 { + return 0, 0, false + } + return d, u, true +} diff --git a/internal/rpc/convert_rich_message.go b/internal/rpc/convert_rich_message.go new file mode 100644 index 00000000..f7260551 --- /dev/null +++ b/internal/rpc/convert_rich_message.go @@ -0,0 +1,134 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 本文件集中 Layer 227 富文本消息(richMessage)的 tg.* ↔ domain 转换。 +// Phase 1:仅支持 inputRichMessage(blocks 形态);HTML/Markdown 变体(需服务端解析为 +// PageBlock)尚未实现,直接拒绝。blocks 以 TL 向量序列化为不透明字节存 domain(详见 +// domain.MessageRichMessage)。 + +// encodeRichBlocks 把 []tg.PageBlockClass 序列化为 TL 向量字节(含 vector 头)。 +func encodeRichBlocks(blocks []tg.PageBlockClass) ([]byte, error) { + var b bin.Buffer + b.PutVectorHeader(len(blocks)) + for _, blk := range blocks { + if blk == nil { + return nil, mediaInvalidErr() + } + if err := blk.Encode(&b); err != nil { + return nil, err + } + } + return b.Buf, nil +} + +// decodeRichBlocks 把 encodeRichBlocks 产生的字节还原为 []tg.PageBlockClass。 +func decodeRichBlocks(data []byte) ([]tg.PageBlockClass, error) { + if len(data) == 0 { + return nil, nil + } + b := &bin.Buffer{Buf: append([]byte(nil), data...)} + n, err := b.VectorHeader() + if err != nil { + return nil, err + } + out := make([]tg.PageBlockClass, 0, n) + for i := 0; i < n; i++ { + blk, err := tg.DecodePageBlock(b) + if err != nil { + return nil, err + } + out = append(out, blk) + } + return out, nil +} + +// domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照: +// 序列化 blocks + 按 id 解析内嵌 photos/documents(复用 sendMedia 同款媒体解析)。 +// 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。 +func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputRichMessageClass) (*domain.MessageRichMessage, error) { + if input == nil { + return nil, nil + } + in, ok := input.(*tg.InputRichMessage) + if !ok { + // Phase 1:HTML/Markdown 变体需服务端解析为 PageBlock,尚未支持。 + return nil, mediaInvalidErr() + } + if r.deps.Files == nil { + return nil, notImplementedErr() + } + blocks, err := encodeRichBlocks(in.Blocks) + if err != nil { + return nil, err + } + rich := &domain.MessageRichMessage{ + Rtl: in.Rtl, + Blocks: blocks, + } + for _, p := range in.Photos { + id, ok := inputPhotoID(p) + if !ok { + return nil, photoInvalidErr() + } + photo, found, err := r.deps.Files.GetPhoto(ctx, id) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, photoInvalidErr() + } + rich.Photos = append(rich.Photos, photo) + } + for _, d := range in.Documents { + id, ok := inputDocumentID(d) + if !ok { + return nil, mediaInvalidErr() + } + doc, found, err := r.deps.Files.GetDocument(ctx, id) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, mediaInvalidErr() + } + rich.Documents = append(rich.Documents, doc) + } + if rich.IsZero() { + return nil, nil + } + return rich, nil +} + +// tgRichMessage 把 domain 富文本快照投影为 tg.RichMessage(反序列化 blocks + 复用 +// tgPhoto/tgDocument 投影内嵌媒体)。空载荷或 blocks 解码失败返回 (nil, err)。 +func tgRichMessage(m *domain.MessageRichMessage) (*tg.RichMessage, error) { + if m.IsZero() { + return nil, nil + } + blocks, err := decodeRichBlocks(m.Blocks) + if err != nil { + return nil, err + } + out := &tg.RichMessage{ + Rtl: m.Rtl, + Part: m.Part, + Blocks: blocks, + Photos: make([]tg.PhotoClass, 0, len(m.Photos)), + Documents: make([]tg.DocumentClass, 0, len(m.Documents)), + } + for _, p := range m.Photos { + out.Photos = append(out.Photos, tgPhoto(p)) + } + for _, d := range m.Documents { + out.Documents = append(out.Documents, tgDocument(d)) + } + return out, nil +} diff --git a/internal/rpc/convert_stories.go b/internal/rpc/convert_stories.go new file mode 100644 index 00000000..226341df --- /dev/null +++ b/internal/rpc/convert_stories.go @@ -0,0 +1,439 @@ +package rpc + +import ( + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func tgStoriesAllStories(viewerUserID int64, list domain.StoryList) tg.StoriesAllStoriesClass { + return &tg.StoriesAllStories{ + HasMore: list.HasMore, + Count: list.Count, + State: list.State, + PeerStories: tgPeerStoriesList(list.Peers), + Chats: tgChannels(viewerUserID, list.Channels), + Users: tgUsersForViewer(viewerUserID, list.Users), + StealthMode: tg.StoriesStealthMode{}, + } +} + +func tgStoriesAllStoriesNotModified(state string) tg.StoriesAllStoriesClass { + return &tg.StoriesAllStoriesNotModified{ + State: state, + StealthMode: tg.StoriesStealthMode{}, + } +} + +func tgStoriesStories(viewerUserID int64, list domain.StoryList) *tg.StoriesStories { + out := &tg.StoriesStories{ + Count: list.Count, + Stories: tgStoryItems(list.Stories), + Chats: tgChannels(viewerUserID, list.Channels), + Users: tgUsersForViewer(viewerUserID, list.Users), + } + if len(list.PinnedToTop) > 0 { + out.SetPinnedToTop(append([]int(nil), list.PinnedToTop...)) + } + return out +} + +func tgStoriesPeerStories(viewerUserID int64, peerStories domain.PeerStories) *tg.StoriesPeerStories { + return &tg.StoriesPeerStories{ + Stories: tgPeerStories(peerStories), + Chats: tgChannels(viewerUserID, peerStories.Channels), + Users: tgUsersForViewer(viewerUserID, peerStories.Users), + } +} + +func tgPeerStoriesList(items []domain.PeerStories) []tg.PeerStories { + out := make([]tg.PeerStories, 0, len(items)) + for _, item := range items { + out = append(out, tgPeerStories(item)) + } + return out +} + +func tgPeerStories(item domain.PeerStories) tg.PeerStories { + out := tg.PeerStories{ + Peer: tgPeer(item.Peer), + Stories: tgStoryItems(item.Stories), + } + if item.MaxReadID > 0 { + out.SetMaxReadID(item.MaxReadID) + } + return out +} + +func tgStoryItems(stories []domain.Story) []tg.StoryItemClass { + out := make([]tg.StoryItemClass, 0, len(stories)) + for _, story := range stories { + out = append(out, tgStoryItem(story)) + } + return out +} + +func tgStoryItem(story domain.Story) tg.StoryItemClass { + if story.Deleted { + return &tg.StoryItemDeleted{ID: story.ID} + } + media := tgMessageMedia(story.Media) + out := &tg.StoryItem{ + Pinned: story.Pinned, + Public: story.Public, + CloseFriends: story.CloseFriends, + Noforwards: story.NoForwards, + Edited: story.Edited, + Contacts: story.Contacts, + SelectedContacts: story.SelectedContacts, + Out: story.Out, + ID: story.ID, + Date: story.Date, + ExpireDate: story.ExpireDate, + Media: media, + } + if peer := tgPeer(story.Owner); peer != nil { + out.SetFromID(peer) + } + if story.Caption != "" { + out.SetCaption(story.Caption) + } + if len(story.Entities) > 0 { + out.SetEntities(tgMessageEntities(story.Entities)) + } + if len(story.MediaAreas) > 0 { + out.SetMediaAreas(tgStoryMediaAreas(story.MediaAreas)) + } + if forward := tgStoryForward(story.Forward); forward != nil { + out.SetFwdFrom(*forward) + } + if story.Out && len(story.PrivacyRules) > 0 { + out.SetPrivacy(tgPrivacyRules(story.PrivacyRules)) + } + if !storyViewsEmpty(story.Views) { + out.SetViews(tgStoryViews(story.Views)) + } + if story.SentReaction != nil { + out.SetSentReaction(tgMessageReaction(*story.SentReaction)) + } + return out +} + +func tgStoryForward(in *domain.StoryForward) *tg.StoryFwdHeader { + if in == nil { + return nil + } + out := &tg.StoryFwdHeader{ + Modified: in.Modified, + } + if peer := tgPeer(in.From); peer != nil { + out.SetFrom(peer) + } + if in.FromName != "" { + out.SetFromName(in.FromName) + } + if in.StoryID > 0 { + out.SetStoryID(in.StoryID) + } + if out.From == nil && out.FromName == "" && out.StoryID == 0 && !out.Modified { + return nil + } + return out +} + +func tgStoryMediaAreas(in []domain.StoryMediaArea) []tg.MediaAreaClass { + out := make([]tg.MediaAreaClass, 0, len(in)) + for _, area := range in { + switch area.Kind { + case domain.StoryMediaAreaSuggestedReaction: + if area.Reaction == nil { + continue + } + reaction := tgMessageReaction(*area.Reaction) + if reaction == nil { + continue + } + out = append(out, &tg.MediaAreaSuggestedReaction{ + Dark: area.Dark, + Flipped: area.Flipped, + Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates), + Reaction: reaction, + }) + case domain.StoryMediaAreaURL: + if area.URL == "" { + continue + } + out = append(out, &tg.MediaAreaURL{ + Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates), + URL: area.URL, + }) + case domain.StoryMediaAreaGeoPoint: + if area.Geo == nil { + continue + } + geo := &tg.MediaAreaGeoPoint{ + Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates), + Geo: tgGeoPoint(*area.Geo), + } + if address := tgStoryGeoPointAddress(area.GeoAddress); address != nil { + geo.SetAddress(*address) + } + out = append(out, geo) + case domain.StoryMediaAreaVenue: + if area.Venue == nil { + continue + } + out = append(out, &tg.MediaAreaVenue{ + Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates), + Geo: tgGeoPoint(area.Venue.Geo), + Title: area.Venue.Title, + Address: area.Venue.Address, + Provider: area.Venue.Provider, + VenueID: area.Venue.VenueID, + VenueType: area.Venue.VenueType, + }) + case domain.StoryMediaAreaWeather: + if area.WeatherEmoji == "" { + continue + } + out = append(out, &tg.MediaAreaWeather{ + Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates), + Emoji: area.WeatherEmoji, + TemperatureC: area.TemperatureC, + Color: area.Color, + }) + case domain.StoryMediaAreaChannelPost: + if area.ChannelID <= 0 || area.MsgID <= 0 { + continue + } + out = append(out, &tg.MediaAreaChannelPost{ + Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates), + ChannelID: area.ChannelID, + MsgID: area.MsgID, + }) + case domain.StoryMediaAreaStarGift: + if area.StarGiftSlug == "" { + continue + } + out = append(out, &tg.MediaAreaStarGift{ + Coordinates: tgStoryMediaAreaCoordinates(area.Coordinates), + Slug: area.StarGiftSlug, + }) + } + } + return out +} + +func tgStoryMediaAreaCoordinates(in domain.StoryMediaAreaCoordinates) tg.MediaAreaCoordinates { + out := tg.MediaAreaCoordinates{ + X: in.X, + Y: in.Y, + W: in.W, + H: in.H, + Rotation: in.Rotation, + } + if in.HasRadius { + out.SetRadius(in.Radius) + } + return out +} + +func tgStoryGeoPointAddress(in *domain.StoryGeoPointAddress) *tg.GeoPointAddress { + if in == nil || in.CountryISO2 == "" { + return nil + } + out := &tg.GeoPointAddress{CountryISO2: in.CountryISO2} + if in.State != "" { + out.SetState(in.State) + } + if in.City != "" { + out.SetCity(in.City) + } + if in.Street != "" { + out.SetStreet(in.Street) + } + return out +} + +func tgStoryViews(views domain.StoryViews) tg.StoryViews { + out := tg.StoryViews{ + HasViewers: views.HasViewers, + ViewsCount: views.ViewsCount, + } + if views.ForwardsCount > 0 { + out.SetForwardsCount(views.ForwardsCount) + } + if len(views.Reactions) > 0 { + out.SetReactions(tgStoryReactionCounts(views.Reactions)) + } + if views.ReactionsCount > 0 { + out.SetReactionsCount(views.ReactionsCount) + } + if len(views.RecentViewers) > 0 { + out.SetRecentViewers(append([]int64(nil), views.RecentViewers...)) + } + return out +} + +func storyViewsEmpty(views domain.StoryViews) bool { + return !views.HasViewers && + views.ViewsCount == 0 && + views.ForwardsCount == 0 && + views.ReactionsCount == 0 && + len(views.Reactions) == 0 && + len(views.RecentViewers) == 0 +} + +func tgStoryReactionCounts(in []domain.ChannelMessageReactionCount) []tg.ReactionCount { + out := make([]tg.ReactionCount, 0, len(in)) + for _, count := range in { + if count.Count <= 0 { + continue + } + item := tg.ReactionCount{ + Reaction: tgMessageReaction(count.Reaction), + Count: count.Count, + } + if count.ChosenOrder > 0 { + item.SetChosenOrder(count.ChosenOrder) + } + out = append(out, item) + } + return out +} + +func tgRecentStories(in []domain.RecentStory) []tg.RecentStory { + out := make([]tg.RecentStory, len(in)) + for i, story := range in { + out[i] = tg.RecentStory{Live: story.Live} + if story.MaxID > 0 { + out[i].SetMaxID(story.MaxID) + } + } + return out +} + +func tgReadStoryUpdates(states []domain.StoryReadState, date int) tg.UpdatesClass { + updates := make([]tg.UpdateClass, 0, len(states)) + for _, state := range states { + if state.MaxReadID <= 0 { + continue + } + peer := tgPeer(state.Peer) + if peer == nil { + continue + } + updates = append(updates, &tg.UpdateReadStories{Peer: peer, MaxID: state.MaxReadID}) + } + return &tg.Updates{ + Updates: updates, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: date, + Seq: 0, + } +} + +func tgStoryViewsList(viewerUserID int64, list domain.StoryViewList, users []domain.User) *tg.StoriesStoryViewsList { + out := &tg.StoriesStoryViewsList{ + Count: list.Count, + ViewsCount: list.ViewsCount, + ForwardsCount: list.ForwardsCount, + ReactionsCount: list.ReactionsCount, + Views: tgStoryViewItems(viewerUserID, list.Views), + Chats: []tg.ChatClass{}, + Users: tgUsersForViewer(viewerUserID, users), + } + if list.NextOffset != "" { + out.SetNextOffset(list.NextOffset) + } + return out +} + +func tgStoryViewItems(viewerUserID int64, views []domain.StoryView) []tg.StoryViewClass { + out := make([]tg.StoryViewClass, 0, len(views)) + for _, view := range views { + if view.PublicForward != nil { + msg := tgChannelMessage(viewerUserID, view.PublicForward.Message) + if msg == nil { + continue + } + out = append(out, &tg.StoryViewPublicForward{ + Blocked: view.Blocked, + BlockedMyStoriesFrom: view.BlockedMyStoriesFrom, + Message: msg, + }) + continue + } + if view.Repost != nil { + item := &tg.StoryViewPublicRepost{ + Blocked: view.Blocked, + BlockedMyStoriesFrom: view.BlockedMyStoriesFrom, + PeerID: tgPeer(view.Repost.Owner), + Story: tgStoryItem(*view.Repost), + } + out = append(out, item) + continue + } + item := &tg.StoryView{ + Blocked: view.Blocked, + BlockedMyStoriesFrom: view.BlockedMyStoriesFrom, + UserID: view.ViewerID, + Date: view.Date, + } + if view.Reaction != nil { + if reaction := tgMessageReaction(*view.Reaction); reaction != nil { + item.SetReaction(reaction) + } + } + out = append(out, item) + } + return out +} + +func tgStoryReactionsList(viewerUserID int64, list domain.StoryReactionList, users []domain.User) *tg.StoriesStoryReactionsList { + out := &tg.StoriesStoryReactionsList{ + Count: list.Count, + Reactions: tgStoryReactionItems(viewerUserID, list.Reactions), + Chats: []tg.ChatClass{}, + Users: tgUsersForViewer(viewerUserID, users), + } + if list.NextOffset != "" { + out.SetNextOffset(list.NextOffset) + } + return out +} + +func tgStoryReactionItems(viewerUserID int64, views []domain.StoryView) []tg.StoryReactionClass { + out := make([]tg.StoryReactionClass, 0, len(views)) + for _, view := range views { + if view.PublicForward != nil { + msg := tgChannelMessage(viewerUserID, view.PublicForward.Message) + if msg == nil { + continue + } + out = append(out, &tg.StoryReactionPublicForward{Message: msg}) + continue + } + if view.Repost != nil { + out = append(out, &tg.StoryReactionPublicRepost{ + PeerID: tgPeer(view.Repost.Owner), + Story: tgStoryItem(*view.Repost), + }) + continue + } + if view.Reaction == nil { + continue + } + reaction := tgMessageReaction(*view.Reaction) + if reaction == nil { + continue + } + out = append(out, &tg.StoryReaction{ + PeerID: &tg.PeerUser{UserID: view.ViewerID}, + Date: view.Date, + Reaction: reaction, + }) + } + return out +} diff --git a/internal/rpc/convert_test.go b/internal/rpc/convert_test.go index 38f5b8da..4b6d100c 100644 --- a/internal/rpc/convert_test.go +++ b/internal/rpc/convert_test.go @@ -61,3 +61,86 @@ func TestTGMessagesDialogsIncludesUserProfilePhoto(t *testing.T) { t.Fatalf("photo = %+v ok=%v, want userProfilePhoto 9301/2/[9 10]", peer.Photo, ok) } } + +func TestTGPhotoRejectsVideoOnlyAvatar(t *testing.T) { + photo := tgPhoto(domain.Photo{ + ID: 9302, + AccessHash: 7, + DCID: 2, + Sizes: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindVideo, Type: "u", W: 640, H: 640, Size: 1024}, + {Kind: domain.PhotoSizeKindVideoEmojiMarkup, EmojiID: 99, BackgroundColors: []int{0xffffff}}, + }, + }) + if _, ok := photo.(*tg.PhotoEmpty); !ok { + t.Fatalf("photo = %T %+v, want PhotoEmpty for video-only avatar", photo, photo) + } +} + +func TestTGPhotoKeepsAnimatedAvatarWithStaticSizes(t *testing.T) { + photo := tgPhoto(domain.Photo{ + ID: 9303, + AccessHash: 7, + DCID: 2, + Sizes: []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindDefault, Type: "a", W: 160, H: 160, Size: 1024}, + {Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 1024}, + {Kind: domain.PhotoSizeKindVideo, Type: "u", W: 640, H: 640, Size: 2048}, + {Kind: domain.PhotoSizeKindVideoEmojiMarkup, EmojiID: 99, BackgroundColors: []int{0xffffff}}, + }, + }) + full, ok := photo.(*tg.Photo) + if !ok { + t.Fatalf("photo = %T %+v, want Photo", photo, photo) + } + if len(full.Sizes) != 2 || len(full.VideoSizes) != 2 { + t.Fatalf("sizes = %d video_sizes = %d, want 2/2", len(full.Sizes), len(full.VideoSizes)) + } +} + +func TestMessageEntitiesRoundTripAllStyledTypes(t *testing.T) { + const viewerID int64 = 1001 + in := []tg.MessageEntityClass{ + &tg.MessageEntityBold{Offset: 0, Length: 1}, + &tg.MessageEntityItalic{Offset: 1, Length: 2}, + &tg.MessageEntityUnderline{Offset: 2, Length: 3}, + &tg.MessageEntityStrike{Offset: 3, Length: 4}, + &tg.MessageEntityCode{Offset: 4, Length: 5}, + &tg.MessageEntityPre{Offset: 5, Length: 6, Language: "go"}, + &tg.MessageEntityTextURL{Offset: 6, Length: 7, URL: "https://example.org"}, + &tg.MessageEntityMentionName{Offset: 7, Length: 8, UserID: 1002}, + &tg.InputMessageEntityMentionName{Offset: 8, Length: 9, UserID: &tg.InputUserSelf{}}, + &tg.MessageEntitySpoiler{Offset: 9, Length: 10}, + &tg.MessageEntityBlockquote{Offset: 10, Length: 11, Collapsed: true}, + &tg.MessageEntityCustomEmoji{Offset: 11, Length: 12, DocumentID: 777}, + &tg.MessageEntityMention{Offset: 12, Length: 13}, + &tg.MessageEntityHashtag{Offset: 13, Length: 14}, + &tg.MessageEntityURL{Offset: 14, Length: 15}, + } + converted := domainMessageEntitiesForViewer(viewerID, in) + if len(converted) != len(in) { + t.Fatalf("converted %d entities, want %d: no styled entity may be dropped", len(converted), len(in)) + } + if converted[6].URL != "https://example.org" { + t.Fatalf("text_url URL = %q, want preserved", converted[6].URL) + } + if converted[7].UserID != 1002 { + t.Fatalf("mention_name user = %d, want 1002", converted[7].UserID) + } + if converted[8].UserID != viewerID { + t.Fatalf("input mention self user = %d, want viewer %d", converted[8].UserID, viewerID) + } + if converted[11].DocumentID != 777 { + t.Fatalf("custom emoji document = %d, want 777", converted[11].DocumentID) + } + out := tgMessageEntities(converted) + if len(out) != len(in) { + t.Fatalf("round-trip produced %d entities, want %d", len(out), len(in)) + } + if quote, ok := out[10].(*tg.MessageEntityBlockquote); !ok || !quote.Collapsed { + t.Fatalf("blockquote = %#v, want collapsed flag preserved", out[10]) + } + if mention, ok := out[8].(*tg.MessageEntityMentionName); !ok || mention.UserID != viewerID { + t.Fatalf("self mention round-trip = %#v, want messageEntityMentionName self", out[8]) + } +} diff --git a/internal/rpc/convert_theme.go b/internal/rpc/convert_theme.go new file mode 100644 index 00000000..23f1caeb --- /dev/null +++ b/internal/rpc/convert_theme.go @@ -0,0 +1,220 @@ +package rpc + +import ( + "context" + "sort" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// themesListHash 计算一组主题的稳定哈希(服务端权威,客户端原样回传)。对 id 升序折叠, +// 与返回顺序无关;主题集合变化(用户新建/安装/卸载)即变,驱动客户端重取。 +func themesListHash(themes []tg.Theme) int64 { + ids := make([]int64, 0, len(themes)) + for _, t := range themes { + ids = append(ids, t.ID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + var h uint64 = 0xcbf29ce484222325 // FNV-1a 64 offset basis + for _, id := range ids { + h ^= uint64(id) + h *= 0x100000001b3 + } + return int64(h & 0x7fffffffffffffff) +} + +// themeRefFromInput 把 tg.InputThemeClass(inputTheme / inputThemeSlug)转成 domain.ThemeRef。 +func themeRefFromInput(in tg.InputThemeClass) (domain.ThemeRef, bool) { + switch v := in.(type) { + case *tg.InputTheme: + if v.ID == 0 { + return domain.ThemeRef{}, false + } + return domain.ThemeRef{ID: v.ID, AccessHash: v.AccessHash}, true + case *tg.InputThemeSlug: + if v.Slug == "" { + return domain.ThemeRef{}, false + } + return domain.ThemeRef{Slug: v.Slug}, true + default: + return domain.ThemeRef{}, false + } +} + +func domainBaseThemeFromInput(in tg.BaseThemeClass) domain.ThemeBaseKind { + switch in.(type) { + case *tg.BaseThemeClassic: + return domain.ThemeBaseClassic + case *tg.BaseThemeDay: + return domain.ThemeBaseDay + case *tg.BaseThemeNight: + return domain.ThemeBaseNight + case *tg.BaseThemeTinted: + return domain.ThemeBaseTinted + case *tg.BaseThemeArctic: + return domain.ThemeBaseArctic + default: + return domain.ThemeBaseClassic + } +} + +func tgBaseTheme(kind domain.ThemeBaseKind) tg.BaseThemeClass { + switch kind { + case domain.ThemeBaseDay: + return &tg.BaseThemeDay{} + case domain.ThemeBaseNight: + return &tg.BaseThemeNight{} + case domain.ThemeBaseTinted: + return &tg.BaseThemeTinted{} + case domain.ThemeBaseArctic: + return &tg.BaseThemeArctic{} + default: + return &tg.BaseThemeClassic{} + } +} + +// domainThemeSettingsFromInput 解析 createTheme/updateTheme 传入的 settings 向量。 +func domainThemeSettingsFromInput(in []tg.InputThemeSettings) []domain.ThemeSettingsSpec { + if len(in) == 0 { + return nil + } + out := make([]domain.ThemeSettingsSpec, 0, len(in)) + for _, s := range in { + spec := domain.ThemeSettingsSpec{ + BaseTheme: domainBaseThemeFromInput(s.BaseTheme), + AccentColor: s.GetAccentColor(), + MessageColorsAnimated: s.GetMessageColorsAnimated(), + } + if v, ok := s.GetOutboxAccentColor(); ok { + spec.OutboxAccentColor = v + spec.HasOutboxAccent = true + } + if v, ok := s.GetMessageColors(); ok { + spec.MessageColors = append([]int(nil), v...) + } + // flag bit1 同时门控 wallpaper 与 wallpaper_settings;渐变色在 wallpaper_settings 里。 + if wp, ok := s.GetWallpaperSettings(); ok { + spec.Wallpaper = domainThemeWallpaperFromInput(wp) + } + out = append(out, spec) + } + return out +} + +func domainThemeWallpaperFromInput(wp tg.WallPaperSettings) *domain.ThemeWallpaperSpec { + out := &domain.ThemeWallpaperSpec{Blur: wp.Blur, Motion: wp.Motion} + colors := make([]int, 0, 4) + if v, ok := wp.GetBackgroundColor(); ok { + colors = append(colors, v) + } + if v, ok := wp.GetSecondBackgroundColor(); ok { + colors = append(colors, v) + } + if v, ok := wp.GetThirdBackgroundColor(); ok { + colors = append(colors, v) + } + if v, ok := wp.GetFourthBackgroundColor(); ok { + colors = append(colors, v) + } + if len(colors) > 0 { + out.BackgroundColors = colors + } + if v, ok := wp.GetIntensity(); ok { + out.Intensity = v + } + if v, ok := wp.GetRotation(); ok { + out.Rotation = v + } + if v, ok := wp.GetEmoticon(); ok { + out.Emoticon = v + } + return out +} + +// tgTheme 把 domain.Theme 投影为 tg.Theme。viewerUserID 决定 creator 标志(决定客户端 +// 下次上传走 update 而非 create)。完整自定义主题靠 DocumentID 解析出可下载 Document。 +func (r *Router) tgTheme(ctx context.Context, t domain.Theme, viewerUserID int64) *tg.Theme { + out := &tg.Theme{ + ID: t.ID, + AccessHash: t.AccessHash, + Slug: t.Slug, + Title: t.Title, + } + if t.IsCreator(viewerUserID) { + out.SetCreator(true) + } + if t.ForChat { + out.SetForChat(true) + } + if t.Emoticon != "" { + out.SetEmoticon(t.Emoticon) + } + out.SetInstallsCount(t.InstallsCount) + if len(t.Settings) > 0 { + out.SetSettings(tgThemeSettingsList(t.Settings)) + } + if t.DocumentID != 0 && r.deps.Files != nil { + if doc, ok, err := r.deps.Files.GetDocument(ctx, t.DocumentID); err == nil && ok { + out.SetDocument(tgDocument(doc)) + } + } + return out +} + +func tgThemeSettingsList(in []domain.ThemeSettingsSpec) []tg.ThemeSettings { + out := make([]tg.ThemeSettings, 0, len(in)) + for _, s := range in { + ts := tg.ThemeSettings{ + BaseTheme: tgBaseTheme(s.BaseTheme), + AccentColor: s.AccentColor, + } + if s.HasOutboxAccent { + ts.SetOutboxAccentColor(s.OutboxAccentColor) + } + if len(s.MessageColors) > 0 { + ts.SetMessageColors(append([]int(nil), s.MessageColors...)) + } + if s.MessageColorsAnimated { + ts.SetMessageColorsAnimated(true) + } + if s.Wallpaper != nil { + ts.SetWallpaper(tgThemeWallpaper(s.Wallpaper)) + } + out = append(out, ts) + } + return out +} + +func tgThemeWallpaper(wp *domain.ThemeWallpaperSpec) tg.WallPaperClass { + settings := tg.WallPaperSettings{Blur: wp.Blur, Motion: wp.Motion} + colors := wp.BackgroundColors + if len(colors) > 0 { + settings.SetBackgroundColor(colors[0]) + } + if len(colors) > 1 { + settings.SetSecondBackgroundColor(colors[1]) + } + if len(colors) > 2 { + settings.SetThirdBackgroundColor(colors[2]) + } + if len(colors) > 3 { + settings.SetFourthBackgroundColor(colors[3]) + } + if wp.Intensity != 0 { + settings.SetIntensity(wp.Intensity) + } + if wp.Rotation != 0 { + settings.SetRotation(wp.Rotation) + } + if wp.Emoticon != "" { + settings.SetEmoticon(wp.Emoticon) + } + out := &tg.WallPaperNoFile{} + if wp.Dark { + out.SetDark(true) + } + out.SetSettings(settings) + return out +} diff --git a/internal/rpc/convert_updates.go b/internal/rpc/convert_updates.go new file mode 100644 index 00000000..a94fea36 --- /dev/null +++ b/internal/rpc/convert_updates.go @@ -0,0 +1,595 @@ +package rpc + +import ( + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func tgUpdateState(st domain.UpdateState) tg.UpdatesState { + return tg.UpdatesState{Pts: st.Pts, Qts: st.Qts, Date: st.Date, Seq: st.Seq} +} + +func tgUpdatesDifference(viewerUserID int64, diff domain.UpdateDifference) tg.UpdatesDifferenceClass { + out := &tg.UpdatesDifference{ + NewMessages: make([]tg.MessageClass, 0, len(diff.Events)), + OtherUpdates: make([]tg.UpdateClass, 0, len(diff.Events)), + Users: make([]tg.UserClass, 0, 1), + Chats: make([]tg.ChatClass, 0, 1), + } + seenUsers := make(map[int64]struct{}) + seenChats := make(map[int64]struct{}) + for _, event := range diff.Events { + addUsers(out, seenUsers, viewerUserID, event.Users) + addChannels(out, seenChats, event.UserID, event.Channels) + switch event.Type { + case domain.UpdateEventNewMessage: + if msg := tgMessage(event.Message); msg != nil { + out.NewMessages = append(out.NewMessages, msg) + addMessageUsers(out, seenUsers, event.Message) + } + case domain.UpdateEventReadHistoryInbox: + if update := tgReadHistoryInboxUpdate(event); update != nil { + out.OtherUpdates = append(out.OtherUpdates, update) + } + case domain.UpdateEventReadHistoryOutbox: + if update := tgReadHistoryOutboxUpdate(event); update != nil { + out.OtherUpdates = append(out.OtherUpdates, update) + } + case domain.UpdateEventMessageReactions, domain.UpdateEventMessagePoll: + // 同时下发消息快照(含最新聚合)与对应通知 update;事件无 TL pts, + // pts 推进靠 difference state 本身。 + if msg := tgMessage(event.Message); msg != nil { + out.NewMessages = append(out.NewMessages, msg) + addMessageUsers(out, seenUsers, event.Message) + } + if update := tgOtherUpdateFromEvent(event); update != nil { + out.OtherUpdates = append(out.OtherUpdates, update) + } + default: + if update := tgOtherUpdateFromEvent(event); update != nil { + out.OtherUpdates = append(out.OtherUpdates, update) + } + } + } + for _, nudge := range diff.ChannelNudges { + if nudge.ChannelID == 0 { + continue + } + update := &tg.UpdateChannelTooLong{ChannelID: nudge.ChannelID} + if nudge.Pts > 0 { + update.SetPts(nudge.Pts) + } + out.OtherUpdates = append(out.OtherUpdates, update) + if nudge.Channel != nil && nudge.Channel.Channel.ID != 0 { + addChannelNudgeChat(out, seenChats, tgChannelChatForView(viewerUserID, *nudge.Channel)) + } + } + // Partial:连续事件被 limit 截断、后面还有 → updates.differenceSlice,客户端据 IntermediateState 续拉。 + if diff.Partial { + return &tg.UpdatesDifferenceSlice{ + NewMessages: out.NewMessages, + OtherUpdates: out.OtherUpdates, + Chats: out.Chats, + Users: out.Users, + IntermediateState: tgUpdateState(diff.State), + } + } + out.State = tgUpdateState(diff.State) + return out +} + +func tgChannelDifference(viewerUserID int64, diff domain.ChannelDifference) tg.UpdatesChannelDifferenceClass { + if diff.TooLong { + messages := make([]tg.MessageClass, 0, len(diff.NewMessages)) + for _, msg := range diff.NewMessages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + dialog := tgDialog(domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: diff.Channel.ID}, + TopMessage: diff.Dialog.TopMessageID, + TopMessageDate: diff.Dialog.TopMessageDate, + ReadInboxMaxID: diff.Dialog.ReadInboxMaxID, + ReadOutboxMaxID: diff.Dialog.ReadOutboxMaxID, + UnreadCount: diff.Dialog.UnreadCount, + UnreadMentions: diff.Dialog.UnreadMentions, + UnreadReactions: diff.Dialog.UnreadReactions, + UnreadMark: diff.Dialog.UnreadMark, + FolderID: diff.Dialog.FolderID, + }) + if dialog != nil { + dialog.SetPts(diff.Pts) + } + return &tg.UpdatesChannelDifferenceTooLong{ + Final: diff.Final, + Timeout: diff.Timeout, + Dialog: dialog, + Messages: messages, + Chats: tgChannelDifferenceChats(viewerUserID, diff), + Users: tgUsersForViewer(viewerUserID, diff.Users), + } + } + if len(diff.Events) == 0 && len(diff.NewMessages) == 0 && len(diff.OtherUpdates) == 0 { + return &tg.UpdatesChannelDifferenceEmpty{ + Final: diff.Final, + Pts: diff.Pts, + Timeout: diff.Timeout, + } + } + messages := make([]tg.MessageClass, 0, len(diff.NewMessages)) + for _, msg := range diff.NewMessages { + if item := tgChannelMessage(viewerUserID, msg); item != nil { + messages = append(messages, item) + } + } + updates := make([]tg.UpdateClass, 0, len(diff.OtherUpdates)) + // 断线后经差量首次看到自己消息时,updateMessageID 让客户端把本地 + // pending 按 random_id 对账,避免短暂的重复气泡。 + for _, msg := range diff.NewMessages { + if msg.SenderUserID == viewerUserID && viewerUserID != 0 && msg.RandomID != 0 && msg.ID > 0 { + updates = append(updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: msg.RandomID}) + } + } + for _, event := range diff.OtherUpdates { + if update := tgChannelUpdate(viewerUserID, event); update != nil { + updates = append(updates, update) + } + } + chats := tgChannelDifferenceChats(viewerUserID, diff) + users := tgUsersForViewer(viewerUserID, diff.Users) + return &tg.UpdatesChannelDifference{ + Final: diff.Final, + Pts: diff.Pts, + Timeout: diff.Timeout, + NewMessages: messages, + OtherUpdates: updates, + Chats: chats, + Users: users, + } +} + +func tgChannelDifferenceChats(viewerUserID int64, diff domain.ChannelDifference) []tg.ChatClass { + return tgChannelChatsWithPrimarySelf(viewerUserID, diff.Channel, diff.Channels, diff.Self) +} + +func tgChannelUpdate(viewerUserID int64, event domain.ChannelUpdateEvent) tg.UpdateClass { + switch event.Type { + case domain.ChannelUpdateNewMessage: + msg := tgChannelMessage(viewerUserID, event.Message) + if msg == nil { + return nil + } + return &tg.UpdateNewChannelMessage{Message: msg, Pts: event.Pts, PtsCount: event.PtsCount} + case domain.ChannelUpdateEditMessage: + msg := tgChannelMessage(viewerUserID, event.Message) + if msg == nil { + return nil + } + return &tg.UpdateEditChannelMessage{Message: msg, Pts: event.Pts, PtsCount: event.PtsCount} + case domain.ChannelUpdateWebPage: + // 频道链接预览就地替换:updateChannelWebPage 仅携已解析 webPage(按 id 关联消息里的 + // pending 占位),客户端就地换卡片、不触碰 edit_date。 + if event.Message.Media == nil || event.Message.Media.WebPage == nil { + return nil + } + return &tg.UpdateChannelWebPage{ + ChannelID: event.ChannelID, + Webpage: tgWebPage(*event.Message.Media.WebPage), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.ChannelUpdateDeleteMessages: + if len(event.MessageIDs) == 0 { + return nil + } + return &tg.UpdateDeleteChannelMessages{ + ChannelID: event.ChannelID, + Messages: append([]int(nil), event.MessageIDs...), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.ChannelUpdatePinnedMessages: + if len(event.MessageIDs) == 0 { + return nil + } + return &tg.UpdatePinnedChannelMessages{ + Pinned: event.Pinned, + ChannelID: event.ChannelID, + Messages: append([]int(nil), event.MessageIDs...), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.ChannelUpdateParticipant: + userID := event.Participant.UserID + if userID == 0 { + userID = event.Previous.UserID + } + if userID == 0 { + return nil + } + actorID := event.SenderUserID + if actorID == 0 { + actorID = userID + } + update := &tg.UpdateChannelParticipant{ + ChannelID: event.ChannelID, + Date: event.Date, + ActorID: actorID, + UserID: userID, + } + if event.Previous.UserID != 0 { + update.SetPrevParticipant(tgChannelParticipantForUpdate(viewerUserID, event.Previous)) + } + if event.Participant.UserID != 0 { + update.SetNewParticipant(tgChannelParticipantForUpdate(viewerUserID, event.Participant)) + } + return update + default: + return nil + } +} + +func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass { + switch event.Type { + case domain.UpdateEventChannelState: + if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { + return nil + } + return &tg.UpdateChannel{ChannelID: event.Peer.ID} + case domain.UpdateEventStory: + peer := tgPeer(event.Peer) + if peer == nil { + peer = tgPeer(event.Story.Owner) + } + if peer == nil || event.Story.ID == 0 { + return nil + } + return &tg.UpdateStory{Peer: peer, Story: tgStoryItem(event.Story)} + case domain.UpdateEventReadStories: + peer := tgPeer(event.Peer) + if peer == nil || event.MaxID <= 0 { + return nil + } + return &tg.UpdateReadStories{Peer: peer, MaxID: event.MaxID} + case domain.UpdateEventSentStoryReaction: + peer := tgPeer(event.Peer) + if peer == nil || event.MaxID <= 0 { + return nil + } + reaction := tg.ReactionClass(&tg.ReactionEmpty{}) + if event.Reaction != nil { + reaction = tgMessageReaction(*event.Reaction) + } + return &tg.UpdateSentStoryReaction{Peer: peer, StoryID: event.MaxID, Reaction: reaction} + case domain.UpdateEventNewStoryReaction: + peer := tgPeer(event.Peer) + if peer == nil || event.MaxID <= 0 || event.Reaction == nil { + return nil + } + return &tg.UpdateNewStoryReaction{Peer: peer, StoryID: event.MaxID, Reaction: tgMessageReaction(*event.Reaction)} + case domain.UpdateEventQuickReplies: + return &tg.UpdateQuickReplies{QuickReplies: tgQuickReplies(event.QuickReplies)} + case domain.UpdateEventNewQuickReply: + return &tg.UpdateNewQuickReply{QuickReply: tgQuickReply(event.QuickReply)} + case domain.UpdateEventDeleteQuickReply: + if event.MaxID <= 0 { + return nil + } + return &tg.UpdateDeleteQuickReply{ShortcutID: event.MaxID} + case domain.UpdateEventQuickReplyMessage: + msg := tgQuickReplyMessage(event.QuickReplyMessage) + if msg == nil { + return nil + } + return &tg.UpdateQuickReplyMessage{Message: msg} + case domain.UpdateEventDeleteQuickReplyMessages: + if event.MaxID <= 0 || len(event.MessageIDs) == 0 { + return nil + } + return &tg.UpdateDeleteQuickReplyMessages{ + ShortcutID: event.MaxID, + Messages: append([]int(nil), event.MessageIDs...), + } + case domain.UpdateEventContactsReset: + return &tg.UpdateContactsReset{} + case domain.UpdateEventDialogPinned: + // archive folder 行自身的置顶:peer 是 dialogPeerFolder 且绝不能带 + // folder_id flag(TDesktop Folder::applyPinnedUpdate 视其为 + // "Nested folders" 错误)。 + if event.Peer.Type == domain.PeerTypeFolder { + if event.Peer.ID == 0 { + return nil + } + return &tg.UpdateDialogPinned{Pinned: event.Bool, Peer: &tg.DialogPeerFolder{FolderID: int(event.Peer.ID)}} + } + peer := tgDialogPeer(event.Peer) + if peer == nil { + return nil + } + // FolderID 零值不编码 flag(EncodeBare 自动 SetFlags),归档内置顶 + // 必须带 folder_id=1,否则离线重放会把它应用到主列表。 + return &tg.UpdateDialogPinned{Pinned: event.Bool, Peer: peer, FolderID: event.FolderID} + case domain.UpdateEventPinnedDialogs: + update := &tg.UpdatePinnedDialogs{FolderID: event.FolderID} + if len(event.Peers) > 0 { + update.Order = tgDialogPeers(event.Peers) + } + return update + case domain.UpdateEventSavedDialogPinned: + peer := tgDialogPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdateSavedDialogPinned{Pinned: event.Bool, Peer: peer} + case domain.UpdateEventPinnedSavedDialogs: + update := &tg.UpdatePinnedSavedDialogs{} + if len(event.Peers) > 0 { + update.SetOrder(tgDialogPeers(event.Peers)) + } + return update + case domain.UpdateEventDialogUnreadMark: + peer := tgDialogPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdateDialogUnreadMark{Unread: event.Bool, Peer: peer} + case domain.UpdateEventDraftMessage: + peer := tgPeer(event.Peer) + if peer == nil { + return nil + } + // Draft 由 enrichDraftMessageEvent 按当前权威态填充;nil/空 = 草稿已删。 + var draft tg.DraftMessageClass + if event.Draft != nil && !event.Draft.Empty() { + draft = tgDialogDraft(*event.Draft) + } else { + empty := &tg.DraftMessageEmpty{} + if event.Date > 0 { + empty.SetDate(event.Date) + } + draft = empty + } + update := &tg.UpdateDraftMessage{Peer: peer, Draft: draft} + if event.MaxID > 0 { + update.SetTopMsgID(event.MaxID) + } + return update + case domain.UpdateEventChannelViewForum: + if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { + return nil + } + return &tg.UpdateChannelViewForumAsMessages{ChannelID: event.Peer.ID, Enabled: event.Bool} + case domain.UpdateEventReadChannelDiscussionInbox: + if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { + return nil + } + return &tg.UpdateReadChannelDiscussionInbox{ChannelID: event.Peer.ID, TopMsgID: event.TopMsgID, ReadMaxID: event.MaxID} + case domain.UpdateEventReadChannelDiscussionOutbox: + if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 { + return nil + } + return &tg.UpdateReadChannelDiscussionOutbox{ChannelID: event.Peer.ID, TopMsgID: event.TopMsgID, ReadMaxID: event.MaxID} + case domain.UpdateEventPeerSettings: + peer := tgPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdatePeerSettings{Peer: peer, Settings: tgPeerSettings(event.Settings)} + case domain.UpdateEventPeerStoryBlocked: + peer := tgPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdatePeerBlocked{PeerID: peer, Blocked: event.Bool, BlockedMyStoriesFrom: true} + case domain.UpdateEventDeleteMessages: + if len(event.MessageIDs) == 0 { + return nil + } + return &tg.UpdateDeleteMessages{ + Messages: append([]int(nil), event.MessageIDs...), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.UpdateEventPinnedMessages: + peer := tgPeer(event.Peer) + if peer == nil || len(event.MessageIDs) == 0 { + return nil + } + return &tg.UpdatePinnedMessages{ + Pinned: event.Bool, + Peer: peer, + Messages: append([]int(nil), event.MessageIDs...), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.UpdateEventReadHistoryOutbox: + return tgReadHistoryOutbox(event) + case domain.UpdateEventReadMessageContents: + if len(event.MessageIDs) == 0 { + return nil + } + contents := &tg.UpdateReadMessagesContents{ + Messages: append([]int(nil), event.MessageIDs...), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + if event.Date > 0 { + // date 是内容被读取的时刻:客户端用它调度 TTL 媒体的 + // "自读取起算"删除与 sender 侧已听时间。 + contents.SetDate(event.Date) + } + return contents + case domain.UpdateEventEditMessage: + msg := tgMessage(event.Message) + if msg == nil { + return nil + } + return &tg.UpdateEditMessage{ + Message: msg, + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.UpdateEventWebPage: + // 链接预览就地替换:updateWebPage 仅携带已解析 webPage(按 webPage id 与消息里的 + // pending 占位关联),客户端就地换卡片、不触碰 edit_date。 + if event.Message.Media == nil || event.Message.Media.WebPage == nil { + return nil + } + return &tg.UpdateWebPage{ + Webpage: tgWebPage(*event.Message.Media.WebPage), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.UpdateEventMessagePoll: + if event.Message.ID <= 0 || event.Message.ID > domain.MaxMessageBoxID { + return nil + } + pollPeer := event.Message.Peer + if pollPeer.Type == "" || pollPeer.ID == 0 { + pollPeer = event.Peer + } + media := event.Message.Media + if media == nil || media.Kind != domain.MessageMediaKindPoll || media.Poll == nil { + return nil + } + return tgUpdateMessagePoll(pollPeer, event.Message.ID, media.Poll) + case domain.UpdateEventMessageReactions: + if event.Message.ID <= 0 || event.Message.ID > domain.MaxMessageBoxID { + return nil + } + peer := event.Message.Peer + if peer.Type == "" || peer.ID == 0 { + peer = event.Peer + } + outPeer := tgPeer(peer) + if outPeer == nil { + return nil + } + reactions := event.Message.Reactions + if reactions == nil { + empty := domain.ChannelMessageReactions{CanSeeList: true, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}} + reactions = &empty + } + converted := tgMessageReactions(event.UserID, reactions) + if converted == nil { + converted = &tg.MessageReactions{Results: []tg.ReactionCount{}} + } + return &tg.UpdateMessageReactions{ + Peer: outPeer, + MsgID: event.Message.ID, + Reactions: *converted, + } + case domain.UpdateEventDialogFilter: + update := &tg.UpdateDialogFilter{ID: event.FilterID} + if event.DialogFilter != nil { + update.SetFilter(tgDialogFilter(*event.DialogFilter)) + } + return update + case domain.UpdateEventDialogFilterOrder: + return &tg.UpdateDialogFilterOrder{Order: append([]int(nil), event.FilterOrder...)} + case domain.UpdateEventDialogFilters: + return &tg.UpdateDialogFilters{} + case domain.UpdateEventFolderPeers: + if len(event.FolderPeers) == 0 { + return nil + } + return &tg.UpdateFolderPeers{ + FolderPeers: tgFolderPeers(event.FolderPeers), + Pts: event.Pts, + PtsCount: event.PtsCount, + } + case domain.UpdateEventChannelAvailable: + if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 || event.MaxID <= 0 { + return nil + } + return &tg.UpdateChannelAvailableMessages{ + ChannelID: event.Peer.ID, + AvailableMinID: event.MaxID, + } + default: + return nil + } +} + +func addChannels(out *tg.UpdatesDifference, seen map[int64]struct{}, viewerUserID int64, channels []domain.Channel) { + for _, ch := range channels { + if ch.ID == 0 { + continue + } + if _, ok := seen[ch.ID]; ok { + continue + } + seen[ch.ID] = struct{}{} + out.Chats = append(out.Chats, tgChannelChatMin(viewerUserID, ch)) + } +} + +func addChannelNudgeChat(out *tg.UpdatesDifference, seen map[int64]struct{}, chat tg.ChatClass) { + channelID, ok := channelChatID(chat) + if !ok || channelID == 0 { + return + } + if _, exists := seen[channelID]; exists { + upgradeMinChannelChat(out, channelID, chat) + return + } + seen[channelID] = struct{}{} + out.Chats = append(out.Chats, chat) +} + +func upgradeMinChannelChat(out *tg.UpdatesDifference, channelID int64, candidate tg.ChatClass) { + next, ok := candidate.(*tg.Channel) + if !ok || next.Min { + return + } + for i, existing := range out.Chats { + existingID, ok := channelChatID(existing) + if !ok || existingID != channelID { + continue + } + if current, ok := existing.(*tg.Channel); ok && current.Min { + out.Chats[i] = candidate + } + return + } +} + +func channelChatID(chat tg.ChatClass) (int64, bool) { + switch v := chat.(type) { + case *tg.Channel: + return v.ID, true + case *tg.ChannelForbidden: + return v.ID, true + default: + return 0, false + } +} + +func tgChannelParticipantForUpdate(selfUserID int64, member domain.ChannelMember) tg.ChannelParticipantClass { + if member.UserID == 0 { + return nil + } + if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages { + return &tg.ChannelParticipantBanned{ + Left: member.Status != domain.ChannelMemberActive, + Peer: &tg.PeerUser{UserID: member.UserID}, + KickedBy: member.InviterUserID, + Date: member.LeftAt, + BannedRights: tgChatBannedRights(member.BannedRights), + } + } + if member.Status == domain.ChannelMemberLeft { + return &tg.ChannelParticipantLeft{Peer: &tg.PeerUser{UserID: member.UserID}} + } + return tgChannelParticipant(selfUserID, member) +} + +func tgLangPackDifference(pack domain.LangPack) *tg.LangPackDifference { + return &tg.LangPackDifference{ + LangCode: pack.LangCode, + FromVersion: pack.FromVersion, + Version: pack.Version, + Strings: tgLangPackStrings(pack.Strings), + } +} diff --git a/internal/rpc/convert_updates_pin_service_test.go b/internal/rpc/convert_updates_pin_service_test.go new file mode 100644 index 00000000..f25a602c --- /dev/null +++ b/internal/rpc/convert_updates_pin_service_test.go @@ -0,0 +1,65 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// TestDifferenceNewMessageKeepsPinServiceReply 验证差分输出的 pin 服务 +// 消息保留 reply 头(reply_to_msg_id = 接收方视角被置顶消息 id);缺失 +// 时 TDesktop 渲染 "pinned Deleted message"。 +func TestDifferenceNewMessageKeepsPinServiceReply(t *testing.T) { + const bobID = int64(1000000042) + const aliceID = int64(1000000041) + event := domain.UpdateEvent{ + UserID: bobID, + Type: domain.UpdateEventNewMessage, + Pts: 351, + PtsCount: 1, + Date: 1700000900, + Message: domain.Message{ + ID: 117, + OwnerUserID: bobID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + Date: 1700000900, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{Kind: domain.MessageServiceActionPinMessage}, + }, + ReplyTo: &domain.MessageReply{ + MessageID: 115, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + }, + }, + } + diff := domain.UpdateDifference{ + State: domain.UpdateState{Pts: 351, Date: 1700000901}, + Events: []domain.UpdateEvent{event}, + } + out, ok := tgUpdatesDifference(bobID, diff).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference type = %T, want *tg.UpdatesDifference", tgUpdatesDifference(bobID, diff)) + } + if len(out.NewMessages) != 1 { + t.Fatalf("new messages = %d, want 1", len(out.NewMessages)) + } + svc, ok := out.NewMessages[0].(*tg.MessageService) + if !ok { + t.Fatalf("message type = %T, want messageService", out.NewMessages[0]) + } + if _, ok := svc.Action.(*tg.MessageActionPinMessage); !ok { + t.Fatalf("action = %T, want messageActionPinMessage", svc.Action) + } + reply, ok := svc.GetReplyTo() + if !ok { + t.Fatalf("difference 服务消息丢失 reply 头(TDesktop 将渲染 pinned Deleted message)") + } + header, ok := reply.(*tg.MessageReplyHeader) + if !ok || header.ReplyToMsgID != 115 { + t.Fatalf("reply header = %+v, want reply_to_msg_id=115", reply) + } +} diff --git a/internal/rpc/convert_users.go b/internal/rpc/convert_users.go new file mode 100644 index 00000000..68571d95 --- /dev/null +++ b/internal/rpc/convert_users.go @@ -0,0 +1,308 @@ +package rpc + +import ( + "time" + + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +// tgSelfUser 把 domain.User 转为 self 标记的 tg.User(optional 字段由 Encode 自动 SetFlags)。 +func tgSelfUser(u domain.User) *tg.User { + out := &tg.User{ + ID: u.ID, + AccessHash: u.AccessHash, + FirstName: u.FirstName, + LastName: u.LastName, + Username: u.Username, + Phone: u.Phone, + Self: true, + Verified: u.Verified, + Support: u.Support, + Contact: u.Contact, + MutualContact: u.Mutual, + CloseFriend: u.CloseFriend, + Usernames: tgUsernames(u.Username), + } + applyTgUserBotFields(out, u) + applyTgUserPremiumFields(out, u) + applyTgUserColorFields(out, u) + if photo := tgUserProfilePhoto(u); photo != nil { + out.Photo = photo + } + return out +} + +func tgUser(u domain.User) *tg.User { + out := &tg.User{ + ID: u.ID, + AccessHash: u.AccessHash, + FirstName: u.FirstName, + LastName: u.LastName, + Username: u.Username, + Phone: u.Phone, + Verified: u.Verified, + Support: u.Support, + Contact: u.Contact, + MutualContact: u.Mutual, + CloseFriend: u.CloseFriend, + Usernames: tgUsernames(u.Username), + } + applyTgUserBotFields(out, u) + applyTgUserPremiumFields(out, u) + applyTgUserColorFields(out, u) + if photo := tgUserProfilePhoto(u); photo != nil { + out.Photo = photo + } + return out +} + +// applyTgUserPremiumFields 由到期时间即时派生 premium flag(bit28,独立位)与 +// emoji status。判断用真实时钟:premium 的权威来源是 premium_expires_at 本身, +// 到期即停发,正确性不依赖后台 sweeper(它只负责清理与 updateUser 通知); +// bot 永不带 premium(PremiumActiveAt 内排除)。 +func applyTgUserPremiumFields(out *tg.User, u domain.User) { + now := time.Now().Unix() + if !u.PremiumActiveAt(now) { + return + } + out.Premium = true + if u.EmojiStatusActiveAt(now) { + out.SetEmojiStatus(tgUserEmojiStatus(u, now)) + } +} + +// tgUserEmojiStatus 把用户 emoji status 转为 TL:未设置/已失效返回 +// emojiStatusEmpty(updateUserEmojiStatus 清除语义用)。user TL 内联输出仍由 +// applyTgUserPremiumFields 控制(无状态时直接省略字段,对齐官方 user 编码)。 +func tgUserEmojiStatus(u domain.User, now int64) tg.EmojiStatusClass { + if !u.EmojiStatusActiveAt(now) { + return &tg.EmojiStatusEmpty{} + } + status := &tg.EmojiStatus{DocumentID: u.EmojiStatusDocumentID} + if u.EmojiStatusUntil > 0 { + status.SetUntil(u.EmojiStatusUntil) + } + return status +} + +func applyTgUserColorFields(out *tg.User, u domain.User) { + if color := tgUserPeerColor(u.Color); color != nil { + out.SetColor(color) + } + if color := tgUserPeerColor(u.ProfileColor); color != nil { + out.SetProfileColor(color) + } +} + +func tgUserPeerColor(color domain.PeerColor) tg.PeerColorClass { + if color.Empty() { + return nil + } + out := &tg.PeerColor{} + if color.HasColor { + out.SetColor(color.Color) + } + if color.BackgroundEmojiID != 0 { + out.SetBackgroundEmojiID(color.BackgroundEmojiID) + } + return out +} + +// applyTgUserBotFields 应用 bot/普通用户的互斥输出: +// - bot:必须同时置 bot flag 与 bot_info_version(Layer 225 两者共用 flags +// bit14,TDesktop 只认 bot_info_version 字段是否存在);无 phone、无 status +// (客户端显示 "bot" 而非 last seen)。 +// - 普通用户:照常输出 presence status。 +func applyTgUserBotFields(out *tg.User, u domain.User) { + if !u.Bot { + out.Status = tgUserStatus(u.Status) + return + } + out.Bot = true + version := u.BotInfoVersion + if version < 1 { + version = 1 + } + out.SetBotInfoVersion(version) + if u.ID != domain.BotFatherUserID { + out.SetBotBusiness(true) + } + out.Phone = "" +} + +// isSystemUserID 报告 id 是否为内置系统账号(777000 / BotFather)。 +func isSystemUserID(id int64) bool { + _, ok := domain.SystemUserByID(id) + return ok +} + +// tgUserProfilePhoto 由 domain.User 反范式头像字段构造 UserProfilePhoto;无头像返回 nil(Encode 时为 empty)。 +func tgUserProfilePhoto(u domain.User) tg.UserProfilePhotoClass { + if u.PhotoID == 0 { + return nil + } + photo := &tg.UserProfilePhoto{PhotoID: u.PhotoID, DCID: u.PhotoDCID, Personal: u.PhotoPersonal} + if u.PhotoHasVideo { + photo.SetHasVideo(true) + } + if len(u.PhotoStripped) > 0 { + photo.SetStrippedThumb(u.PhotoStripped) + } + return photo +} + +func tgUserStatus(status domain.UserStatus) tg.UserStatusClass { + switch status.Kind { + case domain.UserStatusOnline: + if status.Expires > 0 { + return &tg.UserStatusOnline{Expires: status.Expires} + } + case domain.UserStatusOffline: + if status.WasOnline > 0 { + return &tg.UserStatusOffline{WasOnline: status.WasOnline} + } + case domain.UserStatusLastWeek: + return &tg.UserStatusLastWeek{} + case domain.UserStatusLastMonth: + return &tg.UserStatusLastMonth{} + case domain.UserStatusEmpty: + return &tg.UserStatusEmpty{} + case domain.UserStatusRecently, domain.UserStatusUnknown: + return &tg.UserStatusRecently{} + } + return &tg.UserStatusRecently{} +} + +func tgUsernames(username string) []tg.Username { + if username == "" { + return nil + } + return []tg.Username{{Editable: true, Active: true, Username: username}} +} + +func tgContacts(list domain.ContactList) tg.ContactsContactsClass { + out := &tg.ContactsContacts{ + Contacts: make([]tg.Contact, 0, len(list.Contacts)), + Users: make([]tg.UserClass, 0, len(list.Contacts)), + SavedCount: len(list.Contacts), + } + for _, c := range list.Contacts { + out.Contacts = append(out.Contacts, tg.Contact{UserID: c.User.ID, Mutual: c.Mutual}) + out.Users = append(out.Users, tgUser(c.User)) + } + return out +} + +func tgContactsFound(viewerUserID int64, res domain.UserSearchResult) *tg.ContactsFound { + out := &tg.ContactsFound{ + MyResults: make([]tg.PeerClass, 0, len(res.MyResults)+len(res.MyChannelResults)), + Results: make([]tg.PeerClass, 0, len(res.Results)+len(res.ChannelResults)), + Chats: make([]tg.ChatClass, 0, len(res.MyChannelResults)+len(res.ChannelResults)), + Users: make([]tg.UserClass, 0, len(res.MyResults)+len(res.Results)), + } + seen := make(map[int64]struct{}, len(res.MyResults)+len(res.Results)) + appendUser := func(u domain.User) { + if u.ID == 0 { + return + } + if _, ok := seen[u.ID]; ok { + return + } + seen[u.ID] = struct{}{} + out.Users = append(out.Users, tgUser(u)) + } + seenChannels := make(map[int64]struct{}, len(res.MyChannelResults)+len(res.ChannelResults)) + appendChannel := func(ch domain.Channel, self *domain.ChannelMember) { + if ch.ID == 0 { + return + } + if _, ok := seenChannels[ch.ID]; ok { + return + } + seenChannels[ch.ID] = struct{}{} + out.Chats = append(out.Chats, tgChannelChat(viewerUserID, ch, self)) + } + for _, u := range res.MyResults { + out.MyResults = append(out.MyResults, &tg.PeerUser{UserID: u.ID}) + appendUser(u) + } + for _, ch := range res.MyChannelResults { + out.MyResults = append(out.MyResults, &tg.PeerChannel{ChannelID: ch.ID}) + appendChannel(ch, nil) + } + for _, u := range res.Results { + out.Results = append(out.Results, &tg.PeerUser{UserID: u.ID}) + appendUser(u) + } + for _, ch := range res.ChannelResults { + out.Results = append(out.Results, &tg.PeerChannel{ChannelID: ch.ID}) + appendChannel(ch, &domain.ChannelMember{ChannelID: ch.ID, UserID: viewerUserID, Status: domain.ChannelMemberLeft}) + } + return out +} + +func tgUsers(users []domain.User) []tg.UserClass { + out := make([]tg.UserClass, 0, len(users)) + for _, u := range users { + out = append(out, tgUser(u)) + } + return out +} + +func tgUsersForViewer(viewerUserID int64, users []domain.User) []tg.UserClass { + out := make([]tg.UserClass, 0, len(users)) + for _, u := range users { + if viewerUserID != 0 && u.ID == viewerUserID { + out = append(out, tgSelfUser(u)) + continue + } + out = append(out, tgUser(u)) + } + return out +} + +func addUsers(out *tg.UpdatesDifference, seen map[int64]struct{}, viewerUserID int64, users []domain.User) { + for _, u := range users { + if u.ID == 0 { + continue + } + if _, ok := seen[u.ID]; ok { + continue + } + seen[u.ID] = struct{}{} + // difference 里自己的 user 必须带 self 标志:客户端会用该对象 + // 持久覆盖当前账号资料,self=false 会破坏 Saved Messages 语义。 + if viewerUserID != 0 && u.ID == viewerUserID { + out.Users = append(out.Users, tgSelfUser(u)) + continue + } + out.Users = append(out.Users, tgUser(u)) + } +} + +func addMessageUsers(out *tg.UpdatesDifference, seen map[int64]struct{}, msg domain.Message) { + for _, peer := range []domain.Peer{msg.From, msg.Peer, domain.Peer{Type: domain.PeerTypeUser, ID: msg.ViaBotID}} { + if peer.Type != domain.PeerTypeUser { + continue + } + if _, ok := seen[peer.ID]; ok { + continue + } + seen[peer.ID] = struct{}{} + if u, ok := domain.SystemUserByID(peer.ID); ok { + out.Users = append(out.Users, tgUser(u)) + } + } +} + +func tgChannelEmojiStatus(status domain.ChannelEmojiStatus) tg.EmojiStatusClass { + if status.Empty() { + return nil + } + out := &tg.EmojiStatus{DocumentID: status.DocumentID} + if status.Until > 0 { + out.SetUntil(status.Until) + } + return out +} diff --git a/internal/rpc/convert_users_premium_test.go b/internal/rpc/convert_users_premium_test.go new file mode 100644 index 00000000..eb4d9bda --- /dev/null +++ b/internal/rpc/convert_users_premium_test.go @@ -0,0 +1,63 @@ +package rpc + +import ( + "testing" + "time" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func TestTgUserPremiumFlag(t *testing.T) { + now := time.Now().Unix() + premium := domain.User{ID: 1, AccessHash: 2, FirstName: "P", PremiumUntil: int(now + 3600)} + expired := domain.User{ID: 2, AccessHash: 2, FirstName: "E", PremiumUntil: int(now - 1)} + bot := domain.User{ID: 3, AccessHash: 2, FirstName: "B", Bot: true, BotInfoVersion: 1, PremiumUntil: int(now + 3600)} + + if out := tgUser(premium); !out.Premium { + t.Fatal("premium user missing premium flag") + } + if out := tgSelfUser(premium); !out.Premium { + t.Fatal("premium self user missing premium flag") + } + if out := tgUser(expired); out.Premium { + t.Fatal("expired user must not carry premium flag") + } + if out := tgUser(bot); out.Premium { + t.Fatal("bot must never carry premium flag") + } +} + +func TestTgUserEmojiStatusHydration(t *testing.T) { + now := time.Now().Unix() + u := domain.User{ + ID: 1, AccessHash: 2, FirstName: "P", + PremiumUntil: int(now + 3600), + EmojiStatusDocumentID: 777, + EmojiStatusUntil: int(now + 600), + } + out := tgUser(u) + status, ok := out.GetEmojiStatus() + if !ok { + t.Fatal("premium user with status missing emoji_status field") + } + es, ok := status.(*tg.EmojiStatus) + if !ok || es.DocumentID != 777 { + t.Fatalf("emoji status = %#v, want document 777", status) + } + if until, ok := es.GetUntil(); !ok || until != int(now+600) { + t.Fatalf("emoji status until = %d ok=%v", until, ok) + } + + // 会员到期后 emoji status 残值不得内联进 user TL。 + u.PremiumUntil = int(now - 1) + if _, ok := tgUser(u).GetEmojiStatus(); ok { + t.Fatal("expired premium must not inline emoji_status") + } + + // updateUserEmojiStatus 清除语义返回 emojiStatusEmpty。 + if _, ok := tgUserEmojiStatus(u, now).(*tg.EmojiStatusEmpty); !ok { + t.Fatalf("tgUserEmojiStatus expired = %#v, want EmojiStatusEmpty", tgUserEmojiStatus(u, now)) + } +} diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 457bac04..efa6f7d2 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -8,6 +8,9 @@ import ( "github.com/gotd/td/proto" "telesrv/internal/domain" + "telesrv/internal/sfu" + "telesrv/internal/store" + "telesrv/internal/turnsrv" ) // 本文件按「消费者定义接口」惯例,在 rpc 包定义 Router 依赖的业务服务接口。 @@ -19,12 +22,23 @@ type AuthService interface { BindTempAuthKey(ctx context.Context, sessionID int64, binding domain.TempAuthKeyBinding) error ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) + PendingPasswordUserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) + CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) error SendCode(ctx context.Context, phone string) (string, error) ResendCode(ctx context.Context, phone, phoneCodeHash string) (string, error) CancelCode(ctx context.Context, phone, phoneCodeHash string) error SignIn(ctx context.Context, a domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) + // SignInWithEmail 处理带 email_verification 的 auth.signIn(登录邮箱路径)。 + SignInWithEmail(ctx context.Context, a domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) SignUp(ctx context.Context, a domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) + AcceptLoginToken(ctx context.Context, a domain.Authorization, userID int64) (domain.Authorization, error) + // BindVerifiedLogin 绑定一个已由外部强因子(passkey)验证身份的用户,直接完成授权。 + BindVerifiedLogin(ctx context.Context, a domain.Authorization, userID int64) (domain.User, error) + // SignInBot 校验 bot token 并绑定授权(auth.importBotAuthorization); + // 校验失败返回 domain.ErrBotTokenInvalid。 + SignInBot(ctx context.Context, a domain.Authorization, token string) (domain.User, error) LogOut(ctx context.Context, authKeyID [8]byte) error + Authorization(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error) ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) ResetAuthorization(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) @@ -56,6 +70,33 @@ type ScopedSessionBinder interface { PushToUserExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) } +// ScopedImmediateSessionPusher 是可选的登录前信号直推能力。 +// 它绕过登录后 updates-ready 队列,只能用于会解锁登录流程本身的握手消息, +// 例如 updateLoginToken。 +type ScopedImmediateSessionPusher interface { + PushToSessionForAuthKeyImmediate(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error +} + +// SessionUpdatesStateProvider 暴露连接当前的 updates 接收状态(可选能力)。 +// 用于按 RPC 置位 receivesUpdates 时的幂等短路;不实现时每次都走完整置位(幂等,仅多余开销)。 +type SessionUpdatesStateProvider interface { + ReceivesUpdatesForAuthKey(rawAuthKeyID [8]byte, sessionID int64) bool +} + +// SessionTerminator 暴露按业务 auth_key 强制断开活跃连接的能力(可选)。 +// 授权撤销(被踢设备)必须断开连接:出站推送用连接持有的密钥加密、不回查授权, +// perm-key 连接的授权缓存也只有断开重连才会重新回查授权表。 +type SessionTerminator interface { + CloseSessionsForBusinessAuthKey(authKeyID [8]byte) int +} + +// RawSessionTerminator 暴露按连接实际 raw auth_key 强制断开的能力(可选)。 +// temp auth key 被撤销时,Router 会先从 temp→perm 缓存里找出同一授权的 raw temp key, +// 再用这个接口踢掉仍未完全解析到业务 key 的活跃连接,避免等缓存 TTL 或下一帧才失效。 +type RawSessionTerminator interface { + CloseSessionsForRawAuthKeyExcept(authKeyID [8]byte, exceptSessionID int64) int +} + // BestEffortSessionBinder 是 updates fanout 的短超时推送接口;不用于 RPC result/ack。 type BestEffortSessionBinder interface { PushToUserExceptSessionBestEffort(ctx context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) @@ -66,9 +107,23 @@ type ScopedBestEffortSessionBinder interface { PushToUserExceptAuthKeySessionBestEffort(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) } +// TransientSessionBinder 推送短命、不写 durable log 的 update(typing / presence)。 +// 与普通推送的关键区别:目标 session 未就绪时直接跳过、不进 pending——transient 数据 +// getDifference 无法补,就绪后由 getState 快照/下次状态变化重建,囤积过期 transient 无意义。 +type TransientSessionBinder interface { + PushToUserTransientExceptAuthKeySession(ctx context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) +} + +// AuthKeyTargetedSessionBinder 把 update 定向投递给某用户【绑定到具体 business auth_key +// 这台设备】的就绪连接(密聊设备级投递)。SessionManager 实现;测试替身/未装配时 +// rpc 层回退账号级推送。未就绪连接跳过、不进 pending(密聊离线靠 getDifference 补)。 +type AuthKeyTargetedSessionBinder interface { + PushToUserAuthKey(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg bin.Encoder) (int, error) + PushToUserAuthKeyTransient(ctx context.Context, userID int64, businessAuthKeyID [8]byte, t proto.MessageType, msg bin.Encoder, timeout time.Duration) (int, error) +} + // OnlineUserProvider exposes a bounded runtime snapshot for best-effort fanout. type OnlineUserProvider interface { - OnlineUserIDs(limit int) []int64 IsUserOnline(userID int64) bool OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 TrackChannelInterest(rawAuthKeyID [8]byte, sessionID, userID int64, channelIDs []int64) @@ -80,9 +135,17 @@ type OnlineUserProvider interface { OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 } +// ChannelNudgeProvider 暴露「频道在线成员中排除已投递集合后的剩余 user id」,用于 >cap +// 在线成员的 UpdateChannelTooLong nudge(P0-8)。SessionManager 实现;测试/未装配 fake 可不实现 +// (type-assert 失败时跳过 nudge,不影响完整 payload 投递)。 +type ChannelNudgeProvider interface { + OnlineChannelMemberUserIDsExcluding(channelID int64, exclude map[int64]struct{}, limit int) []int64 +} + // RateLimiter 抽象 RPC 高频写操作限流。 type RateLimiter interface { Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error) + AllowN(ctx context.Context, key string, cost, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error) } // UsersService 抽象用户查询。 @@ -92,15 +155,87 @@ type UsersService interface { ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) } +// BatchViewerUsersResolver 是 UsersService 的可选能力:跨多个 viewer 一次性投影同一组 user +// (fan-out 模板化,把 per-recipient 的 ByIDs(=ForViewer) 折叠成 O(owner) 查询)。结果按 viewer +// 与 ByIDs(viewer, ids) 字节等价(personal photo overlay 除外,见 users.ByIDsForViewers)。 +// 未实现时 fan-out 预热静默跳过,回退逐 viewer 解析(行为不变,仅退化为旧的 O(viewer) 成本)。 +type BatchViewerUsersResolver interface { + ByIDsForViewers(ctx context.Context, viewerUserIDs []int64, userIDs []int64) (map[int64][]domain.User, error) +} + +// BotsService 抽象 bot 元数据查询与管理(bots.* RPC + userFull.bot_info hydrate)。 +// 写方法返回 bump 后的 bot_info_version(客户端据此重拉)。 +type BotsService interface { + BotInfo(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) + OwnsBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) + CheckUsername(ctx context.Context, ownerUserID int64, username string) (bool, error) + CreateBot(ctx context.Context, ownerUserID int64, name, username string) (domain.User, string, error) + ListOwnedBots(ctx context.Context, ownerUserID int64) ([]domain.User, error) + ExportBotToken(ctx context.Context, ownerUserID, botUserID int64, revoke bool) (string, error) + SetBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error) + GetBotCommands(ctx context.Context, botUserID int64) ([]domain.BotCommand, error) + SetBotInfo(ctx context.Context, botUserID int64, upd domain.BotInfoUpdate) (int, error) + GetBotInfo(ctx context.Context, botUserID int64) (name, about, description string, err error) + SetBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error) + GetBotMenuButton(ctx context.Context, botUserID int64) (domain.BotMenuButton, error) + SetInlinePlaceholder(ctx context.Context, botUserID int64, placeholder string) (int, error) + CanSendMessage(ctx context.Context, userID, botUserID int64) (bool, error) + AllowSendMessage(ctx context.Context, userID, botUserID int64, fromRequest bool) (bool, error) + UpsertBotApp(ctx context.Context, botUserID int64, app domain.BotApp) (domain.BotApp, int, error) + GetBotAppByID(ctx context.Context, appID, accessHash int64) (domain.BotApp, bool, error) + GetBotAppByShortName(ctx context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) + GetMainBotApp(ctx context.Context, botUserID int64) (domain.BotApp, bool, error) + ListBotApps(ctx context.Context, botUserID int64) ([]domain.BotApp, error) + GetBotAppSettings(ctx context.Context, botUserID int64) (domain.BotAppSettings, bool, error) + UpsertBotAppSettings(ctx context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) + ListBotAppPreviewMedia(ctx context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) + UpsertBotAppPreviewMedia(ctx context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error) + DeleteBotAppPreviewMedia(ctx context.Context, botUserID, appID, mediaID int64) (int, error) + ReorderBotAppPreviewMedia(ctx context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) + UpsertAttachMenuBot(ctx context.Context, botUserID int64, bot domain.BotAttachMenuBot) (int, error) + GetAttachMenuBot(ctx context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) + ListAttachMenuBots(ctx context.Context) ([]domain.BotAttachMenuBot, error) + GetAttachMenuState(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) + SetAttachMenuState(ctx context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) + SaveRequestedWebViewButton(ctx context.Context, button domain.BotRequestedWebViewButton) (domain.BotRequestedWebViewButton, error) + GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, reqID string) (domain.BotRequestedWebViewButton, bool, error) + DeleteRequestedWebViewButton(ctx context.Context, botUserID, userID int64, reqID string) error + SetBotEmojiStatusPermission(ctx context.Context, botUserID, userID int64, allowed bool) error + BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) + PutWebViewCustomMethodQuery(ctx context.Context, botUserID, userID int64, method, paramsJSON string) (domain.BotWebViewCustomMethodQuery, error) +} + // UserIdentityService 是 UsersService 的资料扩展能力,用于 username/phone 解析。 type UserIdentityService interface { CheckUsername(ctx context.Context, userID int64, username string) (bool, error) UpdateProfile(ctx context.Context, userID int64, update domain.UserProfileUpdate) (domain.User, error) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) + UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) + UpdatePersonalChannel(ctx context.Context, userID int64, channelID int64) (domain.User, error) ResolveUsername(ctx context.Context, currentUserID int64, username string) (domain.User, bool, error) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) } +// UserPremiumService 是 UsersService 的会员扩展能力:授予/续期、到期清理 +// (PremiumSweeper)与 emoji status(premium 专属)。设计见 docs/premium-module.md。 +type UserPremiumService interface { + GrantPremium(ctx context.Context, userID int64, months int) (domain.User, error) + SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) + UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) +} + +// UserColorService 是 UsersService 的个人色板扩展能力。用于 account.updateColor +// 持久化当前账号的消息气泡 accent 或资料页背景色。 +type UserColorService interface { + UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) +} + +// UserPremiumStatusService 暴露轻量会员判断(基础用户缓存路径,不做 viewer +// 投影),供限额双档(reaction 上限等)低成本调用。 +type UserPremiumStatusService interface { + PremiumActive(ctx context.Context, userID int64) bool +} + // AccountService 抽象账号设置查询。 type AccountService interface { GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error) @@ -113,8 +248,48 @@ type AccountService interface { ConfirmPasswordEmail(ctx context.Context, userID int64, code string) error ResendPasswordEmail(ctx context.Context, userID int64) error CancelPasswordEmail(ctx context.Context, userID int64) error + // 登录邮箱(独立于 2FA 恢复邮箱):authed 走 userID,登录流程/重置走 phone。 + SetLoginEmail(ctx context.Context, userID int64, email string) error + SetLoginEmailByPhone(ctx context.Context, phone, email string) error + LoginEmail(ctx context.Context, userID int64) (string, bool, error) + LoginEmailByPhone(ctx context.Context, phone string) (string, bool, error) + ClearLoginEmailByPhone(ctx context.Context, phone string) error ResetPassword(ctx context.Context, userID int64) (domain.PasswordResetResult, error) DeclinePasswordReset(ctx context.Context, userID int64) error + SaveMusic(ctx context.Context, userID int64, req domain.SaveMusicRequest) (bool, error) + ListSavedMusicIDs(ctx context.Context, userID int64, limit int) ([]int64, error) + ListSavedMusic(ctx context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) + GetSavedMusicByIDs(ctx context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) +} + +// AccountBusinessAutomationService 是账号业务自动化的可选扩展。 +// 只暴露 domain DTO,避免 Telegram TL 类型越过 rpc 边界。 +type AccountBusinessAutomationService interface { + GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error) + UpdateBusinessWorkHours(ctx context.Context, userID int64, hours *domain.BusinessWorkHours) (domain.BusinessProfile, error) + UpdateBusinessLocation(ctx context.Context, userID int64, location *domain.BusinessLocation) (domain.BusinessProfile, error) + UpdateBusinessIntro(ctx context.Context, userID int64, intro *domain.BusinessIntro) (domain.BusinessProfile, error) + UpdateBusinessGreetingMessage(ctx context.Context, userID int64, greeting *domain.BusinessGreetingMessage) (domain.BusinessProfile, error) + UpdateBusinessAwayMessage(ctx context.Context, userID int64, away *domain.BusinessAwayMessage) (domain.BusinessProfile, error) + ListBusinessChatLinks(ctx context.Context, userID int64) ([]domain.BusinessChatLink, error) + CreateBusinessChatLink(ctx context.Context, userID int64, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) + EditBusinessChatLink(ctx context.Context, userID int64, slug string, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) + DeleteBusinessChatLink(ctx context.Context, userID int64, slug string) (bool, error) + ResolveBusinessChatLink(ctx context.Context, slug string, bumpViews bool) (domain.BusinessChatLink, bool, error) + ListQuickReplies(ctx context.Context, userID int64) (domain.QuickReplyList, error) + CheckQuickReplyShortcut(ctx context.Context, userID int64, shortcut string) (bool, error) + SaveQuickReplyText(ctx context.Context, userID int64, shortcut string, msg domain.QuickReplyMessage) (domain.QuickReplyMutation, error) + GetQuickReplyMessages(ctx context.Context, userID int64, shortcutID int, ids []int) (domain.QuickReplyMessages, error) + RenameQuickReplyShortcut(ctx context.Context, userID int64, shortcutID int, shortcut string) (domain.QuickReplyMutation, error) + ReorderQuickReplies(ctx context.Context, userID int64, order []int) (domain.QuickReplyMutation, error) + DeleteQuickReplyShortcut(ctx context.Context, userID int64, shortcutID int) (domain.QuickReplyMutation, error) + DeleteQuickReplyMessages(ctx context.Context, userID int64, shortcutID int, ids []int) (domain.QuickReplyMutation, error) + GetConnectedBusinessBot(ctx context.Context, ownerUserID int64) (domain.ConnectedBusinessBot, bool, error) + SaveConnectedBusinessBot(ctx context.Context, ownerUserID int64, bot domain.ConnectedBusinessBot) (domain.ConnectedBusinessBot, error) + DeleteConnectedBusinessBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) + SetConnectedBusinessBotPaused(ctx context.Context, ownerUserID, peerUserID int64, paused bool) (domain.ConnectedBusinessBotPeerState, error) + DisableConnectedBusinessBotForPeer(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, error) + GetConnectedBusinessBotPeerState(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, bool, error) } // PrivacyService owns account privacy rule storage/evaluation. @@ -135,21 +310,34 @@ type HelpService interface { type UpdatesService interface { GetState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) CurrentState(ctx context.Context, userID int64) (domain.UpdateState, error) + AcknowledgeCurrentState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) GetDifference(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateDifference, error) ClearAuthKey(ctx context.Context, authKeyID [8]byte) error RecordNewMessage(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) + RecordStory(ctx context.Context, authKeyID [8]byte, userID int64, story domain.Story, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordStoryFanout(ctx context.Context, userID int64, story domain.Story) (domain.UpdateEvent, domain.UpdateState, error) + RecordReadStories(ctx context.Context, authKeyID [8]byte, userID int64, read domain.StoryReadResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordSentStoryReaction(ctx context.Context, authKeyID [8]byte, userID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordNewStoryReaction(ctx context.Context, authKeyID [8]byte, ownerUserID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordQuickReplyMutation(ctx context.Context, authKeyID [8]byte, userID int64, mutation domain.QuickReplyMutation, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordReadHistory(ctx context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordContactsReset(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) - RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) - RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordChannelState(ctx context.Context, authKeyID [8]byte, userID, channelID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, folderID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordPinnedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordSavedDialogPinned(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordPinnedSavedDialogs(ctx context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordDialogUnreadMark(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordPeerSettings(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordPeerStoryBlocked(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, blocked bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordDialogFilter(ctx context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordDialogFilterOrder(ctx context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordDialogFiltersReload(ctx context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordFolderPeers(ctx context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordChannelAvailableMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) RecordChannelViewForumAsMessages(ctx context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordChannelDiscussionInbox(ctx context.Context, authKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) + RecordDraftMessage(ctx context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) } // ContactsService 抽象通讯录查询。 @@ -161,6 +349,7 @@ type ContactsService interface { ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error) DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) + EditCloseFriends(ctx context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photo domain.Photo, date int) (domain.Contact, error) ClearPersonalPhoto(ctx context.Context, userID, contactUserID int64, date int) (domain.Contact, error) @@ -174,14 +363,17 @@ type ContactsService interface { // DialogsService 抽象会话列表查询。 type DialogsService interface { + GetDialogsHash(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogHashCheck, error) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) GetPeerDialogs(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) - SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error + SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) (bool, error) + GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) - TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) - ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error + TogglePinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) + ToggleArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error) + ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) MarkUnread(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) UnreadMarks(ctx context.Context, userID int64) ([]domain.Peer, error) HidePeerSettingsBar(ctx context.Context, userID int64, peer domain.Peer) (bool, error) @@ -197,18 +389,65 @@ type DialogsService interface { // MessagesService 抽象消息历史、搜索与已读。 type MessagesService interface { SendPrivateText(ctx context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) + SetChatTheme(ctx context.Context, userID int64, req domain.SetPrivateChatThemeRequest) (domain.SetPrivateChatThemeResult, error) ForwardPrivateMessages(ctx context.Context, userID int64, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) GetMessages(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) GetHistory(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) Search(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) + SearchPrivateMedia(ctx context.Context, userID, peerID int64, req domain.MediaSearchRequest) (domain.MessageList, error) + CountPrivateMediaCategories(ctx context.Context, userID, peerID int64) (domain.MediaCategoryCounts, error) ReadHistory(ctx context.Context, userID int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) GetOutboxReadDate(ctx context.Context, userID int64, req domain.OutboxReadDateRequest) (int, error) SetMessageReactions(ctx context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) GetMessageReactions(ctx context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) + VoteMessagePoll(ctx context.Context, userID int64, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) + CloseMessagePoll(ctx context.Context, userID int64, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) + ListUnreadReactionMessages(ctx context.Context, userID int64, peer domain.Peer, limit int) ([]domain.Message, error) + ReadPeerReactions(ctx context.Context, userID int64, peer domain.Peer) (int, error) EditMessage(ctx context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) + PinPrivateMessage(ctx context.Context, userID int64, req domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) + UnpinAllPrivateMessages(ctx context.Context, userID int64, req domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) + GetSavedDialogs(ctx context.Context, userID int64, filter domain.SavedDialogsFilter) (domain.SavedDialogList, error) + GetPinnedSavedDialogs(ctx context.Context, userID int64) (domain.SavedDialogList, error) + GetSavedDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error) + ToggleSavedDialogPin(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) + ReorderPinnedSavedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error + DeleteSavedHistory(ctx context.Context, userID int64, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) +} + +// StoriesService 抽象 story 读取、已读、观看与 reaction 状态。 +type StoriesService interface { + CreateStory(ctx context.Context, userID int64, req domain.StoryCreateRequest) (domain.StoryCreateResult, error) + GetAllStories(ctx context.Context, viewerUserID int64, hidden bool, now, limit int) (domain.StoryList, error) + GetAllStoriesPage(ctx context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) (domain.StoryList, error) + GetAllStoriesDigest(ctx context.Context, viewerUserID int64, hidden bool, now int) (domain.StoryListDigest, error) + ListOwnerActiveStories(ctx context.Context, userID int64, owner domain.Peer, now, limit int) (domain.StoryList, error) + GetPeerStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (domain.PeerStories, error) + GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error) + GetStoriesArchive(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) + GetPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) + HasPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) + ListReadStates(ctx context.Context, viewerUserID int64) ([]domain.StoryReadState, error) + GetPeerMaxIDs(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) + GetPeerHiddenStates(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) + GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) + ReadStories(ctx context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) + IncrementViews(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, date int) (int, error) + SendReaction(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction *domain.MessageReaction, date int) (domain.StoryReactionResult, error) + GetStoryViewsList(ctx context.Context, viewerUserID int64, req domain.StoryViewListRequest) (domain.StoryViewList, error) + GetStoryReactionsList(ctx context.Context, viewerUserID int64, req domain.StoryReactionListRequest) (domain.StoryReactionList, error) + GetStoryPublicForwards(ctx context.Context, viewerUserID int64, req domain.StoryPublicForwardListRequest) (domain.StoryPublicForwardList, error) + CanViewStoryStats(ctx context.Context, userID int64, peer domain.Peer) error + ListStoryViewerIDs(ctx context.Context, userID int64, owner domain.Peer, storyID, limit int) ([]int64, error) + EditStory(ctx context.Context, userID int64, req domain.StoryEditRequest) (domain.StoryEditResult, error) + DeleteStories(ctx context.Context, userID int64, peer domain.Peer, ids []int, date int) (domain.StoryMutationResult, error) + TogglePinned(ctx context.Context, userID int64, peer domain.Peer, ids []int, pinned bool, date int) (domain.StoryMutationResult, error) + TogglePinnedToTop(ctx context.Context, userID int64, peer domain.Peer, ids []int) error + TogglePeerStoriesHidden(ctx context.Context, viewerUserID int64, peer domain.Peer, hidden bool) error + CanSendStory(ctx context.Context, viewerUserID int64, peer domain.Peer) (int, error) } // ChannelsService 抽象超级群/频道业务。 @@ -216,21 +455,30 @@ type ChannelsService interface { CreateMegagroupFromCreateChat(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) CreateChannel(ctx context.Context, userID int64, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) GetChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) + // ResolveChannel 是 GetChannel 的轻量版(仅访问校验 + Channel/Self,跳过 dialog/boost), + // 供 inputPeerFor 等只需 access_hash / 频道标志的解析路径用。 + ResolveChannel(ctx context.Context, userID, channelID int64) (domain.ChannelView, error) + GetChannels(ctx context.Context, userID int64, channelIDs []int64) ([]domain.ChannelView, error) GetJoinableChannel(ctx context.Context, userID, channelID int64) (domain.Channel, error) GetParticipants(ctx context.Context, userID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) GetParticipant(ctx context.Context, userID, channelID, participantUserID int64) (domain.ChannelMember, error) + FutureCreatorAfterLeave(ctx context.Context, userID, channelID int64) (domain.ChannelMember, error) InviteToChannel(ctx context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) JoinChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) LeaveChannel(ctx context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) EditTitle(ctx context.Context, userID int64, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) + SetWallpaper(ctx context.Context, userID int64, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error) EditAbout(ctx context.Context, userID int64, req domain.EditChannelAboutRequest) (domain.Channel, error) EditAdmin(ctx context.Context, userID int64, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) + EditMemberRank(ctx context.Context, userID int64, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) EditBanned(ctx context.Context, userID int64, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) EditDefaultBannedRights(ctx context.Context, userID int64, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) DeleteChannel(ctx context.Context, userID int64, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) UpdateUsername(ctx context.Context, userID int64, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) + ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) + ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error) ResolvePublicUsername(ctx context.Context, userID int64, username string) (domain.Channel, bool, error) SearchPublicChannels(ctx context.Context, userID int64, query string, limit int) (domain.PublicChannelSearchResult, error) SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) @@ -240,9 +488,10 @@ type ChannelsService interface { SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) - SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) + SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.ChannelPaidMessagesPriceResult, error) SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) + SetBoostsToUnblockRestrictions(ctx context.Context, userID, channelID int64, boosts int) (domain.Channel, error) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) @@ -255,12 +504,19 @@ type ChannelsService interface { GetMessageViews(ctx context.Context, userID int64, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) SetMessageReactions(ctx context.Context, userID int64, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) GetMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) + VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) + CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) ClearRecentReactions(ctx context.Context, userID int64) error SavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) UpdateSavedReactionTag(ctx context.Context, userID int64, tag domain.SavedReactionTag) error + GetPremiumBoostStatus(ctx context.Context, userID, channelID int64, now int) (domain.PremiumBoostStatus, error) + ListPremiumBoosts(ctx context.Context, userID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error) + GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) + ApplyPremiumBoost(ctx context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error) + GetPremiumUserBoosts(ctx context.Context, userID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error) ReadMessageContents(ctx context.Context, userID int64, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) GetMessageAuthor(ctx context.Context, userID int64, req domain.GetChannelMessageAuthorRequest) (domain.GetChannelMessageAuthorResult, error) CreateForumTopic(ctx context.Context, userID int64, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) @@ -272,10 +528,15 @@ type ChannelsService interface { GetForumTopicsByID(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) SendMessage(ctx context.Context, userID int64, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) EditMessage(ctx context.Context, userID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) + GetInlineBotMessage(ctx context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) + ListStoryMessageForwards(ctx context.Context, userID int64, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) + EditInlineBotMessage(ctx context.Context, botID int64, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) DeleteMessages(ctx context.Context, userID int64, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) DeleteHistory(ctx context.Context, userID int64, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) DeleteParticipantHistory(ctx context.Context, userID int64, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) UpdatePinnedMessage(ctx context.Context, userID int64, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) + UnpinAllMessages(ctx context.Context, userID int64, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error) + ClearDanglingPinnedMessage(ctx context.Context, channelID int64, messageID int) error ExportInvite(ctx context.Context, userID int64, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) ImportInvite(ctx context.Context, userID int64, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) @@ -297,21 +558,34 @@ type ChannelsService interface { SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) SetViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) GetHistory(ctx context.Context, userID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) + SearchChannelMedia(ctx context.Context, userID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error) + CountChannelMediaCategories(ctx context.Context, userID, channelID int64) (domain.MediaCategoryCounts, error) SearchPosts(ctx context.Context, userID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) SearchJoinedMessages(ctx context.Context, userID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) GetMessages(ctx context.Context, userID, channelID int64, ids []int) (domain.ChannelHistory, error) + ChannelPollFanoutViews(ctx context.Context, channelID int64, msgID int, viewers []int64, now int) (map[int64]*domain.MessagePoll, error) GetReplies(ctx context.Context, userID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) GetUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) ReadMentions(ctx context.Context, userID int64, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) GetUnreadReactions(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) ReadReactions(ctx context.Context, userID int64, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) GetDiscussionMessage(ctx context.Context, userID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) + SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) + ListMonoforumHistory(ctx context.Context, filter domain.MonoforumHistoryFilter) (domain.ChannelHistory, error) + ListMonoforumDialogs(ctx context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error) + ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) ReadHistory(ctx context.Context, userID int64, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) + ReadTopicHistory(ctx context.Context, userID int64, req domain.ReadChannelTopicHistoryRequest) (domain.ReadChannelTopicHistoryResult, error) + GeneralForumTopic(ctx context.Context, userID, channelID int64) (domain.ChannelForumTopic, error) GetMessageReadParticipants(ctx context.Context, userID int64, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) GetDifference(ctx context.Context, userID int64, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) ActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) DirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) ActiveMemberIDs(ctx context.Context, userID, channelID int64, limit int) ([]int64, error) + // SetActiveCall / AppendCallServiceMessage 是群通话模块的频道侧挂接点。 + SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error) + AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) + AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error InviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) FilterActiveMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) } @@ -322,15 +596,37 @@ type FilesService interface { SaveFilePart(ctx context.Context, ownerUserID, fileID int64, part int, bytes []byte) (bool, error) SaveBigFilePart(ctx context.Context, ownerUserID, fileID int64, part, totalParts int, bytes []byte) (bool, error) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) + // CreateEncryptedFileFromUpload 把密聊上传分片组装成盲 blob 并铸造 EncryptedFile 快照(P2)。 + CreateEncryptedFileFromUpload(ctx context.Context, file domain.UploadedFileRef, keyFingerprint int) (domain.EncryptedFileRef, error) + // GeoMapTile 渲染 geo 消息地图缩略占位图(upload.getWebFile),确定性、无外部依赖。 + GeoMapTile(lat, long float64, w, h, zoom, scale int) ([]byte, string) // 资源读取(reaction / sticker / document)。 ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) + AvailableEffects(ctx context.Context) ([]domain.AvailableEffect, int, error) GetDocuments(ctx context.Context, ids []int64) ([]domain.Document, error) ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (set domain.StickerSet, documents []domain.Document, found bool, err error) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) // 头像(profile photo)与消息媒体组装。 CreatePhotoFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) + CreatePhotoFromBytes(ctx context.Context, data []byte) (domain.Photo, error) + // CreatePhotoFromURL / CreateDocumentFromURL 抓取外链媒体(inputMediaPhoto/DocumentExternal), + // SSRF 安全;未启用返回 ErrExternalMediaDisabled。 + CreatePhotoFromURL(ctx context.Context, rawURL string) (domain.Photo, error) + CreateDocumentFromURL(ctx context.Context, rawURL string) (domain.Document, error) + // ResolveWebPage 解析链接预览(messages.getWebPagePreview / 发送挂卡片);SSRF 安全, + // 经 L1+L3 去重缓存。未启用返回 ErrWebPagePreviewDisabled;瞬时失败返回错误(调用方降级)。 + ResolveWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, error) + // WebPagePreviewEnabled 报告是否启用链接预览;未启用时发送不挂 pending 占位(否则会永久 pending)。 + WebPagePreviewEnabled() bool + // LookupWebPage 仅查缓存(不抓取)返回已解析的链接预览。发送时用它:若客户端输入时 + // getWebPagePreview 已解析过,则 echo 直接带 done 卡片(与官方一致),不依赖异步换卡。 + LookupWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, bool) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) + CreateAvatarVideoFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64) (domain.Photo, error) + CreateAvatarVideoMarkupFromUpload(ctx context.Context, file domain.UploadedFileRef, videoStartTs float64, markup domain.PhotoSize) (domain.Photo, error) + CreateAvatarMarkup(ctx context.Context, size domain.PhotoSize) (domain.Photo, error) CreateDocumentFromUpload(ctx context.Context, file domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) + CreateDocumentFromBytes(ctx context.Context, data []byte, spec domain.DocumentSpec) (domain.Document, error) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) @@ -354,19 +650,149 @@ type LangPackService interface { // Deps 按业务域注入服务接口。各域的 handler 注册见对应文件(auth.go / users.go / updates.go)。 type Deps struct { - Auth AuthService - Account AccountService - Privacy PrivacyService - Help HelpService - Users UsersService - Updates UpdatesService - Contacts ContactsService - Dialogs DialogsService - Messages MessagesService - Channels ChannelsService - Files FilesService - LangPack LangPackService - Sessions SessionBinder - Limiter RateLimiter - Metrics Metrics + Auth AuthService + Account AccountService + Privacy PrivacyService + Help HelpService + Users UsersService + Updates UpdatesService + Contacts ContactsService + Dialogs DialogsService + Messages MessagesService + Stories StoriesService + Channels ChannelsService + Files FilesService + Bots BotsService + Polls PollsService + Phone PhoneService + GroupCalls GroupCallsService + SFU sfu.Service + TURN turnsrv.Service + LangPack LangPackService + Sessions SessionBinder + Inline store.InlineRegistryStore + Limiter RateLimiter + Metrics Metrics + SecretChats SecretChatService + Stars StarsService + Gifts GiftsService + Passkey PasskeyService + Themes ThemeService +} + +// ThemeService 抽象自定义云主题(app/themes):创建/更新/查询主题 + 维护每用户已安装列表。 +// 文件上传(uploadTheme)由 rpc 层直接经 Files 完成,不经此接口。只用 domain + 基本类型。 +type ThemeService interface { + Create(ctx context.Context, spec domain.ThemeSpec) (domain.Theme, error) + Update(ctx context.Context, userID int64, ref domain.ThemeRef, upd domain.ThemeUpdate) (domain.Theme, error) + Get(ctx context.Context, ref domain.ThemeRef) (domain.Theme, bool, error) + Save(ctx context.Context, userID int64, ref domain.ThemeRef) error + Unsave(ctx context.Context, userID int64, ref domain.ThemeRef) error + Install(ctx context.Context, userID int64, ref domain.ThemeRef, dark bool) error + ListInstalled(ctx context.Context, userID int64) ([]domain.Theme, error) + ListForUser(ctx context.Context, userID int64) ([]domain.Theme, error) +} + +// PasskeyService 抽象 passkey(WebAuthn)登录与管理(app/passkey)。挑战选项以 DataJSON +// 字节返回;注册/登录验证收原始字节(credentialID 已 base64url 解码)。FinishLogin 返回 +// 已验证用户 id,auth_key 绑定由 rpc 经 Auth.BindVerifiedLogin 完成。 +type PasskeyService interface { + InitLogin(ctx context.Context) ([]byte, error) + FinishLogin(ctx context.Context, credentialID, clientDataJSON, authenticatorData, signature []byte, userHandle string) (int64, error) + InitRegistration(ctx context.Context, userID int64, displayName string) ([]byte, error) + Register(ctx context.Context, userID int64, credentialID, clientDataJSON, attestationObject []byte, name string) (domain.PasskeyCredential, error) + List(ctx context.Context, userID int64) ([]domain.PasskeyCredential, error) + Delete(ctx context.Context, userID int64, credentialID []byte) (bool, error) +} + +// GiftsService 抽象 Star 礼物(app/stargifts):目录 + peer 收到的礼物实例 CRUD。 +// 扣费/退款/服务消息投递由 rpc 层经 Stars 账本 + Messages.SendPrivateText 编排。 +type GiftsService interface { + Catalog(ctx context.Context) ([]domain.StarGift, error) + CatalogHash(ctx context.Context) (int, error) + GiftByID(ctx context.Context, id int64) (domain.StarGift, bool, error) + RecordSavedGift(ctx context.Context, gift domain.SavedStarGift) (int64, error) + ListSaved(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) + GetSaved(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) + CountSaved(ctx context.Context, owner domain.Peer) (int, error) + ToggleSaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) + Convert(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) +} + +// StarsService 抽象 Stars 本地账本(app/stars):余额查询、贷记/借记、流水分页。 +// 借记原子且永不为负;余额不足返回 domain.ErrStarsInsufficient(rpc 经 starsErr +// 映射为 BALANCE_TOO_LOW)。getStarsStatus 首读时惰性授予起始余额。 +type StarsService interface { + GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) + Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) + Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, title, desc string) (domain.StarsBalance, error) + ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) +} + +// SecretChatService 抽象私聊端对端加密(Secret Chat)握手状态机(app/secretchat)。 +// 服务端是盲中继;错误集合见 domain.ErrSecretChat* 与 app/secretchat.ErrGAInvalid +// (rpc 层经 secretChatErr 映射为 ENCRYPTION_* / CHAT_ID_INVALID / DH_G_A_INVALID)。 +type SecretChatService interface { + RequestEncryption(ctx context.Context, req domain.SecretChatRequest) (domain.SecretChat, error) + AcceptEncryption(ctx context.Context, chatID int, viewerUserID, participantAuthKeyID, accessHash int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error) + DiscardEncryption(ctx context.Context, chatID int, viewerUserID int64, deleteHistory bool) (domain.SecretChat, bool, error) + // DiscardForAuthKey 级联 discard 绑定该 perm auth_key 的全部活跃密聊(设备登出/授权撤销), + // 返回实际迁移到 discarded 的密聊供通知对端。 + DiscardForAuthKey(ctx context.Context, authKeyID int64) ([]domain.SecretChat, error) + GetSecretChat(ctx context.Context, chatID int) (domain.SecretChat, bool, error) + SendEncrypted(ctx context.Context, chatID int, viewerUserID, accessHash int64, delivery domain.SecretMessageDelivery) (domain.SecretChat, domain.SecretChatMessage, error) + ListNewMessages(ctx context.Context, deviceAuthKeyID int64, sinceQts, limit int) ([]domain.SecretChatMessage, error) + DeviceReservedQts(ctx context.Context, deviceAuthKeyID int64) (int, error) + AckQueue(ctx context.Context, deviceAuthKeyID int64, maxQts int) error + RecordEncryptionEvent(ctx context.Context, chatID int, targetUserID, targetAuthKeyID int64, date int) error + RecordReadEvent(ctx context.Context, chatID int, targetUserID, targetAuthKeyID int64, maxDate, date int) error + ListStateEvents(ctx context.Context, userID, deviceAuthKeyID int64, limit int) ([]domain.EncryptedStateEvent, error) + MarkStateEventsDelivered(ctx context.Context, deviceAuthKeyID int64, eventIDs []int64) error + PutEncryptedFile(ctx context.Context, ownerUserID int64, ref domain.EncryptedFileRef) error + GetEncryptedFile(ctx context.Context, id, accessHash int64) (domain.EncryptedFileRef, bool, error) +} + +// PhoneService 抽象私聊 1:1 通话信令状态机(app/phone)。所有返回值都是状态快照; +// 错误集合见 app/phone 的 Err*(rpc 层经 phoneCallErr 映射为 CALL_* RPC_ERROR)。 +type PhoneService interface { + RequestCall(ctx context.Context, callerID int64, in domain.PhoneCallRequest) (domain.PhoneCall, error) + ReceivedCall(ctx context.Context, userID, callID, accessHash int64) (domain.PhoneCall, bool, error) + AcceptCall(ctx context.Context, userID, callID, accessHash int64, gb []byte, proto domain.PhoneCallProtocol, device domain.SessionRef) (domain.PhoneCall, error) + ConfirmCall(ctx context.Context, userID, callID, accessHash int64, ga []byte, keyFingerprint int64, proto domain.PhoneCallProtocol) (call domain.PhoneCall, forcedDiscard bool, err error) + DiscardCall(ctx context.Context, userID, callID, accessHash int64, reason domain.PhoneCallDiscardReason, duration int) (call domain.PhoneCall, already bool, err error) + // Signal 在该通话的信令顺序锁内执行 forward;drop=true 表示按契约静默吞掉。 + // peerDevice 是对端受理设备锚点(可零值/失效),定向推送失败须回退 user 扇出。 + Signal(ctx context.Context, userID, callID, accessHash int64, forward func(peerUserID int64, peerDevice domain.SessionRef)) (drop bool, err error) + Lookup(ctx context.Context, callID, accessHash int64) (domain.PhoneCall, bool) + // ExpireDue 由 PhoneExpiryDispatcher 周期调用:迁移超时通话并返回快照。 + ExpireDue(ctx context.Context, now time.Time) []domain.PhoneCall +} + +// GroupCallsService 抽象超级群语音聊天信令(app/groupcalls)。 +// 错误集合见 domain.ErrGroupCall*(rpc 层映射为 GROUPCALL_* RPC_ERROR)。 +type GroupCallsService interface { + Create(ctx context.Context, channelID, creatorUserID int64, title string, now int) (domain.GroupCall, error) + Get(ctx context.Context, callID int64) (domain.GroupCall, bool, error) + Join(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) + Leave(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error) + Discard(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) + Touch(ctx context.Context, callID, userID int64, now int) (activeSSRCs []int64, joined bool, err error) + Participant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error) + Participants(ctx context.Context, callID int64, offset string, limit int) (domain.GroupCallParticipantPage, error) + UpdateParticipant(ctx context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) + SetTitle(ctx context.Context, callID int64, title string) (domain.GroupCall, bool, error) + SetJoinMuted(ctx context.Context, callID int64, joinMuted bool) (domain.GroupCall, bool, error) + SetStartedMessageID(ctx context.Context, callID int64, msgID int) error + SweepStale(ctx context.Context, checkOlderThan, now, limit int) ([]domain.GroupCallMutation, error) + ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error) + NextRaiseHandRating(ctx context.Context, callID int64) (int64, error) + SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error + ParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error) +} + +// PollsService 抽象 poll 权威态的发送时创建与投票人列表(messages.getPollVotes)。 +type PollsService interface { + CreatePoll(ctx context.Context, def domain.PollDefinition) error + GetPollDefinition(ctx context.Context, pollID int64) (domain.PollDefinition, bool, error) + ListPollVotes(ctx context.Context, req domain.PollVotesListRequest) (domain.PollVotesList, error) } diff --git a/internal/rpc/dialogs_monoforum_test.go b/internal/rpc/dialogs_monoforum_test.go new file mode 100644 index 00000000..571b5cc7 --- /dev/null +++ b/internal/rpc/dialogs_monoforum_test.go @@ -0,0 +1,81 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestMainGetDialogsDeliversMonoforumWithResolvableParent 锁定主 getDialogs(ListChannelDialogs) +// 对频道私信(monoforum)管理员投递的完整契约:同一响应里同时含 ① monoforum 的 dialog 条目、 +// ② monoforum tg.Channel(Monoforum+Megagroup+Creator=false+linked_monoforum_id→母频道)、 +// ③ 母广播频道 tg.Channel(broadcast+Creator=true+linked_monoforum_id→mono)。TDesktop 据此 +// (mono.isMegagroup && parent.canAccessMonoforum)本地派生 MonoforumAdmin 并把会话渲染为 +// Direct-Messages 容器;缺任一项都会让 mono 退化/从列表消失。 +func TestMainGetDialogsDeliversMonoforumWithResolvableParent(t *testing.T) { + chans := memory.NewChannelStore() + ctx := context.Background() + const owner = int64(1) + bc, err := chans.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "testmono2", Broadcast: true, Date: 1700000900}) + if err != nil { + t.Fatalf("create: %v", err) + } + parentID := bc.Channel.ID + res, err := chans.SetPaidMessagesPrice(ctx, owner, parentID, 0, true) + if err != nil { + t.Fatalf("enable: %v", err) + } + monoID := res.Channel.LinkedMonoforumID + if monoID == 0 { + t.Fatalf("no monoforum created") + } + + list, err := chans.ListChannelDialogs(ctx, owner, domain.DialogFilter{Limit: 100}) + if err != nil { + t.Fatalf("list: %v", err) + } + + // ① monoforum 必须有 dialog 条目(否则客户端列表里根本没有这一行)。 + var monoDialog bool + for _, d := range list.Dialogs { + if d.Peer.ID == monoID && d.Peer.Type == domain.PeerTypeChannel { + monoDialog = true + } + } + if !monoDialog { + t.Fatalf("getDialogs missing monoforum dialog %d: %+v", monoID, list.Dialogs) + } + + // ②③ 投影出的 tg.Channel:母频道与 mono 必须同批且双向 linked,母频道 Creator=true(=canAccessMonoforum), + // mono Creator=false(避免 "You created a group")。 + chats := tgChannelsForDialogs(owner, list.Channels, list.Dialogs) + var parent, mono *tg.Channel + for _, ch := range chats { + if c, ok := ch.(*tg.Channel); ok { + switch c.ID { + case parentID: + parent = c + case monoID: + mono = c + } + } + } + if parent == nil || mono == nil { + t.Fatalf("getDialogs chats missing parent(%d)=%v or mono(%d)=%v", parentID, parent != nil, monoID, mono != nil) + } + if !parent.Broadcast || !parent.Creator { + t.Fatalf("parent broadcast=%v creator=%v, want broadcast + creator (canAccessMonoforum)", parent.Broadcast, parent.Creator) + } + if id, ok := parent.GetLinkedMonoforumID(); !ok || id != monoID { + t.Fatalf("parent linked_monoforum_id = %d ok=%v, want →mono %d", id, ok, monoID) + } + if !mono.Monoforum || !mono.Megagroup || mono.Creator { + t.Fatalf("mono monoforum=%v mega=%v creator=%v, want monoforum+megagroup, creator=false", mono.Monoforum, mono.Megagroup, mono.Creator) + } + if id, ok := mono.GetLinkedMonoforumID(); !ok || id != parentID { + t.Fatalf("mono linked_monoforum_id = %d ok=%v, want →parent %d", id, ok, parentID) + } +} diff --git a/internal/rpc/dialogs_pinned.go b/internal/rpc/dialogs_pinned.go new file mode 100644 index 00000000..d125bbec --- /dev/null +++ b/internal/rpc/dialogs_pinned.go @@ -0,0 +1,30 @@ +package rpc + +import ( + "context" + "fmt" + + "telesrv/internal/domain" +) + +func (r *Router) pinnedDialogsList(ctx context.Context, userID int64, folderID int) (domain.DialogList, error) { + if r == nil || r.deps.Dialogs == nil { + return domain.DialogList{}, nil + } + key := fmt.Sprintf("%d:%d", userID, folderID) + value, err, _ := r.dialogsPinnedSF.Do(key, func() (any, error) { + return r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{ + PinnedOnly: true, + HasFolderID: true, + FolderID: folderID, + Limit: 100, + }) + }) + if err != nil { + return domain.DialogList{}, err + } + if list, ok := value.(domain.DialogList); ok { + return list, nil + } + return domain.DialogList{}, nil +} diff --git a/internal/rpc/dialogs_rpc_test.go b/internal/rpc/dialogs_rpc_test.go new file mode 100644 index 00000000..c7f326a8 --- /dev/null +++ b/internal/rpc/dialogs_rpc_test.go @@ -0,0 +1,1473 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "reflect" + "strings" + "sync" + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" + "time" +) + +func TestDialogFilterFromGetDialogsRequestUsesAllParameters(t *testing.T) { + r := New(Config{}, Deps{ + Users: staticUsersService{user: domain.User{ID: 1000000001, AccessHash: 42}}, + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetDialogsRequest{ + ExcludePinned: true, + OffsetDate: 1700000000, + OffsetID: 22, + OffsetPeer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, + Limit: 999, + Hash: 123456, + } + req.SetFolderID(7) + + filter, err := r.dialogFilterFromRequest(context.Background(), 1000000001, req) + if err != nil { + t.Fatalf("dialogFilterFromRequest: %v", err) + } + if !filter.ExcludePinned || !filter.HasFolderID || filter.FolderID != 7 { + t.Fatalf("folder/pinned filter = %+v", filter) + } + if filter.OffsetDate != req.OffsetDate || filter.OffsetID != req.OffsetID || !filter.HasOffsetPeer || filter.OffsetPeer.ID != domain.OfficialSystemUserID { + t.Fatalf("offset filter = %+v", filter) + } + if filter.Limit != 500 || filter.Hash != req.Hash { + t.Fatalf("limit/hash filter = %+v", filter) + } +} + +func TestMessagesGetDialogsReturnsNotModifiedFromFullListHash(t *testing.T) { + dialogs := &captureDialogs{list: domain.DialogList{Count: 3, Hash: 77}} + r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + Hash: 77, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesDialogsBox", enc) + } + got, ok := box.Dialogs.(*tg.MessagesDialogsNotModified) + if !ok { + t.Fatalf("boxed response = %T, want *tg.MessagesDialogsNotModified", box.Dialogs) + } + if got.Count != 3 || dialogs.filter.Hash != 77 { + t.Fatalf("not modified = %+v filter %+v, want count/hash from service", got, dialogs.filter) + } +} + +func TestMessagesGetDialogsHashHitSkipsFullListLoad(t *testing.T) { + dialogs := &captureDialogs{hashCheck: domain.DialogHashCheck{ + Known: true, + Matched: true, + Hash: 77, + Count: 5, + }} + r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + Hash: 77, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesDialogsBox", enc) + } + got, ok := box.Dialogs.(*tg.MessagesDialogsNotModified) + if !ok { + t.Fatalf("boxed response = %T, want *tg.MessagesDialogsNotModified", box.Dialogs) + } + if got.Count != 5 { + t.Fatalf("not modified count = %d, want 5", got.Count) + } + if dialogs.hashCalls != 1 || dialogs.getDialogsCalls != 0 { + t.Fatalf("hash/full calls = %d/%d, want 1/0", dialogs.hashCalls, dialogs.getDialogsCalls) + } + if dialogs.hashFilter.Hash != 77 || dialogs.hashFilter.OffsetID != 0 || dialogs.hashFilter.HasOffsetPeer { + t.Fatalf("hash filter = %+v, want request hash without offset", dialogs.hashFilter) + } +} + +func TestMessagesGetPeerDialogsReturnsRequestedDialogsAndState(t *testing.T) { + msg := domain.Message{ + ID: 3, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000100, + Body: "Login code: 12345", + } + dialogs := &captureDialogs{peerList: domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: msg.Peer, + TopMessage: msg.ID, + TopMessageDate: msg.Date, + UnreadCount: 1, + }}, + Messages: []domain.Message{msg}, + Users: []domain.User{domain.OfficialSystemUser()}, + }} + updates := &captureUpdates{state: domain.UpdateState{Pts: 8, Date: 1700000200, Seq: 4}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetPeerDialogsRequest{ + Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{ + Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, + }}, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.MessagesPeerDialogs) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesPeerDialogs", enc) + } + if len(dialogs.peers) != 1 || dialogs.peers[0].ID != domain.OfficialSystemUserID { + t.Fatalf("requested peers = %+v, want official peer", dialogs.peers) + } + if got.State.Pts != 8 || got.State.Seq != 4 { + t.Fatalf("state = %+v, want updates state", got.State) + } + if len(got.Dialogs) != 1 || len(got.Messages) != 1 || len(got.Users) != 1 { + t.Fatalf("peer dialogs = %+v, want one dialog/message/user", got) + } +} + +func TestDialogSettingRPCsRecordDurableUpdates(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 9 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + dialogPeer := &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}} + dialogs := &captureDialogs{} + updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + + toggle := &tg.MessagesToggleDialogPinRequest{Peer: dialogPeer} + toggle.SetPinned(true) + if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok { + t.Fatalf("toggle pin = %v, %v", ok, err) + } + if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogPinned || updates.events[0].Peer != peer || !updates.events[0].Bool || updates.excludeSessionID != 55 { + t.Fatalf("pin event = %+v, want durable dialog_pinned", updates.events) + } + + if ok, err := r.onMessagesReorderPinnedDialogs(ctx, &tg.MessagesReorderPinnedDialogsRequest{Order: []tg.InputDialogPeerClass{dialogPeer}}); err != nil || !ok { + t.Fatalf("reorder pinned = %v, %v", ok, err) + } + if len(updates.events) != 2 || updates.events[1].Type != domain.UpdateEventPinnedDialogs || len(updates.events[1].Peers) != 1 || updates.events[1].Peers[0] != peer { + t.Fatalf("reorder event = %+v, want durable pinned_dialogs", updates.events) + } + + mark := &tg.MessagesMarkDialogUnreadRequest{Peer: dialogPeer} + mark.SetUnread(true) + if ok, err := r.onMessagesMarkDialogUnread(ctx, mark); err != nil || !ok { + t.Fatalf("mark unread = %v, %v", ok, err) + } + if len(updates.events) != 3 || updates.events[2].Type != domain.UpdateEventDialogUnreadMark || updates.events[2].Peer != peer || !updates.events[2].Bool { + t.Fatalf("unread event = %+v, want durable dialog_unread_mark", updates.events) + } + + parentMark := &tg.MessagesMarkDialogUnreadRequest{ + ParentPeer: &tg.InputPeerChannel{ChannelID: 9001}, + Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerSelf{}}, + } + parentMark.SetUnread(true) + parentMark.SetParentPeer(parentMark.ParentPeer) + if ok, err := r.onMessagesMarkDialogUnread(ctx, parentMark); err != nil || !ok { + t.Fatalf("mark monoforum unread compat = %v, %v", ok, err) + } + if len(updates.events) != 3 { + t.Fatalf("parent_peer unread compat recorded events = %+v, want no durable event", updates.events) + } + + getParentMarks := &tg.MessagesGetDialogUnreadMarksRequest{ParentPeer: &tg.InputPeerChannel{ChannelID: 9001}} + getParentMarks.SetParentPeer(getParentMarks.ParentPeer) + marks, err := r.onMessagesGetDialogUnreadMarks(ctx, getParentMarks) + if err != nil { + t.Fatalf("get monoforum unread marks compat: %v", err) + } + if len(marks) != 0 { + t.Fatalf("monoforum unread marks = %+v, want empty compat list", marks) + } + + invalidParentMarks := &tg.MessagesGetDialogUnreadMarksRequest{ParentPeer: &tg.InputPeerSelf{}} + invalidParentMarks.SetParentPeer(invalidParentMarks.ParentPeer) + if _, err := r.onMessagesGetDialogUnreadMarks(ctx, invalidParentMarks); err == nil || !strings.Contains(err.Error(), "PARENT_PEER_INVALID") { + t.Fatalf("get unread marks invalid parent err = %v, want PARENT_PEER_INVALID", err) + } + + if ok, err := r.onMessagesHidePeerSettingsBar(ctx, &tg.InputPeerUser{UserID: peer.ID}); err != nil || !ok { + t.Fatalf("hide peer settings = %v, %v", ok, err) + } + if len(updates.events) != 4 || updates.events[3].Type != domain.UpdateEventPeerSettings || updates.events[3].Peer != peer || !updates.events[3].Settings.HiddenPeerSettingsBar { + t.Fatalf("peer settings event = %+v, want durable peer_settings", updates.events) + } +} + +func TestReorderPinnedDialogsNoopDoesNotRecordUpdate(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 9 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + dialogPeer := &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}} + dialogs := &captureDialogs{reorderNoChange: true} + updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + + if ok, err := r.onMessagesReorderPinnedDialogs(ctx, &tg.MessagesReorderPinnedDialogsRequest{ + Order: []tg.InputDialogPeerClass{dialogPeer}, + }); err != nil || !ok { + t.Fatalf("reorder pinned noop = %v, %v", ok, err) + } + if len(updates.events) != 0 { + t.Fatalf("noop reorder recorded events = %+v, want none", updates.events) + } +} + +func TestDialogUnreadMarksIncludeChannelDialogs(t *testing.T) { + ctx := context.Background() + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, 1000000001, domain.CreateChannelRequest{ + Title: "Unread Mark Channel", + Date: 1700001000, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + dialogs := appdialogs.NewService(memory.NewDialogStore(), channelStore) + r := New(Config{}, Deps{ + Channels: channelSvc, + Dialogs: dialogs, + }, zaptest.NewLogger(t), clock.System) + rpcCtx := WithUserID(ctx, 1000000001) + input := &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ + ChannelID: created.Channel.ID, + AccessHash: created.Channel.AccessHash, + }} + + mark := &tg.MessagesMarkDialogUnreadRequest{Peer: input} + mark.SetUnread(true) + if ok, err := r.onMessagesMarkDialogUnread(rpcCtx, mark); err != nil || !ok { + t.Fatalf("mark channel unread = %v, %v", ok, err) + } + marks, err := r.onMessagesGetDialogUnreadMarks(rpcCtx, &tg.MessagesGetDialogUnreadMarksRequest{}) + if err != nil { + t.Fatalf("get channel unread marks: %v", err) + } + if len(marks) != 1 { + t.Fatalf("channel unread marks len = %d, want 1 (%+v)", len(marks), marks) + } + got, ok := marks[0].(*tg.DialogPeer) + if !ok { + t.Fatalf("channel unread mark = %T, want dialogPeer", marks[0]) + } + peer, ok := got.Peer.(*tg.PeerChannel) + if !ok || peer.ChannelID != created.Channel.ID { + t.Fatalf("channel unread mark peer = %#v, want channel %d", got.Peer, created.Channel.ID) + } + dialogsList, err := dialogs.GetPeerDialogs(ctx, 1000000001, []domain.Peer{{Type: domain.PeerTypeChannel, ID: created.Channel.ID}}) + if err != nil { + t.Fatalf("GetPeerDialogs: %v", err) + } + var dialog domain.Dialog + for _, item := range dialogsList.Dialogs { + if item.Peer.Type == domain.PeerTypeChannel && item.Peer.ID == created.Channel.ID { + dialog = item + break + } + } + if dialog.Peer.ID == 0 { + t.Fatalf("channel dialog missing from %+v", dialogsList.Dialogs) + } + if !dialog.UnreadMark { + t.Fatalf("channel dialog unread mark = false, want true") + } + + clear := &tg.MessagesMarkDialogUnreadRequest{Peer: input} + if ok, err := r.onMessagesMarkDialogUnread(rpcCtx, clear); err != nil || !ok { + t.Fatalf("clear channel unread = %v, %v", ok, err) + } + marks, err = r.onMessagesGetDialogUnreadMarks(rpcCtx, &tg.MessagesGetDialogUnreadMarksRequest{}) + if err != nil { + t.Fatalf("get channel unread marks after clear: %v", err) + } + if len(marks) != 0 { + t.Fatalf("channel unread marks after clear = %+v, want empty", marks) + } +} + +func TestDialogFolderRPCsPersistAndRecordUpdates(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 12 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 66) + dialogs := &captureDialogs{} + updates := &captureUpdates{state: domain.UpdateState{Pts: 21, Date: 1700000500, Seq: 0}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + + req := &tg.MessagesUpdateDialogFilterRequest{ID: 2} + req.SetFilter(&tg.DialogFilter{ + ID: 2, + Contacts: true, + Title: tg.TextWithEntities{Text: "Work"}, + IncludePeers: []tg.InputPeerClass{&tg.InputPeerUser{ + UserID: 1000000002, + AccessHash: 222, + }}, + }) + if ok, err := r.onMessagesUpdateDialogFilter(ctx, req); err != nil || !ok { + t.Fatalf("update filter = %v, %v", ok, err) + } + if dialogs.savedFolder.ID != 2 || dialogs.savedFolder.Title != "Work" || !dialogs.savedFolder.Contacts || len(dialogs.savedFolder.IncludePeers) != 1 { + t.Fatalf("saved folder = %+v, want parsed dialog folder", dialogs.savedFolder) + } + if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogFilter || updates.events[0].FilterID != 2 || updates.excludeSessionID != 66 { + t.Fatalf("filter event = %+v, want durable dialog_filter", updates.events) + } + + if ok, err := r.onMessagesUpdateDialogFiltersOrder(ctx, []int{2, 2, 0, 3}); err != nil || !ok { + t.Fatalf("update filter order = %v, %v", ok, err) + } + if len(dialogs.folderOrder) != 2 || dialogs.folderOrder[0] != 2 || dialogs.folderOrder[1] != 3 { + t.Fatalf("folder order = %+v, want cleaned custom IDs", dialogs.folderOrder) + } + if len(updates.events) != 2 || updates.events[1].Type != domain.UpdateEventDialogFilterOrder { + t.Fatalf("order event = %+v, want durable dialog_filter_order", updates.events) + } + + if ok, err := r.onMessagesToggleDialogFilterTags(ctx, true); err != nil || !ok { + t.Fatalf("toggle filter tags = %v, %v", ok, err) + } + if !dialogs.tagsEnabled || len(updates.events) != 3 || updates.events[2].Type != domain.UpdateEventDialogFilters { + t.Fatalf("tags/update = tags %v events %+v, want reload update", dialogs.tagsEnabled, updates.events) + } +} + +func TestFoldersEditPeerFoldersPersistsArchiveAndReturnsPtsUpdate(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 13 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 77) + dialogs := &captureDialogs{} + updates := &captureUpdates{state: domain.UpdateState{Pts: 31, Date: 1700000600, Seq: 0}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + + got, err := r.onFoldersEditPeerFolders(ctx, []tg.InputFolderPeer{{ + Peer: &tg.InputPeerUser{UserID: 1000000002}, + FolderID: domain.DialogArchiveFolderID, + }}) + if err != nil { + t.Fatalf("folders.editPeerFolders: %v", err) + } + if len(dialogs.folderPeers) != 1 || dialogs.folderPeers[0].FolderID != domain.DialogArchiveFolderID { + t.Fatalf("folder peers = %+v, want archive update", dialogs.folderPeers) + } + out, ok := got.(*tg.Updates) + if !ok || len(out.Updates) != 1 { + t.Fatalf("updates = %T %+v, want tg.Updates with updateFolderPeers", got, got) + } + update, ok := out.Updates[0].(*tg.UpdateFolderPeers) + if !ok || update.Pts != 31 || update.PtsCount != 1 || len(update.FolderPeers) != 1 { + t.Fatalf("update = %T %+v, want updateFolderPeers pts=31", out.Updates[0], out.Updates[0]) + } +} + +func TestDialogSettingRPCsSkipManualPushWhenReliableDispatch(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 11 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + dialogs := &captureDialogs{} + updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}, reliableDispatch: true} + sessions := &captureSessions{} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + + toggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}}} + toggle.SetPinned(true) + if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok { + t.Fatalf("toggle pin = %v, %v", ok, err) + } + if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogPinned { + t.Fatalf("events = %+v, want durable event recorded", updates.events) + } + // reliable outbox 仍是 update 本体的唯一在线投递者;当前 session 只 + // 收到一条纯 pts 簿记信封(outbox 排除了它,事件占用的账号 pts 必须 + // 显式同步,否则当前设备水位落后)。 + got := sessions.snapshot() + if got.message == nil { + t.Fatalf("manual push = nil, want pts bookkeeping envelope for the current session") + } + envelope, ok := got.message.(*tg.Updates) + if !ok || len(envelope.Updates) != 1 { + t.Fatalf("manual push = %T %+v, want a single bookkeeping update", got.message, got.message) + } + if bookkeeping, ok := envelope.Updates[0].(*tg.UpdateDeleteMessages); !ok || len(bookkeeping.Messages) != 0 { + t.Fatalf("manual push update = %#v, want empty updateDeleteMessages bookkeeping", envelope.Updates[0]) + } +} + +func TestMessagesSaveDraftPushesDraftUpdateToOtherSessions(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{ + Sessions: sessions, + Users: mapUsersService{users: map[int64]domain.User{ + userID: {ID: userID, FirstName: "Alice"}, + peerID: {ID: peerID, FirstName: "Bob"}, + }}, + }, zaptest.NewLogger(t), clock.System) + var authKeyID [8]byte + authKeyID[0] = 8 + + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKeyID), 66) + ok, err := r.onMessagesSaveDraft(ctx, &tg.MessagesSaveDraftRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + Message: "1111", + Entities: []tg.MessageEntityClass{ + &tg.MessageEntityBold{Offset: 0, Length: 4}, + }, + }) + if err != nil { + t.Fatalf("save draft: %v", err) + } + if !ok { + t.Fatalf("save draft = false, want true") + } + + got := sessions.snapshot() + if got.userID != userID || got.sessionID != 66 || got.messageType != proto.MessageFromServer { + t.Fatalf("push = user %d exclude session %d type %v, want self/exclude/from_server", got.userID, got.sessionID, got.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { + t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) + } + updates, ok := got.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", got.message) + } + if len(updates.Updates) != 1 { + t.Fatalf("updates = %+v, want one update", updates.Updates) + } + update, ok := updates.Updates[0].(*tg.UpdateDraftMessage) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateDraftMessage", updates.Updates[0]) + } + peer, ok := update.Peer.(*tg.PeerUser) + if !ok || peer.UserID != peerID { + t.Fatalf("draft peer = %#v, want peer user %d", update.Peer, peerID) + } + draft, ok := update.Draft.(*tg.DraftMessage) + if !ok || draft.Message != "1111" || len(draft.Entities) != 1 { + t.Fatalf("draft = %#v, want message and entities", update.Draft) + } + if len(updates.Users) != 2 { + t.Fatalf("users = %+v, want self and peer", updates.Users) + } +} + +func TestMessagesSaveDraftPersistsAndGetAllDraftsReturnsForumDraft(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Forum", + Forum: true, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + dialogStore := memory.NewDialogStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + Dialogs: appdialogs.NewService(dialogStore, channelStore), + }, zaptest.NewLogger(t), clock.System) + + reply := &tg.InputReplyToMessage{} + reply.SetTopMsgID(123) + ok, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{ + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + ReplyTo: reply, + Message: "topic draft", + Media: &tg.InputMediaWebPage{ + URL: "https://example.test/draft", + Optional: true, + }, + }) + if err != nil || !ok { + t.Fatalf("save forum draft = %v, %v", ok, err) + } + drafts, err := dialogStore.ListDrafts(ctx, owner.ID, 10) + if err != nil { + t.Fatalf("list stored drafts: %v", err) + } + if len(drafts) != 1 || drafts[0].TopMessageID != 123 || drafts[0].Message != "topic draft" || drafts[0].WebPage == nil { + t.Fatalf("stored drafts = %+v, want forum draft with webpage", drafts) + } + + got, err := r.onMessagesGetAllDrafts(WithUserID(ctx, owner.ID)) + if err != nil { + t.Fatalf("get all drafts: %v", err) + } + updates, ok := got.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("get all drafts = %T %+v, want one updates", got, got) + } + update, ok := updates.Updates[0].(*tg.UpdateDraftMessage) + if !ok { + t.Fatalf("draft update = %T", updates.Updates[0]) + } + if top, ok := update.GetTopMsgID(); !ok || top != 123 { + t.Fatalf("top_msg_id = %d/%v, want 123/true", top, ok) + } + draft, ok := update.Draft.(*tg.DraftMessage) + if !ok || draft.Message != "topic draft" { + t.Fatalf("draft = %#v, want message draft", update.Draft) + } + if media, ok := draft.Media.(*tg.InputMediaWebPage); !ok || media.URL != "https://example.test/draft" { + t.Fatalf("draft media = %#v, want webpage", draft.Media) + } + if len(updates.Chats) != 1 { + t.Fatalf("chats = %+v, want channel context", updates.Chats) + } +} + +func TestMessagesGetAllDraftsUsesSingleBatchUserLookup(t *testing.T) { + const ( + userID = int64(1000000001) + firstID = int64(1000000002) + secondID = int64(1000000003) + channelID = int64(2000000001) + ) + dialogs := &captureDialogs{drafts: []domain.DialogDraft{ + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: firstID}, Date: 1700000100, Message: "first"}, + {Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, TopMessageID: 77, Date: 1700000101, Message: "topic"}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: firstID}, Date: 1700000102, Message: "duplicate"}, + {Peer: domain.Peer{Type: domain.PeerTypeUser, ID: secondID}, Date: 1700000103, Message: "second"}, + }} + users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{ + userID: {ID: userID, FirstName: "Alice"}, + firstID: {ID: firstID, FirstName: "Bob"}, + secondID: {ID: secondID, FirstName: "Carol"}, + }}} + r := New(Config{}, Deps{ + Dialogs: dialogs, + Users: users, + }, zaptest.NewLogger(t), clock.System) + + got, err := r.onMessagesGetAllDrafts(WithUserID(context.Background(), userID)) + if err != nil { + t.Fatalf("get all drafts: %v", err) + } + updates := got.(*tg.Updates) + if len(updates.Updates) != len(dialogs.drafts) { + t.Fatalf("updates = %+v, want one update per draft", updates.Updates) + } + if users.selfCalls != 1 || users.byIDsCalls != 1 || users.byIDCalls != 0 { + t.Fatalf("user lookups self=%d byIDs=%d byID=%d, want one Self, one ByIDs and no ByID", users.selfCalls, users.byIDsCalls, users.byIDCalls) + } + if len(users.lastByIDs) != 2 || users.lastByIDs[0] != firstID || users.lastByIDs[1] != secondID { + t.Fatalf("ByIDs ids = %+v, want [%d %d]", users.lastByIDs, firstID, secondID) + } + if ids := gotUserIDs(updates.Users); len(ids) != 3 || ids[0] != userID || ids[1] != firstID || ids[2] != secondID { + t.Fatalf("users = %+v, want self and two peer users", updates.Users) + } + channelDraft := updates.Updates[1].(*tg.UpdateDraftMessage) + if top, ok := channelDraft.GetTopMsgID(); !ok || top != 77 { + t.Fatalf("channel draft top_msg_id = %d/%v, want 77/true", top, ok) + } +} + +func TestMessagesGetAllDraftsUsesSingleBatchChannelLookup(t *testing.T) { + ctx := context.Background() + const userID = int64(1000000001) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + first, err := channelService.CreateChannel(ctx, userID, domain.CreateChannelRequest{ + CreatorUserID: userID, + Title: "Draft Channel One", + Broadcast: true, + Date: 1700000200, + }) + if err != nil { + t.Fatalf("create first channel: %v", err) + } + second, err := channelService.CreateChannel(ctx, userID, domain.CreateChannelRequest{ + CreatorUserID: userID, + Title: "Draft Channel Two", + Megagroup: true, + Date: 1700000210, + }) + if err != nil { + t.Fatalf("create second channel: %v", err) + } + dialogs := &captureDialogs{drafts: []domain.DialogDraft{ + {Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID}, Date: 1700000220, Message: "first"}, + {Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: second.Channel.ID}, TopMessageID: 77, Date: 1700000221, Message: "topic"}, + {Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID}, Date: 1700000222, Message: "duplicate"}, + }} + counting := &countingChannelsService{Service: channelService} + r := New(Config{}, Deps{ + Dialogs: dialogs, + Channels: counting, + }, zaptest.NewLogger(t), clock.System) + + got, err := r.onMessagesGetAllDrafts(WithUserID(ctx, userID)) + if err != nil { + t.Fatalf("get all drafts: %v", err) + } + updates := got.(*tg.Updates) + if len(updates.Updates) != len(dialogs.drafts) { + t.Fatalf("updates = %+v, want one update per draft", updates.Updates) + } + if counting.getChannelsCalls != 1 || counting.getChannelCalls != 0 { + t.Fatalf("channel lookups GetChannels=%d GetChannel=%d, want one batch and no single lookups", counting.getChannelsCalls, counting.getChannelCalls) + } + if len(updates.Chats) != 2 { + t.Fatalf("chats = %+v, want two unique channel refs", updates.Chats) + } + firstChat, ok := updates.Chats[0].(*tg.Channel) + if !ok || firstChat.ID != first.Channel.ID { + t.Fatalf("first chat = %#v, want first channel", updates.Chats[0]) + } + secondChat, ok := updates.Chats[1].(*tg.Channel) + if !ok || secondChat.ID != second.Channel.ID { + t.Fatalf("second chat = %#v, want second channel", updates.Chats[1]) + } + channelDraft := updates.Updates[1].(*tg.UpdateDraftMessage) + if top, ok := channelDraft.GetTopMsgID(); !ok || top != 77 { + t.Fatalf("channel draft top_msg_id = %d/%v, want 77/true", top, ok) + } +} + +func TestMessagesGetDialogsIncludesCloudDraft(t *testing.T) { + ctx := context.Background() + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + dialogStore := memory.NewDialogStore() + if err := dialogStore.Upsert(ctx, userID, domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + TopMessage: 10, + TopMessageDate: 1700000000, + }); err != nil { + t.Fatalf("upsert dialog: %v", err) + } + if err := dialogStore.SaveDraft(ctx, userID, domain.DialogDraft{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + Date: 1700000100, + Message: "saved draft", + }); err != nil { + t.Fatalf("save draft: %v", err) + } + r := New(Config{}, Deps{ + Users: mapUsersService{users: map[int64]domain.User{ + userID: {ID: userID, FirstName: "Alice"}, + peerID: {ID: peerID, FirstName: "Bob"}, + }}, + Dialogs: appdialogs.NewService(dialogStore), + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 10, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode get dialogs: %v", err) + } + got, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("get dialogs: %v", err) + } + box, ok := got.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("response = %T, want boxed messages.dialogs", got) + } + dialogs, ok := box.Dialogs.(*tg.MessagesDialogs) + if !ok || len(dialogs.Dialogs) != 1 { + t.Fatalf("dialogs = %T %+v, want one messages.dialogs", box.Dialogs, box.Dialogs) + } + dialog, ok := dialogs.Dialogs[0].(*tg.Dialog) + if !ok { + t.Fatalf("dialog = %T", dialogs.Dialogs[0]) + } + draft, ok := dialog.Draft.(*tg.DraftMessage) + if !ok || draft.Message != "saved draft" { + t.Fatalf("dialog draft = %#v, want saved draft", dialog.Draft) + } +} + +func TestMessagesClearAllDraftsClearsAndPushesEmptyUpdates(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + dialogs := &captureDialogs{drafts: []domain.DialogDraft{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + Date: 1700000100, + Message: "draft", + }}} + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{ + Dialogs: dialogs, + Sessions: sessions, + Users: mapUsersService{users: map[int64]domain.User{ + userID: {ID: userID, FirstName: "Alice"}, + peerID: {ID: peerID, FirstName: "Bob"}, + }}, + }, zaptest.NewLogger(t), clock.System) + ok, err := r.onMessagesClearAllDrafts(WithSessionID(WithUserID(context.Background(), userID), 9)) + if err != nil || !ok { + t.Fatalf("clear all drafts = %v, %v", ok, err) + } + got := sessions.snapshot() + updates, ok := got.message.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("pushed = %T %+v, want one update", got.message, got.message) + } + update, ok := updates.Updates[0].(*tg.UpdateDraftMessage) + if !ok { + t.Fatalf("update = %T", updates.Updates[0]) + } + if _, ok := update.Draft.(*tg.DraftMessageEmpty); !ok { + t.Fatalf("draft = %#v, want draftMessageEmpty", update.Draft) + } + if len(dialogs.drafts) != 0 { + t.Fatalf("capture drafts = %+v, want cleared", dialogs.drafts) + } +} + +func TestAccountUpdateStatusPushesPresenceToOnlinePrivateDialogPeers(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} + dialogs := memory.NewDialogStore() + // 私聊 dialog 双向建行:alice↔bob 两侧都存。presence 接收者由「主体自己的 + // dialog 对端 ∩ 在线」算出,故 bob 改状态时需 bob 侧 dialog 含 alice。 + if err := dialogs.SaveList(ctx, alice.ID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + TopMessage: 10, + TopMessageDate: 1700000000, + }}, + Users: []domain.User{bob}, + }); err != nil { + t.Fatalf("save dialogs: %v", err) + } + if err := dialogs.SaveList(ctx, bob.ID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, + TopMessage: 10, + TopMessageDate: 1700000000, + }}, + Users: []domain.User{alice}, + }); err != nil { + t.Fatalf("save bob dialogs: %v", err) + } + sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}} + r := New(Config{}, Deps{ + Dialogs: appdialogs.NewService(dialogs), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false) + if err != nil || !ok { + t.Fatalf("bob account.updateStatus online = %v, %v", ok, err) + } + gotPushes := sessions.pushedUserIDs() + if !reflect.DeepEqual(gotPushes, []int64{bob.ID, alice.ID}) { + t.Fatalf("pushed users = %+v, want self and online private dialog peer", gotPushes) + } + update := pushedUserStatus(t, sessions.snapshot().message) + if update.UserID != bob.ID { + t.Fatalf("status user = %d, want bob", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { + t.Fatalf("status = %#v, want online with future expires", update.Status) + } +} + +func TestDialogPinFolderScoping(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 9 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + dialogPeer := &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}} + dialogs := &captureDialogs{folderID: domain.DialogArchiveFolderID} + updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + + // 归档内会话置顶:durable 事件必须携带 folder_id=1,否则离线设备 + // 重放时会把置顶应用到主列表。 + toggle := &tg.MessagesToggleDialogPinRequest{Peer: dialogPeer} + toggle.SetPinned(true) + if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok { + t.Fatalf("toggle pin = %v, %v", ok, err) + } + if len(updates.events) != 1 || updates.events[0].FolderID != domain.DialogArchiveFolderID { + t.Fatalf("pin event = %+v, want folder_id=1", updates.events) + } + + // 归档列表内重排:req.folder_id 必须传到 service 与 durable 事件。 + reorder := &tg.MessagesReorderPinnedDialogsRequest{Order: []tg.InputDialogPeerClass{dialogPeer}} + reorder.FolderID = domain.DialogArchiveFolderID + if ok, err := r.onMessagesReorderPinnedDialogs(ctx, reorder); err != nil || !ok { + t.Fatalf("reorder pinned = %v, %v", ok, err) + } + if dialogs.folderID != domain.DialogArchiveFolderID { + t.Fatalf("reorder folder = %d, want archive", dialogs.folderID) + } + if len(updates.events) != 2 || updates.events[1].FolderID != domain.DialogArchiveFolderID { + t.Fatalf("reorder event = %+v, want folder_id=1", updates.events) + } + + // 差分重放转换:归档内置顶 update 带 folder_id flag。 + pinnedUpdate := tgOtherUpdateFromEvent(updates.events[0]) + dialogPinned, ok := pinnedUpdate.(*tg.UpdateDialogPinned) + if !ok { + t.Fatalf("replayed update = %T, want *tg.UpdateDialogPinned", pinnedUpdate) + } + if folderID, has := dialogPinned.GetFolderID(); !has || folderID != domain.DialogArchiveFolderID { + dialogPinned.SetFlags() + if folderID, has = dialogPinned.GetFolderID(); !has || folderID != domain.DialogArchiveFolderID { + t.Fatalf("replayed updateDialogPinned folder = %d (%v), want 1", folderID, has) + } + } +} + +func TestToggleDialogPinArchiveFolderRow(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 9 + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + dialogs := &captureDialogs{} + updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + + // archive folder 行自身置顶(TDesktop 归档行右键 Pin)。 + toggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerFolder{FolderID: domain.DialogArchiveFolderID}} + toggle.SetPinned(true) + if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok { + t.Fatalf("toggle archive folder pin = %v, %v", ok, err) + } + if !dialogs.archivePinned { + t.Fatalf("archive pinned not persisted") + } + if len(updates.events) != 1 || updates.events[0].Peer.Type != domain.PeerTypeFolder || updates.events[0].Peer.ID != domain.DialogArchiveFolderID { + t.Fatalf("archive pin event = %+v, want folder peer", updates.events) + } + // 重放转换:dialogPeerFolder 且不带 folder_id flag(TDesktop 会把带 + // flag 的 folder peer 置顶视为 "Nested folders" 协议错误)。 + update := tgOtherUpdateFromEvent(updates.events[0]) + dialogPinned, ok := update.(*tg.UpdateDialogPinned) + if !ok { + t.Fatalf("replayed update = %T, want *tg.UpdateDialogPinned", update) + } + folderPeer, ok := dialogPinned.Peer.(*tg.DialogPeerFolder) + if !ok || folderPeer.FolderID != domain.DialogArchiveFolderID { + t.Fatalf("replayed peer = %+v, want dialogPeerFolder(1)", dialogPinned.Peer) + } + dialogPinned.SetFlags() + if folderID, has := dialogPinned.GetFolderID(); has { + t.Fatalf("folder peer pin carries folder_id=%d flag, want absent", folderID) + } + + // 非归档 folder id 一律 FOLDER_ID_INVALID。 + badToggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeerFolder{FolderID: 0}} + badToggle.SetPinned(true) + if _, err := r.onMessagesToggleDialogPin(ctx, badToggle); err == nil || !strings.Contains(err.Error(), "FOLDER_ID_INVALID") { + t.Fatalf("toggle folder 0 err = %v, want FOLDER_ID_INVALID", err) + } +} + +func TestMessagesDialogsIncludesArchiveFolderEntry(t *testing.T) { + list := domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + TopMessage: 9, + TopMessageDate: 40, + }}, + ArchiveSummary: &domain.DialogArchiveSummary{ + TopPeer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000003}, + TopMessage: 7, + UnreadPeersCount: 2, + UnreadMessagesCount: 5, + Pinned: true, + }, + } + out, ok := tgMessagesDialogs(1000000001, list).(*tg.MessagesDialogs) + if !ok { + t.Fatalf("response type = %T, want *tg.MessagesDialogs", tgMessagesDialogs(1000000001, list)) + } + if len(out.Dialogs) != 2 { + t.Fatalf("dialogs = %d, want folder entry + dialog", len(out.Dialogs)) + } + folder, ok := out.Dialogs[0].(*tg.DialogFolder) + if !ok { + t.Fatalf("first dialog = %T, want *tg.DialogFolder", out.Dialogs[0]) + } + if folder.Folder.ID != domain.DialogArchiveFolderID || !folder.Pinned { + t.Fatalf("folder entry = %+v, want archive id pinned", folder) + } + if folder.TopMessage != 7 || folder.UnreadUnmutedPeersCount != 2 || folder.UnreadUnmutedMessagesCount != 5 { + t.Fatalf("folder counters = %+v, want top 7 / peers 2 / messages 5", folder) + } + peer, ok := folder.Peer.(*tg.PeerUser) + if !ok || peer.UserID != 1000000003 { + t.Fatalf("folder peer = %+v, want archived top peer", folder.Peer) + } + // 无归档时不输出条目。 + plain := domain.DialogList{Dialogs: list.Dialogs} + plainOut, _ := tgMessagesDialogs(1000000001, plain).(*tg.MessagesDialogs) + if len(plainOut.Dialogs) != 1 { + t.Fatalf("plain dialogs = %d, want no folder entry", len(plainOut.Dialogs)) + } +} + +func TestPeerDialogsIncludesArchiveFolderEntry(t *testing.T) { + list := domain.DialogList{ + ArchiveSummary: &domain.DialogArchiveSummary{ + TopPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: 18}, + TopMessage: 2, + UnreadPeersCount: 1, + UnreadMessagesCount: 4, + Pinned: true, + }, + } + out := tgPeerDialogs(1000000001, list, domain.UpdateState{Pts: 5}) + if len(out.Dialogs) != 1 { + t.Fatalf("pinned dialogs = %d entries, want folder entry", len(out.Dialogs)) + } + folder, ok := out.Dialogs[0].(*tg.DialogFolder) + if !ok { + t.Fatalf("first dialog = %T, want *tg.DialogFolder", out.Dialogs[0]) + } + // DrKLO fetchFolderInLoadedPinnedDialogs 要求 top_message 与 peer 非零, + // 否则条目被丢弃。 + if folder.TopMessage != 2 { + t.Fatalf("folder top message = %d, want 2", folder.TopMessage) + } + peer, ok := folder.Peer.(*tg.PeerChannel) + if !ok || peer.ChannelID != 18 { + t.Fatalf("folder peer = %+v, want channel 18", folder.Peer) + } +} + +func TestMessagesGetDialogsTDesktopInitialPageMergesPinnedHeader(t *testing.T) { + viewer := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} + charlie := domain.User{ID: 1000000003, AccessHash: 33, FirstName: "Charlie"} + dialogs := &captureDialogs{ + list: domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: charlie.ID}, + TopMessage: 31, + TopMessageDate: 1781597300, + }}, + Messages: []domain.Message{{ + ID: 31, + OwnerUserID: viewer.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: charlie.ID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: charlie.ID}, + Date: 1781597300, + Body: "normal", + }}, + Users: []domain.User{viewer, charlie}, + Count: 1, + }, + pinnedList: domain.DialogList{ + ArchiveSummary: &domain.DialogArchiveSummary{ + TopPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: 13}, + TopMessage: 7, + UnreadPeersCount: 1, + UnreadMessagesCount: 2, + Pinned: true, + }, + Dialogs: []domain.Dialog{ + { + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9}, + TopMessage: 16, + TopMessageDate: 1781541965, + Pinned: true, + PinnedOrder: 2, + Pts: 16, + }, + { + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + TopMessage: 77, + TopMessageDate: 1781597372, + Pinned: true, + PinnedOrder: 1, + }, + }, + ChannelMessages: []domain.ChannelMessage{ + {ChannelID: 13, ID: 7, SenderUserID: bob.ID, From: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, Date: 1781540000, Body: "archived top", Pts: 7}, + {ChannelID: 9, ID: 16, SenderUserID: bob.ID, From: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, Date: 1781541965, Body: "pinned channel", Pts: 16}, + }, + Messages: []domain.Message{{ + ID: 77, + OwnerUserID: viewer.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: viewer.ID}, + Date: 1781597372, + Out: true, + Body: "pinned private", + }}, + Users: []domain.User{viewer, bob}, + Channels: []domain.Channel{ + {ID: 13, AccessHash: 1313, CreatorUserID: viewer.ID, Title: "Archive Top", Megagroup: true, TopMessageID: 7, Pts: 7}, + {ID: 9, AccessHash: 9090, CreatorUserID: viewer.ID, Title: "Pinned Channel", Megagroup: true, TopMessageID: 16, Pts: 16}, + }, + Count: 2, + }, + hasPinnedList: true, + } + r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetDialogsRequest{ + ExcludePinned: true, + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + Hash: 0, + } + req.SetFolderID(domain.DialogMainFolderID) + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode get dialogs: %v", err) + } + ctx := WithClientInfo(WithUserID(context.Background(), viewer.ID), ClientInfo{Type: ClientTypeTDesktop}) + enc, err := r.Dispatch(ctx, [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch get dialogs: %v", err) + } + box, ok := enc.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesDialogsBox", enc) + } + out, ok := box.Dialogs.(*tg.MessagesDialogs) + if !ok { + t.Fatalf("dialogs response = %T, want *tg.MessagesDialogs", box.Dialogs) + } + if dialogs.getDialogsCalls != 2 || len(dialogs.filters) != 2 { + t.Fatalf("GetDialogs calls = %d filters %+v, want normal + pinned", dialogs.getDialogsCalls, dialogs.filters) + } + if !dialogs.filters[0].ExcludePinned || !dialogs.filters[1].PinnedOnly { + t.Fatalf("filters = %+v, want exclude-pinned then pinned-only", dialogs.filters) + } + if len(out.Dialogs) != 4 { + t.Fatalf("dialogs = %d, want archive + two pinned + normal", len(out.Dialogs)) + } + if _, ok := out.Dialogs[0].(*tg.DialogFolder); !ok { + t.Fatalf("first dialog = %T, want archive folder", out.Dialogs[0]) + } + pinnedChannel := out.Dialogs[1].(*tg.Dialog) + if peer, ok := pinnedChannel.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != 9 || !pinnedChannel.Pinned { + t.Fatalf("second dialog = %+v peer %+v, want pinned channel 9", pinnedChannel, pinnedChannel.Peer) + } + pinnedUser := out.Dialogs[2].(*tg.Dialog) + if peer, ok := pinnedUser.Peer.(*tg.PeerUser); !ok || peer.UserID != bob.ID || !pinnedUser.Pinned { + t.Fatalf("third dialog = %+v peer %+v, want pinned user %d", pinnedUser, pinnedUser.Peer, bob.ID) + } + normal := out.Dialogs[3].(*tg.Dialog) + if peer, ok := normal.Peer.(*tg.PeerUser); !ok || peer.UserID != charlie.ID || normal.Pinned { + t.Fatalf("fourth dialog = %+v peer %+v, want normal user %d", normal, normal.Peer, charlie.ID) + } + if !messagesDialogsHasChannel(out, 13) || !messagesDialogsHasChannel(out, 9) { + t.Fatalf("chats = %+v, want archive and pinned channels", out.Chats) + } + if !messagesDialogsHasUser(out, bob.ID) || !messagesDialogsHasUser(out, charlie.ID) { + t.Fatalf("users = %+v, want pinned and normal users", out.Users) + } + if !messagesDialogsHasChannelMessage(out, 13, 7) || !messagesDialogsHasChannelMessage(out, 9, 16) { + t.Fatalf("messages = %+v, want archive and pinned channel top messages", out.Messages) + } + if !messagesDialogsHasPrivateMessage(out, bob.ID, 77) || !messagesDialogsHasPrivateMessage(out, charlie.ID, 31) { + t.Fatalf("messages = %+v, want pinned and normal private top messages", out.Messages) + } +} + +func TestMessagesGetDialogsNonTDesktopKeepsExcludePinnedResponse(t *testing.T) { + viewer := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} + dialogs := &captureDialogs{ + list: domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + TopMessage: 31, + TopMessageDate: 1781597300, + }}, + Messages: []domain.Message{{ + ID: 31, + OwnerUserID: viewer.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + Date: 1781597300, + Body: "normal", + }}, + Users: []domain.User{viewer, bob}, + Count: 1, + }, + pinnedList: domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9}, + TopMessage: 16, + TopMessageDate: 1781541965, + Pinned: true, + }}, + Channels: []domain.Channel{{ID: 9, AccessHash: 9090, Title: "Pinned Channel", Megagroup: true}}, + Count: 1, + }, + hasPinnedList: true, + } + r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetDialogsRequest{ + ExcludePinned: true, + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + } + req.SetFolderID(domain.DialogMainFolderID) + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode get dialogs: %v", err) + } + enc, err := r.Dispatch(WithUserID(context.Background(), viewer.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch get dialogs: %v", err) + } + box := enc.(*tg.MessagesDialogsBox) + out := box.Dialogs.(*tg.MessagesDialogs) + if dialogs.getDialogsCalls != 1 { + t.Fatalf("GetDialogs calls = %d, want no pinned compatibility load", dialogs.getDialogsCalls) + } + if len(out.Dialogs) != 1 { + t.Fatalf("dialogs = %d, want only exclude-pinned response", len(out.Dialogs)) + } + normal := out.Dialogs[0].(*tg.Dialog) + if peer, ok := normal.Peer.(*tg.PeerUser); !ok || peer.UserID != bob.ID || normal.Pinned { + t.Fatalf("dialog = %+v peer %+v, want normal user %d", normal, normal.Peer, bob.ID) + } +} + +func TestPinnedDialogsListSingleflightsConcurrentStartupLoads(t *testing.T) { + const userID int64 = 1000000001 + dialogs := &blockingPinnedDialogs{ + captureDialogs: captureDialogs{ + pinnedList: domain.DialogList{Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + TopMessage: 11, + TopMessageDate: 1781597372, + Pinned: true, + }}}, + hasPinnedList: true, + }, + entered: make(chan struct{}), + release: make(chan struct{}), + } + r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System) + errs := make(chan error, 2) + results := make(chan domain.DialogList, 2) + call := func() { + list, err := r.pinnedDialogsList(context.Background(), userID, domain.DialogMainFolderID) + errs <- err + results <- list + } + go call() + <-dialogs.entered + go call() + time.Sleep(10 * time.Millisecond) + close(dialogs.release) + for i := 0; i < 2; i++ { + if err := <-errs; err != nil { + t.Fatalf("pinnedDialogsList call %d: %v", i, err) + } + if got := <-results; len(got.Dialogs) != 1 || got.Dialogs[0].TopMessage != 11 { + t.Fatalf("result %d = %+v, want shared pinned list", i, got) + } + } + if calls := dialogs.pinnedCalls(); calls != 1 { + t.Fatalf("pinned GetDialogs calls = %d, want singleflight to share one in-flight load", calls) + } +} + +type blockingPinnedDialogs struct { + captureDialogs + mu sync.Mutex + calls int + entered chan struct{} + release chan struct{} +} + +func (s *blockingPinnedDialogs) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { + if !filter.PinnedOnly { + return s.captureDialogs.GetDialogs(ctx, userID, filter) + } + s.mu.Lock() + s.calls++ + calls := s.calls + s.mu.Unlock() + if calls == 1 { + close(s.entered) + <-s.release + } + return s.captureDialogs.GetDialogs(ctx, userID, filter) +} + +func (s *blockingPinnedDialogs) pinnedCalls() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls +} + +func TestMessagesGetPinnedDialogsIncludesArchiveAndReferencedPeers(t *testing.T) { + viewer := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} + dialogs := &captureDialogs{list: domain.DialogList{ + ArchiveSummary: &domain.DialogArchiveSummary{ + TopPeer: domain.Peer{Type: domain.PeerTypeChannel, ID: 13}, + TopMessage: 7, + UnreadPeersCount: 2, + UnreadMessagesCount: 5, + Pinned: true, + }, + Dialogs: []domain.Dialog{ + { + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 9}, + TopMessage: 16, + TopMessageDate: 1781541965, + Pinned: true, + PinnedOrder: 2, + Pts: 16, + }, + { + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + TopMessage: 77, + TopMessageDate: 1781597372, + Pinned: true, + PinnedOrder: 1, + }, + }, + ChannelMessages: []domain.ChannelMessage{ + { + ChannelID: 13, + ID: 7, + SenderUserID: bob.ID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + Date: 1781540000, + Body: "archived top", + Pts: 7, + }, + { + ChannelID: 9, + ID: 16, + SenderUserID: bob.ID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + Date: 1781541965, + Body: "pinned channel", + Pts: 16, + }, + }, + Messages: []domain.Message{ + { + ID: 77, + OwnerUserID: viewer.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: viewer.ID}, + Date: 1781597372, + Out: true, + Body: "pinned private", + Pts: 77, + }, + }, + Users: []domain.User{viewer, bob}, + Channels: []domain.Channel{ + {ID: 13, AccessHash: 1313, CreatorUserID: viewer.ID, Title: "Archive Top", Megagroup: true, TopMessageID: 7, Pts: 7}, + {ID: 9, AccessHash: 9090, CreatorUserID: viewer.ID, Title: "Pinned Channel", Megagroup: true, TopMessageID: 16, Pts: 16}, + }, + }} + updates := &captureUpdates{state: domain.UpdateState{Pts: 55, Qts: 1, Date: 1781597400}} + r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetPinnedDialogsRequest{FolderID: 0} + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode get pinned dialogs: %v", err) + } + authKeyID := [8]byte{9} + enc, err := r.Dispatch(WithUserID(context.Background(), viewer.ID), authKeyID, 0, &in) + if err != nil { + t.Fatalf("dispatch get pinned dialogs: %v", err) + } + out, ok := enc.(*tg.MessagesPeerDialogs) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesPeerDialogs", enc) + } + if dialogs.filter != (domain.DialogFilter{PinnedOnly: true, HasFolderID: true, FolderID: 0, Limit: 100}) { + t.Fatalf("filter = %+v, want pinned folder 0 limit 100", dialogs.filter) + } + if updates.authKeyID != authKeyID || updates.userID != viewer.ID { + t.Fatalf("state lookup auth/user = %x/%d, want %x/%d", updates.authKeyID, updates.userID, authKeyID, viewer.ID) + } + if len(out.Dialogs) != 3 { + t.Fatalf("dialogs = %d, want archive + two pinned peers", len(out.Dialogs)) + } + folder, ok := out.Dialogs[0].(*tg.DialogFolder) + if !ok { + t.Fatalf("first dialog = %T, want *tg.DialogFolder", out.Dialogs[0]) + } + archivePeer, ok := folder.Peer.(*tg.PeerChannel) + if !ok || archivePeer.ChannelID != 13 || folder.TopMessage != 7 || !folder.Pinned { + t.Fatalf("archive folder = %+v peer %+v, want pinned channel 13 top 7", folder, folder.Peer) + } + pinnedChannel, ok := out.Dialogs[1].(*tg.Dialog) + if !ok || !pinnedChannel.Pinned { + t.Fatalf("second dialog = %T %+v, want pinned channel dialog", out.Dialogs[1], out.Dialogs[1]) + } + channelPeer, ok := pinnedChannel.Peer.(*tg.PeerChannel) + if !ok || channelPeer.ChannelID != 9 || pinnedChannel.TopMessage != 16 { + t.Fatalf("second peer = %+v top %d, want channel 9 top 16", pinnedChannel.Peer, pinnedChannel.TopMessage) + } + pinnedUser, ok := out.Dialogs[2].(*tg.Dialog) + if !ok || !pinnedUser.Pinned { + t.Fatalf("third dialog = %T %+v, want pinned user dialog", out.Dialogs[2], out.Dialogs[2]) + } + userPeer, ok := pinnedUser.Peer.(*tg.PeerUser) + if !ok || userPeer.UserID != bob.ID || pinnedUser.TopMessage != 77 { + t.Fatalf("third peer = %+v top %d, want user %d top 77", pinnedUser.Peer, pinnedUser.TopMessage, bob.ID) + } + if !peerDialogsHasChannel(out, 13) || !peerDialogsHasChannel(out, 9) { + t.Fatalf("chats = %+v, want archive top channel 13 and pinned channel 9", out.Chats) + } + if !peerDialogsHasUser(out, bob.ID) { + t.Fatalf("users = %+v, want pinned private user %d", out.Users, bob.ID) + } + if !peerDialogsHasChannelMessage(out, 13, 7) || !peerDialogsHasChannelMessage(out, 9, 16) { + t.Fatalf("messages = %+v, want channel messages 13/7 and 9/16", out.Messages) + } + if !peerDialogsHasPrivateMessage(out, bob.ID, 77) { + t.Fatalf("messages = %+v, want private dialog top message %d/%d", out.Messages, bob.ID, 77) + } +} + +func peerDialogsHasChannel(out *tg.MessagesPeerDialogs, id int64) bool { + for _, item := range out.Chats { + if ch, ok := item.(*tg.Channel); ok && ch.ID == id { + return true + } + } + return false +} + +func peerDialogsHasUser(out *tg.MessagesPeerDialogs, id int64) bool { + for _, item := range out.Users { + if user, ok := item.(*tg.User); ok && user.ID == id { + return true + } + } + return false +} + +func peerDialogsHasChannelMessage(out *tg.MessagesPeerDialogs, channelID int64, messageID int) bool { + for _, item := range out.Messages { + if msg, ok := item.(*tg.Message); ok && msg.ID == messageID { + if peer, ok := msg.PeerID.(*tg.PeerChannel); ok && peer.ChannelID == channelID { + return true + } + } + } + return false +} + +func peerDialogsHasPrivateMessage(out *tg.MessagesPeerDialogs, userID int64, messageID int) bool { + for _, item := range out.Messages { + if msg, ok := item.(*tg.Message); ok && msg.ID == messageID { + if peer, ok := msg.PeerID.(*tg.PeerUser); ok && peer.UserID == userID { + return true + } + } + } + return false +} + +func messagesDialogsHasChannel(out *tg.MessagesDialogs, id int64) bool { + for _, item := range out.Chats { + if ch, ok := item.(*tg.Channel); ok && ch.ID == id { + return true + } + } + return false +} + +func messagesDialogsHasUser(out *tg.MessagesDialogs, id int64) bool { + for _, item := range out.Users { + if user, ok := item.(*tg.User); ok && user.ID == id { + return true + } + } + return false +} + +func messagesDialogsHasChannelMessage(out *tg.MessagesDialogs, channelID int64, messageID int) bool { + for _, item := range out.Messages { + if msg, ok := item.(*tg.Message); ok && msg.ID == messageID { + if peer, ok := msg.PeerID.(*tg.PeerChannel); ok && peer.ChannelID == channelID { + return true + } + } + } + return false +} + +func messagesDialogsHasPrivateMessage(out *tg.MessagesDialogs, userID int64, messageID int) bool { + for _, item := range out.Messages { + if msg, ok := item.(*tg.Message); ok && msg.ID == messageID { + if peer, ok := msg.PeerID.(*tg.PeerUser); ok && peer.UserID == userID { + return true + } + } + } + return false +} diff --git a/internal/rpc/effects_rpc_test.go b/internal/rpc/effects_rpc_test.go new file mode 100644 index 00000000..74c1a729 --- /dev/null +++ b/internal/rpc/effects_rpc_test.go @@ -0,0 +1,85 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" +) + +// TestMessagesGetAvailableEffects 验证消息特效 handler:effects 引用的文档去重进独立 +// Documents 数组、optional 字段(static/anim/premium)正确编码、hash 命中回 NotModified。 +func TestMessagesGetAvailableEffects(t *testing.T) { + doc := func(id int64) domain.Document { + return domain.Document{ID: id, AccessHash: id * 10, MimeType: "application/x-tgsticker", DCID: 2} + } + files := &fakeFiles{ + docs: map[int64]domain.Document{1: doc(1), 2: doc(2), 3: doc(3)}, + effects: []domain.AvailableEffect{ + {ID: 100, Emoticon: "\U0001f525", StaticIconID: 1, EffectStickerID: 2, EffectAnimationID: 3}, + {ID: 101, Emoticon: "\U0001f4a5", EffectStickerID: 2, PremiumRequired: true}, + }, + } + r := New(Config{}, Deps{Files: files}, zaptest.NewLogger(t), clock.System) + ctx := context.Background() + + res, err := r.onMessagesGetAvailableEffects(ctx, 0) + if err != nil { + t.Fatalf("getAvailableEffects: %v", err) + } + full, ok := res.(*tg.MessagesAvailableEffects) + if !ok { + t.Fatalf("res = %T, want *tg.MessagesAvailableEffects", res) + } + if len(full.Effects) != 2 { + t.Fatalf("effects = %d, want 2", len(full.Effects)) + } + if full.Hash == 0 { + t.Fatalf("hash = 0, want stable nonzero") + } + // 文档去重:effect 引用 1/2/3 + 2 → 3 个唯一文档。 + if len(full.Documents) != 3 { + t.Fatalf("documents = %d, want 3 deduped", len(full.Documents)) + } + e0 := full.Effects[0] + if e0.ID != 100 || e0.Emoticon != "\U0001f525" || e0.EffectStickerID != 2 { + t.Fatalf("effect0 = %#v", e0) + } + if v, ok := e0.GetStaticIconID(); !ok || v != 1 { + t.Fatalf("effect0 static icon = %d ok=%v, want 1", v, ok) + } + if v, ok := e0.GetEffectAnimationID(); !ok || v != 3 { + t.Fatalf("effect0 anim = %d ok=%v, want 3", v, ok) + } + if full.Effects[0].PremiumRequired { + t.Fatalf("effect0 premium = true, want false") + } + if !full.Effects[1].PremiumRequired { + t.Fatalf("effect1 premium = false, want true") + } + + again, err := r.onMessagesGetAvailableEffects(ctx, full.Hash) + if err != nil { + t.Fatalf("getAvailableEffects (cached): %v", err) + } + if _, ok := again.(*tg.MessagesAvailableEffectsNotModified); !ok { + t.Fatalf("matching hash = %T, want NotModified", again) + } +} + +// TestMessagesGetAvailableEffectsEmpty 验证无 effects 时回空(非 NotModified),不让客户端卡住。 +func TestMessagesGetAvailableEffectsEmpty(t *testing.T) { + r := New(Config{}, Deps{Files: &fakeFiles{}}, zaptest.NewLogger(t), clock.System) + res, err := r.onMessagesGetAvailableEffects(context.Background(), 0) + if err != nil { + t.Fatalf("err: %v", err) + } + empty, ok := res.(*tg.MessagesAvailableEffects) + if !ok || len(empty.Effects) != 0 { + t.Fatalf("res = %#v, want empty availableEffects", res) + } +} diff --git a/internal/rpc/encrypted_chats.go b/internal/rpc/encrypted_chats.go new file mode 100644 index 00000000..9c2779c9 --- /dev/null +++ b/internal/rpc/encrypted_chats.go @@ -0,0 +1,213 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + appsecret "telesrv/internal/app/secretchat" + "telesrv/internal/domain" +) + +// 私聊端对端加密(Secret Chat / encrypted chat)握手 RPC handler。状态机与 DH 校验 +// 归 app/secretchat;本文件只做鉴权、入参校验、TL 转换与在线推送编排。 +// +// P0 范围:requestEncryption / acceptEncryption / discardEncryption 的握手闭环 + +// updateEncryption 在线推送(账号级 pushUserMessage,与 phone 同套)。设备级定向、 +// durable 离线 getDifference 补偿、qts 消息投递(sendEncrypted 等)见 P1, +// 设计 docs/secret-chat-module.md。服务端是盲中继,永不接触共享密钥与明文。 + +// secretChatErr 把 app/secretchat + domain 业务错误映射为 RPC_ERROR。 +func secretChatErr(err error) error { + switch { + case errors.Is(err, appsecret.ErrGAInvalid): + return dhGAInvalidErr() + case errors.Is(err, domain.ErrSecretChatAlreadyAccepted): + return encryptionAlreadyAcceptedErr() + case errors.Is(err, domain.ErrSecretChatAlreadyDeclined): + return encryptionAlreadyDeclinedErr() + case errors.Is(err, domain.ErrSecretChatNotFound): + return chatIDInvalidErr() + default: + return internalErr() + } +} + +// secretChatRequireUser 是密聊 RPC 的统一登录闸门。 +func (r *Router) secretChatRequireUser(ctx context.Context) (int64, error) { + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return 0, internalErr() + } + if !ok { + return 0, authKeyUnregisteredErr() + } + return userID, nil +} + +// businessAuthKeyIDFrom 返回当前连接业务视角 auth_key_id 的 int64 绑定值。 +func businessAuthKeyIDFrom(ctx context.Context) (int64, bool) { + id, ok := AuthKeyIDFrom(ctx) + if !ok { + return 0, false + } + return businessAuthKeyInt64(id), true +} + +// pushUpdateEncryption 把 targetUserID 视角的 updateEncryption 推给其全部在线设备。 +// P0 用账号级在线推送(设备级定向 + 离线补偿见 P1)。 +func (r *Router) pushUpdateEncryption(ctx context.Context, targetUserID int64, chat domain.SecretChat, logMessage string) { + now := int(r.clock.Now().Unix()) + upd := &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateEncryption{ + Chat: tgEncryptedChatForViewer(chat, targetUserID), + Date: now, + }}, + Users: r.tgUsersForIDs(ctx, targetUserID, []int64{chat.AdminUserID, chat.ParticipantUserID}), + Chats: []tg.ChatClass{}, + Date: now, + Seq: 0, + } + r.pushUserMessage(ctx, targetUserID, logMessage, upd) +} + +// recordEncryptionEventBestEffort 写入 durable updateEncryption 状态事件供离线设备 +// getDifference 补偿。best-effort:失败仅记日志不阻断 RPC(chat 本身已 durable)。 +func (r *Router) recordEncryptionEventBestEffort(ctx context.Context, chatID int, targetUserID, targetAuthKeyID int64, date int) { + if r.deps.SecretChats == nil { + return + } + if err := r.deps.SecretChats.RecordEncryptionEvent(ctx, chatID, targetUserID, targetAuthKeyID, date); err != nil { + r.log.Debug("record encryption state event", zap.Error(err)) + } +} + +// discardSecretChatsForAuthKey 在设备登出 / 授权撤销时级联 discard 该 perm auth_key 绑定的 +// 全部活跃密聊,并向对端推送 encryptedChatDiscarded(在线)+ 写 durable 事件(离线 getDifference +// 补偿)。ownerUserID 是被销毁设备的所有者,用于定位对端。best-effort:失败仅记日志,绝不阻断 +// 登出/撤销。修复 P1:此前 onAuthLogOut 等不级联 discard,对端继续往死 auth_key 投递成静默死链 +//(消息 acked=f / qts 永久积压,对端永看不到 discarded)。 +func (r *Router) discardSecretChatsForAuthKey(ctx context.Context, businessAuthKeyID, ownerUserID int64) { + if r.deps.SecretChats == nil || businessAuthKeyID == 0 || ownerUserID == 0 { + return + } + discarded, err := r.deps.SecretChats.DiscardForAuthKey(ctx, businessAuthKeyID) + if err != nil { + // DiscardForAuthKey 出错也会返回已成功 discard 的部分,继续通知这部分对端。 + r.log.Debug("cascade discard secret chats for auth key", zap.Error(err)) + } + now := int(r.clock.Now().Unix()) + for _, chat := range discarded { + peer := chat.PeerOf(ownerUserID) + if peer == 0 { + continue + } + // 对端绑定设备已知则 device-level 定向,建链前(未绑定,0)则账号级。 + r.recordEncryptionEventBestEffort(ctx, chat.ID, peer, chat.PeerAuthKeyOf(ownerUserID), now) + r.pushUpdateEncryption(ctx, peer, chat, "secret chat discarded on peer logout/revoke") + } +} + +func (r *Router) onMessagesRequestEncryption(ctx context.Context, req *tg.MessagesRequestEncryptionRequest) (tg.EncryptedChatClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.SecretChats == nil || r.deps.Users == nil { + return nil, notImplementedErr() + } + adminID, err := r.secretChatRequireUser(ctx) + if err != nil { + return nil, err + } + adminAuthKeyID, ok := businessAuthKeyIDFrom(ctx) + if !ok { + return nil, internalErr() + } + participant, found, err := r.userFromInput(ctx, adminID, req.UserID) + if err != nil { + return nil, internalErr() + } + // 自聊 / bot / 不存在统一 USER_ID_INVALID(bot 不支持密聊)。 + if !found || participant.ID == 0 || participant.ID == adminID || participant.Bot { + return nil, userIDInvalidErr() + } + if blocked, err := r.peerBlocksUser(ctx, adminID, participant.ID); err != nil { + return nil, err + } else if blocked { + return nil, userIsBlockedErr() + } + chat, err := r.deps.SecretChats.RequestEncryption(ctx, domain.SecretChatRequest{ + AdminUserID: adminID, + AdminAuthKeyID: adminAuthKeyID, + ParticipantUserID: participant.ID, + RandomID: int32(req.RandomID), + GA: req.GA, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, secretChatErr(err) + } + // 建链前邀请是账号级(targetAuthKeyID=0):participant 所有设备(含离线)可见。 + r.recordEncryptionEventBestEffort(ctx, chat.ID, chat.ParticipantUserID, 0, chat.Date) + // 推接受方全部在线设备 encryptedChatRequested(携 g_a)。离线设备经 getDifference 补回。 + r.pushUpdateEncryption(ctx, chat.ParticipantUserID, chat, "secret chat requested") + // 发起方同步收 encryptedChatWaiting(无 g_a)。 + return tgEncryptedChatForViewer(chat, adminID), nil +} + +func (r *Router) onMessagesAcceptEncryption(ctx context.Context, req *tg.MessagesAcceptEncryptionRequest) (tg.EncryptedChatClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.SecretChats == nil { + return nil, notImplementedErr() + } + userID, err := r.secretChatRequireUser(ctx) + if err != nil { + return nil, err + } + participantAuthKeyID, ok := businessAuthKeyIDFrom(ctx) + if !ok { + return nil, internalErr() + } + chat, err := r.deps.SecretChats.AcceptEncryption(ctx, req.Peer.ChatID, userID, + participantAuthKeyID, req.Peer.AccessHash, req.GB, req.KeyFingerprint) + if err != nil { + return nil, secretChatErr(err) + } + // 建链完成定向发起方绑定设备(device-level):离线发起方经 getDifference 补回成型态。 + r.recordEncryptionEventBestEffort(ctx, chat.ID, chat.AdminUserID, chat.AdminAuthKeyID, int(r.clock.Now().Unix())) + // 推发起方全部在线设备 encryptedChat(GAOrB=g_b, key_fingerprint),发起方据此 + // 算共享密钥并比对指纹。 + r.pushUpdateEncryption(ctx, chat.AdminUserID, chat, "secret chat accepted") + // 接受方同步收 encryptedChat(GAOrB=g_a)。 + return tgEncryptedChatForViewer(chat, userID), nil +} + +func (r *Router) onMessagesDiscardEncryption(ctx context.Context, req *tg.MessagesDiscardEncryptionRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + if r.deps.SecretChats == nil { + return false, notImplementedErr() + } + userID, err := r.secretChatRequireUser(ctx) + if err != nil { + return false, err + } + chat, already, err := r.deps.SecretChats.DiscardEncryption(ctx, req.ChatID, userID, req.DeleteHistory) + if err != nil { + return false, secretChatErr(err) + } + if !already { + // 推对端 encryptedChatDiscarded(history_deleted 决定对端是否删整个会话)。 + // 对端绑定设备已知则 device-level,建链前(未绑定)则账号级(同 requested 集合)。 + if peer := chat.PeerOf(userID); peer != 0 { + r.recordEncryptionEventBestEffort(ctx, chat.ID, peer, chat.PeerAuthKeyOf(userID), int(r.clock.Now().Unix())) + r.pushUpdateEncryption(ctx, peer, chat, "secret chat discarded") + } + } + return true, nil +} diff --git a/internal/rpc/encrypted_chats_test.go b/internal/rpc/encrypted_chats_test.go new file mode 100644 index 00000000..802a0113 --- /dev/null +++ b/internal/rpc/encrypted_chats_test.go @@ -0,0 +1,297 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appsecret "telesrv/internal/app/secretchat" + appupdates "telesrv/internal/app/updates" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// seqSecretChatIDAllocator 是单调自增的测试 chat id 分配器。 +type seqSecretChatIDAllocator struct{ n int } + +func (a *seqSecretChatIDAllocator) NextSecretChatID(context.Context) (int, error) { + a.n++ + return a.n, nil +} + +func (a *seqSecretChatIDAllocator) NextSecretChatIDAtLeast(_ context.Context, floor int) (int, error) { + if a.n < floor { + a.n = floor + } + a.n++ + return a.n, nil +} + +func (a *seqSecretChatIDAllocator) CurrentSecretChatID(context.Context) (int, error) { return a.n, nil } + +func dhParam(lead byte) []byte { + b := make([]byte, 256) + for i := range b { + b[i] = 0x42 + } + b[0] = lead + return b +} + +type encryptedFixture struct { + ctx context.Context + router *Router + sessions *phoneCaptureSessions + store *memory.SecretChatStore + queue *memory.EncryptedQueueStore + admin domain.User + participant domain.User +} + +const ( + encAdminSession = int64(301) + encPartSession = int64(302) +) + +var ( + encAdminAuthKey = [8]byte{1, 0, 0, 0, 0, 0, 0, 0} + encPartAuthKey = [8]byte{2, 0, 0, 0, 0, 0, 0, 0} +) + +func newEncryptedFixture(t *testing.T) *encryptedFixture { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + sessions := &phoneCaptureSessions{} + secretStore := memory.NewSecretChatStore() + queueStore := memory.NewEncryptedQueueStore() + router := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + SecretChats: appsecret.NewService(secretStore, queueStore, &seqSecretChatIDAllocator{}), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + Files: &fakeFiles{}, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + f := &encryptedFixture{ctx: ctx, router: router, sessions: sessions, store: secretStore, queue: queueStore} + mk := func(hash int64, phone, name string) domain.User { + u, err := userStore.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name}) + if err != nil { + t.Fatalf("create user %s: %v", name, err) + } + return u + } + f.admin = mk(5001, "13800000011", "Admin") + f.participant = mk(5002, "13800000012", "Participant") + return f +} + +func (f *encryptedFixture) adminCtx() context.Context { + return WithAuthKeyID(WithSessionID(WithUserID(f.ctx, f.admin.ID), encAdminSession), encAdminAuthKey) +} + +func (f *encryptedFixture) participantCtx() context.Context { + return WithAuthKeyID(WithSessionID(WithUserID(f.ctx, f.participant.ID), encPartSession), encPartAuthKey) +} + +// encChatPayload 从捕获的推送里取出 updateEncryption 载荷。 +func encChatPayload(t *testing.T, rec phonePushRecord) tg.EncryptedChatClass { + t.Helper() + updates, ok := rec.msg.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("pushed msg = %T, want single-update tg.Updates", rec.msg) + } + upd, ok := updates.Updates[0].(*tg.UpdateEncryption) + if !ok { + t.Fatalf("pushed update = %T, want UpdateEncryption", updates.Updates[0]) + } + return upd.Chat +} + +func TestEncryptedChatRPCHappyPath(t *testing.T) { + f := newEncryptedFixture(t) + ga := dhParam(0x55) + gb := dhParam(0x66) + + // --- requestEncryption(发起方) --- + res, err := f.router.onMessagesRequestEncryption(f.adminCtx(), &tg.MessagesRequestEncryptionRequest{ + UserID: &tg.InputUser{UserID: f.participant.ID, AccessHash: f.participant.AccessHash}, + RandomID: 777, + GA: ga, + }) + if err != nil { + t.Fatalf("requestEncryption: %v", err) + } + waiting, ok := res.(*tg.EncryptedChatWaiting) + if !ok { + t.Fatalf("request response = %T, want *tg.EncryptedChatWaiting", res) + } + // 推送给接受方的 encryptedChatRequested(携 g_a)。 + recs := f.sessions.records() + if len(recs) != 1 || recs[0].userID != f.participant.ID { + t.Fatalf("request push = %+v, want single push to participant %d", recs, f.participant.ID) + } + requested, ok := encChatPayload(t, recs[0]).(*tg.EncryptedChatRequested) + if !ok { + t.Fatalf("participant payload = %T, want EncryptedChatRequested", encChatPayload(t, recs[0])) + } + if requested.ID != waiting.ID { + t.Fatalf("chat id mismatch admin=%d participant=%d", waiting.ID, requested.ID) + } + if string(requested.GA) != string(ga) { + t.Fatal("requested g_a not relayed verbatim") + } + + chat, found, _ := f.store.GetSecretChat(f.ctx, waiting.ID) + if !found { + t.Fatal("chat not persisted") + } + + // --- acceptEncryption(接受方) --- + f.sessions.reset() + const fp = int64(0x0123456789abcdef) + accRes, err := f.router.onMessagesAcceptEncryption(f.participantCtx(), &tg.MessagesAcceptEncryptionRequest{ + Peer: tg.InputEncryptedChat{ChatID: chat.ID, AccessHash: chat.ParticipantAccessHash}, + GB: gb, + KeyFingerprint: fp, + }) + if err != nil { + t.Fatalf("acceptEncryption: %v", err) + } + // 接受方同步响应:encryptedChat,GAOrB = g_a。 + partView, ok := accRes.(*tg.EncryptedChat) + if !ok { + t.Fatalf("accept response = %T, want *tg.EncryptedChat", accRes) + } + if string(partView.GAOrB) != string(ga) { + t.Fatal("participant view GAOrB must be g_a") + } + if partView.KeyFingerprint != fp { + t.Fatalf("key fingerprint = %x, want %x", partView.KeyFingerprint, fp) + } + // 推送给发起方:encryptedChat,GAOrB = g_b。 + recs = f.sessions.records() + if len(recs) != 1 || recs[0].userID != f.admin.ID { + t.Fatalf("accept push = %+v, want single push to admin %d", recs, f.admin.ID) + } + adminView, ok := encChatPayload(t, recs[0]).(*tg.EncryptedChat) + if !ok { + t.Fatalf("admin payload = %T, want EncryptedChat", encChatPayload(t, recs[0])) + } + if string(adminView.GAOrB) != string(gb) { + t.Fatal("admin view GAOrB must be g_b") + } + if adminView.KeyFingerprint != fp { + t.Fatal("admin view key fingerprint not relayed byte-for-byte") + } + + // --- discardEncryption(发起方) --- + f.sessions.reset() + okRes, err := f.router.onMessagesDiscardEncryption(f.adminCtx(), &tg.MessagesDiscardEncryptionRequest{ + ChatID: chat.ID, + DeleteHistory: true, + }) + if err != nil || !okRes { + t.Fatalf("discardEncryption: ok=%v err=%v", okRes, err) + } + recs = f.sessions.records() + if len(recs) != 1 || recs[0].userID != f.participant.ID { + t.Fatalf("discard push = %+v, want single push to participant", recs) + } + discarded, ok := encChatPayload(t, recs[0]).(*tg.EncryptedChatDiscarded) + if !ok || !discarded.HistoryDeleted { + t.Fatalf("discard payload = %+v, want EncryptedChatDiscarded{HistoryDeleted:true}", encChatPayload(t, recs[0])) + } +} + +// TestDiscardSecretChatsForAuthKeyOnLogout 回归 P1:设备登出/授权撤销销毁其 perm auth_key 后, +// 必须级联 discard 该设备绑定的活跃密聊并向对端推送 encryptedChatDiscarded。修复前 onAuthLogOut +// 不处理密聊,对端继续往死 auth_key 投递成静默死链(消息 acked=f / qts 永久积压)。 +func TestDiscardSecretChatsForAuthKeyOnLogout(t *testing.T) { + f := newEncryptedFixture(t) + ga := dhParam(0x55) + gb := dhParam(0x66) + + // 建链到 normal:admin 发起 + participant 接受。 + res, err := f.router.onMessagesRequestEncryption(f.adminCtx(), &tg.MessagesRequestEncryptionRequest{ + UserID: &tg.InputUser{UserID: f.participant.ID, AccessHash: f.participant.AccessHash}, + RandomID: 11, + GA: ga, + }) + if err != nil { + t.Fatalf("request: %v", err) + } + chatID := res.(*tg.EncryptedChatWaiting).ID + chat, _, _ := f.store.GetSecretChat(f.ctx, chatID) + if _, err := f.router.onMessagesAcceptEncryption(f.participantCtx(), &tg.MessagesAcceptEncryptionRequest{ + Peer: tg.InputEncryptedChat{ChatID: chat.ID, AccessHash: chat.ParticipantAccessHash}, + GB: gb, + KeyFingerprint: 0x1234, + }); err != nil { + t.Fatalf("accept: %v", err) + } + if chat, _, _ := f.store.GetSecretChat(f.ctx, chatID); chat.State != domain.SecretChatStateNormal { + t.Fatalf("pre-logout state=%s, want normal", chat.State) + } + + // 模拟 participant 设备登出:级联 discard 其 perm auth_key 绑定的密聊。 + f.sessions.reset() + bobAuthKey := businessAuthKeyInt64(encPartAuthKey) + f.router.discardSecretChatsForAuthKey(f.ctx, bobAuthKey, f.participant.ID) + + // [1] 密聊已迁移到 discarded(不再是 normal 死链)。 + if chat, _, _ := f.store.GetSecretChat(f.ctx, chatID); chat.State != domain.SecretChatStateDiscarded { + t.Fatalf("post-logout state=%s, want discarded", chat.State) + } + // [2] 对端 admin 收到单条 encryptedChatDiscarded 在线推送。 + recs := f.sessions.records() + if len(recs) != 1 || recs[0].userID != f.admin.ID { + t.Fatalf("discard push = %+v, want single push to admin %d", recs, f.admin.ID) + } + if _, ok := encChatPayload(t, recs[0]).(*tg.EncryptedChatDiscarded); !ok { + t.Fatalf("peer payload = %T, want EncryptedChatDiscarded", encChatPayload(t, recs[0])) + } + // [3] durable 离线补偿事件写给对端设备(getDifference 兜底)。 + if events, err := f.queue.ListUndeliveredStateEvents(f.ctx, f.admin.ID, businessAuthKeyInt64(encAdminAuthKey), 10); err != nil || len(events) == 0 { + t.Fatalf("durable discard event for admin = %d (err=%v), want >=1", len(events), err) + } + + // [4] 幂等:再次对同 auth_key 登出不再 discard、不再推送(已是终态)。 + f.sessions.reset() + f.router.discardSecretChatsForAuthKey(f.ctx, bobAuthKey, f.participant.ID) + if recs := f.sessions.records(); len(recs) != 0 { + t.Fatalf("idempotent re-logout pushed %d updates, want 0", len(recs)) + } +} + +func TestRequestEncryptionSelf(t *testing.T) { + f := newEncryptedFixture(t) + _, err := f.router.onMessagesRequestEncryption(f.adminCtx(), &tg.MessagesRequestEncryptionRequest{ + UserID: &tg.InputUser{UserID: f.admin.ID, AccessHash: f.admin.AccessHash}, + RandomID: 1, + GA: dhParam(0x55), + }) + assertPhoneRPCErr(t, err, "USER_ID_INVALID") +} + +func TestAcceptEncryptionWrongAccessHashRPC(t *testing.T) { + f := newEncryptedFixture(t) + res, err := f.router.onMessagesRequestEncryption(f.adminCtx(), &tg.MessagesRequestEncryptionRequest{ + UserID: &tg.InputUser{UserID: f.participant.ID, AccessHash: f.participant.AccessHash}, + RandomID: 9, + GA: dhParam(0x55), + }) + if err != nil { + t.Fatalf("request: %v", err) + } + chatID := res.(*tg.EncryptedChatWaiting).ID + _, err = f.router.onMessagesAcceptEncryption(f.participantCtx(), &tg.MessagesAcceptEncryptionRequest{ + Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: 999999}, + GB: dhParam(0x66), + KeyFingerprint: 1, + }) + assertPhoneRPCErr(t, err, "CHAT_ID_INVALID") +} diff --git a/internal/rpc/encrypted_files.go b/internal/rpc/encrypted_files.go new file mode 100644 index 00000000..f9543ea3 --- /dev/null +++ b/internal/rpc/encrypted_files.go @@ -0,0 +1,112 @@ +package rpc + +import ( + "context" + + "go.uber.org/zap" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 私聊密聊文件收发 RPC handler(P2)。盲中继:文件内容是客户端加密的 bytes,服务端 +// 组装上传分片成 blob(location_key "enc:")、铸造 EncryptedFile 快照、原样转发, +// 永不解密。下载经 upload.getFile + inputEncryptedFileLocation 复用同 blob 链路。 +// 设计见 docs/secret-chat-module.md §8。 + +// resolveInputEncryptedFile 把 InputEncryptedFile 解析为服务端文件快照: +// - Empty → nil(无文件)。 +// - Uploaded / BigUploaded → 组装上传分片铸造新 EncryptedFile 并持久化元数据。 +// - InputEncryptedFile{id, access_hash} → 按 id 回查既有快照(resend 复用路径)。 +func (r *Router) resolveInputEncryptedFile(ctx context.Context, ownerUserID int64, input tg.InputEncryptedFileClass) (*domain.EncryptedFileRef, error) { + switch f := input.(type) { + case *tg.InputEncryptedFileEmpty, nil: + return nil, nil + case *tg.InputEncryptedFileUploaded: + return r.mintEncryptedFile(ctx, ownerUserID, domain.UploadedFileRef{ + OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Big: false, + }, f.KeyFingerprint) + case *tg.InputEncryptedFileBigUploaded: + return r.mintEncryptedFile(ctx, ownerUserID, domain.UploadedFileRef{ + OwnerUserID: ownerUserID, FileID: f.ID, Parts: f.Parts, Big: true, + }, f.KeyFingerprint) + case *tg.InputEncryptedFile: + ref, found, err := r.deps.SecretChats.GetEncryptedFile(ctx, f.ID, f.AccessHash) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, fileEmptyErr() + } + return &ref, nil + default: + return nil, fileEmptyErr() + } +} + +// mintEncryptedFile 组装上传分片铸造 EncryptedFile 并持久化元数据快照。 +func (r *Router) mintEncryptedFile(ctx context.Context, ownerUserID int64, upload domain.UploadedFileRef, keyFingerprint int) (*domain.EncryptedFileRef, error) { + if r.deps.Files == nil { + return nil, notImplementedErr() + } + ref, err := r.deps.Files.CreateEncryptedFileFromUpload(ctx, upload, keyFingerprint) + if err != nil { + return nil, fileEmptyErr() + } + if err := r.deps.SecretChats.PutEncryptedFile(ctx, ownerUserID, ref); err != nil { + r.log.Debug("put encrypted file metadata", zap.Error(err)) + } + return &ref, nil +} + +func (r *Router) onMessagesSendEncryptedFile(ctx context.Context, req *tg.MessagesSendEncryptedFileRequest) (tg.MessagesSentEncryptedMessageClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.SecretChats == nil { + return nil, notImplementedErr() + } + userID, err := r.secretChatRequireUser(ctx) + if err != nil { + return nil, err + } + fileRef, err := r.resolveInputEncryptedFile(ctx, userID, req.File) + if err != nil { + return nil, err + } + _, stored, err := r.deps.SecretChats.SendEncrypted(ctx, req.Peer.ChatID, userID, req.Peer.AccessHash, domain.SecretMessageDelivery{ + RandomID: req.RandomID, + Bytes: req.Data, + IsService: false, + File: fileRef, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, secretChatErr(err) + } + r.pushEncryptedNewMessage(ctx, stored) + return &tg.MessagesSentEncryptedFile{Date: stored.Date, File: tgEncryptedFile(stored.File)}, nil +} + +func (r *Router) onMessagesUploadEncryptedFile(ctx context.Context, req *tg.MessagesUploadEncryptedFileRequest) (tg.EncryptedFileClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.SecretChats == nil { + return nil, notImplementedErr() + } + userID, err := r.secretChatRequireUser(ctx) + if err != nil { + return nil, err + } + // 校验调用方是该密聊参与者(access_hash 匹配)。 + if _, _, _, err := r.resolveSecretChatPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + fileRef, err := r.resolveInputEncryptedFile(ctx, userID, req.File) + if err != nil { + return nil, err + } + return tgEncryptedFile(fileRef), nil +} diff --git a/internal/rpc/encrypted_files_test.go b/internal/rpc/encrypted_files_test.go new file mode 100644 index 00000000..78fa5a43 --- /dev/null +++ b/internal/rpc/encrypted_files_test.go @@ -0,0 +1,83 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" +) + +// TestSendEncryptedFileFlow:sendEncryptedFile 铸造 EncryptedFile、随消息投递、返回 +// SentEncryptedFile{date,file},且 InputEncryptedFile 复用路径能回查同一文件。 +func TestSendEncryptedFileFlow(t *testing.T) { + f := newEncryptedFixture(t) + chatID, _ := f.acceptChat(t) + chat, _, _ := f.store.GetSecretChat(f.ctx, chatID) + f.sessions.reset() + + sent, err := f.router.onMessagesSendEncryptedFile(f.adminCtx(), &tg.MessagesSendEncryptedFileRequest{ + Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.AdminAccessHash}, + RandomID: 71717, + Data: []byte{0x01, 0x02}, + File: &tg.InputEncryptedFileUploaded{ID: 555, Parts: 1, KeyFingerprint: 7}, + }) + if err != nil { + t.Fatalf("sendEncryptedFile: %v", err) + } + sf, ok := sent.(*tg.MessagesSentEncryptedFile) + if !ok || sf.Date == 0 { + t.Fatalf("response = %T %+v, want SentEncryptedFile{date,file}", sent, sent) + } + ef, ok := sf.File.(*tg.EncryptedFile) + if !ok || ef.ID == 0 { + t.Fatalf("response file = %T, want non-empty EncryptedFile", sf.File) + } + + // 推送给 participant 的 updateNewEncryptedMessage 携带 file。 + recs := f.sessions.records() + if len(recs) != 1 { + t.Fatalf("send push = %d, want 1", len(recs)) + } + em, ok := encNewMessagePayload(t, recs[0]).Message.(*tg.EncryptedMessage) + if !ok { + t.Fatalf("pushed message = %T, want EncryptedMessage", encNewMessagePayload(t, recs[0]).Message) + } + if _, ok := em.File.(*tg.EncryptedFile); !ok { + t.Fatalf("pushed message file = %T, want EncryptedFile", em.File) + } + + // InputEncryptedFile 复用:按 id+access_hash 回查到同一文件。 + ref, found, err := f.router.deps.SecretChats.GetEncryptedFile(f.ctx, ef.ID, ef.AccessHash) + if err != nil || !found || ref.ID != ef.ID { + t.Fatalf("GetEncryptedFile reuse = found %v id %d err %v", found, ref.ID, err) + } +} + +// TestUploadEncryptedFile:uploadEncryptedFile 铸造并返回 EncryptedFile。 +func TestUploadEncryptedFile(t *testing.T) { + f := newEncryptedFixture(t) + chatID, _ := f.acceptChat(t) + chat, _, _ := f.store.GetSecretChat(f.ctx, chatID) + + res, err := f.router.onMessagesUploadEncryptedFile(f.adminCtx(), &tg.MessagesUploadEncryptedFileRequest{ + Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.AdminAccessHash}, + File: &tg.InputEncryptedFileUploaded{ID: 888, Parts: 1, KeyFingerprint: 9}, + }) + if err != nil { + t.Fatalf("uploadEncryptedFile: %v", err) + } + ef, ok := res.(*tg.EncryptedFile) + if !ok || ef.ID == 0 { + t.Fatalf("upload response = %T, want non-empty EncryptedFile", res) + } +} + +// TestEncryptedFileLocationKey:inputEncryptedFileLocation → "enc:" 下载 key。 +func TestEncryptedFileLocationKey(t *testing.T) { + key, ok := fileLocationKey(&tg.InputEncryptedFileLocation{ID: 123, AccessHash: 456}) + if !ok || key != "enc:123" { + t.Fatalf("location key = %q ok %v, want enc:123", key, ok) + } + if _, ok := fileLocationKey(&tg.InputEncryptedFileLocation{ID: 0}); ok { + t.Fatal("id=0 must be rejected") + } +} diff --git a/internal/rpc/encrypted_messages.go b/internal/rpc/encrypted_messages.go new file mode 100644 index 00000000..e1fc6736 --- /dev/null +++ b/internal/rpc/encrypted_messages.go @@ -0,0 +1,213 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 私聊密聊 qts 消息收发 RPC handler(P1)。服务端是盲中继:sendEncrypted* 的 bytes 是 +// 客户端加密的 DecryptedMessage,服务端盲存进【接收方设备】的 qts 队列、原样转发, +// 永不解密。在线推 updateNewEncryptedMessage(设备定向,离线靠 getDifference 补回)。 +// 设计见 docs/secret-chat-module.md §7/§8。 + +// pushEncryptedNewMessage 把 updateNewEncryptedMessage 定向投递给接收设备。 +func (r *Router) pushEncryptedNewMessage(ctx context.Context, msg domain.SecretChatMessage) { + if msg.ReceiverUserID == 0 || msg.ReceiverAuthKeyID == 0 { + return + } + now := int(r.clock.Now().Unix()) + upd := &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateNewEncryptedMessage{ + Message: tgEncryptedMessage(msg), + Qts: msg.Qts, + }}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: now, + Seq: 0, + } + if targeted, ok := r.deps.Sessions.(AuthKeyTargetedSessionBinder); ok { + _, _ = targeted.PushToUserAuthKey(ctx, msg.ReceiverUserID, deviceAuthKeyBytes(msg.ReceiverAuthKeyID), proto.MessageFromServer, upd) + return + } + // 回退(测试替身/未装配定向能力):账号级推送。 + r.pushUserMessage(ctx, msg.ReceiverUserID, "secret chat message", upd) +} + +func (r *Router) sendEncryptedCommon(ctx context.Context, peer tg.InputEncryptedChat, randomID int64, data []byte, isService bool) (tg.MessagesSentEncryptedMessageClass, error) { + if r.deps.SecretChats == nil { + return nil, notImplementedErr() + } + userID, err := r.secretChatRequireUser(ctx) + if err != nil { + return nil, err + } + _, stored, err := r.deps.SecretChats.SendEncrypted(ctx, peer.ChatID, userID, peer.AccessHash, domain.SecretMessageDelivery{ + RandomID: randomID, + Bytes: data, + IsService: isService, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, secretChatErr(err) + } + r.pushEncryptedNewMessage(ctx, stored) + // 无 server message id;幂等重发返回首次落库 date(store dedup 保证)。 + return &tg.MessagesSentEncryptedMessage{Date: stored.Date}, nil +} + +func (r *Router) onMessagesSendEncrypted(ctx context.Context, req *tg.MessagesSendEncryptedRequest) (tg.MessagesSentEncryptedMessageClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + return r.sendEncryptedCommon(ctx, req.Peer, req.RandomID, req.Data, false) +} + +func (r *Router) onMessagesSendEncryptedService(ctx context.Context, req *tg.MessagesSendEncryptedServiceRequest) (tg.MessagesSentEncryptedMessageClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + return r.sendEncryptedCommon(ctx, req.Peer, req.RandomID, req.Data, true) +} + +// onMessagesReceivedQueue 确认接收设备已处理到 max_qts(推进 confirmed + 标 acked 可 GC)。 +// 返回空 Vector:DrKLO 调用处忽略响应(sendRequest(req, null)),空集安全。 +func (r *Router) onMessagesReceivedQueue(ctx context.Context, maxQts int) ([]int64, error) { + if r.deps.SecretChats == nil { + return nil, notImplementedErr() + } + if _, err := r.secretChatRequireUser(ctx); err != nil { + return nil, err + } + deviceKey, ok := businessAuthKeyIDFrom(ctx) + if !ok { + return nil, internalErr() + } + if err := r.deps.SecretChats.AckQueue(ctx, deviceKey, maxQts); err != nil { + return nil, internalErr() + } + return []int64{}, nil +} + +// onMessagesReportEncryptedSpam 纯记录:服务端不自动 discard、不拉黑、不产生 update +// (discard/block 由客户端独立 RPC 完成)。P1 接受并回 true。 +func (r *Router) onMessagesReportEncryptedSpam(ctx context.Context, _ tg.InputEncryptedChat) (bool, error) { + if _, err := r.secretChatRequireUser(ctx); err != nil { + return false, err + } + return true, nil +} + +// deviceEncryptedQts 返回当前设备已分配的最高 qts(getState 注入用,无则 0)。 +func (r *Router) deviceEncryptedQts(ctx context.Context) int { + if r.deps.SecretChats == nil { + return 0 + } + deviceKey, ok := businessAuthKeyIDFrom(ctx) + if !ok { + return 0 + } + qts, err := r.deps.SecretChats.DeviceReservedQts(ctx, deviceKey) + if err != nil { + return 0 + } + return qts +} + +// encryptedDifference 返回当前设备 qts > sinceQts 的加密消息(TL 投影)与推进后的 qts +// (getDifference 注入用)。无新消息时返回 (nil, sinceQts)。 +func (r *Router) encryptedDifference(ctx context.Context, sinceQts int) ([]tg.EncryptedMessageClass, int) { + if r.deps.SecretChats == nil { + return nil, sinceQts + } + deviceKey, ok := businessAuthKeyIDFrom(ctx) + if !ok { + return nil, sinceQts + } + msgs, err := r.deps.SecretChats.ListNewMessages(ctx, deviceKey, sinceQts, 0) + if err != nil || len(msgs) == 0 { + return nil, sinceQts + } + out := make([]tg.EncryptedMessageClass, 0, len(msgs)) + newQts := sinceQts + for _, m := range msgs { + out = append(out, tgEncryptedMessage(m)) + newQts = m.Qts + } + return out, newQts +} + +// injectEncryptedMessages 把加密消息与推进后的 qts 注入差分响应(按类型分别写 State / +// IntermediateState 的 Qts)。 +func injectEncryptedMessages(diff tg.UpdatesDifferenceClass, encMsgs []tg.EncryptedMessageClass, newQts int) tg.UpdatesDifferenceClass { + switch v := diff.(type) { + case *tg.UpdatesDifference: + v.NewEncryptedMessages = append(v.NewEncryptedMessages, encMsgs...) + v.State.Qts = newQts + case *tg.UpdatesDifferenceSlice: + v.NewEncryptedMessages = append(v.NewEncryptedMessages, encMsgs...) + v.IntermediateState.Qts = newQts + } + return diff +} + +// encryptedStateUpdates 返回当前设备未投递的握手/已读状态事件重建出的 update(进 +// OtherUpdates)、涉及的 peer user id(补 Users)、以及要登记已投递的事件 id。 +// encryption 事件按 secret_chats 权威态重建(不固化快照)。 +func (r *Router) encryptedStateUpdates(ctx context.Context, userID int64) (updates []tg.UpdateClass, peerUserIDs []int64, eventIDs []int64) { + if r.deps.SecretChats == nil { + return nil, nil, nil + } + deviceKey, ok := businessAuthKeyIDFrom(ctx) + if !ok { + return nil, nil, nil + } + events, err := r.deps.SecretChats.ListStateEvents(ctx, userID, deviceKey, 0) + if err != nil || len(events) == 0 { + return nil, nil, nil + } + for _, ev := range events { + eventIDs = append(eventIDs, ev.ID) + switch ev.Type { + case domain.EncryptedStateEventEncryption: + chat, found, gerr := r.deps.SecretChats.GetSecretChat(ctx, ev.ChatID) + if gerr != nil || !found { + continue + } + updates = append(updates, &tg.UpdateEncryption{ + Chat: tgEncryptedChatForViewer(chat, userID), + Date: ev.Date, + }) + peerUserIDs = append(peerUserIDs, chat.AdminUserID, chat.ParticipantUserID) + case domain.EncryptedStateEventRead: + updates = append(updates, &tg.UpdateEncryptedMessagesRead{ + ChatID: ev.ChatID, + MaxDate: ev.MaxDate, + Date: ev.Date, + }) + } + } + return updates, peerUserIDs, eventIDs +} + +// injectEncryptedOtherUpdates 把握手/已读 update 追加进差分的 OtherUpdates、把 peer +// user 对象追加进 Users。 +func (r *Router) injectEncryptedOtherUpdates(ctx context.Context, viewerUserID int64, diff tg.UpdatesDifferenceClass, updates []tg.UpdateClass, peerUserIDs []int64) tg.UpdatesDifferenceClass { + if len(updates) == 0 { + return diff + } + users := r.tgUsersForIDs(ctx, viewerUserID, peerUserIDs) + switch v := diff.(type) { + case *tg.UpdatesDifference: + v.OtherUpdates = append(v.OtherUpdates, updates...) + v.Users = appendUniqueTGUsers(v.Users, users...) + case *tg.UpdatesDifferenceSlice: + v.OtherUpdates = append(v.OtherUpdates, updates...) + v.Users = appendUniqueTGUsers(v.Users, users...) + } + return diff +} diff --git a/internal/rpc/encrypted_messages_test.go b/internal/rpc/encrypted_messages_test.go new file mode 100644 index 00000000..fe3cac21 --- /dev/null +++ b/internal/rpc/encrypted_messages_test.go @@ -0,0 +1,271 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" +) + +// acceptChat 跑完 request→accept,返回 normal 态密聊 id 与 participant 视角 access_hash。 +func (f *encryptedFixture) acceptChat(t *testing.T) (chatID int, partAccessHash int64) { + t.Helper() + res, err := f.router.onMessagesRequestEncryption(f.adminCtx(), &tg.MessagesRequestEncryptionRequest{ + UserID: &tg.InputUser{UserID: f.participant.ID, AccessHash: f.participant.AccessHash}, + RandomID: 4242, + GA: dhParam(0x55), + }) + if err != nil { + t.Fatalf("request: %v", err) + } + chatID = res.(*tg.EncryptedChatWaiting).ID + chat, _, _ := f.store.GetSecretChat(f.ctx, chatID) + if _, err := f.router.onMessagesAcceptEncryption(f.participantCtx(), &tg.MessagesAcceptEncryptionRequest{ + Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.ParticipantAccessHash}, + GB: dhParam(0x66), + KeyFingerprint: 7, + }); err != nil { + t.Fatalf("accept: %v", err) + } + return chatID, chat.ParticipantAccessHash +} + +func encNewMessagePayload(t *testing.T, rec phonePushRecord) *tg.UpdateNewEncryptedMessage { + t.Helper() + updates, ok := rec.msg.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("pushed msg = %T, want single-update tg.Updates", rec.msg) + } + upd, ok := updates.Updates[0].(*tg.UpdateNewEncryptedMessage) + if !ok { + t.Fatalf("pushed update = %T, want UpdateNewEncryptedMessage", updates.Updates[0]) + } + return upd +} + +func TestSendEncryptedRPCFlow(t *testing.T) { + f := newEncryptedFixture(t) + chatID, _ := f.acceptChat(t) + chat, _, _ := f.store.GetSecretChat(f.ctx, chatID) + f.sessions.reset() + + // admin 发加密消息 → 投给 participant 设备。 + data := []byte{0xDE, 0xAD, 0xBE, 0xEF} + sent, err := f.router.onMessagesSendEncrypted(f.adminCtx(), &tg.MessagesSendEncryptedRequest{ + Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.AdminAccessHash}, + RandomID: 88888, + Data: data, + }) + if err != nil { + t.Fatalf("sendEncrypted: %v", err) + } + sentMsg, ok := sent.(*tg.MessagesSentEncryptedMessage) + if !ok || sentMsg.Date == 0 { + t.Fatalf("send response = %T %+v, want SentEncryptedMessage{date}", sent, sent) + } + recs := f.sessions.records() + if len(recs) != 1 || recs[0].userID != f.participant.ID { + t.Fatalf("send push = %+v, want single push to participant", recs) + } + upd := encNewMessagePayload(t, recs[0]) + if upd.Qts != 1 { + t.Fatalf("pushed qts = %d, want 1", upd.Qts) + } + em, ok := upd.Message.(*tg.EncryptedMessage) + if !ok || string(em.Bytes) != string(data) || em.ChatID != chatID { + t.Fatalf("pushed message = %+v, want EncryptedMessage bytes verbatim", upd.Message) + } + + // participant getState:设备 qts = 1。 + st, err := f.router.onUpdatesGetState(f.participantCtx()) + if err != nil { + t.Fatalf("getState: %v", err) + } + if st.Qts != 1 { + t.Fatalf("participant getState qts = %d, want 1", st.Qts) + } + + // participant 离线补差分:从 qts=0 拿回该加密消息。 + diff, err := f.router.onUpdatesGetDifference(f.participantCtx(), &tg.UpdatesGetDifferenceRequest{Qts: 0}) + if err != nil { + t.Fatalf("getDifference: %v", err) + } + full, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", diff) + } + if len(full.NewEncryptedMessages) != 1 || full.State.Qts != 1 { + t.Fatalf("difference enc msgs = %d state.qts = %d, want 1/1", len(full.NewEncryptedMessages), full.State.Qts) + } + gotEM, ok := full.NewEncryptedMessages[0].(*tg.EncryptedMessage) + if !ok || string(gotEM.Bytes) != string(data) { + t.Fatalf("difference message = %+v, want bytes verbatim", full.NewEncryptedMessages[0]) + } + + // receivedQueue:确认到 qts=1,返回空 Vector。 + rq, err := f.router.onMessagesReceivedQueue(f.participantCtx(), 1) + if err != nil { + t.Fatalf("receivedQueue: %v", err) + } + if len(rq) != 0 { + t.Fatalf("receivedQueue = %v, want empty vector", rq) + } + + // 确认后再补差分(qts=1):已无新消息 → DifferenceEmpty。 + diff2, err := f.router.onUpdatesGetDifference(f.participantCtx(), &tg.UpdatesGetDifferenceRequest{Qts: 1}) + if err != nil { + t.Fatalf("getDifference 2: %v", err) + } + if _, ok := diff2.(*tg.UpdatesDifferenceEmpty); !ok { + t.Fatalf("difference after ack = %T, want UpdatesDifferenceEmpty", diff2) + } + + // 幂等重发同 random_id → 返回首次 date,不产生新 qts。 + f.sessions.reset() + sent2, err := f.router.onMessagesSendEncrypted(f.adminCtx(), &tg.MessagesSendEncryptedRequest{ + Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.AdminAccessHash}, + RandomID: 88888, + Data: data, + }) + if err != nil { + t.Fatalf("idempotent resend: %v", err) + } + if sent2.(*tg.MessagesSentEncryptedMessage).Date != sentMsg.Date { + t.Fatalf("idempotent resend date = %d, want %d (首次落库 date)", sent2.(*tg.MessagesSentEncryptedMessage).Date, sentMsg.Date) + } +} + +func encOtherUpdate[T tg.UpdateClass](t *testing.T, diff tg.UpdatesDifferenceClass) T { + t.Helper() + full, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", diff) + } + for _, u := range full.OtherUpdates { + if got, ok := u.(T); ok { + return got + } + } + var zero T + t.Fatalf("OtherUpdates %+v missing %T", full.OtherUpdates, zero) + return zero +} + +// TestEncryptionStateEventOfflineDelivery:participant 在 requestEncryption 时离线, +// 重连 getDifference 经 durable 状态事件补回 updateEncryption(requested),且只补一次。 +func TestEncryptionStateEventOfflineDelivery(t *testing.T) { + f := newEncryptedFixture(t) + res, err := f.router.onMessagesRequestEncryption(f.adminCtx(), &tg.MessagesRequestEncryptionRequest{ + UserID: &tg.InputUser{UserID: f.participant.ID, AccessHash: f.participant.AccessHash}, + RandomID: 5151, + GA: dhParam(0x55), + }) + if err != nil { + t.Fatalf("request: %v", err) + } + chatID := res.(*tg.EncryptedChatWaiting).ID + + // participant 离线补差分:拿到 updateEncryption(encryptedChatRequested, 携 g_a)。 + diff, err := f.router.onUpdatesGetDifference(f.participantCtx(), &tg.UpdatesGetDifferenceRequest{Qts: 0}) + if err != nil { + t.Fatalf("getDifference: %v", err) + } + upd := encOtherUpdate[*tg.UpdateEncryption](t, diff) + requested, ok := upd.Chat.(*tg.EncryptedChatRequested) + if !ok || requested.ID != chatID || len(requested.GA) == 0 { + t.Fatalf("offline handshake update = %+v, want EncryptedChatRequested with g_a", upd.Chat) + } + + // 再次补差分:已投递 → 不重复(DifferenceEmpty)。 + diff2, err := f.router.onUpdatesGetDifference(f.participantCtx(), &tg.UpdatesGetDifferenceRequest{Qts: 0}) + if err != nil { + t.Fatalf("getDifference 2: %v", err) + } + if _, ok := diff2.(*tg.UpdatesDifferenceEmpty); !ok { + t.Fatalf("redelivery: difference = %T, want UpdatesDifferenceEmpty", diff2) + } +} + +// TestDiscardStateEventOfflineDelivery:admin 在 participant 未接受时 discard(账号级), +// participant 离线设备 getDifference 补回 encryptedChatDiscarded。 +func TestDiscardStateEventOfflineDelivery(t *testing.T) { + f := newEncryptedFixture(t) + res, err := f.router.onMessagesRequestEncryption(f.adminCtx(), &tg.MessagesRequestEncryptionRequest{ + UserID: &tg.InputUser{UserID: f.participant.ID, AccessHash: f.participant.AccessHash}, + RandomID: 6262, + GA: dhParam(0x55), + }) + if err != nil { + t.Fatalf("request: %v", err) + } + chatID := res.(*tg.EncryptedChatWaiting).ID + // admin 撤回邀请。 + if _, err := f.router.onMessagesDiscardEncryption(f.adminCtx(), &tg.MessagesDiscardEncryptionRequest{ChatID: chatID, DeleteHistory: false}); err != nil { + t.Fatalf("discard: %v", err) + } + // participant 离线补差分:现态是 discarded,重建出 encryptedChatDiscarded。 + diff, err := f.router.onUpdatesGetDifference(f.participantCtx(), &tg.UpdatesGetDifferenceRequest{Qts: 0}) + if err != nil { + t.Fatalf("getDifference: %v", err) + } + upd := encOtherUpdate[*tg.UpdateEncryption](t, diff) + if _, ok := upd.Chat.(*tg.EncryptedChatDiscarded); !ok { + t.Fatalf("offline discard update = %T, want EncryptedChatDiscarded", upd.Chat) + } +} + +func TestReadEncryptedHistoryPushesPeer(t *testing.T) { + f := newEncryptedFixture(t) + chatID, _ := f.acceptChat(t) + chat, _, _ := f.store.GetSecretChat(f.ctx, chatID) + f.sessions.reset() + + ok, err := f.router.onMessagesReadEncryptedHistory(f.adminCtx(), &tg.MessagesReadEncryptedHistoryRequest{ + Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.AdminAccessHash}, + MaxDate: 1500, + }) + if err != nil || !ok { + t.Fatalf("readEncryptedHistory: ok=%v err=%v", ok, err) + } + recs := f.sessions.records() + if len(recs) != 1 || recs[0].userID != f.participant.ID { + t.Fatalf("read push = %+v, want single push to participant", recs) + } + updates := recs[0].msg.(*tg.Updates) + rd, ok := updates.Updates[0].(*tg.UpdateEncryptedMessagesRead) + if !ok || rd.ChatID != chatID || rd.MaxDate != 1500 { + t.Fatalf("read update = %+v, want UpdateEncryptedMessagesRead{chat,max_date=1500}", updates.Updates[0]) + } +} + +func TestReadEncryptedHistoryInvalidMaxDate(t *testing.T) { + f := newEncryptedFixture(t) + chatID, _ := f.acceptChat(t) + chat, _, _ := f.store.GetSecretChat(f.ctx, chatID) + _, err := f.router.onMessagesReadEncryptedHistory(f.adminCtx(), &tg.MessagesReadEncryptedHistoryRequest{ + Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.AdminAccessHash}, + MaxDate: 0, + }) + assertPhoneRPCErr(t, err, "MAX_DATE_INVALID") +} + +func TestSetEncryptedTypingPushesPeer(t *testing.T) { + f := newEncryptedFixture(t) + chatID, _ := f.acceptChat(t) + chat, _, _ := f.store.GetSecretChat(f.ctx, chatID) + f.sessions.reset() + + ok, err := f.router.onMessagesSetEncryptedTyping(f.adminCtx(), &tg.MessagesSetEncryptedTypingRequest{ + Peer: tg.InputEncryptedChat{ChatID: chatID, AccessHash: chat.AdminAccessHash}, + Typing: true, + }) + if err != nil || !ok { + t.Fatalf("setEncryptedTyping: ok=%v err=%v", ok, err) + } + recs := f.sessions.records() + if len(recs) != 1 || recs[0].userID != f.participant.ID { + t.Fatalf("typing push = %+v, want single push to participant", recs) + } + if _, ok := recs[0].msg.(*tg.Updates).Updates[0].(*tg.UpdateEncryptedChatTyping); !ok { + t.Fatalf("typing update = %T, want UpdateEncryptedChatTyping", recs[0].msg.(*tg.Updates).Updates[0]) + } +} diff --git a/internal/rpc/encrypted_read_typing.go b/internal/rpc/encrypted_read_typing.go new file mode 100644 index 00000000..f5ee83de --- /dev/null +++ b/internal/rpc/encrypted_read_typing.go @@ -0,0 +1,113 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// 私聊密聊已读回执与 typing(P1)。已读/ typing 都是定向投给【对端绑定设备】的 update。 +// P1:在线推送(已读 durable 离线补偿见后续 encrypted_state_events)。typing 是 transient。 +// 设计见 docs/secret-chat-module.md §8。 + +// resolveSecretChatPeer 校验调用方是密聊参与者且 access_hash 匹配,返回密聊、对端 user、 +// 对端绑定设备 auth_key。失败返回 CHAT_ID_INVALID。 +func (r *Router) resolveSecretChatPeer(ctx context.Context, userID int64, peer tg.InputEncryptedChat) (domain.SecretChat, int64, int64, error) { + chat, ok, err := r.deps.SecretChats.GetSecretChat(ctx, peer.ChatID) + if err != nil { + return domain.SecretChat{}, 0, 0, internalErr() + } + if !ok || !chat.HasParticipant(userID) || chat.AccessHashFor(userID) != peer.AccessHash { + return domain.SecretChat{}, 0, 0, chatIDInvalidErr() + } + return chat, chat.PeerOf(userID), chat.PeerAuthKeyOf(userID), nil +} + +// pushEncryptedPeerUpdate 把 update 定向投给对端绑定设备(transient=true 走 best-effort)。 +func (r *Router) pushEncryptedPeerUpdate(ctx context.Context, peerUserID, peerAuthKeyID int64, update tg.UpdateClass, transient bool, logMessage string) { + if peerUserID == 0 || peerAuthKeyID == 0 { + return + } + now := int(r.clock.Now().Unix()) + upd := &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: now, + Seq: 0, + } + if targeted, ok := r.deps.Sessions.(AuthKeyTargetedSessionBinder); ok { + devKey := deviceAuthKeyBytes(peerAuthKeyID) + if transient { + _, _ = targeted.PushToUserAuthKeyTransient(ctx, peerUserID, devKey, proto.MessageFromServer, upd, r.cfg.OutboundPushTimeout) + } else { + _, _ = targeted.PushToUserAuthKey(ctx, peerUserID, devKey, proto.MessageFromServer, upd) + } + return + } + if transient { + r.pushUserMessageTransient(ctx, peerUserID, logMessage, upd) + } else { + r.pushUserMessage(ctx, peerUserID, logMessage, upd) + } +} + +func (r *Router) onMessagesReadEncryptedHistory(ctx context.Context, req *tg.MessagesReadEncryptedHistoryRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + if r.deps.SecretChats == nil { + return false, notImplementedErr() + } + userID, err := r.secretChatRequireUser(ctx) + if err != nil { + return false, err + } + if req.MaxDate <= 0 || req.MaxDate > int(r.clock.Now().Unix())+1 { + return false, maxDateInvalidErr() + } + chat, peerUserID, peerAuthKeyID, err := r.resolveSecretChatPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + now := int(r.clock.Now().Unix()) + // durable(设备定向)供离线对端 getDifference 补回已读回执。 + if err := r.deps.SecretChats.RecordReadEvent(ctx, chat.ID, peerUserID, peerAuthKeyID, req.MaxDate, now); err != nil { + r.log.Debug("record read state event", zap.Error(err)) + } + // 仅投对端绑定设备(发起方靠本地置 read_outbox)。 + r.pushEncryptedPeerUpdate(ctx, peerUserID, peerAuthKeyID, &tg.UpdateEncryptedMessagesRead{ + ChatID: chat.ID, + MaxDate: req.MaxDate, + Date: now, + }, false, "secret chat read") + return true, nil +} + +func (r *Router) onMessagesSetEncryptedTyping(ctx context.Context, req *tg.MessagesSetEncryptedTypingRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + if r.deps.SecretChats == nil { + return false, notImplementedErr() + } + userID, err := r.secretChatRequireUser(ctx) + if err != nil { + return false, err + } + chat, peerUserID, peerAuthKeyID, err := r.resolveSecretChatPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + // typing 是 transient;停止 typing(Typing=false)无对应 update,自动过期。 + if req.Typing { + r.pushEncryptedPeerUpdate(ctx, peerUserID, peerAuthKeyID, &tg.UpdateEncryptedChatTyping{ + ChatID: chat.ID, + }, true, "secret chat typing") + } + return true, nil +} diff --git a/internal/rpc/encrypted_register.go b/internal/rpc/encrypted_register.go new file mode 100644 index 00000000..f4e151db --- /dev/null +++ b/internal/rpc/encrypted_register.go @@ -0,0 +1,30 @@ +package rpc + +import "github.com/gotd/td/tg" + +// registerEncrypted 注册私聊端对端加密(Secret Chat)域 RPC。 +// +// 归属约定:messages.getDhConfig 属通话域(DH 参数下发),由 registerPhone 注册、 +// 密聊复用,**本处绝不重复注册 OnMessagesGetDhConfig**(gotd ServerDispatcher 同一 +// RPC 重复 On* 是静默 last-wins,会覆盖 phone 域真实现)。 +// +// P0 落地握手三件套;sendEncrypted / sendEncryptedFile / sendEncryptedService / +// readEncryptedHistory / setEncryptedTyping / receivedQueue / reportEncryptedSpam / +// uploadEncryptedFile 暂未注册,落 fallback → NOT_IMPLEMENTED + compatibility trace, +// 属 P1/P2(qts 引擎 + 消息投递)。设计 docs/secret-chat-module.md。 +func (r *Router) registerEncrypted(d *tg.ServerDispatcher) { + // P0:握手三件套。 + d.OnMessagesRequestEncryption(r.onMessagesRequestEncryption) + d.OnMessagesAcceptEncryption(r.onMessagesAcceptEncryption) + d.OnMessagesDiscardEncryption(r.onMessagesDiscardEncryption) + // P1:qts 消息收发 + 已读/typing + 队列确认。 + d.OnMessagesSendEncrypted(r.onMessagesSendEncrypted) + d.OnMessagesSendEncryptedService(r.onMessagesSendEncryptedService) + d.OnMessagesReadEncryptedHistory(r.onMessagesReadEncryptedHistory) + d.OnMessagesSetEncryptedTyping(r.onMessagesSetEncryptedTyping) + d.OnMessagesReceivedQueue(r.onMessagesReceivedQueue) + d.OnMessagesReportEncryptedSpam(r.onMessagesReportEncryptedSpam) + // P2:密聊文件。 + d.OnMessagesSendEncryptedFile(r.onMessagesSendEncryptedFile) + d.OnMessagesUploadEncryptedFile(r.onMessagesUploadEncryptedFile) +} diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index e575ab05..5e530ef5 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -29,8 +29,16 @@ func inputConstructorInvalidErr() error { return tgerr.New(400, "INPUT_CONSTRUCT // folderIDInvalidErr 表示客户端传入多个 folder peer 或非法 folder。 func folderIDInvalidErr() error { return tgerr.New(400, "FOLDER_ID_INVALID") } +func pinnedTooMuchErr() error { return tgerr.New(400, "PINNED_DIALOGS_TOO_MUCH") } + +// savedPinnedTooMuchErr 是收藏夹子会话置顶超限(messages.toggleSavedDialogPin / +// reorderPinnedSavedDialogs,官方错误码 PINNED_TOO_MUCH)。 +func savedPinnedTooMuchErr() error { return tgerr.New(400, "PINNED_TOO_MUCH") } + func filterIDInvalidErr() error { return tgerr.New(400, "FILTER_ID_INVALID") } +func filterIncludeEmptyErr() error { return tgerr.New(400, "FILTER_INCLUDE_EMPTY") } + func filterTitleEmptyErr() error { return tgerr.New(400, "FILTER_TITLE_EMPTY") } // peerIDInvalidErr 表示目标 peer 不存在或当前阶段不支持。 @@ -42,6 +50,10 @@ func sendAsPeerInvalidErr() error { return tgerr.New(400, "SEND_AS_PEER_INVALID" func limitInvalidErr() error { return tgerr.New(400, "LIMIT_INVALID") } +func offsetInvalidErr() error { return tgerr.New(400, "OFFSET_INVALID") } + +func platformInvalidErr() error { return tgerr.New(400, "PLATFORM_INVALID") } + func secondsInvalidErr() error { return tgerr.New(400, "SECONDS_INVALID") } func ttlPeriodInvalidErr() error { return tgerr.New(400, "TTL_PERIOD_INVALID") } @@ -52,6 +64,10 @@ func addressInvalidErr() error { return tgerr.New(400, "ADDRESS_INVALID") } func mediaInvalidErr() error { return tgerr.New(400, "MEDIA_INVALID") } +func mediaTypeInvalidErr() error { return tgerr.New(400, "MEDIA_TYPE_INVALID") } + +func urlInvalidErr() error { return tgerr.New(400, "URL_INVALID") } + // 文件上传 / 下载相关错误。 func filePartInvalidErr() error { return tgerr.New(400, "FILE_PART_INVALID") } func filePartsInvalidErr() error { return tgerr.New(400, "FILE_PARTS_INVALID") } @@ -59,17 +75,35 @@ func filePartTooBigErr() error { return tgerr.New(400, "FILE_PART_TOO_BIG" func fileReferenceInvalidErr() error { return tgerr.New(400, "FILE_REFERENCE_INVALID") } func locationInvalidErr() error { return tgerr.New(400, "LOCATION_INVALID") } func fileIDInvalidErr() error { return tgerr.New(400, "FILE_ID_INVALID") } +func documentInvalidErr() error { return tgerr.New(400, "DOCUMENT_INVALID") } func mediaEmptyErr() error { return tgerr.New(400, "MEDIA_EMPTY") } +func frozenMethodInvalidErr() error { return tgerr.New(400, "FROZEN_METHOD_INVALID") } + func photoInvalidErr() error { return tgerr.New(400, "PHOTO_INVALID") } func stickersetInvalidErr() error { return tgerr.New(406, "STICKERSET_INVALID") } +// stickerInvalidErr 表示输入文档不是合法贴纸/GIF(faveSticker/saveRecentSticker/saveGif)。 +func stickerInvalidErr() error { return tgerr.New(400, "STICKER_DOCUMENT_INVALID") } + func mediaCaptionTooLongErr() error { return tgerr.New(400, "MEDIA_CAPTION_TOO_LONG") } func replyMarkupInvalidErr() error { return tgerr.New(400, "REPLY_MARKUP_INVALID") } +// accessTokenInvalidErr 是 auth.importBotAuthorization 的 bot token 校验失败 +// (含 revoke 后旧 token)。 +func accessTokenInvalidErr() error { return tgerr.New(400, "ACCESS_TOKEN_INVALID") } + +// importBotAuthorizationErr 把 bot 登录业务错误映射为 rpc_error。 +func importBotAuthorizationErr(err error) error { + if errors.Is(err, domain.ErrBotTokenInvalid) { + return accessTokenInvalidErr() + } + return internalErr() +} + func shortcutInvalidErr() error { return tgerr.New(400, "SHORTCUT_INVALID") } func effectIDInvalidErr() error { return tgerr.New(400, "EFFECT_ID_INVALID") } @@ -84,6 +118,12 @@ func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") } +func storyIDEmptyErr() error { return tgerr.New(400, "STORY_ID_EMPTY") } + +func storyNotModifiedErr() error { return tgerr.New(400, "STORY_NOT_MODIFIED") } + +func storyPeriodInvalidErr() error { return tgerr.New(400, "STORY_PERIOD_INVALID") } + func replyToMonoforumPeerInvalidErr() error { return tgerr.New(400, "REPLY_TO_MONOFORUM_PEER_INVALID") } func optionsTooMuchErr() error { return tgerr.New(400, "OPTIONS_TOO_MUCH") } @@ -94,8 +134,38 @@ func pollOptionInvalidErr() error { return tgerr.New(400, "POLL_OPTION_INVALID") func pollAnswerInvalidErr() error { return tgerr.New(400, "POLL_ANSWER_INVALID") } +func quizCorrectAnswersInvalidErr() error { return tgerr.New(400, "QUIZ_CORRECT_ANSWERS_INVALID") } + +func pollClosedErr() error { return tgerr.New(400, "MESSAGE_POLL_CLOSED") } + +func revoteNotAllowedErr() error { return tgerr.New(400, "REVOTE_NOT_ALLOWED") } + +// pollVoteRequiredErr 用于 getPollVotes 防御路径(非公开投票/未投票即查投票人,官方同名错误)。 +func pollVoteRequiredErr() error { return tgerr.New(403, "POLL_VOTE_REQUIRED") } + func reactionInvalidErr() error { return tgerr.New(400, "REACTION_INVALID") } +// emoticonInvalidErr 用于 InputMediaDice 等带 emoticon 的输入不在支持集合内。 +func emoticonInvalidErr() error { return tgerr.New(400, "EMOTICON_INVALID") } + +func themeInvalidErr() error { return tgerr.New(400, "THEME_INVALID") } + +// themeErr 把自定义云主题(account.createTheme 等)的 domain 错误映射为 tgerr。 +func themeErr(err error) error { + switch { + case errors.Is(err, domain.ErrThemeFormatInvalid): + return tgerr.New(400, "THEME_FORMAT_INVALID") + case errors.Is(err, domain.ErrThemeSlugTaken): + return tgerr.New(400, "THEME_SLUG_INVALID") + case errors.Is(err, domain.ErrThemeInvalid), errors.Is(err, domain.ErrThemeNotFound): + return tgerr.New(400, "THEME_INVALID") + default: + return internalErr() + } +} + +func colorInvalidErr() error { return tgerr.New(400, "COLOR_INVALID") } + func privacyKeyInvalidErr() error { return tgerr.New(400, "PRIVACY_KEY_INVALID") } func privacyValueInvalidErr() error { return tgerr.New(400, "PRIVACY_VALUE_INVALID") } @@ -122,6 +192,12 @@ func dcIDInvalidErr() error { return tgerr.New(400, "DC_ID_INVALID") } func authTokenInvalidErr() error { return tgerr.New(400, "AUTH_TOKEN_INVALID") } +func authTokenExpiredErr() error { return tgerr.New(400, "AUTH_TOKEN_EXPIRED") } + +func authTokenAlreadyAcceptedErr() error { return tgerr.New(400, "AUTH_TOKEN_ALREADY_ACCEPTED") } + +func authTokenExceptionErr() error { return tgerr.New(400, "AUTH_TOKEN_EXCEPTION") } + func userIDInvalidErr() error { return tgerr.New(400, "USER_ID_INVALID") } func usersTooFewErr() error { return tgerr.New(400, "USERS_TOO_FEW") } @@ -130,6 +206,8 @@ func firstNameInvalidErr() error { return tgerr.New(400, "FIRSTNAME_INVALID") } func aboutTooLongErr() error { return tgerr.New(400, "ABOUT_TOO_LONG") } +func birthdayInvalidErr() error { return tgerr.New(400, "BIRTHDAY_INVALID") } + func contactIDInvalidErr() error { return tgerr.New(400, "CONTACT_ID_INVALID") } func contactNameEmptyErr() error { return tgerr.New(400, "CONTACT_NAME_EMPTY") } @@ -142,6 +220,10 @@ func messageEmptyErr() error { return tgerr.New(400, "MESSAGE_EMPTY") } // messageTooLongErr 表示文本超出当前阶段限制。 func messageTooLongErr() error { return tgerr.New(400, "MESSAGE_TOO_LONG") } +func entitiesTooLongErr() error { return tgerr.New(400, "ENTITIES_TOO_LONG") } + +func entityBoundsInvalidErr() error { return tgerr.New(400, "ENTITY_BOUNDS_INVALID") } + func messageIDInvalidErr() error { return tgerr.New(400, "MESSAGE_ID_INVALID") } func msgIDInvalidErr() error { return tgerr.New(400, "MSG_ID_INVALID") } @@ -152,10 +234,10 @@ func messageNotModifiedErr() error { return tgerr.New(400, "MESSAGE_NOT_MODIFIED func messageEditForbiddenErr() error { return tgerr.New(403, "EDIT_MESSAGES_FORBIDDEN") } -func messageDeleteForbiddenErr() error { return tgerr.New(403, "DELETE_MESSAGES_FORBIDDEN") } - func messageNotReadYetErr() error { return tgerr.New(400, "MESSAGE_NOT_READ_YET") } +func scoreInvalidErr() error { return tgerr.New(400, "SCORE_INVALID") } + func sessionPasswordNeededErr() error { return tgerr.New(401, "SESSION_PASSWORD_NEEDED") } func passwordHashInvalidErr() error { return tgerr.New(400, "PASSWORD_HASH_INVALID") } func srpIDInvalidErr() error { return tgerr.New(400, "SRP_ID_INVALID") } @@ -193,9 +275,60 @@ func floodWaitErr(seconds int) error { return tgerr.New(420, fmt.Sprintf("FLOOD_WAIT_%d", seconds)) } +// phoneNumberInvalidErr 表示手机号为空或格式非法(auth.sendCode/signIn/signUp)。 +func phoneNumberInvalidErr() error { return tgerr.New(406, "PHONE_NUMBER_INVALID") } + +// authKeyUnregisteredErr 表示请求要求登录态而当前连接未授权。 +func authKeyUnregisteredErr() error { return tgerr.New(401, "AUTH_KEY_UNREGISTERED") } + +// 私聊通话(phone.*)错误;触发点见 internal/rpc/phone_calls.go 与 app/phone 错误映射。 +func callPeerInvalidErr() error { return tgerr.New(400, "CALL_PEER_INVALID") } +func callAlreadyAcceptedErr() error { return tgerr.New(400, "CALL_ALREADY_ACCEPTED") } +func callAlreadyDeclinedErr() error { return tgerr.New(400, "CALL_ALREADY_DECLINED") } +func callOccupyFailedErr() error { return tgerr.New(400, "CALL_OCCUPY_FAILED") } +func callProtocolLayerInvalidErr() error { + return tgerr.New(400, "CALL_PROTOCOL_LAYER_INVALID") +} +func callProtocolCompatLayerInvalidErr() error { + return tgerr.New(400, "CALL_PROTOCOL_COMPAT_LAYER_INVALID") +} +func callProtocolFlagsInvalidErr() error { + return tgerr.New(400, "CALL_PROTOCOL_FLAGS_INVALID") +} + +func userIsBlockedErr() error { return tgerr.New(400, "USER_IS_BLOCKED") } +func userPrivacyRestrictedErr() error { return tgerr.New(403, "USER_PRIVACY_RESTRICTED") } + +// signalingDataInvalidErr 表示 phone.sendSignalingData 载荷超限或非法。 +func signalingDataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") } + +// 群通话(group call)错误。 +func groupCallInvalidErr() error { return tgerr.New(400, "GROUPCALL_INVALID") } +func groupCallAlreadyDiscardedErr() error { return tgerr.New(400, "GROUPCALL_ALREADY_DISCARDED") } +func groupCallAlreadyStartedErr() error { return tgerr.New(400, "GROUPCALL_ALREADY_STARTED") } +func groupCallForbiddenErr() error { return tgerr.New(403, "GROUPCALL_FORBIDDEN") } +func groupCallSSRCDuplicateErr() error { + return tgerr.New(400, "GROUPCALL_SSRC_DUPLICATE_MUCH") +} +func groupCallJoinMissingErr() error { return tgerr.New(400, "GROUPCALL_JOIN_MISSING") } +func groupCallNotModifiedErr() error { return tgerr.New(400, "GROUPCALL_NOT_MODIFIED") } + +// 私聊端对端加密(Secret Chat / encrypted chat)错误;触发点见 +// internal/rpc/encrypted_chats.go 与 app/secretchat、domain 错误映射。 +func encryptionIDInvalidErr() error { return tgerr.New(400, "ENCRYPTION_ID_INVALID") } +func encryptionAlreadyAcceptedErr() error { return tgerr.New(400, "ENCRYPTION_ALREADY_ACCEPTED") } +func encryptionAlreadyDeclinedErr() error { return tgerr.New(400, "ENCRYPTION_ALREADY_DECLINED") } +func chatIDInvalidErr() error { return tgerr.New(400, "CHAT_ID_INVALID") } +func dhGAInvalidErr() error { return tgerr.New(400, "DH_G_A_INVALID") } +func maxDateInvalidErr() error { return tgerr.New(400, "MAX_DATE_INVALID") } +func fileEmptyErr() error { return tgerr.New(400, "FILE_EMPTY") } + // signInErr 把登录业务错误映射为客户端可识别的 rpc_error。 func signInErr(err error) error { switch { + case errors.Is(err, auth.ErrPhoneNumberInvalid), + errors.Is(err, auth.ErrSystemUserLoginForbidden): + return phoneNumberInvalidErr() case errors.Is(err, auth.ErrCodeInvalid): return tgerr.New(400, "PHONE_CODE_INVALID") case errors.Is(err, auth.ErrCodeExpired): @@ -209,6 +342,23 @@ func signInErr(err error) error { } } +// passkeyErr 映射 passkey(WebAuthn)错误。验证类失败统一返回 PASSKEY_INVALID,不泄漏 +// 具体哪一步失败(防探测);非验证类(store/内部)返回 500。 +func passkeyErr(err error) error { + switch { + case errors.Is(err, auth.ErrSystemUserLoginForbidden): + return tgerr.New(400, "PASSKEY_INVALID") + case errors.Is(err, domain.ErrPasskeyChallengeInvalid): + return tgerr.New(400, "PASSKEY_CHALLENGE_INVALID") + case errors.Is(err, domain.ErrPasskeyInvalid), + errors.Is(err, domain.ErrPasskeyNotFound), + errors.Is(err, domain.ErrPasskeyUserHandleInvalid): + return tgerr.New(400, "PASSKEY_INVALID") + default: + return internalErr() + } +} + func passwordErr(err error) error { switch { case errors.Is(err, domain.ErrPasswordHashInvalid): diff --git a/internal/rpc/expiry_dispatcher.go b/internal/rpc/expiry_dispatcher.go new file mode 100644 index 00000000..88e961b3 --- /dev/null +++ b/internal/rpc/expiry_dispatcher.go @@ -0,0 +1,105 @@ +package rpc + +import ( + "context" + "time" + + "github.com/gotd/td/tg" + "go.uber.org/zap" +) + +// ExpiryDispatcher deletes messages whose per-message TTL snapshot has expired. +type ExpiryDispatcher struct { + router *Router + log *zap.Logger + batch int + interval time.Duration + maxIdle time.Duration +} + +func NewExpiryDispatcher(router *Router, log *zap.Logger) *ExpiryDispatcher { + if log == nil { + log = zap.NewNop() + } + return &ExpiryDispatcher{ + router: router, + log: log, + batch: defaultExpiryDispatchBatch, + interval: defaultExpiryDispatchTick, + maxIdle: defaultIdleDispatchMaxInterval, + } +} + +func (d *ExpiryDispatcher) Run(ctx context.Context) { + if d == nil || d.router == nil { + return + } + runIdleBackoffLoop(ctx, d.interval, d.maxIdle, d.DispatchOnce) +} + +func (d *ExpiryDispatcher) DispatchOnce(ctx context.Context) bool { + if d == nil || d.router == nil { + return false + } + now := int(d.router.clock.Now().Unix()) + private := d.dispatchPrivate(ctx, now) + channels := d.dispatchChannels(ctx, now) + return private || channels +} + +func (d *ExpiryDispatcher) dispatchPrivate(ctx context.Context, now int) bool { + if d.router.deps.Messages == nil { + return false + } + ttlSvc, ok := d.router.deps.Messages.(historyTTLMessagesService) + if !ok { + return false + } + requests, err := ttlSvc.ClaimExpiredPrivateMessages(ctx, now, d.batch) + if err != nil { + d.log.Warn("claim expired private messages", zap.Error(err)) + return false + } + for _, req := range requests { + res, err := d.router.deps.Messages.DeleteMessages(ctx, req.OwnerUserID, req) + if err != nil { + d.log.Warn("delete expired private messages", zap.Int64("owner_user_id", req.OwnerUserID), zap.Ints("ids", req.IDs), zap.Error(err)) + continue + } + if d.router.hasReliableUpdateDispatch() { + continue + } + for _, deleted := range res.Deleted { + if deleted.Event.Type != "" { + d.router.pushUserUpdates(ctx, deleted.UserID, tgUpdateForOutboxEvent(deleted.Event)) + } + } + } + return len(requests) > 0 +} + +func (d *ExpiryDispatcher) dispatchChannels(ctx context.Context, now int) bool { + if d.router.deps.Channels == nil { + return false + } + ttlSvc, ok := d.router.deps.Channels.(channelHistoryTTLService) + if !ok { + return false + } + requests, err := ttlSvc.ClaimExpiredMessages(ctx, now, d.batch) + if err != nil { + d.log.Warn("claim expired channel messages", zap.Error(err)) + return false + } + for _, req := range requests { + res, err := d.router.deps.Channels.DeleteMessages(ctx, req.UserID, req) + if err != nil { + d.log.Warn("delete expired channel messages", zap.Int64("channel_id", req.ChannelID), zap.Ints("ids", req.IDs), zap.Error(err)) + continue + } + d.router.enqueueChannelFanout(ctx, channelFanoutMembers, req.UserID, req.ChannelID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { + return d.router.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) + }) + } + return len(requests) > 0 +} diff --git a/internal/rpc/external_media_rpc_test.go b/internal/rpc/external_media_rpc_test.go new file mode 100644 index 00000000..98cf8fd4 --- /dev/null +++ b/internal/rpc/external_media_rpc_test.go @@ -0,0 +1,41 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + + "telesrv/internal/domain" +) + +// TestResolveInputMediaExternal 验证 inputMediaPhoto/DocumentExternal 路由到 Files +// 外链抓取,并把结果投影为 photo/document 媒体;空 URL(fakeFiles 拒)→ MEDIA_INVALID。 +func TestResolveInputMediaExternal(t *testing.T) { + r := &Router{deps: Deps{Files: &fakeFiles{}}} + ctx := context.Background() + + // 外链图片。 + media, err := r.resolveInputMedia(ctx, 1001, &tg.InputMediaPhotoExternal{URL: "https://example.com/cat.png", Spoiler: true}) + if err != nil { + t.Fatalf("photo external: %v", err) + } + if media == nil || media.Kind != domain.MessageMediaKindPhoto || media.Photo == nil || !media.Spoiler { + t.Fatalf("photo external media = %+v, want spoiler photo", media) + } + + // 外链文档。 + media, err = r.resolveInputMedia(ctx, 1001, &tg.InputMediaDocumentExternal{URL: "https://example.com/doc.pdf"}) + if err != nil { + t.Fatalf("document external: %v", err) + } + if media == nil || media.Kind != domain.MessageMediaKindDocument || media.Document == nil { + t.Fatalf("document external media = %+v, want document", media) + } + + // 空 URL(抓取失败)→ MEDIA_INVALID。 + if _, err := r.resolveInputMedia(ctx, 1001, &tg.InputMediaPhotoExternal{URL: ""}); !tgerr.Is(err, "MEDIA_INVALID") { + t.Fatalf("empty url photo err = %v, want MEDIA_INVALID", err) + } +} diff --git a/internal/rpc/forum_general_read_rpc_test.go b/internal/rpc/forum_general_read_rpc_test.go new file mode 100644 index 00000000..2b0d0b21 --- /dev/null +++ b/internal/rpc/forum_general_read_rpc_test.go @@ -0,0 +1,182 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestChannelsReadHistoryAdvancesForumGeneralReadRPC 覆盖 forum「General 话题幽灵未读」修复: +// 频道级 channels.readHistory 推进频道级已读后,必须把 General(topic 1) 的话题级已读水位也推进 +// (General 消息即频道根历史,被频道级已读覆盖),否则 getForumTopics 会一直报 General 未读。 +// 同时验证反向不变量:读「非 General 子话题」(readDiscussion) 绝不污染 General —— 零话题间 cross-talk。 +func TestChannelsReadHistoryAdvancesForumGeneralReadRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550007301", FirstName: "Owner"}) + member, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550007302", FirstName: "Member"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + + ownerCtx := WithUserID(ctx, owner.ID) + memberCtx := WithUserID(ctx, member.ID) + + created, err := r.onChannelsCreateChannel(ownerCtx, &tg.ChannelsCreateChannelRequest{Title: "Forum", Megagroup: true}) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + if _, err := r.onChannelsInviteToChannel(ownerCtx, &tg.ChannelsInviteToChannelRequest{ + Channel: input, + Users: []tg.InputUserClass{&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}}, + }); err != nil { + t.Fatalf("invite member: %v", err) + } + if _, err := r.onChannelsToggleForum(ownerCtx, &tg.ChannelsToggleForumRequest{Channel: input, Enabled: true, Tabs: true}); err != nil { + t.Fatalf("toggle forum: %v", err) + } + + // 一个非 General 子话题,用于 cross-talk 反例。 + createdTopic, err := r.onMessagesCreateForumTopic(ownerCtx, &tg.MessagesCreateForumTopicRequest{ + Peer: forumPeer, + Title: "Side", + IconColor: domain.DefaultForumTopicIconColor, + RandomID: 7301001, + }) + if err != nil { + t.Fatalf("create forum topic: %v", err) + } + sideTopicID := forumTopicRootMessageID(t, createdTopic, "Side") + + sendGeneral := func(text string, randomID int64) int { + sent, err := r.onMessagesSendMessage(ownerCtx, &tg.MessagesSendMessageRequest{ + Peer: forumPeer, + Message: text, + RandomID: randomID, + }) + if err != nil { + t.Fatalf("send general %q: %v", text, err) + } + return forumNewChannelMessageID(t, sent, text) + } + sendSide := func(text string, randomID int64) int { + reply := &tg.InputReplyToMessage{ReplyToMsgID: 0} + reply.SetTopMsgID(sideTopicID) + req := &tg.MessagesSendMessageRequest{Peer: forumPeer, Message: text, RandomID: randomID} + req.SetReplyTo(reply) + sent, err := r.onMessagesSendMessage(ownerCtx, req) + if err != nil { + t.Fatalf("send side %q: %v", text, err) + } + return forumNewChannelMessageID(t, sent, text) + } + + // id 序:Side 根 < g1 < g2 < side1 < g3。Side 消息夹在 General 消息中间, + // 是「读 Side 用 GREATEST 误清 General」这类污染的最小复现条件。 + _ = sendGeneral("g1", 7301002) + _ = sendGeneral("g2", 7301003) + sideMsg := sendSide("side1", 7301004) + g3 := sendGeneral("g3", 7301005) + + generalUnread := func() int { + topics, err := r.onMessagesGetForumTopicsByID(memberCtx, &tg.MessagesGetForumTopicsByIDRequest{ + Peer: forumPeer, + Topics: []int{forumGeneralTopicID}, + }) + if err != nil { + t.Fatalf("member getForumTopicsByID General: %v", err) + } + for _, item := range topics.Topics { + if topic, ok := item.(*tg.ForumTopic); ok && topic.ID == forumGeneralTopicID { + return topic.UnreadCount + } + } + t.Fatalf("member getForumTopicsByID General missing in %+v", topics.Topics) + return -1 + } + + before := generalUnread() + if before == 0 { + t.Fatalf("General unread before any member read = 0, want owner's General messages counted as unread") + } + + // 反向不变量:读「Side」子话题(readDiscussion)不得污染 General。readDiscussion 内部对频道级 + // ReadHistory 的「保守叠加」只经 service,不触达 channels.readHistory 的 RPC handler,故不回灌 General。 + if ok, err := r.onMessagesReadDiscussion(memberCtx, &tg.MessagesReadDiscussionRequest{ + Peer: forumPeer, + MsgID: sideTopicID, + ReadMaxID: sideMsg, + }); err != nil || !ok { + t.Fatalf("member readDiscussion Side = ok %v err %v, want true", ok, err) + } + if after := generalUnread(); after != before { + t.Fatalf("General unread after reading Side topic = %d, want unchanged %d (no cross-topic pollution)", after, before) + } + + // 修复点:直接的 channels.readHistory 把频道级读到顶后,General 话题级水位随之推进 → 未读清零。 + if ok, err := r.onChannelsReadHistory(memberCtx, &tg.ChannelsReadHistoryRequest{ + Channel: input, + MaxID: g3, + }); err != nil || !ok { + t.Fatalf("member channels.readHistory = ok %v err %v, want true", ok, err) + } + if after := generalUnread(); after != 0 { + t.Fatalf("General unread after channels.readHistory = %d, want 0 (channel-level read covers General)", after) + } +} + +func forumNewChannelMessageID(t *testing.T, updates tg.UpdatesClass, text string) int { + t.Helper() + u, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, update := range u.Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + if msg, ok := newMsg.Message.(*tg.Message); ok && msg.Message == text { + return msg.ID + } + } + t.Fatalf("updates = %+v, want new channel message %q", u.Updates, text) + return 0 +} + +func forumTopicRootMessageID(t *testing.T, updates tg.UpdatesClass, title string) int { + t.Helper() + u, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, update := range u.Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + msg, ok := newMsg.Message.(*tg.MessageService) + if !ok { + continue + } + if action, ok := msg.Action.(*tg.MessageActionTopicCreate); ok && action.Title == title { + return msg.ID + } + } + t.Fatalf("updates = %+v, want topic create service message %q", u.Updates, title) + return 0 +} diff --git a/internal/rpc/groupcall_sweep_dispatcher.go b/internal/rpc/groupcall_sweep_dispatcher.go new file mode 100644 index 00000000..c4dd34fa --- /dev/null +++ b/internal/rpc/groupcall_sweep_dispatcher.go @@ -0,0 +1,90 @@ +package rpc + +import ( + "context" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/sfu" +) + +// GroupCallSweepDispatcher 清理群通话幽灵参与者(kill 进程/断电后无人发 leave)。 +// +// ⚠ P0-2 活性契约:客户端只在 Connecting 态发 checkGroupCall(媒体连通后心跳停止), +// 单凭 last_check_date 判死会把所有连上媒体的健康参与者踢光。约定的判据是 +// 「max(心跳, 媒体面活性)」:M0(SFU disabled)客户端恒 Connecting、4s 心跳不断, +// 纯水位成立;M1 起 SFU 必须对媒体面存活(ICE consent/近期 SRTP 收包)的 endpoint +// 周期性刷新 last_check_date(liveness reporter),使单一水位同时承载两路活性。 +type GroupCallSweepDispatcher struct { + router *Router + log *zap.Logger + interval time.Duration + checkTTL time.Duration + batch int +} + +func NewGroupCallSweepDispatcher(router *Router, log *zap.Logger, interval, checkTTL time.Duration) *GroupCallSweepDispatcher { + if log == nil { + log = zap.NewNop() + } + if interval <= 0 { + interval = 10 * time.Second + } + if checkTTL <= 0 { + checkTTL = 45 * time.Second + } + return &GroupCallSweepDispatcher{router: router, log: log, interval: interval, checkTTL: checkTTL, batch: 100} +} + +func (d *GroupCallSweepDispatcher) Run(ctx context.Context) { + if d == nil || d.router == nil { + return + } + ticker := time.NewTicker(d.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + d.DispatchOnce(ctx) + } + } +} + +func (d *GroupCallSweepDispatcher) DispatchOnce(ctx context.Context) { + if d == nil || d.router == nil || d.router.deps.GroupCalls == nil { + return + } + now := d.router.clock.Now() + cutoff := int(now.Add(-d.checkTTL).Unix()) + muts, err := d.router.deps.GroupCalls.SweepStale(ctx, cutoff, int(now.Unix()), d.batch) + if err != nil { + d.log.Warn("sweep group call participants", zap.Error(err)) + return + } + for _, mut := range muts { + if d.router.deps.SFU != nil { + _ = d.router.deps.SFU.Leave(ctx, mut.Call.ID, mut.Participant.UserID, sfu.EndpointMain) + } + channel, err := d.router.channelForGroupCall(ctx, mut.Call) + if err != nil { + continue + } + d.router.groupCallMutationFanout(ctx, channel, mut) + d.log.Info("group call participant swept", + zap.Int64("call_id", mut.Call.ID), + zap.Int64("user_id", mut.Participant.UserID)) + } +} + +// channelForGroupCall 取通话所属频道行(推送 hydration 用)。 +func (r *Router) channelForGroupCall(ctx context.Context, call domain.GroupCall) (domain.Channel, error) { + view, err := r.deps.Channels.GetChannel(ctx, call.CreatorUserID, call.ChannelID) + if err != nil { + return domain.Channel{}, err + } + return view.Channel, nil +} diff --git a/internal/rpc/help.go b/internal/rpc/help.go index 28c6639f..c6eb95f1 100644 --- a/internal/rpc/help.go +++ b/internal/rpc/help.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "time" "github.com/gotd/td/tg" @@ -49,10 +50,10 @@ func (r *Router) registerHelp(d *tg.ServerDispatcher) { return tdesktop.TimezonesList(hash), nil }) d.OnHelpGetPeerColors(func(ctx context.Context, hash int) (tg.HelpPeerColorsClass, error) { - return tdesktop.PeerColors(), nil + return tdesktop.PeerColors(hash), nil }) d.OnHelpGetPeerProfileColors(func(ctx context.Context, hash int) (tg.HelpPeerColorsClass, error) { - return tdesktop.PeerColors(), nil + return tdesktop.PeerProfileColors(hash), nil }) d.OnHelpGetPromoData(func(ctx context.Context) (tg.HelpPromoDataClass, error) { return tdesktop.PromoData(r.clock.Now()), nil @@ -60,7 +61,42 @@ func (r *Router) registerHelp(d *tg.ServerDispatcher) { d.OnHelpGetTermsOfServiceUpdate(func(ctx context.Context) (tg.HelpTermsOfServiceUpdateClass, error) { return tdesktop.TermsOfServiceUpdate(r.clock.Now()), nil }) - d.OnHelpGetPremiumPromo(func(ctx context.Context) (*tg.HelpPremiumPromo, error) { - return tdesktop.PremiumPromo(), nil + // 客户端遇到无法识别的 tg:// 深链时会查询 help.getDeepLinkInfo。telesrv 不维护 + // “需更新 App”的特殊深链提示库,对所有 path 返回 deepLinkInfoEmpty——这是规范的 + // “无特殊信息”应答:DrKLO 仅在收到非空 deepLinkInfo 时才弹“请更新 App”弹窗 + // (LaunchActivity.java:5175),收到 Empty 则静默放行按普通链接处理。此前未注册 + // handler 会落 fallback 返回 500 NOT_IMPLEMENTED(污染日志且非正确协议行为)。 + d.OnHelpGetDeepLinkInfo(func(ctx context.Context, path string) (tg.HelpDeepLinkInfoClass, error) { + return &tg.HelpDeepLinkInfoEmpty{}, nil }) + d.OnHelpGetPremiumPromo(r.onHelpGetPremiumPromo) +} + +// onHelpGetPremiumPromo 返回最小真实的 Premium 状态页数据:状态文案按 viewer +// 的会员有效期生成;videos/period_options 留空——购买入口已被 appConfig +// premium_purchase_blocked=true 关闭,订阅价格 UI 不会消费这些字段(TDesktop +// 空 period_options 仅隐藏价格按钮,DrKLO 回退到无价文案,均不报错)。 +// 六个字段全是 TL 必填项,空值也必须给出空集合而非缺失。 +func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) { + promo := &tg.HelpPremiumPromo{ + StatusText: "Telegram Premium is not active on this account.", + StatusEntities: []tg.MessageEntityClass{}, + VideoSections: []string{}, + Videos: []tg.DocumentClass{}, + PeriodOptions: []tg.PremiumSubscriptionOption{}, + Users: []tg.UserClass{}, + } + userID, _, err := r.currentUserID(ctx) + if err != nil || r.deps.Users == nil { + return promo, nil + } + u, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return promo, nil + } + if u.PremiumActiveAt(r.clock.Now().Unix()) { + until := time.Unix(int64(u.PremiumUntil), 0) + promo.StatusText = "Telegram Premium is active until " + until.Format("2006-01-02") + "." + } + return promo, nil } diff --git a/internal/rpc/idle_backoff.go b/internal/rpc/idle_backoff.go new file mode 100644 index 00000000..e6e123b5 --- /dev/null +++ b/internal/rpc/idle_backoff.go @@ -0,0 +1,65 @@ +package rpc + +import ( + "context" + "time" +) + +const defaultIdleDispatchMaxInterval = 5 * time.Second + +type idleBackoff struct { + base time.Duration + max time.Duration + next time.Duration +} + +func newIdleBackoff(base, max time.Duration) idleBackoff { + if base <= 0 { + base = time.Second + } + if max < base { + max = base + } + return idleBackoff{ + base: base, + max: max, + next: base, + } +} + +func (b *idleBackoff) ActiveDelay() time.Duration { + b.next = b.base + return b.base +} + +func (b *idleBackoff) IdleDelay() time.Duration { + delay := b.next + if b.next < b.max { + b.next *= 2 + if b.next > b.max { + b.next = b.max + } + } + return delay +} + +func runIdleBackoffLoop(ctx context.Context, interval, maxIdleInterval time.Duration, dispatch func(context.Context) bool) { + if dispatch == nil { + return + } + backoff := newIdleBackoff(interval, maxIdleInterval) + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + } + if dispatch(ctx) { + timer.Reset(backoff.ActiveDelay()) + continue + } + timer.Reset(backoff.IdleDelay()) + } +} diff --git a/internal/rpc/idle_backoff_test.go b/internal/rpc/idle_backoff_test.go new file mode 100644 index 00000000..39d83c30 --- /dev/null +++ b/internal/rpc/idle_backoff_test.go @@ -0,0 +1,38 @@ +package rpc + +import ( + "testing" + "time" +) + +func TestIdleBackoffSequenceAndReset(t *testing.T) { + backoff := newIdleBackoff(100*time.Millisecond, 450*time.Millisecond) + wantIdle := []time.Duration{ + 100 * time.Millisecond, + 200 * time.Millisecond, + 400 * time.Millisecond, + 450 * time.Millisecond, + 450 * time.Millisecond, + } + for i, want := range wantIdle { + if got := backoff.IdleDelay(); got != want { + t.Fatalf("idle delay[%d] = %v, want %v", i, got, want) + } + } + if got := backoff.ActiveDelay(); got != 100*time.Millisecond { + t.Fatalf("active delay = %v, want base interval", got) + } + if got := backoff.IdleDelay(); got != 100*time.Millisecond { + t.Fatalf("idle delay after reset = %v, want base interval", got) + } +} + +func TestIdleBackoffSanitizesMaxBelowBase(t *testing.T) { + backoff := newIdleBackoff(2*time.Second, time.Second) + if got := backoff.IdleDelay(); got != 2*time.Second { + t.Fatalf("first idle delay = %v, want base interval", got) + } + if got := backoff.IdleDelay(); got != 2*time.Second { + t.Fatalf("second idle delay = %v, want clamped max at base interval", got) + } +} diff --git a/internal/rpc/inline_push.go b/internal/rpc/inline_push.go new file mode 100644 index 00000000..141b20b5 --- /dev/null +++ b/internal/rpc/inline_push.go @@ -0,0 +1,119 @@ +package rpc + +import ( + "context" + "time" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/store" +) + +const inlinePushSubscribeRetry = time.Second + +func (r *Router) RunInlineBotPushSubscriber(ctx context.Context) { + broker, ok := r.deps.Inline.(store.BotInlineQueryPushBroker) + if !ok || broker == nil { + return + } + for { + err := broker.SubscribeBotInlineQueries(ctx, func(ctx context.Context, event store.BotInlineQueryPush) { + r.handleRemoteInlineBotQuery(ctx, event) + }) + if ctx.Err() != nil { + return + } + if err != nil { + r.log.Warn("inline bot push subscriber stopped", zap.Error(err)) + } + select { + case <-ctx.Done(): + return + case <-time.After(inlinePushSubscribeRetry): + } + } +} + +func (r *Router) publishInlineBotQuery(ctx context.Context, event store.BotInlineQueryPush) { + broker, ok := r.deps.Inline.(store.BotInlineQueryPushBroker) + if !ok || broker == nil { + return + } + event.SourceID = r.instanceID + if event.Date == 0 { + event.Date = int(r.clock.Now().Unix()) + } + if err := broker.PublishBotInlineQuery(ctx, event); err != nil { + r.log.Debug("publish inline bot query", zap.Int64("bot_user_id", event.BotUserID), zap.Int64("query_id", event.QueryID), zap.Error(err)) + } +} + +func (r *Router) handleRemoteInlineBotQuery(ctx context.Context, event store.BotInlineQueryPush) { + if event.SourceID == "" || event.SourceID == r.instanceID { + return + } + if event.QueryID == 0 || event.BotUserID == 0 || event.UserID == 0 { + return + } + peerType, ok := tgInlineQueryPeerTypeFromStore(event.PeerType) + if !ok { + return + } + update := &tg.UpdateBotInlineQuery{ + QueryID: event.QueryID, + UserID: event.UserID, + Query: event.Query, + Offset: event.Offset, + PeerType: peerType, + } + if event.Geo != nil { + update.SetGeo(tgGeoPoint(*event.Geo)) + } + date := event.Date + if date <= 0 { + date = int(r.clock.Now().Unix()) + } + r.pushUserMessage(ctx, event.BotUserID, "push remote bot inline query", &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: date, + }) +} + +func storeInlineQueryPeerType(peerType tg.InlineQueryPeerTypeClass) string { + switch peerType.(type) { + case *tg.InlineQueryPeerTypeSameBotPM: + return store.InlineQueryPeerTypeSameBotPM + case *tg.InlineQueryPeerTypePM: + return store.InlineQueryPeerTypePM + case *tg.InlineQueryPeerTypeChat: + return store.InlineQueryPeerTypeChat + case *tg.InlineQueryPeerTypeMegagroup: + return store.InlineQueryPeerTypeMegagroup + case *tg.InlineQueryPeerTypeBroadcast: + return store.InlineQueryPeerTypeBroadcast + case *tg.InlineQueryPeerTypeBotPM: + return store.InlineQueryPeerTypeBotPM + default: + return "" + } +} + +func tgInlineQueryPeerTypeFromStore(peerType string) (tg.InlineQueryPeerTypeClass, bool) { + switch peerType { + case store.InlineQueryPeerTypeSameBotPM: + return &tg.InlineQueryPeerTypeSameBotPM{}, true + case store.InlineQueryPeerTypePM: + return &tg.InlineQueryPeerTypePM{}, true + case store.InlineQueryPeerTypeChat: + return &tg.InlineQueryPeerTypeChat{}, true + case store.InlineQueryPeerTypeMegagroup: + return &tg.InlineQueryPeerTypeMegagroup{}, true + case store.InlineQueryPeerTypeBroadcast: + return &tg.InlineQueryPeerTypeBroadcast{}, true + case store.InlineQueryPeerTypeBotPM: + return &tg.InlineQueryPeerTypeBotPM{}, true + default: + return nil, false + } +} diff --git a/internal/rpc/inline_push_test.go b/internal/rpc/inline_push_test.go new file mode 100644 index 00000000..692395f8 --- /dev/null +++ b/internal/rpc/inline_push_test.go @@ -0,0 +1,236 @@ +package rpc + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + botsapp "telesrv/internal/app/bots" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/memory" +) + +func TestInlineBotQueryPublishesRemotePush(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + shared := newTestInlinePushBroker() + users := memory.NewUserStore() + botStore := memory.NewBotStore(users) + bots := botsapp.NewService(users, botStore, nil) + owner, err := users.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + peer, err := users.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Peer"}) + if err != nil { + t.Fatalf("create peer: %v", err) + } + bot, _, err := bots.CreateBot(ctx, owner.ID, "Inline Push Bot", "inline_push_bot") + if err != nil { + t.Fatalf("create bot: %v", err) + } + if _, err := bots.SetInlinePlaceholder(ctx, bot.ID, "Search push"); err != nil { + t.Fatalf("set inline placeholder: %v", err) + } + + localSessions := &captureSessions{} + remoteSessions := &captureSessions{} + local := New(Config{InstanceID: "node-a"}, Deps{ + Users: appusers.NewService(users), + Bots: bots, + Inline: shared, + Sessions: localSessions, + }, zaptest.NewLogger(t), clock.System) + remote := New(Config{InstanceID: "node-b"}, Deps{ + Inline: shared, + Sessions: remoteSessions, + }, zaptest.NewLogger(t), clock.System) + + go local.RunInlineBotPushSubscriber(ctx) + go remote.RunInlineBotPushSubscriber(ctx) + shared.waitSubscribers(t, 2) + + type inlineGetResult struct { + res *tg.MessagesBotResults + err error + } + gotCh := make(chan inlineGetResult, 1) + go func() { + res, err := local.onMessagesGetInlineBotResults(WithUserID(ctx, owner.ID), &tg.MessagesGetInlineBotResultsRequest{ + Bot: inputUser(bot), + Peer: inputPeerUser(peer), + Query: "remote-shape", + }) + gotCh <- inlineGetResult{res: res, err: err} + }() + + queryID := waitInlineBotQuery(t, local) + event := shared.waitPublished(t) + if event.SourceID != "node-a" || event.QueryID != queryID || event.BotUserID != bot.ID || event.UserID != owner.ID { + t.Fatalf("published event = %+v, queryID=%d bot=%d owner=%d", event, queryID, bot.ID, owner.ID) + } + if event.Query != "remote-shape" || event.PeerType != store.InlineQueryPeerTypePM { + t.Fatalf("published query metadata = %+v", event) + } + + localPushes := localSessions.pushedUserIDs() + if len(localPushes) != 1 || localPushes[0] != bot.ID { + t.Fatalf("local pushes = %+v, want exactly one direct push to bot", localPushes) + } + remoteUpdates := waitInlinePushUpdate(t, remoteSessions) + if remoteUpdates.Date == 0 || len(remoteUpdates.Updates) != 1 { + t.Fatalf("remote updates = %+v", remoteUpdates) + } + update, ok := remoteUpdates.Updates[0].(*tg.UpdateBotInlineQuery) + if !ok { + t.Fatalf("remote update type = %T", remoteUpdates.Updates[0]) + } + if update.QueryID != queryID || update.UserID != owner.ID || update.Query != "remote-shape" { + t.Fatalf("remote inline update = %+v", update) + } + if _, ok := update.PeerType.(*tg.InlineQueryPeerTypePM); !ok { + t.Fatalf("remote peer type = %T", update.PeerType) + } + + if ok, err := local.onMessagesSetInlineBotResults(WithUserID(ctx, bot.ID), &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + CacheTime: 30, + Results: []tg.InputBotInlineResultClass{ + &tg.InputBotInlineResult{ + ID: "remote-1", + Type: "article", + Title: "Remote", + SendMessage: &tg.InputBotInlineMessageText{Message: "remote ok"}, + }, + }, + }); err != nil || !ok { + t.Fatalf("set inline results = %v,%v, want true,nil", ok, err) + } + select { + case got := <-gotCh: + if got.err != nil { + t.Fatalf("get inline results: %v", got.err) + } + if got.res.QueryID != queryID || len(got.res.Results) != 1 { + t.Fatalf("inline results = %+v", got.res) + } + case <-time.After(2 * time.Second): + t.Fatal("get inline results did not finish") + } + + localPushes = localSessions.pushedUserIDs() + if len(localPushes) != 1 { + t.Fatalf("local self subscription duplicated push: %+v", localPushes) + } +} + +type testInlinePushBroker struct { + *testInlineRegistryStore + + mu sync.Mutex + subscribers map[chan store.BotInlineQueryPush]struct{} + published []store.BotInlineQueryPush +} + +func newTestInlinePushBroker() *testInlinePushBroker { + return &testInlinePushBroker{ + testInlineRegistryStore: newTestInlineRegistryStore(), + subscribers: make(map[chan store.BotInlineQueryPush]struct{}), + } +} + +func (s *testInlinePushBroker) PublishBotInlineQuery(ctx context.Context, event store.BotInlineQueryPush) error { + s.mu.Lock() + s.published = append(s.published, event) + subscribers := make([]chan store.BotInlineQueryPush, 0, len(s.subscribers)) + for ch := range s.subscribers { + subscribers = append(subscribers, ch) + } + s.mu.Unlock() + for _, ch := range subscribers { + select { + case ch <- event: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +func (s *testInlinePushBroker) SubscribeBotInlineQueries(ctx context.Context, handle func(context.Context, store.BotInlineQueryPush)) error { + ch := make(chan store.BotInlineQueryPush, 16) + s.mu.Lock() + s.subscribers[ch] = struct{}{} + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.subscribers, ch) + s.mu.Unlock() + }() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case event := <-ch: + handle(ctx, event) + } + } +} + +func (s *testInlinePushBroker) waitSubscribers(t *testing.T, want int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + s.mu.Lock() + got := len(s.subscribers) + s.mu.Unlock() + if got >= want { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("subscribers < %d", want) +} + +func (s *testInlinePushBroker) waitPublished(t *testing.T) store.BotInlineQueryPush { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + s.mu.Lock() + if len(s.published) > 0 { + event := s.published[0] + s.mu.Unlock() + return event + } + s.mu.Unlock() + time.Sleep(5 * time.Millisecond) + } + t.Fatal("inline push event was not published") + return store.BotInlineQueryPush{} +} + +func waitInlinePushUpdate(t *testing.T, sessions *captureSessions) *tg.Updates { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + snap := sessions.snapshot() + if snap.message != nil { + updates, ok := snap.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message type = %T", snap.message) + } + return updates + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("inline push update was not delivered") + return nil +} diff --git a/internal/rpc/inline_registry.go b/internal/rpc/inline_registry.go new file mode 100644 index 00000000..20ea708a --- /dev/null +++ b/internal/rpc/inline_registry.go @@ -0,0 +1,785 @@ +package rpc + +import ( + "context" + "fmt" + "sync" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +type inlineRegistry struct { + mu sync.Mutex + pending map[int64]*pendingInlineQuery + cache map[inlineCacheKey]cachedInlineResults + prepared map[string]preparedInlineMessage + webDocuments map[inlineWebDocumentKey]registeredInlineWebDocument + ttl time.Duration + maxCacheTTL time.Duration + maxCacheEntries int + shared store.InlineRegistryStore +} + +type pendingInlineQuery struct { + ch chan domain.BotInlineResults + botUserID int64 + userID int64 + peer domain.Peer + createdAt time.Time + cacheKey inlineCacheKey + results *domain.BotInlineResults +} + +type inlineCacheKey struct { + botUserID int64 + userID int64 + peer domain.Peer + query string + offset string + hasGeo bool + geoLat float64 + geoLong float64 + geoAccuracy int +} + +type cachedInlineResults struct { + results domain.BotInlineResults + expiresAt time.Time + storedAt time.Time +} + +type preparedInlineMessage struct { + id string + botUserID int64 + userID int64 + results domain.BotInlineResults + expiresAt time.Time +} + +type inlineWebDocumentKey struct { + url string + accessHash int64 +} + +type registeredInlineWebDocument struct { + document domain.BotInlineWebDocument + bytes []byte + mimeType string + expiresAt time.Time +} + +func newInlineRegistry(ttl time.Duration, shared ...store.InlineRegistryStore) *inlineRegistry { + if ttl <= 0 { + ttl = botInlineQueryTTL + } + var sharedStore store.InlineRegistryStore + if len(shared) > 0 { + sharedStore = shared[0] + } + return &inlineRegistry{ + pending: make(map[int64]*pendingInlineQuery), + cache: make(map[inlineCacheKey]cachedInlineResults), + prepared: make(map[string]preparedInlineMessage), + webDocuments: make(map[inlineWebDocumentKey]registeredInlineWebDocument), + ttl: ttl, + maxCacheTTL: botInlineCacheMaxTTL, + maxCacheEntries: botInlineCacheMaxEntries, + shared: sharedStore, + } +} + +func (r *inlineRegistry) register(now time.Time, botUserID, userID int64, peer domain.Peer) (int64, *pendingInlineQuery) { + return r.registerWithCacheKeyContext(context.Background(), now, botUserID, userID, peer, inlineCacheKey{}) +} + +func (r *inlineRegistry) registerWithCacheKey(now time.Time, botUserID, userID int64, peer domain.Peer, key inlineCacheKey) (int64, *pendingInlineQuery) { + return r.registerWithCacheKeyContext(context.Background(), now, botUserID, userID, peer, key) +} + +func (r *inlineRegistry) registerWithCacheKeyContext(ctx context.Context, now time.Time, botUserID, userID int64, peer domain.Peer, key inlineCacheKey) (int64, *pendingInlineQuery) { + p := &pendingInlineQuery{ + ch: make(chan domain.BotInlineResults, 1), + botUserID: botUserID, + userID: userID, + peer: peer, + createdAt: now, + cacheKey: key, + } + r.mu.Lock() + r.pruneLocked(now) + var queryID int64 + for { + queryID = randomNonZeroInt64() + if _, exists := r.pending[queryID]; !exists { + break + } + } + r.pending[queryID] = p + r.mu.Unlock() + r.putSharedPending(ctx, queryID, p) + return queryID, p +} + +func (r *inlineRegistry) cachedContext(ctx context.Context, now time.Time, key inlineCacheKey) (domain.BotInlineResults, bool) { + if key.botUserID == 0 { + return domain.BotInlineResults{}, false + } + r.mu.Lock() + r.pruneLocked(now) + cached, ok := r.cache[key] + if ok { + if !cached.expiresAt.After(now) { + delete(r.cache, key) + } else { + out := cloneInlineResults(cached.results) + r.mu.Unlock() + clampInlineCacheTime(&out, cached.expiresAt.Sub(now)) + return out, true + } + } + r.mu.Unlock() + if r.shared == nil { + return domain.BotInlineResults{}, false + } + out, ok, remaining, err := r.shared.GetInlineCache(ctx, storeInlineCacheKey(key)) + if err != nil || !ok { + return domain.BotInlineResults{}, false + } + out = cloneInlineResults(out) + clampInlineCacheTime(&out, remaining) + return out, true +} + +func (r *inlineRegistry) registerCachedContext(ctx context.Context, now time.Time, botUserID, userID int64, peer domain.Peer, results domain.BotInlineResults) domain.BotInlineResults { + clone := cloneInlineResults(results) + clone.BotUserID = botUserID + clone.UserID = userID + clone.Peer = peer + p := &pendingInlineQuery{ + ch: make(chan domain.BotInlineResults, 1), + botUserID: botUserID, + userID: userID, + peer: peer, + createdAt: now, + results: &clone, + } + r.mu.Lock() + r.pruneLocked(now) + var queryID int64 + for { + queryID = randomNonZeroInt64() + if _, exists := r.pending[queryID]; !exists { + break + } + } + clone.QueryID = queryID + r.pending[queryID] = p + r.storeWebDocumentsLocked(now, clone) + r.mu.Unlock() + r.putSharedResult(ctx, clone, r.ttl) + r.putSharedWebDocuments(ctx, now, clone) + return clone +} + +func (r *inlineRegistry) deregisterIfUnansweredContext(ctx context.Context, queryID int64) { + deleteShared := false + r.mu.Lock() + if p, ok := r.pending[queryID]; ok && p.results == nil { + delete(r.pending, queryID) + deleteShared = true + } + r.mu.Unlock() + if deleteShared && r.shared != nil { + _ = r.shared.DeleteInlinePending(ctx, queryID) + } +} + +func (r *inlineRegistry) resolve(now time.Time, callerBotID, queryID int64, results domain.BotInlineResults) bool { + return r.resolveContext(context.Background(), now, callerBotID, queryID, results) +} + +func (r *inlineRegistry) resolveContext(ctx context.Context, now time.Time, callerBotID, queryID int64, results domain.BotInlineResults) bool { + r.mu.Lock() + r.pruneLocked(now) + p, ok := r.pending[queryID] + if ok && p.botUserID == callerBotID { + results.QueryID = queryID + results.BotUserID = p.botUserID + results.UserID = p.userID + results.Peer = p.peer + results.Query = p.cacheKey.query + if p.cacheKey.hasGeo { + results.Geo = &domain.MessageGeoPoint{ + Lat: p.cacheKey.geoLat, + Long: p.cacheKey.geoLong, + AccuracyRadius: p.cacheKey.geoAccuracy, + } + } + clone := cloneInlineResults(results) + p.results = &clone + r.storeWebDocumentsLocked(now, clone) + r.storeCacheLocked(now, p.cacheKey, clone) + r.mu.Unlock() + r.putSharedResolved(ctx, now, p.cacheKey, clone) + select { + case p.ch <- clone: + default: + } + return true + } + r.mu.Unlock() + if r.shared == nil { + return false + } + sharedPending, found, err := r.shared.GetInlinePending(ctx, queryID) + if err != nil || !found || sharedPending.BotUserID != callerBotID { + return false + } + results.QueryID = queryID + results.BotUserID = sharedPending.BotUserID + results.UserID = sharedPending.UserID + results.Peer = sharedPending.Peer + results.Query = sharedPending.CacheKey.Query + if sharedPending.CacheKey.HasGeo { + results.Geo = &domain.MessageGeoPoint{ + Lat: sharedPending.CacheKey.GeoLat, + Long: sharedPending.CacheKey.GeoLong, + AccuracyRadius: sharedPending.CacheKey.GeoAccuracy, + } + } + clone := cloneInlineResults(results) + r.putSharedResolved(ctx, now, inlineCacheKeyFromStore(sharedPending.CacheKey), clone) + return true +} + +func (r *inlineRegistry) resultsForQueryContext(ctx context.Context, now time.Time, userID, queryID int64) (domain.BotInlineResults, bool) { + r.mu.Lock() + r.pruneLocked(now) + p, ok := r.pending[queryID] + if ok && p.userID == userID && p.results != nil { + results := cloneInlineResults(*p.results) + r.mu.Unlock() + return results, true + } + r.mu.Unlock() + if r.shared == nil { + return domain.BotInlineResults{}, false + } + results, found, err := r.shared.GetInlineResult(ctx, queryID) + if err != nil || !found || results.UserID != userID { + return domain.BotInlineResults{}, false + } + return cloneInlineResults(results), true +} + +func (r *inlineRegistry) resultForSendContext(ctx context.Context, now time.Time, userID, queryID int64, id string) (domain.BotInlineResults, domain.BotInlineResult, bool) { + r.mu.Lock() + r.pruneLocked(now) + p, ok := r.pending[queryID] + if ok && p.userID == userID && p.results != nil { + results, result, found := inlineResultByID(*p.results, id) + r.mu.Unlock() + return results, result, found + } + r.mu.Unlock() + if r.shared == nil { + return domain.BotInlineResults{}, domain.BotInlineResult{}, false + } + results, found, err := r.shared.GetInlineResult(ctx, queryID) + if err != nil || !found || results.UserID != userID { + return domain.BotInlineResults{}, domain.BotInlineResult{}, false + } + return inlineResultByID(results, id) +} + +func (r *inlineRegistry) savePreparedInlineContext(ctx context.Context, now time.Time, botUserID, userID int64, result domain.BotInlineResult, peerTypes []string) (string, int) { + ttl := r.ttl + if ttl <= 0 { + ttl = botInlineQueryTTL + } + results := domain.BotInlineResults{ + BotUserID: botUserID, + UserID: userID, + Results: []domain.BotInlineResult{result}, + CacheTime: int(ttl.Seconds()), + PeerTypes: append([]string(nil), peerTypes...), + } + expiresAt := now.Add(ttl) + r.mu.Lock() + r.pruneLocked(now) + id := "" + for i := 0; i < 16; i++ { + candidate := randomPreparedInlineID() + if _, exists := r.prepared[candidate]; !exists { + id = candidate + break + } + } + if id == "" { + id = fmt.Sprintf("%016x%016x", uint64(now.UnixNano()), uint64(randomNonZeroInt64())) + } + msg := preparedInlineMessage{ + id: id, + botUserID: botUserID, + userID: userID, + results: cloneInlineResults(results), + expiresAt: expiresAt, + } + r.prepared[id] = msg + r.storeWebDocumentsLocked(now, msg.results) + r.mu.Unlock() + r.putSharedPrepared(ctx, msg, ttl) + r.putSharedWebDocuments(ctx, now, msg.results) + return id, int(expiresAt.Unix()) +} + +func (r *inlineRegistry) preparedInlineContext(ctx context.Context, now time.Time, userID, botUserID int64, id string) (domain.BotInlineResults, bool) { + var results domain.BotInlineResults + found := false + r.mu.Lock() + r.pruneLocked(now) + if msg, ok := r.prepared[id]; ok { + if msg.expiresAt.After(now) && msg.userID == userID && msg.botUserID == botUserID { + results = cloneInlineResults(msg.results) + found = true + } + } + r.mu.Unlock() + if found { + return r.registerCachedContext(ctx, now, botUserID, userID, domain.Peer{}, results), true + } + if r.shared == nil { + return domain.BotInlineResults{}, false + } + shared, ok, err := r.shared.GetPreparedInlineMessage(ctx, id) + if err != nil || !ok || shared.UserID != userID || shared.BotUserID != botUserID || !shared.ExpiresAt.After(now) { + return domain.BotInlineResults{}, false + } + results = cloneInlineResults(shared.Results) + return r.registerCachedContext(ctx, now, botUserID, userID, domain.Peer{}, results), true +} + +func (r *inlineRegistry) consume(queryID int64) { + r.consumeContext(context.Background(), queryID) +} + +func (r *inlineRegistry) consumeContext(ctx context.Context, queryID int64) { + r.mu.Lock() + delete(r.pending, queryID) + r.mu.Unlock() + if r.shared != nil { + _ = r.shared.DeleteInlinePending(ctx, queryID) + _ = r.shared.DeleteInlineResult(ctx, queryID) + } +} + +func (r *inlineRegistry) unansweredSize() int { + r.mu.Lock() + defer r.mu.Unlock() + count := 0 + for _, p := range r.pending { + if p.results == nil { + count++ + } + } + return count +} + +func (r *inlineRegistry) pruneLocked(now time.Time) { + if r.ttl > 0 { + for queryID, p := range r.pending { + if now.Sub(p.createdAt) > r.ttl { + delete(r.pending, queryID) + } + } + } + for key, cached := range r.cache { + if !cached.expiresAt.After(now) { + delete(r.cache, key) + } + } + for id, msg := range r.prepared { + if !msg.expiresAt.After(now) { + delete(r.prepared, id) + } + } + for key, document := range r.webDocuments { + if !document.expiresAt.After(now) { + delete(r.webDocuments, key) + } + } +} + +func (r *inlineRegistry) storeCacheLocked(now time.Time, key inlineCacheKey, results domain.BotInlineResults) { + ttl, ok := r.cacheTTL(results) + if key.botUserID == 0 || !ok || r.maxCacheEntries <= 0 { + return + } + cached := cloneInlineResults(results) + cached.QueryID = 0 + r.dropOldestCacheEntryLocked(now) + r.cache[key] = cachedInlineResults{ + results: cached, + expiresAt: now.Add(ttl), + storedAt: now, + } +} + +func (r *inlineRegistry) dropOldestCacheEntryLocked(now time.Time) { + if len(r.cache) < r.maxCacheEntries { + return + } + var ( + oldestKey inlineCacheKey + oldestAt time.Time + found bool + ) + for key, cached := range r.cache { + if !cached.expiresAt.After(now) { + delete(r.cache, key) + continue + } + if !found || cached.storedAt.Before(oldestAt) { + oldestKey = key + oldestAt = cached.storedAt + found = true + } + } + if len(r.cache) >= r.maxCacheEntries && found { + delete(r.cache, oldestKey) + } +} + +func (r *inlineRegistry) storeWebDocumentsLocked(now time.Time, results domain.BotInlineResults) { + expiresAt := now.Add(r.webDocumentTTL(results)) + for _, item := range results.Results { + r.storeWebDocumentLocked(item.Thumb, expiresAt) + r.storeWebDocumentLocked(item.Content, expiresAt) + } +} + +func (r *inlineRegistry) storeWebDocumentLocked(document *domain.BotInlineWebDocument, expiresAt time.Time) { + if document == nil || document.URL == "" || document.AccessHash == 0 { + return + } + key := inlineWebDocumentKey{url: document.URL, accessHash: document.AccessHash} + clone := cloneInlineWebDocument(*document) + existing := r.webDocuments[key] + r.webDocuments[key] = registeredInlineWebDocument{ + document: clone, + bytes: append([]byte(nil), existing.bytes...), + mimeType: existing.mimeType, + expiresAt: expiresAt, + } +} + +func (r *inlineRegistry) webDocumentForDownloadContext(ctx context.Context, now time.Time, url string, accessHash int64) (domain.BotInlineWebDocument, []byte, string, bool) { + r.mu.Lock() + r.pruneLocked(now) + registered, ok := r.webDocuments[inlineWebDocumentKey{url: url, accessHash: accessHash}] + if ok { + r.mu.Unlock() + return cloneInlineWebDocument(registered.document), append([]byte(nil), registered.bytes...), registered.mimeType, true + } + r.mu.Unlock() + if r.shared == nil { + return domain.BotInlineWebDocument{}, nil, "", false + } + shared, ok, err := r.shared.GetInlineWebDocument(ctx, store.InlineWebDocumentKey{URL: url, AccessHash: accessHash}) + if err != nil || !ok { + return domain.BotInlineWebDocument{}, nil, "", false + } + return cloneInlineWebDocument(shared.Document), append([]byte(nil), shared.Bytes...), shared.MimeType, true +} + +func (r *inlineRegistry) cacheWebDocumentBytesContext(ctx context.Context, now time.Time, url string, accessHash int64, data []byte, mimeType string) bool { + if len(data) > domain.MaxBotInlineWebSize { + return false + } + localUpdated := false + r.mu.Lock() + r.pruneLocked(now) + key := inlineWebDocumentKey{url: url, accessHash: accessHash} + registered, ok := r.webDocuments[key] + if ok { + registered.bytes = append([]byte(nil), data...) + registered.mimeType = mimeType + r.webDocuments[key] = registered + localUpdated = true + } + r.mu.Unlock() + if r.shared == nil { + return localUpdated + } + err := r.shared.PutInlineWebDocumentBytes(ctx, store.InlineWebDocumentKey{URL: url, AccessHash: accessHash}, append([]byte(nil), data...), mimeType, r.ttl) + return localUpdated || err == nil +} + +func (r *inlineRegistry) registerWebDocumentContext(ctx context.Context, now time.Time, document domain.BotInlineWebDocument, ttl time.Duration) { + if document.URL == "" || document.AccessHash == 0 { + return + } + if ttl <= 0 { + ttl = r.ttl + } + r.mu.Lock() + r.pruneLocked(now) + r.storeWebDocumentLocked(&document, now.Add(ttl)) + r.mu.Unlock() + r.putSharedWebDocument(ctx, &document, ttl) +} + +func (r *inlineRegistry) putSharedPending(ctx context.Context, queryID int64, p *pendingInlineQuery) { + if r.shared == nil { + return + } + _ = r.shared.PutInlinePending(ctx, store.InlinePending{ + QueryID: queryID, + BotUserID: p.botUserID, + UserID: p.userID, + Peer: p.peer, + CacheKey: storeInlineCacheKey(p.cacheKey), + CreatedAt: p.createdAt, + }, r.ttl) +} + +func (r *inlineRegistry) putSharedResolved(ctx context.Context, now time.Time, key inlineCacheKey, results domain.BotInlineResults) { + r.putSharedResult(ctx, results, r.ttl) + r.putSharedCache(ctx, key, results) + r.putSharedWebDocuments(ctx, now, results) +} + +func (r *inlineRegistry) putSharedResult(ctx context.Context, results domain.BotInlineResults, ttl time.Duration) { + if r.shared == nil || ttl <= 0 { + return + } + _ = r.shared.PutInlineResult(ctx, cloneInlineResults(results), ttl) +} + +func (r *inlineRegistry) putSharedCache(ctx context.Context, key inlineCacheKey, results domain.BotInlineResults) { + if r.shared == nil || key.botUserID == 0 { + return + } + ttl, ok := r.cacheTTL(results) + if !ok { + return + } + cached := cloneInlineResults(results) + cached.QueryID = 0 + _ = r.shared.PutInlineCache(ctx, storeInlineCacheKey(key), cached, ttl) +} + +func (r *inlineRegistry) putSharedWebDocuments(ctx context.Context, now time.Time, results domain.BotInlineResults) { + if r.shared == nil { + return + } + ttl := r.webDocumentTTL(results) + if ttl <= 0 { + return + } + for _, item := range results.Results { + r.putSharedWebDocument(ctx, item.Thumb, ttl) + r.putSharedWebDocument(ctx, item.Content, ttl) + } + _ = now +} + +func (r *inlineRegistry) putSharedWebDocument(ctx context.Context, document *domain.BotInlineWebDocument, ttl time.Duration) { + if r.shared == nil || document == nil || document.URL == "" || document.AccessHash == 0 || ttl <= 0 { + return + } + _ = r.shared.PutInlineWebDocument(ctx, cloneInlineWebDocument(*document), ttl) +} + +func (r *inlineRegistry) putSharedPrepared(ctx context.Context, msg preparedInlineMessage, ttl time.Duration) { + if r.shared == nil || ttl <= 0 { + return + } + _ = r.shared.PutPreparedInlineMessage(ctx, store.PreparedInlineMessage{ + ID: msg.id, + BotUserID: msg.botUserID, + UserID: msg.userID, + Results: cloneInlineResults(msg.results), + ExpiresAt: msg.expiresAt, + }, ttl) +} + +func (r *inlineRegistry) cacheTTL(results domain.BotInlineResults) (time.Duration, bool) { + if results.Private || results.CacheTime <= 0 || r.maxCacheTTL <= 0 { + return 0, false + } + ttl := time.Duration(results.CacheTime) * time.Second + if ttl <= 0 { + return 0, false + } + if ttl > r.maxCacheTTL { + ttl = r.maxCacheTTL + } + return ttl, true +} + +func (r *inlineRegistry) webDocumentTTL(results domain.BotInlineResults) time.Duration { + ttl := r.ttl + if cacheTTL, ok := r.cacheTTL(results); ok && cacheTTL > ttl { + ttl = cacheTTL + } + return ttl +} + +func inlineResultByID(results domain.BotInlineResults, id string) (domain.BotInlineResults, domain.BotInlineResult, bool) { + for _, item := range results.Results { + if item.ID == id { + return cloneInlineResults(results), cloneInlineResult(item), true + } + } + return domain.BotInlineResults{}, domain.BotInlineResult{}, false +} + +func randomPreparedInlineID() string { + return fmt.Sprintf("%016x%016x", uint64(randomNonZeroInt64()), uint64(randomNonZeroInt64())) +} + +func clampInlineCacheTime(results *domain.BotInlineResults, remaining time.Duration) { + seconds := int(remaining.Seconds()) + if seconds <= 0 { + seconds = 1 + } + if results.CacheTime <= 0 || seconds < results.CacheTime { + results.CacheTime = seconds + } +} + +func storeInlineCacheKey(key inlineCacheKey) store.InlineCacheKey { + return store.InlineCacheKey{ + BotUserID: key.botUserID, + UserID: key.userID, + Peer: key.peer, + Query: key.query, + Offset: key.offset, + HasGeo: key.hasGeo, + GeoLat: key.geoLat, + GeoLong: key.geoLong, + GeoAccuracy: key.geoAccuracy, + } +} + +func inlineCacheKeyFromStore(key store.InlineCacheKey) inlineCacheKey { + return inlineCacheKey{ + botUserID: key.BotUserID, + userID: key.UserID, + peer: key.Peer, + query: key.Query, + offset: key.Offset, + hasGeo: key.HasGeo, + geoLat: key.GeoLat, + geoLong: key.GeoLong, + geoAccuracy: key.GeoAccuracy, + } +} + +func cloneInlineResults(in domain.BotInlineResults) domain.BotInlineResults { + if in.Geo != nil { + geo := *in.Geo + in.Geo = &geo + } + if in.SwitchPM != nil { + switchPM := *in.SwitchPM + in.SwitchPM = &switchPM + } + if in.SwitchWeb != nil { + switchWeb := *in.SwitchWeb + in.SwitchWeb = &switchWeb + } + in.PeerTypes = append([]string(nil), in.PeerTypes...) + in.Results = append([]domain.BotInlineResult(nil), in.Results...) + for i := range in.Results { + in.Results[i] = cloneInlineResult(in.Results[i]) + } + return in +} + +func cloneInlineResult(in domain.BotInlineResult) domain.BotInlineResult { + in.Entities = append([]domain.MessageEntity(nil), in.Entities...) + if in.Thumb != nil { + thumb := cloneInlineWebDocument(*in.Thumb) + in.Thumb = &thumb + } + if in.Content != nil { + content := cloneInlineWebDocument(*in.Content) + in.Content = &content + } + in.ReplyMarkup = cloneInlineReplyMarkup(in.ReplyMarkup) + in.Media = cloneInlineMedia(in.Media) + return in +} + +func cloneInlineWebDocument(in domain.BotInlineWebDocument) domain.BotInlineWebDocument { + in.Attributes = append([]domain.DocumentAttribute(nil), in.Attributes...) + return in +} + +func cloneInlineReplyMarkup(in *domain.MessageReplyMarkup) *domain.MessageReplyMarkup { + if in == nil { + return nil + } + out := domain.MessageReplyMarkup{} + if in.Inline != nil { + out.Inline = make([][]domain.MarkupButton, len(in.Inline)) + for i, row := range in.Inline { + out.Inline[i] = make([]domain.MarkupButton, len(row)) + for j, button := range row { + out.Inline[i][j] = button + out.Inline[i][j].Data = append([]byte(nil), button.Data...) + } + } + } + return &out +} + +func cloneInlineMedia(in *domain.MessageMedia) *domain.MessageMedia { + if in == nil { + return nil + } + out := *in + if in.Photo != nil { + photo := *in.Photo + photo.FileReference = append([]byte(nil), in.Photo.FileReference...) + photo.Sizes = cloneInlinePhotoSizes(in.Photo.Sizes) + out.Photo = &photo + } + if in.Document != nil { + doc := *in.Document + doc.FileReference = append([]byte(nil), in.Document.FileReference...) + doc.Attributes = append([]domain.DocumentAttribute(nil), in.Document.Attributes...) + doc.Thumbs = cloneInlinePhotoSizes(in.Document.Thumbs) + out.Document = &doc + } + if in.Geo != nil { + geo := *in.Geo + out.Geo = &geo + } + if in.Venue != nil { + venue := *in.Venue + out.Venue = &venue + } + if in.Contact != nil { + contact := *in.Contact + out.Contact = &contact + } + return &out +} + +func cloneInlinePhotoSizes(in []domain.PhotoSize) []domain.PhotoSize { + if len(in) == 0 { + return nil + } + out := append([]domain.PhotoSize(nil), in...) + for i := range out { + out[i].Bytes = append([]byte(nil), in[i].Bytes...) + out[i].Sizes = append([]int(nil), in[i].Sizes...) + out[i].BackgroundColors = append([]int(nil), in[i].BackgroundColors...) + } + return out +} diff --git a/internal/rpc/inline_registry_shared_test.go b/internal/rpc/inline_registry_shared_test.go new file mode 100644 index 00000000..8ed72aae --- /dev/null +++ b/internal/rpc/inline_registry_shared_test.go @@ -0,0 +1,396 @@ +package rpc + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/gotd/td/clock" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestInlineRegistrySharedResolveUnblocksRemoteWaitAndSend(t *testing.T) { + ctx := context.Background() + shared := newTestInlineRegistryStore() + now := time.Now() + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 3001} + cacheKey := inlineCacheKey{botUserID: 1001, userID: 2001, peer: peer, query: "shape"} + + waitingRegistry := newInlineRegistry(botInlineQueryTTL, shared) + answeringRegistry := newInlineRegistry(botInlineQueryTTL, shared) + queryID, pending := waitingRegistry.registerWithCacheKeyContext(ctx, now, 1001, 2001, peer, cacheKey) + router := &Router{clock: clock.System, inlines: waitingRegistry} + + waitCtx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + gotCh := make(chan struct { + results domain.BotInlineResults + err error + }, 1) + go func() { + results, err := router.awaitInlineBotResults(waitCtx, pending, 2001, queryID) + gotCh <- struct { + results domain.BotInlineResults + err error + }{results: results, err: err} + }() + + ok := answeringRegistry.resolveContext(ctx, now, 1001, queryID, domain.BotInlineResults{ + CacheTime: 60, + Results: []domain.BotInlineResult{{ID: "article-1", Type: "article", Message: "hello"}}, + }) + if !ok { + t.Fatal("remote resolve = false, want true") + } + + select { + case got := <-gotCh: + if got.err != nil { + t.Fatalf("remote wait: %v", got.err) + } + if got.results.QueryID != queryID || got.results.UserID != 2001 || got.results.BotUserID != 1001 || got.results.Peer != peer { + t.Fatalf("remote results metadata = %+v", got.results) + } + case <-time.After(2 * time.Second): + t.Fatal("remote wait did not resolve") + } + + results, item, ok := answeringRegistry.resultForSendContext(ctx, now, 2001, queryID, "article-1") + if !ok { + t.Fatal("remote resultForSend = false, want true") + } + if results.Peer != peer || item.Message != "hello" { + t.Fatalf("remote send recovered results=%+v item=%+v", results, item) + } +} + +func TestInlineRegistrySharedCacheHitCreatesFreshSendableQuery(t *testing.T) { + ctx := context.Background() + shared := newTestInlineRegistryStore() + now := time.Now() + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 3002} + cacheKey := inlineCacheKey{botUserID: 1002, userID: 2002, peer: peer, query: "cached", offset: "p1"} + + ownerRegistry := newInlineRegistry(botInlineQueryTTL, shared) + queryID, _ := ownerRegistry.registerWithCacheKeyContext(ctx, now, 1002, 2002, peer, cacheKey) + if !ownerRegistry.resolveContext(ctx, now, 1002, queryID, domain.BotInlineResults{ + CacheTime: 60, + Results: []domain.BotInlineResult{{ID: "cached-1", Type: "article", Message: "cached hello"}}, + }) { + t.Fatal("resolve cached result = false, want true") + } + + cacheRegistry := newInlineRegistry(botInlineQueryTTL, shared) + cached, ok := cacheRegistry.cachedContext(ctx, now.Add(time.Second), cacheKey) + if !ok { + t.Fatal("shared cache miss, want hit") + } + if cached.QueryID != 0 || cached.CacheTime <= 0 || cached.CacheTime > 60 { + t.Fatalf("shared cached metadata = %+v", cached) + } + fresh := cacheRegistry.registerCachedContext(ctx, now.Add(time.Second), 1002, 2002, peer, cached) + if fresh.QueryID == 0 || fresh.QueryID == queryID { + t.Fatalf("fresh query_id = %d, old %d", fresh.QueryID, queryID) + } + _, item, ok := ownerRegistry.resultForSendContext(ctx, now.Add(time.Second), 2002, fresh.QueryID, "cached-1") + if !ok || item.Message != "cached hello" { + t.Fatalf("fresh shared resultForSend ok=%v item=%+v", ok, item) + } +} + +func TestInlineRegistrySharedWebDocumentAndBytes(t *testing.T) { + ctx := context.Background() + shared := newTestInlineRegistryStore() + now := time.Now() + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 3003} + document := domain.BotInlineWebDocument{ + URL: "https://example.test/inline.png", + AccessHash: 9001, + Size: 6, + MimeType: "image/png", + } + + writerRegistry := newInlineRegistry(botInlineQueryTTL, shared) + queryID, _ := writerRegistry.registerWithCacheKeyContext(ctx, now, 1003, 2003, peer, inlineCacheKey{botUserID: 1003, userID: 2003, peer: peer, query: "web"}) + if !writerRegistry.resolveContext(ctx, now, 1003, queryID, domain.BotInlineResults{ + CacheTime: 60, + Results: []domain.BotInlineResult{{ + ID: "web-1", + Type: "photo", + Content: &document, + MediaAuto: true, + }}, + }) { + t.Fatal("resolve web document result = false, want true") + } + + readerRegistry := newInlineRegistry(botInlineQueryTTL, shared) + gotDoc, data, mime, ok := readerRegistry.webDocumentForDownloadContext(ctx, now.Add(time.Second), document.URL, document.AccessHash) + if !ok { + t.Fatal("shared web document miss, want hit") + } + if gotDoc.URL != document.URL || gotDoc.AccessHash != document.AccessHash || len(data) != 0 || mime != "" { + t.Fatalf("shared web document = %+v bytes=%q mime=%q", gotDoc, data, mime) + } + if ok := readerRegistry.cacheWebDocumentBytesContext(ctx, now.Add(time.Second), document.URL, document.AccessHash, []byte("abcdef"), "image/png"); !ok { + t.Fatal("shared web document byte cache = false, want true") + } + + thirdRegistry := newInlineRegistry(botInlineQueryTTL, shared) + _, data, mime, ok = thirdRegistry.webDocumentForDownloadContext(ctx, now.Add(2*time.Second), document.URL, document.AccessHash) + if !ok || string(data) != "abcdef" || mime != "image/png" { + t.Fatalf("shared web document cached bytes ok=%v bytes=%q mime=%q", ok, data, mime) + } + if _, _, _, ok := thirdRegistry.webDocumentForDownloadContext(ctx, now.Add(2*time.Second), document.URL, document.AccessHash+1); ok { + t.Fatal("shared web document hash mismatch hit, want miss") + } +} + +func TestInlineRegistrySharedPreparedInlineMessage(t *testing.T) { + ctx := context.Background() + shared := newTestInlineRegistryStore() + now := time.Now() + writer := newInlineRegistry(botInlineQueryTTL, shared) + reader := newInlineRegistry(botInlineQueryTTL, shared) + result := domain.BotInlineResult{ID: "prepared-shared", Type: "article", Message: "shared prepared"} + + id, expireDate := writer.savePreparedInlineContext(ctx, now, 1001, 2001, result, []string{store.InlineQueryPeerTypePM}) + if id == "" || expireDate <= int(now.Unix()) { + t.Fatalf("prepared save = id %q expire %d", id, expireDate) + } + got, ok := reader.preparedInlineContext(ctx, now.Add(time.Second), 2001, 1001, id) + if !ok { + t.Fatal("shared prepared inline not found") + } + if got.QueryID == 0 || got.UserID != 2001 || got.BotUserID != 1001 || len(got.PeerTypes) != 1 || got.PeerTypes[0] != store.InlineQueryPeerTypePM { + t.Fatalf("shared prepared results = %+v", got) + } + _, item, ok := reader.resultForSendContext(ctx, now.Add(time.Second), 2001, got.QueryID, "prepared-shared") + if !ok || item.Message != "shared prepared" { + t.Fatalf("shared prepared send lookup ok=%v item=%+v", ok, item) + } +} + +type testInlineRegistryStore struct { + mu sync.Mutex + pending map[int64]testInlineValue[store.InlinePending] + results map[int64]testInlineValue[domain.BotInlineResults] + cache map[store.InlineCacheKey]testInlineValue[domain.BotInlineResults] + prepared map[string]testInlineValue[store.PreparedInlineMessage] + web map[store.InlineWebDocumentKey]testInlineValue[store.InlineWebDocumentEntry] + webview map[int64]testInlineValue[store.WebViewSession] + webviewByBotQuery map[string]int64 +} + +type testInlineValue[T any] struct { + value T + expiresAt time.Time +} + +func newTestInlineRegistryStore() *testInlineRegistryStore { + return &testInlineRegistryStore{ + pending: make(map[int64]testInlineValue[store.InlinePending]), + results: make(map[int64]testInlineValue[domain.BotInlineResults]), + cache: make(map[store.InlineCacheKey]testInlineValue[domain.BotInlineResults]), + prepared: make(map[string]testInlineValue[store.PreparedInlineMessage]), + web: make(map[store.InlineWebDocumentKey]testInlineValue[store.InlineWebDocumentEntry]), + webview: make(map[int64]testInlineValue[store.WebViewSession]), + webviewByBotQuery: make(map[string]int64), + } +} + +func testInlineExpires(ttl time.Duration) time.Time { + if ttl <= 0 { + return time.Time{} + } + return time.Now().Add(ttl) +} + +func testInlineExpired(expiresAt time.Time) bool { + return !expiresAt.IsZero() && !expiresAt.After(time.Now()) +} + +func (s *testInlineRegistryStore) PutInlinePending(_ context.Context, pending store.InlinePending, ttl time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + s.pending[pending.QueryID] = testInlineValue[store.InlinePending]{value: pending, expiresAt: testInlineExpires(ttl)} + return nil +} + +func (s *testInlineRegistryStore) GetInlinePending(_ context.Context, queryID int64) (store.InlinePending, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.pending[queryID] + if !ok || testInlineExpired(v.expiresAt) { + delete(s.pending, queryID) + return store.InlinePending{}, false, nil + } + return v.value, true, nil +} + +func (s *testInlineRegistryStore) DeleteInlinePending(_ context.Context, queryID int64) error { + s.mu.Lock() + delete(s.pending, queryID) + s.mu.Unlock() + return nil +} + +func (s *testInlineRegistryStore) PutInlineResult(_ context.Context, results domain.BotInlineResults, ttl time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + s.results[results.QueryID] = testInlineValue[domain.BotInlineResults]{value: cloneInlineResults(results), expiresAt: testInlineExpires(ttl)} + return nil +} + +func (s *testInlineRegistryStore) GetInlineResult(_ context.Context, queryID int64) (domain.BotInlineResults, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.results[queryID] + if !ok || testInlineExpired(v.expiresAt) { + delete(s.results, queryID) + return domain.BotInlineResults{}, false, nil + } + return cloneInlineResults(v.value), true, nil +} + +func (s *testInlineRegistryStore) DeleteInlineResult(_ context.Context, queryID int64) error { + s.mu.Lock() + delete(s.results, queryID) + s.mu.Unlock() + return nil +} + +func (s *testInlineRegistryStore) PutInlineCache(_ context.Context, key store.InlineCacheKey, results domain.BotInlineResults, ttl time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + results.QueryID = 0 + s.cache[key] = testInlineValue[domain.BotInlineResults]{value: cloneInlineResults(results), expiresAt: testInlineExpires(ttl)} + return nil +} + +func (s *testInlineRegistryStore) GetInlineCache(_ context.Context, key store.InlineCacheKey) (domain.BotInlineResults, bool, time.Duration, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.cache[key] + if !ok || testInlineExpired(v.expiresAt) { + delete(s.cache, key) + return domain.BotInlineResults{}, false, 0, nil + } + remaining := time.Until(v.expiresAt) + if v.expiresAt.IsZero() || remaining <= 0 { + remaining = time.Second + } + return cloneInlineResults(v.value), true, remaining, nil +} + +func (s *testInlineRegistryStore) PutInlineWebDocument(_ context.Context, document domain.BotInlineWebDocument, ttl time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + key := store.InlineWebDocumentKey{URL: document.URL, AccessHash: document.AccessHash} + entry := store.InlineWebDocumentEntry{Document: cloneInlineWebDocument(document)} + if existing, ok := s.web[key]; ok && !testInlineExpired(existing.expiresAt) { + entry.Bytes = append([]byte(nil), existing.value.Bytes...) + entry.MimeType = existing.value.MimeType + } + s.web[key] = testInlineValue[store.InlineWebDocumentEntry]{value: entry, expiresAt: testInlineExpires(ttl)} + return nil +} + +func (s *testInlineRegistryStore) GetInlineWebDocument(_ context.Context, key store.InlineWebDocumentKey) (store.InlineWebDocumentEntry, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.web[key] + if !ok || testInlineExpired(v.expiresAt) { + delete(s.web, key) + return store.InlineWebDocumentEntry{}, false, nil + } + entry := v.value + entry.Document = cloneInlineWebDocument(entry.Document) + entry.Bytes = append([]byte(nil), entry.Bytes...) + return entry, true, nil +} + +func (s *testInlineRegistryStore) PutInlineWebDocumentBytes(_ context.Context, key store.InlineWebDocumentKey, data []byte, mimeType string, ttl time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.web[key] + if !ok || testInlineExpired(v.expiresAt) { + delete(s.web, key) + return errors.New("inline web document missing") + } + v.value.Bytes = append([]byte(nil), data...) + v.value.MimeType = mimeType + if v.expiresAt.IsZero() { + v.expiresAt = testInlineExpires(ttl) + } + s.web[key] = v + return nil +} + +func (s *testInlineRegistryStore) PutPreparedInlineMessage(_ context.Context, msg store.PreparedInlineMessage, ttl time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + msg.Results = cloneInlineResults(msg.Results) + s.prepared[msg.ID] = testInlineValue[store.PreparedInlineMessage]{value: msg, expiresAt: testInlineExpires(ttl)} + return nil +} + +func (s *testInlineRegistryStore) GetPreparedInlineMessage(_ context.Context, id string) (store.PreparedInlineMessage, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.prepared[id] + if !ok || testInlineExpired(v.expiresAt) { + delete(s.prepared, id) + return store.PreparedInlineMessage{}, false, nil + } + msg := v.value + msg.Results = cloneInlineResults(msg.Results) + return msg, true, nil +} + +func (s *testInlineRegistryStore) PutWebViewSession(_ context.Context, session store.WebViewSession, ttl time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + s.webview[session.QueryID] = testInlineValue[store.WebViewSession]{value: cloneWebViewSession(session), expiresAt: testInlineExpires(ttl)} + s.webviewByBotQuery[session.BotQueryID] = session.QueryID + return nil +} + +func (s *testInlineRegistryStore) GetWebViewSession(_ context.Context, queryID int64) (store.WebViewSession, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.webview[queryID] + if !ok || testInlineExpired(v.expiresAt) { + delete(s.webview, queryID) + return store.WebViewSession{}, false, nil + } + return cloneWebViewSession(v.value), true, nil +} + +func (s *testInlineRegistryStore) GetWebViewSessionByBotQuery(_ context.Context, botQueryID string) (store.WebViewSession, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + queryID, ok := s.webviewByBotQuery[botQueryID] + if !ok { + return store.WebViewSession{}, false, nil + } + v, ok := s.webview[queryID] + if !ok || testInlineExpired(v.expiresAt) { + delete(s.webview, queryID) + delete(s.webviewByBotQuery, botQueryID) + return store.WebViewSession{}, false, nil + } + return cloneWebViewSession(v.value), true, nil +} + +func (s *testInlineRegistryStore) DeleteWebViewSession(_ context.Context, queryID int64, botQueryID string) error { + s.mu.Lock() + delete(s.webview, queryID) + delete(s.webviewByBotQuery, botQueryID) + s.mu.Unlock() + return nil +} diff --git a/internal/rpc/langpack.go b/internal/rpc/langpack.go index 42f127c0..70ca5813 100644 --- a/internal/rpc/langpack.go +++ b/internal/rpc/langpack.go @@ -4,28 +4,31 @@ import ( "context" "strings" - "github.com/gotd/td/bin" "github.com/gotd/td/tg" ) -const ( - legacyLangpackGetLangPackTypeID uint32 = 0x9ab5c58e - legacyLangpackGetStringsTypeID uint32 = 0x2e1ee318 - legacyLangpackGetLanguagesTypeID uint32 = 0x800fd57d - - maxLegacyLangpackStringKeys = 512 -) - // registerLangpack 注册 langpack.* RPC handler。 +// +// 老客户端(DrKLO)发的是不带 lang_pack 参数的旧构造器,已由 layerwire 入站升级为 227 +// 形态并把 lang_pack 置空;故这里 lang_pack 为空时回退到按 client 信息派生(langPackFromClient), +// 与历史 handleLegacyLangpack* 的行为一致。 func (r *Router) registerLangpack(d *tg.ServerDispatcher) { d.OnLangpackGetLanguages(func(ctx context.Context, langPack string) ([]tg.LangPackLanguage, error) { return r.langpackLanguages(ctx, langPack), nil }) + d.OnLangpackGetLanguage(func(ctx context.Context, req *tg.LangpackGetLanguageRequest) (*tg.LangPackLanguage, error) { + if req == nil { + return nil, inputConstructorInvalidErr() + } + lang := r.langpackLanguage(ctx, req.LangPack, req.LangCode) + return &lang, nil + }) d.OnLangpackGetLangPack(func(ctx context.Context, req *tg.LangpackGetLangPackRequest) (*tg.LangPackDifference, error) { + langPack := langPackOrClient(ctx, req.LangPack) if r.deps.LangPack == nil { return &tg.LangPackDifference{LangCode: req.LangCode}, nil } - pack, err := r.deps.LangPack.GetLangPack(ctx, req.LangPack, req.LangCode) + pack, err := r.deps.LangPack.GetLangPack(ctx, langPack, req.LangCode) if err != nil { return nil, internalErr() } @@ -35,7 +38,7 @@ func (r *Router) registerLangpack(d *tg.ServerDispatcher) { if r.deps.LangPack == nil { return &tg.LangPackDifference{LangCode: req.LangCode, FromVersion: req.FromVersion}, nil } - pack, err := r.deps.LangPack.GetDifference(ctx, req.LangPack, req.LangCode, req.FromVersion) + pack, err := r.deps.LangPack.GetDifference(ctx, langPackOrClient(ctx, req.LangPack), req.LangCode, req.FromVersion) if err != nil { return nil, internalErr() } @@ -45,7 +48,7 @@ func (r *Router) registerLangpack(d *tg.ServerDispatcher) { if r.deps.LangPack == nil { return nil, nil } - pack, err := r.deps.LangPack.GetStrings(ctx, req.LangPack, req.LangCode, req.Keys) + pack, err := r.deps.LangPack.GetStrings(ctx, langPackOrClient(ctx, req.LangPack), req.LangCode, req.Keys) if err != nil { return nil, internalErr() } @@ -53,64 +56,35 @@ func (r *Router) registerLangpack(d *tg.ServerDispatcher) { }) } -func (r *Router) handleLegacyLangpackGetLangPack(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { - if err := b.ConsumeID(legacyLangpackGetLangPackTypeID); err != nil { - return nil, err +// langPackOrClient 返回请求里的 lang_pack;为空(老客户端经 layerwire 升级而来)时按 client 派生。 +func langPackOrClient(ctx context.Context, langPack string) string { + if langPack != "" { + return langPack } - langCode, err := b.String() - if err != nil { - return nil, err - } - langPack := langPackFromClient(ctx) - if r.deps.LangPack == nil { - return &tg.LangPackDifference{LangCode: langCode}, nil - } - pack, err := r.deps.LangPack.GetLangPack(ctx, langPack, langCode) - if err != nil { - return nil, internalErr() - } - return tgLangPackDifference(pack), nil + return langPackFromClient(ctx) } -func (r *Router) handleLegacyLangpackGetStrings(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { - if err := b.ConsumeID(legacyLangpackGetStringsTypeID); err != nil { - return nil, err - } - langCode, err := b.String() - if err != nil { - return nil, err - } - headerLen, err := b.VectorHeader() - if err != nil { - return nil, err - } - if headerLen > maxLegacyLangpackStringKeys { - return nil, limitInvalidErr() - } - keys := make([]string, 0, headerLen) - for i := 0; i < headerLen; i++ { - key, err := b.String() - if err != nil { - return nil, err +func (r *Router) langpackLanguage(ctx context.Context, langPack, langCode string) tg.LangPackLanguage { + if langCode == "" { + if info, ok := ClientInfoFrom(ctx); ok && info.LangCode != "" { + langCode = info.LangCode + } else { + langCode = "en" } - keys = append(keys, key) } - langPack := langPackFromClient(ctx) - if r.deps.LangPack == nil { - return &tg.LangPackStringClassVector{}, nil + langCode = strings.ToLower(langCode) + languages := r.langpackLanguages(ctx, langPack) + for _, lang := range languages { + if strings.ToLower(lang.LangCode) == langCode { + return lang + } } - pack, err := r.deps.LangPack.GetStrings(ctx, langPack, langCode, keys) - if err != nil { - return nil, internalErr() + for _, lang := range languages { + if strings.ToLower(lang.PluralCode) == langCode { + return lang + } } - return &tg.LangPackStringClassVector{Elems: tgLangPackStrings(pack.Strings)}, nil -} - -func (r *Router) handleLegacyLangpackGetLanguages(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { - if err := b.ConsumeID(legacyLangpackGetLanguagesTypeID); err != nil { - return nil, err - } - return &tg.LangPackLanguageVector{Elems: r.langpackLanguages(ctx, "")}, nil + return languages[0] } func (r *Router) langpackLanguages(ctx context.Context, langPack string) []tg.LangPackLanguage { diff --git a/internal/rpc/langpack_compat_test.go b/internal/rpc/langpack_compat_test.go index bb37551b..852d7a22 100644 --- a/internal/rpc/langpack_compat_test.go +++ b/internal/rpc/langpack_compat_test.go @@ -24,7 +24,7 @@ func TestLangpackGetLanguagesCurrentAndLegacy(t *testing.T) { t.Run("legacy android no args", func(t *testing.T) { var in bin.Buffer - in.PutID(legacyLangpackGetLanguagesTypeID) + in.PutID(0x800fd57d) ctx := WithClientInfo(context.Background(), ClientInfo{ DeviceModel: "Android", AppVersion: "12.7.3", @@ -34,11 +34,35 @@ func TestLangpackGetLanguagesCurrentAndLegacy(t *testing.T) { }) } +func TestLangpackGetLanguage(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + + var in bin.Buffer + if err := (&tg.LangpackGetLanguageRequest{LangPack: "tdesktop", LangCode: "zh-hans"}).Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + enc, err := r.Dispatch(context.Background(), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch langpack.getLanguage: %v", err) + } + var out bin.Buffer + if err := enc.Encode(&out); err != nil { + t.Fatalf("encode response: %v", err) + } + var lang tg.LangPackLanguage + if err := lang.Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if lang.LangCode != "zh-hans" || lang.PluralCode != "zh" { + t.Fatalf("language = %+v, want zh-hans", lang) + } +} + func TestLegacyLangpackGetLangPack(t *testing.T) { r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) var in bin.Buffer - in.PutID(legacyLangpackGetLangPackTypeID) + in.PutID(0x9ab5c58e) in.PutString("en") enc, err := r.Dispatch(androidClientContext(), [8]byte{}, 0, &in) if err != nil { @@ -61,7 +85,7 @@ func TestLegacyLangpackGetStrings(t *testing.T) { r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) var in bin.Buffer - in.PutID(legacyLangpackGetStringsTypeID) + in.PutID(0x2e1ee318) in.PutString("en") in.PutVectorHeader(1) in.PutString("lng_intro_about") @@ -85,7 +109,7 @@ func TestLegacyUpdatesGetDifference(t *testing.T) { var flags bin.Fields flags.Set(0) var in bin.Buffer - in.PutID(legacyUpdatesGetDifferenceTypeID) + in.PutID(0x25939651) if err := flags.Encode(&in); err != nil { t.Fatalf("encode flags: %v", err) } @@ -112,7 +136,7 @@ func TestLegacyAccountRegisterDevice(t *testing.T) { r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) var in bin.Buffer - in.PutID(legacyAccountRegisterDeviceTypeID) + in.PutID(0x637ea878) in.PutInt(2) in.PutString("android-fcm-token") diff --git a/internal/rpc/login_token.go b/internal/rpc/login_token.go new file mode 100644 index 00000000..d2e94e90 --- /dev/null +++ b/internal/rpc/login_token.go @@ -0,0 +1,225 @@ +package rpc + +import ( + "crypto/rand" + "fmt" + "sync" + "time" + + "telesrv/internal/domain" +) + +const ( + loginTokenTTL = 30 * time.Second + loginTokenBytes = 32 + loginTokenMaxRecords = 2048 +) + +type loginTokenTarget struct { + rawAuthKeyID [8]byte + authKeyID [8]byte + sessionID int64 +} + +type loginTokenExport struct { + token []byte + expires time.Time + accepted bool + acceptedAuth domain.Authorization +} + +type loginTokenAcceptStart struct { + target loginTokenTarget + authz domain.Authorization +} + +type loginTokenRecord struct { + token []byte + expires time.Time + target loginTokenTarget + authz domain.Authorization + exceptIDs map[int64]struct{} + + accepting bool + accepted bool + acceptedUserID int64 + acceptedAuth domain.Authorization + acceptedAt time.Time +} + +type loginTokenRegistry struct { + mu sync.Mutex + byToken map[string]*loginTokenRecord + byTarget map[loginTokenTarget]*loginTokenRecord +} + +func newLoginTokenRegistry() *loginTokenRegistry { + return &loginTokenRegistry{ + byToken: make(map[string]*loginTokenRecord), + byTarget: make(map[loginTokenTarget]*loginTokenRecord), + } +} + +func (r *loginTokenRegistry) export(now time.Time, target loginTokenTarget, authz domain.Authorization, exceptIDs []int64) (loginTokenExport, error) { + if r == nil { + return loginTokenExport{}, fmt.Errorf("login token registry is nil") + } + r.mu.Lock() + defer r.mu.Unlock() + r.cleanupExpiredLocked(now) + + if rec := r.byTarget[target]; rec != nil && rec.expires.After(now) { + if rec.accepted { + return loginTokenExport{accepted: true, acceptedAuth: rec.acceptedAuth}, nil + } + return loginTokenExport{token: append([]byte(nil), rec.token...), expires: rec.expires}, nil + } + if len(r.byToken) >= loginTokenMaxRecords { + r.evictOldestLocked() + } + + token, err := randomLoginTokenLocked(r.byToken) + if err != nil { + return loginTokenExport{}, err + } + rec := &loginTokenRecord{ + token: token, + expires: now.Add(loginTokenTTL), + target: target, + authz: authz, + exceptIDs: loginTokenExceptSet(exceptIDs), + } + r.byToken[string(token)] = rec + r.byTarget[target] = rec + return loginTokenExport{token: append([]byte(nil), token...), expires: rec.expires}, nil +} + +func (r *loginTokenRegistry) lookup(now time.Time, token []byte) (loginTokenExport, error) { + if r == nil || len(token) == 0 { + return loginTokenExport{}, authTokenInvalidErr() + } + r.mu.Lock() + defer r.mu.Unlock() + rec := r.byToken[string(token)] + if rec == nil { + r.cleanupExpiredLocked(now) + return loginTokenExport{}, authTokenInvalidErr() + } + if !rec.expires.After(now) { + r.deleteLocked(rec) + return loginTokenExport{}, authTokenExpiredErr() + } + r.cleanupExpiredLocked(now) + if rec.accepted { + return loginTokenExport{accepted: true, acceptedAuth: rec.acceptedAuth}, nil + } + return loginTokenExport{token: append([]byte(nil), rec.token...), expires: rec.expires}, nil +} + +func (r *loginTokenRegistry) beginAccept(now time.Time, token []byte, userID int64) (loginTokenAcceptStart, error) { + if r == nil || len(token) == 0 { + return loginTokenAcceptStart{}, authTokenInvalidErr() + } + r.mu.Lock() + defer r.mu.Unlock() + rec := r.byToken[string(token)] + if rec == nil { + r.cleanupExpiredLocked(now) + return loginTokenAcceptStart{}, authTokenInvalidErr() + } + if !rec.expires.After(now) { + r.deleteLocked(rec) + return loginTokenAcceptStart{}, authTokenExpiredErr() + } + r.cleanupExpiredLocked(now) + if rec.accepted || rec.accepting { + return loginTokenAcceptStart{}, authTokenAlreadyAcceptedErr() + } + if _, denied := rec.exceptIDs[userID]; denied { + return loginTokenAcceptStart{}, authTokenAlreadyAcceptedErr() + } + rec.accepting = true + return loginTokenAcceptStart{target: rec.target, authz: rec.authz}, nil +} + +func (r *loginTokenRegistry) finishAccept(now time.Time, token []byte, userID int64, acceptedAuth domain.Authorization) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + rec := r.byToken[string(token)] + if rec == nil { + return + } + rec.accepting = false + rec.accepted = true + rec.acceptedUserID = userID + rec.acceptedAuth = acceptedAuth + rec.acceptedAt = now + r.byTarget[rec.target] = rec +} + +func (r *loginTokenRegistry) failAccept(token []byte) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + if rec := r.byToken[string(token)]; rec != nil { + rec.accepting = false + } +} + +func (r *loginTokenRegistry) cleanupExpiredLocked(now time.Time) { + for _, rec := range r.byToken { + if !rec.expires.After(now) { + r.deleteLocked(rec) + } + } +} + +func (r *loginTokenRegistry) deleteLocked(rec *loginTokenRecord) { + delete(r.byToken, string(rec.token)) + if r.byTarget[rec.target] == rec { + delete(r.byTarget, rec.target) + } +} + +func (r *loginTokenRegistry) evictOldestLocked() { + var oldest *loginTokenRecord + for _, rec := range r.byToken { + if oldest == nil || rec.expires.Before(oldest.expires) { + oldest = rec + } + } + if oldest != nil { + r.deleteLocked(oldest) + } +} + +func randomLoginTokenLocked(existing map[string]*loginTokenRecord) ([]byte, error) { + for i := 0; i < 4; i++ { + token := make([]byte, loginTokenBytes) + if _, err := rand.Read(token); err != nil { + return nil, fmt.Errorf("generate login token: %w", err) + } + if existing[string(token)] == nil { + return token, nil + } + } + return nil, fmt.Errorf("generate login token: collision") +} + +func loginTokenExceptSet(ids []int64) map[int64]struct{} { + if len(ids) == 0 { + return nil + } + out := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id != 0 { + out[id] = struct{}{} + } + } + return out +} diff --git a/internal/rpc/media_count.go b/internal/rpc/media_count.go new file mode 100644 index 00000000..53f694b5 --- /dev/null +++ b/internal/rpc/media_count.go @@ -0,0 +1,102 @@ +package rpc + +import ( + "context" + "fmt" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) mediaCountsForPeer(ctx context.Context, userID int64, peer domain.Peer) (domain.MediaCategoryCounts, error) { + if userID == 0 || peer.ID == 0 { + return domain.MediaCategoryCounts{}, nil + } + key := fmt.Sprintf("%d:%s:%d", userID, peer.Type, peer.ID) + v, err, _ := r.mediaCountSF.Do(key, func() (any, error) { + counts, err := r.loadMediaCountsForPeer(ctx, userID, peer) + if err != nil { + return nil, err + } + return cloneMediaCategoryCounts(counts), nil + }) + if err != nil { + return nil, err + } + counts, _ := v.(domain.MediaCategoryCounts) + return cloneMediaCategoryCounts(counts), nil +} + +func (r *Router) loadMediaCountsForPeer(ctx context.Context, userID int64, peer domain.Peer) (domain.MediaCategoryCounts, error) { + switch peer.Type { + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return domain.MediaCategoryCounts{}, nil + } + return r.deps.Channels.CountChannelMediaCategories(ctx, userID, peer.ID) + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return domain.MediaCategoryCounts{}, nil + } + return r.deps.Messages.CountPrivateMediaCategories(ctx, userID, peer.ID) + default: + return domain.MediaCategoryCounts{}, nil + } +} + +func cloneMediaCategoryCounts(in domain.MediaCategoryCounts) domain.MediaCategoryCounts { + if len(in) == 0 { + return domain.MediaCategoryCounts{} + } + out := make(domain.MediaCategoryCounts, len(in)) + for category, count := range in { + out[category] = count + } + return out +} + +func mediaSearchCountOnlyRequest(req *tg.MessagesSearchRequest) bool { + if req == nil { + return false + } + if req.Q != "" || req.Limit != 0 || req.OffsetID != 0 || req.AddOffset != 0 || + req.MaxID != 0 || req.MinID != 0 || req.MinDate != 0 || req.MaxDate != 0 || req.Hash != 0 { + return false + } + if _, ok := req.GetFromID(); ok { + return false + } + if _, ok := req.GetSavedPeerID(); ok { + return false + } + if _, ok := req.GetSavedReaction(); ok { + return false + } + if _, ok := req.GetTopMsgID(); ok { + return false + } + return searchFilterNeedsMediaStore(req.Filter) +} + +func mediaSearchCanReusePeerWideCount(req *tg.MessagesSearchRequest) bool { + if req == nil { + return false + } + if req.Q != "" || req.MaxID != 0 || req.MinID != 0 || req.MinDate != 0 || req.MaxDate != 0 { + return false + } + if _, ok := req.GetFromID(); ok { + return false + } + if _, ok := req.GetSavedPeerID(); ok { + return false + } + if _, ok := req.GetSavedReaction(); ok { + return false + } + if _, ok := req.GetTopMsgID(); ok { + return false + } + return searchFilterNeedsMediaStore(req.Filter) +} diff --git a/internal/rpc/message_effect_projection_test.go b/internal/rpc/message_effect_projection_test.go new file mode 100644 index 00000000..6f71e1d8 --- /dev/null +++ b/internal/rpc/message_effect_projection_test.go @@ -0,0 +1,29 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// TestMessageEffectProjection 验证私聊消息特效 effect 投影到 tg.Message: +// 0 值不下发(flag-optional),非零下发。特效是私聊专属,不涉及频道投影。 +func TestMessageEffectProjection(t *testing.T) { + const effect = int64(5104841169784993603) + + withEffect := tgMessage(domain.Message{ + ID: 5, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + Effect: effect, + }).(*tg.Message) + if got, ok := withEffect.GetEffect(); !ok || got != effect { + t.Fatalf("message effect = %d ok %v, want %d", got, ok, effect) + } + + plain := tgMessage(domain.Message{ID: 6, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}}).(*tg.Message) + if _, ok := plain.GetEffect(); ok { + t.Fatal("non-effect message must not carry effect") + } +} diff --git a/internal/rpc/message_entities_autodetect.go b/internal/rpc/message_entities_autodetect.go new file mode 100644 index 00000000..5e9e2490 --- /dev/null +++ b/internal/rpc/message_entities_autodetect.go @@ -0,0 +1,275 @@ +package rpc + +import ( + "strings" + "unicode" + "unicode/utf8" + + "github.com/gotd/td/tg" +) + +// 服务端自动实体检测:@mention / #hashtag / $cashtag / bot command。 +// +// 官方 Telegram 服务端会对消息原文检测这些「可自动识别」实体并写入 message.entities; +// 客户端发 sendMessage 时只发「用户意图」类实体(bold/italic/code/textUrl/customEmoji/ +// inputMessageEntityMentionName 等)以及客户端本地检测的 url。DrKLO 对正常已发送消息 +// useManualParse=false,不本地 Linkify @username,完全依赖 message.entities 渲染 @mention +// 高亮——故服务端不补 messageEntityMention 时 @username 不渲染成可点击蓝色。url 检测见 +// detectURLEntities(webpage_url_extract.go)。 +// +// 所有 offset/length 以 UTF-16 码元计(Telegram 实体口径),复用 utf16CodeUnitLen;@/#/$ / +// 触发字符本身计入实体长度。检测纯按文本正则,不查库校验 username/command 是否真实存在 +// (官方亦如此:messageEntityMention 不带 user_id,客户端点击时再 resolveUsername)。 + +// augmentAutoEntities 在客户端已发实体基础上补充服务端检测的自动实体。补充项与任何 +// 已有实体(客户端富文本意图实体或先补入的自动实体)区间相交时丢弃,避免把 mention/ +// hashtag 打进 code/pre/textUrl/已有 mentionName 内部或彼此重叠(对齐官方不重复打实体)。 +// 客户端已带 url/textUrl 实体(自行检测过,如 DrKLO)时跳过 url 检测,沿用既有口径。 +// 客户端实体保持在前(超过上限裁剪时优先保留),结果裁剪到实体上限。 +func augmentAutoEntities(message string, entities []tg.MessageEntityClass) []tg.MessageEntityClass { + // 快路径:绝大多数消息不含任何可自动识别的触发字符。单次 ContainsAny 扫描即短路返回, + // 跳过下面各检测器对全文的扫描与区间分配(纯文本发送零额外开销)。所有 http(s) 链接 + // 都含 '/',故 "@#$/" 一并覆盖 url 检测;email/phone 未实现故不在触发集内。 + if message == "" || !strings.ContainsAny(message, "@#$/") { + return entities + } + hasClientURL := false + type interval struct{ start, end int } + occupied := make([]interval, 0, len(entities)+8) + for _, e := range entities { + if ln := e.GetLength(); ln > 0 { + off := e.GetOffset() + occupied = append(occupied, interval{off, off + ln}) + } + switch e.(type) { + case *tg.MessageEntityURL, *tg.MessageEntityTextURL: + hasClientURL = true + } + } + overlaps := func(s, e int) bool { + for _, iv := range occupied { + if s < iv.end && iv.start < e { + return true + } + } + return false + } + + // extra 延迟分配:仅当真正补到实体时才建切片并复制 entities。像 "邮箱 a@b.com" 这种有 + // 触发字符但补不出实体的情况(@ 前是单词字符 → 非 mention),保持零复制返回原 entities。 + var extra []tg.MessageEntityClass + accept := func(c tg.MessageEntityClass) { + if len(entities)+len(extra) >= maxMessageEntityCount { + return + } + ln := c.GetLength() + if ln <= 0 { + return + } + off := c.GetOffset() + if overlaps(off, off+ln) { + return + } + extra = append(extra, c) + occupied = append(occupied, interval{off, off + ln}) + } + + // URL 跨度始终计算并加入排除区(occupied),使 @mention/#hashtag 等不会落进 URL 路径内部 + // (如 https://t.me/@scam 的 @scam,既不符官方语义也是钓鱼风险);但仅在客户端未带任何 + // url/textUrl 实体时才作为实体下发,沿用 all-or-nothing(DrKLO 一带即全带;TDesktop 不带、依赖服务端)。 + for _, u := range detectURLEntities(message) { + ln := u.GetLength() + if ln <= 0 { + continue + } + off := u.GetOffset() + if overlaps(off, off+ln) { + continue + } + occupied = append(occupied, interval{off, off + ln}) + if !hasClientURL && len(entities)+len(extra) < maxMessageEntityCount { + extra = append(extra, u) + } + } + + // 其余自动实体落在任何已占区间(客户端实体或 URL 跨度)内则丢弃;客户端实体优先保留(上限内)。 + for _, c := range detectMentionEntities(message) { + accept(c) + } + for _, c := range detectHashtagEntities(message) { + accept(c) + } + for _, c := range detectCashtagEntities(message) { + accept(c) + } + for _, c := range detectBotCommandEntities(message) { + accept(c) + } + + if len(extra) == 0 { + return entities + } + out := make([]tg.MessageEntityClass, 0, len(entities)+len(extra)) + out = append(out, entities...) + return append(out, extra...) +} + +// isWordRune 判定「单词字符」(用于实体前导边界:前一个字符是单词字符时不是新实体起点, +// 借此排除 email 的 local@domain、路径里的 and/or 等)。 +func isWordRune(r rune) bool { + return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) +} + +// isHashtagRune 是 hashtag 正文允许的字符(支持 unicode 字母/数字,如 #日本語)。 +func isHashtagRune(r rune) bool { + return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) +} + +// prevRuneBefore 取字节位置 i 之前的一个完整 rune(供前导边界判定);i<=0 返回 ok=false +// (字符串起点视为合法实体边界)。 +func prevRuneBefore(s string, i int) (rune, bool) { + if i <= 0 || i > len(s) { + return 0, false + } + r, size := utf8.DecodeLastRuneInString(s[:i]) + if size == 0 { + return 0, false + } + return r, true +} + +// detectMentionEntities 检测 @username(messageEntityMention,仅 offset/length 无 user_id)。 +// 规则:前导字符非单词字符且非 '@';username = [A-Za-z0-9_] 长 1..32;'@' 计入长度。 +// (设置 mentioned 标志是另一条独立链路 mentionedUserIDsFromMessage,基于 user_id,与本检测无关。) +func detectMentionEntities(message string) []tg.MessageEntityClass { + var out []tg.MessageEntityClass + for i := 0; i < len(message); i++ { + if message[i] != '@' { + continue + } + if r, ok := prevRuneBefore(message, i); ok && (isWordRune(r) || r == '@') { + continue + } + j := i + 1 + for j < len(message) && isUsernameByte(message[j]) { + j++ + } + if n := j - i - 1; n < 1 || n > 32 { + continue + } + out = append(out, &tg.MessageEntityMention{ + Offset: utf16CodeUnitLen(message[:i]), + Length: utf16CodeUnitLen(message[i:j]), + }) + i = j - 1 + } + return out +} + +// detectBotCommandEntities 检测 /command 与 /command@botusername(messageEntityBotCommand)。 +// 规则:前导字符非单词字符且非 '/','@','<';command = [A-Za-z0-9_] 长 1..64;可选 +// '@' + [A-Za-z0-9_] 1..32 的 bot username 后缀。前导排除单词字符使日期 12/25、and/or、 +// url 路径不被误判为命令。 +func detectBotCommandEntities(message string) []tg.MessageEntityClass { + var out []tg.MessageEntityClass + for i := 0; i < len(message); i++ { + if message[i] != '/' { + continue + } + if r, ok := prevRuneBefore(message, i); ok && (isWordRune(r) || r == '/' || r == '@' || r == '<') { + continue + } + j := i + 1 + for j < len(message) && isUsernameByte(message[j]) { + j++ + } + if n := j - i - 1; n < 1 || n > 64 { + continue + } + end := j + if end < len(message) && message[end] == '@' { + k := end + 1 + for k < len(message) && isUsernameByte(message[k]) { + k++ + } + if bn := k - end - 1; bn >= 1 && bn <= 32 { + end = k + } + } + out = append(out, &tg.MessageEntityBotCommand{ + Offset: utf16CodeUnitLen(message[:i]), + Length: utf16CodeUnitLen(message[i:end]), + }) + i = end - 1 + } + return out +} + +// detectHashtagEntities 检测 #hashtag(messageEntityHashtag,支持 unicode 字母/数字)。 +// 规则:前导字符非单词字符且非 '#','@';正文 1..256 个 hashtag 字符且首字符非数字 +// (排除 #123 这类纯/前导数字串)。 +func detectHashtagEntities(message string) []tg.MessageEntityClass { + var out []tg.MessageEntityClass + // '#' 是 ASCII,绝不出现在多字节 UTF-8 序列内部,故按字节扫描触发字符(避免对每个 + // 位置做 rune 解码);仅在边界判定与 body(支持 unicode 字母/数字)上才做 rune 解码。 + for i := 0; i < len(message); i++ { + if message[i] != '#' { + continue + } + if r, ok := prevRuneBefore(message, i); ok && (isWordRune(r) || r == '#' || r == '@') { + continue + } + j := i + 1 + var firstRune rune + runeCount := 0 + for j < len(message) { + r, size := utf8.DecodeRuneInString(message[j:]) + if size <= 0 || !isHashtagRune(r) { + break + } + if runeCount == 0 { + firstRune = r + } + runeCount++ + j += size + } + if runeCount >= 1 && runeCount <= 256 && !unicode.IsDigit(firstRune) { + out = append(out, &tg.MessageEntityHashtag{ + Offset: utf16CodeUnitLen(message[:i]), + Length: utf16CodeUnitLen(message[i:j]), + }) + i = j - 1 // for 循环 i++ 后落到 j,跳过已消费的 hashtag body + } + } + return out +} + +// detectCashtagEntities 检测 $TICKER(messageEntityCashtag)。规则:前导字符非单词字符 +// 且非 '$';正文 1..8 个大写字母,且其后紧邻字符非单词字符(排除 $USDfoo)。 +func detectCashtagEntities(message string) []tg.MessageEntityClass { + var out []tg.MessageEntityClass + for i := 0; i < len(message); i++ { + if message[i] != '$' { + continue + } + if r, ok := prevRuneBefore(message, i); ok && (isWordRune(r) || r == '$') { + continue + } + j := i + 1 + for j < len(message) && message[j] >= 'A' && message[j] <= 'Z' { + j++ + } + if n := j - i - 1; n < 1 || n > 8 { + continue + } + if r, size := utf8.DecodeRuneInString(message[j:]); size > 0 && isWordRune(r) { + continue + } + out = append(out, &tg.MessageEntityCashtag{ + Offset: utf16CodeUnitLen(message[:i]), + Length: utf16CodeUnitLen(message[i:j]), + }) + i = j - 1 + } + return out +} diff --git a/internal/rpc/message_grouped_projection_test.go b/internal/rpc/message_grouped_projection_test.go new file mode 100644 index 00000000..de11ec62 --- /dev/null +++ b/internal/rpc/message_grouped_projection_test.go @@ -0,0 +1,39 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// TestMessageGroupedIDProjection 验证相册 grouped_id 投影到 tg.Message(私聊+频道), +// 0 值不下发(flag-optional),非零下发。 +func TestMessageGroupedIDProjection(t *testing.T) { + const groupedID = int64(880000000123) + + priv := tgMessage(domain.Message{ + ID: 5, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + GroupedID: groupedID, + }).(*tg.Message) + if got, ok := priv.GetGroupedID(); !ok || got != groupedID { + t.Fatalf("private grouped_id = %d ok %v, want %d", got, ok, groupedID) + } + + plain := tgMessage(domain.Message{ID: 6, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}}).(*tg.Message) + if _, ok := plain.GetGroupedID(); ok { + t.Fatal("non-album private message must not carry grouped_id") + } + + ch := tgChannelMessage(1001, domain.ChannelMessage{ + ChannelID: 2000, + ID: 7, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, + GroupedID: groupedID, + }).(*tg.Message) + if got, ok := ch.GetGroupedID(); !ok || got != groupedID { + t.Fatalf("channel grouped_id = %d ok %v, want %d", got, ok, groupedID) + } +} diff --git a/internal/rpc/messages.go b/internal/rpc/messages.go deleted file mode 100644 index 1c7a18a3..00000000 --- a/internal/rpc/messages.go +++ /dev/null @@ -1,6458 +0,0 @@ -package rpc - -import ( - "context" - "errors" - "hash/fnv" - "sort" - "strconv" - "strings" - "time" - "unicode/utf8" - - "github.com/gotd/td/tg" - "github.com/gotd/td/tgerr" - "go.uber.org/zap" - - "telesrv/internal/compat/tdesktop" - "telesrv/internal/domain" -) - -const ( - maxSendMessageTextLength = domain.MaxMessageTextLength - maxReplyQuoteLength = domain.MaxMessageReplyQuoteLength - maxMessageSearchQLength = 256 - maxMessageEntityCount = domain.MaxMessageEntityCount - maxGetMessagesIDs = 100 - maxMessageSearchFilters = 32 - maxForumTopicIDs = 100 - maxDialogInputPeers = 100 - maxSearchResultsLimit = 100 - maxReactionVector = 16 - maxReactionListOffset = 128 - maxReportOptionLength = 32 - maxReportCommentLength = 1024 - maxReportRandomIDLength = 128 - maxReadMetrics = 100 - maxBusinessConnIDLength = 128 - maxSendMultiMediaItems = 10 - maxForumTopicTitleLength = 128 - maxPollVoteOptions = 10 - maxPollOptionBytes = 256 - maxPollVotesOffsetLength = 128 - maxTodoItems = 30 - maxTodoTitleLength = 200 - maxCommonChatsLimit = domain.MaxCommonChannelsLimit - maxStickerSearchQLength = 128 - maxStickerSearchLangs = 16 - maxEmojiLangCodeLength = 32 - maxEmojiDocuments = 100 - maxSavedReactionTagTitle = 12 - defaultTopReactionsLimit = 14 - sendMessageRateLimit = 30 - sendMessageRateWindow = time.Minute - forumGeneralTopicID = 1 - forumGeneralIconColor = 0x6FB9F0 -) - -type accountDefaultReactionService interface { - SetDefaultReaction(ctx context.Context, userID int64, reaction domain.MessageReaction) (domain.AccountReactionSettings, error) -} - -type accountPaidReactionPrivacyService interface { - GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error) - SetPaidReactionPrivacy(ctx context.Context, userID int64, privacy domain.PaidReactionPrivacy) (domain.AccountReactionSettings, error) -} - -type messageReactionUpdateRecorder interface { - RecordMessageReactions(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) -} - -type messageReactionUsageRecorder interface { - RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error -} - -type channelParticipantReactionModerator interface { - DeleteParticipantReaction(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) - DeleteParticipantReactions(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) -} - -// registerMessages 注册 messages.* RPC handler。 -func (r *Router) registerMessages(d *tg.ServerDispatcher) { - d.OnMessagesSetTyping(r.onMessagesSetTyping) - d.OnMessagesSaveDraft(r.onMessagesSaveDraft) - d.OnMessagesSaveDefaultSendAs(r.onMessagesSaveDefaultSendAs) - d.OnMessagesGetAllDrafts(r.onMessagesGetAllDrafts) - d.OnMessagesClearAllDrafts(r.onMessagesClearAllDrafts) - d.OnMessagesGetAllStickers(r.onMessagesGetAllStickers) - d.OnMessagesGetEmojiStickers(r.onMessagesGetEmojiStickers) - d.OnMessagesGetFeaturedStickers(func(ctx context.Context, hash int64) (tg.MessagesFeaturedStickersClass, error) { - return messagesFeaturedStickersEmpty(hash), nil - }) - d.OnMessagesGetFeaturedEmojiStickers(func(ctx context.Context, hash int64) (tg.MessagesFeaturedStickersClass, error) { - return messagesFeaturedStickersEmpty(hash), nil - }) - d.OnMessagesGetRecentStickers(func(ctx context.Context, req *tg.MessagesGetRecentStickersRequest) (tg.MessagesRecentStickersClass, error) { - if req.Hash != 0 { - return &tg.MessagesRecentStickersNotModified{}, nil - } - return &tg.MessagesRecentStickers{ - Packs: []tg.StickerPack{}, - Stickers: []tg.DocumentClass{}, - Dates: []int{}, - }, nil - }) - d.OnMessagesGetFavedStickers(func(ctx context.Context, hash int64) (tg.MessagesFavedStickersClass, error) { - if hash != 0 { - return &tg.MessagesFavedStickersNotModified{}, nil - } - return &tg.MessagesFavedStickers{ - Packs: []tg.StickerPack{}, - Stickers: []tg.DocumentClass{}, - }, nil - }) - d.OnMessagesGetSavedGifs(func(ctx context.Context, hash int64) (tg.MessagesSavedGifsClass, error) { - if hash != 0 { - return &tg.MessagesSavedGifsNotModified{}, nil - } - return &tg.MessagesSavedGifs{Gifs: []tg.DocumentClass{}}, nil - }) - d.OnMessagesSendMessage(r.onMessagesSendMessage) - d.OnMessagesForwardMessages(r.onMessagesForwardMessages) - d.OnMessagesGetDialogFilters(r.onMessagesGetDialogFilters) - d.OnMessagesGetSuggestedDialogFilters(func(ctx context.Context) ([]tg.DialogFilterSuggested, error) { - return []tg.DialogFilterSuggested{}, nil - }) - d.OnMessagesUpdateDialogFilter(r.onMessagesUpdateDialogFilter) - d.OnMessagesUpdateDialogFiltersOrder(r.onMessagesUpdateDialogFiltersOrder) - d.OnMessagesToggleDialogFilterTags(r.onMessagesToggleDialogFilterTags) - d.OnMessagesGetSavedDialogs(func(ctx context.Context, req *tg.MessagesGetSavedDialogsRequest) (tg.MessagesSavedDialogsClass, error) { - if req.Hash != 0 { - return &tg.MessagesSavedDialogsNotModified{Count: 0}, nil - } - return &tg.MessagesSavedDialogs{}, nil - }) - d.OnMessagesGetPinnedSavedDialogs(func(ctx context.Context) (tg.MessagesSavedDialogsClass, error) { - return &tg.MessagesSavedDialogs{}, nil - }) - d.OnMessagesToggleSavedDialogPin(func(ctx context.Context, req *tg.MessagesToggleSavedDialogPinRequest) (bool, error) { - return true, nil - }) - d.OnMessagesReorderPinnedSavedDialogs(func(ctx context.Context, req *tg.MessagesReorderPinnedSavedDialogsRequest) (bool, error) { - return true, nil - }) - d.OnMessagesGetSavedDialogsByID(func(ctx context.Context, req *tg.MessagesGetSavedDialogsByIDRequest) (tg.MessagesSavedDialogsClass, error) { - return &tg.MessagesSavedDialogs{}, nil - }) - d.OnMessagesGetSavedHistory(r.onMessagesGetSavedHistory) - d.OnMessagesReadSavedHistory(r.onMessagesReadSavedHistory) - d.OnMessagesDeleteSavedHistory(r.onMessagesDeleteSavedHistory) - d.OnMessagesGetCommonChats(r.onMessagesGetCommonChats) - d.OnMessagesGetDefaultHistoryTTL(r.onMessagesGetDefaultHistoryTTL) - d.OnMessagesGetSponsoredMessages(r.onMessagesGetSponsoredMessages) - d.OnMessagesGetWebPagePreview(r.onMessagesGetWebPagePreview) - d.OnMessagesUploadMedia(r.onMessagesUploadMedia) - d.OnMessagesSendMedia(r.onMessagesSendMedia) - d.OnMessagesSendMultiMedia(r.onMessagesSendMultiMedia) - d.OnMessagesReportSpam(r.onMessagesReportSpam) - d.OnMessagesReport(r.onMessagesReport) - d.OnMessagesReportReaction(r.onMessagesReportReaction) - d.OnMessagesReportMessagesDelivery(r.onMessagesReportMessagesDelivery) - d.OnMessagesReportReadMetrics(r.onMessagesReportReadMetrics) - d.OnMessagesReportMusicListen(r.onMessagesReportMusicListen) - d.OnMessagesReportSponsoredMessage(r.onMessagesReportSponsoredMessage) - d.OnMessagesReadMessageContents(r.onMessagesReadMessageContents) - d.OnMessagesGetMessagesViews(r.onMessagesGetMessagesViews) - d.OnMessagesGetUnreadMentions(r.onMessagesGetUnreadMentions) - d.OnMessagesReadMentions(r.onMessagesReadMentions) - d.OnMessagesGetSearchCounters(r.onMessagesGetSearchCounters) - d.OnMessagesGetReplies(r.onMessagesGetReplies) - d.OnMessagesGetDiscussionMessage(r.onMessagesGetDiscussionMessage) - d.OnMessagesReadDiscussion(r.onMessagesReadDiscussion) - d.OnMessagesGetForumTopics(r.onMessagesGetForumTopics) - d.OnMessagesGetForumTopicsByID(r.onMessagesGetForumTopicsByID) - d.OnMessagesGetOnlines(r.onMessagesGetOnlines) - d.OnMessagesGetAvailableReactions(r.onMessagesGetAvailableReactions) - d.OnMessagesGetAvailableEffects(func(ctx context.Context, hash int) (tg.MessagesAvailableEffectsClass, error) { - return &tg.MessagesAvailableEffects{ - Hash: 0, - Effects: []tg.AvailableEffect{}, - Documents: []tg.DocumentClass{}, - }, nil - }) - d.OnMessagesGetStickers(func(ctx context.Context, req *tg.MessagesGetStickersRequest) (tg.MessagesStickersClass, error) { - return tdesktop.Stickers(), nil - }) - d.OnMessagesGetArchivedStickers(func(ctx context.Context, req *tg.MessagesGetArchivedStickersRequest) (*tg.MessagesArchivedStickers, error) { - return &tg.MessagesArchivedStickers{ - Count: 0, - Sets: []tg.StickerSetCoveredClass{}, - }, nil - }) - d.OnMessagesGetStickerSet(r.onMessagesGetStickerSet) - d.OnMessagesGetEmojiGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) { - return tdesktop.EmojiGroups(), nil - }) - d.OnMessagesGetEmojiStickerGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) { - return tdesktop.EmojiGroups(), nil - }) - d.OnMessagesGetEmojiProfilePhotoGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) { - return tdesktop.EmojiProfilePhotoGroups(), nil - }) - d.OnMessagesGetEmojiKeywords(r.onMessagesGetEmojiKeywords) - d.OnMessagesGetEmojiKeywordsDifference(r.onMessagesGetEmojiKeywordsDifference) - d.OnMessagesGetEmojiKeywordsLanguages(func(ctx context.Context, langcodes []string) ([]tg.EmojiLanguage, error) { - return []tg.EmojiLanguage{}, nil - }) - d.OnMessagesGetCustomEmojiDocuments(r.onMessagesGetCustomEmojiDocuments) - d.OnMessagesGetAttachedStickers(r.onMessagesGetAttachedStickers) - d.OnMessagesSearchStickerSets(r.onMessagesSearchStickerSets) - d.OnMessagesSearchStickers(r.onMessagesSearchStickers) - d.OnMessagesGetAttachMenuBots(func(ctx context.Context, hash int64) (tg.AttachMenuBotsClass, error) { - return tdesktop.AttachMenuBots(), nil - }) - d.OnMessagesGetQuickReplies(func(ctx context.Context, hash int64) (tg.MessagesQuickRepliesClass, error) { - return tdesktop.QuickReplies(), nil - }) - d.OnMessagesGetWebPage(func(ctx context.Context, req *tg.MessagesGetWebPageRequest) (*tg.MessagesWebPage, error) { - return tdesktop.WebPage(req.URL), nil - }) - d.OnMessagesGetDialogs(func(ctx context.Context, req *tg.MessagesGetDialogsRequest) (tg.MessagesDialogsClass, error) { - if r.deps.Dialogs == nil { - return &tg.MessagesDialogs{}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - filter, err := r.dialogFilterFromRequest(ctx, userID, req) - if err != nil { - return nil, err - } - list, err := r.deps.Dialogs.GetDialogs(ctx, userID, filter) - if err != nil { - return nil, internalErr() - } - if filter.Hash != 0 && list.Hash == filter.Hash { - return &tg.MessagesDialogsNotModified{Count: list.Count}, nil - } - return tgMessagesDialogs(userID, r.withDialogListPresence(list)), nil - }) - d.OnMessagesGetPinnedDialogs(func(ctx context.Context, folderID int) (*tg.MessagesPeerDialogs, error) { - id, _ := AuthKeyIDFrom(ctx) - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - var list domain.DialogList - if r.deps.Dialogs != nil { - list, err = r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{ - PinnedOnly: true, - HasFolderID: true, - FolderID: folderID, - Limit: 100, - }) - if err != nil { - return nil, internalErr() - } - } - st := domain.UpdateState{Date: int(r.clock.Now().Unix())} - if r.deps.Updates != nil { - var err error - st, err = r.deps.Updates.GetState(ctx, id, userID) - if err != nil { - return nil, internalErr() - } - } - return tgPeerDialogs(userID, r.withDialogListPresence(list), st), nil - }) - d.OnMessagesGetPeerDialogs(func(ctx context.Context, peers []tg.InputDialogPeerClass) (*tg.MessagesPeerDialogs, error) { - id, _ := AuthKeyIDFrom(ctx) - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - domainPeers, err := r.dialogPeersFromInput(ctx, userID, peers) - if err != nil { - return nil, err - } - var list domain.DialogList - if len(domainPeers) > 0 && r.deps.Dialogs != nil { - var err error - list, err = r.deps.Dialogs.GetPeerDialogs(ctx, userID, domainPeers) - if err != nil { - return nil, internalErr() - } - } - st := domain.UpdateState{Date: int(r.clock.Now().Unix())} - if r.deps.Updates != nil { - var err error - st, err = r.deps.Updates.GetState(ctx, id, userID) - if err != nil { - return nil, internalErr() - } - } - r.trackChannelInterest(ctx, userID, channelIDsFromDialogs(list)...) - return tgPeerDialogs(userID, r.withDialogListPresence(list), st), nil - }) - d.OnMessagesGetPeerSettings(r.onMessagesGetPeerSettings) - d.OnMessagesToggleDialogPin(r.onMessagesToggleDialogPin) - d.OnMessagesReorderPinnedDialogs(r.onMessagesReorderPinnedDialogs) - d.OnMessagesMarkDialogUnread(r.onMessagesMarkDialogUnread) - d.OnMessagesGetDialogUnreadMarks(r.onMessagesGetDialogUnreadMarks) - d.OnMessagesHidePeerSettingsBar(r.onMessagesHidePeerSettingsBar) - d.OnMessagesGetMessageEditData(r.onMessagesGetMessageEditData) - d.OnMessagesEditMessage(r.onMessagesEditMessage) - d.OnMessagesGetOutboxReadDate(r.onMessagesGetOutboxReadDate) - d.OnMessagesGetMessageReadParticipants(r.onMessagesGetMessageReadParticipants) - d.OnMessagesDeleteMessages(r.onMessagesDeleteMessages) - d.OnMessagesDeleteHistory(r.onMessagesDeleteHistory) - d.OnMessagesGetMessages(r.onMessagesGetMessages) - d.OnMessagesGetHistory(func(ctx context.Context, req *tg.MessagesGetHistoryRequest) (tg.MessagesMessagesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - filter, ok := r.messageFilterFromHistoryRequest(userID, req) - if !ok { - return messagesNotModifiedOrEmpty(req.Hash), nil - } - if filter.Peer.Type == domain.PeerTypeChannel { - if r.deps.Channels == nil { - return messagesNotModifiedOrEmpty(req.Hash), nil - } - if err := r.validateInputPeerChannelAccess(ctx, userID, req.Peer, filter.Peer.ID); err != nil { - return nil, err - } - if isLegacyInputPeerChat(req.Peer) { - return &tg.MessagesMessages{}, nil - } - history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{ - ChannelID: filter.Peer.ID, - OffsetID: filter.OffsetID, - OffsetDate: filter.OffsetDate, - AddOffset: filter.AddOffset, - Limit: filter.Limit, - MaxID: filter.MaxID, - MinID: filter.MinID, - Hash: filter.Hash, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - history = r.enrichChannelHistory(ctx, userID, history) - r.trackChannelInterest(ctx, userID, filter.Peer.ID) - if filter.Hash != 0 && history.Hash == filter.Hash { - return &tg.MessagesMessagesNotModified{Count: history.Count}, nil - } - return tgChannelHistoryMessages(userID, history), nil - } - r.clearChannelInterest(ctx, userID) - if r.deps.Messages == nil { - return messagesNotModifiedOrEmpty(req.Hash), nil - } - list, err := r.deps.Messages.GetHistory(ctx, userID, filter) - if err != nil { - return nil, internalErr() - } - if filter.Hash != 0 && list.Hash == filter.Hash { - return &tg.MessagesMessagesNotModified{Count: list.Count}, nil - } - return tgMessagesMessages(userID, r.withMessageListPresence(list)), nil - }) - d.OnMessagesReadHistory(func(ctx context.Context, req *tg.MessagesReadHistoryRequest) (*tg.MessagesAffectedMessages, error) { - id, _ := AuthKeyIDFrom(ctx) - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - peer, peerErr := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if peerErr == nil && peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { - read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{ - UserID: userID, - ChannelID: peer.ID, - MaxID: req.MaxID, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelInvalidErr(err) - } - event, err := r.recordChannelReadInbox(ctx, userID, read) - if err != nil { - return nil, err - } - r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates) - if event.Pts != 0 { - return &tg.MessagesAffectedMessages{Pts: event.Pts, PtsCount: event.PtsCount}, nil - } - return r.affectedMessages(ctx, id, userID) - } - if peerErr != nil { - return nil, peerErr - } - if r.deps.Messages != nil { - sessionID, _ := SessionIDFrom(ctx) - read, err := r.deps.Messages.ReadHistory(ctx, userID, domain.ReadHistoryRequest{ - OwnerUserID: userID, - Peer: peer, - MaxID: req.MaxID, - Date: int(r.clock.Now().Unix()), - OriginAuthKeyID: id, - OriginSessionID: sessionID, - }) - if err != nil { - return nil, internalErr() - } - if read.Changed && read.InboxEvent.Pts != 0 { - r.pushReadHistoryEvent(ctx, read.OwnerUserID, read.InboxEvent) - if read.OutboxChanged && read.OutboxEvent.Pts != 0 { - r.pushReadHistoryEvent(ctx, read.OutboxUserID, read.OutboxEvent) - } - return &tg.MessagesAffectedMessages{Pts: read.InboxEvent.Pts, PtsCount: read.InboxEvent.PtsCount}, nil - } - } - return r.affectedMessages(ctx, id, userID) - }) - d.OnMessagesSearch(func(ctx context.Context, req *tg.MessagesSearchRequest) (tg.MessagesMessagesClass, error) { - if utf8.RuneCountInString(req.Q) > maxMessageSearchQLength { - return nil, limitInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - filter := r.messageFilterFromSearchRequest(userID, req) - if filter.HasPeer && filter.Peer.Type == domain.PeerTypeChannel { - if r.deps.Channels == nil { - return messagesNotModifiedOrEmpty(req.Hash), nil - } - if err := r.validateInputPeerChannelAccess(ctx, userID, req.Peer, filter.Peer.ID); err != nil { - return nil, err - } - if isLegacyInputPeerChat(req.Peer) { - return &tg.MessagesMessages{}, nil - } - if searchFilterNeedsMediaStore(req.Filter) { - view, err := r.deps.Channels.GetChannel(ctx, userID, filter.Peer.ID) - if err != nil { - return nil, channelInvalidErr(err) - } - return tgChannelHistoryMessages(userID, domain.ChannelHistory{Channel: view.Channel}), nil - } - chFilter, ok := r.channelHistoryFilterFromSearchRequest(userID, req, filter.Peer.ID) - if !ok { - return nil, peerIDInvalidErr() - } - history, err := r.deps.Channels.GetHistory(ctx, userID, chFilter) - if err != nil { - return nil, channelInvalidErr(err) - } - history = r.enrichChannelHistory(ctx, userID, history) - if chFilter.Hash != 0 && history.Hash == chFilter.Hash { - return &tg.MessagesMessagesNotModified{Count: history.Count}, nil - } - return tgChannelHistoryMessages(userID, history), nil - } - if _, ok := req.Filter.(*tg.InputMessagesFilterPinned); ok { - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - return &tg.MessagesMessages{}, nil - } - if r.deps.Messages == nil { - return messagesNotModifiedOrEmpty(req.Hash), nil - } - if searchFilterNeedsMediaStore(req.Filter) { - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - return &tg.MessagesMessages{}, nil - } - list, err := r.deps.Messages.Search(ctx, userID, filter) - if err != nil { - return nil, internalErr() - } - if filter.Hash != 0 && list.Hash == filter.Hash { - return &tg.MessagesMessagesNotModified{Count: list.Count}, nil - } - return tgMessagesMessages(userID, r.withMessageListPresence(list)), nil - }) - d.OnMessagesSearchGlobal(r.onMessagesSearchGlobal) - d.OnMessagesGetSearchResultsCalendar(r.onMessagesGetSearchResultsCalendar) - d.OnMessagesGetSearchResultsPositions(r.onMessagesGetSearchResultsPositions) - d.OnMessagesSendReaction(r.onMessagesSendReaction) - d.OnMessagesGetMessagesReactions(r.onMessagesGetMessagesReactions) - d.OnMessagesGetMessageReactionsList(r.onMessagesGetMessageReactionsList) - d.OnMessagesSetDefaultReaction(r.onMessagesSetDefaultReaction) - d.OnMessagesGetPaidReactionPrivacy(r.onMessagesGetPaidReactionPrivacy) - d.OnMessagesTogglePaidReactionPrivacy(r.onMessagesTogglePaidReactionPrivacy) - d.OnMessagesSendPaidReaction(r.onMessagesSendPaidReaction) - d.OnMessagesDeleteParticipantReactions(r.onMessagesDeleteParticipantReactions) - d.OnMessagesDeleteParticipantReaction(r.onMessagesDeleteParticipantReaction) - d.OnMessagesGetUnreadReactions(r.onMessagesGetUnreadReactions) - d.OnMessagesReadReactions(r.onMessagesReadReactions) - d.OnMessagesGetTopReactions(r.onMessagesGetTopReactions) - d.OnMessagesGetRecentReactions(r.onMessagesGetRecentReactions) - d.OnMessagesClearRecentReactions(r.onMessagesClearRecentReactions) - d.OnMessagesGetSavedReactionTags(r.onMessagesGetSavedReactionTags) - d.OnMessagesUpdateSavedReactionTag(r.onMessagesUpdateSavedReactionTag) - d.OnMessagesGetDefaultTagReactions(r.onMessagesGetDefaultTagReactions) - d.OnMessagesSendVote(r.onMessagesSendVote) - d.OnMessagesGetPollResults(r.onMessagesGetPollResults) - d.OnMessagesGetPollVotes(r.onMessagesGetPollVotes) - d.OnMessagesAddPollAnswer(r.onMessagesAddPollAnswer) - d.OnMessagesDeletePollAnswer(r.onMessagesDeletePollAnswer) - d.OnMessagesGetUnreadPollVotes(r.onMessagesGetUnreadPollVotes) - d.OnMessagesReadPollVotes(r.onMessagesReadPollVotes) - d.OnMessagesAppendTodoList(r.onMessagesAppendTodoList) - d.OnMessagesToggleTodoCompleted(r.onMessagesToggleTodoCompleted) - d.OnMessagesGetScheduledHistory(r.onMessagesGetScheduledHistory) - d.OnMessagesGetScheduledMessages(r.onMessagesGetScheduledMessages) - d.OnMessagesSendScheduledMessages(r.onMessagesSendScheduledMessages) - d.OnMessagesDeleteScheduledMessages(r.onMessagesDeleteScheduledMessages) - d.OnMessagesCreateForumTopic(r.onMessagesCreateForumTopic) - d.OnMessagesEditForumTopic(r.onMessagesEditForumTopic) - d.OnMessagesUpdatePinnedForumTopic(r.onMessagesUpdatePinnedForumTopic) - d.OnMessagesReorderPinnedForumTopics(r.onMessagesReorderPinnedForumTopics) - d.OnMessagesDeleteTopicHistory(r.onMessagesDeleteTopicHistory) -} - -func (r *Router) onMessagesGetSavedHistory(ctx context.Context, req *tg.MessagesGetSavedHistoryRequest) (tg.MessagesMessagesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if err := validateSavedHistoryBounds(req.OffsetID, req.OffsetDate, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { - return nil, err - } - parentPeer, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer) - if err != nil { - return nil, err - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - if req.Hash != 0 { - return &tg.MessagesMessagesNotModified{Count: 0}, nil - } - chats := r.savedHistoryChats(ctx, userID, hasParent, parentPeer, req.Peer) - return &tg.MessagesMessages{ - Messages: []tg.MessageClass{}, - Chats: chats, - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) onMessagesReadSavedHistory(ctx context.Context, req *tg.MessagesReadSavedHistoryRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { - return false, messageIDInvalidErr() - } - if err := r.validateRequiredSavedHistoryParentPeer(ctx, userID, req.ParentPeer); err != nil { - return false, err - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onMessagesDeleteSavedHistory(ctx context.Context, req *tg.MessagesDeleteSavedHistoryRequest) (*tg.MessagesAffectedHistory, error) { - authKeyID, _ := AuthKeyIDFrom(ctx) - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if minDate, ok := req.GetMinDate(); ok && minDate < 0 { - return nil, limitInvalidErr() - } - if maxDate, ok := req.GetMaxDate(); ok && maxDate < 0 { - return nil, limitInvalidErr() - } - if _, _, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil { - return nil, err - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - return r.affectedHistory(ctx, authKeyID, userID, 0) -} - -func (r *Router) onMessagesGetCommonChats(ctx context.Context, req *tg.MessagesGetCommonChatsRequest) (tg.MessagesChatsClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.Limit < 0 || req.Limit > maxCommonChatsLimit { - return nil, limitInvalidErr() - } - if req.MaxID < 0 { - return nil, messageIDInvalidErr() - } - target, found, err := r.userFromInput(ctx, userID, req.UserID) - if err != nil { - return nil, internalErr() - } - if !found || target.ID == 0 || target.ID == userID { - return nil, userIDInvalidErr() - } - if req.Limit == 0 || r.deps.Channels == nil { - return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil - } - common, err := r.deps.Channels.CommonChannels(ctx, userID, domain.CommonChannelsRequest{ - UserID: userID, - TargetUserID: target.ID, - MaxID: req.MaxID, - Limit: req.Limit, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - chats := make([]tg.ChatClass, 0, len(common.Channels)) - for _, ch := range common.Channels { - chats = append(chats, tgChannelChat(userID, ch, nil)) - } - return &tg.MessagesChats{Chats: chats}, nil -} - -func (r *Router) onMessagesGetAttachedStickers(ctx context.Context, media tg.InputStickeredMediaClass) ([]tg.StickerSetCoveredClass, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - if media == nil { - return nil, mediaEmptyErr() - } - return []tg.StickerSetCoveredClass{}, nil -} - -func (r *Router) onMessagesGetCustomEmojiDocuments(ctx context.Context, documentIDs []int64) ([]tg.DocumentClass, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - if len(documentIDs) > maxEmojiDocuments { - return nil, limitInvalidErr() - } - for _, id := range documentIDs { - if id <= 0 { - return nil, messageIDInvalidErr() - } - } - if r.deps.Files == nil || len(documentIDs) == 0 { - return []tg.DocumentClass{}, nil - } - docs, err := r.deps.Files.GetDocuments(ctx, documentIDs) - if err != nil { - return nil, internalErr() - } - byID := documentsByID(docs) - out := make([]tg.DocumentClass, 0, len(documentIDs)) - for _, id := range documentIDs { - if d, ok := byID[id]; ok { - out = append(out, tgDocument(d)) - } else { - out = append(out, &tg.DocumentEmpty{ID: id}) - } - } - return out, nil -} - -func (r *Router) onMessagesSearchStickerSets(ctx context.Context, req *tg.MessagesSearchStickerSetsRequest) (tg.MessagesFoundStickerSetsClass, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - if utf8.RuneCountInString(req.Q) > maxStickerSearchQLength { - return nil, limitInvalidErr() - } - if req.Hash != 0 { - return &tg.MessagesFoundStickerSetsNotModified{}, nil - } - return &tg.MessagesFoundStickerSets{ - Hash: 0, - Sets: []tg.StickerSetCoveredClass{}, - }, nil -} - -func (r *Router) onMessagesSearchStickers(ctx context.Context, req *tg.MessagesSearchStickersRequest) (tg.MessagesFoundStickersClass, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - if req.Offset < 0 || req.Offset > domain.MaxMessageBoxID || req.Limit < 0 || req.Limit > maxSearchResultsLimit { - return nil, limitInvalidErr() - } - if utf8.RuneCountInString(req.Q) > maxStickerSearchQLength || utf8.RuneCountInString(req.Emoticon) > maxStickerSearchQLength { - return nil, limitInvalidErr() - } - if len(req.LangCode) > maxStickerSearchLangs { - return nil, limitInvalidErr() - } - for _, lang := range req.LangCode { - if err := validateEmojiLangCode(lang); err != nil { - return nil, err - } - } - if req.Hash != 0 { - return &tg.MessagesFoundStickersNotModified{}, nil - } - return &tg.MessagesFoundStickers{ - Hash: 0, - Stickers: []tg.DocumentClass{}, - }, nil -} - -func (r *Router) onMessagesGetEmojiKeywords(ctx context.Context, langcode string) (*tg.EmojiKeywordsDifference, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - if err := validateEmojiLangCode(langcode); err != nil { - return nil, err - } - return &tg.EmojiKeywordsDifference{ - LangCode: langcode, - FromVersion: 0, - Version: 0, - Keywords: []tg.EmojiKeywordClass{}, - }, nil -} - -func (r *Router) onMessagesGetEmojiKeywordsDifference(ctx context.Context, req *tg.MessagesGetEmojiKeywordsDifferenceRequest) (*tg.EmojiKeywordsDifference, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - if err := validateEmojiLangCode(req.LangCode); err != nil { - return nil, err - } - if req.FromVersion < 0 { - return nil, limitInvalidErr() - } - return &tg.EmojiKeywordsDifference{ - LangCode: req.LangCode, - FromVersion: req.FromVersion, - Version: req.FromVersion, - Keywords: []tg.EmojiKeywordClass{}, - }, nil -} - -func (r *Router) onMessagesGetExtendedMedia(ctx context.Context, req *tg.MessagesGetExtendedMediaRequest) (tg.UpdatesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if err := validateMessageIDVector(req.ID); err != nil { - return nil, err - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - return tgEmptyUpdates(int(r.clock.Now().Unix())), nil -} - -func (r *Router) onMessagesGetTopReactions(ctx context.Context, req *tg.MessagesGetTopReactionsRequest) (tg.MessagesReactionsClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.Limit < 0 || req.Limit > maxSearchResultsLimit { - return nil, limitInvalidErr() - } - limit := req.Limit - if limit == 0 { - limit = defaultTopReactionsLimit - } - reactions := []domain.MessageReaction{} - if r.deps.Channels != nil { - var err error - reactions, err = r.deps.Channels.TopReactions(ctx, userID, limit) - if err != nil { - return nil, channelInvalidErr(err) - } - } - return messagesReactionsFromDomain(r.reactionsWithCatalogFallback(ctx, reactions, limit), req.Hash), nil -} - -func (r *Router) onMessagesGetRecentReactions(ctx context.Context, req *tg.MessagesGetRecentReactionsRequest) (tg.MessagesReactionsClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.Limit < 0 || req.Limit > maxSearchResultsLimit { - return nil, limitInvalidErr() - } - if r.deps.Channels == nil { - return messagesReactionsEmpty(req.Hash), nil - } - reactions, err := r.deps.Channels.RecentReactions(ctx, userID, req.Limit) - if err != nil { - return nil, channelInvalidErr(err) - } - return messagesReactionsFromDomain(reactions, req.Hash), nil -} - -func (r *Router) onMessagesClearRecentReactions(ctx context.Context) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if r.deps.Channels != nil { - if err := r.deps.Channels.ClearRecentReactions(ctx, userID); err != nil { - return false, channelInvalidErr(err) - } - } - return true, nil -} - -func (r *Router) onMessagesGetSavedReactionTags(ctx context.Context, req *tg.MessagesGetSavedReactionTagsRequest) (tg.MessagesSavedReactionTagsClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if peer, ok := req.GetPeer(); ok && peer != nil { - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { - return nil, err - } - return savedReactionTagsEmpty(req.Hash), nil - } - if r.deps.Channels == nil { - return savedReactionTagsEmpty(req.Hash), nil - } - tags, err := r.deps.Channels.SavedReactionTags(ctx, userID, domain.MaxSavedReactionTags) - if err != nil { - return nil, channelInvalidErr(err) - } - return savedReactionTagsFromDomain(tags, req.Hash), nil -} - -func (r *Router) onMessagesUpdateSavedReactionTag(ctx context.Context, req *tg.MessagesUpdateSavedReactionTagRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - reaction, err := domainMessageReactionFromTL(req.Reaction) - if err != nil { - return false, err - } - title, ok := req.GetTitle() - if !ok { - title = "" - } - if utf8.RuneCountInString(title) > maxSavedReactionTagTitle { - return false, limitInvalidErr() - } - if r.deps.Channels != nil { - if err := r.deps.Channels.UpdateSavedReactionTag(ctx, userID, domain.SavedReactionTag{ - UserID: userID, - Reaction: reaction, - Title: title, - }); err != nil { - return false, channelInvalidErr(err) - } - } - r.pushUserUpdates(ctx, userID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateSavedReactionTags{}}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - }) - return true, nil -} - -func (r *Router) onMessagesGetDefaultTagReactions(ctx context.Context, hash int64) (tg.MessagesReactionsClass, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - return messagesReactionsEmpty(hash), nil -} - -func (r *Router) onMessagesGetWebPagePreview(ctx context.Context, req *tg.MessagesGetWebPagePreviewRequest) (*tg.MessagesWebPagePreview, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - message := strings.TrimSpace(req.Message) - if message == "" { - return nil, messageEmptyErr() - } - if utf8.RuneCountInString(message) > maxSendMessageTextLength || len(req.Entities) > maxMessageEntityCount { - return nil, limitInvalidErr() - } - return &tg.MessagesWebPagePreview{ - Media: &tg.MessageMediaEmpty{}, - Chats: []tg.ChatClass{}, - Users: []tg.UserClass{}, - }, nil -} - -// onMessagesUploadMedia / onMessagesSendMedia / onMessagesSendMultiMedia 实现见 send_media.go。 - -func (r *Router) onMessagesGetScheduledMessages(ctx context.Context, req *tg.MessagesGetScheduledMessagesRequest) (tg.MessagesMessagesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if err := validateMessageIDVector(req.ID); err != nil { - return nil, err - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - return &tg.MessagesMessages{ - Messages: []tg.MessageClass{}, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) onMessagesGetScheduledHistory(ctx context.Context, req *tg.MessagesGetScheduledHistoryRequest) (tg.MessagesMessagesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - if req.Hash != 0 { - return &tg.MessagesMessagesNotModified{Count: 0}, nil - } - return &tg.MessagesMessages{ - Messages: []tg.MessageClass{}, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) onMessagesSendScheduledMessages(ctx context.Context, req *tg.MessagesSendScheduledMessagesRequest) (tg.UpdatesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if err := validateMessageIDVector(req.ID); err != nil { - return nil, err - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - if len(req.ID) == 0 { - return tgEmptyUpdates(int(r.clock.Now().Unix())), nil - } - return nil, messageIDInvalidErr() -} - -func (r *Router) onMessagesDeleteScheduledMessages(ctx context.Context, req *tg.MessagesDeleteScheduledMessagesRequest) (tg.UpdatesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if err := validateMessageIDVector(req.ID); err != nil { - return nil, err - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - return &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateDeleteScheduledMessages{ - Peer: tgPeer(peer), - Messages: append([]int(nil), req.ID...), - }}, - Users: []tg.UserClass{}, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Date: int(r.clock.Now().Unix()), - Seq: 0, - }, nil -} - -func (r *Router) onMessagesCreateForumTopic(ctx context.Context, req *tg.MessagesCreateForumTopicRequest) (tg.UpdatesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if err := validateForumTopicTitle(req.Title, req.TitleMissing); err != nil { - return nil, err - } - if req.RandomID == 0 { - return nil, randomIDEmptyErr() - } - peer, err := r.forumTopicPeer(ctx, req.Peer) - if err != nil { - return nil, err - } - if r.deps.Channels == nil { - return nil, internalErr() - } - var sendAs *domain.Peer - if req.SendAs != nil { - sendAs, err = r.forumSendAsPeer(ctx, req.Peer, req.SendAs) - if err != nil { - return nil, err - } - } - res, err := r.deps.Channels.CreateForumTopic(ctx, userID, domain.CreateChannelForumTopicRequest{ - UserID: userID, - ChannelID: peer.ID, - Title: strings.TrimSpace(req.Title), - TitleMissing: req.TitleMissing, - IconColor: req.IconColor, - IconEmojiID: req.IconEmojiID, - RandomID: req.RandomID, - SendAs: sendAs, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, forumTopicError(err) - } - sendRes := domain.SendChannelMessageResult{ - Channel: res.Channel, - Message: res.Message, - Event: res.Event, - Recipients: res.Recipients, - Duplicate: res.Duplicate, - } - updates := r.channelMessageUpdates(ctx, userID, sendRes, req.RandomID) - if !res.Duplicate { - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelMessageUpdates(ctx, viewerUserID, sendRes, 0) - }) - } - return updates, nil -} - -func (r *Router) onMessagesEditForumTopic(ctx context.Context, req *tg.MessagesEditForumTopicRequest) (tg.UpdatesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if title, ok := req.GetTitle(); ok { - if err := validateForumTopicTitle(title, false); err != nil { - return nil, err - } - } - if req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - peer, err := r.forumTopicPeer(ctx, req.Peer) - if err != nil { - return nil, err - } - if r.deps.Channels == nil { - return nil, internalErr() - } - edit := domain.EditChannelForumTopicRequest{ - UserID: userID, - ChannelID: peer.ID, - TopicID: req.TopicID, - Date: int(r.clock.Now().Unix()), - } - if title, ok := req.GetTitle(); ok { - edit.Title = &title - } - if iconEmojiID, ok := req.GetIconEmojiID(); ok { - edit.IconEmojiID = &iconEmojiID - } - if closed, ok := req.GetClosed(); ok { - edit.Closed = &closed - } - if hidden, ok := req.GetHidden(); ok { - edit.Hidden = &hidden - } - res, err := r.deps.Channels.EditForumTopic(ctx, userID, edit) - if err != nil { - return nil, forumTopicError(err) - } - sendRes := domain.SendChannelMessageResult{ - Channel: res.Channel, - Message: res.Message, - Event: res.Event, - Recipients: res.Recipients, - } - updates := r.channelMessageUpdates(ctx, userID, sendRes, 0) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelMessageUpdates(ctx, viewerUserID, sendRes, 0) - }) - return updates, nil -} - -func (r *Router) onMessagesUpdatePinnedForumTopic(ctx context.Context, req *tg.MessagesUpdatePinnedForumTopicRequest) (tg.UpdatesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - peer, err := r.forumTopicPeer(ctx, req.Peer) - if err != nil { - return nil, err - } - if r.deps.Channels == nil { - return nil, internalErr() - } - res, err := r.deps.Channels.UpdatePinnedForumTopic(ctx, userID, domain.UpdateChannelForumTopicPinnedRequest{ - UserID: userID, - ChannelID: peer.ID, - TopicID: req.TopicID, - Pinned: req.Pinned, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, forumTopicError(err) - } - updates := r.pinnedForumTopicUpdates(userID, res.Channel, res.Topic.TopicID, res.Topic.Pinned) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.pinnedForumTopicUpdates(viewerUserID, res.Channel, res.Topic.TopicID, res.Topic.Pinned) - }) - return updates, nil -} - -func (r *Router) onMessagesReorderPinnedForumTopics(ctx context.Context, req *tg.MessagesReorderPinnedForumTopicsRequest) (tg.UpdatesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if err := validateMessageIDVector(req.Order); err != nil { - return nil, err - } - peer, err := r.forumTopicPeer(ctx, req.Peer) - if err != nil { - return nil, err - } - if r.deps.Channels == nil { - return nil, internalErr() - } - res, err := r.deps.Channels.ReorderPinnedForumTopics(ctx, userID, domain.ReorderChannelPinnedForumTopicsRequest{ - UserID: userID, - ChannelID: peer.ID, - Order: append([]int(nil), req.Order...), - Force: req.Force, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, forumTopicError(err) - } - updates := r.pinnedForumTopicsOrderUpdates(userID, res.Channel, res.Order) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.pinnedForumTopicsOrderUpdates(viewerUserID, res.Channel, res.Order) - }) - return updates, nil -} - -func (r *Router) onMessagesDeleteTopicHistory(ctx context.Context, req *tg.MessagesDeleteTopicHistoryRequest) (*tg.MessagesAffectedHistory, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.TopMsgID <= 0 || req.TopMsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - peer, err := r.forumTopicPeer(ctx, req.Peer) - if err != nil { - return nil, err - } - if r.deps.Channels == nil { - return nil, internalErr() - } - res, err := r.deps.Channels.DeleteForumTopicHistory(ctx, userID, domain.DeleteChannelForumTopicHistoryRequest{ - UserID: userID, - ChannelID: peer.ID, - TopicID: req.TopMsgID, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, forumTopicError(err) - } - if res.Event.Pts != 0 { - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return &tg.Updates{ - Updates: []tg.UpdateClass{tgChannelUpdate(viewerUserID, res.Event)}, - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, res.Channel, nil)}, - Date: res.Event.Date, - Seq: 0, - } - }) - return &tg.MessagesAffectedHistory{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil - } - return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: 0, Offset: res.Offset}, nil -} - -func validateMessageIDVector(ids []int) error { - if len(ids) > maxGetMessagesIDs { - return limitInvalidErr() - } - for _, id := range ids { - if id <= 0 || id > domain.MaxMessageBoxID { - return messageIDInvalidErr() - } - } - return nil -} - -func validateForumTopicTitle(title string, titleMissing bool) error { - title = strings.TrimSpace(title) - if title == "" && !titleMissing { - return topicTitleEmptyErr() - } - if utf8.RuneCountInString(title) > maxForumTopicTitleLength { - return limitInvalidErr() - } - return nil -} - -func forumTopicError(err error) error { - switch { - case errors.Is(err, domain.ErrChannelForumMissing): - return channelForumMissingErr() - case errors.Is(err, domain.ErrMessageIDInvalid): - return topicIDInvalidErr() - case errors.Is(err, domain.ErrChannelNotModified): - return tgerr400("CHAT_NOT_MODIFIED") - default: - return channelInvalidErr(err) - } -} - -func (r *Router) forumTopicPeer(ctx context.Context, input tg.InputPeerClass) (domain.Peer, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return domain.Peer{}, internalErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) - if err != nil { - return domain.Peer{}, err - } - if peer.Type != domain.PeerTypeChannel { - return domain.Peer{}, peerIDInvalidErr() - } - return peer, nil -} - -func (r *Router) validateForumSendAs(ctx context.Context, peerInput, sendAsInput tg.InputPeerClass) error { - _, err := r.forumSendAsPeer(ctx, peerInput, sendAsInput) - return err -} - -func (r *Router) forumSendAsPeer(ctx context.Context, peerInput, sendAsInput tg.InputPeerClass) (*domain.Peer, error) { - peer, err := r.forumTopicPeer(ctx, peerInput) - if err != nil { - return nil, err - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - sendAs, err := r.checkedDomainPeerFromInputPeer(ctx, userID, sendAsInput) - if err != nil { - return nil, sendAsPeerInvalidErr() - } - if sendAs.Type == domain.PeerTypeUser && sendAs.ID == userID { - return &sendAs, nil - } - if sendAs.Type == domain.PeerTypeChannel && sendAs.ID == peer.ID { - return &sendAs, nil - } - return nil, sendAsPeerInvalidErr() -} - -func (r *Router) onMessagesReportSpam(ctx context.Context, peer tg.InputPeerClass) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportRequest) (tg.ReportResultClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - if len(req.ID) == 0 { - return nil, tgerr.New(400, "MESSAGE_REQUIRED") - } - if len(req.ID) > maxGetMessagesIDs || len(req.Option) > maxReportOptionLength || utf8.RuneCountInString(req.Message) > maxReportCommentLength { - return nil, limitInvalidErr() - } - for _, msgID := range req.ID { - if msgID <= 0 || msgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - } - return reportResultForOption(string(req.Option)) -} - -func reportResultForOption(option string) (tg.ReportResultClass, error) { - switch option { - case "": - return &tg.ReportResultChooseOption{ - Title: "Report", - Options: []tg.MessageReportOption{ - {Text: "Spam", Option: []byte("spam")}, - {Text: "Violence", Option: []byte("violence")}, - {Text: "Illegal goods", Option: []byte("illegal_goods")}, - {Text: "Child abuse", Option: []byte("child_abuse")}, - {Text: "Personal data", Option: []byte("personal_data")}, - {Text: "Copyright", Option: []byte("copyright")}, - {Text: "Other", Option: []byte("other")}, - }, - }, nil - case "other": - return &tg.ReportResultAddComment{Optional: false, Option: []byte("other:comment")}, nil - case "spam", "violence", "illegal_goods", "child_abuse", "personal_data", "copyright", "other:comment": - return &tg.ReportResultReported{}, nil - default: - return nil, tgerr.New(400, "OPTION_INVALID") - } -} - -func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesReportReactionRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return false, messageIDInvalidErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return false, err - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.MessagesReportMessagesDeliveryRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if len(req.ID) > maxGetMessagesIDs { - return false, limitInvalidErr() - } - for _, msgID := range req.ID { - if msgID <= 0 || msgID > domain.MaxMessageBoxID { - return false, messageIDInvalidErr() - } - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onMessagesReportReadMetrics(ctx context.Context, req *tg.MessagesReportReadMetricsRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if len(req.Metrics) > maxReadMetrics { - return false, limitInvalidErr() - } - for _, metric := range req.Metrics { - if metric.MsgID <= 0 || metric.MsgID > domain.MaxMessageBoxID { - return false, messageIDInvalidErr() - } - if metric.TimeInViewMs < 0 || metric.ActiveTimeInViewMs < 0 || metric.HeightToViewportRatioPermille < 0 || metric.SeenRangeRatioPermille < 0 { - return false, limitInvalidErr() - } - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return false, err - } - return true, nil -} - -func (r *Router) onMessagesReportMusicListen(ctx context.Context, req *tg.MessagesReportMusicListenRequest) (bool, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return false, internalErr() - } - if req.ID == nil { - return false, tgerr.New(400, "DOCUMENT_INVALID") - } - if req.ListenedDuration < 0 { - return false, limitInvalidErr() - } - return true, nil -} - -func (r *Router) onMessagesReportSponsoredMessage(ctx context.Context, req *tg.MessagesReportSponsoredMessageRequest) (tg.ChannelsSponsoredMessageReportResultClass, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - if len(req.RandomID) == 0 || len(req.RandomID) > maxReportRandomIDLength || len(req.Option) > maxReportOptionLength { - return nil, limitInvalidErr() - } - return &tg.ChannelsSponsoredMessageReportResultReported{}, nil -} - -func (r *Router) onMessagesGetSearchResultsCalendar(ctx context.Context, req *tg.MessagesGetSearchResultsCalendarRequest) (*tg.MessagesSearchResultsCalendar, error) { - if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || req.OffsetDate < 0 { - return nil, limitInvalidErr() - } - if err := r.validateSearchResultsPeer(ctx, req.Peer, req.GetSavedPeerID); err != nil { - return nil, err - } - minDate := req.OffsetDate - if minDate == 0 { - minDate = int(r.clock.Now().Unix()) - } - return &tg.MessagesSearchResultsCalendar{ - Count: 0, - MinDate: minDate, - MinMsgID: req.OffsetID, - Periods: []tg.SearchResultsCalendarPeriod{}, - Messages: []tg.MessageClass{}, - Chats: []tg.ChatClass{}, - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) onMessagesGetSearchResultsPositions(ctx context.Context, req *tg.MessagesGetSearchResultsPositionsRequest) (*tg.MessagesSearchResultsPositions, error) { - if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || req.Limit < 0 || req.Limit > maxSearchResultsLimit { - return nil, limitInvalidErr() - } - if err := r.validateSearchResultsPeer(ctx, req.Peer, req.GetSavedPeerID); err != nil { - return nil, err - } - return &tg.MessagesSearchResultsPositions{ - Count: 0, - Positions: []tg.SearchResultPosition{}, - }, nil -} - -func (r *Router) validateSearchResultsPeer(ctx context.Context, peer tg.InputPeerClass, savedPeer func() (tg.InputPeerClass, bool)) error { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return internalErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { - return err - } - if savedPeer == nil { - return nil - } - if input, ok := savedPeer(); ok && input != nil { - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input); err != nil { - return err - } - } - return nil -} - -func (r *Router) onMessagesSendReaction(ctx context.Context, req *tg.MessagesSendReactionRequest) (tg.UpdatesClass, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if reactions, ok := req.GetReaction(); ok && len(reactions) > maxReactionVector { - return nil, limitInvalidErr() - } - userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) - if err != nil { - return nil, err - } - reactions, err := domainMessageReactionsFromTL(req) - if err != nil { - return nil, err - } - date := int(r.clock.Now().Unix()) - if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { - res, err := r.deps.Channels.SetMessageReactions(ctx, userID, domain.SetChannelMessageReactionsRequest{ - UserID: userID, - ChannelID: peer.ID, - MessageID: req.MsgID, - Reactions: reactions, - Big: req.Big, - AddToRecent: req.GetAddToRecent(), - Date: date, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - updates := r.channelMessageReactionsUpdates(ctx, userID, res) - r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, []int64{userID}, func(viewerUserID int64) *tg.Updates { - return r.channelMessageReactionsUpdates(ctx, viewerUserID, res) - }) - return updates, nil - } - if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { - res, err := r.deps.Messages.SetMessageReactions(ctx, userID, domain.SetPrivateMessageReactionsRequest{ - UserID: userID, - Peer: peer, - MessageID: req.MsgID, - Reactions: reactions, - Big: req.Big, - AddToRecent: req.GetAddToRecent(), - Date: date, - }) - if err != nil { - return nil, messageReactionErr(err) - } - if err := r.recordMessageReactionUse(ctx, userID, reactions, req.GetAddToRecent(), date); err != nil { - return nil, internalErr() - } - if err := r.recordPrivateMessageReactionEvents(ctx, userID, res); err != nil { - return nil, internalErr() - } - updates := r.privateMessageReactionsUpdates(ctx, userID, peer, res) - r.pushUserUpdates(ctx, userID, updates) - for _, msg := range res.Messages { - if msg.OwnerUserID == 0 || msg.OwnerUserID == userID { - continue - } - viewerPeer := msg.Peer - viewerUpdates := r.privateMessageReactionsUpdates(ctx, msg.OwnerUserID, viewerPeer, res) - r.pushUserUpdates(ctx, msg.OwnerUserID, viewerUpdates) - } - return updates, nil - } - return tgEmptyUpdates(int(r.clock.Now().Unix())), nil -} - -func (r *Router) recordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { - if len(reactions) == 0 || r.deps.Channels == nil { - return nil - } - recorder, ok := r.deps.Channels.(messageReactionUsageRecorder) - if !ok { - return nil - } - return recorder.RecordMessageReactionUse(ctx, userID, reactions, addToRecent, date) -} - -func (r *Router) recordPrivateMessageReactionEvents(ctx context.Context, requestUserID int64, res domain.PrivateMessageReactionsResult) error { - if r.deps.Updates == nil { - return nil - } - recorder, ok := r.deps.Updates.(messageReactionUpdateRecorder) - if !ok { - return nil - } - authKeyID, _ := AuthKeyIDFrom(ctx) - for _, msg := range res.Messages { - if msg.OwnerUserID == 0 || msg.ID == 0 { - continue - } - eventAuthKeyID := [8]byte{} - if msg.OwnerUserID == requestUserID { - eventAuthKeyID = authKeyID - } - if _, _, err := recorder.RecordMessageReactions(ctx, eventAuthKeyID, msg.OwnerUserID, msg); err != nil { - return err - } - } - return nil -} - -func (r *Router) onMessagesSetDefaultReaction(ctx context.Context, reaction tg.ReactionClass) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - parsed, err := domainMessageReactionFromTL(reaction) - if err != nil { - return false, err - } - if svc, ok := r.deps.Account.(accountDefaultReactionService); ok { - if _, err := svc.SetDefaultReaction(ctx, userID, parsed); err != nil { - return false, internalErr() - } - } - return true, nil -} - -func (r *Router) onMessagesGetPaidReactionPrivacy(ctx context.Context) (tg.UpdatesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - settings := domain.DefaultAccountReactionSettings() - if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok { - next, err := svc.GetReactionSettings(ctx, userID) - if err != nil { - return nil, internalErr() - } - settings = next - } - return &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{ - Private: r.tgPaidReactionPrivacy(ctx, userID, settings.PaidPrivacy), - }}, - Users: []tg.UserClass{}, - Chats: []tg.ChatClass{}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - }, nil -} - -func (r *Router) onMessagesTogglePaidReactionPrivacy(ctx context.Context, req *tg.MessagesTogglePaidReactionPrivacyRequest) (bool, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return false, messageIDInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return false, err - } - privacy, err := r.domainPaidReactionPrivacy(ctx, userID, req.Private) - if err != nil { - return false, err - } - if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok { - next, err := svc.SetPaidReactionPrivacy(ctx, userID, privacy) - if err != nil { - return false, internalErr() - } - privacy = next.PaidPrivacy - } - r.pushUserUpdates(ctx, userID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{Private: r.tgPaidReactionPrivacy(ctx, userID, privacy)}}, - Users: []tg.UserClass{}, - Chats: []tg.ChatClass{}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - }) - return true, nil -} - -func (r *Router) onMessagesSendPaidReaction(ctx context.Context, req *tg.MessagesSendPaidReactionRequest) (tg.UpdatesClass, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if req.Count <= 0 { - return nil, starsAmountInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - if private, ok := req.GetPrivate(); ok { - if _, err := r.domainPaidReactionPrivacy(ctx, userID, private); err != nil { - return nil, err - } - } - return nil, balanceTooLowErr() -} - -func (r *Router) onMessagesDeleteParticipantReaction(ctx context.Context, req *tg.MessagesDeleteParticipantReactionRequest) (tg.UpdatesClass, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) - if err != nil { - return nil, err - } - participant, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Participant) - if err != nil { - return nil, err - } - if participant.Type != domain.PeerTypeUser || participant.ID == 0 { - return nil, userIDInvalidErr() - } - if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil { - return tgEmptyUpdates(int(r.clock.Now().Unix())), nil - } - moderator, ok := r.deps.Channels.(channelParticipantReactionModerator) - if !ok { - return nil, channelInvalidErr(domain.ErrChannelInvalid) - } - res, err := moderator.DeleteParticipantReaction(ctx, userID, domain.DeleteChannelParticipantReactionRequest{ - UserID: userID, - ChannelID: peer.ID, - MessageID: req.MsgID, - ParticipantUserID: participant.ID, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelInvalidErr(err) - } - updates := r.channelMessageReactionsUpdates(ctx, userID, res) - r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelMessageReactionsUpdates(ctx, viewerUserID, res) - }) - return updates, nil -} - -func (r *Router) onMessagesDeleteParticipantReactions(ctx context.Context, req *tg.MessagesDeleteParticipantReactionsRequest) (bool, error) { - userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) - if err != nil { - return false, err - } - participant, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Participant) - if err != nil { - return false, err - } - if participant.Type != domain.PeerTypeUser || participant.ID == 0 { - return false, userIDInvalidErr() - } - if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil { - return true, nil - } - moderator, ok := r.deps.Channels.(channelParticipantReactionModerator) - if !ok { - return false, channelInvalidErr(domain.ErrChannelInvalid) - } - res, err := moderator.DeleteParticipantReactions(ctx, userID, domain.DeleteChannelParticipantReactionsRequest{ - UserID: userID, - ChannelID: peer.ID, - ParticipantUserID: participant.ID, - Limit: domain.MaxDeleteParticipantReactionsBatch, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return false, channelInvalidErr(err) - } - if len(res.Messages) > 0 { - reactionRes := domain.ChannelMessageReactionsResult{ - Channel: res.Channel, - Messages: res.Messages, - Recipients: res.Recipients, - } - ids := make([]int, 0, len(res.Messages)) - for _, msg := range res.Messages { - if msg.ID > 0 { - ids = append(ids, msg.ID) - } - } - r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelMessagesReactionsUpdates(ctx, viewerUserID, reactionRes, ids) - }) - } - return true, nil -} - -func (r *Router) onMessagesGetMessagesReactions(ctx context.Context, req *tg.MessagesGetMessagesReactionsRequest) (tg.UpdatesClass, error) { - if len(req.ID) > maxGetMessagesIDs { - return nil, limitInvalidErr() - } - userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) - if err != nil { - return nil, err - } - if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { - res, err := r.deps.Channels.GetMessageReactions(ctx, userID, domain.ChannelMessageReactionsRequest{ - UserID: userID, - ChannelID: peer.ID, - IDs: append([]int(nil), req.ID...), - }) - if err != nil { - return nil, channelInvalidErr(err) - } - return r.channelMessagesReactionsUpdates(ctx, userID, res, req.ID), nil - } - if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { - res, err := r.deps.Messages.GetMessageReactions(ctx, userID, domain.PrivateMessageReactionsRequest{ - OwnerUserID: userID, - Peer: peer, - IDs: append([]int(nil), req.ID...), - }) - if err != nil { - return nil, messageReactionErr(err) - } - return r.privateMessagesReactionsUpdates(ctx, userID, peer, res, req.ID), nil - } - updates := make([]tg.UpdateClass, 0, len(req.ID)) - tgPeer := tgPeer(peer) - for _, msgID := range req.ID { - if msgID <= 0 || msgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - updates = append(updates, &tg.UpdateMessageReactions{ - Peer: tgPeer, - MsgID: msgID, - Reactions: tg.MessageReactions{ - Results: []tg.ReactionCount{}, - }, - }) - } - return &tg.Updates{ - Updates: updates, - Users: []tg.UserClass{}, - Chats: []tg.ChatClass{}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - }, nil -} - -func (r *Router) onMessagesGetMessageReactionsList(ctx context.Context, req *tg.MessagesGetMessageReactionsListRequest) (*tg.MessagesMessageReactionsList, error) { - if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if req.Limit < 0 || req.Limit > maxSearchResultsLimit { - return nil, limitInvalidErr() - } - if offset, ok := req.GetOffset(); ok && len(offset) > maxReactionListOffset { - return nil, limitInvalidErr() - } - userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) - if err != nil { - return nil, err - } - if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { - filter, err := optionalDomainMessageReaction(req.Reaction) - if err != nil { - return nil, err - } - res, err := r.deps.Channels.ListMessageReactions(ctx, userID, domain.ChannelMessageReactionsListRequest{ - UserID: userID, - ChannelID: peer.ID, - MessageID: req.ID, - Reaction: filter, - Offset: optionalString(req.GetOffset), - Limit: req.Limit, - }) - if errors.Is(err, domain.ErrChannelRightForbidden) { - return nil, tgerr.New(403, "BROADCAST_FORBIDDEN") - } - if err != nil { - return nil, channelInvalidErr(err) - } - userIDs := make([]int64, 0, len(res.Reactions)) - reactions := make([]tg.MessagePeerReaction, 0, len(res.Reactions)) - for _, item := range res.Reactions { - if item.UserID != 0 { - userIDs = append(userIDs, item.UserID) - } - if converted := tgMessagePeerReaction(userID, item); converted != nil { - reactions = append(reactions, *converted) - } - } - out := &tg.MessagesMessageReactionsList{ - Count: res.Count, - Reactions: reactions, - Chats: tgChannels(userID, []domain.Channel{res.Channel}), - Users: r.tgUsersForIDs(ctx, userID, userIDs), - } - if res.NextOffset != "" { - out.SetNextOffset(res.NextOffset) - } - return out, nil - } - if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { - filter, err := optionalDomainMessageReaction(req.Reaction) - if err != nil { - return nil, err - } - res, err := r.deps.Messages.GetMessageReactions(ctx, userID, domain.PrivateMessageReactionsRequest{ - OwnerUserID: userID, - Peer: peer, - IDs: []int{req.ID}, - }) - if err != nil { - return nil, messageReactionErr(err) - } - var source domain.ChannelMessageReactions - if len(res.Messages) > 0 && res.Messages[0].Reactions != nil { - source = *res.Messages[0].Reactions - } else { - source = res.Reactions - } - limit := req.Limit - if limit <= 0 || limit > len(source.Recent) { - limit = len(source.Recent) - } - userIDs := []int64{userID, peer.ID} - reactions := make([]tg.MessagePeerReaction, 0, limit) - count := 0 - for _, item := range source.Recent { - if filter != nil && (item.Reaction.Type != filter.Type || item.Reaction.Emoticon != filter.Emoticon) { - continue - } - count++ - if len(reactions) >= limit { - continue - } - if item.UserID != 0 { - userIDs = append(userIDs, item.UserID) - } - if converted := tgMessagePeerReaction(userID, item); converted != nil { - reactions = append(reactions, *converted) - } - } - return &tg.MessagesMessageReactionsList{ - Count: count, - Reactions: reactions, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Users: r.tgUsersForIDs(ctx, userID, userIDs), - }, nil - } - return &tg.MessagesMessageReactionsList{ - Count: 0, - Reactions: []tg.MessagePeerReaction{}, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) channelMessageReactionsUpdates(ctx context.Context, viewerUserID int64, res domain.ChannelMessageReactionsResult) *tg.Updates { - ids := []int{res.Message.ID} - if res.Message.ID <= 0 && len(res.Messages) > 0 { - ids = []int{res.Messages[0].ID} - } - return r.channelMessagesReactionsUpdates(ctx, viewerUserID, res, ids) -} - -func (r *Router) privateMessageReactionsUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, res domain.PrivateMessageReactionsResult) *tg.Updates { - ids := make([]int, 0, 1) - for _, msg := range res.Messages { - if msg.OwnerUserID == viewerUserID && msg.ID > 0 { - ids = append(ids, msg.ID) - break - } - } - return r.privateMessagesReactionsUpdates(ctx, viewerUserID, peer, res, ids) -} - -func (r *Router) privateMessagesReactionsUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, res domain.PrivateMessageReactionsResult, ids []int) *tg.Updates { - updates := make([]tg.UpdateClass, 0, len(ids)) - messagesByID := make(map[int]domain.Message, len(res.Messages)) - userIDs := []int64{viewerUserID} - if peer.Type == domain.PeerTypeUser && peer.ID != 0 { - userIDs = append(userIDs, peer.ID) - } - for _, msg := range res.Messages { - if msg.OwnerUserID != viewerUserID || msg.ID == 0 { - continue - } - messagesByID[msg.ID] = msg - if msg.Peer.Type == domain.PeerTypeUser && msg.Peer.ID != 0 { - userIDs = append(userIDs, msg.Peer.ID) - } - if msg.From.Type == domain.PeerTypeUser && msg.From.ID != 0 { - userIDs = append(userIDs, msg.From.ID) - } - if msg.Reactions != nil { - userIDs = append(userIDs, channelMessageReactionUserIDs(*msg.Reactions)...) - } - } - userIDs = append(userIDs, channelMessageReactionUserIDs(res.Reactions)...) - fallbackPeer := tgPeer(peer) - for _, id := range ids { - if id <= 0 || id > domain.MaxMessageBoxID { - continue - } - msg, ok := messagesByID[id] - outPeer := fallbackPeer - reactions := domain.ChannelMessageReactions{ - CanSeeList: true, - Results: []domain.ChannelMessageReactionCount{}, - Recent: []domain.ChannelMessagePeerReaction{}, - } - if ok { - outPeer = tgPeer(msg.Peer) - if msg.Reactions != nil { - reactions = *msg.Reactions - } - } - if outPeer == nil { - continue - } - converted := tgMessageReactions(viewerUserID, &reactions) - if converted == nil { - converted = &tg.MessageReactions{Results: []tg.ReactionCount{}} - } - updates = append(updates, &tg.UpdateMessageReactions{ - Peer: outPeer, - MsgID: id, - Reactions: *converted, - }) - } - return &tg.Updates{ - Updates: updates, - Users: r.tgUsersForIDs(ctx, viewerUserID, userIDs), - Chats: []tg.ChatClass{}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func (r *Router) channelMessagesReactionsUpdates(ctx context.Context, viewerUserID int64, res domain.ChannelMessageReactionsResult, ids []int) *tg.Updates { - updates := make([]tg.UpdateClass, 0, len(ids)) - messagesByID := make(map[int]domain.ChannelMessage, len(res.Messages)+1) - if res.Message.ID != 0 { - messagesByID[res.Message.ID] = res.Message - } - for _, msg := range res.Messages { - if msg.ID != 0 { - messagesByID[msg.ID] = msg - } - } - userIDs := make([]int64, 0) - for _, msg := range messagesByID { - if msg.Reactions != nil { - userIDs = append(userIDs, channelMessageReactionUserIDs(*msg.Reactions)...) - } - } - userIDs = append(userIDs, channelMessageReactionUserIDs(res.Reactions)...) - for _, id := range ids { - if id <= 0 || id > domain.MaxMessageBoxID { - continue - } - msg, ok := messagesByID[id] - reactions := domain.ChannelMessageReactions{ - CanSeeList: !res.Channel.Broadcast || res.Channel.Megagroup, - Results: []domain.ChannelMessageReactionCount{}, - Recent: []domain.ChannelMessagePeerReaction{}, - } - if ok && msg.Reactions != nil { - reactions = *msg.Reactions - } else if ok && len(res.Reactions.Results) > 0 && res.Message.ID == id { - reactions = res.Reactions - } - converted := tgMessageReactions(viewerUserID, &reactions) - if converted == nil { - converted = &tg.MessageReactions{Results: []tg.ReactionCount{}} - } - update := &tg.UpdateMessageReactions{ - Peer: &tg.PeerChannel{ChannelID: res.Channel.ID}, - MsgID: id, - Reactions: *converted, - } - if ok { - if topID := channelMessageThreadRootID(msg); topID > 0 && topID != id { - update.SetTopMsgID(topID) - } - } - updates = append(updates, update) - } - return &tg.Updates{ - Updates: updates, - Users: r.tgUsersForIDs(ctx, viewerUserID, userIDs), - Chats: tgChannels(viewerUserID, []domain.Channel{res.Channel}), - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func channelMessageReactionUserIDs(reactions domain.ChannelMessageReactions) []int64 { - out := make([]int64, 0, len(reactions.Recent)) - for _, item := range reactions.Recent { - if item.UserID != 0 { - out = append(out, item.UserID) - } - } - return out -} - -func (r *Router) onMessagesGetUnreadReactions(ctx context.Context, req *tg.MessagesGetUnreadReactionsRequest) (tg.MessagesMessagesClass, error) { - if err := validateHistoryBounds(req.OffsetID, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { - return nil, err - } - userID, peer, err := r.reactionPeer(ctx, req.Peer, req.GetSavedPeerID) - if err != nil { - return nil, err - } - if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { - return nil, messageIDInvalidErr() - } - if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { - history, err := r.deps.Channels.GetUnreadReactions(ctx, userID, domain.ChannelUnreadReactionsFilter{ - ChannelID: peer.ID, - TopMsgID: req.TopMsgID, - OffsetID: req.OffsetID, - AddOffset: req.AddOffset, - Limit: req.Limit, - MaxID: req.MaxID, - MinID: req.MinID, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - return tgChannelHistoryMessages(userID, r.enrichChannelHistory(ctx, userID, history)), nil - } - return &tg.MessagesMessages{ - Messages: []tg.MessageClass{}, - Topics: []tg.ForumTopicClass{}, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) onMessagesReadReactions(ctx context.Context, req *tg.MessagesReadReactionsRequest) (*tg.MessagesAffectedHistory, error) { - authKeyID, _ := AuthKeyIDFrom(ctx) - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if savedPeer, ok := req.GetSavedPeerID(); ok && savedPeer != nil { - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, savedPeer); err != nil { - return nil, err - } - } - if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { - return nil, messageIDInvalidErr() - } - if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { - res, err := r.deps.Channels.ReadReactions(ctx, userID, domain.ReadChannelReactionsRequest{ - UserID: userID, - ChannelID: peer.ID, - TopMsgID: req.TopMsgID, - Limit: domain.MaxChannelReadReactionsBatch, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - return &tg.MessagesAffectedHistory{Pts: res.ChannelPts, PtsCount: 0, Offset: res.Offset}, nil - } - return r.affectedHistory(ctx, authKeyID, userID, 0) -} - -func (r *Router) onMessagesSendVote(ctx context.Context, req *tg.MessagesSendVoteRequest) (tg.UpdatesClass, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if err := validatePollOptions(req.Options, true); err != nil { - return nil, err - } - if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { - return nil, err - } - return nil, messageIDInvalidErr() -} - -func (r *Router) onMessagesGetPollResults(ctx context.Context, req *tg.MessagesGetPollResultsRequest) (tg.UpdatesClass, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { - return nil, err - } - return tgEmptyUpdates(int(r.clock.Now().Unix())), nil -} - -func (r *Router) onMessagesGetPollVotes(ctx context.Context, req *tg.MessagesGetPollVotesRequest) (*tg.MessagesVotesList, error) { - if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if req.Limit < 0 || req.Limit > maxSearchResultsLimit { - return nil, limitInvalidErr() - } - if option, ok := req.GetOption(); ok { - if err := validatePollOption(option); err != nil { - return nil, err - } - } - if offset, ok := req.GetOffset(); ok && len(offset) > maxPollVotesOffsetLength { - return nil, limitInvalidErr() - } - userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) - if err != nil { - return nil, err - } - if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { - view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) - if err != nil { - return nil, channelInvalidErr(err) - } - if view.Channel.Broadcast && !view.Channel.Megagroup { - return nil, tgerr.New(403, "BROADCAST_FORBIDDEN") - } - } - return &tg.MessagesVotesList{ - Count: 0, - Votes: []tg.MessagePeerVoteClass{}, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) onMessagesAddPollAnswer(ctx context.Context, req *tg.MessagesAddPollAnswerRequest) (tg.UpdatesClass, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if err := validatePollAnswer(req.Answer); err != nil { - return nil, err - } - if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { - return nil, err - } - return nil, messageIDInvalidErr() -} - -func (r *Router) onMessagesDeletePollAnswer(ctx context.Context, req *tg.MessagesDeletePollAnswerRequest) (tg.UpdatesClass, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if err := validatePollOption(req.Option); err != nil { - return nil, err - } - if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { - return nil, err - } - return nil, messageIDInvalidErr() -} - -func (r *Router) onMessagesGetUnreadPollVotes(ctx context.Context, req *tg.MessagesGetUnreadPollVotesRequest) (tg.MessagesMessagesClass, error) { - if err := validateHistoryBounds(req.OffsetID, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { - return nil, err - } - userID, _, err := r.reactionPeer(ctx, req.Peer, nil) - if err != nil { - return nil, err - } - if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { - return nil, messageIDInvalidErr() - } - return &tg.MessagesMessages{ - Messages: []tg.MessageClass{}, - Topics: []tg.ForumTopicClass{}, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) onMessagesReadPollVotes(ctx context.Context, req *tg.MessagesReadPollVotesRequest) (*tg.MessagesAffectedHistory, error) { - authKeyID, _ := AuthKeyIDFrom(ctx) - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { - return nil, messageIDInvalidErr() - } - return r.affectedHistory(ctx, authKeyID, userID, 0) -} - -func (r *Router) onMessagesAppendTodoList(ctx context.Context, req *tg.MessagesAppendTodoListRequest) (tg.UpdatesClass, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if len(req.List) == 0 { - return nil, todoNotModifiedErr() - } - if err := validateTodoItems(req.List); err != nil { - return nil, err - } - if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { - return nil, err - } - return nil, messageIDInvalidErr() -} - -func (r *Router) onMessagesToggleTodoCompleted(ctx context.Context, req *tg.MessagesToggleTodoCompletedRequest) (tg.UpdatesClass, error) { - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if len(req.Completed) == 0 && len(req.Incompleted) == 0 { - return nil, todoNotModifiedErr() - } - if err := validateTodoIDVector(req.Completed, req.Incompleted); err != nil { - return nil, err - } - if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { - return nil, err - } - return nil, messageIDInvalidErr() -} - -func (r *Router) reactionPeer(ctx context.Context, peer tg.InputPeerClass, savedPeer func() (tg.InputPeerClass, bool)) (int64, domain.Peer, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return 0, domain.Peer{}, internalErr() - } - out, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) - if err != nil { - return 0, domain.Peer{}, err - } - if savedPeer != nil { - if input, ok := savedPeer(); ok && input != nil { - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input); err != nil { - return 0, domain.Peer{}, err - } - } - } - return userID, out, nil -} - -func (r *Router) domainPaidReactionPrivacy(ctx context.Context, userID int64, in tg.PaidReactionPrivacyClass) (domain.PaidReactionPrivacy, error) { - switch typed := in.(type) { - case nil, *tg.PaidReactionPrivacyDefault: - return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyDefault}, nil - case *tg.PaidReactionPrivacyAnonymous: - return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyAnonymous}, nil - case *tg.PaidReactionPrivacyPeer: - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, typed.Peer) - if err != nil { - return domain.PaidReactionPrivacy{}, err - } - return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyPeer, Peer: &peer}, nil - default: - return domain.PaidReactionPrivacy{}, inputConstructorInvalidErr() - } -} - -func (r *Router) tgPaidReactionPrivacy(ctx context.Context, userID int64, in domain.PaidReactionPrivacy) tg.PaidReactionPrivacyClass { - switch in.Kind { - case domain.PaidReactionPrivacyAnonymous: - return &tg.PaidReactionPrivacyAnonymous{} - case domain.PaidReactionPrivacyPeer: - if in.Peer == nil { - return &tg.PaidReactionPrivacyDefault{} - } - if peer := r.inputPeerForDomainPeer(ctx, userID, *in.Peer); peer != nil { - return &tg.PaidReactionPrivacyPeer{Peer: peer} - } - } - return &tg.PaidReactionPrivacyDefault{} -} - -func (r *Router) inputPeerForDomainPeer(ctx context.Context, currentUserID int64, peer domain.Peer) tg.InputPeerClass { - switch peer.Type { - case domain.PeerTypeUser: - switch { - case peer.ID == domain.OfficialSystemUserID: - u := domain.OfficialSystemUser() - return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash} - case r.deps.Users == nil: - return nil - case peer.ID == currentUserID: - u, err := r.deps.Users.Self(ctx, currentUserID) - if err != nil || u.ID == 0 { - return nil - } - return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash} - default: - u, found, err := r.deps.Users.ByID(ctx, currentUserID, peer.ID) - if err != nil || !found { - return nil - } - return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash} - } - case domain.PeerTypeChannel: - if r.deps.Channels == nil || peer.ID == 0 { - return nil - } - view, err := r.deps.Channels.GetChannel(ctx, currentUserID, peer.ID) - if err != nil || view.Channel.ID == 0 { - return nil - } - return &tg.InputPeerChannel{ChannelID: view.Channel.ID, AccessHash: view.Channel.AccessHash} - default: - return nil - } -} - -func domainMessageReactionsFromTL(req *tg.MessagesSendReactionRequest) ([]domain.MessageReaction, error) { - if req == nil { - return nil, nil - } - reactions, ok := req.GetReaction() - if !ok || len(reactions) == 0 { - return nil, nil - } - out := make([]domain.MessageReaction, 0, len(reactions)) - seen := make(map[string]struct{}, len(reactions)) - for _, reaction := range reactions { - parsed, err := domainMessageReactionFromTL(reaction) - if err != nil { - return nil, err - } - key := string(parsed.Type) + "\x00" + parsed.Emoticon - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, parsed) - } - return out, nil -} - -func optionalDomainMessageReaction(reaction tg.ReactionClass) (*domain.MessageReaction, error) { - if reaction == nil { - return nil, nil - } - out, err := domainMessageReactionFromTL(reaction) - if err != nil { - return nil, err - } - return &out, nil -} - -func domainMessageReactionFromTL(reaction tg.ReactionClass) (domain.MessageReaction, error) { - switch typed := reaction.(type) { - case *tg.ReactionEmoji: - emoticon := strings.TrimSpace(typed.Emoticon) - if emoticon == "" || utf8.RuneCountInString(emoticon) > domain.MaxChannelReactionEmoticonLength { - return domain.MessageReaction{}, reactionInvalidErr() - } - return domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}, nil - case nil, *tg.ReactionEmpty, *tg.ReactionCustomEmoji, *tg.ReactionPaid: - return domain.MessageReaction{}, reactionInvalidErr() - default: - return domain.MessageReaction{}, inputConstructorInvalidErr() - } -} - -func optionalString(get func() (string, bool)) string { - if get == nil { - return "" - } - value, ok := get() - if !ok { - return "" - } - return value -} - -func validateEmojiLangCode(langcode string) error { - if langcode == "" || len(langcode) > maxEmojiLangCodeLength { - return limitInvalidErr() - } - for _, c := range langcode { - switch { - case c >= 'a' && c <= 'z': - case c >= 'A' && c <= 'Z': - case c >= '0' && c <= '9': - case c == '-' || c == '_': - default: - return limitInvalidErr() - } - } - return nil -} - -func messagesReactionsEmpty(hash int64) tg.MessagesReactionsClass { - if hash != 0 { - return &tg.MessagesReactionsNotModified{} - } - return &tg.MessagesReactions{ - Hash: 0, - Reactions: []tg.ReactionClass{}, - } -} - -func messagesReactionsFromDomain(reactions []domain.MessageReaction, requestHash int64) tg.MessagesReactionsClass { - hash := messageReactionListHash(reactions) - if hash != 0 && requestHash == hash { - return &tg.MessagesReactionsNotModified{} - } - out := make([]tg.ReactionClass, 0, len(reactions)) - for _, reaction := range reactions { - tgReaction := tgMessageReaction(reaction) - if tgReaction != nil { - out = append(out, tgReaction) - } - } - return &tg.MessagesReactions{ - Hash: hash, - Reactions: out, - } -} - -func savedReactionTagsEmpty(_ int64) tg.MessagesSavedReactionTagsClass { - return &tg.MessagesSavedReactionTags{ - Tags: []tg.SavedReactionTag{}, - Hash: 0, - } -} - -func savedReactionTagsFromDomain(tags []domain.SavedReactionTag, requestHash int64) tg.MessagesSavedReactionTagsClass { - hash := savedReactionTagListHash(tags) - if hash != 0 && requestHash == hash { - return &tg.MessagesSavedReactionTagsNotModified{} - } - out := make([]tg.SavedReactionTag, 0, len(tags)) - for _, tag := range tags { - reaction := tgMessageReaction(tag.Reaction) - if reaction == nil { - continue - } - item := tg.SavedReactionTag{ - Reaction: reaction, - Count: tag.Count, - } - if tag.Title != "" { - item.SetTitle(tag.Title) - } - out = append(out, item) - } - if len(out) == 0 { - return savedReactionTagsEmpty(requestHash) - } - return &tg.MessagesSavedReactionTags{ - Tags: out, - Hash: hash, - } -} - -func (r *Router) reactionsWithCatalogFallback(ctx context.Context, reactions []domain.MessageReaction, limit int) []domain.MessageReaction { - return mergeReactionCatalogFallback(reactions, r.availableReactionCatalog(ctx, limit), limit) -} - -func reactionsWithCatalogFallback(reactions []domain.MessageReaction, limit int) []domain.MessageReaction { - return mergeReactionCatalogFallback(reactions, staticReactionCatalog(), limit) -} - -func (r *Router) availableReactionCatalog(ctx context.Context, limit int) []domain.MessageReaction { - if limit <= 0 { - return nil - } - if r.deps.Files != nil { - catalog, err := r.deps.Files.ListAvailableReactions(ctx) - if err == nil { - out := make([]domain.MessageReaction, 0, min(limit, len(catalog))) - for _, item := range catalog { - emoticon := strings.TrimSpace(item.Reaction) - if item.Inactive || emoticon == "" { - continue - } - out = append(out, domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}) - if len(out) >= limit { - return out - } - } - if len(out) > 0 { - return out - } - } - } - return staticReactionCatalog() -} - -func staticReactionCatalog() []domain.MessageReaction { - emoticons := tdesktop.DefaultReactionEmoticons() - out := make([]domain.MessageReaction, 0, len(emoticons)) - for _, emoticon := range emoticons { - out = append(out, domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}) - } - return out -} - -func mergeReactionCatalogFallback(reactions, fallback []domain.MessageReaction, limit int) []domain.MessageReaction { - if limit <= 0 { - return []domain.MessageReaction{} - } - if limit > domain.MaxTopMessageReactions { - limit = domain.MaxTopMessageReactions - } - out := make([]domain.MessageReaction, 0, limit) - seen := make(map[string]struct{}, limit) - for _, reaction := range reactions { - if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" { - continue - } - key := string(reaction.Type) + "\x00" + reaction.Emoticon - if _, ok := seen[key]; ok { - continue - } - out = append(out, reaction) - seen[key] = struct{}{} - if len(out) >= limit { - return out - } - } - for _, reaction := range fallback { - key := string(reaction.Type) + "\x00" + reaction.Emoticon - if _, ok := seen[key]; ok { - continue - } - out = append(out, reaction) - seen[key] = struct{}{} - if len(out) >= limit { - return out - } - } - return out -} - -func messageReactionListHash(reactions []domain.MessageReaction) int64 { - if len(reactions) == 0 { - return 0 - } - h := fnv.New64a() - for _, reaction := range reactions { - _, _ = h.Write([]byte(reaction.Type)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(reaction.Emoticon)) - _, _ = h.Write([]byte{0xff}) - } - sum := int64(h.Sum64() & 0x7fffffffffffffff) - if sum == 0 { - return 1 - } - return sum -} - -func savedReactionTagListHash(tags []domain.SavedReactionTag) int64 { - if len(tags) == 0 { - return 0 - } - h := fnv.New64a() - for _, tag := range tags { - _, _ = h.Write([]byte(tag.Reaction.Type)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(tag.Reaction.Emoticon)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(tag.Title)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(strconv.Itoa(tag.Count))) - _, _ = h.Write([]byte{0xff}) - } - sum := int64(h.Sum64() & 0x7fffffffffffffff) - if sum == 0 { - return 1 - } - return sum -} - -func validateReactionClass(reaction tg.ReactionClass) error { - switch typed := reaction.(type) { - case *tg.ReactionEmoji: - if strings.TrimSpace(typed.Emoticon) == "" || utf8.RuneCountInString(typed.Emoticon) > maxReportOptionLength { - return reactionInvalidErr() - } - case *tg.ReactionCustomEmoji: - if typed.DocumentID <= 0 { - return reactionInvalidErr() - } - case *tg.ReactionPaid: - case nil, *tg.ReactionEmpty: - return reactionInvalidErr() - default: - return inputConstructorInvalidErr() - } - return nil -} - -func validatePollOptions(options [][]byte, allowEmpty bool) error { - if len(options) == 0 { - if allowEmpty { - return nil - } - return optionInvalidErr() - } - if len(options) > maxPollVoteOptions { - return optionsTooMuchErr() - } - for _, option := range options { - if err := validatePollOption(option); err != nil { - return err - } - } - return nil -} - -func validatePollOption(option []byte) error { - if len(option) == 0 || len(option) > maxPollOptionBytes { - return optionInvalidErr() - } - return nil -} - -func validatePollAnswer(answer tg.PollAnswerClass) error { - if answer == nil { - return pollAnswerInvalidErr() - } - text := answer.GetText() - if strings.TrimSpace(text.Text) == "" || utf8.RuneCountInString(text.Text) > maxTodoTitleLength { - return pollAnswerInvalidErr() - } - if len(text.Entities) > maxMessageEntityCount { - return limitInvalidErr() - } - switch typed := answer.(type) { - case *tg.PollAnswer: - if err := validatePollOption(typed.Option); err != nil { - return err - } - if typed.Media != nil { - return mediaInvalidErr() - } - case *tg.InputPollAnswer: - if typed.Media != nil { - return mediaInvalidErr() - } - default: - return inputConstructorInvalidErr() - } - return nil -} - -func validateTodoItems(items []tg.TodoItem) error { - if len(items) == 0 { - return todoItemsEmptyErr() - } - if len(items) > maxTodoItems { - return limitInvalidErr() - } - seen := make(map[int]struct{}, len(items)) - for _, item := range items { - if item.ID < 0 || item.ID > maxTodoItems { - return messageIDInvalidErr() - } - if item.ID != 0 { - if _, ok := seen[item.ID]; ok { - return tgerr.New(400, "TODO_ITEM_DUPLICATE") - } - seen[item.ID] = struct{}{} - } - if strings.TrimSpace(item.Title.Text) == "" || utf8.RuneCountInString(item.Title.Text) > maxTodoTitleLength { - return limitInvalidErr() - } - if len(item.Title.Entities) > maxMessageEntityCount { - return limitInvalidErr() - } - } - return nil -} - -func validateTodoIDVector(vectors ...[]int) error { - total := 0 - seen := map[int]struct{}{} - for _, ids := range vectors { - total += len(ids) - if total > maxTodoItems { - return limitInvalidErr() - } - for _, id := range ids { - if id <= 0 || id > maxTodoItems { - return messageIDInvalidErr() - } - if _, ok := seen[id]; ok { - return todoNotModifiedErr() - } - seen[id] = struct{}{} - } - } - return nil -} - -func validateHistoryBounds(offsetID, addOffset, limit, maxID, minID int) error { - if offsetID < 0 || offsetID > domain.MaxMessageBoxID || maxID < 0 || maxID > domain.MaxMessageBoxID || minID < 0 || minID > domain.MaxMessageBoxID { - return messageIDInvalidErr() - } - if addOffset < -100 || addOffset > 100 || limit < 0 || limit > maxSearchResultsLimit { - return limitInvalidErr() - } - return nil -} - -func validateSavedHistoryBounds(offsetID, offsetDate, addOffset, limit, maxID, minID int) error { - if err := validateHistoryBounds(offsetID, addOffset, limit, maxID, minID); err != nil { - return err - } - if offsetDate < 0 { - return limitInvalidErr() - } - return nil -} - -func (r *Router) validateSavedHistoryParentPeer(ctx context.Context, userID int64, getParent func() (tg.InputPeerClass, bool)) (domain.Peer, bool, error) { - parentPeer, ok := getParent() - if !ok { - return domain.Peer{}, false, nil - } - if err := r.validateRequiredSavedHistoryParentPeer(ctx, userID, parentPeer); err != nil { - return domain.Peer{}, false, err - } - parent, _ := r.domainPeerFromInputPeer(userID, parentPeer) - return parent, true, nil -} - -func (r *Router) validateRequiredSavedHistoryParentPeer(ctx context.Context, userID int64, parentPeer tg.InputPeerClass) error { - parent, err := r.checkedDomainPeerFromInputPeer(ctx, userID, parentPeer) - if err != nil || parent.Type != domain.PeerTypeChannel { - return parentPeerInvalidErr() - } - return nil -} - -func (r *Router) savedHistoryChats(ctx context.Context, userID int64, hasParent bool, parent domain.Peer, peer tg.InputPeerClass) []tg.ChatClass { - if r.deps.Channels == nil { - return []tg.ChatClass{} - } - seen := make(map[int64]struct{}, 2) - out := make([]tg.ChatClass, 0, 2) - add := func(channelID int64) { - if channelID == 0 { - return - } - if _, ok := seen[channelID]; ok { - return - } - seen[channelID] = struct{}{} - view, err := r.deps.Channels.GetChannel(ctx, userID, channelID) - if err != nil || view.Channel.ID == 0 { - return - } - out = append(out, tgChannelChat(userID, view.Channel, &view.Self)) - } - if hasParent && parent.Type == domain.PeerTypeChannel { - add(parent.ID) - } - if p, ok := r.domainPeerFromInputPeer(userID, peer); ok && p.Type == domain.PeerTypeChannel { - add(p.ID) - } - return out -} - -func (r *Router) onMessagesGetDefaultHistoryTTL(ctx context.Context) (*tg.DefaultHistoryTTL, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - return &tg.DefaultHistoryTTL{Period: 0}, nil -} - -func (r *Router) onMessagesGetSponsoredMessages(ctx context.Context, req *tg.MessagesGetSponsoredMessagesRequest) (tg.MessagesSponsoredMessagesClass, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() - } - return &tg.MessagesSponsoredMessagesEmpty{}, nil -} - -func (r *Router) onMessagesReadMessageContents(ctx context.Context, ids []int) (*tg.MessagesAffectedMessages, error) { - id, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if len(ids) > maxGetMessagesIDs { - return nil, limitInvalidErr() - } - for _, msgID := range ids { - if msgID <= 0 || msgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - } - read := domain.ReadMessageContentsResult{OwnerUserID: userID} - if r.deps.Messages != nil { - read, err = r.deps.Messages.ReadMessageContents(ctx, userID, domain.ReadMessageContentsRequest{ - OwnerUserID: userID, - IDs: ids, - Date: int(r.clock.Now().Unix()), - OriginAuthKeyID: id, - OriginSessionID: sessionID, - }) - if err != nil { - if errors.Is(err, domain.ErrMessageIDInvalid) { - return nil, messageIDInvalidErr() - } - return nil, internalErr() - } - } - affected := &tg.MessagesAffectedMessages{Pts: read.Event.Pts, PtsCount: read.Event.PtsCount} - if read.Event.Pts == 0 { - affected, err = r.affectedMessages(ctx, id, userID) - if err != nil { - return nil, err - } - } - if contentIDs := readMessageContentIDs(read.MessageIDs); len(contentIDs) > 0 { - r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateReadMessagesContents{ - Messages: contentIDs, - Pts: affected.Pts, - PtsCount: affected.PtsCount, - }}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - }) - } - return affected, nil -} - -func readMessageContentIDs(ids []int) []int { - if len(ids) == 0 { - return nil - } - out := make([]int, 0, len(ids)) - seen := make(map[int]struct{}, len(ids)) - for _, id := range ids { - if id <= 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} - -func (r *Router) onMessagesGetMessagesViews(ctx context.Context, req *tg.MessagesGetMessagesViewsRequest) (*tg.MessagesMessageViews, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if len(req.ID) > maxGetMessagesIDs { - return nil, limitInvalidErr() - } - for _, msgID := range req.ID { - if msgID <= 0 || msgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - } - views := make([]tg.MessageViews, len(req.ID)) - peer, peerErr := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if peerErr != nil { - return nil, peerErr - } - if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil && len(req.ID) > 0 { - history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, req.ID) - if err != nil { - return nil, channelInvalidErr(err) - } - viewCounters, err := r.deps.Channels.GetMessageViews(ctx, userID, domain.ChannelMessageViewsRequest{ - UserID: userID, - ChannelID: peer.ID, - IDs: req.ID, - Increment: req.Increment, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelInvalidErr(err) - } - byID := make(map[int]domain.ChannelMessage, len(history.Messages)) - for _, msg := range history.Messages { - byID[msg.ID] = msg - } - for i, id := range req.ID { - if count, ok := viewCounters.Views[id]; ok { - views[i].SetViews(count) - } - if replies := tgChannelMessageReplies(byID[id].Replies); replies != nil { - views[i].SetReplies(*replies) - } - } - channels := make([]domain.Channel, 0, 1+len(history.Channels)) - channels = append(channels, history.Channel) - channels = append(channels, history.Channels...) - return &tg.MessagesMessageViews{ - Views: views, - Chats: tgChannels(userID, channels), - Users: r.tgUsers(history.Users), - }, nil - } - return &tg.MessagesMessageViews{ - Views: views, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) onMessagesGetUnreadMentions(ctx context.Context, req *tg.MessagesGetUnreadMentionsRequest) (tg.MessagesMessagesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.Limit < 0 || req.Limit > domain.MaxChannelUnreadMentionsLimit { - return nil, limitInvalidErr() - } - if req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID || - req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || - req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID || - req.MinID < 0 || req.MinID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { - history, err := r.deps.Channels.GetUnreadMentions(ctx, userID, domain.ChannelUnreadMentionsFilter{ - ChannelID: peer.ID, - TopMsgID: req.TopMsgID, - OffsetID: req.OffsetID, - AddOffset: req.AddOffset, - Limit: req.Limit, - MaxID: req.MaxID, - MinID: req.MinID, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - return tgChannelHistoryMessages(userID, r.enrichChannelHistory(ctx, userID, history)), nil - } - return &tg.MessagesMessages{ - Messages: []tg.MessageClass{}, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) onMessagesReadMentions(ctx context.Context, req *tg.MessagesReadMentionsRequest) (*tg.MessagesAffectedHistory, error) { - id, _ := AuthKeyIDFrom(ctx) - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { - res, err := r.deps.Channels.ReadMentions(ctx, userID, domain.ReadChannelMentionsRequest{ - UserID: userID, - ChannelID: peer.ID, - TopMsgID: req.TopMsgID, - Limit: domain.MaxChannelReadMentionsBatch, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - return &tg.MessagesAffectedHistory{Pts: res.ChannelPts, PtsCount: 0, Offset: res.Offset}, nil - } - return r.affectedHistory(ctx, id, userID, 0) -} - -func (r *Router) onMessagesGetSearchCounters(ctx context.Context, req *tg.MessagesGetSearchCountersRequest) ([]tg.MessagesSearchCounter, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if len(req.Filters) > maxMessageSearchFilters { - return nil, limitInvalidErr() - } - if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { - return nil, err - } - counters := make([]tg.MessagesSearchCounter, 0, len(req.Filters)) - for _, filter := range req.Filters { - if filter == nil { - continue - } - counters = append(counters, tg.MessagesSearchCounter{Filter: filter, Count: 0}) - } - return counters, nil -} - -func (r *Router) onMessagesGetReplies(ctx context.Context, req *tg.MessagesGetRepliesRequest) (tg.MessagesMessagesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if err := validateHistoryBounds(req.OffsetID, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { - return nil, err - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { - replies, err := r.deps.Channels.GetReplies(ctx, userID, domain.ChannelRepliesFilter{ - ChannelID: peer.ID, - RootMessageID: req.MsgID, - OffsetID: req.OffsetID, - OffsetDate: req.OffsetDate, - AddOffset: req.AddOffset, - Limit: req.Limit, - MaxID: req.MaxID, - MinID: req.MinID, - Hash: req.Hash, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - if req.Hash != 0 && replies.Hash == req.Hash { - return &tg.MessagesMessagesNotModified{Count: replies.Count}, nil - } - return tgChannelHistoryMessages(userID, r.enrichChannelHistory(ctx, userID, replies)), nil - } - return &tg.MessagesMessages{ - Messages: []tg.MessageClass{}, - Chats: r.chatsForInputPeer(ctx, userID, req.Peer), - Users: []tg.UserClass{}, - }, nil -} - -func (r *Router) onMessagesGetDiscussionMessage(ctx context.Context, req *tg.MessagesGetDiscussionMessageRequest) (*tg.MessagesDiscussionMessage, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type != domain.PeerTypeChannel { - return nil, peerIDInvalidErr() - } - if r.deps.Channels == nil { - return &tg.MessagesDiscussionMessage{Chats: r.chatsForInputPeer(ctx, userID, req.Peer)}, nil - } - discussion, err := r.deps.Channels.GetDiscussionMessage(ctx, userID, peer.ID, req.MsgID) - if err != nil { - return nil, channelInvalidErr(err) - } - return tgMessagesDiscussionMessage(userID, discussion), nil -} - -func (r *Router) onMessagesReadDiscussion(ctx context.Context, req *tg.MessagesReadDiscussionRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID || req.ReadMaxID < 0 || req.ReadMaxID > domain.MaxMessageBoxID { - return false, messageIDInvalidErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return false, err - } - if peer.Type != domain.PeerTypeChannel { - return false, peerIDInvalidErr() - } - if r.deps.Channels == nil { - return true, nil - } - discussion, err := r.deps.Channels.GetDiscussionMessage(ctx, userID, peer.ID, req.MsgID) - if err != nil { - return false, channelInvalidErr(err) - } - readChannelID := peer.ID - if discussion.DiscussionChannel.ID != 0 { - readChannelID = discussion.DiscussionChannel.ID - } - read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{ - UserID: userID, - ChannelID: readChannelID, - MaxID: req.ReadMaxID, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return false, channelInvalidErr(err) - } - if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil { - return false, err - } - r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates) - return read.Changed, nil -} - -func (r *Router) onMessagesGetForumTopics(ctx context.Context, req *tg.MessagesGetForumTopicsRequest) (*tg.MessagesForumTopics, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.Limit < 0 || req.Limit > 100 || utf8.RuneCountInString(req.Q) > maxMessageSearchQLength { - return nil, limitInvalidErr() - } - view, err := r.forumTopicPeerView(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if !view.Channel.Forum { - return nil, channelForumMissingErr() - } - includeGeneral := req.Limit > 0 && forumTopicQueryMatchesGeneral(req.Q) - list := domain.ChannelForumTopicList{} - if r.deps.Channels != nil && req.Limit > 0 { - limit := req.Limit - if includeGeneral { - limit-- - } - if limit > 0 { - list, err = r.deps.Channels.GetForumTopics(ctx, userID, domain.ChannelForumTopicFilter{ - ChannelID: view.Channel.ID, - Query: req.Q, - OffsetDate: req.OffsetDate, - OffsetID: req.OffsetID, - OffsetTopic: req.OffsetTopic, - Limit: limit, - }) - if err != nil { - return nil, forumTopicError(err) - } - } - } - return r.forumTopicsResponse(ctx, userID, view, list, includeGeneral), nil -} - -func (r *Router) onMessagesGetForumTopicsByID(ctx context.Context, req *tg.MessagesGetForumTopicsByIDRequest) (*tg.MessagesForumTopics, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if len(req.Topics) > maxForumTopicIDs { - return nil, limitInvalidErr() - } - view, err := r.forumTopicPeerView(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if !view.Channel.Forum { - return nil, channelForumMissingErr() - } - includeGeneral := false - ids := make([]int, 0, len(req.Topics)) - for _, topicID := range req.Topics { - if topicID <= 0 || topicID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - if topicID == forumGeneralTopicID { - includeGeneral = true - continue - } - ids = append(ids, topicID) - } - list := domain.ChannelForumTopicList{} - if r.deps.Channels != nil && len(ids) > 0 { - list, err = r.deps.Channels.GetForumTopicsByID(ctx, userID, view.Channel.ID, ids) - if err != nil { - return nil, forumTopicError(err) - } - } - return r.forumTopicsResponse(ctx, userID, view, list, includeGeneral), nil -} - -func (r *Router) onMessagesGetOnlines(ctx context.Context, peer tg.InputPeerClass) (*tg.ChatOnlines, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - domainPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) - if err != nil { - return nil, err - } - if domainPeer.Type == domain.PeerTypeChannel && domainPeer.ID != 0 { - return &tg.ChatOnlines{Onlines: r.channelOnlineCount(ctx, userID, domainPeer.ID)}, nil - } - return &tg.ChatOnlines{Onlines: 1}, nil -} - -func (r *Router) channelOnlineCount(ctx context.Context, userID, channelID int64) int { - if channelID == 0 || r.deps.Channels == nil || r.deps.Sessions == nil { - return 1 - } - provider, ok := r.deps.Sessions.(OnlineUserProvider) - if !ok { - return 1 - } - online := provider.OnlineChannelUserIDs(channelID, domain.MaxChannelRealtimeFanout) - candidates := make([]int64, 0, len(online)+1) - if userID != 0 { - candidates = append(candidates, userID) - } - candidates = append(candidates, online...) - active, err := r.deps.Channels.FilterActiveMemberIDs(ctx, channelID, candidates) - if err != nil { - return 1 - } - return len(active) -} - -func (r *Router) onMessagesSetTyping(ctx context.Context, req *tg.MessagesSetTypingRequest) (bool, error) { - if req == nil { - return false, inputRequestInvalidErr() - } - topMsgID, topMsgIDSet := req.GetTopMsgID() - if !topMsgIDSet && req.TopMsgID != 0 { - topMsgID, topMsgIDSet = req.TopMsgID, true - } - if topMsgIDSet && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { - return false, msgIDInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return false, err - } - if peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID { - if peer.Type == domain.PeerTypeChannel && peer.ID != 0 && r.deps.Channels != nil { - action := req.Action - if action == nil { - action = &tg.SendMessageCancelAction{} - } - updates := &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateChannelUserTyping{ - ChannelID: peer.ID, - FromID: &tg.PeerUser{UserID: userID}, - TopMsgID: topMsgID, - Action: action, - }}, - Date: int(r.clock.Now().Unix()), - } - r.pushChannelViewerUpdates(ctx, 0, peer.ID, nil, func(int64) *tg.Updates { - return updates - }) - } - return true, nil - } - action := req.Action - if action == nil { - action = &tg.SendMessageCancelAction{} - } - update := &tg.UpdateUserTyping{ - UserID: userID, - TopMsgID: topMsgID, - Action: action, - } - updates := &tg.UpdateShort{ - Update: update, - Date: int(r.clock.Now().Unix()), - } - r.pushTypingUpdate(ctx, peer.ID, updates) - return true, nil -} - -func (r *Router) pushTypingUpdate(ctx context.Context, targetUserID int64, updates *tg.UpdateShort) { - r.pushUserMessage(ctx, targetUserID, "push typing update", updates) -} - -func (r *Router) onMessagesGetMessages(ctx context.Context, ids []tg.InputMessageClass) (tg.MessagesMessagesClass, error) { - if len(ids) > maxGetMessagesIDs { - return nil, limitInvalidErr() - } - if r.deps.Messages == nil || len(ids) == 0 { - return &tg.MessagesMessages{}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - - out := make([]tg.MessageClass, 0, len(ids)) - requestedIDs := make([]int, 0, len(ids)) - for _, input := range ids { - id, ok := inputMessageBoxID(input) - if !ok || id <= 0 || id > domain.MaxMessageBoxID { - continue - } - requestedIDs = append(requestedIDs, id) - } - list, err := r.deps.Messages.GetMessages(ctx, userID, requestedIDs) - if err != nil { - return nil, internalErr() - } - foundByID := make(map[int]domain.Message, len(list.Messages)) - for _, msg := range list.Messages { - foundByID[msg.ID] = msg - } - found := make([]domain.Message, 0, len(list.Messages)) - for _, input := range ids { - id, ok := inputMessageBoxID(input) - if !ok || id <= 0 || id > domain.MaxMessageBoxID { - out = append(out, &tg.MessageEmpty{ID: id}) - continue - } - msg, ok := foundByID[id] - if !ok { - out = append(out, &tg.MessageEmpty{ID: id}) - continue - } - found = append(found, msg) - out = append(out, tgMessage(msg)) - } - chats := r.chatsForMessageUpdates(ctx, userID, found) - return &tg.MessagesMessages{ - Messages: out, - Users: r.usersForMessageUpdates(ctx, userID, found), - Chats: chats, - }, nil -} - -func inputMessageBoxID(input tg.InputMessageClass) (int, bool) { - switch msg := input.(type) { - case *tg.InputMessageID: - return msg.ID, true - default: - return 0, false - } -} - -func (r *Router) lookupOwnerMessage(ctx context.Context, userID int64, id int) (domain.Message, bool, error) { - filter := domain.MessageFilter{ - MinID: id - 1, - Limit: 1, - } - if id < domain.MaxMessageBoxID { - filter.MaxID = id + 1 - } - list, err := r.deps.Messages.Search(ctx, userID, filter) - if err != nil { - return domain.Message{}, false, err - } - if len(list.Messages) == 0 || list.Messages[0].ID != id { - return domain.Message{}, false, nil - } - return list.Messages[0], true, nil -} - -func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDraftRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return false, err - } - peerTL := tgPeer(peer) - if peerTL == nil { - return true, nil - } - date := int(r.clock.Now().Unix()) - draft, err := r.dialogDraftFromSaveDraft(ctx, userID, peer, req, date) - if err != nil { - return false, err - } - if r.deps.Dialogs != nil { - if err := r.deps.Dialogs.SaveDraft(ctx, userID, draft); err != nil { - return false, dialogDraftErr(err) - } - } - update := &tg.UpdateDraftMessage{ - Peer: peerTL, - Draft: tgDraftMessageFromSaveDraft(req, date), - } - if draft.TopMessageID > 0 { - update.SetTopMsgID(draft.TopMessageID) - } - updates := &tg.Updates{ - Updates: []tg.UpdateClass{update}, - Users: r.usersForDraftUpdate(ctx, userID, peer), - Chats: r.chatsForDraftUpdate(ctx, userID, peer), - Date: date, - Seq: 0, - } - r.pushDraftUpdate(ctx, userID, updates) - return true, nil -} - -func (r *Router) onMessagesGetAllDrafts(ctx context.Context) (tg.UpdatesClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - date := int(r.clock.Now().Unix()) - if r.deps.Dialogs == nil { - return &tg.Updates{Updates: []tg.UpdateClass{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: date, Seq: 0}, nil - } - drafts, err := r.deps.Dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser) - if err != nil { - return nil, dialogDraftErr(err) - } - updates := make([]tg.UpdateClass, 0, len(drafts)) - users := r.usersForDrafts(ctx, userID, drafts) - chats := r.chatsForDrafts(ctx, userID, drafts) - for _, draft := range drafts { - peer := tgPeer(draft.Peer) - if peer == nil { - continue - } - update := &tg.UpdateDraftMessage{Peer: peer, Draft: tgDialogDraft(draft)} - if draft.TopMessageID > 0 { - update.SetTopMsgID(draft.TopMessageID) - } - updates = append(updates, update) - } - return &tg.Updates{Updates: updates, Users: users, Chats: chats, Date: date, Seq: 0}, nil -} - -func (r *Router) onMessagesClearAllDrafts(ctx context.Context) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if r.deps.Dialogs == nil { - return true, nil - } - drafts, err := r.deps.Dialogs.ClearDrafts(ctx, userID, domain.MaxDialogDraftsPerUser) - if err != nil { - return false, dialogDraftErr(err) - } - if len(drafts) == 0 { - return true, nil - } - date := int(r.clock.Now().Unix()) - updates := make([]tg.UpdateClass, 0, len(drafts)) - for _, draft := range drafts { - update := draftClearUpdate(draft.Peer, draft.TopMessageID, date) - if update != nil { - updates = append(updates, update) - } - } - r.pushDraftUpdate(ctx, userID, &tg.Updates{ - Updates: updates, - Users: r.usersForDrafts(ctx, userID, drafts), - Chats: r.chatsForDrafts(ctx, userID, drafts), - Date: date, - Seq: 0, - }) - return true, nil -} - -func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesSaveDraftRequest, date int) (domain.DialogDraft, error) { - if req == nil { - return domain.DialogDraft{Peer: peer, Date: date}, nil - } - if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { - return domain.DialogDraft{}, messageTooLongErr() - } - if len(req.Entities) > maxMessageEntityCount { - return domain.DialogDraft{}, limitInvalidErr() - } - if !req.SuggestedPost.Zero() { - return domain.DialogDraft{}, suggestedPostPeerInvalidErr() - } - replyTo, err := r.messageReplyFromInput(ctx, userID, peer, req.ReplyTo) - if err != nil { - return domain.DialogDraft{}, err - } - webpage, err := dialogDraftWebPageFromInput(req.Media) - if err != nil { - return domain.DialogDraft{}, err - } - topMessageID := 0 - if replyTo != nil && peer.Type == domain.PeerTypeChannel && replyTo.TopMessageID > 0 { - topMessageID = replyTo.TopMessageID - } - return domain.DialogDraft{ - Peer: peer, - TopMessageID: topMessageID, - Date: date, - NoWebpage: req.NoWebpage, - InvertMedia: req.InvertMedia, - Message: req.Message, - Entities: domainMessageEntities(req.Entities), - ReplyTo: replyTo, - WebPage: webpage, - Effect: req.Effect, - }, nil -} - -func dialogDraftWebPageFromInput(media tg.InputMediaClass) (*domain.DialogDraftWebPage, error) { - switch m := media.(type) { - case nil, *tg.InputMediaEmpty: - return nil, nil - case *tg.InputMediaWebPage: - if m.URL == "" { - return nil, mediaInvalidErr() - } - return &domain.DialogDraftWebPage{ - URL: m.URL, - ForceLargeMedia: m.ForceLargeMedia, - ForceSmallMedia: m.ForceSmallMedia, - Optional: m.Optional, - }, nil - default: - return nil, mediaInvalidErr() - } -} - -func draftClearUpdate(peer domain.Peer, topMessageID, date int) *tg.UpdateDraftMessage { - peerTL := tgPeer(peer) - if peerTL == nil { - return nil - } - draft := &tg.DraftMessageEmpty{} - draft.SetDate(date) - update := &tg.UpdateDraftMessage{Peer: peerTL, Draft: draft} - if topMessageID > 0 { - update.SetTopMsgID(topMessageID) - } - return update -} - -func tgDraftMessageFromSaveDraft(req *tg.MessagesSaveDraftRequest, date int) tg.DraftMessageClass { - if req == nil || saveDraftIsEmpty(req) { - draft := &tg.DraftMessageEmpty{} - draft.SetDate(date) - return draft - } - return &tg.DraftMessage{ - NoWebpage: req.NoWebpage, - InvertMedia: req.InvertMedia, - ReplyTo: req.ReplyTo, - Message: req.Message, - Entities: req.Entities, - Media: draftInputMedia(req.Media), - Date: date, - Effect: req.Effect, - SuggestedPost: req.SuggestedPost, - } -} - -func saveDraftIsEmpty(req *tg.MessagesSaveDraftRequest) bool { - return !req.NoWebpage && - !req.InvertMedia && - draftReplyIsEmpty(req.ReplyTo) && - req.Message == "" && - len(req.Entities) == 0 && - draftInputMedia(req.Media) == nil && - req.Effect == 0 && - req.SuggestedPost.Zero() -} - -func draftReplyIsEmpty(reply tg.InputReplyToClass) bool { - if reply == nil { - return true - } - input, ok := reply.(*tg.InputReplyToMessage) - if !ok { - return false - } - topMsgID, hasTopMsgID := input.GetTopMsgID() - return input.ReplyToMsgID == 0 && hasTopMsgID && topMsgID > 0 -} - -func draftInputMedia(media tg.InputMediaClass) tg.InputMediaClass { - switch media.(type) { - case nil, *tg.InputMediaEmpty: - return nil - default: - return media - } -} - -func (r *Router) usersForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.UserClass { - if r.deps.Users == nil { - return []tg.UserClass{} - } - users := make([]tg.UserClass, 0, 2) - seen := map[int64]struct{}{} - if self, err := r.deps.Users.Self(ctx, userID); err == nil && self.ID != 0 { - users = append(users, r.tgSelfUser(self)) - seen[self.ID] = struct{}{} - } - if peer.Type == domain.PeerTypeUser { - if _, ok := seen[peer.ID]; !ok { - if u, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err == nil && found && u.ID != 0 { - users = append(users, r.tgUser(u)) - } - } - } - return users -} - -func (r *Router) usersForDrafts(ctx context.Context, userID int64, drafts []domain.DialogDraft) []tg.UserClass { - users := make([]tg.UserClass, 0, len(drafts)+1) - seen := map[int64]struct{}{} - if r.deps.Users != nil { - if self, err := r.deps.Users.Self(ctx, userID); err == nil && self.ID != 0 { - users = append(users, r.tgSelfUser(self)) - seen[self.ID] = struct{}{} - } - } - for _, draft := range drafts { - if draft.Peer.Type != domain.PeerTypeUser || draft.Peer.ID == 0 { - continue - } - if _, ok := seen[draft.Peer.ID]; ok { - continue - } - if r.deps.Users != nil { - if u, found, err := r.deps.Users.ByID(ctx, userID, draft.Peer.ID); err == nil && found && u.ID != 0 { - users = append(users, r.tgUser(u)) - seen[u.ID] = struct{}{} - } - } - } - return users -} - -func (r *Router) chatsForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.ChatClass { - if peer.Type != domain.PeerTypeChannel || peer.ID == 0 || r.deps.Channels == nil { - return []tg.ChatClass{} - } - view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) - if err != nil || view.Channel.ID == 0 { - return []tg.ChatClass{} - } - return []tg.ChatClass{tgChannelChat(userID, view.Channel, &view.Self)} -} - -func (r *Router) chatsForDrafts(ctx context.Context, userID int64, drafts []domain.DialogDraft) []tg.ChatClass { - if r.deps.Channels == nil { - return []tg.ChatClass{} - } - chats := make([]tg.ChatClass, 0) - seen := map[int64]struct{}{} - for _, draft := range drafts { - if draft.Peer.Type != domain.PeerTypeChannel || draft.Peer.ID == 0 { - continue - } - if _, ok := seen[draft.Peer.ID]; ok { - continue - } - seen[draft.Peer.ID] = struct{}{} - view, err := r.deps.Channels.GetChannel(ctx, userID, draft.Peer.ID) - if err != nil || view.Channel.ID == 0 { - continue - } - chats = append(chats, tgChannelChat(userID, view.Channel, &view.Self)) - } - return chats -} - -func dialogDraftErr(err error) error { - switch { - case err == nil: - return nil - case errors.Is(err, domain.ErrReplyMessageIDInvalid): - return replyMessageIDInvalidErr() - case errors.Is(err, domain.ErrChannelInvalid): - return peerIDInvalidErr() - default: - return internalErr() - } -} - -func (r *Router) pushDraftUpdate(ctx context.Context, userID int64, updates *tg.Updates) { - r.pushUserMessage(ctx, userID, "push draft update", updates) -} - -func (r *Router) clearDraftAfterSend(ctx context.Context, userID int64, peer domain.Peer, replyTo *domain.MessageReply) { - if r.deps.Dialogs == nil || userID == 0 || peer.ID == 0 { - return - } - topMessageID := 0 - if peer.Type == domain.PeerTypeChannel && replyTo != nil && replyTo.TopMessageID > 0 { - topMessageID = replyTo.TopMessageID - } - changed, err := r.deps.Dialogs.DeleteDraft(ctx, userID, peer, topMessageID) - if err != nil { - r.log.Debug("clear draft after send", zap.Int64("user_id", userID), zap.Error(err)) - return - } - if !changed { - return - } - date := int(r.clock.Now().Unix()) - update := draftClearUpdate(peer, topMessageID, date) - if update == nil { - return - } - r.pushDraftUpdate(ctx, userID, &tg.Updates{ - Updates: []tg.UpdateClass{update}, - Users: r.usersForDraftUpdate(ctx, userID, peer), - Chats: r.chatsForDraftUpdate(ctx, userID, peer), - Date: date, - Seq: 0, - }) -} - -func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSearchGlobalRequest) (tg.MessagesMessagesClass, error) { - if req.BroadcastsOnly && req.GroupsOnly { - return &tg.MessagesMessages{}, nil - } - query := normalizeSearchQuery(req.Q) - if query == "" { - return nil, searchQueryEmptyErr() - } - if utf8.RuneCountInString(query) > maxMessageSearchQLength { - return nil, limitInvalidErr() - } - if searchFilterNeedsMediaStore(req.Filter) { - return &tg.MessagesMessages{}, nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit { - limit = domain.MaxChannelGlobalSearchLimit - } - folderID, hasFolderID := req.GetFolderID() - if hasFolderID && folderID < 0 { - return nil, folderIDInvalidErr() - } - channelOffsetID, err := r.searchGlobalChannelOffsetID(ctx, userID, req.OffsetPeer) - if err != nil { - return nil, err - } - var private domain.MessageList - if !req.BroadcastsOnly && !req.GroupsOnly && r.deps.Messages != nil { - filter := domain.MessageFilter{ - Query: query, - OffsetID: req.OffsetID, - OffsetDate: req.OffsetRate, - Limit: limit + 1, - } - if req.MaxDate > 0 { - filter.OffsetDate = req.MaxDate - } - if req.UsersOnly || !req.BroadcastsOnly && !req.GroupsOnly { - private, err = r.deps.Messages.Search(ctx, userID, filter) - if err != nil { - return nil, internalErr() - } - private = r.withMessageListPresence(private) - } - } - if req.UsersOnly || r.deps.Channels == nil { - return tgMessagesMessages(userID, r.withMessageListPresence(limitMessageList(private, limit))), nil - } - channelHistory, err := r.deps.Channels.SearchJoinedMessages(ctx, userID, domain.ChannelGlobalSearchRequest{ - Query: query, - BroadcastsOnly: req.BroadcastsOnly, - GroupsOnly: req.GroupsOnly, - HasFolderID: hasFolderID, - FolderID: folderID, - OffsetRate: req.OffsetRate, - OffsetChannelID: channelOffsetID, - OffsetID: req.OffsetID, - MinDate: req.MinDate, - MaxDate: req.MaxDate, - Limit: limit, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - channelHistory = r.enrichChannelHistory(ctx, userID, channelHistory) - if req.BroadcastsOnly || req.GroupsOnly { - return tgGlobalChannelMessages(userID, limitChannelHistory(channelHistory, limit)), nil - } - return tgGlobalSearchMessages(userID, limit, private, channelHistory), nil -} - -func (r *Router) searchGlobalChannelOffsetID(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) { - if peer == nil { - return 0, nil - } - switch peer.(type) { - case *tg.InputPeerEmpty: - return 0, nil - } - ref, ok := inputPeerChannelRef(peer) - if !ok { - return 0, nil - } - if ref.ID <= 0 { - return 0, peerIDInvalidErr() - } - if ref.CheckAccessHash && r.deps.Channels != nil { - view, err := r.deps.Channels.GetChannel(ctx, userID, ref.ID) - if err != nil { - return 0, channelInvalidErr(err) - } - if !inputChannelAccessHashMatches(ref, view.Channel) { - return 0, channelInvalidErr(domain.ErrChannelPrivate) - } - } - return ref.ID, nil -} - -func limitMessageList(list domain.MessageList, limit int) domain.MessageList { - if limit <= 0 { - limit = domain.MaxChannelGlobalSearchLimit - } - if len(list.Messages) > limit { - list.Messages = list.Messages[:limit] - list.Count = limit + 1 - } - return list -} - -func limitChannelHistory(history domain.ChannelHistory, limit int) domain.ChannelHistory { - if limit <= 0 { - limit = domain.MaxChannelGlobalSearchLimit - } - if len(history.Messages) > limit { - history.Messages = history.Messages[:limit] - history.Count = limit + 1 - } - return history -} - -func tgGlobalChannelMessages(viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass { - out := tgChannelHistoryMessages(viewerUserID, history) - if slice, ok := out.(*tg.MessagesMessagesSlice); ok && len(history.Messages) > 0 { - slice.SetNextRate(history.Messages[len(history.Messages)-1].Date) - } - return out -} - -type globalSearchHit struct { - date int - peerRank int64 - messageID int - message tg.MessageClass -} - -func tgGlobalSearchMessages(viewerUserID int64, limit int, private domain.MessageList, channel domain.ChannelHistory) tg.MessagesMessagesClass { - if limit <= 0 { - limit = domain.MaxChannelGlobalSearchLimit - } - hits := make([]globalSearchHit, 0, len(private.Messages)+len(channel.Messages)) - for _, msg := range private.Messages { - item := tgMessage(msg) - if item == nil { - continue - } - hits = append(hits, globalSearchHit{ - date: msg.Date, - peerRank: msg.Peer.ID, - messageID: msg.ID, - message: item, - }) - } - for _, msg := range channel.Messages { - item := tgChannelMessage(viewerUserID, msg) - if item == nil { - continue - } - hits = append(hits, globalSearchHit{ - date: msg.Date, - peerRank: msg.ChannelID, - messageID: msg.ID, - message: item, - }) - } - sort.Slice(hits, func(i, j int) bool { - a, b := hits[i], hits[j] - if a.date != b.date { - return a.date > b.date - } - if a.peerRank != b.peerRank { - return a.peerRank > b.peerRank - } - return a.messageID > b.messageID - }) - hasMore := private.Count > len(private.Messages) || channel.Count > len(channel.Messages) || len(hits) > limit - if len(hits) > limit { - hits = hits[:limit] - } - messages := make([]tg.MessageClass, 0, len(hits)) - for _, hit := range hits { - messages = append(messages, hit.message) - } - users := append(tgUsers(private.Users), tgUsers(channel.Users)...) - chats := tgChannels(viewerUserID, channel.Channels) - if hasMore { - out := &tg.MessagesMessagesSlice{ - Count: limit + 1, - Messages: messages, - Chats: chats, - Users: users, - } - if len(hits) > 0 { - out.SetNextRate(hits[len(hits)-1].date) - } - return out - } - return &tg.MessagesMessages{Messages: messages, Chats: chats, Users: users} -} - -func (r *Router) onMessagesGetDialogFilters(ctx context.Context) (*tg.MessagesDialogFilters, error) { - if r.deps.Dialogs == nil { - return tgDialogFilters(domain.DialogFolderList{}), nil - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - list, err := r.deps.Dialogs.GetDialogFolders(ctx, userID) - if err != nil { - return nil, internalErr() - } - return tgDialogFilters(list), nil -} - -func (r *Router) onMessagesUpdateDialogFilter(ctx context.Context, req *tg.MessagesUpdateDialogFilterRequest) (bool, error) { - if req.ID < domain.DialogCustomFolderMinID { - return false, filterIDInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - filter, ok := req.GetFilter() - var folder *domain.DialogFolder - if ok { - parsed, err := r.dialogFolderFromTG(ctx, userID, req.ID, filter) - if err != nil { - return false, err - } - folder = &parsed - if r.deps.Dialogs != nil { - if err := r.deps.Dialogs.SaveDialogFolder(ctx, userID, parsed); err != nil { - return false, internalErr() - } - } - } else if r.deps.Dialogs != nil { - if err := r.deps.Dialogs.DeleteDialogFolder(ctx, userID, req.ID); err != nil { - return false, internalErr() - } - } - event := domain.UpdateEvent{ - Type: domain.UpdateEventDialogFilter, - FilterID: req.ID, - DialogFilter: folder, - Date: int(r.clock.Now().Unix()), - } - if r.deps.Updates != nil { - authKeyID, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, req.ID, folder, sessionID) - if err != nil { - return false, internalErr() - } - } - r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event)) - return true, nil -} - -func (r *Router) onMessagesUpdateDialogFiltersOrder(ctx context.Context, order []int) (bool, error) { - if len(order) > domain.MaxDialogFolders { - return false, limitInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - clean := cleanDialogFilterOrder(order) - if r.deps.Dialogs != nil { - if err := r.deps.Dialogs.ReorderDialogFolders(ctx, userID, clean); err != nil { - return false, internalErr() - } - } - event := domain.UpdateEvent{ - Type: domain.UpdateEventDialogFilterOrder, - FilterOrder: clean, - Date: int(r.clock.Now().Unix()), - } - if r.deps.Updates != nil { - authKeyID, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - event, _, err = r.deps.Updates.RecordDialogFilterOrder(ctx, authKeyID, userID, clean, sessionID) - if err != nil { - return false, internalErr() - } - } - r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event)) - return true, nil -} - -func (r *Router) onMessagesToggleDialogFilterTags(ctx context.Context, enabled bool) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if r.deps.Dialogs != nil { - if err := r.deps.Dialogs.ToggleDialogFolderTags(ctx, userID, enabled); err != nil { - return false, internalErr() - } - } - event := domain.UpdateEvent{ - Type: domain.UpdateEventDialogFilters, - TagsEnabled: enabled, - Date: int(r.clock.Now().Unix()), - } - if r.deps.Updates != nil { - authKeyID, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - event, _, err = r.deps.Updates.RecordDialogFiltersReload(ctx, authKeyID, userID, sessionID) - if err != nil { - return false, internalErr() - } - } - r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event)) - return true, nil -} - -func (r *Router) onMessagesSaveDefaultSendAs(ctx context.Context, req *tg.MessagesSaveDefaultSendAsRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if userID == 0 { - return false, peerIDInvalidErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return false, err - } - if peer.Type != domain.PeerTypeChannel { - return false, peerIDInvalidErr() - } - sendAs, err := r.sendAsPeerFromInput(ctx, userID, peer, req.SendAs) - if err != nil { - return false, err - } - if r.deps.Channels == nil { - return false, channelInvalidErr(domain.ErrChannelInvalid) - } - if _, err := r.deps.Channels.SaveDefaultSendAs(ctx, userID, domain.SaveChannelDefaultSendAsRequest{ - UserID: userID, - ChannelID: peer.ID, - SendAs: sendAs, - }); err != nil { - return false, channelInvalidErr(err) - } - return true, nil -} - -func (r *Router) sendAsPeerFromInput(ctx context.Context, userID int64, to domain.Peer, input tg.InputPeerClass) (*domain.Peer, error) { - if input == nil { - return nil, nil - } - if to.Type != domain.PeerTypeChannel { - return nil, sendAsPeerInvalidErr() - } - sendAs, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) - if err != nil { - return nil, sendAsPeerInvalidErr() - } - switch sendAs.Type { - case domain.PeerTypeUser: - if sendAs.ID != userID { - return nil, sendAsPeerInvalidErr() - } - return nil, nil - case domain.PeerTypeChannel: - if sendAs.ID != to.ID { - return nil, sendAsPeerInvalidErr() - } - if err := r.validateCurrentChannelSendAs(ctx, userID, to.ID); err != nil { - return nil, err - } - out := sendAs - return &out, nil - default: - return nil, sendAsPeerInvalidErr() - } -} - -func (r *Router) resolveSendAsPeer(ctx context.Context, userID int64, to domain.Peer, input tg.InputPeerClass) (*domain.Peer, error) { - if input != nil { - return r.sendAsPeerFromInput(ctx, userID, to, input) - } - if to.Type != domain.PeerTypeChannel || r.deps.Channels == nil { - return nil, nil - } - view, err := r.deps.Channels.GetChannel(ctx, userID, to.ID) - if err != nil { - return nil, nil - } - return validDefaultSendAsPeer(view), nil -} - -func validDefaultSendAsPeer(view domain.ChannelView) *domain.Peer { - if view.Dialog.DefaultSendAs == nil || view.Dialog.DefaultSendAs.ID == 0 { - return nil - } - switch view.Dialog.DefaultSendAs.Type { - case domain.PeerTypeUser: - return nil - case domain.PeerTypeChannel: - if view.Dialog.DefaultSendAs.ID != view.Channel.ID || !canCurrentChannelSendAs(view) { - return nil - } - out := *view.Dialog.DefaultSendAs - return &out - default: - return nil - } -} - -func (r *Router) validateCurrentChannelSendAs(ctx context.Context, userID, channelID int64) error { - if r.deps.Channels == nil { - return sendAsPeerInvalidErr() - } - view, err := r.deps.Channels.GetChannel(ctx, userID, channelID) - if err != nil { - return sendAsPeerInvalidErr() - } - if canCurrentChannelSendAs(view) { - return nil - } - return sendAsPeerInvalidErr() -} - -func canCurrentChannelSendAs(view domain.ChannelView) bool { - if view.Self.Role == domain.ChannelRoleCreator { - return true - } - if view.Channel.Broadcast && view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.PostMessages { - return true - } - if !view.Channel.Broadcast && view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.Anonymous { - return true - } - return false -} - -func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) { - start := r.clock.Now() - var duplicate bool - var sendErr error - defer func() { - r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr) - }() - if req.Message == "" { - sendErr = messageEmptyErr() - return nil, messageEmptyErr() - } - if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { - sendErr = messageTooLongErr() - return nil, sendErr - } - if len(req.Entities) > maxMessageEntityCount { - sendErr = limitInvalidErr() - return nil, sendErr - } - if req.RandomID == 0 { - sendErr = randomIDEmptyErr() - return nil, sendErr - } - if req.ScheduleDate != 0 || req.ScheduleRepeatPeriod != 0 { - sendErr = scheduleDateInvalidErr() - return nil, sendErr - } - if err := sendMessageUnsupportedOptionErr(req); err != nil { - sendErr = err - return nil, sendErr - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - sendErr = internalErr() - return nil, sendErr - } - if userID == 0 { - sendErr = peerIDInvalidErr() - return nil, sendErr - } - if r.deps.Limiter != nil { - allowed, retryAfter, err := r.deps.Limiter.Allow(ctx, "messages:send:"+strconv.FormatInt(userID, 10), sendMessageRateLimit, sendMessageRateWindow) - if err != nil { - sendErr = internalErr() - return nil, sendErr - } - if !allowed { - r.metrics().MessageRateLimited(retryAfter) - sendErr = floodWaitErr(retryAfter) - return nil, sendErr - } - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - sendErr = err - return nil, sendErr - } - updates, dup, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{ - randomID: req.RandomID, - message: req.Message, - entities: req.Entities, - silent: req.Silent, - noforwards: req.Noforwards, - replyToInput: req.ReplyTo, - sendAsInput: req.SendAs, - clearDraft: req.ClearDraft, - }) - duplicate = dup - if err != nil { - sendErr = err - return nil, sendErr - } - return updates, nil -} - -func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.MessagesForwardMessagesRequest) (tg.UpdatesClass, error) { - if len(req.ID) == 0 || len(req.ID) != len(req.RandomID) { - return nil, inputRequestInvalidErr() - } - if len(req.ID) > domain.MaxForwardMessageIDs { - return nil, limitInvalidErr() - } - if req.ScheduleDate != 0 || req.ScheduleRepeatPeriod != 0 { - return nil, scheduleDateInvalidErr() - } - if err := forwardMessagesUnsupportedOptionErr(req); err != nil { - return nil, err - } - topMsgID, topMsgIDSet := req.GetTopMsgID() - if !topMsgIDSet && req.TopMsgID != 0 { - topMsgID, topMsgIDSet = req.TopMsgID, true - } - if topMsgIDSet && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { - return nil, replyMessageIDInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if userID == 0 { - return nil, peerIDInvalidErr() - } - fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.FromPeer) - if err != nil { - return nil, err - } - toPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ToPeer) - if err != nil { - return nil, err - } - sendAs, err := r.resolveSendAsPeer(ctx, userID, toPeer, req.SendAs) - if err != nil { - return nil, err - } - replyTo, err := r.messageReplyFromInput(ctx, userID, toPeer, req.ReplyTo) - if err != nil { - return nil, err - } - replyTo, err = mergeForwardTopMsgID(toPeer, replyTo, topMsgID, topMsgIDSet) - if err != nil { - return nil, err - } - if r.deps.Users != nil { - for _, peer := range []domain.Peer{fromPeer, toPeer} { - if peer.Type != domain.PeerTypeUser || peer.ID == userID { - continue - } - if _, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err != nil { - return nil, internalErr() - } else if !found { - return nil, peerIDInvalidErr() - } - } - } - for i, id := range req.ID { - if id <= 0 || id > domain.MaxMessageBoxID || req.RandomID[i] == 0 { - return nil, messageIDInvalidErr() - } - } - if r.deps.Limiter != nil { - allowed, retryAfter, err := r.deps.Limiter.Allow(ctx, "messages:forward:"+strconv.FormatInt(userID, 10), sendMessageRateLimit, sendMessageRateWindow) - if err != nil { - return nil, internalErr() - } - if !allowed { - r.metrics().MessageRateLimited(retryAfter) - return nil, floodWaitErr(retryAfter) - } - } - if toPeer.Type == domain.PeerTypeChannel { - if r.deps.Channels == nil { - return nil, peerIDInvalidErr() - } - sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID) - if err != nil { - return nil, messageForwardErr(err) - } - recipients := make([]int64, 0) - results := make([]domain.SendChannelMessageResult, 0, len(sources)) - extraUserIDs := make([]int64, 0, len(sources)) - for i, source := range sources { - forward := source.forward - if req.DropAuthor { - forward = nil - } - res, err := r.deps.Channels.SendMessage(ctx, userID, domain.SendChannelMessageRequest{ - UserID: userID, - ChannelID: toPeer.ID, - RandomID: req.RandomID[i], - Message: source.body, - Entities: source.entities, - Media: source.media, - Silent: req.Silent, - NoForwards: req.Noforwards, - ReplyTo: replyTo, - Forward: forward, - SendAs: sendAs, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelInvalidErr(err) - } - results = append(results, res) - recipients = append(recipients, res.Recipients...) - if sourceUserID := source.userID(); sourceUserID != 0 { - extraUserIDs = append(extraUserIDs, sourceUserID) - } - } - updates := r.channelMessagesUpdates(ctx, userID, results, req.RandomID, true, extraUserIDs) - r.pushChannelUpdates(ctx, userID, toPeer.ID, recipients, func(viewerUserID int64) *tg.Updates { - return r.channelMessagesUpdates(ctx, viewerUserID, results, nil, false, extraUserIDs) - }) - for _, res := range results { - r.pushChannelDiscussionUpdate(ctx, userID, res.Discussion) - } - return updates, nil - } - if fromPeer.Type == domain.PeerTypeChannel && toPeer.Type == domain.PeerTypeUser { - if r.deps.Channels == nil || r.deps.Messages == nil { - return nil, peerIDInvalidErr() - } - recipientBlocked, err := r.peerBlocksUser(ctx, userID, toPeer.ID) - if err != nil { - return nil, err - } - sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID) - if err != nil { - return nil, messageForwardErr(err) - } - sessionID, _ := SessionIDFrom(ctx) - authKeyID, _ := AuthKeyIDFrom(ctx) - res := domain.ForwardPrivateMessagesResult{OwnerUserID: userID} - for i, source := range sources { - forward := source.forward - if req.DropAuthor { - forward = nil - } - sent, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ - SenderUserID: userID, - RecipientUserID: toPeer.ID, - RandomID: req.RandomID[i], - Message: source.body, - Entities: source.entities, - Media: source.media, - Silent: req.Silent, - NoForwards: req.Noforwards, - ReplyTo: replyTo, - Forward: forward, - Date: int(r.clock.Now().Unix()), - OriginAuthKeyID: authKeyID, - OriginSessionID: sessionID, - RecipientBlocked: recipientBlocked, - }) - if err != nil { - return nil, messageForwardErr(err) - } - res.SenderMessages = append(res.SenderMessages, sent.SenderMessage) - res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage) - res.SenderEvents = append(res.SenderEvents, sent.SenderEvent) - res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent) - res.Duplicates = append(res.Duplicates, sent.Duplicate) - } - return tgForwardMessagesUpdates(res, req.RandomID, r.usersForMessageUpdates(ctx, userID, res.SenderMessages), r.chatsForMessageUpdates(ctx, userID, res.SenderMessages)), nil - } - if fromPeer.Type != domain.PeerTypeUser || toPeer.Type != domain.PeerTypeUser || r.deps.Messages == nil { - return nil, peerIDInvalidErr() - } - sessionID, _ := SessionIDFrom(ctx) - authKeyID, _ := AuthKeyIDFrom(ctx) - recipientBlocked, err := r.peerBlocksUser(ctx, userID, toPeer.ID) - if err != nil { - return nil, err - } - res, err := r.deps.Messages.ForwardPrivateMessages(ctx, userID, domain.ForwardPrivateMessagesRequest{ - OwnerUserID: userID, - FromPeer: fromPeer, - ToUserID: toPeer.ID, - MessageIDs: append([]int(nil), req.ID...), - RandomIDs: append([]int64(nil), req.RandomID...), - Silent: req.Silent, - NoForwards: req.Noforwards, - DropAuthor: req.DropAuthor, - ReplyTo: replyTo, - Date: int(r.clock.Now().Unix()), - OriginAuthKeyID: authKeyID, - OriginSessionID: sessionID, - RecipientBlocked: recipientBlocked, - }) - if err != nil { - return nil, messageForwardErr(err) - } - return tgForwardMessagesUpdates(res, req.RandomID, r.usersForMessageUpdates(ctx, userID, res.SenderMessages), r.chatsForMessageUpdates(ctx, userID, res.SenderMessages)), nil -} - -func mergeForwardTopMsgID(toPeer domain.Peer, replyTo *domain.MessageReply, topMsgID int, topMsgIDSet bool) (*domain.MessageReply, error) { - if !topMsgIDSet || topMsgID == 0 { - return replyTo, nil - } - if topMsgID < 0 || topMsgID > domain.MaxMessageBoxID || toPeer.Type != domain.PeerTypeChannel { - return nil, replyMessageIDInvalidErr() - } - if replyTo == nil { - return &domain.MessageReply{ - Peer: toPeer, - TopMessageID: topMsgID, - ForumTopic: true, - }, nil - } - if replyTo.Peer.ID != 0 && replyTo.Peer != toPeer { - return nil, replyMessageIDInvalidErr() - } - if replyTo.TopMessageID != 0 && replyTo.TopMessageID != topMsgID { - return nil, replyMessageIDInvalidErr() - } - merged := *replyTo - merged.Peer = toPeer - merged.TopMessageID = topMsgID - merged.QuoteEntities = append([]domain.MessageEntity(nil), replyTo.QuoteEntities...) - if merged.MessageID == 0 { - merged.ForumTopic = true - } - return &merged, nil -} - -type forwardSource struct { - body string - entities []domain.MessageEntity - media *domain.MessageMedia - forward *domain.MessageForward - from domain.Peer - date int - noForward bool -} - -func (s forwardSource) userID() int64 { - if s.from.Type == domain.PeerTypeUser { - return s.from.ID - } - if s.forward != nil && s.forward.From.Type == domain.PeerTypeUser { - return s.forward.From.ID - } - return 0 -} - -func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int) ([]forwardSource, error) { - out := make([]forwardSource, 0, len(ids)) - for _, id := range ids { - switch fromPeer.Type { - case domain.PeerTypeUser: - if r.deps.Messages == nil { - return nil, domain.ErrMessageIDInvalid - } - list, err := r.deps.Messages.GetMessages(ctx, userID, []int{id}) - if err != nil || len(list.Messages) != 1 || list.Messages[0].ID != id { - return nil, domain.ErrMessageIDInvalid - } - msg := list.Messages[0] - if msg.Peer != fromPeer { - return nil, domain.ErrMessageIDInvalid - } - if msg.NoForwards { - return nil, domain.ErrChatForwardsRestricted - } - forward := cloneDomainMessageForward(msg.Forward) - if forward == nil { - forward = &domain.MessageForward{From: msg.From, Date: msg.Date} - } - out = append(out, forwardSource{ - body: msg.Body, - entities: append([]domain.MessageEntity(nil), - msg.Entities...), - media: msg.Media, - forward: forward, - from: msg.From, - date: msg.Date, - }) - case domain.PeerTypeChannel: - if r.deps.Channels == nil { - return nil, domain.ErrMessageIDInvalid - } - history, err := r.deps.Channels.GetMessages(ctx, userID, fromPeer.ID, []int{id}) - if err != nil || len(history.Messages) != 1 || history.Messages[0].ID != id { - return nil, domain.ErrMessageIDInvalid - } - msg := history.Messages[0] - if msg.NoForwards || history.Channel.NoForwards { - return nil, domain.ErrChatForwardsRestricted - } - if msg.Action != nil || (msg.Body == "" && msg.Media.IsZero()) { - return nil, domain.ErrMessageIDInvalid - } - forward := cloneDomainMessageForward(msg.Forward) - from := msg.From - if from.ID == 0 && msg.SenderUserID != 0 { - from = domain.Peer{Type: domain.PeerTypeUser, ID: msg.SenderUserID} - } - if msg.Post { - from = domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID} - } - if forward == nil { - forward = &domain.MessageForward{From: from, Date: msg.Date} - if from.Type == domain.PeerTypeChannel { - forward.ChannelPost = msg.ID - } - } - out = append(out, forwardSource{ - body: msg.Body, - entities: append([]domain.MessageEntity(nil), - msg.Entities...), - media: msg.Media, - forward: forward, - from: from, - date: msg.Date, - }) - default: - return nil, domain.ErrMessageIDInvalid - } - } - return out, nil -} - -func cloneDomainMessageForward(in *domain.MessageForward) *domain.MessageForward { - if in == nil { - return nil - } - out := *in - return &out -} - -func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEditMessageRequest) (tg.UpdatesClass, error) { - if _, ok := req.GetScheduleDate(); ok { - return nil, scheduleDateInvalidErr() - } - if _, ok := req.GetScheduleRepeatPeriod(); ok { - return nil, scheduleDateInvalidErr() - } - if _, ok := req.GetQuickReplyShortcutID(); ok { - return nil, messageIDInvalidErr() - } - if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) { - return nil, mediaInvalidErr() - } - if _, ok := req.GetReplyMarkup(); ok { - return nil, replyMarkupInvalidErr() - } - message, ok := req.GetMessage() - if !ok { - return nil, messageEmptyErr() - } - if message == "" { - return nil, messageEmptyErr() - } - if utf8.RuneCountInString(message) > maxSendMessageTextLength { - return nil, messageTooLongErr() - } - entities, _ := req.GetEntities() - if len(entities) > maxMessageEntityCount { - return nil, limitInvalidErr() - } - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if userID == 0 { - return nil, peerIDInvalidErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type == domain.PeerTypeChannel { - if r.deps.Channels == nil { - return nil, peerIDInvalidErr() - } - res, err := r.deps.Channels.EditMessage(ctx, userID, domain.EditChannelMessageRequest{ - UserID: userID, - ChannelID: peer.ID, - ID: req.ID, - Message: message, - Entities: domainMessageEntities(entities), - EditDate: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelEditErr(err) - } - updates := r.channelEditMessageUpdates(ctx, userID, res) - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelEditMessageUpdates(ctx, viewerUserID, res) - }) - return updates, nil - } - if peer.Type != domain.PeerTypeUser || r.deps.Messages == nil { - return nil, peerIDInvalidErr() - } - blocked, err := r.peerBlocksUser(ctx, userID, peer.ID) - if err != nil { - return nil, err - } - if blocked { - return nil, messageEditForbiddenErr() - } - sessionID, _ := SessionIDFrom(ctx) - authKeyID, _ := AuthKeyIDFrom(ctx) - res, err := r.deps.Messages.EditMessage(ctx, userID, domain.EditMessageRequest{ - OwnerUserID: userID, - Peer: peer, - ID: req.ID, - Message: message, - Entities: domainMessageEntities(entities), - EditDate: int(r.clock.Now().Unix()), - OriginAuthKeyID: authKeyID, - OriginSessionID: sessionID, - }) - if err != nil { - return nil, messageEditErr(err) - } - self := res.Self() - if self.Event.Pts == 0 || self.Message.ID == 0 { - return nil, messageIDInvalidErr() - } - users := r.usersForMessageUpdate(ctx, userID, self.Message) - chats := r.chatsForMessageUpdate(ctx, userID, self.Message) - return tgEditMessageUpdates(self.Event, self.Message, users, chats), nil -} - -func editMessageMediaCanDegradeToText(media tg.InputMediaClass) bool { - switch media.(type) { - case *tg.InputMediaEmpty, *tg.InputMediaWebPage: - return true - default: - return false - } -} - -func (r *Router) onMessagesGetMessageEditData(ctx context.Context, req *tg.MessagesGetMessageEditDataRequest) (*tg.MessagesMessageEditData, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - switch peer.Type { - case domain.PeerTypeChannel: - if r.deps.Channels == nil { - return nil, peerIDInvalidErr() - } - history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{ - ChannelID: peer.ID, - Limit: 1, - MaxID: req.ID, - MinID: req.ID - 1, - }) - if err != nil { - return nil, channelInvalidErr(err) - } - if len(history.Messages) != 1 || history.Messages[0].ID != req.ID { - return nil, messageIDInvalidErr() - } - msg := history.Messages[0] - if msg.Deleted || msg.Action != nil { - return nil, messageIDInvalidErr() - } - view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) - if err != nil { - return nil, channelInvalidErr(err) - } - if msg.SenderUserID != userID && !canEditChannelMessageForRPC(view.Self) { - return nil, messageAuthorRequiredErr() - } - case domain.PeerTypeUser: - if r.deps.Messages == nil { - return nil, messageIDInvalidErr() - } - msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.ID) - if err != nil { - return nil, internalErr() - } - if !ok || msg.Peer != peer { - return nil, messageIDInvalidErr() - } - if !msg.Out || msg.From.ID != userID { - return nil, messageAuthorRequiredErr() - } - default: - return nil, peerIDInvalidErr() - } - return &tg.MessagesMessageEditData{}, nil -} - -func canEditChannelMessageForRPC(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || - (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) -} - -func (r *Router) onMessagesGetOutboxReadDate(ctx context.Context, req *tg.MessagesGetOutboxReadDateRequest) (*tg.OutboxReadDate, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if userID == 0 || r.deps.Messages == nil { - return nil, messageIDInvalidErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type != domain.PeerTypeUser || peer.ID == 0 { - return nil, peerIDInvalidErr() - } - date, err := r.deps.Messages.GetOutboxReadDate(ctx, userID, domain.OutboxReadDateRequest{ - OwnerUserID: userID, - Peer: peer, - ID: req.MsgID, - }) - if err != nil { - return nil, messageReadDateErr(err) - } - return &tg.OutboxReadDate{Date: date}, nil -} - -func (r *Router) onMessagesGetMessageReadParticipants(ctx context.Context, req *tg.MessagesGetMessageReadParticipantsRequest) ([]tg.ReadParticipantDate, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { - return nil, peerIDInvalidErr() - } - if r.deps.Channels == nil { - return []tg.ReadParticipantDate{}, nil - } - res, err := r.deps.Channels.GetMessageReadParticipants(ctx, userID, domain.ChannelReadParticipantsRequest{ - UserID: userID, - ChannelID: peer.ID, - MessageID: req.MsgID, - Limit: domain.MaxChannelReadParticipants, - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - if errors.Is(err, domain.ErrMessageIDInvalid) { - return nil, messageIDInvalidErr() - } - return nil, channelInvalidErr(err) - } - out := make([]tg.ReadParticipantDate, 0, len(res.Participants)) - for _, p := range res.Participants { - if p.UserID == 0 { - continue - } - out = append(out, tg.ReadParticipantDate{UserID: p.UserID, Date: p.Date}) - } - return out, nil -} - -func (r *Router) onMessagesDeleteMessages(ctx context.Context, req *tg.MessagesDeleteMessagesRequest) (*tg.MessagesAffectedMessages, error) { - authKeyID, _ := AuthKeyIDFrom(ctx) - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if len(req.ID) == 0 || r.deps.Messages == nil { - return r.affectedMessages(ctx, authKeyID, userID) - } - if len(req.ID) > domain.MaxDeleteMessageIDs { - return nil, limitInvalidErr() - } - if req.GetRevoke() { - list, err := r.deps.Messages.GetMessages(ctx, userID, req.ID) - if err != nil { - return nil, internalErr() - } - blocked, err := r.messagesTouchBlockedPeer(ctx, userID, list.Messages) - if err != nil { - return nil, err - } - if blocked { - return nil, messageDeleteForbiddenErr() - } - } - sessionID, _ := SessionIDFrom(ctx) - res, err := r.deps.Messages.DeleteMessages(ctx, userID, domain.DeleteMessagesRequest{ - OwnerUserID: userID, - IDs: req.ID, - Revoke: req.GetRevoke(), - Date: int(r.clock.Now().Unix()), - OriginAuthKeyID: authKeyID, - OriginSessionID: sessionID, - }) - if err != nil { - return nil, internalErr() - } - self := res.Self() - if len(self.MessageIDs) == 0 || self.Event.Pts == 0 { - return r.affectedMessages(ctx, authKeyID, userID) - } - return &tg.MessagesAffectedMessages{Pts: self.Event.Pts, PtsCount: self.Event.PtsCount}, nil -} - -func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDeleteHistoryRequest) (*tg.MessagesAffectedHistory, error) { - authKeyID, _ := AuthKeyIDFrom(ctx) - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if _, ok := req.GetMinDate(); ok { - return r.affectedHistory(ctx, authKeyID, userID, 0) - } - if _, ok := req.GetMaxDate(); ok { - return r.affectedHistory(ctx, authKeyID, userID, 0) - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) - if err != nil { - return nil, err - } - if peer.Type == domain.PeerTypeChannel { - if r.deps.Channels == nil { - return r.affectedHistory(ctx, authKeyID, userID, 0) - } - if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() - } - res, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{ - UserID: userID, - ChannelID: peer.ID, - MaxID: req.MaxID, - ForEveryone: req.GetRevoke(), - Date: int(r.clock.Now().Unix()), - }) - if err != nil { - return nil, channelDeleteErr(err) - } - if res.Event.Pts != 0 { - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) - }) - return &tg.MessagesAffectedHistory{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil - } - if res.AvailableMinID > 0 { - event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID) - updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID) - r.pushUserUpdates(ctx, userID, updates) - if event.Pts != 0 { - return &tg.MessagesAffectedHistory{Pts: event.Pts, PtsCount: event.PtsCount, Offset: res.Offset}, nil - } - } - return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: 0, Offset: res.Offset}, nil - } - if peer.Type != domain.PeerTypeUser { - return nil, peerIDInvalidErr() - } - if r.deps.Messages == nil { - return r.affectedHistory(ctx, authKeyID, userID, 0) - } - if req.GetRevoke() { - blocked, err := r.peerBlocksUser(ctx, userID, peer.ID) - if err != nil { - return nil, err - } - if blocked { - return nil, messageDeleteForbiddenErr() - } - } - sessionID, _ := SessionIDFrom(ctx) - res, err := r.deps.Messages.DeleteHistory(ctx, userID, domain.DeleteHistoryRequest{ - OwnerUserID: userID, - Peer: peer, - MaxID: req.MaxID, - JustClear: req.GetJustClear(), - Revoke: req.GetRevoke(), - Date: int(r.clock.Now().Unix()), - OriginAuthKeyID: authKeyID, - OriginSessionID: sessionID, - }) - if err != nil { - return nil, internalErr() - } - self := res.Self() - if len(self.MessageIDs) == 0 || self.Event.Pts == 0 { - return r.affectedHistory(ctx, authKeyID, userID, 0) - } - return &tg.MessagesAffectedHistory{ - Pts: self.Event.Pts, - PtsCount: self.Event.PtsCount, - Offset: res.Offset, - }, nil -} - -func messageEditErr(err error) error { - switch { - case errors.Is(err, domain.ErrMessageIDInvalid): - return messageIDInvalidErr() - case errors.Is(err, domain.ErrMessageAuthorRequired): - return messageAuthorRequiredErr() - case errors.Is(err, domain.ErrMessageNotModified): - return messageNotModifiedErr() - default: - return internalErr() - } -} - -func channelEditErr(err error) error { - switch { - case errors.Is(err, domain.ErrMessageIDInvalid): - return messageIDInvalidErr() - case errors.Is(err, domain.ErrMessageAuthorRequired): - return messageAuthorRequiredErr() - case errors.Is(err, domain.ErrMessageNotModified): - return messageNotModifiedErr() - default: - return channelInvalidErr(err) - } -} - -func messageReadDateErr(err error) error { - switch { - case errors.Is(err, domain.ErrMessageIDInvalid): - return messageIDInvalidErr() - case errors.Is(err, domain.ErrMessageNotReadYet): - return messageNotReadYetErr() - default: - return internalErr() - } -} - -func messageReactionErr(err error) error { - switch { - case errors.Is(err, domain.ErrMessageIDInvalid): - return messageIDInvalidErr() - default: - return internalErr() - } -} - -func messageSendErr(err error) error { - switch { - case errors.Is(err, domain.ErrReplyMessageIDInvalid): - return replyMessageIDInvalidErr() - default: - return internalErr() - } -} - -func (r *Router) peerBlocksUser(ctx context.Context, userID, peerUserID int64) (bool, error) { - if userID == 0 || peerUserID == 0 || userID == peerUserID || r.deps.Contacts == nil { - return false, nil - } - blocked, err := r.deps.Contacts.IsBlocked(ctx, peerUserID, userID) - if err != nil { - return false, internalErr() - } - return blocked, nil -} - -func (r *Router) messagesTouchBlockedPeer(ctx context.Context, userID int64, messages []domain.Message) (bool, error) { - seen := make(map[int64]struct{}, len(messages)) - for _, msg := range messages { - if msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID == 0 { - continue - } - if _, ok := seen[msg.Peer.ID]; ok { - continue - } - seen[msg.Peer.ID] = struct{}{} - blocked, err := r.peerBlocksUser(ctx, userID, msg.Peer.ID) - if err != nil { - return false, err - } - if blocked { - return true, nil - } - } - return false, nil -} - -func messageForwardErr(err error) error { - switch { - case errors.Is(err, domain.ErrMessageIDInvalid): - return messageIDInvalidErr() - case errors.Is(err, domain.ErrChatForwardsRestricted): - return chatForwardsRestrictedErr() - case errors.Is(err, domain.ErrReplyMessageIDInvalid): - return replyMessageIDInvalidErr() - default: - return internalErr() - } -} - -func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer domain.Peer, input tg.InputReplyToClass) (*domain.MessageReply, error) { - if input == nil { - return nil, nil - } - reply, ok := input.(*tg.InputReplyToMessage) - if !ok { - switch input.(type) { - case *tg.InputReplyToStory: - return nil, storyIDInvalidErr() - case *tg.InputReplyToMonoForum: - return nil, replyToMonoforumPeerInvalidErr() - default: - return nil, inputConstructorInvalidErr() - } - } - if _, ok := reply.GetMonoforumPeerID(); ok { - return nil, replyToMonoforumPeerInvalidErr() - } - if _, ok := reply.GetTodoItemID(); ok { - return nil, replyMessageIDInvalidErr() - } - if _, ok := reply.GetPollOption(); ok { - return nil, pollOptionInvalidErr() - } - replyPeer := peer - if inputPeer, ok := reply.GetReplyToPeerID(); ok { - parsed, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer) - if err != nil || parsed != peer { - return nil, replyMessageIDInvalidErr() - } - replyPeer = parsed - } - topMsgID, ok := reply.GetTopMsgID() - if ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { - return nil, replyMessageIDInvalidErr() - } - if reply.ReplyToMsgID < 0 || reply.ReplyToMsgID > domain.MaxMessageBoxID { - return nil, replyMessageIDInvalidErr() - } - if reply.ReplyToMsgID == 0 && topMsgID == 0 { - return nil, replyMessageIDInvalidErr() - } - quoteText, _ := reply.GetQuoteText() - if utf8.RuneCountInString(quoteText) > maxReplyQuoteLength { - return nil, limitInvalidErr() - } - quoteEntities, _ := reply.GetQuoteEntities() - if len(quoteEntities) > maxMessageEntityCount { - return nil, limitInvalidErr() - } - quoteOffset, ok := reply.GetQuoteOffset() - if ok && (quoteOffset < 0 || quoteOffset > domain.MaxMessageReplyQuoteOffset) { - return nil, replyMessageIDInvalidErr() - } - return &domain.MessageReply{ - MessageID: reply.ReplyToMsgID, - Peer: replyPeer, - TopMessageID: topMsgID, - QuoteText: quoteText, - QuoteEntities: domainMessageEntities(quoteEntities), - QuoteOffset: quoteOffset, - }, nil -} - -func sendMessageUnsupportedOptionErr(req *tg.MessagesSendMessageRequest) error { - switch { - case req.ReplyMarkup != nil: - return replyMarkupInvalidErr() - case req.QuickReplyShortcut != nil: - return shortcutInvalidErr() - case req.Effect != 0: - return effectIDInvalidErr() - case req.AllowPaidStars < 0: - return starsAmountInvalidErr() - case req.AllowPaidStars > 0 || req.AllowPaidFloodskip: - return paymentUnsupportedErr() - case !req.SuggestedPost.Zero(): - return suggestedPostPeerInvalidErr() - default: - return nil - } -} - -func forwardMessagesUnsupportedOptionErr(req *tg.MessagesForwardMessagesRequest) error { - switch { - case req.QuickReplyShortcut != nil: - return shortcutInvalidErr() - case req.Effect != 0: - return effectIDInvalidErr() - case req.VideoTimestamp != 0: - return mediaInvalidErr() - case req.AllowPaidStars < 0: - return starsAmountInvalidErr() - case req.AllowPaidStars > 0 || req.AllowPaidFloodskip: - return paymentUnsupportedErr() - case !req.SuggestedPost.Zero(): - return suggestedPostPeerInvalidErr() - default: - return nil - } -} - -func (r *Router) onMessagesGetPeerSettings(ctx context.Context, input tg.InputPeerClass) (*tg.MessagesPeerSettings, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) - if err != nil { - return nil, err - } - settings := domain.PeerSettings{} - if r.deps.Contacts != nil { - settings, err = r.deps.Contacts.GetPeerSettings(ctx, userID, peer) - if err != nil { - return nil, internalErr() - } - } - if r.deps.Dialogs != nil { - hidden, err := r.deps.Dialogs.PeerSettingsBarHidden(ctx, userID, peer) - if err != nil { - return nil, internalErr() - } - settings.HiddenPeerSettingsBar = hidden - } - return &tg.MessagesPeerSettings{ - Settings: tgPeerSettings(settings), - Users: r.peerSettingsUsers(ctx, userID, input), - }, nil -} - -func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.MessagesToggleDialogPinRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) - if err != nil { - return false, err - } - if len(peers) != 1 { - return false, peerIDInvalidErr() - } - pinned := req.GetPinned() - if r.deps.Dialogs == nil { - return true, nil - } - changed, err := r.deps.Dialogs.TogglePinned(ctx, userID, peers[0], pinned) - if err != nil { - return false, internalErr() - } - if changed { - date := int(r.clock.Now().Unix()) - if r.deps.Updates != nil { - authKeyID, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - _, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, peers[0], pinned, sessionID) - if err != nil { - return false, internalErr() - } - date = state.Date - } - r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateDialogPinned{ - Pinned: pinned, - Peer: tgDialogPeer(peers[0]), - }}, - Date: date, - Seq: 0, - }) - } - return true, nil -} - -func (r *Router) onMessagesReorderPinnedDialogs(ctx context.Context, req *tg.MessagesReorderPinnedDialogsRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if req.FolderID != 0 { - return false, folderIDInvalidErr() - } - if len(req.Order) > maxDialogInputPeers { - return false, limitInvalidErr() - } - peers, err := r.dialogPeersFromInput(ctx, userID, req.Order) - if err != nil { - return false, err - } - if r.deps.Dialogs == nil { - return true, nil - } - if err := r.deps.Dialogs.ReorderPinned(ctx, userID, peers, req.GetForce()); err != nil { - return false, internalErr() - } - date := int(r.clock.Now().Unix()) - if r.deps.Updates != nil { - authKeyID, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - _, state, err := r.deps.Updates.RecordPinnedDialogs(ctx, authKeyID, userID, peers, sessionID) - if err != nil { - return false, internalErr() - } - date = state.Date - } - r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdatePinnedDialogs{Order: tgDialogPeers(peers)}}, - Date: date, - Seq: 0, - }) - return true, nil -} - -func (r *Router) onMessagesMarkDialogUnread(ctx context.Context, req *tg.MessagesMarkDialogUnreadRequest) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - if parentPeer, ok := req.GetParentPeer(); ok { - if err := r.validateDialogUnreadParentPeer(ctx, userID, parentPeer); err != nil { - return false, err - } - peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) - if err != nil { - return false, err - } - if len(peers) != 1 { - return false, peerIDInvalidErr() - } - return true, nil - } - peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) - if err != nil { - return false, err - } - if len(peers) != 1 { - return false, peerIDInvalidErr() - } - unread := req.GetUnread() - if r.deps.Dialogs == nil { - return true, nil - } - changed, err := r.deps.Dialogs.MarkUnread(ctx, userID, peers[0], unread) - if err != nil { - return false, internalErr() - } - if changed { - date := int(r.clock.Now().Unix()) - if r.deps.Updates != nil { - authKeyID, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - _, state, err := r.deps.Updates.RecordDialogUnreadMark(ctx, authKeyID, userID, peers[0], unread, sessionID) - if err != nil { - return false, internalErr() - } - date = state.Date - } - r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdateDialogUnreadMark{ - Unread: unread, - Peer: tgDialogPeer(peers[0]), - }}, - Date: date, - Seq: 0, - }) - } - return true, nil -} - -func (r *Router) onMessagesGetDialogUnreadMarks(ctx context.Context, req *tg.MessagesGetDialogUnreadMarksRequest) ([]tg.DialogPeerClass, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return nil, internalErr() - } - if parentPeer, ok := req.GetParentPeer(); ok { - if err := r.validateDialogUnreadParentPeer(ctx, userID, parentPeer); err != nil { - return nil, err - } - return []tg.DialogPeerClass{}, nil - } - if r.deps.Dialogs == nil { - return nil, nil - } - peers, err := r.deps.Dialogs.UnreadMarks(ctx, userID) - if err != nil { - return nil, internalErr() - } - return tgDialogPeers(peers), nil -} - -func (r *Router) validateDialogUnreadParentPeer(ctx context.Context, userID int64, parentPeer tg.InputPeerClass) error { - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, parentPeer) - if err != nil { - return parentPeerInvalidErr() - } - if peer.Type != domain.PeerTypeChannel { - return parentPeerInvalidErr() - } - return nil -} - -func (r *Router) onMessagesHidePeerSettingsBar(ctx context.Context, input tg.InputPeerClass) (bool, error) { - userID, _, err := r.currentUserID(ctx) - if err != nil { - return false, internalErr() - } - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) - if err != nil { - return false, err - } - changed := true - if r.deps.Dialogs != nil { - var err error - changed, err = r.deps.Dialogs.HidePeerSettingsBar(ctx, userID, peer) - if err != nil { - return false, internalErr() - } - } - if !changed { - return true, nil - } - date := int(r.clock.Now().Unix()) - if r.deps.Updates != nil { - authKeyID, _ := AuthKeyIDFrom(ctx) - sessionID, _ := SessionIDFrom(ctx) - _, state, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, domain.PeerSettings{HiddenPeerSettingsBar: true}, sessionID) - if err != nil { - return false, internalErr() - } - date = state.Date - } - r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ - Updates: []tg.UpdateClass{&tg.UpdatePeerSettings{ - Peer: tgPeer(peer), - Settings: tg.PeerSettings{}, - }}, - Date: date, - Seq: 0, - }) - return true, nil -} - -func (r *Router) metrics() Metrics { - if r.deps.Metrics == nil { - return NopMetrics{} - } - return r.deps.Metrics -} - -func (r *Router) dialogFilterFromRequest(ctx context.Context, userID int64, req *tg.MessagesGetDialogsRequest) (domain.DialogFilter, error) { - limit := req.Limit - if limit > 500 { - limit = 500 - } - filter := domain.DialogFilter{ - ExcludePinned: req.ExcludePinned, - OffsetDate: req.OffsetDate, - OffsetID: req.OffsetID, - Limit: limit, - Hash: req.Hash, - } - if folderID, ok := req.GetFolderID(); ok { - filter.HasFolderID = true - filter.FolderID = folderID - } - if peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.OffsetPeer); err == nil { - filter.HasOffsetPeer = true - filter.OffsetPeer = peer - } else if _, ok := req.OffsetPeer.(*tg.InputPeerEmpty); !ok && req.OffsetPeer != nil { - return domain.DialogFilter{}, err - } - return filter, nil -} - -func (r *Router) dialogPeersFromInput(ctx context.Context, userID int64, items []tg.InputDialogPeerClass) ([]domain.Peer, error) { - if len(items) > maxDialogInputPeers { - return nil, limitInvalidErr() - } - peers := make([]domain.Peer, 0, len(items)) - hasFolder := false - for _, item := range items { - switch p := item.(type) { - case *tg.InputDialogPeer: - peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, p.Peer) - if err != nil { - return nil, err - } - peers = append(peers, peer) - case *tg.InputDialogPeerFolder: - if hasFolder { - return nil, folderIDInvalidErr() - } - hasFolder = true - // 第一阶段不维护 archived/folder 会话。若请求同时包含普通 peer, - // 按 Telegram 企业版路径优先返回普通 peer;纯 folder 请求返回空摘要。 - default: - return nil, inputConstructorInvalidErr() - } - } - return peers, nil -} - -func (r *Router) dialogFolderFromTG(ctx context.Context, userID int64, id int, filter tg.DialogFilterClass) (domain.DialogFolder, error) { - if id < domain.DialogCustomFolderMinID { - return domain.DialogFolder{}, filterIDInvalidErr() - } - switch f := filter.(type) { - case *tg.DialogFilter: - title := f.Title.Text - if title == "" { - return domain.DialogFolder{}, filterTitleEmptyErr() - } - if utf8.RuneCountInString(title) > domain.MaxDialogFolderTitleRunes { - return domain.DialogFolder{}, limitInvalidErr() - } - pinned, err := r.dialogFolderPeersFromInput(ctx, userID, f.PinnedPeers) - if err != nil { - return domain.DialogFolder{}, err - } - include, err := r.dialogFolderPeersFromInput(ctx, userID, f.IncludePeers) - if err != nil { - return domain.DialogFolder{}, err - } - exclude, err := r.dialogFolderPeersFromInput(ctx, userID, f.ExcludePeers) - if err != nil { - return domain.DialogFolder{}, err - } - emoticon, hasEmoticon := f.GetEmoticon() - color, hasColor := f.GetColor() - return domain.DialogFolder{ - ID: id, - Contacts: f.Contacts, - NonContacts: f.NonContacts, - Groups: f.Groups, - Broadcasts: f.Broadcasts, - Bots: f.Bots, - ExcludeMuted: f.ExcludeMuted, - ExcludeRead: f.ExcludeRead, - ExcludeArchived: f.ExcludeArchived, - TitleNoanimate: f.TitleNoanimate, - Title: title, - TitleEntities: domainMessageEntities(f.Title.Entities), - Emoticon: emoticon, - HasEmoticon: hasEmoticon, - Color: color, - HasColor: hasColor, - PinnedPeers: pinned, - IncludePeers: include, - ExcludePeers: exclude, - }, nil - case *tg.DialogFilterChatlist: - title := f.Title.Text - if title == "" { - return domain.DialogFolder{}, filterTitleEmptyErr() - } - if utf8.RuneCountInString(title) > domain.MaxDialogFolderTitleRunes { - return domain.DialogFolder{}, limitInvalidErr() - } - pinned, err := r.dialogFolderPeersFromInput(ctx, userID, f.PinnedPeers) - if err != nil { - return domain.DialogFolder{}, err - } - include, err := r.dialogFolderPeersFromInput(ctx, userID, f.IncludePeers) - if err != nil { - return domain.DialogFolder{}, err - } - emoticon, hasEmoticon := f.GetEmoticon() - color, hasColor := f.GetColor() - return domain.DialogFolder{ - ID: id, - TitleNoanimate: f.TitleNoanimate, - Title: title, - TitleEntities: domainMessageEntities(f.Title.Entities), - Emoticon: emoticon, - HasEmoticon: hasEmoticon, - Color: color, - HasColor: hasColor, - PinnedPeers: pinned, - IncludePeers: include, - IsChatlist: true, - }, nil - default: - return domain.DialogFolder{}, inputConstructorInvalidErr() - } -} - -func (r *Router) dialogFolderPeersFromInput(ctx context.Context, userID int64, peers []tg.InputPeerClass) ([]domain.DialogFolderPeer, error) { - if len(peers) > domain.MaxDialogFolderPeers { - return nil, limitInvalidErr() - } - out := make([]domain.DialogFolderPeer, 0, len(peers)) - seen := make(map[domain.Peer]struct{}, len(peers)) - for _, input := range peers { - peer, accessHash, err := r.domainFolderPeerFromInputPeer(ctx, userID, input) - if err != nil { - return nil, err - } - if _, ok := seen[peer]; ok { - continue - } - seen[peer] = struct{}{} - out = append(out, domain.DialogFolderPeer{Peer: peer, AccessHash: accessHash}) - } - return out, nil -} - -func (r *Router) domainFolderPeerFromInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.Peer, int64, error) { - switch p := peer.(type) { - case *tg.InputPeerUser: - return domain.Peer{Type: domain.PeerTypeUser, ID: p.UserID}, p.AccessHash, nil - case *tg.InputPeerChannel: - out := domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID} - if err := r.validateInputPeerChannelAccess(ctx, userID, peer, p.ChannelID); err != nil { - return domain.Peer{}, 0, err - } - return out, p.AccessHash, nil - case *tg.InputPeerChannelFromMessage: - out := domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID} - if p.ChannelID <= 0 { - return domain.Peer{}, 0, peerIDInvalidErr() - } - return out, 0, nil - case *tg.InputPeerSelf: - if userID == 0 { - return domain.Peer{}, 0, peerIDInvalidErr() - } - var accessHash int64 - if r.deps.Users != nil { - if self, err := r.deps.Users.Self(ctx, userID); err == nil { - accessHash = self.AccessHash - } - } - return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, accessHash, nil - default: - return domain.Peer{}, 0, peerIDInvalidErr() - } -} - -func (r *Router) domainPeerFromInputPeer(userID int64, peer tg.InputPeerClass) (domain.Peer, bool) { - switch p := peer.(type) { - case *tg.InputPeerEmpty, nil: - return domain.Peer{}, false - case *tg.InputPeerUser: - return domain.Peer{Type: domain.PeerTypeUser, ID: p.UserID}, true - case *tg.InputPeerChannel: - return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID}, true - case *tg.InputPeerChannelFromMessage: - return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID}, p.ChannelID > 0 - case *tg.InputPeerChat: - return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChatID}, p.ChatID > 0 - case *tg.InputPeerSelf: - if userID == 0 { - return domain.Peer{}, false - } - return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, true - default: - return domain.Peer{}, false - } -} - -func isLegacyInputPeerChat(peer tg.InputPeerClass) bool { - _, ok := peer.(*tg.InputPeerChat) - return ok -} - -func inputPeerChannelRef(peer tg.InputPeerClass) (channelInputRef, bool) { - switch p := peer.(type) { - case *tg.InputPeerChannel: - return channelInputRef{ - ID: p.ChannelID, - AccessHash: p.AccessHash, - CheckAccessHash: p.AccessHash != 0, - }, p.ChannelID > 0 - case *tg.InputPeerChannelFromMessage: - return channelInputRef{ID: p.ChannelID}, p.ChannelID > 0 - default: - return channelInputRef{}, false - } -} - -func (r *Router) validateInputPeerChannelAccess(ctx context.Context, userID int64, peer tg.InputPeerClass, channelID int64) error { - ref, ok := inputPeerChannelRef(peer) - if !ok || ref.ID != channelID || channelID <= 0 { - return nil - } - if !ref.CheckAccessHash || r.deps.Channels == nil { - return nil - } - view, err := r.deps.Channels.GetChannel(ctx, userID, channelID) - if err != nil { - return channelInvalidErr(err) - } - if !inputChannelAccessHashMatches(ref, view.Channel) { - return channelInvalidErr(domain.ErrChannelPrivate) - } - return nil -} - -func (r *Router) checkedDomainPeerFromInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.Peer, error) { - out, ok := r.domainPeerFromInputPeer(userID, peer) - if !ok || out.ID == 0 { - return domain.Peer{}, peerIDInvalidErr() - } - if out.Type == domain.PeerTypeChannel { - if err := r.validateInputPeerChannelAccess(ctx, userID, peer, out.ID); err != nil { - return domain.Peer{}, err - } - } - return out, nil -} - -func cleanDialogFilterOrder(order []int) []int { - out := make([]int, 0, len(order)) - seen := make(map[int]struct{}, len(order)) - for _, id := range order { - if id < domain.DialogCustomFolderMinID { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} - -func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesGetHistoryRequest) (domain.MessageFilter, bool) { - peer, ok := r.domainPeerFromInputPeer(userID, req.Peer) - if !ok { - return domain.MessageFilter{}, false - } - limit := req.Limit - if limit > 50 { - limit = 50 - } - return domain.MessageFilter{ - HasPeer: true, - Peer: peer, - OffsetID: req.OffsetID, - OffsetDate: req.OffsetDate, - AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), - Limit: limit, - MaxID: req.MaxID, - MinID: req.MinID, - Hash: req.Hash, - }, true -} - -func (r *Router) messageFilterFromSearchRequest(userID int64, req *tg.MessagesSearchRequest) domain.MessageFilter { - limit := req.Limit - if limit > 500 { - limit = 500 - } - filter := domain.MessageFilter{ - Query: req.Q, - OffsetID: req.OffsetID, - AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), - Limit: limit, - MaxID: req.MaxID, - MinID: req.MinID, - Hash: req.Hash, - NeedTotalCount: req.OffsetID == 0 && req.MinDate == 0 && req.MaxDate == 0 && req.AddOffset >= 0 && req.Hash == 0, - } - if peer, ok := r.domainPeerFromInputPeer(userID, req.Peer); ok { - filter.HasPeer = true - filter.Peer = peer - } - return filter -} - -func (r *Router) channelHistoryFilterFromSearchRequest(userID int64, req *tg.MessagesSearchRequest, channelID int64) (domain.ChannelHistoryFilter, bool) { - limit := req.Limit - if limit <= 0 || limit > 100 { - limit = 100 - } - filter := domain.ChannelHistoryFilter{ - ChannelID: channelID, - Query: req.Q, - PinnedOnly: messagesSearchFilterPinned(req.Filter), - OffsetID: req.OffsetID, - AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), - Limit: limit, - MinDate: req.MinDate, - MaxDate: req.MaxDate, - MaxID: req.MaxID, - MinID: req.MinID, - Hash: req.Hash, - } - if req.FromID != nil { - from, ok := r.domainPeerFromInputPeer(userID, req.FromID) - if !ok || from.Type != domain.PeerTypeUser || from.ID == 0 { - return domain.ChannelHistoryFilter{}, false - } - filter.SenderUserID = from.ID - } - return filter, true -} - -func messagesSearchFilterPinned(filter tg.MessagesFilterClass) bool { - _, ok := filter.(*tg.InputMessagesFilterPinned) - return ok -} - -func searchFilterNeedsMediaStore(filter tg.MessagesFilterClass) bool { - switch filter.(type) { - case nil, *tg.InputMessagesFilterEmpty: - return false - case *tg.InputMessagesFilterPhotos, - *tg.InputMessagesFilterVideo, - *tg.InputMessagesFilterPhotoVideo, - *tg.InputMessagesFilterDocument, - *tg.InputMessagesFilterURL, - *tg.InputMessagesFilterGif, - *tg.InputMessagesFilterVoice, - *tg.InputMessagesFilterRoundVoice, - *tg.InputMessagesFilterRoundVideo, - *tg.InputMessagesFilterMusic, - *tg.InputMessagesFilterPoll: - return true - default: - return false - } -} - -func (r *Router) peerSettingsUsers(ctx context.Context, userID int64, peer tg.InputPeerClass) []tg.UserClass { - if r.deps.Users == nil { - return nil - } - switch p := peer.(type) { - case *tg.InputPeerSelf: - u, err := r.deps.Users.Self(ctx, userID) - if err == nil && u.ID != 0 { - return []tg.UserClass{r.tgSelfUser(u)} - } - case *tg.InputPeerUser: - u, found, err := r.deps.Users.ByID(ctx, userID, p.UserID) - if err == nil && found { - return []tg.UserClass{r.tgUser(u)} - } - } - return nil -} - -func (r *Router) affectedMessages(ctx context.Context, authKeyID [8]byte, userID int64) (*tg.MessagesAffectedMessages, error) { - st := domain.UpdateState{Date: int(r.clock.Now().Unix())} - if r.deps.Updates != nil { - var err error - st, err = r.deps.Updates.GetState(ctx, authKeyID, userID) - if err != nil { - return nil, internalErr() - } - } - return &tg.MessagesAffectedMessages{Pts: st.Pts, PtsCount: 0}, nil -} - -func (r *Router) mentionedUserIDsFromMessage(ctx context.Context, currentUserID int64, message string, entities []tg.MessageEntityClass) ([]int64, error) { - if r.deps.Users == nil { - return nil, nil - } - identity, _ := r.deps.Users.(UserIdentityService) - seen := make(map[int64]struct{}) - out := make([]int64, 0) - add := func(id int64) { - if id == 0 { - return - } - if _, ok := seen[id]; ok { - return - } - seen[id] = struct{}{} - out = append(out, id) - } - for _, entity := range entities { - input, ok := entity.(*tg.InputMessageEntityMentionName) - if !ok || input.UserID == nil { - continue - } - user, found, err := r.userFromInput(ctx, currentUserID, input.UserID) - if err != nil { - return nil, internalErr() - } - if found { - add(user.ID) - } - if len(out) >= domain.MaxChannelMentionRecipients { - return out, nil - } - } - if identity != nil { - for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out)) { - user, found, err := identity.ResolveUsername(ctx, currentUserID, username) - if err != nil { - return nil, internalErr() - } - if found { - add(user.ID) - } - if len(out) >= domain.MaxChannelMentionRecipients { - return out, nil - } - } - } - return out, nil -} - -func extractMentionUsernames(message string, limit int) []string { - if limit <= 0 || message == "" { - return nil - } - seen := make(map[string]struct{}) - out := make([]string, 0) - for i := 0; i < len(message); i++ { - if message[i] != '@' { - continue - } - if i > 0 && isUsernameByte(message[i-1]) { - continue - } - j := i + 1 - for j < len(message) && isUsernameByte(message[j]) { - j++ - } - if j == i+1 { - continue - } - username := strings.ToLower(message[i+1 : j]) - if _, ok := seen[username]; ok { - continue - } - seen[username] = struct{}{} - out = append(out, username) - if len(out) == limit { - return out - } - i = j - 1 - } - return out -} - -func isUsernameByte(b byte) bool { - return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' -} - -func (r *Router) affectedHistory(ctx context.Context, authKeyID [8]byte, userID int64, offset int) (*tg.MessagesAffectedHistory, error) { - st := domain.UpdateState{Date: int(r.clock.Now().Unix())} - if r.deps.Updates != nil { - var err error - st, err = r.deps.Updates.GetState(ctx, authKeyID, userID) - if err != nil { - return nil, internalErr() - } - } - return &tg.MessagesAffectedHistory{Pts: st.Pts, PtsCount: 0, Offset: offset}, nil -} - -func (r *Router) chatsForInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) []tg.ChatClass { - p, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) - if err != nil || p.Type != domain.PeerTypeChannel || p.ID == 0 || r.deps.Channels == nil { - return []tg.ChatClass{} - } - view, err := r.deps.Channels.GetChannel(ctx, userID, p.ID) - if err != nil || view.Channel.ID == 0 { - return []tg.ChatClass{} - } - return []tg.ChatClass{tgChannelChat(userID, view.Channel, &view.Self)} -} - -func (r *Router) forumTopicPeerView(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.ChannelView, error) { - p, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) - if err != nil { - return domain.ChannelView{}, err - } - if p.Type != domain.PeerTypeChannel || p.ID == 0 { - return domain.ChannelView{}, peerIDInvalidErr() - } - if r.deps.Channels == nil { - return domain.ChannelView{}, nil - } - view, err := r.deps.Channels.GetChannel(ctx, userID, p.ID) - if err != nil { - return domain.ChannelView{}, channelInvalidErr(err) - } - return view, nil -} - -func (r *Router) forumTopicsResponse(ctx context.Context, userID int64, view domain.ChannelView, list domain.ChannelForumTopicList, includeGeneral bool) *tg.MessagesForumTopics { - if list.Channel.ID != 0 { - view.Channel = list.Channel - } - if list.Dialog.ChannelID != 0 { - view.Dialog = list.Dialog - } - channels := []domain.Channel{view.Channel} - messages := []tg.MessageClass{} - messageIDs := map[int]struct{}{} - topics := []tg.ForumTopicClass{} - userIDs := make([]int64, 0, len(list.Topics)+len(list.Messages)+1) - count := list.Count - if includeGeneral { - count++ - topics = append(topics, tgForumGeneralTopic(userID, view)) - userIDs = append(userIDs, view.Channel.CreatorUserID) - if view.Channel.TopMessageID > 0 && r.deps.Channels != nil { - if history, err := r.deps.Channels.GetMessages(ctx, userID, view.Channel.ID, []int{view.Channel.TopMessageID}); err == nil { - for _, msg := range history.Messages { - if _, ok := messageIDs[msg.ID]; ok { - continue - } - messageIDs[msg.ID] = struct{}{} - if item := tgChannelMessage(userID, msg); item != nil { - messages = append(messages, item) - } - userIDs = append(userIDs, msg.SenderUserID) - } - channels = append(channels, history.Channels...) - for _, u := range history.Users { - userIDs = append(userIDs, u.ID) - } - } - } - } - for _, topic := range list.Topics { - topics = append(topics, tgForumTopicFromDomain(userID, topic)) - userIDs = append(userIDs, topic.CreatorUserID) - } - for _, msg := range list.Messages { - if _, ok := messageIDs[msg.ID]; ok { - continue - } - messageIDs[msg.ID] = struct{}{} - if item := tgChannelMessage(userID, msg); item != nil { - messages = append(messages, item) - } - userIDs = append(userIDs, msg.SenderUserID) - if msg.SendAs != nil && msg.SendAs.Type == domain.PeerTypeUser { - userIDs = append(userIDs, msg.SendAs.ID) - } - } - for _, u := range list.Users { - userIDs = append(userIDs, u.ID) - } - return &tg.MessagesForumTopics{ - Count: count, - Topics: topics, - Messages: messages, - Chats: tgChannels(userID, channels), - Users: r.tgUsersForIDs(ctx, userID, userIDs), - Pts: view.Channel.Pts, - } -} - -func tgForumGeneralTopic(viewerUserID int64, view domain.ChannelView) *tg.ForumTopic { - return &tg.ForumTopic{ - My: view.Channel.CreatorUserID == viewerUserID && viewerUserID != 0, - ID: forumGeneralTopicID, - Date: view.Channel.Date, - Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}, - Title: "General", - IconColor: forumGeneralIconColor, - TopMessage: view.Channel.TopMessageID, - ReadInboxMaxID: view.Dialog.ReadInboxMaxID, - ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID, - UnreadCount: view.Dialog.UnreadCount, - UnreadMentionsCount: view.Dialog.UnreadMentions, - FromID: &tg.PeerUser{UserID: view.Channel.CreatorUserID}, - NotifySettings: *tdesktop.NotifySettings(), - } -} - -func tgForumTopicFromDomain(viewerUserID int64, topic domain.ChannelForumTopic) *tg.ForumTopic { - iconColor := topic.IconColor - if iconColor == 0 { - iconColor = domain.DefaultForumTopicIconColor - } - return &tg.ForumTopic{ - My: topic.CreatorUserID == viewerUserID && viewerUserID != 0, - Closed: topic.Closed, - Pinned: topic.Pinned, - Hidden: topic.Hidden, - TitleMissing: topic.TitleMissing, - ID: topic.TopicID, - Date: topic.Date, - Peer: &tg.PeerChannel{ChannelID: topic.ChannelID}, - Title: topic.Title, - IconColor: iconColor, - IconEmojiID: topic.IconEmojiID, - TopMessage: topic.TopMessageID, - ReadInboxMaxID: topic.ReadInboxMaxID, - ReadOutboxMaxID: topic.ReadOutboxMaxID, - UnreadCount: topic.UnreadCount, - UnreadMentionsCount: topic.UnreadMentionsCount, - UnreadReactionsCount: topic.UnreadReactionsCount, - UnreadPollVotesCount: topic.UnreadPollVotesCount, - FromID: &tg.PeerUser{UserID: topic.CreatorUserID}, - NotifySettings: *tdesktop.NotifySettings(), - } -} - -func (r *Router) pinnedForumTopicUpdates(viewerUserID int64, channel domain.Channel, topicID int, pinned bool) *tg.Updates { - update := &tg.UpdatePinnedForumTopic{ - Peer: &tg.PeerChannel{ChannelID: channel.ID}, - TopicID: topicID, - } - update.SetPinned(pinned) - return &tg.Updates{ - Updates: []tg.UpdateClass{update}, - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func (r *Router) pinnedForumTopicsOrderUpdates(viewerUserID int64, channel domain.Channel, order []int) *tg.Updates { - update := &tg.UpdatePinnedForumTopics{ - Peer: &tg.PeerChannel{ChannelID: channel.ID}, - Order: append([]int(nil), order...), - } - if order != nil { - update.SetOrder(append([]int(nil), order...)) - } - return &tg.Updates{ - Updates: []tg.UpdateClass{update}, - Chats: []tg.ChatClass{tgChannelChat(viewerUserID, channel, nil)}, - Date: int(r.clock.Now().Unix()), - Seq: 0, - } -} - -func forumTopicQueryMatchesGeneral(query string) bool { - query = strings.TrimSpace(strings.ToLower(query)) - return query == "" || strings.Contains("general", query) -} - -func (r *Router) pushReadHistoryEvent(ctx context.Context, userID int64, event domain.UpdateEvent) { - if r.hasReliableUpdateDispatch() { - return - } - if r.deps.Sessions == nil || userID == 0 { - return - } - var update tg.UpdateClass - switch event.Type { - case domain.UpdateEventReadHistoryInbox: - update = tgReadHistoryInboxUpdate(event) - case domain.UpdateEventReadHistoryOutbox: - update = tgReadHistoryOutboxUpdate(event) - } - if update == nil { - return - } - updates := &tg.Updates{ - Updates: []tg.UpdateClass{update}, - Date: event.Date, - Seq: 0, - } - r.pushUserMessage(ctx, userID, "push read history", updates) -} - -func tgReadHistoryInbox(event domain.UpdateEvent) *tg.UpdateReadHistoryInbox { - peer := tgPeer(event.Peer) - if peer == nil { - return nil - } - return &tg.UpdateReadHistoryInbox{ - Peer: peer, - MaxID: event.MaxID, - StillUnreadCount: event.StillUnreadCount, - Pts: event.Pts, - PtsCount: event.PtsCount, - } -} - -func tgReadHistoryInboxUpdate(event domain.UpdateEvent) tg.UpdateClass { - if event.Peer.Type == domain.PeerTypeChannel && event.Peer.ID != 0 { - return &tg.UpdateReadChannelInbox{ - ChannelID: event.Peer.ID, - MaxID: event.MaxID, - StillUnreadCount: event.StillUnreadCount, - Pts: event.Pts, - } - } - return tgReadHistoryInbox(event) -} - -func tgReadHistoryOutbox(event domain.UpdateEvent) *tg.UpdateReadHistoryOutbox { - peer := tgPeer(event.Peer) - if peer == nil { - return nil - } - return &tg.UpdateReadHistoryOutbox{ - Peer: peer, - MaxID: event.MaxID, - Pts: event.Pts, - PtsCount: event.PtsCount, - } -} - -func tgReadHistoryOutboxUpdate(event domain.UpdateEvent) tg.UpdateClass { - if event.Peer.Type == domain.PeerTypeChannel && event.Peer.ID != 0 { - return &tg.UpdateReadChannelOutbox{ - ChannelID: event.Peer.ID, - MaxID: event.MaxID, - } - } - return tgReadHistoryOutbox(event) -} - -func messagesNotModifiedOrEmpty(hash int64) tg.MessagesMessagesClass { - if hash != 0 { - return &tg.MessagesMessagesNotModified{Count: 0} - } - return &tg.MessagesMessages{} -} - -func messagesAllStickersEmpty(hash int64) tg.MessagesAllStickersClass { - if hash != 0 { - return &tg.MessagesAllStickersNotModified{} - } - return &tg.MessagesAllStickers{Sets: []tg.StickerSet{}} -} - -func messagesFeaturedStickersEmpty(hash int64) tg.MessagesFeaturedStickersClass { - if hash != 0 { - return &tg.MessagesFeaturedStickersNotModified{Count: 0} - } - return &tg.MessagesFeaturedStickers{ - Count: 0, - Sets: []tg.StickerSetCoveredClass{}, - Unread: []int64{}, - } -} - -func tgEditMessageUpdates(event domain.UpdateEvent, msg domain.Message, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates { - update := tgOtherUpdateFromEvent(domain.UpdateEvent{ - Type: domain.UpdateEventEditMessage, - Pts: event.Pts, - PtsCount: event.PtsCount, - Message: msg, - }) - if update == nil { - return &tg.Updates{Date: event.Date} - } - return &tg.Updates{ - Updates: []tg.UpdateClass{update}, - Users: users, - Chats: chats, - Date: event.Date, - Seq: 0, - } -} - -func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, randomID int64, includeMessageID bool, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates { - updates := make([]tg.UpdateClass, 0, 2) - if includeMessageID { - updates = append(updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: randomID}) - } - item := tgMessage(msg) - if item == nil { - item = &tg.MessageEmpty{ID: msg.ID} - } - updates = append(updates, &tg.UpdateNewMessage{ - Message: item, - Pts: event.Pts, - PtsCount: event.PtsCount, - }) - date := event.Date - if date == 0 { - date = msg.Date - } - return &tg.Updates{ - Updates: updates, - Users: users, - Chats: chats, - Date: date, - Seq: 0, // 私聊不维护账号级 seq,恒 0(客户端仅靠 pts 同步) - } -} - -func tgForwardMessagesUpdates(res domain.ForwardPrivateMessagesResult, randomIDs []int64, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates { - updates := make([]tg.UpdateClass, 0, len(res.SenderMessages)*2) - date := 0 - for i, msg := range res.SenderMessages { - randomID := int64(0) - if i < len(randomIDs) { - randomID = randomIDs[i] - } - updates = append(updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: randomID}) - event := domain.UpdateEvent{} - if i < len(res.SenderEvents) { - event = res.SenderEvents[i] - } - item := tgMessage(msg) - if item == nil { - item = &tg.MessageEmpty{ID: msg.ID} - } - pts := event.Pts - if pts == 0 { - pts = msg.Pts - } - ptsCount := event.PtsCount - if ptsCount == 0 { - ptsCount = 1 - } - updates = append(updates, &tg.UpdateNewMessage{ - Message: item, - Pts: pts, - PtsCount: ptsCount, - }) - if date == 0 { - date = event.Date - } - if date == 0 { - date = msg.Date - } - } - return &tg.Updates{ - Updates: updates, - Users: users, - Chats: chats, - Date: date, - Seq: 0, - } -} - -func (r *Router) usersForMessageUpdate(ctx context.Context, ownerUserID int64, msg domain.Message) []tg.UserClass { - seen := make(map[int64]struct{}, 2) - users := make([]tg.UserClass, 0, 2) - add := func(id int64) { - if id == 0 { - return - } - if _, ok := seen[id]; ok { - return - } - seen[id] = struct{}{} - switch { - case id == domain.OfficialSystemUserID: - users = append(users, r.tgUser(domain.OfficialSystemUser())) - case id == ownerUserID: - if r.deps.Users == nil { - return - } - u, err := r.deps.Users.Self(ctx, ownerUserID) - if err == nil && u.ID != 0 { - users = append(users, r.tgSelfUser(u)) - } - default: - if r.deps.Users == nil { - return - } - u, found, err := r.deps.Users.ByID(ctx, ownerUserID, id) - if err == nil && found { - users = append(users, r.tgUser(u)) - } - } - } - if msg.From.Type == domain.PeerTypeUser { - add(msg.From.ID) - } - if msg.Peer.Type == domain.PeerTypeUser { - add(msg.Peer.ID) - } - if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeUser { - add(msg.Forward.From.ID) - } - if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeUser { - add(msg.ReplyTo.Peer.ID) - } - return users -} - -func (r *Router) usersForMessageUpdates(ctx context.Context, ownerUserID int64, messages []domain.Message) []tg.UserClass { - seen := make(map[int64]struct{}, len(messages)*2) - ids := make([]int64, 0, len(messages)*2) - addID := func(id int64) { - if id == 0 { - return - } - if _, ok := seen[id]; ok { - return - } - seen[id] = struct{}{} - ids = append(ids, id) - } - for _, msg := range messages { - if msg.From.Type == domain.PeerTypeUser { - addID(msg.From.ID) - } - if msg.Peer.Type == domain.PeerTypeUser { - addID(msg.Peer.ID) - } - if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeUser { - addID(msg.Forward.From.ID) - } - if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeUser { - addID(msg.ReplyTo.Peer.ID) - } - } - if len(ids) == 0 { - return nil - } - loaded := make(map[int64]domain.User, len(ids)) - if r.deps.Users != nil { - if users, err := r.deps.Users.ByIDs(ctx, ownerUserID, ids); err == nil { - for _, user := range users { - loaded[user.ID] = user - } - } - } - users := make([]tg.UserClass, 0, len(ids)) - for _, id := range ids { - switch { - case id == domain.OfficialSystemUserID: - users = append(users, r.tgUser(domain.OfficialSystemUser())) - case id == ownerUserID: - if user, ok := loaded[id]; ok { - users = append(users, r.tgSelfUser(user)) - } - default: - if user, ok := loaded[id]; ok { - users = append(users, r.tgUser(user)) - } - } - } - return users -} - -func (r *Router) chatsForMessageUpdate(ctx context.Context, ownerUserID int64, msg domain.Message) []tg.ChatClass { - if r.deps.Channels == nil { - return nil - } - seen := make(map[int64]struct{}, 2) - chats := make([]tg.ChatClass, 0, 2) - add := func(id int64) { - if id == 0 { - return - } - if _, ok := seen[id]; ok { - return - } - seen[id] = struct{}{} - view, err := r.deps.Channels.GetChannel(ctx, ownerUserID, id) - if err != nil || view.Channel.ID == 0 { - return - } - chats = append(chats, tgChannelChat(ownerUserID, view.Channel, &view.Self)) - } - if msg.From.Type == domain.PeerTypeChannel { - add(msg.From.ID) - } - if msg.Peer.Type == domain.PeerTypeChannel { - add(msg.Peer.ID) - } - if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeChannel { - add(msg.Forward.From.ID) - } - if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeChannel { - add(msg.ReplyTo.Peer.ID) - } - return chats -} - -func (r *Router) chatsForMessageUpdates(ctx context.Context, ownerUserID int64, messages []domain.Message) []tg.ChatClass { - seen := make(map[int64]struct{}, len(messages)) - chats := make([]tg.ChatClass, 0, len(messages)) - for _, msg := range messages { - for _, chat := range r.chatsForMessageUpdate(ctx, ownerUserID, msg) { - channel, ok := chat.(*tg.Channel) - if !ok || channel.ID == 0 { - continue - } - if _, ok := seen[channel.ID]; ok { - continue - } - seen[channel.ID] = struct{}{} - chats = append(chats, chat) - } - } - return chats -} diff --git a/internal/rpc/messages_attach_menu.go b/internal/rpc/messages_attach_menu.go new file mode 100644 index 00000000..be2d866f --- /dev/null +++ b/internal/rpc/messages_attach_menu.go @@ -0,0 +1,261 @@ +package rpc + +import ( + "context" + "strconv" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) onMessagesGetAttachMenuBots(ctx context.Context, hash int64) (tg.AttachMenuBotsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 || r.deps.Bots == nil { + return &tg.AttachMenuBots{Hash: 0, Bots: []tg.AttachMenuBot{}, Users: []tg.UserClass{}}, nil + } + items, err := r.deps.Bots.ListAttachMenuBots(ctx) + if err != nil { + return nil, internalErr() + } + stateByBot := make(map[int64]domain.BotAttachMenuState, len(items)) + for _, item := range items { + if state, found, err := r.deps.Bots.GetAttachMenuState(ctx, userID, item.BotUserID); err == nil && found { + stateByBot[item.BotUserID] = state + } else if err != nil { + return nil, internalErr() + } + } + outHash := attachMenuBotsHash(userID, items, stateByBot) + if hash != 0 && hash == outHash { + return &tg.AttachMenuBotsNotModified{}, nil + } + bots := make([]tg.AttachMenuBot, 0, len(items)) + userIDs := make([]int64, 0, len(items)) + for _, item := range items { + state := stateByBot[item.BotUserID] + bots = append(bots, r.tgAttachMenuBot(ctx, item, state)) + userIDs = append(userIDs, item.BotUserID) + } + return &tg.AttachMenuBots{ + Hash: outHash, + Bots: bots, + Users: r.attachMenuUsers(ctx, userID, userIDs, stateByBot), + }, nil +} + +func (r *Router) onMessagesGetAttachMenuBot(ctx context.Context, bot tg.InputUserClass) (*tg.AttachMenuBotsBot, error) { + userID, target, err := r.attachMenuBotTarget(ctx, bot) + if err != nil { + return nil, err + } + item, found, err := r.deps.Bots.GetAttachMenuBot(ctx, target.ID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, botInvalidErr() + } + var state domain.BotAttachMenuState + if st, found, err := r.deps.Bots.GetAttachMenuState(ctx, userID, target.ID); err == nil && found { + state = st + } else if err != nil { + return nil, internalErr() + } + stateByBot := map[int64]domain.BotAttachMenuState{target.ID: state} + return &tg.AttachMenuBotsBot{ + Bot: r.tgAttachMenuBot(ctx, item, state), + Users: r.attachMenuUsers(ctx, userID, []int64{target.ID}, stateByBot), + }, nil +} + +func (r *Router) onMessagesToggleBotInAttachMenu(ctx context.Context, req *tg.MessagesToggleBotInAttachMenuRequest) (bool, error) { + if req == nil { + return false, botInvalidErr() + } + userID, target, err := r.attachMenuBotTarget(ctx, req.Bot) + if err != nil { + return false, err + } + if _, found, err := r.deps.Bots.GetAttachMenuBot(ctx, target.ID); err != nil { + return false, internalErr() + } else if !found { + return false, botInvalidErr() + } + prev, found, err := r.deps.Bots.GetAttachMenuState(ctx, userID, target.ID) + if err != nil { + return false, internalErr() + } + next := domain.BotAttachMenuState{ + UserID: userID, + BotUserID: target.ID, + Enabled: req.Enabled, + WriteAllowed: prev.WriteAllowed || req.WriteAllowed, + } + if _, err := r.deps.Bots.SetAttachMenuState(ctx, next); err != nil { + return false, botInvalidErr() + } + if req.WriteAllowed && !prev.WriteAllowed { + if _, err := r.deps.Bots.AllowSendMessage(ctx, userID, target.ID, true); err != nil { + return false, botInvalidErr() + } + res, err := r.sendBotAllowedServiceMessageWith(ctx, userID, target.ID, domain.MessageBotAllowedAction{AttachMenu: true}) + if err != nil { + return false, internalErr() + } + if !res.Duplicate { + senderUsers := r.usersForMessageUpdate(ctx, userID, res.SenderMessage) + senderChats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage) + r.pushUserMessage(ctx, userID, "push attach menu bot allowed", tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, senderUsers, senderChats)) + } + } + if !found || prev.Enabled != next.Enabled || prev.WriteAllowed != next.WriteAllowed { + r.pushUserMessageTransient(ctx, userID, "push attach menu bots changed", &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateAttachMenuBots{}}, + Date: int(r.clock.Now().Unix()), + }) + } + return true, nil +} + +func (r *Router) attachMenuBotTarget(ctx context.Context, bot tg.InputUserClass) (int64, domain.User, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, domain.User{}, internalErr() + } + if userID == 0 || r.deps.Bots == nil || bot == nil { + return 0, domain.User{}, botInvalidErr() + } + u, found, err := r.userFromInput(ctx, userID, bot) + if err != nil { + return 0, domain.User{}, internalErr() + } + if !found || !u.Bot { + return 0, domain.User{}, botInvalidErr() + } + return userID, u, nil +} + +func (r *Router) tgAttachMenuBot(ctx context.Context, item domain.BotAttachMenuBot, state domain.BotAttachMenuState) tg.AttachMenuBot { + out := tg.AttachMenuBot{ + Inactive: item.Inactive || !state.Enabled, + HasSettings: item.HasSettings, + RequestWriteAccess: item.RequestWriteAccess, + ShowInAttachMenu: item.ShowInAttachMenu, + ShowInSideMenu: item.ShowInSideMenu, + SideMenuDisclaimerNeeded: item.SideMenuDisclaimerNeeded, + BotID: item.BotUserID, + ShortName: item.ShortName, + PeerTypes: tgAttachMenuPeerTypes(item.PeerTypes), + Icons: r.tgAttachMenuIcons(ctx, item.Icons), + } + if len(out.PeerTypes) == 0 { + out.PeerTypes = []tg.AttachMenuPeerTypeClass{&tg.AttachMenuPeerTypePM{}, &tg.AttachMenuPeerTypeChat{}, &tg.AttachMenuPeerTypeBroadcast{}} + } + return out +} + +func (r *Router) tgAttachMenuIcons(ctx context.Context, icons []domain.BotAttachMenuIcon) []tg.AttachMenuBotIcon { + if len(icons) == 0 { + return []tg.AttachMenuBotIcon{} + } + out := make([]tg.AttachMenuBotIcon, 0, len(icons)) + for _, icon := range icons { + if icon.Name == "" || icon.DocumentID == 0 || r.deps.Files == nil { + continue + } + doc, found, err := r.deps.Files.GetDocument(ctx, icon.DocumentID) + if err != nil || !found { + continue + } + tgIcon := tg.AttachMenuBotIcon{Name: icon.Name, Icon: tgDocument(doc)} + if len(icon.Colors) > 0 { + colors := make([]tg.AttachMenuBotIconColor, 0, len(icon.Colors)) + for _, color := range icon.Colors { + if color.Name != "" { + colors = append(colors, tg.AttachMenuBotIconColor{Name: color.Name, Color: color.Color}) + } + } + if len(colors) > 0 { + tgIcon.SetColors(colors) + } + } + out = append(out, tgIcon) + } + return out +} + +func tgAttachMenuPeerTypes(in []string) []tg.AttachMenuPeerTypeClass { + out := make([]tg.AttachMenuPeerTypeClass, 0, len(in)) + seen := map[string]bool{} + for _, typ := range in { + if seen[typ] { + continue + } + seen[typ] = true + switch typ { + case "same_bot_pm": + out = append(out, &tg.AttachMenuPeerTypeSameBotPM{}) + case "bot_pm": + out = append(out, &tg.AttachMenuPeerTypeBotPM{}) + case "pm": + out = append(out, &tg.AttachMenuPeerTypePM{}) + case "chat", "megagroup": + out = append(out, &tg.AttachMenuPeerTypeChat{}) + case "broadcast", "channel": + out = append(out, &tg.AttachMenuPeerTypeBroadcast{}) + } + } + return out +} + +func (r *Router) attachMenuUsers(ctx context.Context, viewerID int64, ids []int64, states map[int64]domain.BotAttachMenuState) []tg.UserClass { + if len(ids) == 0 || r.deps.Users == nil { + return []tg.UserClass{} + } + users, err := r.deps.Users.ByIDs(ctx, viewerID, ids) + if err != nil { + return []tg.UserClass{} + } + out := make([]tg.UserClass, 0, len(users)) + for _, user := range users { + tgUser := r.withBotProfileFlags(ctx, r.tgUser(user)) + if state, ok := states[user.ID]; ok && state.Enabled { + tgUser.SetAttachMenuEnabled(true) + } + out = append(out, tgUser) + } + return out +} + +func attachMenuBotsHash(userID int64, items []domain.BotAttachMenuBot, states map[int64]domain.BotAttachMenuState) int64 { + if len(items) == 0 { + return 0 + } + parts := []string{"attach-menu", strconv.FormatInt(userID, 10)} + for _, item := range items { + state := states[item.BotUserID] + parts = append(parts, + strconv.FormatInt(item.BotUserID, 10), + item.ShortName, + strconv.FormatBool(item.Inactive), + strconv.FormatBool(item.HasSettings), + strconv.FormatBool(item.RequestWriteAccess), + strconv.FormatBool(item.ShowInAttachMenu), + strconv.FormatBool(item.ShowInSideMenu), + strconv.FormatBool(item.SideMenuDisclaimerNeeded), + strconv.FormatBool(state.Enabled), + strconv.FormatBool(state.WriteAllowed), + ) + for _, typ := range item.PeerTypes { + parts = append(parts, typ) + } + for _, icon := range item.Icons { + parts = append(parts, icon.Name, strconv.FormatInt(icon.DocumentID, 10)) + } + } + return stableBotAppInt64(parts...) +} diff --git a/internal/rpc/messages_attach_menu_rpc_test.go b/internal/rpc/messages_attach_menu_rpc_test.go new file mode 100644 index 00000000..ef729403 --- /dev/null +++ b/internal/rpc/messages_attach_menu_rpc_test.go @@ -0,0 +1,100 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" +) + +func TestMessagesAttachMenuBotsEmptyCatalog(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + + got, err := f.router.onMessagesGetAttachMenuBots(WithUserID(ctx, f.owner.ID), 0) + if err != nil { + t.Fatalf("get attach menu bots: %v", err) + } + list, ok := got.(*tg.AttachMenuBots) + if !ok { + t.Fatalf("attach menu bots = %T, want *tg.AttachMenuBots", got) + } + if list.Hash != 0 || len(list.Bots) != 0 || len(list.Users) != 0 { + t.Fatalf("attach menu bots = %+v, want empty catalog", list) + } +} + +func TestMessagesGetAttachMenuBotRejectsWithoutCatalog(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + + if _, err := f.router.onMessagesGetAttachMenuBot(ownerCtx, inputUser(f.bot)); !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("get attach menu bot err = %v, want BOT_INVALID", err) + } + if _, err := f.router.onMessagesGetAttachMenuBot(ownerCtx, inputUser(f.peer)); !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("get non-bot attach menu err = %v, want BOT_INVALID", err) + } +} + +func TestMessagesToggleBotInAttachMenuRejectsWithoutCatalog(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + + if ok, err := f.router.onMessagesToggleBotInAttachMenu(ownerCtx, &tg.MessagesToggleBotInAttachMenuRequest{ + Bot: inputUser(f.bot), + Enabled: true, + }); ok || !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("enable attach menu bot = %v,%v, want false,BOT_INVALID", ok, err) + } + if ok, err := f.router.onMessagesToggleBotInAttachMenu(ownerCtx, &tg.MessagesToggleBotInAttachMenuRequest{ + Bot: inputUser(f.bot), + Enabled: false, + }); ok || !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("disable attach menu bot = %v,%v, want false,BOT_INVALID", ok, err) + } + if ok, err := f.router.onMessagesToggleBotInAttachMenu(ownerCtx, &tg.MessagesToggleBotInAttachMenuRequest{ + Bot: inputUser(f.peer), + Enabled: true, + }); ok || !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("toggle non-bot attach menu = %v,%v, want false,BOT_INVALID", ok, err) + } +} + +func TestMessagesAttachMenuExplicitStubsAreRegistered(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + userCtx := WithUserID(ctx, f.owner.ID) + + tests := []struct { + name string + req bin.Encoder + }{ + { + name: "getAttachMenuBot", + req: &tg.MessagesGetAttachMenuBotRequest{Bot: inputUser(f.bot)}, + }, + { + name: "toggleBotInAttachMenu", + req: &tg.MessagesToggleBotInAttachMenuRequest{ + Bot: inputUser(f.bot), + Enabled: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var in bin.Buffer + if err := tt.req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := f.router.Dispatch(userCtx, [8]byte{}, 0, &in); !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("dispatch err = %v, want BOT_INVALID", err) + } + }) + } +} diff --git a/internal/rpc/messages_bot_longtail.go b/internal/rpc/messages_bot_longtail.go new file mode 100644 index 00000000..de592f2f --- /dev/null +++ b/internal/rpc/messages_bot_longtail.go @@ -0,0 +1,220 @@ +package rpc + +import ( + "context" + "strconv" + "strings" + "unicode/utf8" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) onMessagesSendWebViewData(ctx context.Context, req *tg.MessagesSendWebViewDataRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, botInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Messages == nil { + return nil, internalErr() + } + if req.RandomID == 0 || + strings.TrimSpace(req.ButtonText) == "" || + utf8.RuneCountInString(req.ButtonText) > domain.MaxWebViewDataButtonTextLen || + len(req.Data) > domain.MaxWebViewDataPayloadLen { + return nil, buttonDataInvalidErr() + } + bot, err := r.botUserFromInput(ctx, userID, req.Bot) + if err != nil { + return nil, err + } + if bot.ID == userID { + return nil, botInvalidErr() + } + recipientBlocked, err := r.peerBlocksUser(ctx, userID, bot.ID) + if err != nil { + return nil, err + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: bot.ID, + RandomID: req.RandomID, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionWebViewDataSent, + WebViewData: &domain.MessageWebViewDataAction{ + ButtonText: req.ButtonText, + Data: req.Data, + }, + }, + }, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + RecipientBlocked: recipientBlocked, + }) + if err != nil { + return nil, internalErr() + } + users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage) + chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage) + return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, chats), nil +} + +func (r *Router) onMessagesSendBotRequestedPeer(ctx context.Context, req *tg.MessagesSendBotRequestedPeerRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, buttonDataInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Bots == nil || r.deps.Messages == nil || r.deps.Users == nil { + return nil, internalErr() + } + botPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if botPeer.Type != domain.PeerTypeUser { + return nil, botInvalidErr() + } + botUser, found, err := r.deps.Users.ByID(ctx, userID, botPeer.ID) + if err != nil { + return nil, internalErr() + } + if !found || !botUser.Bot { + return nil, botInvalidErr() + } + webAppReqID, ok := req.GetWebappReqID() + if !ok || webAppReqID == "" { + return nil, buttonDataInvalidErr() + } + button, found, err := r.deps.Bots.GetRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) + if err != nil { + return nil, internalErr() + } + if !found || button.ButtonID != req.ButtonID { + return nil, buttonDataInvalidErr() + } + if len(req.RequestedPeers) == 0 || len(req.RequestedPeers) > button.MaxQuantity { + return nil, buttonDataInvalidErr() + } + peers := make([]domain.Peer, 0, len(req.RequestedPeers)) + for _, peer := range req.RequestedPeers { + resolved, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return nil, err + } + if !requestedPeerTypeMatches(button.PeerType, resolved) { + return nil, buttonDataInvalidErr() + } + peers = append(peers, resolved) + } + recipientBlocked, err := r.peerBlocksUser(ctx, userID, botUser.ID) + if err != nil { + return nil, err + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: botUser.ID, + RandomID: botRequestedPeerServiceMessageRandomID(userID, botUser.ID, webAppReqID, button.ButtonID, peers), + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionRequestedPeer, + RequestedPeer: &domain.MessageRequestedPeerAction{ + ButtonID: button.ButtonID, + Peers: peers, + }, + }, + }, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + RecipientBlocked: recipientBlocked, + }) + if err != nil { + return nil, internalErr() + } + _ = r.deps.Bots.DeleteRequestedWebViewButton(ctx, botUser.ID, userID, webAppReqID) + users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage) + chats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage) + return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, users, chats), nil +} + +func requestedPeerTypeMatches(kind string, peer domain.Peer) bool { + switch kind { + case "user", "": + return peer.Type == domain.PeerTypeUser + case "chat", "broadcast": + return peer.Type == domain.PeerTypeChannel + default: + return false + } +} + +func botRequestedPeerServiceMessageRandomID(userID, botUserID int64, reqID string, buttonID int, peers []domain.Peer) int64 { + parts := []string{"bot-requested-peer", strconv.FormatInt(userID, 10), strconv.FormatInt(botUserID, 10), reqID, strconv.Itoa(buttonID)} + for _, peer := range peers { + parts = append(parts, string(peer.Type), strconv.FormatInt(peer.ID, 10)) + } + return -stableBotAppInt64(parts...) +} + +func (r *Router) onMessagesGetPreparedInlineMessage(ctx context.Context, req *tg.MessagesGetPreparedInlineMessageRequest) (*tg.MessagesPreparedInlineMessage, error) { + if req == nil { + return nil, botInvalidErr() + } + if req.ID == "" { + return nil, resultIDEmptyErr() + } + if len(req.ID) > domain.MaxBotPreparedInlineIDLen { + return nil, resultIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + bot, err := r.botUserFromInput(ctx, userID, req.Bot) + if err != nil { + return nil, err + } + results, ok := r.inlines.preparedInlineContext(ctx, r.clock.Now(), userID, bot.ID, req.ID) + if !ok || len(results.Results) != 1 { + return nil, resultIDInvalidErr() + } + out := &tg.MessagesPreparedInlineMessage{ + QueryID: results.QueryID, + Result: tgBotInlineResult(results.Results[0]), + PeerTypes: tgPreparedInlinePeerTypes(results.PeerTypes), + CacheTime: results.CacheTime, + Users: []tg.UserClass{}, + } + if r.deps.Users != nil { + if u, found, err := r.deps.Users.ByID(ctx, userID, bot.ID); err == nil && found { + out.Users = append(out.Users, r.tgUser(u)) + } + } + return out, nil +} + +func (r *Router) botUserFromInput(ctx context.Context, userID int64, bot tg.InputUserClass) (domain.User, error) { + u, found, err := r.userFromInput(ctx, userID, bot) + if err != nil { + return domain.User{}, internalErr() + } + if !found || !u.Bot { + return domain.User{}, botInvalidErr() + } + return u, nil +} diff --git a/internal/rpc/messages_bot_longtail_rpc_test.go b/internal/rpc/messages_bot_longtail_rpc_test.go new file mode 100644 index 00000000..996879f0 --- /dev/null +++ b/internal/rpc/messages_bot_longtail_rpc_test.go @@ -0,0 +1,329 @@ +package rpc + +import ( + "context" + "strings" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestMessagesSendWebViewDataServiceMessageRoundTrip(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + + updatesClass, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{ + Bot: inputUser(f.bot), + RandomID: 1001, + ButtonText: "Open", + Data: `{"ok":true}`, + }) + if err != nil { + t.Fatalf("send webview data: %v", err) + } + updates := updatesClass.(*tg.Updates) + if len(updates.Updates) != 1 { + t.Fatalf("updates len = %d, want one service message", len(updates.Updates)) + } + newMessage, ok := updates.Updates[0].(*tg.UpdateNewMessage) + if !ok { + t.Fatalf("update = %T, want UpdateNewMessage", updates.Updates[0]) + } + service, ok := newMessage.Message.(*tg.MessageService) + if !ok { + t.Fatalf("message = %T, want MessageService", newMessage.Message) + } + sent, ok := service.Action.(*tg.MessageActionWebViewDataSent) + if !ok || sent.Text != "Open" { + t.Fatalf("user action = %+v (%T), want messageActionWebViewDataSent Open", service.Action, service.Action) + } + if service.PeerID.(*tg.PeerUser).UserID != f.bot.ID || service.FromID.(*tg.PeerUser).UserID != f.owner.ID { + t.Fatalf("service peer/from = %+v/%+v, want bot/user", service.PeerID, service.FromID) + } + + botHistory, err := f.router.deps.Messages.GetHistory(ctx, f.bot.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}, + Limit: 10, + }) + if err != nil || len(botHistory.Messages) != 1 { + t.Fatalf("bot history = %+v err=%v, want one service message", botHistory, err) + } + botService, ok := tgMessage(botHistory.Messages[0]).(*tg.MessageService) + if !ok { + t.Fatalf("bot history tg message = %T, want MessageService", tgMessage(botHistory.Messages[0])) + } + sentMe, ok := botService.Action.(*tg.MessageActionWebViewDataSentMe) + if !ok || sentMe.Text != "Open" || sentMe.Data != `{"ok":true}` { + t.Fatalf("bot action = %+v (%T), want messageActionWebViewDataSentMe Open/data", botService.Action, botService.Action) + } + + repeat, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{ + Bot: inputUser(f.bot), + RandomID: 1001, + ButtonText: "Open changed", + Data: `{"ok":false}`, + }) + if err != nil { + t.Fatalf("repeat send webview data: %v", err) + } + repeatMsg := repeat.(*tg.Updates).Updates[0].(*tg.UpdateNewMessage).Message.(*tg.MessageService) + if repeatMsg.ID != service.ID { + t.Fatalf("repeat message id = %d, want original %d", repeatMsg.ID, service.ID) + } + botHistory, err = f.router.deps.Messages.GetHistory(ctx, f.bot.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}, + Limit: 10, + }) + if err != nil || len(botHistory.Messages) != 1 { + t.Fatalf("bot history after repeat = %+v err=%v, want still one service message", botHistory, err) + } + + if _, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{ + Bot: inputUser(f.peer), + RandomID: 1002, + ButtonText: "Open", + Data: "{}", + }); !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("send webview data non-bot err = %v, want BOT_INVALID", err) + } + if _, err := f.router.onMessagesSendWebViewData(ownerCtx, &tg.MessagesSendWebViewDataRequest{ + Bot: inputUser(f.bot), + RandomID: 1003, + ButtonText: "Open", + Data: strings.Repeat("x", domain.MaxWebViewDataPayloadLen+1), + }); !tgerr.Is(err, "BUTTON_DATA_INVALID") { + t.Fatalf("send webview data too large err = %v, want BUTTON_DATA_INVALID", err) + } +} + +func TestMessagesSendBotRequestedPeerRejectsWithoutRequestButtonState(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + + if _, err := f.router.onMessagesSendBotRequestedPeer(ownerCtx, &tg.MessagesSendBotRequestedPeerRequest{ + Peer: inputPeerUser(f.bot), + ButtonID: 1, + RequestedPeers: []tg.InputPeerClass{inputPeerUser(f.peer)}, + }); !tgerr.Is(err, "BUTTON_DATA_INVALID") { + t.Fatalf("send requested peer err = %v, want BUTTON_DATA_INVALID", err) + } + if _, err := f.router.onMessagesSendBotRequestedPeer(ownerCtx, &tg.MessagesSendBotRequestedPeerRequest{ + Peer: inputPeerUser(f.bot), + ButtonID: 1, + RequestedPeers: nil, + }); !tgerr.Is(err, "BUTTON_DATA_INVALID") { + t.Fatalf("send requested peer without peers err = %v, want BUTTON_DATA_INVALID", err) + } +} + +func TestMessagesGetPreparedInlineMessageRejectsMissingRegistry(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + + if _, err := f.router.onMessagesGetPreparedInlineMessage(ownerCtx, &tg.MessagesGetPreparedInlineMessageRequest{ + Bot: inputUser(f.bot), + ID: "prepared-1", + }); !tgerr.Is(err, "RESULT_ID_INVALID") { + t.Fatalf("get prepared inline err = %v, want RESULT_ID_INVALID", err) + } + if _, err := f.router.onMessagesGetPreparedInlineMessage(ownerCtx, &tg.MessagesGetPreparedInlineMessageRequest{ + Bot: inputUser(f.bot), + }); !tgerr.Is(err, "RESULT_ID_EMPTY") { + t.Fatalf("get prepared inline empty id err = %v, want RESULT_ID_EMPTY", err) + } + if _, err := f.router.onMessagesGetPreparedInlineMessage(ownerCtx, &tg.MessagesGetPreparedInlineMessageRequest{ + Bot: inputUser(f.peer), + ID: "prepared-1", + }); !tgerr.Is(err, "BOT_INVALID") { + t.Fatalf("get prepared inline non-bot err = %v, want BOT_INVALID", err) + } +} + +func TestMessagesPreparedInlineRoundTripSendsMessage(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + botCtx := WithUserID(ctx, f.bot.ID) + ownerCtx := WithUserID(ctx, f.owner.ID) + + save := &tg.MessagesSavePreparedInlineMessageRequest{ + Result: preparedInlineArticleResult(), + UserID: inputUser(f.owner), + } + save.SetPeerTypes([]tg.InlineQueryPeerTypeClass{&tg.InlineQueryPeerTypePM{}}) + prepared, err := f.router.onMessagesSavePreparedInlineMessage(botCtx, save) + if err != nil { + t.Fatalf("save prepared inline: %v", err) + } + got, err := f.router.onMessagesGetPreparedInlineMessage(ownerCtx, &tg.MessagesGetPreparedInlineMessageRequest{ + Bot: inputUser(f.bot), + ID: prepared.ID, + }) + if err != nil { + t.Fatalf("get prepared inline: %v", err) + } + if got.QueryID == 0 || got.CacheTime <= 0 || len(got.PeerTypes) != 1 { + t.Fatalf("prepared inline = query %d cache %d peer_types %d", got.QueryID, got.CacheTime, len(got.PeerTypes)) + } + if _, ok := got.PeerTypes[0].(*tg.InlineQueryPeerTypePM); !ok { + t.Fatalf("prepared peer type = %T, want PM", got.PeerTypes[0]) + } + result, ok := got.Result.(*tg.BotInlineResult) + if !ok || result.ID != "prepared-article" { + t.Fatalf("prepared result = %T/%#v, want prepared article", got.Result, got.Result) + } + if len(got.Users) != 1 { + t.Fatalf("prepared users = %d, want bot user", len(got.Users)) + } + + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 9931, + QueryID: got.QueryID, + ID: result.ID, + }) + if err != nil { + t.Fatalf("send prepared inline result: %v", err) + } + sent := messageFromUpdates(t, updatesClass) + if sent.Message != "prepared text" { + t.Fatalf("sent prepared message = %q, want prepared text", sent.Message) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent prepared via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } +} + +func TestBotAPISavePreparedInlineMessageUsesRouterRegistry(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + + id, expireDate, err := f.router.SavePreparedInlineMessageFromBotAPI(ctx, f.bot.ID, f.owner.ID, domain.BotInlineResult{ + ID: "botapi-prepared", + Type: "article", + Message: "prepared from bot api", + }, []string{store.InlineQueryPeerTypePM}) + if err != nil { + t.Fatalf("save prepared inline from bot api: %v", err) + } + if id == "" || expireDate == 0 { + t.Fatalf("prepared id/expire = %q/%d, want non-empty", id, expireDate) + } + got, err := f.router.onMessagesGetPreparedInlineMessage(ownerCtx, &tg.MessagesGetPreparedInlineMessageRequest{ + Bot: inputUser(f.bot), + ID: id, + }) + if err != nil { + t.Fatalf("get bot api prepared inline: %v", err) + } + result, ok := got.Result.(*tg.BotInlineResult) + if !ok || result.ID != "botapi-prepared" { + t.Fatalf("prepared result = %T/%#v, want botapi-prepared", got.Result, got.Result) + } + if len(got.PeerTypes) != 1 { + t.Fatalf("prepared peer types = %d, want 1", len(got.PeerTypes)) + } + if _, ok := got.PeerTypes[0].(*tg.InlineQueryPeerTypePM); !ok { + t.Fatalf("prepared peer type = %T, want PM", got.PeerTypes[0]) + } + updatesClass, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 9933, + QueryID: got.QueryID, + ID: result.ID, + }) + if err != nil { + t.Fatalf("send bot api prepared inline result: %v", err) + } + sent := messageFromUpdates(t, updatesClass) + if sent.Message != "prepared from bot api" { + t.Fatalf("sent prepared message = %q, want prepared from bot api", sent.Message) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("sent prepared via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } +} + +func TestMessagesPreparedInlinePeerTypesRestrictSend(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + botCtx := WithUserID(ctx, f.bot.ID) + ownerCtx := WithUserID(ctx, f.owner.ID) + + save := &tg.MessagesSavePreparedInlineMessageRequest{ + Result: preparedInlineArticleResult(), + UserID: inputUser(f.owner), + } + save.SetPeerTypes([]tg.InlineQueryPeerTypeClass{&tg.InlineQueryPeerTypeBroadcast{}}) + prepared, err := f.router.onMessagesSavePreparedInlineMessage(botCtx, save) + if err != nil { + t.Fatalf("save prepared inline: %v", err) + } + got, err := f.router.onMessagesGetPreparedInlineMessage(ownerCtx, &tg.MessagesGetPreparedInlineMessageRequest{ + Bot: inputUser(f.bot), + ID: prepared.ID, + }) + if err != nil { + t.Fatalf("get prepared inline: %v", err) + } + if _, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 9932, + QueryID: got.QueryID, + ID: "prepared-article", + }); !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("send prepared to disallowed peer err = %v, want PEER_ID_INVALID", err) + } +} + +func TestMessagesBotLongtailExplicitStubsAreRegistered(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + userCtx := WithUserID(ctx, f.owner.ID) + + tests := []struct { + name string + req bin.Encoder + want string + }{ + { + name: "sendBotRequestedPeer", + req: &tg.MessagesSendBotRequestedPeerRequest{ + Peer: inputPeerUser(f.bot), + ButtonID: 1, + RequestedPeers: []tg.InputPeerClass{inputPeerUser(f.peer)}, + }, + want: "BUTTON_DATA_INVALID", + }, + { + name: "getPreparedInlineMessage", + req: &tg.MessagesGetPreparedInlineMessageRequest{ + Bot: inputUser(f.bot), + ID: "prepared-1", + }, + want: "RESULT_ID_INVALID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var in bin.Buffer + if err := tt.req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := f.router.Dispatch(userCtx, [8]byte{}, 0, &in); !tgerr.Is(err, tt.want) { + t.Fatalf("dispatch err = %v, want %s", err, tt.want) + } + }) + } +} diff --git a/internal/rpc/messages_bot_no_state.go b/internal/rpc/messages_bot_no_state.go new file mode 100644 index 00000000..d18edd86 --- /dev/null +++ b/internal/rpc/messages_bot_no_state.go @@ -0,0 +1,318 @@ +package rpc + +import ( + "context" + "unicode/utf8" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) onMessagesSavePreparedInlineMessage(ctx context.Context, req *tg.MessagesSavePreparedInlineMessageRequest) (*tg.MessagesBotPreparedInlineMessage, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return nil, err + } + if req == nil || req.Result == nil { + return nil, resultIDInvalidErr() + } + if r.deps.Users == nil { + return nil, internalErr() + } + currentUserID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + target, found, err := r.userFromInput(ctx, currentUserID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, userIDInvalidErr() + } + result, err := r.domainInlineResultFromTG(ctx, botID, req.Result) + if err != nil { + return nil, err + } + peerTypes, err := preparedInlinePeerTypesFromTG(req.PeerTypes) + if err != nil { + return nil, err + } + id, expireDate := r.inlines.savePreparedInlineContext(ctx, r.clock.Now(), botID, target.ID, result, peerTypes) + return &tg.MessagesBotPreparedInlineMessage{ + ID: id, + ExpireDate: expireDate, + }, nil +} + +func preparedInlinePeerTypesFromTG(in []tg.InlineQueryPeerTypeClass) ([]string, error) { + if len(in) == 0 { + return nil, nil + } + out := make([]string, 0, len(in)) + seen := make(map[string]struct{}, len(in)) + for _, peerType := range in { + value := storeInlineQueryPeerType(peerType) + if value == "" { + return nil, peerIDInvalidErr() + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out, nil +} + +func tgPreparedInlinePeerTypes(in []string) []tg.InlineQueryPeerTypeClass { + if len(in) == 0 { + return []tg.InlineQueryPeerTypeClass{} + } + out := make([]tg.InlineQueryPeerTypeClass, 0, len(in)) + for _, peerType := range in { + if value, ok := tgInlineQueryPeerTypeFromStore(peerType); ok { + out = append(out, value) + } + } + return out +} + +func (r *Router) onMessagesEditInlineBotMessage(ctx context.Context, req *tg.MessagesEditInlineBotMessageRequest) (bool, error) { + botID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req == nil { + return false, messageIDInvalidErr() + } + if botID == 0 || !r.userIsBot(ctx, botID) || r.deps.Messages == nil { + return false, messageIDInvalidErr() + } + target, found, err := r.privateMessageFromInlineID(ctx, botID, req.ID) + if err != nil { + return false, err + } + if found { + return r.editPrivateInlineBotMessage(ctx, botID, target, req) + } + _, channelTarget, found, err := r.channelMessageFromInlineID(ctx, botID, req.ID) + if err != nil { + return false, err + } + if found { + return r.editChannelInlineBotMessage(ctx, botID, channelTarget, req) + } + return false, messageIDInvalidErr() +} + +func (r *Router) editPrivateInlineBotMessage(ctx context.Context, botID int64, target domain.Message, req *tg.MessagesEditInlineBotMessageRequest) (bool, error) { + newMedia, err := r.inlineEditMedia(ctx, target.OwnerUserID, req) + if err != nil { + return false, err + } + message := target.Body + entities := append([]domain.MessageEntity(nil), target.Entities...) + if rawMessage, ok := req.GetMessage(); ok { + if rawMessage == "" && newMedia == nil && target.Media.IsZero() { + return false, messageEmptyErr() + } + if utf8.RuneCountInString(rawMessage) > maxSendMessageTextLength { + return false, messageTooLongErr() + } + rawEntities, _ := req.GetEntities() + if len(rawEntities) > maxMessageEntityCount { + return false, entitiesTooLongErr() + } + message = rawMessage + entities = domainMessageEntitiesForViewer(botID, rawEntities) + } else if req.ReplyMarkup == nil && newMedia == nil { + return false, messageNotModifiedErr() + } + var replyMarkup *domain.MessageReplyMarkup + setReplyMarkup := false + if req.ReplyMarkup != nil { + var err error + replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, true) + if err != nil { + return false, replyMarkupErr(err) + } + if _, ok := req.ReplyMarkup.(*tg.ReplyInlineMarkup); ok { + setReplyMarkup = true + } + } + _, err = r.deps.Messages.EditMessage(ctx, target.OwnerUserID, domain.EditMessageRequest{ + OwnerUserID: target.OwnerUserID, + Peer: target.Peer, + ID: target.ID, + Message: message, + Entities: entities, + Media: newMedia, + EditDate: int(r.clock.Now().Unix()), + SetReplyMarkup: setReplyMarkup, + ReplyMarkup: replyMarkup, + ViaBotEditBotID: botID, + }) + if err != nil { + return false, messageEditErr(err) + } + return true, nil +} + +func (r *Router) editChannelInlineBotMessage(ctx context.Context, botID int64, target domain.ChannelMessage, req *tg.MessagesEditInlineBotMessageRequest) (bool, error) { + if r.deps.Channels == nil { + return false, messageIDInvalidErr() + } + newMedia, err := r.inlineEditMedia(ctx, target.SenderUserID, req) + if err != nil { + return false, err + } + message := target.Body + entities := append([]domain.MessageEntity(nil), target.Entities...) + var mentionUserIDs []int64 + if rawMessage, ok := req.GetMessage(); ok { + if rawMessage == "" && newMedia == nil && target.Media.IsZero() { + return false, messageEmptyErr() + } + if utf8.RuneCountInString(rawMessage) > maxSendMessageTextLength { + return false, messageTooLongErr() + } + rawEntities, _ := req.GetEntities() + if len(rawEntities) > maxMessageEntityCount { + return false, entitiesTooLongErr() + } + message = rawMessage + entities = domainMessageEntitiesForViewer(botID, rawEntities) + var err error + mentionUserIDs, err = r.mentionedUserIDsFromMessage(ctx, botID, message, rawEntities) + if err != nil { + return false, err + } + } else { + if req.ReplyMarkup == nil && newMedia == nil { + return false, messageNotModifiedErr() + } + mentionUserIDs, err = r.mentionedUserIDsFromDomainMessage(ctx, botID, message, entities) + if err != nil { + return false, err + } + } + var replyMarkup *domain.MessageReplyMarkup + setReplyMarkup := false + if req.ReplyMarkup != nil { + var err error + replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, true) + if err != nil { + return false, replyMarkupErr(err) + } + if _, ok := req.ReplyMarkup.(*tg.ReplyInlineMarkup); ok { + setReplyMarkup = true + } + } + res, err := r.deps.Channels.EditInlineBotMessage(ctx, botID, domain.EditChannelMessageRequest{ + UserID: target.SenderUserID, + ChannelID: target.ChannelID, + ID: target.ID, + Message: message, + Entities: entities, + Media: newMedia, + MentionUserIDs: mentionUserIDs, + EditDate: int(r.clock.Now().Unix()), + SetReplyMarkup: setReplyMarkup, + ReplyMarkup: replyMarkup, + ViaBotEditBotID: botID, + }) + if err != nil { + return false, channelEditErr(err) + } + r.enqueueChannelEditMessageFanout(ctx, target.SenderUserID, res) + return true, nil +} + +func (r *Router) inlineEditMedia(ctx context.Context, userID int64, req *tg.MessagesEditInlineBotMessageRequest) (*domain.MessageMedia, error) { + input, ok := req.GetMedia() + if !ok || editMessageMediaCanDegradeToText(input) { + return nil, nil + } + media, err := r.resolveInputMedia(ctx, userID, input) + if err != nil { + return nil, err + } + if !inlineEditMediaAllowed(media) { + return nil, mediaInvalidErr() + } + return media, nil +} + +func inlineEditMediaAllowed(media *domain.MessageMedia) bool { + if media == nil || media.IsZero() { + return false + } + switch media.Kind { + case domain.MessageMediaKindPhoto, + domain.MessageMediaKindDocument, + domain.MessageMediaKindContact, + domain.MessageMediaKindGeo, + domain.MessageMediaKindVenue: + return true + default: + return false + } +} + +func (r *Router) mentionedUserIDsFromDomainMessage(ctx context.Context, currentUserID int64, message string, entities []domain.MessageEntity) ([]int64, error) { + if r.deps.Users == nil { + return nil, nil + } + identity, _ := r.deps.Users.(UserIdentityService) + seen := make(map[int64]struct{}) + out := make([]int64, 0) + add := func(id int64) { + if id == 0 { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + out = append(out, id) + } + for _, entity := range entities { + if entity.Type == domain.MessageEntityMentionName { + add(entity.UserID) + if len(out) >= domain.MaxChannelMentionRecipients { + return out, nil + } + } + } + if identity != nil { + for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out)) { + user, found, err := identity.ResolveUsername(ctx, currentUserID, username) + if err != nil { + return nil, internalErr() + } + if found { + add(user.ID) + } + if len(out) >= domain.MaxChannelMentionRecipients { + return out, nil + } + } + } + return out, nil +} + +func (r *Router) onMessagesSetBotShippingResults(ctx context.Context, req *tg.MessagesSetBotShippingResultsRequest) (bool, error) { + if _, err := r.callerBotID(ctx); err != nil { + return false, err + } + return false, queryIDInvalidErr() +} + +func (r *Router) onMessagesSetBotPrecheckoutResults(ctx context.Context, req *tg.MessagesSetBotPrecheckoutResultsRequest) (bool, error) { + if _, err := r.callerBotID(ctx); err != nil { + return false, err + } + return false, queryIDInvalidErr() +} diff --git a/internal/rpc/messages_bot_no_state_rpc_test.go b/internal/rpc/messages_bot_no_state_rpc_test.go new file mode 100644 index 00000000..8e6a2cd5 --- /dev/null +++ b/internal/rpc/messages_bot_no_state_rpc_test.go @@ -0,0 +1,332 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + + "telesrv/internal/domain" +) + +func TestMessagesSavePreparedInlineMessageStoresRegistry(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + botCtx := WithUserID(ctx, f.bot.ID) + userCtx := WithUserID(ctx, f.owner.ID) + + req := &tg.MessagesSavePreparedInlineMessageRequest{ + Result: preparedInlineArticleResult(), + UserID: inputUser(f.owner), + } + req.SetPeerTypes([]tg.InlineQueryPeerTypeClass{&tg.InlineQueryPeerTypePM{}}) + got, err := f.router.onMessagesSavePreparedInlineMessage(botCtx, req) + if err != nil { + t.Fatalf("save prepared inline: %v", err) + } + if got.ID == "" || got.ExpireDate <= int(f.router.clock.Now().Unix()) { + t.Fatalf("save prepared inline = %+v, want id and future expire_date", got) + } + if _, err := f.router.onMessagesSavePreparedInlineMessage(userCtx, req); !tgerr.Is(err, "USER_BOT_REQUIRED") { + t.Fatalf("save prepared inline by user err = %v, want USER_BOT_REQUIRED", err) + } + if _, err := f.router.onMessagesSavePreparedInlineMessage(botCtx, &tg.MessagesSavePreparedInlineMessageRequest{ + Result: preparedInlineArticleResult(), + UserID: &tg.InputUser{UserID: f.owner.ID + 999999, AccessHash: 1}, + }); !tgerr.Is(err, "USER_ID_INVALID") { + t.Fatalf("save prepared inline bad user err = %v, want USER_ID_INVALID", err) + } +} + +func TestMessagesEditInlineBotMessageRejectsWithoutInlineMessageState(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + userCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + req := &tg.MessagesEditInlineBotMessageRequest{ + ID: validInputBotInlineMessageID(f.owner.ID), + Message: "edited", + } + req.SetFlags() + if ok, err := f.router.onMessagesEditInlineBotMessage(userCtx, req); ok || !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("edit inline by user = %v,%v, want false,MESSAGE_ID_INVALID", ok, err) + } + if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, req); ok || !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("edit inline by bot = %v,%v, want false,MESSAGE_ID_INVALID", ok, err) + } + if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, &tg.MessagesEditInlineBotMessageRequest{}); ok || !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("edit inline nil id = %v,%v, want false,MESSAGE_ID_INVALID", ok, err) + } +} + +func TestMessagesEditInlineBotMessageEditsPrivateInlineMessage(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + sessions := &captureSessions{} + f.router.deps.Sessions = sessions + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + queryID, _ := f.router.inlines.registerWithCacheKey(f.router.clock.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}, inlineCacheKey{query: "editable"}) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + Results: []tg.InputBotInlineResultClass{ + inlineArticleResultWithCallbackMarkup("editable-1", "before edit", "open", []byte("v1")), + }, + }); err != nil || !ok { + t.Fatalf("set inline results = %v,%v, want true,nil", ok, err) + } + + if _, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 991001, + QueryID: queryID, + ID: "editable-1", + }); err != nil { + t.Fatalf("send inline bot result: %v", err) + } + feedback := inlineSendFeedbackFromSessions(t, sessions, f.bot.ID) + if feedback.UserID != f.owner.ID || feedback.Query != "editable" || feedback.ID != "editable-1" { + t.Fatalf("inline send feedback = user %d query %q id %q", feedback.UserID, feedback.Query, feedback.ID) + } + msgID, ok := feedback.GetMsgID() + if !ok { + t.Fatalf("inline send feedback missing msg_id") + } + + editReq := &tg.MessagesEditInlineBotMessageRequest{ID: msgID} + editReq.SetMessage("after edit") + editReq.SetReplyMarkup(&tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{ + Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "done", Data: []byte("v2")}}, + }}}) + if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, editReq); err != nil || !ok { + t.Fatalf("edit inline bot message = %v,%v, want true,nil", ok, err) + } + + ownerHistory, err := f.router.deps.Messages.GetHistory(ctx, f.owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}, + Limit: 1, + }) + if err != nil || len(ownerHistory.Messages) != 1 { + t.Fatalf("owner history len = %d err=%v, want 1,nil", len(ownerHistory.Messages), err) + } + assertInlineEditedMessage(t, ownerHistory.Messages[0], "after edit", f.bot.ID, "done", []byte("v2")) + + peerHistory, err := f.router.deps.Messages.GetHistory(ctx, f.peer.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}, + Limit: 1, + }) + if err != nil || len(peerHistory.Messages) != 1 { + t.Fatalf("peer history len = %d err=%v, want 1,nil", len(peerHistory.Messages), err) + } + assertInlineEditedMessage(t, peerHistory.Messages[0], "after edit", f.bot.ID, "done", []byte("v2")) +} + +func TestMessagesEditInlineBotMessageRewritesPrivateMedia(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + sessions := &captureSessions{} + f.router.deps.Sessions = sessions + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + queryID, _ := f.router.inlines.registerWithCacheKey(f.router.clock.Now(), f.bot.ID, f.owner.ID, domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}, inlineCacheKey{query: "editable-media"}) + if ok, err := f.router.onMessagesSetInlineBotResults(botCtx, &tg.MessagesSetInlineBotResultsRequest{ + QueryID: queryID, + Results: []tg.InputBotInlineResultClass{ + inlineArticleResultWithCallbackMarkup("editable-media-1", "caption before media edit", "open", []byte("media-v1")), + }, + }); err != nil || !ok { + t.Fatalf("set inline media result = %v,%v, want true,nil", ok, err) + } + if _, err := f.router.onMessagesSendInlineBotResult(ownerCtx, &tg.MessagesSendInlineBotResultRequest{ + Peer: inputPeerUser(f.peer), + RandomID: 991003, + QueryID: queryID, + ID: "editable-media-1", + }); err != nil { + t.Fatalf("send inline bot media result: %v", err) + } + msgID, ok := inlineSendFeedbackFromSessions(t, sessions, f.bot.ID).GetMsgID() + if !ok { + t.Fatalf("inline send feedback missing msg_id") + } + + badEdit := &tg.MessagesEditInlineBotMessageRequest{ID: msgID} + badEdit.SetMedia(&tg.InputMediaDice{Emoticon: "🎲"}) + if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, badEdit); ok || !tgerr.Is(err, "MEDIA_INVALID") { + t.Fatalf("unsupported inline media edit = %v,%v, want false,MEDIA_INVALID", ok, err) + } + + editReq := &tg.MessagesEditInlineBotMessageRequest{ID: msgID} + editReq.SetMedia(&tg.InputMediaContact{ + PhoneNumber: f.peer.Phone, + FirstName: "Peer", + LastName: "Edited", + Vcard: "BEGIN:VCARD\nFN:Peer Edited\nEND:VCARD", + }) + if ok, err := f.router.onMessagesEditInlineBotMessage(botCtx, editReq); err != nil || !ok { + t.Fatalf("edit inline bot media = %v,%v, want true,nil", ok, err) + } + + for _, tc := range []struct { + name string + userID int64 + peer domain.Peer + }{ + {name: "owner", userID: f.owner.ID, peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}}, + {name: "peer", userID: f.peer.ID, peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.owner.ID}}, + } { + history, err := f.router.deps.Messages.GetHistory(ctx, tc.userID, domain.MessageFilter{ + HasPeer: true, + Peer: tc.peer, + Limit: 1, + }) + if err != nil || len(history.Messages) != 1 { + t.Fatalf("%s history len = %d err=%v, want 1,nil", tc.name, len(history.Messages), err) + } + msg := history.Messages[0] + if msg.Body != "caption before media edit" || msg.ViaBotID != f.bot.ID { + t.Fatalf("%s edited message = %+v, want original caption via bot", tc.name, msg) + } + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindContact || msg.Media.Contact == nil { + t.Fatalf("%s edited media = %+v, want contact", tc.name, msg.Media) + } + if msg.Media.Contact.PhoneNumber != f.peer.Phone || msg.Media.Contact.UserID != f.peer.ID { + t.Fatalf("%s edited contact = %+v, want peer contact", tc.name, msg.Media.Contact) + } + assertDomainInlineReplyMarkup(t, msg.ReplyMarkup, "open", []byte("media-v1")) + } +} + +func TestMessagesPaymentQueryAnswersRejectWithoutPaymentState(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + botCtx := WithUserID(ctx, f.bot.ID) + userCtx := WithUserID(ctx, f.owner.ID) + + if ok, err := f.router.onMessagesSetBotShippingResults(botCtx, &tg.MessagesSetBotShippingResultsRequest{QueryID: 42}); ok || !tgerr.Is(err, "QUERY_ID_INVALID") { + t.Fatalf("set shipping = %v,%v, want false,QUERY_ID_INVALID", ok, err) + } + if ok, err := f.router.onMessagesSetBotShippingResults(userCtx, &tg.MessagesSetBotShippingResultsRequest{QueryID: 42}); ok || !tgerr.Is(err, "USER_BOT_REQUIRED") { + t.Fatalf("set shipping by user = %v,%v, want false,USER_BOT_REQUIRED", ok, err) + } + precheckout := &tg.MessagesSetBotPrecheckoutResultsRequest{QueryID: 43} + precheckout.SetSuccess(true) + if ok, err := f.router.onMessagesSetBotPrecheckoutResults(botCtx, precheckout); ok || !tgerr.Is(err, "QUERY_ID_INVALID") { + t.Fatalf("set precheckout = %v,%v, want false,QUERY_ID_INVALID", ok, err) + } + if ok, err := f.router.onMessagesSetBotPrecheckoutResults(userCtx, precheckout); ok || !tgerr.Is(err, "USER_BOT_REQUIRED") { + t.Fatalf("set precheckout by user = %v,%v, want false,USER_BOT_REQUIRED", ok, err) + } +} + +func TestMessagesBotNoStateExplicitStubsAreRegistered(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + botCtx := WithUserID(ctx, f.bot.ID) + + tests := []struct { + name string + req bin.Encoder + want string + }{ + { + name: "editInlineBotMessage", + req: &tg.MessagesEditInlineBotMessageRequest{ + ID: validInputBotInlineMessageID(f.owner.ID), + Message: "edited", + }, + want: "MESSAGE_ID_INVALID", + }, + { + name: "setBotShippingResults", + req: &tg.MessagesSetBotShippingResultsRequest{QueryID: 42}, + want: "QUERY_ID_INVALID", + }, + { + name: "setBotPrecheckoutResults", + req: func() bin.Encoder { + req := &tg.MessagesSetBotPrecheckoutResultsRequest{QueryID: 43} + req.SetSuccess(true) + return req + }(), + want: "QUERY_ID_INVALID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var in bin.Buffer + if err := tt.req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := f.router.Dispatch(botCtx, [8]byte{}, 0, &in); !tgerr.Is(err, tt.want) { + t.Fatalf("dispatch err = %v, want %s", err, tt.want) + } + }) + } +} + +func preparedInlineArticleResult() tg.InputBotInlineResultClass { + return &tg.InputBotInlineResult{ + ID: "prepared-article", + Type: "article", + Title: "Prepared", + SendMessage: &tg.InputBotInlineMessageText{ + Message: "prepared text", + }, + } +} + +func inlineArticleResultWithCallbackMarkup(id, message, button string, data []byte) tg.InputBotInlineResultClass { + return &tg.InputBotInlineResult{ + ID: id, + Type: "article", + Title: id, + SendMessage: &tg.InputBotInlineMessageText{ + Message: message, + ReplyMarkup: &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{{ + Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: button, Data: data}}, + }}}, + }, + } +} + +func inlineSendFeedbackFromSessions(t *testing.T, sessions *captureSessions, botID int64) *tg.UpdateBotInlineSend { + t.Helper() + snap := sessions.snapshot() + if snap.userID != botID { + t.Fatalf("feedback pushed to user %d, want bot %d", snap.userID, botID) + } + updates, ok := snap.message.(*tg.Updates) + if !ok { + t.Fatalf("feedback message = %T, want *tg.Updates", snap.message) + } + for _, update := range updates.Updates { + if feedback, ok := update.(*tg.UpdateBotInlineSend); ok { + return feedback + } + } + t.Fatalf("feedback updates missing UpdateBotInlineSend: %+v", updates.Updates) + return nil +} + +func assertInlineEditedMessage(t *testing.T, msg domain.Message, wantBody string, wantBotID int64, wantButton string, wantData []byte) { + t.Helper() + if msg.Body != wantBody || msg.ViaBotID != wantBotID { + t.Fatalf("message body/via = %q/%d, want %q/%d", msg.Body, msg.ViaBotID, wantBody, wantBotID) + } + if msg.ReplyMarkup == nil || len(msg.ReplyMarkup.Inline) != 1 || len(msg.ReplyMarkup.Inline[0]) != 1 { + t.Fatalf("message markup = %+v, want one callback button", msg.ReplyMarkup) + } + btn := msg.ReplyMarkup.Inline[0][0] + if btn.Text != wantButton || string(btn.Data) != string(wantData) { + t.Fatalf("message button = %q/%q, want %q/%q", btn.Text, string(btn.Data), wantButton, string(wantData)) + } +} diff --git a/internal/rpc/messages_channel_forward_rpc_test.go b/internal/rpc/messages_channel_forward_rpc_test.go new file mode 100644 index 00000000..6240c76e --- /dev/null +++ b/internal/rpc/messages_channel_forward_rpc_test.go @@ -0,0 +1,63 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + appchannels "telesrv/internal/app/channels" + appmessages "telesrv/internal/app/messages" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestForwardChannelMessageToUserIncludesSourceChannelChat(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550002033", FirstName: "Owner"}) + recipient, _ := userStore.Create(ctx, domain.User{AccessHash: 34, Phone: "15550002034", FirstName: "Recipient"}) + channelStore := memory.NewChannelStore() + messageStore := memory.NewMessageStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Messages: appmessages.NewService(messageStore, nil), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Source Channel", + About: "forward source", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create source channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: "source post", + RandomID: 4001, + }) + if err != nil { + t.Fatalf("send source channel message: %v", err) + } + msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID + forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ToPeer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, + ID: []int{msgID}, + RandomID: []int64{4002}, + }) + if err != nil { + t.Fatalf("forward channel to user: %v", err) + } + updates := forwarded.(*tg.Updates) + if len(updates.Chats) != 1 || updates.Chats[0].(*tg.Channel).ID != channel.ID { + t.Fatalf("forward updates chats = %+v, want source channel chat", updates.Chats) + } + newMsg := updates.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) + if from, ok := newMsg.FwdFrom.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { + t.Fatalf("forward header = %#v, want source channel peer", newMsg.FwdFrom) + } +} diff --git a/internal/rpc/messages_chat_theme_rpc_test.go b/internal/rpc/messages_chat_theme_rpc_test.go new file mode 100644 index 00000000..e0af9bc9 --- /dev/null +++ b/internal/rpc/messages_chat_theme_rpc_test.go @@ -0,0 +1,303 @@ +package rpc + +import ( + "context" + "strings" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appmessages "telesrv/internal/app/messages" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/seed/appearance" + "telesrv/internal/store/memory" +) + +func TestMessagesSetChatThemePrivatePersistsServiceMessageAndUserFull(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, _ := userStore.Create(ctx, domain.User{AccessHash: 1001, Phone: "15550003101", FirstName: "Alice"}) + bob, _ := userStore.Create(ctx, domain.User{AccessHash: 1002, Phone: "15550003102", FirstName: "Bob"}) + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + router := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Dialogs: appdialogs.NewService(dialogStore), + Messages: appmessages.NewService(messageStore, dialogStore), + }, zaptest.NewLogger(t), clock.System) + + theme := "\U0001f338" + if cts := appearance.Default().ChatThemes; len(cts) > 0 { + theme = cts[0].Emoticon + } + set, err := router.onMessagesSetChatTheme(WithUserID(ctx, alice.ID), &tg.MessagesSetChatThemeRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + Theme: &tg.InputChatTheme{Emoticon: theme}, + }) + if err != nil { + t.Fatalf("set chat theme: %v", err) + } + setAction := chatThemeActionFromUpdates(t, set) + if got := setAction.Theme.(*tg.ChatTheme).Emoticon; got != theme { + t.Fatalf("set action emoticon = %q, want %q", got, theme) + } + assertDialogTheme(t, dialogStore, alice.ID, bob.ID, theme) + assertDialogTheme(t, dialogStore, bob.ID, alice.ID, theme) + full, err := router.onUsersGetFullUser(WithUserID(ctx, alice.ID), &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash}) + if err != nil { + t.Fatalf("get full user after set: %v", err) + } + if gotTheme, ok := full.FullUser.GetTheme(); !ok || gotTheme.(*tg.ChatTheme).Emoticon != theme { + t.Fatalf("full user theme = %#v ok=%v, want %q", gotTheme, ok, theme) + } + + clear, err := router.onMessagesSetChatTheme(WithUserID(ctx, alice.ID), &tg.MessagesSetChatThemeRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + Theme: &tg.InputChatTheme{Emoticon: ""}, + }) + if err != nil { + t.Fatalf("clear chat theme: %v", err) + } + clearAction := chatThemeActionFromUpdates(t, clear) + if got := clearAction.Theme.(*tg.ChatTheme).Emoticon; got != "" { + t.Fatalf("clear action emoticon = %q, want empty", got) + } + assertDialogTheme(t, dialogStore, alice.ID, bob.ID, "") + assertDialogTheme(t, dialogStore, bob.ID, alice.ID, "") + full, err = router.onUsersGetFullUser(WithUserID(ctx, alice.ID), &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash}) + if err != nil { + t.Fatalf("get full user after clear: %v", err) + } + if gotTheme, ok := full.FullUser.GetTheme(); ok { + t.Fatalf("full user theme after clear = %#v, want absent", gotTheme) + } +} + +func TestMessagesSetChatThemeRejectsUnsupportedThemes(t *testing.T) { + router := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), 1000000001) + for _, req := range []*tg.MessagesSetChatThemeRequest{ + {Peer: &tg.InputPeerUser{UserID: 1000000002}, Theme: &tg.InputChatTheme{Emoticon: "\U0001f47b"}}, + {Peer: &tg.InputPeerUser{UserID: 1000000002}, Theme: &tg.InputChatThemeUniqueGift{Slug: "gift"}}, + } { + _, err := router.onMessagesSetChatTheme(ctx, req) + if err == nil || !strings.Contains(err.Error(), "THEME_INVALID") { + t.Fatalf("set unsupported theme err = %v, want THEME_INVALID", err) + } + } +} + +func TestMessagesSetChatWallPaperAcksPrivatePeerAndSupportsChannel(t *testing.T) { + router := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), 1000000001) + okReq := &tg.MessagesSetChatWallPaperRequest{ + Peer: &tg.InputPeerUser{UserID: 1000000002}, + } + okReq.SetWallpaper(&tg.InputWallPaperNoFile{ID: 930000000000000000}) + if got, err := router.onMessagesSetChatWallPaper(ctx, okReq); err != nil { + t.Fatalf("set chat wallpaper: %v", err) + } else if updates, ok := got.(*tg.Updates); !ok || len(updates.Updates) != 0 || updates.Date == 0 { + t.Fatalf("set chat wallpaper updates = %#v, want dated empty updates", got) + } + + for _, req := range []*tg.MessagesSetChatWallPaperRequest{ + nil, + {Peer: &tg.InputPeerEmpty{}}, + {Peer: &tg.InputPeerChannel{ChannelID: 10}}, + } { + _, err := router.onMessagesSetChatWallPaper(ctx, req) + if err == nil { + t.Fatalf("set chat wallpaper with %#v succeeded, want error", req) + } + } + + userStore := memory.NewUserStore() + alice, _ := userStore.Create(context.Background(), domain.User{AccessHash: 2001, Phone: "15550003201", FirstName: "Alice"}) + bob, _ := userStore.Create(context.Background(), domain.User{AccessHash: 2002, Phone: "15550003202", FirstName: "Bob"}) + channelStore := memory.NewChannelStore() + channels := appchannels.NewService(channelStore) + created, err := channels.CreateChannel(context.Background(), alice.ID, domain.CreateChannelRequest{ + Title: "Wallpaper Channel", + Broadcast: true, + MemberUserIDs: []int64{bob.ID}, + Date: 1700000100, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + router = New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channels, + }, zaptest.NewLogger(t), clock.System) + settings := tg.WallPaperSettings{} + settings.SetBackgroundColor(0) + settings.SetSecondBackgroundColor(0x95c46a) + settings.SetRotation(45) + channelReq := &tg.MessagesSetChatWallPaperRequest{ + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + } + channelReq.SetWallpaper(&tg.InputWallPaperNoFile{ID: 930000000000000001}) + channelReq.SetSettings(settings) + got, err := router.onMessagesSetChatWallPaper(WithUserID(context.Background(), alice.ID), channelReq) + if err != nil { + t.Fatalf("set channel wallpaper: %v", err) + } + updates, ok := got.(*tg.Updates) + if !ok { + t.Fatalf("set channel wallpaper updates = %T, want *tg.Updates", got) + } + action := channelWallpaperActionFromUpdates(t, updates) + actionWallpaper, ok := action.Wallpaper.(*tg.WallPaperNoFile) + if !ok || actionWallpaper.ID != 930000000000000001 { + t.Fatalf("service wallpaper = %T %#v, want wallPaperNoFile id", action.Wallpaper, action.Wallpaper) + } + peerWallpaper := channelPeerWallpaperFromUpdates(t, updates) + if gotID := peerWallpaper.ID; gotID != 930000000000000001 { + t.Fatalf("updatePeerWallpaper id = %d, want selected wallpaper id", gotID) + } + if color, ok := peerWallpaper.Settings.GetBackgroundColor(); !ok || color != 0 { + t.Fatalf("background color = %d ok=%v, want explicit black", color, ok) + } + view, err := channels.GetChannel(context.Background(), alice.ID, created.Channel.ID) + if err != nil { + t.Fatalf("get channel after wallpaper: %v", err) + } + if view.Channel.Wallpaper == nil || !view.Channel.Wallpaper.NoFile || !view.Channel.Wallpaper.Settings.HasBackgroundColor { + t.Fatalf("stored channel wallpaper = %#v, want no-file with explicit settings", view.Channel.Wallpaper) + } + full := tgChannelFull(view) + fullWallpaper, ok := full.GetWallpaper() + if !ok { + t.Fatalf("channelFull wallpaper missing") + } + fullNoFile, ok := fullWallpaper.(*tg.WallPaperNoFile) + if !ok || fullNoFile.ID != 930000000000000001 { + t.Fatalf("channelFull wallpaper = %T %#v, want selected no-file", fullWallpaper, fullWallpaper) + } + + clearReq := &tg.MessagesSetChatWallPaperRequest{Peer: channelReq.Peer} + clearGot, err := router.onMessagesSetChatWallPaper(WithUserID(context.Background(), alice.ID), clearReq) + if err != nil { + t.Fatalf("clear channel wallpaper: %v", err) + } + clearUpdates, ok := clearGot.(*tg.Updates) + if !ok { + t.Fatalf("clear channel wallpaper updates = %T, want *tg.Updates", clearGot) + } + if channelPeerWallpaperPresent(clearUpdates) { + t.Fatalf("clear updatePeerWallpaper carried wallpaper, want absent wallpaper") + } + view, err = channels.GetChannel(context.Background(), alice.ID, created.Channel.ID) + if err != nil { + t.Fatalf("get channel after clear wallpaper: %v", err) + } + if view.Channel.Wallpaper != nil { + t.Fatalf("stored channel wallpaper after clear = %#v, want nil", view.Channel.Wallpaper) + } + if _, ok := tgChannelFull(view).GetWallpaper(); ok { + t.Fatalf("channelFull wallpaper after clear present, want absent") + } + + _, err = router.onMessagesSetChatWallPaper(WithUserID(context.Background(), bob.ID), channelReq) + if err == nil || !strings.Contains(err.Error(), "CHAT_ADMIN_REQUIRED") { + t.Fatalf("non-admin set channel wallpaper err = %v, want CHAT_ADMIN_REQUIRED", err) + } +} + +func channelWallpaperActionFromUpdates(t *testing.T, updates *tg.Updates) *tg.MessageActionSetChatWallPaper { + t.Helper() + for _, update := range updates.Updates { + newMessage, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + service, ok := newMessage.Message.(*tg.MessageService) + if !ok { + continue + } + action, ok := service.Action.(*tg.MessageActionSetChatWallPaper) + if !ok { + t.Fatalf("channel service action = %T, want MessageActionSetChatWallPaper", service.Action) + } + return action + } + t.Fatalf("updates = %#v, want UpdateNewChannelMessage wallpaper service action", updates.Updates) + return nil +} + +func channelPeerWallpaperFromUpdates(t *testing.T, updates *tg.Updates) *tg.WallPaperNoFile { + t.Helper() + for _, update := range updates.Updates { + peerWallpaper, ok := update.(*tg.UpdatePeerWallpaper) + if !ok { + continue + } + wallpaper, ok := peerWallpaper.GetWallpaper() + if !ok { + t.Fatalf("updatePeerWallpaper missing wallpaper") + } + noFile, ok := wallpaper.(*tg.WallPaperNoFile) + if !ok { + t.Fatalf("updatePeerWallpaper wallpaper = %T, want WallPaperNoFile", wallpaper) + } + return noFile + } + t.Fatalf("updates = %#v, want UpdatePeerWallpaper", updates.Updates) + return nil +} + +func channelPeerWallpaperPresent(updates *tg.Updates) bool { + for _, update := range updates.Updates { + peerWallpaper, ok := update.(*tg.UpdatePeerWallpaper) + if !ok { + continue + } + _, ok = peerWallpaper.GetWallpaper() + return ok + } + return false +} + +func chatThemeActionFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.MessageActionSetChatTheme { + t.Helper() + got, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, update := range got.Updates { + newMessage, ok := update.(*tg.UpdateNewMessage) + if !ok { + continue + } + service, ok := newMessage.Message.(*tg.MessageService) + if !ok { + continue + } + action, ok := service.Action.(*tg.MessageActionSetChatTheme) + if !ok { + t.Fatalf("service action = %T, want MessageActionSetChatTheme", service.Action) + } + return action + } + t.Fatalf("updates = %#v, want UpdateNewMessage service action", got.Updates) + return nil +} + +func assertDialogTheme(t *testing.T, dialogs *memory.DialogStore, ownerID, peerID int64, want string) { + t.Helper() + list, err := dialogs.ListByPeers(context.Background(), ownerID, []domain.Peer{{Type: domain.PeerTypeUser, ID: peerID}}) + if err != nil { + t.Fatalf("list dialogs for %d/%d: %v", ownerID, peerID, err) + } + if len(list.Dialogs) != 1 { + t.Fatalf("dialogs for %d/%d = %d, want 1", ownerID, peerID, len(list.Dialogs)) + } + if got := list.Dialogs[0].ThemeEmoticon; got != want { + t.Fatalf("dialog theme for %d/%d = %q, want %q", ownerID, peerID, got, want) + } +} diff --git a/internal/rpc/messages_compat.go b/internal/rpc/messages_compat.go new file mode 100644 index 00000000..edd31dfa --- /dev/null +++ b/internal/rpc/messages_compat.go @@ -0,0 +1,535 @@ +package rpc + +import ( + "context" + "time" + + "github.com/gotd/td/tg" + + "strings" + "telesrv/internal/domain" + "telesrv/internal/seed/catalog" + "unicode/utf8" +) + +// webpageRequestResolveBudget 是交互式读 RPC(getWebPagePreview/getWebPage)未命中缓存时 +// 同步抓取的短预算上界——远小于异步解析的 20s,避免慢/挂上游把 RPC worker 钉死。 +const webpageRequestResolveBudget = 6 * time.Second + +func (r *Router) onMessagesGetSavedHistory(ctx context.Context, req *tg.MessagesGetSavedHistoryRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateSavedHistoryBounds(req.OffsetID, req.OffsetDate, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { + return nil, err + } + parentPeer, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer) + if err != nil { + return nil, err + } + savedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if hasParent { + mono, isMono, err := r.resolveMonoforumForAdmin(ctx, userID, parentPeer) + if err != nil { + return nil, err + } + if !isMono { + // 普通频道(非 monoforum)传 parent_peer:保持旧的良性空响应。 + if req.Hash != 0 { + return &tg.MessagesMessagesNotModified{Count: 0}, nil + } + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Chats: r.savedHistoryChats(ctx, userID, hasParent, parentPeer, req.Peer), + Users: []tg.UserClass{}, + }, nil + } + // parent_peer = monoforum:返回该订阅者(req.Peer)在频道私信内的历史。 + return r.monoforumSavedHistory(ctx, userID, mono, savedPeer, req.Limit, req.OffsetID) + } + if r.deps.Messages == nil { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + list, err := r.deps.Messages.GetHistory(ctx, userID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: userID}, + SavedPeer: savedPeer, + OffsetID: req.OffsetID, + OffsetDate: req.OffsetDate, + AddOffset: req.AddOffset, + Limit: req.Limit, + MaxID: req.MaxID, + MinID: req.MinID, + Hash: req.Hash, + NeedTotalCount: true, + }) + if err != nil { + return nil, internalErr() + } + if req.Hash != 0 && list.Hash == req.Hash { + return &tg.MessagesMessagesNotModified{Count: list.Count}, nil + } + out := tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, list)) + // saved peer 是频道(频道转发子会话)时补 chat 上下文。 + if chats := r.savedHistoryChats(ctx, userID, false, domain.Peer{}, req.Peer); len(chats) > 0 { + switch m := out.(type) { + case *tg.MessagesMessages: + m.Chats = mergeTGChats(m.Chats, chats) + case *tg.MessagesMessagesSlice: + m.Chats = mergeTGChats(m.Chats, chats) + } + } + return out, nil +} + +func (r *Router) onMessagesReadSavedHistory(ctx context.Context, req *tg.MessagesReadSavedHistoryRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + if err := r.validateRequiredSavedHistoryParentPeer(ctx, userID, req.ParentPeer); err != nil { + return false, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesDeleteSavedHistory(ctx context.Context, req *tg.MessagesDeleteSavedHistoryRequest) (*tg.MessagesAffectedHistory, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + minDate, hasMinDate := req.GetMinDate() + if hasMinDate && minDate < 0 { + return nil, limitInvalidErr() + } + maxDate, hasMaxDate := req.GetMaxDate() + if hasMaxDate && maxDate < 0 { + return nil, limitInvalidErr() + } + _, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer) + if err != nil { + return nil, err + } + savedPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if hasParent || r.deps.Messages == nil { + // monoforum 子会话删除范围外:parent 校验后保持 no-op 语义。 + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + sessionID, _ := SessionIDFrom(ctx) + res, err := r.deps.Messages.DeleteSavedHistory(ctx, userID, domain.DeleteSavedHistoryRequest{ + OwnerUserID: userID, + SavedPeer: savedPeer, + MaxID: req.MaxID, + MinDate: minDate, + MaxDate: maxDate, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, internalErr() + } + if len(res.MessageIDs) == 0 || res.Event.Pts == 0 { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + offset := 0 + if res.More { + offset = 1 + } + return &tg.MessagesAffectedHistory{ + Pts: res.Event.Pts, + PtsCount: res.Event.PtsCount, + Offset: offset, + }, nil +} + +func (r *Router) onMessagesGetCommonChats(ctx context.Context, req *tg.MessagesGetCommonChatsRequest) (tg.MessagesChatsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Limit < 0 || req.Limit > maxCommonChatsLimit { + return nil, limitInvalidErr() + } + if req.MaxID < 0 { + return nil, messageIDInvalidErr() + } + target, found, err := r.userFromInput(ctx, userID, req.UserID) + if err != nil { + return nil, internalErr() + } + if !found || target.ID == 0 || target.ID == userID { + return nil, userIDInvalidErr() + } + if req.Limit == 0 || r.deps.Channels == nil { + return &tg.MessagesChats{Chats: []tg.ChatClass{}}, nil + } + common, err := r.deps.Channels.CommonChannels(ctx, userID, domain.CommonChannelsRequest{ + UserID: userID, + TargetUserID: target.ID, + MaxID: req.MaxID, + Limit: req.Limit, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + chats := make([]tg.ChatClass, 0, len(common.Channels)) + for _, ch := range common.Channels { + chats = append(chats, tgChannelChatMin(userID, ch)) + } + return &tg.MessagesChats{Chats: chats}, nil +} + +func (r *Router) onMessagesGetAttachedStickers(ctx context.Context, media tg.InputStickeredMediaClass) ([]tg.StickerSetCoveredClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if media == nil { + return nil, mediaEmptyErr() + } + return []tg.StickerSetCoveredClass{}, nil +} + +func (r *Router) onMessagesGetCustomEmojiDocuments(ctx context.Context, documentIDs []int64) ([]tg.DocumentClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if len(documentIDs) > maxEmojiDocuments { + return nil, limitInvalidErr() + } + for _, id := range documentIDs { + if id <= 0 { + return nil, messageIDInvalidErr() + } + } + if r.deps.Files == nil || len(documentIDs) == 0 { + return []tg.DocumentClass{}, nil + } + docs, err := r.deps.Files.GetDocuments(ctx, documentIDs) + if err != nil { + return nil, internalErr() + } + byID := documentsByID(docs) + out := make([]tg.DocumentClass, 0, len(documentIDs)) + for _, id := range documentIDs { + if d, ok := byID[id]; ok { + out = append(out, tgDocument(d)) + } else { + out = append(out, &tg.DocumentEmpty{ID: id}) + } + } + return out, nil +} + +func (r *Router) onMessagesGetEmojiKeywords(ctx context.Context, langcode string) (*tg.EmojiKeywordsDifference, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if err := validateEmojiLangCode(langcode); err != nil { + return nil, err + } + if set, ok := catalog.EmojiKeywords(langcode); ok { + return &tg.EmojiKeywordsDifference{ + LangCode: langcode, + FromVersion: 0, + Version: set.Version, + Keywords: catalogEmojiKeywords(set.Keywords), + }, nil + } + return &tg.EmojiKeywordsDifference{ + LangCode: langcode, + FromVersion: 0, + Version: 0, + Keywords: []tg.EmojiKeywordClass{}, + }, nil +} + +func (r *Router) onMessagesGetEmojiKeywordsDifference(ctx context.Context, req *tg.MessagesGetEmojiKeywordsDifferenceRequest) (*tg.EmojiKeywordsDifference, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if err := validateEmojiLangCode(req.LangCode); err != nil { + return nil, err + } + if req.FromVersion < 0 { + return nil, limitInvalidErr() + } + // 我们只固化了单一版本词典:客户端版本落后则下发全量(0..version),否则回显空增量。 + if set, ok := catalog.EmojiKeywords(req.LangCode); ok && req.FromVersion < set.Version { + return &tg.EmojiKeywordsDifference{ + LangCode: req.LangCode, + FromVersion: req.FromVersion, + Version: set.Version, + Keywords: catalogEmojiKeywords(set.Keywords), + }, nil + } + version := req.FromVersion + if set, ok := catalog.EmojiKeywords(req.LangCode); ok { + version = set.Version + } + return &tg.EmojiKeywordsDifference{ + LangCode: req.LangCode, + FromVersion: req.FromVersion, + Version: version, + Keywords: []tg.EmojiKeywordClass{}, + }, nil +} + +// catalogEmojiKeywords 把 catalog 词典条目转成 TL emojiKeyword 向量。 +func catalogEmojiKeywords(kws []catalog.EmojiKeyword) []tg.EmojiKeywordClass { + out := make([]tg.EmojiKeywordClass, 0, len(kws)) + for _, k := range kws { + out = append(out, &tg.EmojiKeyword{Keyword: k.Keyword, Emoticons: k.Emoticons}) + } + return out +} + +func (r *Router) onMessagesGetExtendedMedia(ctx context.Context, req *tg.MessagesGetExtendedMediaRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateMessageIDVector(req.ID); err != nil { + return nil, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil +} + +func (r *Router) onMessagesGetWebPagePreview(ctx context.Context, req *tg.MessagesGetWebPagePreviewRequest) (*tg.MessagesWebPagePreview, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + message := strings.TrimSpace(req.Message) + if message == "" { + return nil, messageEmptyErr() + } + if utf8.RuneCountInString(message) > maxSendMessageTextLength || len(req.Entities) > maxMessageEntityCount { + return nil, limitInvalidErr() + } + media := r.webPagePreviewMedia(ctx, req.Message, req.Entities) + return &tg.MessagesWebPagePreview{ + Media: media, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil +} + +// onMessagesGetWebPage 返回某 URL 的已解析链接预览(instant view 入口;cached_page 不填充)。 +// hash 匹配则回 webPageNotModified 让客户端复用本地缓存;无预览/未启用/失败回 webPageEmpty。 +func (r *Router) onMessagesGetWebPage(ctx context.Context, req *tg.MessagesGetWebPageRequest) (*tg.MessagesWebPage, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return r.webPageForURL(ctx, req.URL, req.Hash), nil +} + +// webPageForURL 解析 URL 的链接预览供 getWebPage 返回。hash 匹配回 webPageNotModified; +// 无预览/未启用/失败回 webPageEmpty(带 URL)。cached_page(instant view)不填充。 +func (r *Router) webPageForURL(ctx context.Context, url string, hash int) *tg.MessagesWebPage { + emptyResult := func() *tg.MessagesWebPage { + page := &tg.WebPageEmpty{} + if url != "" { + page.SetURL(url) + } + return &tg.MessagesWebPage{Webpage: page, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}} + } + page, ok := r.resolveWebPageForRequest(ctx, url) + if !ok || page.State != domain.MessageWebPageStateDone { + return emptyResult() + } + if hash != 0 && hash == page.Hash { + return &tg.MessagesWebPage{Webpage: &tg.WebPageNotModified{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}} + } + return &tg.MessagesWebPage{Webpage: tgWebPage(page), Chats: []tg.ChatClass{}, Users: []tg.UserClass{}} +} + +// webPagePreviewMedia 解析消息内首个链接,返回输入框预览用的 media。getWebPagePreview 是同步 +// 探针:只有已解析出 done 卡片才返回 messageMediaWebPage,其余一律 messageMediaEmpty(不返回 +// pending——客户端不会对 preview 轮询 pending)。抓取失败/未启用一律降级为空,绝不报错。 +func (r *Router) webPagePreviewMedia(ctx context.Context, message string, entities []tg.MessageEntityClass) tg.MessageMediaClass { + url, ok := firstPreviewableURL(message, entities) + if !ok { + return &tg.MessageMediaEmpty{} + } + page, ok := r.resolveWebPageForRequest(ctx, url) + if !ok || page.State != domain.MessageWebPageStateDone { + return &tg.MessageMediaEmpty{} + } + return tgWebPageMedia(page) +} + +// resolveWebPageForRequest 为交互式读 RPC 解析链接预览:先查缓存(LookupWebPage,命中即返回, +// 不抓取不阻塞);未命中才同步抓取,但用受限短预算(webpageRequestResolveBudget)而非异步解析 +// 的 20s,避免慢/挂上游把 RPC worker 钉死。命中(含负缓存的 empty)返回 ok=true,调用方据 state +// 决定;抓取失败返回 false。未启用返回 false。 +func (r *Router) resolveWebPageForRequest(ctx context.Context, url string) (domain.MessageWebPage, bool) { + if r.deps.Files == nil { + return domain.MessageWebPage{}, false + } + if page, ok := r.deps.Files.LookupWebPage(ctx, url); ok { + return page, true + } + fctx, cancel := context.WithTimeout(ctx, webpageRequestResolveBudget) + defer cancel() + page, err := r.deps.Files.ResolveWebPage(fctx, url) + if err != nil { + return domain.MessageWebPage{}, false + } + return page, true +} + +func tgInputMessageEntities(entities []domain.MessageEntity) []tg.MessageEntityClass { + // 定时消息到点投递必须与即时发送等价:实体全类型回放(mentionName + // 在 domain 已解析为 user_id,直接走输出形态)。 + return tgMessageEntities(entities) +} + +func mergeTGUsers(base []tg.UserClass, extra []tg.UserClass) []tg.UserClass { + seen := make(map[int64]struct{}, len(base)+len(extra)) + out := make([]tg.UserClass, 0, len(base)+len(extra)) + add := func(user tg.UserClass) { + u, ok := user.(*tg.User) + if !ok || u.ID == 0 { + out = append(out, user) + return + } + if _, ok := seen[u.ID]; ok { + return + } + seen[u.ID] = struct{}{} + out = append(out, user) + } + for _, user := range base { + add(user) + } + for _, user := range extra { + add(user) + } + return out +} + +func mergeTGChats(base []tg.ChatClass, extra []tg.ChatClass) []tg.ChatClass { + seen := make(map[int64]struct{}, len(base)+len(extra)) + out := make([]tg.ChatClass, 0, len(base)+len(extra)) + add := func(chat tg.ChatClass) { + var id int64 + switch c := chat.(type) { + case *tg.Channel: + id = c.ID + case *tg.Chat: + id = c.ID + } + if id == 0 { + out = append(out, chat) + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + out = append(out, chat) + } + for _, chat := range base { + add(chat) + } + for _, chat := range extra { + add(chat) + } + return out +} + +func optionalString(get func() (string, bool)) string { + if get == nil { + return "" + } + value, ok := get() + if !ok { + return "" + } + return value +} + +func validateEmojiLangCode(langcode string) error { + if langcode == "" || len(langcode) > maxEmojiLangCodeLength { + return limitInvalidErr() + } + for _, c := range langcode { + switch { + case c >= 'a' && c <= 'z': + case c >= 'A' && c <= 'Z': + case c >= '0' && c <= '9': + case c == '-' || c == '_': + default: + return limitInvalidErr() + } + } + return nil +} + +func validateSavedHistoryBounds(offsetID, offsetDate, addOffset, limit, maxID, minID int) error { + if err := validateHistoryBounds(offsetID, addOffset, limit, maxID, minID); err != nil { + return err + } + if offsetDate < 0 { + return limitInvalidErr() + } + return nil +} + +func (r *Router) validateSavedHistoryParentPeer(ctx context.Context, userID int64, getParent func() (tg.InputPeerClass, bool)) (domain.Peer, bool, error) { + parentPeer, ok := getParent() + if !ok { + return domain.Peer{}, false, nil + } + if err := r.validateRequiredSavedHistoryParentPeer(ctx, userID, parentPeer); err != nil { + return domain.Peer{}, false, err + } + parent, _ := r.domainPeerFromInputPeer(userID, parentPeer) + return parent, true, nil +} + +func (r *Router) validateRequiredSavedHistoryParentPeer(ctx context.Context, userID int64, parentPeer tg.InputPeerClass) error { + parent, err := r.checkedDomainPeerFromInputPeer(ctx, userID, parentPeer) + if err != nil || parent.Type != domain.PeerTypeChannel { + return parentPeerInvalidErr() + } + return nil +} + +func messagesAllStickersEmpty(hash int64) tg.MessagesAllStickersClass { + if hash != 0 { + return &tg.MessagesAllStickersNotModified{} + } + return &tg.MessagesAllStickers{Sets: []tg.StickerSet{}} +} + +func messagesFeaturedStickersEmpty(hash int64) tg.MessagesFeaturedStickersClass { + if hash != 0 { + return &tg.MessagesFeaturedStickersNotModified{Count: 0} + } + return &tg.MessagesFeaturedStickers{ + Count: 0, + Sets: []tg.StickerSetCoveredClass{}, + Unread: []int64{}, + } +} diff --git a/internal/rpc/messages_constants.go b/internal/rpc/messages_constants.go new file mode 100644 index 00000000..ad9ec030 --- /dev/null +++ b/internal/rpc/messages_constants.go @@ -0,0 +1,58 @@ +package rpc + +import ( + "telesrv/internal/domain" + "time" +) + +const ( + maxSendMessageTextLength = domain.MaxMessageTextLength + maxReplyQuoteLength = domain.MaxMessageReplyQuoteLength + maxMessageSearchQLength = 256 + maxMessageEntityCount = domain.MaxMessageEntityCount + maxGetMessagesIDs = 100 + maxMessageSearchFilters = 32 + maxForumTopicIDs = 100 + maxDialogInputPeers = 100 + maxSearchResultsLimit = 100 + maxReactionVector = 16 + maxReactionListOffset = 128 + maxReportOptionLength = 32 + maxReportCommentLength = 1024 + maxReportRandomIDLength = 128 + maxReadMetrics = 100 + maxBusinessConnIDLength = 128 + maxSendMultiMediaItems = 10 + maxScheduledMessagePage = 100 + maxForumTopicTitleLength = 128 + maxPollVoteOptions = 10 + maxPollOptionBytes = 256 + maxPollVotesOffsetLength = 128 + maxTodoItems = 30 + maxTodoTitleLength = 200 + // maxTodoItemID 是清单项 id 的防御上限:协议只要求列表内唯一正整数(客户端通常 + // 顺序分配),不能用条目数上限当 id 边界,否则非顺序分配的合法 id 被误拒。 + maxTodoItemID = 1 << 16 + maxVenueTitleLength = 256 + maxVenueAddressLength = 512 + maxVenueProviderLength = 64 + maxVenueIDLength = 256 + // live location 有效期边界(官方 60s..1d;0x7FFFFFFF = 手动停止前一直共享)。 + minLiveLocationPeriod = 60 + maxLiveLocationPeriod = 86400 + foreverLiveLocationPeriod = 0x7FFFFFFF + maxLiveLocationHeading = 360 + maxProximityRadiusMeters = 100000 + // maxGeoAccuracyRadiusMeters 与 proximity radius 同量级上限;越界静默归零(按未知精度处理)。 + maxGeoAccuracyRadiusMeters = 100000 + maxCommonChatsLimit = domain.MaxCommonChannelsLimit + maxStickerSearchQLength = 128 + maxStickerSearchLangs = 16 + maxEmojiLangCodeLength = 32 + maxEmojiDocuments = 100 + maxSavedReactionTagTitle = 12 + defaultTopReactionsLimit = 14 + sendMessageRateWindow = time.Minute + forumGeneralTopicID = 1 + forumGeneralIconColor = 0x6FB9F0 +) diff --git a/internal/rpc/messages_contracts.go b/internal/rpc/messages_contracts.go new file mode 100644 index 00000000..daf4acab --- /dev/null +++ b/internal/rpc/messages_contracts.go @@ -0,0 +1,76 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +type accountDefaultReactionService interface { + SetDefaultReaction(ctx context.Context, userID int64, reaction domain.MessageReaction) (domain.AccountReactionSettings, error) +} + +type accountPaidReactionPrivacyService interface { + GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, error) + SetPaidReactionPrivacy(ctx context.Context, userID int64, privacy domain.PaidReactionPrivacy) (domain.AccountReactionSettings, error) +} + +type messageReactionUpdateRecorder interface { + RecordMessageReactions(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) +} + +type messagePollUpdateRecorder interface { + RecordMessagePoll(ctx context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) +} + +type messageReactionUsageRecorder interface { + RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error +} + +type channelParticipantReactionModerator interface { + DeleteParticipantReaction(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) + DeleteParticipantReactions(ctx context.Context, userID int64, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) +} + +type scheduledMessagesService interface { + ScheduleMessage(ctx context.Context, userID int64, req domain.ScheduleMessageRequest) (domain.ScheduledMessage, error) + EditScheduledMessage(ctx context.Context, userID int64, req domain.EditScheduledMessageRequest) (domain.ScheduledMessage, error) + ListScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) + GetScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) + DeleteScheduledMessages(ctx context.Context, userID int64, filter domain.ScheduledMessageFilter, date int) ([]domain.ScheduledMessage, error) + ClaimScheduledMessages(ctx context.Context, userID int64, claim domain.ScheduledMessageClaim) ([]domain.ScheduledMessage, error) + ClaimDueScheduledMessages(ctx context.Context, now, limit, leaseSeconds int) ([]domain.ScheduledMessage, error) + MarkScheduledMessageSent(ctx context.Context, ownerUserID int64, id, sentMessageID, date int) error + ReleaseScheduledMessage(ctx context.Context, ownerUserID int64, id int, errText string) error + HasScheduledMessages(ctx context.Context, userID int64, peer domain.Peer) (bool, error) +} + +type historyTTLMessagesService interface { + GetPrivateHistoryTTL(ctx context.Context, userID int64, peer domain.Peer) (int, error) + SetPrivateHistoryTTL(ctx context.Context, userID int64, peer domain.Peer, period int) error + DefaultHistoryTTL(ctx context.Context, userID int64) (int, error) + SetDefaultHistoryTTL(ctx context.Context, userID int64, period int) error + ClaimExpiredPrivateMessages(ctx context.Context, now, limit int) ([]domain.DeleteMessagesRequest, error) +} + +type channelHistoryTTLService interface { + SetHistoryTTL(ctx context.Context, userID, channelID int64, period int, date int) (domain.Channel, []int64, error) + ClaimExpiredMessages(ctx context.Context, now, limit int) ([]domain.DeleteChannelMessagesRequest, error) +} + +type globalSearchHit struct { + date int + peerRank int64 + messageID int + message tg.MessageClass +} + +type forwardSource struct { + body string + entities []domain.MessageEntity + media *domain.MessageMedia + forward *domain.MessageForward + from domain.Peer + date int + noForward bool +} diff --git a/internal/rpc/messages_create_chat_test.go b/internal/rpc/messages_create_chat_test.go new file mode 100644 index 00000000..1ab72f68 --- /dev/null +++ b/internal/rpc/messages_create_chat_test.go @@ -0,0 +1,391 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strings" + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550001002", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "E2E Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + updates, ok := invited.Updates.(*tg.Updates) + if !ok || len(updates.Chats) != 1 { + t.Fatalf("updates = %T %+v, want one chat", invited.Updates, invited.Updates) + } + channel, ok := updates.Chats[0].(*tg.Channel) + if !ok || !channel.Megagroup || channel.Broadcast { + t.Fatalf("chat = %#v, want megagroup channel", updates.Chats[0]) + } + assertDefaultBannedRightsAllowsSend(t, channel) + if len(updates.Updates) != 4 { + t.Fatalf("updates len = %d, want create/invite service messages plus channel refreshes", len(updates.Updates)) + } + newMsg, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage) + if !ok || newMsg.Pts != 1 || newMsg.PtsCount != 1 { + t.Fatalf("create update = %#v, want channel pts=1", updates.Updates[0]) + } + if refresh, ok := updates.Updates[1].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID { + t.Fatalf("create refresh = %#v, want channel refresh", updates.Updates[1]) + } + service, ok := newMsg.Message.(*tg.MessageService) + if !ok { + t.Fatalf("create message = %T, want service", newMsg.Message) + } + if _, ok := service.Action.(*tg.MessageActionChannelCreate); !ok { + t.Fatalf("service action = %T, want channel create", service.Action) + } + inviteMsg, ok := updates.Updates[2].(*tg.UpdateNewChannelMessage) + if !ok || inviteMsg.Pts != 2 || inviteMsg.PtsCount != 1 { + t.Fatalf("invite update = %#v, want channel pts=2", updates.Updates[2]) + } + if refresh, ok := updates.Updates[3].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID { + t.Fatalf("invite refresh = %#v, want channel refresh", updates.Updates[3]) + } + inviteService, ok := inviteMsg.Message.(*tg.MessageService) + if !ok { + t.Fatalf("invite message = %T, want service", inviteMsg.Message) + } + addUser, ok := inviteService.Action.(*tg.MessageActionChatAddUser) + if !ok || len(addUser.Users) != 1 || addUser.Users[0] != friend.ID { + t.Fatalf("invite action = %#v, want add friend %d", inviteService.Action, friend.ID) + } + if len(updates.Users) != 2 { + t.Fatalf("updates users len = %d, want owner + friend", len(updates.Users)) + } + + participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get participants: %v", err) + } + participantList, ok := participants.(*tg.ChannelsChannelParticipants) + if !ok || participantList.Count != 2 || len(participantList.Participants) != 2 || len(participantList.Users) != 2 { + t.Fatalf("participants = %T %+v, want owner + friend participants/users", participants, participants) + } + + req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20} + var b bin.Buffer + if err := req.Encode(&b); err != nil { + t.Fatalf("encode get dialogs: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch get dialogs: %v", err) + } + box, ok := enc.(*tg.MessagesDialogsBox) + if !ok { + t.Fatalf("dialogs response = %T, want box", enc) + } + dialogs, ok := box.Dialogs.(*tg.MessagesDialogs) + if !ok || len(dialogs.Dialogs) != 1 || len(dialogs.Chats) != 1 || len(dialogs.Messages) != 1 { + t.Fatalf("dialogs = %T %+v, want channel dialog/chat/message", box.Dialogs, box.Dialogs) + } + dialog := dialogs.Dialogs[0].(*tg.Dialog) + if peer, ok := dialog.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { + t.Fatalf("dialog peer = %#v, want channel %d", dialog.Peer, channel.ID) + } +} + +func TestMessagesCreateChatRejectsEmptyInviteListRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550001021", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(memory.NewChannelStore()), + }, zaptest.NewLogger(t), clock.System) + + if _, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Title: "No Invitees", + }); err == nil || !strings.Contains(err.Error(), "USERS_TOO_FEW") { + t.Fatalf("create chat without users err = %v, want USERS_TOO_FEW", err) + } +} + +func TestMessagesCreateChatTDesktopReturnsLegacyChatAndAcceptsInputPeerChatRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550001031", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + tdCtx := WithClientInfo(WithUserID(ctx, owner.ID), ClientInfo{ + DeviceModel: "Desktop", + AppVersion: "6.8.4 x64", + LangPack: "tdesktop", + }) + + invited, err := r.onMessagesCreateChat(tdCtx, &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "TDesktop Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + updates, ok := invited.Updates.(*tg.Updates) + if !ok || len(updates.Chats) != 2 { + t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates) + } + legacy, ok := updates.Chats[0].(*tg.Chat) + if !ok || !legacy.Deactivated { + t.Fatalf("legacy chat = %#v, want migrated chat", updates.Chats[0]) + } + assertDefaultBannedRightsAllowsSend(t, legacy) + channel, ok := updates.Chats[1].(*tg.Channel) + if !ok || !channel.Megagroup || channel.Broadcast { + t.Fatalf("channel = %#v, want megagroup channel", updates.Chats[1]) + } + assertDefaultBannedRightsAllowsSend(t, channel) + if !channel.Creator { + t.Fatalf("channel creator flag = false, want true for creator") + } + if rights, ok := channel.GetAdminRights(); !ok || !rights.ChangeInfo || !rights.InviteUsers { + t.Fatalf("channel admin rights = %+v ok=%v, want creator manage rights", rights, ok) + } + migrated, ok := legacy.GetMigratedTo() + if !ok { + t.Fatalf("legacy chat missing migrated_to") + } + migratedTo, ok := migrated.(*tg.InputChannel) + if !ok || migratedTo.ChannelID != channel.ID || migratedTo.AccessHash != channel.AccessHash { + t.Fatalf("migrated_to = %#v, want channel %d/%d", migrated, channel.ID, channel.AccessHash) + } + + participants, err := r.onChannelsGetParticipants(tdCtx, &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get participants after create chat: %v", err) + } + participantList, ok := participants.(*tg.ChannelsChannelParticipants) + if !ok || len(participantList.Chats) != 0 { + t.Fatalf("participants = %T %+v, want no chat side vector", participants, participants) + } + if participantList.Count != 2 || len(participantList.Participants) != 2 || len(participantList.Users) != 2 { + t.Fatalf("participants count/rows/users = %d/%d/%d, want owner + friend", + participantList.Count, len(participantList.Participants), len(participantList.Users)) + } + legacyHistoryReq := &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerChat{ChatID: channel.ID}, + Limit: 20, + } + var legacyHistoryBuf bin.Buffer + if err := legacyHistoryReq.Encode(&legacyHistoryBuf); err != nil { + t.Fatalf("encode legacy history: %v", err) + } + legacyHistory, err := r.Dispatch(tdCtx, [8]byte{}, 0, &legacyHistoryBuf) + if err != nil { + t.Fatalf("legacy history: %v", err) + } + legacyBox, ok := legacyHistory.(*tg.MessagesMessagesBox) + if !ok { + t.Fatalf("legacy history = %T %+v, want messages box", legacyHistory, legacyHistory) + } + legacyMessages, ok := legacyBox.Messages.(*tg.MessagesMessages) + if !ok || len(legacyMessages.Messages) != 0 { + t.Fatalf("legacy history = %T %+v, want empty messages.messages", legacyHistory, legacyHistory) + } + + sent, err := r.onMessagesSendMessage(tdCtx, &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChat{ChatID: channel.ID}, + RandomID: 99, + Message: "via legacy input peer", + }) + if err != nil { + t.Fatalf("send via inputPeerChat: %v", err) + } + sentUpdates, ok := sent.(*tg.Updates) + if !ok || len(sentUpdates.Updates) < 2 { + t.Fatalf("send updates = %T %+v, want channel message updates", sent, sent) + } + newMsg, ok := sentUpdates.Updates[1].(*tg.UpdateNewChannelMessage) + if !ok { + t.Fatalf("send update = %#v, want updateNewChannelMessage", sentUpdates.Updates[1]) + } + msg, ok := newMsg.Message.(*tg.Message) + if !ok || msg.Message != "via legacy input peer" { + t.Fatalf("sent message = %#v, want text channel message", newMsg.Message) + } + if peer, ok := msg.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { + t.Fatalf("sent peer = %#v, want peerChannel %d", msg.PeerID, channel.ID) + } +} + +func TestMessagesCreateChatDispatchRemembersTDesktopClientInfo(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550001041", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550001042", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + sessions := &captureSessions{} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + rawAuthKeyID := [8]byte{0x42, 0x01} + sessionID := int64(77) + initReq := &tg.InvokeWithLayerRequest{ + Layer: 225, + Query: &tg.InitConnectionRequest{ + APIID: 111111, + DeviceModel: "Desktop", + AppVersion: "6.8.4 x64", + SystemLangCode: "en", + LangPack: "tdesktop", + LangCode: "en", + Query: &tg.HelpGetConfigRequest{}, + }, + } + var initBuf bin.Buffer + if err := initReq.Encode(&initBuf); err != nil { + t.Fatalf("encode init: %v", err) + } + if _, err := r.Dispatch(WithUserID(ctx, owner.ID), rawAuthKeyID, sessionID, &initBuf); err != nil { + t.Fatalf("dispatch init: %v", err) + } + + createReq := &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Dispatch TDesktop Group", + } + var createBuf bin.Buffer + if err := createReq.Encode(&createBuf); err != nil { + t.Fatalf("encode create chat: %v", err) + } + enc, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &createBuf) + if err != nil { + t.Fatalf("dispatch create chat: %v", err) + } + invited, ok := enc.(*tg.MessagesInvitedUsers) + if !ok { + t.Fatalf("create response = %T, want messages.invitedUsers", enc) + } + updates, ok := invited.Updates.(*tg.Updates) + if !ok || len(updates.Chats) != 2 { + t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates) + } + if legacy, ok := updates.Chats[0].(*tg.Chat); !ok || !legacy.Deactivated { + t.Fatalf("first chat = %#v, want migrated legacy chat", updates.Chats[0]) + } + channel, ok := updates.Chats[1].(*tg.Channel) + if !ok || !channel.Megagroup || !channel.Creator { + t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1]) + } + if len(updates.Updates) != 4 { + t.Fatalf("updates len = %d, want create/invite service messages plus channel refreshes", len(updates.Updates)) + } + participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get participants: %v", err) + } + list, ok := participants.(*tg.ChannelsChannelParticipants) + if !ok || list.Count != 2 || len(list.Participants) != 2 || len(list.Users) != 2 { + t.Fatalf("participants = %T %+v, want owner + friend", participants, participants) + } + sessions.mu.Lock() + pushUserIDs := append([]int64(nil), sessions.pushUserIDs...) + sessions.mu.Unlock() + if len(pushUserIDs) != 1 || pushUserIDs[0] != friend.ID { + t.Fatalf("push user ids = %v, want only invited friend %d", pushUserIDs, friend.ID) + } +} + +func TestMessagesCreateChatSessionWithoutClientInfoReturnsLegacyChat(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550001051", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550001052", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + + invited, err := r.onMessagesCreateChat(WithSessionID(WithUserID(ctx, owner.ID), 99), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "Session Legacy Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + updates, ok := invited.Updates.(*tg.Updates) + if !ok || len(updates.Chats) != 2 { + t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates) + } + if legacy, ok := updates.Chats[0].(*tg.Chat); !ok || !legacy.Deactivated { + t.Fatalf("first chat = %#v, want migrated legacy chat", updates.Chats[0]) + } + if channel, ok := updates.Chats[1].(*tg.Channel); !ok || !channel.Megagroup || !channel.Creator { + t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1]) + } +} diff --git a/internal/rpc/messages_delete.go b/internal/rpc/messages_delete.go new file mode 100644 index 00000000..d0fa0c37 --- /dev/null +++ b/internal/rpc/messages_delete.go @@ -0,0 +1,120 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func (r *Router) onMessagesDeleteMessages(ctx context.Context, req *tg.MessagesDeleteMessagesRequest) (*tg.MessagesAffectedMessages, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if len(req.ID) == 0 || r.deps.Messages == nil { + return r.affectedMessages(ctx, authKeyID, userID) + } + if len(req.ID) > domain.MaxDeleteMessageIDs { + return nil, limitInvalidErr() + } + // 官方对私聊 revoke 不做 block 限制:被对方拉黑后撤回自己的消息仍 + // 双向生效;客户端删除请求的 fail handler 是静默的,服务端报错只会 + // 造成"本地已删、服务端未删"的复活错乱。 + sessionID, _ := SessionIDFrom(ctx) + res, err := r.deps.Messages.DeleteMessages(ctx, userID, domain.DeleteMessagesRequest{ + OwnerUserID: userID, + IDs: req.ID, + Revoke: req.GetRevoke(), + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, internalErr() + } + self := res.Self() + if len(self.MessageIDs) == 0 || self.Event.Pts == 0 { + return r.affectedMessages(ctx, authKeyID, userID) + } + return &tg.MessagesAffectedMessages{Pts: self.Event.Pts, PtsCount: self.Event.PtsCount}, nil +} + +func (r *Router) onMessagesDeleteHistory(ctx context.Context, req *tg.MessagesDeleteHistoryRequest) (*tg.MessagesAffectedHistory, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + if req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + res, err := r.deps.Channels.DeleteHistory(ctx, userID, domain.DeleteChannelHistoryRequest{ + UserID: userID, + ChannelID: peer.ID, + MaxID: req.MaxID, + ForEveryone: req.GetRevoke(), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelDeleteErr(err) + } + if res.Event.Pts != 0 { + r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { + return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event) + }) + return &tg.MessagesAffectedHistory{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil + } + if res.AvailableMinID > 0 { + event := r.recordChannelAvailableMessages(ctx, userID, res.Channel.ID, res.AvailableMinID) + updates := r.channelAvailableMessagesUpdates(userID, res.Channel, event.MaxID) + updates.Updates = appendAuxPtsBookkeeping(updates.Updates, event) + r.pushUserUpdates(ctx, userID, updates) + if event.Pts != 0 { + return &tg.MessagesAffectedHistory{Pts: event.Pts, PtsCount: event.PtsCount, Offset: res.Offset}, nil + } + } + return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: 0, Offset: res.Offset}, nil + } + if peer.Type != domain.PeerTypeUser { + return nil, peerIDInvalidErr() + } + if r.deps.Messages == nil { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + sessionID, _ := SessionIDFrom(ctx) + minDate, _ := req.GetMinDate() + maxDate, _ := req.GetMaxDate() + res, err := r.deps.Messages.DeleteHistory(ctx, userID, domain.DeleteHistoryRequest{ + OwnerUserID: userID, + Peer: peer, + MaxID: req.MaxID, + MinDate: minDate, + MaxDate: maxDate, + JustClear: req.GetJustClear(), + Revoke: req.GetRevoke(), + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, internalErr() + } + self := res.Self() + if len(self.MessageIDs) == 0 || self.Event.Pts == 0 { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + return &tg.MessagesAffectedHistory{ + Pts: self.Event.Pts, + PtsCount: self.Event.PtsCount, + Offset: res.Offset, + }, nil +} diff --git a/internal/rpc/messages_delete_rpc_test.go b/internal/rpc/messages_delete_rpc_test.go new file mode 100644 index 00000000..1361ff09 --- /dev/null +++ b/internal/rpc/messages_delete_rpc_test.go @@ -0,0 +1,181 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + appchannels "telesrv/internal/app/channels" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestMessagesDeleteHistoryChannelReturnsOffsetForBoundedPage(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 45, Phone: "15550002145", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 46, Phone: "15550002146", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Delete History Offset", + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1_700_002_145, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + total := domain.MaxDeleteHistoryBatch + 2 + for i := 0; i < total; i++ { + if _, err := channelStore.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: created.Channel.ID, + RandomID: int64(210_000 + i), + Message: "bulk delete", + Date: 1_700_002_146 + i, + }); err != nil { + t.Fatalf("send channel message %d: %v", i, err) + } + } + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Channels: appchannels.NewService(channelStore), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + req := &tg.MessagesDeleteHistoryRequest{ + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + MaxID: 0, + } + req.SetRevoke(true) + affected, err := r.onMessagesDeleteHistory(WithUserID(ctx, owner.ID), req) + if err != nil { + t.Fatalf("messages.deleteHistory channel: %v", err) + } + if affected.Offset != 1 || affected.PtsCount != domain.MaxDeleteHistoryBatch { + t.Fatalf("affected history = %+v, want offset=1 pts_count=%d", affected, domain.MaxDeleteHistoryBatch) + } + pushed := sessions.snapshot() + updates, ok := pushed.message.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("pushed update = %T %+v, want one bounded delete update", pushed.message, pushed.message) + } + deleted, ok := updates.Updates[0].(*tg.UpdateDeleteChannelMessages) + if !ok { + t.Fatalf("pushed update[0] = %T, want updateDeleteChannelMessages", updates.Updates[0]) + } + if len(deleted.Messages) != domain.MaxDeleteHistoryBatch || deleted.PtsCount != domain.MaxDeleteHistoryBatch { + t.Fatalf("delete update len=%d pts_count=%d, want bounded %d", len(deleted.Messages), deleted.PtsCount, domain.MaxDeleteHistoryBatch) + } +} + +func TestUpdatesDifferenceIncludesDeleteMessages(t *testing.T) { + got, ok := tgUpdatesDifference(0, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 8, Date: 1700000250, Seq: 0}, + Events: []domain.UpdateEvent{{ + Type: domain.UpdateEventDeleteMessages, + Pts: 8, + PtsCount: 3, + Date: 1700000250, + MessageIDs: []int{3, 4, 5}, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if got.State.Pts != 8 || len(got.OtherUpdates) != 1 { + t.Fatalf("difference = %+v, want one delete update and pts=8", got) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateDeleteMessages) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateDeleteMessages", got.OtherUpdates[0]) + } + if update.Pts != 8 || update.PtsCount != 3 || len(update.Messages) != 3 || update.Messages[0] != 3 || update.Messages[2] != 5 { + t.Fatalf("delete update = %+v, want ids [3 4 5] pts=8 pts_count=3", update) + } +} + +func TestMessagesDeleteMessagesPassesOwnerContext(t *testing.T) { + const userID = int64(1000000001) + var authKeyID [8]byte + authKeyID[0] = 10 + messages := &captureMessages{deleteMessagesRes: domain.DeleteMessagesResult{ + OwnerUserID: userID, + Deleted: []domain.DeletedMessagesForUser{{ + UserID: userID, + MessageIDs: []int{2, 3}, + Event: domain.UpdateEvent{Pts: 9, PtsCount: 2}, + }}, + }} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesDeleteMessagesRequest{Revoke: true, ID: []int{3, 2}} + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), userID), authKeyID, 77, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.MessagesAffectedMessages) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesAffectedMessages", enc) + } + if got.Pts != 9 || got.PtsCount != 2 { + t.Fatalf("affected = %+v, want pts=9 pts_count=2", got) + } + if messages.deleteMessagesReq.OwnerUserID != userID || !messages.deleteMessagesReq.Revoke || messages.deleteMessagesReq.OriginSessionID != 77 || messages.deleteMessagesReq.OriginAuthKeyID != authKeyID { + t.Fatalf("delete request = %+v, want owner/revoke/current session", messages.deleteMessagesReq) + } + if len(messages.deleteMessagesReq.IDs) != 2 || messages.deleteMessagesReq.IDs[0] != 3 || messages.deleteMessagesReq.IDs[1] != 2 { + t.Fatalf("delete ids = %+v, want request order [3 2]", messages.deleteMessagesReq.IDs) + } +} + +func TestMessagesDeleteHistoryPassesJustClearContext(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + var authKeyID [8]byte + authKeyID[0] = 11 + messages := &captureMessages{deleteHistoryRes: domain.DeleteMessagesResult{ + OwnerUserID: userID, + Deleted: []domain.DeletedMessagesForUser{{ + UserID: userID, + MessageIDs: []int{1, 2, 3}, + Event: domain.UpdateEvent{Pts: 12, PtsCount: 3}, + }}, + }} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesDeleteHistoryRequest{ + JustClear: true, + Revoke: true, + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + MaxID: 15, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), userID), authKeyID, 88, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.MessagesAffectedHistory) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesAffectedHistory", enc) + } + if got.Pts != 12 || got.PtsCount != 3 { + t.Fatalf("affected = %+v, want pts=12 pts_count=3", got) + } + reqGot := messages.deleteHistoryReq + if reqGot.OwnerUserID != userID || reqGot.Peer.ID != peerID || reqGot.MaxID != 15 || !reqGot.JustClear || !reqGot.Revoke || reqGot.OriginSessionID != 88 || reqGot.OriginAuthKeyID != authKeyID { + t.Fatalf("delete history request = %+v, want owner peer max_id flags current session", reqGot) + } +} diff --git a/internal/rpc/messages_dialogs.go b/internal/rpc/messages_dialogs.go new file mode 100644 index 00000000..5d4043e4 --- /dev/null +++ b/internal/rpc/messages_dialogs.go @@ -0,0 +1,1029 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "go.uber.org/zap" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDraftRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + peerTL := tgPeer(peer) + if peerTL == nil { + return true, nil + } + date := int(r.clock.Now().Unix()) + draft, err := r.dialogDraftFromSaveDraft(ctx, userID, peer, req, date) + if err != nil { + return false, err + } + var recorded domain.UpdateEvent + changed := true + if r.deps.Dialogs != nil { + changed, err = r.deps.Dialogs.SaveDraft(ctx, userID, draft) + if err != nil { + return false, dialogDraftErr(err) + } + if !changed { + return true, nil + } + // durable 事件:草稿是绝对状态,事件只记 peer(+top_msg_id) 标记, + // difference/outbox 重放时按 peer 重载当前值(见 enrichDraftMessageEvent)。 + recorded = r.recordDraftMessageEvent(ctx, userID, peer, draft.TopMessageID, &date) + } + update := &tg.UpdateDraftMessage{ + Peer: peerTL, + Draft: tgDraftMessageFromSaveDraft(req, date), + } + if draft.TopMessageID > 0 { + update.SetTopMsgID(draft.TopMessageID) + } + updates := &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, recorded), + Users: r.usersForDraftUpdate(ctx, userID, peer), + Chats: r.chatsForDraftUpdate(ctx, userID, peer), + Date: date, + Seq: 0, + } + r.bookkeepAuxPtsForCurrentSession(ctx, recorded) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, updates) + return true, nil +} + +// recordDraftMessageEvent 记录 draft_message durable 事件(占账号 pts,无 wire pts), +// 经 outbox dispatcher 推给其它在线 session;Updates 服务缺席时返回零值(退化为直推)。 +func (r *Router) recordDraftMessageEvent(ctx context.Context, userID int64, peer domain.Peer, topMsgID int, date *int) domain.UpdateEvent { + if r.deps.Updates == nil { + return domain.UpdateEvent{} + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, state, err := r.deps.Updates.RecordDraftMessage(ctx, authKeyID, userID, peer, topMsgID, sessionID) + if err != nil { + r.log.Warn("record draft message event", zap.Int64("user_id", userID), zap.Error(err)) + return domain.UpdateEvent{} + } + if date != nil && state.Date != 0 { + *date = state.Date + } + return event +} + +func (r *Router) onMessagesGetAllDrafts(ctx context.Context) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + date := int(r.clock.Now().Unix()) + if r.deps.Dialogs == nil { + return &tg.Updates{Updates: []tg.UpdateClass{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: date, Seq: 0}, nil + } + drafts, err := r.deps.Dialogs.ListDrafts(ctx, userID, domain.MaxDialogDraftsPerUser) + if err != nil { + return nil, dialogDraftErr(err) + } + updates := make([]tg.UpdateClass, 0, len(drafts)) + users := r.usersForDrafts(ctx, userID, drafts) + chats := r.chatsForDrafts(ctx, userID, drafts) + for _, draft := range drafts { + peer := tgPeer(draft.Peer) + if peer == nil { + continue + } + update := &tg.UpdateDraftMessage{Peer: peer, Draft: tgDialogDraft(draft)} + if draft.TopMessageID > 0 { + update.SetTopMsgID(draft.TopMessageID) + } + updates = append(updates, update) + } + return &tg.Updates{Updates: updates, Users: users, Chats: chats, Date: date, Seq: 0}, nil +} + +func (r *Router) onMessagesClearAllDrafts(ctx context.Context) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Dialogs == nil { + return true, nil + } + drafts, err := r.deps.Dialogs.ClearDrafts(ctx, userID, domain.MaxDialogDraftsPerUser) + if err != nil { + return false, dialogDraftErr(err) + } + if len(drafts) == 0 { + return true, nil + } + date := int(r.clock.Now().Unix()) + updates := make([]tg.UpdateClass, 0, len(drafts)) + events := make([]domain.UpdateEvent, 0, len(drafts)) + for _, draft := range drafts { + update := draftClearUpdate(draft.Peer, draft.TopMessageID, date) + if update == nil { + continue + } + updates = append(updates, update) + if event := r.recordDraftMessageEvent(ctx, userID, draft.Peer, draft.TopMessageID, nil); event.Pts > 0 { + events = append(events, event) + updates = appendAuxPtsBookkeeping(updates, event) + } + } + r.bookkeepAuxPtsForCurrentSession(ctx, events...) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: updates, + Users: r.usersForDrafts(ctx, userID, drafts), + Chats: r.chatsForDrafts(ctx, userID, drafts), + Date: date, + Seq: 0, + }) + return true, nil +} + +func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesSaveDraftRequest, date int) (domain.DialogDraft, error) { + if req == nil { + return domain.DialogDraft{Peer: peer, Date: date}, nil + } + if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { + return domain.DialogDraft{}, messageTooLongErr() + } + if len(req.Entities) > maxMessageEntityCount { + return domain.DialogDraft{}, limitInvalidErr() + } + if !req.SuggestedPost.Zero() { + return domain.DialogDraft{}, suggestedPostPeerInvalidErr() + } + replyTo, err := r.messageReplyFromInput(ctx, userID, peer, req.ReplyTo) + if err != nil { + return domain.DialogDraft{}, err + } + webpage, err := dialogDraftWebPageFromInput(req.Media) + if err != nil { + return domain.DialogDraft{}, err + } + topMessageID := 0 + if replyTo != nil && peer.Type == domain.PeerTypeChannel && replyTo.TopMessageID > 0 { + topMessageID = replyTo.TopMessageID + } + return domain.DialogDraft{ + Peer: peer, + TopMessageID: topMessageID, + Date: date, + NoWebpage: req.NoWebpage, + InvertMedia: req.InvertMedia, + Message: req.Message, + Entities: domainMessageEntities(req.Entities), + ReplyTo: replyTo, + WebPage: webpage, + Effect: req.Effect, + }, nil +} + +func dialogDraftWebPageFromInput(media tg.InputMediaClass) (*domain.DialogDraftWebPage, error) { + switch m := media.(type) { + case nil, *tg.InputMediaEmpty: + return nil, nil + case *tg.InputMediaWebPage: + if m.URL == "" { + return nil, mediaInvalidErr() + } + return &domain.DialogDraftWebPage{ + URL: m.URL, + ForceLargeMedia: m.ForceLargeMedia, + ForceSmallMedia: m.ForceSmallMedia, + Optional: m.Optional, + }, nil + default: + return nil, mediaInvalidErr() + } +} + +func tgDraftMessageFromSaveDraft(req *tg.MessagesSaveDraftRequest, date int) tg.DraftMessageClass { + if req == nil || saveDraftIsEmpty(req) { + draft := &tg.DraftMessageEmpty{} + draft.SetDate(date) + return draft + } + return &tg.DraftMessage{ + NoWebpage: req.NoWebpage, + InvertMedia: req.InvertMedia, + ReplyTo: req.ReplyTo, + Message: req.Message, + Entities: req.Entities, + Media: draftInputMedia(req.Media), + Date: date, + Effect: req.Effect, + SuggestedPost: req.SuggestedPost, + } +} + +func saveDraftIsEmpty(req *tg.MessagesSaveDraftRequest) bool { + return !req.NoWebpage && + !req.InvertMedia && + draftReplyIsEmpty(req.ReplyTo) && + req.Message == "" && + len(req.Entities) == 0 && + draftInputMedia(req.Media) == nil && + req.Effect == 0 && + req.SuggestedPost.Zero() +} + +func (r *Router) usersForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.UserClass { + if r.deps.Users == nil { + return []tg.UserClass{} + } + users := make([]tg.UserClass, 0, 2) + if self, err := r.deps.Users.Self(ctx, userID); err == nil && self.ID != 0 { + users = append(users, r.tgSelfUser(self)) + } + if peer.Type == domain.PeerTypeUser && peer.ID != 0 && peer.ID != userID { + users = append(users, r.tgUsersForIDs(ctx, userID, []int64{peer.ID})...) + } + return users +} + +func (r *Router) usersForDrafts(ctx context.Context, userID int64, drafts []domain.DialogDraft) []tg.UserClass { + users := make([]tg.UserClass, 0, len(drafts)+1) + seen := map[int64]struct{}{} + if r.deps.Users != nil { + if self, err := r.deps.Users.Self(ctx, userID); err == nil && self.ID != 0 { + users = append(users, r.tgSelfUser(self)) + seen[self.ID] = struct{}{} + } + } + peerUserIDs := make([]int64, 0, len(drafts)) + for _, draft := range drafts { + if draft.Peer.Type != domain.PeerTypeUser || draft.Peer.ID == 0 { + continue + } + if _, ok := seen[draft.Peer.ID]; ok { + continue + } + seen[draft.Peer.ID] = struct{}{} + peerUserIDs = append(peerUserIDs, draft.Peer.ID) + } + users = append(users, r.tgUsersForIDs(ctx, userID, peerUserIDs)...) + return users +} + +func (r *Router) chatsForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.ChatClass { + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 || r.deps.Channels == nil { + return []tg.ChatClass{} + } + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil || view.Channel.ID == 0 { + return []tg.ChatClass{} + } + return []tg.ChatClass{tgChannelChatForView(userID, view)} +} + +func (r *Router) chatsForDrafts(ctx context.Context, userID int64, drafts []domain.DialogDraft) []tg.ChatClass { + if r.deps.Channels == nil || len(drafts) == 0 { + return []tg.ChatClass{} + } + ids := make([]int64, 0, len(drafts)) + seen := map[int64]struct{}{} + for _, draft := range drafts { + if draft.Peer.Type != domain.PeerTypeChannel || draft.Peer.ID == 0 { + continue + } + if _, ok := seen[draft.Peer.ID]; ok { + continue + } + seen[draft.Peer.ID] = struct{}{} + ids = append(ids, draft.Peer.ID) + } + if len(ids) == 0 { + return []tg.ChatClass{} + } + views, err := r.deps.Channels.GetChannels(ctx, userID, ids) + if err != nil { + return []tg.ChatClass{} + } + byID := make(map[int64]domain.ChannelView, len(views)) + for _, view := range views { + if view.Channel.ID != 0 { + byID[view.Channel.ID] = view + } + } + chats := make([]tg.ChatClass, 0, len(ids)) + for _, id := range ids { + if view, ok := byID[id]; ok { + chats = append(chats, tgChannelChatForView(userID, view)) + } + } + return chats +} + +func dialogDraftErr(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, domain.ErrReplyMessageIDInvalid): + return replyMessageIDInvalidErr() + case errors.Is(err, domain.ErrChannelInvalid): + return peerIDInvalidErr() + default: + return internalErr() + } +} + +func (r *Router) clearDraftAfterSend(ctx context.Context, userID int64, peer domain.Peer, replyTo *domain.MessageReply) { + if r.deps.Dialogs == nil || userID == 0 || peer.ID == 0 { + return + } + topMessageID := 0 + if peer.Type == domain.PeerTypeChannel && replyTo != nil && replyTo.TopMessageID > 0 { + topMessageID = replyTo.TopMessageID + } + changed, err := r.deps.Dialogs.DeleteDraft(ctx, userID, peer, topMessageID) + if err != nil { + r.log.Debug("clear draft after send", zap.Int64("user_id", userID), zap.Error(err)) + return + } + if !changed { + return + } + date := int(r.clock.Now().Unix()) + update := draftClearUpdate(peer, topMessageID, date) + if update == nil { + return + } + recorded := r.recordDraftMessageEvent(ctx, userID, peer, topMessageID, &date) + r.bookkeepAuxPtsForCurrentSession(ctx, recorded) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, recorded), + Users: r.usersForDraftUpdate(ctx, userID, peer), + Chats: r.chatsForDraftUpdate(ctx, userID, peer), + Date: date, + Seq: 0, + }) +} + +func (r *Router) onMessagesGetDialogFilters(ctx context.Context) (*tg.MessagesDialogFilters, error) { + if r.deps.Dialogs == nil { + return tgDialogFilters(domain.DialogFolderList{}), nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + list, err := r.deps.Dialogs.GetDialogFolders(ctx, userID) + if err != nil { + return nil, internalErr() + } + return tgDialogFilters(list), nil +} + +func (r *Router) onMessagesUpdateDialogFilter(ctx context.Context, req *tg.MessagesUpdateDialogFilterRequest) (bool, error) { + if req.ID < domain.DialogCustomFolderMinID { + return false, filterIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + filter, ok := req.GetFilter() + var folder *domain.DialogFolder + if ok { + parsed, err := r.dialogFolderFromTG(ctx, userID, req.ID, filter) + if err != nil { + return false, err + } + folder = &parsed + if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.SaveDialogFolder(ctx, userID, parsed); err != nil { + return false, internalErr() + } + } + } else if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.DeleteDialogFolder(ctx, userID, req.ID); err != nil { + return false, internalErr() + } + } + // 顺序约定:先 Save(durable 真值)后 Record(增量通知)。Record 失败时 + // RPC 返回错误,但真值已落库——其它设备丢的只是即时 update,重启/重拉 + // getDialogFilters 即收敛,不会产生持久漂移;反向顺序(先 Record)失败 + // 会让其它设备应用 update 内嵌的新 filter 而 store 仍是旧值,重拉后回退 + // 抖动,故不可取。 + event := domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilter, + FilterID: req.ID, + DialogFilter: folder, + Date: int(r.clock.Now().Unix()), + } + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err = r.deps.Updates.RecordDialogFilter(ctx, authKeyID, userID, req.ID, folder, sessionID) + if err != nil { + return false, internalErr() + } + } + r.bookkeepAuxPtsForCurrentSession(ctx, event) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event)) + return true, nil +} + +func (r *Router) onMessagesUpdateDialogFiltersOrder(ctx context.Context, order []int) (bool, error) { + if len(order) > domain.MaxDialogFolders { + return false, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + clean := cleanDialogFilterOrder(order) + if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.ReorderDialogFolders(ctx, userID, clean); err != nil { + return false, internalErr() + } + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilterOrder, + FilterOrder: clean, + Date: int(r.clock.Now().Unix()), + } + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err = r.deps.Updates.RecordDialogFilterOrder(ctx, authKeyID, userID, clean, sessionID) + if err != nil { + return false, internalErr() + } + } + r.bookkeepAuxPtsForCurrentSession(ctx, event) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event)) + return true, nil +} + +func (r *Router) onMessagesToggleDialogFilterTags(ctx context.Context, enabled bool) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Dialogs != nil { + if err := r.deps.Dialogs.ToggleDialogFolderTags(ctx, userID, enabled); err != nil { + return false, internalErr() + } + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventDialogFilters, + TagsEnabled: enabled, + Date: int(r.clock.Now().Unix()), + } + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err = r.deps.Updates.RecordDialogFiltersReload(ctx, authKeyID, userID, sessionID) + if err != nil { + return false, internalErr() + } + } + r.bookkeepAuxPtsForCurrentSession(ctx, event) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event)) + return true, nil +} + +func (r *Router) onMessagesGetPeerSettings(ctx context.Context, input tg.InputPeerClass) (*tg.MessagesPeerSettings, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return nil, err + } + loadEpoch := r.peerSettingsProjectionCache.LoadEpoch() + settings, ok := r.peerSettingsProjectionCache.Lookup(userID, peer) + if !ok { + settings, err = r.buildPeerSettingsProjection(ctx, userID, peer) + if err != nil { + return nil, err + } + r.peerSettingsProjectionCache.StoreIfEpoch(userID, peer, settings, loadEpoch) + } + return &tg.MessagesPeerSettings{ + Settings: tgPeerSettings(settings), + Users: r.peerSettingsUsers(ctx, userID, input), + }, nil +} + +func (r *Router) buildPeerSettingsProjection(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) { + settings := domain.PeerSettings{} + if r.deps.Contacts != nil { + var err error + settings, err = r.deps.Contacts.GetPeerSettings(ctx, userID, peer) + if err != nil { + return domain.PeerSettings{}, internalErr() + } + } + if r.deps.Dialogs != nil { + hidden, err := r.deps.Dialogs.PeerSettingsBarHidden(ctx, userID, peer) + if err != nil { + return domain.PeerSettings{}, internalErr() + } + settings.HiddenPeerSettingsBar = hidden + } + var err error + settings, err = r.connectedBusinessBotPeerSettings(ctx, userID, peer, settings) + if err != nil { + return domain.PeerSettings{}, err + } + return settings, nil +} + +func (r *Router) onMessagesToggleDialogPin(ctx context.Context, req *tg.MessagesToggleDialogPinRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + // archive folder 行自身的置顶(TDesktop 归档行右键 Pin/Unpin)。 + if folderPeer, ok := req.Peer.(*tg.InputDialogPeerFolder); ok { + return r.toggleArchiveFolderPin(ctx, userID, folderPeer.FolderID, req.GetPinned()) + } + peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) + if err != nil { + return false, err + } + if len(peers) != 1 { + return false, peerIDInvalidErr() + } + pinned := req.GetPinned() + if r.deps.Dialogs == nil { + return true, nil + } + changed, folderID, err := r.deps.Dialogs.TogglePinned(ctx, userID, peers[0], pinned) + if err != nil { + if errors.Is(err, domain.ErrPinnedDialogsTooMuch) { + return false, pinnedTooMuchErr() + } + return false, internalErr() + } + if changed { + date := int(r.clock.Now().Unix()) + var recorded domain.UpdateEvent + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, peers[0], pinned, folderID, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + recorded = event + } + update := &tg.UpdateDialogPinned{ + Pinned: pinned, + Peer: tgDialogPeer(peers[0]), + } + if folderID != domain.DialogMainFolderID { + update.SetFolderID(folderID) + } + r.bookkeepAuxPtsForCurrentSession(ctx, recorded) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, recorded), + Date: date, + Seq: 0, + }) + } + return true, nil +} + +// toggleArchiveFolderPin 处理 toggleDialogPin(inputDialogPeerFolder):置顶/ +// 取消置顶 archive folder 行本身。事件 peer 用 PeerTypeFolder 表达,生成的 +// updateDialogPinned 携带 dialogPeerFolder 且不带 folder_id flag。 +func (r *Router) toggleArchiveFolderPin(ctx context.Context, userID int64, folderID int, pinned bool) (bool, error) { + if folderID != domain.DialogArchiveFolderID { + return false, folderIDInvalidErr() + } + if r.deps.Dialogs == nil { + return true, nil + } + changed, err := r.deps.Dialogs.ToggleArchivePinned(ctx, userID, pinned) + if err != nil { + return false, internalErr() + } + if !changed { + return true, nil + } + folderPeer := domain.Peer{Type: domain.PeerTypeFolder, ID: int64(folderID)} + date := int(r.clock.Now().Unix()) + var recorded domain.UpdateEvent + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, state, err := r.deps.Updates.RecordDialogPinned(ctx, authKeyID, userID, folderPeer, pinned, 0, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + recorded = event + } + r.bookkeepAuxPtsForCurrentSession(ctx, recorded) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{&tg.UpdateDialogPinned{ + Pinned: pinned, + Peer: &tg.DialogPeerFolder{FolderID: folderID}, + }}, recorded), + Date: date, + Seq: 0, + }) + return true, nil +} + +func (r *Router) onMessagesReorderPinnedDialogs(ctx context.Context, req *tg.MessagesReorderPinnedDialogsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + // 官方允许在主列表(0)与归档(1)内分别重排置顶;自定义 filter 没有 + // 服务器侧置顶顺序。 + if req.FolderID != 0 && req.FolderID != domain.DialogArchiveFolderID { + return false, folderIDInvalidErr() + } + if len(req.Order) > maxDialogInputPeers { + return false, limitInvalidErr() + } + peers, err := r.dialogPeersFromInput(ctx, userID, req.Order) + if err != nil { + return false, err + } + if r.deps.Dialogs == nil { + return true, nil + } + changed, err := r.deps.Dialogs.ReorderPinned(ctx, userID, req.FolderID, peers, req.GetForce()) + if err != nil { + return false, internalErr() + } + if !changed { + return true, nil + } + date := int(r.clock.Now().Unix()) + var recorded domain.UpdateEvent + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, state, err := r.deps.Updates.RecordPinnedDialogs(ctx, authKeyID, userID, req.FolderID, peers, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + recorded = event + } + update := &tg.UpdatePinnedDialogs{Order: tgDialogPeers(peers)} + if req.FolderID != domain.DialogMainFolderID { + update.SetFolderID(req.FolderID) + } + r.bookkeepAuxPtsForCurrentSession(ctx, recorded) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, recorded), + Date: date, + Seq: 0, + }) + return true, nil +} + +func (r *Router) onMessagesMarkDialogUnread(ctx context.Context, req *tg.MessagesMarkDialogUnreadRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if parentPeer, ok := req.GetParentPeer(); ok { + if err := r.validateDialogUnreadParentPeer(ctx, userID, parentPeer); err != nil { + return false, err + } + peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) + if err != nil { + return false, err + } + if len(peers) != 1 { + return false, peerIDInvalidErr() + } + return true, nil + } + peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) + if err != nil { + return false, err + } + if len(peers) != 1 { + return false, peerIDInvalidErr() + } + unread := req.GetUnread() + if r.deps.Dialogs == nil { + return true, nil + } + changed, err := r.deps.Dialogs.MarkUnread(ctx, userID, peers[0], unread) + if err != nil { + return false, internalErr() + } + if changed { + date := int(r.clock.Now().Unix()) + var recorded domain.UpdateEvent + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, state, err := r.deps.Updates.RecordDialogUnreadMark(ctx, authKeyID, userID, peers[0], unread, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + recorded = event + } + r.bookkeepAuxPtsForCurrentSession(ctx, recorded) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{&tg.UpdateDialogUnreadMark{ + Unread: unread, + Peer: tgDialogPeer(peers[0]), + }}, recorded), + Date: date, + Seq: 0, + }) + } + return true, nil +} + +func (r *Router) onMessagesGetDialogUnreadMarks(ctx context.Context, req *tg.MessagesGetDialogUnreadMarksRequest) ([]tg.DialogPeerClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if parentPeer, ok := req.GetParentPeer(); ok { + if err := r.validateDialogUnreadParentPeer(ctx, userID, parentPeer); err != nil { + return nil, err + } + return []tg.DialogPeerClass{}, nil + } + if r.deps.Dialogs == nil { + return nil, nil + } + peers, err := r.deps.Dialogs.UnreadMarks(ctx, userID) + if err != nil { + return nil, internalErr() + } + return tgDialogPeers(peers), nil +} + +func (r *Router) validateDialogUnreadParentPeer(ctx context.Context, userID int64, parentPeer tg.InputPeerClass) error { + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, parentPeer) + if err != nil { + return parentPeerInvalidErr() + } + if peer.Type != domain.PeerTypeChannel { + return parentPeerInvalidErr() + } + return nil +} + +func (r *Router) onMessagesHidePeerSettingsBar(ctx context.Context, input tg.InputPeerClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return false, err + } + changed := true + if r.deps.Dialogs != nil { + var err error + changed, err = r.deps.Dialogs.HidePeerSettingsBar(ctx, userID, peer) + if err != nil { + return false, internalErr() + } + } + if !changed { + return true, nil + } + r.invalidateRPCProjectionForPeer(userID, peer) + date := int(r.clock.Now().Unix()) + var recorded domain.UpdateEvent + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, state, err := r.deps.Updates.RecordPeerSettings(ctx, authKeyID, userID, peer, domain.PeerSettings{HiddenPeerSettingsBar: true}, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + recorded = event + } + r.bookkeepAuxPtsForCurrentSession(ctx, recorded) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{&tg.UpdatePeerSettings{ + Peer: tgPeer(peer), + Settings: tg.PeerSettings{}, + }}, recorded), + Date: date, + Seq: 0, + }) + return true, nil +} + +func (r *Router) dialogFilterFromRequest(ctx context.Context, userID int64, req *tg.MessagesGetDialogsRequest) (domain.DialogFilter, error) { + limit := req.Limit + if limit > 500 { + limit = 500 + } + filter := domain.DialogFilter{ + ExcludePinned: req.ExcludePinned, + OffsetDate: req.OffsetDate, + OffsetID: req.OffsetID, + Limit: limit, + Hash: req.Hash, + } + if folderID, ok := req.GetFolderID(); ok { + filter.HasFolderID = true + filter.FolderID = folderID + } + if peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.OffsetPeer); err == nil { + filter.HasOffsetPeer = true + filter.OffsetPeer = peer + } else if _, ok := req.OffsetPeer.(*tg.InputPeerEmpty); !ok && req.OffsetPeer != nil { + return domain.DialogFilter{}, err + } + return filter, nil +} + +func (r *Router) dialogPeersFromInput(ctx context.Context, userID int64, items []tg.InputDialogPeerClass) ([]domain.Peer, error) { + if len(items) > maxDialogInputPeers { + return nil, limitInvalidErr() + } + peers := make([]domain.Peer, 0, len(items)) + hasFolder := false + for _, item := range items { + switch p := item.(type) { + case *tg.InputDialogPeer: + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, p.Peer) + if err != nil { + return nil, err + } + peers = append(peers, peer) + case *tg.InputDialogPeerFolder: + if hasFolder { + return nil, folderIDInvalidErr() + } + hasFolder = true + // 第一阶段不维护 archived/folder 会话。若请求同时包含普通 peer, + // 按 Telegram 企业版路径优先返回普通 peer;纯 folder 请求返回空摘要。 + default: + return nil, inputConstructorInvalidErr() + } + } + return peers, nil +} + +func (r *Router) dialogFolderFromTG(ctx context.Context, userID int64, id int, filter tg.DialogFilterClass) (domain.DialogFolder, error) { + if id < domain.DialogCustomFolderMinID { + return domain.DialogFolder{}, filterIDInvalidErr() + } + switch f := filter.(type) { + case *tg.DialogFilter: + title := f.Title.Text + if title == "" { + return domain.DialogFolder{}, filterTitleEmptyErr() + } + if utf8.RuneCountInString(title) > domain.MaxDialogFolderTitleRunes { + return domain.DialogFolder{}, limitInvalidErr() + } + pinned, err := r.dialogFolderPeersFromInput(ctx, userID, f.PinnedPeers) + if err != nil { + return domain.DialogFolder{}, err + } + include, err := r.dialogFolderPeersFromInput(ctx, userID, f.IncludePeers) + if err != nil { + return domain.DialogFolder{}, err + } + exclude, err := r.dialogFolderPeersFromInput(ctx, userID, f.ExcludePeers) + if err != nil { + return domain.DialogFolder{}, err + } + emoticon, hasEmoticon := f.GetEmoticon() + color, hasColor := f.GetColor() + // 官方要求 filter 至少一个 include 条件(类别开关或 include/pinned + // peer),否则永不匹配任何会话;TDesktop UI 不会发空 filter,这里 + // 兜底拒绝(FILTER_INCLUDE_EMPTY)。 + if !f.Contacts && !f.NonContacts && !f.Groups && !f.Broadcasts && !f.Bots && + len(include) == 0 && len(pinned) == 0 { + return domain.DialogFolder{}, filterIncludeEmptyErr() + } + return domain.DialogFolder{ + ID: id, + Contacts: f.Contacts, + NonContacts: f.NonContacts, + Groups: f.Groups, + Broadcasts: f.Broadcasts, + Bots: f.Bots, + ExcludeMuted: f.ExcludeMuted, + ExcludeRead: f.ExcludeRead, + ExcludeArchived: f.ExcludeArchived, + TitleNoanimate: f.TitleNoanimate, + Title: title, + TitleEntities: domainMessageEntities(f.Title.Entities), + Emoticon: emoticon, + HasEmoticon: hasEmoticon, + Color: color, + HasColor: hasColor, + PinnedPeers: pinned, + IncludePeers: include, + ExcludePeers: exclude, + }, nil + case *tg.DialogFilterChatlist: + title := f.Title.Text + if title == "" { + return domain.DialogFolder{}, filterTitleEmptyErr() + } + if utf8.RuneCountInString(title) > domain.MaxDialogFolderTitleRunes { + return domain.DialogFolder{}, limitInvalidErr() + } + pinned, err := r.dialogFolderPeersFromInput(ctx, userID, f.PinnedPeers) + if err != nil { + return domain.DialogFolder{}, err + } + include, err := r.dialogFolderPeersFromInput(ctx, userID, f.IncludePeers) + if err != nil { + return domain.DialogFolder{}, err + } + emoticon, hasEmoticon := f.GetEmoticon() + color, hasColor := f.GetColor() + return domain.DialogFolder{ + ID: id, + TitleNoanimate: f.TitleNoanimate, + Title: title, + TitleEntities: domainMessageEntities(f.Title.Entities), + Emoticon: emoticon, + HasEmoticon: hasEmoticon, + Color: color, + HasColor: hasColor, + PinnedPeers: pinned, + IncludePeers: include, + IsChatlist: true, + }, nil + default: + return domain.DialogFolder{}, inputConstructorInvalidErr() + } +} + +func (r *Router) dialogFolderPeersFromInput(ctx context.Context, userID int64, peers []tg.InputPeerClass) ([]domain.DialogFolderPeer, error) { + if len(peers) > domain.MaxDialogFolderPeers { + return nil, limitInvalidErr() + } + out := make([]domain.DialogFolderPeer, 0, len(peers)) + seen := make(map[domain.Peer]struct{}, len(peers)) + for _, input := range peers { + peer, accessHash, err := r.domainFolderPeerFromInputPeer(ctx, userID, input) + if err != nil { + return nil, err + } + if _, ok := seen[peer]; ok { + continue + } + seen[peer] = struct{}{} + out = append(out, domain.DialogFolderPeer{Peer: peer, AccessHash: accessHash}) + } + return out, nil +} + +func cleanDialogFilterOrder(order []int) []int { + out := make([]int, 0, len(order)) + seen := make(map[int]struct{}, len(order)) + for _, id := range order { + if id < domain.DialogCustomFolderMinID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func (r *Router) peerSettingsUsers(ctx context.Context, userID int64, peer tg.InputPeerClass) []tg.UserClass { + if r.deps.Users == nil { + return nil + } + switch p := peer.(type) { + case *tg.InputPeerSelf: + u, err := r.deps.Users.Self(ctx, userID) + if err == nil && u.ID != 0 { + return []tg.UserClass{r.tgSelfUser(u)} + } + case *tg.InputPeerUser: + u, found, err := r.deps.Users.ByID(ctx, userID, p.UserID) + if err == nil && found { + return []tg.UserClass{r.tgUser(u)} + } + } + return nil +} diff --git a/internal/rpc/messages_drafts_difference_test.go b/internal/rpc/messages_drafts_difference_test.go new file mode 100644 index 00000000..9d053d18 --- /dev/null +++ b/internal/rpc/messages_drafts_difference_test.go @@ -0,0 +1,115 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appdialogs "telesrv/internal/app/dialogs" + appupdates "telesrv/internal/app/updates" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// 回归:云草稿变更必须可被离线设备经 updates.getDifference 恢复(draft_message +// durable 事件,重放时按 peer 重载当前权威态)。 + +func newDraftDifferenceTestRouter(t *testing.T) (*Router, domain.User, domain.User) { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550008801", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550008802", FirstName: "Friend"}) + dialogStore := memory.NewDialogStore() + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Dialogs: appdialogs.NewService(dialogStore, memory.NewChannelStore()), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + Sessions: &captureSessions{}, + }, zaptest.NewLogger(t), clock.System) + return r, owner, friend +} + +func draftUpdatesFromDifference(t *testing.T, diff tg.UpdatesDifferenceClass) []*tg.UpdateDraftMessage { + t.Helper() + full, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("expected *tg.UpdatesDifference, got %T", diff) + } + out := make([]*tg.UpdateDraftMessage, 0, 1) + for _, u := range full.OtherUpdates { + if dm, ok := u.(*tg.UpdateDraftMessage); ok { + out = append(out, dm) + } + } + return out +} + +func TestDraftMessageDifferenceReplay(t *testing.T) { + ctx := context.Background() + r, owner, friend := newDraftDifferenceTestRouter(t) + ownerCtx := WithUserID(ctx, owner.ID) + peer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash} + + // 设备 A 存草稿。 + saveReq := &tg.MessagesSaveDraftRequest{Peer: peer, Message: "typing offline sync"} + if _, err := r.onMessagesSaveDraft(ownerCtx, saveReq); err != nil { + t.Fatalf("saveDraft: %v", err) + } + + // 离线设备从 pts=0 拉差分:必须拿到 updateDraftMessage(内容为当前权威草稿)。 + diff, err := r.onUpdatesGetDifference(ownerCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("getDifference: %v", err) + } + drafts := draftUpdatesFromDifference(t, diff) + if len(drafts) != 1 { + t.Fatalf("difference draft updates = %d, want 1", len(drafts)) + } + dm, ok := drafts[0].Draft.(*tg.DraftMessage) + if !ok { + t.Fatalf("difference draft = %T, want *tg.DraftMessage", drafts[0].Draft) + } + if dm.Message != "typing offline sync" { + t.Fatalf("difference draft message = %q, want saved text", dm.Message) + } + if peerUser, ok := drafts[0].Peer.(*tg.PeerUser); !ok || peerUser.UserID != friend.ID { + t.Fatalf("difference draft peer = %#v, want friend", drafts[0].Peer) + } + full := diff.(*tg.UpdatesDifference) + if full.State.Pts < 1 { + t.Fatalf("difference state pts = %d, want advanced by draft event", full.State.Pts) + } + ptsAfterSave := full.State.Pts + + // 清空草稿(空 saveDraft)→ 差分重放变为 draftMessageEmpty。 + if _, err := r.onMessagesSaveDraft(ownerCtx, &tg.MessagesSaveDraftRequest{Peer: peer}); err != nil { + t.Fatalf("clear draft: %v", err) + } + diff, err = r.onUpdatesGetDifference(ownerCtx, &tg.UpdatesGetDifferenceRequest{Pts: ptsAfterSave}) + if err != nil { + t.Fatalf("getDifference after clear: %v", err) + } + drafts = draftUpdatesFromDifference(t, diff) + if len(drafts) != 1 { + t.Fatalf("difference draft updates after clear = %d, want 1", len(drafts)) + } + if _, ok := drafts[0].Draft.(*tg.DraftMessageEmpty); !ok { + t.Fatalf("difference draft after clear = %T, want DraftMessageEmpty", drafts[0].Draft) + } + + // 历史事件重放也按"当前权威态"渲染:从 0 重拉,两条事件都应是 empty(草稿已删)。 + diff, err = r.onUpdatesGetDifference(ownerCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("getDifference replay: %v", err) + } + for _, dm := range draftUpdatesFromDifference(t, diff) { + if _, ok := dm.Draft.(*tg.DraftMessageEmpty); !ok { + t.Fatalf("replayed draft = %T, want DraftMessageEmpty (authority deleted)", dm.Draft) + } + } +} diff --git a/internal/rpc/messages_edit.go b/internal/rpc/messages_edit.go new file mode 100644 index 00000000..d7b2522b --- /dev/null +++ b/internal/rpc/messages_edit.go @@ -0,0 +1,278 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEditMessageRequest) (tg.UpdatesClass, error) { + scheduleDate, hasScheduleDate := req.GetScheduleDate() + scheduleRepeatPeriod, hasScheduleRepeatPeriod := req.GetScheduleRepeatPeriod() + if hasScheduleRepeatPeriod && scheduleRepeatPeriod != 0 { + return nil, scheduleDateInvalidErr() + } + if hasScheduleRepeatPeriod && !hasScheduleDate { + return nil, scheduleDateInvalidErr() + } + if _, ok := req.GetQuickReplyShortcutID(); ok { + return nil, messageIDInvalidErr() + } + message, hasMessage := req.GetMessage() + if hasMessage && utf8.RuneCountInString(message) > maxSendMessageTextLength { + return nil, messageTooLongErr() + } + entities, _ := req.GetEntities() + if hasMessage { + if len(entities) > maxMessageEntityCount { + return nil, entitiesTooLongErr() + } + // 编辑后的文本同样补服务端自动实体(url/@mention/#hashtag/bot command),与发送一致; + // 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。 + entities = augmentAutoEntities(message, entities) + } else { + entities = nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if hasScheduleDate { + if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) { + return nil, mediaInvalidErr() + } + return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, scheduleDate) + } + if media, ok := req.GetMedia(); ok { + // 关闭 poll 走 editMessage + InputMediaPoll(closed)(TDesktop "Stop poll" 路径)。 + if pollMedia, isPoll := media.(*tg.InputMediaPoll); isPoll { + return r.onEditMessageClosePoll(ctx, req, pollMedia) + } + // live location 续报/停止走 editMessage + InputMediaGeoLive(DrKLO Android 路径)。 + if liveMedia, isLive := media.(*tg.InputMediaGeoLive); isLive { + return r.onEditMessageLiveLocation(ctx, req, liveMedia) + } + if !editMessageMediaCanDegradeToText(media) { + return nil, mediaInvalidErr() + } + } + if !hasMessage { + return nil, messageEmptyErr() + } + if message == "" { + // 编辑媒体消息时 message="" 是合法的清空 caption;当前文本-only + // 编辑模型由 store 层校验目标消息(无媒体的纯文本消息清空仍会 + // 落 MESSAGE_EMPTY),RPC 层不再一刀切拒绝。 + if _, hasMedia := req.GetMedia(); !hasMedia { + return nil, messageEmptyErr() + } + } + // reply_markup(bot inline keyboard):仅 bot 编辑自己消息时替换/清空(非 bot 丢弃, + // 不报错)。P3 仅私聊;markup-only 编辑(无文本)暂不支持——须随文本一起编辑。 + // 携带 inline markup(即便空 rows)即视为「设置」:空 inline 表示清空键盘, + // 否则替换;非 inline(reply keyboard 等)或非 bot 一律不设置(保留原 markup)。 + var replyMarkup *domain.MessageReplyMarkup + var setReplyMarkup bool + if req.ReplyMarkup != nil { + isBot := r.userIsBot(ctx, userID) + replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, isBot) + if err != nil { + return nil, replyMarkupErr(err) + } + if _, isInline := req.ReplyMarkup.(*tg.ReplyInlineMarkup); isInline && isBot { + setReplyMarkup = true + } + } + if peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return nil, peerIDInvalidErr() + } + mentionUserIDs, err := r.mentionedUserIDsFromMessage(ctx, userID, message, entities) + if err != nil { + return nil, internalErr() + } + res, err := r.deps.Channels.EditMessage(ctx, userID, domain.EditChannelMessageRequest{ + UserID: userID, + ChannelID: peer.ID, + ID: req.ID, + Message: message, + Entities: domainMessageEntitiesForViewer(userID, entities), + MentionUserIDs: mentionUserIDs, + EditDate: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelEditErr(err) + } + updates := r.channelEditMessageUpdates(ctx, userID, res) + // 编辑 fan-out 异步化 + 跨 viewer 投影预热(设计 Phase 0/Phase 1;P1-x bot 高频 editMessage + // 是稳态放大源)。同步 echo 仍走上面单 viewer 的 channelEditMessageUpdates。 + r.enqueueChannelEditMessageFanout(ctx, userID, res) + return updates, nil + } + if peer.Type != domain.PeerTypeUser || r.deps.Messages == nil { + return nil, peerIDInvalidErr() + } + blocked, err := r.peerBlocksUser(ctx, userID, peer.ID) + if err != nil { + return nil, err + } + if blocked { + return nil, messageEditForbiddenErr() + } + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + res, err := r.deps.Messages.EditMessage(ctx, userID, domain.EditMessageRequest{ + OwnerUserID: userID, + Peer: peer, + ID: req.ID, + Message: message, + Entities: domainMessageEntitiesForViewer(userID, entities), + EditDate: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + SetReplyMarkup: setReplyMarkup, + ReplyMarkup: replyMarkup, + }) + if err != nil { + return nil, messageEditErr(err) + } + self := res.Self() + if self.Event.Pts == 0 || self.Message.ID == 0 { + return nil, messageIDInvalidErr() + } + users := r.usersForMessageUpdate(ctx, userID, self.Message) + chats := r.chatsForMessageUpdate(ctx, userID, self.Message) + return tgEditMessageUpdates(self.Event, self.Message, users, chats), nil +} + +func editMessageMediaCanDegradeToText(media tg.InputMediaClass) bool { + switch media.(type) { + case *tg.InputMediaEmpty, *tg.InputMediaWebPage: + return true + default: + return false + } +} + +func (r *Router) onMessagesGetMessageEditData(ctx context.Context, req *tg.MessagesGetMessageEditDataRequest) (*tg.MessagesMessageEditData, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + switch peer.Type { + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return nil, peerIDInvalidErr() + } + history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{ + ChannelID: peer.ID, + Limit: 1, + MaxID: req.ID, + MinID: req.ID - 1, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + if len(history.Messages) != 1 || history.Messages[0].ID != req.ID { + return nil, messageIDInvalidErr() + } + msg := history.Messages[0] + if msg.Deleted || msg.Action != nil { + return nil, messageIDInvalidErr() + } + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + if msg.SenderUserID != userID && !canEditChannelMessageForRPC(view.Self) { + return nil, messageAuthorRequiredErr() + } + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return nil, messageIDInvalidErr() + } + msg, ok, err := r.lookupOwnerMessage(ctx, userID, req.ID) + if err != nil { + return nil, internalErr() + } + if !ok || msg.Peer != peer { + return nil, messageIDInvalidErr() + } + if !msg.Out || msg.From.ID != userID { + return nil, messageAuthorRequiredErr() + } + default: + return nil, peerIDInvalidErr() + } + return &tg.MessagesMessageEditData{}, nil +} + +func canEditChannelMessageForRPC(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) +} + +func messageEditErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrMessageEmpty): + return messageEmptyErr() + case errors.Is(err, domain.ErrMessageAuthorRequired): + return messageAuthorRequiredErr() + case errors.Is(err, domain.ErrMessageNotModified): + return messageNotModifiedErr() + default: + return internalErr() + } +} + +func channelEditErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrMessageEmpty): + return messageEmptyErr() + case errors.Is(err, domain.ErrMessageAuthorRequired): + return messageAuthorRequiredErr() + case errors.Is(err, domain.ErrMessageNotModified): + return messageNotModifiedErr() + default: + return channelInvalidErr(err) + } +} + +func tgEditMessageUpdates(event domain.UpdateEvent, msg domain.Message, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates { + update := tgOtherUpdateFromEvent(domain.UpdateEvent{ + Type: domain.UpdateEventEditMessage, + Pts: event.Pts, + PtsCount: event.PtsCount, + Message: msg, + }) + if update == nil { + return &tg.Updates{Date: event.Date} + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: users, + Chats: chats, + Date: event.Date, + Seq: 0, + } +} diff --git a/internal/rpc/messages_edit_geolive.go b/internal/rpc/messages_edit_geolive.go new file mode 100644 index 00000000..95751ece --- /dev/null +++ b/internal/rpc/messages_edit_geolive.go @@ -0,0 +1,159 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 本文件实现 live location 的续报与停止:editMessage + InputMediaGeoLive。 +// DrKLO Android 用它周期性上报新坐标;stopped=true 表示停止共享。 +// 停止语义:把 Period 改写为「已逝时长」,所有客户端按 message.date + period +// 判定立即过期,服务端不需要后台到点任务。 + +func (r *Router) onEditMessageLiveLocation(ctx context.Context, req *tg.MessagesEditMessageRequest, media *tg.InputMediaGeoLive) (tg.UpdatesClass, error) { + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + current, found := r.loadLiveLocationMessage(ctx, userID, peer, req.ID) + if !found { + return nil, messageIDInvalidErr() + } + now := int(r.clock.Now().Unix()) + next := *current.media.GeoLive + if media.Stopped { + // 立即过期:elapsed 至少 1 秒;forever 共享停止后同样按 elapsed 收口。 + elapsed := now - current.date + if elapsed < 1 { + elapsed = 1 + } + next.Period = elapsed + } else { + geo, err := domainGeoPointFromInput(media.GeoPoint) + if err != nil { + return nil, err + } + // 保留首发 access_hash:客户端可能继续用旧 hash 拉地图缩略。 + geo.AccessHash = next.Geo.AccessHash + next.Geo = *geo + if heading, ok := media.GetHeading(); ok { + if heading < 0 || heading > maxLiveLocationHeading { + return nil, mediaInvalidErr() + } + next.Heading = heading + } + if period, ok := media.GetPeriod(); ok { + if period != foreverLiveLocationPeriod && (period < minLiveLocationPeriod || period > maxLiveLocationPeriod) { + return nil, mediaInvalidErr() + } + next.Period = period + } + if radius, ok := media.GetProximityNotificationRadius(); ok { + if radius < 0 || radius > maxProximityRadiusMeters { + return nil, mediaInvalidErr() + } + next.ProximityNotificationRadius = radius + } + } + newMedia := &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: &next} + + if peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.EditMessage(ctx, userID, domain.EditChannelMessageRequest{ + UserID: userID, + ChannelID: peer.ID, + ID: req.ID, + Message: current.body, + Entities: current.entities, + Media: newMedia, + EditDate: now, + }) + if err != nil { + return nil, channelEditErr(err) + } + updates := r.channelEditMessageUpdates(ctx, userID, res) + r.enqueueChannelEditMessageFanout(ctx, userID, res) + return updates, nil + } + if peer.Type != domain.PeerTypeUser || r.deps.Messages == nil { + return nil, peerIDInvalidErr() + } + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + res, err := r.deps.Messages.EditMessage(ctx, userID, domain.EditMessageRequest{ + OwnerUserID: userID, + Peer: peer, + ID: req.ID, + Message: current.body, + Entities: current.entities, + Media: newMedia, + EditDate: now, + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, messageEditErr(err) + } + self := res.Self() + if self.Event.Pts == 0 || self.Message.ID == 0 { + return nil, messageIDInvalidErr() + } + users := r.usersForMessageUpdate(ctx, userID, self.Message) + chats := r.chatsForMessageUpdate(ctx, userID, self.Message) + return tgEditMessageUpdates(self.Event, self.Message, users, chats), nil +} + +type liveLocationTarget struct { + media *domain.MessageMedia + body string + entities []domain.MessageEntity + date int +} + +// loadLiveLocationMessage 加载目标消息并校验它是一条 live location。 +func (r *Router) loadLiveLocationMessage(ctx context.Context, userID int64, peer domain.Peer, msgID int) (liveLocationTarget, bool) { + switch peer.Type { + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return liveLocationTarget{}, false + } + history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID}) + if err != nil { + return liveLocationTarget{}, false + } + for _, msg := range history.Messages { + if msg.ID == msgID && msg.Media != nil && msg.Media.Kind == domain.MessageMediaKindGeoLive && msg.Media.GeoLive != nil { + return liveLocationTarget{media: msg.Media, body: msg.Body, entities: msg.Entities, date: msg.Date}, true + } + } + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return liveLocationTarget{}, false + } + list, err := r.deps.Messages.GetMessages(ctx, userID, []int{msgID}) + if err != nil { + return liveLocationTarget{}, false + } + for _, msg := range list.Messages { + if msg.ID == msgID && msg.Peer == peer && msg.Media != nil && msg.Media.Kind == domain.MessageMediaKindGeoLive && msg.Media.GeoLive != nil { + return liveLocationTarget{media: msg.Media, body: msg.Body, entities: msg.Entities, date: msg.Date}, true + } + } + } + return liveLocationTarget{}, false +} diff --git a/internal/rpc/messages_edit_geolive_test.go b/internal/rpc/messages_edit_geolive_test.go new file mode 100644 index 00000000..3399df38 --- /dev/null +++ b/internal/rpc/messages_edit_geolive_test.go @@ -0,0 +1,129 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" +) + +func sendTestLiveLocation(t *testing.T, r *Router, fromID int64, peer tg.InputPeerClass, randomID int64, period int) *tg.Message { + t.Helper() + point := &tg.InputGeoPoint{Lat: 39.9, Long: 116.4} + media := &tg.InputMediaGeoLive{GeoPoint: point} + media.SetPeriod(period) + media.SetHeading(90) + updates, err := r.onMessagesSendMedia(WithUserID(context.Background(), fromID), &tg.MessagesSendMediaRequest{ + Peer: peer, + Media: media, + RandomID: randomID, + }) + if err != nil { + t.Fatalf("sendMedia geolive: %v", err) + } + return newMessageFromUpdates(t, updates) +} + +func TestSendMediaGeoLive(t *testing.T) { + r, owner, friend := newMediaTestRouter(t) + msg := sendTestLiveLocation(t, r, owner.ID, &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, 6001, 900) + media, ok := msg.Media.(*tg.MessageMediaGeoLive) + if !ok { + t.Fatalf("expected MessageMediaGeoLive, got %T", msg.Media) + } + if media.Period != 900 { + t.Errorf("period = %d, want 900", media.Period) + } + if heading, ok := media.GetHeading(); !ok || heading != 90 { + t.Errorf("heading = %d (%v), want 90", heading, ok) + } + geo, ok := media.Geo.(*tg.GeoPoint) + if !ok || geo.Lat != 39.9 || geo.Long != 116.4 { + t.Fatalf("geo = %#v, want (39.9, 116.4)", media.Geo) + } +} + +func TestEditMessageLiveLocationUpdateAndStop(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + ownerPeer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash} + ownerCtx := WithUserID(ctx, owner.ID) + + msg := sendTestLiveLocation(t, r, owner.ID, ownerPeer, 6002, 3600) + + // 续报新坐标。 + editReq := &tg.MessagesEditMessageRequest{Peer: ownerPeer, ID: msg.ID} + next := &tg.InputMediaGeoLive{GeoPoint: &tg.InputGeoPoint{Lat: 40.0, Long: 116.5}} + next.SetHeading(180) + editReq.SetMedia(next) + updates, err := r.onMessagesEditMessage(ownerCtx, editReq) + if err != nil { + t.Fatalf("editMessage live update: %v", err) + } + edited := editedMessageFromUpdates(t, updates) + live, ok := edited.Media.(*tg.MessageMediaGeoLive) + if !ok { + t.Fatalf("expected MessageMediaGeoLive after edit, got %T", edited.Media) + } + geo := live.Geo.(*tg.GeoPoint) + if geo.Lat != 40.0 || geo.Long != 116.5 { + t.Fatalf("edited geo = (%v, %v), want (40.0, 116.5)", geo.Lat, geo.Long) + } + if heading, ok := live.GetHeading(); !ok || heading != 180 { + t.Fatalf("edited heading = %d (%v), want 180", heading, ok) + } + if live.Period != 3600 { + t.Fatalf("edited period = %d, want preserved 3600", live.Period) + } + + // 停止共享:period 收口为已逝时长(≤ 原 period 且 ≥1),客户端立即判定过期。 + stopReq := &tg.MessagesEditMessageRequest{Peer: ownerPeer, ID: msg.ID} + stopped := &tg.InputMediaGeoLive{GeoPoint: &tg.InputGeoPointEmpty{}, Stopped: true} + stopReq.SetMedia(stopped) + updates, err = r.onMessagesEditMessage(ownerCtx, stopReq) + if err != nil { + t.Fatalf("editMessage live stop: %v", err) + } + edited = editedMessageFromUpdates(t, updates) + live, ok = edited.Media.(*tg.MessageMediaGeoLive) + if !ok { + t.Fatalf("expected MessageMediaGeoLive after stop, got %T", edited.Media) + } + if live.Period < 1 || live.Period > 60 { + t.Fatalf("stopped period = %d, want small elapsed value", live.Period) + } + + // 非 geolive 消息不可走 live 编辑。 + plain, err := r.onMessagesSendMessage(ownerCtx, &tg.MessagesSendMessageRequest{ + Peer: ownerPeer, Message: "hello", RandomID: 6003, + }) + if err != nil { + t.Fatalf("send plain: %v", err) + } + plainMsg := newMessageFromUpdates(t, plain) + badReq := &tg.MessagesEditMessageRequest{Peer: ownerPeer, ID: plainMsg.ID} + badReq.SetMedia(&tg.InputMediaGeoLive{GeoPoint: &tg.InputGeoPoint{Lat: 1, Long: 1}}) + if _, err := r.onMessagesEditMessage(ownerCtx, badReq); err == nil || !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("live edit on text message err = %v, want MESSAGE_ID_INVALID", err) + } +} + +func editedMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message { + t.Helper() + upd, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("expected *tg.Updates, got %T", updates) + } + for _, u := range upd.Updates { + if em, ok := u.(*tg.UpdateEditMessage); ok { + msg, ok := em.Message.(*tg.Message) + if !ok { + t.Fatalf("expected *tg.Message, got %T", em.Message) + } + return msg + } + } + t.Fatal("no updateEditMessage found") + return nil +} diff --git a/internal/rpc/messages_edit_rpc_test.go b/internal/rpc/messages_edit_rpc_test.go new file mode 100644 index 00000000..df17f88d --- /dev/null +++ b/internal/rpc/messages_edit_rpc_test.go @@ -0,0 +1,156 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strings" + "telesrv/internal/domain" + "testing" +) + +func TestUpdatesDifferenceIncludesEditMessage(t *testing.T) { + msg := domain.Message{ + ID: 4, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000200, + EditDate: 1700000300, + Out: true, + Body: "edited", + Pts: 8, + } + got, ok := tgUpdatesDifference(0, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 8, Date: 1700000300, Seq: 0}, + Events: []domain.UpdateEvent{{ + Type: domain.UpdateEventEditMessage, + Pts: 8, + PtsCount: 1, + Date: 1700000300, + Message: msg, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if len(got.OtherUpdates) != 1 { + t.Fatalf("other updates = %+v, want one edit update", got.OtherUpdates) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateEditMessage) + if !ok || update.Pts != 8 || update.PtsCount != 1 { + t.Fatalf("edit update = %T %+v, want pts=8", got.OtherUpdates[0], got.OtherUpdates[0]) + } + edited, ok := update.Message.(*tg.Message) + if !ok || edited.Message != "edited" || edited.EditDate != 1700000300 { + t.Fatalf("edited message = %#v, want text and edit_date", update.Message) + } +} + +func TestMessagesEditMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + var authKeyID [8]byte + authKeyID[0] = 9 + messages := &captureMessages{} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesEditMessageRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + ID: 3, + } + req.SetMessage("edited") + req.SetEntities([]tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 6}}) + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), userID), authKeyID, 77, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.UpdatesBox) + if !ok { + t.Fatalf("response = %T, want *tg.UpdatesBox", enc) + } + got, ok := box.Updates.(*tg.Updates) + if !ok || len(got.Updates) != 1 { + t.Fatalf("boxed updates = %T %+v, want one update", box.Updates, box.Updates) + } + edit, ok := got.Updates[0].(*tg.UpdateEditMessage) + if !ok || edit.Pts != 7 || edit.PtsCount != 1 { + t.Fatalf("edit update = %#v, want pts=7 count=1", got.Updates[0]) + } + msg, ok := edit.Message.(*tg.Message) + if !ok || msg.ID != 3 || msg.Message != "edited" { + t.Fatalf("edited message = %#v, want id=3 text edited", edit.Message) + } + if messages.editReq.OwnerUserID != userID || messages.editReq.Peer.ID != peerID || messages.editReq.ID != 3 || messages.editReq.OriginAuthKeyID != authKeyID || messages.editReq.OriginSessionID != 77 { + t.Fatalf("edit request = %+v, want owner peer message id and origin", messages.editReq) + } + if len(messages.editReq.Entities) != 1 || messages.editReq.Entities[0].Type != domain.MessageEntityBold { + t.Fatalf("edit entities = %+v, want bold", messages.editReq.Entities) + } +} + +func TestMessagesEditMessageOptionBoundaries(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + ctx := WithUserID(context.Background(), userID) + r := New(Config{}, Deps{Messages: &captureMessages{}}, zaptest.NewLogger(t), clock.System) + + webPreviewReq := &tg.MessagesEditMessageRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + ID: 3, + } + webPreviewReq.SetMessage("edited with link") + webPreviewReq.SetMedia(&tg.InputMediaWebPage{URL: "https://example.test/"}) + if _, err := r.onMessagesEditMessage(ctx, webPreviewReq); err != nil { + t.Fatalf("edit with webpage media err = %v, want text-only downgrade", err) + } + + cases := []struct { + name string + req *tg.MessagesEditMessageRequest + want string + }{ + { + name: "quick reply shortcut", + req: func() *tg.MessagesEditMessageRequest { + req := &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3} + req.SetMessage("edited") + req.SetQuickReplyShortcutID(11) + return req + }(), + want: "MESSAGE_ID_INVALID", + }, + { + name: "unsupported media", + req: func() *tg.MessagesEditMessageRequest { + req := &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3} + req.SetMessage("edited") + req.SetMedia(&tg.InputMediaUploadedPhoto{}) + return req + }(), + want: "MEDIA_INVALID", + }, + { + name: "message flag missing", + req: &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3}, + want: "MESSAGE_EMPTY", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onMessagesEditMessage(ctx, tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("edit err = %v, want %s", err, tc.want) + } + }) + } +} diff --git a/internal/rpc/messages_forum.go b/internal/rpc/messages_forum.go new file mode 100644 index 00000000..12fc2ab6 --- /dev/null +++ b/internal/rpc/messages_forum.go @@ -0,0 +1,527 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "strings" + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onMessagesCreateForumTopic(ctx context.Context, req *tg.MessagesCreateForumTopicRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateForumTopicTitle(req.Title, req.TitleMissing); err != nil { + return nil, err + } + if req.RandomID == 0 { + return nil, randomIDEmptyErr() + } + peer, err := r.forumTopicPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, internalErr() + } + var sendAs *domain.Peer + if req.SendAs != nil { + sendAs, err = r.forumSendAsPeer(ctx, req.Peer, req.SendAs) + if err != nil { + return nil, err + } + } + res, err := r.deps.Channels.CreateForumTopic(ctx, userID, domain.CreateChannelForumTopicRequest{ + UserID: userID, + ChannelID: peer.ID, + Title: strings.TrimSpace(req.Title), + TitleMissing: req.TitleMissing, + IconColor: req.IconColor, + IconEmojiID: req.IconEmojiID, + RandomID: req.RandomID, + SendAs: sendAs, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, forumTopicError(err) + } + sendRes := domain.SendChannelMessageResult{ + Channel: res.Channel, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + Duplicate: res.Duplicate, + } + echoCache := newViewerPeerCache(r) + updates := r.channelMessageUpdatesWithPeerCache(ctx, userID, sendRes, req.RandomID, echoCache) + if !res.Duplicate { + r.enqueueChannelMessageFanout(ctx, userID, sendRes, nil) + } + return updates, nil +} + +func (r *Router) onMessagesEditForumTopic(ctx context.Context, req *tg.MessagesEditForumTopicRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if title, ok := req.GetTitle(); ok { + if err := validateForumTopicTitle(title, false); err != nil { + return nil, err + } + } + if req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.forumTopicPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, internalErr() + } + edit := domain.EditChannelForumTopicRequest{ + UserID: userID, + ChannelID: peer.ID, + TopicID: req.TopicID, + Date: int(r.clock.Now().Unix()), + } + if title, ok := req.GetTitle(); ok { + edit.Title = &title + } + if iconEmojiID, ok := req.GetIconEmojiID(); ok { + edit.IconEmojiID = &iconEmojiID + } + if closed, ok := req.GetClosed(); ok { + edit.Closed = &closed + } + if hidden, ok := req.GetHidden(); ok { + edit.Hidden = &hidden + } + res, err := r.deps.Channels.EditForumTopic(ctx, userID, edit) + if err != nil { + return nil, forumTopicError(err) + } + sendRes := domain.SendChannelMessageResult{ + Channel: res.Channel, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + } + echoCache := newViewerPeerCache(r) + updates := r.channelMessageUpdatesWithPeerCache(ctx, userID, sendRes, 0, echoCache) + r.enqueueChannelMessageFanout(ctx, userID, sendRes, nil) + return updates, nil +} + +func (r *Router) onMessagesUpdatePinnedForumTopic(ctx context.Context, req *tg.MessagesUpdatePinnedForumTopicRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.TopicID <= 0 || req.TopicID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.forumTopicPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, internalErr() + } + res, err := r.deps.Channels.UpdatePinnedForumTopic(ctx, userID, domain.UpdateChannelForumTopicPinnedRequest{ + UserID: userID, + ChannelID: peer.ID, + TopicID: req.TopicID, + Pinned: req.Pinned, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, forumTopicError(err) + } + updates := r.pinnedForumTopicUpdates(userID, res.Channel, res.Topic.TopicID, res.Topic.Pinned) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.pinnedForumTopicUpdates(viewerUserID, res.Channel, res.Topic.TopicID, res.Topic.Pinned) + }) + return updates, nil +} + +func (r *Router) onMessagesReorderPinnedForumTopics(ctx context.Context, req *tg.MessagesReorderPinnedForumTopicsRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateMessageIDVector(req.Order); err != nil { + return nil, err + } + peer, err := r.forumTopicPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, internalErr() + } + res, err := r.deps.Channels.ReorderPinnedForumTopics(ctx, userID, domain.ReorderChannelPinnedForumTopicsRequest{ + UserID: userID, + ChannelID: peer.ID, + Order: append([]int(nil), req.Order...), + Force: req.Force, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, forumTopicError(err) + } + updates := r.pinnedForumTopicsOrderUpdates(userID, res.Channel, res.Order) + r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.pinnedForumTopicsOrderUpdates(viewerUserID, res.Channel, res.Order) + }) + return updates, nil +} + +func (r *Router) onMessagesDeleteTopicHistory(ctx context.Context, req *tg.MessagesDeleteTopicHistoryRequest) (*tg.MessagesAffectedHistory, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.TopMsgID <= 0 || req.TopMsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.forumTopicPeer(ctx, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Channels == nil { + return nil, internalErr() + } + res, err := r.deps.Channels.DeleteForumTopicHistory(ctx, userID, domain.DeleteChannelForumTopicHistoryRequest{ + UserID: userID, + ChannelID: peer.ID, + TopicID: req.TopMsgID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, forumTopicError(err) + } + if res.Event.Pts != 0 { + r.enqueueChannelFanout(ctx, channelFanoutMembers, userID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates { + return &tg.Updates{ + Updates: []tg.UpdateClass{tgChannelUpdate(viewerUserID, res.Event)}, + Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, res.Channel)}, + Date: res.Event.Date, + Seq: 0, + } + }) + return &tg.MessagesAffectedHistory{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount, Offset: res.Offset}, nil + } + return &tg.MessagesAffectedHistory{Pts: res.Channel.Pts, PtsCount: 0, Offset: res.Offset}, nil +} + +func validateMessageIDVector(ids []int) error { + if len(ids) > maxGetMessagesIDs { + return limitInvalidErr() + } + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return messageIDInvalidErr() + } + } + return nil +} + +func validateForumTopicTitle(title string, titleMissing bool) error { + title = strings.TrimSpace(title) + if title == "" && !titleMissing { + return topicTitleEmptyErr() + } + if utf8.RuneCountInString(title) > maxForumTopicTitleLength { + return limitInvalidErr() + } + return nil +} + +func forumTopicError(err error) error { + switch { + case errors.Is(err, domain.ErrChannelForumMissing): + return channelForumMissingErr() + case errors.Is(err, domain.ErrMessageIDInvalid): + return topicIDInvalidErr() + case errors.Is(err, domain.ErrChannelNotModified): + return tgerr400("CHAT_NOT_MODIFIED") + default: + return channelInvalidErr(err) + } +} + +func (r *Router) forumTopicPeer(ctx context.Context, input tg.InputPeerClass) (domain.Peer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return domain.Peer{}, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return domain.Peer{}, err + } + if peer.Type != domain.PeerTypeChannel { + return domain.Peer{}, peerIDInvalidErr() + } + return peer, nil +} + +func (r *Router) onMessagesGetForumTopics(ctx context.Context, req *tg.MessagesGetForumTopicsRequest) (*tg.MessagesForumTopics, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Limit < 0 || utf8.RuneCountInString(req.Q) > maxMessageSearchQLength { + return nil, limitInvalidErr() + } + // 官方客户端会请求超过单页上限的数量(telegram-tt 首屏 loadTopics 发 limit=500),对齐官方与 + // store 行为=钳到服务端单页上限,而非报 LIMIT_INVALID;客户端据响应里的 count 用 offset 翻页取其余。 + effectiveLimit := req.Limit + if effectiveLimit > domain.MaxChannelForumTopicsLimit { + effectiveLimit = domain.MaxChannelForumTopicsLimit + } + view, err := r.forumTopicPeerView(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if !view.Channel.Forum { + return nil, channelForumMissingErr() + } + includeGeneral := effectiveLimit > 0 && forumTopicQueryMatchesGeneral(req.Q) + list := domain.ChannelForumTopicList{} + if r.deps.Channels != nil && effectiveLimit > 0 { + limit := effectiveLimit + if includeGeneral { + limit-- + } + if limit > 0 { + list, err = r.deps.Channels.GetForumTopics(ctx, userID, domain.ChannelForumTopicFilter{ + ChannelID: view.Channel.ID, + Query: req.Q, + OffsetDate: req.OffsetDate, + OffsetID: req.OffsetID, + OffsetTopic: req.OffsetTopic, + Limit: limit, + }) + if err != nil { + return nil, forumTopicError(err) + } + } + } + return r.forumTopicsResponse(ctx, userID, view, list, includeGeneral), nil +} + +func (r *Router) onMessagesGetForumTopicsByID(ctx context.Context, req *tg.MessagesGetForumTopicsByIDRequest) (*tg.MessagesForumTopics, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if len(req.Topics) > maxForumTopicIDs { + return nil, limitInvalidErr() + } + view, err := r.forumTopicPeerView(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if !view.Channel.Forum { + return nil, channelForumMissingErr() + } + includeGeneral := false + ids := make([]int, 0, len(req.Topics)) + for _, topicID := range req.Topics { + if topicID <= 0 || topicID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if topicID == forumGeneralTopicID { + includeGeneral = true + continue + } + ids = append(ids, topicID) + } + list := domain.ChannelForumTopicList{} + if r.deps.Channels != nil && len(ids) > 0 { + list, err = r.deps.Channels.GetForumTopicsByID(ctx, userID, view.Channel.ID, ids) + if err != nil { + return nil, forumTopicError(err) + } + } + return r.forumTopicsResponse(ctx, userID, view, list, includeGeneral), nil +} + +func (r *Router) forumTopicPeerView(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.ChannelView, error) { + p, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return domain.ChannelView{}, err + } + if p.Type != domain.PeerTypeChannel || p.ID == 0 { + return domain.ChannelView{}, peerIDInvalidErr() + } + if r.deps.Channels == nil { + return domain.ChannelView{}, nil + } + view, err := r.deps.Channels.GetChannel(ctx, userID, p.ID) + if err != nil { + return domain.ChannelView{}, channelInvalidErr(err) + } + return view, nil +} + +func (r *Router) forumTopicsResponse(ctx context.Context, userID int64, view domain.ChannelView, list domain.ChannelForumTopicList, includeGeneral bool) *tg.MessagesForumTopics { + if list.Channel.ID != 0 { + view.Channel = list.Channel + } + if list.Dialog.ChannelID != 0 { + view.Dialog = list.Dialog + } + channels := []domain.Channel{view.Channel} + messages := []tg.MessageClass{} + messageIDs := map[int]struct{}{} + topics := []tg.ForumTopicClass{} + userIDs := make([]int64, 0, len(list.Topics)+len(list.Messages)+1) + count := list.Count + if includeGeneral { + count++ + general := domain.ChannelForumTopic{TopMessageID: view.Channel.TopMessageID} + if r.deps.Channels != nil { + if g, err := r.deps.Channels.GeneralForumTopic(ctx, userID, view.Channel.ID); err == nil { + general = g + } + } + topics = append(topics, tgForumGeneralTopic(userID, view, general)) + userIDs = append(userIDs, view.Channel.CreatorUserID) + if general.TopMessageID > 0 && r.deps.Channels != nil { + if history, err := r.deps.Channels.GetMessages(ctx, userID, view.Channel.ID, []int{general.TopMessageID}); err == nil { + for _, msg := range history.Messages { + if _, ok := messageIDs[msg.ID]; ok { + continue + } + messageIDs[msg.ID] = struct{}{} + if item := tgChannelMessage(userID, msg); item != nil { + messages = append(messages, item) + } + userIDs = append(userIDs, msg.SenderUserID) + } + channels = append(channels, history.Channels...) + for _, u := range history.Users { + userIDs = append(userIDs, u.ID) + } + } + } + } + for _, topic := range list.Topics { + topics = append(topics, tgForumTopicFromDomain(userID, topic)) + userIDs = append(userIDs, topic.CreatorUserID) + } + for _, msg := range list.Messages { + if _, ok := messageIDs[msg.ID]; ok { + continue + } + messageIDs[msg.ID] = struct{}{} + if item := tgChannelMessage(userID, msg); item != nil { + messages = append(messages, item) + } + userIDs = append(userIDs, msg.SenderUserID) + if msg.SendAs != nil && msg.SendAs.Type == domain.PeerTypeUser { + userIDs = append(userIDs, msg.SendAs.ID) + } + } + for _, u := range list.Users { + userIDs = append(userIDs, u.ID) + } + return r.applyStoryMaxIDsToForumTopics(ctx, userID, &tg.MessagesForumTopics{ + Count: count, + Topics: topics, + Messages: messages, + Chats: tgChannels(userID, channels), + Users: r.tgUsersForIDs(ctx, userID, userIDs), + Pts: view.Channel.Pts, + }) +} + +func tgForumGeneralTopic(viewerUserID int64, view domain.ChannelView, topic domain.ChannelForumTopic) *tg.ForumTopic { + return &tg.ForumTopic{ + My: view.Channel.CreatorUserID == viewerUserID && viewerUserID != 0, + ID: forumGeneralTopicID, + Date: view.Channel.Date, + Peer: &tg.PeerChannel{ChannelID: view.Channel.ID}, + Title: "General", + IconColor: forumGeneralIconColor, + TopMessage: topic.TopMessageID, + ReadInboxMaxID: topic.ReadInboxMaxID, + ReadOutboxMaxID: topic.ReadOutboxMaxID, + UnreadCount: topic.UnreadCount, + UnreadMentionsCount: topic.UnreadMentionsCount, + UnreadReactionsCount: topic.UnreadReactionsCount, + FromID: &tg.PeerUser{UserID: view.Channel.CreatorUserID}, + NotifySettings: *tdesktop.NotifySettings(), + } +} + +func tgForumTopicFromDomain(viewerUserID int64, topic domain.ChannelForumTopic) *tg.ForumTopic { + iconColor := topic.IconColor + if iconColor == 0 { + iconColor = domain.DefaultForumTopicIconColor + } + return &tg.ForumTopic{ + My: topic.CreatorUserID == viewerUserID && viewerUserID != 0, + Closed: topic.Closed, + Pinned: topic.Pinned, + Hidden: topic.Hidden, + TitleMissing: topic.TitleMissing, + ID: topic.TopicID, + Date: topic.Date, + Peer: &tg.PeerChannel{ChannelID: topic.ChannelID}, + Title: topic.Title, + IconColor: iconColor, + IconEmojiID: topic.IconEmojiID, + TopMessage: topic.TopMessageID, + ReadInboxMaxID: topic.ReadInboxMaxID, + ReadOutboxMaxID: topic.ReadOutboxMaxID, + UnreadCount: topic.UnreadCount, + UnreadMentionsCount: topic.UnreadMentionsCount, + UnreadReactionsCount: topic.UnreadReactionsCount, + UnreadPollVotesCount: topic.UnreadPollVotesCount, + FromID: &tg.PeerUser{UserID: topic.CreatorUserID}, + NotifySettings: *tdesktop.NotifySettings(), + } +} + +func (r *Router) pinnedForumTopicUpdates(viewerUserID int64, channel domain.Channel, topicID int, pinned bool) *tg.Updates { + update := &tg.UpdatePinnedForumTopic{ + Peer: &tg.PeerChannel{ChannelID: channel.ID}, + TopicID: topicID, + } + update.SetPinned(pinned) + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) pinnedForumTopicsOrderUpdates(viewerUserID int64, channel domain.Channel, order []int) *tg.Updates { + update := &tg.UpdatePinnedForumTopics{ + Peer: &tg.PeerChannel{ChannelID: channel.ID}, + Order: append([]int(nil), order...), + } + if order != nil { + update.SetOrder(append([]int(nil), order...)) + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Chats: []tg.ChatClass{tgChannelChatMin(viewerUserID, channel)}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func forumTopicQueryMatchesGeneral(query string) bool { + query = strings.TrimSpace(strings.ToLower(query)) + return query == "" || strings.Contains("general", query) +} diff --git a/internal/rpc/messages_forum_read.go b/internal/rpc/messages_forum_read.go new file mode 100644 index 00000000..12181f73 --- /dev/null +++ b/internal/rpc/messages_forum_read.go @@ -0,0 +1,99 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + "telesrv/internal/domain" +) + +// advanceForumGeneralReadAfterChannelRead 在「直接的频道级 readHistory」(channels.readHistory / +// messages.readHistory 频道 peer) 推进频道级已读后,顺带把 forum 的 General(topic 1) 话题级水位 +// 也推进到同一个 maxID,并向自己其它设备/当前设备下发 updateReadChannelDiscussionInbox、向 +// General 内发送者下发已读回执。 +// +// 语义:General 消息即频道根历史(reply_to_top_id ∈ {0,1}),被频道级已读覆盖,故频道级读到顶 +// ⇒ General 也读到顶。仅对 forum 生效(read.Forum 守卫,避免对普通频道多一次查询)。**普通子话题 +// 的 readDiscussion 不经此路径**(其内部对频道级 ReadHistory 的「保守叠加」由 onMessagesReadDiscussion +// 直接调 service,不触达本 RPC handler),因此读子话题不会回灌 General —— 零话题间 cross-talk, +// 只有真正的频道级整读才推进 General。 +func (r *Router) advanceForumGeneralReadAfterChannelRead(ctx context.Context, userID int64, read domain.ReadChannelHistoryResult) { + if r.deps.Channels == nil || !read.Forum || read.ChannelID == 0 || read.MaxID <= 0 { + return + } + topicRes, err := r.deps.Channels.ReadTopicHistory(ctx, userID, domain.ReadChannelTopicHistoryRequest{ + UserID: userID, + ChannelID: read.ChannelID, + TopicID: domain.ForumGeneralTopicID, + MaxID: read.MaxID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil || !topicRes.Changed { + return + } + // 话题级水位此刻已落库;下面的 durable 事件+在线推送失败只影响其它设备的即时刷新, + // getForumTopics 重拉会收敛,故失败仅记日志、不阻断已成功的频道级 readHistory。 + if err := r.recordChannelDiscussionInbox(ctx, userID, read.ChannelID, topicRes.TopicID, topicRes.MaxID, topicRes.Pts); err != nil { + r.log.Warn("record general discussion inbox after channel read", + zap.Int64("channel_id", read.ChannelID), zap.Error(err)) + return + } + r.pushChannelDiscussionOutboxUpdates(ctx, read.ChannelID, topicRes.TopicID, topicRes.OutboxUpdates) +} + +// recordChannelDiscussionInbox 记录 forum 话题级已读并向自己其它设备/当前设备下发 +// updateReadChannelDiscussionInbox(durable + 账号 pts 簿记,供离线差分恢复)。 +func (r *Router) recordChannelDiscussionInbox(ctx context.Context, userID, channelID int64, topicID, maxID, channelPts int) error { + if maxID <= 0 || channelID == 0 || topicID <= 0 { + return nil + } + event := domain.UpdateEvent{ + Type: domain.UpdateEventReadChannelDiscussionInbox, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + TopMsgID: topicID, + MaxID: maxID, + ChannelPts: channelPts, + Date: int(r.clock.Now().Unix()), + PtsCount: 1, + } + recorded := event + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + rec, _, err := r.deps.Updates.RecordChannelDiscussionInbox(ctx, authKeyID, userID, channelID, topicID, maxID, sessionID) + if err != nil { + return internalErr() + } + recorded = rec + } + r.pushCurrentReadHistoryEvent(ctx, recorded) + r.pushReadHistoryEvent(ctx, userID, recorded) + return nil +} + +// pushChannelDiscussionOutboxUpdates 在线向话题内发送者下发已读回执 +// updateReadChannelDiscussionOutbox。与频道级 pushChannelReadOutboxUpdates 同为在线 best-effort +// (不进 durable,客户端重连可由 getForumTopics 的 read_outbox_max_id 兜底)。 +func (r *Router) pushChannelDiscussionOutboxUpdates(ctx context.Context, channelID int64, topicID int, updates []domain.ChannelReadOutboxUpdate) { + if r.deps.Sessions == nil || channelID == 0 || topicID <= 0 || len(updates) == 0 { + return + } + seen := make(map[int64]int, len(updates)) + for _, update := range updates { + if update.UserID == 0 || update.MaxID <= 0 { + continue + } + if seen[update.UserID] < update.MaxID { + seen[update.UserID] = update.MaxID + } + } + date := int(r.clock.Now().Unix()) + for userID, maxID := range seen { + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateReadChannelDiscussionOutbox{ChannelID: channelID, TopMsgID: topicID, ReadMaxID: maxID}}, + Date: date, + Seq: 0, + }) + } +} diff --git a/internal/rpc/messages_forward.go b/internal/rpc/messages_forward.go new file mode 100644 index 00000000..4dc86b22 --- /dev/null +++ b/internal/rpc/messages_forward.go @@ -0,0 +1,425 @@ +package rpc + +import ( + "context" + "errors" + "strings" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.MessagesForwardMessagesRequest) (tg.UpdatesClass, error) { + if len(req.ID) == 0 || len(req.ID) != len(req.RandomID) { + return nil, inputRequestInvalidErr() + } + if len(req.ID) > domain.MaxForwardMessageIDs { + return nil, limitInvalidErr() + } + if req.ScheduleRepeatPeriod != 0 { + return nil, scheduleDateInvalidErr() + } + if err := forwardMessagesUnsupportedOptionErr(req); err != nil { + return nil, err + } + topMsgID, topMsgIDSet := req.GetTopMsgID() + if !topMsgIDSet && req.TopMsgID != 0 { + topMsgID, topMsgIDSet = req.TopMsgID, true + } + if topMsgIDSet && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, replyMessageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + fromPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.FromPeer) + if err != nil { + return nil, err + } + toPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ToPeer) + if err != nil { + return nil, err + } + sendAs, err := r.resolveSendAsPeer(ctx, userID, toPeer, req.SendAs) + if err != nil { + return nil, err + } + replyTo, err := r.messageReplyFromInput(ctx, userID, toPeer, req.ReplyTo) + if err != nil { + return nil, err + } + replyTo, err = mergeForwardTopMsgID(toPeer, replyTo, topMsgID, topMsgIDSet) + if err != nil { + return nil, err + } + if r.deps.Users != nil { + for _, peer := range []domain.Peer{fromPeer, toPeer} { + if peer.Type != domain.PeerTypeUser || peer.ID == userID { + continue + } + if _, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err != nil { + return nil, internalErr() + } else if !found { + return nil, peerIDInvalidErr() + } + } + } + for i, id := range req.ID { + if id <= 0 || id > domain.MaxMessageBoxID || req.RandomID[i] == 0 { + return nil, messageIDInvalidErr() + } + } + if err := r.checkSendRateLimit(ctx, userID, len(req.ID)); err != nil { + return nil, err + } + if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { + return r.scheduleForwardMessages(ctx, userID, fromPeer, toPeer, req, replyTo, sendAs) + } + if toPeer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return nil, peerIDInvalidErr() + } + sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID) + if err != nil { + return nil, messageForwardErr(err) + } + recipients := make([]int64, 0) + results := make([]domain.SendChannelMessageResult, 0, len(sources)) + extraUserIDs := make([]int64, 0, len(sources)) + for i, source := range sources { + forward := source.forward + if req.DropAuthor { + forward = nil + } + mentionUserIDs := r.mentionUserIDsFromDomain(ctx, userID, source.body, source.entities) + res, err := r.deps.Channels.SendMessage(ctx, userID, domain.SendChannelMessageRequest{ + UserID: userID, + ChannelID: toPeer.ID, + RandomID: req.RandomID[i], + Message: source.body, + Entities: source.entities, + Media: source.media, + MentionUserIDs: mentionUserIDs, + Silent: req.Silent, + NoForwards: req.Noforwards, + ReplyTo: replyTo, + Forward: forward, + SendAs: sendAs, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + results = append(results, res) + // 收件人是该频道的活跃成员集,对本次转发的每条源都相同;只取一次, + // 避免一次转发 ≤100 条到 N 成员大群时把 recipients 累积成 ~100×N 条目 + // 的巨大临时切片(N=10^5 时约千万级)。 + if len(recipients) == 0 { + recipients = res.Recipients + } + if sourceUserID := source.userID(); sourceUserID != 0 { + extraUserIDs = append(extraUserIDs, sourceUserID) + } + } + // echo 与 fan-out 用各自独立 cache(RPC vs worker goroutine 防竞态)。多条转发汇成 + // 一个 fan-out job(channelMessagesUpdatesWithPeerCache 内含多条 UpdateNewChannelMessage), + // 由同 channel 分片 FIFO 原子投递。 + echoCache := newViewerPeerCache(r) + updates := r.channelMessagesUpdatesWithPeerCache(ctx, userID, results, req.RandomID, true, extraUserIDs, echoCache) + fanoutPts := 0 + if n := len(results); n > 0 { + fanoutPts = results[n-1].Event.Pts + } + r.enqueueChannelMessagesFanout(ctx, userID, toPeer.ID, fanoutPts, recipients, results, extraUserIDs) + for _, res := range results { + r.pushChannelDiscussionUpdate(ctx, userID, res.Discussion) + } + return updates, nil + } + if toPeer.Type == domain.PeerTypeUser { + if r.deps.Messages == nil { + return nil, peerIDInvalidErr() + } + recipientBlocked, err := r.peerBlocksUser(ctx, userID, toPeer.ID) + if err != nil { + return nil, err + } + // 私聊源与频道源统一经 forwardSources 取源:首次生成的 forward header 在 + // forwardSources 内已按原作者 PrivacyKeyForwards 降级(不允许链接回账号时仅保 + // 留 from_name),避免私聊→私聊路径泄漏原作者可点击账号;media 也随 source 透传。 + sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID) + if err != nil { + return nil, messageForwardErr(err) + } + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + res := domain.ForwardPrivateMessagesResult{OwnerUserID: userID} + for i, source := range sources { + forward := source.forward + if req.DropAuthor { + forward = nil + } + if forward != nil && toPeer.ID == userID { + saved := *forward + saved.SavedFrom = fromPeer + saved.SavedFromMsgID = req.ID[i] + forward = &saved + } + sent, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: toPeer.ID, + RandomID: req.RandomID[i], + Message: source.body, + Entities: source.entities, + Media: source.media, + Silent: req.Silent, + NoForwards: req.Noforwards, + ReplyTo: replyTo, + Forward: forward, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + RecipientBlocked: recipientBlocked, + }) + if err != nil { + return nil, messageForwardErr(err) + } + res.SenderMessages = append(res.SenderMessages, sent.SenderMessage) + res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage) + res.SenderEvents = append(res.SenderEvents, sent.SenderEvent) + res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent) + res.Duplicates = append(res.Duplicates, sent.Duplicate) + } + return tgForwardMessagesUpdates(res, req.RandomID, r.usersForMessageUpdates(ctx, userID, res.SenderMessages), r.chatsForMessageUpdates(ctx, userID, res.SenderMessages)), nil + } + return nil, peerIDInvalidErr() +} + +func mergeForwardTopMsgID(toPeer domain.Peer, replyTo *domain.MessageReply, topMsgID int, topMsgIDSet bool) (*domain.MessageReply, error) { + if !topMsgIDSet || topMsgID == 0 { + return replyTo, nil + } + if topMsgID < 0 || topMsgID > domain.MaxMessageBoxID || toPeer.Type != domain.PeerTypeChannel { + return nil, replyMessageIDInvalidErr() + } + if replyTo == nil { + return &domain.MessageReply{ + Peer: toPeer, + TopMessageID: topMsgID, + ForumTopic: true, + }, nil + } + if replyTo.Peer.ID != 0 && replyTo.Peer != toPeer { + return nil, replyMessageIDInvalidErr() + } + if replyTo.TopMessageID != 0 && replyTo.TopMessageID != topMsgID { + return nil, replyMessageIDInvalidErr() + } + merged := *replyTo + merged.Peer = toPeer + merged.TopMessageID = topMsgID + merged.QuoteEntities = append([]domain.MessageEntity(nil), replyTo.QuoteEntities...) + if merged.MessageID == 0 { + merged.ForumTopic = true + } + return &merged, nil +} + +func (r *Router) forwardSources(ctx context.Context, userID int64, fromPeer domain.Peer, ids []int) ([]forwardSource, error) { + out := make([]forwardSource, 0, len(ids)) + switch fromPeer.Type { + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return nil, domain.ErrMessageIDInvalid + } + list, err := r.deps.Messages.GetMessages(ctx, userID, ids) + if err != nil { + return nil, domain.ErrMessageIDInvalid + } + byID := make(map[int]domain.Message, len(list.Messages)) + for _, msg := range list.Messages { + byID[msg.ID] = msg + } + for _, id := range ids { + msg, ok := byID[id] + if !ok { + return nil, domain.ErrMessageIDInvalid + } + if msg.Peer != fromPeer { + return nil, domain.ErrMessageIDInvalid + } + if msg.NoForwards { + return nil, domain.ErrChatForwardsRestricted + } + forward := cloneDomainMessageForward(msg.Forward) + if forward == nil { + forward = &domain.MessageForward{From: msg.From, Date: msg.Date} + r.applyForwardAuthorPrivacy(ctx, userID, forward) + } + out = append(out, forwardSource{ + body: msg.Body, + entities: append([]domain.MessageEntity(nil), + msg.Entities...), + media: msg.Media, + forward: forward, + from: msg.From, + date: msg.Date, + }) + } + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return nil, domain.ErrMessageIDInvalid + } + history, err := r.deps.Channels.GetMessages(ctx, userID, fromPeer.ID, ids) + if err != nil { + return nil, domain.ErrMessageIDInvalid + } + byID := make(map[int]domain.ChannelMessage, len(history.Messages)) + for _, msg := range history.Messages { + byID[msg.ID] = msg + } + for _, id := range ids { + msg, ok := byID[id] + if !ok { + return nil, domain.ErrMessageIDInvalid + } + if msg.NoForwards || history.Channel.NoForwards { + return nil, domain.ErrChatForwardsRestricted + } + if msg.Action != nil || (msg.Body == "" && msg.Media.IsZero()) { + return nil, domain.ErrMessageIDInvalid + } + forward := cloneDomainMessageForward(msg.Forward) + from := msg.From + if from.ID == 0 && msg.SenderUserID != 0 { + from = domain.Peer{Type: domain.PeerTypeUser, ID: msg.SenderUserID} + } + if msg.Post { + from = domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID} + } + if forward == nil { + forward = &domain.MessageForward{From: from, Date: msg.Date} + if from.Type == domain.PeerTypeChannel { + forward.ChannelPost = msg.ID + } + r.applyForwardAuthorPrivacy(ctx, userID, forward) + } + out = append(out, forwardSource{ + body: msg.Body, + entities: append([]domain.MessageEntity(nil), + msg.Entities...), + media: msg.Media, + forward: forward, + from: from, + date: msg.Date, + }) + } + default: + return nil, domain.ErrMessageIDInvalid + } + return out, nil +} + +func cloneDomainMessageForward(in *domain.MessageForward) *domain.MessageForward { + if in == nil { + return nil + } + out := *in + return &out +} + +func (r *Router) forwardAuthorDisplayName(ctx context.Context, viewerUserID, authorUserID int64) string { + name := "" + if r.deps.Users != nil { + if author, found, err := r.deps.Users.ByID(ctx, viewerUserID, authorUserID); err == nil && found { + name = strings.TrimSpace(strings.TrimSpace(author.FirstName) + " " + strings.TrimSpace(author.LastName)) + } + } + if name == "" { + name = "Deleted Account" + } + return name +} + +// applyForwardAuthorPrivacy 在首次生成 forward header 时按原作者的 +// forwards 隐私规则降级:不允许链接回账号时只保留展示名 from_name。 +// 已有 header 的再转发沿用原 header,不重新评估。 +func (r *Router) applyForwardAuthorPrivacy(ctx context.Context, forwarderUserID int64, forward *domain.MessageForward) { + if forward == nil || forward.From.Type != domain.PeerTypeUser || forward.From.ID == 0 || forward.FromName != "" { + return + } + if forward.From.ID == forwarderUserID || r.deps.Privacy == nil { + return + } + allowed, err := r.deps.Privacy.CanSee(ctx, forward.From.ID, forwarderUserID, domain.PrivacyKeyForwards) + if err != nil || allowed { + return + } + name := r.forwardAuthorDisplayName(ctx, forwarderUserID, forward.From.ID) + forward.From = domain.Peer{} + forward.FromName = name +} + +func messageForwardErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrChatForwardsRestricted): + return chatForwardsRestrictedErr() + case errors.Is(err, domain.ErrReplyMessageIDInvalid): + return replyMessageIDInvalidErr() + default: + return internalErr() + } +} + +func tgForwardMessagesUpdates(res domain.ForwardPrivateMessagesResult, randomIDs []int64, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates { + updates := make([]tg.UpdateClass, 0, len(res.SenderMessages)*2) + date := 0 + for i, msg := range res.SenderMessages { + randomID := int64(0) + if i < len(randomIDs) { + randomID = randomIDs[i] + } + updates = append(updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: randomID}) + event := domain.UpdateEvent{} + if i < len(res.SenderEvents) { + event = res.SenderEvents[i] + } + item := tgMessage(msg) + if item == nil { + item = &tg.MessageEmpty{ID: msg.ID} + } + pts := event.Pts + if pts == 0 { + pts = msg.Pts + } + ptsCount := event.PtsCount + if ptsCount == 0 { + ptsCount = 1 + } + updates = append(updates, &tg.UpdateNewMessage{ + Message: item, + Pts: pts, + PtsCount: ptsCount, + }) + if date == 0 { + date = event.Date + } + if date == 0 { + date = msg.Date + } + } + return &tg.Updates{ + Updates: updates, + Users: users, + Chats: chats, + Date: date, + Seq: 0, + } +} diff --git a/internal/rpc/messages_forward_rpc_test.go b/internal/rpc/messages_forward_rpc_test.go new file mode 100644 index 00000000..22770106 --- /dev/null +++ b/internal/rpc/messages_forward_rpc_test.go @@ -0,0 +1,350 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strings" + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestMessagesForwardMessagesRecordsRequestAndReturnsUpdates(t *testing.T) { + const ( + ownerID = int64(1000000001) + fromID = int64(1000000002) + toID = int64(1000000003) + ) + // 私聊→私聊转发统一经 forwardSources 取源(GetMessages)再逐条 SendPrivateText, + // 以便在 RPC 层对原作者做 PrivacyKeyForwards 降级,故必须提供源消息。 + messages := &captureMessages{list: domain.MessageList{Messages: []domain.Message{ + {ID: 3, OwnerUserID: ownerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID}, Date: 1700000000, Body: "first"}, + {ID: 4, OwnerUserID: ownerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID}, Date: 1700000001, Body: "second"}, + }}} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{ + ownerID: {ID: ownerID, FirstName: "Owner"}, + fromID: {ID: fromID, FirstName: "From"}, + toID: {ID: toID, FirstName: "To"}, + }}, + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerUser{UserID: fromID}, + ToPeer: &tg.InputPeerUser{UserID: toID}, + ID: []int{3, 4}, + RandomID: []int64{1001, 1002}, + Silent: true, + Noforwards: true, + } + replyTo := &tg.InputReplyToMessage{ReplyToMsgID: 9} + replyTo.SetQuoteText("target") + req.SetReplyTo(replyTo) + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), ownerID), [8]byte{}, 99, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if messages.sendUserID != ownerID || messages.sendReq.SenderUserID != ownerID || messages.sendReq.RecipientUserID != toID || messages.sendReq.OriginSessionID != 99 { + t.Fatalf("forward send = user %d %+v, want owner/to/session", messages.sendUserID, messages.sendReq) + } + if messages.sendReq.ReplyTo == nil || messages.sendReq.ReplyTo.MessageID != 9 || messages.sendReq.ReplyTo.Peer.ID != toID || messages.sendReq.ReplyTo.QuoteText != "target" { + t.Fatalf("forward reply = %+v, want target peer reply metadata", messages.sendReq.ReplyTo) + } + box, ok := enc.(*tg.UpdatesBox) + if !ok { + t.Fatalf("response = %T, want *tg.UpdatesBox", enc) + } + got := box.Updates.(*tg.Updates) + if len(got.Updates) != 4 { + t.Fatalf("updates = %+v, want two message ids and two new messages", got.Updates) + } + if id, ok := got.Updates[0].(*tg.UpdateMessageID); !ok || id.RandomID != 1001 { + t.Fatalf("first update = %#v, want updateMessageID random 1001", got.Updates[0]) + } + newMsg := got.Updates[1].(*tg.UpdateNewMessage) + msg := newMsg.Message.(*tg.Message) + if msg.FwdFrom.Date == 0 || !msg.Silent || !msg.Noforwards { + t.Fatalf("forwarded message = %#v, want fwd header and flags", msg) + } + if header, ok := msg.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != 9 { + t.Fatalf("forwarded reply = %#v, want reply header id=9", msg.ReplyTo) + } + hasForwardAuthor := false + for _, user := range got.Users { + if u, ok := user.(*tg.User); ok && u.ID == fromID { + hasForwardAuthor = true + break + } + } + if !hasForwardAuthor { + t.Fatalf("forward users = %+v, want original author %d for fwd_from", got.Users, fromID) + } +} + +func TestMessagesForwardMessagesLoadsPrivateSourcesInSingleBatch(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "15550004001", + FirstName: "ForwardOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + sourceUser, err := userStore.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "15550004002", + FirstName: "ForwardSource", + }) + if err != nil { + t.Fatalf("create source: %v", err) + } + channelSvc := appchannels.NewService(memory.NewChannelStore()) + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Forward Batch", + Megagroup: true, + Date: 1700001200, + }) + if err != nil { + t.Fatalf("create target channel: %v", err) + } + messages := &captureMessages{ + getMessagesListed: true, + list: domain.MessageList{Messages: []domain.Message{ + { + ID: 5, + OwnerUserID: owner.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sourceUser.ID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: sourceUser.ID}, + Date: 1700001195, + Body: "source five", + }, + { + ID: 7, + OwnerUserID: owner.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sourceUser.ID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: sourceUser.ID}, + Date: 1700001197, + Body: "source seven", + }, + }}, + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Messages: messages, + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + updatesClass, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerUser{UserID: sourceUser.ID, AccessHash: sourceUser.AccessHash}, + ToPeer: &tg.InputPeerChannel{ + ChannelID: created.Channel.ID, + AccessHash: created.Channel.AccessHash, + }, + ID: []int{7, 5}, + RandomID: []int64{7001, 5001}, + }) + if err != nil { + t.Fatalf("forward private sources to channel: %v", err) + } + if messages.getMessagesCalls != 1 { + t.Fatalf("GetMessages calls = %d, want one batched source load", messages.getMessagesCalls) + } + if len(messages.getMessagesIDs) != 1 || len(messages.getMessagesIDs[0]) != 2 || messages.getMessagesIDs[0][0] != 7 || messages.getMessagesIDs[0][1] != 5 { + t.Fatalf("GetMessages ids = %+v, want [[7 5]]", messages.getMessagesIDs) + } + updates, ok := updatesClass.(*tg.Updates) + if !ok || len(updates.Updates) != 4 { + t.Fatalf("forward updates = %T %+v, want 4 updates", updatesClass, updatesClass) + } + firstID, ok := updates.Updates[0].(*tg.UpdateMessageID) + if !ok || firstID.RandomID != 7001 { + t.Fatalf("first update = %#v, want random 7001", updates.Updates[0]) + } + firstNew, ok := updates.Updates[1].(*tg.UpdateNewChannelMessage) + if !ok { + t.Fatalf("second update = %#v, want updateNewChannelMessage", updates.Updates[1]) + } + firstMsg, ok := firstNew.Message.(*tg.Message) + if !ok || firstMsg.Message != "source seven" { + t.Fatalf("first forwarded message = %#v, want source seven", firstNew.Message) + } + secondID, ok := updates.Updates[2].(*tg.UpdateMessageID) + if !ok || secondID.RandomID != 5001 { + t.Fatalf("third update = %#v, want random 5001", updates.Updates[2]) + } + secondNew, ok := updates.Updates[3].(*tg.UpdateNewChannelMessage) + if !ok { + t.Fatalf("fourth update = %#v, want updateNewChannelMessage", updates.Updates[3]) + } + secondMsg, ok := secondNew.Message.(*tg.Message) + if !ok || secondMsg.Message != "source five" { + t.Fatalf("second forwarded message = %#v, want source five", secondNew.Message) + } +} + +func TestChatsForMessageUpdatesUsesBatchChannelProjection(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{ + AccessHash: 51, + Phone: "15550005001", + FirstName: "BatchOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + first, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Message Ref One", + Broadcast: true, + Date: 1700001500, + }) + if err != nil { + t.Fatalf("create first channel: %v", err) + } + second, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Message Ref Two", + Megagroup: true, + Date: 1700001510, + }) + if err != nil { + t.Fatalf("create second channel: %v", err) + } + counting := &countingChannelsService{Service: channelService} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: counting, + }, zaptest.NewLogger(t), clock.System) + + chats := r.chatsForMessageUpdates(ctx, owner.ID, []domain.Message{ + { + From: domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID}, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + Forward: &domain.MessageForward{ + From: domain.Peer{Type: domain.PeerTypeChannel, ID: second.Channel.ID}, + }, + }, + { + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: first.Channel.ID}, + ReplyTo: &domain.MessageReply{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: second.Channel.ID}, + }, + }, + }) + if len(chats) != 2 { + t.Fatalf("chats = %d, want two unique channel refs", len(chats)) + } + firstChat, ok := chats[0].(*tg.Channel) + if !ok || firstChat.ID != first.Channel.ID { + t.Fatalf("first chat = %#v, want first channel", chats[0]) + } + secondChat, ok := chats[1].(*tg.Channel) + if !ok || secondChat.ID != second.Channel.ID { + t.Fatalf("second chat = %#v, want second channel", chats[1]) + } + if counting.getChannelsCalls != 1 || counting.getChannelCalls != 0 { + t.Fatalf("channel service calls: GetChannels=%d GetChannel=%d, want one batch call only", counting.getChannelsCalls, counting.getChannelCalls) + } +} + +func TestMessagesForwardMessagesUnsupportedOptionErrors(t *testing.T) { + const ownerID = int64(1000000001) + ctx := WithUserID(context.Background(), ownerID) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + base := func() *tg.MessagesForwardMessagesRequest { + return &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerUser{UserID: 1000000002}, + ToPeer: &tg.InputPeerUser{UserID: 1000000003}, + ID: []int{3}, + RandomID: []int64{1001}, + } + } + suggested := func() tg.SuggestedPost { + post := tg.SuggestedPost{} + post.SetAccepted(true) + return post + } + cases := []struct { + name string + req *tg.MessagesForwardMessagesRequest + want string + }{ + { + name: "quick reply", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetQuickReplyShortcut(&tg.InputQuickReplyShortcut{Shortcut: "hello"}) + return req + }(), + want: "SHORTCUT_INVALID", + }, + { + name: "effect", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetEffect(1) + return req + }(), + want: "EFFECT_ID_INVALID", + }, + { + name: "video timestamp without media model", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetVideoTimestamp(10) + return req + }(), + want: "MEDIA_INVALID", + }, + { + name: "negative paid stars", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetAllowPaidStars(-1) + return req + }(), + want: "STARS_AMOUNT_INVALID", + }, + { + name: "paid floodskip", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetAllowPaidFloodskip(true) + return req + }(), + want: "PAYMENT_UNSUPPORTED", + }, + { + name: "suggested post", + req: func() *tg.MessagesForwardMessagesRequest { + req := base() + req.SetSuggestedPost(suggested()) + return req + }(), + want: "SUGGESTED_POST_PEER_INVALID", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onMessagesForwardMessages(ctx, tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("forward err = %v, want %s", err, tc.want) + } + }) + } +} diff --git a/internal/rpc/messages_games.go b/internal/rpc/messages_games.go new file mode 100644 index 00000000..e0c0826d --- /dev/null +++ b/internal/rpc/messages_games.go @@ -0,0 +1,96 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" +) + +func (r *Router) onMessagesGetEmojiGameInfo(ctx context.Context) (tg.MessagesEmojiGameInfoClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return &tg.MessagesEmojiGameUnavailable{}, nil +} + +func (r *Router) onMessagesGetGameHighScores(ctx context.Context, req *tg.MessagesGetGameHighScoresRequest) (*tg.MessagesHighScores, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return nil, err + } + if req.ID <= 0 { + return nil, messageIDInvalidErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, botID, req.Peer); err != nil { + return nil, err + } + if err := r.validateInputUser(ctx, req.UserID); err != nil { + return nil, err + } + return nil, messageIDInvalidErr() +} + +func (r *Router) onMessagesGetInlineGameHighScores(ctx context.Context, req *tg.MessagesGetInlineGameHighScoresRequest) (*tg.MessagesHighScores, error) { + if _, err := r.callerBotID(ctx); err != nil { + return nil, err + } + if err := validateInputBotInlineMessageID(req.ID); err != nil { + return nil, err + } + if err := r.validateInputUser(ctx, req.UserID); err != nil { + return nil, err + } + return nil, messageIDInvalidErr() +} + +func (r *Router) onMessagesSetGameScore(ctx context.Context, req *tg.MessagesSetGameScoreRequest) (tg.UpdatesClass, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return nil, err + } + if req.ID <= 0 { + return nil, messageIDInvalidErr() + } + if req.Score < 0 { + return nil, scoreInvalidErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, botID, req.Peer); err != nil { + return nil, err + } + if err := r.validateInputUser(ctx, req.UserID); err != nil { + return nil, err + } + return nil, messageIDInvalidErr() +} + +func (r *Router) onMessagesSetInlineGameScore(ctx context.Context, req *tg.MessagesSetInlineGameScoreRequest) (bool, error) { + if _, err := r.callerBotID(ctx); err != nil { + return false, err + } + if err := validateInputBotInlineMessageID(req.ID); err != nil { + return false, err + } + if req.Score < 0 { + return false, scoreInvalidErr() + } + if err := r.validateInputUser(ctx, req.UserID); err != nil { + return false, err + } + return false, messageIDInvalidErr() +} + +func validateInputBotInlineMessageID(id tg.InputBotInlineMessageIDClass) error { + switch v := id.(type) { + case *tg.InputBotInlineMessageID: + if v.ID == 0 || v.AccessHash == 0 { + return messageIDInvalidErr() + } + case *tg.InputBotInlineMessageID64: + if v.OwnerID == 0 || v.ID <= 0 || v.AccessHash == 0 { + return messageIDInvalidErr() + } + default: + return messageIDInvalidErr() + } + return nil +} diff --git a/internal/rpc/messages_games_rpc_test.go b/internal/rpc/messages_games_rpc_test.go new file mode 100644 index 00000000..2857005e --- /dev/null +++ b/internal/rpc/messages_games_rpc_test.go @@ -0,0 +1,130 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" +) + +func TestMessagesGetEmojiGameInfoUnavailable(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + + got, err := f.router.onMessagesGetEmojiGameInfo(WithUserID(ctx, f.owner.ID)) + if err != nil { + t.Fatalf("get emoji game info: %v", err) + } + if _, ok := got.(*tg.MessagesEmojiGameUnavailable); !ok { + t.Fatalf("emoji game info = %T, want *tg.MessagesEmojiGameUnavailable", got) + } +} + +func TestMessagesGameScoreRPCsRequireBot(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + userCtx := WithUserID(ctx, f.owner.ID) + inlineID := validInputBotInlineMessageID(f.owner.ID) + + if _, err := f.router.onMessagesGetGameHighScores(userCtx, &tg.MessagesGetGameHighScoresRequest{ + Peer: inputPeerUser(f.peer), + ID: 1, + UserID: inputUser(f.owner), + }); !tgerr.Is(err, "USER_BOT_REQUIRED") { + t.Fatalf("get game high scores err = %v, want USER_BOT_REQUIRED", err) + } + if _, err := f.router.onMessagesGetInlineGameHighScores(userCtx, &tg.MessagesGetInlineGameHighScoresRequest{ + ID: inlineID, + UserID: inputUser(f.owner), + }); !tgerr.Is(err, "USER_BOT_REQUIRED") { + t.Fatalf("get inline game high scores err = %v, want USER_BOT_REQUIRED", err) + } + if _, err := f.router.onMessagesSetGameScore(userCtx, &tg.MessagesSetGameScoreRequest{ + Peer: inputPeerUser(f.peer), + ID: 1, + UserID: inputUser(f.owner), + Score: 10, + }); !tgerr.Is(err, "USER_BOT_REQUIRED") { + t.Fatalf("set game score err = %v, want USER_BOT_REQUIRED", err) + } + if ok, err := f.router.onMessagesSetInlineGameScore(userCtx, &tg.MessagesSetInlineGameScoreRequest{ + ID: inlineID, + UserID: inputUser(f.owner), + Score: 10, + }); ok || !tgerr.Is(err, "USER_BOT_REQUIRED") { + t.Fatalf("set inline game score = %v,%v, want false,USER_BOT_REQUIRED", ok, err) + } +} + +func TestMessagesGameScoreRPCsRejectWithoutGameModel(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + botCtx := WithUserID(ctx, f.bot.ID) + + if _, err := f.router.onMessagesGetGameHighScores(botCtx, &tg.MessagesGetGameHighScoresRequest{ + Peer: inputPeerUser(f.peer), + ID: 1, + UserID: inputUser(f.owner), + }); !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("get game high scores err = %v, want MESSAGE_ID_INVALID", err) + } + if _, err := f.router.onMessagesSetGameScore(botCtx, &tg.MessagesSetGameScoreRequest{ + Peer: inputPeerUser(f.peer), + ID: 1, + UserID: inputUser(f.owner), + Score: 10, + }); !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("set game score err = %v, want MESSAGE_ID_INVALID", err) + } + if _, err := f.router.onMessagesSetGameScore(botCtx, &tg.MessagesSetGameScoreRequest{ + Peer: inputPeerUser(f.peer), + ID: 1, + UserID: inputUser(f.owner), + Score: -1, + }); !tgerr.Is(err, "SCORE_INVALID") { + t.Fatalf("set negative game score err = %v, want SCORE_INVALID", err) + } +} + +func TestMessagesInlineGameScoreRPCsRejectWithoutGameModel(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + botCtx := WithUserID(ctx, f.bot.ID) + inlineID := validInputBotInlineMessageID(f.owner.ID) + + if _, err := f.router.onMessagesGetInlineGameHighScores(botCtx, &tg.MessagesGetInlineGameHighScoresRequest{ + ID: inlineID, + UserID: inputUser(f.owner), + }); !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("get inline game high scores err = %v, want MESSAGE_ID_INVALID", err) + } + if ok, err := f.router.onMessagesSetInlineGameScore(botCtx, &tg.MessagesSetInlineGameScoreRequest{ + ID: inlineID, + UserID: inputUser(f.owner), + Score: 10, + }); ok || !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("set inline game score = %v,%v, want false,MESSAGE_ID_INVALID", ok, err) + } + if ok, err := f.router.onMessagesSetInlineGameScore(botCtx, &tg.MessagesSetInlineGameScoreRequest{ + ID: inlineID, + UserID: inputUser(f.owner), + Score: -1, + }); ok || !tgerr.Is(err, "SCORE_INVALID") { + t.Fatalf("set negative inline game score = %v,%v, want false,SCORE_INVALID", ok, err) + } + if _, err := f.router.onMessagesGetInlineGameHighScores(botCtx, &tg.MessagesGetInlineGameHighScoresRequest{ + UserID: inputUser(f.owner), + }); !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("get inline game high scores nil id err = %v, want MESSAGE_ID_INVALID", err) + } +} + +func validInputBotInlineMessageID(ownerID int64) tg.InputBotInlineMessageIDClass { + return &tg.InputBotInlineMessageID64{ + DCID: 2, + OwnerID: ownerID, + ID: 1, + AccessHash: 12345, + } +} diff --git a/internal/rpc/messages_get_webpage_test.go b/internal/rpc/messages_get_webpage_test.go new file mode 100644 index 00000000..ebec9e04 --- /dev/null +++ b/internal/rpc/messages_get_webpage_test.go @@ -0,0 +1,86 @@ +package rpc + +import ( + "context" + "errors" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// TestWebPageForURL 验证 getWebPage 决策:done 卡片→webPage;hash 匹配→webPageNotModified; +// 无预览/失败→webPageEmpty(带 URL)。 +func TestWebPageForURL(t *testing.T) { + ctx := context.Background() + const url = "https://example.com/x" + done := domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: 7, URL: url, DisplayURL: "example.com", Title: "T", Hash: 4242} + + files := &fakeFiles{resolveWebPageFn: func(string) (domain.MessageWebPage, error) { return done, nil }} + r := &Router{deps: Deps{Files: files}} + + t.Run("done-card", func(t *testing.T) { + res := r.webPageForURL(ctx, url, 0) + page, ok := res.Webpage.(*tg.WebPage) + if !ok { + t.Fatalf("webpage = %T, want *tg.WebPage", res.Webpage) + } + if page.URL != url || page.Hash != 4242 { + t.Errorf("page = %+v", page) + } + }) + + t.Run("hash-match-not-modified", func(t *testing.T) { + res := r.webPageForURL(ctx, url, 4242) + if _, ok := res.Webpage.(*tg.WebPageNotModified); !ok { + t.Fatalf("webpage = %T, want *tg.WebPageNotModified", res.Webpage) + } + }) + + t.Run("error-empty", func(t *testing.T) { + files.resolveWebPageFn = func(string) (domain.MessageWebPage, error) { + return domain.MessageWebPage{}, errors.New("boom") + } + res := r.webPageForURL(ctx, url, 0) + empty, ok := res.Webpage.(*tg.WebPageEmpty) + if !ok { + t.Fatalf("webpage = %T, want *tg.WebPageEmpty", res.Webpage) + } + if u, _ := empty.GetURL(); u != url { + t.Errorf("empty url = %q, want %q", u, url) + } + }) + + t.Run("cache-first-skips-resolve", func(t *testing.T) { + // 缓存命中时不应再调 ResolveWebPage(避免请求路径阻塞抓取)。 + cf := &fakeFiles{ + lookupWebPageFn: func(u string) (domain.MessageWebPage, bool) { + return domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: 1, URL: u, DisplayURL: "x", Title: "Cached", Hash: 7}, true + }, + resolveWebPageFn: func(string) (domain.MessageWebPage, error) { + t.Fatalf("ResolveWebPage must not be called on cache hit") + return domain.MessageWebPage{}, nil + }, + } + rc := &Router{deps: Deps{Files: cf}} + res := rc.webPageForURL(ctx, url, 0) + page, ok := res.Webpage.(*tg.WebPage) + if !ok { + t.Fatalf("webpage = %T, want *tg.WebPage", res.Webpage) + } + if v, _ := page.GetTitle(); v != "Cached" { + t.Errorf("title = %q, want Cached", v) + } + }) + + t.Run("empty-state-empty", func(t *testing.T) { + files.resolveWebPageFn = func(string) (domain.MessageWebPage, error) { + return domain.MessageWebPage{State: domain.MessageWebPageStateEmpty, ID: 7, URL: url}, nil + } + res := r.webPageForURL(ctx, url, 0) + if _, ok := res.Webpage.(*tg.WebPageEmpty); !ok { + t.Fatalf("webpage = %T, want *tg.WebPageEmpty for empty state", res.Webpage) + } + }) +} diff --git a/internal/rpc/messages_helpers.go b/internal/rpc/messages_helpers.go new file mode 100644 index 00000000..acd767b7 --- /dev/null +++ b/internal/rpc/messages_helpers.go @@ -0,0 +1,572 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "telesrv/internal/domain" +) + +func reportResultForOption(option string) (tg.ReportResultClass, error) { + switch option { + case "": + return &tg.ReportResultChooseOption{ + Title: "Report", + Options: []tg.MessageReportOption{ + {Text: "Spam", Option: []byte("spam")}, + {Text: "Violence", Option: []byte("violence")}, + {Text: "Illegal goods", Option: []byte("illegal_goods")}, + {Text: "Child abuse", Option: []byte("child_abuse")}, + {Text: "Personal data", Option: []byte("personal_data")}, + {Text: "Copyright", Option: []byte("copyright")}, + {Text: "Other", Option: []byte("other")}, + }, + }, nil + case "other": + return &tg.ReportResultAddComment{Optional: false, Option: []byte("other:comment")}, nil + case "spam", "violence", "illegal_goods", "child_abuse", "personal_data", "copyright", "other:comment": + return &tg.ReportResultReported{}, nil + default: + return nil, tgerr.New(400, "OPTION_INVALID") + } +} + +func (r *Router) inputPeerForDomainPeer(ctx context.Context, currentUserID int64, peer domain.Peer) tg.InputPeerClass { + switch peer.Type { + case domain.PeerTypeUser: + if u, ok := domain.SystemUserByID(peer.ID); ok { + return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash} + } + switch { + case r.deps.Users == nil: + return nil + case peer.ID == currentUserID: + u, err := r.deps.Users.Self(ctx, currentUserID) + if err != nil || u.ID == 0 { + return nil + } + return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash} + default: + u, found, err := r.deps.Users.ByID(ctx, currentUserID, peer.ID) + if err != nil || !found { + return nil + } + return &tg.InputPeerUser{UserID: u.ID, AccessHash: u.AccessHash} + } + case domain.PeerTypeChannel: + if r.deps.Channels == nil || peer.ID == 0 { + return nil + } + // 只需 access_hash 解析 InputPeerChannel:走轻量 ResolveChannel(仅访问校验,省 dialog/读态/boost + // 这 3 条额外查询)。inputPeerFor 是各消息类 RPC 的通用 peer 解析器、调用极频,是 GetChannel 放大的主源头。 + view, err := r.deps.Channels.ResolveChannel(ctx, currentUserID, peer.ID) + if err != nil || view.Channel.ID == 0 { + return nil + } + return &tg.InputPeerChannel{ChannelID: view.Channel.ID, AccessHash: view.Channel.AccessHash} + default: + return nil + } +} + +func validateHistoryBounds(offsetID, addOffset, limit, maxID, minID int) error { + if offsetID < 0 || offsetID > domain.MaxMessageBoxID || maxID < 0 || maxID > domain.MaxMessageBoxID || minID < 0 || minID > domain.MaxMessageBoxID { + return messageIDInvalidErr() + } + if addOffset < -100 || addOffset > 100 || limit < 0 || limit > maxSearchResultsLimit { + return limitInvalidErr() + } + return nil +} + +func (r *Router) savedHistoryChats(ctx context.Context, userID int64, hasParent bool, parent domain.Peer, peer tg.InputPeerClass) []tg.ChatClass { + if r.deps.Channels == nil { + return []tg.ChatClass{} + } + seen := make(map[int64]struct{}, 2) + out := make([]tg.ChatClass, 0, 2) + add := func(channelID int64) { + if channelID == 0 { + return + } + if _, ok := seen[channelID]; ok { + return + } + seen[channelID] = struct{}{} + view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID) + if err != nil || view.Channel.ID == 0 { + return + } + out = append(out, tgChannelChatForView(userID, view)) + } + if hasParent && parent.Type == domain.PeerTypeChannel { + add(parent.ID) + } + if p, ok := r.domainPeerFromInputPeer(userID, peer); ok && p.Type == domain.PeerTypeChannel { + add(p.ID) + } + return out +} + +func (r *Router) channelOnlineCount(ctx context.Context, userID, channelID int64) int { + if channelID == 0 || r.deps.Channels == nil || r.deps.Sessions == nil { + return 1 + } + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return 1 + } + online := provider.OnlineChannelUserIDs(channelID, domain.MaxChannelRealtimeFanout) + candidates := make([]int64, 0, len(online)+1) + if userID != 0 { + candidates = append(candidates, userID) + } + candidates = append(candidates, online...) + active, err := r.deps.Channels.FilterActiveMemberIDs(ctx, channelID, candidates) + if err != nil { + return 1 + } + return len(active) +} + +func (r *Router) onMessagesSetTyping(ctx context.Context, req *tg.MessagesSetTypingRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + topMsgID, topMsgIDSet := req.GetTopMsgID() + if !topMsgIDSet && req.TopMsgID != 0 { + topMsgID, topMsgIDSet = req.TopMsgID, true + } + if topMsgIDSet { + switch { + case topMsgID <= 0: + topMsgID = 0 + case topMsgID > domain.MaxMessageBoxID: + return false, msgIDInvalidErr() + } + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + if peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID { + if peer.Type == domain.PeerTypeChannel && peer.ID != 0 && r.deps.Channels != nil { + action := req.Action + if action == nil { + action = &tg.SendMessageCancelAction{} + } + updates := &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateChannelUserTyping{ + ChannelID: peer.ID, + FromID: &tg.PeerUser{UserID: userID}, + TopMsgID: topMsgID, + Action: action, + }}, + Date: int(r.clock.Now().Unix()), + } + r.pushChannelViewerUpdates(ctx, 0, peer.ID, nil, func(int64) *tg.Updates { + return updates + }) + } + return true, nil + } + action := req.Action + if action == nil { + action = &tg.SendMessageCancelAction{} + } + update := &tg.UpdateUserTyping{ + UserID: userID, + TopMsgID: topMsgID, + Action: action, + } + updates := &tg.UpdateShort{ + Update: update, + Date: int(r.clock.Now().Unix()), + } + r.pushTypingUpdate(ctx, peer.ID, updates) + return true, nil +} + +func (r *Router) pushTypingUpdate(ctx context.Context, targetUserID int64, updates *tg.UpdateShort) { + // typing 是 transient(不写 durable log):未就绪的 session 直接跳过、不进 pending。 + r.pushUserMessageTransient(ctx, targetUserID, "push typing update", updates) +} + +func inputMessageBoxID(input tg.InputMessageClass) (int, bool) { + switch msg := input.(type) { + case *tg.InputMessageID: + return msg.ID, true + default: + return 0, false + } +} + +func (r *Router) lookupOwnerMessage(ctx context.Context, userID int64, id int) (domain.Message, bool, error) { + filter := domain.MessageFilter{ + MinID: id - 1, + Limit: 1, + } + if id < domain.MaxMessageBoxID { + filter.MaxID = id + 1 + } + list, err := r.deps.Messages.Search(ctx, userID, filter) + if err != nil { + return domain.Message{}, false, err + } + if len(list.Messages) == 0 || list.Messages[0].ID != id { + return domain.Message{}, false, nil + } + return list.Messages[0], true, nil +} + +func draftClearUpdate(peer domain.Peer, topMessageID, date int) *tg.UpdateDraftMessage { + peerTL := tgPeer(peer) + if peerTL == nil { + return nil + } + draft := &tg.DraftMessageEmpty{} + draft.SetDate(date) + update := &tg.UpdateDraftMessage{Peer: peerTL, Draft: draft} + if topMessageID > 0 { + update.SetTopMsgID(topMessageID) + } + return update +} + +func draftReplyIsEmpty(reply tg.InputReplyToClass) bool { + if reply == nil { + return true + } + input, ok := reply.(*tg.InputReplyToMessage) + if !ok { + return false + } + topMsgID, hasTopMsgID := input.GetTopMsgID() + return input.ReplyToMsgID == 0 && hasTopMsgID && topMsgID > 0 +} + +func draftInputMedia(media tg.InputMediaClass) tg.InputMediaClass { + switch media.(type) { + case nil, *tg.InputMediaEmpty: + return nil + default: + return media + } +} + +func (r *Router) searchGlobalChannelOffsetID(ctx context.Context, userID int64, peer tg.InputPeerClass) (int64, error) { + if peer == nil { + return 0, nil + } + switch peer.(type) { + case *tg.InputPeerEmpty: + return 0, nil + } + ref, ok := inputPeerChannelRef(peer) + if !ok { + return 0, nil + } + if ref.ID <= 0 { + return 0, peerIDInvalidErr() + } + if ref.CheckAccessHash && r.deps.Channels != nil { + view, err := r.deps.Channels.ResolveChannel(ctx, userID, ref.ID) + if err != nil { + return 0, channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return 0, channelInvalidErr(domain.ErrChannelPrivate) + } + } + return ref.ID, nil +} + +func (s forwardSource) userID() int64 { + if s.from.Type == domain.PeerTypeUser { + return s.from.ID + } + if s.forward != nil && s.forward.From.Type == domain.PeerTypeUser { + return s.forward.From.ID + } + return 0 +} + +func (r *Router) onMessagesGetOutboxReadDate(ctx context.Context, req *tg.MessagesGetOutboxReadDateRequest) (*tg.OutboxReadDate, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 || r.deps.Messages == nil { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + date, err := r.deps.Messages.GetOutboxReadDate(ctx, userID, domain.OutboxReadDateRequest{ + OwnerUserID: userID, + Peer: peer, + ID: req.MsgID, + }) + if err != nil { + return nil, messageReadDateErr(err) + } + return &tg.OutboxReadDate{Date: date}, nil +} + +func (r *Router) onMessagesGetMessageReadParticipants(ctx context.Context, req *tg.MessagesGetMessageReadParticipantsRequest) ([]tg.ReadParticipantDate, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + if r.deps.Channels == nil { + return []tg.ReadParticipantDate{}, nil + } + res, err := r.deps.Channels.GetMessageReadParticipants(ctx, userID, domain.ChannelReadParticipantsRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.MsgID, + Limit: domain.MaxChannelReadParticipants, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, messageIDInvalidErr() + } + return nil, channelInvalidErr(err) + } + out := make([]tg.ReadParticipantDate, 0, len(res.Participants)) + for _, p := range res.Participants { + if p.UserID == 0 { + continue + } + out = append(out, tg.ReadParticipantDate{UserID: p.UserID, Date: p.Date}) + } + return out, nil +} + +func messageReadDateErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + case errors.Is(err, domain.ErrMessageNotReadYet): + return messageNotReadYetErr() + default: + return internalErr() + } +} + +func forwardMessagesUnsupportedOptionErr(req *tg.MessagesForwardMessagesRequest) error { + switch { + case req.QuickReplyShortcut != nil: + return shortcutInvalidErr() + case req.Effect != 0: + return effectIDInvalidErr() + case req.VideoTimestamp != 0: + return mediaInvalidErr() + case req.AllowPaidStars < 0: + return starsAmountInvalidErr() + case req.AllowPaidStars > 0 || req.AllowPaidFloodskip: + return paymentUnsupportedErr() + case !req.SuggestedPost.Zero(): + return suggestedPostPeerInvalidErr() + default: + return nil + } +} + +func (r *Router) metrics() Metrics { + if r.deps.Metrics == nil { + return NopMetrics{} + } + return r.deps.Metrics +} + +func (r *Router) domainFolderPeerFromInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.Peer, int64, error) { + if inputPeerClassNil(peer) { + return domain.Peer{}, 0, peerIDInvalidErr() + } + switch p := peer.(type) { + case *tg.InputPeerUser: + return domain.Peer{Type: domain.PeerTypeUser, ID: p.UserID}, p.AccessHash, nil + case *tg.InputPeerChannel: + out := domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID} + if err := r.validateInputPeerChannelAccess(ctx, userID, peer, p.ChannelID); err != nil { + return domain.Peer{}, 0, err + } + return out, p.AccessHash, nil + case *tg.InputPeerChannelFromMessage: + out := domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID} + if p.ChannelID <= 0 { + return domain.Peer{}, 0, peerIDInvalidErr() + } + return out, 0, nil + case *tg.InputPeerSelf: + if userID == 0 { + return domain.Peer{}, 0, peerIDInvalidErr() + } + var accessHash int64 + if r.deps.Users != nil { + if self, err := r.deps.Users.Self(ctx, userID); err == nil { + accessHash = self.AccessHash + } + } + return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, accessHash, nil + default: + return domain.Peer{}, 0, peerIDInvalidErr() + } +} + +func (r *Router) domainPeerFromInputPeer(userID int64, peer tg.InputPeerClass) (domain.Peer, bool) { + if inputPeerClassNil(peer) { + return domain.Peer{}, false + } + switch p := peer.(type) { + case *tg.InputPeerEmpty: + return domain.Peer{}, false + case *tg.InputPeerUser: + return domain.Peer{Type: domain.PeerTypeUser, ID: p.UserID}, true + case *tg.InputPeerChannel: + return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID}, true + case *tg.InputPeerChannelFromMessage: + return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChannelID}, p.ChannelID > 0 + case *tg.InputPeerChat: + return domain.Peer{Type: domain.PeerTypeChannel, ID: p.ChatID}, p.ChatID > 0 + case *tg.InputPeerSelf: + if userID == 0 { + return domain.Peer{}, false + } + return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, true + default: + return domain.Peer{}, false + } +} + +func inputPeerClassNil(peer tg.InputPeerClass) bool { + switch typed := peer.(type) { + case nil: + return true + case *tg.InputPeerEmpty: + return typed == nil + case *tg.InputPeerSelf: + return typed == nil + case *tg.InputPeerChat: + return typed == nil + case *tg.InputPeerUser: + return typed == nil + case *tg.InputPeerChannel: + return typed == nil + case *tg.InputPeerUserFromMessage: + return typed == nil + case *tg.InputPeerChannelFromMessage: + return typed == nil + default: + return false + } +} + +func isLegacyInputPeerChat(peer tg.InputPeerClass) bool { + typed, ok := peer.(*tg.InputPeerChat) + return ok && typed != nil +} + +func inputPeerChannelRef(peer tg.InputPeerClass) (channelInputRef, bool) { + switch p := peer.(type) { + case *tg.InputPeerChannel: + if p == nil { + return channelInputRef{}, false + } + return channelInputRef{ + ID: p.ChannelID, + AccessHash: p.AccessHash, + CheckAccessHash: p.AccessHash != 0, + }, p.ChannelID > 0 + case *tg.InputPeerChannelFromMessage: + if p == nil { + return channelInputRef{}, false + } + return channelInputRef{ID: p.ChannelID}, p.ChannelID > 0 + default: + return channelInputRef{}, false + } +} + +func (r *Router) validateInputPeerChannelAccess(ctx context.Context, userID int64, peer tg.InputPeerClass, channelID int64) error { + ref, ok := inputPeerChannelRef(peer) + if !ok || ref.ID != channelID || channelID <= 0 { + return nil + } + if !ref.CheckAccessHash || r.deps.Channels == nil { + return nil + } + // 这里只校验 InputPeerChannel 的 access_hash,不消费 dialog/read/unread/boost + // 等完整频道视图字段。走 ResolveChannel,避免 messages.search/getPeerSettings + // 这类高频入口为一次纯 access check 触发完整 GetChannel 投影。 + view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID) + if err != nil { + return channelInvalidErr(err) + } + if !inputChannelAccessHashMatches(ref, view.Channel) { + return channelInvalidErr(domain.ErrChannelPrivate) + } + return nil +} + +func (r *Router) resolveInputPeerChannelView(ctx context.Context, userID int64, peer tg.InputPeerClass, channelID int64) (domain.ChannelView, error) { + if channelID <= 0 || r.deps.Channels == nil { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + if ref, ok := inputPeerChannelRef(peer); ok { + if ref.ID != channelID || (ref.CheckAccessHash && !inputChannelAccessHashMatches(ref, view.Channel)) { + return domain.ChannelView{}, domain.ErrChannelPrivate + } + } + return view, nil +} + +func (r *Router) checkedDomainPeerFromInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.Peer, error) { + out, ok := r.domainPeerFromInputPeer(userID, peer) + if !ok || out.ID == 0 { + return domain.Peer{}, peerIDInvalidErr() + } + if out.Type == domain.PeerTypeChannel { + if err := r.validateInputPeerChannelAccess(ctx, userID, peer, out.ID); err != nil { + return domain.Peer{}, err + } + } + return out, nil +} + +func (r *Router) chatsForInputPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) []tg.ChatClass { + p, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil || p.Type != domain.PeerTypeChannel || p.ID == 0 || r.deps.Channels == nil { + return []tg.ChatClass{} + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, p.ID) + if err != nil || view.Channel.ID == 0 { + return []tg.ChatClass{} + } + return []tg.ChatClass{tgChannelChatForView(userID, view)} +} diff --git a/internal/rpc/messages_history.go b/internal/rpc/messages_history.go new file mode 100644 index 00000000..30509257 --- /dev/null +++ b/internal/rpc/messages_history.go @@ -0,0 +1,825 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "sort" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onMessagesSearchStickerSets(ctx context.Context, req *tg.MessagesSearchStickerSetsRequest) (tg.MessagesFoundStickerSetsClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if utf8.RuneCountInString(req.Q) > maxStickerSearchQLength { + return nil, limitInvalidErr() + } + if req.Hash != 0 { + return &tg.MessagesFoundStickerSetsNotModified{}, nil + } + return &tg.MessagesFoundStickerSets{ + Hash: 0, + Sets: []tg.StickerSetCoveredClass{}, + }, nil +} + +func (r *Router) onMessagesSearchStickers(ctx context.Context, req *tg.MessagesSearchStickersRequest) (tg.MessagesFoundStickersClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if req.Offset < 0 || req.Offset > domain.MaxMessageBoxID || req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + if utf8.RuneCountInString(req.Q) > maxStickerSearchQLength || utf8.RuneCountInString(req.Emoticon) > maxStickerSearchQLength { + return nil, limitInvalidErr() + } + if len(req.LangCode) > maxStickerSearchLangs { + return nil, limitInvalidErr() + } + for _, lang := range req.LangCode { + if err := validateEmojiLangCode(lang); err != nil { + return nil, err + } + } + if req.Hash != 0 { + return &tg.MessagesFoundStickersNotModified{}, nil + } + return &tg.MessagesFoundStickers{ + Hash: 0, + Stickers: []tg.DocumentClass{}, + }, nil +} + +func (r *Router) onMessagesGetSearchResultsCalendar(ctx context.Context, req *tg.MessagesGetSearchResultsCalendarRequest) (*tg.MessagesSearchResultsCalendar, error) { + if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || req.OffsetDate < 0 { + return nil, limitInvalidErr() + } + if err := r.validateSearchResultsPeer(ctx, req.Peer, req.GetSavedPeerID); err != nil { + return nil, err + } + minDate := req.OffsetDate + if minDate == 0 { + minDate = int(r.clock.Now().Unix()) + } + return &tg.MessagesSearchResultsCalendar{ + Count: 0, + MinDate: minDate, + MinMsgID: req.OffsetID, + Periods: []tg.SearchResultsCalendarPeriod{}, + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesGetSearchResultsPositions(ctx context.Context, req *tg.MessagesGetSearchResultsPositionsRequest) (*tg.MessagesSearchResultsPositions, error) { + if req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + if err := r.validateSearchResultsPeer(ctx, req.Peer, req.GetSavedPeerID); err != nil { + return nil, err + } + return &tg.MessagesSearchResultsPositions{ + Count: 0, + Positions: []tg.SearchResultPosition{}, + }, nil +} + +func (r *Router) validateSearchResultsPeer(ctx context.Context, peer tg.InputPeerClass, savedPeer func() (tg.InputPeerClass, bool)) error { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { + return err + } + if savedPeer == nil { + return nil + } + if input, ok := savedPeer(); ok && input != nil { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input); err != nil { + return err + } + } + return nil +} + +func (r *Router) onMessagesGetMessagesViews(ctx context.Context, req *tg.MessagesGetMessagesViewsRequest) (*tg.MessagesMessageViews, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if len(req.ID) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + for _, msgID := range req.ID { + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + } + views := make([]tg.MessageViews, len(req.ID)) + peer, peerErr := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if peerErr != nil { + return nil, peerErr + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil && len(req.ID) > 0 { + viewCounters, err := r.deps.Channels.GetMessageViews(ctx, userID, domain.ChannelMessageViewsRequest{ + UserID: userID, + ChannelID: peer.ID, + IDs: req.ID, + Increment: req.Increment, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + for i, id := range req.ID { + if count, ok := viewCounters.Views[id]; ok { + views[i].SetViews(count) + } + if replies := tgChannelMessageReplies(viewCounters.Replies[id]); replies != nil { + views[i].SetReplies(*replies) + } + } + channels, users := r.messageViewPeerObjects(ctx, userID, viewCounters) + return r.applyStoryMaxIDsToMessageViews(ctx, userID, &tg.MessagesMessageViews{ + Views: views, + Chats: tgChannels(userID, channels), + Users: r.tgUsersForViewer(userID, users), + }), nil + } + return r.applyStoryMaxIDsToMessageViews(ctx, userID, &tg.MessagesMessageViews{ + Views: views, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }), nil +} + +func (r *Router) messageViewPeerObjects(ctx context.Context, viewerUserID int64, result domain.ChannelMessageViewsResult) ([]domain.Channel, []domain.User) { + channels := []domain.Channel{} + if result.Channel.ID != 0 { + channels = append(channels, result.Channel) + } + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, peer := range result.Peers { + addDomainPeerRef(peer, result.Channel.ID, userIDs, channelIDs) + } + for _, replies := range result.Replies { + if replies == nil { + continue + } + if replies.ChannelID != 0 && replies.ChannelID != result.Channel.ID { + channelIDs[replies.ChannelID] = struct{}{} + } + for _, peer := range replies.RecentRepliers { + addDomainPeerRef(peer, result.Channel.ID, userIDs, channelIDs) + } + } + removeKnownChannelRefs(channelIDs, channels) + cache := newViewerPeerCache(r) + channels = mergeDomainChannels(channels, cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) + users := cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs)) + return channels, users +} + +func (r *Router) onMessagesGetSearchCounters(ctx context.Context, req *tg.MessagesGetSearchCountersRequest) ([]tg.MessagesSearchCounter, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if len(req.Filters) > maxMessageSearchFilters { + return nil, limitInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + pinnedCount := 0 + mediaCounts := domain.MediaCategoryCounts{} + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + // 只读 PinnedMessageID(Channel 字段):走轻量 ResolveChannel,省 dialog/读态/boost 查询。 + view, err := r.deps.Channels.ResolveChannel(ctx, userID, peer.ID) + if err != nil { + return nil, err + } + if view.Channel.PinnedMessageID > 0 { + pinnedCount = 1 + } + counts, err := r.mediaCountsForPeer(ctx, userID, peer) + if err != nil { + return nil, err + } + mediaCounts = counts + } + if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { + list, err := r.deps.Messages.Search(ctx, userID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + PinnedOnly: true, + Limit: 1, + NeedTotalCount: true, + }) + if err != nil { + return nil, err + } + pinnedCount = list.Count + counts, err := r.mediaCountsForPeer(ctx, userID, peer) + if err != nil { + return nil, err + } + mediaCounts = counts + } + counters := make([]tg.MessagesSearchCounter, 0, len(req.Filters)) + for _, filter := range req.Filters { + if filter == nil { + continue + } + count := 0 + if _, ok := filter.(*tg.InputMessagesFilterPinned); ok { + count = pinnedCount + } else if categories := mediaCategoriesForFilter(filter); len(categories) > 0 { + count = mediaCounts.CountAny(categories) + } + counters = append(counters, tg.MessagesSearchCounter{Filter: filter, Count: count}) + } + return counters, nil +} + +func (r *Router) onMessagesGetReplies(ctx context.Context, req *tg.MessagesGetRepliesRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := validateHistoryBounds(req.OffsetID, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { + return nil, err + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + replies, err := r.deps.Channels.GetReplies(ctx, userID, domain.ChannelRepliesFilter{ + ChannelID: peer.ID, + RootMessageID: req.MsgID, + OffsetID: req.OffsetID, + OffsetDate: req.OffsetDate, + AddOffset: req.AddOffset, + Limit: req.Limit, + MaxID: req.MaxID, + MinID: req.MinID, + Hash: req.Hash, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + if req.Hash != 0 && replies.Hash == req.Hash { + return &tg.MessagesMessagesNotModified{Count: replies.Count}, nil + } + return r.tgChannelHistoryMessages(ctx, userID, r.enrichChannelHistory(ctx, userID, replies)), nil + } + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesGetDiscussionMessage(ctx context.Context, req *tg.MessagesGetDiscussionMessageRequest) (*tg.MessagesDiscussionMessage, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel { + return nil, peerIDInvalidErr() + } + if r.deps.Channels == nil { + return &tg.MessagesDiscussionMessage{Chats: r.chatsForInputPeer(ctx, userID, req.Peer)}, nil + } + discussion, err := r.deps.Channels.GetDiscussionMessage(ctx, userID, peer.ID, req.MsgID) + if err != nil { + return nil, channelInvalidErr(err) + } + // 解析讨论帖/回复作者的 user/channel 实体(store 不填 Users),否则客户端拿不到 + // 作者实体需额外 getUser 兜底。 + discussion = r.enrichChannelDiscussion(ctx, userID, discussion) + return r.tgMessagesDiscussionMessage(ctx, userID, discussion), nil +} + +func (r *Router) onMessagesReadDiscussion(ctx context.Context, req *tg.MessagesReadDiscussionRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID || req.ReadMaxID < 0 || req.ReadMaxID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + if peer.Type != domain.PeerTypeChannel { + return false, peerIDInvalidErr() + } + if r.deps.Channels == nil { + return true, nil + } + now := int(r.clock.Now().Unix()) + // forum 话题已读:req.MsgID 是话题 id,推进 per-topic 水位(不碰频道级,消除话题间已读串扰), + // 向自己其它设备下发 DiscussionInbox、向话题内发送者下发 DiscussionOutbox 回执。不经 + // GetDiscussionMessage,避免话题根消息被裁剪/删除时整条已读 400(root 不存活不应阻塞标已读)。 + topicRes, terr := r.deps.Channels.ReadTopicHistory(ctx, userID, domain.ReadChannelTopicHistoryRequest{ + UserID: userID, + ChannelID: peer.ID, + TopicID: req.MsgID, + MaxID: req.ReadMaxID, + Date: now, + }) + if terr == nil { + if topicRes.Changed { + if err := r.recordChannelDiscussionInbox(ctx, userID, peer.ID, topicRes.TopicID, topicRes.MaxID, topicRes.Pts); err != nil { + return false, err + } + r.pushChannelDiscussionOutboxUpdates(ctx, peer.ID, topicRes.TopicID, topicRes.OutboxUpdates) + } + // 保守叠加:同时推进频道级 inbox 水位,保持 getDialogs/getPeerDialogs 会话总未读不退化。 + if read, rerr := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{ + UserID: userID, + ChannelID: peer.ID, + MaxID: req.ReadMaxID, + Date: now, + }); rerr == nil { + if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil { + return false, err + } + r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates) + } + return topicRes.Changed, nil + } + if !errors.Is(terr, domain.ErrChannelForumMissing) { + return false, channelInvalidErr(terr) + } + // 非 forum(频道-讨论组 linked comments):保持原频道级已读链路。 + discussion, err := r.deps.Channels.GetDiscussionMessage(ctx, userID, peer.ID, req.MsgID) + if err != nil { + return false, channelInvalidErr(err) + } + readChannelID := peer.ID + if discussion.DiscussionChannel.ID != 0 { + readChannelID = discussion.DiscussionChannel.ID + } + read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{ + UserID: userID, + ChannelID: readChannelID, + MaxID: req.ReadMaxID, + Date: now, + }) + if err != nil { + return false, channelInvalidErr(err) + } + if _, err := r.recordChannelReadInbox(ctx, userID, read); err != nil { + return false, err + } + r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates) + return read.Changed, nil +} + +func (r *Router) onMessagesGetOnlines(ctx context.Context, peer tg.InputPeerClass) (*tg.ChatOnlines, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + domainPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return nil, err + } + if domainPeer.Type == domain.PeerTypeChannel && domainPeer.ID != 0 { + return &tg.ChatOnlines{Onlines: r.channelOnlineCount(ctx, userID, domainPeer.ID)}, nil + } + return &tg.ChatOnlines{Onlines: 1}, nil +} + +func (r *Router) onMessagesGetMessages(ctx context.Context, ids []tg.InputMessageClass) (tg.MessagesMessagesClass, error) { + if len(ids) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + if r.deps.Messages == nil || len(ids) == 0 { + return &tg.MessagesMessages{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + + out := make([]tg.MessageClass, 0, len(ids)) + requestedIDs := make([]int, 0, len(ids)) + for _, input := range ids { + id, ok := inputMessageBoxID(input) + if !ok || id <= 0 || id > domain.MaxMessageBoxID { + continue + } + requestedIDs = append(requestedIDs, id) + } + list, err := r.deps.Messages.GetMessages(ctx, userID, requestedIDs) + if err != nil { + return nil, internalErr() + } + foundByID := make(map[int]domain.Message, len(list.Messages)) + for _, msg := range list.Messages { + foundByID[msg.ID] = msg + } + found := make([]domain.Message, 0, len(list.Messages)) + for _, input := range ids { + id, ok := inputMessageBoxID(input) + if !ok || id <= 0 || id > domain.MaxMessageBoxID { + out = append(out, &tg.MessageEmpty{ID: id}) + continue + } + msg, ok := foundByID[id] + if !ok { + out = append(out, &tg.MessageEmpty{ID: id}) + continue + } + found = append(found, msg) + out = append(out, tgMessage(msg)) + } + chats := r.chatsForMessageUpdates(ctx, userID, found) + return &tg.MessagesMessages{ + Messages: out, + Users: r.usersForMessageUpdates(ctx, userID, found), + Chats: chats, + }, nil +} + +// onMessagesGetRichMessage 返回单条消息的完整富文本(Layer 227 richMessage)。消息列表 +// 投影里已带 richMessage(tgMessage),本 RPC 是客户端按 (peer,id) 拉取完整富文本的入口。 +// Phase 1 仅私聊:按 box id 从请求者自己的消息盒取出并投影;频道侧富文本留 Phase 2。 +func (r *Router) onMessagesGetRichMessage(ctx context.Context, req *tg.MessagesGetRichMessageRequest) (tg.MessagesMessagesClass, error) { + if r.deps.Messages == nil || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return &tg.MessagesMessages{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 { + return nil, peerIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel { + // 频道富文本留 Phase 2(channel_message 富文本列已备未接线)。 + return &tg.MessagesMessages{}, nil + } + list, err := r.deps.Messages.GetMessages(ctx, userID, []int{req.ID}) + if err != nil { + return nil, internalErr() + } + out := make([]tg.MessageClass, 0, 1) + found := make([]domain.Message, 0, 1) + for _, msg := range list.Messages { + if msg.ID != req.ID || msg.Peer != peer { + continue + } + found = append(found, msg) + out = append(out, tgMessage(msg)) + } + if len(out) == 0 { + out = append(out, &tg.MessageEmpty{ID: req.ID}) + } + return &tg.MessagesMessages{ + Messages: out, + Users: r.usersForMessageUpdates(ctx, userID, found), + Chats: r.chatsForMessageUpdates(ctx, userID, found), + }, nil +} + +func (r *Router) onMessagesSearchGlobal(ctx context.Context, req *tg.MessagesSearchGlobalRequest) (tg.MessagesMessagesClass, error) { + if req.BroadcastsOnly && req.GroupsOnly { + return &tg.MessagesMessages{}, nil + } + query := normalizeSearchQuery(req.Q) + musicOnly := messagesSearchFilterMusic(req.Filter) + if query == "" && !musicOnly { + return nil, searchQueryEmptyErr() + } + if utf8.RuneCountInString(query) > maxMessageSearchQLength { + return nil, limitInvalidErr() + } + if !musicOnly && searchFilterNeedsMediaStore(req.Filter) { + return &tg.MessagesMessages{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit { + limit = domain.MaxChannelGlobalSearchLimit + } + folderID, hasFolderID := req.GetFolderID() + if hasFolderID && folderID < 0 { + return nil, folderIDInvalidErr() + } + channelOffsetID, err := r.searchGlobalChannelOffsetID(ctx, userID, req.OffsetPeer) + if err != nil { + return nil, err + } + var private domain.MessageList + if !req.BroadcastsOnly && !req.GroupsOnly && r.deps.Messages != nil { + filter := domain.MessageFilter{ + Query: query, + OffsetID: req.OffsetID, + OffsetDate: req.OffsetRate, + Limit: limit + 1, + MusicOnly: musicOnly, + } + if req.MaxDate > 0 { + filter.OffsetDate = req.MaxDate + } + if req.UsersOnly || !req.BroadcastsOnly && !req.GroupsOnly { + private, err = r.deps.Messages.Search(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + private = r.enrichMessageList(ctx, userID, private) + } + } + if req.UsersOnly || r.deps.Channels == nil { + return tgMessagesMessages(userID, r.enrichMessageList(ctx, userID, limitMessageList(private, limit))), nil + } + channelHistory, err := r.deps.Channels.SearchJoinedMessages(ctx, userID, domain.ChannelGlobalSearchRequest{ + Query: query, + BroadcastsOnly: req.BroadcastsOnly, + GroupsOnly: req.GroupsOnly, + MusicOnly: musicOnly, + HasFolderID: hasFolderID, + FolderID: folderID, + OffsetRate: req.OffsetRate, + OffsetChannelID: channelOffsetID, + OffsetID: req.OffsetID, + MinDate: req.MinDate, + MaxDate: req.MaxDate, + Limit: limit, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + channelHistory = r.enrichChannelHistory(ctx, userID, channelHistory) + if req.BroadcastsOnly || req.GroupsOnly { + return r.tgGlobalChannelMessages(ctx, userID, limitChannelHistory(channelHistory, limit)), nil + } + return r.tgGlobalSearchMessages(ctx, userID, limit, private, channelHistory), nil +} + +func limitMessageList(list domain.MessageList, limit int) domain.MessageList { + if limit <= 0 { + limit = domain.MaxChannelGlobalSearchLimit + } + if len(list.Messages) > limit { + list.Messages = list.Messages[:limit] + list.Count = limit + 1 + } + return list +} + +func limitChannelHistory(history domain.ChannelHistory, limit int) domain.ChannelHistory { + if limit <= 0 { + limit = domain.MaxChannelGlobalSearchLimit + } + if len(history.Messages) > limit { + history.Messages = history.Messages[:limit] + history.Count = limit + 1 + } + return history +} + +func tgGlobalChannelMessages(viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass { + out := tgChannelHistoryMessages(viewerUserID, history) + if slice, ok := out.(*tg.MessagesMessagesSlice); ok && len(history.Messages) > 0 { + slice.SetNextRate(history.Messages[len(history.Messages)-1].Date) + } + return out +} + +func tgGlobalSearchMessages(viewerUserID int64, limit int, private domain.MessageList, channel domain.ChannelHistory) tg.MessagesMessagesClass { + if limit <= 0 { + limit = domain.MaxChannelGlobalSearchLimit + } + hits := make([]globalSearchHit, 0, len(private.Messages)+len(channel.Messages)) + for _, msg := range private.Messages { + item := tgMessage(msg) + if item == nil { + continue + } + hits = append(hits, globalSearchHit{ + date: msg.Date, + peerRank: msg.Peer.ID, + messageID: msg.ID, + message: item, + }) + } + for _, msg := range channel.Messages { + item := tgChannelMessage(viewerUserID, msg) + if item == nil { + continue + } + hits = append(hits, globalSearchHit{ + date: msg.Date, + peerRank: msg.ChannelID, + messageID: msg.ID, + message: item, + }) + } + sort.Slice(hits, func(i, j int) bool { + a, b := hits[i], hits[j] + if a.date != b.date { + return a.date > b.date + } + if a.peerRank != b.peerRank { + return a.peerRank > b.peerRank + } + return a.messageID > b.messageID + }) + hasMore := private.Count > len(private.Messages) || channel.Count > len(channel.Messages) || len(hits) > limit + if len(hits) > limit { + hits = hits[:limit] + } + messages := make([]tg.MessageClass, 0, len(hits)) + for _, hit := range hits { + messages = append(messages, hit.message) + } + // 全局搜索命中自己发的消息时 viewer 自己会出现在 users 里,须带 self 标志。 + users := append(tgUsersForViewer(viewerUserID, private.Users), tgUsersForViewer(viewerUserID, channel.Users)...) + chats := tgChannels(viewerUserID, channel.Channels) + if hasMore { + out := &tg.MessagesMessagesSlice{ + Count: limit + 1, + Messages: messages, + Chats: chats, + Users: users, + } + if len(hits) > 0 { + out.SetNextRate(hits[len(hits)-1].date) + } + return out + } + return &tg.MessagesMessages{Messages: messages, Chats: chats, Users: users} +} + +func (r *Router) messageFilterFromHistoryRequest(userID int64, req *tg.MessagesGetHistoryRequest) (domain.MessageFilter, bool) { + peer, ok := r.domainPeerFromInputPeer(userID, req.Peer) + if !ok { + return domain.MessageFilter{}, false + } + limit := req.Limit + if limit > 50 { + limit = 50 + } + return domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: req.OffsetID, + OffsetDate: req.OffsetDate, + AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), + Limit: limit, + MaxID: req.MaxID, + MinID: req.MinID, + Hash: req.Hash, + }, true +} + +func (r *Router) messageFilterFromSearchRequest(userID int64, req *tg.MessagesSearchRequest) domain.MessageFilter { + limit := req.Limit + if limit > 500 { + limit = 500 + } + filter := domain.MessageFilter{ + Query: req.Q, + OffsetID: req.OffsetID, + AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), + Limit: limit, + MaxID: req.MaxID, + MinID: req.MinID, + Hash: req.Hash, + MusicOnly: messagesSearchFilterMusic(req.Filter), + NeedTotalCount: req.OffsetID == 0 && req.MinDate == 0 && req.MaxDate == 0 && req.AddOffset >= 0 && req.Hash == 0, + } + if peer, ok := r.domainPeerFromInputPeer(userID, req.Peer); ok { + filter.HasPeer = true + filter.Peer = peer + } + return filter +} + +func (r *Router) channelHistoryFilterFromSearchRequest(userID int64, req *tg.MessagesSearchRequest, channelID int64) (domain.ChannelHistoryFilter, bool) { + limit := req.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + filter := domain.ChannelHistoryFilter{ + ChannelID: channelID, + Query: req.Q, + PinnedOnly: messagesSearchFilterPinned(req.Filter), + MusicOnly: messagesSearchFilterMusic(req.Filter), + OffsetID: req.OffsetID, + AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), + Limit: limit, + MinDate: req.MinDate, + MaxDate: req.MaxDate, + MaxID: req.MaxID, + MinID: req.MinID, + Hash: req.Hash, + } + if req.FromID != nil { + from, ok := r.domainPeerFromInputPeer(userID, req.FromID) + if !ok || from.Type != domain.PeerTypeUser || from.ID == 0 { + return domain.ChannelHistoryFilter{}, false + } + filter.SenderUserID = from.ID + } + return filter, true +} + +func messagesSearchFilterPinned(filter tg.MessagesFilterClass) bool { + _, ok := filter.(*tg.InputMessagesFilterPinned) + return ok +} + +func messagesSearchFilterMusic(filter tg.MessagesFilterClass) bool { + _, ok := filter.(*tg.InputMessagesFilterMusic) + return ok +} + +func searchFilterNeedsMediaStore(filter tg.MessagesFilterClass) bool { + switch filter.(type) { + case nil, *tg.InputMessagesFilterEmpty: + return false + case *tg.InputMessagesFilterPhotos, + *tg.InputMessagesFilterVideo, + *tg.InputMessagesFilterPhotoVideo, + *tg.InputMessagesFilterDocument, + *tg.InputMessagesFilterMusic, + *tg.InputMessagesFilterURL, + *tg.InputMessagesFilterGif, + *tg.InputMessagesFilterVoice, + *tg.InputMessagesFilterRoundVoice, + *tg.InputMessagesFilterRoundVideo, + *tg.InputMessagesFilterPoll: + return true + default: + return false + } +} + +// mediaCategoriesForFilter 把客户端共享媒体标签页过滤器映射为媒体索引的基础类别并集。 +// 复合标签页(PhotoVideo / RoundVoice)映射为多个基础类别;返回 nil 表示该过滤器不走媒体索引。 +func mediaCategoriesForFilter(filter tg.MessagesFilterClass) []domain.MediaCategory { + switch filter.(type) { + case *tg.InputMessagesFilterPhotos: + return []domain.MediaCategory{domain.MediaCategoryPhoto} + case *tg.InputMessagesFilterVideo: + return []domain.MediaCategory{domain.MediaCategoryVideo} + case *tg.InputMessagesFilterPhotoVideo: + return []domain.MediaCategory{domain.MediaCategoryPhoto, domain.MediaCategoryVideo} + case *tg.InputMessagesFilterDocument: + return []domain.MediaCategory{domain.MediaCategoryFile} + case *tg.InputMessagesFilterMusic: + return []domain.MediaCategory{domain.MediaCategoryMusic} + case *tg.InputMessagesFilterURL: + return []domain.MediaCategory{domain.MediaCategoryURL} + case *tg.InputMessagesFilterGif: + return []domain.MediaCategory{domain.MediaCategoryGif} + case *tg.InputMessagesFilterVoice: + return []domain.MediaCategory{domain.MediaCategoryVoice} + case *tg.InputMessagesFilterRoundVideo: + return []domain.MediaCategory{domain.MediaCategoryRoundVideo} + case *tg.InputMessagesFilterRoundVoice: + return []domain.MediaCategory{domain.MediaCategoryVoice, domain.MediaCategoryRoundVideo} + case *tg.InputMessagesFilterPoll: + return []domain.MediaCategory{domain.MediaCategoryPoll} + default: + return nil + } +} + +func messagesNotModifiedOrEmpty(hash int64) tg.MessagesMessagesClass { + if hash != 0 { + return &tg.MessagesMessagesNotModified{Count: 0} + } + return &tg.MessagesMessages{} +} diff --git a/internal/rpc/messages_history_rpc_test.go b/internal/rpc/messages_history_rpc_test.go new file mode 100644 index 00000000..6068426b --- /dev/null +++ b/internal/rpc/messages_history_rpc_test.go @@ -0,0 +1,674 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strings" + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +func TestMessagesSearchChannelPeerReturnsSingleCopyMessages(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 35, Phone: "15550002035", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 36, Phone: "15550002036", FirstName: "Friend"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, + Title: "RPC Search Group", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) + pinnedMsgID := 0 + for _, item := range []struct { + userID int64 + text string + random int64 + }{ + {owner.ID, "needle from owner", 5001}, + {friend.ID, "not this one", 5002}, + {friend.ID, "needle from friend", 5003}, + } { + sent, err := r.onMessagesSendMessage(WithUserID(ctx, item.userID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Message: item.text, + RandomID: item.random, + }) + if err != nil { + t.Fatalf("send %q: %v", item.text, err) + } + if item.text == "not this one" { + channelUpdates := sent.(*tg.Updates) + if len(channelUpdates.Updates) == 0 { + t.Fatalf("send %q updates = %+v, want updateMessageID", item.text, channelUpdates.Updates) + } + pinnedMsgID = channelUpdates.Updates[0].(*tg.UpdateMessageID).ID + } + } + + req := &tg.MessagesSearchRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Q: "needle", + Filter: &tg.InputMessagesFilterEmpty{}, + Limit: 10, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode search: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, friend.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch search: %v", err) + } + messages, chats, users := searchMessagesPayload(t, enc) + if len(messages) != 2 || len(chats) != 1 || len(users) < 2 { + t.Fatalf("search payload sizes = messages %d chats %d users %d, want 2/1/2+", len(messages), len(chats), len(users)) + } + for _, msg := range messages { + item := msg.(*tg.Message) + if !strings.Contains(item.Message, "needle") { + t.Fatalf("search result message = %q, want only needle hits", item.Message) + } + } + + fromReq := *req + fromReq.SetFromID(&tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}) + in.Reset() + if err := fromReq.Encode(&in); err != nil { + t.Fatalf("encode from search: %v", err) + } + enc, err = r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch from search: %v", err) + } + messages, _, _ = searchMessagesPayload(t, enc) + if len(messages) != 1 || messages[0].(*tg.Message).Message != "needle from friend" { + t.Fatalf("from search messages = %+v, want friend needle only", messages) + } + + mediaCountReq := &tg.MessagesSearchRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.InputMessagesFilterPhotos{}, + Limit: 0, + } + in.Reset() + if err := mediaCountReq.Encode(&in); err != nil { + t.Fatalf("encode shared media count search: %v", err) + } + enc, err = r.Dispatch(WithUserID(ctx, friend.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch shared media count search: %v", err) + } + if box, ok := enc.(*tg.MessagesMessagesBox); ok { + enc = box.Messages + } + channelMessages, ok := enc.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("shared media count search result = %T, want messages.channelMessages", enc) + } + if channelMessages.Count != 0 || len(channelMessages.Messages) != 0 { + t.Fatalf("shared media count search = count %d messages %d, want empty without media store", channelMessages.Count, len(channelMessages.Messages)) + } + + if _, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, owner.ID), &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + ID: pinnedMsgID, + }); err != nil { + t.Fatalf("pin channel message: %v", err) + } + pinnedReq := &tg.MessagesSearchRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.InputMessagesFilterPinned{}, + Limit: 40, + } + in.Reset() + if err := pinnedReq.Encode(&in); err != nil { + t.Fatalf("encode pinned search: %v", err) + } + enc, err = r.Dispatch(WithUserID(ctx, friend.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch pinned search: %v", err) + } + pinnedMessages, _, _ := searchMessagesPayload(t, enc) + if len(pinnedMessages) != 1 { + t.Fatalf("pinned search returned %d messages, want 1", len(pinnedMessages)) + } + pinnedMessage, ok := pinnedMessages[0].(*tg.Message) + if !ok || pinnedMessage.ID != pinnedMsgID || !pinnedMessage.GetPinned() { + t.Fatalf("pinned search message = %#v, want pinned message id=%d", pinnedMessages[0], pinnedMsgID) + } +} + +func TestMessagesGetSearchCountersUsesMediaCategoryCounts(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 93501, Phone: "15550093501", FirstName: "Owner"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Channels: channelService, + }, zaptest.NewLogger(t), clock.System) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Media Counters", Megagroup: true, Date: 1700035000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.Channel + docMedia := func(id int64, attrs ...domain.DocumentAttribute) *domain.MessageMedia { + return &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &domain.Document{ID: id, AccessHash: id + 10, Attributes: attrs}} + } + send := func(randomID int64, msg string, media *domain.MessageMedia, entities []domain.MessageEntity) { + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: channel.ID, + RandomID: randomID, + Message: msg, + Media: media, + Entities: entities, + Date: 1700035000 + int(randomID), + }); err != nil { + t.Fatalf("send %d: %v", randomID, err) + } + } + send(1, "photo https://x.test", &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 1, AccessHash: 11}}, []domain.MessageEntity{{Type: domain.MessageEntityURL, Offset: 6, Length: 14}}) + send(2, "video", docMedia(2, domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: 1, H: 1}), nil) + send(3, "file", docMedia(3, domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: "a.bin"}), nil) + send(4, "music", docMedia(4, domain.DocumentAttribute{Kind: domain.DocAttrAudio, Title: "song"}), nil) + send(5, "voice", docMedia(5, domain.DocumentAttribute{Kind: domain.DocAttrAudio, Voice: true}), nil) + send(6, "round", docMedia(6, domain.DocumentAttribute{Kind: domain.DocAttrVideo, RoundMessage: true}), nil) + send(7, "gif", docMedia(7, domain.DocumentAttribute{Kind: domain.DocAttrAnimated}), nil) + send(8, "poll", &domain.MessageMedia{Kind: domain.MessageMediaKindPoll}, nil) + + filters := []tg.MessagesFilterClass{ + &tg.InputMessagesFilterPhotos{}, + &tg.InputMessagesFilterPhotoVideo{}, + &tg.InputMessagesFilterDocument{}, + &tg.InputMessagesFilterMusic{}, + &tg.InputMessagesFilterURL{}, + &tg.InputMessagesFilterGif{}, + &tg.InputMessagesFilterVoice{}, + &tg.InputMessagesFilterRoundVideo{}, + &tg.InputMessagesFilterRoundVoice{}, + &tg.InputMessagesFilterPoll{}, + &tg.InputMessagesFilterChatPhotos{}, + } + counters, err := r.onMessagesGetSearchCounters(WithUserID(ctx, owner.ID), &tg.MessagesGetSearchCountersRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filters: filters, + }) + if err != nil { + t.Fatalf("messages.getSearchCounters: %v", err) + } + want := []int{1, 2, 1, 1, 1, 1, 1, 1, 2, 1, 0} + if len(counters) != len(want) { + t.Fatalf("got %d counters, want %d", len(counters), len(want)) + } + for i, counter := range counters { + if counter.Count != want[i] { + t.Fatalf("counter[%d] %T = %d, want %d", i, counter.Filter, counter.Count, want[i]) + } + if counter.Inexact { + t.Fatalf("counter[%d] is inexact, want exact", i) + } + } + + searchReq := &tg.MessagesSearchRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.InputMessagesFilterPhotoVideo{}, + Limit: 0, + } + var in bin.Buffer + if err := searchReq.Encode(&in); err != nil { + t.Fatalf("encode shared media count search: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch shared media count search: %v", err) + } + if box, ok := enc.(*tg.MessagesMessagesBox); ok { + enc = box.Messages + } + channelMessages, ok := enc.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("shared media count search result = %T, want messages.channelMessages", enc) + } + if channelMessages.Count != 2 || len(channelMessages.Messages) != 0 { + t.Fatalf("shared media count search = count %d messages %d, want count 2 and no messages", channelMessages.Count, len(channelMessages.Messages)) + } +} + +func TestMessagesGetSearchCountersPropagatesMediaCountError(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 93502, Phone: "15550093502", FirstName: "Owner"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Media Counter Error", Megagroup: true, Date: 1700035100, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + wantErr := errors.New("media count store down") + r := New(Config{}, Deps{ + Channels: &failingMediaCountChannelsService{Service: channelService, err: wantErr}, + }, zaptest.NewLogger(t), clock.System) + + _, err = r.onMessagesGetSearchCounters(WithUserID(ctx, owner.ID), &tg.MessagesGetSearchCountersRequest{ + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + Filters: []tg.MessagesFilterClass{&tg.InputMessagesFilterURL{}}, + }) + if !errors.Is(err, wantErr) { + t.Fatalf("messages.getSearchCounters error = %v, want %v", err, wantErr) + } +} + +type failingMediaCountChannelsService struct { + *appchannels.Service + err error +} + +func (s *failingMediaCountChannelsService) CountChannelMediaCategories(context.Context, int64, int64) (domain.MediaCategoryCounts, error) { + return nil, s.err +} + +func TestMessagesSearchGlobalReturnsJoinedChannelMessages(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91101, Phone: "15550091101", FirstName: "Owner"}) + viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 91102, Phone: "15550091102", FirstName: "Viewer"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), clock.System) + joined, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Joined Broadcast", + Broadcast: true, + Date: 1700020000, + }) + if err != nil { + t.Fatalf("create joined channel: %v", err) + } + if _, err := channelService.InviteToChannel(ctx, owner.ID, joined.Channel.ID, []int64{viewer.ID}, 1700020001); err != nil { + t.Fatalf("invite viewer: %v", err) + } + hidden, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Hidden Broadcast", + Broadcast: true, + Date: 1700020002, + }) + if err != nil { + t.Fatalf("create hidden channel: %v", err) + } + for i, body := range []string{"global needle older", "global needle newer"} { + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: joined.Channel.ID, + RandomID: int64(201 + i), + Message: body, + Date: 1700020010 + i*10, + }); err != nil { + t.Fatalf("send joined %d: %v", i, err) + } + } + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: hidden.Channel.ID, + RandomID: 301, + Message: "global needle hidden", + Date: 1700020030, + }); err != nil { + t.Fatalf("send hidden: %v", err) + } + + req := &tg.MessagesSearchGlobalRequest{ + BroadcastsOnly: true, + Q: "global needle", + Filter: &tg.InputMessagesFilterEmpty{}, + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 1, + } + got, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), req) + if err != nil { + t.Fatalf("messages.searchGlobal first page: %v", err) + } + slice, ok := got.(*tg.MessagesMessagesSlice) + if !ok { + t.Fatalf("first page = %T %+v, want messagesSlice", got, got) + } + if nextRate, ok := slice.GetNextRate(); !ok || nextRate != 1700020020 { + t.Fatalf("first page next_rate = %d ok %v, want newest date", nextRate, ok) + } + messages, chats, users := searchMessagesPayload(t, got) + if len(messages) != 1 || len(chats) != 1 || len(users) != 1 { + t.Fatalf("first payload messages=%d chats=%d users=%d, want 1/1/1", len(messages), len(chats), len(users)) + } + first := messages[0].(*tg.Message) + if first.Message != "global needle newer" { + t.Fatalf("first message = %q, want newest joined channel hit", first.Message) + } + if peer, ok := first.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != joined.Channel.ID { + t.Fatalf("first peer = %#v, want joined channel %d", first.PeerID, joined.Channel.ID) + } + + page2 := &tg.MessagesSearchGlobalRequest{ + BroadcastsOnly: true, + Q: "global needle", + Filter: &tg.InputMessagesFilterEmpty{}, + OffsetRate: slice.NextRate, + OffsetPeer: &tg.InputPeerChannel{ + ChannelID: joined.Channel.ID, + AccessHash: joined.Channel.AccessHash, + }, + OffsetID: first.ID, + Limit: 10, + } + got, err = r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), page2) + if err != nil { + t.Fatalf("messages.searchGlobal second page: %v", err) + } + messages, chats, _ = searchMessagesPayload(t, got) + if len(messages) != 1 || len(chats) != 1 { + t.Fatalf("second payload messages=%d chats=%d, want older joined hit only", len(messages), len(chats)) + } + if msg := messages[0].(*tg.Message); msg.Message != "global needle older" { + t.Fatalf("second message = %q, want older joined hit", msg.Message) + } +} + +func TestMessagesGetHistoryReturnsStoredMessages(t *testing.T) { + msg := domain.Message{ + ID: 1, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000100, + Body: "Login code: 12345", + } + messages := &captureMessages{ + list: domain.MessageList{ + Messages: []domain.Message{msg}, + Users: []domain.User{domain.OfficialSystemUser()}, + Count: 1, + Hash: 99, + }, + } + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, + Limit: 20, + AddOffset: 1 << 30, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.MessagesMessagesBox) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesMessagesBox", enc) + } + got, ok := box.Messages.(*tg.MessagesMessages) + if !ok { + t.Fatalf("boxed response = %T, want *tg.MessagesMessages", box.Messages) + } + if len(got.Messages) != 1 || len(got.Users) != 1 { + t.Fatalf("history = %+v, want one message and one user", got) + } + if messages.filter.Peer.ID != domain.OfficialSystemUserID || messages.filter.Limit != 20 || messages.filter.AddOffset != domain.MaxMessageHistoryAddOffset { + t.Fatalf("filter = %+v, want official peer limit 20 and clamped add_offset", messages.filter) + } +} + +func TestMessagesSetTypingPushesUserTypingUpdate(t *testing.T) { + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System) + var authKeyID [8]byte + authKeyID[0] = 7 + + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + ok, err := r.onMessagesSetTyping(ctx, &tg.MessagesSetTypingRequest{ + Peer: &tg.InputPeerUser{UserID: 1000000002, AccessHash: 22}, + Action: &tg.SendMessageTypingAction{}, + }) + if err != nil { + t.Fatalf("set typing: %v", err) + } + if !ok { + t.Fatalf("set typing = false, want true") + } + + got := sessions.snapshot() + if got.userID != 1000000002 || got.sessionID != 55 || got.messageType != proto.MessageFromServer { + t.Fatalf("push = user %d exclude session %d type %v, want target/exclude/from_server", got.userID, got.sessionID, got.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { + t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) + } + updateShort, ok := got.message.(*tg.UpdateShort) + if !ok { + t.Fatalf("pushed message = %T, want *tg.UpdateShort", got.message) + } + typing, ok := updateShort.Update.(*tg.UpdateUserTyping) + if !ok { + t.Fatalf("short update = %T, want *tg.UpdateUserTyping", updateShort.Update) + } + if typing.UserID != 1000000001 { + t.Fatalf("typing user_id = %d, want sender", typing.UserID) + } + if _, ok := typing.Action.(*tg.SendMessageTypingAction); !ok { + t.Fatalf("typing action = %T, want *tg.SendMessageTypingAction", typing.Action) + } +} + +func TestMessagesSetTypingRejectsInvalidTopMsgID(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesSetTypingRequest{ + Peer: &tg.InputPeerUser{UserID: 1000000002, AccessHash: 22}, + Action: &tg.SendMessageTypingAction{}, + } + req.SetTopMsgID(domain.MaxMessageBoxID + 1) + + ok, err := r.onMessagesSetTyping(WithUserID(context.Background(), 1000000001), req) + if ok || err == nil || !strings.Contains(err.Error(), "MSG_ID_INVALID") { + t.Fatalf("set typing invalid top_msg_id = ok %v err %v, want MSG_ID_INVALID", ok, err) + } +} + +func TestMessagesSetTypingTreatsWebAMainThreadSentinelAsUnset(t *testing.T) { + sessions := &captureScopedSessions{captureSessions: &captureSessions{}} + r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesSetTypingRequest{ + Peer: &tg.InputPeerUser{UserID: 1000000002, AccessHash: 22}, + Action: &tg.SendMessageTypingAction{}, + } + req.SetTopMsgID(-1) + + ok, err := r.onMessagesSetTyping(WithUserID(context.Background(), 1000000001), req) + if err != nil { + t.Fatalf("set typing with WebA main thread sentinel: %v", err) + } + if !ok { + t.Fatalf("set typing = false, want true") + } + got := sessions.snapshot() + updateShort, ok := got.message.(*tg.UpdateShort) + if !ok { + t.Fatalf("pushed message = %T, want *tg.UpdateShort", got.message) + } + typing, ok := updateShort.Update.(*tg.UpdateUserTyping) + if !ok { + t.Fatalf("short update = %T, want *tg.UpdateUserTyping", updateShort.Update) + } + if typing.TopMsgID != 0 { + t.Fatalf("typing top_msg_id = %d, want unset/0", typing.TopMsgID) + } +} + +func TestMessagesSetTypingPushesChannelTypingTopMsgID(t *testing.T) { + const ( + ownerID = int64(1000000001) + memberID = int64(1000000002) + topicID = 7 + ) + channels := appchannels.NewService(memory.NewChannelStore()) + created, err := channels.CreateChannel(context.Background(), ownerID, domain.CreateChannelRequest{ + Title: "topic group", + CreatorUserID: ownerID, + Megagroup: true, + MemberUserIDs: []int64{memberID}, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sessions := &captureScopedSessions{captureSessions: &captureSessions{ + channelViewers: map[int64][]int64{created.Channel.ID: {memberID}}, + }} + r := New(Config{}, Deps{Channels: channels, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + var authKeyID [8]byte + authKeyID[0] = 9 + + req := &tg.MessagesSetTypingRequest{ + Peer: &tg.InputPeerChannel{ + ChannelID: created.Channel.ID, + AccessHash: created.Channel.AccessHash, + }, + Action: &tg.SendMessageTypingAction{}, + } + req.SetTopMsgID(topicID) + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), ownerID), authKeyID), 77) + + ok, err := r.onMessagesSetTyping(ctx, req) + if err != nil { + t.Fatalf("set channel typing: %v", err) + } + if !ok { + t.Fatalf("set channel typing = false, want true") + } + got := sessions.snapshot() + if got.userID != memberID || got.sessionID != 77 || got.messageType != proto.MessageFromServer { + t.Fatalf("channel typing push = user %d exclude session %d type %v, want member/exclude/from_server", got.userID, got.sessionID, got.messageType) + } + if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { + t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) + } + updates, ok := got.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", got.message) + } + if len(updates.Updates) != 1 { + t.Fatalf("updates len = %d, want 1", len(updates.Updates)) + } + typing, ok := updates.Updates[0].(*tg.UpdateChannelUserTyping) + if !ok { + t.Fatalf("channel update = %T, want *tg.UpdateChannelUserTyping", updates.Updates[0]) + } + if typing.ChannelID != created.Channel.ID || typing.TopMsgID != topicID { + t.Fatalf("channel typing = channel %d top %d, want channel %d top %d", typing.ChannelID, typing.TopMsgID, created.Channel.ID, topicID) + } + from, ok := typing.FromID.(*tg.PeerUser) + if !ok || from.UserID != ownerID { + t.Fatalf("typing from = %T %+v, want owner peer", typing.FromID, typing.FromID) + } + if _, ok := typing.Action.(*tg.SendMessageTypingAction); !ok { + t.Fatalf("typing action = %T, want *tg.SendMessageTypingAction", typing.Action) + } +} + +func TestMessagesSetTypingSkipsChannelMemberWithoutViewerInterest(t *testing.T) { + const ( + ownerID = int64(1000000001) + memberID = int64(1000000002) + ) + channels := appchannels.NewService(memory.NewChannelStore()) + created, err := channels.CreateChannel(context.Background(), ownerID, domain.CreateChannelRequest{ + Title: "quiet group", + CreatorUserID: ownerID, + Megagroup: true, + MemberUserIDs: []int64{memberID}, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sessions := &captureScopedSessions{captureSessions: &captureSessions{ + channelMembers: map[int64][]int64{created.Channel.ID: {memberID}}, + }} + r := New(Config{}, Deps{Channels: channels, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + + ok, err := r.onMessagesSetTyping(WithUserID(context.Background(), ownerID), &tg.MessagesSetTypingRequest{ + Peer: &tg.InputPeerChannel{ + ChannelID: created.Channel.ID, + AccessHash: created.Channel.AccessHash, + }, + Action: &tg.SendMessageTypingAction{}, + }) + if err != nil { + t.Fatalf("set channel typing: %v", err) + } + if !ok { + t.Fatalf("set channel typing = false, want true") + } + if got := sessions.snapshot(); got.message != nil { + t.Fatalf("typing push without viewer interest = %+v, want none", got) + } +} + +func TestMessagesGetMessagesReturnsOwnerMessages(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + messages := &captureMessages{list: domain.MessageList{ + Messages: []domain.Message{{ + ID: 7, + OwnerUserID: userID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + Date: 1700000000, + Body: "reply source", + }}, + Count: 1, + }} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{ + userID: {ID: userID, FirstName: "Alice"}, + peerID: {ID: peerID, FirstName: "Bob"}, + }}, + }, zaptest.NewLogger(t), clock.System) + + got, err := r.onMessagesGetMessages(WithUserID(context.Background(), userID), []tg.InputMessageClass{&tg.InputMessageID{ID: 7}}) + if err != nil { + t.Fatalf("get messages: %v", err) + } + box, ok := got.(*tg.MessagesMessages) + if !ok || len(box.Messages) != 1 { + t.Fatalf("response = %T %+v, want one messages.messages", got, got) + } + msg, ok := box.Messages[0].(*tg.Message) + if !ok || msg.ID != 7 || msg.Message != "reply source" { + t.Fatalf("message = %#v, want source message", box.Messages[0]) + } + if len(box.Users) != 1 { + t.Fatalf("users = %+v, want peer user", box.Users) + } +} diff --git a/internal/rpc/messages_monoforum.go b/internal/rpc/messages_monoforum.go new file mode 100644 index 00000000..62276089 --- /dev/null +++ b/internal/rpc/messages_monoforum.go @@ -0,0 +1,252 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// resolveMonoforumForAdmin 解析 parent_peer 指向的 monoforum 虚拟频道,并校验当前用户是其母广播频道 +// 的管理员/创建者(频道私信只有频道管理员可读/回复)。monoforum 是私有零成员频道,管理员并非其成员, +// 故走 store 的 membership-agnostic 解析,在母频道上做授权。 +// 返回 (monoforum频道, isMonoforum, err):parent 是有效频道但非 monoforum 时返回 (零, false, nil), +// 由调用方回退良性空响应(兼容对普通频道传 parent_peer 的被动探测);是 monoforum 但非管理员→CHAT_ADMIN_REQUIRED。 +func (r *Router) resolveMonoforumForAdmin(ctx context.Context, userID int64, parent domain.Peer) (domain.Channel, bool, error) { + if r.deps.Channels == nil { + return domain.Channel{}, false, notImplementedErr() + } + if parent.Type != domain.PeerTypeChannel || parent.ID == 0 { + return domain.Channel{}, false, parentPeerInvalidErr() + } + mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, parent.ID) + if err != nil { + if errors.Is(err, domain.ErrChannelInvalid) { + // 非 monoforum 频道(或不存在):非错误,交由调用方回退良性空响应。 + return domain.Channel{}, false, nil + } + return domain.Channel{}, false, internalErr() + } + if !isAdmin { + // 是 monoforum,但调用者不是母频道管理员:读私信列表/历史仅限管理员。 + return domain.Channel{}, false, tgerr400("CHAT_ADMIN_REQUIRED") + } + return mono, true, nil +} + +// monoforumSavedDialogs 返回 monoforum 的订阅者子会话列表(管理员视角的私信列表)。mono 已解析+鉴权。 +func (r *Router) monoforumSavedDialogs(ctx context.Context, userID int64, mono domain.Channel, limit, offsetID int) (tg.MessagesSavedDialogsClass, error) { + list, err := r.deps.Channels.ListMonoforumDialogs(ctx, domain.MonoforumDialogsFilter{ + MonoforumID: mono.ID, + Limit: limit, + OffsetID: offsetID, + }) + if err != nil { + return nil, internalErr() + } + dialogs := make([]tg.SavedDialogClass, 0, len(list.Dialogs)) + for _, d := range list.Dialogs { + peer := tgPeer(d.SavedPeer) + if peer == nil { + continue + } + md := &tg.MonoForumDialog{ + Peer: peer, + TopMessage: d.TopMessageID, + ReadInboxMaxID: d.ReadInboxMaxID, + ReadOutboxMaxID: d.ReadOutboxMaxID, + UnreadCount: d.UnreadCount, + } + dialogs = append(dialogs, md) + } + messages := make([]tg.MessageClass, 0, len(list.Messages)) + for _, m := range list.Messages { + if item := tgChannelMessage(userID, m); item != nil { + messages = append(messages, item) + } + } + return &tg.MessagesSavedDialogs{ + Dialogs: dialogs, + Messages: messages, + Chats: r.monoforumChats(ctx, userID, mono), + Users: r.monoforumSubscriberUsers(ctx, userID, list.Dialogs, list.Messages), + }, nil +} + +// monoforumSavedHistory 返回某订阅者在 monoforum 内的私信历史。mono 已解析+鉴权。 +func (r *Router) monoforumSavedHistory(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, limit, offsetID int) (tg.MessagesMessagesClass, error) { + if savedPeer.ID == 0 { + return nil, peerIDInvalidErr() + } + hist, err := r.deps.Channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{ + MonoforumID: mono.ID, + SavedPeer: savedPeer, + Limit: limit, + OffsetID: offsetID, + }) + if err != nil { + return nil, internalErr() + } + messages := make([]tg.MessageClass, 0, len(hist.Messages)) + for _, m := range hist.Messages { + if item := tgChannelMessage(userID, m); item != nil { + messages = append(messages, item) + } + } + return &tg.MessagesMessagesSlice{ + Count: hist.Count, + Messages: messages, + Chats: r.monoforumChats(ctx, userID, mono), + Users: r.monoforumSubscriberUsers(ctx, userID, nil, hist.Messages), + }, nil +} + +// monoforumChats 投影客户端 materialize monoforum 私信所需的频道:monoforum 自身(直接投影,管理员 +// 非其成员故不能走可见性受限的 GetChannels)+ 母广播频道(管理员是其成员)。 +func (r *Router) monoforumChats(ctx context.Context, userID int64, mono domain.Channel) []tg.ChatClass { + chats := []tg.ChatClass{tgChannelChatForView(userID, domain.ChannelView{Channel: mono})} + if mono.LinkedMonoforumID != 0 && r.deps.Channels != nil { + if views, err := r.deps.Channels.GetChannels(ctx, userID, []int64{mono.LinkedMonoforumID}); err == nil { + for _, view := range views { + if view.Channel.ID != 0 { + chats = append(chats, tgChannelChatForView(userID, view)) + } + } + } + } + return chats +} + +// monoforumSubscriberUsers 投影订阅者用户(子会话 saved_peer + 消息发件人)。 +func (r *Router) monoforumSubscriberUsers(ctx context.Context, userID int64, dialogs []domain.MonoforumDialog, messages []domain.ChannelMessage) []tg.UserClass { + ids := make([]int64, 0, len(dialogs)+len(messages)) + seen := map[int64]struct{}{} + add := func(id int64) { + if id == 0 { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + ids = append(ids, id) + } + for _, d := range dialogs { + if d.SavedPeer.Type == domain.PeerTypeUser { + add(d.SavedPeer.ID) + } + } + for _, m := range messages { + add(m.SenderUserID) + } + if len(ids) == 0 || r.deps.Users == nil { + return []tg.UserClass{} + } + found, err := r.deps.Users.ByIDs(ctx, userID, ids) + if err != nil { + return []tg.UserClass{} + } + return r.tgUsers(found) +} + +// monoforumReplyPresent 判断 sendMessage 的 reply_to 是否带 monoforum_peer_id(频道私信发送的唯一标志)。 +// 普通发送恒不带,故据此 gate monoforum 分支,普通发送热路径零额外成本。 +func monoforumReplyPresent(input tg.InputReplyToClass) bool { + switch v := input.(type) { + case *tg.InputReplyToMonoForum: + return v != nil + case *tg.InputReplyToMessage: + if v == nil { + return false + } + _, ok := v.GetMonoforumPeerID() + return ok + default: + return false + } +} + +// monoforumReplyTargetPeer 从 reply_to 的 monoforum_peer_id 解析订阅者子会话 peer。 +func (r *Router) monoforumReplyTargetPeer(userID int64, input tg.InputReplyToClass) (domain.Peer, bool) { + var inputPeer tg.InputPeerClass + switch v := input.(type) { + case *tg.InputReplyToMonoForum: + if v != nil { + inputPeer = v.MonoforumPeerID + } + case *tg.InputReplyToMessage: + if v != nil { + if p, ok := v.GetMonoforumPeerID(); ok { + inputPeer = p + } + } + } + if inputPeer == nil { + return domain.Peer{}, false + } + return r.domainPeerFromInputPeer(userID, inputPeer) +} + +// sendMonoforumMessage 处理向频道私信(monoforum)发送:订阅者发到自己的子会话,管理员回复到目标订阅者。 +// saved_peer 来自 reply_to 的 monoforum_peer_id;管理员可写任意订阅者子会话,普通订阅者只能写自己的。 +func (r *Router) sendMonoforumMessage(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + mono, isAdmin, err := r.deps.Channels.ResolveMonoforumSend(ctx, userID, peer.ID) + if err != nil { + if errors.Is(err, domain.ErrChannelInvalid) { + // 带 monoforum_peer_id 却不是 monoforum 频道。 + return nil, tgerr400("CHANNEL_MONOFORUM_UNSUPPORTED") + } + return nil, internalErr() + } + savedPeer, ok := r.monoforumReplyTargetPeer(userID, req.ReplyTo) + if !ok || savedPeer.Type != domain.PeerTypeUser || savedPeer.ID == 0 { + return nil, replyToMonoforumPeerInvalidErr() + } + if !isAdmin && savedPeer.ID != userID { + // 普通订阅者只能写自己的子会话,不能写他人的。 + return nil, replyToMonoforumPeerInvalidErr() + } + res, err := r.deps.Channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: mono.ID, + SenderUserID: userID, + SavedPeer: savedPeer, + RandomID: req.RandomID, + Message: req.Message, + Entities: domainMessageEntities(req.Entities), + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, messageSendErr(err) + } + return r.monoforumSendUpdates(ctx, userID, mono, savedPeer, res), nil +} + +// monoforumSendUpdates 给发送者构造回声 Updates:updateMessageID(关联 random_id)+ updateNewChannelMessage +// (monoforum 走 channel pts)。另一方经 monoforum 频道的 getChannelDifference 收取该 durable 事件。 +func (r *Router) monoforumSendUpdates(ctx context.Context, userID int64, mono domain.Channel, savedPeer domain.Peer, res domain.SendChannelMessageResult) tg.UpdatesClass { + updates := make([]tg.UpdateClass, 0, 2) + if res.Message.RandomID != 0 { + updates = append(updates, &tg.UpdateMessageID{ID: res.Message.ID, RandomID: res.Message.RandomID}) + } + newMsg := &tg.UpdateNewChannelMessage{Pts: res.Event.Pts, PtsCount: res.Event.PtsCount} + if channelMsg := tgChannelMessage(userID, res.Message); channelMsg != nil { + newMsg.Message = channelMsg + } else { + newMsg.Message = &tg.MessageEmpty{ID: res.Message.ID} + } + updates = append(updates, newMsg) + return &tg.Updates{ + Updates: updates, + Chats: r.monoforumChats(ctx, userID, mono), + Users: r.monoforumSubscriberUsers(ctx, userID, []domain.MonoforumDialog{{SavedPeer: savedPeer}}, []domain.ChannelMessage{res.Message}), + Date: int(r.clock.Now().Unix()), + } +} diff --git a/internal/rpc/messages_monoforum_rpc_test.go b/internal/rpc/messages_monoforum_rpc_test.go new file mode 100644 index 00000000..eb475af9 --- /dev/null +++ b/internal/rpc/messages_monoforum_rpc_test.go @@ -0,0 +1,262 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestMonoforumSavedDialogsAndHistory 验证频道私信(monoforum)读侧 RPC:管理员经 +// getSavedDialogs(parent_peer=monoforum) 看订阅者子会话列表、经 getSavedHistory 看某订阅者历史 +// (消息带 saved_peer_id);非管理员被拒。 +func TestMonoforumSavedDialogsAndHistory(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + sub, err := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550002002", FirstName: "Sub"}) + if err != nil { + t.Fatalf("create sub: %v", err) + } + + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Broadcast", Broadcast: true, Date: 1000}) + if err != nil { + t.Fatalf("create channel: %v", err) + } + enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + if monoID == 0 { + t.Fatalf("no monoforum created") + } + + subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID} + if _, err := channelStore.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{ + MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 1, Message: "hello channel", Date: 1100, + }); err != nil { + t.Fatalf("seed monoforum message: %v", err) + } + + mono, err := channelStore.GetChannelByID(ctx, monoID) + if err != nil { + t.Fatalf("get monoforum: %v", err) + } + monoInput := &tg.InputPeerChannel{ChannelID: monoID, AccessHash: mono.AccessHash} + + // TDesktop 点 Direct Messages 入口会先按 monoforum peer 拉普通 channel history。 + // 主历史只应返回 monoforum 自身的 service messages,不能混入 saved_peer 子会话消息。 + var raw bin.Buffer + if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&raw); err != nil { + t.Fatalf("encode getHistory(monoforum): %v", err) + } + mainEnc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &raw) + if err != nil { + t.Fatalf("dispatch getHistory(monoforum): %v", err) + } + mainBox, ok := mainEnc.(*tg.MessagesMessagesBox) + if !ok { + t.Fatalf("getHistory(monoforum) = %T, want MessagesMessagesBox", mainEnc) + } + mainHistory, ok := mainBox.Messages.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("getHistory(monoforum) boxed = %T, want MessagesChannelMessages", mainBox.Messages) + } + if len(mainHistory.Messages) != 1 { + t.Fatalf("main monoforum history = %d msgs, want only the creation service", len(mainHistory.Messages)) + } + service, ok := mainHistory.Messages[0].(*tg.MessageService) + if !ok { + t.Fatalf("main monoforum message = %T, want MessageService", mainHistory.Messages[0]) + } + // monoforum 的首条服务消息是创建消息;TDesktop 对 monoforum 渲染为 "Direct messages were + // enabled in this channel."(lng_action_created_monoforum)。paid_messages_price 只进母广播频道。 + if _, ok := service.Action.(*tg.MessageActionChannelCreate); !ok { + t.Fatalf("main monoforum action = %T %+v, want MessageActionChannelCreate", service.Action, service.Action) + } + seenChats := map[int64]bool{} + for _, chat := range mainHistory.Chats { + if ch, ok := chat.(*tg.Channel); ok { + seenChats[ch.ID] = true + } + } + if !seenChats[monoID] || !seenChats[created.Channel.ID] { + t.Fatalf("main monoforum chats = %+v, want monoforum %d and parent %d", seenChats, monoID, created.Channel.ID) + } + var deniedRaw bin.Buffer + if err := (&tg.MessagesGetHistoryRequest{Peer: monoInput, Limit: 20}).Encode(&deniedRaw); err != nil { + t.Fatalf("encode non-admin getHistory(monoforum): %v", err) + } + if _, err := r.Dispatch(WithUserID(ctx, sub.ID), [8]byte{}, 0, &deniedRaw); err == nil { + t.Fatalf("non-admin getHistory(monoforum) = nil err, want denied") + } + + // 管理员看私信列表。 + dreq := &tg.MessagesGetSavedDialogsRequest{} + dreq.SetParentPeer(monoInput) + dres, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, owner.ID), dreq) + if err != nil { + t.Fatalf("getSavedDialogs(monoforum): %v", err) + } + sd, ok := dres.(*tg.MessagesSavedDialogs) + if !ok { + t.Fatalf("getSavedDialogs = %T, want *tg.MessagesSavedDialogs", dres) + } + if len(sd.Dialogs) != 1 { + t.Fatalf("dialogs = %d, want 1 subscriber sublist", len(sd.Dialogs)) + } + md, ok := sd.Dialogs[0].(*tg.MonoForumDialog) + if !ok { + t.Fatalf("dialog = %T, want *tg.MonoForumDialog", sd.Dialogs[0]) + } + if pu, ok := md.Peer.(*tg.PeerUser); !ok || pu.UserID != sub.ID { + t.Fatalf("dialog peer = %#v, want PeerUser %d", md.Peer, sub.ID) + } + foundSub := false + for _, u := range sd.Users { + if usr, ok := u.(*tg.User); ok && usr.ID == sub.ID { + foundSub = true + } + } + if !foundSub { + t.Fatalf("getSavedDialogs users missing subscriber %d", sub.ID) + } + + // 管理员看某订阅者会话历史。 + hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}} + hreq.SetParentPeer(monoInput) + hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq) + if err != nil { + t.Fatalf("getSavedHistory(monoforum): %v", err) + } + var gotMsgs []tg.MessageClass + switch m := hres.(type) { + case *tg.MessagesMessages: + gotMsgs = m.Messages + case *tg.MessagesMessagesSlice: + gotMsgs = m.Messages + case *tg.MessagesChannelMessages: + gotMsgs = m.Messages + default: + t.Fatalf("getSavedHistory = %T, want messages", hres) + } + if len(gotMsgs) != 1 { + t.Fatalf("history = %d msgs, want 1", len(gotMsgs)) + } + msg, ok := gotMsgs[0].(*tg.Message) + if !ok { + t.Fatalf("msg = %T, want *tg.Message", gotMsgs[0]) + } + if msg.Message != "hello channel" { + t.Fatalf("body = %q, want 'hello channel'", msg.Message) + } + sp, ok := msg.GetSavedPeerID() + if !ok { + t.Fatalf("message missing saved_peer_id (client can't group into subscriber sublist)") + } + if pu, ok := sp.(*tg.PeerUser); !ok || pu.UserID != sub.ID { + t.Fatalf("saved_peer_id = %#v, want sub %d", sp, sub.ID) + } + + // 非管理员(订阅者本人)经管理员入口看列表被拒。 + if _, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, sub.ID), dreq); err == nil { + t.Fatalf("non-admin getSavedDialogs(monoforum) = nil err, want denied") + } +} + +// TestMonoforumSendMessageWritePath 验证写侧:订阅者经 sendMessage(peer=monoforum, +// reply_to=InputReplyToMonoForum{自己}) 发私信;管理员回复到目标订阅者;订阅者不能写他人子会话; +// 普通发送(无 monoforum_peer_id)不受影响。 +func TestMonoforumSendMessageWritePath(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550003001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + sub, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550003002", FirstName: "Sub"}) + if err != nil { + t.Fatalf("create sub: %v", err) + } + + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + }, zaptest.NewLogger(t), clock.System) + + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{Title: "DM Broadcast", Broadcast: true, Date: 1000}) + if err != nil { + t.Fatalf("create channel: %v", err) + } + enabled, err := channelStore.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + monoInput := &tg.InputPeerChannel{ChannelID: monoID} + + // 订阅者发私信到自己的子会话。 + subReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "hi from sub", RandomID: 555} + subReq.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}}) + subUpd, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), subReq) + if err != nil { + t.Fatalf("subscriber sendMessage(monoforum): %v", err) + } + if _, ok := subUpd.(*tg.Updates); !ok { + t.Fatalf("subscriber send updates = %T, want *tg.Updates", subUpd) + } + + // 管理员回复到该订阅者的子会话。 + adminReq := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "admin reply", RandomID: 556} + adminReq.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: sub.ID}}) + if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), adminReq); err != nil { + t.Fatalf("admin reply sendMessage(monoforum): %v", err) + } + + // 订阅者不能写他人(owner)的子会话。 + sneaky := &tg.MessagesSendMessageRequest{Peer: monoInput, Message: "sneaky", RandomID: 557} + sneaky.SetReplyTo(&tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerUser{UserID: owner.ID}}) + if _, err := r.onMessagesSendMessage(WithUserID(ctx, sub.ID), sneaky); err == nil { + t.Fatalf("subscriber writing another's sublist = nil err, want REPLY_TO_MONOFORUM_PEER_INVALID") + } + + // 经管理员读历史:子会话含两条(订阅者发 + 管理员回复),倒序。 + hreq := &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerUser{UserID: sub.ID}} + hreq.SetParentPeer(monoInput) + hres, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), hreq) + if err != nil { + t.Fatalf("getSavedHistory after sends: %v", err) + } + slice, ok := hres.(*tg.MessagesMessagesSlice) + if !ok { + t.Fatalf("getSavedHistory = %T, want *tg.MessagesMessagesSlice", hres) + } + if len(slice.Messages) != 2 { + t.Fatalf("history = %d msgs, want 2 (sub + admin)", len(slice.Messages)) + } + top, ok := slice.Messages[0].(*tg.Message) + if !ok || top.Message != "admin reply" { + t.Fatalf("history[0] = %#v, want newest 'admin reply'", slice.Messages[0]) + } +} diff --git a/internal/rpc/messages_paid_reaction.go b/internal/rpc/messages_paid_reaction.go new file mode 100644 index 00000000..16564c82 --- /dev/null +++ b/internal/rpc/messages_paid_reaction.go @@ -0,0 +1,112 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// channelPaidReactionService 是 r.deps.Channels 的可选扩展(app/channels.Service 实现), +// 用类型断言接入,避免在 ChannelsService 接口上为付费 reaction 单加方法。 +type channelPaidReactionService interface { + SendPaidReaction(ctx context.Context, userID int64, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) +} + +// channelPaidReactionUpdates 为某 viewer 构造一条 updateMessageReactions:把消息已有的普通 +// reaction 与付费 ReactionPaid(总星数 + top reactors)合并。非请求者走 min 语义(置 Min, +// 客户端忽略 chosen 保留本地态),避免把请求者视角串给他人。 +func (r *Router) channelPaidReactionUpdates(ctx context.Context, requestUserID, viewerUserID int64, res domain.ChannelMessagePaidReactionResult, ids []int) *tg.Updates { + isRequester := viewerUserID == requestUserID + base := domain.ChannelMessageReactions{} + if res.Message.Reactions != nil { + base = *res.Message.Reactions + } + paid := clonePaidForViewer(res.Paid, isRequester) + base.Paid = &paid + mr := tgMessageReactions(viewerUserID, &base) + if mr == nil { + mr = &tg.MessageReactions{Results: []tg.ReactionCount{}} + } + if !isRequester { + mr.Min = true + } + msgID := res.Message.ID + if msgID == 0 && len(ids) > 0 { + msgID = ids[0] + } + update := &tg.UpdateMessageReactions{ + Peer: &tg.PeerChannel{ChannelID: res.Channel.ID}, + MsgID: msgID, + Reactions: *mr, + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: r.tgUsersForIDs(ctx, viewerUserID, paidReactorUserIDs(res.Paid)), + Chats: tgChannels(viewerUserID, []domain.Channel{res.Channel}), + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +// clonePaidForViewer 返回付费聚合的副本;非请求者抹除 My/MyStars(min 语义防串视角)。 +func clonePaidForViewer(paid domain.ChannelMessagePaidReactions, isRequester bool) domain.ChannelMessagePaidReactions { + out := paid + out.TopReactors = make([]domain.PaidReactor, len(paid.TopReactors)) + copy(out.TopReactors, paid.TopReactors) + if !isRequester { + out.MyStars = 0 + out.MyAnonymous = false + for i := range out.TopReactors { + out.TopReactors[i].My = false + } + } + return out +} + +// injectPaidReaction 把付费 reaction 注入 MessageReactions:ReactionPaid 计数置首位、填充 +// top reactors 排行。My/chosen 完全由 paid 的视角数据驱动(调用方对他人视角已抹除 My/MyStars)。 +func injectPaidReaction(mr *tg.MessageReactions, paid domain.ChannelMessagePaidReactions) { + if paid.TotalStars <= 0 { + return + } + paidCount := tg.ReactionCount{Reaction: &tg.ReactionPaid{}, Count: int(paid.TotalStars)} + if paid.MyStars > 0 { + // chosen_order 仅标记「本人已投」,具体序值不关键,置正。 + paidCount.SetChosenOrder(int(paid.MyStars)) + } + mr.Results = append([]tg.ReactionCount{paidCount}, mr.Results...) + + reactors := make([]tg.MessageReactor, 0, len(paid.TopReactors)) + for _, rr := range paid.TopReactors { + item := tg.MessageReactor{Count: int(rr.Stars)} + if rr.My { + item.My = true + } else { + item.Top = true + } + if rr.Anonymous { + item.Anonymous = true + } + // PeerID:非匿名给出;匿名仅本人给出(TL 语义:匿名本人 anonymous+peer 都置)。 + if rr.UserID != 0 && (!rr.Anonymous || rr.My) { + item.SetPeerID(&tg.PeerUser{UserID: rr.UserID}) + } + reactors = append(reactors, item) + } + if len(reactors) > 0 { + mr.SetTopReactors(reactors) + } +} + +// paidReactorUserIDs 收集非匿名 reactor 的用户 id,供 Updates.Users 富化头像。 +func paidReactorUserIDs(paid domain.ChannelMessagePaidReactions) []int64 { + ids := make([]int64, 0, len(paid.TopReactors)) + for _, rr := range paid.TopReactors { + if rr.UserID != 0 && !rr.Anonymous { + ids = append(ids, rr.UserID) + } + } + return ids +} diff --git a/internal/rpc/messages_paid_reaction_test.go b/internal/rpc/messages_paid_reaction_test.go new file mode 100644 index 00000000..a7030752 --- /dev/null +++ b/internal/rpc/messages_paid_reaction_test.go @@ -0,0 +1,109 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// injectPaidReaction 请求者视角:ReactionPaid 置首、计数=总星、chosen 置位; +// TopReactors 含本人带 My + PeerID。 +func TestInjectPaidReactionRequesterView(t *testing.T) { + paid := domain.ChannelMessagePaidReactions{ + TotalStars: 150, + MyStars: 50, + TopReactors: []domain.PaidReactor{ + {UserID: 2, Stars: 100, My: false}, + {UserID: 1, Stars: 50, My: true}, + }, + } + mr := &tg.MessageReactions{Results: []tg.ReactionCount{}} + injectPaidReaction(mr, clonePaidForViewer(paid, true)) + + if len(mr.Results) != 1 { + t.Fatalf("results = %d, want 1 paid", len(mr.Results)) + } + if _, ok := mr.Results[0].Reaction.(*tg.ReactionPaid); !ok { + t.Fatalf("results[0] reaction = %T, want *tg.ReactionPaid", mr.Results[0].Reaction) + } + if mr.Results[0].Count != 150 { + t.Fatalf("paid count = %d, want 150", mr.Results[0].Count) + } + if order, ok := mr.Results[0].GetChosenOrder(); !ok || order <= 0 { + t.Fatalf("requester chosen = %d ok %v, want set+positive (MyStars>0)", order, ok) + } + reactors, ok := mr.GetTopReactors() + if !ok || len(reactors) != 2 { + t.Fatalf("top reactors = %d ok %v, want 2", len(reactors), ok) + } + // 第二条是本人。 + me := reactors[1] + if !me.My || me.Count != 50 { + t.Fatalf("my reactor = %+v, want My count 50", me) + } + if peer, ok := me.GetPeerID(); !ok { + t.Fatalf("my reactor peer = %v, want set", peer) + } + // 第一条是 top(非本人)。 + if !reactors[0].Top || reactors[0].My { + t.Fatalf("top reactor = %+v, want Top not My", reactors[0]) + } +} + +// 非请求者视角:无 chosen、My 一律抹去(min 语义防串视角)。 +func TestInjectPaidReactionOtherViewerScrubsMy(t *testing.T) { + paid := domain.ChannelMessagePaidReactions{ + TotalStars: 150, + MyStars: 50, + TopReactors: []domain.PaidReactor{ + {UserID: 2, Stars: 100, My: false}, + {UserID: 1, Stars: 50, My: true}, + }, + } + mr := &tg.MessageReactions{Results: []tg.ReactionCount{}} + injectPaidReaction(mr, clonePaidForViewer(paid, false)) + + if _, ok := mr.Results[0].GetChosenOrder(); ok { + t.Fatalf("non-requester chosen must be unset") + } + reactors, _ := mr.GetTopReactors() + for i, rr := range reactors { + if rr.My { + t.Fatalf("reactor[%d] My must be scrubbed for non-requester: %+v", i, rr) + } + } +} + +// 匿名 reactor(非本人):Anonymous 置位、不暴露 PeerID。 +func TestInjectPaidReactionAnonymousHidesPeer(t *testing.T) { + paid := domain.ChannelMessagePaidReactions{ + TotalStars: 100, + TopReactors: []domain.PaidReactor{{UserID: 9, Stars: 100, Anonymous: true, My: false}}, + } + mr := &tg.MessageReactions{Results: []tg.ReactionCount{}} + injectPaidReaction(mr, clonePaidForViewer(paid, true)) + reactors, ok := mr.GetTopReactors() + if !ok || len(reactors) != 1 { + t.Fatalf("reactors = %d ok %v, want 1", len(reactors), ok) + } + if !reactors[0].Anonymous { + t.Fatalf("reactor must be Anonymous: %+v", reactors[0]) + } + if peer, ok := reactors[0].GetPeerID(); ok { + t.Fatalf("anonymous non-self reactor must not expose peer, got %v", peer) + } +} + +// 空付费态:不注入任何 ReactionPaid。 +func TestInjectPaidReactionEmpty(t *testing.T) { + mr := &tg.MessageReactions{Results: []tg.ReactionCount{}} + injectPaidReaction(mr, domain.ChannelMessagePaidReactions{}) + if len(mr.Results) != 0 { + t.Fatalf("empty paid must inject nothing, got %d results", len(mr.Results)) + } + if _, ok := mr.GetTopReactors(); ok { + t.Fatalf("empty paid must not set top reactors") + } +} diff --git a/internal/rpc/messages_pin.go b/internal/rpc/messages_pin.go new file mode 100644 index 00000000..6d001498 --- /dev/null +++ b/internal/rpc/messages_pin.go @@ -0,0 +1,149 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// updatePrivatePinnedMessage 处理 messages.updatePinnedMessage 的私聊 +// 分支(含 Saved Messages)。官方语义:非 pm_oneside 的 pin 双侧翻转并 +// 生成 messageActionPinMessage 服务消息(reply_to 指向被置顶消息); +// pm_oneside / Saved Messages / unpin 不生成服务消息;对端与本账号其它 +// 设备经可靠投递收到 updatePinnedMessages(账号 pts)。 +func (r *Router) updatePrivatePinnedMessage(ctx context.Context, userID int64, peer domain.Peer, req *tg.MessagesUpdatePinnedMessageRequest) (tg.UpdatesClass, error) { + if r.deps.Messages == nil { + return nil, notImplementedErr() + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + res, err := r.deps.Messages.PinPrivateMessage(ctx, userID, domain.PinPrivateMessageRequest{ + OwnerUserID: userID, + Peer: peer, + MessageID: req.ID, + Pinned: !req.Unpin, + PmOneside: req.PmOneside, + Silent: req.Silent, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, messageIDInvalidErr() + } + return nil, internalErr() + } + if res.Changed() { + r.invalidateRPCProjectionForPeer(userID, peer) + if !req.PmOneside && peer.ID != userID { + r.invalidateRPCProjectionForPeer(peer.ID, domain.Peer{Type: domain.PeerTypeUser, ID: userID}) + } + } + self := res.Self() + updates := []tg.UpdateClass(nil) + if update := tgOtherUpdateFromEvent(self.Event); update != nil { + updates = append(updates, update) + } + users := []tg.UserClass{} + if res.Changed() && !req.Unpin && !req.PmOneside && peer.ID != userID { + // 服务消息独立于置顶状态事务:置顶状态是真值源,服务消息失败 + // 不回滚置顶(与官方时间线装饰语义一致)。req.ID 即 owner 视角 + // box id(store 已校验存在),共享升级场景 Self() 可能为空仍需发。 + if sent, sendErr := r.sendPinServiceMessage(ctx, userID, peer.ID, req.ID, req.Silent); sendErr == nil && sent.SenderMessage.ID != 0 { + if msg := tgMessage(sent.SenderMessage); msg != nil { + updates = append(updates, &tg.UpdateNewMessage{ + Message: msg, + Pts: sent.SenderEvent.Pts, + PtsCount: sent.SenderEvent.PtsCount, + }) + } + users = r.usersForMessageUpdate(ctx, userID, sent.SenderMessage) + } + } + if len(updates) == 0 { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + return &tg.Updates{ + Updates: updates, + Users: users, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }, nil +} + +func (r *Router) sendPinServiceMessage(ctx context.Context, userID, peerUserID int64, pinnedBoxID int, silent bool) (domain.SendPrivateTextResult, error) { + recipientBlocked, err := r.peerBlocksUser(ctx, userID, peerUserID) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + return r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: peerUserID, + RandomID: pinServiceMessageRandomID(userID, peerUserID, pinnedBoxID, r.clock.Now().UnixNano()), + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{Kind: domain.MessageServiceActionPinMessage}, + }, + Silent: silent, + // reply_to 指向被置顶消息(owner 视角 box id),发送路径自动 + // 翻译为对端视角;客户端凭此渲染"X 置顶了「…」"预览。 + ReplyTo: &domain.MessageReply{MessageID: pinnedBoxID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerUserID}}, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + RecipientBlocked: recipientBlocked, + }) +} + +func pinServiceMessageRandomID(userID, peerUserID int64, pinnedBoxID int, nowNano int64) int64 { + id := nowNano ^ (userID << 23) ^ (peerUserID << 11) ^ int64(pinnedBoxID) + if id == 0 { + return int64(pinnedBoxID) | 1 + } + return id +} + +// unpinAllPrivateMessages 处理 messages.unpinAllMessages 的私聊分支。 +func (r *Router) unpinAllPrivateMessages(ctx context.Context, userID int64, peer domain.Peer) (*tg.MessagesAffectedHistory, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + if r.deps.Messages == nil { + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + sessionID, _ := SessionIDFrom(ctx) + res, err := r.deps.Messages.UnpinAllPrivateMessages(ctx, userID, domain.UnpinAllPrivateMessagesRequest{ + OwnerUserID: userID, + Peer: peer, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, messageIDInvalidErr() + } + return nil, internalErr() + } + self := res.Self() + if self.Event.Pts == 0 { + // 无置顶可清:返回当前账号 pts,避免客户端误判 pts 空洞。 + return r.affectedHistory(ctx, authKeyID, userID, 0) + } + r.invalidateRPCProjectionForPeer(userID, peer) + if peer.Type == domain.PeerTypeUser && peer.ID != userID { + r.invalidateRPCProjectionForPeer(peer.ID, domain.Peer{Type: domain.PeerTypeUser, ID: userID}) + } + return &tg.MessagesAffectedHistory{ + Pts: self.Event.Pts, + PtsCount: self.Event.PtsCount, + // Offset>0 时客户端按官方 affectedHistory 语义续发 unpinAll 清 + // 剩余批次。 + Offset: res.Offset, + }, nil +} diff --git a/internal/rpc/messages_pin_rpc_test.go b/internal/rpc/messages_pin_rpc_test.go new file mode 100644 index 00000000..5a2e66df --- /dev/null +++ b/internal/rpc/messages_pin_rpc_test.go @@ -0,0 +1,416 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appcontacts "telesrv/internal/app/contacts" + appdialogs "telesrv/internal/app/dialogs" + appmessages "telesrv/internal/app/messages" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func newPinTestRouter(t *testing.T) (*Router, *memory.MessageStore, *memory.UserStore) { + t.Helper() + userStore := memory.NewUserStore() + dialogs := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogs) + contactStore := memory.NewContactStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Contacts: appcontacts.NewService(contactStore, userStore), + Messages: appmessages.NewService(messageStore, dialogs), + Dialogs: appdialogs.NewService(dialogs), + }, zaptest.NewLogger(t), clock.System) + return r, messageStore, userStore +} + +func findPinnedMessagesUpdate(t *testing.T, updates tg.UpdatesClass) *tg.UpdatePinnedMessages { + t.Helper() + box, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, update := range box.Updates { + if pinned, ok := update.(*tg.UpdatePinnedMessages); ok { + return pinned + } + } + return nil +} + +func findServiceMessageUpdate(t *testing.T, updates tg.UpdatesClass) *tg.MessageService { + t.Helper() + box, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, update := range box.Updates { + if newMsg, ok := update.(*tg.UpdateNewMessage); ok { + if svc, ok := newMsg.Message.(*tg.MessageService); ok { + return svc + } + } + } + return nil +} + +func privatePinnedIDs(t *testing.T, store *memory.MessageStore, ownerID, peerID int64) []int { + t.Helper() + list, err := store.ListByUser(context.Background(), ownerID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + PinnedOnly: true, + Limit: 50, + }) + if err != nil { + t.Fatalf("list pinned: %v", err) + } + ids := make([]int, 0, len(list.Messages)) + for _, msg := range list.Messages { + ids = append(ids, msg.ID) + } + return ids +} + +// TestMessagesUpdatePinnedMessagePrivateSharedPin 验证私聊共享置顶全链路: +// 双侧 box 翻转、updatePinnedMessages 账号 pts 事件、messageActionPinMessage +// 服务消息(reply_to 指向被置顶消息)、filterPinned 搜索与 userFull 发现。 +func TestMessagesUpdatePinnedMessagePrivateSharedPin(t *testing.T) { + ctx := context.Background() + r, messageStore, userStore := newPinTestRouter(t) + alice, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550009201", FirstName: "Alice"}) + bob, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550009202", FirstName: "Bob"}) + + sent, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + Message: "pin target", + RandomID: 92001, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + aliceMsg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) + + bobList, err := messageStore.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, Limit: 10, + }) + if err != nil || len(bobList.Messages) != 1 { + t.Fatalf("bob list = %+v err %v, want delivered message", bobList.Messages, err) + } + bobBoxID := bobList.Messages[0].ID + + // 共享置顶(未设 pm_oneside = 官方"同时为对方置顶"勾选形态)。 + pinRes, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, alice.ID), &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + ID: aliceMsg.ID, + }) + if err != nil { + t.Fatalf("pin: %v", err) + } + pinnedUpdate := findPinnedMessagesUpdate(t, pinRes) + if pinnedUpdate == nil || !pinnedUpdate.Pinned || pinnedUpdate.Pts == 0 || pinnedUpdate.PtsCount != 1 { + t.Fatalf("pin update = %+v, want pinned with account pts", pinnedUpdate) + } + if len(pinnedUpdate.Messages) != 1 || pinnedUpdate.Messages[0] != aliceMsg.ID { + t.Fatalf("pin update messages = %v, want [%d] (alice 视角 box id)", pinnedUpdate.Messages, aliceMsg.ID) + } + if peer, ok := pinnedUpdate.Peer.(*tg.PeerUser); !ok || peer.UserID != bob.ID { + t.Fatalf("pin update peer = %+v, want bob", pinnedUpdate.Peer) + } + svc := findServiceMessageUpdate(t, pinRes) + if svc == nil { + t.Fatalf("pin response lacks messageService, want messageActionPinMessage") + } + if _, ok := svc.Action.(*tg.MessageActionPinMessage); !ok { + t.Fatalf("service action = %T, want messageActionPinMessage", svc.Action) + } + reply, ok := svc.GetReplyTo() + if !ok { + t.Fatalf("pin service message lacks reply_to (客户端凭此渲染置顶预览)") + } + if header, ok := reply.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != aliceMsg.ID { + t.Fatalf("service reply_to = %+v, want alice box id %d", reply, aliceMsg.ID) + } + + // 双侧置顶状态 + 对端服务消息(含对端视角 reply_to 翻译)。 + if ids := privatePinnedIDs(t, messageStore, alice.ID, bob.ID); len(ids) != 1 || ids[0] != aliceMsg.ID { + t.Fatalf("alice pinned = %v, want [%d]", ids, aliceMsg.ID) + } + if ids := privatePinnedIDs(t, messageStore, bob.ID, alice.ID); len(ids) != 1 || ids[0] != bobBoxID { + t.Fatalf("bob pinned = %v, want [%d] (bob 视角 box id)", ids, bobBoxID) + } + bobAfter, _ := messageStore.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, Limit: 10, + }) + foundService := false + for _, msg := range bobAfter.Messages { + if msg.Media != nil && msg.Media.Kind == domain.MessageMediaKindService && + msg.Media.ServiceAction != nil && msg.Media.ServiceAction.Kind == domain.MessageServiceActionPinMessage { + foundService = true + if msg.ReplyTo == nil || msg.ReplyTo.MessageID != bobBoxID { + t.Fatalf("bob service reply = %+v, want bob box id %d", msg.ReplyTo, bobBoxID) + } + } + } + if !foundService { + t.Fatalf("bob history lacks pin service message: %+v", bobAfter.Messages) + } + + // 幂等:重复 pin 返回空 updates,不再生成服务消息。 + repeat, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, alice.ID), &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + ID: aliceMsg.ID, + }) + if err != nil { + t.Fatalf("repeat pin: %v", err) + } + if update := findPinnedMessagesUpdate(t, repeat); update != nil { + t.Fatalf("repeat pin update = %+v, want no-op", update) + } + + // 搜索发现链路:filterPinned(bob 视角)带总数与 message.pinned 标志。 + searchReq := &tg.MessagesSearchRequest{ + Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, + Filter: &tg.InputMessagesFilterPinned{}, + Limit: 10, + } + var in bin.Buffer + if err := searchReq.Encode(&in); err != nil { + t.Fatalf("encode pinned search: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, bob.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch pinned search: %v", err) + } + pinnedMessages, _, _ := searchMessagesPayload(t, enc) + if len(pinnedMessages) != 1 { + t.Fatalf("pinned search len = %d, want 1", len(pinnedMessages)) + } + if msg := pinnedMessages[0].(*tg.Message); msg.ID != bobBoxID || !msg.Pinned { + t.Fatalf("pinned search msg = id %d pinned %v, want bob box id %d pinned", msg.ID, msg.Pinned, bobBoxID) + } + + // userFull 发现链路:pinned_msg_id + can_pin_message。 + full, err := r.onUsersGetFullUser(WithUserID(ctx, alice.ID), &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash}) + if err != nil { + t.Fatalf("get full user: %v", err) + } + if pinnedID, ok := full.FullUser.GetPinnedMsgID(); !ok || pinnedID != aliceMsg.ID { + t.Fatalf("userFull pinned_msg_id = %d (%v), want %d", pinnedID, ok, aliceMsg.ID) + } + if !full.FullUser.CanPinMessage { + t.Fatalf("userFull can_pin_message = false, want true") + } + + // unpin:双侧清除,事件 pinned=false。 + unpinReq := &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + ID: aliceMsg.ID, + } + unpinReq.SetUnpin(true) + unpinRes, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, alice.ID), unpinReq) + if err != nil { + t.Fatalf("unpin: %v", err) + } + unpinUpdate := findPinnedMessagesUpdate(t, unpinRes) + if unpinUpdate == nil || unpinUpdate.Pinned { + t.Fatalf("unpin update = %+v, want pinned=false", unpinUpdate) + } + if findServiceMessageUpdate(t, unpinRes) != nil { + t.Fatalf("unpin generated service message, official unpin emits none") + } + if ids := privatePinnedIDs(t, messageStore, alice.ID, bob.ID); len(ids) != 0 { + t.Fatalf("alice pinned after unpin = %v, want empty", ids) + } + if ids := privatePinnedIDs(t, messageStore, bob.ID, alice.ID); len(ids) != 0 { + t.Fatalf("bob pinned after unpin = %v, want empty (共享置顶 unpin 双侧传播)", ids) + } +} + +// TestMessagesUpdatePinnedMessagePrivateOnesideAndUnpinAll 验证 pm_oneside +// 单侧置顶(不传播、无服务消息)与 unpinAllMessages 清扫。 +func TestMessagesUpdatePinnedMessagePrivateOnesideAndUnpinAll(t *testing.T) { + ctx := context.Background() + r, messageStore, userStore := newPinTestRouter(t) + alice, _ := userStore.Create(ctx, domain.User{AccessHash: 73, Phone: "15550009203", FirstName: "Alice"}) + bob, _ := userStore.Create(ctx, domain.User{AccessHash: 74, Phone: "15550009204", FirstName: "Bob"}) + + sent, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + Message: "oneside target", + RandomID: 92011, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + aliceMsg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) + bobBefore, _ := messageStore.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, Limit: 10, + }) + + // pm_oneside = 官方私聊置顶框默认形态(未勾选"同时为对方置顶")。 + onesideReq := &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + ID: aliceMsg.ID, + } + onesideReq.SetPmOneside(true) + onesideRes, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, alice.ID), onesideReq) + if err != nil { + t.Fatalf("oneside pin: %v", err) + } + if update := findPinnedMessagesUpdate(t, onesideRes); update == nil || !update.Pinned { + t.Fatalf("oneside pin update = %+v, want pinned", update) + } + if findServiceMessageUpdate(t, onesideRes) != nil { + t.Fatalf("oneside pin generated service message, want none") + } + if ids := privatePinnedIDs(t, messageStore, alice.ID, bob.ID); len(ids) != 1 { + t.Fatalf("alice oneside pinned = %v, want 1", ids) + } + if ids := privatePinnedIDs(t, messageStore, bob.ID, alice.ID); len(ids) != 0 { + t.Fatalf("bob pinned after oneside = %v, want empty (单侧不传播)", ids) + } + bobAfter, _ := messageStore.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, Limit: 10, + }) + if len(bobAfter.Messages) != len(bobBefore.Messages) { + t.Fatalf("bob history grew %d -> %d after oneside pin, want unchanged", len(bobBefore.Messages), len(bobAfter.Messages)) + } + + // 单侧→共享升级:同一消息再发不带 pm_oneside 的 pin,必须向对端 + // 传播并生成服务消息(own 侧已置顶不构成整体 no-op)。 + upgradeRes, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, alice.ID), &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + ID: aliceMsg.ID, + }) + if err != nil { + t.Fatalf("upgrade pin: %v", err) + } + if ids := privatePinnedIDs(t, messageStore, bob.ID, alice.ID); len(ids) != 1 { + t.Fatalf("bob pinned after upgrade = %v, want shared pin propagated", ids) + } + if findServiceMessageUpdate(t, upgradeRes) == nil { + t.Fatalf("upgrade pin lacks service message, want messageActionPinMessage") + } + + // 再追加一条共享置顶,然后 unpinAll 一次清掉两条。 + sent2, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + Message: "shared target", + RandomID: 92012, + }) + if err != nil { + t.Fatalf("send shared: %v", err) + } + sharedMsg := sent2.(*tg.Updates).Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) + if _, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, alice.ID), &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + ID: sharedMsg.ID, + }); err != nil { + t.Fatalf("shared pin: %v", err) + } + if ids := privatePinnedIDs(t, messageStore, alice.ID, bob.ID); len(ids) != 2 { + t.Fatalf("alice pinned = %v, want 2 before unpinAll", ids) + } + if ids := privatePinnedIDs(t, messageStore, bob.ID, alice.ID); len(ids) != 2 { + t.Fatalf("bob pinned = %v, want 2 shared before unpinAll", ids) + } + + affected, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, alice.ID), &tg.MessagesUnpinAllMessagesRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + }) + if err != nil { + t.Fatalf("unpinAll: %v", err) + } + if affected.Pts == 0 || affected.Offset != 0 { + t.Fatalf("unpinAll affected = %+v, want account pts and offset 0", affected) + } + if ids := privatePinnedIDs(t, messageStore, alice.ID, bob.ID); len(ids) != 0 { + t.Fatalf("alice pinned after unpinAll = %v, want empty", ids) + } + if ids := privatePinnedIDs(t, messageStore, bob.ID, alice.ID); len(ids) != 0 { + t.Fatalf("bob pinned after unpinAll = %v, want empty (共享置顶同步清除)", ids) + } +} + +// TestMessagesUpdatePinnedMessageSavedMessagesAndServiceGuard 验证 Saved +// Messages 置顶(无服务消息)与服务消息禁止置顶。 +func TestMessagesUpdatePinnedMessageSavedMessagesAndServiceGuard(t *testing.T) { + ctx := context.Background() + r, messageStore, userStore := newPinTestRouter(t) + alice, _ := userStore.Create(ctx, domain.User{AccessHash: 75, Phone: "15550009205", FirstName: "Alice"}) + + sent, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerSelf{}, + Message: "saved note", + RandomID: 92021, + }) + if err != nil { + t.Fatalf("send saved: %v", err) + } + savedMsg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) + + pinRes, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, alice.ID), &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerSelf{}, + ID: savedMsg.ID, + }) + if err != nil { + t.Fatalf("pin saved: %v", err) + } + if update := findPinnedMessagesUpdate(t, pinRes); update == nil || !update.Pinned { + t.Fatalf("saved pin update = %+v, want pinned", update) + } + if findServiceMessageUpdate(t, pinRes) != nil { + t.Fatalf("saved messages pin generated service message, want none (无人可通知)") + } + list, _ := messageStore.ListByUser(ctx, alice.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, Limit: 10, + }) + if len(list.Messages) != 1 || !list.Messages[0].Pinned { + t.Fatalf("saved history = %+v, want single pinned message without service message", list.Messages) + } + + // 服务消息不可置顶:先用共享置顶在双人聊天里生成一条服务消息再试。 + bob, _ := userStore.Create(ctx, domain.User{AccessHash: 76, Phone: "15550009206", FirstName: "Bob"}) + sent2, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + Message: "to pin", + RandomID: 92022, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + target := sent2.(*tg.Updates).Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) + if _, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, alice.ID), &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + ID: target.ID, + }); err != nil { + t.Fatalf("pin for service msg: %v", err) + } + aliceList, _ := messageStore.ListByUser(ctx, alice.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, Limit: 10, + }) + serviceID := 0 + for _, msg := range aliceList.Messages { + if msg.Media != nil && msg.Media.Kind == domain.MessageMediaKindService { + serviceID = msg.ID + } + } + if serviceID == 0 { + t.Fatalf("no service message found in alice history: %+v", aliceList.Messages) + } + if _, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, alice.ID), &tg.MessagesUpdatePinnedMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + ID: serviceID, + }); err == nil { + t.Fatalf("pin service message succeeded, want MESSAGE_ID_INVALID") + } +} diff --git a/internal/rpc/messages_polls.go b/internal/rpc/messages_polls.go new file mode 100644 index 00000000..16e9d4c4 --- /dev/null +++ b/internal/rpc/messages_polls.go @@ -0,0 +1,343 @@ +package rpc + +import ( + "context" + "encoding/hex" + "strings" + "unicode/utf8" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// pollOptionsHex 把 options 编码成日志可读形态(每个 option 一段 hex)。 +func pollOptionsHex(options [][]byte) []string { + out := make([]string, 0, len(options)) + for _, option := range options { + out = append(out, hex.EncodeToString(option)) + } + return out +} + +// onMessagesSendVote 给 poll 投票(options 为空 = 撤票)。响应必须内联 updateMessagePoll: +// TDesktop 靠它清 sendingVotes,否则 UI 卡 pending。 +func (r *Router) onMessagesSendVote(ctx context.Context, req *tg.MessagesSendVoteRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := validatePollOptions(req.Options, true); err != nil { + return nil, err + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + // 双端联调诊断:记录收到的原始 options(空 = 撤票)与处理结果,定位“投票不生效”一类问题。 + r.log.Info("sendVote received", + zap.Int64("user_id", userID), + zap.String("peer_type", string(peer.Type)), + zap.Int64("peer_id", peer.ID), + zap.Int("msg_id", req.MsgID), + zap.Int("option_count", len(req.Options)), + zap.Strings("options_hex", pollOptionsHex(req.Options)), + ) + now := int(r.clock.Now().Unix()) + switch peer.Type { + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return nil, messageIDInvalidErr() + } + res, err := r.deps.Channels.VoteMessagePoll(ctx, userID, domain.VoteChannelMessagePollRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.MsgID, + Options: req.Options, + Date: now, + }) + if err != nil { + r.log.Warn("sendVote failed", zap.Error(err), zap.Int64("user_id", userID), zap.Int("msg_id", req.MsgID)) + return nil, pollMutationErr(err) + } + r.logPollOutcome("sendVote applied", userID, res.PollID, res.Message.Media) + return r.channelPollUpdates(ctx, userID, peer, req.MsgID, res, true), nil + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return nil, messageIDInvalidErr() + } + res, err := r.deps.Messages.VoteMessagePoll(ctx, userID, domain.VotePrivateMessagePollRequest{ + UserID: userID, + Peer: peer, + MessageID: req.MsgID, + Options: req.Options, + Date: now, + }) + if err != nil { + r.log.Warn("sendVote failed", zap.Error(err), zap.Int64("user_id", userID), zap.Int("msg_id", req.MsgID)) + return nil, pollMutationErr(err) + } + for _, msg := range res.Messages { + if msg.OwnerUserID == userID { + r.logPollOutcome("sendVote applied", userID, res.PollID, msg.Media) + } + } + return r.privatePollUpdates(ctx, userID, res), nil + default: + return nil, peerIDInvalidErr() + } +} + +// logPollOutcome 摘要化 viewer 视角的 poll 状态(chosen/总票数),与 sendVote received 配对读。 +func (r *Router) logPollOutcome(event string, viewerUserID, pollID int64, media *domain.MessageMedia) { + if media == nil || media.Poll == nil || media.Poll.Results == nil { + r.log.Warn(event+" but poll results missing", zap.Int64("user_id", viewerUserID), zap.Int64("poll_id", pollID)) + return + } + results := media.Poll.Results + chosen := make([]string, 0, 1) + for _, item := range results.Voters { + if item.Chosen { + chosen = append(chosen, hex.EncodeToString(item.Option)) + } + } + r.log.Info(event, + zap.Int64("user_id", viewerUserID), + zap.Int64("poll_id", pollID), + zap.Bool("closed", media.Poll.Closed), + zap.Int("total_voters", results.TotalVoters), + zap.Bool("viewer_voted", results.ViewerVoted), + zap.Strings("viewer_chosen_hex", chosen), + ) +} + +// onMessagesGetPollResults 返回 viewer 视角的最新 poll 状态(updateMessagePoll 内联 poll)。 +func (r *Router) onMessagesGetPollResults(ctx context.Context, req *tg.MessagesGetPollResultsRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + poll, _, found := r.loadMessagePoll(ctx, userID, peer, req.MsgID) + if !found { + return nil, messageIDInvalidErr() + } + r.logPollOutcome("getPollResults", userID, poll.ID, &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: poll}) + update := tgUpdateMessagePoll(peer, req.MsgID, poll) + if update == nil { + return nil, messageIDInvalidErr() + } + users, chats := r.pollUpdateRefs(ctx, userID, peer, poll, domain.Channel{}) + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: users, + Chats: chats, + Date: int(r.clock.Now().Unix()), + }, nil +} + +// onMessagesGetPollVotes 列出公开投票的投票人(按 vote_date DESC 分页)。 +func (r *Router) onMessagesGetPollVotes(ctx context.Context, req *tg.MessagesGetPollVotesRequest) (*tg.MessagesVotesList, error) { + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + option, hasOption := req.GetOption() + if hasOption { + if err := validatePollOption(option); err != nil { + return nil, err + } + } + offset, _ := req.GetOffset() + if len(offset) > maxPollVotesOffsetLength { + return nil, limitInvalidErr() + } + offsetDate, offsetUserID, ok := decodePollVotesOffset(offset) + if !ok { + return nil, limitInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + if view.Channel.Broadcast && !view.Channel.Megagroup { + return nil, tgerr.New(403, "BROADCAST_FORBIDDEN") + } + } + if r.deps.Polls == nil { + return nil, messageIDInvalidErr() + } + poll, _, found := r.loadMessagePoll(ctx, userID, peer, req.ID) + if !found { + return nil, messageIDInvalidErr() + } + if !poll.PublicVoters { + return nil, pollVoteRequiredErr() + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxPollVotesPageLimit { + limit = domain.MaxPollVotesPageLimit + } + list, err := r.deps.Polls.ListPollVotes(ctx, domain.PollVotesListRequest{ + PollID: poll.ID, + Option: option, + OffsetDate: offsetDate, + OffsetUserID: offsetUserID, + Limit: limit, + }) + if err != nil { + return nil, pollMutationErr(err) + } + votes := make([]tg.MessagePeerVoteClass, 0, len(list.Votes)) + userIDs := make([]int64, 0, len(list.Votes)) + for _, vote := range list.Votes { + userIDs = append(userIDs, vote.UserID) + votes = append(votes, tgMessagePeerVote(vote, option, hasOption)) + } + out := &tg.MessagesVotesList{ + Count: list.Count, + Votes: votes, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: r.tgUsersForIDs(ctx, userID, userIDs), + } + if list.HasMore { + out.SetNextOffset(pollVotesNextOffset(list.Votes)) + } + return out, nil +} + +// tgMessagePeerVote 输出单个投票人条目;按 option 过滤时官方用 messagePeerVoteInputOption 省字节。 +func tgMessagePeerVote(vote domain.PollVote, option []byte, filtered bool) tg.MessagePeerVoteClass { + peer := &tg.PeerUser{UserID: vote.UserID} + if filtered { + return &tg.MessagePeerVoteInputOption{Peer: peer, Date: vote.Date} + } + if len(vote.Options) == 1 { + return &tg.MessagePeerVote{Peer: peer, Option: vote.Options[0], Date: vote.Date} + } + return &tg.MessagePeerVoteMultiple{Peer: peer, Options: vote.Options, Date: vote.Date} +} + +func (r *Router) onMessagesAddPollAnswer(ctx context.Context, req *tg.MessagesAddPollAnswerRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := validatePollAnswer(req.Answer); err != nil { + return nil, err + } + if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { + return nil, err + } + return nil, messageIDInvalidErr() +} + +func (r *Router) onMessagesDeletePollAnswer(ctx context.Context, req *tg.MessagesDeletePollAnswerRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if err := validatePollOption(req.Option); err != nil { + return nil, err + } + if _, _, err := r.reactionPeer(ctx, req.Peer, nil); err != nil { + return nil, err + } + return nil, messageIDInvalidErr() +} + +func (r *Router) onMessagesGetUnreadPollVotes(ctx context.Context, req *tg.MessagesGetUnreadPollVotesRequest) (tg.MessagesMessagesClass, error) { + if err := validateHistoryBounds(req.OffsetID, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { + return nil, err + } + userID, _, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, messageIDInvalidErr() + } + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Topics: []tg.ForumTopicClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }, nil +} + +func (r *Router) onMessagesReadPollVotes(ctx context.Context, req *tg.MessagesReadPollVotesRequest) (*tg.MessagesAffectedHistory, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, messageIDInvalidErr() + } + return r.affectedHistory(ctx, authKeyID, userID, 0) +} + +func validatePollOptions(options [][]byte, allowEmpty bool) error { + if len(options) == 0 { + if allowEmpty { + return nil + } + return optionInvalidErr() + } + if len(options) > maxPollVoteOptions { + return optionsTooMuchErr() + } + for _, option := range options { + if err := validatePollOption(option); err != nil { + return err + } + } + return nil +} + +func validatePollOption(option []byte) error { + if len(option) == 0 || len(option) > maxPollOptionBytes { + return optionInvalidErr() + } + return nil +} + +func validatePollAnswer(answer tg.PollAnswerClass) error { + if answer == nil { + return pollAnswerInvalidErr() + } + text := answer.GetText() + if strings.TrimSpace(text.Text) == "" || utf8.RuneCountInString(text.Text) > maxTodoTitleLength { + return pollAnswerInvalidErr() + } + if len(text.Entities) > maxMessageEntityCount { + return limitInvalidErr() + } + switch typed := answer.(type) { + case *tg.PollAnswer: + if err := validatePollOption(typed.Option); err != nil { + return err + } + if typed.Media != nil { + return mediaInvalidErr() + } + case *tg.InputPollAnswer: + if typed.Media != nil { + return mediaInvalidErr() + } + default: + return inputConstructorInvalidErr() + } + return nil +} diff --git a/internal/rpc/messages_polls_rpc_test.go b/internal/rpc/messages_polls_rpc_test.go new file mode 100644 index 00000000..cb1d99a3 --- /dev/null +++ b/internal/rpc/messages_polls_rpc_test.go @@ -0,0 +1,407 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" +) + +// sendTestPoll 用 sendMedia 发一个 poll,返回消息与 poll id。 +func sendTestPoll(t *testing.T, r *Router, fromID int64, peer tg.InputPeerClass, randomID int64, mutate func(*tg.InputMediaPoll)) *tg.Message { + t.Helper() + media := &tg.InputMediaPoll{ + Poll: tg.Poll{ + Question: tg.TextWithEntities{Text: "favorite color?", Entities: []tg.MessageEntityClass{}}, + Answers: []tg.PollAnswerClass{ + &tg.PollAnswer{Text: tg.TextWithEntities{Text: "red", Entities: []tg.MessageEntityClass{}}, Option: []byte{0}}, + &tg.PollAnswer{Text: tg.TextWithEntities{Text: "blue", Entities: []tg.MessageEntityClass{}}, Option: []byte{1}}, + &tg.PollAnswer{Text: tg.TextWithEntities{Text: "green", Entities: []tg.MessageEntityClass{}}, Option: []byte{2}}, + }, + }, + } + if mutate != nil { + mutate(media) + } + updates, err := r.onMessagesSendMedia(WithUserID(context.Background(), fromID), &tg.MessagesSendMediaRequest{ + Peer: peer, + Media: media, + RandomID: randomID, + }) + if err != nil { + t.Fatalf("sendMedia poll: %v", err) + } + return newMessageFromUpdates(t, updates) +} + +func pollFromUpdates(t *testing.T, updates tg.UpdatesClass) (*tg.Poll, *tg.PollResults) { + t.Helper() + upd, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("expected *tg.Updates, got %T", updates) + } + for _, u := range upd.Updates { + if mp, ok := u.(*tg.UpdateMessagePoll); ok { + poll, _ := mp.GetPoll() + return &poll, &mp.Results + } + } + t.Fatalf("no updateMessagePoll in %#v", upd.Updates) + return nil, nil +} + +func votersByOption(t *testing.T, results *tg.PollResults) map[byte]tg.PollAnswerVoters { + t.Helper() + out := map[byte]tg.PollAnswerVoters{} + list, _ := results.GetResults() + for _, item := range list { + if len(item.Option) != 1 { + t.Fatalf("unexpected option key %v", item.Option) + } + out[item.Option[0]] = item + } + return out +} + +func TestSendMediaPollEcho(t *testing.T) { + ctx := context.Background() + _ = ctx + r, owner, friend := newMediaTestRouter(t) + + msg := sendTestPoll(t, r, owner.ID, &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, 4001, nil) + media, ok := msg.Media.(*tg.MessageMediaPoll) + if !ok { + t.Fatalf("expected MessageMediaPoll, got %T", msg.Media) + } + if media.Poll.ID == 0 { + t.Error("poll id should be allocated server-side") + } + if media.Poll.Question.Text != "favorite color?" || len(media.Poll.Answers) != 3 { + t.Fatalf("poll definition mangled: %+v", media.Poll) + } + if media.Poll.Closed { + t.Error("fresh poll should be open") + } +} + +func TestSendVotePrivateChooseChangeRetract(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + peerForFriend := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash} + + msg := sendTestPoll(t, r, owner.ID, &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, 4002, nil) + pollID := msg.Media.(*tg.MessageMediaPoll).Poll.ID + + // friend 的 box id 与 owner 的相同(同一会话双方各自 box 计数都从 1 开始且只有这一条)。 + friendCtx := WithUserID(ctx, friend.ID) + updates, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{ + Peer: peerForFriend, + MsgID: msg.ID, + Options: [][]byte{{1}}, + }) + if err != nil { + t.Fatalf("sendVote: %v", err) + } + poll, results := pollFromUpdates(t, updates) + if poll.ID != pollID { + t.Fatalf("update poll id = %d, want %d", poll.ID, pollID) + } + byOption := votersByOption(t, results) + if total, _ := results.GetTotalVoters(); total != 1 { + t.Fatalf("total voters = %d, want 1", total) + } + if v := byOption[1]; !v.Chosen || mustVoters(t, v) != 1 { + t.Fatalf("option 1 = %+v, want chosen with 1 voter", v) + } + if v := byOption[0]; v.Chosen || mustVoters(t, v) != 0 { + t.Fatalf("option 0 = %+v, want not chosen 0 voters", v) + } + + // 改票:单选 poll 重投另一项。 + updates, err = r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{ + Peer: peerForFriend, + MsgID: msg.ID, + Options: [][]byte{{2}}, + }) + if err != nil { + t.Fatalf("sendVote change: %v", err) + } + _, results = pollFromUpdates(t, updates) + byOption = votersByOption(t, results) + if v := byOption[2]; !v.Chosen || mustVoters(t, v) != 1 { + t.Fatalf("after change option 2 = %+v, want chosen 1", v) + } + if v := byOption[1]; v.Chosen || mustVoters(t, v) != 0 { + t.Fatalf("after change option 1 = %+v, want cleared", v) + } + + // 撤票:空 options。 + updates, err = r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{ + Peer: peerForFriend, + MsgID: msg.ID, + }) + if err != nil { + t.Fatalf("sendVote retract: %v", err) + } + _, results = pollFromUpdates(t, updates) + if total, _ := results.GetTotalVoters(); total != 0 { + t.Fatalf("total after retract = %d, want 0", total) + } + + // 非法选项。 + if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{ + Peer: peerForFriend, + MsgID: msg.ID, + Options: [][]byte{{9}}, + }); err == nil || !tgerr.Is(err, "OPTION_INVALID") { + t.Fatalf("invalid option err = %v, want OPTION_INVALID", err) + } + // 单选投两项。 + if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{ + Peer: peerForFriend, + MsgID: msg.ID, + Options: [][]byte{{0}, {1}}, + }); err == nil || !tgerr.Is(err, "OPTION_INVALID") { + t.Fatalf("multi options on single choice err = %v, want OPTION_INVALID", err) + } +} + +func mustVoters(t *testing.T, v tg.PollAnswerVoters) int { + t.Helper() + count, _ := v.GetVoters() + return count +} + +func TestSendVoteQuizRevealsAndLocks(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + peerForFriend := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash} + + msg := sendTestPoll(t, r, owner.ID, &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, 4003, func(in *tg.InputMediaPoll) { + in.Poll.Quiz = true + in.SetCorrectAnswers([]int{1}) + in.SetSolution("blue is correct") + in.SetSolutionEntities([]tg.MessageEntityClass{}) + }) + friendCtx := WithUserID(ctx, friend.ID) + + // 投票前(owner 自己查结果)不得泄漏 correct/solution。 + ownerCtx := WithUserID(ctx, owner.ID) + preUpdates, err := r.onMessagesGetPollResults(ownerCtx, &tg.MessagesGetPollResultsRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + MsgID: msg.ID, + }) + if err != nil { + t.Fatalf("getPollResults pre-vote: %v", err) + } + _, preResults := pollFromUpdates(t, preUpdates) + if _, has := preResults.GetSolution(); has { + t.Fatal("solution must not leak before voting/closing") + } + for _, v := range votersByOption(t, preResults) { + if v.Correct { + t.Fatal("correct flag must not leak before voting/closing") + } + } + + // friend 答错:correct 标在正确选项上,solution 揭示。 + updates, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{ + Peer: peerForFriend, + MsgID: msg.ID, + Options: [][]byte{{0}}, + }) + if err != nil { + t.Fatalf("quiz vote: %v", err) + } + _, results := pollFromUpdates(t, updates) + byOption := votersByOption(t, results) + if !byOption[0].Chosen || byOption[0].Correct { + t.Fatalf("wrong answer entry = %+v, want chosen non-correct", byOption[0]) + } + if !byOption[1].Correct { + t.Fatalf("correct answer entry = %+v, want correct flag", byOption[1]) + } + if solution, has := results.GetSolution(); !has || solution != "blue is correct" { + t.Fatalf("solution = %q (%v), want revealed after voting", solution, has) + } + + // quiz 不可改票/撤票。 + if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{ + Peer: peerForFriend, + MsgID: msg.ID, + Options: [][]byte{{1}}, + }); err == nil || !tgerr.Is(err, "REVOTE_NOT_ALLOWED") { + t.Fatalf("quiz revote err = %v, want REVOTE_NOT_ALLOWED", err) + } + if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{ + Peer: peerForFriend, + MsgID: msg.ID, + }); err == nil || !tgerr.Is(err, "REVOTE_NOT_ALLOWED") { + t.Fatalf("quiz retract err = %v, want REVOTE_NOT_ALLOWED", err) + } +} + +func TestEditMessageClosePoll(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + ownerPeer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash} + friendPeer := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash} + + msg := sendTestPoll(t, r, owner.ID, ownerPeer, 4004, nil) + pollID := msg.Media.(*tg.MessageMediaPoll).Poll.ID + ownerCtx := WithUserID(ctx, owner.ID) + friendCtx := WithUserID(ctx, friend.ID) + + closeReq := func() *tg.MessagesEditMessageRequest { + req := &tg.MessagesEditMessageRequest{Peer: ownerPeer, ID: msg.ID} + closed := tg.Poll{ID: pollID, Closed: true} + req.SetMedia(&tg.InputMediaPoll{Poll: closed}) + return req + } + + // 非创建者不能关闭。 + friendClose := &tg.MessagesEditMessageRequest{Peer: friendPeer, ID: msg.ID} + friendClose.SetMedia(&tg.InputMediaPoll{Poll: tg.Poll{ID: pollID, Closed: true}}) + if _, err := r.onMessagesEditMessage(friendCtx, friendClose); err == nil || !tgerr.Is(err, "MESSAGE_AUTHOR_REQUIRED") { + t.Fatalf("non-creator close err = %v, want MESSAGE_AUTHOR_REQUIRED", err) + } + + updates, err := r.onMessagesEditMessage(ownerCtx, closeReq()) + if err != nil { + t.Fatalf("close poll: %v", err) + } + poll, _ := pollFromUpdates(t, updates) + if !poll.Closed { + t.Fatal("poll should be closed after edit") + } + + // 关闭后投票必须被拒。 + if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{ + Peer: friendPeer, + MsgID: msg.ID, + Options: [][]byte{{0}}, + }); err == nil || !tgerr.Is(err, "MESSAGE_POLL_CLOSED") { + t.Fatalf("vote on closed poll err = %v, want MESSAGE_POLL_CLOSED", err) + } + + // 对端视角读取也必须看到 closed(权威覆盖快照)。 + res, err := r.onMessagesGetPollResults(friendCtx, &tg.MessagesGetPollResultsRequest{Peer: friendPeer, MsgID: msg.ID}) + if err != nil { + t.Fatalf("getPollResults after close: %v", err) + } + peerPoll, _ := pollFromUpdates(t, res) + if !peerPoll.Closed { + t.Fatal("peer view should see closed poll (authority overrides snapshot)") + } +} + +func TestGetPollVotesPublicOnly(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + ownerPeer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash} + friendPeer := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash} + ownerCtx := WithUserID(ctx, owner.ID) + friendCtx := WithUserID(ctx, friend.ID) + + // 非公开 poll:getPollVotes 拒绝。 + anonymous := sendTestPoll(t, r, owner.ID, ownerPeer, 4005, nil) + if _, err := r.onMessagesGetPollVotes(ownerCtx, &tg.MessagesGetPollVotesRequest{ + Peer: ownerPeer, ID: anonymous.ID, Limit: 10, + }); err == nil || !tgerr.Is(err, "POLL_VOTE_REQUIRED") { + t.Fatalf("anonymous poll votes err = %v, want POLL_VOTE_REQUIRED", err) + } + + public := sendTestPoll(t, r, owner.ID, ownerPeer, 4006, func(in *tg.InputMediaPoll) { + in.Poll.PublicVoters = true + }) + if _, err := r.onMessagesSendVote(friendCtx, &tg.MessagesSendVoteRequest{ + Peer: friendPeer, MsgID: public.ID, Options: [][]byte{{1}}, + }); err != nil { + t.Fatalf("public vote: %v", err) + } + votes, err := r.onMessagesGetPollVotes(ownerCtx, &tg.MessagesGetPollVotesRequest{ + Peer: ownerPeer, ID: public.ID, Limit: 10, + }) + if err != nil { + t.Fatalf("getPollVotes: %v", err) + } + if votes.Count != 1 || len(votes.Votes) != 1 { + t.Fatalf("votes = %+v, want exactly friend's vote", votes) + } + single, ok := votes.Votes[0].(*tg.MessagePeerVote) + if !ok { + t.Fatalf("vote entry type = %T, want MessagePeerVote", votes.Votes[0]) + } + if peerUser, ok := single.Peer.(*tg.PeerUser); !ok || peerUser.UserID != friend.ID { + t.Fatalf("vote peer = %#v, want friend", single.Peer) + } + if len(single.Option) != 1 || single.Option[0] != 1 { + t.Fatalf("vote option = %v, want [1]", single.Option) + } + foundFriend := false + for _, u := range votes.Users { + if got, ok := u.(*tg.User); ok && got.ID == friend.ID { + foundFriend = true + } + } + if !foundFriend { + t.Fatal("votes.users should contain the voter entity") + } +} + +func TestSendMediaPollValidation(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + peer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash} + ownerCtx := WithUserID(ctx, owner.ID) + + send := func(mutate func(*tg.InputMediaPoll)) error { + media := &tg.InputMediaPoll{ + Poll: tg.Poll{ + Question: tg.TextWithEntities{Text: "q", Entities: []tg.MessageEntityClass{}}, + Answers: []tg.PollAnswerClass{ + &tg.PollAnswer{Text: tg.TextWithEntities{Text: "a", Entities: []tg.MessageEntityClass{}}, Option: []byte{0}}, + &tg.PollAnswer{Text: tg.TextWithEntities{Text: "b", Entities: []tg.MessageEntityClass{}}, Option: []byte{1}}, + }, + }, + } + if mutate != nil { + mutate(media) + } + _, err := r.onMessagesSendMedia(ownerCtx, &tg.MessagesSendMediaRequest{Peer: peer, Media: media, RandomID: int64(5000 + len(media.Poll.Answers))}) + return err + } + + if err := send(func(in *tg.InputMediaPoll) { + in.Poll.Answers = in.Poll.Answers[:1] + }); err == nil || !tgerr.Is(err, "OPTION_INVALID") { + t.Fatalf("single answer err = %v, want OPTION_INVALID", err) + } + if err := send(func(in *tg.InputMediaPoll) { + in.Poll.Question.Text = " " + }); err == nil || !tgerr.Is(err, "MEDIA_EMPTY") { + t.Fatalf("empty question err = %v, want MEDIA_EMPTY", err) + } + if err := send(func(in *tg.InputMediaPoll) { + in.Poll.Answers[1].(*tg.PollAnswer).Option = []byte{0} + }); err == nil || !tgerr.Is(err, "POLL_OPTION_INVALID") { + t.Fatalf("duplicate option err = %v, want POLL_OPTION_INVALID", err) + } + if err := send(func(in *tg.InputMediaPoll) { + in.Poll.Quiz = true + }); err == nil || !tgerr.Is(err, "QUIZ_CORRECT_ANSWERS_INVALID") { + t.Fatalf("quiz without correct err = %v, want QUIZ_CORRECT_ANSWERS_INVALID", err) + } + if err := send(func(in *tg.InputMediaPoll) { + in.Poll.Quiz = true + in.SetCorrectAnswers([]int{5}) + }); err == nil || !tgerr.Is(err, "QUIZ_CORRECT_ANSWERS_INVALID") { + t.Fatalf("quiz bad index err = %v, want QUIZ_CORRECT_ANSWERS_INVALID", err) + } + if err := send(func(in *tg.InputMediaPoll) { + in.SetCorrectAnswers([]int{0}) + }); err == nil || !tgerr.Is(err, "MEDIA_INVALID") { + t.Fatalf("non-quiz with correct err = %v, want MEDIA_INVALID", err) + } +} diff --git a/internal/rpc/messages_polls_tdesktop_shape_test.go b/internal/rpc/messages_polls_tdesktop_shape_test.go new file mode 100644 index 00000000..cfb2ff1f --- /dev/null +++ b/internal/rpc/messages_polls_tdesktop_shape_test.go @@ -0,0 +1,143 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" +) + +// 回归:TDesktop 创建 poll 的真实请求形状(kDefaultPollCreateFlags 默认带 +// public_voters/multiple_choice/open_answers/shuffle_answers,答案为无 option 的 +// inputPollAnswer——option 键由服务端分配)。修复前 open_answers 被一刀切 +// MEDIA_INVALID,inputPollAnswer 被 POLL_ANSWER_INVALID。 +func TestSendMediaPollTDesktopShape(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + // 第一个答案带配图(实测 TDesktop 创建面板可给答案附 photo/document), + // 引用 newMediaTestRouter 预置的 document 555。 + withMedia := &tg.InputPollAnswer{Text: tg.TextWithEntities{Text: "r", Entities: []tg.MessageEntityClass{}}} + withMedia.SetMedia(&tg.InputMediaDocument{ID: &tg.InputDocument{ID: 555, AccessHash: 5}}) + media := &tg.InputMediaPoll{ + Poll: tg.Poll{ + ID: 0, + PublicVoters: true, + MultipleChoice: true, + OpenAnswers: true, + ShuffleAnswers: true, + Question: tg.TextWithEntities{Text: "color?", Entities: []tg.MessageEntityClass{}}, + Answers: []tg.PollAnswerClass{ + withMedia, + &tg.InputPollAnswer{Text: tg.TextWithEntities{Text: "blue", Entities: []tg.MessageEntityClass{}}}, + &tg.InputPollAnswer{Text: tg.TextWithEntities{Text: "green", Entities: []tg.MessageEntityClass{}}}, + }, + }, + } + updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: media, + RandomID: 9001, + }) + if err != nil { + t.Fatalf("sendMedia TDesktop poll shape: %v", err) + } + msg := newMessageFromUpdates(t, updates) + mediaPoll, ok := msg.Media.(*tg.MessageMediaPoll) + if !ok { + t.Fatalf("expected MessageMediaPoll, got %T", msg.Media) + } + poll := mediaPoll.Poll + if !poll.PublicVoters || !poll.MultipleChoice || !poll.ShuffleAnswers { + t.Fatalf("create flags lost: %+v", poll) + } + if poll.OpenAnswers { + t.Fatal("open_answers must be stripped (addPollAnswer not wired, no add-answer UI entry)") + } + if len(poll.Answers) != 3 { + t.Fatalf("answers = %d, want 3", len(poll.Answers)) + } + seen := map[string]struct{}{} + for i, answerClass := range poll.Answers { + answer, ok := answerClass.(*tg.PollAnswer) + if !ok { + t.Fatalf("echoed answer %d type = %T, want pollAnswer", i, answerClass) + } + if len(answer.Option) == 0 { + t.Fatalf("answer %d option not assigned server-side", i) + } + if _, dup := seen[string(answer.Option)]; dup { + t.Fatalf("answer %d option duplicated", i) + } + seen[string(answer.Option)] = struct{}{} + } + // 答案配图必须随快照回 echo。 + firstAnswer := poll.Answers[0].(*tg.PollAnswer) + answerMedia, hasAnswerMedia := firstAnswer.GetMedia() + if !hasAnswerMedia { + t.Fatal("first answer media lost") + } + docMedia, ok := answerMedia.(*tg.MessageMediaDocument) + if !ok { + t.Fatalf("answer media = %T, want MessageMediaDocument", answerMedia) + } + if doc, ok := docMedia.Document.(*tg.Document); !ok || doc.ID != 555 { + t.Fatalf("answer media document = %#v, want id 555", docMedia.Document) + } + + // 服务端分配的 option 必须能直接用于投票。 + firstOption := poll.Answers[0].(*tg.PollAnswer).Option + voteUpdates, err := r.onMessagesSendVote(WithUserID(ctx, friend.ID), &tg.MessagesSendVoteRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + MsgID: msg.ID, + Options: [][]byte{firstOption}, + }) + if err != nil { + t.Fatalf("vote with server-assigned option: %v", err) + } + _, results := pollFromUpdates(t, voteUpdates) + if total, _ := results.GetTotalVoters(); total != 1 { + t.Fatalf("total voters = %d, want 1", total) + } +} + +// 回归:quiz 同样走 inputPollAnswer + 下标 correct_answers,服务端分配 option 后下标语义必须成立。 +func TestSendMediaQuizTDesktopShape(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + media := &tg.InputMediaPoll{ + Poll: tg.Poll{ + Quiz: true, + Question: tg.TextWithEntities{Text: "2+2?", Entities: []tg.MessageEntityClass{}}, + Answers: []tg.PollAnswerClass{ + &tg.InputPollAnswer{Text: tg.TextWithEntities{Text: "3", Entities: []tg.MessageEntityClass{}}}, + &tg.InputPollAnswer{Text: tg.TextWithEntities{Text: "4", Entities: []tg.MessageEntityClass{}}}, + }, + }, + } + media.SetCorrectAnswers([]int{1}) + updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: media, + RandomID: 9002, + }) + if err != nil { + t.Fatalf("sendMedia quiz shape: %v", err) + } + msg := newMessageFromUpdates(t, updates) + poll := msg.Media.(*tg.MessageMediaPoll).Poll + correctOption := poll.Answers[1].(*tg.PollAnswer).Option + + voteUpdates, err := r.onMessagesSendVote(WithUserID(ctx, friend.ID), &tg.MessagesSendVoteRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + MsgID: msg.ID, + Options: [][]byte{correctOption}, + }) + if err != nil { + t.Fatalf("quiz vote: %v", err) + } + _, results := pollFromUpdates(t, voteUpdates) + byOption := votersByOption(t, results) + if v := byOption[correctOption[0]]; !v.Chosen || !v.Correct { + t.Fatalf("correct answer entry = %+v, want chosen+correct", v) + } +} diff --git a/internal/rpc/messages_polls_updates.go b/internal/rpc/messages_polls_updates.go new file mode 100644 index 00000000..2200aee7 --- /dev/null +++ b/internal/rpc/messages_polls_updates.go @@ -0,0 +1,264 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// 本文件是 poll 链路的响应/推送/事件辅助,与 reaction(messages_reactions_send.go)同构: +// - 私聊:双方各记一条 message_poll durable event(无 pts 的 updateMessagePoll + aux pts 簿记), +// 离线端经 getDifference 拿到消息快照与最新 poll 状态; +// - 频道:实时 fan-out 给在线 viewer(作者进 explicit 收件人),无 durable event—— +// 与 channel reaction 现状一致(离线缺口见 compatibility-matrix.md,客户端靠 getPollResults 刷新)。 + +// pollMutationErr 把 poll/消息域错误映射为 RPC error。 +func pollMutationErr(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, domain.ErrPollClosed): + return pollClosedErr() + case errors.Is(err, domain.ErrPollRevoteNotAllowed): + return revoteNotAllowedErr() + case errors.Is(err, domain.ErrPollOptionInvalid): + return optionInvalidErr() + case errors.Is(err, domain.ErrPollNotCreator): + return tgerr.New(403, "MESSAGE_AUTHOR_REQUIRED") + case errors.Is(err, domain.ErrPollNotFound), errors.Is(err, domain.ErrPollInvalid), errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + default: + return channelInvalidErr(err) + } +} + +// loadMessagePoll 按 viewer 视角加载消息上的 poll(已 enrich)。 +func (r *Router) loadMessagePoll(ctx context.Context, userID int64, peer domain.Peer, msgID int) (*domain.MessagePoll, domain.Peer, bool) { + switch peer.Type { + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return nil, peer, false + } + history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID}) + if err != nil { + return nil, peer, false + } + for _, msg := range history.Messages { + if msg.ID == msgID && msg.Media != nil && msg.Media.Kind == domain.MessageMediaKindPoll && msg.Media.Poll != nil { + return msg.Media.Poll, peer, true + } + } + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return nil, peer, false + } + list, err := r.deps.Messages.GetMessages(ctx, userID, []int{msgID}) + if err != nil { + return nil, peer, false + } + for _, msg := range list.Messages { + if msg.ID == msgID && msg.Peer == peer && msg.Media != nil && msg.Media.Kind == domain.MessageMediaKindPoll && msg.Media.Poll != nil { + return msg.Media.Poll, peer, true + } + } + } + return nil, peer, false +} + +// pollUpdateRefs 收集 updateMessagePoll 响应需要的 users/chats(recent voters 头像 + channel 实体)。 +// channel 由调用方传入(频道 poll 用结果里的 res.Channel;私聊传零值):与 reaction fan-out 同款用 +// 单个 channel 一次性投影 tgChannels,避免频道 poll fan-out 每 viewer 一次 GetChannel 的 DB N+1 +// (poll 聚合 N+1 已由 ChannelPollFanoutViews 消除,这是 poll fan-out 残留的 per-viewer DB 调用)。 +func (r *Router) pollUpdateRefs(ctx context.Context, viewerUserID int64, peer domain.Peer, poll *domain.MessagePoll, channel domain.Channel) ([]tg.UserClass, []tg.ChatClass) { + userIDs := make([]int64, 0, domain.MaxPollRecentVoters) + if poll != nil && poll.Results != nil { + userIDs = append(userIDs, poll.Results.RecentVoters...) + } + users := r.tgUsersForIDs(ctx, viewerUserID, userIDs) + chats := []tg.ChatClass{} + if peer.Type == domain.PeerTypeChannel { + if channel.ID != 0 { + // fan-out 路径:调用方已带 channel,一次性投影(同 reaction),免 per-viewer GetChannel。 + chats = tgChannels(viewerUserID, []domain.Channel{channel}) + } else if r.deps.Channels != nil { + // 非 fan-out 单 viewer 路径(getPollResults):channel 未带,保持原 GetChannel 行为不变。 + if view, err := r.deps.Channels.GetChannel(ctx, viewerUserID, peer.ID); err == nil && view.Channel.ID != 0 { + chats = []tg.ChatClass{tgChannelChatForView(viewerUserID, view)} + } + } + } + return users, chats +} + +// privatePollUpdates 记录双方 durable event、推送双方在线 session,并返回投票者视角响应。 +func (r *Router) privatePollUpdates(ctx context.Context, requestUserID int64, res domain.PrivateMessagePollResult) tg.UpdatesClass { + recordedEvents := r.recordPrivateMessagePollEvents(ctx, requestUserID, res) + var requesterUpdates *tg.Updates + for _, msg := range res.Messages { + if msg.ID <= 0 || msg.Media == nil || msg.Media.Poll == nil { + continue + } + update := tgUpdateMessagePoll(msg.Peer, msg.ID, msg.Media.Poll) + if update == nil { + continue + } + users, chats := r.pollUpdateRefs(ctx, msg.OwnerUserID, msg.Peer, msg.Media.Poll, domain.Channel{}) + updates := &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: users, + Chats: chats, + Date: int(r.clock.Now().Unix()), + } + // message_poll 事件占账号 pts 但 updateMessagePoll 无 pts 字段,附 aux 簿记推水位。 + updates.Updates = appendAuxPtsBookkeeping(updates.Updates, recordedEvents[msg.OwnerUserID]) + if msg.OwnerUserID == requestUserID { + requesterUpdates = updates + } + r.pushUserUpdates(ctx, msg.OwnerUserID, updates) + } + if requesterUpdates == nil { + return tgEmptyUpdates(int(r.clock.Now().Unix())) + } + return requesterUpdates +} + +func (r *Router) recordPrivateMessagePollEvents(ctx context.Context, requestUserID int64, res domain.PrivateMessagePollResult) map[int64]domain.UpdateEvent { + if r.deps.Updates == nil { + return nil + } + recorder, ok := r.deps.Updates.(messagePollUpdateRecorder) + if !ok { + return nil + } + authKeyID, _ := AuthKeyIDFrom(ctx) + events := make(map[int64]domain.UpdateEvent, len(res.Messages)) + for _, msg := range res.Messages { + if msg.OwnerUserID == 0 || msg.ID == 0 { + continue + } + eventAuthKeyID := [8]byte{} + if msg.OwnerUserID == requestUserID { + eventAuthKeyID = authKeyID + } + event, _, err := recorder.RecordMessagePoll(ctx, eventAuthKeyID, msg.OwnerUserID, msg) + if err != nil { + r.log.Warn("record message poll event failed") + continue + } + events[msg.OwnerUserID] = event + } + return events +} + +// onEditMessageClosePoll 处理 editMessage + InputMediaPoll:当前唯一支持的 poll 编辑是 +// 关闭(closed=true,仅 poll 创建者);改题/改选项无官方客户端路径,显式拒绝。 +func (r *Router) onEditMessageClosePoll(ctx context.Context, req *tg.MessagesEditMessageRequest, media *tg.InputMediaPoll) (tg.UpdatesClass, error) { + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if !media.Poll.Closed { + return nil, mediaInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + now := int(r.clock.Now().Unix()) + switch peer.Type { + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return nil, messageIDInvalidErr() + } + res, err := r.deps.Channels.CloseMessagePoll(ctx, userID, domain.CloseChannelMessagePollRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.ID, + Date: now, + }) + if err != nil { + return nil, pollMutationErr(err) + } + return r.channelPollUpdates(ctx, userID, peer, req.ID, res, true), nil + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return nil, messageIDInvalidErr() + } + res, err := r.deps.Messages.CloseMessagePoll(ctx, userID, domain.ClosePrivateMessagePollRequest{ + UserID: userID, + Peer: peer, + MessageID: req.ID, + Date: now, + }) + if err != nil { + return nil, pollMutationErr(err) + } + return r.privatePollUpdates(ctx, userID, res), nil + default: + return nil, peerIDInvalidErr() + } +} + +// channelPollUpdates 组装投票者视角响应;push 为 true 时按 viewer 重建并 fan-out 给在线成员。 +// +// Phase 4 模板化:fan-out 前一次性批量加载所有收件人的 per-viewer poll 投影(ChannelPollFanoutViews +// 把 viewer-invariant 聚合只算一次 + 批量 viewerOptions/可见性),消除原先每 viewer 一次 GetMessages +// 的 N+1。dispatch 仍同步(poll 是 viewer-only 无 durable event,改异步丢队列即永久漏)。预取未覆盖 +// 的 viewer 回退逐 viewer loadMessagePoll 保正确(与旧路径字节同源)。actor echo 仍用 res poll(旧行为)。 +func (r *Router) channelPollUpdates(ctx context.Context, userID int64, peer domain.Peer, msgID int, res domain.ChannelMessagePollResult, push bool) tg.UpdatesClass { + var batched map[int64]*domain.MessagePoll + if push && peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + recipients := r.channelFanoutRecipients(ctx, channelFanoutViewers, res.Channel.ID, res.Recipients) + if len(recipients) > 0 { + views, err := r.deps.Channels.ChannelPollFanoutViews(ctx, res.Channel.ID, msgID, recipients, int(r.clock.Now().Unix())) + if err != nil { + r.log.Warn("channel poll fanout prefetch failed; falling back to per-viewer reload", + zap.Int64("channel_id", res.Channel.ID), zap.Int("msg_id", msgID), zap.Error(err)) + } else { + batched = views + } + } + } + build := func(viewerUserID int64) *tg.Updates { + poll := res.Message.Media.Poll + if viewerUserID != userID { + // 其它 viewer 的 chosen/correct/solution 门控不同,按其视角取投影。 + if p, evaluated := batched[viewerUserID]; evaluated { + if p == nil { + return nil // 预取已判定该 viewer 不可见 + } + poll = p + } else { + // 预取未覆盖(极少:并发/收件人集漂移)→ 回退逐 viewer 重载。 + reloaded, _, found := r.loadMessagePoll(ctx, viewerUserID, peer, msgID) + if !found { + return nil + } + poll = reloaded + } + } + update := tgUpdateMessagePoll(peer, msgID, poll) + if update == nil { + return nil + } + users, chats := r.pollUpdateRefs(ctx, viewerUserID, peer, poll, res.Channel) + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: users, + Chats: chats, + Date: int(r.clock.Now().Unix()), + } + } + updates := build(userID) + if push { + r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, res.Recipients, build) + } + if updates == nil { + return tgEmptyUpdates(int(r.clock.Now().Unix())) + } + return updates +} diff --git a/internal/rpc/messages_private_rpc_test.go b/internal/rpc/messages_private_rpc_test.go new file mode 100644 index 00000000..9ab09d14 --- /dev/null +++ b/internal/rpc/messages_private_rpc_test.go @@ -0,0 +1,166 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strings" + appcontacts "telesrv/internal/app/contacts" + appdialogs "telesrv/internal/app/dialogs" + appmessages "telesrv/internal/app/messages" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" + "time" +) + +func TestMessagesSendMultiMediaRateLimitCountsItems(t *testing.T) { + const userID = int64(1000000001) + limiter := &captureRateLimiter{block: true, retryAfter: 11} + r := New(Config{SendRateLimit: 2, SendRateWindow: 3 * time.Second}, Deps{Limiter: limiter}, zaptest.NewLogger(t), clock.System) + + _, err := r.onMessagesSendMultiMedia(WithUserID(context.Background(), userID), &tg.MessagesSendMultiMediaRequest{ + Peer: &tg.InputPeerSelf{}, + MultiMedia: []tg.InputSingleMedia{ + {Media: &tg.InputMediaEmpty{}, RandomID: 1}, + {Media: &tg.InputMediaEmpty{}, RandomID: 2}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "FLOOD_WAIT") || !strings.Contains(err.Error(), "(11)") { + t.Fatalf("sendMultiMedia rate err = %v, want FLOOD_WAIT 11", err) + } + if len(limiter.calls) != 1 || limiter.calls[0].cost != 2 { + t.Fatalf("limiter calls = %+v, want one cost=2", limiter.calls) + } +} + +func TestMessagesPrivateBlockPreventsRecipientInboxAndRevokeRPC(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009101", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550009102", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + dialogs := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogs) + contactStore := memory.NewContactStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Contacts: appcontacts.NewService(contactStore, userStore), + Messages: appmessages.NewService(messageStore, dialogs), + Dialogs: appdialogs.NewService(dialogs), + }, zaptest.NewLogger(t), clock.System) + + delivered, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + Message: "before block", + RandomID: 91001, + }) + if err != nil { + t.Fatalf("send before block: %v", err) + } + deliveredUpdates := delivered.(*tg.Updates) + deliveredMsg := deliveredUpdates.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) + + if ok, err := r.onContactsBlock(WithUserID(ctx, bob.ID), &tg.ContactsBlockRequest{ + ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, + }); err != nil || !ok { + t.Fatalf("bob block alice = %v, %v", ok, err) + } + blockedSend, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + Message: "after block", + RandomID: 91002, + }) + if err != nil { + t.Fatalf("send after block: %v", err) + } + blockedUpdates := blockedSend.(*tg.Updates) + blockedMsg := blockedUpdates.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) + if blockedMsg.ID == 0 || !blockedMsg.Out { + t.Fatalf("blocked sender update = %#v, want outgoing sender message", blockedMsg) + } + bobHistory, err := messageStore.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("bob history: %v", err) + } + if len(bobHistory.Messages) != 1 || bobHistory.Messages[0].Body != "before block" { + t.Fatalf("bob history = %+v, want only pre-block delivered message", bobHistory.Messages) + } + aliceHistory, err := messageStore.ListByUser(ctx, alice.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("alice history: %v", err) + } + if len(aliceHistory.Messages) != 2 { + t.Fatalf("alice history len = %d, want delivered + sender-only blocked message", len(aliceHistory.Messages)) + } + // 官方对私聊 revoke 不做 block 限制:被拉黑后撤回自己的消息仍双向 + // 生效(客户端本地已先删,服务端报错只会造成消息复活错乱)。 + deleteReq := &tg.MessagesDeleteMessagesRequest{ID: []int{deliveredMsg.ID}} + deleteReq.SetRevoke(true) + if _, err := r.onMessagesDeleteMessages(WithUserID(ctx, alice.ID), deleteReq); err != nil { + t.Fatalf("revoke after block err = %v, want success", err) + } + bobAfter, err := messageStore.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("bob history after revoke: %v", err) + } + if len(bobAfter.Messages) != 0 { + t.Fatalf("bob history after revoke = %+v, want revoked message removed on blocked side too", bobAfter.Messages) + } +} + +func TestMessagesGetMessageEditDataPrivateValidatesAuthor(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + messages := &captureMessages{list: domain.MessageList{ + Messages: []domain.Message{{ + ID: 3, + OwnerUserID: userID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: userID}, + Out: true, + Body: "editable", + }}, + }} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + + got, err := r.onMessagesGetMessageEditData(WithUserID(context.Background(), userID), &tg.MessagesGetMessageEditDataRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + ID: 3, + }) + if err != nil { + t.Fatalf("get private edit data: %v", err) + } + if got.GetCaption() { + t.Fatalf("private edit data caption = true, want false for text-only message") + } + + messages.list.Messages[0].Out = false + if _, err := r.onMessagesGetMessageEditData(WithUserID(context.Background(), userID), &tg.MessagesGetMessageEditDataRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + ID: 3, + }); err == nil || !strings.Contains(err.Error(), "MESSAGE_AUTHOR_REQUIRED") { + t.Fatalf("non-author edit data err = %v, want MESSAGE_AUTHOR_REQUIRED", err) + } +} diff --git a/internal/rpc/messages_quick_replies.go b/internal/rpc/messages_quick_replies.go new file mode 100644 index 00000000..c2e5516a --- /dev/null +++ b/internal/rpc/messages_quick_replies.go @@ -0,0 +1,395 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func (r *Router) onMessagesGetQuickReplies(ctx context.Context, hash int64) (tg.MessagesQuickRepliesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return &tg.MessagesQuickReplies{ + QuickReplies: []tg.QuickReply{}, + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil + } + list, err := svc.ListQuickReplies(ctx, userID) + if err != nil { + return nil, businessAutomationErr(err) + } + if hash != 0 && hash == list.Hash { + return &tg.MessagesQuickRepliesNotModified{}, nil + } + out := tgMessagesQuickReplies(list) + out.Users = r.quickReplyUsers(ctx, userID) + return out, nil +} + +func (r *Router) onMessagesCheckQuickReplyShortcut(ctx context.Context, shortcut string) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, premiumAccountRequiredErr() + } + available, err := svc.CheckQuickReplyShortcut(ctx, userID, shortcut) + if err != nil { + return false, businessAutomationErr(err) + } + return available, nil +} + +func (r *Router) onMessagesReorderQuickReplies(ctx context.Context, order []int) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, premiumAccountRequiredErr() + } + mutation, err := svc.ReorderQuickReplies(ctx, userID, order) + if err != nil { + return false, businessAutomationErr(err) + } + if err := r.pushQuickReplyMutation(ctx, userID, mutation); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesEditQuickReplyShortcut(ctx context.Context, req *tg.MessagesEditQuickReplyShortcutRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req == nil || req.ShortcutID <= 0 { + return false, shortcutInvalidErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, premiumAccountRequiredErr() + } + mutation, err := svc.RenameQuickReplyShortcut(ctx, userID, req.ShortcutID, req.Shortcut) + if err != nil { + return false, businessAutomationErr(err) + } + if err := r.pushQuickReplyMutation(ctx, userID, mutation); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesDeleteQuickReplyShortcut(ctx context.Context, shortcutID int) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if shortcutID <= 0 { + return false, shortcutInvalidErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return false, shortcutInvalidErr() + } + mutation, err := svc.DeleteQuickReplyShortcut(ctx, userID, shortcutID) + if err != nil { + return false, businessAutomationErr(err) + } + if err := r.pushQuickReplyMutation(ctx, userID, mutation); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesGetQuickReplyMessages(ctx context.Context, req *tg.MessagesGetQuickReplyMessagesRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req == nil || len(req.ID) > domain.MaxQuickReplyMessages { + return nil, shortcutInvalidErr() + } + if req.ShortcutID <= 0 { + return emptyQuickReplyMessages(), nil + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return emptyQuickReplyMessages(), nil + } + list, err := svc.GetQuickReplyMessages(ctx, userID, req.ShortcutID, req.ID) + if err != nil { + if errors.Is(err, domain.ErrShortcutInvalid) { + return emptyQuickReplyMessages(), nil + } + return nil, businessAutomationErr(err) + } + if req.Hash != 0 && req.Hash == list.Hash { + return &tg.MessagesMessagesNotModified{Count: list.Count}, nil + } + out := tgMessagesQuickReplyMessages(list) + out.Users = r.quickReplyUsers(ctx, userID) + return out, nil +} + +func emptyQuickReplyMessages() *tg.MessagesMessages { + return &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +func (r *Router) onMessagesSendQuickReplyMessages(ctx context.Context, req *tg.MessagesSendQuickReplyMessagesRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req == nil || req.ShortcutID <= 0 || len(req.ID) > domain.MaxQuickReplyMessages || len(req.RandomID) > domain.MaxQuickReplyMessages { + return nil, shortcutInvalidErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok || r.deps.Messages == nil { + return nil, shortcutInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeUser { + return nil, peerIDInvalidErr() + } + list, err := svc.GetQuickReplyMessages(ctx, userID, req.ShortcutID, req.ID) + if err != nil { + return nil, businessAutomationErr(err) + } + if len(list.Messages) == 0 { + return &tg.Updates{Updates: []tg.UpdateClass{}, Users: []tg.UserClass{}, Chats: []tg.ChatClass{}, Date: int(r.clock.Now().Unix())}, nil + } + randomIDs := append([]int64(nil), req.RandomID...) + if len(randomIDs) == 0 { + randomIDs = r.quickReplyRandomIDs(len(list.Messages)) + } + if len(randomIDs) != len(list.Messages) { + return nil, inputRequestInvalidErr() + } + for _, randomID := range randomIDs { + if randomID == 0 { + return nil, randomIDEmptyErr() + } + } + if err := r.checkSendRateLimit(ctx, userID, len(list.Messages)); err != nil { + return nil, err + } + recipientBlocked, err := r.peerBlocksUser(ctx, userID, peer.ID) + if err != nil { + return nil, err + } + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + res := domain.ForwardPrivateMessagesResult{OwnerUserID: userID} + now := int(r.clock.Now().Unix()) + for i, template := range list.Messages { + sent, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: peer.ID, + RandomID: randomIDs[i], + Message: template.Message, + Entities: append([]domain.MessageEntity(nil), template.Entities...), + Date: now, + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + RecipientBlocked: recipientBlocked, + }) + if err != nil { + return nil, messageSendErr(err) + } + res.SenderMessages = append(res.SenderMessages, sent.SenderMessage) + res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage) + res.SenderEvents = append(res.SenderEvents, sent.SenderEvent) + res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent) + res.Duplicates = append(res.Duplicates, sent.Duplicate) + } + return tgForwardMessagesUpdates(res, randomIDs, r.usersForMessageUpdates(ctx, userID, res.SenderMessages), r.chatsForMessageUpdates(ctx, userID, res.SenderMessages)), nil +} + +func (r *Router) onMessagesDeleteQuickReplyMessages(ctx context.Context, req *tg.MessagesDeleteQuickReplyMessagesRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req == nil || req.ShortcutID <= 0 || len(req.ID) == 0 || len(req.ID) > domain.MaxQuickReplyMessages { + return nil, shortcutInvalidErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return nil, shortcutInvalidErr() + } + mutation, err := svc.DeleteQuickReplyMessages(ctx, userID, req.ShortcutID, req.ID) + if err != nil { + return nil, businessAutomationErr(err) + } + return r.quickReplyMutationUpdates(ctx, userID, mutation, nil) +} + +func (r *Router) onMessagesSaveQuickReplyText(ctx context.Context, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + shortcut, err := quickReplyShortcutName(req.QuickReplyShortcut) + if err != nil { + return nil, err + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeUser || peer.ID != userID { + return nil, shortcutInvalidErr() + } + svc, ok := r.accountBusinessAutomation() + if !ok { + return nil, premiumAccountRequiredErr() + } + mutation, err := svc.SaveQuickReplyText(ctx, userID, shortcut, domain.QuickReplyMessage{ + RandomID: req.RandomID, + Date: int(r.clock.Now().Unix()), + Message: req.Message, + // 快速回复模板与普通发送一致补服务端自动实体(url/@mention/#hashtag/bot command)。 + Entities: domainMessageEntitiesForViewer(userID, augmentAutoEntities(req.Message, req.Entities)), + }) + if err != nil { + return nil, businessAutomationErr(err) + } + return r.quickReplyMutationUpdates(ctx, userID, mutation, []tg.UpdateClass{ + &tg.UpdateMessageID{ID: mutation.Message.ID, RandomID: req.RandomID}, + }) +} + +func quickReplyShortcutName(input tg.InputQuickReplyShortcutClass) (string, error) { + switch shortcut := input.(type) { + case *tg.InputQuickReplyShortcut: + return shortcut.Shortcut, nil + default: + return "", shortcutInvalidErr() + } +} + +func (r *Router) quickReplyRandomIDs(n int) []int64 { + base := r.clock.Now().UnixNano() + if base == 0 { + base = 1 + } + out := make([]int64, n) + for i := range out { + out[i] = base + int64(i) + if out[i] == 0 { + out[i] = int64(i + 1) + } + } + return out +} + +func (r *Router) quickReplyUsers(ctx context.Context, userID int64) []tg.UserClass { + if r.deps.Users == nil || userID == 0 { + return []tg.UserClass{} + } + self, err := r.deps.Users.Self(ctx, userID) + if err != nil || self.ID == 0 { + return []tg.UserClass{} + } + return []tg.UserClass{r.tgSelfUser(self)} +} + +func (r *Router) quickReplyMutationUpdates(ctx context.Context, userID int64, mutation domain.QuickReplyMutation, prefix []tg.UpdateClass) (*tg.Updates, error) { + event, err := r.recordQuickReplyMutation(ctx, userID, mutation) + if err != nil { + return nil, err + } + updates := append([]tg.UpdateClass(nil), prefix...) + if update := tgOtherUpdateFromEvent(event); update != nil { + updates = append(updates, update) + } + updates = appendAuxPtsBookkeeping(updates, event) + date := event.Date + if date == 0 { + date = int(r.clock.Now().Unix()) + } + return &tg.Updates{Updates: updates, Users: r.quickReplyUsers(ctx, userID), Chats: []tg.ChatClass{}, Date: date, Seq: 0}, nil +} + +func (r *Router) pushQuickReplyMutation(ctx context.Context, userID int64, mutation domain.QuickReplyMutation) error { + event, err := r.recordQuickReplyMutation(ctx, userID, mutation) + if err != nil { + return err + } + updates := make([]tg.UpdateClass, 0, 2) + if update := tgOtherUpdateFromEvent(event); update != nil { + updates = append(updates, update) + } + updates = appendAuxPtsBookkeeping(updates, event) + r.bookkeepAuxPtsForCurrentSession(ctx, event) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: updates, + Users: r.quickReplyUsers(ctx, userID), + Chats: []tg.ChatClass{}, + Date: event.Date, + Seq: 0, + }) + return nil +} + +func (r *Router) recordQuickReplyMutation(ctx context.Context, userID int64, mutation domain.QuickReplyMutation) (domain.UpdateEvent, error) { + if r.deps.Updates == nil { + return quickReplyEventFromMutation(userID, mutation, int(r.clock.Now().Unix())), nil + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, _, err := r.deps.Updates.RecordQuickReplyMutation(ctx, authKeyID, userID, mutation, sessionID) + if err != nil { + return domain.UpdateEvent{}, internalErr() + } + return event, nil +} + +func quickReplyEventFromMutation(userID int64, mutation domain.QuickReplyMutation, date int) domain.UpdateEvent { + event := domain.UpdateEvent{ + UserID: userID, + Date: date, + QuickReply: mutation.QuickReply, + MaxID: mutation.ShortcutID, + MessageIDs: append([]int(nil), mutation.MessageIDs...), + } + switch mutation.Kind { + case domain.QuickReplyMutationNew: + event.Type = domain.UpdateEventNewQuickReply + event.QuickReply = mutation.QuickReply + case domain.QuickReplyMutationDelete: + event.Type = domain.UpdateEventDeleteQuickReply + case domain.QuickReplyMutationMessage: + event.Type = domain.UpdateEventQuickReplyMessage + event.QuickReplyMessage = mutation.Message + case domain.QuickReplyMutationIDs: + event.Type = domain.UpdateEventDeleteQuickReplyMessages + default: + event.Type = domain.UpdateEventQuickReplies + event.QuickReplies = append([]domain.QuickReply(nil), mutation.List.QuickReplies...) + } + if event.Date == 0 && mutation.Date != 0 { + event.Date = mutation.Date + } + return event +} diff --git a/internal/rpc/messages_reactions_catalog.go b/internal/rpc/messages_reactions_catalog.go new file mode 100644 index 00000000..706b50fa --- /dev/null +++ b/internal/rpc/messages_reactions_catalog.go @@ -0,0 +1,276 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "hash/fnv" + "strconv" + "strings" + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +func (r *Router) onMessagesGetTopReactions(ctx context.Context, req *tg.MessagesGetTopReactionsRequest) (tg.MessagesReactionsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + limit := req.Limit + if limit == 0 { + limit = defaultTopReactionsLimit + } + reactions := []domain.MessageReaction{} + if r.deps.Channels != nil { + var err error + reactions, err = r.deps.Channels.TopReactions(ctx, userID, limit) + if err != nil { + return nil, channelInvalidErr(err) + } + } + return messagesReactionsFromDomain(r.reactionsWithCatalogFallback(ctx, reactions, limit), req.Hash), nil +} + +func (r *Router) onMessagesGetRecentReactions(ctx context.Context, req *tg.MessagesGetRecentReactionsRequest) (tg.MessagesReactionsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + if r.deps.Channels == nil { + return messagesReactionsEmpty(req.Hash), nil + } + reactions, err := r.deps.Channels.RecentReactions(ctx, userID, req.Limit) + if err != nil { + return nil, channelInvalidErr(err) + } + return messagesReactionsFromDomain(reactions, req.Hash), nil +} + +func (r *Router) onMessagesClearRecentReactions(ctx context.Context) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Channels != nil { + if err := r.deps.Channels.ClearRecentReactions(ctx, userID); err != nil { + return false, channelInvalidErr(err) + } + } + return true, nil +} + +func (r *Router) onMessagesGetSavedReactionTags(ctx context.Context, req *tg.MessagesGetSavedReactionTagsRequest) (tg.MessagesSavedReactionTagsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if peer, ok := req.GetPeer(); ok && peer != nil { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { + return nil, err + } + return savedReactionTagsEmpty(req.Hash), nil + } + if r.deps.Channels == nil { + return savedReactionTagsEmpty(req.Hash), nil + } + tags, err := r.deps.Channels.SavedReactionTags(ctx, userID, domain.MaxSavedReactionTags) + if err != nil { + return nil, channelInvalidErr(err) + } + return savedReactionTagsFromDomain(tags, req.Hash), nil +} + +func (r *Router) onMessagesGetDefaultTagReactions(ctx context.Context, hash int64) (tg.MessagesReactionsClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return messagesReactionsEmpty(hash), nil +} + +func messagesReactionsEmpty(hash int64) tg.MessagesReactionsClass { + if hash != 0 { + return &tg.MessagesReactionsNotModified{} + } + return &tg.MessagesReactions{ + Hash: 0, + Reactions: []tg.ReactionClass{}, + } +} + +func messagesReactionsFromDomain(reactions []domain.MessageReaction, requestHash int64) tg.MessagesReactionsClass { + hash := messageReactionListHash(reactions) + if hash != 0 && requestHash == hash { + return &tg.MessagesReactionsNotModified{} + } + out := make([]tg.ReactionClass, 0, len(reactions)) + for _, reaction := range reactions { + tgReaction := tgMessageReaction(reaction) + if tgReaction != nil { + out = append(out, tgReaction) + } + } + return &tg.MessagesReactions{ + Hash: hash, + Reactions: out, + } +} + +func savedReactionTagsEmpty(_ int64) tg.MessagesSavedReactionTagsClass { + return &tg.MessagesSavedReactionTags{ + Tags: []tg.SavedReactionTag{}, + Hash: 0, + } +} + +func savedReactionTagsFromDomain(tags []domain.SavedReactionTag, requestHash int64) tg.MessagesSavedReactionTagsClass { + hash := savedReactionTagListHash(tags) + if hash != 0 && requestHash == hash { + return &tg.MessagesSavedReactionTagsNotModified{} + } + out := make([]tg.SavedReactionTag, 0, len(tags)) + for _, tag := range tags { + reaction := tgMessageReaction(tag.Reaction) + if reaction == nil { + continue + } + item := tg.SavedReactionTag{ + Reaction: reaction, + Count: tag.Count, + } + if tag.Title != "" { + item.SetTitle(tag.Title) + } + out = append(out, item) + } + if len(out) == 0 { + return savedReactionTagsEmpty(requestHash) + } + return &tg.MessagesSavedReactionTags{ + Tags: out, + Hash: hash, + } +} + +func (r *Router) reactionsWithCatalogFallback(ctx context.Context, reactions []domain.MessageReaction, limit int) []domain.MessageReaction { + return mergeReactionCatalogFallback(reactions, r.availableReactionCatalog(ctx, limit), limit) +} + +func (r *Router) availableReactionCatalog(ctx context.Context, limit int) []domain.MessageReaction { + if limit <= 0 { + return nil + } + if r.deps.Files != nil { + catalog, err := r.deps.Files.ListAvailableReactions(ctx) + if err == nil { + out := make([]domain.MessageReaction, 0, min(limit, len(catalog))) + for _, item := range catalog { + emoticon := strings.TrimSpace(item.Reaction) + if item.Inactive || emoticon == "" { + continue + } + out = append(out, domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}) + if len(out) >= limit { + return out + } + } + if len(out) > 0 { + return out + } + } + } + return staticReactionCatalog() +} + +func staticReactionCatalog() []domain.MessageReaction { + emoticons := tdesktop.DefaultReactionEmoticons() + out := make([]domain.MessageReaction, 0, len(emoticons)) + for _, emoticon := range emoticons { + out = append(out, domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}) + } + return out +} + +func mergeReactionCatalogFallback(reactions, fallback []domain.MessageReaction, limit int) []domain.MessageReaction { + if limit <= 0 { + return []domain.MessageReaction{} + } + if limit > domain.MaxTopMessageReactions { + limit = domain.MaxTopMessageReactions + } + out := make([]domain.MessageReaction, 0, limit) + seen := make(map[string]struct{}, limit) + for _, reaction := range reactions { + if !reaction.Valid() { + continue + } + key := reaction.Key() + if _, ok := seen[key]; ok { + continue + } + out = append(out, reaction) + seen[key] = struct{}{} + if len(out) >= limit { + return out + } + } + for _, reaction := range fallback { + if !reaction.Valid() { + continue + } + key := reaction.Key() + if _, ok := seen[key]; ok { + continue + } + out = append(out, reaction) + seen[key] = struct{}{} + if len(out) >= limit { + return out + } + } + return out +} + +func messageReactionListHash(reactions []domain.MessageReaction) int64 { + if len(reactions) == 0 { + return 0 + } + h := fnv.New64a() + for _, reaction := range reactions { + _, _ = h.Write([]byte(reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(reaction.Value())) + _, _ = h.Write([]byte{0xff}) + } + sum := int64(h.Sum64() & 0x7fffffffffffffff) + if sum == 0 { + return 1 + } + return sum +} + +func savedReactionTagListHash(tags []domain.SavedReactionTag) int64 { + if len(tags) == 0 { + return 0 + } + h := fnv.New64a() + for _, tag := range tags { + _, _ = h.Write([]byte(tag.Reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(tag.Reaction.Value())) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(tag.Title)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(strconv.Itoa(tag.Count))) + _, _ = h.Write([]byte{0xff}) + } + sum := int64(h.Sum64() & 0x7fffffffffffffff) + if sum == 0 { + return 1 + } + return sum +} diff --git a/internal/rpc/messages_reactions_helpers.go b/internal/rpc/messages_reactions_helpers.go new file mode 100644 index 00000000..98cfe719 --- /dev/null +++ b/internal/rpc/messages_reactions_helpers.go @@ -0,0 +1,185 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "strings" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onMessagesUpdateSavedReactionTag(ctx context.Context, req *tg.MessagesUpdateSavedReactionTagRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + reaction, err := domainMessageReactionFromTL(req.Reaction) + if err != nil { + return false, err + } + if reaction.Type != domain.MessageReactionEmoji { + return false, reactionInvalidErr() + } + title, ok := req.GetTitle() + if !ok { + title = "" + } + if utf8.RuneCountInString(title) > maxSavedReactionTagTitle { + return false, limitInvalidErr() + } + if r.deps.Channels != nil { + if err := r.deps.Channels.UpdateSavedReactionTag(ctx, userID, domain.SavedReactionTag{ + UserID: userID, + Reaction: reaction, + Title: title, + }); err != nil { + return false, channelInvalidErr(err) + } + } + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateSavedReactionTags{}}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) + return true, nil +} + +func (r *Router) reactionPeer(ctx context.Context, peer tg.InputPeerClass, savedPeer func() (tg.InputPeerClass, bool)) (int64, domain.Peer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, domain.Peer{}, internalErr() + } + out, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return 0, domain.Peer{}, err + } + if savedPeer != nil { + if input, ok := savedPeer(); ok && input != nil { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input); err != nil { + return 0, domain.Peer{}, err + } + } + } + return userID, out, nil +} + +func domainMessageReactionsFromTL(req *tg.MessagesSendReactionRequest) ([]domain.MessageReaction, error) { + if req == nil { + return nil, nil + } + reactions, ok := req.GetReaction() + if !ok || len(reactions) == 0 { + return nil, nil + } + out := make([]domain.MessageReaction, 0, len(reactions)) + seen := make(map[string]struct{}, len(reactions)) + for _, reaction := range reactions { + parsed, err := domainMessageReactionFromTL(reaction) + if err != nil { + return nil, err + } + key := parsed.Key() + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, parsed) + } + return out, nil +} + +func optionalDomainMessageReaction(reaction tg.ReactionClass) (*domain.MessageReaction, error) { + if reaction == nil { + return nil, nil + } + if reactionClassNil(reaction) { + return nil, reactionInvalidErr() + } + out, err := domainMessageReactionFromTL(reaction) + if err != nil { + return nil, err + } + return &out, nil +} + +func domainMessageReactionFromTL(reaction tg.ReactionClass) (domain.MessageReaction, error) { + if reactionClassNil(reaction) { + return domain.MessageReaction{}, reactionInvalidErr() + } + switch typed := reaction.(type) { + case *tg.ReactionEmoji: + emoticon := strings.TrimSpace(typed.Emoticon) + if emoticon == "" || utf8.RuneCountInString(emoticon) > domain.MaxChannelReactionEmoticonLength { + return domain.MessageReaction{}, reactionInvalidErr() + } + return domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}, nil + case *tg.ReactionCustomEmoji: + if typed.DocumentID <= 0 { + return domain.MessageReaction{}, reactionInvalidErr() + } + return domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: typed.DocumentID}, nil + case nil, *tg.ReactionEmpty, *tg.ReactionPaid: + return domain.MessageReaction{}, reactionInvalidErr() + default: + return domain.MessageReaction{}, inputConstructorInvalidErr() + } +} + +func reactionClassNil(reaction tg.ReactionClass) bool { + switch typed := reaction.(type) { + case nil: + return true + case *tg.ReactionEmpty: + return typed == nil + case *tg.ReactionEmoji: + return typed == nil + case *tg.ReactionCustomEmoji: + return typed == nil + case *tg.ReactionPaid: + return typed == nil + default: + return false + } +} + +func domainStoryReactionValueFromTL(reaction tg.ReactionClass) (domain.MessageReaction, error) { + if reactionClassNil(reaction) { + return domain.MessageReaction{}, reactionInvalidErr() + } + switch typed := reaction.(type) { + case *tg.ReactionEmoji: + return domainMessageReactionFromTL(typed) + case *tg.ReactionCustomEmoji: + if typed.DocumentID <= 0 { + return domain.MessageReaction{}, reactionInvalidErr() + } + return domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: typed.DocumentID}, nil + case nil, *tg.ReactionEmpty, *tg.ReactionPaid: + return domain.MessageReaction{}, reactionInvalidErr() + default: + return domain.MessageReaction{}, inputConstructorInvalidErr() + } +} + +func messageReactionErr(err error) error { + switch { + case errors.Is(err, domain.ErrMessageIDInvalid): + return messageIDInvalidErr() + default: + return internalErr() + } +} + +func channelReactionErr(err error) error { + switch { + case errors.Is(err, domain.ErrReactionInvalid): + return reactionInvalidErr() + case errors.Is(err, domain.ErrReactionsTooMany): + // 官方:消息去重 emoji 种类已达 reactions_uniq_max(或 chat 自定义 reactions_limit)时 + // 新种类 reaction 报 400 REACTIONS_TOO_MANY,追加已有种类不受限。 + return tgerr400("REACTIONS_TOO_MANY") + default: + return channelInvalidErr(err) + } +} diff --git a/internal/rpc/messages_reactions_lists.go b/internal/rpc/messages_reactions_lists.go new file mode 100644 index 00000000..daf42a9c --- /dev/null +++ b/internal/rpc/messages_reactions_lists.go @@ -0,0 +1,353 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "telesrv/internal/domain" +) + +func (r *Router) onMessagesDeleteParticipantReaction(ctx context.Context, req *tg.MessagesDeleteParticipantReactionRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + participant, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Participant) + if err != nil { + return nil, err + } + if participant.Type != domain.PeerTypeUser || participant.ID == 0 { + return nil, userIDInvalidErr() + } + if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + moderator, ok := r.deps.Channels.(channelParticipantReactionModerator) + if !ok { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + res, err := moderator.DeleteParticipantReaction(ctx, userID, domain.DeleteChannelParticipantReactionRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.MsgID, + ParticipantUserID: participant.ID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + updates := r.channelMessageReactionsUpdates(ctx, userID, res) + moderationIDs := []int{res.Message.ID} + r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelReactionsViewerUpdates(ctx, userID, viewerUserID, res, moderationIDs) + }) + return updates, nil +} + +func (r *Router) onMessagesDeleteParticipantReactions(ctx context.Context, req *tg.MessagesDeleteParticipantReactionsRequest) (bool, error) { + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return false, err + } + participant, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Participant) + if err != nil { + return false, err + } + if participant.Type != domain.PeerTypeUser || participant.ID == 0 { + return false, userIDInvalidErr() + } + if peer.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return true, nil + } + moderator, ok := r.deps.Channels.(channelParticipantReactionModerator) + if !ok { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + res, err := moderator.DeleteParticipantReactions(ctx, userID, domain.DeleteChannelParticipantReactionsRequest{ + UserID: userID, + ChannelID: peer.ID, + ParticipantUserID: participant.ID, + Limit: domain.MaxDeleteParticipantReactionsBatch, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return false, channelInvalidErr(err) + } + if len(res.Messages) > 0 { + reactionRes := domain.ChannelMessageReactionsResult{ + Channel: res.Channel, + Messages: res.Messages, + Recipients: res.Recipients, + } + ids := make([]int, 0, len(res.Messages)) + for _, msg := range res.Messages { + if msg.ID > 0 { + ids = append(ids, msg.ID) + } + } + r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelReactionsViewerUpdates(ctx, userID, viewerUserID, reactionRes, ids) + }) + } + return true, nil +} + +func (r *Router) onMessagesGetMessagesReactions(ctx context.Context, req *tg.MessagesGetMessagesReactionsRequest) (tg.UpdatesClass, error) { + if len(req.ID) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + res, err := r.deps.Channels.GetMessageReactions(ctx, userID, domain.ChannelMessageReactionsRequest{ + UserID: userID, + ChannelID: peer.ID, + IDs: append([]int(nil), req.ID...), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + return r.channelMessagesReactionsUpdates(ctx, userID, res, req.ID), nil + } + if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { + res, err := r.deps.Messages.GetMessageReactions(ctx, userID, domain.PrivateMessageReactionsRequest{ + OwnerUserID: userID, + Peer: peer, + IDs: append([]int(nil), req.ID...), + }) + if err != nil { + return nil, messageReactionErr(err) + } + return r.privateMessagesReactionsUpdates(ctx, userID, peer, res, req.ID), nil + } + updates := make([]tg.UpdateClass, 0, len(req.ID)) + tgPeer := tgPeer(peer) + for _, msgID := range req.ID { + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + updates = append(updates, &tg.UpdateMessageReactions{ + Peer: tgPeer, + MsgID: msgID, + Reactions: tg.MessageReactions{ + Results: []tg.ReactionCount{}, + }, + }) + } + return &tg.Updates{ + Updates: updates, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }, nil +} + +func (r *Router) onMessagesGetMessageReactionsList(ctx context.Context, req *tg.MessagesGetMessageReactionsListRequest) (*tg.MessagesMessageReactionsList, error) { + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if req.Limit < 0 || req.Limit > maxSearchResultsLimit { + return nil, limitInvalidErr() + } + if offset, ok := req.GetOffset(); ok && len(offset) > maxReactionListOffset { + return nil, limitInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + filter, err := optionalDomainMessageReaction(req.Reaction) + if err != nil { + return nil, err + } + res, err := r.deps.Channels.ListMessageReactions(ctx, userID, domain.ChannelMessageReactionsListRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.ID, + Reaction: filter, + Offset: optionalString(req.GetOffset), + Limit: req.Limit, + }) + if errors.Is(err, domain.ErrChannelRightForbidden) { + return nil, tgerr.New(403, "BROADCAST_FORBIDDEN") + } + if err != nil { + return nil, channelInvalidErr(err) + } + userIDs := make([]int64, 0, len(res.Reactions)) + reactions := make([]tg.MessagePeerReaction, 0, len(res.Reactions)) + for _, item := range res.Reactions { + if item.UserID != 0 { + userIDs = append(userIDs, item.UserID) + } + if converted := tgMessagePeerReaction(userID, item); converted != nil { + reactions = append(reactions, *converted) + } + } + out := &tg.MessagesMessageReactionsList{ + Count: res.Count, + Reactions: reactions, + Chats: tgChannels(userID, []domain.Channel{res.Channel}), + Users: r.tgUsersForIDs(ctx, userID, userIDs), + } + if res.NextOffset != "" { + out.SetNextOffset(res.NextOffset) + } + return r.applyStoryMaxIDsToMessageReactionsList(ctx, userID, out), nil + } + if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { + filter, err := optionalDomainMessageReaction(req.Reaction) + if err != nil { + return nil, err + } + res, err := r.deps.Messages.GetMessageReactions(ctx, userID, domain.PrivateMessageReactionsRequest{ + OwnerUserID: userID, + Peer: peer, + IDs: []int{req.ID}, + }) + if err != nil { + return nil, messageReactionErr(err) + } + var source domain.ChannelMessageReactions + if len(res.Messages) > 0 && res.Messages[0].Reactions != nil { + source = *res.Messages[0].Reactions + } else { + source = res.Reactions + } + limit := req.Limit + if limit <= 0 || limit > len(source.Recent) { + limit = len(source.Recent) + } + userIDs := []int64{userID, peer.ID} + reactions := make([]tg.MessagePeerReaction, 0, limit) + count := 0 + for _, item := range source.Recent { + if filter != nil && item.Reaction.Key() != filter.Key() { + continue + } + count++ + if len(reactions) >= limit { + continue + } + if item.UserID != 0 { + userIDs = append(userIDs, item.UserID) + } + if converted := tgMessagePeerReaction(userID, item); converted != nil { + reactions = append(reactions, *converted) + } + } + return r.applyStoryMaxIDsToMessageReactionsList(ctx, userID, &tg.MessagesMessageReactionsList{ + Count: count, + Reactions: reactions, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: r.tgUsersForIDs(ctx, userID, userIDs), + }), nil + } + return r.applyStoryMaxIDsToMessageReactionsList(ctx, userID, &tg.MessagesMessageReactionsList{ + Count: 0, + Reactions: []tg.MessagePeerReaction{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + }), nil +} + +func (r *Router) onMessagesGetUnreadReactions(ctx context.Context, req *tg.MessagesGetUnreadReactionsRequest) (tg.MessagesMessagesClass, error) { + if err := validateHistoryBounds(req.OffsetID, req.AddOffset, req.Limit, req.MaxID, req.MinID); err != nil { + return nil, err + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, req.GetSavedPeerID) + if err != nil { + return nil, err + } + if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, messageIDInvalidErr() + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + history, err := r.deps.Channels.GetUnreadReactions(ctx, userID, domain.ChannelUnreadReactionsFilter{ + ChannelID: peer.ID, + TopMsgID: req.TopMsgID, + OffsetID: req.OffsetID, + AddOffset: req.AddOffset, + Limit: req.Limit, + MaxID: req.MaxID, + MinID: req.MinID, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + return r.tgChannelHistoryMessages(ctx, userID, r.enrichChannelHistory(ctx, userID, history)), nil + } + if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { + messages, err := r.deps.Messages.ListUnreadReactionMessages(ctx, userID, peer, req.Limit) + if err != nil { + return nil, internalErr() + } + out := &tg.MessagesMessages{ + Messages: make([]tg.MessageClass, 0, len(messages)), + Topics: []tg.ForumTopicClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: r.usersForMessageUpdates(ctx, userID, messages), + } + for _, msg := range messages { + if item := tgMessage(msg); item != nil { + out.Messages = append(out.Messages, item) + } + } + r.applyStoryMaxIDsToMessages(ctx, userID, out) + return out, nil + } + out := &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Topics: []tg.ForumTopicClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + } + r.applyStoryMaxIDsToMessages(ctx, userID, out) + return out, nil +} + +func (r *Router) onMessagesReadReactions(ctx context.Context, req *tg.MessagesReadReactionsRequest) (*tg.MessagesAffectedHistory, error) { + authKeyID, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if savedPeer, ok := req.GetSavedPeerID(); ok && savedPeer != nil { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, savedPeer); err != nil { + return nil, err + } + } + if topMsgID, ok := req.GetTopMsgID(); ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, messageIDInvalidErr() + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + res, err := r.deps.Channels.ReadReactions(ctx, userID, domain.ReadChannelReactionsRequest{ + UserID: userID, + ChannelID: peer.ID, + TopMsgID: req.TopMsgID, + Limit: domain.MaxChannelReadReactionsBatch, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + return &tg.MessagesAffectedHistory{Pts: res.ChannelPts, PtsCount: 0, Offset: res.Offset}, nil + } + if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { + if _, err := r.deps.Messages.ReadPeerReactions(ctx, userID, peer); err != nil { + return nil, internalErr() + } + } + return r.affectedHistory(ctx, authKeyID, userID, 0) +} diff --git a/internal/rpc/messages_reactions_policy_rpc_test.go b/internal/rpc/messages_reactions_policy_rpc_test.go new file mode 100644 index 00000000..4ecf73b2 --- /dev/null +++ b/internal/rpc/messages_reactions_policy_rpc_test.go @@ -0,0 +1,319 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strings" + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +type reactionPolicyFixture struct { + router *Router + channelSvc *appchannels.Service + sessions *captureSessions + channel domain.Channel + messageID int + ownerID int64 + memberID int64 + member2ID int64 +} + +func newReactionPolicyFixture(t *testing.T, broadcast bool) reactionPolicyFixture { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550003001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550003002", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + member2, err := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550003003", FirstName: "Member2"}) + if err != nil { + t.Fatalf("create member2: %v", err) + } + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Reaction Policy", + Broadcast: broadcast, + Megagroup: !broadcast, + MemberUserIDs: []int64{member.ID, member2.ID}, + Date: 1000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := channelSvc.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 9001, + Message: "react to me", + Date: 1100, + }) + if err != nil { + t.Fatalf("send message: %v", err) + } + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + return reactionPolicyFixture{ + router: r, + channelSvc: channelSvc, + sessions: sessions, + channel: created.Channel, + messageID: sent.Message.ID, + ownerID: owner.ID, + memberID: member.ID, + member2ID: member2.ID, + } +} + +func (f reactionPolicyFixture) sendReaction(t *testing.T, userID int64, emoticons ...string) (tg.UpdatesClass, error) { + t.Helper() + reactions := make([]tg.ReactionClass, 0, len(emoticons)) + if len(emoticons) > 0 { + for _, emoticon := range emoticons { + reactions = append(reactions, &tg.ReactionEmoji{Emoticon: emoticon}) + } + } + return f.sendTLReactions(t, userID, reactions...) +} + +func (f reactionPolicyFixture) sendTLReactions(t *testing.T, userID int64, reactions ...tg.ReactionClass) (tg.UpdatesClass, error) { + t.Helper() + req := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + MsgID: f.messageID, + } + if len(reactions) > 0 { + req.SetReaction(reactions) + } + return f.router.onMessagesSendReaction(WithUserID(context.Background(), userID), req) +} + +func reactionUpdateFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.UpdateMessageReactions { + t.Helper() + box, ok := updates.(*tg.Updates) + if !ok || len(box.Updates) == 0 { + t.Fatalf("updates = %T %+v, want non-empty *tg.Updates", updates, updates) + } + update, ok := box.Updates[0].(*tg.UpdateMessageReactions) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateMessageReactions", box.Updates[0]) + } + return update +} + +func TestSendReactionRespectsChannelReactionPolicy(t *testing.T) { + f := newReactionPolicyFixture(t, false) + ctx := context.Background() + + if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{ + Type: domain.ChannelReactionPolicySome, + Emoticons: []string{"\U0001f44d"}, + }); err != nil { + t.Fatalf("set whitelist policy: %v", err) + } + if _, err := f.sendReaction(t, f.memberID, "❤"); err == nil || !strings.Contains(err.Error(), "REACTION_INVALID") { + t.Fatalf("off-whitelist reaction err = %v, want REACTION_INVALID", err) + } + if _, err := f.sendReaction(t, f.memberID, "\U0001f44d"); err != nil { + t.Fatalf("whitelisted reaction: %v", err) + } + + if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{ + Type: domain.ChannelReactionPolicyNone, + }); err != nil { + t.Fatalf("set none policy: %v", err) + } + if _, err := f.sendReaction(t, f.ownerID, "\U0001f44d"); err == nil || !strings.Contains(err.Error(), "REACTION_INVALID") { + t.Fatalf("reaction under none policy err = %v, want REACTION_INVALID", err) + } + // 策略收紧后撤销存量 reaction 必须仍然可行。 + if _, err := f.sendReaction(t, f.memberID); err != nil { + t.Fatalf("retract reaction under none policy: %v", err) + } +} + +func TestSendReactionAllowsCustomEmojiFromChannelPolicy(t *testing.T) { + f := newReactionPolicyFixture(t, false) + ctx := context.Background() + const customDocumentID int64 = 7770001 + + updates, err := f.router.onMessagesSetChatAvailableReactions(WithUserID(ctx, f.ownerID), &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{ + &tg.ReactionCustomEmoji{DocumentID: customDocumentID}, + }}, + }) + if err != nil { + t.Fatalf("set custom emoji reactions: %v", err) + } + if _, ok := updates.(*tg.Updates); !ok { + t.Fatalf("set custom emoji updates = %T, want *tg.Updates", updates) + } + + if _, err := f.sendReaction(t, f.memberID, "\U0001f44d"); err == nil || !strings.Contains(err.Error(), "REACTION_INVALID") { + t.Fatalf("off-whitelist emoji err = %v, want REACTION_INVALID", err) + } + + f.sessions.channelViewers = map[int64][]int64{f.channel.ID: {f.member2ID}} + sent, err := f.sendTLReactions(t, f.memberID, &tg.ReactionCustomEmoji{DocumentID: customDocumentID}) + if err != nil { + t.Fatalf("send custom emoji reaction: %v", err) + } + update := reactionUpdateFromUpdates(t, sent) + if len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 || update.Reactions.Results[0].ChosenOrder != 1 { + t.Fatalf("custom reaction results = %+v, want one chosen custom reaction", update.Reactions.Results) + } + reaction, ok := update.Reactions.Results[0].Reaction.(*tg.ReactionCustomEmoji) + if !ok || reaction.DocumentID != customDocumentID { + t.Fatalf("custom reaction = %T %+v, want document %d", update.Reactions.Results[0].Reaction, update.Reactions.Results[0].Reaction, customDocumentID) + } + + pushed := f.sessions.pushedUserIDs() + foundOtherViewer := false + for _, userID := range pushed { + if userID == f.member2ID { + foundOtherViewer = true + break + } + } + if !foundOtherViewer { + t.Fatalf("pushed users = %+v, want other online member %d", pushed, f.member2ID) + } +} + +func TestSendReactionEnforcesUniqueReactionsLimit(t *testing.T) { + f := newReactionPolicyFixture(t, false) + ctx := context.Background() + + // reactions_limit 是 appConfig reactions_uniq_max 的 per-chat 覆盖,用 1 触发上限。 + if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{ + Type: domain.ChannelReactionPolicyAll, + Limit: 1, + }); err != nil { + t.Fatalf("set uniq limit policy: %v", err) + } + if _, err := f.sendReaction(t, f.ownerID, "\U0001f44d"); err != nil { + t.Fatalf("first reaction: %v", err) + } + if _, err := f.sendReaction(t, f.memberID, "❤"); err == nil || !strings.Contains(err.Error(), "REACTIONS_TOO_MANY") { + t.Fatalf("new unique emoji past limit err = %v, want REACTIONS_TOO_MANY", err) + } + // 追加已存在的种类不受 uniq 上限约束。 + updates, err := f.sendReaction(t, f.memberID, "\U0001f44d") + if err != nil { + t.Fatalf("existing emoji reaction: %v", err) + } + update := reactionUpdateFromUpdates(t, updates) + if len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 2 { + t.Fatalf("results = %+v, want one emoji with count 2", update.Reactions.Results) + } +} + +func TestSendReactionOverLimitMessageStillAllowsExistingKinds(t *testing.T) { + f := newReactionPolicyFixture(t, false) + ctx := context.Background() + + // 默认策略下先造出 {👍, ❤} 两个去重种类,再把 reactions_limit 调低到 1, + // 模拟「存量已超限」(管理员事后调低 / 部署前无 uniq 闸门的旧数据)。 + if _, err := f.sendReaction(t, f.ownerID, "\U0001f44d"); err != nil { + t.Fatalf("seed owner reaction: %v", err) + } + if _, err := f.sendReaction(t, f.memberID, "❤"); err != nil { + t.Fatalf("seed member reaction: %v", err) + } + if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{ + Type: domain.ChannelReactionPolicyAll, + Limit: 1, + }); err != nil { + t.Fatalf("lower uniq limit: %v", err) + } + + // 重发自己已有的 reaction(no-op)必须放行。 + if _, err := f.sendReaction(t, f.ownerID, "\U0001f44d"); err != nil { + t.Fatalf("owner re-send own reaction on over-limit message: %v", err) + } + if _, err := f.sendReaction(t, f.memberID, "❤"); err != nil { + t.Fatalf("member re-send own reaction on over-limit message: %v", err) + } + // 第三人给已有种类投票必须放行(不引入新种类)。 + if _, err := f.sendReaction(t, f.member2ID, "\U0001f44d"); err != nil { + t.Fatalf("third user piles onto existing kind on over-limit message: %v", err) + } + // 引入新种类仍然 REACTIONS_TOO_MANY。 + if _, err := f.sendReaction(t, f.member2ID, "\U0001f525"); err == nil || !strings.Contains(err.Error(), "REACTIONS_TOO_MANY") { + t.Fatalf("new kind on over-limit message err = %v, want REACTIONS_TOO_MANY", err) + } +} + +func TestSendReactionTrimsVectorToPerUserMax(t *testing.T) { + f := newReactionPolicyFixture(t, false) + + // 超出 reactions_user_max_default 的向量保留尾部最新项,不报错。 + updates, err := f.sendReaction(t, f.memberID, "\U0001f44d", "❤") + if err != nil { + t.Fatalf("send oversized reaction vector: %v", err) + } + update := reactionUpdateFromUpdates(t, updates) + if len(update.Reactions.Results) != 1 { + t.Fatalf("results = %+v, want single trimmed reaction", update.Reactions.Results) + } + emoji, ok := update.Reactions.Results[0].Reaction.(*tg.ReactionEmoji) + if !ok || emoji.Emoticon != "❤" { + t.Fatalf("kept reaction = %+v, want newest vector entry kept", update.Reactions.Results[0].Reaction) + } + if update.Reactions.Results[0].ChosenOrder != 1 { + t.Fatalf("chosen_order = %d, want 1", update.Reactions.Results[0].ChosenOrder) + } + recent, ok := update.Reactions.GetRecentReactions() + if !ok || len(recent) != 1 { + t.Fatalf("megagroup recent reactions = %+v set=%v, want one entry", recent, ok) + } +} + +func TestBroadcastReactionsHideRecentReactors(t *testing.T) { + f := newReactionPolicyFixture(t, true) + + updates, err := f.sendReaction(t, f.memberID, "\U0001f44d") + if err != nil { + t.Fatalf("send broadcast reaction: %v", err) + } + update := reactionUpdateFromUpdates(t, updates) + if len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 { + t.Fatalf("broadcast results = %+v, want count-only aggregate", update.Reactions.Results) + } + if recent, ok := update.Reactions.GetRecentReactions(); ok && len(recent) > 0 { + t.Fatalf("broadcast recent reactions = %+v, want anonymous (absent)", recent) + } + if update.Reactions.CanSeeList { + t.Fatalf("broadcast can_see_list = true, want false") + } +} + +func TestChannelRealtimeFanoutPassesCapToProvider(t *testing.T) { + f := newReactionPolicyFixture(t, false) + f.sessions.channelViewers = map[int64][]int64{f.channel.ID: {f.memberID}} + + if _, err := f.sendReaction(t, f.memberID, "\U0001f44d"); err != nil { + t.Fatalf("send reaction: %v", err) + } + f.sessions.mu.Lock() + gotLimit := f.sessions.channelViewersLimit + f.sessions.mu.Unlock() + if gotLimit != domain.MaxChannelRealtimeFanout { + t.Fatalf("online viewers limit = %d, want MaxChannelRealtimeFanout %d", gotLimit, domain.MaxChannelRealtimeFanout) + } +} diff --git a/internal/rpc/messages_reactions_privacy.go b/internal/rpc/messages_reactions_privacy.go new file mode 100644 index 00000000..f51befe3 --- /dev/null +++ b/internal/rpc/messages_reactions_privacy.go @@ -0,0 +1,203 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +func (r *Router) onMessagesSetDefaultReaction(ctx context.Context, reaction tg.ReactionClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + parsed, err := domainMessageReactionFromTL(reaction) + if err != nil { + return false, err + } + if svc, ok := r.deps.Account.(accountDefaultReactionService); ok { + if _, err := svc.SetDefaultReaction(ctx, userID, parsed); err != nil { + return false, internalErr() + } + } + return true, nil +} + +func (r *Router) onMessagesGetPaidReactionPrivacy(ctx context.Context) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + settings := domain.DefaultAccountReactionSettings() + if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok { + next, err := svc.GetReactionSettings(ctx, userID) + if err != nil { + return nil, internalErr() + } + settings = next + } + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{ + Private: r.tgPaidReactionPrivacy(ctx, userID, settings.PaidPrivacy), + }}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }, nil +} + +func (r *Router) onMessagesTogglePaidReactionPrivacy(ctx context.Context, req *tg.MessagesTogglePaidReactionPrivacyRequest) (bool, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + privacy, err := r.domainPaidReactionPrivacy(ctx, userID, req.Private) + if err != nil { + return false, err + } + if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok { + next, err := svc.SetPaidReactionPrivacy(ctx, userID, privacy) + if err != nil { + return false, internalErr() + } + privacy = next.PaidPrivacy + } + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePaidReactionPrivacy{Private: r.tgPaidReactionPrivacy(ctx, userID, privacy)}}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + }) + return true, nil +} + +// onMessagesSendPaidReaction 为一条广播频道消息发送付费 reaction:从 Stars 账本 Debit +// req.Count 星,累计到消息上,返回带 updateMessageReactions(含 ReactionPaid 总星数 + +// top reactors)与 updateStarsBalance 的 Updates。崩溃约束:必须返回合法 Updates—— +// DrKLO StarsController 对响应无 instanceof 强转 (TLRPC.Updates)。 +func (r *Router) onMessagesSendPaidReaction(ctx context.Context, req *tg.MessagesSendPaidReactionRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if req.Count <= 0 || req.Count > domain.MaxPaidReactionStarsPerRequest { + return nil, starsAmountInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + // 付费 reaction 仅用于(广播)频道帖子。 + if peer.Type != domain.PeerTypeChannel { + return nil, peerIDInvalidErr() + } + anonymous, err := r.resolvePaidReactionAnonymous(ctx, userID, req) + if err != nil { + return nil, err + } + if r.deps.Stars == nil { + return nil, balanceTooLowErr() + } + paidSvc, ok := r.deps.Channels.(channelPaidReactionService) + if !ok { + return nil, notImplementedErr() + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: peer.ID} + // 1. 先从账本 Debit(余额不足→真实 BALANCE_TOO_LOW)。 + balance, err := r.deps.Stars.Debit(ctx, userID, int64(req.Count), domain.StarsReasonReaction, channelPeer, "Paid reaction", "") + if err != nil { + return nil, starsErr(err) + } + // 2. 累计付费 reaction;失败则补偿退款。 + res, err := paidSvc.SendPaidReaction(ctx, userID, domain.SendChannelPaidReactionRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.MsgID, + Stars: int64(req.Count), + Anonymous: anonymous, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + if _, refundErr := r.deps.Stars.Credit(ctx, userID, int64(req.Count), domain.StarsReasonReaction, channelPeer, "Paid reaction refund", ""); refundErr != nil { + r.log.Error("paid reaction refund failed after record error", + zap.Int64("user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("msg_id", req.MsgID), zap.Error(refundErr)) + } + return nil, channelReactionErr(err) + } + // 3. 构建并扇出 updateMessageReactions;请求者额外带 updateStarsBalance。 + ids := []int{res.Message.ID} + build := func(viewerUserID int64) *tg.Updates { + updates := r.channelPaidReactionUpdates(ctx, userID, viewerUserID, res, ids) + if updates != nil && viewerUserID == userID { + updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}) + } + return updates + } + recipients := append([]int64{res.Message.SenderUserID}, res.Recipients...) + r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, recipients, build) + return build(userID), nil +} + +// resolvePaidReactionAnonymous 计算本次付费 reaction 是否匿名:显式 private 优先, +// 缺省回退用户保存的默认付费 reaction 隐私。 +func (r *Router) resolvePaidReactionAnonymous(ctx context.Context, userID int64, req *tg.MessagesSendPaidReactionRequest) (bool, error) { + if private, ok := req.GetPrivate(); ok { + privacy, err := r.domainPaidReactionPrivacy(ctx, userID, private) + if err != nil { + return false, err + } + return privacy.Kind == domain.PaidReactionPrivacyAnonymous, nil + } + if svc, ok := r.deps.Account.(accountPaidReactionPrivacyService); ok { + if settings, err := svc.GetReactionSettings(ctx, userID); err == nil { + return settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyAnonymous, nil + } + } + return false, nil +} + +func (r *Router) domainPaidReactionPrivacy(ctx context.Context, userID int64, in tg.PaidReactionPrivacyClass) (domain.PaidReactionPrivacy, error) { + switch typed := in.(type) { + case nil, *tg.PaidReactionPrivacyDefault: + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyDefault}, nil + case *tg.PaidReactionPrivacyAnonymous: + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyAnonymous}, nil + case *tg.PaidReactionPrivacyPeer: + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, typed.Peer) + if err != nil { + return domain.PaidReactionPrivacy{}, err + } + return domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyPeer, Peer: &peer}, nil + default: + return domain.PaidReactionPrivacy{}, inputConstructorInvalidErr() + } +} + +func (r *Router) tgPaidReactionPrivacy(ctx context.Context, userID int64, in domain.PaidReactionPrivacy) tg.PaidReactionPrivacyClass { + switch in.Kind { + case domain.PaidReactionPrivacyAnonymous: + return &tg.PaidReactionPrivacyAnonymous{} + case domain.PaidReactionPrivacyPeer: + if in.Peer == nil { + return &tg.PaidReactionPrivacyDefault{} + } + if peer := r.inputPeerForDomainPeer(ctx, userID, *in.Peer); peer != nil { + return &tg.PaidReactionPrivacyPeer{Peer: peer} + } + } + return &tg.PaidReactionPrivacyDefault{} +} diff --git a/internal/rpc/messages_reactions_send.go b/internal/rpc/messages_reactions_send.go new file mode 100644 index 00000000..dbfc3a51 --- /dev/null +++ b/internal/rpc/messages_reactions_send.go @@ -0,0 +1,370 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func (r *Router) onMessagesSendReaction(ctx context.Context, req *tg.MessagesSendReactionRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if reactions, ok := req.GetReaction(); ok && len(reactions) > maxReactionVector { + return nil, limitInvalidErr() + } + userID, peer, err := r.reactionPeer(ctx, req.Peer, nil) + if err != nil { + return nil, err + } + reactions, err := domainMessageReactionsFromTL(req) + if err != nil { + return nil, err + } + // 官方语义(reactions_user_max_default/premium):向量尾部是最新选择, + // 超出每用户上限丢弃旧的而非报错;premium viewer 用 premium 档(appConfig + // reactions_user_max_premium=3),否则客户端允许的多 reaction 会被静默裁剪。 + perUserMax := domain.MessageReactionsUserMax(r.viewerPremium(ctx, userID)) + reactions = domain.TrimMessageReactionsToUserMax(reactions, perUserMax) + date := int(r.clock.Now().Unix()) + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + res, err := r.deps.Channels.SetMessageReactions(ctx, userID, domain.SetChannelMessageReactionsRequest{ + UserID: userID, + ChannelID: peer.ID, + MessageID: req.MsgID, + Reactions: reactions, + Big: req.Big, + AddToRecent: req.GetAddToRecent(), + Date: date, + ReactionsPerUserMax: perUserMax, + }) + if err != nil { + return nil, channelReactionErr(err) + } + updates := r.channelMessageReactionsUpdates(ctx, userID, res) + // 官方保证消息作者收到 updateMessageReactions:作者进 explicit 收件人, + // 不依赖在线 viewer 采样(fan-out 封顶后作者可能被挤出采样集)。 + ids := []int{res.Message.ID} + r.pushChannelViewerUpdates(ctx, userID, res.Channel.ID, []int64{userID, res.Message.SenderUserID}, func(viewerUserID int64) *tg.Updates { + return r.channelReactionsViewerUpdates(ctx, userID, viewerUserID, res, ids) + }) + return updates, nil + } + if peer.Type == domain.PeerTypeUser && r.deps.Messages != nil { + res, err := r.deps.Messages.SetMessageReactions(ctx, userID, domain.SetPrivateMessageReactionsRequest{ + UserID: userID, + Peer: peer, + MessageID: req.MsgID, + Reactions: reactions, + Big: req.Big, + AddToRecent: req.GetAddToRecent(), + Date: date, + ReactionsPerUserMax: perUserMax, + }) + if err != nil { + return nil, messageReactionErr(err) + } + if err := r.recordMessageReactionUse(ctx, userID, reactions, req.GetAddToRecent(), date); err != nil { + return nil, internalErr() + } + recordedEvents, err := r.recordPrivateMessageReactionEvents(ctx, userID, res) + if err != nil { + return nil, internalErr() + } + // reaction 事件占双方账号 pts 但 updateMessageReactions 不带 pts; + // 在线直推必须附 pts 簿记,否则双方下一条带 pts 的更新被判空洞。 + updates := r.privateMessageReactionsUpdates(ctx, userID, peer, res) + if updates != nil { + updates.Updates = appendAuxPtsBookkeeping(updates.Updates, recordedEvents[userID]) + } + r.pushUserUpdates(ctx, userID, updates) + for _, msg := range res.Messages { + if msg.OwnerUserID == 0 || msg.OwnerUserID == userID { + continue + } + viewerPeer := msg.Peer + viewerUpdates := r.privateMessageReactionsUpdates(ctx, msg.OwnerUserID, viewerPeer, res) + if viewerUpdates != nil { + viewerUpdates.Updates = appendAuxPtsBookkeeping(viewerUpdates.Updates, recordedEvents[msg.OwnerUserID]) + } + r.pushUserUpdates(ctx, msg.OwnerUserID, viewerUpdates) + } + return updates, nil + } + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil +} + +func (r *Router) recordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { + if len(reactions) == 0 || r.deps.Channels == nil { + return nil + } + recorder, ok := r.deps.Channels.(messageReactionUsageRecorder) + if !ok { + return nil + } + return recorder.RecordMessageReactionUse(ctx, userID, reactions, addToRecent, date) +} + +func (r *Router) recordPrivateMessageReactionEvents(ctx context.Context, requestUserID int64, res domain.PrivateMessageReactionsResult) (map[int64]domain.UpdateEvent, error) { + if r.deps.Updates == nil { + return nil, nil + } + recorder, ok := r.deps.Updates.(messageReactionUpdateRecorder) + if !ok { + return nil, nil + } + authKeyID, _ := AuthKeyIDFrom(ctx) + events := make(map[int64]domain.UpdateEvent, len(res.Messages)) + for _, msg := range res.Messages { + if msg.OwnerUserID == 0 || msg.ID == 0 { + continue + } + eventAuthKeyID := [8]byte{} + if msg.OwnerUserID == requestUserID { + eventAuthKeyID = authKeyID + } + event, _, err := recorder.RecordMessageReactions(ctx, eventAuthKeyID, msg.OwnerUserID, msg) + if err != nil { + return nil, err + } + events[msg.OwnerUserID] = event + } + return events, nil +} + +func (r *Router) channelMessageReactionsUpdates(ctx context.Context, viewerUserID int64, res domain.ChannelMessageReactionsResult) *tg.Updates { + ids := []int{res.Message.ID} + if res.Message.ID <= 0 && len(res.Messages) > 0 { + ids = []int{res.Messages[0].ID} + } + return r.channelMessagesReactionsUpdates(ctx, viewerUserID, res, ids) +} + +// channelReactionsViewerUpdates 为 fan-out 构造某 viewer 的 updateMessageReactions。 +// res 内的聚合是请求者视角(chosen/My/unread 都是 per-viewer 字段): +// - 请求者本人:直接用; +// - 消息作者:按作者视角重载(unread 角标与作者自己的 chosen 必须正确); +// - 其他 viewer:官方 min 语义——只下发计数与 recent 列表,客户端保留本地 chosen +// (TDesktop 非 min 更新会用 chosen_order 直接覆盖本地 my 状态,串视角即"别人的 +// reaction 显示成我选的")。 +func (r *Router) channelReactionsViewerUpdates(ctx context.Context, requestUserID, viewerUserID int64, res domain.ChannelMessageReactionsResult, ids []int) *tg.Updates { + if viewerUserID == requestUserID { + return r.channelMessagesReactionsUpdates(ctx, viewerUserID, res, ids) + } + if viewerUserID != 0 && viewerUserID == reactionsResultSenderID(res) && r.deps.Channels != nil { + reloaded, err := r.deps.Channels.GetMessageReactions(ctx, viewerUserID, domain.ChannelMessageReactionsRequest{ + UserID: viewerUserID, + ChannelID: res.Channel.ID, + IDs: append([]int(nil), ids...), + }) + if err == nil && len(reloaded.Messages) > 0 { + reloaded.Channel = res.Channel + return r.channelMessagesReactionsUpdates(ctx, viewerUserID, reloaded, ids) + } + } + updates := r.channelMessagesReactionsUpdates(ctx, viewerUserID, minifyChannelReactionsResult(res), ids) + if updates != nil { + for _, update := range updates.Updates { + if reactions, ok := update.(*tg.UpdateMessageReactions); ok { + reactions.Reactions.Min = true + } + } + } + return updates +} + +// reactionsResultSenderID 取结果中消息作者(单消息场景;多消息 moderation 不做作者特判)。 +func reactionsResultSenderID(res domain.ChannelMessageReactionsResult) int64 { + if res.Message.ID != 0 { + return res.Message.SenderUserID + } + if len(res.Messages) == 1 { + return res.Messages[0].SenderUserID + } + return 0 +} + +// minifyChannelReactionsResult 深拷并清掉所有 per-viewer 字段(chosen/My/unread), +// 供 min 推送使用;不改原 res(请求者响应仍用全量视角)。 +func minifyChannelReactionsResult(res domain.ChannelMessageReactionsResult) domain.ChannelMessageReactionsResult { + scrub := func(in *domain.ChannelMessageReactions) *domain.ChannelMessageReactions { + if in == nil { + return nil + } + out := domain.ChannelMessageReactions{ + CanSeeList: in.CanSeeList, + Results: make([]domain.ChannelMessageReactionCount, 0, len(in.Results)), + Recent: make([]domain.ChannelMessagePeerReaction, 0, len(in.Recent)), + } + for _, item := range in.Results { + item.ChosenOrder = 0 + out.Results = append(out.Results, item) + } + for _, item := range in.Recent { + item.My = false + item.Unread = false + item.ChosenOrder = 0 + out.Recent = append(out.Recent, item) + } + return &out + } + out := res + if scrubbed := scrub(res.Message.Reactions); scrubbed != nil { + msg := res.Message + msg.Reactions = scrubbed + out.Message = msg + } + if len(res.Messages) > 0 { + out.Messages = make([]domain.ChannelMessage, 0, len(res.Messages)) + for _, msg := range res.Messages { + msg.Reactions = scrub(msg.Reactions) + out.Messages = append(out.Messages, msg) + } + } + if scrubbed := scrub(&res.Reactions); scrubbed != nil { + out.Reactions = *scrubbed + } + return out +} + +func (r *Router) privateMessageReactionsUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, res domain.PrivateMessageReactionsResult) *tg.Updates { + ids := make([]int, 0, 1) + for _, msg := range res.Messages { + if msg.OwnerUserID == viewerUserID && msg.ID > 0 { + ids = append(ids, msg.ID) + break + } + } + return r.privateMessagesReactionsUpdates(ctx, viewerUserID, peer, res, ids) +} + +func (r *Router) privateMessagesReactionsUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, res domain.PrivateMessageReactionsResult, ids []int) *tg.Updates { + updates := make([]tg.UpdateClass, 0, len(ids)) + messagesByID := make(map[int]domain.Message, len(res.Messages)) + userIDs := []int64{viewerUserID} + if peer.Type == domain.PeerTypeUser && peer.ID != 0 { + userIDs = append(userIDs, peer.ID) + } + for _, msg := range res.Messages { + if msg.OwnerUserID != viewerUserID || msg.ID == 0 { + continue + } + messagesByID[msg.ID] = msg + if msg.Peer.Type == domain.PeerTypeUser && msg.Peer.ID != 0 { + userIDs = append(userIDs, msg.Peer.ID) + } + if msg.From.Type == domain.PeerTypeUser && msg.From.ID != 0 { + userIDs = append(userIDs, msg.From.ID) + } + if msg.Reactions != nil { + userIDs = append(userIDs, channelMessageReactionUserIDs(*msg.Reactions)...) + } + } + userIDs = append(userIDs, channelMessageReactionUserIDs(res.Reactions)...) + fallbackPeer := tgPeer(peer) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + continue + } + msg, ok := messagesByID[id] + outPeer := fallbackPeer + reactions := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{}, + Recent: []domain.ChannelMessagePeerReaction{}, + } + if ok { + outPeer = tgPeer(msg.Peer) + if msg.Reactions != nil { + reactions = *msg.Reactions + } + } + if outPeer == nil { + continue + } + converted := tgMessageReactions(viewerUserID, &reactions) + if converted == nil { + converted = &tg.MessageReactions{Results: []tg.ReactionCount{}} + } + updates = append(updates, &tg.UpdateMessageReactions{ + Peer: outPeer, + MsgID: id, + Reactions: *converted, + }) + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, viewerUserID, userIDs), + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func (r *Router) channelMessagesReactionsUpdates(ctx context.Context, viewerUserID int64, res domain.ChannelMessageReactionsResult, ids []int) *tg.Updates { + updates := make([]tg.UpdateClass, 0, len(ids)) + messagesByID := make(map[int]domain.ChannelMessage, len(res.Messages)+1) + if res.Message.ID != 0 { + messagesByID[res.Message.ID] = res.Message + } + for _, msg := range res.Messages { + if msg.ID != 0 { + messagesByID[msg.ID] = msg + } + } + userIDs := make([]int64, 0) + for _, msg := range messagesByID { + if msg.Reactions != nil { + userIDs = append(userIDs, channelMessageReactionUserIDs(*msg.Reactions)...) + } + } + userIDs = append(userIDs, channelMessageReactionUserIDs(res.Reactions)...) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + continue + } + msg, ok := messagesByID[id] + reactions := domain.ChannelMessageReactions{ + CanSeeList: !res.Channel.Broadcast || res.Channel.Megagroup, + Results: []domain.ChannelMessageReactionCount{}, + Recent: []domain.ChannelMessagePeerReaction{}, + } + if ok && msg.Reactions != nil { + reactions = *msg.Reactions + } else if ok && len(res.Reactions.Results) > 0 && res.Message.ID == id { + reactions = res.Reactions + } + converted := tgMessageReactions(viewerUserID, &reactions) + if converted == nil { + converted = &tg.MessageReactions{Results: []tg.ReactionCount{}} + } + update := &tg.UpdateMessageReactions{ + Peer: &tg.PeerChannel{ChannelID: res.Channel.ID}, + MsgID: id, + Reactions: *converted, + } + if ok { + if topID := channelMessageThreadRootID(msg); topID > 0 && topID != id { + update.SetTopMsgID(topID) + } + } + updates = append(updates, update) + } + return &tg.Updates{ + Updates: updates, + Users: r.tgUsersForIDs(ctx, viewerUserID, userIDs), + Chats: tgChannels(viewerUserID, []domain.Channel{res.Channel}), + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +func channelMessageReactionUserIDs(reactions domain.ChannelMessageReactions) []int64 { + out := make([]int64, 0, len(reactions.Recent)) + for _, item := range reactions.Recent { + if item.UserID != 0 { + out = append(out, item.UserID) + } + } + return out +} diff --git a/internal/rpc/messages_reactions_test.go b/internal/rpc/messages_reactions_test.go new file mode 100644 index 00000000..ea05776d --- /dev/null +++ b/internal/rpc/messages_reactions_test.go @@ -0,0 +1,296 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + appchannels "telesrv/internal/app/channels" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" + "time" +) + +func TestUpdatesDifferenceIncludesReactionMessageAndUpdate(t *testing.T) { + const ( + aliceID = int64(1000000001) + bobID = int64(1000000002) + ) + reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"} + reactions := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{{ + Reaction: reaction, + Count: 1, + ChosenOrder: 1, + }}, + Recent: []domain.ChannelMessagePeerReaction{{ + UserID: bobID, + Reaction: reaction, + My: true, + ChosenOrder: 1, + Date: 1700000310, + }}, + } + msg := domain.Message{ + ID: 68, + UID: 7001, + OwnerUserID: aliceID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + Date: 1700000300, + Body: "rx", + Reactions: &reactions, + } + got, ok := tgUpdatesDifference(0, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 9, Date: 1700000310}, + Events: []domain.UpdateEvent{{ + UserID: aliceID, + Type: domain.UpdateEventMessageReactions, + Pts: 9, + PtsCount: 1, + Date: 1700000310, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + Message: msg, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if len(got.NewMessages) != 1 || len(got.OtherUpdates) != 1 { + t.Fatalf("difference messages/updates = %d/%d, want 1/1", len(got.NewMessages), len(got.OtherUpdates)) + } + wireMsg, ok := got.NewMessages[0].(*tg.Message) + if !ok || wireMsg.ID != msg.ID { + t.Fatalf("message = %T %+v, want message %d", got.NewMessages[0], got.NewMessages[0], msg.ID) + } + msgReactions, ok := wireMsg.GetReactions() + if !ok || len(msgReactions.Results) != 1 || msgReactions.Results[0].Count != 1 || msgReactions.Results[0].ChosenOrder != 1 { + t.Fatalf("message reactions = %+v set=%v, want chosen reaction", msgReactions, ok) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateMessageReactions) + if !ok || update.MsgID != msg.ID || len(update.Reactions.Results) != 1 || update.Reactions.Results[0].ChosenOrder != 1 { + t.Fatalf("reaction update = %T %+v, want update for msg %d", got.OtherUpdates[0], got.OtherUpdates[0], msg.ID) + } +} + +func TestMessagesUpdateSavedReactionTagPersistsAndPushesRefresh(t *testing.T) { + const userID = int64(1000000001) + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Channels: appchannels.NewService(memory.NewChannelStore()), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + req := &tg.MessagesUpdateSavedReactionTagRequest{ + Reaction: &tg.ReactionEmoji{Emoticon: "\U0001f44d"}, + } + req.SetTitle("Fav") + ok, err := r.onMessagesUpdateSavedReactionTag(WithSessionID(WithUserID(context.Background(), userID), 55), req) + if err != nil || !ok { + t.Fatalf("update saved reaction tag = %v, %v, want true nil", ok, err) + } + + got, err := r.onMessagesGetSavedReactionTags(WithUserID(context.Background(), userID), &tg.MessagesGetSavedReactionTagsRequest{}) + if err != nil { + t.Fatalf("get saved reaction tags: %v", err) + } + page, ok := got.(*tg.MessagesSavedReactionTags) + if !ok || len(page.Tags) != 1 { + t.Fatalf("saved reaction tags = %T %+v, want one tag", got, got) + } + if emoji, ok := page.Tags[0].Reaction.(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" || page.Tags[0].Title != "Fav" { + t.Fatalf("saved reaction tag = %+v, want persisted thumb/Fav", page.Tags[0]) + } + + push := sessions.snapshot() + if push.userID != userID || push.sessionID != 55 || push.messageType != proto.MessageFromServer { + t.Fatalf("push = user %d exclude session %d type %v, want self/exclude/from_server", push.userID, push.sessionID, push.messageType) + } + updates, ok := push.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", push.message) + } + if len(updates.Updates) != 1 { + t.Fatalf("updates = %+v, want one update", updates.Updates) + } + if _, ok := updates.Updates[0].(*tg.UpdateSavedReactionTags); !ok { + t.Fatalf("update = %T, want *tg.UpdateSavedReactionTags", updates.Updates[0]) + } +} + +func TestMessagesSendReactionPrivatePeerReturnsReactionUpdate(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + now = int64(1700000200) + ) + messages := &captureMessages{} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + req := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + MsgID: 7, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, + Big: true, + } + req.SetReaction(req.Reaction) + req.SetAddToRecent(true) + + updates, err := r.onMessagesSendReaction(WithUserID(context.Background(), userID), req) + if err != nil { + t.Fatalf("messages.sendReaction private: %v", err) + } + if messages.setReactionReq.UserID != userID || messages.setReactionReq.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: peerID}) || messages.setReactionReq.MessageID != req.MsgID || !messages.setReactionReq.Big || !messages.setReactionReq.AddToRecent { + t.Fatalf("set reaction req = %+v, want private peer/message context", messages.setReactionReq) + } + got := updates.(*tg.Updates).Updates + if len(got) != 1 { + t.Fatalf("updates = %+v, want one reaction update", got) + } + update, ok := got[0].(*tg.UpdateMessageReactions) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateMessageReactions", got[0]) + } + peer, ok := update.Peer.(*tg.PeerUser) + if !ok || peer.UserID != peerID || update.MsgID != req.MsgID { + t.Fatalf("update peer/msg = %+v/%d, want peer %d msg %d", update.Peer, update.MsgID, peerID, req.MsgID) + } + if len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 || update.Reactions.Results[0].ChosenOrder != 1 { + t.Fatalf("reaction results = %+v, want one chosen reaction", update.Reactions.Results) + } +} + +func TestMessagesSendReactionPrivatePeerAllowsCustomEmoji(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + customDocumentID = int64(990001) + ) + messages := &captureMessages{} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000200, 0)}) + req := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + MsgID: 7, + } + req.SetReaction([]tg.ReactionClass{&tg.ReactionCustomEmoji{DocumentID: customDocumentID}}) + + updates, err := r.onMessagesSendReaction(WithUserID(context.Background(), userID), req) + if err != nil { + t.Fatalf("messages.sendReaction custom private: %v", err) + } + if len(messages.setReactionReq.Reactions) != 1 || messages.setReactionReq.Reactions[0].Type != domain.MessageReactionCustomEmoji || messages.setReactionReq.Reactions[0].DocumentID != customDocumentID { + t.Fatalf("set reaction req reactions = %+v, want custom document %d", messages.setReactionReq.Reactions, customDocumentID) + } + update := updates.(*tg.Updates).Updates[0].(*tg.UpdateMessageReactions) + reaction, ok := update.Reactions.Results[0].Reaction.(*tg.ReactionCustomEmoji) + if !ok || reaction.DocumentID != customDocumentID { + t.Fatalf("update reaction = %T %+v, want custom document %d", update.Reactions.Results[0].Reaction, update.Reactions.Results[0].Reaction, customDocumentID) + } +} + +func TestMessagesSendReactionPrivatePushesViewerLocalMessageID(t *testing.T) { + const ( + aliceID = int64(1000000001) + bobID = int64(1000000002) + now = int64(1700000200) + ) + reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"} + aliceReactions := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{{ + Reaction: reaction, + Count: 1, + }}, + Recent: []domain.ChannelMessagePeerReaction{{ + SenderUserID: aliceID, + UserID: bobID, + Reaction: reaction, + Unread: true, + Date: int(now), + }}, + } + bobReactions := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{{ + Reaction: reaction, + Count: 1, + ChosenOrder: 1, + }}, + Recent: []domain.ChannelMessagePeerReaction{{ + UserID: bobID, + Reaction: reaction, + My: true, + ChosenOrder: 1, + Date: int(now), + }}, + } + messages := &captureMessages{ + setReactionRes: domain.PrivateMessageReactionsResult{ + Messages: []domain.Message{ + { + ID: 68, + UID: 7001, + OwnerUserID: aliceID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + Date: int(now), + Reactions: &aliceReactions, + }, + { + ID: 64, + UID: 7001, + OwnerUserID: bobID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + Date: int(now), + Reactions: &bobReactions, + }, + }, + Reactions: bobReactions, + }, + } + sessions := &captureSessions{} + r := New(Config{}, Deps{Messages: messages, Sessions: sessions}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + req := &tg.MessagesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: aliceID, AccessHash: 11}, + MsgID: 64, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, + } + req.SetReaction(req.Reaction) + + updates, err := r.onMessagesSendReaction(WithSessionID(WithUserID(context.Background(), bobID), 77), req) + if err != nil { + t.Fatalf("messages.sendReaction private: %v", err) + } + self := updates.(*tg.Updates).Updates[0].(*tg.UpdateMessageReactions) + if peer, ok := self.Peer.(*tg.PeerUser); !ok || peer.UserID != aliceID || self.MsgID != 64 { + t.Fatalf("self update peer/msg = %#v/%d, want alice/msg64", self.Peer, self.MsgID) + } + if got := sessions.pushedUserIDs(); len(got) != 2 || got[0] != bobID || got[1] != aliceID { + t.Fatalf("pushed users = %+v, want bob then alice", got) + } + pushed := sessions.snapshot() + if pushed.userID != aliceID || pushed.sessionID != 77 || pushed.messageType != proto.MessageFromServer { + t.Fatalf("last push = user %d session %d type %v, want alice/exclude bob/from_server", pushed.userID, pushed.sessionID, pushed.messageType) + } + pushedUpdates, ok := pushed.message.(*tg.Updates) + if !ok || len(pushedUpdates.Updates) != 1 { + t.Fatalf("pushed message = %T %+v, want one updates container", pushed.message, pushed.message) + } + other, ok := pushedUpdates.Updates[0].(*tg.UpdateMessageReactions) + if !ok { + t.Fatalf("pushed update = %T, want *tg.UpdateMessageReactions", pushedUpdates.Updates[0]) + } + peer, ok := other.Peer.(*tg.PeerUser) + if !ok || peer.UserID != bobID || other.MsgID != 68 { + t.Fatalf("pushed update peer/msg = %#v/%d, want bob/msg68", other.Peer, other.MsgID) + } + if len(other.Reactions.Results) != 1 || other.Reactions.Results[0].Count != 1 || other.Reactions.Results[0].ChosenOrder != 0 { + t.Fatalf("pushed reaction results = %+v, want one non-chosen reaction", other.Reactions.Results) + } + if recent, ok := other.Reactions.GetRecentReactions(); !ok || len(recent) != 1 || !recent[0].Unread || recent[0].My { + t.Fatalf("pushed recent reactions = %+v set=%v, want one unread non-my reaction", recent, ok) + } +} diff --git a/internal/rpc/messages_reactions_viewpoint_test.go b/internal/rpc/messages_reactions_viewpoint_test.go new file mode 100644 index 00000000..3fcfec65 --- /dev/null +++ b/internal/rpc/messages_reactions_viewpoint_test.go @@ -0,0 +1,87 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// TestChannelReactionsViewerUpdatesMin 回归:channel reaction fan-out 中, +// 非发起者 viewer 收到的 updateMessageReactions 必须是 min 形态(不带发起者的 +// chosen/My),否则 TDesktop 会把发起者的 reaction 高亮成接收者自己的。 +func TestChannelReactionsViewerUpdatesMin(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + reactions := &domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{{ + Reaction: domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001F44D"}, + Count: 1, + ChosenOrder: 1, // 发起者视角烘焙的 chosen + }}, + Recent: []domain.ChannelMessagePeerReaction{{ + UserID: owner.ID, + Reaction: domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001F44D"}, + My: true, + ChosenOrder: 1, + Date: 100, + }}, + } + res := domain.ChannelMessageReactionsResult{ + Channel: domain.Channel{ID: 9001, Megagroup: true}, + Message: domain.ChannelMessage{ID: 5, ChannelID: 9001, SenderUserID: friend.ID, Reactions: reactions}, + Reactions: *reactions, + } + ids := []int{5} + + findUpdate := func(updates *tg.Updates) *tg.UpdateMessageReactions { + t.Helper() + if updates == nil { + t.Fatal("nil updates") + } + for _, u := range updates.Updates { + if mr, ok := u.(*tg.UpdateMessageReactions); ok { + return mr + } + } + t.Fatal("no updateMessageReactions") + return nil + } + + // 发起者本人:全量视角,chosen 保留。 + self := findUpdate(r.channelReactionsViewerUpdates(ctx, owner.ID, owner.ID, res, ids)) + if self.Reactions.Min { + t.Fatal("requester's own update must not be min") + } + if _, has := self.Reactions.Results[0].GetChosenOrder(); !has { + t.Fatal("requester's own update should keep chosen_order") + } + + // 其他 viewer:min 形态,chosen/My 全剥离(deps.Channels 为 nil 时作者分支也落 min)。 + other := findUpdate(r.channelReactionsViewerUpdates(ctx, owner.ID, friend.ID, res, ids)) + if !other.Reactions.Min { + t.Fatal("other viewer's update must be min") + } + if _, has := other.Reactions.Results[0].GetChosenOrder(); has { + t.Fatal("other viewer's update must not carry requester's chosen_order") + } + if recent, has := other.Reactions.GetRecentReactions(); has { + for _, item := range recent { + if item.My { + t.Fatalf("other viewer's recent entry must not be My: %+v", item) + } + } + } + if count := other.Reactions.Results[0].Count; count != 1 { + t.Fatalf("min update should keep counts, got %d", count) + } + + // 原 res 不得被 minify 污染(请求者响应还要用全量视角)。 + if res.Message.Reactions.Results[0].ChosenOrder != 1 || !res.Message.Reactions.Recent[0].My { + t.Fatalf("minify must not mutate the original result: %+v", res.Message.Reactions) + } +} diff --git a/internal/rpc/messages_read.go b/internal/rpc/messages_read.go new file mode 100644 index 00000000..c623d777 --- /dev/null +++ b/internal/rpc/messages_read.go @@ -0,0 +1,320 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func (r *Router) onMessagesReadMessageContents(ctx context.Context, ids []int) (*tg.MessagesAffectedMessages, error) { + id, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if len(ids) > maxGetMessagesIDs { + return nil, limitInvalidErr() + } + for _, msgID := range ids { + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + } + read := domain.ReadMessageContentsResult{OwnerUserID: userID} + if r.deps.Messages != nil { + read, err = r.deps.Messages.ReadMessageContents(ctx, userID, domain.ReadMessageContentsRequest{ + OwnerUserID: userID, + IDs: ids, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: id, + OriginSessionID: sessionID, + }) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, messageIDInvalidErr() + } + return nil, internalErr() + } + } + affected := &tg.MessagesAffectedMessages{Pts: read.Event.Pts, PtsCount: read.Event.PtsCount} + if read.Event.Pts == 0 { + affected, err = r.affectedMessages(ctx, id, userID) + if err != nil { + return nil, err + } + } + now := int(r.clock.Now().Unix()) + if contentIDs := readMessageContentIDs(read.MessageIDs); len(contentIDs) > 0 { + contents := &tg.UpdateReadMessagesContents{ + Messages: contentIDs, + Pts: affected.Pts, + PtsCount: affected.PtsCount, + } + contents.SetDate(now) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{contents}, + Date: now, + Seq: 0, + }) + } + // voice/round 的对端发送者收到自己视角 box id 的内容已读回执, + // 让 sender 端的"未听"蓝点消失;reliable outbox 部署下由 worker 投递。 + for _, event := range read.SenderEvents { + if update := tgOtherUpdateFromEvent(event); update != nil { + r.pushUserUpdatesIfNoReliableDispatch(ctx, event.UserID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Date: now, + Seq: 0, + }) + } + } + return affected, nil +} + +func readMessageContentIDs(ids []int) []int { + if len(ids) == 0 { + return nil + } + out := make([]int, 0, len(ids)) + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func (r *Router) onMessagesGetUnreadMentions(ctx context.Context, req *tg.MessagesGetUnreadMentionsRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Limit < 0 || req.Limit > domain.MaxChannelUnreadMentionsLimit { + return nil, limitInvalidErr() + } + if req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID || + req.OffsetID < 0 || req.OffsetID > domain.MaxMessageBoxID || + req.MaxID < 0 || req.MaxID > domain.MaxMessageBoxID || + req.MinID < 0 || req.MinID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + history, err := r.deps.Channels.GetUnreadMentions(ctx, userID, domain.ChannelUnreadMentionsFilter{ + ChannelID: peer.ID, + TopMsgID: req.TopMsgID, + OffsetID: req.OffsetID, + AddOffset: req.AddOffset, + Limit: req.Limit, + MaxID: req.MaxID, + MinID: req.MinID, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + return r.tgChannelHistoryMessages(ctx, userID, r.enrichChannelHistory(ctx, userID, history)), nil + } + out := &tg.MessagesMessages{ + Messages: []tg.MessageClass{}, + Chats: r.chatsForInputPeer(ctx, userID, req.Peer), + Users: []tg.UserClass{}, + } + r.applyStoryMaxIDsToMessages(ctx, userID, out) + return out, nil +} + +func (r *Router) onMessagesReadMentions(ctx context.Context, req *tg.MessagesReadMentionsRequest) (*tg.MessagesAffectedHistory, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.TopMsgID < 0 || req.TopMsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + res, err := r.deps.Channels.ReadMentions(ctx, userID, domain.ReadChannelMentionsRequest{ + UserID: userID, + ChannelID: peer.ID, + TopMsgID: req.TopMsgID, + Limit: domain.MaxChannelReadMentionsBatch, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + return &tg.MessagesAffectedHistory{Pts: res.ChannelPts, PtsCount: 0, Offset: res.Offset}, nil + } + return r.affectedHistory(ctx, id, userID, 0) +} + +func (r *Router) affectedMessages(ctx context.Context, authKeyID [8]byte, userID int64) (*tg.MessagesAffectedMessages, error) { + st := domain.UpdateState{Date: int(r.clock.Now().Unix())} + if r.deps.Updates != nil { + var err error + st, err = r.deps.Updates.GetState(ctx, authKeyID, userID) + if err != nil { + return nil, internalErr() + } + } + return &tg.MessagesAffectedMessages{Pts: st.Pts, PtsCount: 0}, nil +} + +func (r *Router) affectedHistory(ctx context.Context, authKeyID [8]byte, userID int64, offset int) (*tg.MessagesAffectedHistory, error) { + st := domain.UpdateState{Date: int(r.clock.Now().Unix())} + if r.deps.Updates != nil { + var err error + st, err = r.deps.Updates.GetState(ctx, authKeyID, userID) + if err != nil { + return nil, internalErr() + } + } + return &tg.MessagesAffectedHistory{Pts: st.Pts, PtsCount: 0, Offset: offset}, nil +} + +func (r *Router) pushReadHistoryEvent(ctx context.Context, userID int64, event domain.UpdateEvent) { + if r.hasReliableUpdateDispatch() { + return + } + if r.deps.Sessions == nil || userID == 0 { + return + } + var update tg.UpdateClass + switch event.Type { + case domain.UpdateEventReadHistoryInbox: + update = tgReadHistoryInboxUpdate(event) + case domain.UpdateEventReadHistoryOutbox: + update = tgReadHistoryOutboxUpdate(event) + case domain.UpdateEventReadChannelDiscussionInbox: + if event.Peer.ID != 0 { + update = &tg.UpdateReadChannelDiscussionInbox{ChannelID: event.Peer.ID, TopMsgID: event.TopMsgID, ReadMaxID: event.MaxID} + } + } + if update == nil { + return + } + updates := &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, event), + Date: event.Date, + Seq: 0, + } + r.pushUserMessage(ctx, userID, "push read history", updates) +} + +func (r *Router) pushCurrentReadHistoryEvent(ctx context.Context, event domain.UpdateEvent) { + var update tg.UpdateClass + switch event.Type { + case domain.UpdateEventReadHistoryInbox: + update = tgReadHistoryInboxUpdate(event) + case domain.UpdateEventReadHistoryOutbox: + update = tgReadHistoryOutboxUpdate(event) + case domain.UpdateEventReadChannelDiscussionInbox: + if event.Peer.ID != 0 { + update = &tg.UpdateReadChannelDiscussionInbox{ChannelID: event.Peer.ID, TopMsgID: event.TopMsgID, ReadMaxID: event.MaxID} + } + } + if update == nil { + return + } + r.pushCurrentSessionMessage(ctx, "push current read history", &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, event), + Date: event.Date, + Seq: 0, + }) +} + +// bookkeepAuxPtsForCurrentSession 把 aux 事件占用的账号 pts 同步给发起 +// session:可靠 outbox 投递排除了当前 session,而这些 RPC 的响应多为 +// Bool/无 pts 容器,不补这一条当前设备的水位会落后并误判后续空洞。 +func (r *Router) bookkeepAuxPtsForCurrentSession(ctx context.Context, events ...domain.UpdateEvent) { + updates := make([]tg.UpdateClass, 0, len(events)) + date := 0 + for _, event := range events { + before := len(updates) + updates = appendAuxPtsBookkeeping(updates, event) + if len(updates) > before && date == 0 { + date = event.Date + } + } + if len(updates) == 0 { + return + } + if date == 0 { + date = int(r.clock.Now().Unix()) + } + r.pushCurrentSessionMessage(ctx, "aux pts bookkeeping", &tg.Updates{ + Updates: updates, + Date: date, + Seq: 0, + }) +} + +func tgReadHistoryInbox(event domain.UpdateEvent) *tg.UpdateReadHistoryInbox { + peer := tgPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdateReadHistoryInbox{ + Peer: peer, + MaxID: event.MaxID, + StillUnreadCount: event.StillUnreadCount, + Pts: event.Pts, + PtsCount: event.PtsCount, + } +} + +func tgReadHistoryInboxUpdate(event domain.UpdateEvent) tg.UpdateClass { + if event.Peer.Type == domain.PeerTypeChannel && event.Peer.ID != 0 { + // updateReadChannelInbox.pts 只能是 channel pts;账号 pts 在这里 + // 是错误语义(TDesktop 拿它与本地 channel pts 比较),缺失时宁可 + // 填 0 让客户端回退本地估算。 + update := &tg.UpdateReadChannelInbox{ + ChannelID: event.Peer.ID, + MaxID: event.MaxID, + StillUnreadCount: event.StillUnreadCount, + Pts: event.ChannelPts, + } + if event.FolderID > 0 { + update.SetFolderID(event.FolderID) + } + return update + } + return tgReadHistoryInbox(event) +} + +func tgReadHistoryOutbox(event domain.UpdateEvent) *tg.UpdateReadHistoryOutbox { + peer := tgPeer(event.Peer) + if peer == nil { + return nil + } + return &tg.UpdateReadHistoryOutbox{ + Peer: peer, + MaxID: event.MaxID, + Pts: event.Pts, + PtsCount: event.PtsCount, + } +} + +func tgReadHistoryOutboxUpdate(event domain.UpdateEvent) tg.UpdateClass { + if event.Peer.Type == domain.PeerTypeChannel && event.Peer.ID != 0 { + return &tg.UpdateReadChannelOutbox{ + ChannelID: event.Peer.ID, + MaxID: event.MaxID, + } + } + return tgReadHistoryOutbox(event) +} diff --git a/internal/rpc/messages_read_rpc_test.go b/internal/rpc/messages_read_rpc_test.go new file mode 100644 index 00000000..b876b9e8 --- /dev/null +++ b/internal/rpc/messages_read_rpc_test.go @@ -0,0 +1,298 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "reflect" + "telesrv/internal/domain" + "testing" +) + +func TestUpdatesDifferenceIncludesReadHistoryInbox(t *testing.T) { + peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} + got, ok := tgUpdatesDifference(0, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 6, Date: 1700000200, Seq: 5}, + Events: []domain.UpdateEvent{{ + Type: domain.UpdateEventReadHistoryInbox, + Pts: 6, + PtsCount: 1, + Date: 1700000200, + Peer: peer, + MaxID: 12, + StillUnreadCount: 0, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if got.State.Pts != 6 || len(got.OtherUpdates) != 1 { + t.Fatalf("difference = %+v, want one read history update and pts=6", got) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateReadHistoryInbox) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateReadHistoryInbox", got.OtherUpdates[0]) + } + if update.MaxID != 12 || update.Pts != 6 || update.PtsCount != 1 { + t.Fatalf("read update = %+v, want max_id=12 pts=6 pts_count=1", update) + } +} + +func TestUpdatesDifferenceIncludesReadHistoryOutbox(t *testing.T) { + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + got, ok := tgUpdatesDifference(0, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 7, Date: 1700000210, Seq: 0}, + Events: []domain.UpdateEvent{{ + Type: domain.UpdateEventReadHistoryOutbox, + Pts: 7, + PtsCount: 1, + Date: 1700000210, + Peer: peer, + MaxID: 9, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if len(got.OtherUpdates) != 1 { + t.Fatalf("other updates = %+v, want one read outbox update", got.OtherUpdates) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateReadHistoryOutbox) + if !ok || update.MaxID != 9 || update.Pts != 7 || update.PtsCount != 1 { + t.Fatalf("read outbox = %T %+v, want max_id=9 pts=7", got.OtherUpdates[0], got.OtherUpdates[0]) + } +} + +func TestMessagesGetOutboxReadDateReturnsDate(t *testing.T) { + const ( + userID = int64(1000000001) + peerID = int64(1000000002) + ) + messages := &captureMessages{outboxReadDate: 1700000300} + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesGetOutboxReadDateRequest{ + Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, + MsgID: 3, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), userID), [8]byte{}, 77, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.OutboxReadDate) + if !ok || got.Date != 1700000300 { + t.Fatalf("response = %T %#v, want outboxReadDate date", enc, enc) + } + if messages.outboxReadDateReq.OwnerUserID != userID || messages.outboxReadDateReq.Peer.ID != peerID || messages.outboxReadDateReq.ID != 3 { + t.Fatalf("read date request = %+v, want owner peer message id", messages.outboxReadDateReq) + } +} + +func TestMessagesReadMessageContentsPushesUpdateToOtherSessions(t *testing.T) { + authKeyID := [8]byte{9, 9, 9} + messages := &captureMessages{ + readContentsRes: domain.ReadMessageContentsResult{ + OwnerUserID: 1000000001, + MessageIDs: []int{7, 8}, + }, + } + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Messages: messages, + Updates: &captureUpdates{state: domain.UpdateState{Pts: 42, Date: 1700000200}}, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) + + affected, err := r.onMessagesReadMessageContents(ctx, []int{7, 8}) + if err != nil { + t.Fatalf("messages.readMessageContents: %v", err) + } + if affected.Pts != 42 || affected.PtsCount != 0 { + t.Fatalf("affected = %+v, want pts=42 pts_count=0", affected) + } + if messages.readContentsReq.OwnerUserID != 1000000001 || !reflect.DeepEqual(messages.readContentsReq.IDs, []int{7, 8}) { + t.Fatalf("read contents req = %+v", messages.readContentsReq) + } + snap := sessions.snapshot() + if snap.userID != 1000000001 || snap.sessionID != 55 || snap.messageType != proto.MessageFromServer { + t.Fatalf("push target = %+v", snap) + } + updates, ok := snap.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", snap.message) + } + if len(updates.Updates) != 1 { + t.Fatalf("updates = %+v", updates.Updates) + } + read, ok := updates.Updates[0].(*tg.UpdateReadMessagesContents) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateReadMessagesContents", updates.Updates[0]) + } + if !reflect.DeepEqual(read.Messages, []int{7, 8}) || read.Pts != 42 || read.PtsCount != 0 { + t.Fatalf("read update = %+v", read) + } +} + +func TestMessagesReadHistoryMarksDialogRead(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 7 + messages := &captureMessages{readResult: domain.ReadHistoryResult{ + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + MaxID: 12, + Changed: true, + InboxEvent: domain.UpdateEvent{ + Type: domain.UpdateEventReadHistoryInbox, + Pts: 5, + PtsCount: 1, + Date: 1700000100, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + MaxID: 12, + }, + }} + updates := &captureUpdates{state: domain.UpdateState{Pts: 5, Date: 1700000100, Seq: 3}} + sessions := &captureSessions{} + r := New(Config{}, Deps{Messages: messages, Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesReadHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, + MaxID: 12, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), authKeyID, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.MessagesAffectedMessages) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesAffectedMessages", enc) + } + if messages.readPeer.ID != domain.OfficialSystemUserID || messages.readMaxID != 12 { + t.Fatalf("read = peer %+v max %d, want official/12", messages.readPeer, messages.readMaxID) + } + if got.Pts != 5 || got.PtsCount != 1 { + t.Fatalf("affected = %+v, want recorded read-history pts", got) + } + gotSession := sessions.snapshot() + if gotSession.userID != 1000000001 || gotSession.messageType != proto.MessageFromServer { + t.Fatalf("push target = user %d type %v, want read update push to other sessions", gotSession.userID, gotSession.messageType) + } +} + +func TestMessagesReadHistoryWithReliableDispatchPushesCurrentSessionReadUpdate(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 8 + messages := &captureMessages{readResult: domain.ReadHistoryResult{ + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + MaxID: 12, + StillUnreadCount: 2, + Changed: true, + InboxEvent: domain.UpdateEvent{ + Type: domain.UpdateEventReadHistoryInbox, + Pts: 5, + PtsCount: 1, + Date: 1700000100, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + MaxID: 12, + StillUnreadCount: 2, + }, + }} + updates := &captureUpdates{ + state: domain.UpdateState{Pts: 5, Date: 1700000100, Seq: 3}, + reliableDispatch: true, + } + sessions := &captureSessions{} + r := New(Config{}, Deps{Messages: messages, Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesReadHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, + MaxID: 12, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), authKeyID, 77, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.MessagesAffectedMessages) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesAffectedMessages", enc) + } + if got.Pts != 5 || got.PtsCount != 1 { + t.Fatalf("affected = %+v, want recorded read-history pts", got) + } + if messages.readReq.OriginSessionID != 77 || messages.readReq.OriginAuthKeyID != authKeyID { + t.Fatalf("read origin = auth %v session %d, want request auth/session", messages.readReq.OriginAuthKeyID, messages.readReq.OriginSessionID) + } + snap := sessions.snapshot() + if snap.sessionID != 77 || snap.messageType != proto.MessageFromServer { + t.Fatalf("current-session push target = session %d type %v, want session 77 server message", snap.sessionID, snap.messageType) + } + updatesMsg, ok := snap.message.(*tg.Updates) + if !ok || len(updatesMsg.Updates) != 1 { + t.Fatalf("current-session push = %T %+v, want one updates container", snap.message, snap.message) + } + update, ok := updatesMsg.Updates[0].(*tg.UpdateReadHistoryInbox) + if !ok { + t.Fatalf("current-session update = %T, want *tg.UpdateReadHistoryInbox", updatesMsg.Updates[0]) + } + if update.Pts != 5 || update.PtsCount != 1 || update.MaxID != 12 || update.StillUnreadCount != 2 { + t.Fatalf("current-session update = %+v, want pts=5 count=1 max=12 still=2", update) + } +} + +func TestMessagesReadHistoryAlreadyReadReturnsCurrentStateWithoutEcho(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 9 + messages := &captureMessages{readResult: domain.ReadHistoryResult{ + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + MaxID: 12, + StillUnreadCount: 0, + Changed: false, + }} + updates := &captureUpdates{ + state: domain.UpdateState{Pts: 7, Date: 1700000101, Seq: 3}, + reliableDispatch: true, + } + sessions := &captureSessions{} + r := New(Config{}, Deps{Messages: messages, Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesReadHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, + MaxID: 12, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), authKeyID, 88, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + got, ok := enc.(*tg.MessagesAffectedMessages) + if !ok { + t.Fatalf("response = %T, want *tg.MessagesAffectedMessages", enc) + } + if got.Pts != 7 || got.PtsCount != 0 { + t.Fatalf("affected = %+v, want current pts without advancing", got) + } + snap := sessions.snapshot() + if snap.message != nil { + t.Fatalf("current-session push = %T, want no direct echo for already-read request", snap.message) + } +} diff --git a/internal/rpc/messages_register.go b/internal/rpc/messages_register.go new file mode 100644 index 00000000..b2e03c02 --- /dev/null +++ b/internal/rpc/messages_register.go @@ -0,0 +1,543 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" + "unicode/utf8" +) + +// registerMessages 注册 messages.* RPC handler。 +func (r *Router) registerMessages(d *tg.ServerDispatcher) { + d.OnMessagesSetTyping(r.onMessagesSetTyping) + d.OnMessagesSaveDraft(r.onMessagesSaveDraft) + d.OnMessagesSaveDefaultSendAs(r.onMessagesSaveDefaultSendAs) + d.OnMessagesGetAllDrafts(r.onMessagesGetAllDrafts) + d.OnMessagesClearAllDrafts(r.onMessagesClearAllDrafts) + d.OnMessagesGetAllStickers(r.onMessagesGetAllStickers) + d.OnMessagesGetEmojiStickers(r.onMessagesGetEmojiStickers) + d.OnMessagesGetMaskStickers(r.onMessagesGetMaskStickers) + d.OnMessagesGetFeaturedStickers(r.onMessagesGetFeaturedStickers) + d.OnMessagesGetFeaturedEmojiStickers(r.onMessagesGetFeaturedEmojiStickers) + d.OnMessagesGetRecentStickers(r.onMessagesGetRecentStickers) + d.OnMessagesGetFavedStickers(r.onMessagesGetFavedStickers) + d.OnMessagesGetSavedGifs(r.onMessagesGetSavedGifs) + d.OnMessagesFaveSticker(r.onMessagesFaveSticker) + d.OnMessagesSaveRecentSticker(r.onMessagesSaveRecentSticker) + d.OnMessagesSaveGif(r.onMessagesSaveGif) + d.OnMessagesClearRecentStickers(r.onMessagesClearRecentStickers) + d.OnMessagesSendMessage(r.onMessagesSendMessage) + d.OnMessagesForwardMessages(r.onMessagesForwardMessages) + d.OnMessagesGetDialogFilters(r.onMessagesGetDialogFilters) + d.OnMessagesGetSuggestedDialogFilters(func(ctx context.Context) ([]tg.DialogFilterSuggested, error) { + return tdesktop.SuggestedDialogFilters(), nil + }) + d.OnMessagesUpdateDialogFilter(r.onMessagesUpdateDialogFilter) + d.OnMessagesUpdateDialogFiltersOrder(r.onMessagesUpdateDialogFiltersOrder) + d.OnMessagesToggleDialogFilterTags(r.onMessagesToggleDialogFilterTags) + d.OnMessagesGetSavedDialogs(r.onMessagesGetSavedDialogs) + d.OnMessagesGetPinnedSavedDialogs(func(ctx context.Context) (tg.MessagesSavedDialogsClass, error) { + return r.onMessagesGetPinnedSavedDialogs(ctx) + }) + d.OnMessagesToggleSavedDialogPin(r.onMessagesToggleSavedDialogPin) + d.OnMessagesReorderPinnedSavedDialogs(r.onMessagesReorderPinnedSavedDialogs) + d.OnMessagesGetSavedDialogsByID(r.onMessagesGetSavedDialogsByID) + d.OnMessagesGetSavedHistory(r.onMessagesGetSavedHistory) + d.OnMessagesReadSavedHistory(r.onMessagesReadSavedHistory) + d.OnMessagesDeleteSavedHistory(r.onMessagesDeleteSavedHistory) + d.OnMessagesGetCommonChats(r.onMessagesGetCommonChats) + d.OnMessagesGetDefaultHistoryTTL(r.onMessagesGetDefaultHistoryTTL) + d.OnMessagesSetHistoryTTL(r.onMessagesSetHistoryTTL) + d.OnMessagesSetDefaultHistoryTTL(r.onMessagesSetDefaultHistoryTTL) + d.OnMessagesGetSponsoredMessages(r.onMessagesGetSponsoredMessages) + d.OnMessagesGetWebPagePreview(r.onMessagesGetWebPagePreview) + d.OnMessagesRequestWebView(r.onMessagesRequestWebView) + d.OnMessagesProlongWebView(r.onMessagesProlongWebView) + d.OnMessagesSendWebViewResultMessage(r.onMessagesSendWebViewResultMessage) + d.OnMessagesRequestSimpleWebView(r.onMessagesRequestSimpleWebView) + d.OnMessagesGetBotApp(r.onMessagesGetBotApp) + d.OnMessagesRequestAppWebView(r.onMessagesRequestAppWebView) + d.OnMessagesRequestMainWebView(r.onMessagesRequestMainWebView) + d.OnMessagesSendWebViewData(r.onMessagesSendWebViewData) + d.OnMessagesSendBotRequestedPeer(r.onMessagesSendBotRequestedPeer) + d.OnMessagesGetPreparedInlineMessage(r.onMessagesGetPreparedInlineMessage) + d.OnMessagesGetEmojiGameInfo(r.onMessagesGetEmojiGameInfo) + d.OnMessagesGetGameHighScores(r.onMessagesGetGameHighScores) + d.OnMessagesGetInlineGameHighScores(r.onMessagesGetInlineGameHighScores) + d.OnMessagesSetGameScore(r.onMessagesSetGameScore) + d.OnMessagesSetInlineGameScore(r.onMessagesSetInlineGameScore) + d.OnMessagesUploadMedia(r.onMessagesUploadMedia) + d.OnMessagesSendMedia(r.onMessagesSendMedia) + d.OnMessagesSendMultiMedia(r.onMessagesSendMultiMedia) + d.OnMessagesReportSpam(r.onMessagesReportSpam) + d.OnMessagesReport(r.onMessagesReport) + d.OnMessagesReportReaction(r.onMessagesReportReaction) + d.OnMessagesReportMessagesDelivery(r.onMessagesReportMessagesDelivery) + d.OnMessagesReportReadMetrics(r.onMessagesReportReadMetrics) + d.OnMessagesReportMusicListen(r.onMessagesReportMusicListen) + d.OnMessagesReportSponsoredMessage(r.onMessagesReportSponsoredMessage) + d.OnMessagesReadMessageContents(r.onMessagesReadMessageContents) + d.OnMessagesGetMessagesViews(r.onMessagesGetMessagesViews) + d.OnMessagesGetUnreadMentions(r.onMessagesGetUnreadMentions) + d.OnMessagesReadMentions(r.onMessagesReadMentions) + d.OnMessagesGetSearchCounters(r.onMessagesGetSearchCounters) + d.OnMessagesGetReplies(r.onMessagesGetReplies) + d.OnMessagesGetDiscussionMessage(r.onMessagesGetDiscussionMessage) + d.OnMessagesReadDiscussion(r.onMessagesReadDiscussion) + d.OnMessagesGetForumTopics(r.onMessagesGetForumTopics) + d.OnMessagesGetForumTopicsByID(r.onMessagesGetForumTopicsByID) + d.OnMessagesGetOnlines(r.onMessagesGetOnlines) + d.OnMessagesGetAvailableReactions(r.onMessagesGetAvailableReactions) + d.OnMessagesGetAvailableEffects(r.onMessagesGetAvailableEffects) + d.OnMessagesGetStickers(r.onMessagesGetStickers) + d.OnMessagesGetArchivedStickers(func(ctx context.Context, req *tg.MessagesGetArchivedStickersRequest) (*tg.MessagesArchivedStickers, error) { + return &tg.MessagesArchivedStickers{ + Count: 0, + Sets: []tg.StickerSetCoveredClass{}, + }, nil + }) + d.OnMessagesGetStickerSet(r.onMessagesGetStickerSet) + d.OnMessagesGetEmojiGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) { + return tdesktop.EmojiGroups(hash), nil + }) + d.OnMessagesGetEmojiStatusGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) { + return tdesktop.EmojiStatusGroups(), nil + }) + d.OnMessagesGetEmojiStickerGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) { + // 自定义 emoji 贴纸的分类(Premium);telesrv 未 seed custom-emoji 集,保持空。 + return &tg.MessagesEmojiGroupsNotModified{}, nil + }) + d.OnMessagesGetEmojiProfilePhotoGroups(func(ctx context.Context, hash int) (tg.MessagesEmojiGroupsClass, error) { + return tdesktop.EmojiProfilePhotoGroups(), nil + }) + d.OnMessagesGetEmojiKeywords(r.onMessagesGetEmojiKeywords) + d.OnMessagesGetEmojiKeywordsDifference(r.onMessagesGetEmojiKeywordsDifference) + d.OnMessagesGetEmojiKeywordsLanguages(func(ctx context.Context, langcodes []string) ([]tg.EmojiLanguage, error) { + return []tg.EmojiLanguage{}, nil + }) + d.OnMessagesGetCustomEmojiDocuments(r.onMessagesGetCustomEmojiDocuments) + d.OnMessagesGetAttachedStickers(r.onMessagesGetAttachedStickers) + d.OnMessagesSearchStickerSets(r.onMessagesSearchStickerSets) + d.OnMessagesSearchStickers(r.onMessagesSearchStickers) + d.OnMessagesGetAttachMenuBots(r.onMessagesGetAttachMenuBots) + d.OnMessagesGetAttachMenuBot(r.onMessagesGetAttachMenuBot) + d.OnMessagesToggleBotInAttachMenu(r.onMessagesToggleBotInAttachMenu) + d.OnMessagesGetQuickReplies(r.onMessagesGetQuickReplies) + d.OnMessagesCheckQuickReplyShortcut(r.onMessagesCheckQuickReplyShortcut) + d.OnMessagesReorderQuickReplies(r.onMessagesReorderQuickReplies) + d.OnMessagesEditQuickReplyShortcut(r.onMessagesEditQuickReplyShortcut) + d.OnMessagesDeleteQuickReplyShortcut(r.onMessagesDeleteQuickReplyShortcut) + d.OnMessagesGetQuickReplyMessages(r.onMessagesGetQuickReplyMessages) + d.OnMessagesSendQuickReplyMessages(r.onMessagesSendQuickReplyMessages) + d.OnMessagesDeleteQuickReplyMessages(r.onMessagesDeleteQuickReplyMessages) + d.OnMessagesGetWebPage(r.onMessagesGetWebPage) + d.OnMessagesGetDialogs(func(ctx context.Context, req *tg.MessagesGetDialogsRequest) (tg.MessagesDialogsClass, error) { + if r.deps.Dialogs == nil { + return &tg.MessagesDialogs{}, nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + filter, err := r.dialogFilterFromRequest(ctx, userID, req) + if err != nil { + return nil, err + } + if filter.Hash != 0 { + hashCheck, err := r.deps.Dialogs.GetDialogsHash(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + if hashCheck.Known && hashCheck.Matched { + return &tg.MessagesDialogsNotModified{Count: hashCheck.Count}, nil + } + } + list, err := r.deps.Dialogs.GetDialogs(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + if ClientTypeFrom(ctx) == ClientTypeTDesktop && tdesktop.ShouldMergePinnedIntoInitialDialogs(filter) { + pinned, err := r.pinnedDialogsList(ctx, userID, domain.DialogMainFolderID) + if err != nil { + return nil, internalErr() + } + list = tdesktop.MergeInitialDialogsWithPinned(list, pinned) + } + if filter.Hash != 0 && list.Hash == filter.Hash { + return &tg.MessagesDialogsNotModified{Count: list.Count}, nil + } + return r.tgMessagesDialogs(ctx, userID, r.withDialogListPresence(ctx, userID, list)), nil + }) + d.OnMessagesGetPinnedDialogs(func(ctx context.Context, folderID int) (*tg.MessagesPeerDialogs, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + list, err := r.pinnedDialogsList(ctx, userID, folderID) + if err != nil { + return nil, internalErr() + } + st := domain.UpdateState{Date: int(r.clock.Now().Unix())} + if r.deps.Updates != nil { + var err error + st, err = r.deps.Updates.GetState(ctx, id, userID) + if err != nil { + return nil, internalErr() + } + } + return r.tgPeerDialogs(ctx, userID, r.withDialogListPresence(ctx, userID, list), st), nil + }) + d.OnMessagesGetPeerDialogs(func(ctx context.Context, peers []tg.InputDialogPeerClass) (*tg.MessagesPeerDialogs, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + // difference 类 catch-up FLOOD_WAIT(设计 Phase 2 / §10.3):DrKLO 收 nudge 对未加载频道 + // 走 loadUnknownChannel→getPeerDialogs,限速须同时覆盖它(不止 getChannelDifference)。 + if err := r.checkCatchupRateLimit(ctx, userID, peerDialogsRateLimitKeyPrefix); err != nil { + return nil, err + } + domainPeers, err := r.dialogPeersFromInput(ctx, userID, peers) + if err != nil { + return nil, err + } + var list domain.DialogList + if len(domainPeers) > 0 && r.deps.Dialogs != nil { + var err error + list, err = r.deps.Dialogs.GetPeerDialogs(ctx, userID, domainPeers) + if err != nil { + return nil, internalErr() + } + } + st := domain.UpdateState{Date: int(r.clock.Now().Unix())} + if r.deps.Updates != nil { + var err error + st, err = r.deps.Updates.GetState(ctx, id, userID) + if err != nil { + return nil, internalErr() + } + } + r.trackChannelInterest(ctx, userID, channelIDsFromDialogs(list)...) + return r.tgPeerDialogs(ctx, userID, r.withDialogListPresence(ctx, userID, list), st), nil + }) + d.OnMessagesGetPeerSettings(r.onMessagesGetPeerSettings) + d.OnMessagesToggleDialogPin(r.onMessagesToggleDialogPin) + d.OnMessagesReorderPinnedDialogs(r.onMessagesReorderPinnedDialogs) + d.OnMessagesMarkDialogUnread(r.onMessagesMarkDialogUnread) + d.OnMessagesGetDialogUnreadMarks(r.onMessagesGetDialogUnreadMarks) + d.OnMessagesHidePeerSettingsBar(r.onMessagesHidePeerSettingsBar) + d.OnMessagesGetMessageEditData(r.onMessagesGetMessageEditData) + d.OnMessagesEditMessage(r.onMessagesEditMessage) + d.OnMessagesGetOutboxReadDate(r.onMessagesGetOutboxReadDate) + d.OnMessagesGetMessageReadParticipants(r.onMessagesGetMessageReadParticipants) + d.OnMessagesDeleteMessages(r.onMessagesDeleteMessages) + d.OnMessagesDeleteHistory(r.onMessagesDeleteHistory) + d.OnMessagesGetMessages(r.onMessagesGetMessages) + d.OnMessagesGetRichMessage(r.onMessagesGetRichMessage) + d.OnMessagesGetHistory(func(ctx context.Context, req *tg.MessagesGetHistoryRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + filter, ok := r.messageFilterFromHistoryRequest(userID, req) + if !ok { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + if filter.Peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + if err := r.validateInputPeerChannelAccess(ctx, userID, req.Peer, filter.Peer.ID); err != nil { + return nil, err + } + if isLegacyInputPeerChat(req.Peer) { + return &tg.MessagesMessages{}, nil + } + history, err := r.deps.Channels.GetHistory(ctx, userID, domain.ChannelHistoryFilter{ + ChannelID: filter.Peer.ID, + OffsetID: filter.OffsetID, + OffsetDate: filter.OffsetDate, + AddOffset: filter.AddOffset, + Limit: filter.Limit, + MaxID: filter.MaxID, + MinID: filter.MinID, + Hash: filter.Hash, + }) + if err != nil { + return nil, channelInvalidErr(err) + } + history = r.enrichChannelHistory(ctx, userID, history) + r.trackChannelInterest(ctx, userID, filter.Peer.ID) + if filter.Hash != 0 && history.Hash == filter.Hash { + return &tg.MessagesMessagesNotModified{Count: history.Count}, nil + } + return r.tgChannelHistoryMessages(ctx, userID, history), nil + } + r.clearChannelInterest(ctx, userID) + if r.deps.Messages == nil { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + list, err := r.deps.Messages.GetHistory(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + if filter.Hash != 0 && list.Hash == filter.Hash { + return &tg.MessagesMessagesNotModified{Count: list.Count}, nil + } + return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil + }) + d.OnMessagesReadHistory(func(ctx context.Context, req *tg.MessagesReadHistoryRequest) (*tg.MessagesAffectedMessages, error) { + id, _ := AuthKeyIDFrom(ctx) + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, peerErr := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if peerErr == nil && peer.Type == domain.PeerTypeChannel && r.deps.Channels != nil { + read, err := r.deps.Channels.ReadHistory(ctx, userID, domain.ReadChannelHistoryRequest{ + UserID: userID, + ChannelID: peer.ID, + MaxID: req.MaxID, + Date: int(r.clock.Now().Unix()), + }) + if err != nil { + return nil, channelInvalidErr(err) + } + event, err := r.recordChannelReadInbox(ctx, userID, read) + if err != nil { + return nil, err + } + r.pushChannelReadOutboxUpdates(ctx, read.ChannelID, read.OutboxUpdates) + r.advanceForumGeneralReadAfterChannelRead(ctx, userID, read) + if event.Pts != 0 { + return &tg.MessagesAffectedMessages{Pts: event.Pts, PtsCount: event.PtsCount}, nil + } + return r.affectedMessages(ctx, id, userID) + } + if peerErr != nil { + return nil, peerErr + } + if r.deps.Messages != nil { + sessionID, _ := SessionIDFrom(ctx) + read, err := r.deps.Messages.ReadHistory(ctx, userID, domain.ReadHistoryRequest{ + OwnerUserID: userID, + Peer: peer, + MaxID: req.MaxID, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: id, + OriginSessionID: sessionID, + }) + if err != nil { + return nil, internalErr() + } + if read.Changed && read.InboxEvent.Pts != 0 { + r.pushCurrentReadHistoryEvent(ctx, read.InboxEvent) + r.pushReadHistoryEvent(ctx, read.OwnerUserID, read.InboxEvent) + if read.OutboxChanged && read.OutboxEvent.Pts != 0 { + r.pushReadHistoryEvent(ctx, read.OutboxUserID, read.OutboxEvent) + } + return &tg.MessagesAffectedMessages{Pts: read.InboxEvent.Pts, PtsCount: read.InboxEvent.PtsCount}, nil + } + } + return r.affectedMessages(ctx, id, userID) + }) + d.OnMessagesSearch(func(ctx context.Context, req *tg.MessagesSearchRequest) (tg.MessagesMessagesClass, error) { + if utf8.RuneCountInString(req.Q) > maxMessageSearchQLength { + return nil, limitInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + filter := r.messageFilterFromSearchRequest(userID, req) + if filter.HasPeer && filter.Peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + if isLegacyInputPeerChat(req.Peer) { + return &tg.MessagesMessages{}, nil + } + if searchFilterNeedsMediaStore(req.Filter) { + if mediaSearchCountOnlyRequest(req) { + view, err := r.resolveInputPeerChannelView(ctx, userID, req.Peer, filter.Peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + counts, err := r.mediaCountsForPeer(ctx, userID, filter.Peer) + if err != nil { + return nil, channelInvalidErr(err) + } + out := &tg.MessagesChannelMessages{ + Pts: view.Channel.Pts, + Count: counts.CountAny(mediaCategoriesForFilter(req.Filter)), + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{tgChannelChatForView(userID, view)}, + Users: []tg.UserClass{}, + } + r.applyStoryMaxIDsToMessages(ctx, userID, out) + return out, nil + } + if err := r.validateInputPeerChannelAccess(ctx, userID, req.Peer, filter.Peer.ID); err != nil { + return nil, err + } + categories := mediaCategoriesForFilter(req.Filter) + mediaReq := domain.MediaSearchRequest{ + Categories: categories, + OffsetID: req.OffsetID, + AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), + Limit: req.Limit, + MaxID: req.MaxID, + MinID: req.MinID, + } + if mediaSearchCanReusePeerWideCount(req) { + counts, err := r.mediaCountsForPeer(ctx, userID, filter.Peer) + if err != nil { + return nil, channelInvalidErr(err) + } + mediaReq.KnownCount = counts.CountAny(categories) + mediaReq.HasKnownCount = true + } + history, err := r.deps.Channels.SearchChannelMedia(ctx, userID, filter.Peer.ID, mediaReq) + if err != nil { + return nil, channelInvalidErr(err) + } + history = r.enrichChannelHistory(ctx, userID, history) + return r.tgChannelHistoryMessages(ctx, userID, history), nil + } + if err := r.validateInputPeerChannelAccess(ctx, userID, req.Peer, filter.Peer.ID); err != nil { + return nil, err + } + chFilter, ok := r.channelHistoryFilterFromSearchRequest(userID, req, filter.Peer.ID) + if !ok { + return nil, peerIDInvalidErr() + } + history, err := r.deps.Channels.GetHistory(ctx, userID, chFilter) + if err != nil { + return nil, channelInvalidErr(err) + } + history = r.enrichChannelHistory(ctx, userID, history) + if chFilter.Hash != 0 && history.Hash == chFilter.Hash { + return &tg.MessagesMessagesNotModified{Count: history.Count}, nil + } + return r.tgChannelHistoryMessages(ctx, userID, history), nil + } + if _, ok := req.Filter.(*tg.InputMessagesFilterPinned); ok { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + if r.deps.Messages == nil || !filter.HasPeer || filter.Peer.Type != domain.PeerTypeUser { + return &tg.MessagesMessages{}, nil + } + // 私聊置顶列表:客户端置顶栏经 filterPinned 分页拉取,必须 + // 带总数(NotModified 哈希不参与该过滤器)。 + filter.PinnedOnly = true + filter.NeedTotalCount = true + filter.Hash = 0 + list, err := r.deps.Messages.Search(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil + } + if r.deps.Messages == nil { + return messagesNotModifiedOrEmpty(req.Hash), nil + } + if searchFilterNeedsMediaStore(req.Filter) { + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeUser { + return &tg.MessagesMessages{}, nil + } + if mediaSearchCountOnlyRequest(req) { + counts, err := r.mediaCountsForPeer(ctx, userID, peer) + if err != nil { + return nil, internalErr() + } + return r.tgMessagesMessages(ctx, userID, domain.MessageList{ + Count: counts.CountAny(mediaCategoriesForFilter(req.Filter)), + }), nil + } + categories := mediaCategoriesForFilter(req.Filter) + mediaReq := domain.MediaSearchRequest{ + Categories: categories, + OffsetID: req.OffsetID, + AddOffset: domain.ClampMessageHistoryAddOffset(req.AddOffset), + Limit: req.Limit, + MaxID: req.MaxID, + MinID: req.MinID, + } + if mediaSearchCanReusePeerWideCount(req) { + counts, err := r.mediaCountsForPeer(ctx, userID, peer) + if err != nil { + return nil, internalErr() + } + mediaReq.KnownCount = counts.CountAny(categories) + mediaReq.HasKnownCount = true + } + list, err := r.deps.Messages.SearchPrivateMedia(ctx, userID, peer.ID, mediaReq) + if err != nil { + return nil, internalErr() + } + return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil + } + list, err := r.deps.Messages.Search(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + if filter.Hash != 0 && list.Hash == filter.Hash { + return &tg.MessagesMessagesNotModified{Count: list.Count}, nil + } + return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil + }) + d.OnMessagesSearchGlobal(r.onMessagesSearchGlobal) + d.OnMessagesGetSearchResultsCalendar(r.onMessagesGetSearchResultsCalendar) + d.OnMessagesGetSearchResultsPositions(r.onMessagesGetSearchResultsPositions) + d.OnMessagesSendReaction(r.onMessagesSendReaction) + // 语音转文字无识别后端:注册为显式失败(TRANSCRIPTION_FAILED),premium + // 客户端点击转录按钮得到优雅失败提示,而不是 NOT_IMPLEMENTED trace。 + d.OnMessagesTranscribeAudio(func(ctx context.Context, req *tg.MessagesTranscribeAudioRequest) (*tg.MessagesTranscribedAudio, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return nil, tgerr400("TRANSCRIPTION_FAILED") + }) + d.OnMessagesGetMessagesReactions(r.onMessagesGetMessagesReactions) + d.OnMessagesGetMessageReactionsList(r.onMessagesGetMessageReactionsList) + d.OnMessagesSetDefaultReaction(r.onMessagesSetDefaultReaction) + d.OnMessagesGetPaidReactionPrivacy(r.onMessagesGetPaidReactionPrivacy) + d.OnMessagesTogglePaidReactionPrivacy(r.onMessagesTogglePaidReactionPrivacy) + d.OnMessagesSendPaidReaction(r.onMessagesSendPaidReaction) + d.OnMessagesDeleteParticipantReactions(r.onMessagesDeleteParticipantReactions) + d.OnMessagesDeleteParticipantReaction(r.onMessagesDeleteParticipantReaction) + d.OnMessagesGetUnreadReactions(r.onMessagesGetUnreadReactions) + d.OnMessagesReadReactions(r.onMessagesReadReactions) + d.OnMessagesGetTopReactions(r.onMessagesGetTopReactions) + d.OnMessagesGetRecentReactions(r.onMessagesGetRecentReactions) + d.OnMessagesClearRecentReactions(r.onMessagesClearRecentReactions) + d.OnMessagesGetSavedReactionTags(r.onMessagesGetSavedReactionTags) + d.OnMessagesUpdateSavedReactionTag(r.onMessagesUpdateSavedReactionTag) + d.OnMessagesGetDefaultTagReactions(r.onMessagesGetDefaultTagReactions) + d.OnMessagesSendVote(r.onMessagesSendVote) + d.OnMessagesGetPollResults(r.onMessagesGetPollResults) + d.OnMessagesGetPollVotes(r.onMessagesGetPollVotes) + d.OnMessagesAddPollAnswer(r.onMessagesAddPollAnswer) + d.OnMessagesDeletePollAnswer(r.onMessagesDeletePollAnswer) + d.OnMessagesGetUnreadPollVotes(r.onMessagesGetUnreadPollVotes) + d.OnMessagesReadPollVotes(r.onMessagesReadPollVotes) + d.OnMessagesAppendTodoList(r.onMessagesAppendTodoList) + d.OnMessagesToggleTodoCompleted(r.onMessagesToggleTodoCompleted) + d.OnMessagesGetScheduledHistory(r.onMessagesGetScheduledHistory) + d.OnMessagesGetScheduledMessages(r.onMessagesGetScheduledMessages) + d.OnMessagesSendScheduledMessages(r.onMessagesSendScheduledMessages) + d.OnMessagesDeleteScheduledMessages(r.onMessagesDeleteScheduledMessages) + d.OnMessagesCreateForumTopic(r.onMessagesCreateForumTopic) + d.OnMessagesEditForumTopic(r.onMessagesEditForumTopic) + d.OnMessagesUpdatePinnedForumTopic(r.onMessagesUpdatePinnedForumTopic) + d.OnMessagesReorderPinnedForumTopics(r.onMessagesReorderPinnedForumTopics) + d.OnMessagesDeleteTopicHistory(r.onMessagesDeleteTopicHistory) +} diff --git a/internal/rpc/messages_reports.go b/internal/rpc/messages_reports.go new file mode 100644 index 00000000..a68feb11 --- /dev/null +++ b/internal/rpc/messages_reports.go @@ -0,0 +1,130 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onMessagesReportSpam(ctx context.Context, peer tg.InputPeerClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportRequest) (tg.ReportResultClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + if len(req.ID) == 0 { + return nil, tgerr.New(400, "MESSAGE_REQUIRED") + } + if len(req.ID) > maxGetMessagesIDs || len(req.Option) > maxReportOptionLength || utf8.RuneCountInString(req.Message) > maxReportCommentLength { + return nil, limitInvalidErr() + } + for _, msgID := range req.ID { + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + } + return reportResultForOption(string(req.Option)) +} + +func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesReportReactionRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.MessagesReportMessagesDeliveryRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if len(req.ID) > maxGetMessagesIDs { + return false, limitInvalidErr() + } + for _, msgID := range req.ID { + if msgID <= 0 || msgID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesReportReadMetrics(ctx context.Context, req *tg.MessagesReportReadMetricsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if len(req.Metrics) > maxReadMetrics { + return false, limitInvalidErr() + } + for _, metric := range req.Metrics { + if metric.MsgID <= 0 || metric.MsgID > domain.MaxMessageBoxID { + return false, messageIDInvalidErr() + } + if metric.TimeInViewMs < 0 || metric.ActiveTimeInViewMs < 0 || metric.HeightToViewportRatioPermille < 0 || metric.SeenRangeRatioPermille < 0 { + return false, limitInvalidErr() + } + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return false, err + } + return true, nil +} + +func (r *Router) onMessagesReportMusicListen(ctx context.Context, req *tg.MessagesReportMusicListenRequest) (bool, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return false, internalErr() + } + if req.ID == nil { + return false, tgerr.New(400, "DOCUMENT_INVALID") + } + if req.ListenedDuration < 0 { + return false, limitInvalidErr() + } + return true, nil +} + +func (r *Router) onMessagesReportSponsoredMessage(ctx context.Context, req *tg.MessagesReportSponsoredMessageRequest) (tg.ChannelsSponsoredMessageReportResultClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + if len(req.RandomID) == 0 || len(req.RandomID) > maxReportRandomIDLength || len(req.Option) > maxReportOptionLength { + return nil, limitInvalidErr() + } + return &tg.ChannelsSponsoredMessageReportResultReported{}, nil +} + +func (r *Router) onMessagesGetSponsoredMessages(ctx context.Context, req *tg.MessagesGetSponsoredMessagesRequest) (tg.MessagesSponsoredMessagesClass, error) { + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return &tg.MessagesSponsoredMessagesEmpty{}, nil +} diff --git a/internal/rpc/messages_rich_message_rpc_test.go b/internal/rpc/messages_rich_message_rpc_test.go new file mode 100644 index 00000000..f2539760 --- /dev/null +++ b/internal/rpc/messages_rich_message_rpc_test.go @@ -0,0 +1,184 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// richTextBlocks 构造一组纯文本 IV 页面块,用于富文本往返断言。 +func richTextBlocks() []tg.PageBlockClass { + return []tg.PageBlockClass{ + &tg.PageBlockTitle{Text: &tg.TextPlain{Text: "Rich Title"}}, + &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "First paragraph."}}, + } +} + +// assertRichTextBlocks 校验投影出的 RichMessage 携带 richTextBlocks 的两个块(标题+段落)。 +func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) { + t.Helper() + if !rich.Rtl { + t.Errorf("%s: rtl = false, want true", label) + } + if len(rich.Blocks) != 2 { + t.Fatalf("%s: blocks = %d, want 2", label, len(rich.Blocks)) + } + title, ok := rich.Blocks[0].(*tg.PageBlockTitle) + if !ok { + t.Fatalf("%s: block[0] = %T, want *tg.PageBlockTitle", label, rich.Blocks[0]) + } + if tp, ok := title.Text.(*tg.TextPlain); !ok || tp.Text != "Rich Title" { + t.Errorf("%s: title text = %+v, want plain %q", label, title.Text, "Rich Title") + } + para, ok := rich.Blocks[1].(*tg.PageBlockParagraph) + if !ok { + t.Fatalf("%s: block[1] = %T, want *tg.PageBlockParagraph", label, rich.Blocks[1]) + } + if tp, ok := para.Text.(*tg.TextPlain); !ok || tp.Text != "First paragraph." { + t.Errorf("%s: paragraph text = %+v, want plain %q", label, para.Text, "First paragraph.") + } +} + +// TestSendMessageRichMessageTextBlocksRoundTrip 验证 Layer 227 富文本(inputRichMessage 的 +// blocks 形态)经 send → 发送方 echo / getMessages / getRichMessage 全链路原样往返。 +func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: "rich", + RandomID: 7001, + RichMessage: &tg.InputRichMessage{ + Rtl: true, + Blocks: richTextBlocks(), + }, + }) + if err != nil { + t.Fatalf("send rich message: %v", err) + } + echo := newMessageFromUpdates(t, updates) + rich, ok := echo.GetRichMessage() + if !ok { + t.Fatalf("send echo missing rich message") + } + assertRichTextBlocks(t, "send echo", rich) + + // getMessages(发送方按 box id 拉取)也应带富文本。 + got, err := r.onMessagesGetMessages(WithUserID(ctx, owner.ID), []tg.InputMessageClass{&tg.InputMessageID{ID: echo.ID}}) + if err != nil { + t.Fatalf("get messages: %v", err) + } + stored := singleStoredMessage(t, got) + rich, ok = stored.GetRichMessage() + if !ok { + t.Fatalf("getMessages missing rich message") + } + assertRichTextBlocks(t, "getMessages", rich) + + // getRichMessage(按 peer+id 拉取完整富文本)应带富文本。 + gotRich, err := r.onMessagesGetRichMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetRichMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + ID: echo.ID, + }) + if err != nil { + t.Fatalf("get rich message: %v", err) + } + stored = singleStoredMessage(t, gotRich) + rich, ok = stored.GetRichMessage() + if !ok { + t.Fatalf("getRichMessage missing rich message") + } + assertRichTextBlocks(t, "getRichMessage", rich) +} + +// TestGetRichMessageWrongPeerReturnsEmpty 验证 getRichMessage 的 peer 校验:用不匹配的 peer +// 拉取应返回 messageEmpty(不跨会话泄漏)。 +func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: "rich", + RandomID: 7002, + RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()}, + }) + if err != nil { + t.Fatalf("send rich message: %v", err) + } + echo := newMessageFromUpdates(t, updates) + + // 用 self peer(≠ 该消息盒的 peer=friend)拉取 → messageEmpty。 + gotRich, err := r.onMessagesGetRichMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetRichMessageRequest{ + Peer: &tg.InputPeerSelf{}, + ID: echo.ID, + }) + if err != nil { + t.Fatalf("get rich message wrong peer: %v", err) + } + box, ok := gotRich.(*tg.MessagesMessages) + if !ok || len(box.Messages) != 1 { + t.Fatalf("getRichMessage wrong peer = %T %+v, want one messages.messages", gotRich, gotRich) + } + if _, ok := box.Messages[0].(*tg.MessageEmpty); !ok { + t.Fatalf("getRichMessage wrong peer message = %T, want *tg.MessageEmpty", box.Messages[0]) + } +} + +// TestSendMessageRichMessageEmbeddedPhoto 验证富文本内嵌图片:按 id 解析为媒体快照存储, +// 投影时复用 tgPhoto 还原。 +func TestSendMessageRichMessageEmbeddedPhoto(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + files, ok := r.deps.Files.(*fakeFiles) + if !ok { + t.Fatalf("deps.Files = %T, want *fakeFiles", r.deps.Files) + } + files.photos[889] = domain.Photo{ID: 889, AccessHash: 42, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600}}} + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: "rich+photo", + RandomID: 7003, + RichMessage: &tg.InputRichMessage{ + Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "see photo"}}}, + Photos: []tg.InputPhotoClass{&tg.InputPhoto{ID: 889, AccessHash: 42}}, + }, + }) + if err != nil { + t.Fatalf("send rich message with photo: %v", err) + } + echo := newMessageFromUpdates(t, updates) + rich, ok := echo.GetRichMessage() + if !ok { + t.Fatalf("send echo missing rich message") + } + if len(rich.Photos) != 1 { + t.Fatalf("rich photos = %d, want 1", len(rich.Photos)) + } + photo, ok := rich.Photos[0].(*tg.Photo) + if !ok { + t.Fatalf("rich photo = %T, want *tg.Photo", rich.Photos[0]) + } + if photo.ID != 889 { + t.Errorf("rich photo id = %d, want 889", photo.ID) + } +} + +// singleStoredMessage 从 messages.messages 取出唯一一条非空 *tg.Message。 +func singleStoredMessage(t *testing.T, res tg.MessagesMessagesClass) *tg.Message { + t.Helper() + box, ok := res.(*tg.MessagesMessages) + if !ok || len(box.Messages) != 1 { + t.Fatalf("messages = %T %+v, want one messages.messages", res, res) + } + msg, ok := box.Messages[0].(*tg.Message) + if !ok { + t.Fatalf("stored message = %T, want *tg.Message", box.Messages[0]) + } + return msg +} diff --git a/internal/rpc/messages_saved_dialogs.go b/internal/rpc/messages_saved_dialogs.go new file mode 100644 index 00000000..91cf79b4 --- /dev/null +++ b/internal/rpc/messages_saved_dialogs.go @@ -0,0 +1,375 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +// Saved Messages 分会话(saved dialogs)真实实现。 +// 纯 saved 形态(无 parent_peer)按 self-chat 消息的 saved_peer 分组返回; +// parent_peer = monoforum topic list 仍是范围外,仅校验 parent channel 后 +// 返回空(与既有 monoforum stub 行为一致,见 compatibility-matrix)。 + +func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.MessagesGetSavedDialogsRequest) (tg.MessagesSavedDialogsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if parent, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil { + return nil, err + } else if hasParent { + if req.Limit < 0 || req.OffsetID < 0 { + return nil, limitInvalidErr() + } + mono, isMono, err := r.resolveMonoforumForAdmin(ctx, userID, parent) + if err != nil { + return nil, err + } + if !isMono { + // 普通频道(非 monoforum)传 parent_peer:保持旧的良性空响应 + parent 上下文。 + return &tg.MessagesSavedDialogs{ + Dialogs: []tg.SavedDialogClass{}, + Messages: []tg.MessageClass{}, + Chats: r.savedHistoryChats(ctx, userID, true, parent, nil), + Users: []tg.UserClass{}, + }, nil + } + // parent_peer = monoforum:返回该频道私信的订阅者子会话列表(管理员视角)。 + return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, req.OffsetID) + } + if req.Limit < 0 || req.OffsetID < 0 || req.OffsetDate < 0 { + return nil, limitInvalidErr() + } + filter := domain.SavedDialogsFilter{ + ExcludePinned: req.ExcludePinned, + OffsetID: req.OffsetID, + OffsetDate: req.OffsetDate, + Limit: req.Limit, + } + if req.OffsetPeer != nil { + filter.OffsetPeer, _ = r.domainPeerFromInputPeer(userID, req.OffsetPeer) + } + if r.deps.Messages == nil { + if req.Hash != 0 { + return &tg.MessagesSavedDialogsNotModified{Count: 0}, nil + } + return &tg.MessagesSavedDialogs{ + Dialogs: []tg.SavedDialogClass{}, + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil + } + list, err := r.deps.Messages.GetSavedDialogs(ctx, userID, filter) + if err != nil { + return nil, internalErr() + } + if req.Hash != 0 && savedDialogsHash(list) == req.Hash { + return &tg.MessagesSavedDialogsNotModified{Count: list.Count}, nil + } + return r.tgMessagesSavedDialogs(ctx, userID, list), nil +} + +func (r *Router) onMessagesGetPinnedSavedDialogs(ctx context.Context) (tg.MessagesSavedDialogsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Messages == nil { + return &tg.MessagesSavedDialogs{ + Dialogs: []tg.SavedDialogClass{}, + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil + } + list, err := r.deps.Messages.GetPinnedSavedDialogs(ctx, userID) + if err != nil { + return nil, internalErr() + } + return r.tgMessagesSavedDialogs(ctx, userID, list), nil +} + +func (r *Router) onMessagesGetSavedDialogsByID(ctx context.Context, req *tg.MessagesGetSavedDialogsByIDRequest) (tg.MessagesSavedDialogsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil { + return nil, err + } else if hasParent { + return &tg.MessagesSavedDialogs{ + Dialogs: []tg.SavedDialogClass{}, + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil + } + if len(req.IDs) > maxDialogInputPeers { + return nil, limitInvalidErr() + } + peers := make([]domain.Peer, 0, len(req.IDs)) + for _, input := range req.IDs { + // 未知/已删除 peer 静默缺席(客户端用本接口补 stale 数据, + // 部分命中是正常结果);显式非法构造仍拒绝。 + peer, ok := r.domainPeerFromInputPeer(userID, input) + if !ok { + return nil, peerIDInvalidErr() + } + peers = append(peers, peer) + } + if r.deps.Messages == nil { + return &tg.MessagesSavedDialogs{ + Dialogs: []tg.SavedDialogClass{}, + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil + } + list, err := r.deps.Messages.GetSavedDialogsByPeers(ctx, userID, peers) + if err != nil { + return nil, internalErr() + } + return r.tgMessagesSavedDialogs(ctx, userID, list), nil +} + +func (r *Router) onMessagesToggleSavedDialogPin(ctx context.Context, req *tg.MessagesToggleSavedDialogPinRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peers, err := r.dialogPeersFromInput(ctx, userID, []tg.InputDialogPeerClass{req.Peer}) + if err != nil { + return false, err + } + if len(peers) != 1 { + return false, peerIDInvalidErr() + } + pinned := req.GetPinned() + if r.deps.Messages == nil { + return true, nil + } + changed, err := r.deps.Messages.ToggleSavedDialogPin(ctx, userID, peers[0], pinned) + if err != nil { + if errors.Is(err, domain.ErrPinnedSavedDialogsTooMuch) { + return false, savedPinnedTooMuchErr() + } + return false, internalErr() + } + if changed { + date := int(r.clock.Now().Unix()) + var recorded domain.UpdateEvent + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, state, err := r.deps.Updates.RecordSavedDialogPinned(ctx, authKeyID, userID, peers[0], pinned, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + recorded = event + } + r.bookkeepAuxPtsForCurrentSession(ctx, recorded) + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{&tg.UpdateSavedDialogPinned{ + Pinned: pinned, + Peer: tgDialogPeer(peers[0]), + }}, recorded), + Date: date, + Seq: 0, + }) + } + return true, nil +} + +func (r *Router) onMessagesReorderPinnedSavedDialogs(ctx context.Context, req *tg.MessagesReorderPinnedSavedDialogsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if len(req.Order) > maxDialogInputPeers { + return false, limitInvalidErr() + } + peers, err := r.dialogPeersFromInput(ctx, userID, req.Order) + if err != nil { + return false, err + } + if r.deps.Messages == nil { + return true, nil + } + if err := r.deps.Messages.ReorderPinnedSavedDialogs(ctx, userID, peers, req.GetForce()); err != nil { + if errors.Is(err, domain.ErrPinnedSavedDialogsTooMuch) { + return false, savedPinnedTooMuchErr() + } + return false, internalErr() + } + date := int(r.clock.Now().Unix()) + var recorded domain.UpdateEvent + if r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + event, state, err := r.deps.Updates.RecordPinnedSavedDialogs(ctx, authKeyID, userID, peers, sessionID) + if err != nil { + return false, internalErr() + } + date = state.Date + recorded = event + } + r.bookkeepAuxPtsForCurrentSession(ctx, recorded) + update := &tg.UpdatePinnedSavedDialogs{} + if len(peers) > 0 { + update.SetOrder(tgDialogPeers(peers)) + } + r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, &tg.Updates{ + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, recorded), + Date: date, + Seq: 0, + }) + return true, nil +} + +// tgMessagesSavedDialogs 把业务结果转 TL 输出:Full 映射 savedDialogs(已到 +// 末尾),否则 savedDialogsSlice + 总数。 +func (r *Router) tgMessagesSavedDialogs(ctx context.Context, userID int64, list domain.SavedDialogList) tg.MessagesSavedDialogsClass { + dialogs := make([]tg.SavedDialogClass, 0, len(list.Dialogs)) + for _, d := range list.Dialogs { + peer := tgPeer(d.Peer) + if peer == nil { + continue + } + sd := &tg.SavedDialog{Peer: peer, TopMessage: d.TopMessage} + if d.Pinned { + sd.SetPinned(true) + } + dialogs = append(dialogs, sd) + } + messages := make([]tg.MessageClass, 0, len(list.Messages)) + for _, msg := range list.Messages { + if item := tgMessage(msg); item != nil { + messages = append(messages, item) + } + } + users, chats := r.savedDialogsProjection(ctx, userID, list) + if list.Full { + return &tg.MessagesSavedDialogs{ + Dialogs: dialogs, + Messages: messages, + Chats: chats, + Users: users, + } + } + return &tg.MessagesSavedDialogsSlice{ + Count: list.Count, + Dialogs: dialogs, + Messages: messages, + Chats: chats, + Users: users, + } +} + +// savedDialogsProjection 投影子会话引用到的 users/chats:self 恒带(self-chat +// 上下文 + My Notes 子会话),saved peer / fwd 作者 / 跳回源会话批量补齐, +// hidden author 占位用户在库中不存在、按需合成。 +func (r *Router) savedDialogsProjection(ctx context.Context, userID int64, list domain.SavedDialogList) ([]tg.UserClass, []tg.ChatClass) { + userIDs := make([]int64, 0, len(list.Dialogs)) + channelIDs := make([]int64, 0) + seenUsers := map[int64]struct{}{} + seenChannels := map[int64]struct{}{} + needHidden := false + addPeer := func(p domain.Peer) { + switch p.Type { + case domain.PeerTypeUser: + if p.ID == 0 || p.ID == userID { + return + } + if p.ID == domain.SavedHiddenAuthorUserID { + needHidden = true + return + } + if _, ok := seenUsers[p.ID]; ok { + return + } + seenUsers[p.ID] = struct{}{} + userIDs = append(userIDs, p.ID) + case domain.PeerTypeChannel: + if p.ID == 0 { + return + } + if _, ok := seenChannels[p.ID]; ok { + return + } + seenChannels[p.ID] = struct{}{} + channelIDs = append(channelIDs, p.ID) + } + } + for _, d := range list.Dialogs { + addPeer(d.Peer) + } + for _, msg := range list.Messages { + if msg.Forward != nil { + addPeer(msg.Forward.From) + addPeer(msg.Forward.SavedFrom) + } + } + users := make([]tg.UserClass, 0, len(userIDs)+2) + if r.deps.Users != nil { + if self, err := r.deps.Users.Self(ctx, userID); err == nil && self.ID != 0 { + users = append(users, r.tgSelfUser(self)) + } + if len(userIDs) > 0 { + if found, err := r.deps.Users.ByIDs(ctx, userID, userIDs); err == nil { + users = append(users, r.tgUsers(found)...) + } + } + } + if needHidden { + users = append(users, tgSavedHiddenAuthorUser()) + } + chats := make([]tg.ChatClass, 0, len(channelIDs)) + if r.deps.Channels != nil && len(channelIDs) > 0 { + if views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs); err == nil { + for _, view := range views { + if view.Channel.ID == 0 { + continue + } + chats = append(chats, tgChannelChatForView(userID, view)) + } + } + } + return users, chats +} + +// tgSavedHiddenAuthorUser 合成 hidden-author 占位用户(官方 user 2666000)。 +// TDesktop/Android 都按 id 特判本地展示,名字仅作兜底。 +func tgSavedHiddenAuthorUser() *tg.User { + return &tg.User{ID: domain.SavedHiddenAuthorUserID, FirstName: "Anonymous"} +} + +// savedDialogsHash 与 DrKLO Android SavedMessagesController 的请求 hash 算法 +// 对齐:对返回序列逐项喂 pinned/abs(peerId)/top_message_id/top date;TDesktop +// hash 恒 0,不触发 notModified。 +func savedDialogsHash(list domain.SavedDialogList) int64 { + dateByID := make(map[int]int, len(list.Messages)) + for _, msg := range list.Messages { + dateByID[msg.ID] = msg.Date + } + var hash uint64 + for _, d := range list.Dialogs { + pinned := int64(0) + if d.Pinned { + pinned = 1 + } + id := d.Peer.ID + if id < 0 { + id = -id + } + hash = tdesktopHashUpdate(hash, pinned) + hash = tdesktopHashUpdate(hash, id) + hash = tdesktopHashUpdate(hash, int64(d.TopMessage)) + hash = tdesktopHashUpdate(hash, int64(dateByID[d.TopMessage])) + } + return int64(hash) +} diff --git a/internal/rpc/messages_saved_dialogs_rpc_test.go b/internal/rpc/messages_saved_dialogs_rpc_test.go new file mode 100644 index 00000000..4f75782c --- /dev/null +++ b/internal/rpc/messages_saved_dialogs_rpc_test.go @@ -0,0 +1,626 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appcontacts "telesrv/internal/app/contacts" + appdialogs "telesrv/internal/app/dialogs" + appmessages "telesrv/internal/app/messages" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func newSavedDialogsTestRouter(t *testing.T) (*Router, *memory.MessageStore, *memory.UserStore) { + t.Helper() + userStore := memory.NewUserStore() + dialogs := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogs) + contactStore := memory.NewContactStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Contacts: appcontacts.NewService(contactStore, userStore), + Messages: appmessages.NewService(messageStore, dialogs), + Dialogs: appdialogs.NewService(dialogs), + }, zaptest.NewLogger(t), clock.System) + return r, messageStore, userStore +} + +// savedDialogsFixture 构造收藏夹两个子会话:直发笔记(saved=self)+ +// 从 bob 私聊转发(saved=bob,转发更晚 top 更新)。 +func savedDialogsFixture(t *testing.T) (*Router, *memory.MessageStore, domain.User, domain.User, int, int) { + t.Helper() + ctx := context.Background() + r, messageStore, userStore := newSavedDialogsTestRouter(t) + alice, _ := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550009301", FirstName: "Alice"}) + bob, _ := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550009302", FirstName: "Bob"}) + + if _, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerSelf{}, + Message: "my note", + RandomID: 93001, + }); err != nil { + t.Fatalf("send self note: %v", err) + } + if _, err := r.onMessagesSendMessage(WithUserID(ctx, bob.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, + Message: "from bob", + RandomID: 93002, + }); err != nil { + t.Fatalf("bob send: %v", err) + } + bobMsgID := privateTopMessageID(t, messageStore, alice.ID, bob.ID) + if _, err := r.onMessagesForwardMessages(WithUserID(ctx, alice.ID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + ToPeer: &tg.InputPeerSelf{}, + ID: []int{bobMsgID}, + RandomID: []int64{93003}, + }); err != nil { + t.Fatalf("forward to saved: %v", err) + } + noteID := savedTopMessageID(t, messageStore, alice.ID, domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}) + fwdID := savedTopMessageID(t, messageStore, alice.ID, domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}) + return r, messageStore, alice, bob, noteID, fwdID +} + +func privateTopMessageID(t *testing.T, store *memory.MessageStore, ownerID, peerID int64) int { + t.Helper() + list, err := store.ListByUser(context.Background(), ownerID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + Limit: 1, + }) + if err != nil || len(list.Messages) == 0 { + t.Fatalf("list private top: %+v err %v", list.Messages, err) + } + return list.Messages[0].ID +} + +func savedTopMessageID(t *testing.T, store *memory.MessageStore, ownerID int64, savedPeer domain.Peer) int { + t.Helper() + list, err := store.ListByUser(context.Background(), ownerID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}, + SavedPeer: savedPeer, + Limit: 1, + }) + if err != nil || len(list.Messages) == 0 { + t.Fatalf("list saved top: %+v err %v", list.Messages, err) + } + return list.Messages[0].ID +} + +func savedDialogPage(t *testing.T, res tg.MessagesSavedDialogsClass) ([]*tg.SavedDialog, []tg.MessageClass, []tg.UserClass, int, bool) { + t.Helper() + var rawDialogs []tg.SavedDialogClass + var messages []tg.MessageClass + var users []tg.UserClass + count := 0 + full := false + switch v := res.(type) { + case *tg.MessagesSavedDialogs: + rawDialogs, messages, users = v.Dialogs, v.Messages, v.Users + count = len(v.Dialogs) + full = true + case *tg.MessagesSavedDialogsSlice: + rawDialogs, messages, users = v.Dialogs, v.Messages, v.Users + count = v.Count + default: + t.Fatalf("saved dialogs = %T, want savedDialogs/slice", res) + } + dialogs := make([]*tg.SavedDialog, 0, len(rawDialogs)) + for _, d := range rawDialogs { + sd, ok := d.(*tg.SavedDialog) + if !ok { + t.Fatalf("dialog = %T, want *tg.SavedDialog", d) + } + dialogs = append(dialogs, sd) + } + return dialogs, messages, users, count, full +} + +func savedDialogPeerID(t *testing.T, d *tg.SavedDialog) int64 { + t.Helper() + switch p := d.Peer.(type) { + case *tg.PeerUser: + return p.UserID + case *tg.PeerChannel: + return p.ChannelID + default: + t.Fatalf("saved dialog peer = %T", d.Peer) + return 0 + } +} + +// TestMessagesGetSavedDialogsGroupsBySource 验证直发/转发分组、排序、 +// saved_peer_id 下发与 users 投影。 +func TestMessagesGetSavedDialogsGroupsBySource(t *testing.T) { + ctx := context.Background() + r, _, alice, bob, noteID, fwdID := savedDialogsFixture(t) + + res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 20}) + if err != nil { + t.Fatalf("getSavedDialogs: %v", err) + } + dialogs, messages, users, _, full := savedDialogPage(t, res) + if !full || len(dialogs) != 2 { + t.Fatalf("dialogs = %d full %v, want 2 full", len(dialogs), full) + } + // 转发更晚,bob 子会话在前。 + if savedDialogPeerID(t, dialogs[0]) != bob.ID || dialogs[0].TopMessage != fwdID { + t.Fatalf("first dialog = peer %d top %d, want bob %d top %d", savedDialogPeerID(t, dialogs[0]), dialogs[0].TopMessage, bob.ID, fwdID) + } + if savedDialogPeerID(t, dialogs[1]) != alice.ID || dialogs[1].TopMessage != noteID { + t.Fatalf("second dialog = peer %d top %d, want self %d top %d", savedDialogPeerID(t, dialogs[1]), dialogs[1].TopMessage, alice.ID, noteID) + } + if len(messages) != 2 { + t.Fatalf("messages = %d, want 2 tops", len(messages)) + } + for _, raw := range messages { + msg, ok := raw.(*tg.Message) + if !ok { + t.Fatalf("message = %T", raw) + } + saved, ok := msg.GetSavedPeerID() + if !ok { + t.Fatalf("message %d lacks saved_peer_id", msg.ID) + } + want := alice.ID + if msg.ID == fwdID { + want = bob.ID + } + if user, ok := saved.(*tg.PeerUser); !ok || user.UserID != want { + t.Fatalf("message %d saved_peer_id = %+v, want user %d", msg.ID, saved, want) + } + if msg.ID == fwdID { + fwd, ok := msg.GetFwdFrom() + if !ok { + t.Fatalf("forwarded top lacks fwd header") + } + savedFrom, ok := fwd.GetSavedFromPeer() + if !ok { + t.Fatalf("forwarded top lacks saved_from_peer") + } + if user, ok := savedFrom.(*tg.PeerUser); !ok || user.UserID != bob.ID { + t.Fatalf("saved_from_peer = %+v, want bob", savedFrom) + } + } + } + var hasSelf, hasBob bool + for _, raw := range users { + u, ok := raw.(*tg.User) + if !ok { + continue + } + if u.ID == alice.ID { + hasSelf = true + if !u.Self { + t.Fatalf("self user lacks self flag") + } + } + if u.ID == bob.ID { + hasBob = true + } + } + if !hasSelf || !hasBob { + t.Fatalf("users projection self=%v bob=%v, want both", hasSelf, hasBob) + } +} + +// TestMessagesGetSavedDialogsPagination 验证分页:limit 截断 → slice+count, +// offset 翻页 → 尾页 savedDialogs;Android 首页 offset_id=int32 max 等价首页。 +func TestMessagesGetSavedDialogsPagination(t *testing.T) { + ctx := context.Background() + r, _, alice, bob, noteID, fwdID := savedDialogsFixture(t) + + first, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 1}) + if err != nil { + t.Fatalf("first page: %v", err) + } + dialogs, _, _, count, full := savedDialogPage(t, first) + if full || count != 2 || len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != bob.ID { + t.Fatalf("first page = %d dialogs count %d full %v, want slice 1/2 bob first", len(dialogs), count, full) + } + second, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{ + Limit: 1, + OffsetID: fwdID, + }) + if err != nil { + t.Fatalf("second page: %v", err) + } + dialogs, _, _, _, full = savedDialogPage(t, second) + if !full || len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != alice.ID || dialogs[0].TopMessage != noteID { + t.Fatalf("second page = %+v full %v, want self note tail", dialogs, full) + } + + androidFirst, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{ + Limit: 20, + OffsetID: 2147483647, + }) + if err != nil { + t.Fatalf("android first page: %v", err) + } + dialogs, _, _, _, full = savedDialogPage(t, androidFirst) + if !full || len(dialogs) != 2 { + t.Fatalf("android first page = %d full %v, want 2 full", len(dialogs), full) + } +} + +// TestMessagesGetSavedDialogsHashNotModified 验证 Android calcHash 序列命中 +// 返回 savedDialogsNotModified{count}。 +func TestMessagesGetSavedDialogsHashNotModified(t *testing.T) { + ctx := context.Background() + r, _, alice, _, _, _ := savedDialogsFixture(t) + + res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 20}) + if err != nil { + t.Fatalf("getSavedDialogs: %v", err) + } + dialogs, messages, _, _, _ := savedDialogPage(t, res) + dateByID := map[int]int{} + for _, raw := range messages { + if msg, ok := raw.(*tg.Message); ok { + dateByID[msg.ID] = msg.Date + } + } + var hash uint64 + for _, d := range dialogs { + pinned := int64(0) + if d.Pinned { + pinned = 1 + } + hash = tdesktopHashUpdate(hash, pinned) + hash = tdesktopHashUpdate(hash, savedDialogPeerID(t, d)) + hash = tdesktopHashUpdate(hash, int64(d.TopMessage)) + hash = tdesktopHashUpdate(hash, int64(dateByID[d.TopMessage])) + } + cached, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{ + Limit: 20, + Hash: int64(hash), + }) + if err != nil { + t.Fatalf("getSavedDialogs hash: %v", err) + } + notModified, ok := cached.(*tg.MessagesSavedDialogsNotModified) + if !ok || notModified.Count != 2 { + t.Fatalf("hash hit = %#v, want notModified count 2", cached) + } +} + +// TestMessagesSavedDialogPinLifecycle 验证置顶翻转/排序/排除全链路: +// updateSavedDialogPinned 推送、首页 pinned 在前、getPinnedSavedDialogs、 +// exclude_pinned 口径、reorder 推 updatePinnedSavedDialogs。 +func TestMessagesSavedDialogPinLifecycle(t *testing.T) { + ctx := context.Background() + r, _, alice, bob, noteID, _ := savedDialogsFixture(t) + + pinSelfReq := &tg.MessagesToggleSavedDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerSelf{}}} + pinSelfReq.SetPinned(true) + ok, err := r.onMessagesToggleSavedDialogPin(WithUserID(ctx, alice.ID), pinSelfReq) + if err != nil || !ok { + t.Fatalf("pin self sublist = %v err %v", ok, err) + } + + res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 20}) + if err != nil { + t.Fatalf("getSavedDialogs: %v", err) + } + dialogs, _, _, _, _ := savedDialogPage(t, res) + if len(dialogs) != 2 || savedDialogPeerID(t, dialogs[0]) != alice.ID || !dialogs[0].Pinned { + t.Fatalf("pinned self sublist not first: %+v", dialogs) + } + if dialogs[1].Pinned { + t.Fatalf("bob sublist unexpectedly pinned") + } + + pinnedRes, err := r.onMessagesGetPinnedSavedDialogs(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("getPinnedSavedDialogs: %v", err) + } + dialogs, _, _, _, full := savedDialogPage(t, pinnedRes) + if !full || len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != alice.ID || dialogs[0].TopMessage != noteID { + t.Fatalf("pinned list = %+v, want self note", dialogs) + } + + excluded, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{ + ExcludePinned: true, + Limit: 20, + }) + if err != nil { + t.Fatalf("getSavedDialogs exclude: %v", err) + } + dialogs, _, _, count, full := savedDialogPage(t, excluded) + if !full || len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != bob.ID { + t.Fatalf("exclude pinned = %+v, want only bob", dialogs) + } + if count != 1 { + t.Fatalf("exclude pinned count = %d, want 1", count) + } + + pinBobReq := &tg.MessagesToggleSavedDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}}} + pinBobReq.SetPinned(true) + ok, err = r.onMessagesToggleSavedDialogPin(WithUserID(ctx, alice.ID), pinBobReq) + if err != nil || !ok { + t.Fatalf("pin bob sublist = %v err %v", ok, err) + } + // 新置顶插到最前。 + pinnedRes, err = r.onMessagesGetPinnedSavedDialogs(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("getPinnedSavedDialogs: %v", err) + } + dialogs, _, _, _, _ = savedDialogPage(t, pinnedRes) + if len(dialogs) != 2 || savedDialogPeerID(t, dialogs[0]) != bob.ID { + t.Fatalf("pinned order after pin bob = %+v, want bob first", dialogs) + } + + ok, err = r.onMessagesReorderPinnedSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesReorderPinnedSavedDialogsRequest{ + Order: []tg.InputDialogPeerClass{ + &tg.InputDialogPeer{Peer: &tg.InputPeerSelf{}}, + &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}}, + }, + }) + if err != nil || !ok { + t.Fatalf("reorder = %v err %v", ok, err) + } + pinnedRes, err = r.onMessagesGetPinnedSavedDialogs(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("getPinnedSavedDialogs after reorder: %v", err) + } + dialogs, _, _, _, _ = savedDialogPage(t, pinnedRes) + if len(dialogs) != 2 || savedDialogPeerID(t, dialogs[0]) != alice.ID || savedDialogPeerID(t, dialogs[1]) != bob.ID { + t.Fatalf("reorder result = %+v, want self then bob", dialogs) + } + + ok, err = r.onMessagesToggleSavedDialogPin(WithUserID(ctx, alice.ID), &tg.MessagesToggleSavedDialogPinRequest{ + Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerSelf{}}, + }) + if err != nil || !ok { + t.Fatalf("unpin self = %v err %v", ok, err) + } + pinnedRes, err = r.onMessagesGetPinnedSavedDialogs(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("getPinnedSavedDialogs after unpin: %v", err) + } + dialogs, _, _, _, _ = savedDialogPage(t, pinnedRes) + if len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != bob.ID { + t.Fatalf("after unpin = %+v, want only bob", dialogs) + } +} + +// TestMessagesGetSavedDialogsByID 验证指定查询:命中返回、未知 peer 静默缺席。 +func TestMessagesGetSavedDialogsByID(t *testing.T) { + ctx := context.Background() + r, _, alice, bob, _, fwdID := savedDialogsFixture(t) + + res, err := r.onMessagesGetSavedDialogsByID(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsByIDRequest{ + IDs: []tg.InputPeerClass{ + &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + &tg.InputPeerUser{UserID: 999999, AccessHash: 1}, + }, + }) + if err != nil { + t.Fatalf("getSavedDialogsByID: %v", err) + } + dialogs, _, _, _, full := savedDialogPage(t, res) + if !full || len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != bob.ID || dialogs[0].TopMessage != fwdID { + t.Fatalf("byID = %+v, want bob sublist only", dialogs) + } +} + +// TestMessagesGetSavedHistoryFiltersBySavedPeer 验证子会话历史过滤、 +// count 与 hash notModified。 +func TestMessagesGetSavedHistoryFiltersBySavedPeer(t *testing.T) { + ctx := context.Background() + r, _, alice, bob, noteID, fwdID := savedDialogsFixture(t) + + bobHistory, err := r.onMessagesGetSavedHistory(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + Limit: 20, + }) + if err != nil { + t.Fatalf("getSavedHistory bob: %v", err) + } + bobMsgs := savedHistoryMessages(t, bobHistory) + if len(bobMsgs) != 1 || bobMsgs[0].ID != fwdID { + t.Fatalf("bob saved history = %+v, want forwarded message only", bobMsgs) + } + + selfHistory, err := r.onMessagesGetSavedHistory(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedHistoryRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: 20, + }) + if err != nil { + t.Fatalf("getSavedHistory self: %v", err) + } + selfMsgs := savedHistoryMessages(t, selfHistory) + if len(selfMsgs) != 1 || selfMsgs[0].ID != noteID { + t.Fatalf("self saved history = %+v, want note only", selfMsgs) + } + + // max_id 边界:低于转发 id 时空。 + empty, err := r.onMessagesGetSavedHistory(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + MaxID: fwdID, + Limit: 20, + }) + if err != nil { + t.Fatalf("getSavedHistory max_id: %v", err) + } + if msgs := savedHistoryMessages(t, empty); len(msgs) != 0 { + t.Fatalf("max_id bounded history = %+v, want empty", msgs) + } +} + +func savedHistoryMessages(t *testing.T, res tg.MessagesMessagesClass) []*tg.Message { + t.Helper() + var raw []tg.MessageClass + switch v := res.(type) { + case *tg.MessagesMessages: + raw = v.Messages + case *tg.MessagesMessagesSlice: + raw = v.Messages + default: + t.Fatalf("saved history = %T", res) + } + out := make([]*tg.Message, 0, len(raw)) + for _, m := range raw { + msg, ok := m.(*tg.Message) + if !ok { + t.Fatalf("message = %T", m) + } + out = append(out, msg) + } + return out +} + +// TestMessagesDeleteSavedHistoryRemovesSublist 验证删除子会话:带 pts 的 +// affectedHistory、子会话从列表消失、置顶清理、self-chat 历史同步消失。 +func TestMessagesDeleteSavedHistoryRemovesSublist(t *testing.T) { + ctx := context.Background() + r, messageStore, alice, bob, noteID, fwdID := savedDialogsFixture(t) + + pinBobReq := &tg.MessagesToggleSavedDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}}} + pinBobReq.SetPinned(true) + if _, err := r.onMessagesToggleSavedDialogPin(WithUserID(ctx, alice.ID), pinBobReq); err != nil { + t.Fatalf("pin bob: %v", err) + } + + affected, err := r.onMessagesDeleteSavedHistory(WithUserID(ctx, alice.ID), &tg.MessagesDeleteSavedHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, + }) + if err != nil { + t.Fatalf("deleteSavedHistory: %v", err) + } + if affected.Pts == 0 || affected.PtsCount != 1 || affected.Offset != 0 { + t.Fatalf("affected = %+v, want pts advance for 1 message, no continuation", affected) + } + + res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 20}) + if err != nil { + t.Fatalf("getSavedDialogs after delete: %v", err) + } + dialogs, _, _, _, _ := savedDialogPage(t, res) + if len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != alice.ID { + t.Fatalf("dialogs after delete = %+v, want only self note", dialogs) + } + + pinnedRes, err := r.onMessagesGetPinnedSavedDialogs(WithUserID(ctx, alice.ID)) + if err != nil { + t.Fatalf("getPinnedSavedDialogs after delete: %v", err) + } + dialogs, _, _, _, _ = savedDialogPage(t, pinnedRes) + if len(dialogs) != 0 { + t.Fatalf("pinned after delete = %+v, want empty (pin cleared)", dialogs) + } + + list, err := messageStore.ListByUser(ctx, alice.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, + Limit: 20, + }) + if err != nil { + t.Fatalf("self-chat history: %v", err) + } + if len(list.Messages) != 1 || list.Messages[0].ID != noteID { + t.Fatalf("self-chat history after delete = %+v, want only note (fwd %d gone)", list.Messages, fwdID) + } +} + +// TestModernForwardMessagesConstructorSavesToSelf 复现 Android「保存到收藏夹」 +// 路径:DrKLO 12.7.x 发晚于 Layer 225 的 messages.forwardMessages#41d41ade, +// 服务端 500 时 tgnet 无限重试(UI 永久转圈)。compat 解码后必须等价于 +// #13704a7c:转发成功、收藏夹出现来源子会话。 +func TestModernForwardMessagesConstructorSavesToSelf(t *testing.T) { + ctx := context.Background() + r, messageStore, userStore := newSavedDialogsTestRouter(t) + alice, _ := userStore.Create(ctx, domain.User{AccessHash: 83, Phone: "15550009401", FirstName: "Alice"}) + bob, _ := userStore.Create(ctx, domain.User{AccessHash: 84, Phone: "15550009402", FirstName: "Bob"}) + + if _, err := r.onMessagesSendMessage(WithUserID(ctx, bob.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, + Message: "save me", + RandomID: 94001, + }); err != nil { + t.Fatalf("bob send: %v", err) + } + bobMsgID := privateTopMessageID(t, messageStore, alice.ID, bob.ID) + + // 按 DrKLO TL_messages_forwardMessages.serializeToStream 的线格式手工编码: + // constructor + flags + from_peer + Vector id + Vector random_id + to_peer。 + var raw bin.Buffer + raw.PutID(0x41d41ade) + raw.PutInt(0) // flags:保存到收藏夹不带任何可选字段 + if err := (&tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}).Encode(&raw); err != nil { + t.Fatalf("encode from_peer: %v", err) + } + raw.PutVectorHeader(1) + raw.PutInt(bobMsgID) + raw.PutVectorHeader(1) + raw.PutLong(94002) + if err := (&tg.InputPeerSelf{}).Encode(&raw); err != nil { + t.Fatalf("encode to_peer: %v", err) + } + + enc, err := r.Dispatch(WithUserID(androidClientContext(), alice.ID), [8]byte{}, 0, &raw) + if err != nil { + t.Fatalf("dispatch messages.forwardMessages#41d41ade: %v", err) + } + // Now routed through the unified layerwire client-alias (4-byte id swap) and + // the normal gotd dispatcher, which boxes a class result as *tg.UpdatesBox + // (wire-identical to the raw UpdatesClass the dedicated handler used to return). + switch v := enc.(type) { + case tg.UpdatesClass: + case *tg.UpdatesBox: + if v.Updates == nil { + t.Fatalf("forward result box has nil Updates") + } + default: + t.Fatalf("forward result = %T, want UpdatesClass or *tg.UpdatesBox", enc) + } + + res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: 20}) + if err != nil { + t.Fatalf("getSavedDialogs after android forward: %v", err) + } + dialogs, messages, _, _, _ := savedDialogPage(t, res) + if len(dialogs) != 1 || savedDialogPeerID(t, dialogs[0]) != bob.ID { + t.Fatalf("saved dialogs after android forward = %+v, want bob sublist", dialogs) + } + msg, ok := messages[0].(*tg.Message) + if !ok { + t.Fatalf("top message = %T", messages[0]) + } + if saved, ok := msg.GetSavedPeerID(); !ok { + t.Fatalf("forwarded message lacks saved_peer_id") + } else if user, ok := saved.(*tg.PeerUser); !ok || user.UserID != bob.ID { + t.Fatalf("saved_peer_id = %+v, want bob", saved) + } +} + +// TestSavedPeerForSelfChat 验证子会话分组键计算与 TDLib 语义对齐。 +func TestSavedPeerForSelfChat(t *testing.T) { + self := int64(1000000001) + bobPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + cases := []struct { + name string + forward *domain.MessageForward + want domain.Peer + }{ + {"direct note", nil, domain.Peer{Type: domain.PeerTypeUser, ID: self}}, + {"forward with source", &domain.MessageForward{From: bobPeer, SavedFrom: bobPeer, SavedFromMsgID: 7}, bobPeer}, + {"forward channel source", &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: 500}, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: 500}}, domain.Peer{Type: domain.PeerTypeChannel, ID: 500}}, + {"hidden author without source", &domain.MessageForward{FromName: "Hidden"}, domain.Peer{Type: domain.PeerTypeUser, ID: domain.SavedHiddenAuthorUserID}}, + {"visible author without source", &domain.MessageForward{From: bobPeer}, domain.Peer{Type: domain.PeerTypeUser, ID: self}}, + } + for _, tc := range cases { + if got := domain.SavedPeerForSelfChat(self, tc.forward); got != tc.want { + t.Fatalf("%s: SavedPeerForSelfChat = %+v, want %+v", tc.name, got, tc.want) + } + } +} diff --git a/internal/rpc/messages_scheduled.go b/internal/rpc/messages_scheduled.go new file mode 100644 index 00000000..0b580b13 --- /dev/null +++ b/internal/rpc/messages_scheduled.go @@ -0,0 +1,458 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func (r *Router) onMessagesGetScheduledMessages(ctx context.Context, req *tg.MessagesGetScheduledMessagesRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateMessageIDVector(req.ID); err != nil { + return nil, err + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService) + if r.deps.Messages == nil || !ok { + return &tg.MessagesMessages{Chats: r.chatsForMessageUpdate(ctx, userID, domain.Message{Peer: peer})}, nil + } + list, err := scheduledSvc.GetScheduledMessages(ctx, userID, domain.ScheduledMessageFilter{ + OwnerUserID: userID, + Peer: peer, + IDs: append([]int(nil), req.ID...), + Limit: len(req.ID), + }) + if err != nil { + return nil, messageIDInvalidErr() + } + return r.tgScheduledMessages(ctx, userID, peer, list), nil +} + +func (r *Router) onMessagesGetScheduledHistory(ctx context.Context, req *tg.MessagesGetScheduledHistoryRequest) (tg.MessagesMessagesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService) + if r.deps.Messages == nil || !ok { + if req.Hash != 0 { + return &tg.MessagesMessagesNotModified{Count: 0}, nil + } + return &tg.MessagesMessages{Chats: r.chatsForMessageUpdate(ctx, userID, domain.Message{Peer: peer})}, nil + } + list, err := scheduledSvc.ListScheduledMessages(ctx, userID, domain.ScheduledMessageFilter{ + OwnerUserID: userID, + Peer: peer, + Limit: maxScheduledMessagePage, + Hash: req.Hash, + }) + if err != nil { + return nil, peerIDInvalidErr() + } + if req.Hash != 0 && list.Hash == req.Hash && len(list.Messages) == 0 { + return &tg.MessagesMessagesNotModified{Count: 0}, nil + } + return r.tgScheduledMessages(ctx, userID, peer, list), nil +} + +func (r *Router) onMessagesSendScheduledMessages(ctx context.Context, req *tg.MessagesSendScheduledMessagesRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateMessageIDVector(req.ID); err != nil { + return nil, err + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService) + if len(req.ID) == 0 { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + if r.deps.Messages == nil || !ok { + return nil, messageIDInvalidErr() + } + now := int(r.clock.Now().Unix()) + claimed, err := scheduledSvc.ClaimScheduledMessages(ctx, userID, domain.ScheduledMessageClaim{ + OwnerUserID: userID, + Peer: peer, + IDs: append([]int(nil), req.ID...), + Now: now, + LeaseUntil: now + 60, + }) + if err != nil { + return nil, messageIDInvalidErr() + } + return r.sendClaimedScheduledMessages(ctx, userID, peer, claimed, now) +} + +func (r *Router) onMessagesDeleteScheduledMessages(ctx context.Context, req *tg.MessagesDeleteScheduledMessagesRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if err := validateMessageIDVector(req.ID); err != nil { + return nil, err + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService) + if r.deps.Messages == nil || !ok { + date := int(r.clock.Now().Unix()) + return tgDeleteScheduledUpdates(peer, append([]int(nil), req.ID...), nil, r.chatsForMessageUpdate(ctx, userID, domain.Message{Peer: peer}), date), nil + } + date := int(r.clock.Now().Unix()) + deleted, err := scheduledSvc.DeleteScheduledMessages(ctx, userID, domain.ScheduledMessageFilter{ + OwnerUserID: userID, + Peer: peer, + IDs: append([]int(nil), req.ID...), + }, date) + if err != nil { + return nil, messageIDInvalidErr() + } + ids := make([]int, 0, len(deleted)) + for _, msg := range deleted { + ids = append(ids, msg.ID) + } + if len(ids) == 0 { + return tgEmptyUpdates(date), nil + } + updates := tgDeleteScheduledUpdates(peer, ids, nil, r.chatsForMessageUpdates(ctx, userID, scheduledMessagesAsDomainMessages(deleted, userID)), date) + r.pushUserUpdates(ctx, userID, updates) + return updates, nil +} + +func tgDeleteScheduledUpdates(peer domain.Peer, ids []int, sentIDs []int, chats []tg.ChatClass, date int) *tg.Updates { + update := &tg.UpdateDeleteScheduledMessages{ + Peer: tgPeer(peer), + Messages: append([]int(nil), ids...), + } + if len(sentIDs) > 0 { + update.SetSentMessages(append([]int(nil), sentIDs...)) + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: []tg.UserClass{}, + Chats: chats, + Date: date, + Seq: 0, + } +} + +func scheduleDateIsImmediate(scheduleDate, now int) bool { + return scheduleDate <= 0 || scheduleDate <= now+20 +} + +func (r *Router) scheduleOutgoing(ctx context.Context, userID int64, peer domain.Peer, p outgoingSend, scheduleDate, repeatPeriod int) (tg.UpdatesClass, error) { + scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService) + if r.deps.Messages == nil || !ok { + return nil, peerIDInvalidErr() + } + if repeatPeriod != 0 { + return nil, scheduleDateInvalidErr() + } + if peer.Type == domain.PeerTypeUser && p.randomID == 0 { + // 私聊发送要求非零 random_id(幂等键)。random_id==0 的定时私聊消息到点 + // 投递必然在 SendPrivateText 失败并陷入重试,故在排程入口即拒绝。 + return nil, randomIDEmptyErr() + } + if peer.Type == domain.PeerTypeUser && r.deps.Users != nil && peer.ID != userID { + if _, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err != nil { + return nil, internalErr() + } else if !found { + return nil, peerIDInvalidErr() + } + } + replyTo, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput) + if err != nil { + return nil, err + } + sendAs, err := r.resolveSendAsPeer(ctx, userID, peer, p.sendAsInput) + if err != nil { + return nil, err + } + date := int(r.clock.Now().Unix()) + msg, err := scheduledSvc.ScheduleMessage(ctx, userID, domain.ScheduleMessageRequest{ + OwnerUserID: userID, + Peer: peer, + RandomID: p.randomID, + Message: p.message, + Entities: domainMessageEntitiesForViewer(userID, p.entities), + Media: p.media, + Silent: p.silent, + NoForwards: p.noforwards, + ReplyTo: replyTo, + SendAs: sendAs, + ScheduleDate: scheduleDate, + ScheduleRepeatPeriod: repeatPeriod, + Date: date, + }) + if err != nil { + return nil, messageSendErr(err) + } + if p.clearDraft { + r.clearDraftAfterSend(ctx, userID, peer, replyTo) + } + updates := r.tgNewScheduledMessageUpdates(ctx, userID, msg, p.randomID, date) + r.pushUserUpdates(ctx, userID, updates) + return updates, nil +} + +func (r *Router) sendClaimedScheduledMessages(ctx context.Context, userID int64, peer domain.Peer, claimed []domain.ScheduledMessage, date int) (tg.UpdatesClass, error) { + if len(claimed) == 0 { + return tgEmptyUpdates(date), nil + } + combined := &tg.Updates{ + Updates: make([]tg.UpdateClass, 0, len(claimed)*3), + Users: []tg.UserClass{}, + Chats: r.chatsForMessageUpdates(ctx, userID, scheduledMessagesAsDomainMessages(claimed, userID)), + Date: date, + Seq: 0, + } + deletedIDs := make([]int, 0, len(claimed)) + sentIDs := make([]int, 0, len(claimed)) + for _, scheduled := range claimed { + updates, _, err := r.sendOutgoing(ctx, userID, scheduled.Peer, outgoingSend{ + randomID: scheduled.RandomID, + message: scheduled.Message, + entities: tgInputMessageEntities(scheduled.Entities), + media: scheduled.Media, + silent: scheduled.Silent, + noforwards: scheduled.NoForwards, + }) + if err != nil { + if scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService); ok { + _ = scheduledSvc.ReleaseScheduledMessage(ctx, scheduled.OwnerUserID, scheduled.ID, err.Error()) + } + return nil, err + } + sentID := sentMessageIDFromUpdates(updates) + scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService) + if !ok { + return nil, internalErr() + } + if err := scheduledSvc.MarkScheduledMessageSent(ctx, scheduled.OwnerUserID, scheduled.ID, sentID, date); err != nil { + return nil, internalErr() + } + deletedIDs = append(deletedIDs, scheduled.ID) + sentIDs = append(sentIDs, sentID) + if up, ok := updates.(*tg.Updates); ok { + combined.Updates = append(combined.Updates, up.Updates...) + combined.Users = mergeTGUsers(combined.Users, up.Users) + combined.Chats = mergeTGChats(combined.Chats, up.Chats) + if up.Date > combined.Date { + combined.Date = up.Date + } + } + } + deleteUpdate := tgDeleteScheduledUpdates(peer, deletedIDs, sentIDs, r.chatsForMessageUpdates(ctx, userID, scheduledMessagesAsDomainMessages(claimed, userID)), date) + combined.Updates = append(combined.Updates, deleteUpdate.Updates...) + combined.Chats = mergeTGChats(combined.Chats, deleteUpdate.Chats) + r.pushUserUpdates(ctx, userID, deleteUpdate) + return combined, nil +} + +func (r *Router) tgScheduledMessages(ctx context.Context, userID int64, peer domain.Peer, list domain.ScheduledMessageList) tg.MessagesMessagesClass { + messages := scheduledMessagesAsDomainMessages(list.Messages, userID) + out := make([]tg.MessageClass, 0, len(messages)) + for _, msg := range messages { + item := tgMessage(msg) + if scheduled, ok := item.(*tg.Message); ok { + scheduled.SetFromScheduled(true) + } + if item != nil { + out = append(out, item) + } + } + chats := r.chatsForMessageUpdates(ctx, userID, messages) + if len(chats) == 0 && peer.Type == domain.PeerTypeChannel { + chats = r.chatsForMessageUpdate(ctx, userID, domain.Message{Peer: peer}) + } + return &tg.MessagesMessages{ + Messages: out, + Chats: chats, + Users: r.usersForMessageUpdates(ctx, userID, messages), + } +} + +func (r *Router) tgNewScheduledMessageUpdates(ctx context.Context, userID int64, msg domain.ScheduledMessage, randomID int64, date int) *tg.Updates { + domainMsg := scheduledMessageAsDomainMessage(msg, userID) + item := tgMessage(domainMsg) + if scheduled, ok := item.(*tg.Message); ok { + scheduled.SetFromScheduled(true) + } + updates := make([]tg.UpdateClass, 0, 2) + if randomID != 0 { + updates = append(updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: randomID}) + } + if item == nil { + item = &tg.MessageEmpty{ID: msg.ID} + } + updates = append(updates, &tg.UpdateNewScheduledMessage{Message: item}) + return &tg.Updates{ + Updates: updates, + Users: r.usersForMessageUpdate(ctx, userID, domainMsg), + Chats: r.chatsForMessageUpdate(ctx, userID, domainMsg), + Date: date, + Seq: 0, + } +} + +func scheduledMessagesAsDomainMessages(messages []domain.ScheduledMessage, viewerUserID int64) []domain.Message { + out := make([]domain.Message, 0, len(messages)) + for _, msg := range messages { + out = append(out, scheduledMessageAsDomainMessage(msg, viewerUserID)) + } + return out +} + +func scheduledMessageAsDomainMessage(msg domain.ScheduledMessage, viewerUserID int64) domain.Message { + from := domain.Peer{Type: domain.PeerTypeUser, ID: msg.OwnerUserID} + if msg.SendAs != nil && msg.SendAs.ID != 0 { + from = *msg.SendAs + } + return domain.Message{ + ID: msg.ID, + RandomID: msg.RandomID, + OwnerUserID: msg.OwnerUserID, + Peer: msg.Peer, + From: from, + Date: msg.ScheduleDate, + Out: msg.OwnerUserID == viewerUserID, + Silent: msg.Silent, + NoForwards: msg.NoForwards, + Body: msg.Message, + Entities: append([]domain.MessageEntity(nil), msg.Entities...), + ReplyTo: msg.ReplyTo, + Forward: msg.Forward, + Media: msg.Media, + } +} + +func sentMessageIDFromUpdates(updates tg.UpdatesClass) int { + up, ok := updates.(*tg.Updates) + if !ok { + return 0 + } + for _, update := range up.Updates { + switch v := update.(type) { + case *tg.UpdateMessageID: + return v.ID + case *tg.UpdateNewMessage: + if msg, ok := v.Message.(*tg.Message); ok { + return msg.ID + } + case *tg.UpdateNewChannelMessage: + if msg, ok := v.Message.(*tg.Message); ok { + return msg.ID + } + } + } + return 0 +} + +func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, fromPeer, toPeer domain.Peer, req *tg.MessagesForwardMessagesRequest, replyTo *domain.MessageReply, sendAs *domain.Peer) (tg.UpdatesClass, error) { + scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService) + if r.deps.Messages == nil || !ok { + return nil, peerIDInvalidErr() + } + sources, err := r.forwardSources(ctx, userID, fromPeer, req.ID) + if err != nil { + return nil, messageForwardErr(err) + } + date := int(r.clock.Now().Unix()) + updates := &tg.Updates{ + Updates: make([]tg.UpdateClass, 0, len(sources)*2), + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: date, + Seq: 0, + } + domainMessages := make([]domain.Message, 0, len(sources)) + for i, source := range sources { + forward := source.forward + if req.DropAuthor { + forward = nil + } + msg, err := scheduledSvc.ScheduleMessage(ctx, userID, domain.ScheduleMessageRequest{ + OwnerUserID: userID, + Peer: toPeer, + RandomID: req.RandomID[i], + Message: source.body, + Entities: append([]domain.MessageEntity(nil), source.entities...), + Media: source.media, + Silent: req.Silent, + NoForwards: req.Noforwards, + ReplyTo: replyTo, + Forward: forward, + SendAs: sendAs, + ScheduleDate: req.ScheduleDate, + ScheduleRepeatPeriod: req.ScheduleRepeatPeriod, + Date: date, + }) + if err != nil { + return nil, messageForwardErr(err) + } + domainMsg := scheduledMessageAsDomainMessage(msg, userID) + domainMessages = append(domainMessages, domainMsg) + if req.RandomID[i] != 0 { + updates.Updates = append(updates.Updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: req.RandomID[i]}) + } + item := tgMessage(domainMsg) + if scheduled, ok := item.(*tg.Message); ok { + scheduled.SetFromScheduled(true) + } + if item == nil { + item = &tg.MessageEmpty{ID: msg.ID} + } + updates.Updates = append(updates.Updates, &tg.UpdateNewScheduledMessage{Message: item}) + } + updates.Users = r.usersForMessageUpdates(ctx, userID, domainMessages) + updates.Chats = r.chatsForMessageUpdates(ctx, userID, domainMessages) + r.pushUserUpdates(ctx, userID, updates) + return updates, nil +} + +func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer domain.Peer, id int, message string, setMessage bool, entities []tg.MessageEntityClass, scheduleDate int) (tg.UpdatesClass, error) { + if r.deps.Messages == nil { + return nil, messageIDInvalidErr() + } + scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService) + if !ok { + return nil, messageIDInvalidErr() + } + now := int(r.clock.Now().Unix()) + if scheduleDateIsImmediate(scheduleDate, now) { + return nil, scheduleDateInvalidErr() + } + msg, err := scheduledSvc.EditScheduledMessage(ctx, userID, domain.EditScheduledMessageRequest{ + OwnerUserID: userID, + Peer: peer, + ID: id, + SetMessage: setMessage, + Message: message, + Entities: domainMessageEntitiesForViewer(userID, entities), + ScheduleDate: scheduleDate, + Date: now, + }) + if err != nil { + return nil, messageEditErr(err) + } + updates := r.tgNewScheduledMessageUpdates(ctx, userID, msg, 0, now) + r.pushUserUpdates(ctx, userID, updates) + return updates, nil +} diff --git a/internal/rpc/messages_scheduled_ttl_test.go b/internal/rpc/messages_scheduled_ttl_test.go new file mode 100644 index 00000000..218b108a --- /dev/null +++ b/internal/rpc/messages_scheduled_ttl_test.go @@ -0,0 +1,232 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "telesrv/internal/domain" + "testing" + "time" +) + +func TestScheduledMessagesLifecycleUsesScheduledStore(t *testing.T) { + const ( + aliceID = int64(1000000001) + bobID = int64(1000000002) + now = int64(1700001000) + ) + alice := domain.User{ID: aliceID, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: bobID, AccessHash: 22, FirstName: "Bob"} + messages := &scheduledCaptureMessages{captureMessages: &captureMessages{}} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{aliceID: alice, bobID: bob}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + ctx := WithUserID(context.Background(), aliceID) + + sendReq := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bobID, AccessHash: bob.AccessHash}, + Message: "later", + RandomID: 123456, + } + sendReq.SetScheduleDate(int(now + 3600)) + updates, err := r.onMessagesSendMessage(ctx, sendReq) + if err != nil { + t.Fatalf("send scheduled message: %v", err) + } + if messages.scheduleReq.OwnerUserID != aliceID || messages.scheduleReq.Peer.ID != bobID || messages.scheduleReq.ScheduleDate != int(now+3600) { + t.Fatalf("schedule req = %+v, want alice->bob at requested date", messages.scheduleReq) + } + gotUpdates := updates.(*tg.Updates).Updates + if len(gotUpdates) != 2 { + t.Fatalf("scheduled send updates = %+v, want id + new scheduled", gotUpdates) + } + newScheduled, ok := gotUpdates[1].(*tg.UpdateNewScheduledMessage) + if !ok { + t.Fatalf("scheduled update = %T, want UpdateNewScheduledMessage", gotUpdates[1]) + } + scheduledMsg, ok := newScheduled.Message.(*tg.Message) + if !ok || !scheduledMsg.FromScheduled || scheduledMsg.Message != "later" || scheduledMsg.Date != int(now+3600) { + t.Fatalf("scheduled message = %#v, want from_scheduled later at schedule date", newScheduled.Message) + } + + history, err := r.onMessagesGetScheduledHistory(ctx, &tg.MessagesGetScheduledHistoryRequest{ + Peer: &tg.InputPeerUser{UserID: bobID, AccessHash: bob.AccessHash}, + }) + if err != nil { + t.Fatalf("get scheduled history: %v", err) + } + if list := history.(*tg.MessagesMessages).Messages; len(list) != 1 { + t.Fatalf("scheduled history = %+v, want one message", list) + } + + editReq := &tg.MessagesEditMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bobID, AccessHash: bob.AccessHash}, + ID: scheduledMsg.ID, + } + editReq.SetMessage("edited") + editReq.SetScheduleDate(int(now + 7200)) + edited, err := r.onMessagesEditMessage(ctx, editReq) + if err != nil { + t.Fatalf("edit scheduled message: %v", err) + } + if messages.editScheduledReq.ID != scheduledMsg.ID || !messages.editScheduledReq.SetMessage || messages.editScheduledReq.Message != "edited" || messages.editScheduledReq.ScheduleDate != int(now+7200) { + t.Fatalf("edit scheduled req = %+v, want updated scheduled text/date", messages.editScheduledReq) + } + editedMessage := edited.(*tg.Updates).Updates[0].(*tg.UpdateNewScheduledMessage).Message.(*tg.Message) + if !editedMessage.FromScheduled || editedMessage.Message != "edited" || editedMessage.Date != int(now+7200) { + t.Fatalf("edited scheduled message = %#v, want edited future scheduled item", editedMessage) + } + + sent, err := r.onMessagesSendScheduledMessages(ctx, &tg.MessagesSendScheduledMessagesRequest{ + Peer: &tg.InputPeerUser{UserID: bobID, AccessHash: bob.AccessHash}, + ID: []int{scheduledMsg.ID}, + }) + if err != nil { + t.Fatalf("send scheduled now: %v", err) + } + if messages.sendReq.Message != "edited" || messages.markedScheduledID != scheduledMsg.ID || messages.markedSentID == 0 { + t.Fatalf("sent scheduled state send=%+v marked scheduled=%d sent=%d", messages.sendReq, messages.markedScheduledID, messages.markedSentID) + } + var deleteUpdate *tg.UpdateDeleteScheduledMessages + for _, update := range sent.(*tg.Updates).Updates { + if v, ok := update.(*tg.UpdateDeleteScheduledMessages); ok { + deleteUpdate = v + } + } + if deleteUpdate == nil || len(deleteUpdate.Messages) != 1 || deleteUpdate.Messages[0] != scheduledMsg.ID { + t.Fatalf("send scheduled delete update = %#v, want deleted scheduled id", deleteUpdate) + } + if sentIDs, ok := deleteUpdate.GetSentMessages(); !ok || len(sentIDs) != 1 || sentIDs[0] != messages.markedSentID { + t.Fatalf("delete scheduled sent ids = %+v ok %v, want sent message id %d", sentIDs, ok, messages.markedSentID) + } +} + +func TestScheduledMessageEditDateOnlyPreservesContent(t *testing.T) { + const ( + aliceID = int64(1000000001) + bobID = int64(1000000002) + now = int64(1700003000) + ) + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindDocument, + Document: &domain.Document{ID: 910000000000000001, AccessHash: 91, DCID: 2}, + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: bobID} + messages := &scheduledCaptureMessages{ + captureMessages: &captureMessages{}, + scheduled: []domain.ScheduledMessage{{ + OwnerUserID: aliceID, + ID: 77, + Peer: peer, + Message: "", + Media: media, + ScheduleDate: int(now + 3600), + State: "pending", + }}, + } + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + ctx := WithUserID(context.Background(), aliceID) + + editReq := &tg.MessagesEditMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bobID, AccessHash: 22}, + ID: 77, + } + editReq.SetScheduleDate(int(now + 7200)) + updates, err := r.onMessagesEditMessage(ctx, editReq) + if err != nil { + t.Fatalf("date-only scheduled edit: %v", err) + } + if messages.editScheduledReq.SetMessage { + t.Fatalf("edit scheduled req = %+v, want date-only edit without content overwrite", messages.editScheduledReq) + } + if messages.scheduled[0].Message != "" || messages.scheduled[0].Media != media || messages.scheduled[0].ScheduleDate != int(now+7200) { + t.Fatalf("scheduled item after date-only edit = %+v, want original content and new date", messages.scheduled[0]) + } + editedMessage := updates.(*tg.Updates).Updates[0].(*tg.UpdateNewScheduledMessage).Message.(*tg.Message) + if !editedMessage.FromScheduled || editedMessage.Date != int(now+7200) { + t.Fatalf("date-only edit update = %#v, want scheduled message at new date", editedMessage) + } +} + +func TestScheduledMessageEditAllowsEmptyCaptionForMedia(t *testing.T) { + const ( + aliceID = int64(1000000001) + bobID = int64(1000000002) + now = int64(1700004000) + ) + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindDocument, + Document: &domain.Document{ID: 910000000000000002, AccessHash: 92, DCID: 2}, + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: bobID} + messages := &scheduledCaptureMessages{ + captureMessages: &captureMessages{}, + scheduled: []domain.ScheduledMessage{{ + OwnerUserID: aliceID, + ID: 78, + Peer: peer, + Message: "caption", + Media: media, + ScheduleDate: int(now + 3600), + State: "pending", + }}, + } + r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + ctx := WithUserID(context.Background(), aliceID) + + editReq := &tg.MessagesEditMessageRequest{ + Peer: &tg.InputPeerUser{UserID: bobID, AccessHash: 22}, + ID: 78, + } + editReq.SetMessage("") + editReq.SetScheduleDate(int(now + 7200)) + if _, err := r.onMessagesEditMessage(ctx, editReq); err != nil { + t.Fatalf("empty-caption scheduled edit: %v", err) + } + if !messages.editScheduledReq.SetMessage || messages.editScheduledReq.Message != "" { + t.Fatalf("edit scheduled req = %+v, want explicit empty caption update", messages.editScheduledReq) + } + if messages.scheduled[0].Message != "" || messages.scheduled[0].Media != media || messages.scheduled[0].ScheduleDate != int(now+7200) { + t.Fatalf("scheduled item after empty-caption edit = %+v, want media kept and caption cleared", messages.scheduled[0]) + } +} + +func TestMessagesSetHistoryTTLPushesPrivatePeerBothSides(t *testing.T) { + const ( + aliceID = int64(1000000001) + bobID = int64(1000000002) + now = int64(1700002000) + period = 86400 + ) + messages := &ttlCaptureMessages{captureMessages: &captureMessages{}} + sessions := &captureSessions{} + r := New(Config{}, Deps{Messages: messages, Sessions: sessions}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) + ctx := WithSessionID(WithUserID(context.Background(), aliceID), 77) + + out, err := r.onMessagesSetHistoryTTL(ctx, &tg.MessagesSetHistoryTTLRequest{ + Peer: &tg.InputPeerUser{UserID: bobID, AccessHash: 22}, + Period: period, + }) + if err != nil { + t.Fatalf("set private history ttl: %v", err) + } + if messages.setTTLUserID != aliceID || messages.setTTLPeer != (domain.Peer{Type: domain.PeerTypeUser, ID: bobID}) || messages.setTTLPeriod != period { + t.Fatalf("set ttl context user=%d peer=%+v period=%d", messages.setTTLUserID, messages.setTTLPeer, messages.setTTLPeriod) + } + selfUpdate := out.(*tg.Updates).Updates[0].(*tg.UpdatePeerHistoryTTL) + if peer, ok := selfUpdate.Peer.(*tg.PeerUser); !ok || peer.UserID != bobID { + t.Fatalf("self ttl peer = %#v, want bob", selfUpdate.Peer) + } + if ttl, ok := selfUpdate.GetTTLPeriod(); !ok || ttl != period { + t.Fatalf("self ttl period = %d ok %v, want %d", ttl, ok, period) + } + if got := sessions.pushedUserIDs(); len(got) != 2 || got[0] != aliceID || got[1] != bobID { + t.Fatalf("pushed users = %+v, want alice then bob", got) + } + peerUpdates := sessions.snapshot().message.(*tg.Updates) + peerUpdate := peerUpdates.Updates[0].(*tg.UpdatePeerHistoryTTL) + if peer, ok := peerUpdate.Peer.(*tg.PeerUser); !ok || peer.UserID != aliceID { + t.Fatalf("peer ttl update peer = %#v, want alice", peerUpdate.Peer) + } +} diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go new file mode 100644 index 00000000..e93de0c3 --- /dev/null +++ b/internal/rpc/messages_send.go @@ -0,0 +1,616 @@ +package rpc + +import ( + "context" + "errors" + "github.com/gotd/td/tg" + "strings" + "telesrv/internal/domain" + "unicode/utf8" +) + +func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSendMessageRequest) (tg.UpdatesClass, error) { + start := r.clock.Now() + var duplicate bool + var sendErr error + defer func() { + r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr) + }() + if req.Message == "" { + sendErr = messageEmptyErr() + return nil, messageEmptyErr() + } + if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { + sendErr = messageTooLongErr() + return nil, sendErr + } + if len(req.Entities) > maxMessageEntityCount { + sendErr = entitiesTooLongErr() + return nil, sendErr + } + if req.RandomID == 0 { + sendErr = randomIDEmptyErr() + return nil, sendErr + } + if req.QuickReplyShortcut != nil { + updates, err := r.onMessagesSaveQuickReplyText(ctx, req) + if err != nil { + sendErr = err + return nil, err + } + return updates, nil + } + if req.ScheduleRepeatPeriod != 0 { + sendErr = scheduleDateInvalidErr() + return nil, sendErr + } + if err := sendMessageUnsupportedOptionErr(req); err != nil { + sendErr = err + return nil, sendErr + } + // 消息特效:仅接受 catalog 内的合法 effect id(非法 → EFFECT_ID_INVALID,官方行为)。 + if r.messageEffectInvalid(ctx, req.Effect) { + sendErr = effectIDInvalidErr() + return nil, sendErr + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + sendErr = internalErr() + return nil, sendErr + } + if userID == 0 { + sendErr = peerIDInvalidErr() + return nil, sendErr + } + if err := r.checkSendRateLimit(ctx, userID, 1); err != nil { + sendErr = err + return nil, sendErr + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + sendErr = err + return nil, sendErr + } + // 频道私信(monoforum):仅当 reply_to 带 monoforum_peer_id 时走专用发送路径(普通发送恒不带, + // 故此 gate 对普通发送零额外成本)。peer 解析为 monoforum 频道时按订阅者子会话发送。 + if monoforumReplyPresent(req.ReplyTo) { + updates, err := r.sendMonoforumMessage(ctx, userID, peer, req) + if err != nil { + sendErr = err + return nil, sendErr + } + return updates, nil + } + // reply_markup(bot inline keyboard):仅 bot 账号发送被接受+校验;非 bot 静默丢弃。 + // 仅在请求携带 markup 时才查 is_bot,避免普通发送多打一次查询。 + var replyMarkup *domain.MessageReplyMarkup + if req.ReplyMarkup != nil { + replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID)) + if err != nil { + sendErr = replyMarkupErr(err) + return nil, sendErr + } + } + // rich_message(Layer 227 富文本):解析 blocks + 内嵌媒体快照;普通消息恒 nil。 + // Phase 1 仅认 inputRichMessage(blocks 形态),HTML/Markdown 变体返回错误。 + var richMessage *domain.MessageRichMessage + if req.RichMessage != nil { + richMessage, err = r.domainRichMessageFromInput(ctx, req.RichMessage) + if err != nil { + sendErr = err + return nil, sendErr + } + } + // 自动实体高亮:客户端未带 url/@mention/#hashtag/bot command 等「可自动识别」实体时,服务端 + // 检测原文补充(官方服务端行为),否则 @username/链接等不渲染为可点蓝色。富文本走独立结构,不处理。 + if richMessage == nil { + req.Entities = augmentAutoEntities(req.Message, req.Entities) + } + // 链接预览:纯文本消息(私聊或频道)含可预览 URL 且未抑制时,挂 pending 占位,异步解析回填。 + // 富文本消息有独立媒体语义,不叠加。 + var previewMedia *domain.MessageMedia + if richMessage == nil { + previewMedia = r.webPageMediaFromText(ctx, req.Message, req.Entities, req.NoWebpage, req.InvertMedia) + } + if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { + updates, err := r.scheduleOutgoing(ctx, userID, peer, outgoingSend{ + randomID: req.RandomID, + message: req.Message, + entities: req.Entities, + media: previewMedia, + silent: req.Silent, + noforwards: req.Noforwards, + replyToInput: req.ReplyTo, + sendAsInput: req.SendAs, + clearDraft: req.ClearDraft, + }, req.ScheduleDate, req.ScheduleRepeatPeriod) + if err != nil { + sendErr = err + return nil, err + } + return updates, nil + } + updates, dup, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{ + randomID: req.RandomID, + message: req.Message, + entities: req.Entities, + media: previewMedia, + silent: req.Silent, + noforwards: req.Noforwards, + replyToInput: req.ReplyTo, + sendAsInput: req.SendAs, + clearDraft: req.ClearDraft, + replyMarkup: replyMarkup, + richMessage: richMessage, + effect: req.Effect, + }) + duplicate = dup + if err != nil { + sendErr = err + return nil, sendErr + } + return updates, nil +} + +func messageSendErr(err error) error { + switch { + case errors.Is(err, domain.ErrUserSendRestricted): + return frozenMethodInvalidErr() + case errors.Is(err, domain.ErrReplyMessageIDInvalid): + return replyMessageIDInvalidErr() + default: + return internalErr() + } +} + +func (r *Router) peerBlocksUser(ctx context.Context, userID, peerUserID int64) (bool, error) { + if userID == 0 || peerUserID == 0 || userID == peerUserID || r.deps.Contacts == nil { + return false, nil + } + blocked, err := r.deps.Contacts.IsBlocked(ctx, peerUserID, userID) + if err != nil { + return false, internalErr() + } + return blocked, nil +} + +func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer domain.Peer, input tg.InputReplyToClass) (*domain.MessageReply, error) { + if input == nil { + return nil, nil + } + reply, ok := input.(*tg.InputReplyToMessage) + if !ok { + switch st := input.(type) { + case *tg.InputReplyToStory: + // story 回复(评论):客户端发一条带 reply_to=inputReplyToStory 的私聊消息。 + // 只支持回复会话对端(story 作者)的 story,投影为 messageReplyStoryHeader。 + if st.StoryID <= 0 || st.StoryID > domain.MaxStoryID { + return nil, storyIDInvalidErr() + } + storyOwner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, st.Peer) + if err != nil { + return nil, err + } + if storyOwner != peer { + return nil, storyIDInvalidErr() + } + return &domain.MessageReply{Peer: storyOwner, StoryID: st.StoryID}, nil + case *tg.InputReplyToMonoForum: + return nil, replyToMonoforumPeerInvalidErr() + default: + return nil, inputConstructorInvalidErr() + } + } + if _, ok := reply.GetMonoforumPeerID(); ok { + return nil, replyToMonoforumPeerInvalidErr() + } + if _, ok := reply.GetTodoItemID(); ok { + return nil, replyMessageIDInvalidErr() + } + if _, ok := reply.GetPollOption(); ok { + return nil, pollOptionInvalidErr() + } + replyPeer := peer + if inputPeer, ok := reply.GetReplyToPeerID(); ok { + parsed, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inputPeer) + if err != nil || parsed != peer { + return nil, replyMessageIDInvalidErr() + } + replyPeer = parsed + } + topMsgID, ok := reply.GetTopMsgID() + if ok && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { + return nil, replyMessageIDInvalidErr() + } + if reply.ReplyToMsgID < 0 || reply.ReplyToMsgID > domain.MaxMessageBoxID { + return nil, replyMessageIDInvalidErr() + } + if reply.ReplyToMsgID == 0 && topMsgID == 0 { + return nil, replyMessageIDInvalidErr() + } + quoteText, _ := reply.GetQuoteText() + if utf8.RuneCountInString(quoteText) > maxReplyQuoteLength { + return nil, limitInvalidErr() + } + quoteEntities, _ := reply.GetQuoteEntities() + if len(quoteEntities) > maxMessageEntityCount { + return nil, limitInvalidErr() + } + quoteOffset, ok := reply.GetQuoteOffset() + if ok && (quoteOffset < 0 || quoteOffset > domain.MaxMessageReplyQuoteOffset) { + return nil, replyMessageIDInvalidErr() + } + return &domain.MessageReply{ + MessageID: reply.ReplyToMsgID, + Peer: replyPeer, + TopMessageID: topMsgID, + QuoteText: quoteText, + QuoteEntities: domainMessageEntities(quoteEntities), + QuoteOffset: quoteOffset, + }, nil +} + +func sendMessageUnsupportedOptionErr(req *tg.MessagesSendMessageRequest) error { + switch { + // reply_markup 不再一律拒绝:bot inline keyboard 在 sendOutgoing 前单独解析+校验 + // (非 bot 静默丢弃,I1)。 + case req.QuickReplyShortcut != nil: + return shortcutInvalidErr() + // req.Effect 不再一律拒绝:消息特效已实现,合法性在 messageEffectInvalid 单独校验。 + case req.AllowPaidStars < 0: + return starsAmountInvalidErr() + case req.AllowPaidStars > 0 || req.AllowPaidFloodskip: + return paymentUnsupportedErr() + case !req.SuggestedPost.Zero(): + return suggestedPostPeerInvalidErr() + default: + return nil + } +} + +// messageEffectInvalid 校验消息特效 id:0(无特效)恒合法;非零必须命中 getAvailableEffects +// 目录(客户端只会从该目录选取 id),否则视为非法 → EFFECT_ID_INVALID。effects 目录常驻 +// 内存(seed 时构建),校验为内存线性扫描,不查库;effect==0 的常规发送零额外成本。 +func (r *Router) messageEffectInvalid(ctx context.Context, effect int64) bool { + if effect == 0 { + return false + } + if r.deps.Files == nil { + return true + } + effects, _, err := r.deps.Files.AvailableEffects(ctx) + if err != nil { + return true + } + for _, e := range effects { + if e.ID == effect { + return false + } + } + return true +} + +func (r *Router) mentionedUserIDsFromMessage(ctx context.Context, currentUserID int64, message string, entities []tg.MessageEntityClass) ([]int64, error) { + if r.deps.Users == nil { + return nil, nil + } + identity, _ := r.deps.Users.(UserIdentityService) + seen := make(map[int64]struct{}) + out := make([]int64, 0) + add := func(id int64) { + if id == 0 { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + out = append(out, id) + } + for _, entity := range entities { + input, ok := entity.(*tg.InputMessageEntityMentionName) + if !ok || input.UserID == nil { + continue + } + user, found, err := r.userFromInput(ctx, currentUserID, input.UserID) + if err != nil { + return nil, internalErr() + } + if found { + add(user.ID) + } + if len(out) >= domain.MaxChannelMentionRecipients { + return out, nil + } + } + if identity != nil { + for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out)) { + user, found, err := identity.ResolveUsername(ctx, currentUserID, username) + if err != nil { + return nil, internalErr() + } + if found { + add(user.ID) + } + if len(out) >= domain.MaxChannelMentionRecipients { + return out, nil + } + } + } + return out, nil +} + +func extractMentionUsernames(message string, limit int) []string { + if limit <= 0 || message == "" { + return nil + } + seen := make(map[string]struct{}) + out := make([]string, 0) + for i := 0; i < len(message); i++ { + if message[i] != '@' { + continue + } + if i > 0 && isUsernameByte(message[i-1]) { + continue + } + j := i + 1 + for j < len(message) && isUsernameByte(message[j]) { + j++ + } + if j == i+1 { + continue + } + username := strings.ToLower(message[i+1 : j]) + if _, ok := seen[username]; ok { + continue + } + seen[username] = struct{}{} + out = append(out, username) + if len(out) == limit { + return out + } + i = j - 1 + } + return out +} + +func isUsernameByte(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' +} + +func tgPrivateMessageUpdates(event domain.UpdateEvent, msg domain.Message, randomID int64, includeMessageID bool, users []tg.UserClass, chats []tg.ChatClass) *tg.Updates { + updates := make([]tg.UpdateClass, 0, 2) + if includeMessageID { + updates = append(updates, &tg.UpdateMessageID{ID: msg.ID, RandomID: randomID}) + } + item := tgMessage(msg) + if item == nil { + item = &tg.MessageEmpty{ID: msg.ID} + } + updates = append(updates, &tg.UpdateNewMessage{ + Message: item, + Pts: event.Pts, + PtsCount: event.PtsCount, + }) + date := event.Date + if date == 0 { + date = msg.Date + } + return &tg.Updates{ + Updates: updates, + Users: users, + Chats: chats, + Date: date, + Seq: 0, // 私聊不维护账号级 seq,恒 0(客户端仅靠 pts 同步) + } +} + +func (r *Router) usersForMessageUpdate(ctx context.Context, ownerUserID int64, msg domain.Message) []tg.UserClass { + seen := make(map[int64]struct{}, 2) + users := make([]tg.UserClass, 0, 2) + add := func(id int64) { + if id == 0 { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + switch { + case isSystemUserID(id): + if u, ok := domain.SystemUserByID(id); ok { + users = append(users, r.tgUser(u)) + } + case id == ownerUserID: + if r.deps.Users == nil { + return + } + u, err := r.deps.Users.Self(ctx, ownerUserID) + if err == nil && u.ID != 0 { + users = append(users, r.tgSelfUser(u)) + } + default: + if r.deps.Users == nil { + return + } + u, found, err := r.deps.Users.ByID(ctx, ownerUserID, id) + if err == nil && found { + users = append(users, r.tgUser(u)) + } + } + } + if msg.From.Type == domain.PeerTypeUser { + add(msg.From.ID) + } + if msg.Peer.Type == domain.PeerTypeUser { + add(msg.Peer.ID) + } + if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeUser { + add(msg.Forward.From.ID) + } + add(msg.ViaBotID) + if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeUser { + add(msg.ReplyTo.Peer.ID) + } + if msg.Media != nil && msg.Media.Contact != nil { + add(msg.Media.Contact.UserID) + } + return users +} + +func (r *Router) usersForMessageUpdates(ctx context.Context, ownerUserID int64, messages []domain.Message) []tg.UserClass { + seen := make(map[int64]struct{}, len(messages)*2) + ids := make([]int64, 0, len(messages)*2) + addID := func(id int64) { + if id == 0 { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + ids = append(ids, id) + } + for _, msg := range messages { + if msg.From.Type == domain.PeerTypeUser { + addID(msg.From.ID) + } + if msg.Peer.Type == domain.PeerTypeUser { + addID(msg.Peer.ID) + } + if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeUser { + addID(msg.Forward.From.ID) + } + addID(msg.ViaBotID) + if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeUser { + addID(msg.ReplyTo.Peer.ID) + } + if msg.Media != nil && msg.Media.Contact != nil { + addID(msg.Media.Contact.UserID) + } + } + if len(ids) == 0 { + return nil + } + loaded := make(map[int64]domain.User, len(ids)) + if r.deps.Users != nil { + if users, err := r.deps.Users.ByIDs(ctx, ownerUserID, ids); err == nil { + for _, user := range users { + loaded[user.ID] = user + } + } + } + users := make([]tg.UserClass, 0, len(ids)) + for _, id := range ids { + switch { + case isSystemUserID(id): + if u, ok := domain.SystemUserByID(id); ok { + users = append(users, r.tgUser(u)) + } + case id == ownerUserID: + if user, ok := loaded[id]; ok { + users = append(users, r.tgSelfUser(user)) + } + default: + if user, ok := loaded[id]; ok { + users = append(users, r.tgUser(user)) + } + } + } + return users +} + +func (r *Router) chatsForMessageUpdate(ctx context.Context, ownerUserID int64, msg domain.Message) []tg.ChatClass { + return r.chatsForMessageUpdates(ctx, ownerUserID, []domain.Message{msg}) +} + +func appendMessageChannelIDs(ids []int64, seen map[int64]struct{}, msg domain.Message) []int64 { + add := func(id int64) { + if id == 0 { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if msg.From.Type == domain.PeerTypeChannel { + add(msg.From.ID) + } + if msg.Peer.Type == domain.PeerTypeChannel { + add(msg.Peer.ID) + } + if msg.Forward != nil && msg.Forward.From.Type == domain.PeerTypeChannel { + add(msg.Forward.From.ID) + } + if msg.ReplyTo != nil && msg.ReplyTo.Peer.Type == domain.PeerTypeChannel { + add(msg.ReplyTo.Peer.ID) + } + return ids +} + +func (r *Router) chatsForMessageUpdates(ctx context.Context, ownerUserID int64, messages []domain.Message) []tg.ChatClass { + if r.deps.Channels == nil || len(messages) == 0 { + return nil + } + seen := make(map[int64]struct{}, len(messages)*2) + ids := make([]int64, 0, len(messages)) + for _, msg := range messages { + ids = appendMessageChannelIDs(ids, seen, msg) + } + if len(ids) == 0 { + return nil + } + views, err := r.deps.Channels.GetChannels(ctx, ownerUserID, ids) + if err != nil { + return nil + } + byID := make(map[int64]domain.ChannelView, len(views)) + for _, view := range views { + if view.Channel.ID != 0 { + byID[view.Channel.ID] = view + } + } + chats := make([]tg.ChatClass, 0, len(ids)) + for _, id := range ids { + if view, ok := byID[id]; ok { + chats = append(chats, tgChannelChatForView(ownerUserID, view)) + } + } + return chats +} + +// mentionUserIDsFromDomain 从 domain 实体与文本解析 @ 目标(转发/重放路径, +// mentionName 实体已携带解析好的 user_id)。 +func (r *Router) mentionUserIDsFromDomain(ctx context.Context, currentUserID int64, message string, entities []domain.MessageEntity) []int64 { + seen := make(map[int64]struct{}) + out := make([]int64, 0) + add := func(id int64) { + if id == 0 || len(out) >= domain.MaxChannelMentionRecipients { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + out = append(out, id) + } + for _, entity := range entities { + if entity.Type == domain.MessageEntityMentionName { + add(entity.UserID) + } + } + if identity, ok := r.deps.Users.(UserIdentityService); ok && identity != nil { + for _, username := range extractMentionUsernames(message, domain.MaxChannelMentionRecipients-len(out)) { + user, found, err := identity.ResolveUsername(ctx, currentUserID, username) + if err != nil { + break + } + if found { + add(user.ID) + } + } + } + return out +} diff --git a/internal/rpc/messages_send_as.go b/internal/rpc/messages_send_as.go new file mode 100644 index 00000000..8b9b6064 --- /dev/null +++ b/internal/rpc/messages_send_as.go @@ -0,0 +1,252 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +func (r *Router) forumSendAsPeer(ctx context.Context, peerInput, sendAsInput tg.InputPeerClass) (*domain.Peer, error) { + peer, err := r.forumTopicPeer(ctx, peerInput) + if err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + // 复用统一的 send-as 校验:自己→nil(以自己发言),当前频道/群→匿名/广播自帖, + // 自己拥有的其它广播频道→个人频道(需会员)。forum topic 与普通消息走同一套资格判定。 + return r.sendAsPeerFromInput(ctx, userID, peer, sendAsInput) +} + +func (r *Router) onMessagesSaveDefaultSendAs(ctx context.Context, req *tg.MessagesSaveDefaultSendAsRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if userID == 0 { + return false, peerIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + if peer.Type != domain.PeerTypeChannel { + return false, peerIDInvalidErr() + } + sendAs, err := r.sendAsPeerFromInput(ctx, userID, peer, req.SendAs) + if err != nil { + return false, err + } + if r.deps.Channels == nil { + return false, channelInvalidErr(domain.ErrChannelInvalid) + } + if _, err := r.deps.Channels.SaveDefaultSendAs(ctx, userID, domain.SaveChannelDefaultSendAsRequest{ + UserID: userID, + ChannelID: peer.ID, + SendAs: sendAs, + }); err != nil { + return false, channelInvalidErr(err) + } + r.invalidateRPCProjectionForPeer(userID, peer) + return true, nil +} + +func (r *Router) sendAsPeerFromInput(ctx context.Context, userID int64, to domain.Peer, input tg.InputPeerClass) (*domain.Peer, error) { + if input == nil { + return nil, nil + } + if to.Type != domain.PeerTypeChannel { + return nil, sendAsPeerInvalidErr() + } + sendAs, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return nil, sendAsPeerInvalidErr() + } + switch sendAs.Type { + case domain.PeerTypeUser: + if sendAs.ID != userID { + return nil, sendAsPeerInvalidErr() + } + return nil, nil + case domain.PeerTypeChannel: + if sendAs.ID == to.ID { + // 以当前频道/群本身发言(广播自帖、匿名管理员)。 + if err := r.validateCurrentChannelSendAs(ctx, userID, to.ID); err != nil { + return nil, err + } + } else { + // 以自己拥有的其它广播频道身份在本群发言(个人频道需会员)。 + if err := r.validateForeignChannelSendAs(ctx, userID, to.ID, sendAs.ID); err != nil { + return nil, err + } + } + out := sendAs + return &out, nil + default: + return nil, sendAsPeerInvalidErr() + } +} + +// validateForeignChannelSendAs 校验「以自己拥有的另一个(广播)频道身份在 toChannel 里发言」: +// 必须是该频道的 creator 或有 PostMessages 权的 admin;个人频道(非 toChannel 的关联讨论频道) +// 需有效会员。gotd 错误集无 CHAT_SEND_AS_DENIED,故一律收敛为 SEND_AS_PEER_INVALID(客户端在 +// 选择阶段已按 premium_required 拦截,此为服务端兜底)。 +func (r *Router) validateForeignChannelSendAs(ctx context.Context, userID, toChannelID, sendAsChannelID int64) error { + if r.deps.Channels == nil { + return sendAsPeerInvalidErr() + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, sendAsChannelID) + if err != nil || !canSendAsForeignChannel(view) { + return sendAsPeerInvalidErr() + } + // 接受 iff 会员 或 该频道是本群关联讨论频道(免会员)。先查会员(带 user:base 缓存、且注册默认赠送 + // 故多数命中 true)即短路,省掉 linked 判定那次 ResolveChannel(toChannel)。 + if !r.userIsPremium(ctx, userID) && !r.foreignSendAsIsLinkedChannel(ctx, userID, toChannelID, sendAsChannelID) { + return sendAsPeerInvalidErr() + } + return nil +} + +// foreignSendAsIsLinkedChannel 报告 sendAsChannel 是否是 toChannel 的关联讨论频道(该情形免会员, +// 对齐官方:在自己频道的讨论组里以频道身份发言不需要会员)。 +func (r *Router) foreignSendAsIsLinkedChannel(ctx context.Context, userID, toChannelID, sendAsChannelID int64) bool { + if r.deps.Channels == nil { + return false + } + toView, err := r.deps.Channels.ResolveChannel(ctx, userID, toChannelID) + if err != nil { + return false + } + return toView.Channel.LinkedChatID != 0 && toView.Channel.LinkedChatID == sendAsChannelID +} + +// canSendAsForeignChannel 判定 view 描述的频道能否被当前用户「作为身份」在别的群里发言: +// 仅限广播频道,且用户是 creator 或持 PostMessages 权的 admin(以自己拥有的 megagroup 身份发言 +// 不是真实的 Telegram 能力,故排除)。 +func canSendAsForeignChannel(view domain.ChannelView) bool { + if !view.Channel.Broadcast { + return false + } + if view.Self.Role == domain.ChannelRoleCreator { + return true + } + if view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.PostMessages { + return true + } + return false +} + +// userIsPremium 报告用户当前是否为有效会员(PremiumActiveAt 即时派生)。 +func (r *Router) userIsPremium(ctx context.Context, userID int64) bool { + if r.deps.Users == nil || userID == 0 { + return false + } + u, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return false + } + return u.PremiumActiveAt(r.clock.Now().Unix()) +} + +func (r *Router) resolveSendAsPeer(ctx context.Context, userID int64, to domain.Peer, input tg.InputPeerClass) (*domain.Peer, error) { + if input != nil { + return r.sendAsPeerFromInput(ctx, userID, to, input) + } + if to.Type != domain.PeerTypeChannel || r.deps.Channels == nil { + return nil, nil + } + view, err := r.deps.Channels.GetChannel(ctx, userID, to.ID) + if err != nil { + return nil, nil + } + return r.effectiveDefaultSendAs(ctx, userID, view), nil +} + +// effectiveDefaultSendAs 解析某频道会话保存的默认 send-as(无显式 send_as 时用)。每次发送都重新 +// 校验:当前频道默认按 canCurrentChannelSendAs,外部频道默认按 validateForeignChannelSendAs;失效的 +// 默认(被降权 / 不再拥有 / 会员过期)静默回落为「以自己发言」(nil),对齐官方客户端的 reload-on-400。 +func (r *Router) effectiveDefaultSendAs(ctx context.Context, userID int64, view domain.ChannelView) *domain.Peer { + def := view.Dialog.DefaultSendAs + if def == nil || def.ID == 0 || def.Type != domain.PeerTypeChannel { + return nil + } + if def.ID == view.Channel.ID { + if !canCurrentChannelSendAs(view) { + return nil + } + out := *def + return &out + } + if err := r.validateForeignChannelSendAs(ctx, userID, view.Channel.ID, def.ID); err != nil { + return nil + } + out := *def + return &out +} + +// applyForeignDefaultSendAsToFull 在 getFullChannel 投影里补上「外部频道默认 send-as」:当某会话保存的 +// 默认是用户自己拥有的另一个广播频道时,设置 channelFull.default_send_as 并把该频道对象放进 Chats +// (否则客户端无法渲染默认 chip)。当前频道默认由 tgChannelFull 处理,此处只管外部频道;按所有权(而非 +// 会员)投影——会员到期等情形由发送侧 effectiveDefaultSendAs 兜底回落为自己。 +func (r *Router) applyForeignDefaultSendAsToFull(ctx context.Context, userID int64, view domain.ChannelView, full *tg.ChannelFull, chats *[]tg.ChatClass) { + def := view.Dialog.DefaultSendAs + if def == nil || def.Type != domain.PeerTypeChannel || def.ID == 0 || def.ID == view.Channel.ID { + return + } + if r.deps.Channels == nil { + return + } + sendAsView, err := r.deps.Channels.ResolveChannel(ctx, userID, def.ID) + if err != nil || !canSendAsForeignChannel(sendAsView) { + return + } + full.SetDefaultSendAs(&tg.PeerChannel{ChannelID: def.ID}) + *chats = appendUniqueTGChats(*chats, tgChannels(userID, []domain.Channel{sendAsView.Channel})...) +} + +func validDefaultSendAsPeer(view domain.ChannelView) *domain.Peer { + if view.Dialog.DefaultSendAs == nil || view.Dialog.DefaultSendAs.ID == 0 { + return nil + } + switch view.Dialog.DefaultSendAs.Type { + case domain.PeerTypeUser: + return nil + case domain.PeerTypeChannel: + if view.Dialog.DefaultSendAs.ID != view.Channel.ID || !canCurrentChannelSendAs(view) { + return nil + } + out := *view.Dialog.DefaultSendAs + return &out + default: + return nil + } +} + +func (r *Router) validateCurrentChannelSendAs(ctx context.Context, userID, channelID int64) error { + if r.deps.Channels == nil { + return sendAsPeerInvalidErr() + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID) + if err != nil { + return sendAsPeerInvalidErr() + } + if canCurrentChannelSendAs(view) { + return nil + } + return sendAsPeerInvalidErr() +} + +func canCurrentChannelSendAs(view domain.ChannelView) bool { + if view.Self.Role == domain.ChannelRoleCreator { + return true + } + if view.Channel.Broadcast && view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.PostMessages { + return true + } + if !view.Channel.Broadcast && view.Self.Role == domain.ChannelRoleAdmin && view.Self.AdminRights.Anonymous { + return true + } + return false +} diff --git a/internal/rpc/messages_send_rpc_test.go b/internal/rpc/messages_send_rpc_test.go new file mode 100644 index 00000000..639584ff --- /dev/null +++ b/internal/rpc/messages_send_rpc_test.go @@ -0,0 +1,389 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + "strconv" + "strings" + "telesrv/internal/domain" + "testing" + "time" +) + +func TestMessagesSendMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) { + sender := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Sender"} + recipient := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Recipient"} + messages := &captureMessages{} + metrics := &captureRPCMetrics{} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{sender.ID: sender, recipient.ID: recipient}}, + Metrics: metrics, + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, + Message: "hello", + RandomID: 123456, + Entities: []tg.MessageEntityClass{ + &tg.MessageEntityBold{Offset: 0, Length: 5}, + }, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), sender.ID), [8]byte{}, 77, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := enc.(*tg.UpdatesBox) + if !ok { + t.Fatalf("response = %T, want *tg.UpdatesBox", enc) + } + got, ok := box.Updates.(*tg.Updates) + if !ok { + t.Fatalf("boxed response = %T, want *tg.Updates", box.Updates) + } + if messages.sendUserID != sender.ID || messages.sendReq.SenderUserID != sender.ID || messages.sendReq.RecipientUserID != recipient.ID || messages.sendReq.OriginSessionID != 77 { + t.Fatalf("send context = user %d req %+v, want sender/recipient/session", messages.sendUserID, messages.sendReq) + } + if len(messages.sendReq.Entities) != 1 || messages.sendReq.Entities[0].Type != domain.MessageEntityBold { + t.Fatalf("entities = %+v, want bold entity converted to domain", messages.sendReq.Entities) + } + if len(got.Updates) != 2 { + t.Fatalf("updates = %+v, want message id + new message", got.Updates) + } + if id, ok := got.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != 1 || id.RandomID != req.RandomID { + t.Fatalf("update id = %#v, want id=1 random_id=%d", got.Updates[0], req.RandomID) + } + newMsg, ok := got.Updates[1].(*tg.UpdateNewMessage) + if !ok || newMsg.Pts != 1 || newMsg.PtsCount != 1 { + t.Fatalf("new message update = %#v, want pts=1 pts_count=1", got.Updates[1]) + } + msg, ok := newMsg.Message.(*tg.Message) + if !ok || !msg.Out || msg.PeerID.(*tg.PeerUser).UserID != recipient.ID || msg.Message != req.Message { + t.Fatalf("message = %#v, want outgoing private text to recipient", newMsg.Message) + } + if metrics.messageSend != 1 || metrics.messageSendErr != nil { + t.Fatalf("metrics send=%d err=%v, want one successful send", metrics.messageSend, metrics.messageSendErr) + } +} + +func TestMessagesSendMessageRateLimitReturnsFloodWait(t *testing.T) { + const userID = int64(1000000001) + limiter := &captureRateLimiter{block: true, retryAfter: 9} + metrics := &captureRPCMetrics{} + r := New(Config{SendRateLimit: 1, SendRateWindow: time.Minute}, Deps{ + Limiter: limiter, + Metrics: metrics, + }, zaptest.NewLogger(t), clock.System) + + _, err := r.onMessagesSendMessage(WithUserID(context.Background(), userID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerSelf{}, + Message: "too fast", + RandomID: 123456, + }) + if err == nil || !strings.Contains(err.Error(), "FLOOD_WAIT") || !strings.Contains(err.Error(), "(9)") { + t.Fatalf("sendMessage rate err = %v, want FLOOD_WAIT 9", err) + } + if len(limiter.calls) != 1 { + t.Fatalf("limiter calls = %d, want 1", len(limiter.calls)) + } + call := limiter.calls[0] + if call.key != sendRateLimitKeyPrefix+strconv.FormatInt(userID, 10) || call.cost != 1 || call.limit != 1 || call.window != time.Minute { + t.Fatalf("limiter call = %+v, want send key cost=1 limit=1 window=1m", call) + } + if metrics.rateLimited != 9 { + t.Fatalf("rate limited metric = %d, want 9", metrics.rateLimited) + } +} + +func TestMessagesForwardMessagesRateLimitCountsIDs(t *testing.T) { + const userID = int64(1000000001) + limiter := &captureRateLimiter{block: true, retryAfter: 13} + r := New(Config{SendRateLimit: 3, SendRateWindow: 4 * time.Second}, Deps{Limiter: limiter}, zaptest.NewLogger(t), clock.System) + + _, err := r.onMessagesForwardMessages(WithUserID(context.Background(), userID), &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerSelf{}, + ToPeer: &tg.InputPeerSelf{}, + ID: []int{1, 2, 3}, + RandomID: []int64{11, 22, 33}, + }) + if err == nil || !strings.Contains(err.Error(), "FLOOD_WAIT") || !strings.Contains(err.Error(), "(13)") { + t.Fatalf("forwardMessages rate err = %v, want FLOOD_WAIT 13", err) + } + if len(limiter.calls) != 1 { + t.Fatalf("limiter calls = %d, want 1", len(limiter.calls)) + } + call := limiter.calls[0] + if call.key != sendRateLimitKeyPrefix+strconv.FormatInt(userID, 10) || call.cost != 3 || call.limit != 3 || call.window != 4*time.Second { + t.Fatalf("limiter call = %+v, want shared send key cost=3 limit=3 window=4s", call) + } +} + +func TestMessagesSendMessageSupportsReplyAndFlags(t *testing.T) { + const ( + senderID = int64(1000000001) + recipientID = int64(1000000002) + ) + messages := &captureMessages{} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{senderID: {ID: senderID, FirstName: "Sender"}, recipientID: {ID: recipientID, FirstName: "Recipient"}}}, + }, zaptest.NewLogger(t), clock.System) + reply := &tg.InputReplyToMessage{ReplyToMsgID: 7} + reply.SetQuoteText("hello") + reply.SetQuoteOffset(1) + req := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: recipientID}, + Message: "reply", + RandomID: 456, + Silent: true, + } + req.SetNoforwards(true) + req.SetReplyTo(reply) + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + enc, err := r.Dispatch(WithUserID(context.Background(), senderID), [8]byte{}, 88, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if messages.sendReq.ReplyTo == nil || messages.sendReq.ReplyTo.MessageID != 7 || messages.sendReq.ReplyTo.Peer.ID != recipientID || messages.sendReq.ReplyTo.QuoteText != "hello" { + t.Fatalf("reply request = %+v, want reply metadata", messages.sendReq.ReplyTo) + } + if !messages.sendReq.Silent || !messages.sendReq.NoForwards { + t.Fatalf("send flags silent=%v noforwards=%v, want true/true", messages.sendReq.Silent, messages.sendReq.NoForwards) + } + box, ok := enc.(*tg.UpdatesBox) + if !ok { + t.Fatalf("response = %T, want *tg.UpdatesBox", enc) + } + got := box.Updates.(*tg.Updates) + newMsg := got.Updates[1].(*tg.UpdateNewMessage) + msg := newMsg.Message.(*tg.Message) + if !msg.Silent || !msg.Noforwards { + t.Fatalf("message flags silent=%v noforwards=%v, want true/true", msg.Silent, msg.Noforwards) + } + header, ok := msg.ReplyTo.(*tg.MessageReplyHeader) + if !ok || header.ReplyToMsgID != 7 { + t.Fatalf("reply header = %#v, want msg id 7", msg.ReplyTo) + } +} + +func TestMessagesSendMessageRejectsHugeReplyQuoteOffset(t *testing.T) { + const ( + senderID = int64(1000000001) + recipientID = int64(1000000002) + ) + messages := &captureMessages{} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{recipientID: {ID: recipientID, FirstName: "Recipient"}}}, + }, zaptest.NewLogger(t), clock.System) + reply := &tg.InputReplyToMessage{ReplyToMsgID: 7} + reply.SetQuoteText("hello") + reply.SetQuoteOffset(domain.MaxMessageReplyQuoteOffset + 1) + req := &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: recipientID}, + Message: "reply", + RandomID: 457, + } + req.SetReplyTo(reply) + + if _, err := r.onMessagesSendMessage(WithUserID(context.Background(), senderID), req); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("huge quote offset err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } + if messages.sendReq.RandomID != 0 { + t.Fatalf("send request reached service: %+v", messages.sendReq) + } +} + +// story 回复(评论):reply_to=inputReplyToStory 必须被接受并投影为 messageReplyStoryHeader, +// 而非旧的 STORY_ID_INVALID 拒绝(真机暴露:Alice 回复 Bob story 时评论消息发送失败)。 +func TestMessageReplyFromInputStorySucceedsAndProjectsStoryHeader(t *testing.T) { + sender := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Sender"} + recipient := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Recipient"} + r := New(Config{}, Deps{ + Users: mapUsersService{users: map[int64]domain.User{sender.ID: sender, recipient.ID: recipient}}, + }, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), sender.ID) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID} + input := &tg.InputReplyToStory{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, StoryID: 7} + + reply, err := r.messageReplyFromInput(ctx, sender.ID, peer, input) + if err != nil { + t.Fatalf("story reply err = %v, want nil(story 回复应被接受)", err) + } + if reply == nil || reply.StoryID != 7 || reply.Peer != peer { + t.Fatalf("reply = %+v, want StoryID=7 peer=recipient", reply) + } + + header := tgMessageReplyHeader(domain.Message{Peer: peer, ReplyTo: reply}) + sh, ok := header.(*tg.MessageReplyStoryHeader) + if !ok { + t.Fatalf("header = %T, want *tg.MessageReplyStoryHeader", header) + } + if sh.StoryID != 7 { + t.Fatalf("header story id = %d, want 7", sh.StoryID) + } + if pu, ok := sh.Peer.(*tg.PeerUser); !ok || pu.UserID != recipient.ID { + t.Fatalf("header peer = %#v, want recipient story owner", sh.Peer) + } + + // 回复非会话对端的 story(peer 不匹配)仍被拒。 + wrongPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID} + if _, err := r.messageReplyFromInput(ctx, sender.ID, wrongPeer, input); err == nil || !strings.Contains(err.Error(), "STORY_ID_INVALID") { + t.Fatalf("mismatched story owner err = %v, want STORY_ID_INVALID", err) + } +} + +func TestMessageReplyFromInputUnsupportedShapesReturnExplicitErrors(t *testing.T) { + const userID = int64(1000000001) + ctx := WithUserID(context.Background(), userID) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 1000000002} + withReplyMsg := func(update func(*tg.InputReplyToMessage)) *tg.InputReplyToMessage { + reply := &tg.InputReplyToMessage{ReplyToMsgID: 7} + update(reply) + return reply + } + cases := []struct { + name string + input tg.InputReplyToClass + want string + }{ + { + name: "story", + input: &tg.InputReplyToStory{Peer: &tg.InputPeerUser{UserID: 1000000003}, StoryID: 1}, + want: "STORY_ID_INVALID", + }, + { + name: "monoforum constructor", + input: &tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerChannel{ChannelID: 1000000004}}, + want: "REPLY_TO_MONOFORUM_PEER_INVALID", + }, + { + name: "monoforum field", + input: withReplyMsg(func(reply *tg.InputReplyToMessage) { + reply.SetMonoforumPeerID(&tg.InputPeerChannel{ChannelID: 1000000004}) + }), + want: "REPLY_TO_MONOFORUM_PEER_INVALID", + }, + { + name: "todo item", + input: withReplyMsg(func(reply *tg.InputReplyToMessage) { + reply.SetTodoItemID(1) + }), + want: "REPLY_MESSAGE_ID_INVALID", + }, + { + name: "poll option", + input: withReplyMsg(func(reply *tg.InputReplyToMessage) { + reply.SetPollOption([]byte{1}) + }), + want: "POLL_OPTION_INVALID", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.messageReplyFromInput(ctx, userID, peer, tc.input); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("reply err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestMessagesSendMessageUnsupportedOptionErrors(t *testing.T) { + const ( + senderID = int64(1000000001) + recipientID = int64(1000000002) + ) + ctx := WithUserID(context.Background(), senderID) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + base := func() *tg.MessagesSendMessageRequest { + return &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: recipientID}, + Message: "hello", + RandomID: 456, + } + } + suggested := func() tg.SuggestedPost { + post := tg.SuggestedPost{} + post.SetAccepted(true) + return post + } + cases := []struct { + name string + req *tg.MessagesSendMessageRequest + want string + }{ + { + name: "quick reply", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetQuickReplyShortcut(&tg.InputQuickReplyShortcut{Shortcut: "hello"}) + return req + }(), + want: "SHORTCUT_INVALID", + }, + { + name: "effect", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetEffect(1) + return req + }(), + want: "EFFECT_ID_INVALID", + }, + { + name: "negative paid stars", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetAllowPaidStars(-1) + return req + }(), + want: "STARS_AMOUNT_INVALID", + }, + { + name: "paid stars", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetAllowPaidStars(1) + return req + }(), + want: "PAYMENT_UNSUPPORTED", + }, + { + name: "paid floodskip", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetAllowPaidFloodskip(true) + return req + }(), + want: "PAYMENT_UNSUPPORTED", + }, + { + name: "suggested post", + req: func() *tg.MessagesSendMessageRequest { + req := base() + req.SetSuggestedPost(suggested()) + return req + }(), + want: "SUGGESTED_POST_PEER_INVALID", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onMessagesSendMessage(ctx, tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("send err = %v, want %s", err, tc.want) + } + }) + } +} diff --git a/internal/rpc/messages_send_webpage_rpc_test.go b/internal/rpc/messages_send_webpage_rpc_test.go new file mode 100644 index 00000000..63b2f18d --- /dev/null +++ b/internal/rpc/messages_send_webpage_rpc_test.go @@ -0,0 +1,203 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// "see https://example.com/x" 的 URL 在 UTF-16 偏移 4、长度 21。 +const ( + wpTestMessage = "see https://example.com/x" + wpTestURL = "https://example.com/x" + wpURLEntityOff = 4 + wpURLEntityLen = 21 +) + +func wpURLEntities() []tg.MessageEntityClass { + return []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: wpURLEntityOff, Length: wpURLEntityLen}} +} + +// TestSendMessageAttachesWebPagePending 验证启用预览时,带 URL 的文本消息挂上 pending 占位 +// (id==url_hash 保证与 done 解析关联),并投影 invert_media。 +func TestSendMessageAttachesWebPagePending(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + r.deps.Files.(*fakeFiles).webPagePreviewOn = true + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: wpTestMessage, + Entities: wpURLEntities(), + RandomID: 5101, + InvertMedia: true, + }) + if err != nil { + t.Fatalf("sendMessage: %v", err) + } + msg := newMessageFromUpdates(t, updates) + + wrap, ok := msg.Media.(*tg.MessageMediaWebPage) + if !ok { + t.Fatalf("media = %T, want *tg.MessageMediaWebPage", msg.Media) + } + pending, ok := wrap.Webpage.(*tg.WebPagePending) + if !ok { + t.Fatalf("webpage = %T, want *tg.WebPagePending", wrap.Webpage) + } + wantID := domain.WebPageURLHash(wpTestURL) + if pending.ID != wantID { + t.Errorf("pending id = %d, want url_hash %d", pending.ID, wantID) + } + if url, _ := pending.GetURL(); url != wpTestURL { + t.Errorf("pending url = %q, want %q", url, wpTestURL) + } + if !msg.InvertMedia { + t.Errorf("invert_media not projected onto message") + } +} + +// TestSendMessageAttachesCachedDoneCard 验证:URL 已缓存解析时,发送 echo 直接带 done 卡片 +// (非 pending)——官方行为,TDesktop 据此立即渲染、不依赖异步换卡。 +func TestSendMessageAttachesCachedDoneCard(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + f := r.deps.Files.(*fakeFiles) + f.webPagePreviewOn = true + f.lookupWebPageFn = func(u string) (domain.MessageWebPage, bool) { + return domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: domain.WebPageURLHash(u), URL: u, DisplayURL: "example.com", Title: "Cached"}, true + } + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Message: wpTestMessage, Entities: wpURLEntities(), RandomID: 5201, + }) + if err != nil { + t.Fatalf("sendMessage: %v", err) + } + msg := newMessageFromUpdates(t, updates) + wrap, ok := msg.Media.(*tg.MessageMediaWebPage) + if !ok { + t.Fatalf("media = %T, want *tg.MessageMediaWebPage", msg.Media) + } + page, ok := wrap.Webpage.(*tg.WebPage) + if !ok { + t.Fatalf("echo webpage = %T, want *tg.WebPage (done card directly)", wrap.Webpage) + } + if v, _ := page.GetTitle(); v != "Cached" { + t.Errorf("title = %q, want Cached", v) + } +} + +// TestSendMessageNoWebpageSuppressesPreview 验证 no_webpage 抑制占位。 +func TestSendMessageNoWebpageSuppressesPreview(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + r.deps.Files.(*fakeFiles).webPagePreviewOn = true + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: wpTestMessage, + Entities: wpURLEntities(), + RandomID: 5102, + NoWebpage: true, + }) + if err != nil { + t.Fatalf("sendMessage: %v", err) + } + if msg := newMessageFromUpdates(t, updates); msg.Media != nil { + t.Fatalf("media = %T, want nil (no_webpage)", msg.Media) + } +} + +// TestSendMessagePreviewDisabledNoPlaceholder 验证未启用预览时不挂占位(否则永久 pending)。 +func TestSendMessagePreviewDisabledNoPlaceholder(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + // webPagePreviewOn 默认 false。 + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: wpTestMessage, + Entities: wpURLEntities(), + RandomID: 5103, + }) + if err != nil { + t.Fatalf("sendMessage: %v", err) + } + if msg := newMessageFromUpdates(t, updates); msg.Media != nil { + t.Fatalf("media = %T, want nil (preview disabled)", msg.Media) + } +} + +// TestSendMediaInputWebPageAttachesPending 验证 sendMedia 的 InputMediaWebPage 经降级到 +// sendMessage 后,仍从文本 URL 挂上 pending 占位(启用预览时)。 +func TestSendMediaInputWebPageAttachesPending(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + r.deps.Files.(*fakeFiles).webPagePreviewOn = true + + updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaWebPage{URL: wpTestURL}, + Message: wpTestMessage, + Entities: wpURLEntities(), + RandomID: 5105, + }) + if err != nil { + t.Fatalf("sendMedia webpage: %v", err) + } + msg := newMessageFromUpdates(t, updates) + wrap, ok := msg.Media.(*tg.MessageMediaWebPage) + if !ok { + t.Fatalf("media = %T, want *tg.MessageMediaWebPage", msg.Media) + } + if _, ok := wrap.Webpage.(*tg.WebPagePending); !ok { + t.Fatalf("webpage = %T, want *tg.WebPagePending", wrap.Webpage) + } +} + +// TestSendMessageHighlightsBareURL 验证服务端为不带 url 实体的消息(如 TDesktop 发的)补 +// MessageEntityURL,使链接高亮——独立于预览是否启用。 +func TestSendMessageHighlightsBareURL(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: "see https://example.com/x", + RandomID: 5301, // 无 entities,模拟 TDesktop + }) + if err != nil { + t.Fatalf("sendMessage: %v", err) + } + msg := newMessageFromUpdates(t, updates) + var found bool + for _, e := range msg.Entities { + if u, ok := e.(*tg.MessageEntityURL); ok && u.Offset == 4 && u.Length == 21 { + found = true + } + } + if !found { + t.Fatalf("sent message missing url highlight entity: %+v", msg.Entities) + } +} + +// TestSendMessageNoURLNoPlaceholder 验证无 URL 实体时不挂占位。 +func TestSendMessageNoURLNoPlaceholder(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + r.deps.Files.(*fakeFiles).webPagePreviewOn = true + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: "just plain text, no link", + RandomID: 5104, + }) + if err != nil { + t.Fatalf("sendMessage: %v", err) + } + if msg := newMessageFromUpdates(t, updates); msg.Media != nil { + t.Fatalf("media = %T, want nil (no url)", msg.Media) + } +} diff --git a/internal/rpc/messages_stickers_personal.go b/internal/rpc/messages_stickers_personal.go new file mode 100644 index 00000000..1e9c6820 --- /dev/null +++ b/internal/rpc/messages_stickers_personal.go @@ -0,0 +1,251 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 个人贴纸集合:收藏贴纸 / 最近贴纸 / 保存的 GIF。读写都经 stickerCollectionService +// (由 *app/account.Service 实现,类型断言自 r.deps.Account)。文档经 Files 解析为完整 +// 文档对象,未接通服务时回落历史空 stub 行为。 + +type stickerCollectionService interface { + SaveStickerCollectionItem(ctx context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now int) error + ListStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) + ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error +} + +func (r *Router) stickerCollectionSvc() (stickerCollectionService, bool) { + svc, ok := r.deps.Account.(stickerCollectionService) + return svc, ok +} + +// stickerDocumentFromInput 校验输入文档存在且为贴纸(faveSticker/saveRecentSticker)。 +func (r *Router) stickerDocumentFromInput(ctx context.Context, input tg.InputDocumentClass, requireGif bool) (domain.Document, error) { + in, ok := input.(*tg.InputDocument) + if !ok || in.ID == 0 || r.deps.Files == nil { + return domain.Document{}, stickerInvalidErr() + } + doc, found, err := r.deps.Files.GetDocument(ctx, in.ID) + if err != nil { + return domain.Document{}, internalErr() + } + ok = found && doc.AccessHash == in.AccessHash + if ok { + if requireGif { + ok = doc.IsGif() + } else { + ok = doc.IsSticker() + } + } + if !ok { + return domain.Document{}, stickerInvalidErr() + } + return doc, nil +} + +func (r *Router) onMessagesFaveSticker(ctx context.Context, req *tg.MessagesFaveStickerRequest) (bool, error) { + if req == nil { + return false, stickerInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + doc, err := r.stickerDocumentFromInput(ctx, req.ID, false) + if err != nil { + return false, err + } + if svc, ok := r.stickerCollectionSvc(); ok { + if err := svc.SaveStickerCollectionItem(ctx, userID, domain.StickerCollectionFaved, doc.ID, req.Unfave, int(r.clock.Now().Unix())); err != nil { + return false, internalErr() + } + } + r.pushStickerCollectionUpdate(ctx, userID, &tg.UpdateFavedStickers{}) + return true, nil +} + +func (r *Router) onMessagesSaveRecentSticker(ctx context.Context, req *tg.MessagesSaveRecentStickerRequest) (bool, error) { + if req == nil { + return false, stickerInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + doc, err := r.stickerDocumentFromInput(ctx, req.ID, false) + if err != nil { + return false, err + } + kind := domain.StickerCollectionRecent + if req.Attached { + kind = domain.StickerCollectionRecentAttached + } + if svc, ok := r.stickerCollectionSvc(); ok { + if err := svc.SaveStickerCollectionItem(ctx, userID, kind, doc.ID, req.Unsave, int(r.clock.Now().Unix())); err != nil { + return false, internalErr() + } + } + r.pushStickerCollectionUpdate(ctx, userID, &tg.UpdateRecentStickers{}) + return true, nil +} + +func (r *Router) onMessagesSaveGif(ctx context.Context, req *tg.MessagesSaveGifRequest) (bool, error) { + if req == nil { + return false, stickerInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + doc, err := r.stickerDocumentFromInput(ctx, req.ID, true) + if err != nil { + return false, err + } + if svc, ok := r.stickerCollectionSvc(); ok { + if err := svc.SaveStickerCollectionItem(ctx, userID, domain.StickerCollectionGif, doc.ID, req.Unsave, int(r.clock.Now().Unix())); err != nil { + return false, internalErr() + } + } + r.pushStickerCollectionUpdate(ctx, userID, &tg.UpdateSavedGifs{}) + return true, nil +} + +func (r *Router) onMessagesClearRecentStickers(ctx context.Context, req *tg.MessagesClearRecentStickersRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + kind := domain.StickerCollectionRecent + if req != nil && req.Attached { + kind = domain.StickerCollectionRecentAttached + } + if svc, ok := r.stickerCollectionSvc(); ok { + if err := svc.ClearStickerCollection(ctx, userID, kind); err != nil { + return false, internalErr() + } + } + r.pushStickerCollectionUpdate(ctx, userID, &tg.UpdateRecentStickers{}) + return true, nil +} + +func (r *Router) onMessagesGetFavedStickers(ctx context.Context, hash int64) (tg.MessagesFavedStickersClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + docs := r.stickerCollectionDocuments(ctx, userID, domain.StickerCollectionFaved, nil) + catalogHash := stickerDocumentsHash(docs) + if hash != 0 && hash == catalogHash { + return &tg.MessagesFavedStickersNotModified{}, nil + } + return &tg.MessagesFavedStickers{ + Hash: catalogHash, + Packs: []tg.StickerPack{}, + Stickers: tgDocuments(docs), + }, nil +} + +func (r *Router) onMessagesGetRecentStickers(ctx context.Context, req *tg.MessagesGetRecentStickersRequest) (tg.MessagesRecentStickersClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + kind := domain.StickerCollectionRecent + if req != nil && req.Attached { + kind = domain.StickerCollectionRecentAttached + } + var dates []int + docs := r.stickerCollectionDocuments(ctx, userID, kind, &dates) + catalogHash := stickerDocumentsHash(docs) + if req != nil && req.Hash != 0 && req.Hash == catalogHash { + return &tg.MessagesRecentStickersNotModified{}, nil + } + return &tg.MessagesRecentStickers{ + Hash: catalogHash, + Packs: []tg.StickerPack{}, + Stickers: tgDocuments(docs), + Dates: dates, + }, nil +} + +func (r *Router) onMessagesGetSavedGifs(ctx context.Context, hash int64) (tg.MessagesSavedGifsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + docs := r.stickerCollectionDocuments(ctx, userID, domain.StickerCollectionGif, nil) + catalogHash := stickerDocumentsHash(docs) + if hash != 0 && hash == catalogHash { + return &tg.MessagesSavedGifsNotModified{}, nil + } + return &tg.MessagesSavedGifs{Hash: catalogHash, Gifs: tgDocuments(docs)}, nil +} + +// stickerCollectionDocuments 取某集合并解析为完整文档(最新在前,顺序与集合一致)。 +// 解析不到的文档(已删/不可用)跳过。若 datesOut 非 nil,按相同顺序填充 used_at。 +func (r *Router) stickerCollectionDocuments(ctx context.Context, userID int64, kind domain.StickerCollectionKind, datesOut *[]int) []domain.Document { + svc, ok := r.stickerCollectionSvc() + if !ok || r.deps.Files == nil { + if datesOut != nil { + *datesOut = []int{} + } + return nil + } + items, err := svc.ListStickerCollection(ctx, userID, kind, domain.MaxStickerCollectionItems(kind)) + if err != nil || len(items) == 0 { + if datesOut != nil { + *datesOut = []int{} + } + return nil + } + ids := make([]int64, 0, len(items)) + dateByID := make(map[int64]int, len(items)) + for _, it := range items { + ids = append(ids, it.DocumentID) + dateByID[it.DocumentID] = it.Date + } + resolved, err := r.deps.Files.GetDocuments(ctx, ids) + if err != nil { + if datesOut != nil { + *datesOut = []int{} + } + return nil + } + byID := documentsByID(resolved) + docs := make([]domain.Document, 0, len(items)) + dates := make([]int, 0, len(items)) + for _, id := range ids { // 保持集合顺序(最新在前) + doc, ok := byID[id] + if !ok { + continue + } + docs = append(docs, doc) + dates = append(dates, dateByID[id]) + } + if datesOut != nil { + *datesOut = dates + } + return docs +} + +func stickerDocumentsHash(docs []domain.Document) int64 { + values := make([]int64, 0, len(docs)) + for _, d := range docs { + values = append(values, d.ID) + } + return int64(tdesktopCountHash(values)) +} + +// pushStickerCollectionUpdate 把 updateFaved/Recent/SavedGifs nudge 推给本人其它在线设备。 +func (r *Router) pushStickerCollectionUpdate(ctx context.Context, userID int64, update tg.UpdateClass) { + r.pushUserUpdates(ctx, userID, &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + }) +} diff --git a/internal/rpc/messages_stickers_personal_rpc_test.go b/internal/rpc/messages_stickers_personal_rpc_test.go new file mode 100644 index 00000000..3cd98139 --- /dev/null +++ b/internal/rpc/messages_stickers_personal_rpc_test.go @@ -0,0 +1,149 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + appaccount "telesrv/internal/app/account" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func stickerCollectionRouter(t *testing.T) *Router { + t.Helper() + files := &fakeFiles{docs: map[int64]domain.Document{ + 101: {ID: 101, AccessHash: 11, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 102: {ID: 102, AccessHash: 12, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker}}}, + 201: {ID: 201, AccessHash: 21, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAnimated}}}, + 301: {ID: 301, AccessHash: 31, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio}}}, + }} + passwordStore := memory.NewPasswordStore() + return New(Config{}, Deps{ + Account: appaccount.NewService(passwordStore, appaccount.WithStickerCollections(passwordStore)), + Files: files, + Sessions: &captureSessions{}, + }, zaptest.NewLogger(t), clock.System) +} + +func inputDoc(id, accessHash int64) *tg.InputDocument { + return &tg.InputDocument{ID: id, AccessHash: accessHash} +} + +// TestFavedStickersRoundTrip 回归:faveSticker/getFavedStickers 此前未注册/返空。 +func TestFavedStickersRoundTrip(t *testing.T) { + r := stickerCollectionRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + // 非贴纸文档拒绝。 + if ok, err := r.onMessagesFaveSticker(ctx, &tg.MessagesFaveStickerRequest{ID: inputDoc(301, 31)}); ok || !tgerr.Is(err, "STICKER_DOCUMENT_INVALID") { + t.Fatalf("fave non-sticker = ok %v err %v, want STICKER_DOCUMENT_INVALID", ok, err) + } + + // fave 101 → 102(最新在前)。 + for _, id := range []int64{101, 102} { + if ok, err := r.onMessagesFaveSticker(ctx, &tg.MessagesFaveStickerRequest{ID: inputDoc(id, id%100+10)}); err != nil || !ok { + t.Fatalf("fave %d = ok %v err %v", id, ok, err) + } + } + faved := favedStickerIDs(t, r, ctx, 0) + if len(faved) != 2 || faved[0] != 102 || faved[1] != 101 { + t.Fatalf("faved = %v, want [102 101](最新在前)", faved) + } + + // not-modified:用返回 hash 再请求。 + full, err := r.onMessagesGetFavedStickers(ctx, 0) + if err != nil { + t.Fatalf("get faved: %v", err) + } + hash := full.(*tg.MessagesFavedStickers).Hash + again, err := r.onMessagesGetFavedStickers(ctx, hash) + if err != nil { + t.Fatalf("get faved again: %v", err) + } + if _, ok := again.(*tg.MessagesFavedStickersNotModified); !ok { + t.Fatalf("re-get with hash = %T, want NotModified", again) + } + + // unfave 101 → 只剩 102。 + if ok, err := r.onMessagesFaveSticker(ctx, &tg.MessagesFaveStickerRequest{ID: inputDoc(101, 11), Unfave: true}); err != nil || !ok { + t.Fatalf("unfave 101 = ok %v err %v", ok, err) + } + if faved := favedStickerIDs(t, r, ctx, 0); len(faved) != 1 || faved[0] != 102 { + t.Fatalf("faved after unfave = %v, want [102]", faved) + } +} + +// TestRecentStickersRoundTrip 验证 saveRecentSticker/getRecentStickers + dates + clear。 +func TestRecentStickersRoundTrip(t *testing.T) { + r := stickerCollectionRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + if ok, err := r.onMessagesSaveRecentSticker(ctx, &tg.MessagesSaveRecentStickerRequest{ID: inputDoc(101, 11)}); err != nil || !ok { + t.Fatalf("save recent = ok %v err %v", ok, err) + } + out, err := r.onMessagesGetRecentStickers(ctx, &tg.MessagesGetRecentStickersRequest{}) + if err != nil { + t.Fatalf("get recent: %v", err) + } + recent := out.(*tg.MessagesRecentStickers) + if len(recent.Stickers) != 1 { + t.Fatalf("recent stickers = %d, want 1", len(recent.Stickers)) + } + if len(recent.Dates) != 1 || recent.Dates[0] == 0 { + t.Fatalf("recent dates = %v, want one non-zero date", recent.Dates) + } + + // clear → 空。 + if ok, err := r.onMessagesClearRecentStickers(ctx, &tg.MessagesClearRecentStickersRequest{}); err != nil || !ok { + t.Fatalf("clear recent = ok %v err %v", ok, err) + } + out, _ = r.onMessagesGetRecentStickers(ctx, &tg.MessagesGetRecentStickersRequest{}) + if got := out.(*tg.MessagesRecentStickers); len(got.Stickers) != 0 { + t.Fatalf("recent after clear = %d, want 0", len(got.Stickers)) + } +} + +// TestSavedGifsRoundTrip 验证 saveGif/getSavedGifs + 非 GIF 拒绝。 +func TestSavedGifsRoundTrip(t *testing.T) { + r := stickerCollectionRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + // 非 GIF(贴纸)拒绝。 + if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(101, 11)}); ok || !tgerr.Is(err, "STICKER_DOCUMENT_INVALID") { + t.Fatalf("save non-gif = ok %v err %v, want STICKER_DOCUMENT_INVALID", ok, err) + } + if ok, err := r.onMessagesSaveGif(ctx, &tg.MessagesSaveGifRequest{ID: inputDoc(201, 21)}); err != nil || !ok { + t.Fatalf("save gif = ok %v err %v", ok, err) + } + out, err := r.onMessagesGetSavedGifs(ctx, 0) + if err != nil { + t.Fatalf("get saved gifs: %v", err) + } + if got := out.(*tg.MessagesSavedGifs); len(got.Gifs) != 1 { + t.Fatalf("saved gifs = %d, want 1", len(got.Gifs)) + } +} + +func favedStickerIDs(t *testing.T, r *Router, ctx context.Context, hash int64) []int64 { + t.Helper() + out, err := r.onMessagesGetFavedStickers(ctx, hash) + if err != nil { + t.Fatalf("get faved: %v", err) + } + faved, ok := out.(*tg.MessagesFavedStickers) + if !ok { + t.Fatalf("get faved = %T, want *tg.MessagesFavedStickers", out) + } + ids := make([]int64, 0, len(faved.Stickers)) + for _, d := range faved.Stickers { + if doc, ok := d.(*tg.Document); ok { + ids = append(ids, doc.ID) + } + } + return ids +} diff --git a/internal/rpc/messages_todos.go b/internal/rpc/messages_todos.go new file mode 100644 index 00000000..c18e8c8e --- /dev/null +++ b/internal/rpc/messages_todos.go @@ -0,0 +1,364 @@ +package rpc + +import ( + "context" + "strings" + "unicode/utf8" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// todo 清单:发送/渲染/转发全链路真实现;appendTodoList / toggleTodoCompleted +// 在私聊和超级群均支持 others_can_* 协作。超级群成功协作会同时更新原 +// checklist media 快照,并生成 reply 到原消息的 todo 服务消息,二者各占 +// 一个 channel pts。premium 创建入口依赖 premium 业务模型,记矩阵 todo。 + +// domainTodoFromInput 校验 InputMediaTodo 的清单定义并转为 domain 快照。 +func domainTodoFromInput(in tg.TodoList) (*domain.MessageTodo, error) { + title := in.Title.Text + if strings.TrimSpace(title) == "" { + return nil, mediaEmptyErr() + } + if utf8.RuneCountInString(title) > maxTodoTitleLength { + return nil, limitInvalidErr() + } + if len(in.Title.Entities) > maxMessageEntityCount { + return nil, limitInvalidErr() + } + if err := validateTodoItems(in.List); err != nil { + return nil, err + } + out := &domain.MessageTodo{ + OthersCanAppend: in.OthersCanAppend, + OthersCanComplete: in.OthersCanComplete, + Title: title, + TitleEntities: domainMessageEntities(in.Title.Entities), + } + for _, item := range in.List { + if item.ID <= 0 { + return nil, messageIDInvalidErr() + } + out.Items = append(out.Items, domain.MessageTodoItem{ + ID: item.ID, + Title: item.Title.Text, + Entities: domainMessageEntities(item.Title.Entities), + }) + } + return out, nil +} + +// tgTodoMedia 把 domain 快照转回 tg.MessageMediaToDo。 +func tgTodoMedia(todo domain.MessageTodo) *tg.MessageMediaToDo { + list := tg.TodoList{ + OthersCanAppend: todo.OthersCanAppend, + OthersCanComplete: todo.OthersCanComplete, + Title: tgTextWithEntities(todo.Title, todo.TitleEntities), + } + for _, item := range todo.Items { + list.List = append(list.List, tg.TodoItem{ + ID: item.ID, + Title: tgTextWithEntities(item.Title, item.Entities), + }) + } + out := &tg.MessageMediaToDo{Todo: list} + if len(todo.Completions) > 0 { + completions := make([]tg.TodoCompletion, 0, len(todo.Completions)) + for _, c := range todo.Completions { + completions = append(completions, tg.TodoCompletion{ + ID: c.ID, + CompletedBy: &tg.PeerUser{UserID: c.CompletedBy}, + Date: c.Date, + }) + } + out.SetCompletions(completions) + } + return out +} + +func tgTodoItems(items []domain.MessageTodoItem) []tg.TodoItem { + if len(items) == 0 { + return nil + } + out := make([]tg.TodoItem, 0, len(items)) + for _, item := range items { + out = append(out, tg.TodoItem{ + ID: item.ID, + Title: tgTextWithEntities(item.Title, item.Entities), + }) + } + return out +} + +func (r *Router) onMessagesAppendTodoList(ctx context.Context, req *tg.MessagesAppendTodoListRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if len(req.List) == 0 { + return nil, todoNotModifiedErr() + } + if err := validateTodoItems(req.List); err != nil { + return nil, err + } + return r.mutateTodoMedia(ctx, req.Peer, req.MsgID, func(todo *domain.MessageTodo) bool { + return todo.OthersCanAppend + }, func(userID int64, todo *domain.MessageTodo, _ int) (*domain.ChannelMessageAction, error) { + existing := make(map[int]struct{}, len(todo.Items)) + for _, item := range todo.Items { + existing[item.ID] = struct{}{} + } + if len(todo.Items)+len(req.List) > maxTodoItems { + return nil, limitInvalidErr() + } + appended := make([]domain.MessageTodoItem, 0, len(req.List)) + for _, item := range req.List { + if item.ID <= 0 { + return nil, messageIDInvalidErr() + } + if _, dup := existing[item.ID]; dup { + return nil, tgerr.New(400, "TODO_ITEM_DUPLICATE") + } + existing[item.ID] = struct{}{} + appended = append(appended, domain.MessageTodoItem{ + ID: item.ID, + Title: item.Title.Text, + Entities: domainMessageEntities(item.Title.Entities), + }) + } + todo.Items = append(todo.Items, appended...) + return &domain.ChannelMessageAction{ + Type: domain.ChannelActionTodoAppendTasks, + TodoItems: appended, + }, nil + }) +} + +func (r *Router) onMessagesToggleTodoCompleted(ctx context.Context, req *tg.MessagesToggleTodoCompletedRequest) (tg.UpdatesClass, error) { + if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID { + return nil, messageIDInvalidErr() + } + if len(req.Completed) == 0 && len(req.Incompleted) == 0 { + return nil, todoNotModifiedErr() + } + if err := validateTodoIDVector(req.Completed, req.Incompleted); err != nil { + return nil, err + } + return r.mutateTodoMedia(ctx, req.Peer, req.MsgID, func(todo *domain.MessageTodo) bool { + return todo.OthersCanComplete + }, func(userID int64, todo *domain.MessageTodo, now int) (*domain.ChannelMessageAction, error) { + valid := make(map[int]struct{}, len(todo.Items)) + for _, item := range todo.Items { + valid[item.ID] = struct{}{} + } + done := make(map[int]domain.MessageTodoCompletion, len(todo.Completions)) + for _, c := range todo.Completions { + done[c.ID] = c + } + changed := false + for _, id := range req.Completed { + if _, ok := valid[id]; !ok { + return nil, messageIDInvalidErr() + } + if _, already := done[id]; !already { + done[id] = domain.MessageTodoCompletion{ID: id, CompletedBy: userID, Date: now} + changed = true + } + } + for _, id := range req.Incompleted { + if _, ok := valid[id]; !ok { + return nil, messageIDInvalidErr() + } + if _, exists := done[id]; exists { + delete(done, id) + changed = true + } + } + if !changed { + return nil, todoNotModifiedErr() + } + todo.Completions = todo.Completions[:0] + for _, item := range todo.Items { + if c, ok := done[item.ID]; ok { + todo.Completions = append(todo.Completions, c) + } + } + return &domain.ChannelMessageAction{ + Type: domain.ChannelActionTodoCompletions, + Completed: append([]int(nil), req.Completed...), + Incompleted: append([]int(nil), req.Incompleted...), + }, nil + }) +} + +// mutateTodoMedia 加载目标 todo 消息、应用变更并经 editMessage 媒体替换链路落库推送。 +func (r *Router) mutateTodoMedia(ctx context.Context, inputPeer tg.InputPeerClass, msgID int, participantAllowed func(todo *domain.MessageTodo) bool, mutate func(userID int64, todo *domain.MessageTodo, now int) (*domain.ChannelMessageAction, error)) (tg.UpdatesClass, error) { + userID, peer, err := r.reactionPeer(ctx, inputPeer, nil) + if err != nil { + return nil, err + } + now := int(r.clock.Now().Unix()) + current, found := r.loadTodoMessage(ctx, userID, peer, msgID) + if !found { + return nil, messageIDInvalidErr() + } + todo := *current.media.Todo + todo.Items = append([]domain.MessageTodoItem(nil), current.media.Todo.Items...) + todo.Completions = append([]domain.MessageTodoCompletion(nil), current.media.Todo.Completions...) + participantEdit := peer.Type == domain.PeerTypeUser && !current.out + if participantEdit && (participantAllowed == nil || !participantAllowed(&todo)) { + return nil, messageAuthorRequiredErr() + } + serviceAction, err := mutate(userID, &todo, now) + if err != nil { + return nil, err + } + newMedia := &domain.MessageMedia{Kind: domain.MessageMediaKindTodo, Todo: &todo} + + if peer.Type == domain.PeerTypeChannel { + if r.deps.Channels == nil { + return nil, peerIDInvalidErr() + } + res, err := r.deps.Channels.EditMessage(ctx, userID, domain.EditChannelMessageRequest{ + UserID: userID, + ChannelID: peer.ID, + ID: msgID, + Message: current.body, + Entities: current.entities, + Media: newMedia, + AllowTodoParticipantMutation: participantAllowed != nil && participantAllowed(&todo), + TodoServiceAction: serviceAction, + EditDate: now, + }) + if err != nil { + return nil, channelEditErr(err) + } + updates := r.channelEditMessageUpdates(ctx, userID, res) + r.enqueueChannelEditMessageFanout(ctx, userID, res) + return updates, nil + } + if peer.Type != domain.PeerTypeUser || r.deps.Messages == nil { + return nil, peerIDInvalidErr() + } + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + res, err := r.deps.Messages.EditMessage(ctx, userID, domain.EditMessageRequest{ + OwnerUserID: userID, + Peer: peer, + ID: msgID, + Message: current.body, + Entities: current.entities, + Media: newMedia, + EditDate: now, + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + AllowTodoParticipantMutation: participantEdit, + }) + if err != nil { + r.log.Warn("todo edit message failed", + zap.Int64("user_id", userID), + zap.String("peer_type", string(peer.Type)), + zap.Int64("peer_id", peer.ID), + zap.Int("msg_id", msgID), + zap.Bool("participant_edit", participantEdit), + zap.Error(err)) + return nil, messageEditErr(err) + } + self := res.Self() + if self.Event.Pts == 0 || self.Message.ID == 0 { + return nil, messageIDInvalidErr() + } + users := r.usersForMessageUpdate(ctx, userID, self.Message) + chats := r.chatsForMessageUpdate(ctx, userID, self.Message) + return tgEditMessageUpdates(self.Event, self.Message, users, chats), nil +} + +type todoMessageTarget struct { + media *domain.MessageMedia + body string + entities []domain.MessageEntity + out bool +} + +func (r *Router) loadTodoMessage(ctx context.Context, userID int64, peer domain.Peer, msgID int) (todoMessageTarget, bool) { + switch peer.Type { + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return todoMessageTarget{}, false + } + history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, []int{msgID}) + if err != nil { + return todoMessageTarget{}, false + } + for _, msg := range history.Messages { + if msg.ID == msgID && msg.Media != nil && msg.Media.Kind == domain.MessageMediaKindTodo && msg.Media.Todo != nil { + return todoMessageTarget{media: msg.Media, body: msg.Body, entities: msg.Entities}, true + } + } + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return todoMessageTarget{}, false + } + list, err := r.deps.Messages.GetMessages(ctx, userID, []int{msgID}) + if err != nil { + return todoMessageTarget{}, false + } + for _, msg := range list.Messages { + if msg.ID == msgID && msg.Peer == peer && msg.Media != nil && msg.Media.Kind == domain.MessageMediaKindTodo && msg.Media.Todo != nil { + return todoMessageTarget{media: msg.Media, body: msg.Body, entities: msg.Entities, out: msg.Out}, true + } + } + } + return todoMessageTarget{}, false +} + +func validateTodoItems(items []tg.TodoItem) error { + if len(items) == 0 { + return todoItemsEmptyErr() + } + if len(items) > maxTodoItems { + return limitInvalidErr() + } + seen := make(map[int]struct{}, len(items)) + for _, item := range items { + if item.ID < 0 || item.ID > maxTodoItemID { + return messageIDInvalidErr() + } + if item.ID != 0 { + if _, ok := seen[item.ID]; ok { + return tgerr.New(400, "TODO_ITEM_DUPLICATE") + } + seen[item.ID] = struct{}{} + } + if strings.TrimSpace(item.Title.Text) == "" || utf8.RuneCountInString(item.Title.Text) > maxTodoTitleLength { + return limitInvalidErr() + } + if len(item.Title.Entities) > maxMessageEntityCount { + return limitInvalidErr() + } + } + return nil +} + +func validateTodoIDVector(vectors ...[]int) error { + total := 0 + seen := map[int]struct{}{} + for _, ids := range vectors { + total += len(ids) + if total > maxTodoItems { + return limitInvalidErr() + } + for _, id := range ids { + if id <= 0 || id > maxTodoItemID { + return messageIDInvalidErr() + } + if _, ok := seen[id]; ok { + return todoNotModifiedErr() + } + seen[id] = struct{}{} + } + } + return nil +} diff --git a/internal/rpc/messages_todos_rpc_test.go b/internal/rpc/messages_todos_rpc_test.go new file mode 100644 index 00000000..ea397707 --- /dev/null +++ b/internal/rpc/messages_todos_rpc_test.go @@ -0,0 +1,392 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + + "telesrv/internal/domain" +) + +func sendTestTodo(t *testing.T, r *Router, fromID int64, peer tg.InputPeerClass, randomID int64) *tg.Message { + return sendTestTodoWithFlags(t, r, fromID, peer, randomID, false, true) +} + +func sendTestTodoWithFlags(t *testing.T, r *Router, fromID int64, peer tg.InputPeerClass, randomID int64, othersCanAppend, othersCanComplete bool) *tg.Message { + t.Helper() + updates, err := r.onMessagesSendMedia(WithUserID(context.Background(), fromID), &tg.MessagesSendMediaRequest{ + Peer: peer, + Media: &tg.InputMediaTodo{Todo: tg.TodoList{ + OthersCanAppend: othersCanAppend, + OthersCanComplete: othersCanComplete, + Title: tg.TextWithEntities{Text: "groceries", Entities: []tg.MessageEntityClass{}}, + List: []tg.TodoItem{ + {ID: 1, Title: tg.TextWithEntities{Text: "milk", Entities: []tg.MessageEntityClass{}}}, + {ID: 2, Title: tg.TextWithEntities{Text: "bread", Entities: []tg.MessageEntityClass{}}}, + }, + }}, + RandomID: randomID, + }) + if err != nil { + t.Fatalf("sendMedia todo: %v", err) + } + return newMessageFromUpdates(t, updates) +} + +func TestSendMediaTodoEcho(t *testing.T) { + r, owner, friend := newMediaTestRouter(t) + msg := sendTestTodo(t, r, owner.ID, &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, 7001) + media, ok := msg.Media.(*tg.MessageMediaToDo) + if !ok { + t.Fatalf("expected MessageMediaToDo, got %T", msg.Media) + } + if media.Todo.Title.Text != "groceries" || len(media.Todo.List) != 2 { + t.Fatalf("todo definition mangled: %+v", media.Todo) + } + if !media.Todo.OthersCanComplete { + t.Error("others_can_complete flag lost") + } + if _, has := media.GetCompletions(); has { + t.Error("fresh todo should have no completions") + } +} + +func TestToggleTodoCompletedAndAppend(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + ownerPeer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash} + ownerCtx := WithUserID(ctx, owner.ID) + + msg := sendTestTodo(t, r, owner.ID, ownerPeer, 7002) + + // 勾选 1 项。 + updates, err := r.onMessagesToggleTodoCompleted(ownerCtx, &tg.MessagesToggleTodoCompletedRequest{ + Peer: ownerPeer, + MsgID: msg.ID, + Completed: []int{1}, + }) + if err != nil { + t.Fatalf("toggleTodoCompleted: %v", err) + } + edited := editedMessageFromUpdates(t, updates) + todo := edited.Media.(*tg.MessageMediaToDo) + completions, has := todo.GetCompletions() + if !has || len(completions) != 1 || completions[0].ID != 1 { + t.Fatalf("completions = %+v, want item 1 completed", completions) + } + if peerUser, ok := completions[0].CompletedBy.(*tg.PeerUser); !ok || peerUser.UserID != owner.ID { + t.Fatalf("completed_by = %#v, want owner", completions[0].CompletedBy) + } + + // 取消勾选。 + updates, err = r.onMessagesToggleTodoCompleted(ownerCtx, &tg.MessagesToggleTodoCompletedRequest{ + Peer: ownerPeer, + MsgID: msg.ID, + Incompleted: []int{1}, + }) + if err != nil { + t.Fatalf("toggle incompleted: %v", err) + } + edited = editedMessageFromUpdates(t, updates) + todo = edited.Media.(*tg.MessageMediaToDo) + if completions, has := todo.GetCompletions(); has && len(completions) != 0 { + t.Fatalf("completions after untoggle = %+v, want empty", completions) + } + + // 空变更 → TODO_NOT_MODIFIED。 + if _, err := r.onMessagesToggleTodoCompleted(ownerCtx, &tg.MessagesToggleTodoCompletedRequest{ + Peer: ownerPeer, + MsgID: msg.ID, + Incompleted: []int{2}, + }); err == nil || !tgerr.Is(err, "TODO_NOT_MODIFIED") { + t.Fatalf("no-op toggle err = %v, want TODO_NOT_MODIFIED", err) + } + // 不存在的项。 + if _, err := r.onMessagesToggleTodoCompleted(ownerCtx, &tg.MessagesToggleTodoCompletedRequest{ + Peer: ownerPeer, + MsgID: msg.ID, + Completed: []int{9}, + }); err == nil || !tgerr.Is(err, "MESSAGE_ID_INVALID") { + t.Fatalf("unknown item toggle err = %v, want MESSAGE_ID_INVALID", err) + } + + // 追加两项。 + updates, err = r.onMessagesAppendTodoList(ownerCtx, &tg.MessagesAppendTodoListRequest{ + Peer: ownerPeer, + MsgID: msg.ID, + List: []tg.TodoItem{ + {ID: 3, Title: tg.TextWithEntities{Text: "eggs", Entities: []tg.MessageEntityClass{}}}, + {ID: 4, Title: tg.TextWithEntities{Text: "tea", Entities: []tg.MessageEntityClass{}}}, + }, + }) + if err != nil { + t.Fatalf("appendTodoList: %v", err) + } + edited = editedMessageFromUpdates(t, updates) + todo = edited.Media.(*tg.MessageMediaToDo) + if len(todo.Todo.List) != 4 { + t.Fatalf("items after append = %d, want 4", len(todo.Todo.List)) + } + // 重复 id 追加被拒。 + if _, err := r.onMessagesAppendTodoList(ownerCtx, &tg.MessagesAppendTodoListRequest{ + Peer: ownerPeer, + MsgID: msg.ID, + List: []tg.TodoItem{{ID: 3, Title: tg.TextWithEntities{Text: "dup", Entities: []tg.MessageEntityClass{}}}}, + }); err == nil || !tgerr.Is(err, "TODO_ITEM_DUPLICATE") { + t.Fatalf("duplicate append err = %v, want TODO_ITEM_DUPLICATE", err) + } +} + +func TestTodoMutationParticipantAllowed(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + ownerPeer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash} + friendPeer := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash} + + ownerMsg := sendTestTodo(t, r, owner.ID, ownerPeer, 7003) + friendMsg := privateMessageForPeer(t, r, friend.ID, domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}) + + updates, err := r.onMessagesToggleTodoCompleted(WithUserID(ctx, friend.ID), &tg.MessagesToggleTodoCompletedRequest{ + Peer: friendPeer, + MsgID: friendMsg.ID, + Completed: []int{1}, + }) + if err != nil { + t.Fatalf("participant toggleTodoCompleted: %v", err) + } + edited := editedMessageFromUpdates(t, updates) + todo := edited.Media.(*tg.MessageMediaToDo) + completions, has := todo.GetCompletions() + if !has || len(completions) != 1 || completions[0].ID != 1 { + t.Fatalf("participant response completions = %+v, want item 1", completions) + } + if peerUser, ok := completions[0].CompletedBy.(*tg.PeerUser); !ok || peerUser.UserID != friend.ID { + t.Fatalf("participant completed_by = %#v, want friend", completions[0].CompletedBy) + } + + ownerView, err := r.deps.Messages.GetMessages(ctx, owner.ID, []int{ownerMsg.ID}) + if err != nil { + t.Fatalf("owner get messages: %v", err) + } + if len(ownerView.Messages) != 1 || ownerView.Messages[0].Media == nil || ownerView.Messages[0].Media.Todo == nil { + t.Fatalf("owner todo view = %+v, want todo media", ownerView.Messages) + } + if got := ownerView.Messages[0].Media.Todo.Completions; len(got) != 1 || got[0].ID != 1 || got[0].CompletedBy != friend.ID { + t.Fatalf("owner durable completions = %+v, want friend completed item 1", got) + } +} + +func TestTodoMutationParticipantRequiresFlag(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + ownerPeer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash} + friendPeer := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash} + + sendTestTodoWithFlags(t, r, owner.ID, ownerPeer, 7004, false, false) + friendMsg := privateMessageForPeer(t, r, friend.ID, domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}) + if _, err := r.onMessagesToggleTodoCompleted(WithUserID(ctx, friend.ID), &tg.MessagesToggleTodoCompletedRequest{ + Peer: friendPeer, + MsgID: friendMsg.ID, + Completed: []int{1}, + }); err == nil || !tgerr.Is(err, "MESSAGE_AUTHOR_REQUIRED") { + t.Fatalf("participant toggle without flag err = %v, want MESSAGE_AUTHOR_REQUIRED", err) + } +} + +func TestChannelTodoMutationParticipantAllowedEmitsEditAndService(t *testing.T) { + f := newRPCChannelFixture(t) + owner := f.user(8101, "15550008101", "TodoOwner") + member := f.user(8102, "15550008102", "TodoMember") + channel := createTodoMegagroup(t, f, owner, member) + peer := inputPeerChannel(channel) + + msg := sendTestTodoWithFlags(t, f.router, owner.ID, peer, 81001, true, true) + updates, err := f.router.onMessagesToggleTodoCompleted(f.userCtx(member), &tg.MessagesToggleTodoCompletedRequest{ + Peer: peer, + MsgID: msg.ID, + Completed: []int{1}, + }) + if err != nil { + t.Fatalf("channel participant toggleTodoCompleted: %v", err) + } + edit := findUpdate[*tg.UpdateEditChannelMessage](t, updates) + service := findUpdate[*tg.UpdateNewChannelMessage](t, updates) + if service.Pts != edit.Pts+1 { + t.Fatalf("todo service pts = %d, edit pts = %d, want contiguous", service.Pts, edit.Pts) + } + edited, ok := edit.Message.(*tg.Message) + if !ok { + t.Fatalf("edit message = %T, want *tg.Message", edit.Message) + } + todo, ok := edited.Media.(*tg.MessageMediaToDo) + if !ok { + t.Fatalf("edit media = %T, want MessageMediaToDo", edited.Media) + } + completions, has := todo.GetCompletions() + if !has || len(completions) != 1 || completions[0].ID != 1 { + t.Fatalf("channel completions = %+v, want item 1 completed", completions) + } + if peerUser, ok := completions[0].CompletedBy.(*tg.PeerUser); !ok || peerUser.UserID != member.ID { + t.Fatalf("channel completed_by = %#v, want member", completions[0].CompletedBy) + } + if !todoUpdatesHaveUser(updates, member.ID) { + t.Fatalf("channel todo updates users missing completer %d", member.ID) + } + svcMsg, ok := service.Message.(*tg.MessageService) + if !ok { + t.Fatalf("service message = %T, want *tg.MessageService", service.Message) + } + action, ok := svcMsg.Action.(*tg.MessageActionTodoCompletions) + if !ok { + t.Fatalf("service action = %T, want MessageActionTodoCompletions", svcMsg.Action) + } + if len(action.Completed) != 1 || action.Completed[0] != 1 || len(action.Incompleted) != 0 { + t.Fatalf("service action payload = %+v, want completed item 1", action) + } + reply, ok := svcMsg.GetReplyTo() + if !ok { + t.Fatal("todo service message missing reply_to") + } + header, ok := reply.(*tg.MessageReplyHeader) + if !ok || header.ReplyToMsgID != msg.ID { + t.Fatalf("todo service reply = %#v, want original msg %d", reply, msg.ID) + } + + history, err := f.router.deps.Channels.GetMessages(f.ctx, member.ID, channel.ID, []int{msg.ID}) + if err != nil { + t.Fatalf("get channel todo: %v", err) + } + if len(history.Messages) != 1 || history.Messages[0].Media == nil || history.Messages[0].Media.Todo == nil { + t.Fatalf("channel stored todo = %+v, want todo media", history.Messages) + } + if got := history.Messages[0].Media.Todo.Completions; len(got) != 1 || got[0].ID != 1 || got[0].CompletedBy != member.ID { + t.Fatalf("stored channel completions = %+v, want member completed item 1", got) + } + + diff, err := f.router.deps.Channels.GetDifference(f.ctx, member.ID, domain.ChannelDifferenceRequest{ + ChannelID: channel.ID, + Pts: edit.Pts - 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel difference after todo mutation: %v", err) + } + if len(diff.OtherUpdates) != 1 || diff.OtherUpdates[0].Type != domain.ChannelUpdateEditMessage { + t.Fatalf("difference other updates = %+v, want edit update", diff.OtherUpdates) + } + if len(diff.NewMessages) != 1 || diff.NewMessages[0].Action == nil || diff.NewMessages[0].Action.Type != domain.ChannelActionTodoCompletions { + t.Fatalf("difference new messages = %+v, want todo completions service", diff.NewMessages) + } +} + +func TestChannelTodoAppendParticipantAllowedEmitsService(t *testing.T) { + f := newRPCChannelFixture(t) + owner := f.user(8111, "15550008111", "TodoOwner") + member := f.user(8112, "15550008112", "TodoMember") + channel := createTodoMegagroup(t, f, owner, member) + peer := inputPeerChannel(channel) + + msg := sendTestTodoWithFlags(t, f.router, owner.ID, peer, 81101, true, true) + updates, err := f.router.onMessagesAppendTodoList(f.userCtx(member), &tg.MessagesAppendTodoListRequest{ + Peer: peer, + MsgID: msg.ID, + List: []tg.TodoItem{ + {ID: 3, Title: tg.TextWithEntities{Text: "eggs", Entities: []tg.MessageEntityClass{}}}, + }, + }) + if err != nil { + t.Fatalf("channel participant appendTodoList: %v", err) + } + edit := findUpdate[*tg.UpdateEditChannelMessage](t, updates) + edited := edit.Message.(*tg.Message) + todo := edited.Media.(*tg.MessageMediaToDo) + if len(todo.Todo.List) != 3 { + t.Fatalf("channel todo items after append = %d, want 3", len(todo.Todo.List)) + } + service := findUpdate[*tg.UpdateNewChannelMessage](t, updates) + svcMsg := service.Message.(*tg.MessageService) + action, ok := svcMsg.Action.(*tg.MessageActionTodoAppendTasks) + if !ok { + t.Fatalf("service action = %T, want MessageActionTodoAppendTasks", svcMsg.Action) + } + if len(action.List) != 1 || action.List[0].ID != 3 || action.List[0].Title.Text != "eggs" { + t.Fatalf("append service payload = %+v, want appended task 3", action.List) + } +} + +func TestChannelTodoMutationParticipantRequiresFlag(t *testing.T) { + f := newRPCChannelFixture(t) + owner := f.user(8121, "15550008121", "TodoOwner") + member := f.user(8122, "15550008122", "TodoMember") + channel := createTodoMegagroup(t, f, owner, member) + peer := inputPeerChannel(channel) + + msg := sendTestTodoWithFlags(t, f.router, owner.ID, peer, 81201, false, false) + if _, err := f.router.onMessagesToggleTodoCompleted(f.userCtx(member), &tg.MessagesToggleTodoCompletedRequest{ + Peer: peer, + MsgID: msg.ID, + Completed: []int{1}, + }); err == nil || !tgerr.Is(err, "MESSAGE_AUTHOR_REQUIRED") { + t.Fatalf("channel participant toggle without flag err = %v, want MESSAGE_AUTHOR_REQUIRED", err) + } +} + +func todoUpdatesHaveUser(updates tg.UpdatesClass, userID int64) bool { + full, ok := updates.(*tg.Updates) + if !ok { + return false + } + for _, item := range full.Users { + user, ok := item.(*tg.User) + if ok && user.ID == userID { + return true + } + } + return false +} + +func createTodoMegagroup(t *testing.T, f *rpcChannelFixture, owner domain.User, members ...domain.User) *tg.Channel { + t.Helper() + created, err := f.router.onChannelsCreateChannel(f.userCtx(owner), &tg.ChannelsCreateChannelRequest{ + Title: "Todo Group", + Megagroup: true, + }) + if err != nil { + t.Fatalf("create todo megagroup: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + if len(members) > 0 { + users := make([]tg.InputUserClass, 0, len(members)) + for _, member := range members { + users = append(users, inputUser(member)) + } + if _, err := f.router.onChannelsInviteToChannel(f.userCtx(owner), &tg.ChannelsInviteToChannelRequest{ + Channel: inputChannel(channel), + Users: users, + }); err != nil { + t.Fatalf("invite todo members: %v", err) + } + } + return channel +} + +func privateMessageForPeer(t *testing.T, r *Router, userID int64, peer domain.Peer) domain.Message { + t.Helper() + list, err := r.deps.Messages.GetHistory(context.Background(), userID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("get history: %v", err) + } + for _, msg := range list.Messages { + if msg.Peer == peer { + return msg + } + } + t.Fatalf("no message for user %d peer %+v", userID, peer) + return domain.Message{} +} diff --git a/internal/rpc/messages_ttl.go b/internal/rpc/messages_ttl.go new file mode 100644 index 00000000..bd3ca1f4 --- /dev/null +++ b/internal/rpc/messages_ttl.go @@ -0,0 +1,111 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/tg" + "telesrv/internal/domain" +) + +// maxHistoryTTLPeriod 限制自毁倒计时上界(366 天)。无上界时客户端可传接近 +// int32 上限的 period,使发送路径 expires_at = date + ttl_period 经 int32 截断 +// 回绕为负值,被到期扫描的 expires_at>0 过滤掉,导致 TTL 静默失效(消息以为 +// 会自毁实则永久留存)。 +const maxHistoryTTLPeriod = 366 * 24 * 3600 + +func (r *Router) onMessagesGetDefaultHistoryTTL(ctx context.Context) (*tg.DefaultHistoryTTL, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + period := 0 + if ttlSvc, ok := r.deps.Messages.(historyTTLMessagesService); r.deps.Messages != nil && ok { + period, err = ttlSvc.DefaultHistoryTTL(ctx, userID) + if err != nil { + return nil, internalErr() + } + } + return &tg.DefaultHistoryTTL{Period: period}, nil +} + +func (r *Router) onMessagesSetDefaultHistoryTTL(ctx context.Context, period int) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if period < 0 || period > maxHistoryTTLPeriod { + return false, ttlPeriodInvalidErr() + } + if ttlSvc, ok := r.deps.Messages.(historyTTLMessagesService); r.deps.Messages != nil && ok { + if err := ttlSvc.SetDefaultHistoryTTL(ctx, userID, period); err != nil { + return false, internalErr() + } + } + return true, nil +} + +func (r *Router) onMessagesSetHistoryTTL(ctx context.Context, req *tg.MessagesSetHistoryTTLRequest) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req.Period < 0 || req.Period > maxHistoryTTLPeriod { + return nil, ttlPeriodInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + switch peer.Type { + case domain.PeerTypeUser: + if ttlSvc, ok := r.deps.Messages.(historyTTLMessagesService); r.deps.Messages != nil && ok { + if err := ttlSvc.SetPrivateHistoryTTL(ctx, userID, peer, req.Period); err != nil { + return nil, internalErr() + } + } + case domain.PeerTypeChannel: + ttlSvc, ok := r.deps.Channels.(channelHistoryTTLService) + if r.deps.Channels == nil || !ok { + return nil, channelInvalidErr(domain.ErrChannelInvalid) + } + channel, recipients, err := ttlSvc.SetHistoryTTL(ctx, userID, peer.ID, req.Period, int(r.clock.Now().Unix())) + if err != nil { + return nil, channelInvalidErr(err) + } + date := int(r.clock.Now().Unix()) + out := r.peerHistoryTTLUpdates(ctx, userID, peer, req.Peer, req.Period, date) + r.pushChannelUpdates(ctx, userID, channel.ID, recipients, func(viewerUserID int64) *tg.Updates { + return r.peerHistoryTTLUpdates(ctx, viewerUserID, peer, req.Peer, req.Period, date) + }) + return out, nil + default: + return nil, peerIDInvalidErr() + } + date := int(r.clock.Now().Unix()) + out := r.peerHistoryTTLUpdates(ctx, userID, peer, req.Peer, req.Period, date) + r.pushUserUpdates(ctx, userID, out) + if peer.Type == domain.PeerTypeUser && peer.ID != userID { + otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + r.pushUserUpdates(ctx, peer.ID, r.peerHistoryTTLUpdates(ctx, peer.ID, otherPeer, nil, req.Period, date)) + } + return out, nil +} + +func (r *Router) peerHistoryTTLUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, inputPeer tg.InputPeerClass, period int, date int) *tg.Updates { + update := &tg.UpdatePeerHistoryTTL{Peer: tgPeer(peer)} + if period > 0 { + update.SetTTLPeriod(period) + } + chats := []tg.ChatClass{} + if inputPeer != nil { + chats = r.chatsForInputPeer(ctx, viewerUserID, inputPeer) + } else if peer.Type == domain.PeerTypeChannel { + chats = r.chatsForMessageUpdate(ctx, viewerUserID, domain.Message{Peer: peer}) + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: []tg.UserClass{}, + Chats: chats, + Date: date, + Seq: 0, + } +} diff --git a/internal/rpc/messages_upload_media_android_compat_test.go b/internal/rpc/messages_upload_media_android_compat_test.go new file mode 100644 index 00000000..fb1a6949 --- /dev/null +++ b/internal/rpc/messages_upload_media_android_compat_test.go @@ -0,0 +1,61 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestLegacyAndroidMessagesUploadMediaDispatch 验证 DrKLO Android 仍发出的旧版 +// messages.uploadMedia 构造器 #519bc2b1 被 compat 分发到真实 onMessagesUploadMedia +// (而非落到 fallback 的 NOT_IMPLEMENTED)。这是「单附件发送正常、多附件发送失败」的 +// 根因修复——相册在 sendMultiMedia 前会对每个附件先 uploadMedia,任一失败即整组 markAsError。 +func TestLegacyAndroidMessagesUploadMediaDispatch(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550001061", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Files: &fakeFiles{}, + }, zaptest.NewLogger(t), clock.System) + + // 按 DrKLO 字段序构造原始 wire:constructor + peer:InputPeer + media:InputMedia(均为 boxed)。 + var in bin.Buffer + in.PutID(0x519bc2b1) + if err := (&tg.InputPeerSelf{}).Encode(&in); err != nil { + t.Fatalf("encode peer: %v", err) + } + if err := (&tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 10, Parts: 1, Name: "a.jpg"}}).Encode(&in); err != nil { + t.Fatalf("encode media: %v", err) + } + + enc, err := r.Dispatch(WithUserID(androidClientContext(), owner.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch legacy uploadMedia: %v", err) + } + // Routed through the unified layerwire inbound body-transform + the normal + // gotd dispatcher, which boxes a class result (MessageMedia) as *...Box. + box, ok := enc.(*tg.MessageMediaBox) + if !ok { + t.Fatalf("response = %T, want *tg.MessageMediaBox", enc) + } + media, ok := box.MessageMedia.(*tg.MessageMediaPhoto) + if !ok { + t.Fatalf("media = %T, want messageMediaPhoto", box.MessageMedia) + } + photo, ok := media.Photo.(*tg.Photo) + if !ok || photo.ID != 777 { + t.Fatalf("photo = %T %+v, want photo id 777", media.Photo, media.Photo) + } +} diff --git a/internal/rpc/messages_webpage_preview_rpc_test.go b/internal/rpc/messages_webpage_preview_rpc_test.go new file mode 100644 index 00000000..d87e02c1 --- /dev/null +++ b/internal/rpc/messages_webpage_preview_rpc_test.go @@ -0,0 +1,77 @@ +package rpc + +import ( + "context" + "errors" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// TestWebPagePreviewMedia 验证 getWebPagePreview 的 media 决策:done 卡片→messageMediaWebPage, +// 无链接/解析失败/非 done 一律 messageMediaEmpty(绝不返回 pending、绝不报错)。 +func TestWebPagePreviewMedia(t *testing.T) { + ctx := context.Background() + urlEntities := []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: 0, Length: 9}} // "https://x" + + doneCard := domain.MessageWebPage{ + State: domain.MessageWebPageStateDone, + ID: 42, + URL: "https://x", + DisplayURL: "x", + Title: "Hello", + Photo: &domain.Photo{ + ID: 7, AccessHash: 8, DCID: 2, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 320, H: 200, Size: 1024}}, + }, + } + + files := &fakeFiles{resolveWebPageFn: func(string) (domain.MessageWebPage, error) { return doneCard, nil }} + r := &Router{deps: Deps{Files: files}} + + t.Run("done-card", func(t *testing.T) { + media := r.webPagePreviewMedia(ctx, "https://x", urlEntities) + wrap, ok := media.(*tg.MessageMediaWebPage) + if !ok { + t.Fatalf("media = %T, want *tg.MessageMediaWebPage", media) + } + page, ok := wrap.Webpage.(*tg.WebPage) + if !ok { + t.Fatalf("webpage = %T, want *tg.WebPage", wrap.Webpage) + } + if v, _ := page.GetTitle(); v != "Hello" { + t.Fatalf("title = %q, want Hello", v) + } + }) + + t.Run("no-url", func(t *testing.T) { + if media := r.webPagePreviewMedia(ctx, "plain text", nil); !isEmptyMedia(media) { + t.Fatalf("media = %T, want empty", media) + } + }) + + t.Run("resolve-error-degrades", func(t *testing.T) { + files.resolveWebPageFn = func(string) (domain.MessageWebPage, error) { + return domain.MessageWebPage{}, errors.New("boom") + } + if media := r.webPagePreviewMedia(ctx, "https://x", urlEntities); !isEmptyMedia(media) { + t.Fatalf("media = %T, want empty on error", media) + } + }) + + t.Run("non-done-state-degrades", func(t *testing.T) { + files.resolveWebPageFn = func(string) (domain.MessageWebPage, error) { + return domain.MessageWebPage{State: domain.MessageWebPageStateEmpty, ID: 1, URL: "https://x"}, nil + } + if media := r.webPagePreviewMedia(ctx, "https://x", urlEntities); !isEmptyMedia(media) { + t.Fatalf("media = %T, want empty for empty-state webpage", media) + } + }) +} + +func isEmptyMedia(m tg.MessageMediaClass) bool { + _, ok := m.(*tg.MessageMediaEmpty) + return ok +} diff --git a/internal/rpc/messages_webview.go b/internal/rpc/messages_webview.go new file mode 100644 index 00000000..164954e5 --- /dev/null +++ b/internal/rpc/messages_webview.go @@ -0,0 +1,746 @@ +package rpc + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "net/url" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func (r *Router) onMessagesRequestWebView(ctx context.Context, req *tg.MessagesRequestWebViewRequest) (*tg.WebViewResultURL, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 || r.deps.Users == nil || r.deps.Bots == nil { + return nil, botInvalidErr() + } + user, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return nil, internalErr() + } + bot, profile, err := r.webViewBotFromInput(ctx, userID, req.Bot) + if err != nil { + return nil, err + } + rawURL, err := webViewRequestURL(req.GetURL, req.FromBotMenu, profile) + if err != nil { + return nil, err + } + startParam, err := webViewStartParam(req.GetStartParam) + if err != nil { + return nil, err + } + if err := validateWebViewPlatform(req.Platform); err != nil { + return nil, err + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + replyTo, err := r.messageReplyFromInput(ctx, userID, peer, req.ReplyTo) + if err != nil { + return nil, err + } + sendAs, err := r.resolveSendAsPeer(ctx, userID, peer, req.SendAs) + if err != nil { + return nil, err + } + now := r.clock.Now() + session := r.webviews.registerContext(ctx, now, store.WebViewSession{ + BotUserID: bot.ID, + UserID: userID, + Peer: peer, + Source: "webview", + StartParam: startParam, + Silent: req.Silent, + ReplyTo: replyTo, + SendAs: sendAs, + }) + signedURL, err := webViewURLWithInitData(rawURL, session.BotQueryID, profile, user, startParam, req.Platform, now) + if err != nil { + return nil, internalErr() + } + out := &tg.WebViewResultURL{URL: signedURL} + out.SetQueryID(session.QueryID) + if req.Fullscreen { + out.SetFullscreen(true) + } + return out, nil +} + +func (r *Router) onMessagesRequestSimpleWebView(ctx context.Context, req *tg.MessagesRequestSimpleWebViewRequest) (*tg.WebViewResultURL, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 || r.deps.Users == nil { + return nil, botInvalidErr() + } + bot, found, err := r.userFromInput(ctx, userID, req.Bot) + if err != nil { + return nil, internalErr() + } + if !found || !bot.Bot { + return nil, botInvalidErr() + } + urlValue, ok := req.GetURL() + if !ok || validateInlineWebURL(urlValue, true) != nil { + return nil, urlInvalidErr() + } + if startParam, ok := req.GetStartParam(); ok && !validInlineStartParam(startParam) { + return nil, startParamInvalidErr() + } + if req.Platform == "" || utf8.RuneCountInString(req.Platform) > domain.MaxBotInlineSwitchTextLen || strings.TrimSpace(req.Platform) != req.Platform { + return nil, platformInvalidErr() + } + out := &tg.WebViewResultURL{URL: urlValue} + if req.Fullscreen { + out.SetFullscreen(true) + } + return out, nil +} + +func (r *Router) onMessagesGetBotApp(ctx context.Context, req *tg.MessagesGetBotAppRequest) (*tg.MessagesBotApp, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 || r.deps.Users == nil || r.deps.Bots == nil { + return nil, botAppInvalidErr() + } + _, _, appDomain, app, _, err := r.webViewBotAppFromInput(ctx, userID, req.App) + if err != nil { + return nil, err + } + out := &tg.MessagesBotApp{} + if req.Hash != 0 && req.Hash == appDomain.Hash { + out.App = &tg.BotAppNotModified{} + return out, nil + } + out.App = app + return out, nil +} + +func (r *Router) onMessagesRequestAppWebView(ctx context.Context, req *tg.MessagesRequestAppWebViewRequest) (*tg.WebViewResultURL, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 || r.deps.Users == nil || r.deps.Bots == nil { + return nil, botAppInvalidErr() + } + user, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return nil, internalErr() + } + bot, profile, app, _, rawURL, err := r.webViewBotAppFromInput(ctx, userID, req.App) + if err != nil { + return nil, err + } + startParam, err := webViewStartParam(req.GetStartParam) + if err != nil { + return nil, err + } + if err := validateWebViewPlatform(req.Platform); err != nil { + return nil, err + } + peer, err := r.webViewPeerFromInput(ctx, userID, req.Peer, bot.ID) + if err != nil { + return nil, err + } + if req.WriteAllowed { + created, err := r.deps.Bots.AllowSendMessage(ctx, userID, bot.ID, true) + if err != nil { + return nil, botInvalidErr() + } + if created { + res, err := r.sendBotAllowedServiceMessageWith(ctx, userID, bot.ID, domain.MessageBotAllowedAction{FromRequest: true}) + if err != nil { + return nil, internalErr() + } + if !res.Duplicate { + senderUsers := r.usersForMessageUpdate(ctx, userID, res.SenderMessage) + senderChats := r.chatsForMessageUpdate(ctx, userID, res.SenderMessage) + r.pushUserMessage(ctx, userID, "push webview write allowed", tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, 0, false, senderUsers, senderChats)) + } + } + } + now := r.clock.Now() + session := r.webviews.registerContext(ctx, now, store.WebViewSession{ + BotUserID: bot.ID, + UserID: userID, + Peer: peer, + AppID: app.ID, + Source: "app", + StartParam: startParam, + WriteAllowed: req.WriteAllowed, + }) + signedURL, err := webViewURLWithInitData(rawURL, session.BotQueryID, profile, user, startParam, req.Platform, now) + if err != nil { + return nil, internalErr() + } + out := &tg.WebViewResultURL{URL: signedURL} + out.SetQueryID(session.QueryID) + if req.Fullscreen { + out.SetFullscreen(true) + } + return out, nil +} + +func (r *Router) onMessagesRequestMainWebView(ctx context.Context, req *tg.MessagesRequestMainWebViewRequest) (*tg.WebViewResultURL, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if userID == 0 || r.deps.Users == nil || r.deps.Bots == nil { + return nil, botInvalidErr() + } + user, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return nil, internalErr() + } + bot, profile, err := r.webViewBotFromInput(ctx, userID, req.Bot) + if err != nil { + return nil, err + } + var app domain.BotApp + if mainApp, found, err := r.deps.Bots.GetMainBotApp(ctx, bot.ID); err != nil { + return nil, internalErr() + } else if found { + app = mainApp + } else { + rawURL, err := webViewMenuAppURL(profile, botWebviewDisabledErr()) + if err != nil { + return nil, err + } + app = domain.BotApp{ + ID: bot.ID, + BotUserID: bot.ID, + ShortName: "main", + Title: profile.MenuButton.Text, + Description: profile.Description, + URL: rawURL, + AccessHash: menuBackedBotAppAccessHash(profile), + Hash: menuBackedBotAppHash(bot, profile, "main"), + Main: true, + } + } + rawURL := app.URL + startParam, err := webViewStartParam(req.GetStartParam) + if err != nil { + return nil, err + } + if err := validateWebViewPlatform(req.Platform); err != nil { + return nil, err + } + peer, err := r.webViewPeerFromInput(ctx, userID, req.Peer, bot.ID) + if err != nil { + return nil, err + } + now := r.clock.Now() + session := r.webviews.registerContext(ctx, now, store.WebViewSession{ + BotUserID: bot.ID, + UserID: userID, + Peer: peer, + AppID: app.ID, + Source: "main", + StartParam: startParam, + }) + signedURL, err := webViewURLWithInitData(rawURL, session.BotQueryID, profile, user, startParam, req.Platform, now) + if err != nil { + return nil, internalErr() + } + out := &tg.WebViewResultURL{URL: signedURL} + out.SetQueryID(session.QueryID) + if req.Fullscreen { + out.SetFullscreen(true) + } + return out, nil +} + +func (r *Router) onMessagesProlongWebView(ctx context.Context, req *tg.MessagesProlongWebViewRequest) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if userID == 0 || r.deps.Users == nil || r.deps.Bots == nil || req.QueryID == 0 { + return false, queryIDInvalidErr() + } + bot, _, err := r.webViewBotFromInput(ctx, userID, req.Bot) + if err != nil { + return false, err + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + replyTo, err := r.messageReplyFromInput(ctx, userID, peer, req.ReplyTo) + if err != nil { + return false, err + } + sendAs, err := r.resolveSendAsPeer(ctx, userID, peer, req.SendAs) + if err != nil { + return false, err + } + if !r.webviews.prolongContext(ctx, r.clock.Now(), req.QueryID, userID, bot.ID, peer, req.Silent, replyTo, sendAs) { + return false, queryIDInvalidErr() + } + return true, nil +} + +func (r *Router) onMessagesSendWebViewResultMessage(ctx context.Context, req *tg.MessagesSendWebViewResultMessageRequest) (*tg.WebViewMessageSent, error) { + botID, err := r.callerBotID(ctx) + if err != nil { + return nil, err + } + if req.BotQueryID == "" { + return nil, queryIDInvalidErr() + } + result, err := r.domainInlineResultFromTG(ctx, botID, req.Result) + if err != nil { + return nil, err + } + if err := r.sendWebViewDomainResultMessage(ctx, botID, req.BotQueryID, result); err != nil { + return nil, err + } + return &tg.WebViewMessageSent{}, nil +} + +// AnswerWebAppQueryFromBotAPI answers a Bot API answerWebAppQuery request using +// the same webview session registry and message pipeline as MTProto +// messages.sendWebViewResultMessage. It intentionally accepts only domain +// values, keeping Bot API parsing out of the MTProto/tg boundary. +func (r *Router) AnswerWebAppQueryFromBotAPI(ctx context.Context, botID int64, botQueryID string, result domain.BotInlineResult) (string, error) { + if botID == 0 || strings.TrimSpace(botQueryID) == "" { + return "", queryIDInvalidErr() + } + if r.deps.Bots == nil { + return "", userBotRequiredErr() + } + if _, found, err := r.deps.Bots.BotInfo(ctx, botID); err != nil { + return "", internalErr() + } else if !found { + return "", userBotRequiredErr() + } + if err := r.sendWebViewDomainResultMessage(ctx, botID, botQueryID, result); err != nil { + return "", err + } + return "", nil +} + +// SavePreparedInlineMessageFromBotAPI stores a Bot API prepared inline message +// in the same short-lived local/shared inline registry used by +// messages.savePreparedInlineMessage. +func (r *Router) SavePreparedInlineMessageFromBotAPI(ctx context.Context, botID, userID int64, result domain.BotInlineResult, peerTypes []string) (string, int, error) { + if botID == 0 { + return "", 0, userBotRequiredErr() + } + if userID == 0 || r.deps.Users == nil || r.deps.Bots == nil { + return "", 0, userIDInvalidErr() + } + if _, found, err := r.deps.Bots.BotInfo(ctx, botID); err != nil { + return "", 0, internalErr() + } else if !found { + return "", 0, userBotRequiredErr() + } + if _, found, err := r.deps.Users.ByID(ctx, botID, userID); err != nil { + return "", 0, internalErr() + } else if !found { + return "", 0, userIDInvalidErr() + } + id, expireDate := r.inlines.savePreparedInlineContext(ctx, r.clock.Now(), botID, userID, result, peerTypes) + return id, expireDate, nil +} + +func (r *Router) sendWebViewDomainResultMessage(ctx context.Context, botID int64, botQueryID string, result domain.BotInlineResult) error { + now := r.clock.Now() + session, ok := r.webviews.sessionForBotQueryContext(ctx, now, botID, botQueryID) + if !ok { + return queryIDInvalidErr() + } + if err := r.checkSendRateLimit(ctx, session.UserID, 1); err != nil { + return err + } + media := cloneInlineMedia(result.Media) + if result.MediaAuto { + if result.Content != nil { + r.inlines.registerWebDocumentContext(ctx, now, *result.Content, webViewSessionTTL) + } + var err error + media, err = r.domainInlineExternalContentMedia(ctx, result) + if err != nil { + return err + } + } + r.resolveInlineContactMediaForSender(ctx, session.UserID, media) + _, _, err := r.sendOutgoing(ctx, session.UserID, session.Peer, outgoingSend{ + randomID: randomNonZeroInt64(), + message: result.Message, + entities: tgMessageEntities(result.Entities), + media: media, + silent: session.Silent, + replyTo: session.ReplyTo, + replyToReady: true, + sendAs: session.SendAs, + sendAsReady: true, + replyMarkup: result.ReplyMarkup, + viaBotID: botID, + }) + if err != nil { + return err + } + r.webviews.consumeContext(ctx, session.QueryID, session.BotQueryID) + r.pushUserMessage(ctx, session.UserID, "push webview result sent", &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateWebViewResultSent{QueryID: session.QueryID}}, + Date: int(now.Unix()), + }) + return nil +} + +func (r *Router) webViewBotFromInput(ctx context.Context, userID int64, input tg.InputUserClass) (domain.User, domain.BotProfile, error) { + bot, found, err := r.userFromInput(ctx, userID, input) + if err != nil { + return domain.User{}, domain.BotProfile{}, internalErr() + } + if !found || !bot.Bot { + return domain.User{}, domain.BotProfile{}, botInvalidErr() + } + profile, found, err := r.deps.Bots.BotInfo(ctx, bot.ID) + if err != nil { + return domain.User{}, domain.BotProfile{}, internalErr() + } + if !found { + return domain.User{}, domain.BotProfile{}, botInvalidErr() + } + return bot, profile, nil +} + +func (r *Router) webViewBotAppFromInput(ctx context.Context, userID int64, input tg.InputBotAppClass) (domain.User, domain.BotProfile, domain.BotApp, *tg.BotApp, string, error) { + switch app := input.(type) { + case *tg.InputBotAppShortName: + if !validBotAppShortName(app.ShortName) { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", botAppShortNameInvalidErr() + } + bot, profile, err := r.webViewBotAppOwnerFromInput(ctx, userID, app.BotID) + if err != nil { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", err + } + domainApp, found, err := r.deps.Bots.GetBotAppByShortName(ctx, bot.ID, app.ShortName) + if err != nil { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", internalErr() + } + if !found { + domainApp, err = r.ensureMenuBackedBotApp(ctx, bot, profile, app.ShortName) + if err != nil { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", err + } + } + if domainApp.Inactive || validateInlineWebURL(domainApp.URL, true) != nil { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", botAppInvalidErr() + } + return bot, profile, domainApp, r.tgBotAppFromDomain(ctx, domainApp), domainApp.URL, nil + case *tg.InputBotAppID: + if app.ID <= 0 { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", botAppInvalidErr() + } + domainApp, found, err := r.deps.Bots.GetBotAppByID(ctx, app.ID, app.AccessHash) + if err != nil { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", internalErr() + } + if !found || domainApp.Inactive || validateInlineWebURL(domainApp.URL, true) != nil { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", botAppInvalidErr() + } + bot, found, err := r.deps.Users.ByID(ctx, userID, domainApp.BotUserID) + if err != nil { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", internalErr() + } + if !found || !bot.Bot { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", botAppInvalidErr() + } + profile, found, err := r.deps.Bots.BotInfo(ctx, bot.ID) + if err != nil { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", internalErr() + } + if !found { + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", botAppInvalidErr() + } + return bot, profile, domainApp, r.tgBotAppFromDomain(ctx, domainApp), domainApp.URL, nil + default: + return domain.User{}, domain.BotProfile{}, domain.BotApp{}, nil, "", botAppInvalidErr() + } +} + +func (r *Router) ensureMenuBackedBotApp(ctx context.Context, bot domain.User, profile domain.BotProfile, shortName string) (domain.BotApp, error) { + rawURL, err := webViewMenuAppURL(profile, botAppInvalidErr()) + if err != nil { + return domain.BotApp{}, err + } + title := profile.MenuButton.Text + if title == "" { + title = bot.FirstName + } + app := domain.BotApp{ + BotUserID: bot.ID, + ShortName: strings.ToLower(shortName), + Title: title, + Description: profile.Description, + URL: rawURL, + Main: strings.EqualFold(shortName, "main"), + RequestWriteAccess: true, + } + out, _, err := r.deps.Bots.UpsertBotApp(ctx, bot.ID, app) + if err != nil { + return domain.BotApp{}, botAppInvalidErr() + } + return out, nil +} + +func (r *Router) webViewBotAppOwnerFromInput(ctx context.Context, userID int64, input tg.InputUserClass) (domain.User, domain.BotProfile, error) { + bot, found, err := r.userFromInput(ctx, userID, input) + if err != nil { + return domain.User{}, domain.BotProfile{}, internalErr() + } + if !found || !bot.Bot { + return domain.User{}, domain.BotProfile{}, botAppBotInvalidErr() + } + profile, found, err := r.deps.Bots.BotInfo(ctx, bot.ID) + if err != nil { + return domain.User{}, domain.BotProfile{}, internalErr() + } + if !found { + return domain.User{}, domain.BotProfile{}, botAppBotInvalidErr() + } + return bot, profile, nil +} + +func (r *Router) webViewPeerFromInput(ctx context.Context, userID int64, input tg.InputPeerClass, fallbackBotID int64) (domain.Peer, error) { + switch input.(type) { + case nil, *tg.InputPeerEmpty: + if fallbackBotID <= 0 { + return domain.Peer{}, peerIDInvalidErr() + } + return domain.Peer{Type: domain.PeerTypeUser, ID: fallbackBotID}, nil + default: + return r.checkedDomainPeerFromInputPeer(ctx, userID, input) + } +} + +func webViewRequestURL(getURL func() (string, bool), fromBotMenu bool, profile domain.BotProfile) (string, error) { + rawURL, ok := getURL() + if !ok { + if fromBotMenu && profile.MenuButton.Type == domain.BotMenuButtonWebView { + rawURL = profile.MenuButton.URL + } else if fromBotMenu { + return "", botWebviewDisabledErr() + } else { + return "", urlInvalidErr() + } + } + if validateInlineWebURL(rawURL, true) != nil { + return "", urlInvalidErr() + } + return rawURL, nil +} + +func webViewMenuAppURL(profile domain.BotProfile, disabledErr error) (string, error) { + if profile.MenuButton.Type != domain.BotMenuButtonWebView || profile.MenuButton.URL == "" { + return "", disabledErr + } + if validateInlineWebURL(profile.MenuButton.URL, true) != nil { + return "", disabledErr + } + return profile.MenuButton.URL, nil +} + +func validBotAppShortName(shortName string) bool { + if shortName == "" || len(shortName) > 64 { + return false + } + for _, r := range shortName { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_': + default: + return false + } + } + return true +} + +func (r *Router) tgBotAppFromDomain(ctx context.Context, in domain.BotApp) *tg.BotApp { + out := &tg.BotApp{ + ID: in.ID, + AccessHash: in.AccessHash, + ShortName: in.ShortName, + Title: in.Title, + Description: in.Description, + Photo: &tg.PhotoEmpty{ID: in.PhotoID}, + Hash: in.Hash, + } + if in.PhotoID != 0 && r.deps.Files != nil { + if photo, found, err := r.deps.Files.GetPhoto(ctx, in.PhotoID); err == nil && found { + out.Photo = tgPhoto(photo) + } + } + if out.Photo == nil { + out.Photo = &tg.PhotoEmpty{ID: in.ID} + } + if in.DocumentID != 0 && r.deps.Files != nil { + if doc, found, err := r.deps.Files.GetDocument(ctx, in.DocumentID); err == nil && found { + out.SetDocument(tgDocument(doc)) + } + } + return out +} + +func tgMenuBackedBotApp(bot domain.User, profile domain.BotProfile, shortName string) *tg.BotApp { + title := profile.MenuButton.Text + if title == "" { + title = bot.FirstName + } + app := &tg.BotApp{ + ID: bot.ID, + AccessHash: menuBackedBotAppAccessHash(profile), + ShortName: shortName, + Title: title, + Description: profile.Description, + Photo: &tg.PhotoEmpty{ID: bot.ID}, + Hash: menuBackedBotAppHash(bot, profile, shortName), + } + return app +} + +func menuBackedBotAppAccessHash(profile domain.BotProfile) int64 { + return stableBotAppInt64("access", strconv.FormatInt(profile.BotUserID, 10), profile.TokenSecret, profile.MenuButton.URL) +} + +func menuBackedBotAppHash(bot domain.User, profile domain.BotProfile, shortName string) int64 { + return stableBotAppInt64( + "hash", + strconv.FormatInt(profile.BotUserID, 10), + shortName, + profile.MenuButton.Text, + profile.MenuButton.URL, + profile.Description, + bot.FirstName, + bot.Username, + ) +} + +func stableBotAppInt64(parts ...string) int64 { + hash := sha256.New() + for _, part := range parts { + _, _ = hash.Write([]byte(part)) + _, _ = hash.Write([]byte{0}) + } + sum := hash.Sum(nil) + value := int64(binary.BigEndian.Uint64(sum[:8]) & 0x7fffffffffffffff) + if value == 0 { + return 1 + } + return value +} + +func webViewStartParam(getStartParam func() (string, bool)) (string, error) { + startParam, ok := getStartParam() + if ok && !validInlineStartParam(startParam) { + return "", startParamInvalidErr() + } + return startParam, nil +} + +func validateWebViewPlatform(platform string) error { + if platform == "" || utf8.RuneCountInString(platform) > domain.MaxBotInlineSwitchTextLen || strings.TrimSpace(platform) != platform { + return platformInvalidErr() + } + return nil +} + +func webViewURLWithInitData(rawURL, botQueryID string, profile domain.BotProfile, user domain.User, startParam, platform string, now time.Time) (string, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return "", err + } + initData, err := signedWebViewInitData(botQueryID, profile, user, startParam, now) + if err != nil { + return "", err + } + q := parsed.Query() + q.Set("tgWebAppData", initData) + q.Set("tgWebAppVersion", "6.0") + q.Set("tgWebAppPlatform", platform) + parsed.RawQuery = q.Encode() + return parsed.String(), nil +} + +func signedWebViewInitData(botQueryID string, profile domain.BotProfile, user domain.User, startParam string, now time.Time) (string, error) { + userJSON, err := json.Marshal(webViewInitUser{ + ID: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Username: user.Username, + }) + if err != nil { + return "", err + } + values := url.Values{} + values.Set("query_id", botQueryID) + values.Set("user", string(userJSON)) + values.Set("auth_date", strconv.FormatInt(now.Unix(), 10)) + if startParam != "" { + values.Set("start_param", startParam) + } + token := domain.FormatBotToken(profile.BotUserID, profile.TokenSecret) + values.Set("hash", webViewInitDataHash(values, token)) + return values.Encode(), nil +} + +type webViewInitUser struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name,omitempty"` + Username string `json:"username,omitempty"` +} + +func webViewInitDataHash(values url.Values, botToken string) string { + check := webViewDataCheckString(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 webViewDataCheckString(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() +} diff --git a/internal/rpc/messages_webview_rpc_test.go b/internal/rpc/messages_webview_rpc_test.go new file mode 100644 index 00000000..bd8d201f --- /dev/null +++ b/internal/rpc/messages_webview_rpc_test.go @@ -0,0 +1,428 @@ +package rpc + +import ( + "context" + "net/url" + "strconv" + "strings" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +func TestMessagesRequestWebViewProlongAndSendResult(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + req := &tg.MessagesRequestWebViewRequest{ + Peer: inputPeerUser(f.peer), + Bot: inputUser(f.bot), + Platform: "tdesktop", + Silent: true, + } + req.SetURL("https://example.test/app?existing=1") + req.SetStartParam("start_1") + got, err := f.router.onMessagesRequestWebView(ownerCtx, req) + if err != nil { + t.Fatalf("request webview: %v", err) + } + queryID, ok := got.GetQueryID() + if !ok || queryID == 0 { + t.Fatalf("request webview query_id = %d,%v, want non-zero", queryID, ok) + } + initData := webViewInitDataFromURL(t, got.URL) + botQueryID := initData.Get("query_id") + if botQueryID != strconv.FormatInt(queryID, 10) { + t.Fatalf("init query_id = %q, want %d", botQueryID, queryID) + } + if initData.Get("hash") == "" || initData.Get("auth_date") == "" || initData.Get("start_param") != "start_1" { + t.Fatalf("init data missing signed fields: %s", initData.Encode()) + } + if gotURL, _ := url.Parse(got.URL); gotURL.Query().Get("existing") != "1" { + t.Fatalf("webview url dropped existing query: %q", got.URL) + } + + ok, err = f.router.onMessagesProlongWebView(ownerCtx, &tg.MessagesProlongWebViewRequest{ + Peer: inputPeerUser(f.peer), + Bot: inputUser(f.bot), + QueryID: queryID, + Silent: true, + }) + if err != nil || !ok { + t.Fatalf("prolong webview = %v,%v, want true,nil", ok, err) + } + + if _, err := f.router.onMessagesSendWebViewResultMessage(botCtx, &tg.MessagesSendWebViewResultMessageRequest{ + BotQueryID: botQueryID, + Result: inlineArticleResult("webview-article", "from webview"), + }); err != nil { + t.Fatalf("send webview result: %v", err) + } + if f.router.webviews.size() != 0 { + t.Fatalf("webview registry size = %d, want consumed", f.router.webviews.size()) + } + historyList, err := f.router.deps.Messages.GetHistory(ownerCtx, f.owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get history: %v", err) + } + history := tgMessagesMessages(f.owner.ID, f.router.enrichMessageList(ownerCtx, f.owner.ID, historyList)) + messages := messagesFromClass(history) + if len(messages) != 1 { + t.Fatalf("history messages = %d, want 1", len(messages)) + } + sent, ok := messages[0].(*tg.Message) + if !ok { + t.Fatalf("history message = %T, want *tg.Message", messages[0]) + } + if sent.Message != "from webview" { + t.Fatalf("history text = %q, want from webview", sent.Message) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("history via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } +} + +func TestBotAPIAnswerWebAppQueryUsesRouterWebViewSession(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + + req := &tg.MessagesRequestWebViewRequest{ + Peer: inputPeerUser(f.peer), + Bot: inputUser(f.bot), + Platform: "tdesktop", + } + req.SetURL("https://example.test/botapi") + got, err := f.router.onMessagesRequestWebView(ownerCtx, req) + if err != nil { + t.Fatalf("request webview: %v", err) + } + botQueryID := webViewInitDataFromURL(t, got.URL).Get("query_id") + if botQueryID == "" { + t.Fatal("bot query id empty") + } + + if _, err := f.router.AnswerWebAppQueryFromBotAPI(ctx, f.bot.ID, botQueryID, domain.BotInlineResult{ + ID: "botapi-webapp", + Type: "article", + Message: "from bot api webapp", + }); err != nil { + t.Fatalf("answer web app query from bot api: %v", err) + } + if f.router.webviews.size() != 0 { + t.Fatalf("webview registry size = %d, want consumed", f.router.webviews.size()) + } + historyList, err := f.router.deps.Messages.GetHistory(ownerCtx, f.owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.peer.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get history: %v", err) + } + history := tgMessagesMessages(f.owner.ID, f.router.enrichMessageList(ownerCtx, f.owner.ID, historyList)) + messages := messagesFromClass(history) + if len(messages) != 1 { + t.Fatalf("history messages = %d, want 1", len(messages)) + } + sent, ok := messages[0].(*tg.Message) + if !ok || sent.Message != "from bot api webapp" { + t.Fatalf("history message = %T %+v, want bot api webapp text", messages[0], messages[0]) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("history via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } + if _, err := f.router.AnswerWebAppQueryFromBotAPI(ctx, f.bot.ID, botQueryID, domain.BotInlineResult{ + ID: "botapi-webapp-repeat", + Type: "article", + Message: "repeat", + }); err == nil || !strings.Contains(err.Error(), "QUERY_ID_INVALID") { + t.Fatalf("repeat answer err = %v, want QUERY_ID_INVALID", err) + } +} + +func TestMessagesWebViewRejectsInvalidSessionAndKeepsSessionOnBadResult(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + ownerCtx := WithUserID(ctx, f.owner.ID) + botCtx := WithUserID(ctx, f.bot.ID) + + req := &tg.MessagesRequestWebViewRequest{ + Peer: inputPeerUser(f.peer), + Bot: inputUser(f.bot), + Platform: "tdesktop", + } + req.SetURL("https://example.test/app") + got, err := f.router.onMessagesRequestWebView(ownerCtx, req) + if err != nil { + t.Fatalf("request webview: %v", err) + } + queryID, _ := got.GetQueryID() + if _, err := f.router.onMessagesProlongWebView(ownerCtx, &tg.MessagesProlongWebViewRequest{ + Peer: inputPeerUser(f.owner), + Bot: inputUser(f.bot), + QueryID: queryID, + }); err == nil || !strings.Contains(err.Error(), "QUERY_ID_INVALID") { + t.Fatalf("wrong peer prolong err = %v, want QUERY_ID_INVALID", err) + } + + botQueryID := webViewInitDataFromURL(t, got.URL).Get("query_id") + if _, err := f.router.onMessagesSendWebViewResultMessage(ownerCtx, &tg.MessagesSendWebViewResultMessageRequest{ + BotQueryID: botQueryID, + Result: inlineArticleResult("webview-article", "from webview"), + }); err == nil || !strings.Contains(err.Error(), "USER_BOT_REQUIRED") { + t.Fatalf("non-bot send webview result err = %v, want USER_BOT_REQUIRED", err) + } + if _, err := f.router.onMessagesSendWebViewResultMessage(botCtx, &tg.MessagesSendWebViewResultMessageRequest{ + BotQueryID: botQueryID, + Result: &tg.InputBotInlineResult{ + ID: "bad", + Type: "photo", + Title: "bad", + SendMessage: &tg.InputBotInlineMessageText{Message: "bad"}, + }, + }); err == nil || !strings.Contains(err.Error(), "RESULT_TYPE_INVALID") { + t.Fatalf("bad result err = %v, want RESULT_TYPE_INVALID", err) + } + if f.router.webviews.size() != 1 { + t.Fatalf("webview registry size after bad result = %d, want 1", f.router.webviews.size()) + } + if _, err := f.router.onMessagesSendWebViewResultMessage(botCtx, &tg.MessagesSendWebViewResultMessageRequest{ + BotQueryID: botQueryID, + Result: inlineArticleResult("webview-article", "ok after bad result"), + }); err != nil { + t.Fatalf("send webview result after bad result: %v", err) + } +} + +func TestMessagesRequestWebViewFromMenuUsesConfiguredURL(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + if _, err := f.bots.SetBotMenuButton(ctx, f.bot.ID, domain.BotMenuButton{ + Type: domain.BotMenuButtonWebView, + Text: "Open", + URL: "https://example.test/menu", + }); err != nil { + t.Fatalf("set menu button: %v", err) + } + got, err := f.router.onMessagesRequestWebView(WithUserID(ctx, f.owner.ID), &tg.MessagesRequestWebViewRequest{ + FromBotMenu: true, + Peer: inputPeerUser(f.bot), + Bot: inputUser(f.bot), + Platform: "tdesktop", + }) + if err != nil { + t.Fatalf("request menu webview: %v", err) + } + if !strings.HasPrefix(got.URL, "https://example.test/menu?") { + t.Fatalf("menu webview url = %q, want configured URL", got.URL) + } + if _, ok := got.GetQueryID(); !ok { + t.Fatal("menu webview missing query_id") + } +} + +func TestMessagesGetBotAppAndRequestAppWebViewUsesMenuBackedSession(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + if _, err := f.bots.SetBotMenuButton(ctx, f.bot.ID, domain.BotMenuButton{ + Type: domain.BotMenuButtonWebView, + Text: "Launch", + URL: "https://example.test/direct", + }); err != nil { + t.Fatalf("set menu button: %v", err) + } + + gotApp, err := f.router.onMessagesGetBotApp(WithUserID(ctx, f.owner.ID), &tg.MessagesGetBotAppRequest{ + App: &tg.InputBotAppShortName{ + BotID: inputUser(f.bot), + ShortName: "main", + }, + }) + if err != nil { + t.Fatalf("get bot app: %v", err) + } + app, ok := gotApp.App.(*tg.BotApp) + if !ok { + t.Fatalf("bot app = %T, want *tg.BotApp", gotApp.App) + } + if app.ID == 0 || app.AccessHash == 0 || app.ShortName != "main" || app.Title != "Launch" { + t.Fatalf("bot app = %+v, want catalog-backed menu app", app) + } + + cached, err := f.router.onMessagesGetBotApp(WithUserID(ctx, f.owner.ID), &tg.MessagesGetBotAppRequest{ + App: &tg.InputBotAppShortName{ + BotID: inputUser(f.bot), + ShortName: "main", + }, + Hash: app.Hash, + }) + if err != nil { + t.Fatalf("get cached bot app: %v", err) + } + if _, ok := cached.App.(*tg.BotAppNotModified); !ok { + t.Fatalf("cached app = %T, want *tg.BotAppNotModified", cached.App) + } + + req := &tg.MessagesRequestAppWebViewRequest{ + Peer: &tg.InputPeerEmpty{}, + App: &tg.InputBotAppID{ID: app.ID, AccessHash: app.AccessHash}, + Platform: "tdesktop", + Fullscreen: true, + } + req.SetStartParam("start_app") + got, err := f.router.onMessagesRequestAppWebView(WithUserID(ctx, f.owner.ID), req) + if err != nil { + t.Fatalf("request app webview: %v", err) + } + if fullscreen := got.GetFullscreen(); !fullscreen { + t.Fatal("request app webview fullscreen = false, want true") + } + if !strings.HasPrefix(got.URL, "https://example.test/direct?") { + t.Fatalf("app webview url = %q, want configured URL", got.URL) + } + queryID, ok := got.GetQueryID() + if !ok || queryID == 0 { + t.Fatalf("app webview query_id = %d,%v, want non-zero", queryID, ok) + } + initData := webViewInitDataFromURL(t, got.URL) + if initData.Get("start_param") != "start_app" { + t.Fatalf("start_param = %q, want start_app", initData.Get("start_param")) + } + + if _, err := f.router.onMessagesSendWebViewResultMessage(WithUserID(ctx, f.bot.ID), &tg.MessagesSendWebViewResultMessageRequest{ + BotQueryID: initData.Get("query_id"), + Result: inlineArticleResult("app-webview", "from app"), + }); err != nil { + t.Fatalf("send app webview result: %v", err) + } + historyList, err := f.router.deps.Messages.GetHistory(ctx, f.owner.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.bot.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get bot peer history: %v", err) + } + history := tgMessagesMessages(f.owner.ID, f.router.enrichMessageList(ctx, f.owner.ID, historyList)) + messages := messagesFromClass(history) + if len(messages) != 1 { + t.Fatalf("bot peer history messages = %d, want 1", len(messages)) + } + sent, ok := messages[0].(*tg.Message) + if !ok || sent.Message != "from app" { + t.Fatalf("bot peer message = %T %+v, want text from app", messages[0], messages[0]) + } + if via, ok := sent.GetViaBotID(); !ok || via != f.bot.ID { + t.Fatalf("app message via_bot_id = %d,%v, want %d,true", via, ok, f.bot.ID) + } +} + +func TestMessagesRequestMainWebViewUsesMenuBackedSession(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + if _, err := f.bots.SetBotMenuButton(ctx, f.bot.ID, domain.BotMenuButton{ + Type: domain.BotMenuButtonWebView, + Text: "Main", + URL: "https://example.test/main", + }); err != nil { + t.Fatalf("set menu button: %v", err) + } + + req := &tg.MessagesRequestMainWebViewRequest{ + Peer: &tg.InputPeerEmpty{}, + Bot: inputUser(f.bot), + Platform: "android", + } + req.SetStartParam("from_profile") + got, err := f.router.onMessagesRequestMainWebView(WithUserID(ctx, f.owner.ID), req) + if err != nil { + t.Fatalf("request main webview: %v", err) + } + if !strings.HasPrefix(got.URL, "https://example.test/main?") { + t.Fatalf("main webview url = %q, want configured URL", got.URL) + } + queryID, ok := got.GetQueryID() + if !ok || queryID == 0 { + t.Fatalf("main webview query_id = %d,%v, want non-zero", queryID, ok) + } + initData := webViewInitDataFromURL(t, got.URL) + if initData.Get("start_param") != "from_profile" { + t.Fatalf("start_param = %q, want from_profile", initData.Get("start_param")) + } + if _, err := f.router.onMessagesProlongWebView(WithUserID(ctx, f.owner.ID), &tg.MessagesProlongWebViewRequest{ + Peer: inputPeerUser(f.bot), + Bot: inputUser(f.bot), + QueryID: queryID, + }); err != nil { + t.Fatalf("prolong main webview using bot peer fallback: %v", err) + } +} + +func TestMessagesBotAppRejectsInvalidInputs(t *testing.T) { + ctx := context.Background() + f := newInlineBotRPCTestFixture(t) + userCtx := WithUserID(ctx, f.owner.ID) + + if _, err := f.router.onMessagesGetBotApp(userCtx, &tg.MessagesGetBotAppRequest{ + App: &tg.InputBotAppShortName{BotID: inputUser(f.bot), ShortName: "main"}, + }); err == nil || !strings.Contains(err.Error(), "BOT_APP_INVALID") { + t.Fatalf("get app without menu err = %v, want BOT_APP_INVALID", err) + } + if _, err := f.bots.SetBotMenuButton(ctx, f.bot.ID, domain.BotMenuButton{ + Type: domain.BotMenuButtonWebView, + Text: "Launch", + URL: "https://example.test/app", + }); err != nil { + t.Fatalf("set menu button: %v", err) + } + if _, err := f.router.onMessagesGetBotApp(userCtx, &tg.MessagesGetBotAppRequest{ + App: &tg.InputBotAppShortName{BotID: inputUser(f.bot), ShortName: "bad-name"}, + }); err == nil || !strings.Contains(err.Error(), "BOT_APP_SHORTNAME_INVALID") { + t.Fatalf("bad short name err = %v, want BOT_APP_SHORTNAME_INVALID", err) + } + if _, err := f.router.onMessagesGetBotApp(userCtx, &tg.MessagesGetBotAppRequest{ + App: &tg.InputBotAppShortName{BotID: inputUser(f.owner), ShortName: "main"}, + }); err == nil || !strings.Contains(err.Error(), "BOT_APP_BOT_INVALID") { + t.Fatalf("non-bot owner err = %v, want BOT_APP_BOT_INVALID", err) + } + if _, err := f.router.onMessagesRequestAppWebView(userCtx, &tg.MessagesRequestAppWebViewRequest{ + Peer: inputPeerUser(f.bot), + App: &tg.InputBotAppID{ID: f.bot.ID, AccessHash: 123}, + Platform: "tdesktop", + }); err == nil || !strings.Contains(err.Error(), "BOT_APP_INVALID") { + t.Fatalf("wrong app access hash err = %v, want BOT_APP_INVALID", err) + } + if _, err := f.router.onMessagesRequestMainWebView(userCtx, &tg.MessagesRequestMainWebViewRequest{ + Peer: &tg.InputPeerEmpty{}, + Bot: inputUser(f.owner), + Platform: "tdesktop", + }); err == nil || !strings.Contains(err.Error(), "BOT_INVALID") { + t.Fatalf("main non-bot err = %v, want BOT_INVALID", err) + } +} + +func webViewInitDataFromURL(t *testing.T, raw string) url.Values { + t.Helper() + parsed, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse webview url: %v", err) + } + initData := parsed.Query().Get("tgWebAppData") + if initData == "" { + t.Fatalf("webview url missing tgWebAppData: %q", raw) + } + values, err := url.ParseQuery(initData) + if err != nil { + t.Fatalf("parse init data: %v", err) + } + return values +} diff --git a/internal/rpc/music_rpc_test.go b/internal/rpc/music_rpc_test.go new file mode 100644 index 00000000..1ed682d0 --- /dev/null +++ b/internal/rpc/music_rpc_test.go @@ -0,0 +1,309 @@ +package rpc + +import ( + "context" + "strings" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appaccount "telesrv/internal/app/account" + appmessages "telesrv/internal/app/messages" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestSavedMusicRPCLifecycle(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000011", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + passwords := memory.NewPasswordStore() + music1 := testMusicDocument(101, 1001, "First") + music2 := testMusicDocument(102, 1002, "Second") + voice := domain.Document{ID: 103, AccessHash: 1003, MimeType: "audio/ogg", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio, Voice: true, AudioDuration: 3}}} + files := &fakeFiles{docs: map[int64]domain.Document{ + music1.ID: music1, + music2.ID: music2, + voice.ID: voice, + }} + r := New(Config{}, Deps{ + Account: appaccount.NewService(passwords, appaccount.WithSavedMusic(passwords)), + Files: files, + Users: appusers.NewService(userStore), + }, zaptest.NewLogger(t), clock.System) + reqCtx := WithUserID(ctx, owner.ID) + + if ok, err := r.onAccountSaveMusic(reqCtx, &tg.AccountSaveMusicRequest{ + ID: &tg.InputDocument{ID: music1.ID, AccessHash: music1.AccessHash}, + }); err != nil || !ok { + t.Fatalf("save first music = ok %v err %v, want true/nil", ok, err) + } + afterFirst := &tg.AccountSaveMusicRequest{ + ID: &tg.InputDocument{ID: music2.ID, AccessHash: music2.AccessHash}, + } + afterFirst.SetAfterID(&tg.InputDocument{ID: music1.ID, AccessHash: music1.AccessHash}) + if ok, err := r.onAccountSaveMusic(reqCtx, afterFirst); err != nil || !ok { + t.Fatalf("save second after first = ok %v err %v, want true/nil", ok, err) + } + idsBox, err := r.onAccountGetSavedMusicIDs(reqCtx, 0) + if err != nil { + t.Fatalf("get saved music ids: %v", err) + } + ids, ok := idsBox.(*tg.AccountSavedMusicIDs) + if !ok || !int64SliceEqual(ids.IDs, []int64{music1.ID, music2.ID}) { + t.Fatalf("ids = %T %+v, want [first second]", idsBox, idsBox) + } + if notModified, err := r.onAccountGetSavedMusicIDs(reqCtx, int64(tdesktopCountHash(ids.IDs))); err != nil { + t.Fatalf("get saved music ids hash: %v", err) + } else if _, ok := notModified.(*tg.AccountSavedMusicIDsNotModified); !ok { + t.Fatalf("hash result = %T, want notModified", notModified) + } + + page, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ID: &tg.InputUserSelf{}, Limit: 2}) + if err != nil { + t.Fatalf("users.getSavedMusic: %v", err) + } + saved, ok := page.(*tg.UsersSavedMusic) + if !ok || saved.Count != 2 || len(saved.Documents) != 2 || documentClassID(saved.Documents[0]) != music1.ID { + t.Fatalf("saved music page = %T %+v, want two docs with first first", page, page) + } + hashPage, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ + ID: &tg.InputUserSelf{}, + Limit: 2, + Hash: int64(tdesktopCountHash([]int64{music1.ID, music2.ID})), + }) + if err != nil { + t.Fatalf("users.getSavedMusic hash: %v", err) + } + if nm, ok := hashPage.(*tg.UsersSavedMusicNotModified); !ok || nm.Count != 2 { + t.Fatalf("hash page = %T %+v, want notModified count=2", hashPage, hashPage) + } + + full, err := r.onUsersGetFullUser(reqCtx, &tg.InputUserSelf{}) + if err != nil { + t.Fatalf("users.getFullUser: %v", err) + } + if doc, ok := full.FullUser.GetSavedMusicAsNotEmpty(); !ok || doc.ID != music1.ID { + t.Fatalf("full saved_music = %+v ok=%v, want first document", doc, ok) + } + byID, err := r.onUsersGetSavedMusicByID(reqCtx, &tg.UsersGetSavedMusicByIDRequest{ + ID: &tg.InputUserSelf{}, + Documents: []tg.InputDocumentClass{&tg.InputDocument{ID: music2.ID, AccessHash: music2.AccessHash}}, + }) + if err != nil { + t.Fatalf("users.getSavedMusicByID: %v", err) + } + byIDMusic, ok := byID.(*tg.UsersSavedMusic) + if !ok || len(byIDMusic.Documents) != 1 || documentClassID(byIDMusic.Documents[0]) != music2.ID { + t.Fatalf("by id = %T %+v, want second document", byID, byID) + } + + if ok, err := r.onAccountSaveMusic(reqCtx, &tg.AccountSaveMusicRequest{ + ID: &tg.InputDocument{ID: music2.ID, AccessHash: music2.AccessHash}, + }); err != nil || !ok { + t.Fatalf("move second to top = ok %v err %v, want true/nil", ok, err) + } + idsBox, err = r.onAccountGetSavedMusicIDs(reqCtx, 0) + if err != nil { + t.Fatalf("get reordered ids: %v", err) + } + ids = idsBox.(*tg.AccountSavedMusicIDs) + if !int64SliceEqual(ids.IDs, []int64{music2.ID, music1.ID}) { + t.Fatalf("reordered ids = %v, want [second first]", ids.IDs) + } + unsave := &tg.AccountSaveMusicRequest{ID: &tg.InputDocument{ID: music2.ID, AccessHash: music2.AccessHash}} + unsave.SetUnsave(true) + if ok, err := r.onAccountSaveMusic(reqCtx, unsave); err != nil || !ok { + t.Fatalf("unsave second = ok %v err %v, want true/nil", ok, err) + } + idsBox, err = r.onAccountGetSavedMusicIDs(reqCtx, 0) + if err != nil { + t.Fatalf("get ids after unsave: %v", err) + } + ids = idsBox.(*tg.AccountSavedMusicIDs) + if !int64SliceEqual(ids.IDs, []int64{music1.ID}) { + t.Fatalf("ids after unsave = %v, want [first]", ids.IDs) + } + if _, err := r.onAccountSaveMusic(reqCtx, &tg.AccountSaveMusicRequest{ + ID: &tg.InputDocument{ID: voice.ID, AccessHash: voice.AccessHash}, + }); err == nil || !strings.Contains(err.Error(), "DOCUMENT_INVALID") { + t.Fatalf("save voice err = %v, want DOCUMENT_INVALID", err) + } +} + +func TestMessagesSearchMusicFiltersAudioDocuments(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550000021", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550000022", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + dialogs := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogs) + music := testMusicDocument(201, 2001, "Song") + voice := domain.Document{ID: 202, AccessHash: 2002, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio, Voice: true, AudioDuration: 5}}} + plain := domain.Document{ID: 203, AccessHash: 2003, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "file.bin"}}} + for i, media := range []*domain.MessageMedia{ + {Kind: domain.MessageMediaKindDocument, Document: &plain}, + {Kind: domain.MessageMediaKindDocument, Document: &voice, Voice: true}, + {Kind: domain.MessageMediaKindDocument, Document: &music}, + } { + if _, err := messageStore.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, + RecipientUserID: bob.ID, + RandomID: int64(300 + i), + Message: "", + Media: media, + Date: 1700000000 + i, + }); err != nil { + t.Fatalf("send media %d: %v", i, err) + } + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Messages: appmessages.NewService(messageStore, dialogs), + }, zaptest.NewLogger(t), clock.System) + + var payload bin.Buffer + if err := (&tg.MessagesSearchRequest{ + Peer: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, + Filter: &tg.InputMessagesFilterMusic{}, + Limit: 10, + }).Encode(&payload); err != nil { + t.Fatalf("encode messages.search: %v", err) + } + got, err := r.Dispatch(WithUserID(ctx, bob.ID), [8]byte{}, 0, &payload) + if err != nil { + t.Fatalf("messages.search music: %v", err) + } + messages, _, _ := searchMessagesPayload(t, got) + if len(messages) != 1 { + t.Fatalf("music search messages = %d, want 1", len(messages)) + } + msg, ok := messages[0].(*tg.Message) + if !ok { + t.Fatalf("music search message = %T, want *tg.Message", messages[0]) + } + docMedia, ok := msg.Media.(*tg.MessageMediaDocument) + if !ok || documentClassID(docMedia.Document) != music.ID { + t.Fatalf("music search media = %T %+v, want music document", msg.Media, msg.Media) + } +} + +func TestMessagesSearchGlobalMusicAllowsEmptyQuery(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550000031", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550000032", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + dialogs := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogs) + music := testMusicDocument(301, 3001, "Global Song") + voice := domain.Document{ID: 302, AccessHash: 3002, Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrAudio, Voice: true, AudioDuration: 5}}} + for i, media := range []*domain.MessageMedia{ + {Kind: domain.MessageMediaKindDocument, Document: &voice, Voice: true}, + {Kind: domain.MessageMediaKindDocument, Document: &music}, + } { + if _, err := messageStore.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, + RecipientUserID: bob.ID, + RandomID: int64(400 + i), + Media: media, + Date: 1700000100 + i, + }); err != nil { + t.Fatalf("send media %d: %v", i, err) + } + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Messages: appmessages.NewService(messageStore, dialogs), + }, zaptest.NewLogger(t), clock.System) + reqCtx := WithUserID(ctx, bob.ID) + + if _, err := r.onMessagesSearchGlobal(reqCtx, &tg.MessagesSearchGlobalRequest{ + Filter: &tg.InputMessagesFilterEmpty{}, + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 10, + }); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") { + t.Fatalf("empty non-music searchGlobal err = %v, want SEARCH_QUERY_EMPTY", err) + } + got, err := r.onMessagesSearchGlobal(reqCtx, &tg.MessagesSearchGlobalRequest{ + Filter: &tg.InputMessagesFilterMusic{}, + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("empty music searchGlobal: %v", err) + } + messages := messagesFromMessagesClass(t, got) + if len(messages) != 1 { + t.Fatalf("global music messages = %d, want 1", len(messages)) + } + msg, ok := messages[0].(*tg.Message) + if !ok { + t.Fatalf("global music message = %T, want *tg.Message", messages[0]) + } + docMedia, ok := msg.Media.(*tg.MessageMediaDocument) + if !ok || documentClassID(docMedia.Document) != music.ID { + t.Fatalf("global music media = %T %+v, want music document", msg.Media, msg.Media) + } +} + +func testMusicDocument(id, accessHash int64, title string) domain.Document { + return domain.Document{ + ID: id, + AccessHash: accessHash, + MimeType: "audio/mpeg", + Attributes: []domain.DocumentAttribute{ + {Kind: domain.DocAttrAudio, AudioDuration: 180, Title: title, Performer: "Artist"}, + }, + } +} + +func documentClassID(doc tg.DocumentClass) int64 { + if d, ok := doc.(*tg.Document); ok { + return d.ID + } + return 0 +} + +func int64SliceEqual(a, b []int64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func messagesFromMessagesClass(t *testing.T, got tg.MessagesMessagesClass) []tg.MessageClass { + t.Helper() + switch v := got.(type) { + case *tg.MessagesMessages: + return v.Messages + case *tg.MessagesMessagesSlice: + return v.Messages + default: + t.Fatalf("messages class = %T, want messages/messagesSlice", got) + return nil + } +} diff --git a/internal/rpc/notify_settings_cache.go b/internal/rpc/notify_settings_cache.go new file mode 100644 index 00000000..1a8841c6 --- /dev/null +++ b/internal/rpc/notify_settings_cache.go @@ -0,0 +1,70 @@ +package rpc + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +const ( + notifySettingsCacheMaxEntries = 4096 + // notifySettingsCacheTTL 兜底跨实例失效窗口;同实例 Save/Reset 即时失效。 + // 通知设置改动后其它实例的 dialog 列表静音态最多滞后该时长(可接受)。 + notifySettingsCacheTTL = 60 * time.Second +) + +// notifySettingsCache 缓存 userID→该用户全部整-peer 通知设置 map,消除 getDialogs/ +// getPeerDialogs/getFullUser/getFullChannel 每次刷新都查 notify_settings 的热路径开销 +// (命中即 0 PG)。内建 TTL + singleflight + LRU。 +type notifySettingsCache struct { + cache *readmodelcache.Cache[int64, map[domain.Peer]domain.PeerNotifySettings] +} + +func newNotifySettingsCache(now func() time.Time) *notifySettingsCache { + return ¬ifySettingsCache{ + cache: readmodelcache.New[int64, map[domain.Peer]domain.PeerNotifySettings](readmodelcache.Config[int64, map[domain.Peer]domain.PeerNotifySettings]{ + MaxEntries: notifySettingsCacheMaxEntries, + TTL: notifySettingsCacheTTL, + Now: now, + }), + } +} + +// getOrLoad 返回缓存的 map(只读,调用方查表后须 Clone 单条再持有)。 +func (c *notifySettingsCache) getOrLoad(ctx context.Context, userID int64, load func() (map[domain.Peer]domain.PeerNotifySettings, error)) (map[domain.Peer]domain.PeerNotifySettings, error) { + if c == nil || userID == 0 { + return load() + } + return c.cache.GetOrLoad(ctx, userID, load) +} + +func (c *notifySettingsCache) Delete(userID int64) { + if c == nil || userID == 0 { + return + } + c.cache.Invalidate(userID) +} + +func (c *notifySettingsCache) Flush() { + if c == nil { + return + } + c.cache.Flush() +} + +// userNotifySettings 取(缓存的)某用户全部整-peer 通知设置;服务未接通返回 nil。 +func (r *Router) userNotifySettings(ctx context.Context, userID int64) map[domain.Peer]domain.PeerNotifySettings { + svc, ok := r.accountNotifySvc() + if !ok { + return nil + } + settings, err := r.notifySettings.getOrLoad(ctx, userID, func() (map[domain.Peer]domain.PeerNotifySettings, error) { + return svc.AllPeerNotifySettings(ctx, userID) + }) + if err != nil { + return nil + } + return settings +} diff --git a/internal/rpc/outbox_dispatcher.go b/internal/rpc/outbox_dispatcher.go index ee7a9b83..1eed45f7 100644 --- a/internal/rpc/outbox_dispatcher.go +++ b/internal/rpc/outbox_dispatcher.go @@ -3,6 +3,7 @@ package rpc import ( "context" "errors" + "sort" "sync" "time" @@ -17,7 +18,12 @@ import ( const ( defaultOutboxBatch = 100 defaultOutboxInterval = 200 * time.Millisecond - defaultOutboxWorkers = 2 + defaultOutboxWorkers = 1 + // defaultOutboxMaxIdleInterval 是空闲退避上界:连续空 claim 时把轮询间隔从 interval + // 指数退避到此上界,削减「无消息时也每 200ms 查一次 DB」的空转;一旦有活就立刻复位到 + // interval。代价是「长时间静默后的第一条消息」最多多等这个上界——退避只在持续空闲时触及, + // 会话期间有近期活动即保持快速轮询,故对正常收发延迟无影响。 + defaultOutboxMaxIdleInterval = 1 * time.Second ) var errMissingOutboxEvent = errors.New("missing outbox update event") @@ -25,20 +31,38 @@ var errMissingOutboxEvent = errors.New("missing outbox update event") // OutboxDispatcher 把 PG transactional outbox 中的 update 批量推给在线 session。 // 多 worker 并发 claim:ClaimPending 用 FOR UPDATE SKIP LOCKED,worker 间认领不重叠。 type OutboxDispatcher struct { - events store.UpdateEventStore - outbox store.DispatchOutboxStore - sessions SessionBinder - log *zap.Logger - metrics Metrics - batch int - interval time.Duration - workers int - pushTimeout time.Duration + events store.UpdateEventStore + outbox store.DispatchOutboxStore + sessions SessionBinder + log *zap.Logger + metrics Metrics + updateBuilder OutboxUpdateBuilder + batch int + interval time.Duration + maxIdleInterval time.Duration + workers int + pushTimeout time.Duration } // OutboxOption 调整 OutboxDispatcher 的运行参数。 type OutboxOption func(*OutboxDispatcher) +// OutboxUpdateRequest 是 outbox worker 构造在线 updates 时的批量输入。 +type OutboxUpdateRequest struct { + TargetUserID int64 + Event domain.UpdateEvent +} + +// OutboxUpdateBuilder 按接收者视角批量把 domain.UpdateEvent 转为 TL updates。 +type OutboxUpdateBuilder func(ctx context.Context, requests []OutboxUpdateRequest) []*tg.Updates + +// WithOutboxUpdateBuilder 注入按接收者视角的批量 updates 构建器。 +func WithOutboxUpdateBuilder(builder OutboxUpdateBuilder) OutboxOption { + return func(d *OutboxDispatcher) { + d.updateBuilder = builder + } +} + // WithOutboxBatch 设置每次 claim 的最大条数;<=0 时保持默认。 func WithOutboxBatch(n int) OutboxOption { return func(d *OutboxDispatcher) { @@ -91,14 +115,15 @@ func NewOutboxDispatcher(events store.UpdateEventStore, outbox store.DispatchOut log = zap.NewNop() } d := &OutboxDispatcher{ - events: events, - outbox: outbox, - sessions: sessions, - log: log, - metrics: NopMetrics{}, - batch: defaultOutboxBatch, - interval: defaultOutboxInterval, - workers: defaultOutboxWorkers, + events: events, + outbox: outbox, + sessions: sessions, + log: log, + metrics: NopMetrics{}, + batch: defaultOutboxBatch, + interval: defaultOutboxInterval, + maxIdleInterval: defaultOutboxMaxIdleInterval, + workers: defaultOutboxWorkers, } for _, opt := range opts { if opt != nil { @@ -129,17 +154,9 @@ func (d *OutboxDispatcher) Run(ctx context.Context) { } // runWorker 是单个 claim 循环;多 worker 靠 ClaimPending 的 SKIP LOCKED 互不重叠。 +// 空闲指数退避(interval→maxIdleInterval),claim 到事件即复位到 interval:有活快、无活省。 func (d *OutboxDispatcher) runWorker(ctx context.Context) { - ticker := time.NewTicker(d.interval) - defer ticker.Stop() - for { - d.DispatchOnce(ctx) - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } + runIdleBackoffLoop(ctx, d.interval, d.maxIdleInterval, d.DispatchOnce) } // batchEventLoader 是 UpdateEventStore 的可选批量能力:一次取多条 (user,pts) 事件。 @@ -152,27 +169,43 @@ type batchOutboxMarker interface { MarkDeliveredBatch(ctx context.Context, items []store.DispatchOutboxItem) error } -// DispatchOnce claim 一批 outbox 并投递,测试可直接调用。 +// DispatchOnce claim 一批 outbox 并投递,测试可直接调用。返回本次是否 claim 到事件, +// 供 runWorker 决定快轮询还是空闲退避。 // store 同时具备批量取事件 + 批量标记能力时走批量路径(每批 ~3 次 PG 往返),否则逐条回退。 -func (d *OutboxDispatcher) DispatchOnce(ctx context.Context) { +func (d *OutboxDispatcher) DispatchOnce(ctx context.Context) bool { items, err := d.outbox.ClaimPending(ctx, d.batch) if err != nil { d.log.Warn("claim dispatch outbox", zap.Error(err)) - return + return false } if len(items) == 0 { - return + return false } + sortDispatchItems(items) d.metrics.OutboxClaimed(len(items)) if loader, ok := d.events.(batchEventLoader); ok { if marker, ok := d.outbox.(batchOutboxMarker); ok { d.dispatchBatch(ctx, items, loader, marker) - return + return true } } for _, item := range items { d.dispatchItem(ctx, item) } + return true +} + +func sortDispatchItems(items []store.DispatchOutboxItem) { + sort.SliceStable(items, func(i, j int) bool { + a, b := items[i], items[j] + if a.TargetUserID != b.TargetUserID { + return a.TargetUserID < b.TargetUserID + } + if a.Pts != b.Pts { + return a.Pts < b.Pts + } + return a.ID < b.ID + }) } type outboxEventKey struct { @@ -200,14 +233,22 @@ func (d *OutboxDispatcher) dispatchBatch(ctx context.Context, items []store.Disp byKey[outboxEventKey{event.UserID, event.Pts}] = event } start := time.Now() - delivered := make([]store.DispatchOutboxItem, 0, len(items)) + ready := make([]outboxDispatchReady, 0, len(items)) + requests := make([]OutboxUpdateRequest, 0, len(items)) for _, item := range items { event, ok := byKey[outboxEventKey{item.TargetUserID, item.Pts}] if !ok { d.markDispatchFailed(ctx, item, errMissingOutboxEvent) continue } - update := tgUpdateForOutboxEvent(event) + ready = append(ready, outboxDispatchReady{item: item}) + requests = append(requests, OutboxUpdateRequest{TargetUserID: item.TargetUserID, Event: event}) + } + builtUpdates := d.buildOutboxUpdates(ctx, requests) + delivered := make([]store.DispatchOutboxItem, 0, len(items)) + for i, entry := range ready { + item := entry.item + update := builtUpdates[i] if update == nil { delivered = append(delivered, item) continue @@ -241,6 +282,10 @@ func (d *OutboxDispatcher) dispatchBatch(ctx context.Context, items []store.Disp } } +type outboxDispatchReady struct { + item store.DispatchOutboxItem +} + func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.DispatchOutboxItem) { start := time.Now() events, err := d.events.ListAfter(ctx, item.TargetUserID, item.Pts-1, 1) @@ -252,7 +297,7 @@ func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.Dispatch d.markDispatchFailed(ctx, item, errMissingOutboxEvent) return } - update := tgUpdateForOutboxEvent(events[0]) + update := d.buildOutboxUpdate(ctx, item, events[0]) if update == nil { if err := d.outbox.MarkDelivered(ctx, item.TargetUserID, item.ID); err != nil { d.log.Warn("mark noop dispatch delivered", zap.Int64("target_user_id", item.TargetUserID), zap.Int64("outbox_id", item.ID), zap.Error(err)) @@ -289,6 +334,35 @@ func (d *OutboxDispatcher) dispatchItem(ctx context.Context, item store.Dispatch ) } +func (d *OutboxDispatcher) buildOutboxUpdate(ctx context.Context, item store.DispatchOutboxItem, event domain.UpdateEvent) *tg.Updates { + updates := d.buildOutboxUpdates(ctx, []OutboxUpdateRequest{{TargetUserID: item.TargetUserID, Event: event}}) + if len(updates) == 0 { + return nil + } + return updates[0] +} + +func (d *OutboxDispatcher) buildOutboxUpdates(ctx context.Context, requests []OutboxUpdateRequest) []*tg.Updates { + out := make([]*tg.Updates, len(requests)) + if len(requests) == 0 { + return out + } + if d.updateBuilder != nil { + built := d.updateBuilder(ctx, requests) + if len(built) == len(requests) { + return built + } + d.log.Warn("outbox update builder returned mismatched count", + zap.Int("requests", len(requests)), + zap.Int("updates", len(built)), + ) + } + for i, req := range requests { + out[i] = tgUpdateForOutboxEvent(req.Event) + } + return out +} + // pushOutboxUpdate 投递一条 outbox update,返回 (送达的在线 session 数, 是否可重试, err)。 // best-effort 路径(pushTimeout>0)的失败只可能是出站队列拥塞(慢消费者入队超时),属暂时性、 // 可重试:调用方应保留 dispatching 行靠租约过期重投,而非计入 attempts 升级为 failed。 @@ -335,9 +409,16 @@ func (d *OutboxDispatcher) markDispatchFailed(ctx context.Context, item store.Di } func tgUpdateForOutboxEvent(event domain.UpdateEvent) *tg.Updates { + return tgUpdateForOutboxEventForViewer(event, event.UserID) +} + +func tgUpdateForOutboxEventForViewer(event domain.UpdateEvent, viewerUserID int64) *tg.Updates { + if viewerUserID == 0 { + viewerUserID = event.UserID + } switch event.Type { case domain.UpdateEventNewMessage: - return tgPrivateMessageUpdates(event, event.Message, 0, false, tgUsers(event.Users), tgChannels(event.UserID, event.Channels)) + return tgPrivateMessageUpdates(event, event.Message, 0, false, tgUsersForViewer(viewerUserID, event.Users), tgChannels(viewerUserID, event.Channels)) case domain.UpdateEventReadHistoryInbox, domain.UpdateEventReadHistoryOutbox: var update tg.UpdateClass if event.Type == domain.UpdateEventReadHistoryOutbox { @@ -349,7 +430,7 @@ func tgUpdateForOutboxEvent(event domain.UpdateEvent) *tg.Updates { return nil } return &tg.Updates{ - Updates: []tg.UpdateClass{update}, + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, event), Date: event.Date, Seq: 0, // 私聊不维护账号级 seq,恒 0 } @@ -361,9 +442,23 @@ func tgUpdateForOutboxEvent(event domain.UpdateEvent) *tg.Updates { return nil } return &tg.Updates{ - Updates: []tg.UpdateClass{update}, + Updates: appendAuxPtsBookkeeping([]tg.UpdateClass{update}, event), Date: event.Date, Seq: 0, } } } + +// appendAuxPtsBookkeeping 给"占账号 pts 但 TL update 不带 pts"的事件附一条 +// 空 updateDeleteMessages:客户端按 pts/pts_count 推进本地水位且不产生任何 +// 可见变化。没有它,客户端水位停在事件前,下一条带 pts 的更新会被判为空洞。 +func appendAuxPtsBookkeeping(updates []tg.UpdateClass, event domain.UpdateEvent) []tg.UpdateClass { + if event.Pts <= 0 || event.PtsCount <= 0 || !event.LacksWirePts() { + return updates + } + return append(updates, &tg.UpdateDeleteMessages{ + Messages: []int{}, + Pts: event.Pts, + PtsCount: event.PtsCount, + }) +} diff --git a/internal/rpc/outbox_dispatcher_test.go b/internal/rpc/outbox_dispatcher_test.go index 74c0d2d5..92478731 100644 --- a/internal/rpc/outbox_dispatcher_test.go +++ b/internal/rpc/outbox_dispatcher_test.go @@ -3,10 +3,12 @@ package rpc import ( "context" "errors" + "reflect" "testing" "time" "github.com/gotd/td/bin" + "github.com/gotd/td/clock" "github.com/gotd/td/proto" "github.com/gotd/td/tg" "go.uber.org/zap/zaptest" @@ -150,6 +152,400 @@ func TestOutboxDispatcherBatchPath(t *testing.T) { } } +func TestRouterBuildOutboxUpdatesProjectsSenderPerViewerAndCaches(t *testing.T) { + const ( + senderUserID = int64(1000000001) + viewerUserID = int64(1000000002) + ) + projected := domain.User{ + ID: senderUserID, + FirstName: "Sender", + PhotoID: 9301, + PhotoDCID: 2, + } + users := &countingOutboxUsersService{users: map[int64]domain.User{senderUserID: projected}} + router := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System) + requests := make([]OutboxUpdateRequest, 0, 2) + for i, pts := range []int{7, 8} { + msg := domain.Message{ + ID: 10 + i, + OwnerUserID: viewerUserID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + Date: 1700000300 + i, + Body: "hello", + Pts: pts, + } + requests = append(requests, OutboxUpdateRequest{ + TargetUserID: viewerUserID, + Event: domain.UpdateEvent{ + UserID: viewerUserID, + Type: domain.UpdateEventNewMessage, + Pts: pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{{ID: senderUserID, FirstName: "Stale"}}, + }, + }) + } + + updates := router.BuildOutboxUpdates(context.Background(), requests) + if len(updates) != len(requests) { + t.Fatalf("updates count = %d, want %d", len(updates), len(requests)) + } + for i, update := range updates { + if update == nil || len(update.Users) != 1 { + t.Fatalf("updates[%d].Users = %+v, want projected sender", i, update) + } + user, ok := update.Users[0].(*tg.User) + if !ok { + t.Fatalf("updates[%d].Users[0] = %T, want *tg.User", i, update.Users[0]) + } + if user.FirstName != "Sender" { + t.Fatalf("updates[%d] user first_name = %q, want projected Sender", i, user.FirstName) + } + photo, ok := user.Photo.(*tg.UserProfilePhoto) + if !ok || photo.PhotoID != projected.PhotoID || photo.DCID != projected.PhotoDCID { + t.Fatalf("updates[%d] user photo = %#v, want photo_id=%d dc=%d", i, user.Photo, projected.PhotoID, projected.PhotoDCID) + } + } + if len(users.calls) != 1 { + t.Fatalf("ByIDs calls = %+v, want one batch call for repeated sender", users.calls) + } + if users.calls[0].viewerUserID != viewerUserID || !reflect.DeepEqual(users.calls[0].ids, []int64{senderUserID}) { + t.Fatalf("ByIDs call = %+v, want viewer=%d ids=[%d]", users.calls[0], viewerUserID, senderUserID) + } +} + +func TestRouterBuildOutboxUpdatesSeparatesViewerCache(t *testing.T) { + const senderUserID = int64(1000000001) + users := &viewerSpecificOutboxUsersService{} + router := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System) + requests := []OutboxUpdateRequest{ + { + TargetUserID: 1000000002, + Event: domain.UpdateEvent{ + UserID: 1000000002, + Type: domain.UpdateEventNewMessage, + Pts: 7, + PtsCount: 1, + Date: 1700000307, + Message: domain.Message{ + ID: 10, + OwnerUserID: 1000000002, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + Date: 1700000307, + Pts: 7, + }, + }, + }, + { + TargetUserID: 1000000003, + Event: domain.UpdateEvent{ + UserID: 1000000003, + Type: domain.UpdateEventNewMessage, + Pts: 8, + PtsCount: 1, + Date: 1700000308, + Message: domain.Message{ + ID: 11, + OwnerUserID: 1000000003, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + Date: 1700000308, + Pts: 8, + }, + }, + }, + } + + updates := router.BuildOutboxUpdates(context.Background(), requests) + if len(updates) != 2 || updates[0] == nil || updates[1] == nil { + t.Fatalf("updates = %+v, want two updates", updates) + } + firstUser, ok := updates[0].Users[0].(*tg.User) + if !ok { + t.Fatalf("updates[0].Users[0] = %T, want *tg.User", updates[0].Users[0]) + } + secondUser, ok := updates[1].Users[0].(*tg.User) + if !ok { + t.Fatalf("updates[1].Users[0] = %T, want *tg.User", updates[1].Users[0]) + } + if firstUser.FirstName != "viewer2" || secondUser.FirstName != "viewer3" { + t.Fatalf("projected users = %q/%q, want viewer-specific names", firstUser.FirstName, secondUser.FirstName) + } + wantCalls := []outboxUsersCall{ + {viewerUserID: 1000000002, ids: []int64{senderUserID}}, + {viewerUserID: 1000000003, ids: []int64{senderUserID}}, + } + if !sameOutboxUsersCalls(users.calls, wantCalls) { + t.Fatalf("ByIDs calls = %+v, want %+v", users.calls, wantCalls) + } +} + +func TestChannelMessageUpdatesIncludesActionUsers(t *testing.T) { + const ( + viewerUserID = int64(1000000002) + senderUserID = int64(1000000001) + actionUserID = int64(1000000003) + channelID = int64(2000000001) + messageID = 41 + messageDate = 1700000330 + messagePts = 17 + ) + msg := domain.ChannelMessage{ + ID: messageID, + ChannelID: channelID, + SenderUserID: senderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + Date: messageDate, + Action: &domain.ChannelMessageAction{ + Type: domain.ChannelActionChatAddUser, + UserIDs: []int64{actionUserID}, + }, + Pts: messagePts, + } + router := New(Config{}, Deps{ + Users: mapUsersService{users: map[int64]domain.User{ + senderUserID: {ID: senderUserID, FirstName: "Sender"}, + actionUserID: {ID: actionUserID, FirstName: "Invitee", PhotoID: 9302, PhotoDCID: 2}, + }}, + }, zaptest.NewLogger(t), clock.System) + + updates := router.channelMessageUpdatesWithPeerCache(context.Background(), viewerUserID, domain.SendChannelMessageResult{ + Channel: domain.Channel{ID: channelID, AccessHash: 44, Title: "Group", Megagroup: true, Date: messageDate}, + Message: msg, + Event: domain.ChannelUpdateEvent{ + ChannelID: channelID, + Type: domain.ChannelUpdateNewMessage, + Pts: messagePts, + PtsCount: 1, + Date: messageDate, + Message: msg, + }, + }, 0, newViewerPeerCache(router)) + got := map[int64]*tg.User{} + for _, user := range updates.Users { + if u, ok := user.(*tg.User); ok { + got[u.ID] = u + } + } + if _, ok := got[senderUserID]; !ok { + t.Fatalf("sender user missing from updates.users: %+v", updates.Users) + } + actionUser, ok := got[actionUserID] + if !ok { + t.Fatalf("action user missing from updates.users: %+v", updates.Users) + } + if photo, ok := actionUser.Photo.(*tg.UserProfilePhoto); !ok || photo.PhotoID != 9302 { + t.Fatalf("action user photo = %#v, want projected profile photo", actionUser.Photo) + } +} + +func TestOutboxDispatcherBatchPathUsesUpdateBuilder(t *testing.T) { + items := []store.DispatchOutboxItem{ + {ID: 3, TargetUserID: 1000000003, Pts: 12, EventType: domain.UpdateEventReadHistoryInbox}, + {ID: 1, TargetUserID: 1000000002, Pts: 10, EventType: domain.UpdateEventReadHistoryInbox}, + } + events := &batchEventStore{captureUpdateEventStore: &captureUpdateEventStore{events: []domain.UpdateEvent{ + { + UserID: 1000000002, + Type: domain.UpdateEventReadHistoryInbox, + Pts: 10, + PtsCount: 1, + Date: 1700000310, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + MaxID: 10, + StillUnreadCount: 0, + }, + { + UserID: 1000000003, + Type: domain.UpdateEventReadHistoryInbox, + Pts: 12, + PtsCount: 1, + Date: 1700000312, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + MaxID: 12, + StillUnreadCount: 0, + }, + }}} + outbox := &batchDispatchOutbox{captureDispatchOutbox: &captureDispatchOutbox{items: items}} + sessions := &orderedOutboxCaptureSessions{} + var gotRequests []OutboxUpdateRequest + builder := func(_ context.Context, requests []OutboxUpdateRequest) []*tg.Updates { + gotRequests = append([]OutboxUpdateRequest(nil), requests...) + out := make([]*tg.Updates, len(requests)) + for i, req := range requests { + out[i] = &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateReadHistoryInbox{ + Peer: &tg.PeerUser{UserID: req.Event.Peer.ID}, + MaxID: req.Event.MaxID, + StillUnreadCount: req.Event.StillUnreadCount, + Pts: req.Event.Pts, + PtsCount: req.Event.PtsCount, + }}, + Date: req.Event.Date, + } + } + return out + } + dispatcher := NewOutboxDispatcher(events, outbox, sessions, zaptest.NewLogger(t), WithOutboxUpdateBuilder(builder)) + + dispatcher.DispatchOnce(context.Background()) + + if len(gotRequests) != 2 { + t.Fatalf("builder requests = %+v, want two requests", gotRequests) + } + if gotRequests[0].TargetUserID != 1000000002 || gotRequests[0].Event.Pts != 10 || gotRequests[1].TargetUserID != 1000000003 || gotRequests[1].Event.Pts != 12 { + t.Fatalf("builder requests = %+v, want sorted by target user then pts", gotRequests) + } + if got := sessions.pushedPts(); !reflect.DeepEqual(got, []int{10, 12}) { + t.Fatalf("pushed pts = %v, want builder updates in sorted order", got) + } + if len(outbox.deliveredBatch) != 2 { + t.Fatalf("delivered batch = %+v, want two delivered items", outbox.deliveredBatch) + } +} + +func TestOutboxDispatcherOrdersClaimedItemsByUserPts(t *testing.T) { + const targetUserID int64 = 1000000002 + items := []store.DispatchOutboxItem{ + {ID: 3, TargetUserID: targetUserID, Pts: 12, EventType: domain.UpdateEventNewMessage}, + {ID: 1, TargetUserID: targetUserID, Pts: 10, EventType: domain.UpdateEventNewMessage}, + {ID: 2, TargetUserID: targetUserID, Pts: 11, EventType: domain.UpdateEventNewMessage}, + } + events := make([]domain.UpdateEvent, 0, len(items)) + for _, pts := range []int{10, 11, 12} { + msg := domain.Message{ + ID: pts, + OwnerUserID: targetUserID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000300 + pts, + Body: "ordered", + Pts: pts, + } + events = append(events, domain.UpdateEvent{ + UserID: targetUserID, + Type: domain.UpdateEventNewMessage, + Pts: pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{{ID: msg.From.ID, FirstName: "Sender"}}, + }) + } + eventStore := &batchEventStore{captureUpdateEventStore: &captureUpdateEventStore{events: events}} + outbox := &batchDispatchOutbox{captureDispatchOutbox: &captureDispatchOutbox{items: items}} + sessions := &orderedOutboxCaptureSessions{} + dispatcher := NewOutboxDispatcher(eventStore, outbox, sessions, zaptest.NewLogger(t)) + + dispatcher.DispatchOnce(context.Background()) + + want := []int{10, 11, 12} + if got := sessions.pushedPts(); !reflect.DeepEqual(got, want) { + t.Fatalf("pushed pts = %v, want %v", got, want) + } + if got := eventStore.batchCursors; len(got) != len(want) { + t.Fatalf("batch cursors = %+v, want %d cursors", got, len(want)) + } else { + for i, cursor := range got { + if cursor.UserID != targetUserID || cursor.Pts != want[i] { + t.Fatalf("batch cursor[%d] = %+v, want user=%d pts=%d", i, cursor, targetUserID, want[i]) + } + } + } +} + +type outboxUsersCall struct { + viewerUserID int64 + ids []int64 +} + +func sameOutboxUsersCalls(got, want []outboxUsersCall) bool { + if len(got) != len(want) { + return false + } + used := make([]bool, len(want)) + for _, call := range got { + found := false + for i, expected := range want { + if used[i] || call.viewerUserID != expected.viewerUserID || !reflect.DeepEqual(call.ids, expected.ids) { + continue + } + used[i] = true + found = true + break + } + if !found { + return false + } + } + return true +} + +type countingOutboxUsersService struct { + users map[int64]domain.User + calls []outboxUsersCall +} + +type viewerSpecificOutboxUsersService struct { + calls []outboxUsersCall +} + +func (s *viewerSpecificOutboxUsersService) Self(_ context.Context, userID int64) (domain.User, error) { + return domain.User{ID: userID, FirstName: "self"}, nil +} + +func (s *viewerSpecificOutboxUsersService) ByID(_ context.Context, currentUserID, userID int64) (domain.User, bool, error) { + return domain.User{ID: userID, FirstName: viewerSpecificName(currentUserID)}, true, nil +} + +func (s *viewerSpecificOutboxUsersService) ByIDs(_ context.Context, viewerUserID int64, userIDs []int64) ([]domain.User, error) { + s.calls = append(s.calls, outboxUsersCall{viewerUserID: viewerUserID, ids: append([]int64(nil), userIDs...)}) + out := make([]domain.User, 0, len(userIDs)) + for _, userID := range userIDs { + out = append(out, domain.User{ID: userID, FirstName: viewerSpecificName(viewerUserID)}) + } + return out, nil +} + +func viewerSpecificName(viewerUserID int64) string { + switch viewerUserID { + case 1000000002: + return "viewer2" + case 1000000003: + return "viewer3" + default: + return "viewer" + } +} + +func (s *countingOutboxUsersService) Self(_ context.Context, userID int64) (domain.User, error) { + if u, ok := s.users[userID]; ok { + return u, nil + } + return domain.User{}, nil +} + +func (s *countingOutboxUsersService) ByID(_ context.Context, _ int64, userID int64) (domain.User, bool, error) { + u, ok := s.users[userID] + return u, ok, nil +} + +func (s *countingOutboxUsersService) ByIDs(_ context.Context, viewerUserID int64, userIDs []int64) ([]domain.User, error) { + s.calls = append(s.calls, outboxUsersCall{viewerUserID: viewerUserID, ids: append([]int64(nil), userIDs...)}) + out := make([]domain.User, 0, len(userIDs)) + for _, id := range userIDs { + if u, ok := s.users[id]; ok { + out = append(out, u) + } + } + return out, nil +} + func TestOutboxDispatcherUsesBestEffortPush(t *testing.T) { msg := domain.Message{ ID: 10, @@ -200,6 +596,38 @@ func (s *captureBestEffortSessions) PushToUserExceptSessionBestEffort(ctx contex return s.PushToUserExceptSession(ctx, userID, excludeSessionID, t, msg) } +type orderedOutboxCaptureSessions struct { + captureSessions + pushed []int +} + +func (s *orderedOutboxCaptureSessions) PushToUserExceptSession(_ context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + if updates, ok := msg.(*tg.Updates); ok { + s.pushed = append(s.pushed, firstOutboxUpdatePts(updates)) + } + return s.captureSessions.PushToUserExceptSession(context.Background(), userID, excludeSessionID, t, msg) +} + +func (s *orderedOutboxCaptureSessions) pushedPts() []int { + return append([]int(nil), s.pushed...) +} + +func firstOutboxUpdatePts(updates *tg.Updates) int { + if updates == nil || len(updates.Updates) == 0 { + return 0 + } + switch update := updates.Updates[0].(type) { + case *tg.UpdateNewMessage: + return update.Pts + case *tg.UpdateReadHistoryInbox: + return update.Pts + case *tg.UpdateReadHistoryOutbox: + return update.Pts + default: + return 0 + } +} + // batchEventStore 给 captureUpdateEventStore 加上 BatchByCursor 批量能力。 type batchEventStore struct { *captureUpdateEventStore @@ -238,6 +666,22 @@ func (s *captureUpdateEventStore) Append(context.Context, int64, domain.UpdateEv return nil } +func (s *captureUpdateEventStore) AppendAllocated(_ context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, error) { + if event.PtsCount <= 0 { + event.PtsCount = 1 + } + event.UserID = userID + maxPts := 0 + for _, existing := range s.events { + if existing.UserID == userID && existing.Pts > maxPts { + maxPts = existing.Pts + } + } + event.Pts = maxPts + event.PtsCount + s.events = append(s.events, event) + return event, nil +} + func (s *captureUpdateEventStore) ListAfter(_ context.Context, _ int64, pts, limit int) ([]domain.UpdateEvent, error) { out := make([]domain.UpdateEvent, 0, len(s.events)) for _, event := range s.events { @@ -276,10 +720,6 @@ func (s *captureUpdateEventStore) MaxContiguousPts(context.Context, int64) (int, return contiguous, nil } -func (s *captureUpdateEventStore) AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) { - return s.MaxContiguousPts(ctx, userID) -} - type captureDispatchOutbox struct { items []store.DispatchOutboxItem delivered bool @@ -292,6 +732,7 @@ type captureDispatchOutbox struct { type captureScopedSessions struct { *captureSessions scopedAuthKeyID [8]byte + immediatePush bool } func (s *captureScopedSessions) BindAuthKeyForSession(rawAuthKeyID [8]byte, sessionID int64, authKeyID [8]byte) { @@ -323,6 +764,12 @@ func (s *captureScopedSessions) PushToSessionForAuthKey(_ context.Context, rawAu return s.PushToSession(context.Background(), sessionID, t, msg) } +func (s *captureScopedSessions) PushToSessionForAuthKeyImmediate(_ context.Context, rawAuthKeyID [8]byte, sessionID int64, t proto.MessageType, msg bin.Encoder) error { + s.immediatePush = true + s.scopedAuthKeyID = rawAuthKeyID + return s.PushToSession(context.Background(), sessionID, t, msg) +} + func (s *captureScopedSessions) PushToUserExceptAuthKeySession(_ context.Context, userID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { s.scopedAuthKeyID = excludeAuthKeyID return s.PushToUserExceptSession(context.Background(), userID, excludeSessionID, t, msg) diff --git a/internal/rpc/outbox_update_builder.go b/internal/rpc/outbox_update_builder.go new file mode 100644 index 00000000..21291462 --- /dev/null +++ b/internal/rpc/outbox_update_builder.go @@ -0,0 +1,50 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// BuildOutboxUpdates 为在线 outbox worker 构造按接收者视角补全后的 updates。 +func (r *Router) BuildOutboxUpdates(ctx context.Context, requests []OutboxUpdateRequest) []*tg.Updates { + out := make([]*tg.Updates, len(requests)) + if len(requests) == 0 { + return out + } + cache := newViewerPeerCache(r) + groups := make(map[int64][]outboxUpdateBuildItem) + for i, req := range requests { + viewerUserID := req.TargetUserID + if viewerUserID == 0 { + viewerUserID = req.Event.UserID + } + event := req.Event + if event.UserID == 0 { + event.UserID = viewerUserID + } + groups[viewerUserID] = append(groups[viewerUserID], outboxUpdateBuildItem{index: i, event: event}) + } + for viewerUserID, items := range groups { + events := make([]domain.UpdateEvent, len(items)) + for i, item := range items { + events[i] = item.event + } + events = r.enrichUpdateEventsWithPeerCache(ctx, viewerUserID, events, cache) + for i, item := range items { + update := tgUpdateForOutboxEventForViewer(events[i], viewerUserID) + if peers := storyUpdateEventPeers(events[i]); len(peers) > 0 { + update = r.withStoryUpdatePeerObjects(ctx, viewerUserID, update, peers...) + } + out[item.index] = update + } + } + return out +} + +type outboxUpdateBuildItem struct { + index int + event domain.UpdateEvent +} diff --git a/internal/rpc/passive_compat_test.go b/internal/rpc/passive_compat_test.go new file mode 100644 index 00000000..8c24e22b --- /dev/null +++ b/internal/rpc/passive_compat_test.go @@ -0,0 +1,286 @@ +package rpc + +import ( + "context" + "strings" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" +) + +func TestAccountGetChatThemesReturnsStaticThemes(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + req := &tg.AccountGetChatThemesRequest{} + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + got, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := got.(*tg.AccountThemesBox) + if !ok { + t.Fatalf("response type = %T, want *tg.AccountThemesBox", got) + } + themes, ok := box.Themes.(*tg.AccountThemes) + if !ok { + t.Fatalf("boxed response type = %T, want *tg.AccountThemes", box.Themes) + } + if themes.Hash == 0 || len(themes.Themes) == 0 { + t.Fatalf("themes = %+v, want non-empty stable list", themes) + } +} + +func TestAccountGetUniqueGiftChatThemesReturnsEmptyStub(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + req := &tg.AccountGetUniqueGiftChatThemesRequest{Limit: 20} + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + got, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := got.(*tg.AccountChatThemesBox) + if !ok { + t.Fatalf("response type = %T, want *tg.AccountChatThemesBox", got) + } + themes, ok := box.ChatThemes.(*tg.AccountChatThemes) + if !ok { + t.Fatalf("boxed response type = %T, want *tg.AccountChatThemes", box.ChatThemes) + } + if themes.Hash == 0 || len(themes.Themes) != 0 || len(themes.Chats) != 0 || len(themes.Users) != 0 { + t.Fatalf("unique gift themes = %+v, want stable empty catalog", themes) + } +} + +func TestAccountGetWallPapersReturnsOrangeCatalog(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + req := &tg.AccountGetWallPapersRequest{} + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + got, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := got.(*tg.AccountWallPapersBox) + if !ok { + t.Fatalf("response type = %T, want *tg.AccountWallPapersBox", got) + } + wallpapers, ok := box.WallPapers.(*tg.AccountWallPapers) + if !ok { + t.Fatalf("boxed response type = %T, want *tg.AccountWallPapers", box.WallPapers) + } + if wallpapers.Hash == 0 || len(wallpapers.Wallpapers) == 0 { + t.Fatalf("wallpapers = %+v, want stable seed catalog", wallpapers) + } +} + +func TestAccountWallpaperSeedLookupAndAckRPCs(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), 1000000001) + + var listReq bin.Buffer + if err := (&tg.AccountGetWallPapersRequest{}).Encode(&listReq); err != nil { + t.Fatalf("encode list request: %v", err) + } + listGot, err := r.Dispatch(ctx, [8]byte{}, 0, &listReq) + if err != nil { + t.Fatalf("dispatch list: %v", err) + } + list := listGot.(*tg.AccountWallPapersBox).WallPapers.(*tg.AccountWallPapers) + first := list.Wallpapers[0].(*tg.WallPaper) + input := &tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash} + + var oneReq bin.Buffer + if err := (&tg.AccountGetWallPaperRequest{Wallpaper: input}).Encode(&oneReq); err != nil { + t.Fatalf("encode getWallPaper request: %v", err) + } + oneGot, err := r.Dispatch(ctx, [8]byte{}, 0, &oneReq) + if err != nil { + t.Fatalf("dispatch getWallPaper: %v", err) + } + oneBox, ok := oneGot.(*tg.WallPaperBox) + if !ok { + t.Fatalf("getWallPaper = %T, want *tg.WallPaperBox", oneGot) + } + oneWallpaper, ok := oneBox.WallPaper.(*tg.WallPaper) + if !ok { + t.Fatalf("getWallPaper boxed = %T, want *tg.WallPaper", oneBox.WallPaper) + } + if oneWallpaper.ID != first.ID { + t.Fatalf("getWallPaper id = %d, want %d", oneWallpaper.ID, first.ID) + } + + nofileInput := &tg.InputWallPaperNoFile{ID: 930000000000000001} + var nofileReq bin.Buffer + if err := (&tg.AccountGetWallPaperRequest{Wallpaper: nofileInput}).Encode(&nofileReq); err != nil { + t.Fatalf("encode getWallPaper nofile request: %v", err) + } + nofileGot, err := r.Dispatch(ctx, [8]byte{}, 0, &nofileReq) + if err != nil { + t.Fatalf("dispatch getWallPaper nofile: %v", err) + } + nofileBox, ok := nofileGot.(*tg.WallPaperBox) + if !ok { + t.Fatalf("getWallPaper nofile = %T, want *tg.WallPaperBox", nofileGot) + } + nofileWallpaper, ok := nofileBox.WallPaper.(*tg.WallPaperNoFile) + if !ok || nofileWallpaper.ID != nofileInput.ID { + t.Fatalf("getWallPaper nofile boxed = %T %#v, want no-file id", nofileBox.WallPaper, nofileBox.WallPaper) + } + + var multiReq bin.Buffer + if err := (&tg.AccountGetMultiWallPapersRequest{Wallpapers: []tg.InputWallPaperClass{ + input, + &tg.InputWallPaperSlug{Slug: first.Slug}, + nofileInput, + }}).Encode(&multiReq); err != nil { + t.Fatalf("encode getMultiWallPapers request: %v", err) + } + multiGot, err := r.Dispatch(ctx, [8]byte{}, 0, &multiReq) + if err != nil { + t.Fatalf("dispatch getMultiWallPapers: %v", err) + } + if vector, ok := multiGot.(*tg.WallPaperClassVector); !ok || len(vector.Elems) != 3 { + t.Fatalf("getMultiWallPapers = %T %#v, want 3 wallpapers", multiGot, multiGot) + } + + for name, request := range map[string]bin.Encoder{ + "save": &tg.AccountSaveWallPaperRequest{Wallpaper: input}, + "install": &tg.AccountInstallWallPaperRequest{Wallpaper: input}, + "save_nofile": &tg.AccountSaveWallPaperRequest{Wallpaper: nofileInput}, + "install_nofile": &tg.AccountInstallWallPaperRequest{Wallpaper: nofileInput}, + "reset": &tg.AccountResetWallPapersRequest{}, + } { + var encoded bin.Buffer + if err := request.Encode(&encoded); err != nil { + t.Fatalf("encode %s: %v", name, err) + } + got, err := r.Dispatch(ctx, [8]byte{}, 0, &encoded) + if err != nil { + t.Fatalf("dispatch %s: %v", name, err) + } + box, ok := got.(*tg.BoolBox) + if !ok { + t.Fatalf("%s = %T, want *tg.BoolBox", name, got) + } + if _, ok := box.Bool.(*tg.BoolTrue); !ok { + t.Fatalf("%s boxed = %T, want *tg.BoolTrue", name, box.Bool) + } + } + + var badReq bin.Buffer + if err := (&tg.AccountGetWallPaperRequest{Wallpaper: &tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash + 1}}).Encode(&badReq); err != nil { + t.Fatalf("encode bad getWallPaper request: %v", err) + } + if _, err := r.Dispatch(ctx, [8]byte{}, 0, &badReq); err == nil || !strings.Contains(err.Error(), "WALLPAPER_INVALID") { + t.Fatalf("bad getWallPaper err = %v, want WALLPAPER_INVALID", err) + } +} + +func TestPaymentsGetStarGiftCollectionsReturnsEmptyAndValidatesPeer(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), 1000000001) + + var okReq bin.Buffer + if err := (&tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}}).Encode(&okReq); err != nil { + t.Fatalf("encode request: %v", err) + } + got, err := r.Dispatch(ctx, [8]byte{}, 0, &okReq) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + box, ok := got.(*tg.PaymentsStarGiftCollectionsBox) + if !ok { + t.Fatalf("response type = %T, want *tg.PaymentsStarGiftCollectionsBox", got) + } + collections, ok := box.StarGiftCollections.(*tg.PaymentsStarGiftCollections) + if !ok { + t.Fatalf("boxed response type = %T, want *tg.PaymentsStarGiftCollections", box.StarGiftCollections) + } + if len(collections.Collections) != 0 { + t.Fatalf("collections = %+v, want empty list", collections.Collections) + } + + var badReq bin.Buffer + if err := (&tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerEmpty{}}).Encode(&badReq); err != nil { + t.Fatalf("encode bad request: %v", err) + } + if _, err := r.Dispatch(ctx, [8]byte{}, 0, &badReq); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") { + t.Fatalf("bad peer err = %v, want PEER_ID_INVALID", err) + } +} + +func TestPaymentsGetStarsRevenueAdsAccountURLReturnsCompatURLAndValidatesPeer(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), 1000000001) + + var okReq bin.Buffer + if err := (&tg.PaymentsGetStarsRevenueAdsAccountURLRequest{Peer: &tg.InputPeerSelf{}}).Encode(&okReq); err != nil { + t.Fatalf("encode request: %v", err) + } + got, err := r.Dispatch(ctx, [8]byte{}, 0, &okReq) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + url, ok := got.(*tg.PaymentsStarsRevenueAdsAccountURL) + if !ok { + t.Fatalf("response type = %T, want *tg.PaymentsStarsRevenueAdsAccountURL", got) + } + if url.URL != "https://ads.telegram.org/" { + t.Fatalf("url = %q, want ads compat URL", url.URL) + } + + var badReq bin.Buffer + if err := (&tg.PaymentsGetStarsRevenueAdsAccountURLRequest{Peer: &tg.InputPeerEmpty{}}).Encode(&badReq); err != nil { + t.Fatalf("encode bad request: %v", err) + } + if _, err := r.Dispatch(ctx, [8]byte{}, 0, &badReq); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") { + t.Fatalf("bad peer err = %v, want PEER_ID_INVALID", err) + } +} + +func TestPaymentsGetStarsRevenueStatsReturnsZeroStubAndValidatesPeer(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), 1000000001) + + var okReq bin.Buffer + req := &tg.PaymentsGetStarsRevenueStatsRequest{Ton: true, Peer: &tg.InputPeerSelf{}} + req.SetFlags() + if err := req.Encode(&okReq); err != nil { + t.Fatalf("encode request: %v", err) + } + got, err := r.Dispatch(ctx, [8]byte{}, 0, &okReq) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + stats, ok := got.(*tg.PaymentsStarsRevenueStats) + if !ok { + t.Fatalf("response type = %T, want *tg.PaymentsStarsRevenueStats", got) + } + if _, ok := stats.Status.CurrentBalance.(*tg.StarsTonAmount); !ok { + t.Fatalf("current balance = %T, want *tg.StarsTonAmount", stats.Status.CurrentBalance) + } + if _, ok := stats.RevenueGraph.(*tg.StatsGraphError); !ok { + t.Fatalf("revenue graph = %T, want *tg.StatsGraphError", stats.RevenueGraph) + } + + var badReq bin.Buffer + if err := (&tg.PaymentsGetStarsRevenueStatsRequest{Peer: &tg.InputPeerEmpty{}}).Encode(&badReq); err != nil { + t.Fatalf("encode bad request: %v", err) + } + if _, err := r.Dispatch(ctx, [8]byte{}, 0, &badReq); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") { + t.Fatalf("bad peer err = %v, want PEER_ID_INVALID", err) + } +} diff --git a/internal/rpc/payments.go b/internal/rpc/payments.go index eabfb288..c5249d6d 100644 --- a/internal/rpc/payments.go +++ b/internal/rpc/payments.go @@ -2,32 +2,215 @@ package rpc import ( "context" + "errors" + "strconv" "github.com/gotd/td/tg" "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" ) -// registerPayments 注册第一阶段 TDesktop 启动所需 payments.* RPC 兼容响应。 +// registerPayments 注册 payments.* RPC:Stars 本地账本(余额/流水真实化)+ 其余 +// gift/auction/revenue 第一阶段兼容桩。 func (r *Router) registerPayments(d *tg.ServerDispatcher) { - d.OnPaymentsGetStarsStatus(func(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) { - balance := tg.StarsAmountClass(&tg.StarsAmount{}) - if req.GetTon() { - balance = &tg.StarsTonAmount{} - } - return &tg.PaymentsStarsStatus{ - Balance: balance, - Chats: []tg.ChatClass{}, - Users: []tg.UserClass{}, - }, nil + d.OnPaymentsGetStarsTopupOptions(func(ctx context.Context) ([]tg.StarsTopupOption, error) { + return []tg.StarsTopupOption{}, nil }) + // premium 订阅赠送 telesrv 不实现(无支付流),返回空选项。关键作用:TDesktop 送礼框 + // ShowStarGiftBox 的 ready() 门控要求 getPremiumGiftCodeOptions 成功返回(on_next)才置 + // premiumGiftsReady=true,否则整框不弹出——此前返 NOT_IMPLEMENTED 导致点生日礼物无反应。 + // 空列表即解门,星礼物正常发送;premium 区段另由 userFull.disallow_premium_gifts=true 隐藏。 + d.OnPaymentsGetPremiumGiftCodeOptions(func(ctx context.Context, req *tg.PaymentsGetPremiumGiftCodeOptionsRequest) ([]tg.PremiumGiftCodeOption, error) { + return []tg.PremiumGiftCodeOption{}, nil + }) + d.OnPaymentsGetStarsStatus(r.onPaymentsGetStarsStatus) + d.OnPaymentsGetStarsTransactions(r.onPaymentsGetStarsTransactions) d.OnPaymentsGetStarGiftActiveAuctions(func(ctx context.Context, hash int64) (tg.PaymentsStarGiftActiveAuctionsClass, error) { return tdesktop.StarGiftActiveAuctions(), nil }) - d.OnPaymentsGetSavedStarGifts(func(ctx context.Context, req *tg.PaymentsGetSavedStarGiftsRequest) (*tg.PaymentsSavedStarGifts, error) { - return tdesktop.SavedStarGifts(), nil + d.OnPaymentsGetStarGifts(r.onPaymentsGetStarGifts) + d.OnPaymentsGetPaymentForm(r.onPaymentsGetPaymentForm) + d.OnPaymentsSendStarsForm(r.onPaymentsSendStarsForm) + d.OnPaymentsGetSavedStarGifts(r.onPaymentsGetSavedStarGifts) + d.OnPaymentsGetSavedStarGift(r.onPaymentsGetSavedStarGift) + d.OnPaymentsSaveStarGift(r.onPaymentsSaveStarGift) + d.OnPaymentsConvertStarGift(r.onPaymentsConvertStarGift) + d.OnPaymentsGetStarGiftCollections(func(ctx context.Context, req *tg.PaymentsGetStarGiftCollectionsRequest) (tg.PaymentsStarGiftCollectionsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req == nil { + return nil, peerIDInvalidErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + return tdesktop.StarGiftCollections(), nil }) - d.OnPaymentsGetSavedStarGift(func(ctx context.Context, stargift []tg.InputSavedStarGiftClass) (*tg.PaymentsSavedStarGifts, error) { - return tdesktop.SavedStarGifts(), nil + d.OnPaymentsGetStarsRevenueAdsAccountURL(func(ctx context.Context, peer tg.InputPeerClass) (*tg.PaymentsStarsRevenueAdsAccountURL, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { + return nil, err + } + return &tg.PaymentsStarsRevenueAdsAccountURL{URL: "https://ads.telegram.org/"}, nil + }) + d.OnPaymentsGetStarsRevenueStats(func(ctx context.Context, req *tg.PaymentsGetStarsRevenueStatsRequest) (*tg.PaymentsStarsRevenueStats, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if req == nil { + return nil, peerIDInvalidErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + return tdesktop.StarsRevenueStats(req.GetTon()), nil }) } + +// onPaymentsGetStarsStatus 返回当前账号的 Stars 余额(首读时惰性授予起始余额)。 +// 响应必须是 payments.starsStatus(balance/chats/users 都是必填,空 vector 即可)—— +// 两端客户端无条件读取 balance(DrKLO StarsAmount 反序列化 / TDesktop vbalance())。 +func (r *Router) onPaymentsGetStarsStatus(ctx context.Context, req *tg.PaymentsGetStarsStatusRequest) (*tg.PaymentsStarsStatus, error) { + if req != nil && req.GetTon() { + // TON 余额未建模:返回 0 nanoton 的合法响应。 + return emptyStarsStatus(&tg.StarsTonAmount{}), nil + } + if r.deps.Stars == nil { + return emptyStarsStatus(&tg.StarsAmount{}), nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + bal, err := r.deps.Stars.GetBalance(ctx, userID) + if err != nil { + return nil, starsErr(err) + } + return emptyStarsStatus(&tg.StarsAmount{Amount: bal.Balance}), nil +} + +// onPaymentsGetStarsTransactions 返回 keyset 分页的 Stars 流水(同 starsStatus 信封)。 +// 末页必须省略 next_offset(flag 不置),否则 DrKLO 会无限翻页。 +func (r *Router) onPaymentsGetStarsTransactions(ctx context.Context, req *tg.PaymentsGetStarsTransactionsRequest) (*tg.PaymentsStarsStatus, error) { + if req != nil && req.GetTon() { + return emptyStarsStatus(&tg.StarsTonAmount{}), nil + } + if r.deps.Stars == nil { + return emptyStarsStatus(&tg.StarsAmount{}), nil + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + offset := "" + limit := domain.MaxStarsTransactionsLimit + if req != nil { + offset = req.Offset + if req.Limit > 0 { + limit = req.Limit + } + } + page, err := r.deps.Stars.ListTransactions(ctx, userID, offset, limit) + if err != nil { + return nil, starsErr(err) + } + out := emptyStarsStatus(&tg.StarsAmount{Amount: page.Balance}) + if txns := tgStarsTransactions(page.Transactions); len(txns) > 0 { + out.SetHistory(txns) + } + if page.NextOffset != "" { + out.SetNextOffset(page.NextOffset) + } + // 富化流水中提到的用户对手方(频道对手方进 Chats 留待 paid reaction 阶段)。 + if ids := starsTransactionUserIDs(page.Transactions); len(ids) > 0 { + out.Users = tgUsersForViewer(userID, r.domainUsersForIDs(ctx, userID, ids)) + } + return out, nil +} + +// emptyStarsStatus 构造一个合法的最小 payments.starsStatus(chats/users 非空 vector 但可空)。 +func emptyStarsStatus(balance tg.StarsAmountClass) *tg.PaymentsStarsStatus { + return &tg.PaymentsStarsStatus{ + Balance: balance, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +// tgStarsTransactions 把账本流水投影为 tg.StarsTransaction(amount 带符号:借记为负)。 +func tgStarsTransactions(in []domain.StarsTransaction) []tg.StarsTransaction { + out := make([]tg.StarsTransaction, 0, len(in)) + for _, t := range in { + item := tg.StarsTransaction{ + ID: strconv.FormatInt(t.ID, 10), + Amount: &tg.StarsAmount{Amount: t.Amount}, + Date: t.Date, + Peer: tgStarsTransactionPeer(t), + } + if t.Title != "" { + item.SetTitle(t.Title) + } + if t.Description != "" { + item.SetDescription(t.Description) + } + switch t.Reason { + case domain.StarsReasonReaction: + item.Reaction = true + case domain.StarsReasonGift: + item.Gift = true + } + out = append(out, item) + } + return out +} + +// tgStarsTransactionPeer 选择对手方构造器:grant/topup 走 Fragment(站外充值轨), +// 真实 peer 走 starsTransactionPeer,其余兜底 Unsupported(Peer 字段必填,不可为 nil)。 +func tgStarsTransactionPeer(t domain.StarsTransaction) tg.StarsTransactionPeerClass { + switch t.Reason { + case domain.StarsReasonGrant, domain.StarsReasonTopup: + return &tg.StarsTransactionPeerFragment{} + } + if t.Peer.Type != "" && t.Peer.ID != 0 { + if p := tgPeer(t.Peer); p != nil { + return &tg.StarsTransactionPeer{Peer: p} + } + } + return &tg.StarsTransactionPeerUnsupported{} +} + +// starsTransactionUserIDs 收集流水中去重的用户类对手方 id。 +func starsTransactionUserIDs(in []domain.StarsTransaction) []int64 { + seen := make(map[int64]struct{}, len(in)) + ids := make([]int64, 0, len(in)) + for _, t := range in { + if t.Peer.Type != domain.PeerTypeUser || t.Peer.ID == 0 { + continue + } + if _, ok := seen[t.Peer.ID]; ok { + continue + } + seen[t.Peer.ID] = struct{}{} + ids = append(ids, t.Peer.ID) + } + return ids +} + +// starsErr 把 Stars 账本领域错误映射为客户端可识别的 tgerr(仿 premiumBoostErr)。 +func starsErr(err error) error { + switch { + case errors.Is(err, domain.ErrStarsInsufficient): + return balanceTooLowErr() + case errors.Is(err, domain.ErrStarsInvalidAmount): + return starsAmountInvalidErr() + default: + return internalErr() + } +} diff --git a/internal/rpc/payments_star_gifts.go b/internal/rpc/payments_star_gifts.go new file mode 100644 index 00000000..0fe0ae7a --- /dev/null +++ b/internal/rpc/payments_star_gifts.go @@ -0,0 +1,684 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// Star gift(payments.* 礼物 RPC):目录 / 购买表单 / 发送 / 收礼列表 / 展示切换 / 转换回 Stars。 +// 扣费经 r.deps.Stars 账本;用户礼物走私聊服务消息,频道礼物只落 saved gifts + admin log。 + +func starGiftInvalidErr() error { return tgerr.New(400, "STARGIFT_INVALID") } + +// onPaymentsGetStarGifts 返回可购买礼物目录(hash 命中返回 NotModified)。 +func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) { + if r.deps.Gifts == nil { + return &tg.PaymentsStarGifts{Gifts: []tg.StarGiftClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil + } + catalogHash, err := r.deps.Gifts.CatalogHash(ctx) + if err != nil { + return nil, internalErr() + } + catalog, err := r.deps.Gifts.Catalog(ctx) + if err != nil { + return nil, internalErr() + } + // 刻意不返回 starGiftsNotModified:目录只有少量静态礼物,而 DrKLO 在 force-stop/重装后 + // 会保留 catalog hash 但丢失礼物缓存——一旦命中 hash 返回 NotModified,送礼选择器就永远空。 + // 始终回完整目录(带宽可忽略),保证客户端无论缓存状态都能渲染礼物网格。 + _ = catalogHash + return &tg.PaymentsStarGifts{ + Hash: catalogHash, + Gifts: tgStarGifts(catalog), + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil +} + +// onPaymentsGetPaymentForm 仅处理 inputInvoiceStarGift:返回 paymentFormStarGift。 +// 崩溃约束:star gift invoice 必须返 paymentFormStarGift#b425cfe1(TDesktop 单分支 match), +// Invoice.Prices 必须非空(DrKLO/TDesktop 读 prices.front())。 +func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsGetPaymentFormRequest) (tg.PaymentsPaymentFormClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + inv, ok := req.Invoice.(*tg.InputInvoiceStarGift) + if !ok { + return nil, notImplementedErr() + } + if r.deps.Gifts == nil { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer); err != nil { + return nil, err + } + gift, err := r.starGiftFromCatalog(ctx, inv.GiftID) + if err != nil { + return nil, err + } + return &tg.PaymentsPaymentFormStarGift{ + FormID: starGiftFormID(userID, inv.GiftID), + Invoice: tg.Invoice{ + Currency: "XTR", + Prices: []tg.LabeledPrice{{Label: giftPriceLabel(gift), Amount: gift.Stars}}, + }, + }, nil +} + +// onPaymentsSendStarsForm 仅处理 inputInvoiceStarGift:Debit→投递/记账, +// 返回 paymentResult{updates}(含 updateStarsBalance;用户礼物还含私聊服务消息)。失败补偿退款。 +// 崩溃约束:必须返回合法 paymentResult{非空 Updates}(DrKLO 强转)。 +func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSendStarsFormRequest) (tg.PaymentsPaymentResultClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + inv, ok := req.Invoice.(*tg.InputInvoiceStarGift) + if !ok { + return nil, notImplementedErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer) + if err != nil { + return nil, err + } + if (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + if r.deps.Stars == nil || r.deps.Gifts == nil { + return nil, notImplementedErr() + } + if peer.Type == domain.PeerTypeUser && r.deps.Messages == nil { + return nil, notImplementedErr() + } + if peer.Type == domain.PeerTypeChannel && r.deps.Channels == nil { + return nil, notImplementedErr() + } + gift, err := r.starGiftFromCatalog(ctx, inv.GiftID) + if err != nil { + return nil, err + } + giftMessage := "" + if m, ok := inv.GetMessage(); ok { + giftMessage = clampGiftMessage(m.Text) + } + + // 1. Debit 送礼人(不足→BALANCE_TOO_LOW)。 + balance, err := r.deps.Stars.Debit(ctx, userID, gift.Stars, domain.StarsReasonGift, peer, "Star gift", gift.Title) + if err != nil { + return nil, starsErr(err) + } + + var updates *tg.Updates + switch peer.Type { + case domain.PeerTypeUser: + updates, err = r.sendStarGiftToUser(ctx, userID, peer.ID, gift, inv.HideName, giftMessage) + case domain.PeerTypeChannel: + updates, err = r.sendStarGiftToChannel(ctx, userID, peer.ID, gift, inv.HideName, giftMessage) + default: + err = domain.ErrStarGiftInvalid + } + if err != nil { + r.refundStarGift(ctx, userID, peer, gift) + return nil, internalErr() + } + + // 4. 构建送礼人 Updates(服务消息 + updateStarsBalance)。 + if updates != nil { + updates.Updates = append(updates.Updates, &tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}) + } else { + updates = &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance.Balance}}}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + } + } + return &tg.PaymentsPaymentResult{Updates: updates}, nil +} + +func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) { + // 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。 + send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message) + if err != nil { + return nil, err + } + // 3. 记账:收礼人收到一份礼物实例(msg_id = 收礼人侧消息 id)。 + if _, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, + FromUserID: senderID, + GiftID: gift.ID, + MsgID: send.RecipientMessage.ID, + Date: send.RecipientMessage.Date, + NameHidden: hideName, + Unsaved: false, + ConvertStars: gift.ConvertStars, + Message: message, + }); err != nil { + return nil, err + } + // 收礼人 stargifts_count 变化 → 失效其 userFull 投影,资料页 Gifts 区段才会出现。 + r.invalidateRPCProjectionForUser(recipientID) + + users := r.usersForMessageUpdate(ctx, senderID, send.SenderMessage) + chats := r.chatsForMessageUpdate(ctx, senderID, send.SenderMessage) + return tgPrivateMessageUpdates(send.SenderEvent, send.SenderMessage, 0, false, users, chats), nil +} + +func (r *Router) sendStarGiftToChannel(ctx context.Context, senderID, channelID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) { + now := int(r.clock.Now().Unix()) + sticker := gift.Sticker + action := domain.ChannelMessageAction{ + Type: domain.ChannelActionStarGift, + StarGift: &domain.MessageStarGiftAction{ + GiftID: gift.ID, + Stars: gift.Stars, + ConvertStars: gift.ConvertStars, + Title: gift.Title, + Sticker: &sticker, + Message: message, + FromUserID: senderID, + NameHidden: hideName, + Saved: true, + }, + } + savedID, err := r.deps.Gifts.RecordSavedGift(ctx, domain.SavedStarGift{ + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + FromUserID: senderID, + GiftID: gift.ID, + MsgID: 0, + SavedID: 0, + Date: now, + NameHidden: hideName, + Unsaved: false, + ConvertStars: gift.ConvertStars, + Message: message, + }) + if err != nil { + return nil, err + } + action.StarGift.PeerChannelID = channelID + action.StarGift.SavedID = savedID + if err := r.deps.Channels.AppendStarGiftAdminLog(ctx, channelID, senderID, savedID, now, action); err != nil { + r.log.Warn("channel_star_gift_admin_log_failed", + zap.Int64("sender_id", senderID), + zap.Int64("channel_id", channelID), + zap.Int64("saved_id", savedID), + zap.Error(err), + ) + } + r.invalidateRPCProjectionForChannel(channelID) + return nil, nil +} + +// deliverStarGift 经 SendPrivateText 把 messageActionStarGift 服务消息投递到收礼人私聊。 +func (r *Router) deliverStarGift(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (domain.SendPrivateTextResult, error) { + recipientBlocked, err := r.peerBlocksUser(ctx, senderID, recipientID) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + sticker := gift.Sticker + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionStarGift, + StarGift: &domain.MessageStarGiftAction{ + GiftID: gift.ID, + Stars: gift.Stars, + ConvertStars: gift.ConvertStars, + Title: gift.Title, + Sticker: &sticker, + Message: message, + FromUserID: senderID, + PeerUserID: recipientID, + NameHidden: hideName, + Saved: true, + }, + }, + } + return r.deps.Messages.SendPrivateText(ctx, senderID, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: randomNonZeroInt64(), + Media: media, + Silent: false, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + RecipientBlocked: recipientBlocked, + }) +} + +// refundStarGift 补偿退款(投递/记账失败时把已 Debit 的星退回)。 +func (r *Router) refundStarGift(ctx context.Context, userID int64, peer domain.Peer, gift domain.StarGift) { + if _, err := r.deps.Stars.Credit(ctx, userID, gift.Stars, domain.StarsReasonGift, peer, "Star gift refund", gift.Title); err != nil { + r.log.Error("star gift refund failed", zap.Int64("user_id", userID), zap.Int64("gift_id", gift.ID), zap.Error(err)) + } +} + +// onPaymentsGetSavedStarGifts 返回某 peer 收到的礼物列表(末页省略 next_offset)。 +func (r *Router) onPaymentsGetSavedStarGifts(ctx context.Context, req *tg.PaymentsGetSavedStarGiftsRequest) (*tg.PaymentsSavedStarGifts, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + owner, err := r.starGiftOwnerPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Gifts == nil { + return emptySavedStarGifts(), nil + } + page, err := r.deps.Gifts.ListSaved(ctx, owner, req.ExcludeUnsaved, req.Offset, req.Limit) + if err != nil { + return nil, internalErr() + } + return r.tgSavedStarGiftsResponse(ctx, userID, page.Gifts, page.Count, page.NextOffset), nil +} + +// onPaymentsGetSavedStarGift 按 InputSavedStarGift 引用取指定礼物。 +func (r *Router) onPaymentsGetSavedStarGift(ctx context.Context, refs []tg.InputSavedStarGiftClass) (*tg.PaymentsSavedStarGifts, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Gifts == nil { + return emptySavedStarGifts(), nil + } + gifts := make([]domain.SavedStarGift, 0, len(refs)) + for _, ref := range refs { + dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref) + if err != nil { + return nil, err + } + if !ok { + continue + } + g, found, err := r.deps.Gifts.GetSaved(ctx, dref) + if err != nil { + return nil, internalErr() + } + if found && !g.Converted { + gifts = append(gifts, g) + } + } + return r.tgSavedStarGiftsResponse(ctx, userID, gifts, len(gifts), ""), nil +} + +// onPaymentsSaveStarGift 切换礼物在资料的展示(unsave=true 隐藏)。 +func (r *Router) onPaymentsSaveStarGift(ctx context.Context, req *tg.PaymentsSaveStarGiftRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Gifts == nil { + return false, notImplementedErr() + } + ref, ok, err := r.starGiftRefFromInput(ctx, userID, req.Stargift) + if err != nil { + return false, err + } + if !ok { + return false, starGiftInvalidErr() + } + if err := r.ensureCanManageStarGiftOwner(ctx, userID, ref.Owner); err != nil { + return false, err + } + updated, err := r.deps.Gifts.ToggleSaved(ctx, ref, req.Unsave) + if err != nil { + return false, internalErr() + } + if !updated { + return false, starGiftInvalidErr() + } + // 隐藏/展示切换改变展示礼物数 → 失效 owner full 投影。 + r.invalidateStarGiftOwnerProjection(ref.Owner) + return true, nil +} + +// onPaymentsConvertStarGift 把收到的礼物转换回 Stars(Credit + 标记 converted)。 +func (r *Router) onPaymentsConvertStarGift(ctx context.Context, ref tg.InputSavedStarGiftClass) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + if r.deps.Gifts == nil || r.deps.Stars == nil { + return false, notImplementedErr() + } + dref, ok, err := r.starGiftRefFromInput(ctx, userID, ref) + if err != nil { + return false, err + } + if !ok { + return false, starGiftInvalidErr() + } + if dref.Owner.Type == domain.PeerTypeChannel { + return false, notImplementedErr() + } + saved, err := r.deps.Gifts.Convert(ctx, dref) + if err != nil { + switch { + case errors.Is(err, domain.ErrStarGiftNotFound): + return false, starGiftInvalidErr() + case errors.Is(err, domain.ErrStarGiftAlreadyConverted): + return false, starGiftInvalidErr() + default: + return false, internalErr() + } + } + if saved.ConvertStars > 0 { + fromPeer := domain.Peer{Type: domain.PeerTypeUser, ID: saved.FromUserID} + if _, err := r.deps.Stars.Credit(ctx, userID, saved.ConvertStars, domain.StarsReasonGift, fromPeer, "Star gift conversion", ""); err != nil { + r.log.Error("star gift convert credit failed", zap.Int64("user_id", userID), zap.Int("msg_id", dref.MsgID), zap.Error(err)) + return false, internalErr() + } + } + // 转换移除一份展示礼物 → 失效 owner full 投影。 + r.invalidateStarGiftOwnerProjection(dref.Owner) + return true, nil +} + +// ---- helpers ---- + +func (r *Router) starGiftFromCatalog(ctx context.Context, giftID int64) (domain.StarGift, error) { + gift, ok, err := r.deps.Gifts.GiftByID(ctx, giftID) + if err != nil { + return domain.StarGift{}, internalErr() + } + if !ok { + return domain.StarGift{}, starGiftInvalidErr() + } + return gift, nil +} + +// starGiftOwnerPeer 解析 getSavedStarGifts 的 owner:inputPeerSelf/空 → 自己,否则解析 user/channel peer。 +func (r *Router) starGiftOwnerPeer(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.Peer, error) { + switch peer.(type) { + case nil, *tg.InputPeerSelf: + return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, nil + } + resolved, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return domain.Peer{}, err + } + if (resolved.Type != domain.PeerTypeUser && resolved.Type != domain.PeerTypeChannel) || resolved.ID == 0 { + return domain.Peer{}, peerIDInvalidErr() + } + return resolved, nil +} + +func (r *Router) starGiftRefFromInput(ctx context.Context, userID int64, ref tg.InputSavedStarGiftClass) (domain.SavedStarGiftRef, bool, error) { + switch v := ref.(type) { + case *tg.InputSavedStarGiftUser: + if v == nil || v.MsgID <= 0 { + return domain.SavedStarGiftRef{}, false, nil + } + return domain.SavedStarGiftRef{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: userID}, + MsgID: v.MsgID, + }, true, nil + case *tg.InputSavedStarGiftChat: + if v == nil || v.SavedID <= 0 { + return domain.SavedStarGiftRef{}, false, nil + } + owner, err := r.checkedDomainPeerFromInputPeer(ctx, userID, v.Peer) + if err != nil { + return domain.SavedStarGiftRef{}, false, err + } + if owner.Type != domain.PeerTypeChannel || owner.ID == 0 { + return domain.SavedStarGiftRef{}, false, peerIDInvalidErr() + } + return domain.SavedStarGiftRef{Owner: owner, SavedID: v.SavedID}, true, nil + default: + return domain.SavedStarGiftRef{}, false, nil + } +} + +func (r *Router) ensureCanManageStarGiftOwner(ctx context.Context, userID int64, owner domain.Peer) error { + if owner.Type != domain.PeerTypeChannel { + return nil + } + if r.deps.Channels == nil { + return notImplementedErr() + } + view, err := r.deps.Channels.ResolveChannel(ctx, userID, owner.ID) + if err != nil { + return channelInvalidErr(err) + } + if !channelMemberIsAdmin(view.Self) { + return channelInvalidErr(domain.ErrChannelAdminRequired) + } + return nil +} + +func (r *Router) invalidateStarGiftOwnerProjection(owner domain.Peer) { + switch owner.Type { + case domain.PeerTypeUser: + r.invalidateRPCProjectionForUser(owner.ID) + case domain.PeerTypeChannel: + r.invalidateRPCProjectionForChannel(owner.ID) + } +} + +func (r *Router) tgSavedStarGiftsResponse(ctx context.Context, viewerUserID int64, gifts []domain.SavedStarGift, count int, nextOffset string) *tg.PaymentsSavedStarGifts { + catalog := r.resolveStarGiftCatalog(ctx, gifts) + out := &tg.PaymentsSavedStarGifts{ + Count: count, + Gifts: tgSavedStarGifts(gifts, catalog), + Chats: []tg.ChatClass{}, + } + if ids := savedStarGiftUserIDs(gifts); len(ids) > 0 { + out.Users = tgUsersForViewer(viewerUserID, r.domainUsersForIDs(ctx, viewerUserID, ids)) + } else { + out.Users = []tg.UserClass{} + } + if nextOffset != "" { + out.SetNextOffset(nextOffset) + } + return out +} + +func emptySavedStarGifts() *tg.PaymentsSavedStarGifts { + return &tg.PaymentsSavedStarGifts{ + Gifts: []tg.SavedStarGift{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + } +} + +// tgStarGifts 把目录投影为 []tg.StarGiftClass。 +func tgStarGifts(catalog []domain.StarGift) []tg.StarGiftClass { + out := make([]tg.StarGiftClass, 0, len(catalog)) + for _, g := range catalog { + out = append(out, tgStarGift(g)) + } + return out +} + +// tgStarGift 把目录项投影为 tg.StarGift(Sticker 须为带 sticker 属性的有效 Document)。 +func tgStarGift(g domain.StarGift) *tg.StarGift { + gift := &tg.StarGift{ + ID: g.ID, + Sticker: tgDocument(g.Sticker), + Stars: g.Stars, + ConvertStars: g.ConvertStars, + } + if g.Title != "" { + gift.SetTitle(g.Title) + } + return gift +} + +// tgMessageActionStarGift 把礼物服务消息载荷投影为 messageActionStarGift。 +func tgMessageActionStarGift(in *domain.MessageStarGiftAction) tg.MessageActionClass { + if in == nil { + return &tg.MessageActionEmpty{} + } + gift := &tg.StarGift{ + ID: in.GiftID, + Stars: in.Stars, + ConvertStars: in.ConvertStars, + } + if in.Sticker != nil { + gift.Sticker = tgDocument(*in.Sticker) + } else { + gift.Sticker = &tg.DocumentEmpty{} + } + if in.Title != "" { + gift.SetTitle(in.Title) + } + action := &tg.MessageActionStarGift{Gift: gift} + if in.NameHidden { + action.NameHidden = true + } + if in.Saved { + action.Saved = true + } + if in.Converted { + action.Converted = true + } + if in.ConvertStars > 0 { + action.SetConvertStars(in.ConvertStars) + } + if in.Message != "" { + action.SetMessage(tg.TextWithEntities{Text: in.Message}) + } + if in.FromUserID != 0 && !in.NameHidden { + action.SetFromID(&tg.PeerUser{UserID: in.FromUserID}) + } + if in.PeerUserID != 0 { + action.SetPeer(&tg.PeerUser{UserID: in.PeerUserID}) + } else if in.PeerChannelID != 0 { + action.SetPeer(&tg.PeerChannel{ChannelID: in.PeerChannelID}) + } + if in.SavedID != 0 { + action.SetSavedID(in.SavedID) + } + return action +} + +// resolveStarGiftCatalog 解析这批 saved gift 涉及的目录项(giftID → StarGift),供下发完整贴纸/星价。 +func (r *Router) resolveStarGiftCatalog(ctx context.Context, gifts []domain.SavedStarGift) map[int64]domain.StarGift { + out := make(map[int64]domain.StarGift, len(gifts)) + if r.deps.Gifts == nil { + return out + } + for _, g := range gifts { + if _, ok := out[g.GiftID]; ok { + continue + } + if gift, found, err := r.deps.Gifts.GiftByID(ctx, g.GiftID); err == nil && found { + out[g.GiftID] = gift + } + } + return out +} + +// tgSavedStarGifts 把已收到礼物实例投影为 []tg.SavedStarGift。 +func tgSavedStarGifts(gifts []domain.SavedStarGift, catalog map[int64]domain.StarGift) []tg.SavedStarGift { + out := make([]tg.SavedStarGift, 0, len(gifts)) + for _, g := range gifts { + item := tg.SavedStarGift{ + Date: g.Date, + Gift: tgSavedStarGiftGift(g, catalog), + } + if g.NameHidden { + item.NameHidden = true + } + if g.Unsaved { + item.Unsaved = true + } + if g.FromUserID != 0 && !g.NameHidden { + item.SetFromID(&tg.PeerUser{UserID: g.FromUserID}) + } + if g.Owner.Type == domain.PeerTypeUser && g.MsgID > 0 { + item.SetMsgID(g.MsgID) + } + if g.Owner.Type == domain.PeerTypeChannel && g.SavedID > 0 { + item.SetSavedID(g.SavedID) + } + if g.ConvertStars > 0 { + item.SetConvertStars(g.ConvertStars) + } + if g.Message != "" { + item.SetMessage(tg.TextWithEntities{Text: g.Message}) + } + out = append(out, item) + } + return out +} + +// tgSavedStarGiftGift 把 SavedStarGift 内嵌礼物投影为 tg.StarGift:优先用目录解析出完整贴纸/星价 +// (客户端据有效 sticker 渲染),目录缺失(礼物已下架)时兜底最小形态。convert_stars 用实例值。 +func tgSavedStarGiftGift(g domain.SavedStarGift, catalog map[int64]domain.StarGift) tg.StarGiftClass { + if gift, ok := catalog[g.GiftID]; ok { + out := tgStarGift(gift) + out.ConvertStars = g.ConvertStars + return out + } + return &tg.StarGift{ + ID: g.GiftID, + Sticker: &tg.DocumentEmpty{}, + Stars: 0, + ConvertStars: g.ConvertStars, + } +} + +func savedStarGiftUserIDs(gifts []domain.SavedStarGift) []int64 { + seen := make(map[int64]struct{}, len(gifts)) + ids := make([]int64, 0, len(gifts)) + for _, g := range gifts { + if g.FromUserID == 0 || g.NameHidden { + continue + } + if _, ok := seen[g.FromUserID]; ok { + continue + } + seen[g.FromUserID] = struct{}{} + ids = append(ids, g.FromUserID) + } + return ids +} + +func starGiftFormID(userID, giftID int64) int64 { + id := userID*0x9e3779b1 ^ (giftID << 7) ^ 0x5347494654 + if id == 0 { + id = 0x5347 + } + return id +} + +func giftPriceLabel(g domain.StarGift) string { + if g.Title != "" { + return g.Title + } + return "Gift" +} + +func clampGiftMessage(s string) string { + runes := []rune(s) + if len(runes) > domain.MaxStarGiftMessageRunes { + return string(runes[:domain.MaxStarGiftMessageRunes]) + } + return s +} diff --git a/internal/rpc/payments_star_gifts_rpc_test.go b/internal/rpc/payments_star_gifts_rpc_test.go new file mode 100644 index 00000000..45690614 --- /dev/null +++ b/internal/rpc/payments_star_gifts_rpc_test.go @@ -0,0 +1,385 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appmessages "telesrv/internal/app/messages" + appstargifts "telesrv/internal/app/stargifts" + appstars "telesrv/internal/app/stars" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +type stubGiftCatalog struct{ gifts []domain.StarGift } + +func (s stubGiftCatalog) BuildStarGiftCatalog(_ context.Context) ([]domain.StarGift, error) { + return s.gifts, nil +} + +func starGiftTestRouter(t *testing.T) (*Router, domain.User, domain.User, domain.StarGift) { + t.Helper() + ctx := context.Background() + users := memory.NewUserStore() + dialogs := memory.NewDialogStore() + msgStore := memory.NewMessageStore(dialogs) + channelStore := memory.NewChannelStore() + sender, err := users.Create(ctx, domain.User{AccessHash: 7101, Phone: "15550007101", FirstName: "Sender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 7102, Phone: "15550007102", FirstName: "Recipient"}) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + gift := domain.StarGift{ + ID: 8001, Stars: 50, ConvertStars: 50, Title: "Cake", + Sticker: domain.Document{ID: 700, AccessHash: 7, DCID: 2, MimeType: "image/webp"}, + } + gifts := appstargifts.NewService(memory.NewStarGiftStore(), stubGiftCatalog{[]domain.StarGift{gift}}) + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(users), + Messages: appmessages.NewService(msgStore, dialogs), + Channels: appchannels.NewService(channelStore), + Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)), + Gifts: gifts, + }, zaptest.NewLogger(t), clock.System) + return r, sender, recipient, gift +} + +// 完整 star gift saga:catalog → getPaymentForm(paymentFormStarGift) → sendStarsForm(扣费+服务消息 +// +paymentResult) → 收礼人 getSavedStarGifts → save/convert。 +func TestStarGiftSaga(t *testing.T) { + r, sender, recipient, gift := starGiftTestRouter(t) + ctx := context.Background() + senderCtx := WithUserID(ctx, sender.ID) + recipientCtx := WithUserID(ctx, recipient.ID) + + // 1. 目录。 + catRes, err := r.onPaymentsGetStarGifts(senderCtx, 0) + if err != nil { + t.Fatalf("getStarGifts: %v", err) + } + cat, ok := catRes.(*tg.PaymentsStarGifts) + if !ok || len(cat.Gifts) != 1 { + t.Fatalf("catalog = %T %+v, want 1 gift", catRes, catRes) + } + if g, ok := cat.Gifts[0].(*tg.StarGift); !ok || g.ID != gift.ID || g.Stars != 50 { + t.Fatalf("catalog gift = %#v, want id %d stars 50", cat.Gifts[0], gift.ID) + } + // 即便 hash 命中也始终回完整目录(DrKLO force-stop 后保留 hash 但丢礼物缓存, + // 返 NotModified 会让送礼选择器永远空)。 + if again, err := r.onPaymentsGetStarGifts(senderCtx, cat.Hash); err != nil { + t.Fatalf("getStarGifts hash: %v", err) + } else if full, ok := again.(*tg.PaymentsStarGifts); !ok || len(full.Gifts) != 1 { + t.Fatalf("hash match = %T, want full catalog (不返 NotModified)", again) + } + + inv := &tg.InputInvoiceStarGift{ + Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, + GiftID: gift.ID, + } + + // 2. getPaymentForm → paymentFormStarGift(XTR + 非空 prices)。 + formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("getPaymentForm: %v", err) + } + form, ok := formRes.(*tg.PaymentsPaymentFormStarGift) + if !ok { + t.Fatalf("form = %T, want *tg.PaymentsPaymentFormStarGift (TDesktop 单分支 match)", formRes) + } + if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != 50 { + t.Fatalf("form invoice = %+v, want XTR + 1 price 50", form.Invoice) + } + + // 3. sendStarsForm → paymentResult(扣费 + 服务消息 + updateStarsBalance)。 + payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}) + if err != nil { + t.Fatalf("sendStarsForm: %v", err) + } + pay, ok := payRes.(*tg.PaymentsPaymentResult) + if !ok { + t.Fatalf("pay result = %T, want *tg.PaymentsPaymentResult (DrKLO 强转)", payRes) + } + updates, ok := pay.Updates.(*tg.Updates) + if !ok { + t.Fatalf("pay updates = %T, want *tg.Updates", pay.Updates) + } + hasBalance, hasGiftMsg := false, false + for _, up := range updates.Updates { + switch u := up.(type) { + case *tg.UpdateStarsBalance: + hasBalance = true + if amt, ok := u.Balance.(*tg.StarsAmount); !ok || amt.Amount != 950 { + t.Fatalf("updateStarsBalance = %#v, want 950", u.Balance) + } + case *tg.UpdateNewMessage: + if svc, ok := u.Message.(*tg.MessageService); ok { + if _, ok := svc.Action.(*tg.MessageActionStarGift); ok { + hasGiftMsg = true + } + } + } + } + if !hasBalance || !hasGiftMsg { + t.Fatalf("pay updates: balance=%v giftMsg=%v, want both (崩溃约束:必返合法 Updates)", hasBalance, hasGiftMsg) + } + // 送礼人余额扣 50。 + if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 950 { + t.Fatalf("sender balance = %d, want 950", bal.Balance) + } + + // 4. 收礼人 getSavedStarGifts。 + savedRes, err := r.onPaymentsGetSavedStarGifts(recipientCtx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}}) + if err != nil { + t.Fatalf("getSavedStarGifts: %v", err) + } + if savedRes.Count != 1 || len(savedRes.Gifts) != 1 { + t.Fatalf("saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts)) + } + saved := savedRes.Gifts[0] + msgID, ok := saved.GetMsgID() + if !ok || msgID <= 0 { + t.Fatalf("saved gift msg_id = %d ok %v, want positive", msgID, ok) + } + if g, ok := saved.Gift.(*tg.StarGift); !ok || g.ID != gift.ID { + t.Fatalf("saved gift inner = %#v, want gift %d", saved.Gift, gift.ID) + } + if from, ok := saved.GetFromID(); !ok { + t.Fatalf("saved gift from = %v, want sender peer", from) + } + + // 4b. 收礼人 userFull 必须带 stargifts_count(否则客户端资料页 Gifts 区段不出现)。 + fullRes, err := r.onUsersGetFullUser(senderCtx, &tg.InputUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}) + if err != nil { + t.Fatalf("getFullUser: %v", err) + } + if cnt, ok := fullRes.FullUser.GetStargiftsCount(); !ok || cnt != 1 { + t.Fatalf("recipient userFull stargifts_count = %d ok %v, want 1 (资料页 Gifts 门控)", cnt, ok) + } + + // 5. saveStarGift(隐藏)。 + if ok, err := r.onPaymentsSaveStarGift(recipientCtx, &tg.PaymentsSaveStarGiftRequest{ + Unsave: true, Stargift: &tg.InputSavedStarGiftUser{MsgID: msgID}, + }); err != nil || !ok { + t.Fatalf("saveStarGift hide = %v err %v", ok, err) + } + + // 6. convertStarGift(转回 Stars,收礼人 +50)。 + recipBefore, _ := r.deps.Stars.GetBalance(ctx, recipient.ID) + if ok, err := r.onPaymentsConvertStarGift(recipientCtx, &tg.InputSavedStarGiftUser{MsgID: msgID}); err != nil || !ok { + t.Fatalf("convertStarGift = %v err %v", ok, err) + } + recipAfter, _ := r.deps.Stars.GetBalance(ctx, recipient.ID) + if recipAfter.Balance != recipBefore.Balance+50 { + t.Fatalf("recipient balance %d -> %d, want +50", recipBefore.Balance, recipAfter.Balance) + } + // 转换后从列表消失。 + afterRes, _ := r.onPaymentsGetSavedStarGifts(recipientCtx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}}) + if afterRes.Count != 0 { + t.Fatalf("saved gifts after convert = %d, want 0", afterRes.Count) + } + // 重复转换被拒。 + if _, err := r.onPaymentsConvertStarGift(recipientCtx, &tg.InputSavedStarGiftUser{MsgID: msgID}); err == nil { + t.Fatalf("double convert should error") + } +} + +// 频道 star gift saga:channel peer 能付款发送,但不生成频道历史消息; +// saved gift 用 inputSavedStarGiftChat.saved_id 定位,Recent Actions 用 admin log 快照承载。 +func TestStarGiftChannelSaga(t *testing.T) { + r, sender, owner, gift := starGiftTestRouter(t) + ctx := context.Background() + senderCtx := WithUserID(ctx, sender.ID) + ownerCtx := WithUserID(ctx, owner.ID) + + created, err := r.deps.Channels.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Gift Channel", + Broadcast: true, + MemberUserIDs: []int64{sender.ID}, + Date: 1700001000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.Channel + channelPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + channelInput := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + inv := &tg.InputInvoiceStarGift{ + Peer: channelPeer, + GiftID: gift.ID, + } + + formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv}) + if err != nil { + t.Fatalf("getPaymentForm(channel): %v", err) + } + form, ok := formRes.(*tg.PaymentsPaymentFormStarGift) + if !ok || form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 { + t.Fatalf("form(channel) = %T %+v, want star gift XTR form", formRes, formRes) + } + + payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv}) + if err != nil { + t.Fatalf("sendStarsForm(channel): %v", err) + } + pay, ok := payRes.(*tg.PaymentsPaymentResult) + if !ok { + t.Fatalf("pay result = %T, want *tg.PaymentsPaymentResult", payRes) + } + updates, ok := pay.Updates.(*tg.Updates) + if !ok { + t.Fatalf("pay updates = %T, want *tg.Updates", pay.Updates) + } + var ( + hasBalance bool + ) + for _, up := range updates.Updates { + switch u := up.(type) { + case *tg.UpdateStarsBalance: + hasBalance = true + if amt, ok := u.Balance.(*tg.StarsAmount); !ok || amt.Amount != 950 { + t.Fatalf("channel updateStarsBalance = %#v, want 950", u.Balance) + } + case *tg.UpdateNewChannelMessage: + t.Fatalf("channel gift must not be pushed as UpdateNewChannelMessage: %#v", u.Message) + } + } + if !hasBalance { + t.Fatalf("channel pay updates: balance=%v, want updateStarsBalance", hasBalance) + } + + savedRes, err := r.onPaymentsGetSavedStarGifts(senderCtx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: channelPeer}) + if err != nil { + t.Fatalf("getSavedStarGifts(channel): %v", err) + } + if savedRes.Count != 1 || len(savedRes.Gifts) != 1 { + t.Fatalf("channel saved gifts = count %d len %d, want 1/1", savedRes.Count, len(savedRes.Gifts)) + } + savedID, ok := savedRes.Gifts[0].GetSavedID() + if !ok || savedID <= 0 { + t.Fatalf("saved gift saved_id = %d ok %v, want positive", savedID, ok) + } + if _, ok := savedRes.Gifts[0].GetMsgID(); ok { + t.Fatalf("channel saved gift should not expose inputSavedStarGiftUser.msg_id") + } + nextHistoryID := channel.TopMessageID + 1 + history, err := r.onChannelsGetMessages(ownerCtx, &tg.ChannelsGetMessagesRequest{ + Channel: channelInput, + ID: []tg.InputMessageClass{&tg.InputMessageID{ID: nextHistoryID}}, + }) + if err != nil { + t.Fatalf("get channel message after gift payment: %v", err) + } + gotMessages := history.(*tg.MessagesMessages).Messages + if len(gotMessages) != 1 { + t.Fatalf("channel getMessages len = %d, want 1 messageEmpty", len(gotMessages)) + } + if _, ok := gotMessages[0].(*tg.MessageEmpty); !ok { + t.Fatalf("channel gift leaked into message history as %T", gotMessages[0]) + } + + sendFilter := tg.ChannelAdminLogEventsFilter{} + sendFilter.SetSend(true) + adminReq := &tg.ChannelsGetAdminLogRequest{Channel: channelInput, Limit: 10} + adminReq.SetEventsFilter(sendFilter) + adminLog, err := r.onChannelsGetAdminLog(ownerCtx, adminReq) + if err != nil { + t.Fatalf("getAdminLog(channel gift): %v", err) + } + foundAdminGift := false + for _, event := range adminLog.Events { + send, ok := event.Action.(*tg.ChannelAdminLogEventActionSendMessage) + if !ok { + continue + } + svc, ok := send.Message.(*tg.MessageService) + if !ok { + continue + } + action, ok := svc.Action.(*tg.MessageActionStarGift) + if !ok { + continue + } + if got, ok := action.GetSavedID(); !ok || got != savedID { + t.Fatalf("admin log star gift saved_id = %d ok %v, want %d", got, ok, savedID) + } + if peer, ok := action.GetPeer(); !ok { + t.Fatalf("admin log star gift peer missing") + } else if ch, ok := peer.(*tg.PeerChannel); !ok || ch.ChannelID != channel.ID { + t.Fatalf("admin log star gift peer = %#v, want channel %d", peer, channel.ID) + } + foundAdminGift = true + } + if !foundAdminGift { + t.Fatalf("admin log did not include star gift send_message action") + } + + oneRes, err := r.onPaymentsGetSavedStarGift(senderCtx, []tg.InputSavedStarGiftClass{ + &tg.InputSavedStarGiftChat{Peer: channelPeer, SavedID: savedID}, + }) + if err != nil || oneRes.Count != 1 || len(oneRes.Gifts) != 1 { + t.Fatalf("getSavedStarGift(channel) count=%d len=%d err=%v, want 1/1", oneRes.Count, len(oneRes.Gifts), err) + } + + fullRes, err := r.onChannelsGetFullChannel(ownerCtx, channelInput) + if err != nil { + t.Fatalf("getFullChannel: %v", err) + } + full, ok := fullRes.FullChat.(*tg.ChannelFull) + if !ok { + t.Fatalf("full chat = %T, want *tg.ChannelFull", fullRes.FullChat) + } + if cnt, ok := full.GetStargiftsCount(); !ok || cnt != 1 { + t.Fatalf("channelFull stargifts_count = %d ok %v, want 1", cnt, ok) + } + if !full.GetStargiftsAvailable() { + t.Fatalf("channelFull stargifts_available = false, want true for broadcast channel gift entry") + } + + if ok, err := r.onPaymentsSaveStarGift(ownerCtx, &tg.PaymentsSaveStarGiftRequest{ + Unsave: true, + Stargift: &tg.InputSavedStarGiftChat{Peer: channelPeer, SavedID: savedID}, + }); err != nil || !ok { + t.Fatalf("saveStarGift(channel hide) = %v err %v", ok, err) + } + hiddenRes, err := r.onPaymentsGetSavedStarGifts(senderCtx, &tg.PaymentsGetSavedStarGiftsRequest{Peer: channelPeer, ExcludeUnsaved: true}) + if err != nil || hiddenRes.Count != 0 || len(hiddenRes.Gifts) != 0 { + t.Fatalf("channel excludeUnsaved after hide = count %d len %d err %v, want 0/0", hiddenRes.Count, len(hiddenRes.Gifts), err) + } +} + +// 余额不足 → sendStarsForm 返回 BALANCE_TOO_LOW(不发礼、不扣费)。 +func TestStarGiftInsufficientBalance(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + dialogs := memory.NewDialogStore() + msgStore := memory.NewMessageStore(dialogs) + sender, _ := users.Create(ctx, domain.User{AccessHash: 7201, Phone: "15550007201", FirstName: "Poor"}) + recipient, _ := users.Create(ctx, domain.User{AccessHash: 7202, Phone: "15550007202", FirstName: "Rich"}) + gift := domain.StarGift{ID: 8002, Stars: 5000, ConvertStars: 5000, Title: "Expensive", + Sticker: domain.Document{ID: 701, AccessHash: 7, DCID: 2, MimeType: "image/webp"}} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(users), + Messages: appmessages.NewService(msgStore, dialogs), + Stars: appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(1000)), // < 5000 + Gifts: appstargifts.NewService(memory.NewStarGiftStore(), stubGiftCatalog{[]domain.StarGift{gift}}), + }, zaptest.NewLogger(t), clock.System) + senderCtx := WithUserID(ctx, sender.ID) + inv := &tg.InputInvoiceStarGift{Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, GiftID: gift.ID} + if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{Invoice: inv}); err == nil { + t.Fatalf("over-budget gift should error BALANCE_TOO_LOW") + } + // 余额未变。 + if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 1000 { + t.Fatalf("sender balance = %d, want 1000 unchanged", bal.Balance) + } +} diff --git a/internal/rpc/payments_stars_rpc_test.go b/internal/rpc/payments_stars_rpc_test.go new file mode 100644 index 00000000..dc16458b --- /dev/null +++ b/internal/rpc/payments_stars_rpc_test.go @@ -0,0 +1,100 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appstars "telesrv/internal/app/stars" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func starsRouter(t *testing.T, grant int64) *Router { + t.Helper() + svc := appstars.NewService(memory.NewStarsStore(), appstars.WithStartingGrant(grant)) + return New(Config{}, Deps{Stars: svc}, zaptest.NewLogger(t), clock.System) +} + +// getStarsStatus 首读惰性授予后返回真实余额;响应必须是合法 starsStatus +// (balance 必填 + chats/users 非 nil vector)。 +func TestOnPaymentsGetStarsStatusGranted(t *testing.T) { + r := starsRouter(t, 1000) + ctx := WithUserID(context.Background(), 1000000001) + + status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}) + if err != nil { + t.Fatalf("getStarsStatus: %v", err) + } + amount, ok := status.Balance.(*tg.StarsAmount) + if !ok || amount.Amount != 1000 { + t.Fatalf("balance = %#v, want StarsAmount 1000", status.Balance) + } + if status.Chats == nil || status.Users == nil { + t.Fatalf("chats/users must be non-nil vectors, got chats=%v users=%v", status.Chats, status.Users) + } + // 余额是 flag 外必填字段,不能省略。 + if _, hasHistory := status.GetHistory(); hasHistory { + t.Fatalf("status (not transactions) should carry no history") + } +} + +// TON 余额未建模:返回 starsTonAmount 的合法响应(不崩客户端)。 +func TestOnPaymentsGetStarsStatusTon(t *testing.T) { + r := starsRouter(t, 1000) + ctx := WithUserID(context.Background(), 1000000001) + // SetTon 同时置 flag 位+字段(gotd true-flag:GetTon 读 flag 位,手工 struct 字面量不置位)。 + req := &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}} + req.SetTon(true) + status, err := r.onPaymentsGetStarsStatus(ctx, req) + if err != nil { + t.Fatalf("getStarsStatus ton: %v", err) + } + if _, ok := status.Balance.(*tg.StarsTonAmount); !ok { + t.Fatalf("ton balance = %#v, want StarsTonAmount", status.Balance) + } +} + +// getStarsTransactions 返回授予流水;keyset 分页末页省略 next_offset(防 DrKLO 死循环)。 +func TestOnPaymentsGetStarsTransactions(t *testing.T) { + r := starsRouter(t, 1000) + ctx := WithUserID(context.Background(), 1000000001) + + status, err := r.onPaymentsGetStarsTransactions(ctx, &tg.PaymentsGetStarsTransactionsRequest{Peer: &tg.InputPeerSelf{}}) + if err != nil { + t.Fatalf("getStarsTransactions: %v", err) + } + history, ok := status.GetHistory() + if !ok || len(history) != 1 { + t.Fatalf("history = %d ok=%v, want 1 grant txn", len(history), ok) + } + txn := history[0] + if amount, ok := txn.Amount.(*tg.StarsAmount); !ok || amount.Amount != 1000 { + t.Fatalf("grant txn amount = %#v, want +1000", txn.Amount) + } + // grant 走 Fragment 对手方(Peer 必填,不可 nil)。 + if _, ok := txn.Peer.(*tg.StarsTransactionPeerFragment); !ok { + t.Fatalf("grant peer = %#v, want StarsTransactionPeerFragment", txn.Peer) + } + // 单页装得下 → 无 next_offset。 + if off, ok := status.GetNextOffset(); ok { + t.Fatalf("single-page next_offset = %q, want absent (no infinite paging)", off) + } +} + +// deps.Stars==nil 兜底:返回合法的空 starsStatus(余额 0),不崩。 +func TestOnPaymentsGetStarsStatusNilDeps(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(context.Background(), 1000000001) + status, err := r.onPaymentsGetStarsStatus(ctx, &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}) + if err != nil { + t.Fatalf("nil-deps getStarsStatus: %v", err) + } + if amount, ok := status.Balance.(*tg.StarsAmount); !ok || amount.Amount != 0 { + t.Fatalf("nil-deps balance = %#v, want StarsAmount 0", status.Balance) + } + _ = domain.DefaultStarsStartingGrant +} diff --git a/internal/rpc/peer_projection_cache.go b/internal/rpc/peer_projection_cache.go new file mode 100644 index 00000000..befee15f --- /dev/null +++ b/internal/rpc/peer_projection_cache.go @@ -0,0 +1,158 @@ +package rpc + +import ( + "context" + + "go.uber.org/zap" + + "telesrv/internal/app/peerview" + "telesrv/internal/domain" +) + +// viewerPeerCache 是一次 outbox/fanout 构建内的短生命周期缓存。 +// 用户资料含联系人、隐私、头像和在线状态,必须按 viewerUserID 隔离,不能跨视角复用。 +type viewerPeerCache struct { + r *Router + + users *peerview.BatchCache + channels map[int64]map[int64]domain.Channel + missingChats map[int64]map[int64]struct{} +} + +func newViewerPeerCache(r *Router) *viewerPeerCache { + var users peerview.UserResolver + if r != nil { + users = r.deps.Users + } + return &viewerPeerCache{ + r: r, + users: peerview.NewBatchCache(users), + channels: make(map[int64]map[int64]domain.Channel), + missingChats: make(map[int64]map[int64]struct{}), + } +} + +func (c *viewerPeerCache) usersForIDs(ctx context.Context, viewerUserID int64, ids []int64) []domain.User { + if c == nil || c.users == nil { + return nil + } + users, err := c.users.UsersForView(ctx, viewerUserID, ids) + if err != nil && c.r != nil { + c.r.log.Warn("batch resolve users for peer projection failed", + zap.Int64("viewer_user_id", viewerUserID), + zap.Int("count", len(uniquePeerIDs(ids))), + zap.Error(err), + ) + } + if c.r == nil { + return users + } + return c.r.withUsersPresence(users) +} + +// primeUsers 把跨 viewer 一次性投影(ByIDsForViewers)的结果按 viewer 预热进底层 BatchCache, +// 使随后每 viewer 的 usersForIDs 命中缓存、不再逐 viewer 解析投影。仅 fan-out 预热路径调用。 +func (c *viewerPeerCache) primeUsers(viewerUserID int64, users []domain.User) { + if c == nil || c.users == nil { + return + } + c.users.Prime(viewerUserID, users) +} + +func (c *viewerPeerCache) channelsForIDs(ctx context.Context, viewerUserID int64, ids []int64) []domain.Channel { + unique := uniquePeerIDs(ids) + if c == nil || c.r == nil || len(unique) == 0 || c.r.deps.Channels == nil || viewerUserID == 0 { + return nil + } + byID := c.viewerChannels(viewerUserID) + missing := c.viewerMissingChannels(viewerUserID) + load := make([]int64, 0, len(unique)) + for _, id := range unique { + if _, ok := byID[id]; ok { + continue + } + if _, ok := missing[id]; ok { + continue + } + load = append(load, id) + } + if len(load) > 0 { + views, err := c.r.deps.Channels.GetChannels(ctx, viewerUserID, load) + if err != nil { + for _, id := range load { + missing[id] = struct{}{} + } + } else { + found := make(map[int64]struct{}, len(views)) + for _, view := range views { + if view.Channel.ID == 0 { + continue + } + byID[view.Channel.ID] = view.Channel + found[view.Channel.ID] = struct{}{} + } + for _, id := range load { + if _, ok := found[id]; !ok { + missing[id] = struct{}{} + } + } + } + } + out := make([]domain.Channel, 0, len(unique)) + for _, id := range unique { + if ch, ok := byID[id]; ok { + out = append(out, ch) + } + } + return out +} + +func (c *viewerPeerCache) viewerChannels(viewerUserID int64) map[int64]domain.Channel { + if byID, ok := c.channels[viewerUserID]; ok { + return byID + } + byID := make(map[int64]domain.Channel) + c.channels[viewerUserID] = byID + return byID +} + +func (c *viewerPeerCache) viewerMissingChannels(viewerUserID int64) map[int64]struct{} { + if missing, ok := c.missingChats[viewerUserID]; ok { + return missing + } + missing := make(map[int64]struct{}) + c.missingChats[viewerUserID] = missing + return missing +} + +func uniquePeerIDs(ids []int64) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func peerIDMapKeys(ids map[int64]struct{}) []int64 { + if len(ids) == 0 { + return nil + } + out := make([]int64, 0, len(ids)) + for id := range ids { + if id != 0 { + out = append(out, id) + } + } + return out +} diff --git a/internal/rpc/peer_projection_cache_test.go b/internal/rpc/peer_projection_cache_test.go new file mode 100644 index 00000000..7ab1465d --- /dev/null +++ b/internal/rpc/peer_projection_cache_test.go @@ -0,0 +1,83 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestViewerPeerCacheChannelsForIDsUsesBatchAndCachesMissing(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550009101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + first, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Cache One", + Broadcast: true, + Date: 1700001900, + }) + if err != nil { + t.Fatalf("create first channel: %v", err) + } + second, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Cache Two", + Megagroup: true, + Date: 1700001910, + }) + if err != nil { + t.Fatalf("create second channel: %v", err) + } + + counting := &countingChannelsService{Service: channelService} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: counting, + Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), + }, zaptest.NewLogger(t), clock.System) + cache := newViewerPeerCache(r) + + got := cache.channelsForIDs(ctx, owner.ID, []int64{first.Channel.ID, second.Channel.ID, first.Channel.ID, 0}) + if len(got) != 2 { + t.Fatalf("first channelsForIDs returned %d channels, want 2", len(got)) + } + if counting.getChannelsCalls != 1 || counting.getChannelCalls != 0 { + t.Fatalf("first load calls: GetChannels=%d GetChannel=%d, want one batch only", counting.getChannelsCalls, counting.getChannelCalls) + } + + again := cache.channelsForIDs(ctx, owner.ID, []int64{second.Channel.ID, first.Channel.ID}) + if len(again) != 2 { + t.Fatalf("cached channelsForIDs returned %d channels, want 2", len(again)) + } + if counting.getChannelsCalls != 1 || counting.getChannelCalls != 0 { + t.Fatalf("cached load calls: GetChannels=%d GetChannel=%d, want no extra calls", counting.getChannelsCalls, counting.getChannelCalls) + } + + missingID := second.Channel.ID + 9999 + missing := cache.channelsForIDs(ctx, owner.ID, []int64{missingID}) + if len(missing) != 0 { + t.Fatalf("missing channelsForIDs returned %d channels, want 0", len(missing)) + } + if counting.getChannelsCalls != 2 || counting.getChannelCalls != 0 { + t.Fatalf("missing load calls: GetChannels=%d GetChannel=%d, want second batch only", counting.getChannelsCalls, counting.getChannelCalls) + } + + missingAgain := cache.channelsForIDs(ctx, owner.ID, []int64{missingID}) + if len(missingAgain) != 0 { + t.Fatalf("cached missing channelsForIDs returned %d channels, want 0", len(missingAgain)) + } + if counting.getChannelsCalls != 2 || counting.getChannelCalls != 0 { + t.Fatalf("cached missing calls: GetChannels=%d GetChannel=%d, want no extra calls", counting.getChannelsCalls, counting.getChannelCalls) + } +} diff --git a/internal/rpc/phone_calls.go b/internal/rpc/phone_calls.go new file mode 100644 index 00000000..2e93cb51 --- /dev/null +++ b/internal/rpc/phone_calls.go @@ -0,0 +1,275 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + appphone "telesrv/internal/app/phone" + "telesrv/internal/domain" +) + +// 私聊 1:1 通话 RPC handler。状态机与密钥材料归 app/phone;本文件只做 +// 鉴权、入参校验、隐私/拉黑闸门、推送编排与 TL 转换。 +// 推送策略总表与硬契约见设计文档;标 ⚠ 的注释是对抗评审定下的修正,勿改回。 + +// phoneCallErr 把 app/phone 业务错误映射为 RPC_ERROR。 +func phoneCallErr(err error) error { + switch { + case errors.Is(err, appphone.ErrPeerInvalid): + return callPeerInvalidErr() + case errors.Is(err, appphone.ErrAlreadyAccepted): + return callAlreadyAcceptedErr() + case errors.Is(err, appphone.ErrAlreadyDeclined): + return callAlreadyDeclinedErr() + case errors.Is(err, appphone.ErrOccupyFailed): + return callOccupyFailedErr() + case errors.Is(err, appphone.ErrProtocolLayerInvalid): + return callProtocolLayerInvalidErr() + case errors.Is(err, appphone.ErrProtocolCompatLayerInvalid): + return callProtocolCompatLayerInvalidErr() + case errors.Is(err, appphone.ErrProtocolFlagsInvalid): + return callProtocolFlagsInvalidErr() + default: + return internalErr() + } +} + +// phoneRequireUser 是 phone.* 的统一登录闸门。 +func (r *Router) phoneRequireUser(ctx context.Context) (int64, error) { + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return 0, internalErr() + } + if !ok { + return 0, authKeyUnregisteredErr() + } + return userID, nil +} + +func sessionRefFrom(ctx context.Context) domain.SessionRef { + ref := domain.SessionRef{} + if raw, ok := RawAuthKeyIDFrom(ctx); ok { + ref.RawAuthKeyID = raw + } + if sid, ok := SessionIDFrom(ctx); ok { + ref.SessionID = sid + } + return ref +} + +func (r *Router) onPhoneRequestCall(ctx context.Context, req *tg.PhoneRequestCallRequest) (*tg.PhonePhoneCall, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.Phone == nil || r.deps.Users == nil { + return nil, notImplementedErr() + } + callerID, err := r.phoneRequireUser(ctx) + if err != nil { + return nil, err + } + callee, found, err := r.userFromInput(ctx, callerID, req.UserID) + if err != nil { + return nil, internalErr() + } + // 自呼 / bot / 不存在统一 USER_ID_INVALID。 + if !found || callee.ID == 0 || callee.ID == callerID || callee.Bot { + return nil, userIDInvalidErr() + } + if blocked, err := r.peerBlocksUser(ctx, callerID, callee.ID); err != nil { + return nil, err + } else if blocked { + return nil, userIsBlockedErr() + } + if r.deps.Privacy != nil { + allowed, err := r.deps.Privacy.CanSee(ctx, callee.ID, callerID, domain.PrivacyKeyPhoneCall) + if err != nil { + return nil, internalErr() + } + if !allowed { + return nil, userPrivacyRestrictedErr() + } + } + call, err := r.deps.Phone.RequestCall(ctx, callerID, domain.PhoneCallRequest{ + CalleeID: callee.ID, + RandomID: int64(req.RandomID), + GAHash: req.GAHash, + Video: req.Video, + Protocol: phoneCallProtocolFromTL(req.Protocol), + CallerDevice: sessionRefFrom(ctx), + PrivacyP2P: r.phoneCallPrivacyP2P(ctx, callerID, callee.ID), + Connections: r.phoneCallConnections(callerID), + }) + if err != nil { + return nil, phoneCallErr(err) + } + // 推被叫全部在线设备。sent==0(被叫全离线)不报错:主叫客户端按 + // callReceiveTimeoutMs(20s) 自行超时 discard(missed),服务端兜底超时收尾。 + r.pushPhoneCall(ctx, call.ParticipantID, call, "phone call requested") + return &tg.PhonePhoneCall{ + PhoneCall: tgPhoneCallForViewer(call, callerID), + Users: r.tgUsersForIDs(ctx, callerID, []int64{call.AdminID, call.ParticipantID}), + }, nil +} + +func (r *Router) onPhoneReceivedCall(ctx context.Context, peer tg.InputPhoneCall) (bool, error) { + if r.deps.Phone == nil { + return false, notImplementedErr() + } + userID, err := r.phoneRequireUser(ctx) + if err != nil { + return false, err + } + call, transitioned, err := r.deps.Phone.ReceivedCall(ctx, userID, peer.ID, peer.AccessHash) + if err != nil { + // 终态晚到的 receivedCall 无害:宽容返回 true(多设备竞态常见)。 + if errors.Is(err, appphone.ErrPeerInvalid) { + if _, found := r.deps.Phone.Lookup(ctx, peer.ID, peer.AccessHash); found { + return true, nil + } + } + return false, phoneCallErr(err) + } + if transitioned { + // ⚠ P1-2:receiveDate 推送必须在 P1 就位。主叫只有收到带 receive_date 的 + // phoneCallWaiting 才会把 20s receive 定时器换成 90s ring 定时器。 + r.pushPhoneCall(ctx, call.AdminID, call, "phone call ringing") + } + return true, nil +} + +func (r *Router) onPhoneAcceptCall(ctx context.Context, req *tg.PhoneAcceptCallRequest) (*tg.PhonePhoneCall, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.Phone == nil { + return nil, notImplementedErr() + } + userID, err := r.phoneRequireUser(ctx) + if err != nil { + return nil, err + } + call, err := r.deps.Phone.AcceptCall(ctx, userID, req.Peer.ID, req.Peer.AccessHash, + req.GB, phoneCallProtocolFromTL(req.Protocol), sessionRefFrom(ctx)) + if err != nil { + return nil, phoneCallErr(err) + } + // 推主叫全部设备:phoneCallAccepted{g_b, 协商 protocol},主叫据此算 AuthKey 并 confirm。 + r.pushPhoneCall(ctx, call.AdminID, call, "phone call accepted") + // ⚠ P0-1:被叫其它设备推合成 phoneCallDiscarded 停振铃(ctx except 排除接听设备)。 + // 绝不能推 phoneCallAccepted——incoming 侧收到 accepted 会 finish(Failed) 回发 + // discardCall(TDesktop)/按主叫流程发 confirmCall(DrKLO),杀死刚建立的通话。 + r.pushPhoneCallStopRinging(ctx, call) + return &tg.PhonePhoneCall{ + PhoneCall: tgPhoneCallForViewer(call, userID), // 被叫视角:phoneCallWaiting,等 confirm + Users: r.tgUsersForIDs(ctx, userID, []int64{call.AdminID, call.ParticipantID}), + }, nil +} + +func (r *Router) onPhoneConfirmCall(ctx context.Context, req *tg.PhoneConfirmCallRequest) (*tg.PhonePhoneCall, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.Phone == nil { + return nil, notImplementedErr() + } + userID, err := r.phoneRequireUser(ctx) + if err != nil { + return nil, err + } + call, forcedDiscard, err := r.deps.Phone.ConfirmCall(ctx, userID, req.Peer.ID, req.Peer.AccessHash, + req.GA, req.KeyFingerprint, phoneCallProtocolFromTL(req.Protocol)) + if err != nil { + if forcedDiscard { + // g_a 揭示与承诺不符:通话已被强制终结,推送双方防状态机卡死。 + r.pushPhoneCallDiscardedBoth(ctx, call) + return nil, callPeerInvalidErr() + } + return nil, phoneCallErr(err) + } + // 推被叫全部设备:phoneCall{g_a_or_b=GA}。实际只有接听设备持有本 call, + // 其余设备按 call_id 不识别而静默忽略。 + r.pushPhoneCall(ctx, call.ParticipantID, call, "phone call confirmed") + return &tg.PhonePhoneCall{ + PhoneCall: tgPhoneCallForViewer(call, userID), // 主叫视角:g_a_or_b = g_b + Users: r.tgUsersForIDs(ctx, userID, []int64{call.AdminID, call.ParticipantID}), + }, nil +} + +func (r *Router) onPhoneDiscardCall(ctx context.Context, req *tg.PhoneDiscardCallRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.Phone == nil { + return nil, notImplementedErr() + } + userID, err := r.phoneRequireUser(ctx) + if err != nil { + return nil, err + } + reason := phoneCallDiscardReasonFromTL(req.Reason) + call, already, err := r.deps.Phone.DiscardCall(ctx, userID, req.Peer.ID, req.Peer.AccessHash, reason, req.Duration) + if err != nil { + return nil, phoneCallErr(err) + } + if !already { + // 对端全部设备 + 发起者其它设备(ctx except 排除发起设备,其结果在 RPC 响应里)。 + r.pushPhoneCallDiscardedBoth(ctx, call) + // 落 messageActionPhoneCall 历史(带 pts 走 outbox,双方全部设备可靠收到)。 + r.sendPhoneCallServiceMessage(ctx, call) + } + // 双方同时挂断的竞态:先到者定 reason,后到者拿终态快照(幂等成功)。 + return r.phoneCallUpdates(ctx, call, userID), nil +} + +func (r *Router) onPhoneSendSignalingData(ctx context.Context, req *tg.PhoneSendSignalingDataRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + if r.deps.Phone == nil { + return false, notImplementedErr() + } + userID, err := r.phoneRequireUser(ctx) + if err != nil { + return false, err + } + if max := r.cfg.CallSignalingMaxBytes; max > 0 && len(req.Data) > max { + return false, signalingDataInvalidErr() + } + // forward 在该通话的信令顺序锁内执行(连接层对同 session 的 RPC 是多 worker + // 并发分发,无此锁则转发顺序可能与受理顺序不一致);drop=true 的两种情况 + //(tombstone 尾包、超速率)都按契约静默返回 true——返回错误会让 TDesktop + // 把正常挂断渲染成「通话失败」。 + drop, err := r.deps.Phone.Signal(ctx, userID, req.Peer.ID, req.Peer.AccessHash, func(peerUserID int64, peerDevice domain.SessionRef) { + r.pushPhoneSignalingData(ctx, peerUserID, peerDevice, req.Peer.ID, req.Data) + }) + if err != nil { + return false, phoneCallErr(err) + } + _ = drop + return true, nil +} + +func (r *Router) onPhoneSetCallRating(ctx context.Context, req *tg.PhoneSetCallRatingRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if _, err := r.phoneRequireUser(ctx); err != nil { + return nil, err + } + // 宽容校验:评分常在通话回收后才提交,miss 也成功。不推送任何人。 + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil +} + +func (r *Router) onPhoneSaveCallDebug(ctx context.Context, req *tg.PhoneSaveCallDebugRequest) (bool, error) { + if req == nil { + return false, inputRequestInvalidErr() + } + if _, err := r.phoneRequireUser(ctx); err != nil { + return false, err + } + // 丢弃 debug JSON;phoneCallDiscarded 恒不置 need_debug,正常客户端不会主动上报。 + return true, nil +} diff --git a/internal/rpc/phone_dh.go b/internal/rpc/phone_dh.go new file mode 100644 index 00000000..c0d6ca60 --- /dev/null +++ b/internal/rpc/phone_dh.go @@ -0,0 +1,39 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + appphone "telesrv/internal/app/phone" +) + +// onMessagesGetDhConfig 下发私聊通话(及未来 secret chat)的 DH 参数。 +// +// 契约(TDesktop calls_instance.cpp updateDhConfig): +// - p/g 必须过 MTP::IsPrimeAndGood——用官方 2048-bit prime + g=3 走白名单快速路径; +// - random 字节数必须与请求的 random_length 一致; +// - 客户端缓存命中(version 相同)时返回 dhConfigNotModified,只补新随机数。 +func (r *Router) onMessagesGetDhConfig(ctx context.Context, req *tg.MessagesGetDhConfigRequest) (tg.MessagesDhConfigClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if _, ok, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } else if !ok { + return nil, authKeyUnregisteredErr() + } + random, err := appphone.DHRandom(req.RandomLength) + if err != nil { + return nil, internalErr() + } + if req.Version == appphone.DHConfigVersion { + return &tg.MessagesDhConfigNotModified{Random: random}, nil + } + return &tg.MessagesDhConfig{ + G: appphone.DHG, + P: appphone.DHPrime(), + Version: appphone.DHConfigVersion, + Random: random, + }, nil +} diff --git a/internal/rpc/phone_expiry_dispatcher.go b/internal/rpc/phone_expiry_dispatcher.go new file mode 100644 index 00000000..59b68856 --- /dev/null +++ b/internal/rpc/phone_expiry_dispatcher.go @@ -0,0 +1,60 @@ +package rpc + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +const defaultPhoneExpiryTick = time.Second + +// PhoneExpiryDispatcher 是私聊通话的服务端兜底超时 worker:双方都掉线/卡死时 +// 由它把超时通话迁入终态、推送双方并落「未接来电」历史。正常路径下客户端自身 +// 的 20s/90s 定时器会先行 discard,两者幂等汇合于 tombstone。 +type PhoneExpiryDispatcher struct { + router *Router + log *zap.Logger + interval time.Duration +} + +func NewPhoneExpiryDispatcher(router *Router, log *zap.Logger, interval time.Duration) *PhoneExpiryDispatcher { + if log == nil { + log = zap.NewNop() + } + if interval <= 0 { + interval = defaultPhoneExpiryTick + } + return &PhoneExpiryDispatcher{router: router, log: log, interval: interval} +} + +func (d *PhoneExpiryDispatcher) Run(ctx context.Context) { + if d == nil || d.router == nil { + return + } + ticker := time.NewTicker(d.interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + d.DispatchOnce(ctx) + } + } +} + +func (d *PhoneExpiryDispatcher) DispatchOnce(ctx context.Context) { + if d == nil || d.router == nil || d.router.deps.Phone == nil { + return + } + expired := d.router.deps.Phone.ExpireDue(ctx, d.router.clock.Now()) + for _, call := range expired { + // ctx 无 session 锚点 ⇒ 推送不排除任何设备,双方全部设备收到终态。 + d.router.pushPhoneCallDiscardedBoth(ctx, call) + d.router.sendPhoneCallServiceMessage(ctx, call) + d.log.Info("phone call expired", + zap.Int64("call_id", call.ID), + zap.String("reason", string(call.DiscardReason))) + } +} diff --git a/internal/rpc/phone_group_call.go b/internal/rpc/phone_group_call.go new file mode 100644 index 00000000..f457c907 --- /dev/null +++ b/internal/rpc/phone_group_call.go @@ -0,0 +1,422 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/sfu" +) + +// 超级群语音聊天(group call)核心 RPC。信令真值在 GroupCallStore(version 单调), +// 媒体面经 deps.SFU(M0 为 Disabled:纯信令,客户端停留在 Connecting 属预期)。 + +// groupCallErr 把 domain 群通话错误映射为 RPC_ERROR。 +func groupCallErr(err error) error { + switch { + case errors.Is(err, domain.ErrGroupCallInvalid): + return groupCallInvalidErr() + case errors.Is(err, domain.ErrGroupCallDiscarded): + return groupCallAlreadyDiscardedErr() + case errors.Is(err, domain.ErrGroupCallAlreadyStarted): + return groupCallAlreadyStartedErr() + case errors.Is(err, domain.ErrGroupCallSSRCDuplicate): + return groupCallSSRCDuplicateErr() + case errors.Is(err, domain.ErrGroupCallNotJoined): + return groupCallJoinMissingErr() + default: + return internalErr() + } +} + +// groupCallScope 是群通话 handler 的通用前置解析结果。 +type groupCallScope struct { + userID int64 + call domain.GroupCall + channel domain.Channel + member domain.ChannelMember +} + +func (s *groupCallScope) canManage() bool { + return channelMemberIsAdmin(s.member) +} + +// groupCallScopeFrom 解析 InputGroupCallClass(仅 id+access_hash 变体;slug/ +// inviteMessage 属 conference 路径,返回 GROUPCALL_INVALID)并校验成员资格。 +func (r *Router) groupCallScopeFrom(ctx context.Context, in tg.InputGroupCallClass) (*groupCallScope, error) { + if r.deps.GroupCalls == nil || r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, err := r.phoneRequireUser(ctx) + if err != nil { + return nil, err + } + callID, accessHash, err := inputGroupCallRef(in) + if err != nil { + return nil, err + } + call, found, err := r.deps.GroupCalls.Get(ctx, callID) + if err != nil { + return nil, internalErr() + } + if !found || call.AccessHash != accessHash { + return nil, groupCallInvalidErr() + } + view, err := r.deps.Channels.GetChannel(ctx, userID, call.ChannelID) + if err != nil { + return nil, groupCallForbiddenErr() + } + if view.Self.Status != domain.ChannelMemberActive { + return nil, groupCallForbiddenErr() + } + return &groupCallScope{userID: userID, call: call, channel: view.Channel, member: view.Self}, nil +} + +func (r *Router) onPhoneCreateGroupCall(ctx context.Context, req *tg.PhoneCreateGroupCallRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if r.deps.GroupCalls == nil || r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, err := r.phoneRequireUser(ctx) + if err != nil { + return nil, err + } + if req.RtmpStream { + return nil, notImplementedErr() + } + if _, ok := req.GetScheduleDate(); ok { + return nil, notImplementedErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return nil, peerIDInvalidErr() + } + view, err := r.deps.Channels.GetChannel(ctx, userID, peer.ID) + if err != nil { + return nil, peerIDInvalidErr() + } + if view.Self.Status != domain.ChannelMemberActive || !channelMemberIsAdmin(view.Self) { + return nil, tgerr400("CHAT_ADMIN_REQUIRED") + } + if !view.Channel.Megagroup { + // broadcast 频道的 livestream 属范围外。 + return nil, notImplementedErr() + } + now := int(r.clock.Now().Unix()) + call, err := r.deps.GroupCalls.Create(ctx, view.Channel.ID, userID, req.Title, now) + if err != nil { + return nil, groupCallErr(err) + } + // started 服务消息(带频道 pts,离线成员经 channels difference 补收)。 + var serviceRes domain.SendChannelMessageResult + if res, err := r.deps.Channels.AppendCallServiceMessage(ctx, view.Channel.ID, userID, now, domain.ChannelMessageAction{ + Type: domain.ChannelActionGroupCall, + CallID: call.ID, + CallAccessHash: call.AccessHash, + }); err == nil { + serviceRes = res + _ = r.deps.GroupCalls.SetStartedMessageID(ctx, call.ID, res.Message.ID) + } else { + r.log.Warn("group call started service message", zap.Int64("channel_id", view.Channel.ID), zap.Error(err)) + } + channel, err := r.deps.Channels.SetActiveCall(ctx, view.Channel.ID, call.ID, call.AccessHash, false) + if err != nil { + channel = view.Channel + channel.ActiveCallID = call.ID + channel.ActiveCallAccessHash = call.AccessHash + } + // 扇出:banner flag 刷新 + updateGroupCall + 服务消息。 + r.pushChannelStateToMembers(ctx, userID, channel) + r.pushGroupCallUpdate(ctx, channel, call) + if serviceRes.Event.Pts != 0 { + r.pushGroupCallServiceMessage(ctx, userID, serviceRes) + } + // 响应:updateGroupCall + 服务消息(发起设备视角)。TDesktop 创建后自行 joinGroupCall。 + update := &tg.UpdateGroupCall{Call: tgGroupCall(call, userID, true)} + update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID}) + out := r.groupCallUpdateContainer(ctx, userID, channel, update, []int64{userID}) + if serviceRes.Event.Pts != 0 { + if msgUpdate := tgChannelUpdate(userID, serviceRes.Event); msgUpdate != nil { + out.Updates = append(out.Updates, msgUpdate) + } + } + return out, nil +} + +func (r *Router) onPhoneJoinGroupCall(ctx context.Context, req *tg.PhoneJoinGroupCallRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + scope, err := r.groupCallScopeFrom(ctx, req.Call) + if err != nil { + return nil, err + } + if !scope.call.Active() { + return nil, groupCallAlreadyDiscardedErr() + } + // 解析上行 join JSON(容忍 video_stopped 等 flag 与 ssrc-groups——TDesktop join 即带)。 + offer, ssrc, err := parseGroupCallJoinPayload(req.Params.Data) + if err != nil { + r.log.Warn("group call join payload", zap.Error(err)) + return nil, groupCallInvalidErr() + } + // 房间上限(演示规模):rejoin(已在会换 ssrc)不受限。 + if max := r.cfg.GroupCallMaxParticipants; max > 0 && scope.call.ParticipantsCount >= max { + if p, found, _ := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID); !found || p.Left { + return nil, groupCallForbiddenErr() + } + } + now := int(r.clock.Now().Unix()) + // 视频内部状态:endpoint 服务端铸造(join 响应 video.endpoint 与日后 + // participant.video.endpoint 必须逐字节一致);ssrc-groups 无论摄像头开关都 + // 存档——video_stopped=false(join flag 或后续 self-edit)时原样回放。 + endpoint := groupCallEndpointID(sfu.EndpointMain, offer.AudioSSRC) + videoState := participantVideoState{ + Endpoint: endpoint, + SourceGroups: groupCallSsrcGroupsFromOffer(offer), + Active: !req.VideoStopped && len(offer.SsrcGroups) > 0, + } + mut, err := r.deps.GroupCalls.Join(ctx, domain.JoinGroupCallRequest{ + CallID: scope.call.ID, + UserID: scope.userID, + SSRC: ssrc, + Muted: req.Muted, + IsAdmin: scope.canManage(), + VideoJSON: encodeVideoState(videoState), + Now: now, + }) + if err != nil { + return nil, groupCallErr(err) + } + // 媒体面:SFU 分配 endpoint(M0 Disabled:语法完备空 candidates,客户端保持 + // Connecting 并以 4s checkGroupCall 心跳维持保活——M0 sweeper 判据依赖该行为)。 + sfuService := r.deps.SFU + if sfuService == nil { + sfuService = sfu.Disabled() + } + answer, err := sfuService.Join(ctx, scope.call.ID, scope.userID, sfu.EndpointMain, offer) + if err != nil { + // 媒体面失败回滚信令侧(保持两面一致),返回 500。 + _, _ = r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now) + r.log.Warn("group call sfu join", zap.Error(err)) + return nil, internalErr() + } + params, err := buildGroupCallConnectionParams(answer, endpoint) + if err != nil { + _ = sfuService.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointMain) + _, _ = r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now) + return nil, internalErr() + } + // 扇出给房间/在线群成员(操作者其它设备含其中;本设备从 RPC 返回拿)。 + channel := r.groupCallMutationFanout(ctx, scope.channel, mut) + // 响应(TDesktop 从本 RPC 返回的 Updates 摘取 updateGroupCallConnection,不会等推送): + out := r.groupCallUpdateContainer(ctx, scope.userID, channel, + &tg.UpdateGroupCallParticipants{ + Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash}, + Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID), + Version: mut.Call.Version, + }, []int64{scope.userID}) + out.Updates = append(out.Updates, &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}}) + callUpdate := &tg.UpdateGroupCall{Call: tgGroupCall(mut.Call, scope.userID, scope.canManage())} + callUpdate.SetPeer(&tg.PeerChannel{ChannelID: channel.ID}) + out.Updates = append(out.Updates, callUpdate) + return out, nil +} + +func (r *Router) onPhoneLeaveGroupCall(ctx context.Context, req *tg.PhoneLeaveGroupCallRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + scope, err := r.groupCallScopeFrom(ctx, req.Call) + if err != nil { + return nil, err + } + now := int(r.clock.Now().Unix()) + mut, err := r.deps.GroupCalls.Leave(ctx, scope.call.ID, scope.userID, now) + if errors.Is(err, domain.ErrGroupCallNotJoined) { + // 幂等:重复 leave / sweeper 已清,返回当前快照。 + return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, + groupCallUpdateFor(scope.channel, scope.call, scope.userID, false), nil), nil + } + if err != nil { + return nil, groupCallErr(err) + } + if r.deps.SFU != nil { + _ = r.deps.SFU.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointMain) + } + channel := r.groupCallMutationFanout(ctx, scope.channel, mut) + return r.groupCallUpdateContainer(ctx, scope.userID, channel, + &tg.UpdateGroupCallParticipants{ + Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash}, + Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID), + Version: mut.Call.Version, + }, []int64{scope.userID}), nil +} + +func (r *Router) onPhoneDiscardGroupCall(ctx context.Context, in tg.InputGroupCallClass) (tg.UpdatesClass, error) { + scope, err := r.groupCallScopeFrom(ctx, in) + if err != nil { + return nil, err + } + if !scope.canManage() { + return nil, tgerr400("CHAT_ADMIN_REQUIRED") + } + now := int(r.clock.Now().Unix()) + call, _, err := r.deps.GroupCalls.Discard(ctx, scope.call.ID, now) + if err != nil { + return nil, groupCallErr(err) + } + if r.deps.SFU != nil { + _ = r.deps.SFU.CloseRoom(ctx, call.ID) + } + // 清 channel 关联 + ended 服务消息(带 duration)。 + channel := scope.channel + if updated, err := r.deps.Channels.SetActiveCall(ctx, channel.ID, 0, 0, false); err == nil { + channel = updated + } + var serviceRes domain.SendChannelMessageResult + if res, err := r.deps.Channels.AppendCallServiceMessage(ctx, channel.ID, scope.userID, now, domain.ChannelMessageAction{ + Type: domain.ChannelActionGroupCall, + CallID: call.ID, + CallAccessHash: call.AccessHash, + CallDuration: call.Duration, + }); err == nil { + serviceRes = res + } else { + r.log.Warn("group call ended service message", zap.Int64("channel_id", channel.ID), zap.Error(err)) + } + r.pushChannelStateToMembers(ctx, scope.userID, channel) + r.pushGroupCallUpdate(ctx, channel, call) + if serviceRes.Event.Pts != 0 { + r.pushGroupCallServiceMessage(ctx, scope.userID, serviceRes) + } + out := r.groupCallUpdateContainer(ctx, scope.userID, channel, + groupCallUpdateFor(channel, call, scope.userID, true), nil) + if serviceRes.Event.Pts != 0 { + if msgUpdate := tgChannelUpdate(scope.userID, serviceRes.Event); msgUpdate != nil { + out.Updates = append(out.Updates, msgUpdate) + } + } + return out, nil +} + +func (r *Router) onPhoneGetGroupCall(ctx context.Context, req *tg.PhoneGetGroupCallRequest) (*tg.PhoneGroupCall, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + scope, err := r.groupCallScopeFrom(ctx, req.Call) + if err != nil { + return nil, err + } + limit := req.Limit + if limit <= 0 || limit > 200 { + limit = 50 + } + page, err := r.deps.GroupCalls.Participants(ctx, scope.call.ID, "", limit) + if err != nil { + return nil, groupCallErr(err) + } + userIDs := make([]int64, 0, len(page.Participants)) + for _, p := range page.Participants { + userIDs = append(userIDs, p.UserID) + } + return &tg.PhoneGroupCall{ + Call: tgGroupCall(scope.call, scope.userID, scope.canManage()), + Participants: tgGroupCallParticipants(page.Participants, scope.userID), + ParticipantsNextOffset: page.NextOffset, + Chats: []tg.ChatClass{tgChannel(scope.userID, scope.channel, &scope.member)}, + Users: r.tgUsersForIDs(ctx, scope.userID, userIDs), + }, nil +} + +func (r *Router) onPhoneGetGroupParticipants(ctx context.Context, req *tg.PhoneGetGroupParticipantsRequest) (*tg.PhoneGroupParticipants, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + scope, err := r.groupCallScopeFrom(ctx, req.Call) + if err != nil { + return nil, err + } + limit := req.Limit + if limit <= 0 || limit > 200 { + limit = 50 + } + page, err := r.deps.GroupCalls.Participants(ctx, scope.call.ID, req.Offset, limit) + if err != nil { + return nil, groupCallErr(err) + } + userIDs := make([]int64, 0, len(page.Participants)) + for _, p := range page.Participants { + userIDs = append(userIDs, p.UserID) + } + // 响应 version=当前值:客户端 version 跳号后据此重建本地状态并恢复增量应用。 + return &tg.PhoneGroupParticipants{ + Count: page.Count, + Participants: tgGroupCallParticipants(page.Participants, scope.userID), + NextOffset: page.NextOffset, + Chats: []tg.ChatClass{tgChannel(scope.userID, scope.channel, &scope.member)}, + Users: r.tgUsersForIDs(ctx, scope.userID, userIDs), + Version: page.Version, + }, nil +} + +// onPhoneCheckGroupCall 是保活与「踢人/重启恢复」的统一出口:返回入参 sources 中 +// 仍属于该用户活跃 endpoint 的子集;自己的 ssrc 不在 ⇒ 客户端自动 rejoin。 +// 注意:客户端只在 Connecting 态调它(媒体连通后心跳停止),不可据此单独判死。 +func (r *Router) onPhoneCheckGroupCall(ctx context.Context, req *tg.PhoneCheckGroupCallRequest) ([]int, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + scope, err := r.groupCallScopeFrom(ctx, req.Call) + if err != nil { + return nil, err + } + now := int(r.clock.Now().Unix()) + active, joined, err := r.deps.GroupCalls.Touch(ctx, scope.call.ID, scope.userID, now) + if err != nil { + return nil, groupCallErr(err) + } + if !joined { + return []int{}, nil + } + activeSet := make(map[int]struct{}, len(active)) + for _, ssrc := range active { + activeSet[int(int32(uint32(ssrc)))] = struct{}{} + } + // presentation 的全部 ssrc(音频+视频层+RTX)也属于活跃 endpoint:TDesktop 把 + // screen ssrc 一并塞进 sources,DrKLO 用 presentation.audio_source(缺省退化 + // 取首个视频 ssrc)——任一不在返回集合都会触发 4s 循环重建屏幕实例。 + if p, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID); err == nil && found && !p.Left { + if st, ok := decodeVideoState(p.PresentationJSON); ok && st.Active { + if st.AudioSource != 0 { + activeSet[int(int32(uint32(st.AudioSource)))] = struct{}{} + } + for _, g := range st.SourceGroups { + for _, src := range g.Sources { + activeSet[int(int32(uint32(src)))] = struct{}{} + } + } + } + } + out := make([]int, 0, len(req.Sources)) + for _, src := range req.Sources { + if _, ok := activeSet[src]; ok { + out = append(out, src) + } + } + return out, nil +} + +func groupCallUpdateFor(channel domain.Channel, call domain.GroupCall, viewerUserID int64, canManage bool) *tg.UpdateGroupCall { + update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerUserID, canManage)} + update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID}) + return update +} diff --git a/internal/rpc/phone_group_call_edit.go b/internal/rpc/phone_group_call_edit.go new file mode 100644 index 00000000..af1c3886 --- /dev/null +++ b/internal/rpc/phone_group_call_edit.go @@ -0,0 +1,327 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// editGroupCallParticipant 权限矩阵(M2 完整版): +// +// 操作 自己→自己 普通成员→他人 admin/creator→他人 +// muted=true muted, can_self_unmute overrides.muted_by_you muted_by_admin(禁言) +// muted=false 仅当 can_self_unmute 清 overrides 允许发言(清 muted_by_admin+举手) +// volume 忽略 overrides.volume volume_by_admin(全员) +// raise_hand rating=全局单调序号 ✗ admin 放下他人手 +// +// ⚠ P1-5:self 的 video_stopped/video_paused 一律接受/忽略转发,绝不返回错误 +// (edit 失败在客户端有 rejoin 风暴风险)。 +func (r *Router) onPhoneEditGroupCallParticipant(ctx context.Context, req *tg.PhoneEditGroupCallParticipantRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + scope, err := r.groupCallScopeFrom(ctx, req.Call) + if err != nil { + return nil, err + } + targetID, err := r.groupCallParticipantTarget(ctx, scope.userID, req.Participant) + if err != nil { + return nil, err + } + target, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, targetID) + if err != nil { + return nil, internalErr() + } + if !found || target.Left { + return nil, groupCallJoinMissingErr() + } + + switch { + case targetID == scope.userID: + return r.editSelfParticipant(ctx, scope, req, target) + case scope.canManage(): + return r.adminEditParticipant(ctx, scope, req, target) + default: + return r.memberOverrideParticipant(ctx, scope, req, targetID) + } +} + +// editSelfParticipant 处理 self 维度(mute 自由翻转、举手、视频开关/暂停)。 +// ⚠ P1-5:video/presentation 维度的 self-edit 绝不返回错误(edit 失败有客户端 +// rejoin 风暴风险;幂等重放也回成功空 Updates 而非 GROUPCALL_NOT_MODIFIED)。 +func (r *Router) editSelfParticipant(ctx context.Context, scope *groupCallScope, req *tg.PhoneEditGroupCallParticipantRequest, self domain.GroupCallParticipant) (tg.UpdatesClass, error) { + update := domain.GroupCallParticipantUpdate{Now: int(r.clock.Now().Unix())} + if raise, ok := req.GetRaiseHand(); ok { + if raise { + rating, err := r.deps.GroupCalls.NextRaiseHandRating(ctx, scope.call.ID) + if err != nil { + return nil, internalErr() + } + update.RaiseHandRating = &rating + } else { + zero := int64(0) + update.RaiseHandRating = &zero + } + } + if muted, ok := req.GetMuted(); ok { + if self.MutedByAdmin && !muted { + // 被管理员压制时不能自行 unmute(客户端此时只会发 raise_hand)。 + return nil, groupCallForbiddenErr() + } + update.Muted = &muted + } + videoEdit := false + // video_stopped:翻转 video 字段的出现/消失。endpoint 与 source_groups 来自 + // join 时的存档(开关摄像头不重发 join),缺档(极老会话)静默容忍。 + if stopped, ok := req.GetVideoStopped(); ok { + videoEdit = true + if st, found := decodeVideoState(self.VideoJSON); found { + st.Active = !stopped && len(st.SourceGroups) > 0 + if !st.Active { + st.Paused = false + } + raw := encodeVideoState(st) + update.VideoJSON = &raw + } + } + // video_paused:video 字段保留、置 paused(观看端渲染占位)。 + if paused, ok := req.GetVideoPaused(); ok { + videoEdit = true + if st, found := decodeVideoState(self.VideoJSON); found && st.Active { + st.Paused = paused + raw := encodeVideoState(st) + update.VideoJSON = &raw + } + } + if paused, ok := req.GetPresentationPaused(); ok { + videoEdit = true + if st, found := decodeVideoState(self.PresentationJSON); found && st.Active { + st.Paused = paused + raw := encodeVideoState(st) + update.PresentationJSON = &raw + } + } + mut, changed, err := r.deps.GroupCalls.UpdateParticipant(ctx, scope.call.ID, scope.userID, update) + if err != nil { + if videoEdit { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + return nil, groupCallErr(err) + } + if !changed { + if videoEdit { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + return nil, groupCallNotModifiedErr() + } + channel := r.groupCallMutationFanout(ctx, scope.channel, mut) + return r.groupCallUpdateContainer(ctx, scope.userID, channel, + &tg.UpdateGroupCallParticipants{ + Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash}, + Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID), + Version: mut.Call.Version, + }, []int64{scope.userID}), nil +} + +// adminEditParticipant 处理 admin→他人(禁言/允许发言/全局音量/放下举手)。 +func (r *Router) adminEditParticipant(ctx context.Context, scope *groupCallScope, req *tg.PhoneEditGroupCallParticipantRequest, target domain.GroupCallParticipant) (tg.UpdatesClass, error) { + update := domain.GroupCallParticipantUpdate{Now: int(r.clock.Now().Unix())} + if muted, ok := req.GetMuted(); ok { + mutedByAdmin := muted + update.MutedByAdmin = &mutedByAdmin + if muted { + // 禁言:muted=true 且 can_self_unmute=false。 + update.Muted = &muted + } else { + // "允许发言":只恢复 can_self_unmute 并清举手,不替用户开麦 + //(官方语义:用户收到后仍是 muted,需自行 unmute)。 + zero := int64(0) + update.RaiseHandRating = &zero + } + } + if raise, ok := req.GetRaiseHand(); ok && !raise { + // admin 放下他人举手。 + zero := int64(0) + update.RaiseHandRating = &zero + } + if volume, ok := req.GetVolume(); ok { + update.VolumeByAdmin = &volume + } + return r.applyParticipantMutation(ctx, scope, target.UserID, update) +} + +// memberOverrideParticipant 处理普通成员→他人(本地静音/本地音量,per-viewer, +// 仅 setter 自己可见,不进全房间 version)。 +func (r *Router) memberOverrideParticipant(ctx context.Context, scope *groupCallScope, req *tg.PhoneEditGroupCallParticipantRequest, targetID int64) (tg.UpdatesClass, error) { + ov, _, err := r.deps.GroupCalls.ParticipantOverride(ctx, scope.call.ID, scope.userID, targetID) + if err != nil { + return nil, internalErr() + } + changed := false + if muted, ok := req.GetMuted(); ok { + if ov.MutedByYou != muted { + ov.MutedByYou = muted + changed = true + } + } + if volume, ok := req.GetVolume(); ok { + if ov.Volume != volume { + ov.Volume = volume + changed = true + } + } + if !changed { + return nil, groupCallNotModifiedErr() + } + clear := !ov.MutedByYou && ov.Volume == 0 + if err := r.deps.GroupCalls.SetParticipantOverride(ctx, scope.call.ID, scope.userID, targetID, ov, clear); err != nil { + return nil, internalErr() + } + // per-viewer:只推给 setter 自己(其全部设备,带 min flag 防覆盖本地状态)。 + target, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, targetID) + if err != nil || !found { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + } + row := applyParticipantOverride(tgGroupCallParticipant(target, scope.userID), ov) + update := &tg.UpdateGroupCallParticipants{ + Call: &tg.InputGroupCall{ID: scope.call.ID, AccessHash: scope.call.AccessHash}, + Participants: []tg.GroupCallParticipant{row}, + Version: scope.call.Version, // overrides 不推进 version + } + out := r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, update, []int64{targetID}) + r.pushUserMessage(ctx, scope.userID, "group call override", out) + return out, nil +} + +// applyParticipantMutation 把全房间维度的字段级更新落库并扇出(version++)。 +func (r *Router) applyParticipantMutation(ctx context.Context, scope *groupCallScope, targetID int64, update domain.GroupCallParticipantUpdate) (tg.UpdatesClass, error) { + mut, changed, err := r.deps.GroupCalls.UpdateParticipant(ctx, scope.call.ID, targetID, update) + if err != nil { + return nil, groupCallErr(err) + } + if !changed { + return nil, groupCallNotModifiedErr() + } + channel := r.groupCallMutationFanout(ctx, scope.channel, mut) + return r.groupCallUpdateContainer(ctx, scope.userID, channel, + &tg.UpdateGroupCallParticipants{ + Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash}, + Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID), + Version: mut.Call.Version, + }, []int64{targetID}), nil +} + +func (r *Router) onPhoneEditGroupCallTitle(ctx context.Context, req *tg.PhoneEditGroupCallTitleRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + scope, err := r.groupCallScopeFrom(ctx, req.Call) + if err != nil { + return nil, err + } + if !scope.canManage() { + return nil, tgerr400("CHAT_ADMIN_REQUIRED") + } + call, changed, err := r.deps.GroupCalls.SetTitle(ctx, scope.call.ID, req.Title) + if err != nil { + return nil, groupCallErr(err) + } + if !changed { + return nil, groupCallNotModifiedErr() + } + r.pushGroupCallUpdate(ctx, scope.channel, call) + return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, + groupCallUpdateFor(scope.channel, call, scope.userID, true), nil), nil +} + +func (r *Router) onPhoneToggleGroupCallSettings(ctx context.Context, req *tg.PhoneToggleGroupCallSettingsRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + scope, err := r.groupCallScopeFrom(ctx, req.Call) + if err != nil { + return nil, err + } + if !scope.canManage() { + return nil, tgerr400("CHAT_ADMIN_REQUIRED") + } + joinMuted, ok := req.GetJoinMuted() + if !ok { + // reset_invite_hash / messages_enabled 等范围外开关:无变化语义。 + return nil, groupCallNotModifiedErr() + } + call, changed, err := r.deps.GroupCalls.SetJoinMuted(ctx, scope.call.ID, joinMuted) + if err != nil { + return nil, groupCallErr(err) + } + if !changed { + return nil, groupCallNotModifiedErr() + } + r.pushGroupCallUpdate(ctx, scope.channel, call) + return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, + groupCallUpdateFor(scope.channel, call, scope.userID, true), nil), nil +} + +const maxInviteToGroupCallUsers = 10 + +// onPhoneInviteToGroupCall 发 messageActionInviteToGroupCall 服务消息(带频道 pts), +// 被邀请者通过正常频道消息收到可点入会卡片;不直接拉人入会。 +func (r *Router) onPhoneInviteToGroupCall(ctx context.Context, req *tg.PhoneInviteToGroupCallRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + scope, err := r.groupCallScopeFrom(ctx, req.Call) + if err != nil { + return nil, err + } + if len(req.Users) == 0 || len(req.Users) > maxInviteToGroupCallUsers { + return nil, limitInvalidErr() + } + targetIDs := make([]int64, 0, len(req.Users)) + for _, in := range req.Users { + u, found, err := r.userFromInput(ctx, scope.userID, in) + if err != nil { + return nil, internalErr() + } + if !found || u.ID == 0 { + return nil, userIDInvalidErr() + } + // 目标必须是本群成员。 + member, err := r.deps.Channels.GetParticipant(ctx, scope.userID, scope.channel.ID, u.ID) + if err != nil || member.Status != domain.ChannelMemberActive { + return nil, tgerr400("USER_NOT_PARTICIPANT") + } + targetIDs = append(targetIDs, u.ID) + } + now := int(r.clock.Now().Unix()) + res, err := r.deps.Channels.AppendCallServiceMessage(ctx, scope.channel.ID, scope.userID, now, domain.ChannelMessageAction{ + Type: domain.ChannelActionInviteToGroupCall, + CallID: scope.call.ID, + CallAccessHash: scope.call.AccessHash, + UserIDs: targetIDs, + }) + if err != nil { + return nil, internalErr() + } + r.pushGroupCallServiceMessage(ctx, scope.userID, res) + out := r.groupCallUpdateContainer(ctx, scope.userID, res.Channel, nil, targetIDs) + out.Updates = nil + if msgUpdate := tgChannelUpdate(scope.userID, res.Event); msgUpdate != nil { + out.Updates = append(out.Updates, msgUpdate) + } + return out, nil +} + +// groupCallParticipantTarget 解析 editGroupCallParticipant 的目标(仅 user peer)。 +func (r *Router) groupCallParticipantTarget(ctx context.Context, currentUserID int64, peer tg.InputPeerClass) (int64, error) { + switch v := peer.(type) { + case *tg.InputPeerSelf: + return currentUserID, nil + case *tg.InputPeerUser: + return v.UserID, nil + default: + return 0, peerIDInvalidErr() + } +} diff --git a/internal/rpc/phone_group_call_presentation.go b/internal/rpc/phone_group_call_presentation.go new file mode 100644 index 00000000..fa8ad869 --- /dev/null +++ b/internal/rpc/phone_group_call_presentation.go @@ -0,0 +1,145 @@ +package rpc + +import ( + "context" + "errors" + + "github.com/gotd/td/tg" + + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/sfu" +) + +// 屏幕共享(presentation):同一参与者的第二条媒体连接(独立 ICE/DTLS/ssrc 集), +// TL 层落在同一 participant 行的 presentation 字段。硬契约(读客户端源码定): +// - 响应 Updates 里的 updateGroupCallConnection 必须置 presentation flag +// (bit0),否则被主实例误食;DrKLO 只在响应里就地找它,不能只走推送; +// - presentation 字段必须带 audio_source:DrKLO 用它做屏幕 mySource 并塞进 +// checkGroupCall,缺失会退化取首个视频 ssrc 致 4s 循环重建; +// - 主连接 rejoin / 整体离会从不补发 leaveGroupCallPresentation:join 整体 +// 替换 video_json 时清 presentation_json,SFU 侧主 endpoint 拆除联动拆屏幕。 + +func (r *Router) onPhoneJoinGroupCallPresentation(ctx context.Context, req *tg.PhoneJoinGroupCallPresentationRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + scope, err := r.groupCallScopeFrom(ctx, req.Call) + if err != nil { + return nil, err + } + if !scope.call.Active() { + return nil, groupCallAlreadyDiscardedErr() + } + // 前置:必须已通过主 join 在会(客户端也只在主 join ack 后才发本 RPC)。 + self, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID) + if err != nil { + return nil, internalErr() + } + if !found || self.Left { + return nil, groupCallJoinMissingErr() + } + offer, ssrc, err := parseGroupCallJoinPayload(req.Params.Data) + if err != nil { + r.log.Warn("group call presentation payload", zap.Error(err)) + return nil, groupCallInvalidErr() + } + endpoint := groupCallEndpointID(sfu.EndpointPresentation, offer.AudioSSRC) + state := participantVideoState{ + Endpoint: endpoint, + SourceGroups: groupCallSsrcGroupsFromOffer(offer), + Active: true, // joinGroupCallPresentation 即开始共享(无 video_stopped 概念) + AudioSource: ssrc, + } + // 媒体面先行:独立第二 endpoint(重复 join 替换式幂等——rejoinPresentation + // 会带同一套 ufrag/ssrc 重发)。 + sfuService := r.deps.SFU + if sfuService == nil { + sfuService = sfu.Disabled() + } + answer, err := sfuService.Join(ctx, scope.call.ID, scope.userID, sfu.EndpointPresentation, offer) + if err != nil { + r.log.Warn("group call presentation sfu join", zap.Error(err)) + return nil, internalErr() + } + raw := encodeVideoState(state) + update := domain.GroupCallParticipantUpdate{PresentationJSON: &raw, Now: int(r.clock.Now().Unix())} + mut, changed, err := r.deps.GroupCalls.UpdateParticipant(ctx, scope.call.ID, scope.userID, update) + if err != nil { + _ = sfuService.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointPresentation) + return nil, groupCallErr(err) + } + params, err := buildGroupCallConnectionParams(answer, endpoint) + if err != nil { + _ = sfuService.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointPresentation) + return nil, internalErr() + } + var channel domain.Channel + if changed { + channel = r.groupCallMutationFanout(ctx, scope.channel, mut) + } else { + // 同参数重放(幂等 rejoin):无状态变化也要返回完整连接参数。 + channel = scope.channel + mut = domain.GroupCallMutation{Call: scope.call, Participant: self} + } + out := r.groupCallUpdateContainer(ctx, scope.userID, channel, + &tg.UpdateGroupCallParticipants{ + Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash}, + Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID), + Version: mut.Call.Version, + }, []int64{scope.userID}) + conn := &tg.UpdateGroupCallConnection{Params: tg.DataJSON{Data: params}} + conn.SetPresentation(true) + out.Updates = append(out.Updates, conn) + return out, nil +} + +func (r *Router) onPhoneLeaveGroupCallPresentation(ctx context.Context, call tg.InputGroupCallClass) (tg.UpdatesClass, error) { + scope, err := r.groupCallScopeFrom(ctx, call) + if err != nil { + return nil, err + } + if r.deps.SFU != nil { + _ = r.deps.SFU.Leave(ctx, scope.call.ID, scope.userID, sfu.EndpointPresentation) + } + // 幂等快照:未在共享/已清理时返回当前态而非报错(屏幕共享既已下架即达成目的)。 + idempotentSnapshot := func() (tg.UpdatesClass, error) { + return r.groupCallUpdateContainer(ctx, scope.userID, scope.channel, + groupCallUpdateFor(scope.channel, scope.call, scope.userID, scope.canManage()), nil), nil + } + self, found, err := r.deps.GroupCalls.Participant(ctx, scope.call.ID, scope.userID) + if err != nil { + return nil, internalErr() + } + if !found || self.Left || len(self.PresentationJSON) == 0 { + return idempotentSnapshot() + } + empty := []byte(nil) + mut, changed, err := r.deps.GroupCalls.UpdateParticipant(ctx, scope.call.ID, scope.userID, + domain.GroupCallParticipantUpdate{PresentationJSON: &empty, Now: int(r.clock.Now().Unix())}) + if err != nil { + // 与并发的 leaveGroupCall/discard 撞车:presentation 随主连接离会被级联清掉后, + // 本显式 leave 在写时发现 participant 已 Left(ErrGroupCallNotJoined)或 call 已 + // discarded。屏幕共享既已下架,按幂等成功返回快照而非 GROUPCALL_JOIN_MISSING 400 + // (客户端此刻正同时离会,会忽略该 400,但报错污染日志且非正确语义)。 + if errors.Is(err, domain.ErrGroupCallNotJoined) || errors.Is(err, domain.ErrGroupCallDiscarded) { + return idempotentSnapshot() + } + return nil, groupCallErr(err) + } + if !changed { + mut = domain.GroupCallMutation{Call: scope.call, Participant: self} + } + channel := scope.channel + if changed { + channel = r.groupCallMutationFanout(ctx, scope.channel, mut) + } + // 其他端靠本行 presentation 字段消失把屏幕画面下架。 + return r.groupCallUpdateContainer(ctx, scope.userID, channel, + &tg.UpdateGroupCallParticipants{ + Call: &tg.InputGroupCall{ID: mut.Call.ID, AccessHash: mut.Call.AccessHash}, + Participants: tgGroupCallParticipants([]domain.GroupCallParticipant{mut.Participant}, scope.userID), + Version: mut.Call.Version, + }, []int64{scope.userID}), nil +} diff --git a/internal/rpc/phone_group_call_rpc_test.go b/internal/rpc/phone_group_call_rpc_test.go new file mode 100644 index 00000000..bb955d72 --- /dev/null +++ b/internal/rpc/phone_group_call_rpc_test.go @@ -0,0 +1,649 @@ +package rpc + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appgroupcalls "telesrv/internal/app/groupcalls" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// groupCallSessions 在推送捕获之上叠加 OnlineUserProvider(群通话扇出需要)。 +type groupCallSessions struct { + phoneCaptureSessions + online []int64 +} + +func (s *groupCallSessions) IsUserOnline(userID int64) bool { return false } +func (s *groupCallSessions) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 { + return nil +} +func (s *groupCallSessions) TrackChannelInterest([8]byte, int64, int64, []int64) {} +func (s *groupCallSessions) ClearChannelInterest([8]byte, int64, int64) {} +func (s *groupCallSessions) OnlineChannelUserIDs(int64, int) []int64 { return nil } +func (s *groupCallSessions) SetSessionChannelMemberships([8]byte, int64, int64, []int64) {} +func (s *groupCallSessions) AddUserChannelMembership(int64, int64) {} +func (s *groupCallSessions) RemoveUserChannelMembership(int64, int64) {} +func (s *groupCallSessions) OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 { + return append([]int64(nil), s.online...) +} + +type groupCallFixture struct { + t *testing.T + ctx context.Context + router *Router + sessions *groupCallSessions + clk *phoneTestClock + owner domain.User + member domain.User + outsider domain.User + channel *tg.Channel +} + +func newGroupCallFixture(t *testing.T) *groupCallFixture { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + channelStore := memory.NewChannelStore() + sessions := &groupCallSessions{} + clk := &phoneTestClock{now: time.Unix(1_700_000_000, 0)} + router := New(Config{GroupCallMaxParticipants: 8}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + GroupCalls: appgroupcalls.NewService(memory.NewGroupCallStore()), + Sessions: sessions, + }, zaptest.NewLogger(t), clk) + f := &groupCallFixture{t: t, ctx: ctx, router: router, sessions: sessions, clk: clk} + mk := func(hash int64, phone, name string) domain.User { + u, err := userStore.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name}) + if err != nil { + t.Fatalf("create user %s: %v", name, err) + } + return u + } + f.owner = mk(2001, "13900000001", "Owner") + f.member = mk(2002, "13900000002", "Member") + f.outsider = mk(2003, "13900000003", "Outsider") + + created, err := router.onMessagesCreateChat(f.userCtx(f.owner, 11), &tg.MessagesCreateChatRequest{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: f.member.ID, AccessHash: f.member.AccessHash}}, + Title: "voice room", + }) + if err != nil { + t.Fatalf("create chat: %v", err) + } + updates := created.Updates.(*tg.Updates) + for _, chat := range updates.Chats { + if ch, ok := chat.(*tg.Channel); ok { + f.channel = ch + break + } + } + if f.channel == nil { + t.Fatalf("no channel in create chat result") + } + f.sessions.online = []int64{f.owner.ID, f.member.ID} + f.sessions.reset() + return f +} + +func (f *groupCallFixture) userCtx(u domain.User, session int64) context.Context { + return WithSessionID(WithUserID(f.ctx, u.ID), session) +} + +func groupCallJoinParams(t *testing.T, ssrc int32) tg.DataJSON { + t.Helper() + payload := map[string]any{ + "ssrc": ssrc, + "ufrag": "clientufrag", + "pwd": "clientpwd-clientpwd-1234", + "fingerprints": []map[string]any{ + {"hash": "sha-256", "fingerprint": "AA:BB:CC:DD", "setup": "passive"}, + }, + // TDesktop join 可能携带的多余字段:解析必须容忍。 + "ssrc-groups": []map[string]any{}, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal join payload: %v", err) + } + return tg.DataJSON{Data: string(data)} +} + +func findUpdate[T tg.UpdateClass](t *testing.T, updates tg.UpdatesClass) T { + t.Helper() + box, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, u := range box.Updates { + if v, ok := u.(T); ok { + return v + } + } + var zero T + t.Fatalf("updates %v missing %T", box.Updates, zero) + return zero +} + +func TestGroupCallM0Lifecycle(t *testing.T) { + f := newGroupCallFixture(t) + ownerCtx := f.userCtx(f.owner, 11) + memberCtx := f.userCtx(f.member, 22) + + // --- create --- + createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + RandomID: 1, + }) + if err != nil { + t.Fatalf("createGroupCall: %v", err) + } + callUpdate := findUpdate[*tg.UpdateGroupCall](t, createRes) + call, ok := callUpdate.Call.(*tg.GroupCall) + if !ok || call.ID == 0 || call.AccessHash == 0 || call.Version != 1 { + t.Fatalf("created call = %+v", callUpdate.Call) + } + // started 服务消息(带频道 pts、messageActionGroupCall 无 duration)。 + newMsg := findUpdate[*tg.UpdateNewChannelMessage](t, createRes) + svc, ok := newMsg.Message.(*tg.MessageService) + if !ok { + t.Fatalf("service message = %T", newMsg.Message) + } + action, ok := svc.Action.(*tg.MessageActionGroupCall) + if !ok { + t.Fatalf("service action = %T, want MessageActionGroupCall", svc.Action) + } + if _, hasDuration := action.GetDuration(); hasDuration { + t.Fatalf("started action must not carry duration") + } + if newMsg.Pts == 0 { + t.Fatalf("service message must carry channel pts") + } + inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash} + + // banner 数据源:channel 行已挂 ActiveCall。 + full, err := f.router.deps.Channels.GetChannel(f.ctx, f.member.ID, f.channel.ID) + if err != nil || full.Channel.ActiveCallID != call.ID || full.Channel.ActiveCallNotEmpty { + t.Fatalf("channel active call = %+v err=%v", full.Channel, err) + } + f.sessions.reset() + + // --- owner join --- + joinRes, err := f.router.onPhoneJoinGroupCall(ownerCtx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, + JoinAs: &tg.InputPeerSelf{}, + Params: groupCallJoinParams(t, -100200300), // 负数 ssrc:uint32 按位转 int32 + }) + if err != nil { + t.Fatalf("joinGroupCall: %v", err) + } + conn := findUpdate[*tg.UpdateGroupCallConnection](t, joinRes) + var params struct { + Transport struct { + Ufrag string `json:"ufrag"` + Pwd string `json:"pwd"` + Fingerprints []struct { + Hash string `json:"hash"` + Setup string `json:"setup"` + Print string `json:"fingerprint"` + } `json:"fingerprints"` + Candidates []any `json:"candidates"` + } `json:"transport"` + } + if err := json.Unmarshal([]byte(conn.Params.Data), ¶ms); err != nil { + t.Fatalf("connection params not parseable: %v\n%s", err, conn.Params.Data) + } + // ⚠ P2-6:M0 下发的 transport JSON 必须语法完备(合法 ufrag/pwd/sha-256 指纹 + 空 candidates)。 + if params.Transport.Ufrag == "" || params.Transport.Pwd == "" || + len(params.Transport.Fingerprints) != 1 || + params.Transport.Fingerprints[0].Hash != "sha-256" || + params.Transport.Fingerprints[0].Setup != "active" || + params.Transport.Fingerprints[0].Print == "" { + t.Fatalf("transport params incomplete: %s", conn.Params.Data) + } + if len(params.Transport.Candidates) != 0 { + t.Fatalf("M0 candidates = %v, want empty", params.Transport.Candidates) + } + joinParts := findUpdate[*tg.UpdateGroupCallParticipants](t, joinRes) + if joinParts.Version != 2 || len(joinParts.Participants) != 1 { + t.Fatalf("join participants update = %+v", joinParts) + } + if !joinParts.Participants[0].Self || joinParts.Participants[0].Source != -100200300 { + t.Fatalf("join self participant = %+v", joinParts.Participants[0]) + } + f.sessions.reset() + + // --- member join:在线成员收到 version=3 增量 --- + if _, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, + JoinAs: &tg.InputPeerSelf{}, + Params: groupCallJoinParams(t, 500600700), + }); err != nil { + t.Fatalf("member join: %v", err) + } + sawParticipants := false + for _, rec := range f.sessions.records() { + box, ok := rec.msg.(*tg.Updates) + if !ok { + continue + } + for _, u := range box.Updates { + if parts, ok := u.(*tg.UpdateGroupCallParticipants); ok && rec.userID == f.owner.ID { + if parts.Version != 3 || len(parts.Participants) != 1 { + t.Fatalf("fanout participants = %+v", parts) + } + if parts.Participants[0].Self { + t.Fatalf("member row pushed to owner must not be self-flagged") + } + sawParticipants = true + } + } + } + if !sawParticipants { + t.Fatalf("owner must receive member-join participants update, got %+v", f.sessions.records()) + } + // call_not_empty 翻转已写回 channel。 + view, _ := f.router.deps.Channels.GetChannel(f.ctx, f.owner.ID, f.channel.ID) + if !view.Channel.ActiveCallNotEmpty { + t.Fatalf("call_not_empty should flip after first join") + } + f.sessions.reset() + + // --- checkGroupCall:在会返回自己的 ssrc 子集;未在会返回空 --- + ssrcs, err := f.router.onPhoneCheckGroupCall(memberCtx, &tg.PhoneCheckGroupCallRequest{ + Call: &inputCall, Sources: []int{500600700, 42}, + }) + if err != nil || len(ssrcs) != 1 || ssrcs[0] != 500600700 { + t.Fatalf("checkGroupCall = %v err=%v", ssrcs, err) + } + // getGroupParticipants 携带当前 version。 + parts, err := f.router.onPhoneGetGroupParticipants(memberCtx, &tg.PhoneGetGroupParticipantsRequest{ + Call: &inputCall, Limit: 10, + }) + if err != nil || parts.Count != 2 || parts.Version != 3 { + t.Fatalf("getGroupParticipants = %+v err=%v", parts, err) + } + + // --- 自我静音(M0 self-edit)--- + editRes, err := f.router.onPhoneEditGroupCallParticipant(memberCtx, &tg.PhoneEditGroupCallParticipantRequest{ + Call: &inputCall, + Participant: &tg.InputPeerSelf{}, + }) + if err == nil { + t.Fatalf("edit without flags should be NOT_MODIFIED, got %+v", editRes) + } + req := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}} + req.SetMuted(true) + editRes, err = f.router.onPhoneEditGroupCallParticipant(memberCtx, req) + if err != nil { + t.Fatalf("self mute: %v", err) + } + muteParts := findUpdate[*tg.UpdateGroupCallParticipants](t, editRes) + if muteParts.Version != 4 || !muteParts.Participants[0].Muted || !muteParts.Participants[0].CanSelfUnmute { + t.Fatalf("self mute participants = %+v", muteParts.Participants[0]) + } + f.sessions.reset() + + // --- leave --- + leaveRes, err := f.router.onPhoneLeaveGroupCall(memberCtx, &tg.PhoneLeaveGroupCallRequest{ + Call: &inputCall, Source: 500600700, + }) + if err != nil { + t.Fatalf("leave: %v", err) + } + leaveParts := findUpdate[*tg.UpdateGroupCallParticipants](t, leaveRes) + if leaveParts.Version != 5 || !leaveParts.Participants[0].Left { + t.Fatalf("leave participants = %+v", leaveParts.Participants[0]) + } + + // --- discard(owner)--- + f.sessions.reset() + f.clk.Advance(90 * time.Second) // 让 ended 服务消息有非零 duration + discardRes, err := f.router.onPhoneDiscardGroupCall(ownerCtx, &inputCall) + if err != nil { + t.Fatalf("discard: %v", err) + } + discardUpdate := findUpdate[*tg.UpdateGroupCall](t, discardRes) + if _, ok := discardUpdate.Call.(*tg.GroupCallDiscarded); !ok { + t.Fatalf("discard call = %T, want GroupCallDiscarded", discardUpdate.Call) + } + endedMsg := findUpdate[*tg.UpdateNewChannelMessage](t, discardRes) + endedAction := endedMsg.Message.(*tg.MessageService).Action.(*tg.MessageActionGroupCall) + if _, hasDuration := endedAction.GetDuration(); !hasDuration { + t.Fatalf("ended action must carry duration") + } + view, _ = f.router.deps.Channels.GetChannel(f.ctx, f.owner.ID, f.channel.ID) + if view.Channel.ActiveCallID != 0 || view.Channel.ActiveCallNotEmpty { + t.Fatalf("channel active call must clear after discard, got %+v", view.Channel) + } + // 重复 discard。 + if _, err := f.router.onPhoneDiscardGroupCall(ownerCtx, &inputCall); err == nil { + t.Fatalf("double discard must fail") + } +} + +func TestGroupCallM0Permissions(t *testing.T) { + f := newGroupCallFixture(t) + ownerCtx := f.userCtx(f.owner, 11) + memberCtx := f.userCtx(f.member, 22) + outsiderCtx := f.userCtx(f.outsider, 33) + + // 普通成员建会:CHAT_ADMIN_REQUIRED。 + _, err := f.router.onPhoneCreateGroupCall(memberCtx, &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + }) + assertPhoneRPCErr(t, err, "CHAT_ADMIN_REQUIRED") + + createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall) + inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash} + + // 重复建会。 + _, err = f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + }) + assertPhoneRPCErr(t, err, "GROUPCALL_ALREADY_STARTED") + + // 非群成员。 + _, err = f.router.onPhoneJoinGroupCall(outsiderCtx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 123), + }) + assertPhoneRPCErr(t, err, "GROUPCALL_FORBIDDEN") + + // access_hash 不符。 + _, err = f.router.onPhoneGetGroupCall(memberCtx, &tg.PhoneGetGroupCallRequest{ + Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash + 1}, Limit: 10, + }) + assertPhoneRPCErr(t, err, "GROUPCALL_INVALID") + + // ⚠ P1-6:slug 变体(conference 路径)须返回 GROUPCALL_INVALID 而非 panic。 + _, err = f.router.onPhoneGetGroupCall(memberCtx, &tg.PhoneGetGroupCallRequest{ + Call: &tg.InputGroupCallSlug{Slug: "x"}, Limit: 10, + }) + assertPhoneRPCErr(t, err, "GROUPCALL_INVALID") + + // ssrc 撞车:member 用 owner 的 ssrc。 + if _, err := f.router.onPhoneJoinGroupCall(ownerCtx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 777), + }); err != nil { + t.Fatalf("owner join: %v", err) + } + _, err = f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 777), + }) + assertPhoneRPCErr(t, err, "GROUPCALL_SSRC_DUPLICATE_MUCH") + + // 普通成员 mute 他人:M2 起为 per-viewer 本地覆盖(合法,详见 TestGroupCallM2MemberOverride)。 + muteOther := &tg.PhoneEditGroupCallParticipantRequest{ + Call: &inputCall, + Participant: &tg.InputPeerUser{UserID: f.owner.ID, AccessHash: f.owner.AccessHash}, + } + muteOther.SetMuted(true) + if _, err = f.router.onPhoneEditGroupCallParticipant(memberCtx, muteOther); err != nil { + t.Fatalf("member local-mute other: %v", err) + } + + // 普通成员结束通话 / 改标题。 + _, err = f.router.onPhoneDiscardGroupCall(memberCtx, &inputCall) + assertPhoneRPCErr(t, err, "CHAT_ADMIN_REQUIRED") + _, err = f.router.onPhoneEditGroupCallTitle(memberCtx, &tg.PhoneEditGroupCallTitleRequest{Call: &inputCall, Title: "x"}) + assertPhoneRPCErr(t, err, "CHAT_ADMIN_REQUIRED") +} + +func TestGroupCallSweeper(t *testing.T) { + f := newGroupCallFixture(t) + ownerCtx := f.userCtx(f.owner, 11) + + createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall) + inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash} + if _, err := f.router.onPhoneJoinGroupCall(ownerCtx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9001), + }); err != nil { + t.Fatalf("join: %v", err) + } + dispatcher := NewGroupCallSweepDispatcher(f.router, zaptest.NewLogger(t), 10*time.Second, 45*time.Second) + + // 30s:心跳未过期,不清。 + f.clk.Advance(30 * time.Second) + dispatcher.DispatchOnce(f.ctx) + if ssrcs, err := f.router.onPhoneCheckGroupCall(ownerCtx, &tg.PhoneCheckGroupCallRequest{Call: &inputCall, Sources: []int{9001}}); err != nil || len(ssrcs) != 1 { + t.Fatalf("participant swept too early: %v err=%v", ssrcs, err) + } + // checkGroupCall 刷新水位后再过 30s:仍不清(水位以最后心跳为准)。 + f.clk.Advance(30 * time.Second) + dispatcher.DispatchOnce(f.ctx) + if ssrcs, _ := f.router.onPhoneCheckGroupCall(ownerCtx, &tg.PhoneCheckGroupCallRequest{Call: &inputCall, Sources: []int{9001}}); len(ssrcs) != 1 { + t.Fatalf("heartbeat-refreshed participant must survive, got %v", ssrcs) + } + // 46s 无心跳:清掉;checkGroupCall 返回空集触发客户端 rejoin。 + f.clk.Advance(46 * time.Second) + dispatcher.DispatchOnce(f.ctx) + ssrcs, err := f.router.onPhoneCheckGroupCall(ownerCtx, &tg.PhoneCheckGroupCallRequest{Call: &inputCall, Sources: []int{9001}}) + if err != nil || len(ssrcs) != 0 { + t.Fatalf("stale participant must be swept, got %v err=%v", ssrcs, err) + } + // rejoin(换 ssrc)恢复。 + if _, err := f.router.onPhoneJoinGroupCall(ownerCtx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, 9002), + }); err != nil { + t.Fatalf("rejoin after sweep: %v", err) + } +} + +// 防御未来误删:fmt 占位避免 unused import(findUpdate 泛型在部分场景未实例化时)。 +var _ = fmt.Sprintf + +// TestGroupCallM2MuteMatrix 覆盖 editGroupCallParticipant 权限矩阵全表。 +func TestGroupCallM2MuteMatrix(t *testing.T) { + f := newGroupCallFixture(t) + ownerCtx := f.userCtx(f.owner, 11) + memberCtx := f.userCtx(f.member, 22) + + createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall) + inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash} + for i, u := range []struct { + ctx context.Context + ssrc int32 + }{{ownerCtx, 9001}, {memberCtx, 9002}} { + if _, err := f.router.onPhoneJoinGroupCall(u.ctx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, u.ssrc), + }); err != nil { + t.Fatalf("join %d: %v", i, err) + } + } + memberPeer := &tg.InputPeerUser{UserID: f.member.ID, AccessHash: f.member.AccessHash} + + // --- admin 禁言成员:muted=true 且 can_self_unmute=false --- + muteReq := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: memberPeer} + muteReq.SetMuted(true) + res, err := f.router.onPhoneEditGroupCallParticipant(ownerCtx, muteReq) + if err != nil { + t.Fatalf("admin mute: %v", err) + } + row := findUpdate[*tg.UpdateGroupCallParticipants](t, res).Participants[0] + if !row.Muted || row.CanSelfUnmute { + t.Fatalf("admin-muted row = %+v, want muted && !can_self_unmute", row) + } + + // --- 被禁言成员自行 unmute:forbidden(客户端此时只会发 raise_hand)--- + selfUnmute := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}} + selfUnmute.SetMuted(false) + _, err = f.router.onPhoneEditGroupCallParticipant(memberCtx, selfUnmute) + assertPhoneRPCErr(t, err, "GROUPCALL_FORBIDDEN") + + // --- 举手:rating 单调、versioned 推送 --- + raise := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}} + raise.SetRaiseHand(true) + res, err = f.router.onPhoneEditGroupCallParticipant(memberCtx, raise) + if err != nil { + t.Fatalf("raise hand: %v", err) + } + row = findUpdate[*tg.UpdateGroupCallParticipants](t, res).Participants[0] + rating, hasRating := row.GetRaiseHandRating() + if !hasRating || rating <= 0 { + t.Fatalf("raise hand row = %+v, want rating > 0", row) + } + + // --- admin 允许发言(muted=false):恢复 can_self_unmute、清举手、不替用户开麦 --- + allow := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: memberPeer} + allow.SetMuted(false) + res, err = f.router.onPhoneEditGroupCallParticipant(ownerCtx, allow) + if err != nil { + t.Fatalf("admin allow: %v", err) + } + row = findUpdate[*tg.UpdateGroupCallParticipants](t, res).Participants[0] + if !row.Muted || !row.CanSelfUnmute { + t.Fatalf("allow-to-speak row = %+v, want still muted && can_self_unmute", row) + } + if _, has := row.GetRaiseHandRating(); has { + t.Fatalf("allow-to-speak must clear raise hand, got %+v", row) + } + + // --- 现在成员可自行 unmute --- + if _, err := f.router.onPhoneEditGroupCallParticipant(memberCtx, selfUnmute); err != nil { + t.Fatalf("self unmute after allow: %v", err) + } + + // --- admin 设全局音量 --- + vol := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: memberPeer} + vol.SetVolume(5000) + res, err = f.router.onPhoneEditGroupCallParticipant(ownerCtx, vol) + if err != nil { + t.Fatalf("admin volume: %v", err) + } + row = findUpdate[*tg.UpdateGroupCallParticipants](t, res).Participants[0] + if v, has := row.GetVolume(); !has || v != 5000 || !row.VolumeByAdmin { + t.Fatalf("admin volume row = %+v", row) + } +} + +// TestGroupCallM2MemberOverride 覆盖普通成员的本地静音/音量(per-viewer overrides)。 +func TestGroupCallM2MemberOverride(t *testing.T) { + f := newGroupCallFixture(t) + ownerCtx := f.userCtx(f.owner, 11) + memberCtx := f.userCtx(f.member, 22) + + createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall) + inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash} + for _, u := range []struct { + ctx context.Context + ssrc int32 + }{{ownerCtx, 9001}, {memberCtx, 9002}} { + if _, err := f.router.onPhoneJoinGroupCall(u.ctx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, JoinAs: &tg.InputPeerSelf{}, Params: groupCallJoinParams(t, u.ssrc), + }); err != nil { + t.Fatalf("join: %v", err) + } + } + versionBefore := 0 + if c, found, _ := f.router.deps.GroupCalls.Get(f.ctx, call.ID); found { + versionBefore = c.Version + } + f.sessions.reset() + + // member 本地静音 owner。 + localMute := &tg.PhoneEditGroupCallParticipantRequest{ + Call: &inputCall, + Participant: &tg.InputPeerUser{UserID: f.owner.ID, AccessHash: f.owner.AccessHash}, + } + localMute.SetMuted(true) + res, err := f.router.onPhoneEditGroupCallParticipant(memberCtx, localMute) + if err != nil { + t.Fatalf("local mute: %v", err) + } + // ⚠ P2-7:推给 setter 的行必须带 min flag + muted_by_you,防 setter 其它设备 + // 用该行覆盖本地 muted/volume 状态。 + row := findUpdate[*tg.UpdateGroupCallParticipants](t, res).Participants[0] + if !row.Min || !row.MutedByYou { + t.Fatalf("override row = %+v, want min && muted_by_you", row) + } + // overrides 不推进全房间 version。 + if c, found, _ := f.router.deps.GroupCalls.Get(f.ctx, call.ID); !found || c.Version != versionBefore { + t.Fatalf("override must not bump version: %d → %d", versionBefore, c.Version) + } + // target 自身权威态不受影响。 + if p, found, _ := f.router.deps.GroupCalls.Participant(f.ctx, call.ID, f.owner.ID); !found || p.Muted { + t.Fatalf("target authoritative state polluted: %+v", p) + } + // 推送只发给 setter 自己。 + for _, rec := range f.sessions.records() { + if rec.userID != f.member.ID { + t.Fatalf("override pushed to %d, want setter only", rec.userID) + } + } +} + +// TestGroupCallM2Invite 覆盖 inviteToGroupCall 服务消息。 +func TestGroupCallM2Invite(t *testing.T) { + f := newGroupCallFixture(t) + ownerCtx := f.userCtx(f.owner, 11) + + createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall) + inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash} + + // 邀请群成员:产生带频道 pts 的 messageActionInviteToGroupCall。 + res, err := f.router.onPhoneInviteToGroupCall(ownerCtx, &tg.PhoneInviteToGroupCallRequest{ + Call: &inputCall, + Users: []tg.InputUserClass{&tg.InputUser{UserID: f.member.ID, AccessHash: f.member.AccessHash}}, + }) + if err != nil { + t.Fatalf("invite: %v", err) + } + msgUpdate := findUpdate[*tg.UpdateNewChannelMessage](t, res) + if msgUpdate.Pts == 0 { + t.Fatalf("invite service message must carry channel pts") + } + action, ok := msgUpdate.Message.(*tg.MessageService).Action.(*tg.MessageActionInviteToGroupCall) + if !ok { + t.Fatalf("invite action = %T", msgUpdate.Message.(*tg.MessageService).Action) + } + if len(action.Users) != 1 || action.Users[0] != f.member.ID { + t.Fatalf("invite action users = %v", action.Users) + } + + // 邀请非群成员。 + _, err = f.router.onPhoneInviteToGroupCall(ownerCtx, &tg.PhoneInviteToGroupCallRequest{ + Call: &inputCall, + Users: []tg.InputUserClass{&tg.InputUser{UserID: f.outsider.ID, AccessHash: f.outsider.AccessHash}}, + }) + assertPhoneRPCErr(t, err, "USER_NOT_PARTICIPANT") +} diff --git a/internal/rpc/phone_group_call_updates.go b/internal/rpc/phone_group_call_updates.go new file mode 100644 index 00000000..c58a2479 --- /dev/null +++ b/internal/rpc/phone_group_call_updates.go @@ -0,0 +1,109 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 群通话推送策略(updateGroupCall / updateGroupCallParticipants / connection 均无 +// pts,不进 getDifference):扇出对象统一为「在线群成员」(OnlineChannelMemberUserIDs, +// MaxChannelRealtimeFanout 封顶)——覆盖未入会的面板观察者(Android 点 banner 的 +// 参与者列表靠它增量刷新)。离线一致性三板斧:服务消息带频道 pts 可补收;banner 靠 +// channel.call_active/call_not_empty flag(拉 dialogs/getFullChannel 重建);房间内 +// 状态靠 checkGroupCall 与 version 跳号触发 getGroupParticipants 全量 reload 自愈。 + +// groupCallOnlineRecipients 返回在线群成员(含 originUserID 本人,推送时由 +// pushUserMessage 的 ctx except 排除发起 session、保留其它设备)。 +func (r *Router) groupCallOnlineRecipients(channelID int64) []int64 { + provider, ok := r.deps.Sessions.(OnlineUserProvider) + if !ok { + return nil + } + return provider.OnlineChannelMemberUserIDs(channelID, domain.MaxChannelRealtimeFanout) +} + +// groupCallUpdateContainer 把单条 update 包进 viewer 视角的 Updates 容器。 +func (r *Router) groupCallUpdateContainer(ctx context.Context, viewerUserID int64, channel domain.Channel, update tg.UpdateClass, userIDs []int64) *tg.Updates { + chats := []tg.ChatClass{} + if channel.ID != 0 { + chats = append(chats, tgChannel(viewerUserID, channel, nil)) + } + return &tg.Updates{ + Updates: []tg.UpdateClass{update}, + Users: r.tgUsersForIDs(ctx, viewerUserID, userIDs), + Chats: chats, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +// pushGroupCallUpdate 把 updateGroupCall(call 行变化)推给在线群成员。 +func (r *Router) pushGroupCallUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall) { + recipients := r.groupCallOnlineRecipients(channel.ID) + for _, viewerID := range recipients { + update := &tg.UpdateGroupCall{Call: tgGroupCall(call, viewerID, false)} + update.SetPeer(&tg.PeerChannel{ChannelID: channel.ID}) + r.pushUserMessage(ctx, viewerID, "group call update", + r.groupCallUpdateContainer(ctx, viewerID, channel, update, []int64{call.CreatorUserID})) + } +} + +// pushGroupCallParticipantsUpdate 把参与者增量(version=N)推给在线群成员。 +// 每个 viewer 单独构建:participant.Self flag 是 per-viewer 的。 +func (r *Router) pushGroupCallParticipantsUpdate(ctx context.Context, channel domain.Channel, call domain.GroupCall, rows []domain.GroupCallParticipant) { + if len(rows) == 0 { + return + } + userIDs := make([]int64, 0, len(rows)) + for _, p := range rows { + userIDs = append(userIDs, p.UserID) + } + recipients := r.groupCallOnlineRecipients(channel.ID) + for _, viewerID := range recipients { + update := &tg.UpdateGroupCallParticipants{ + Call: &tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash}, + Participants: tgGroupCallParticipants(rows, viewerID), + Version: call.Version, + } + r.pushUserMessage(ctx, viewerID, "group call participants", + r.groupCallUpdateContainer(ctx, viewerID, channel, update, userIDs)) + } +} + +// pushGroupCallServiceMessage 把 started/ended/invite 服务消息(带频道 pts)推给 +// 活跃成员(res.Recipients)。复用 channelOperationUpdates 的 per-viewer 构建。 +func (r *Router) pushGroupCallServiceMessage(ctx context.Context, originUserID int64, res domain.SendChannelMessageResult) { + if res.Event.Pts == 0 { + return + } + op := domain.CreateChannelResult{ + Channel: res.Channel, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + } + r.pushChannelUpdates(ctx, originUserID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { + return r.channelOperationUpdates(ctx, viewerUserID, op) + }) +} + +// groupCallMutationFanout 是参与者维度变更后的统一扇出:participants 增量 + +// call_not_empty 翻转时的 channel 维度刷新(Android banner 对 flag 依赖更重)。 +func (r *Router) groupCallMutationFanout(ctx context.Context, channel domain.Channel, mut domain.GroupCallMutation) domain.Channel { + r.pushGroupCallParticipantsUpdate(ctx, channel, mut.Call, []domain.GroupCallParticipant{mut.Participant}) + wantNotEmpty := mut.Call.Active() && mut.Call.ParticipantsCount > 0 + if channel.ActiveCallNotEmpty != wantNotEmpty && r.deps.Channels != nil { + updated, err := r.deps.Channels.SetActiveCall(ctx, channel.ID, channel.ActiveCallID, channel.ActiveCallAccessHash, wantNotEmpty) + if err == nil { + channel = updated + r.pushGroupCallUpdate(ctx, channel, mut.Call) + r.pushChannelStateToMembers(ctx, 0, channel) + } + } else { + r.pushGroupCallUpdate(ctx, channel, mut.Call) + } + return channel +} diff --git a/internal/rpc/phone_group_call_video_rpc_test.go b/internal/rpc/phone_group_call_video_rpc_test.go new file mode 100644 index 00000000..ea2c6373 --- /dev/null +++ b/internal/rpc/phone_group_call_video_rpc_test.go @@ -0,0 +1,392 @@ +package rpc + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// groupCallVideoJoinParams 构造带视频 ssrc-groups 的上行 join JSON +// (ssrc/sources 全部按 int32 位重解释写出,与 tgcalls serialize 一致)。 +func groupCallVideoJoinParams(t *testing.T, audioSSRC int32, groups []map[string]any) tg.DataJSON { + t.Helper() + payload := map[string]any{ + "ssrc": audioSSRC, + "ufrag": "clientufrag", + "pwd": "clientpwd-clientpwd-1234", + "fingerprints": []map[string]any{ + {"hash": "sha-256", "fingerprint": "AA:BB:CC:DD", "setup": "passive"}, + }, + "ssrc-groups": groups, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal join payload: %v", err) + } + return tg.DataJSON{Data: string(data)} +} + +// simulcastGroups 是摄像头 3 层标准布局:SIM[s,s+2,s+4] + 每层 FID 对。 +func simulcastGroups(base int64) []map[string]any { + return []map[string]any{ + {"semantics": "SIM", "sources": []int64{base, base + 2, base + 4}}, + {"semantics": "FID", "sources": []int64{base, base + 1}}, + {"semantics": "FID", "sources": []int64{base + 2, base + 3}}, + {"semantics": "FID", "sources": []int64{base + 4, base + 5}}, + } +} + +func connectionVideoEndpoint(t *testing.T, conn *tg.UpdateGroupCallConnection) string { + t.Helper() + var params struct { + Video struct { + Endpoint string `json:"endpoint"` + PayloadTypes []map[string]any `json:"payload-types"` + RtpHdrexts []struct { + ID int `json:"id"` + } `json:"rtp-hdrexts"` + } `json:"video"` + } + if err := json.Unmarshal([]byte(conn.Params.Data), ¶ms); err != nil { + t.Fatalf("parse connection params: %v", err) + } + if len(params.Video.PayloadTypes) < 4 { + t.Fatalf("video payload-types = %v, want VP8/rtx/VP9/rtx", params.Video.PayloadTypes) + } + ids := map[int]bool{} + for _, h := range params.Video.RtpHdrexts { + ids[h.ID] = true + } + // 客户端硬编码的扩展 id 表:1=audio-level、2=abs-send-time、3=twcc、13=video-orientation。 + for _, want := range []int{1, 2, 3, 13} { + if !ids[want] { + t.Fatalf("rtp-hdrexts missing id=%d: %v", want, params.Video.RtpHdrexts) + } + } + return params.Video.Endpoint +} + +func participantRow(t *testing.T, updates tg.UpdatesClass) tg.GroupCallParticipant { + t.Helper() + parts := findUpdate[*tg.UpdateGroupCallParticipants](t, updates) + if len(parts.Participants) != 1 { + t.Fatalf("participants = %d, want 1", len(parts.Participants)) + } + return parts.Participants[0] +} + +func TestGroupCallVideoLifecycle(t *testing.T) { + f := newGroupCallFixture(t) + ownerCtx := f.userCtx(f.owner, 11) + + createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + RandomID: 1, + }) + if err != nil { + t.Fatalf("createGroupCall: %v", err) + } + call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall) + // ⚠ unmuted_video_limit 是非可选 int:0 会让两端视频/屏幕共享闸门 + //(activeVideoSendersCount >= limit)恒真,按钮直接报「不能共享」。 + if call.UnmutedVideoLimit <= 0 || !call.CanStartVideo { + t.Fatalf("call video gates = limit %d can_start_video %v, want positive limit + can_start_video", + call.UnmutedVideoLimit, call.CanStartVideo) + } + inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash} + f.sessions.reset() + + // join:带 simulcast 组 + video_stopped(摄像头未开)。 + audioSSRC := int32(-100200300) + videoBase := int64(audioSSRC) + 1 + joinReq := &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, + JoinAs: &tg.InputPeerSelf{}, + Params: groupCallVideoJoinParams(t, audioSSRC, simulcastGroups(videoBase)), + } + joinReq.SetVideoStopped(true) + joinRes, err := f.router.onPhoneJoinGroupCall(ownerCtx, joinReq) + if err != nil { + t.Fatalf("joinGroupCall: %v", err) + } + conn := findUpdate[*tg.UpdateGroupCallConnection](t, joinRes) + if conn.Presentation { + t.Fatalf("main join connection must not carry presentation flag") + } + wantEndpoint := fmt.Sprintf("audio-%d", uint32(audioSSRC)) + if got := connectionVideoEndpoint(t, conn); got != wantEndpoint { + t.Fatalf("join video.endpoint = %q, want %q", got, wantEndpoint) + } + row := participantRow(t, joinRes) + if !row.VideoJoined { + t.Fatalf("self row must carry video_joined(缺它 TDesktop 强制关本端摄像头)") + } + if _, has := row.GetVideo(); has { + t.Fatalf("video_stopped join must not expose video field") + } + f.sessions.reset() + + // 开摄像头:editGroupCallParticipant(video_stopped=false) → video 字段出现, + // endpoint 与 join 响应一致、source_groups 原样回放。 + editReq := &tg.PhoneEditGroupCallParticipantRequest{ + Call: &inputCall, + Participant: &tg.InputPeerSelf{}, + } + editReq.SetVideoStopped(false) + editRes, err := f.router.onPhoneEditGroupCallParticipant(ownerCtx, editReq) + if err != nil { + t.Fatalf("video_stopped=false: %v", err) + } + row = participantRow(t, editRes) + video, has := row.GetVideo() + if !has { + t.Fatalf("video field missing after camera on") + } + if video.Endpoint != wantEndpoint { + t.Fatalf("participant video.endpoint = %q, want %q(必须与 join 响应逐字节一致)", video.Endpoint, wantEndpoint) + } + if len(video.SourceGroups) != 4 || video.SourceGroups[0].Semantics != "SIM" { + t.Fatalf("source_groups = %+v, want SIM+3×FID 原样回放", video.SourceGroups) + } + if got := video.SourceGroups[0].Sources[0]; int64(int32(got)) != int64(int32(videoBase)) { + t.Fatalf("SIM[0] = %d, want %d", got, videoBase) + } + + // 幂等重放(video 维度绝不报错)。 + editAgain := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}} + editAgain.SetVideoStopped(false) + if _, err := f.router.onPhoneEditGroupCallParticipant(ownerCtx, editAgain); err != nil { + t.Fatalf("redundant video edit must succeed, got %v(错误会触发客户端 rejoin 风暴)", err) + } + + // 暂停:字段保留 + paused。 + pauseReq := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}} + pauseReq.SetVideoPaused(true) + pauseRes, err := f.router.onPhoneEditGroupCallParticipant(ownerCtx, pauseReq) + if err != nil { + t.Fatalf("video_paused: %v", err) + } + pausedRow := participantRow(t, pauseRes) + video, has = pausedRow.GetVideo() + if !has || !video.Paused { + t.Fatalf("paused video = %+v has=%v, want paused field kept", video, has) + } + + // 关摄像头:字段消失。 + stopReq := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}} + stopReq.SetVideoStopped(true) + stopRes, err := f.router.onPhoneEditGroupCallParticipant(ownerCtx, stopReq) + if err != nil { + t.Fatalf("video_stopped=true: %v", err) + } + stopRow := participantRow(t, stopRes) + if _, has := stopRow.GetVideo(); has { + t.Fatalf("video field must disappear after camera off") + } +} + +func TestGroupCallPresentationLifecycle(t *testing.T) { + f := newGroupCallFixture(t) + ownerCtx := f.userCtx(f.owner, 11) + memberCtx := f.userCtx(f.member, 22) + + createRes, err := f.router.onPhoneCreateGroupCall(ownerCtx, &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + RandomID: 1, + }) + if err != nil { + t.Fatalf("createGroupCall: %v", err) + } + call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall) + inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash} + + // 未入会先共享:GROUPCALL_JOIN_MISSING。 + if _, err := f.router.onPhoneJoinGroupCallPresentation(memberCtx, &tg.PhoneJoinGroupCallPresentationRequest{ + Call: &inputCall, + Params: groupCallVideoJoinParams(t, 7777, simulcastGroups(7778)), + }); err == nil { + t.Fatalf("presentation before main join must fail") + } + + // 主 join。 + const mainSSRC = int32(424242) + if _, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, + JoinAs: &tg.InputPeerSelf{}, + Params: groupCallVideoJoinParams(t, mainSSRC, simulcastGroups(int64(mainSSRC)+1)), + }); err != nil { + t.Fatalf("main join: %v", err) + } + f.sessions.reset() + + // 屏幕共享 join:独立 ssrc、2 层布局。 + const screenSSRC = int32(515151) + screenGroups := []map[string]any{ + {"semantics": "SIM", "sources": []int64{int64(screenSSRC) + 1, int64(screenSSRC) + 3}}, + {"semantics": "FID", "sources": []int64{int64(screenSSRC) + 1, int64(screenSSRC) + 2}}, + {"semantics": "FID", "sources": []int64{int64(screenSSRC) + 3, int64(screenSSRC) + 4}}, + } + presRes, err := f.router.onPhoneJoinGroupCallPresentation(memberCtx, &tg.PhoneJoinGroupCallPresentationRequest{ + Call: &inputCall, + Params: groupCallVideoJoinParams(t, screenSSRC, screenGroups), + }) + if err != nil { + t.Fatalf("joinGroupCallPresentation: %v", err) + } + conn := findUpdate[*tg.UpdateGroupCallConnection](t, presRes) + if !conn.Presentation { + t.Fatalf("presentation connection must carry presentation flag(否则被主实例误食)") + } + wantEndpoint := fmt.Sprintf("presentation-%d", uint32(screenSSRC)) + if got := connectionVideoEndpoint(t, conn); got != wantEndpoint { + t.Fatalf("presentation video.endpoint = %q, want %q", got, wantEndpoint) + } + row := participantRow(t, presRes) + pres, has := row.GetPresentation() + if !has { + t.Fatalf("participant presentation field missing") + } + if pres.Endpoint != wantEndpoint { + t.Fatalf("presentation.endpoint = %q, want %q", pres.Endpoint, wantEndpoint) + } + audioSource, hasAudio := pres.GetAudioSource() + if !hasAudio || audioSource != int(screenSSRC) { + t.Fatalf("presentation.audio_source = %d has=%v, want %d(DrKLO 缺它会 4s 循环重建)", audioSource, hasAudio, screenSSRC) + } + if len(pres.SourceGroups) != 3 { + t.Fatalf("presentation source_groups = %+v", pres.SourceGroups) + } + + // checkGroupCall 必须把 presentation 的全部 ssrc 认作活跃。 + sources := []int{int(mainSSRC), int(screenSSRC), int(screenSSRC) + 1, int(screenSSRC) + 3} + alive, err := f.router.onPhoneCheckGroupCall(memberCtx, &tg.PhoneCheckGroupCallRequest{ + Call: &inputCall, + Sources: sources, + }) + if err != nil { + t.Fatalf("checkGroupCall: %v", err) + } + if len(alive) != len(sources) { + t.Fatalf("checkGroupCall alive = %v, want all of %v(缺失会触发屏幕实例循环重建)", alive, sources) + } + + // presentation_paused。 + pauseReq := &tg.PhoneEditGroupCallParticipantRequest{Call: &inputCall, Participant: &tg.InputPeerSelf{}} + pauseReq.SetPresentationPaused(true) + pauseRes, err := f.router.onPhoneEditGroupCallParticipant(memberCtx, pauseReq) + if err != nil { + t.Fatalf("presentation_paused: %v", err) + } + pausedRow := participantRow(t, pauseRes) + pres, _ = pausedRow.GetPresentation() + if !pres.Paused { + t.Fatalf("presentation must be paused") + } + + // leaveGroupCallPresentation:字段消失。 + leaveRes, err := f.router.onPhoneLeaveGroupCallPresentation(memberCtx, &inputCall) + if err != nil { + t.Fatalf("leaveGroupCallPresentation: %v", err) + } + leaveRow := participantRow(t, leaveRes) + if _, has := leaveRow.GetPresentation(); has { + t.Fatalf("presentation field must disappear after leave") + } + // 重复 leave 幂等。 + if _, err := f.router.onPhoneLeaveGroupCallPresentation(memberCtx, &inputCall); err != nil { + t.Fatalf("double leave presentation: %v", err) + } + + // rejoin 主连接 ⇒ 旧 presentation 作废(客户端会重发 joinGroupCallPresentation)。 + if _, err := f.router.onPhoneJoinGroupCallPresentation(memberCtx, &tg.PhoneJoinGroupCallPresentationRequest{ + Call: &inputCall, + Params: groupCallVideoJoinParams(t, screenSSRC, screenGroups), + }); err != nil { + t.Fatalf("re-share: %v", err) + } + rejoinRes, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, + JoinAs: &tg.InputPeerSelf{}, + Params: groupCallVideoJoinParams(t, mainSSRC+1, simulcastGroups(int64(mainSSRC)+100)), + }) + if err != nil { + t.Fatalf("main rejoin: %v", err) + } + rejoinRow := participantRow(t, rejoinRes) + if _, has := rejoinRow.GetPresentation(); has { + t.Fatalf("main rejoin must invalidate old presentation registration") + } +} + +// raceLeavePresentationStore 包一层 GroupCallsService,把 UpdateParticipant 强制返回 +// 注入的错误,用来模拟 leaveGroupCallPresentation 读到 presentation 仍在、但写阶段与 +// 并发 leaveGroupCall/discard 撞车的竞态(其它方法照常委派,Participant 仍返回真实行)。 +type raceLeavePresentationStore struct { + GroupCallsService + updateErr error +} + +func (s *raceLeavePresentationStore) UpdateParticipant(ctx context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) { + if s.updateErr != nil { + return domain.GroupCallMutation{}, false, s.updateErr + } + return s.GroupCallsService.UpdateParticipant(ctx, callID, userID, update) +} + +// TestLeaveGroupCallPresentationIdempotentOnConcurrentLeave 回归:屏幕共享结束与整体离会 +// 同时发生时,leaveGroupCallPresentation 在写阶段撞 ErrGroupCallNotJoined(participant 已被 +// 并发 leave 置 Left,presentation 随主连接离会级联清掉)或 ErrGroupCallDiscarded(主叫同时 +// discard),必须按幂等成功返回而非 GROUPCALL_JOIN_MISSING 400(真双机 16:48 撞到过)。 +func TestLeaveGroupCallPresentationIdempotentOnConcurrentLeave(t *testing.T) { + f := newGroupCallFixture(t) + memberCtx := f.userCtx(f.member, 22) + + createRes, err := f.router.onPhoneCreateGroupCall(f.userCtx(f.owner, 11), &tg.PhoneCreateGroupCallRequest{ + Peer: &tg.InputPeerChannel{ChannelID: f.channel.ID, AccessHash: f.channel.AccessHash}, + RandomID: 1, + }) + if err != nil { + t.Fatalf("createGroupCall: %v", err) + } + call := findUpdate[*tg.UpdateGroupCall](t, createRes).Call.(*tg.GroupCall) + inputCall := tg.InputGroupCall{ID: call.ID, AccessHash: call.AccessHash} + + const mainSSRC = int32(424242) + if _, err := f.router.onPhoneJoinGroupCall(memberCtx, &tg.PhoneJoinGroupCallRequest{ + Call: &inputCall, + JoinAs: &tg.InputPeerSelf{}, + Params: groupCallVideoJoinParams(t, mainSSRC, simulcastGroups(int64(mainSSRC)+1)), + }); err != nil { + t.Fatalf("main join: %v", err) + } + const screenSSRC = int32(515151) + if _, err := f.router.onPhoneJoinGroupCallPresentation(memberCtx, &tg.PhoneJoinGroupCallPresentationRequest{ + Call: &inputCall, + Params: groupCallVideoJoinParams(t, screenSSRC, simulcastGroups(int64(screenSSRC)+1)), + }); err != nil { + t.Fatalf("joinGroupCallPresentation: %v", err) + } + + // 注入竞态:Participant() 仍返回带 presentation 的真实行(过早期幂等检查), + // UpdateParticipant() 撞错误(模拟并发 leave 已置 Left / 并发 discard)。 + race := &raceLeavePresentationStore{GroupCallsService: f.router.deps.GroupCalls} + f.router.deps.GroupCalls = race + + for _, injected := range []error{domain.ErrGroupCallNotJoined, domain.ErrGroupCallDiscarded} { + race.updateErr = injected + if _, err := f.router.onPhoneLeaveGroupCallPresentation(memberCtx, &inputCall); err != nil { + t.Fatalf("leaveGroupCallPresentation 撞并发 %v 必须幂等成功,got %v(修复前=GROUPCALL_JOIN_MISSING 400)", injected, err) + } + } + + // 反向保险:非竞态类错误仍须如实抛出(不被幂等吞掉)。 + race.updateErr = domain.ErrGroupCallInvalid + if _, err := f.router.onPhoneLeaveGroupCallPresentation(memberCtx, &inputCall); err == nil { + t.Fatalf("非竞态错误必须传播,却被幂等吞掉") + } +} diff --git a/internal/rpc/phone_history.go b/internal/rpc/phone_history.go new file mode 100644 index 00000000..1cb5e6fd --- /dev/null +++ b/internal/rpc/phone_history.go @@ -0,0 +1,67 @@ +package rpc + +import ( + "context" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// sendPhoneCallServiceMessage 把已终结的通话落成 messageActionPhoneCall 历史 +// (完整复刻 pin 服务消息模式,messages_pin.go)。要点: +// - sender 恒为主叫 AdminID——无论谁发起 discard(官方语义:主叫侧渲染 +// 「呼出」、被叫侧渲染「未接来电/通话 X 分钟」,missed 在被叫侧计未读); +// - Origin 置零:双方全部设备(含 discard 发起设备)都经 outbox 收到 +// updateNewMessage(带 pts)——发起设备的 RPC 响应里只有 updatePhoneCall, +// 服务消息必须从 update 流来;离线设备重连经 getDifference 补收,正好 +// 补偿信令直推的不可靠(「未接来电」由此而来); +// - RandomID 由 callID 确定性派生:状态机保证一通通话只落一次历史, +// 极端重放也会被发送管道的 random_id 去重吸收; +// - 失败仅 Warn 不回滚:通话状态是真值,历史是装饰(与 pin 同哲学)。 +// +// 注意:本路径绕过 RPC 层的 checkSendRateLimit(限速挂在 handler 侧)—— +// 这是有意为之,呼叫轰炸防护由通话并发上限与振铃超时承担。 +func (r *Router) sendPhoneCallServiceMessage(ctx context.Context, call domain.PhoneCall) { + if r.deps.Messages == nil || !call.Terminal() { + return + } + recipientBlocked, err := r.peerBlocksUser(ctx, call.AdminID, call.ParticipantID) + if err != nil { + recipientBlocked = false + } + _, err = r.deps.Messages.SendPrivateText(ctx, call.AdminID, domain.SendPrivateTextRequest{ + SenderUserID: call.AdminID, + RecipientUserID: call.ParticipantID, + RandomID: phoneCallServiceMessageRandomID(call.ID), + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionPhoneCall, + Call: &domain.MessagePhoneCallAction{ + CallID: call.ID, + Reason: string(call.DiscardReason), + Duration: call.Duration, + Video: call.Video, + }, + }, + }, + Date: int(r.clock.Now().Unix()), + RecipientBlocked: recipientBlocked, + }) + if err != nil { + r.log.Warn("phone call service message", + zap.Int64("call_id", call.ID), + zap.Int64("admin_id", call.AdminID), + zap.Int64("participant_id", call.ParticipantID), + zap.Error(err)) + } +} + +func phoneCallServiceMessageRandomID(callID int64) int64 { + id := callID ^ 0x70686f6e6563616c // "phonecal" + if id == 0 { + return 1 + } + return id +} diff --git a/internal/rpc/phone_push.go b/internal/rpc/phone_push.go new file mode 100644 index 00000000..92d9a0ce --- /dev/null +++ b/internal/rpc/phone_push.go @@ -0,0 +1,74 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// 通话信令推送全部走无 pts 直推(pushUserMessage):信令有效期秒级,离线设备 +// 重连后经 getDifference 补收一条早已失效的来电毫无意义甚至有害。唯一带 pts 的 +// 产物是结束后的 messageActionPhoneCall 服务消息(P2,走 outbox 补偿离线设备)。 + +// phoneCallUpdates 把 viewer 视角的 phoneCall 状态包成可直推的 Updates 容器。 +func (r *Router) phoneCallUpdates(ctx context.Context, call domain.PhoneCall, viewerID int64) *tg.Updates { + return r.phoneCallUpdatesWith(ctx, tgPhoneCallForViewer(call, viewerID), call, viewerID) +} + +func (r *Router) phoneCallUpdatesWith(ctx context.Context, view tg.PhoneCallClass, call domain.PhoneCall, viewerID int64) *tg.Updates { + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePhoneCall{PhoneCall: view}}, + Users: r.tgUsersForIDs(ctx, viewerID, []int64{call.AdminID, call.ParticipantID}), + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } +} + +// pushPhoneCall 把 call 当前状态按 targetUserID 视角推给其全部在线设备 +// (ctx 携带的发起设备会被 pushUserMessage 的 except 语义排除)。 +func (r *Router) pushPhoneCall(ctx context.Context, targetUserID int64, call domain.PhoneCall, logMessage string) int { + return r.pushUserMessage(ctx, targetUserID, logMessage, r.phoneCallUpdates(ctx, call, targetUserID)) +} + +// pushPhoneCallStopRinging 向被叫其它设备推合成 phoneCallDiscarded 停振铃(P0-1 修正)。 +// ctx 必须是接听设备的请求上下文:except 语义恰好把赢家排除在外。 +func (r *Router) pushPhoneCallStopRinging(ctx context.Context, call domain.PhoneCall) int { + upd := r.phoneCallUpdatesWith(ctx, tgPhoneCallStopRinging(call), call, call.ParticipantID) + return r.pushUserMessage(ctx, call.ParticipantID, "phone call stop ringing", upd) +} + +// pushPhoneCallDiscardedBoth 把终态推给双方全部设备(发起设备由 ctx except 排除, +// 其结果从 RPC 响应获得)。 +func (r *Router) pushPhoneCallDiscardedBoth(ctx context.Context, call domain.PhoneCall) { + r.pushPhoneCall(ctx, call.AdminID, call, "phone call discarded") + r.pushPhoneCall(ctx, call.ParticipantID, call, "phone call discarded") +} + +// pushPhoneSignalingData 把信令字节透传给对端。优先走设备锚点定向推送 +// (requestCall/acceptCall 受理设备),锚点失效(设备重连换 session)则回退 +// user 级扇出——非参与设备按 phone_call_id 不匹配静默丢弃(TDesktop +// handleSignalingData 行为),扇出无害。 +func (r *Router) pushPhoneSignalingData(ctx context.Context, targetUserID int64, device domain.SessionRef, callID int64, data []byte) { + upd := &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdatePhoneCallSignalingData{ + PhoneCallID: callID, + Data: data, + }}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: int(r.clock.Now().Unix()), + Seq: 0, + } + if !device.Zero() { + if scoped, ok := r.scopedSessions(); ok { + if err := scoped.PushToSessionForAuthKey(ctx, device.RawAuthKeyID, device.SessionID, proto.MessageFromServer, upd); err == nil { + return + } + } + } + r.pushUserMessage(ctx, targetUserID, "phone call signaling", upd) +} diff --git a/internal/rpc/phone_register.go b/internal/rpc/phone_register.go new file mode 100644 index 00000000..78f837bd --- /dev/null +++ b/internal/rpc/phone_register.go @@ -0,0 +1,47 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" +) + +// registerPhone 注册通话域 RPC。 +// +// 归属约定(跨任务协调,群聊 M0 落地时遵守):本文件是 phone.* 的唯一注册点; +// gotd ServerDispatcher 对同一 RPC 重复 On* 注册是静默 last-wins,群聊 stub +// 清单不得覆盖此处已注册的真实现。messages.getDhConfig 属通话域(DH 参数下发), +// 注册在这里而非 messages_register.go。 +func (r *Router) registerPhone(d *tg.ServerDispatcher) { + d.OnMessagesGetDhConfig(r.onMessagesGetDhConfig) + + d.OnPhoneRequestCall(r.onPhoneRequestCall) + d.OnPhoneReceivedCall(r.onPhoneReceivedCall) + d.OnPhoneAcceptCall(r.onPhoneAcceptCall) + d.OnPhoneConfirmCall(r.onPhoneConfirmCall) + d.OnPhoneDiscardCall(r.onPhoneDiscardCall) + d.OnPhoneSendSignalingData(r.onPhoneSendSignalingData) + d.OnPhoneSetCallRating(r.onPhoneSetCallRating) + d.OnPhoneSaveCallDebug(r.onPhoneSaveCallDebug) + d.OnPhoneGetCallConfig(func(ctx context.Context) (*tg.DataJSON, error) { + // tgcalls 对空配置走默认值;需要精调(audio_max_bitrate 等)时再填键值。 + return &tg.DataJSON{Data: "{}"}, nil + }) + + // 超级群语音聊天(group call)。 + d.OnPhoneCreateGroupCall(r.onPhoneCreateGroupCall) + d.OnPhoneJoinGroupCall(r.onPhoneJoinGroupCall) + d.OnPhoneLeaveGroupCall(r.onPhoneLeaveGroupCall) + d.OnPhoneDiscardGroupCall(r.onPhoneDiscardGroupCall) + d.OnPhoneGetGroupCall(r.onPhoneGetGroupCall) + d.OnPhoneGetGroupParticipants(r.onPhoneGetGroupParticipants) + d.OnPhoneCheckGroupCall(r.onPhoneCheckGroupCall) + d.OnPhoneEditGroupCallParticipant(r.onPhoneEditGroupCallParticipant) + d.OnPhoneEditGroupCallTitle(r.onPhoneEditGroupCallTitle) + d.OnPhoneToggleGroupCallSettings(r.onPhoneToggleGroupCallSettings) + d.OnPhoneInviteToGroupCall(r.onPhoneInviteToGroupCall) + // 屏幕共享(M4):同参与者第二媒体连接。 + d.OnPhoneJoinGroupCallPresentation(r.onPhoneJoinGroupCallPresentation) + d.OnPhoneLeaveGroupCallPresentation(r.onPhoneLeaveGroupCallPresentation) + r.registerPhoneStubs(d) +} diff --git a/internal/rpc/phone_rpc_test.go b/internal/rpc/phone_rpc_test.go new file mode 100644 index 00000000..34f96569 --- /dev/null +++ b/internal/rpc/phone_rpc_test.go @@ -0,0 +1,785 @@ +package rpc + +import ( + "context" + "crypto/sha256" + "errors" + "sync" + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + appmessages "telesrv/internal/app/messages" + appphone "telesrv/internal/app/phone" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// phonePushRecord 记录一次定向推送(目标用户、被排除的 session、载荷)。 +type phonePushRecord struct { + userID int64 + excludeSession int64 + msg bin.Encoder +} + +// phoneCaptureSessions 是带完整推送日志的 SessionBinder fake(captureSessions 只留最后一条)。 +type phoneCaptureSessions struct { + mu sync.Mutex + log []phonePushRecord +} + +func (s *phoneCaptureSessions) BindAuthKey(int64, [8]byte) {} +func (s *phoneCaptureSessions) AuthKeyID(int64) ([8]byte, bool) { return [8]byte{}, false } +func (s *phoneCaptureSessions) BindUser(int64, int64) {} +func (s *phoneCaptureSessions) UserID(int64) (int64, bool) { return 0, false } +func (s *phoneCaptureSessions) UserIDResolved(int64) (int64, bool) { return 0, false } +func (s *phoneCaptureSessions) UnbindAuthKey([8]byte) int { return 0 } +func (s *phoneCaptureSessions) SetReceivesUpdates(int64, bool) {} +func (s *phoneCaptureSessions) PushToSession(context.Context, int64, proto.MessageType, bin.Encoder) error { + return nil +} + +func (s *phoneCaptureSessions) PushToUserExceptSession(_ context.Context, userID, excludeSessionID int64, _ proto.MessageType, msg bin.Encoder) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.log = append(s.log, phonePushRecord{userID: userID, excludeSession: excludeSessionID, msg: msg}) + return 1, nil +} + +func (s *phoneCaptureSessions) records() []phonePushRecord { + s.mu.Lock() + defer s.mu.Unlock() + return append([]phonePushRecord(nil), s.log...) +} + +func (s *phoneCaptureSessions) reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.log = nil +} + +// stubPrivacy 只为 CanSee 服务;其余接口方法不在通话链路使用。 +type stubPrivacy struct { + deny map[domain.PrivacyKey]bool +} + +func (p stubPrivacy) GetRules(context.Context, int64, domain.PrivacyKey) (domain.PrivacyRules, error) { + return domain.PrivacyRules{}, nil +} + +func (p stubPrivacy) SetRules(context.Context, int64, domain.PrivacyKey, []domain.PrivacyRule) (domain.PrivacyRules, error) { + return domain.PrivacyRules{}, nil +} + +func (p stubPrivacy) AddAllowUser(context.Context, int64, domain.PrivacyKey, int64) (domain.PrivacyRules, bool, error) { + return domain.PrivacyRules{}, false, nil +} + +func (p stubPrivacy) CanSee(_ context.Context, _, _ int64, key domain.PrivacyKey) (bool, error) { + return !p.deny[key], nil +} + +type phoneFixture struct { + t *testing.T + ctx context.Context + router *Router + sessions *phoneCaptureSessions + caller domain.User + callee domain.User +} + +const ( + phoneCallerSession = int64(101) + phoneCalleeSession = int64(202) +) + +func newPhoneFixture(t *testing.T, privacy PrivacyService) *phoneFixture { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + sessions := &phoneCaptureSessions{} + router := New(Config{CallSignalingMaxBytes: 1024}, Deps{ + Users: appusers.NewService(userStore), + Privacy: privacy, + Phone: appphone.NewService(appphone.Config{}), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + f := &phoneFixture{t: t, ctx: ctx, router: router, sessions: sessions} + mkUser := func(hash int64, phone, name string) domain.User { + u, err := userStore.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name}) + if err != nil { + t.Fatalf("create user %s: %v", name, err) + } + return u + } + f.caller = mkUser(1001, "13800000001", "Caller") + f.callee = mkUser(1002, "13800000002", "Callee") + return f +} + +func (f *phoneFixture) callerCtx() context.Context { + return WithSessionID(WithUserID(f.ctx, f.caller.ID), phoneCallerSession) +} + +func (f *phoneFixture) calleeCtx() context.Context { + return WithSessionID(WithUserID(f.ctx, f.callee.ID), phoneCalleeSession) +} + +func phoneTestProtocol() tg.PhoneCallProtocol { + p := tg.PhoneCallProtocol{MinLayer: 65, MaxLayer: 92, LibraryVersions: []string{"11.0.0", "9.0.0"}} + p.SetUDPP2P(true) + p.SetUDPReflector(true) + return p +} + +func phoneTestKeys() (ga, gaHash, gb []byte) { + ga = make([]byte, 256) + for i := range ga { + ga[i] = byte(i + 3) + } + h := sha256.Sum256(ga) + gb = make([]byte, 256) + for i := range gb { + gb[i] = byte(200 - i%150) + } + return ga, h[:], gb +} + +func assertPhoneRPCErr(t *testing.T, err error, wantType string) { + t.Helper() + var rpcErr *tgerr.Error + if !errors.As(err, &rpcErr) || rpcErr.Type != wantType { + t.Fatalf("err = %v, want rpc error %s", err, wantType) + } +} + +// phoneCallPayload 从捕获的推送里取出 updatePhoneCall 的载荷。 +func phoneCallPayload(t *testing.T, rec phonePushRecord) tg.PhoneCallClass { + t.Helper() + updates, ok := rec.msg.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("pushed msg = %T %+v, want single-update tg.Updates", rec.msg, rec.msg) + } + upd, ok := updates.Updates[0].(*tg.UpdatePhoneCall) + if !ok { + t.Fatalf("pushed update = %T, want UpdatePhoneCall", updates.Updates[0]) + } + return upd.PhoneCall +} + +func TestPhoneCallRPCHappyPath(t *testing.T) { + f := newPhoneFixture(t, stubPrivacy{}) + ga, gaHash, gb := phoneTestKeys() + + // --- requestCall(主叫) --- + res, err := f.router.onPhoneRequestCall(f.callerCtx(), &tg.PhoneRequestCallRequest{ + UserID: &tg.InputUser{UserID: f.callee.ID, AccessHash: f.callee.AccessHash}, + RandomID: 42, + GAHash: gaHash, + Protocol: phoneTestProtocol(), + }) + if err != nil { + t.Fatalf("requestCall: %v", err) + } + waiting, ok := res.PhoneCall.(*tg.PhoneCallWaiting) + if !ok { + t.Fatalf("requestCall result = %T, want PhoneCallWaiting", res.PhoneCall) + } + if waiting.AdminID != f.caller.ID || waiting.ParticipantID != f.callee.ID { + t.Fatalf("waiting identities = %+v", waiting) + } + if len(res.Users) != 2 { + t.Fatalf("requestCall users = %d, want 2", len(res.Users)) + } + callID, accessHash := waiting.ID, waiting.AccessHash + + pushes := f.sessions.records() + if len(pushes) != 1 || pushes[0].userID != f.callee.ID { + t.Fatalf("requestCall pushes = %+v, want one to callee", pushes) + } + requested, ok := phoneCallPayload(t, pushes[0]).(*tg.PhoneCallRequested) + if !ok { + t.Fatalf("callee payload = %T, want PhoneCallRequested", phoneCallPayload(t, pushes[0])) + } + if string(requested.GAHash) != string(gaHash) { + t.Fatalf("g_a_hash must be relayed verbatim") + } + f.sessions.reset() + + // --- receivedCall(被叫上报触达)→ 主叫收 receive_date --- + if ok, err := f.router.onPhoneReceivedCall(f.calleeCtx(), tg.InputPhoneCall{ID: callID, AccessHash: accessHash}); err != nil || !ok { + t.Fatalf("receivedCall = %v err=%v", ok, err) + } + pushes = f.sessions.records() + if len(pushes) != 1 || pushes[0].userID != f.caller.ID { + t.Fatalf("receivedCall pushes = %+v, want one to caller", pushes) + } + ringing, ok := phoneCallPayload(t, pushes[0]).(*tg.PhoneCallWaiting) + if !ok || ringing.ReceiveDate == 0 { + // ⚠ P1-2:缺 receive_date 推送时主叫只有 20s 接听窗口。 + t.Fatalf("caller payload = %+v, want waiting with receive_date", phoneCallPayload(t, pushes[0])) + } + f.sessions.reset() + + // --- acceptCall(被叫赢家设备) --- + acceptRes, err := f.router.onPhoneAcceptCall(f.calleeCtx(), &tg.PhoneAcceptCallRequest{ + Peer: tg.InputPhoneCall{ID: callID, AccessHash: accessHash}, + GB: gb, + Protocol: phoneTestProtocol(), + }) + if err != nil { + t.Fatalf("acceptCall: %v", err) + } + if _, ok := acceptRes.PhoneCall.(*tg.PhoneCallWaiting); !ok { + t.Fatalf("acceptCall result = %T, want PhoneCallWaiting (callee view)", acceptRes.PhoneCall) + } + pushes = f.sessions.records() + if len(pushes) != 2 { + t.Fatalf("acceptCall pushes = %d, want 2 (caller accepted + callee stop-ringing)", len(pushes)) + } + accepted, ok := phoneCallPayload(t, pushes[0]).(*tg.PhoneCallAccepted) + if !ok || pushes[0].userID != f.caller.ID { + t.Fatalf("first accept push = %+v payload %T, want PhoneCallAccepted to caller", pushes[0], phoneCallPayload(t, pushes[0])) + } + if string(accepted.GB) != string(gb) { + t.Fatalf("accepted.g_b must be relayed verbatim") + } + // 协商优先 "9.0.0"(不是语义化最高):DrKLO 视频 gate 是字符串字典序比较, + // "1x.0.0" < "2.7.7" 会关掉 Android 视频;见 app/phone preferredVersions。 + if got := accepted.Protocol.LibraryVersions; len(got) != 1 || got[0] != "9.0.0" { + t.Fatalf("negotiated library_versions = %v, want preferred [9.0.0]", got) + } + // ⚠ P0-1:被叫其它设备必须收合成 phoneCallDiscarded(无 reason),绝不能是 accepted。 + stop, ok := phoneCallPayload(t, pushes[1]).(*tg.PhoneCallDiscarded) + if !ok || pushes[1].userID != f.callee.ID { + t.Fatalf("second accept push = %+v payload %T, want PhoneCallDiscarded to callee devices", pushes[1], phoneCallPayload(t, pushes[1])) + } + if pushes[1].excludeSession != phoneCalleeSession { + t.Fatalf("stop-ringing must exclude the accepting session, got exclude=%d", pushes[1].excludeSession) + } + if _, hasReason := stop.GetReason(); hasReason || stop.NeedRating || stop.NeedDebug { + t.Fatalf("stop-ringing payload = %+v, want reason-less, need_*=false", stop) + } + f.sessions.reset() + + // --- confirmCall(主叫揭示 g_a) --- + confirmRes, err := f.router.onPhoneConfirmCall(f.callerCtx(), &tg.PhoneConfirmCallRequest{ + Peer: tg.InputPhoneCall{ID: callID, AccessHash: accessHash}, + GA: ga, + KeyFingerprint: 0xCAFE, + Protocol: phoneTestProtocol(), + }) + if err != nil { + t.Fatalf("confirmCall: %v", err) + } + callerView, ok := confirmRes.PhoneCall.(*tg.PhoneCall) + if !ok || string(callerView.GAOrB) != string(gb) { + t.Fatalf("confirm result = %T g_a_or_b mismatch, 主叫视角必须是 g_b", confirmRes.PhoneCall) + } + if callerView.Connections == nil || len(callerView.Connections) != 0 { + t.Fatalf("P1 connections = %v, want empty non-nil", callerView.Connections) + } + if !callerView.P2PAllowed { + t.Fatalf("p2p_allowed must be true in P1") + } + pushes = f.sessions.records() + if len(pushes) != 1 || pushes[0].userID != f.callee.ID { + t.Fatalf("confirm pushes = %+v, want one to callee", pushes) + } + calleeView, ok := phoneCallPayload(t, pushes[0]).(*tg.PhoneCall) + if !ok || string(calleeView.GAOrB) != string(ga) || calleeView.KeyFingerprint != 0xCAFE { + t.Fatalf("callee confirm payload = %+v, 被叫视角必须是 g_a + fingerprint", calleeView) + } + f.sessions.reset() + + // --- sendSignalingData 双向透传 --- + okSig, err := f.router.onPhoneSendSignalingData(f.callerCtx(), &tg.PhoneSendSignalingDataRequest{ + Peer: tg.InputPhoneCall{ID: callID, AccessHash: accessHash}, + Data: []byte("sdp-offer"), + }) + if err != nil || !okSig { + t.Fatalf("sendSignalingData = %v err=%v", okSig, err) + } + pushes = f.sessions.records() + if len(pushes) != 1 || pushes[0].userID != f.callee.ID { + t.Fatalf("signaling pushes = %+v, want one to callee", pushes) + } + sigUpdates := pushes[0].msg.(*tg.Updates) + sig, ok := sigUpdates.Updates[0].(*tg.UpdatePhoneCallSignalingData) + if !ok || sig.PhoneCallID != callID || string(sig.Data) != "sdp-offer" { + t.Fatalf("signaling payload = %+v", sigUpdates.Updates[0]) + } + f.sessions.reset() + + // --- discardCall(主叫挂断,duration 在 Confirmed 后被采纳) --- + discardRes, err := f.router.onPhoneDiscardCall(f.callerCtx(), &tg.PhoneDiscardCallRequest{ + Peer: tg.InputPhoneCall{ID: callID, AccessHash: accessHash}, + Duration: 42, + Reason: &tg.PhoneCallDiscardReasonHangup{}, + }) + if err != nil { + t.Fatalf("discardCall: %v", err) + } + respUpdates, ok := discardRes.(*tg.Updates) + if !ok || len(respUpdates.Updates) != 1 { + t.Fatalf("discard result = %T %+v", discardRes, discardRes) + } + discarded, ok := respUpdates.Updates[0].(*tg.UpdatePhoneCall).PhoneCall.(*tg.PhoneCallDiscarded) + if !ok { + t.Fatalf("discard payload = %T", respUpdates.Updates[0]) + } + if d, _ := discarded.GetDuration(); d != 42 { + t.Fatalf("discard duration = %d, want 42", d) + } + if discarded.NeedRating || discarded.NeedDebug { + t.Fatalf("need_rating/need_debug must stay false") + } + pushes = f.sessions.records() + if len(pushes) != 2 || pushes[0].userID != f.caller.ID || pushes[1].userID != f.callee.ID { + t.Fatalf("discard pushes = %+v, want caller(other devices)+callee", pushes) + } + f.sessions.reset() + + // --- 终态尾包:信令静默吞掉、重复 discard 幂等 --- + if okSig, err := f.router.onPhoneSendSignalingData(f.calleeCtx(), &tg.PhoneSendSignalingDataRequest{ + Peer: tg.InputPhoneCall{ID: callID, AccessHash: accessHash}, + Data: []byte("late"), + }); err != nil || !okSig { + t.Fatalf("late signaling = %v err=%v, want silent true", okSig, err) + } + if len(f.sessions.records()) != 0 { + t.Fatalf("late signaling must not forward") + } + if _, err := f.router.onPhoneDiscardCall(f.calleeCtx(), &tg.PhoneDiscardCallRequest{ + Peer: tg.InputPhoneCall{ID: callID, AccessHash: accessHash}, + Reason: &tg.PhoneCallDiscardReasonBusy{}, + }); err != nil { + t.Fatalf("idempotent discard: %v", err) + } + if len(f.sessions.records()) != 0 { + t.Fatalf("idempotent discard must not re-push") + } + + // --- 评分/调试上报打发掉 --- + if _, err := f.router.onPhoneSetCallRating(f.callerCtx(), &tg.PhoneSetCallRatingRequest{ + Peer: tg.InputPhoneCall{ID: callID, AccessHash: accessHash}, Rating: 5, + }); err != nil { + t.Fatalf("setCallRating: %v", err) + } + if ok, err := f.router.onPhoneSaveCallDebug(f.callerCtx(), &tg.PhoneSaveCallDebugRequest{ + Peer: tg.InputPhoneCall{ID: callID, AccessHash: accessHash}, Debug: tg.DataJSON{Data: "{}"}, + }); err != nil || !ok { + t.Fatalf("saveCallDebug = %v err=%v", ok, err) + } +} + +func TestPhoneCallRPCValidation(t *testing.T) { + f := newPhoneFixture(t, stubPrivacy{}) + _, gaHash, gb := phoneTestKeys() + + // 自呼。 + _, err := f.router.onPhoneRequestCall(f.callerCtx(), &tg.PhoneRequestCallRequest{ + UserID: &tg.InputUser{UserID: f.caller.ID, AccessHash: f.caller.AccessHash}, + GAHash: gaHash, Protocol: phoneTestProtocol(), + }) + assertPhoneRPCErr(t, err, "USER_ID_INVALID") + + // 协议层级非法。 + badProto := phoneTestProtocol() + badProto.MinLayer = 93 + _, err = f.router.onPhoneRequestCall(f.callerCtx(), &tg.PhoneRequestCallRequest{ + UserID: &tg.InputUser{UserID: f.callee.ID, AccessHash: f.callee.AccessHash}, + GAHash: gaHash, Protocol: badProto, + }) + assertPhoneRPCErr(t, err, "CALL_PROTOCOL_LAYER_INVALID") + + // g_a_hash 长度非法。 + _, err = f.router.onPhoneRequestCall(f.callerCtx(), &tg.PhoneRequestCallRequest{ + UserID: &tg.InputUser{UserID: f.callee.ID, AccessHash: f.callee.AccessHash}, + GAHash: []byte("short"), Protocol: phoneTestProtocol(), + }) + assertPhoneRPCErr(t, err, "CALL_PROTOCOL_FLAGS_INVALID") + + // 建一通合法通话供后续状态校验。 + res, err := f.router.onPhoneRequestCall(f.callerCtx(), &tg.PhoneRequestCallRequest{ + UserID: &tg.InputUser{UserID: f.callee.ID, AccessHash: f.callee.AccessHash}, + GAHash: gaHash, Protocol: phoneTestProtocol(), RandomID: 7, + }) + if err != nil { + t.Fatalf("requestCall: %v", err) + } + waiting := res.PhoneCall.(*tg.PhoneCallWaiting) + peer := tg.InputPhoneCall{ID: waiting.ID, AccessHash: waiting.AccessHash} + f.sessions.reset() + + // 主叫不能 accept 自己发起的通话。 + _, err = f.router.onPhoneAcceptCall(f.callerCtx(), &tg.PhoneAcceptCallRequest{Peer: peer, GB: gb, Protocol: phoneTestProtocol()}) + assertPhoneRPCErr(t, err, "CALL_PEER_INVALID") + + // 信令超限。 + _, err = f.router.onPhoneSendSignalingData(f.callerCtx(), &tg.PhoneSendSignalingDataRequest{ + Peer: peer, Data: make([]byte, 2048), + }) + assertPhoneRPCErr(t, err, "DATA_INVALID") + + // accept 后重复 accept。 + if _, err := f.router.onPhoneAcceptCall(f.calleeCtx(), &tg.PhoneAcceptCallRequest{Peer: peer, GB: gb, Protocol: phoneTestProtocol()}); err != nil { + t.Fatalf("accept: %v", err) + } + _, err = f.router.onPhoneAcceptCall(f.calleeCtx(), &tg.PhoneAcceptCallRequest{Peer: peer, GB: gb, Protocol: phoneTestProtocol()}) + assertPhoneRPCErr(t, err, "CALL_ALREADY_ACCEPTED") + f.sessions.reset() + + // g_a 与承诺不符:CALL_PEER_INVALID 且双方收到强制 discarded。 + wrongGA := make([]byte, 256) + wrongGA[0] = 9 + _, err = f.router.onPhoneConfirmCall(f.callerCtx(), &tg.PhoneConfirmCallRequest{Peer: peer, GA: wrongGA, KeyFingerprint: 1, Protocol: phoneTestProtocol()}) + assertPhoneRPCErr(t, err, "CALL_PEER_INVALID") + pushes := f.sessions.records() + if len(pushes) != 2 { + t.Fatalf("forced discard pushes = %d, want both sides", len(pushes)) + } + for _, rec := range pushes { + if _, ok := phoneCallPayload(t, rec).(*tg.PhoneCallDiscarded); !ok { + t.Fatalf("forced discard payload to %d = %T", rec.userID, phoneCallPayload(t, rec)) + } + } +} + +func TestPhoneCallRPCPrivacyGate(t *testing.T) { + f := newPhoneFixture(t, stubPrivacy{deny: map[domain.PrivacyKey]bool{domain.PrivacyKeyPhoneCall: true}}) + _, gaHash, _ := phoneTestKeys() + _, err := f.router.onPhoneRequestCall(f.callerCtx(), &tg.PhoneRequestCallRequest{ + UserID: &tg.InputUser{UserID: f.callee.ID, AccessHash: f.callee.AccessHash}, + GAHash: gaHash, Protocol: phoneTestProtocol(), + }) + assertPhoneRPCErr(t, err, "USER_PRIVACY_RESTRICTED") + if len(f.sessions.records()) != 0 { + t.Fatalf("privacy-restricted request must not push") + } +} + +func TestPhoneUserFullCallFlags(t *testing.T) { + t.Run("allowed", func(t *testing.T) { + f := newPhoneFixture(t, stubPrivacy{}) + full, err := f.router.onUsersGetFullUser(f.callerCtx(), &tg.InputUser{UserID: f.callee.ID, AccessHash: f.callee.AccessHash}) + if err != nil { + t.Fatalf("getFullUser: %v", err) + } + if !full.FullUser.PhoneCallsAvailable || !full.FullUser.VideoCallsAvailable || full.FullUser.PhoneCallsPrivate { + t.Fatalf("call flags = %+v, want available && !private", full.FullUser) + } + }) + t.Run("denied", func(t *testing.T) { + f := newPhoneFixture(t, stubPrivacy{deny: map[domain.PrivacyKey]bool{ + domain.PrivacyKeyPhoneCall: true, + domain.PrivacyKeyPhoneP2P: true, + }}) + full, err := f.router.onUsersGetFullUser(f.callerCtx(), &tg.InputUser{UserID: f.callee.ID, AccessHash: f.callee.AccessHash}) + if err != nil { + t.Fatalf("getFullUser: %v", err) + } + if full.FullUser.PhoneCallsAvailable || full.FullUser.VideoCallsAvailable || !full.FullUser.PhoneCallsPrivate { + t.Fatalf("call flags = %+v, want unavailable && private", full.FullUser) + } + }) +} + +func TestMessagesGetDhConfig(t *testing.T) { + f := newPhoneFixture(t, stubPrivacy{}) + + res, err := f.router.onMessagesGetDhConfig(f.callerCtx(), &tg.MessagesGetDhConfigRequest{Version: 0, RandomLength: 256}) + if err != nil { + t.Fatalf("getDhConfig: %v", err) + } + full, ok := res.(*tg.MessagesDhConfig) + if !ok { + t.Fatalf("result = %T, want MessagesDhConfig", res) + } + if full.G != appphone.DHG || full.Version != appphone.DHConfigVersion { + t.Fatalf("dh config g=%d version=%d", full.G, full.Version) + } + if len(full.P) != 256 { + t.Fatalf("prime length = %d, want 256 (2048-bit)", len(full.P)) + } + if string(full.P) != string(appphone.DHPrime()) { + t.Fatalf("prime must equal the official srp prime") + } + if len(full.Random) != 256 { + t.Fatalf("random length = %d, want exactly requested 256", len(full.Random)) + } + + res, err = f.router.onMessagesGetDhConfig(f.callerCtx(), &tg.MessagesGetDhConfigRequest{Version: appphone.DHConfigVersion, RandomLength: 32}) + if err != nil { + t.Fatalf("getDhConfig cached: %v", err) + } + notModified, ok := res.(*tg.MessagesDhConfigNotModified) + if !ok { + t.Fatalf("cached result = %T, want MessagesDhConfigNotModified", res) + } + if len(notModified.Random) != 32 { + t.Fatalf("cached random length = %d, want 32", len(notModified.Random)) + } + + // 未登录 → 401。 + _, err = f.router.onMessagesGetDhConfig(f.ctx, &tg.MessagesGetDhConfigRequest{Version: 0, RandomLength: 256}) + assertPhoneRPCErr(t, err, "AUTH_KEY_UNREGISTERED") +} + +// phoneTestClock 是可推进的测试时钟(fixedClock 不可变,dispatcher 测试需要推进)。 +type phoneTestClock struct { + mu sync.Mutex + now time.Time +} + +func (c *phoneTestClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *phoneTestClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +func (c *phoneTestClock) Timer(d time.Duration) clock.Timer { return clock.System.Timer(d) } +func (c *phoneTestClock) Ticker(d time.Duration) clock.Ticker { return clock.System.Ticker(d) } + +// phoneCaptureMessages 只捕获 SendPrivateText;其余 MessagesService 方法不在通话链路使用。 +type phoneCaptureMessages struct { + MessagesService + mu sync.Mutex + sent []domain.SendPrivateTextRequest +} + +func (m *phoneCaptureMessages) SendPrivateText(_ context.Context, _ int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.sent = append(m.sent, req) + return domain.SendPrivateTextResult{}, nil +} + +func (m *phoneCaptureMessages) records() []domain.SendPrivateTextRequest { + m.mu.Lock() + defer m.mu.Unlock() + return append([]domain.SendPrivateTextRequest(nil), m.sent...) +} + +func newPhoneFixtureFull(t *testing.T, clk clock.Clock, messages *phoneCaptureMessages) *phoneFixture { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + sessions := &phoneCaptureSessions{} + deps := Deps{ + Users: appusers.NewService(userStore), + Privacy: stubPrivacy{}, + Phone: appphone.NewService(appphone.Config{}, appphone.WithClock(clk)), + Sessions: sessions, + } + if messages != nil { + deps.Messages = messages + } + router := New(Config{CallSignalingMaxBytes: 1024}, deps, zaptest.NewLogger(t), clk) + f := &phoneFixture{t: t, ctx: ctx, router: router, sessions: sessions} + mkUser := func(hash int64, phone, name string) domain.User { + u, err := userStore.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name}) + if err != nil { + t.Fatalf("create user %s: %v", name, err) + } + return u + } + f.caller = mkUser(1001, "13800000001", "Caller") + f.callee = mkUser(1002, "13800000002", "Callee") + return f +} + +// establishPhoneCall 把一通通话推进到 Confirmed,返回 peer 引用。 +func establishPhoneCall(t *testing.T, f *phoneFixture) tg.InputPhoneCall { + t.Helper() + ga, gaHash, gb := phoneTestKeys() + res, err := f.router.onPhoneRequestCall(f.callerCtx(), &tg.PhoneRequestCallRequest{ + UserID: &tg.InputUser{UserID: f.callee.ID, AccessHash: f.callee.AccessHash}, + GAHash: gaHash, Protocol: phoneTestProtocol(), RandomID: 9, + }) + if err != nil { + t.Fatalf("requestCall: %v", err) + } + waiting := res.PhoneCall.(*tg.PhoneCallWaiting) + peer := tg.InputPhoneCall{ID: waiting.ID, AccessHash: waiting.AccessHash} + if _, err := f.router.onPhoneAcceptCall(f.calleeCtx(), &tg.PhoneAcceptCallRequest{Peer: peer, GB: gb, Protocol: phoneTestProtocol()}); err != nil { + t.Fatalf("acceptCall: %v", err) + } + if _, err := f.router.onPhoneConfirmCall(f.callerCtx(), &tg.PhoneConfirmCallRequest{Peer: peer, GA: ga, KeyFingerprint: 7, Protocol: phoneTestProtocol()}); err != nil { + t.Fatalf("confirmCall: %v", err) + } + f.sessions.reset() + return peer +} + +func TestPhoneCallDiscardWritesHistory(t *testing.T) { + clk := &phoneTestClock{now: time.Unix(1_700_000_000, 0)} + messages := &phoneCaptureMessages{} + f := newPhoneFixtureFull(t, clk, messages) + peer := establishPhoneCall(t, f) + + // 被叫挂断:sender 仍须是主叫(AdminID)。 + if _, err := f.router.onPhoneDiscardCall(f.calleeCtx(), &tg.PhoneDiscardCallRequest{ + Peer: peer, Duration: 17, Reason: &tg.PhoneCallDiscardReasonHangup{}, + }); err != nil { + t.Fatalf("discardCall: %v", err) + } + sent := messages.records() + if len(sent) != 1 { + t.Fatalf("history messages = %d, want 1", len(sent)) + } + req := sent[0] + if req.SenderUserID != f.caller.ID || req.RecipientUserID != f.callee.ID { + t.Fatalf("history sender/recipient = %d/%d, want caller→callee", req.SenderUserID, req.RecipientUserID) + } + if req.OriginAuthKeyID != [8]byte{} || req.OriginSessionID != 0 { + t.Fatalf("history origin must be zero (all devices via outbox), got %+v/%d", req.OriginAuthKeyID, req.OriginSessionID) + } + if req.Media == nil || req.Media.ServiceAction == nil || req.Media.ServiceAction.Kind != domain.MessageServiceActionPhoneCall { + t.Fatalf("history media = %+v, want phone_call service action", req.Media) + } + action := req.Media.ServiceAction.Call + if action == nil || action.CallID != peer.ID || action.Duration != 17 || action.Reason != string(domain.PhoneCallDiscardReasonHangup) { + t.Fatalf("history action = %+v", action) + } + // 幂等:重复 discard 不再落历史。 + if _, err := f.router.onPhoneDiscardCall(f.callerCtx(), &tg.PhoneDiscardCallRequest{Peer: peer}); err != nil { + t.Fatalf("idempotent discard: %v", err) + } + if len(messages.records()) != 1 { + t.Fatalf("idempotent discard must not duplicate history") + } +} + +func TestPhoneExpiryDispatcherMissedCall(t *testing.T) { + clk := &phoneTestClock{now: time.Unix(1_700_000_000, 0)} + messages := &phoneCaptureMessages{} + f := newPhoneFixtureFull(t, clk, messages) + _, gaHash, _ := phoneTestKeys() + + res, err := f.router.onPhoneRequestCall(f.callerCtx(), &tg.PhoneRequestCallRequest{ + UserID: &tg.InputUser{UserID: f.callee.ID, AccessHash: f.callee.AccessHash}, + GAHash: gaHash, Protocol: phoneTestProtocol(), + }) + if err != nil { + t.Fatalf("requestCall: %v", err) + } + waiting := res.PhoneCall.(*tg.PhoneCallWaiting) + f.sessions.reset() + + dispatcher := NewPhoneExpiryDispatcher(f.router, zaptest.NewLogger(t), time.Second) + dispatcher.DispatchOnce(f.ctx) + if len(f.sessions.records()) != 0 || len(messages.records()) != 0 { + t.Fatalf("nothing should expire before ring timeout") + } + + clk.Advance(91 * time.Second) + dispatcher.DispatchOnce(f.ctx) + pushes := f.sessions.records() + if len(pushes) != 2 || pushes[0].userID != f.caller.ID || pushes[1].userID != f.callee.ID { + t.Fatalf("expiry pushes = %+v, want both sides", pushes) + } + for _, rec := range pushes { + payload, ok := phoneCallPayload(t, rec).(*tg.PhoneCallDiscarded) + if !ok { + t.Fatalf("expiry payload to %d = %T", rec.userID, phoneCallPayload(t, rec)) + } + if reason, _ := payload.GetReason(); reason == nil { + t.Fatalf("expiry discarded must carry missed reason") + } else if _, isMissed := reason.(*tg.PhoneCallDiscardReasonMissed); !isMissed { + t.Fatalf("expiry reason = %T, want missed", reason) + } + // dispatcher 推送不排除任何设备。 + if rec.excludeSession != 0 { + t.Fatalf("expiry push exclude = %d, want 0", rec.excludeSession) + } + } + sent := messages.records() + if len(sent) != 1 || sent[0].Media.ServiceAction.Call.Reason != string(domain.PhoneCallDiscardReasonMissed) { + t.Fatalf("missed history = %+v, want one missed entry", sent) + } + if sent[0].Media.ServiceAction.Call.CallID != waiting.ID { + t.Fatalf("missed history call id = %d, want %d", sent[0].Media.ServiceAction.Call.CallID, waiting.ID) + } +} + +// TestPhoneCallHistoryThroughRealPipeline 走真实 messages 管线(memory store)验证 +// 通话历史端到端:新 action kind 经媒体 JSONB 序列化、读路径 TL 转换后完整存活。 +func TestPhoneCallHistoryThroughRealPipeline(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + dialogs := memorydialogs(t) + messageStore := memory.NewMessageStore(dialogs) + sessions := &phoneCaptureSessions{} + clk := &phoneTestClock{now: time.Unix(1_700_000_000, 0)} + router := New(Config{CallSignalingMaxBytes: 1024}, Deps{ + Users: appusers.NewService(userStore), + Privacy: stubPrivacy{}, + Phone: appphone.NewService(appphone.Config{}, appphone.WithClock(clk)), + Messages: appmessages.NewService(messageStore, dialogs), + Sessions: sessions, + }, zaptest.NewLogger(t), clk) + f := &phoneFixture{t: t, ctx: ctx, router: router, sessions: sessions} + mk := func(hash int64, phone, name string) domain.User { + u, err := userStore.Create(ctx, domain.User{AccessHash: hash, Phone: phone, FirstName: name}) + if err != nil { + t.Fatalf("create user: %v", err) + } + return u + } + f.caller = mk(1001, "13800000001", "Caller") + f.callee = mk(1002, "13800000002", "Callee") + + peer := establishPhoneCall(t, f) + if _, err := router.onPhoneDiscardCall(f.callerCtx(), &tg.PhoneDiscardCallRequest{ + Peer: peer, Duration: 33, Reason: &tg.PhoneCallDiscardReasonHangup{}, + }); err != nil { + t.Fatalf("discardCall: %v", err) + } + + // 被叫视角读历史:服务消息存在且转换为 messageActionPhoneCall。 + list, err := router.deps.Messages.GetHistory(ctx, f.callee.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: f.caller.ID}, + Limit: 10, + }) + if err != nil || len(list.Messages) == 0 { + t.Fatalf("history = %+v err=%v, want phone call entry", list, err) + } + msg := list.Messages[0] + if msg.From.ID != f.caller.ID { + t.Fatalf("history sender = %d, want caller %d", msg.From.ID, f.caller.ID) + } + converted := tgMessageServiceAction(msg) + action, ok := converted.(*tg.MessageActionPhoneCall) + if !ok { + t.Fatalf("converted action = %T, want MessageActionPhoneCall", converted) + } + if action.CallID != peer.ID { + t.Fatalf("action call id = %d, want %d", action.CallID, peer.ID) + } + if d, _ := action.GetDuration(); d != 33 { + t.Fatalf("action duration = %d, want 33", d) + } + if reason, _ := action.GetReason(); reason == nil { + t.Fatalf("action reason missing") + } +} + +func memorydialogs(t *testing.T) *memory.DialogStore { + t.Helper() + return memory.NewDialogStore() +} diff --git a/internal/rpc/phone_stubs.go b/internal/rpc/phone_stubs.go new file mode 100644 index 00000000..34845be7 --- /dev/null +++ b/internal/rpc/phone_stubs.go @@ -0,0 +1,36 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" +) + +// 群通话范围外入口的被动 stub(防点崩)。未列出的 phone.*(conference 族 / +// 通话内消息族 / scheduled / RTMP)走 router fallback:400/500 NOT_IMPLEMENTED + +// 兼容矩阵日志,客户端不断连。 +func (r *Router) registerPhoneStubs(d *tg.ServerDispatcher) { + // 入会面板前置调用:返回 self 一个候选身份(空返回会卡 UI)。 + d.OnPhoneGetGroupCallJoinAs(func(ctx context.Context, peer tg.InputPeerClass) (*tg.PhoneJoinAsPeers, error) { + userID, err := r.phoneRequireUser(ctx) + if err != nil { + return nil, err + } + return &tg.PhoneJoinAsPeers{ + Peers: []tg.PeerClass{&tg.PeerUser{UserID: userID}}, + Chats: []tg.ChatClass{}, + Users: r.tgUsersForIDs(ctx, userID, []int64{userID}), + }, nil + }) + d.OnPhoneSaveDefaultGroupCallJoinAs(func(ctx context.Context, req *tg.PhoneSaveDefaultGroupCallJoinAsRequest) (bool, error) { + return true, nil + }) + // 录制范围外:客户端只看 record_start_date(恒不下发),打发掉即可。 + d.OnPhoneToggleGroupCallRecord(func(ctx context.Context, req *tg.PhoneToggleGroupCallRecordRequest) (tg.UpdatesClass, error) { + return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + }) + // RTMP 直播范围外。 + d.OnPhoneGetGroupCallStreamChannels(func(ctx context.Context, call tg.InputGroupCallClass) (*tg.PhoneGroupCallStreamChannels, error) { + return &tg.PhoneGroupCallStreamChannels{Channels: []tg.GroupCallStreamChannel{}}, nil + }) +} diff --git a/internal/rpc/phone_turn.go b/internal/rpc/phone_turn.go new file mode 100644 index 00000000..617cbd47 --- /dev/null +++ b/internal/rpc/phone_turn.go @@ -0,0 +1,57 @@ +package rpc + +import ( + "context" + "strconv" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// 私聊通话 P3:STUN/TURN 中继参数(phoneConnectionWebrtc)与 p2p_allowed 真值。 +// 凭据在 requestCall 受理时一次性签发并存于 call 快照——同一通话的所有视角 +//(RPC 响应与推送、主叫与被叫)看到同一份 connections,与官方行为一致。 + +// phoneCallPrivacyP2P 计算 phone_p2p 隐私的双向 AND:任一方不允许对方 P2P, +// 该通话就只能走中继(p2p_allowed=false 时 tgcalls 丢弃全部非 relay candidate)。 +// 隐私服务缺席时按放行处理(与 P1 行为一致);CallForceRelay 强制中继(调试用)。 +func (r *Router) phoneCallPrivacyP2P(ctx context.Context, callerID, calleeID int64) bool { + if r.cfg.CallForceRelay { + return false + } + if r.deps.Privacy == nil { + return true + } + calleeAllows, err := r.deps.Privacy.CanSee(ctx, calleeID, callerID, domain.PrivacyKeyPhoneP2P) + if err != nil { + return true + } + callerAllows, err := r.deps.Privacy.CanSee(ctx, callerID, calleeID, domain.PrivacyKeyPhoneP2P) + if err != nil { + return true + } + return calleeAllows && callerAllows +} + +// phoneCallConnections 为一通通话签发 STUN/TURN 条目。TURN 未启用返回空列表 +// (tgcalls 遍历空列表零次,退回纯信令交换 host candidates 的 LAN 直连)。 +func (r *Router) phoneCallConnections(callerID int64) []domain.PhoneCallConnection { + t := r.deps.TURN + if t == nil || !t.Enabled() { + return nil + } + username, password, err := t.Credentials(strconv.FormatInt(callerID, 10)) + if err != nil { + r.log.Warn("phone call turn credentials", zap.Error(err)) + return nil + } + // ⚠ STUN 与 TURN 必须拆成两个条目(与官方一致):DrKLO 的 JNI 层根本不读 + // stun flag——单条目 stun+turn 在 Android 上只会产出 TURN server、丢失 STUN + //(org_telegram_messenger_voip_Instance.cpp:848-884)。TDesktop 两种写法都认。 + // TURN username 是 REST 格式 ":",天然避开 "reflector" 劫持禁区。 + return []domain.PhoneCallConnection{ + {ID: 1, IP: t.IP(), Port: t.Port(), Stun: true}, + {ID: 2, IP: t.IP(), Port: t.Port(), Username: username, Password: password, Turn: true}, + } +} diff --git a/internal/rpc/photos.go b/internal/rpc/photos.go index fa0061a7..5c0f16d9 100644 --- a/internal/rpc/photos.go +++ b/internal/rpc/photos.go @@ -32,24 +32,29 @@ func (r *Router) onPhotosUploadProfilePhoto(ctx context.Context, req *tg.PhotosU if bot, hasBot := req.GetBot(); hasBot && bot != nil { return nil, inputConstructorInvalidErr() } - file, hasFile := req.GetFile() - if !hasFile { - // video / emoji markup 等头像变体本阶段不支持。 - return nil, photoInvalidErr() + upload, mediaFlags, err := parseProfilePhotoUpload(req) + if err != nil { + return nil, err } - ref, ok := uploadedFileRef(userID, file) - if !ok { - return nil, fileReferenceInvalidErr() + if mediaFlags == 0 { + return nil, photoInvalidErr() } kind := domain.ProfilePhotoKindProfile if req.GetFallback() { kind = domain.ProfilePhotoKindFallback } - photo, err := r.deps.Files.UploadProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind, ref, int(r.clock.Now().Unix())) + photo, err := r.createProfilePhoto(ctx, userID, upload) if err != nil { - return nil, photoUploadErr(err) + return nil, err } - return r.photosPhotoForSelf(ctx, userID, photo), nil + photo, found, err := r.deps.Files.SetCurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind, photo.ID, int(r.clock.Now().Unix())) + if err != nil { + return nil, internalErr() + } + if !found { + return nil, photoInvalidErr() + } + return r.photosPhotoForSelf(ctx, userID, photo, kind), nil } func (r *Router) onPhotosUpdateProfilePhoto(ctx context.Context, req *tg.PhotosUpdateProfilePhotoRequest) (*tg.PhotosPhoto, error) { @@ -79,18 +84,18 @@ func (r *Router) onPhotosUpdateProfilePhoto(ctx context.Context, req *tg.PhotosU if !found { return nil, photoInvalidErr() } - return r.photosPhotoForSelf(ctx, userID, photo), nil + return r.photosPhotoForSelf(ctx, userID, photo, kind), nil default: // InputPhotoEmpty:移除当前头像(停用现有当前照片)。 if cur, found, err := r.deps.Files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, userID, kind); err == nil && found { _, _ = r.deps.Files.DeleteProfilePhotosKind(ctx, domain.PeerTypeUser, userID, kind, []int64{cur.ID}) } - return r.photosPhotoForSelf(ctx, userID, domain.Photo{}), nil + return r.photosPhotoForSelf(ctx, userID, domain.Photo{}, kind), nil } } func (r *Router) onPhotosUploadContactProfilePhoto(ctx context.Context, req *tg.PhotosUploadContactProfilePhotoRequest) (*tg.PhotosPhoto, error) { - if r.deps.Files == nil || r.deps.Contacts == nil { + if r.deps.Files == nil || r.deps.Users == nil { return nil, notImplementedErr() } userID, ok, err := r.currentUserID(ctx) @@ -107,33 +112,255 @@ func (r *Router) onPhotosUploadContactProfilePhoto(ctx context.Context, req *tg. if !found || target.ID == 0 || target.ID == userID { return nil, userIDInvalidErr() } - file, hasFile := req.GetFile() - if !hasFile { - if req.GetSave() { - if _, err := r.deps.Contacts.ClearPersonalPhoto(ctx, userID, target.ID, int(r.clock.Now().Unix())); err != nil { - return nil, contactErr(err) - } - return r.photosPhotoForUser(ctx, userID, target.ID, domain.Photo{}), nil + upload, mediaFlags, err := parseProfilePhotoUpload(req) + if err != nil { + return nil, err + } + if mediaFlags == 0 && req.GetSave() { + if r.deps.Contacts == nil { + return nil, notImplementedErr() } + if _, err := r.deps.Contacts.ClearPersonalPhoto(ctx, userID, target.ID, int(r.clock.Now().Unix())); err != nil { + return nil, contactErr(err) + } + r.invalidateRPCProjectionForPeer(userID, domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}) + return r.photosPhotoForUser(ctx, userID, target.ID, domain.Photo{}), nil + } + if mediaFlags == 0 { return nil, photoInvalidErr() } - ref, ok := uploadedFileRef(userID, file) - if !ok { - return nil, fileReferenceInvalidErr() - } - photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref) + photo, err := r.createProfilePhoto(ctx, userID, upload) if err != nil { - return nil, photoUploadErr(err) + return nil, err } if req.GetSuggest() { - // TODO: add private MessageActionSuggestProfilePhoto once private service-message - // actions exist in the domain message model. + if r.deps.Messages == nil { + return nil, notImplementedErr() + } + res, err := r.sendSuggestedProfilePhotoMessage(ctx, userID, target.ID, photo) + if err != nil { + return nil, err + } + r.pushSuggestedProfilePhotoCurrentSession(ctx, userID, res) return r.photosPhotoForUser(ctx, userID, target.ID, photo), nil } + if r.deps.Contacts == nil { + return nil, notImplementedErr() + } if _, err := r.deps.Contacts.SetPersonalPhoto(ctx, userID, target.ID, photo, int(r.clock.Now().Unix())); err != nil { return nil, contactErr(err) } - return r.photosPhotoForUser(ctx, userID, target.ID, photo), nil + r.invalidateRPCProjectionForPeer(userID, domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}) + return r.photosPhotoForUserWithPersonalPhoto(ctx, userID, target.ID, photo), nil +} + +type profilePhotoUploadRequest interface { + GetFile() (tg.InputFileClass, bool) + GetVideo() (tg.InputFileClass, bool) + GetVideoStartTs() (float64, bool) + GetVideoEmojiMarkup() (tg.VideoSizeClass, bool) +} + +type profilePhotoUpload struct { + file tg.InputFileClass + hasFile bool + video tg.InputFileClass + hasVideo bool + videoStartTs float64 + markup tg.VideoSizeClass + hasMarkup bool +} + +func parseProfilePhotoUpload(req profilePhotoUploadRequest) (profilePhotoUpload, int, error) { + file, hasFile := req.GetFile() + video, hasVideo := req.GetVideo() + videoStartTs, hasVideoStartTs := req.GetVideoStartTs() + markup, hasMarkup := req.GetVideoEmojiMarkup() + if hasFile && file == nil { + return profilePhotoUpload{}, 0, photoInvalidErr() + } + if hasVideo && video == nil { + return profilePhotoUpload{}, 0, photoInvalidErr() + } + if hasMarkup && markup == nil { + return profilePhotoUpload{}, 0, photoInvalidErr() + } + if hasFile && (hasVideo || hasMarkup) { + return profilePhotoUpload{}, 0, photoInvalidErr() + } + if hasVideoStartTs && !hasVideo { + return profilePhotoUpload{}, 0, photoInvalidErr() + } + mediaFlags := 0 + if hasFile || hasVideo || hasMarkup { + mediaFlags = 1 + } + return profilePhotoUpload{ + file: file, + hasFile: hasFile, + video: video, + hasVideo: hasVideo, + videoStartTs: videoStartTs, + markup: markup, + hasMarkup: hasMarkup, + }, mediaFlags, nil +} + +func (r *Router) createProfilePhoto(ctx context.Context, userID int64, upload profilePhotoUpload) (domain.Photo, error) { + switch { + case upload.hasFile: + ref, ok := uploadedFileRef(userID, upload.file) + if !ok { + return domain.Photo{}, fileReferenceInvalidErr() + } + photo, err := r.deps.Files.CreateAvatarFromUpload(ctx, ref) + if err != nil { + return domain.Photo{}, photoUploadErr(err) + } + return photo, nil + case upload.hasVideo && upload.hasMarkup: + ref, ok := uploadedFileRef(userID, upload.video) + if !ok { + return domain.Photo{}, fileReferenceInvalidErr() + } + size, ok := domainPhotoVideoMarkup(upload.markup) + if !ok { + return domain.Photo{}, photoInvalidErr() + } + photo, err := r.deps.Files.CreateAvatarVideoMarkupFromUpload(ctx, ref, upload.videoStartTs, size) + if err != nil { + return domain.Photo{}, photoUploadErr(err) + } + return photo, nil + case upload.hasVideo: + ref, ok := uploadedFileRef(userID, upload.video) + if !ok { + return domain.Photo{}, fileReferenceInvalidErr() + } + photo, err := r.deps.Files.CreateAvatarVideoFromUpload(ctx, ref, upload.videoStartTs) + if err != nil { + return domain.Photo{}, photoUploadErr(err) + } + return photo, nil + case upload.hasMarkup: + size, ok := domainPhotoVideoMarkup(upload.markup) + if !ok { + return domain.Photo{}, photoInvalidErr() + } + photo, err := r.deps.Files.CreateAvatarMarkup(ctx, size) + if err != nil { + return domain.Photo{}, photoUploadErr(err) + } + return photo, nil + default: + return domain.Photo{}, photoInvalidErr() + } +} + +func domainPhotoVideoMarkup(markup tg.VideoSizeClass) (domain.PhotoSize, bool) { + switch v := markup.(type) { + case *tg.VideoSizeEmojiMarkup: + if v.EmojiID == 0 || len(v.BackgroundColors) == 0 { + return domain.PhotoSize{}, false + } + return domain.PhotoSize{ + Kind: domain.PhotoSizeKindVideoEmojiMarkup, + EmojiID: v.EmojiID, + BackgroundColors: append([]int(nil), v.BackgroundColors...), + }, true + case *tg.VideoSizeStickerMarkup: + if v.StickerID == 0 || len(v.BackgroundColors) == 0 { + return domain.PhotoSize{}, false + } + size := domain.PhotoSize{ + Kind: domain.PhotoSizeKindVideoStickerMarkup, + StickerID: v.StickerID, + BackgroundColors: append([]int(nil), v.BackgroundColors...), + } + setRef, ok := stickerSetRefFromInput(v.Stickerset) + if !ok { + return domain.PhotoSize{}, false + } + switch setRef.Kind { + case domain.StickerSetRefByID: + if setRef.ID == 0 { + return domain.PhotoSize{}, false + } + size.StickerSetID = setRef.ID + size.StickerSetAccessHash = setRef.AccessHash + case domain.StickerSetRefByShortName: + if setRef.ShortName == "" { + return domain.PhotoSize{}, false + } + size.StickerSetShortName = setRef.ShortName + case domain.StickerSetRefBySystem: + if setRef.SystemKey == "" { + return domain.PhotoSize{}, false + } + size.StickerSetSystemKey = setRef.SystemKey + default: + return domain.PhotoSize{}, false + } + return size, true + default: + return domain.PhotoSize{}, false + } +} + +func (r *Router) sendSuggestedProfilePhotoMessage(ctx context.Context, userID, targetUserID int64, photo domain.Photo) (domain.SendPrivateTextResult, error) { + if photo.ID == 0 { + return domain.SendPrivateTextResult{}, photoInvalidErr() + } + recipientBlocked, err := r.peerBlocksUser(ctx, userID, targetUserID) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + photoCopy := photo + res, err := r.deps.Messages.SendPrivateText(ctx, userID, domain.SendPrivateTextRequest{ + SenderUserID: userID, + RecipientUserID: targetUserID, + RandomID: contactProfilePhotoSuggestRandomID(userID, targetUserID, photo.ID, r.clock.Now().UnixNano()), + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionSuggestProfilePhoto, + Photo: &photoCopy, + }, + }, + Date: int(r.clock.Now().Unix()), + OriginAuthKeyID: authKeyID, + OriginSessionID: sessionID, + RecipientBlocked: recipientBlocked, + }) + if err != nil { + return domain.SendPrivateTextResult{}, messageSendErr(err) + } + return res, nil +} + +func contactProfilePhotoSuggestRandomID(userID, targetUserID, photoID, nowNano int64) int64 { + id := nowNano ^ (userID << 17) ^ (targetUserID << 7) ^ photoID + if id == 0 { + return photoID + } + return id +} + +func (r *Router) pushSuggestedProfilePhotoCurrentSession(ctx context.Context, userID int64, res domain.SendPrivateTextResult) { + if res.SenderMessage.ID == 0 || res.SenderEvent.Pts == 0 { + return + } + updates := tgPrivateMessageUpdates( + res.SenderEvent, + res.SenderMessage, + 0, + false, + r.usersForMessageUpdate(ctx, userID, res.SenderMessage), + r.chatsForMessageUpdate(ctx, userID, res.SenderMessage), + ) + r.pushCurrentSessionMessage(ctx, "push suggested profile photo service message", updates) } func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUserPhotosRequest) (tg.PhotosPhotosClass, error) { @@ -155,7 +382,10 @@ func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUse return nil, userIDInvalidErr() } offset := req.Offset - if offset < 0 { + if offset < -1 { + offset = -1 + } + if offset < 0 && req.MaxID <= 0 { offset = 0 } limit := req.Limit @@ -170,8 +400,19 @@ func (r *Router) onPhotosGetUserPhotos(ctx context.Context, req *tg.PhotosGetUse for _, p := range photos { tgPhotos = append(tgPhotos, tgPhoto(p)) } - users := []tg.UserClass{r.tgUser(target)} - if total > len(photos)+offset { + // 打开自己的头像相册(inputUserSelf 或显式自己 ID)时 target 即 viewer,须带 self + // 标志,否则 self=false 的自己 user 会污染 DrKLO 账号缓存(Saved Messages 变身)。 + selfUser := r.tgUser(target) + if target.ID == currentUserID { + selfUser = r.tgSelfUser(target) + } + users := []tg.UserClass{selfUser} + r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, users, nil) + countOffset := offset + if countOffset < 0 { + countOffset = 0 + } + if total > len(photos)+countOffset { return &tg.PhotosPhotosSlice{Count: total, Photos: tgPhotos, Users: users}, nil } return &tg.PhotosPhotos{Photos: tgPhotos, Users: users}, nil @@ -200,14 +441,16 @@ func (r *Router) onPhotosDeletePhotos(ctx context.Context, id []tg.InputPhotoCla if _, err := r.deps.Files.DeleteProfilePhotos(ctx, domain.PeerTypeUser, userID, ids); err != nil { return nil, internalErr() } + r.invalidateRPCProjectionForUser(userID) return ids, nil } // photosPhotoForSelf 组装 photos.photo 响应(新照片 + 带头像的 self user),并在头像变更后 // 向该账号其它在线设备推送,使其即时刷新头像。仅由 uploadProfilePhoto / updateProfilePhoto // 等头像变更路径调用(只读路径不得使用,否则会误触发推送)。 -func (r *Router) photosPhotoForSelf(ctx context.Context, userID int64, photo domain.Photo) *tg.PhotosPhoto { +func (r *Router) photosPhotoForSelf(ctx context.Context, userID int64, photo domain.Photo, kind domain.ProfilePhotoKind) *tg.PhotosPhoto { out := &tg.PhotosPhoto{Photo: tgPhoto(photo), Users: []tg.UserClass{}} + r.invalidateRPCProjectionForUser(userID) if r.deps.Users == nil { return out } @@ -215,6 +458,9 @@ func (r *Router) photosPhotoForSelf(ctx context.Context, userID int64, photo dom if err != nil { return out } + if kind == domain.ProfilePhotoKindProfile { + applyProfilePhotoToUser(&self, photo) + } out.Users = append(out.Users, r.tgSelfUser(self)) r.pushSelfPhotoUpdate(ctx, self) return out @@ -233,20 +479,62 @@ func (r *Router) photosPhotoForUser(ctx context.Context, viewerUserID, targetUse return out } +func (r *Router) photosPhotoForUserWithPersonalPhoto(ctx context.Context, viewerUserID, targetUserID int64, photo domain.Photo) *tg.PhotosPhoto { + out := r.photosPhotoForUser(ctx, viewerUserID, targetUserID, photo) + if photo.ID == 0 || len(out.Users) == 0 { + return out + } + user, ok := out.Users[0].(*tg.User) + if !ok || user == nil { + return out + } + user.Photo = &tg.UserProfilePhoto{PhotoID: photo.ID, DCID: photo.DCID, Personal: true} + if domain.PhotoHasVideo(photo.Sizes) { + user.Photo.(*tg.UserProfilePhoto).SetHasVideo(true) + } + if stripped := domain.StrippedFromSizes(photo.Sizes); len(stripped) > 0 { + user.Photo.(*tg.UserProfilePhoto).SetStrippedThumb(stripped) + } + return out +} + +func applyProfilePhotoToUser(user *domain.User, photo domain.Photo) { + if user == nil { + return + } + if photo.ID == 0 { + user.PhotoID = 0 + user.PhotoDCID = 0 + user.PhotoStripped = nil + user.PhotoPersonal = false + user.PhotoHasVideo = false + return + } + user.PhotoID = photo.ID + user.PhotoDCID = photo.DCID + user.PhotoStripped = domain.StrippedFromSizes(photo.Sizes) + user.PhotoPersonal = false + user.PhotoHasVideo = domain.PhotoHasVideo(photo.Sizes) +} + // pushSelfPhotoUpdate 向该账号其它在线设备推送头像变更。updateUserName 不含 photo 无法刷新 // 头像;updateUser 只是「该 user 变了」的信号(TDesktop 仅当 peer 已 full-loaded 时才 // forceFull 重拉);最可靠是在 Updates.Users 带上含新 userProfilePhoto 的完整 self user, -// TDesktop 经 processUser→setPhoto→peerUpdated(Photo) 即时刷新。当前设备同时经 RPC 返回更新, -// 重复推送对 TDesktop 幂等(setUserpicChecked 比对 photo_id,相同则 no-op)。 +// TDesktop 经 processUser→setPhoto→peerUpdated(Photo) 即时刷新。当前设备同时经 RPC 返回更新。 func (r *Router) pushSelfPhotoUpdate(ctx context.Context, self domain.User) { if self.ID == 0 { return } - r.pushUserUpdates(ctx, self.ID, &tg.Updates{ + updates := selfPhotoUpdates(self, int(r.clock.Now().Unix()), r.tgSelfUser(self)) + r.pushUserUpdates(ctx, self.ID, updates) +} + +func selfPhotoUpdates(self domain.User, date int, user tg.UserClass) *tg.Updates { + return &tg.Updates{ Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: self.ID}}, - Users: []tg.UserClass{r.tgSelfUser(self)}, - Date: int(r.clock.Now().Unix()), - }) + Users: []tg.UserClass{user}, + Date: date, + } } // uploadedFileRef 把 tg.InputFile / InputFileBig 转成 domain.UploadedFileRef。 diff --git a/internal/rpc/photos_contact_profile_test.go b/internal/rpc/photos_contact_profile_test.go new file mode 100644 index 00000000..d37037dd --- /dev/null +++ b/internal/rpc/photos_contact_profile_test.go @@ -0,0 +1,275 @@ +package rpc + +import ( + "context" + "strings" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appcontacts "telesrv/internal/app/contacts" + appmessages "telesrv/internal/app/messages" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func newContactProfilePhotoTestRouter(t *testing.T) (*Router, *memory.ContactStore, *captureSessions, domain.User, domain.User) { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550002002", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + contactStore := memory.NewContactStore() + contacts := appcontacts.NewService(contactStore, userStore) + if _, err := contacts.AddContact(ctx, owner.ID, domain.ContactInput{ContactUserID: friend.ID, FirstName: "Friend"}); err != nil { + t.Fatalf("add contact: %v", err) + } + dialogStore := memory.NewDialogStore() + messageStore := memory.NewMessageStore(dialogStore) + users := appusers.NewService(userStore, appusers.WithContactStore(contactStore)) + files := &fakeFiles{photos: map[int64]domain.Photo{}} + sessions := &captureSessions{} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: users, + Contacts: contacts, + Files: files, + Messages: appmessages.NewService(messageStore, dialogStore, appmessages.WithContactStore(contactStore)), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + return r, contactStore, sessions, owner, friend +} + +func contactProfilePhotoReq(friend domain.User) *tg.PhotosUploadContactProfilePhotoRequest { + return &tg.PhotosUploadContactProfilePhotoRequest{ + UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + } +} + +func uploadedAvatarFile() *tg.InputFile { + return &tg.InputFile{ID: 42, Parts: 1, Name: "avatar.jpg", MD5Checksum: "d41d8cd98f00b204e9800998ecf8427e"} +} + +func personalPhotoID(t *testing.T, store *memory.ContactStore, ownerID, friendID int64) int64 { + t.Helper() + refs, err := store.PersonalPhotos(context.Background(), ownerID, []int64{friendID}) + if err != nil { + t.Fatalf("personal photos: %v", err) + } + return refs[friendID].PhotoID +} + +func firstPhotosUser(t *testing.T, got *tg.PhotosPhoto) *tg.User { + t.Helper() + if got == nil || len(got.Users) == 0 { + t.Fatalf("photos.photo users missing: %+v", got) + } + user, ok := got.Users[0].(*tg.User) + if !ok { + t.Fatalf("photos.photo user = %T, want *tg.User", got.Users[0]) + } + return user +} + +func TestUploadContactProfilePhotoSaveAndClearPersonalPhoto(t *testing.T) { + ctx := context.Background() + r, contacts, _, owner, friend := newContactProfilePhotoTestRouter(t) + + saveReq := contactProfilePhotoReq(friend) + saveReq.SetSave(true) + saveReq.SetFile(uploadedAvatarFile()) + got, err := r.onPhotosUploadContactProfilePhoto(WithUserID(ctx, owner.ID), saveReq) + if err != nil { + t.Fatalf("save contact profile photo: %v", err) + } + if photo, ok := got.Photo.(*tg.Photo); !ok || photo.ID != 778 || photo.DCID != 2 { + t.Fatalf("returned photo = %+v, want photo 778/dc2", got.Photo) + } + if id := personalPhotoID(t, contacts, owner.ID, friend.ID); id != 778 { + t.Fatalf("personal photo id = %d, want 778", id) + } + userPhoto, ok := firstPhotosUser(t, got).Photo.(*tg.UserProfilePhoto) + if !ok || userPhoto.PhotoID != 778 || userPhoto.DCID != 2 || !userPhoto.Personal { + t.Fatalf("returned user photo = %+v, want personal 778/dc2", firstPhotosUser(t, got).Photo) + } + + clearReq := contactProfilePhotoReq(friend) + clearReq.SetSave(true) + cleared, err := r.onPhotosUploadContactProfilePhoto(WithUserID(ctx, owner.ID), clearReq) + if err != nil { + t.Fatalf("clear contact profile photo: %v", err) + } + if _, ok := cleared.Photo.(*tg.PhotoEmpty); !ok { + t.Fatalf("cleared photo = %T, want *tg.PhotoEmpty", cleared.Photo) + } + if id := personalPhotoID(t, contacts, owner.ID, friend.ID); id != 0 { + t.Fatalf("personal photo after clear = %d, want 0", id) + } +} + +func TestUploadContactProfilePhotoSuggestCreatesServiceMessage(t *testing.T) { + ctx := context.Background() + r, contacts, sessions, owner, friend := newContactProfilePhotoTestRouter(t) + + req := contactProfilePhotoReq(friend) + req.SetSuggest(true) + req.SetFile(uploadedAvatarFile()) + got, err := r.onPhotosUploadContactProfilePhoto(WithSessionID(WithUserID(ctx, owner.ID), 91), req) + if err != nil { + t.Fatalf("suggest contact profile photo: %v", err) + } + if photo, ok := got.Photo.(*tg.Photo); !ok || photo.ID != 778 { + t.Fatalf("suggest returned photo = %+v, want photo 778", got.Photo) + } + if id := personalPhotoID(t, contacts, owner.ID, friend.ID); id != 0 { + t.Fatalf("suggest set personal photo id = %d, want 0", id) + } + if firstPhotosUser(t, got).Photo != nil { + t.Fatalf("suggest returned user photo = %+v, want unchanged user", firstPhotosUser(t, got).Photo) + } + + assertSuggestProfilePhotoHistory(t, r, owner.ID, friend.ID, true) + assertSuggestProfilePhotoHistory(t, r, friend.ID, owner.ID, false) + snap := sessions.snapshot() + pushed, ok := snap.message.(*tg.Updates) + if !ok { + t.Fatalf("current-session push = %T, want *tg.Updates", snap.message) + } + if len(pushed.Updates) != 1 { + t.Fatalf("current-session updates = %+v, want UpdateNewMessage only", pushed.Updates) + } + update, ok := pushed.Updates[0].(*tg.UpdateNewMessage) + if !ok { + t.Fatalf("current-session update = %T, want *tg.UpdateNewMessage", pushed.Updates[0]) + } + if service, ok := update.Message.(*tg.MessageService); !ok || !service.Out { + t.Fatalf("current-session message = %T %+v, want outgoing MessageService", update.Message, update.Message) + } +} + +func TestUploadContactProfilePhotoAnimatedInputsSetPersonalPhoto(t *testing.T) { + ctx := context.Background() + r, contacts, _, owner, friend := newContactProfilePhotoTestRouter(t) + + videoReq := contactProfilePhotoReq(friend) + videoReq.SetSave(true) + videoReq.SetVideo(&tg.InputFile{ID: 43, Parts: 1, Name: "avatar.mp4"}) + videoReq.SetVideoStartTs(0.5) + videoGot, err := r.onPhotosUploadContactProfilePhoto(WithUserID(ctx, owner.ID), videoReq) + if err != nil { + t.Fatalf("save animated video profile photo: %v", err) + } + videoPhoto, ok := videoGot.Photo.(*tg.Photo) + if !ok || videoPhoto.ID != 779 || len(videoPhoto.VideoSizes) != 1 { + t.Fatalf("video photo = %+v, want photo 779 with video_sizes", videoGot.Photo) + } + videoSize, ok := videoPhoto.VideoSizes[0].(*tg.VideoSize) + if !ok || videoSize.Type != "u" || videoSize.VideoStartTs != 0.5 { + t.Fatalf("video size = %+v, want videoSize u start 0.5", videoPhoto.VideoSizes[0]) + } + if id := personalPhotoID(t, contacts, owner.ID, friend.ID); id != 779 { + t.Fatalf("personal photo after video = %d, want 779", id) + } + + markupReq := contactProfilePhotoReq(friend) + markupReq.SetSave(true) + markupReq.SetVideoEmojiMarkup(&tg.VideoSizeEmojiMarkup{EmojiID: 99, BackgroundColors: []int{0xffffff}}) + markupGot, err := r.onPhotosUploadContactProfilePhoto(WithUserID(ctx, owner.ID), markupReq) + if err != nil { + t.Fatalf("save emoji markup profile photo: %v", err) + } + markupPhoto, ok := markupGot.Photo.(*tg.Photo) + if !ok || markupPhoto.ID != 780 || len(markupPhoto.VideoSizes) != 1 { + t.Fatalf("markup photo = %+v, want photo 780 with video_sizes", markupGot.Photo) + } + if _, ok := markupPhoto.VideoSizes[0].(*tg.VideoSizeEmojiMarkup); !ok { + t.Fatalf("markup video size = %T, want *tg.VideoSizeEmojiMarkup", markupPhoto.VideoSizes[0]) + } + if id := personalPhotoID(t, contacts, owner.ID, friend.ID); id != 780 { + t.Fatalf("personal photo after markup = %d, want 780", id) + } +} + +func TestUploadContactProfilePhotoInvalidAnimatedFlagsDoNotClear(t *testing.T) { + ctx := context.Background() + r, contacts, _, owner, friend := newContactProfilePhotoTestRouter(t) + saveReq := contactProfilePhotoReq(friend) + saveReq.SetSave(true) + saveReq.SetFile(uploadedAvatarFile()) + if _, err := r.onPhotosUploadContactProfilePhoto(WithUserID(ctx, owner.ID), saveReq); err != nil { + t.Fatalf("seed personal photo: %v", err) + } + + cases := []struct { + name string + setup func(*tg.PhotosUploadContactProfilePhotoRequest) + }{ + { + name: "file-and-video", + setup: func(req *tg.PhotosUploadContactProfilePhotoRequest) { + req.SetSave(true) + req.SetFile(uploadedAvatarFile()) + req.SetVideo(&tg.InputFile{ID: 44, Parts: 1, Name: "avatar.mp4"}) + }, + }, + { + name: "video-start-without-video", + setup: func(req *tg.PhotosUploadContactProfilePhotoRequest) { + req.SetSave(true) + req.SetVideoStartTs(0.5) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := contactProfilePhotoReq(friend) + tc.setup(req) + if _, err := r.onPhotosUploadContactProfilePhoto(WithUserID(ctx, owner.ID), req); err == nil || !strings.Contains(err.Error(), "PHOTO_INVALID") { + t.Fatalf("invalid animated request error = %v, want PHOTO_INVALID", err) + } + if id := personalPhotoID(t, contacts, owner.ID, friend.ID); id != 778 { + t.Fatalf("personal photo after invalid request = %d, want preserved 778", id) + } + }) + } +} + +func assertSuggestProfilePhotoHistory(t *testing.T, r *Router, ownerID, peerID int64, out bool) { + t.Helper() + list, err := r.deps.Messages.GetHistory(context.Background(), ownerID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get history owner %d peer %d: %v", ownerID, peerID, err) + } + if len(list.Messages) != 1 { + t.Fatalf("history owner %d peer %d len = %d, want 1", ownerID, peerID, len(list.Messages)) + } + msg := tgMessage(list.Messages[0]) + service, ok := msg.(*tg.MessageService) + if !ok { + t.Fatalf("history message = %T %+v, want *tg.MessageService", msg, msg) + } + if service.Out != out { + t.Fatalf("history service out = %v, want %v", service.Out, out) + } + action, ok := service.Action.(*tg.MessageActionSuggestProfilePhoto) + if !ok { + t.Fatalf("service action = %T, want suggest profile photo", service.Action) + } + photo, ok := action.Photo.(*tg.Photo) + if !ok || photo.ID != 778 { + t.Fatalf("suggest action photo = %+v, want photo 778", action.Photo) + } +} diff --git a/internal/rpc/photos_get_user_photos_test.go b/internal/rpc/photos_get_user_photos_test.go new file mode 100644 index 00000000..f1e3cf01 --- /dev/null +++ b/internal/rpc/photos_get_user_photos_test.go @@ -0,0 +1,112 @@ +package rpc + +import ( + "context" + "testing" + "time" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appstories "telesrv/internal/app/stories" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestPhotosGetUserPhotosPreservesMaxIDRefreshOffset(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550002101", FirstName: "Photo"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + files := &fakeFiles{ + profilePhotos: []domain.Photo{{ID: 9100000000000000301, AccessHash: 9, DCID: 2}}, + profilePhotosTotal: 1, + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Files: files, + }, zaptest.NewLogger(t), clock.System) + + got, err := r.onPhotosGetUserPhotos(WithUserID(ctx, user.ID), &tg.PhotosGetUserPhotosRequest{ + UserID: &tg.InputUser{UserID: user.ID, AccessHash: user.AccessHash}, + Offset: -1, + MaxID: 9100000000000000301, + Limit: 1, + }) + if err != nil { + t.Fatalf("get user photos: %v", err) + } + if _, ok := got.(*tg.PhotosPhotos); !ok { + t.Fatalf("get user photos = %T, want *tg.PhotosPhotos", got) + } + if files.lastProfileOffset != -1 || files.lastProfileMaxID != 9100000000000000301 || files.lastProfileLimit != 1 { + t.Fatalf("profile photo args offset=%d max_id=%d limit=%d, want -1/exact/1", files.lastProfileOffset, files.lastProfileMaxID, files.lastProfileLimit) + } +} + +func TestPhotosGetUserPhotosProjectsStoryPeerFlags(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + target, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002102", FirstName: "StoryTarget"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 23, Phone: "15550002103", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}, + ID: 4, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + files := &fakeFiles{profilePhotosTotal: 0} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Files: files, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, viewer.ID) + if ok, err := r.onStoriesTogglePeerStoriesHidden(reqCtx, &tg.StoriesTogglePeerStoriesHiddenRequest{ + Peer: &tg.InputPeerUser{UserID: target.ID, AccessHash: target.AccessHash}, + Hidden: true, + }); err != nil || !ok { + t.Fatalf("hide target stories = %v, %v", ok, err) + } + + got, err := r.onPhotosGetUserPhotos(reqCtx, &tg.PhotosGetUserPhotosRequest{ + UserID: &tg.InputUser{UserID: target.ID, AccessHash: target.AccessHash}, + Limit: 1, + }) + if err != nil { + t.Fatalf("get user photos: %v", err) + } + photos, ok := got.(*tg.PhotosPhotos) + if !ok { + t.Fatalf("get user photos = %T, want *tg.PhotosPhotos", got) + } + if len(photos.Users) != 1 { + t.Fatalf("photos users = %d, want target user", len(photos.Users)) + } + user, ok := photos.Users[0].(*tg.User) + if !ok { + t.Fatalf("photos user = %T, want *tg.User", photos.Users[0]) + } + recent, ok := user.GetStoriesMaxID() + maxID, hasMaxID := recent.GetMaxID() + if !ok || !hasMaxID || maxID != 4 { + t.Fatalf("photos user stories_max_id = %+v ok=%v hasMaxID=%v, want max_id 4", recent, ok, hasMaxID) + } + if !user.StoriesHidden { + t.Fatalf("photos user stories_hidden = false, want true") + } +} diff --git a/internal/rpc/photos_push_test.go b/internal/rpc/photos_push_test.go index 5e1db46b..291399b1 100644 --- a/internal/rpc/photos_push_test.go +++ b/internal/rpc/photos_push_test.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "strings" "testing" "github.com/gotd/td/clock" @@ -31,9 +32,14 @@ func TestUploadProfilePhotoPushesUpdateToOtherDevices(t *testing.T) { req := &tg.PhotosUploadProfilePhotoRequest{} req.SetFile(&tg.InputFile{ID: 42, Parts: 1, Name: "a.jpg"}) // File 是 flags 可选字段,须 SetFile 置位 - if _, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), req); err != nil { + got, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), req) + if err != nil { t.Fatalf("uploadProfilePhoto: %v", err) } + returnedPhoto, ok := firstPhotosUser(t, got).Photo.(*tg.UserProfilePhoto) + if !ok || returnedPhoto.PhotoID != 778 || returnedPhoto.DCID != 2 { + t.Fatalf("returned self photo = %+v, want photo_id=778 dc_id=2", firstPhotosUser(t, got).Photo) + } snap := sessions.snapshot() if snap.userID != owner.ID { @@ -55,4 +61,156 @@ func TestUploadProfilePhotoPushesUpdateToOtherDevices(t *testing.T) { if len(updates.Users) == 0 { t.Fatal("pushed updates missing self user — other devices cannot refresh avatar") } + pushedUser, ok := updates.Users[0].(*tg.User) + if !ok { + t.Fatalf("pushed user = %T, want *tg.User", updates.Users[0]) + } + pushedPhoto, ok := pushedUser.Photo.(*tg.UserProfilePhoto) + if !ok || pushedPhoto.PhotoID != 778 || pushedPhoto.DCID != 2 { + t.Fatalf("pushed self photo = %+v, want photo_id=778 dc_id=2", pushedUser.Photo) + } +} + +func TestUploadProfilePhotoSupportsAnimatedVideoAndEmojiMarkup(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001002", FirstName: "Owner"}) + sessions := &captureSessions{} + files := &fakeFiles{} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore, appusers.WithPhotoProvider(files)), + Files: files, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + videoReq := &tg.PhotosUploadProfilePhotoRequest{} + videoReq.SetVideo(&tg.InputFile{ID: 43, Parts: 1, Name: "avatar.mp4"}) + videoReq.SetVideoStartTs(0.25) + videoGot, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), videoReq) + if err != nil { + t.Fatalf("upload animated profile video: %v", err) + } + videoPhoto, ok := videoGot.Photo.(*tg.Photo) + if !ok || videoPhoto.ID != 779 || len(videoPhoto.VideoSizes) != 1 { + t.Fatalf("video profile photo = %+v, want photo 779 with video_sizes", videoGot.Photo) + } + videoSize, ok := videoPhoto.VideoSizes[0].(*tg.VideoSize) + if !ok || videoSize.Type != "u" || videoSize.VideoStartTs != 0.25 { + t.Fatalf("video size = %+v, want videoSize u start 0.25", videoPhoto.VideoSizes[0]) + } + selfPhoto := firstPhotosUser(t, videoGot).Photo + userPhoto, ok := selfPhoto.(*tg.UserProfilePhoto) + if !ok || userPhoto.PhotoID != 779 || userPhoto.DCID != 2 || !userPhoto.HasVideo { + t.Fatalf("returned self photo = %+v, want photo 779 dc 2 has_video", selfPhoto) + } + if cur, ok, err := files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, owner.ID, domain.ProfilePhotoKindProfile); err != nil || !ok || cur.ID != 779 { + t.Fatalf("current profile photo = %+v ok=%v err=%v, want 779", cur, ok, err) + } + + markupReq := &tg.PhotosUploadProfilePhotoRequest{} + markupReq.SetVideoEmojiMarkup(&tg.VideoSizeEmojiMarkup{EmojiID: 99, BackgroundColors: []int{0xffffff}}) + markupGot, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), markupReq) + if err != nil { + t.Fatalf("upload emoji profile photo: %v", err) + } + markupPhoto, ok := markupGot.Photo.(*tg.Photo) + if !ok || markupPhoto.ID != 780 || len(markupPhoto.VideoSizes) != 1 { + t.Fatalf("emoji profile photo = %+v, want photo 780 with video_sizes", markupGot.Photo) + } + if _, ok := markupPhoto.VideoSizes[0].(*tg.VideoSizeEmojiMarkup); !ok { + t.Fatalf("emoji video size = %T, want *tg.VideoSizeEmojiMarkup", markupPhoto.VideoSizes[0]) + } + selfPhoto = firstPhotosUser(t, markupGot).Photo + userPhoto, ok = selfPhoto.(*tg.UserProfilePhoto) + if !ok || userPhoto.PhotoID != 780 || userPhoto.DCID != 2 || !userPhoto.HasVideo { + t.Fatalf("returned self photo after emoji = %+v, want photo 780 dc 2 has_video", selfPhoto) + } + + stickerReq := &tg.PhotosUploadProfilePhotoRequest{} + stickerReq.SetVideoEmojiMarkup(&tg.VideoSizeStickerMarkup{ + Stickerset: &tg.InputStickerSetAnimatedEmoji{}, + StickerID: 123, + BackgroundColors: []int{0x112233, 0x445566}, + }) + stickerGot, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), stickerReq) + if err != nil { + t.Fatalf("upload animated emoji sticker profile photo: %v", err) + } + stickerPhoto, ok := stickerGot.Photo.(*tg.Photo) + if !ok || len(stickerPhoto.VideoSizes) != 1 { + t.Fatalf("sticker profile photo = %+v, want photo with video_sizes", stickerGot.Photo) + } + stickerSize, ok := stickerPhoto.VideoSizes[0].(*tg.VideoSizeStickerMarkup) + if !ok { + t.Fatalf("sticker video size = %T, want *tg.VideoSizeStickerMarkup", stickerPhoto.VideoSizes[0]) + } + if _, ok := stickerSize.Stickerset.(*tg.InputStickerSetAnimatedEmoji); !ok { + t.Fatalf("sticker markup set = %T, want inputStickerSetAnimatedEmoji", stickerSize.Stickerset) + } + + comboReq := &tg.PhotosUploadProfilePhotoRequest{} + comboReq.SetVideo(&tg.InputFile{ID: 45, Parts: 9, Name: "avatar.mp4"}) + comboReq.SetVideoStartTs(0) + comboReq.SetVideoEmojiMarkup(&tg.VideoSizeEmojiMarkup{ + EmojiID: 1258816259753929, + BackgroundColors: []int{0x536dfe, 0x29b6f6, 0x26a69a, 0x66bb6a}, + }) + comboGot, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), comboReq) + if err != nil { + t.Fatalf("upload Android video+emoji profile photo: %v", err) + } + comboPhoto, ok := comboGot.Photo.(*tg.Photo) + if !ok || len(comboPhoto.VideoSizes) != 2 { + t.Fatalf("combo profile photo = %+v, want two video_sizes", comboGot.Photo) + } + if _, ok := comboPhoto.VideoSizes[0].(*tg.VideoSize); !ok { + t.Fatalf("combo first video size = %T, want *tg.VideoSize", comboPhoto.VideoSizes[0]) + } + if _, ok := comboPhoto.VideoSizes[1].(*tg.VideoSizeEmojiMarkup); !ok { + t.Fatalf("combo second video size = %T, want *tg.VideoSizeEmojiMarkup", comboPhoto.VideoSizes[1]) + } +} + +func TestUploadProfilePhotoFallbackEmojiAndInvalidFlags(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001003", FirstName: "Owner"}) + files := &fakeFiles{} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore, appusers.WithPhotoProvider(files)), + Files: files, + }, zaptest.NewLogger(t), clock.System) + + profileReq := &tg.PhotosUploadProfilePhotoRequest{} + profileReq.SetFile(&tg.InputFile{ID: 42, Parts: 1, Name: "avatar.jpg"}) + if _, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), profileReq); err != nil { + t.Fatalf("seed profile photo: %v", err) + } + + fallbackReq := &tg.PhotosUploadProfilePhotoRequest{} + fallbackReq.SetFallback(true) + fallbackReq.SetVideoEmojiMarkup(&tg.VideoSizeEmojiMarkup{EmojiID: 100, BackgroundColors: []int{0x112233}}) + fallbackGot, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), fallbackReq) + if err != nil { + t.Fatalf("upload fallback emoji profile photo: %v", err) + } + if photo, ok := fallbackGot.Photo.(*tg.Photo); !ok || photo.ID != 780 { + t.Fatalf("fallback photo = %+v, want photo 780", fallbackGot.Photo) + } + if cur, ok, err := files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, owner.ID, domain.ProfilePhotoKindFallback); err != nil || !ok || cur.ID != 780 { + t.Fatalf("current fallback photo = %+v ok=%v err=%v, want 780", cur, ok, err) + } + if cur, ok, err := files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, owner.ID, domain.ProfilePhotoKindProfile); err != nil || !ok || cur.ID != 778 { + t.Fatalf("current profile photo after fallback = %+v ok=%v err=%v, want preserved 778", cur, ok, err) + } + + invalidReq := &tg.PhotosUploadProfilePhotoRequest{} + invalidReq.SetFile(&tg.InputFile{ID: 44, Parts: 1, Name: "avatar.jpg"}) + invalidReq.SetVideoEmojiMarkup(&tg.VideoSizeEmojiMarkup{EmojiID: 101, BackgroundColors: []int{0x445566}}) + if _, err := r.onPhotosUploadProfilePhoto(WithUserID(ctx, owner.ID), invalidReq); err == nil || !strings.Contains(err.Error(), "PHOTO_INVALID") { + t.Fatalf("invalid mixed profile photo error = %v, want PHOTO_INVALID", err) + } + if cur, ok, err := files.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, owner.ID, domain.ProfilePhotoKindProfile); err != nil || !ok || cur.ID != 778 { + t.Fatalf("current profile photo after invalid = %+v ok=%v err=%v, want preserved 778", cur, ok, err) + } } diff --git a/internal/rpc/premium.go b/internal/rpc/premium.go index b6b83d92..2b91caeb 100644 --- a/internal/rpc/premium.go +++ b/internal/rpc/premium.go @@ -2,18 +2,15 @@ package rpc import ( "context" + "errors" + "fmt" + "time" "github.com/gotd/td/tg" "telesrv/internal/domain" ) -const ( - maxPremiumBoostsListLimit = 100 - maxPremiumBoostsOffsetBytes = 128 - maxPremiumApplyBoostSlots = 16 -) - func (r *Router) registerPremium(d *tg.ServerDispatcher) { d.OnPremiumGetBoostsStatus(r.onPremiumGetBoostsStatus) d.OnPremiumGetBoostsList(r.onPremiumGetBoostsList) @@ -23,30 +20,67 @@ func (r *Router) registerPremium(d *tg.ServerDispatcher) { } func (r *Router) onPremiumGetBoostsStatus(ctx context.Context, peer tg.InputPeerClass) (*tg.PremiumBoostsStatus, error) { - if _, _, err := r.premiumBoostChannelView(ctx, peer, false); err != nil { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + userID, view, err := r.premiumBoostChannelView(ctx, peer, false) + if err != nil { return nil, err } - return emptyPremiumBoostsStatus(), nil + status, err := r.deps.Channels.GetPremiumBoostStatus(ctx, userID, view.Channel.ID, int(time.Now().Unix())) + if err != nil { + return nil, premiumBoostErr(err) + } + return tgPremiumBoostsStatus(view.Channel.ID, status), nil } func (r *Router) onPremiumGetBoostsList(ctx context.Context, req *tg.PremiumGetBoostsListRequest) (*tg.PremiumBoostsList, error) { - if req.Limit < 0 || req.Limit > maxPremiumBoostsListLimit || len(req.Offset) > maxPremiumBoostsOffsetBytes { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if req == nil { + return nil, inputRequestInvalidErr() + } + if req.Limit < 0 || req.Limit > domain.MaxPremiumBoostsListLimit || len(req.Offset) > domain.MaxPremiumBoostsOffsetBytes { return nil, limitInvalidErr() } - if _, _, err := r.premiumBoostChannelView(ctx, req.Peer, true); err != nil { + userID, view, err := r.premiumBoostChannelView(ctx, req.Peer, true) + if err != nil { return nil, err } - return emptyPremiumBoostsList(), nil + limit := req.Limit + if limit == 0 { + limit = domain.MaxPremiumBoostsListLimit + } + list, err := r.deps.Channels.ListPremiumBoosts(ctx, userID, view.Channel.ID, req.Gifts, req.Offset, limit, int(time.Now().Unix())) + if err != nil { + return nil, premiumBoostErr(err) + } + return r.tgPremiumBoostsList(ctx, userID, list), nil } func (r *Router) onPremiumGetMyBoosts(ctx context.Context) (*tg.PremiumMyBoosts, error) { - if _, _, err := r.currentUserID(ctx); err != nil { - return nil, internalErr() + if r.deps.Channels == nil { + return nil, notImplementedErr() } - return emptyPremiumMyBoosts(), nil + userID, premiumUntil, err := r.currentPremiumUntil(ctx) + if err != nil { + return nil, err + } + my, err := r.deps.Channels.GetPremiumMyBoosts(ctx, userID, int(time.Now().Unix()), premiumUntil) + if err != nil { + return nil, premiumBoostErr(err) + } + return r.tgPremiumMyBoosts(ctx, userID, my), nil } func (r *Router) onPremiumApplyBoost(ctx context.Context, req *tg.PremiumApplyBoostRequest) (*tg.PremiumMyBoosts, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if req == nil { + return nil, inputRequestInvalidErr() + } slots, ok := req.GetSlots() if !ok { return nil, tgerr400("BOOSTS_EMPTY") @@ -54,30 +88,52 @@ func (r *Router) onPremiumApplyBoost(ctx context.Context, req *tg.PremiumApplyBo if len(slots) == 0 { return nil, tgerr400("SLOTS_EMPTY") } - if len(slots) > maxPremiumApplyBoostSlots { + if len(slots) > domain.MaxPremiumBoostSlotsPerApply { return nil, limitInvalidErr() } for _, slot := range slots { - if slot < 0 { + if slot != domain.DefaultPremiumBoostSlotID { return nil, tgerr400("SLOTS_INVALID") } } - if _, _, err := r.premiumBoostChannelView(ctx, req.Peer, false); err != nil { + userID, view, err := r.premiumBoostChannelView(ctx, req.Peer, false) + if err != nil { return nil, err } - return emptyPremiumMyBoosts(), nil + _, premiumUntil, err := r.currentPremiumUntil(ctx) + if err != nil { + return nil, err + } + my, err := r.deps.Channels.ApplyPremiumBoost(ctx, userID, view.Channel.ID, slots, int(time.Now().Unix()), premiumUntil) + if err != nil { + return nil, premiumBoostErr(err) + } + return r.tgPremiumMyBoosts(ctx, userID, my), nil } func (r *Router) onPremiumGetUserBoosts(ctx context.Context, req *tg.PremiumGetUserBoostsRequest) (*tg.PremiumBoostsList, error) { + if r.deps.Channels == nil { + return nil, notImplementedErr() + } + if req == nil { + return nil, inputRequestInvalidErr() + } userID, view, err := r.premiumBoostChannelView(ctx, req.Peer, true) if err != nil { return nil, err } - if _, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.UserID}); err != nil { + ids, err := r.userIDsFromInputUsers(ctx, userID, []tg.InputUserClass{req.UserID}) + if err != nil { return nil, err } - _ = view - return emptyPremiumBoostsList(), nil + if len(ids) != 1 || ids[0] == 0 { + return nil, userIDInvalidErr() + } + list, err := r.deps.Channels.GetPremiumUserBoosts(ctx, userID, view.Channel.ID, ids[0], int(time.Now().Unix())) + if err != nil { + return nil, premiumBoostErr(err) + } + return r.tgPremiumBoostsList(ctx, userID, list), nil } func (r *Router) premiumBoostChannelView(ctx context.Context, peer tg.InputPeerClass, requireAdmin bool) (int64, domain.ChannelView, error) { @@ -116,27 +172,188 @@ func premiumBoostChannelRef(peer tg.InputPeerClass) (channelInputRef, bool) { } } -func emptyPremiumBoostsStatus() *tg.PremiumBoostsStatus { - return &tg.PremiumBoostsStatus{ - Level: 0, - CurrentLevelBoosts: 0, - Boosts: 0, - BoostURL: "", +func (r *Router) currentPremiumUntil(ctx context.Context) (int64, int, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil || userID == 0 { + return 0, 0, internalErr() } + if r.deps.Users == nil { + return userID, 0, nil + } + u, found, err := r.deps.Users.ByID(ctx, userID, userID) + if err != nil { + return 0, 0, internalErr() + } + if !found { + return userID, 0, nil + } + return userID, u.PremiumUntil, nil } -func emptyPremiumBoostsList() *tg.PremiumBoostsList { - return &tg.PremiumBoostsList{ - Count: 0, - Boosts: []tg.Boost{}, - Users: []tg.UserClass{}, +func tgPremiumBoostsStatus(channelID int64, in domain.PremiumBoostStatus) *tg.PremiumBoostsStatus { + out := &tg.PremiumBoostsStatus{ + MyBoost: len(in.MyBoostSlots) > 0, + Level: in.Level, + CurrentLevelBoosts: in.CurrentLevelBoosts, + Boosts: in.Boosts, + BoostURL: fmt.Sprintf("https://telesrv.net/boost?c=%d", channelID), } + if in.GiftBoosts > 0 { + out.SetGiftBoosts(in.GiftBoosts) + } + if in.HasNextLevelBoosts { + out.SetNextLevelBoosts(in.NextLevelBoosts) + } + if in.PremiumAudienceTotal > 0 { + out.SetPremiumAudience(tg.StatsPercentValue{ + Part: float64(in.PremiumAudiencePart), + Total: float64(in.PremiumAudienceTotal), + }) + } + if len(in.MyBoostSlots) > 0 { + out.SetMyBoostSlots(premiumBoostSlotIDs(in.MyBoostSlots)) + } + return out } -func emptyPremiumMyBoosts() *tg.PremiumMyBoosts { +func (r *Router) tgPremiumBoostsList(ctx context.Context, viewerUserID int64, in domain.PremiumBoostList) *tg.PremiumBoostsList { + users := in.Users + if len(users) == 0 { + users = r.domainUsersForIDs(ctx, viewerUserID, premiumBoostUserIDs(in.Boosts)) + } + out := &tg.PremiumBoostsList{ + Count: in.Count, + Boosts: tgBoosts(in.Boosts), + Users: tgUsersForViewer(viewerUserID, users), + } + if in.NextOffset != "" { + out.SetNextOffset(in.NextOffset) + } + return out +} + +func (r *Router) tgPremiumMyBoosts(ctx context.Context, viewerUserID int64, in domain.PremiumMyBoosts) *tg.PremiumMyBoosts { + users := in.Users + if len(users) == 0 { + users = r.domainUsersForIDs(ctx, viewerUserID, premiumBoostPeerUserIDs(in.Slots)) + } return &tg.PremiumMyBoosts{ - MyBoosts: []tg.MyBoost{}, - Chats: []tg.ChatClass{}, - Users: []tg.UserClass{}, + MyBoosts: tgMyBoosts(in.Slots), + Chats: tgChannels(viewerUserID, in.Channels), + Users: tgUsersForViewer(viewerUserID, users), + } +} + +func tgBoosts(slots []domain.PremiumBoostSlot) []tg.Boost { + out := make([]tg.Boost, 0, len(slots)) + for _, slot := range slots { + boost := tg.Boost{ + ID: fmt.Sprintf("%d:%d:%d", slot.UserID, slot.Slot, slot.Date), + Date: slot.Date, + Expires: slot.Expires, + } + if slot.UserID != 0 { + boost.SetUserID(slot.UserID) + } + if slot.Gift { + boost.SetGift(true) + } + if slot.Giveaway { + boost.SetGiveaway(true) + } + if slot.Unclaimed { + boost.SetUnclaimed(true) + } + if slot.GiveawayMsgID > 0 { + boost.SetGiveawayMsgID(slot.GiveawayMsgID) + } + if slot.UsedGiftSlug != "" { + boost.SetUsedGiftSlug(slot.UsedGiftSlug) + } + if slot.Multiplier > 1 { + boost.SetMultiplier(slot.Multiplier) + } + if slot.Stars > 0 { + boost.SetStars(slot.Stars) + } + out = append(out, boost) + } + return out +} + +func tgMyBoosts(slots []domain.PremiumBoostSlot) []tg.MyBoost { + out := make([]tg.MyBoost, 0, len(slots)) + for _, slot := range slots { + item := tg.MyBoost{ + Slot: slot.Slot, + Date: slot.Date, + Expires: slot.Expires, + } + if peer := tgPeer(slot.Peer); peer != nil { + item.SetPeer(peer) + } + if slot.CooldownUntil > 0 { + item.SetCooldownUntilDate(slot.CooldownUntil) + } + out = append(out, item) + } + return out +} + +func premiumBoostUserIDs(slots []domain.PremiumBoostSlot) []int64 { + ids := make([]int64, 0, len(slots)) + for _, slot := range slots { + if slot.UserID != 0 { + ids = append(ids, slot.UserID) + } + } + return ids +} + +func premiumBoostSlotIDs(slots []domain.PremiumBoostSlot) []int { + ids := make([]int, 0, len(slots)) + seen := make(map[int]struct{}, len(slots)) + for _, slot := range slots { + if slot.Slot <= 0 { + continue + } + if _, ok := seen[slot.Slot]; ok { + continue + } + seen[slot.Slot] = struct{}{} + ids = append(ids, slot.Slot) + } + return ids +} + +func premiumBoostPeerUserIDs(slots []domain.PremiumBoostSlot) []int64 { + ids := make([]int64, 0) + for _, slot := range slots { + if slot.Peer.Type == domain.PeerTypeUser && slot.Peer.ID != 0 { + ids = append(ids, slot.Peer.ID) + } + } + return ids +} + +func premiumBoostErr(err error) error { + if seconds, ok := domain.PremiumBoostFloodWaitSeconds(err); ok { + return floodWaitErr(seconds) + } + switch { + case errors.Is(err, domain.ErrPremiumRequired): + return tgerr400("PREMIUM_ACCOUNT_REQUIRED") + case errors.Is(err, domain.ErrBoostNotModified): + return tgerr400("BOOST_NOT_MODIFIED") + case errors.Is(err, domain.ErrChannelAdminRequired): + return tgerr400("CHAT_ADMIN_REQUIRED") + case errors.Is(err, domain.ErrChannelInvalid): + return tgerr400("CHANNEL_INVALID") + case errors.Is(err, domain.ErrChannelPrivate): + return tgerr400("CHANNEL_PRIVATE") + case errors.Is(err, domain.ErrChannelUserBanned): + return tgerr400("USER_BANNED_IN_CHANNEL") + default: + return internalErr() } } diff --git a/internal/rpc/premium_sweeper.go b/internal/rpc/premium_sweeper.go new file mode 100644 index 00000000..0881fb4a --- /dev/null +++ b/internal/rpc/premium_sweeper.go @@ -0,0 +1,95 @@ +package rpc + +import ( + "context" + "time" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// RunPremiumSweeper 周期清理到期会员并通知本人在线 session。 +// +// premium 下发正确性由 hydration 即时派生(premium_expires_at > now)保证, +// 不依赖本 sweeper;这里只负责两件收尾事:把过期行清 NULL(保持索引/语义 +// 干净),以及向该用户全部在线 session 推 updateUser + 最新 self user,让在线 +// 客户端立即降级 UI(updateUser 无 pts,不进 update_events;离线设备重连后由 +// 任意带 self user 的响应自愈)。 +func (r *Router) RunPremiumSweeper(ctx context.Context, interval time.Duration, batch int) { + if interval <= 0 { + interval = time.Minute + } + if batch <= 0 { + batch = 500 + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + r.sweepExpiredPremium(ctx, batch) + } +} + +func (r *Router) sweepExpiredPremium(ctx context.Context, batch int) { + svc, ok := r.deps.Users.(UserPremiumService) + if !ok { + return + } + for { + sweepCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + users, err := svc.SweepExpiredPremium(sweepCtx, r.clock.Now().Unix(), batch) + cancel() + if err != nil { + r.log.Warn("premium sweep failed", zap.Error(err)) + return + } + for _, u := range users { + r.pushPremiumStatusUpdate(ctx, u) + } + // 不满一批说明已扫完当前积压;满批则继续,避免长停机后积压跨多个周期。 + if len(users) < batch { + return + } + } +} + +// viewerPremium 报告 viewer 当前是否有效会员(限额双档判断用,best-effort: +// 服务未接通时按非会员档处理)。 +func (r *Router) viewerPremium(ctx context.Context, userID int64) bool { + svc, ok := r.deps.Users.(UserPremiumStatusService) + return ok && svc.PremiumActive(ctx, userID) +} + +// NotifyUserChanged 是 Admin 用例层可调用的 domain-only hook:账号基础事实 +// 变更后失效 RPC 投影缓存,并向本人在线 session 推 updateUser。它不把 tg.* +// 泄漏给 admin/domain/app,协议对象只在 rpc 边界内构造。 +func (r *Router) NotifyUserChanged(ctx context.Context, u domain.User) error { + if r == nil || u.ID == 0 { + return nil + } + r.invalidateRPCProjectionForUser(u.ID) + r.pushPremiumStatusUpdate(ctx, u) + return nil +} + +// pushPremiumStatusUpdate 向用户本人的全部在线 session 推送会员状态变化。 +// 授予、到期与 admin 认证变更共用:updateUser 触发客户端用随附的 self user +// 对象刷新 premium/verified 等基础 flag(TDesktop processUser 按 flag 翻转)。 +func (r *Router) pushPremiumStatusUpdate(ctx context.Context, u domain.User) { + if u.ID == 0 { + return + } + pushCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + r.pushUserUpdates(pushCtx, u.ID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: u.ID}}, + Users: []tg.UserClass{r.tgSelfUser(u)}, + Date: int(r.clock.Now().Unix()), + }) +} diff --git a/internal/rpc/presence.go b/internal/rpc/presence.go index 29ab780c..24da0a01 100644 --- a/internal/rpc/presence.go +++ b/internal/rpc/presence.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "strconv" "sync" "time" @@ -14,6 +15,25 @@ import ( const userOnlineTTL = 5 * time.Minute const presenceDialogFanoutCandidateLimit = 512 +// onlineNotifyRefreshInterval 是「每 RPC 上线广播」的去重间隔:同一 session 在此间隔内的在线续期 +// 不再向对端重复广播在线态。TDesktop 的在线 TTL 为 5min,客户端 account.updateStatus 周期为分钟级; +// 普通 RPC 只需刷新内存水位,presence fan-out 以分钟级节流即可保持 UI 新鲜。 +const onlineNotifyRefreshInterval = 2 * time.Minute + +type presencePersistMode uint8 + +const ( + presencePersistSync presencePersistMode = iota + presencePersistAsync +) + +// offlineAnnounceGrace 是最后一个连接断开后广播 offline 的去抖宽限。 +// 移动端重连/换 session 都是「先断旧再建新」,瞬时无连接会对全部好友广播一轮 +// offline→online 抖动;宽限期内重新上线则跳过广播。被动查询不受去抖影响: +// presence 条目断连即清,查询回落到持久化 last_seen,语义一致。 +// var 而非 const:测试需要缩短等待。 +var offlineAnnounceGrace = 8 * time.Second + type presenceSessionKey struct { rawAuthKeyID [8]byte sessionID int64 @@ -28,22 +48,66 @@ type presenceTracker struct { mu sync.RWMutex bySession map[presenceSessionKey]presenceSessionState byUser map[int64]map[presenceSessionKey]domain.UserStatus + // offlineTimers 跟踪每个 user 挂起的 offline 广播去抖定时器,使重新上线能取消它, + // 避免断连风暴下 O(N) 个裸 time.AfterFunc 在 runtime timer heap 长期堆积。 + offlineTimers map[int64]*time.Timer } func newPresenceTracker() *presenceTracker { return &presenceTracker{ - bySession: make(map[presenceSessionKey]presenceSessionState), - byUser: make(map[int64]map[presenceSessionKey]domain.UserStatus), + bySession: make(map[presenceSessionKey]presenceSessionState), + byUser: make(map[int64]map[presenceSessionKey]domain.UserStatus), + offlineTimers: make(map[int64]*time.Timer), } } -func (p *presenceTracker) setSessionStatus(key presenceSessionKey, userID int64, status domain.UserStatus) { +// armOfflineTimer 安排(或重置)某 user 的 offline 广播去抖定时器。定时器触发时先把自己 +// 从 map 移除再执行 fire(fire 内部会查在线态做最终去抖),全程不持 p.mu 调用 fire。 +func (p *presenceTracker) armOfflineTimer(userID int64, d time.Duration, fire func()) { if p == nil || userID == 0 { return } p.mu.Lock() + if p.offlineTimers == nil { + p.offlineTimers = make(map[int64]*time.Timer) + } + if old := p.offlineTimers[userID]; old != nil { + old.Stop() + } + p.offlineTimers[userID] = time.AfterFunc(d, func() { + p.mu.Lock() + delete(p.offlineTimers, userID) + p.mu.Unlock() + fire() + }) + p.mu.Unlock() +} + +// cancelOfflineTimer 取消某 user 挂起的 offline 广播去抖定时器(重新上线时调用)。 +func (p *presenceTracker) cancelOfflineTimer(userID int64) { + if p == nil || userID == 0 { + return + } + p.mu.Lock() + if t := p.offlineTimers[userID]; t != nil { + t.Stop() + delete(p.offlineTimers, userID) + } + p.mu.Unlock() +} + +// setSessionStatus 记录 session 的在线态,并返回是否需要向对端「广播在线」(notify)。 +// notify 仅在「本 session 此前已在线、本次仍是在线续期、且距上次广播未超 onlineNotifyRefreshInterval」 +// 时为 false——即每 RPC 的重复在线续期被去重。首次上线/离线/转在线/超刷新间隔一律 true。 +// 整个判定在 p.mu 内原子完成:开群洪峰下并发首帧只有第一个返回 true,其余看到刚写入的新鲜态返回 false。 +func (p *presenceTracker) setSessionStatus(key presenceSessionKey, userID int64, status domain.UserStatus) bool { + if p == nil || userID == 0 { + return true + } + p.mu.Lock() defer p.mu.Unlock() - if old, ok := p.bySession[key]; ok { + old, hadOld := p.bySession[key] + if hadOld { p.removeSessionLocked(key, old.userID) } p.bySession[key] = presenceSessionState{userID: userID, status: status} @@ -53,6 +117,12 @@ func (p *presenceTracker) setSessionStatus(key presenceSessionKey, userID int64, p.byUser[userID] = sessions } sessions[key] = status + if hadOld && old.userID == userID && + old.status.Kind == domain.UserStatusOnline && status.Kind == domain.UserStatusOnline && + old.status.Expires-status.WasOnline > int((userOnlineTTL-onlineNotifyRefreshInterval)/time.Second) { + return false + } + return true } func (p *presenceTracker) clearSession(key presenceSessionKey) { @@ -75,6 +145,32 @@ func (p *presenceTracker) removeSessionLocked(key presenceSessionKey, userID int } } +// expireOnline 把已过期的 online 条目原地降级为 offline,返回受影响用户的去重列表。 +func (p *presenceTracker) expireOnline(now int) []int64 { + if p == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + var users []int64 + seen := map[int64]struct{}{} + for key, st := range p.bySession { + if st.status.Kind != domain.UserStatusOnline || st.status.Expires > now { + continue + } + offline := normalizePresenceStatus(st.status, now) + p.bySession[key] = presenceSessionState{userID: st.userID, status: offline} + if sessions := p.byUser[st.userID]; sessions != nil { + sessions[key] = offline + } + if _, dup := seen[st.userID]; !dup { + seen[st.userID] = struct{}{} + users = append(users, st.userID) + } + } + return users +} + func (p *presenceTracker) statusFor(userID int64, now int) (domain.UserStatus, bool) { if p == nil || userID == 0 { return domain.UserStatus{}, false @@ -128,7 +224,7 @@ func normalizePresenceStatus(status domain.UserStatus, now int) domain.UserStatu return status } -func (r *Router) setPresenceFromContext(ctx context.Context, userID int64, offline bool) domain.UserStatus { +func (r *Router) setPresenceFromContext(ctx context.Context, userID int64, offline bool, persistMode presencePersistMode) (domain.UserStatus, bool) { now := int(r.clock.Now().Unix()) status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now} if !offline { @@ -137,21 +233,68 @@ func (r *Router) setPresenceFromContext(ctx context.Context, userID int64, offli Expires: now + int(userOnlineTTL/time.Second), WasOnline: now, } + // 重新上线:取消可能挂起的 offline 去抖广播定时器。 + r.presence.cancelOfflineTimer(userID) } + notify := true if key, ok := presenceSessionKeyFromContext(ctx); ok { - r.presence.setSessionStatus(key, userID, status) + notify = r.presence.setSessionStatus(key, userID, status) } - r.persistLastSeen(ctx, userID, now) - return r.userPresenceStatusForUser(domain.User{ID: userID, LastSeenAt: now}) + // 在线续期(!offline)去抖;显式 offline 是权威写,强制落库。 + if persistMode == presencePersistAsync { + r.persistLastSeenAsync(ctx, userID, now, !offline) + } else { + r.persistLastSeen(ctx, userID, now, !offline) + } + return r.userPresenceStatusForUser(domain.User{ID: userID, LastSeenAt: now}), notify } func (r *Router) announceSessionOnline(ctx context.Context, userID int64) { if userID == 0 { return } - status := r.setPresenceFromContext(ctx, userID, false) - r.pushUserStatus(ctx, userID, status) - r.pushOnlinePeerStatusesToCurrentSession(ctx, userID) + // bot 不参与 presence:官方 bot 无 status,从不产生 updateUserStatus,也不写 + // last_seen。bot 登录(importBotAuthorization)经此路径,必须整体短路——否则 + // 与 bot 有私聊的用户会收到协议中不存在的 bot 在线/离线广播,bot 也不登记 + // presence 条目,sweeper 因此天然不会遇到 bot。 + if r.userIsBot(ctx, userID) { + return + } + status, notify := r.setPresenceFromContext(ctx, userID, false, presencePersistAsync) + if !notify { + // 本 session 已在线且在刷新间隔内:跳过「向对端广播在线 + 拉对端在线态给本 session」。 + // setPresenceFromContext 已每 RPC 续期在线水位;这里省掉开群洪峰下每 RPC 的扇出 + 推送 + // (实测每 RPC ~70ms,是去分区后开群尾延迟/CPU 的主要残余)。 + return + } + r.pushSessionOnlineAsync(ctx, userID, status) +} + +// userIsBot 报告 userID 是否为 bot 账号(presence 广播豁免用)。仅在连接生命周期 +// 事件(登录/断线/登出)调用,频率低;命中 redis UserCache。 +func (r *Router) userIsBot(ctx context.Context, userID int64) bool { + if userID == 0 || r.deps.Users == nil { + return false + } + // bot 标志不可变 → 永久 in-process 缓存,避免 announceSessionOnline 每 RPC 都发一次重投影 + // Users.ByID。仅缓存已存在的用户结果。命中后纯内存。 + if v, ok := r.botStatus.Load(userID); ok { + return v.(bool) + } + // 冷启动洪峰下用 singleflight 合并并发首查:~50 个并发首帧只打 1 次 PG(其余共享), + // 避免 Users.ByID 重投影 herd(曾让首帧 user_resolve 飙到 ~1.1s)。 + v, _, _ := r.authUserSF.Do("bot:"+strconv.FormatInt(userID, 10), func() (any, error) { + if cached, ok := r.botStatus.Load(userID); ok { + return cached.(bool), nil + } + u, found, err := r.deps.Users.ByID(ctx, userID, userID) + if err != nil || !found { + return false, nil + } + r.botStatus.Store(userID, u.Bot) + return u.Bot, nil + }) + return v.(bool) } func (r *Router) userPresenceStatus(userID int64) domain.UserStatus { @@ -184,11 +327,30 @@ type userLastSeenUpdater interface { UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error } -func (r *Router) persistLastSeen(ctx context.Context, userID int64, lastSeenAt int) { +func (r *Router) reserveLastSeenPersist(userID int64, lastSeenAt int, debounce bool) (userLastSeenUpdater, bool) { if userID == 0 || lastSeenAt <= 0 || r.deps.Users == nil { - return + return nil, false } updater, ok := r.deps.Users.(userLastSeenUpdater) + if !ok { + return nil, false + } + now := r.clock.Now().Unix() + if debounce { + if last, ok := r.lastSeenPersist.Load(userID); ok { + if lastUnix, ok := last.(int64); ok && now-lastUnix < int64(lastSeenPersistDebounce/time.Second) { + return nil, false + } + } + } + r.lastSeenPersist.Store(userID, now) + return updater, true +} + +// persistLastSeen 落库 last_seen。debounce=true 时(在线高频续期)数秒内只落一次 DB; +// debounce=false 是权威写(显式 offline / 断连),从不跳过。 +func (r *Router) persistLastSeen(ctx context.Context, userID int64, lastSeenAt int, debounce bool) { + updater, ok := r.reserveLastSeenPersist(userID, lastSeenAt, debounce) if !ok { return } @@ -197,25 +359,144 @@ func (r *Router) persistLastSeen(ctx context.Context, userID int64, lastSeenAt i } } +func (r *Router) persistLastSeenAsync(ctx context.Context, userID int64, lastSeenAt int, debounce bool) { + updater, ok := r.reserveLastSeenPersist(userID, lastSeenAt, debounce) + if !ok { + return + } + bgCtx, cancel := r.presenceBackgroundContext(ctx, 10*time.Second) + go func() { + defer cancel() + defer func() { + if rec := recover(); rec != nil { + r.log.Error("Update user last seen panicked", zap.Int64("user_id", userID), zap.Any("panic", rec)) + } + }() + if err := updater.UpdateLastSeen(bgCtx, userID, lastSeenAt); err != nil { + r.log.Warn("Update user last seen failed", zap.Int64("user_id", userID), zap.Int("last_seen_at", lastSeenAt), zap.Error(err)) + } + }() +} + +func (r *Router) pushSessionOnlineAsync(ctx context.Context, userID int64, status domain.UserStatus) { + pushCtx, cancel := r.presenceBackgroundContext(ctx, 10*time.Second) + go func() { + defer cancel() + defer func() { + if rec := recover(); rec != nil { + r.log.Error("push session online panicked", zap.Int64("user_id", userID), zap.Any("panic", rec)) + } + }() + r.pushUserStatus(pushCtx, userID, status) + r.pushOnlinePeerStatusesToCurrentSession(pushCtx, userID) + }() +} + +func (r *Router) presenceBackgroundContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + bg, cancel := context.WithTimeout(context.Background(), timeout) + if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { + bg = WithRawAuthKeyID(bg, rawAuthKeyID) + } + if authKeyID, ok := AuthKeyIDFrom(ctx); ok { + bg = WithAuthKeyID(bg, authKeyID) + } + if sessionID, ok := SessionIDFrom(ctx); ok { + bg = WithSessionID(bg, sessionID) + } + if userID, ok := UserIDFrom(ctx); ok { + bg = WithUserID(bg, userID) + } + if layer := LayerFrom(ctx); layer != 0 { + bg = WithLayer(bg, layer) + } + if info, ok := ClientInfoFrom(ctx); ok { + bg = WithClientInfo(bg, info) + } + if invokeWithoutUpdatesFrom(ctx) { + bg = withInvokeWithoutUpdates(bg) + } + return bg, cancel +} + // SessionOffline is called by mtprotoedge when an active connection disappears. // Business-side effects stay here: mtprotoedge only reports lifecycle facts. +// +// 同步路径只做内存清理(presence/clientInfo 条目);持久化与 offline 广播经 +// 去抖宽限后在后台带超时执行——连接关闭发生在 serveConn 退出路径上,同步做 +// DB 写与逐 peer 查询会在断连风暴时放大 DB 压力并拖住 goroutine 退出。 func (r *Router) SessionOffline(rawAuthKeyID [8]byte, sessionID, userID int64, lastForUser bool) { + r.forgetClientSessionInfo(rawAuthKeyID, sessionID) if userID == 0 { return } - ctx := WithSessionID(WithRawAuthKeyID(context.Background(), rawAuthKeyID), sessionID) key := presenceSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID} + r.presence.clearSession(key) if !lastForUser { - r.presence.clearSession(key) return } - now := int(r.clock.Now().Unix()) - status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now} - r.presence.setSessionStatus(key, userID, status) - r.persistLastSeen(ctx, userID, now) + disconnectedAt := int(r.clock.Now().Unix()) + // 用可跟踪的定时器,重新上线时能取消(见 cancelOfflineTimer),避免断连风暴堆积。 + r.presence.armOfflineTimer(userID, offlineAnnounceGrace, func() { + r.announceUserOfflineIfStillGone(rawAuthKeyID, sessionID, userID, disconnectedAt) + }) +} + +// announceUserOfflineIfStillGone 在去抖宽限到期后持久化 last_seen 并广播 offline; +// 宽限期内用户重新上线则整体跳过。WasOnline 取断连时刻,数据不受去抖延迟影响。 +func (r *Router) announceUserOfflineIfStillGone(rawAuthKeyID [8]byte, sessionID, userID int64, disconnectedAt int) { + defer func() { + if rec := recover(); rec != nil { + r.log.Error("announce user offline panicked", zap.Int64("user_id", userID), zap.Any("panic", rec)) + } + }() + if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok && provider.IsUserOnline(userID) { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ctx = WithSessionID(WithRawAuthKeyID(ctx, rawAuthKeyID), sessionID) + // bot 不广播 offline、不写 last_seen(与 announceSessionOnline 对称)。 + if r.userIsBot(ctx, userID) { + return + } + status := domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: disconnectedAt} + // 断连降级是权威 last_seen,强制落库(不去抖)。 + r.persistLastSeen(ctx, userID, disconnectedAt, false) r.pushUserStatus(ctx, userID, status) } +// RunPresenceSweeper 周期把过期的 online presence 降级为 offline 并广播给在线 +// 关注方。客户端正常每隔几分钟 updateStatus 续期;停止续期(后台挂起)的设备 +// 到期后好友应主动收到 offline,而不是等到下次查询才看到状态变化。 +func (r *Router) RunPresenceSweeper(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + r.sweepExpiredPresence(ctx) + } +} + +func (r *Router) sweepExpiredPresence(ctx context.Context) { + now := int(r.clock.Now().Unix()) + for _, userID := range r.presence.expireOnline(now) { + status, ok := r.presence.statusFor(userID, now) + if ok && status.Kind == domain.UserStatusOnline { + continue // 该用户另有未过期的 online session + } + if !ok { + status = domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: now} + } + pushCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + r.pushUserStatus(pushCtx, userID, status) + cancel() + } +} + func presenceSessionKeyFromContext(ctx context.Context) (presenceSessionKey, bool) { sessionID, ok := SessionIDFrom(ctx) if !ok { @@ -234,6 +515,11 @@ func (r *Router) withUserPresence(u domain.User) domain.User { if u.ID == 0 { return u } + // bot 不参与 presence:官方 bot user 不带 status(客户端状态栏恒显 "bot"), + // tg 转换层对 bot 也不会输出 Status。 + if u.Bot { + return u + } u.Status = r.userPresenceStatusForUser(u) return u } @@ -249,25 +535,18 @@ func (r *Router) withUsersPresence(users []domain.User) []domain.User { return out } -func (r *Router) withContactListPresence(list domain.ContactList) domain.ContactList { - if len(list.Contacts) == 0 { - return list +func (r *Router) withDialogListPresence(ctx context.Context, viewerUserID int64, list domain.DialogList) domain.DialogList { + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, msg := range list.Messages { + collectMessagePeerRefs(msg, 0, userIDs, channelIDs) } - out := list - out.Contacts = append([]domain.Contact(nil), list.Contacts...) - for i := range out.Contacts { - out.Contacts[i].User = r.withUserPresence(out.Contacts[i].User) + for _, msg := range list.ChannelMessages { + collectChannelMessagePeerRefs(msg, msg.ChannelID, userIDs, channelIDs) } - return out -} - -func (r *Router) withMessageListPresence(list domain.MessageList) domain.MessageList { - list.Users = r.withUsersPresence(list.Users) - return list -} - -func (r *Router) withDialogListPresence(list domain.DialogList) domain.DialogList { - list.Users = r.withUsersPresence(list.Users) + cache := newViewerPeerCache(r) + list.Users = r.withUsersPresence(mergeDomainUsers(list.Users, cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) + list.Channels = mergeDomainChannels(list.Channels, cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) return list } @@ -278,15 +557,105 @@ func (r *Router) withUserSearchPresence(res domain.UserSearchResult) domain.User } func (r *Router) tgUser(u domain.User) *tg.User { - return tgUser(r.withUserPresence(u)) + return r.withBotProfileFlags(context.Background(), tgUser(r.withUserPresence(u))) } func (r *Router) tgSelfUser(u domain.User) *tg.User { - return tgSelfUser(r.withUserPresence(u)) + return r.withBotProfileFlags(context.Background(), tgSelfUser(r.withUserPresence(u))) } func (r *Router) tgUsers(users []domain.User) []tg.UserClass { - return tgUsers(r.withUsersPresence(users)) + out := tgUsers(r.withUsersPresence(users)) + r.withBotProfileFlagsForUsers(context.Background(), out) + return out +} + +// tgUsersForViewer 与 tgUsers 同样补 presence,但 viewer 自己的 user 走 self 分支 +// (DrKLO 靠 user.self 判定 Saved Messages;self=false 会污染账号缓存)。 +func (r *Router) tgUsersForViewer(viewerUserID int64, users []domain.User) []tg.UserClass { + out := tgUsersForViewer(viewerUserID, r.withUsersPresence(users)) + r.withBotProfileFlagsForUsers(context.Background(), out) + return out +} + +func (r *Router) withBotProfileFlagsForUsers(ctx context.Context, users []tg.UserClass) { + if r.deps.Bots == nil || len(users) == 0 { + return + } + botUsers := make(map[int64][]*tg.User) + ids := make([]int64, 0) + for _, user := range users { + u, ok := user.(*tg.User) + if !ok || u == nil || !u.Bot || u.ID == 0 { + continue + } + if _, ok := botUsers[u.ID]; !ok { + ids = append(ids, u.ID) + } + botUsers[u.ID] = append(botUsers[u.ID], u) + } + if len(ids) == 0 { + return + } + if batch, ok := r.deps.Bots.(botProfileBatchResolver); ok { + if profiles, err := batch.BotInfos(ctx, ids); err == nil { + for id, items := range botUsers { + profile, found := profiles[id] + if !found { + continue + } + for _, u := range items { + applyBotProfileFlags(u, profile) + } + } + return + } + } + for id, items := range botUsers { + profile, found, err := r.deps.Bots.BotInfo(ctx, id) + if err != nil || !found { + continue + } + for _, u := range items { + applyBotProfileFlags(u, profile) + } + } +} + +func (r *Router) withBotProfileFlags(ctx context.Context, out *tg.User) *tg.User { + if out == nil || !out.Bot || r.deps.Bots == nil { + return out + } + profile, found, err := r.deps.Bots.BotInfo(ctx, out.ID) + if err != nil || !found { + return out + } + applyBotProfileFlags(out, profile) + return out +} + +func applyBotProfileFlags(out *tg.User, profile domain.BotProfile) { + if out == nil { + return + } + if profile.ChatHistory { + out.SetBotChatHistory(true) + } + if profile.Nochats { + out.SetBotNochats(true) + } + if profile.InlineGeo { + out.SetBotInlineGeo(true) + } + if profile.InlinePlaceholder != "" { + out.SetBotInlinePlaceholder(profile.InlinePlaceholder) + } + if profile.HasMainApp { + out.SetBotHasMainApp(true) + } + if profile.HasAttachMenu { + out.SetBotAttachMenu(true) + } } func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain.UserStatus) { @@ -301,98 +670,20 @@ func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain Date: int(r.clock.Now().Unix()), Seq: 0, } - r.pushUserUpdates(ctx, userID, update) - for _, recipientID := range r.onlinePresenceRecipientIDs(ctx, userID) { + // presence 是 transient(不写 durable log):未就绪的 session 直接跳过、不进 pending。 + r.pushUserMessageTransient(ctx, userID, "push user status (own sessions)", update) + // 接收者集 = 与本 user 有联系人/私聊关系且当前在线的对端。用「本 user 自己的 + // contacts + dialog 对端 ∩ 在线」一次性算出(onlineRelevantPeerIDs,2 次查询 + 内存过滤), + // 而非遍历全部在线候选逐个 GetPeerDialogs(旧 onlinePrivateDialogPeerIDs 的 O(在线数) N+1, + // 断连/sweeper 风暴下 O(M×512) 串行 PG)。私聊 dialog 双向建行,两种算法得到同一集合。 + for _, recipientID := range r.onlineRelevantPeerIDs(ctx, userID) { if recipientID == userID { continue } - r.pushUserMessage(ctx, recipientID, "push user status", update) + r.pushUserMessageTransient(ctx, recipientID, "push user status", update) } } -func (r *Router) onlinePresenceRecipientIDs(ctx context.Context, userID int64) []int64 { - if userID == 0 { - return nil - } - seen := map[int64]struct{}{} - out := make([]int64, 0) - add := func(ids []int64) { - for _, id := range ids { - if id == 0 || id == userID { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - } - add(r.onlineContactIDs(ctx, userID)) - add(r.onlinePrivateDialogPeerIDs(ctx, userID, seen)) - return out -} - -func (r *Router) onlineContactIDs(ctx context.Context, userID int64) []int64 { - if r.deps.Contacts == nil || userID == 0 { - return nil - } - ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0) - if err != nil || notModified || len(ids) == 0 { - return nil - } - candidates := make([]int64, 0, len(ids)) - seen := make(map[int64]struct{}, len(ids)) - for _, id := range ids { - contactID := int64(id) - if contactID == 0 || contactID == userID { - continue - } - if _, ok := seen[contactID]; ok { - continue - } - seen[contactID] = struct{}{} - candidates = append(candidates, contactID) - } - if len(candidates) == 0 { - return nil - } - if provider, ok := r.deps.Sessions.(OnlineUserProvider); ok { - return provider.OnlineUserIDsForCandidates(candidates, 0) - } - return candidates -} - -func (r *Router) onlinePrivateDialogPeerIDs(ctx context.Context, userID int64, already map[int64]struct{}) []int64 { - if r.deps.Dialogs == nil || userID == 0 { - return nil - } - provider, ok := r.deps.Sessions.(OnlineUserProvider) - if !ok { - return nil - } - candidates := provider.OnlineUserIDs(presenceDialogFanoutCandidateLimit) - if len(candidates) == 0 { - return nil - } - peer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} - out := make([]int64, 0, len(candidates)) - for _, candidateID := range candidates { - if candidateID == 0 || candidateID == userID { - continue - } - if _, ok := already[candidateID]; ok { - continue - } - list, err := r.deps.Dialogs.GetPeerDialogs(ctx, candidateID, []domain.Peer{peer}) - if err != nil || !dialogListHasPeer(list, peer) { - continue - } - out = append(out, candidateID) - } - return out -} - func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, userID int64) { peerIDs := r.onlineRelevantPeerIDs(ctx, userID) if len(peerIDs) == 0 { @@ -419,6 +710,18 @@ func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, use }) } +// presenceCandidateCacheTTL 是 presence fan-out 候选集(联系人 ∪ 私聊对端)的缓存有效期; +// lastSeenPersistDebounce 是在线续期写 last_seen 的去抖窗口。 +const ( + presenceCandidateCacheTTL = 2 * time.Minute + lastSeenPersistDebounce = 25 * time.Second +) + +type presenceCandidateEntry struct { + ids []int64 + expireAt time.Time +} + func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int64 { if userID == 0 { return nil @@ -427,6 +730,25 @@ func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int6 if !ok { return nil } + candidates := r.presenceFanoutCandidates(ctx, userID) + if len(candidates) == 0 { + return nil + } + // online 过滤每次实时进行(不进缓存),所以缓存只影响「候选范围」,不影响在线判定时效。 + return provider.OnlineUserIDsForCandidates(candidates, 0) +} + +// presenceFanoutCandidates 返回 presence fan-out 的候选 peer 集合(联系人 ∪ 私聊对端,online +// 过滤前),带短 TTL 缓存。命中时零 DB 查询;未命中走原 hydration 路径算一次并缓存。候选集 +// 变动很慢(加好友 / 开新私聊),陈旧最多 TTL 秒生效,对 presence(best-effort、transient)可接受。 +// 仅在两条查询都未报错时才写缓存,避免把瞬时错误导致的残缺集合钉住 TTL。 +func (r *Router) presenceFanoutCandidates(ctx context.Context, userID int64) []int64 { + now := r.clock.Now() + if cached, ok := r.presenceCandidateCache.Load(userID); ok { + if entry, ok := cached.(*presenceCandidateEntry); ok && entry.expireAt.After(now) { + return entry.ids + } + } seen := map[int64]struct{}{} candidates := make([]int64, 0) add := func(id int64) { @@ -439,9 +761,12 @@ func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int6 seen[id] = struct{}{} candidates = append(candidates, id) } + failed := false if r.deps.Contacts != nil { ids, notModified, err := r.deps.Contacts.ContactIDs(ctx, userID, 0) - if err == nil && !notModified { + if err != nil { + failed = true + } else if !notModified { for _, id := range ids { add(int64(id)) } @@ -449,7 +774,9 @@ func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int6 } if r.deps.Dialogs != nil { list, err := r.deps.Dialogs.GetDialogs(ctx, userID, domain.DialogFilter{Limit: presenceDialogFanoutCandidateLimit}) - if err == nil { + if err != nil { + failed = true + } else { for _, dialog := range list.Dialogs { if dialog.Peer.Type == domain.PeerTypeUser { add(dialog.Peer.ID) @@ -457,34 +784,8 @@ func (r *Router) onlineRelevantPeerIDs(ctx context.Context, userID int64) []int6 } } } - if len(candidates) == 0 { - return nil + if !failed { + r.presenceCandidateCache.Store(userID, &presenceCandidateEntry{ids: candidates, expireAt: now.Add(presenceCandidateCacheTTL)}) } - return provider.OnlineUserIDsForCandidates(candidates, 0) -} - -func dialogListHasPeer(list domain.DialogList, peer domain.Peer) bool { - for _, dialog := range list.Dialogs { - if dialog.Peer != peer { - continue - } - if dialog.TopMessage != 0 || - dialog.TopMessageDate != 0 || - dialog.ReadInboxMaxID != 0 || - dialog.ReadOutboxMaxID != 0 || - dialog.UnreadCount != 0 || - dialog.UnreadMentions != 0 || - dialog.UnreadReactions != 0 || - dialog.Pinned || - dialog.UnreadMark || - dialog.Draft != nil { - return true - } - for _, msg := range list.Messages { - if msg.Peer == peer { - return true - } - } - } - return false + return candidates } diff --git a/internal/rpc/projection_cache.go b/internal/rpc/projection_cache.go new file mode 100644 index 00000000..e42b180b --- /dev/null +++ b/internal/rpc/projection_cache.go @@ -0,0 +1,76 @@ +package rpc + +import ( + "time" + + "telesrv/internal/readmodelcache" +) + +// projectionCache 是 RPC 层 per-viewer TTL 投影缓存的统一核心,基于 readmodelcache.Cache。 +// 4 个结构同形的投影缓存(userFull/peerSettings/channelFull/channelFullBot)收敛到它, +// 仅在键类型、值类型、clone、失效维度上不同;TTL 与时钟、LRU 单条驱逐、epoch 守卫均由原语承载。 +// +// 这些投影走「外部构建再写回」模式(LoadEpoch → 在 handler 内构建 → StoreIfEpoch),而非 +// GetOrLoad:投影需要 ctx、多次往返与富化,无法塞进单个 load 回调。epoch 守卫确保构建期间 +// 到达的失效不会被 pre-invalidation 快照覆盖(避免陈旧 per-viewer 投影留满一个 TTL)。 +type projectionCache[K comparable, V any] struct { + cache *readmodelcache.Cache[K, V] +} + +func newProjectionCache[K comparable, V any](max int, ttl time.Duration, clock func() time.Time, clone func(V) V) *projectionCache[K, V] { + return &projectionCache[K, V]{ + cache: readmodelcache.New[K, V](readmodelcache.Config[K, V]{ + MaxEntries: max, + TTL: ttl, + Now: clock, + Clone: clone, + }), + } +} + +// Lookup 返回缓存项(TTL 过期视为 miss);clone 由原语在返回边界完成。 +func (c *projectionCache[K, V]) lookup(key K) (V, bool) { + if c == nil { + var zero V + return zero, false + } + return c.cache.Peek(key) +} + +// storeIfEpoch 仅在 epoch 自 loadEpoch 以来未变时写回外部构建的值。 +func (c *projectionCache[K, V]) storeIfEpoch(key K, v V, loadEpoch uint64) { + if c == nil { + return + } + c.cache.StoreIfEpoch(key, v, loadEpoch) +} + +// LoadEpoch 在构建前快照 epoch,与 storeIfEpoch 配对实现外部构建路径的 epoch 守卫。 +func (c *projectionCache[K, V]) LoadEpoch() uint64 { + if c == nil { + return 0 + } + return c.cache.LoadEpoch() +} + +func (c *projectionCache[K, V]) deleteKey(key K) { + if c == nil { + return + } + c.cache.Invalidate(key) +} + +func (c *projectionCache[K, V]) deleteWhere(pred func(K) bool) { + if c == nil { + return + } + c.cache.InvalidateWhere(pred) +} + +// Clear 清空缓存并自增 epoch(listener 重连兜底)。 +func (c *projectionCache[K, V]) Clear() { + if c == nil { + return + } + c.cache.Flush() +} diff --git a/internal/rpc/push.go b/internal/rpc/push.go index c4920f21..4f58a50c 100644 --- a/internal/rpc/push.go +++ b/internal/rpc/push.go @@ -49,6 +49,25 @@ func (r *Router) pushUserMessage(ctx context.Context, userID int64, logMessage s } } +// pushUserMessageTransient 推送 transient(typing/presence)update:未就绪的 session 直接 +// 跳过、不进 pending。实现未提供 TransientSessionBinder 能力时回退到普通 pushUserMessage +// (退化为旧行为:会进 pending,但仍不影响 durable 正确性)。 +func (r *Router) pushUserMessageTransient(ctx context.Context, userID int64, logMessage string, msg bin.Encoder) int { + if r.deps.Sessions == nil || userID == 0 || msg == nil { + return 0 + } + if transient, ok := r.deps.Sessions.(TransientSessionBinder); ok { + sessionID, _ := SessionIDFrom(ctx) + authKeyID, _ := AuthKeyIDFrom(ctx) + sent, err := transient.PushToUserTransientExceptAuthKeySession(ctx, userID, authKeyID, sessionID, proto.MessageFromServer, msg, r.cfg.OutboundPushTimeout) + if err != nil { + r.log.Debug(logMessage, zap.Int64("user_id", userID), zap.Int("sent", sent), zap.Error(err)) + } + return sent + } + return r.pushUserMessage(ctx, userID, logMessage, msg) +} + func (r *Router) pushCurrentSessionMessage(ctx context.Context, logMessage string, msg bin.Encoder) { if r.deps.Sessions == nil || msg == nil { return diff --git a/internal/rpc/rate_limit.go b/internal/rpc/rate_limit.go new file mode 100644 index 00000000..863c6f82 --- /dev/null +++ b/internal/rpc/rate_limit.go @@ -0,0 +1,67 @@ +package rpc + +import ( + "context" + "strconv" + "time" + + "go.uber.org/zap" +) + +const sendRateLimitKeyPrefix = "messages:send:" + +const ( + channelDifferenceRateLimitKeyPrefix = "updates:channeldifference:" + peerDialogsRateLimitKeyPrefix = "messages:peerdialogs:" + defaultCatchupRateWindow = time.Minute +) + +// checkCatchupRateLimit 对 difference 类 catch-up RPC(getChannelDifference / getPeerDialogs)按 +// 每用户频率限速,超限返回 FLOOD_WAIT(设计 Phase 2 / §10.3)。keyPrefix 区分两类(各自独立计数), +// 共用 cfg.CatchupRateLimit/Window 阈值。Limiter 未装配或阈值 <=0 时不限速(行为不变)。 +func (r *Router) checkCatchupRateLimit(ctx context.Context, userID int64, keyPrefix string) error { + if r.deps.Limiter == nil || userID == 0 { + return nil + } + limit := r.cfg.CatchupRateLimit + if limit <= 0 { + return nil + } + window := r.cfg.CatchupRateWindow + if window <= 0 { + window = defaultCatchupRateWindow + } + allowed, retryAfter, err := r.deps.Limiter.AllowN(ctx, keyPrefix+strconv.FormatInt(userID, 10), 1, limit, window) + if err != nil { + return internalErr() + } + if allowed { + return nil + } + r.log.Debug("catch-up rpc rate limited (flood wait)", + zap.Int64("user_id", userID), zap.String("kind", keyPrefix), zap.Int("retry_after", retryAfter)) + return floodWaitErr(retryAfter) +} + +func (r *Router) checkSendRateLimit(ctx context.Context, userID int64, cost int) error { + if r.deps.Limiter == nil || userID == 0 || cost <= 0 { + return nil + } + limit := r.cfg.SendRateLimit + if limit <= 0 { + return nil + } + window := r.cfg.SendRateWindow + if window <= 0 { + window = sendMessageRateWindow + } + allowed, retryAfter, err := r.deps.Limiter.AllowN(ctx, sendRateLimitKeyPrefix+strconv.FormatInt(userID, 10), cost, limit, window) + if err != nil { + return internalErr() + } + if allowed { + return nil + } + r.metrics().MessageRateLimited(retryAfter) + return floodWaitErr(retryAfter) +} diff --git a/internal/rpc/rate_limit_catchup_test.go b/internal/rpc/rate_limit_catchup_test.go new file mode 100644 index 00000000..e0e1560f --- /dev/null +++ b/internal/rpc/rate_limit_catchup_test.go @@ -0,0 +1,71 @@ +package rpc + +import ( + "context" + "testing" + "time" + + "github.com/gotd/td/clock" + "go.uber.org/zap/zaptest" +) + +// TestCheckCatchupRateLimit 锁定 Phase 2 difference 类 catch-up FLOOD_WAIT 行为:配置阈值后 +// 放行返回 nil、超限返回 FLOOD_WAIT 错误;阈值 <=0 或 Limiter 未装配时完全不限速(行为不变)。 +func TestCheckCatchupRateLimit(t *testing.T) { + const userID = int64(7001) + + t.Run("disabled when limit unset", func(t *testing.T) { + lim := &captureRateLimiter{} + r := New(Config{}, Deps{Limiter: lim}, zaptest.NewLogger(t), clock.System) + if err := r.checkCatchupRateLimit(context.Background(), userID, channelDifferenceRateLimitKeyPrefix); err != nil { + t.Fatalf("disabled limit should allow: %v", err) + } + if len(lim.calls) != 0 { + t.Fatalf("limiter should not be consulted when CatchupRateLimit<=0, calls=%v", lim.calls) + } + }) + + t.Run("disabled when limiter nil", func(t *testing.T) { + r := New(Config{CatchupRateLimit: 5}, Deps{}, zaptest.NewLogger(t), clock.System) + if err := r.checkCatchupRateLimit(context.Background(), userID, peerDialogsRateLimitKeyPrefix); err != nil { + t.Fatalf("nil limiter should allow: %v", err) + } + }) + + t.Run("allowed under budget", func(t *testing.T) { + lim := &captureRateLimiter{} + r := New(Config{CatchupRateLimit: 5, CatchupRateWindow: time.Minute}, Deps{Limiter: lim}, zaptest.NewLogger(t), clock.System) + if err := r.checkCatchupRateLimit(context.Background(), userID, channelDifferenceRateLimitKeyPrefix); err != nil { + t.Fatalf("under budget should allow: %v", err) + } + if len(lim.calls) != 1 || lim.calls[0].limit != 5 || lim.calls[0].window != time.Minute || lim.calls[0].cost != 1 { + t.Fatalf("limiter call = %+v, want limit5/window1m/cost1", lim.calls) + } + if lim.calls[0].key != channelDifferenceRateLimitKeyPrefix+"7001" { + t.Fatalf("limiter key = %q, want difference-prefixed per user", lim.calls[0].key) + } + }) + + t.Run("flood wait when over budget", func(t *testing.T) { + lim := &captureRateLimiter{block: true, retryAfter: 7} + r := New(Config{CatchupRateLimit: 5}, Deps{Limiter: lim}, zaptest.NewLogger(t), clock.System) + err := r.checkCatchupRateLimit(context.Background(), userID, peerDialogsRateLimitKeyPrefix) + if err == nil { + t.Fatal("over budget should return FLOOD_WAIT error") + } + // 默认 window 在未配置时回落 defaultCatchupRateWindow。 + if lim.calls[0].window != defaultCatchupRateWindow { + t.Fatalf("window = %v, want default %v when unset", lim.calls[0].window, defaultCatchupRateWindow) + } + }) + + t.Run("separate budgets per kind", func(t *testing.T) { + lim := &captureRateLimiter{} + r := New(Config{CatchupRateLimit: 5}, Deps{Limiter: lim}, zaptest.NewLogger(t), clock.System) + _ = r.checkCatchupRateLimit(context.Background(), userID, channelDifferenceRateLimitKeyPrefix) + _ = r.checkCatchupRateLimit(context.Background(), userID, peerDialogsRateLimitKeyPrefix) + if len(lim.calls) != 2 || lim.calls[0].key == lim.calls[1].key { + t.Fatalf("difference and peerDialogs must use distinct keys: %+v", lim.calls) + } + }) +} diff --git a/internal/rpc/receives_updates_mark_test.go b/internal/rpc/receives_updates_mark_test.go new file mode 100644 index 00000000..f0f5fec3 --- /dev/null +++ b/internal/rpc/receives_updates_mark_test.go @@ -0,0 +1,74 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" +) + +func dispatchForReceivesUpdates(t *testing.T, sessions *captureSessions, wrapWithoutUpdates, loggedIn bool) { + t.Helper() + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System) + + var inner bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&inner); err != nil { + t.Fatalf("encode help.getConfig: %v", err) + } + var in bin.Buffer + if wrapWithoutUpdates { + in.PutID(tg.InvokeWithoutUpdatesRequestTypeID) + } + in.Put(inner.Buf) + + ctx := context.Background() + if loggedIn { + ctx = WithUserID(ctx, 1000000001) + } + if _, err := r.Dispatch(ctx, [8]byte{1}, 42, &in); err != nil { + t.Fatalf("dispatch: %v", err) + } +} + +// TestDispatchMarksSessionReceivesUpdates 验证已登录连接发出的裸 RPC(未包 +// invokeWithoutUpdates)即视为 updates 接收声明。仅靠 updates.getState/getDifference +// 置位会漏掉热恢复重连的客户端:它不重建同步基线,置位永不发生时主动推送一直 +// 暂存直至超时丢弃,表现为另一端消息不再实时同步。 +func TestDispatchMarksSessionReceivesUpdates(t *testing.T) { + sessions := &captureSessions{} + dispatchForReceivesUpdates(t, sessions, false, true) + sessions.mu.Lock() + defer sessions.mu.Unlock() + if !sessions.receives { + t.Fatal("plain RPC from logged-in session must mark receivesUpdates") + } + if sessions.sessionID != 42 { + t.Fatalf("marked session_id = %d, want 42", sessions.sessionID) + } +} + +// TestDispatchSkipsReceivesUpdatesForInvokeWithoutUpdates 验证 invokeWithoutUpdates +// 包装的请求(media/temp 连接)不会把该 session 标记为 updates 接收者。 +func TestDispatchSkipsReceivesUpdatesForInvokeWithoutUpdates(t *testing.T) { + sessions := &captureSessions{} + dispatchForReceivesUpdates(t, sessions, true, true) + sessions.mu.Lock() + defer sessions.mu.Unlock() + if sessions.receives { + t.Fatal("invokeWithoutUpdates-wrapped RPC must not mark receivesUpdates") + } +} + +// TestDispatchSkipsReceivesUpdatesWhenLoggedOut 验证未登录连接的 RPC 不置位。 +func TestDispatchSkipsReceivesUpdatesWhenLoggedOut(t *testing.T) { + sessions := &captureSessions{} + dispatchForReceivesUpdates(t, sessions, false, false) + sessions.mu.Lock() + defer sessions.mu.Unlock() + if sessions.receives { + t.Fatal("RPC without bound user must not mark receivesUpdates") + } +} diff --git a/internal/rpc/router.go b/internal/rpc/router.go index 391de403..de1c89a3 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -13,10 +13,30 @@ import ( "github.com/gotd/td/bin" "github.com/gotd/td/clock" "github.com/gotd/td/tg" + + "telesrv/internal/compat/layerwire" + "telesrv/internal/domain" + "telesrv/internal/observability/dbtrace" ) -// maxWrapperDepth 限制 invokeWithLayer/initConnection 等 wrapper 的嵌套深度,防御恶意构造。 -const maxWrapperDepth = 4 +// maxWrapperDepth 限制 invokeWithLayer/initConnection/invokeAfter 等 wrapper 的嵌套深度,防御恶意构造。 +// 合法客户端的最深包装来自 gotd 数据连接模式的握手初始化: +// invokeWithLayer → invokeWithoutUpdates → initConnection → invokeWithoutUpdates → query(深度 5), +// 官方服务器同样接受。取 8 留余量,仍是小常量、不削弱对无界嵌套的防御。 +const maxWrapperDepth = 8 + +const maxInvokeAfterMsgIDs = 128 + +// tempResolveResult 是 effectiveAuthKeyID 里 temp→perm 解析经 singleflight 共享的结果。 +type tempResolveResult struct { + perm [8]byte + ok bool +} + +const ( + authKeyResolveSingleflightPrefix = "resolve:" + authClientInfoSingleflightPrefix = "authinfo:" +) var ( tlTypeNamesOnce sync.Once @@ -28,26 +48,91 @@ type Config struct { DC int IP string // 对外公布的 DC IP(写入 DCOptions) Port int // 对外公布的 DC 端口 + InstanceID string // 进程内唯一标识,用于跨实例 ephemeral push 去重。 OutboundPushTimeout time.Duration + SendRateLimit int + SendRateWindow time.Duration + // CatchupRateLimit/CatchupRateWindow 限制 difference 类 catch-up RPC(getChannelDifference / + // getPeerDialogs)的每用户频率(设计 Phase 2 / §10.3):nudge 被消费后客户端会触发这两类 + // catch-up,放开大群 nudge 全速前需 FLOOD_WAIT 兜底防风暴打爆 PG。两类各自独立计数、共用同一 + // 阈值。<=0 关闭(默认行为不变)。 + CatchupRateLimit int + CatchupRateWindow time.Duration + // ChannelNudgeMaxTargets 是一次 fan-out 的 >cap nudge 目标上限(设计 Phase 0b 限速兜底); + // <=0 用内置默认 defaultChannelNudgeMaxTargets。 + ChannelNudgeMaxTargets int + // CallSignalingMaxBytes 是 phone.sendSignalingData 单条载荷上限;<=0 不限制。 + CallSignalingMaxBytes int + // CallForceRelay 强制私聊通话 p2p_allowed=false(调试 TURN 中继路径)。 + CallForceRelay bool + // GroupCallMaxParticipants 是群通话单房间参与者上限;<=0 不限制。 + GroupCallMaxParticipants int + // TempKeyResolveCacheTTL 是 PFS temp→perm auth key 解析的进程内缓存有效期。>0 时同一 temp key + // 在 TTL 内复用上次解析、跳过每帧 ResolveAuthKey 的 PG 查询;0(默认/测试)关闭=每帧重校验。 + // 显式撤销会删除协议 auth key、清缓存并断开活跃连接;TTL 只影响自然过期或异常路径下的 + // 下一次重新解析。re-bind 由 onAuthBindTempAuthKey 显式失效,避免跨账号串号。 + TempKeyResolveCacheTTL time.Duration + // TempKeyResolveCacheMaxEntries 是 temp→perm 解析缓存容量;<=0 用内置默认。 + TempKeyResolveCacheMaxEntries int } // Router 把解密后的 RPC 请求按 TypeID 路由到 typed handler(tg.ServerDispatcher)。 // // handler 输入输出均为 gotd/td/tg 类型,各业务域的 handler // 与注册见 help.go / auth.go / users.go / updates.go。Router 本身只负责协议外壳: -// 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates,并兜底未注册 RPC。 +// 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。 type Router struct { - cfg Config - log *zap.Logger - clock clock.Clock - deps Deps - dispatcher *tg.ServerDispatcher - clientInfoMu sync.RWMutex - clientInfo map[clientInfoSessionKey]ClientInfo - authUserMu sync.RWMutex - authUsers map[[8]byte]authUserCacheEntry - authUserSF singleflight.Group - presence *presenceTracker + cfg Config + log *zap.Logger + clock clock.Clock + deps Deps + dispatcher *tg.ServerDispatcher + clientInfoMu sync.RWMutex + clientInfo map[clientInfoSessionKey]clientSessionInfo + authInfo map[[8]byte]clientSessionInfo + authUserMu sync.RWMutex + authUsers map[[8]byte]authUserCacheEntry + authUserSF singleflight.Group + mediaCountSF singleflight.Group + dialogsPinnedSF singleflight.Group + channelFullBotSF singleflight.Group + presence *presenceTracker + callbacks *callbackRegistry + inlines *inlineRegistry + webviews *webViewRegistry + loginTokens *loginTokenRegistry + instanceID string + channelFanout *channelFanoutDispatcher + + // presenceCandidateCache 缓存 presence fan-out 的候选 peer 集合(联系人 ∪ 私聊对端, + // online 过滤前),按 userID 短 TTL;零值 sync.Map 即可用,无需构造器初始化。候选集变动 + // 很慢(加好友/开新私聊),短 TTL 内复用避免 updateStatus 每次重跑 ~25-30 条 hydration 查询。 + presenceCandidateCache sync.Map // userID(int64) -> *presenceCandidateEntry + + // botStatus 永久缓存 userID->是否 bot。bot 标志按账号不可变(BotFather 注册即定,普通用户永不变 bot), + // 故可无 TTL 缓存。userIsBot 在 PFS 连接上被 announceSessionOnline 每 RPC 调用,不缓存则每次一发 + // Users.ByID 重投影——开群洪峰 ~50 并发时退化成 ~300ms herd(既拖尾延迟也飙 PG CPU)。零值即可用。 + botStatus sync.Map // userID(int64) -> bool + // lastSeenPersist 记录每个 user 最近一次 last_seen 落库时刻(unix),用于写去抖: + // updateStatus 高频续期时数秒内只落一次 DB。 + lastSeenPersist sync.Map // userID(int64) -> int64(unix) + // tempKeyResolveCache 缓存 rawTempKeyID -> resolved perm(带过期),容量有界。 + tempKeyResolveCache *tempKeyResolveCache + storyProjectionCache *storyProjectionCache + storyPinnedCache *storyPinnedAvailableCache + storyPinnedListCache *storyPinnedStoriesCache + channelFullBotCache *channelFullBotInfoCache + userFullProjectionCache *userFullProjectionCache + peerSettingsProjectionCache *peerSettingsProjectionCache + channelFullProjectionCache *channelFullProjectionCache + emojiStickers *emojiStickerIndex + notifySettings *notifySettingsCache + stickerCatalog *stickerCatalogCache + accountSettings *accountSettingsCache + // webPageResolveSem 是链接预览异步解析的并发信号量(有界):发送后把 pending 占位 + // 解析为卡片并就地替换。满则丢弃任务(消息留 pending)。nil=未启用(测试可直接调 + // resolvePendingWebPage 同步验证)。 + webPageResolveSem chan struct{} } type clientInfoSessionKey struct { @@ -55,6 +140,13 @@ type clientInfoSessionKey struct { sessionID int64 } +type clientSessionInfo struct { + layer int + clientInfo ClientInfo + hasClientInfo bool + authorizationChecked bool +} + type authUserCacheEntry struct { userID int64 found bool @@ -62,7 +154,13 @@ type authUserCacheEntry struct { // New 创建 Router,由各业务域自行注册其 RPC handler(registerHelp/Auth/Users/Updates)。 func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { - r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, presence: newPresenceTracker()} + instanceID := cfg.InstanceID + if instanceID == "" { + instanceID = fmt.Sprintf("%016x", randomNonZeroInt64()) + } + r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, presence: newPresenceTracker(), callbacks: newCallbackRegistry(), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID} + r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer) + r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency) d := tg.NewServerDispatcher(r.fallback) r.registerHelp(d) @@ -78,24 +176,29 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { r.registerContacts(d) r.registerLangpack(d) r.registerStories(d) + r.registerPhone(d) + r.registerEncrypted(d) r.registerPayments(d) r.registerStats(d) r.registerPremium(d) r.registerAiCompose(d) + r.registerBots(d) r.dispatcher = d return r } // Dispatch 路由一条 RPC 请求:先剥离 invokeWithLayer / initConnection / -// invokeWithoutUpdates 等 wrapper(注入 layer / 客户端信息到 ctx), +// invokeWithoutUpdates / invokeAfter* 等 wrapper(注入 layer / 客户端信息到 ctx), // 再按 TypeID 路由到 typed handler。满足 mtprotoedge.RPCHandler。 func (r *Router) Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int64, b *bin.Buffer) (bin.Encoder, error) { + preStart := r.clock.Now() ctx = WithRawAuthKeyID(ctx, authKeyID) effectiveAuthKeyID, err := r.effectiveAuthKeyID(ctx, authKeyID, sessionID) if err != nil { return nil, internalErr() } + tAuth := r.clock.Now() ctx = WithAuthKeyID(ctx, effectiveAuthKeyID) ctx = WithSessionID(ctx, sessionID) userID, hasUserID, err := r.effectiveUserID(ctx, authKeyID, effectiveAuthKeyID, sessionID) @@ -105,22 +208,105 @@ func (r *Router) Dispatch(ctx context.Context, authKeyID [8]byte, sessionID int6 if hasUserID { ctx = WithUserID(ctx, userID) } - if info, ok := r.clientInfoForSession(ctx); ok { - ctx = WithClientInfo(ctx, info) + tUser := r.clock.Now() + info, hasClientMetadata := r.clientSessionInfo(ctx) + if hasUserID { + if authInfo, ok := r.clientSessionInfoFromAuthorization(ctx, userID, effectiveAuthKeyID, info); ok { + info = mergeClientSessionInfo(info, authInfo) + hasClientMetadata = true + r.rememberClientSessionInfo(ctx, info) + } + } + // 前置鉴权阶段(auth key 解析 / user 重校验 / client info)慢路径告警:超阈值才记,避免刷屏。 + // 常驻观测——正常应 ≪50ms;再现 >50ms 时按 auth_resolve / user_resolve / client_info 三段拆分 + // 即可定位(历史上 client_info ~1s 的根因是 DSN 用 localhost 触发 IPv6 连接回退,已改 127.0.0.1)。 + if r.log != nil { + if tInfo := r.clock.Now(); tInfo.Sub(preStart) > 50*time.Millisecond { + id, _ := b.PeekID() + r.log.Info("slow pre-handler", + zap.String("method", tlTypeName(id)), + zap.Duration("pre_total", tInfo.Sub(preStart)), + zap.Duration("auth_resolve", tAuth.Sub(preStart)), + zap.Duration("user_resolve", tUser.Sub(tAuth)), + zap.Duration("client_info", tInfo.Sub(tUser)), + zap.Int64("session_id", sessionID), + ) + } + } + if hasClientMetadata { + if info.layer != 0 { + ctx = WithLayer(ctx, info.layer) + } + if info.hasClientInfo { + ctx = WithClientInfo(ctx, info.clientInfo) + } } return r.dispatch(ctx, b, 0) } func (r *Router) effectiveAuthKeyID(ctx context.Context, rawAuthKeyID [8]byte, sessionID int64) ([8]byte, error) { + var ( + cached [8]byte + hasCached bool + ) if r.deps.Sessions != nil { if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { if id, ok := scoped.AuthKeyIDForSession(rawAuthKeyID, sessionID); ok { - return id, nil + cached = id + hasCached = true } } else if id, ok := r.deps.Sessions.AuthKeyID(sessionID); ok { - return id, nil + cached = id + hasCached = true } } + if hasCached { + if cached == rawAuthKeyID || r.deps.Auth == nil { + return cached, nil + } + // temp→perm 解析缓存:PFS 连接每帧都要解析一次 temp key(ResolveAuthKey 打 PG)。TTL 内复用 + // 上次解析、跳过 DB。仅当缓存的 perm 仍等于 session binder 当前 perm 才用(rebind 会改 binder + // 且 onAuthBindTempAuthKey / 授权撤销都会显式 Delete 缓存,双保险防跨账号串号和被踢滞后)。 + ttl := r.cfg.TempKeyResolveCacheTTL + if ttl > 0 { + if perm, ok := r.tempKeyResolveCache.Get(rawAuthKeyID, cached, r.clock.Now()); ok { + return perm, nil + } + } + // cold burst 下并发 temp-key 解析用 singleflight 合并:同一 temp key 的 N 个并发 RPC 只打 + // 1 次 PG ResolveAuthKey(其余共享),避免开群/重连首帧 ~50 并发 herd(曾让 auth_resolve 飙到 + // ~1s)。解析结果 + 缓存写入在 SF 内(幂等共享);session 绑定仍每 caller 各自做(按 session)。 + // 顺序调用不合并(SF 仅合并真并发),「每帧重校验」语义与固化测试 resolveCount 不变。 + v, sfErr, _ := r.authUserSF.Do(authKeyResolveSingleflightPrefix+string(rawAuthKeyID[:]), func() (any, error) { + if ttl > 0 { + if perm, ok := r.tempKeyResolveCache.Get(rawAuthKeyID, cached, r.clock.Now()); ok { + return tempResolveResult{perm: perm, ok: true}, nil + } + } + resolved, ok, err := r.deps.Auth.ResolveAuthKey(ctx, rawAuthKeyID) + if err != nil { + return tempResolveResult{}, err + } + if ok && ttl > 0 { + r.tempKeyResolveCache.Store(rawAuthKeyID, resolved, r.clock.Now().Add(ttl), r.clock.Now()) + } + return tempResolveResult{perm: resolved, ok: ok}, nil + }) + if sfErr != nil { + return [8]byte{}, sfErr + } + out := v.(tempResolveResult) + if out.ok { + if out.perm != cached { + r.bindEffectiveAuthKey(rawAuthKeyID, sessionID, out.perm) + } + return out.perm, nil + } + r.tempKeyResolveCache.Delete(rawAuthKeyID) + r.invalidateAuthUserCache(cached) + r.bindEffectiveAuthKey(rawAuthKeyID, sessionID, rawAuthKeyID) + return rawAuthKeyID, nil + } effective := rawAuthKeyID if r.deps.Auth != nil { resolved, ok, err := r.deps.Auth.ResolveAuthKey(ctx, rawAuthKeyID) @@ -131,6 +317,11 @@ func (r *Router) effectiveAuthKeyID(ctx context.Context, rawAuthKeyID [8]byte, s effective = resolved } } + r.bindEffectiveAuthKey(rawAuthKeyID, sessionID, effective) + return effective, nil +} + +func (r *Router) bindEffectiveAuthKey(rawAuthKeyID [8]byte, sessionID int64, effective [8]byte) { if r.deps.Sessions != nil { if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { scoped.BindAuthKeyForSession(rawAuthKeyID, sessionID, effective) @@ -138,7 +329,6 @@ func (r *Router) effectiveAuthKeyID(ctx context.Context, rawAuthKeyID [8]byte, s r.deps.Sessions.BindAuthKey(sessionID, effective) } } - return effective, nil } func (r *Router) effectiveUserID(ctx context.Context, rawAuthKeyID, authKeyID [8]byte, sessionID int64) (int64, bool, error) { @@ -153,26 +343,51 @@ func (r *Router) effectiveUserID(ctx context.Context, rawAuthKeyID, authKeyID [8 if r.deps.Sessions != nil { if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { if userID, resolved := scoped.UserIDResolvedForAuthKey(rawAuthKeyID, sessionID); resolved { + if userID == 0 { + if cachedUserID, ok := r.positiveCachedAuthUser(authKeyID); ok { + scoped.BindUserForAuthKey(rawAuthKeyID, sessionID, cachedUserID) + r.announceSessionOnline(ctx, cachedUserID) + return cachedUserID, true, nil + } + } return userID, userID != 0, nil } } else if userID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved { + if userID == 0 { + if cachedUserID, ok := r.positiveCachedAuthUser(authKeyID); ok { + r.deps.Sessions.BindUser(sessionID, cachedUserID) + r.announceSessionOnline(ctx, cachedUserID) + return cachedUserID, true, nil + } + } return userID, userID != 0, nil } } if r.deps.Auth == nil { return 0, false, nil } - userID, found, err := r.lookupAuthUser(ctx, authKeyID) + var ( + userID int64 + found bool + err error + ) + userID, found, err = r.lookupAuthUser(ctx, authKeyID) if err != nil { return 0, false, err } if r.deps.Sessions != nil { if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { if cachedUserID, resolved := scoped.UserIDResolvedForAuthKey(rawAuthKeyID, sessionID); resolved { - return cachedUserID, cachedUserID != 0, nil + if cachedUserID != 0 || !found { + return cachedUserID, cachedUserID != 0, nil + } + } + } else { + if cachedUserID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved { + if cachedUserID != 0 || !found { + return cachedUserID, cachedUserID != 0, nil + } } - } else if cachedUserID, resolved := r.deps.Sessions.UserIDResolved(sessionID); resolved { - return cachedUserID, cachedUserID != 0, nil } if found { if scoped, ok := r.deps.Sessions.(ScopedSessionBinder); ok { @@ -225,12 +440,23 @@ func (r *Router) cachedAuthUser(authKeyID [8]byte) (int64, bool, bool) { return entry.userID, entry.found, true } +func (r *Router) positiveCachedAuthUser(authKeyID [8]byte) (int64, bool) { + userID, found, ok := r.cachedAuthUser(authKeyID) + if !ok || !found || userID == 0 { + return 0, false + } + return userID, true +} + func (r *Router) setAuthUserCache(authKeyID [8]byte, userID int64, found bool) { r.authUserMu.Lock() defer r.authUserMu.Unlock() if r.authUsers == nil { r.authUsers = make(map[[8]byte]authUserCacheEntry) } + if _, exists := r.authUsers[authKeyID]; !exists { + evictMapEntryIfFullLocked(r.authUsers, maxAuthUsersCached) + } r.authUsers[authKeyID] = authUserCacheEntry{userID: userID, found: found} } @@ -238,7 +464,13 @@ func (r *Router) invalidateAuthUserCache(authKeyID [8]byte) { r.authUserMu.Lock() delete(r.authUsers, authKeyID) r.authUserMu.Unlock() - r.authUserSF.Forget(string(authKeyID[:])) + r.clientInfoMu.Lock() + delete(r.authInfo, authKeyID) + r.clientInfoMu.Unlock() + key := string(authKeyID[:]) + r.authUserSF.Forget(key) + r.authUserSF.Forget(authKeyResolveSingleflightPrefix + key) + r.authUserSF.Forget(authClientInfoSingleflightPrefix + key) } func (r *Router) scopedSessions() (ScopedSessionBinder, bool) { @@ -269,12 +501,41 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.En return nil, fmt.Errorf("decode invokeWithLayer layer: %w", err) } // query 紧跟 layer,buffer 剩余即内层请求。 - return r.dispatch(WithLayer(ctx, layer), b, depth+1) + ctx = WithLayer(ctx, layer) + r.rememberClientLayer(ctx, layer) + return r.dispatch(ctx, b, depth+1) case tg.InvokeWithoutUpdatesRequestTypeID: if err := b.ConsumeID(id); err != nil { return nil, err } + return r.dispatch(withInvokeWithoutUpdates(ctx), b, depth+1) + + case tg.InvokeAfterMsgRequestTypeID: + if err := b.ConsumeID(id); err != nil { + return nil, err + } + if _, err := b.Long(); err != nil { + return nil, fmt.Errorf("decode invokeAfterMsg msg_id: %w", err) + } + return r.dispatch(ctx, b, depth+1) + + case tg.InvokeAfterMsgsRequestTypeID: + if err := b.ConsumeID(id); err != nil { + return nil, err + } + msgIDs, err := b.VectorHeader() + if err != nil { + return nil, fmt.Errorf("decode invokeAfterMsgs msg_ids: %w", err) + } + if msgIDs > maxInvokeAfterMsgIDs { + return nil, fmt.Errorf("decode invokeAfterMsgs msg_ids: too many ids %d", msgIDs) + } + for i := 0; i < msgIDs; i++ { + if _, err := b.Long(); err != nil { + return nil, fmt.Errorf("decode invokeAfterMsgs msg_ids[%d]: %w", i, err) + } + } return r.dispatch(ctx, b, depth+1) case tg.InitConnectionRequestTypeID: @@ -296,8 +557,9 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.En r.log.Debug("initConnection", zap.Int("api_id", req.APIID), zap.String("device", req.DeviceModel), - zap.String("app", req.AppVersion), + zap.String("app_version", req.AppVersion), zap.Int("layer", LayerFrom(ctx)), + zap.String("client_type", string(ClientTypeFrom(ctx))), ) inner, ok := req.Query.(*rawObject) if !ok { @@ -306,17 +568,57 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int) (bin.En return r.dispatch(ctx, &bin.Buffer{Buf: inner.data}, depth+1) default: - if enc, ok, err := r.dispatchCompat(ctx, b, id); ok { - return enc, err + // 入站兼容统一入口(先于鉴权门/dispatcher):layerwire 把老客户端请求升级为 + // canonical(227) 形态——①官方层漂移(生成表,flag-gated 新增→换 4 字节 id) + // ②客户端构造器漂移(client_aliases 纯换 id / client-drift.tl 通用 body 变换: + // 插 flags、按 kind 补默认、类型转换、改名映射)。替代了原先散落在 rpc 的 + // dispatchCompat + 各 handleLegacy* 解码器。提前到鉴权门之前,使后续一切只面对 227 + // 形态(鉴权白名单、dispatcher 均无需再认旧构造器 id)。 + if id != 0 { + clientDrift := layerwire.IsClientDrift(id) + if up, ok, err := layerwire.UpgradeInbound(id, b); ok { + if err != nil { + return nil, err + } + b = up + newID, err := b.PeekID() + if err != nil { + return nil, err + } + id = newID + if clientDrift { + // 客户端漂移多来自未完整 initConnection 的 DrKLO;按既有行为在 + // 类型/层未知时兜底为 android(withAndroidCompatMetadata 自带 unknown 守卫)。 + ctx = r.withAndroidCompatMetadata(ctx) + } + } } + if r.deps.Auth != nil { + if _, ok := UserIDFrom(ctx); !ok && !rpcAllowedWithoutAuthorization(id) { + fields := append([]zap.Field{ + zap.String("method", tlTypeName(id)), + zap.String("type_id", fmt.Sprintf("%#x", id)), + }, r.contextLogFields(ctx)...) + r.log.Info("RPC rejected before authorization", fields...) + return nil, authKeyUnregisteredErr() + } + } + // 任何未包 invokeWithoutUpdates 的已登录 RPC 都把当前 session 视为 updates + // 接收者。仅靠 updates.getState/getDifference 置位会漏掉 DrKLO 热恢复: + // 它重连后不重建同步基线(pts 在进程内存里),只发普通业务请求,置位 + // 永不发生时主动推送会一直暂存直至超时丢弃,表现为另一端消息不再实时同步。 + r.maybeMarkSessionReceivesUpdates(ctx) + dbBefore := dbtrace.SnapshotFromContext(ctx) start := time.Now() enc, err := r.dispatcher.Handle(ctx, b) dur := time.Since(start) + dbDelta := dbtrace.SnapshotFromContext(ctx).Sub(dbBefore) fields := append([]zap.Field{ zap.String("method", tlTypeName(id)), zap.String("type_id", fmt.Sprintf("%#x", id)), zap.Duration("dur", dur), }, r.contextLogFields(ctx)...) + fields = dbtrace.AppendZapFields(fields, "handler_", dbDelta) if err != nil || dur > 100*time.Millisecond { if err != nil { fields = append(fields, zap.Error(err)) @@ -343,7 +645,60 @@ func tlTypeName(id uint32) string { return fmt.Sprintf("%#x", id) } +// maxClientInfoEntries / maxAuthInfoEntries 是客户端元数据缓存的容量上限兜底。 +// 条目含客户端可控字符串且 session_id 由客户端任意生成,无上限时恶意客户端 +// 在单连接上反复换 session_id / 轮换 temp auth key 可线速膨胀直至 OOM。 +// 达到上限后驱逐任意旧条目:受害条目只损失 layer/clientType 缓存, +// 下一次 initConnection 或 authorization 回填即恢复。 +const ( + maxClientInfoEntries = 1 << 16 + maxAuthInfoEntries = 1 << 16 + // maxAuthUsersCached 给 authUsers 授权缓存设容量上界,与 clientInfo/authInfo 一致。 + // 原本无任何上限:设备轮换 temp 键而不显式登出时每个新 authKeyID 永久累积一条, + // 只靠 logout/reset 的显式 invalidate 清理。达上限驱逐任意旧条目,下次按需回查回填。 + maxAuthUsersCached = 1 << 16 +) + func (r *Router) rememberClientInfo(ctx context.Context, info ClientInfo) { + info = normalizeClientInfo(info) + layer := LayerFrom(ctx) + r.mutateClientSessionInfo(ctx, func(sessionInfo *clientSessionInfo) { + sessionInfo.clientInfo = info + sessionInfo.hasClientInfo = true + if layer != 0 { + sessionInfo.layer = layer + } + }) +} + +func (r *Router) rememberClientLayer(ctx context.Context, layer int) { + r.mutateClientSessionInfo(ctx, func(sessionInfo *clientSessionInfo) { + sessionInfo.layer = layer + }) +} + +// NegotiatedLayer returns the TL layer the given session negotiated via +// invokeWithLayer/initConnection. It is keyed first by (auth_key, session) then +// falls back to the stable auth_key — so a reconnect with a new session_id still +// inherits the layer within the process lifetime. ok=false means no layer was +// ever observed (cold connection, or the in-memory entry was evicted): callers +// MUST NOT overwrite a connection's last-known layer in that case, only treat it +// as canonical (227) when no value was ever recorded. +func (r *Router) NegotiatedLayer(authKeyID [8]byte, sessionID int64) (int, bool) { + r.clientInfoMu.RLock() + defer r.clientInfoMu.RUnlock() + if info, ok := r.clientInfo[clientInfoSessionKey{rawAuthKeyID: authKeyID, sessionID: sessionID}]; ok && info.layer != 0 { + return info.layer, true + } + if info, ok := r.authInfo[authKeyID]; ok && info.layer != 0 { + return info.layer, true + } + return currentClientLayer, false +} + +// mutateClientSessionInfo 在单个临界区内完成「读旧值-修改-写回」,避免 +// RLock 读出与 Lock 写回之间被并发写覆盖的窗口。 +func (r *Router) mutateClientSessionInfo(ctx context.Context, mutate func(*clientSessionInfo)) { rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) if !ok { return @@ -355,37 +710,211 @@ func (r *Router) rememberClientInfo(ctx context.Context, info ClientInfo) { r.clientInfoMu.Lock() defer r.clientInfoMu.Unlock() if r.clientInfo == nil { - r.clientInfo = make(map[clientInfoSessionKey]ClientInfo) + r.clientInfo = make(map[clientInfoSessionKey]clientSessionInfo) + } + sessionKey := clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID} + sessionInfo, exists := r.clientInfo[sessionKey] + mutate(&sessionInfo) + if !exists { + evictMapEntryIfFullLocked(r.clientInfo, maxClientInfoEntries) + } + r.clientInfo[sessionKey] = sessionInfo + r.rememberAuthClientInfoLocked(rawAuthKeyID, sessionInfo) + if authKeyID, ok := AuthKeyIDFrom(ctx); ok { + r.rememberAuthClientInfoLocked(authKeyID, sessionInfo) } - r.clientInfo[clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}] = info } -func (r *Router) clientInfoForSession(ctx context.Context) (ClientInfo, bool) { +func (r *Router) rememberClientSessionInfo(ctx context.Context, sessionInfo clientSessionInfo) { rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) if !ok { - return ClientInfo{}, false + return } sessionID, ok := SessionIDFrom(ctx) if !ok { - return ClientInfo{}, false + return + } + r.clientInfoMu.Lock() + defer r.clientInfoMu.Unlock() + if r.clientInfo == nil { + r.clientInfo = make(map[clientInfoSessionKey]clientSessionInfo) + } + sessionKey := clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID} + if _, exists := r.clientInfo[sessionKey]; !exists { + evictMapEntryIfFullLocked(r.clientInfo, maxClientInfoEntries) + } + r.clientInfo[sessionKey] = mergeClientSessionInfo(r.clientInfo[sessionKey], sessionInfo) + r.rememberAuthClientInfoLocked(rawAuthKeyID, sessionInfo) + if authKeyID, ok := AuthKeyIDFrom(ctx); ok { + r.rememberAuthClientInfoLocked(authKeyID, sessionInfo) + } +} + +func (r *Router) rememberAuthClientInfoLocked(authKeyID [8]byte, info clientSessionInfo) { + if r.authInfo == nil { + r.authInfo = make(map[[8]byte]clientSessionInfo) + } + if _, exists := r.authInfo[authKeyID]; !exists { + evictMapEntryIfFullLocked(r.authInfo, maxAuthInfoEntries) + } + current := r.authInfo[authKeyID] + r.authInfo[authKeyID] = mergeClientSessionInfo(current, info) +} + +// forgetClientSessionInfo 随连接下线移除该 session 的元数据缓存条目,并清掉以该 raw +// auth_key 为键的 authInfo 兜底条目,使 authInfo 收敛到活跃 raw auth key(主导的单 +// session/key 场景下严格回收)。共享同一 raw auth_key 的其它 session 若仍在线,会在下一次 +// initConnection/authorization 回填——authInfo 只是廉价的元数据兜底。temp→perm 解析后以 +// 业务 perm key 为键的条目仍靠容量上限兜底(SessionOffline 不带业务 key,无法在此精确清理)。 +func (r *Router) forgetClientSessionInfo(rawAuthKeyID [8]byte, sessionID int64) { + r.clientInfoMu.Lock() + delete(r.clientInfo, clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}) + delete(r.authInfo, rawAuthKeyID) + r.clientInfoMu.Unlock() +} + +func evictMapEntryIfFullLocked[K comparable, V any](m map[K]V, limit int) { + if len(m) < limit { + return + } + for k := range m { + delete(m, k) + return + } +} + +func (r *Router) clientSessionInfo(ctx context.Context) (clientSessionInfo, bool) { + rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx) + if !ok { + return clientSessionInfo{}, false + } + sessionID, ok := SessionIDFrom(ctx) + if !ok { + return clientSessionInfo{}, false } r.clientInfoMu.RLock() defer r.clientInfoMu.RUnlock() info, ok := r.clientInfo[clientInfoSessionKey{rawAuthKeyID: rawAuthKeyID, sessionID: sessionID}] + if authInfo, authOK := r.authInfo[rawAuthKeyID]; authOK { + info = mergeClientSessionInfo(info, authInfo) + ok = true + } + if authKeyID, hasAuthKeyID := AuthKeyIDFrom(ctx); hasAuthKeyID { + if authInfo, authOK := r.authInfo[authKeyID]; authOK { + info = mergeClientSessionInfo(info, authInfo) + ok = true + } + } return info, ok } +func (r *Router) cachedResolvedAuthClientInfo(authKeyID [8]byte) (clientSessionInfo, bool) { + r.clientInfoMu.RLock() + defer r.clientInfoMu.RUnlock() + info, ok := r.authInfo[authKeyID] + if !ok || clientSessionInfoNeedsAuthorization(info) { + return clientSessionInfo{}, false + } + return info, true +} + +func mergeClientSessionInfo(base, fallback clientSessionInfo) clientSessionInfo { + if base.layer == 0 { + base.layer = fallback.layer + } + if !base.hasClientInfo && fallback.hasClientInfo { + base.clientInfo = fallback.clientInfo + base.hasClientInfo = true + } + if fallback.authorizationChecked { + base.authorizationChecked = true + } + return base +} + +func (r *Router) clientSessionInfoFromAuthorization(ctx context.Context, userID int64, authKeyID [8]byte, current clientSessionInfo) (clientSessionInfo, bool) { + if !clientSessionInfoNeedsAuthorization(current) || r.deps.Auth == nil || userID == 0 { + return clientSessionInfo{}, false + } + v, err, _ := r.authUserSF.Do(authClientInfoSingleflightPrefix+string(authKeyID[:]), func() (any, error) { + if cached, ok := r.cachedResolvedAuthClientInfo(authKeyID); ok { + return cached, nil + } + item, found, err := r.deps.Auth.Authorization(ctx, authKeyID) + if err != nil { + return clientSessionInfo{}, err + } + if !found || item.UserID != userID || item.PasswordPending { + return clientSessionInfo{authorizationChecked: true}, nil + } + return clientSessionInfoFromAuthorizationRecord(item, current), nil + }) + if err != nil { + return clientSessionInfo{}, false + } + return v.(clientSessionInfo), true +} + +func clientSessionInfoFromAuthorizationRecord(item domain.Authorization, current clientSessionInfo) clientSessionInfo { + info := clientSessionInfo{ + layer: item.Layer, + authorizationChecked: true, + clientInfo: ClientInfo{ + APIID: item.APIID, + DeviceModel: item.DeviceModel, + SystemVersion: item.SystemVersion, + AppVersion: item.AppVersion, + Type: ClientType(item.Platform), + }, + } + info.clientInfo = normalizeClientInfo(info.clientInfo) + info.hasClientInfo = info.clientInfo.ClientType() != ClientTypeUnknown || + info.clientInfo.DeviceModel != "" || + info.clientInfo.SystemVersion != "" || + info.clientInfo.AppVersion != "" || + info.clientInfo.APIID != 0 + if info.layer == 0 { + if current.layer != 0 { + info.layer = current.layer + } else if info.clientInfo.ClientType() != ClientTypeUnknown { + info.layer = currentClientLayer + } + } + return info +} + +func clientSessionInfoNeedsAuthorization(info clientSessionInfo) bool { + if info.authorizationChecked { + return false + } + return info.layer == 0 || !info.hasClientInfo || info.clientInfo.ClientType() == ClientTypeUnknown +} + // fallback 处理未注册的 RPC:记录到 compatibility trace(落兼容矩阵), // 返回 NOT_IMPLEMENTED rpc_error 让客户端继续运行而非断连。 func (r *Router) fallback(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { + // DrKLO 12.8.1 的 theme 方法构造器比 gotd schema 新,dispatcher 匹配不上; + // 在落到「未实现」前先按 DrKLO 字段序手动解码处理。 + if enc, handled, err := r.tryLegacyThemeRPC(ctx, b); handled { + return enc, err + } id, _ := b.PeekID() - fields := append([]zap.Field{zap.String("type_id", fmt.Sprintf("%#x", id))}, r.contextLogFields(ctx)...) + fields := append([]zap.Field{ + zap.String("method", tlTypeName(id)), + zap.String("type_id", fmt.Sprintf("%#x", id)), + }, r.contextLogFields(ctx)...) r.log.Warn("Unhandled RPC (compatibility trace)", fields...) return nil, notImplementedErr() } func (r *Router) contextLogFields(ctx context.Context) []zap.Field { - fields := []zap.Field{zap.Int("layer", LayerFrom(ctx))} + fields := []zap.Field{ + zap.Int("layer", LayerFrom(ctx)), + zap.String("client_type", string(ClientTypeFrom(ctx))), + } + if info, ok := ClientInfoFrom(ctx); ok && info.AppVersion != "" { + fields = append(fields, zap.String("app_version", info.AppVersion)) + } if sessionID, ok := SessionIDFrom(ctx); ok { fields = append(fields, zap.Int64("session_id", sessionID)) } diff --git a/internal/rpc/router_auth_cache_test.go b/internal/rpc/router_auth_cache_test.go new file mode 100644 index 00000000..6443d735 --- /dev/null +++ b/internal/rpc/router_auth_cache_test.go @@ -0,0 +1,196 @@ +package rpc + +import ( + "context" + "encoding/binary" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + "testing" + "time" +) + +func TestDispatchPromotesNegativeSessionCacheFromPositiveAuthCache(t *testing.T) { + authKeyID := [8]byte{0x91, 0x91, 0x91, 0x91, 0x91, 0x91, 0x91, 0x91} + const ( + sessionID = int64(300) + userID = int64(1000000001) + ) + sessions := &captureSessions{} + sessions.BindAuthKey(sessionID, authKeyID) + sessions.BindUser(sessionID, 0) + auth := &captureAuthService{} + r := New(Config{}, Deps{ + Auth: auth, + Files: &fakeFiles{}, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + r.setAuthUserCache(authKeyID, userID, true) + + var in bin.Buffer + if err := (&tg.UploadSaveFilePartRequest{FileID: 10, FilePart: 0, Bytes: []byte{1}}).Encode(&in); err != nil { + t.Fatalf("encode upload part: %v", err) + } + enc, err := r.Dispatch(context.Background(), authKeyID, sessionID, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if box, ok := enc.(*tg.BoolBox); !ok { + t.Fatalf("dispatch result = %T, want *tg.BoolBox", enc) + } else if _, ok := box.Bool.(*tg.BoolTrue); !ok { + t.Fatalf("dispatch bool = %T, want BoolTrue", box.Bool) + } + gotSession := sessions.snapshot() + if gotSession.userID != userID || !gotSession.userResolved { + t.Fatalf("session user = %d resolved %v, want %d/true", gotSession.userID, gotSession.userResolved, userID) + } + if auth.userIDCount != 0 { + t.Fatalf("auth UserID lookups = %d, want 0", auth.userIDCount) + } +} + +func TestBindTempAuthKeyClearsNegativeUserCache(t *testing.T) { + var tempAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55} + var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11} + sessions := &captureSessions{} + auth := &captureAuthService{} + r := New(Config{}, Deps{ + Auth: auth, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + req := &tg.AuthBindTempAuthKeyRequest{ + PermAuthKeyID: int64(binary.LittleEndian.Uint64(permAuthKeyID[:])), + Nonce: 2, + ExpiresAt: int(time.Now().Add(time.Hour).Unix()), + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 123, &in); err != nil { + t.Fatalf("dispatch: %v", err) + } + if auth.userIDCount != 1 { + t.Fatalf("user lookup count = %d, want one negative lookup before temp binding", auth.userIDCount) + } + gotSession := sessions.snapshot() + if gotSession.authKeyID != permAuthKeyID || !gotSession.authKeyResolved { + t.Fatalf("session auth key = %x resolved %v, want perm %x", gotSession.authKeyID, gotSession.authKeyResolved, permAuthKeyID) + } + if gotSession.userResolved || gotSession.userID != 0 { + t.Fatalf("negative user cache = user %d resolved %v, want cleared after auth key switch", gotSession.userID, gotSession.userResolved) + } +} + +func TestDispatchRevalidatesCachedTempAuthKeyBinding(t *testing.T) { + var tempAuthKeyID = [8]byte{0x65, 0x65, 0x65, 0x65, 0x65, 0x65, 0x65, 0x65} + var permAuthKeyID = [8]byte{0x21, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21, 0x21} + sessions := &captureSessions{} + auth := &captureAuthService{ + resolvedAuthKeyID: permAuthKeyID, + hasResolved: true, + userID: 1000000001, + } + r := New(Config{}, Deps{ + Auth: auth, + Files: &fakeFiles{}, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + var first bin.Buffer + if err := (&tg.UploadSaveFilePartRequest{FileID: 10, FilePart: 0, Bytes: []byte{1}}).Encode(&first); err != nil { + t.Fatalf("encode first upload part: %v", err) + } + if enc, err := r.Dispatch(context.Background(), tempAuthKeyID, 123, &first); err != nil { + t.Fatalf("first dispatch: %v", err) + } else if box, ok := enc.(*tg.BoolBox); !ok { + t.Fatalf("first dispatch result = %T, want *tg.BoolBox", enc) + } else if _, ok := box.Bool.(*tg.BoolTrue); !ok { + t.Fatalf("first dispatch bool = %T, want BoolTrue", box.Bool) + } + gotSession := sessions.snapshot() + if gotSession.authKeyID != permAuthKeyID || gotSession.userID != 1000000001 { + t.Fatalf("session after valid temp binding = auth %x user %d, want perm/user", gotSession.authKeyID, gotSession.userID) + } + + auth.hasResolved = false + auth.resolvedAuthKeyID = [8]byte{} + auth.userID = 0 + var second bin.Buffer + if err := (&tg.UploadSaveFilePartRequest{FileID: 10, FilePart: 1, Bytes: []byte{2}}).Encode(&second); err != nil { + t.Fatalf("encode second upload part: %v", err) + } + if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 123, &second); err == nil || !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") { + t.Fatalf("second dispatch err = %v, want AUTH_KEY_UNREGISTERED after temp binding no longer resolves", err) + } + gotSession = sessions.snapshot() + if gotSession.authKeyID != tempAuthKeyID || !gotSession.authKeyResolved { + t.Fatalf("session auth after stale temp binding = %x resolved %v, want raw temp", gotSession.authKeyID, gotSession.authKeyResolved) + } + if gotSession.userID != 0 || !gotSession.userResolved { + t.Fatalf("session user after stale temp binding = %d resolved %v, want 0/true", gotSession.userID, gotSession.userResolved) + } + if auth.resolveCount != 2 { + t.Fatalf("ResolveAuthKey calls = %d, want revalidation on cached temp mapping", auth.resolveCount) + } +} + +func TestDispatchUsesCachedTempAuthKeyUserUntilWriteSideInvalidation(t *testing.T) { + var tempAuthKeyID = [8]byte{0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66} + var permAuthKeyID = [8]byte{0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 0x22} + sessions := &captureSessions{} + auth := &captureAuthService{ + resolvedAuthKeyID: permAuthKeyID, + hasResolved: true, + userID: 1000000001, + } + r := New(Config{}, Deps{ + Auth: auth, + Files: &fakeFiles{}, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + var first bin.Buffer + if err := (&tg.UploadSaveFilePartRequest{FileID: 11, FilePart: 0, Bytes: []byte{1}}).Encode(&first); err != nil { + t.Fatalf("encode first upload part: %v", err) + } + if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 124, &first); err != nil { + t.Fatalf("first dispatch: %v", err) + } + + auth.userID = 0 + var second bin.Buffer + if err := (&tg.UploadSaveFilePartRequest{FileID: 11, FilePart: 1, Bytes: []byte{2}}).Encode(&second); err != nil { + t.Fatalf("encode second upload part: %v", err) + } + if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 124, &second); err != nil { + t.Fatalf("second dispatch should use cached user until write-side invalidation: %v", err) + } + gotSession := sessions.snapshot() + if gotSession.authKeyID != permAuthKeyID || !gotSession.authKeyResolved { + t.Fatalf("session auth = %x resolved %v, want still mapped perm", gotSession.authKeyID, gotSession.authKeyResolved) + } + if gotSession.userID != 1000000001 || !gotSession.userResolved { + t.Fatalf("session user = %d resolved %v, want cached user", gotSession.userID, gotSession.userResolved) + } + if auth.resolveCount != 2 || auth.userIDCount != 1 { + t.Fatalf("lookups = resolve %d user %d, want temp mapping checks and cached user identity", auth.resolveCount, auth.userIDCount) + } + + r.revokeAuthKeySessions(permAuthKeyID) + var third bin.Buffer + if err := (&tg.UploadSaveFilePartRequest{FileID: 11, FilePart: 2, Bytes: []byte{3}}).Encode(&third); err != nil { + t.Fatalf("encode third upload part: %v", err) + } + if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 124, &third); err == nil || !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") { + t.Fatalf("third dispatch err = %v, want AUTH_KEY_UNREGISTERED after write-side invalidation", err) + } + gotSession = sessions.snapshot() + if gotSession.userID != 0 || !gotSession.userResolved { + t.Fatalf("session user after write-side invalidation = %d resolved %v, want 0/true", gotSession.userID, gotSession.userResolved) + } +} diff --git a/internal/rpc/router_dispatch_test.go b/internal/rpc/router_dispatch_test.go new file mode 100644 index 00000000..163ee75e --- /dev/null +++ b/internal/rpc/router_dispatch_test.go @@ -0,0 +1,998 @@ +package rpc + +import ( + "context" + "encoding/binary" + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" + "go.uber.org/zap/zaptest/observer" + "reflect" + "sync" + appauth "telesrv/internal/app/auth" + appdialogs "telesrv/internal/app/dialogs" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" + "time" +) + +// TestDispatchUnwrapsWrappers 验证 Router 能剥离 +// invokeWithLayer(initConnection(help.getConfig)) 并路由到 getConfig handler。 +func TestDispatchUnwrapsWrappers(t *testing.T) { + const ( + dc = 2 + ip = "127.0.0.1" + port = 2398 + layer = 225 + ) + r := New(Config{DC: dc, IP: ip, Port: port}, Deps{}, zaptest.NewLogger(t), clock.System) + + req := &tg.InvokeWithLayerRequest{ + Layer: layer, + Query: &tg.InitConnectionRequest{ + APIID: 123, + DeviceModel: "TestDevice", + SystemVersion: "1.0", + AppVersion: "1.0", + SystemLangCode: "en", + LangPack: "tdesktop", + LangCode: "en", + Query: &tg.HelpGetConfigRequest{}, + }, + } + var b bin.Buffer + if err := req.Encode(&b); err != nil { + t.Fatalf("encode wrapped request: %v", err) + } + + enc, err := r.Dispatch(context.Background(), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + cfg, ok := enc.(*tg.Config) + if !ok { + t.Fatalf("result type = %T, want *tg.Config", enc) + } + if cfg.ThisDC != dc { + t.Fatalf("ThisDC = %d, want %d", cfg.ThisDC, dc) + } + if len(cfg.DCOptions) != 0 { + t.Fatalf("DCOptions = %+v, want empty (client uses pinned static address)", cfg.DCOptions) + } +} + +func TestDispatchRejectsAuthorizedRPCBeforeLogin(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Auth: &captureAuthService{}, + }, zaptest.NewLogger(t), clock.System) + authKeyID := [8]byte{0x68, 0xa7, 0x30, 0x65, 0xc1, 0x11, 0x6e, 0x35} + + var dialogs bin.Buffer + if err := (&tg.MessagesGetDialogsRequest{ + OffsetPeer: &tg.InputPeerEmpty{}, + Limit: 20, + }).Encode(&dialogs); err != nil { + t.Fatalf("encode messages.getDialogs: %v", err) + } + if _, err := r.Dispatch(context.Background(), authKeyID, 1, &dialogs); !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") { + t.Fatalf("messages.getDialogs err = %v, want AUTH_KEY_UNREGISTERED", err) + } + + var help bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&help); err != nil { + t.Fatalf("encode help.getConfig: %v", err) + } + if _, err := r.Dispatch(context.Background(), authKeyID, 1, &help); err != nil { + t.Fatalf("help.getConfig should be allowed before login: %v", err) + } + + var sendCode bin.Buffer + if err := (&tg.AuthSendCodeRequest{ + PhoneNumber: "+15550001111", + APIID: 2040, + APIHash: "test", + Settings: tg.CodeSettings{}, + }).Encode(&sendCode); err != nil { + t.Fatalf("encode auth.sendCode: %v", err) + } + if _, err := r.Dispatch(context.Background(), authKeyID, 1, &sendCode); err != nil { + t.Fatalf("auth.sendCode should be allowed before login: %v", err) + } +} + +func TestDispatchRemembersLayerAndClientTypeForSession(t *testing.T) { + const layer = 225 + core, logs := observer.New(zap.DebugLevel) + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zap.New(core), clock.System) + rawAuthKeyID := [8]byte{0x22, 0x5} + sessionID := int64(88) + + initReq := &tg.InvokeWithLayerRequest{ + Layer: layer, + Query: &tg.InitConnectionRequest{ + APIID: 123, + DeviceModel: "Desktop", + SystemVersion: "Windows", + AppVersion: "6.8.4 x64", + SystemLangCode: "en", + LangPack: "tdesktop", + LangCode: "en", + Query: &tg.HelpGetConfigRequest{}, + }, + } + var initBuf bin.Buffer + if err := initReq.Encode(&initBuf); err != nil { + t.Fatalf("encode init request: %v", err) + } + if _, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &initBuf); err != nil { + t.Fatalf("dispatch init request: %v", err) + } + + sessionCtx := WithSessionID(WithRawAuthKeyID(WithAuthKeyID(context.Background(), rawAuthKeyID), rawAuthKeyID), sessionID) + info, ok := r.clientSessionInfo(sessionCtx) + if !ok { + t.Fatalf("session metadata missing") + } + if info.layer != layer { + t.Fatalf("remembered layer = %d, want %d", info.layer, layer) + } + if !info.hasClientInfo || info.clientInfo.ClientType() != ClientTypeTDesktop { + t.Fatalf("remembered client info = %+v, want tdesktop", info.clientInfo) + } + + var plainBuf bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&plainBuf); err != nil { + t.Fatalf("encode plain request: %v", err) + } + if _, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &plainBuf); err != nil { + t.Fatalf("dispatch plain request: %v", err) + } + + entries := logs.FilterMessage("RPC inner handled").All() + if len(entries) == 0 { + t.Fatalf("RPC inner handled log missing") + } + fields := entries[len(entries)-1].ContextMap() + if got := intLogField(fields["layer"]); got != layer { + t.Fatalf("logged layer = %d fields=%v, want %d", got, fields, layer) + } + if got := fields["client_type"]; got != string(ClientTypeTDesktop) { + t.Fatalf("logged client_type = %v, want %s", got, ClientTypeTDesktop) + } + if got := fields["app_version"]; got != "6.8.4 x64" { + t.Fatalf("logged app_version = %v, want 6.8.4 x64", got) + } + + newSessionID := int64(99) + var newSessionBuf bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&newSessionBuf); err != nil { + t.Fatalf("encode new session request: %v", err) + } + if _, err := r.Dispatch(context.Background(), rawAuthKeyID, newSessionID, &newSessionBuf); err != nil { + t.Fatalf("dispatch new session request: %v", err) + } + entries = logs.FilterMessage("RPC inner handled").All() + fields = entries[len(entries)-1].ContextMap() + if got := intLogField(fields["layer"]); got != layer { + t.Fatalf("new session logged layer = %d fields=%v, want %d", got, fields, layer) + } + if got := fields["client_type"]; got != string(ClientTypeTDesktop) { + t.Fatalf("new session logged client_type = %v, want %s", got, ClientTypeTDesktop) + } + if got := fields["app_version"]; got != "6.8.4 x64" { + t.Fatalf("new session logged app_version = %v, want 6.8.4 x64", got) + } +} + +func TestAndroidLegacyCompatLogsClientMetadataWithoutInit(t *testing.T) { + core, logs := observer.New(zap.DebugLevel) + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zap.New(core), clock.System) + + var flags bin.Fields + flags.Set(0) + var in bin.Buffer + in.PutID(0x25939651) + if err := flags.Encode(&in); err != nil { + t.Fatalf("encode flags: %v", err) + } + in.PutInt(10) + in.PutInt(100) + in.PutInt(123456) + in.PutInt(0) + + rawAuthKeyID := [8]byte{0x68, 0x25} + sessionID := int64(-3479521421865518854) + if _, err := r.Dispatch(WithUserID(context.Background(), 1780269504), rawAuthKeyID, sessionID, &in); err != nil { + t.Fatalf("dispatch legacy updates.getDifference: %v", err) + } + + // The legacy android constructor is upgraded by layerwire and dispatched + // normally; client metadata is still applied (withAndroidCompatMetadata for + // client drift), now surfaced on the standard "RPC inner handled" log. + entries := logs.FilterMessage("RPC inner handled").All() + if len(entries) == 0 { + t.Fatalf("RPC inner handled log missing") + } + fields := entries[len(entries)-1].ContextMap() + if got := intLogField(fields["layer"]); got != currentClientLayer { + t.Fatalf("logged layer = %d fields=%v, want %d", got, fields, currentClientLayer) + } + if got := fields["client_type"]; got != string(ClientTypeAndroid) { + t.Fatalf("logged client_type = %v, want %s", got, ClientTypeAndroid) + } +} + +// TestNegotiatedLayerStickyContract pins the (layer, ok) contract that keeps the +// edge from clobbering a live connection's layer: unknown ⇒ ok=false (edge keeps +// last-known), a recorded layer ⇒ ok=true, and a reconnect with a NEW session_id +// still inherits the layer via the stable auth_key fallback. +func TestNegotiatedLayerStickyContract(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + authKey := [8]byte{1, 2, 3, 4, 5, 6, 7, 8} + const session = int64(42) + + if l, ok := r.NegotiatedLayer(authKey, session); ok || l != currentClientLayer { + t.Fatalf("unknown = (%d,%v), want (%d,false)", l, ok, currentClientLayer) + } + + ctx := WithSessionID(WithRawAuthKeyID(context.Background(), authKey), session) + r.rememberClientLayer(ctx, 220) + + if l, ok := r.NegotiatedLayer(authKey, session); !ok || l != 220 { + t.Fatalf("recorded = (%d,%v), want (220,true)", l, ok) + } + // Reconnect: new session_id, same auth_key → inherits 220 (no re-invokeWithLayer). + if l, ok := r.NegotiatedLayer(authKey, 999); !ok || l != 220 { + t.Fatalf("reconnect new session = (%d,%v), want (220,true)", l, ok) + } + // Unrelated auth_key stays unknown. + if _, ok := r.NegotiatedLayer([8]byte{9, 9}, session); ok { + t.Fatalf("unrelated auth_key reported known") + } +} + +func TestClientTypeDetectsAndroidSDKVersion(t *testing.T) { + info := normalizeClientInfo(ClientInfo{ + DeviceModel: "GooglePixel 9a", + SystemVersion: "SDK 36", + AppVersion: "12.7.3 (67509) pbeta", + }) + if got := info.ClientType(); got != ClientTypeAndroid { + t.Fatalf("client type = %s, want %s", got, ClientTypeAndroid) + } + + info = normalizeClientInfo(ClientInfo{ + DeviceModel: "go1.26.2", + SystemVersion: "windows", + AppVersion: "v0.144.0", + }) + if got := info.ClientType(); got != ClientTypeUnknown { + t.Fatalf("gotd test client type = %s, want %s", got, ClientTypeUnknown) + } +} + +func TestDispatchRestoresClientMetadataFromAuthorization(t *testing.T) { + core, logs := observer.New(zap.DebugLevel) + authKeyID := [8]byte{0x68, 0x25, 0xc2, 0xee, 0xf8, 0x82, 0xef, 0x71} + userID := int64(1780269504) + auth := &captureAuthService{ + userID: userID, + authorizations: []domain.Authorization{{ + AuthKeyID: authKeyID, + UserID: userID, + Layer: currentClientLayer, + DeviceModel: "Android", + Platform: string(ClientTypeAndroid), + SystemVersion: "Android 15", + APIID: 6, + AppVersion: "12.7.3", + }}, + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Auth: auth, + }, zap.New(core), clock.System) + + var in bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(context.Background(), authKeyID, -3479521421865518854, &in); err != nil { + t.Fatalf("dispatch plain request: %v", err) + } + + entries := logs.FilterMessage("RPC inner handled").All() + if len(entries) == 0 { + t.Fatalf("RPC inner handled log missing") + } + fields := entries[len(entries)-1].ContextMap() + if got := intLogField(fields["layer"]); got != currentClientLayer { + t.Fatalf("logged layer = %d fields=%v, want %d", got, fields, currentClientLayer) + } + if got := fields["client_type"]; got != string(ClientTypeAndroid) { + t.Fatalf("logged client_type = %v, want %s", got, ClientTypeAndroid) + } + if got := fields["app_version"]; got != "12.7.3" { + t.Fatalf("logged app_version = %v, want 12.7.3", got) + } + + var second bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&second); err != nil { + t.Fatalf("encode second request: %v", err) + } + if _, err := r.Dispatch(context.Background(), authKeyID, -3479521421865518853, &second); err != nil { + t.Fatalf("dispatch second plain request: %v", err) + } + if auth.authorizationLookups != 1 || auth.authorizationLists != 0 { + t.Fatalf("authorization lookups/list calls = %d/%d, want exact-key 1/list 0", auth.authorizationLookups, auth.authorizationLists) + } +} + +func TestDispatchRestoresAndroidMetadataFromAuthorizationSDKVersion(t *testing.T) { + core, logs := observer.New(zap.DebugLevel) + authKeyID := [8]byte{0x16, 0x65, 0x54, 0x12, 0xaa, 0xbb, 0xcc, 0xdd} + userID := int64(1780269504) + auth := &captureAuthService{ + userID: userID, + authorizations: []domain.Authorization{{ + AuthKeyID: authKeyID, + UserID: userID, + DeviceModel: "GooglePixel 9a", + SystemVersion: "SDK 36", + APIID: 4, + AppVersion: "12.7.3 (67509) pbeta", + }}, + } + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Auth: auth, + }, zap.New(core), clock.System) + + var in bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(context.Background(), authKeyID, -3479521421865518854, &in); err != nil { + t.Fatalf("dispatch plain request: %v", err) + } + + entries := logs.FilterMessage("RPC inner handled").All() + if len(entries) == 0 { + t.Fatalf("RPC inner handled log missing") + } + fields := entries[len(entries)-1].ContextMap() + if got := intLogField(fields["layer"]); got != currentClientLayer { + t.Fatalf("logged layer = %d fields=%v, want %d", got, fields, currentClientLayer) + } + if got := fields["client_type"]; got != string(ClientTypeAndroid) { + t.Fatalf("logged client_type = %v, want %s", got, ClientTypeAndroid) + } + if got := fields["app_version"]; got != "12.7.3 (67509) pbeta" { + t.Fatalf("logged app_version = %v, want 12.7.3 (67509) pbeta", got) + } + if auth.authorizationLookups != 1 || auth.authorizationLists != 0 { + t.Fatalf("authorization lookups/list calls = %d/%d, want exact-key 1/list 0", auth.authorizationLookups, auth.authorizationLists) + } +} + +func TestDispatchCachesMissingClientMetadataAuthorizationLookup(t *testing.T) { + core, logs := observer.New(zap.DebugLevel) + authKeyID := [8]byte{0x68, 0x25, 0xc2, 0xee, 0xf8, 0x82, 0xef, 0x71} + auth := &captureAuthService{userID: 1780269504} + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Auth: auth, + }, zap.New(core), clock.System) + + for _, sessionID := range []int64{-3479521421865518854, -3479521421865518853} { + var in bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(context.Background(), authKeyID, sessionID, &in); err != nil { + t.Fatalf("dispatch plain request: %v", err) + } + } + + if auth.authorizationLookups != 1 || auth.authorizationLists != 0 { + t.Fatalf("authorization lookups/list calls = %d/%d, want one cached exact-key empty lookup/list 0", auth.authorizationLookups, auth.authorizationLists) + } + entries := logs.FilterMessage("RPC inner handled").All() + if len(entries) == 0 { + t.Fatalf("RPC inner handled log missing") + } + fields := entries[len(entries)-1].ContextMap() + if got := intLogField(fields["layer"]); got != 0 { + t.Fatalf("logged layer = %d fields=%v, want unknown 0", got, fields) + } + if got := fields["client_type"]; got != string(ClientTypeUnknown) { + t.Fatalf("logged client_type = %v, want %s", got, ClientTypeUnknown) + } +} + +func TestCurrentUserIDUsesAuthUserCache(t *testing.T) { + authKeyID := [8]byte{0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42} + auth := &captureAuthService{userID: 1000000001} + r := New(Config{}, Deps{Auth: auth}, zaptest.NewLogger(t), clock.System) + ctx := WithAuthKeyID(context.Background(), authKeyID) + + for i := 0; i < 2; i++ { + userID, ok, err := r.currentUserID(ctx) + if err != nil || !ok || userID != auth.userID { + t.Fatalf("currentUserID %d = user %d ok %v err %v, want %d/true", i, userID, ok, err, auth.userID) + } + } + if auth.userIDCount != 1 { + t.Fatalf("auth user lookups = %d, want 1", auth.userIDCount) + } +} + +func intLogField(v any) int { + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case int32: + return int(n) + case float64: + return int(n) + default: + return 0 + } +} + +func TestDispatchUnwrapsInvokeAfterWrappers(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + + tests := []struct { + name string + req bin.Encoder + }{ + { + name: "invokeAfterMsg", + req: &tg.InvokeAfterMsgRequest{ + MsgID: 123, + Query: &tg.HelpGetConfigRequest{}, + }, + }, + { + name: "invokeAfterMsgs", + req: &tg.InvokeAfterMsgsRequest{ + MsgIDs: []int64{123, 456}, + Query: &tg.HelpGetConfigRequest{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b bin.Buffer + if err := tt.req.Encode(&b); err != nil { + t.Fatalf("encode: %v", err) + } + enc, err := r.Dispatch(context.Background(), [8]byte{}, 0, &b) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if _, ok := enc.(*tg.Config); !ok { + t.Fatalf("result type = %T, want *tg.Config", enc) + } + }) + } +} + +// TestDispatchUnknownReturnsError 验证未注册 RPC 经 fallback 返回 rpc_error。 +func TestDispatchUnknownReturnsError(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) + + // help.getCdnConfig 第一阶段未注册,应走 fallback。 + var b bin.Buffer + if err := (&tg.HelpGetCDNConfigRequest{}).Encode(&b); err != nil { + t.Fatalf("encode: %v", err) + } + + _, err := r.Dispatch(context.Background(), [8]byte{}, 0, &b) + if err == nil { + t.Fatal("expected error for unregistered RPC") + } +} + +func TestDispatchResolvesBoundTempAuthKey(t *testing.T) { + var tempAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55} + var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11} + tempBindings := memory.NewTempAuthKeyBindingStore() + if err := tempBindings.Save(context.Background(), domain.TempAuthKeyBinding{ + TempAuthKeyID: tempAuthKeyID, + PermAuthKeyID: int64(binary.LittleEndian.Uint64(permAuthKeyID[:])), + ExpiresAt: int(time.Now().Add(time.Hour).Unix()), + }); err != nil { + t.Fatalf("save temp binding: %v", err) + } + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Auth: appauth.NewService(nil, nil, nil, nil, tempBindings, "12345"), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + req := &tg.HelpGetConfigRequest{} + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + + if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 123, &in); err != nil { + t.Fatalf("dispatch: %v", err) + } + gotSession := sessions.snapshot() + if gotSession.authKeyID != permAuthKeyID || !gotSession.authKeyResolved { + t.Fatalf("session auth key = %x resolved %v, want resolved perm %x", gotSession.authKeyID, gotSession.authKeyResolved, permAuthKeyID) + } +} + +func TestDispatchCachesUnauthenticatedIdentity(t *testing.T) { + var rawAuthKeyID = [8]byte{0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0x77} + auth := &captureAuthService{} + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Auth: auth, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + req := &tg.HelpGetConfigRequest{} + + for i := 0; i < 2; i++ { + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(context.Background(), rawAuthKeyID, 777, &in); err != nil { + t.Fatalf("dispatch %d: %v", i, err) + } + } + + if auth.resolveCount != 1 || auth.userIDCount != 1 { + t.Fatalf("identity lookups = resolve %d user %d, want one-time negative cache", auth.resolveCount, auth.userIDCount) + } + gotSession := sessions.snapshot() + if !gotSession.userResolved || gotSession.userID != 0 { + t.Fatalf("cached unauth identity = user %d resolved %v, want 0/true", gotSession.userID, gotSession.userResolved) + } +} + +func TestDispatchCachesTempConnectionIdentity(t *testing.T) { + var rawAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55} + var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11} + auth := &captureAuthService{ + resolvedAuthKeyID: permAuthKeyID, + hasResolved: true, + userID: 1000000001, + } + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Auth: auth, + Sessions: sessions, + Users: staticUsersService{user: domain.User{ + ID: 1000000001, + FirstName: "Test", + }}, + }, zaptest.NewLogger(t), clock.System) + req := &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}} + + for i := 0; i < 2; i++ { + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(context.Background(), rawAuthKeyID, 777, &in); err != nil { + t.Fatalf("dispatch %d: %v", i, err) + } + } + + if auth.resolveCount != 2 || auth.userIDCount != 1 { + t.Fatalf("identity lookups = resolve %d user %d, want temp resolve checks and cached user identity", auth.resolveCount, auth.userIDCount) + } + gotSession := sessions.snapshot() + if gotSession.authKeyID != permAuthKeyID || gotSession.userID != 1000000001 { + t.Fatalf("cached identity = auth %x user %d, want perm/user", gotSession.authKeyID, gotSession.userID) + } +} + +func TestDispatchSingleflightsAuthUserLookupAcrossStartupRPCs(t *testing.T) { + var authKeyID = [8]byte{0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42} + auth := newBlockingUserAuthService(1000000001) + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Auth: auth, + }, zaptest.NewLogger(t), clock.System) + + const calls = 16 + errs := make(chan error, calls) + var wg sync.WaitGroup + for i := 0; i < calls; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + var in bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil { + errs <- err + return + } + _, err := r.Dispatch(context.Background(), authKeyID, int64(100+i), &in) + errs <- err + }(i) + } + + select { + case <-auth.started: + case <-time.After(time.Second): + t.Fatal("auth lookup did not start") + } + close(auth.release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("dispatch: %v", err) + } + } + if got := auth.UserIDCount(); got != 1 { + t.Fatalf("UserID lookups = %d, want singleflighted one lookup", got) + } + + for i := 0; i < 3; i++ { + var in bin.Buffer + if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil { + t.Fatalf("encode: %v", err) + } + if _, err := r.Dispatch(context.Background(), authKeyID, int64(200+i), &in); err != nil { + t.Fatalf("cached dispatch %d: %v", i, err) + } + } + if got := auth.UserIDCount(); got != 1 { + t.Fatalf("UserID lookups after cache hits = %d, want still one lookup", got) + } +} + +func TestDispatchAnnouncesPresenceWhenSessionIdentityRestored(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} + dialogs := memory.NewDialogStore() + // 私聊 dialog 双向建行:两侧都存。presence 接收者由「主体自己的 dialog 对端 ∩ 在线」 + // 算出,bob 改状态时需 bob 侧 dialog 含 alice。 + if err := dialogs.SaveList(ctx, alice.ID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + TopMessage: 10, + TopMessageDate: 1700000000, + }}, + Users: []domain.User{bob}, + }); err != nil { + t.Fatalf("save dialogs: %v", err) + } + if err := dialogs.SaveList(ctx, bob.ID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, + TopMessage: 10, + TopMessageDate: 1700000000, + }}, + Users: []domain.User{alice}, + }); err != nil { + t.Fatalf("save bob dialogs: %v", err) + } + auth := &captureAuthService{userID: bob.ID} + sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}} + r := New(Config{}, Deps{ + Auth: auth, + Dialogs: appdialogs.NewService(dialogs), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + req := &tg.HelpGetConfigRequest{} + + for i := 0; i < 2; i++ { + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(ctx, [8]byte{0x22}, 333, &in); err != nil { + t.Fatalf("dispatch %d: %v", i, err) + } + } + + if auth.userIDCount != 1 { + t.Fatalf("user lookup count = %d, want one restored identity lookup", auth.userIDCount) + } + gotPushes := waitForPushedUserIDs(t, sessions, 2) + if !reflect.DeepEqual(gotPushes, []int64{bob.ID, alice.ID}) { + t.Fatalf("pushed users = %+v, want self and online private dialog peer once", gotPushes) + } + // 用 lastUserPush(PushToUser* 的内容)而非 snapshot().message:后者还会被 + // pushOnlinePeerStatusesToCurrentSession 把 alice 的在线状态推给 bob 当前 session 覆盖。 + update := pushedUserStatus(t, waitForLastUserPush(t, sessions)) + if update.UserID != bob.ID { + t.Fatalf("status user = %d, want bob", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { + t.Fatalf("status = %#v, want online with future expires", update.Status) + } +} + +func TestDispatchPushesOnlinePeerStatusesToRestoredSession(t *testing.T) { + ctx := context.Background() + alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} + bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} + dialogs := memory.NewDialogStore() + if err := dialogs.SaveList(ctx, alice.ID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + TopMessage: 10, + TopMessageDate: 1700000000, + }}, + Users: []domain.User{bob}, + }); err != nil { + t.Fatalf("save dialogs: %v", err) + } + auth := &captureAuthService{userID: alice.ID} + sessions := &captureSessions{onlineUserIDs: []int64{bob.ID}} + r := New(Config{}, Deps{ + Auth: auth, + Dialogs: appdialogs.NewService(dialogs), + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false); err != nil || !ok { + t.Fatalf("bob account.updateStatus online = %v, %v", ok, err) + } + sessions.clearMessages() + req := &tg.HelpGetConfigRequest{} + + for i := 0; i < 2; i++ { + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + if _, err := r.Dispatch(ctx, [8]byte{0x33}, 444, &in); err != nil { + t.Fatalf("dispatch %d: %v", i, err) + } + } + + if auth.userIDCount != 1 { + t.Fatalf("user lookup count = %d, want one restored identity lookup", auth.userIDCount) + } + update := waitForSessionUserStatus(t, sessions, bob.ID) + if update.UserID != bob.ID { + t.Fatalf("status user = %d, want bob", update.UserID) + } + if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { + t.Fatalf("status = %#v, want online peer status for restored session", update.Status) + } +} + +// TestTDesktopStartupRPCsEncode 验证第一阶段 TDesktop 启动 RPC 均能被路由并编码回包。 +func TestTDesktopStartupRPCsEncode(t *testing.T) { + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: staticUsersService{user: domain.User{ + ID: 1000000001, + AccessHash: 42, + FirstName: "Test", + LastName: "User", + Phone: "15550000000", + }}, + }, zaptest.NewLogger(t), clock.System) + + tests := []struct { + name string + req bin.Encoder + }{ + {name: "auth.bindTempAuthKey", req: &tg.AuthBindTempAuthKeyRequest{PermAuthKeyID: 1, Nonce: 2, ExpiresAt: 3, EncryptedMessage: []byte("binding")}}, + {name: "auth.exportLoginToken", req: &tg.AuthExportLoginTokenRequest{APIID: 1, APIHash: "hash"}}, + {name: "help.getAppConfig", req: &tg.HelpGetAppConfigRequest{}}, + {name: "help.getCountriesList", req: &tg.HelpGetCountriesListRequest{LangCode: "en"}}, + {name: "help.getTimezonesList", req: &tg.HelpGetTimezonesListRequest{}}, + {name: "help.getPeerColors", req: &tg.HelpGetPeerColorsRequest{}}, + {name: "help.getPeerProfileColors", req: &tg.HelpGetPeerProfileColorsRequest{}}, + {name: "help.getPromoData", req: &tg.HelpGetPromoDataRequest{}}, + {name: "help.getTermsOfServiceUpdate", req: &tg.HelpGetTermsOfServiceUpdateRequest{}}, + {name: "help.getPremiumPromo", req: &tg.HelpGetPremiumPromoRequest{}}, + {name: "help.getInviteText", req: &tg.HelpGetInviteTextRequest{}}, + {name: "auth.initPasskeyLogin", req: &tg.AuthInitPasskeyLoginRequest{APIID: 4, APIHash: "test"}}, + {name: "account.getPassword", req: &tg.AccountGetPasswordRequest{}}, + {name: "account.getNotifySettings", req: &tg.AccountGetNotifySettingsRequest{Peer: &tg.InputNotifyUsers{}}}, + {name: "account.resetNotifySettings", req: &tg.AccountResetNotifySettingsRequest{}}, + {name: "account.getPrivacy", req: &tg.AccountGetPrivacyRequest{Key: &tg.InputPrivacyKeyStatusTimestamp{}}}, + {name: "account.getAuthorizations", req: &tg.AccountGetAuthorizationsRequest{}}, + {name: "account.getWebAuthorizations", req: &tg.AccountGetWebAuthorizationsRequest{}}, + {name: "account.getNotifyExceptions", req: &tg.AccountGetNotifyExceptionsRequest{}}, + {name: "account.getDefaultEmojiStatuses", req: &tg.AccountGetDefaultEmojiStatusesRequest{}}, + {name: "account.getRecentEmojiStatuses", req: &tg.AccountGetRecentEmojiStatusesRequest{}}, + {name: "account.getCollectibleEmojiStatuses", req: &tg.AccountGetCollectibleEmojiStatusesRequest{}}, + {name: "account.getDefaultProfilePhotoEmojis", req: &tg.AccountGetDefaultProfilePhotoEmojisRequest{}}, + {name: "account.getDefaultGroupPhotoEmojis", req: &tg.AccountGetDefaultGroupPhotoEmojisRequest{}}, + {name: "account.getDefaultBackgroundEmojis", req: &tg.AccountGetDefaultBackgroundEmojisRequest{}}, + {name: "account.getChannelDefaultEmojiStatuses", req: &tg.AccountGetChannelDefaultEmojiStatusesRequest{}}, + {name: "account.getChannelRestrictedStatusEmojis", req: &tg.AccountGetChannelRestrictedStatusEmojisRequest{}}, + {name: "account.getConnectedBots", req: &tg.AccountGetConnectedBotsRequest{}}, + {name: "account.getBusinessChatLinks", req: &tg.AccountGetBusinessChatLinksRequest{}}, + {name: "account.getReactionsNotifySettings", req: &tg.AccountGetReactionsNotifySettingsRequest{}}, + {name: "account.getContactSignUpNotification", req: &tg.AccountGetContactSignUpNotificationRequest{}}, + {name: "account.getThemes", req: &tg.AccountGetThemesRequest{Format: "tdesktop"}}, + {name: "account.getChatThemes", req: &tg.AccountGetChatThemesRequest{}}, + {name: "account.getWallPapers", req: &tg.AccountGetWallPapersRequest{}}, + {name: "account.getUniqueGiftChatThemes", req: &tg.AccountGetUniqueGiftChatThemesRequest{Limit: 20}}, + {name: "account.getContentSettings", req: &tg.AccountGetContentSettingsRequest{}}, + {name: "account.getGlobalPrivacySettings", req: &tg.AccountGetGlobalPrivacySettingsRequest{}}, + {name: "account.getPasskeys", req: &tg.AccountGetPasskeysRequest{}}, + {name: "account.getAutoDownloadSettings", req: &tg.AccountGetAutoDownloadSettingsRequest{}}, + {name: "account.getSavedMusicIds", req: &tg.AccountGetSavedMusicIDsRequest{}}, + {name: "account.getSavedRingtones", req: &tg.AccountGetSavedRingtonesRequest{}}, + {name: "account.resetPassword", req: &tg.AccountResetPasswordRequest{}}, + {name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}}, + {name: "payments.getStarsTopupOptions", req: &tg.PaymentsGetStarsTopupOptionsRequest{}}, + {name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}}, + {name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}}, + {name: "users.getFullUser", req: &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}}}, + {name: "users.getRequirementsToContact", req: &tg.UsersGetRequirementsToContactRequest{ID: []tg.InputUserClass{&tg.InputUserSelf{}}}}, + {name: "users.getSavedMusic", req: &tg.UsersGetSavedMusicRequest{ID: &tg.InputUserSelf{}, Limit: 20}}, + {name: "users.getSavedMusicByID", req: &tg.UsersGetSavedMusicByIDRequest{ID: &tg.InputUserSelf{}, Documents: []tg.InputDocumentClass{}}}, + {name: "messages.getDialogFilters", req: &tg.MessagesGetDialogFiltersRequest{}}, + {name: "messages.getDialogs", req: &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}}, + {name: "messages.getPinnedDialogs", req: &tg.MessagesGetPinnedDialogsRequest{}}, + {name: "messages.getPeerDialogs", req: &tg.MessagesGetPeerDialogsRequest{Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}}}, + {name: "messages.getAvailableReactions", req: &tg.MessagesGetAvailableReactionsRequest{}}, + {name: "messages.getAvailableEffects", req: &tg.MessagesGetAvailableEffectsRequest{}}, + {name: "messages.getStickers", req: &tg.MessagesGetStickersRequest{}}, + {name: "messages.getArchivedStickers", req: &tg.MessagesGetArchivedStickersRequest{Limit: 20}}, + {name: "messages.getMaskStickers", req: &tg.MessagesGetMaskStickersRequest{}}, + {name: "messages.getStickerSet", req: &tg.MessagesGetStickerSetRequest{Stickerset: &tg.InputStickerSetEmpty{}}}, + {name: "messages.getEmojiGroups", req: &tg.MessagesGetEmojiGroupsRequest{}}, + {name: "messages.getEmojiStatusGroups", req: &tg.MessagesGetEmojiStatusGroupsRequest{}}, + {name: "messages.getEmojiStickerGroups", req: &tg.MessagesGetEmojiStickerGroupsRequest{}}, + {name: "messages.getEmojiProfilePhotoGroups", req: &tg.MessagesGetEmojiProfilePhotoGroupsRequest{}}, + {name: "messages.getEmojiKeywordsLanguages", req: &tg.MessagesGetEmojiKeywordsLanguagesRequest{LangCodes: []string{"en"}}}, + {name: "messages.getAttachMenuBots", req: &tg.MessagesGetAttachMenuBotsRequest{}}, + {name: "messages.getQuickReplies", req: &tg.MessagesGetQuickRepliesRequest{}}, + {name: "messages.getQuickReplyMessages", req: &tg.MessagesGetQuickReplyMessagesRequest{ShortcutID: 1}}, + {name: "messages.getSavedHistory", req: &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, + {name: "messages.readSavedHistory", req: &tg.MessagesReadSavedHistoryRequest{ParentPeer: &tg.InputPeerChannel{ChannelID: 1, AccessHash: 1}, Peer: &tg.InputPeerSelf{}}}, + {name: "messages.deleteSavedHistory", req: &tg.MessagesDeleteSavedHistoryRequest{Peer: &tg.InputPeerSelf{}}}, + {name: "messages.getPeerSettings", req: &tg.MessagesGetPeerSettingsRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}, + {name: "messages.setChatWallPaper", req: &tg.MessagesSetChatWallPaperRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Wallpaper: &tg.InputWallPaperNoFile{ID: 930000000000000000}}}, + {name: "messages.getHistory", req: &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Limit: 20}}, + {name: "messages.readHistory", req: &tg.MessagesReadHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}, + {name: "messages.search", req: &tg.MessagesSearchRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Filter: &tg.InputMessagesFilterEmpty{}, Limit: 20}}, + {name: "messages.searchGlobal", req: &tg.MessagesSearchGlobalRequest{Q: "login", Filter: &tg.InputMessagesFilterEmpty{}, OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}}, + {name: "messages.getWebPage", req: &tg.MessagesGetWebPageRequest{URL: "https://example.invalid"}}, + {name: "messages.getScheduledHistory", req: &tg.MessagesGetScheduledHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}, + {name: "contacts.getContacts", req: &tg.ContactsGetContactsRequest{}}, + {name: "contacts.search", req: &tg.ContactsSearchRequest{Q: "Test", Limit: 20}}, + {name: "contacts.getBlocked", req: &tg.ContactsGetBlockedRequest{Limit: 20}}, + {name: "contacts.getBirthdays", req: &tg.ContactsGetBirthdaysRequest{}}, + {name: "contacts.getTopPeers", req: &tg.ContactsGetTopPeersRequest{Correspondents: true, Limit: 10}}, + {name: "contacts.getSponsoredPeers", req: &tg.ContactsGetSponsoredPeersRequest{Q: "Test"}}, + {name: "stories.getAllStories", req: &tg.StoriesGetAllStoriesRequest{}}, + {name: "stories.getStoriesArchive", req: &tg.StoriesGetStoriesArchiveRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, + {name: "stories.getPinnedStories", req: &tg.StoriesGetPinnedStoriesRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, + {name: "stories.exportStoryLink", req: &tg.StoriesExportStoryLinkRequest{Peer: &tg.InputPeerSelf{}, ID: 1}}, + {name: "stories.report", req: &tg.StoriesReportRequest{Peer: &tg.InputPeerSelf{}, ID: []int{1}}}, + {name: "stories.activateStealthMode", req: &tg.StoriesActivateStealthModeRequest{Past: true, Future: true}}, + {name: "stories.searchPosts", req: &tg.StoriesSearchPostsRequest{Hashtag: "storytag", Limit: 20}}, + {name: "stories.getAlbums", req: &tg.StoriesGetAlbumsRequest{Peer: &tg.InputPeerSelf{}}}, + {name: "stories.getAlbumStories", req: &tg.StoriesGetAlbumStoriesRequest{Peer: &tg.InputPeerSelf{}, AlbumID: 1, Limit: 20}}, + {name: "stories.toggleAllStoriesHidden", req: &tg.StoriesToggleAllStoriesHiddenRequest{Hidden: true}}, + {name: "stories.reorderAlbums", req: &tg.StoriesReorderAlbumsRequest{Peer: &tg.InputPeerSelf{}, Order: []int{1}}}, + {name: "stories.deleteAlbum", req: &tg.StoriesDeleteAlbumRequest{Peer: &tg.InputPeerSelf{}, AlbumID: 1}}, + {name: "stories.getAllReadPeerStories", req: &tg.StoriesGetAllReadPeerStoriesRequest{}}, + {name: "stories.getPeerMaxIDs", req: &tg.StoriesGetPeerMaxIDsRequest{ID: []tg.InputPeerClass{&tg.InputPeerSelf{}}}}, + {name: "stories.getStoriesViews", req: &tg.StoriesGetStoriesViewsRequest{Peer: &tg.InputPeerSelf{}, ID: []int{1}}}, + {name: "stories.getChatsToSend", req: &tg.StoriesGetChatsToSendRequest{}}, + {name: "payments.getStarGiftActiveAuctions", req: &tg.PaymentsGetStarGiftActiveAuctionsRequest{}}, + {name: "payments.getStarGifts", req: &tg.PaymentsGetStarGiftsRequest{}}, + {name: "payments.getStarGiftCollections", req: &tg.PaymentsGetStarGiftCollectionsRequest{Peer: &tg.InputPeerSelf{}}}, + {name: "payments.getSavedStarGifts", req: &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, + {name: "payments.getSavedStarGift", req: &tg.PaymentsGetSavedStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{}}}, + {name: "payments.getStarsRevenueAdsAccountUrl", req: &tg.PaymentsGetStarsRevenueAdsAccountURLRequest{Peer: &tg.InputPeerSelf{}}}, + {name: "payments.getStarsRevenueStats", req: &tg.PaymentsGetStarsRevenueStatsRequest{Ton: true, Peer: &tg.InputPeerSelf{}}}, + {name: "bots.getBotRecommendations", req: &tg.BotsGetBotRecommendationsRequest{Bot: &tg.InputUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}, + {name: "aicompose.getTones", req: &tg.AicomposeGetTonesRequest{}}, + {name: "langpack.getLanguage", req: &tg.LangpackGetLanguageRequest{LangPack: "tdesktop", LangCode: "en"}}, + {name: "langpack.getLangPack", req: &tg.LangpackGetLangPackRequest{LangPack: "tdesktop", LangCode: "en"}}, + {name: "langpack.getDifference", req: &tg.LangpackGetDifferenceRequest{LangPack: "tdesktop", LangCode: "en", FromVersion: 1}}, + {name: "langpack.getStrings", req: &tg.LangpackGetStringsRequest{LangPack: "tdesktop", LangCode: "en", Keys: []string{"lng_intro_about"}}}, + {name: "help.getDeepLinkInfo", req: &tg.HelpGetDeepLinkInfoRequest{Path: "join?invite=abc"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var in bin.Buffer + if err := tt.req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + ctx := WithUserID(context.Background(), 1000000001) + enc, err := r.Dispatch(ctx, [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + var out bin.Buffer + if err := enc.Encode(&out); err != nil { + t.Fatalf("encode response: %v", err) + } + if out.Len() == 0 { + t.Fatal("encoded response is empty") + } + }) + } +} + +// TestHelpGetDeepLinkInfoReturnsEmpty 回归:help.getDeepLinkInfo 此前未注册 handler, +// 落 fallback 返回 500 NOT_IMPLEMENTED。客户端遇到无法识别的 tg:// 深链就会发该请求 +// (DrKLO LaunchActivity unsupportedUrl 分支),应返回规范的 deepLinkInfoEmpty 而非报错。 +func TestHelpGetDeepLinkInfoReturnsEmpty(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + req := &tg.HelpGetDeepLinkInfoRequest{Path: "resolve?domain=unknown"} + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch help.getDeepLinkInfo err = %v, want nil (未注册时为 500 NOT_IMPLEMENTED)", err) + } + var out bin.Buffer + if err := enc.Encode(&out); err != nil { + t.Fatalf("encode response: %v", err) + } + res, err := tg.DecodeHelpDeepLinkInfo(&out) + if err != nil { + t.Fatalf("decode response: %v", err) + } + if _, ok := res.(*tg.HelpDeepLinkInfoEmpty); !ok { + t.Fatalf("response type = %T, want *tg.HelpDeepLinkInfoEmpty", res) + } +} + +func TestStoriesStartLiveDispatchReturnsMethodInvalid(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + req := &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerSelf{}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 42, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + _, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err == nil || !tgerr.Is(err, "METHOD_INVALID") { + t.Fatalf("dispatch stories.startLive err = %v, want METHOD_INVALID", err) + } +} + +func TestStoriesAlbumMutationDispatchReturnsMethodInvalid(t *testing.T) { + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + updateReq := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerSelf{}, + AlbumID: 1, + } + updateReq.SetTitle("Travel") + + tests := []struct { + name string + req bin.Encoder + }{ + {name: "stories.createAlbum", req: &tg.StoriesCreateAlbumRequest{ + Peer: &tg.InputPeerSelf{}, + Title: "Favorites", + Stories: []int{1}, + }}, + {name: "stories.updateAlbum", req: updateReq}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var in bin.Buffer + if err := tt.req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + _, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) + if err == nil || !tgerr.Is(err, "METHOD_INVALID") { + t.Fatalf("dispatch %s err = %v, want METHOD_INVALID", tt.name, err) + } + }) + } +} diff --git a/internal/rpc/router_tempkey_cache_test.go b/internal/rpc/router_tempkey_cache_test.go new file mode 100644 index 00000000..062e8485 --- /dev/null +++ b/internal/rpc/router_tempkey_cache_test.go @@ -0,0 +1,157 @@ +package rpc + +import ( + "context" + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" +) + +type revokeCaptureSessions struct { + captureSessions + closedBusinessAuthKeyIDs [][8]byte + closedRawAuthKeyIDs [][8]byte +} + +func (s *revokeCaptureSessions) CloseSessionsForBusinessAuthKey(authKeyID [8]byte) int { + s.mu.Lock() + defer s.mu.Unlock() + s.closedBusinessAuthKeyIDs = append(s.closedBusinessAuthKeyIDs, authKeyID) + return 1 +} + +func (s *revokeCaptureSessions) CloseSessionsForRawAuthKeyExcept(authKeyID [8]byte, _ int64) int { + s.mu.Lock() + defer s.mu.Unlock() + s.closedRawAuthKeyIDs = append(s.closedRawAuthKeyIDs, authKeyID) + return 1 +} + +// TestTempKeyResolveCacheHitsWithinTTL 验证:TempKeyResolveCacheTTL>0 时,同一 temp key 的连续 +// 请求在 TTL 内只解析一次(首帧走 !hasCached 解析 1 次、次帧 hasCached 解析并填缓存 1 次,之后命中 +// 缓存不再打 ResolveAuthKey)。固化「缓存生效」语义,与现有「TTL=0 每帧重校验」的安全测试互补。 +func TestTempKeyResolveCacheHitsWithinTTL(t *testing.T) { + tempAuthKeyID := [8]byte{0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0x77} + permAuthKeyID := [8]byte{0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33} + sessions := &captureSessions{} + auth := &captureAuthService{ + resolvedAuthKeyID: permAuthKeyID, + hasResolved: true, + userID: 1000000001, + } + r := New(Config{TempKeyResolveCacheTTL: time.Minute}, Deps{ + Auth: auth, + Files: &fakeFiles{}, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + for i := 0; i < 8; i++ { + var in bin.Buffer + if err := (&tg.UploadSaveFilePartRequest{FileID: 20, FilePart: i, Bytes: []byte{1}}).Encode(&in); err != nil { + t.Fatalf("encode %d: %v", i, err) + } + if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 555, &in); err != nil { + t.Fatalf("dispatch %d: %v", i, err) + } + } + // 首帧 !hasCached 解析 1 次;次帧 hasCached miss 解析 1 次并填缓存;其余 6 帧命中缓存。 + if auth.resolveCount != 2 { + t.Fatalf("ResolveAuthKey calls = %d over 8 dispatches, want 2 (cached within TTL)", auth.resolveCount) + } + got := sessions.snapshot() + if got.authKeyID != permAuthKeyID || got.userID != 1000000001 { + t.Fatalf("session = auth %x user %d, want perm/user", got.authKeyID, got.userID) + } +} + +// TestTempKeyResolveCacheExpires 验证 TTL 过期后会重新解析自然到期的 temp key。 +func TestTempKeyResolveCacheExpires(t *testing.T) { + tempAuthKeyID := [8]byte{0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78, 0x78} + permAuthKeyID := [8]byte{0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34, 0x34} + sessions := &captureSessions{} + auth := &captureAuthService{ + resolvedAuthKeyID: permAuthKeyID, + hasResolved: true, + userID: 1000000001, + } + r := New(Config{TempKeyResolveCacheTTL: time.Millisecond}, Deps{ + Auth: auth, + Files: &fakeFiles{}, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + dispatch := func() { + var in bin.Buffer + if err := (&tg.UploadSaveFilePartRequest{FileID: 21, FilePart: 0, Bytes: []byte{1}}).Encode(&in); err != nil { + t.Fatalf("encode: %v", err) + } + if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 556, &in); err != nil { + t.Fatalf("dispatch: %v", err) + } + } + dispatch() // 首帧 !hasCached + dispatch() // 次帧填缓存 + before := auth.resolveCount + time.Sleep(10 * time.Millisecond) // 等缓存过期 + dispatch() + if auth.resolveCount <= before { + t.Fatalf("ResolveAuthKey calls = %d, want > %d after TTL expiry (re-validation)", auth.resolveCount, before) + } +} + +func TestRevokeAuthKeySessionsInvalidatesCachedTempKeysAndClosesRawConnections(t *testing.T) { + permAuthKeyID := [8]byte{0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44} + tempAuthKeyID := [8]byte{0x79, 0x79, 0x79, 0x79, 0x79, 0x79, 0x79, 0x79} + otherTempAuthKeyID := [8]byte{0x7a, 0x7a, 0x7a, 0x7a, 0x7a, 0x7a, 0x7a, 0x7a} + otherPermAuthKeyID := [8]byte{0x45, 0x45, 0x45, 0x45, 0x45, 0x45, 0x45, 0x45} + sessions := &revokeCaptureSessions{} + r := New(Config{TempKeyResolveCacheTTL: time.Minute}, Deps{ + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + expires := time.Now().Add(time.Minute) + now := time.Now() + r.tempKeyResolveCache.Store(tempAuthKeyID, permAuthKeyID, expires, now) + r.tempKeyResolveCache.Store(otherTempAuthKeyID, otherPermAuthKeyID, expires, now) + + r.revokeAuthKeySessions(permAuthKeyID) + + if _, ok := r.tempKeyResolveCache.Get(tempAuthKeyID, permAuthKeyID, time.Now()); ok { + t.Fatal("revoked temp auth key cache entry still present") + } + if _, ok := r.tempKeyResolveCache.Get(otherTempAuthKeyID, otherPermAuthKeyID, time.Now()); !ok { + t.Fatal("unrelated temp auth key cache entry was deleted") + } + if got := len(sessions.closedBusinessAuthKeyIDs); got != 1 || sessions.closedBusinessAuthKeyIDs[0] != permAuthKeyID { + t.Fatalf("business closes = %x, want only %x", sessions.closedBusinessAuthKeyIDs, permAuthKeyID) + } + if got := len(sessions.closedRawAuthKeyIDs); got != 1 || sessions.closedRawAuthKeyIDs[0] != tempAuthKeyID { + t.Fatalf("raw closes = %x, want only %x", sessions.closedRawAuthKeyIDs, tempAuthKeyID) + } +} + +func TestTempKeyResolveCacheEvictsOldestAtCapacity(t *testing.T) { + cache := newTempKeyResolveCache(2) + now := time.Now() + perm := [8]byte{0x40} + first := [8]byte{0x80} + second := [8]byte{0x81} + third := [8]byte{0x82} + + cache.Store(first, perm, now.Add(time.Minute), now) + cache.Store(second, perm, now.Add(time.Minute), now) + cache.Store(third, perm, now.Add(time.Minute), now) + + if _, ok := cache.Get(first, perm, now); ok { + t.Fatal("oldest cache entry still present after capacity eviction") + } + if _, ok := cache.Get(second, perm, now); !ok { + t.Fatal("second cache entry missing after capacity eviction") + } + if _, ok := cache.Get(third, perm, now); !ok { + t.Fatal("newest cache entry missing after capacity eviction") + } +} diff --git a/internal/rpc/router_test.go b/internal/rpc/router_test.go deleted file mode 100644 index 29b0ee75..00000000 --- a/internal/rpc/router_test.go +++ /dev/null @@ -1,10464 +0,0 @@ -package rpc - -import ( - "context" - "encoding/binary" - "reflect" - "strconv" - "strings" - "sync" - "testing" - "time" - - "go.uber.org/zap/zaptest" - - "github.com/gotd/td/bin" - "github.com/gotd/td/clock" - "github.com/gotd/td/proto" - "github.com/gotd/td/tg" - - appauth "telesrv/internal/app/auth" - appchannels "telesrv/internal/app/channels" - appcontacts "telesrv/internal/app/contacts" - appdialogs "telesrv/internal/app/dialogs" - appmessages "telesrv/internal/app/messages" - appupdates "telesrv/internal/app/updates" - appusers "telesrv/internal/app/users" - "telesrv/internal/domain" - "telesrv/internal/store/memory" -) - -// TestDispatchUnwrapsWrappers 验证 Router 能剥离 -// invokeWithLayer(initConnection(help.getConfig)) 并路由到 getConfig handler。 -func TestDispatchUnwrapsWrappers(t *testing.T) { - const ( - dc = 2 - ip = "127.0.0.1" - port = 2398 - layer = 225 - ) - r := New(Config{DC: dc, IP: ip, Port: port}, Deps{}, zaptest.NewLogger(t), clock.System) - - req := &tg.InvokeWithLayerRequest{ - Layer: layer, - Query: &tg.InitConnectionRequest{ - APIID: 123, - DeviceModel: "TestDevice", - SystemVersion: "1.0", - AppVersion: "1.0", - SystemLangCode: "en", - LangPack: "tdesktop", - LangCode: "en", - Query: &tg.HelpGetConfigRequest{}, - }, - } - var b bin.Buffer - if err := req.Encode(&b); err != nil { - t.Fatalf("encode wrapped request: %v", err) - } - - enc, err := r.Dispatch(context.Background(), [8]byte{}, 0, &b) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - cfg, ok := enc.(*tg.Config) - if !ok { - t.Fatalf("result type = %T, want *tg.Config", enc) - } - if cfg.ThisDC != dc { - t.Fatalf("ThisDC = %d, want %d", cfg.ThisDC, dc) - } - if len(cfg.DCOptions) != 1 || cfg.DCOptions[0].Port != port { - t.Fatalf("DCOptions = %+v", cfg.DCOptions) - } -} - -// TestDispatchUnknownReturnsError 验证未注册 RPC 经 fallback 返回 rpc_error。 -func TestDispatchUnknownReturnsError(t *testing.T) { - r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) - - // help.getCdnConfig 第一阶段未注册,应走 fallback。 - var b bin.Buffer - if err := (&tg.HelpGetCDNConfigRequest{}).Encode(&b); err != nil { - t.Fatalf("encode: %v", err) - } - - _, err := r.Dispatch(context.Background(), [8]byte{}, 0, &b) - if err == nil { - t.Fatal("expected error for unregistered RPC") - } -} - -func TestDispatchResolvesBoundTempAuthKey(t *testing.T) { - var tempAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55} - var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11} - tempBindings := memory.NewTempAuthKeyBindingStore() - if err := tempBindings.Save(context.Background(), domain.TempAuthKeyBinding{ - TempAuthKeyID: tempAuthKeyID, - PermAuthKeyID: int64(binary.LittleEndian.Uint64(permAuthKeyID[:])), - ExpiresAt: int(time.Now().Add(time.Hour).Unix()), - }); err != nil { - t.Fatalf("save temp binding: %v", err) - } - sessions := &captureSessions{} - r := New(Config{}, Deps{ - Auth: appauth.NewService(nil, nil, nil, nil, tempBindings, "12345"), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - req := &tg.HelpGetConfigRequest{} - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 123, &in); err != nil { - t.Fatalf("dispatch: %v", err) - } - gotSession := sessions.snapshot() - if gotSession.authKeyID != permAuthKeyID || !gotSession.authKeyResolved { - t.Fatalf("session auth key = %x resolved %v, want resolved perm %x", gotSession.authKeyID, gotSession.authKeyResolved, permAuthKeyID) - } -} - -func TestBindTempAuthKeyClearsNegativeUserCache(t *testing.T) { - var tempAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55} - var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11} - sessions := &captureSessions{} - auth := &captureAuthService{} - r := New(Config{}, Deps{ - Auth: auth, - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - req := &tg.AuthBindTempAuthKeyRequest{ - PermAuthKeyID: int64(binary.LittleEndian.Uint64(permAuthKeyID[:])), - Nonce: 2, - ExpiresAt: int(time.Now().Add(time.Hour).Unix()), - } - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - if _, err := r.Dispatch(context.Background(), tempAuthKeyID, 123, &in); err != nil { - t.Fatalf("dispatch: %v", err) - } - if auth.userIDCount != 1 { - t.Fatalf("user lookup count = %d, want one negative lookup before temp binding", auth.userIDCount) - } - gotSession := sessions.snapshot() - if gotSession.authKeyID != permAuthKeyID || !gotSession.authKeyResolved { - t.Fatalf("session auth key = %x resolved %v, want perm %x", gotSession.authKeyID, gotSession.authKeyResolved, permAuthKeyID) - } - if gotSession.userResolved || gotSession.userID != 0 { - t.Fatalf("negative user cache = user %d resolved %v, want cleared after auth key switch", gotSession.userID, gotSession.userResolved) - } -} - -func TestDispatchCachesUnauthenticatedIdentity(t *testing.T) { - var rawAuthKeyID = [8]byte{0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0x77} - auth := &captureAuthService{} - sessions := &captureSessions{} - r := New(Config{}, Deps{ - Auth: auth, - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - req := &tg.HelpGetConfigRequest{} - - for i := 0; i < 2; i++ { - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - if _, err := r.Dispatch(context.Background(), rawAuthKeyID, 777, &in); err != nil { - t.Fatalf("dispatch %d: %v", i, err) - } - } - - if auth.resolveCount != 1 || auth.userIDCount != 1 { - t.Fatalf("identity lookups = resolve %d user %d, want one-time negative cache", auth.resolveCount, auth.userIDCount) - } - gotSession := sessions.snapshot() - if !gotSession.userResolved || gotSession.userID != 0 { - t.Fatalf("cached unauth identity = user %d resolved %v, want 0/true", gotSession.userID, gotSession.userResolved) - } -} - -func TestDispatchCachesConnectionIdentity(t *testing.T) { - var rawAuthKeyID = [8]byte{0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55} - var permAuthKeyID = [8]byte{0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11} - auth := &captureAuthService{ - resolvedAuthKeyID: permAuthKeyID, - hasResolved: true, - userID: 1000000001, - } - sessions := &captureSessions{} - r := New(Config{}, Deps{ - Auth: auth, - Sessions: sessions, - Users: staticUsersService{user: domain.User{ - ID: 1000000001, - FirstName: "Test", - }}, - }, zaptest.NewLogger(t), clock.System) - req := &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}} - - for i := 0; i < 2; i++ { - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - if _, err := r.Dispatch(context.Background(), rawAuthKeyID, 777, &in); err != nil { - t.Fatalf("dispatch %d: %v", i, err) - } - } - - if auth.resolveCount != 1 || auth.userIDCount != 1 { - t.Fatalf("identity lookups = resolve %d user %d, want one-time cache", auth.resolveCount, auth.userIDCount) - } - gotSession := sessions.snapshot() - if gotSession.authKeyID != permAuthKeyID || gotSession.userID != 1000000001 { - t.Fatalf("cached identity = auth %x user %d, want perm/user", gotSession.authKeyID, gotSession.userID) - } -} - -func TestDispatchSingleflightsAuthUserLookupAcrossStartupRPCs(t *testing.T) { - var authKeyID = [8]byte{0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x42} - auth := newBlockingUserAuthService(1000000001) - r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ - Auth: auth, - }, zaptest.NewLogger(t), clock.System) - - const calls = 16 - errs := make(chan error, calls) - var wg sync.WaitGroup - for i := 0; i < calls; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - var in bin.Buffer - if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil { - errs <- err - return - } - _, err := r.Dispatch(context.Background(), authKeyID, int64(100+i), &in) - errs <- err - }(i) - } - - select { - case <-auth.started: - case <-time.After(time.Second): - t.Fatal("auth lookup did not start") - } - close(auth.release) - wg.Wait() - close(errs) - for err := range errs { - if err != nil { - t.Fatalf("dispatch: %v", err) - } - } - if got := auth.UserIDCount(); got != 1 { - t.Fatalf("UserID lookups = %d, want singleflighted one lookup", got) - } - - for i := 0; i < 3; i++ { - var in bin.Buffer - if err := (&tg.HelpGetConfigRequest{}).Encode(&in); err != nil { - t.Fatalf("encode: %v", err) - } - if _, err := r.Dispatch(context.Background(), authKeyID, int64(200+i), &in); err != nil { - t.Fatalf("cached dispatch %d: %v", i, err) - } - } - if got := auth.UserIDCount(); got != 1 { - t.Fatalf("UserID lookups after cache hits = %d, want still one lookup", got) - } -} - -func TestDispatchAnnouncesPresenceWhenSessionIdentityRestored(t *testing.T) { - ctx := context.Background() - alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} - bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} - dialogs := memory.NewDialogStore() - if err := dialogs.SaveList(ctx, alice.ID, domain.DialogList{ - Dialogs: []domain.Dialog{{ - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, - TopMessage: 10, - TopMessageDate: 1700000000, - }}, - Users: []domain.User{bob}, - }); err != nil { - t.Fatalf("save dialogs: %v", err) - } - auth := &captureAuthService{userID: bob.ID} - sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}} - r := New(Config{}, Deps{ - Auth: auth, - Dialogs: appdialogs.NewService(dialogs), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - req := &tg.HelpGetConfigRequest{} - - for i := 0; i < 2; i++ { - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - if _, err := r.Dispatch(ctx, [8]byte{0x22}, 333, &in); err != nil { - t.Fatalf("dispatch %d: %v", i, err) - } - } - - if auth.userIDCount != 1 { - t.Fatalf("user lookup count = %d, want one restored identity lookup", auth.userIDCount) - } - gotPushes := sessions.pushedUserIDs() - if !reflect.DeepEqual(gotPushes, []int64{bob.ID, alice.ID}) { - t.Fatalf("pushed users = %+v, want self and online private dialog peer once", gotPushes) - } - update := pushedUserStatus(t, sessions.snapshot().message) - if update.UserID != bob.ID { - t.Fatalf("status user = %d, want bob", update.UserID) - } - if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { - t.Fatalf("status = %#v, want online with future expires", update.Status) - } -} - -func TestDispatchPushesOnlinePeerStatusesToRestoredSession(t *testing.T) { - ctx := context.Background() - alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} - bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} - dialogs := memory.NewDialogStore() - if err := dialogs.SaveList(ctx, alice.ID, domain.DialogList{ - Dialogs: []domain.Dialog{{ - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, - TopMessage: 10, - TopMessageDate: 1700000000, - }}, - Users: []domain.User{bob}, - }); err != nil { - t.Fatalf("save dialogs: %v", err) - } - auth := &captureAuthService{userID: alice.ID} - sessions := &captureSessions{onlineUserIDs: []int64{bob.ID}} - r := New(Config{}, Deps{ - Auth: auth, - Dialogs: appdialogs.NewService(dialogs), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false); err != nil || !ok { - t.Fatalf("bob account.updateStatus online = %v, %v", ok, err) - } - req := &tg.HelpGetConfigRequest{} - - for i := 0; i < 2; i++ { - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - if _, err := r.Dispatch(ctx, [8]byte{0x33}, 444, &in); err != nil { - t.Fatalf("dispatch %d: %v", i, err) - } - } - - if auth.userIDCount != 1 { - t.Fatalf("user lookup count = %d, want one restored identity lookup", auth.userIDCount) - } - update := pushedUserStatus(t, sessions.snapshot().message) - if update.UserID != bob.ID { - t.Fatalf("status user = %d, want bob", update.UserID) - } - if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { - t.Fatalf("status = %#v, want online peer status for restored session", update.Status) - } -} - -// TestTDesktopStartupRPCsEncode 验证第一阶段 TDesktop 启动 RPC 均能被路由并编码回包。 -func TestTDesktopStartupRPCsEncode(t *testing.T) { - r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ - Users: staticUsersService{user: domain.User{ - ID: 1000000001, - AccessHash: 42, - FirstName: "Test", - LastName: "User", - Phone: "15550000000", - }}, - }, zaptest.NewLogger(t), clock.System) - - tests := []struct { - name string - req bin.Encoder - }{ - {name: "auth.bindTempAuthKey", req: &tg.AuthBindTempAuthKeyRequest{PermAuthKeyID: 1, Nonce: 2, ExpiresAt: 3, EncryptedMessage: []byte("binding")}}, - {name: "auth.exportLoginToken", req: &tg.AuthExportLoginTokenRequest{APIID: 1, APIHash: "hash"}}, - {name: "help.getAppConfig", req: &tg.HelpGetAppConfigRequest{}}, - {name: "help.getCountriesList", req: &tg.HelpGetCountriesListRequest{LangCode: "en"}}, - {name: "help.getTimezonesList", req: &tg.HelpGetTimezonesListRequest{}}, - {name: "help.getPeerColors", req: &tg.HelpGetPeerColorsRequest{}}, - {name: "help.getPeerProfileColors", req: &tg.HelpGetPeerProfileColorsRequest{}}, - {name: "help.getPromoData", req: &tg.HelpGetPromoDataRequest{}}, - {name: "help.getTermsOfServiceUpdate", req: &tg.HelpGetTermsOfServiceUpdateRequest{}}, - {name: "help.getPremiumPromo", req: &tg.HelpGetPremiumPromoRequest{}}, - {name: "help.getInviteText", req: &tg.HelpGetInviteTextRequest{}}, - {name: "auth.initPasskeyLogin", req: &tg.AuthInitPasskeyLoginRequest{APIID: 4, APIHash: "test"}}, - {name: "account.getPassword", req: &tg.AccountGetPasswordRequest{}}, - {name: "account.getNotifySettings", req: &tg.AccountGetNotifySettingsRequest{Peer: &tg.InputNotifyUsers{}}}, - {name: "account.resetNotifySettings", req: &tg.AccountResetNotifySettingsRequest{}}, - {name: "account.getPrivacy", req: &tg.AccountGetPrivacyRequest{Key: &tg.InputPrivacyKeyStatusTimestamp{}}}, - {name: "account.getAuthorizations", req: &tg.AccountGetAuthorizationsRequest{}}, - {name: "account.getWebAuthorizations", req: &tg.AccountGetWebAuthorizationsRequest{}}, - {name: "account.getNotifyExceptions", req: &tg.AccountGetNotifyExceptionsRequest{}}, - {name: "account.getDefaultEmojiStatuses", req: &tg.AccountGetDefaultEmojiStatusesRequest{}}, - {name: "account.getRecentEmojiStatuses", req: &tg.AccountGetRecentEmojiStatusesRequest{}}, - {name: "account.getCollectibleEmojiStatuses", req: &tg.AccountGetCollectibleEmojiStatusesRequest{}}, - {name: "account.getDefaultProfilePhotoEmojis", req: &tg.AccountGetDefaultProfilePhotoEmojisRequest{}}, - {name: "account.getDefaultGroupPhotoEmojis", req: &tg.AccountGetDefaultGroupPhotoEmojisRequest{}}, - {name: "account.getDefaultBackgroundEmojis", req: &tg.AccountGetDefaultBackgroundEmojisRequest{}}, - {name: "account.getChannelDefaultEmojiStatuses", req: &tg.AccountGetChannelDefaultEmojiStatusesRequest{}}, - {name: "account.getChannelRestrictedStatusEmojis", req: &tg.AccountGetChannelRestrictedStatusEmojisRequest{}}, - {name: "account.getConnectedBots", req: &tg.AccountGetConnectedBotsRequest{}}, - {name: "account.getReactionsNotifySettings", req: &tg.AccountGetReactionsNotifySettingsRequest{}}, - {name: "account.getContactSignUpNotification", req: &tg.AccountGetContactSignUpNotificationRequest{}}, - {name: "account.getThemes", req: &tg.AccountGetThemesRequest{Format: "tdesktop"}}, - {name: "account.getContentSettings", req: &tg.AccountGetContentSettingsRequest{}}, - {name: "account.getGlobalPrivacySettings", req: &tg.AccountGetGlobalPrivacySettingsRequest{}}, - {name: "account.getPasskeys", req: &tg.AccountGetPasskeysRequest{}}, - {name: "account.getAutoDownloadSettings", req: &tg.AccountGetAutoDownloadSettingsRequest{}}, - {name: "account.getSavedMusicIds", req: &tg.AccountGetSavedMusicIDsRequest{}}, - {name: "account.getSavedRingtones", req: &tg.AccountGetSavedRingtonesRequest{}}, - {name: "account.resetPassword", req: &tg.AccountResetPasswordRequest{}}, - {name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}}, - {name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}}, - {name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}}, - {name: "users.getFullUser", req: &tg.UsersGetFullUserRequest{ID: &tg.InputUserSelf{}}}, - {name: "users.getSavedMusic", req: &tg.UsersGetSavedMusicRequest{ID: &tg.InputUserSelf{}, Limit: 20}}, - {name: "users.getSavedMusicByID", req: &tg.UsersGetSavedMusicByIDRequest{ID: &tg.InputUserSelf{}, Documents: []tg.InputDocumentClass{}}}, - {name: "messages.getDialogFilters", req: &tg.MessagesGetDialogFiltersRequest{}}, - {name: "messages.getDialogs", req: &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}}, - {name: "messages.getPinnedDialogs", req: &tg.MessagesGetPinnedDialogsRequest{}}, - {name: "messages.getPeerDialogs", req: &tg.MessagesGetPeerDialogsRequest{Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}}}, - {name: "messages.getAvailableReactions", req: &tg.MessagesGetAvailableReactionsRequest{}}, - {name: "messages.getAvailableEffects", req: &tg.MessagesGetAvailableEffectsRequest{}}, - {name: "messages.getStickers", req: &tg.MessagesGetStickersRequest{}}, - {name: "messages.getArchivedStickers", req: &tg.MessagesGetArchivedStickersRequest{Limit: 20}}, - {name: "messages.getStickerSet", req: &tg.MessagesGetStickerSetRequest{Stickerset: &tg.InputStickerSetEmpty{}}}, - {name: "messages.getEmojiGroups", req: &tg.MessagesGetEmojiGroupsRequest{}}, - {name: "messages.getEmojiStickerGroups", req: &tg.MessagesGetEmojiStickerGroupsRequest{}}, - {name: "messages.getEmojiProfilePhotoGroups", req: &tg.MessagesGetEmojiProfilePhotoGroupsRequest{}}, - {name: "messages.getEmojiKeywordsLanguages", req: &tg.MessagesGetEmojiKeywordsLanguagesRequest{LangCodes: []string{"en"}}}, - {name: "messages.getAttachMenuBots", req: &tg.MessagesGetAttachMenuBotsRequest{}}, - {name: "messages.getQuickReplies", req: &tg.MessagesGetQuickRepliesRequest{}}, - {name: "messages.getSavedHistory", req: &tg.MessagesGetSavedHistoryRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, - {name: "messages.readSavedHistory", req: &tg.MessagesReadSavedHistoryRequest{ParentPeer: &tg.InputPeerChannel{ChannelID: 1, AccessHash: 1}, Peer: &tg.InputPeerSelf{}}}, - {name: "messages.deleteSavedHistory", req: &tg.MessagesDeleteSavedHistoryRequest{Peer: &tg.InputPeerSelf{}}}, - {name: "messages.getPeerSettings", req: &tg.MessagesGetPeerSettingsRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}, - {name: "messages.getHistory", req: &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Limit: 20}}, - {name: "messages.readHistory", req: &tg.MessagesReadHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}, - {name: "messages.search", req: &tg.MessagesSearchRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Filter: &tg.InputMessagesFilterEmpty{}, Limit: 20}}, - {name: "messages.searchGlobal", req: &tg.MessagesSearchGlobalRequest{Q: "login", Filter: &tg.InputMessagesFilterEmpty{}, OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}}, - {name: "messages.getWebPage", req: &tg.MessagesGetWebPageRequest{URL: "https://example.invalid"}}, - {name: "messages.getScheduledHistory", req: &tg.MessagesGetScheduledHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}}, - {name: "contacts.getContacts", req: &tg.ContactsGetContactsRequest{}}, - {name: "contacts.search", req: &tg.ContactsSearchRequest{Q: "Test", Limit: 20}}, - {name: "contacts.getBlocked", req: &tg.ContactsGetBlockedRequest{Limit: 20}}, - {name: "contacts.getBirthdays", req: &tg.ContactsGetBirthdaysRequest{}}, - {name: "contacts.getTopPeers", req: &tg.ContactsGetTopPeersRequest{Correspondents: true, Limit: 10}}, - {name: "contacts.getSponsoredPeers", req: &tg.ContactsGetSponsoredPeersRequest{Q: "Test"}}, - {name: "stories.getAllStories", req: &tg.StoriesGetAllStoriesRequest{}}, - {name: "stories.getStoriesArchive", req: &tg.StoriesGetStoriesArchiveRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, - {name: "stories.getPinnedStories", req: &tg.StoriesGetPinnedStoriesRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, - {name: "stories.getAlbums", req: &tg.StoriesGetAlbumsRequest{Peer: &tg.InputPeerSelf{}}}, - {name: "stories.getAllReadPeerStories", req: &tg.StoriesGetAllReadPeerStoriesRequest{}}, - {name: "stories.getPeerMaxIDs", req: &tg.StoriesGetPeerMaxIDsRequest{ID: []tg.InputPeerClass{&tg.InputPeerSelf{}}}}, - {name: "payments.getStarGiftActiveAuctions", req: &tg.PaymentsGetStarGiftActiveAuctionsRequest{}}, - {name: "payments.getSavedStarGifts", req: &tg.PaymentsGetSavedStarGiftsRequest{Peer: &tg.InputPeerSelf{}, Limit: 20}}, - {name: "payments.getSavedStarGift", req: &tg.PaymentsGetSavedStarGiftRequest{Stargift: []tg.InputSavedStarGiftClass{}}}, - {name: "aicompose.getTones", req: &tg.AicomposeGetTonesRequest{}}, - {name: "langpack.getLangPack", req: &tg.LangpackGetLangPackRequest{LangPack: "tdesktop", LangCode: "en"}}, - {name: "langpack.getDifference", req: &tg.LangpackGetDifferenceRequest{LangPack: "tdesktop", LangCode: "en", FromVersion: 1}}, - {name: "langpack.getStrings", req: &tg.LangpackGetStringsRequest{LangPack: "tdesktop", LangCode: "en", Keys: []string{"lng_intro_about"}}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var in bin.Buffer - if err := tt.req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - ctx := WithUserID(context.Background(), 1000000001) - enc, err := r.Dispatch(ctx, [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - var out bin.Buffer - if err := enc.Encode(&out); err != nil { - t.Fatalf("encode response: %v", err) - } - if out.Len() == 0 { - t.Fatal("encoded response is empty") - } - }) - } -} - -func TestContactsSearchFindsUsers(t *testing.T) { - ctx := context.Background() - users := memory.NewUserStore() - owner, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Searchable", LastName: "Friend", Username: "search_friend"}) - if err != nil { - t.Fatalf("create friend: %v", err) - } - r := New(Config{}, Deps{ - Contacts: appcontacts.NewService(memory.NewContactStore(), users), - }, zaptest.NewLogger(t), clock.System) - - var in bin.Buffer - if err := (&tg.ContactsSearchRequest{Q: "@search", Limit: 20}).Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - box, ok := enc.(*tg.ContactsFound) - if !ok { - t.Fatalf("result type = %T, want *tg.ContactsFound", enc) - } - if len(box.Results) != 1 || len(box.Users) != 1 { - t.Fatalf("search result sizes = results %d users %d, want 1/1", len(box.Results), len(box.Users)) - } - peer, ok := box.Results[0].(*tg.PeerUser) - if !ok || peer.UserID != friend.ID { - t.Fatalf("peer = %T %+v, want friend", box.Results[0], box.Results[0]) - } -} - -func TestContactsSearchFindsPublicChannels(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000011", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - viewer, err := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550000012", FirstName: "Viewer"}) - if err != nil { - t.Fatalf("create viewer: %v", err) - } - stranger, err := userStore.Create(ctx, domain.User{AccessHash: 13, Phone: "15550000013", FirstName: "Stranger"}) - if err != nil { - t.Fatalf("create stranger: %v", err) - } - channelStore := memory.NewChannelStore() - channelSvc := appchannels.NewService(channelStore) - created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ - Title: "CU Public RPC", - Megagroup: true, - MemberUserIDs: []int64{viewer.ID}, - Date: 1700000012, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - public, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ - ChannelID: created.Channel.ID, - Username: "cu_public_rpc", - }) - if err != nil { - t.Fatalf("set channel username: %v", err) - } - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Contacts: appcontacts.NewService(memory.NewContactStore(), userStore), - Channels: channelSvc, - }, zaptest.NewLogger(t), clock.System) - - var in bin.Buffer - if err := (&tg.ContactsSearchRequest{Q: "CU Public", Limit: 20}).Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - enc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - box, ok := enc.(*tg.ContactsFound) - if !ok { - t.Fatalf("result type = %T, want *tg.ContactsFound", enc) - } - if len(box.MyResults) != 1 || len(box.Chats) != 1 { - t.Fatalf("search result sizes = my %d chats %d, want 1/1", len(box.MyResults), len(box.Chats)) - } - peer, ok := box.MyResults[0].(*tg.PeerChannel) - if !ok || peer.ChannelID != public.ID { - t.Fatalf("peer = %T %+v, want public channel", box.MyResults[0], box.MyResults[0]) - } - chat, ok := box.Chats[0].(*tg.Channel) - if !ok || chat.ID != public.ID || chat.Username != "cu_public_rpc" { - t.Fatalf("chat = %T %+v, want public channel chat", box.Chats[0], box.Chats[0]) - } - if chat.Left { - t.Fatalf("member search chat left = true, want active member channel") - } - - var strangerIn bin.Buffer - if err := (&tg.ContactsSearchRequest{Q: "CU Public", Limit: 20}).Encode(&strangerIn); err != nil { - t.Fatalf("encode stranger request: %v", err) - } - strangerEnc, err := r.Dispatch(WithUserID(ctx, stranger.ID), [8]byte{}, 0, &strangerIn) - if err != nil { - t.Fatalf("dispatch stranger: %v", err) - } - strangerBox, ok := strangerEnc.(*tg.ContactsFound) - if !ok { - t.Fatalf("stranger result type = %T, want *tg.ContactsFound", strangerEnc) - } - if len(strangerBox.Results) != 1 || len(strangerBox.Chats) != 1 { - t.Fatalf("stranger search sizes = results %d chats %d, want 1/1", len(strangerBox.Results), len(strangerBox.Chats)) - } - strangerPeer, ok := strangerBox.Results[0].(*tg.PeerChannel) - if !ok || strangerPeer.ChannelID != public.ID { - t.Fatalf("stranger peer = %T %+v, want public channel", strangerBox.Results[0], strangerBox.Results[0]) - } - strangerChat, ok := strangerBox.Chats[0].(*tg.Channel) - if !ok || !strangerChat.Left || strangerChat.ID != public.ID { - t.Fatalf("stranger chat = %T %+v, want left public channel", strangerBox.Chats[0], strangerBox.Chats[0]) - } - - resolved, err := r.onContactsResolveUsername(WithUserID(ctx, viewer.ID), &tg.ContactsResolveUsernameRequest{Username: "@CU_PUBLIC_RPC"}) - if err != nil { - t.Fatalf("resolve username: %v", err) - } - resolvedPeer, ok := resolved.Peer.(*tg.PeerChannel) - if !ok || resolvedPeer.ChannelID != public.ID || len(resolved.Chats) != 1 { - t.Fatalf("resolved channel = %+v, want peer + chat", resolved) - } -} - -func TestUsernameRPCLifecycle(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - other, err := userStore.Create(ctx, domain.User{AccessHash: 2, Phone: "15550000002", FirstName: "Other", Username: "taken_name"}) - if err != nil { - t.Fatalf("create other: %v", err) - } - r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System) - reqCtx := WithUserID(ctx, owner.ID) - - ok, err := r.onAccountCheckUsername(reqCtx, "taken_name") - if err != nil || ok { - t.Fatalf("check occupied = ok %v err %v, want false/nil", ok, err) - } - user, err := r.onAccountUpdateUsername(reqCtx, "owner_name") - if err != nil { - t.Fatalf("update username: %v", err) - } - self, ok := user.(*tg.User) - if !ok || self.Username != "owner_name" || len(self.Usernames) != 1 || !self.Usernames[0].Active { - t.Fatalf("updated user = %T %+v, want self with active username", user, user) - } - - resolved, err := r.onContactsResolveUsername(reqCtx, &tg.ContactsResolveUsernameRequest{Username: "@OWNER_NAME"}) - if err != nil { - t.Fatalf("resolve username: %v", err) - } - peer, ok := resolved.Peer.(*tg.PeerUser) - if !ok || peer.UserID != owner.ID || len(resolved.Users) != 1 { - t.Fatalf("resolved username = %+v, want owner peer", resolved) - } - - resolvedPhone, err := r.onContactsResolvePhone(reqCtx, "+15550000002") - if err != nil { - t.Fatalf("resolve phone: %v", err) - } - phonePeer, ok := resolvedPhone.Peer.(*tg.PeerUser) - if !ok || phonePeer.UserID != other.ID { - t.Fatalf("resolved phone peer = %+v, want other", resolvedPhone.Peer) - } -} - -func TestAccountUpdateProfileRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 1, Phone: "15550000001", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System) - req := &tg.AccountUpdateProfileRequest{} - req.SetFirstName("Updated") - req.SetLastName("User") - req.SetAbout("profile bio") - - user, err := r.onAccountUpdateProfile(WithUserID(ctx, owner.ID), req) - if err != nil { - t.Fatalf("update profile: %v", err) - } - self, ok := user.(*tg.User) - if !ok || self.FirstName != "Updated" || self.LastName != "User" { - t.Fatalf("updated user = %T %+v, want updated self user", user, user) - } - full, err := r.onUsersGetFullUser(WithUserID(ctx, owner.ID), &tg.InputUserSelf{}) - if err != nil { - t.Fatalf("get full user: %v", err) - } - if full.FullUser.About != "profile bio" { - t.Fatalf("full about = %q, want profile bio", full.FullUser.About) - } -} - -func TestUsersSavedMusicStubsValidateInput(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550000001", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550000002", FirstName: "Friend"}) - if err != nil { - t.Fatalf("create friend: %v", err) - } - r := New(Config{}, Deps{Users: appusers.NewService(userStore)}, zaptest.NewLogger(t), clock.System) - reqCtx := WithUserID(ctx, owner.ID) - - got, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ - ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - Offset: 0, - Limit: 20, - }) - if err != nil { - t.Fatalf("users.getSavedMusic: %v", err) - } - music, ok := got.(*tg.UsersSavedMusic) - if !ok || music.Count != 0 || len(music.Documents) != 0 { - t.Fatalf("saved music = %T %+v, want empty users.savedMusic", got, got) - } - if _, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ - ID: &tg.InputUserSelf{}, - Limit: maxSavedMusicLimit + 1, - }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("large saved music limit err = %v, want LIMIT_INVALID", err) - } - if _, err := r.onUsersGetSavedMusic(reqCtx, &tg.UsersGetSavedMusicRequest{ - ID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash + 1}, - Limit: 20, - }); err == nil || !strings.Contains(err.Error(), "USER_ID_INVALID") { - t.Fatalf("bad saved music user err = %v, want USER_ID_INVALID", err) - } -} - -func TestDialogFilterFromGetDialogsRequestUsesAllParameters(t *testing.T) { - r := New(Config{}, Deps{ - Users: staticUsersService{user: domain.User{ID: 1000000001, AccessHash: 42}}, - }, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesGetDialogsRequest{ - ExcludePinned: true, - OffsetDate: 1700000000, - OffsetID: 22, - OffsetPeer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, - Limit: 999, - Hash: 123456, - } - req.SetFolderID(7) - - filter, err := r.dialogFilterFromRequest(context.Background(), 1000000001, req) - if err != nil { - t.Fatalf("dialogFilterFromRequest: %v", err) - } - if !filter.ExcludePinned || !filter.HasFolderID || filter.FolderID != 7 { - t.Fatalf("folder/pinned filter = %+v", filter) - } - if filter.OffsetDate != req.OffsetDate || filter.OffsetID != req.OffsetID || !filter.HasOffsetPeer || filter.OffsetPeer.ID != domain.OfficialSystemUserID { - t.Fatalf("offset filter = %+v", filter) - } - if filter.Limit != 500 || filter.Hash != req.Hash { - t.Fatalf("limit/hash filter = %+v", filter) - } -} - -func TestMessagesGetDialogsReturnsNotModifiedFromFullListHash(t *testing.T) { - dialogs := &captureDialogs{list: domain.DialogList{Count: 3, Hash: 77}} - r := New(Config{}, Deps{Dialogs: dialogs}, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesGetDialogsRequest{ - OffsetPeer: &tg.InputPeerEmpty{}, - Limit: 20, - Hash: 77, - } - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - box, ok := enc.(*tg.MessagesDialogsBox) - if !ok { - t.Fatalf("response = %T, want *tg.MessagesDialogsBox", enc) - } - got, ok := box.Dialogs.(*tg.MessagesDialogsNotModified) - if !ok { - t.Fatalf("boxed response = %T, want *tg.MessagesDialogsNotModified", box.Dialogs) - } - if got.Count != 3 || dialogs.filter.Hash != 77 { - t.Fatalf("not modified = %+v filter %+v, want count/hash from service", got, dialogs.filter) - } -} - -func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550001002", FirstName: "Friend"}) - if err != nil { - t.Fatalf("create friend: %v", err) - } - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), - }, zaptest.NewLogger(t), clock.System) - - invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "E2E Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - updates, ok := invited.Updates.(*tg.Updates) - if !ok || len(updates.Chats) != 1 { - t.Fatalf("updates = %T %+v, want one chat", invited.Updates, invited.Updates) - } - channel, ok := updates.Chats[0].(*tg.Channel) - if !ok || !channel.Megagroup || channel.Broadcast { - t.Fatalf("chat = %#v, want megagroup channel", updates.Chats[0]) - } - if len(updates.Updates) != 4 { - t.Fatalf("updates len = %d, want create/invite service messages plus channel refreshes", len(updates.Updates)) - } - newMsg, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage) - if !ok || newMsg.Pts != 1 || newMsg.PtsCount != 1 { - t.Fatalf("create update = %#v, want channel pts=1", updates.Updates[0]) - } - if refresh, ok := updates.Updates[1].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID { - t.Fatalf("create refresh = %#v, want channel refresh", updates.Updates[1]) - } - service, ok := newMsg.Message.(*tg.MessageService) - if !ok { - t.Fatalf("create message = %T, want service", newMsg.Message) - } - if _, ok := service.Action.(*tg.MessageActionChannelCreate); !ok { - t.Fatalf("service action = %T, want channel create", service.Action) - } - inviteMsg, ok := updates.Updates[2].(*tg.UpdateNewChannelMessage) - if !ok || inviteMsg.Pts != 2 || inviteMsg.PtsCount != 1 { - t.Fatalf("invite update = %#v, want channel pts=2", updates.Updates[2]) - } - if refresh, ok := updates.Updates[3].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID { - t.Fatalf("invite refresh = %#v, want channel refresh", updates.Updates[3]) - } - inviteService, ok := inviteMsg.Message.(*tg.MessageService) - if !ok { - t.Fatalf("invite message = %T, want service", inviteMsg.Message) - } - addUser, ok := inviteService.Action.(*tg.MessageActionChatAddUser) - if !ok || len(addUser.Users) != 1 || addUser.Users[0] != friend.ID { - t.Fatalf("invite action = %#v, want add friend %d", inviteService.Action, friend.ID) - } - if len(updates.Users) != 2 { - t.Fatalf("updates users len = %d, want owner + friend", len(updates.Users)) - } - - participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelParticipantsRecent{}, - Limit: 10, - }) - if err != nil { - t.Fatalf("get participants: %v", err) - } - participantList, ok := participants.(*tg.ChannelsChannelParticipants) - if !ok || participantList.Count != 2 || len(participantList.Participants) != 2 || len(participantList.Users) != 2 { - t.Fatalf("participants = %T %+v, want owner + friend participants/users", participants, participants) - } - - req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20} - var b bin.Buffer - if err := req.Encode(&b); err != nil { - t.Fatalf("encode get dialogs: %v", err) - } - enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &b) - if err != nil { - t.Fatalf("dispatch get dialogs: %v", err) - } - box, ok := enc.(*tg.MessagesDialogsBox) - if !ok { - t.Fatalf("dialogs response = %T, want box", enc) - } - dialogs, ok := box.Dialogs.(*tg.MessagesDialogs) - if !ok || len(dialogs.Dialogs) != 1 || len(dialogs.Chats) != 1 || len(dialogs.Messages) != 1 { - t.Fatalf("dialogs = %T %+v, want channel dialog/chat/message", box.Dialogs, box.Dialogs) - } - dialog := dialogs.Dialogs[0].(*tg.Dialog) - if peer, ok := dialog.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { - t.Fatalf("dialog peer = %#v, want channel %d", dialog.Peer, channel.ID) - } -} - -func TestMessagesCreateChatRejectsEmptyInviteListRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550001021", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(memory.NewChannelStore()), - }, zaptest.NewLogger(t), clock.System) - - if _, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Title: "No Invitees", - }); err == nil || !strings.Contains(err.Error(), "USERS_TOO_FEW") { - t.Fatalf("create chat without users err = %v, want USERS_TOO_FEW", err) - } -} - -func TestMessagesCreateChatTDesktopReturnsLegacyChatAndAcceptsInputPeerChatRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550001031", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Friend"}) - if err != nil { - t.Fatalf("create friend: %v", err) - } - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), - }, zaptest.NewLogger(t), clock.System) - tdCtx := WithClientInfo(WithUserID(ctx, owner.ID), ClientInfo{ - DeviceModel: "Desktop", - AppVersion: "6.8.4 x64", - LangPack: "tdesktop", - }) - - invited, err := r.onMessagesCreateChat(tdCtx, &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "TDesktop Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - updates, ok := invited.Updates.(*tg.Updates) - if !ok || len(updates.Chats) != 2 { - t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates) - } - legacy, ok := updates.Chats[0].(*tg.Chat) - if !ok || !legacy.Deactivated { - t.Fatalf("legacy chat = %#v, want migrated chat", updates.Chats[0]) - } - channel, ok := updates.Chats[1].(*tg.Channel) - if !ok || !channel.Megagroup || channel.Broadcast { - t.Fatalf("channel = %#v, want megagroup channel", updates.Chats[1]) - } - if !channel.Creator { - t.Fatalf("channel creator flag = false, want true for creator") - } - if rights, ok := channel.GetAdminRights(); !ok || !rights.ChangeInfo || !rights.InviteUsers { - t.Fatalf("channel admin rights = %+v ok=%v, want creator manage rights", rights, ok) - } - migrated, ok := legacy.GetMigratedTo() - if !ok { - t.Fatalf("legacy chat missing migrated_to") - } - migratedTo, ok := migrated.(*tg.InputChannel) - if !ok || migratedTo.ChannelID != channel.ID || migratedTo.AccessHash != channel.AccessHash { - t.Fatalf("migrated_to = %#v, want channel %d/%d", migrated, channel.ID, channel.AccessHash) - } - - participants, err := r.onChannelsGetParticipants(tdCtx, &tg.ChannelsGetParticipantsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelParticipantsRecent{}, - Limit: 10, - }) - if err != nil { - t.Fatalf("get participants after create chat: %v", err) - } - participantList, ok := participants.(*tg.ChannelsChannelParticipants) - if !ok || len(participantList.Chats) != 0 { - t.Fatalf("participants = %T %+v, want no chat side vector", participants, participants) - } - if participantList.Count != 2 || len(participantList.Participants) != 2 || len(participantList.Users) != 2 { - t.Fatalf("participants count/rows/users = %d/%d/%d, want owner + friend", - participantList.Count, len(participantList.Participants), len(participantList.Users)) - } - legacyHistoryReq := &tg.MessagesGetHistoryRequest{ - Peer: &tg.InputPeerChat{ChatID: channel.ID}, - Limit: 20, - } - var legacyHistoryBuf bin.Buffer - if err := legacyHistoryReq.Encode(&legacyHistoryBuf); err != nil { - t.Fatalf("encode legacy history: %v", err) - } - legacyHistory, err := r.Dispatch(tdCtx, [8]byte{}, 0, &legacyHistoryBuf) - if err != nil { - t.Fatalf("legacy history: %v", err) - } - legacyBox, ok := legacyHistory.(*tg.MessagesMessagesBox) - if !ok { - t.Fatalf("legacy history = %T %+v, want messages box", legacyHistory, legacyHistory) - } - legacyMessages, ok := legacyBox.Messages.(*tg.MessagesMessages) - if !ok || len(legacyMessages.Messages) != 0 { - t.Fatalf("legacy history = %T %+v, want empty messages.messages", legacyHistory, legacyHistory) - } - - sent, err := r.onMessagesSendMessage(tdCtx, &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChat{ChatID: channel.ID}, - RandomID: 99, - Message: "via legacy input peer", - }) - if err != nil { - t.Fatalf("send via inputPeerChat: %v", err) - } - sentUpdates, ok := sent.(*tg.Updates) - if !ok || len(sentUpdates.Updates) < 2 { - t.Fatalf("send updates = %T %+v, want channel message updates", sent, sent) - } - newMsg, ok := sentUpdates.Updates[1].(*tg.UpdateNewChannelMessage) - if !ok { - t.Fatalf("send update = %#v, want updateNewChannelMessage", sentUpdates.Updates[1]) - } - msg, ok := newMsg.Message.(*tg.Message) - if !ok || msg.Message != "via legacy input peer" { - t.Fatalf("sent message = %#v, want text channel message", newMsg.Message) - } - if peer, ok := msg.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { - t.Fatalf("sent peer = %#v, want peerChannel %d", msg.PeerID, channel.ID) - } -} - -func TestMessagesCreateChatDispatchRemembersTDesktopClientInfo(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550001041", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550001042", FirstName: "Friend"}) - if err != nil { - t.Fatalf("create friend: %v", err) - } - channelStore := memory.NewChannelStore() - sessions := &captureSessions{} - r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - - rawAuthKeyID := [8]byte{0x42, 0x01} - sessionID := int64(77) - initReq := &tg.InvokeWithLayerRequest{ - Layer: 225, - Query: &tg.InitConnectionRequest{ - APIID: 111111, - DeviceModel: "Desktop", - AppVersion: "6.8.4 x64", - SystemLangCode: "en", - LangPack: "tdesktop", - LangCode: "en", - Query: &tg.HelpGetConfigRequest{}, - }, - } - var initBuf bin.Buffer - if err := initReq.Encode(&initBuf); err != nil { - t.Fatalf("encode init: %v", err) - } - if _, err := r.Dispatch(WithUserID(ctx, owner.ID), rawAuthKeyID, sessionID, &initBuf); err != nil { - t.Fatalf("dispatch init: %v", err) - } - - createReq := &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Dispatch TDesktop Group", - } - var createBuf bin.Buffer - if err := createReq.Encode(&createBuf); err != nil { - t.Fatalf("encode create chat: %v", err) - } - enc, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &createBuf) - if err != nil { - t.Fatalf("dispatch create chat: %v", err) - } - invited, ok := enc.(*tg.MessagesInvitedUsers) - if !ok { - t.Fatalf("create response = %T, want messages.invitedUsers", enc) - } - updates, ok := invited.Updates.(*tg.Updates) - if !ok || len(updates.Chats) != 2 { - t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates) - } - if legacy, ok := updates.Chats[0].(*tg.Chat); !ok || !legacy.Deactivated { - t.Fatalf("first chat = %#v, want migrated legacy chat", updates.Chats[0]) - } - channel, ok := updates.Chats[1].(*tg.Channel) - if !ok || !channel.Megagroup || !channel.Creator { - t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1]) - } - if len(updates.Updates) != 4 { - t.Fatalf("updates len = %d, want create/invite service messages plus channel refreshes", len(updates.Updates)) - } - participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelParticipantsRecent{}, - Limit: 10, - }) - if err != nil { - t.Fatalf("get participants: %v", err) - } - list, ok := participants.(*tg.ChannelsChannelParticipants) - if !ok || list.Count != 2 || len(list.Participants) != 2 || len(list.Users) != 2 { - t.Fatalf("participants = %T %+v, want owner + friend", participants, participants) - } - sessions.mu.Lock() - pushUserIDs := append([]int64(nil), sessions.pushUserIDs...) - sessions.mu.Unlock() - if len(pushUserIDs) != 1 || pushUserIDs[0] != friend.ID { - t.Fatalf("push user ids = %v, want only invited friend %d", pushUserIDs, friend.ID) - } -} - -func TestMessagesCreateChatSessionWithoutClientInfoReturnsLegacyChat(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550001051", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550001052", FirstName: "Friend"}) - if err != nil { - t.Fatalf("create friend: %v", err) - } - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), - }, zaptest.NewLogger(t), clock.System) - - invited, err := r.onMessagesCreateChat(WithSessionID(WithUserID(ctx, owner.ID), 99), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Session Legacy Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - updates, ok := invited.Updates.(*tg.Updates) - if !ok || len(updates.Chats) != 2 { - t.Fatalf("updates = %T %+v, want legacy chat + channel", invited.Updates, invited.Updates) - } - if legacy, ok := updates.Chats[0].(*tg.Chat); !ok || !legacy.Deactivated { - t.Fatalf("first chat = %#v, want migrated legacy chat", updates.Chats[0]) - } - if channel, ok := updates.Chats[1].(*tg.Channel); !ok || !channel.Megagroup || !channel.Creator { - t.Fatalf("second chat = %#v, want creator megagroup channel", updates.Chats[1]) - } -} - -func TestChannelsGetInactiveChannelsReturnsLeastActiveRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550001041", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - channelStore := memory.NewChannelStore() - channelSvc := appchannels.NewService(channelStore) - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: channelSvc, - }, zaptest.NewLogger(t), clock.System) - - createAndSend := func(title string, createDate, msgDate int) int64 { - t.Helper() - created, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ - Title: title, - Megagroup: true, - Date: createDate, - }) - if err != nil { - t.Fatalf("create %s: %v", title, err) - } - if _, err := channelSvc.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ - ChannelID: created.Channel.ID, - RandomID: int64(msgDate), - Message: title + " message", - Date: msgDate, - }); err != nil { - t.Fatalf("send %s: %v", title, err) - } - return created.Channel.ID - } - - oldID := createAndSend("Old inactive", 1000, 1100) - midID := createAndSend("Middle inactive", 1000, 1200) - newID := createAndSend("New inactive", 1000, 1300) - leftID := createAndSend("Left inactive", 1000, 900) - if _, err := channelSvc.LeaveChannel(ctx, owner.ID, leftID, 1400); err != nil { - t.Fatalf("leave channel: %v", err) - } - - got, err := r.onChannelsGetInactiveChannels(WithUserID(ctx, owner.ID)) - if err != nil { - t.Fatalf("get inactive channels: %v", err) - } - if len(got.Dates) != 3 || len(got.Chats) != 3 || len(got.Users) != 0 { - t.Fatalf("inactive chats = %+v, want three active channel chats and no users", got) - } - wantIDs := []int64{oldID, midID, newID} - wantDates := []int{1100, 1200, 1300} - for i, chat := range got.Chats { - channel, ok := chat.(*tg.Channel) - if !ok { - t.Fatalf("chat %d = %T, want *tg.Channel", i, chat) - } - if channel.ID != wantIDs[i] || got.Dates[i] != wantDates[i] { - t.Fatalf("inactive item %d = id %d date %d, want id %d date %d", i, channel.ID, got.Dates[i], wantIDs[i], wantDates[i]) - } - } -} - -func TestChannelsGetChannelRecommendationsRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550001051", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - other, err := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550001052", FirstName: "Other"}) - if err != nil { - t.Fatalf("create other: %v", err) - } - channelStore := memory.NewChannelStore() - channelSvc := appchannels.NewService(channelStore) - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: channelSvc, - }, zaptest.NewLogger(t), clock.System) - - createPublicBroadcast := func(creator domain.User, title, username string, date int) domain.Channel { - t.Helper() - created, err := channelSvc.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ - Title: title, - Broadcast: true, - Date: date, - }) - if err != nil { - t.Fatalf("create %s: %v", title, err) - } - channel, err := channelSvc.UpdateUsername(ctx, creator.ID, domain.UpdateChannelUsernameRequest{ - ChannelID: created.Channel.ID, - Username: username, - }) - if err != nil { - t.Fatalf("set username for %s: %v", title, err) - } - return channel - } - - source := createPublicBroadcast(owner, "Source Recommendations", "source_recs", 1000) - for i := 0; i < 12; i++ { - createPublicBroadcast(owner, "Candidate "+strconv.Itoa(i), "rec"+strconv.Itoa(i)+"public", 2000+i) - } - groupCreated, err := channelSvc.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ - Title: "Public Group", - Megagroup: true, - Date: 3000, - }) - if err != nil { - t.Fatalf("create public group: %v", err) - } - group, err := channelSvc.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ - ChannelID: groupCreated.Channel.ID, - Username: "group_recs", - }) - if err != nil { - t.Fatalf("set group username: %v", err) - } - - recommendationsReq := func(channel domain.Channel) *tg.ChannelsGetChannelRecommendationsRequest { - req := &tg.ChannelsGetChannelRecommendationsRequest{} - req.SetChannel(&tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) - return req - } - - got, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq(source)) - if err != nil { - t.Fatalf("get recommendations by source: %v", err) - } - slice, ok := got.(*tg.MessagesChatsSlice) - if !ok { - t.Fatalf("recommendations = %T %+v, want messages.chatsSlice", got, got) - } - if slice.Count != 12 || len(slice.Chats) != domain.DefaultChannelRecommendationsLimit { - t.Fatalf("recommendations count=%d len=%d, want count 12 len %d", slice.Count, len(slice.Chats), domain.DefaultChannelRecommendationsLimit) - } - for _, chat := range slice.Chats { - channel, ok := chat.(*tg.Channel) - if !ok { - t.Fatalf("recommendation chat = %T, want channel", chat) - } - if channel.ID == source.ID || !channel.Broadcast || channel.Megagroup || channel.Username == "" { - t.Fatalf("recommendation channel = %+v, want public broadcast excluding source", channel) - } - } - - if _, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq(group)); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { - t.Fatalf("megagroup recommendations err = %v, want CHANNEL_INVALID", err) - } - - globalA := createPublicBroadcast(other, "Global A", "global_recs_a", 5000) - globalB := createPublicBroadcast(other, "Global B", "global_recs_b", 5100) - global, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), &tg.ChannelsGetChannelRecommendationsRequest{}) - if err != nil { - t.Fatalf("get global recommendations: %v", err) - } - box, ok := global.(*tg.MessagesChats) - if !ok { - t.Fatalf("global recommendations = %T %+v, want messages.chats", global, global) - } - if len(box.Chats) != 2 { - t.Fatalf("global recommendations len=%d chats=%+v, want two channels", len(box.Chats), box.Chats) - } - wantIDs := []int64{globalB.ID, globalA.ID} - for i, chat := range box.Chats { - channel, ok := chat.(*tg.Channel) - if !ok { - t.Fatalf("global chat %d = %T, want channel", i, chat) - } - if channel.ID != wantIDs[i] { - t.Fatalf("global chat %d id=%d, want %d", i, channel.ID, wantIDs[i]) - } - } -} - -func TestChannelsDeleteChannelReturnsForbiddenChatAndHidesDialogRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550001031", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Friend"}) - if err != nil { - t.Fatalf("create friend: %v", err) - } - channelStore := memory.NewChannelStore() - sessions := &captureSessions{} - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - - invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Delete Me", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := invited.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - getDialogs := func(userID int64) *tg.MessagesDialogs { - t.Helper() - req := &tg.MessagesGetDialogsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20} - var b bin.Buffer - if err := req.Encode(&b); err != nil { - t.Fatalf("encode get dialogs: %v", err) - } - enc, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &b) - if err != nil { - t.Fatalf("dispatch get dialogs: %v", err) - } - box, ok := enc.(*tg.MessagesDialogsBox) - if !ok { - t.Fatalf("dialogs response = %T, want box", enc) - } - dialogs, ok := box.Dialogs.(*tg.MessagesDialogs) - if !ok { - t.Fatalf("dialogs = %T %+v, want messages.dialogs", box.Dialogs, box.Dialogs) - } - return dialogs - } - if got := getDialogs(owner.ID); len(got.Dialogs) != 1 || len(got.Chats) != 1 { - t.Fatalf("dialogs before delete = %+v, want one channel dialog", got) - } - - deleted, err := r.onChannelsDeleteChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ - ChannelID: channel.ID, - AccessHash: channel.AccessHash, - }) - if err != nil { - t.Fatalf("delete channel: %v", err) - } - updates, ok := deleted.(*tg.Updates) - if !ok || len(updates.Updates) != 1 || len(updates.Chats) != 1 { - t.Fatalf("delete response = %T %+v, want updateChannel + channelForbidden", deleted, deleted) - } - if update, ok := updates.Updates[0].(*tg.UpdateChannel); !ok || update.ChannelID != channel.ID { - t.Fatalf("delete update = %#v, want updateChannel %d", updates.Updates[0], channel.ID) - } - forbidden, ok := updates.Chats[0].(*tg.ChannelForbidden) - if !ok || forbidden.ID != channel.ID || forbidden.AccessHash != channel.AccessHash || forbidden.Title != channel.Title || !forbidden.Megagroup { - t.Fatalf("delete chat = %#v, want channelForbidden tombstone", updates.Chats[0]) - } - - pushed := sessions.snapshot() - if pushed.messageType != proto.MessageFromServer || pushed.userID == 0 { - t.Fatalf("push snapshot = %+v, want server update to a channel member", pushed) - } - pushedUpdates, ok := pushed.message.(*tg.Updates) - if !ok || len(pushedUpdates.Chats) != 1 { - t.Fatalf("pushed update = %T %+v, want channelForbidden chat", pushed.message, pushed.message) - } - if pushedForbidden, ok := pushedUpdates.Chats[0].(*tg.ChannelForbidden); !ok || pushedForbidden.ID != channel.ID { - t.Fatalf("pushed chat = %#v, want channelForbidden %d", pushedUpdates.Chats[0], channel.ID) - } - if got := getDialogs(owner.ID); len(got.Dialogs) != 0 || len(got.Chats) != 0 { - t.Fatalf("owner dialogs after delete = %+v, want hidden deleted channel", got) - } - if got := getDialogs(friend.ID); len(got.Dialogs) != 0 || len(got.Chats) != 0 { - t.Fatalf("friend dialogs after delete = %+v, want hidden deleted channel", got) - } -} - -func TestChannelRealtimeRecipientsPreferOnlineMembers(t *testing.T) { - ctx := context.Background() - channelStore := memory.NewChannelStore() - channelService := appchannels.NewService(channelStore) - created, err := channelService.CreateMegagroupFromCreateChat(ctx, 1001, domain.CreateChannelRequest{ - Title: "Fanout", - MemberUserIDs: []int64{1002, 1999}, - Date: 10, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - sessions := &captureSessions{ - onlineUserIDs: []int64{1999, 3000}, - channelViewers: map[int64][]int64{created.Channel.ID: {1999}}, - channelMembers: map[int64][]int64{created.Channel.ID: {1999, 3000}}, - } - r := New(Config{}, Deps{ - Channels: channelService, - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - contains := func(items []int64, want int64) bool { - for _, item := range items { - if item == want { - return true - } - } - return false - } - - got := r.channelFanoutRecipients(ctx, channelFanoutMembers, created.Channel.ID, []int64{1002}) - if !contains(got, 1999) { - t.Fatalf("recipients = %v, want online active member 1999", got) - } - if contains(got, 3000) { - t.Fatalf("recipients = %v, non-member online user leaked", got) - } - if !contains(got, 1002) { - t.Fatalf("recipients = %v, want explicit fallback recipient 1002", got) - } - onlines, err := r.onMessagesGetOnlines(WithUserID(ctx, 1001), &tg.InputPeerChannel{ - ChannelID: created.Channel.ID, - AccessHash: created.Channel.AccessHash, - }) - if err != nil { - t.Fatalf("messages.getOnlines: %v", err) - } - if onlines.Onlines != 2 { - t.Fatalf("messages.getOnlines = %d, want caller plus online active member", onlines.Onlines) - } -} - -func TestChannelInputAccessHashIsValidatedRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001101", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550001102", FirstName: "Friend"}) - if err != nil { - t.Fatalf("create friend: %v", err) - } - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore), - }, zaptest.NewLogger(t), clock.System) - - invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Hash Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := invited.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - badHash := channel.AccessHash + 1 - if badHash == 0 { - badHash = 1 - } - - if _, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: badHash}); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("get full bad access_hash err = %v, want CHANNEL_PRIVATE", err) - } - if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, - Message: "bad hash", - RandomID: 991, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("send bad access_hash err = %v, want CHANNEL_PRIVATE", err) - } - if _, err := r.onMessagesGetPeerSettings(WithUserID(ctx, owner.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("peer settings bad access_hash err = %v, want CHANNEL_PRIVATE", err) - } - if _, err := r.onMessagesToggleDialogPin(WithUserID(ctx, owner.ID), &tg.MessagesToggleDialogPinRequest{ - Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}}, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("toggle pin bad access_hash err = %v, want CHANNEL_PRIVATE", err) - } - if _, err := r.onFoldersEditPeerFolders(WithUserID(ctx, owner.ID), []tg.InputFolderPeer{{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, - FolderID: domain.DialogArchiveFolderID, - }}); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("edit peer folder bad access_hash err = %v, want CHANNEL_PRIVATE", err) - } - if _, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: badHash}, - Filter: &tg.ChannelMessagesFilterEmpty{}, - Pts: 0, - Limit: 10, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("difference bad access_hash err = %v, want CHANNEL_PRIVATE", err) - } - chats, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{&tg.InputChannel{ChannelID: channel.ID, AccessHash: badHash}}) - if err != nil { - t.Fatalf("get channels bad access_hash: %v", err) - } - if got := len(chats.(*tg.MessagesChats).Chats); got != 0 { - t.Fatalf("get channels bad access_hash chats = %d, want 0", got) - } - if _, err := r.dialogFilterFromRequest(WithUserID(ctx, owner.ID), owner.ID, &tg.MessagesGetDialogsRequest{ - OffsetPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, - Limit: 20, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("get dialogs offset bad access_hash err = %v, want CHANNEL_PRIVATE", err) - } - if _, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, - Message: "draft", - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("save draft bad access_hash err = %v, want CHANNEL_PRIVATE", err) - } - filterReq := &tg.MessagesUpdateDialogFilterRequest{ID: domain.DialogCustomFolderMinID} - filterReq.SetFilter(&tg.DialogFilter{ - ID: domain.DialogCustomFolderMinID, - Title: tg.TextWithEntities{Text: "Channels"}, - IncludePeers: []tg.InputPeerClass{ - &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}, - }, - }) - if ok, err := r.onMessagesUpdateDialogFilter(WithUserID(ctx, owner.ID), filterReq); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") || ok { - t.Fatalf("update dialog filter bad access_hash = %v, %v; want CHANNEL_PRIVATE", ok, err) - } - reply := &tg.InputReplyToMessage{ReplyToMsgID: 1} - reply.SetReplyToPeerID(&tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: badHash}) - sendReq := &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "bad reply hash", - RandomID: 992, - } - sendReq.SetReplyTo(reply) - if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), sendReq); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { - t.Fatalf("send reply_to_peer bad access_hash err = %v, want REPLY_MESSAGE_ID_INVALID", err) - } -} - -func TestChannelSendHistoryAndDifferenceRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002001", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002002", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - sessions := &captureScopedSessions{captureSessions: &captureSessions{}} - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "RPC Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - - var authKeyID [8]byte - authKeyID[0] = 9 - sendCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), authKeyID), 77) - sent, err := r.onMessagesSendMessage(sendCtx, &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "hello channel", - RandomID: 99, - }) - if err != nil { - t.Fatalf("send channel message: %v", err) - } - sendUpdates := sent.(*tg.Updates) - if id, ok := sendUpdates.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != 3 || id.RandomID != 99 { - t.Fatalf("message id update = %#v, want id=3 random_id=99", sendUpdates.Updates[0]) - } - newMsg, ok := sendUpdates.Updates[1].(*tg.UpdateNewChannelMessage) - if !ok || newMsg.Pts != 3 || newMsg.PtsCount != 1 { - t.Fatalf("new channel update = %#v, want pts=3", sendUpdates.Updates[1]) - } - msg := newMsg.Message.(*tg.Message) - if msg.PeerID.(*tg.PeerChannel).ChannelID != channel.ID || msg.Message != "hello channel" || !msg.Out { - t.Fatalf("channel message = %#v, want outgoing channel text", msg) - } - pushed := sessions.snapshot() - if pushed.userID != friend.ID || pushed.sessionID != 77 || pushed.messageType != proto.MessageFromServer { - t.Fatalf("pushed channel update = user %d exclude session %d type %v, want friend/exclude/from_server", pushed.userID, pushed.sessionID, pushed.messageType) - } - if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { - t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) - } - pushedUpdates, ok := pushed.message.(*tg.Updates) - if !ok || len(pushedUpdates.Updates) != 1 { - t.Fatalf("pushed channel update = %T %+v, want one updates container without updateMessageID", pushed.message, pushed.message) - } - pushedNew, ok := pushedUpdates.Updates[0].(*tg.UpdateNewChannelMessage) - if !ok { - t.Fatalf("pushed update[0] = %T, want updateNewChannelMessage", pushedUpdates.Updates[0]) - } - pushedMsg := pushedNew.Message.(*tg.Message) - if pushedMsg.Out || pushedMsg.Message != "hello channel" { - t.Fatalf("pushed message = %#v, want incoming channel text for friend", pushedMsg) - } - - history, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []tg.InputMessageClass{&tg.InputMessageID{ID: msg.ID}}, - }) - if err != nil { - t.Fatalf("get channel messages: %v", err) - } - messages := history.(*tg.MessagesMessages) - got := messages.Messages[0].(*tg.Message) - if got.Message != "hello channel" || got.Out { - t.Fatalf("history message = %#v, want incoming text for friend", got) - } - - contentAuthKeyID := [8]byte{0x44} - contentCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, friend.ID), contentAuthKeyID), 88) - if ok, err := r.onChannelsReadMessageContents(contentCtx, &tg.ChannelsReadMessageContentsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{msg.ID}, - }); err != nil || !ok { - t.Fatalf("channels.readMessageContents = ok %v err %v, want true", ok, err) - } - contentPush := sessions.snapshot() - if contentPush.userID != friend.ID || contentPush.sessionID != 88 || contentPush.messageType != proto.MessageFromServer { - t.Fatalf("content-read push = user %d exclude session %d type %v, want friend/exclude/from_server", contentPush.userID, contentPush.sessionID, contentPush.messageType) - } - if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != contentAuthKeyID { - t.Fatalf("content-read exclude auth_key_id = %x, want %x", gotAuthKeyID, contentAuthKeyID) - } - contentUpdates, ok := contentPush.message.(*tg.Updates) - if !ok || len(contentUpdates.Updates) != 1 { - t.Fatalf("content-read pushed message = %T %+v, want one update", contentPush.message, contentPush.message) - } - contentRead, ok := contentUpdates.Updates[0].(*tg.UpdateChannelReadMessagesContents) - if !ok || contentRead.ChannelID != channel.ID || len(contentRead.Messages) != 1 || contentRead.Messages[0] != msg.ID { - t.Fatalf("content-read update = %#v, want channel %d msg %d", contentUpdates.Updates[0], channel.ID, msg.ID) - } - - diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelMessagesFilterEmpty{}, - Pts: newMsg.Pts - 1, - Limit: 10, - }) - if err != nil { - t.Fatalf("channel difference: %v", err) - } - fullDiff, ok := diff.(*tg.UpdatesChannelDifference) - if !ok || fullDiff.Pts != newMsg.Pts || len(fullDiff.NewMessages) != 1 { - t.Fatalf("diff = %T %+v, want one new message at pts=%d", diff, diff, newMsg.Pts) - } - if _, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelMessagesFilterEmpty{}, - Pts: fullDiff.Pts + 1, - Limit: 10, - }); err == nil || !strings.Contains(err.Error(), "PERSISTENT_TIMESTAMP_INVALID") { - t.Fatalf("future channel pts err = %v, want PERSISTENT_TIMESTAMP_INVALID", err) - } - - readOK, err := r.onChannelsReadHistory(WithUserID(ctx, friend.ID), &tg.ChannelsReadHistoryRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MaxID: msg.ID, - }) - if err != nil || !readOK { - t.Fatalf("channels.readHistory = %v err %v, want true", readOK, err) - } - readPush := sessions.snapshot() - if readPush.userID != owner.ID || readPush.messageType != proto.MessageFromServer { - t.Fatalf("read outbox push = user %d type %v, want owner/from_server", readPush.userID, readPush.messageType) - } - readPushUpdates, ok := readPush.message.(*tg.Updates) - if !ok || len(readPushUpdates.Updates) != 1 { - t.Fatalf("read outbox pushed message = %T %+v, want one update", readPush.message, readPush.message) - } - readOutbox, ok := readPushUpdates.Updates[0].(*tg.UpdateReadChannelOutbox) - if !ok || readOutbox.ChannelID != channel.ID || readOutbox.MaxID != msg.ID { - t.Fatalf("read outbox update = %#v, want channel %d max %d", readPushUpdates.Updates[0], channel.ID, msg.ID) - } - fullAfterRead, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) - if err != nil { - t.Fatalf("get full channel after read: %v", err) - } - fullChannel := fullAfterRead.FullChat.(*tg.ChannelFull) - if fullChannel.ReadOutboxMaxID != msg.ID { - t.Fatalf("full channel read_outbox = %d, want %d", fullChannel.ReadOutboxMaxID, msg.ID) - } - readers, err := r.onMessagesGetMessageReadParticipants(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageReadParticipantsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: msg.ID, - }) - if err != nil { - t.Fatalf("get message read participants: %v", err) - } - if len(readers) != 1 || readers[0].UserID != friend.ID || readers[0].Date == 0 { - t.Fatalf("read participants = %+v, want friend read date", readers) - } - - editReq := &tg.MessagesEditMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: msg.ID, - Message: "edited channel", - } - editReq.SetMessage("edited channel") - edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq) - if err != nil { - t.Fatalf("edit channel message: %v", err) - } - editUpdates := edited.(*tg.Updates) - edit, ok := editUpdates.Updates[0].(*tg.UpdateEditChannelMessage) - if !ok || edit.Pts != newMsg.Pts+1 || edit.PtsCount != 1 { - t.Fatalf("edit update = %#v, want updateEditChannelMessage pts=%d", editUpdates.Updates[0], newMsg.Pts+1) - } - if edit.Message.(*tg.Message).Message != "edited channel" { - t.Fatalf("edited message = %#v, want edited text", edit.Message) - } - editData, err := r.onMessagesGetMessageEditData(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageEditDataRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: msg.ID, - }) - if err != nil { - t.Fatalf("get channel edit data: %v", err) - } - if editData.GetCaption() { - t.Fatalf("channel edit data caption = true, want false for text-only message") - } - - forwardReplyTo := &tg.InputReplyToMessage{ReplyToMsgID: msg.ID} - forwardReplyTo.SetQuoteText("channel") - forwardReq := &tg.MessagesForwardMessagesRequest{ - FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{msg.ID}, - RandomID: []int64{100}, - } - forwardReq.SetReplyTo(forwardReplyTo) - forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, friend.ID), forwardReq) - if err != nil { - t.Fatalf("forward channel message: %v", err) - } - forwardUpdates := forwarded.(*tg.Updates) - if id, ok := forwardUpdates.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != msg.ID+1 || id.RandomID != 100 { - t.Fatalf("forward id update = %#v, want id=%d", forwardUpdates.Updates[0], msg.ID+1) - } - forwardNew, ok := forwardUpdates.Updates[1].(*tg.UpdateNewChannelMessage) - if !ok || forwardNew.Pts != edit.Pts+1 || forwardNew.PtsCount != 1 { - t.Fatalf("forward new update = %#v, want pts=%d", forwardUpdates.Updates[1], edit.Pts+1) - } - forwardMsg := forwardNew.Message.(*tg.Message) - if forwardMsg.Message != "edited channel" || forwardMsg.FwdFrom.FromID == nil { - t.Fatalf("forward message = %#v, want fwd header and edited body", forwardMsg) - } - if header, ok := forwardMsg.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != msg.ID { - t.Fatalf("forward reply header = %#v, want reply to channel message %d", forwardMsg.ReplyTo, msg.ID) - } - - deleted, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{msg.ID, forwardMsg.ID}, - }) - if err != nil { - t.Fatalf("delete channel messages: %v", err) - } - if deleted.Pts != forwardNew.Pts+2 || deleted.PtsCount != 2 { - t.Fatalf("delete affected = %+v, want pts=%d count=2", deleted, forwardNew.Pts+2) - } - - diff, err = r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelMessagesFilterEmpty{}, - Pts: newMsg.Pts, - Limit: 10, - }) - if err != nil { - t.Fatalf("channel difference after edit/delete: %v", err) - } - fullDiff, ok = diff.(*tg.UpdatesChannelDifference) - if !ok || fullDiff.Pts != deleted.Pts || len(fullDiff.NewMessages) != 1 || len(fullDiff.OtherUpdates) != 2 { - t.Fatalf("diff after edit/delete = %T %+v, want forward message plus edit/delete updates", diff, diff) - } -} - -func TestChannelsReadMessageContentsClearsUnreadReactionAndPushesUpdate(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002141", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002142", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - sessions := &captureScopedSessions{captureSessions: &captureSessions{}} - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Reaction Read", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "owner message", - RandomID: 21041, - }) - if err != nil { - t.Fatalf("send channel message: %v", err) - } - msgID := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message).ID - req := &tg.MessagesSendReactionRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: msgID, - Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f525"}}, - } - req.SetReaction(req.Reaction) - if _, err := r.onMessagesSendReaction(WithUserID(ctx, friend.ID), req); err != nil { - t.Fatalf("friend send reaction: %v", err) - } - unread, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - }) - if err != nil { - t.Fatalf("get unread reactions: %v", err) - } - unreadMessages, _, _ := searchMessagesPayload(t, unread) - if len(unreadMessages) != 1 { - t.Fatalf("unread reactions = %+v, want one message", unread) - } - - contentAuthKeyID := [8]byte{0x66} - contentCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), contentAuthKeyID), 99) - if ok, err := r.onChannelsReadMessageContents(contentCtx, &tg.ChannelsReadMessageContentsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{msgID}, - }); err != nil || !ok { - t.Fatalf("channels.readMessageContents = ok %v err %v, want true", ok, err) - } - pushed := sessions.snapshot() - if pushed.userID != owner.ID || pushed.sessionID != 99 || pushed.messageType != proto.MessageFromServer { - t.Fatalf("reaction read push = user %d session %d type %v, want owner/exclude/from_server", pushed.userID, pushed.sessionID, pushed.messageType) - } - if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != contentAuthKeyID { - t.Fatalf("reaction read exclude auth_key_id = %x, want %x", gotAuthKeyID, contentAuthKeyID) - } - pushedUpdates, ok := pushed.message.(*tg.Updates) - if !ok || len(pushedUpdates.Updates) != 1 { - t.Fatalf("reaction read push = %T %+v, want one updateMessageReactions", pushed.message, pushed.message) - } - reactionUpdate, ok := pushedUpdates.Updates[0].(*tg.UpdateMessageReactions) - if !ok || reactionUpdate.MsgID != msgID { - t.Fatalf("reaction read update = %#v, want updateMessageReactions for %d", pushedUpdates.Updates[0], msgID) - } - for _, recent := range reactionUpdate.Reactions.RecentReactions { - if recent.Unread { - t.Fatalf("reaction read update recent = %+v, want unread cleared", reactionUpdate.Reactions.RecentReactions) - } - } - unreadAfter, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - }) - if err != nil { - t.Fatalf("get unread reactions after read contents: %v", err) - } - unreadAfterMessages, _, _ := searchMessagesPayload(t, unreadAfter) - if len(unreadAfterMessages) != 0 { - t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter) - } -} - -func TestChannelReadHistoryProducesReadChannelInboxDifference(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002131", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002132", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - updates := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Updates: updates, - Sessions: &captureSessions{}, - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Read Channel Inbox", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "read me", - RandomID: 301, - }) - if err != nil { - t.Fatalf("send channel message: %v", err) - } - msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - readOK, err := r.onChannelsReadHistory(WithUserID(ctx, friend.ID), &tg.ChannelsReadHistoryRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MaxID: msg.ID, - }) - if err != nil { - t.Fatalf("channels.readHistory: %v", err) - } - if !readOK { - t.Fatalf("channels.readHistory = false, want true") - } - diff, err := r.onUpdatesGetDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetDifferenceRequest{Pts: 0}) - if err != nil { - t.Fatalf("updates.getDifference: %v", err) - } - full, ok := diff.(*tg.UpdatesDifference) - if !ok || len(full.OtherUpdates) != 1 { - t.Fatalf("difference = %T %+v, want one read channel inbox update", diff, diff) - } - read, ok := full.OtherUpdates[0].(*tg.UpdateReadChannelInbox) - if !ok || read.ChannelID != channel.ID || read.MaxID != msg.ID || read.StillUnreadCount != 0 { - t.Fatalf("difference update = %#v, want updateReadChannelInbox channel %d max %d", full.OtherUpdates[0], channel.ID, msg.ID) - } - if len(full.Chats) != 1 { - t.Fatalf("difference chats = %d, want channel context", len(full.Chats)) - } -} - -func TestChannelGetMessagesReturnsSparseIDs(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002101", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002102", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Sessions: &captureSessions{}, - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Sparse RPC Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - var firstID, lastID int - for i := 0; i < 5; i++ { - updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "sparse-" + strconv.Itoa(i), - RandomID: int64(100 + i), - }) - if err != nil { - t.Fatalf("send channel message %d: %v", i, err) - } - msg := updates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - if i == 0 { - firstID = msg.ID - } - lastID = msg.ID - } - - got, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []tg.InputMessageClass{ - &tg.InputMessageID{ID: firstID}, - &tg.InputMessageID{ID: lastID}, - }, - }) - if err != nil { - t.Fatalf("get sparse channel messages: %v", err) - } - messages := got.(*tg.MessagesMessages).Messages - if len(messages) != 2 { - t.Fatalf("messages len = %d, want 2", len(messages)) - } - first := messages[0].(*tg.Message) - last := messages[1].(*tg.Message) - if first.ID != firstID || first.Message != "sparse-0" || last.ID != lastID || last.Message != "sparse-4" { - t.Fatalf("sparse messages = %#v %#v, want first and last exact ids", first, last) - } -} - -func TestChannelsDeleteHistoryLocalClearEmitsAvailableMessagesUpdate(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002141", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002142", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) - sessions := &captureSessions{} - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Updates: updateSvc, - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Clear Channel", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "clear me", - RandomID: 401, - }) - if err != nil { - t.Fatalf("send channel message: %v", err) - } - msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - cleared, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MaxID: msg.ID, - }) - if err != nil { - t.Fatalf("delete channel history local: %v", err) - } - updates, ok := cleared.(*tg.Updates) - if !ok || len(updates.Updates) != 1 { - t.Fatalf("clear response = %T %+v, want one update", cleared, cleared) - } - available, ok := updates.Updates[0].(*tg.UpdateChannelAvailableMessages) - if !ok || available.ChannelID != channel.ID || available.AvailableMinID != msg.ID { - t.Fatalf("clear update = %#v, want updateChannelAvailableMessages channel=%d min=%d", updates.Updates[0], channel.ID, msg.ID) - } - pushed := sessions.snapshot() - if pushed.userID != owner.ID { - t.Fatalf("pushed user = %d, want owner %d", pushed.userID, owner.ID) - } - pushedUpdates, ok := pushed.message.(*tg.Updates) - if !ok || len(pushedUpdates.Updates) != 1 { - t.Fatalf("pushed clear update = %T %+v, want one update", pushed.message, pushed.message) - } - if _, ok := pushedUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages); !ok { - t.Fatalf("pushed update[0] = %T, want updateChannelAvailableMessages", pushedUpdates.Updates[0]) - } - diff, err := r.onUpdatesGetDifference(WithUserID(ctx, owner.ID), &tg.UpdatesGetDifferenceRequest{Pts: 0}) - if err != nil { - t.Fatalf("get difference: %v", err) - } - full, ok := diff.(*tg.UpdatesDifference) - if !ok || len(full.OtherUpdates) != 1 { - t.Fatalf("difference = %T %+v, want one other update", diff, diff) - } - if diffUpdate, ok := full.OtherUpdates[0].(*tg.UpdateChannelAvailableMessages); !ok || diffUpdate.ChannelID != channel.ID || diffUpdate.AvailableMinID != msg.ID { - t.Fatalf("difference update = %#v, want updateChannelAvailableMessages", full.OtherUpdates[0]) - } - - stale, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MaxID: msg.ID - 1, - }) - if err != nil { - t.Fatalf("stale delete channel history local: %v", err) - } - staleUpdates, ok := stale.(*tg.Updates) - if !ok || len(staleUpdates.Updates) != 1 { - t.Fatalf("stale clear response = %T %+v, want one update", stale, stale) - } - staleAvailable, ok := staleUpdates.Updates[0].(*tg.UpdateChannelAvailableMessages) - if !ok || staleAvailable.ChannelID != channel.ID || staleAvailable.AvailableMinID != msg.ID { - t.Fatalf("stale clear update = %#v, want monotonic updateChannelAvailableMessages channel=%d min=%d", staleUpdates.Updates[0], channel.ID, msg.ID) - } -} - -func TestChannelDeleteRejectsInvalidMessageIDsRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 43, Phone: "15550002143", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 44, Phone: "15550002144", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Invalid Delete IDs", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} - peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} - - for _, maxID := range []int{-1, domain.MaxMessageBoxID + 1} { - if _, err := r.onChannelsDeleteHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteHistoryRequest{Channel: input, MaxID: maxID}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("channels.deleteHistory max_id=%d err = %v, want MESSAGE_ID_INVALID", maxID, err) - } - if _, err := r.onMessagesDeleteHistory(WithUserID(ctx, owner.ID), &tg.MessagesDeleteHistoryRequest{Peer: peer, MaxID: maxID}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("messages.deleteHistory channel max_id=%d err = %v, want MESSAGE_ID_INVALID", maxID, err) - } - } - if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{Channel: input, ID: []int{domain.MaxMessageBoxID + 1}}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("channels.deleteMessages huge id err = %v, want MESSAGE_ID_INVALID", err) - } - tooMany := make([]int, domain.MaxDeleteMessageIDs+1) - for i := range tooMany { - tooMany[i] = i + 1 - } - if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteMessagesRequest{Channel: input, ID: tooMany}); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("channels.deleteMessages too many ids err = %v, want LIMIT_INVALID", err) - } -} - -func TestMessagesDeleteHistoryChannelReturnsOffsetForBoundedPage(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 45, Phone: "15550002145", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 46, Phone: "15550002146", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - created, err := channelStore.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Delete History Offset", - Megagroup: true, - MemberUserIDs: []int64{friend.ID}, - Date: 1_700_002_145, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - total := domain.MaxDeleteHistoryBatch + 2 - for i := 0; i < total; i++ { - if _, err := channelStore.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: created.Channel.ID, - RandomID: int64(210_000 + i), - Message: "bulk delete", - Date: 1_700_002_146 + i, - }); err != nil { - t.Fatalf("send channel message %d: %v", i, err) - } - } - sessions := &captureSessions{} - r := New(Config{}, Deps{ - Channels: appchannels.NewService(channelStore), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - - req := &tg.MessagesDeleteHistoryRequest{ - Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, - MaxID: 0, - } - req.SetRevoke(true) - affected, err := r.onMessagesDeleteHistory(WithUserID(ctx, owner.ID), req) - if err != nil { - t.Fatalf("messages.deleteHistory channel: %v", err) - } - if affected.Offset != 1 || affected.PtsCount != domain.MaxDeleteHistoryBatch { - t.Fatalf("affected history = %+v, want offset=1 pts_count=%d", affected, domain.MaxDeleteHistoryBatch) - } - pushed := sessions.snapshot() - updates, ok := pushed.message.(*tg.Updates) - if !ok || len(updates.Updates) != 1 { - t.Fatalf("pushed update = %T %+v, want one bounded delete update", pushed.message, pushed.message) - } - deleted, ok := updates.Updates[0].(*tg.UpdateDeleteChannelMessages) - if !ok { - t.Fatalf("pushed update[0] = %T, want updateDeleteChannelMessages", updates.Updates[0]) - } - if len(deleted.Messages) != domain.MaxDeleteHistoryBatch || deleted.PtsCount != domain.MaxDeleteHistoryBatch { - t.Fatalf("delete update len=%d pts_count=%d, want bounded %d", len(deleted.Messages), deleted.PtsCount, domain.MaxDeleteHistoryBatch) - } -} - -func TestChannelDifferenceTooLongCarriesDialogPts(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002161", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002162", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "RPC TooLong Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - sourceCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ - Title: "RPC TooLong Source", - About: "forward source", - Broadcast: true, - }) - if err != nil { - t.Fatalf("create source channel: %v", err) - } - sourceChannel := sourceCreated.(*tg.Updates).Chats[0].(*tg.Channel) - if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ - Channel: &tg.InputChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash}, - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - }); err != nil { - t.Fatalf("invite friend to source channel: %v", err) - } - sourceSent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash}, - Message: "forward source", - RandomID: 7000, - }) - if err != nil { - t.Fatalf("send source channel message: %v", err) - } - sourceMsgID := sourceSent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID - if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ - FromPeer: &tg.InputPeerChannel{ChannelID: sourceChannel.ID, AccessHash: sourceChannel.AccessHash}, - ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{sourceMsgID}, - RandomID: []int64{7001}, - }); err != nil { - t.Fatalf("forward source channel to target channel: %v", err) - } - for i := 0; i < 12; i++ { - if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "too long page", - RandomID: int64(i + 1), - }); err != nil { - t.Fatalf("send channel message %d: %v", i, err) - } - } - diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelMessagesFilterEmpty{}, - Pts: 0, - Limit: 3, - }) - if err != nil { - t.Fatalf("channel difference: %v", err) - } - tooLong, ok := diff.(*tg.UpdatesChannelDifferenceTooLong) - if !ok { - t.Fatalf("diff = %T %+v, want channelDifferenceTooLong", diff, diff) - } - dialog, ok := tooLong.Dialog.(*tg.Dialog) - if !ok { - t.Fatalf("tooLong dialog = %T, want dialog", tooLong.Dialog) - } - pts, ok := dialog.GetPts() - if !ok || pts == 0 { - t.Fatalf("tooLong dialog pts = %d ok=%v, want current channel pts", pts, ok) - } - if len(tooLong.Messages) == 0 || len(tooLong.Messages) > domain.MaxChannelDifferenceTooLongMessages { - t.Fatalf("tooLong messages = %d, want bounded latest snapshot", len(tooLong.Messages)) - } - if len(tooLong.Chats) == 0 || tooLong.Chats[0].(*tg.Channel).ID != channel.ID { - t.Fatalf("tooLong chats = %+v, want source channel context", tooLong.Chats) - } - hasSourceChannel := false - for _, chat := range tooLong.Chats { - if ch, ok := chat.(*tg.Channel); ok && ch.ID == sourceChannel.ID { - hasSourceChannel = true - break - } - } - if !hasSourceChannel { - t.Fatalf("tooLong chats = %+v, want forwarded source channel context %d", tooLong.Chats, sourceChannel.ID) - } - hasOwnerUser := false - for _, user := range tooLong.Users { - if u, ok := user.(*tg.User); ok && u.ID == owner.ID { - hasOwnerUser = true - break - } - } - if !hasOwnerUser { - t.Fatalf("tooLong users = %+v, want sender user context", tooLong.Users) - } -} - -func TestBroadcastChannelPostRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 71, Phone: "15550002171", FirstName: "Owner"}) - member, _ := userStore.Create(ctx, domain.User{AccessHash: 72, Phone: "15550002172", FirstName: "Member"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ - Title: "RPC Broadcast", - About: "news", - Broadcast: true, - }) - if err != nil { - t.Fatalf("create broadcast: %v", err) - } - channel := created.(*tg.Updates).Chats[0].(*tg.Channel) - if !channel.Broadcast || channel.Megagroup { - t.Fatalf("channel flags = broadcast:%v megagroup:%v, want broadcast only", channel.Broadcast, channel.Megagroup) - } - if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Users: []tg.InputUserClass{&tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}}, - }); err != nil { - t.Fatalf("invite member to broadcast: %v", err) - } - posted, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "broadcast post", - RandomID: 7071, - }) - if err != nil { - t.Fatalf("owner send broadcast post: %v", err) - } - postUpdate := posted.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage) - postMsg := postUpdate.Message.(*tg.Message) - if !postMsg.Post || postMsg.FromID != nil || postMsg.Message != "broadcast post" { - t.Fatalf("broadcast post message = %#v, want post without from_id", postMsg) - } - if _, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "member post", - RandomID: 7072, - }); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") { - t.Fatalf("member broadcast send err = %v, want CHAT_WRITE_FORBIDDEN", err) - } - if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}, - AdminRights: tg.ChatAdminRights{ChangeInfo: true}, - }); err != nil { - t.Fatalf("promote member without post_messages: %v", err) - } - if _, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "admin without post right", - RandomID: 7073, - }); err == nil || !strings.Contains(err.Error(), "CHAT_WRITE_FORBIDDEN") { - t.Fatalf("admin without post right send err = %v, want CHAT_WRITE_FORBIDDEN", err) - } - if _, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - UserID: &tg.InputUser{UserID: member.ID, AccessHash: member.AccessHash}, - AdminRights: tg.ChatAdminRights{PostMessages: true}, - }); err != nil { - t.Fatalf("grant member post_messages: %v", err) - } - adminPosted, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "admin post", - RandomID: 7074, - }) - if err != nil { - t.Fatalf("admin send broadcast post: %v", err) - } - adminPostMsg := adminPosted.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - if !adminPostMsg.Post || adminPostMsg.FromID != nil || adminPostMsg.Message != "admin post" { - t.Fatalf("admin broadcast post message = %#v, want post without from_id", adminPostMsg) - } - diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, member.ID), &tg.UpdatesGetChannelDifferenceRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelMessagesFilterEmpty{}, - Pts: 1, - Limit: 10, - }) - if err != nil { - t.Fatalf("member channel difference: %v", err) - } - fullDiff := diff.(*tg.UpdatesChannelDifference) - foundPost := false - for _, msg := range fullDiff.NewMessages { - if item, ok := msg.(*tg.Message); ok && item.Message == "broadcast post" { - foundPost = item.Post && item.FromID == nil - } - } - if !foundPost { - t.Fatalf("diff new messages = %+v, want broadcast post without from_id", fullDiff.NewMessages) - } -} - -func TestChannelsCreateChannelUnsupportedOptionsReturnExplicitErrors(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 73, Phone: "15550002173", FirstName: "Owner"}) - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(memory.NewChannelStore()), - }, zaptest.NewLogger(t), clock.System) - - tests := []struct { - name string - req *tg.ChannelsCreateChannelRequest - want string - }{ - { - name: "history import", - req: &tg.ChannelsCreateChannelRequest{ - Title: "Import Group", - Megagroup: true, - ForImport: true, - }, - want: "CHAT_INVALID", - }, - { - name: "geogroup", - req: &tg.ChannelsCreateChannelRequest{ - Title: "Geo Group", - Megagroup: true, - GeoPoint: &tg.InputGeoPoint{Lat: 1.2, Long: 3.4}, - Address: "somewhere", - }, - want: "ADDRESS_INVALID", - }, - { - name: "address without geo", - req: &tg.ChannelsCreateChannelRequest{ - Title: "Bad Geo Group", - Megagroup: true, - Address: "somewhere", - }, - want: "ADDRESS_INVALID", - }, - { - name: "negative ttl", - req: &tg.ChannelsCreateChannelRequest{ - Title: "Bad TTL Group", - Megagroup: true, - TTLPeriod: -1, - }, - want: "TTL_PERIOD_INVALID", - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if _, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("create channel err = %v, want %s", err, tc.want) - } - }) - } -} - -func TestLegacyChannelSettingsRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 81, Phone: "15550002181", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 82, Phone: "15550002182", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Legacy Settings Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} - - themeUpdates, err := r.onMessagesSetChatTheme(WithUserID(ctx, owner.ID), &tg.MessagesSetChatThemeRequest{Peer: peer}) - if err != nil { - t.Fatalf("set chat theme channel peer: %v", err) - } - if len(themeUpdates.(*tg.Updates).Chats) != 1 { - t.Fatalf("set chat theme updates = %+v, want channel context", themeUpdates) - } - privateTheme, err := r.onMessagesSetChatTheme(WithUserID(ctx, owner.ID), &tg.MessagesSetChatThemeRequest{ - Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - }) - if err != nil { - t.Fatalf("set chat theme private peer: %v", err) - } - if len(privateTheme.(*tg.Updates).Updates) != 0 { - t.Fatalf("private set chat theme updates = %+v, want empty compat ack", privateTheme) - } - - reactionUpdates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ - Peer: peer, - AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}}, - ReactionsLimit: 8, - }) - if err != nil { - t.Fatalf("set available reactions: %v", err) - } - if len(reactionUpdates.(*tg.Updates).Chats) != 1 { - t.Fatalf("set reactions updates = %+v, want channel state update", reactionUpdates) - } - full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) - if err != nil { - t.Fatalf("get full channel after reactions: %v", err) - } - fullChannel := full.FullChat.(*tg.ChannelFull) - reactions, ok := fullChannel.GetAvailableReactions() - if !ok { - t.Fatalf("full channel reactions missing after set") - } - some, ok := reactions.(*tg.ChatReactionsSome) - if !ok || len(some.Reactions) != 1 { - t.Fatalf("full channel reactions = %#v, want one explicit reaction", reactions) - } - if fullChannel.ReactionsLimit != 8 { - t.Fatalf("full channel reactions limit = %d, want 8", fullChannel.ReactionsLimit) - } - if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ - Peer: peer, - AvailableReactions: &tg.ChatReactionsSome{Reactions: make([]tg.ReactionClass, maxChatAvailableReactions+1)}, - }); err == nil { - t.Fatalf("set too many reactions err = nil, want limit error") - } - - noForwards, err := r.onMessagesToggleNoForwards(WithUserID(ctx, owner.ID), &tg.MessagesToggleNoForwardsRequest{ - Peer: peer, - Enabled: true, - }) - if err != nil { - t.Fatalf("toggle noforwards: %v", err) - } - if got := noForwards.(*tg.Updates).Chats[0].(*tg.Channel); !got.Noforwards { - t.Fatalf("noforwards channel = %+v, want enabled", got) - } - sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: peer, - Message: "protected content", - RandomID: 8181, - }) - if err != nil { - t.Fatalf("send protected message: %v", err) - } - msg := sent.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - if !msg.Noforwards { - t.Fatalf("protected channel message = %+v, want noforwards inherited", msg) - } -} - -func TestChannelParticipantsSearchQueryIsBounded(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550002111", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550002112", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Participants RPC Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - - _, err = r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelParticipantsSearch{Q: strings.Repeat("x", domain.MaxChannelParticipantsQueryLength+1)}, - Limit: 20, - }) - if err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("get participants long query err = %v, want LIMIT_INVALID", err) - } -} - -func TestChannelSendMessageRPCResolvesReplyHeader(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550002031", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550002032", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "RPC Reply Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - rootUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "root", - RandomID: 3001, - }) - if err != nil { - t.Fatalf("send root: %v", err) - } - rootMsg := rootUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - replyTo := &tg.InputReplyToMessage{ReplyToMsgID: rootMsg.ID} - replyTo.SetQuoteText("root") - replyTo.SetQuoteOffset(0) - replyReq := &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "reply", - RandomID: 3002, - } - replyReq.SetReplyTo(replyTo) - replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, friend.ID), replyReq) - if err != nil { - t.Fatalf("send reply: %v", err) - } - replyMsg := replyUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - header, ok := replyMsg.ReplyTo.(*tg.MessageReplyHeader) - if !ok { - t.Fatalf("reply header = %#v, want messageReplyHeader", replyMsg.ReplyTo) - } - topID, topOK := header.GetReplyToTopID() - quoteText, quoteOK := header.GetQuoteText() - if header.ReplyToMsgID != rootMsg.ID || !topOK || topID != rootMsg.ID || !quoteOK || quoteText != "root" { - t.Fatalf("reply header = %#v, want msg/top %d and quote", header, rootMsg.ID) - } - badReq := &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "bad", - RandomID: 3003, - } - badReq.SetReplyTo(&tg.InputReplyToMessage{ReplyToMsgID: 999}) - if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), badReq); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { - t.Fatalf("bad reply err = %v, want REPLY_MESSAGE_ID_INVALID", err) - } -} - -func TestMessagesSearchChannelPeerReturnsSingleCopyMessages(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 35, Phone: "15550002035", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 36, Phone: "15550002036", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "RPC Search Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - pinnedMsgID := 0 - for _, item := range []struct { - userID int64 - text string - random int64 - }{ - {owner.ID, "needle from owner", 5001}, - {friend.ID, "not this one", 5002}, - {friend.ID, "needle from friend", 5003}, - } { - sent, err := r.onMessagesSendMessage(WithUserID(ctx, item.userID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: item.text, - RandomID: item.random, - }) - if err != nil { - t.Fatalf("send %q: %v", item.text, err) - } - if item.text == "not this one" { - channelUpdates := sent.(*tg.Updates) - if len(channelUpdates.Updates) == 0 { - t.Fatalf("send %q updates = %+v, want updateMessageID", item.text, channelUpdates.Updates) - } - pinnedMsgID = channelUpdates.Updates[0].(*tg.UpdateMessageID).ID - } - } - - req := &tg.MessagesSearchRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Q: "needle", - Filter: &tg.InputMessagesFilterEmpty{}, - Limit: 10, - } - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode search: %v", err) - } - enc, err := r.Dispatch(WithUserID(ctx, friend.ID), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch search: %v", err) - } - messages, chats, users := searchMessagesPayload(t, enc) - if len(messages) != 2 || len(chats) != 1 || len(users) < 2 { - t.Fatalf("search payload sizes = messages %d chats %d users %d, want 2/1/2+", len(messages), len(chats), len(users)) - } - for _, msg := range messages { - item := msg.(*tg.Message) - if !strings.Contains(item.Message, "needle") { - t.Fatalf("search result message = %q, want only needle hits", item.Message) - } - } - - fromReq := *req - fromReq.SetFromID(&tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}) - in.Reset() - if err := fromReq.Encode(&in); err != nil { - t.Fatalf("encode from search: %v", err) - } - enc, err = r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch from search: %v", err) - } - messages, _, _ = searchMessagesPayload(t, enc) - if len(messages) != 1 || messages[0].(*tg.Message).Message != "needle from friend" { - t.Fatalf("from search messages = %+v, want friend needle only", messages) - } - - mediaCountReq := &tg.MessagesSearchRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.InputMessagesFilterPhotos{}, - Limit: 0, - } - in.Reset() - if err := mediaCountReq.Encode(&in); err != nil { - t.Fatalf("encode shared media count search: %v", err) - } - enc, err = r.Dispatch(WithUserID(ctx, friend.ID), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch shared media count search: %v", err) - } - if box, ok := enc.(*tg.MessagesMessagesBox); ok { - enc = box.Messages - } - channelMessages, ok := enc.(*tg.MessagesChannelMessages) - if !ok { - t.Fatalf("shared media count search result = %T, want messages.channelMessages", enc) - } - if channelMessages.Count != 0 || len(channelMessages.Messages) != 0 { - t.Fatalf("shared media count search = count %d messages %d, want empty without media store", channelMessages.Count, len(channelMessages.Messages)) - } - - if _, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, owner.ID), &tg.MessagesUpdatePinnedMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: pinnedMsgID, - }); err != nil { - t.Fatalf("pin channel message: %v", err) - } - pinnedReq := &tg.MessagesSearchRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.InputMessagesFilterPinned{}, - Limit: 40, - } - in.Reset() - if err := pinnedReq.Encode(&in); err != nil { - t.Fatalf("encode pinned search: %v", err) - } - enc, err = r.Dispatch(WithUserID(ctx, friend.ID), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch pinned search: %v", err) - } - pinnedMessages, _, _ := searchMessagesPayload(t, enc) - if len(pinnedMessages) != 1 { - t.Fatalf("pinned search returned %d messages, want 1", len(pinnedMessages)) - } - pinnedMessage, ok := pinnedMessages[0].(*tg.Message) - if !ok || pinnedMessage.ID != pinnedMsgID || !pinnedMessage.GetPinned() { - t.Fatalf("pinned search message = %#v, want pinned message id=%d", pinnedMessages[0], pinnedMsgID) - } -} - -func searchMessagesPayload(t *testing.T, enc bin.Encoder) ([]tg.MessageClass, []tg.ChatClass, []tg.UserClass) { - t.Helper() - switch result := enc.(type) { - case *tg.MessagesMessages: - return result.Messages, result.Chats, result.Users - case *tg.MessagesMessagesSlice: - return result.Messages, result.Chats, result.Users - case *tg.MessagesChannelMessages: - return result.Messages, result.Chats, result.Users - case *tg.MessagesMessagesBox: - return searchMessagesPayload(t, result.Messages) - default: - t.Fatalf("search result type = %T, want messages/messagesSlice", enc) - return nil, nil, nil - } -} - -func TestForwardChannelMessageToUserIncludesSourceChannelChat(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550002033", FirstName: "Owner"}) - recipient, _ := userStore.Create(ctx, domain.User{AccessHash: 34, Phone: "15550002034", FirstName: "Recipient"}) - channelStore := memory.NewChannelStore() - messageStore := memory.NewMessageStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Messages: appmessages.NewService(messageStore, nil), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ - Title: "Source Channel", - About: "forward source", - Broadcast: true, - }) - if err != nil { - t.Fatalf("create source channel: %v", err) - } - channel := created.(*tg.Updates).Chats[0].(*tg.Channel) - sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "source post", - RandomID: 4001, - }) - if err != nil { - t.Fatalf("send source channel message: %v", err) - } - msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID - forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ - FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ToPeer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, - ID: []int{msgID}, - RandomID: []int64{4002}, - }) - if err != nil { - t.Fatalf("forward channel to user: %v", err) - } - updates := forwarded.(*tg.Updates) - if len(updates.Chats) != 1 || updates.Chats[0].(*tg.Channel).ID != channel.ID { - t.Fatalf("forward updates chats = %+v, want source channel chat", updates.Chats) - } - newMsg := updates.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) - if from, ok := newMsg.FwdFrom.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { - t.Fatalf("forward header = %#v, want source channel peer", newMsg.FwdFrom) - } -} - -func TestChannelSendAsCurrentChannelRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550002111", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550002112", FirstName: "Friend"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "Send As Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - sendAs := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} - sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "as channel", - RandomID: 501, - SendAs: sendAs, - }) - if err != nil { - t.Fatalf("send as current channel: %v", err) - } - sentUpdates := sent.(*tg.Updates) - sentMsg := sentUpdates.Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - if from, ok := sentMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { - t.Fatalf("send_as message from = %#v, want channel %d", sentMsg.FromID, channel.ID) - } - - history, err := r.onChannelsGetMessages(WithUserID(ctx, friend.ID), &tg.ChannelsGetMessagesRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []tg.InputMessageClass{&tg.InputMessageID{ID: sentMsg.ID}}, - }) - if err != nil { - t.Fatalf("get send_as history: %v", err) - } - historyMsg := history.(*tg.MessagesMessages).Messages[0].(*tg.Message) - if from, ok := historyMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { - t.Fatalf("history send_as from = %#v, want channel %d", historyMsg.FromID, channel.ID) - } - - forwarded, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ - FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{sentMsg.ID}, - RandomID: []int64{502}, - SendAs: sendAs, - }) - if err != nil { - t.Fatalf("forward as current channel: %v", err) - } - forwardMsg := forwarded.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - if from, ok := forwardMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { - t.Fatalf("forward send_as from = %#v, want channel %d", forwardMsg.FromID, channel.ID) - } - - if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - Message: "bad private send_as", - RandomID: 503, - SendAs: sendAs, - }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { - t.Fatalf("private send_as err = %v, want SEND_AS_PEER_INVALID", err) - } - if _, err := r.onMessagesSendMessage(WithUserID(ctx, friend.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "bad member send_as", - RandomID: 504, - SendAs: sendAs, - }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { - t.Fatalf("member send_as err = %v, want SEND_AS_PEER_INVALID", err) - } - if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ - FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ToPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - ID: []int{sentMsg.ID}, - RandomID: []int64{505}, - SendAs: sendAs, - }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { - t.Fatalf("private forward send_as err = %v, want SEND_AS_PEER_INVALID", err) - } -} - -func TestTDesktopPassiveChannelStubs(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 41, Phone: "15550002101", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 42, Phone: "15550002102", FirstName: "Friend"}) - invited, _ := userStore.Create(ctx, domain.User{AccessHash: 43, Phone: "15550002103", FirstName: "Invited"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "RPC Passive Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - - sendAs, err := r.onChannelsGetSendAs(WithUserID(ctx, owner.ID), &tg.ChannelsGetSendAsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }) - if err != nil { - t.Fatalf("get send as: %v", err) - } - if len(sendAs.Peers) != 2 || len(sendAs.Users) != 1 || len(sendAs.Chats) != 1 { - t.Fatalf("send as = %+v, want self + current channel peers with current channel context", sendAs) - } - if peer, ok := sendAs.Peers[0].Peer.(*tg.PeerUser); !ok || peer.UserID != owner.ID { - t.Fatalf("send as peer = %#v, want owner user", sendAs.Peers[0].Peer) - } - if peer, ok := sendAs.Peers[1].Peer.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { - t.Fatalf("send as peer[1] = %#v, want current channel", sendAs.Peers[1].Peer) - } - friendSendAs, err := r.onChannelsGetSendAs(WithUserID(ctx, friend.ID), &tg.ChannelsGetSendAsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }) - if err != nil { - t.Fatalf("friend get send as: %v", err) - } - if len(friendSendAs.Peers) != 1 { - t.Fatalf("friend send as peers = %+v, want only self", friendSendAs.Peers) - } - if ok, err := r.onMessagesSaveDefaultSendAs(WithUserID(ctx, owner.ID), &tg.MessagesSaveDefaultSendAsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - SendAs: &tg.InputPeerSelf{}, - }); err != nil || !ok { - t.Fatalf("save default send as self = ok %v err %v, want true", ok, err) - } - if ok, err := r.onMessagesSaveDefaultSendAs(WithUserID(ctx, owner.ID), &tg.MessagesSaveDefaultSendAsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - SendAs: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }); err != nil || !ok { - t.Fatalf("save default send as channel = ok %v err %v, want true", ok, err) - } - fullWithDefault, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) - if err != nil { - t.Fatalf("get full channel with default send as: %v", err) - } - channelFull := fullWithDefault.FullChat.(*tg.ChannelFull) - defaultSendAs, ok := channelFull.GetDefaultSendAs() - if !ok { - t.Fatalf("full channel default_send_as missing") - } - if peer, ok := defaultSendAs.(*tg.PeerChannel); !ok || peer.ChannelID != channel.ID { - t.Fatalf("full channel default_send_as = %#v, want current channel %d", defaultSendAs, channel.ID) - } - sentWithDefault, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "default send as fallback", - RandomID: 80102, - }) - if err != nil { - t.Fatalf("send message with default send as: %v", err) - } - defaultMsg := sentWithDefault.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - if from, ok := defaultMsg.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { - t.Fatalf("default send_as message from = %#v, want channel %d", defaultMsg.FromID, channel.ID) - } - forwardedWithDefault, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), &tg.MessagesForwardMessagesRequest{ - FromPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ToPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{defaultMsg.ID}, - RandomID: []int64{80103}, - }) - if err != nil { - t.Fatalf("forward with default send as: %v", err) - } - defaultForward := forwardedWithDefault.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - if from, ok := defaultForward.FromID.(*tg.PeerChannel); !ok || from.ChannelID != channel.ID { - t.Fatalf("default forward send_as from = %#v, want channel %d", defaultForward.FromID, channel.ID) - } - if ok, err := r.onMessagesSaveDefaultSendAs(WithUserID(ctx, owner.ID), &tg.MessagesSaveDefaultSendAsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - SendAs: &tg.InputPeerSelf{}, - }); err != nil || !ok { - t.Fatalf("clear default send as self = ok %v err %v, want true", ok, err) - } - fullAfterClear, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) - if err != nil { - t.Fatalf("get full channel after clearing default send as: %v", err) - } - if _, ok := fullAfterClear.FullChat.(*tg.ChannelFull).GetDefaultSendAs(); ok { - t.Fatalf("full channel default_send_as still set after saving self") - } - if _, err := r.onMessagesSaveDefaultSendAs(WithUserID(ctx, owner.ID), &tg.MessagesSaveDefaultSendAsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - SendAs: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - }); err == nil || !strings.Contains(err.Error(), "SEND_AS_PEER_INVALID") { - t.Fatalf("save default send as friend err = %v, want SEND_AS_PEER_INVALID", err) - } - - sponsored, err := r.onMessagesGetSponsoredMessages(WithUserID(ctx, owner.ID), &tg.MessagesGetSponsoredMessagesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }) - if err != nil { - t.Fatalf("get sponsored messages: %v", err) - } - if _, ok := sponsored.(*tg.MessagesSponsoredMessagesEmpty); !ok { - t.Fatalf("sponsored = %T, want empty", sponsored) - } - historyTTL, err := r.onMessagesGetDefaultHistoryTTL(WithUserID(ctx, owner.ID)) - if err != nil { - t.Fatalf("get default history ttl: %v", err) - } - if historyTTL.Period != 0 { - t.Fatalf("default history ttl = %+v, want disabled", historyTTL) - } - accountTTL, err := r.onAccountGetAccountTTL(WithUserID(ctx, owner.ID)) - if err != nil { - t.Fatalf("get account ttl: %v", err) - } - if accountTTL.Days <= 0 { - t.Fatalf("account ttl = %+v, want positive days", accountTTL) - } - preview, err := r.onMessagesGetWebPagePreview(WithUserID(ctx, owner.ID), &tg.MessagesGetWebPagePreviewRequest{ - Message: "https://example.com", - }) - if err != nil { - t.Fatalf("messages.getWebPagePreview: %v", err) - } - if _, ok := preview.Media.(*tg.MessageMediaEmpty); !ok || len(preview.Chats) != 0 || len(preview.Users) != 0 { - t.Fatalf("messages.getWebPagePreview = %+v, want empty media preview", preview) - } - if _, err := r.onMessagesGetWebPagePreview(WithUserID(ctx, owner.ID), &tg.MessagesGetWebPagePreviewRequest{}); err == nil || !strings.Contains(err.Error(), "MESSAGE_EMPTY") { - t.Fatalf("messages.getWebPagePreview empty err = %v, want MESSAGE_EMPTY", err) - } - if media, err := r.onMessagesUploadMedia(WithUserID(ctx, owner.ID), &tg.MessagesUploadMediaRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Media: &tg.InputMediaEmpty{}, - }); err != nil { - t.Fatalf("messages.uploadMedia empty: %v", err) - } else if _, ok := media.(*tg.MessageMediaEmpty); !ok { - t.Fatalf("messages.uploadMedia empty = %#v, want messageMediaEmpty", media) - } - if _, err := r.onMessagesUploadMedia(WithUserID(ctx, owner.ID), &tg.MessagesUploadMediaRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Media: &tg.InputMediaUploadedPhoto{}, - }); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { - t.Fatalf("messages.uploadMedia unsupported err = %v, want MEDIA_INVALID", err) - } - if updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Media: &tg.InputMediaWebPage{URL: "https://example.com"}, - Message: "https://example.com", - RandomID: 91001, - }); err != nil { - t.Fatalf("messages.sendMedia webpage-as-text: %v", err) - } else if len(updates.(*tg.Updates).Updates) == 0 { - t.Fatalf("messages.sendMedia webpage-as-text = %+v, want channel message updates", updates) - } - if _, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Media: &tg.InputMediaUploadedPhoto{}, - Message: "photo", - RandomID: 91002, - }); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { - t.Fatalf("messages.sendMedia unsupported err = %v, want MEDIA_INVALID", err) - } - if _, err := r.onMessagesSendMultiMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMultiMediaRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MultiMedia: []tg.InputSingleMedia{{ - Media: &tg.InputMediaUploadedPhoto{}, - RandomID: 91003, - Message: "photo", - }}, - }); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { - t.Fatalf("messages.sendMultiMedia unsupported err = %v, want MEDIA_INVALID", err) - } - savedHistoryReq := &tg.MessagesGetSavedHistoryRequest{ - Peer: &tg.InputPeerSelf{}, - Limit: 20, - } - savedHistoryReq.SetParentPeer(&tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) - savedHistory, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), savedHistoryReq) - if err != nil { - t.Fatalf("messages.getSavedHistory: %v", err) - } - if len(savedHistory.(*tg.MessagesMessages).Messages) != 0 || len(savedHistory.(*tg.MessagesMessages).Chats) != 1 { - t.Fatalf("messages.getSavedHistory = %+v, want empty with parent channel context", savedHistory) - } - badSavedParent := &tg.MessagesGetSavedHistoryRequest{ - Peer: &tg.InputPeerSelf{}, - Limit: 20, - } - badSavedParent.SetParentPeer(&tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash + 1}) - if _, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), badSavedParent); err == nil || !strings.Contains(err.Error(), "PARENT_PEER_INVALID") { - t.Fatalf("messages.getSavedHistory bad parent err = %v, want PARENT_PEER_INVALID", err) - } - if _, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), &tg.MessagesGetSavedHistoryRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash + 1}, - Limit: 20, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("messages.getSavedHistory bad peer err = %v, want CHANNEL_PRIVATE", err) - } - if _, err := r.onMessagesGetSavedHistory(WithUserID(ctx, owner.ID), &tg.MessagesGetSavedHistoryRequest{ - Peer: &tg.InputPeerSelf{}, - Limit: maxSearchResultsLimit + 1, - }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("messages.getSavedHistory limit err = %v, want LIMIT_INVALID", err) - } - if ok, err := r.onMessagesReadSavedHistory(WithUserID(ctx, owner.ID), &tg.MessagesReadSavedHistoryRequest{ - ParentPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Peer: &tg.InputPeerSelf{}, - MaxID: 0, - }); err != nil || !ok { - t.Fatalf("messages.readSavedHistory = ok %v err %v, want true", ok, err) - } - if _, err := r.onMessagesReadSavedHistory(WithUserID(ctx, owner.ID), &tg.MessagesReadSavedHistoryRequest{ - ParentPeer: &tg.InputPeerSelf{}, - Peer: &tg.InputPeerSelf{}, - }); err == nil || !strings.Contains(err.Error(), "PARENT_PEER_INVALID") { - t.Fatalf("messages.readSavedHistory bad parent err = %v, want PARENT_PEER_INVALID", err) - } - if _, err := r.onMessagesReadSavedHistory(WithUserID(ctx, owner.ID), &tg.MessagesReadSavedHistoryRequest{ - ParentPeer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Peer: &tg.InputPeerSelf{}, - MaxID: domain.MaxMessageBoxID + 1, - }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("messages.readSavedHistory max_id err = %v, want MESSAGE_ID_INVALID", err) - } - deleteSavedReq := &tg.MessagesDeleteSavedHistoryRequest{ - Peer: &tg.InputPeerSelf{}, - } - deleteSavedReq.SetParentPeer(&tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) - deletedSaved, err := r.onMessagesDeleteSavedHistory(WithUserID(ctx, owner.ID), deleteSavedReq) - if err != nil { - t.Fatalf("messages.deleteSavedHistory: %v", err) - } - if deletedSaved.Offset != 0 || deletedSaved.PtsCount != 0 { - t.Fatalf("messages.deleteSavedHistory = %+v, want empty affected history", deletedSaved) - } - badDeleteDate := &tg.MessagesDeleteSavedHistoryRequest{ - Peer: &tg.InputPeerSelf{}, - } - badDeleteDate.SetMinDate(-1) - if _, err := r.onMessagesDeleteSavedHistory(WithUserID(ctx, owner.ID), badDeleteDate); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("messages.deleteSavedHistory min_date err = %v, want LIMIT_INVALID", err) - } - scheduledHistory, err := r.onMessagesGetScheduledHistory(WithUserID(ctx, owner.ID), &tg.MessagesGetScheduledHistoryRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }) - if err != nil { - t.Fatalf("messages.getScheduledHistory: %v", err) - } - if len(scheduledHistory.(*tg.MessagesMessages).Messages) != 0 || len(scheduledHistory.(*tg.MessagesMessages).Chats) != 1 { - t.Fatalf("messages.getScheduledHistory = %+v, want empty with channel context", scheduledHistory) - } - if _, err := r.onMessagesGetScheduledHistory(WithUserID(ctx, owner.ID), &tg.MessagesGetScheduledHistoryRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash + 1}, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("messages.getScheduledHistory bad hash err = %v, want CHANNEL_PRIVATE", err) - } - scheduled, err := r.onMessagesGetScheduledMessages(WithUserID(ctx, owner.ID), &tg.MessagesGetScheduledMessagesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{1}, - }) - if err != nil { - t.Fatalf("messages.getScheduledMessages: %v", err) - } - if len(scheduled.(*tg.MessagesMessages).Messages) != 0 || len(scheduled.(*tg.MessagesMessages).Chats) != 1 { - t.Fatalf("messages.getScheduledMessages = %+v, want empty with channel context", scheduled) - } - if _, err := r.onMessagesGetScheduledMessages(WithUserID(ctx, owner.ID), &tg.MessagesGetScheduledMessagesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: make([]int, maxGetMessagesIDs+1), - }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("messages.getScheduledMessages id cap err = %v, want LIMIT_INVALID", err) - } - deletedScheduled, err := r.onMessagesDeleteScheduledMessages(WithUserID(ctx, owner.ID), &tg.MessagesDeleteScheduledMessagesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{7, 8}, - }) - if err != nil { - t.Fatalf("messages.deleteScheduledMessages: %v", err) - } - if got := deletedScheduled.(*tg.Updates).Updates; len(got) != 1 { - t.Fatalf("messages.deleteScheduledMessages updates = %+v, want one update", got) - } else if update, ok := got[0].(*tg.UpdateDeleteScheduledMessages); !ok || len(update.Messages) != 2 || update.Messages[0] != 7 { - t.Fatalf("messages.deleteScheduledMessages update = %#v, want requested ids", got[0]) - } - if _, err := r.onMessagesSendScheduledMessages(WithUserID(ctx, owner.ID), &tg.MessagesSendScheduledMessagesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{7}, - }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("messages.sendScheduledMessages err = %v, want MESSAGE_ID_INVALID", err) - } - if _, err := r.onMessagesCreateForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesCreateForumTopicRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Title: "Topic", - RandomID: 91004, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { - t.Fatalf("messages.createForumTopic err = %v, want CHANNEL_FORUM_MISSING", err) - } - if _, err := r.onMessagesCreateForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesCreateForumTopicRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - RandomID: 91005, - }); err == nil || !strings.Contains(err.Error(), "TOPIC_TITLE_EMPTY") { - t.Fatalf("messages.createForumTopic empty title err = %v, want TOPIC_TITLE_EMPTY", err) - } - if _, err := r.onMessagesEditForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesEditForumTopicRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - TopicID: 1, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { - t.Fatalf("messages.editForumTopic err = %v, want CHANNEL_FORUM_MISSING", err) - } - if _, err := r.onMessagesUpdatePinnedForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesUpdatePinnedForumTopicRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - TopicID: 1, - Pinned: true, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { - t.Fatalf("messages.updatePinnedForumTopic err = %v, want CHANNEL_FORUM_MISSING", err) - } - if _, err := r.onMessagesReorderPinnedForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesReorderPinnedForumTopicsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Order: []int{1, 2}, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { - t.Fatalf("messages.reorderPinnedForumTopics err = %v, want CHANNEL_FORUM_MISSING", err) - } - if _, err := r.onMessagesDeleteTopicHistory(WithUserID(ctx, owner.ID), &tg.MessagesDeleteTopicHistoryRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - TopMsgID: 1, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { - t.Fatalf("messages.deleteTopicHistory err = %v, want CHANNEL_FORUM_MISSING", err) - } - chats, err := r.onMessagesGetChats(WithUserID(ctx, owner.ID), []int64{channel.ID}) - if err != nil { - t.Fatalf("messages.getChats legacy wrapper: %v", err) - } - if len(chats.(*tg.MessagesChats).Chats) != 1 { - t.Fatalf("messages.getChats = %+v, want current megagroup channel", chats) - } - migrated, err := r.onMessagesMigrateChat(WithUserID(ctx, owner.ID), channel.ID) - if err != nil { - t.Fatalf("messages.migrateChat legacy mapping: %v", err) - } - migratedUpdates := migrated.(*tg.Updates) - if len(migratedUpdates.Chats) != 1 || len(migratedUpdates.Updates) != 1 { - t.Fatalf("messages.migrateChat = %+v, want updateChannel with current megagroup", migratedUpdates) - } - if _, err := r.onMessagesMigrateChat(WithUserID(ctx, friend.ID), channel.ID); err == nil { - t.Fatalf("messages.migrateChat by non-admin = nil err, want CHAT_ADMIN_REQUIRED") - } - full, err := r.onMessagesGetFullChat(WithUserID(ctx, owner.ID), channel.ID) - if err != nil { - t.Fatalf("messages.getFullChat legacy wrapper: %v", err) - } - if got := full.FullChat.(*tg.ChannelFull).ID; got != channel.ID { - t.Fatalf("messages.getFullChat id = %d, want %d", got, channel.ID) - } - viewedID := defaultMsg.ID - views, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{viewedID}, - }) - if err != nil { - t.Fatalf("messages.getMessagesViews: %v", err) - } - if len(views.Views) != 1 || len(views.Chats) != 1 { - t.Fatalf("messages.getMessagesViews = %+v, want one view with channel context", views) - } - if got, ok := views.Views[0].GetViews(); !ok || got != 0 { - t.Fatalf("messages.getMessagesViews views = %d ok %v, want explicit zero before increment", got, ok) - } - incremented, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{viewedID}, - Increment: true, - }) - if err != nil { - t.Fatalf("messages.getMessagesViews increment: %v", err) - } - if got, ok := incrementalViewCount(incremented); !ok || got != 1 { - t.Fatalf("messages.getMessagesViews increment count = %d ok %v, want 1", got, ok) - } - repeated, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{viewedID}, - Increment: true, - }) - if err != nil { - t.Fatalf("messages.getMessagesViews repeat increment: %v", err) - } - if got, ok := incrementalViewCount(repeated); !ok || got != 1 { - t.Fatalf("messages.getMessagesViews repeated count = %d ok %v, want still 1", got, ok) - } - friendIncremented, err := r.onMessagesGetMessagesViews(WithUserID(ctx, friend.ID), &tg.MessagesGetMessagesViewsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{viewedID}, - Increment: true, - }) - if err != nil { - t.Fatalf("messages.getMessagesViews friend increment: %v", err) - } - if got, ok := incrementalViewCount(friendIncremented); !ok || got != 2 { - t.Fatalf("messages.getMessagesViews friend count = %d ok %v, want 2", got, ok) - } - if _, err := r.onMessagesReadMessageContents(WithUserID(ctx, owner.ID), []int{1}); err != nil { - t.Fatalf("messages.readMessageContents: %v", err) - } - mentions, err := r.onMessagesGetUnreadMentions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadMentionsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getUnreadMentions: %v", err) - } - _, mentionChats, _ := searchMessagesPayload(t, mentions) - if len(mentionChats) != 1 { - t.Fatalf("messages.getUnreadMentions = %+v, want channel context", mentions) - } - if _, err := r.onMessagesReadMentions(WithUserID(ctx, owner.ID), &tg.MessagesReadMentionsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }); err != nil { - t.Fatalf("messages.readMentions: %v", err) - } - if ok, err := r.onMessagesReportSpam(WithUserID(ctx, owner.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}); err != nil || !ok { - t.Fatalf("messages.reportSpam = ok %v err %v, want true nil", ok, err) - } - reportOptions, err := r.onMessagesReport(WithUserID(ctx, owner.ID), &tg.MessagesReportRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{1}, - }) - if err != nil { - t.Fatalf("messages.report options: %v", err) - } - if choices, ok := reportOptions.(*tg.ReportResultChooseOption); !ok || len(choices.Options) == 0 { - t.Fatalf("messages.report options = %#v, want chooseOption", reportOptions) - } - reportComment, err := r.onMessagesReport(WithUserID(ctx, owner.ID), &tg.MessagesReportRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{1}, - Option: []byte("other"), - }) - if err != nil { - t.Fatalf("messages.report other: %v", err) - } - if _, ok := reportComment.(*tg.ReportResultAddComment); !ok { - t.Fatalf("messages.report other = %#v, want addComment", reportComment) - } - reported, err := r.onMessagesReport(WithUserID(ctx, owner.ID), &tg.MessagesReportRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{1}, - Option: []byte("spam"), - }) - if err != nil { - t.Fatalf("messages.report spam: %v", err) - } - if _, ok := reported.(*tg.ReportResultReported); !ok { - t.Fatalf("messages.report spam = %#v, want reported", reported) - } - if _, err := r.onMessagesReport(WithUserID(ctx, owner.ID), &tg.MessagesReportRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{1}, - Option: []byte("bogus"), - }); err == nil || !strings.Contains(err.Error(), "OPTION_INVALID") { - t.Fatalf("messages.report invalid option err = %v, want OPTION_INVALID", err) - } - if ok, err := r.onMessagesReportReaction(WithUserID(ctx, owner.ID), &tg.MessagesReportReactionRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: 1, - ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - }); err != nil || !ok { - t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err) - } - if ok, err := r.onMessagesReportMessagesDelivery(WithUserID(ctx, owner.ID), &tg.MessagesReportMessagesDeliveryRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{1}, - }); err != nil || !ok { - t.Fatalf("messages.reportMessagesDelivery = ok %v err %v, want true nil", ok, err) - } - if ok, err := r.onMessagesReportReadMetrics(WithUserID(ctx, owner.ID), &tg.MessagesReportReadMetricsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Metrics: []tg.InputMessageReadMetric{{ - MsgID: 1, - ViewID: 99, - TimeInViewMs: 10, - ActiveTimeInViewMs: 10, - HeightToViewportRatioPermille: 1000, - SeenRangeRatioPermille: 1000, - }}, - }); err != nil || !ok { - t.Fatalf("messages.reportReadMetrics = ok %v err %v, want true nil", ok, err) - } - if ok, err := r.onMessagesReportMusicListen(WithUserID(ctx, owner.ID), &tg.MessagesReportMusicListenRequest{ - ID: &tg.InputDocument{ID: 1, AccessHash: 2}, - ListenedDuration: 1, - }); err != nil || !ok { - t.Fatalf("messages.reportMusicListen = ok %v err %v, want true nil", ok, err) - } - sponsoredReport, err := r.onMessagesReportSponsoredMessage(WithUserID(ctx, owner.ID), &tg.MessagesReportSponsoredMessageRequest{ - RandomID: []byte("ad"), - Option: []byte("spam"), - }) - if err != nil { - t.Fatalf("messages.reportSponsoredMessage: %v", err) - } - if _, ok := sponsoredReport.(*tg.ChannelsSponsoredMessageReportResultReported); !ok { - t.Fatalf("messages.reportSponsoredMessage = %#v, want reported", sponsoredReport) - } - sendReactionReq := &tg.MessagesSendReactionRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: viewedID, - Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, - } - sendReactionReq.SetReaction(sendReactionReq.Reaction) - sendReactionReq.SetAddToRecent(true) - if updates, err := r.onMessagesSendReaction(WithUserID(ctx, owner.ID), sendReactionReq); err != nil { - t.Fatalf("messages.sendReaction: %v", err) - } else if got := updates.(*tg.Updates).Updates; len(got) != 1 { - t.Fatalf("messages.sendReaction updates = %+v, want one reaction update", got) - } else if update, ok := got[0].(*tg.UpdateMessageReactions); !ok || update.MsgID != viewedID || len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 || update.Reactions.Results[0].ChosenOrder != 1 { - t.Fatalf("messages.sendReaction update = %#v, want one chosen reaction", got[0]) - } - tooManyReactionsReq := &tg.MessagesSendReactionRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: viewedID, - Reaction: make([]tg.ReactionClass, maxReactionVector+1), - } - tooManyReactionsReq.SetReaction(tooManyReactionsReq.Reaction) - if _, err := r.onMessagesSendReaction(WithUserID(ctx, owner.ID), tooManyReactionsReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("messages.sendReaction huge reaction vector err = %v, want LIMIT_INVALID", err) - } - reactionUpdates, err := r.onMessagesGetMessagesReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesReactionsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{viewedID}, - }) - if err != nil { - t.Fatalf("messages.getMessagesReactions: %v", err) - } - if got := reactionUpdates.(*tg.Updates).Updates; len(got) != 1 { - t.Fatalf("messages.getMessagesReactions updates = %+v, want one reaction update", got) - } else if update, ok := got[0].(*tg.UpdateMessageReactions); !ok || update.MsgID != viewedID || len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 { - t.Fatalf("messages.getMessagesReactions update = %#v, want one reaction", got[0]) - } - reactionList, err := r.onMessagesGetMessageReactionsList(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageReactionsListRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: viewedID, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getMessageReactionsList: %v", err) - } - if reactionList.Count != 1 || len(reactionList.Reactions) != 1 || len(reactionList.Chats) != 1 || len(reactionList.Users) != 1 { - t.Fatalf("messages.getMessageReactionsList = %+v, want one reaction with channel context", reactionList) - } - if peer, ok := reactionList.Reactions[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != owner.ID || !reactionList.Reactions[0].My { - t.Fatalf("messages.getMessageReactionsList reaction = %+v, want current user reaction", reactionList.Reactions[0]) - } - friendReactionReq := &tg.MessagesSendReactionRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: viewedID, - Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, - } - friendReactionReq.SetReaction(friendReactionReq.Reaction) - if _, err := r.onMessagesSendReaction(WithUserID(ctx, friend.ID), friendReactionReq); err != nil { - t.Fatalf("messages.sendReaction by friend: %v", err) - } - unreadReactions, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getUnreadReactions: %v", err) - } - unreadMessages, unreadChats, unreadUsers := searchMessagesPayload(t, unreadReactions) - if len(unreadMessages) != 1 || len(unreadChats) != 1 || len(unreadUsers) == 0 { - t.Fatalf("messages.getUnreadReactions = %+v, want one unread reaction with channel context", unreadReactions) - } - unreadMessage, ok := unreadMessages[0].(*tg.Message) - if !ok || unreadMessage.ID != viewedID { - t.Fatalf("messages.getUnreadReactions message = %#v, want message %d", unreadMessages[0], viewedID) - } - reactions, ok := unreadMessage.GetReactions() - hasUnreadRecent := false - for _, recent := range reactions.RecentReactions { - if recent.Unread { - hasUnreadRecent = true - break - } - } - if !ok || !hasUnreadRecent { - t.Fatalf("messages.getUnreadReactions reactions = %+v ok %v, want unread recent reaction", reactions, ok) - } - if _, err := r.onMessagesReadReactions(WithUserID(ctx, owner.ID), &tg.MessagesReadReactionsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }); err != nil { - t.Fatalf("messages.readReactions: %v", err) - } - unreadAfterRead, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getUnreadReactions after read: %v", err) - } - unreadAfterReadMessages, _, _ := searchMessagesPayload(t, unreadAfterRead) - if len(unreadAfterReadMessages) != 0 { - t.Fatalf("messages.getUnreadReactions after read = %+v, want empty messages", unreadAfterRead) - } - common, err := r.onMessagesGetCommonChats(WithUserID(ctx, owner.ID), &tg.MessagesGetCommonChatsRequest{ - UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - Limit: 40, - }) - if err != nil { - t.Fatalf("messages.getCommonChats: %v", err) - } - commonChats := common.(*tg.MessagesChats).Chats - if len(commonChats) != 1 || commonChats[0].(*tg.Channel).ID != channel.ID { - t.Fatalf("messages.getCommonChats = %+v, want current shared megagroup", common) - } - commonNext, err := r.onMessagesGetCommonChats(WithUserID(ctx, owner.ID), &tg.MessagesGetCommonChatsRequest{ - UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - MaxID: channel.ID, - Limit: 40, - }) - if err != nil { - t.Fatalf("messages.getCommonChats next page: %v", err) - } - if len(commonNext.(*tg.MessagesChats).Chats) != 0 { - t.Fatalf("messages.getCommonChats next page = %+v, want empty after max id", commonNext) - } - fullFriend, err := r.onUsersGetFullUser(WithUserID(ctx, owner.ID), &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}) - if err != nil { - t.Fatalf("users.getFullUser friend: %v", err) - } - if fullFriend.FullUser.CommonChatsCount != 1 { - t.Fatalf("users.getFullUser commonChatsCount = %d, want 1", fullFriend.FullUser.CommonChatsCount) - } - if _, err := r.onMessagesGetCommonChats(WithUserID(ctx, owner.ID), &tg.MessagesGetCommonChatsRequest{ - UserID: &tg.InputUserSelf{}, - Limit: 1, - }); err == nil || !strings.Contains(err.Error(), "USER_ID_INVALID") { - t.Fatalf("messages.getCommonChats self err = %v, want USER_ID_INVALID", err) - } - if _, err := r.onMessagesGetAttachedStickers(WithUserID(ctx, owner.ID), nil); err == nil || !strings.Contains(err.Error(), "MEDIA_EMPTY") { - t.Fatalf("messages.getAttachedStickers nil err = %v, want MEDIA_EMPTY", err) - } - attached, err := r.onMessagesGetAttachedStickers(WithUserID(ctx, owner.ID), &tg.InputStickeredMediaDocument{ - ID: &tg.InputDocument{ID: 1, AccessHash: 2}, - }) - if err != nil || len(attached) != 0 { - t.Fatalf("messages.getAttachedStickers = %+v err=%v, want empty", attached, err) - } - customEmojiDocs, err := r.onMessagesGetCustomEmojiDocuments(WithUserID(ctx, owner.ID), []int64{1, 2}) - if err != nil || len(customEmojiDocs) != 0 { - t.Fatalf("messages.getCustomEmojiDocuments = %+v err=%v, want empty", customEmojiDocs, err) - } - if _, err := r.onMessagesGetCustomEmojiDocuments(WithUserID(ctx, owner.ID), make([]int64, maxEmojiDocuments+1)); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("messages.getCustomEmojiDocuments too many err = %v, want LIMIT_INVALID", err) - } - stickerSets, err := r.onMessagesSearchStickerSets(WithUserID(ctx, owner.ID), &tg.MessagesSearchStickerSetsRequest{Q: "cat"}) - if err != nil { - t.Fatalf("messages.searchStickerSets: %v", err) - } - if found, ok := stickerSets.(*tg.MessagesFoundStickerSets); !ok || len(found.Sets) != 0 { - t.Fatalf("messages.searchStickerSets = %#v, want empty found sets", stickerSets) - } - stickers, err := r.onMessagesSearchStickers(WithUserID(ctx, owner.ID), &tg.MessagesSearchStickersRequest{ - Q: "cat", - LangCode: []string{"en"}, - Limit: 50, - }) - if err != nil { - t.Fatalf("messages.searchStickers: %v", err) - } - if found, ok := stickers.(*tg.MessagesFoundStickers); !ok || len(found.Stickers) != 0 { - t.Fatalf("messages.searchStickers = %#v, want empty found stickers", stickers) - } - if _, err := r.onMessagesSearchStickers(WithUserID(ctx, owner.ID), &tg.MessagesSearchStickersRequest{ - Limit: maxSearchResultsLimit + 1, - }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("messages.searchStickers huge limit err = %v, want LIMIT_INVALID", err) - } - keywords, err := r.onMessagesGetEmojiKeywords(WithUserID(ctx, owner.ID), "en") - if err != nil || keywords.LangCode != "en" || len(keywords.Keywords) != 0 { - t.Fatalf("messages.getEmojiKeywords = %+v err=%v, want empty en", keywords, err) - } - keywordsDiff, err := r.onMessagesGetEmojiKeywordsDifference(WithUserID(ctx, owner.ID), &tg.MessagesGetEmojiKeywordsDifferenceRequest{ - LangCode: "en", - FromVersion: 7, - }) - if err != nil || keywordsDiff.Version != 7 || keywordsDiff.FromVersion != 7 { - t.Fatalf("messages.getEmojiKeywordsDifference = %+v err=%v, want version echo", keywordsDiff, err) - } - extended, err := r.onMessagesGetExtendedMedia(WithUserID(ctx, owner.ID), &tg.MessagesGetExtendedMediaRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: []int{1}, - }) - if err != nil { - t.Fatalf("messages.getExtendedMedia: %v", err) - } - if len(extended.(*tg.Updates).Updates) != 0 { - t.Fatalf("messages.getExtendedMedia = %+v, want empty updates", extended) - } - topReactions, err := r.onMessagesGetTopReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetTopReactionsRequest{Limit: 3}) - if err != nil { - t.Fatalf("messages.getTopReactions: %v", err) - } - topPage, ok := topReactions.(*tg.MessagesReactions) - if !ok || topPage.Hash == 0 || len(topPage.Reactions) != 3 { - t.Fatalf("messages.getTopReactions = %#v, want three hashable reactions", topReactions) - } - if emoji, ok := topPage.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" { - t.Fatalf("messages.getTopReactions first reaction = %#v, want account top thumb", topPage.Reactions[0]) - } - if emoji, ok := topPage.Reactions[1].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\u2764\ufe0f" { - t.Fatalf("messages.getTopReactions fallback reaction = %#v, want catalog heart", topPage.Reactions[1]) - } - topNotModified, err := r.onMessagesGetTopReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetTopReactionsRequest{Limit: 3, Hash: topPage.Hash}) - if err != nil { - t.Fatalf("messages.getTopReactions hash: %v", err) - } - if _, ok := topNotModified.(*tg.MessagesReactionsNotModified); !ok { - t.Fatalf("messages.getTopReactions hash = %#v, want notModified", topNotModified) - } - recentReactions, err := r.onMessagesGetRecentReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetRecentReactionsRequest{Limit: 40}) - if err != nil { - t.Fatalf("messages.getRecentReactions: %v", err) - } - recentPage, ok := recentReactions.(*tg.MessagesReactions) - if !ok || recentPage.Hash == 0 || len(recentPage.Reactions) != 1 { - t.Fatalf("messages.getRecentReactions = %#v, want one reaction with non-zero hash", recentReactions) - } - if emoji, ok := recentPage.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" { - t.Fatalf("messages.getRecentReactions reaction = %#v, want thumb emoji", recentPage.Reactions[0]) - } - recentNotModified, err := r.onMessagesGetRecentReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetRecentReactionsRequest{Limit: 40, Hash: recentPage.Hash}) - if err != nil { - t.Fatalf("messages.getRecentReactions hash: %v", err) - } - if _, ok := recentNotModified.(*tg.MessagesReactionsNotModified); !ok { - t.Fatalf("messages.getRecentReactions hash = %#v, want notModified", recentNotModified) - } - if ok, err := r.onMessagesClearRecentReactions(WithUserID(ctx, owner.ID)); err != nil || !ok { - t.Fatalf("messages.clearRecentReactions = ok %v err %v, want true nil", ok, err) - } - recentAfterClear, err := r.onMessagesGetRecentReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetRecentReactionsRequest{Limit: 40, Hash: recentPage.Hash}) - if err != nil { - t.Fatalf("messages.getRecentReactions after clear: %v", err) - } - clearedPage, ok := recentAfterClear.(*tg.MessagesReactions) - if !ok || clearedPage.Hash != 0 || len(clearedPage.Reactions) != 0 { - t.Fatalf("messages.getRecentReactions after clear = %#v, want empty page hash 0", recentAfterClear) - } - savedTagsReq := &tg.MessagesGetSavedReactionTagsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - } - savedTagsReq.SetPeer(savedTagsReq.Peer) - savedTags, err := r.onMessagesGetSavedReactionTags(WithUserID(ctx, owner.ID), savedTagsReq) - if err != nil { - t.Fatalf("messages.getSavedReactionTags: %v", err) - } - if got := savedTags.(*tg.MessagesSavedReactionTags).Tags; len(got) != 0 { - t.Fatalf("messages.getSavedReactionTags = %+v, want empty", got) - } - staleEmptyTags, err := r.onMessagesGetSavedReactionTags(WithUserID(ctx, owner.ID), &tg.MessagesGetSavedReactionTagsRequest{Hash: 123}) - if err != nil { - t.Fatalf("messages.getSavedReactionTags stale empty hash: %v", err) - } - staleEmptyPage, ok := staleEmptyTags.(*tg.MessagesSavedReactionTags) - if !ok || staleEmptyPage.Hash != 0 || len(staleEmptyPage.Tags) != 0 { - t.Fatalf("messages.getSavedReactionTags stale empty hash = %#v, want empty page hash 0", staleEmptyTags) - } - updateTagReq := &tg.MessagesUpdateSavedReactionTagRequest{Reaction: &tg.ReactionEmoji{Emoticon: "ok"}} - updateTagReq.SetTitle("Work") - if ok, err := r.onMessagesUpdateSavedReactionTag(WithUserID(ctx, owner.ID), updateTagReq); err != nil || !ok { - t.Fatalf("messages.updateSavedReactionTag = ok %v err %v, want true nil", ok, err) - } - globalTags, err := r.onMessagesGetSavedReactionTags(WithUserID(ctx, owner.ID), &tg.MessagesGetSavedReactionTagsRequest{}) - if err != nil { - t.Fatalf("messages.getSavedReactionTags global: %v", err) - } - globalPage, ok := globalTags.(*tg.MessagesSavedReactionTags) - if !ok || globalPage.Hash == 0 || len(globalPage.Tags) != 1 { - t.Fatalf("messages.getSavedReactionTags global = %#v, want one hashable tag", globalTags) - } - if emoji, ok := globalPage.Tags[0].Reaction.(*tg.ReactionEmoji); !ok || emoji.Emoticon != "ok" || globalPage.Tags[0].Title != "Work" || globalPage.Tags[0].Count != 0 { - t.Fatalf("messages.getSavedReactionTags tag = %+v, want ok/Work/count0", globalPage.Tags[0]) - } - globalNotModified, err := r.onMessagesGetSavedReactionTags(WithUserID(ctx, owner.ID), &tg.MessagesGetSavedReactionTagsRequest{Hash: globalPage.Hash}) - if err != nil { - t.Fatalf("messages.getSavedReactionTags hash: %v", err) - } - if _, ok := globalNotModified.(*tg.MessagesSavedReactionTagsNotModified); !ok { - t.Fatalf("messages.getSavedReactionTags hash = %#v, want notModified", globalNotModified) - } - peerTagsAfterUpdate, err := r.onMessagesGetSavedReactionTags(WithUserID(ctx, owner.ID), savedTagsReq) - if err != nil { - t.Fatalf("messages.getSavedReactionTags peer after update: %v", err) - } - if got := peerTagsAfterUpdate.(*tg.MessagesSavedReactionTags).Tags; len(got) != 0 { - t.Fatalf("messages.getSavedReactionTags peer after update = %+v, want empty until saved-message tag store exists", got) - } - longTagReq := &tg.MessagesUpdateSavedReactionTagRequest{Reaction: &tg.ReactionEmoji{Emoticon: "ok"}} - longTagReq.SetTitle("abcdefghijklmnop") - if _, err := r.onMessagesUpdateSavedReactionTag(WithUserID(ctx, owner.ID), longTagReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("messages.updateSavedReactionTag long title err = %v, want LIMIT_INVALID", err) - } - if _, err := r.onMessagesUpdateSavedReactionTag(WithUserID(ctx, owner.ID), &tg.MessagesUpdateSavedReactionTagRequest{ - Reaction: &tg.ReactionCustomEmoji{DocumentID: 1}, - }); err == nil || !strings.Contains(err.Error(), "REACTION_INVALID") { - t.Fatalf("messages.updateSavedReactionTag custom emoji err = %v, want REACTION_INVALID", err) - } - tagReactions, err := r.onMessagesGetDefaultTagReactions(WithUserID(ctx, owner.ID), 0) - if err != nil { - t.Fatalf("messages.getDefaultTagReactions: %v", err) - } - if got := tagReactions.(*tg.MessagesReactions).Reactions; len(got) != 0 { - t.Fatalf("messages.getDefaultTagReactions = %+v, want empty", got) - } - if _, err := r.onMessagesSendVote(WithUserID(ctx, owner.ID), &tg.MessagesSendVoteRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - Options: [][]byte{[]byte("a")}, - }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("messages.sendVote err = %v, want MESSAGE_ID_INVALID without poll store", err) - } - if _, err := r.onMessagesSendVote(WithUserID(ctx, owner.ID), &tg.MessagesSendVoteRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - Options: make([][]byte, maxPollVoteOptions+1), - }); err == nil || !strings.Contains(err.Error(), "OPTIONS_TOO_MUCH") { - t.Fatalf("messages.sendVote too many options err = %v, want OPTIONS_TOO_MUCH", err) - } - pollResults, err := r.onMessagesGetPollResults(WithUserID(ctx, owner.ID), &tg.MessagesGetPollResultsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - }) - if err != nil { - t.Fatalf("messages.getPollResults: %v", err) - } - if len(pollResults.(*tg.Updates).Updates) != 0 { - t.Fatalf("messages.getPollResults = %+v, want empty updates", pollResults) - } - pollVotesReq := &tg.MessagesGetPollVotesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: 1, - Option: []byte("a"), - Limit: 10, - } - pollVotesReq.SetOption(pollVotesReq.Option) - pollVotes, err := r.onMessagesGetPollVotes(WithUserID(ctx, owner.ID), pollVotesReq) - if err != nil { - t.Fatalf("messages.getPollVotes: %v", err) - } - if pollVotes.Count != 0 || len(pollVotes.Votes) != 0 || len(pollVotes.Chats) != 1 { - t.Fatalf("messages.getPollVotes = %+v, want empty votes with channel context", pollVotes) - } - if _, err := r.onMessagesGetPollVotes(WithUserID(ctx, owner.ID), &tg.MessagesGetPollVotesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: 1, - Limit: maxSearchResultsLimit + 1, - }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("messages.getPollVotes huge limit err = %v, want LIMIT_INVALID", err) - } - if _, err := r.onMessagesAddPollAnswer(WithUserID(ctx, owner.ID), &tg.MessagesAddPollAnswerRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - Answer: &tg.InputPollAnswer{Text: tg.TextWithEntities{Text: "extra"}}, - }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("messages.addPollAnswer err = %v, want MESSAGE_ID_INVALID without poll store", err) - } - if _, err := r.onMessagesDeletePollAnswer(WithUserID(ctx, owner.ID), &tg.MessagesDeletePollAnswerRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - Option: []byte("a"), - }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("messages.deletePollAnswer err = %v, want MESSAGE_ID_INVALID without poll store", err) - } - unreadPollVotes, err := r.onMessagesGetUnreadPollVotes(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadPollVotesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getUnreadPollVotes: %v", err) - } - if len(unreadPollVotes.(*tg.MessagesMessages).Messages) != 0 { - t.Fatalf("messages.getUnreadPollVotes = %+v, want empty messages", unreadPollVotes) - } - if _, err := r.onMessagesReadPollVotes(WithUserID(ctx, owner.ID), &tg.MessagesReadPollVotesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }); err != nil { - t.Fatalf("messages.readPollVotes: %v", err) - } - if _, err := r.onMessagesAppendTodoList(WithUserID(ctx, owner.ID), &tg.MessagesAppendTodoListRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - }); err == nil || !strings.Contains(err.Error(), "TODO_NOT_MODIFIED") { - t.Fatalf("messages.appendTodoList empty err = %v, want TODO_NOT_MODIFIED", err) - } - if _, err := r.onMessagesAppendTodoList(WithUserID(ctx, owner.ID), &tg.MessagesAppendTodoListRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - List: []tg.TodoItem{{Title: tg.TextWithEntities{Text: "item"}}}, - }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("messages.appendTodoList err = %v, want MESSAGE_ID_INVALID without todo store", err) - } - if _, err := r.onMessagesToggleTodoCompleted(WithUserID(ctx, owner.ID), &tg.MessagesToggleTodoCompletedRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - }); err == nil || !strings.Contains(err.Error(), "TODO_NOT_MODIFIED") { - t.Fatalf("messages.toggleTodoCompleted empty err = %v, want TODO_NOT_MODIFIED", err) - } - if _, err := r.onMessagesToggleTodoCompleted(WithUserID(ctx, owner.ID), &tg.MessagesToggleTodoCompletedRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - Completed: []int{1}, - }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("messages.toggleTodoCompleted err = %v, want MESSAGE_ID_INVALID without todo store", err) - } - counters, err := r.onMessagesGetSearchCounters(WithUserID(ctx, owner.ID), &tg.MessagesGetSearchCountersRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filters: []tg.MessagesFilterClass{&tg.InputMessagesFilterEmpty{}}, - }) - if err != nil { - t.Fatalf("messages.getSearchCounters: %v", err) - } - if len(counters) != 1 || counters[0].Count != 0 { - t.Fatalf("messages.getSearchCounters = %+v, want one zero counter", counters) - } - calendar, err := r.onMessagesGetSearchResultsCalendar(WithUserID(ctx, owner.ID), &tg.MessagesGetSearchResultsCalendarRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.InputMessagesFilterPhotos{}, - OffsetID: 7, - OffsetDate: 1700000000, - }) - if err != nil { - t.Fatalf("messages.getSearchResultsCalendar: %v", err) - } - if calendar.Count != 0 || calendar.MinDate != 1700000000 || calendar.MinMsgID != 7 || len(calendar.Messages) != 0 { - t.Fatalf("messages.getSearchResultsCalendar = %+v, want empty stable-offset result", calendar) - } - positions, err := r.onMessagesGetSearchResultsPositions(WithUserID(ctx, owner.ID), &tg.MessagesGetSearchResultsPositionsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.InputMessagesFilterPhotos{}, - OffsetID: 7, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getSearchResultsPositions: %v", err) - } - if positions.Count != 0 || len(positions.Positions) != 0 { - t.Fatalf("messages.getSearchResultsPositions = %+v, want empty positions", positions) - } - if _, err := r.onMessagesGetSearchResultsPositions(WithUserID(ctx, owner.ID), &tg.MessagesGetSearchResultsPositionsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.InputMessagesFilterPhotos{}, - OffsetID: 7, - Limit: maxSearchResultsLimit + 1, - }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("messages.getSearchResultsPositions huge limit err = %v, want LIMIT_INVALID", err) - } - replies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getReplies: %v", err) - } - _, replyChats, _ := searchMessagesPayload(t, replies) - if len(replyChats) != 1 { - t.Fatalf("messages.getReplies = %+v, want channel context", replies) - } - discussion, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - }) - if err != nil { - t.Fatalf("messages.getDiscussionMessage: %v", err) - } - if len(discussion.Messages) != 1 || len(discussion.Chats) != 1 || discussion.UnreadCount != 0 { - t.Fatalf("messages.getDiscussionMessage = %+v, want root message with channel context", discussion) - } - if _, ok := discussion.Messages[0].(*tg.MessageService); !ok { - t.Fatalf("messages.getDiscussionMessage message = %T, want service root", discussion.Messages[0]) - } - if _, err := r.onMessagesReadDiscussion(WithUserID(ctx, owner.ID), &tg.MessagesReadDiscussionRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - ReadMaxID: 1, - }); err != nil { - t.Fatalf("messages.readDiscussion err = %v, want nil", err) - } - if _, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash + 1}, - MsgID: 1, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_PRIVATE") { - t.Fatalf("messages.getDiscussionMessage bad hash err = %v, want CHANNEL_PRIVATE", err) - } - if _, err := r.onMessagesReadDiscussion(WithUserID(ctx, owner.ID), &tg.MessagesReadDiscussionRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - ReadMaxID: domain.MaxMessageBoxID + 1, - }); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("messages.readDiscussion huge read_max_id err = %v, want MESSAGE_ID_INVALID", err) - } - if _, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { - t.Fatalf("messages.getForumTopics non-forum err = %v, want CHANNEL_FORUM_MISSING", err) - } - if _, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Topics: []int{1}, - }); err == nil || !strings.Contains(err.Error(), "CHANNEL_FORUM_MISSING") { - t.Fatalf("messages.getForumTopicsByID non-forum err = %v, want CHANNEL_FORUM_MISSING", err) - } - onlines, err := r.onMessagesGetOnlines(WithUserID(ctx, owner.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) - if err != nil { - t.Fatalf("messages.getOnlines: %v", err) - } - if onlines.Onlines != 1 { - t.Fatalf("messages.getOnlines = %+v, want count 1", onlines) - } - stats, err := r.onStatsGetMegagroupStats(WithUserID(ctx, owner.ID), &tg.StatsGetMegagroupStatsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }) - if err != nil { - t.Fatalf("stats.getMegagroupStats: %v", err) - } - if stats.GrowthGraph == nil || stats.MembersGraph == nil { - t.Fatalf("megagroup stats = %+v, want non-nil graph stubs", stats) - } - if _, err := r.onStatsGetBroadcastStats(WithUserID(ctx, owner.ID), &tg.StatsGetBroadcastStatsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }); err == nil || !strings.Contains(err.Error(), "BROADCAST_REQUIRED") { - t.Fatalf("stats.getBroadcastStats on megagroup err = %v, want BROADCAST_REQUIRED", err) - } - messageStats, err := r.onStatsGetMessageStats(WithUserID(ctx, owner.ID), &tg.StatsGetMessageStatsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - }) - if err != nil { - t.Fatalf("stats.getMessageStats: %v", err) - } - if messageStats.ViewsGraph == nil || messageStats.ReactionsByEmotionGraph == nil { - t.Fatalf("message stats = %+v, want non-nil graph stubs", messageStats) - } - forwards, err := r.onStatsGetMessagePublicForwards(WithUserID(ctx, owner.ID), &tg.StatsGetMessagePublicForwardsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - Limit: 10, - }) - if err != nil { - t.Fatalf("stats.getMessagePublicForwards: %v", err) - } - if forwards.Count != 0 || len(forwards.Forwards) != 0 { - t.Fatalf("message public forwards = %+v, want empty", forwards) - } - if _, err := r.onStatsGetMessagePublicForwards(WithUserID(ctx, owner.ID), &tg.StatsGetMessagePublicForwardsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - MsgID: 1, - Limit: maxStatsPublicForwardsLimit + 1, - }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("stats.getMessagePublicForwards huge limit err = %v, want LIMIT_INVALID", err) - } - graph, err := r.onStatsLoadAsyncGraph(WithUserID(ctx, owner.ID), &tg.StatsLoadAsyncGraphRequest{Token: "stale"}) - if err != nil { - t.Fatalf("stats.loadAsyncGraph: %v", err) - } - if _, ok := graph.(*tg.StatsGraphError); !ok { - t.Fatalf("stats.loadAsyncGraph = %T, want statsGraphError", graph) - } - boostStatus, err := r.onPremiumGetBoostsStatus(WithUserID(ctx, owner.ID), &tg.InputPeerChannel{ - ChannelID: channel.ID, - AccessHash: channel.AccessHash, - }) - if err != nil { - t.Fatalf("premium.getBoostsStatus: %v", err) - } - if boostStatus.Level != 0 || boostStatus.Boosts != 0 { - t.Fatalf("premium.getBoostsStatus = %+v, want zero status", boostStatus) - } - boosts, err := r.onPremiumGetBoostsList(WithUserID(ctx, owner.ID), &tg.PremiumGetBoostsListRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - }) - if err != nil { - t.Fatalf("premium.getBoostsList: %v", err) - } - if boosts.Count != 0 || len(boosts.Boosts) != 0 || len(boosts.Users) != 0 { - t.Fatalf("premium.getBoostsList = %+v, want empty", boosts) - } - if _, err := r.onPremiumGetBoostsList(WithUserID(ctx, owner.ID), &tg.PremiumGetBoostsListRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: maxPremiumBoostsListLimit + 1, - }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("premium.getBoostsList huge limit err = %v, want LIMIT_INVALID", err) - } - myBoosts, err := r.onPremiumGetMyBoosts(WithUserID(ctx, owner.ID)) - if err != nil { - t.Fatalf("premium.getMyBoosts: %v", err) - } - if len(myBoosts.MyBoosts) != 0 || len(myBoosts.Chats) != 0 || len(myBoosts.Users) != 0 { - t.Fatalf("premium.getMyBoosts = %+v, want empty", myBoosts) - } - applyReq := &tg.PremiumApplyBoostRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - } - applyReq.SetSlots([]int{1}) - applied, err := r.onPremiumApplyBoost(WithUserID(ctx, owner.ID), applyReq) - if err != nil { - t.Fatalf("premium.applyBoost: %v", err) - } - if len(applied.MyBoosts) != 0 { - t.Fatalf("premium.applyBoost = %+v, want empty noop", applied) - } - if _, err := r.onPremiumApplyBoost(WithUserID(ctx, owner.ID), &tg.PremiumApplyBoostRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }); err == nil || !strings.Contains(err.Error(), "BOOSTS_EMPTY") { - t.Fatalf("premium.applyBoost missing slots err = %v, want BOOSTS_EMPTY", err) - } - userBoosts, err := r.onPremiumGetUserBoosts(WithUserID(ctx, owner.ID), &tg.PremiumGetUserBoostsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, - }) - if err != nil { - t.Fatalf("premium.getUserBoosts: %v", err) - } - if userBoosts.Count != 0 || len(userBoosts.Boosts) != 0 { - t.Fatalf("premium.getUserBoosts = %+v, want empty", userBoosts) - } - if _, err := r.onMessagesAddChatUser(WithUserID(ctx, owner.ID), &tg.MessagesAddChatUserRequest{ - ChatID: channel.ID, - UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, - }); err != nil { - t.Fatalf("messages.addChatUser legacy wrapper: %v", err) - } - if _, err := r.onMessagesEditChatPhoto(WithUserID(ctx, owner.ID), &tg.MessagesEditChatPhotoRequest{ - ChatID: channel.ID, - Photo: &tg.InputChatPhotoEmpty{}, - }); err != nil { - t.Fatalf("messages.editChatPhoto legacy wrapper: %v", err) - } - if _, err := r.onChannelsEditPhoto(WithUserID(ctx, owner.ID), &tg.ChannelsEditPhotoRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Photo: &tg.InputChatUploadedPhoto{}, - }); err == nil || !strings.Contains(err.Error(), "PHOTO_INVALID") { - t.Fatalf("channels.editPhoto uploaded photo err = %v, want PHOTO_INVALID", err) - } - if _, err := r.onChannelsEditPhoto(WithUserID(ctx, owner.ID), &tg.ChannelsEditPhotoRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Photo: &tg.InputChatPhoto{ID: &tg.InputPhoto{ID: 1, AccessHash: 2, FileReference: []byte{3}}}, - }); err == nil || !strings.Contains(err.Error(), "PHOTO_INVALID") { - t.Fatalf("channels.editPhoto existing photo err = %v, want PHOTO_INVALID", err) - } - if ok, err := r.onMessagesEditChatAdmin(WithUserID(ctx, owner.ID), &tg.MessagesEditChatAdminRequest{ - ChatID: channel.ID, - UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, - IsAdmin: true, - }); err != nil || !ok { - t.Fatalf("messages.editChatAdmin legacy wrapper = %v, %v", ok, err) - } - if _, err := r.onMessagesEditChatParticipantRank(WithUserID(ctx, owner.ID), &tg.MessagesEditChatParticipantRankRequest{ - Peer: &tg.InputPeerChat{ChatID: channel.ID}, - Participant: &tg.InputPeerUser{UserID: invited.ID, AccessHash: invited.AccessHash}, - Rank: "ops", - }); err != nil { - t.Fatalf("messages.editChatParticipantRank legacy wrapper: %v", err) - } - if ok, err := r.onMessagesEditChatAbout(WithUserID(ctx, owner.ID), &tg.MessagesEditChatAboutRequest{ - Peer: &tg.InputPeerChat{ChatID: channel.ID}, - About: "legacy about", - }); err != nil || !ok { - t.Fatalf("messages.editChatAbout legacy wrapper = %v, %v", ok, err) - } - fullAfterAbout, err := r.onMessagesGetFullChat(WithUserID(ctx, owner.ID), channel.ID) - if err != nil { - t.Fatalf("messages.getFullChat after editChatAbout: %v", err) - } - if got := fullAfterAbout.FullChat.(*tg.ChannelFull).About; got != "legacy about" { - t.Fatalf("full chat about = %q, want legacy about", got) - } - if _, err := r.onMessagesEditChatCreator(WithUserID(ctx, owner.ID), &tg.MessagesEditChatCreatorRequest{ - Peer: &tg.InputPeerChat{ChatID: channel.ID}, - UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, - }); err == nil { - t.Fatalf("messages.editChatCreator = nil error, want explicit password/2FA error") - } - permUpdates, err := r.onMessagesEditChatDefaultBannedRights(WithUserID(ctx, owner.ID), &tg.MessagesEditChatDefaultBannedRightsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - BannedRights: tg.ChatBannedRights{ - SendMessages: true, - }, - }) - if err != nil { - t.Fatalf("messages.editChatDefaultBannedRights: %v", err) - } - if len(permUpdates.(*tg.Updates).Updates) == 0 { - t.Fatalf("messages.editChatDefaultBannedRights updates = %+v, want updateChannel", permUpdates) - } - if _, err := r.onMessagesDeleteChatUser(WithUserID(ctx, owner.ID), &tg.MessagesDeleteChatUserRequest{ - ChatID: channel.ID, - UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, - }); err != nil { - t.Fatalf("messages.deleteChatUser legacy wrapper: %v", err) - } -} - -func incrementalViewCount(result *tg.MessagesMessageViews) (int, bool) { - if result == nil || len(result.Views) == 0 { - return 0, false - } - return result.Views[0].GetViews() -} - -func TestChannelAdminPinInviteRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 51, Phone: "15550002201", FirstName: "Owner"}) - friend, _ := userStore.Create(ctx, domain.User{AccessHash: 52, Phone: "15550002202", FirstName: "Friend"}) - joiner, _ := userStore.Create(ctx, domain.User{AccessHash: 53, Phone: "15550002203", FirstName: "Joiner"}) - invited, _ := userStore.Create(ctx, domain.User{AccessHash: 54, Phone: "15550002204", FirstName: "Invited"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{&tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}}, - Title: "RPC Admin Group", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - createdChannel, err := channelStore.GetChannelByID(ctx, channel.ID) - if err != nil { - t.Fatalf("get created channel: %v", err) - } - initialChannelPts := createdChannel.Pts - - selfParticipant, err := r.onChannelsGetParticipant(WithUserID(ctx, friend.ID), &tg.ChannelsGetParticipantRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Participant: &tg.InputPeerSelf{}, - }) - if err != nil { - t.Fatalf("get self regular participant: %v", err) - } - if _, ok := selfParticipant.Participant.(*tg.ChannelParticipantSelf); !ok { - t.Fatalf("self regular participant = %T, want channelParticipantSelf", selfParticipant.Participant) - } - recentForFriend, err := r.onChannelsGetParticipants(WithUserID(ctx, friend.ID), &tg.ChannelsGetParticipantsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelParticipantsRecent{}, - Limit: 10, - }) - if err != nil { - t.Fatalf("get recent participants for regular self: %v", err) - } - foundSelf := false - for _, participant := range recentForFriend.(*tg.ChannelsChannelParticipants).Participants { - if _, ok := participant.(*tg.ChannelParticipantSelf); ok { - foundSelf = true - break - } - } - if !foundSelf { - t.Fatalf("recent participants = %+v, want current regular member as channelParticipantSelf", recentForFriend.(*tg.ChannelsChannelParticipants).Participants) - } - - adminUpdates, err := r.onChannelsEditAdmin(WithUserID(ctx, owner.ID), &tg.ChannelsEditAdminRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - UserID: &tg.InputUser{UserID: friend.ID, AccessHash: friend.AccessHash}, - AdminRights: tg.ChatAdminRights{ - ChangeInfo: true, - InviteUsers: true, - PinMessages: true, - }, - Rank: "ops", - }) - if err != nil { - t.Fatalf("edit admin: %v", err) - } - if updates := adminUpdates.(*tg.Updates); len(updates.Updates) != 2 { - t.Fatalf("admin updates = %+v, want participant update and channel refresh", updates.Updates) - } else if _, ok := updates.Updates[0].(*tg.UpdateChannelParticipant); !ok { - t.Fatalf("admin update[0] = %T, want updateChannelParticipant", updates.Updates[0]) - } else if _, ok := updates.Updates[1].(*tg.UpdateChannel); !ok { - t.Fatalf("admin update[1] = %T, want updateChannel", updates.Updates[1]) - } - adminDiff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, friend.ID), &tg.UpdatesGetChannelDifferenceRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelMessagesFilterEmpty{}, - Pts: initialChannelPts, - Limit: 10, - }) - if err != nil { - t.Fatalf("channel difference after admin: %v", err) - } - adminEmptyDiff, ok := adminDiff.(*tg.UpdatesChannelDifferenceEmpty) - if !ok || !adminEmptyDiff.Final || adminEmptyDiff.Pts != initialChannelPts { - t.Fatalf("admin diff = %T %+v, want empty difference at unchanged pts %d", adminDiff, adminDiff, initialChannelPts) - } - admins, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Filter: &tg.ChannelParticipantsAdmins{}, - Limit: 10, - }) - if err != nil { - t.Fatalf("get admin participants: %v", err) - } - if list := admins.(*tg.ChannelsChannelParticipants); len(list.Participants) != 2 { - t.Fatalf("admin participants = %+v, want creator and promoted admin", list.Participants) - } - - titleUpdates, err := r.onChannelsEditTitle(WithUserID(ctx, friend.ID), &tg.ChannelsEditTitleRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Title: "RPC Admin Group 2", - }) - if err != nil { - t.Fatalf("edit title: %v", err) - } - titleContainer := titleUpdates.(*tg.Updates) - if len(titleContainer.Updates) < 2 { - t.Fatalf("title updates = %+v, want channel + service message", titleContainer.Updates) - } - titleMsg, ok := titleContainer.Updates[1].(*tg.UpdateNewChannelMessage) - if !ok { - t.Fatalf("title update[1] = %T, want updateNewChannelMessage", titleContainer.Updates[1]) - } - if action := titleMsg.Message.(*tg.MessageService).Action; action.(*tg.MessageActionChatEditTitle).Title != "RPC Admin Group 2" { - t.Fatalf("title action = %#v, want new title", action) - } - - sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "pin me", - RandomID: 123, - }) - if err != nil { - t.Fatalf("send for pin: %v", err) - } - msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID - pinUpdates, err := r.onMessagesUpdatePinnedMessage(WithUserID(ctx, friend.ID), &tg.MessagesUpdatePinnedMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - ID: msgID, - }) - if err != nil { - t.Fatalf("pin message: %v", err) - } - pinned, ok := pinUpdates.(*tg.Updates).Updates[0].(*tg.UpdatePinnedChannelMessages) - if !ok || !pinned.Pinned || pinned.Messages[0] != msgID { - t.Fatalf("pin update = %#v, want pinned channel message id=%d", pinUpdates.(*tg.Updates).Updates[0], msgID) - } - - invitedUsers, err := r.onChannelsInviteToChannel(WithUserID(ctx, friend.ID), &tg.ChannelsInviteToChannelRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Users: []tg.InputUserClass{&tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}}, - }) - if err != nil { - t.Fatalf("invite to channel: %v", err) - } - if invitedUsers.Updates == nil || len(invitedUsers.MissingInvitees) != 0 { - t.Fatalf("invited users = %+v, want updates and no missing users", invitedUsers) - } - - invite, err := r.onMessagesExportChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesExportChatInviteRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Title: "join", - }) - if err != nil { - t.Fatalf("export invite: %v", err) - } - exported := invite.(*tg.ChatInviteExported) - hash := strings.TrimPrefix(exported.Link, "https://t.me/+") - checked, err := r.onMessagesCheckChatInvite(WithUserID(ctx, joiner.ID), hash) - if err != nil { - t.Fatalf("check invite: %v", err) - } - if preview, ok := checked.(*tg.ChatInvite); !ok || !preview.Megagroup || preview.Title != "RPC Admin Group 2" { - t.Fatalf("invite preview = %#v, want megagroup title", checked) - } - imported, err := r.onMessagesImportChatInvite(WithUserID(ctx, joiner.ID), hash) - if err != nil { - t.Fatalf("import invite: %v", err) - } - if updates := imported.(*tg.Updates); len(updates.Chats) != 1 || len(updates.Updates) != 2 { - t.Fatalf("import updates = %+v, want chat, join service update, and channel refresh", updates) - } else if _, ok := updates.Updates[0].(*tg.UpdateNewChannelMessage); !ok { - t.Fatalf("import first update = %T, want join service update", updates.Updates[0]) - } else if refresh, ok := updates.Updates[1].(*tg.UpdateChannel); !ok || refresh.ChannelID != channel.ID { - t.Fatalf("import second update = %#v, want channel refresh", updates.Updates[1]) - } - inviteList, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInvitesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - AdminID: &tg.InputUserSelf{}, - Limit: 10, - }) - if err != nil { - t.Fatalf("get exported invites: %v", err) - } - if inviteList.Count != 1 || len(inviteList.Invites) != 1 || len(inviteList.Users) == 0 { - t.Fatalf("exported invite list = %+v, want persisted invite plus admin user context", inviteList) - } - listedInvite, ok := inviteList.Invites[0].(*tg.ChatInviteExported) - listedUsage, listedUsageOK := listedInvite.GetUsage() - listedTitle, listedTitleOK := listedInvite.GetTitle() - if !ok || listedInvite.Link != exported.Link || !listedUsageOK || listedUsage != 1 || !listedTitleOK || listedTitle != "join" { - t.Fatalf("listed invite = %#v, want exported link with one import", inviteList.Invites[0]) - } - if _, err := r.onMessagesGetExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInvitesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - AdminID: &tg.InputUserSelf{}, - Limit: 101, - }); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("get exported invites high limit err = %v, want LIMIT_INVALID", err) - } - inviteDetails, err := r.onMessagesGetExportedChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesGetExportedChatInviteRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Link: exported.Link, - }) - if err != nil { - t.Fatalf("get exported invite: %v", err) - } - if details := inviteDetails.(*tg.MessagesExportedChatInvite); details.Invite == nil || len(details.Users) == 0 { - t.Fatalf("exported invite details = %+v, want invite plus user context", inviteDetails) - } - editInviteReq := &tg.MessagesEditExportedChatInviteRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Link: exported.Link, - } - editInviteReq.SetTitle("ops link") - editedInvite, err := r.onMessagesEditExportedChatInvite(WithUserID(ctx, friend.ID), editInviteReq) - if err != nil { - t.Fatalf("edit exported invite: %v", err) - } - if edited := editedInvite.(*tg.MessagesExportedChatInvite); edited.Invite == nil || len(edited.Users) == 0 { - t.Fatalf("edited invite = %+v, want invite plus user context", editedInvite) - } else if got, ok := edited.Invite.(*tg.ChatInviteExported).GetTitle(); !ok || got != "ops link" { - t.Fatalf("edited invite title = %q, want ops link", got) - } - adminsWithInvites, err := r.onMessagesGetAdminsWithInvites(WithUserID(ctx, friend.ID), &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) - if err != nil { - t.Fatalf("get admins with invites: %v", err) - } - if len(adminsWithInvites.Admins) != 1 || adminsWithInvites.Admins[0].AdminID != friend.ID || adminsWithInvites.Admins[0].InvitesCount != 1 || len(adminsWithInvites.Users) == 0 { - t.Fatalf("admins with invites = %+v, want self admin context with one invite", adminsWithInvites) - } - importers, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, friend.ID), &tg.MessagesGetChatInviteImportersRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - }) - if err != nil { - t.Fatalf("get invite importers: %v", err) - } - if importers.Count != 1 || len(importers.Importers) != 1 || importers.Importers[0].UserID != joiner.ID || len(importers.Users) == 0 { - t.Fatalf("invite importers = %+v, want joined importer", importers) - } - importersSearchReq := &tg.MessagesGetChatInviteImportersRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - } - importersSearchReq.SetLink(exported.Link) - importersSearchReq.SetQ("bob") - if _, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, friend.ID), importersSearchReq); err == nil || !strings.Contains(err.Error(), "SEARCH_WITH_LINK_NOT_SUPPORTED") { - t.Fatalf("get invite importers q+link err = %v, want SEARCH_WITH_LINK_NOT_SUPPORTED", err) - } - if _, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, friend.ID), &tg.MessagesHideChatJoinRequestRequest{ - Approved: true, - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - UserID: &tg.InputUser{UserID: invited.ID, AccessHash: invited.AccessHash}, - }); err == nil || !strings.Contains(err.Error(), "HIDE_REQUESTER_MISSING") { - t.Fatalf("hide chat join request without pending err = %v, want HIDE_REQUESTER_MISSING", err) - } - if updates, err := r.onMessagesHideAllChatJoinRequests(WithUserID(ctx, friend.ID), &tg.MessagesHideAllChatJoinRequestsRequest{ - Approved: false, - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }); err != nil { - t.Fatalf("hide all chat join requests: %v", err) - } else if _, ok := updates.(*tg.Updates); !ok { - t.Fatalf("hide all chat join requests updates = %T, want *tg.Updates", updates) - } - if ok, err := r.onMessagesDeleteExportedChatInvite(WithUserID(ctx, friend.ID), &tg.MessagesDeleteExportedChatInviteRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Link: exported.Link, - }); err != nil || !ok { - t.Fatalf("delete exported invite ok=%v err=%v, want true nil", ok, err) - } - if ok, err := r.onMessagesDeleteRevokedExportedChatInvites(WithUserID(ctx, friend.ID), &tg.MessagesDeleteRevokedExportedChatInvitesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - AdminID: &tg.InputUserSelf{}, - }); err != nil || !ok { - t.Fatalf("delete revoked invites ok=%v err=%v, want true nil", ok, err) - } - adminLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 20, - }) - if err != nil { - t.Fatalf("get admin log: %v", err) - } - if len(adminLog.Events) < 5 || len(adminLog.Chats) != 1 || len(adminLog.Users) < 3 { - t.Fatalf("admin log = %+v, want events plus chat/users", adminLog) - } - tooManyAdmins := make([]tg.InputUserClass, domain.MaxChannelAdminLogAdmins+1) - for i := range tooManyAdmins { - tooManyAdmins[i] = &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash} - } - tooManyAdminsReq := &tg.ChannelsGetAdminLogRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 1, - } - tooManyAdminsReq.SetAdmins(tooManyAdmins) - if _, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), tooManyAdminsReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("get admin log too many admins err = %v, want LIMIT_INVALID", err) - } - pinnedFilter := tg.ChannelAdminLogEventsFilter{} - pinnedFilter.SetPinned(true) - pinnedReq := &tg.ChannelsGetAdminLogRequest{ - Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - } - pinnedReq.SetEventsFilter(pinnedFilter) - pinnedLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), pinnedReq) - if err != nil { - t.Fatalf("get pinned admin log: %v", err) - } - if len(pinnedLog.Events) != 1 { - t.Fatalf("pinned admin log events = %+v, want one", pinnedLog.Events) - } - if _, ok := pinnedLog.Events[0].Action.(*tg.ChannelAdminLogEventActionUpdatePinned); !ok { - t.Fatalf("pinned admin log action = %T, want updatePinned", pinnedLog.Events[0].Action) - } - unpinnedAll, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), &tg.MessagesUnpinAllMessagesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }) - if err != nil { - t.Fatalf("unpin all messages: %v", err) - } - if unpinnedAll.Pts == 0 || unpinnedAll.PtsCount != 1 || unpinnedAll.Offset != 0 { - t.Fatalf("unpin all affected history = %+v, want one channel pts event", unpinnedAll) - } - afterUnpin, err := r.deps.Channels.GetChannel(ctx, friend.ID, channel.ID) - if err != nil { - t.Fatalf("get channel after unpin: %v", err) - } - if afterUnpin.Channel.PinnedMessageID != 0 { - t.Fatalf("pinned message after unpin all = %d, want 0", afterUnpin.Channel.PinnedMessageID) - } - unpinnedAgain, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), &tg.MessagesUnpinAllMessagesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - }) - if err != nil { - t.Fatalf("unpin all messages again: %v", err) - } - if unpinnedAgain.Pts != afterUnpin.Channel.Pts || unpinnedAgain.PtsCount != 0 || unpinnedAgain.Offset != 0 { - t.Fatalf("unpin all no-op affected history = %+v, want current pts with zero pts_count", unpinnedAgain) - } - invalidTopicUnpin := &tg.MessagesUnpinAllMessagesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - } - invalidTopicUnpin.SetTopMsgID(domain.MaxMessageBoxID + 1) - if _, err := r.onMessagesUnpinAllMessages(WithUserID(ctx, friend.ID), invalidTopicUnpin); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("unpin all invalid top msg err = %v, want MESSAGE_ID_INVALID", err) - } -} - -func TestChannelInviteKickedMemberRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 55, Phone: "15550002255", FirstName: "Owner"}) - helper, _ := userStore.Create(ctx, domain.User{AccessHash: 56, Phone: "15550002256", FirstName: "Helper"}) - kicked, _ := userStore.Create(ctx, domain.User{AccessHash: 57, Phone: "15550002257", FirstName: "Kicked"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{ - Users: []tg.InputUserClass{ - &tg.InputUser{UserID: helper.ID, AccessHash: helper.AccessHash}, - &tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}, - }, - Title: "RPC Invite Kicked", - }) - if err != nil { - t.Fatalf("create chat: %v", err) - } - channel := created.Updates.(*tg.Updates).Chats[0].(*tg.Channel) - input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} - if _, err := r.onChannelsEditBanned(WithUserID(ctx, owner.ID), &tg.ChannelsEditBannedRequest{ - Channel: input, - Participant: &tg.InputPeerUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}, - BannedRights: tg.ChatBannedRights{ - ViewMessages: true, - }, - }); err != nil { - t.Fatalf("kick member: %v", err) - } - if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, helper.ID), &tg.ChannelsInviteToChannelRequest{ - Channel: input, - Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}}, - }); err == nil || !strings.Contains(err.Error(), "USER_KICKED") { - t.Fatalf("helper invite kicked err = %v, want USER_KICKED", err) - } - if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ - Channel: input, - Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}}, - }); err != nil { - t.Fatalf("owner restore kicked invite: %v", err) - } - if _, err := r.onChannelsInviteToChannel(WithUserID(ctx, owner.ID), &tg.ChannelsInviteToChannelRequest{ - Channel: input, - Users: []tg.InputUserClass{&tg.InputUser{UserID: kicked.ID, AccessHash: kicked.AccessHash}}, - }); err == nil || !strings.Contains(err.Error(), "USER_ALREADY_PARTICIPANT") { - t.Fatalf("duplicate invite err = %v, want USER_ALREADY_PARTICIPANT", err) - } -} - -func TestImportChatInviteErrorsRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 58, Phone: "15550002258", FirstName: "Owner"}) - first, _ := userStore.Create(ctx, domain.User{AccessHash: 59, Phone: "15550002259", FirstName: "First"}) - second, _ := userStore.Create(ctx, domain.User{AccessHash: 60, Phone: "15550002260", FirstName: "Second"}) - channelStore := memory.NewChannelStore() - sessions := &captureSessions{} - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ - Title: "RPC Import Errors", - Megagroup: true, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channel := created.(*tg.Updates).Chats[0].(*tg.Channel) - input := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} - inputChannel := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} - requestInvite, err := r.onMessagesExportChatInvite(WithUserID(ctx, owner.ID), &tg.MessagesExportChatInviteRequest{ - Peer: input, - Title: "approval", - RequestNeeded: true, - }) - if err != nil { - t.Fatalf("export request-needed invite: %v", err) - } - requestHash := strings.TrimPrefix(requestInvite.(*tg.ChatInviteExported).Link, "https://t.me/+") - if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, first.ID), requestHash); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") { - t.Fatalf("import request-needed err = %v, want INVITE_REQUEST_SENT", err) - } - pushedPending := sessions.snapshot() - if pushedPending.userID != owner.ID || pushedPending.messageType != proto.MessageFromServer { - t.Fatalf("pending request push = %+v, want owner server update", pushedPending) - } - pushedPendingUpdates, ok := pushedPending.message.(*tg.Updates) - if !ok || len(pushedPendingUpdates.Updates) != 1 { - t.Fatalf("pending request push message = %T %+v, want one update", pushedPending.message, pushedPending.message) - } - pushedPendingUpdate, ok := pushedPendingUpdates.Updates[0].(*tg.UpdatePendingJoinRequests) - if !ok || pushedPendingUpdate.RequestsPending != 1 || len(pushedPendingUpdate.RecentRequesters) != 1 || pushedPendingUpdate.RecentRequesters[0] != first.ID { - t.Fatalf("pending request update = %+v, want first requester", pushedPendingUpdates.Updates[0]) - } - fullAfterPending, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), inputChannel) - if err != nil { - t.Fatalf("get full channel after pending request: %v", err) - } - fullPending := fullAfterPending.FullChat.(*tg.ChannelFull) - requestsPending, ok := fullPending.GetRequestsPending() - recentRequesters, recentOK := fullPending.GetRecentRequesters() - if !ok || requestsPending != 1 || !recentOK || len(recentRequesters) != 1 || recentRequesters[0] != first.ID { - t.Fatalf("full pending = count %d ok %v recent %+v ok %v, want first requester", requestsPending, ok, recentRequesters, recentOK) - } - pendingReq := &tg.MessagesGetChatInviteImportersRequest{ - Requested: true, - Peer: input, - Limit: 10, - } - pendingReq.SetLink(requestInvite.(*tg.ChatInviteExported).Link) - pending, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq) - if err != nil { - t.Fatalf("get pending invite importers: %v", err) - } - if pending.Count != 1 || len(pending.Importers) != 1 || pending.Importers[0].UserID != first.ID || !pending.Importers[0].Requested { - t.Fatalf("pending importers = %+v, want first pending request", pending) - } - limitedInvite, err := r.onMessagesExportChatInvite(WithUserID(ctx, owner.ID), &tg.MessagesExportChatInviteRequest{ - Peer: input, - Title: "one", - UsageLimit: 1, - }) - if err != nil { - t.Fatalf("export limited invite: %v", err) - } - limitedHash := strings.TrimPrefix(limitedInvite.(*tg.ChatInviteExported).Link, "https://t.me/+") - if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, first.ID), limitedHash); err != nil { - t.Fatalf("first import limited invite: %v", err) - } - pendingAfterJoin, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq) - if err != nil { - t.Fatalf("get pending invite importers after join: %v", err) - } - if pendingAfterJoin.Count != 0 || len(pendingAfterJoin.Importers) != 0 { - t.Fatalf("pending importers after alternate invite join = %+v, want cleared", pendingAfterJoin) - } - if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, second.ID), limitedHash); err == nil || !strings.Contains(err.Error(), "USERS_TOO_MUCH") { - t.Fatalf("second import limited err = %v, want USERS_TOO_MUCH", err) - } - if _, err := r.onMessagesImportChatInvite(WithUserID(ctx, second.ID), requestHash); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") { - t.Fatalf("second import request-needed err = %v, want INVITE_REQUEST_SENT", err) - } - pendingSecond, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), pendingReq) - if err != nil { - t.Fatalf("get second pending invite importers: %v", err) - } - if pendingSecond.Count != 1 || len(pendingSecond.Importers) != 1 || pendingSecond.Importers[0].UserID != second.ID || !pendingSecond.Importers[0].Requested { - t.Fatalf("second pending importers = %+v, want second pending request", pendingSecond) - } - approved, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{ - Approved: true, - Peer: input, - UserID: &tg.InputUser{UserID: second.ID, AccessHash: second.AccessHash}, - }) - if err != nil { - t.Fatalf("approve chat join request: %v", err) - } - if updates := approved.(*tg.Updates); len(updates.Chats) != 1 || len(updates.Updates) == 0 { - t.Fatalf("approve join request updates = %+v, want channel updates", updates) - } - approvedUpdates := approved.(*tg.Updates) - var pendingCleared *tg.UpdatePendingJoinRequests - for _, update := range approvedUpdates.Updates { - if pending, ok := update.(*tg.UpdatePendingJoinRequests); ok { - pendingCleared = pending - break - } - } - if pendingCleared == nil || pendingCleared.RequestsPending != 0 || len(pendingCleared.RecentRequesters) != 0 { - t.Fatalf("approve pending update = %+v, want cleared pending requests", pendingCleared) - } - if _, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{ - Approved: true, - Peer: input, - UserID: &tg.InputUser{UserID: second.ID, AccessHash: second.AccessHash}, - }); err == nil || !strings.Contains(err.Error(), "HIDE_REQUESTER_MISSING") { - t.Fatalf("approve missing join request err = %v, want HIDE_REQUESTER_MISSING", err) - } -} - -func TestChannelUsernameAndManagementRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"}) - requester, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002302", FirstName: "Requester"}) - channelStore := memory.NewChannelStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: appchannels.NewService(channelStore), - }, zaptest.NewLogger(t), clock.System) - created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ - Title: "Public Team", - About: "public", - Megagroup: true, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channel := created.(*tg.Updates).Chats[0].(*tg.Channel) - input := &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} - sent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "channel management seed", - RandomID: 991, - }) - if err != nil { - t.Fatalf("send seed message: %v", err) - } - msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID - - okUsername, err := r.onChannelsCheckUsername(WithUserID(ctx, owner.ID), &tg.ChannelsCheckUsernameRequest{ - Channel: input, - Username: "public_team", - }) - if err != nil || !okUsername { - t.Fatalf("check username = ok %v err %v, want true", okUsername, err) - } - updated, err := r.onChannelsUpdateUsername(WithUserID(ctx, owner.ID), &tg.ChannelsUpdateUsernameRequest{ - Channel: input, - Username: "public_team", - }) - if err != nil || !updated { - t.Fatalf("update username = %v err %v, want true", updated, err) - } - admined, err := r.onChannelsGetAdminedPublicChannels(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminedPublicChannelsRequest{}) - if err != nil { - t.Fatalf("get admined public channels: %v", err) - } - adminedChats := admined.(*tg.MessagesChats).Chats - if len(adminedChats) != 1 || adminedChats[0].(*tg.Channel).Username != "public_team" { - t.Fatalf("admined chats = %+v, want public channel with username", adminedChats) - } - - signatures, err := r.onChannelsToggleSignatures(WithUserID(ctx, owner.ID), &tg.ChannelsToggleSignaturesRequest{ - Channel: input, - SignaturesEnabled: true, - }) - if err != nil { - t.Fatalf("toggle signatures: %v", err) - } - if signed := signatures.(*tg.Updates).Chats[0].(*tg.Channel); !signed.Signatures { - t.Fatalf("signed channel = %+v, want signatures enabled", signed) - } - if _, err := r.onChannelsToggleSlowMode(WithUserID(ctx, owner.ID), &tg.ChannelsToggleSlowModeRequest{Channel: input, Seconds: 7}); err == nil { - t.Fatalf("toggle slow mode invalid seconds err = nil, want SECONDS_INVALID") - } - slowUpdates, err := r.onChannelsToggleSlowMode(WithUserID(ctx, owner.ID), &tg.ChannelsToggleSlowModeRequest{Channel: input, Seconds: 30}) - if err != nil { - t.Fatalf("toggle slow mode valid: %v", err) - } - if slowChannel := slowUpdates.(*tg.Updates).Chats[0].(*tg.Channel); !slowChannel.SlowmodeEnabled { - t.Fatalf("slow mode channel = %+v, want slowmode enabled", slowChannel) - } - if ok, err := r.onChannelsSetStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetStickersRequest{Channel: input, Stickerset: &tg.InputStickerSetEmpty{}}); err != nil || !ok { - t.Fatalf("set stickers = ok %v err %v, want true", ok, err) - } - if _, err := r.onChannelsSetStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetStickersRequest{ - Channel: input, - Stickerset: &tg.InputStickerSetID{ID: 1, AccessHash: 2}, - }); err == nil || !strings.Contains(err.Error(), "STICKERSET_INVALID") { - t.Fatalf("set stickers non-empty err = %v, want STICKERSET_INVALID", err) - } - if ok, err := r.onChannelsReorderUsernames(WithUserID(ctx, owner.ID), &tg.ChannelsReorderUsernamesRequest{Channel: input, Order: []string{"public_team"}}); err != nil || !ok { - t.Fatalf("reorder usernames = ok %v err %v, want true", ok, err) - } - if _, err := r.onChannelsTogglePreHistoryHidden(WithUserID(ctx, owner.ID), &tg.ChannelsTogglePreHistoryHiddenRequest{Channel: input, Enabled: true}); err != nil { - t.Fatalf("toggle prehistory hidden: %v", err) - } - fullAfterSettings, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) - if err != nil { - t.Fatalf("get full channel after settings: %v", err) - } - fullSettings := fullAfterSettings.FullChat.(*tg.ChannelFull) - if !fullSettings.HiddenPrehistory || fullSettings.SlowmodeSeconds != 30 { - t.Fatalf("full settings = %+v, want hidden prehistory and slowmode=30", fullSettings) - } - joinToSendUpdates, err := r.onChannelsToggleJoinToSend(WithUserID(ctx, owner.ID), &tg.ChannelsToggleJoinToSendRequest{Channel: input, Enabled: true}) - if err != nil { - t.Fatalf("toggle join-to-send: %v", err) - } - if joinToSendChannel := joinToSendUpdates.(*tg.Updates).Chats[0].(*tg.Channel); !joinToSendChannel.GetJoinToSend() { - t.Fatalf("join-to-send channel = %+v, want join_to_send flag", joinToSendChannel) - } - joinRequestUpdates, err := r.onChannelsToggleJoinRequest(WithUserID(ctx, owner.ID), &tg.ChannelsToggleJoinRequestRequest{Channel: input, Enabled: true}) - if err != nil { - t.Fatalf("toggle join request: %v", err) - } - if joinRequestChannel := joinRequestUpdates.(*tg.Updates).Chats[0].(*tg.Channel); !joinRequestChannel.GetJoinRequest() { - t.Fatalf("join-request channel = %+v, want join_request flag", joinRequestChannel) - } - chatsWithJoinFlags, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) - if err != nil { - t.Fatalf("get channels after join settings: %v", err) - } - listedJoinChannel := chatsWithJoinFlags.(*tg.MessagesChats).Chats[0].(*tg.Channel) - if !listedJoinChannel.GetJoinToSend() || !listedJoinChannel.GetJoinRequest() { - t.Fatalf("listed channel join flags = %+v, want both flags", listedJoinChannel) - } - if _, err := r.onChannelsJoinChannel(WithUserID(ctx, requester.ID), input); err == nil || !strings.Contains(err.Error(), "INVITE_REQUEST_SENT") { - t.Fatalf("public join request err = %v, want INVITE_REQUEST_SENT", err) - } - pendingPublic, err := r.onMessagesGetChatInviteImporters(WithUserID(ctx, owner.ID), &tg.MessagesGetChatInviteImportersRequest{ - Requested: true, - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Limit: 10, - }) - if err != nil { - t.Fatalf("get public pending join request: %v", err) - } - if pendingPublic.Count != 1 || len(pendingPublic.Importers) != 1 || pendingPublic.Importers[0].UserID != requester.ID || !pendingPublic.Importers[0].Requested { - t.Fatalf("public pending importers = %+v, want requester pending", pendingPublic) - } - fullWithPublicPending, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) - if err != nil { - t.Fatalf("get full channel with public pending request: %v", err) - } - fullPublicPending := fullWithPublicPending.FullChat.(*tg.ChannelFull) - publicPendingCount, ok := fullPublicPending.GetRequestsPending() - publicRecent, recentOK := fullPublicPending.GetRecentRequesters() - if !ok || publicPendingCount != 1 || !recentOK || len(publicRecent) != 1 || publicRecent[0] != requester.ID { - t.Fatalf("public full pending = count %d ok %v recent %+v ok %v, want requester", publicPendingCount, ok, publicRecent, recentOK) - } - approvedPublic, err := r.onMessagesHideChatJoinRequest(WithUserID(ctx, owner.ID), &tg.MessagesHideChatJoinRequestRequest{ - Approved: true, - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - UserID: &tg.InputUser{UserID: requester.ID, AccessHash: requester.AccessHash}, - }) - if err != nil { - t.Fatalf("approve public join request: %v", err) - } - var publicPendingCleared *tg.UpdatePendingJoinRequests - for _, update := range approvedPublic.(*tg.Updates).Updates { - if pending, ok := update.(*tg.UpdatePendingJoinRequests); ok { - publicPendingCleared = pending - break - } - } - if publicPendingCleared == nil || publicPendingCleared.RequestsPending != 0 || len(publicPendingCleared.RecentRequesters) != 0 { - t.Fatalf("public approve pending update = %+v, want cleared pending requests", publicPendingCleared) - } - colorReq := &tg.ChannelsUpdateColorRequest{Channel: input} - colorReq.SetForProfile(true) - colorReq.SetColor(1) - colorReq.SetBackgroundEmojiID(9001) - colorUpdates, err := r.onChannelsUpdateColor(WithUserID(ctx, owner.ID), colorReq) - if err != nil { - t.Fatalf("update color: %v", err) - } - colorChannel := colorUpdates.(*tg.Updates).Chats[0].(*tg.Channel) - profileColor, ok := colorChannel.GetProfileColor() - if !ok { - t.Fatalf("update color channel = %+v, want profile color", colorChannel) - } - if peerColor := profileColor.(*tg.PeerColor); peerColor.Color != 1 || peerColor.BackgroundEmojiID != 9001 { - t.Fatalf("profile color = %+v, want color/background", peerColor) - } - status := &tg.EmojiStatus{DocumentID: 9101} - status.SetUntil(1700000100) - statusUpdates, err := r.onChannelsUpdateEmojiStatus(WithUserID(ctx, owner.ID), &tg.ChannelsUpdateEmojiStatusRequest{Channel: input, EmojiStatus: status}) - if err != nil { - t.Fatalf("update emoji status: %v", err) - } - statusChannel := statusUpdates.(*tg.Updates).Chats[0].(*tg.Channel) - emojiStatus, ok := statusChannel.GetEmojiStatus() - if !ok { - t.Fatalf("update emoji status channel = %+v, want emoji status", statusChannel) - } - if got := emojiStatus.(*tg.EmojiStatus); got.DocumentID != 9101 || got.Until != 1700000100 { - t.Fatalf("emoji status = %+v, want document/until", got) - } - chatsWithAppearance, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) - if err != nil { - t.Fatalf("get channels after appearance settings: %v", err) - } - persistedAppearance := chatsWithAppearance.(*tg.MessagesChats).Chats[0].(*tg.Channel) - if _, ok := persistedAppearance.GetProfileColor(); !ok { - t.Fatalf("persisted channel appearance = %+v, want profile color", persistedAppearance) - } - if _, ok := persistedAppearance.GetEmojiStatus(); !ok { - t.Fatalf("persisted channel appearance = %+v, want emoji status", persistedAppearance) - } - link, err := r.onChannelsExportMessageLink(WithUserID(ctx, owner.ID), &tg.ChannelsExportMessageLinkRequest{Channel: input, ID: msgID}) - if err != nil { - t.Fatalf("export message link: %v", err) - } - if !strings.Contains(link.Link, "public_team") || !strings.Contains(link.Link, strconv.Itoa(msgID)) { - t.Fatalf("exported link = %+v, want username/message id", link) - } - replyToSeed := &tg.InputReplyToMessage{ReplyToMsgID: msgID} - replyReq := &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, - Message: "thread reply", - RandomID: 992, - } - replyReq.SetReplyTo(replyToSeed) - replySent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), replyReq) - if err != nil { - t.Fatalf("send reply for export link: %v", err) - } - replyMsgID := replySent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID - threadLink, err := r.onChannelsExportMessageLink(WithUserID(ctx, owner.ID), &tg.ChannelsExportMessageLinkRequest{Thread: true, Channel: input, ID: replyMsgID}) - if err != nil { - t.Fatalf("export thread message link: %v", err) - } - if !strings.Contains(threadLink.Link, strconv.Itoa(replyMsgID)) || !strings.Contains(threadLink.Link, "?thread="+strconv.Itoa(msgID)) { - t.Fatalf("thread exported link = %+v, want reply id and thread root %d", threadLink, msgID) - } - if _, err := r.onChannelsExportMessageLink(WithUserID(ctx, owner.ID), &tg.ChannelsExportMessageLinkRequest{Channel: input, ID: replyMsgID + 10000}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("export missing message link err = %v, want MESSAGE_ID_INVALID", err) - } - if ok, err := r.onChannelsReadMessageContents(WithUserID(ctx, owner.ID), &tg.ChannelsReadMessageContentsRequest{Channel: input, ID: []int{msgID}}); err != nil || !ok { - t.Fatalf("read message contents = ok %v err %v, want true", ok, err) - } - author, err := r.onChannelsGetMessageAuthor(WithUserID(ctx, owner.ID), &tg.ChannelsGetMessageAuthorRequest{Channel: input, ID: msgID}) - if err != nil { - t.Fatalf("get message author: %v", err) - } - if author.(*tg.User).ID != owner.ID { - t.Fatalf("message author = %+v, want owner", author) - } - if _, err := r.onChannelsGetMessageAuthor(WithUserID(ctx, owner.ID), &tg.ChannelsGetMessageAuthorRequest{Channel: input, ID: msgID + 10000}); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("get missing message author err = %v, want MESSAGE_ID_INVALID", err) - } - if ok, err := r.onChannelsReportSpam(WithUserID(ctx, owner.ID), &tg.ChannelsReportSpamRequest{ - Channel: input, - Participant: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, - ID: []int{msgID}, - }); err != nil || !ok { - t.Fatalf("report spam = ok %v err %v, want true", ok, err) - } - if _, err := r.onChannelsGetLeftChannels(WithUserID(ctx, owner.ID), 0); err != nil { - t.Fatalf("get left channels: %v", err) - } - if _, err := r.onChannelsGetInactiveChannels(WithUserID(ctx, owner.ID)); err != nil { - t.Fatalf("get inactive channels: %v", err) - } - if _, err := r.onChannelsGetGroupsForDiscussion(WithUserID(ctx, owner.ID)); err != nil { - t.Fatalf("get groups for discussion: %v", err) - } - if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, owner.ID), &tg.ChannelsSetDiscussionGroupRequest{Broadcast: input, Group: &tg.InputChannelEmpty{}}); err == nil || !strings.Contains(err.Error(), "BROADCAST_ID_INVALID") { - t.Fatalf("set discussion group on megagroup err = %v, want BROADCAST_ID_INVALID", err) - } - if ok, err := r.onChannelsEditLocation(WithUserID(ctx, owner.ID), &tg.ChannelsEditLocationRequest{Channel: input}); err != nil || !ok { - t.Fatalf("edit location = ok %v err %v, want true", ok, err) - } - if _, err := r.onChannelsConvertToGigagroup(WithUserID(ctx, owner.ID), input); err != nil { - t.Fatalf("convert to gigagroup: %v", err) - } - if affected, err := r.onChannelsDeleteParticipantHistory(WithUserID(ctx, owner.ID), &tg.ChannelsDeleteParticipantHistoryRequest{Channel: input, Participant: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}}); err != nil || affected.PtsCount != 3 || affected.Pts == 0 || affected.Offset != 0 { - t.Fatalf("delete participant history = %+v err %v, want one bounded delete update for owner service, text and reply messages", affected, err) - } - if updates, err := r.onChannelsToggleParticipantsHidden(WithUserID(ctx, owner.ID), &tg.ChannelsToggleParticipantsHiddenRequest{Channel: input, Enabled: true}); err != nil { - t.Fatalf("toggle participants hidden: %v", err) - } else if len(updates.(*tg.Updates).Chats) != 1 { - t.Fatalf("toggle participants hidden updates = %+v, want channel chat", updates) - } - fullHidden, err := r.onChannelsGetFullChannel(WithUserID(ctx, requester.ID), input) - if err != nil { - t.Fatalf("requester get full after participants hidden: %v", err) - } - fullChannel := fullHidden.FullChat.(*tg.ChannelFull) - if hidden := fullChannel.GetParticipantsHidden(); !hidden || fullChannel.CanViewParticipants { - t.Fatalf("requester full channel = %+v, want participants_hidden and can_view_participants=false", fullChannel) - } - hiddenMembers, err := r.onChannelsGetParticipants(WithUserID(ctx, requester.ID), &tg.ChannelsGetParticipantsRequest{ - Channel: input, - Filter: &tg.ChannelParticipantsRecent{}, - Limit: 10, - }) - if err != nil { - t.Fatalf("requester get participants hidden: %v", err) - } - if page := hiddenMembers.(*tg.ChannelsChannelParticipants); len(page.Participants) != 0 || page.Count == 0 { - t.Fatalf("hidden participants page = %+v, want empty page with aggregate count", page) - } - viewAsMessagesUpdates, err := r.onChannelsToggleViewForumAsMessages(WithUserID(ctx, owner.ID), &tg.ChannelsToggleViewForumAsMessagesRequest{Channel: input, Enabled: true}) - if err != nil { - t.Fatalf("toggle forum as messages: %v", err) - } - viewAsMessagesUpdate, ok := viewAsMessagesUpdates.(*tg.Updates).Updates[0].(*tg.UpdateChannelViewForumAsMessages) - if !ok || viewAsMessagesUpdate.ChannelID != channel.ID || !viewAsMessagesUpdate.Enabled { - t.Fatalf("toggle forum as messages updates = %+v, want updateChannelViewForumAsMessages", viewAsMessagesUpdates) - } - fullWithViewAsMessages, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) - if err != nil { - t.Fatalf("full channel after view as messages: %v", err) - } - if full := fullWithViewAsMessages.FullChat.(*tg.ChannelFull); !full.ViewForumAsMessages { - t.Fatalf("full channel view_forum_as_messages = false, want true") - } - forumUpdates, err := r.onChannelsToggleForum(WithUserID(ctx, owner.ID), &tg.ChannelsToggleForumRequest{Channel: input, Enabled: true, Tabs: true}) - if err != nil { - t.Fatalf("toggle forum: %v", err) - } - if len(forumUpdates.(*tg.Updates).Chats) != 1 { - t.Fatalf("toggle forum updates = %+v, want channel chat", forumUpdates) - } - forumChat, ok := forumUpdates.(*tg.Updates).Chats[0].(*tg.Channel) - if !ok || !forumChat.Forum || !forumChat.ForumTabs { - t.Fatalf("toggle forum chat = %+v, want forum+forum_tabs", forumUpdates.(*tg.Updates).Chats[0]) - } - forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} - forumTopics, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ - Peer: forumPeer, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getForumTopics after toggle: %v", err) - } - if forumTopics.Count != 1 || len(forumTopics.Topics) != 1 || len(forumTopics.Chats) != 1 || forumTopics.Pts == 0 { - t.Fatalf("messages.getForumTopics after toggle = %+v, want general topic with channel context", forumTopics) - } - generalTopic, ok := forumTopics.Topics[0].(*tg.ForumTopic) - if !ok || generalTopic.ID != forumGeneralTopicID || generalTopic.Title != "General" { - t.Fatalf("forum topic = %+v, want General topic", forumTopics.Topics[0]) - } - forumTopicsByID, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{ - Peer: forumPeer, - Topics: []int{forumGeneralTopicID}, - }) - if err != nil { - t.Fatalf("messages.getForumTopicsByID after toggle: %v", err) - } - if forumTopicsByID.Count != 1 || len(forumTopicsByID.Topics) != 1 { - t.Fatalf("messages.getForumTopicsByID after toggle = %+v, want General topic", forumTopicsByID) - } - createdForumTopic, err := r.onMessagesCreateForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesCreateForumTopicRequest{ - Peer: forumPeer, - Title: "Ops", - IconColor: domain.DefaultForumTopicIconColor, - RandomID: 17803001, - }) - if err != nil { - t.Fatalf("messages.createForumTopic after toggle: %v", err) - } - var topicID int - for _, update := range createdForumTopic.(*tg.Updates).Updates { - newMsg, ok := update.(*tg.UpdateNewChannelMessage) - if !ok { - continue - } - msg, ok := newMsg.Message.(*tg.MessageService) - if !ok { - continue - } - action, ok := msg.Action.(*tg.MessageActionTopicCreate) - if ok && action.Title == "Ops" { - topicID = msg.ID - break - } - } - if topicID == 0 { - t.Fatalf("messages.createForumTopic updates = %+v, want topic root service message", createdForumTopic) - } - forumTopicsWithCreated, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ - Peer: forumPeer, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getForumTopics with created topic: %v", err) - } - if forumTopicsWithCreated.Count != 2 || len(forumTopicsWithCreated.Topics) != 2 || len(forumTopicsWithCreated.Messages) == 0 { - t.Fatalf("messages.getForumTopics with created topic = %+v, want General + created topic + root message", forumTopicsWithCreated) - } - var foundCreated bool - for _, item := range forumTopicsWithCreated.Topics { - topic, ok := item.(*tg.ForumTopic) - if ok && topic.ID == topicID && topic.Title == "Ops" && topic.TopMessage == topicID { - foundCreated = true - break - } - } - if !foundCreated { - t.Fatalf("messages.getForumTopics topics = %+v, want created topic id %d", forumTopicsWithCreated.Topics, topicID) - } - forumTopicsByID, err = r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{ - Peer: forumPeer, - Topics: []int{forumGeneralTopicID, topicID}, - }) - if err != nil { - t.Fatalf("messages.getForumTopicsByID with created topic: %v", err) - } - if forumTopicsByID.Count != 2 || len(forumTopicsByID.Topics) != 2 || len(forumTopicsByID.Messages) == 0 { - t.Fatalf("messages.getForumTopicsByID with created topic = %+v, want General + created topic", forumTopicsByID) - } - topicReply := &tg.InputReplyToMessage{ReplyToMsgID: 0} - topicReply.SetTopMsgID(topicID) - topicSendReq := &tg.MessagesSendMessageRequest{ - Peer: forumPeer, - Message: "topic body", - RandomID: 17803002, - } - topicSendReq.SetReplyTo(topicReply) - topicSent, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), topicSendReq) - if err != nil { - t.Fatalf("messages.sendMessage topic-only reply: %v", err) - } - var topicMsg *tg.Message - for _, update := range topicSent.(*tg.Updates).Updates { - newMsg, ok := update.(*tg.UpdateNewChannelMessage) - if !ok { - continue - } - if msg, ok := newMsg.Message.(*tg.Message); ok && msg.Message == "topic body" { - topicMsg = msg - break - } - } - if topicMsg == nil { - t.Fatalf("messages.sendMessage topic updates = %+v, want topic message", topicSent) - } - header, ok := topicMsg.ReplyTo.(*tg.MessageReplyHeader) - if !ok || !header.ForumTopic || header.ReplyToMsgID != 0 { - t.Fatalf("topic reply header = %#v, want forum topic without reply_to_msg_id", topicMsg.ReplyTo) - } - if topID, ok := header.GetReplyToTopID(); !ok || topID != topicID { - t.Fatalf("topic reply top id = %d ok %v, want %d", topID, ok, topicID) - } - topicReplies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{ - Peer: forumPeer, - MsgID: topicID, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getReplies forum topic: %v", err) - } - topicReplyPage, ok := topicReplies.(*tg.MessagesChannelMessages) - if !ok || topicReplyPage.Pts == 0 || len(topicReplyPage.Messages) != 1 || len(topicReplyPage.Topics) != 1 { - t.Fatalf("messages.getReplies forum topic = %T %+v, want channelMessages with one topic", topicReplies, topicReplies) - } - if got := topicReplyPage.Messages[0].(*tg.Message); got.ID != topicMsg.ID || got.Message != "topic body" { - t.Fatalf("topic reply message = %#v, want id %d", got, topicMsg.ID) - } - if topic, ok := topicReplyPage.Topics[0].(*tg.ForumTopic); !ok || !topic.Short || topic.ID != topicID || topic.TopMessage != topicMsg.ID { - t.Fatalf("topic reply page topic = %#v, want short topic with updated top message %d", topicReplyPage.Topics[0], topicMsg.ID) - } - forumTopicsAfterMessage, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ - Peer: forumPeer, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getForumTopics after topic message: %v", err) - } - var foundTopicTop bool - for _, item := range forumTopicsAfterMessage.Topics { - topic, ok := item.(*tg.ForumTopic) - if ok && topic.ID == topicID && topic.TopMessage == topicMsg.ID { - foundTopicTop = true - break - } - } - if !foundTopicTop { - t.Fatalf("messages.getForumTopics after topic message topics = %+v, want top message %d", forumTopicsAfterMessage.Topics, topicMsg.ID) - } - forwardToTopicReq := &tg.MessagesForwardMessagesRequest{ - FromPeer: forumPeer, - ToPeer: forumPeer, - ID: []int{topicMsg.ID}, - RandomID: []int64{17803003}, - } - forwardToTopicReq.SetTopMsgID(topicID) - forwardToTopic, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), forwardToTopicReq) - if err != nil { - t.Fatalf("messages.forwardMessages to forum topic: %v", err) - } - var forwardedTopicMsg *tg.Message - for _, update := range forwardToTopic.(*tg.Updates).Updates { - newMsg, ok := update.(*tg.UpdateNewChannelMessage) - if !ok { - continue - } - if msg, ok := newMsg.Message.(*tg.Message); ok && msg.Message == "topic body" && msg.ID != topicMsg.ID { - forwardedTopicMsg = msg - break - } - } - if forwardedTopicMsg == nil { - t.Fatalf("messages.forwardMessages to topic updates = %+v, want forwarded topic message", forwardToTopic) - } - if _, ok := forwardedTopicMsg.GetFwdFrom(); !ok { - t.Fatalf("forwarded topic message = %#v, want fwd header", forwardedTopicMsg) - } - forwardReply, ok := forwardedTopicMsg.ReplyTo.(*tg.MessageReplyHeader) - if !ok || !forwardReply.ForumTopic || forwardReply.ReplyToMsgID != 0 { - t.Fatalf("forward topic reply header = %#v, want forum topic without reply_to_msg_id", forwardedTopicMsg.ReplyTo) - } - if topID, ok := forwardReply.GetReplyToTopID(); !ok || topID != topicID { - t.Fatalf("forward topic reply top id = %d ok %v, want %d", topID, ok, topicID) - } - forumTopicsAfterForward, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ - Peer: forumPeer, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getForumTopics after forward: %v", err) - } - var foundForwardTop bool - for _, item := range forumTopicsAfterForward.Topics { - topic, ok := item.(*tg.ForumTopic) - if ok && topic.ID == topicID && topic.TopMessage == forwardedTopicMsg.ID { - foundForwardTop = true - break - } - } - if !foundForwardTop { - t.Fatalf("messages.getForumTopics after forward topics = %+v, want top message %d", forumTopicsAfterForward.Topics, forwardedTopicMsg.ID) - } - invalidForwardTop := &tg.MessagesForwardMessagesRequest{ - FromPeer: forumPeer, - ToPeer: forumPeer, - ID: []int{topicMsg.ID}, - RandomID: []int64{17803004}, - } - invalidForwardTop.SetTopMsgID(domain.MaxMessageBoxID + 1) - if _, err := r.onMessagesForwardMessages(WithUserID(ctx, owner.ID), invalidForwardTop); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { - t.Fatalf("messages.forwardMessages huge top_msg_id err = %v, want REPLY_MESSAGE_ID_INVALID", err) - } - editTopicReq := &tg.MessagesEditForumTopicRequest{Peer: forumPeer, TopicID: topicID} - editTopicReq.SetTitle("Ops 2") - editTopicReq.SetClosed(true) - editedForumTopic, err := r.onMessagesEditForumTopic(WithUserID(ctx, owner.ID), editTopicReq) - if err != nil { - t.Fatalf("messages.editForumTopic: %v", err) - } - var editedRootID int - for _, update := range editedForumTopic.(*tg.Updates).Updates { - newMsg, ok := update.(*tg.UpdateNewChannelMessage) - if !ok { - continue - } - msg, ok := newMsg.Message.(*tg.MessageService) - if !ok { - continue - } - action, ok := msg.Action.(*tg.MessageActionTopicEdit) - if ok && action.Title == "Ops 2" { - if closed, closedOK := action.GetClosed(); closedOK && closed { - editedRootID = msg.ID - break - } - } - } - if editedRootID == 0 { - t.Fatalf("messages.editForumTopic updates = %+v, want topic edit service message", editedForumTopic) - } - pinnedTopic, err := r.onMessagesUpdatePinnedForumTopic(WithUserID(ctx, owner.ID), &tg.MessagesUpdatePinnedForumTopicRequest{ - Peer: forumPeer, - TopicID: topicID, - Pinned: true, - }) - if err != nil { - t.Fatalf("messages.updatePinnedForumTopic: %v", err) - } - if got := pinnedTopic.(*tg.Updates).Updates; len(got) != 1 { - t.Fatalf("messages.updatePinnedForumTopic updates = %+v, want one update", got) - } else if update, ok := got[0].(*tg.UpdatePinnedForumTopic); !ok || update.TopicID != topicID || !update.GetPinned() { - t.Fatalf("messages.updatePinnedForumTopic update = %+v, want pinned topic", got[0]) - } - reorderedTopics, err := r.onMessagesReorderPinnedForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesReorderPinnedForumTopicsRequest{ - Peer: forumPeer, - Order: []int{topicID}, - Force: true, - }) - if err != nil { - t.Fatalf("messages.reorderPinnedForumTopics: %v", err) - } - if got := reorderedTopics.(*tg.Updates).Updates; len(got) != 1 { - t.Fatalf("messages.reorderPinnedForumTopics updates = %+v, want one update", got) - } else if update, ok := got[0].(*tg.UpdatePinnedForumTopics); !ok || len(update.Order) != 1 || update.Order[0] != topicID { - t.Fatalf("messages.reorderPinnedForumTopics update = %+v, want order", got[0]) - } - deletedTopic, err := r.onMessagesDeleteTopicHistory(WithUserID(ctx, owner.ID), &tg.MessagesDeleteTopicHistoryRequest{ - Peer: forumPeer, - TopMsgID: topicID, - }) - if err != nil { - t.Fatalf("messages.deleteTopicHistory: %v", err) - } - if deletedTopic.Pts == 0 || deletedTopic.PtsCount == 0 || deletedTopic.Offset != 0 { - t.Fatalf("messages.deleteTopicHistory = %+v, want affected history with final offset", deletedTopic) - } - forumTopicsAfterDelete, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ - Peer: forumPeer, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getForumTopics after delete topic: %v", err) - } - if forumTopicsAfterDelete.Count != 1 || len(forumTopicsAfterDelete.Topics) != 1 { - t.Fatalf("messages.getForumTopics after delete topic = %+v, want General only", forumTopicsAfterDelete) - } - antiSpamUpdates, err := r.onChannelsToggleAntiSpam(WithUserID(ctx, owner.ID), &tg.ChannelsToggleAntiSpamRequest{Channel: input, Enabled: true}) - if err != nil { - t.Fatalf("toggle antispam: %v", err) - } - if len(antiSpamUpdates.(*tg.Updates).Chats) != 1 { - t.Fatalf("toggle antispam updates = %+v, want channel chat", antiSpamUpdates) - } - fullWithAntiSpam, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) - if err != nil { - t.Fatalf("full channel after antispam: %v", err) - } - if full := fullWithAntiSpam.FullChat.(*tg.ChannelFull); !full.GetAntispam() { - t.Fatalf("full channel antispam = false, want true") - } - settingsFilter := tg.ChannelAdminLogEventsFilter{} - settingsFilter.SetSettings(true) - antiSpamLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{ - Channel: input, - EventsFilter: settingsFilter, - Limit: 10, - }) - if err != nil { - t.Fatalf("admin log after antispam: %v", err) - } - foundAntiSpamLog := false - foundForumLog := false - for _, event := range antiSpamLog.Events { - if action, ok := event.Action.(*tg.ChannelAdminLogEventActionToggleAntiSpam); ok && action.NewValue { - foundAntiSpamLog = true - } - if action, ok := event.Action.(*tg.ChannelAdminLogEventActionToggleForum); ok && action.NewValue { - foundForumLog = true - } - } - if !foundAntiSpamLog || !foundForumLog { - t.Fatalf("admin log events = %+v, want toggle antispam and toggle forum actions", antiSpamLog.Events) - } - channelUpdateStubs := []struct { - name string - call func() (tg.UpdatesClass, error) - }{ - {"set boosts", func() (tg.UpdatesClass, error) { - return r.onChannelsSetBoostsToUnblockRestrictions(WithUserID(ctx, owner.ID), &tg.ChannelsSetBoostsToUnblockRestrictionsRequest{Channel: input, Boosts: 1}) - }}, - {"restrict sponsored", func() (tg.UpdatesClass, error) { - return r.onChannelsRestrictSponsoredMessages(WithUserID(ctx, owner.ID), &tg.ChannelsRestrictSponsoredMessagesRequest{Channel: input, Restricted: true}) - }}, - {"update paid messages", func() (tg.UpdatesClass, error) { - return r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: input, SendPaidMessagesStars: 7}) - }}, - {"toggle autotranslation", func() (tg.UpdatesClass, error) { - return r.onChannelsToggleAutotranslation(WithUserID(ctx, owner.ID), &tg.ChannelsToggleAutotranslationRequest{Channel: input, Enabled: true}) - }}, - } - for _, item := range channelUpdateStubs { - updates, err := item.call() - if err != nil { - t.Fatalf("%s: %v", item.name, err) - } - if len(updates.(*tg.Updates).Chats) != 1 { - t.Fatalf("%s updates = %+v, want channel chat", item.name, updates) - } - } - fullAfterPaidSettings, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) - if err != nil { - t.Fatalf("full channel after paid/settings toggles: %v", err) - } - fullPaidSettings := fullAfterPaidSettings.FullChat.(*tg.ChannelFull) - if !fullPaidSettings.GetRestrictedSponsored() { - t.Fatalf("full channel restricted_sponsored = false, want true") - } - if stars, ok := fullPaidSettings.GetSendPaidMessagesStars(); !fullPaidSettings.GetPaidMessagesAvailable() || !ok || stars != 7 { - t.Fatalf("full channel paid messages = available %v stars %d ok %v, want available true stars 7", fullPaidSettings.GetPaidMessagesAvailable(), stars, ok) - } - chatsAfterAutotranslation, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) - if err != nil { - t.Fatalf("get channels after autotranslation: %v", err) - } - channelAfterAutotranslation := chatsAfterAutotranslation.(*tg.MessagesChats).Chats[0].(*tg.Channel) - if !channelAfterAutotranslation.GetAutotranslation() { - t.Fatalf("channel autotranslation = false, want true") - } - autoLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{ - Channel: input, - EventsFilter: settingsFilter, - Limit: 10, - }) - if err != nil { - t.Fatalf("admin log after autotranslation: %v", err) - } - foundAutoLog := false - for _, event := range autoLog.Events { - if action, ok := event.Action.(*tg.ChannelAdminLogEventActionToggleAutotranslation); ok && action.NewValue { - foundAutoLog = true - break - } - } - if !foundAutoLog { - t.Fatalf("admin log events = %+v, want toggle autotranslation action", autoLog.Events) - } - if _, err := r.onChannelsSetBoostsToUnblockRestrictions(WithUserID(ctx, owner.ID), &tg.ChannelsSetBoostsToUnblockRestrictionsRequest{Channel: input, Boosts: maxChannelBoostsToUnblockRestrictions + 1}); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("set boosts over max err = %v, want LIMIT_INVALID", err) - } - if _, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: input, SendPaidMessagesStars: -1}); err == nil || !strings.Contains(err.Error(), "STARS_AMOUNT_INVALID") { - t.Fatalf("update paid messages negative supergroup err = %v, want STARS_AMOUNT_INVALID", err) - } - if _, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: input, SendPaidMessagesStars: maxChannelPaidMessageStars + 1}); err == nil || !strings.Contains(err.Error(), "STARS_AMOUNT_INVALID") { - t.Fatalf("update paid messages over max err = %v, want STARS_AMOUNT_INVALID", err) - } - broadcastCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ - Title: "Broadcast Paid", - About: "broadcast direct messages", - Broadcast: true, - }) - if err != nil { - t.Fatalf("create broadcast channel: %v", err) - } - broadcast := broadcastCreated.(*tg.Updates).Chats[0].(*tg.Channel) - broadcastInput := &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash} - if _, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{Channel: broadcastInput, SendPaidMessagesStars: -1}); err != nil { - t.Fatalf("update paid messages broadcast disable: %v", err) - } - if _, err := r.onChannelsUpdatePaidMessagesPrice(WithUserID(ctx, owner.ID), &tg.ChannelsUpdatePaidMessagesPriceRequest{ - BroadcastMessagesAllowed: true, - Channel: broadcastInput, - SendPaidMessagesStars: maxChannelPaidMessageStars, - }); err != nil { - t.Fatalf("update paid messages broadcast max: %v", err) - } - broadcastChats, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{broadcastInput}) - if err != nil { - t.Fatalf("get broadcast after paid messages: %v", err) - } - broadcastAfterPaid := broadcastChats.(*tg.MessagesChats).Chats[0].(*tg.Channel) - if !broadcastAfterPaid.GetBroadcastMessagesAllowed() { - t.Fatalf("broadcast_messages_allowed = false, want true") - } - if stars, ok := broadcastAfterPaid.GetSendPaidMessagesStars(); !ok || stars != maxChannelPaidMessageStars { - t.Fatalf("broadcast send_paid_messages_stars = %d ok %v, want %d true", stars, ok, maxChannelPaidMessageStars) - } - if _, err := r.onChannelsSetStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetStickersRequest{Channel: broadcastInput, Stickerset: &tg.InputStickerSetEmpty{}}); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { - t.Fatalf("set stickers broadcast err = %v, want CHANNEL_INVALID", err) - } - if ok, err := r.onChannelsReportAntiSpamFalsePositive(WithUserID(ctx, owner.ID), &tg.ChannelsReportAntiSpamFalsePositiveRequest{Channel: input, MsgID: msgID}); err != nil || !ok { - t.Fatalf("report antispam false positive = ok %v err %v, want true", ok, err) - } - recommendationsReq := &tg.ChannelsGetChannelRecommendationsRequest{} - recommendationsReq.SetChannel(input) - if _, err := r.onChannelsGetChannelRecommendations(WithUserID(ctx, owner.ID), recommendationsReq); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { - t.Fatalf("megagroup channel recommendations err = %v, want CHANNEL_INVALID", err) - } - if ok, err := r.onChannelsSetEmojiStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetEmojiStickersRequest{Channel: input, Stickerset: &tg.InputStickerSetEmpty{}}); err != nil || !ok { - t.Fatalf("set emoji stickers = ok %v err %v, want true", ok, err) - } - if _, err := r.onChannelsSetEmojiStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetEmojiStickersRequest{ - Channel: input, - Stickerset: &tg.InputStickerSetShortName{ShortName: "custom_emoji"}, - }); err == nil || !strings.Contains(err.Error(), "STICKERSET_INVALID") { - t.Fatalf("set emoji stickers non-empty err = %v, want STICKERSET_INVALID", err) - } - if _, err := r.onChannelsSetEmojiStickers(WithUserID(ctx, owner.ID), &tg.ChannelsSetEmojiStickersRequest{Channel: broadcastInput, Stickerset: &tg.InputStickerSetEmpty{}}); err == nil || !strings.Contains(err.Error(), "CHANNEL_INVALID") { - t.Fatalf("set emoji stickers broadcast err = %v, want CHANNEL_INVALID", err) - } - searchPostsReq := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 10} - searchPostsReq.SetHashtag("ops") - if posts, err := r.onChannelsSearchPosts(WithUserID(ctx, owner.ID), searchPostsReq); err != nil || len(posts.(*tg.MessagesMessages).Messages) != 0 { - t.Fatalf("search posts = %+v err %v, want empty", posts, err) - } - floodReq := &tg.ChannelsCheckSearchPostsFloodRequest{} - floodReq.SetQuery("ops") - if flood, err := r.onChannelsCheckSearchPostsFlood(WithUserID(ctx, owner.ID), floodReq); err != nil || !flood.QueryIsFree { - t.Fatalf("check search posts flood = %+v err %v, want free", flood, err) - } - if ok, err := r.onChannelsSetMainProfileTab(WithUserID(ctx, owner.ID), &tg.ChannelsSetMainProfileTabRequest{Channel: input}); err != nil || !ok { - t.Fatalf("set main profile tab = ok %v err %v, want true", ok, err) - } - if ok, err := r.onChannelsToggleUsername(WithUserID(ctx, owner.ID), &tg.ChannelsToggleUsernameRequest{Channel: input, Username: "public_team", Active: false}); err != nil || !ok { - t.Fatalf("toggle username = ok %v err %v, want true", ok, err) - } - if ok, err := r.onChannelsDeactivateAllUsernames(WithUserID(ctx, owner.ID), input); err != nil || !ok { - t.Fatalf("deactivate usernames = ok %v err %v, want true", ok, err) - } - stillPublic, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) - if err != nil { - t.Fatalf("get channel after deactivate: %v", err) - } - if got := stillPublic.(*tg.MessagesChats).Chats[0].(*tg.Channel); got.Username != "public_team" { - t.Fatalf("channel after fragment username stubs = %+v, want primary username preserved", got) - } - if ok, err := r.onChannelsUpdateUsername(WithUserID(ctx, owner.ID), &tg.ChannelsUpdateUsernameRequest{Channel: input, Username: ""}); err != nil || !ok { - t.Fatalf("clear username = ok %v err %v, want true", ok, err) - } - after, err := r.onChannelsGetChannels(WithUserID(ctx, owner.ID), []tg.InputChannelClass{input}) - if err != nil { - t.Fatalf("get channel after clear: %v", err) - } - if got := after.(*tg.MessagesChats).Chats[0].(*tg.Channel); got.Username != "" || len(got.Usernames) != 0 { - t.Fatalf("channel after clear = %+v, want username cleared", got) - } -} - -func TestChannelsGetLeftChannelsRPCReturnsLeftFlagAndSafePaging(t *testing.T) { - ctx := context.Background() - const ( - ownerID int64 = 1000000901 - memberID int64 = 1000000902 - ) - channelStore := memory.NewChannelStore() - channelService := appchannels.NewService(channelStore) - r := New(Config{}, Deps{Channels: channelService}, zaptest.NewLogger(t), clock.System) - older, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{ - Title: "Older Left", - MemberUserIDs: []int64{memberID}, - Date: 1700000900, - }) - if err != nil { - t.Fatalf("create older megagroup: %v", err) - } - newer, err := channelService.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ - Title: "Newer Left Broadcast", - Broadcast: true, - MemberUserIDs: []int64{memberID}, - Date: 1700000901, - }) - if err != nil { - t.Fatalf("create newer broadcast: %v", err) - } - if _, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{ - Title: "Active Excluded", - MemberUserIDs: []int64{memberID}, - Date: 1700000902, - }); err != nil { - t.Fatalf("create active megagroup: %v", err) - } - if _, err := channelService.LeaveChannel(ctx, memberID, older.Channel.ID, 1700000903); err != nil { - t.Fatalf("leave older channel: %v", err) - } - if _, err := channelService.LeaveChannel(ctx, memberID, newer.Channel.ID, 1700000904); err != nil { - t.Fatalf("leave newer channel: %v", err) - } - - got, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), 0) - if err != nil { - t.Fatalf("get left channels: %v", err) - } - chats, ok := got.(*tg.MessagesChats) - if !ok || len(chats.Chats) != 2 { - t.Fatalf("left channels = %T %+v, want final messages.chats with two chats", got, got) - } - first, ok := chats.Chats[0].(*tg.Channel) - if !ok || first.ID != newer.Channel.ID || !first.Left { - t.Fatalf("first left channel = %+v (%T), want newest with left flag", chats.Chats[0], chats.Chats[0]) - } - second, ok := chats.Chats[1].(*tg.Channel) - if !ok || second.ID != older.Channel.ID || !second.Left { - t.Fatalf("second left channel = %+v (%T), want older with left flag", chats.Chats[1], chats.Chats[1]) - } - - empty, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), 2) - if err != nil { - t.Fatalf("get empty left channels page: %v", err) - } - emptySlice, ok := empty.(*tg.MessagesChatsSlice) - if !ok || emptySlice.Count != 2 || len(emptySlice.Chats) != 0 { - t.Fatalf("empty left page = %T %+v, want empty slice with full count", empty, empty) - } - if _, err := r.onChannelsGetLeftChannels(WithUserID(ctx, memberID), domain.MaxLeftChannelsOffset+1); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("huge offset err = %v, want LIMIT_INVALID", err) - } -} - -func TestChannelsDiscussionGroupRPCPersistsFullChannelLink(t *testing.T) { - ctx := context.Background() - const ownerID int64 = 1000000911 - channelStore := memory.NewChannelStore() - channelService := appchannels.NewService(channelStore) - r := New(Config{}, Deps{Channels: channelService}, zaptest.NewLogger(t), clock.System) - broadcast, err := channelService.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ - Title: "Discussion Broadcast", - Broadcast: true, - Date: 1700000910, - }) - if err != nil { - t.Fatalf("create broadcast: %v", err) - } - group, err := channelService.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{ - Title: "Discussion Group", - Date: 1700000911, - }) - if err != nil { - t.Fatalf("create group: %v", err) - } - inputBroadcast := &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash} - inputGroup := &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash} - - candidates, err := r.onChannelsGetGroupsForDiscussion(WithUserID(ctx, ownerID)) - if err != nil { - t.Fatalf("get groups for discussion: %v", err) - } - candidateChats := candidates.(*tg.MessagesChats).Chats - if len(candidateChats) != 1 || candidateChats[0].(*tg.Channel).ID != group.Channel.ID { - t.Fatalf("discussion candidates = %+v, want the creator megagroup", candidateChats) - } - if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ - Broadcast: inputBroadcast, - Group: inputGroup, - }); err != nil || !ok { - t.Fatalf("set discussion group = ok %v err %v, want true", ok, err) - } - fullBroadcast, err := r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputBroadcast) - if err != nil { - t.Fatalf("get full broadcast: %v", err) - } - linkedID, ok := fullBroadcast.FullChat.(*tg.ChannelFull).GetLinkedChatID() - if !ok || linkedID != group.Channel.ID { - t.Fatalf("broadcast linked_chat_id = %d ok %v, want group %d", linkedID, ok, group.Channel.ID) - } - fullGroup, err := r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputGroup) - if err != nil { - t.Fatalf("get full group: %v", err) - } - groupLinkedID, ok := fullGroup.FullChat.(*tg.ChannelFull).GetLinkedChatID() - if !ok || groupLinkedID != broadcast.Channel.ID { - t.Fatalf("group linked_chat_id = %d ok %v, want broadcast %d", groupLinkedID, ok, broadcast.Channel.ID) - } - gotChannel := fullBroadcast.Chats[0].(*tg.Channel) - if !gotChannel.GetHasLink() { - t.Fatalf("broadcast channel = %+v, want has_link", gotChannel) - } - if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ - Broadcast: &tg.InputChannelEmpty{}, - Group: inputGroup, - }); err != nil || !ok { - t.Fatalf("unlink discussion group from group side = ok %v err %v, want true", ok, err) - } - fullBroadcast, err = r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), inputBroadcast) - if err != nil { - t.Fatalf("get full broadcast after unlink: %v", err) - } - if linkedID, ok := fullBroadcast.FullChat.(*tg.ChannelFull).GetLinkedChatID(); ok || linkedID != 0 { - t.Fatalf("broadcast linked_chat_id after unlink = %d ok %v, want unset", linkedID, ok) - } - if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ - Broadcast: &tg.InputChannelEmpty{}, - Group: inputGroup, - }); err == nil || !strings.Contains(err.Error(), "LINK_NOT_MODIFIED") { - t.Fatalf("repeat unlink err = %v, want LINK_NOT_MODIFIED", err) - } - if _, err := channelService.SetPreHistoryHidden(ctx, ownerID, group.Channel.ID, true); err != nil { - t.Fatalf("hide group prehistory: %v", err) - } - if _, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, ownerID), &tg.ChannelsSetDiscussionGroupRequest{ - Broadcast: inputBroadcast, - Group: inputGroup, - }); err == nil || !strings.Contains(err.Error(), "MEGAGROUP_PREHISTORY_HIDDEN") { - t.Fatalf("hidden group link err = %v, want MEGAGROUP_PREHISTORY_HIDDEN", err) - } -} - -func TestChannelDiscussionRepliesRPCUsesLinkedMegagroup(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91, Phone: "15550002911", FirstName: "Owner"}) - member, _ := userStore.Create(ctx, domain.User{AccessHash: 92, Phone: "15550002912", FirstName: "Member"}) - channelStore := memory.NewChannelStore() - channelService := appchannels.NewService(channelStore) - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: channelService, - }, zaptest.NewLogger(t), clock.System) - broadcast, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ - Title: "Discussion Source", - Broadcast: true, - Date: 1700002911, - }) - if err != nil { - t.Fatalf("create broadcast: %v", err) - } - group, err := channelService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{ - Title: "Discussion Replies", - MemberUserIDs: []int64{member.ID}, - Date: 1700002912, - }) - if err != nil { - t.Fatalf("create group: %v", err) - } - inputBroadcast := &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash} - inputGroup := &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash} - if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, owner.ID), &tg.ChannelsSetDiscussionGroupRequest{ - Broadcast: inputBroadcast, - Group: inputGroup, - }); err != nil || !ok { - t.Fatalf("set discussion group = ok %v err %v, want true", ok, err) - } - - postUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, - Message: "channel post", - RandomID: 2911001, - }) - if err != nil { - t.Fatalf("send broadcast post: %v", err) - } - post := postUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - if !post.Post { - t.Fatalf("broadcast post = %#v, want channel post", post) - } - discussion, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, - MsgID: post.ID, - }) - if err != nil { - t.Fatalf("get discussion message: %v", err) - } - if len(discussion.Messages) != 1 || len(discussion.Chats) != 2 { - t.Fatalf("discussion = %+v, want linked root message with source and group chats", discussion) - } - root, ok := discussion.Messages[0].(*tg.Message) - if !ok { - t.Fatalf("discussion root = %T, want message", discussion.Messages[0]) - } - if peer, ok := root.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != group.Channel.ID { - t.Fatalf("discussion root peer = %#v, want linked group %d", root.PeerID, group.Channel.ID) - } - if from, ok := root.FromID.(*tg.PeerChannel); !ok || from.ChannelID != broadcast.Channel.ID { - t.Fatalf("discussion root from = %#v, want source channel %d", root.FromID, broadcast.Channel.ID) - } - fwd, ok := root.GetFwdFrom() - if !ok { - t.Fatalf("discussion root fwd_from missing") - } - if channelPost, ok := fwd.GetChannelPost(); !ok || channelPost != post.ID { - t.Fatalf("discussion root channel_post = %d ok %v, want %d", channelPost, ok, post.ID) - } - if savedMsgID, ok := fwd.GetSavedFromMsgID(); !ok || savedMsgID != post.ID { - t.Fatalf("discussion root saved_from_msg_id = %d ok %v, want %d", savedMsgID, ok, post.ID) - } - if savedPeer, ok := fwd.GetSavedFromPeer(); !ok { - t.Fatalf("discussion root saved_from_peer missing") - } else if savedChannel, ok := savedPeer.(*tg.PeerChannel); !ok || savedChannel.ChannelID != broadcast.Channel.ID { - t.Fatalf("discussion root saved_from_peer = %#v, want source channel %d", savedPeer, broadcast.Channel.ID) - } - - replyTo := &tg.InputReplyToMessage{ReplyToMsgID: root.ID} - replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), func() *tg.MessagesSendMessageRequest { - req := &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, - Message: "discussion reply", - RandomID: 2911002, - } - req.SetReplyTo(replyTo) - return req - }()) - if err != nil { - t.Fatalf("send discussion reply: %v", err) - } - comment := replyUpdates.(*tg.Updates).Updates[1].(*tg.UpdateNewChannelMessage).Message.(*tg.Message) - replies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{ - Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, - MsgID: post.ID, - Limit: 10, - }) - if err != nil { - t.Fatalf("get replies: %v", err) - } - replyMessages, replyChats, _ := searchMessagesPayload(t, replies) - if len(replyMessages) != 1 || len(replyChats) != 2 { - t.Fatalf("get replies = %T %+v, want one linked group reply with both channel contexts", replies, replies) - } - gotReply := replyMessages[0].(*tg.Message) - if gotReply.ID != comment.ID || gotReply.Message != "discussion reply" { - t.Fatalf("reply message = %#v, want comment id %d", gotReply, comment.ID) - } - if peer, ok := gotReply.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != group.Channel.ID { - t.Fatalf("reply peer = %#v, want linked group %d", gotReply.PeerID, group.Channel.ID) - } - header, ok := gotReply.ReplyTo.(*tg.MessageReplyHeader) - if !ok { - t.Fatalf("reply header = %#v, want messageReplyHeader", gotReply.ReplyTo) - } - topID, topOK := header.GetReplyToTopID() - if header.ReplyToMsgID != root.ID || !topOK || topID != root.ID { - t.Fatalf("reply header = %#v, want msg/top %d", header, root.ID) - } - views, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{ - Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, - ID: []int{post.ID}, - }) - if err != nil { - t.Fatalf("get message views: %v", err) - } - replyInfo, ok := views.Views[0].GetReplies() - if !ok || !replyInfo.Comments || replyInfo.Replies != 1 { - t.Fatalf("message views replies = %+v ok %v, want one comment", replyInfo, ok) - } - if channelID, ok := replyInfo.GetChannelID(); !ok || channelID != group.Channel.ID { - t.Fatalf("message views channel_id = %d ok %v, want %d", channelID, ok, group.Channel.ID) - } - if maxID, ok := replyInfo.GetMaxID(); !ok || maxID != comment.ID { - t.Fatalf("message views max_id = %d ok %v, want %d", maxID, ok, comment.ID) - } - if ok, err := r.onMessagesReadDiscussion(WithUserID(ctx, owner.ID), &tg.MessagesReadDiscussionRequest{ - Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, - MsgID: post.ID, - ReadMaxID: comment.ID, - }); err != nil || !ok { - t.Fatalf("read discussion = ok %v err %v, want true", ok, err) - } - afterRead, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ - Peer: &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, - MsgID: post.ID, - }) - if err != nil { - t.Fatalf("get discussion after read: %v", err) - } - if afterRead.ReadInboxMaxID != comment.ID || afterRead.UnreadCount != 0 { - t.Fatalf("discussion after read = %+v, want read inbox %d and no unread", afterRead, comment.ID) - } -} - -func TestChannelUnreadMentionsRPCUsesMentionState(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 9101, Phone: "15550009101", FirstName: "Owner", Username: "owner_mention"}) - member, _ := userStore.Create(ctx, domain.User{AccessHash: 9102, Phone: "15550009102", FirstName: "Mentioned", Username: "mention_friend"}) - channelStore := memory.NewChannelStore() - channelService := appchannels.NewService(channelStore) - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: channelService, - }, zaptest.NewLogger(t), clock.System) - created, err := channelService.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{ - Title: "Mention RPC", - MemberUserIDs: []int64{member.ID}, - Date: 1700009101, - }) - if err != nil { - t.Fatalf("create megagroup: %v", err) - } - peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} - if _, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ - Peer: peer, - Message: "hello @mention_friend", - RandomID: 9102001, - }); err != nil { - t.Fatalf("send mention: %v", err) - } - mentions, err := r.onMessagesGetUnreadMentions(WithUserID(ctx, member.ID), &tg.MessagesGetUnreadMentionsRequest{ - Peer: peer, - OffsetID: 1, - AddOffset: -10, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getUnreadMentions: %v", err) - } - mentionMessages, _, _ := searchMessagesPayload(t, mentions) - if len(mentionMessages) != 1 { - t.Fatalf("messages.getUnreadMentions = %T %+v, want one unread mention", mentions, mentions) - } - if msg := mentionMessages[0].(*tg.Message); msg.Message != "hello @mention_friend" { - t.Fatalf("mention message = %#v, want sent mention", msg) - } - read, err := r.onMessagesReadMentions(WithUserID(ctx, member.ID), &tg.MessagesReadMentionsRequest{Peer: peer}) - if err != nil { - t.Fatalf("messages.readMentions: %v", err) - } - if read.Pts <= 0 || read.PtsCount != 0 || read.Offset != 0 { - t.Fatalf("messages.readMentions = %+v, want current channel pts and no offset", read) - } - mentions, err = r.onMessagesGetUnreadMentions(WithUserID(ctx, member.ID), &tg.MessagesGetUnreadMentionsRequest{ - Peer: peer, - Limit: 10, - }) - if err != nil { - t.Fatalf("messages.getUnreadMentions after read: %v", err) - } - mentionMessages, _, _ = searchMessagesPayload(t, mentions) - if got := len(mentionMessages); got != 0 { - t.Fatalf("unread mentions after read = %d, want 0", got) - } -} - -func TestChannelsGetChannelsRejectsHugeVector(t *testing.T) { - r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{}, zaptest.NewLogger(t), clock.System) - ids := make([]tg.InputChannelClass, maxGetMessagesIDs+1) - for i := range ids { - ids[i] = &tg.InputChannel{ChannelID: int64(i + 1)} - } - if _, err := r.onChannelsGetChannels(context.Background(), ids); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("channels.getChannels huge vector err = %v, want LIMIT_INVALID", err) - } -} - -func TestChannelsSearchPostsReturnsPublicPostsWithSeekPaging(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91001, Phone: "15550091001", FirstName: "Owner"}) - viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 91002, Phone: "15550091002", FirstName: "Viewer"}) - channelStore := memory.NewChannelStore() - channelService := appchannels.NewService(channelStore) - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: channelService, - }, zaptest.NewLogger(t), clock.System) - public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ - Title: "Public Search", - Broadcast: true, - Date: 1700010000, - }) - if err != nil { - t.Fatalf("create public channel: %v", err) - } - if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ - UserID: owner.ID, - ChannelID: public.Channel.ID, - Username: "public_search_posts", - }); err != nil { - t.Fatalf("publish channel username: %v", err) - } - private, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ - Title: "Private Search", - Broadcast: true, - Date: 1700010001, - }) - if err != nil { - t.Fatalf("create private channel: %v", err) - } - if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ - ChannelID: public.Channel.ID, - RandomID: 101, - Message: "launch alpha #ops", - Date: 1700010010, - }); err != nil { - t.Fatalf("send public first: %v", err) - } - if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ - ChannelID: public.Channel.ID, - RandomID: 102, - Message: "launch beta #ops", - Date: 1700010020, - }); err != nil { - t.Fatalf("send public second: %v", err) - } - if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ - ChannelID: private.Channel.ID, - RandomID: 103, - Message: "launch private #ops", - Date: 1700010030, - }); err != nil { - t.Fatalf("send private: %v", err) - } - - req := &tg.ChannelsSearchPostsRequest{ - OffsetPeer: &tg.InputPeerEmpty{}, - Limit: 1, - } - req.SetQuery("launch") - got, err := r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), req) - if err != nil { - t.Fatalf("channels.searchPosts first page: %v", err) - } - slice, ok := got.(*tg.MessagesMessagesSlice) - if !ok { - t.Fatalf("first page = %T %+v, want messagesSlice", got, got) - } - if slice.Count <= len(slice.Messages) { - t.Fatalf("first page count = %d messages=%d, want more page", slice.Count, len(slice.Messages)) - } - if nextRate, ok := slice.GetNextRate(); !ok || nextRate != 1700010020 { - t.Fatalf("first page next_rate = %d ok %v, want newest message date", nextRate, ok) - } - if flood, ok := slice.GetSearchFlood(); !ok || !flood.QueryIsFree { - t.Fatalf("first page search_flood = %+v ok %v, want free flood state", flood, ok) - } - messages, chats, users := searchMessagesPayload(t, got) - if len(messages) != 1 || len(chats) != 1 || len(users) != 1 { - t.Fatalf("first page payload messages=%d chats=%d users=%d, want 1/1/1", len(messages), len(chats), len(users)) - } - first := messages[0].(*tg.Message) - if first.Message != "launch beta #ops" { - t.Fatalf("first result message = %q, want newest public hit", first.Message) - } - if peer, ok := first.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != public.Channel.ID { - t.Fatalf("first result peer = %#v, want public channel %d", first.PeerID, public.Channel.ID) - } - - page2 := &tg.ChannelsSearchPostsRequest{ - OffsetRate: slice.NextRate, - OffsetPeer: &tg.InputPeerChannel{ - ChannelID: public.Channel.ID, - AccessHash: public.Channel.AccessHash, - }, - OffsetID: first.ID, - Limit: 10, - } - page2.SetQuery("launch") - got, err = r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), page2) - if err != nil { - t.Fatalf("channels.searchPosts second page: %v", err) - } - messages, chats, _ = searchMessagesPayload(t, got) - if len(messages) != 1 || len(chats) != 1 { - t.Fatalf("second page payload messages=%d chats=%d, want only older public hit", len(messages), len(chats)) - } - if msg := messages[0].(*tg.Message); msg.Message != "launch alpha #ops" { - t.Fatalf("second result message = %q, want older public hit", msg.Message) - } - - hashtagReq := &tg.ChannelsSearchPostsRequest{ - OffsetPeer: &tg.InputPeerEmpty{}, - Limit: 10, - } - hashtagReq.SetHashtag("ops") - got, err = r.onChannelsSearchPosts(WithUserID(ctx, viewer.ID), hashtagReq) - if err != nil { - t.Fatalf("channels.searchPosts hashtag: %v", err) - } - messages, _, _ = searchMessagesPayload(t, got) - if len(messages) != 2 { - t.Fatalf("hashtag results = %d, want two public hits only", len(messages)) - } - for _, item := range messages { - if strings.Contains(item.(*tg.Message).Message, "private") { - t.Fatalf("hashtag leaked private message: %#v", item) - } - } -} - -func TestPublicChannelPreviewRPCsAllowNonMember(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 92001, Phone: "15550092001", FirstName: "Owner"}) - viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 92002, Phone: "15550092002", FirstName: "Viewer"}) - channelStore := memory.NewChannelStore() - channelService := appchannels.NewService(channelStore) - dialogService := appdialogs.NewService(memory.NewDialogStore(), channelStore) - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: channelService, - Dialogs: dialogService, - }, zaptest.NewLogger(t), clock.System) - public, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ - Title: "Public Preview RPC", - Broadcast: true, - Date: 1700010100, - }) - if err != nil { - t.Fatalf("create public channel: %v", err) - } - if _, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ - UserID: owner.ID, - ChannelID: public.Channel.ID, - Username: "public_preview_rpc", - }); err != nil { - t.Fatalf("publish channel username: %v", err) - } - sent, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ - ChannelID: public.Channel.ID, - RandomID: 201, - Message: "public preview rpc post", - Date: 1700010110, - }) - if err != nil { - t.Fatalf("send public post: %v", err) - } - input := &tg.InputChannel{ChannelID: public.Channel.ID, AccessHash: public.Channel.AccessHash} - peer := &tg.InputPeerChannel{ChannelID: public.Channel.ID, AccessHash: public.Channel.AccessHash} - - full, err := r.onChannelsGetFullChannel(WithUserID(ctx, viewer.ID), input) - if err != nil { - t.Fatalf("non-member getFullChannel public preview: %v", err) - } - if len(full.Chats) != 1 { - t.Fatalf("full chats = %d, want one channel", len(full.Chats)) - } - chat, ok := full.Chats[0].(*tg.Channel) - if !ok || !chat.Left || chat.ID != public.Channel.ID { - t.Fatalf("full channel chat = %T %+v, want left public channel", full.Chats[0], full.Chats[0]) - } - channelFull, ok := full.FullChat.(*tg.ChannelFull) - if !ok || channelFull.ID != public.Channel.ID || channelFull.UnreadCount != 0 { - t.Fatalf("full chat = %T %+v, want channel full without unread", full.FullChat, full.FullChat) - } - - sendAs, err := r.onChannelsGetSendAs(WithUserID(ctx, viewer.ID), &tg.ChannelsGetSendAsRequest{Peer: peer}) - if err != nil { - t.Fatalf("non-member getSendAs public preview: %v", err) - } - if len(sendAs.Peers) != 1 { - t.Fatalf("sendAs peers = %+v, want only current user peer", sendAs.Peers) - } - if len(sendAs.Chats) != 1 { - t.Fatalf("sendAs chats = %d, want public channel chat", len(sendAs.Chats)) - } - - historyReq := &tg.MessagesGetHistoryRequest{Peer: peer, Limit: 10} - var in bin.Buffer - if err := historyReq.Encode(&in); err != nil { - t.Fatalf("encode getHistory: %v", err) - } - enc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch getHistory public preview: %v", err) - } - box, ok := enc.(*tg.MessagesMessagesBox) - if !ok { - t.Fatalf("getHistory response = %T, want boxed messages", enc) - } - history, ok := box.Messages.(*tg.MessagesChannelMessages) - if !ok { - t.Fatalf("boxed getHistory = %T, want channel messages", box.Messages) - } - foundPost := false - for _, item := range history.Messages { - if msg, ok := item.(*tg.Message); ok && msg.Message == "public preview rpc post" { - foundPost = true - } - } - if !foundPost { - t.Fatalf("history messages = %+v, want public preview post", history.Messages) - } - if len(history.Chats) != 1 { - t.Fatalf("history chats = %d, want public channel chat", len(history.Chats)) - } - historyChat, ok := history.Chats[0].(*tg.Channel) - if !ok || !historyChat.Left || historyChat.ID != public.Channel.ID { - t.Fatalf("history chat = %T %+v, want left public channel", history.Chats[0], history.Chats[0]) - } - - diff, err := r.onUpdatesGetChannelDifference(WithUserID(ctx, viewer.ID), &tg.UpdatesGetChannelDifferenceRequest{ - Channel: input, - Pts: public.Event.Pts, - Limit: 10, - }) - if err != nil { - t.Fatalf("non-member getChannelDifference public preview: %v", err) - } - fullDiff, ok := diff.(*tg.UpdatesChannelDifference) - if !ok || !fullDiff.Final || fullDiff.Pts != sent.Event.Pts || len(fullDiff.NewMessages) != 1 { - t.Fatalf("channel difference = %T %+v, want one public preview message at current pts", diff, diff) - } - diffMsg, ok := fullDiff.NewMessages[0].(*tg.Message) - if !ok || diffMsg.Message != "public preview rpc post" { - t.Fatalf("channel difference message = %T %+v, want public preview rpc post", fullDiff.NewMessages[0], fullDiff.NewMessages[0]) - } - if len(fullDiff.Chats) != 1 { - t.Fatalf("channel difference chats = %d, want public channel chat", len(fullDiff.Chats)) - } - diffChat, ok := fullDiff.Chats[0].(*tg.Channel) - if !ok || !diffChat.Left || diffChat.ID != public.Channel.ID { - t.Fatalf("channel difference chat = %T %+v, want left public channel", fullDiff.Chats[0], fullDiff.Chats[0]) - } - - domainPeers, err := r.dialogPeersFromInput(WithUserID(ctx, viewer.ID), viewer.ID, []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: peer}}) - if err != nil { - t.Fatalf("dialog peer conversion public preview: %v", err) - } - if len(domainPeers) != 1 || domainPeers[0].Type != domain.PeerTypeChannel || domainPeers[0].ID != public.Channel.ID { - t.Fatalf("domain peers = %+v, want public channel peer", domainPeers) - } - directPeerDialogs, err := dialogService.GetPeerDialogs(ctx, viewer.ID, domainPeers) - if err != nil { - t.Fatalf("dialog service public preview: %v", err) - } - if len(directPeerDialogs.Dialogs) != 1 || len(directPeerDialogs.ChannelMessages) != 1 || len(directPeerDialogs.Channels) != 1 { - t.Fatalf("direct peer dialogs = %+v, want one dialog/message/channel", directPeerDialogs) - } - - peerDialogsReq := &tg.MessagesGetPeerDialogsRequest{ - Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{Peer: peer}}, - } - var peerDialogsIn bin.Buffer - if err := peerDialogsReq.Encode(&peerDialogsIn); err != nil { - t.Fatalf("encode getPeerDialogs: %v", err) - } - peerDialogsEnc, err := r.Dispatch(WithUserID(ctx, viewer.ID), [8]byte{}, 0, &peerDialogsIn) - if err != nil { - t.Fatalf("dispatch getPeerDialogs public preview: %v", err) - } - peerDialogs, ok := peerDialogsEnc.(*tg.MessagesPeerDialogs) - if !ok { - t.Fatalf("getPeerDialogs response = %T, want peer dialogs", peerDialogsEnc) - } - if len(peerDialogs.Dialogs) != 1 || len(peerDialogs.Messages) != 1 || len(peerDialogs.Chats) != 1 { - t.Fatalf("peer dialogs = %+v, want one dialog/message/channel", peerDialogs) - } - tgDialog, ok := peerDialogs.Dialogs[0].(*tg.Dialog) - if !ok || tgDialog.TopMessage <= 0 || tgDialog.UnreadCount != 0 { - t.Fatalf("peer dialog = %T %+v, want read-only public preview dialog", peerDialogs.Dialogs[0], peerDialogs.Dialogs[0]) - } - tgMessage, ok := peerDialogs.Messages[0].(*tg.Message) - if !ok || tgMessage.Message != "public preview rpc post" { - t.Fatalf("peer dialog message = %T %+v, want public preview rpc post", peerDialogs.Messages[0], peerDialogs.Messages[0]) - } - peerDialogChat, ok := peerDialogs.Chats[0].(*tg.Channel) - if !ok || !peerDialogChat.Left || peerDialogChat.ID != public.Channel.ID { - t.Fatalf("peer dialog chat = %T %+v, want left public channel", peerDialogs.Chats[0], peerDialogs.Chats[0]) - } -} - -func TestMessagesSearchGlobalReturnsJoinedChannelMessages(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, _ := userStore.Create(ctx, domain.User{AccessHash: 91101, Phone: "15550091101", FirstName: "Owner"}) - viewer, _ := userStore.Create(ctx, domain.User{AccessHash: 91102, Phone: "15550091102", FirstName: "Viewer"}) - channelStore := memory.NewChannelStore() - channelService := appchannels.NewService(channelStore) - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: channelService, - }, zaptest.NewLogger(t), clock.System) - joined, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ - Title: "Joined Broadcast", - Broadcast: true, - Date: 1700020000, - }) - if err != nil { - t.Fatalf("create joined channel: %v", err) - } - if _, err := channelService.InviteToChannel(ctx, owner.ID, joined.Channel.ID, []int64{viewer.ID}, 1700020001); err != nil { - t.Fatalf("invite viewer: %v", err) - } - hidden, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ - Title: "Hidden Broadcast", - Broadcast: true, - Date: 1700020002, - }) - if err != nil { - t.Fatalf("create hidden channel: %v", err) - } - for i, body := range []string{"global needle older", "global needle newer"} { - if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ - ChannelID: joined.Channel.ID, - RandomID: int64(201 + i), - Message: body, - Date: 1700020010 + i*10, - }); err != nil { - t.Fatalf("send joined %d: %v", i, err) - } - } - if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ - ChannelID: hidden.Channel.ID, - RandomID: 301, - Message: "global needle hidden", - Date: 1700020030, - }); err != nil { - t.Fatalf("send hidden: %v", err) - } - - req := &tg.MessagesSearchGlobalRequest{ - BroadcastsOnly: true, - Q: "global needle", - Filter: &tg.InputMessagesFilterEmpty{}, - OffsetPeer: &tg.InputPeerEmpty{}, - Limit: 1, - } - got, err := r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), req) - if err != nil { - t.Fatalf("messages.searchGlobal first page: %v", err) - } - slice, ok := got.(*tg.MessagesMessagesSlice) - if !ok { - t.Fatalf("first page = %T %+v, want messagesSlice", got, got) - } - if nextRate, ok := slice.GetNextRate(); !ok || nextRate != 1700020020 { - t.Fatalf("first page next_rate = %d ok %v, want newest date", nextRate, ok) - } - messages, chats, users := searchMessagesPayload(t, got) - if len(messages) != 1 || len(chats) != 1 || len(users) != 1 { - t.Fatalf("first payload messages=%d chats=%d users=%d, want 1/1/1", len(messages), len(chats), len(users)) - } - first := messages[0].(*tg.Message) - if first.Message != "global needle newer" { - t.Fatalf("first message = %q, want newest joined channel hit", first.Message) - } - if peer, ok := first.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != joined.Channel.ID { - t.Fatalf("first peer = %#v, want joined channel %d", first.PeerID, joined.Channel.ID) - } - - page2 := &tg.MessagesSearchGlobalRequest{ - BroadcastsOnly: true, - Q: "global needle", - Filter: &tg.InputMessagesFilterEmpty{}, - OffsetRate: slice.NextRate, - OffsetPeer: &tg.InputPeerChannel{ - ChannelID: joined.Channel.ID, - AccessHash: joined.Channel.AccessHash, - }, - OffsetID: first.ID, - Limit: 10, - } - got, err = r.onMessagesSearchGlobal(WithUserID(ctx, viewer.ID), page2) - if err != nil { - t.Fatalf("messages.searchGlobal second page: %v", err) - } - messages, chats, _ = searchMessagesPayload(t, got) - if len(messages) != 1 || len(chats) != 1 { - t.Fatalf("second payload messages=%d chats=%d, want older joined hit only", len(messages), len(chats)) - } - if msg := messages[0].(*tg.Message); msg.Message != "global needle older" { - t.Fatalf("second message = %q, want older joined hit", msg.Message) - } -} - -func TestChannelsSearchPostsValidatesStubBounds(t *testing.T) { - const userID = int64(1000000001) - r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) - - valid := &tg.ChannelsSearchPostsRequest{ - OffsetPeer: &tg.InputPeerEmpty{}, - Limit: 20, - } - valid.SetQuery("launch") - got, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), valid) - if err != nil { - t.Fatalf("channels.searchPosts valid stub: %v", err) - } - if page, ok := got.(*tg.MessagesMessages); !ok || len(page.Messages) != 0 || len(page.Chats) != 0 || len(page.Users) != 0 { - t.Fatalf("channels.searchPosts = %T %+v, want empty messages", got, got) - } - - both := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1} - both.SetQuery("launch") - both.SetHashtag("launch") - if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), both); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") { - t.Fatalf("channels.searchPosts query+hashtag err = %v, want SEARCH_QUERY_EMPTY", err) - } - if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1}); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") { - t.Fatalf("channels.searchPosts empty query err = %v, want SEARCH_QUERY_EMPTY", err) - } - - huge := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1} - huge.SetQuery(strings.Repeat("x", maxChannelSearchPostsQuery+1)) - if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), huge); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("channels.searchPosts huge query err = %v, want LIMIT_INVALID", err) - } - - badOffset := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1, OffsetID: domain.MaxMessageBoxID + 1} - badOffset.SetHashtag("launch") - if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badOffset); err == nil || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") { - t.Fatalf("channels.searchPosts huge offset_id err = %v, want MESSAGE_ID_INVALID", err) - } - - badStars := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerEmpty{}, Limit: 1, AllowPaidStars: -1} - badStars.SetQuery("launch") - if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badStars); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("channels.searchPosts negative stars err = %v, want LIMIT_INVALID", err) - } - - badPeer := &tg.ChannelsSearchPostsRequest{OffsetPeer: &tg.InputPeerUser{UserID: 42}, Limit: 1} - badPeer.SetQuery("launch") - if _, err := r.onChannelsSearchPosts(WithUserID(context.Background(), userID), badPeer); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") { - t.Fatalf("channels.searchPosts user offset peer err = %v, want PEER_ID_INVALID", err) - } - - floodReq := &tg.ChannelsCheckSearchPostsFloodRequest{} - floodReq.SetQuery("launch") - flood, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), floodReq) - if err != nil { - t.Fatalf("channels.checkSearchPostsFlood: %v", err) - } - if !flood.QueryIsFree || flood.Remains <= 0 || flood.TotalDaily <= 0 { - t.Fatalf("channels.checkSearchPostsFlood = %+v, want free quota", flood) - } - - if _, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), &tg.ChannelsCheckSearchPostsFloodRequest{}); err == nil || !strings.Contains(err.Error(), "SEARCH_QUERY_EMPTY") { - t.Fatalf("channels.checkSearchPostsFlood empty query err = %v, want SEARCH_QUERY_EMPTY", err) - } - floodHuge := &tg.ChannelsCheckSearchPostsFloodRequest{} - floodHuge.SetQuery(strings.Repeat("x", maxChannelSearchPostsQuery+1)) - if _, err := r.onChannelsCheckSearchPostsFlood(WithUserID(context.Background(), userID), floodHuge); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { - t.Fatalf("channels.checkSearchPostsFlood huge query err = %v, want LIMIT_INVALID", err) - } -} - -func TestMessagesGetPeerDialogsReturnsRequestedDialogsAndState(t *testing.T) { - msg := domain.Message{ - ID: 3, - OwnerUserID: 1000000001, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, - Date: 1700000100, - Body: "Login code: 12345", - } - dialogs := &captureDialogs{peerList: domain.DialogList{ - Dialogs: []domain.Dialog{{ - Peer: msg.Peer, - TopMessage: msg.ID, - TopMessageDate: msg.Date, - UnreadCount: 1, - }}, - Messages: []domain.Message{msg}, - Users: []domain.User{domain.OfficialSystemUser()}, - }} - updates := &captureUpdates{state: domain.UpdateState{Pts: 8, Date: 1700000200, Seq: 4}} - r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesGetPeerDialogsRequest{ - Peers: []tg.InputDialogPeerClass{&tg.InputDialogPeer{ - Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, - }}, - } - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - got, ok := enc.(*tg.MessagesPeerDialogs) - if !ok { - t.Fatalf("response = %T, want *tg.MessagesPeerDialogs", enc) - } - if len(dialogs.peers) != 1 || dialogs.peers[0].ID != domain.OfficialSystemUserID { - t.Fatalf("requested peers = %+v, want official peer", dialogs.peers) - } - if got.State.Pts != 8 || got.State.Seq != 4 { - t.Fatalf("state = %+v, want updates state", got.State) - } - if len(got.Dialogs) != 1 || len(got.Messages) != 1 || len(got.Users) != 1 { - t.Fatalf("peer dialogs = %+v, want one dialog/message/user", got) - } -} - -func TestPushLoginMessageSendsUpdateNewMessage(t *testing.T) { - sessions := &captureSessions{} - r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System) - msg := domain.Message{ - ID: 99, - OwnerUserID: 1000000001, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, - Date: 1700000100, - Body: "Login code: 12345", - Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 11}}, - } - event := domain.UpdateEvent{Type: domain.UpdateEventNewMessage, Pts: 4, PtsCount: 1, Date: msg.Date, Message: msg} - state := domain.UpdateState{Pts: 4, Date: 1700000100, Seq: 2} - - r.pushLoginMessage(context.Background(), [8]byte{}, 55, event, state) - - gotSession := sessions.snapshot() - if gotSession.sessionID != 55 || gotSession.messageType != proto.MessageFromServer { - t.Fatalf("push target = session %d type %v, want session 55 MessageFromServer", gotSession.sessionID, gotSession.messageType) - } - got, ok := gotSession.message.(*tg.Updates) - if !ok { - t.Fatalf("pushed message = %T, want *tg.Updates", gotSession.message) - } - if len(got.Updates) != 1 || len(got.Users) != 1 { - t.Fatalf("updates payload = %+v, want one update and official user", got) - } - update, ok := got.Updates[0].(*tg.UpdateNewMessage) - if !ok { - t.Fatalf("update = %T, want *tg.UpdateNewMessage", got.Updates[0]) - } - if update.Pts != event.Pts || update.PtsCount != event.PtsCount || got.Seq != state.Seq { - t.Fatalf("update state = pts %d pts_count %d seq %d, want %d/%d/%d", update.Pts, update.PtsCount, got.Seq, event.Pts, event.PtsCount, state.Seq) - } - message, ok := update.Message.(*tg.Message) - if !ok { - t.Fatalf("update message = %T, want *tg.Message", update.Message) - } - if message.ID != msg.ID || message.Message != msg.Body { - t.Fatalf("message = %+v, want login message %+v", message, msg) - } - user, ok := got.Users[0].(*tg.User) - if !ok || user.ID != domain.OfficialSystemUserID || !user.Verified || !user.Support { - t.Fatalf("user = %#v, want verified official system user", got.Users[0]) - } -} - -func TestSignInServiceNotificationMatchesEnterpriseShape(t *testing.T) { - var authKeyID [8]byte - authKeyID[0] = 9 - r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) - ctx := WithClientInfo(context.Background(), ClientInfo{ - DeviceModel: "Telegram Desktop", - SystemVersion: "Windows", - AppVersion: "6.8.4", - }) - - got := r.tgSignInServiceNotification(ctx, domain.User{ - ID: 1000000001, - FirstName: "Test", - LastName: "User", - }, authKeyID) - - if len(got.Updates) != 1 { - t.Fatalf("updates = %+v, want one service notification", got.Updates) - } - update, ok := got.Updates[0].(*tg.UpdateServiceNotification) - if !ok { - t.Fatalf("update = %T, want *tg.UpdateServiceNotification", got.Updates[0]) - } - if update.Popup || update.InboxDate == 0 || update.Media == nil { - t.Fatalf("notification flags/media = popup %v inbox %d media %T", update.Popup, update.InboxDate, update.Media) - } - for _, want := range []string{"New login.", "Test User", "Telegram Desktop", "Settings > Devices"} { - if !strings.Contains(update.Message, want) { - t.Fatalf("notification message %q missing %q", update.Message, want) - } - } - if len(update.Entities) < 3 { - t.Fatalf("entities = %+v, want bold entities for title/settings links", update.Entities) - } -} - -func TestLogOutClearsSessionAndUpdateState(t *testing.T) { - var authKeyID [8]byte - authKeyID[0] = 5 - auth := &captureAuthService{} - updates := &captureUpdates{} - sessions := &captureSessions{authKeyID: authKeyID, authKeyResolved: true, userID: 1000000001, userResolved: true} - r := New(Config{}, Deps{ - Auth: auth, - Updates: updates, - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - - _, err := r.onAuthLogOut(WithAuthKeyID(context.Background(), authKeyID)) - if err != nil { - t.Fatalf("auth.logOut: %v", err) - } - if auth.loggedOutAuthKeyID != authKeyID { - t.Fatalf("logged out auth key = %x, want %x", auth.loggedOutAuthKeyID, authKeyID) - } - if updates.clearedAuthKeyID != authKeyID || !updates.cleared { - t.Fatalf("cleared auth key = %x cleared=%v, want %x", updates.clearedAuthKeyID, updates.cleared, authKeyID) - } - gotSession := sessions.snapshot() - if gotSession.userID != 0 || !gotSession.userResolved { - t.Fatalf("session user after logout = %d resolved=%v, want 0/true", gotSession.userID, gotSession.userResolved) - } -} - -func TestSignInDifferentUserClearsAuthKeyUpdateState(t *testing.T) { - var authKeyID [8]byte - authKeyID[0] = 6 - auth := &captureAuthService{signInUser: domain.User{ID: 1000000002, FirstName: "Two"}} - updates := &captureUpdates{} - sessions := &captureSessions{authKeyID: authKeyID, authKeyResolved: true, userID: 1000000001, userResolved: true} - r := New(Config{}, Deps{ - Auth: auth, - Updates: updates, - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - ctx := WithUserID(WithAuthKeyID(WithSessionID(context.Background(), 77), authKeyID), 1000000001) - - _, err := r.onAuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: "15550000002", PhoneCodeHash: "hash", PhoneCode: "12345"}) - if err != nil { - t.Fatalf("auth.signIn: %v", err) - } - if updates.clearedAuthKeyID != authKeyID || !updates.cleared { - t.Fatalf("cleared auth key = %x cleared=%v, want %x", updates.clearedAuthKeyID, updates.cleared, authKeyID) - } - gotSession := sessions.snapshot() - if gotSession.userID != 1000000002 { - t.Fatalf("session user = %d, want new user 1000000002", gotSession.userID) - } -} - -func TestDialogSettingRPCsRecordDurableUpdates(t *testing.T) { - var authKeyID [8]byte - authKeyID[0] = 9 - ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) - peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} - dialogPeer := &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}} - dialogs := &captureDialogs{} - updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}} - r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) - - toggle := &tg.MessagesToggleDialogPinRequest{Peer: dialogPeer} - toggle.SetPinned(true) - if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok { - t.Fatalf("toggle pin = %v, %v", ok, err) - } - if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogPinned || updates.events[0].Peer != peer || !updates.events[0].Bool || updates.excludeSessionID != 55 { - t.Fatalf("pin event = %+v, want durable dialog_pinned", updates.events) - } - - if ok, err := r.onMessagesReorderPinnedDialogs(ctx, &tg.MessagesReorderPinnedDialogsRequest{Order: []tg.InputDialogPeerClass{dialogPeer}}); err != nil || !ok { - t.Fatalf("reorder pinned = %v, %v", ok, err) - } - if len(updates.events) != 2 || updates.events[1].Type != domain.UpdateEventPinnedDialogs || len(updates.events[1].Peers) != 1 || updates.events[1].Peers[0] != peer { - t.Fatalf("reorder event = %+v, want durable pinned_dialogs", updates.events) - } - - mark := &tg.MessagesMarkDialogUnreadRequest{Peer: dialogPeer} - mark.SetUnread(true) - if ok, err := r.onMessagesMarkDialogUnread(ctx, mark); err != nil || !ok { - t.Fatalf("mark unread = %v, %v", ok, err) - } - if len(updates.events) != 3 || updates.events[2].Type != domain.UpdateEventDialogUnreadMark || updates.events[2].Peer != peer || !updates.events[2].Bool { - t.Fatalf("unread event = %+v, want durable dialog_unread_mark", updates.events) - } - - parentMark := &tg.MessagesMarkDialogUnreadRequest{ - ParentPeer: &tg.InputPeerChannel{ChannelID: 9001}, - Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerSelf{}}, - } - parentMark.SetUnread(true) - parentMark.SetParentPeer(parentMark.ParentPeer) - if ok, err := r.onMessagesMarkDialogUnread(ctx, parentMark); err != nil || !ok { - t.Fatalf("mark monoforum unread compat = %v, %v", ok, err) - } - if len(updates.events) != 3 { - t.Fatalf("parent_peer unread compat recorded events = %+v, want no durable event", updates.events) - } - - getParentMarks := &tg.MessagesGetDialogUnreadMarksRequest{ParentPeer: &tg.InputPeerChannel{ChannelID: 9001}} - getParentMarks.SetParentPeer(getParentMarks.ParentPeer) - marks, err := r.onMessagesGetDialogUnreadMarks(ctx, getParentMarks) - if err != nil { - t.Fatalf("get monoforum unread marks compat: %v", err) - } - if len(marks) != 0 { - t.Fatalf("monoforum unread marks = %+v, want empty compat list", marks) - } - - invalidParentMarks := &tg.MessagesGetDialogUnreadMarksRequest{ParentPeer: &tg.InputPeerSelf{}} - invalidParentMarks.SetParentPeer(invalidParentMarks.ParentPeer) - if _, err := r.onMessagesGetDialogUnreadMarks(ctx, invalidParentMarks); err == nil || !strings.Contains(err.Error(), "PARENT_PEER_INVALID") { - t.Fatalf("get unread marks invalid parent err = %v, want PARENT_PEER_INVALID", err) - } - - if ok, err := r.onMessagesHidePeerSettingsBar(ctx, &tg.InputPeerUser{UserID: peer.ID}); err != nil || !ok { - t.Fatalf("hide peer settings = %v, %v", ok, err) - } - if len(updates.events) != 4 || updates.events[3].Type != domain.UpdateEventPeerSettings || updates.events[3].Peer != peer || !updates.events[3].Settings.HiddenPeerSettingsBar { - t.Fatalf("peer settings event = %+v, want durable peer_settings", updates.events) - } -} - -func TestDialogUnreadMarksIncludeChannelDialogs(t *testing.T) { - ctx := context.Background() - channelStore := memory.NewChannelStore() - channelSvc := appchannels.NewService(channelStore) - created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, 1000000001, domain.CreateChannelRequest{ - Title: "Unread Mark Channel", - Date: 1700001000, - }) - if err != nil { - t.Fatalf("CreateMegagroupFromCreateChat: %v", err) - } - dialogs := appdialogs.NewService(memory.NewDialogStore(), channelStore) - r := New(Config{}, Deps{ - Channels: channelSvc, - Dialogs: dialogs, - }, zaptest.NewLogger(t), clock.System) - rpcCtx := WithUserID(ctx, 1000000001) - input := &tg.InputDialogPeer{Peer: &tg.InputPeerChannel{ - ChannelID: created.Channel.ID, - AccessHash: created.Channel.AccessHash, - }} - - mark := &tg.MessagesMarkDialogUnreadRequest{Peer: input} - mark.SetUnread(true) - if ok, err := r.onMessagesMarkDialogUnread(rpcCtx, mark); err != nil || !ok { - t.Fatalf("mark channel unread = %v, %v", ok, err) - } - marks, err := r.onMessagesGetDialogUnreadMarks(rpcCtx, &tg.MessagesGetDialogUnreadMarksRequest{}) - if err != nil { - t.Fatalf("get channel unread marks: %v", err) - } - if len(marks) != 1 { - t.Fatalf("channel unread marks len = %d, want 1 (%+v)", len(marks), marks) - } - got, ok := marks[0].(*tg.DialogPeer) - if !ok { - t.Fatalf("channel unread mark = %T, want dialogPeer", marks[0]) - } - peer, ok := got.Peer.(*tg.PeerChannel) - if !ok || peer.ChannelID != created.Channel.ID { - t.Fatalf("channel unread mark peer = %#v, want channel %d", got.Peer, created.Channel.ID) - } - dialogsList, err := dialogs.GetPeerDialogs(ctx, 1000000001, []domain.Peer{{Type: domain.PeerTypeChannel, ID: created.Channel.ID}}) - if err != nil { - t.Fatalf("GetPeerDialogs: %v", err) - } - var dialog domain.Dialog - for _, item := range dialogsList.Dialogs { - if item.Peer.Type == domain.PeerTypeChannel && item.Peer.ID == created.Channel.ID { - dialog = item - break - } - } - if dialog.Peer.ID == 0 { - t.Fatalf("channel dialog missing from %+v", dialogsList.Dialogs) - } - if !dialog.UnreadMark { - t.Fatalf("channel dialog unread mark = false, want true") - } - - clear := &tg.MessagesMarkDialogUnreadRequest{Peer: input} - if ok, err := r.onMessagesMarkDialogUnread(rpcCtx, clear); err != nil || !ok { - t.Fatalf("clear channel unread = %v, %v", ok, err) - } - marks, err = r.onMessagesGetDialogUnreadMarks(rpcCtx, &tg.MessagesGetDialogUnreadMarksRequest{}) - if err != nil { - t.Fatalf("get channel unread marks after clear: %v", err) - } - if len(marks) != 0 { - t.Fatalf("channel unread marks after clear = %+v, want empty", marks) - } -} - -func TestDialogFolderRPCsPersistAndRecordUpdates(t *testing.T) { - var authKeyID [8]byte - authKeyID[0] = 12 - ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 66) - dialogs := &captureDialogs{} - updates := &captureUpdates{state: domain.UpdateState{Pts: 21, Date: 1700000500, Seq: 0}} - r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) - - req := &tg.MessagesUpdateDialogFilterRequest{ID: 2} - req.SetFilter(&tg.DialogFilter{ - ID: 2, - Contacts: true, - Title: tg.TextWithEntities{Text: "Work"}, - IncludePeers: []tg.InputPeerClass{&tg.InputPeerUser{ - UserID: 1000000002, - AccessHash: 222, - }}, - }) - if ok, err := r.onMessagesUpdateDialogFilter(ctx, req); err != nil || !ok { - t.Fatalf("update filter = %v, %v", ok, err) - } - if dialogs.savedFolder.ID != 2 || dialogs.savedFolder.Title != "Work" || !dialogs.savedFolder.Contacts || len(dialogs.savedFolder.IncludePeers) != 1 { - t.Fatalf("saved folder = %+v, want parsed dialog folder", dialogs.savedFolder) - } - if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogFilter || updates.events[0].FilterID != 2 || updates.excludeSessionID != 66 { - t.Fatalf("filter event = %+v, want durable dialog_filter", updates.events) - } - - if ok, err := r.onMessagesUpdateDialogFiltersOrder(ctx, []int{2, 2, 0, 3}); err != nil || !ok { - t.Fatalf("update filter order = %v, %v", ok, err) - } - if len(dialogs.folderOrder) != 2 || dialogs.folderOrder[0] != 2 || dialogs.folderOrder[1] != 3 { - t.Fatalf("folder order = %+v, want cleaned custom IDs", dialogs.folderOrder) - } - if len(updates.events) != 2 || updates.events[1].Type != domain.UpdateEventDialogFilterOrder { - t.Fatalf("order event = %+v, want durable dialog_filter_order", updates.events) - } - - if ok, err := r.onMessagesToggleDialogFilterTags(ctx, true); err != nil || !ok { - t.Fatalf("toggle filter tags = %v, %v", ok, err) - } - if !dialogs.tagsEnabled || len(updates.events) != 3 || updates.events[2].Type != domain.UpdateEventDialogFilters { - t.Fatalf("tags/update = tags %v events %+v, want reload update", dialogs.tagsEnabled, updates.events) - } -} - -func TestFoldersEditPeerFoldersPersistsArchiveAndReturnsPtsUpdate(t *testing.T) { - var authKeyID [8]byte - authKeyID[0] = 13 - ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 77) - dialogs := &captureDialogs{} - updates := &captureUpdates{state: domain.UpdateState{Pts: 31, Date: 1700000600, Seq: 0}} - r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates}, zaptest.NewLogger(t), clock.System) - - got, err := r.onFoldersEditPeerFolders(ctx, []tg.InputFolderPeer{{ - Peer: &tg.InputPeerUser{UserID: 1000000002}, - FolderID: domain.DialogArchiveFolderID, - }}) - if err != nil { - t.Fatalf("folders.editPeerFolders: %v", err) - } - if len(dialogs.folderPeers) != 1 || dialogs.folderPeers[0].FolderID != domain.DialogArchiveFolderID { - t.Fatalf("folder peers = %+v, want archive update", dialogs.folderPeers) - } - out, ok := got.(*tg.Updates) - if !ok || len(out.Updates) != 1 { - t.Fatalf("updates = %T %+v, want tg.Updates with updateFolderPeers", got, got) - } - update, ok := out.Updates[0].(*tg.UpdateFolderPeers) - if !ok || update.Pts != 31 || update.PtsCount != 1 || len(update.FolderPeers) != 1 { - t.Fatalf("update = %T %+v, want updateFolderPeers pts=31", out.Updates[0], out.Updates[0]) - } -} - -func TestDialogSettingRPCsSkipManualPushWhenReliableDispatch(t *testing.T) { - var authKeyID [8]byte - authKeyID[0] = 11 - ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) - peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} - dialogs := &captureDialogs{} - updates := &captureUpdates{state: domain.UpdateState{Pts: 11, Date: 1700000400, Seq: 0}, reliableDispatch: true} - sessions := &captureSessions{} - r := New(Config{}, Deps{Dialogs: dialogs, Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System) - - toggle := &tg.MessagesToggleDialogPinRequest{Peer: &tg.InputDialogPeer{Peer: &tg.InputPeerUser{UserID: peer.ID}}} - toggle.SetPinned(true) - if ok, err := r.onMessagesToggleDialogPin(ctx, toggle); err != nil || !ok { - t.Fatalf("toggle pin = %v, %v", ok, err) - } - if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventDialogPinned { - t.Fatalf("events = %+v, want durable event recorded", updates.events) - } - if got := sessions.snapshot(); got.message != nil { - t.Fatalf("manual push = %+v, want reliable outbox to be sole online dispatcher", got) - } -} - -func TestUpdatesGetStateMarksSessionReadyForPush(t *testing.T) { - sessions := &captureSessions{} - r := New(Config{}, Deps{ - Sessions: sessions, - Updates: &captureUpdates{state: domain.UpdateState{Pts: 3, Date: 1700000000, Seq: 2}}, - }, zaptest.NewLogger(t), clock.System) - - got, err := r.onUpdatesGetState(WithSessionID(context.Background(), 77)) - if err != nil { - t.Fatalf("updates.getState: %v", err) - } - if got.Pts != 3 || got.Seq != 2 { - t.Fatalf("state = %+v, want pts=3 seq=2", got) - } - gotSession := sessions.snapshot() - if gotSession.sessionID != 77 || !gotSession.receives { - t.Fatalf("session ready = id %d receives %v, want 77/true", gotSession.sessionID, gotSession.receives) - } -} - -func TestUpdatesGetStateNudgesDifferenceWhenCurrentStateAhead(t *testing.T) { - sessions := &captureSessions{} - current := domain.UpdateState{Pts: 4, Date: 1700000001} - r := New(Config{}, Deps{ - Sessions: sessions, - Updates: &captureUpdates{ - state: domain.UpdateState{Pts: 3, Date: 1700000000}, - currentState: ¤t, - }, - }, zaptest.NewLogger(t), clock.System) - - if _, err := r.onUpdatesGetState(WithSessionID(context.Background(), 77)); err != nil { - t.Fatalf("updates.getState: %v", err) - } - deadline := time.Now().Add(time.Second) - for { - got := sessions.snapshot() - if _, ok := got.message.(*tg.UpdatesTooLong); ok { - if got.sessionID != 77 || got.messageType != proto.MessageFromServer { - t.Fatalf("nudge target = session %d type %v, want 77/from-server", got.sessionID, got.messageType) - } - return - } - if time.Now().After(deadline) { - t.Fatalf("timed out waiting for updatesTooLong nudge, last message=%T", got.message) - } - time.Sleep(10 * time.Millisecond) - } -} - -func TestUpdatesDifferenceIncludesLoginMessageAndOfficialUser(t *testing.T) { - msg := domain.Message{ - ID: 88, - OwnerUserID: 1000000001, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, - Date: 1700000100, - Body: "Login code: 12345", - } - got, ok := tgUpdatesDifference(domain.UpdateDifference{ - State: domain.UpdateState{Pts: 5, Date: msg.Date, Seq: 4}, - Events: []domain.UpdateEvent{{ - Type: domain.UpdateEventNewMessage, - Pts: 5, - PtsCount: 1, - Date: msg.Date, - Message: msg, - }}, - }).(*tg.UpdatesDifference) - if !ok { - t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) - } - if got.State.Pts != 5 || len(got.NewMessages) != 1 || len(got.Users) != 1 { - t.Fatalf("difference = %+v, want one message, one official user, pts=5", got) - } - user, ok := got.Users[0].(*tg.User) - if !ok || user.ID != domain.OfficialSystemUserID { - t.Fatalf("user = %#v, want official system user", got.Users[0]) - } -} - -func TestUpdatesDifferenceIncludesForwardSourceChannelChat(t *testing.T) { - source := domain.Channel{ - ID: 2000000001, - AccessHash: 9001, - Title: "Source Channel", - Broadcast: true, - Date: 1700000000, - } - msg := domain.Message{ - ID: 89, - OwnerUserID: 1000000001, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, - Date: 1700000101, - Body: "forwarded", - Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000}, - } - got, ok := tgUpdatesDifference(domain.UpdateDifference{ - State: domain.UpdateState{Pts: 6, Date: msg.Date}, - Events: []domain.UpdateEvent{{ - UserID: msg.OwnerUserID, - Type: domain.UpdateEventNewMessage, - Pts: 6, - PtsCount: 1, - Date: msg.Date, - Message: msg, - Channels: []domain.Channel{source}, - }}, - }).(*tg.UpdatesDifference) - if !ok { - t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) - } - if len(got.Chats) != 1 { - t.Fatalf("chats = %+v, want source channel", got.Chats) - } - ch, ok := got.Chats[0].(*tg.Channel) - if !ok || ch.ID != source.ID || ch.Title != source.Title { - t.Fatalf("chat = %#v, want source channel", got.Chats[0]) - } -} - -func TestOutboxEventIncludesForwardSourceChannelChat(t *testing.T) { - source := domain.Channel{ - ID: 2000000002, - AccessHash: 9002, - Title: "Forward Source", - Broadcast: true, - Date: 1700000000, - } - update := tgUpdateForOutboxEvent(domain.UpdateEvent{ - UserID: 1000000001, - Type: domain.UpdateEventNewMessage, - Pts: 7, - PtsCount: 1, - Date: 1700000102, - Message: domain.Message{ - ID: 90, - OwnerUserID: 1000000001, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, - Date: 1700000102, - Body: "forwarded", - Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000}, - }, - Channels: []domain.Channel{source}, - }) - if update == nil || len(update.Chats) != 1 { - t.Fatalf("update = %+v, want source channel chat", update) - } - ch, ok := update.Chats[0].(*tg.Channel) - if !ok || ch.ID != source.ID { - t.Fatalf("chat = %#v, want source channel", update.Chats[0]) - } -} - -func TestOutboxEventChannelViewForumAsMessages(t *testing.T) { - update := tgUpdateForOutboxEvent(domain.UpdateEvent{ - UserID: 1000000001, - Type: domain.UpdateEventChannelViewForum, - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002}, - Bool: true, - Date: 1700000200, - }) - if update == nil || len(update.Updates) != 1 { - t.Fatalf("update = %+v, want one updateChannelViewForumAsMessages", update) - } - got, ok := update.Updates[0].(*tg.UpdateChannelViewForumAsMessages) - if !ok || got.ChannelID != 2000000002 || !got.Enabled { - t.Fatalf("update = %#v, want channel view-forum-as-messages enabled", update.Updates[0]) - } -} - -func TestChannelDifferenceIncludesExtraForwardSourceChannel(t *testing.T) { - channel := domain.Channel{ID: 2000000100, AccessHash: 9010, Title: "Megagroup", Megagroup: true, Date: 1700000000, Pts: 3} - source := domain.Channel{ID: 2000000101, AccessHash: 9011, Title: "Source", Broadcast: true, Date: 1700000000} - got, ok := tgChannelDifference(1000000001, domain.ChannelDifference{ - Channel: channel, - NewMessages: []domain.ChannelMessage{{ - ChannelID: channel.ID, - ID: 3, - SenderUserID: 1000000002, - From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, - Date: 1700000103, - Body: "forwarded", - Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000}, - Pts: 3, - }}, - Users: []domain.User{{ID: 1000000002, AccessHash: 42, FirstName: "Bob"}}, - Channels: []domain.Channel{source}, - Pts: 3, - Final: true, - Timeout: 30, - }).(*tg.UpdatesChannelDifference) - if !ok { - t.Fatalf("difference = %T, want *tg.UpdatesChannelDifference", got) - } - if len(got.Users) != 1 || len(got.Chats) != 2 { - t.Fatalf("difference users/chats = %d/%d, want 1/2", len(got.Users), len(got.Chats)) - } - if ch, ok := got.Chats[1].(*tg.Channel); !ok || ch.ID != source.ID { - t.Fatalf("extra chat = %#v, want source channel", got.Chats[1]) - } -} - -func TestUpdatesDifferenceIncludesReadHistoryInbox(t *testing.T) { - peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} - got, ok := tgUpdatesDifference(domain.UpdateDifference{ - State: domain.UpdateState{Pts: 6, Date: 1700000200, Seq: 5}, - Events: []domain.UpdateEvent{{ - Type: domain.UpdateEventReadHistoryInbox, - Pts: 6, - PtsCount: 1, - Date: 1700000200, - Peer: peer, - MaxID: 12, - StillUnreadCount: 0, - }}, - }).(*tg.UpdatesDifference) - if !ok { - t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) - } - if got.State.Pts != 6 || len(got.OtherUpdates) != 1 { - t.Fatalf("difference = %+v, want one read history update and pts=6", got) - } - update, ok := got.OtherUpdates[0].(*tg.UpdateReadHistoryInbox) - if !ok { - t.Fatalf("update = %T, want *tg.UpdateReadHistoryInbox", got.OtherUpdates[0]) - } - if update.MaxID != 12 || update.Pts != 6 || update.PtsCount != 1 { - t.Fatalf("read update = %+v, want max_id=12 pts=6 pts_count=1", update) - } -} - -func TestUpdatesDifferenceIncludesReadHistoryOutbox(t *testing.T) { - peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} - got, ok := tgUpdatesDifference(domain.UpdateDifference{ - State: domain.UpdateState{Pts: 7, Date: 1700000210, Seq: 0}, - Events: []domain.UpdateEvent{{ - Type: domain.UpdateEventReadHistoryOutbox, - Pts: 7, - PtsCount: 1, - Date: 1700000210, - Peer: peer, - MaxID: 9, - }}, - }).(*tg.UpdatesDifference) - if !ok { - t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) - } - if len(got.OtherUpdates) != 1 { - t.Fatalf("other updates = %+v, want one read outbox update", got.OtherUpdates) - } - update, ok := got.OtherUpdates[0].(*tg.UpdateReadHistoryOutbox) - if !ok || update.MaxID != 9 || update.Pts != 7 || update.PtsCount != 1 { - t.Fatalf("read outbox = %T %+v, want max_id=9 pts=7", got.OtherUpdates[0], got.OtherUpdates[0]) - } -} - -func TestUpdatesDifferenceIncludesEditMessage(t *testing.T) { - msg := domain.Message{ - ID: 4, - OwnerUserID: 1000000001, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, - Date: 1700000200, - EditDate: 1700000300, - Out: true, - Body: "edited", - Pts: 8, - } - got, ok := tgUpdatesDifference(domain.UpdateDifference{ - State: domain.UpdateState{Pts: 8, Date: 1700000300, Seq: 0}, - Events: []domain.UpdateEvent{{ - Type: domain.UpdateEventEditMessage, - Pts: 8, - PtsCount: 1, - Date: 1700000300, - Message: msg, - }}, - }).(*tg.UpdatesDifference) - if !ok { - t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) - } - if len(got.OtherUpdates) != 1 { - t.Fatalf("other updates = %+v, want one edit update", got.OtherUpdates) - } - update, ok := got.OtherUpdates[0].(*tg.UpdateEditMessage) - if !ok || update.Pts != 8 || update.PtsCount != 1 { - t.Fatalf("edit update = %T %+v, want pts=8", got.OtherUpdates[0], got.OtherUpdates[0]) - } - edited, ok := update.Message.(*tg.Message) - if !ok || edited.Message != "edited" || edited.EditDate != 1700000300 { - t.Fatalf("edited message = %#v, want text and edit_date", update.Message) - } -} - -func TestUpdatesDifferenceIncludesReactionMessageAndUpdate(t *testing.T) { - const ( - aliceID = int64(1000000001) - bobID = int64(1000000002) - ) - reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"} - reactions := domain.ChannelMessageReactions{ - CanSeeList: true, - Results: []domain.ChannelMessageReactionCount{{ - Reaction: reaction, - Count: 1, - ChosenOrder: 1, - }}, - Recent: []domain.ChannelMessagePeerReaction{{ - UserID: bobID, - Reaction: reaction, - My: true, - ChosenOrder: 1, - Date: 1700000310, - }}, - } - msg := domain.Message{ - ID: 68, - UID: 7001, - OwnerUserID: aliceID, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, - Date: 1700000300, - Body: "rx", - Reactions: &reactions, - } - got, ok := tgUpdatesDifference(domain.UpdateDifference{ - State: domain.UpdateState{Pts: 9, Date: 1700000310}, - Events: []domain.UpdateEvent{{ - UserID: aliceID, - Type: domain.UpdateEventMessageReactions, - Pts: 9, - PtsCount: 1, - Date: 1700000310, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, - Message: msg, - }}, - }).(*tg.UpdatesDifference) - if !ok { - t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) - } - if len(got.NewMessages) != 1 || len(got.OtherUpdates) != 1 { - t.Fatalf("difference messages/updates = %d/%d, want 1/1", len(got.NewMessages), len(got.OtherUpdates)) - } - wireMsg, ok := got.NewMessages[0].(*tg.Message) - if !ok || wireMsg.ID != msg.ID { - t.Fatalf("message = %T %+v, want message %d", got.NewMessages[0], got.NewMessages[0], msg.ID) - } - msgReactions, ok := wireMsg.GetReactions() - if !ok || len(msgReactions.Results) != 1 || msgReactions.Results[0].Count != 1 || msgReactions.Results[0].ChosenOrder != 1 { - t.Fatalf("message reactions = %+v set=%v, want chosen reaction", msgReactions, ok) - } - update, ok := got.OtherUpdates[0].(*tg.UpdateMessageReactions) - if !ok || update.MsgID != msg.ID || len(update.Reactions.Results) != 1 || update.Reactions.Results[0].ChosenOrder != 1 { - t.Fatalf("reaction update = %T %+v, want update for msg %d", got.OtherUpdates[0], got.OtherUpdates[0], msg.ID) - } -} - -func TestUpdatesDifferenceIncludesDeleteMessages(t *testing.T) { - got, ok := tgUpdatesDifference(domain.UpdateDifference{ - State: domain.UpdateState{Pts: 8, Date: 1700000250, Seq: 0}, - Events: []domain.UpdateEvent{{ - Type: domain.UpdateEventDeleteMessages, - Pts: 8, - PtsCount: 3, - Date: 1700000250, - MessageIDs: []int{3, 4, 5}, - }}, - }).(*tg.UpdatesDifference) - if !ok { - t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) - } - if got.State.Pts != 8 || len(got.OtherUpdates) != 1 { - t.Fatalf("difference = %+v, want one delete update and pts=8", got) - } - update, ok := got.OtherUpdates[0].(*tg.UpdateDeleteMessages) - if !ok { - t.Fatalf("update = %T, want *tg.UpdateDeleteMessages", got.OtherUpdates[0]) - } - if update.Pts != 8 || update.PtsCount != 3 || len(update.Messages) != 3 || update.Messages[0] != 3 || update.Messages[2] != 5 { - t.Fatalf("delete update = %+v, want ids [3 4 5] pts=8 pts_count=3", update) - } -} - -func TestUpdatesDifferenceIncludesChannelTooLongNudge(t *testing.T) { - got, ok := tgUpdatesDifference(domain.UpdateDifference{ - State: domain.UpdateState{Pts: 8, Date: 1700000250, Seq: 0}, - ChannelNudges: []domain.ChannelDifferenceNudge{{ - ChannelID: 2000000001, - Pts: 12, - }}, - }).(*tg.UpdatesDifference) - if !ok { - t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) - } - if got.State.Pts != 8 || len(got.OtherUpdates) != 1 { - t.Fatalf("difference = %+v, want one channel nudge and account pts unchanged", got) - } - update, ok := got.OtherUpdates[0].(*tg.UpdateChannelTooLong) - if !ok || update.ChannelID != 2000000001 { - t.Fatalf("update = %T %+v, want UpdateChannelTooLong", got.OtherUpdates[0], got.OtherUpdates[0]) - } - if pts, ok := update.GetPts(); !ok || pts != 12 { - t.Fatalf("channel nudge pts = %d set=%v, want 12", pts, ok) - } -} - -func TestUpdatesDifferenceIncludesSettingsUpdates(t *testing.T) { - peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} - got, ok := tgUpdatesDifference(domain.UpdateDifference{ - State: domain.UpdateState{Pts: 5, Date: 1700000300, Seq: 0}, - Events: []domain.UpdateEvent{ - {Type: domain.UpdateEventContactsReset, Pts: 1, PtsCount: 1, Date: 1700000300}, - {Type: domain.UpdateEventDialogPinned, Pts: 2, PtsCount: 1, Date: 1700000300, Peer: peer, Bool: true}, - {Type: domain.UpdateEventPinnedDialogs, Pts: 3, PtsCount: 1, Date: 1700000300, Peers: []domain.Peer{peer}}, - {Type: domain.UpdateEventDialogUnreadMark, Pts: 4, PtsCount: 1, Date: 1700000300, Peer: peer, Bool: false}, - {Type: domain.UpdateEventPeerSettings, Pts: 5, PtsCount: 1, Date: 1700000300, Peer: peer, Settings: domain.PeerSettings{ShareContact: true}}, - }, - }).(*tg.UpdatesDifference) - if !ok { - t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) - } - if got.State.Pts != 5 || len(got.OtherUpdates) != 5 { - t.Fatalf("difference = %+v, want five settings updates and pts=5", got) - } - if _, ok := got.OtherUpdates[0].(*tg.UpdateContactsReset); !ok { - t.Fatalf("update[0] = %T, want contacts reset", got.OtherUpdates[0]) - } - pinned, ok := got.OtherUpdates[1].(*tg.UpdateDialogPinned) - if !ok || !pinned.Pinned { - t.Fatalf("update[1] = %+v (%T), want pinned dialog", got.OtherUpdates[1], got.OtherUpdates[1]) - } - pinnedDialogs, ok := got.OtherUpdates[2].(*tg.UpdatePinnedDialogs) - if !ok || len(pinnedDialogs.Order) != 1 { - t.Fatalf("update[2] = %T, want pinned dialogs", got.OtherUpdates[2]) - } - unread, ok := got.OtherUpdates[3].(*tg.UpdateDialogUnreadMark) - if !ok || unread.Unread { - t.Fatalf("update[3] = %+v (%T), want unread=false mark", got.OtherUpdates[3], got.OtherUpdates[3]) - } - peerSettings, ok := got.OtherUpdates[4].(*tg.UpdatePeerSettings) - if !ok || !peerSettings.Settings.ShareContact { - t.Fatalf("update[4] = %T, want peer settings", got.OtherUpdates[4]) - } -} - -func TestMessagesGetHistoryReturnsStoredMessages(t *testing.T) { - msg := domain.Message{ - ID: 1, - OwnerUserID: 1000000001, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, - Date: 1700000100, - Body: "Login code: 12345", - } - messages := &captureMessages{ - list: domain.MessageList{ - Messages: []domain.Message{msg}, - Users: []domain.User{domain.OfficialSystemUser()}, - Count: 1, - Hash: 99, - }, - } - r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesGetHistoryRequest{ - Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, - Limit: 20, - AddOffset: 1 << 30, - } - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - box, ok := enc.(*tg.MessagesMessagesBox) - if !ok { - t.Fatalf("response = %T, want *tg.MessagesMessagesBox", enc) - } - got, ok := box.Messages.(*tg.MessagesMessages) - if !ok { - t.Fatalf("boxed response = %T, want *tg.MessagesMessages", box.Messages) - } - if len(got.Messages) != 1 || len(got.Users) != 1 { - t.Fatalf("history = %+v, want one message and one user", got) - } - if messages.filter.Peer.ID != domain.OfficialSystemUserID || messages.filter.Limit != 20 || messages.filter.AddOffset != domain.MaxMessageHistoryAddOffset { - t.Fatalf("filter = %+v, want official peer limit 20 and clamped add_offset", messages.filter) - } -} - -func TestMessagesSetTypingPushesUserTypingUpdate(t *testing.T) { - sessions := &captureScopedSessions{captureSessions: &captureSessions{}} - r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System) - var authKeyID [8]byte - authKeyID[0] = 7 - - ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) - ok, err := r.onMessagesSetTyping(ctx, &tg.MessagesSetTypingRequest{ - Peer: &tg.InputPeerUser{UserID: 1000000002, AccessHash: 22}, - Action: &tg.SendMessageTypingAction{}, - }) - if err != nil { - t.Fatalf("set typing: %v", err) - } - if !ok { - t.Fatalf("set typing = false, want true") - } - - got := sessions.snapshot() - if got.userID != 1000000002 || got.sessionID != 55 || got.messageType != proto.MessageFromServer { - t.Fatalf("push = user %d exclude session %d type %v, want target/exclude/from_server", got.userID, got.sessionID, got.messageType) - } - if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { - t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) - } - updateShort, ok := got.message.(*tg.UpdateShort) - if !ok { - t.Fatalf("pushed message = %T, want *tg.UpdateShort", got.message) - } - typing, ok := updateShort.Update.(*tg.UpdateUserTyping) - if !ok { - t.Fatalf("short update = %T, want *tg.UpdateUserTyping", updateShort.Update) - } - if typing.UserID != 1000000001 { - t.Fatalf("typing user_id = %d, want sender", typing.UserID) - } - if _, ok := typing.Action.(*tg.SendMessageTypingAction); !ok { - t.Fatalf("typing action = %T, want *tg.SendMessageTypingAction", typing.Action) - } -} - -func TestMessagesSetTypingRejectsInvalidTopMsgID(t *testing.T) { - r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesSetTypingRequest{ - Peer: &tg.InputPeerUser{UserID: 1000000002, AccessHash: 22}, - Action: &tg.SendMessageTypingAction{}, - } - req.SetTopMsgID(domain.MaxMessageBoxID + 1) - - ok, err := r.onMessagesSetTyping(WithUserID(context.Background(), 1000000001), req) - if ok || err == nil || !strings.Contains(err.Error(), "MSG_ID_INVALID") { - t.Fatalf("set typing invalid top_msg_id = ok %v err %v, want MSG_ID_INVALID", ok, err) - } -} - -func TestMessagesSetTypingPushesChannelTypingTopMsgID(t *testing.T) { - const ( - ownerID = int64(1000000001) - memberID = int64(1000000002) - topicID = 7 - ) - channels := appchannels.NewService(memory.NewChannelStore()) - created, err := channels.CreateChannel(context.Background(), ownerID, domain.CreateChannelRequest{ - Title: "topic group", - CreatorUserID: ownerID, - Megagroup: true, - MemberUserIDs: []int64{memberID}, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - sessions := &captureScopedSessions{captureSessions: &captureSessions{ - channelViewers: map[int64][]int64{created.Channel.ID: {memberID}}, - }} - r := New(Config{}, Deps{Channels: channels, Sessions: sessions}, zaptest.NewLogger(t), clock.System) - var authKeyID [8]byte - authKeyID[0] = 9 - - req := &tg.MessagesSetTypingRequest{ - Peer: &tg.InputPeerChannel{ - ChannelID: created.Channel.ID, - AccessHash: created.Channel.AccessHash, - }, - Action: &tg.SendMessageTypingAction{}, - } - req.SetTopMsgID(topicID) - ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), ownerID), authKeyID), 77) - - ok, err := r.onMessagesSetTyping(ctx, req) - if err != nil { - t.Fatalf("set channel typing: %v", err) - } - if !ok { - t.Fatalf("set channel typing = false, want true") - } - got := sessions.snapshot() - if got.userID != memberID || got.sessionID != 77 || got.messageType != proto.MessageFromServer { - t.Fatalf("channel typing push = user %d exclude session %d type %v, want member/exclude/from_server", got.userID, got.sessionID, got.messageType) - } - if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { - t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) - } - updates, ok := got.message.(*tg.Updates) - if !ok { - t.Fatalf("pushed message = %T, want *tg.Updates", got.message) - } - if len(updates.Updates) != 1 { - t.Fatalf("updates len = %d, want 1", len(updates.Updates)) - } - typing, ok := updates.Updates[0].(*tg.UpdateChannelUserTyping) - if !ok { - t.Fatalf("channel update = %T, want *tg.UpdateChannelUserTyping", updates.Updates[0]) - } - if typing.ChannelID != created.Channel.ID || typing.TopMsgID != topicID { - t.Fatalf("channel typing = channel %d top %d, want channel %d top %d", typing.ChannelID, typing.TopMsgID, created.Channel.ID, topicID) - } - from, ok := typing.FromID.(*tg.PeerUser) - if !ok || from.UserID != ownerID { - t.Fatalf("typing from = %T %+v, want owner peer", typing.FromID, typing.FromID) - } - if _, ok := typing.Action.(*tg.SendMessageTypingAction); !ok { - t.Fatalf("typing action = %T, want *tg.SendMessageTypingAction", typing.Action) - } -} - -func TestMessagesSetTypingSkipsChannelMemberWithoutViewerInterest(t *testing.T) { - const ( - ownerID = int64(1000000001) - memberID = int64(1000000002) - ) - channels := appchannels.NewService(memory.NewChannelStore()) - created, err := channels.CreateChannel(context.Background(), ownerID, domain.CreateChannelRequest{ - Title: "quiet group", - CreatorUserID: ownerID, - Megagroup: true, - MemberUserIDs: []int64{memberID}, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - sessions := &captureScopedSessions{captureSessions: &captureSessions{ - channelMembers: map[int64][]int64{created.Channel.ID: {memberID}}, - }} - r := New(Config{}, Deps{Channels: channels, Sessions: sessions}, zaptest.NewLogger(t), clock.System) - - ok, err := r.onMessagesSetTyping(WithUserID(context.Background(), ownerID), &tg.MessagesSetTypingRequest{ - Peer: &tg.InputPeerChannel{ - ChannelID: created.Channel.ID, - AccessHash: created.Channel.AccessHash, - }, - Action: &tg.SendMessageTypingAction{}, - }) - if err != nil { - t.Fatalf("set channel typing: %v", err) - } - if !ok { - t.Fatalf("set channel typing = false, want true") - } - if got := sessions.snapshot(); got.message != nil { - t.Fatalf("typing push without viewer interest = %+v, want none", got) - } -} - -func TestMessagesGetMessagesReturnsOwnerMessages(t *testing.T) { - const ( - userID = int64(1000000001) - peerID = int64(1000000002) - ) - messages := &captureMessages{list: domain.MessageList{ - Messages: []domain.Message{{ - ID: 7, - OwnerUserID: userID, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, - Date: 1700000000, - Body: "reply source", - }}, - Count: 1, - }} - r := New(Config{}, Deps{ - Messages: messages, - Users: mapUsersService{users: map[int64]domain.User{ - userID: {ID: userID, FirstName: "Alice"}, - peerID: {ID: peerID, FirstName: "Bob"}, - }}, - }, zaptest.NewLogger(t), clock.System) - - got, err := r.onMessagesGetMessages(WithUserID(context.Background(), userID), []tg.InputMessageClass{&tg.InputMessageID{ID: 7}}) - if err != nil { - t.Fatalf("get messages: %v", err) - } - box, ok := got.(*tg.MessagesMessages) - if !ok || len(box.Messages) != 1 { - t.Fatalf("response = %T %+v, want one messages.messages", got, got) - } - msg, ok := box.Messages[0].(*tg.Message) - if !ok || msg.ID != 7 || msg.Message != "reply source" { - t.Fatalf("message = %#v, want source message", box.Messages[0]) - } - if len(box.Users) != 1 { - t.Fatalf("users = %+v, want peer user", box.Users) - } -} - -func TestMessagesSaveDraftPushesDraftUpdateToOtherSessions(t *testing.T) { - const ( - userID = int64(1000000001) - peerID = int64(1000000002) - ) - sessions := &captureScopedSessions{captureSessions: &captureSessions{}} - r := New(Config{}, Deps{ - Sessions: sessions, - Users: mapUsersService{users: map[int64]domain.User{ - userID: {ID: userID, FirstName: "Alice"}, - peerID: {ID: peerID, FirstName: "Bob"}, - }}, - }, zaptest.NewLogger(t), clock.System) - var authKeyID [8]byte - authKeyID[0] = 8 - - ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKeyID), 66) - ok, err := r.onMessagesSaveDraft(ctx, &tg.MessagesSaveDraftRequest{ - Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, - Message: "1111", - Entities: []tg.MessageEntityClass{ - &tg.MessageEntityBold{Offset: 0, Length: 4}, - }, - }) - if err != nil { - t.Fatalf("save draft: %v", err) - } - if !ok { - t.Fatalf("save draft = false, want true") - } - - got := sessions.snapshot() - if got.userID != userID || got.sessionID != 66 || got.messageType != proto.MessageFromServer { - t.Fatalf("push = user %d exclude session %d type %v, want self/exclude/from_server", got.userID, got.sessionID, got.messageType) - } - if gotAuthKeyID := sessions.scopedAuthKeyID; gotAuthKeyID != authKeyID { - t.Fatalf("exclude auth_key_id = %x, want %x", gotAuthKeyID, authKeyID) - } - updates, ok := got.message.(*tg.Updates) - if !ok { - t.Fatalf("pushed message = %T, want *tg.Updates", got.message) - } - if len(updates.Updates) != 1 { - t.Fatalf("updates = %+v, want one update", updates.Updates) - } - update, ok := updates.Updates[0].(*tg.UpdateDraftMessage) - if !ok { - t.Fatalf("update = %T, want *tg.UpdateDraftMessage", updates.Updates[0]) - } - peer, ok := update.Peer.(*tg.PeerUser) - if !ok || peer.UserID != peerID { - t.Fatalf("draft peer = %#v, want peer user %d", update.Peer, peerID) - } - draft, ok := update.Draft.(*tg.DraftMessage) - if !ok || draft.Message != "1111" || len(draft.Entities) != 1 { - t.Fatalf("draft = %#v, want message and entities", update.Draft) - } - if len(updates.Users) != 2 { - t.Fatalf("users = %+v, want self and peer", updates.Users) - } -} - -func TestMessagesUpdateSavedReactionTagPersistsAndPushesRefresh(t *testing.T) { - const userID = int64(1000000001) - sessions := &captureSessions{} - r := New(Config{}, Deps{ - Channels: appchannels.NewService(memory.NewChannelStore()), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - - req := &tg.MessagesUpdateSavedReactionTagRequest{ - Reaction: &tg.ReactionEmoji{Emoticon: "\U0001f44d"}, - } - req.SetTitle("Fav") - ok, err := r.onMessagesUpdateSavedReactionTag(WithSessionID(WithUserID(context.Background(), userID), 55), req) - if err != nil || !ok { - t.Fatalf("update saved reaction tag = %v, %v, want true nil", ok, err) - } - - got, err := r.onMessagesGetSavedReactionTags(WithUserID(context.Background(), userID), &tg.MessagesGetSavedReactionTagsRequest{}) - if err != nil { - t.Fatalf("get saved reaction tags: %v", err) - } - page, ok := got.(*tg.MessagesSavedReactionTags) - if !ok || len(page.Tags) != 1 { - t.Fatalf("saved reaction tags = %T %+v, want one tag", got, got) - } - if emoji, ok := page.Tags[0].Reaction.(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" || page.Tags[0].Title != "Fav" { - t.Fatalf("saved reaction tag = %+v, want persisted thumb/Fav", page.Tags[0]) - } - - push := sessions.snapshot() - if push.userID != userID || push.sessionID != 55 || push.messageType != proto.MessageFromServer { - t.Fatalf("push = user %d exclude session %d type %v, want self/exclude/from_server", push.userID, push.sessionID, push.messageType) - } - updates, ok := push.message.(*tg.Updates) - if !ok { - t.Fatalf("pushed message = %T, want *tg.Updates", push.message) - } - if len(updates.Updates) != 1 { - t.Fatalf("updates = %+v, want one update", updates.Updates) - } - if _, ok := updates.Updates[0].(*tg.UpdateSavedReactionTags); !ok { - t.Fatalf("update = %T, want *tg.UpdateSavedReactionTags", updates.Updates[0]) - } -} - -func TestMessagesSaveDraftPersistsAndGetAllDraftsReturnsForumDraft(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - owner, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550001001", FirstName: "Owner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - channelStore := memory.NewChannelStore() - channelSvc := appchannels.NewService(channelStore) - created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Forum", - Forum: true, - Date: 1700000000, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - dialogStore := memory.NewDialogStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Channels: channelSvc, - Dialogs: appdialogs.NewService(dialogStore, channelStore), - }, zaptest.NewLogger(t), clock.System) - - reply := &tg.InputReplyToMessage{} - reply.SetTopMsgID(123) - ok, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{ - Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, - ReplyTo: reply, - Message: "topic draft", - Media: &tg.InputMediaWebPage{ - URL: "https://example.test/draft", - Optional: true, - }, - }) - if err != nil || !ok { - t.Fatalf("save forum draft = %v, %v", ok, err) - } - drafts, err := dialogStore.ListDrafts(ctx, owner.ID, 10) - if err != nil { - t.Fatalf("list stored drafts: %v", err) - } - if len(drafts) != 1 || drafts[0].TopMessageID != 123 || drafts[0].Message != "topic draft" || drafts[0].WebPage == nil { - t.Fatalf("stored drafts = %+v, want forum draft with webpage", drafts) - } - - got, err := r.onMessagesGetAllDrafts(WithUserID(ctx, owner.ID)) - if err != nil { - t.Fatalf("get all drafts: %v", err) - } - updates, ok := got.(*tg.Updates) - if !ok || len(updates.Updates) != 1 { - t.Fatalf("get all drafts = %T %+v, want one updates", got, got) - } - update, ok := updates.Updates[0].(*tg.UpdateDraftMessage) - if !ok { - t.Fatalf("draft update = %T", updates.Updates[0]) - } - if top, ok := update.GetTopMsgID(); !ok || top != 123 { - t.Fatalf("top_msg_id = %d/%v, want 123/true", top, ok) - } - draft, ok := update.Draft.(*tg.DraftMessage) - if !ok || draft.Message != "topic draft" { - t.Fatalf("draft = %#v, want message draft", update.Draft) - } - if media, ok := draft.Media.(*tg.InputMediaWebPage); !ok || media.URL != "https://example.test/draft" { - t.Fatalf("draft media = %#v, want webpage", draft.Media) - } - if len(updates.Chats) != 1 { - t.Fatalf("chats = %+v, want channel context", updates.Chats) - } -} - -func TestMessagesGetDialogsIncludesCloudDraft(t *testing.T) { - ctx := context.Background() - const ( - userID = int64(1000000001) - peerID = int64(1000000002) - ) - dialogStore := memory.NewDialogStore() - if err := dialogStore.Upsert(ctx, userID, domain.Dialog{ - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, - TopMessage: 10, - TopMessageDate: 1700000000, - }); err != nil { - t.Fatalf("upsert dialog: %v", err) - } - if err := dialogStore.SaveDraft(ctx, userID, domain.DialogDraft{ - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, - Date: 1700000100, - Message: "saved draft", - }); err != nil { - t.Fatalf("save draft: %v", err) - } - r := New(Config{}, Deps{ - Users: mapUsersService{users: map[int64]domain.User{ - userID: {ID: userID, FirstName: "Alice"}, - peerID: {ID: peerID, FirstName: "Bob"}, - }}, - Dialogs: appdialogs.NewService(dialogStore), - }, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesGetDialogsRequest{ - OffsetPeer: &tg.InputPeerEmpty{}, - Limit: 10, - } - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode get dialogs: %v", err) - } - got, err := r.Dispatch(WithUserID(ctx, userID), [8]byte{}, 0, &in) - if err != nil { - t.Fatalf("get dialogs: %v", err) - } - box, ok := got.(*tg.MessagesDialogsBox) - if !ok { - t.Fatalf("response = %T, want boxed messages.dialogs", got) - } - dialogs, ok := box.Dialogs.(*tg.MessagesDialogs) - if !ok || len(dialogs.Dialogs) != 1 { - t.Fatalf("dialogs = %T %+v, want one messages.dialogs", box.Dialogs, box.Dialogs) - } - dialog, ok := dialogs.Dialogs[0].(*tg.Dialog) - if !ok { - t.Fatalf("dialog = %T", dialogs.Dialogs[0]) - } - draft, ok := dialog.Draft.(*tg.DraftMessage) - if !ok || draft.Message != "saved draft" { - t.Fatalf("dialog draft = %#v, want saved draft", dialog.Draft) - } -} - -func TestMessagesClearAllDraftsClearsAndPushesEmptyUpdates(t *testing.T) { - const ( - userID = int64(1000000001) - peerID = int64(1000000002) - ) - dialogs := &captureDialogs{drafts: []domain.DialogDraft{{ - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, - Date: 1700000100, - Message: "draft", - }}} - sessions := &captureScopedSessions{captureSessions: &captureSessions{}} - r := New(Config{}, Deps{ - Dialogs: dialogs, - Sessions: sessions, - Users: mapUsersService{users: map[int64]domain.User{ - userID: {ID: userID, FirstName: "Alice"}, - peerID: {ID: peerID, FirstName: "Bob"}, - }}, - }, zaptest.NewLogger(t), clock.System) - ok, err := r.onMessagesClearAllDrafts(WithSessionID(WithUserID(context.Background(), userID), 9)) - if err != nil || !ok { - t.Fatalf("clear all drafts = %v, %v", ok, err) - } - got := sessions.snapshot() - updates, ok := got.message.(*tg.Updates) - if !ok || len(updates.Updates) != 1 { - t.Fatalf("pushed = %T %+v, want one update", got.message, got.message) - } - update, ok := updates.Updates[0].(*tg.UpdateDraftMessage) - if !ok { - t.Fatalf("update = %T", updates.Updates[0]) - } - if _, ok := update.Draft.(*tg.DraftMessageEmpty); !ok { - t.Fatalf("draft = %#v, want draftMessageEmpty", update.Draft) - } - if len(dialogs.drafts) != 0 { - t.Fatalf("capture drafts = %+v, want cleared", dialogs.drafts) - } -} - -func TestTGUserIncludesRecentlyStatusForTypingEligibility(t *testing.T) { - got := tgUser(domain.User{ID: 1000000002, FirstName: "Bob"}) - if _, ok := got.Status.(*tg.UserStatusRecently); !ok { - t.Fatalf("status = %T, want *tg.UserStatusRecently", got.Status) - } - - online := tgUser(domain.User{ - ID: 1000000002, - FirstName: "Bob", - Status: domain.UserStatus{Kind: domain.UserStatusOnline, Expires: 1700000300}, - }) - if status, ok := online.Status.(*tg.UserStatusOnline); !ok || status.Expires != 1700000300 { - t.Fatalf("online status = %#v, want userStatusOnline expires=1700000300", online.Status) - } - - offline := tgUser(domain.User{ - ID: 1000000002, - FirstName: "Bob", - Status: domain.UserStatus{Kind: domain.UserStatusOffline, WasOnline: 1700000000}, - }) - if status, ok := offline.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != 1700000000 { - t.Fatalf("offline status = %#v, want userStatusOffline was_online=1700000000", offline.Status) - } -} - -func TestRouterTGUserUsesPersistedLastSeen(t *testing.T) { - r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) - got := r.tgUser(domain.User{ID: 1000000002, FirstName: "Bob", LastSeenAt: 1700000000}) - if status, ok := got.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != 1700000000 { - t.Fatalf("status = %#v, want userStatusOffline was_online=1700000000", got.Status) - } -} - -func TestAccountUpdateStatusPushesPresenceToOnlineContacts(t *testing.T) { - ctx := context.Background() - alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} - bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} - contacts := memory.NewContactStore() - if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ - Contacts: []domain.Contact{{User: bob}}, - }); err != nil { - t.Fatalf("save contacts: %v", err) - } - sessions := &captureSessions{onlineUserIDs: []int64{bob.ID}} - r := New(Config{}, Deps{ - Contacts: appcontacts.NewService(contacts), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - callCtx := WithSessionID(WithUserID(ctx, alice.ID), 77) - - ok, err := r.onAccountUpdateStatus(callCtx, false) - if err != nil || !ok { - t.Fatalf("account.updateStatus online = %v, %v", ok, err) - } - gotPushes := sessions.pushedUserIDs() - if !reflect.DeepEqual(gotPushes, []int64{alice.ID, bob.ID}) { - t.Fatalf("pushed users = %+v, want self and online contact", gotPushes) - } - update := pushedUserStatus(t, sessions.snapshot().message) - if update.UserID != alice.ID { - t.Fatalf("status user = %d, want alice", update.UserID) - } - if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { - t.Fatalf("status = %#v, want online with future expires", update.Status) - } - - ok, err = r.onAccountUpdateStatus(callCtx, true) - if err != nil || !ok { - t.Fatalf("account.updateStatus offline = %v, %v", ok, err) - } - update = pushedUserStatus(t, sessions.snapshot().message) - if update.UserID != alice.ID { - t.Fatalf("offline status user = %d, want alice", update.UserID) - } - if status, ok := update.Status.(*tg.UserStatusOffline); !ok || status.WasOnline == 0 { - t.Fatalf("status = %#v, want offline with was_online", update.Status) - } -} - -func TestAccountUpdateStatusPersistsLastSeen(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"}) - if err != nil { - t.Fatalf("create alice: %v", err) - } - const now = 1700000200 - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - }, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) - - ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, alice.ID), 77), false) - if err != nil || !ok { - t.Fatalf("account.updateStatus online = %v, %v", ok, err) - } - stored, found, err := userStore.ByID(ctx, alice.ID) - if err != nil || !found { - t.Fatalf("load alice found=%v err=%v", found, err) - } - if stored.LastSeenAt != now { - t.Fatalf("last_seen_at after online = %d, want %d", stored.LastSeenAt, now) - } -} - -func TestAccountUpdateStatusPushesPresenceToOnlinePrivateDialogPeers(t *testing.T) { - ctx := context.Background() - alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} - bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob"} - dialogs := memory.NewDialogStore() - if err := dialogs.SaveList(ctx, alice.ID, domain.DialogList{ - Dialogs: []domain.Dialog{{ - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, - TopMessage: 10, - TopMessageDate: 1700000000, - }}, - Users: []domain.User{bob}, - }); err != nil { - t.Fatalf("save dialogs: %v", err) - } - sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}} - r := New(Config{}, Deps{ - Dialogs: appdialogs.NewService(dialogs), - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - - ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false) - if err != nil || !ok { - t.Fatalf("bob account.updateStatus online = %v, %v", ok, err) - } - gotPushes := sessions.pushedUserIDs() - if !reflect.DeepEqual(gotPushes, []int64{bob.ID, alice.ID}) { - t.Fatalf("pushed users = %+v, want self and online private dialog peer", gotPushes) - } - update := pushedUserStatus(t, sessions.snapshot().message) - if update.UserID != bob.ID { - t.Fatalf("status user = %d, want bob", update.UserID) - } - if status, ok := update.Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { - t.Fatalf("status = %#v, want online with future expires", update.Status) - } -} - -func TestContactsStatusesUsesPersistedLastSeen(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"}) - if err != nil { - t.Fatalf("create alice: %v", err) - } - bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob"}) - if err != nil { - t.Fatalf("create bob: %v", err) - } - const lastSeen = 1700000000 - if err := userStore.UpdateLastSeen(ctx, bob.ID, lastSeen); err != nil { - t.Fatalf("update last seen: %v", err) - } - contacts := memory.NewContactStore() - if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ - Contacts: []domain.Contact{{User: domain.User{ID: bob.ID, FirstName: "Old Bob", Contact: true}}}, - }); err != nil { - t.Fatalf("save contacts: %v", err) - } - r := New(Config{}, Deps{ - Contacts: appcontacts.NewService(contacts, userStore), - Users: appusers.NewService(userStore), - }, zaptest.NewLogger(t), clock.System) - - statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) - if err != nil { - t.Fatalf("contacts.getStatuses: %v", err) - } - if len(statuses) != 1 || statuses[0].UserID != bob.ID { - t.Fatalf("statuses = %+v, want bob only", statuses) - } - if status, ok := statuses[0].Status.(*tg.UserStatusOffline); !ok || status.WasOnline != lastSeen { - t.Fatalf("status = %#v, want userStatusOffline was_online=%d", statuses[0].Status, lastSeen) - } -} - -func TestContactsStatusesAndContactsUsePresence(t *testing.T) { - ctx := context.Background() - alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} - bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} - contacts := memory.NewContactStore() - if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ - Contacts: []domain.Contact{{User: bob}}, - }); err != nil { - t.Fatalf("save contacts: %v", err) - } - r := New(Config{}, Deps{ - Contacts: appcontacts.NewService(contacts), - }, zaptest.NewLogger(t), clock.System) - - if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), false); err != nil || !ok { - t.Fatalf("bob account.updateStatus = %v, %v", ok, err) - } - statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) - if err != nil { - t.Fatalf("contacts.getStatuses: %v", err) - } - if len(statuses) != 1 || statuses[0].UserID != bob.ID { - t.Fatalf("statuses = %+v, want bob only", statuses) - } - if _, ok := statuses[0].Status.(*tg.UserStatusOnline); !ok { - t.Fatalf("contacts.getStatuses status = %T, want online", statuses[0].Status) - } - - contactsRes, err := r.onContactsGetContacts(WithUserID(ctx, alice.ID), 0) - if err != nil { - t.Fatalf("contacts.getContacts: %v", err) - } - full, ok := contactsRes.(*tg.ContactsContacts) - if !ok || len(full.Users) != 1 { - t.Fatalf("contacts result = %T %+v, want one full contacts response", contactsRes, contactsRes) - } - user, ok := full.Users[0].(*tg.User) - if !ok || user.ID != bob.ID { - t.Fatalf("contacts user = %T %+v, want bob", full.Users[0], full.Users[0]) - } - if _, ok := user.Status.(*tg.UserStatusOnline); !ok { - t.Fatalf("contacts user status = %T, want online", user.Status) - } -} - -func TestContactsAcceptContactReturnsSettingsAndReset(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - contactsStore := memory.NewContactStore() - alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice", LastName: "A"}) - if err != nil { - t.Fatalf("create alice: %v", err) - } - bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob", LastName: "B"}) - if err != nil { - t.Fatalf("create bob: %v", err) - } - contactsSvc := appcontacts.NewService(contactsStore, userStore) - if _, err := contactsSvc.AddContact(ctx, alice.ID, domain.ContactInput{ - ContactUserID: bob.ID, - Phone: bob.Phone, - FirstName: "Bobby", - LastName: "Remark", - }); err != nil { - t.Fatalf("alice add bob: %v", err) - } - updatesSvc := &captureUpdates{state: domain.UpdateState{Pts: 10, Date: 1700000400}} - r := New(Config{}, Deps{ - Contacts: contactsSvc, - Users: appusers.NewService(userStore, appusers.WithContactStore(contactsStore)), - Updates: updatesSvc, - }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000400, 0)}) - - out, err := r.onContactsAcceptContact(WithUserID(ctx, alice.ID), &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash}) - if err != nil { - t.Fatalf("contacts.acceptContact: %v", err) - } - got, ok := out.(*tg.Updates) - if !ok { - t.Fatalf("updates = %T, want *tg.Updates", out) - } - if len(got.Updates) != 2 { - t.Fatalf("updates = %+v, want peer settings + contacts reset", got.Updates) - } - settings, ok := got.Updates[0].(*tg.UpdatePeerSettings) - if !ok { - t.Fatalf("update[0] = %T, want UpdatePeerSettings", got.Updates[0]) - } - if settings.Settings.ShareContact || settings.Settings.AddContact { - t.Fatalf("peer settings = %+v, want share/add false", settings.Settings) - } - if _, ok := got.Updates[1].(*tg.UpdateContactsReset); !ok { - t.Fatalf("update[1] = %T, want UpdateContactsReset", got.Updates[1]) - } - if len(updatesSvc.events) != 4 { - t.Fatalf("recorded events = %+v, want current peer/reset and target peer/reset", updatesSvc.events) - } - if updatesSvc.events[0].UserID != alice.ID || updatesSvc.events[0].Settings.ShareContact { - t.Fatalf("current peer settings event = %+v, want alice share=false", updatesSvc.events[0]) - } - if updatesSvc.events[2].UserID != bob.ID || updatesSvc.events[2].Settings.ShareContact { - t.Fatalf("target peer settings event = %+v, want bob share=false", updatesSvc.events[2]) - } - reverse, found, err := contactsStore.Get(ctx, bob.ID, alice.ID) - if err != nil || !found { - t.Fatalf("bob contact alice found=%v err=%v", found, err) - } - if reverse.Phone != alice.Phone || !reverse.Mutual { - t.Fatalf("bob contact alice = %+v, want shared phone and mutual", reverse) - } -} - -func TestContactsStatusesUsesOnlineSessionFallback(t *testing.T) { - ctx := context.Background() - alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} - bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} - contacts := memory.NewContactStore() - if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ - Contacts: []domain.Contact{{User: bob}}, - }); err != nil { - t.Fatalf("save contacts: %v", err) - } - r := New(Config{}, Deps{ - Contacts: appcontacts.NewService(contacts), - Sessions: &captureSessions{onlineUserIDs: []int64{bob.ID}}, - }, zaptest.NewLogger(t), clock.System) - - statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) - if err != nil { - t.Fatalf("contacts.getStatuses: %v", err) - } - if len(statuses) != 1 || statuses[0].UserID != bob.ID { - t.Fatalf("statuses = %+v, want bob only", statuses) - } - if status, ok := statuses[0].Status.(*tg.UserStatusOnline); !ok || status.Expires <= int(time.Now().Unix()) { - t.Fatalf("fallback status = %#v, want online from active session", statuses[0].Status) - } -} - -func TestContactsStatusesHonorsExplicitOffline(t *testing.T) { - ctx := context.Background() - alice := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Alice"} - bob := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Bob", Contact: true} - contacts := memory.NewContactStore() - if err := contacts.SaveList(ctx, alice.ID, domain.ContactList{ - Contacts: []domain.Contact{{User: bob}}, - }); err != nil { - t.Fatalf("save contacts: %v", err) - } - r := New(Config{}, Deps{ - Contacts: appcontacts.NewService(contacts), - Sessions: &captureSessions{onlineUserIDs: []int64{bob.ID}}, - }, zaptest.NewLogger(t), clock.System) - if ok, err := r.onAccountUpdateStatus(WithSessionID(WithUserID(ctx, bob.ID), 22), true); err != nil || !ok { - t.Fatalf("bob account.updateStatus offline = %v, %v", ok, err) - } - - statuses, err := r.onContactsGetStatuses(WithUserID(ctx, alice.ID)) - if err != nil { - t.Fatalf("contacts.getStatuses: %v", err) - } - if len(statuses) != 1 || statuses[0].UserID != bob.ID { - t.Fatalf("statuses = %+v, want bob only", statuses) - } - if status, ok := statuses[0].Status.(*tg.UserStatusOffline); !ok || status.WasOnline == 0 { - t.Fatalf("status = %#v, want explicit offline", statuses[0].Status) - } -} - -func TestSessionOfflinePersistsLastSeenAndPushes(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "1001", FirstName: "Alice"}) - if err != nil { - t.Fatalf("create alice: %v", err) - } - bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "1002", FirstName: "Bob"}) - if err != nil { - t.Fatalf("create bob: %v", err) - } - contacts := memory.NewContactStore() - if err := contacts.SaveList(ctx, bob.ID, domain.ContactList{Contacts: []domain.Contact{{User: alice}}}); err != nil { - t.Fatalf("save bob contacts: %v", err) - } - const now = 1700000300 - sessions := &captureSessions{onlineUserIDs: []int64{alice.ID}} - r := New(Config{}, Deps{ - Contacts: appcontacts.NewService(contacts, userStore), - Users: appusers.NewService(userStore), - Sessions: sessions, - }, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) - - r.SessionOffline([8]byte{1, 2, 3}, 22, bob.ID, true) - - stored, found, err := userStore.ByID(ctx, bob.ID) - if err != nil || !found { - t.Fatalf("load bob found=%v err=%v", found, err) - } - if stored.LastSeenAt != now { - t.Fatalf("last_seen_at = %d, want %d", stored.LastSeenAt, now) - } - if got := sessions.pushedUserIDs(); !reflect.DeepEqual(got, []int64{bob.ID, alice.ID}) { - t.Fatalf("pushed users = %+v, want bob self and online contact alice", got) - } - update := pushedUserStatus(t, sessions.snapshot().message) - if update.UserID != bob.ID { - t.Fatalf("status user = %d, want bob", update.UserID) - } - if status, ok := update.Status.(*tg.UserStatusOffline); !ok || status.WasOnline != now { - t.Fatalf("status = %#v, want offline was_online=%d", update.Status, now) - } -} - -func TestAccountUpdateStatusKeepsUserOnlineUntilAllSessionsOffline(t *testing.T) { - const userID = int64(1000000001) - r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) - session1 := WithSessionID(WithUserID(context.Background(), userID), 1) - session2 := WithSessionID(WithUserID(context.Background(), userID), 2) - - if ok, err := r.onAccountUpdateStatus(session1, false); err != nil || !ok { - t.Fatalf("session1 online = %v, %v", ok, err) - } - if ok, err := r.onAccountUpdateStatus(session2, false); err != nil || !ok { - t.Fatalf("session2 online = %v, %v", ok, err) - } - if ok, err := r.onAccountUpdateStatus(session1, true); err != nil || !ok { - t.Fatalf("session1 offline = %v, %v", ok, err) - } - if status := r.userPresenceStatus(userID); status.Kind != domain.UserStatusOnline { - t.Fatalf("aggregate after one offline = %+v, want online", status) - } - if ok, err := r.onAccountUpdateStatus(session2, true); err != nil || !ok { - t.Fatalf("session2 offline = %v, %v", ok, err) - } - if status := r.userPresenceStatus(userID); status.Kind != domain.UserStatusOffline || status.WasOnline == 0 { - t.Fatalf("aggregate after all offline = %+v, want offline", status) - } -} - -func pushedUserStatus(t *testing.T, msg bin.Encoder) *tg.UpdateUserStatus { - t.Helper() - updates, ok := msg.(*tg.Updates) - if !ok || len(updates.Updates) != 1 { - t.Fatalf("pushed message = %T %+v, want one update", msg, msg) - } - update, ok := updates.Updates[0].(*tg.UpdateUserStatus) - if !ok { - t.Fatalf("update = %T, want *tg.UpdateUserStatus", updates.Updates[0]) - } - return update -} - -type fixedClock struct { - now time.Time -} - -func (c fixedClock) Now() time.Time { - return c.now -} - -func (c fixedClock) Timer(d time.Duration) clock.Timer { - return clock.System.Timer(d) -} - -func (c fixedClock) Ticker(d time.Duration) clock.Ticker { - return clock.System.Ticker(d) -} - -func TestMessagesSendReactionPrivatePeerReturnsReactionUpdate(t *testing.T) { - const ( - userID = int64(1000000001) - peerID = int64(1000000002) - now = int64(1700000200) - ) - messages := &captureMessages{} - r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) - req := &tg.MessagesSendReactionRequest{ - Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, - MsgID: 7, - Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, - Big: true, - } - req.SetReaction(req.Reaction) - req.SetAddToRecent(true) - - updates, err := r.onMessagesSendReaction(WithUserID(context.Background(), userID), req) - if err != nil { - t.Fatalf("messages.sendReaction private: %v", err) - } - if messages.setReactionReq.UserID != userID || messages.setReactionReq.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: peerID}) || messages.setReactionReq.MessageID != req.MsgID || !messages.setReactionReq.Big || !messages.setReactionReq.AddToRecent { - t.Fatalf("set reaction req = %+v, want private peer/message context", messages.setReactionReq) - } - got := updates.(*tg.Updates).Updates - if len(got) != 1 { - t.Fatalf("updates = %+v, want one reaction update", got) - } - update, ok := got[0].(*tg.UpdateMessageReactions) - if !ok { - t.Fatalf("update = %T, want *tg.UpdateMessageReactions", got[0]) - } - peer, ok := update.Peer.(*tg.PeerUser) - if !ok || peer.UserID != peerID || update.MsgID != req.MsgID { - t.Fatalf("update peer/msg = %+v/%d, want peer %d msg %d", update.Peer, update.MsgID, peerID, req.MsgID) - } - if len(update.Reactions.Results) != 1 || update.Reactions.Results[0].Count != 1 || update.Reactions.Results[0].ChosenOrder != 1 { - t.Fatalf("reaction results = %+v, want one chosen reaction", update.Reactions.Results) - } -} - -func TestMessagesSendReactionPrivatePushesViewerLocalMessageID(t *testing.T) { - const ( - aliceID = int64(1000000001) - bobID = int64(1000000002) - now = int64(1700000200) - ) - reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"} - aliceReactions := domain.ChannelMessageReactions{ - CanSeeList: true, - Results: []domain.ChannelMessageReactionCount{{ - Reaction: reaction, - Count: 1, - }}, - Recent: []domain.ChannelMessagePeerReaction{{ - UserID: bobID, - Reaction: reaction, - Date: int(now), - }}, - } - bobReactions := domain.ChannelMessageReactions{ - CanSeeList: true, - Results: []domain.ChannelMessageReactionCount{{ - Reaction: reaction, - Count: 1, - ChosenOrder: 1, - }}, - Recent: []domain.ChannelMessagePeerReaction{{ - UserID: bobID, - Reaction: reaction, - My: true, - ChosenOrder: 1, - Date: int(now), - }}, - } - messages := &captureMessages{ - setReactionRes: domain.PrivateMessageReactionsResult{ - Messages: []domain.Message{ - { - ID: 68, - UID: 7001, - OwnerUserID: aliceID, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bobID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, - Date: int(now), - Reactions: &aliceReactions, - }, - { - ID: 64, - UID: 7001, - OwnerUserID: bobID, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, - Date: int(now), - Reactions: &bobReactions, - }, - }, - Reactions: bobReactions, - }, - } - sessions := &captureSessions{} - r := New(Config{}, Deps{Messages: messages, Sessions: sessions}, zaptest.NewLogger(t), fixedClock{now: time.Unix(now, 0)}) - req := &tg.MessagesSendReactionRequest{ - Peer: &tg.InputPeerUser{UserID: aliceID, AccessHash: 11}, - MsgID: 64, - Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, - } - req.SetReaction(req.Reaction) - - updates, err := r.onMessagesSendReaction(WithSessionID(WithUserID(context.Background(), bobID), 77), req) - if err != nil { - t.Fatalf("messages.sendReaction private: %v", err) - } - self := updates.(*tg.Updates).Updates[0].(*tg.UpdateMessageReactions) - if peer, ok := self.Peer.(*tg.PeerUser); !ok || peer.UserID != aliceID || self.MsgID != 64 { - t.Fatalf("self update peer/msg = %#v/%d, want alice/msg64", self.Peer, self.MsgID) - } - if got := sessions.pushedUserIDs(); len(got) != 2 || got[0] != bobID || got[1] != aliceID { - t.Fatalf("pushed users = %+v, want bob then alice", got) - } - pushed := sessions.snapshot() - if pushed.userID != aliceID || pushed.sessionID != 77 || pushed.messageType != proto.MessageFromServer { - t.Fatalf("last push = user %d session %d type %v, want alice/exclude bob/from_server", pushed.userID, pushed.sessionID, pushed.messageType) - } - pushedUpdates, ok := pushed.message.(*tg.Updates) - if !ok || len(pushedUpdates.Updates) != 1 { - t.Fatalf("pushed message = %T %+v, want one updates container", pushed.message, pushed.message) - } - other, ok := pushedUpdates.Updates[0].(*tg.UpdateMessageReactions) - if !ok { - t.Fatalf("pushed update = %T, want *tg.UpdateMessageReactions", pushedUpdates.Updates[0]) - } - peer, ok := other.Peer.(*tg.PeerUser) - if !ok || peer.UserID != bobID || other.MsgID != 68 { - t.Fatalf("pushed update peer/msg = %#v/%d, want bob/msg68", other.Peer, other.MsgID) - } - if len(other.Reactions.Results) != 1 || other.Reactions.Results[0].Count != 1 || other.Reactions.Results[0].ChosenOrder != 0 { - t.Fatalf("pushed reaction results = %+v, want one non-chosen reaction", other.Reactions.Results) - } -} - -func TestMessagesSendMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) { - sender := domain.User{ID: 1000000001, AccessHash: 11, FirstName: "Sender"} - recipient := domain.User{ID: 1000000002, AccessHash: 22, FirstName: "Recipient"} - messages := &captureMessages{} - metrics := &captureRPCMetrics{} - r := New(Config{}, Deps{ - Messages: messages, - Users: mapUsersService{users: map[int64]domain.User{sender.ID: sender, recipient.ID: recipient}}, - Metrics: metrics, - }, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash}, - Message: "hello", - RandomID: 123456, - Entities: []tg.MessageEntityClass{ - &tg.MessageEntityBold{Offset: 0, Length: 5}, - }, - } - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), sender.ID), [8]byte{}, 77, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - box, ok := enc.(*tg.UpdatesBox) - if !ok { - t.Fatalf("response = %T, want *tg.UpdatesBox", enc) - } - got, ok := box.Updates.(*tg.Updates) - if !ok { - t.Fatalf("boxed response = %T, want *tg.Updates", box.Updates) - } - if messages.sendUserID != sender.ID || messages.sendReq.SenderUserID != sender.ID || messages.sendReq.RecipientUserID != recipient.ID || messages.sendReq.OriginSessionID != 77 { - t.Fatalf("send context = user %d req %+v, want sender/recipient/session", messages.sendUserID, messages.sendReq) - } - if len(messages.sendReq.Entities) != 1 || messages.sendReq.Entities[0].Type != domain.MessageEntityBold { - t.Fatalf("entities = %+v, want bold entity converted to domain", messages.sendReq.Entities) - } - if len(got.Updates) != 2 { - t.Fatalf("updates = %+v, want message id + new message", got.Updates) - } - if id, ok := got.Updates[0].(*tg.UpdateMessageID); !ok || id.ID != 1 || id.RandomID != req.RandomID { - t.Fatalf("update id = %#v, want id=1 random_id=%d", got.Updates[0], req.RandomID) - } - newMsg, ok := got.Updates[1].(*tg.UpdateNewMessage) - if !ok || newMsg.Pts != 1 || newMsg.PtsCount != 1 { - t.Fatalf("new message update = %#v, want pts=1 pts_count=1", got.Updates[1]) - } - msg, ok := newMsg.Message.(*tg.Message) - if !ok || !msg.Out || msg.PeerID.(*tg.PeerUser).UserID != recipient.ID || msg.Message != req.Message { - t.Fatalf("message = %#v, want outgoing private text to recipient", newMsg.Message) - } - if metrics.messageSend != 1 || metrics.messageSendErr != nil { - t.Fatalf("metrics send=%d err=%v, want one successful send", metrics.messageSend, metrics.messageSendErr) - } -} - -func TestContactsBlockGetBlockedAndUnblockRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009001", FirstName: "Alice"}) - if err != nil { - t.Fatalf("create alice: %v", err) - } - bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550009002", FirstName: "Bob"}) - if err != nil { - t.Fatalf("create bob: %v", err) - } - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Contacts: appcontacts.NewService(memory.NewContactStore(), userStore), - }, zaptest.NewLogger(t), clock.System) - - ok, err := r.onContactsBlock(WithUserID(ctx, bob.ID), &tg.ContactsBlockRequest{ - ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, - }) - if err != nil || !ok { - t.Fatalf("contacts.block = %v, %v", ok, err) - } - blocked, err := r.onContactsGetBlocked(WithUserID(ctx, bob.ID), &tg.ContactsGetBlockedRequest{Limit: 10}) - if err != nil { - t.Fatalf("contacts.getBlocked: %v", err) - } - full, ok := blocked.(*tg.ContactsBlocked) - if !ok || len(full.Blocked) != 1 || len(full.Users) != 1 { - t.Fatalf("blocked = %T %+v, want one blocked user", blocked, blocked) - } - if peer, ok := full.Blocked[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != alice.ID { - t.Fatalf("blocked peer = %#v, want alice", full.Blocked[0].PeerID) - } - if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID { - t.Fatalf("blocked user = %#v, want alice", full.Users[0]) - } - - ok, err = r.onContactsUnblock(WithUserID(ctx, bob.ID), &tg.ContactsUnblockRequest{ - ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, - }) - if err != nil || !ok { - t.Fatalf("contacts.unblock = %v, %v", ok, err) - } - blocked, err = r.onContactsGetBlocked(WithUserID(ctx, bob.ID), &tg.ContactsGetBlockedRequest{Limit: 10}) - if err != nil { - t.Fatalf("contacts.getBlocked after unblock: %v", err) - } - if full, ok := blocked.(*tg.ContactsBlocked); !ok || len(full.Blocked) != 0 { - t.Fatalf("blocked after unblock = %T %+v, want empty contacts.blocked", blocked, blocked) - } -} - -func TestMessagesPrivateBlockPreventsRecipientInboxAndRevokeRPC(t *testing.T) { - ctx := context.Background() - userStore := memory.NewUserStore() - alice, err := userStore.Create(ctx, domain.User{AccessHash: 11, Phone: "15550009101", FirstName: "Alice"}) - if err != nil { - t.Fatalf("create alice: %v", err) - } - bob, err := userStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550009102", FirstName: "Bob"}) - if err != nil { - t.Fatalf("create bob: %v", err) - } - dialogs := memory.NewDialogStore() - messageStore := memory.NewMessageStore(dialogs) - contactStore := memory.NewContactStore() - r := New(Config{}, Deps{ - Users: appusers.NewService(userStore), - Contacts: appcontacts.NewService(contactStore, userStore), - Messages: appmessages.NewService(messageStore, dialogs), - Dialogs: appdialogs.NewService(dialogs), - }, zaptest.NewLogger(t), clock.System) - - delivered, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, - Message: "before block", - RandomID: 91001, - }) - if err != nil { - t.Fatalf("send before block: %v", err) - } - deliveredUpdates := delivered.(*tg.Updates) - deliveredMsg := deliveredUpdates.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) - - if ok, err := r.onContactsBlock(WithUserID(ctx, bob.ID), &tg.ContactsBlockRequest{ - ID: &tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}, - }); err != nil || !ok { - t.Fatalf("bob block alice = %v, %v", ok, err) - } - blockedSend, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash}, - Message: "after block", - RandomID: 91002, - }) - if err != nil { - t.Fatalf("send after block: %v", err) - } - blockedUpdates := blockedSend.(*tg.Updates) - blockedMsg := blockedUpdates.Updates[1].(*tg.UpdateNewMessage).Message.(*tg.Message) - if blockedMsg.ID == 0 || !blockedMsg.Out { - t.Fatalf("blocked sender update = %#v, want outgoing sender message", blockedMsg) - } - bobHistory, err := messageStore.ListByUser(ctx, bob.ID, domain.MessageFilter{ - HasPeer: true, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, - Limit: 10, - }) - if err != nil { - t.Fatalf("bob history: %v", err) - } - if len(bobHistory.Messages) != 1 || bobHistory.Messages[0].Body != "before block" { - t.Fatalf("bob history = %+v, want only pre-block delivered message", bobHistory.Messages) - } - aliceHistory, err := messageStore.ListByUser(ctx, alice.ID, domain.MessageFilter{ - HasPeer: true, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, - Limit: 10, - }) - if err != nil { - t.Fatalf("alice history: %v", err) - } - if len(aliceHistory.Messages) != 2 { - t.Fatalf("alice history len = %d, want delivered + sender-only blocked message", len(aliceHistory.Messages)) - } - deleteReq := &tg.MessagesDeleteMessagesRequest{ID: []int{deliveredMsg.ID}} - deleteReq.SetRevoke(true) - if _, err := r.onMessagesDeleteMessages(WithUserID(ctx, alice.ID), deleteReq); err == nil || !strings.Contains(err.Error(), "DELETE_MESSAGES_FORBIDDEN") { - t.Fatalf("revoke after block err = %v, want DELETE_MESSAGES_FORBIDDEN", err) - } -} - -func TestMessagesSendMessageSupportsReplyAndFlags(t *testing.T) { - const ( - senderID = int64(1000000001) - recipientID = int64(1000000002) - ) - messages := &captureMessages{} - r := New(Config{}, Deps{ - Messages: messages, - Users: mapUsersService{users: map[int64]domain.User{senderID: {ID: senderID, FirstName: "Sender"}, recipientID: {ID: recipientID, FirstName: "Recipient"}}}, - }, zaptest.NewLogger(t), clock.System) - reply := &tg.InputReplyToMessage{ReplyToMsgID: 7} - reply.SetQuoteText("hello") - reply.SetQuoteOffset(1) - req := &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerUser{UserID: recipientID}, - Message: "reply", - RandomID: 456, - Silent: true, - } - req.SetNoforwards(true) - req.SetReplyTo(reply) - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), senderID), [8]byte{}, 88, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - if messages.sendReq.ReplyTo == nil || messages.sendReq.ReplyTo.MessageID != 7 || messages.sendReq.ReplyTo.Peer.ID != recipientID || messages.sendReq.ReplyTo.QuoteText != "hello" { - t.Fatalf("reply request = %+v, want reply metadata", messages.sendReq.ReplyTo) - } - if !messages.sendReq.Silent || !messages.sendReq.NoForwards { - t.Fatalf("send flags silent=%v noforwards=%v, want true/true", messages.sendReq.Silent, messages.sendReq.NoForwards) - } - box, ok := enc.(*tg.UpdatesBox) - if !ok { - t.Fatalf("response = %T, want *tg.UpdatesBox", enc) - } - got := box.Updates.(*tg.Updates) - newMsg := got.Updates[1].(*tg.UpdateNewMessage) - msg := newMsg.Message.(*tg.Message) - if !msg.Silent || !msg.Noforwards { - t.Fatalf("message flags silent=%v noforwards=%v, want true/true", msg.Silent, msg.Noforwards) - } - header, ok := msg.ReplyTo.(*tg.MessageReplyHeader) - if !ok || header.ReplyToMsgID != 7 { - t.Fatalf("reply header = %#v, want msg id 7", msg.ReplyTo) - } -} - -func TestMessagesSendMessageRejectsHugeReplyQuoteOffset(t *testing.T) { - const ( - senderID = int64(1000000001) - recipientID = int64(1000000002) - ) - messages := &captureMessages{} - r := New(Config{}, Deps{ - Messages: messages, - Users: mapUsersService{users: map[int64]domain.User{recipientID: {ID: recipientID, FirstName: "Recipient"}}}, - }, zaptest.NewLogger(t), clock.System) - reply := &tg.InputReplyToMessage{ReplyToMsgID: 7} - reply.SetQuoteText("hello") - reply.SetQuoteOffset(domain.MaxMessageReplyQuoteOffset + 1) - req := &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerUser{UserID: recipientID}, - Message: "reply", - RandomID: 457, - } - req.SetReplyTo(reply) - - if _, err := r.onMessagesSendMessage(WithUserID(context.Background(), senderID), req); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { - t.Fatalf("huge quote offset err = %v, want REPLY_MESSAGE_ID_INVALID", err) - } - if messages.sendReq.RandomID != 0 { - t.Fatalf("send request reached service: %+v", messages.sendReq) - } -} - -func TestMessageReplyFromInputUnsupportedShapesReturnExplicitErrors(t *testing.T) { - const userID = int64(1000000001) - ctx := WithUserID(context.Background(), userID) - r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) - peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 1000000002} - withReplyMsg := func(update func(*tg.InputReplyToMessage)) *tg.InputReplyToMessage { - reply := &tg.InputReplyToMessage{ReplyToMsgID: 7} - update(reply) - return reply - } - cases := []struct { - name string - input tg.InputReplyToClass - want string - }{ - { - name: "story", - input: &tg.InputReplyToStory{Peer: &tg.InputPeerUser{UserID: 1000000003}, StoryID: 1}, - want: "STORY_ID_INVALID", - }, - { - name: "monoforum constructor", - input: &tg.InputReplyToMonoForum{MonoforumPeerID: &tg.InputPeerChannel{ChannelID: 1000000004}}, - want: "REPLY_TO_MONOFORUM_PEER_INVALID", - }, - { - name: "monoforum field", - input: withReplyMsg(func(reply *tg.InputReplyToMessage) { - reply.SetMonoforumPeerID(&tg.InputPeerChannel{ChannelID: 1000000004}) - }), - want: "REPLY_TO_MONOFORUM_PEER_INVALID", - }, - { - name: "todo item", - input: withReplyMsg(func(reply *tg.InputReplyToMessage) { - reply.SetTodoItemID(1) - }), - want: "REPLY_MESSAGE_ID_INVALID", - }, - { - name: "poll option", - input: withReplyMsg(func(reply *tg.InputReplyToMessage) { - reply.SetPollOption([]byte{1}) - }), - want: "POLL_OPTION_INVALID", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if _, err := r.messageReplyFromInput(ctx, userID, peer, tc.input); err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("reply err = %v, want %s", err, tc.want) - } - }) - } -} - -func TestMessagesSendMessageUnsupportedOptionErrors(t *testing.T) { - const ( - senderID = int64(1000000001) - recipientID = int64(1000000002) - ) - ctx := WithUserID(context.Background(), senderID) - r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) - base := func() *tg.MessagesSendMessageRequest { - return &tg.MessagesSendMessageRequest{ - Peer: &tg.InputPeerUser{UserID: recipientID}, - Message: "hello", - RandomID: 456, - } - } - suggested := func() tg.SuggestedPost { - post := tg.SuggestedPost{} - post.SetAccepted(true) - return post - } - cases := []struct { - name string - req *tg.MessagesSendMessageRequest - want string - }{ - { - name: "reply markup", - req: func() *tg.MessagesSendMessageRequest { - req := base() - req.SetReplyMarkup(&tg.ReplyKeyboardHide{}) - return req - }(), - want: "REPLY_MARKUP_INVALID", - }, - { - name: "quick reply", - req: func() *tg.MessagesSendMessageRequest { - req := base() - req.SetQuickReplyShortcut(&tg.InputQuickReplyShortcut{Shortcut: "hello"}) - return req - }(), - want: "SHORTCUT_INVALID", - }, - { - name: "effect", - req: func() *tg.MessagesSendMessageRequest { - req := base() - req.SetEffect(1) - return req - }(), - want: "EFFECT_ID_INVALID", - }, - { - name: "negative paid stars", - req: func() *tg.MessagesSendMessageRequest { - req := base() - req.SetAllowPaidStars(-1) - return req - }(), - want: "STARS_AMOUNT_INVALID", - }, - { - name: "paid stars", - req: func() *tg.MessagesSendMessageRequest { - req := base() - req.SetAllowPaidStars(1) - return req - }(), - want: "PAYMENT_UNSUPPORTED", - }, - { - name: "paid floodskip", - req: func() *tg.MessagesSendMessageRequest { - req := base() - req.SetAllowPaidFloodskip(true) - return req - }(), - want: "PAYMENT_UNSUPPORTED", - }, - { - name: "suggested post", - req: func() *tg.MessagesSendMessageRequest { - req := base() - req.SetSuggestedPost(suggested()) - return req - }(), - want: "SUGGESTED_POST_PEER_INVALID", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if _, err := r.onMessagesSendMessage(ctx, tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("send err = %v, want %s", err, tc.want) - } - }) - } -} - -func TestMessagesForwardMessagesRecordsRequestAndReturnsUpdates(t *testing.T) { - const ( - ownerID = int64(1000000001) - fromID = int64(1000000002) - toID = int64(1000000003) - ) - messages := &captureMessages{} - r := New(Config{}, Deps{ - Messages: messages, - Users: mapUsersService{users: map[int64]domain.User{ - ownerID: {ID: ownerID, FirstName: "Owner"}, - fromID: {ID: fromID, FirstName: "From"}, - toID: {ID: toID, FirstName: "To"}, - }}, - }, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesForwardMessagesRequest{ - FromPeer: &tg.InputPeerUser{UserID: fromID}, - ToPeer: &tg.InputPeerUser{UserID: toID}, - ID: []int{3, 4}, - RandomID: []int64{1001, 1002}, - Silent: true, - Noforwards: true, - } - replyTo := &tg.InputReplyToMessage{ReplyToMsgID: 9} - replyTo.SetQuoteText("target") - req.SetReplyTo(replyTo) - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), ownerID), [8]byte{}, 99, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - if messages.forwardUserID != ownerID || messages.forwardReq.FromPeer.ID != fromID || messages.forwardReq.ToUserID != toID || len(messages.forwardReq.MessageIDs) != 2 || messages.forwardReq.OriginSessionID != 99 { - t.Fatalf("forward request = user %d %+v, want owner/from/to/session", messages.forwardUserID, messages.forwardReq) - } - if messages.forwardReq.ReplyTo == nil || messages.forwardReq.ReplyTo.MessageID != 9 || messages.forwardReq.ReplyTo.Peer.ID != toID || messages.forwardReq.ReplyTo.QuoteText != "target" { - t.Fatalf("forward reply = %+v, want target peer reply metadata", messages.forwardReq.ReplyTo) - } - box, ok := enc.(*tg.UpdatesBox) - if !ok { - t.Fatalf("response = %T, want *tg.UpdatesBox", enc) - } - got := box.Updates.(*tg.Updates) - if len(got.Updates) != 4 { - t.Fatalf("updates = %+v, want two message ids and two new messages", got.Updates) - } - if id, ok := got.Updates[0].(*tg.UpdateMessageID); !ok || id.RandomID != 1001 { - t.Fatalf("first update = %#v, want updateMessageID random 1001", got.Updates[0]) - } - newMsg := got.Updates[1].(*tg.UpdateNewMessage) - msg := newMsg.Message.(*tg.Message) - if msg.FwdFrom.Date == 0 || !msg.Silent || !msg.Noforwards { - t.Fatalf("forwarded message = %#v, want fwd header and flags", msg) - } - if header, ok := msg.ReplyTo.(*tg.MessageReplyHeader); !ok || header.ReplyToMsgID != 9 { - t.Fatalf("forwarded reply = %#v, want reply header id=9", msg.ReplyTo) - } - hasForwardAuthor := false - for _, user := range got.Users { - if u, ok := user.(*tg.User); ok && u.ID == fromID { - hasForwardAuthor = true - break - } - } - if !hasForwardAuthor { - t.Fatalf("forward users = %+v, want original author %d for fwd_from", got.Users, fromID) - } -} - -func TestMessagesForwardMessagesUnsupportedOptionErrors(t *testing.T) { - const ownerID = int64(1000000001) - ctx := WithUserID(context.Background(), ownerID) - r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) - base := func() *tg.MessagesForwardMessagesRequest { - return &tg.MessagesForwardMessagesRequest{ - FromPeer: &tg.InputPeerUser{UserID: 1000000002}, - ToPeer: &tg.InputPeerUser{UserID: 1000000003}, - ID: []int{3}, - RandomID: []int64{1001}, - } - } - suggested := func() tg.SuggestedPost { - post := tg.SuggestedPost{} - post.SetAccepted(true) - return post - } - cases := []struct { - name string - req *tg.MessagesForwardMessagesRequest - want string - }{ - { - name: "quick reply", - req: func() *tg.MessagesForwardMessagesRequest { - req := base() - req.SetQuickReplyShortcut(&tg.InputQuickReplyShortcut{Shortcut: "hello"}) - return req - }(), - want: "SHORTCUT_INVALID", - }, - { - name: "effect", - req: func() *tg.MessagesForwardMessagesRequest { - req := base() - req.SetEffect(1) - return req - }(), - want: "EFFECT_ID_INVALID", - }, - { - name: "video timestamp without media model", - req: func() *tg.MessagesForwardMessagesRequest { - req := base() - req.SetVideoTimestamp(10) - return req - }(), - want: "MEDIA_INVALID", - }, - { - name: "negative paid stars", - req: func() *tg.MessagesForwardMessagesRequest { - req := base() - req.SetAllowPaidStars(-1) - return req - }(), - want: "STARS_AMOUNT_INVALID", - }, - { - name: "paid floodskip", - req: func() *tg.MessagesForwardMessagesRequest { - req := base() - req.SetAllowPaidFloodskip(true) - return req - }(), - want: "PAYMENT_UNSUPPORTED", - }, - { - name: "suggested post", - req: func() *tg.MessagesForwardMessagesRequest { - req := base() - req.SetSuggestedPost(suggested()) - return req - }(), - want: "SUGGESTED_POST_PEER_INVALID", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if _, err := r.onMessagesForwardMessages(ctx, tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("forward err = %v, want %s", err, tc.want) - } - }) - } -} - -func TestMessagesEditMessageReturnsUpdateAndRecordsOwnerContext(t *testing.T) { - const ( - userID = int64(1000000001) - peerID = int64(1000000002) - ) - var authKeyID [8]byte - authKeyID[0] = 9 - messages := &captureMessages{} - r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesEditMessageRequest{ - Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, - ID: 3, - } - req.SetMessage("edited") - req.SetEntities([]tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 6}}) - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), userID), authKeyID, 77, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - box, ok := enc.(*tg.UpdatesBox) - if !ok { - t.Fatalf("response = %T, want *tg.UpdatesBox", enc) - } - got, ok := box.Updates.(*tg.Updates) - if !ok || len(got.Updates) != 1 { - t.Fatalf("boxed updates = %T %+v, want one update", box.Updates, box.Updates) - } - edit, ok := got.Updates[0].(*tg.UpdateEditMessage) - if !ok || edit.Pts != 7 || edit.PtsCount != 1 { - t.Fatalf("edit update = %#v, want pts=7 count=1", got.Updates[0]) - } - msg, ok := edit.Message.(*tg.Message) - if !ok || msg.ID != 3 || msg.Message != "edited" { - t.Fatalf("edited message = %#v, want id=3 text edited", edit.Message) - } - if messages.editReq.OwnerUserID != userID || messages.editReq.Peer.ID != peerID || messages.editReq.ID != 3 || messages.editReq.OriginAuthKeyID != authKeyID || messages.editReq.OriginSessionID != 77 { - t.Fatalf("edit request = %+v, want owner peer message id and origin", messages.editReq) - } - if len(messages.editReq.Entities) != 1 || messages.editReq.Entities[0].Type != domain.MessageEntityBold { - t.Fatalf("edit entities = %+v, want bold", messages.editReq.Entities) - } -} - -func TestMessagesEditMessageOptionBoundaries(t *testing.T) { - const ( - userID = int64(1000000001) - peerID = int64(1000000002) - ) - ctx := WithUserID(context.Background(), userID) - r := New(Config{}, Deps{Messages: &captureMessages{}}, zaptest.NewLogger(t), clock.System) - - webPreviewReq := &tg.MessagesEditMessageRequest{ - Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, - ID: 3, - } - webPreviewReq.SetMessage("edited with link") - webPreviewReq.SetMedia(&tg.InputMediaWebPage{URL: "https://example.test/"}) - if _, err := r.onMessagesEditMessage(ctx, webPreviewReq); err != nil { - t.Fatalf("edit with webpage media err = %v, want text-only downgrade", err) - } - - cases := []struct { - name string - req *tg.MessagesEditMessageRequest - want string - }{ - { - name: "quick reply shortcut", - req: func() *tg.MessagesEditMessageRequest { - req := &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3} - req.SetMessage("edited") - req.SetQuickReplyShortcutID(11) - return req - }(), - want: "MESSAGE_ID_INVALID", - }, - { - name: "unsupported media", - req: func() *tg.MessagesEditMessageRequest { - req := &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3} - req.SetMessage("edited") - req.SetMedia(&tg.InputMediaUploadedPhoto{}) - return req - }(), - want: "MEDIA_INVALID", - }, - { - name: "reply markup", - req: func() *tg.MessagesEditMessageRequest { - req := &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3} - req.SetMessage("edited") - req.SetReplyMarkup(&tg.ReplyKeyboardHide{}) - return req - }(), - want: "REPLY_MARKUP_INVALID", - }, - { - name: "message flag missing", - req: &tg.MessagesEditMessageRequest{Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, ID: 3}, - want: "MESSAGE_EMPTY", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if _, err := r.onMessagesEditMessage(ctx, tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("edit err = %v, want %s", err, tc.want) - } - }) - } -} - -func TestMessagesGetMessageEditDataPrivateValidatesAuthor(t *testing.T) { - const ( - userID = int64(1000000001) - peerID = int64(1000000002) - ) - messages := &captureMessages{list: domain.MessageList{ - Messages: []domain.Message{{ - ID: 3, - OwnerUserID: userID, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: userID}, - Out: true, - Body: "editable", - }}, - }} - r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) - - got, err := r.onMessagesGetMessageEditData(WithUserID(context.Background(), userID), &tg.MessagesGetMessageEditDataRequest{ - Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, - ID: 3, - }) - if err != nil { - t.Fatalf("get private edit data: %v", err) - } - if got.GetCaption() { - t.Fatalf("private edit data caption = true, want false for text-only message") - } - - messages.list.Messages[0].Out = false - if _, err := r.onMessagesGetMessageEditData(WithUserID(context.Background(), userID), &tg.MessagesGetMessageEditDataRequest{ - Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, - ID: 3, - }); err == nil || !strings.Contains(err.Error(), "MESSAGE_AUTHOR_REQUIRED") { - t.Fatalf("non-author edit data err = %v, want MESSAGE_AUTHOR_REQUIRED", err) - } -} - -func TestMessagesGetOutboxReadDateReturnsDate(t *testing.T) { - const ( - userID = int64(1000000001) - peerID = int64(1000000002) - ) - messages := &captureMessages{outboxReadDate: 1700000300} - r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesGetOutboxReadDateRequest{ - Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, - MsgID: 3, - } - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), userID), [8]byte{}, 77, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - got, ok := enc.(*tg.OutboxReadDate) - if !ok || got.Date != 1700000300 { - t.Fatalf("response = %T %#v, want outboxReadDate date", enc, enc) - } - if messages.outboxReadDateReq.OwnerUserID != userID || messages.outboxReadDateReq.Peer.ID != peerID || messages.outboxReadDateReq.ID != 3 { - t.Fatalf("read date request = %+v, want owner peer message id", messages.outboxReadDateReq) - } -} - -func TestMessagesReadMessageContentsPushesUpdateToOtherSessions(t *testing.T) { - authKeyID := [8]byte{9, 9, 9} - messages := &captureMessages{ - readContentsRes: domain.ReadMessageContentsResult{ - OwnerUserID: 1000000001, - MessageIDs: []int{7, 8}, - }, - } - sessions := &captureSessions{} - r := New(Config{}, Deps{ - Messages: messages, - Updates: &captureUpdates{state: domain.UpdateState{Pts: 42, Date: 1700000200}}, - Sessions: sessions, - }, zaptest.NewLogger(t), clock.System) - ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), 1000000001), authKeyID), 55) - - affected, err := r.onMessagesReadMessageContents(ctx, []int{7, 8}) - if err != nil { - t.Fatalf("messages.readMessageContents: %v", err) - } - if affected.Pts != 42 || affected.PtsCount != 0 { - t.Fatalf("affected = %+v, want pts=42 pts_count=0", affected) - } - if messages.readContentsReq.OwnerUserID != 1000000001 || !reflect.DeepEqual(messages.readContentsReq.IDs, []int{7, 8}) { - t.Fatalf("read contents req = %+v", messages.readContentsReq) - } - snap := sessions.snapshot() - if snap.userID != 1000000001 || snap.sessionID != 55 || snap.messageType != proto.MessageFromServer { - t.Fatalf("push target = %+v", snap) - } - updates, ok := snap.message.(*tg.Updates) - if !ok { - t.Fatalf("pushed message = %T, want *tg.Updates", snap.message) - } - if len(updates.Updates) != 1 { - t.Fatalf("updates = %+v", updates.Updates) - } - read, ok := updates.Updates[0].(*tg.UpdateReadMessagesContents) - if !ok { - t.Fatalf("update = %T, want *tg.UpdateReadMessagesContents", updates.Updates[0]) - } - if !reflect.DeepEqual(read.Messages, []int{7, 8}) || read.Pts != 42 || read.PtsCount != 0 { - t.Fatalf("read update = %+v", read) - } -} - -func TestMessagesReadHistoryMarksDialogRead(t *testing.T) { - var authKeyID [8]byte - authKeyID[0] = 7 - messages := &captureMessages{readResult: domain.ReadHistoryResult{ - OwnerUserID: 1000000001, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, - MaxID: 12, - Changed: true, - InboxEvent: domain.UpdateEvent{ - Type: domain.UpdateEventReadHistoryInbox, - Pts: 5, - PtsCount: 1, - Date: 1700000100, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, - MaxID: 12, - }, - }} - updates := &captureUpdates{state: domain.UpdateState{Pts: 5, Date: 1700000100, Seq: 3}} - sessions := &captureSessions{} - r := New(Config{}, Deps{Messages: messages, Updates: updates, Sessions: sessions}, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesReadHistoryRequest{ - Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, - MaxID: 12, - } - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), 1000000001), authKeyID, 0, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - got, ok := enc.(*tg.MessagesAffectedMessages) - if !ok { - t.Fatalf("response = %T, want *tg.MessagesAffectedMessages", enc) - } - if messages.readPeer.ID != domain.OfficialSystemUserID || messages.readMaxID != 12 { - t.Fatalf("read = peer %+v max %d, want official/12", messages.readPeer, messages.readMaxID) - } - if got.Pts != 5 || got.PtsCount != 1 { - t.Fatalf("affected = %+v, want recorded read-history pts", got) - } - gotSession := sessions.snapshot() - if gotSession.userID != 1000000001 || gotSession.messageType != proto.MessageFromServer { - t.Fatalf("push target = user %d type %v, want read update push to other sessions", gotSession.userID, gotSession.messageType) - } -} - -func TestMessagesDeleteMessagesPassesOwnerContext(t *testing.T) { - const userID = int64(1000000001) - var authKeyID [8]byte - authKeyID[0] = 10 - messages := &captureMessages{deleteMessagesRes: domain.DeleteMessagesResult{ - OwnerUserID: userID, - Deleted: []domain.DeletedMessagesForUser{{ - UserID: userID, - MessageIDs: []int{2, 3}, - Event: domain.UpdateEvent{Pts: 9, PtsCount: 2}, - }}, - }} - r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesDeleteMessagesRequest{Revoke: true, ID: []int{3, 2}} - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), userID), authKeyID, 77, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - got, ok := enc.(*tg.MessagesAffectedMessages) - if !ok { - t.Fatalf("response = %T, want *tg.MessagesAffectedMessages", enc) - } - if got.Pts != 9 || got.PtsCount != 2 { - t.Fatalf("affected = %+v, want pts=9 pts_count=2", got) - } - if messages.deleteMessagesReq.OwnerUserID != userID || !messages.deleteMessagesReq.Revoke || messages.deleteMessagesReq.OriginSessionID != 77 || messages.deleteMessagesReq.OriginAuthKeyID != authKeyID { - t.Fatalf("delete request = %+v, want owner/revoke/current session", messages.deleteMessagesReq) - } - if len(messages.deleteMessagesReq.IDs) != 2 || messages.deleteMessagesReq.IDs[0] != 3 || messages.deleteMessagesReq.IDs[1] != 2 { - t.Fatalf("delete ids = %+v, want request order [3 2]", messages.deleteMessagesReq.IDs) - } -} - -func TestMessagesDeleteHistoryPassesJustClearContext(t *testing.T) { - const ( - userID = int64(1000000001) - peerID = int64(1000000002) - ) - var authKeyID [8]byte - authKeyID[0] = 11 - messages := &captureMessages{deleteHistoryRes: domain.DeleteMessagesResult{ - OwnerUserID: userID, - Deleted: []domain.DeletedMessagesForUser{{ - UserID: userID, - MessageIDs: []int{1, 2, 3}, - Event: domain.UpdateEvent{Pts: 12, PtsCount: 3}, - }}, - }} - r := New(Config{}, Deps{Messages: messages}, zaptest.NewLogger(t), clock.System) - req := &tg.MessagesDeleteHistoryRequest{ - JustClear: true, - Revoke: true, - Peer: &tg.InputPeerUser{UserID: peerID, AccessHash: 22}, - MaxID: 15, - } - var in bin.Buffer - if err := req.Encode(&in); err != nil { - t.Fatalf("encode request: %v", err) - } - - enc, err := r.Dispatch(WithUserID(context.Background(), userID), authKeyID, 88, &in) - if err != nil { - t.Fatalf("dispatch: %v", err) - } - got, ok := enc.(*tg.MessagesAffectedHistory) - if !ok { - t.Fatalf("response = %T, want *tg.MessagesAffectedHistory", enc) - } - if got.Pts != 12 || got.PtsCount != 3 { - t.Fatalf("affected = %+v, want pts=12 pts_count=3", got) - } - reqGot := messages.deleteHistoryReq - if reqGot.OwnerUserID != userID || reqGot.Peer.ID != peerID || reqGot.MaxID != 15 || !reqGot.JustClear || !reqGot.Revoke || reqGot.OriginSessionID != 88 || reqGot.OriginAuthKeyID != authKeyID { - t.Fatalf("delete history request = %+v, want owner peer max_id flags current session", reqGot) - } -} - -type staticUsersService struct { - user domain.User -} - -type mapUsersService struct { - users map[int64]domain.User -} - -type captureAuthService struct { - resolvedAuthKeyID [8]byte - hasResolved bool - resolveCount int - userID int64 - userIDCount int - signInUser domain.User - loggedOutAuthKeyID [8]byte -} - -type blockingUserAuthService struct { - userID int64 - started chan struct{} - release chan struct{} - once sync.Once - mu sync.Mutex - count int -} - -func newBlockingUserAuthService(userID int64) *blockingUserAuthService { - return &blockingUserAuthService{ - userID: userID, - started: make(chan struct{}), - release: make(chan struct{}), - } -} - -func (s *blockingUserAuthService) UserIDCount() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.count -} - -func (s *blockingUserAuthService) BindTempAuthKey(context.Context, int64, domain.TempAuthKeyBinding) error { - return nil -} - -func (s *blockingUserAuthService) ResolveAuthKey(context.Context, [8]byte) ([8]byte, bool, error) { - return [8]byte{}, false, nil -} - -func (s *blockingUserAuthService) UserID(ctx context.Context, _ [8]byte) (int64, bool, error) { - s.mu.Lock() - s.count++ - s.mu.Unlock() - s.once.Do(func() { close(s.started) }) - select { - case <-s.release: - return s.userID, s.userID != 0, nil - case <-ctx.Done(): - return 0, false, ctx.Err() - } -} - -func (s *blockingUserAuthService) SendCode(context.Context, string) (string, error) { - return "", nil -} - -func (s *blockingUserAuthService) ResendCode(context.Context, string, string) (string, error) { - return "", nil -} - -func (s *blockingUserAuthService) CancelCode(context.Context, string, string) error { - return nil -} - -func (s *blockingUserAuthService) SignIn(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) { - return domain.User{}, domain.Message{}, false, nil -} - -func (s *blockingUserAuthService) SignUp(context.Context, domain.Authorization, string, string, string, string) (domain.User, domain.Message, error) { - return domain.User{}, domain.Message{}, nil -} - -func (s *blockingUserAuthService) LogOut(context.Context, [8]byte) error { - return nil -} - -func (s *blockingUserAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) { - return nil, nil -} - -func (s *blockingUserAuthService) ResetAuthorization(context.Context, int64, int64) (domain.Authorization, bool, error) { - return domain.Authorization{}, false, nil -} - -func (s *blockingUserAuthService) ResetAuthorizations(context.Context, int64, [8]byte) ([]domain.Authorization, error) { - return nil, nil -} - -func (s *captureAuthService) BindTempAuthKey(context.Context, int64, domain.TempAuthKeyBinding) error { - return nil -} - -func (s *captureAuthService) ResolveAuthKey(context.Context, [8]byte) ([8]byte, bool, error) { - s.resolveCount++ - return s.resolvedAuthKeyID, s.hasResolved, nil -} - -func (s *captureAuthService) UserID(context.Context, [8]byte) (int64, bool, error) { - s.userIDCount++ - return s.userID, s.userID != 0, nil -} - -func (s *captureAuthService) SendCode(context.Context, string) (string, error) { - return "", nil -} - -func (s *captureAuthService) ResendCode(context.Context, string, string) (string, error) { - return "", nil -} - -func (s *captureAuthService) CancelCode(context.Context, string, string) error { - return nil -} - -func (s *captureAuthService) SignIn(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) { - if s.signInUser.ID != 0 { - return s.signInUser, domain.Message{}, false, nil - } - return domain.User{}, domain.Message{}, false, nil -} - -func (s *captureAuthService) SignUp(context.Context, domain.Authorization, string, string, string, string) (domain.User, domain.Message, error) { - return domain.User{}, domain.Message{}, nil -} - -func (s *captureAuthService) LogOut(_ context.Context, authKeyID [8]byte) error { - s.loggedOutAuthKeyID = authKeyID - return nil -} - -func (s *captureAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) { - return nil, nil -} - -func (s *captureAuthService) ResetAuthorization(context.Context, int64, int64) (domain.Authorization, bool, error) { - return domain.Authorization{}, false, nil -} - -func (s *captureAuthService) ResetAuthorizations(context.Context, int64, [8]byte) ([]domain.Authorization, error) { - return nil, nil -} - -func (s staticUsersService) Self(context.Context, int64) (domain.User, error) { - return s.user, nil -} - -func (s staticUsersService) ByID(_ context.Context, _, userID int64) (domain.User, bool, error) { - if userID == s.user.ID { - return s.user, true, nil - } - if userID == domain.OfficialSystemUserID { - return domain.OfficialSystemUser(), true, nil - } - return domain.User{}, false, nil -} - -func (s staticUsersService) ByIDs(_ context.Context, _ int64, userIDs []int64) ([]domain.User, error) { - out := make([]domain.User, 0, len(userIDs)) - seen := map[int64]struct{}{} - for _, userID := range userIDs { - if _, ok := seen[userID]; ok { - continue - } - seen[userID] = struct{}{} - if userID == s.user.ID { - out = append(out, s.user) - } else if userID == domain.OfficialSystemUserID { - out = append(out, domain.OfficialSystemUser()) - } - } - return out, nil -} - -func (s mapUsersService) Self(_ context.Context, userID int64) (domain.User, error) { - return s.users[userID], nil -} - -func (s mapUsersService) ByID(_ context.Context, _, userID int64) (domain.User, bool, error) { - u, ok := s.users[userID] - return u, ok, nil -} - -func (s mapUsersService) ByIDs(_ context.Context, _ int64, userIDs []int64) ([]domain.User, error) { - out := make([]domain.User, 0, len(userIDs)) - seen := map[int64]struct{}{} - for _, userID := range userIDs { - if _, ok := seen[userID]; ok { - continue - } - seen[userID] = struct{}{} - if u, ok := s.users[userID]; ok { - out = append(out, u) - } - } - return out, nil -} - -type captureUsersService struct { - user domain.User - userID int64 -} - -func (s *captureUsersService) Self(_ context.Context, userID int64) (domain.User, error) { - s.userID = userID - return s.user, nil -} - -func (s *captureUsersService) ByID(_ context.Context, currentUserID, userID int64) (domain.User, bool, error) { - s.userID = currentUserID - if userID == s.user.ID { - return s.user, true, nil - } - return domain.User{}, false, nil -} - -func (s *captureUsersService) ByIDs(_ context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) { - s.userID = currentUserID - out := make([]domain.User, 0, len(userIDs)) - for _, userID := range userIDs { - if userID == s.user.ID { - out = append(out, s.user) - } - } - return out, nil -} - -type captureSessions struct { - mu sync.Mutex - sessionID int64 - userID int64 - userResolved bool - authKeyID [8]byte - authKeyResolved bool - receives bool - messageType proto.MessageType - message bin.Encoder - pushUserIDs []int64 - onlineUserIDs []int64 - channelViewers map[int64][]int64 - channelMembers map[int64][]int64 -} - -type captureSessionsSnapshot struct { - sessionID int64 - userID int64 - userResolved bool - authKeyID [8]byte - authKeyResolved bool - receives bool - messageType proto.MessageType - message bin.Encoder -} - -func (s *captureSessions) snapshot() captureSessionsSnapshot { - s.mu.Lock() - defer s.mu.Unlock() - return captureSessionsSnapshot{ - sessionID: s.sessionID, - userID: s.userID, - userResolved: s.userResolved, - authKeyID: s.authKeyID, - authKeyResolved: s.authKeyResolved, - receives: s.receives, - messageType: s.messageType, - message: s.message, - } -} - -func (s *captureSessions) pushedUserIDs() []int64 { - s.mu.Lock() - defer s.mu.Unlock() - return append([]int64(nil), s.pushUserIDs...) -} - -func (s *captureSessions) BindAuthKey(sessionID int64, authKeyID [8]byte) { - s.mu.Lock() - defer s.mu.Unlock() - if s.authKeyResolved && s.authKeyID != authKeyID { - s.userID = 0 - s.userResolved = false - } - s.sessionID = sessionID - s.authKeyID = authKeyID - s.authKeyResolved = true -} - -func (s *captureSessions) AuthKeyID(int64) ([8]byte, bool) { - s.mu.Lock() - defer s.mu.Unlock() - return s.authKeyID, s.authKeyResolved -} - -func (s *captureSessions) BindUser(sessionID, userID int64) { - s.mu.Lock() - defer s.mu.Unlock() - s.sessionID = sessionID - s.userID = userID - s.userResolved = true -} - -func (s *captureSessions) UserID(int64) (int64, bool) { - s.mu.Lock() - defer s.mu.Unlock() - return s.userID, s.userID != 0 -} - -func (s *captureSessions) UserIDResolved(int64) (int64, bool) { - s.mu.Lock() - defer s.mu.Unlock() - return s.userID, s.userResolved -} - -func (s *captureSessions) UnbindAuthKey(authKeyID [8]byte) int { - s.mu.Lock() - defer s.mu.Unlock() - if s.authKeyID == authKeyID { - s.userID = 0 - s.userResolved = true - return 1 - } - return 0 -} - -func (s *captureSessions) SetReceivesUpdates(sessionID int64, receives bool) { - s.mu.Lock() - defer s.mu.Unlock() - s.sessionID = sessionID - s.receives = receives -} - -func (s *captureSessions) PushToSession(_ context.Context, sessionID int64, t proto.MessageType, msg bin.Encoder) error { - s.mu.Lock() - defer s.mu.Unlock() - s.sessionID = sessionID - s.messageType = t - s.message = msg - return nil -} - -func (s *captureSessions) PushToUserExceptSession(_ context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { - s.mu.Lock() - defer s.mu.Unlock() - s.userID = userID - s.sessionID = excludeSessionID - s.messageType = t - s.message = msg - s.pushUserIDs = append(s.pushUserIDs, userID) - return 1, nil -} - -func (s *captureSessions) OnlineUserIDs(limit int) []int64 { - s.mu.Lock() - defer s.mu.Unlock() - ids := append([]int64(nil), s.onlineUserIDs...) - if limit > 0 && len(ids) > limit { - ids = ids[:limit] - } - return ids -} - -func (s *captureSessions) IsUserOnline(userID int64) bool { - s.mu.Lock() - defer s.mu.Unlock() - for _, id := range s.onlineUserIDs { - if id == userID { - return true - } - } - return false -} - -func (s *captureSessions) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 { - s.mu.Lock() - defer s.mu.Unlock() - online := make(map[int64]struct{}, len(s.onlineUserIDs)) - for _, id := range s.onlineUserIDs { - online[id] = struct{}{} - } - out := make([]int64, 0, len(candidateUserIDs)) - seen := map[int64]struct{}{} - for _, id := range candidateUserIDs { - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - if _, ok := online[id]; !ok { - continue - } - out = append(out, id) - if limit > 0 && len(out) >= limit { - break - } - } - return out -} - -func (s *captureSessions) TrackChannelInterest(_ [8]byte, _ int64, userID int64, channelIDs []int64) { - s.mu.Lock() - defer s.mu.Unlock() - if s.channelViewers == nil { - s.channelViewers = make(map[int64][]int64) - } - for channelID, viewers := range s.channelViewers { - out := viewers[:0] - for _, viewerID := range viewers { - if viewerID != userID { - out = append(out, viewerID) - } - } - if len(out) == 0 { - delete(s.channelViewers, channelID) - continue - } - s.channelViewers[channelID] = out - } - for _, channelID := range channelIDs { - if channelID == 0 { - continue - } - s.channelViewers[channelID] = append(s.channelViewers[channelID], userID) - } -} - -func (s *captureSessions) ClearChannelInterest(_ [8]byte, _ int64, userID int64) { - s.mu.Lock() - defer s.mu.Unlock() - for channelID, viewers := range s.channelViewers { - out := viewers[:0] - for _, viewerID := range viewers { - if viewerID != userID { - out = append(out, viewerID) - } - } - if len(out) == 0 { - delete(s.channelViewers, channelID) - continue - } - s.channelViewers[channelID] = out - } -} - -func (s *captureSessions) OnlineChannelUserIDs(channelID int64, limit int) []int64 { - s.mu.Lock() - defer s.mu.Unlock() - return limitIDs(s.channelViewers[channelID], limit) -} - -func (s *captureSessions) SetSessionChannelMemberships(_ [8]byte, _ int64, userID int64, channelIDs []int64) { - s.mu.Lock() - defer s.mu.Unlock() - if s.channelMembers == nil { - s.channelMembers = make(map[int64][]int64) - } - for _, channelID := range channelIDs { - if channelID == 0 { - continue - } - s.channelMembers[channelID] = append(s.channelMembers[channelID], userID) - } -} - -func (s *captureSessions) AddUserChannelMembership(userID, channelID int64) { - s.mu.Lock() - defer s.mu.Unlock() - if s.channelMembers == nil { - s.channelMembers = make(map[int64][]int64) - } - s.channelMembers[channelID] = append(s.channelMembers[channelID], userID) -} - -func (s *captureSessions) RemoveUserChannelMembership(userID, channelID int64) { - s.mu.Lock() - defer s.mu.Unlock() - members := s.channelMembers[channelID] - out := members[:0] - for _, id := range members { - if id != userID { - out = append(out, id) - } - } - if len(out) == 0 { - delete(s.channelMembers, channelID) - return - } - s.channelMembers[channelID] = out -} - -func (s *captureSessions) OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 { - s.mu.Lock() - defer s.mu.Unlock() - return limitIDs(s.channelMembers[channelID], limit) -} - -func limitIDs(ids []int64, limit int) []int64 { - out := make([]int64, 0, len(ids)) - seen := make(map[int64]struct{}, len(ids)) - for _, id := range ids { - if id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - if limit > 0 && len(out) >= limit { - break - } - } - return out -} - -type captureUpdates struct { - state domain.UpdateState - currentState *domain.UpdateState - authKeyID [8]byte - userID int64 - clearedAuthKeyID [8]byte - cleared bool - date int - events []domain.UpdateEvent - excludeSessionID int64 - reliableDispatch bool -} - -func (s *captureUpdates) UsesReliableDispatch() bool { - return s.reliableDispatch -} - -func (s *captureUpdates) GetState(_ context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) { - s.authKeyID = authKeyID - s.userID = userID - return s.state, nil -} - -func (s *captureUpdates) CurrentState(_ context.Context, userID int64) (domain.UpdateState, error) { - s.userID = userID - if s.currentState != nil { - return *s.currentState, nil - } - return s.state, nil -} - -func (s *captureUpdates) GetDifference(_ context.Context, authKeyID [8]byte, userID int64, _ domain.UpdateState) (domain.UpdateDifference, error) { - s.authKeyID = authKeyID - s.userID = userID - return domain.UpdateDifference{State: s.state}, nil -} - -func (s *captureUpdates) ClearAuthKey(_ context.Context, authKeyID [8]byte) error { - s.clearedAuthKeyID = authKeyID - s.cleared = true - return nil -} - -func (s *captureUpdates) RecordNewMessage(_ context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) { - s.authKeyID = authKeyID - s.userID = userID - s.date = msg.Date - event := domain.UpdateEvent{Type: domain.UpdateEventNewMessage, Pts: s.state.Pts, PtsCount: 1, Date: msg.Date, Message: msg} - s.events = append(s.events, event) - return event, s.state, nil -} - -func (s *captureUpdates) RecordReadHistory(_ context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.authKeyID = authKeyID - s.userID = userID - s.excludeSessionID = excludeSessionID - event := domain.UpdateEvent{ - Type: domain.UpdateEventReadHistoryInbox, - Pts: s.state.Pts, - PtsCount: 1, - Date: s.state.Date, - Peer: read.Peer, - MaxID: read.MaxID, - StillUnreadCount: read.StillUnreadCount, - } - s.events = append(s.events, event) - return event, s.state, nil -} - -func (s *captureUpdates) RecordContactsReset(_ context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventContactsReset}) -} - -func (s *captureUpdates) RecordDialogPinned(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogPinned, Peer: peer, Bool: pinned}) -} - -func (s *captureUpdates) RecordPinnedDialogs(_ context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPinnedDialogs, Peers: append([]domain.Peer(nil), order...)}) -} - -func (s *captureUpdates) RecordDialogUnreadMark(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogUnreadMark, Peer: peer, Bool: unread}) -} - -func (s *captureUpdates) RecordPeerSettings(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPeerSettings, Peer: peer, Settings: settings}) -} - -func (s *captureUpdates) RecordDialogFilter(_ context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilter, FilterID: folderID, DialogFilter: folder}) -} - -func (s *captureUpdates) RecordDialogFilterOrder(_ context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilterOrder, FilterOrder: append([]int(nil), order...)}) -} - -func (s *captureUpdates) RecordDialogFiltersReload(_ context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilters}) -} - -func (s *captureUpdates) RecordFolderPeers(_ context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventFolderPeers, FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...)}) -} - -func (s *captureUpdates) RecordChannelAvailableMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ - Type: domain.UpdateEventChannelAvailable, - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, - MaxID: availableMinID, - }) -} - -func (s *captureUpdates) RecordChannelViewForumAsMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { - s.excludeSessionID = excludeSessionID - return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ - Type: domain.UpdateEventChannelViewForum, - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, - Bool: enabled, - }) -} - -func (s *captureUpdates) recordCapturedEvent(authKeyID [8]byte, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, domain.UpdateState, error) { - s.authKeyID = authKeyID - s.userID = userID - if event.Pts == 0 { - event.Pts = s.state.Pts - } - if event.PtsCount == 0 { - event.PtsCount = 1 - } - if event.Date == 0 { - event.Date = s.state.Date - } - event.UserID = userID - s.events = append(s.events, event) - return event, s.state, nil -} - -type captureMessages struct { - list domain.MessageList - filter domain.MessageFilter - sendResult domain.SendPrivateTextResult - sendUserID int64 - sendReq domain.SendPrivateTextRequest - forwardUserID int64 - forwardReq domain.ForwardPrivateMessagesRequest - forwardRes domain.ForwardPrivateMessagesResult - readResult domain.ReadHistoryResult - readReq domain.ReadHistoryRequest - readPeer domain.Peer - readMaxID int - readContentsReq domain.ReadMessageContentsRequest - readContentsRes domain.ReadMessageContentsResult - setReactionReq domain.SetPrivateMessageReactionsRequest - setReactionRes domain.PrivateMessageReactionsResult - getReactionReq domain.PrivateMessageReactionsRequest - getReactionRes domain.PrivateMessageReactionsResult - editReq domain.EditMessageRequest - editRes domain.EditMessageResult - outboxReadDateReq domain.OutboxReadDateRequest - outboxReadDate int - deleteMessagesReq domain.DeleteMessagesRequest - deleteMessagesRes domain.DeleteMessagesResult - deleteHistoryReq domain.DeleteHistoryRequest - deleteHistoryRes domain.DeleteMessagesResult -} - -func (s *captureMessages) SendPrivateText(_ context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { - s.sendUserID = userID - s.sendReq = req - if s.sendResult.SenderMessage.ID == 0 { - s.sendResult.SenderMessage = domain.Message{ - ID: 1, - OwnerUserID: req.SenderUserID, - RandomID: req.RandomID, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, - Date: req.Date, - Out: true, - Silent: req.Silent, - NoForwards: req.NoForwards, - Body: req.Message, - Entities: req.Entities, - ReplyTo: req.ReplyTo, - Forward: req.Forward, - Pts: 1, - } - s.sendResult.SenderEvent = domain.UpdateEvent{ - UserID: req.SenderUserID, - Type: domain.UpdateEventNewMessage, - Pts: 1, - PtsCount: 1, - Date: req.Date, - Message: s.sendResult.SenderMessage, - } - } - return s.sendResult, nil -} - -func (s *captureMessages) ForwardPrivateMessages(_ context.Context, userID int64, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { - s.forwardUserID = userID - s.forwardReq = req - if len(s.forwardRes.SenderMessages) == 0 { - s.forwardRes.OwnerUserID = userID - s.forwardRes.SenderMessages = make([]domain.Message, 0, len(req.MessageIDs)) - s.forwardRes.SenderEvents = make([]domain.UpdateEvent, 0, len(req.MessageIDs)) - for i := range req.MessageIDs { - msg := domain.Message{ - ID: i + 1, - OwnerUserID: req.OwnerUserID, - RandomID: req.RandomIDs[i], - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.ToUserID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, - Date: req.Date, - Out: true, - Silent: req.Silent, - NoForwards: req.NoForwards, - Body: "forwarded", - ReplyTo: req.ReplyTo, - Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: req.FromPeer.ID}, Date: req.Date - 1}, - Pts: i + 1, - } - event := domain.UpdateEvent{ - UserID: req.OwnerUserID, - Type: domain.UpdateEventNewMessage, - Pts: msg.Pts, - PtsCount: 1, - Date: req.Date, - Message: msg, - } - s.forwardRes.SenderMessages = append(s.forwardRes.SenderMessages, msg) - s.forwardRes.SenderEvents = append(s.forwardRes.SenderEvents, event) - } - } - return s.forwardRes, nil -} - -func (s *captureMessages) GetMessages(_ context.Context, _ int64, ids []int) (domain.MessageList, error) { - byID := make(map[int]domain.Message, len(s.list.Messages)) - for _, msg := range s.list.Messages { - byID[msg.ID] = msg - } - out := domain.MessageList{Messages: make([]domain.Message, 0, len(ids)), Users: s.list.Users} - for _, id := range ids { - if msg, ok := byID[id]; ok { - out.Messages = append(out.Messages, msg) - } - } - return out, nil -} - -func (s *captureMessages) GetHistory(_ context.Context, _ int64, filter domain.MessageFilter) (domain.MessageList, error) { - s.filter = filter - return s.list, nil -} - -func (s *captureMessages) Search(_ context.Context, _ int64, filter domain.MessageFilter) (domain.MessageList, error) { - s.filter = filter - return s.list, nil -} - -func (s *captureMessages) ReadHistory(_ context.Context, _ int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) { - s.readReq = req - s.readPeer = req.Peer - s.readMaxID = req.MaxID - if s.readResult.OwnerUserID == 0 { - s.readResult.OwnerUserID = req.OwnerUserID - } - if s.readResult.Peer.ID == 0 { - s.readResult.Peer = req.Peer - } - if s.readResult.MaxID == 0 { - s.readResult.MaxID = req.MaxID - } - return s.readResult, nil -} - -func (s *captureMessages) ReadMessageContents(_ context.Context, userID int64, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { - s.readContentsReq = req - if s.readContentsRes.OwnerUserID == 0 { - s.readContentsRes.OwnerUserID = userID - } - return s.readContentsRes, nil -} - -func (s *captureMessages) GetOutboxReadDate(_ context.Context, _ int64, req domain.OutboxReadDateRequest) (int, error) { - s.outboxReadDateReq = req - if s.outboxReadDate == 0 { - return 0, domain.ErrMessageNotReadYet - } - return s.outboxReadDate, nil -} - -func (s *captureMessages) SetMessageReactions(_ context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { - s.setReactionReq = req - if len(s.setReactionRes.Messages) == 0 { - if len(req.Reactions) == 0 { - reactions := domain.ChannelMessageReactions{CanSeeList: true, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}} - s.setReactionRes = domain.PrivateMessageReactionsResult{ - Messages: []domain.Message{{ - ID: req.MessageID, - OwnerUserID: userID, - Peer: req.Peer, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.Peer.ID}, - Date: req.Date, - Reactions: &reactions, - }}, - Reactions: reactions, - } - return s.setReactionRes, nil - } - reactions := domain.ChannelMessageReactions{ - CanSeeList: true, - Results: []domain.ChannelMessageReactionCount{{ - Reaction: req.Reactions[0], - Count: 1, - ChosenOrder: 1, - }}, - Recent: []domain.ChannelMessagePeerReaction{{ - UserID: userID, - Reaction: req.Reactions[0], - My: true, - Big: req.Big, - ChosenOrder: 1, - Date: req.Date, - }}, - } - s.setReactionRes = domain.PrivateMessageReactionsResult{ - Messages: []domain.Message{{ - ID: req.MessageID, - OwnerUserID: userID, - Peer: req.Peer, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.Peer.ID}, - Date: req.Date, - Reactions: &reactions, - }}, - Reactions: reactions, - } - } - return s.setReactionRes, nil -} - -func (s *captureMessages) GetMessageReactions(_ context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { - s.getReactionReq = req - if len(s.getReactionRes.Messages) == 0 && len(req.IDs) > 0 { - reactions := domain.ChannelMessageReactions{CanSeeList: true, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}} - s.getReactionRes = domain.PrivateMessageReactionsResult{ - Messages: []domain.Message{{ - ID: req.IDs[0], - OwnerUserID: userID, - Peer: req.Peer, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.Peer.ID}, - Reactions: &reactions, - }}, - Reactions: reactions, - } - } - return s.getReactionRes, nil -} - -func (s *captureMessages) EditMessage(_ context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) { - s.editReq = req - if s.editRes.OwnerUserID == 0 { - s.editRes.OwnerUserID = userID - } - if len(s.editRes.Edited) == 0 { - msg := domain.Message{ - ID: req.ID, - OwnerUserID: req.OwnerUserID, - Peer: req.Peer, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, - Date: req.EditDate - 10, - EditDate: req.EditDate, - Out: true, - Body: req.Message, - Entities: append([]domain.MessageEntity(nil), req.Entities...), - Pts: 7, - } - s.editRes.Edited = []domain.EditedMessageForUser{{ - UserID: req.OwnerUserID, - Message: msg, - Event: domain.UpdateEvent{ - UserID: req.OwnerUserID, - Type: domain.UpdateEventEditMessage, - Pts: 7, - PtsCount: 1, - Date: req.EditDate, - Message: msg, - }, - }} - } - return s.editRes, nil -} - -func (s *captureMessages) DeleteMessages(_ context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { - s.deleteMessagesReq = req - if s.deleteMessagesRes.OwnerUserID == 0 { - s.deleteMessagesRes.OwnerUserID = userID - } - return s.deleteMessagesRes, nil -} - -func (s *captureMessages) DeleteHistory(_ context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { - s.deleteHistoryReq = req - if s.deleteHistoryRes.OwnerUserID == 0 { - s.deleteHistoryRes.OwnerUserID = userID - } - return s.deleteHistoryRes, nil -} - -type captureDialogs struct { - list domain.DialogList - peerList domain.DialogList - folderList domain.DialogFolderList - filter domain.DialogFilter - peers []domain.Peer - savedFolder domain.DialogFolder - deletedFolderID int - folderOrder []int - tagsEnabled bool - folderPeers []domain.FolderPeerUpdate - drafts []domain.DialogDraft - savedDraft domain.DialogDraft - deletedDraft struct { - peer domain.Peer - topMessageID int - } -} - -type captureRPCMetrics struct { - messageSend int - messageDup bool - messageSendErr error - rateLimited int -} - -func (m *captureRPCMetrics) MessageSend(_ time.Duration, duplicate bool, err error) { - m.messageSend++ - m.messageDup = duplicate - m.messageSendErr = err -} - -func (m *captureRPCMetrics) MessageRateLimited(retryAfterSeconds int) { - m.rateLimited = retryAfterSeconds -} - -func (m *captureRPCMetrics) OutboxClaimed(int) {} - -func (m *captureRPCMetrics) OutboxDelivered(time.Duration) {} - -func (m *captureRPCMetrics) OutboxFailed(error) {} - -func (s *captureDialogs) GetDialogs(_ context.Context, _ int64, filter domain.DialogFilter) (domain.DialogList, error) { - s.filter = filter - return s.list, nil -} - -func (s *captureDialogs) GetPeerDialogs(_ context.Context, _ int64, peers []domain.Peer) (domain.DialogList, error) { - s.peers = append([]domain.Peer(nil), peers...) - return s.peerList, nil -} - -func (s *captureDialogs) SaveDraft(_ context.Context, _ int64, draft domain.DialogDraft) error { - s.savedDraft = draft - return nil -} - -func (s *captureDialogs) DeleteDraft(_ context.Context, _ int64, peer domain.Peer, topMessageID int) (bool, error) { - s.deletedDraft.peer = peer - s.deletedDraft.topMessageID = topMessageID - return true, nil -} - -func (s *captureDialogs) ListDrafts(_ context.Context, _ int64, _ int) ([]domain.DialogDraft, error) { - return append([]domain.DialogDraft(nil), s.drafts...), nil -} - -func (s *captureDialogs) ClearDrafts(_ context.Context, _ int64, _ int) ([]domain.DialogDraft, error) { - drafts := append([]domain.DialogDraft(nil), s.drafts...) - s.drafts = nil - return drafts, nil -} - -func (s *captureDialogs) TogglePinned(_ context.Context, _ int64, peer domain.Peer, _ bool) (bool, error) { - s.peers = []domain.Peer{peer} - return true, nil -} - -func (s *captureDialogs) ReorderPinned(_ context.Context, _ int64, order []domain.Peer, _ bool) error { - s.peers = append([]domain.Peer(nil), order...) - return nil -} - -func (s *captureDialogs) MarkUnread(_ context.Context, _ int64, peer domain.Peer, _ bool) (bool, error) { - s.peers = []domain.Peer{peer} - return true, nil -} - -func (s *captureDialogs) UnreadMarks(_ context.Context, _ int64) ([]domain.Peer, error) { - return s.peers, nil -} - -func (s *captureDialogs) HidePeerSettingsBar(_ context.Context, _ int64, peer domain.Peer) (bool, error) { - s.peers = []domain.Peer{peer} - return true, nil -} - -func (s *captureDialogs) PeerSettingsBarHidden(_ context.Context, _ int64, _ domain.Peer) (bool, error) { - return false, nil -} - -func (s *captureDialogs) GetDialogFolders(_ context.Context, _ int64) (domain.DialogFolderList, error) { - return s.folderList, nil -} - -func (s *captureDialogs) SaveDialogFolder(_ context.Context, _ int64, folder domain.DialogFolder) error { - s.savedFolder = folder - return nil -} - -func (s *captureDialogs) DeleteDialogFolder(_ context.Context, _ int64, folderID int) error { - s.deletedFolderID = folderID - return nil -} - -func (s *captureDialogs) ReorderDialogFolders(_ context.Context, _ int64, order []int) error { - s.folderOrder = append([]int(nil), order...) - return nil -} - -func (s *captureDialogs) ToggleDialogFolderTags(_ context.Context, _ int64, enabled bool) error { - s.tagsEnabled = enabled - return nil -} - -func (s *captureDialogs) EditPeerFolders(_ context.Context, _ int64, peers []domain.FolderPeerUpdate) error { - s.folderPeers = append([]domain.FolderPeerUpdate(nil), peers...) - return nil -} diff --git a/internal/rpc/rpc_projection_cache.go b/internal/rpc/rpc_projection_cache.go new file mode 100644 index 00000000..2cc284f5 --- /dev/null +++ b/internal/rpc/rpc_projection_cache.go @@ -0,0 +1,330 @@ +package rpc + +import ( + "time" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +const ( + rpcProjectionCacheTTL = 30 * time.Minute + rpcProjectionCacheMaxEntries = 100000 +) + +// userFullProjectionCache / peerSettingsProjectionCache / channelFullProjectionCache 是三个 +// 结构同形的 per-viewer TTL 投影缓存,统一收敛到 projectionCache[K,V](epoch 守卫 / LRU 单条 +// 驱逐 / TTL / clone 由 readmodelcache 原语承载)。各自仅在键/值类型、clone、失效维度上不同。 + +type userFullProjectionKey struct { + viewerUserID int64 + targetUserID int64 +} + +type userFullProjectionCache struct { + *projectionCache[userFullProjectionKey, tg.UserFull] +} + +func newUserFullProjectionCache(clock func() time.Time) *userFullProjectionCache { + return &userFullProjectionCache{ + newProjectionCache[userFullProjectionKey, tg.UserFull](rpcProjectionCacheMaxEntries, rpcProjectionCacheTTL, clock, cloneUserFull), + } +} + +func (c *userFullProjectionCache) Lookup(viewerUserID, targetUserID int64) (tg.UserFull, bool) { + if c == nil || viewerUserID == 0 || targetUserID == 0 { + return tg.UserFull{}, false + } + return c.lookup(userFullProjectionKey{viewerUserID: viewerUserID, targetUserID: targetUserID}) +} + +// StoreIfEpoch 仅在 epoch 未变(构建期间没有失效)时写入。 +func (c *userFullProjectionCache) StoreIfEpoch(viewerUserID, targetUserID int64, full tg.UserFull, loadEpoch uint64) { + if c == nil || viewerUserID == 0 || targetUserID == 0 { + return + } + c.storeIfEpoch(userFullProjectionKey{viewerUserID: viewerUserID, targetUserID: targetUserID}, full, loadEpoch) +} + +func (c *userFullProjectionCache) DeleteViewer(viewerUserID int64) { + if c == nil || viewerUserID == 0 { + return + } + c.deleteWhere(func(k userFullProjectionKey) bool { return k.viewerUserID == viewerUserID }) +} + +func (c *userFullProjectionCache) DeleteTarget(targetUserID int64) { + if c == nil || targetUserID == 0 { + return + } + c.deleteWhere(func(k userFullProjectionKey) bool { return k.targetUserID == targetUserID }) +} + +func (c *userFullProjectionCache) DeletePair(viewerUserID, targetUserID int64) { + if c == nil || viewerUserID == 0 || targetUserID == 0 { + return + } + c.deleteKey(userFullProjectionKey{viewerUserID: viewerUserID, targetUserID: targetUserID}) +} + +type peerSettingsProjectionKey struct { + viewerUserID int64 + peer domain.Peer +} + +type peerSettingsProjectionCache struct { + // domain.PeerSettings 为扁平值(无共享可变字段),故 Clone=nil。 + *projectionCache[peerSettingsProjectionKey, domain.PeerSettings] +} + +func newPeerSettingsProjectionCache(clock func() time.Time) *peerSettingsProjectionCache { + return &peerSettingsProjectionCache{ + newProjectionCache[peerSettingsProjectionKey, domain.PeerSettings](rpcProjectionCacheMaxEntries, rpcProjectionCacheTTL, clock, nil), + } +} + +func (c *peerSettingsProjectionCache) Lookup(viewerUserID int64, peer domain.Peer) (domain.PeerSettings, bool) { + if c == nil || viewerUserID == 0 || peer.ID == 0 { + return domain.PeerSettings{}, false + } + return c.lookup(peerSettingsProjectionKey{viewerUserID: viewerUserID, peer: peer}) +} + +// StoreIfEpoch 仅在 epoch 未变(构建期间没有失效)时写入。 +func (c *peerSettingsProjectionCache) StoreIfEpoch(viewerUserID int64, peer domain.Peer, settings domain.PeerSettings, loadEpoch uint64) { + if c == nil || viewerUserID == 0 || peer.ID == 0 { + return + } + c.storeIfEpoch(peerSettingsProjectionKey{viewerUserID: viewerUserID, peer: peer}, settings, loadEpoch) +} + +func (c *peerSettingsProjectionCache) DeleteViewer(viewerUserID int64) { + if c == nil || viewerUserID == 0 { + return + } + c.deleteWhere(func(k peerSettingsProjectionKey) bool { return k.viewerUserID == viewerUserID }) +} + +func (c *peerSettingsProjectionCache) DeletePeer(peer domain.Peer) { + if c == nil || peer.ID == 0 { + return + } + c.deleteWhere(func(k peerSettingsProjectionKey) bool { return k.peer == peer }) +} + +func (c *peerSettingsProjectionCache) DeletePair(viewerUserID int64, peer domain.Peer) { + if c == nil || viewerUserID == 0 || peer.ID == 0 { + return + } + c.deleteKey(peerSettingsProjectionKey{viewerUserID: viewerUserID, peer: peer}) +} + +type channelFullProjectionKey struct { + viewerUserID int64 + channelID int64 +} + +type channelFullProjection struct { + accessHash int64 + full tg.ChannelFull + chats []tg.ChatClass + userIDs []int64 +} + +type channelFullProjectionCache struct { + *projectionCache[channelFullProjectionKey, channelFullProjection] +} + +func newChannelFullProjectionCache(clock func() time.Time) *channelFullProjectionCache { + return &channelFullProjectionCache{ + newProjectionCache[channelFullProjectionKey, channelFullProjection](rpcProjectionCacheMaxEntries, rpcProjectionCacheTTL, clock, cloneChannelFullProjection), + } +} + +func (c *channelFullProjectionCache) Lookup(viewerUserID, channelID int64) (channelFullProjection, bool) { + if c == nil || viewerUserID == 0 || channelID == 0 { + return channelFullProjection{}, false + } + return c.lookup(channelFullProjectionKey{viewerUserID: viewerUserID, channelID: channelID}) +} + +// StoreIfEpoch 仅在 epoch 未变(构建期间没有失效)时写入。 +func (c *channelFullProjectionCache) StoreIfEpoch(viewerUserID, channelID int64, value channelFullProjection, loadEpoch uint64) { + if c == nil || viewerUserID == 0 || channelID == 0 { + return + } + c.storeIfEpoch(channelFullProjectionKey{viewerUserID: viewerUserID, channelID: channelID}, value, loadEpoch) +} + +func (c *channelFullProjectionCache) DeleteViewer(viewerUserID int64) { + if c == nil || viewerUserID == 0 { + return + } + c.deleteWhere(func(k channelFullProjectionKey) bool { return k.viewerUserID == viewerUserID }) +} + +func (c *channelFullProjectionCache) DeleteChannel(channelID int64) { + if c == nil || channelID == 0 { + return + } + c.deleteWhere(func(k channelFullProjectionKey) bool { return k.channelID == channelID }) +} + +func (c *channelFullProjectionCache) DeletePair(viewerUserID, channelID int64) { + if c == nil || viewerUserID == 0 || channelID == 0 { + return + } + c.deleteKey(channelFullProjectionKey{viewerUserID: viewerUserID, channelID: channelID}) +} + +func cloneChannelFullProjection(in channelFullProjection) channelFullProjection { + return channelFullProjection{ + accessHash: in.accessHash, + full: cloneChannelFull(in.full), + chats: cloneChatClasses(in.chats), + userIDs: cloneInt64s(in.userIDs), + } +} + +func cloneUserFull(in tg.UserFull) tg.UserFull { + out := in + out.BotInfo = cloneBotInfo(in.BotInfo) + return out +} + +func cloneChannelFull(in tg.ChannelFull) tg.ChannelFull { + out := in + out.BotInfo = cloneBotInfos(in.BotInfo) + out.PendingSuggestions = append([]string(nil), in.PendingSuggestions...) + out.RecentRequesters = cloneInt64s(in.RecentRequesters) + out.ExportedInvite = cloneExportedChatInvite(in.ExportedInvite) + return out +} + +func cloneExportedChatInvite(in tg.ExportedChatInviteClass) tg.ExportedChatInviteClass { + switch v := in.(type) { + case *tg.ChatInviteExported: + out := *v + return &out + default: + return v + } +} + +func cloneBotInfo(in tg.BotInfo) tg.BotInfo { + out := in + out.Commands = append([]tg.BotCommand(nil), in.Commands...) + return out +} + +func cloneChatClass(in tg.ChatClass) tg.ChatClass { + switch v := in.(type) { + case *tg.Channel: + out := *v + out.RestrictionReason = append([]tg.RestrictionReason(nil), v.RestrictionReason...) + out.Usernames = append([]tg.Username(nil), v.Usernames...) + return &out + case *tg.Chat: + out := *v + return &out + case *tg.ChannelForbidden: + out := *v + return &out + case *tg.ChatForbidden: + out := *v + return &out + case *tg.ChatEmpty: + out := *v + return &out + default: + return in + } +} + +func cloneChatClasses(in []tg.ChatClass) []tg.ChatClass { + if len(in) == 0 { + return nil + } + out := make([]tg.ChatClass, 0, len(in)) + for _, chat := range in { + out = append(out, cloneChatClass(chat)) + } + return out +} + +func (r *Router) invalidateRPCProjectionForViewer(viewerUserID int64) { + if r.userFullProjectionCache != nil { + r.userFullProjectionCache.DeleteViewer(viewerUserID) + } + if r.peerSettingsProjectionCache != nil { + r.peerSettingsProjectionCache.DeleteViewer(viewerUserID) + } + if r.channelFullProjectionCache != nil { + r.channelFullProjectionCache.DeleteViewer(viewerUserID) + } +} + +func (r *Router) invalidateRPCProjectionForUser(userID int64) { + if r.userFullProjectionCache != nil { + r.userFullProjectionCache.DeleteTarget(userID) + r.userFullProjectionCache.DeleteViewer(userID) + } + if r.peerSettingsProjectionCache != nil { + r.peerSettingsProjectionCache.DeleteViewer(userID) + r.peerSettingsProjectionCache.DeletePeer(domain.Peer{Type: domain.PeerTypeUser, ID: userID}) + } +} + +func (r *Router) invalidateRPCProjectionForPeer(ownerUserID int64, peer domain.Peer) { + if r.userFullProjectionCache != nil && peer.Type == domain.PeerTypeUser { + r.userFullProjectionCache.DeletePair(ownerUserID, peer.ID) + } + if r.peerSettingsProjectionCache != nil { + r.peerSettingsProjectionCache.DeletePair(ownerUserID, peer) + } + if r.channelFullProjectionCache != nil && peer.Type == domain.PeerTypeChannel { + r.channelFullProjectionCache.DeletePair(ownerUserID, peer.ID) + } +} + +func (r *Router) invalidateRPCProjectionForChannel(channelID int64) { + if r.channelFullProjectionCache != nil { + r.channelFullProjectionCache.DeleteChannel(channelID) + } + if r.peerSettingsProjectionCache != nil { + r.peerSettingsProjectionCache.DeletePeer(domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) + } +} + +func (r *Router) flushRPCProjectionCache() { + if r.userFullProjectionCache != nil { + r.userFullProjectionCache.Clear() + } + if r.peerSettingsProjectionCache != nil { + r.peerSettingsProjectionCache.Clear() + } + if r.channelFullProjectionCache != nil { + r.channelFullProjectionCache.Clear() + } +} + +func (r *Router) InvalidateRPCProjectionReadModelForViewer(viewerUserID int64) { + r.invalidateRPCProjectionForViewer(viewerUserID) +} + +func (r *Router) InvalidateRPCProjectionReadModelForUser(userID int64) { + r.invalidateRPCProjectionForUser(userID) +} + +func (r *Router) InvalidateRPCProjectionReadModelForPeer(ownerUserID int64, peer domain.Peer) { + r.invalidateRPCProjectionForPeer(ownerUserID, peer) +} + +func (r *Router) InvalidateRPCProjectionReadModelForChannel(channelID int64) { + r.invalidateRPCProjectionForChannel(channelID) +} + +func (r *Router) FlushRPCProjectionReadModel() { + r.flushRPCProjectionCache() +} diff --git a/internal/rpc/rpc_testkit_auth_test.go b/internal/rpc/rpc_testkit_auth_test.go new file mode 100644 index 00000000..5f662ec0 --- /dev/null +++ b/internal/rpc/rpc_testkit_auth_test.go @@ -0,0 +1,246 @@ +package rpc + +import ( + "context" + "sync" + "telesrv/internal/domain" +) + +type captureAuthService struct { + resolvedAuthKeyID [8]byte + hasResolved bool + resolveCount int + userID int64 + userIDCount int + signInUser domain.User + signUpPhone string + signUpHash string + signUpFirstName string + signUpLastName string + signUpAuth domain.Authorization + signUpUser domain.User + acceptedAuth domain.Authorization + acceptedUserID int64 + authorizations []domain.Authorization + authorizationLookups int + authorizationLists int + loggedOutAuthKeyID [8]byte + pendingPasswordUserID int64 + pendingPassword bool + completedPasswordKey [8]byte + completePasswordCount int +} + +type blockingUserAuthService struct { + userID int64 + started chan struct{} + release chan struct{} + once sync.Once + mu sync.Mutex + count int +} + +func (s *blockingUserAuthService) UserIDCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.count +} + +func (s *blockingUserAuthService) BindTempAuthKey(context.Context, int64, domain.TempAuthKeyBinding) error { + return nil +} + +func (s *blockingUserAuthService) ResolveAuthKey(context.Context, [8]byte) ([8]byte, bool, error) { + return [8]byte{}, false, nil +} + +func (s *blockingUserAuthService) UserID(ctx context.Context, _ [8]byte) (int64, bool, error) { + s.mu.Lock() + s.count++ + s.mu.Unlock() + s.once.Do(func() { close(s.started) }) + select { + case <-s.release: + return s.userID, s.userID != 0, nil + case <-ctx.Done(): + return 0, false, ctx.Err() + } +} + +func (s *blockingUserAuthService) SendCode(context.Context, string) (string, error) { + return "", nil +} + +func (s *blockingUserAuthService) ResendCode(context.Context, string, string) (string, error) { + return "", nil +} + +func (s *blockingUserAuthService) CancelCode(context.Context, string, string) error { + return nil +} + +func (s *blockingUserAuthService) SignIn(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) { + return domain.User{}, domain.Message{}, false, nil +} + +func (s *blockingUserAuthService) SignInWithEmail(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) { + return domain.User{}, domain.Message{}, false, nil +} + +func (s *blockingUserAuthService) BindVerifiedLogin(_ context.Context, _ domain.Authorization, userID int64) (domain.User, error) { + return domain.User{ID: userID}, nil +} + +func (s *blockingUserAuthService) SignUp(context.Context, domain.Authorization, string, string, string, string) (domain.User, domain.Message, error) { + return domain.User{}, domain.Message{}, nil +} + +func (s *blockingUserAuthService) AcceptLoginToken(context.Context, domain.Authorization, int64) (domain.Authorization, error) { + return domain.Authorization{}, nil +} + +func (s *blockingUserAuthService) SignInBot(context.Context, domain.Authorization, string) (domain.User, error) { + return domain.User{}, domain.ErrBotTokenInvalid +} + +func (s *blockingUserAuthService) LogOut(context.Context, [8]byte) error { + return nil +} + +func (s *blockingUserAuthService) Authorization(context.Context, [8]byte) (domain.Authorization, bool, error) { + return domain.Authorization{}, false, nil +} + +func (s *blockingUserAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) { + return nil, nil +} + +func (s *blockingUserAuthService) ResetAuthorization(context.Context, int64, int64) (domain.Authorization, bool, error) { + return domain.Authorization{}, false, nil +} + +func (s *blockingUserAuthService) ResetAuthorizations(context.Context, int64, [8]byte) ([]domain.Authorization, error) { + return nil, nil +} + +func (s *blockingUserAuthService) PendingPasswordUserID(context.Context, [8]byte) (int64, bool, error) { + return 0, false, nil +} + +func (s *blockingUserAuthService) CompletePasswordSignIn(context.Context, [8]byte) error { + return nil +} + +func (s *captureAuthService) BindTempAuthKey(context.Context, int64, domain.TempAuthKeyBinding) error { + return nil +} + +func (s *captureAuthService) ResolveAuthKey(context.Context, [8]byte) ([8]byte, bool, error) { + s.resolveCount++ + return s.resolvedAuthKeyID, s.hasResolved, nil +} + +func (s *captureAuthService) UserID(context.Context, [8]byte) (int64, bool, error) { + s.userIDCount++ + return s.userID, s.userID != 0, nil +} + +func (s *captureAuthService) SendCode(context.Context, string) (string, error) { + return "", nil +} + +func (s *captureAuthService) ResendCode(context.Context, string, string) (string, error) { + return "", nil +} + +func (s *captureAuthService) CancelCode(context.Context, string, string) error { + return nil +} + +func (s *captureAuthService) SignIn(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) { + if s.signInUser.ID != 0 { + return s.signInUser, domain.Message{}, false, nil + } + return domain.User{}, domain.Message{}, false, nil +} + +func (s *captureAuthService) SignInWithEmail(context.Context, domain.Authorization, string, string, string) (domain.User, domain.Message, bool, error) { + if s.signInUser.ID != 0 { + return s.signInUser, domain.Message{}, false, nil + } + return domain.User{}, domain.Message{}, false, nil +} + +func (s *captureAuthService) BindVerifiedLogin(_ context.Context, _ domain.Authorization, userID int64) (domain.User, error) { + if s.signInUser.ID != 0 { + return s.signInUser, nil + } + return domain.User{ID: userID}, nil +} + +func (s *captureAuthService) SignUp(_ context.Context, a domain.Authorization, phone, hash, first, last string) (domain.User, domain.Message, error) { + s.signUpAuth = a + s.signUpPhone = phone + s.signUpHash = hash + s.signUpFirstName = first + s.signUpLastName = last + if s.signUpUser.ID != 0 { + return s.signUpUser, domain.Message{}, nil + } + return domain.User{}, domain.Message{}, nil +} + +func (s *captureAuthService) AcceptLoginToken(_ context.Context, a domain.Authorization, userID int64) (domain.Authorization, error) { + a.UserID = userID + if a.Hash == 0 { + a.Hash = 77 + } + s.acceptedAuth = a + s.acceptedUserID = userID + return a, nil +} + +func (s *captureAuthService) SignInBot(context.Context, domain.Authorization, string) (domain.User, error) { + if s.signInUser.ID != 0 { + return s.signInUser, nil + } + return domain.User{}, domain.ErrBotTokenInvalid +} + +func (s *captureAuthService) LogOut(_ context.Context, authKeyID [8]byte) error { + s.loggedOutAuthKeyID = authKeyID + return nil +} + +func (s *captureAuthService) Authorization(_ context.Context, authKeyID [8]byte) (domain.Authorization, bool, error) { + s.authorizationLookups++ + for _, item := range s.authorizations { + if item.AuthKeyID == authKeyID { + return item, true, nil + } + } + return domain.Authorization{}, false, nil +} + +func (s *captureAuthService) ListAuthorizations(context.Context, int64) ([]domain.Authorization, error) { + s.authorizationLists++ + return append([]domain.Authorization(nil), s.authorizations...), nil +} + +func (s *captureAuthService) ResetAuthorization(context.Context, int64, int64) (domain.Authorization, bool, error) { + return domain.Authorization{}, false, nil +} + +func (s *captureAuthService) ResetAuthorizations(context.Context, int64, [8]byte) ([]domain.Authorization, error) { + return nil, nil +} + +func (s *captureAuthService) PendingPasswordUserID(context.Context, [8]byte) (int64, bool, error) { + return s.pendingPasswordUserID, s.pendingPassword, nil +} + +func (s *captureAuthService) CompletePasswordSignIn(_ context.Context, authKeyID [8]byte) error { + s.completedPasswordKey = authKeyID + s.completePasswordCount++ + return nil +} diff --git a/internal/rpc/rpc_testkit_clock_test.go b/internal/rpc/rpc_testkit_clock_test.go new file mode 100644 index 00000000..39a52bc2 --- /dev/null +++ b/internal/rpc/rpc_testkit_clock_test.go @@ -0,0 +1,22 @@ +package rpc + +import ( + "github.com/gotd/td/clock" + "time" +) + +type fixedClock struct { + now time.Time +} + +func (c fixedClock) Now() time.Time { + return c.now +} + +func (c fixedClock) Timer(d time.Duration) clock.Timer { + return clock.System.Timer(d) +} + +func (c fixedClock) Ticker(d time.Duration) clock.Ticker { + return clock.System.Ticker(d) +} diff --git a/internal/rpc/rpc_testkit_dialogs_test.go b/internal/rpc/rpc_testkit_dialogs_test.go new file mode 100644 index 00000000..c707e6c1 --- /dev/null +++ b/internal/rpc/rpc_testkit_dialogs_test.go @@ -0,0 +1,149 @@ +package rpc + +import ( + "context" + "telesrv/internal/domain" +) + +type captureDialogs struct { + list domain.DialogList + pinnedList domain.DialogList + hasPinnedList bool + peerList domain.DialogList + hashCheck domain.DialogHashCheck + folderList domain.DialogFolderList + filter domain.DialogFilter + filters []domain.DialogFilter + hashFilter domain.DialogFilter + getDialogsCalls int + hashCalls int + peers []domain.Peer + folderID int + archivePinned bool + savedFolder domain.DialogFolder + deletedFolderID int + folderOrder []int + tagsEnabled bool + folderPeers []domain.FolderPeerUpdate + drafts []domain.DialogDraft + savedDraft domain.DialogDraft + deletedDraft struct { + peer domain.Peer + topMessageID int + } + reorderNoChange bool +} + +func (s *captureDialogs) GetDialogsHash(_ context.Context, _ int64, filter domain.DialogFilter) (domain.DialogHashCheck, error) { + s.hashCalls++ + s.hashFilter = filter + return s.hashCheck, nil +} + +func (s *captureDialogs) GetDialogs(_ context.Context, _ int64, filter domain.DialogFilter) (domain.DialogList, error) { + s.getDialogsCalls++ + s.filter = filter + s.filters = append(s.filters, filter) + if filter.PinnedOnly && s.hasPinnedList { + return s.pinnedList, nil + } + return s.list, nil +} + +func (s *captureDialogs) GetPeerDialogs(_ context.Context, _ int64, peers []domain.Peer) (domain.DialogList, error) { + s.peers = append([]domain.Peer(nil), peers...) + return s.peerList, nil +} + +func (s *captureDialogs) SaveDraft(_ context.Context, _ int64, draft domain.DialogDraft) (bool, error) { + s.savedDraft = draft + return true, nil +} + +func (s *captureDialogs) DeleteDraft(_ context.Context, _ int64, peer domain.Peer, topMessageID int) (bool, error) { + s.deletedDraft.peer = peer + s.deletedDraft.topMessageID = topMessageID + return true, nil +} + +func (s *captureDialogs) GetDraft(_ context.Context, _ int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) { + for _, draft := range s.drafts { + if draft.Peer == peer && draft.TopMessageID == topMessageID { + return draft, true, nil + } + } + return domain.DialogDraft{}, false, nil +} + +func (s *captureDialogs) ListDrafts(_ context.Context, _ int64, _ int) ([]domain.DialogDraft, error) { + return append([]domain.DialogDraft(nil), s.drafts...), nil +} + +func (s *captureDialogs) ClearDrafts(_ context.Context, _ int64, _ int) ([]domain.DialogDraft, error) { + drafts := append([]domain.DialogDraft(nil), s.drafts...) + s.drafts = nil + return drafts, nil +} + +func (s *captureDialogs) TogglePinned(_ context.Context, _ int64, peer domain.Peer, _ bool) (bool, int, error) { + s.peers = []domain.Peer{peer} + return true, s.folderID, nil +} + +func (s *captureDialogs) ToggleArchivePinned(_ context.Context, _ int64, pinned bool) (bool, error) { + s.archivePinned = pinned + return true, nil +} + +func (s *captureDialogs) ReorderPinned(_ context.Context, _ int64, folderID int, order []domain.Peer, _ bool) (bool, error) { + s.folderID = folderID + s.peers = append([]domain.Peer(nil), order...) + return !s.reorderNoChange, nil +} + +func (s *captureDialogs) MarkUnread(_ context.Context, _ int64, peer domain.Peer, _ bool) (bool, error) { + s.peers = []domain.Peer{peer} + return true, nil +} + +func (s *captureDialogs) UnreadMarks(_ context.Context, _ int64) ([]domain.Peer, error) { + return s.peers, nil +} + +func (s *captureDialogs) HidePeerSettingsBar(_ context.Context, _ int64, peer domain.Peer) (bool, error) { + s.peers = []domain.Peer{peer} + return true, nil +} + +func (s *captureDialogs) PeerSettingsBarHidden(_ context.Context, _ int64, _ domain.Peer) (bool, error) { + return false, nil +} + +func (s *captureDialogs) GetDialogFolders(_ context.Context, _ int64) (domain.DialogFolderList, error) { + return s.folderList, nil +} + +func (s *captureDialogs) SaveDialogFolder(_ context.Context, _ int64, folder domain.DialogFolder) error { + s.savedFolder = folder + return nil +} + +func (s *captureDialogs) DeleteDialogFolder(_ context.Context, _ int64, folderID int) error { + s.deletedFolderID = folderID + return nil +} + +func (s *captureDialogs) ReorderDialogFolders(_ context.Context, _ int64, order []int) error { + s.folderOrder = append([]int(nil), order...) + return nil +} + +func (s *captureDialogs) ToggleDialogFolderTags(_ context.Context, _ int64, enabled bool) error { + s.tagsEnabled = enabled + return nil +} + +func (s *captureDialogs) EditPeerFolders(_ context.Context, _ int64, peers []domain.FolderPeerUpdate) error { + s.folderPeers = append([]domain.FolderPeerUpdate(nil), peers...) + return nil +} diff --git a/internal/rpc/rpc_testkit_helpers_test.go b/internal/rpc/rpc_testkit_helpers_test.go new file mode 100644 index 00000000..72a47376 --- /dev/null +++ b/internal/rpc/rpc_testkit_helpers_test.go @@ -0,0 +1,245 @@ +package rpc + +import ( + "context" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" + "testing" +) + +type rpcChannelFixture struct { + t *testing.T + ctx context.Context + users *memory.UserStore + channels *memory.ChannelStore + router *Router +} + +func newRPCChannelFixture(t *testing.T) *rpcChannelFixture { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + channelStore := memory.NewChannelStore() + return &rpcChannelFixture{ + t: t, + ctx: ctx, + users: userStore, + channels: channelStore, + router: New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System), + } +} + +func (f *rpcChannelFixture) user(accessHash int64, phone, firstName string) domain.User { + f.t.Helper() + user, err := f.users.Create(f.ctx, domain.User{ + AccessHash: accessHash, + Phone: phone, + FirstName: firstName, + }) + if err != nil { + f.t.Fatalf("create user %s: %v", firstName, err) + } + return user +} + +func (f *rpcChannelFixture) userCtx(user domain.User) context.Context { + return WithUserID(f.ctx, user.ID) +} + +func (f *rpcChannelFixture) createLegacyMegagroup(owner domain.User, title string, users ...domain.User) *tg.Channel { + f.t.Helper() + inputUsers := make([]tg.InputUserClass, 0, len(users)) + for _, user := range users { + inputUsers = append(inputUsers, inputUser(user)) + } + created, err := f.router.onMessagesCreateChat(f.userCtx(owner), &tg.MessagesCreateChatRequest{ + Users: inputUsers, + Title: title, + }) + if err != nil { + f.t.Fatalf("create chat: %v", err) + } + updates, ok := created.Updates.(*tg.Updates) + if !ok || len(updates.Chats) == 0 { + f.t.Fatalf("create chat updates = %T %+v, want chats", created.Updates, created.Updates) + } + channel, ok := updates.Chats[0].(*tg.Channel) + if !ok { + f.t.Fatalf("created chat = %T, want *tg.Channel", updates.Chats[0]) + } + return channel +} + +func inputUser(user domain.User) *tg.InputUser { + return &tg.InputUser{UserID: user.ID, AccessHash: user.AccessHash} +} + +func inputPeerChannel(channel *tg.Channel) *tg.InputPeerChannel { + return inputPeerChannelWithHash(channel, channel.AccessHash) +} + +func inputPeerChannelWithHash(channel *tg.Channel, accessHash int64) *tg.InputPeerChannel { + return &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: accessHash} +} + +func inputChannel(channel *tg.Channel) *tg.InputChannel { + return inputChannelWithHash(channel, channel.AccessHash) +} + +func inputChannelWithHash(channel *tg.Channel, accessHash int64) *tg.InputChannel { + return &tg.InputChannel{ChannelID: channel.ID, AccessHash: accessHash} +} + +func searchMessagesPayload(t *testing.T, enc bin.Encoder) ([]tg.MessageClass, []tg.ChatClass, []tg.UserClass) { + t.Helper() + switch result := enc.(type) { + case *tg.MessagesMessages: + return result.Messages, result.Chats, result.Users + case *tg.MessagesMessagesSlice: + return result.Messages, result.Chats, result.Users + case *tg.MessagesChannelMessages: + return result.Messages, result.Chats, result.Users + case *tg.MessagesMessagesBox: + return searchMessagesPayload(t, result.Messages) + default: + t.Fatalf("search result type = %T, want messages/messagesSlice", enc) + return nil, nil, nil + } +} + +func incrementalViewCount(result *tg.MessagesMessageViews) (int, bool) { + if result == nil || len(result.Views) == 0 { + return 0, false + } + return result.Views[0].GetViews() +} + +func assertDefaultBannedRightsAllowsSend(t *testing.T, chat tg.ChatClass) { + t.Helper() + var rights tg.ChatBannedRights + var ok bool + switch ch := chat.(type) { + case *tg.Channel: + rights, ok = ch.GetDefaultBannedRights() + case *tg.Chat: + rights, ok = ch.GetDefaultBannedRights() + default: + t.Fatalf("chat = %T, want channel/chat with default banned rights", chat) + } + if !ok { + t.Fatalf("default_banned_rights missing in %T", chat) + } + if rights.SendMessages { + t.Fatalf("default_banned_rights.send_messages = true, want false") + } + if rights.UntilDate != defaultChatBannedRightsUntilDate { + t.Fatalf("default_banned_rights.until_date = %d, want %d", rights.UntilDate, defaultChatBannedRightsUntilDate) + } +} + +func pushedUserStatus(t *testing.T, msg bin.Encoder) *tg.UpdateUserStatus { + t.Helper() + updates, ok := msg.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + t.Fatalf("pushed message = %T %+v, want one update", msg, msg) + } + update, ok := updates.Updates[0].(*tg.UpdateUserStatus) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateUserStatus", updates.Updates[0]) + } + return update +} + +func waitForPushedUserIDs(t *testing.T, sessions *captureSessions, min int) []int64 { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + got := sessions.pushedUserIDs() + if len(got) >= min { + return got + } + if time.Now().After(deadline) { + t.Fatalf("pushed users = %+v, want at least %d", got, min) + } + time.Sleep(5 * time.Millisecond) + } +} + +func waitForLastUserPush(t *testing.T, sessions *captureSessions) bin.Encoder { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + if msg := sessions.lastUserPush(); msg != nil { + return msg + } + if time.Now().After(deadline) { + t.Fatal("timed out waiting for user push") + } + time.Sleep(5 * time.Millisecond) + } +} + +func waitForSessionUserStatus(t *testing.T, sessions *captureSessions, userID int64) *tg.UpdateUserStatus { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + if update := userStatusFromMessage(sessions.snapshot().message); update != nil && update.UserID == userID { + return update + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for session user status user_id=%d", userID) + } + time.Sleep(5 * time.Millisecond) + } +} + +func userStatusFromMessage(msg bin.Encoder) *tg.UpdateUserStatus { + updates, ok := msg.(*tg.Updates) + if !ok || len(updates.Updates) != 1 { + return nil + } + update, ok := updates.Updates[0].(*tg.UpdateUserStatus) + if !ok { + return nil + } + return update +} + +func newBlockingUserAuthService(userID int64) *blockingUserAuthService { + return &blockingUserAuthService{ + userID: userID, + started: make(chan struct{}), + release: make(chan struct{}), + } +} + +func limitIDs(ids []int64, limit int) []int64 { + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} diff --git a/internal/rpc/rpc_testkit_messages_test.go b/internal/rpc/rpc_testkit_messages_test.go new file mode 100644 index 00000000..e871e205 --- /dev/null +++ b/internal/rpc/rpc_testkit_messages_test.go @@ -0,0 +1,594 @@ +package rpc + +import ( + "context" + "telesrv/internal/domain" +) + +type captureMessages struct { + list domain.MessageList + filter domain.MessageFilter + sendResult domain.SendPrivateTextResult + sendUserID int64 + sendReq domain.SendPrivateTextRequest + setThemeUserID int64 + setThemeReq domain.SetPrivateChatThemeRequest + setThemeRes domain.SetPrivateChatThemeResult + forwardUserID int64 + forwardReq domain.ForwardPrivateMessagesRequest + forwardRes domain.ForwardPrivateMessagesResult + readResult domain.ReadHistoryResult + readReq domain.ReadHistoryRequest + readPeer domain.Peer + readMaxID int + readContentsReq domain.ReadMessageContentsRequest + readContentsRes domain.ReadMessageContentsResult + setReactionReq domain.SetPrivateMessageReactionsRequest + setReactionRes domain.PrivateMessageReactionsResult + getReactionReq domain.PrivateMessageReactionsRequest + getReactionRes domain.PrivateMessageReactionsResult + getMessagesCalls int + getMessagesIDs [][]int + getMessagesListed bool + editReq domain.EditMessageRequest + editRes domain.EditMessageResult + outboxReadDateReq domain.OutboxReadDateRequest + outboxReadDate int + deleteMessagesReq domain.DeleteMessagesRequest + deleteMessagesRes domain.DeleteMessagesResult + deleteHistoryReq domain.DeleteHistoryRequest + deleteHistoryRes domain.DeleteMessagesResult +} + +type scheduledCaptureMessages struct { + *captureMessages + scheduled []domain.ScheduledMessage + scheduleReq domain.ScheduleMessageRequest + editScheduledReq domain.EditScheduledMessageRequest + claimScheduledReq domain.ScheduledMessageClaim + deletedScheduled domain.ScheduledMessageFilter + markedScheduledID int + markedSentID int + releasedScheduled int + releasedErrMessage string +} + +type ttlCaptureMessages struct { + *captureMessages + setTTLUserID int64 + setTTLPeer domain.Peer + setTTLPeriod int + defaultTTLUserID int64 + defaultTTLPeriod int + expiredPrivateClaims []domain.DeleteMessagesRequest +} + +func (s *ttlCaptureMessages) GetPrivateHistoryTTL(_ context.Context, _ int64, _ domain.Peer) (int, error) { + return s.setTTLPeriod, nil +} + +func (s *ttlCaptureMessages) SetPrivateHistoryTTL(_ context.Context, userID int64, peer domain.Peer, period int) error { + s.setTTLUserID = userID + s.setTTLPeer = peer + s.setTTLPeriod = period + return nil +} + +func (s *ttlCaptureMessages) DefaultHistoryTTL(_ context.Context, userID int64) (int, error) { + s.defaultTTLUserID = userID + return s.defaultTTLPeriod, nil +} + +func (s *ttlCaptureMessages) SetDefaultHistoryTTL(_ context.Context, userID int64, period int) error { + s.defaultTTLUserID = userID + s.defaultTTLPeriod = period + return nil +} + +func (s *ttlCaptureMessages) ClaimExpiredPrivateMessages(context.Context, int, int) ([]domain.DeleteMessagesRequest, error) { + return append([]domain.DeleteMessagesRequest(nil), s.expiredPrivateClaims...), nil +} + +func (s *scheduledCaptureMessages) ScheduleMessage(_ context.Context, userID int64, req domain.ScheduleMessageRequest) (domain.ScheduledMessage, error) { + s.scheduleReq = req + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + msg := domain.ScheduledMessage{ + OwnerUserID: req.OwnerUserID, + ID: len(s.scheduled) + 41, + Peer: req.Peer, + RandomID: req.RandomID, + Message: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + Silent: req.Silent, + NoForwards: req.NoForwards, + ReplyTo: req.ReplyTo, + Forward: req.Forward, + SendAs: req.SendAs, + ScheduleDate: req.ScheduleDate, + ScheduleRepeatPeriod: req.ScheduleRepeatPeriod, + CreatedAt: req.Date, + UpdatedAt: req.Date, + State: "pending", + } + s.scheduled = append(s.scheduled, msg) + return msg, nil +} + +func (s *scheduledCaptureMessages) EditScheduledMessage(_ context.Context, userID int64, req domain.EditScheduledMessageRequest) (domain.ScheduledMessage, error) { + s.editScheduledReq = req + if req.OwnerUserID == 0 { + req.OwnerUserID = userID + } + for i := range s.scheduled { + if s.scheduled[i].OwnerUserID == req.OwnerUserID && s.scheduled[i].Peer == req.Peer && s.scheduled[i].ID == req.ID { + if req.SetMessage { + s.scheduled[i].Message = req.Message + s.scheduled[i].Entities = append([]domain.MessageEntity(nil), req.Entities...) + } + s.scheduled[i].ScheduleDate = req.ScheduleDate + s.scheduled[i].UpdatedAt = req.Date + return s.scheduled[i], nil + } + } + return domain.ScheduledMessage{}, domain.ErrMessageIDInvalid +} + +func (s *scheduledCaptureMessages) ListScheduledMessages(_ context.Context, userID int64, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) { + if filter.OwnerUserID == 0 { + filter.OwnerUserID = userID + } + items := s.matchScheduled(filter) + return domain.ScheduledMessageList{Messages: items, Count: len(items)}, nil +} + +func (s *scheduledCaptureMessages) GetScheduledMessages(_ context.Context, userID int64, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) { + if filter.OwnerUserID == 0 { + filter.OwnerUserID = userID + } + items := s.matchScheduled(filter) + return domain.ScheduledMessageList{Messages: items, Count: len(items)}, nil +} + +func (s *scheduledCaptureMessages) DeleteScheduledMessages(_ context.Context, userID int64, filter domain.ScheduledMessageFilter, _ int) ([]domain.ScheduledMessage, error) { + if filter.OwnerUserID == 0 { + filter.OwnerUserID = userID + } + s.deletedScheduled = filter + deleted := s.matchScheduled(filter) + s.removeScheduled(filter) + return deleted, nil +} + +func (s *scheduledCaptureMessages) ClaimScheduledMessages(_ context.Context, userID int64, claim domain.ScheduledMessageClaim) ([]domain.ScheduledMessage, error) { + if claim.OwnerUserID == 0 { + claim.OwnerUserID = userID + } + s.claimScheduledReq = claim + return s.matchScheduled(domain.ScheduledMessageFilter{OwnerUserID: claim.OwnerUserID, Peer: claim.Peer, IDs: claim.IDs}), nil +} + +func (s *scheduledCaptureMessages) ClaimDueScheduledMessages(context.Context, int, int, int) ([]domain.ScheduledMessage, error) { + return nil, nil +} + +func (s *scheduledCaptureMessages) MarkScheduledMessageSent(_ context.Context, _ int64, id, sentMessageID, _ int) error { + s.markedScheduledID = id + s.markedSentID = sentMessageID + s.removeScheduled(domain.ScheduledMessageFilter{OwnerUserID: s.scheduleReq.OwnerUserID, Peer: s.scheduleReq.Peer, IDs: []int{id}}) + return nil +} + +func (s *scheduledCaptureMessages) ReleaseScheduledMessage(_ context.Context, _ int64, id int, errText string) error { + s.releasedScheduled = id + s.releasedErrMessage = errText + return nil +} + +func (s *scheduledCaptureMessages) HasScheduledMessages(_ context.Context, userID int64, peer domain.Peer) (bool, error) { + return len(s.matchScheduled(domain.ScheduledMessageFilter{OwnerUserID: userID, Peer: peer})) > 0, nil +} + +func (s *scheduledCaptureMessages) matchScheduled(filter domain.ScheduledMessageFilter) []domain.ScheduledMessage { + idSet := make(map[int]struct{}, len(filter.IDs)) + for _, id := range filter.IDs { + idSet[id] = struct{}{} + } + out := make([]domain.ScheduledMessage, 0, len(s.scheduled)) + for _, msg := range s.scheduled { + if filter.OwnerUserID != 0 && msg.OwnerUserID != filter.OwnerUserID { + continue + } + if filter.Peer.ID != 0 && msg.Peer != filter.Peer { + continue + } + if len(idSet) > 0 { + if _, ok := idSet[msg.ID]; !ok { + continue + } + } + out = append(out, msg) + } + return out +} + +func (s *scheduledCaptureMessages) removeScheduled(filter domain.ScheduledMessageFilter) { + idSet := make(map[int]struct{}, len(filter.IDs)) + for _, id := range filter.IDs { + idSet[id] = struct{}{} + } + kept := s.scheduled[:0] + for _, msg := range s.scheduled { + if filter.OwnerUserID != 0 && msg.OwnerUserID != filter.OwnerUserID { + kept = append(kept, msg) + continue + } + if filter.Peer.ID != 0 && msg.Peer != filter.Peer { + kept = append(kept, msg) + continue + } + if len(idSet) > 0 { + if _, ok := idSet[msg.ID]; ok { + continue + } + } + kept = append(kept, msg) + } + s.scheduled = kept +} + +func (s *captureMessages) SendPrivateText(_ context.Context, userID int64, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { + s.sendUserID = userID + s.sendReq = req + if s.sendResult.SenderMessage.ID == 0 { + s.sendResult.SenderMessage = domain.Message{ + ID: 1, + OwnerUserID: req.SenderUserID, + RandomID: req.RandomID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, + Date: req.Date, + Out: true, + Silent: req.Silent, + NoForwards: req.NoForwards, + Body: req.Message, + Entities: req.Entities, + Media: req.Media, + ReplyTo: req.ReplyTo, + Forward: req.Forward, + ViaBotID: req.ViaBotID, + Pts: 1, + } + s.sendResult.SenderEvent = domain.UpdateEvent{ + UserID: req.SenderUserID, + Type: domain.UpdateEventNewMessage, + Pts: 1, + PtsCount: 1, + Date: req.Date, + Message: s.sendResult.SenderMessage, + } + } + return s.sendResult, nil +} + +func (s *captureMessages) SetChatTheme(_ context.Context, userID int64, req domain.SetPrivateChatThemeRequest) (domain.SetPrivateChatThemeResult, error) { + s.setThemeUserID = userID + s.setThemeReq = req + if s.setThemeRes.OwnerUserID == 0 { + s.setThemeRes = domain.SetPrivateChatThemeResult{ + OwnerUserID: userID, + Peer: req.Peer, + Emoticon: req.Emoticon, + Changed: true, + Send: domain.SendPrivateTextResult{ + SenderMessage: domain.Message{ + ID: 1, + OwnerUserID: userID, + Peer: req.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: userID}, + Date: req.Date, + Out: true, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionSetChatTheme, + ChatThemeEmoticon: req.Emoticon, + }, + }, + Pts: 1, + }, + SenderEvent: domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventNewMessage, + Pts: 1, + PtsCount: 1, + Date: req.Date, + }, + }, + } + s.setThemeRes.Send.SenderEvent.Message = s.setThemeRes.Send.SenderMessage + } + return s.setThemeRes, nil +} + +func (s *captureMessages) ForwardPrivateMessages(_ context.Context, userID int64, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { + s.forwardUserID = userID + s.forwardReq = req + if len(s.forwardRes.SenderMessages) == 0 { + s.forwardRes.OwnerUserID = userID + s.forwardRes.SenderMessages = make([]domain.Message, 0, len(req.MessageIDs)) + s.forwardRes.SenderEvents = make([]domain.UpdateEvent, 0, len(req.MessageIDs)) + for i := range req.MessageIDs { + msg := domain.Message{ + ID: i + 1, + OwnerUserID: req.OwnerUserID, + RandomID: req.RandomIDs[i], + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.ToUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + Date: req.Date, + Out: true, + Silent: req.Silent, + NoForwards: req.NoForwards, + Body: "forwarded", + ReplyTo: req.ReplyTo, + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeUser, ID: req.FromPeer.ID}, Date: req.Date - 1}, + Pts: i + 1, + } + event := domain.UpdateEvent{ + UserID: req.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: req.Date, + Message: msg, + } + s.forwardRes.SenderMessages = append(s.forwardRes.SenderMessages, msg) + s.forwardRes.SenderEvents = append(s.forwardRes.SenderEvents, event) + } + } + return s.forwardRes, nil +} + +func (s *captureMessages) GetMessages(_ context.Context, _ int64, ids []int) (domain.MessageList, error) { + s.getMessagesCalls++ + s.getMessagesIDs = append(s.getMessagesIDs, append([]int(nil), ids...)) + byID := make(map[int]domain.Message, len(s.list.Messages)) + for _, msg := range s.list.Messages { + byID[msg.ID] = msg + } + out := domain.MessageList{Messages: make([]domain.Message, 0, len(ids)), Users: s.list.Users} + if s.getMessagesListed { + for _, msg := range s.list.Messages { + for _, id := range ids { + if msg.ID == id { + out.Messages = append(out.Messages, msg) + break + } + } + } + return out, nil + } + for _, id := range ids { + if msg, ok := byID[id]; ok { + out.Messages = append(out.Messages, msg) + } + } + return out, nil +} + +func (s *captureMessages) GetHistory(_ context.Context, _ int64, filter domain.MessageFilter) (domain.MessageList, error) { + s.filter = filter + return s.list, nil +} + +func (s *captureMessages) Search(_ context.Context, _ int64, filter domain.MessageFilter) (domain.MessageList, error) { + s.filter = filter + return s.list, nil +} + +func (s *captureMessages) SearchPrivateMedia(_ context.Context, _, _ int64, _ domain.MediaSearchRequest) (domain.MessageList, error) { + return domain.MessageList{}, nil +} + +func (s *captureMessages) CountPrivateMediaCategories(_ context.Context, _, _ int64) (domain.MediaCategoryCounts, error) { + return domain.MediaCategoryCounts{}, nil +} + +func (s *captureMessages) ReadHistory(_ context.Context, _ int64, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) { + s.readReq = req + s.readPeer = req.Peer + s.readMaxID = req.MaxID + if s.readResult.OwnerUserID == 0 { + s.readResult.OwnerUserID = req.OwnerUserID + } + if s.readResult.Peer.ID == 0 { + s.readResult.Peer = req.Peer + } + if s.readResult.MaxID == 0 { + s.readResult.MaxID = req.MaxID + } + return s.readResult, nil +} + +func (s *captureMessages) ReadMessageContents(_ context.Context, userID int64, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { + s.readContentsReq = req + if s.readContentsRes.OwnerUserID == 0 { + s.readContentsRes.OwnerUserID = userID + } + return s.readContentsRes, nil +} + +func (s *captureMessages) GetOutboxReadDate(_ context.Context, _ int64, req domain.OutboxReadDateRequest) (int, error) { + s.outboxReadDateReq = req + if s.outboxReadDate == 0 { + return 0, domain.ErrMessageNotReadYet + } + return s.outboxReadDate, nil +} + +func (s *captureMessages) SetMessageReactions(_ context.Context, userID int64, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + s.setReactionReq = req + if len(s.setReactionRes.Messages) == 0 { + if len(req.Reactions) == 0 { + reactions := domain.ChannelMessageReactions{CanSeeList: true, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}} + s.setReactionRes = domain.PrivateMessageReactionsResult{ + Messages: []domain.Message{{ + ID: req.MessageID, + OwnerUserID: userID, + Peer: req.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.Peer.ID}, + Date: req.Date, + Reactions: &reactions, + }}, + Reactions: reactions, + } + return s.setReactionRes, nil + } + reactions := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{{ + Reaction: req.Reactions[0], + Count: 1, + ChosenOrder: 1, + }}, + Recent: []domain.ChannelMessagePeerReaction{{ + UserID: userID, + Reaction: req.Reactions[0], + My: true, + Big: req.Big, + ChosenOrder: 1, + Date: req.Date, + }}, + } + s.setReactionRes = domain.PrivateMessageReactionsResult{ + Messages: []domain.Message{{ + ID: req.MessageID, + OwnerUserID: userID, + Peer: req.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.Peer.ID}, + Date: req.Date, + Reactions: &reactions, + }}, + Reactions: reactions, + } + } + return s.setReactionRes, nil +} + +func (s *captureMessages) VoteMessagePoll(_ context.Context, _ int64, _ domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) { + return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid +} + +func (s *captureMessages) CloseMessagePoll(_ context.Context, _ int64, _ domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) { + return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid +} + +func (s *captureMessages) GetMessageReactions(_ context.Context, userID int64, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + s.getReactionReq = req + if len(s.getReactionRes.Messages) == 0 && len(req.IDs) > 0 { + reactions := domain.ChannelMessageReactions{CanSeeList: true, Results: []domain.ChannelMessageReactionCount{}, Recent: []domain.ChannelMessagePeerReaction{}} + s.getReactionRes = domain.PrivateMessageReactionsResult{ + Messages: []domain.Message{{ + ID: req.IDs[0], + OwnerUserID: userID, + Peer: req.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.Peer.ID}, + Reactions: &reactions, + }}, + Reactions: reactions, + } + } + return s.getReactionRes, nil +} + +func (s *captureMessages) EditMessage(_ context.Context, userID int64, req domain.EditMessageRequest) (domain.EditMessageResult, error) { + s.editReq = req + if s.editRes.OwnerUserID == 0 { + s.editRes.OwnerUserID = userID + } + if len(s.editRes.Edited) == 0 { + msg := domain.Message{ + ID: req.ID, + OwnerUserID: req.OwnerUserID, + Peer: req.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + Date: req.EditDate - 10, + EditDate: req.EditDate, + Out: true, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Pts: 7, + } + s.editRes.Edited = []domain.EditedMessageForUser{{ + UserID: req.OwnerUserID, + Message: msg, + Event: domain.UpdateEvent{ + UserID: req.OwnerUserID, + Type: domain.UpdateEventEditMessage, + Pts: 7, + PtsCount: 1, + Date: req.EditDate, + Message: msg, + }, + }} + } + return s.editRes, nil +} + +func (s *captureMessages) DeleteMessages(_ context.Context, userID int64, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { + s.deleteMessagesReq = req + if s.deleteMessagesRes.OwnerUserID == 0 { + s.deleteMessagesRes.OwnerUserID = userID + } + return s.deleteMessagesRes, nil +} + +func (s *captureMessages) GetSavedDialogs(_ context.Context, _ int64, _ domain.SavedDialogsFilter) (domain.SavedDialogList, error) { + return domain.SavedDialogList{}, nil +} + +func (s *captureMessages) GetPinnedSavedDialogs(_ context.Context, _ int64) (domain.SavedDialogList, error) { + return domain.SavedDialogList{}, nil +} + +func (s *captureMessages) GetSavedDialogsByPeers(_ context.Context, _ int64, _ []domain.Peer) (domain.SavedDialogList, error) { + return domain.SavedDialogList{}, nil +} + +func (s *captureMessages) ToggleSavedDialogPin(_ context.Context, _ int64, _ domain.Peer, _ bool) (bool, error) { + return false, nil +} + +func (s *captureMessages) ReorderPinnedSavedDialogs(_ context.Context, _ int64, _ []domain.Peer, _ bool) error { + return nil +} + +func (s *captureMessages) DeleteSavedHistory(_ context.Context, _ int64, _ domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) { + return domain.DeleteSavedHistoryResult{}, nil +} + +func (s *captureMessages) DeleteHistory(_ context.Context, userID int64, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { + s.deleteHistoryReq = req + if s.deleteHistoryRes.OwnerUserID == 0 { + s.deleteHistoryRes.OwnerUserID = userID + } + return s.deleteHistoryRes, nil +} + +func (s *captureMessages) PinPrivateMessage(_ context.Context, userID int64, req domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) { + return domain.PinPrivateMessageResult{OwnerUserID: userID}, nil +} + +func (s *captureMessages) UnpinAllPrivateMessages(_ context.Context, userID int64, req domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) { + return domain.PinPrivateMessageResult{OwnerUserID: userID}, nil +} + +func (s *captureMessages) ListUnreadReactionMessages(_ context.Context, _ int64, _ domain.Peer, _ int) ([]domain.Message, error) { + return nil, nil +} + +func (s *captureMessages) ReadPeerReactions(_ context.Context, _ int64, _ domain.Peer) (int, error) { + return 0, nil +} diff --git a/internal/rpc/rpc_testkit_metrics_test.go b/internal/rpc/rpc_testkit_metrics_test.go new file mode 100644 index 00000000..b269f23d --- /dev/null +++ b/internal/rpc/rpc_testkit_metrics_test.go @@ -0,0 +1,58 @@ +package rpc + +import ( + "context" + "time" +) + +type captureRPCMetrics struct { + messageSend int + messageDup bool + messageSendErr error + rateLimited int +} + +func (m *captureRPCMetrics) MessageSend(_ time.Duration, duplicate bool, err error) { + m.messageSend++ + m.messageDup = duplicate + m.messageSendErr = err +} + +func (m *captureRPCMetrics) MessageRateLimited(retryAfterSeconds int) { + m.rateLimited = retryAfterSeconds +} + +func (m *captureRPCMetrics) OutboxClaimed(int) {} + +func (m *captureRPCMetrics) OutboxDelivered(time.Duration) {} + +func (m *captureRPCMetrics) OutboxFailed(error) {} + +type rateLimitCall struct { + key string + cost int + limit int + window time.Duration +} + +type captureRateLimiter struct { + block bool + retryAfter int + calls []rateLimitCall +} + +func (l *captureRateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) { + return l.AllowN(ctx, key, 1, limit, window) +} + +func (l *captureRateLimiter) AllowN(_ context.Context, key string, cost, limit int, window time.Duration) (bool, int, error) { + l.calls = append(l.calls, rateLimitCall{key: key, cost: cost, limit: limit, window: window}) + if l.block { + retry := l.retryAfter + if retry <= 0 { + retry = 1 + } + return false, retry, nil + } + return true, 0, nil +} diff --git a/internal/rpc/rpc_testkit_sessions_test.go b/internal/rpc/rpc_testkit_sessions_test.go new file mode 100644 index 00000000..948e8a34 --- /dev/null +++ b/internal/rpc/rpc_testkit_sessions_test.go @@ -0,0 +1,286 @@ +package rpc + +import ( + "context" + "github.com/gotd/td/bin" + "github.com/gotd/td/proto" + "sync" +) + +type captureSessions struct { + mu sync.Mutex + sessionID int64 + userID int64 + userResolved bool + authKeyID [8]byte + authKeyResolved bool + receives bool + messageType proto.MessageType + message bin.Encoder + userMessage bin.Encoder // 最近一次 PushToUser* 的消息(与 message 区分:message 也被 PushToSession 覆盖) + pushUserIDs []int64 + onlineUserIDs []int64 + channelViewers map[int64][]int64 + channelMembers map[int64][]int64 + // channelViewersLimit 记录最近一次 OnlineChannelUserIDs 收到的 limit,验证 fan-out 封顶传参。 + channelViewersLimit int +} + +type captureSessionsSnapshot struct { + sessionID int64 + userID int64 + userResolved bool + authKeyID [8]byte + authKeyResolved bool + receives bool + messageType proto.MessageType + message bin.Encoder +} + +func (s *captureSessions) snapshot() captureSessionsSnapshot { + s.mu.Lock() + defer s.mu.Unlock() + return captureSessionsSnapshot{ + sessionID: s.sessionID, + userID: s.userID, + userResolved: s.userResolved, + authKeyID: s.authKeyID, + authKeyResolved: s.authKeyResolved, + receives: s.receives, + messageType: s.messageType, + message: s.message, + } +} + +func (s *captureSessions) pushedUserIDs() []int64 { + s.mu.Lock() + defer s.mu.Unlock() + return append([]int64(nil), s.pushUserIDs...) +} + +// lastUserPush 返回最近一次 PushToUser* 的消息,独立于 message(后者也会被 +// pushOnlinePeerStatusesToCurrentSession 经 PushToSession 覆盖成对端状态)。 +func (s *captureSessions) lastUserPush() bin.Encoder { + s.mu.Lock() + defer s.mu.Unlock() + return s.userMessage +} + +func (s *captureSessions) clearMessages() { + s.mu.Lock() + defer s.mu.Unlock() + s.message = nil + s.userMessage = nil + s.pushUserIDs = nil +} + +func (s *captureSessions) BindAuthKey(sessionID int64, authKeyID [8]byte) { + s.mu.Lock() + defer s.mu.Unlock() + if s.authKeyResolved && s.authKeyID != authKeyID { + s.userID = 0 + s.userResolved = false + } + s.sessionID = sessionID + s.authKeyID = authKeyID + s.authKeyResolved = true +} + +func (s *captureSessions) AuthKeyID(int64) ([8]byte, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.authKeyID, s.authKeyResolved +} + +func (s *captureSessions) BindUser(sessionID, userID int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessionID = sessionID + s.userID = userID + s.userResolved = true +} + +func (s *captureSessions) UserID(int64) (int64, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.userID, s.userID != 0 +} + +func (s *captureSessions) UserIDResolved(int64) (int64, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.userID, s.userResolved +} + +func (s *captureSessions) UnbindAuthKey(authKeyID [8]byte) int { + s.mu.Lock() + defer s.mu.Unlock() + if s.authKeyID == authKeyID { + s.userID = 0 + s.userResolved = true + return 1 + } + return 0 +} + +func (s *captureSessions) SetReceivesUpdates(sessionID int64, receives bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessionID = sessionID + s.receives = receives +} + +func (s *captureSessions) PushToSession(_ context.Context, sessionID int64, t proto.MessageType, msg bin.Encoder) error { + s.mu.Lock() + defer s.mu.Unlock() + s.sessionID = sessionID + s.messageType = t + s.message = msg + return nil +} + +func (s *captureSessions) PushToUserExceptSession(_ context.Context, userID, excludeSessionID int64, t proto.MessageType, msg bin.Encoder) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.userID = userID + s.sessionID = excludeSessionID + s.messageType = t + s.message = msg + s.userMessage = msg + s.pushUserIDs = append(s.pushUserIDs, userID) + return 1, nil +} + +func (s *captureSessions) IsUserOnline(userID int64) bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, id := range s.onlineUserIDs { + if id == userID { + return true + } + } + return false +} + +func (s *captureSessions) OnlineUserIDsForCandidates(candidateUserIDs []int64, limit int) []int64 { + s.mu.Lock() + defer s.mu.Unlock() + online := make(map[int64]struct{}, len(s.onlineUserIDs)) + for _, id := range s.onlineUserIDs { + online[id] = struct{}{} + } + out := make([]int64, 0, len(candidateUserIDs)) + seen := map[int64]struct{}{} + for _, id := range candidateUserIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + if _, ok := online[id]; !ok { + continue + } + out = append(out, id) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +func (s *captureSessions) TrackChannelInterest(_ [8]byte, _ int64, userID int64, channelIDs []int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.channelViewers == nil { + s.channelViewers = make(map[int64][]int64) + } + for channelID, viewers := range s.channelViewers { + out := viewers[:0] + for _, viewerID := range viewers { + if viewerID != userID { + out = append(out, viewerID) + } + } + if len(out) == 0 { + delete(s.channelViewers, channelID) + continue + } + s.channelViewers[channelID] = out + } + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + s.channelViewers[channelID] = append(s.channelViewers[channelID], userID) + } +} + +func (s *captureSessions) ClearChannelInterest(_ [8]byte, _ int64, userID int64) { + s.mu.Lock() + defer s.mu.Unlock() + for channelID, viewers := range s.channelViewers { + out := viewers[:0] + for _, viewerID := range viewers { + if viewerID != userID { + out = append(out, viewerID) + } + } + if len(out) == 0 { + delete(s.channelViewers, channelID) + continue + } + s.channelViewers[channelID] = out + } +} + +func (s *captureSessions) OnlineChannelUserIDs(channelID int64, limit int) []int64 { + s.mu.Lock() + defer s.mu.Unlock() + s.channelViewersLimit = limit + return limitIDs(s.channelViewers[channelID], limit) +} + +func (s *captureSessions) SetSessionChannelMemberships(_ [8]byte, _ int64, userID int64, channelIDs []int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.channelMembers == nil { + s.channelMembers = make(map[int64][]int64) + } + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + s.channelMembers[channelID] = append(s.channelMembers[channelID], userID) + } +} + +func (s *captureSessions) AddUserChannelMembership(userID, channelID int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.channelMembers == nil { + s.channelMembers = make(map[int64][]int64) + } + s.channelMembers[channelID] = append(s.channelMembers[channelID], userID) +} + +func (s *captureSessions) RemoveUserChannelMembership(userID, channelID int64) { + s.mu.Lock() + defer s.mu.Unlock() + members := s.channelMembers[channelID] + out := members[:0] + for _, id := range members { + if id != userID { + out = append(out, id) + } + } + if len(out) == 0 { + delete(s.channelMembers, channelID) + return + } + s.channelMembers[channelID] = out +} + +func (s *captureSessions) OnlineChannelMemberUserIDs(channelID int64, limit int) []int64 { + s.mu.Lock() + defer s.mu.Unlock() + return limitIDs(s.channelMembers[channelID], limit) +} diff --git a/internal/rpc/rpc_testkit_updates_test.go b/internal/rpc/rpc_testkit_updates_test.go new file mode 100644 index 00000000..0b8eab06 --- /dev/null +++ b/internal/rpc/rpc_testkit_updates_test.go @@ -0,0 +1,280 @@ +package rpc + +import ( + "context" + "telesrv/internal/domain" +) + +type captureUpdates struct { + state domain.UpdateState + currentState *domain.UpdateState + acknowledged bool + authKeyID [8]byte + userID int64 + clearedAuthKeyID [8]byte + cleared bool + date int + events []domain.UpdateEvent + excludeSessionID int64 + reliableDispatch bool +} + +func (s *captureUpdates) UsesReliableDispatch() bool { + return s.reliableDispatch +} + +func (s *captureUpdates) GetState(_ context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) { + s.authKeyID = authKeyID + s.userID = userID + return s.state, nil +} + +func (s *captureUpdates) CurrentState(_ context.Context, userID int64) (domain.UpdateState, error) { + s.userID = userID + if s.currentState != nil { + return *s.currentState, nil + } + return s.state, nil +} + +func (s *captureUpdates) AcknowledgeCurrentState(_ context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) { + s.authKeyID = authKeyID + s.userID = userID + st := s.state + if s.currentState != nil { + st = *s.currentState + } + // 模拟真实 service:确认水位推进到账号最新。 + s.state = st + s.acknowledged = true + return st, nil +} + +func (s *captureUpdates) GetDifference(_ context.Context, authKeyID [8]byte, userID int64, _ domain.UpdateState) (domain.UpdateDifference, error) { + s.authKeyID = authKeyID + s.userID = userID + return domain.UpdateDifference{State: s.state}, nil +} + +func (s *captureUpdates) ClearAuthKey(_ context.Context, authKeyID [8]byte) error { + s.clearedAuthKeyID = authKeyID + s.cleared = true + return nil +} + +func (s *captureUpdates) RecordNewMessage(_ context.Context, authKeyID [8]byte, userID int64, msg domain.Message) (domain.UpdateEvent, domain.UpdateState, error) { + s.authKeyID = authKeyID + s.userID = userID + s.date = msg.Date + event := domain.UpdateEvent{Type: domain.UpdateEventNewMessage, Pts: s.state.Pts, PtsCount: 1, Date: msg.Date, Message: msg} + s.events = append(s.events, event) + return event, s.state, nil +} + +func (s *captureUpdates) RecordStory(_ context.Context, authKeyID [8]byte, userID int64, story domain.Story, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventStory, + Peer: story.Owner, + Story: story, + MaxID: story.ID, + }) +} + +func (s *captureUpdates) RecordStoryFanout(_ context.Context, userID int64, story domain.Story) (domain.UpdateEvent, domain.UpdateState, error) { + return s.recordCapturedEvent([8]byte{}, userID, domain.UpdateEvent{ + Type: domain.UpdateEventStory, + Peer: story.Owner, + Story: story, + MaxID: story.ID, + }) +} + +func (s *captureUpdates) RecordReadHistory(_ context.Context, authKeyID [8]byte, userID int64, read domain.ReadHistoryResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.authKeyID = authKeyID + s.userID = userID + s.excludeSessionID = excludeSessionID + event := domain.UpdateEvent{ + Type: domain.UpdateEventReadHistoryInbox, + Pts: s.state.Pts, + PtsCount: 1, + Date: s.state.Date, + Peer: read.Peer, + MaxID: read.MaxID, + StillUnreadCount: read.StillUnreadCount, + ChannelPts: read.ChannelPts, + } + s.events = append(s.events, event) + return event, s.state, nil +} + +func (s *captureUpdates) RecordReadStories(_ context.Context, authKeyID [8]byte, userID int64, read domain.StoryReadResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventReadStories, + Peer: read.Peer, + MaxID: read.MaxReadID, + }) +} + +func (s *captureUpdates) RecordSentStoryReaction(_ context.Context, authKeyID [8]byte, userID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventSentStoryReaction, + Peer: reaction.Peer, + MaxID: reaction.StoryID, + Story: reaction.Story, + Reaction: reaction.Reaction, + }) +} + +func (s *captureUpdates) RecordNewStoryReaction(_ context.Context, authKeyID [8]byte, ownerUserID int64, reaction domain.StoryReactionResult, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, ownerUserID, domain.UpdateEvent{ + Type: domain.UpdateEventNewStoryReaction, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: reaction.ViewerID}, + MaxID: reaction.StoryID, + Story: reaction.Story, + Reaction: reaction.Reaction, + }) +} + +func (s *captureUpdates) RecordQuickReplyMutation(_ context.Context, authKeyID [8]byte, userID int64, mutation domain.QuickReplyMutation, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + event := domain.UpdateEvent{ + Date: mutation.Date, + QuickReplies: append([]domain.QuickReply(nil), mutation.List.QuickReplies...), + QuickReply: mutation.QuickReply, + QuickReplyMessage: mutation.Message, + MessageIDs: append([]int(nil), mutation.MessageIDs...), + MaxID: mutation.ShortcutID, + } + switch mutation.Kind { + case domain.QuickReplyMutationNew: + event.Type = domain.UpdateEventNewQuickReply + case domain.QuickReplyMutationDelete: + event.Type = domain.UpdateEventDeleteQuickReply + case domain.QuickReplyMutationMessage: + event.Type = domain.UpdateEventQuickReplyMessage + case domain.QuickReplyMutationIDs: + event.Type = domain.UpdateEventDeleteQuickReplyMessages + default: + event.Type = domain.UpdateEventQuickReplies + } + return s.recordCapturedEvent(authKeyID, userID, event) +} + +func (s *captureUpdates) RecordChannelState(_ context.Context, authKeyID [8]byte, userID, channelID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventChannelState, Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}}) +} + +func (s *captureUpdates) RecordContactsReset(_ context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventContactsReset}) +} + +func (s *captureUpdates) RecordDraftMessage(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, topMsgID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDraftMessage, Peer: peer, MaxID: topMsgID}) +} + +func (s *captureUpdates) RecordDialogPinned(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, folderID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogPinned, Peer: peer, Bool: pinned, FolderID: folderID}) +} + +func (s *captureUpdates) RecordPinnedDialogs(_ context.Context, authKeyID [8]byte, userID int64, folderID int, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPinnedDialogs, Peers: append([]domain.Peer(nil), order...), FolderID: folderID}) +} + +func (s *captureUpdates) RecordSavedDialogPinned(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, pinned bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventSavedDialogPinned, Peer: peer, Bool: pinned}) +} + +func (s *captureUpdates) RecordPinnedSavedDialogs(_ context.Context, authKeyID [8]byte, userID int64, order []domain.Peer, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPinnedSavedDialogs, Peers: append([]domain.Peer(nil), order...)}) +} + +func (s *captureUpdates) RecordDialogUnreadMark(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, unread bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogUnreadMark, Peer: peer, Bool: unread}) +} + +func (s *captureUpdates) RecordPeerSettings(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, settings domain.PeerSettings, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPeerSettings, Peer: peer, Settings: settings}) +} + +func (s *captureUpdates) RecordPeerStoryBlocked(_ context.Context, authKeyID [8]byte, userID int64, peer domain.Peer, blocked bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventPeerStoryBlocked, Peer: peer, Bool: blocked}) +} + +func (s *captureUpdates) RecordDialogFilter(_ context.Context, authKeyID [8]byte, userID int64, folderID int, folder *domain.DialogFolder, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilter, FilterID: folderID, DialogFilter: folder}) +} + +func (s *captureUpdates) RecordDialogFilterOrder(_ context.Context, authKeyID [8]byte, userID int64, order []int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilterOrder, FilterOrder: append([]int(nil), order...)}) +} + +func (s *captureUpdates) RecordDialogFiltersReload(_ context.Context, authKeyID [8]byte, userID int64, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventDialogFilters}) +} + +func (s *captureUpdates) RecordFolderPeers(_ context.Context, authKeyID [8]byte, userID int64, peers []domain.FolderPeerUpdate, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{Type: domain.UpdateEventFolderPeers, FolderPeers: append([]domain.FolderPeerUpdate(nil), peers...)}) +} + +func (s *captureUpdates) RecordChannelAvailableMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, availableMinID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventChannelAvailable, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + MaxID: availableMinID, + }) +} + +func (s *captureUpdates) RecordChannelViewForumAsMessages(_ context.Context, authKeyID [8]byte, userID, channelID int64, enabled bool, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventChannelViewForum, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + Bool: enabled, + }) +} + +func (s *captureUpdates) RecordChannelDiscussionInbox(_ context.Context, authKeyID [8]byte, userID, channelID int64, topicID, maxID int, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) { + s.excludeSessionID = excludeSessionID + return s.recordCapturedEvent(authKeyID, userID, domain.UpdateEvent{ + Type: domain.UpdateEventReadChannelDiscussionInbox, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + TopMsgID: topicID, + MaxID: maxID, + }) +} + +func (s *captureUpdates) recordCapturedEvent(authKeyID [8]byte, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, domain.UpdateState, error) { + s.authKeyID = authKeyID + s.userID = userID + if event.Pts == 0 { + event.Pts = s.state.Pts + } + if event.PtsCount == 0 { + event.PtsCount = 1 + } + if event.Date == 0 { + event.Date = s.state.Date + } + event.UserID = userID + s.events = append(s.events, event) + return event, s.state, nil +} diff --git a/internal/rpc/rpc_testkit_users_test.go b/internal/rpc/rpc_testkit_users_test.go new file mode 100644 index 00000000..b20a5998 --- /dev/null +++ b/internal/rpc/rpc_testkit_users_test.go @@ -0,0 +1,122 @@ +package rpc + +import ( + "context" + "telesrv/internal/domain" +) + +type staticUsersService struct { + user domain.User +} + +type mapUsersService struct { + users map[int64]domain.User +} + +type countingMapUsersService struct { + mapUsersService + selfCalls int + byIDCalls int + byIDsCalls int + lastByIDs []int64 +} + +func (s staticUsersService) Self(context.Context, int64) (domain.User, error) { + return s.user, nil +} + +func (s staticUsersService) ByID(_ context.Context, _, userID int64) (domain.User, bool, error) { + if userID == s.user.ID { + return s.user, true, nil + } + if userID == domain.OfficialSystemUserID { + return domain.OfficialSystemUser(), true, nil + } + return domain.User{}, false, nil +} + +func (s staticUsersService) ByIDs(_ context.Context, _ int64, userIDs []int64) ([]domain.User, error) { + out := make([]domain.User, 0, len(userIDs)) + seen := map[int64]struct{}{} + for _, userID := range userIDs { + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + if userID == s.user.ID { + out = append(out, s.user) + } else if userID == domain.OfficialSystemUserID { + out = append(out, domain.OfficialSystemUser()) + } + } + return out, nil +} + +func (s mapUsersService) Self(_ context.Context, userID int64) (domain.User, error) { + return s.users[userID], nil +} + +func (s mapUsersService) ByID(_ context.Context, _, userID int64) (domain.User, bool, error) { + u, ok := s.users[userID] + return u, ok, nil +} + +func (s mapUsersService) ByIDs(_ context.Context, _ int64, userIDs []int64) ([]domain.User, error) { + out := make([]domain.User, 0, len(userIDs)) + seen := map[int64]struct{}{} + for _, userID := range userIDs { + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + if u, ok := s.users[userID]; ok { + out = append(out, u) + } + } + return out, nil +} + +func (s *countingMapUsersService) ByID(ctx context.Context, currentUserID, userID int64) (domain.User, bool, error) { + s.byIDCalls++ + return s.mapUsersService.ByID(ctx, currentUserID, userID) +} + +func (s *countingMapUsersService) Self(ctx context.Context, userID int64) (domain.User, error) { + s.selfCalls++ + return s.mapUsersService.Self(ctx, userID) +} + +func (s *countingMapUsersService) ByIDs(ctx context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) { + s.byIDsCalls++ + s.lastByIDs = append([]int64(nil), userIDs...) + return s.mapUsersService.ByIDs(ctx, currentUserID, userIDs) +} + +type captureUsersService struct { + user domain.User + userID int64 +} + +func (s *captureUsersService) Self(_ context.Context, userID int64) (domain.User, error) { + s.userID = userID + return s.user, nil +} + +func (s *captureUsersService) ByID(_ context.Context, currentUserID, userID int64) (domain.User, bool, error) { + s.userID = currentUserID + if userID == s.user.ID { + return s.user, true, nil + } + return domain.User{}, false, nil +} + +func (s *captureUsersService) ByIDs(_ context.Context, currentUserID int64, userIDs []int64) ([]domain.User, error) { + s.userID = currentUserID + out := make([]domain.User, 0, len(userIDs)) + for _, userID := range userIDs { + if userID == s.user.ID { + out = append(out, s.user) + } + } + return out, nil +} diff --git a/internal/rpc/scheduled_dispatcher.go b/internal/rpc/scheduled_dispatcher.go new file mode 100644 index 00000000..052d0600 --- /dev/null +++ b/internal/rpc/scheduled_dispatcher.go @@ -0,0 +1,71 @@ +package rpc + +import ( + "context" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +const ( + defaultScheduledDispatchBatch = 100 + defaultScheduledDispatchLease = 60 + defaultScheduledDispatchTick = time.Second + defaultExpiryDispatchBatch = 100 + defaultExpiryDispatchTick = time.Second +) + +// ScheduledDispatcher promotes due scheduled messages into normal history. +type ScheduledDispatcher struct { + router *Router + log *zap.Logger + batch int + leaseSeconds int + interval time.Duration + maxIdle time.Duration +} + +func NewScheduledDispatcher(router *Router, log *zap.Logger) *ScheduledDispatcher { + if log == nil { + log = zap.NewNop() + } + return &ScheduledDispatcher{ + router: router, + log: log, + batch: defaultScheduledDispatchBatch, + leaseSeconds: defaultScheduledDispatchLease, + interval: defaultScheduledDispatchTick, + maxIdle: defaultIdleDispatchMaxInterval, + } +} + +func (d *ScheduledDispatcher) Run(ctx context.Context) { + if d == nil || d.router == nil { + return + } + runIdleBackoffLoop(ctx, d.interval, d.maxIdle, d.DispatchOnce) +} + +func (d *ScheduledDispatcher) DispatchOnce(ctx context.Context) bool { + if d == nil || d.router == nil || d.router.deps.Messages == nil { + return false + } + scheduledSvc, ok := d.router.deps.Messages.(scheduledMessagesService) + if !ok { + return false + } + now := int(d.router.clock.Now().Unix()) + items, err := scheduledSvc.ClaimDueScheduledMessages(ctx, now, d.batch, d.leaseSeconds) + if err != nil { + d.log.Warn("claim due scheduled messages", zap.Error(err)) + return false + } + for _, item := range items { + if _, err := d.router.sendClaimedScheduledMessages(ctx, item.OwnerUserID, item.Peer, []domain.ScheduledMessage{item}, now); err != nil { + d.log.Warn("send scheduled message", zap.Int64("owner_user_id", item.OwnerUserID), zap.Int("scheduled_id", item.ID), zap.Error(err)) + } + } + return len(items) > 0 +} diff --git a/internal/rpc/self_flag_emission_test.go b/internal/rpc/self_flag_emission_test.go new file mode 100644 index 00000000..4b402d86 --- /dev/null +++ b/internal/rpc/self_flag_emission_test.go @@ -0,0 +1,84 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// DrKLO 的 UserObject.isUserSelf 只看 user.self flag;服务端任何把"请求者自己的 user" +// 用 self=false 形态下发的路径,都会让 Android putUsers 覆盖账号缓存,Saved Messages +// 退化为普通自聊。本测试锁定几个曾漏 self 分支的 channel/global 历史转换出口:viewer +// 自己出现在 users 集合时必须带 self 标志。 + +// selfFlag 在 users 里定位 viewer,返回其 self 标志(找不到时 fail)。 +func selfFlag(t *testing.T, users []tg.UserClass, viewerID int64) bool { + t.Helper() + for _, uc := range users { + u, ok := uc.(*tg.User) + if !ok || u.ID != viewerID { + continue + } + return u.Self + } + t.Fatalf("viewer %d missing from users slice", viewerID) + return false +} + +func TestChannelHistoryMessagesMarksViewerSelf(t *testing.T) { + const viewer = int64(1001) + history := domain.ChannelHistory{ + Channel: domain.Channel{ID: 40, AccessHash: 7, Megagroup: true}, + Users: []domain.User{ + {ID: viewer, AccessHash: 5, FirstName: "Me"}, + {ID: 2002, AccessHash: 6, FirstName: "Other"}, + }, + } + out, ok := tgChannelHistoryMessages(viewer, history).(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("expected MessagesChannelMessages, got %T", tgChannelHistoryMessages(viewer, history)) + } + if !selfFlag(t, out.Users, viewer) { + t.Fatal("viewer's own user must carry self flag in channel history (Saved Messages cache integrity)") + } + if selfFlag(t, out.Users, 2002) { + t.Fatal("other user must not carry self flag") + } +} + +func TestChannelSearchPostsMessagesMarksViewerSelf(t *testing.T) { + const viewer = int64(1001) + history := domain.ChannelHistory{ + Channel: domain.Channel{ID: 40, AccessHash: 7, Megagroup: true}, + Count: 5, // 触发 MessagesMessagesSlice 分支 + Channels: []domain.Channel{{ID: 40, AccessHash: 7, Megagroup: true}}, + Users: []domain.User{{ID: viewer, AccessHash: 5, FirstName: "Me"}}, + } + slice, ok := tgChannelSearchPostsMessages(viewer, history).(*tg.MessagesMessagesSlice) + if !ok { + t.Fatalf("expected MessagesMessagesSlice, got %T", tgChannelSearchPostsMessages(viewer, history)) + } + if !selfFlag(t, slice.Users, viewer) { + t.Fatal("viewer self flag lost in channel post search") + } +} + +func TestGlobalSearchMessagesMarksViewerSelf(t *testing.T) { + const viewer = int64(1001) + private := domain.MessageList{Users: []domain.User{{ID: viewer, AccessHash: 5, FirstName: "Me"}}} + channel := domain.ChannelHistory{Users: []domain.User{{ID: viewer, AccessHash: 5, FirstName: "Me"}}} + switch out := tgGlobalSearchMessages(viewer, 50, private, channel).(type) { + case *tg.MessagesMessages: + if !selfFlag(t, out.Users, viewer) { + t.Fatal("viewer self flag lost in global search (messages)") + } + case *tg.MessagesMessagesSlice: + if !selfFlag(t, out.Users, viewer) { + t.Fatal("viewer self flag lost in global search (slice)") + } + default: + t.Fatalf("unexpected global search result type %T", out) + } +} diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go index a463ee65..aac8ae81 100644 --- a/internal/rpc/send_media.go +++ b/internal/rpc/send_media.go @@ -2,9 +2,11 @@ package rpc import ( "context" + cryptorand "crypto/rand" + "encoding/binary" "errors" "fmt" - "strconv" + "strings" "unicode/utf8" "github.com/gotd/td/tg" @@ -16,6 +18,8 @@ import ( // 本文件实现 messages.uploadMedia / sendMedia / sendMultiMedia 的 photo/document/sticker 主路径, // 并抽取 sendOutgoing 作为「已校验的一条出站消息(文本或媒体)落地」的共享实现,私聊与频道共用。 +const maxContactVcardLength = 8192 + // outgoingSend 是 sendOutgoing 的入参:一条已校验的出站消息。 type outgoingSend struct { randomID int64 @@ -26,51 +30,87 @@ type outgoingSend struct { noforwards bool replyToInput tg.InputReplyToClass sendAsInput tg.InputPeerClass + replyTo *domain.MessageReply + replyToReady bool + sendAs *domain.Peer + sendAsReady bool clearDraft bool + // replyMarkup 是 bot inline keyboard(已解析+校验;非 bot 恒 nil)。 + replyMarkup *domain.MessageReplyMarkup + viaBotID int64 + // richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。 + // Phase 1 仅接入私聊;频道侧留 Phase 2。 + richMessage *domain.MessageRichMessage + // groupedID 是相册分组 id:sendMultiMedia 同组各条共享一个非零值(客户端据此渲染 + // 成一个相册组);单条发送恒 0。 + groupedID int64 + // effect 是消息特效 id(私聊专属,0 表无特效)。调用方已对 catalog 校验合法性; + // 频道侧忽略(官方群/频道不渲染特效)。 + effect int64 } // sendOutgoing 把一条出站消息落地到私聊或频道,返回 *tg.Updates、是否重复、错误。 // media 为空即纯文本。校验(长度/random_id/限流)由调用方完成。 func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Peer, p outgoingSend) (tg.UpdatesClass, bool, error) { - sendAs, err := r.resolveSendAsPeer(ctx, userID, peer, p.sendAsInput) - if err != nil { - return nil, false, err + sendAs := p.sendAs + if !p.sendAsReady { + resolved, err := r.resolveSendAsPeer(ctx, userID, peer, p.sendAsInput) + if err != nil { + return nil, false, err + } + sendAs = resolved } if peer.Type == domain.PeerTypeChannel { if r.deps.Channels == nil { return nil, false, peerIDInvalidErr() } - replyTo, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput) - if err != nil { - return nil, false, err + replyTo := p.replyTo + if !p.replyToReady { + resolved, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput) + if err != nil { + return nil, false, err + } + replyTo = resolved } mentionUserIDs, err := r.mentionedUserIDsFromMessage(ctx, userID, p.message, p.entities) if err != nil { return nil, false, err } res, err := r.deps.Channels.SendMessage(ctx, userID, domain.SendChannelMessageRequest{ - UserID: userID, - ChannelID: peer.ID, - RandomID: p.randomID, - Message: p.message, - Entities: domainMessageEntities(p.entities), - Media: p.media, - MentionUserIDs: mentionUserIDs, - Silent: p.silent, - NoForwards: p.noforwards, - ReplyTo: replyTo, - SendAs: sendAs, - Date: int(r.clock.Now().Unix()), + UserID: userID, + ChannelID: peer.ID, + RandomID: p.randomID, + Message: p.message, + Entities: domainMessageEntitiesForViewer(userID, p.entities), + Media: p.media, + MentionUserIDs: mentionUserIDs, + SkipRecipientLookup: true, + PostAuthor: r.channelPostAuthorName(ctx, userID), + Silent: p.silent, + NoForwards: p.noforwards, + ReplyTo: replyTo, + ViaBotID: p.viaBotID, + GroupedID: p.groupedID, + ReplyMarkup: p.replyMarkup, + SendAs: sendAs, + Date: int(r.clock.Now().Unix()), }) if err != nil { return nil, false, channelInvalidErr(err) } - updates := r.channelMessageUpdates(ctx, userID, res, p.randomID) + // 发送者 echo 走 rpc_result(同步,用独立 cache);其余成员的 fan-out 异步化, + // 移出发送者 RPC 同步路径(设计 Phase 0)。echo 与 fan-out 必须用各自独立的 + // viewerPeerCache——前者在 RPC goroutine、后者在 worker goroutine,共享会数据竞态。 + // 发送者 echo 走 rpc_result(同步,用独立 cache);其余成员的 fan-out 异步化。 + // echo 与 fan-out 必须用各自独立的 viewerPeerCache——前者在 RPC goroutine、后者在 + // worker goroutine,共享会数据竞态。 + echoCache := newViewerPeerCache(r) + updates := r.channelMessageUpdatesWithPeerCache(ctx, userID, res, p.randomID, echoCache) if !res.Duplicate { - r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates { - return r.channelMessageUpdates(ctx, viewerUserID, res, 0) - }) + r.enqueueChannelMessageFanout(ctx, userID, res, nil) r.pushChannelDiscussionUpdate(ctx, userID, res.Discussion) + // 频道链接预览 pending 占位:带外解析并就地替换(异步,不阻塞发送 echo)。 + r.maybeEnqueueWebPageResolve(userID, peer, res.Message.ID, res.Message.Media) } if p.clearDraft { r.clearDraftAfterSend(ctx, userID, peer, replyTo) @@ -90,9 +130,13 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee return nil, false, peerIDInvalidErr() } } - replyTo, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput) - if err != nil { - return nil, false, err + replyTo := p.replyTo + if !p.replyToReady { + resolved, err := r.messageReplyFromInput(ctx, userID, peer, p.replyToInput) + if err != nil { + return nil, false, err + } + replyTo = resolved } recipientBlocked, err := r.peerBlocksUser(ctx, userID, peer.ID) if err != nil { @@ -105,7 +149,7 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee RecipientUserID: peer.ID, RandomID: p.randomID, Message: p.message, - Entities: domainMessageEntities(p.entities), + Entities: domainMessageEntitiesForViewer(userID, p.entities), Media: p.media, Silent: p.silent, NoForwards: p.noforwards, @@ -114,8 +158,25 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee OriginAuthKeyID: authKeyID, OriginSessionID: sessionID, RecipientBlocked: recipientBlocked, + ReplyMarkup: p.replyMarkup, + RichMessage: p.richMessage, + ViaBotID: p.viaBotID, + GroupedID: p.groupedID, + Effect: p.effect, }) if err != nil { + fields := append(r.contextLogFields(ctx), + zap.Error(err), + zap.Int64("user_id", userID), + zap.Int64("peer_user_id", peer.ID), + zap.Int64("random_id", p.randomID), + zap.Int("message_len", utf8.RuneCountInString(p.message)), + zap.Bool("has_media", p.media != nil && !p.media.IsZero()), + zap.Bool("has_reply_to", replyTo != nil), + zap.Bool("clear_draft", p.clearDraft), + zap.Int64("via_bot_id", p.viaBotID), + ) + r.log.Warn("messages.sendMessage private store failed", fields...) return nil, false, messageSendErr(err) } users := r.usersForMessageUpdate(ctx, userID, res.SenderMessage) @@ -123,6 +184,10 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee if p.clearDraft { r.clearDraftAfterSend(ctx, userID, peer, replyTo) } + if !res.Duplicate { + // 链接预览 pending 占位:带外解析并就地替换(异步,不阻塞发送 echo)。 + r.maybeEnqueueWebPageResolve(userID, peer, res.SenderMessage.ID, res.SenderMessage.Media) + } return tgPrivateMessageUpdates(res.SenderEvent, res.SenderMessage, p.randomID, true, users, chats), res.Duplicate, nil } @@ -167,17 +232,19 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe if len(req.Entities) > maxMessageEntityCount { return nil, limitInvalidErr() } - if req.ScheduleDate != 0 || req.ScheduleRepeatPeriod != 0 { + if req.ScheduleRepeatPeriod != 0 { return nil, scheduleDateInvalidErr() } if req.Media == nil { return nil, mediaInvalidErr() } - // InputMediaEmpty / WebPage:退化为纯文本发送(复用 sendMessage 校验与流程)。 + // InputMediaEmpty / WebPage:退化为纯文本发送(复用 sendMessage 校验与流程,含 url 实体补全)。 switch req.Media.(type) { case *tg.InputMediaEmpty, *tg.InputMediaWebPage: return r.onMessagesSendMessage(ctx, sendMessageRequestFromSendMedia(req)) } + // 媒体 caption 里的链接/@mention/#hashtag 等同样补自动高亮实体(客户端未带时)。 + req.Entities = augmentAutoEntities(req.Message, req.Entities) userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() @@ -185,15 +252,12 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe if userID == 0 { return nil, peerIDInvalidErr() } - if r.deps.Limiter != nil { - allowed, retryAfter, err := r.deps.Limiter.Allow(ctx, "messages:send:"+strconv.FormatInt(userID, 10), sendMessageRateLimit, sendMessageRateWindow) - if err != nil { - return nil, internalErr() - } - if !allowed { - r.metrics().MessageRateLimited(retryAfter) - return nil, floodWaitErr(retryAfter) - } + // 消息特效:仅接受 catalog 内的合法 effect id(非法 id → EFFECT_ID_INVALID,官方行为)。 + if r.messageEffectInvalid(ctx, req.Effect) { + return nil, effectIDInvalidErr() + } + if err := r.checkSendRateLimit(ctx, userID, 1); err != nil { + return nil, err } peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) if err != nil { @@ -206,6 +270,27 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe if media == nil { return nil, mediaInvalidErr() } + // reply_markup(bot inline keyboard on media):仅 bot 接受+校验,非 bot 静默丢弃。 + var replyMarkup *domain.MessageReplyMarkup + if req.ReplyMarkup != nil { + replyMarkup, err = domainReplyMarkupForSender(req.ReplyMarkup, r.userIsBot(ctx, userID)) + if err != nil { + return nil, replyMarkupErr(err) + } + } + if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { + return r.scheduleOutgoing(ctx, userID, peer, outgoingSend{ + randomID: req.RandomID, + message: req.Message, + entities: req.Entities, + media: media, + silent: req.Silent, + noforwards: req.Noforwards, + replyToInput: req.ReplyTo, + sendAsInput: req.SendAs, + clearDraft: req.ClearDraft, + }, req.ScheduleDate, req.ScheduleRepeatPeriod) + } updates, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{ randomID: req.RandomID, message: req.Message, @@ -216,6 +301,8 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe replyToInput: req.ReplyTo, sendAsInput: req.SendAs, clearDraft: req.ClearDraft, + replyMarkup: replyMarkup, + effect: req.Effect, }) if err != nil { return nil, err @@ -235,9 +322,6 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS if len(req.MultiMedia) == 0 || len(req.MultiMedia) > maxSendMultiMediaItems { return nil, limitInvalidErr() } - if req.ScheduleDate != 0 { - return nil, scheduleDateInvalidErr() - } peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) if err != nil { return nil, err @@ -256,12 +340,17 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS return nil, mediaInvalidErr() } } + if err := r.checkSendRateLimit(ctx, userID, len(req.MultiMedia)); err != nil { + return nil, err + } combined := make([]tg.UpdateClass, 0, len(req.MultiMedia)*2) usersByID := map[int64]tg.UserClass{} chatsByID := map[int64]tg.ChatClass{} date := 0 - for _, item := range req.MultiMedia { + // 整个 album 共享一个 grouped_id,客户端据此把各条渲染成一个相册组。 + groupedID := randomNonZeroInt64() + for i, item := range req.MultiMedia { media, err := r.resolveInputMedia(ctx, userID, item.Media) if err != nil { return nil, err @@ -269,16 +358,24 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS if media == nil { return nil, mediaInvalidErr() } - result, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{ + p := outgoingSend{ randomID: item.RandomID, message: item.Message, - entities: item.Entities, + entities: augmentAutoEntities(item.Message, item.Entities), media: media, silent: req.Silent, noforwards: req.Noforwards, replyToInput: req.ReplyTo, sendAsInput: req.SendAs, - }) + clearDraft: req.ClearDraft && i == 0, + groupedID: groupedID, + } + var result tg.UpdatesClass + if req.ScheduleDate != 0 && !scheduleDateIsImmediate(req.ScheduleDate, int(r.clock.Now().Unix())) { + result, err = r.scheduleOutgoing(ctx, userID, peer, p, req.ScheduleDate, 0) + } else { + result, _, err = r.sendOutgoing(ctx, userID, peer, p) + } if err != nil { return nil, err } @@ -310,13 +407,30 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS // resolveInputMedia 把 tg.InputMedia 解析为 domain.MessageMedia(上传则落库,引用则加载)。 // 返回 nil 表示 InputMediaEmpty(调用方退化为纯文本)。 func (r *Router) resolveInputMedia(ctx context.Context, userID int64, input tg.InputMediaClass) (*domain.MessageMedia, error) { - if r.deps.Files == nil { - return nil, mediaInvalidErr() - } switch in := input.(type) { case *tg.InputMediaEmpty: return nil, nil + case *tg.InputMediaContact: + if !validContactInput(in.PhoneNumber, in.FirstName, in.LastName, "", 0) || utf8.RuneCountInString(in.Vcard) > maxContactVcardLength { + return nil, mediaInvalidErr() + } + if strings.TrimSpace(in.PhoneNumber) == "" && strings.TrimSpace(in.FirstName) == "" && strings.TrimSpace(in.LastName) == "" && strings.TrimSpace(in.Vcard) == "" { + return nil, mediaEmptyErr() + } + return &domain.MessageMedia{ + Kind: domain.MessageMediaKindContact, + Contact: &domain.MessageContact{ + PhoneNumber: in.PhoneNumber, + FirstName: in.FirstName, + LastName: in.LastName, + Vcard: in.Vcard, + UserID: r.messageContactUserID(ctx, userID, in.PhoneNumber), + }, + }, nil case *tg.InputMediaUploadedPhoto: + if r.deps.Files == nil { + return nil, mediaInvalidErr() + } if in.File == nil { return nil, mediaInvalidErr() } @@ -330,6 +444,9 @@ func (r *Router) resolveInputMedia(ctx context.Context, userID int64, input tg.I } return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil case *tg.InputMediaUploadedDocument: + if r.deps.Files == nil { + return nil, mediaInvalidErr() + } if in.File == nil { return nil, mediaInvalidErr() } @@ -352,7 +469,30 @@ func (r *Router) resolveInputMedia(ctx context.Context, userID int64, input tg.I return nil, mediaUploadErr(err) } return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil + case *tg.InputMediaPhotoExternal: + // 外链图片:服务端 SSRF 安全抓取 URL 并铸造 Photo。未启用/拦截/上游失败统一 MEDIA_INVALID。 + if r.deps.Files == nil { + return nil, mediaInvalidErr() + } + photo, err := r.deps.Files.CreatePhotoFromURL(ctx, in.URL) + if err != nil { + return nil, mediaInvalidErr() + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil + case *tg.InputMediaDocumentExternal: + // 外链文档:抓取 URL,mime 取 Content-Type、文件名取 URL basename。video_cover/timestamp 暂忽略。 + if r.deps.Files == nil { + return nil, mediaInvalidErr() + } + doc, err := r.deps.Files.CreateDocumentFromURL(ctx, in.URL) + if err != nil { + return nil, mediaInvalidErr() + } + return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil case *tg.InputMediaPhoto: + if r.deps.Files == nil { + return nil, mediaInvalidErr() + } photoID, ok := inputPhotoID(in.ID) if !ok { return nil, photoInvalidErr() @@ -366,6 +506,9 @@ func (r *Router) resolveInputMedia(ctx context.Context, userID int64, input tg.I } return &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &photo, Spoiler: in.Spoiler, TTLSeconds: in.TTLSeconds}, nil case *tg.InputMediaDocument: + if r.deps.Files == nil { + return nil, mediaInvalidErr() + } docIDs, ok := inputDocumentCandidateIDs(in.ID) if !ok { r.log.Warn("sendMedia InputMediaDocument unresolvable id", zap.String("id_type", fmt.Sprintf("%T", in.ID))) @@ -388,12 +531,291 @@ func (r *Router) resolveInputMedia(ctx context.Context, userID int64, input tg.I return nil, mediaInvalidErr() } return messageMediaFromDocument(doc, in.Spoiler, in.TTLSeconds), nil + case *tg.InputMediaGeoPoint: + geo, err := domainGeoPointFromInput(in.GeoPoint) + if err != nil { + return nil, err + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindGeo, Geo: geo}, nil + case *tg.InputMediaVenue: + geo, err := domainGeoPointFromInput(in.GeoPoint) + if err != nil { + return nil, err + } + if strings.TrimSpace(in.Title) == "" { + return nil, mediaEmptyErr() + } + if utf8.RuneCountInString(in.Title) > maxVenueTitleLength || + utf8.RuneCountInString(in.Address) > maxVenueAddressLength || + utf8.RuneCountInString(in.Provider) > maxVenueProviderLength || + utf8.RuneCountInString(in.VenueID) > maxVenueIDLength || + utf8.RuneCountInString(in.VenueType) > maxVenueIDLength { + return nil, mediaInvalidErr() + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{ + Geo: *geo, + Title: in.Title, + Address: in.Address, + Provider: in.Provider, + VenueID: in.VenueID, + VenueType: in.VenueType, + }}, nil + case *tg.InputMediaDice: + emoticon := normalizeDiceEmoticon(in.Emoticon) + sides, ok := diceValueSides(emoticon) + if !ok { + return nil, emoticonInvalidErr() + } + value, err := randomDiceValue(sides) + if err != nil { + return nil, internalErr() + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindDice, Dice: &domain.MessageDice{ + Emoticon: emoticon, + Value: value, + }}, nil + case *tg.InputMediaGeoLive: + if in.Stopped { + // stopped 只在 editMessage 停止共享时有意义,发送即停没有客户端路径。 + return nil, mediaInvalidErr() + } + geo, err := domainGeoPointFromInput(in.GeoPoint) + if err != nil { + return nil, err + } + live := &domain.MessageGeoLive{Geo: *geo, Period: minLiveLocationPeriod} + if period, ok := in.GetPeriod(); ok { + if period != foreverLiveLocationPeriod && (period < minLiveLocationPeriod || period > maxLiveLocationPeriod) { + return nil, mediaInvalidErr() + } + live.Period = period + } + if heading, ok := in.GetHeading(); ok { + if heading < 0 || heading > maxLiveLocationHeading { + return nil, mediaInvalidErr() + } + live.Heading = heading + } + if radius, ok := in.GetProximityNotificationRadius(); ok { + if radius < 0 || radius > maxProximityRadiusMeters { + return nil, mediaInvalidErr() + } + live.ProximityNotificationRadius = radius + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindGeoLive, GeoLive: live}, nil + case *tg.InputMediaPoll: + if r.deps.Polls == nil { + return nil, mediaInvalidErr() + } + pollID, err := randomPollID() + if err != nil { + return nil, internalErr() + } + snapshot, def, err := r.domainPollFromInputMedia(ctx, in, userID, pollID, int(r.clock.Now().Unix())) + if err != nil { + // compat 诊断:客户端 poll 构造形状多变(flag 默认值/答案构造器随版本漂移), + // 拒绝时必须留痕,否则只有一个裸 4xx 无从对账。 + answerTypes := make([]string, 0, len(in.Poll.Answers)) + for _, answerClass := range in.Poll.Answers { + switch answer := answerClass.(type) { + case *tg.PollAnswer: + answerTypes = append(answerTypes, fmt.Sprintf("pollAnswer{flags:%#x,text:%d,option:%d,media:%T}", + uint32(answer.Flags), len(answer.Text.Text), len(answer.Option), answer.Media)) + case *tg.InputPollAnswer: + media, hasMedia := answer.GetMedia() + answerTypes = append(answerTypes, fmt.Sprintf("inputPollAnswer{flags:%#x,text:%d,has_media:%v,media:%T}", + uint32(answer.Flags), len(answer.Text.Text), hasMedia, media)) + default: + answerTypes = append(answerTypes, fmt.Sprintf("%T", answerClass)) + } + } + closePeriod, hasPeriod := in.Poll.GetClosePeriod() + closeDate, hasDate := in.Poll.GetCloseDate() + correct, hasCorrect := in.GetCorrectAnswers() + solution, hasSolution := in.GetSolution() + r.log.Warn("sendMedia poll rejected", + zap.Error(err), + zap.Int64("user_id", userID), + zap.String("input_flags", fmt.Sprintf("%#x", uint32(in.Flags))), + zap.String("poll_flags", fmt.Sprintf("%#x", uint32(in.Poll.Flags))), + zap.Bool("quiz", in.Poll.Quiz), + zap.Bool("multiple_choice", in.Poll.MultipleChoice), + zap.Bool("public_voters", in.Poll.PublicVoters), + zap.Bool("open_answers", in.Poll.OpenAnswers), + zap.Bool("shuffle_answers", in.Poll.ShuffleAnswers), + zap.Bool("revoting_disabled", in.Poll.RevotingDisabled), + zap.Bool("hide_results", in.Poll.HideResultsUntilClose), + zap.Bool("subscribers_only", in.Poll.SubscribersOnly), + zap.Int("countries", len(in.Poll.CountriesISO2)), + zap.Int("question_len", len(in.Poll.Question.Text)), + zap.Int("question_entities", len(in.Poll.Question.Entities)), + zap.Strings("answer_types", answerTypes), + zap.Bool("has_close_period", hasPeriod), zap.Int("close_period", closePeriod), + zap.Bool("has_close_date", hasDate), zap.Int("close_date", closeDate), + zap.Bool("has_correct", hasCorrect), zap.Ints("correct", correct), + zap.Bool("has_solution", hasSolution), zap.Int("solution_len", len(solution)), + ) + return nil, err + } + // 权威行先落库(消息发送失败产生的孤儿 poll 无害且可回收)。 + if err := r.deps.Polls.CreatePoll(ctx, def); err != nil { + r.log.Warn("create poll failed", zap.Error(err)) + return nil, internalErr() + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: snapshot}, nil + case *tg.InputMediaTodo: + todo, err := domainTodoFromInput(in.Todo) + if err != nil { + return nil, err + } + return &domain.MessageMedia{Kind: domain.MessageMediaKindTodo, Todo: todo}, nil + case *tg.InputMediaStory: + return r.domainMessageStoryFromInput(ctx, userID, in) default: - // geo / contact / poll / venue / dice / story / 等本阶段不支持。 + // poll(独立链路见 messages_polls.go)/ geoLive / todo / game / invoice / + // paid media / external 等未接入;范围与 stub 决策见 docs/compatibility-matrix.md。 return nil, mediaInvalidErr() } } +func (r *Router) domainMessageStoryFromInput(ctx context.Context, userID int64, in *tg.InputMediaStory) (*domain.MessageMedia, error) { + if in == nil || in.ID <= 0 || in.ID > domain.MaxStoryID { + return nil, storyIDInvalidErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, in.Peer) + if err != nil { + return nil, err + } + if r.deps.Stories == nil { + return nil, storyIDInvalidErr() + } + now := int(r.clock.Now().Unix()) + list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, []int{in.ID}, now) + if err != nil { + return nil, storyErr(err) + } + if len(list.Stories) != 1 || list.Stories[0].Owner != peer || list.Stories[0].ID != in.ID { + return nil, storyIDInvalidErr() + } + story := list.Stories[0] + if story.NoForwards { + return nil, chatForwardsRestrictedErr() + } + return &domain.MessageMedia{ + Kind: domain.MessageMediaKindStory, + Story: &domain.MessageStory{ + Peer: peer, + ID: in.ID, + Story: &story, + }, + }, nil +} + +// domainGeoPointFromInput 校验并转换 InputGeoPoint;空点返回 MEDIA_EMPTY,越界返回 MEDIA_INVALID。 +// access_hash 在此随机生成:客户端会把它原样带回 upload.getWebFile 的地图缩略请求,但 +// 服务端地图渲染不依赖鉴权(详见 upload_webfile.go),故无需持久化校验。 +func domainGeoPointFromInput(input tg.InputGeoPointClass) (*domain.MessageGeoPoint, error) { + point, ok := input.(*tg.InputGeoPoint) + if !ok || point == nil { + return nil, mediaEmptyErr() + } + if point.Lat < -90 || point.Lat > 90 || point.Long < -180 || point.Long > 180 { + return nil, mediaInvalidErr() + } + accuracy, _ := point.GetAccuracyRadius() + if accuracy < 0 || accuracy > maxGeoAccuracyRadiusMeters { + accuracy = 0 + } + hash, err := randomGeoAccessHash() + if err != nil { + return nil, internalErr() + } + return &domain.MessageGeoPoint{ + Lat: point.Lat, + Long: point.Long, + AccessHash: hash, + AccuracyRadius: accuracy, + }, nil +} + +func randomGeoAccessHash() (int64, error) { + var b [8]byte + if _, err := cryptorand.Read(b[:]); err != nil { + return 0, err + } + v := int64(binary.LittleEndian.Uint64(b[:])) + if v == 0 { + v = 1 + } + return v, nil +} + +// randomPollID 生成正的 63 位随机 poll id;polls 主键冲突概率可忽略(冲突时 INSERT 报错重试由客户端承担)。 +func randomPollID() (int64, error) { + var b [8]byte + if _, err := cryptorand.Read(b[:]); err != nil { + return 0, err + } + v := int64(binary.LittleEndian.Uint64(b[:]) >> 1) + if v == 0 { + v = 1 + } + return v, nil +} + +// normalizeDiceEmoticon 去掉 emoji 变体选择符(U+FE0F):TDesktop 的 ⚽️ 与 ⚽ 都允许发送, +// 但 dice 贴纸系统集 key 与官方回包都是裸码点形态。 +func normalizeDiceEmoticon(emoticon string) string { + return strings.ReplaceAll(emoticon, "️", "") +} + +// diceValueSides 返回 emoticon 对应的取值上限(值域 [1, sides],与官方一致)。 +// 列表必须与 appConfig 的 emojies_send_dice 保持同步(客户端据其决定单 emoji 是否转 dice)。 +func diceValueSides(emoticon string) (int, bool) { + switch emoticon { + case "\U0001F3B2", "\U0001F3AF", "\U0001F3B3": // 🎲 🎯 🎳 + return 6, true + case "\U0001F3C0", "⚽": // 🏀 ⚽ + return 5, true + case "\U0001F3B0": // 🎰 + return 64, true + default: + return 0, false + } +} + +func randomDiceValue(sides int) (int, error) { + if sides <= 0 { + return 0, fmt.Errorf("dice sides must be positive") + } + // 拒绝采样消除模偏差;sides ≤ 64,单字节足够。 + max := 256 - 256%sides + var b [1]byte + for { + if _, err := cryptorand.Read(b[:]); err != nil { + return 0, err + } + if int(b[0]) < max { + return 1 + int(b[0])%sides, nil + } + } +} + +func (r *Router) messageContactUserID(ctx context.Context, userID int64, phone string) int64 { + if r.deps.Users == nil || strings.TrimSpace(phone) == "" { + return 0 + } + identity, ok := r.deps.Users.(UserIdentityService) + if !ok { + return 0 + } + u, found, err := identity.ResolvePhone(ctx, userID, phone) + if err != nil || !found { + return 0 + } + return u.ID +} + // messageMediaFromDocument 由 Document 构造 MessageMedia,并从属性推导 Video/Round/Voice 标志。 func messageMediaFromDocument(doc domain.Document, spoiler bool, ttl int) *domain.MessageMedia { media := &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &doc, Spoiler: spoiler, TTLSeconds: ttl} @@ -414,21 +836,21 @@ func messageMediaFromDocument(doc domain.Document, spoiler bool, ttl int) *domai } func inputPhotoID(input tg.InputPhotoClass) (int64, bool) { - if p, ok := input.(*tg.InputPhoto); ok && p.ID != 0 { + if p, ok := input.(*tg.InputPhoto); ok && p != nil && p.ID != 0 { return p.ID, true } return 0, false } func inputDocumentID(input tg.InputDocumentClass) (int64, bool) { - if d, ok := input.(*tg.InputDocument); ok && d.ID != 0 { + if d, ok := input.(*tg.InputDocument); ok && d != nil && d.ID != 0 { return d.ID, true } return 0, false } func inputDocumentCandidateIDs(input tg.InputDocumentClass) ([]int64, bool) { - if d, ok := input.(*tg.InputDocument); ok && d.ID != 0 { + if d, ok := input.(*tg.InputDocument); ok && d != nil && d.ID != 0 { return []int64{d.ID}, true } return nil, false @@ -546,3 +968,17 @@ func mapValuesChats(m map[int64]tg.ChatClass) []tg.ChatClass { } return out } + +// channelPostAuthorName 取当前用户的展示名作为 broadcast post 签名快照; +// store 层只在 signatures 开启的 post 上落库。 +func (r *Router) channelPostAuthorName(ctx context.Context, userID int64) string { + if r.deps.Users == nil || userID == 0 { + return "" + } + self, err := r.deps.Users.Self(ctx, userID) + if err != nil { + return "" + } + name := strings.TrimSpace(strings.TrimSpace(self.FirstName) + " " + strings.TrimSpace(self.LastName)) + return name +} diff --git a/internal/rpc/send_media_geo_test.go b/internal/rpc/send_media_geo_test.go new file mode 100644 index 00000000..036e2096 --- /dev/null +++ b/internal/rpc/send_media_geo_test.go @@ -0,0 +1,234 @@ +package rpc + +import ( + "bytes" + "context" + "testing" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" +) + +func TestSendMediaPrivateGeoPoint(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + point := &tg.InputGeoPoint{Lat: 39.9042, Long: 116.4074} + point.SetAccuracyRadius(30) // gotd flag 字段必须 Set*() 置 flag 位,直接赋字段 getter 读不到 + updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaGeoPoint{GeoPoint: point}, + RandomID: 2001, + }) + if err != nil { + t.Fatalf("sendMedia geo: %v", err) + } + msg := newMessageFromUpdates(t, updates) + media, ok := msg.Media.(*tg.MessageMediaGeo) + if !ok { + t.Fatalf("expected MessageMediaGeo, got %T", msg.Media) + } + geo, ok := media.Geo.(*tg.GeoPoint) + if !ok { + t.Fatalf("expected tg.GeoPoint, got %T", media.Geo) + } + if geo.Lat != 39.9042 || geo.Long != 116.4074 { + t.Errorf("geo = (%v, %v), want (39.9042, 116.4074)", geo.Lat, geo.Long) + } + if geo.AccessHash == 0 { + t.Error("geo access_hash should be non-zero (clients echo it to upload.getWebFile)") + } + if radius, ok := geo.GetAccuracyRadius(); !ok || radius != 30 { + t.Errorf("accuracy_radius = %d (%v), want 30", radius, ok) + } +} + +func TestSendMediaGeoPointOutOfRange(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + for _, point := range []*tg.InputGeoPoint{ + {Lat: 90.5, Long: 0}, + {Lat: -91, Long: 0}, + {Lat: 0, Long: 180.01}, + {Lat: 0, Long: -181}, + } { + _, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaGeoPoint{GeoPoint: point}, + RandomID: 2002, + }) + if err == nil || !tgerr.Is(err, "MEDIA_INVALID") { + t.Errorf("geo (%v,%v): err = %v, want MEDIA_INVALID", point.Lat, point.Long, err) + } + } +} + +func TestSendMediaGeoPointEmpty(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + _, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaGeoPoint{GeoPoint: &tg.InputGeoPointEmpty{}}, + RandomID: 2003, + }) + if err == nil || !tgerr.Is(err, "MEDIA_EMPTY") { + t.Fatalf("err = %v, want MEDIA_EMPTY", err) + } +} + +func TestSendMediaPrivateVenue(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaVenue{ + GeoPoint: &tg.InputGeoPoint{Lat: 31.2304, Long: 121.4737}, + Title: "People's Square", + Address: "Huangpu, Shanghai", + Provider: "foursquare", + VenueID: "4b5b3c4ff964a520fd0029e3", + VenueType: "arts_entertainment/default", + }, + RandomID: 2004, + }) + if err != nil { + t.Fatalf("sendMedia venue: %v", err) + } + msg := newMessageFromUpdates(t, updates) + media, ok := msg.Media.(*tg.MessageMediaVenue) + if !ok { + t.Fatalf("expected MessageMediaVenue, got %T", msg.Media) + } + if media.Title != "People's Square" || media.Address != "Huangpu, Shanghai" || + media.Provider != "foursquare" || media.VenueID != "4b5b3c4ff964a520fd0029e3" || + media.VenueType != "arts_entertainment/default" { + t.Fatalf("venue media = %+v, want preserved venue payload", media) + } + geo, ok := media.Geo.(*tg.GeoPoint) + if !ok || geo.Lat != 31.2304 || geo.Long != 121.4737 { + t.Fatalf("venue geo = %#v, want (31.2304, 121.4737)", media.Geo) + } +} + +func TestSendMediaVenueWithoutTitle(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + _, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaVenue{ + GeoPoint: &tg.InputGeoPoint{Lat: 1, Long: 1}, + Title: " ", + }, + RandomID: 2005, + }) + if err == nil || !tgerr.Is(err, "MEDIA_EMPTY") { + t.Fatalf("err = %v, want MEDIA_EMPTY", err) + } +} + +func TestSendMediaPrivateDiceValueRanges(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + cases := []struct { + emoticon string + want string + max int + }{ + {"\U0001F3B2", "\U0001F3B2", 6}, // 🎲 + {"\U0001F3AF", "\U0001F3AF", 6}, // 🎯 + {"\U0001F3B3", "\U0001F3B3", 6}, // 🎳 + {"\U0001F3C0", "\U0001F3C0", 5}, // 🏀 + {"⚽", "⚽", 5}, // 裸足球 + {"⚽️", "⚽", 5}, // 带 U+FE0F 变体,须归一 + {"\U0001F3B0", "\U0001F3B0", 64}, // 🎰 + } + randomID := int64(3000) + for _, tc := range cases { + // 多抽几次覆盖取值边界;value 必须始终落在 [1, max]。 + for i := 0; i < 8; i++ { + randomID++ + updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaDice{Emoticon: tc.emoticon}, + RandomID: randomID, + }) + if err != nil { + t.Fatalf("sendMedia dice %q: %v", tc.emoticon, err) + } + msg := newMessageFromUpdates(t, updates) + media, ok := msg.Media.(*tg.MessageMediaDice) + if !ok { + t.Fatalf("expected MessageMediaDice, got %T", msg.Media) + } + if media.Emoticon != tc.want { + t.Fatalf("dice emoticon = %q, want %q", media.Emoticon, tc.want) + } + if media.Value < 1 || media.Value > tc.max { + t.Fatalf("dice %q value = %d, want in [1, %d]", tc.emoticon, media.Value, tc.max) + } + } + } +} + +func TestSendMediaDiceUnknownEmoticon(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + _, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaDice{Emoticon: "\U0001F600"}, + RandomID: 3100, + }) + if err == nil || !tgerr.Is(err, "EMOTICON_INVALID") { + t.Fatalf("err = %v, want EMOTICON_INVALID", err) + } +} + +func TestUploadGetWebFileGeoTile(t *testing.T) { + ctx := context.Background() + r, owner, _ := newMediaTestRouter(t) + + req := &tg.UploadGetWebFileRequest{ + Location: &tg.InputWebFileGeoPointLocation{ + GeoPoint: &tg.InputGeoPoint{Lat: 39.9, Long: 116.4}, + AccessHash: 12345, + W: 100, H: 100, Zoom: 15, Scale: 1, + }, + Offset: 0, + Limit: 1 << 17, + } + file, err := r.onUploadGetWebFile(WithUserID(ctx, owner.ID), req) + if err != nil { + t.Fatalf("getWebFile: %v", err) + } + if file.Size != 12 || len(file.Bytes) != 12 { + t.Fatalf("size = %d bytes = %d, want 12/12 (fake tile)", file.Size, len(file.Bytes)) + } + if file.MimeType != "image/png" { + t.Errorf("mime = %q, want image/png", file.MimeType) + } + + // offset 续传按字节切片,size 始终是全量大小。 + req.Offset = 4 + req.Limit = 4 + chunk, err := r.onUploadGetWebFile(WithUserID(ctx, owner.ID), req) + if err != nil { + t.Fatalf("getWebFile offset: %v", err) + } + if chunk.Size != 12 || !bytes.Equal(chunk.Bytes, []byte{0x0D, 0x0A, 0x1A, 0x0A}) { + t.Fatalf("offset chunk = %v (size %d), want bytes 4..8 of tile", chunk.Bytes, chunk.Size) + } + + // URL 形态当前无来源,必须显式拒绝而非静默。 + if _, err := r.onUploadGetWebFile(WithUserID(ctx, owner.ID), &tg.UploadGetWebFileRequest{ + Location: &tg.InputWebFileLocation{URL: "https://example.com/a.png", AccessHash: 1}, + Limit: 4096, + }); err == nil || !tgerr.Is(err, "LOCATION_INVALID") { + t.Fatalf("url webfile err = %v, want LOCATION_INVALID", err) + } +} diff --git a/internal/rpc/send_media_test.go b/internal/rpc/send_media_test.go index e8e19c86..cd3ac292 100644 --- a/internal/rpc/send_media_test.go +++ b/internal/rpc/send_media_test.go @@ -2,13 +2,17 @@ package rpc import ( "context" + "errors" "testing" "github.com/gotd/td/clock" "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" "go.uber.org/zap/zaptest" appmessages "telesrv/internal/app/messages" + apppolls "telesrv/internal/app/polls" + appstories "telesrv/internal/app/stories" appusers "telesrv/internal/app/users" "telesrv/internal/domain" "telesrv/internal/store/memory" @@ -16,10 +20,34 @@ import ( // fakeFiles 是 FilesService 的最小测试替身:贴纸文档可解析,上传图片返回固定 Photo。 type fakeFiles struct { - docs map[int64]domain.Document - photos map[int64]domain.Photo - reactions []domain.AvailableReaction - sets map[domain.StickerSetKind][]domain.StickerSet + docs map[int64]domain.Document + photos map[int64]domain.Photo + profile map[fakeProfilePhotoKey]int64 + reactions []domain.AvailableReaction + effects []domain.AvailableEffect + sets map[domain.StickerSetKind][]domain.StickerSet + profilePhotos []domain.Photo + profilePhotosTotal int + lastProfileOffset int + lastProfileLimit int + lastProfileMaxID int64 + resolveWebPageFn func(string) (domain.MessageWebPage, error) + lookupWebPageFn func(string) (domain.MessageWebPage, bool) + webPagePreviewOn bool +} + +type fakeProfilePhotoKey struct { + ownerType domain.PeerType + ownerID int64 + kind domain.ProfilePhotoKind +} + +func (f *fakeFiles) putPhoto(photo domain.Photo) domain.Photo { + if f.photos == nil { + f.photos = map[int64]domain.Photo{} + } + f.photos[photo.ID] = photo + return photo } func (f *fakeFiles) SaveFilePart(context.Context, int64, int64, int, []byte) (bool, error) { @@ -31,9 +59,22 @@ func (f *fakeFiles) SaveBigFilePart(context.Context, int64, int64, int, int, []b func (f *fakeFiles) GetFile(context.Context, domain.FileDownloadRequest) (domain.FileChunk, bool, error) { return domain.FileChunk{}, false, nil } +func (f *fakeFiles) CreateEncryptedFileFromUpload(context.Context, domain.UploadedFileRef, int) (domain.EncryptedFileRef, error) { + return domain.EncryptedFileRef{ID: 9001, AccessHash: 9002, Size: 16, DCID: 2, KeyFingerprint: 7}, nil +} +func (f *fakeFiles) GeoMapTile(lat, long float64, w, h, zoom, scale int) ([]byte, string) { + return []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4}, "image/png" +} func (f *fakeFiles) ListAvailableReactions(context.Context) ([]domain.AvailableReaction, error) { return append([]domain.AvailableReaction(nil), f.reactions...), nil } +func (f *fakeFiles) AvailableEffects(context.Context) ([]domain.AvailableEffect, int, error) { + hash := 0 + for _, e := range f.effects { + hash = hash*31 + int(e.ID&0x7fffffff) + } + return append([]domain.AvailableEffect(nil), f.effects...), hash & 0x7fffffff, nil +} func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) { out := make([]domain.Document, 0, len(ids)) for _, id := range ids { @@ -43,7 +84,30 @@ func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Docum } return out, nil } -func (f *fakeFiles) ResolveStickerSet(context.Context, domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { +func (f *fakeFiles) ResolveStickerSet(_ context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { + for _, sets := range f.sets { + for _, set := range sets { + match := false + switch ref.Kind { + case domain.StickerSetRefByID: + match = set.ID == ref.ID + case domain.StickerSetRefByShortName: + match = set.ShortName == ref.ShortName + case domain.StickerSetRefBySystem: + match = set.SystemKey == ref.SystemKey + } + if !match { + continue + } + docs := make([]domain.Document, 0, len(set.DocumentIDs)) + for _, id := range set.DocumentIDs { + if doc, ok := f.docs[id]; ok { + docs = append(docs, doc) + } + } + return set, docs, true, nil + } + } return domain.StickerSet{}, nil, false, nil } func (f *fakeFiles) ListStickerSets(_ context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { @@ -51,14 +115,96 @@ func (f *fakeFiles) ListStickerSets(_ context.Context, kind domain.StickerSetKin return append([]domain.StickerSet(nil), sets...), nil } func (f *fakeFiles) CreatePhotoFromUpload(_ context.Context, _ domain.UploadedFileRef) (domain.Photo, error) { - return domain.Photo{ID: 777, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600}}}, nil + photo := domain.Photo{ID: 777, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600}}} + return f.putPhoto(photo), nil +} +func (f *fakeFiles) CreatePhotoFromBytes(_ context.Context, data []byte) (domain.Photo, error) { + photo := domain.Photo{ + ID: 8300 + int64(len(f.photos)), + AccessHash: 83, + DCID: 2, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 320, H: 200, Size: len(data)}}, + } + return f.putPhoto(photo), nil } func (f *fakeFiles) CreateAvatarFromUpload(_ context.Context, _ domain.UploadedFileRef) (domain.Photo, error) { - return domain.Photo{ID: 778, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "a", W: 160, H: 160}, {Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640}}}, nil + photo := domain.Photo{ID: 778, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "a", W: 160, H: 160}, {Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640}}} + return f.putPhoto(photo), nil +} +func (f *fakeFiles) CreateAvatarVideoFromUpload(_ context.Context, _ domain.UploadedFileRef, videoStartTs float64) (domain.Photo, error) { + photo := domain.Photo{ID: 779, AccessHash: 7, DCID: 2, Sizes: append(fakeAvatarStaticSizes(), domain.PhotoSize{Kind: domain.PhotoSizeKindVideo, Type: "u", W: 640, H: 640, Size: 1024, VideoStartTs: videoStartTs})} + return f.putPhoto(photo), nil +} +func (f *fakeFiles) CreateAvatarVideoMarkupFromUpload(_ context.Context, _ domain.UploadedFileRef, videoStartTs float64, markup domain.PhotoSize) (domain.Photo, error) { + sizes := append(fakeAvatarStaticSizes(), domain.PhotoSize{Kind: domain.PhotoSizeKindVideo, Type: "u", W: 640, H: 640, Size: 1024, VideoStartTs: videoStartTs}) + sizes = append(sizes, markup) + photo := domain.Photo{ID: 781, AccessHash: 7, DCID: 2, Sizes: sizes} + return f.putPhoto(photo), nil +} +func (f *fakeFiles) CreateAvatarMarkup(_ context.Context, size domain.PhotoSize) (domain.Photo, error) { + photo := domain.Photo{ID: 780, AccessHash: 7, DCID: 2, Sizes: append(fakeAvatarStaticSizes(), size)} + return f.putPhoto(photo), nil +} + +func fakeAvatarStaticSizes() []domain.PhotoSize { + return []domain.PhotoSize{ + {Kind: domain.PhotoSizeKindDefault, Type: "a", W: 160, H: 160, Size: 1024}, + {Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 1024}, + } } func (f *fakeFiles) CreateDocumentFromUpload(_ context.Context, _ domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) { return domain.Document{ID: 888, AccessHash: 8, DCID: 2, MimeType: spec.MimeType, Attributes: spec.Attributes}, nil } +func (f *fakeFiles) CreateDocumentFromBytes(_ context.Context, data []byte, spec domain.DocumentSpec) (domain.Document, error) { + doc := domain.Document{ + ID: 8400 + int64(len(f.docs)), + AccessHash: 84, + DCID: 2, + MimeType: spec.MimeType, + Size: int64(len(data)), + Attributes: append([]domain.DocumentAttribute(nil), spec.Attributes...), + } + if f.docs == nil { + f.docs = map[int64]domain.Document{} + } + f.docs[doc.ID] = doc + return doc, nil +} +func (f *fakeFiles) CreatePhotoFromURL(_ context.Context, rawURL string) (domain.Photo, error) { + if rawURL == "" { + return domain.Photo{}, domain.ErrPhotoInvalid + } + return f.putPhoto(domain.Photo{ID: 9100, AccessHash: 91, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 320, H: 200}}}), nil +} + +func (f *fakeFiles) ResolveWebPage(_ context.Context, rawURL string) (domain.MessageWebPage, error) { + if f.resolveWebPageFn != nil { + return f.resolveWebPageFn(rawURL) + } + return domain.MessageWebPage{}, errors.New("web page preview unavailable") +} + +func (f *fakeFiles) WebPagePreviewEnabled() bool { return f.webPagePreviewOn } + +func (f *fakeFiles) LookupWebPage(_ context.Context, rawURL string) (domain.MessageWebPage, bool) { + if f.lookupWebPageFn != nil { + return f.lookupWebPageFn(rawURL) + } + return domain.MessageWebPage{}, false +} + +func (f *fakeFiles) CreateDocumentFromURL(_ context.Context, rawURL string) (domain.Document, error) { + if rawURL == "" { + return domain.Document{}, domain.ErrDocumentInvalid + } + doc := domain.Document{ID: 9200, AccessHash: 92, DCID: 2, MimeType: "image/jpeg", Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrFilename, FileName: "ext.jpg"}}} + if f.docs == nil { + f.docs = map[int64]domain.Document{} + } + f.docs[doc.ID] = doc + return doc, nil +} + func (f *fakeFiles) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) { p, ok := f.photos[id] return p, ok, nil @@ -67,29 +213,76 @@ func (f *fakeFiles) GetDocument(_ context.Context, id int64) (domain.Document, b d, ok := f.docs[id] return d, ok, nil } -func (f *fakeFiles) UploadProfilePhoto(context.Context, domain.PeerType, int64, domain.UploadedFileRef, int) (domain.Photo, error) { - return domain.Photo{}, nil +func (f *fakeFiles) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) { + return f.UploadProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, file, date) } -func (f *fakeFiles) UploadProfilePhotoKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, domain.UploadedFileRef, int) (domain.Photo, error) { - return domain.Photo{}, nil +func (f *fakeFiles) UploadProfilePhotoKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, _ domain.UploadedFileRef, _ int) (domain.Photo, error) { + photo, _ := f.CreateAvatarFromUpload(context.Background(), domain.UploadedFileRef{}) + if f.profile == nil { + f.profile = map[fakeProfilePhotoKey]int64{} + } + f.profile[fakeProfilePhotoKey{ownerType: ownerType, ownerID: ownerID, kind: kind}] = photo.ID + return photo, nil } -func (f *fakeFiles) SetCurrentProfilePhoto(context.Context, domain.PeerType, int64, int64, int) (domain.Photo, bool, error) { - return domain.Photo{}, false, nil +func (f *fakeFiles) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) { + return f.SetCurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoID, date) } -func (f *fakeFiles) SetCurrentProfilePhotoKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, int64, int) (domain.Photo, bool, error) { - return domain.Photo{}, false, nil +func (f *fakeFiles) SetCurrentProfilePhotoKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, _ int) (domain.Photo, bool, error) { + photo, ok := f.photos[photoID] + if !ok { + return domain.Photo{}, false, nil + } + if f.profile == nil { + f.profile = map[fakeProfilePhotoKey]int64{} + } + f.profile[fakeProfilePhotoKey{ownerType: ownerType, ownerID: ownerID, kind: kind}] = photoID + return photo, true, nil } -func (f *fakeFiles) CurrentProfilePhoto(context.Context, domain.PeerType, int64) (domain.Photo, bool, error) { - return domain.Photo{}, false, nil +func (f *fakeFiles) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) { + return f.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile) } -func (f *fakeFiles) CurrentProfilePhotoKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind) (domain.Photo, bool, error) { - return domain.Photo{}, false, nil +func (f *fakeFiles) CurrentProfilePhotoKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error) { + photoID := f.profile[fakeProfilePhotoKey{ownerType: ownerType, ownerID: ownerID, kind: kind}] + if photoID == 0 { + return domain.Photo{}, false, nil + } + photo, ok := f.photos[photoID] + return photo, ok, nil } -func (f *fakeFiles) GetProfilePhotos(context.Context, domain.PeerType, int64, int, int, int64) ([]domain.Photo, int, error) { - return nil, 0, nil +func (f *fakeFiles) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + return f.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile) } -func (f *fakeFiles) GetProfilePhotosKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, int, int, int64) ([]domain.Photo, int, error) { - return nil, 0, nil +func (f *fakeFiles) CurrentProfilePhotosKind(_ context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { + out := make(map[int64]domain.ProfilePhotoRef, len(ownerIDs)) + for _, ownerID := range ownerIDs { + photoID := f.profile[fakeProfilePhotoKey{ownerType: ownerType, ownerID: ownerID, kind: kind}] + if photoID == 0 { + continue + } + photo, ok := f.photos[photoID] + if !ok { + continue + } + out[ownerID] = domain.ProfilePhotoRef{ + PhotoID: photo.ID, + DCID: photo.DCID, + Stripped: domain.StrippedFromSizes(photo.Sizes), + HasVideo: domain.PhotoHasVideo(photo.Sizes), + } + } + return out, nil +} +func (f *fakeFiles) GetProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, offset, limit int, maxID int64) ([]domain.Photo, int, error) { + f.lastProfileOffset = offset + f.lastProfileLimit = limit + f.lastProfileMaxID = maxID + return append([]domain.Photo(nil), f.profilePhotos...), f.profilePhotosTotal, nil +} +func (f *fakeFiles) GetProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) { + f.lastProfileOffset = offset + f.lastProfileLimit = limit + f.lastProfileMaxID = maxID + return append([]domain.Photo(nil), f.profilePhotos...), f.profilePhotosTotal, nil } func (f *fakeFiles) DeleteProfilePhotos(context.Context, domain.PeerType, int64, []int64) (int, error) { return 0, nil @@ -106,6 +299,8 @@ func newMediaTestRouter(t *testing.T) (*Router, domain.User, domain.User) { friend, _ := userStore.Create(ctx, domain.User{AccessHash: 12, Phone: "15550009002", FirstName: "Friend"}) dialogStore := memory.NewDialogStore() messageStore := memory.NewMessageStore(dialogStore) + pollStore := memory.NewPollStore() + messageStore.AttachPollStore(pollStore) files := &fakeFiles{ docs: map[int64]domain.Document{ 555: { @@ -122,6 +317,7 @@ func newMediaTestRouter(t *testing.T) (*Router, domain.User, domain.User) { Users: appusers.NewService(userStore), Messages: appmessages.NewService(messageStore, dialogStore), Files: files, + Polls: apppolls.NewService(pollStore), Sessions: &captureSessions{}, }, zaptest.NewLogger(t), clock.System) return r, owner, friend @@ -141,11 +337,43 @@ func newMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message { } return msg } + if nm, ok := u.(*tg.UpdateNewChannelMessage); ok { + msg, ok := nm.Message.(*tg.Message) + if !ok { + t.Fatalf("expected channel *tg.Message, got %T", nm.Message) + } + return msg + } } - t.Fatal("no UpdateNewMessage found") + t.Fatal("no new message update found") return nil } +func assertMessageMediaStory(t *testing.T, media tg.MessageMediaClass, wantUserID int64, wantStoryID int, wantEmbedded bool) { + t.Helper() + storyMedia, ok := media.(*tg.MessageMediaStory) + if !ok { + t.Fatalf("message media = %T, want *tg.MessageMediaStory", media) + } + peer, ok := storyMedia.Peer.(*tg.PeerUser) + if !ok || peer.UserID != wantUserID { + t.Fatalf("story media peer = %T %+v, want user %d", storyMedia.Peer, storyMedia.Peer, wantUserID) + } + if storyMedia.ID != wantStoryID { + t.Fatalf("story media id = %d, want %d", storyMedia.ID, wantStoryID) + } + story, hasStory := storyMedia.GetStory() + if hasStory != wantEmbedded { + t.Fatalf("story media embedded = %v, want %v", hasStory, wantEmbedded) + } + if wantEmbedded { + item, ok := story.(*tg.StoryItem) + if !ok || item.ID != wantStoryID { + t.Fatalf("embedded story = %T %+v, want story id %d", story, story, wantStoryID) + } + } +} + func TestSendMediaPrivateSticker(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) @@ -214,6 +442,145 @@ func TestSendMediaPrivateUploadedPhoto(t *testing.T) { } } +func TestSendMediaInputMediaStoryStoresMessageMediaStory(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + storyStore := memory.NewStoryStore() + r.deps.Stories = appstories.NewService(storyStore) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 7, + Date: 1700000001, + ExpireDate: 1700003600, + Public: true, + Caption: "story source", + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 771, AccessHash: 77, DCID: 2}}, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + + updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaStory{Peer: &tg.InputPeerSelf{}, ID: 7}, + RandomID: 10021, + }) + if err != nil { + t.Fatalf("sendMedia story: %v", err) + } + msg := newMessageFromUpdates(t, updates) + assertMessageMediaStory(t, msg.Media, owner.ID, 7, true) + + got, err := r.onMessagesGetMessages(WithUserID(ctx, owner.ID), []tg.InputMessageClass{&tg.InputMessageID{ID: msg.ID}}) + if err != nil { + t.Fatalf("get story message: %v", err) + } + box, ok := got.(*tg.MessagesMessages) + if !ok || len(box.Messages) != 1 { + t.Fatalf("get story message = %T %+v, want one messages.messages", got, got) + } + stored, ok := box.Messages[0].(*tg.Message) + if !ok { + t.Fatalf("stored story message = %T, want *tg.Message", box.Messages[0]) + } + assertMessageMediaStory(t, stored.Media, owner.ID, 7, true) +} + +func TestSendMediaInputMediaStoryRejectsNoForwardsSource(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + storyStore := memory.NewStoryStore() + r.deps.Stories = appstories.NewService(storyStore) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 8, + Date: 1700000002, + ExpireDate: 1700003600, + Public: true, + NoForwards: true, + }}); err != nil { + t.Fatalf("upsert noforwards story: %v", err) + } + + _, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaStory{Peer: &tg.InputPeerSelf{}, ID: 8}, + RandomID: 10022, + }) + if err == nil || !tgerr.Is(err, "CHAT_FORWARDS_RESTRICTED") { + t.Fatalf("sendMedia noforwards story err = %v, want CHAT_FORWARDS_RESTRICTED", err) + } +} + +func TestSendMediaPrivateContact(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + r.deps.Files = nil + + updates, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaContact{ + PhoneNumber: "+1 (555) 000-9002", + FirstName: "Bob", + LastName: "Shared", + Vcard: "BEGIN:VCARD\nFN:Bob Shared\nEND:VCARD", + }, + RandomID: 1003, + }) + if err != nil { + t.Fatalf("sendMedia contact: %v", err) + } + upd := updates.(*tg.Updates) + msg := newMessageFromUpdates(t, updates) + media, ok := msg.Media.(*tg.MessageMediaContact) + if !ok { + t.Fatalf("expected MessageMediaContact, got %T", msg.Media) + } + if media.PhoneNumber != "+1 (555) 000-9002" || media.FirstName != "Bob" || media.LastName != "Shared" || media.Vcard == "" { + t.Fatalf("contact media = %+v, want preserved contact payload", media) + } + if media.UserID != friend.ID { + t.Fatalf("contact user_id = %d, want %d", media.UserID, friend.ID) + } + foundFriend := false + for _, u := range upd.Users { + if got, ok := u.(*tg.User); ok && got.ID == friend.ID { + foundFriend = true + } + } + if !foundFriend { + t.Fatalf("updates users = %#v, want shared contact user", upd.Users) + } +} + +func TestUploadMediaContactUnregistered(t *testing.T) { + ctx := context.Background() + r, owner, _ := newMediaTestRouter(t) + + media, err := r.onMessagesUploadMedia(WithUserID(ctx, owner.ID), &tg.MessagesUploadMediaRequest{ + Peer: &tg.InputPeerEmpty{}, + Media: &tg.InputMediaContact{ + PhoneNumber: "+19990000000", + FirstName: "External", + LastName: "Contact", + }, + }) + if err != nil { + t.Fatalf("uploadMedia contact: %v", err) + } + contact, ok := media.(*tg.MessageMediaContact) + if !ok { + t.Fatalf("expected MessageMediaContact, got %T", media) + } + if contact.UserID != 0 { + t.Fatalf("unregistered contact user_id = %d, want 0", contact.UserID) + } + if contact.FirstName != "External" || contact.LastName != "Contact" { + t.Fatalf("contact media = %+v, want external contact", contact) + } +} + func TestUploadMediaReturnsReusableMedia(t *testing.T) { ctx := context.Background() r, owner, _ := newMediaTestRouter(t) diff --git a/internal/rpc/stats.go b/internal/rpc/stats.go index ff4307b0..3f8adb6c 100644 --- a/internal/rpc/stats.go +++ b/internal/rpc/stats.go @@ -82,12 +82,22 @@ func (r *Router) onStatsLoadAsyncGraph(_ context.Context, req *tg.StatsLoadAsync } func (r *Router) onStatsGetStoryStats(ctx context.Context, req *tg.StatsGetStoryStatsRequest) (*tg.StatsStoryStats, error) { - if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() + if req.ID <= 0 || req.ID > domain.MaxStoryID { + return nil, storyIDInvalidErr() } - if err := r.validateStatsPeer(ctx, req.Peer); err != nil { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { return nil, err } + if r.deps.Stories != nil { + if err := r.deps.Stories.CanViewStoryStats(ctx, userID, peer); err != nil { + return nil, storyErr(err) + } + } return &tg.StatsStoryStats{ ViewsGraph: r.emptyStatsGraph("Views"), ReactionsByEmotionGraph: r.emptyStatsGraph("Reactions"), @@ -95,16 +105,61 @@ func (r *Router) onStatsGetStoryStats(ctx context.Context, req *tg.StatsGetStory } func (r *Router) onStatsGetStoryPublicForwards(ctx context.Context, req *tg.StatsGetStoryPublicForwardsRequest) (*tg.StatsPublicForwards, error) { - if req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return nil, messageIDInvalidErr() + if req.ID <= 0 || req.ID > domain.MaxStoryID { + return nil, storyIDInvalidErr() } if req.Limit < 0 || req.Limit > maxStatsPublicForwardsLimit || len(req.Offset) > maxStatsOffsetLength { return nil, limitInvalidErr() } - if err := r.validateStatsPeer(ctx, req.Peer); err != nil { + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return nil, storyErr(err) + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { return nil, err } - return emptyStatsPublicForwards(), nil + if r.deps.Stories == nil || userID == 0 { + return emptyStatsPublicForwards(), nil + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxStoryInteractionListLimit { + limit = domain.MaxStoryInteractionListLimit + } + storyForwards, err := r.deps.Stories.GetStoryPublicForwards(ctx, userID, domain.StoryPublicForwardListRequest{ + ViewerUserID: userID, + Owner: peer, + StoryID: req.ID, + Offset: req.Offset, + Limit: limit, + }) + if err != nil { + return nil, storyErr(err) + } + messageForwards := domain.StoryMessageForwardList{} + if r.deps.Channels != nil { + messageForwards, err = r.deps.Channels.ListStoryMessageForwards(ctx, userID, domain.StoryMessageForwardListRequest{ + ViewerUserID: userID, + Owner: peer, + StoryID: req.ID, + Offset: req.Offset, + Limit: limit, + ForwardsFirst: true, + }) + if err != nil { + return nil, storyErr(err) + } + } + hasMore := storyInteractionSourcesHaveMore(len(storyForwards.Forwards), len(messageForwards.Forwards), limit, storyForwards.NextOffset != "" || messageForwards.NextOffset != "") + forwards := mergeStoryInteractionViews(storyForwards.Forwards, messageForwards.Forwards, limit, false, true) + return r.tgStatsPublicForwards(ctx, userID, domain.StoryPublicForwardList{ + Count: storyForwards.Count + messageForwards.Count, + Forwards: forwards, + NextOffset: nextStoryInteractionOffset(forwards, limit, false, true, hasMore), + }), nil } func (r *Router) onStatsGetPollStats(ctx context.Context, req *tg.StatsGetPollStatsRequest) (*tg.StatsPollStats, error) { @@ -209,3 +264,45 @@ func emptyStatsPublicForwards() *tg.StatsPublicForwards { Users: []tg.UserClass{}, } } + +func (r *Router) tgStatsPublicForwards(ctx context.Context, viewerUserID int64, list domain.StoryPublicForwardList) *tg.StatsPublicForwards { + users := r.domainUsersForIDs(ctx, viewerUserID, storyViewUserIDs(list.Forwards)) + peerUsers, peerChannels := r.storyPeerObjects(ctx, viewerUserID, storyViewPeers(list.Forwards)) + users = mergeDomainUsers(users, peerUsers...) + out := &tg.StatsPublicForwards{ + Count: list.Count, + Forwards: tgPublicForwardItems(viewerUserID, list.Forwards), + Chats: tgChannels(viewerUserID, peerChannels), + Users: tgUsersForViewer(viewerUserID, users), + } + if list.NextOffset != "" { + out.SetNextOffset(list.NextOffset) + } + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + return out +} + +func tgPublicForwardItems(viewerUserID int64, views []domain.StoryView) []tg.PublicForwardClass { + out := make([]tg.PublicForwardClass, 0, len(views)) + for _, view := range views { + if view.PublicForward != nil { + msg := tgChannelMessage(viewerUserID, view.PublicForward.Message) + if msg == nil { + continue + } + out = append(out, &tg.PublicForwardMessage{Message: msg}) + continue + } + if view.Repost != nil { + peer := tgPeer(view.Repost.Owner) + if peer == nil { + continue + } + out = append(out, &tg.PublicForwardStory{ + Peer: peer, + Story: tgStoryItem(*view.Repost), + }) + } + } + return out +} diff --git a/internal/rpc/sticker_catalog_cache.go b/internal/rpc/sticker_catalog_cache.go new file mode 100644 index 00000000..ed15b149 --- /dev/null +++ b/internal/rpc/sticker_catalog_cache.go @@ -0,0 +1,54 @@ +package rpc + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +const ( + stickerCatalogCacheMaxEntries = 8 + // stickerCatalogCacheTTL 对齐客户端 trending/贴纸目录 ~5min 刷新率:TTL 内 getAllStickers/ + // getFeaturedStickers/getStickers(emoji 索引重建) 复用同一份集目录,避免每请求 ListStickerSets。 + stickerCatalogCacheTTL = 5 * time.Minute +) + +// stickerCatalogCache 缓存 kind→贴纸集目录(全局、非 per-viewer:ListStickerSets 无用户维度)。 +// 内建 TTL + singleflight,让"先查后判 hash"的目录类 RPC 在 hash 命中时不打 PG。 +type stickerCatalogCache struct { + cache *readmodelcache.Cache[domain.StickerSetKind, []domain.StickerSet] +} + +func newStickerCatalogCache(now func() time.Time) *stickerCatalogCache { + return &stickerCatalogCache{ + cache: readmodelcache.New[domain.StickerSetKind, []domain.StickerSet](readmodelcache.Config[domain.StickerSetKind, []domain.StickerSet]{ + MaxEntries: stickerCatalogCacheMaxEntries, + TTL: stickerCatalogCacheTTL, + Now: now, + }), + } +} + +// stickerCatalogSets 返回某 kind 的(缓存的)贴纸集目录。Files 缺失或出错返回 nil。 +// 返回的切片只读(调用方不得修改元素),需要派生时各自计算 hash/过滤。 +func (r *Router) stickerCatalogSets(ctx context.Context, kind domain.StickerSetKind) []domain.StickerSet { + if r.deps.Files == nil { + return nil + } + if r.stickerCatalog == nil { + sets, err := r.deps.Files.ListStickerSets(ctx, kind) + if err != nil { + return nil + } + return sets + } + sets, err := r.stickerCatalog.cache.GetOrLoad(ctx, kind, func() ([]domain.StickerSet, error) { + return r.deps.Files.ListStickerSets(ctx, kind) + }) + if err != nil { + return nil + } + return sets +} diff --git a/internal/rpc/stickers.go b/internal/rpc/stickers.go index 3cd39619..b022b35a 100644 --- a/internal/rpc/stickers.go +++ b/internal/rpc/stickers.go @@ -4,6 +4,7 @@ import ( "context" "github.com/gotd/td/tg" + "go.uber.org/zap" "telesrv/internal/compat/tdesktop" "telesrv/internal/domain" @@ -34,6 +35,34 @@ func (r *Router) onMessagesGetAvailableReactions(ctx context.Context, hash int) return tgAvailableReactions(reactions, documentsByID(docs), catalogHash), nil } +// onMessagesGetAvailableEffects 返回消息发送特效目录(全局静态,seed 进内存)。镜像 +// getAvailableReactions:文档批量预加载后由 tgAvailableEffects 组装,hash 命中回 NotModified。 +func (r *Router) onMessagesGetAvailableEffects(ctx context.Context, hash int) (tg.MessagesAvailableEffectsClass, error) { + empty := func() *tg.MessagesAvailableEffects { + return &tg.MessagesAvailableEffects{Hash: 0, Effects: []tg.AvailableEffect{}, Documents: []tg.DocumentClass{}} + } + if r.deps.Files == nil { + return empty(), nil + } + effects, catalogHash, err := r.deps.Files.AvailableEffects(ctx) + if err != nil { + return nil, internalErr() + } + if len(effects) == 0 { + return empty(), nil + } + // catalogHash 在 seed 时算好(全局静态),命中即零查库返回 NotModified——必须在 + // GetDocuments(唯一打 PG 的点)之前。 + if hash == catalogHash { + return &tg.MessagesAvailableEffectsNotModified{}, nil + } + docs, err := r.deps.Files.GetDocuments(ctx, effectDocumentIDs(effects)) + if err != nil { + return nil, internalErr() + } + return tgAvailableEffects(effects, documentsByID(docs), catalogHash), nil +} + func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGetStickerSetRequest) (tg.MessagesStickerSetClass, error) { if r.deps.Files == nil { return tdesktop.StickerSet(req), nil @@ -46,7 +75,34 @@ func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGe if err != nil { return nil, internalErr() } + // 观测:量化客户端反复请求哪些集(同集重试 vs 大量不同集)。未 seed 集走 stub, + // 由 ResolveStickerSet 的负缓存短路 PG;这里只记 ref 与命中情况。 + if r.log != nil { + r.log.Debug("getStickerSet", + zap.String("ref_kind", string(ref.Kind)), + zap.String("short_name", ref.ShortName), + zap.Int64("set_id", ref.ID), + zap.String("system_key", ref.SystemKey), + zap.Bool("found", found), + ) + } if !found { + if fallbackSet, fallbackDocs, fallbackFound, fallbackErr := r.resolvePlaceholderStickerSet(ctx, ref); fallbackErr != nil { + return nil, internalErr() + } else if fallbackFound { + if r.log != nil { + r.log.Debug("getStickerSet placeholder fallback", + zap.String("short_name", ref.ShortName), + zap.Int64("fallback_set_id", fallbackSet.ID), + zap.String("fallback_short_name", fallbackSet.ShortName), + zap.Int("documents", len(fallbackDocs)), + ) + } + if req.Hash != 0 && req.Hash == fallbackSet.Hash { + return &tg.MessagesStickerSetNotModified{}, nil + } + return tgMessagesStickerSet(fallbackSet, fallbackDocs), nil + } // 未 seed 的系统集 / 未知短名:回退兼容 stub,避免破坏客户端。 return tdesktop.StickerSet(req), nil } @@ -56,6 +112,69 @@ func (r *Router) onMessagesGetStickerSet(ctx context.Context, req *tg.MessagesGe return tgMessagesStickerSet(set, docs), nil } +func (r *Router) resolvePlaceholderStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) { + if ref.Kind != domain.StickerSetRefByShortName || !isClientPlaceholderStickerSet(ref.ShortName) { + return domain.StickerSet{}, nil, false, nil + } + for _, candidate := range placeholderStickerSetCandidates() { + set, docs, found, err := r.deps.Files.ResolveStickerSet(ctx, candidate) + if err != nil || !found { + if err != nil { + return domain.StickerSet{}, nil, false, err + } + continue + } + if len(docs) >= androidPlaceholderStickerMinDocuments { + return set, docs, true, nil + } + } + for _, kind := range []domain.StickerSetKind{domain.StickerSetKindSystem, domain.StickerSetKindEmoji, domain.StickerSetKindStickers} { + sets, err := r.deps.Files.ListStickerSets(ctx, kind) + if err != nil { + return domain.StickerSet{}, nil, false, err + } + for _, candidate := range sets { + if len(candidate.DocumentIDs) < androidPlaceholderStickerMinDocuments { + continue + } + set, docs, found, err := r.deps.Files.ResolveStickerSet(ctx, domain.StickerSetRef{ + Kind: domain.StickerSetRefByID, + ID: candidate.ID, + AccessHash: candidate.AccessHash, + }) + if err != nil { + return domain.StickerSet{}, nil, false, err + } + if found && len(docs) >= androidPlaceholderStickerMinDocuments { + return set, docs, true, nil + } + } + } + return domain.StickerSet{}, nil, false, nil +} + +const androidPlaceholderStickerMinDocuments = 7 + +func isClientPlaceholderStickerSet(shortName string) bool { + switch shortName { + case "tg_placeholders_android", "tg_superplaceholders_android_2": + return true + default: + return false + } +} + +func placeholderStickerSetCandidates() []domain.StickerSetRef { + return []domain.StickerSetRef{ + {Kind: domain.StickerSetRefBySystem, SystemKey: "animated_emoji"}, + {Kind: domain.StickerSetRefBySystem, SystemKey: "emoji_generic_animations"}, + {Kind: domain.StickerSetRefBySystem, SystemKey: "animated_emoji_animations"}, + {Kind: domain.StickerSetRefByShortName, ShortName: "AnimatedEmojies"}, + {Kind: domain.StickerSetRefByShortName, ShortName: "EmojiGenericAnimations"}, + {Kind: domain.StickerSetRefByShortName, ShortName: "EmojiAnimations"}, + } +} + func (r *Router) onMessagesGetAllStickers(ctx context.Context, hash int64) (tg.MessagesAllStickersClass, error) { return r.allStickersForKind(ctx, hash, domain.StickerSetKindStickers) } @@ -64,14 +183,16 @@ func (r *Router) onMessagesGetEmojiStickers(ctx context.Context, hash int64) (tg return r.allStickersForKind(ctx, hash, domain.StickerSetKindEmoji) } +func (r *Router) onMessagesGetMaskStickers(ctx context.Context, hash int64) (tg.MessagesAllStickersClass, error) { + return r.allStickersForKind(ctx, hash, domain.StickerSetKindMasks) +} + func (r *Router) allStickersForKind(ctx context.Context, hash int64, kind domain.StickerSetKind) (tg.MessagesAllStickersClass, error) { if r.deps.Files == nil { return messagesAllStickersEmpty(hash), nil } - sets, err := r.deps.Files.ListStickerSets(ctx, kind) - if err != nil { - return nil, internalErr() - } + // perf:从目录缓存读集(TTL 内 hash 命中不打 PG)。 + sets := r.stickerCatalogSets(ctx, kind) if len(sets) == 0 { return messagesAllStickersEmpty(hash), nil } @@ -82,6 +203,94 @@ func (r *Router) allStickersForKind(ctx context.Context, hash int64, kind domain return &tg.MessagesAllStickers{Hash: catalogHash, Sets: tgStickerSets(sets)}, nil } +// featuredCoversPerSet 限制每个 featured 集解析的封面贴纸数量(trending 预览用)。 +const featuredCoversPerSet = 5 + +func (r *Router) onMessagesGetFeaturedStickers(ctx context.Context, hash int64) (tg.MessagesFeaturedStickersClass, error) { + return r.featuredStickersForKind(ctx, hash, domain.StickerSetKindStickers) +} + +func (r *Router) onMessagesGetFeaturedEmojiStickers(ctx context.Context, hash int64) (tg.MessagesFeaturedStickersClass, error) { + return r.featuredStickersForKind(ctx, hash, domain.StickerSetKindEmoji) +} + +// featuredStickersForKind 把已 seed 的(未归档)贴纸/emoji 集作为 trending 呈现。 +// 性能:先用集目录 hash 比对,命中即返回 *NotModified——封面文档解析只在 cache-miss +// 时发生(一次批量 GetDocuments),避免每次请求都解析封面。 +func (r *Router) featuredStickersForKind(ctx context.Context, hash int64, kind domain.StickerSetKind) (tg.MessagesFeaturedStickersClass, error) { + if r.deps.Files == nil { + return messagesFeaturedStickersEmpty(hash), nil + } + // perf:从目录缓存读集(TTL 内 hash 命中不打 PG,封面仅 cache-miss 解析)。 + sets := r.stickerCatalogSets(ctx, kind) + visible := make([]domain.StickerSet, 0, len(sets)) + for _, s := range sets { + if s.ID == 0 || s.Archived { + continue + } + visible = append(visible, s) + } + if len(visible) == 0 { + return messagesFeaturedStickersEmpty(hash), nil + } + catalogHash := stickerSetsCatalogHash(visible) + if hash != 0 && hash == catalogHash { + // 关键 perf 短路:目录未变直接返回,不解析任何封面文档。 + return &tg.MessagesFeaturedStickersNotModified{Count: len(visible)}, nil + } + covers := r.resolveFeaturedCovers(ctx, visible) + covered := make([]tg.StickerSetCoveredClass, 0, len(visible)) + for _, s := range visible { + covered = append(covered, featuredCoveredSet(s, covers)) + } + return &tg.MessagesFeaturedStickers{ + Hash: catalogHash, + Count: len(visible), + Sets: covered, + Unread: []int64{}, + }, nil +} + +// resolveFeaturedCovers 批量解析所有 featured 集的前若干封面文档(一次查询)。 +func (r *Router) resolveFeaturedCovers(ctx context.Context, sets []domain.StickerSet) map[int64]domain.Document { + ids := make([]int64, 0, len(sets)*featuredCoversPerSet) + for _, s := range sets { + for i, id := range s.DocumentIDs { + if i >= featuredCoversPerSet { + break + } + if id != 0 { + ids = append(ids, id) + } + } + } + if len(ids) == 0 { + return nil + } + docs, err := r.deps.Files.GetDocuments(ctx, ids) + if err != nil { + return nil + } + return documentsByID(docs) +} + +// featuredCoveredSet 用已解析的封面构造 covered set;无封面时回退 noCovered。 +func featuredCoveredSet(s domain.StickerSet, covers map[int64]domain.Document) tg.StickerSetCoveredClass { + out := make([]tg.DocumentClass, 0, featuredCoversPerSet) + for i, id := range s.DocumentIDs { + if i >= featuredCoversPerSet { + break + } + if doc, ok := covers[id]; ok { + out = append(out, tgDocument(doc)) + } + } + if len(out) == 0 { + return &tg.StickerSetNoCovered{Set: tgStickerSet(s)} + } + return &tg.StickerSetMultiCovered{Set: tgStickerSet(s), Covers: out} +} + func documentsByID(docs []domain.Document) map[int64]domain.Document { m := make(map[int64]domain.Document, len(docs)) for _, d := range docs { diff --git a/internal/rpc/stickers_emoji_index.go b/internal/rpc/stickers_emoji_index.go new file mode 100644 index 00000000..b58cd4d6 --- /dev/null +++ b/internal/rpc/stickers_emoji_index.go @@ -0,0 +1,148 @@ +package rpc + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// messages.getStickers(emoticon):按 emoji 返回匹配的贴纸。emoji→贴纸映射来自各 +// 贴纸集的 Packs(Emoticon→DocumentIDs)。为避免每次请求都 ListStickerSets + 遍历 +// packs,用一个进程级 TTL 缓存索引;命中 hash 时返回 *NotModified 不解析任何文档。 + +const ( + // emojiStickerIndexTTL:贴纸集 seed 后基本静态,TTL 内复用索引免重复 PG 查询。 + // 安装/归档贴纸集后索引最多滞后该时长(贴纸搜索可接受)。 + emojiStickerIndexTTL = 5 * time.Minute + // maxStickersPerEmoji 限制单个 emoji 返回的贴纸数。 + maxStickersPerEmoji = 100 +) + +// emojiStickerIndex 是 emoji→贴纸文档 id 的 TTL 缓存索引。 +type emojiStickerIndex struct { + mu sync.RWMutex + now func() time.Time + ttl time.Duration + builtAt time.Time + ready bool + byEmoji map[string][]int64 +} + +func newEmojiStickerIndex(now func() time.Time) *emojiStickerIndex { + return &emojiStickerIndex{now: now, ttl: emojiStickerIndexTTL, byEmoji: map[string][]int64{}} +} + +// lookup 返回某 emoji 的贴纸文档 id;索引未建或过期时用 build 重建。 +// perf:重建在锁外执行(build 读目录缓存,I/O 不在临界区),避免 TTL 过期点把所有 +// 并发请求堵在互斥锁上。并发 stale 请求可能各自 build 一次(目录缓存自身 singleflight +// 去重 PG,pack 遍历重复但廉价)。build 返回 nil(如目录读失败)时保留旧索引。 +func (idx *emojiStickerIndex) lookup(emoticon string, build func() map[string][]int64) []int64 { + idx.mu.RLock() + fresh := idx.ready && idx.now().Sub(idx.builtAt) < idx.ttl + if fresh { + out := append([]int64(nil), idx.byEmoji[emoticon]...) + idx.mu.RUnlock() + return out + } + idx.mu.RUnlock() + + next := build() // 锁外重建 + + idx.mu.Lock() + defer idx.mu.Unlock() + // 复查:可能已有并发者在锁外重建并先一步换入。 + stillStale := !idx.ready || idx.now().Sub(idx.builtAt) >= idx.ttl + if stillStale && next != nil { + idx.byEmoji = next + idx.builtAt = idx.now() + idx.ready = true + } + return append([]int64(nil), idx.byEmoji[emoticon]...) +} + +// normalizeStickerEmoticon 去掉变体选择符(U+FE0F/U+FE0E)并裁剪空白,使客户端发的 +// "👍️"(带 VS16)能匹配 pack 里的 "👍"。 +func normalizeStickerEmoticon(e string) string { + e = strings.ReplaceAll(e, "️", "") + e = strings.ReplaceAll(e, "︎", "") + return strings.TrimSpace(e) +} + +func (r *Router) onMessagesGetStickers(ctx context.Context, req *tg.MessagesGetStickersRequest) (tg.MessagesStickersClass, error) { + if req == nil || r.deps.Files == nil || r.emojiStickers == nil { + return &tg.MessagesStickers{Hash: 0, Stickers: []tg.DocumentClass{}}, nil + } + docIDs := r.emojiStickers.lookup(normalizeStickerEmoticon(req.Emoticon), func() map[string][]int64 { + return r.buildEmojiStickerIndex(ctx) + }) + if len(docIDs) > maxStickersPerEmoji { + docIDs = docIDs[:maxStickersPerEmoji] + } + catalogHash := int64(tdesktopCountHash(docIDs)) + if req.Hash != 0 && req.Hash == catalogHash { + // perf 短路:与客户端缓存一致,不解析任何文档。 + // 硬契约:仅在 hash!=0 时可返回 NotModified——DrKLO premium 预览贴纸预取发 + // hash=0 且无条件强转 TL_messages_stickers,对 notModified 会 ClassCastException + // 闪退;hash=0 一律走下方完整响应。勿"优化"成对 hash=0 也返回 NotModified。 + return &tg.MessagesStickersNotModified{}, nil + } + if len(docIDs) == 0 { + return &tg.MessagesStickers{Hash: catalogHash, Stickers: []tg.DocumentClass{}}, nil + } + docs, err := r.deps.Files.GetDocuments(ctx, docIDs) + if err != nil { + return nil, internalErr() + } + byID := documentsByID(docs) + ordered := make([]domain.Document, 0, len(docIDs)) + for _, id := range docIDs { // 保持索引顺序 + if d, ok := byID[id]; ok { + ordered = append(ordered, d) + } + } + return &tg.MessagesStickers{Hash: catalogHash, Stickers: tgDocuments(ordered)}, nil +} + +// buildEmojiStickerIndex 从所有(未归档)常规贴纸集的 Packs 构建 emoji→去重有序 docIDs。 +// 返回 nil 表示构建失败(保留旧索引)。 +func (r *Router) buildEmojiStickerIndex(ctx context.Context) map[string][]int64 { + // perf:从目录缓存读集(与 getAllStickers/featured 共用,TTL 内不打 PG)。 + sets := r.stickerCatalogSets(ctx, domain.StickerSetKindStickers) + if sets == nil { + return nil // 目录读失败:保留旧索引(nil=不替换) + } + byEmoji := make(map[string][]int64) + seen := make(map[string]map[int64]struct{}) + for _, s := range sets { + if s.Archived { + continue + } + for _, pack := range s.Packs { + key := normalizeStickerEmoticon(pack.Emoticon) + if key == "" { + continue + } + dedup := seen[key] + if dedup == nil { + dedup = make(map[int64]struct{}) + seen[key] = dedup + } + for _, id := range pack.DocumentIDs { + if id == 0 { + continue + } + if _, ok := dedup[id]; ok { + continue + } + dedup[id] = struct{}{} + byEmoji[key] = append(byEmoji[key], id) + } + } + } + return byEmoji +} diff --git a/internal/rpc/stickers_emoji_index_test.go b/internal/rpc/stickers_emoji_index_test.go new file mode 100644 index 00000000..b10f5550 --- /dev/null +++ b/internal/rpc/stickers_emoji_index_test.go @@ -0,0 +1,108 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" +) + +func emojiStickerRouter(t *testing.T) *Router { + t.Helper() + files := &fakeFiles{ + docs: map[int64]domain.Document{ + 201: {ID: 201, AccessHash: 1, MimeType: "application/x-tgsticker"}, + 202: {ID: 202, AccessHash: 2, MimeType: "application/x-tgsticker"}, + 301: {ID: 301, AccessHash: 3, MimeType: "application/x-tgsticker"}, + }, + sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindStickers: { + {ID: 10, Hash: 1, Packs: []domain.StickerPack{ + {Emoticon: "👍", DocumentIDs: []int64{201, 202}}, + {Emoticon: "🔥", DocumentIDs: []int64{301}}, + }}, + {ID: 12, Hash: 2, Archived: true, Packs: []domain.StickerPack{ + {Emoticon: "👍", DocumentIDs: []int64{999}}, // 归档集应被排除 + }}, + }, + }, + } + return New(Config{}, Deps{Files: files}, zaptest.NewLogger(t), clock.System) +} + +func stickerDocIDs(t *testing.T, res tg.MessagesStickersClass) []int64 { + t.Helper() + full, ok := res.(*tg.MessagesStickers) + if !ok { + t.Fatalf("res = %T, want *tg.MessagesStickers", res) + } + ids := make([]int64, 0, len(full.Stickers)) + for _, d := range full.Stickers { + if doc, ok := d.(*tg.Document); ok { + ids = append(ids, doc.ID) + } + } + return ids +} + +// TestMessagesGetStickersByEmoji 验证 emoji→贴纸索引检索 + 归档排除 + hash notModified +// + hash=0 永不返回 NotModified(崩溃安全契约)+ 变体选择符归一化。 +func TestMessagesGetStickersByEmoji(t *testing.T) { + r := emojiStickerRouter(t) + ctx := WithUserID(context.Background(), 1000000001) + + first, err := r.onMessagesGetStickers(ctx, &tg.MessagesGetStickersRequest{Emoticon: "👍"}) + if err != nil { + t.Fatalf("getStickers 👍: %v", err) + } + ids := stickerDocIDs(t, first) + if len(ids) != 2 || ids[0] != 201 || ids[1] != 202 { + t.Fatalf("👍 stickers = %v, want [201 202](归档集 999 排除)", ids) + } + hash := first.(*tg.MessagesStickers).Hash + if hash == 0 { + t.Fatal("hash must be non-zero for cache round-trips") + } + + // hash 命中 → NotModified。 + if again, err := r.onMessagesGetStickers(ctx, &tg.MessagesGetStickersRequest{Emoticon: "👍", Hash: hash}); err != nil { + t.Fatalf("getStickers 👍 hash: %v", err) + } else if _, ok := again.(*tg.MessagesStickersNotModified); !ok { + t.Fatalf("re-get with hash = %T, want NotModified", again) + } + + // 崩溃安全:hash=0 永远返回完整响应(DrKLO premium 预览强转,notModified 会闪退)。 + if zero, err := r.onMessagesGetStickers(ctx, &tg.MessagesGetStickersRequest{Emoticon: "👍", Hash: 0}); err != nil { + t.Fatalf("getStickers 👍 hash=0: %v", err) + } else if _, ok := zero.(*tg.MessagesStickers); !ok { + t.Fatalf("hash=0 = %T, want full *tg.MessagesStickers (never NotModified)", zero) + } + + // 另一个 emoji。 + if ids := stickerDocIDs(t, mustStickers(t, r, ctx, "🔥", 0)); len(ids) != 1 || ids[0] != 301 { + t.Fatalf("🔥 stickers = %v, want [301]", ids) + } + + // 变体选择符归一化:👍 + VS16 仍匹配。 + if ids := stickerDocIDs(t, mustStickers(t, r, ctx, "👍️", 0)); len(ids) != 2 { + t.Fatalf("👍+VS16 stickers = %v, want 2 (variation selector 归一化)", ids) + } + + // 未知 emoji → 空。 + if ids := stickerDocIDs(t, mustStickers(t, r, ctx, "🦄", 0)); len(ids) != 0 { + t.Fatalf("unknown emoji stickers = %v, want empty", ids) + } +} + +func mustStickers(t *testing.T, r *Router, ctx context.Context, emoticon string, hash int64) tg.MessagesStickersClass { + t.Helper() + res, err := r.onMessagesGetStickers(ctx, &tg.MessagesGetStickersRequest{Emoticon: emoticon, Hash: hash}) + if err != nil { + t.Fatalf("getStickers %q: %v", emoticon, err) + } + return res +} diff --git a/internal/rpc/stickers_test.go b/internal/rpc/stickers_test.go index 0cf53245..a779e0c2 100644 --- a/internal/rpc/stickers_test.go +++ b/internal/rpc/stickers_test.go @@ -4,7 +4,9 @@ import ( "context" "testing" + "github.com/gotd/td/clock" "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" "telesrv/internal/domain" ) @@ -74,6 +76,297 @@ func TestMessagesGetAllStickersUsesTDesktopHashForNotModified(t *testing.T) { } } +func TestAccountGetDefaultEmojiStatusesServesSynthesizedSet(t *testing.T) { + ctx := context.Background() + files := &fakeFiles{ + docs: map[int64]domain.Document{ + 101: {ID: 101, AccessHash: 1}, + 102: {ID: 102, AccessHash: 2}, + }, + sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindSystem: { + { + ID: 77, + ShortName: "TelesrvDefaultStatuses", + Kind: domain.StickerSetKindSystem, + SystemKey: domain.StickerSetSystemKeyEmojiDefaultStatuses, + Emojis: true, + Count: 2, + Hash: 12345, + DocumentIDs: []int64{101, 102}, + }, + }, + }, + } + r := &Router{deps: Deps{Files: files}} + + first, err := r.onAccountGetDefaultEmojiStatuses(ctx, 0) + if err != nil { + t.Fatalf("first getDefaultEmojiStatuses: %v", err) + } + full, ok := first.(*tg.AccountEmojiStatuses) + if !ok { + t.Fatalf("first getDefaultEmojiStatuses = %T, want *tg.AccountEmojiStatuses", first) + } + if len(full.Statuses) != 2 { + t.Fatalf("statuses = %d, want 2", len(full.Statuses)) + } + status, ok := full.Statuses[0].(*tg.EmojiStatus) + if !ok || status.DocumentID != 101 { + t.Fatalf("statuses[0] = %#v, want EmojiStatus document 101", full.Statuses[0]) + } + if full.Hash == 0 { + t.Fatal("hash must be non-zero for cache round-trips") + } + + second, err := r.onAccountGetDefaultEmojiStatuses(ctx, full.Hash) + if err != nil { + t.Fatalf("second getDefaultEmojiStatuses: %v", err) + } + if _, ok := second.(*tg.AccountEmojiStatusesNotModified); !ok { + t.Fatalf("second getDefaultEmojiStatuses = %T, want notModified", second) + } + + // getStickerSet(inputStickerSetEmojiDefaultStatuses) 走同一系统集。 + resolved, err := r.onMessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetEmojiDefaultStatuses{}, + }) + if err != nil { + t.Fatalf("getStickerSet default statuses: %v", err) + } + setFull, ok := resolved.(*tg.MessagesStickerSet) + if !ok { + t.Fatalf("getStickerSet = %T, want *tg.MessagesStickerSet", resolved) + } + if setFull.Set.ID != 77 || len(setFull.Documents) != 2 { + t.Fatalf("resolved set = %d with %d docs, want 77 with 2", setFull.Set.ID, len(setFull.Documents)) + } +} + +func TestAccountGetDefaultEmojiStatusesFallsBackWhenUnseeded(t *testing.T) { + ctx := context.Background() + r := &Router{deps: Deps{Files: &fakeFiles{}}} + res, err := r.onAccountGetDefaultEmojiStatuses(ctx, 0) + if err != nil { + t.Fatalf("getDefaultEmojiStatuses without seed: %v", err) + } + if _, ok := res.(*tg.AccountEmojiStatusesNotModified); !ok { + t.Fatalf("unseeded result = %T, want compat notModified stub", res) + } +} + +func TestMessagesGetStickerSetAndroidPlaceholderUsesSeededSet(t *testing.T) { + ctx := context.Background() + docs := make(map[int64]domain.Document) + documentIDs := make([]int64, 0, androidPlaceholderStickerMinDocuments) + for i := 0; i < androidPlaceholderStickerMinDocuments; i++ { + id := int64(100 + i) + documentIDs = append(documentIDs, id) + docs[id] = domain.Document{ID: id, AccessHash: id + 1000, DCID: 2} + } + files := &fakeFiles{ + docs: docs, + sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindSystem: { + { + ID: 77, + AccessHash: 7700, + ShortName: "AnimatedEmojies", + Title: "Animated Emoji", + Kind: domain.StickerSetKindSystem, + SystemKey: "animated_emoji", + Emojis: true, + Count: len(documentIDs), + Hash: 12345, + DocumentIDs: documentIDs, + }, + }, + }, + } + r := &Router{deps: Deps{Files: files}} + + res, err := r.onMessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "tg_placeholders_android"}, + }) + if err != nil { + t.Fatalf("getStickerSet placeholder: %v", err) + } + full, ok := res.(*tg.MessagesStickerSet) + if !ok { + t.Fatalf("getStickerSet placeholder = %T, want *tg.MessagesStickerSet", res) + } + if full.Set.ID != 77 { + t.Fatalf("placeholder fallback set id = %d, want 77", full.Set.ID) + } + if len(full.Documents) < androidPlaceholderStickerMinDocuments { + t.Fatalf("placeholder fallback documents = %d, want >= %d", len(full.Documents), androidPlaceholderStickerMinDocuments) + } +} + +func TestMessagesGetStickerSetAndroidPlaceholderFallsBackToEmptyWithoutSeed(t *testing.T) { + ctx := context.Background() + r := &Router{deps: Deps{Files: &fakeFiles{}}} + res, err := r.onMessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{ + Stickerset: &tg.InputStickerSetShortName{ShortName: "tg_placeholders_android"}, + }) + if err != nil { + t.Fatalf("getStickerSet placeholder without seed: %v", err) + } + full, ok := res.(*tg.MessagesStickerSet) + if !ok { + t.Fatalf("getStickerSet placeholder without seed = %T, want *tg.MessagesStickerSet", res) + } + if len(full.Documents) != 0 { + t.Fatalf("placeholder without seed documents = %d, want empty compat set", len(full.Documents)) + } +} + +func TestMessagesGetMaskStickersUsesMaskCatalog(t *testing.T) { + ctx := context.Background() + files := &fakeFiles{ + sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindStickers: { + {ID: 10, AccessHash: 100, ShortName: "regular", Title: "Regular", Count: 1, Hash: 123, Installed: true}, + }, + domain.StickerSetKindMasks: { + {ID: 20, AccessHash: 200, ShortName: "masks", Title: "Masks", Count: 1, Hash: 789, Installed: true}, + }, + }, + } + r := &Router{deps: Deps{Files: files}} + + first, err := r.onMessagesGetMaskStickers(ctx, 0) + if err != nil { + t.Fatalf("first getMaskStickers: %v", err) + } + full, ok := first.(*tg.MessagesAllStickers) + if !ok { + t.Fatalf("first getMaskStickers = %T, want *tg.MessagesAllStickers", first) + } + if len(full.Sets) != 1 || full.Sets[0].ID != 20 { + t.Fatalf("mask sets = %+v, want only mask set 20", full.Sets) + } + + second, err := r.onMessagesGetMaskStickers(ctx, full.Hash) + if err != nil { + t.Fatalf("second getMaskStickers: %v", err) + } + if _, ok := second.(*tg.MessagesAllStickersNotModified); !ok { + t.Fatalf("second getMaskStickers = %T, want *tg.MessagesAllStickersNotModified", second) + } +} + +func TestMessagesGetFeaturedStickersSurfacesSeededSets(t *testing.T) { + ctx := context.Background() + files := &fakeFiles{ + docs: map[int64]domain.Document{ + 201: {ID: 201, AccessHash: 1, MimeType: "application/x-tgsticker"}, + 202: {ID: 202, AccessHash: 2, MimeType: "application/x-tgsticker"}, + 301: {ID: 301, AccessHash: 3, MimeType: "application/x-tgsticker"}, + }, + sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindStickers: { + {ID: 10, AccessHash: 100, ShortName: "one", Title: "One", Count: 2, Hash: 123, DocumentIDs: []int64{201, 202}}, + {ID: 11, AccessHash: 110, ShortName: "two", Title: "Two", Count: 1, Hash: 456, DocumentIDs: []int64{301}}, + {ID: 12, AccessHash: 120, ShortName: "arch", Title: "Arch", Hash: 789, Archived: true, DocumentIDs: []int64{301}}, + }, + domain.StickerSetKindEmoji: { + {ID: 20, AccessHash: 200, ShortName: "emo", Title: "Emo", Count: 1, Hash: 999, Emojis: true, DocumentIDs: []int64{201}}, + }, + }, + } + r := &Router{deps: Deps{Files: files}} + + first, err := r.onMessagesGetFeaturedStickers(ctx, 0) + if err != nil { + t.Fatalf("first getFeaturedStickers: %v", err) + } + full, ok := first.(*tg.MessagesFeaturedStickers) + if !ok { + t.Fatalf("first getFeaturedStickers = %T, want *tg.MessagesFeaturedStickers", first) + } + if full.Count != 2 || len(full.Sets) != 2 { + t.Fatalf("featured = count %d / %d sets, want 2/2 (archived excluded)", full.Count, len(full.Sets)) + } + covered, ok := full.Sets[0].(*tg.StickerSetMultiCovered) + if !ok { + t.Fatalf("featured set[0] = %T, want *tg.StickerSetMultiCovered", full.Sets[0]) + } + if covered.Set.ID != 10 || len(covered.Covers) != 2 { + t.Fatalf("featured set[0] = id %d with %d covers, want 10 with 2", covered.Set.ID, len(covered.Covers)) + } + if full.Hash == 0 { + t.Fatal("featured hash must be non-zero for cache round-trips") + } + + second, err := r.onMessagesGetFeaturedStickers(ctx, full.Hash) + if err != nil { + t.Fatalf("second getFeaturedStickers: %v", err) + } + if _, ok := second.(*tg.MessagesFeaturedStickersNotModified); !ok { + t.Fatalf("second getFeaturedStickers = %T, want NotModified", second) + } + + // emoji featured 走 emoji 集。 + emoji, err := r.onMessagesGetFeaturedEmojiStickers(ctx, 0) + if err != nil { + t.Fatalf("getFeaturedEmojiStickers: %v", err) + } + emojiFull, ok := emoji.(*tg.MessagesFeaturedStickers) + if !ok || emojiFull.Count != 1 { + t.Fatalf("featured emoji = %T count %v, want 1 emoji set", emoji, emojiFull) + } +} + +// countingStickerFiles 包 *fakeFiles 计数 ListStickerSets,验证目录缓存短路。 +type countingStickerFiles struct { + *fakeFiles + listCalls int +} + +func (f *countingStickerFiles) ListStickerSets(ctx context.Context, kind domain.StickerSetKind) ([]domain.StickerSet, error) { + f.listCalls++ + return f.fakeFiles.ListStickerSets(ctx, kind) +} + +// TestStickerCatalogCacheShortCircuitsListStickerSets 回归 P2-5:getAllStickers/ +// getFeaturedStickers 经目录缓存——同 kind 重复请求只 ListStickerSets 一次(TTL 内 0 PG)。 +func TestStickerCatalogCacheShortCircuitsListStickerSets(t *testing.T) { + ctx := context.Background() + files := &countingStickerFiles{fakeFiles: &fakeFiles{ + docs: map[int64]domain.Document{201: {ID: 201, AccessHash: 1}}, + sets: map[domain.StickerSetKind][]domain.StickerSet{ + domain.StickerSetKindStickers: {{ID: 10, AccessHash: 100, ShortName: "one", Count: 1, Hash: 123, DocumentIDs: []int64{201}}}, + }, + }} + r := New(Config{}, Deps{Files: files}, zaptest.NewLogger(t), clock.System) + + for i := 0; i < 3; i++ { + if _, err := r.onMessagesGetAllStickers(ctx, 0); err != nil { + t.Fatalf("getAllStickers %d: %v", i, err) + } + } + if _, err := r.onMessagesGetFeaturedStickers(ctx, 0); err != nil { + t.Fatalf("getFeaturedStickers: %v", err) + } + if files.listCalls != 1 { + t.Fatalf("ListStickerSets(stickers) calls = %d, want 1 (目录缓存应短路)", files.listCalls) + } +} + +func TestMessagesGetFeaturedStickersEmptyWithoutSeed(t *testing.T) { + ctx := context.Background() + r := &Router{deps: Deps{Files: &fakeFiles{}}} + res, err := r.onMessagesGetFeaturedStickers(ctx, 0) + if err != nil { + t.Fatalf("getFeaturedStickers without seed: %v", err) + } + full, ok := res.(*tg.MessagesFeaturedStickers) + if !ok || full.Count != 0 || len(full.Sets) != 0 { + t.Fatalf("unseeded featured = %#v, want empty", res) + } +} + func TestMessagesGetAvailableReactionsNotModified(t *testing.T) { ctx := context.Background() reactions := []domain.AvailableReaction{ diff --git a/internal/rpc/stories.go b/internal/rpc/stories.go index 12259d79..c4196e04 100644 --- a/internal/rpc/stories.go +++ b/internal/rpc/stories.go @@ -2,6 +2,13 @@ package rpc import ( "context" + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" + "unicode/utf8" "github.com/gotd/td/tg" @@ -9,33 +16,653 @@ import ( "telesrv/internal/domain" ) -// registerStories 注册第一阶段 TDesktop 启动所需 stories.* RPC 兼容响应。 +const ( + maxStoryAlbumTitleLength = 12 + maxStoryAllStoriesStateLength = 128 + maxStorySearchPostsOffsetLength = 64 + minStoryWeatherTemperatureC = -274 + maxStoryWeatherTemperatureC = 1000000 + storyStealthFuturePeriodSeconds = 25 * 60 + storyStealthCooldownSeconds = 3 * 60 * 60 +) + +// registerStories 注册 TDesktop/Android 已发现的 stories.* RPC。 func (r *Router) registerStories(d *tg.ServerDispatcher) { - d.OnStoriesGetAllStories(func(ctx context.Context, req *tg.StoriesGetAllStoriesRequest) (tg.StoriesAllStoriesClass, error) { - return tdesktop.AllStories(), nil - }) - d.OnStoriesGetStoriesArchive(func(ctx context.Context, req *tg.StoriesGetStoriesArchiveRequest) (*tg.StoriesStories, error) { - return tdesktop.StoriesArchive(), nil - }) - d.OnStoriesGetPinnedStories(func(ctx context.Context, req *tg.StoriesGetPinnedStoriesRequest) (*tg.StoriesStories, error) { - return tdesktop.PinnedStories(), nil - }) - d.OnStoriesGetAlbums(func(ctx context.Context, req *tg.StoriesGetAlbumsRequest) (tg.StoriesAlbumsClass, error) { - return tdesktop.StoryAlbums(), nil - }) - d.OnStoriesGetAllReadPeerStories(func(ctx context.Context) (tg.UpdatesClass, error) { - return tgEmptyUpdates(int(r.clock.Now().Unix())), nil - }) - d.OnStoriesGetPeerMaxIDs(func(ctx context.Context, id []tg.InputPeerClass) ([]tg.RecentStory, error) { - out := make([]tg.RecentStory, len(id)) - return out, nil - }) + d.OnStoriesGetAllStories(r.onStoriesGetAllStories) + d.OnStoriesGetPeerStories(r.onStoriesGetPeerStories) + d.OnStoriesGetStoriesByID(r.onStoriesGetStoriesByID) + d.OnStoriesGetStoriesArchive(r.onStoriesGetStoriesArchive) + d.OnStoriesGetPinnedStories(r.onStoriesGetPinnedStories) + d.OnStoriesExportStoryLink(r.onStoriesExportStoryLink) + d.OnStoriesReport(r.onStoriesReport) + d.OnStoriesActivateStealthMode(r.onStoriesActivateStealthMode) + d.OnStoriesSearchPosts(r.onStoriesSearchPosts) + d.OnStoriesSendStory(r.onStoriesSendStory) + d.OnStoriesEditStory(r.onStoriesEditStory) + d.OnStoriesDeleteStories(r.onStoriesDeleteStories) + d.OnStoriesTogglePinned(r.onStoriesTogglePinned) + d.OnStoriesTogglePinnedToTop(r.onStoriesTogglePinnedToTop) + d.OnStoriesToggleAllStoriesHidden(r.onStoriesToggleAllStoriesHidden) + d.OnStoriesCreateAlbum(r.onStoriesCreateAlbum) + d.OnStoriesUpdateAlbum(r.onStoriesUpdateAlbum) + d.OnStoriesReorderAlbums(r.onStoriesReorderAlbums) + d.OnStoriesDeleteAlbum(r.onStoriesDeleteAlbum) + d.OnStoriesGetAlbums(r.onStoriesGetAlbums) + d.OnStoriesGetAlbumStories(r.onStoriesGetAlbumStories) + d.OnStoriesGetAllReadPeerStories(r.onStoriesGetAllReadPeerStories) + d.OnStoriesGetPeerMaxIDs(r.onStoriesGetPeerMaxIDs) + d.OnStoriesReadStories(r.onStoriesReadStories) + d.OnStoriesIncrementStoryViews(r.onStoriesIncrementStoryViews) + d.OnStoriesGetStoriesViews(r.onStoriesGetStoriesViews) + d.OnStoriesGetStoryViewsList(r.onStoriesGetStoryViewsList) + d.OnStoriesGetStoryReactionsList(r.onStoriesGetStoryReactionsList) + d.OnStoriesTogglePeerStoriesHidden(r.onStoriesTogglePeerStoriesHidden) + d.OnStoriesCanSendStory(r.onStoriesCanSendStory) + d.OnStoriesGetChatsToSend(r.onStoriesGetChatsToSend) d.OnStoriesSendReaction(r.onStoriesSendReaction) + d.OnStoriesStartLive(r.onStoriesStartLive) } -func (r *Router) onStoriesSendReaction(ctx context.Context, req *tg.StoriesSendReactionRequest) (tg.UpdatesClass, error) { - if req.StoryID <= 0 || req.StoryID > domain.MaxMessageBoxID { - return nil, storyIDInvalidErr() +func (r *Router) onStoriesGetAllStories(ctx context.Context, req *tg.StoriesGetAllStoriesRequest) (tg.StoriesAllStoriesClass, error) { + if err := validateStoriesGetAllStoriesRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + hidden := storyAllStoriesHidden(req) + requestState, hasRequestState := storyAllStoriesRequestState(req) + next := storyAllStoriesNext(req) + now := int(r.clock.Now().Unix()) + var cursor domain.StoryListCursor + if next { + cursor, err = storyAllStoriesCursorFromState(requestState, hidden) + if err != nil { + return nil, offsetInvalidErr() + } + } + if r.deps.Stories == nil || userID == 0 { + list := domain.StoryList{Hidden: hidden, State: storyAllStoriesDigestState(hidden, domain.StoryListDigest{})} + if hasRequestState && !next && requestState == list.State { + return tgStoriesAllStoriesNotModified(list.State), nil + } + return tgStoriesAllStories(userID, list), nil + } + var ( + completeState string + haveDigest bool + ) + if hasRequestState && !next && storyAllStoriesCompleteState(requestState) { + digest, err := r.deps.Stories.GetAllStoriesDigest(ctx, userID, hidden, now) + if err != nil { + return nil, storyErr(err) + } + completeState = storyAllStoriesDigestState(hidden, digest) + haveDigest = true + if requestState == completeState { + return tgStoriesAllStoriesNotModified(completeState), nil + } + } + list, err := r.deps.Stories.GetAllStoriesPage(ctx, userID, hidden, now, cursor, domain.MaxStoryListLimit) + if err != nil { + return nil, storyErr(err) + } + if list.HasMore { + list.State = storyAllStoriesCursorStateFromList(hidden, list) + } else { + if !haveDigest { + digest, err := r.deps.Stories.GetAllStoriesDigest(ctx, userID, hidden, now) + if err != nil { + return nil, storyErr(err) + } + completeState = storyAllStoriesDigestState(hidden, digest) + } + list.State = completeState + } + list.Hidden = hidden + return r.tgStoriesAllStories(ctx, userID, list), nil +} + +func validateStoriesGetAllStoriesRequest(req *tg.StoriesGetAllStoriesRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + state, hasState := storyAllStoriesRequestState(req) + if len(state) > maxStoryAllStoriesStateLength { + return offsetInvalidErr() + } + next := storyAllStoriesNext(req) + hidden := storyAllStoriesHidden(req) + if next { + if !hasState || state == "" || !storyAllStoriesCursorStateToken(state) { + return offsetInvalidErr() + } + if _, err := storyAllStoriesCursorFromState(state, hidden); err != nil { + return offsetInvalidErr() + } + return nil + } + if !hasState { + return nil + } + if state == "" || !storyAllStoriesCompleteStateForHidden(state, hidden) { + return offsetInvalidErr() + } + return nil +} + +func storyAllStoriesRequestState(req *tg.StoriesGetAllStoriesRequest) (string, bool) { + state, hasState := req.GetState() + if !hasState && req.State != "" { + state, hasState = req.State, true + } + return state, hasState +} + +func storyAllStoriesNext(req *tg.StoriesGetAllStoriesRequest) bool { + return req != nil && (req.Next || req.GetNext()) +} + +func storyAllStoriesHidden(req *tg.StoriesGetAllStoriesRequest) bool { + return req != nil && (req.Hidden || req.GetHidden()) +} + +func storyAllStoriesCompleteState(state string) bool { + return strings.HasPrefix(state, "ts1:") +} + +func storyAllStoriesCompleteStateForHidden(state string, hidden bool) bool { + parts := strings.Split(state, ":") + if len(parts) != 4 || parts[0] != "ts1" { + return false + } + switch parts[1] { + case "0": + if hidden { + return false + } + case "1": + if !hidden { + return false + } + default: + return false + } + count, err := strconv.Atoi(parts[2]) + if err != nil || count < 0 { + return false + } + if len(parts[3]) != 16 { + return false + } + _, err = strconv.ParseUint(parts[3], 16, 64) + return err == nil +} + +func storyAllStoriesDigestState(hidden bool, digest domain.StoryListDigest) string { + hiddenBit := "0" + if hidden { + hiddenBit = "1" + } + return fmt.Sprintf("ts1:%s:%d:%016x", hiddenBit, digest.Count, digest.Hash) +} + +func storyAllStoriesCursorStateFromList(hidden bool, list domain.StoryList) string { + if cursor, ok := storyAllStoriesCursorFromList(list); ok { + return storyAllStoriesCursorState(hidden, cursor) + } + return storyAllStoriesDigestState(hidden, domain.DigestStoryPeerList(list.Peers)) +} + +func storyAllStoriesCursorFromList(list domain.StoryList) (domain.StoryListCursor, bool) { + if len(list.Peers) == 0 { + return domain.StoryListCursor{}, false + } + peer := list.Peers[len(list.Peers)-1] + maxDate := 0 + for _, story := range peer.Stories { + if story.Date > maxDate { + maxDate = story.Date + } + } + if maxDate <= 0 { + return domain.StoryListCursor{}, false + } + return domain.StoryListCursor{Set: true, Date: maxDate, Peer: peer.Peer}, true +} + +func storyAllStoriesCursorState(hidden bool, cursor domain.StoryListCursor) string { + hiddenBit := "0" + if hidden { + hiddenBit = "1" + } + return fmt.Sprintf("tsc1:%s:%d:%s:%d", hiddenBit, cursor.Date, cursor.Peer.Type, cursor.Peer.ID) +} + +func storyAllStoriesCursorStateToken(state string) bool { + return strings.HasPrefix(state, "tsc1:") +} + +func storyAllStoriesCursorFromState(state string, hidden bool) (domain.StoryListCursor, error) { + parts := strings.Split(state, ":") + if len(parts) != 5 || parts[0] != "tsc1" { + return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid + } + switch parts[1] { + case "0": + if hidden { + return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid + } + case "1": + if !hidden { + return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid + } + default: + return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid + } + date, err := strconv.Atoi(parts[2]) + if err != nil || date <= 0 { + return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid + } + peer := domain.Peer{Type: domain.PeerType(parts[3])} + switch peer.Type { + case domain.PeerTypeUser, domain.PeerTypeChannel: + default: + return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid + } + peerID, err := strconv.ParseInt(parts[4], 10, 64) + if err != nil || peerID <= 0 { + return domain.StoryListCursor{}, domain.ErrStoryOffsetInvalid + } + peer.ID = peerID + return domain.StoryListCursor{Set: true, Date: date, Peer: peer}, nil +} + +func (r *Router) onStoriesGetPeerStories(ctx context.Context, peer tg.InputPeerClass) (*tg.StoriesPeerStories, error) { + if err := validateStoriesDirectInputPeer(peer); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + domainPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return nil, err + } + if r.deps.Stories == nil || userID == 0 { + return r.tgStoriesPeerStories(ctx, userID, domain.PeerStories{Peer: domainPeer}), nil + } + stories, err := r.deps.Stories.GetPeerStories(ctx, userID, domainPeer, int(r.clock.Now().Unix())) + if err != nil { + return nil, storyErr(err) + } + return r.tgStoriesPeerStories(ctx, userID, stories), nil +} + +func (r *Router) onStoriesGetStoriesByID(ctx context.Context, req *tg.StoriesGetStoriesByIDRequest) (*tg.StoriesStories, error) { + if err := validateStoriesGetStoriesByIDRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Stories == nil || userID == 0 { + return &tg.StoriesStories{}, nil + } + list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, uniqueStoryIDs(req.ID), int(r.clock.Now().Unix())) + if err != nil { + return nil, storyErr(err) + } + return r.tgStoriesStories(ctx, userID, list), nil +} + +func validateStoriesGetStoriesByIDRequest(req *tg.StoriesGetStoriesByIDRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if len(req.ID) == 0 { + return storyIDEmptyErr() + } + if err := validateStoryIDSlice(req.ID); err != nil { + return err + } + return nil +} + +func (r *Router) onStoriesGetChatsToSend(ctx context.Context) (tg.MessagesChatsClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Channels == nil || userID == 0 { + return r.applyStoryMaxIDsToChats(ctx, userID, &tg.MessagesChats{Chats: []tg.ChatClass{}}), nil + } + channels, err := r.deps.Channels.ListStoryPostableChannels(ctx, userID) + if err != nil { + return nil, internalErr() + } + ids := make([]int64, 0, len(channels)) + for _, channel := range channels { + if channel.ID != 0 { + ids = append(ids, channel.ID) + } + } + views, err := r.deps.Channels.GetChannels(ctx, userID, ids) + if err != nil { + return nil, internalErr() + } + chats := make([]tg.ChatClass, 0, len(views)) + for _, view := range views { + if view.Forbidden { + continue + } + chats = append(chats, tgChannelChatForView(userID, view)) + } + return r.applyStoryMaxIDsToChats(ctx, userID, &tg.MessagesChats{Chats: chats}), nil +} + +func (r *Router) onStoriesGetStoriesArchive(ctx context.Context, req *tg.StoriesGetStoriesArchiveRequest) (*tg.StoriesStories, error) { + if err := validateStoriesGetStoriesArchiveRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Stories == nil || userID == 0 { + return tdesktop.StoriesArchive(), nil + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + list, err := r.deps.Stories.GetStoriesArchive(ctx, userID, peer, req.OffsetID, req.Limit, int(r.clock.Now().Unix())) + if err != nil { + return nil, storyErr(err) + } + return r.tgStoriesStories(ctx, userID, list), nil +} + +func validateStoriesGetStoriesArchiveRequest(req *tg.StoriesGetStoriesArchiveRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if err := validateStoryPageBounds(req.OffsetID, req.Limit); err != nil { + return err + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesGetPinnedStories(ctx context.Context, req *tg.StoriesGetPinnedStoriesRequest) (*tg.StoriesStories, error) { + if err := validateStoriesGetPinnedStoriesRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if r.deps.Stories == nil || userID == 0 { + return tdesktop.PinnedStories(), nil + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + list, err := r.cachedPinnedStories(ctx, userID, peer, req.OffsetID, req.Limit) + if err != nil { + return nil, storyErr(err) + } + return r.tgStoriesStories(ctx, userID, list), nil +} + +func (r *Router) cachedPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit int) (domain.StoryList, error) { + if r.storyPinnedListCache == nil { + return r.deps.Stories.GetPinnedStories(ctx, viewerUserID, peer, offsetID, limit, int(r.clock.Now().Unix())) + } + key := storyPinnedStoriesKey(viewerUserID, peer, offsetID, limit) + return r.storyPinnedListCache.getOrLoad(ctx, key, func() (domain.StoryList, error) { + return r.deps.Stories.GetPinnedStories(ctx, viewerUserID, peer, key.offsetID, key.limit, int(r.clock.Now().Unix())) + }) +} + +func validateStoriesGetPinnedStoriesRequest(req *tg.StoriesGetPinnedStoriesRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if err := validateStoryPageBounds(req.OffsetID, req.Limit); err != nil { + return err + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesExportStoryLink(ctx context.Context, req *tg.StoriesExportStoryLinkRequest) (*tg.ExportedStoryLink, error) { + if err := validateStoriesExportStoryLinkRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Stories != nil && userID != 0 { + list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, []int{req.ID}, int(r.clock.Now().Unix())) + if err != nil { + return nil, storyErr(err) + } + if len(list.Stories) == 0 { + return nil, storyIDInvalidErr() + } + } + return &tg.ExportedStoryLink{Link: storyExportLink(peer, req.ID)}, nil +} + +func validateStoriesExportStoryLinkRequest(req *tg.StoriesExportStoryLinkRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if req.ID <= 0 || req.ID > domain.MaxStoryID { + return storyIDInvalidErr() + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesReport(ctx context.Context, req *tg.StoriesReportRequest) (tg.ReportResultClass, error) { + if err := validateStoriesReportRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Stories != nil && userID != 0 { + ids := uniqueStoryIDs(req.ID) + list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, ids, int(r.clock.Now().Unix())) + if err != nil { + return nil, storyErr(err) + } + if len(list.Stories) != len(ids) { + return nil, storyIDInvalidErr() + } + } + return reportResultForOption(string(req.Option)) +} + +func validateStoriesReportRequest(req *tg.StoriesReportRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if len(req.ID) == 0 { + return storyIDEmptyErr() + } + if len(req.ID) > domain.MaxStoryIDs || len(req.Option) > maxReportOptionLength || utf8.RuneCountInString(req.Message) > maxReportCommentLength { + return limitInvalidErr() + } + if err := validateStoryIDSlice(req.ID); err != nil { + return err + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesActivateStealthMode(ctx context.Context, req *tg.StoriesActivateStealthModeRequest) (tg.UpdatesClass, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + past, future := storyStealthPastRequested(req), storyStealthFutureRequested(req) + if !past && !future { + return nil, inputRequestInvalidErr() + } + if _, _, err := r.currentUserID(ctx); err != nil { + return nil, internalErr() + } + return tgStoryStealthModeUpdates(int(r.clock.Now().Unix()), past, future), nil +} + +func storyStealthPastRequested(req *tg.StoriesActivateStealthModeRequest) bool { + return req != nil && (req.Past || req.GetPast()) +} + +func storyStealthFutureRequested(req *tg.StoriesActivateStealthModeRequest) bool { + return req != nil && (req.Future || req.GetFuture()) +} + +func (r *Router) onStoriesSearchPosts(ctx context.Context, req *tg.StoriesSearchPostsRequest) (*tg.StoriesFoundStories, error) { + if err := validateStoriesSearchPostsRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if peer, ok := req.GetPeer(); ok { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil { + return nil, err + } + } + return &tg.StoriesFoundStories{ + Stories: []tg.FoundStory{}, + Chats: []tg.ChatClass{}, + Users: []tg.UserClass{}, + }, nil +} + +func validateStoriesSearchPostsRequest(req *tg.StoriesSearchPostsRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if req.Limit < 0 || req.Limit > domain.MaxStoryListLimit { + return limitInvalidErr() + } + if len(req.Offset) > maxStorySearchPostsOffsetLength { + return offsetInvalidErr() + } + hashtag, hasHashtag := storySearchPostsHashtag(req) + area, hasArea := storySearchPostsArea(req) + if hasHashtag == hasArea { + return searchQueryEmptyErr() + } + if hasHashtag { + hashtag, err := normalizeStorySearchHashtag(hashtag) + if err != nil { + return err + } + if hashtag == "" { + return searchQueryEmptyErr() + } + if utf8.RuneCountInString(hashtag) > maxChannelSearchPostsQuery { + return limitInvalidErr() + } + return validateStoriesSearchPostsPeer(req) + } + if err := validateStorySearchArea(area); err != nil { + return err + } + return validateStoriesSearchPostsPeer(req) +} + +func validateStoriesSearchPostsPeer(req *tg.StoriesSearchPostsRequest) error { + if peer, ok := req.GetPeer(); ok { + return validateStoriesDirectInputPeer(peer) + } + return nil +} + +func storySearchPostsHashtag(req *tg.StoriesSearchPostsRequest) (string, bool) { + hashtag, hasHashtag := req.GetHashtag() + if !hasHashtag && req.Hashtag != "" { + hashtag, hasHashtag = req.Hashtag, true + } + return hashtag, hasHashtag +} + +func storySearchPostsArea(req *tg.StoriesSearchPostsRequest) (tg.MediaAreaClass, bool) { + area, hasArea := req.GetArea() + if !hasArea && req.Area != nil { + area, hasArea = req.Area, true + } + return area, hasArea +} + +func normalizeStorySearchHashtag(hashtag string) (string, error) { + hashtag = strings.TrimSpace(hashtag) + if hashtag == "" { + return "", nil + } + if strings.HasPrefix(hashtag, "#") || strings.HasPrefix(hashtag, "$") { + hashtag = strings.TrimSpace(hashtag[1:]) + } + if hashtag == "" { + return "", nil + } + if strings.ContainsAny(hashtag, "#$") { + return "", limitInvalidErr() + } + return hashtag, nil +} + +func validateStorySearchArea(area tg.MediaAreaClass) error { + if storyMediaAreaClassNil(area) { + return mediaInvalidErr() + } + switch typed := area.(type) { + case *tg.MediaAreaGeoPoint: + if _, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates); err != nil { + return err + } + if _, err := domainStoryGeoPointFromTL(typed.Geo); err != nil { + return err + } + if _, err := domainStoryGeoPointAddressFromTL(typed); err != nil { + return err + } + return nil + case *tg.MediaAreaVenue: + if _, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates); err != nil { + return err + } + _, err := domainStoryVenueFromTL(typed) + return err + default: + return mediaInvalidErr() + } +} + +func (r *Router) onStoriesGetAlbums(ctx context.Context, req *tg.StoriesGetAlbumsRequest) (tg.StoriesAlbumsClass, error) { + if err := validateStoriesGetAlbumsRequest(req); err != nil { + return nil, err } userID, _, err := r.currentUserID(ctx) if err != nil { @@ -44,13 +671,2610 @@ func (r *Router) onStoriesSendReaction(ctx context.Context, req *tg.StoriesSendR if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { return nil, err } - if err := validateReactionClass(req.Reaction); err != nil { + return tdesktop.StoryAlbums(), nil +} + +func validateStoriesGetAlbumsRequest(req *tg.StoriesGetAlbumsRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesGetAlbumStories(ctx context.Context, req *tg.StoriesGetAlbumStoriesRequest) (*tg.StoriesStories, error) { + if err := validateStoriesGetAlbumStoriesRequest(req); err != nil { return nil, err } - if parsed, err := domainMessageReactionFromTL(req.Reaction); err == nil { - if err := r.recordMessageReactionUse(ctx, userID, []domain.MessageReaction{parsed}, req.GetAddToRecent(), int(r.clock.Now().Unix())); err != nil { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + return r.tgStoriesStories(ctx, userID, domain.StoryList{}), nil +} + +func validateStoriesGetAlbumStoriesRequest(req *tg.StoriesGetAlbumStoriesRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if err := validateStoryAlbumID(req.AlbumID); err != nil { + return err + } + if req.Offset < 0 || req.Offset > domain.MaxStoryAlbumOffset { + return offsetInvalidErr() + } + if req.Limit < 0 || req.Limit > domain.MaxStoryListLimit { + return limitInvalidErr() + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesToggleAllStoriesHidden(ctx context.Context, hidden bool) (bool, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + r.invalidateStoryProjectionCacheForViewer(userID) + return true, nil +} + +func (r *Router) onStoriesStartLive(ctx context.Context, req *tg.StoriesStartLiveRequest) (tg.UpdatesClass, error) { + if err := validateStoriesStartLiveRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if _, err := r.storyVisibilityFromInputPrivacyRules(ctx, userID, req.PrivacyRules); err != nil { + return nil, err + } + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + return nil, methodInvalidErr() +} + +func validateStoriesStartLiveRequest(req *tg.StoriesStartLiveRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if req.RandomID == 0 { + return randomIDEmptyErr() + } + if err := validateStoryCaptionEntities(req.Caption, req.Entities); err != nil { + return err + } + if stars, ok := req.GetSendPaidMessagesStars(); ok { + if stars < 0 || stars > maxChannelPaidMessageStars { + return starsAmountInvalidErr() + } + } + if err := validateStoryInputPrivacyRulesShape(req.PrivacyRules); err != nil { + return err + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func validateStoryInputPrivacyRulesShape(rules []tg.InputPrivacyRuleClass) error { + for _, rule := range rules { + if inputPrivacyRuleClassNil(rule) { + return privacyValueInvalidErr() + } + switch typed := rule.(type) { + case *tg.InputPrivacyValueAllowUsers: + for _, user := range typed.Users { + if inputUserClassNil(user) { + return userIDInvalidErr() + } + } + case *tg.InputPrivacyValueDisallowUsers: + for _, user := range typed.Users { + if inputUserClassNil(user) { + return userIDInvalidErr() + } + } + } + } + return nil +} + +func (r *Router) onStoriesCreateAlbum(ctx context.Context, req *tg.StoriesCreateAlbumRequest) (*tg.StoryAlbum, error) { + if err := validateStoriesCreateAlbumRequest(req); err != nil { + return nil, err + } + if _, _, err := r.storyAlbumWriteScope(ctx, req.Peer); err != nil { + return nil, err + } + return nil, methodInvalidErr() +} + +func (r *Router) onStoriesUpdateAlbum(ctx context.Context, req *tg.StoriesUpdateAlbumRequest) (*tg.StoryAlbum, error) { + if err := validateStoriesUpdateAlbumRequest(req); err != nil { + return nil, err + } + if _, _, err := r.storyAlbumWriteScope(ctx, req.Peer); err != nil { + return nil, err + } + return nil, methodInvalidErr() +} + +func validateStoriesCreateAlbumRequest(req *tg.StoriesCreateAlbumRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if err := validateStoryAlbumTitle(req.Title); err != nil { + return err + } + if err := validateStoryAlbumStoryIDSlice(req.Stories); err != nil { + return err + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func validateStoriesUpdateAlbumRequest(req *tg.StoriesUpdateAlbumRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if err := validateStoryAlbumID(req.AlbumID); err != nil { + return err + } + if !storyUpdateAlbumHasMutation(req) { + return inputRequestInvalidErr() + } + if title, ok := req.GetTitle(); ok { + if err := validateStoryAlbumTitle(title); err != nil { + return err + } + } + if ids, ok := req.GetDeleteStories(); ok { + if err := validateStoryAlbumUpdateStoryIDSlice(ids); err != nil { + return err + } + } + if ids, ok := req.GetAddStories(); ok { + if err := validateStoryAlbumUpdateStoryIDSlice(ids); err != nil { + return err + } + } + if ids, ok := req.GetOrder(); ok { + if err := validateStoryAlbumUpdateStoryIDSlice(ids); err != nil { + return err + } + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func storyUpdateAlbumHasMutation(req *tg.StoriesUpdateAlbumRequest) bool { + if req == nil { + return false + } + if _, ok := req.GetTitle(); ok { + return true + } + if _, ok := req.GetDeleteStories(); ok { + return true + } + if _, ok := req.GetAddStories(); ok { + return true + } + if _, ok := req.GetOrder(); ok { + return true + } + return false +} + +func (r *Router) onStoriesReorderAlbums(ctx context.Context, req *tg.StoriesReorderAlbumsRequest) (bool, error) { + if err := validateStoriesReorderAlbumsRequest(req); err != nil { + return false, err + } + if _, _, err := r.storyAlbumWriteScope(ctx, req.Peer); err != nil { + return false, err + } + return true, nil +} + +func validateStoriesReorderAlbumsRequest(req *tg.StoriesReorderAlbumsRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if err := validateStoryAlbumIDSlice(req.Order); err != nil { + return err + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesDeleteAlbum(ctx context.Context, req *tg.StoriesDeleteAlbumRequest) (bool, error) { + if err := validateStoriesDeleteAlbumRequest(req); err != nil { + return false, err + } + if _, _, err := r.storyAlbumWriteScope(ctx, req.Peer); err != nil { + return false, err + } + return true, nil +} + +func validateStoriesDeleteAlbumRequest(req *tg.StoriesDeleteAlbumRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if err := validateStoryAlbumID(req.AlbumID); err != nil { + return err + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesGetAllReadPeerStories(ctx context.Context) (tg.UpdatesClass, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + now := int(r.clock.Now().Unix()) + if r.deps.Stories == nil || userID == 0 { + return tgEmptyUpdates(now), nil + } + states, err := r.deps.Stories.ListReadStates(ctx, userID) + if err != nil { + return nil, storyErr(err) + } + states = normalizeStoryReadStates(userID, states) + return tgReadStoryUpdates(states, now), nil +} + +func normalizeStoryReadStates(viewerUserID int64, states []domain.StoryReadState) []domain.StoryReadState { + byPeer := make(map[domain.Peer]domain.StoryReadState, len(states)) + for _, state := range states { + if state.ViewerID != viewerUserID || state.MaxReadID <= 0 || state.MaxReadID > domain.MaxStoryID { + continue + } + if state.Peer.ID == 0 || state.Peer.Type == "" { + continue + } + existing, ok := byPeer[state.Peer] + if !ok || state.MaxReadID > existing.MaxReadID || (state.MaxReadID == existing.MaxReadID && state.Date > existing.Date) { + byPeer[state.Peer] = state + } + } + out := make([]domain.StoryReadState, 0, len(byPeer)) + for _, state := range byPeer { + out = append(out, state) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Peer.Type != out[j].Peer.Type { + return out[i].Peer.Type < out[j].Peer.Type + } + return out[i].Peer.ID < out[j].Peer.ID + }) + return out +} + +func (r *Router) onStoriesGetPeerMaxIDs(ctx context.Context, id []tg.InputPeerClass) ([]tg.RecentStory, error) { + if len(id) > domain.MaxStoryIDs { + return nil, storyIDInvalidErr() + } + for _, input := range id { + if err := validateStoriesDirectInputPeer(input); err != nil { + return nil, err + } + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peers := make([]domain.Peer, 0, len(id)) + for _, input := range id { + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return nil, err + } + peers = append(peers, peer) + } + if r.deps.Stories == nil || userID == 0 { + return make([]tg.RecentStory, len(id)), nil + } + recent, err := r.deps.Stories.GetPeerMaxIDs(ctx, userID, peers, int(r.clock.Now().Unix())) + if err != nil { + return nil, storyErr(err) + } + return tgRecentStories(alignStoryRecentByPeer(peers, recent)), nil +} + +func alignStoryRecentByPeer(peers []domain.Peer, recent []domain.RecentStory) []domain.RecentStory { + byPeer := make(map[domain.Peer]domain.RecentStory, len(recent)) + for _, item := range recent { + if item.Peer.ID == 0 || item.Peer.Type == "" { + continue + } + existing, ok := byPeer[item.Peer] + if !ok || item.MaxID > existing.MaxID { + byPeer[item.Peer] = item + continue + } + if item.Live && !existing.Live { + existing.Live = true + byPeer[item.Peer] = existing + } + } + out := make([]domain.RecentStory, len(peers)) + for i, peer := range peers { + out[i] = domain.RecentStory{Peer: peer} + if item, ok := byPeer[peer]; ok { + item.Peer = peer + out[i] = item + } + } + return out +} + +func (r *Router) onStoriesCanSendStory(ctx context.Context, peer tg.InputPeerClass) (*tg.StoriesCanSendStoryCount, error) { + if err := validateStoriesDirectInputPeer(peer); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + domainPeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer) + if err != nil { + return nil, err + } + if r.deps.Stories == nil { + if !canSendStoryFallbackPeerAllowed(userID, domainPeer) { + return nil, peerIDInvalidErr() + } + return &tg.StoriesCanSendStoryCount{CountRemains: domain.DefaultStoryCanSendRemaining}, nil + } + count, err := r.deps.Stories.CanSendStory(ctx, userID, domainPeer) + if err != nil { + return nil, storyErr(err) + } + return &tg.StoriesCanSendStoryCount{CountRemains: count}, nil +} + +func canSendStoryFallbackPeerAllowed(userID int64, peer domain.Peer) bool { + return userID != 0 && peer.Type == domain.PeerTypeUser && peer.ID == userID +} + +func validateStoriesDirectInputPeer(peer tg.InputPeerClass) error { + if inputPeerClassNil(peer) { + return peerIDInvalidErr() + } + switch typed := peer.(type) { + case *tg.InputPeerEmpty: + return peerIDInvalidErr() + case *tg.InputPeerChat: + if typed.ChatID <= 0 { + return peerIDInvalidErr() + } + case *tg.InputPeerUser: + if typed.UserID <= 0 { + return peerIDInvalidErr() + } + case *tg.InputPeerChannel: + if typed.ChannelID <= 0 { + return peerIDInvalidErr() + } + case *tg.InputPeerUserFromMessage: + if typed.UserID <= 0 || typed.MsgID <= 0 { + return peerIDInvalidErr() + } + case *tg.InputPeerChannelFromMessage: + if typed.ChannelID <= 0 || typed.MsgID <= 0 { + return peerIDInvalidErr() + } + } + return nil +} + +func (r *Router) onStoriesSendStory(ctx context.Context, req *tg.StoriesSendStoryRequest) (tg.UpdatesClass, error) { + period, err := validateStoriesSendStoryRequest(req) + if err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Stories == nil || userID == 0 { + return nil, peerIDInvalidErr() + } + now := int(r.clock.Now().Unix()) + forward, err := r.domainStoryForwardFromSend(ctx, userID, req, now) + if err != nil { + return nil, err + } + media, err := r.resolveStoryInputMedia(ctx, userID, req.Media) + if err != nil { + return nil, err + } + visibility, err := r.storyVisibilityFromInputPrivacyRules(ctx, userID, req.PrivacyRules) + if err != nil { + return nil, err + } + mediaAreas, err := r.domainStoryMediaAreasFromSend(ctx, userID, peer, req) + if err != nil { + return nil, err + } + res, err := r.deps.Stories.CreateStory(ctx, userID, domain.StoryCreateRequest{ + Owner: peer, + RandomID: req.RandomID, + Date: now, + Period: period, + Pinned: req.Pinned, + Public: visibility.Public, + CloseFriends: visibility.CloseFriends, + Contacts: visibility.Contacts, + SelectedContacts: visibility.SelectedContacts, + NoForwards: req.Noforwards, + PrivacyRules: visibility.Rules, + AllowUserIDs: visibility.AllowUserIDs, + DisallowUserIDs: visibility.DisallowUserIDs, + Caption: req.Caption, + Entities: domainMessageEntitiesForViewer(userID, req.Entities), + Media: media, + MediaAreas: mediaAreas, + Forward: forward, + }) + if err != nil { + return nil, storyErr(err) + } + r.invalidateStoryProjectionCacheForPeer(peer) + if !res.Duplicate { + if err := r.recordStoryChange(ctx, userID, res.Story); err != nil { + return nil, err + } + if err := r.fanoutChannelStoryChange(ctx, userID, res.Story); err != nil { + return nil, err + } + } + return r.tgStoryChangeUpdates(ctx, userID, peer, res.Story, req.RandomID, true, now), nil +} + +func validateStoriesSendStoryRequest(req *tg.StoriesSendStoryRequest) (int, error) { + if req == nil { + return 0, inputRequestInvalidErr() + } + if req.RandomID == 0 { + return 0, randomIDEmptyErr() + } + if err := validateStoryCaptionEntities(req.Caption, req.Entities); err != nil { + return 0, err + } + if err := validateStoryInputMediaClass(req.Media); err != nil { + return 0, err + } + if err := validateStorySendUnsupportedOptions(req); err != nil { + return 0, err + } + return storySendPeriodFromRequest(req) +} + +func storySendPeriodFromRequest(req *tg.StoriesSendStoryRequest) (int, error) { + period, ok := req.GetPeriod() + if !ok { + return domain.DefaultStoryPeriod, nil + } + switch period { + case 6 * 3600, 12 * 3600, domain.DefaultStoryPeriod, 2 * domain.DefaultStoryPeriod: + return period, nil + default: + return 0, storyPeriodInvalidErr() + } +} + +func (r *Router) domainStoryForwardFromSend(ctx context.Context, userID int64, req *tg.StoriesSendStoryRequest, now int) (*domain.StoryForward, error) { + sourceInput, hasSourcePeer := req.GetFwdFromID() + sourceStoryID, hasSourceStory := req.GetFwdFromStory() + if !hasSourcePeer && !hasSourceStory { + if req.FwdModified { + return nil, storyIDInvalidErr() + } + return nil, nil + } + if !hasSourcePeer || !hasSourceStory { + return nil, storyIDInvalidErr() + } + if err := validateStorySendForwardPayload(sourceInput, sourceStoryID); err != nil { + return nil, err + } + sourcePeer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, sourceInput) + if err != nil { + return nil, err + } + if r.deps.Stories == nil { + return nil, storyIDInvalidErr() + } + list, err := r.deps.Stories.GetStoriesByID(ctx, userID, sourcePeer, []int{sourceStoryID}, now) + if err != nil { + return nil, storyErr(err) + } + if len(list.Stories) != 1 || list.Stories[0].ID != sourceStoryID || list.Stories[0].Owner != sourcePeer { + return nil, storyIDInvalidErr() + } + source := list.Stories[0] + if source.NoForwards { + return nil, chatForwardsRestrictedErr() + } + forward := &domain.StoryForward{ + Source: source.Owner, + From: source.Owner, + StoryID: source.ID, + Modified: req.FwdModified, + } + r.applyStoryForwardAuthorPrivacy(ctx, userID, forward) + return forward, nil +} + +func (r *Router) applyStoryForwardAuthorPrivacy(ctx context.Context, forwarderUserID int64, forward *domain.StoryForward) { + if forward == nil || forward.From.Type != domain.PeerTypeUser || forward.From.ID == 0 || forward.FromName != "" { + return + } + if forward.From.ID == forwarderUserID || r.deps.Privacy == nil { + return + } + allowed, err := r.deps.Privacy.CanSee(ctx, forward.From.ID, forwarderUserID, domain.PrivacyKeyForwards) + if err != nil || allowed { + return + } + name := r.forwardAuthorDisplayName(ctx, forwarderUserID, forward.From.ID) + forward.From = domain.Peer{} + forward.FromName = name +} + +func (r *Router) onStoriesEditStory(ctx context.Context, req *tg.StoriesEditStoryRequest) (tg.UpdatesClass, error) { + if err := validateStoriesEditStoryRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + var media *domain.MessageMedia + updateMedia := false + if input, ok := req.GetMedia(); ok { + updateMedia = true + media, err = r.resolveStoryInputMedia(ctx, userID, input) + if err != nil { + return nil, err + } + } + caption, updateCaption := req.GetCaption() + entities := req.Entities + if _, ok := req.GetEntities(); !ok { + entities = nil + } + rules, updatePrivacy := req.GetPrivacyRules() + visibility, err := r.storyVisibilityFromInputPrivacyRules(ctx, userID, rules) + if err != nil { + return nil, err + } + mediaAreas, updateMediaAreas, err := r.domainStoryMediaAreasFromEdit(ctx, userID, peer, req) + if err != nil { + return nil, err + } + if r.deps.Stories == nil || userID == 0 { + return nil, peerIDInvalidErr() + } + musicOnlyNoop := storyEditHasOnlyEmptyMusic(req, updateMedia, updateCaption, updatePrivacy, updateMediaAreas) + res, err := r.deps.Stories.EditStory(ctx, userID, domain.StoryEditRequest{ + Owner: peer, + ID: req.ID, + Media: media, + UpdateMedia: updateMedia, + Caption: caption, + Entities: domainMessageEntitiesForViewer(userID, entities), + UpdateCaption: updateCaption, + Public: visibility.Public, + CloseFriends: visibility.CloseFriends, + Contacts: visibility.Contacts, + SelectedContacts: visibility.SelectedContacts, + PrivacyRules: visibility.Rules, + AllowUserIDs: visibility.AllowUserIDs, + DisallowUserIDs: visibility.DisallowUserIDs, + UpdatePrivacy: updatePrivacy, + MediaAreas: mediaAreas, + UpdateMediaAreas: updateMediaAreas, + }) + if err != nil { + if musicOnlyNoop && errors.Is(err, domain.ErrStoryNotModified) { + return r.tgUnchangedStoryUpdates(ctx, userID, peer, req.ID, int(r.clock.Now().Unix())) + } + return nil, storyErr(err) + } + r.invalidateStoryProjectionCacheForPeer(peer) + if err := r.recordStoryChange(ctx, userID, res.Story); err != nil { + return nil, err + } + if err := r.fanoutChannelStoryChange(ctx, userID, res.Story); err != nil { + return nil, err + } + if updatePrivacy { + if err := r.fanoutStoryPrivacyChange(ctx, userID, res.Previous, res.Story); err != nil { + return nil, err + } + } + return r.tgStoryChangeUpdates(ctx, userID, peer, res.Story, 0, false, int(r.clock.Now().Unix())), nil +} + +func validateStoriesEditStoryRequest(req *tg.StoriesEditStoryRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if req.ID <= 0 || req.ID > domain.MaxStoryID { + return storyIDInvalidErr() + } + if err := validateStoryCaptionEntities(req.Caption, req.Entities); err != nil { + return err + } + if media, ok := req.GetMedia(); ok { + if err := validateStoryInputMediaClass(media); err != nil { + return err + } + } + return validateStoryEditUnsupportedOptions(req) +} + +func validateStoryCaptionEntities(caption string, entities []tg.MessageEntityClass) error { + if utf8.RuneCountInString(caption) > maxSendMessageTextLength { + return mediaCaptionTooLongErr() + } + if len(entities) > maxMessageEntityCount { + return entitiesTooLongErr() + } + if len(entities) == 0 { + return nil + } + limit := utf16CodeUnitLen(caption) + for _, entity := range entities { + if messageEntityClassNil(entity) || !storyCaptionEntitySupported(entity) { + return entityBoundsInvalidErr() + } + offset, length := entity.GetOffset(), entity.GetLength() + if offset < 0 || length <= 0 || offset > limit || length > limit-offset { + return entityBoundsInvalidErr() + } + switch typed := entity.(type) { + case *tg.MessageEntityCustomEmoji: + if typed.DocumentID <= 0 { + return entityBoundsInvalidErr() + } + case *tg.InputMessageEntityMentionName: + if inputUserClassNil(typed.UserID) { + return userIDInvalidErr() + } + } + } + return nil +} + +func utf16CodeUnitLen(s string) int { + n := 0 + for _, r := range s { + if r <= 0xFFFF { + n++ + } else { + n += 2 + } + } + return n +} + +func messageEntityClassNil(entity tg.MessageEntityClass) bool { + switch typed := entity.(type) { + case nil: + return true + case *tg.MessageEntityUnknown: + return typed == nil + case *tg.MessageEntityMention: + return typed == nil + case *tg.MessageEntityHashtag: + return typed == nil + case *tg.MessageEntityBotCommand: + return typed == nil + case *tg.MessageEntityURL: + return typed == nil + case *tg.MessageEntityEmail: + return typed == nil + case *tg.MessageEntityBold: + return typed == nil + case *tg.MessageEntityItalic: + return typed == nil + case *tg.MessageEntityCode: + return typed == nil + case *tg.MessageEntityPre: + return typed == nil + case *tg.MessageEntityTextURL: + return typed == nil + case *tg.MessageEntityMentionName: + return typed == nil + case *tg.InputMessageEntityMentionName: + return typed == nil + case *tg.MessageEntityPhone: + return typed == nil + case *tg.MessageEntityCashtag: + return typed == nil + case *tg.MessageEntityUnderline: + return typed == nil + case *tg.MessageEntityStrike: + return typed == nil + case *tg.MessageEntityBankCard: + return typed == nil + case *tg.MessageEntitySpoiler: + return typed == nil + case *tg.MessageEntityCustomEmoji: + return typed == nil + case *tg.MessageEntityBlockquote: + return typed == nil + case *tg.MessageEntityFormattedDate: + return typed == nil + case *tg.MessageEntityDiffInsert: + return typed == nil + case *tg.MessageEntityDiffReplace: + return typed == nil + case *tg.MessageEntityDiffDelete: + return typed == nil + default: + return false + } +} + +func storyCaptionEntitySupported(entity tg.MessageEntityClass) bool { + switch entity.(type) { + case *tg.MessageEntityMention, + *tg.MessageEntityHashtag, + *tg.MessageEntityBotCommand, + *tg.MessageEntityURL, + *tg.MessageEntityEmail, + *tg.MessageEntityBold, + *tg.MessageEntityItalic, + *tg.MessageEntityCode, + *tg.MessageEntityPre, + *tg.MessageEntityTextURL, + *tg.MessageEntityMentionName, + *tg.InputMessageEntityMentionName, + *tg.MessageEntityPhone, + *tg.MessageEntityCashtag, + *tg.MessageEntityUnderline, + *tg.MessageEntityStrike, + *tg.MessageEntityBankCard, + *tg.MessageEntitySpoiler, + *tg.MessageEntityCustomEmoji, + *tg.MessageEntityBlockquote: + return true + default: + return false + } +} + +func validateStorySendUnsupportedOptions(req *tg.StoriesSendStoryRequest) error { + if albums, ok := req.GetAlbums(); ok && len(albums) > 0 { + return mediaInvalidErr() + } + if music, ok := req.GetMusic(); ok && !storyInputDocumentIsEmpty(music) { + return documentInvalidErr() + } + return validateStorySendForwardFlags(req) +} + +func validateStorySendForwardFlags(req *tg.StoriesSendStoryRequest) error { + sourceInput, hasSourcePeer := req.GetFwdFromID() + sourceStoryID, hasSourceStory := req.GetFwdFromStory() + if !hasSourcePeer && !hasSourceStory { + if req.FwdModified { + return storyIDInvalidErr() + } + return nil + } + if !hasSourcePeer || !hasSourceStory { + return storyIDInvalidErr() + } + return validateStorySendForwardPayload(sourceInput, sourceStoryID) +} + +func validateStorySendForwardPayload(sourceInput tg.InputPeerClass, sourceStoryID int) error { + if sourceStoryID <= 0 || sourceStoryID > domain.MaxStoryID { + return storyIDInvalidErr() + } + if err := validateStoriesDirectInputPeer(sourceInput); err != nil { + return storyIDInvalidErr() + } + return nil +} + +func validateStoryEditUnsupportedOptions(req *tg.StoriesEditStoryRequest) error { + if music, ok := req.GetMusic(); ok && !storyInputDocumentIsEmpty(music) { + return documentInvalidErr() + } + return nil +} + +func storyInputDocumentIsEmpty(input tg.InputDocumentClass) bool { + if input == nil { + return false + } + empty, ok := input.(*tg.InputDocumentEmpty) + return ok && empty != nil +} + +func storyEditHasOnlyEmptyMusic(req *tg.StoriesEditStoryRequest, updateMedia, updateCaption, updatePrivacy, updateMediaAreas bool) bool { + if updateMedia || updateCaption || updatePrivacy || updateMediaAreas { + return false + } + music, ok := req.GetMusic() + return ok && storyInputDocumentIsEmpty(music) +} + +func (r *Router) domainStoryMediaAreasFromSend(ctx context.Context, userID int64, peer domain.Peer, req *tg.StoriesSendStoryRequest) ([]domain.StoryMediaArea, error) { + areas, ok := req.GetMediaAreas() + if !ok { + return nil, nil + } + return r.domainStoryMediaAreasFromTL(ctx, userID, peer, areas) +} + +func (r *Router) domainStoryMediaAreasFromEdit(ctx context.Context, userID int64, peer domain.Peer, req *tg.StoriesEditStoryRequest) ([]domain.StoryMediaArea, bool, error) { + areas, ok := req.GetMediaAreas() + if !ok { + return nil, false, nil + } + out, err := r.domainStoryMediaAreasFromTL(ctx, userID, peer, areas) + return out, true, err +} + +func (r *Router) domainStoryMediaAreasFromTL(ctx context.Context, userID int64, peer domain.Peer, areas []tg.MediaAreaClass) ([]domain.StoryMediaArea, error) { + if len(areas) == 0 { + return nil, nil + } + if len(areas) > domain.MaxStoryMediaAreas { + return nil, limitInvalidErr() + } + out := make([]domain.StoryMediaArea, 0, len(areas)) + for _, area := range areas { + if storyMediaAreaClassNil(area) { + return nil, mediaInvalidErr() + } + switch typed := area.(type) { + case *tg.MediaAreaSuggestedReaction: + coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates) + if err != nil { + return nil, err + } + reaction, err := domainStoryReactionValueFromTL(typed.Reaction) + if err != nil { + return nil, err + } + out = append(out, domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaSuggestedReaction, + Coordinates: coords, + Dark: typed.Dark, + Flipped: typed.Flipped, + Reaction: &reaction, + }) + case *tg.MediaAreaURL: + coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates) + if err != nil { + return nil, err + } + if !validStoryMediaAreaURL(typed.URL) { + return nil, mediaInvalidErr() + } + out = append(out, domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaURL, + Coordinates: coords, + URL: typed.URL, + }) + case *tg.MediaAreaGeoPoint: + coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates) + if err != nil { + return nil, err + } + geo, err := domainStoryGeoPointFromTL(typed.Geo) + if err != nil { + return nil, err + } + address, err := domainStoryGeoPointAddressFromTL(typed) + if err != nil { + return nil, err + } + out = append(out, domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaGeoPoint, + Coordinates: coords, + Geo: geo, + GeoAddress: address, + }) + case *tg.MediaAreaVenue: + coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates) + if err != nil { + return nil, err + } + venue, err := domainStoryVenueFromTL(typed) + if err != nil { + return nil, err + } + out = append(out, domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaVenue, + Coordinates: coords, + Geo: &venue.Geo, + Venue: venue, + }) + case *tg.InputMediaAreaVenue: + coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates) + if err != nil { + return nil, err + } + area, err := r.domainStoryVenueMediaAreaFromInput(ctx, userID, peer, coords, typed) + if err != nil { + return nil, err + } + out = append(out, area) + case *tg.MediaAreaWeather: + coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates) + if err != nil { + return nil, err + } + if !validStoryWeatherEmoji(typed.Emoji) || !validStoryWeatherTemperature(typed.TemperatureC) { + return nil, mediaInvalidErr() + } + out = append(out, domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaWeather, + Coordinates: coords, + WeatherEmoji: typed.Emoji, + TemperatureC: typed.TemperatureC, + Color: typed.Color, + }) + case *tg.MediaAreaChannelPost: + coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates) + if err != nil { + return nil, err + } + if !validStoryChannelPost(typed.ChannelID, typed.MsgID) { + return nil, mediaInvalidErr() + } + out = append(out, domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaChannelPost, + Coordinates: coords, + ChannelID: typed.ChannelID, + MsgID: typed.MsgID, + }) + case *tg.MediaAreaStarGift: + coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates) + if err != nil { + return nil, err + } + if !validStoryStarGiftSlug(typed.Slug) { + return nil, mediaInvalidErr() + } + out = append(out, domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaStarGift, + Coordinates: coords, + StarGiftSlug: typed.Slug, + }) + case *tg.InputMediaAreaChannelPost: + coords, err := domainStoryMediaAreaCoordinatesFromTL(typed.Coordinates) + if err != nil { + return nil, err + } + area, err := r.domainStoryChannelPostMediaAreaFromInput(ctx, userID, coords, typed) + if err != nil { + return nil, err + } + out = append(out, area) + default: + return nil, mediaInvalidErr() + } + } + return out, nil +} + +func storyMediaAreaClassNil(area tg.MediaAreaClass) bool { + switch typed := area.(type) { + case nil: + return true + case *tg.MediaAreaSuggestedReaction: + return typed == nil + case *tg.MediaAreaURL: + return typed == nil + case *tg.MediaAreaGeoPoint: + return typed == nil + case *tg.MediaAreaVenue: + return typed == nil + case *tg.InputMediaAreaVenue: + return typed == nil + case *tg.MediaAreaWeather: + return typed == nil + case *tg.MediaAreaChannelPost: + return typed == nil + case *tg.MediaAreaStarGift: + return typed == nil + case *tg.InputMediaAreaChannelPost: + return typed == nil + default: + return false + } +} + +func domainStoryMediaAreaCoordinatesFromTL(in tg.MediaAreaCoordinates) (domain.StoryMediaAreaCoordinates, error) { + radius, hasRadius := in.GetRadius() + out := domain.StoryMediaAreaCoordinates{ + X: in.X, + Y: in.Y, + W: in.W, + H: in.H, + Rotation: in.Rotation, + Radius: radius, + HasRadius: hasRadius, + } + if !storyAreaPercent(out.X, true) || + !storyAreaPercent(out.Y, true) || + !storyAreaPercent(out.W, false) || + !storyAreaPercent(out.H, false) || + !storyAreaRotation(out.Rotation) || + (hasRadius && !storyAreaPercent(out.Radius, true)) { + return domain.StoryMediaAreaCoordinates{}, mediaInvalidErr() + } + return out, nil +} + +func storyAreaPercent(v float64, allowZero bool) bool { + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 100 { + return false + } + return allowZero || v > 0 +} + +func storyAreaRotation(v float64) bool { + return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 360 +} + +func validStoryMediaAreaURL(raw string) bool { + return raw != "" && + len(raw) <= domain.MaxStoryMediaAreaURLLength && + strings.TrimSpace(raw) == raw +} + +func validStoryWeatherEmoji(raw string) bool { + return raw != "" && + strings.TrimSpace(raw) == raw && + utf8.RuneCountInString(raw) <= domain.MaxStoryWeatherEmojiLength +} + +func validStoryWeatherTemperature(v float64) bool { + return !math.IsNaN(v) && + !math.IsInf(v, 0) && + v >= minStoryWeatherTemperatureC && + v <= maxStoryWeatherTemperatureC +} + +func validStoryChannelPost(channelID int64, msgID int) bool { + return channelID > 0 && msgID > 0 && msgID <= domain.MaxMessageBoxID +} + +func validStoryStarGiftSlug(raw string) bool { + if raw == "" || + len(raw) > domain.MaxStoryStarGiftSlugLength || + strings.TrimSpace(raw) != raw { + return false + } + for i := 0; i < len(raw); i++ { + c := raw[i] + if (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '.' || + c == '_' || + c == '-' { + continue + } + return false + } + return true +} + +func (r *Router) domainStoryChannelPostMediaAreaFromInput(ctx context.Context, userID int64, coords domain.StoryMediaAreaCoordinates, area *tg.InputMediaAreaChannelPost) (domain.StoryMediaArea, error) { + if area == nil || area.Channel == nil || area.MsgID <= 0 || area.MsgID > domain.MaxMessageBoxID { + return domain.StoryMediaArea{}, mediaInvalidErr() + } + if r.deps.Channels == nil { + return domain.StoryMediaArea{}, channelInvalidErr(domain.ErrChannelInvalid) + } + channelID, err := r.channelIDFromInput(ctx, userID, area.Channel) + if err != nil { + return domain.StoryMediaArea{}, err + } + if !validStoryChannelPost(channelID, area.MsgID) { + return domain.StoryMediaArea{}, mediaInvalidErr() + } + history, err := r.deps.Channels.GetMessages(ctx, userID, channelID, []int{area.MsgID}) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return domain.StoryMediaArea{}, mediaInvalidErr() + } + return domain.StoryMediaArea{}, channelInvalidErr(err) + } + if len(history.Messages) != 1 || history.Messages[0].ID != area.MsgID { + return domain.StoryMediaArea{}, mediaInvalidErr() + } + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaChannelPost, + Coordinates: coords, + ChannelID: channelID, + MsgID: area.MsgID, + }, nil +} + +func domainStoryVenueFromTL(area *tg.MediaAreaVenue) (*domain.MessageVenue, error) { + if area == nil { + return nil, mediaInvalidErr() + } + geo, err := domainStoryGeoPointFromTL(area.Geo) + if err != nil { + return nil, err + } + return domainStoryVenueFromValues(*geo, area.Title, area.Address, area.Provider, area.VenueID, area.VenueType) +} + +func (r *Router) domainStoryVenueMediaAreaFromInput(ctx context.Context, userID int64, peer domain.Peer, coords domain.StoryMediaAreaCoordinates, area *tg.InputMediaAreaVenue) (domain.StoryMediaArea, error) { + if area == nil || + area.QueryID == 0 || + area.ResultID == "" || + len(area.ResultID) > domain.MaxBotInlineResultIDLen { + return domain.StoryMediaArea{}, mediaInvalidErr() + } + results, result, ok := r.inlines.resultForSendContext(ctx, r.clock.Now(), userID, area.QueryID, area.ResultID) + if !ok || !r.inlineResultsAllowPeer(ctx, userID, results, peer) || + result.Media == nil || + result.Media.Kind != domain.MessageMediaKindVenue || + result.Media.Venue == nil { + return domain.StoryMediaArea{}, mediaInvalidErr() + } + venue, err := domainStoryVenueFromValues(result.Media.Venue.Geo, result.Media.Venue.Title, result.Media.Venue.Address, result.Media.Venue.Provider, result.Media.Venue.VenueID, result.Media.Venue.VenueType) + if err != nil { + return domain.StoryMediaArea{}, err + } + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaVenue, + Coordinates: coords, + Geo: &venue.Geo, + Venue: venue, + }, nil +} + +func domainStoryVenueFromValues(geo domain.MessageGeoPoint, title, address, provider, venueID, venueType string) (*domain.MessageVenue, error) { + if !validStoryDomainGeoPoint(geo) || + strings.TrimSpace(title) == "" || + utf8.RuneCountInString(title) > maxVenueTitleLength || + utf8.RuneCountInString(address) > maxVenueAddressLength || + utf8.RuneCountInString(provider) > maxVenueProviderLength || + utf8.RuneCountInString(venueID) > maxVenueIDLength || + utf8.RuneCountInString(venueType) > maxVenueIDLength { + return nil, mediaInvalidErr() + } + return &domain.MessageVenue{ + Geo: geo, + Title: title, + Address: address, + Provider: provider, + VenueID: venueID, + VenueType: venueType, + }, nil +} + +func validStoryDomainGeoPoint(geo domain.MessageGeoPoint) bool { + return !math.IsNaN(geo.Lat) && + !math.IsInf(geo.Lat, 0) && + !math.IsNaN(geo.Long) && + !math.IsInf(geo.Long, 0) && + geo.Lat >= -90 && + geo.Lat <= 90 && + geo.Long >= -180 && + geo.Long <= 180 +} + +func domainStoryGeoPointFromTL(geo tg.GeoPointClass) (*domain.MessageGeoPoint, error) { + point, ok := geo.(*tg.GeoPoint) + if !ok || point == nil || + math.IsNaN(point.Lat) || math.IsInf(point.Lat, 0) || + math.IsNaN(point.Long) || math.IsInf(point.Long, 0) || + point.Lat < -90 || point.Lat > 90 || + point.Long < -180 || point.Long > 180 { + return nil, mediaInvalidErr() + } + accuracy, _ := point.GetAccuracyRadius() + if accuracy < 0 || accuracy > maxGeoAccuracyRadiusMeters { + accuracy = 0 + } + return &domain.MessageGeoPoint{ + Lat: point.Lat, + Long: point.Long, + AccessHash: point.AccessHash, + AccuracyRadius: accuracy, + }, nil +} + +func domainStoryGeoPointAddressFromTL(area *tg.MediaAreaGeoPoint) (*domain.StoryGeoPointAddress, error) { + if area == nil { + return nil, nil + } + address, ok := area.GetAddress() + if !ok { + return nil, nil + } + if !validStoryGeoCountry(address.CountryISO2) || + !validStoryGeoAddressPart(address.State) || + !validStoryGeoAddressPart(address.City) || + !validStoryGeoAddressPart(address.Street) { + return nil, mediaInvalidErr() + } + return &domain.StoryGeoPointAddress{ + CountryISO2: address.CountryISO2, + State: address.State, + City: address.City, + Street: address.Street, + }, nil +} + +func validStoryGeoCountry(raw string) bool { + return utf8.RuneCountInString(raw) == 2 && strings.TrimSpace(raw) == raw +} + +func validStoryGeoAddressPart(raw string) bool { + return utf8.RuneCountInString(raw) <= domain.MaxStoryGeoAddressPartLength && + strings.TrimSpace(raw) == raw +} + +func (r *Router) tgUnchangedStoryUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID, date int) (tg.UpdatesClass, error) { + list, err := r.deps.Stories.GetStoriesByID(ctx, viewerUserID, peer, []int{storyID}, date) + if err != nil { + return nil, storyErr(err) + } + if len(list.Stories) == 0 { + return nil, storyIDInvalidErr() + } + return r.tgStoryChangeUpdates(ctx, viewerUserID, peer, list.Stories[0], 0, false, date), nil +} + +type storyPrivacyFanoutFacts struct { + isContact bool + closeFriend bool + storyBlocked bool +} + +func (r *Router) fanoutStoryPrivacyChange(ctx context.Context, ownerID int64, before, after domain.Story) error { + if ownerID == 0 || r.deps.Stories == nil || r.deps.Updates == nil { + return nil + } + if before.Owner != after.Owner || before.ID != after.ID || after.Owner.Type != domain.PeerTypeUser || after.Owner.ID != ownerID { + return nil + } + now := int(r.clock.Now().Unix()) + beforeActive := before.Active(now) + afterActive := after.Active(now) + if !beforeActive && !afterActive { + return nil + } + candidates := make(map[int64]storyPrivacyFanoutFacts) + addCandidate := func(userID int64, facts storyPrivacyFanoutFacts) { + if userID == 0 || userID == ownerID { + return + } + existing, ok := candidates[userID] + if !ok && len(candidates) >= domain.MaxStoryPrivacyFanoutTargets { + return + } + facts.isContact = facts.isContact || existing.isContact + facts.closeFriend = facts.closeFriend || existing.closeFriend + facts.storyBlocked = facts.storyBlocked || existing.storyBlocked + candidates[userID] = facts + } + addCandidateIDs := func(ids []int64) { + for _, userID := range ids { + addCandidate(userID, storyPrivacyFanoutFacts{}) + } + } + addCandidateIDs(before.AllowUserIDs) + addCandidateIDs(after.AllowUserIDs) + addCandidateIDs(before.DisallowUserIDs) + addCandidateIDs(after.DisallowUserIDs) + if r.deps.Contacts != nil { + list, _, err := r.deps.Contacts.GetContacts(ctx, ownerID, 0) + if err != nil { + return internalErr() + } + for _, contact := range list.Contacts { + contactUserID := contact.User.ID + addCandidate(contactUserID, storyPrivacyFanoutFacts{ + isContact: true, + closeFriend: contact.CloseFriend || contact.User.CloseFriend, + }) + } + } + viewerIDs, err := r.deps.Stories.ListStoryViewerIDs(ctx, ownerID, after.Owner, after.ID, domain.MaxStoryPrivacyFanoutTargets) + if err != nil { + return storyErr(err) + } + addCandidateIDs(viewerIDs) + candidateIDs := make([]int64, 0, len(candidates)) + for userID := range candidates { + candidateIDs = append(candidateIDs, userID) + } + blockedFacts, err := r.storyBlockedFactsForUsers(ctx, ownerID, candidateIDs) + if err != nil { + return err + } + for userID, blocked := range blockedFacts { + facts := candidates[userID] + facts.storyBlocked = blocked + candidates[userID] = facts + } + visible := func(story domain.Story, active bool, userID int64, facts storyPrivacyFanoutFacts) bool { + return active && story.VisibleToWithStoryFacts(userID, facts.isContact, facts.closeFriend, facts.storyBlocked) + } + for userID, facts := range candidates { + beforeVisible := visible(before, beforeActive, userID, facts) + afterVisible := visible(after, afterActive, userID, facts) + switch { + case afterVisible: + if err := r.recordStoryFanout(ctx, userID, storyFanoutSnapshot(after)); err != nil { + return err + } + case beforeVisible: + deleted := storyFanoutSnapshot(after) + deleted.Deleted = true + if err := r.recordStoryFanout(ctx, userID, deleted); err != nil { + return err + } + } + } + return nil +} + +func (r *Router) fanoutDeletedUserStory(ctx context.Context, ownerID int64, before, deleted domain.Story, now int) error { + if ownerID == 0 || r.deps.Stories == nil || r.deps.Updates == nil { + return nil + } + if before.ID == 0 { + before = deleted + before.Deleted = false + } + if deleted.ID == 0 { + deleted = before + deleted.Deleted = true + } + if before.Owner.Type != domain.PeerTypeUser || before.Owner.ID != ownerID || before.ID <= 0 { + return nil + } + if deleted.Owner != before.Owner || deleted.ID != before.ID { + return nil + } + if !before.Active(now) && !before.Pinned { + return nil + } + candidates := make(map[int64]storyPrivacyFanoutFacts) + addCandidate := func(userID int64, facts storyPrivacyFanoutFacts) { + if userID == 0 || userID == ownerID { + return + } + existing, ok := candidates[userID] + if !ok && len(candidates) >= domain.MaxStoryPrivacyFanoutTargets { + return + } + facts.isContact = facts.isContact || existing.isContact + facts.closeFriend = facts.closeFriend || existing.closeFriend + facts.storyBlocked = facts.storyBlocked || existing.storyBlocked + candidates[userID] = facts + } + addCandidateIDs := func(ids []int64) { + for _, userID := range ids { + addCandidate(userID, storyPrivacyFanoutFacts{}) + } + } + addCandidateIDs(before.AllowUserIDs) + addCandidateIDs(before.DisallowUserIDs) + if r.deps.Contacts != nil { + list, _, err := r.deps.Contacts.GetContacts(ctx, ownerID, 0) + if err != nil { + return internalErr() + } + for _, contact := range list.Contacts { + contactUserID := contact.User.ID + addCandidate(contactUserID, storyPrivacyFanoutFacts{ + isContact: true, + closeFriend: contact.CloseFriend || contact.User.CloseFriend, + }) + } + } + viewerIDs, err := r.deps.Stories.ListStoryViewerIDs(ctx, ownerID, before.Owner, before.ID, domain.MaxStoryPrivacyFanoutTargets) + if err != nil { + return storyErr(err) + } + addCandidateIDs(viewerIDs) + candidateIDs := make([]int64, 0, len(candidates)) + for userID := range candidates { + candidateIDs = append(candidateIDs, userID) + } + blockedFacts, err := r.storyBlockedFactsForUsers(ctx, ownerID, candidateIDs) + if err != nil { + return err + } + for userID, blocked := range blockedFacts { + facts := candidates[userID] + facts.storyBlocked = blocked + candidates[userID] = facts + } + deleted = storyFanoutSnapshot(deleted) + deleted.Deleted = true + for userID, facts := range candidates { + if before.VisibleToWithStoryFacts(userID, facts.isContact, facts.closeFriend, facts.storyBlocked) { + if err := r.recordStoryFanout(ctx, userID, deleted); err != nil { + return err + } + } + } + return nil +} + +func (r *Router) fanoutUnpinnedExpiredUserStory(ctx context.Context, ownerID int64, before, after domain.Story, now int) error { + if before.ID == 0 || after.ID == 0 { + return nil + } + if before.Owner != after.Owner || before.ID != after.ID { + return nil + } + if !before.Pinned || before.Active(now) || after.Active(now) || after.Pinned || after.Deleted { + return nil + } + return r.fanoutDeletedUserStory(ctx, ownerID, before, after, now) +} + +func (r *Router) fanoutChannelStoryChange(ctx context.Context, originUserID int64, story domain.Story) error { + if originUserID == 0 || + story.Owner.Type != domain.PeerTypeChannel || + story.Owner.ID == 0 || + r.deps.Channels == nil || + r.deps.Updates == nil { + return nil + } + memberIDs, err := r.deps.Channels.ActiveMemberIDs(ctx, originUserID, story.Owner.ID, domain.MaxChannelRealtimeFanout) + if err != nil { + return internalErr() + } + snapshot := storyFanoutSnapshot(story) + for _, userID := range memberIDs { + if userID == 0 || userID == originUserID { + continue + } + if err := r.recordStoryFanout(ctx, userID, snapshot); err != nil { + return err + } + } + return nil +} + +func (r *Router) onStoriesDeleteStories(ctx context.Context, req *tg.StoriesDeleteStoriesRequest) ([]int, error) { + ids, err := validateStoriesDeleteStoriesRequest(req) + if err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + now := int(r.clock.Now().Unix()) + if r.deps.Stories == nil || userID == 0 { + return append([]int(nil), ids...), nil + } + res, err := r.deps.Stories.DeleteStories(ctx, userID, peer, ids, now) + if err != nil { + return nil, storyErr(err) + } + r.invalidateStoryProjectionCacheForPeer(peer) + previousByID := make(map[int]domain.Story, len(res.Previous)) + for _, story := range res.Previous { + previousByID[story.ID] = story + } + for _, story := range res.Stories { + if err := r.recordStoryChange(ctx, userID, story); err != nil { + return nil, err + } + if err := r.fanoutDeletedUserStory(ctx, userID, previousByID[story.ID], story, now); err != nil { + return nil, err + } + if err := r.fanoutChannelStoryChange(ctx, userID, story); err != nil { + return nil, err + } + } + return res.IDs, nil +} + +func validateStoriesDeleteStoriesRequest(req *tg.StoriesDeleteStoriesRequest) ([]int, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if len(req.ID) == 0 { + return nil, storyIDEmptyErr() + } + if err := validateStoryIDSlice(req.ID); err != nil { + return nil, err + } + if err := validateStoriesDirectInputPeer(req.Peer); err != nil { + return nil, err + } + return uniqueStoryIDs(req.ID), nil +} + +func (r *Router) onStoriesTogglePinned(ctx context.Context, req *tg.StoriesTogglePinnedRequest) ([]int, error) { + ids, err := validateStoriesTogglePinnedRequest(req) + if err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + now := int(r.clock.Now().Unix()) + if r.deps.Stories == nil || userID == 0 { + return append([]int(nil), ids...), nil + } + res, err := r.deps.Stories.TogglePinned(ctx, userID, peer, ids, req.Pinned, now) + if err != nil { + return nil, storyErr(err) + } + r.invalidateStoryProjectionCacheForPeer(peer) + previousByID := make(map[int]domain.Story, len(res.Previous)) + for _, story := range res.Previous { + previousByID[story.ID] = story + } + for _, story := range res.Stories { + if err := r.recordStoryChange(ctx, userID, story); err != nil { + return nil, err + } + if err := r.fanoutUnpinnedExpiredUserStory(ctx, userID, previousByID[story.ID], story, now); err != nil { + return nil, err + } + if err := r.fanoutChannelStoryChange(ctx, userID, story); err != nil { + return nil, err + } + } + return res.IDs, nil +} + +func validateStoriesTogglePinnedRequest(req *tg.StoriesTogglePinnedRequest) ([]int, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if len(req.ID) > 0 { + if err := validateStoryIDSlice(req.ID); err != nil { + return nil, err + } + } + if err := validateStoriesDirectInputPeer(req.Peer); err != nil { + return nil, err + } + if len(req.ID) == 0 { + return []int{}, nil + } + return uniqueStoryIDs(req.ID), nil +} + +func (r *Router) onStoriesTogglePinnedToTop(ctx context.Context, req *tg.StoriesTogglePinnedToTopRequest) (bool, error) { + ids, err := validateStoriesTogglePinnedToTopRequest(req) + if err != nil { + return false, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + if r.deps.Stories == nil || userID == 0 { + return true, nil + } + if err := r.deps.Stories.TogglePinnedToTop(ctx, userID, peer, ids); err != nil { + return false, storyErr(err) + } + r.invalidateStoryProjectionCacheForPeer(peer) + return true, nil +} + +func validateStoriesTogglePinnedToTopRequest(req *tg.StoriesTogglePinnedToTopRequest) ([]int, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if len(req.ID) > 0 { + if err := validateStoryIDSlice(req.ID); err != nil { + return nil, err + } + } + ids := uniqueStoryIDs(req.ID) + if len(ids) > domain.MaxStoryPinnedToTop { + return nil, storyIDInvalidErr() + } + if err := validateStoriesDirectInputPeer(req.Peer); err != nil { + return nil, err + } + return ids, nil +} + +func (r *Router) onStoriesReadStories(ctx context.Context, req *tg.StoriesReadStoriesRequest) ([]int, error) { + if err := validateStoriesReadStoriesRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Stories == nil || userID == 0 { + return []int{}, nil + } + now := int(r.clock.Now().Unix()) + read, err := r.deps.Stories.ReadStories(ctx, userID, peer, req.MaxID, now) + if err != nil { + return nil, storyErr(err) + } + if read.Advanced && r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + if _, _, err := r.deps.Updates.RecordReadStories(ctx, authKeyID, userID, read, sessionID); err != nil { return nil, internalErr() } } - return tgEmptyUpdates(int(r.clock.Now().Unix())), nil + if read.MaxReadID <= 0 { + return []int{}, nil + } + return []int{read.MaxReadID}, nil +} + +func validateStoriesReadStoriesRequest(req *tg.StoriesReadStoriesRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if req.MaxID <= 0 || req.MaxID > domain.MaxStoryID { + return storyIDInvalidErr() + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesIncrementStoryViews(ctx context.Context, req *tg.StoriesIncrementStoryViewsRequest) (bool, error) { + if err := validateStoriesIncrementStoryViewsRequest(req); err != nil { + return false, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + if r.deps.Stories == nil || userID == 0 { + return true, nil + } + if _, err := r.deps.Stories.IncrementViews(ctx, userID, peer, uniqueStoryIDs(req.ID), int(r.clock.Now().Unix())); err != nil { + return false, storyErr(err) + } + // 浏览计数变化主要影响作者自己查看自己故事时的 ViewsCount。view 增量高频, + // 只失效作者自视角这一条缓存,避免按 peer 全量失效把热门故事的缓存打穿。 + if peer.Type == domain.PeerTypeUser { + r.invalidateStoryProjectionCache(peer.ID, peer) + } + return true, nil +} + +func validateStoriesIncrementStoryViewsRequest(req *tg.StoriesIncrementStoryViewsRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if len(req.ID) == 0 { + return storyIDEmptyErr() + } + if err := validateStoryIDSlice(req.ID); err != nil { + return err + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesGetStoriesViews(ctx context.Context, req *tg.StoriesGetStoriesViewsRequest) (*tg.StoriesStoryViews, error) { + if err := validateStoriesGetStoriesViewsRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Stories == nil || userID == 0 { + return tgEmptyStoriesStoryViews(len(req.ID)), nil + } + list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, req.ID, int(r.clock.Now().Unix())) + if err != nil { + return nil, storyErr(err) + } + r.addStoryMessageForwardCounts(ctx, userID, list.Stories) + return r.tgStoriesStoryViewsForIDs(ctx, userID, peer, req.ID, list.Stories), nil +} + +func validateStoriesGetStoriesViewsRequest(req *tg.StoriesGetStoriesViewsRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if len(req.ID) == 0 { + return storyIDEmptyErr() + } + if err := validateStoryIDSlice(req.ID); err != nil { + return err + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesGetStoryViewsList(ctx context.Context, req *tg.StoriesGetStoryViewsListRequest) (*tg.StoriesStoryViewsList, error) { + if err := validateStoriesGetStoryViewsListRequest(req); err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if r.deps.Stories == nil || userID == 0 { + return r.tgStoryViewsList(ctx, userID, domain.StoryViewList{}), nil + } + q, _ := storyViewsListQuery(req) + list, err := r.deps.Stories.GetStoryViewsList(ctx, userID, domain.StoryViewListRequest{ + Owner: peer, + StoryID: req.ID, + Offset: req.Offset, + Limit: req.Limit, + Query: q, + JustContacts: req.GetJustContacts(), + ReactionsFirst: req.GetReactionsFirst(), + ForwardsFirst: req.GetForwardsFirst(), + }) + if err != nil { + return nil, storyErr(err) + } + if q == "" && !req.GetJustContacts() { + list = r.withStoryMessageForwardViews(ctx, userID, list, domain.StoryMessageForwardListRequest{ + Owner: peer, + StoryID: req.ID, + Offset: req.Offset, + Limit: req.Limit, + ReactionsFirst: req.GetReactionsFirst(), + ForwardsFirst: req.GetForwardsFirst(), + }) + } + return r.tgStoryViewsList(ctx, userID, list), nil +} + +func validateStoriesGetStoryViewsListRequest(req *tg.StoriesGetStoryViewsListRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + if req.ID <= 0 || req.ID > domain.MaxStoryID { + return storyIDInvalidErr() + } + if err := validateStoryInteractionListLimit(req.Limit); err != nil { + return err + } + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return storyErr(err) + } + if q, ok := storyViewsListQuery(req); ok && utf8.RuneCountInString(q) > domain.MaxStoryViewQueryLength { + return limitInvalidErr() + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func storyViewsListQuery(req *tg.StoriesGetStoryViewsListRequest) (string, bool) { + if req == nil { + return "", false + } + q, ok := req.GetQ() + if !ok && req.Q != "" { + q, ok = req.Q, true + } + return q, ok +} + +func (r *Router) onStoriesGetStoryReactionsList(ctx context.Context, req *tg.StoriesGetStoryReactionsListRequest) (*tg.StoriesStoryReactionsList, error) { + reaction, offset, err := validateStoriesGetStoryReactionsListRequest(req) + if err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + if peer.Type != domain.PeerTypeChannel { + return nil, peerIDInvalidErr() + } + if err := r.requireChannelStoryInteractionsAdmin(ctx, userID, peer.ID); err != nil { + return nil, err + } + if r.deps.Stories == nil || userID == 0 { + return r.tgStoryReactionsList(ctx, userID, domain.StoryReactionList{}), nil + } + list, err := r.deps.Stories.GetStoryReactionsList(ctx, userID, domain.StoryReactionListRequest{ + Owner: peer, + StoryID: req.ID, + Reaction: reaction, + Offset: offset, + Limit: req.Limit, + ForwardsFirst: req.GetForwardsFirst(), + CanViewOwnerInteractions: true, + }) + if err != nil { + return nil, storyErr(err) + } + if reaction == nil { + list = r.withStoryMessageForwardReactions(ctx, userID, list, domain.StoryMessageForwardListRequest{ + Owner: peer, + StoryID: req.ID, + Offset: offset, + Limit: req.Limit, + ForwardsFirst: req.GetForwardsFirst(), + }) + } + return r.tgStoryReactionsList(ctx, userID, list), nil +} + +func validateStoriesGetStoryReactionsListRequest(req *tg.StoriesGetStoryReactionsListRequest) (*domain.MessageReaction, string, error) { + if req == nil { + return nil, "", inputRequestInvalidErr() + } + if req.ID <= 0 || req.ID > domain.MaxStoryID { + return nil, "", storyIDInvalidErr() + } + if err := validateStoryInteractionListLimit(req.Limit); err != nil { + return nil, "", err + } + var reaction *domain.MessageReaction + if inputReaction, ok := req.GetReaction(); ok { + parsed, err := domainStoryReactionFromTL(inputReaction) + if err != nil { + return nil, "", err + } + reaction = parsed + } + offset, _ := req.GetOffset() + if err := domain.ValidateStoryReactionInteractionOffset(offset, req.GetForwardsFirst()); err != nil { + return nil, "", storyErr(err) + } + if err := validateStoriesDirectInputPeer(req.Peer); err != nil { + return nil, "", err + } + return reaction, offset, nil +} + +func (r *Router) requireChannelStoryInteractionsAdmin(ctx context.Context, userID, channelID int64) error { + if userID == 0 || channelID == 0 || r.deps.Channels == nil { + return channelInvalidErr(domain.ErrChannelInvalid) + } + member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, userID) + if err != nil { + return channelInvalidErr(err) + } + if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + return nil + } + return channelInvalidErr(domain.ErrChannelAdminRequired) +} + +func (r *Router) onStoriesTogglePeerStoriesHidden(ctx context.Context, req *tg.StoriesTogglePeerStoriesHiddenRequest) (bool, error) { + if err := validateStoriesTogglePeerStoriesHiddenRequest(req); err != nil { + return false, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return false, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return false, err + } + if r.deps.Stories != nil && userID != 0 { + if err := r.deps.Stories.TogglePeerStoriesHidden(ctx, userID, peer, req.Hidden); err != nil { + return false, storyErr(err) + } + } + r.invalidateStoryProjectionCache(userID, peer) + return true, nil +} + +func validateStoriesTogglePeerStoriesHiddenRequest(req *tg.StoriesTogglePeerStoriesHiddenRequest) error { + if req == nil { + return inputRequestInvalidErr() + } + return validateStoriesDirectInputPeer(req.Peer) +} + +func (r *Router) onStoriesSendReaction(ctx context.Context, req *tg.StoriesSendReactionRequest) (tg.UpdatesClass, error) { + reaction, err := validateStoriesSendReactionRequest(req) + if err != nil { + return nil, err + } + userID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer) + if err != nil { + return nil, err + } + now := int(r.clock.Now().Unix()) + if r.deps.Stories == nil || userID == 0 { + return r.tgStoryReactionUpdates(ctx, userID, peer, req.StoryID, req.Reaction, now), nil + } + res, err := r.deps.Stories.SendReaction(ctx, userID, peer, req.StoryID, reaction, now) + if err != nil { + return nil, storyErr(err) + } + if res.Changed { + // 反应改变了该故事的 reaction 计数(所有 viewer 可见)与本 viewer 的 SentReaction, + // 必须失效该 peer 的故事投影缓存,否则 getPinnedStories 会命中陈旧页(陈旧 SentReaction/计数)。 + r.invalidateStoryProjectionCacheForPeer(peer) + } + if reaction != nil && res.Changed { + if err := r.recordMessageReactionUse(ctx, userID, []domain.MessageReaction{*reaction}, req.GetAddToRecent(), now); err != nil { + return nil, internalErr() + } + } + if res.Changed && r.deps.Updates != nil { + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + if _, _, err := r.deps.Updates.RecordSentStoryReaction(ctx, authKeyID, userID, res, sessionID); err != nil { + return nil, internalErr() + } + if ownerUserID, ok := ownerStoryReactionNotificationUserID(res, userID); ok && res.Reaction != nil { + event, _, err := r.deps.Updates.RecordNewStoryReaction(ctx, [8]byte{}, ownerUserID, res, 0) + if err != nil { + return nil, internalErr() + } + if updates := r.BuildOutboxUpdates(ctx, []OutboxUpdateRequest{{ + TargetUserID: ownerUserID, + Event: event, + }}); len(updates) == 1 && updates[0] != nil { + r.pushUserUpdatesIfNoReliableDispatch(ctx, ownerUserID, updates[0]) + } + } + } + return r.tgStoryReactionUpdates(ctx, userID, peer, req.StoryID, req.Reaction, now), nil +} + +func validateStoriesSendReactionRequest(req *tg.StoriesSendReactionRequest) (*domain.MessageReaction, error) { + if req == nil { + return nil, inputRequestInvalidErr() + } + if req.StoryID <= 0 || req.StoryID > domain.MaxStoryID { + return nil, storyIDInvalidErr() + } + reaction, err := domainStoryReactionFromTL(req.Reaction) + if err != nil { + return nil, err + } + if err := validateStoriesDirectInputPeer(req.Peer); err != nil { + return nil, err + } + return reaction, nil +} + +func ownerStoryReactionNotificationUserID(res domain.StoryReactionResult, viewerUserID int64) (int64, bool) { + owner := res.Story.Owner + if owner.ID == 0 { + owner = res.Peer + } + if owner.Type != domain.PeerTypeUser || owner.ID == 0 || owner.ID == viewerUserID { + return 0, false + } + return owner.ID, true +} + +type storyVisibility struct { + Public bool + CloseFriends bool + Contacts bool + SelectedContacts bool + Rules []domain.PrivacyRule + AllowUserIDs []int64 + DisallowUserIDs []int64 +} + +func (r *Router) storyVisibilityFromInputPrivacyRules(ctx context.Context, userID int64, rules []tg.InputPrivacyRuleClass) (storyVisibility, error) { + domainRules, err := r.domainPrivacyRulesFromInput(ctx, userID, rules) + if err != nil { + return storyVisibility{}, err + } + return storyVisibilityFromDomainPrivacyRules(domainRules), nil +} + +func storyVisibilityFromDomainPrivacyRules(rules []domain.PrivacyRule) storyVisibility { + out := storyVisibility{Rules: cloneDomainPrivacyRules(rules)} + for i, rule := range rules { + if i == 0 { + switch rule.Kind { + case domain.PrivacyRuleAllowAll: + out.Public = true + case domain.PrivacyRuleAllowContacts: + out.Contacts = true + case domain.PrivacyRuleAllowUsers: + out.SelectedContacts = true + case domain.PrivacyRuleAllowCloseFriends: + out.CloseFriends = true + } + } + switch rule.Kind { + case domain.PrivacyRuleAllowUsers: + out.AllowUserIDs = appendUniquePositiveInt64(out.AllowUserIDs, rule.UserIDs...) + case domain.PrivacyRuleDisallowUsers: + out.DisallowUserIDs = appendUniquePositiveInt64(out.DisallowUserIDs, rule.UserIDs...) + case domain.PrivacyRuleDisallowAll: + out.Public = false + } + } + return out +} + +func cloneDomainPrivacyRules(in []domain.PrivacyRule) []domain.PrivacyRule { + if len(in) == 0 { + return nil + } + out := make([]domain.PrivacyRule, len(in)) + for i, rule := range in { + out[i] = rule + out[i].UserIDs = append([]int64(nil), rule.UserIDs...) + out[i].ChatIDs = append([]int64(nil), rule.ChatIDs...) + } + return out +} + +func appendUniquePositiveInt64(base []int64, ids ...int64) []int64 { + seen := make(map[int64]struct{}, len(base)+len(ids)) + out := make([]int64, 0, len(base)+len(ids)) + for _, id := range base { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func (r *Router) resolveStoryInputMedia(ctx context.Context, userID int64, input tg.InputMediaClass) (*domain.MessageMedia, error) { + if err := validateStoryInputMediaClass(input); err != nil { + return nil, err + } + media, err := r.resolveInputMedia(ctx, userID, input) + if err != nil { + return nil, err + } + if media == nil { + return nil, mediaEmptyErr() + } + switch media.Kind { + case domain.MessageMediaKindPhoto, domain.MessageMediaKindDocument: + return media, nil + default: + return nil, mediaTypeInvalidErr() + } +} + +func validateStoryInputMediaClass(input tg.InputMediaClass) error { + switch typed := input.(type) { + case nil: + return mediaEmptyErr() + case *tg.InputMediaEmpty: + return mediaEmptyErr() + case *tg.InputMediaUploadedPhoto: + if typed == nil { + return mediaEmptyErr() + } + if typed.File == nil { + return mediaInvalidErr() + } + case *tg.InputMediaUploadedDocument: + if typed == nil { + return mediaEmptyErr() + } + if typed.File == nil { + return mediaInvalidErr() + } + case *tg.InputMediaPhoto: + if typed == nil { + return mediaEmptyErr() + } + if _, ok := inputPhotoID(typed.ID); !ok { + return photoInvalidErr() + } + case *tg.InputMediaDocument: + if typed == nil { + return mediaEmptyErr() + } + if _, ok := inputDocumentCandidateIDs(typed.ID); !ok { + return mediaInvalidErr() + } + default: + return mediaTypeInvalidErr() + } + return nil +} + +func validateStoryIDSlice(ids []int) error { + if len(ids) > domain.MaxStoryIDs { + return storyIDInvalidErr() + } + for _, id := range ids { + if id <= 0 || id > domain.MaxStoryID { + return storyIDInvalidErr() + } + } + return nil +} + +func validateStoryPageBounds(offsetID, limit int) error { + if offsetID < -1 || offsetID > domain.MaxStoryID { + return storyIDInvalidErr() + } + if limit < 0 || limit > domain.MaxStoryListLimit { + return limitInvalidErr() + } + return nil +} + +func validateStoryInteractionListLimit(limit int) error { + if limit < 0 || limit > domain.MaxStoryListLimit { + return limitInvalidErr() + } + return nil +} + +func validateStoryAlbumID(id int) error { + if id <= 0 || id > domain.MaxStoryID { + return inputRequestInvalidErr() + } + return nil +} + +func validateStoryAlbumIDSlice(ids []int) error { + if len(ids) > domain.MaxStoryIDs { + return limitInvalidErr() + } + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if err := validateStoryAlbumID(id); err != nil { + return err + } + if _, ok := seen[id]; ok { + return inputRequestInvalidErr() + } + seen[id] = struct{}{} + } + return nil +} + +func validateStoryAlbumStoryIDSlice(ids []int) error { + if err := validateStoryIDSlice(ids); err != nil { + return err + } + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + return inputRequestInvalidErr() + } + seen[id] = struct{}{} + } + return nil +} + +func validateStoryAlbumUpdateStoryIDSlice(ids []int) error { + if len(ids) == 0 { + return inputRequestInvalidErr() + } + return validateStoryAlbumStoryIDSlice(ids) +} + +func validateStoryAlbumTitle(title string) error { + if strings.TrimSpace(title) == "" { + return inputRequestInvalidErr() + } + if utf8.RuneCountInString(title) > maxStoryAlbumTitleLength { + return limitInvalidErr() + } + return nil +} + +func (r *Router) storyAlbumWriteScope(ctx context.Context, input tg.InputPeerClass) (int64, domain.Peer, error) { + userID, _, err := r.currentUserID(ctx) + if err != nil { + return 0, domain.Peer{}, internalErr() + } + peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, input) + if err != nil { + return 0, domain.Peer{}, err + } + switch peer.Type { + case domain.PeerTypeUser: + if userID != 0 && peer.ID == userID { + return userID, peer, nil + } + return 0, domain.Peer{}, peerIDInvalidErr() + case domain.PeerTypeChannel: + if err := r.requireChannelStoryAlbumAdmin(ctx, userID, peer.ID); err != nil { + return 0, domain.Peer{}, err + } + return userID, peer, nil + default: + return 0, domain.Peer{}, peerIDInvalidErr() + } +} + +func (r *Router) requireChannelStoryAlbumAdmin(ctx context.Context, userID, channelID int64) error { + if userID == 0 || channelID == 0 || r.deps.Channels == nil { + return channelInvalidErr(domain.ErrChannelInvalid) + } + member, err := r.deps.Channels.GetParticipant(ctx, userID, channelID, userID) + if err != nil { + return channelInvalidErr(err) + } + if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditStories) { + return nil + } + return channelInvalidErr(domain.ErrChannelAdminRequired) +} + +func uniqueStoryIDs(ids []int) []int { + if len(ids) == 0 { + return nil + } + seen := make(map[int]struct{}, len(ids)) + out := make([]int, 0, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func storyExportLink(peer domain.Peer, storyID int) string { + return fmt.Sprintf("https://telesrv.local/story/%s/%d/%d", peer.Type, peer.ID, storyID) +} + +func (r *Router) recordStoryChange(ctx context.Context, userID int64, story domain.Story) error { + if r.deps.Updates == nil { + return nil + } + authKeyID, _ := AuthKeyIDFrom(ctx) + sessionID, _ := SessionIDFrom(ctx) + if _, _, err := r.deps.Updates.RecordStory(ctx, authKeyID, userID, story, sessionID); err != nil { + return internalErr() + } + return nil +} + +func tgStoryChangeUpdates(peer domain.Peer, story domain.Story, randomID int64, includeStoryID bool, date int) tg.UpdatesClass { + updates := make([]tg.UpdateClass, 0, 2) + if includeStoryID { + updates = append(updates, &tg.UpdateStoryID{ID: story.ID, RandomID: randomID}) + } + updates = append(updates, &tg.UpdateStory{Peer: tgPeer(peer), Story: tgStoryItem(story)}) + return &tg.Updates{ + Updates: updates, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: date, + Seq: 0, + } +} + +func tgStoryStealthModeUpdates(date int, past, future bool) tg.UpdatesClass { + mode := tg.StoriesStealthMode{} + if future { + mode.SetActiveUntilDate(date + storyStealthFuturePeriodSeconds) + } + if past || future { + mode.SetCooldownUntilDate(date + storyStealthCooldownSeconds) + } + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateStoriesStealthMode{StealthMode: mode}}, + Users: []tg.UserClass{}, + Chats: []tg.ChatClass{}, + Date: date, + Seq: 0, + } +} + +func domainStoryReactionFromTL(reaction tg.ReactionClass) (*domain.MessageReaction, error) { + if reactionClassNil(reaction) { + return nil, reactionInvalidErr() + } + if empty, ok := reaction.(*tg.ReactionEmpty); ok && empty != nil { + return nil, nil + } + out, err := domainStoryReactionValueFromTL(reaction) + if err != nil { + return nil, err + } + return &out, nil +} + +func tgStoryReactionUpdates(peer domain.Peer, storyID int, reaction tg.ReactionClass, date int) tg.UpdatesClass { + if reaction == nil { + reaction = &tg.ReactionEmpty{} + } + return &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateSentStoryReaction{Peer: tgPeer(peer), StoryID: storyID, Reaction: reaction}}, + Date: date, + Seq: 0, + } +} + +func tgEmptyStoriesStoryViews(count int) *tg.StoriesStoryViews { + if count < 0 { + count = 0 + } + views := make([]tg.StoryViews, count) + return &tg.StoriesStoryViews{Views: views, Users: []tg.UserClass{}} +} + +func (r *Router) tgStoriesStoryViewsForIDs(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, stories []domain.Story) *tg.StoriesStoryViews { + byID := make(map[int]domain.Story, len(stories)) + recentViewerIDs := make([]int64, 0) + for _, story := range stories { + if story.Owner != peer || story.ID <= 0 || story.ID > domain.MaxStoryID { + continue + } + byID[story.ID] = story + recentViewerIDs = append(recentViewerIDs, storyViewsForCounterResponse(viewerUserID, story).RecentViewers...) + } + views := make([]tg.StoryViews, 0, len(ids)) + for _, id := range ids { + story, ok := byID[id] + if !ok { + views = append(views, tg.StoryViews{}) + continue + } + views = append(views, tgStoryViews(storyViewsForCounterResponse(viewerUserID, story))) + } + return &tg.StoriesStoryViews{ + Views: views, + Users: tgUsersForViewer(viewerUserID, r.domainUsersForIDs(ctx, viewerUserID, uniquePeerIDs(recentViewerIDs))), + } +} + +func storyViewsForCounterResponse(viewerUserID int64, story domain.Story) domain.StoryViews { + views := story.Views + if !story.Owner.IsSelfUser(viewerUserID) { + views.HasViewers = false + views.RecentViewers = nil + } + return views +} + +func (r *Router) addStoryMessageForwardCounts(ctx context.Context, viewerUserID int64, stories []domain.Story) { + if r.deps.Channels == nil || viewerUserID == 0 { + return + } + for i := range stories { + count, err := r.storyMessageForwardCount(ctx, viewerUserID, stories[i].Owner, stories[i].ID) + if err != nil { + continue + } + stories[i].Views.ForwardsCount += count + } +} + +func (r *Router) withStoryMessageForwardViews(ctx context.Context, viewerUserID int64, list domain.StoryViewList, req domain.StoryMessageForwardListRequest) domain.StoryViewList { + forwards := r.storyMessageForwardPage(ctx, viewerUserID, req) + if forwards.Count == 0 && len(forwards.Forwards) == 0 { + return list + } + hasMore := storyInteractionSourcesHaveMore(len(list.Views), len(forwards.Forwards), req.Limit, list.NextOffset != "" || forwards.NextOffset != "") + list.Count += forwards.Count + list.ForwardsCount += forwards.Count + list.Views = mergeStoryInteractionViews(list.Views, forwards.Forwards, req.Limit, req.ReactionsFirst, req.ForwardsFirst) + list.NextOffset = nextStoryInteractionOffset(list.Views, req.Limit, req.ReactionsFirst, req.ForwardsFirst, hasMore) + return list +} + +func (r *Router) withStoryMessageForwardReactions(ctx context.Context, viewerUserID int64, list domain.StoryReactionList, req domain.StoryMessageForwardListRequest) domain.StoryReactionList { + forwards := r.storyMessageForwardPage(ctx, viewerUserID, req) + if forwards.Count == 0 && len(forwards.Forwards) == 0 { + return list + } + hasMore := storyInteractionSourcesHaveMore(len(list.Reactions), len(forwards.Forwards), req.Limit, list.NextOffset != "" || forwards.NextOffset != "") + list.Count += forwards.Count + list.Reactions = mergeStoryInteractionViews(list.Reactions, forwards.Forwards, req.Limit, false, req.ForwardsFirst) + list.NextOffset = nextStoryInteractionOffset(list.Reactions, req.Limit, false, req.ForwardsFirst, hasMore) + return list +} + +func (r *Router) storyMessageForwardPage(ctx context.Context, viewerUserID int64, req domain.StoryMessageForwardListRequest) domain.StoryMessageForwardList { + if r.deps.Channels == nil || viewerUserID == 0 { + return domain.StoryMessageForwardList{} + } + req.ViewerUserID = viewerUserID + if req.Limit <= 0 || req.Limit > domain.MaxStoryInteractionListLimit { + req.Limit = domain.MaxStoryInteractionListLimit + } + list, err := r.deps.Channels.ListStoryMessageForwards(ctx, viewerUserID, req) + if err != nil { + return domain.StoryMessageForwardList{} + } + return list +} + +func (r *Router) storyMessageForwardCount(ctx context.Context, viewerUserID int64, owner domain.Peer, storyID int) (int, error) { + list, err := r.deps.Channels.ListStoryMessageForwards(ctx, viewerUserID, domain.StoryMessageForwardListRequest{ + ViewerUserID: viewerUserID, + Owner: owner, + StoryID: storyID, + Limit: 1, + }) + if err != nil { + return 0, err + } + return list.Count, nil +} + +func storyInteractionSourcesHaveMore(leftLen, rightLen, limit int, sourceHasMore bool) bool { + if limit <= 0 || limit > domain.MaxStoryInteractionListLimit { + limit = domain.MaxStoryInteractionListLimit + } + return sourceHasMore || leftLen+rightLen > limit +} + +func mergeStoryInteractionViews(left, right []domain.StoryView, limit int, reactionsFirst, forwardsFirst bool) []domain.StoryView { + if limit <= 0 || limit > domain.MaxStoryInteractionListLimit { + limit = domain.MaxStoryInteractionListLimit + } + merged := make([]domain.StoryView, 0, len(left)+len(right)) + merged = append(merged, left...) + merged = append(merged, right...) + sort.Slice(merged, func(i, j int) bool { + return storyInteractionLess(merged[i], merged[j], reactionsFirst, forwardsFirst) + }) + if len(merged) > limit { + return merged[:limit] + } + return merged +} + +func nextStoryInteractionOffset(views []domain.StoryView, limit int, reactionsFirst, forwardsFirst bool, hasMore bool) string { + if limit <= 0 || limit > domain.MaxStoryInteractionListLimit { + limit = domain.MaxStoryInteractionListLimit + } + if !hasMore || len(views) == 0 || len(views) < limit { + return "" + } + return formatStoryInteractionOffset(views[len(views)-1], reactionsFirst, forwardsFirst) +} + +func storyInteractionLess(a, b domain.StoryView, reactionsFirst, forwardsFirst bool) bool { + ga := storyInteractionGroup(a, reactionsFirst, forwardsFirst) + gb := storyInteractionGroup(b, reactionsFirst, forwardsFirst) + if ga != gb { + return ga < gb + } + if a.Date != b.Date { + return a.Date > b.Date + } + ak, aid := storyInteractionCursorKey(a) + bk, bid := storyInteractionCursorKey(b) + if ak != bk { + return ak > bk + } + return aid > bid +} + +func storyInteractionGroup(view domain.StoryView, reactionsFirst, forwardsFirst bool) int { + if forwardsFirst { + if view.Repost != nil || view.PublicForward != nil { + return 0 + } + return 1 + } + if reactionsFirst && view.Reaction == nil && view.Repost == nil && view.PublicForward == nil { + return 1 + } + return 0 +} + +func storyInteractionCursorKey(view domain.StoryView) (int64, int) { + if view.PublicForward != nil { + return -view.PublicForward.Message.ChannelID, view.PublicForward.Message.ID + } + if view.Repost != nil { + if view.Repost.Owner.Type == domain.PeerTypeChannel { + return -view.Repost.Owner.ID, 0 + } + return view.Repost.Owner.ID, 0 + } + return view.ViewerID, 0 +} + +func formatStoryInteractionOffset(view domain.StoryView, reactionsFirst, forwardsFirst bool) string { + group := storyInteractionGroup(view, reactionsFirst, forwardsFirst) + key, messageID := storyInteractionCursorKey(view) + out := strconv.Itoa(group) + ":" + strconv.Itoa(view.Date) + ":" + strconv.FormatInt(key, 10) + if messageID > 0 { + out += ":" + strconv.Itoa(messageID) + } + return out +} + +func storyErr(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, domain.ErrStoryIDInvalid): + return storyIDInvalidErr() + case errors.Is(err, domain.ErrStoryPeerInvalid): + return peerIDInvalidErr() + case errors.Is(err, domain.ErrStoryNotFound): + return storyIDInvalidErr() + case errors.Is(err, domain.ErrStoryNotModified): + return storyNotModifiedErr() + case errors.Is(err, domain.ErrStoryOffsetInvalid): + return offsetInvalidErr() + case errors.Is(err, domain.ErrStoryPeriodInvalid): + return storyPeriodInvalidErr() + case errors.Is(err, domain.ErrChannelInvalid), + errors.Is(err, domain.ErrChannelPrivate), + errors.Is(err, domain.ErrChannelUserBanned), + errors.Is(err, domain.ErrChannelWriteForbidden), + errors.Is(err, domain.ErrChannelAdminRequired): + return channelInvalidErr(err) + default: + return internalErr() + } } diff --git a/internal/rpc/stories_rpc_test.go b/internal/rpc/stories_rpc_test.go new file mode 100644 index 00000000..ba083e11 --- /dev/null +++ b/internal/rpc/stories_rpc_test.go @@ -0,0 +1,8369 @@ +package rpc + +import ( + "context" + "math" + "strconv" + "strings" + "testing" + "time" + + "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appcontacts "telesrv/internal/app/contacts" + appprivacy "telesrv/internal/app/privacy" + appstories "telesrv/internal/app/stories" + appupdates "telesrv/internal/app/updates" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestStoriesGetPeerStoriesReturnsStoredStory(t *testing.T) { + ctx := context.Background() + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + Caption: "hello story", + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{Stories: appstories.NewService(storyStore)}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + got, err := r.onStoriesGetPeerStories(WithUserID(ctx, 1000000002), &tg.InputPeerUser{UserID: owner.ID}) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + if got.Stories.Peer.(*tg.PeerUser).UserID != owner.ID || len(got.Stories.Stories) != 1 { + t.Fatalf("peer stories = %+v, want one story for owner", got.Stories) + } + item, ok := got.Stories.Stories[0].(*tg.StoryItem) + if !ok { + t.Fatalf("story item = %T, want *tg.StoryItem", got.Stories.Stories[0]) + } + if item.ID != 1 || item.Caption != "hello story" { + t.Fatalf("story item = %+v, want id 1 caption", item) + } +} + +func TestStoriesGetAllStoriesStateNotModifiedAndBounds(t *testing.T) { + ctx := context.Background() + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + Caption: "first story", + }}); err != nil { + t.Fatalf("upsert first story: %v", err) + } + r := New(Config{}, Deps{Stories: appstories.NewService(storyStore)}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, 1000000002) + + if _, err := r.onStoriesGetAllStories(ctx, nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("nil get all stories without user ctx err = %v, want INPUT_REQUEST_INVALID", err) + } + + firstClass, err := r.onStoriesGetAllStories(reqCtx, &tg.StoriesGetAllStoriesRequest{}) + if err != nil { + t.Fatalf("get all stories first: %v", err) + } + first, ok := firstClass.(*tg.StoriesAllStories) + if !ok { + t.Fatalf("first all stories = %T, want stories.allStories", firstClass) + } + if first.State == "" || len(first.PeerStories) != 1 || len(first.PeerStories[0].Stories) != 1 { + t.Fatalf("first all stories = %+v, want one peer story and non-empty state", first) + } + + sameReq := &tg.StoriesGetAllStoriesRequest{} + sameReq.SetState(first.State) + sameClass, err := r.onStoriesGetAllStories(reqCtx, sameReq) + if err != nil { + t.Fatalf("get all stories same state: %v", err) + } + notModified, ok := sameClass.(*tg.StoriesAllStoriesNotModified) + if !ok || notModified.State != first.State { + t.Fatalf("same state response = %T %+v, want notModified with original state", sameClass, sameClass) + } + + readIDs, err := r.onStoriesReadStories(reqCtx, &tg.StoriesReadStoriesRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID}, + MaxID: 1, + }) + if err != nil { + t.Fatalf("read stories: %v", err) + } + if len(readIDs) != 1 || readIDs[0] != 1 { + t.Fatalf("read stories ids = %v, want [1]", readIDs) + } + readChangedClass, err := r.onStoriesGetAllStories(reqCtx, sameReq) + if err != nil { + t.Fatalf("get all stories after read boundary change: %v", err) + } + readChanged, ok := readChangedClass.(*tg.StoriesAllStories) + if !ok { + t.Fatalf("read boundary response = %T, want stories.allStories", readChangedClass) + } + if readChanged.State == "" || readChanged.State == first.State || len(readChanged.PeerStories) != 1 { + t.Fatalf("read boundary all stories = %+v, want one peer story and new state", readChanged) + } + if readChanged.PeerStories[0].MaxReadID != 1 { + t.Fatalf("read boundary max_read_id = %d, want 1", readChanged.PeerStories[0].MaxReadID) + } + sameReq.SetState(readChanged.State) + readSameClass, err := r.onStoriesGetAllStories(reqCtx, sameReq) + if err != nil { + t.Fatalf("get all stories same read state: %v", err) + } + readNotModified, ok := readSameClass.(*tg.StoriesAllStoriesNotModified) + if !ok || readNotModified.State != readChanged.State { + t.Fatalf("same read state response = %T %+v, want notModified with read state", readSameClass, readSameClass) + } + + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 2, + Date: 1700000001, + ExpireDate: 1700003600, + Public: true, + Caption: "second story", + }}); err != nil { + t.Fatalf("upsert second story: %v", err) + } + changedClass, err := r.onStoriesGetAllStories(reqCtx, sameReq) + if err != nil { + t.Fatalf("get all stories changed state: %v", err) + } + changed, ok := changedClass.(*tg.StoriesAllStories) + if !ok { + t.Fatalf("changed state response = %T, want stories.allStories", changedClass) + } + if changed.State == "" || changed.State == readChanged.State || len(changed.PeerStories) != 1 || len(changed.PeerStories[0].Stories) != 2 { + t.Fatalf("changed all stories = %+v, want two stories and new state", changed) + } + + nextWithoutState := &tg.StoriesGetAllStoriesRequest{} + nextWithoutState.SetNext(true) + nextEmptyState := &tg.StoriesGetAllStoriesRequest{} + nextEmptyState.SetNext(true) + nextEmptyState.SetState("") + emptyState := &tg.StoriesGetAllStoriesRequest{} + emptyState.SetState("") + unknownState := &tg.StoriesGetAllStoriesRequest{} + unknownState.SetState("bogus") + cursorWithoutNext := &tg.StoriesGetAllStoriesRequest{} + cursorWithoutNext.SetState("tsc1:0:1700003000:user:1000000001") + malformedComplete := &tg.StoriesGetAllStoriesRequest{} + malformedComplete.SetState("ts1:0:not-a-count:0000000000000000") + hiddenMismatchComplete := &tg.StoriesGetAllStoriesRequest{} + hiddenMismatchComplete.SetHidden(true) + hiddenMismatchComplete.SetState("ts1:0:0:0000000000000000") + oversizedState := &tg.StoriesGetAllStoriesRequest{} + oversizedState.SetState(strings.Repeat("x", maxStoryAllStoriesStateLength+1)) + for _, tc := range []struct { + name string + req *tg.StoriesGetAllStoriesRequest + }{ + {name: "next_without_state", req: nextWithoutState}, + {name: "next_empty_state", req: nextEmptyState}, + {name: "empty_state", req: emptyState}, + {name: "unknown_state", req: unknownState}, + {name: "cursor_without_next", req: cursorWithoutNext}, + {name: "malformed_complete", req: malformedComplete}, + {name: "hidden_mismatch_complete", req: hiddenMismatchComplete}, + {name: "oversized_state", req: oversizedState}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onStoriesGetAllStories(ctx, tc.req); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("err = %v, want OFFSET_INVALID before auth/session", err) + } + }) + } +} + +func TestStoriesGetAllStoriesPaginatesByPeerState(t *testing.T) { + ctx := context.Background() + storyStore := memory.NewStoryStore() + for i := 0; i < domain.MaxStoryListLimit+1; i++ { + owner := domain.Peer{Type: domain.PeerTypeUser, ID: int64(1000001000 + i)} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700003000 - i, + ExpireDate: 1700009000, + Public: true, + Caption: "story " + strconv.Itoa(i), + }}); err != nil { + t.Fatalf("upsert story %d: %v", i, err) + } + } + firstOwner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000001000} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: firstOwner, + ID: 2, + Date: 1700002999, + ExpireDate: 1700009000, + Public: true, + Caption: "same peer older story", + }}); err != nil { + t.Fatalf("upsert same-peer story: %v", err) + } + r := New(Config{}, Deps{Stories: appstories.NewService(storyStore)}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700004000, 0)}) + reqCtx := WithUserID(ctx, 2000000001) + + firstClass, err := r.onStoriesGetAllStories(reqCtx, &tg.StoriesGetAllStoriesRequest{}) + if err != nil { + t.Fatalf("get all stories first page: %v", err) + } + first, ok := firstClass.(*tg.StoriesAllStories) + if !ok { + t.Fatalf("first page = %T, want stories.allStories", firstClass) + } + if !first.HasMore || first.Count != domain.MaxStoryListLimit+1 || len(first.PeerStories) != domain.MaxStoryListLimit { + t.Fatalf("first page count/more/peers = count %d more %v peers %d", first.Count, first.HasMore, len(first.PeerStories)) + } + if !strings.HasPrefix(first.State, "tsc1:") { + t.Fatalf("first page state = %q, want cursor state", first.State) + } + if len(first.PeerStories[0].Stories) != 2 { + t.Fatalf("first peer stories = %d, want same peer kept on one page", len(first.PeerStories[0].Stories)) + } + + nextReq := &tg.StoriesGetAllStoriesRequest{} + nextReq.SetState(first.State) + nextReq.SetNext(true) + nextClass, err := r.onStoriesGetAllStories(reqCtx, nextReq) + if err != nil { + t.Fatalf("get all stories next page: %v", err) + } + next, ok := nextClass.(*tg.StoriesAllStories) + if !ok { + t.Fatalf("next page = %T, want stories.allStories", nextClass) + } + if next.HasMore || next.Count != domain.MaxStoryListLimit+1 || len(next.PeerStories) != 1 { + t.Fatalf("next page count/more/peers = count %d more %v peers %d", next.Count, next.HasMore, len(next.PeerStories)) + } + if !strings.HasPrefix(next.State, "ts1:") { + t.Fatalf("next page state = %q, want final snapshot state", next.State) + } + + finalCheck := &tg.StoriesGetAllStoriesRequest{} + finalCheck.SetState(next.State) + finalCheckClass, err := r.onStoriesGetAllStories(reqCtx, finalCheck) + if err != nil { + t.Fatalf("get all stories final state check: %v", err) + } + if notModified, ok := finalCheckClass.(*tg.StoriesAllStoriesNotModified); !ok || notModified.State != next.State { + t.Fatalf("final state check = %T %+v, want notModified with final state", finalCheckClass, finalCheckClass) + } + + newOwner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000009999} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: newOwner, + ID: 1, + Date: 1700004000, + ExpireDate: 1700009000, + Public: true, + }}); err != nil { + t.Fatalf("upsert changed story: %v", err) + } + changedClass, err := r.onStoriesGetAllStories(reqCtx, finalCheck) + if err != nil { + t.Fatalf("get all stories changed final state: %v", err) + } + changed, ok := changedClass.(*tg.StoriesAllStories) + if !ok || !changed.HasMore || changed.State == next.State { + t.Fatalf("changed final state response = %T %+v, want refreshed first page with new cursor state", changedClass, changedClass) + } + + wrongHidden := &tg.StoriesGetAllStoriesRequest{} + wrongHidden.SetState(first.State) + wrongHidden.SetNext(true) + wrongHidden.SetHidden(true) + if _, err := r.onStoriesGetAllStories(ctx, wrongHidden); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("hidden/cursor mismatch err = %v, want OFFSET_INVALID", err) + } + finalAsCursor := &tg.StoriesGetAllStoriesRequest{} + finalAsCursor.SetState(next.State) + finalAsCursor.SetNext(true) + if _, err := r.onStoriesGetAllStories(ctx, finalAsCursor); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("final state as cursor err = %v, want OFFSET_INVALID", err) + } +} + +func TestStoriesTogglePeerStoriesHiddenProjectsAllStoriesPeerFlags(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + ownerUser, err := userStore.Create(ctx, domain.User{ + AccessHash: 12345, + Phone: "15550000101", + FirstName: "StoryOwner", + }) + if err != nil { + t.Fatalf("create owner user: %v", err) + } + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerUser.ID} + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700001000, + ExpireDate: 1700009000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{ + Stories: appstories.NewService(storyStore), + Users: appusers.NewService(userStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700002000, 0)}) + reqCtx := WithUserID(ctx, ownerUser.ID+100) + + firstClass, err := r.onStoriesGetAllStories(reqCtx, &tg.StoriesGetAllStoriesRequest{}) + if err != nil { + t.Fatalf("get normal stories: %v", err) + } + first := mustAllStories(t, firstClass) + if got := storiesHiddenForUser(t, first.Users, ownerUser.ID); got { + t.Fatalf("normal allStories user stories_hidden = true, want false") + } + + if _, err := r.onStoriesTogglePeerStoriesHidden(reqCtx, nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("nil toggle peer stories hidden err = %v, want INPUT_REQUEST_INVALID", err) + } + + ok, err := r.onStoriesTogglePeerStoriesHidden(reqCtx, &tg.StoriesTogglePeerStoriesHiddenRequest{ + Peer: &tg.InputPeerUser{UserID: ownerUser.ID}, + Hidden: true, + }) + if err != nil || !ok { + t.Fatalf("toggle hidden = %v, %v", ok, err) + } + hiddenReq := &tg.StoriesGetAllStoriesRequest{} + hiddenReq.SetHidden(true) + hiddenClass, err := r.onStoriesGetAllStories(reqCtx, hiddenReq) + if err != nil { + t.Fatalf("get hidden stories: %v", err) + } + hidden := mustAllStories(t, hiddenClass) + if len(hidden.PeerStories) != 1 { + t.Fatalf("hidden peer stories = %d, want 1", len(hidden.PeerStories)) + } + if got := storiesHiddenForUser(t, hidden.Users, ownerUser.ID); !got { + t.Fatalf("hidden allStories user stories_hidden = false, want true") + } + + staleReq := &tg.StoriesGetAllStoriesRequest{} + staleReq.SetState(first.State) + normalAfterHideClass, err := r.onStoriesGetAllStories(reqCtx, staleReq) + if err != nil { + t.Fatalf("get normal stories with stale state: %v", err) + } + normalAfterHide := mustAllStories(t, normalAfterHideClass) + if normalAfterHide.State == first.State || len(normalAfterHide.PeerStories) != 0 { + t.Fatalf("normal after hide = state %q peers %d, want changed empty list", normalAfterHide.State, len(normalAfterHide.PeerStories)) + } + + ok, err = r.onStoriesTogglePeerStoriesHidden(reqCtx, &tg.StoriesTogglePeerStoriesHiddenRequest{ + Peer: &tg.InputPeerUser{UserID: ownerUser.ID}, + Hidden: false, + }) + if err != nil || !ok { + t.Fatalf("toggle unhidden = %v, %v", ok, err) + } + unhiddenClass, err := r.onStoriesGetAllStories(reqCtx, &tg.StoriesGetAllStoriesRequest{}) + if err != nil { + t.Fatalf("get unhidden stories: %v", err) + } + unhidden := mustAllStories(t, unhiddenClass) + if len(unhidden.PeerStories) != 1 { + t.Fatalf("unhidden peer stories = %d, want 1", len(unhidden.PeerStories)) + } + if got := storiesHiddenForUser(t, unhidden.Users, ownerUser.ID); got { + t.Fatalf("unhidden allStories user stories_hidden = true, want false") + } +} + +func mustAllStories(t *testing.T, got tg.StoriesAllStoriesClass) *tg.StoriesAllStories { + t.Helper() + out, ok := got.(*tg.StoriesAllStories) + if !ok { + t.Fatalf("allStories response = %T, want *tg.StoriesAllStories", got) + } + return out +} + +func storiesHiddenForUser(t *testing.T, users []tg.UserClass, userID int64) bool { + t.Helper() + item := findUserClass(users, userID) + if item == nil { + t.Fatalf("user %d missing from companion users", userID) + } + user, ok := item.(*tg.User) + if !ok { + t.Fatalf("user %d companion = %T, want *tg.User", userID, item) + } + return user.StoriesHidden +} + +func TestStoriesReadStoriesRecordsDifferenceUpdate(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{1, 2, 3} + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 3, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{ + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + readCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, 1000000002), authKeyID), 77) + ids, err := r.onStoriesReadStories(readCtx, &tg.StoriesReadStoriesRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID}, + MaxID: 3, + }) + if err != nil { + t.Fatalf("read stories: %v", err) + } + if len(ids) != 1 || ids[0] != 3 { + t.Fatalf("read ids = %v, want [3]", ids) + } + diff, err := r.onUpdatesGetDifference(readCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference: %v", err) + } + got, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", diff) + } + if got.State.Pts != 1 || len(got.OtherUpdates) != 1 { + t.Fatalf("difference state/updates = pts %d updates %d, want pts 1 and read update", got.State.Pts, len(got.OtherUpdates)) + } + readUpdate, ok := got.OtherUpdates[0].(*tg.UpdateReadStories) + if !ok { + t.Fatalf("first update = %T, want *tg.UpdateReadStories", got.OtherUpdates[0]) + } + if readUpdate.MaxID != 3 { + t.Fatalf("read update max_id = %d, want 3", readUpdate.MaxID) + } + retryIDs, err := r.onStoriesReadStories(readCtx, &tg.StoriesReadStoriesRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID}, + MaxID: 3, + }) + if err != nil { + t.Fatalf("retry read stories: %v", err) + } + if len(retryIDs) != 1 || retryIDs[0] != 3 { + t.Fatalf("retry read ids = %v, want current boundary [3]", retryIDs) + } + lowerIDs, err := r.onStoriesReadStories(readCtx, &tg.StoriesReadStoriesRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID}, + MaxID: 2, + }) + if err != nil { + t.Fatalf("lower read stories: %v", err) + } + if len(lowerIDs) != 1 || lowerIDs[0] != 3 { + t.Fatalf("lower read ids = %v, want current boundary [3]", lowerIDs) + } + events, err := updateStore.ListAfter(ctx, 1000000002, 0, 10) + if err != nil { + t.Fatalf("list read story events: %v", err) + } + if len(events) != 1 { + t.Fatalf("read story events after retry/lower reads = %d, want only initial advancement", len(events)) + } +} + +func TestStoriesReadAndReactionEventsExcludeCurrentSession(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{1, 2, 7} + const sessionID int64 = 2277 + userStore := memory.NewUserStore() + ownerUser, err := userStore.Create(ctx, domain.User{AccessHash: 90011, Phone: "15550090011", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 90012, Phone: "15550090012", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + creator, err := userStore.Create(ctx, domain.User{AccessHash: 90013, Phone: "15550090013", FirstName: "Creator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "story reaction session channel", + Broadcast: true, + MemberUserIDs: []int64{viewer.ID}, + Date: 1700000210, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + storyStore := memory.NewStoryStore() + userPeer := domain.Peer{Type: domain.PeerTypeUser, ID: ownerUser.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: userPeer, + ID: 3, + Date: 1700000200, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert user story: %v", err) + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: channelPeer, + ID: 1, + Date: 1700000201, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + updates := &captureUpdates{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + Updates: updates, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000220, 0)}) + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, viewer.ID), authKeyID), sessionID) + + if ids, err := r.onStoriesReadStories(reqCtx, &tg.StoriesReadStoriesRequest{ + Peer: &tg.InputPeerUser{UserID: ownerUser.ID, AccessHash: ownerUser.AccessHash}, + MaxID: 3, + }); err != nil { + t.Fatalf("read stories: %v", err) + } else if len(ids) != 1 || ids[0] != 3 { + t.Fatalf("read ids = %v, want [3]", ids) + } + if updates.authKeyID != authKeyID || updates.userID != viewer.ID || updates.excludeSessionID != sessionID { + t.Fatalf("read captured update = auth %v user %d exclude_session %d, want auth %v user %d exclude_session %d", updates.authKeyID, updates.userID, updates.excludeSessionID, authKeyID, viewer.ID, sessionID) + } + if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventReadStories || updates.events[0].Peer != userPeer || updates.events[0].MaxID != 3 { + t.Fatalf("read captured events = %+v, want readStories max_id 3", updates.events) + } + + updates.events = nil + if _, err := r.onStoriesSendReaction(reqCtx, &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + StoryID: 1, + Reaction: &tg.ReactionEmoji{Emoticon: "🔥"}, + }); err != nil { + t.Fatalf("send channel story reaction: %v", err) + } + if updates.authKeyID != authKeyID || updates.userID != viewer.ID || updates.excludeSessionID != sessionID { + t.Fatalf("reaction captured update = auth %v user %d exclude_session %d, want auth %v user %d exclude_session %d", updates.authKeyID, updates.userID, updates.excludeSessionID, authKeyID, viewer.ID, sessionID) + } + if len(updates.events) != 1 || updates.events[0].Type != domain.UpdateEventSentStoryReaction || updates.events[0].Peer != channelPeer || updates.events[0].MaxID != 1 { + t.Fatalf("reaction captured events = %+v, want sentStoryReaction for channel story 1", updates.events) + } + if updates.events[0].Reaction == nil || updates.events[0].Reaction.Emoticon != "🔥" { + t.Fatalf("reaction captured event = %+v, want fire reaction", updates.events[0]) + } +} + +func TestStoriesReadStoriesClampsFutureMaxID(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{4, 5, 6} + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + for _, id := range []int{1, 2} { + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: id, + Date: 1700000000 + id, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %d: %v", id, err) + } + } + r := New(Config{}, Deps{ + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + readCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, 1000000002), authKeyID), 88) + ids, err := r.onStoriesReadStories(readCtx, &tg.StoriesReadStoriesRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID}, + MaxID: 99, + }) + if err != nil { + t.Fatalf("read future max id: %v", err) + } + if len(ids) != 1 || ids[0] != 2 { + t.Fatalf("read ids = %v, want [2]", ids) + } + diff, err := r.onUpdatesGetDifference(readCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference: %v", err) + } + got, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", diff) + } + if len(got.OtherUpdates) != 1 { + t.Fatalf("difference updates = %d, want one read update", len(got.OtherUpdates)) + } + readUpdate, ok := got.OtherUpdates[0].(*tg.UpdateReadStories) + if !ok { + t.Fatalf("first update = %T, want *tg.UpdateReadStories", got.OtherUpdates[0]) + } + if readUpdate.MaxID != 2 { + t.Fatalf("read update max_id = %d, want 2", readUpdate.MaxID) + } + + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 3, + Date: 1700000200, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story after read: %v", err) + } + peerStories, err := r.onStoriesGetPeerStories(readCtx, &tg.InputPeerUser{UserID: owner.ID}) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + if peerStories.Stories.MaxReadID != 2 || len(peerStories.Stories.Stories) != 3 { + t.Fatalf("peer stories = %+v, want max read 2 and three stories", peerStories.Stories) + } +} + +func TestStoriesReadStoriesInvisiblePeerDoesNotWriteDifference(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{4, 5, 7} + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + viewerID := int64(1000000002) + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000001, + ExpireDate: 1700003600, + }}); err != nil { + t.Fatalf("upsert invisible story: %v", err) + } + r := New(Config{}, Deps{ + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + readCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, viewerID), authKeyID), 89) + ids, err := r.onStoriesReadStories(readCtx, &tg.StoriesReadStoriesRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID}, + MaxID: 99, + }) + if err != nil { + t.Fatalf("read invisible stories: %v", err) + } + if len(ids) != 0 { + t.Fatalf("read invisible ids = %v, want empty no-op", ids) + } + events, err := updateStore.ListAfter(ctx, viewerID, 0, 10) + if err != nil { + t.Fatalf("list invisible read events: %v", err) + } + if len(events) != 0 { + t.Fatalf("read events after invisible story = %+v, want none", events) + } + + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 2, + Date: 1700000200, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert visible story after invisible read: %v", err) + } + peerStories, err := r.onStoriesGetPeerStories(readCtx, &tg.InputPeerUser{UserID: owner.ID}) + if err != nil { + t.Fatalf("get peer stories after invisible read: %v", err) + } + if peerStories.Stories.MaxReadID != 0 || len(peerStories.Stories.Stories) != 1 { + t.Fatalf("peer stories after invisible read = %+v, want unread visible story only", peerStories.Stories) + } + if item, ok := peerStories.Stories.Stories[0].(*tg.StoryItem); !ok || item.ID != 2 { + t.Fatalf("visible story after invisible read = %T %+v, want story 2", peerStories.Stories.Stories[0], peerStories.Stories.Stories[0]) + } +} + +func TestStoriesReadStoriesFallbackDoesNotConfirmFutureBoundary(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, 1000000001) + + if _, err := r.onStoriesReadStories(reqCtx, nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("nil read stories err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesReadStories(reqCtx, &tg.StoriesReadStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + MaxID: 0, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("zero read stories max id before peer err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesReadStories(reqCtx, &tg.StoriesReadStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + MaxID: domain.MaxStoryID + 1, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("oversized read stories max id before peer err = %v, want STORY_ID_INVALID", err) + } + + ids, err := r.onStoriesReadStories(reqCtx, &tg.StoriesReadStoriesRequest{ + Peer: &tg.InputPeerUser{UserID: 1000000002}, + MaxID: 99, + }) + if err != nil { + t.Fatalf("read stories fallback: %v", err) + } + if len(ids) != 0 { + t.Fatalf("read stories fallback ids = %v, want no-op empty vector", ids) + } +} + +func TestStoriesGetAllReadPeerStoriesReturnsStoredReadUpdates(t *testing.T) { + ctx := context.Background() + viewerID := int64(1000000001) + storyStore := memory.NewStoryStore() + userPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 1000000003} + if _, err := storyStore.MarkRead(ctx, viewerID, userPeer, 2, 1700000001); err != nil { + t.Fatalf("mark user read: %v", err) + } + if _, err := storyStore.MarkRead(ctx, viewerID, channelPeer, 5, 1700000002); err != nil { + t.Fatalf("mark channel read: %v", err) + } + if _, err := storyStore.MarkRead(ctx, 1000000099, userPeer, 9, 1700000003); err != nil { + t.Fatalf("mark other viewer read: %v", err) + } + r := New(Config{}, Deps{ + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + updates, err := r.onStoriesGetAllReadPeerStories(WithUserID(ctx, viewerID)) + if err != nil { + t.Fatalf("get all read peer stories: %v", err) + } + got, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + if got.Date != 1700000100 || got.Seq != 0 || len(got.Users) != 0 || len(got.Chats) != 0 { + t.Fatalf("updates shape = %+v, want date 1700000100 seq 0 empty users/chats", got) + } + readByPeer := map[string]int{} + for _, update := range got.Updates { + read, ok := update.(*tg.UpdateReadStories) + if !ok { + t.Fatalf("update = %T, want updateReadStories", update) + } + switch peer := read.Peer.(type) { + case *tg.PeerUser: + readByPeer["user"] = read.MaxID + if peer.UserID != userPeer.ID { + t.Fatalf("peer user id = %d, want %d", peer.UserID, userPeer.ID) + } + case *tg.PeerChannel: + readByPeer["channel"] = read.MaxID + if peer.ChannelID != channelPeer.ID { + t.Fatalf("peer channel id = %d, want %d", peer.ChannelID, channelPeer.ID) + } + default: + t.Fatalf("read peer = %T, want user/channel", read.Peer) + } + } + if len(readByPeer) != 2 || readByPeer["user"] != 2 || readByPeer["channel"] != 5 { + t.Fatalf("read updates = %+v, want user 2 channel 5", readByPeer) + } + + emptyRouter := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000200, 0)}) + empty, err := emptyRouter.onStoriesGetAllReadPeerStories(WithUserID(ctx, viewerID)) + if err != nil { + t.Fatalf("empty get all read peer stories: %v", err) + } + emptyUpdates, ok := empty.(*tg.Updates) + if !ok || emptyUpdates.Date != 1700000200 || len(emptyUpdates.Updates) != 0 || len(emptyUpdates.Users) != 0 || len(emptyUpdates.Chats) != 0 { + t.Fatalf("empty updates = %#v, want empty *tg.Updates with fixed date", empty) + } +} + +type readStateListStoryService struct { + StoriesService + states []domain.StoryReadState +} + +func (s readStateListStoryService) ListReadStates(context.Context, int64) ([]domain.StoryReadState, error) { + return append([]domain.StoryReadState(nil), s.states...), nil +} + +type storiesByIDStoryService struct { + StoriesService + list domain.StoryList +} + +func (s storiesByIDStoryService) GetStoriesByID(context.Context, int64, domain.Peer, []int, int) (domain.StoryList, error) { + return s.list, nil +} + +type incrementViewsStoryService struct { + StoriesService + viewerUserID int64 + peer domain.Peer + ids []int + calls int +} + +func (s *incrementViewsStoryService) IncrementViews(_ context.Context, viewerUserID int64, peer domain.Peer, ids []int, _ int) (int, error) { + s.viewerUserID = viewerUserID + s.peer = peer + s.ids = append([]int(nil), ids...) + s.calls++ + return 0, nil +} + +func TestStoriesGetAllReadPeerStoriesNormalizesReadSnapshot(t *testing.T) { + ctx := context.Background() + viewerID := int64(1000000001) + userPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 1000000003} + r := New(Config{}, Deps{ + Stories: readStateListStoryService{states: []domain.StoryReadState{ + {ViewerID: viewerID, Peer: userPeer, MaxReadID: 3, Date: 1700000001}, + {ViewerID: viewerID, Peer: userPeer, MaxReadID: 5, Date: 1700000002}, + {ViewerID: viewerID, Peer: userPeer, MaxReadID: 4, Date: 1700000009}, + {ViewerID: viewerID, Peer: channelPeer, MaxReadID: 7, Date: 1700000003}, + {ViewerID: viewerID + 1, Peer: userPeer, MaxReadID: 99, Date: 1700000004}, + {ViewerID: viewerID, Peer: domain.Peer{Type: domain.PeerTypeUser}, MaxReadID: 8, Date: 1700000005}, + {ViewerID: viewerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000004}, MaxReadID: 0, Date: 1700000006}, + {ViewerID: viewerID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000005}, MaxReadID: domain.MaxStoryID + 1, Date: 1700000007}, + }}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + updates, err := r.onStoriesGetAllReadPeerStories(WithUserID(ctx, viewerID)) + if err != nil { + t.Fatalf("get all read peer stories: %v", err) + } + got, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + readByPeer := map[string]int{} + for _, update := range got.Updates { + read, ok := update.(*tg.UpdateReadStories) + if !ok { + t.Fatalf("update = %T, want updateReadStories", update) + } + switch peer := read.Peer.(type) { + case *tg.PeerUser: + readByPeer["user:"+strconv.FormatInt(peer.UserID, 10)] = read.MaxID + case *tg.PeerChannel: + readByPeer["channel:"+strconv.FormatInt(peer.ChannelID, 10)] = read.MaxID + default: + t.Fatalf("read peer = %T, want user/channel", read.Peer) + } + } + if len(readByPeer) != 2 || readByPeer["user:1000000002"] != 5 || readByPeer["channel:1000000003"] != 7 { + t.Fatalf("read updates = %+v, want only current viewer max user/channel boundaries", readByPeer) + } +} + +func TestStoriesIncrementStoryViewsRejectsEmptyIDs(t *testing.T) { + ctx := context.Background() + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{Stories: appstories.NewService(storyStore)}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + _, err := r.onStoriesIncrementStoryViews(WithUserID(ctx, 1000000002), &tg.StoriesIncrementStoryViewsRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID}, + }) + if err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("empty increment story views err = %v, want STORY_ID_EMPTY", err) + } + + oversized := make([]int, domain.MaxStoryIDs+1) + for i := range oversized { + oversized[i] = 1 + } + for _, tc := range []struct { + name string + ids []int + want string + }{ + {name: "empty before peer", ids: nil, want: "STORY_ID_EMPTY"}, + {name: "invalid before peer", ids: []int{0}, want: "STORY_ID_INVALID"}, + {name: "oversized before peer", ids: oversized, want: "STORY_ID_INVALID"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := r.onStoriesIncrementStoryViews(WithUserID(ctx, 1000000002), &tg.StoriesIncrementStoryViewsRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: tc.ids, + }) + if err == nil || !tgerr.Is(err, tc.want) { + t.Fatalf("increment story views err = %v, want %s", err, tc.want) + } + }) + } +} + +func TestStoriesIncrementStoryViewsDedupesIDsBeforeCounters(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + ownerUser, err := userStore.Create(ctx, domain.User{AccessHash: 90101, Phone: "15550090101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 90102, Phone: "15550090102", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerUser.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + viewerCtx := WithUserID(ctx, viewer.ID) + peer := &tg.InputPeerUser{UserID: ownerUser.ID, AccessHash: ownerUser.AccessHash} + + ok, err := r.onStoriesIncrementStoryViews(viewerCtx, &tg.StoriesIncrementStoryViewsRequest{ + Peer: peer, + ID: []int{1, 1, 1}, + }) + if err != nil || !ok { + t.Fatalf("increment duplicate story views = %v, %v; want true nil", ok, err) + } + got, err := r.onStoriesGetStoriesViews(viewerCtx, &tg.StoriesGetStoriesViewsRequest{ + Peer: peer, + ID: []int{1}, + }) + if err != nil { + t.Fatalf("get story views after duplicate increment: %v", err) + } + if len(got.Views) != 1 || got.Views[0].ViewsCount != 1 { + t.Fatalf("story view counters after duplicate increment = %+v, want one view", got.Views) + } + if got.Views[0].HasViewers || len(got.Views[0].RecentViewers) != 0 || len(got.Users) != 0 { + t.Fatalf("non-owner story views = %+v users=%+v, want counters only", got.Views[0], got.Users) + } + + ownerViews, err := r.onStoriesGetStoryViewsList(WithUserID(ctx, ownerUser.ID), &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("get owner views list after duplicate increment: %v", err) + } + if ownerViews.Count != 1 || ownerViews.ViewsCount != 1 || len(ownerViews.Views) != 1 { + t.Fatalf("owner views after duplicate increment = %+v, want one durable viewer row", ownerViews) + } + view, ok := ownerViews.Views[0].(*tg.StoryView) + if !ok || view.UserID != viewer.ID { + t.Fatalf("owner view row = %T %+v, want viewer %d", ownerViews.Views[0], ownerViews.Views[0], viewer.ID) + } + if findUserClass(ownerViews.Users, viewer.ID) == nil { + t.Fatalf("owner views users = %+v, want viewer companion user", ownerViews.Users) + } + + if ok, err = r.onStoriesIncrementStoryViews(viewerCtx, &tg.StoriesIncrementStoryViewsRequest{ + Peer: peer, + ID: []int{1, 1}, + }); err != nil || !ok { + t.Fatalf("retry duplicate story views = %v, %v; want true nil", ok, err) + } + ownerViews, err = r.onStoriesGetStoryViewsList(WithUserID(ctx, ownerUser.ID), &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("get owner views list after duplicate retry: %v", err) + } + if ownerViews.Count != 1 || ownerViews.ViewsCount != 1 || len(ownerViews.Views) != 1 { + t.Fatalf("owner views after duplicate retry = %+v, want still one durable viewer row", ownerViews) + } +} + +func TestStoriesIncrementStoryViewsDedupesIDsBeforeService(t *testing.T) { + ctx := context.Background() + userID := int64(1000000001) + stories := &incrementViewsStoryService{} + r := New(Config{}, Deps{Stories: stories}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + ok, err := r.onStoriesIncrementStoryViews(WithUserID(ctx, userID), &tg.StoriesIncrementStoryViewsRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{3, 1, 3, 2, 1}, + }) + if err != nil || !ok { + t.Fatalf("increment duplicate story views = %v, %v; want true nil", ok, err) + } + if stories.calls != 1 { + t.Fatalf("increment service calls = %d, want 1", stories.calls) + } + if stories.viewerUserID != userID || stories.peer.Type != domain.PeerTypeUser || stories.peer.ID != userID { + t.Fatalf("increment service viewer/peer = user %d peer %+v, want self user %d", stories.viewerUserID, stories.peer, userID) + } + want := []int{3, 1, 2} + if len(stories.ids) != len(want) { + t.Fatalf("increment service ids = %v, want %v", stories.ids, want) + } + for i := range want { + if stories.ids[i] != want[i] { + t.Fatalf("increment service ids = %v, want first-seen dedupe %v", stories.ids, want) + } + } +} + +func TestStoriesGetPeerMaxIDsBoundsBeforeFallback(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + empty, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, 1000000001), nil) + if err != nil { + t.Fatalf("empty peer max ids: %v", err) + } + if len(empty) != 0 { + t.Fatalf("empty peer max ids length = %d, want 0", len(empty)) + } + + one, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, 1000000001), []tg.InputPeerClass{&tg.InputPeerSelf{}}) + if err != nil { + t.Fatalf("single peer max id fallback: %v", err) + } + if len(one) != 1 { + t.Fatalf("single peer max id fallback length = %d, want 1", len(one)) + } + + oversized := make([]tg.InputPeerClass, domain.MaxStoryIDs+1) + for i := range oversized { + oversized[i] = &tg.InputPeerSelf{} + } + if _, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, 1000000001), oversized); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("oversized peer max ids err = %v, want STORY_ID_INVALID", err) + } +} + +func TestStoriesGetPeerMaxIDsRepeatsDuplicatePeersInOrder(t *testing.T) { + ctx := context.Background() + ownerID := int64(1000000001) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID} + storyStore := memory.NewStoryStore() + for _, storyID := range []int{1, 3} { + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: storyID, + Date: 1700000000 + storyID, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %d: %v", storyID, err) + } + } + r := New(Config{}, Deps{ + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + got, err := r.onStoriesGetPeerMaxIDs(WithUserID(ctx, ownerID), []tg.InputPeerClass{ + &tg.InputPeerSelf{}, + &tg.InputPeerSelf{}, + }) + if err != nil { + t.Fatalf("get duplicate peer max ids: %v", err) + } + if len(got) != 2 { + t.Fatalf("recent stories length = %d, want one slot per requested peer", len(got)) + } + for i := range got { + if maxID, ok := got[i].GetMaxID(); !ok || maxID != 3 { + t.Fatalf("recent stories[%d].max_id = %d ok=%v, want 3 true", i, maxID, ok) + } + } +} + +func TestStoriesRecentAlignmentPreservesRequestSlots(t *testing.T) { + userPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000001} + missingPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + got := alignStoryRecentByPeer([]domain.Peer{userPeer, channelPeer, userPeer, missingPeer}, []domain.RecentStory{ + {Peer: channelPeer, MaxID: 5}, + {Peer: userPeer, MaxID: 3}, + {Peer: userPeer, MaxID: 4, Live: true}, + {Peer: domain.Peer{}, MaxID: 99}, + }) + if len(got) != 4 { + t.Fatalf("aligned recent length = %d, want one slot per requested peer", len(got)) + } + if got[0].Peer != userPeer || got[0].MaxID != 4 || !got[0].Live { + t.Fatalf("aligned recent[0] = %+v, want user max 4 live", got[0]) + } + if got[1].Peer != channelPeer || got[1].MaxID != 5 || got[1].Live { + t.Fatalf("aligned recent[1] = %+v, want channel max 5 non-live", got[1]) + } + if got[2].Peer != userPeer || got[2].MaxID != 4 || !got[2].Live { + t.Fatalf("aligned recent[2] = %+v, want repeated user max 4 live", got[2]) + } + if got[3].Peer != missingPeer || got[3].MaxID != 0 || got[3].Live { + t.Fatalf("aligned recent[3] = %+v, want missing peer zero recent", got[3]) + } +} + +func TestStoriesCanSendStoryFallbackRejectsNonOwnerPeers(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, 1000000001) + + self, err := r.onStoriesCanSendStory(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("self canSendStory fallback: %v", err) + } + if self.CountRemains != domain.DefaultStoryCanSendRemaining { + t.Fatalf("self canSendStory fallback = %+v, want default remaining", self) + } + if _, err := r.onStoriesCanSendStory(reqCtx, &tg.InputPeerUser{UserID: 1000000001}); err != nil { + t.Fatalf("self inputUser canSendStory fallback: %v", err) + } + if _, err := r.onStoriesCanSendStory(reqCtx, &tg.InputPeerUser{UserID: 1000000002}); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("other user canSendStory fallback err = %v, want PEER_ID_INVALID", err) + } + if _, err := r.onStoriesCanSendStory(reqCtx, &tg.InputPeerChannel{ChannelID: 10}); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("channel canSendStory fallback err = %v, want PEER_ID_INVALID", err) + } +} + +func TestStoriesCanSendStoryRejectsNilPeersBeforeFallback(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, 1000000001) + var typedNilSelf *tg.InputPeerSelf + var typedNilUser *tg.InputPeerUser + var typedNilChannel *tg.InputPeerChannel + for _, peer := range []tg.InputPeerClass{ + nil, + typedNilSelf, + typedNilUser, + typedNilChannel, + } { + if _, err := r.onStoriesCanSendStory(reqCtx, peer); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("canSendStory peer %T err = %v, want PEER_ID_INVALID", peer, err) + } + } +} + +func TestStoriesDirectPeerRequestsValidateMalformedPeerBeforeAuth(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + var typedNilSelf *tg.InputPeerSelf + malformedPeers := []tg.InputPeerClass{ + nil, + typedNilSelf, + &tg.InputPeerEmpty{}, + &tg.InputPeerChat{}, + &tg.InputPeerUser{}, + &tg.InputPeerChannel{}, + &tg.InputPeerUserFromMessage{Peer: &tg.InputPeerSelf{}, UserID: 1}, + &tg.InputPeerChannelFromMessage{Peer: &tg.InputPeerSelf{}, ChannelID: 1}, + } + calls := []struct { + name string + call func(tg.InputPeerClass) error + }{ + { + name: "getPeerStories", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesGetPeerStories(ctx, peer) + return err + }, + }, + { + name: "getPeerMaxIDs", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesGetPeerMaxIDs(ctx, []tg.InputPeerClass{peer}) + return err + }, + }, + { + name: "canSendStory", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesCanSendStory(ctx, peer) + return err + }, + }, + } + for _, peer := range malformedPeers { + for _, call := range calls { + if err := call.call(peer); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("%s peer %T err = %v, want PEER_ID_INVALID before auth", call.name, peer, err) + } + } + } + + oversized := make([]tg.InputPeerClass, domain.MaxStoryIDs+1) + for i := range oversized { + oversized[i] = &tg.InputPeerSelf{} + } + if _, err := r.onStoriesGetPeerMaxIDs(ctx, oversized); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("oversized getPeerMaxIDs err = %v, want STORY_ID_INVALID before auth", err) + } +} + +func TestStoriesReadViewReactionRequestsValidateMalformedPeerBeforeAuth(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + malformedPeers := []tg.InputPeerClass{ + nil, + (*tg.InputPeerSelf)(nil), + &tg.InputPeerEmpty{}, + &tg.InputPeerChat{}, + &tg.InputPeerUser{}, + &tg.InputPeerChannel{}, + &tg.InputPeerUserFromMessage{Peer: &tg.InputPeerSelf{}, UserID: 1}, + &tg.InputPeerChannelFromMessage{Peer: &tg.InputPeerSelf{}, ChannelID: 1}, + } + calls := []struct { + name string + call func(tg.InputPeerClass) error + }{ + { + name: "readStories", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesReadStories(ctx, &tg.StoriesReadStoriesRequest{Peer: peer, MaxID: 1}) + return err + }, + }, + { + name: "incrementStoryViews", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesIncrementStoryViews(ctx, &tg.StoriesIncrementStoryViewsRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + { + name: "getStoriesViews", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesGetStoriesViews(ctx, &tg.StoriesGetStoriesViewsRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + { + name: "sendReaction", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesSendReaction(ctx, &tg.StoriesSendReactionRequest{ + Peer: peer, + StoryID: 1, + Reaction: &tg.ReactionEmoji{Emoticon: "👍"}, + }) + return err + }, + }, + } + for _, peer := range malformedPeers { + for _, call := range calls { + if err := call.call(peer); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("%s peer %T err = %v, want PEER_ID_INVALID before auth", call.name, peer, err) + } + } + } + + if _, err := r.onStoriesReadStories(ctx, &tg.StoriesReadStoriesRequest{Peer: &tg.InputPeerEmpty{}}); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("readStories invalid id err = %v, want STORY_ID_INVALID before peer", err) + } + if _, err := r.onStoriesIncrementStoryViews(ctx, &tg.StoriesIncrementStoryViewsRequest{Peer: &tg.InputPeerEmpty{}}); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("incrementStoryViews empty ids err = %v, want STORY_ID_EMPTY before peer", err) + } + if _, err := r.onStoriesGetStoriesViews(ctx, &tg.StoriesGetStoriesViewsRequest{Peer: &tg.InputPeerEmpty{}}); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("getStoriesViews empty ids err = %v, want STORY_ID_EMPTY before peer", err) + } + if _, err := r.onStoriesSendReaction(ctx, &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerEmpty{}, + StoryID: 1, + Reaction: &tg.ReactionCustomEmoji{}, + }); err == nil || !tgerr.Is(err, "REACTION_INVALID") { + t.Fatalf("sendReaction malformed reaction err = %v, want REACTION_INVALID before peer", err) + } +} + +func TestStoriesProfileWriteRequestsValidateMalformedPeerBeforeAuth(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + malformedPeers := []tg.InputPeerClass{ + nil, + (*tg.InputPeerSelf)(nil), + &tg.InputPeerEmpty{}, + &tg.InputPeerChat{}, + &tg.InputPeerUser{}, + &tg.InputPeerChannel{}, + &tg.InputPeerUserFromMessage{Peer: &tg.InputPeerSelf{}, UserID: 1}, + &tg.InputPeerChannelFromMessage{Peer: &tg.InputPeerSelf{}, ChannelID: 1}, + } + calls := []struct { + name string + call func(tg.InputPeerClass) error + }{ + { + name: "deleteStories", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesDeleteStories(ctx, &tg.StoriesDeleteStoriesRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + { + name: "togglePinned", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesTogglePinned(ctx, &tg.StoriesTogglePinnedRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + { + name: "togglePinned empty IDs", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesTogglePinned(ctx, &tg.StoriesTogglePinnedRequest{Peer: peer}) + return err + }, + }, + { + name: "togglePinnedToTop", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesTogglePinnedToTop(ctx, &tg.StoriesTogglePinnedToTopRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + { + name: "togglePinnedToTop empty IDs", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesTogglePinnedToTop(ctx, &tg.StoriesTogglePinnedToTopRequest{Peer: peer}) + return err + }, + }, + } + for _, peer := range malformedPeers { + for _, call := range calls { + if err := call.call(peer); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("%s peer %T err = %v, want PEER_ID_INVALID before auth", call.name, peer, err) + } + } + } + + if _, err := r.onStoriesDeleteStories(ctx, &tg.StoriesDeleteStoriesRequest{Peer: &tg.InputPeerEmpty{}}); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("deleteStories empty ids err = %v, want STORY_ID_EMPTY before peer", err) + } + if _, err := r.onStoriesDeleteStories(ctx, &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("deleteStories invalid id err = %v, want STORY_ID_INVALID before peer", err) + } + if _, err := r.onStoriesTogglePinned(ctx, &tg.StoriesTogglePinnedRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("togglePinned invalid id err = %v, want STORY_ID_INVALID before peer", err) + } + if _, err := r.onStoriesTogglePinnedToTop(ctx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("togglePinnedToTop invalid id err = %v, want STORY_ID_INVALID before peer", err) + } + overTop := make([]int, domain.MaxStoryPinnedToTop+1) + for i := range overTop { + overTop[i] = i + 1 + } + if _, err := r.onStoriesTogglePinnedToTop(ctx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: overTop, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("togglePinnedToTop over top limit err = %v, want STORY_ID_INVALID before peer", err) + } +} + +func TestStoriesAlbumRequestsValidateMalformedPeerBeforeAuth(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + malformedPeers := []tg.InputPeerClass{ + nil, + (*tg.InputPeerSelf)(nil), + &tg.InputPeerEmpty{}, + &tg.InputPeerChat{}, + &tg.InputPeerUser{}, + &tg.InputPeerChannel{}, + &tg.InputPeerUserFromMessage{Peer: &tg.InputPeerSelf{}, UserID: 1}, + &tg.InputPeerChannelFromMessage{Peer: &tg.InputPeerSelf{}, ChannelID: 1}, + } + calls := []struct { + name string + call func(tg.InputPeerClass) error + }{ + { + name: "getAlbums", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesGetAlbums(ctx, &tg.StoriesGetAlbumsRequest{Peer: peer}) + return err + }, + }, + { + name: "getAlbumStories", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesGetAlbumStories(ctx, &tg.StoriesGetAlbumStoriesRequest{Peer: peer, AlbumID: 1, Limit: 20}) + return err + }, + }, + { + name: "createAlbum", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesCreateAlbum(ctx, &tg.StoriesCreateAlbumRequest{Peer: peer, Title: "Favorites", Stories: []int{1}}) + return err + }, + }, + { + name: "updateAlbum title", + call: func(peer tg.InputPeerClass) error { + req := &tg.StoriesUpdateAlbumRequest{Peer: peer, AlbumID: 1} + req.SetTitle("Travel") + _, err := r.onStoriesUpdateAlbum(ctx, req) + return err + }, + }, + { + name: "updateAlbum add stories", + call: func(peer tg.InputPeerClass) error { + req := &tg.StoriesUpdateAlbumRequest{Peer: peer, AlbumID: 1} + req.SetAddStories([]int{1}) + _, err := r.onStoriesUpdateAlbum(ctx, req) + return err + }, + }, + { + name: "reorderAlbums", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesReorderAlbums(ctx, &tg.StoriesReorderAlbumsRequest{Peer: peer, Order: []int{1}}) + return err + }, + }, + { + name: "reorderAlbums empty order", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesReorderAlbums(ctx, &tg.StoriesReorderAlbumsRequest{Peer: peer}) + return err + }, + }, + { + name: "deleteAlbum", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesDeleteAlbum(ctx, &tg.StoriesDeleteAlbumRequest{Peer: peer, AlbumID: 1}) + return err + }, + }, + } + for _, peer := range malformedPeers { + for _, call := range calls { + if err := call.call(peer); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("%s peer %T err = %v, want PEER_ID_INVALID before auth", call.name, peer, err) + } + } + } + + if _, err := r.onStoriesGetAlbumStories(ctx, &tg.StoriesGetAlbumStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 0, + Limit: 20, + }); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("getAlbumStories invalid album id err = %v, want INPUT_REQUEST_INVALID before peer", err) + } + if _, err := r.onStoriesCreateAlbum(ctx, &tg.StoriesCreateAlbumRequest{ + Peer: &tg.InputPeerEmpty{}, + Title: strings.Repeat("x", maxStoryAlbumTitleLength+1), + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("createAlbum long title err = %v, want LIMIT_INVALID before peer", err) + } + emptyUpdateReq := &tg.StoriesUpdateAlbumRequest{Peer: &tg.InputPeerEmpty{}, AlbumID: 1} + if _, err := r.onStoriesUpdateAlbum(ctx, emptyUpdateReq); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("updateAlbum empty mutation err = %v, want INPUT_REQUEST_INVALID before peer", err) + } + invalidUpdateReq := &tg.StoriesUpdateAlbumRequest{Peer: &tg.InputPeerEmpty{}, AlbumID: 1} + invalidUpdateReq.SetAddStories([]int{0}) + if _, err := r.onStoriesUpdateAlbum(ctx, invalidUpdateReq); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("updateAlbum invalid story id err = %v, want STORY_ID_INVALID before peer", err) + } + if _, err := r.onStoriesReorderAlbums(ctx, &tg.StoriesReorderAlbumsRequest{ + Peer: &tg.InputPeerEmpty{}, + Order: []int{1, 1}, + }); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("reorderAlbums duplicate err = %v, want INPUT_REQUEST_INVALID before peer", err) + } + if _, err := r.onStoriesDeleteAlbum(ctx, &tg.StoriesDeleteAlbumRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 0, + }); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("deleteAlbum invalid album id err = %v, want INPUT_REQUEST_INVALID before peer", err) + } +} + +func TestStoriesLongtailPeerRequestsValidateMalformedPeerBeforeAuth(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + malformedPeers := []tg.InputPeerClass{ + nil, + (*tg.InputPeerSelf)(nil), + &tg.InputPeerEmpty{}, + &tg.InputPeerChat{}, + &tg.InputPeerUser{}, + &tg.InputPeerChannel{}, + &tg.InputPeerUserFromMessage{Peer: &tg.InputPeerSelf{}, UserID: 1}, + &tg.InputPeerChannelFromMessage{Peer: &tg.InputPeerSelf{}, ChannelID: 1}, + } + calls := []struct { + name string + call func(tg.InputPeerClass) error + }{ + { + name: "getStoriesArchive", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesGetStoriesArchive(ctx, &tg.StoriesGetStoriesArchiveRequest{Peer: peer, Limit: 10}) + return err + }, + }, + { + name: "getPinnedStories", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesGetPinnedStories(ctx, &tg.StoriesGetPinnedStoriesRequest{Peer: peer, Limit: 10}) + return err + }, + }, + { + name: "exportStoryLink", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesExportStoryLink(ctx, &tg.StoriesExportStoryLinkRequest{Peer: peer, ID: 1}) + return err + }, + }, + { + name: "report", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesReport(ctx, &tg.StoriesReportRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + { + name: "startLive", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesStartLive(ctx, &tg.StoriesStartLiveRequest{ + Peer: peer, + RandomID: 9001, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + }) + return err + }, + }, + { + name: "searchPosts", + call: func(peer tg.InputPeerClass) error { + req := &tg.StoriesSearchPostsRequest{Limit: 10} + req.SetHashtag("travel") + req.SetPeer(peer) + _, err := r.onStoriesSearchPosts(ctx, req) + return err + }, + }, + { + name: "getStoryViewsList", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesGetStoryViewsList(ctx, &tg.StoriesGetStoryViewsListRequest{Peer: peer, ID: 1, Limit: 10}) + return err + }, + }, + { + name: "getStoryReactionsList", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesGetStoryReactionsList(ctx, &tg.StoriesGetStoryReactionsListRequest{Peer: peer, ID: 1, Limit: 10}) + return err + }, + }, + { + name: "togglePeerStoriesHidden", + call: func(peer tg.InputPeerClass) error { + _, err := r.onStoriesTogglePeerStoriesHidden(ctx, &tg.StoriesTogglePeerStoriesHiddenRequest{Peer: peer, Hidden: true}) + return err + }, + }, + } + for _, peer := range malformedPeers { + for _, call := range calls { + if err := call.call(peer); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("%s peer %T err = %v, want PEER_ID_INVALID before auth", call.name, peer, err) + } + } + } + + if _, err := r.onStoriesExportStoryLink(ctx, &tg.StoriesExportStoryLinkRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 0, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("exportStoryLink invalid id err = %v, want STORY_ID_INVALID before peer", err) + } + if _, err := r.onStoriesReport(ctx, &tg.StoriesReportRequest{Peer: &tg.InputPeerEmpty{}}); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("report empty ids err = %v, want STORY_ID_EMPTY before peer", err) + } + if _, err := r.onStoriesStartLive(ctx, &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerEmpty{}, + RandomID: 0, + }); err == nil || !tgerr.Is(err, "RANDOM_ID_EMPTY") { + t.Fatalf("startLive zero random err = %v, want RANDOM_ID_EMPTY before peer", err) + } + if _, err := r.onStoriesStartLive(ctx, &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerEmpty{}, + RandomID: 9001, + PrivacyRules: []tg.InputPrivacyRuleClass{nil}, + }); err == nil || !tgerr.Is(err, "PRIVACY_VALUE_INVALID") { + t.Fatalf("startLive malformed privacy err = %v, want PRIVACY_VALUE_INVALID before peer", err) + } + if _, err := r.onStoriesSearchPosts(ctx, &tg.StoriesSearchPostsRequest{Limit: 10}); err == nil || !tgerr.Is(err, "SEARCH_QUERY_EMPTY") { + t.Fatalf("searchPosts empty query err = %v, want SEARCH_QUERY_EMPTY before peer", err) + } + if _, err := r.onStoriesGetStoryViewsList(ctx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 0, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("getStoryViewsList invalid id err = %v, want STORY_ID_INVALID before peer", err) + } + invalidReactionListReq := &tg.StoriesGetStoryReactionsListRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 1, + } + invalidReactionListReq.SetReaction(&tg.ReactionCustomEmoji{}) + if _, err := r.onStoriesGetStoryReactionsList(ctx, invalidReactionListReq); err == nil || !tgerr.Is(err, "REACTION_INVALID") { + t.Fatalf("getStoryReactionsList invalid reaction err = %v, want REACTION_INVALID before peer", err) + } +} + +func TestStoriesTogglePinnedFallbackDedupeIDs(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, 1000000001) + + got, err := r.onStoriesTogglePinned(reqCtx, &tg.StoriesTogglePinnedRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{3, 2, 3, 1, 2}, + Pinned: true, + }) + if err != nil { + t.Fatalf("toggle pinned fallback: %v", err) + } + want := []int{3, 2, 1} + if len(got) != len(want) { + t.Fatalf("toggle pinned fallback ids = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("toggle pinned fallback ids = %v, want first-seen dedupe %v", got, want) + } + } +} + +func TestStoriesProfileAndLongtailRequestsValidateBeforePeerAndFallback(t *testing.T) { + ctx := WithUserID(context.Background(), 1000000001) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + nilCases := []struct { + name string + call func() error + }{ + {name: "get archive", call: func() error { + _, err := r.onStoriesGetStoriesArchive(ctx, nil) + return err + }}, + {name: "get pinned", call: func() error { + _, err := r.onStoriesGetPinnedStories(ctx, nil) + return err + }}, + {name: "export link", call: func() error { + _, err := r.onStoriesExportStoryLink(ctx, nil) + return err + }}, + {name: "get albums", call: func() error { + _, err := r.onStoriesGetAlbums(ctx, nil) + return err + }}, + {name: "start live", call: func() error { + _, err := r.onStoriesStartLive(ctx, nil) + return err + }}, + {name: "toggle pinned", call: func() error { + _, err := r.onStoriesTogglePinned(ctx, nil) + return err + }}, + {name: "toggle pinned to top", call: func() error { + _, err := r.onStoriesTogglePinnedToTop(ctx, nil) + return err + }}, + } + for _, tc := range nilCases { + t.Run(tc.name, func(t *testing.T) { + if err := tc.call(); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("%s nil err = %v, want INPUT_REQUEST_INVALID", tc.name, err) + } + }) + } + + if _, err := r.onStoriesGetStoriesArchive(ctx, &tg.StoriesGetStoriesArchiveRequest{ + Peer: &tg.InputPeerEmpty{}, + OffsetID: -2, + Limit: 1, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("archive bad offset before peer err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesGetPinnedStories(ctx, &tg.StoriesGetPinnedStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + OffsetID: 0, + Limit: domain.MaxStoryListLimit + 1, + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("pinned bad limit before peer err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesExportStoryLink(ctx, &tg.StoriesExportStoryLinkRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 0, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("export bad id before peer err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesStartLive(ctx, &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerEmpty{}, + RandomID: 0, + }); err == nil || !tgerr.Is(err, "RANDOM_ID_EMPTY") { + t.Fatalf("start live zero random before peer err = %v, want RANDOM_ID_EMPTY", err) + } + if _, err := r.onStoriesTogglePinned(ctx, &tg.StoriesTogglePinnedRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("toggle pinned bad id before peer err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesTogglePinnedToTop(ctx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("toggle pinned to top bad id before peer err = %v, want STORY_ID_INVALID", err) + } +} + +func TestStoriesTogglePinnedToTopBoundsBeforeNoop(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, 1000000001) + + ok, err := r.onStoriesTogglePinnedToTop(reqCtx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerSelf{}, + }) + if err != nil || !ok { + t.Fatalf("empty togglePinnedToTop = %v, %v; want true nil", ok, err) + } + ok, err = r.onStoriesTogglePinnedToTop(reqCtx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{2, 1, 2}, + }) + if err != nil || !ok { + t.Fatalf("valid togglePinnedToTop = %v, %v; want true nil", ok, err) + } + if _, err := r.onStoriesTogglePinnedToTop(reqCtx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1, 2, 3, 4}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("over limit togglePinnedToTop err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesTogglePinnedToTop(reqCtx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("invalid togglePinnedToTop id err = %v, want STORY_ID_INVALID", err) + } + + oversized := make([]int, domain.MaxStoryIDs+1) + for i := range oversized { + oversized[i] = 1 + } + if _, err := r.onStoriesTogglePinnedToTop(reqCtx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerSelf{}, + ID: oversized, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("oversized togglePinnedToTop err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesTogglePinnedToTop(reqCtx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{1}, + }); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("invalid togglePinnedToTop peer err = %v, want PEER_ID_INVALID", err) + } +} + +func TestStoriesTogglePinnedToTopPersistsOrder(t *testing.T) { + ctx := context.Background() + ownerID := int64(1000000001) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID} + storyStore := memory.NewStoryStore() + for _, id := range []int{1, 2, 3} { + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: id, + Date: 1700000000 + id, + ExpireDate: 1700003600, + Pinned: true, + Public: true, + }}); err != nil { + t.Fatalf("upsert pinned story %d: %v", id, err) + } + } + r := New(Config{}, Deps{Stories: appstories.NewService(storyStore)}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, ownerID) + + ok, err := r.onStoriesTogglePinnedToTop(reqCtx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{2, 1, 2}, + }) + if err != nil || !ok { + t.Fatalf("toggle pinned to top = %v, %v; want true nil", ok, err) + } + pinned, err := r.onStoriesGetPinnedStories(reqCtx, &tg.StoriesGetPinnedStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get pinned stories: %v", err) + } + top, topSet := pinned.GetPinnedToTop() + if !topSet || !sameIntIDs(top, []int{2, 1}) { + t.Fatalf("pinned_to_top = %v ok=%v, want 2,1", top, topSet) + } + + if _, err := r.onStoriesTogglePinned(reqCtx, &tg.StoriesTogglePinnedRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{2}, + Pinned: false, + }); err != nil { + t.Fatalf("unpin top story: %v", err) + } + pinned, err = r.onStoriesGetPinnedStories(reqCtx, &tg.StoriesGetPinnedStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get pinned stories after unpin: %v", err) + } + top, topSet = pinned.GetPinnedToTop() + if !topSet || !sameIntIDs(top, []int{1}) { + t.Fatalf("pinned_to_top after unpin = %v ok=%v, want 1", top, topSet) + } + if _, err := r.onStoriesTogglePinnedToTop(reqCtx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{2}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("unpinned story top err = %v, want STORY_ID_INVALID", err) + } + + ok, err = r.onStoriesTogglePinnedToTop(reqCtx, &tg.StoriesTogglePinnedToTopRequest{ + Peer: &tg.InputPeerSelf{}, + }) + if err != nil || !ok { + t.Fatalf("clear pinned to top = %v, %v; want true nil", ok, err) + } + pinned, err = r.onStoriesGetPinnedStories(reqCtx, &tg.StoriesGetPinnedStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get pinned stories after clear: %v", err) + } + top, topSet = pinned.GetPinnedToTop() + if topSet || len(top) != 0 { + t.Fatalf("pinned_to_top after clear = %v ok=%v, want absent", top, topSet) + } +} + +func TestStoriesPinnedArchiveBoundsBeforeFallback(t *testing.T) { + ctx := WithUserID(context.Background(), 1000000001) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + callPinned := func(offsetID, limit int) error { + _, err := r.onStoriesGetPinnedStories(ctx, &tg.StoriesGetPinnedStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + OffsetID: offsetID, + Limit: limit, + }) + return err + } + callArchive := func(offsetID, limit int) error { + _, err := r.onStoriesGetStoriesArchive(ctx, &tg.StoriesGetStoriesArchiveRequest{ + Peer: &tg.InputPeerSelf{}, + OffsetID: offsetID, + Limit: limit, + }) + return err + } + + if err := callPinned(-1, 0); err != nil { + t.Fatalf("pinned Android sentinel bounds err = %v, want nil", err) + } + if err := callArchive(-1, 0); err != nil { + t.Fatalf("archive Android sentinel bounds err = %v, want nil", err) + } + + tests := []struct { + name string + offsetID int + limit int + want string + }{ + {name: "offset below android sentinel", offsetID: -2, limit: 1, want: "STORY_ID_INVALID"}, + {name: "offset above max story id", offsetID: domain.MaxStoryID + 1, limit: 1, want: "STORY_ID_INVALID"}, + {name: "negative limit", offsetID: 0, limit: -1, want: "LIMIT_INVALID"}, + {name: "limit above max", offsetID: 0, limit: domain.MaxStoryListLimit + 1, want: "LIMIT_INVALID"}, + } + for _, tt := range tests { + t.Run(tt.name+"/pinned", func(t *testing.T) { + if err := callPinned(tt.offsetID, tt.limit); err == nil || !tgerr.Is(err, tt.want) { + t.Fatalf("pinned err = %v, want %s", err, tt.want) + } + }) + t.Run(tt.name+"/archive", func(t *testing.T) { + if err := callArchive(tt.offsetID, tt.limit); err == nil || !tgerr.Is(err, tt.want) { + t.Fatalf("archive err = %v, want %s", err, tt.want) + } + }) + } +} + +func TestStoriesDeleteStoriesRejectsEmptyIDsBeforeFallback(t *testing.T) { + ctx := context.Background() + reqCtx := WithUserID(ctx, 1000000001) + req := &tg.StoriesDeleteStoriesRequest{Peer: &tg.InputPeerSelf{}} + + emptyRouter := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + if _, err := emptyRouter.onStoriesDeleteStories(reqCtx, req); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("empty deleteStories fallback err = %v, want STORY_ID_EMPTY", err) + } + + serviceRouter := New(Config{}, Deps{ + Stories: appstories.NewService(memory.NewStoryStore()), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + if _, err := serviceRouter.onStoriesDeleteStories(reqCtx, req); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("empty deleteStories service err = %v, want STORY_ID_EMPTY", err) + } +} + +func TestStoriesDeleteStoriesDedupeIDsBeforeFallback(t *testing.T) { + ctx := context.Background() + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + deleted, err := r.onStoriesDeleteStories(WithUserID(ctx, 1000000001), &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{2, 1, 2, 1}, + }) + if err != nil { + t.Fatalf("deleteStories fallback duplicate ids: %v", err) + } + if len(deleted) != 2 || deleted[0] != 2 || deleted[1] != 1 { + t.Fatalf("deleteStories fallback ids = %v, want [2 1]", deleted) + } +} + +func TestStoriesWriteRequestsValidateBeforePeerAndFallback(t *testing.T) { + ctx := WithUserID(context.Background(), 1000000001) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + if _, err := r.onStoriesSendStory(ctx, nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("nil sendStory err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesEditStory(ctx, nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("nil editStory err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesDeleteStories(ctx, nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("nil deleteStories err = %v, want INPUT_REQUEST_INVALID", err) + } + + if _, err := r.onStoriesSendStory(ctx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerEmpty{}, + RandomID: 0, + }); err == nil || !tgerr.Is(err, "RANDOM_ID_EMPTY") { + t.Fatalf("sendStory zero random before peer err = %v, want RANDOM_ID_EMPTY", err) + } + if _, err := r.onStoriesEditStory(ctx, &tg.StoriesEditStoryRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 0, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("editStory invalid id before peer err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesDeleteStories(ctx, &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + }); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("deleteStories empty ids before peer err = %v, want STORY_ID_EMPTY", err) + } + if _, err := r.onStoriesDeleteStories(ctx, &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("deleteStories invalid id before peer err = %v, want STORY_ID_INVALID", err) + } +} + +func TestStoriesRejectTypedNilInputPeers(t *testing.T) { + ctx := context.Background() + storyStore := memory.NewStoryStore() + r := New(Config{}, Deps{ + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000101, 0)}) + reqCtx := WithUserID(ctx, 1000000001) + + peers := []struct { + name string + peer tg.InputPeerClass + }{ + {name: "nil", peer: nil}, + {name: "typed nil empty", peer: (*tg.InputPeerEmpty)(nil)}, + {name: "typed nil self", peer: (*tg.InputPeerSelf)(nil)}, + {name: "typed nil chat", peer: (*tg.InputPeerChat)(nil)}, + {name: "typed nil user", peer: (*tg.InputPeerUser)(nil)}, + {name: "typed nil channel", peer: (*tg.InputPeerChannel)(nil)}, + {name: "typed nil user from message", peer: (*tg.InputPeerUserFromMessage)(nil)}, + {name: "typed nil channel from message", peer: (*tg.InputPeerChannelFromMessage)(nil)}, + } + calls := []struct { + name string + call func(t *testing.T, peer tg.InputPeerClass) error + }{ + { + name: "getPeerStories", + call: func(t *testing.T, peer tg.InputPeerClass) error { + _, err := r.onStoriesGetPeerStories(reqCtx, peer) + return err + }, + }, + { + name: "getPeerMaxIDs", + call: func(t *testing.T, peer tg.InputPeerClass) error { + _, err := r.onStoriesGetPeerMaxIDs(reqCtx, []tg.InputPeerClass{peer}) + return err + }, + }, + { + name: "readStories", + call: func(t *testing.T, peer tg.InputPeerClass) error { + _, err := r.onStoriesReadStories(reqCtx, &tg.StoriesReadStoriesRequest{Peer: peer, MaxID: 1}) + return err + }, + }, + { + name: "incrementStoryViews", + call: func(t *testing.T, peer tg.InputPeerClass) error { + _, err := r.onStoriesIncrementStoryViews(reqCtx, &tg.StoriesIncrementStoryViewsRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + { + name: "getStoriesViews", + call: func(t *testing.T, peer tg.InputPeerClass) error { + _, err := r.onStoriesGetStoriesViews(reqCtx, &tg.StoriesGetStoriesViewsRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + { + name: "sendStory", + call: func(t *testing.T, peer tg.InputPeerClass) error { + _, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: peer, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 9001, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 9001, + Period: 86400, + }) + return err + }, + }, + { + name: "editStory", + call: func(t *testing.T, peer tg.InputPeerClass) error { + _, err := r.onStoriesEditStory(reqCtx, &tg.StoriesEditStoryRequest{Peer: peer, ID: 1}) + return err + }, + }, + { + name: "deleteStories", + call: func(t *testing.T, peer tg.InputPeerClass) error { + _, err := r.onStoriesDeleteStories(reqCtx, &tg.StoriesDeleteStoriesRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + { + name: "togglePinned", + call: func(t *testing.T, peer tg.InputPeerClass) error { + _, err := r.onStoriesTogglePinned(reqCtx, &tg.StoriesTogglePinnedRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + { + name: "togglePinnedToTop", + call: func(t *testing.T, peer tg.InputPeerClass) error { + _, err := r.onStoriesTogglePinnedToTop(reqCtx, &tg.StoriesTogglePinnedToTopRequest{Peer: peer, ID: []int{1}}) + return err + }, + }, + } + for _, peerCase := range peers { + for _, callCase := range calls { + t.Run(peerCase.name+"/"+callCase.name, func(t *testing.T) { + if err := callCase.call(t, peerCase.peer); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("%s with %s err = %v, want PEER_ID_INVALID", callCase.name, peerCase.name, err) + } + }) + } + } + + stories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get self stories after rejected typed nil peers: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected typed nil peers = %+v, want none", stories.Stories.Stories) + } +} + +func TestStoriesSelfUserViewAndReactionDoNotPolluteOwnerInteractions(t *testing.T) { + ctx := context.Background() + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{Stories: appstories.NewService(storyStore)}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, owner.ID) + + ok, err := r.onStoriesIncrementStoryViews(reqCtx, &tg.StoriesIncrementStoryViewsRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1}, + }) + if err != nil || !ok { + t.Fatalf("increment self story views = %v, %v; want true nil", ok, err) + } + views, err := r.onStoriesGetStoryViewsList(reqCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("get self story views list: %v", err) + } + if views.Count != 0 || views.ViewsCount != 0 || views.ReactionsCount != 0 || len(views.Views) != 0 { + t.Fatalf("self story views = %+v, want empty counters", views) + } + if _, err := r.onStoriesSendReaction(reqCtx, &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerSelf{}, + StoryID: 1, + Reaction: &tg.ReactionEmoji{Emoticon: "🔥"}, + }); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("self story reaction err = %v, want PEER_ID_INVALID", err) + } +} + +func TestStoriesViewsListMarksStoryBlockedViewer(t *testing.T) { + ctx := context.Background() + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001} + viewerID := int64(1000000002) + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + if created, err := storyStore.IncrementViews(ctx, viewerID, owner, []int{1}, 1700000100); err != nil || created != 1 { + t.Fatalf("increment story view = %d, %v, want 1 nil", created, err) + } + storyStore.SetStoryBlockedUsers(owner.ID, viewerID) + r := New(Config{}, Deps{Stories: appstories.NewService(storyStore)}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000200, 0)}) + + views, err := r.onStoriesGetStoryViewsList(WithUserID(ctx, owner.ID), &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("get story views list: %v", err) + } + if views.Count != 1 || len(views.Views) != 1 { + t.Fatalf("story views = %+v, want one historical viewer", views) + } + view, ok := views.Views[0].(*tg.StoryView) + if !ok { + t.Fatalf("story view = %T, want storyView", views.Views[0]) + } + if !view.BlockedMyStoriesFrom || view.Blocked { + t.Fatalf("story view flags = blocked %v blocked_my_stories_from %v, want only story block", view.Blocked, view.BlockedMyStoriesFrom) + } +} + +func TestStoriesSendReactionAllowsEmptyClear(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + ownerUser, err := userStore.Create(ctx, domain.User{AccessHash: 91001, Phone: "15550091001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 91002, Phone: "15550091002", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerUser.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + updateStore := memory.NewUpdateEventStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + viewerCtx := WithAuthKeyID(WithUserID(ctx, viewer.ID), [8]byte{9, 1, 2}) + updates, err := r.onStoriesSendReaction(viewerCtx, &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: ownerUser.AccessHash}, + StoryID: 1, + Reaction: &tg.ReactionEmpty{}, + }) + if err != nil { + t.Fatalf("send empty reaction: %v", err) + } + got, ok := updates.(*tg.Updates) + if !ok || len(got.Updates) != 1 { + t.Fatalf("updates = %T %+v, want one *tg.Updates", updates, updates) + } + reaction, ok := got.Updates[0].(*tg.UpdateSentStoryReaction) + if !ok { + t.Fatalf("update = %T, want updateSentStoryReaction", got.Updates[0]) + } + if _, ok := reaction.Reaction.(*tg.ReactionEmpty); !ok { + t.Fatalf("reaction = %T, want reactionEmpty", reaction.Reaction) + } + assertUserStoryMaxID(t, findUserClass(got.Users, owner.ID), 1) + diff, err := r.onUpdatesGetDifference(viewerCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("viewer get difference after empty clear: %v", err) + } + if _, ok := diff.(*tg.UpdatesDifferenceEmpty); !ok { + t.Fatalf("viewer difference after empty clear = %T %+v, want empty", diff, diff) + } + events, err := updateStore.ListAfter(ctx, viewer.ID, 0, 10) + if err != nil { + t.Fatalf("list viewer events after empty clear: %v", err) + } + if len(events) != 0 { + t.Fatalf("viewer events after empty clear = %+v, want none", events) + } + ownerEvents, err := updateStore.ListAfter(ctx, owner.ID, 0, 10) + if err != nil { + t.Fatalf("list owner events after empty clear: %v", err) + } + if len(ownerEvents) != 0 { + t.Fatalf("owner events after empty clear = %+v, want none", ownerEvents) + } +} + +func TestStoriesSendReactionRejectsUnsupportedReactionsBeforeFallback(t *testing.T) { + ctx := WithUserID(context.Background(), 91003) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + if _, err := r.onStoriesSendReaction(ctx, nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("nil send story reaction err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesSendReaction(ctx, &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerEmpty{}, + StoryID: 0, + Reaction: &tg.ReactionEmoji{Emoticon: "🔥"}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("invalid story reaction id before peer err = %v, want STORY_ID_INVALID", err) + } + + for _, tt := range []struct { + name string + reaction tg.ReactionClass + }{ + {name: "custom emoji without document id", reaction: &tg.ReactionCustomEmoji{}}, + {name: "paid", reaction: &tg.ReactionPaid{}}, + {name: "nil", reaction: nil}, + {name: "typed nil emoji", reaction: (*tg.ReactionEmoji)(nil)}, + {name: "typed nil custom emoji", reaction: (*tg.ReactionCustomEmoji)(nil)}, + {name: "typed nil empty", reaction: (*tg.ReactionEmpty)(nil)}, + {name: "typed nil paid", reaction: (*tg.ReactionPaid)(nil)}, + } { + t.Run(tt.name, func(t *testing.T) { + _, err := r.onStoriesSendReaction(ctx, &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerEmpty{}, + StoryID: 1, + Reaction: tt.reaction, + }) + if err == nil || !tgerr.Is(err, "REACTION_INVALID") { + t.Fatalf("send unsupported story reaction before peer err = %v, want REACTION_INVALID", err) + } + }) + } +} + +func TestStoriesSendReactionRecordsUsageOnlyAfterStorySuccess(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + ownerUser, err := userStore.Create(ctx, domain.User{AccessHash: 91011, Phone: "15550091011", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 91012, Phone: "15550091012", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + storyStore := memory.NewStoryStore() + channelService := appchannels.NewService(memory.NewChannelStore()) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Channels: channelService, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + missingStoryReaction := &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: ownerUser.ID, AccessHash: ownerUser.AccessHash}, + StoryID: 1, + Reaction: &tg.ReactionEmoji{Emoticon: "\U0001f44d"}, + } + missingStoryReaction.SetAddToRecent(true) + _, err = r.onStoriesSendReaction(WithUserID(ctx, viewer.ID), missingStoryReaction) + if err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("missing story reaction err = %v, want STORY_ID_INVALID", err) + } + + top, err := channelService.TopReactions(ctx, viewer.ID, 10) + if err != nil { + t.Fatalf("top reactions after failed story reaction: %v", err) + } + if len(top) != 0 { + t.Fatalf("top reactions after failed story reaction = %+v, want empty", top) + } + recent, err := r.onMessagesGetRecentReactions(WithUserID(ctx, viewer.ID), &tg.MessagesGetRecentReactionsRequest{Limit: 40}) + if err != nil { + t.Fatalf("recent reactions after failed story reaction: %v", err) + } + page, ok := recent.(*tg.MessagesReactions) + if !ok || page.Hash != 0 || len(page.Reactions) != 0 { + t.Fatalf("recent reactions after failed story reaction = %#v, want empty page", recent) + } + + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerUser.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + successfulStoryReaction := &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: ownerUser.ID, AccessHash: ownerUser.AccessHash}, + StoryID: 1, + Reaction: &tg.ReactionEmoji{Emoticon: "\U0001f44d"}, + } + successfulStoryReaction.SetAddToRecent(true) + if _, err := r.onStoriesSendReaction(WithUserID(ctx, viewer.ID), successfulStoryReaction); err != nil { + t.Fatalf("successful story reaction: %v", err) + } + top, err = channelService.TopReactions(ctx, viewer.ID, 10) + if err != nil { + t.Fatalf("top reactions after successful story reaction: %v", err) + } + if len(top) != 1 || top[0].Emoticon != "\U0001f44d" { + t.Fatalf("top reactions after successful story reaction = %+v, want thumb", top) + } + recent, err = r.onMessagesGetRecentReactions(WithUserID(ctx, viewer.ID), &tg.MessagesGetRecentReactionsRequest{Limit: 40}) + if err != nil { + t.Fatalf("recent reactions after successful story reaction: %v", err) + } + page, ok = recent.(*tg.MessagesReactions) + if !ok || page.Hash == 0 || len(page.Reactions) != 1 { + t.Fatalf("recent reactions after successful story reaction = %#v, want one hashable reaction", recent) + } + if emoji, ok := page.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" { + t.Fatalf("recent reaction after successful story reaction = %#v, want thumb", page.Reactions[0]) + } + + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 2, + Date: 1700000001, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert second story: %v", err) + } + laterRouter := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Channels: channelService, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000200, 0)}) + fireStoryReaction := &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: ownerUser.ID, AccessHash: ownerUser.AccessHash}, + StoryID: 2, + Reaction: &tg.ReactionEmoji{Emoticon: "🔥"}, + } + fireStoryReaction.SetAddToRecent(true) + if _, err := laterRouter.onStoriesSendReaction(WithUserID(ctx, viewer.ID), fireStoryReaction); err != nil { + t.Fatalf("fire story reaction: %v", err) + } + retryRouter := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Channels: channelService, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000300, 0)}) + if _, err := retryRouter.onStoriesSendReaction(WithUserID(ctx, viewer.ID), successfulStoryReaction); err != nil { + t.Fatalf("retry same story reaction: %v", err) + } + top, err = channelService.TopReactions(ctx, viewer.ID, 10) + if err != nil { + t.Fatalf("top reactions after duplicate story reaction: %v", err) + } + if len(top) < 2 || top[0].Emoticon != "🔥" || top[1].Emoticon != "\U0001f44d" { + t.Fatalf("top reactions after duplicate story reaction = %+v, want fire before unchanged thumb", top) + } + recent, err = retryRouter.onMessagesGetRecentReactions(WithUserID(ctx, viewer.ID), &tg.MessagesGetRecentReactionsRequest{Limit: 40}) + if err != nil { + t.Fatalf("recent reactions after duplicate story reaction: %v", err) + } + page, ok = recent.(*tg.MessagesReactions) + if !ok || len(page.Reactions) < 2 { + t.Fatalf("recent reactions after duplicate story reaction = %#v, want two reactions", recent) + } + if emoji, ok := page.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "🔥" { + t.Fatalf("recent first after duplicate story reaction = %#v, want fire", page.Reactions[0]) + } + + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 3, + Date: 1700000002, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert custom reaction story: %v", err) + } + customRouter := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Channels: channelService, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000400, 0)}) + customStoryReaction := &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: ownerUser.ID, AccessHash: ownerUser.AccessHash}, + StoryID: 3, + Reaction: &tg.ReactionCustomEmoji{DocumentID: 12345}, + } + customStoryReaction.SetAddToRecent(true) + if _, err := customRouter.onStoriesSendReaction(WithUserID(ctx, viewer.ID), customStoryReaction); err != nil { + t.Fatalf("custom story reaction: %v", err) + } + top, err = channelService.TopReactions(ctx, viewer.ID, 10) + if err != nil { + t.Fatalf("top reactions after custom story reaction: %v", err) + } + if len(top) < 2 || top[0].Emoticon != "🔥" || top[1].Emoticon != "\U0001f44d" { + t.Fatalf("top reactions after custom story reaction = %+v, want unchanged emoji-only top reactions", top) + } + recent, err = customRouter.onMessagesGetRecentReactions(WithUserID(ctx, viewer.ID), &tg.MessagesGetRecentReactionsRequest{Limit: 40}) + if err != nil { + t.Fatalf("recent reactions after custom story reaction: %v", err) + } + page, ok = recent.(*tg.MessagesReactions) + if !ok || len(page.Reactions) < 2 { + t.Fatalf("recent reactions after custom story reaction = %#v, want unchanged emoji reactions", recent) + } + if emoji, ok := page.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "🔥" { + t.Fatalf("recent first after custom story reaction = %#v, want fire", page.Reactions[0]) + } +} + +func TestStoriesSendReactionRecordsOwnerNewReactionDifference(t *testing.T) { + ctx := context.Background() + ownerAuthKeyID := [8]byte{7, 7, 1} + viewerAuthKeyID := [8]byte{7, 7, 2} + userStore := memory.NewUserStore() + ownerUser, err := userStore.Create(ctx, domain.User{AccessHash: 91101, Phone: "15550091101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 91102, Phone: "15550091102", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerUser.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + viewerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, viewer.ID), viewerAuthKeyID), 7102) + + updates, err := r.onStoriesSendReaction(viewerCtx, &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: ownerUser.AccessHash}, + StoryID: 1, + Reaction: &tg.ReactionCustomEmoji{DocumentID: 12345}, + }) + if err != nil { + t.Fatalf("send reaction: %v", err) + } + gotUpdates, ok := updates.(*tg.Updates) + if !ok || len(gotUpdates.Updates) != 1 { + t.Fatalf("send updates = %T %+v, want one updateSentStoryReaction", updates, updates) + } + sentReaction, ok := gotUpdates.Updates[0].(*tg.UpdateSentStoryReaction) + if !ok { + t.Fatalf("send update = %T, want updateSentStoryReaction", gotUpdates.Updates[0]) + } + if reaction, ok := sentReaction.Reaction.(*tg.ReactionCustomEmoji); !ok || reaction.DocumentID != 12345 { + t.Fatalf("send update reaction = %T %+v, want custom emoji 12345", sentReaction.Reaction, sentReaction.Reaction) + } + + ownerCtx := WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuthKeyID) + diff, err := r.onUpdatesGetDifference(ownerCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("owner get difference: %v", err) + } + got, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want updates.difference", diff) + } + if got.State.Pts != 1 || len(got.OtherUpdates) != 1 { + t.Fatalf("difference pts/updates = %d/%d, want one owner reaction update", got.State.Pts, len(got.OtherUpdates)) + } + ownerUpdate, ok := got.OtherUpdates[0].(*tg.UpdateNewStoryReaction) + if !ok { + t.Fatalf("owner update = %T, want updateNewStoryReaction", got.OtherUpdates[0]) + } + reactorPeer, ok := ownerUpdate.Peer.(*tg.PeerUser) + if !ok || reactorPeer.UserID != viewer.ID { + t.Fatalf("owner update peer = %T %+v, want reacting viewer peer", ownerUpdate.Peer, ownerUpdate.Peer) + } + if reaction, ok := ownerUpdate.Reaction.(*tg.ReactionCustomEmoji); !ok || reaction.DocumentID != 12345 { + t.Fatalf("owner update reaction = %T %+v, want custom emoji 12345", ownerUpdate.Reaction, ownerUpdate.Reaction) + } + if findUserClass(got.Users, viewer.ID) == nil { + t.Fatalf("difference users = %+v, want reacting viewer user", got.Users) + } + + if _, err := r.onStoriesSendReaction(viewerCtx, &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: ownerUser.AccessHash}, + StoryID: 1, + Reaction: &tg.ReactionCustomEmoji{DocumentID: 12345}, + }); err != nil { + t.Fatalf("retry same reaction: %v", err) + } + diff, err = r.onUpdatesGetDifference(ownerCtx, &tg.UpdatesGetDifferenceRequest{Pts: got.State.Pts}) + if err != nil { + t.Fatalf("owner get difference after same reaction retry: %v", err) + } + if _, ok := diff.(*tg.UpdatesDifferenceEmpty); !ok { + t.Fatalf("difference after same reaction retry = %T %+v, want empty owner difference", diff, diff) + } + + if _, err := r.onStoriesSendReaction(viewerCtx, &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: ownerUser.AccessHash}, + StoryID: 1, + Reaction: &tg.ReactionEmpty{}, + }); err != nil { + t.Fatalf("clear reaction: %v", err) + } + diff, err = r.onUpdatesGetDifference(ownerCtx, &tg.UpdatesGetDifferenceRequest{Pts: got.State.Pts}) + if err != nil { + t.Fatalf("owner get difference after clear: %v", err) + } + if _, ok := diff.(*tg.UpdatesDifferenceEmpty); !ok { + t.Fatalf("difference after clear = %T %+v, want empty owner difference", diff, diff) + } +} + +func TestStoriesViewsAndReactionsListReturnViewerUsers(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + ownerUser, err := userStore.Create(ctx, domain.User{AccessHash: 91201, Phone: "15550091201", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewerOne, err := userStore.Create(ctx, domain.User{AccessHash: 91202, Phone: "15550091202", FirstName: "Viewer", LastName: "One"}) + if err != nil { + t.Fatalf("create viewer one: %v", err) + } + viewerTwo, err := userStore.Create(ctx, domain.User{AccessHash: 91203, Phone: "15550091203", FirstName: "Viewer", LastName: "Two"}) + if err != nil { + t.Fatalf("create viewer two: %v", err) + } + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerUser.ID} + storyStore.SetStoryViewerProfiles(viewerOne, viewerTwo) + storyStore.SetStoryViewerContacts(ownerUser.ID, domain.Contact{ + User: viewerOne, + FirstName: "Saved", + LastName: "One", + Phone: viewerOne.Phone, + }) + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + if _, err := storyStore.IncrementViews(ctx, viewerOne.ID, owner, []int{1}, 1700000001); err != nil { + t.Fatalf("increment view: %v", err) + } + fire := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "🔥"} + if _, err := storyStore.SetReaction(ctx, viewerTwo.ID, owner, 1, fire, 1700000002); err != nil { + t.Fatalf("set reaction: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, ownerUser.ID) + + views, err := r.onStoriesGetStoryViewsList(reqCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Limit: 10, + ReactionsFirst: true, + }) + if err != nil { + t.Fatalf("get story views list: %v", err) + } + if views.Count != 2 || views.ViewsCount != 2 || views.ReactionsCount != 1 || len(views.Views) != 2 { + t.Fatalf("views list = %+v, want two views and one reaction", views) + } + firstView, ok := views.Views[0].(*tg.StoryView) + if !ok || firstView.UserID != viewerTwo.ID { + t.Fatalf("first view = %T %+v, want reacting viewer first", views.Views[0], views.Views[0]) + } + if reaction, ok := firstView.Reaction.(*tg.ReactionEmoji); !ok || reaction.Emoticon != "🔥" { + t.Fatalf("first view reaction = %T %+v, want fire emoji", firstView.Reaction, firstView.Reaction) + } + if findUserClass(views.Users, viewerOne.ID) == nil || findUserClass(views.Users, viewerTwo.ID) == nil { + t.Fatalf("views users = %+v, want both viewer users", views.Users) + } + + contactsOnlyReq := &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Limit: 10, + } + contactsOnlyReq.SetJustContacts(true) + contactsOnly, err := r.onStoriesGetStoryViewsList(reqCtx, contactsOnlyReq) + if err != nil { + t.Fatalf("get story views contacts-only: %v", err) + } + if contactsOnly.Count != 1 || contactsOnly.ViewsCount != 2 || len(contactsOnly.Views) != 1 { + t.Fatalf("contacts-only views = %+v, want one filtered contact with total views 2", contactsOnly) + } + contactView, ok := contactsOnly.Views[0].(*tg.StoryView) + if !ok || contactView.UserID != viewerOne.ID { + t.Fatalf("contacts-only view = %T %+v, want viewer one", contactsOnly.Views[0], contactsOnly.Views[0]) + } + if findUserClass(contactsOnly.Users, viewerOne.ID) == nil { + t.Fatalf("contacts-only users = %+v, want viewer one user", contactsOnly.Users) + } + + searchReq := &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Limit: 10, + } + searchReq.SetQ("two") + search, err := r.onStoriesGetStoryViewsList(reqCtx, searchReq) + if err != nil { + t.Fatalf("get story views search: %v", err) + } + if search.Count != 1 || len(search.Views) != 1 { + t.Fatalf("search views = %+v, want one filtered viewer", search) + } + searchView, ok := search.Views[0].(*tg.StoryView) + if !ok || searchView.UserID != viewerTwo.ID { + t.Fatalf("search view = %T %+v, want viewer two", search.Views[0], search.Views[0]) + } + if _, err := r.onStoriesGetStoryViewsList(reqCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Offset: "bad", + Limit: 10, + }); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("bad story views offset err = %v, want OFFSET_INVALID", err) + } + if _, err := r.onStoriesGetStoryViewsList(reqCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Limit: -1, + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("negative story views limit err = %v, want LIMIT_INVALID", err) + } + compatOnly := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + if _, err := compatOnly.onStoriesGetStoryViewsList(reqCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Offset: strings.Repeat("9", domain.MaxStoryInteractionOffsetLength+1), + Limit: 10, + }); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("oversized story views offset fallback err = %v, want OFFSET_INVALID", err) + } + if _, err := compatOnly.onStoriesGetStoryViewsList(reqCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Limit: domain.MaxStoryListLimit + 1, + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("oversized story views limit fallback err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesGetStoryViewsList(reqCtx, nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("nil story views list err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesGetStoryViewsList(reqCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 0, + Limit: 10, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("bad story views id before peer err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesGetStoryViewsList(reqCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 1, + Limit: -1, + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("bad story views limit before peer err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesGetStoryViewsList(reqCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 1, + Offset: "bad", + Limit: 10, + }); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("bad story views offset before peer err = %v, want OFFSET_INVALID", err) + } + oversizedQuery := &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 1, + Limit: 10, + } + oversizedQuery.SetQ(strings.Repeat("x", domain.MaxStoryViewQueryLength+1)) + if _, err := r.onStoriesGetStoryViewsList(reqCtx, oversizedQuery); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("oversized story views query before peer err = %v, want LIMIT_INVALID", err) + } +} + +func TestStoriesGetStoriesViewsAlignsWithRequestedIDs(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + ownerUser, err := userStore.Create(ctx, domain.User{AccessHash: 91211, Phone: "15550091211", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewerOne, err := userStore.Create(ctx, domain.User{AccessHash: 91212, Phone: "15550091212", FirstName: "Viewer", LastName: "One"}) + if err != nil { + t.Fatalf("create viewer one: %v", err) + } + viewerTwo, err := userStore.Create(ctx, domain.User{AccessHash: 91213, Phone: "15550091213", FirstName: "Viewer", LastName: "Two"}) + if err != nil { + t.Fatalf("create viewer two: %v", err) + } + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerUser.ID} + for _, storyID := range []int{1, 2} { + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: storyID, + Date: 1700001000 + storyID, + ExpireDate: 1700004600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %d: %v", storyID, err) + } + } + if _, err := storyStore.IncrementViews(ctx, viewerOne.ID, owner, []int{1, 2}, 1700001101); err != nil { + t.Fatalf("increment viewer one: %v", err) + } + if _, err := storyStore.IncrementViews(ctx, viewerTwo.ID, owner, []int{2}, 1700001102); err != nil { + t.Fatalf("increment viewer two: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700001200, 0)}) + + got, err := r.onStoriesGetStoriesViews(WithUserID(ctx, ownerUser.ID), &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1, 1, 99, 2}, + }) + if err != nil { + t.Fatalf("get stories views: %v", err) + } + if len(got.Views) != 4 { + t.Fatalf("views length = %d, want one entry per requested id including duplicates", len(got.Views)) + } + if got.Views[0].ViewsCount != 1 || got.Views[1].ViewsCount != 1 || got.Views[2].ViewsCount != 0 || got.Views[3].ViewsCount != 2 { + t.Fatalf("views counts = [%d %d %d %d], want [1 1 0 2]", got.Views[0].ViewsCount, got.Views[1].ViewsCount, got.Views[2].ViewsCount, got.Views[3].ViewsCount) + } + if recent, ok := got.Views[0].GetRecentViewers(); !ok || len(recent) != 1 || recent[0] != viewerOne.ID { + t.Fatalf("owner recent viewers = %v ok %v, want viewer one", recent, ok) + } + if findUserClass(got.Users, viewerOne.ID) == nil || findUserClass(got.Users, viewerTwo.ID) == nil { + t.Fatalf("views users = %+v, want recent viewer companions", got.Users) + } + if len(got.Users) != 2 { + t.Fatalf("views users length = %d, want deduped recent viewer companions", len(got.Users)) + } + + nonOwner, err := r.onStoriesGetStoriesViews(WithUserID(ctx, viewerTwo.ID), &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerUser{UserID: ownerUser.ID, AccessHash: ownerUser.AccessHash}, + ID: []int{1, 2}, + }) + if err != nil { + t.Fatalf("non-owner get stories views: %v", err) + } + if len(nonOwner.Views) != 2 || nonOwner.Views[0].ViewsCount != 1 || nonOwner.Views[1].ViewsCount != 2 { + t.Fatalf("non-owner views = %+v, want counts [1 2]", nonOwner.Views) + } + for i, view := range nonOwner.Views { + if view.HasViewers { + t.Fatalf("non-owner view[%d].has_viewers = true, want false", i) + } + if recent, ok := view.GetRecentViewers(); ok || len(recent) != 0 { + t.Fatalf("non-owner view[%d] recent viewers = %v ok %v, want absent", i, recent, ok) + } + } + if len(nonOwner.Users) != 0 { + t.Fatalf("non-owner users = %+v, want no recent-viewer companions", nonOwner.Users) + } + + if _, err := r.onStoriesGetStoriesViews(WithUserID(ctx, ownerUser.ID), &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerSelf{}, + }); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("empty get stories views err = %v, want STORY_ID_EMPTY", err) + } + if _, err := r.onStoriesGetStoriesViews(WithUserID(ctx, ownerUser.ID), &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerEmpty{}, + }); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("empty get stories views before peer err = %v, want STORY_ID_EMPTY", err) + } + + compatOnly := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700001200, 0)}) + if _, err := compatOnly.onStoriesGetStoriesViews(WithUserID(ctx, ownerUser.ID), &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("invalid get stories views id err = %v, want STORY_ID_INVALID", err) + } + if _, err := compatOnly.onStoriesGetStoriesViews(WithUserID(ctx, ownerUser.ID), &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("invalid get stories views id before peer err = %v, want STORY_ID_INVALID", err) + } + oversizedIDs := make([]int, domain.MaxStoryIDs+1) + for i := range oversizedIDs { + oversizedIDs[i] = 1 + } + if _, err := compatOnly.onStoriesGetStoriesViews(WithUserID(ctx, ownerUser.ID), &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerSelf{}, + ID: oversizedIDs, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("oversized get stories views id err = %v, want STORY_ID_INVALID", err) + } + if _, err := compatOnly.onStoriesGetStoriesViews(WithUserID(ctx, ownerUser.ID), &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: oversizedIDs, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("oversized get stories views id before peer err = %v, want STORY_ID_INVALID", err) + } +} + +func TestStoriesGetStoriesViewsIgnoresWrongOwnerRows(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + requester, err := userStore.Create(ctx, domain.User{AccessHash: 91221, Phone: "15550091221", FirstName: "Requester"}) + if err != nil { + t.Fatalf("create requester: %v", err) + } + ownerUser, err := userStore.Create(ctx, domain.User{AccessHash: 91222, Phone: "15550091222", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + wrongRecentViewer, err := userStore.Create(ctx, domain.User{AccessHash: 91223, Phone: "15550091223", FirstName: "Wrong", LastName: "Viewer"}) + if err != nil { + t.Fatalf("create wrong recent viewer: %v", err) + } + requestedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: ownerUser.ID} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: storiesByIDStoryService{list: domain.StoryList{Stories: []domain.Story{ + { + Owner: requestedPeer, + ID: 1, + Views: domain.StoryViews{ViewsCount: 3}, + }, + { + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: requester.ID}, + ID: 2, + Views: domain.StoryViews{ + ViewsCount: 99, + HasViewers: true, + RecentViewers: []int64{wrongRecentViewer.ID}, + }, + }, + }}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700001200, 0)}) + + got, err := r.onStoriesGetStoriesViews(WithUserID(ctx, requester.ID), &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerUser{UserID: ownerUser.ID, AccessHash: ownerUser.AccessHash}, + ID: []int{1, 2, 2}, + }) + if err != nil { + t.Fatalf("get stories views with wrong owner rows: %v", err) + } + if len(got.Views) != 3 { + t.Fatalf("views length = %d, want three request-aligned slots", len(got.Views)) + } + if got.Views[0].ViewsCount != 3 || got.Views[1].ViewsCount != 0 || got.Views[2].ViewsCount != 0 { + t.Fatalf("views counts = [%d %d %d], want [3 0 0]", got.Views[0].ViewsCount, got.Views[1].ViewsCount, got.Views[2].ViewsCount) + } + for i, view := range got.Views { + if view.HasViewers { + t.Fatalf("view[%d].has_viewers = true, want false", i) + } + if recent, ok := view.GetRecentViewers(); ok || len(recent) != 0 { + t.Fatalf("view[%d] recent viewers = %v ok %v, want absent", i, recent, ok) + } + } + if len(got.Users) != 0 { + t.Fatalf("users = %+v, want no companions from wrong-owner recent viewers", got.Users) + } +} + +func TestStoriesGetStoryReactionsListReturnsChannelAdminReactions(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + creator, err := userStore.Create(ctx, domain.User{AccessHash: 91301, Phone: "15550091301", FirstName: "Creator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + admin, err := userStore.Create(ctx, domain.User{AccessHash: 91302, Phone: "15550091302", FirstName: "Admin"}) + if err != nil { + t.Fatalf("create admin: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 91303, Phone: "15550091303", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + reactor, err := userStore.Create(ctx, domain.User{AccessHash: 91304, Phone: "15550091304", FirstName: "Reactor"}) + if err != nil { + t.Fatalf("create reactor: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "story reaction channel", + Broadcast: true, + MemberUserIDs: []int64{admin.ID, member.ID, reactor.ID}, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := channelService.EditAdmin(ctx, creator.ID, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: admin.ID, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + }, + Date: 1700000001, + }); err != nil { + t.Fatalf("promote admin: %v", err) + } + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000002, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + fire := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "🔥"} + if _, err := storyStore.SetReaction(ctx, reactor.ID, owner, 1, fire, 1700000003); err != nil { + t.Fatalf("set reaction: %v", err) + } + custom := &domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: 12345} + if _, err := storyStore.SetReaction(ctx, member.ID, owner, 1, custom, 1700000004); err != nil { + t.Fatalf("set custom reaction: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + req := &tg.StoriesGetStoryReactionsListRequest{ + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + ID: 1, + Limit: 10, + } + req.SetReaction(&tg.ReactionEmoji{Emoticon: "🔥"}) + + creatorList, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), req) + if err != nil { + t.Fatalf("creator get story reactions list: %v", err) + } + assertStoryReactionListViewer(t, creatorList, reactor.ID) + + adminList, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, admin.ID), req) + if err != nil { + t.Fatalf("admin get story reactions list: %v", err) + } + assertStoryReactionListViewer(t, adminList, reactor.ID) + + customReq := *req + customReq.SetReaction(&tg.ReactionCustomEmoji{DocumentID: 12345}) + customList, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &customReq) + if err != nil { + t.Fatalf("creator get custom story reactions list: %v", err) + } + assertStoryReactionListCustomViewer(t, customList, member.ID, 12345) + + invalidOffsetReq := *req + invalidOffsetReq.SetOffset("1:1700000003:" + strconv.FormatInt(reactor.ID, 10)) + if _, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &invalidOffsetReq); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("bad story reactions offset err = %v, want OFFSET_INVALID", err) + } + compatOnly := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + if _, err := compatOnly.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &invalidOffsetReq); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("bad story reactions offset fallback err = %v, want OFFSET_INVALID", err) + } + invalidLimitReq := *req + invalidLimitReq.Limit = -1 + if _, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &invalidLimitReq); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("negative story reactions limit err = %v, want LIMIT_INVALID", err) + } + invalidLimitReq.Limit = domain.MaxStoryListLimit + 1 + if _, err := compatOnly.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &invalidLimitReq); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("oversized story reactions limit fallback err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("nil story reactions list err = %v, want INPUT_REQUEST_INVALID", err) + } + for _, tt := range []struct { + name string + reaction tg.ReactionClass + }{ + {name: "typed nil emoji", reaction: (*tg.ReactionEmoji)(nil)}, + {name: "typed nil custom emoji", reaction: (*tg.ReactionCustomEmoji)(nil)}, + {name: "typed nil empty", reaction: (*tg.ReactionEmpty)(nil)}, + {name: "typed nil paid", reaction: (*tg.ReactionPaid)(nil)}, + } { + t.Run("bad filter "+tt.name, func(t *testing.T) { + badFilter := &tg.StoriesGetStoryReactionsListRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 1, + Limit: 10, + } + badFilter.SetReaction(tt.reaction) + if _, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), badFilter); err == nil || !tgerr.Is(err, "REACTION_INVALID") { + t.Fatalf("bad story reactions filter err = %v, want REACTION_INVALID", err) + } + }) + } + if _, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &tg.StoriesGetStoryReactionsListRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 0, + Limit: 10, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("bad story reactions id before peer err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &tg.StoriesGetStoryReactionsListRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 1, + Limit: -1, + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("bad story reactions limit before peer err = %v, want LIMIT_INVALID", err) + } + badOffsetBeforePeer := &tg.StoriesGetStoryReactionsListRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: 1, + Limit: 10, + } + badOffsetBeforePeer.SetOffset("1:1700000003:" + strconv.FormatInt(reactor.ID, 10)) + if _, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), badOffsetBeforePeer); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("bad story reactions offset before peer err = %v, want OFFSET_INVALID", err) + } + + if _, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, member.ID), req); err == nil || !strings.Contains(err.Error(), "CHAT_ADMIN_REQUIRED") { + t.Fatalf("member get story reactions list err = %v, want CHAT_ADMIN_REQUIRED", err) + } + if _, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &tg.StoriesGetStoryReactionsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + Limit: 10, + }); err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") { + t.Fatalf("user peer story reactions list err = %v, want PEER_ID_INVALID", err) + } +} + +func TestStoriesGetStoryReactionsListReturnsPublicRepost(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + creator, err := userStore.Create(ctx, domain.User{AccessHash: 91401, Phone: "15550091401", FirstName: "Creator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + reposter, err := userStore.Create(ctx, domain.User{AccessHash: 91402, Phone: "15550091402", FirstName: "Reposter"}) + if err != nil { + t.Fatalf("create reposter: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "story repost channel", + Broadcast: true, + MemberUserIDs: []int64{reposter.ID}, + Date: 1700000200, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + storyStore := memory.NewStoryStore() + sourceOwner := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: sourceOwner, + ID: 1, + Date: 1700000201, + ExpireDate: 1700003800, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + repostOwner := domain.Peer{Type: domain.PeerTypeUser, ID: reposter.ID} + repost, err := storyStore.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: repostOwner, + RandomID: 9140201, + Date: 1700000202, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + From: sourceOwner, + StoryID: 1, + }, + }) + if err != nil { + t.Fatalf("create repost story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000300, 0)}) + req := &tg.StoriesGetStoryReactionsListRequest{ + ForwardsFirst: true, + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + ID: 1, + Limit: 20, + } + list, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), req) + if err != nil { + t.Fatalf("creator get story repost reactions list: %v", err) + } + if list.Count != 1 || len(list.Reactions) != 1 { + t.Fatalf("repost reactions list = %+v, want one public repost", list) + } + repostReaction, ok := list.Reactions[0].(*tg.StoryReactionPublicRepost) + if !ok { + t.Fatalf("story reaction = %T, want *tg.StoryReactionPublicRepost", list.Reactions[0]) + } + peer, ok := repostReaction.PeerID.(*tg.PeerUser) + if !ok || peer.UserID != reposter.ID { + t.Fatalf("repost reaction peer = %T %+v, want user %d", repostReaction.PeerID, repostReaction.PeerID, reposter.ID) + } + storyItem, ok := repostReaction.Story.(*tg.StoryItem) + if !ok || storyItem.ID != repost.Story.ID { + t.Fatalf("repost reaction story = %T %+v, want story id %d", repostReaction.Story, repostReaction.Story, repost.Story.ID) + } + if findUserClass(list.Users, reposter.ID) == nil { + t.Fatalf("repost reaction users = %+v, want reposter %d", list.Users, reposter.ID) + } + if findChannelClass(list.Chats, created.Channel.ID) == nil { + t.Fatalf("repost reaction chats = %+v, want source channel %d", list.Chats, created.Channel.ID) + } + + filteredReq := *req + filteredReq.SetReaction(&tg.ReactionEmoji{Emoticon: "🔥"}) + filtered, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &filteredReq) + if err != nil { + t.Fatalf("creator get filtered story reactions list: %v", err) + } + if filtered.Count != 0 || len(filtered.Reactions) != 0 { + t.Fatalf("filtered reactions list = %+v, want no repost rows", filtered) + } + if _, err := storyStore.DeleteStories(ctx, repostOwner, []int{repost.Story.ID}, 1700000203); err != nil { + t.Fatalf("delete repost: %v", err) + } + empty, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), req) + if err != nil { + t.Fatalf("creator get story reactions list after delete: %v", err) + } + if empty.Count != 0 || len(empty.Reactions) != 0 { + t.Fatalf("repost reactions list after delete = %+v, want empty", empty) + } +} + +func TestStoriesGetStoryViewsListReturnsPublicMessageForward(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + author, err := userStore.Create(ctx, domain.User{AccessHash: 91411, Phone: "15550091411", FirstName: "Author"}) + if err != nil { + t.Fatalf("create author: %v", err) + } + poster, err := userStore.Create(ctx, domain.User{AccessHash: 91412, Phone: "15550091412", FirstName: "Poster"}) + if err != nil { + t.Fatalf("create poster: %v", err) + } + storyStore := memory.NewStoryStore() + authorPeer := domain.Peer{Type: domain.PeerTypeUser, ID: author.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: authorPeer, + ID: 11, + Date: 1700000211, + ExpireDate: 1700003811, + Public: true, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 9141101, AccessHash: 11, DCID: 2}}, + }}); err != nil { + t.Fatalf("upsert source story: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, poster.ID, domain.CreateChannelRequest{ + CreatorUserID: poster.ID, + Title: "story forward public", + Broadcast: true, + Date: 1700000212, + }) + if err != nil { + t.Fatalf("create public channel: %v", err) + } + public, err := channelService.UpdateUsername(ctx, poster.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: created.Channel.ID, + Username: "story_forward_public", + }) + if err != nil { + t.Fatalf("make channel public: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000300, 0)}) + updates, err := r.onMessagesSendMedia(WithUserID(ctx, poster.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerChannel{ChannelID: public.ID, AccessHash: public.AccessHash}, + Media: &tg.InputMediaStory{Peer: &tg.InputPeerUser{UserID: author.ID, AccessHash: author.AccessHash}, ID: 11}, + RandomID: 9141201, + }) + if err != nil { + t.Fatalf("send public story message: %v", err) + } + msg := newMessageFromUpdates(t, updates) + + views, err := r.onStoriesGetStoriesViews(WithUserID(ctx, author.ID), &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{11}, + }) + if err != nil { + t.Fatalf("get story views: %v", err) + } + if len(views.Views) != 1 || views.Views[0].ForwardsCount != 1 { + t.Fatalf("story views = %+v, want forwards_count=1", views.Views) + } + list, err := r.onStoriesGetStoryViewsList(WithUserID(ctx, author.ID), &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 11, + Limit: 20, + Offset: "", + }) + if err != nil { + t.Fatalf("get story views list: %v", err) + } + if list.Count != 1 || list.ForwardsCount != 1 || len(list.Views) != 1 { + t.Fatalf("story views list = %+v, want one public message forward", list) + } + forward, ok := list.Views[0].(*tg.StoryViewPublicForward) + if !ok { + t.Fatalf("story view = %T, want *tg.StoryViewPublicForward", list.Views[0]) + } + forwardMsg, ok := forward.Message.(*tg.Message) + if !ok || forwardMsg.ID != msg.ID { + t.Fatalf("forward message = %T %+v, want message id %d", forward.Message, forward.Message, msg.ID) + } + if peer, ok := forwardMsg.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != public.ID { + t.Fatalf("forward message peer = %T %+v, want channel %d", forwardMsg.PeerID, forwardMsg.PeerID, public.ID) + } + assertMessageMediaStory(t, forwardMsg.Media, author.ID, 11, true) + if findChannelClass(list.Chats, public.ID) == nil { + t.Fatalf("story views list chats = %+v, want public forward channel %d", list.Chats, public.ID) + } + + if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, poster.ID), &tg.ChannelsDeleteMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: public.ID, AccessHash: public.AccessHash}, + ID: []int{msg.ID}, + }); err != nil { + t.Fatalf("delete public story message: %v", err) + } + emptyList, err := r.onStoriesGetStoryViewsList(WithUserID(ctx, author.ID), &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 11, + Limit: 20, + }) + if err != nil { + t.Fatalf("get story views list after delete: %v", err) + } + if emptyList.Count != 0 || emptyList.ForwardsCount != 0 || len(emptyList.Views) != 0 { + t.Fatalf("story views list after delete = %+v, want empty", emptyList) + } +} + +func TestStoriesGetStoryReactionsListReturnsPublicMessageForward(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + creator, err := userStore.Create(ctx, domain.User{AccessHash: 91421, Phone: "15550091421", FirstName: "Creator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + source, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "source story channel", + Broadcast: true, + Date: 1700000220, + }) + if err != nil { + t.Fatalf("create source channel: %v", err) + } + target, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "target public channel", + Broadcast: true, + Date: 1700000221, + }) + if err != nil { + t.Fatalf("create target channel: %v", err) + } + targetPublic, err := channelService.UpdateUsername(ctx, creator.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: target.Channel.ID, + Username: "story_forward_target", + }) + if err != nil { + t.Fatalf("make target public: %v", err) + } + storyStore := memory.NewStoryStore() + sourcePeer := domain.Peer{Type: domain.PeerTypeChannel, ID: source.Channel.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: sourcePeer, + ID: 21, + Date: 1700000222, + ExpireDate: 1700003822, + Public: true, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 9142101, AccessHash: 21, DCID: 2}}, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000300, 0)}) + updates, err := r.onMessagesSendMedia(WithUserID(ctx, creator.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerChannel{ChannelID: targetPublic.ID, AccessHash: targetPublic.AccessHash}, + Media: &tg.InputMediaStory{ + Peer: &tg.InputPeerChannel{ChannelID: source.Channel.ID, AccessHash: source.Channel.AccessHash}, + ID: 21, + }, + RandomID: 9142102, + }) + if err != nil { + t.Fatalf("send channel story message: %v", err) + } + msg := newMessageFromUpdates(t, updates) + list, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &tg.StoriesGetStoryReactionsListRequest{ + ForwardsFirst: true, + Peer: &tg.InputPeerChannel{ChannelID: source.Channel.ID, AccessHash: source.Channel.AccessHash}, + ID: 21, + Limit: 20, + }) + if err != nil { + t.Fatalf("get story reactions list: %v", err) + } + if list.Count != 1 || len(list.Reactions) != 1 { + t.Fatalf("story reactions list = %+v, want one public message forward", list) + } + forward, ok := list.Reactions[0].(*tg.StoryReactionPublicForward) + if !ok { + t.Fatalf("story reaction = %T, want *tg.StoryReactionPublicForward", list.Reactions[0]) + } + forwardMsg, ok := forward.Message.(*tg.Message) + if !ok || forwardMsg.ID != msg.ID { + t.Fatalf("forward reaction message = %T %+v, want message id %d", forward.Message, forward.Message, msg.ID) + } + if peer, ok := forwardMsg.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != targetPublic.ID { + t.Fatalf("forward reaction message peer = %T %+v, want channel %d", forwardMsg.PeerID, forwardMsg.PeerID, targetPublic.ID) + } + storyMedia, ok := forwardMsg.Media.(*tg.MessageMediaStory) + if !ok { + t.Fatalf("forward reaction media = %T, want *tg.MessageMediaStory", forwardMsg.Media) + } + if peer, ok := storyMedia.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != source.Channel.ID || storyMedia.ID != 21 { + t.Fatalf("forward reaction story media = %+v, want source channel story", storyMedia) + } + if story, ok := storyMedia.GetStory(); !ok || story == nil { + t.Fatalf("forward reaction story media embedded = %T, want embedded story", story) + } + if findChannelClass(list.Chats, source.Channel.ID) == nil || findChannelClass(list.Chats, targetPublic.ID) == nil { + t.Fatalf("story reactions list chats = %+v, want source and target channels", list.Chats) + } + + filteredReq := &tg.StoriesGetStoryReactionsListRequest{ + ForwardsFirst: true, + Peer: &tg.InputPeerChannel{ChannelID: source.Channel.ID, AccessHash: source.Channel.AccessHash}, + ID: 21, + Limit: 20, + } + filteredReq.SetReaction(&tg.ReactionEmoji{Emoticon: "🔥"}) + filtered, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), filteredReq) + if err != nil { + t.Fatalf("get filtered story reactions list: %v", err) + } + if filtered.Count != 0 || len(filtered.Reactions) != 0 { + t.Fatalf("filtered story reactions list = %+v, want no public forwards", filtered) + } + + if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, creator.ID), &tg.ChannelsDeleteMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: targetPublic.ID, AccessHash: targetPublic.AccessHash}, + ID: []int{msg.ID}, + }); err != nil { + t.Fatalf("delete public story message: %v", err) + } + empty, err := r.onStoriesGetStoryReactionsList(WithUserID(ctx, creator.ID), &tg.StoriesGetStoryReactionsListRequest{ + ForwardsFirst: true, + Peer: &tg.InputPeerChannel{ChannelID: source.Channel.ID, AccessHash: source.Channel.AccessHash}, + ID: 21, + Limit: 20, + }) + if err != nil { + t.Fatalf("get story reactions after delete: %v", err) + } + if empty.Count != 0 || len(empty.Reactions) != 0 { + t.Fatalf("story reactions after delete = %+v, want empty", empty) + } +} + +func TestStatsGetStoryPublicForwardsReturnsStoryAndMessageForwards(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + creator, err := userStore.Create(ctx, domain.User{AccessHash: 91431, Phone: "15550091431", FirstName: "Creator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + source, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "stats source story channel", + Broadcast: true, + Date: 1700000300, + }) + if err != nil { + t.Fatalf("create source channel: %v", err) + } + target, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "stats target public channel", + Broadcast: true, + Date: 1700000301, + }) + if err != nil { + t.Fatalf("create target channel: %v", err) + } + targetPublic, err := channelService.UpdateUsername(ctx, creator.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: target.Channel.ID, + Username: "stats_story_forward_target", + }) + if err != nil { + t.Fatalf("make target public: %v", err) + } + repostOwner, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "stats repost public channel", + Broadcast: true, + Date: 1700000302, + }) + if err != nil { + t.Fatalf("create repost channel: %v", err) + } + repostPublic, err := channelService.UpdateUsername(ctx, creator.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: repostOwner.Channel.ID, + Username: "stats_story_repost_public", + }) + if err != nil { + t.Fatalf("make repost channel public: %v", err) + } + + storyStore := memory.NewStoryStore() + sourcePeer := domain.Peer{Type: domain.PeerTypeChannel, ID: source.Channel.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: sourcePeer, + ID: 31, + Date: 1700000303, + ExpireDate: 1700003903, + Public: true, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 9143101, AccessHash: 31, DCID: 2}}, + }}); err != nil { + t.Fatalf("upsert source story: %v", err) + } + repostPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: repostPublic.ID} + repost, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: repostPeer, + ID: 32, + Date: 1700000310, + ExpireDate: 1700003910, + Public: true, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 9143102, AccessHash: 32, DCID: 2}}, + Forward: &domain.StoryForward{ + From: sourcePeer, + StoryID: 31, + }, + }}) + if err != nil { + t.Fatalf("upsert public repost story: %v", err) + } + + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore, appstories.WithChannelStoryAccess(channelService)), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000400, 0)}) + updates, err := r.onMessagesSendMedia(WithUserID(ctx, creator.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerChannel{ChannelID: targetPublic.ID, AccessHash: targetPublic.AccessHash}, + Media: &tg.InputMediaStory{ + Peer: &tg.InputPeerChannel{ChannelID: source.Channel.ID, AccessHash: source.Channel.AccessHash}, + ID: 31, + }, + RandomID: 9143103, + }) + if err != nil { + t.Fatalf("send public story message: %v", err) + } + msg := newMessageFromUpdates(t, updates) + + req := &tg.StatsGetStoryPublicForwardsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: source.Channel.ID, AccessHash: source.Channel.AccessHash}, + ID: 31, + Limit: 1, + } + first, err := r.onStatsGetStoryPublicForwards(WithUserID(ctx, creator.ID), req) + if err != nil { + t.Fatalf("get story public forwards first page: %v", err) + } + if first.Count != 2 || len(first.Forwards) != 1 { + t.Fatalf("first public forwards page = %+v, want count 2 and one row", first) + } + next, ok := first.GetNextOffset() + if !ok || next == "" { + t.Fatalf("first public forwards next_offset = %q %v, want set", next, ok) + } + forwardMsg, ok := first.Forwards[0].(*tg.PublicForwardMessage) + if !ok { + t.Fatalf("first forward = %T, want *tg.PublicForwardMessage", first.Forwards[0]) + } + msgForward, ok := forwardMsg.Message.(*tg.Message) + if !ok || msgForward.ID != msg.ID { + t.Fatalf("first forward message = %T %+v, want message id %d", forwardMsg.Message, forwardMsg.Message, msg.ID) + } + if peer, ok := msgForward.PeerID.(*tg.PeerChannel); !ok || peer.ChannelID != targetPublic.ID { + t.Fatalf("first forward peer = %T %+v, want target channel %d", msgForward.PeerID, msgForward.PeerID, targetPublic.ID) + } + storyMedia, ok := msgForward.Media.(*tg.MessageMediaStory) + if !ok { + t.Fatalf("first forward media = %T, want *tg.MessageMediaStory", msgForward.Media) + } + if peer, ok := storyMedia.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != source.Channel.ID || storyMedia.ID != 31 { + t.Fatalf("first forward story media = %+v, want source channel story", storyMedia) + } + if story, ok := storyMedia.GetStory(); !ok || story == nil { + t.Fatalf("first forward story media embedded = %T, want embedded story", story) + } + if findChannelClass(first.Chats, source.Channel.ID) == nil || findChannelClass(first.Chats, targetPublic.ID) == nil { + t.Fatalf("first public forwards chats = %+v, want source and target channels", first.Chats) + } + assertChannelStoryMaxID(t, findChannelClass(first.Chats, source.Channel.ID), 31) + + secondReq := *req + secondReq.Offset = next + second, err := r.onStatsGetStoryPublicForwards(WithUserID(ctx, creator.ID), &secondReq) + if err != nil { + t.Fatalf("get story public forwards second page: %v", err) + } + if second.Count != 2 || len(second.Forwards) != 1 { + t.Fatalf("second public forwards page = %+v, want count 2 and one row", second) + } + if next, ok := second.GetNextOffset(); ok || next != "" { + t.Fatalf("second public forwards next_offset = %q %v, want empty", next, ok) + } + forwardStory, ok := second.Forwards[0].(*tg.PublicForwardStory) + if !ok { + t.Fatalf("second forward = %T, want *tg.PublicForwardStory", second.Forwards[0]) + } + if peer, ok := forwardStory.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != repostPublic.ID { + t.Fatalf("second forward peer = %T %+v, want repost channel %d", forwardStory.Peer, forwardStory.Peer, repostPublic.ID) + } + storyItem, ok := forwardStory.Story.(*tg.StoryItem) + if !ok || storyItem.ID != repost.ID { + t.Fatalf("second forward story = %T %+v, want story id %d", forwardStory.Story, forwardStory.Story, repost.ID) + } + if findChannelClass(second.Chats, source.Channel.ID) == nil || findChannelClass(second.Chats, repostPublic.ID) == nil { + t.Fatalf("second public forwards chats = %+v, want source and repost channels", second.Chats) + } + assertChannelStoryMaxID(t, findChannelClass(second.Chats, source.Channel.ID), 31) + assertChannelStoryMaxID(t, findChannelClass(second.Chats, repostPublic.ID), 32) + + if _, err := r.onChannelsDeleteMessages(WithUserID(ctx, creator.ID), &tg.ChannelsDeleteMessagesRequest{ + Channel: &tg.InputChannel{ChannelID: targetPublic.ID, AccessHash: targetPublic.AccessHash}, + ID: []int{msg.ID}, + }); err != nil { + t.Fatalf("delete public story message: %v", err) + } + afterMessageDelete, err := r.onStatsGetStoryPublicForwards(WithUserID(ctx, creator.ID), &tg.StatsGetStoryPublicForwardsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: source.Channel.ID, AccessHash: source.Channel.AccessHash}, + ID: 31, + Limit: 20, + }) + if err != nil { + t.Fatalf("get story public forwards after message delete: %v", err) + } + if afterMessageDelete.Count != 1 || len(afterMessageDelete.Forwards) != 1 { + t.Fatalf("public forwards after message delete = %+v, want one repost", afterMessageDelete) + } + if _, ok := afterMessageDelete.Forwards[0].(*tg.PublicForwardStory); !ok { + t.Fatalf("public forwards after message delete row = %T, want *tg.PublicForwardStory", afterMessageDelete.Forwards[0]) + } + + if _, err := storyStore.DeleteStories(ctx, repostPeer, []int{repost.ID}, 1700000410); err != nil { + t.Fatalf("delete public repost: %v", err) + } + empty, err := r.onStatsGetStoryPublicForwards(WithUserID(ctx, creator.ID), &tg.StatsGetStoryPublicForwardsRequest{ + Peer: &tg.InputPeerChannel{ChannelID: source.Channel.ID, AccessHash: source.Channel.AccessHash}, + ID: 31, + Limit: 20, + }) + if err != nil { + t.Fatalf("get story public forwards after repost delete: %v", err) + } + if empty.Count != 0 || len(empty.Forwards) != 0 { + t.Fatalf("public forwards after repost delete = %+v, want empty", empty) + } +} + +func TestStatsStoryRPCsValidateStoryIDBeforePeer(t *testing.T) { + ctx := WithUserID(context.Background(), 1000000001) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000400, 0)}) + + for _, id := range []int{0, -1, domain.MaxStoryID + 1} { + if _, err := r.onStatsGetStoryStats(ctx, &tg.StatsGetStoryStatsRequest{ID: id}); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("stats.getStoryStats id %d err = %v, want STORY_ID_INVALID", id, err) + } + if _, err := r.onStatsGetStoryPublicForwards(ctx, &tg.StatsGetStoryPublicForwardsRequest{ID: id, Limit: 1}); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("stats.getStoryPublicForwards id %d err = %v, want STORY_ID_INVALID", id, err) + } + } +} + +func TestStatsGetStoryStatsUsesStoryOwnerPermissions(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 91441, Phone: "15550091441", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + other, err := userStore.Create(ctx, domain.User{AccessHash: 91442, Phone: "15550091442", FirstName: "Other"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + admin, err := userStore.Create(ctx, domain.User{AccessHash: 91443, Phone: "15550091443", FirstName: "Admin"}) + if err != nil { + t.Fatalf("create admin: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 91444, Phone: "15550091444", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story stats channel", + Broadcast: true, + MemberUserIDs: []int64{admin.ID, member.ID}, + Date: 1700000400, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := channelService.EditAdmin(ctx, owner.ID, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: admin.ID, + AdminRights: domain.ChannelAdminRights{ + EditStories: true, + }, + Date: 1700000401, + }); err != nil { + t.Fatalf("promote story stats admin: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(memory.NewStoryStore(), appstories.WithChannelStoryAccess(channelService)), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000402, 0)}) + + if _, err := r.onStatsGetStoryStats(WithUserID(ctx, owner.ID), &tg.StatsGetStoryStatsRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + }); err != nil { + t.Fatalf("owner self story stats: %v", err) + } + if _, err := r.onStatsGetStoryStats(WithUserID(ctx, owner.ID), &tg.StatsGetStoryStatsRequest{ + Peer: &tg.InputPeerUser{UserID: other.ID, AccessHash: other.AccessHash}, + ID: 1, + }); err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("other user story stats err = %v, want PEER_ID_INVALID", err) + } + channelPeer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + if _, err := r.onStatsGetStoryStats(WithUserID(ctx, member.ID), &tg.StatsGetStoryStatsRequest{ + Peer: channelPeer, + ID: 1, + }); err == nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") { + t.Fatalf("member channel story stats err = %v, want CHAT_ADMIN_REQUIRED", err) + } + if _, err := r.onStatsGetStoryStats(WithUserID(ctx, admin.ID), &tg.StatsGetStoryStatsRequest{ + Peer: channelPeer, + ID: 1, + }); err != nil { + t.Fatalf("admin channel story stats: %v", err) + } +} + +func TestStoriesSendReactionDoesNotFakeChannelAdminNewReactionNotification(t *testing.T) { + ctx := context.Background() + creatorAuthKeyID := [8]byte{8, 1, 1} + adminAuthKeyID := [8]byte{8, 1, 2} + reactorAuthKeyID := [8]byte{8, 1, 3} + userStore := memory.NewUserStore() + creator, err := userStore.Create(ctx, domain.User{AccessHash: 91321, Phone: "15550091321", FirstName: "Creator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + admin, err := userStore.Create(ctx, domain.User{AccessHash: 91322, Phone: "15550091322", FirstName: "Admin"}) + if err != nil { + t.Fatalf("create admin: %v", err) + } + reactor, err := userStore.Create(ctx, domain.User{AccessHash: 91323, Phone: "15550091323", FirstName: "Reactor"}) + if err != nil { + t.Fatalf("create reactor: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "story reaction notification channel", + Broadcast: true, + MemberUserIDs: []int64{admin.ID, reactor.ID}, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := channelService.EditAdmin(ctx, creator.ID, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: admin.ID, + AdminRights: domain.ChannelAdminRights{ + EditStories: true, + }, + Date: 1700000001, + }); err != nil { + t.Fatalf("promote story admin: %v", err) + } + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000002, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + updates, err := r.onStoriesSendReaction( + WithSessionID(WithAuthKeyID(WithUserID(ctx, reactor.ID), reactorAuthKeyID), 8123), + &tg.StoriesSendReactionRequest{ + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + StoryID: 1, + Reaction: &tg.ReactionEmoji{Emoticon: "🔥"}, + }, + ) + if err != nil { + t.Fatalf("send channel story reaction: %v", err) + } + gotUpdates, ok := updates.(*tg.Updates) + if !ok || len(gotUpdates.Updates) != 1 { + t.Fatalf("send updates = %T %+v, want one updateSentStoryReaction", updates, updates) + } + sentReaction, ok := gotUpdates.Updates[0].(*tg.UpdateSentStoryReaction) + if !ok { + t.Fatalf("send update = %T, want updateSentStoryReaction", gotUpdates.Updates[0]) + } + if peer, ok := sentReaction.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != created.Channel.ID { + t.Fatalf("sent reaction peer = %T %+v, want channel", sentReaction.Peer, sentReaction.Peer) + } + + for _, tt := range []struct { + name string + userID int64 + authKeyID [8]byte + }{ + {name: "creator", userID: creator.ID, authKeyID: creatorAuthKeyID}, + {name: "story admin", userID: admin.ID, authKeyID: adminAuthKeyID}, + } { + t.Run(tt.name, func(t *testing.T) { + diff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, tt.userID), tt.authKeyID), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference: %v", err) + } + if _, ok := diff.(*tg.UpdatesDifferenceEmpty); !ok { + t.Fatalf("difference = %T %+v, want empty; updateNewStoryReaction has no owner/channel field for channel admin routing", diff, diff) + } + }) + } +} + +func TestStoriesGetStoryViewsListReturnsChannelAdminViews(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + creator, err := userStore.Create(ctx, domain.User{AccessHash: 92301, Phone: "15550092301", FirstName: "Creator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + admin, err := userStore.Create(ctx, domain.User{AccessHash: 92302, Phone: "15550092302", FirstName: "Admin"}) + if err != nil { + t.Fatalf("create admin: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 92303, Phone: "15550092303", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 92304, Phone: "15550092304", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "story views channel", + Broadcast: true, + MemberUserIDs: []int64{admin.ID, member.ID, viewer.ID}, + Date: 1700005000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := channelService.EditAdmin(ctx, creator.ID, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: admin.ID, + AdminRights: domain.ChannelAdminRights{ + EditStories: true, + }, + Date: 1700005001, + }); err != nil { + t.Fatalf("promote story admin: %v", err) + } + storyStore := memory.NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700005002, + ExpireDate: 1700008600, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + if _, err := storyStore.IncrementViews(ctx, viewer.ID, owner, []int{1}, 1700005003); err != nil { + t.Fatalf("increment channel story view: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore, appstories.WithChannelStoryAccess(channelService)), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700005100, 0)}) + req := &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}, + ID: 1, + Limit: 10, + } + + creatorList, err := r.onStoriesGetStoryViewsList(WithUserID(ctx, creator.ID), req) + if err != nil { + t.Fatalf("creator get story views list: %v", err) + } + assertStoryViewsListViewer(t, creatorList, viewer.ID) + + adminList, err := r.onStoriesGetStoryViewsList(WithUserID(ctx, admin.ID), req) + if err != nil { + t.Fatalf("admin get story views list: %v", err) + } + assertStoryViewsListViewer(t, adminList, viewer.ID) + + if _, err := r.onStoriesGetStoryViewsList(WithUserID(ctx, member.ID), req); err == nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") { + t.Fatalf("member get story views list err = %v, want CHAT_ADMIN_REQUIRED", err) + } +} + +func TestStoriesChannelAdminCanSendEditDeleteStory(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{6, 6, 1} + userStore := memory.NewUserStore() + creator, err := userStore.Create(ctx, domain.User{AccessHash: 91401, Phone: "15550091401", FirstName: "Creator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + admin, err := userStore.Create(ctx, domain.User{AccessHash: 91402, Phone: "15550091402", FirstName: "Admin"}) + if err != nil { + t.Fatalf("create admin: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 91403, Phone: "15550091403", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "story publish channel", + Broadcast: true, + MemberUserIDs: []int64{admin.ID, member.ID}, + Date: 1700002000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := channelService.EditAdmin(ctx, creator.ID, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: admin.ID, + AdminRights: domain.ChannelAdminRights{ + PostStories: true, + EditStories: true, + DeleteStories: true, + }, + Date: 1700002001, + }); err != nil { + t.Fatalf("promote story admin: %v", err) + } + storyStore := memory.NewStoryStore() + stateStore := memory.NewUpdateStateStore() + updateStore := memory.NewUpdateEventStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore, appstories.WithChannelStoryAccess(channelService)), + Updates: appupdates.NewService(stateStore, updateStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700002100, 0)}) + channelPeer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + if _, err := r.onStoriesCanSendStory(WithUserID(ctx, member.ID), channelPeer); err == nil || !strings.Contains(err.Error(), "CHAT_ADMIN_REQUIRED") { + t.Fatalf("member canSendStory err = %v, want CHAT_ADMIN_REQUIRED", err) + } + canSend, err := r.onStoriesCanSendStory(WithUserID(ctx, admin.ID), channelPeer) + if err != nil { + t.Fatalf("admin canSendStory: %v", err) + } + if canSend.CountRemains <= 0 { + t.Fatalf("canSendStory = %+v, want positive count", canSend) + } + + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, admin.ID), authKeyID), 66) + updates, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: channelPeer, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 62, Parts: 1, Name: "channel-story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 9001, + Period: 86400, + Caption: "channel story", + }) + if err != nil { + t.Fatalf("send channel story: %v", err) + } + sendUpdates, ok := updates.(*tg.Updates) + if !ok || len(sendUpdates.Updates) != 2 { + t.Fatalf("send updates = %T %+v, want updateStoryID + updateStory", updates, updates) + } + idUpdate, ok := sendUpdates.Updates[0].(*tg.UpdateStoryID) + if !ok || idUpdate.ID != 1 || idUpdate.RandomID != 9001 { + t.Fatalf("story id update = %T %+v, want id 1 random 9001", sendUpdates.Updates[0], sendUpdates.Updates[0]) + } + storyUpdate, ok := sendUpdates.Updates[1].(*tg.UpdateStory) + if !ok { + t.Fatalf("story update = %T, want updateStory", sendUpdates.Updates[1]) + } + if peer, ok := storyUpdate.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != created.Channel.ID { + t.Fatalf("story update peer = %T %+v, want channel peer", storyUpdate.Peer, storyUpdate.Peer) + } + item, ok := storyUpdate.Story.(*tg.StoryItem) + if !ok || item.ID != 1 || item.Caption != "channel story" || !item.Public { + t.Fatalf("sent channel story = %T %+v, want public story item", storyUpdate.Story, storyUpdate.Story) + } + assertChannelStoryMaxID(t, findChannelClass(sendUpdates.Chats, created.Channel.ID), 1) + + retryUpdatesClass, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: channelPeer, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 63, Parts: 1, Name: "channel-story-retry.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 9001, + Period: 86400, + Caption: "retry must not replace channel story", + }) + if err != nil { + t.Fatalf("retry channel story: %v", err) + } + retryUpdates, ok := retryUpdatesClass.(*tg.Updates) + if !ok || len(retryUpdates.Updates) != 2 { + t.Fatalf("retry updates = %T %+v, want updateStoryID + updateStory", retryUpdatesClass, retryUpdatesClass) + } + retryID, ok := retryUpdates.Updates[0].(*tg.UpdateStoryID) + if !ok || retryID.ID != idUpdate.ID || retryID.RandomID != 9001 { + t.Fatalf("retry story id update = %T %+v, want original id/random", retryUpdates.Updates[0], retryUpdates.Updates[0]) + } + retryStoryUpdate, ok := retryUpdates.Updates[1].(*tg.UpdateStory) + if !ok { + t.Fatalf("retry story update = %T, want updateStory", retryUpdates.Updates[1]) + } + retryItem, ok := retryStoryUpdate.Story.(*tg.StoryItem) + if !ok || retryItem.ID != idUpdate.ID || retryItem.Caption != "channel story" { + t.Fatalf("retry channel story = %T %+v, want original story snapshot", retryStoryUpdate.Story, retryStoryUpdate.Story) + } + + memberStories, err := r.onStoriesGetPeerStories(WithUserID(ctx, member.ID), &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}) + if err != nil { + t.Fatalf("member get channel stories: %v", err) + } + if len(memberStories.Stories.Stories) != 1 { + t.Fatalf("member channel stories = %+v, want one story", memberStories.Stories.Stories) + } + memberItem, ok := memberStories.Stories.Stories[0].(*tg.StoryItem) + if !ok || memberItem.ID != idUpdate.ID || memberItem.Caption != "channel story" { + t.Fatalf("member channel story after retry = %T %+v, want original story", memberStories.Stories.Stories[0], memberStories.Stories.Stories[0]) + } + + edit := &tg.StoriesEditStoryRequest{Peer: channelPeer, ID: idUpdate.ID} + edit.SetCaption("edited channel story") + updates, err = r.onStoriesEditStory(reqCtx, edit) + if err != nil { + t.Fatalf("edit channel story: %v", err) + } + editUpdates := updates.(*tg.Updates) + edited, ok := editUpdates.Updates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("edit update = %T, want updateStory", editUpdates.Updates[0]) + } + editedItem, ok := edited.Story.(*tg.StoryItem) + if !ok || !editedItem.Edited || editedItem.Caption != "edited channel story" { + t.Fatalf("edited story = %T %+v, want edited channel story", edited.Story, edited.Story) + } + + pinnedIDs, err := r.onStoriesTogglePinned(reqCtx, &tg.StoriesTogglePinnedRequest{ + Peer: channelPeer, + ID: []int{idUpdate.ID}, + Pinned: true, + }) + if err != nil { + t.Fatalf("pin channel story: %v", err) + } + if len(pinnedIDs) != 1 || pinnedIDs[0] != idUpdate.ID { + t.Fatalf("pinned ids = %v, want story id", pinnedIDs) + } + retryPinnedIDs, err := r.onStoriesTogglePinned(reqCtx, &tg.StoriesTogglePinnedRequest{ + Peer: channelPeer, + ID: []int{idUpdate.ID}, + Pinned: true, + }) + if err != nil { + t.Fatalf("retry pin channel story: %v", err) + } + if len(retryPinnedIDs) != 1 || retryPinnedIDs[0] != idUpdate.ID { + t.Fatalf("retry pinned ids = %v, want story id echo", retryPinnedIDs) + } + pinnedStories, err := r.onStoriesGetPinnedStories(WithUserID(ctx, member.ID), &tg.StoriesGetPinnedStoriesRequest{ + Peer: channelPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("member get pinned channel stories: %v", err) + } + if len(pinnedStories.Stories) != 1 { + t.Fatalf("member pinned channel stories = %+v, want one story", pinnedStories.Stories) + } + + deletedIDs, err := r.onStoriesDeleteStories(reqCtx, &tg.StoriesDeleteStoriesRequest{ + Peer: channelPeer, + ID: []int{idUpdate.ID}, + }) + if err != nil { + t.Fatalf("delete channel story: %v", err) + } + if len(deletedIDs) != 1 || deletedIDs[0] != idUpdate.ID { + t.Fatalf("deleted ids = %v, want story id", deletedIDs) + } + memberStories, err = r.onStoriesGetPeerStories(WithUserID(ctx, member.ID), &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash}) + if err != nil { + t.Fatalf("member get channel stories after delete: %v", err) + } + if len(memberStories.Stories.Stories) != 0 { + t.Fatalf("member channel stories after delete = %+v, want empty", memberStories.Stories.Stories) + } + memberDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, member.ID), [8]byte{6, 6, 2}), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get member difference: %v", err) + } + memberUpdates, ok := memberDiff.(*tg.UpdatesDifference) + if !ok || memberUpdates.State.Pts != 4 || len(memberUpdates.OtherUpdates) != 4 { + t.Fatalf("member difference = %T %+v, want pts 4 and four story updates", memberDiff, memberDiff) + } + memberSend, ok := memberUpdates.OtherUpdates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("member first diff update = %T, want updateStory", memberUpdates.OtherUpdates[0]) + } + if peer, ok := memberSend.Peer.(*tg.PeerChannel); !ok || peer.ChannelID != created.Channel.ID { + t.Fatalf("member story peer = %T %+v, want channel peer", memberSend.Peer, memberSend.Peer) + } + memberSendItem, ok := memberSend.Story.(*tg.StoryItem) + if !ok || memberSendItem.ID != idUpdate.ID || memberSendItem.Caption != "channel story" { + t.Fatalf("member sent story = %T %+v, want original story item", memberSend.Story, memberSend.Story) + } + if _, hasViews := memberSendItem.GetViews(); memberSendItem.Out || len(memberSendItem.Privacy) != 0 || hasViews { + t.Fatalf("member sent story owner-only fields = out:%v privacy:%v views:%+v, want stripped", memberSendItem.Out, memberSendItem.Privacy, memberSendItem.Views) + } + memberEdit, ok := memberUpdates.OtherUpdates[1].(*tg.UpdateStory) + if !ok { + t.Fatalf("member second diff update = %T, want updateStory", memberUpdates.OtherUpdates[1]) + } + memberEditItem, ok := memberEdit.Story.(*tg.StoryItem) + if !ok || !memberEditItem.Edited || memberEditItem.Caption != "edited channel story" { + t.Fatalf("member edited story = %T %+v, want edited story item", memberEdit.Story, memberEdit.Story) + } + memberPinned, ok := memberUpdates.OtherUpdates[2].(*tg.UpdateStory) + if !ok { + t.Fatalf("member third diff update = %T, want updateStory", memberUpdates.OtherUpdates[2]) + } + memberPinnedItem, ok := memberPinned.Story.(*tg.StoryItem) + if !ok || !memberPinnedItem.Pinned { + t.Fatalf("member pinned story = %T %+v, want pinned story item", memberPinned.Story, memberPinned.Story) + } + memberDelete, ok := memberUpdates.OtherUpdates[3].(*tg.UpdateStory) + if !ok { + t.Fatalf("member fourth diff update = %T, want updateStory", memberUpdates.OtherUpdates[3]) + } + if deleted, ok := memberDelete.Story.(*tg.StoryItemDeleted); !ok || deleted.ID != idUpdate.ID { + t.Fatalf("member deleted story = %T %+v, want storyItemDeleted", memberDelete.Story, memberDelete.Story) + } + if findChannelClass(memberUpdates.Chats, created.Channel.ID) == nil { + t.Fatalf("member difference chats = %+v, want story owner channel", memberUpdates.Chats) + } + diff, err := r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get admin difference: %v", err) + } + got, ok := diff.(*tg.UpdatesDifference) + if !ok || got.State.Pts != 4 || len(got.OtherUpdates) != 4 { + t.Fatalf("admin difference = %T %+v, want pts 4 and four story updates", diff, diff) + } + last, ok := got.OtherUpdates[3].(*tg.UpdateStory) + if !ok { + t.Fatalf("last diff update = %T, want updateStory", got.OtherUpdates[2]) + } + if _, ok := last.Story.(*tg.StoryItemDeleted); !ok { + t.Fatalf("last diff story = %T, want storyItemDeleted", last.Story) + } +} + +func TestStoriesAlbumCompatHandlersValidatePeerAndPermissions(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + creator, err := userStore.Create(ctx, domain.User{AccessHash: 92201, Phone: "15550092201", FirstName: "Creator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + admin, err := userStore.Create(ctx, domain.User{AccessHash: 92202, Phone: "15550092202", FirstName: "Admin"}) + if err != nil { + t.Fatalf("create admin: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 92203, Phone: "15550092203", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, creator.ID, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "story album channel", + Broadcast: true, + MemberUserIDs: []int64{admin.ID, member.ID}, + Date: 1700004000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := channelService.EditAdmin(ctx, creator.ID, domain.EditChannelAdminRequest{ + ChannelID: created.Channel.ID, + MemberID: admin.ID, + AdminRights: domain.ChannelAdminRights{ + EditStories: true, + }, + Date: 1700004001, + }); err != nil { + t.Fatalf("promote album admin: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700004100, 0)}) + creatorCtx := WithUserID(ctx, creator.ID) + adminCtx := WithUserID(ctx, admin.ID) + memberCtx := WithUserID(ctx, member.ID) + channelPeer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + albums, err := r.onStoriesGetAlbums(creatorCtx, &tg.StoriesGetAlbumsRequest{Peer: channelPeer, Hash: 123}) + if err != nil { + t.Fatalf("get albums: %v", err) + } + gotAlbums, ok := albums.(*tg.StoriesAlbums) + if !ok { + t.Fatalf("albums = %T, want *tg.StoriesAlbums", albums) + } + if gotAlbums.Hash != 0 || len(gotAlbums.Albums) != 0 { + t.Fatalf("albums = %+v, want empty modified list", gotAlbums) + } + albumStories, err := r.onStoriesGetAlbumStories(creatorCtx, &tg.StoriesGetAlbumStoriesRequest{ + Peer: channelPeer, + AlbumID: 1, + Limit: 20, + }) + if err != nil { + t.Fatalf("get album stories: %v", err) + } + if len(albumStories.Stories) != 0 || len(albumStories.Users) != 0 || len(albumStories.Chats) != 0 { + t.Fatalf("album stories = %+v, want empty stories page", albumStories) + } + if _, err := r.onStoriesGetAlbumStories(creatorCtx, &tg.StoriesGetAlbumStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 0, + Limit: 20, + }); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("invalid album id before peer err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesGetAlbumStories(creatorCtx, &tg.StoriesGetAlbumStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 1, + Offset: -1, + Limit: 20, + }); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("invalid album offset before peer err = %v, want OFFSET_INVALID", err) + } + if _, err := r.onStoriesGetAlbumStories(creatorCtx, &tg.StoriesGetAlbumStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 1, + Offset: domain.MaxStoryAlbumOffset + 1, + Limit: 20, + }); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("oversized album offset before peer err = %v, want OFFSET_INVALID", err) + } + if _, err := r.onStoriesGetAlbumStories(creatorCtx, &tg.StoriesGetAlbumStoriesRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 1, + Limit: domain.MaxStoryListLimit + 1, + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("invalid album limit before peer err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesReorderAlbums(adminCtx, &tg.StoriesReorderAlbumsRequest{ + Peer: &tg.InputPeerEmpty{}, + Order: []int{1, 1}, + }); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("duplicate reorder albums before peer err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesReorderAlbums(adminCtx, &tg.StoriesReorderAlbumsRequest{ + Peer: &tg.InputPeerEmpty{}, + Order: make([]int, domain.MaxStoryIDs+1), + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("oversized reorder albums before peer err = %v, want LIMIT_INVALID", err) + } + okReorder, err := r.onStoriesReorderAlbums(adminCtx, &tg.StoriesReorderAlbumsRequest{ + Peer: channelPeer, + Order: []int{2, 1}, + }) + if err != nil || !okReorder { + t.Fatalf("admin reorder albums = %v, %v, want true nil", okReorder, err) + } + okDelete, err := r.onStoriesDeleteAlbum(adminCtx, &tg.StoriesDeleteAlbumRequest{ + Peer: channelPeer, + AlbumID: 1, + }) + if err != nil || !okDelete { + t.Fatalf("admin delete album = %v, %v, want true nil", okDelete, err) + } + if _, err := r.onStoriesDeleteAlbum(adminCtx, &tg.StoriesDeleteAlbumRequest{Peer: &tg.InputPeerEmpty{}, AlbumID: 0}); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("invalid delete album id before peer err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesReorderAlbums(memberCtx, &tg.StoriesReorderAlbumsRequest{Peer: channelPeer, Order: []int{1}}); err == nil || !tgerr.Is(err, "CHAT_ADMIN_REQUIRED") { + t.Fatalf("member reorder albums err = %v, want CHAT_ADMIN_REQUIRED", err) + } + if _, err := r.onStoriesReorderAlbums(adminCtx, &tg.StoriesReorderAlbumsRequest{Peer: channelPeer, Order: []int{1, 1}}); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("duplicate reorder albums err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesReorderAlbums(adminCtx, &tg.StoriesReorderAlbumsRequest{Peer: channelPeer, Order: make([]int, domain.MaxStoryIDs+1)}); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("oversized reorder albums err = %v, want LIMIT_INVALID", err) + } + + if _, err := r.onStoriesCreateAlbum(creatorCtx, &tg.StoriesCreateAlbumRequest{ + Peer: &tg.InputPeerSelf{}, + Title: "Favorites", + Stories: []int{1}, + }); err == nil || !tgerr.Is(err, "METHOD_INVALID") { + t.Fatalf("create album err = %v, want METHOD_INVALID", err) + } + if _, err := r.onStoriesCreateAlbum(creatorCtx, &tg.StoriesCreateAlbumRequest{ + Peer: &tg.InputPeerSelf{}, + Title: "Favorites", + Stories: []int{1, 1}, + }); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("duplicate create album stories err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesCreateAlbum(creatorCtx, &tg.StoriesCreateAlbumRequest{ + Peer: &tg.InputPeerEmpty{}, + Title: strings.Repeat("x", maxStoryAlbumTitleLength+1), + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("long title create before peer err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesCreateAlbum(creatorCtx, &tg.StoriesCreateAlbumRequest{ + Peer: &tg.InputPeerEmpty{}, + Title: "Favorites", + Stories: []int{1, 1}, + }); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("duplicate create album stories before peer err = %v, want INPUT_REQUEST_INVALID", err) + } + updateReq := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerSelf{}, + AlbumID: 1, + } + updateReq.SetTitle("Travel") + if _, err := r.onStoriesUpdateAlbum(creatorCtx, updateReq); err == nil || !tgerr.Is(err, "METHOD_INVALID") { + t.Fatalf("update album err = %v, want METHOD_INVALID", err) + } + invalidUpdateReq := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerSelf{}, + AlbumID: 1, + } + invalidUpdateReq.SetAddStories([]int{0}) + if _, err := r.onStoriesUpdateAlbum(creatorCtx, invalidUpdateReq); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("invalid add stories update album err = %v, want STORY_ID_INVALID", err) + } + invalidAlbumUpdateReq := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 0, + } + if _, err := r.onStoriesUpdateAlbum(creatorCtx, invalidAlbumUpdateReq); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("invalid update album id before peer err = %v, want INPUT_REQUEST_INVALID", err) + } + emptyUpdateReq := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 1, + } + if _, err := r.onStoriesUpdateAlbum(creatorCtx, emptyUpdateReq); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("empty update album mutation before peer err = %v, want INPUT_REQUEST_INVALID", err) + } + emptyUpdateVectors := []struct { + name string + set func(*tg.StoriesUpdateAlbumRequest) + }{ + {name: "add stories", set: func(req *tg.StoriesUpdateAlbumRequest) { req.SetAddStories([]int{}) }}, + {name: "delete stories", set: func(req *tg.StoriesUpdateAlbumRequest) { req.SetDeleteStories([]int{}) }}, + {name: "order stories", set: func(req *tg.StoriesUpdateAlbumRequest) { req.SetOrder([]int{}) }}, + } + for _, tc := range emptyUpdateVectors { + req := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 1, + } + tc.set(req) + if _, err := r.onStoriesUpdateAlbum(creatorCtx, req); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("empty update album %s vector before peer err = %v, want INPUT_REQUEST_INVALID", tc.name, err) + } + } + longTitleUpdateReq := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 1, + } + longTitleUpdateReq.SetTitle(strings.Repeat("x", maxStoryAlbumTitleLength+1)) + if _, err := r.onStoriesUpdateAlbum(creatorCtx, longTitleUpdateReq); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("long title update before peer err = %v, want LIMIT_INVALID", err) + } + duplicateAddReq := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerSelf{}, + AlbumID: 1, + } + duplicateAddReq.SetAddStories([]int{1, 1}) + if _, err := r.onStoriesUpdateAlbum(creatorCtx, duplicateAddReq); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("duplicate add stories update album err = %v, want INPUT_REQUEST_INVALID", err) + } + duplicateAddBeforePeerReq := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerEmpty{}, + AlbumID: 1, + } + duplicateAddBeforePeerReq.SetAddStories([]int{1, 1}) + if _, err := r.onStoriesUpdateAlbum(creatorCtx, duplicateAddBeforePeerReq); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("duplicate add stories update before peer err = %v, want INPUT_REQUEST_INVALID", err) + } + duplicateDeleteReq := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerSelf{}, + AlbumID: 1, + } + duplicateDeleteReq.SetDeleteStories([]int{1, 1}) + if _, err := r.onStoriesUpdateAlbum(creatorCtx, duplicateDeleteReq); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("duplicate delete stories update album err = %v, want INPUT_REQUEST_INVALID", err) + } + duplicateOrderReq := &tg.StoriesUpdateAlbumRequest{ + Peer: &tg.InputPeerSelf{}, + AlbumID: 1, + } + duplicateOrderReq.SetOrder([]int{1, 1}) + if _, err := r.onStoriesUpdateAlbum(creatorCtx, duplicateOrderReq); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("duplicate order stories update album err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesCreateAlbum(creatorCtx, &tg.StoriesCreateAlbumRequest{Peer: &tg.InputPeerSelf{}, Title: strings.Repeat("x", maxStoryAlbumTitleLength+1)}); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("long title create err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesDeleteAlbum(creatorCtx, &tg.StoriesDeleteAlbumRequest{Peer: &tg.InputPeerSelf{}, AlbumID: 0}); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("invalid album id err = %v, want INPUT_REQUEST_INVALID", err) + } +} + +func TestStoriesGetChatsToSendReturnsPostableChannels(t *testing.T) { + ctx := context.Background() + userID := int64(91001) + creatorID := int64(91002) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + owned, err := channelService.CreateChannel(ctx, userID, domain.CreateChannelRequest{ + CreatorUserID: userID, + Title: "own story send-as", + Broadcast: true, + Date: 1700003000, + }) + if err != nil { + t.Fatalf("create owned channel: %v", err) + } + postable, err := channelService.CreateChannel(ctx, creatorID, domain.CreateChannelRequest{ + CreatorUserID: creatorID, + Title: "admin story send-as", + Broadcast: true, + MemberUserIDs: []int64{userID}, + Date: 1700003001, + }) + if err != nil { + t.Fatalf("create postable channel: %v", err) + } + if _, err := channelService.EditAdmin(ctx, creatorID, domain.EditChannelAdminRequest{ + ChannelID: postable.Channel.ID, + MemberID: userID, + AdminRights: domain.ChannelAdminRights{ + PostStories: true, + }, + Date: 1700003002, + }); err != nil { + t.Fatalf("grant post stories: %v", err) + } + editOnly, err := channelService.CreateChannel(ctx, creatorID, domain.CreateChannelRequest{ + CreatorUserID: creatorID, + Title: "edit-only story send-as", + Broadcast: true, + MemberUserIDs: []int64{userID}, + Date: 1700003003, + }) + if err != nil { + t.Fatalf("create edit-only channel: %v", err) + } + if _, err := channelService.EditAdmin(ctx, creatorID, domain.EditChannelAdminRequest{ + ChannelID: editOnly.Channel.ID, + MemberID: userID, + AdminRights: domain.ChannelAdminRights{ + EditStories: true, + }, + Date: 1700003004, + }); err != nil { + t.Fatalf("grant edit stories only: %v", err) + } + memberOnly, err := channelService.CreateChannel(ctx, creatorID, domain.CreateChannelRequest{ + CreatorUserID: creatorID, + Title: "member-only story send-as", + Broadcast: true, + MemberUserIDs: []int64{userID}, + Date: 1700003005, + }) + if err != nil { + t.Fatalf("create member-only channel: %v", err) + } + storyStore := memory.NewStoryStore() + upsertStory := func(peer domain.Peer, id int) { + t.Helper() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: peer, + ID: id, + Date: 1700003006, + ExpireDate: 1700006600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %v/%d: %v", peer, id, err) + } + } + upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: postable.Channel.ID}, 31) + upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: owned.Channel.ID}, 37) + + r := New(Config{}, Deps{ + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700003010, 0)}) + got, err := r.onStoriesGetChatsToSend(WithUserID(ctx, userID)) + if err != nil { + t.Fatalf("get chats to send: %v", err) + } + chats, ok := got.(*tg.MessagesChats) + if !ok { + t.Fatalf("get chats to send = %T, want messages.chats", got) + } + if len(chats.Chats) != 2 { + t.Fatalf("send-as chats = %+v, want two channels; excluded edit-only=%d member-only=%d", chats.Chats, editOnly.Channel.ID, memberOnly.Channel.ID) + } + first, ok := chats.Chats[0].(*tg.Channel) + if !ok { + t.Fatalf("first send-as chat = %T %+v, want channel", chats.Chats[0], chats.Chats[0]) + } + firstRights, firstHasRights := first.GetAdminRights() + if first.ID != postable.Channel.ID || first.Min || !firstHasRights || !firstRights.PostStories { + t.Fatalf("first send-as chat = %T %+v, want full post_stories admin channel", chats.Chats[0], chats.Chats[0]) + } + assertChannelStoryMaxID(t, first, 31) + second, ok := chats.Chats[1].(*tg.Channel) + if !ok { + t.Fatalf("second send-as chat = %T %+v, want channel", chats.Chats[1], chats.Chats[1]) + } + if second.ID != owned.Channel.ID || second.Min || !second.Creator { + t.Fatalf("second send-as chat = %T %+v, want full creator channel", chats.Chats[1], chats.Chats[1]) + } + assertChannelStoryMaxID(t, second, 37) +} + +func assertStoryReactionListViewer(t *testing.T, list *tg.StoriesStoryReactionsList, wantUserID int64) { + t.Helper() + if list.Count != 1 || len(list.Reactions) != 1 { + t.Fatalf("reactions list = %+v, want one reaction", list) + } + storyReaction, ok := list.Reactions[0].(*tg.StoryReaction) + if !ok { + t.Fatalf("story reaction = %T, want *tg.StoryReaction", list.Reactions[0]) + } + peer, ok := storyReaction.PeerID.(*tg.PeerUser) + if !ok || peer.UserID != wantUserID { + t.Fatalf("reaction peer = %T %+v, want user %d", storyReaction.PeerID, storyReaction.PeerID, wantUserID) + } + if reaction, ok := storyReaction.Reaction.(*tg.ReactionEmoji); !ok || reaction.Emoticon != "🔥" { + t.Fatalf("reaction = %T %+v, want fire emoji", storyReaction.Reaction, storyReaction.Reaction) + } + if findUserClass(list.Users, wantUserID) == nil { + t.Fatalf("reaction users = %+v, want user %d", list.Users, wantUserID) + } +} + +func assertStoryReactionListCustomViewer(t *testing.T, list *tg.StoriesStoryReactionsList, wantUserID, wantDocumentID int64) { + t.Helper() + if list.Count != 1 || len(list.Reactions) != 1 { + t.Fatalf("reactions list = %+v, want one reaction", list) + } + storyReaction, ok := list.Reactions[0].(*tg.StoryReaction) + if !ok { + t.Fatalf("story reaction = %T, want *tg.StoryReaction", list.Reactions[0]) + } + peer, ok := storyReaction.PeerID.(*tg.PeerUser) + if !ok || peer.UserID != wantUserID { + t.Fatalf("reaction peer = %T %+v, want user %d", storyReaction.PeerID, storyReaction.PeerID, wantUserID) + } + reaction, ok := storyReaction.Reaction.(*tg.ReactionCustomEmoji) + if !ok || reaction.DocumentID != wantDocumentID { + t.Fatalf("reaction = %T %+v, want custom emoji %d", storyReaction.Reaction, storyReaction.Reaction, wantDocumentID) + } + if findUserClass(list.Users, wantUserID) == nil { + t.Fatalf("reaction users = %+v, want user %d", list.Users, wantUserID) + } +} + +func assertStoryViewsListViewer(t *testing.T, list *tg.StoriesStoryViewsList, wantUserID int64) { + t.Helper() + if list.Count != 1 || list.ViewsCount != 1 || len(list.Views) != 1 { + t.Fatalf("views list = %+v, want one view", list) + } + view, ok := list.Views[0].(*tg.StoryView) + if !ok { + t.Fatalf("story view = %T, want *tg.StoryView", list.Views[0]) + } + if view.UserID != wantUserID { + t.Fatalf("story view user = %d, want %d", view.UserID, wantUserID) + } + if findUserClass(list.Users, wantUserID) == nil { + t.Fatalf("view users = %+v, want user %d", list.Users, wantUserID) + } +} + +func sameIntIDs(got, want []int) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +func TestStoryDifferenceProjectsCompanionStoriesMaxID(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{4, 5, 6} + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 91501, Phone: "15550091501", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + now := time.Unix(1700000500, 0) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: now}) + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 91) + + if _, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 44, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7101, + Period: 86400, + Caption: "diff story", + }); err != nil { + t.Fatalf("send story: %v", err) + } + diff, err := r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference: %v", err) + } + got, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want updates.difference", diff) + } + if got.State.Pts != 1 || len(got.OtherUpdates) != 1 { + t.Fatalf("difference pts/updates = %d/%d, want 1/1", got.State.Pts, len(got.OtherUpdates)) + } + assertUserStoryMaxID(t, findUserClass(got.Users, user.ID), 1) +} + +func TestStoriesSendStoryRepostRecordsForwardHeaderAndDifference(t *testing.T) { + ctx := context.Background() + bobAuthKeyID := [8]byte{4, 6, 8} + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 91601, Phone: "15550091601", FirstName: "Alice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 91602, Phone: "15550091602", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + now := time.Unix(1700000501, 0) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: now}) + aliceCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, alice.ID), [8]byte{4, 6, 7}), 61) + bobCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, bob.ID), bobAuthKeyID), 62) + + sourceUpdatesClass, err := r.onStoriesSendStory(aliceCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 91601, Parts: 1, Name: "alice-source.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 91601, + Period: 86400, + Caption: "source story", + }) + if err != nil { + t.Fatalf("send source story: %v", err) + } + sourceUpdates := sourceUpdatesClass.(*tg.Updates) + sourceID := sourceUpdates.Updates[0].(*tg.UpdateStoryID).ID + + repostReq := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 91602, Parts: 1, Name: "bob-repost.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 91602, + Period: 86400, + Caption: "bob repost", + FwdModified: true, + } + repostReq.SetFwdFromID(&tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}) + repostReq.SetFwdFromStory(sourceID) + repostUpdatesClass, err := r.onStoriesSendStory(bobCtx, repostReq) + if err != nil { + t.Fatalf("send repost story: %v", err) + } + repostUpdates, ok := repostUpdatesClass.(*tg.Updates) + if !ok || len(repostUpdates.Updates) != 2 { + t.Fatalf("repost updates = %T %+v, want updateStoryID + updateStory", repostUpdatesClass, repostUpdatesClass) + } + repostID := repostUpdates.Updates[0].(*tg.UpdateStoryID).ID + repostItem, ok := repostUpdates.Updates[1].(*tg.UpdateStory).Story.(*tg.StoryItem) + if !ok { + t.Fatalf("repost story update = %T, want storyItem", repostUpdates.Updates[1].(*tg.UpdateStory).Story) + } + assertStoryForwardHeader(t, repostItem, alice.ID, sourceID, true) + if findUserClass(repostUpdates.Users, alice.ID) == nil || findUserClass(repostUpdates.Users, bob.ID) == nil { + t.Fatalf("repost companion users = %+v, want source and repost owner users", repostUpdates.Users) + } + + peerStories, err := r.onStoriesGetPeerStories(bobCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get bob peer stories: %v", err) + } + if len(peerStories.Stories.Stories) != 1 { + t.Fatalf("bob peer stories = %+v, want one repost", peerStories.Stories.Stories) + } + assertStoryForwardHeader(t, peerStories.Stories.Stories[0].(*tg.StoryItem), alice.ID, sourceID, true) + if findUserClass(peerStories.Users, alice.ID) == nil { + t.Fatalf("peer stories users = %+v, want source user", peerStories.Users) + } + + byID, err := r.onStoriesGetStoriesByID(bobCtx, &tg.StoriesGetStoriesByIDRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{repostID}, + }) + if err != nil { + t.Fatalf("get repost by id: %v", err) + } + if len(byID.Stories) != 1 { + t.Fatalf("stories by id = %+v, want repost", byID.Stories) + } + assertStoryForwardHeader(t, byID.Stories[0].(*tg.StoryItem), alice.ID, sourceID, true) + if findUserClass(byID.Users, alice.ID) == nil { + t.Fatalf("stories by id users = %+v, want source user", byID.Users) + } + + diff, err := r.onUpdatesGetDifference(bobCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get bob difference: %v", err) + } + gotDiff, ok := diff.(*tg.UpdatesDifference) + if !ok || gotDiff.State.Pts != 1 || len(gotDiff.OtherUpdates) != 1 { + t.Fatalf("bob difference = %T %+v, want one repost update", diff, diff) + } + diffItem, ok := gotDiff.OtherUpdates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + if !ok { + t.Fatalf("difference story = %T, want storyItem", gotDiff.OtherUpdates[0].(*tg.UpdateStory).Story) + } + assertStoryForwardHeader(t, diffItem, alice.ID, sourceID, true) + if findUserClass(gotDiff.Users, alice.ID) == nil || findUserClass(gotDiff.Users, bob.ID) == nil { + t.Fatalf("difference users = %+v, want source and repost owner users", gotDiff.Users) + } + + sourceViews, err := r.onStoriesGetStoriesViews(aliceCtx, &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{sourceID}, + }) + if err != nil { + t.Fatalf("get source story views: %v", err) + } + if len(sourceViews.Views) != 1 || sourceViews.Views[0].ForwardsCount != 1 { + t.Fatalf("source story views = %+v, want forwards_count=1", sourceViews.Views) + } + sourceViewList, err := r.onStoriesGetStoryViewsList(aliceCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: sourceID, + Offset: "", + Limit: 20, + }) + if err != nil { + t.Fatalf("get source story views list: %v", err) + } + if sourceViewList.Count != 1 || sourceViewList.ForwardsCount != 1 || len(sourceViewList.Views) != 1 { + t.Fatalf("source story views list = %+v, want one public repost", sourceViewList) + } + publicRepost, ok := sourceViewList.Views[0].(*tg.StoryViewPublicRepost) + if !ok { + t.Fatalf("source story view = %T, want storyViewPublicRepost", sourceViewList.Views[0]) + } + repostPeer, ok := publicRepost.PeerID.(*tg.PeerUser) + if !ok || repostPeer.UserID != bob.ID { + t.Fatalf("public repost peer = %T %+v, want Bob peer", publicRepost.PeerID, publicRepost.PeerID) + } + repostStory, ok := publicRepost.Story.(*tg.StoryItem) + if !ok || repostStory.ID != repostID { + t.Fatalf("public repost story = %T %+v, want repost story id %d", publicRepost.Story, publicRepost.Story, repostID) + } + assertStoryForwardHeader(t, repostStory, alice.ID, sourceID, true) + if findUserClass(sourceViewList.Users, alice.ID) == nil || findUserClass(sourceViewList.Users, bob.ID) == nil { + t.Fatalf("source story views list users = %+v, want source and repost users", sourceViewList.Users) + } + + protectedSourceClass, err := r.onStoriesSendStory(aliceCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 91603, Parts: 1, Name: "alice-protected.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 91603, + Period: 86400, + Caption: "protected source", + Noforwards: true, + }) + if err != nil { + t.Fatalf("send protected source story: %v", err) + } + protectedID := protectedSourceClass.(*tg.Updates).Updates[0].(*tg.UpdateStoryID).ID + protectedRepost := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 91604, Parts: 1, Name: "blocked-repost.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 91604, + Period: 86400, + } + protectedRepost.SetFwdFromID(&tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}) + protectedRepost.SetFwdFromStory(protectedID) + if _, err := r.onStoriesSendStory(bobCtx, protectedRepost); err == nil || !tgerr.Is(err, "CHAT_FORWARDS_RESTRICTED") { + t.Fatalf("protected repost err = %v, want CHAT_FORWARDS_RESTRICTED", err) + } + peerStories, err = r.onStoriesGetPeerStories(bobCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get bob peer stories after protected repost: %v", err) + } + if len(peerStories.Stories.Stories) != 1 { + t.Fatalf("bob peer stories after protected repost = %+v, want only first repost", peerStories.Stories.Stories) + } + + if _, err := r.onStoriesDeleteStories(bobCtx, &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{repostID}, + }); err != nil { + t.Fatalf("delete repost story: %v", err) + } + sourceViews, err = r.onStoriesGetStoriesViews(aliceCtx, &tg.StoriesGetStoriesViewsRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{sourceID}, + }) + if err != nil { + t.Fatalf("get source story views after delete: %v", err) + } + if len(sourceViews.Views) != 1 || sourceViews.Views[0].ForwardsCount != 0 { + t.Fatalf("source story views after delete = %+v, want forwards_count=0", sourceViews.Views) + } +} + +func TestStoriesSendStoryRepostRespectsForwardPrivacyFromName(t *testing.T) { + ctx := context.Background() + bobAuthKeyID := [8]byte{4, 6, 9} + userStore := memory.NewUserStore() + alice, err := userStore.Create(ctx, domain.User{AccessHash: 91611, Phone: "15550091611", FirstName: "Alice", LastName: "Hidden"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := userStore.Create(ctx, domain.User{AccessHash: 91612, Phone: "15550091612", FirstName: "Bob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + privacyStore := memory.NewPrivacyStore() + privacyService := appprivacy.NewService(privacyStore, nil) + if _, err := privacyService.SetRules(ctx, alice.ID, domain.PrivacyKeyForwards, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil { + t.Fatalf("set alice forwards privacy: %v", err) + } + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + now := time.Unix(1700000521, 0) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Privacy: privacyService, + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: now}) + aliceCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, alice.ID), [8]byte{4, 6, 10}), 63) + bobCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, bob.ID), bobAuthKeyID), 64) + + sourceUpdatesClass, err := r.onStoriesSendStory(aliceCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 91611, Parts: 1, Name: "alice-hidden-source.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 91611, + Period: 86400, + Caption: "source story", + }) + if err != nil { + t.Fatalf("send source story: %v", err) + } + sourceID := sourceUpdatesClass.(*tg.Updates).Updates[0].(*tg.UpdateStoryID).ID + + repostReq := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 91612, Parts: 1, Name: "bob-hidden-repost.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 91612, + Period: 86400, + Caption: "bob repost hidden source", + FwdModified: true, + } + repostReq.SetFwdFromID(&tg.InputPeerUser{UserID: alice.ID, AccessHash: alice.AccessHash}) + repostReq.SetFwdFromStory(sourceID) + repostUpdatesClass, err := r.onStoriesSendStory(bobCtx, repostReq) + if err != nil { + t.Fatalf("send repost story: %v", err) + } + repostUpdates, ok := repostUpdatesClass.(*tg.Updates) + if !ok || len(repostUpdates.Updates) != 2 { + t.Fatalf("repost updates = %T %+v, want updateStoryID + updateStory", repostUpdatesClass, repostUpdatesClass) + } + repostID := repostUpdates.Updates[0].(*tg.UpdateStoryID).ID + repostItem, ok := repostUpdates.Updates[1].(*tg.UpdateStory).Story.(*tg.StoryItem) + if !ok { + t.Fatalf("repost story update = %T, want storyItem", repostUpdates.Updates[1].(*tg.UpdateStory).Story) + } + assertStoryForwardNameHeader(t, repostItem, "Alice Hidden", sourceID, true) + if findUserClass(repostUpdates.Users, alice.ID) != nil { + t.Fatalf("repost companion users = %+v, want no hidden source user", repostUpdates.Users) + } + if findUserClass(repostUpdates.Users, bob.ID) == nil { + t.Fatalf("repost companion users = %+v, want repost owner user", repostUpdates.Users) + } + + peerStories, err := r.onStoriesGetPeerStories(bobCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get bob peer stories: %v", err) + } + if len(peerStories.Stories.Stories) != 1 { + t.Fatalf("bob peer stories = %+v, want one repost", peerStories.Stories.Stories) + } + assertStoryForwardNameHeader(t, peerStories.Stories.Stories[0].(*tg.StoryItem), "Alice Hidden", sourceID, true) + if findUserClass(peerStories.Users, alice.ID) != nil { + t.Fatalf("peer stories users = %+v, want no hidden source user", peerStories.Users) + } + + byID, err := r.onStoriesGetStoriesByID(bobCtx, &tg.StoriesGetStoriesByIDRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{repostID}, + }) + if err != nil { + t.Fatalf("get repost by id: %v", err) + } + if len(byID.Stories) != 1 { + t.Fatalf("stories by id = %+v, want repost", byID.Stories) + } + assertStoryForwardNameHeader(t, byID.Stories[0].(*tg.StoryItem), "Alice Hidden", sourceID, true) + if findUserClass(byID.Users, alice.ID) != nil { + t.Fatalf("stories by id users = %+v, want no hidden source user", byID.Users) + } + + diff, err := r.onUpdatesGetDifference(bobCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get bob difference: %v", err) + } + gotDiff, ok := diff.(*tg.UpdatesDifference) + if !ok || gotDiff.State.Pts != 1 || len(gotDiff.OtherUpdates) != 1 { + t.Fatalf("bob difference = %T %+v, want one repost update", diff, diff) + } + diffItem, ok := gotDiff.OtherUpdates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + if !ok { + t.Fatalf("difference story = %T, want storyItem", gotDiff.OtherUpdates[0].(*tg.UpdateStory).Story) + } + assertStoryForwardNameHeader(t, diffItem, "Alice Hidden", sourceID, true) + if findUserClass(gotDiff.Users, alice.ID) != nil { + t.Fatalf("difference users = %+v, want no hidden source user", gotDiff.Users) + } + + sourceViewList, err := r.onStoriesGetStoryViewsList(aliceCtx, &tg.StoriesGetStoryViewsListRequest{ + Peer: &tg.InputPeerSelf{}, + ID: sourceID, + Limit: 20, + }) + if err != nil { + t.Fatalf("get source story views list: %v", err) + } + if sourceViewList.Count != 1 || sourceViewList.ForwardsCount != 1 || len(sourceViewList.Views) != 1 { + t.Fatalf("source story views list = %+v, want one public repost", sourceViewList) + } + publicRepost, ok := sourceViewList.Views[0].(*tg.StoryViewPublicRepost) + if !ok { + t.Fatalf("source story view = %T, want storyViewPublicRepost", sourceViewList.Views[0]) + } + if peer, ok := publicRepost.PeerID.(*tg.PeerUser); !ok || peer.UserID != bob.ID { + t.Fatalf("public repost peer = %T %+v, want Bob peer", publicRepost.PeerID, publicRepost.PeerID) + } + sourceViewRepost, ok := publicRepost.Story.(*tg.StoryItem) + if !ok || sourceViewRepost.ID != repostID { + t.Fatalf("public repost story = %T %+v, want repost story id %d", publicRepost.Story, publicRepost.Story, repostID) + } + assertStoryForwardNameHeader(t, sourceViewRepost, "Alice Hidden", sourceID, true) + if findUserClass(sourceViewList.Users, alice.ID) != nil { + t.Fatalf("source story views list users = %+v, want no hidden source user", sourceViewList.Users) + } + if findUserClass(sourceViewList.Users, bob.ID) == nil { + t.Fatalf("source story views list users = %+v, want repost owner user", sourceViewList.Users) + } + + publicForwards, err := r.onStatsGetStoryPublicForwards(aliceCtx, &tg.StatsGetStoryPublicForwardsRequest{ + Peer: &tg.InputPeerSelf{}, + ID: sourceID, + Limit: 20, + }) + if err != nil { + t.Fatalf("get source story public forwards: %v", err) + } + if publicForwards.Count != 1 || len(publicForwards.Forwards) != 1 { + t.Fatalf("source story public forwards = %+v, want one public repost", publicForwards) + } + forwardStory, ok := publicForwards.Forwards[0].(*tg.PublicForwardStory) + if !ok { + t.Fatalf("public forward = %T, want *tg.PublicForwardStory", publicForwards.Forwards[0]) + } + if peer, ok := forwardStory.Peer.(*tg.PeerUser); !ok || peer.UserID != bob.ID { + t.Fatalf("public forward peer = %T %+v, want Bob peer", forwardStory.Peer, forwardStory.Peer) + } + statsRepost, ok := forwardStory.Story.(*tg.StoryItem) + if !ok || statsRepost.ID != repostID { + t.Fatalf("public forward story = %T %+v, want repost story id %d", forwardStory.Story, forwardStory.Story, repostID) + } + assertStoryForwardNameHeader(t, statsRepost, "Alice Hidden", sourceID, true) + if findUserClass(publicForwards.Users, alice.ID) != nil { + t.Fatalf("public forwards users = %+v, want no hidden source user", publicForwards.Users) + } + if findUserClass(publicForwards.Users, bob.ID) == nil { + t.Fatalf("public forwards users = %+v, want repost owner user", publicForwards.Users) + } +} + +func TestStoriesSendEditDeleteRecordsStoryUpdates(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{9, 8, 7} + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92001, Phone: "15550092001", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + userID := user.ID + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + now := time.Unix(1700000500, 0) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: now}) + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, userID), authKeyID), 99) + + updates, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 42, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7001, + Period: 86400, + Caption: "first story", + Pinned: true, + }) + if err != nil { + t.Fatalf("send story: %v", err) + } + sendUpdates, ok := updates.(*tg.Updates) + if !ok || len(sendUpdates.Updates) != 2 { + t.Fatalf("send updates = %T %+v, want updateStoryID + updateStory", updates, updates) + } + idUpdate, ok := sendUpdates.Updates[0].(*tg.UpdateStoryID) + if !ok || idUpdate.ID != 1 || idUpdate.RandomID != 7001 { + t.Fatalf("story id update = %T %+v, want id 1 random 7001", sendUpdates.Updates[0], sendUpdates.Updates[0]) + } + storyUpdate, ok := sendUpdates.Updates[1].(*tg.UpdateStory) + if !ok { + t.Fatalf("second update = %T, want updateStory", sendUpdates.Updates[1]) + } + item, ok := storyUpdate.Story.(*tg.StoryItem) + if !ok || item.ID != 1 || item.Caption != "first story" || !item.Pinned || !item.Public { + t.Fatalf("sent story item = %T %+v, want pinned public story", storyUpdate.Story, storyUpdate.Story) + } + media, ok := item.Media.(*tg.MessageMediaPhoto) + if !ok { + t.Fatalf("story media = %T, want photo", item.Media) + } + if photo, ok := media.Photo.(*tg.Photo); !ok || photo.ID != 777 { + t.Fatalf("story photo = %T %+v, want fake photo id 777", media.Photo, media.Photo) + } + assertUserStoryMaxID(t, findUserClass(sendUpdates.Users, userID), 1) + + edit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + edit.SetCaption("edited story") + if updates, err = r.onStoriesEditStory(reqCtx, edit); err != nil { + t.Fatalf("edit story: %v", err) + } + editUpdates := updates.(*tg.Updates) + edited, ok := editUpdates.Updates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("edit update = %T, want updateStory", editUpdates.Updates[0]) + } + editedItem, ok := edited.Story.(*tg.StoryItem) + if !ok || !editedItem.Edited || editedItem.Caption != "edited story" { + t.Fatalf("edited story = %T %+v, want edited caption", edited.Story, edited.Story) + } + + pinnedIDs, err := r.onStoriesTogglePinned(reqCtx, &tg.StoriesTogglePinnedRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{idUpdate.ID, idUpdate.ID}, + Pinned: false, + }) + if err != nil { + t.Fatalf("toggle pinned with duplicate ids: %v", err) + } + if len(pinnedIDs) != 1 || pinnedIDs[0] != idUpdate.ID { + t.Fatalf("pinned ids = %v, want deduped story id", pinnedIDs) + } + retryPinnedIDs, err := r.onStoriesTogglePinned(reqCtx, &tg.StoriesTogglePinnedRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{idUpdate.ID}, + Pinned: false, + }) + if err != nil { + t.Fatalf("retry toggle pinned: %v", err) + } + if len(retryPinnedIDs) != 1 || retryPinnedIDs[0] != idUpdate.ID { + t.Fatalf("retry pinned ids = %v, want story id echo", retryPinnedIDs) + } + + deletedIDs, err := r.onStoriesDeleteStories(reqCtx, &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{idUpdate.ID, idUpdate.ID}, + }) + if err != nil { + t.Fatalf("delete story with duplicate ids: %v", err) + } + if len(deletedIDs) != 1 || deletedIDs[0] != idUpdate.ID { + t.Fatalf("deleted ids = %v, want deduped story id", deletedIDs) + } + retryDeletedIDs, err := r.onStoriesDeleteStories(reqCtx, &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{idUpdate.ID}, + }) + if err != nil { + t.Fatalf("retry delete story: %v", err) + } + if len(retryDeletedIDs) != 1 || retryDeletedIDs[0] != idUpdate.ID { + t.Fatalf("retry deleted ids = %v, want story id echo", retryDeletedIDs) + } + diff, err := r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference: %v", err) + } + got, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want updates.difference", diff) + } + if got.State.Pts != 4 || len(got.OtherUpdates) != 4 { + t.Fatalf("difference pts/updates = %d/%d, want 4/4", got.State.Pts, len(got.OtherUpdates)) + } + unpinned, ok := got.OtherUpdates[2].(*tg.UpdateStory) + if !ok { + t.Fatalf("third diff update = %T, want updateStory", got.OtherUpdates[2]) + } + unpinnedItem, ok := unpinned.Story.(*tg.StoryItem) + if !ok || unpinnedItem.Pinned { + t.Fatalf("third story = %T %+v, want unpinned storyItem", unpinned.Story, unpinned.Story) + } + last, ok := got.OtherUpdates[3].(*tg.UpdateStory) + if !ok { + t.Fatalf("last diff update = %T, want updateStory", got.OtherUpdates[3]) + } + if _, ok := last.Story.(*tg.StoryItemDeleted); !ok { + t.Fatalf("last story = %T, want storyItemDeleted", last.Story) + } +} + +func TestStoriesWriteEventsExcludeCurrentSession(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{9, 8, 6} + const sessionID int64 = 1234 + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92011, Phone: "15550092011", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + updates := &captureUpdates{} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: updates, + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000510, 0)}) + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), sessionID) + + response, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 92011, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 92011, + Period: 86400, + Caption: "first story", + Pinned: true, + }) + if err != nil { + t.Fatalf("send story: %v", err) + } + storyID := response.(*tg.Updates).Updates[0].(*tg.UpdateStoryID).ID + assertCapturedStoryEventExcludesSession(t, updates, authKeyID, user.ID, sessionID, 1, storyID, false) + + edit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: storyID} + edit.SetCaption("edited story") + if _, err := r.onStoriesEditStory(reqCtx, edit); err != nil { + t.Fatalf("edit story: %v", err) + } + assertCapturedStoryEventExcludesSession(t, updates, authKeyID, user.ID, sessionID, 2, storyID, false) + + if _, err := r.onStoriesTogglePinned(reqCtx, &tg.StoriesTogglePinnedRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{storyID}, + Pinned: false, + }); err != nil { + t.Fatalf("toggle pinned: %v", err) + } + assertCapturedStoryEventExcludesSession(t, updates, authKeyID, user.ID, sessionID, 3, storyID, false) + + if _, err := r.onStoriesDeleteStories(reqCtx, &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{storyID}, + }); err != nil { + t.Fatalf("delete story: %v", err) + } + assertCapturedStoryEventExcludesSession(t, updates, authKeyID, user.ID, sessionID, 4, storyID, true) +} + +func assertCapturedStoryEventExcludesSession(t *testing.T, updates *captureUpdates, authKeyID [8]byte, userID, sessionID int64, wantEvents, storyID int, wantDeleted bool) { + t.Helper() + if updates.authKeyID != authKeyID || updates.userID != userID || updates.excludeSessionID != sessionID { + t.Fatalf("captured update = auth %v user %d exclude_session %d, want auth %v user %d exclude_session %d", updates.authKeyID, updates.userID, updates.excludeSessionID, authKeyID, userID, sessionID) + } + if len(updates.events) != wantEvents { + t.Fatalf("captured events = %d, want %d", len(updates.events), wantEvents) + } + last := updates.events[len(updates.events)-1] + if last.Type != domain.UpdateEventStory || last.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: userID}) || last.Story.ID != storyID { + t.Fatalf("last captured event = %+v, want story event for user %d story %d", last, userID, storyID) + } + if last.Story.Deleted != wantDeleted { + t.Fatalf("last captured story deleted = %v, want %v", last.Story.Deleted, wantDeleted) + } +} + +func TestStoriesDeleteStoriesFanoutsDeletedUserStoryToVisibleViewer(t *testing.T) { + ctx := context.Background() + ownerAuthKey := [8]byte{9, 2, 1} + viewerAuthKey := [8]byte{9, 2, 2} + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 92101, Phone: "15550092101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 92102, Phone: "15550092102", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + storyStore := memory.NewStoryStore() + stateStore := memory.NewUpdateStateStore() + updateStore := memory.NewUpdateEventStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(stateStore, updateStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000520, 0)}) + ownerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuthKey), 92) + + updates, err := r.onStoriesSendStory(ownerCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 92101, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 92101, + Period: 86400, + Caption: "public story", + }) + if err != nil { + t.Fatalf("send story: %v", err) + } + storyID := updates.(*tg.Updates).Updates[0].(*tg.UpdateStoryID).ID + viewerCtx := WithAuthKeyID(WithUserID(ctx, viewer.ID), viewerAuthKey) + if ok, err := r.onStoriesIncrementStoryViews(viewerCtx, &tg.StoriesIncrementStoryViewsRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + ID: []int{storyID}, + }); err != nil || !ok { + t.Fatalf("increment story view = %v, %v, want true nil", ok, err) + } + + deletedIDs, err := r.onStoriesDeleteStories(ownerCtx, &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{storyID}, + }) + if err != nil { + t.Fatalf("delete story: %v", err) + } + if len(deletedIDs) != 1 || deletedIDs[0] != storyID { + t.Fatalf("deleted ids = %v, want %d", deletedIDs, storyID) + } + diff, err := r.onUpdatesGetDifference(viewerCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("viewer get difference: %v", err) + } + got := diff.(*tg.UpdatesDifference) + if got.State.Pts != 1 || len(got.OtherUpdates) != 1 { + t.Fatalf("viewer difference pts/updates = %d/%d, want 1/1", got.State.Pts, len(got.OtherUpdates)) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("viewer update = %T, want *tg.UpdateStory", got.OtherUpdates[0]) + } + if peer, ok := update.Peer.(*tg.PeerUser); !ok || peer.UserID != owner.ID { + t.Fatalf("viewer update peer = %T %+v, want owner peer", update.Peer, update.Peer) + } + deleted, ok := update.Story.(*tg.StoryItemDeleted) + if !ok || deleted.ID != storyID { + t.Fatalf("viewer story = %T %+v, want storyItemDeleted %d", update.Story, update.Story, storyID) + } +} + +func TestStoriesDeleteStoriesFanoutsDeletedUserStoryToAllStoriesCachedViewer(t *testing.T) { + ctx := context.Background() + ownerAuthKey := [8]byte{9, 3, 1} + viewerAuthKey := [8]byte{9, 3, 2} + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93101, Phone: "15550093101", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 93102, Phone: "15550093102", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + storyStore := memory.NewStoryStore() + stateStore := memory.NewUpdateStateStore() + updateStore := memory.NewUpdateEventStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(stateStore, updateStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000520, 0)}) + ownerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuthKey), 93) + + updates, err := r.onStoriesSendStory(ownerCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 93101, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 93101, + Period: 86400, + Caption: "public story", + }) + if err != nil { + t.Fatalf("send story: %v", err) + } + storyID := updates.(*tg.Updates).Updates[0].(*tg.UpdateStoryID).ID + viewerCtx := WithAuthKeyID(WithUserID(ctx, viewer.ID), viewerAuthKey) + allClass, err := r.onStoriesGetAllStories(viewerCtx, &tg.StoriesGetAllStoriesRequest{}) + if err != nil { + t.Fatalf("viewer get all stories: %v", err) + } + all := mustAllStories(t, allClass) + if len(all.PeerStories) != 1 || len(all.PeerStories[0].Stories) != 1 { + t.Fatalf("all stories = %+v, want cached owner story", all.PeerStories) + } + views, err := storyStore.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + StoryID: storyID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story views: %v", err) + } + if views.Count != 0 || views.ViewsCount != 0 || len(views.Views) != 0 { + t.Fatalf("story views = %+v, want cached exposure without view counters", views) + } + + deletedIDs, err := r.onStoriesDeleteStories(ownerCtx, &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{storyID}, + }) + if err != nil { + t.Fatalf("delete story: %v", err) + } + if len(deletedIDs) != 1 || deletedIDs[0] != storyID { + t.Fatalf("deleted ids = %v, want %d", deletedIDs, storyID) + } + diff, err := r.onUpdatesGetDifference(viewerCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("viewer get difference: %v", err) + } + got := diff.(*tg.UpdatesDifference) + if got.State.Pts != 1 || len(got.OtherUpdates) != 1 { + t.Fatalf("viewer difference pts/updates = %d/%d, want 1/1", got.State.Pts, len(got.OtherUpdates)) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("viewer update = %T, want *tg.UpdateStory", got.OtherUpdates[0]) + } + if peer, ok := update.Peer.(*tg.PeerUser); !ok || peer.UserID != owner.ID { + t.Fatalf("viewer update peer = %T %+v, want owner peer", update.Peer, update.Peer) + } + deleted, ok := update.Story.(*tg.StoryItemDeleted) + if !ok || deleted.ID != storyID { + t.Fatalf("viewer story = %T %+v, want storyItemDeleted %d", update.Story, update.Story, storyID) + } +} + +func TestStoriesDeleteStoriesFanoutsExpiredPinnedUserStory(t *testing.T) { + ctx := context.Background() + ownerAuthKey := [8]byte{9, 2, 3} + viewerAuthKey := [8]byte{9, 2, 4} + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 92103, Phone: "15550092103", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 92104, Phone: "15550092104", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + now := time.Unix(1700000800, 0) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 1, + Date: int(now.Unix()) - 7200, + ExpireDate: int(now.Unix()) - 3600, + Pinned: true, + Public: true, + }}); err != nil { + t.Fatalf("upsert expired pinned story: %v", err) + } + if _, err := storyStore.IncrementViews(ctx, viewer.ID, ownerPeer, []int{1}, int(now.Unix())); err != nil { + t.Fatalf("increment expired pinned story view: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + }, zaptest.NewLogger(t), fixedClock{now: now}) + ownerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuthKey), 92) + + deletedIDs, err := r.onStoriesDeleteStories(ownerCtx, &tg.StoriesDeleteStoriesRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1}, + }) + if err != nil { + t.Fatalf("delete expired pinned story: %v", err) + } + if len(deletedIDs) != 1 || deletedIDs[0] != 1 { + t.Fatalf("deleted ids = %v, want [1]", deletedIDs) + } + viewerCtx := WithAuthKeyID(WithUserID(ctx, viewer.ID), viewerAuthKey) + diff, err := r.onUpdatesGetDifference(viewerCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("viewer get difference: %v", err) + } + got := diff.(*tg.UpdatesDifference) + if got.State.Pts != 1 || len(got.OtherUpdates) != 1 { + t.Fatalf("viewer difference pts/updates = %d/%d, want 1/1", got.State.Pts, len(got.OtherUpdates)) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("viewer update = %T, want *tg.UpdateStory", got.OtherUpdates[0]) + } + deleted, ok := update.Story.(*tg.StoryItemDeleted) + if !ok || deleted.ID != 1 { + t.Fatalf("viewer story = %T %+v, want storyItemDeleted 1", update.Story, update.Story) + } +} + +func TestStoriesTogglePinnedFanoutsExpiredPinnedUserStoryRemoval(t *testing.T) { + ctx := context.Background() + ownerAuthKey := [8]byte{9, 2, 5} + viewerAuthKey := [8]byte{9, 2, 6} + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 92105, Phone: "15550092105", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 92106, Phone: "15550092106", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + now := time.Unix(1700000900, 0) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 1, + Date: int(now.Unix()) - 7200, + ExpireDate: int(now.Unix()) - 3600, + Pinned: true, + Public: true, + }}); err != nil { + t.Fatalf("upsert expired pinned story: %v", err) + } + if _, err := storyStore.IncrementViews(ctx, viewer.ID, ownerPeer, []int{1}, int(now.Unix())); err != nil { + t.Fatalf("increment expired pinned story view: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + }, zaptest.NewLogger(t), fixedClock{now: now}) + ownerCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, owner.ID), ownerAuthKey), 93) + + pinnedIDs, err := r.onStoriesTogglePinned(ownerCtx, &tg.StoriesTogglePinnedRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1}, + Pinned: false, + }) + if err != nil { + t.Fatalf("toggle expired pinned story: %v", err) + } + if len(pinnedIDs) != 1 || pinnedIDs[0] != 1 { + t.Fatalf("pinned ids = %v, want [1]", pinnedIDs) + } + viewerCtx := WithAuthKeyID(WithUserID(ctx, viewer.ID), viewerAuthKey) + diff, err := r.onUpdatesGetDifference(viewerCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("viewer get difference: %v", err) + } + got := diff.(*tg.UpdatesDifference) + if got.State.Pts != 1 || len(got.OtherUpdates) != 1 { + t.Fatalf("viewer difference pts/updates = %d/%d, want 1/1", got.State.Pts, len(got.OtherUpdates)) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("viewer update = %T, want *tg.UpdateStory", got.OtherUpdates[0]) + } + deleted, ok := update.Story.(*tg.StoryItemDeleted) + if !ok || deleted.ID != 1 { + t.Fatalf("viewer story = %T %+v, want storyItemDeleted 1", update.Story, update.Story) + } +} + +func TestStoriesSendStoryRandomIDRetryReturnsOriginalWithoutNewUpdate(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{7, 4, 1} + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92011, Phone: "15550092011", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + now := time.Unix(1700000510, 0) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: now}) + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 74) + + first, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 7401, Parts: 1, Name: "first.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7401, + Period: 86400, + Caption: "first random story", + }) + if err != nil { + t.Fatalf("send first story: %v", err) + } + firstUpdates, ok := first.(*tg.Updates) + if !ok || len(firstUpdates.Updates) != 2 { + t.Fatalf("first updates = %T %+v, want updateStoryID + updateStory", first, first) + } + firstID, ok := firstUpdates.Updates[0].(*tg.UpdateStoryID) + if !ok || firstID.ID != 1 || firstID.RandomID != 7401 { + t.Fatalf("first story id update = %T %+v, want id 1 random 7401", firstUpdates.Updates[0], firstUpdates.Updates[0]) + } + + retry, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 7402, Parts: 1, Name: "retry.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7401, + Period: 86400, + Caption: "retry must not replace", + }) + if err != nil { + t.Fatalf("retry story: %v", err) + } + retryUpdates, ok := retry.(*tg.Updates) + if !ok || len(retryUpdates.Updates) != 2 { + t.Fatalf("retry updates = %T %+v, want updateStoryID + updateStory", retry, retry) + } + retryID, ok := retryUpdates.Updates[0].(*tg.UpdateStoryID) + if !ok || retryID.ID != firstID.ID || retryID.RandomID != 7401 { + t.Fatalf("retry story id update = %T %+v, want same id/random", retryUpdates.Updates[0], retryUpdates.Updates[0]) + } + retryStoryUpdate, ok := retryUpdates.Updates[1].(*tg.UpdateStory) + if !ok { + t.Fatalf("retry second update = %T, want updateStory", retryUpdates.Updates[1]) + } + retryItem, ok := retryStoryUpdate.Story.(*tg.StoryItem) + if !ok || retryItem.ID != firstID.ID || retryItem.Caption != "first random story" { + t.Fatalf("retry story = %T %+v, want original story snapshot", retryStoryUpdate.Story, retryStoryUpdate.Story) + } + + peerStories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after retry: %v", err) + } + if len(peerStories.Stories.Stories) != 1 { + t.Fatalf("peer stories = %+v, want one story after retry", peerStories.Stories.Stories) + } + stored, ok := peerStories.Stories.Stories[0].(*tg.StoryItem) + if !ok || stored.ID != firstID.ID || stored.Caption != "first random story" { + t.Fatalf("stored story = %T %+v, want original story", peerStories.Stories.Stories[0], peerStories.Stories.Stories[0]) + } + + diff, err := r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after retry: %v", err) + } + got, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want updates.difference", diff) + } + if got.State.Pts != 1 || len(got.OtherUpdates) != 1 { + t.Fatalf("difference pts/updates = %d/%d, want only first publish", got.State.Pts, len(got.OtherUpdates)) + } + diffStory, ok := got.OtherUpdates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("difference update = %T, want updateStory", got.OtherUpdates[0]) + } + diffItem, ok := diffStory.Story.(*tg.StoryItem) + if !ok || diffItem.ID != firstID.ID || diffItem.Caption != "first random story" { + t.Fatalf("difference story = %T %+v, want original story snapshot", diffStory.Story, diffStory.Story) + } +} + +func TestStoriesGetStoriesArchiveReturnsOwnerExpiredStories(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 92021, Phone: "15550092021", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + stranger, err := userStore.Create(ctx, domain.User{AccessHash: 92022, Phone: "15550092022", FirstName: "Stranger"}) + if err != nil { + t.Fatalf("create stranger: %v", err) + } + storyStore := memory.NewStoryStore() + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + fixtures := []domain.Story{ + {Owner: ownerPeer, ID: 1, Date: 1700000511, ExpireDate: 1700000520, Public: true, Caption: "old one"}, + {Owner: ownerPeer, ID: 2, Date: 1700000512, ExpireDate: 1700000600, Public: true, Caption: "active"}, + {Owner: ownerPeer, ID: 3, Date: 1700000513, ExpireDate: 1700000520, Public: true, Pinned: true, Caption: "old pinned"}, + {Owner: ownerPeer, ID: 4, Date: 1700000514, ExpireDate: 1700000520, Public: true, Deleted: true, Caption: "deleted"}, + {Owner: ownerPeer, ID: 5, Date: 1700000515, ExpireDate: 1700000520, Public: true, Caption: "old latest"}, + } + for _, story := range fixtures { + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story %d: %v", story.ID, err) + } + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000550, 0)}) + ownerCtx := WithUserID(ctx, owner.ID) + + countOnly, err := r.onStoriesGetStoriesArchive(ownerCtx, &tg.StoriesGetStoriesArchiveRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: 0, + }) + if err != nil { + t.Fatalf("count archive stories: %v", err) + } + if countOnly.Count != 3 || len(countOnly.Stories) != 0 { + t.Fatalf("count-only archive = %+v, want count 3 and no stories", countOnly) + } + + first, err := r.onStoriesGetStoriesArchive(ownerCtx, &tg.StoriesGetStoriesArchiveRequest{ + Peer: &tg.InputPeerSelf{}, + Limit: 2, + }) + if err != nil { + t.Fatalf("get archive first page: %v", err) + } + if first.Count != 3 || len(first.Stories) != 2 { + t.Fatalf("first archive page = %+v, want count 3 and two stories", first) + } + firstA, ok := first.Stories[0].(*tg.StoryItem) + firstB, okB := first.Stories[1].(*tg.StoryItem) + if !ok || !okB || firstA.ID != 5 || firstB.ID != 3 || !firstB.Pinned || !firstA.Out || !firstB.Out { + t.Fatalf("first archive stories = %T/%T %+v, want story ids 5,3 owner out and pinned id 3", first.Stories[0], first.Stories[1], first.Stories) + } + + second, err := r.onStoriesGetStoriesArchive(ownerCtx, &tg.StoriesGetStoriesArchiveRequest{ + Peer: &tg.InputPeerSelf{}, + OffsetID: 3, + Limit: 2, + }) + if err != nil { + t.Fatalf("get archive second page: %v", err) + } + if second.Count != 3 || len(second.Stories) != 1 { + t.Fatalf("second archive page = %+v, want count 3 and one story", second) + } + secondItem, ok := second.Stories[0].(*tg.StoryItem) + if !ok || secondItem.ID != 1 || secondItem.Caption != "old one" { + t.Fatalf("second archive story = %T %+v, want story id 1", second.Stories[0], second.Stories[0]) + } + + _, err = r.onStoriesGetStoriesArchive(WithUserID(ctx, stranger.ID), &tg.StoriesGetStoriesArchiveRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + Limit: 10, + }) + if err == nil || !strings.Contains(err.Error(), "PEER_ID_INVALID") { + t.Fatalf("stranger archive err = %v, want PEER_ID_INVALID", err) + } +} + +func TestStoriesLongtailCompatHandlers(t *testing.T) { + ctx := context.Background() + storyStore := memory.NewStoryStore() + ownerID := int64(9311) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{ + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, ownerID) + + link, err := r.onStoriesExportStoryLink(reqCtx, &tg.StoriesExportStoryLinkRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 1, + }) + if err != nil { + t.Fatalf("export story link: %v", err) + } + if link.Link != "https://telesrv.local/story/user/9311/1" { + t.Fatalf("export story link = %q, want deterministic telesrv link", link.Link) + } + if _, err := r.onStoriesExportStoryLink(reqCtx, &tg.StoriesExportStoryLinkRequest{ + Peer: &tg.InputPeerSelf{}, + ID: 99, + }); err == nil || !strings.Contains(err.Error(), "STORY_ID_INVALID") { + t.Fatalf("export missing story err = %v, want STORY_ID_INVALID", err) + } + + if _, err := r.onStoriesActivateStealthMode(reqCtx, nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("activate stealth nil request err = %v, want INPUT_REQUEST_INVALID", err) + } + if _, err := r.onStoriesActivateStealthMode(ctx, &tg.StoriesActivateStealthModeRequest{}); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("activate stealth no flags err = %v, want INPUT_REQUEST_INVALID before auth", err) + } + activateBoth := &tg.StoriesActivateStealthModeRequest{} + activateBoth.SetPast(true) + activateBoth.SetFuture(true) + updates, err := r.onStoriesActivateStealthMode(reqCtx, activateBoth) + if err != nil { + t.Fatalf("activate stealth mode: %v", err) + } + got, ok := updates.(*tg.Updates) + if !ok || len(got.Updates) != 1 || got.Date != 1700000100 { + t.Fatalf("stealth updates = %T %+v, want one update at fixed date", updates, updates) + } + stealthUpdate, ok := got.Updates[0].(*tg.UpdateStoriesStealthMode) + if !ok { + t.Fatalf("stealth update = %T %+v, want updateStoriesStealthMode", got.Updates[0], got.Updates[0]) + } + if active, ok := stealthUpdate.StealthMode.GetActiveUntilDate(); !ok || active != 1700000100+storyStealthFuturePeriodSeconds { + t.Fatalf("stealth active_until = %d %v, want %d", active, ok, 1700000100+storyStealthFuturePeriodSeconds) + } + if cooldown, ok := stealthUpdate.StealthMode.GetCooldownUntilDate(); !ok || cooldown != 1700000100+storyStealthCooldownSeconds { + t.Fatalf("stealth cooldown_until = %d %v, want %d", cooldown, ok, 1700000100+storyStealthCooldownSeconds) + } + activatePastOnly := &tg.StoriesActivateStealthModeRequest{} + activatePastOnly.SetPast(true) + updates, err = r.onStoriesActivateStealthMode(reqCtx, activatePastOnly) + if err != nil { + t.Fatalf("activate stealth past-only: %v", err) + } + got = updates.(*tg.Updates) + stealthUpdate = got.Updates[0].(*tg.UpdateStoriesStealthMode) + if active, ok := stealthUpdate.StealthMode.GetActiveUntilDate(); ok || active != 0 { + t.Fatalf("past-only stealth active_until = %d %v, want absent", active, ok) + } + if cooldown, ok := stealthUpdate.StealthMode.GetCooldownUntilDate(); !ok || cooldown != 1700000100+storyStealthCooldownSeconds { + t.Fatalf("past-only stealth cooldown_until = %d %v, want %d", cooldown, ok, 1700000100+storyStealthCooldownSeconds) + } + + if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerSelf{}, + }); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("report empty stories err = %v, want STORY_ID_EMPTY", err) + } + if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerEmpty{}, + }); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("report empty stories before peer err = %v, want STORY_ID_EMPTY", err) + } + oversizedReportIDs := make([]int, domain.MaxStoryIDs+1) + for i := range oversizedReportIDs { + oversizedReportIDs[i] = 1 + } + if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerSelf{}, + ID: oversizedReportIDs, + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("report oversized stories err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: oversizedReportIDs, + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("report oversized stories before peer err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("report invalid story id before peer err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1}, + Option: []byte(strings.Repeat("x", maxReportOptionLength+1)), + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("report oversized option err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{1}, + Option: []byte(strings.Repeat("x", maxReportOptionLength+1)), + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("report oversized option before peer err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1}, + Message: strings.Repeat("x", maxReportCommentLength+1), + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("report oversized comment err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1}, + Option: []byte("definitely_unknown"), + }); err == nil || !tgerr.Is(err, "OPTION_INVALID") { + t.Fatalf("report unknown option err = %v, want OPTION_INVALID", err) + } + + reportOptions, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1, 1}, + }) + if err != nil { + t.Fatalf("report initial options: %v", err) + } + if choices, ok := reportOptions.(*tg.ReportResultChooseOption); !ok || len(choices.Options) == 0 { + t.Fatalf("report options = %T %+v, want choose-option", reportOptions, reportOptions) + } + reportComment, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1}, + Option: []byte("other"), + }) + if err != nil { + t.Fatalf("report other: %v", err) + } + if _, ok := reportComment.(*tg.ReportResultAddComment); !ok { + t.Fatalf("report other = %T %+v, want add-comment", reportComment, reportComment) + } + reported, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{1}, + Option: []byte("spam"), + }) + if err != nil { + t.Fatalf("report spam: %v", err) + } + if _, ok := reported.(*tg.ReportResultReported); !ok { + t.Fatalf("report spam = %T %+v, want reported", reported, reported) + } + if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{99}, + Option: []byte("spam"), + }); err == nil || !strings.Contains(err.Error(), "STORY_ID_INVALID") { + t.Fatalf("report missing story err = %v, want STORY_ID_INVALID", err) + } + + found, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{ + Hashtag: "storytag", + Limit: 20, + }) + if err != nil { + t.Fatalf("search story posts: %v", err) + } + if found.Count != 0 || len(found.Stories) != 0 || len(found.Users) != 0 || len(found.Chats) != 0 { + t.Fatalf("search story posts = %+v, want empty bounded result", found) + } + if _, err := r.onStoriesSearchPosts(ctx, nil); err == nil || !tgerr.Is(err, "INPUT_REQUEST_INVALID") { + t.Fatalf("nil search story posts without user ctx err = %v, want INPUT_REQUEST_INVALID", err) + } + areaSearch, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{ + Area: testStoryGeoPointMediaArea(31.2, 121.4, 10), + Limit: 20, + }) + if err != nil { + t.Fatalf("search story posts by area: %v", err) + } + if areaSearch.Count != 0 || len(areaSearch.Stories) != 0 || len(areaSearch.Users) != 0 || len(areaSearch.Chats) != 0 { + t.Fatalf("search story posts by area = %+v, want empty bounded result", areaSearch) + } + venueAreaSearch, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{ + Area: testStoryVenueMediaArea("Inline Cafe", 31.2, 121.4, 12), + Limit: 20, + }) + if err != nil { + t.Fatalf("search story posts by venue area: %v", err) + } + if venueAreaSearch.Count != 0 || len(venueAreaSearch.Stories) != 0 || len(venueAreaSearch.Users) != 0 || len(venueAreaSearch.Chats) != 0 { + t.Fatalf("search story posts by venue area = %+v, want empty bounded result", venueAreaSearch) + } + if _, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{Limit: 20}); err == nil || !tgerr.Is(err, "SEARCH_QUERY_EMPTY") { + t.Fatalf("search story posts empty query err = %v, want SEARCH_QUERY_EMPTY", err) + } + if _, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{ + Hashtag: "storytag", + Area: testStoryGeoPointMediaArea(31.2, 121.4, 10), + Limit: 20, + }); err == nil || !tgerr.Is(err, "SEARCH_QUERY_EMPTY") { + t.Fatalf("search story posts hashtag+area err = %v, want SEARCH_QUERY_EMPTY", err) + } + prefixed, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{ + Hashtag: "#storytag", + Limit: 20, + }) + if err != nil { + t.Fatalf("search story posts prefixed hashtag: %v", err) + } + if prefixed.Count != 0 || len(prefixed.Stories) != 0 || len(prefixed.Users) != 0 || len(prefixed.Chats) != 0 { + t.Fatalf("search story posts prefixed hashtag = %+v, want empty bounded result", prefixed) + } + cashtag, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{ + Hashtag: "$storytag", + Limit: 20, + }) + if err != nil { + t.Fatalf("search story posts cashtag: %v", err) + } + if cashtag.Count != 0 || len(cashtag.Stories) != 0 || len(cashtag.Users) != 0 || len(cashtag.Chats) != 0 { + t.Fatalf("search story posts cashtag = %+v, want empty bounded result", cashtag) + } + if _, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{ + Hashtag: "story#tag", + Limit: 20, + }); err == nil || !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("search story posts embedded marker err = %v, want LIMIT_INVALID", err) + } + if _, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{ + Hashtag: "storytag", + Offset: strings.Repeat("x", maxStorySearchPostsOffsetLength+1), + Limit: 20, + }); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { + t.Fatalf("search story posts offset err = %v, want OFFSET_INVALID", err) + } + if _, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{ + Area: &tg.MediaAreaSuggestedReaction{}, + Limit: 20, + }); err == nil || !tgerr.Is(err, "MEDIA_INVALID") { + t.Fatalf("search story posts unsupported area err = %v, want MEDIA_INVALID", err) + } + var typedNilGeo *tg.MediaAreaGeoPoint + for _, tc := range []struct { + name string + area tg.MediaAreaClass + }{ + {name: "typed nil geo", area: typedNilGeo}, + {name: "missing geo", area: &tg.MediaAreaGeoPoint{Coordinates: tg.MediaAreaCoordinates{X: 10, Y: 10, W: 10, H: 10}}}, + {name: "bad venue", area: &tg.MediaAreaVenue{ + Coordinates: tg.MediaAreaCoordinates{X: 10, Y: 10, W: 10, H: 10}, + Geo: &tg.GeoPoint{Lat: 31.2, Long: 121.4}, + }}, + } { + t.Run("search posts area "+tc.name, func(t *testing.T) { + if _, err := r.onStoriesSearchPosts(reqCtx, &tg.StoriesSearchPostsRequest{ + Area: tc.area, + Peer: &tg.InputPeerEmpty{}, + Limit: 20, + }); err == nil || !tgerr.Is(err, "MEDIA_INVALID") { + t.Fatalf("search story posts bad area err = %v, want MEDIA_INVALID", err) + } + }) + } + + if ok, err := r.onStoriesToggleAllStoriesHidden(reqCtx, true); err != nil || !ok { + t.Fatalf("toggle all stories hidden = %v, %v, want true nil", ok, err) + } + + _, err = r.onStoriesStartLive(reqCtx, &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerSelf{}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 42, + }) + if err == nil || !tgerr.Is(err, "METHOD_INVALID") { + t.Fatalf("start live err = %v, want METHOD_INVALID", err) + } + _, err = r.onStoriesStartLive(reqCtx, &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerEmpty{}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + }) + if err == nil || !tgerr.Is(err, "RANDOM_ID_EMPTY") { + t.Fatalf("start live zero random before peer err = %v, want RANDOM_ID_EMPTY", err) + } + for _, tc := range []struct { + name string + stars int64 + }{ + {name: "negative stars", stars: -1}, + {name: "too many stars", stars: maxChannelPaidMessageStars + 1}, + } { + t.Run("start live "+tc.name, func(t *testing.T) { + req := &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerEmpty{}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 42, + } + req.SetSendPaidMessagesStars(tc.stars) + _, err := r.onStoriesStartLive(reqCtx, req) + if err == nil || !tgerr.Is(err, "STARS_AMOUNT_INVALID") { + t.Fatalf("start live %s err = %v, want STARS_AMOUNT_INVALID", tc.name, err) + } + }) + } + _, err = r.onStoriesStartLive(reqCtx, &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerEmpty{}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 42, + }) + if err == nil || !tgerr.Is(err, "PEER_ID_INVALID") { + t.Fatalf("start live invalid peer err = %v, want PEER_ID_INVALID", err) + } + _, err = r.onStoriesStartLive(reqCtx, &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerEmpty{}, + PrivacyRules: []tg.InputPrivacyRuleClass{nil}, + RandomID: 42, + }) + if err == nil || !tgerr.Is(err, "PRIVACY_VALUE_INVALID") { + t.Fatalf("start live bad privacy before peer err = %v, want PRIVACY_VALUE_INVALID", err) + } + _, err = r.onStoriesStartLive(reqCtx, &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerSelf{}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowUsers{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: 1000000002}}, + }}, + RandomID: 42, + }) + if err == nil || !tgerr.Is(err, "USER_ID_INVALID") { + t.Fatalf("start live allow users without user resolver err = %v, want USER_ID_INVALID", err) + } + inBounds := &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerSelf{}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 43, + RtmpStream: true, + } + inBounds.SetMessagesEnabled(false) + inBounds.SetSendPaidMessagesStars(0) + _, err = r.onStoriesStartLive(reqCtx, inBounds) + if err == nil || !tgerr.Is(err, "METHOD_INVALID") { + t.Fatalf("start live false messages/zero stars err = %v, want METHOD_INVALID", err) + } +} + +func TestStoriesMediaAreasRoundTripSendEditClearAndDifference(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{9, 7, 1} + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92001, Phone: "15550092001", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000520, 0)}) + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 71) + + sendReq := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 41, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7101, + Period: 86400, + Caption: "area story", + } + sendReq.SetMediaAreas([]tg.MediaAreaClass{testStoryMediaAreaWith("🔥", 10, true, true)}) + updates, err := r.onStoriesSendStory(reqCtx, sendReq) + if err != nil { + t.Fatalf("send story with media area: %v", err) + } + sendUpdates := updates.(*tg.Updates) + idUpdate := sendUpdates.Updates[0].(*tg.UpdateStoryID) + sentItem := sendUpdates.Updates[1].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStorySuggestedReactionArea(t, sentItem, "🔥", 10, true, true) + + peerStories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + if len(peerStories.Stories.Stories) != 1 { + t.Fatalf("peer stories = %+v, want one story", peerStories.Stories.Stories) + } + assertStorySuggestedReactionArea(t, peerStories.Stories.Stories[0].(*tg.StoryItem), "🔥", 10, true, true) + + byID, err := r.onStoriesGetStoriesByID(reqCtx, &tg.StoriesGetStoriesByIDRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{idUpdate.ID, idUpdate.ID}, + }) + if err != nil { + t.Fatalf("get stories by id: %v", err) + } + if len(byID.Stories) != 1 { + t.Fatalf("stories by duplicate ids = %+v, want one story", byID.Stories) + } + assertStorySuggestedReactionArea(t, byID.Stories[0].(*tg.StoryItem), "🔥", 10, true, true) + if _, err := r.onStoriesGetStoriesByID(reqCtx, &tg.StoriesGetStoriesByIDRequest{ + Peer: &tg.InputPeerSelf{}, + }); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("empty get stories by id err = %v, want STORY_ID_EMPTY", err) + } + if _, err := r.onStoriesGetStoriesByID(reqCtx, &tg.StoriesGetStoriesByIDRequest{ + Peer: &tg.InputPeerEmpty{}, + }); err == nil || !tgerr.Is(err, "STORY_ID_EMPTY") { + t.Fatalf("empty get stories by id before peer err = %v, want STORY_ID_EMPTY", err) + } + if _, err := r.onStoriesGetStoriesByID(reqCtx, &tg.StoriesGetStoriesByIDRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: []int{0}, + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("invalid get stories by id before peer err = %v, want STORY_ID_INVALID", err) + } + if _, err := r.onStoriesGetStoriesByID(reqCtx, &tg.StoriesGetStoriesByIDRequest{ + Peer: &tg.InputPeerEmpty{}, + ID: make([]int, domain.MaxStoryIDs+1), + }); err == nil || !tgerr.Is(err, "STORY_ID_INVALID") { + t.Fatalf("oversized get stories by id before peer err = %v, want STORY_ID_INVALID", err) + } + + edit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + edit.SetMediaAreas([]tg.MediaAreaClass{testStoryURLMediaArea("https://example.com/story/link", 25)}) + updates, err = r.onStoriesEditStory(reqCtx, edit) + if err != nil { + t.Fatalf("edit story media area: %v", err) + } + editedItem := updates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + if !editedItem.Edited { + t.Fatalf("edited item = %+v, want edited flag", editedItem) + } + assertStoryURLMediaArea(t, editedItem, "https://example.com/story/link", 25) + + diff, err := r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after media area edit: %v", err) + } + gotDiff := diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 2 || len(gotDiff.OtherUpdates) != 2 { + t.Fatalf("difference = pts %d updates %d, want send+edit", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } + diffEdited := gotDiff.OtherUpdates[1].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryURLMediaArea(t, diffEdited, "https://example.com/story/link", 25) + + geoEdit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + geoEdit.SetMediaAreas([]tg.MediaAreaClass{testStoryGeoPointMediaArea(31.2304, 121.4737, 35)}) + updates, err = r.onStoriesEditStory(reqCtx, geoEdit) + if err != nil { + t.Fatalf("edit story geo media area: %v", err) + } + geoItem := updates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryGeoPointMediaArea(t, geoItem, 31.2304, 121.4737, 35) + + diff, err = r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after geo media area edit: %v", err) + } + gotDiff = diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 3 || len(gotDiff.OtherUpdates) != 3 { + t.Fatalf("difference after geo edit = pts %d updates %d, want send+url+geo edits", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } + diffGeo := gotDiff.OtherUpdates[2].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryGeoPointMediaArea(t, diffGeo, 31.2304, 121.4737, 35) + + weatherEdit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + weatherEdit.SetMediaAreas([]tg.MediaAreaClass{testStoryWeatherMediaArea("☀️", 22.5, 0x00cc6600, 45)}) + updates, err = r.onStoriesEditStory(reqCtx, weatherEdit) + if err != nil { + t.Fatalf("edit story weather media area: %v", err) + } + weatherItem := updates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryWeatherMediaArea(t, weatherItem, "☀️", 22.5, 0x00cc6600, 45) + + diff, err = r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after weather media area edit: %v", err) + } + gotDiff = diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 4 || len(gotDiff.OtherUpdates) != 4 { + t.Fatalf("difference after weather edit = pts %d updates %d, want send+url+geo+weather edits", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } + diffWeather := gotDiff.OtherUpdates[3].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryWeatherMediaArea(t, diffWeather, "☀️", 22.5, 0x00cc6600, 45) + + storyPeer := domain.Peer{Type: domain.PeerTypeUser, ID: user.ID} + queryID, _ := r.inlines.register(r.clock.Now(), 90001, user.ID, storyPeer) + if !r.inlines.resolve(r.clock.Now(), 90001, queryID, domain.BotInlineResults{ + BotUserID: 90001, + UserID: user.ID, + Peer: storyPeer, + Results: []domain.BotInlineResult{testStoryInlineVenueResult("venue-result", "Inline Cafe", 31.231, 121.474)}, + }) { + t.Fatalf("resolve inline venue result") + } + inputVenueEdit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + inputVenueEdit.SetMediaAreas([]tg.MediaAreaClass{testStoryInputVenueMediaArea(queryID, "venue-result", 55)}) + updates, err = r.onStoriesEditStory(reqCtx, inputVenueEdit) + if err != nil { + t.Fatalf("edit story input venue media area: %v", err) + } + inputVenueItem := updates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryVenueMediaArea(t, inputVenueItem, "Inline Cafe", 31.231, 121.474, 55) + + concreteVenueEdit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + concreteVenueEdit.SetMediaAreas([]tg.MediaAreaClass{testStoryVenueMediaArea("Gallery", 31.232, 121.475, 65)}) + updates, err = r.onStoriesEditStory(reqCtx, concreteVenueEdit) + if err != nil { + t.Fatalf("edit story concrete venue media area: %v", err) + } + concreteVenueItem := updates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryVenueMediaArea(t, concreteVenueItem, "Gallery", 31.232, 121.475, 65) + + diff, err = r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after venue media area edits: %v", err) + } + gotDiff = diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 6 || len(gotDiff.OtherUpdates) != 6 { + t.Fatalf("difference after venue edits = pts %d updates %d, want send+url+geo+weather+venue edits", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } + diffInputVenue := gotDiff.OtherUpdates[4].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryVenueMediaArea(t, diffInputVenue, "Inline Cafe", 31.231, 121.474, 55) + diffConcreteVenue := gotDiff.OtherUpdates[5].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryVenueMediaArea(t, diffConcreteVenue, "Gallery", 31.232, 121.475, 65) + + channelPostEdit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + channelPostEdit.SetMediaAreas([]tg.MediaAreaClass{testStoryChannelPostMediaArea(777001, 42, 75)}) + updates, err = r.onStoriesEditStory(reqCtx, channelPostEdit) + if err != nil { + t.Fatalf("edit story channel post media area: %v", err) + } + channelPostItem := updates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryChannelPostMediaArea(t, channelPostItem, 777001, 42, 75) + + diff, err = r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after channel post media area edit: %v", err) + } + gotDiff = diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 7 || len(gotDiff.OtherUpdates) != 7 { + t.Fatalf("difference after channel post edit = pts %d updates %d, want seven story updates", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } + diffChannelPost := gotDiff.OtherUpdates[6].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryChannelPostMediaArea(t, diffChannelPost, 777001, 42, 75) + + starGiftEdit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + starGiftEdit.SetMediaAreas([]tg.MediaAreaClass{testStoryStarGiftMediaArea("Gift.Series_01-42", 85)}) + updates, err = r.onStoriesEditStory(reqCtx, starGiftEdit) + if err != nil { + t.Fatalf("edit story star gift media area: %v", err) + } + starGiftItem := updates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryStarGiftMediaArea(t, starGiftItem, "Gift.Series_01-42", 85) + + diff, err = r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after star gift media area edit: %v", err) + } + gotDiff = diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 8 || len(gotDiff.OtherUpdates) != 8 { + t.Fatalf("difference after star gift edit = pts %d updates %d, want eight story updates", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } + diffStarGift := gotDiff.OtherUpdates[7].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryStarGiftMediaArea(t, diffStarGift, "Gift.Series_01-42", 85) + + clear := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + clear.SetMediaAreas([]tg.MediaAreaClass{}) + updates, err = r.onStoriesEditStory(reqCtx, clear) + if err != nil { + t.Fatalf("clear story media areas: %v", err) + } + clearedItem := updates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryHasNoMediaAreas(t, clearedItem) + + diff, err = r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after media area clear: %v", err) + } + gotDiff = diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 9 || len(gotDiff.OtherUpdates) != 9 { + t.Fatalf("difference after clear = pts %d updates %d, want nine story updates", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } + diffCleared := gotDiff.OtherUpdates[8].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryHasNoMediaAreas(t, diffCleared) +} + +func TestStoriesInputChannelPostMediaAreaResolvesToConcrete(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92006, Phone: "15550092006", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, user.ID, domain.CreateChannelRequest{ + CreatorUserID: user.ID, + Title: "story channel post source", + Broadcast: true, + Date: 1700000521, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := channelService.SendMessage(ctx, user.ID, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 8101, + Message: "source post", + Date: 1700000522, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + storyStore := memory.NewStoryStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000523, 0)}) + reqCtx := WithUserID(ctx, user.ID) + + sendReq := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 8102, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 8102, + Period: 86400, + } + sendReq.SetMediaAreas([]tg.MediaAreaClass{testStoryInputChannelPostMediaArea(created.Channel.ID, created.Channel.AccessHash, sent.Message.ID, 35)}) + updates, err := r.onStoriesSendStory(reqCtx, sendReq) + if err != nil { + t.Fatalf("send story with input channel post media area: %v", err) + } + item := updates.(*tg.Updates).Updates[1].(*tg.UpdateStory).Story.(*tg.StoryItem) + assertStoryChannelPostMediaArea(t, item, created.Channel.ID, sent.Message.ID, 35) + + peerStories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + if len(peerStories.Stories.Stories) != 1 { + t.Fatalf("peer stories = %+v, want one story", peerStories.Stories.Stories) + } + assertStoryChannelPostMediaArea(t, peerStories.Stories.Stories[0].(*tg.StoryItem), created.Channel.ID, sent.Message.ID, 35) + + badHash := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 8103, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 8103, + Period: 86400, + } + badHash.SetMediaAreas([]tg.MediaAreaClass{testStoryInputChannelPostMediaArea(created.Channel.ID, created.Channel.AccessHash+1, sent.Message.ID, 35)}) + if _, err := r.onStoriesSendStory(reqCtx, badHash); err == nil || !tgerr.Is(err, "CHANNEL_PRIVATE") { + t.Fatalf("send story input channel post bad hash err = %v, want CHANNEL_PRIVATE", err) + } + + missingMessage := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 8104, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 8104, + Period: 86400, + } + missingMessage.SetMediaAreas([]tg.MediaAreaClass{testStoryInputChannelPostMediaArea(created.Channel.ID, created.Channel.AccessHash, sent.Message.ID+100, 35)}) + if _, err := r.onStoriesSendStory(reqCtx, missingMessage); err == nil || !tgerr.Is(err, "MEDIA_INVALID") { + t.Fatalf("send story input channel post missing message err = %v, want MEDIA_INVALID", err) + } + + emptyInput := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 8105, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 8105, + Period: 86400, + } + emptyInput.SetMediaAreas([]tg.MediaAreaClass{&tg.InputMediaAreaChannelPost{Coordinates: tg.MediaAreaCoordinates{X: 35, Y: 12, W: 22, H: 11}}}) + if _, err := r.onStoriesSendStory(reqCtx, emptyInput); err == nil || !tgerr.Is(err, "MEDIA_INVALID") { + t.Fatalf("send story empty input channel post err = %v, want MEDIA_INVALID", err) + } + + peerStories, err = r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected channel posts: %v", err) + } + if len(peerStories.Stories.Stories) != 1 { + t.Fatalf("stories after rejected channel posts = %+v, want only original story", peerStories.Stories.Stories) + } +} + +func TestStoriesMediaAreasRejectInvalidInputWithoutMutation(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92011, Phone: "15550092011", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000530, 0)}) + reqCtx := WithUserID(ctx, user.ID) + + tooMany := make([]tg.MediaAreaClass, domain.MaxStoryMediaAreas+1) + for i := range tooMany { + tooMany[i] = testStoryMediaAreaWith("🔥", float64(i), false, false) + } + tooManyReq := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 42, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7111, + Period: 86400, + } + tooManyReq.SetMediaAreas(tooMany) + if _, err := r.onStoriesSendStory(reqCtx, tooManyReq); err == nil || !strings.Contains(err.Error(), "LIMIT_INVALID") { + t.Fatalf("send too many media areas err = %v, want LIMIT_INVALID", err) + } + stories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected send: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected send = %+v, want none", stories.Stories.Stories) + } + for i, area := range testNilStoryMediaAreas() { + req := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: int64(7140 + i), Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: int64(7140 + i), + Period: 86400, + } + req.SetMediaAreas([]tg.MediaAreaClass{area}) + if _, err := r.onStoriesSendStory(reqCtx, req); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("send nil media area[%d] err = %v, want MEDIA_INVALID", i, err) + } + } + stories, err = r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected nil media areas: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected nil media areas = %+v, want none", stories.Stories.Stories) + } + badURLReq := func(randomID int64, url string) *tg.StoriesSendStoryRequest { + req := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: randomID, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: randomID, + Period: 86400, + } + req.SetMediaAreas([]tg.MediaAreaClass{testStoryURLMediaArea(url, 10)}) + return req + } + for _, tc := range []struct { + name string + req *tg.StoriesSendStoryRequest + }{ + {name: "empty url", req: badURLReq(7113, "")}, + {name: "trimmed url", req: badURLReq(7114, " https://example.com ")}, + {name: "long url", req: badURLReq(7115, "https://example.com/"+strings.Repeat("x", domain.MaxStoryMediaAreaURLLength))}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onStoriesSendStory(reqCtx, tc.req); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("send story invalid url err = %v, want MEDIA_INVALID", err) + } + }) + } + stories, err = r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected url sends: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected url sends = %+v, want none", stories.Stories.Stories) + } + badGeoReq := func(randomID int64, area tg.MediaAreaClass) *tg.StoriesSendStoryRequest { + req := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: randomID, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: randomID, + Period: 86400, + } + req.SetMediaAreas([]tg.MediaAreaClass{area}) + return req + } + badAddress := testStoryGeoPointMediaArea(31.2, 121.4, 10).(*tg.MediaAreaGeoPoint) + badAddress.SetAddress(tg.GeoPointAddress{CountryISO2: "C", City: strings.Repeat("x", domain.MaxStoryGeoAddressPartLength+1)}) + for _, tc := range []struct { + name string + req *tg.StoriesSendStoryRequest + }{ + {name: "geo empty", req: badGeoReq(7116, &tg.MediaAreaGeoPoint{Coordinates: tg.MediaAreaCoordinates{X: 10, Y: 10, W: 10, H: 10}, Geo: &tg.GeoPointEmpty{}})}, + {name: "geo out of range", req: badGeoReq(7117, testStoryGeoPointMediaArea(91, 0, 10))}, + {name: "geo bad address", req: badGeoReq(7118, badAddress)}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onStoriesSendStory(reqCtx, tc.req); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("send story invalid geo err = %v, want MEDIA_INVALID", err) + } + }) + } + stories, err = r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected geo sends: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected geo sends = %+v, want none", stories.Stories.Stories) + } + badWeatherReq := func(randomID int64, area tg.MediaAreaClass) *tg.StoriesSendStoryRequest { + req := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: randomID, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: randomID, + Period: 86400, + } + req.SetMediaAreas([]tg.MediaAreaClass{area}) + return req + } + for _, tc := range []struct { + name string + req *tg.StoriesSendStoryRequest + }{ + {name: "weather empty emoji", req: badWeatherReq(7119, testStoryWeatherMediaArea("", 22, 0x00cc6600, 10))}, + {name: "weather trimmed emoji", req: badWeatherReq(7120, testStoryWeatherMediaArea(" ☀️ ", 22, 0x00cc6600, 10))}, + {name: "weather long emoji", req: badWeatherReq(7121, testStoryWeatherMediaArea(strings.Repeat("☀", domain.MaxStoryWeatherEmojiLength+1), 22, 0x00cc6600, 10))}, + {name: "weather nan temperature", req: badWeatherReq(7122, testStoryWeatherMediaArea("☀️", math.NaN(), 0x00cc6600, 10))}, + {name: "weather too cold", req: badWeatherReq(7123, testStoryWeatherMediaArea("☀️", -275, 0x00cc6600, 10))}, + {name: "weather too hot", req: badWeatherReq(7124, testStoryWeatherMediaArea("☀️", 1000001, 0x00cc6600, 10))}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onStoriesSendStory(reqCtx, tc.req); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("send story invalid weather err = %v, want MEDIA_INVALID", err) + } + }) + } + stories, err = r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected weather sends: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected weather sends = %+v, want none", stories.Stories.Stories) + } + badChannelPostReq := func(randomID int64, area tg.MediaAreaClass) *tg.StoriesSendStoryRequest { + req := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: randomID, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: randomID, + Period: 86400, + } + req.SetMediaAreas([]tg.MediaAreaClass{area}) + return req + } + for _, tc := range []struct { + name string + req *tg.StoriesSendStoryRequest + }{ + {name: "channel post empty channel", req: badChannelPostReq(7130, testStoryChannelPostMediaArea(0, 1, 10))}, + {name: "channel post empty message", req: badChannelPostReq(7131, testStoryChannelPostMediaArea(1001, 0, 10))}, + {name: "channel post message overflow", req: badChannelPostReq(7132, testStoryChannelPostMediaArea(1001, domain.MaxMessageBoxID+1, 10))}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onStoriesSendStory(reqCtx, tc.req); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("send story invalid channel post err = %v, want MEDIA_INVALID", err) + } + }) + } + stories, err = r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected channel post sends: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected channel post sends = %+v, want none", stories.Stories.Stories) + } + badStarGiftReq := func(randomID int64, slug string) *tg.StoriesSendStoryRequest { + req := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: randomID, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: randomID, + Period: 86400, + } + req.SetMediaAreas([]tg.MediaAreaClass{testStoryStarGiftMediaArea(slug, 10)}) + return req + } + for _, tc := range []struct { + name string + req *tg.StoriesSendStoryRequest + }{ + {name: "star gift empty slug", req: badStarGiftReq(7133, "")}, + {name: "star gift trimmed slug", req: badStarGiftReq(7134, " GiftSlug ")}, + {name: "star gift long slug", req: badStarGiftReq(7135, strings.Repeat("x", domain.MaxStoryStarGiftSlugLength+1))}, + {name: "star gift query separator", req: badStarGiftReq(7136, "GiftSlug&bad=1")}, + {name: "star gift fragment separator", req: badStarGiftReq(7137, "GiftSlug#fragment")}, + {name: "star gift unicode", req: badStarGiftReq(7138, "礼物")}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onStoriesSendStory(reqCtx, tc.req); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("send story invalid star gift err = %v, want MEDIA_INVALID", err) + } + }) + } + stories, err = r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected star gift sends: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected star gift sends = %+v, want none", stories.Stories.Stories) + } + badVenueReq := func(randomID int64, area tg.MediaAreaClass) *tg.StoriesSendStoryRequest { + req := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: randomID, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: randomID, + Period: 86400, + } + req.SetMediaAreas([]tg.MediaAreaClass{area}) + return req + } + for _, tc := range []struct { + name string + req *tg.StoriesSendStoryRequest + }{ + {name: "venue empty title", req: badVenueReq(7125, testStoryVenueMediaArea("", 31.2, 121.4, 10))}, + {name: "venue out of range geo", req: badVenueReq(7126, testStoryVenueMediaArea("Cafe", 91, 121.4, 10))}, + {name: "venue long provider", req: badVenueReq(7127, &tg.MediaAreaVenue{ + Coordinates: tg.MediaAreaCoordinates{X: 10, Y: 10, W: 10, H: 10}, + Geo: &tg.GeoPoint{Lat: 31.2, Long: 121.4, AccessHash: 123456}, + Title: "Cafe", + Provider: strings.Repeat("p", maxVenueProviderLength+1), + })}, + {name: "input venue unknown query", req: badVenueReq(7128, testStoryInputVenueMediaArea(99999, "venue-result", 10))}, + {name: "input venue empty result", req: badVenueReq(7129, testStoryInputVenueMediaArea(1, "", 10))}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onStoriesSendStory(reqCtx, tc.req); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("send story invalid venue err = %v, want MEDIA_INVALID", err) + } + }) + } + stories, err = r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected venue sends: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected venue sends = %+v, want none", stories.Stories.Stories) + } + + sendReq := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 43, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7112, + Period: 86400, + } + sendReq.SetMediaAreas([]tg.MediaAreaClass{testStoryMediaAreaWith("🔥", 10, false, false)}) + updates, err := r.onStoriesSendStory(reqCtx, sendReq) + if err != nil { + t.Fatalf("send valid media area story: %v", err) + } + idUpdate := updates.(*tg.Updates).Updates[0].(*tg.UpdateStoryID) + + customReaction := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + customReaction.SetMediaAreas([]tg.MediaAreaClass{&tg.MediaAreaSuggestedReaction{ + Coordinates: tg.MediaAreaCoordinates{X: 10, Y: 10, W: 10, H: 10}, + Reaction: &tg.ReactionCustomEmoji{DocumentID: 12345}, + }}) + customUpdates, err := r.onStoriesEditStory(reqCtx, customReaction) + if err != nil { + t.Fatalf("custom reaction media area edit: %v", err) + } + assertStorySuggestedCustomReactionArea(t, customUpdates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem), 12345, 10, false, false) + + invalidCoordinates := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + invalidCoordinates.SetMediaAreas([]tg.MediaAreaClass{&tg.MediaAreaSuggestedReaction{ + Coordinates: tg.MediaAreaCoordinates{X: 10, Y: 10, W: 0, H: 10}, + Reaction: &tg.ReactionEmoji{Emoticon: "👍"}, + }}) + if _, err := r.onStoriesEditStory(reqCtx, invalidCoordinates); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("invalid coordinates media area edit err = %v, want MEDIA_INVALID", err) + } + for i, area := range testNilStoryMediaAreas() { + nilAreaEdit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + nilAreaEdit.SetMediaAreas([]tg.MediaAreaClass{area}) + if _, err := r.onStoriesEditStory(reqCtx, nilAreaEdit); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("nil media area edit[%d] err = %v, want MEDIA_INVALID", i, err) + } + } + + stories, err = r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected edits: %v", err) + } + if len(stories.Stories.Stories) != 1 { + t.Fatalf("stories after rejected edits = %+v, want one original story", stories.Stories.Stories) + } + assertStorySuggestedCustomReactionArea(t, stories.Stories.Stories[0].(*tg.StoryItem), 12345, 10, false, false) +} + +func TestStoriesSendStoryAcceptsEmptyOptionalNoops(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92051, Phone: "15550092051", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000525, 0)}) + reqCtx := WithUserID(ctx, user.ID) + req := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 7210, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7210, + Period: 86400, + Caption: "empty optionals", + } + req.SetAlbums([]int{}) + req.SetMusic(&tg.InputDocumentEmpty{}) + + updates, err := r.onStoriesSendStory(reqCtx, req) + if err != nil { + t.Fatalf("send story with empty optional noops: %v", err) + } + got, ok := updates.(*tg.Updates) + if !ok || len(got.Updates) != 2 { + t.Fatalf("updates = %T %+v, want updateStoryID + updateStory", updates, updates) + } + storyUpdate, ok := got.Updates[1].(*tg.UpdateStory) + if !ok { + t.Fatalf("second update = %T, want updateStory", got.Updates[1]) + } + item, ok := storyUpdate.Story.(*tg.StoryItem) + if !ok { + t.Fatalf("story update item = %T, want storyItem", storyUpdate.Story) + } + if albums, ok := item.GetAlbums(); ok || len(albums) != 0 { + t.Fatalf("story albums = %v ok=%v, want absent", albums, ok) + } + if music, ok := item.GetMusic(); ok || music != nil { + t.Fatalf("story music = %T ok=%v, want absent", music, ok) + } + + stories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after empty optional noops: %v", err) + } + if len(stories.Stories.Stories) != 1 { + t.Fatalf("peer stories = %+v, want one story", stories.Stories.Stories) + } + stored := stories.Stories.Stories[0].(*tg.StoryItem) + if albums, ok := stored.GetAlbums(); ok || len(albums) != 0 { + t.Fatalf("stored story albums = %v ok=%v, want absent", albums, ok) + } + if music, ok := stored.GetMusic(); ok || music != nil { + t.Fatalf("stored story music = %T ok=%v, want absent", music, ok) + } +} + +func TestStoriesSendStoryValidatesPeriodBeforeMutation(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92061, Phone: "15550092061", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000526, 0)}) + reqCtx := WithUserID(ctx, user.ID) + cases := []int{-1, 1, 5 * 3600, domain.DefaultStoryPeriod + 1, 3 * domain.DefaultStoryPeriod} + for _, period := range cases { + req := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: int64(7300 + period), Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: int64(7300 + period), + } + req.SetPeriod(period) + if _, err := r.onStoriesSendStory(reqCtx, req); err == nil || !strings.Contains(err.Error(), "STORY_PERIOD_INVALID") { + t.Fatalf("send story period %d err = %v, want STORY_PERIOD_INVALID", period, err) + } + } + stories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected periods: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected periods = %+v, want none persisted", stories.Stories.Stories) + } +} + +func TestStoriesSendStoryUsesDefaultPeriodWhenFlagAbsent(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92071, Phone: "15550092071", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + now := time.Unix(1700000527, 0) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: now}) + reqCtx := WithUserID(ctx, user.ID) + updates, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 7310, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7310, + Period: -1, + Caption: "default period", + }) + if err != nil { + t.Fatalf("send story without period flag: %v", err) + } + got := updates.(*tg.Updates) + item, ok := got.Updates[1].(*tg.UpdateStory).Story.(*tg.StoryItem) + if !ok { + t.Fatalf("story update item = %T, want storyItem", got.Updates[1].(*tg.UpdateStory).Story) + } + if want := int(now.Unix()) + domain.DefaultStoryPeriod; item.ExpireDate != want { + t.Fatalf("story expire_date = %d, want %d", item.ExpireDate, want) + } +} + +func TestStoriesCaptionEntitiesRejectMalformedInputsBeforePeerAndMutation(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92081, Phone: "15550092081", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000528, 0)}) + reqCtx := WithUserID(ctx, user.ID) + + validReq := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 7320, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7320, + Period: 86400, + Caption: "🙂 ok", + Entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 2}}, + } + updates, err := r.onStoriesSendStory(reqCtx, validReq) + if err != nil { + t.Fatalf("send story with utf16 entity: %v", err) + } + item := updates.(*tg.Updates).Updates[1].(*tg.UpdateStory).Story.(*tg.StoryItem) + entities, ok := item.GetEntities() + if !ok || len(entities) != 1 { + t.Fatalf("story entities = ok %v %+v, want one entity", ok, entities) + } + if bold, ok := entities[0].(*tg.MessageEntityBold); !ok || bold.Offset != 0 || bold.Length != 2 { + t.Fatalf("story entity = %T %+v, want bold offset 0 length 2", entities[0], entities[0]) + } + storyID := updates.(*tg.Updates).Updates[0].(*tg.UpdateStoryID).ID + + var typedNilBold *tg.MessageEntityBold + var typedNilMention *tg.InputMessageEntityMentionName + tooMany := make([]tg.MessageEntityClass, maxMessageEntityCount+1) + for i := range tooMany { + tooMany[i] = &tg.MessageEntityBold{Offset: 0, Length: 1} + } + cases := []struct { + name string + caption string + entities []tg.MessageEntityClass + want string + }{ + {name: "nil entity", caption: "bad", entities: []tg.MessageEntityClass{nil}, want: "ENTITY_BOUNDS_INVALID"}, + {name: "typed nil entity", caption: "bad", entities: []tg.MessageEntityClass{typedNilBold}, want: "ENTITY_BOUNDS_INVALID"}, + {name: "typed nil mention name", caption: "bad", entities: []tg.MessageEntityClass{typedNilMention}, want: "ENTITY_BOUNDS_INVALID"}, + {name: "unknown constructor", caption: "bad", entities: []tg.MessageEntityClass{&tg.MessageEntityUnknown{Offset: 0, Length: 1}}, want: "ENTITY_BOUNDS_INVALID"}, + {name: "offset beyond caption", caption: "bad", entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 3, Length: 1}}, want: "ENTITY_BOUNDS_INVALID"}, + {name: "negative offset", caption: "bad", entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: -1, Length: 1}}, want: "ENTITY_BOUNDS_INVALID"}, + {name: "zero length", caption: "bad", entities: []tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 0, Length: 0}}, want: "ENTITY_BOUNDS_INVALID"}, + {name: "custom emoji zero document", caption: "bad", entities: []tg.MessageEntityClass{&tg.MessageEntityCustomEmoji{Offset: 0, Length: 1}}, want: "ENTITY_BOUNDS_INVALID"}, + {name: "mention name nil user", caption: "bad", entities: []tg.MessageEntityClass{&tg.InputMessageEntityMentionName{Offset: 0, Length: 1}}, want: "USER_ID_INVALID"}, + {name: "too many entities", caption: "bad", entities: tooMany, want: "ENTITIES_TOO_LONG"}, + } + for i, tc := range cases { + t.Run("send "+tc.name, func(t *testing.T) { + _, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerEmpty{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: int64(7330 + i), Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: int64(7330 + i), + Period: 86400, + Caption: tc.caption, + Entities: tc.entities, + }) + if err == nil || !tgerr.Is(err, tc.want) { + t.Fatalf("send bad entities err = %v, want %s", err, tc.want) + } + }) + t.Run("edit "+tc.name, func(t *testing.T) { + edit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerEmpty{}, ID: storyID} + edit.SetCaption(tc.caption) + edit.SetEntities(tc.entities) + _, err := r.onStoriesEditStory(reqCtx, edit) + if err == nil || !tgerr.Is(err, tc.want) { + t.Fatalf("edit bad entities err = %v, want %s", err, tc.want) + } + }) + t.Run("start live "+tc.name, func(t *testing.T) { + _, err := r.onStoriesStartLive(reqCtx, &tg.StoriesStartLiveRequest{ + Peer: &tg.InputPeerEmpty{}, + RandomID: int64(7340 + i), + Caption: tc.caption, + Entities: tc.entities, + }) + if err == nil || !tgerr.Is(err, tc.want) { + t.Fatalf("startLive bad entities err = %v, want %s", err, tc.want) + } + }) + } + + edit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: storyID} + edit.SetCaption("mutate") + edit.SetEntities([]tg.MessageEntityClass{&tg.MessageEntityBold{Offset: 6, Length: 1}}) + if _, err := r.onStoriesEditStory(reqCtx, edit); err == nil || !tgerr.Is(err, "ENTITY_BOUNDS_INVALID") { + t.Fatalf("edit bad entities before mutation err = %v, want ENTITY_BOUNDS_INVALID", err) + } + stories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected caption entities: %v", err) + } + if len(stories.Stories.Stories) != 1 { + t.Fatalf("stories after rejected caption entities = %+v, want original story only", stories.Stories.Stories) + } + stored := stories.Stories.Stories[0].(*tg.StoryItem) + if stored.Caption != "🙂 ok" || stored.Edited { + t.Fatalf("story after rejected caption entities = %+v, want original caption and not edited", stored) + } +} + +func TestStoriesSendStoryRejectsUnsupportedOptionalFlags(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92101, Phone: "15550092101", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + files := &fakeFiles{photos: map[int64]domain.Photo{}} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Files: files, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000550, 0)}) + reqCtx := WithUserID(ctx, user.ID) + baseReq := func(randomID int64) *tg.StoriesSendStoryRequest { + return &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: randomID, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: randomID, + Period: 86400, + } + } + cases := []struct { + name string + req *tg.StoriesSendStoryRequest + want string + }{ + { + name: "nil main media before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7208) + req.Peer = &tg.InputPeerEmpty{} + req.Media = nil + return req + }(), + want: "MEDIA_EMPTY", + }, + { + name: "typed nil main media before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7209) + req.Peer = &tg.InputPeerEmpty{} + req.Media = (*tg.InputMediaUploadedPhoto)(nil) + return req + }(), + want: "MEDIA_EMPTY", + }, + { + name: "unsupported main media before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7210) + req.Peer = &tg.InputPeerEmpty{} + req.Media = &tg.InputMediaContact{PhoneNumber: "15550000000", FirstName: "Contact"} + return req + }(), + want: "MEDIA_TYPE_INVALID", + }, + { + name: "uploaded photo missing file before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7211) + req.Peer = &tg.InputPeerEmpty{} + req.Media = &tg.InputMediaUploadedPhoto{} + return req + }(), + want: "MEDIA_INVALID", + }, + { + name: "referenced photo missing id before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7212) + req.Peer = &tg.InputPeerEmpty{} + req.Media = &tg.InputMediaPhoto{} + return req + }(), + want: "PHOTO_INVALID", + }, + { + name: "referenced photo typed nil id before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7213) + req.Peer = &tg.InputPeerEmpty{} + req.Media = &tg.InputMediaPhoto{ID: (*tg.InputPhoto)(nil)} + return req + }(), + want: "PHOTO_INVALID", + }, + { + name: "uploaded document missing file before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7214) + req.Peer = &tg.InputPeerEmpty{} + req.Media = &tg.InputMediaUploadedDocument{} + return req + }(), + want: "MEDIA_INVALID", + }, + { + name: "referenced document missing id before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7215) + req.Peer = &tg.InputPeerEmpty{} + req.Media = &tg.InputMediaDocument{} + return req + }(), + want: "MEDIA_INVALID", + }, + { + name: "referenced document typed nil id before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7216) + req.Peer = &tg.InputPeerEmpty{} + req.Media = &tg.InputMediaDocument{ID: (*tg.InputDocument)(nil)} + return req + }(), + want: "MEDIA_INVALID", + }, + { + name: "music document", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7201) + req.SetMusic(&tg.InputDocument{ID: 1001, AccessHash: 2001}) + return req + }(), + want: "DOCUMENT_INVALID", + }, + { + name: "typed nil empty music before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7206) + req.Peer = &tg.InputPeerEmpty{} + req.SetMusic((*tg.InputDocumentEmpty)(nil)) + return req + }(), + want: "DOCUMENT_INVALID", + }, + { + name: "typed nil document music", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7207) + req.SetMusic((*tg.InputDocument)(nil)) + return req + }(), + want: "DOCUMENT_INVALID", + }, + { + name: "malformed media area", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7202) + req.SetMediaAreas([]tg.MediaAreaClass{testMalformedStoryMediaArea()}) + return req + }(), + want: "MEDIA_INVALID", + }, + { + name: "albums", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7203) + req.SetAlbums([]int{1}) + return req + }(), + want: "MEDIA_INVALID", + }, + { + name: "repost", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7204) + req.SetFwdFromID(&tg.InputPeerSelf{}) + req.SetFwdFromStory(1) + return req + }(), + want: "STORY_ID_INVALID", + }, + { + name: "repost story without source peer before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7217) + req.Peer = &tg.InputPeerEmpty{} + req.SetFwdFromStory(1) + return req + }(), + want: "STORY_ID_INVALID", + }, + { + name: "repost source peer without story before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7218) + req.Peer = &tg.InputPeerEmpty{} + req.SetFwdFromID(&tg.InputPeerSelf{}) + return req + }(), + want: "STORY_ID_INVALID", + }, + { + name: "repost invalid story id before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7219) + req.Peer = &tg.InputPeerEmpty{} + req.SetFwdFromID(&tg.InputPeerSelf{}) + req.SetFwdFromStory(domain.MaxStoryID + 1) + return req + }(), + want: "STORY_ID_INVALID", + }, + { + name: "repost typed nil source peer before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7220) + req.Peer = &tg.InputPeerEmpty{} + req.SetFwdFromID((*tg.InputPeerUser)(nil)) + req.SetFwdFromStory(1) + return req + }(), + want: "STORY_ID_INVALID", + }, + { + name: "repost empty source peer before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7221) + req.Peer = &tg.InputPeerEmpty{} + req.SetFwdFromID(&tg.InputPeerEmpty{}) + req.SetFwdFromStory(1) + return req + }(), + want: "STORY_ID_INVALID", + }, + { + name: "repost zero source user id before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7222) + req.Peer = &tg.InputPeerEmpty{} + req.SetFwdFromID(&tg.InputPeerUser{}) + req.SetFwdFromStory(1) + return req + }(), + want: "STORY_ID_INVALID", + }, + { + name: "repost source from message without msg id before peer", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7223) + req.Peer = &tg.InputPeerEmpty{} + req.SetFwdFromID(&tg.InputPeerUserFromMessage{Peer: &tg.InputPeerSelf{}, UserID: 1}) + req.SetFwdFromStory(1) + return req + }(), + want: "STORY_ID_INVALID", + }, + { + name: "fwd modified", + req: func() *tg.StoriesSendStoryRequest { + req := baseReq(7205) + req.FwdModified = true + return req + }(), + want: "STORY_ID_INVALID", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := r.onStoriesSendStory(reqCtx, tc.req); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("send story err = %v, want %s", err, tc.want) + } + }) + } + stories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected sends: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected sends = %+v, want none persisted", stories.Stories.Stories) + } +} + +func TestStoriesSendStoryRejectsMissingRepostSourceBeforeMediaResolution(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92151, Phone: "15550092151", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + files := &fakeFiles{photos: map[int64]domain.Photo{}} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Files: files, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000575, 0)}) + reqCtx := WithUserID(ctx, user.ID) + req := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 7250, Parts: 1, Name: "orphan.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7250, + Period: 86400, + } + req.SetFwdFromID(&tg.InputPeerSelf{}) + req.SetFwdFromStory(1) + if _, err := r.onStoriesSendStory(reqCtx, req); err == nil || !strings.Contains(err.Error(), "STORY_ID_INVALID") { + t.Fatalf("missing repost source err = %v, want STORY_ID_INVALID", err) + } + if len(files.photos) != 0 { + t.Fatalf("photos after missing repost source = %+v, want no media resolution before source validation", files.photos) + } + malformedSource := &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 7251, Parts: 1, Name: "malformed-source.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7251, + Period: 86400, + } + malformedSource.SetFwdFromID(&tg.InputPeerEmpty{}) + malformedSource.SetFwdFromStory(1) + if _, err := r.onStoriesSendStory(reqCtx, malformedSource); err == nil || !strings.Contains(err.Error(), "STORY_ID_INVALID") { + t.Fatalf("malformed repost source err = %v, want STORY_ID_INVALID", err) + } + if len(files.photos) != 0 { + t.Fatalf("photos after malformed repost source = %+v, want no media resolution before source validation", files.photos) + } + stories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after missing repost source: %v", err) + } + if len(stories.Stories.Stories) != 0 { + t.Fatalf("stories after missing repost source = %+v, want none persisted", stories.Stories.Stories) + } +} + +func TestStoriesEditStoryHandlesAndroidEmptyMusicAndRejectsUnsupportedFlags(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{9, 8, 8} + userStore := memory.NewUserStore() + user, err := userStore.Create(ctx, domain.User{AccessHash: 92201, Phone: "15550092201", FirstName: "Author"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000600, 0)}) + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 98) + updates, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 43, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 7301, + Period: 86400, + Caption: "first story", + }) + if err != nil { + t.Fatalf("send story: %v", err) + } + idUpdate := updates.(*tg.Updates).Updates[0].(*tg.UpdateStoryID) + + noop := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + noop.SetMusic(&tg.InputDocumentEmpty{}) + updates, err = r.onStoriesEditStory(reqCtx, noop) + if err != nil { + t.Fatalf("empty music noop edit: %v", err) + } + noopItem := updates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + if noopItem.Caption != "first story" || noopItem.Edited { + t.Fatalf("noop edit story = %+v, want unchanged caption and edited=false", noopItem) + } + diff, err := r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after noop: %v", err) + } + gotDiff := diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 1 || len(gotDiff.OtherUpdates) != 1 { + t.Fatalf("difference after noop = pts %d updates %d, want only original send", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } + + edit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + edit.SetMusic(&tg.InputDocumentEmpty{}) + edit.SetCaption("android caption edit") + if updates, err = r.onStoriesEditStory(reqCtx, edit); err != nil { + t.Fatalf("caption edit with empty music: %v", err) + } + editedItem := updates.(*tg.Updates).Updates[0].(*tg.UpdateStory).Story.(*tg.StoryItem) + if !editedItem.Edited || editedItem.Caption != "android caption edit" { + t.Fatalf("caption edit story = %+v, want edited caption", editedItem) + } + if music, ok := editedItem.GetMusic(); ok || music != nil { + t.Fatalf("caption edit story music = %T ok=%v, want absent", music, ok) + } + diff, err = r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after caption edit: %v", err) + } + gotDiff = diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 2 || len(gotDiff.OtherUpdates) != 2 { + t.Fatalf("difference after caption edit = pts %d updates %d, want send+edit", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } + diffEdited := gotDiff.OtherUpdates[1].(*tg.UpdateStory).Story.(*tg.StoryItem) + if diffEdited.Caption != "android caption edit" || !diffEdited.Edited { + t.Fatalf("difference edited story = %+v, want edited caption", diffEdited) + } + if music, ok := diffEdited.GetMusic(); ok || music != nil { + t.Fatalf("difference edited story music = %T ok=%v, want absent", music, ok) + } + + sameCaption := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + sameCaption.SetMusic(&tg.InputDocumentEmpty{}) + sameCaption.SetCaption("android caption edit") + if _, err := r.onStoriesEditStory(reqCtx, sameCaption); err == nil || !tgerr.Is(err, "STORY_NOT_MODIFIED") { + t.Fatalf("same caption edit err = %v, want STORY_NOT_MODIFIED", err) + } + diff, err = r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after not modified edit: %v", err) + } + gotDiff = diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 2 || len(gotDiff.OtherUpdates) != 2 { + t.Fatalf("difference after not modified edit = pts %d updates %d, want no new events", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } + + nonEmptyMusic := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + nonEmptyMusic.SetMusic(&tg.InputDocument{ID: 1002, AccessHash: 2002}) + if _, err := r.onStoriesEditStory(reqCtx, nonEmptyMusic); err == nil || !strings.Contains(err.Error(), "DOCUMENT_INVALID") { + t.Fatalf("non-empty music edit err = %v, want DOCUMENT_INVALID", err) + } + typedNilEmptyMusic := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + typedNilEmptyMusic.SetMusic((*tg.InputDocumentEmpty)(nil)) + if _, err := r.onStoriesEditStory(reqCtx, typedNilEmptyMusic); err == nil || !strings.Contains(err.Error(), "DOCUMENT_INVALID") { + t.Fatalf("typed nil empty music edit err = %v, want DOCUMENT_INVALID", err) + } + typedNilMusicDocument := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + typedNilMusicDocument.SetMusic((*tg.InputDocument)(nil)) + if _, err := r.onStoriesEditStory(reqCtx, typedNilMusicDocument); err == nil || !strings.Contains(err.Error(), "DOCUMENT_INVALID") { + t.Fatalf("typed nil document music edit err = %v, want DOCUMENT_INVALID", err) + } + invalidMediaEdits := []struct { + name string + media tg.InputMediaClass + want string + }{ + { + name: "nil media before peer", + media: nil, + want: "MEDIA_EMPTY", + }, + { + name: "typed nil media before peer", + media: (*tg.InputMediaUploadedPhoto)(nil), + want: "MEDIA_EMPTY", + }, + { + name: "unsupported media before peer", + media: &tg.InputMediaContact{PhoneNumber: "15550000000", FirstName: "Contact"}, + want: "MEDIA_TYPE_INVALID", + }, + { + name: "uploaded photo missing file before peer", + media: &tg.InputMediaUploadedPhoto{}, + want: "MEDIA_INVALID", + }, + { + name: "referenced photo missing id before peer", + media: &tg.InputMediaPhoto{}, + want: "PHOTO_INVALID", + }, + { + name: "referenced photo typed nil id before peer", + media: &tg.InputMediaPhoto{ID: (*tg.InputPhoto)(nil)}, + want: "PHOTO_INVALID", + }, + { + name: "uploaded document missing file before peer", + media: &tg.InputMediaUploadedDocument{}, + want: "MEDIA_INVALID", + }, + { + name: "referenced document missing id before peer", + media: &tg.InputMediaDocument{}, + want: "MEDIA_INVALID", + }, + { + name: "referenced document typed nil id before peer", + media: &tg.InputMediaDocument{ID: (*tg.InputDocument)(nil)}, + want: "MEDIA_INVALID", + }, + } + for _, tc := range invalidMediaEdits { + t.Run(tc.name, func(t *testing.T) { + invalidMedia := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerEmpty{}, ID: idUpdate.ID} + invalidMedia.SetMedia(tc.media) + if _, err := r.onStoriesEditStory(reqCtx, invalidMedia); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("invalid media edit err = %v, want %s", err, tc.want) + } + }) + } + mediaAreas := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + mediaAreas.SetMediaAreas([]tg.MediaAreaClass{testMalformedStoryMediaArea()}) + if _, err := r.onStoriesEditStory(reqCtx, mediaAreas); err == nil || !strings.Contains(err.Error(), "MEDIA_INVALID") { + t.Fatalf("media areas edit err = %v, want MEDIA_INVALID", err) + } + stories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected edits: %v", err) + } + item := stories.Stories.Stories[0].(*tg.StoryItem) + if item.Caption != "android caption edit" { + t.Fatalf("story after rejected edits = %+v, want caption preserved", item) + } + if music, ok := item.GetMusic(); ok || music != nil { + t.Fatalf("story after rejected edits music = %T ok=%v, want absent", music, ok) + } + diff, err = r.onUpdatesGetDifference(reqCtx, &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get difference after rejected edits: %v", err) + } + gotDiff = diff.(*tg.UpdatesDifference) + if gotDiff.State.Pts != 2 || len(gotDiff.OtherUpdates) != 2 { + t.Fatalf("difference after rejected edits = pts %d updates %d, want no new events", gotDiff.State.Pts, len(gotDiff.OtherUpdates)) + } +} + +func TestStoriesSendEditPrivacyRulesDriveVisibility(t *testing.T) { + ctx := context.Background() + authKeyID := [8]byte{9, 9, 1} + userStore := memory.NewUserStore() + author, err := userStore.Create(ctx, domain.User{AccessHash: 93001, Phone: "15550093001", FirstName: "Author"}) + if err != nil { + t.Fatalf("create author: %v", err) + } + selected, err := userStore.Create(ctx, domain.User{AccessHash: 93002, Phone: "15550093002", FirstName: "Selected"}) + if err != nil { + t.Fatalf("create selected viewer: %v", err) + } + stranger, err := userStore.Create(ctx, domain.User{AccessHash: 93003, Phone: "15550093003", FirstName: "Stranger"}) + if err != nil { + t.Fatalf("create stranger: %v", err) + } + storyStore := memory.NewStoryStore() + now := time.Unix(1700000900, 0) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: now}) + reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, author.ID), authKeyID), 991) + + updates, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 52, Parts: 1, Name: "selected.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowUsers{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: selected.ID, AccessHash: selected.AccessHash}}, + }}, + RandomID: 8001, + Period: 86400, + Caption: "selected only", + }) + if err != nil { + t.Fatalf("send selected story: %v", err) + } + sendUpdates, ok := updates.(*tg.Updates) + if !ok || len(sendUpdates.Updates) != 2 { + t.Fatalf("send updates = %T %+v, want updateStoryID + updateStory", updates, updates) + } + idUpdate := sendUpdates.Updates[0].(*tg.UpdateStoryID) + storyUpdate, ok := sendUpdates.Updates[1].(*tg.UpdateStory) + if !ok { + t.Fatalf("story update = %T, want updateStory", sendUpdates.Updates[1]) + } + item, ok := storyUpdate.Story.(*tg.StoryItem) + if !ok || !item.SelectedContacts || item.Public { + t.Fatalf("sent story item = %T %+v, want selected_contacts private", storyUpdate.Story, storyUpdate.Story) + } + if len(item.Privacy) != 1 { + t.Fatalf("sent story privacy = %+v, want one allowUsers rule", item.Privacy) + } + allowUsers, ok := item.Privacy[0].(*tg.PrivacyValueAllowUsers) + if !ok || len(allowUsers.Users) != 1 || allowUsers.Users[0] != selected.ID { + t.Fatalf("sent allowUsers = %T %+v, want selected user", item.Privacy[0], item.Privacy[0]) + } + if findUserClass(sendUpdates.Users, selected.ID) == nil { + t.Fatalf("send users = %+v, want selected privacy user", sendUpdates.Users) + } + + selectedPeerStories, err := r.onStoriesGetPeerStories(WithUserID(ctx, selected.ID), &tg.InputPeerUser{UserID: author.ID, AccessHash: author.AccessHash}) + if err != nil { + t.Fatalf("selected get peer stories: %v", err) + } + if len(selectedPeerStories.Stories.Stories) != 1 { + t.Fatalf("selected peer stories = %+v, want one visible story", selectedPeerStories.Stories.Stories) + } + strangerPeerStories, err := r.onStoriesGetPeerStories(WithUserID(ctx, stranger.ID), &tg.InputPeerUser{UserID: author.ID, AccessHash: author.AccessHash}) + if err != nil { + t.Fatalf("stranger get peer stories: %v", err) + } + if len(strangerPeerStories.Stories.Stories) != 0 { + t.Fatalf("stranger peer stories = %+v, want hidden selected story", strangerPeerStories.Stories.Stories) + } + + edit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: idUpdate.ID} + edit.SetPrivacyRules([]tg.InputPrivacyRuleClass{ + &tg.InputPrivacyValueAllowAll{}, + &tg.InputPrivacyValueDisallowUsers{Users: []tg.InputUserClass{ + &tg.InputUser{UserID: selected.ID, AccessHash: selected.AccessHash}, + }}, + }) + updates, err = r.onStoriesEditStory(reqCtx, edit) + if err != nil { + t.Fatalf("edit story privacy: %v", err) + } + editUpdates := updates.(*tg.Updates) + edited, ok := editUpdates.Updates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("edit update = %T, want updateStory", editUpdates.Updates[0]) + } + editedItem, ok := edited.Story.(*tg.StoryItem) + if !ok || !editedItem.Public || editedItem.SelectedContacts { + t.Fatalf("edited story = %T %+v, want public with privacy exceptions", edited.Story, edited.Story) + } + if len(editedItem.Privacy) != 2 { + t.Fatalf("edited privacy = %+v, want allowAll + disallowUsers", editedItem.Privacy) + } + if findUserClass(editUpdates.Users, selected.ID) == nil { + t.Fatalf("edit users = %+v, want selected privacy user", editUpdates.Users) + } + + selectedPeerStories, err = r.onStoriesGetPeerStories(WithUserID(ctx, selected.ID), &tg.InputPeerUser{UserID: author.ID, AccessHash: author.AccessHash}) + if err != nil { + t.Fatalf("selected get peer stories after edit: %v", err) + } + if len(selectedPeerStories.Stories.Stories) != 0 { + t.Fatalf("selected peer stories after disallow = %+v, want hidden", selectedPeerStories.Stories.Stories) + } + strangerPeerStories, err = r.onStoriesGetPeerStories(WithUserID(ctx, stranger.ID), &tg.InputPeerUser{UserID: author.ID, AccessHash: author.AccessHash}) + if err != nil { + t.Fatalf("stranger get peer stories after edit: %v", err) + } + if len(strangerPeerStories.Stories.Stories) != 1 { + t.Fatalf("stranger peer stories after public = %+v, want visible", strangerPeerStories.Stories.Stories) + } +} + +func TestStoriesPrivacyRulesRejectNilInputsBeforeMutation(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + author, err := userStore.Create(ctx, domain.User{AccessHash: 93201, Phone: "15550093201", FirstName: "Author"}) + if err != nil { + t.Fatalf("create author: %v", err) + } + storyStore := memory.NewStoryStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), + Files: &fakeFiles{photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000920, 0)}) + reqCtx := WithUserID(ctx, author.ID) + + var typedNilAllowAll *tg.InputPrivacyValueAllowAll + var typedNilAllowUsers *tg.InputPrivacyValueAllowUsers + var typedNilInputUser *tg.InputUser + cases := []struct { + name string + rules []tg.InputPrivacyRuleClass + want string + }{ + {name: "nil rule", rules: []tg.InputPrivacyRuleClass{nil}, want: "PRIVACY_VALUE_INVALID"}, + {name: "typed nil allow all", rules: []tg.InputPrivacyRuleClass{typedNilAllowAll}, want: "PRIVACY_VALUE_INVALID"}, + {name: "typed nil allow users", rules: []tg.InputPrivacyRuleClass{typedNilAllowUsers}, want: "PRIVACY_VALUE_INVALID"}, + {name: "nil input user", rules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowUsers{Users: []tg.InputUserClass{nil}}}, want: "USER_ID_INVALID"}, + {name: "typed nil input user", rules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowUsers{Users: []tg.InputUserClass{typedNilInputUser}}}, want: "USER_ID_INVALID"}, + } + for i, tc := range cases { + t.Run("send "+tc.name, func(t *testing.T) { + _, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: int64(8200 + i), Parts: 1, Name: "story.jpg"}}, + PrivacyRules: tc.rules, + RandomID: int64(8200 + i), + Period: 86400, + Caption: "bad privacy", + }) + if err == nil || !tgerr.Is(err, tc.want) { + t.Fatalf("send privacy err = %v, want %s", err, tc.want) + } + }) + } + peerStories, err := r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected privacy sends: %v", err) + } + if len(peerStories.Stories.Stories) != 0 { + t.Fatalf("stories after rejected privacy sends = %+v, want none", peerStories.Stories.Stories) + } + + updates, err := r.onStoriesSendStory(reqCtx, &tg.StoriesSendStoryRequest{ + Peer: &tg.InputPeerSelf{}, + Media: &tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 8300, Parts: 1, Name: "story.jpg"}}, + PrivacyRules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}, + RandomID: 8300, + Period: 86400, + Caption: "valid privacy", + }) + if err != nil { + t.Fatalf("send valid privacy story: %v", err) + } + storyID := updates.(*tg.Updates).Updates[0].(*tg.UpdateStoryID).ID + + for _, tc := range cases { + t.Run("edit "+tc.name, func(t *testing.T) { + edit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: storyID} + edit.SetPrivacyRules(tc.rules) + _, err := r.onStoriesEditStory(reqCtx, edit) + if err == nil || !tgerr.Is(err, tc.want) { + t.Fatalf("edit privacy err = %v, want %s", err, tc.want) + } + }) + } + peerStories, err = r.onStoriesGetPeerStories(reqCtx, &tg.InputPeerSelf{}) + if err != nil { + t.Fatalf("get peer stories after rejected privacy edits: %v", err) + } + if len(peerStories.Stories.Stories) != 1 { + t.Fatalf("stories after rejected privacy edits = %+v, want original story", peerStories.Stories.Stories) + } + item := peerStories.Stories.Stories[0].(*tg.StoryItem) + if !item.Public || item.SelectedContacts || item.CloseFriends || item.Contacts { + t.Fatalf("story visibility after rejected privacy edits = public %v selected %v close %v contacts %v, want original public only", item.Public, item.SelectedContacts, item.CloseFriends, item.Contacts) + } + if item.Caption != "valid privacy" { + t.Fatalf("story caption after rejected privacy edits = %q, want original", item.Caption) + } + if len(item.Privacy) != 1 { + t.Fatalf("story privacy after rejected edits = %+v, want original allowAll", item.Privacy) + } + if _, ok := item.Privacy[0].(*tg.PrivacyValueAllowAll); !ok { + t.Fatalf("story privacy[0] after rejected edits = %T, want allowAll", item.Privacy[0]) + } +} + +func testStoryMediaArea() tg.MediaAreaClass { + return testStoryMediaAreaWith("🔥", 10, false, false) +} + +func testStoryMediaAreaWith(emoticon string, x float64, dark, flipped bool) tg.MediaAreaClass { + coordinates := tg.MediaAreaCoordinates{X: x, Y: 10, W: 10, H: 10, Rotation: 15} + coordinates.SetRadius(8) + return &tg.MediaAreaSuggestedReaction{ + Dark: dark, + Flipped: flipped, + Coordinates: coordinates, + Reaction: &tg.ReactionEmoji{Emoticon: emoticon}, + } +} + +func testMalformedStoryMediaArea() tg.MediaAreaClass { + return nil +} + +func testNilStoryMediaAreas() []tg.MediaAreaClass { + var suggested *tg.MediaAreaSuggestedReaction + var url *tg.MediaAreaURL + var geo *tg.MediaAreaGeoPoint + var venue *tg.MediaAreaVenue + var inputVenue *tg.InputMediaAreaVenue + var weather *tg.MediaAreaWeather + var channelPost *tg.MediaAreaChannelPost + var starGift *tg.MediaAreaStarGift + var inputChannelPost *tg.InputMediaAreaChannelPost + return []tg.MediaAreaClass{ + nil, + suggested, + url, + geo, + venue, + inputVenue, + weather, + channelPost, + starGift, + inputChannelPost, + } +} + +func testStoryURLMediaArea(url string, x float64) tg.MediaAreaClass { + return &tg.MediaAreaURL{ + Coordinates: tg.MediaAreaCoordinates{X: x, Y: 10, W: 20, H: 10, Rotation: 15}, + URL: url, + } +} + +func testStoryGeoPointMediaArea(lat, long, x float64) tg.MediaAreaClass { + area := &tg.MediaAreaGeoPoint{ + Coordinates: tg.MediaAreaCoordinates{X: x, Y: 12, W: 22, H: 11, Rotation: 20}, + Geo: &tg.GeoPoint{Lat: lat, Long: long, AccessHash: 123456}, + } + area.SetAddress(tg.GeoPointAddress{ + CountryISO2: "CN", + State: "Shanghai", + City: "Shanghai", + Street: "People Square", + }) + return area +} + +func testStoryVenueMediaArea(title string, lat, long, x float64) tg.MediaAreaClass { + return &tg.MediaAreaVenue{ + Coordinates: tg.MediaAreaCoordinates{X: x, Y: 13, W: 23, H: 12, Rotation: 22}, + Geo: &tg.GeoPoint{Lat: lat, Long: long, AccessHash: 123456}, + Title: title, + Address: "Inline Street", + Provider: "gplaces", + VenueID: "venue-id", + VenueType: "cafe", + } +} + +func testStoryInputVenueMediaArea(queryID int64, resultID string, x float64) tg.MediaAreaClass { + return &tg.InputMediaAreaVenue{ + Coordinates: tg.MediaAreaCoordinates{X: x, Y: 13, W: 23, H: 12, Rotation: 22}, + QueryID: queryID, + ResultID: resultID, + } +} + +func testStoryInlineVenueResult(id, title string, lat, long float64) domain.BotInlineResult { + return domain.BotInlineResult{ + ID: id, + Type: "venue", + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindVenue, Venue: &domain.MessageVenue{ + Geo: domain.MessageGeoPoint{Lat: lat, Long: long, AccessHash: 123456}, + Title: title, + Address: "Inline Street", + Provider: "gplaces", + VenueID: "venue-id", + VenueType: "cafe", + }}, + } +} + +func testStoryWeatherMediaArea(emoji string, temperatureC float64, color int, x float64) tg.MediaAreaClass { + return &tg.MediaAreaWeather{ + Coordinates: tg.MediaAreaCoordinates{X: x, Y: 14, W: 24, H: 12, Rotation: 25}, + Emoji: emoji, + TemperatureC: temperatureC, + Color: color, + } +} + +func testStoryChannelPostMediaArea(channelID int64, msgID int, x float64) tg.MediaAreaClass { + return &tg.MediaAreaChannelPost{ + Coordinates: tg.MediaAreaCoordinates{X: x, Y: 15, W: 25, H: 12, Rotation: 27}, + ChannelID: channelID, + MsgID: msgID, + } +} + +func testStoryStarGiftMediaArea(slug string, x float64) tg.MediaAreaClass { + return &tg.MediaAreaStarGift{ + Coordinates: tg.MediaAreaCoordinates{X: x, Y: 16, W: 26, H: 12, Rotation: 29}, + Slug: slug, + } +} + +func testStoryInputChannelPostMediaArea(channelID, accessHash int64, msgID int, x float64) tg.MediaAreaClass { + return &tg.InputMediaAreaChannelPost{ + Coordinates: tg.MediaAreaCoordinates{X: x, Y: 15, W: 25, H: 12, Rotation: 27}, + Channel: &tg.InputChannel{ChannelID: channelID, AccessHash: accessHash}, + MsgID: msgID, + } +} + +func assertStorySuggestedReactionArea(t *testing.T, item *tg.StoryItem, wantEmoticon string, wantX float64, wantDark, wantFlipped bool) { + t.Helper() + areas, ok := item.GetMediaAreas() + if !ok || len(areas) != 1 { + t.Fatalf("story media areas = ok %v %+v, want one area", ok, areas) + } + area, ok := areas[0].(*tg.MediaAreaSuggestedReaction) + if !ok { + t.Fatalf("story media area = %T, want mediaAreaSuggestedReaction", areas[0]) + } + if area.Dark != wantDark || area.Flipped != wantFlipped { + t.Fatalf("story media area flags = dark %v flipped %v, want %v/%v", area.Dark, area.Flipped, wantDark, wantFlipped) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 10 || area.Coordinates.W != 10 || area.Coordinates.H != 10 || area.Coordinates.Rotation != 15 { + t.Fatalf("story media area coordinates = %+v, want x %v y/w/h 10 rotation 15", area.Coordinates, wantX) + } + radius, ok := area.Coordinates.GetRadius() + if !ok || radius != 8 { + t.Fatalf("story media area radius = %v ok %v, want 8", radius, ok) + } + reaction, ok := area.Reaction.(*tg.ReactionEmoji) + if !ok || reaction.Emoticon != wantEmoticon { + t.Fatalf("story media area reaction = %T %+v, want emoji %q", area.Reaction, area.Reaction, wantEmoticon) + } +} + +func assertStorySuggestedCustomReactionArea(t *testing.T, item *tg.StoryItem, wantDocumentID int64, wantX float64, wantDark, wantFlipped bool) { + t.Helper() + areas, ok := item.GetMediaAreas() + if !ok || len(areas) != 1 { + t.Fatalf("story media areas = ok %v %+v, want one area", ok, areas) + } + area, ok := areas[0].(*tg.MediaAreaSuggestedReaction) + if !ok { + t.Fatalf("story media area = %T, want mediaAreaSuggestedReaction", areas[0]) + } + if area.Dark != wantDark || area.Flipped != wantFlipped { + t.Fatalf("story media area flags = dark %v flipped %v, want %v/%v", area.Dark, area.Flipped, wantDark, wantFlipped) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 10 || area.Coordinates.W != 10 || area.Coordinates.H != 10 { + t.Fatalf("story media area coordinates = %+v, want x %v y/w/h 10", area.Coordinates, wantX) + } + reaction, ok := area.Reaction.(*tg.ReactionCustomEmoji) + if !ok || reaction.DocumentID != wantDocumentID { + t.Fatalf("story media area reaction = %T %+v, want custom emoji %d", area.Reaction, area.Reaction, wantDocumentID) + } +} + +func assertStoryURLMediaArea(t *testing.T, item *tg.StoryItem, wantURL string, wantX float64) { + t.Helper() + areas, ok := item.GetMediaAreas() + if !ok || len(areas) != 1 { + t.Fatalf("story media areas = ok %v %+v, want one url area", ok, areas) + } + area, ok := areas[0].(*tg.MediaAreaURL) + if !ok { + t.Fatalf("story media area = %T, want mediaAreaUrl", areas[0]) + } + if area.URL != wantURL { + t.Fatalf("story media area url = %q, want %q", area.URL, wantURL) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 10 || area.Coordinates.W != 20 || area.Coordinates.H != 10 || area.Coordinates.Rotation != 15 { + t.Fatalf("story media area coordinates = %+v, want x %v y 10 w 20 h 10 rotation 15", area.Coordinates, wantX) + } +} + +func assertStoryGeoPointMediaArea(t *testing.T, item *tg.StoryItem, wantLat, wantLong, wantX float64) { + t.Helper() + areas, ok := item.GetMediaAreas() + if !ok || len(areas) != 1 { + t.Fatalf("story media areas = ok %v %+v, want one geo area", ok, areas) + } + area, ok := areas[0].(*tg.MediaAreaGeoPoint) + if !ok { + t.Fatalf("story media area = %T, want mediaAreaGeoPoint", areas[0]) + } + geo, ok := area.Geo.(*tg.GeoPoint) + if !ok || geo.Lat != wantLat || geo.Long != wantLong || geo.AccessHash != 123456 { + t.Fatalf("story geo point = %T %+v, want lat %v long %v access_hash 123456", area.Geo, area.Geo, wantLat, wantLong) + } + address, ok := area.GetAddress() + if !ok || address.CountryISO2 != "CN" || address.State != "Shanghai" || address.City != "Shanghai" || address.Street != "People Square" { + t.Fatalf("story geo address = ok %v %+v, want CN/Shanghai/Shanghai/People Square", ok, address) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 12 || area.Coordinates.W != 22 || area.Coordinates.H != 11 || area.Coordinates.Rotation != 20 { + t.Fatalf("story media area coordinates = %+v, want x %v y 12 w 22 h 11 rotation 20", area.Coordinates, wantX) + } +} + +func assertStoryVenueMediaArea(t *testing.T, item *tg.StoryItem, wantTitle string, wantLat, wantLong, wantX float64) { + t.Helper() + areas, ok := item.GetMediaAreas() + if !ok || len(areas) != 1 { + t.Fatalf("story media areas = ok %v %+v, want one venue area", ok, areas) + } + area, ok := areas[0].(*tg.MediaAreaVenue) + if !ok { + t.Fatalf("story media area = %T, want mediaAreaVenue", areas[0]) + } + geo, ok := area.Geo.(*tg.GeoPoint) + if !ok || geo.Lat != wantLat || geo.Long != wantLong || geo.AccessHash != 123456 { + t.Fatalf("story venue geo = %T %+v, want lat %v long %v access_hash 123456", area.Geo, area.Geo, wantLat, wantLong) + } + if area.Title != wantTitle || area.Address != "Inline Street" || area.Provider != "gplaces" || area.VenueID != "venue-id" || area.VenueType != "cafe" { + t.Fatalf("story venue fields = title %q address %q provider %q id %q type %q, want %q/Inline Street/gplaces/venue-id/cafe", area.Title, area.Address, area.Provider, area.VenueID, area.VenueType, wantTitle) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 13 || area.Coordinates.W != 23 || area.Coordinates.H != 12 || area.Coordinates.Rotation != 22 { + t.Fatalf("story media area coordinates = %+v, want x %v y 13 w 23 h 12 rotation 22", area.Coordinates, wantX) + } +} + +func assertStoryWeatherMediaArea(t *testing.T, item *tg.StoryItem, wantEmoji string, wantTemperatureC float64, wantColor int, wantX float64) { + t.Helper() + areas, ok := item.GetMediaAreas() + if !ok || len(areas) != 1 { + t.Fatalf("story media areas = ok %v %+v, want one weather area", ok, areas) + } + area, ok := areas[0].(*tg.MediaAreaWeather) + if !ok { + t.Fatalf("story media area = %T, want mediaAreaWeather", areas[0]) + } + if area.Emoji != wantEmoji || area.TemperatureC != wantTemperatureC || area.Color != wantColor { + t.Fatalf("story weather area = emoji %q temp %v color %d, want %q/%v/%d", area.Emoji, area.TemperatureC, area.Color, wantEmoji, wantTemperatureC, wantColor) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 14 || area.Coordinates.W != 24 || area.Coordinates.H != 12 || area.Coordinates.Rotation != 25 { + t.Fatalf("story media area coordinates = %+v, want x %v y 14 w 24 h 12 rotation 25", area.Coordinates, wantX) + } +} + +func assertStoryChannelPostMediaArea(t *testing.T, item *tg.StoryItem, wantChannelID int64, wantMsgID int, wantX float64) { + t.Helper() + areas, ok := item.GetMediaAreas() + if !ok || len(areas) != 1 { + t.Fatalf("story media areas = ok %v %+v, want one channel post area", ok, areas) + } + area, ok := areas[0].(*tg.MediaAreaChannelPost) + if !ok { + t.Fatalf("story media area = %T, want mediaAreaChannelPost", areas[0]) + } + if area.ChannelID != wantChannelID || area.MsgID != wantMsgID { + t.Fatalf("story channel post area = channel %d msg %d, want %d/%d", area.ChannelID, area.MsgID, wantChannelID, wantMsgID) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 15 || area.Coordinates.W != 25 || area.Coordinates.H != 12 || area.Coordinates.Rotation != 27 { + t.Fatalf("story media area coordinates = %+v, want x %v y 15 w 25 h 12 rotation 27", area.Coordinates, wantX) + } +} + +func assertStoryStarGiftMediaArea(t *testing.T, item *tg.StoryItem, wantSlug string, wantX float64) { + t.Helper() + areas, ok := item.GetMediaAreas() + if !ok || len(areas) != 1 { + t.Fatalf("story media areas = ok %v %+v, want one star gift area", ok, areas) + } + area, ok := areas[0].(*tg.MediaAreaStarGift) + if !ok { + t.Fatalf("story media area = %T, want mediaAreaStarGift", areas[0]) + } + if area.Slug != wantSlug { + t.Fatalf("story star gift slug = %q, want %q", area.Slug, wantSlug) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 16 || area.Coordinates.W != 26 || area.Coordinates.H != 12 || area.Coordinates.Rotation != 29 { + t.Fatalf("story media area coordinates = %+v, want x %v y 16 w 26 h 12 rotation 29", area.Coordinates, wantX) + } +} + +func assertStoryForwardHeader(t *testing.T, item *tg.StoryItem, wantUserID int64, wantStoryID int, wantModified bool) { + t.Helper() + if item == nil { + t.Fatalf("story item is nil") + } + forward, ok := item.GetFwdFrom() + if !ok { + t.Fatalf("story fwd_from absent, want source user %d story %d", wantUserID, wantStoryID) + } + if forward.Modified != wantModified { + t.Fatalf("story fwd_from modified = %v, want %v", forward.Modified, wantModified) + } + if forward.StoryID != wantStoryID { + t.Fatalf("story fwd_from story_id = %d, want %d", forward.StoryID, wantStoryID) + } + source, ok := forward.From.(*tg.PeerUser) + if !ok || source.UserID != wantUserID { + t.Fatalf("story fwd_from source = %T %+v, want peerUser %d", forward.From, forward.From, wantUserID) + } +} + +func assertStoryForwardNameHeader(t *testing.T, item *tg.StoryItem, wantName string, wantStoryID int, wantModified bool) { + t.Helper() + if item == nil { + t.Fatalf("story item is nil") + } + forward, ok := item.GetFwdFrom() + if !ok { + t.Fatalf("story fwd_from absent, want source name %q story %d", wantName, wantStoryID) + } + if forward.Modified != wantModified { + t.Fatalf("story fwd_from modified = %v, want %v", forward.Modified, wantModified) + } + if forward.StoryID != wantStoryID { + t.Fatalf("story fwd_from story_id = %d, want %d", forward.StoryID, wantStoryID) + } + if forward.From != nil { + t.Fatalf("story fwd_from source = %T %+v, want hidden source", forward.From, forward.From) + } + name, ok := forward.GetFromName() + if !ok || name != wantName { + t.Fatalf("story fwd_from from_name = %q %v, want %q", name, ok, wantName) + } +} + +func assertStoryHasNoMediaAreas(t *testing.T, item *tg.StoryItem) { + t.Helper() + if areas, ok := item.GetMediaAreas(); ok && len(areas) > 0 { + t.Fatalf("story media areas = %+v, want absent or empty", areas) + } +} + +func TestStoriesEditPrivacyFanoutsKnownViewerDifferences(t *testing.T) { + ctx := context.Background() + ownerAuth := [8]byte{7, 7, 1} + userStore := memory.NewUserStore() + author, err := userStore.Create(ctx, domain.User{AccessHash: 93101, Phone: "15550093101", FirstName: "Author"}) + if err != nil { + t.Fatalf("create author: %v", err) + } + selected, err := userStore.Create(ctx, domain.User{AccessHash: 93102, Phone: "15550093102", FirstName: "Selected"}) + if err != nil { + t.Fatalf("create selected: %v", err) + } + stranger, err := userStore.Create(ctx, domain.User{AccessHash: 93103, Phone: "15550093103", FirstName: "Stranger"}) + if err != nil { + t.Fatalf("create stranger: %v", err) + } + storyStore := memory.NewStoryStore() + stateStore := memory.NewUpdateStateStore() + updateStore := memory.NewUpdateEventStore() + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: author.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 1, + Date: 1700001000, + ExpireDate: 1700004600, + Public: true, + Out: true, + Views: domain.StoryViews{ViewsCount: 1, HasViewers: true, RecentViewers: []int64{stranger.ID}}, + }}); err != nil { + t.Fatalf("upsert public story: %v", err) + } + if _, err := storyStore.IncrementViews(ctx, stranger.ID, ownerPeer, []int{1}, 1700001100); err != nil { + t.Fatalf("increment stranger view: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(stateStore, updateStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700001200, 0)}) + editCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, author.ID), ownerAuth), 101) + edit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: 1} + edit.SetPrivacyRules([]tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowUsers{ + Users: []tg.InputUserClass{&tg.InputUser{UserID: selected.ID, AccessHash: selected.AccessHash}}, + }}) + if _, err := r.onStoriesEditStory(editCtx, edit); err != nil { + t.Fatalf("edit story privacy: %v", err) + } + if _, found, err := stateStore.Get(ctx, ownerAuth, selected.ID); err != nil || found { + t.Fatalf("owner auth state for selected found=%v err=%v, want absent", found, err) + } + + selectedDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, selected.ID), [8]byte{8, 1}), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get selected difference: %v", err) + } + selectedUpdates := selectedDiff.(*tg.UpdatesDifference) + if selectedUpdates.State.Pts != 1 || len(selectedUpdates.OtherUpdates) != 1 { + t.Fatalf("selected difference pts/updates = %d/%d, want 1/1", selectedUpdates.State.Pts, len(selectedUpdates.OtherUpdates)) + } + selectedStoryUpdate, ok := selectedUpdates.OtherUpdates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("selected update = %T, want *tg.UpdateStory", selectedUpdates.OtherUpdates[0]) + } + selectedItem, ok := selectedStoryUpdate.Story.(*tg.StoryItem) + if !ok || selectedItem.ID != 1 || !selectedItem.SelectedContacts || selectedItem.Public { + t.Fatalf("selected story = %T %+v, want selected story item", selectedStoryUpdate.Story, selectedStoryUpdate.Story) + } + if _, ok := selectedItem.GetViews(); selectedItem.Out || ok || len(selectedItem.Privacy) != 0 { + t.Fatalf("selected story item = %+v, want viewer fanout without out/views/privacy", selectedItem) + } + + strangerDiff, err := r.onUpdatesGetDifference(WithAuthKeyID(WithUserID(ctx, stranger.ID), [8]byte{8, 2}), &tg.UpdatesGetDifferenceRequest{Pts: 0}) + if err != nil { + t.Fatalf("get stranger difference: %v", err) + } + strangerUpdates := strangerDiff.(*tg.UpdatesDifference) + if strangerUpdates.State.Pts != 1 || len(strangerUpdates.OtherUpdates) != 1 { + t.Fatalf("stranger difference pts/updates = %d/%d, want 1/1", strangerUpdates.State.Pts, len(strangerUpdates.OtherUpdates)) + } + strangerStoryUpdate, ok := strangerUpdates.OtherUpdates[0].(*tg.UpdateStory) + if !ok { + t.Fatalf("stranger update = %T, want *tg.UpdateStory", strangerUpdates.OtherUpdates[0]) + } + deleted, ok := strangerStoryUpdate.Story.(*tg.StoryItemDeleted) + if !ok || deleted.ID != 1 { + t.Fatalf("stranger story = %T %+v, want storyItemDeleted id 1", strangerStoryUpdate.Story, strangerStoryUpdate.Story) + } +} + +func TestStoriesEditPrivacyDoesNotFanoutToStoryBlockedViewer(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + contactStore := memory.NewContactStore() + author, err := userStore.Create(ctx, domain.User{AccessHash: 93201, Phone: "15550093201", FirstName: "Author"}) + if err != nil { + t.Fatalf("create author: %v", err) + } + blocked, err := userStore.Create(ctx, domain.User{AccessHash: 93202, Phone: "15550093202", FirstName: "Blocked"}) + if err != nil { + t.Fatalf("create blocked viewer: %v", err) + } + if _, err := contactStore.Upsert(ctx, author.ID, domain.ContactInput{ContactUserID: blocked.ID, FirstName: "Blocked"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + contactsSvc := appcontacts.NewService(contactStore, userStore) + if _, err := contactsSvc.BlockContact(ctx, author.ID, blocked.ID, 1700001000); err != nil { + t.Fatalf("block story viewer: %v", err) + } + storyStore := memory.NewStoryStore() + updateStore := memory.NewUpdateEventStore() + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: author.ID} + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 1, + Date: 1700001000, + ExpireDate: 1700004600, + SelectedContacts: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{ + Contacts: contactsSvc, + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + Updates: appupdates.NewService(memory.NewUpdateStateStore(), updateStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700001200, 0)}) + + edit := &tg.StoriesEditStoryRequest{Peer: &tg.InputPeerSelf{}, ID: 1} + edit.SetPrivacyRules([]tg.InputPrivacyRuleClass{&tg.InputPrivacyValueAllowAll{}}) + if _, err := r.onStoriesEditStory(WithUserID(ctx, author.ID), edit); err != nil { + t.Fatalf("edit story privacy: %v", err) + } + events, err := updateStore.ListAfter(ctx, blocked.ID, 0, 10) + if err != nil { + t.Fatalf("list blocked viewer events: %v", err) + } + if len(events) != 0 { + t.Fatalf("blocked viewer events = %+v, want no story fanout", events) + } +} diff --git a/internal/rpc/story_peer_projection.go b/internal/rpc/story_peer_projection.go new file mode 100644 index 00000000..4255ea04 --- /dev/null +++ b/internal/rpc/story_peer_projection.go @@ -0,0 +1,615 @@ +package rpc + +import ( + "context" + + "github.com/gotd/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +func (r *Router) tgStoriesAllStories(ctx context.Context, viewerUserID int64, list domain.StoryList) tg.StoriesAllStoriesClass { + list = r.withStoryListPeerObjects(ctx, viewerUserID, list) + out := tgStoriesAllStories(viewerUserID, list) + if stories, ok := out.(*tg.StoriesAllStories); ok { + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, stories.Users, stories.Chats) + } + return out +} + +func (r *Router) tgStoriesStories(ctx context.Context, viewerUserID int64, list domain.StoryList) *tg.StoriesStories { + list = r.withStoryListPeerObjects(ctx, viewerUserID, list) + out := tgStoriesStories(viewerUserID, list) + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + return out +} + +func (r *Router) tgStoriesPeerStories(ctx context.Context, viewerUserID int64, peerStories domain.PeerStories) *tg.StoriesPeerStories { + peerStories = r.withPeerStoriesPeerObjects(ctx, viewerUserID, peerStories) + out := tgStoriesPeerStories(viewerUserID, peerStories) + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + return out +} + +func (r *Router) tgStoryViewsList(ctx context.Context, viewerUserID int64, list domain.StoryViewList) *tg.StoriesStoryViewsList { + users := r.domainUsersForIDs(ctx, viewerUserID, storyViewUserIDs(list.Views)) + peerUsers, peerChannels := r.storyPeerObjects(ctx, viewerUserID, storyViewPeers(list.Views)) + users = mergeDomainUsers(users, peerUsers...) + out := tgStoryViewsList(viewerUserID, list, users) + if len(peerChannels) > 0 { + out.Chats = appendUniqueTGChats(out.Chats, tgChannels(viewerUserID, peerChannels)...) + } + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + return out +} + +func (r *Router) tgStoryReactionsList(ctx context.Context, viewerUserID int64, list domain.StoryReactionList) *tg.StoriesStoryReactionsList { + users := r.domainUsersForIDs(ctx, viewerUserID, storyViewUserIDs(list.Reactions)) + peerUsers, peerChannels := r.storyPeerObjects(ctx, viewerUserID, storyViewPeers(list.Reactions)) + users = mergeDomainUsers(users, peerUsers...) + out := tgStoryReactionsList(viewerUserID, list, users) + if len(peerChannels) > 0 { + out.Chats = appendUniqueTGChats(out.Chats, tgChannels(viewerUserID, peerChannels)...) + } + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + return out +} + +func (r *Router) applyStoryMaxIDsToChats(ctx context.Context, viewerUserID int64, out tg.MessagesChatsClass) tg.MessagesChatsClass { + switch v := out.(type) { + case *tg.MessagesChats: + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, nil, v.Chats) + case *tg.MessagesChatsSlice: + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, nil, v.Chats) + } + return out +} + +func (r *Router) tgMessagesDialogs(ctx context.Context, viewerUserID int64, list domain.DialogList) tg.MessagesDialogsClass { + list = r.withDialogNotifySettings(ctx, viewerUserID, list) + out := tgMessagesDialogs(viewerUserID, list) + switch v := out.(type) { + case *tg.MessagesDialogs: + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats) + case *tg.MessagesDialogsSlice: + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats) + } + return out +} + +func (r *Router) tgPeerDialogs(ctx context.Context, viewerUserID int64, list domain.DialogList, st domain.UpdateState) *tg.MessagesPeerDialogs { + list = r.withDialogNotifySettings(ctx, viewerUserID, list) + out := tgPeerDialogs(viewerUserID, list, st) + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + return out +} + +func (r *Router) tgContacts(ctx context.Context, viewerUserID int64, list domain.ContactList) tg.ContactsContactsClass { + out := tgContacts(list) + if contacts, ok := out.(*tg.ContactsContacts); ok { + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, contacts.Users, nil) + } + return out +} + +func (r *Router) tgContactsFound(ctx context.Context, viewerUserID int64, res domain.UserSearchResult) *tg.ContactsFound { + out := tgContactsFound(viewerUserID, res) + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + return out +} + +func (r *Router) tgResolvedUserPeerWithStories(ctx context.Context, viewerUserID int64, u domain.User) *tg.ContactsResolvedPeer { + out := r.tgResolvedUserPeer(viewerUserID, u) + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, nil) + return out +} + +func (r *Router) tgResolvedChannelPeerWithStories(ctx context.Context, viewerUserID int64, view domain.ChannelView) *tg.ContactsResolvedPeer { + out := tgResolvedChannelPeer(viewerUserID, view) + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, nil, out.Chats) + return out +} + +func (r *Router) tgGlobalChannelMessages(ctx context.Context, viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass { + out := tgGlobalChannelMessages(viewerUserID, history) + r.applyStoryMaxIDsToMessages(ctx, viewerUserID, out) + return out +} + +func (r *Router) tgMessagesMessages(ctx context.Context, viewerUserID int64, list domain.MessageList) tg.MessagesMessagesClass { + out := tgMessagesMessages(viewerUserID, list) + r.applyStoryMaxIDsToMessages(ctx, viewerUserID, out) + return out +} + +func (r *Router) tgChannelHistoryMessages(ctx context.Context, viewerUserID int64, history domain.ChannelHistory) tg.MessagesMessagesClass { + out := tgChannelHistoryMessages(viewerUserID, history) + r.applyStoryMaxIDsToMessages(ctx, viewerUserID, out) + return out +} + +func (r *Router) tgMessagesDiscussionMessage(ctx context.Context, viewerUserID int64, discussion domain.ChannelDiscussionMessage) *tg.MessagesDiscussionMessage { + out := tgMessagesDiscussionMessage(viewerUserID, discussion) + // 用带 presence + self 标志的投影覆盖裸 tgUsers,防止 viewer 自己以 self=false + // 进入 Users(Android putUsers 会覆盖 currentUser)。 + out.Users = r.tgUsersForViewer(viewerUserID, discussion.Users) + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + return out +} + +func (r *Router) applyStoryMaxIDsToForumTopics(ctx context.Context, viewerUserID int64, out *tg.MessagesForumTopics) *tg.MessagesForumTopics { + if out != nil { + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + } + return out +} + +func (r *Router) applyStoryMaxIDsToMessageViews(ctx context.Context, viewerUserID int64, out *tg.MessagesMessageViews) *tg.MessagesMessageViews { + if out != nil { + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + } + return out +} + +func (r *Router) applyStoryMaxIDsToMessageReactionsList(ctx context.Context, viewerUserID int64, out *tg.MessagesMessageReactionsList) *tg.MessagesMessageReactionsList { + if out != nil { + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, out.Users, out.Chats) + } + return out +} + +func (r *Router) tgGlobalSearchMessages(ctx context.Context, viewerUserID int64, limit int, private domain.MessageList, channel domain.ChannelHistory) tg.MessagesMessagesClass { + out := tgGlobalSearchMessages(viewerUserID, limit, private, channel) + r.applyStoryMaxIDsToMessages(ctx, viewerUserID, out) + return out +} + +func (r *Router) applyStoryMaxIDsToMessages(ctx context.Context, viewerUserID int64, out tg.MessagesMessagesClass) { + switch v := out.(type) { + case *tg.MessagesMessages: + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats) + case *tg.MessagesMessagesSlice: + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats) + case *tg.MessagesChannelMessages: + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats) + } +} + +func (r *Router) tgStoryChangeUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, story domain.Story, randomID int64, includeStoryID bool, date int) tg.UpdatesClass { + out, _ := tgStoryChangeUpdates(peer, story, randomID, includeStoryID, date).(*tg.Updates) + peers := append([]domain.Peer{peer, story.Owner}, storyForwardPeers(story)...) + out = r.withStoryUpdatePeerObjects(ctx, viewerUserID, out, peers...) + r.appendStoryPrivacyUsers(ctx, viewerUserID, out, []domain.Story{story}) + return out +} + +func (r *Router) tgStoryReactionUpdates(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction tg.ReactionClass, date int) tg.UpdatesClass { + out, _ := tgStoryReactionUpdates(peer, storyID, reaction, date).(*tg.Updates) + return r.withStoryUpdatePeerObjects(ctx, viewerUserID, out, peer) +} + +func (r *Router) tgUpdatesDifference(ctx context.Context, viewerUserID int64, diff domain.UpdateDifference) tg.UpdatesDifferenceClass { + out := tgUpdatesDifference(viewerUserID, diff) + switch v := out.(type) { + case *tg.UpdatesDifference: + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats) + case *tg.UpdatesDifferenceSlice: + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, v.Users, v.Chats) + } + return out +} + +func (r *Router) withStoryUpdatePeerObjects(ctx context.Context, viewerUserID int64, updates *tg.Updates, peers ...domain.Peer) *tg.Updates { + if updates == nil { + return nil + } + users, channels := r.storyPeerObjects(ctx, viewerUserID, peers) + if len(users) > 0 { + updates.Users = appendUniqueTGUsers(updates.Users, r.tgUsersForViewer(viewerUserID, users)...) + } + if len(channels) > 0 { + updates.Chats = appendUniqueTGChats(updates.Chats, tgChannels(viewerUserID, channels)...) + } + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, updates.Users, updates.Chats) + return updates +} + +func (r *Router) withStoryListPeerObjects(ctx context.Context, viewerUserID int64, list domain.StoryList) domain.StoryList { + users, channels := r.storyPeerObjects(ctx, viewerUserID, storyListOwnerPeers(list)) + if len(users) > 0 { + list.Users = mergeDomainUsers(list.Users, users...) + } + if privacyUsers := r.domainUsersForIDs(ctx, viewerUserID, storyListPrivacyUserIDs(list)); len(privacyUsers) > 0 { + list.Users = mergeDomainUsers(list.Users, privacyUsers...) + } + if len(channels) > 0 { + list.Channels = mergeDomainChannels(list.Channels, channels...) + } + return list +} + +func (r *Router) withPeerStoriesPeerObjects(ctx context.Context, viewerUserID int64, peerStories domain.PeerStories) domain.PeerStories { + peers := make([]domain.Peer, 0, len(peerStories.Stories)+1) + peers = append(peers, peerStories.Peer) + for _, story := range peerStories.Stories { + peers = append(peers, story.Owner) + peers = append(peers, storyForwardPeers(story)...) + } + users, channels := r.storyPeerObjects(ctx, viewerUserID, peers) + if len(users) > 0 { + peerStories.Users = mergeDomainUsers(peerStories.Users, users...) + } + if privacyUsers := r.domainUsersForIDs(ctx, viewerUserID, storyPrivacyUserIDs(peerStories.Stories)); len(privacyUsers) > 0 { + peerStories.Users = mergeDomainUsers(peerStories.Users, privacyUsers...) + } + if len(channels) > 0 { + peerStories.Channels = mergeDomainChannels(peerStories.Channels, channels...) + } + return peerStories +} + +func (r *Router) appendStoryPrivacyUsers(ctx context.Context, viewerUserID int64, updates *tg.Updates, stories []domain.Story) { + if updates == nil { + return + } + users := r.domainUsersForIDs(ctx, viewerUserID, storyPrivacyUserIDs(stories)) + if len(users) == 0 { + return + } + updates.Users = appendUniqueTGUsers(updates.Users, r.tgUsersForViewer(viewerUserID, users)...) + r.applyStoryMaxIDsToPeerObjects(ctx, viewerUserID, updates.Users, updates.Chats) +} + +func storyViewUserIDs(views []domain.StoryView) []int64 { + if len(views) == 0 { + return nil + } + seen := make(map[int64]struct{}, len(views)) + ids := make([]int64, 0, len(views)) + for _, view := range views { + if view.PublicForward != nil { + if media := view.PublicForward.Message.Media; media != nil && media.Kind == domain.MessageMediaKindStory && media.Story != nil && media.Story.Peer.Type == domain.PeerTypeUser && media.Story.Peer.ID != 0 { + if _, ok := seen[media.Story.Peer.ID]; !ok { + seen[media.Story.Peer.ID] = struct{}{} + ids = append(ids, media.Story.Peer.ID) + } + } + if view.PublicForward.Message.SenderUserID != 0 { + if _, ok := seen[view.PublicForward.Message.SenderUserID]; !ok { + seen[view.PublicForward.Message.SenderUserID] = struct{}{} + ids = append(ids, view.PublicForward.Message.SenderUserID) + } + } + if view.PublicForward.Message.From.Type == domain.PeerTypeUser && view.PublicForward.Message.From.ID != 0 { + if _, ok := seen[view.PublicForward.Message.From.ID]; !ok { + seen[view.PublicForward.Message.From.ID] = struct{}{} + ids = append(ids, view.PublicForward.Message.From.ID) + } + } + if view.PublicForward.Message.SendAs != nil && view.PublicForward.Message.SendAs.Type == domain.PeerTypeUser && view.PublicForward.Message.SendAs.ID != 0 { + if _, ok := seen[view.PublicForward.Message.SendAs.ID]; !ok { + seen[view.PublicForward.Message.SendAs.ID] = struct{}{} + ids = append(ids, view.PublicForward.Message.SendAs.ID) + } + } + continue + } + if view.Repost != nil { + continue + } + if view.ViewerID == 0 { + continue + } + if _, ok := seen[view.ViewerID]; ok { + continue + } + seen[view.ViewerID] = struct{}{} + ids = append(ids, view.ViewerID) + } + return ids +} + +func storyViewPeers(views []domain.StoryView) []domain.Peer { + if len(views) == 0 { + return nil + } + peers := make([]domain.Peer, 0, len(views)) + for _, view := range views { + if view.PublicForward != nil { + if view.PublicForward.Message.ChannelID != 0 { + peers = append(peers, domain.Peer{Type: domain.PeerTypeChannel, ID: view.PublicForward.Message.ChannelID}) + } + if media := view.PublicForward.Message.Media; media != nil && media.Kind == domain.MessageMediaKindStory && media.Story != nil && media.Story.Peer.ID != 0 { + peers = append(peers, media.Story.Peer) + } + if view.PublicForward.Message.From.ID != 0 { + peers = append(peers, view.PublicForward.Message.From) + } + if view.PublicForward.Message.SendAs != nil && view.PublicForward.Message.SendAs.ID != 0 { + peers = append(peers, *view.PublicForward.Message.SendAs) + } + continue + } + if view.Repost == nil { + continue + } + peers = append(peers, view.Repost.Owner) + peers = append(peers, storyForwardPeers(*view.Repost)...) + } + return peers +} + +func (r *Router) storyPeerObjects(ctx context.Context, viewerUserID int64, peers []domain.Peer) ([]domain.User, []domain.Channel) { + if viewerUserID == 0 || len(peers) == 0 { + return nil, nil + } + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, peer := range peers { + switch peer.Type { + case domain.PeerTypeUser: + if peer.ID != 0 { + userIDs[peer.ID] = struct{}{} + } + case domain.PeerTypeChannel: + if peer.ID != 0 { + channelIDs[peer.ID] = struct{}{} + } + } + } + cache := newViewerPeerCache(r) + return cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs)), + cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs)) +} + +func storyListOwnerPeers(list domain.StoryList) []domain.Peer { + peers := make([]domain.Peer, 0, len(list.Peers)+len(list.Stories)) + for _, item := range list.Peers { + peers = append(peers, item.Peer) + for _, story := range item.Stories { + peers = append(peers, storyForwardPeers(story)...) + } + } + for _, story := range list.Stories { + peers = append(peers, story.Owner) + peers = append(peers, storyForwardPeers(story)...) + } + return peers +} + +func storyForwardPeers(story domain.Story) []domain.Peer { + if story.Forward == nil || story.Forward.From.ID == 0 { + return nil + } + switch story.Forward.From.Type { + case domain.PeerTypeUser, domain.PeerTypeChannel: + return []domain.Peer{story.Forward.From} + default: + return nil + } +} + +func storyListPrivacyUserIDs(list domain.StoryList) []int64 { + stories := make([]domain.Story, 0, len(list.Stories)) + stories = append(stories, list.Stories...) + for _, item := range list.Peers { + stories = append(stories, item.Stories...) + } + return storyPrivacyUserIDs(stories) +} + +func storyPrivacyUserIDs(stories []domain.Story) []int64 { + if len(stories) == 0 { + return nil + } + seen := make(map[int64]struct{}) + out := make([]int64, 0) + for _, story := range stories { + if !story.Out { + continue + } + for _, id := range privacyRuleUserIDs(story.PrivacyRules) { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + } + return out +} + +func storyUpdateEventPeers(event domain.UpdateEvent) []domain.Peer { + switch event.Type { + case domain.UpdateEventStory: + return append([]domain.Peer{event.Peer, event.Story.Owner}, storyForwardPeers(event.Story)...) + case domain.UpdateEventReadStories, domain.UpdateEventSentStoryReaction: + return []domain.Peer{event.Peer} + case domain.UpdateEventNewStoryReaction: + return append([]domain.Peer{event.Peer, event.Story.Owner}, storyForwardPeers(event.Story)...) + default: + return nil + } +} + +func appendUniqueTGUsers(base []tg.UserClass, extra ...tg.UserClass) []tg.UserClass { + seen := make(map[int64]struct{}, len(base)+len(extra)) + out := make([]tg.UserClass, 0, len(base)+len(extra)) + appendOne := func(item tg.UserClass) { + user, ok := item.(*tg.User) + if !ok || user.ID == 0 { + out = append(out, item) + return + } + if _, exists := seen[user.ID]; exists { + return + } + seen[user.ID] = struct{}{} + out = append(out, item) + } + for _, item := range base { + appendOne(item) + } + for _, item := range extra { + appendOne(item) + } + return out +} + +func appendUniqueTGChats(base []tg.ChatClass, extra ...tg.ChatClass) []tg.ChatClass { + seen := make(map[int64]struct{}, len(base)+len(extra)) + out := make([]tg.ChatClass, 0, len(base)+len(extra)) + appendOne := func(item tg.ChatClass) { + channel, ok := item.(*tg.Channel) + if !ok || channel.ID == 0 { + out = append(out, item) + return + } + if _, exists := seen[channel.ID]; exists { + return + } + seen[channel.ID] = struct{}{} + out = append(out, item) + } + for _, item := range base { + appendOne(item) + } + for _, item := range extra { + appendOne(item) + } + return out +} + +func (r *Router) applyStoryMaxIDsToPeerObjects(ctx context.Context, viewerUserID int64, users []tg.UserClass, chats []tg.ChatClass) { + if r.deps.Stories == nil || viewerUserID == 0 || len(users)+len(chats) == 0 { + return + } + peers := make([]domain.Peer, 0, len(users)+len(chats)) + seen := make(map[domain.Peer]struct{}, len(users)+len(chats)) + addPeer := func(peer domain.Peer) { + if peer.ID == 0 { + return + } + if _, ok := seen[peer]; ok { + return + } + seen[peer] = struct{}{} + peers = append(peers, peer) + } + for _, item := range users { + if u, ok := item.(*tg.User); ok { + addPeer(domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}) + } + } + for _, item := range chats { + if ch, ok := item.(*tg.Channel); ok { + addPeer(domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}) + } + } + recent, hidden := r.storyProjectionMaps(ctx, viewerUserID, peers) + for _, item := range users { + u, ok := item.(*tg.User) + if !ok { + continue + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: u.ID} + if story, ok := recent[peer]; ok { + u.SetStoriesMaxID(story) + } + if state, ok := hidden[peer]; ok { + u.SetStoriesHidden(state) + } + } + for _, item := range chats { + ch, ok := item.(*tg.Channel) + if !ok { + continue + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID} + if story, ok := recent[peer]; ok { + ch.SetStoriesMaxID(story) + } + if state, ok := hidden[peer]; ok { + ch.SetStoriesHidden(state) + } + } +} + +func (r *Router) applyStoriesPinnedAvailableToUserFull(ctx context.Context, viewerUserID, ownerUserID int64, full *tg.UserFull) { + if full != nil && r.storyPinnedAvailable(ctx, viewerUserID, domain.Peer{Type: domain.PeerTypeUser, ID: ownerUserID}) { + full.SetStoriesPinnedAvailable(true) + } +} + +func (r *Router) applyStoriesPinnedAvailableToChannelFull(ctx context.Context, viewerUserID int64, channelID int64, full *tg.ChannelFull) { + if full != nil && r.storyPinnedAvailable(ctx, viewerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) { + full.SetStoriesPinnedAvailable(true) + } +} + +func (r *Router) storyPinnedAvailable(ctx context.Context, viewerUserID int64, peer domain.Peer) bool { + if r.deps.Stories == nil || viewerUserID == 0 || peer.ID == 0 { + return false + } + available, err := r.storyPinnedCache.getOrLoad(ctx, viewerUserID, peer, func() (bool, error) { + return r.deps.Stories.HasPinnedStories(ctx, viewerUserID, peer, int(r.clock.Now().Unix())) + }) + if err != nil { + r.log.Warn("project story pinned availability failed", + zap.Int64("viewer_user_id", viewerUserID), + zap.String("peer_type", string(peer.Type)), + zap.Int64("peer_id", peer.ID), + zap.Error(err)) + return false + } + return available +} + +func (r *Router) storyProjectionFreshMaps(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]tg.RecentStory, map[domain.Peer]bool) { + if r.deps.Stories == nil || viewerUserID == 0 || len(peers) == 0 { + return nil, nil + } + recent := make(map[domain.Peer]tg.RecentStory, len(peers)) + hidden := make(map[domain.Peer]bool, len(peers)) + now := int(r.clock.Now().Unix()) + for start := 0; start < len(peers); start += domain.MaxStoryIDs { + end := start + domain.MaxStoryIDs + if end > len(peers) { + end = len(peers) + } + chunk := peers[start:end] + for _, peer := range chunk { + hidden[peer] = false + } + projections, err := r.deps.Stories.GetPeerStoryProjections(ctx, viewerUserID, chunk, now) + if err != nil { + r.log.Warn("project story peer summaries failed", + zap.Int64("viewer_user_id", viewerUserID), + zap.Int("peer_count", len(chunk)), + zap.Error(err)) + continue + } + for _, item := range projections { + if story, ok := tgRecentStory(item.Recent); ok { + recent[item.Peer] = story + } + hidden[item.Peer] = item.Hidden + } + } + return recent, hidden +} + +func tgRecentStory(in domain.RecentStory) (tg.RecentStory, bool) { + if in.MaxID <= 0 && !in.Live { + return tg.RecentStory{}, false + } + out := tg.RecentStory{Live: in.Live} + if in.MaxID > 0 { + out.SetMaxID(in.MaxID) + } + return out, true +} diff --git a/internal/rpc/story_peer_projection_rpc_test.go b/internal/rpc/story_peer_projection_rpc_test.go new file mode 100644 index 00000000..5dec1e6c --- /dev/null +++ b/internal/rpc/story_peer_projection_rpc_test.go @@ -0,0 +1,1613 @@ +package rpc + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/gotd/td/bin" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appstories "telesrv/internal/app/stories" + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +type countingStoriesService struct { + StoriesService + maxIDCalls int + hiddenCalls int + projectionCalls int + pinnedAvailCalls int + pinnedStoriesCalls int +} + +type blockingProjectionStoriesService struct { + StoriesService + mu sync.Mutex + calls int + started chan struct{} + release chan struct{} + once sync.Once +} + +func (s *blockingProjectionStoriesService) GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) { + s.mu.Lock() + s.calls++ + s.mu.Unlock() + s.once.Do(func() { close(s.started) }) + select { + case <-s.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return s.StoriesService.GetPeerStoryProjections(ctx, viewerUserID, peers, now) +} + +func (s *blockingProjectionStoriesService) callCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls +} + +func (s *countingStoriesService) GetPeerMaxIDs(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) { + s.maxIDCalls++ + return s.StoriesService.GetPeerMaxIDs(ctx, viewerUserID, peers, now) +} + +func (s *countingStoriesService) GetPeerHiddenStates(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) { + s.hiddenCalls++ + return s.StoriesService.GetPeerHiddenStates(ctx, viewerUserID, peers) +} + +func (s *countingStoriesService) GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) { + s.projectionCalls++ + return s.StoriesService.GetPeerStoryProjections(ctx, viewerUserID, peers, now) +} + +func (s *countingStoriesService) HasPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) { + s.pinnedAvailCalls++ + return s.StoriesService.HasPinnedStories(ctx, viewerUserID, peer, now) +} + +func (s *countingStoriesService) GetPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) { + s.pinnedStoriesCalls++ + return s.StoriesService.GetPinnedStories(ctx, viewerUserID, peer, offsetID, limit, now) +} + +func TestUsersProjectStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93001, Phone: "15550093001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 93002, Phone: "15550093002", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + ID: 7, + Date: 1700000000, + ExpireDate: 1700003600, + Pinned: true, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + users, err := r.onUsersGetUsers(WithUserID(ctx, viewer.ID), []tg.InputUserClass{ + &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + }) + if err != nil { + t.Fatalf("get users: %v", err) + } + if len(users) != 1 { + t.Fatalf("users = %d, want one", len(users)) + } + assertUserStoryMaxID(t, users[0], 7) + + full, err := r.onUsersGetFullUser(WithUserID(ctx, viewer.ID), &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash}) + if err != nil { + t.Fatalf("get full user: %v", err) + } + if len(full.Users) != 1 { + t.Fatalf("full users = %d, want one", len(full.Users)) + } + assertUserStoryMaxID(t, full.Users[0], 7) + if !full.FullUser.GetStoriesPinnedAvailable() { + t.Fatalf("user full stories_pinned_available = false, want true") + } +} + +func TestUsersProjectStoriesHiddenWithoutActiveStory(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93101, Phone: "15550093101", FirstName: "HiddenOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 93102, Phone: "15550093102", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + storyStore := memory.NewStoryStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, viewer.ID) + + if ok, err := r.onStoriesTogglePeerStoriesHidden(reqCtx, &tg.StoriesTogglePeerStoriesHiddenRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + Hidden: true, + }); err != nil || !ok { + t.Fatalf("toggle hidden = %v, %v", ok, err) + } + users, err := r.onUsersGetUsers(reqCtx, []tg.InputUserClass{ + &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + }) + if err != nil { + t.Fatalf("get users after hide: %v", err) + } + if got := storiesHiddenForUser(t, users, owner.ID); !got { + t.Fatalf("users.getUsers stories_hidden = false, want true") + } + + if ok, err := r.onStoriesTogglePeerStoriesHidden(reqCtx, &tg.StoriesTogglePeerStoriesHiddenRequest{ + Peer: &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + Hidden: false, + }); err != nil || !ok { + t.Fatalf("toggle unhidden = %v, %v", ok, err) + } + users, err = r.onUsersGetUsers(reqCtx, []tg.InputUserClass{ + &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash}, + }) + if err != nil { + t.Fatalf("get users after unhide: %v", err) + } + if got := storiesHiddenForUser(t, users, owner.ID); got { + t.Fatalf("users.getUsers stories_hidden = true, want false") + } +} + +func TestChannelsProjectStoriesMaxID(t *testing.T) { + ctx := context.Background() + const ownerID int64 = 1000000001 + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, ownerID, domain.CreateChannelRequest{ + CreatorUserID: ownerID, + Title: "story channel", + Broadcast: true, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}, + ID: 11, + Date: 1700000000, + ExpireDate: 1700003600, + Pinned: true, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + r := New(Config{}, Deps{ + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + chats, err := r.onChannelsGetChannels(WithUserID(ctx, ownerID), []tg.InputChannelClass{input}) + if err != nil { + t.Fatalf("get channels: %v", err) + } + list, ok := chats.(*tg.MessagesChats) + if !ok || len(list.Chats) != 1 { + t.Fatalf("channels = %T %+v, want one messages.chats", chats, chats) + } + assertChannelStoryMaxID(t, list.Chats[0], 11) + + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, ownerID), input) + if err != nil { + t.Fatalf("get full channel: %v", err) + } + if len(full.Chats) != 1 { + t.Fatalf("full chats = %d, want one", len(full.Chats)) + } + assertChannelStoryMaxID(t, full.Chats[0], 11) + channelFull, ok := full.FullChat.(*tg.ChannelFull) + if !ok { + t.Fatalf("full chat = %T, want *tg.ChannelFull", full.FullChat) + } + if !channelFull.GetStoriesPinnedAvailable() { + t.Fatalf("channel full stories_pinned_available = false, want true") + } +} + +func TestFullPeerPinnedAvailabilityUsesBooleanCache(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93401, Phone: "15550093401", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 93402, Phone: "15550093402", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "pinned story profile cache", + Broadcast: true, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + ID: 7, + Date: 1700000000, + ExpireDate: 1700003600, + Pinned: true, + Public: true, + }}); err != nil { + t.Fatalf("upsert user story: %v", err) + } + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}, + ID: 11, + Date: 1700000000, + ExpireDate: 1700003600, + Pinned: true, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + stories := &countingStoriesService{StoriesService: appstories.NewService(storyStore)} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: stories, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + for i := 0; i < 2; i++ { + full, err := r.onUsersGetFullUser(WithUserID(ctx, viewer.ID), &tg.InputUser{UserID: owner.ID, AccessHash: owner.AccessHash}) + if err != nil { + t.Fatalf("get full user #%d: %v", i+1, err) + } + if !full.FullUser.GetStoriesPinnedAvailable() { + t.Fatalf("user full #%d stories_pinned_available = false, want true", i+1) + } + } + input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + for i := 0; i < 2; i++ { + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), input) + if err != nil { + t.Fatalf("get full channel #%d: %v", i+1, err) + } + channelFull, ok := full.FullChat.(*tg.ChannelFull) + if !ok { + t.Fatalf("full channel #%d = %T, want *tg.ChannelFull", i+1, full.FullChat) + } + if !channelFull.GetStoriesPinnedAvailable() { + t.Fatalf("channel full #%d stories_pinned_available = false, want true", i+1) + } + } + if stories.pinnedAvailCalls != 2 { + t.Fatalf("HasPinnedStories calls = %d, want 2", stories.pinnedAvailCalls) + } + if stories.pinnedStoriesCalls != 0 { + t.Fatalf("GetPinnedStories calls = %d, want 0", stories.pinnedStoriesCalls) + } +} + +func TestGetPinnedStoriesCachesUntilStoryMutation(t *testing.T) { + ctx := context.Background() + ownerID := int64(93541) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID} + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 61, + Date: 1700000000, + ExpireDate: 1700003600, + Pinned: true, + Public: true, + }}); err != nil { + t.Fatalf("upsert pinned story: %v", err) + } + stories := &countingStoriesService{StoriesService: appstories.NewService(storyStore)} + r := New(Config{}, Deps{Stories: stories}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + reqCtx := WithUserID(ctx, ownerID) + req := &tg.StoriesGetPinnedStoriesRequest{Peer: &tg.InputPeerSelf{}, Limit: 10} + + for i := 0; i < 2; i++ { + got, err := r.onStoriesGetPinnedStories(reqCtx, req) + if err != nil { + t.Fatalf("get pinned stories #%d: %v", i+1, err) + } + if got.Count != 1 || len(got.Stories) != 1 { + t.Fatalf("pinned stories #%d = count %d len %d, want one", i+1, got.Count, len(got.Stories)) + } + } + if stories.pinnedStoriesCalls != 1 { + t.Fatalf("GetPinnedStories calls = %d, want 1 after cache hit", stories.pinnedStoriesCalls) + } + + if _, err := r.onStoriesTogglePinned(reqCtx, &tg.StoriesTogglePinnedRequest{ + Peer: &tg.InputPeerSelf{}, + ID: []int{61}, + Pinned: false, + }); err != nil { + t.Fatalf("unpin story: %v", err) + } + got, err := r.onStoriesGetPinnedStories(reqCtx, req) + if err != nil { + t.Fatalf("get pinned stories after unpin: %v", err) + } + if got.Count != 0 || len(got.Stories) != 0 { + t.Fatalf("pinned stories after unpin = count %d len %d, want empty", got.Count, len(got.Stories)) + } + if stories.pinnedStoriesCalls != 2 { + t.Fatalf("GetPinnedStories calls after mutation = %d, want 2", stories.pinnedStoriesCalls) + } +} + +func TestChannelParticipantsProjectStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93501, Phone: "15550093501", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 93502, Phone: "15550093502", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story participants", + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + ID: 29, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert inviter story: %v", err) + } + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, + ID: 31, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert member story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: input, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get participants: %v", err) + } + page, ok := participants.(*tg.ChannelsChannelParticipants) + if !ok { + t.Fatalf("participants = %T, want *tg.ChannelsChannelParticipants", participants) + } + assertUserStoryMaxID(t, findUserClass(page.Users, member.ID), 31) + assertUserStoryMaxID(t, findUserClass(page.Users, owner.ID), 29) + + single, err := r.onChannelsGetParticipant(WithUserID(ctx, member.ID), &tg.ChannelsGetParticipantRequest{ + Channel: input, + Participant: &tg.InputPeerSelf{}, + }) + if err != nil { + t.Fatalf("get participant: %v", err) + } + self, ok := single.Participant.(*tg.ChannelParticipantSelf) + if !ok { + t.Fatalf("participant = %T, want *tg.ChannelParticipantSelf", single.Participant) + } + if self.InviterID != owner.ID { + t.Fatalf("self inviter = %d, want owner %d", self.InviterID, owner.ID) + } + assertUserStoryMaxID(t, findUserClass(single.Users, member.ID), 31) + assertUserStoryMaxID(t, findUserClass(single.Users, owner.ID), 29) +} + +func TestChannelParticipantsReuseStoryProjectionWithinBurst(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93511, Phone: "15550093511", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 93512, Phone: "15550093512", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story participants cache", + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + ID: 41, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert owner story: %v", err) + } + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, + ID: 43, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert member story: %v", err) + } + stories := &countingStoriesService{StoriesService: appstories.NewService(storyStore)} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: stories, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + for i := 0; i < 2; i++ { + participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{ + Channel: input, + Filter: &tg.ChannelParticipantsRecent{}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get participants #%d: %v", i+1, err) + } + page, ok := participants.(*tg.ChannelsChannelParticipants) + if !ok { + t.Fatalf("participants #%d = %T, want *tg.ChannelsChannelParticipants", i+1, participants) + } + assertUserStoryMaxID(t, findUserClass(page.Users, member.ID), 43) + assertUserStoryMaxID(t, findUserClass(page.Users, owner.ID), 41) + } + if stories.projectionCalls != 1 || stories.maxIDCalls != 0 || stories.hiddenCalls != 0 { + t.Fatalf("story projection calls combined=%d max_id=%d hidden=%d, want 1/0/0", stories.projectionCalls, stories.maxIDCalls, stories.hiddenCalls) + } +} + +func TestStoryProjectionSingleflightsConcurrentMiss(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93521, Phone: "15550093521", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 93522, Phone: "15550093522", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + storyStore := memory.NewStoryStore() + for _, item := range []struct { + owner domain.Peer + id int + }{ + {owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, id: 51}, + {owner: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, id: 53}, + } { + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: item.owner, + ID: item.id, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %d: %v", item.id, err) + } + } + stories := &blockingProjectionStoriesService{ + StoriesService: appstories.NewService(storyStore), + started: make(chan struct{}), + release: make(chan struct{}), + } + r := New(Config{}, Deps{Stories: stories}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + peers := []domain.Peer{ + {Type: domain.PeerTypeUser, ID: owner.ID}, + {Type: domain.PeerTypeUser, ID: member.ID}, + } + + const goroutines = 16 + var wg sync.WaitGroup + errs := make(chan string, goroutines) + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + recent, hidden := r.storyProjectionMaps(ctx, owner.ID, peers) + if story, ok := recent[peers[0]]; !ok || story.MaxID != 51 { + errs <- "owner story max id missing" + return + } + if story, ok := recent[peers[1]]; !ok || story.MaxID != 53 { + errs <- "member story max id missing" + return + } + if got := hidden[peers[0]] || hidden[peers[1]]; got { + errs <- "hidden state should be false" + } + }() + } + <-stories.started + time.Sleep(20 * time.Millisecond) + close(stories.release) + wg.Wait() + close(errs) + for err := range errs { + if err != "" { + t.Fatal(err) + } + } + if got := stories.callCount(); got != 1 { + t.Fatalf("projection calls = %d, want 1", got) + } +} + +func TestStoryProjectionCacheSurvivesFastDialogBurstWindow(t *testing.T) { + ctx := context.Background() + now := time.Unix(1700000100, 0) + viewerID := int64(93531) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 93532} + cache := newStoryProjectionCache(func() time.Time { return now }) + + loadCalls := 0 + load := func(_ context.Context, _ []domain.Peer) (map[domain.Peer]tg.RecentStory, map[domain.Peer]bool) { + loadCalls++ + return map[domain.Peer]tg.RecentStory{peer: {MaxID: 7}}, map[domain.Peer]bool{peer: true} + } + + // 首次:miss → load → 缓存。 + if recent, hidden := cache.getMany(ctx, viewerID, []domain.Peer{peer}, load); recent[peer].MaxID != 7 || !hidden[peer] { + t.Fatalf("first getMany recent=%+v hidden=%v", recent[peer], hidden[peer]) + } + // 推进 11s(仍在 TTL 内):再次应命中缓存、不再 load。 + now = now.Add(11 * time.Second) + recent, hidden := cache.getMany(ctx, viewerID, []domain.Peer{peer}, load) + if loadCalls != 1 { + t.Fatalf("burst window re-loaded: loadCalls=%d, want 1", loadCalls) + } + if story, ok := recent[peer]; !ok || story.MaxID != 7 { + t.Fatalf("recent story after burst window = %+v ok=%v, want max_id 7", story, ok) + } + if state, ok := hidden[peer]; !ok || !state { + t.Fatalf("hidden after burst window = %v ok=%v, want true", state, ok) + } +} + +func TestChannelAdminLogProjectsStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93601, Phone: "15550093601", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story admin log", + Megagroup: true, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := channelService.EditTitle(ctx, owner.ID, domain.EditChannelTitleRequest{ + ChannelID: created.Channel.ID, + Title: "story admin log renamed", + Date: 1700000010, + }); err != nil { + t.Fatalf("edit title: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + ID: 37, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert owner story: %v", err) + } + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}, + ID: 41, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + input := &tg.InputChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + adminLog, err := r.onChannelsGetAdminLog(WithUserID(ctx, owner.ID), &tg.ChannelsGetAdminLogRequest{ + Channel: input, + Limit: 10, + }) + if err != nil { + t.Fatalf("get admin log: %v", err) + } + if len(adminLog.Events) == 0 { + t.Fatalf("admin log events = none, want title event") + } + assertUserStoryMaxID(t, findUserClass(adminLog.Users, owner.ID), 37) + assertChannelStoryMaxID(t, findChannelClass(adminLog.Chats, created.Channel.ID), 41) +} + +func TestMessagesHistoryAndSearchProjectStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93701, Phone: "15550093701", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 93702, Phone: "15550093702", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + ID: 43, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert owner story: %v", err) + } + messages := &captureMessages{list: domain.MessageList{ + Messages: []domain.Message{{ + ID: 1, + OwnerUserID: viewer.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + Date: 1700000100, + Body: "needle", + }}, + Users: []domain.User{owner}, + Count: 1, + }} + r := New(Config{}, Deps{ + Messages: messages, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + peer := &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash} + + history := dispatchMessagesPayload(t, r, WithUserID(ctx, viewer.ID), &tg.MessagesGetHistoryRequest{ + Peer: peer, + Limit: 20, + }) + _, _, users := searchMessagesPayload(t, history) + assertUserStoryMaxID(t, findUserClass(users, owner.ID), 43) + + search := dispatchMessagesPayload(t, r, WithUserID(ctx, viewer.ID), &tg.MessagesSearchRequest{ + Peer: peer, + Q: "needle", + Filter: &tg.InputMessagesFilterEmpty{}, + Limit: 20, + }) + _, _, users = searchMessagesPayload(t, search) + assertUserStoryMaxID(t, findUserClass(users, owner.ID), 43) +} + +func TestChannelHistoryAndSearchProjectStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93801, Phone: "15550093801", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story history channel", + Megagroup: true, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 1, + Message: "channel needle", + Date: 1700000010, + }); err != nil { + t.Fatalf("send channel message: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + ID: 47, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert owner story: %v", err) + } + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}, + ID: 53, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + history := dispatchMessagesPayload(t, r, WithUserID(ctx, owner.ID), &tg.MessagesGetHistoryRequest{ + Peer: peer, + Limit: 20, + }) + _, chats, users := searchMessagesPayload(t, history) + assertUserStoryMaxID(t, findUserClass(users, owner.ID), 47) + assertChannelStoryMaxID(t, findChannelClass(chats, created.Channel.ID), 53) + + search := dispatchMessagesPayload(t, r, WithUserID(ctx, owner.ID), &tg.MessagesSearchRequest{ + Peer: peer, + Q: "needle", + Filter: &tg.InputMessagesFilterEmpty{}, + Limit: 20, + }) + _, chats, users = searchMessagesPayload(t, search) + assertUserStoryMaxID(t, findUserClass(users, owner.ID), 47) + assertChannelStoryMaxID(t, findChannelClass(chats, created.Channel.ID), 53) +} + +func TestRepliesDiscussionAndForumTopicsProjectStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93901, Phone: "15550093901", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 93902, Phone: "15550093902", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + broadcast, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story discussion source", + Broadcast: true, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + group, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story discussion group", + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000001, + }) + if err != nil { + t.Fatalf("create group: %v", err) + } + forum, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story forum", + Megagroup: true, + Forum: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000002, + }) + if err != nil { + t.Fatalf("create forum: %v", err) + } + storyStore := memory.NewStoryStore() + upsertStory := func(peer domain.Peer, id int) { + t.Helper() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: peer, + ID: id, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %v/%d: %v", peer, id, err) + } + } + upsertStory(domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, 57) + upsertStory(domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, 59) + upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: broadcast.Channel.ID}, 61) + upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: group.Channel.ID}, 63) + upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: forum.Channel.ID}, 67) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + broadcastPeer := &tg.InputPeerChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash} + groupPeer := &tg.InputPeerChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash} + forumPeer := &tg.InputPeerChannel{ChannelID: forum.Channel.ID, AccessHash: forum.Channel.AccessHash} + if ok, err := r.onChannelsSetDiscussionGroup(WithUserID(ctx, owner.ID), &tg.ChannelsSetDiscussionGroupRequest{ + Broadcast: &tg.InputChannel{ChannelID: broadcast.Channel.ID, AccessHash: broadcast.Channel.AccessHash}, + Group: &tg.InputChannel{ChannelID: group.Channel.ID, AccessHash: group.Channel.AccessHash}, + }); err != nil || !ok { + t.Fatalf("set discussion group = ok %v err %v, want true", ok, err) + } + + postUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: broadcastPeer, + Message: "story post", + RandomID: 939001, + }) + if err != nil { + t.Fatalf("send broadcast post: %v", err) + } + post := findNewChannelTextMessage(t, postUpdates, "story post") + discussion, err := r.onMessagesGetDiscussionMessage(WithUserID(ctx, owner.ID), &tg.MessagesGetDiscussionMessageRequest{ + Peer: broadcastPeer, + MsgID: post.ID, + }) + if err != nil { + t.Fatalf("get discussion message: %v", err) + } + if len(discussion.Messages) != 1 || len(discussion.Chats) != 2 { + t.Fatalf("discussion = %+v, want one root message and source/group chats", discussion) + } + assertChannelStoryMaxID(t, findChannelClass(discussion.Chats, broadcast.Channel.ID), 61) + assertChannelStoryMaxID(t, findChannelClass(discussion.Chats, group.Channel.ID), 63) + root := discussion.Messages[0].(*tg.Message) + + replyTo := &tg.InputReplyToMessage{ReplyToMsgID: root.ID} + replyReq := &tg.MessagesSendMessageRequest{ + Peer: groupPeer, + Message: "story discussion reply", + RandomID: 939002, + } + replyReq.SetReplyTo(replyTo) + replyUpdates, err := r.onMessagesSendMessage(WithUserID(ctx, member.ID), replyReq) + if err != nil { + t.Fatalf("send discussion reply: %v", err) + } + comment := findNewChannelTextMessage(t, replyUpdates, "story discussion reply") + replies, err := r.onMessagesGetReplies(WithUserID(ctx, owner.ID), &tg.MessagesGetRepliesRequest{ + Peer: broadcastPeer, + MsgID: post.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("get replies: %v", err) + } + replyMessages, replyChats, replyUsers := searchMessagesPayload(t, replies) + if len(replyMessages) != 1 || replyMessages[0].(*tg.Message).ID != comment.ID { + t.Fatalf("replies = %T %+v, want linked discussion reply %d", replies, replies, comment.ID) + } + assertUserStoryMaxID(t, findUserClass(replyUsers, member.ID), 59) + assertChannelStoryMaxID(t, findChannelClass(replyChats, broadcast.Channel.ID), 61) + assertChannelStoryMaxID(t, findChannelClass(replyChats, group.Channel.ID), 63) + + forumTopics, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{ + Peer: forumPeer, + Limit: 10, + }) + if err != nil { + t.Fatalf("get forum topics: %v", err) + } + if forumTopics.Count != 1 || len(forumTopics.Topics) != 1 || forumTopics.Pts == 0 { + t.Fatalf("forum topics = %+v, want general topic with pts", forumTopics) + } + assertUserStoryMaxID(t, findUserClass(forumTopics.Users, owner.ID), 57) + assertChannelStoryMaxID(t, findChannelClass(forumTopics.Chats, forum.Channel.ID), 67) + + forumTopicsByID, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{ + Peer: forumPeer, + Topics: []int{forumGeneralTopicID}, + }) + if err != nil { + t.Fatalf("get forum topics by id: %v", err) + } + if forumTopicsByID.Count != 1 || len(forumTopicsByID.Topics) != 1 || forumTopicsByID.Pts != forumTopics.Pts { + t.Fatalf("forum topics by id = %+v, want same general topic pts %d", forumTopicsByID, forumTopics.Pts) + } + assertUserStoryMaxID(t, findUserClass(forumTopicsByID.Users, owner.ID), 57) + assertChannelStoryMaxID(t, findChannelClass(forumTopicsByID.Chats, forum.Channel.ID), 67) +} + +func TestViewsUnreadMentionsAndReactionsProjectStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93911, Phone: "15550093911", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 93912, Phone: "15550093912", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story view surfaces", + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + storyStore := memory.NewStoryStore() + upsertStory := func(peer domain.Peer, id int) { + t.Helper() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: peer, + ID: id, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %v/%d: %v", peer, id, err) + } + } + upsertStory(domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, 71) + upsertStory(domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, 73) + upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}, 79) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + viewed, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 9391101, + Message: "story views", + Date: 1700000010, + }) + if err != nil { + t.Fatalf("send viewed message: %v", err) + } + views, err := r.onMessagesGetMessagesViews(WithUserID(ctx, member.ID), &tg.MessagesGetMessagesViewsRequest{ + Peer: peer, + ID: []int{viewed.Message.ID}, + }) + if err != nil { + t.Fatalf("get message views: %v", err) + } + if len(views.Views) != 1 { + t.Fatalf("views = %+v, want one view", views) + } + assertUserStoryMaxID(t, findUserClass(views.Users, owner.ID), 71) + assertChannelStoryMaxID(t, findChannelClass(views.Chats, created.Channel.ID), 79) + + mention, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 9391102, + Message: "story mention", + MentionUserIDs: []int64{member.ID}, + Date: 1700000020, + }) + if err != nil { + t.Fatalf("send mention: %v", err) + } + mentions, err := r.onMessagesGetUnreadMentions(WithUserID(ctx, member.ID), &tg.MessagesGetUnreadMentionsRequest{ + Peer: peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("get unread mentions: %v", err) + } + mentionMessages, mentionChats, mentionUsers := searchMessagesPayload(t, mentions) + if len(mentionMessages) != 1 || mentionMessages[0].(*tg.Message).ID != mention.Message.ID { + t.Fatalf("mentions = %T %+v, want mention message %d", mentions, mentions, mention.Message.ID) + } + assertUserStoryMaxID(t, findUserClass(mentionUsers, owner.ID), 71) + assertChannelStoryMaxID(t, findChannelClass(mentionChats, created.Channel.ID), 79) + + reactionReq := &tg.MessagesSendReactionRequest{ + Peer: peer, + MsgID: viewed.Message.ID, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, + } + reactionReq.SetReaction(reactionReq.Reaction) + if _, err := r.onMessagesSendReaction(WithUserID(ctx, member.ID), reactionReq); err != nil { + t.Fatalf("send reaction: %v", err) + } + unreadReactions, err := r.onMessagesGetUnreadReactions(WithUserID(ctx, owner.ID), &tg.MessagesGetUnreadReactionsRequest{ + Peer: peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("get unread reactions: %v", err) + } + reactionMessages, reactionChats, reactionUsers := searchMessagesPayload(t, unreadReactions) + if len(reactionMessages) != 1 || reactionMessages[0].(*tg.Message).ID != viewed.Message.ID { + t.Fatalf("unread reactions = %T %+v, want viewed message %d", unreadReactions, unreadReactions, viewed.Message.ID) + } + assertUserStoryMaxID(t, findUserClass(reactionUsers, member.ID), 73) + assertChannelStoryMaxID(t, findChannelClass(reactionChats, created.Channel.ID), 79) +} + +func TestMessageReactionsListProjectsStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 93921, Phone: "15550093921", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := userStore.Create(ctx, domain.User{AccessHash: 93922, Phone: "15550093922", FirstName: "Member"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story reaction list", + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + storyStore := memory.NewStoryStore() + upsertStory := func(peer domain.Peer, id int) { + t.Helper() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: peer, + ID: id, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %v/%d: %v", peer, id, err) + } + } + upsertStory(domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, 83) + upsertStory(domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}, 89) + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + peer := &tg.InputPeerChannel{ChannelID: created.Channel.ID, AccessHash: created.Channel.AccessHash} + + sent, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: created.Channel.ID, + RandomID: 9392101, + Message: "story reaction list", + Date: 1700000010, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + reactionReq := &tg.MessagesSendReactionRequest{ + Peer: peer, + MsgID: sent.Message.ID, + Reaction: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}, + } + reactionReq.SetReaction(reactionReq.Reaction) + if _, err := r.onMessagesSendReaction(WithUserID(ctx, member.ID), reactionReq); err != nil { + t.Fatalf("send reaction: %v", err) + } + + list, err := r.onMessagesGetMessageReactionsList(WithUserID(ctx, owner.ID), &tg.MessagesGetMessageReactionsListRequest{ + Peer: peer, + ID: sent.Message.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("get message reactions list: %v", err) + } + if list.Count != 1 || len(list.Reactions) != 1 { + t.Fatalf("reaction list = %+v, want one reaction", list) + } + if peerID, ok := list.Reactions[0].PeerID.(*tg.PeerUser); !ok || peerID.UserID != member.ID { + t.Fatalf("reaction peer = %+v, want member %d", list.Reactions[0].PeerID, member.ID) + } + assertUserStoryMaxID(t, findUserClass(list.Users, member.ID), 83) + assertChannelStoryMaxID(t, findChannelClass(list.Chats, created.Channel.ID), 89) +} + +func TestStoriesAllStoriesHydratesCompanionPeersWithStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 94001, Phone: "15550094001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story strip channel", + Broadcast: true, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + ID: 7, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert user story: %v", err) + } + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}, + ID: 11, + Date: 1700000010, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + got, err := r.onStoriesGetAllStories(WithUserID(ctx, owner.ID), &tg.StoriesGetAllStoriesRequest{}) + if err != nil { + t.Fatalf("get all stories: %v", err) + } + all, ok := got.(*tg.StoriesAllStories) + if !ok { + t.Fatalf("all stories = %T, want *tg.StoriesAllStories", got) + } + if len(all.PeerStories) != 2 { + t.Fatalf("peer stories = %d, want user and channel stories", len(all.PeerStories)) + } + assertUserStoryMaxID(t, findUserClass(all.Users, owner.ID), 7) + assertChannelStoryMaxID(t, findChannelClass(all.Chats, created.Channel.ID), 11) + + peerStories, err := r.onStoriesGetPeerStories(WithUserID(ctx, owner.ID), &tg.InputPeerUser{UserID: owner.ID, AccessHash: owner.AccessHash}) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + assertUserStoryMaxID(t, findUserClass(peerStories.Users, owner.ID), 7) +} + +func TestBuildOutboxStoryUpdatesHydratesCompanionPeersWithStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 95001, Phone: "15550095001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 95002, Phone: "15550095002", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + storyStore := memory.NewStoryStore() + story := domain.Story{ + Owner: ownerPeer, + ID: 13, + Date: 1700000200, + ExpireDate: 1700003800, + Public: true, + Caption: "outbox story", + } + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000300, 0)}) + + updates := r.BuildOutboxUpdates(ctx, []OutboxUpdateRequest{{ + TargetUserID: viewer.ID, + Event: domain.UpdateEvent{ + UserID: viewer.ID, + Type: domain.UpdateEventStory, + Pts: 21, + PtsCount: 1, + Date: story.Date, + Peer: ownerPeer, + Story: story, + }, + }}) + if len(updates) != 1 || updates[0] == nil { + t.Fatalf("updates = %+v, want one story update", updates) + } + if len(updates[0].Updates) != 2 { + t.Fatalf("update count = %d, want updateStory plus pts bookkeeping", len(updates[0].Updates)) + } + if _, ok := updates[0].Updates[0].(*tg.UpdateStory); !ok { + t.Fatalf("first update = %T, want updateStory", updates[0].Updates[0]) + } + assertUserStoryMaxID(t, findUserClass(updates[0].Users, owner.ID), 13) +} + +func TestBuildOutboxNewStoryReactionHydratesReactorUser(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 95201, Phone: "15550095201", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + reactor, err := userStore.Create(ctx, domain.User{AccessHash: 95202, Phone: "15550095202", FirstName: "Reactor"}) + if err != nil { + t.Fatalf("create reactor: %v", err) + } + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + storyStore := memory.NewStoryStore() + story := domain.Story{ + Owner: ownerPeer, + ID: 14, + Date: 1700000210, + ExpireDate: 1700003810, + Public: true, + } + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000310, 0)}) + + updates := r.BuildOutboxUpdates(ctx, []OutboxUpdateRequest{{ + TargetUserID: owner.ID, + Event: domain.UpdateEvent{ + UserID: owner.ID, + Type: domain.UpdateEventNewStoryReaction, + Pts: 22, + PtsCount: 1, + Date: 1700000310, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: reactor.ID}, + Story: story, + MaxID: story.ID, + Reaction: &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "🔥"}, + }, + }}) + if len(updates) != 1 || updates[0] == nil { + t.Fatalf("updates = %+v, want one new story reaction update", updates) + } + if len(updates[0].Updates) != 2 { + t.Fatalf("update count = %d, want updateNewStoryReaction plus pts bookkeeping", len(updates[0].Updates)) + } + got, ok := updates[0].Updates[0].(*tg.UpdateNewStoryReaction) + if !ok { + t.Fatalf("first update = %T, want updateNewStoryReaction", updates[0].Updates[0]) + } + peer, ok := got.Peer.(*tg.PeerUser) + if !ok || peer.UserID != reactor.ID { + t.Fatalf("reaction peer = %T %+v, want reactor user peer", got.Peer, got.Peer) + } + if findUserClass(updates[0].Users, reactor.ID) == nil { + t.Fatalf("users = %+v, want reactor companion user", updates[0].Users) + } + assertUserStoryMaxID(t, findUserClass(updates[0].Users, owner.ID), story.ID) +} + +func TestDialogContactAndSearchPayloadsProjectStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 96001, Phone: "15550096001", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 96002, Phone: "15550096002", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story payload channel", + Broadcast: true, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 17, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert user story: %v", err) + } + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: channelPeer, + ID: 19, + Date: 1700000010, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + dialogs, ok := r.tgMessagesDialogs(ctx, viewer.ID, domain.DialogList{ + Dialogs: []domain.Dialog{{Peer: ownerPeer}, {Peer: channelPeer}}, + Users: []domain.User{owner}, + Channels: []domain.Channel{ + created.Channel, + }, + }).(*tg.MessagesDialogs) + if !ok { + t.Fatalf("dialogs type = %T, want *tg.MessagesDialogs", dialogs) + } + assertUserStoryMaxID(t, findUserClass(dialogs.Users, owner.ID), 17) + assertChannelStoryMaxID(t, findChannelClass(dialogs.Chats, created.Channel.ID), 19) + + peerDialogs := r.tgPeerDialogs(ctx, viewer.ID, domain.DialogList{ + Dialogs: []domain.Dialog{{Peer: ownerPeer}, {Peer: channelPeer}}, + Users: []domain.User{owner}, + Channels: []domain.Channel{ + created.Channel, + }, + }, domain.UpdateState{Pts: 7, Date: 1700000100}) + assertUserStoryMaxID(t, findUserClass(peerDialogs.Users, owner.ID), 17) + assertChannelStoryMaxID(t, findChannelClass(peerDialogs.Chats, created.Channel.ID), 19) + + contacts, ok := r.tgContacts(ctx, viewer.ID, domain.ContactList{ + Contacts: []domain.Contact{{User: owner}}, + }).(*tg.ContactsContacts) + if !ok { + t.Fatalf("contacts type = %T, want *tg.ContactsContacts", contacts) + } + assertUserStoryMaxID(t, findUserClass(contacts.Users, owner.ID), 17) + + found := r.tgContactsFound(ctx, viewer.ID, domain.UserSearchResult{ + Results: []domain.User{owner}, + ChannelResults: []domain.Channel{created.Channel}, + }) + assertUserStoryMaxID(t, findUserClass(found.Users, owner.ID), 17) + assertChannelStoryMaxID(t, findChannelClass(found.Chats, created.Channel.ID), 19) + + contactUpdate := r.contactPeerSettingsUpdates(ctx, viewer.ID, owner, domain.PeerSettings{}, false) + assertUserStoryMaxID(t, findUserClass(contactUpdate.Users, owner.ID), 17) + + global, ok := r.tgGlobalSearchMessages(ctx, viewer.ID, 10, domain.MessageList{ + Users: []domain.User{owner}, + }, domain.ChannelHistory{ + Channels: []domain.Channel{created.Channel}, + }).(*tg.MessagesMessages) + if !ok { + t.Fatalf("global search type = %T, want *tg.MessagesMessages", global) + } + assertUserStoryMaxID(t, findUserClass(global.Users, owner.ID), 17) + assertChannelStoryMaxID(t, findChannelClass(global.Chats, created.Channel.ID), 19) +} + +func TestContactsResolveUsernameProjectsStoriesMaxID(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, err := userStore.Create(ctx, domain.User{AccessHash: 97001, Phone: "15550097001", FirstName: "Owner", Username: "story_owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := userStore.Create(ctx, domain.User{AccessHash: 97002, Phone: "15550097002", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "story resolve channel", + Broadcast: true, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + public, err := channelService.UpdateUsername(ctx, owner.ID, domain.UpdateChannelUsernameRequest{ + ChannelID: created.Channel.ID, + Username: "story_resolve_channel", + }) + if err != nil { + t.Fatalf("set channel username: %v", err) + } + storyStore := memory.NewStoryStore() + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + ID: 23, + Date: 1700000000, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert user story: %v", err) + } + if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeChannel, ID: public.ID}, + ID: 29, + Date: 1700000010, + ExpireDate: 1700003600, + Public: true, + }}); err != nil { + t.Fatalf("upsert channel story: %v", err) + } + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelService, + Stories: appstories.NewService(storyStore), + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)}) + + userResolved, err := r.onContactsResolveUsername(WithUserID(ctx, viewer.ID), &tg.ContactsResolveUsernameRequest{Username: "@story_owner"}) + if err != nil { + t.Fatalf("resolve user: %v", err) + } + assertUserStoryMaxID(t, findUserClass(userResolved.Users, owner.ID), 23) + + channelResolved, err := r.onContactsResolveUsername(WithUserID(ctx, viewer.ID), &tg.ContactsResolveUsernameRequest{Username: "@story_resolve_channel"}) + if err != nil { + t.Fatalf("resolve channel: %v", err) + } + assertChannelStoryMaxID(t, findChannelClass(channelResolved.Chats, public.ID), 29) +} + +func dispatchMessagesPayload(t *testing.T, r *Router, ctx context.Context, req bin.Encoder) bin.Encoder { + t.Helper() + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode request: %v", err) + } + out, err := r.Dispatch(ctx, [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if box, ok := out.(*tg.MessagesMessagesBox); ok { + return box.Messages + } + return out +} + +func findNewChannelTextMessage(t *testing.T, updates tg.UpdatesClass, body string) *tg.Message { + t.Helper() + box, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, update := range box.Updates { + newMsg, ok := update.(*tg.UpdateNewChannelMessage) + if !ok { + continue + } + msg, ok := newMsg.Message.(*tg.Message) + if ok && msg.Message == body { + return msg + } + } + t.Fatalf("updates = %+v, want new channel message %q", box.Updates, body) + return nil +} + +func findUserClass(users []tg.UserClass, id int64) tg.UserClass { + for _, item := range users { + if user, ok := item.(*tg.User); ok && user.ID == id { + return item + } + } + return nil +} + +func findChannelClass(chats []tg.ChatClass, id int64) tg.ChatClass { + for _, item := range chats { + if channel, ok := item.(*tg.Channel); ok && channel.ID == id { + return item + } + } + return nil +} + +func assertUserStoryMaxID(t *testing.T, item tg.UserClass, want int) { + t.Helper() + user, ok := item.(*tg.User) + if !ok { + t.Fatalf("user = %T, want *tg.User", item) + } + recent, ok := user.GetStoriesMaxID() + if !ok { + t.Fatalf("user stories_max_id missing") + } + assertRecentStoryMaxID(t, recent, want) +} + +func assertChannelStoryMaxID(t *testing.T, item tg.ChatClass, want int) { + t.Helper() + channel, ok := item.(*tg.Channel) + if !ok { + t.Fatalf("channel = %T, want *tg.Channel", item) + } + recent, ok := channel.GetStoriesMaxID() + if !ok { + t.Fatalf("channel stories_max_id missing") + } + assertRecentStoryMaxID(t, recent, want) +} + +func assertRecentStoryMaxID(t *testing.T, recent tg.RecentStory, want int) { + t.Helper() + got, ok := recent.GetMaxID() + if !ok || got != want { + t.Fatalf("recent story max_id = %d/%v, want %d", got, ok, want) + } +} diff --git a/internal/rpc/story_pinned_cache.go b/internal/rpc/story_pinned_cache.go new file mode 100644 index 00000000..4b341787 --- /dev/null +++ b/internal/rpc/story_pinned_cache.go @@ -0,0 +1,60 @@ +package rpc + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +// storyPinnedAvailableCache 缓存 (viewer,peer)→是否有置顶故事,由统一缓存原语承载 +// (LRU 单条驱逐 / epoch 守卫 / 内建 singleflight;纯 TTL,无版本闸门)。 +type storyPinnedAvailableCache struct { + cache *readmodelcache.Cache[storyProjectionCacheKey, bool] +} + +func newStoryPinnedAvailableCache(now func() time.Time) *storyPinnedAvailableCache { + return &storyPinnedAvailableCache{ + cache: readmodelcache.New[storyProjectionCacheKey, bool](readmodelcache.Config[storyProjectionCacheKey, bool]{ + MaxEntries: storyProjectionCacheMaxEntries, + TTL: storyPinnedStoriesCacheTTL, + Now: now, + }), + } +} + +func (c *storyPinnedAvailableCache) getOrLoad(ctx context.Context, viewerUserID int64, peer domain.Peer, load func() (bool, error)) (bool, error) { + if c == nil || viewerUserID == 0 || peer.ID == 0 { + return load() + } + return c.cache.GetOrLoad(ctx, storyProjectionCacheKey{viewerUserID: viewerUserID, peer: peer}, load) +} + +func (c *storyPinnedAvailableCache) Delete(viewerUserID int64, peer domain.Peer) { + if c == nil || viewerUserID == 0 || peer.ID == 0 { + return + } + c.cache.Invalidate(storyProjectionCacheKey{viewerUserID: viewerUserID, peer: peer}) +} + +func (c *storyPinnedAvailableCache) DeleteViewer(viewerUserID int64) { + if c == nil || viewerUserID == 0 { + return + } + c.cache.InvalidateWhere(func(k storyProjectionCacheKey) bool { return k.viewerUserID == viewerUserID }) +} + +func (c *storyPinnedAvailableCache) DeletePeer(peer domain.Peer) { + if c == nil || peer.ID == 0 { + return + } + c.cache.InvalidateWhere(func(k storyProjectionCacheKey) bool { return k.peer == peer }) +} + +func (c *storyPinnedAvailableCache) Flush() { + if c == nil { + return + } + c.cache.Flush() +} diff --git a/internal/rpc/story_pinned_stories_cache.go b/internal/rpc/story_pinned_stories_cache.go new file mode 100644 index 00000000..6fd3901a --- /dev/null +++ b/internal/rpc/story_pinned_stories_cache.go @@ -0,0 +1,180 @@ +package rpc + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +const storyPinnedStoriesCacheTTL = 30 * time.Minute + +type storyPinnedStoriesCacheKey struct { + viewerUserID int64 + peer domain.Peer + offsetID int + limit int +} + +// storyPinnedStoriesCache 缓存某 viewer 对某 peer 的置顶故事分页结果,由统一缓存原语承载。 +// key 含 (offsetID,limit) 分页维度——LRU 单条驱逐终于给这个 page-key 上界,消掉原先无界增长。 +type storyPinnedStoriesCache struct { + cache *readmodelcache.Cache[storyPinnedStoriesCacheKey, domain.StoryList] +} + +func newStoryPinnedStoriesCache(now func() time.Time) *storyPinnedStoriesCache { + return &storyPinnedStoriesCache{ + cache: readmodelcache.New[storyPinnedStoriesCacheKey, domain.StoryList](readmodelcache.Config[storyPinnedStoriesCacheKey, domain.StoryList]{ + MaxEntries: storyProjectionCacheMaxEntries, + TTL: storyPinnedStoriesCacheTTL, + Now: now, + Clone: cloneStoryListForCache, + }), + } +} + +func storyPinnedStoriesKey(viewerUserID int64, peer domain.Peer, offsetID, limit int) storyPinnedStoriesCacheKey { + if offsetID < 0 { + offsetID = 0 + } + if limit <= 0 || limit > domain.MaxStoryListLimit { + limit = domain.MaxStoryListLimit + } + return storyPinnedStoriesCacheKey{ + viewerUserID: viewerUserID, + peer: peer, + offsetID: offsetID, + limit: limit, + } +} + +func (c *storyPinnedStoriesCache) getOrLoad(ctx context.Context, key storyPinnedStoriesCacheKey, load func() (domain.StoryList, error)) (domain.StoryList, error) { + if c == nil || key.viewerUserID == 0 || key.peer.ID == 0 { + return load() + } + return c.cache.GetOrLoad(ctx, key, load) +} + +func (c *storyPinnedStoriesCache) Delete(viewerUserID int64, peer domain.Peer) { + if c == nil || viewerUserID == 0 || peer.ID == 0 { + return + } + c.cache.InvalidateWhere(func(k storyPinnedStoriesCacheKey) bool { + return k.viewerUserID == viewerUserID && k.peer == peer + }) +} + +func (c *storyPinnedStoriesCache) DeleteViewer(viewerUserID int64) { + if c == nil || viewerUserID == 0 { + return + } + c.cache.InvalidateWhere(func(k storyPinnedStoriesCacheKey) bool { return k.viewerUserID == viewerUserID }) +} + +func (c *storyPinnedStoriesCache) DeletePeer(peer domain.Peer) { + if c == nil || peer.ID == 0 { + return + } + c.cache.InvalidateWhere(func(k storyPinnedStoriesCacheKey) bool { return k.peer == peer }) +} + +func (c *storyPinnedStoriesCache) Flush() { + if c == nil { + return + } + c.cache.Flush() +} + +func cloneStoryListForCache(in domain.StoryList) domain.StoryList { + in.Stories = cloneStoriesForCache(in.Stories) + in.PinnedToTop = append([]int(nil), in.PinnedToTop...) + in.Peers = clonePeerStoriesForCache(in.Peers) + in.Users = append([]domain.User(nil), in.Users...) + in.Channels = append([]domain.Channel(nil), in.Channels...) + return in +} + +func clonePeerStoriesForCache(in []domain.PeerStories) []domain.PeerStories { + if len(in) == 0 { + return nil + } + out := make([]domain.PeerStories, len(in)) + for i, item := range in { + out[i] = item + out[i].Stories = cloneStoriesForCache(item.Stories) + out[i].Users = append([]domain.User(nil), item.Users...) + out[i].Channels = append([]domain.Channel(nil), item.Channels...) + } + return out +} + +func cloneStoriesForCache(in []domain.Story) []domain.Story { + if len(in) == 0 { + return nil + } + out := make([]domain.Story, len(in)) + for i, story := range in { + out[i] = cloneStoryForCache(story) + } + return out +} + +func cloneStoryForCache(story domain.Story) domain.Story { + story.PrivacyRules = clonePrivacyRulesForCache(story.PrivacyRules) + story.AllowUserIDs = append([]int64(nil), story.AllowUserIDs...) + story.DisallowUserIDs = append([]int64(nil), story.DisallowUserIDs...) + story.Entities = append([]domain.MessageEntity(nil), story.Entities...) + story.MediaAreas = cloneStoryMediaAreasForCache(story.MediaAreas) + if story.Forward != nil { + forward := *story.Forward + story.Forward = &forward + } + story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...) + story.Views.RecentViewers = append([]int64(nil), story.Views.RecentViewers...) + if story.SentReaction != nil { + reaction := *story.SentReaction + story.SentReaction = &reaction + } + return story +} + +func clonePrivacyRulesForCache(in []domain.PrivacyRule) []domain.PrivacyRule { + if len(in) == 0 { + return nil + } + out := make([]domain.PrivacyRule, len(in)) + for i, rule := range in { + out[i] = rule + out[i].UserIDs = append([]int64(nil), rule.UserIDs...) + out[i].ChatIDs = append([]int64(nil), rule.ChatIDs...) + } + return out +} + +func cloneStoryMediaAreasForCache(in []domain.StoryMediaArea) []domain.StoryMediaArea { + if len(in) == 0 { + return nil + } + out := make([]domain.StoryMediaArea, len(in)) + for i, area := range in { + out[i] = area + if area.Reaction != nil { + reaction := *area.Reaction + out[i].Reaction = &reaction + } + if area.Geo != nil { + geo := *area.Geo + out[i].Geo = &geo + } + if area.GeoAddress != nil { + address := *area.GeoAddress + out[i].GeoAddress = &address + } + if area.Venue != nil { + venue := *area.Venue + out[i].Venue = &venue + } + } + return out +} diff --git a/internal/rpc/story_projection_cache.go b/internal/rpc/story_projection_cache.go new file mode 100644 index 00000000..2de4c6ba --- /dev/null +++ b/internal/rpc/story_projection_cache.go @@ -0,0 +1,248 @@ +package rpc + +import ( + "context" + "sort" + "strconv" + "strings" + "time" + + "github.com/gotd/td/tg" + "golang.org/x/sync/singleflight" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +const ( + storyProjectionCacheTTL = storyPinnedStoriesCacheTTL + storyProjectionCacheMaxEntries = 4096 +) + +type storyProjectionCacheKey struct { + viewerUserID int64 + peer domain.Peer +} + +// storyProjectionValue 含负缓存:hasRecent=false 表示「查过、该 peer 无 recent 故事」(仍带 +// hidden 状态)。tg.RecentStory 按值视为 copy-safe(与原实现一致,不深拷)。 +type storyProjectionValue struct { + recent tg.RecentStory + hasRecent bool + hidden bool +} + +type storyProjectionMaps struct { + recent map[domain.Peer]tg.RecentStory + hidden map[domain.Peer]bool +} + +// storyProjectionCache 缓存 (viewer,peer)→{recent, hidden},由统一缓存原语承载,批量经 +// GetOrLoadBatch(per-peer 查 + 合批 load misses + per-key 写回;纯 TTL,无版本闸门)。 +// GetOrLoadBatch 无批量级 singleflight,故此处保留按 (viewer,peer 集) 维度的 sf,防止并发相同 +// 投影请求对昂贵的 story 后端形成 thundering herd。 +type storyProjectionCache struct { + cache *readmodelcache.Cache[storyProjectionCacheKey, storyProjectionValue] + sf singleflight.Group +} + +func newStoryProjectionCache(now func() time.Time) *storyProjectionCache { + return &storyProjectionCache{ + cache: readmodelcache.New[storyProjectionCacheKey, storyProjectionValue](readmodelcache.Config[storyProjectionCacheKey, storyProjectionValue]{ + MaxEntries: storyProjectionCacheMaxEntries, + TTL: storyProjectionCacheTTL, + Now: now, + }), + } +} + +// getMany 批量解析 (viewer, peers) 的 recent/hidden 投影。loadMissing 为缺失 peer 计算两张图 +// (与 storyProjectionFreshMaps 同形);每个 miss peer 都被缓存(含「无 recent」负结果与 hidden 态)。 +// 整体按 (viewer, 排序去重 peer 集) 走 singleflight,并发相同请求只打一次后端。 +func (c *storyProjectionCache) getMany( + ctx context.Context, + viewerUserID int64, + peers []domain.Peer, + loadMissing func(context.Context, []domain.Peer) (map[domain.Peer]tg.RecentStory, map[domain.Peer]bool), +) (map[domain.Peer]tg.RecentStory, map[domain.Peer]bool) { + if c == nil { + return loadMissing(ctx, peers) + } + v, _, _ := c.sf.Do(storyProjectionSingleflightKey(viewerUserID, peers), func() (any, error) { + recent, hidden := c.getManyUncached(ctx, viewerUserID, peers, loadMissing) + return storyProjectionMaps{recent: recent, hidden: hidden}, nil + }) + res := v.(storyProjectionMaps) + return res.recent, res.hidden +} + +func (c *storyProjectionCache) getManyUncached( + ctx context.Context, + viewerUserID int64, + peers []domain.Peer, + loadMissing func(context.Context, []domain.Peer) (map[domain.Peer]tg.RecentStory, map[domain.Peer]bool), +) (map[domain.Peer]tg.RecentStory, map[domain.Peer]bool) { + keys := make([]storyProjectionCacheKey, 0, len(peers)) + for _, peer := range peers { + keys = append(keys, storyProjectionCacheKey{viewerUserID: viewerUserID, peer: peer}) + } + values, _ := c.cache.GetOrLoadBatch(ctx, keys, + func(storyProjectionCacheKey) (int64, bool) { return 0, true }, // 纯 TTL,无版本闸门 + func(ctx context.Context, missing []storyProjectionCacheKey) (map[storyProjectionCacheKey]storyProjectionValue, error) { + missPeers := make([]domain.Peer, len(missing)) + for i, k := range missing { + missPeers[i] = k.peer + } + recent, hidden := loadMissing(ctx, missPeers) + out := make(map[storyProjectionCacheKey]storyProjectionValue, len(missing)) + for _, k := range missing { + v := storyProjectionValue{} + if story, ok := recent[k.peer]; ok { + v.recent = story + v.hasRecent = true + } + if state, ok := hidden[k.peer]; ok { + v.hidden = state + } + out[k] = v + } + return out, nil + }) + recentOut := make(map[domain.Peer]tg.RecentStory, len(values)) + hiddenOut := make(map[domain.Peer]bool, len(values)) + for k, v := range values { + if v.hasRecent { + recentOut[k.peer] = v.recent + } + hiddenOut[k.peer] = v.hidden + } + return recentOut, hiddenOut +} + +func (c *storyProjectionCache) Delete(viewerUserID int64, peer domain.Peer) { + if c == nil || viewerUserID == 0 || peer.ID == 0 { + return + } + c.cache.Invalidate(storyProjectionCacheKey{viewerUserID: viewerUserID, peer: peer}) +} + +func (c *storyProjectionCache) DeleteViewer(viewerUserID int64) { + if c == nil || viewerUserID == 0 { + return + } + c.cache.InvalidateWhere(func(k storyProjectionCacheKey) bool { return k.viewerUserID == viewerUserID }) +} + +func (c *storyProjectionCache) DeletePeer(peer domain.Peer) { + if c == nil || peer.ID == 0 { + return + } + c.cache.InvalidateWhere(func(k storyProjectionCacheKey) bool { return k.peer == peer }) +} + +func (c *storyProjectionCache) Flush() { + if c == nil { + return + } + c.cache.Flush() +} + +func (r *Router) storyProjectionMaps(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]tg.RecentStory, map[domain.Peer]bool) { + if r.deps.Stories == nil || viewerUserID == 0 || len(peers) == 0 { + return nil, nil + } + if r.storyProjectionCache == nil { + return r.storyProjectionFreshMaps(ctx, viewerUserID, peers) + } + return r.storyProjectionCache.getMany(ctx, viewerUserID, peers, func(ctx context.Context, missPeers []domain.Peer) (map[domain.Peer]tg.RecentStory, map[domain.Peer]bool) { + return r.storyProjectionFreshMaps(ctx, viewerUserID, missPeers) + }) +} + +func storyProjectionSingleflightKey(viewerUserID int64, peers []domain.Peer) string { + uniq := make(map[domain.Peer]struct{}, len(peers)) + keys := make([]domain.Peer, 0, len(peers)) + for _, peer := range peers { + if peer.ID == 0 { + continue + } + if _, ok := uniq[peer]; ok { + continue + } + uniq[peer] = struct{}{} + keys = append(keys, peer) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].Type != keys[j].Type { + return keys[i].Type < keys[j].Type + } + return keys[i].ID < keys[j].ID + }) + var b strings.Builder + b.WriteString(strconv.FormatInt(viewerUserID, 10)) + for _, peer := range keys { + b.WriteByte('|') + b.WriteString(string(peer.Type)) + b.WriteByte(':') + b.WriteString(strconv.FormatInt(peer.ID, 10)) + } + return b.String() +} + +func (r *Router) invalidateStoryProjectionCache(viewerUserID int64, peer domain.Peer) { + if r.storyProjectionCache != nil { + r.storyProjectionCache.Delete(viewerUserID, peer) + } + if r.storyPinnedCache != nil { + r.storyPinnedCache.Delete(viewerUserID, peer) + } + if r.storyPinnedListCache != nil { + r.storyPinnedListCache.Delete(viewerUserID, peer) + } +} + +func (r *Router) invalidateStoryProjectionCacheForViewer(viewerUserID int64) { + if r.storyProjectionCache != nil { + r.storyProjectionCache.DeleteViewer(viewerUserID) + } + if r.storyPinnedCache != nil { + r.storyPinnedCache.DeleteViewer(viewerUserID) + } + if r.storyPinnedListCache != nil { + r.storyPinnedListCache.DeleteViewer(viewerUserID) + } +} + +func (r *Router) invalidateStoryProjectionCacheForPeer(peer domain.Peer) { + if r.storyProjectionCache != nil { + r.storyProjectionCache.DeletePeer(peer) + } + if r.storyPinnedCache != nil { + r.storyPinnedCache.DeletePeer(peer) + } + if r.storyPinnedListCache != nil { + r.storyPinnedListCache.DeletePeer(peer) + } +} + +func (r *Router) InvalidateStoryReadModelViewers(viewerUserIDs ...int64) { + for _, id := range viewerUserIDs { + r.invalidateStoryProjectionCacheForViewer(id) + } +} + +func (r *Router) InvalidateStoryReadModelPeer(peer domain.Peer) { + r.invalidateStoryProjectionCacheForPeer(peer) +} + +func (r *Router) FlushStoryReadModelCache() { + if r.storyProjectionCache != nil { + r.storyProjectionCache.Flush() + } + if r.storyPinnedCache != nil { + r.storyPinnedCache.Flush() + } + if r.storyPinnedListCache != nil { + r.storyPinnedListCache.Flush() + } +} diff --git a/internal/rpc/temp_key_cache.go b/internal/rpc/temp_key_cache.go new file mode 100644 index 00000000..cb4b303a --- /dev/null +++ b/internal/rpc/temp_key_cache.go @@ -0,0 +1,125 @@ +package rpc + +import ( + "container/list" + "sync" + "time" +) + +const defaultTempKeyResolveCacheMaxEntries = 4096 + +type tempKeyResolveEntry struct { + perm [8]byte + expireAt time.Time +} + +type tempKeyResolveCacheItem struct { + raw [8]byte + entry tempKeyResolveEntry +} + +type tempKeyResolveCache struct { + mu sync.Mutex + max int + entries map[[8]byte]*list.Element + order *list.List +} + +func newTempKeyResolveCache(maxEntries int) *tempKeyResolveCache { + if maxEntries <= 0 { + maxEntries = defaultTempKeyResolveCacheMaxEntries + } + return &tempKeyResolveCache{ + max: maxEntries, + entries: make(map[[8]byte]*list.Element, maxEntries), + order: list.New(), + } +} + +func (c *tempKeyResolveCache) Get(rawAuthKeyID, expectedPermAuthKeyID [8]byte, now time.Time) ([8]byte, bool) { + if c == nil { + return [8]byte{}, false + } + c.mu.Lock() + defer c.mu.Unlock() + el := c.entries[rawAuthKeyID] + if el == nil { + return [8]byte{}, false + } + item := el.Value.(tempKeyResolveCacheItem) + if !item.entry.expireAt.After(now) || item.entry.perm != expectedPermAuthKeyID { + c.removeElementLocked(el) + return [8]byte{}, false + } + c.order.MoveToBack(el) + return item.entry.perm, true +} + +func (c *tempKeyResolveCache) Store(rawAuthKeyID, permAuthKeyID [8]byte, expireAt, now time.Time) { + if c == nil || c.max <= 0 || rawAuthKeyID == ([8]byte{}) || permAuthKeyID == ([8]byte{}) { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if el := c.entries[rawAuthKeyID]; el != nil { + el.Value = tempKeyResolveCacheItem{raw: rawAuthKeyID, entry: tempKeyResolveEntry{perm: permAuthKeyID, expireAt: expireAt}} + c.order.MoveToBack(el) + return + } + c.evictExpiredLocked(now) + el := c.order.PushBack(tempKeyResolveCacheItem{raw: rawAuthKeyID, entry: tempKeyResolveEntry{perm: permAuthKeyID, expireAt: expireAt}}) + c.entries[rawAuthKeyID] = el + for len(c.entries) > c.max { + c.removeElementLocked(c.order.Front()) + } +} + +func (c *tempKeyResolveCache) Delete(rawAuthKeyID [8]byte) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if el := c.entries[rawAuthKeyID]; el != nil { + c.removeElementLocked(el) + } +} + +func (c *tempKeyResolveCache) DeleteByPerm(permAuthKeyID [8]byte) [][8]byte { + if c == nil { + return nil + } + c.mu.Lock() + defer c.mu.Unlock() + rawAuthKeyIDs := make([][8]byte, 0) + for el := c.order.Front(); el != nil; { + next := el.Next() + item := el.Value.(tempKeyResolveCacheItem) + if item.entry.perm == permAuthKeyID { + rawAuthKeyIDs = append(rawAuthKeyIDs, item.raw) + c.removeElementLocked(el) + } + el = next + } + return rawAuthKeyIDs +} + +func (c *tempKeyResolveCache) evictExpiredLocked(now time.Time) { + for el := c.order.Front(); el != nil; { + next := el.Next() + item := el.Value.(tempKeyResolveCacheItem) + if !item.entry.expireAt.After(now) { + c.removeElementLocked(el) + } + el = next + } +} + +func (c *tempKeyResolveCache) removeElementLocked(el *list.Element) { + if el == nil { + return + } + item := el.Value.(tempKeyResolveCacheItem) + delete(c.entries, item.raw) + c.order.Remove(el) +} diff --git a/internal/rpc/update_peer_refs.go b/internal/rpc/update_peer_refs.go index f3ef3a3c..a91be7b0 100644 --- a/internal/rpc/update_peer_refs.go +++ b/internal/rpc/update_peer_refs.go @@ -10,24 +10,64 @@ func (r *Router) enrichUpdateEvents(ctx context.Context, viewerUserID int64, eve if len(events) == 0 { return events } + return r.enrichUpdateEventsWithPeerCache(ctx, viewerUserID, events, newViewerPeerCache(r)) +} + +func (r *Router) enrichUpdateEventsWithPeerCache(ctx context.Context, viewerUserID int64, events []domain.UpdateEvent, cache *viewerPeerCache) []domain.UpdateEvent { + if len(events) == 0 { + return events + } + if cache == nil { + cache = newViewerPeerCache(r) + } out := append([]domain.UpdateEvent(nil), events...) + refs := make([]updateEventPeerRefs, len(out)) + allUserIDs := make(map[int64]struct{}) + allChannelIDs := make(map[int64]struct{}) for i := range out { if out[i].Type == domain.UpdateEventMessageReactions { out[i] = r.enrichMessageReactionEvent(ctx, viewerUserID, out[i]) } + if out[i].Type == domain.UpdateEventMessagePoll { + out[i] = r.enrichMessagePollEvent(ctx, viewerUserID, out[i]) + } + if out[i].Type == domain.UpdateEventDraftMessage { + out[i] = r.enrichDraftMessageEvent(ctx, viewerUserID, out[i]) + } userIDs := make(map[int64]struct{}) channelIDs := make(map[int64]struct{}) addDomainPeerRef(out[i].Peer, 0, userIDs, channelIDs) for _, peer := range out[i].Peers { addDomainPeerRef(peer, 0, userIDs, channelIDs) } + addDomainPeerRef(out[i].Story.Owner, 0, userIDs, channelIDs) + for _, peer := range storyForwardPeers(out[i].Story) { + addDomainPeerRef(peer, 0, userIDs, channelIDs) + } collectMessagePeerRefs(out[i].Message, 0, userIDs, channelIDs) - out[i].Users = r.withUsersPresence(mergeDomainUsers(out[i].Users, r.domainUsersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) - out[i].Channels = mergeDomainChannels(out[i].Channels, r.domainChannelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) + removeKnownChannelRefs(channelIDs, out[i].Channels) + refs[i] = updateEventPeerRefs{userIDs: userIDs, channelIDs: channelIDs} + for id := range userIDs { + allUserIDs[id] = struct{}{} + } + for id := range channelIDs { + allChannelIDs[id] = struct{}{} + } + } + cache.usersForIDs(ctx, viewerUserID, mapKeys(allUserIDs)) + cache.channelsForIDs(ctx, viewerUserID, mapKeys(allChannelIDs)) + for i := range out { + out[i].Users = r.withUsersPresence(mergeDomainUsers(out[i].Users, cache.usersForIDs(ctx, viewerUserID, mapKeys(refs[i].userIDs))...)) + out[i].Channels = mergeDomainChannels(out[i].Channels, cache.channelsForIDs(ctx, viewerUserID, mapKeys(refs[i].channelIDs))...) } return out } +type updateEventPeerRefs struct { + userIDs map[int64]struct{} + channelIDs map[int64]struct{} +} + func (r *Router) enrichMessageReactionEvent(ctx context.Context, viewerUserID int64, event domain.UpdateEvent) domain.UpdateEvent { if r.deps.Messages == nil || event.Message.ID <= 0 { return event @@ -58,6 +98,49 @@ func (r *Router) enrichMessageReactionEvent(ctx context.Context, viewerUserID in return event } +// enrichMessagePollEvent 在 difference 重放时按 viewer 重载消息(media 含最新 poll 权威态与 +// viewer 门控),与 reaction 事件 enrich 同构。 +func (r *Router) enrichMessagePollEvent(ctx context.Context, viewerUserID int64, event domain.UpdateEvent) domain.UpdateEvent { + if r.deps.Messages == nil || event.Message.ID <= 0 { + return event + } + peer := event.Message.Peer + if peer.Type == "" || peer.ID == 0 { + peer = event.Peer + } + if peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return event + } + list, err := r.deps.Messages.GetMessages(ctx, viewerUserID, []int{event.Message.ID}) + if err != nil { + return event + } + for _, msg := range list.Messages { + if msg.OwnerUserID == viewerUserID && msg.ID == event.Message.ID { + msg.Pts = event.Pts + event.Message = msg + event.Peer = msg.Peer + return event + } + } + return event +} + +// enrichDraftMessageEvent 重放 draft_message 事件时按当前权威态填充草稿内容: +// 草稿是绝对状态,事件行不固化快照;草稿已删(或读取失败)时 Draft 置 nil → 下发 empty。 +func (r *Router) enrichDraftMessageEvent(ctx context.Context, viewerUserID int64, event domain.UpdateEvent) domain.UpdateEvent { + event.Draft = nil + if r.deps.Dialogs == nil || event.Peer.ID == 0 { + return event + } + draft, found, err := r.deps.Dialogs.GetDraft(ctx, viewerUserID, event.Peer, event.MaxID) + if err != nil || !found { + return event + } + event.Draft = &draft + return event +} + func (r *Router) enrichChannelDifference(ctx context.Context, viewerUserID int64, diff domain.ChannelDifference) domain.ChannelDifference { userIDs := make(map[int64]struct{}) channelIDs := make(map[int64]struct{}) @@ -70,8 +153,10 @@ func (r *Router) enrichChannelDifference(ctx context.Context, viewerUserID int64 for _, event := range diff.OtherUpdates { collectChannelUpdatePeerRefs(event, diff.Channel.ID, userIDs, channelIDs) } - diff.Users = r.withUsersPresence(mergeDomainUsers(diff.Users, r.domainUsersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) - diff.Channels = mergeDomainChannels(diff.Channels, r.domainChannelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) + removeKnownChannelRefs(channelIDs, diff.Channels) + cache := newViewerPeerCache(r) + diff.Users = r.withUsersPresence(mergeDomainUsers(diff.Users, cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) + diff.Channels = mergeDomainChannels(diff.Channels, cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) return diff } @@ -86,20 +171,57 @@ func (r *Router) enrichChannelHistory(ctx context.Context, viewerUserID int64, h userIDs[topic.CreatorUserID] = struct{}{} } } - history.Users = r.withUsersPresence(mergeDomainUsers(history.Users, r.domainUsersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) - history.Channels = mergeDomainChannels(history.Channels, r.domainChannelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) + removeKnownChannelRefs(channelIDs, history.Channels) + cache := newViewerPeerCache(r) + history.Users = r.withUsersPresence(mergeDomainUsers(history.Users, cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) + history.Channels = mergeDomainChannels(history.Channels, cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) return history } +func (r *Router) enrichChannelDiscussion(ctx context.Context, viewerUserID int64, discussion domain.ChannelDiscussionMessage) domain.ChannelDiscussionMessage { + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, msg := range discussion.Messages { + collectChannelMessagePeerRefs(msg, discussion.DiscussionChannel.ID, userIDs, channelIDs) + } + // Post/Discussion channel 已由转换层单独下发,避免重复进 chats。 + delete(channelIDs, discussion.PostChannel.ID) + delete(channelIDs, discussion.DiscussionChannel.ID) + removeKnownChannelRefs(channelIDs, discussion.Channels) + cache := newViewerPeerCache(r) + discussion.Users = r.withUsersPresence(mergeDomainUsers(discussion.Users, cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) + discussion.Channels = mergeDomainChannels(discussion.Channels, cache.channelsForIDs(ctx, viewerUserID, mapKeys(channelIDs))...) + return discussion +} + +func (r *Router) enrichMessageList(ctx context.Context, viewerUserID int64, list domain.MessageList) domain.MessageList { + userIDs := make(map[int64]struct{}) + channelIDs := make(map[int64]struct{}) + for _, msg := range list.Messages { + collectMessagePeerRefs(msg, 0, userIDs, channelIDs) + } + cache := newViewerPeerCache(r) + list.Users = r.withUsersPresence(mergeDomainUsers(list.Users, cache.usersForIDs(ctx, viewerUserID, mapKeys(userIDs))...)) + return list +} + func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) { addDomainPeerRef(msg.From, currentChannelID, userIDs, channelIDs) addDomainPeerRef(msg.Peer, currentChannelID, userIDs, channelIDs) if msg.Forward != nil { addDomainPeerRef(msg.Forward.From, currentChannelID, userIDs, channelIDs) } + if msg.ViaBotID != 0 { + userIDs[msg.ViaBotID] = struct{}{} + } if msg.ReplyTo != nil { addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs) } + if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 { + userIDs[msg.Media.Contact.UserID] = struct{}{} + } + collectPollMediaUserRefs(msg.Media, userIDs) + collectTodoMediaUserRefs(msg.Media, userIDs) if msg.Reactions != nil { for _, reaction := range msg.Reactions.Recent { if reaction.UserID != 0 { @@ -109,6 +231,29 @@ func collectMessagePeerRefs(msg domain.Message, currentChannelID int64, userIDs, } } +// collectPollMediaUserRefs 收集 poll recent voters(公开投票头像渲染需要 user 实体)。 +func collectPollMediaUserRefs(media *domain.MessageMedia, userIDs map[int64]struct{}) { + if media == nil || media.Poll == nil || media.Poll.Results == nil { + return + } + for _, id := range media.Poll.Results.RecentVoters { + if id != 0 { + userIDs[id] = struct{}{} + } + } +} + +func collectTodoMediaUserRefs(media *domain.MessageMedia, userIDs map[int64]struct{}) { + if media == nil || media.Todo == nil { + return + } + for _, completion := range media.Todo.Completions { + if completion.CompletedBy != 0 { + userIDs[completion.CompletedBy] = struct{}{} + } + } +} + func collectChannelUpdatePeerRefs(event domain.ChannelUpdateEvent, currentChannelID int64, userIDs, channelIDs map[int64]struct{}) { if event.SenderUserID != 0 { userIDs[event.SenderUserID] = struct{}{} @@ -140,15 +285,34 @@ func collectChannelMessagePeerRefs(msg domain.ChannelMessage, currentChannelID i if msg.Forward != nil { addDomainPeerRef(msg.Forward.From, currentChannelID, userIDs, channelIDs) } + if msg.ViaBotID != 0 { + userIDs[msg.ViaBotID] = struct{}{} + } if msg.ReplyTo != nil { addDomainPeerRef(msg.ReplyTo.Peer, currentChannelID, userIDs, channelIDs) } + if msg.Media != nil && msg.Media.Contact != nil && msg.Media.Contact.UserID != 0 { + userIDs[msg.Media.Contact.UserID] = struct{}{} + } + collectPollMediaUserRefs(msg.Media, userIDs) + collectTodoMediaUserRefs(msg.Media, userIDs) if msg.Action != nil { for _, id := range msg.Action.UserIDs { if id != 0 { userIDs[id] = struct{}{} } } + if msg.Action.StarGift != nil { + if id := msg.Action.StarGift.FromUserID; id != 0 && !msg.Action.StarGift.NameHidden { + userIDs[id] = struct{}{} + } + if id := msg.Action.StarGift.PeerUserID; id != 0 { + userIDs[id] = struct{}{} + } + if id := msg.Action.StarGift.PeerChannelID; id != 0 && id != currentChannelID { + channelIDs[id] = struct{}{} + } + } } if msg.Reactions != nil { for _, reaction := range msg.Reactions.Recent { @@ -172,78 +336,48 @@ func addDomainPeerRef(peer domain.Peer, currentChannelID int64, userIDs, channel } } +func removeKnownChannelRefs(channelIDs map[int64]struct{}, channels []domain.Channel) { + if len(channelIDs) == 0 || len(channels) == 0 { + return + } + for _, ch := range channels { + if ch.ID != 0 { + delete(channelIDs, ch.ID) + } + } +} + func (r *Router) domainUsersForIDs(ctx context.Context, currentUserID int64, ids []int64) []domain.User { if len(ids) == 0 { return nil } - out := make([]domain.User, 0, len(ids)) - seen := make(map[int64]struct{}, len(ids)) - for _, id := range ids { - if id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - switch { - case id == domain.OfficialSystemUserID: - out = append(out, r.withUserPresence(domain.OfficialSystemUser())) - case r.deps.Users == nil: - continue - case id == currentUserID: - if u, err := r.deps.Users.Self(ctx, currentUserID); err == nil && u.ID != 0 { - out = append(out, r.withUserPresence(u)) - } - default: - if u, found, err := r.deps.Users.ByID(ctx, currentUserID, id); err == nil && found { - out = append(out, r.withUserPresence(u)) - } - } - } - return out -} - -func (r *Router) domainChannelsForIDs(ctx context.Context, currentUserID int64, ids []int64) []domain.Channel { - if r.deps.Channels == nil || len(ids) == 0 { - return nil - } - out := make([]domain.Channel, 0, len(ids)) - seen := make(map[int64]struct{}, len(ids)) - for _, id := range ids { - if id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - view, err := r.deps.Channels.GetChannel(ctx, currentUserID, id) - if err != nil || view.Channel.ID == 0 { - continue - } - out = append(out, view.Channel) - } - return out + return newViewerPeerCache(r).usersForIDs(ctx, currentUserID, ids) } func mergeDomainUsers(base []domain.User, extra ...domain.User) []domain.User { - out := append([]domain.User(nil), base...) - seen := make(map[int64]struct{}, len(out)+len(extra)) - for _, u := range out { - if u.ID != 0 { - seen[u.ID] = struct{}{} + out := make([]domain.User, 0, len(base)+len(extra)) + index := make(map[int64]int, len(base)+len(extra)) + appendOrReplace := func(u domain.User, replace bool) { + if u.ID == 0 { + if !replace { + out = append(out, u) + } + return } + if i, ok := index[u.ID]; ok { + if replace { + out[i] = u + } + return + } + index[u.ID] = len(out) + out = append(out, u) + } + for _, u := range base { + appendOrReplace(u, false) } for _, u := range extra { - if u.ID == 0 { - continue - } - if _, ok := seen[u.ID]; ok { - continue - } - seen[u.ID] = struct{}{} - out = append(out, u) + appendOrReplace(u, true) } return out } diff --git a/internal/rpc/update_peer_refs_test.go b/internal/rpc/update_peer_refs_test.go new file mode 100644 index 00000000..f43c44b2 --- /dev/null +++ b/internal/rpc/update_peer_refs_test.go @@ -0,0 +1,30 @@ +package rpc + +import ( + "testing" + + "telesrv/internal/domain" +) + +func TestRemoveKnownChannelRefs(t *testing.T) { + refs := map[int64]struct{}{ + 1001: {}, + 1002: {}, + 1003: {}, + } + + removeKnownChannelRefs(refs, []domain.Channel{ + {ID: 1002}, + {ID: 0}, + {ID: 1004}, + }) + + if _, ok := refs[1002]; ok { + t.Fatalf("known channel ref was not removed: %+v", refs) + } + for _, id := range []int64{1001, 1003} { + if _, ok := refs[id]; !ok { + t.Fatalf("unexpectedly removed channel %d from refs %+v", id, refs) + } + } +} diff --git a/internal/rpc/updates.go b/internal/rpc/updates.go index f2fe915f..1d397e38 100644 --- a/internal/rpc/updates.go +++ b/internal/rpc/updates.go @@ -2,24 +2,23 @@ package rpc import ( "context" - "time" - "github.com/gotd/td/bin" "github.com/gotd/td/tg" "telesrv/internal/domain" ) -const updatesTooLongNudgeDelay = 300 * time.Millisecond -const legacyUpdatesGetDifferenceTypeID uint32 = 0x25939651 - // registerUpdates 注册 updates.* RPC handler。 func (r *Router) registerUpdates(d *tg.ServerDispatcher) { d.OnUpdatesGetState(r.onUpdatesGetState) d.OnUpdatesGetDifference(r.onUpdatesGetDifference) } -// onUpdatesGetState 处理 updates.getState(第一阶段返回零状态)。 +// onUpdatesGetState 处理 updates.getState:返回账号当前最新连续状态并推进该设备 +// 的确认水位。协议语义是客户端宣告「从现在开始同步」,启动期离线数据由 +// getDialogs 快照承载——返回设备旧确认水位会让 TDesktop(不持久化 pts、每次 +// 启动都调 getState)在 getDialogs 快照之上重放历史差分,未读重复累计、 +// dialog 预览被旧消息抢占。 func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error) { id, _ := AuthKeyIDFrom(ctx) userID, _, err := r.currentUserID(ctx) @@ -28,21 +27,17 @@ func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error } if r.deps.Updates == nil { r.markSessionReceivesUpdates(ctx, userID) - return &tg.UpdatesState{Date: int(r.clock.Now().Unix())}, nil + return &tg.UpdatesState{Date: int(r.clock.Now().Unix()), Qts: r.deviceEncryptedQts(ctx)}, nil } - st, err := r.deps.Updates.GetState(ctx, id, userID) - if err != nil { - return nil, internalErr() - } - current, err := r.deps.Updates.CurrentState(ctx, userID) + st, err := r.deps.Updates.AcknowledgeCurrentState(ctx, id, userID) if err != nil { return nil, internalErr() } r.markSessionReceivesUpdates(ctx, userID) - if current.Pts > st.Pts { - r.scheduleCurrentSessionDifferenceNudge(ctx) - } - return ptr(tgUpdateState(st)), nil + // 密聊 qts 是设备级、独立于账号级 pts 引擎:注入当前设备已分配的 qts(无密聊设备为 0)。 + out := tgUpdateState(st) + out.Qts = r.deviceEncryptedQts(ctx) + return ptr(out), nil } func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetDifferenceRequest) (tg.UpdatesDifferenceClass, error) { @@ -56,6 +51,16 @@ func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetD r.markSessionReceivesUpdates(ctx, userID) return &tg.UpdatesDifferenceEmpty{Date: now}, nil } + // pts_total_limit 是客户端显式请求的 fast-skip:差距超限时返回 + // differenceTooLong{pts},客户端据此整体重置会话列表而不是串行翻 + // 上千页 slice。不传该参数的客户端(TDesktop)永远不会收到 tooLong。 + if limit, ok := req.GetPtsTotalLimit(); ok && limit > 0 && req.Pts > 0 { + current, err := r.deps.Updates.CurrentState(ctx, userID) + if err == nil && current.Pts-req.Pts > limit { + r.markSessionReceivesUpdates(ctx, userID) + return &tg.UpdatesDifferenceTooLong{Pts: current.Pts}, nil + } + } st, err := r.deps.Updates.GetDifference(ctx, id, userID, domain.UpdateState{ Pts: req.Pts, Qts: req.Qts, @@ -66,67 +71,97 @@ func (r *Router) onUpdatesGetDifference(ctx context.Context, req *tg.UpdatesGetD } r.markSessionReceivesUpdates(ctx, userID) st.ChannelNudges = r.accountChannelDifferenceNudges(ctx, userID, req.Date) - if len(st.Events) == 0 && len(st.ChannelNudges) == 0 { + // 密聊设备级 qts 消息(独立于账号级 pts 事件):按当前设备 req.Qts 补回。 + encMsgs, newQts := r.encryptedDifference(ctx, req.Qts) + // 密聊握手/已读状态事件(无 qts):按未投递标记补回 OtherUpdates。 + stateUpdates, statePeerUserIDs, stateEventIDs := r.encryptedStateUpdates(ctx, userID) + if len(st.Events) == 0 && len(st.ChannelNudges) == 0 && len(encMsgs) == 0 && len(stateUpdates) == 0 { return &tg.UpdatesDifferenceEmpty{Date: st.State.Date, Seq: st.State.Seq}, nil } st.Events = r.enrichUpdateEvents(ctx, userID, st.Events) - return tgUpdatesDifference(st), nil -} - -func (r *Router) handleLegacyUpdatesGetDifference(ctx context.Context, b *bin.Buffer) (bin.Encoder, error) { - if err := b.ConsumeID(legacyUpdatesGetDifferenceTypeID); err != nil { - return nil, err - } - var flags bin.Fields - if err := flags.Decode(b); err != nil { - return nil, err - } - pts, err := b.Int() - if err != nil { - return nil, err - } - var ptsTotalLimit int - if flags.Has(0) { - ptsTotalLimit, err = b.Int() - if err != nil { - return nil, err + diff := r.tgUpdatesDifference(ctx, userID, st) + diff = injectEncryptedMessages(diff, encMsgs, newQts) + diff = r.injectEncryptedOtherUpdates(ctx, userID, diff, stateUpdates, statePeerUserIDs) + if len(stateEventIDs) > 0 { + if deviceKey, ok := businessAuthKeyIDFrom(ctx); ok { + _ = r.deps.SecretChats.MarkStateEventsDelivered(ctx, deviceKey, stateEventIDs) } } - date, err := b.Int() - if err != nil { - return nil, err - } - qts, err := b.Int() - if err != nil { - return nil, err - } - return r.onUpdatesGetDifference(ctx, &tg.UpdatesGetDifferenceRequest{ - Flags: flags, - Pts: pts, - PtsTotalLimit: ptsTotalLimit, - Date: date, - Qts: qts, - }) + return diff, nil } func (r *Router) accountChannelDifferenceNudges(ctx context.Context, userID int64, sinceDate int) []domain.ChannelDifferenceNudge { if r.deps.Channels == nil || userID == 0 || sinceDate <= 0 { return nil } - dirty, err := r.deps.Channels.DirtyActiveChannelsForUser(ctx, userID, sinceDate, 0, domain.MaxChannelDifferenceLimit) - if err != nil || len(dirty) == 0 { - return nil - } - out := make([]domain.ChannelDifferenceNudge, 0, len(dirty)) - for _, item := range dirty { - if item.ChannelID == 0 { - continue + // 按 channel_id 翻页注入全部 dirty channel 的 nudge(仍有总量硬上限), + // 避免加入超过一页活跃频道的账号在长离线恢复时漏掉 channel_id 较大的频道。 + const maxNudges = 500 + out := make([]domain.ChannelDifferenceNudge, 0, domain.MaxChannelDifferenceLimit) + afterChannelID := int64(0) + for len(out) < maxNudges { + dirty, err := r.deps.Channels.DirtyActiveChannelsForUser(ctx, userID, sinceDate, afterChannelID, domain.MaxChannelDifferenceLimit) + if err != nil || len(dirty) == 0 { + break + } + channelIDs := make([]int64, 0, len(dirty)) + for _, item := range dirty { + if item.ChannelID != 0 { + channelIDs = append(channelIDs, item.ChannelID) + } + } + viewsByID := make(map[int64]domain.ChannelView, len(channelIDs)) + if len(channelIDs) != 0 { + if views, err := r.deps.Channels.GetChannels(ctx, userID, channelIDs); err == nil { + for _, view := range views { + if view.Channel.ID != 0 { + viewsByID[view.Channel.ID] = view + } + } + } + } + for _, item := range dirty { + if item.ChannelID == 0 { + continue + } + nudge := domain.ChannelDifferenceNudge{ChannelID: item.ChannelID, Pts: item.Pts} + if view, ok := viewsByID[item.ChannelID]; ok { + nudge.Channel = &view + } + out = append(out, nudge) + if item.ChannelID > afterChannelID { + afterChannelID = item.ChannelID + } + } + if len(dirty) < domain.MaxChannelDifferenceLimit { + break } - out = append(out, domain.ChannelDifferenceNudge{ChannelID: item.ChannelID, Pts: item.Pts}) } return out } +// maybeMarkSessionReceivesUpdates 把已登录连接发出的裸 RPC(未包 invokeWithoutUpdates) +// 视为该 session 的 updates 接收声明,对齐官方语义:客户端只在主连接上发裸请求, +// media/temp 连接一律带 invokeWithoutUpdates 包装。已在接收的 session 直接短路, +// 避免每条 RPC 重复同步 channel membership。 +func (r *Router) maybeMarkSessionReceivesUpdates(ctx context.Context) { + if invokeWithoutUpdatesFrom(ctx) { + return + } + userID, ok := UserIDFrom(ctx) + if !ok { + return + } + if provider, ok := r.deps.Sessions.(SessionUpdatesStateProvider); ok { + rawAuthKeyID, okRaw := RawAuthKeyIDFrom(ctx) + sessionID, okSess := SessionIDFrom(ctx) + if okRaw && okSess && provider.ReceivesUpdatesForAuthKey(rawAuthKeyID, sessionID) { + return + } + } + r.markSessionReceivesUpdates(ctx, userID) +} + func (r *Router) markSessionReceivesUpdates(ctx context.Context, userID int64) { if r.deps.Sessions == nil { return @@ -145,20 +180,4 @@ func (r *Router) markSessionReceivesUpdates(ctx context.Context, userID int64) { r.deps.Sessions.SetReceivesUpdates(sessionID, true) } -func (r *Router) scheduleCurrentSessionDifferenceNudge(ctx context.Context) { - pushCtx := context.Background() - if sessionID, ok := SessionIDFrom(ctx); ok { - pushCtx = WithSessionID(pushCtx, sessionID) - } - if rawAuthKeyID, ok := RawAuthKeyIDFrom(ctx); ok { - pushCtx = WithRawAuthKeyID(pushCtx, rawAuthKeyID) - } - if authKeyID, ok := AuthKeyIDFrom(ctx); ok { - pushCtx = WithAuthKeyID(pushCtx, authKeyID) - } - time.AfterFunc(updatesTooLongNudgeDelay, func() { - r.pushCurrentSessionMessage(pushCtx, "push updatesTooLong after getState", &tg.UpdatesTooLong{}) - }) -} - func ptr[T any](v T) *T { return &v } diff --git a/internal/rpc/updates_rpc_test.go b/internal/rpc/updates_rpc_test.go new file mode 100644 index 00000000..963f614f --- /dev/null +++ b/internal/rpc/updates_rpc_test.go @@ -0,0 +1,572 @@ +package rpc + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/gotd/td/clock" + "github.com/gotd/td/proto" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + appchannels "telesrv/internal/app/channels" + appupdates "telesrv/internal/app/updates" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestPushLoginMessageSendsUpdateNewMessage(t *testing.T) { + sessions := &captureSessions{} + r := New(Config{}, Deps{Sessions: sessions}, zaptest.NewLogger(t), clock.System) + msg := domain.Message{ + ID: 99, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000100, + Body: "Login code: 12345", + Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 11}}, + } + event := domain.UpdateEvent{Type: domain.UpdateEventNewMessage, Pts: 4, PtsCount: 1, Date: msg.Date, Message: msg} + state := domain.UpdateState{Pts: 4, Date: 1700000100, Seq: 2} + + r.pushLoginMessage(context.Background(), [8]byte{}, 55, event, state) + + gotSession := sessions.snapshot() + if gotSession.sessionID != 55 || gotSession.messageType != proto.MessageFromServer { + t.Fatalf("push target = session %d type %v, want session 55 MessageFromServer", gotSession.sessionID, gotSession.messageType) + } + got, ok := gotSession.message.(*tg.Updates) + if !ok { + t.Fatalf("pushed message = %T, want *tg.Updates", gotSession.message) + } + if len(got.Updates) != 1 || len(got.Users) != 1 { + t.Fatalf("updates payload = %+v, want one update and official user", got) + } + update, ok := got.Updates[0].(*tg.UpdateNewMessage) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateNewMessage", got.Updates[0]) + } + if update.Pts != event.Pts || update.PtsCount != event.PtsCount || got.Seq != state.Seq { + t.Fatalf("update state = pts %d pts_count %d seq %d, want %d/%d/%d", update.Pts, update.PtsCount, got.Seq, event.Pts, event.PtsCount, state.Seq) + } + message, ok := update.Message.(*tg.Message) + if !ok { + t.Fatalf("update message = %T, want *tg.Message", update.Message) + } + if message.ID != msg.ID || message.Message != msg.Body { + t.Fatalf("message = %+v, want login message %+v", message, msg) + } + user, ok := got.Users[0].(*tg.User) + if !ok || user.ID != domain.OfficialSystemUserID || !user.Verified || !user.Support { + t.Fatalf("user = %#v, want verified official system user", got.Users[0]) + } +} + +func TestSignInServiceNotificationMatchesEnterpriseShape(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 9 + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + ctx := WithClientInfo(context.Background(), ClientInfo{ + DeviceModel: "Telegram Desktop", + SystemVersion: "Windows", + AppVersion: "6.8.4", + }) + + got := r.tgSignInServiceNotification(ctx, domain.User{ + ID: 1000000001, + FirstName: "Test", + LastName: "User", + }, authKeyID) + + if len(got.Updates) != 1 { + t.Fatalf("updates = %+v, want one service notification", got.Updates) + } + update, ok := got.Updates[0].(*tg.UpdateServiceNotification) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateServiceNotification", got.Updates[0]) + } + if update.Popup || update.InboxDate == 0 || update.Media == nil { + t.Fatalf("notification flags/media = popup %v inbox %d media %T", update.Popup, update.InboxDate, update.Media) + } + for _, want := range []string{"New login.", "Test User", "Telegram Desktop", "Settings > Devices"} { + if !strings.Contains(update.Message, want) { + t.Fatalf("notification message %q missing %q", update.Message, want) + } + } + if len(update.Entities) < 3 { + t.Fatalf("entities = %+v, want bold entities for title/settings links", update.Entities) + } +} + +func TestLogOutClearsSessionAndUpdateState(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 5 + auth := &captureAuthService{} + updates := &captureUpdates{} + sessions := &captureSessions{authKeyID: authKeyID, authKeyResolved: true, userID: 1000000001, userResolved: true} + r := New(Config{}, Deps{ + Auth: auth, + Updates: updates, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + _, err := r.onAuthLogOut(WithAuthKeyID(context.Background(), authKeyID)) + if err != nil { + t.Fatalf("auth.logOut: %v", err) + } + if auth.loggedOutAuthKeyID != authKeyID { + t.Fatalf("logged out auth key = %x, want %x", auth.loggedOutAuthKeyID, authKeyID) + } + if updates.clearedAuthKeyID != authKeyID || !updates.cleared { + t.Fatalf("cleared auth key = %x cleared=%v, want %x", updates.clearedAuthKeyID, updates.cleared, authKeyID) + } + gotSession := sessions.snapshot() + if gotSession.userID != 0 || !gotSession.userResolved { + t.Fatalf("session user after logout = %d resolved=%v, want 0/true", gotSession.userID, gotSession.userResolved) + } +} + +func TestSignInDifferentUserClearsAuthKeyUpdateState(t *testing.T) { + var authKeyID [8]byte + authKeyID[0] = 6 + auth := &captureAuthService{signInUser: domain.User{ID: 1000000002, FirstName: "Two"}} + updates := &captureUpdates{} + sessions := &captureSessions{authKeyID: authKeyID, authKeyResolved: true, userID: 1000000001, userResolved: true} + r := New(Config{}, Deps{ + Auth: auth, + Updates: updates, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + ctx := WithUserID(WithAuthKeyID(WithSessionID(context.Background(), 77), authKeyID), 1000000001) + + _, err := r.onAuthSignIn(ctx, &tg.AuthSignInRequest{PhoneNumber: "15550000002", PhoneCodeHash: "hash", PhoneCode: "12345"}) + if err != nil { + t.Fatalf("auth.signIn: %v", err) + } + if updates.clearedAuthKeyID != authKeyID || !updates.cleared { + t.Fatalf("cleared auth key = %x cleared=%v, want %x", updates.clearedAuthKeyID, updates.cleared, authKeyID) + } + gotSession := sessions.snapshot() + if gotSession.userID != 1000000002 { + t.Fatalf("session user = %d, want new user 1000000002", gotSession.userID) + } +} + +func TestUpdatesGetStateMarksSessionReadyForPush(t *testing.T) { + sessions := &captureSessions{} + r := New(Config{}, Deps{ + Sessions: sessions, + Updates: &captureUpdates{state: domain.UpdateState{Pts: 3, Date: 1700000000, Seq: 2}}, + }, zaptest.NewLogger(t), clock.System) + + got, err := r.onUpdatesGetState(WithSessionID(context.Background(), 77)) + if err != nil { + t.Fatalf("updates.getState: %v", err) + } + if got.Pts != 3 || got.Seq != 2 { + t.Fatalf("state = %+v, want pts=3 seq=2", got) + } + gotSession := sessions.snapshot() + if gotSession.sessionID != 77 || !gotSession.receives { + t.Fatalf("session ready = id %d receives %v, want 77/true", gotSession.sessionID, gotSession.receives) + } +} + +// TestUpdatesGetStateReturnsAccountCurrentState 复现 TDesktop 冷启动未读重复: +// getState 必须返回账号当前最新状态(而非设备旧确认水位),且不得再推 +// updatesTooLong 诱导差分——TDesktop 不持久化 pts,启动期离线数据由 +// getDialogs 快照承载,旧水位差分会把快照里已计入的消息重放一遍 +// (实测未读 2→3、dialog 预览被旧消息抢占)。 +func TestUpdatesGetStateReturnsAccountCurrentState(t *testing.T) { + sessions := &captureSessions{} + current := domain.UpdateState{Pts: 4, Date: 1700000001} + updates := &captureUpdates{ + state: domain.UpdateState{Pts: 3, Date: 1700000000}, + currentState: ¤t, + } + r := New(Config{}, Deps{ + Sessions: sessions, + Updates: updates, + }, zaptest.NewLogger(t), clock.System) + + got, err := r.onUpdatesGetState(WithSessionID(context.Background(), 77)) + if err != nil { + t.Fatalf("updates.getState: %v", err) + } + if got.Pts != 4 { + t.Fatalf("state pts = %d, want account current 4 (per-key stale=3 会触发客户端重放)", got.Pts) + } + if !updates.acknowledged { + t.Fatal("getState 未推进设备确认水位") + } + time.Sleep(500 * time.Millisecond) + if msg := sessions.snapshot().message; msg != nil { + if _, ok := msg.(*tg.UpdatesTooLong); ok { + t.Fatal("getState 后不应再推 updatesTooLong(会诱导 TDesktop 重放快照前差分)") + } + } +} + +func TestUpdatesDifferenceIncludesLoginMessageAndOfficialUser(t *testing.T) { + msg := domain.Message{ + ID: 88, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}, + Date: 1700000100, + Body: "Login code: 12345", + } + got, ok := tgUpdatesDifference(0, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 5, Date: msg.Date, Seq: 4}, + Events: []domain.UpdateEvent{{ + Type: domain.UpdateEventNewMessage, + Pts: 5, + PtsCount: 1, + Date: msg.Date, + Message: msg, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if got.State.Pts != 5 || len(got.NewMessages) != 1 || len(got.Users) != 1 { + t.Fatalf("difference = %+v, want one message, one official user, pts=5", got) + } + user, ok := got.Users[0].(*tg.User) + if !ok || user.ID != domain.OfficialSystemUserID { + t.Fatalf("user = %#v, want official system user", got.Users[0]) + } +} + +func TestUpdatesDifferenceMarksViewerUserAsSelf(t *testing.T) { + viewerID := int64(1000000001) + msg := domain.Message{ + ID: 90, + OwnerUserID: viewerID, + Out: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: viewerID}, + Date: 1700000102, + Body: "self probe", + } + got, ok := tgUpdatesDifference(viewerID, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 7, Date: msg.Date}, + Events: []domain.UpdateEvent{{ + UserID: viewerID, + Type: domain.UpdateEventNewMessage, + Pts: 7, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Users: []domain.User{ + {ID: viewerID, FirstName: "Me"}, + {ID: 1000000002, FirstName: "Peer"}, + }, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + var sawSelf, sawPeer bool + for _, item := range got.Users { + user, ok := item.(*tg.User) + if !ok { + t.Fatalf("user = %#v, want *tg.User", item) + } + switch user.ID { + case viewerID: + sawSelf = true + if !user.Self { + t.Fatalf("viewer user = %#v, want self flag set: clients persist this object as the current account", user) + } + case 1000000002: + sawPeer = true + if user.Self { + t.Fatalf("peer user = %#v, must not carry self flag", user) + } + } + } + if !sawSelf || !sawPeer { + t.Fatalf("users = %+v, want viewer and peer entries", got.Users) + } +} + +func TestUpdatesDifferenceIncludesForwardSourceChannelChat(t *testing.T) { + source := domain.Channel{ + ID: 2000000001, + AccessHash: 9001, + Title: "Source Channel", + Broadcast: true, + Date: 1700000000, + } + msg := domain.Message{ + ID: 89, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000001}, + Date: 1700000101, + Body: "forwarded", + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000}, + } + got, ok := tgUpdatesDifference(0, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 6, Date: msg.Date}, + Events: []domain.UpdateEvent{{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: 6, + PtsCount: 1, + Date: msg.Date, + Message: msg, + Channels: []domain.Channel{source}, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if len(got.Chats) != 1 { + t.Fatalf("chats = %+v, want source channel", got.Chats) + } + ch, ok := got.Chats[0].(*tg.Channel) + if !ok || ch.ID != source.ID || ch.Title != source.Title { + t.Fatalf("chat = %#v, want source channel", got.Chats[0]) + } +} + +func TestOutboxEventIncludesForwardSourceChannelChat(t *testing.T) { + source := domain.Channel{ + ID: 2000000002, + AccessHash: 9002, + Title: "Forward Source", + Broadcast: true, + Date: 1700000000, + } + update := tgUpdateForOutboxEvent(domain.UpdateEvent{ + UserID: 1000000001, + Type: domain.UpdateEventNewMessage, + Pts: 7, + PtsCount: 1, + Date: 1700000102, + Message: domain.Message{ + ID: 90, + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + Date: 1700000102, + Body: "forwarded", + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, Date: 1700000000}, + }, + Channels: []domain.Channel{source}, + }) + if update == nil || len(update.Chats) != 1 { + t.Fatalf("update = %+v, want source channel chat", update) + } + ch, ok := update.Chats[0].(*tg.Channel) + if !ok || ch.ID != source.ID { + t.Fatalf("chat = %#v, want source channel", update.Chats[0]) + } +} + +func TestOutboxEventChannelViewForumAsMessages(t *testing.T) { + update := tgUpdateForOutboxEvent(domain.UpdateEvent{ + UserID: 1000000001, + Type: domain.UpdateEventChannelViewForum, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 2000000002}, + Bool: true, + Date: 1700000200, + }) + if update == nil || len(update.Updates) != 1 { + t.Fatalf("update = %+v, want one updateChannelViewForumAsMessages", update) + } + got, ok := update.Updates[0].(*tg.UpdateChannelViewForumAsMessages) + if !ok || got.ChannelID != 2000000002 || !got.Enabled { + t.Fatalf("update = %#v, want channel view-forum-as-messages enabled", update.Updates[0]) + } +} + +func TestUpdatesDifferenceIncludesChannelTooLongNudge(t *testing.T) { + const viewerID int64 = 1000000002 + got, ok := tgUpdatesDifference(viewerID, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 8, Date: 1700000250, Seq: 0}, + ChannelNudges: []domain.ChannelDifferenceNudge{{ + ChannelID: 2000000001, + Pts: 12, + Channel: &domain.ChannelView{ + Channel: domain.Channel{ + ID: 2000000001, + AccessHash: 9100000001, + Title: "Dirty group", + Megagroup: true, + Date: 1700000200, + }, + Self: domain.ChannelMember{ + ChannelID: 2000000001, + UserID: viewerID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + }, + }, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if got.State.Pts != 8 || len(got.OtherUpdates) != 1 || len(got.Chats) != 1 { + t.Fatalf("difference = %+v, want one channel nudge, one chat and account pts unchanged", got) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateChannelTooLong) + if !ok || update.ChannelID != 2000000001 { + t.Fatalf("update = %T %+v, want UpdateChannelTooLong", got.OtherUpdates[0], got.OtherUpdates[0]) + } + if pts, ok := update.GetPts(); !ok || pts != 12 { + t.Fatalf("channel nudge pts = %d set=%v, want 12", pts, ok) + } + chat, ok := got.Chats[0].(*tg.Channel) + if !ok || chat.ID != 2000000001 || chat.Min { + t.Fatalf("chat = %T %+v, want full channel chat for channel nudge", got.Chats[0], got.Chats[0]) + } +} + +func TestUpdatesDifferenceChannelNudgeUpgradesExistingMinChat(t *testing.T) { + const viewerID int64 = 1000000001 + channel := domain.Channel{ + ID: 2000000003, + AccessHash: 9100000003, + CreatorUserID: viewerID, + Title: "Offline created group", + Megagroup: true, + Date: 1700000300, + } + got, ok := tgUpdatesDifference(viewerID, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 9, Date: 1700000310, Seq: 0}, + Events: []domain.UpdateEvent{{ + UserID: viewerID, + Type: domain.UpdateEventChannelState, + Pts: 8, + PtsCount: 1, + Date: 1700000300, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, + Channels: []domain.Channel{channel}, + }}, + ChannelNudges: []domain.ChannelDifferenceNudge{{ + ChannelID: channel.ID, + Pts: 3, + Channel: &domain.ChannelView{ + Channel: channel, + Self: domain.ChannelMember{ + ChannelID: channel.ID, + UserID: viewerID, + Role: domain.ChannelRoleCreator, + Status: domain.ChannelMemberActive, + }, + }, + }}, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if len(got.OtherUpdates) != 2 || len(got.Chats) != 1 { + t.Fatalf("difference = %+v, want channel state, nudge and one deduped chat", got) + } + chat, ok := got.Chats[0].(*tg.Channel) + if !ok || chat.ID != channel.ID || chat.Min || !chat.Creator { + t.Fatalf("chat = %T %+v, want upgraded full creator channel for Android unknown-channel recovery", got.Chats[0], got.Chats[0]) + } +} + +func TestUpdatesGetDifferenceChannelNudgeIncludesFullChat(t *testing.T) { + ctx := context.Background() + const ( + ownerID int64 = 1000000101 + memberID int64 = 1000000102 + ) + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, ownerID, domain.CreateChannelRequest{ + Title: "Android visible group", + Date: 1700000400, + MemberUserIDs: []int64{memberID}, + }) + if err != nil { + t.Fatalf("CreateMegagroupFromCreateChat: %v", err) + } + updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()) + r := New(Config{}, Deps{ + Updates: updateSvc, + Channels: channelSvc, + }, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000410, 0)}) + + diff, err := r.onUpdatesGetDifference(WithUserID(ctx, memberID), &tg.UpdatesGetDifferenceRequest{ + Date: 1700000399, + }) + if err != nil { + t.Fatalf("updates.getDifference: %v", err) + } + got, ok := diff.(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", diff) + } + if len(got.OtherUpdates) != 1 || len(got.Chats) != 1 { + t.Fatalf("difference = %+v, want channel nudge with chat", got) + } + update, ok := got.OtherUpdates[0].(*tg.UpdateChannelTooLong) + if !ok || update.ChannelID != created.Channel.ID { + t.Fatalf("update = %T %+v, want updateChannelTooLong for channel %d", got.OtherUpdates[0], got.OtherUpdates[0], created.Channel.ID) + } + chat, ok := got.Chats[0].(*tg.Channel) + if !ok || chat.ID != created.Channel.ID || chat.Min { + t.Fatalf("chat = %T %+v, want full channel chat for Android unknown-channel recovery", got.Chats[0], got.Chats[0]) + } + if chat.AccessHash == 0 { + t.Fatalf("chat access_hash = 0, want full channel access hash") + } +} + +func TestUpdatesDifferenceIncludesSettingsUpdates(t *testing.T) { + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + got, ok := tgUpdatesDifference(0, domain.UpdateDifference{ + State: domain.UpdateState{Pts: 6, Date: 1700000300, Seq: 0}, + Events: []domain.UpdateEvent{ + {Type: domain.UpdateEventContactsReset, Pts: 1, PtsCount: 1, Date: 1700000300}, + {Type: domain.UpdateEventDialogPinned, Pts: 2, PtsCount: 1, Date: 1700000300, Peer: peer, Bool: true}, + {Type: domain.UpdateEventPinnedDialogs, Pts: 3, PtsCount: 1, Date: 1700000300, Peers: []domain.Peer{peer}}, + {Type: domain.UpdateEventDialogUnreadMark, Pts: 4, PtsCount: 1, Date: 1700000300, Peer: peer, Bool: false}, + {Type: domain.UpdateEventPeerSettings, Pts: 5, PtsCount: 1, Date: 1700000300, Peer: peer, Settings: domain.PeerSettings{ShareContact: true}}, + {Type: domain.UpdateEventPeerStoryBlocked, Pts: 6, PtsCount: 1, Date: 1700000300, Peer: peer, Bool: true}, + }, + }).(*tg.UpdatesDifference) + if !ok { + t.Fatalf("difference = %T, want *tg.UpdatesDifference", got) + } + if got.State.Pts != 6 || len(got.OtherUpdates) != 6 { + t.Fatalf("difference = %+v, want six settings updates and pts=6", got) + } + if _, ok := got.OtherUpdates[0].(*tg.UpdateContactsReset); !ok { + t.Fatalf("update[0] = %T, want contacts reset", got.OtherUpdates[0]) + } + pinned, ok := got.OtherUpdates[1].(*tg.UpdateDialogPinned) + if !ok || !pinned.Pinned { + t.Fatalf("update[1] = %+v (%T), want pinned dialog", got.OtherUpdates[1], got.OtherUpdates[1]) + } + pinnedDialogs, ok := got.OtherUpdates[2].(*tg.UpdatePinnedDialogs) + if !ok || len(pinnedDialogs.Order) != 1 { + t.Fatalf("update[2] = %T, want pinned dialogs", got.OtherUpdates[2]) + } + unread, ok := got.OtherUpdates[3].(*tg.UpdateDialogUnreadMark) + if !ok || unread.Unread { + t.Fatalf("update[3] = %+v (%T), want unread=false mark", got.OtherUpdates[3], got.OtherUpdates[3]) + } + peerSettings, ok := got.OtherUpdates[4].(*tg.UpdatePeerSettings) + if !ok || !peerSettings.Settings.ShareContact { + t.Fatalf("update[4] = %T, want peer settings", got.OtherUpdates[4]) + } + peerBlocked, ok := got.OtherUpdates[5].(*tg.UpdatePeerBlocked) + if !ok || !peerBlocked.Blocked || !peerBlocked.BlockedMyStoriesFrom { + t.Fatalf("update[5] = %+v (%T), want story peer blocked", got.OtherUpdates[5], got.OtherUpdates[5]) + } + if peerUser, ok := peerBlocked.PeerID.(*tg.PeerUser); !ok || peerUser.UserID != peer.ID { + t.Fatalf("update[5] peer = %#v, want user %d", peerBlocked.PeerID, peer.ID) + } +} diff --git a/internal/rpc/upload.go b/internal/rpc/upload.go index 00cf537d..0a4cfaad 100644 --- a/internal/rpc/upload.go +++ b/internal/rpc/upload.go @@ -11,12 +11,17 @@ import ( "telesrv/internal/domain" ) -// registerUpload 注册 upload.* RPC handler(分片上传 + 文件下载)。 +// Telegram 客户端按 chunk 调 upload.getFile;单次响应限制为 1MiB, +// 防止异常客户端用超大/空 limit 触发整 blob 读回。 +const maxUploadGetFileChunkLimit = 1 << 20 + +// registerUpload 注册 upload.* RPC handler(分片上传 + 文件下载 + 地图 webfile)。 func (r *Router) registerUpload(d *tg.ServerDispatcher) { d.OnUploadSaveFilePart(r.onUploadSaveFilePart) d.OnUploadSaveBigFilePart(r.onUploadSaveBigFilePart) d.OnUploadGetFile(r.onUploadGetFile) d.OnUploadGetFileHashes(r.onUploadGetFileHashes) + r.registerUploadWebFile(d) } func (r *Router) onUploadSaveFilePart(ctx context.Context, req *tg.UploadSaveFilePartRequest) (bool, error) { @@ -65,6 +70,9 @@ func (r *Router) onUploadGetFile(ctx context.Context, req *tg.UploadGetFileReque if r.deps.Files == nil { return nil, notImplementedErr() } + if req.Offset < 0 || req.Limit <= 0 || req.Limit > maxUploadGetFileChunkLimit { + return nil, limitInvalidErr() + } key, ok := fileLocationKey(req.Location) if !ok { return nil, locationInvalidErr() @@ -100,6 +108,8 @@ func (r *Router) onUploadGetFileHashes(ctx context.Context, req *tg.UploadGetFil // photo:: 照片某尺寸(头像 big→c / small→a) func fileLocationKey(location tg.InputFileLocationClass) (string, bool) { switch loc := location.(type) { + case *tg.InputFileLocation: + return legacyVolumeLocationKey(loc.VolumeID, loc.LocalID) case *tg.InputDocumentFileLocation: if loc.ID == 0 { return "", false @@ -122,12 +132,82 @@ func fileLocationKey(location tg.InputFileLocationClass) (string, bool) { size = "c" } return fmt.Sprintf("photo:%d:%s", loc.PhotoID, size), true + case *tg.InputPhotoLegacyFileLocation: + photoID := loc.ID + if photoID == 0 && loc.VolumeID < 0 { + photoID = -loc.VolumeID + } + if photoID == 0 { + return "", false + } + size, ok := legacyPhotoSizeType(loc.LocalID) + if !ok { + return "", false + } + return fmt.Sprintf("photo:%d:%s", photoID, size), true + case *tg.InputPeerPhotoFileLocationLegacy: + photoID := int64(0) + if loc.VolumeID < 0 { + photoID = -loc.VolumeID + } + if photoID == 0 { + return "", false + } + size := "a" + if loc.Big { + size = "c" + } + return fmt.Sprintf("photo:%d:%s", photoID, size), true + case *tg.InputEncryptedFileLocation: + // 密聊文件(P2):盲 blob,location_key "enc:"。access_hash 不强校验 + // (沿用现有媒体 dev 姿态,依赖不可枚举 id)。 + if loc.ID == 0 { + return "", false + } + return fmt.Sprintf("enc:%d", loc.ID), true default: - // InputFileLocation(legacy volume/local/secret) / InputStickerSetThumb 等本阶段不生成对应资源。 + // InputStickerSetThumb / secure / takeout 等本阶段不生成对应资源。 return "", false } } +func legacyVolumeLocationKey(volumeID int64, localID int) (string, bool) { + if volumeID >= 0 || localID <= 0 { + return "", false + } + id := -volumeID + if size, ok := legacyPhotoSizeType(localID); ok { + return fmt.Sprintf("photo:%d:%s", id, size), true + } + if size, ok := legacyDocumentThumbSizeType(localID); ok { + return fmt.Sprintf("doc:%d:%s", id, size), true + } + return "", false +} + +func legacyPhotoSizeType(localID int) (string, bool) { + if localID < 1 || localID > 127 { + return "", false + } + ch := byte(localID) + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') { + return string(ch), true + } + return "", false +} + +func legacyDocumentThumbSizeType(localID int) (string, bool) { + localID -= 1000 + if localID < 1 || localID > 127 { + return "", false + } + ch := byte(localID) + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') { + return string(ch), true + } + return "", false +} + // storageFileType 映射 storage.FileType,优先信任字节魔数以兼容历史上写错 mime 的 seed blob。 func storageFileType(mime string, data []byte) tg.StorageFileTypeClass { switch sniffImageType(data) { @@ -182,6 +262,8 @@ func fileSaveErr(err error) error { return filePartsInvalidErr() case errors.Is(err, domain.ErrFilePartTooBig): return filePartTooBigErr() + case errors.Is(err, domain.ErrUploadQuotaExceeded): + return floodWaitErr(60) default: return internalErr() } diff --git a/internal/rpc/upload_test.go b/internal/rpc/upload_test.go index 52d6c70c..ef6fe62c 100644 --- a/internal/rpc/upload_test.go +++ b/internal/rpc/upload_test.go @@ -1,9 +1,11 @@ package rpc import ( + "context" "testing" "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" ) func TestStorageFileTypePrefersMagicOverMime(t *testing.T) { @@ -19,6 +21,33 @@ func TestStorageFileTypeFallsBackToMime(t *testing.T) { } } +func TestUploadGetFileRejectsInvalidRanges(t *testing.T) { + r := &Router{deps: Deps{Files: &fakeFiles{}}} + location := &tg.InputDocumentFileLocation{ID: 42} + tests := []struct { + name string + offset int64 + limit int + }{ + {name: "negative offset", offset: -1, limit: 1}, + {name: "zero limit", offset: 0, limit: 0}, + {name: "negative limit", offset: 0, limit: -1}, + {name: "too large limit", offset: 0, limit: maxUploadGetFileChunkLimit + 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := r.onUploadGetFile(context.Background(), &tg.UploadGetFileRequest{ + Location: location, + Offset: tt.offset, + Limit: tt.limit, + }) + if !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("err = %v, want LIMIT_INVALID", err) + } + }) + } +} + func TestFileLocationKeyUsesDocumentID(t *testing.T) { key, ok := fileLocationKey(&tg.InputDocumentFileLocation{ ID: 1382305375846410902, @@ -32,3 +61,67 @@ func TestFileLocationKeyUsesDocumentID(t *testing.T) { t.Fatalf("key = %q, want %q", key, want) } } + +func TestFileLocationKeyMapsLegacyAndroidPhotoLocations(t *testing.T) { + tests := []struct { + name string + location tg.InputFileLocationClass + want string + }{ + { + name: "plain small avatar", + location: &tg.InputFileLocation{VolumeID: -3999, LocalID: int('a')}, + want: "photo:3999:a", + }, + { + name: "plain big avatar", + location: &tg.InputFileLocation{VolumeID: -3999, LocalID: int('c')}, + want: "photo:3999:c", + }, + { + name: "plain animated avatar video", + location: &tg.InputFileLocation{VolumeID: -3999, LocalID: int('u')}, + want: "photo:3999:u", + }, + { + name: "photo legacy with id", + location: &tg.InputPhotoLegacyFileLocation{ID: 4001, VolumeID: -3999, LocalID: int('a')}, + want: "photo:4001:a", + }, + { + name: "peer legacy big", + location: &tg.InputPeerPhotoFileLocationLegacy{VolumeID: -4002, LocalID: int('a'), Big: true, Peer: &tg.InputPeerSelf{}}, + want: "photo:4002:c", + }, + { + name: "document thumb", + location: &tg.InputFileLocation{VolumeID: -1129957402753368786, LocalID: 1000 + int('m')}, + want: "doc:1129957402753368786:m", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, ok := fileLocationKey(tt.location) + if !ok { + t.Fatal("fileLocationKey returned !ok") + } + if key != tt.want { + t.Fatalf("key = %q, want %q", key, tt.want) + } + }) + } +} + +func TestFileLocationKeyRejectsUnknownLegacyLocations(t *testing.T) { + tests := []tg.InputFileLocationClass{ + &tg.InputFileLocation{VolumeID: 3999, LocalID: int('a')}, + &tg.InputFileLocation{VolumeID: -3999, LocalID: 7}, + &tg.InputPhotoLegacyFileLocation{VolumeID: -3999, LocalID: 7}, + } + for _, location := range tests { + if key, ok := fileLocationKey(location); ok { + t.Fatalf("fileLocationKey(%T) = %q, true; want false", location, key) + } + } +} diff --git a/internal/rpc/upload_webfile.go b/internal/rpc/upload_webfile.go new file mode 100644 index 00000000..2f252548 --- /dev/null +++ b/internal/rpc/upload_webfile.go @@ -0,0 +1,267 @@ +package rpc + +import ( + "context" + "errors" + "io" + "math" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// validFiniteCoord 校验坐标是有限值且在 [-bound, bound] 内(NaN/±Inf 一律拒绝)。 +func validFiniteCoord(v, bound float64) bool { + return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= -bound && v <= bound +} + +// 本文件实现 upload.getWebFile。当前唯一真实路径是 geo/venue 消息的地图缩略图 +// (inputWebFileGeoPointLocation):TDesktop 与 DrKLO Android 渲染位置消息时都会调它。 +// 地图图像由 files 服务提供:配置 Mapbox 代理时为真实静态地图(落盘缓存保证分片一致), +// 否则确定性占位合成(见 app/files/maptile.go 与 maptile_proxy.go); +// access_hash 不做校验(发送时随机生成且未持久化,地图缩略图无机密性可言)。 +// URL 形态(inputWebFileLocation)只代理 inline registry 中已登记的 web document; +// 任意客户端自造 URL/access_hash 仍显式拒绝,避免把服务端变成开放代理。 + +// maxWebFileChunkLimit 与 upload.getFile 的单次上限一致,防止客户端超大 limit 放大内存。 +const ( + maxWebFileChunkLimit = maxUploadGetFileChunkLimit + inlineWebFileFetchTimeout = 10 * time.Second +) + +type inlineWebFileFetcher func(ctx context.Context, document domain.BotInlineWebDocument) ([]byte, string, error) + +var fetchInlineWebFile inlineWebFileFetcher = defaultFetchInlineWebFile + +func (r *Router) registerUploadWebFile(d *tg.ServerDispatcher) { + d.OnUploadGetWebFile(r.onUploadGetWebFile) +} + +func (r *Router) onUploadGetWebFile(ctx context.Context, req *tg.UploadGetWebFileRequest) (*tg.UploadWebFile, error) { + if r.deps.Files == nil { + return nil, notImplementedErr() + } + userID, ok, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + if !ok || userID == 0 { + return nil, locationInvalidErr() + } + if req.Offset < 0 || req.Limit <= 0 || req.Limit > maxWebFileChunkLimit { + return nil, limitInvalidErr() + } + if loc, ok := req.Location.(*tg.InputWebFileLocation); ok { + return r.onUploadGetRegisteredWebFile(ctx, loc, req.Offset, req.Limit) + } + loc, ok := req.Location.(*tg.InputWebFileGeoPointLocation) + if !ok { + return nil, locationInvalidErr() + } + point, ok := loc.GeoPoint.(*tg.InputGeoPoint) + // NaN 与所有比较都为 false,会穿过区间校验,必须显式拒绝(NaN/Inf 会污染缓存 key 与上游 URL)。 + if !ok || !validFiniteCoord(point.Lat, 90) || !validFiniteCoord(point.Long, 180) { + return nil, locationInvalidErr() + } + full, mime := r.deps.Files.GeoMapTile(point.Lat, point.Long, loc.W, loc.H, loc.Zoom, loc.Scale) + if len(full) == 0 { + return nil, locationInvalidErr() + } + fileType := webFileStorageType(mime) + if req.Offset >= len(full) { + return &tg.UploadWebFile{ + Size: len(full), + MimeType: mime, + FileType: fileType, + Mtime: int(r.clock.Now().Unix()), + Bytes: []byte{}, + }, nil + } + end := req.Offset + req.Limit + if end > len(full) { + end = len(full) + } + return &tg.UploadWebFile{ + Size: len(full), + MimeType: mime, + FileType: fileType, + Mtime: int(r.clock.Now().Unix()), + Bytes: full[req.Offset:end], + }, nil +} + +func (r *Router) onUploadGetRegisteredWebFile(ctx context.Context, loc *tg.InputWebFileLocation, offset, limit int) (*tg.UploadWebFile, error) { + if loc.URL == "" || loc.AccessHash == 0 { + return nil, locationInvalidErr() + } + _, data, mime, err := r.registeredInlineWebDocumentBytes(ctx, loc.URL, loc.AccessHash) + if err != nil { + return nil, locationInvalidErr() + } + fileType := storageFileType(mime, data) + if offset >= len(data) { + return &tg.UploadWebFile{ + Size: len(data), + MimeType: mime, + FileType: fileType, + Mtime: int(r.clock.Now().Unix()), + Bytes: []byte{}, + }, nil + } + end := offset + limit + if end > len(data) { + end = len(data) + } + return &tg.UploadWebFile{ + Size: len(data), + MimeType: mime, + FileType: fileType, + Mtime: int(r.clock.Now().Unix()), + Bytes: append([]byte(nil), data[offset:end]...), + }, nil +} + +func (r *Router) registeredInlineWebDocumentBytes(ctx context.Context, url string, accessHash int64) (domain.BotInlineWebDocument, []byte, string, error) { + document, data, mime, ok := r.inlines.webDocumentForDownloadContext(ctx, r.clock.Now(), url, accessHash) + if !ok { + return domain.BotInlineWebDocument{}, nil, "", locationInvalidErr() + } + if len(data) == 0 { + fetched, fetchedMime, err := fetchInlineWebFile(ctx, document) + if err != nil { + return domain.BotInlineWebDocument{}, nil, "", err + } + if len(fetched) == 0 || len(fetched) > domain.MaxBotInlineWebSize { + return domain.BotInlineWebDocument{}, nil, "", locationInvalidErr() + } + data = fetched + mime = normalizeInlineWebFileMime(fetchedMime, data, document.MimeType) + r.inlines.cacheWebDocumentBytesContext(ctx, r.clock.Now(), document.URL, document.AccessHash, data, mime) + } + if mime == "" { + mime = document.MimeType + } + return document, data, mime, nil +} + +func webFileStorageType(mime string) tg.StorageFileTypeClass { + switch mime { + case "image/jpeg": + return &tg.StorageFileJpeg{} + case "image/webp": + return &tg.StorageFileWebp{} + default: + return &tg.StorageFilePng{} + } +} + +func defaultFetchInlineWebFile(ctx context.Context, document domain.BotInlineWebDocument) ([]byte, string, error) { + if err := validateInlineWebURL(document.URL, true); err != nil { + return nil, "", err + } + ctx, cancel := context.WithTimeout(ctx, inlineWebFileFetchTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, document.URL, nil) + if err != nil { + return nil, "", err + } + req.Header.Set("User-Agent", "telesrv-inline-webfile") + client := &http.Client{ + Timeout: inlineWebFileFetchTimeout, + Transport: &http.Transport{ + Proxy: nil, + DialContext: safeExternalDialContext, + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, "", errors.New("webfile fetch status " + strconv.Itoa(resp.StatusCode)) + } + if resp.ContentLength > int64(domain.MaxBotInlineWebSize) { + return nil, "", webDocumentSizeTooBigErr() + } + limited := io.LimitReader(resp.Body, int64(domain.MaxBotInlineWebSize)+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, "", err + } + if len(data) > domain.MaxBotInlineWebSize { + return nil, "", webDocumentSizeTooBigErr() + } + return data, resp.Header.Get("Content-Type"), nil +} + +func safeExternalDialContext(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + if port == "" { + port = "443" + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + var lastErr error + dialer := &net.Dialer{Timeout: 5 * time.Second} + for _, candidate := range ips { + if !safeExternalIP(candidate.IP) { + lastErr = errors.New("unsafe webfile host") + continue + } + conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(candidate.IP.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + } + if lastErr != nil { + return nil, lastErr + } + return nil, errors.New("webfile host has no safe address") +} + +func safeExternalIP(ip net.IP) bool { + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return false + } + if v4 := ip.To4(); v4 != nil { + first, second := v4[0], v4[1] + if first == 0 || first >= 224 || first == 127 || first == 169 && second == 254 { + return false + } + if first == 100 && second >= 64 && second <= 127 { + return false + } + if first == 198 && (second == 18 || second == 19) { + return false + } + } + return true +} + +func normalizeInlineWebFileMime(header string, data []byte, fallback string) string { + mime := strings.TrimSpace(strings.Split(header, ";")[0]) + if !validInlineWebMime(mime) { + mime = http.DetectContentType(data) + } + if !validInlineWebMime(mime) { + mime = fallback + } + return mime +} diff --git a/internal/rpc/users.go b/internal/rpc/users.go index 2b41cbb8..54c04548 100644 --- a/internal/rpc/users.go +++ b/internal/rpc/users.go @@ -12,11 +12,13 @@ import ( ) const maxSavedMusicLimit = 100 +const maxRequirementsToContactUsers = 100 // registerUsers 注册 users.* RPC handler。 func (r *Router) registerUsers(d *tg.ServerDispatcher) { d.OnUsersGetUsers(r.onUsersGetUsers) d.OnUsersGetFullUser(r.onUsersGetFullUser) + d.OnUsersGetRequirementsToContact(r.onUsersGetRequirementsToContact) d.OnUsersGetSavedMusic(r.onUsersGetSavedMusic) d.OnUsersGetSavedMusicByID(r.onUsersGetSavedMusicByID) } @@ -27,41 +29,86 @@ func (r *Router) onUsersGetUsers(ctx context.Context, ids []tg.InputUserClass) ( if err != nil { return nil, internalErr() } - out := make([]tg.UserClass, 0, len(ids)) + if r.deps.Users == nil { + return []tg.UserClass{}, nil + } + type userInput struct { + self bool + userID int64 + accessHash int64 + } + inputs := make([]userInput, 0, len(ids)) + uniqueUserIDs := make([]int64, 0, len(ids)) + seenUserIDs := make(map[int64]struct{}, len(ids)) + needSelf := false for _, in := range ids { - if r.deps.Users == nil { - continue - } switch v := in.(type) { case *tg.InputUserSelf: if !authorized { continue } - u, err := r.deps.Users.Self(ctx, currentUserID) - if err != nil { - if errors.Is(err, users.ErrNotAuthorized) { - continue // 未登录:getUsers 尽力而为,跳过 self - } - return nil, internalErr() - } - out = append(out, r.tgSelfUser(u)) + needSelf = true + inputs = append(inputs, userInput{self: true}) case *tg.InputUser: - if !authorized { + if !authorized || v.UserID == 0 { continue } - u, found, err := r.deps.Users.ByID(ctx, currentUserID, v.UserID) - if err != nil { - if errors.Is(err, users.ErrNotAuthorized) { - continue - } - return nil, internalErr() - } - if !found || (v.AccessHash != 0 && v.AccessHash != u.AccessHash) { + inputs = append(inputs, userInput{userID: v.UserID, accessHash: v.AccessHash}) + if _, ok := seenUserIDs[v.UserID]; ok { continue } - out = append(out, r.tgUser(u)) + seenUserIDs[v.UserID] = struct{}{} + uniqueUserIDs = append(uniqueUserIDs, v.UserID) } } + var selfUser domain.User + if needSelf { + selfUser, err = r.deps.Users.Self(ctx, currentUserID) + if err != nil { + if errors.Is(err, users.ErrNotAuthorized) { + needSelf = false + } else { + return nil, internalErr() + } + } else if selfUser.ID == 0 { + needSelf = false + } + } + usersByID := make(map[int64]domain.User, len(uniqueUserIDs)) + if len(uniqueUserIDs) > 0 { + list, err := r.deps.Users.ByIDs(ctx, currentUserID, uniqueUserIDs) + if err != nil { + if !errors.Is(err, users.ErrNotAuthorized) { + return nil, internalErr() + } + } + for _, u := range list { + if u.ID != 0 { + usersByID[u.ID] = u + } + } + } + out := make([]tg.UserClass, 0, len(ids)) + for _, in := range inputs { + if in.self { + if needSelf { + out = append(out, r.tgSelfUser(selfUser)) + } + continue + } + u, found := usersByID[in.userID] + if !found || (in.accessHash != 0 && in.accessHash != u.AccessHash) { + continue + } + // 客户端也可能用显式自己 ID(非 inputUserSelf)请求;同样须带 self 标志, + // 否则 self=false 的自己 user 会污染 DrKLO 账号缓存(Saved Messages 变身)。 + if u.ID == currentUserID { + out = append(out, r.tgSelfUser(u)) + continue + } + out = append(out, r.tgUser(u)) + } + r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, out, nil) return out, nil } @@ -84,14 +131,45 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) ( return emptyUserFull(), nil } user := r.tgUser(u) - if _, ok := id.(*tg.InputUserSelf); ok { + if _, ok := id.(*tg.InputUserSelf); ok || u.ID == currentUserID { user = r.tgSelfUser(u) } + if err := r.applyBotCanEditToUser(ctx, currentUserID, u, user); err != nil { + return nil, err + } + r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, []tg.UserClass{user}, nil) + loadEpoch := r.userFullProjectionCache.LoadEpoch() + if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok { + r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full) + r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full) + chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full) + return &tg.UsersUserFull{ + FullUser: full, + Users: []tg.UserClass{user}, + Chats: chats, + }, nil + } + full, err := r.buildUserFullProjection(ctx, currentUserID, u) + if err != nil { + return nil, err + } + r.userFullProjectionCache.StoreIfEpoch(currentUserID, u.ID, full, loadEpoch) + r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full) + r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full) + chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full) + return &tg.UsersUserFull{ + FullUser: full, + Users: []tg.UserClass{user}, + Chats: chats, + }, nil +} + +func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int64, u domain.User) (tg.UserFull, error) { about := u.About if r.deps.Privacy != nil && u.ID != currentUserID { allowed, err := r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyAbout) if err != nil { - return nil, internalErr() + return tg.UserFull{}, internalErr() } if !allowed { about = "" @@ -103,8 +181,94 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) ( Settings: tg.PeerSettings{}, NotifySettings: *tdesktop.NotifySettings(), } + // 通话入口:客户端不见 phone_calls_available=true 不显示通话按钮(P1 前置项)。 + // phone_calls_private 标记对端禁 P2P(p2p_allowed 真值在通话确认时另行计算)。 + if !u.Bot && u.ID != currentUserID { + callsAllowed, p2pAllowed := true, true + if r.deps.Privacy != nil { + allowed, err := r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyPhoneCall) + if err != nil { + return tg.UserFull{}, internalErr() + } + callsAllowed = allowed + allowed, err = r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyPhoneP2P) + if err != nil { + return tg.UserFull{}, internalErr() + } + p2pAllowed = allowed + } + full.PhoneCallsAvailable = callsAllowed + full.VideoCallsAvailable = callsAllowed + full.PhoneCallsPrivate = !p2pAllowed + } + if u.Bot { + full.SetBotInfo(r.tgBotInfo(ctx, u)) + } + if r.deps.Dialogs != nil && u.ID != currentUserID { + list, err := r.deps.Dialogs.GetPeerDialogs(ctx, currentUserID, []domain.Peer{{Type: domain.PeerTypeUser, ID: u.ID}}) + if err != nil { + return tg.UserFull{}, internalErr() + } + for _, dialog := range list.Dialogs { + if dialog.Peer.Type != domain.PeerTypeUser || dialog.Peer.ID != u.ID { + continue + } + if dialog.HasScheduled { + full.SetHasScheduled(true) + } + if dialog.TTLPeriod > 0 { + full.SetTTLPeriod(dialog.TTLPeriod) + } + if dialog.ThemeEmoticon != "" { + full.SetTheme(&tg.ChatTheme{Emoticon: dialog.ThemeEmoticon}) + } + break + } + } if err := r.fillUserFullPhotos(ctx, currentUserID, u.ID, &full); err != nil { - return nil, err + return tg.UserFull{}, err + } + if r.deps.Account != nil { + allowed, err := r.canSeeSavedMusic(ctx, currentUserID, u.ID) + if err != nil { + return tg.UserFull{}, err + } + if allowed { + music, err := r.deps.Account.ListSavedMusic(ctx, u.ID, 0, 1) + if err != nil { + return tg.UserFull{}, internalErr() + } + if len(music.Documents) > 0 { + full.SetSavedMusic(tgDocument(music.Documents[0])) + } + } + } + if svc, ok := r.accountBusinessAutomation(); ok { + profile, found, err := svc.GetBusinessProfile(ctx, u.ID) + if err != nil { + return tg.UserFull{}, internalErr() + } + if found { + r.applyBusinessProfileToUserFull(ctx, &full, profile) + } + } + if r.deps.Messages != nil { + // 置顶发现链路:userFull.pinned_msg_id 是该私聊(含 Saved + // Messages)当前最顶置顶;客户端凭此触发 filterPinned 搜索。 + pinnedList, err := r.deps.Messages.Search(ctx, currentUserID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}, + PinnedOnly: true, + Limit: 1, + }) + if err != nil { + return tg.UserFull{}, internalErr() + } + if len(pinnedList.Messages) > 0 { + full.SetPinnedMsgID(pinnedList.Messages[0].ID) + } + // 私聊双方恒可置顶(官方对 user peer 置位 can_pin_message)。 + full.SetCanPinMessage(true) } if r.deps.Channels != nil && u.ID != currentUserID { common, err := r.deps.Channels.CommonChannels(ctx, currentUserID, domain.CommonChannelsRequest{ @@ -114,26 +278,139 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) ( CountOnly: true, }) if err != nil { - return nil, internalErr() + return tg.UserFull{}, internalErr() } full.CommonChatsCount = common.Count } - return &tg.UsersUserFull{ - FullUser: full, - Users: []tg.UserClass{user}, - }, nil + // star gift 数量:客户端把资料页 Gifts 区段/标签页门控在 stargifts_count>0 + //(DrKLO ProfileActivity:10497 / TDesktop data_user.cpp:924),不下发则收到的礼物 + // 不在资料页展示。计展示在资料的礼物数(非转换、非隐藏)。 + if r.deps.Gifts != nil { + if n, err := r.deps.Gifts.CountSaved(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: u.ID}); err == nil && n > 0 { + full.SetStargiftsCount(n) + } + } + // 屏蔽 premium 礼物赠送:telesrv 未实现 payments.getPremiumGiftCodeOptions, + // DrKLO GiftSheet 对个人送礼时总会渲染一个「Gift Premium」区段(fillItems:918), + // premiumTiers 永远空 → 卡死成三个 flicker 占位骨架。设 disallow_premium_gifts=true + // 会让该区段整体隐藏(GiftSheet:918 gate),而 star gift 礼物墙不受影响。 + // 仅对「他人视角」下发:送礼给自己时该区段本就被 !self 排除(无占位),故 self 投影 + // 保持干净,避免用户自己的礼物隐私设置里「接收 premium 礼物」被显示为关闭。 + // 务必只设这一个 flag——四个 disallow 全置会触发 GiftSheet:788 整个送礼弹窗自动 dismiss。 + if u.ID != currentUserID { + var disallow tg.DisallowedGiftsSettings + disallow.SetDisallowPremiumGifts(true) + full.SetDisallowedGifts(disallow) + } + // 生日(account.updateBirthday):落 userFull.birthday,按 PrivacyKeyBirthday 对他人裁剪, + // 本人恒可见。 + if u.Birthday.IsSet() { + birthdayVisible := true + if r.deps.Privacy != nil && u.ID != currentUserID { + allowed, err := r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyBirthday) + if err != nil { + return tg.UserFull{}, internalErr() + } + birthdayVisible = allowed + } + if birthdayVisible { + full.SetBirthday(tgBirthday(u.Birthday)) + } + } + // 个人频道(account.updatePersonalChannel)不在此落地:它按 viewer 实时解析,作为缓存后的 + // overlay 处理(applyPersonalChannelToUserFull),避免烤进 per-(viewer,target) 投影缓存以及 + // build/chats 两次解析同一频道。 + return full, nil +} + +// tgBirthday 把 domain 生日转 tg.Birthday(Year 可选,0 表示不含年份)。 +func tgBirthday(b domain.Birthday) tg.Birthday { + out := tg.Birthday{Day: b.Day, Month: b.Month} + if b.Year != 0 { + out.SetYear(b.Year) + } + return out +} + +// applyPersonalChannelToUserFull 一次解析资料页个人频道:设 personal_channel_id + 最新一帖 id +// 到 userFull,并返回频道对象供响应 chats;频道不可解析/已删则不下发。放在 userFull 投影缓存 +// 之后做(与 notify/stories overlay 同构):个人频道可访问性随 viewer 变化须实时解析,且避免 +// build(缓存 miss)与 chats 两次解析同一频道。personalChannelID 取自当次加载的 owner 用户。 +func (r *Router) applyPersonalChannelToUserFull(ctx context.Context, viewerUserID, personalChannelID int64, full *tg.UserFull) []tg.ChatClass { + if personalChannelID == 0 || r.deps.Channels == nil { + return nil + } + view, err := r.deps.Channels.ResolveChannel(ctx, viewerUserID, personalChannelID) + if err != nil || view.Channel.ID == 0 || view.Channel.Deleted { + return nil + } + full.SetPersonalChannelID(view.Channel.ID) + full.SetPersonalChannelMessage(view.Channel.TopMessageID) + return []tg.ChatClass{tgChannelChatForView(viewerUserID, view)} +} + +func (r *Router) applyBotCanEditToUser(ctx context.Context, currentUserID int64, u domain.User, user *tg.User) error { + if !u.Bot || r.deps.Bots == nil || currentUserID == 0 || currentUserID == u.ID || user == nil { + return nil + } + // owner 视角置 bot_can_edit(flags2.1):TDesktop/DrKLO 据此显示 bot 管理 UI + //(改名/简介/头像入口)。仅 owner 看得到。 + owns, err := r.deps.Bots.OwnsBot(ctx, currentUserID, u.ID) + if err != nil { + return nil + } + if owns { + user.SetBotCanEdit(true) + } + return nil +} + +func (r *Router) onUsersGetRequirementsToContact(ctx context.Context, ids []tg.InputUserClass) ([]tg.RequirementToContactClass, error) { + if len(ids) > maxRequirementsToContactUsers { + return nil, limitInvalidErr() + } + out := make([]tg.RequirementToContactClass, 0, len(ids)) + for range ids { + out = append(out, &tg.RequirementToContactEmpty{}) + } + return out, nil } func (r *Router) onUsersGetSavedMusic(ctx context.Context, req *tg.UsersGetSavedMusicRequest) (tg.UsersSavedMusicClass, error) { if req == nil || req.Offset < 0 || req.Limit < 0 || req.Limit > maxSavedMusicLimit { return nil, limitInvalidErr() } - if err := r.validateInputUser(ctx, req.ID); err != nil { + currentUserID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + owner, found, err := r.userFromInput(ctx, currentUserID, req.ID) + if err != nil { + if errors.Is(err, users.ErrNotAuthorized) { + return emptyUsersSavedMusic(), nil + } + return nil, internalErr() + } + if !found { + return nil, userIDInvalidErr() + } + allowed, err := r.canSeeSavedMusic(ctx, currentUserID, owner.ID) + if err != nil { return nil, err } + if !allowed || r.deps.Account == nil { + return emptyUsersSavedMusic(), nil + } + list, err := r.deps.Account.ListSavedMusic(ctx, owner.ID, req.Offset, req.Limit) + if err != nil { + return nil, internalErr() + } + if req.Hash != 0 && req.Offset == 0 && int64(tdesktopCountHash(savedMusicDocumentIDs(list.Documents))) == req.Hash { + return &tg.UsersSavedMusicNotModified{Count: list.Count}, nil + } return &tg.UsersSavedMusic{ - Count: 0, - Documents: []tg.DocumentClass{}, + Count: list.Count, + Documents: tgDocuments(list.Documents), }, nil } @@ -141,13 +418,79 @@ func (r *Router) onUsersGetSavedMusicByID(ctx context.Context, req *tg.UsersGetS if req == nil || len(req.Documents) > maxSavedMusicLimit { return nil, limitInvalidErr() } - if err := r.validateInputUser(ctx, req.ID); err != nil { + currentUserID, _, err := r.currentUserID(ctx) + if err != nil { + return nil, internalErr() + } + owner, found, err := r.userFromInput(ctx, currentUserID, req.ID) + if err != nil { + if errors.Is(err, users.ErrNotAuthorized) { + return emptyUsersSavedMusic(), nil + } + return nil, internalErr() + } + if !found { + return nil, userIDInvalidErr() + } + allowed, err := r.canSeeSavedMusic(ctx, currentUserID, owner.ID) + if err != nil { return nil, err } + if !allowed || r.deps.Account == nil || len(req.Documents) == 0 { + return emptyUsersSavedMusic(), nil + } + ids := make([]int64, 0, len(req.Documents)) + accessHashes := make(map[int64]int64, len(req.Documents)) + for _, input := range req.Documents { + doc, ok := input.(*tg.InputDocument) + if !ok || doc.ID == 0 { + return nil, documentInvalidErr() + } + ids = append(ids, doc.ID) + if _, seen := accessHashes[doc.ID]; !seen { + accessHashes[doc.ID] = doc.AccessHash + } + } + list, err := r.deps.Account.GetSavedMusicByIDs(ctx, owner.ID, ids) + if err != nil { + return nil, internalErr() + } + docs := list.Documents[:0] + for _, doc := range list.Documents { + if doc.AccessHash == accessHashes[doc.ID] && doc.IsMusic() { + docs = append(docs, doc) + } + } + return &tg.UsersSavedMusic{ + Count: list.Count, + Documents: tgDocuments(docs), + }, nil +} + +func emptyUsersSavedMusic() *tg.UsersSavedMusic { return &tg.UsersSavedMusic{ Count: 0, Documents: []tg.DocumentClass{}, - }, nil + } +} + +func (r *Router) canSeeSavedMusic(ctx context.Context, viewerUserID, ownerUserID int64) (bool, error) { + if viewerUserID == 0 || ownerUserID == 0 || viewerUserID == ownerUserID || r.deps.Privacy == nil { + return true, nil + } + allowed, err := r.deps.Privacy.CanSee(ctx, ownerUserID, viewerUserID, domain.PrivacyKeySavedMusic) + if err != nil { + return false, internalErr() + } + return allowed, nil +} + +func savedMusicDocumentIDs(docs []domain.Document) []int64 { + ids := make([]int64, 0, len(docs)) + for _, doc := range docs { + ids = append(ids, doc.ID) + } + return ids } func (r *Router) fillUserFullPhotos(ctx context.Context, viewerUserID, ownerUserID int64, full *tg.UserFull) error { @@ -206,6 +549,98 @@ func (r *Router) fillUserFullPhotos(ctx context.Context, viewerUserID, ownerUser return nil } +// tgBotInfo 构造 userFull.bot_info。bot 用户必须携带(哪怕命令为空):缺失时 +// TDesktop 的 botInfo.inited 永不置位,每次开聊/输 "/" 都会重拉 getFullUser; +// user_id 必填且必须等于该 bot 的 id,不匹配会被客户端整体静默忽略。 +func (r *Router) tgBotInfo(ctx context.Context, u domain.User) tg.BotInfo { + if r.deps.Bots == nil { + return tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false) + } + profile, found, err := r.deps.Bots.BotInfo(ctx, u.ID) + if err != nil || !found { + return tgBotInfoFromProfile(u.ID, domain.BotProfile{}, false) + } + return tgBotInfoFromProfile(u.ID, profile, true) +} + +type botProfileBatchResolver interface { + BotInfos(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) +} + +func (r *Router) tgBotInfos(ctx context.Context, userIDs []int64) []tg.BotInfo { + if len(userIDs) == 0 { + return nil + } + ids := uniquePeerIDs(userIDs) + profiles := map[int64]domain.BotProfile{} + if r.deps.Bots != nil { + if batch, ok := r.deps.Bots.(botProfileBatchResolver); ok { + if loaded, err := batch.BotInfos(ctx, ids); err == nil { + profiles = loaded + } + } else { + for _, id := range ids { + if profile, found, err := r.deps.Bots.BotInfo(ctx, id); err == nil && found { + profiles[id] = profile + } + } + } + } + out := make([]tg.BotInfo, 0, len(userIDs)) + for _, id := range userIDs { + profile, found := profiles[id] + out = append(out, tgBotInfoFromProfile(id, profile, found)) + } + return out +} + +func tgBotInfoFromProfile(userID int64, profile domain.BotProfile, found bool) tg.BotInfo { + info := tg.BotInfo{} + info.SetUserID(userID) + info.SetMenuButton(&tg.BotMenuButtonDefault{}) + if !found { + return info + } + if profile.Description != "" { + info.SetDescription(profile.Description) + } + if len(profile.Commands) > 0 { + cmds := make([]tg.BotCommand, 0, len(profile.Commands)) + for _, c := range profile.Commands { + cmds = append(cmds, tg.BotCommand{Command: c.Command, Description: c.Description}) + } + info.SetCommands(cmds) + } + info.SetMenuButton(tgBotMenuButton(profile.MenuButton)) + if profile.HasPreviewMedias { + info.SetHasPreviewMedias(true) + } + if profile.AppSettings != nil { + info.SetAppSettings(tgBotAppSettings(*profile.AppSettings)) + } + return info +} + +func tgBotAppSettings(in domain.BotAppSettings) tg.BotAppSettings { + out := tg.BotAppSettings{} + if len(in.PlaceholderPath) > 0 { + out.SetPlaceholderPath(append([]byte(nil), in.PlaceholderPath...)) + } + if in.HasBackgroundColor { + out.SetBackgroundColor(in.BackgroundColor) + } + if in.HasBackgroundDark { + out.SetBackgroundDarkColor(in.BackgroundDarkColor) + } + if in.HasHeaderColor { + out.SetHeaderColor(in.HeaderColor) + } + if in.HasHeaderDarkColor { + out.SetHeaderDarkColor(in.HeaderDarkColor) + } + return out +} + func emptyUserFull() *tg.UsersUserFull { return &tg.UsersUserFull{ FullUser: tg.UserFull{ diff --git a/internal/rpc/users_rpc_test.go b/internal/rpc/users_rpc_test.go new file mode 100644 index 00000000..8b54eaf8 --- /dev/null +++ b/internal/rpc/users_rpc_test.go @@ -0,0 +1,58 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/clock" + "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" +) + +func TestUsersGetUsersUsesSingleBatchLookupAndPreservesInputOrder(t *testing.T) { + const ( + currentID = int64(1000000001) + peerID = int64(1000000002) + ) + users := &countingMapUsersService{mapUsersService: mapUsersService{users: map[int64]domain.User{ + currentID: {ID: currentID, AccessHash: 101, FirstName: "Alice"}, + peerID: {ID: peerID, AccessHash: 202, FirstName: "Bob"}, + }}} + r := New(Config{}, Deps{Users: users}, zaptest.NewLogger(t), clock.System) + + got, err := r.onUsersGetUsers(WithUserID(context.Background(), currentID), []tg.InputUserClass{ + &tg.InputUserSelf{}, + &tg.InputUser{UserID: peerID, AccessHash: 202}, + &tg.InputUser{UserID: peerID, AccessHash: 999}, + &tg.InputUser{UserID: peerID}, + &tg.InputUser{UserID: currentID, AccessHash: 101}, + &tg.InputUserSelf{}, + }) + if err != nil { + t.Fatalf("get users: %v", err) + } + if users.selfCalls != 1 || users.byIDsCalls != 1 || users.byIDCalls != 0 { + t.Fatalf("lookups self=%d byIDs=%d byID=%d, want one Self, one ByIDs and no ByID", users.selfCalls, users.byIDsCalls, users.byIDCalls) + } + if len(users.lastByIDs) != 2 || users.lastByIDs[0] != peerID || users.lastByIDs[1] != currentID { + t.Fatalf("ByIDs ids = %+v, want [%d %d]", users.lastByIDs, peerID, currentID) + } + if len(got) != 5 { + t.Fatalf("users = %+v, want bad access_hash skipped and duplicate inputs preserved", got) + } + // 显式自己 ID(InputUser{UserID: currentID},index 3)也必须带 self 标志—— + // 否则 self=false 的自己 user 会污染 DrKLO 账号缓存(Saved Messages 变身)。 + wantIDs := []int64{currentID, peerID, peerID, currentID, currentID} + wantSelf := []bool{true, false, false, true, true} + for i, item := range got { + user, ok := item.(*tg.User) + if !ok { + t.Fatalf("user[%d] = %T, want *tg.User", i, item) + } + if user.ID != wantIDs[i] || user.Self != wantSelf[i] { + t.Fatalf("user[%d] = id %d self %v, want id %d self %v", i, user.ID, user.Self, wantIDs[i], wantSelf[i]) + } + } +} diff --git a/internal/rpc/wallpaper.go b/internal/rpc/wallpaper.go new file mode 100644 index 00000000..3bd999ee --- /dev/null +++ b/internal/rpc/wallpaper.go @@ -0,0 +1,224 @@ +package rpc + +import ( + "github.com/gotd/td/tg" + + "telesrv/internal/compat/tdesktop" + "telesrv/internal/domain" +) + +func domainWallpaperFromSetChatWallPaper(req *tg.MessagesSetChatWallPaperRequest) (*domain.Wallpaper, error) { + if req == nil || req.Revert { + return nil, nil + } + input, ok := req.GetWallpaper() + if !ok { + return nil, nil + } + settings, hasSettings := req.GetSettings() + return domainWallpaperFromInput(input, settings, hasSettings) +} + +func domainWallpaperFromInput(input tg.InputWallPaperClass, settings tg.WallPaperSettings, hasSettings bool) (*domain.Wallpaper, error) { + switch in := input.(type) { + case *tg.InputWallPaperNoFile: + wp := &domain.Wallpaper{ID: in.ID, NoFile: true} + if hasSettings { + wp.Settings = domainWallpaperSettings(settings) + } + return wp, nil + case *tg.InputWallPaper, *tg.InputWallPaperSlug: + found, ok := tdesktop.LookupWallPaper(input) + if !ok { + return nil, tgerr400("WALLPAPER_INVALID") + } + wp := domainWallpaperFromTG(found) + if wp == nil { + return nil, tgerr400("WALLPAPER_INVALID") + } + if hasSettings { + wp.Settings = domainWallpaperSettings(settings) + } + return wp, nil + default: + return nil, inputConstructorInvalidErr() + } +} + +func domainWallpaperFromTG(input tg.WallPaperClass) *domain.Wallpaper { + switch wp := input.(type) { + case *tg.WallPaperNoFile: + out := &domain.Wallpaper{ + ID: wp.ID, + NoFile: true, + Default: wp.Default, + Dark: wp.Dark, + Settings: domainWallpaperSettingsFromOptional(wp.GetSettings()), + } + return out + case *tg.WallPaper: + out := &domain.Wallpaper{ + ID: wp.ID, + AccessHash: wp.AccessHash, + Slug: wp.Slug, + Default: wp.Default, + Pattern: wp.Pattern, + Dark: wp.Dark, + Settings: domainWallpaperSettingsFromOptional(wp.GetSettings()), + } + return out + default: + return nil + } +} + +func tgWallpaper(wp *domain.Wallpaper) tg.WallPaperClass { + if wp == nil { + return nil + } + if wp.NoFile { + out := &tg.WallPaperNoFile{ID: wp.ID} + out.SetDefault(wp.Default) + out.SetDark(wp.Dark) + if !wp.Settings.Empty() { + out.SetSettings(tgWallpaperSettings(wp.Settings)) + } + return out + } + if wp.ID != 0 && wp.AccessHash != 0 { + if found, ok := tdesktop.LookupWallPaper(&tg.InputWallPaper{ID: wp.ID, AccessHash: wp.AccessHash}); ok { + return tgWallpaperWithSettings(found, wp.Settings) + } + } + if wp.Slug != "" { + if found, ok := tdesktop.LookupWallPaper(&tg.InputWallPaperSlug{Slug: wp.Slug}); ok { + return tgWallpaperWithSettings(found, wp.Settings) + } + } + out := &tg.WallPaperNoFile{ID: wp.ID} + out.SetDefault(wp.Default) + out.SetDark(wp.Dark) + if !wp.Settings.Empty() { + out.SetSettings(tgWallpaperSettings(wp.Settings)) + } + return out +} + +func tgWallpaperWithSettings(input tg.WallPaperClass, settings domain.WallpaperSettings) tg.WallPaperClass { + switch wp := input.(type) { + case *tg.WallPaperNoFile: + out := *wp + if !settings.Empty() { + out.SetSettings(tgWallpaperSettings(settings)) + } + return &out + case *tg.WallPaper: + out := *wp + if !settings.Empty() { + out.SetSettings(tgWallpaperSettings(settings)) + } + return &out + default: + return input + } +} + +func domainWallpaperSettingsFromOptional(settings tg.WallPaperSettings, ok bool) domain.WallpaperSettings { + if !ok { + return domain.WallpaperSettings{} + } + return domainWallpaperSettings(settings) +} + +func domainWallpaperSettings(in tg.WallPaperSettings) domain.WallpaperSettings { + out := domain.WallpaperSettings{ + Blur: in.Blur || in.GetBlur(), + Motion: in.Motion || in.GetMotion(), + } + if v, ok := in.GetBackgroundColor(); ok { + out.HasBackgroundColor = true + out.BackgroundColor = v + } else if in.BackgroundColor != 0 { + out.HasBackgroundColor = true + out.BackgroundColor = in.BackgroundColor + } + if v, ok := in.GetSecondBackgroundColor(); ok { + out.HasSecondBackgroundColor = true + out.SecondBackgroundColor = v + } else if in.SecondBackgroundColor != 0 { + out.HasSecondBackgroundColor = true + out.SecondBackgroundColor = in.SecondBackgroundColor + } + if v, ok := in.GetThirdBackgroundColor(); ok { + out.HasThirdBackgroundColor = true + out.ThirdBackgroundColor = v + } else if in.ThirdBackgroundColor != 0 { + out.HasThirdBackgroundColor = true + out.ThirdBackgroundColor = in.ThirdBackgroundColor + } + if v, ok := in.GetFourthBackgroundColor(); ok { + out.HasFourthBackgroundColor = true + out.FourthBackgroundColor = v + } else if in.FourthBackgroundColor != 0 { + out.HasFourthBackgroundColor = true + out.FourthBackgroundColor = in.FourthBackgroundColor + } + if v, ok := in.GetIntensity(); ok { + out.HasIntensity = true + out.Intensity = v + } else if in.Intensity != 0 { + out.HasIntensity = true + out.Intensity = in.Intensity + } + if v, ok := in.GetRotation(); ok { + out.HasRotation = true + out.Rotation = v + } else if in.Rotation != 0 { + out.HasRotation = true + out.Rotation = in.Rotation + } + if v, ok := in.GetEmoticon(); ok { + out.HasEmoticon = true + out.Emoticon = v + } else if in.Emoticon != "" { + out.HasEmoticon = true + out.Emoticon = in.Emoticon + } + return out +} + +func tgWallpaperSettings(in domain.WallpaperSettings) tg.WallPaperSettings { + var out tg.WallPaperSettings + out.SetBlur(in.Blur) + out.SetMotion(in.Motion) + if in.HasBackgroundColor { + out.SetBackgroundColor(in.BackgroundColor) + } + if in.HasSecondBackgroundColor { + out.SetSecondBackgroundColor(in.SecondBackgroundColor) + } + if in.HasThirdBackgroundColor { + out.SetThirdBackgroundColor(in.ThirdBackgroundColor) + } + if in.HasFourthBackgroundColor { + out.SetFourthBackgroundColor(in.FourthBackgroundColor) + } + if in.HasIntensity { + out.SetIntensity(in.Intensity) + } + if in.HasRotation { + out.SetRotation(in.Rotation) + } + if in.HasEmoticon { + out.SetEmoticon(in.Emoticon) + } + return out +} + +func tgUpdatePeerWallpaper(peer domain.Peer, wallpaper *domain.Wallpaper) *tg.UpdatePeerWallpaper { + update := &tg.UpdatePeerWallpaper{Peer: tgPeer(peer)} + if wp := tgWallpaper(wallpaper); wp != nil { + update.SetWallpaper(wp) + } + return update +} diff --git a/internal/rpc/webpage_resolver.go b/internal/rpc/webpage_resolver.go new file mode 100644 index 00000000..ff435f4a --- /dev/null +++ b/internal/rpc/webpage_resolver.go @@ -0,0 +1,140 @@ +package rpc + +import ( + "context" + "errors" + "time" + + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +// 链接预览异步回填(P3):发送时挂的 pending 占位(见 webpage_url_extract.go)由这里带外 +// 解析为已解析卡片,并经 messages.EditMessage 的 WebPageResolve 模式就地替换——只换 media、 +// 生成 updateWebPage(按 webPage id 与占位关联,不标记「已编辑」、不翻倍扇出)。投递与 +// difference 复用既有 dispatch_outbox + 通用消息事件路径。 + +const ( + webPageResolveConcurrency = 16 + webPageResolveTimeout = 30 * time.Second +) + +type webPageResolveJob struct { + senderID int64 + peer domain.Peer + msgID int + expectedID int64 + url string + invertMedia bool + forceLarge bool + forceSmall bool +} + +// maybeEnqueueWebPageResolve 若刚发出的消息(私聊或频道)携带 pending 链接预览占位, +// 异步排入解析。msgID 为发送方视角的消息 id,media 为该消息媒体。 +func (r *Router) maybeEnqueueWebPageResolve(senderID int64, peer domain.Peer, msgID int, media *domain.MessageMedia) { + if msgID == 0 || (peer.Type != domain.PeerTypeUser && peer.Type != domain.PeerTypeChannel) { + return + } + if media == nil || media.WebPage == nil || media.WebPage.State != domain.MessageWebPageStatePending { + return + } + r.enqueueWebPageResolve(webPageResolveJob{ + senderID: senderID, + peer: peer, + msgID: msgID, + expectedID: media.WebPage.ID, + url: media.WebPage.URL, + invertMedia: media.InvertMedia, + forceLarge: media.WebPage.ForceLargeMedia, + forceSmall: media.WebPage.ForceSmallMedia, + }) +} + +// enqueueWebPageResolve 在并发上限内异步处理一个解析任务;满则丢弃(消息留 pending, +// 兜底靠客户端重开会话 getHistory 读到当时仍 pending 的占位——WIP 可接受,P4 加清扫)。 +func (r *Router) enqueueWebPageResolve(job webPageResolveJob) { + if r.webPageResolveSem == nil { + return + } + select { + case r.webPageResolveSem <- struct{}{}: + default: + r.log.Debug("web page resolve dropped: at capacity", zap.Int64("expected_id", job.expectedID)) + return + } + go func() { + defer func() { <-r.webPageResolveSem }() + ctx, cancel := context.WithTimeout(context.Background(), webPageResolveTimeout) + defer cancel() + if err := r.resolvePendingWebPage(ctx, job); err != nil { + r.log.Debug("web page resolve failed", + zap.Int64("sender_id", job.senderID), + zap.Int("msg_id", job.msgID), + zap.Error(err)) + } + }() +} + +// resolvePendingWebPage 解析链接并就地把消息的 pending 占位替换为已解析卡片(同步,可测)。 +// 瞬时抓取失败返回 error(消息留 pending,不毒化热门链接);幂等冲突(消息已删/已改/已被 +// 解析)经 ErrMessageNotModified 静默成功。done 与 empty 两态都替换(empty 让客户端停止转圈)。 +func (r *Router) resolvePendingWebPage(ctx context.Context, job webPageResolveJob) error { + if r.deps.Files == nil { + return nil + } + resolved, err := r.deps.Files.ResolveWebPage(ctx, job.url) + if err != nil { + return err + } + // 保留发送时占位上的 wrapper 偏好(force large/small),它们不在抓取结果里。 + resolved.ForceLargeMedia = job.forceLarge + resolved.ForceSmallMedia = job.forceSmall + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindWebPage, + InvertMedia: job.invertMedia, + WebPage: &resolved, + } + switch job.peer.Type { + case domain.PeerTypeChannel: + if r.deps.Channels == nil { + return nil + } + res, err := r.deps.Channels.EditMessage(ctx, job.senderID, domain.EditChannelMessageRequest{ + UserID: job.senderID, + ChannelID: job.peer.ID, + ID: job.msgID, + Media: media, + WebPageResolve: true, + ExpectedWebPageID: job.expectedID, + }) + if errors.Is(err, domain.ErrMessageNotModified) { + return nil + } + if err != nil { + return err + } + // 复用频道编辑扇出(投影 updateChannelWebPage)触达成员;difference 走通用回放。 + r.enqueueChannelEditMessageFanout(ctx, job.senderID, res) + return nil + case domain.PeerTypeUser: + if r.deps.Messages == nil { + return nil + } + _, err = r.deps.Messages.EditMessage(ctx, job.senderID, domain.EditMessageRequest{ + OwnerUserID: job.senderID, + Peer: job.peer, + ID: job.msgID, + Media: media, + WebPageResolve: true, + ExpectedWebPageID: job.expectedID, + }) + if errors.Is(err, domain.ErrMessageNotModified) { + return nil + } + return err + default: + return nil + } +} diff --git a/internal/rpc/webpage_resolver_test.go b/internal/rpc/webpage_resolver_test.go new file mode 100644 index 00000000..8ccdb2ae --- /dev/null +++ b/internal/rpc/webpage_resolver_test.go @@ -0,0 +1,130 @@ +package rpc + +import ( + "context" + "testing" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// TestTgWebPageUpdateFromEvent 验证 web_page 事件投影为 updateWebPage(携带已解析 webPage + pts)。 +func TestTgWebPageUpdateFromEvent(t *testing.T) { + urlHash := domain.WebPageURLHash("https://example.com/x") + event := domain.UpdateEvent{ + UserID: 1, Type: domain.UpdateEventWebPage, Pts: 5, PtsCount: 1, Date: 1700000100, + Message: domain.Message{ + ID: 10, OwnerUserID: 1, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindWebPage, + WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: urlHash, URL: "https://example.com/x", DisplayURL: "example.com", Title: "Example"}, + }, + }, + } + update := tgOtherUpdateFromEvent(event) + wp, ok := update.(*tg.UpdateWebPage) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateWebPage", update) + } + if wp.Pts != 5 || wp.PtsCount != 1 { + t.Errorf("pts = %d/%d, want 5/1", wp.Pts, wp.PtsCount) + } + page, ok := wp.Webpage.(*tg.WebPage) + if !ok { + t.Fatalf("webpage = %T, want *tg.WebPage", wp.Webpage) + } + if page.ID != urlHash { + t.Errorf("webpage id = %d, want url_hash %d", page.ID, urlHash) + } + if v, _ := page.GetTitle(); v != "Example" { + t.Errorf("title = %q, want Example", v) + } +} + +// TestTgChannelWebPageUpdateFromEvent 验证频道 web_page 事件投影为 updateChannelWebPage。 +func TestTgChannelWebPageUpdateFromEvent(t *testing.T) { + urlHash := domain.WebPageURLHash("https://example.com/c") + event := domain.ChannelUpdateEvent{ + ChannelID: 555, Type: domain.ChannelUpdateWebPage, Pts: 9, PtsCount: 1, Date: 1700000100, + Message: domain.ChannelMessage{ + ChannelID: 555, ID: 12, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindWebPage, + WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: urlHash, URL: "https://example.com/c", DisplayURL: "example.com", Title: "Example"}, + }, + }, + } + update := tgChannelUpdate(1, event) + wp, ok := update.(*tg.UpdateChannelWebPage) + if !ok { + t.Fatalf("update = %T, want *tg.UpdateChannelWebPage", update) + } + if wp.ChannelID != 555 || wp.Pts != 9 || wp.PtsCount != 1 { + t.Errorf("channel/pts = %d/%d/%d", wp.ChannelID, wp.Pts, wp.PtsCount) + } + page, ok := wp.Webpage.(*tg.WebPage) + if !ok { + t.Fatalf("webpage = %T, want *tg.WebPage", wp.Webpage) + } + if page.ID != urlHash { + t.Errorf("webpage id = %d, want url_hash %d", page.ID, urlHash) + } +} + +// TestResolvePendingWebPageSwapsCard 验证 resolver glue:发送→解析→消息 media 就地变为 done 卡片。 +func TestResolvePendingWebPageSwapsCard(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + f := r.deps.Files.(*fakeFiles) + f.webPagePreviewOn = true + f.resolveWebPageFn = func(u string) (domain.MessageWebPage, error) { + return domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: domain.WebPageURLHash(u), URL: u, DisplayURL: "example.com", Title: "Resolved"}, nil + } + + const url = "https://example.com/x" + urlHash := domain.WebPageURLHash(url) + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Message: "see " + url, + Entities: []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: 4, Length: 21}}, + RandomID: 6001, + }) + if err != nil { + t.Fatalf("sendMessage: %v", err) + } + msg := newMessageFromUpdates(t, updates) + // echo 同步反映 pending 占位。 + if wrap, ok := msg.Media.(*tg.MessageMediaWebPage); !ok { + t.Fatalf("echo media = %T, want pending webpage", msg.Media) + } else if _, ok := wrap.Webpage.(*tg.WebPagePending); !ok { + t.Fatalf("echo webpage = %T, want pending", wrap.Webpage) + } + + // 同步解析(异步路径也会跑,但幂等收敛到同一 done 终态)。 + job := webPageResolveJob{ + senderID: owner.ID, + peer: domain.Peer{Type: domain.PeerTypeUser, ID: friend.ID}, + msgID: msg.ID, + expectedID: urlHash, + url: url, + } + if err := r.resolvePendingWebPage(ctx, job); err != nil { + t.Fatalf("resolvePendingWebPage: %v", err) + } + + // 发送方消息 media 已就地替换为 done。 + got, found, err := r.lookupOwnerMessage(ctx, owner.ID, msg.ID) + if err != nil || !found { + t.Fatalf("lookupOwnerMessage: found=%v err=%v", found, err) + } + if got.Media == nil || got.Media.WebPage == nil || got.Media.WebPage.State != domain.MessageWebPageStateDone { + t.Fatalf("owner media not resolved: %+v", got.Media) + } + if got.Media.WebPage.Title != "Resolved" { + t.Errorf("resolved title = %q, want Resolved", got.Media.WebPage.Title) + } + if got.EditDate != 0 { + t.Errorf("edit_date = %d, want 0 (no 'edited' marker)", got.EditDate) + } +} diff --git a/internal/rpc/webpage_url_extract.go b/internal/rpc/webpage_url_extract.go new file mode 100644 index 00000000..a2a10968 --- /dev/null +++ b/internal/rpc/webpage_url_extract.go @@ -0,0 +1,152 @@ +package rpc + +import ( + "context" + "regexp" + "strings" + "unicode/utf16" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// urlInTextRe 匹配原始文本里的 http(s) 链接(取到首个空白或尖括号/引号为止)。 +var urlInTextRe = regexp.MustCompile(`https?://[^\s<>"')】]+`) + +// urlTrailingPunct 是不属于 URL 的句末标点('/' 是合法路径末尾,保留)。 +const urlTrailingPunct = ".,;:!?)]}'\"。,、!?" + +// detectURLEntities 服务端扫描消息文本生成 url 高亮实体(MessageEntityURL)。TDesktop 等客户端 +// 发消息不带 url 实体、依赖服务端检测原文(官方服务端行为),否则链接不高亮。偏移/长度按 +// UTF-16 码元(Telegram 实体口径)。 +func detectURLEntities(message string) []tg.MessageEntityClass { + if !strings.Contains(message, "http") { + return nil + } + locs := urlInTextRe.FindAllStringIndex(message, -1) + if len(locs) == 0 { + return nil + } + out := make([]tg.MessageEntityClass, 0, len(locs)) + for _, loc := range locs { + raw := strings.TrimRight(message[loc[0]:loc[1]], urlTrailingPunct) + if raw == "" { + continue + } + out = append(out, &tg.MessageEntityURL{ + Offset: utf16CodeUnitLen(message[:loc[0]]), + Length: utf16CodeUnitLen(raw), + }) + } + return out +} + +// firstPreviewableURL 从消息文本+实体中提取首个可预览的 http/https 链接,用于链接预览: +// - MessageEntityTextURL:URL 直接在实体里(markdown 风格 [text](url))。 +// - MessageEntityURL:URL 是文本里 [offset,offset+length) 的子串(Telegram 实体偏移以 +// UTF-16 码元计,需按 UTF-16 切片,不能按 rune/byte)。 +// - 回退:实体里没有 URL 时扫描原始文本。DrKLO 发送时带 url 实体,但 TDesktop 等客户端 +// 不带、依赖服务端检测原始文本里的链接(与官方服务端行为一致),故必须扫文本兜底。 +// +// 取出现顺序的第一个合法链接(与官方"预览第一条链接"一致)。无可预览链接返回 ok=false。 +func firstPreviewableURL(message string, entities []tg.MessageEntityClass) (string, bool) { + var units []uint16 // 惰性:仅遇到 MessageEntityURL(需按 UTF-16 偏移切片)时才编码全文。 + for _, entity := range entities { + var candidate string + switch e := entity.(type) { + case *tg.MessageEntityTextURL: + candidate = e.URL // URL 内联,无需切文本。 + case *tg.MessageEntityURL: + if units == nil { + units = utf16.Encode([]rune(message)) + } + candidate = sliceUTF16(units, e.Offset, e.Length) + default: + continue + } + if normalized, ok := domain.NormalizeWebPageURL(candidate); ok { + return normalized, true + } + } + // 回退扫原始文本:绝大多数消息无链接,无 "http" 子串则直接跳过正则与分配。 + if !strings.Contains(message, "http") { + return "", false + } + if raw, ok := firstURLInText(message); ok { + if normalized, ok := domain.NormalizeWebPageURL(raw); ok { + return normalized, true + } + } + return "", false +} + +// firstURLInText 扫描原始文本里的首个 http(s) 链接,剥掉句末标点。 +func firstURLInText(message string) (string, bool) { + match := urlInTextRe.FindString(message) + if match == "" { + return "", false + } + // 句末标点不属于 URL("见 https://example.com。" / "...go).")。'/' 是合法路径末尾,保留。 + match = strings.TrimRight(match, urlTrailingPunct) + if match == "" { + return "", false + } + return match, true +} + +// sliceUTF16 按 UTF-16 码元偏移/长度从已编码序列取子串;越界返回空串。 +func sliceUTF16(units []uint16, offset, length int) string { + if offset < 0 || length <= 0 || offset > len(units) || length > len(units)-offset { + return "" + } + return strings.TrimSpace(string(utf16.Decode(units[offset : offset+length]))) +} + +// webPagePendingOrCachedMedia 为发送构造链接预览媒体: +// - 若该 URL 已解析缓存(典型:客户端输入时 getWebPagePreview 已触发解析),直接挂 done +// 卡片——发送 echo 即带卡,不依赖异步 updateWebPage 换卡(官方行为;TDesktop 对自己 +// 发出的消息不应用该换卡,故必须在 echo 直接带 done)。 +// - 否则挂 pending 占位,由异步 resolver 解析后经 updateWebPage 换卡。 +// +// 未启用预览或 URL 不可规范化返回 nil(发送降级为无预览,不报错)。 +func (r *Router) webPagePendingOrCachedMedia(ctx context.Context, rawURL string, invertMedia, forceLarge, forceSmall bool) *domain.MessageMedia { + if r.deps.Files == nil || !r.deps.Files.WebPagePreviewEnabled() { + return nil + } + normalized, ok := domain.NormalizeWebPageURL(rawURL) + if !ok { + return nil + } + if page, found := r.deps.Files.LookupWebPage(ctx, normalized); found && page.State == domain.MessageWebPageStateDone { + page.ForceLargeMedia = forceLarge + page.ForceSmallMedia = forceSmall + return &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, InvertMedia: invertMedia, WebPage: &page} + } + return &domain.MessageMedia{ + Kind: domain.MessageMediaKindWebPage, + InvertMedia: invertMedia, + WebPage: &domain.MessageWebPage{ + State: domain.MessageWebPageStatePending, + ID: domain.WebPageURLHash(normalized), + URL: normalized, + // Date=「processing started」时刻:留 0(=1970)会被严格客户端判为 pending 早已 + // 过期 → 直接显示纯文本,须填发送时刻。 + Date: int(r.clock.Now().Unix()), + ForceLargeMedia: forceLarge, + ForceSmallMedia: forceSmall, + }, + } +} + +// webPageMediaFromText 为文本发送构造链接预览媒体:no_webpage 抑制;否则取首个可预览 URL。 +func (r *Router) webPageMediaFromText(ctx context.Context, message string, entities []tg.MessageEntityClass, noWebpage, invertMedia bool) *domain.MessageMedia { + if noWebpage { + return nil + } + rawURL, ok := firstPreviewableURL(message, entities) + if !ok { + return nil + } + return r.webPagePendingOrCachedMedia(ctx, rawURL, invertMedia, false, false) +} diff --git a/internal/rpc/webpage_url_extract_test.go b/internal/rpc/webpage_url_extract_test.go new file mode 100644 index 00000000..e71c8b59 --- /dev/null +++ b/internal/rpc/webpage_url_extract_test.go @@ -0,0 +1,268 @@ +package rpc + +import ( + "testing" + + "github.com/gotd/td/tg" +) + +func TestFirstPreviewableURL(t *testing.T) { + t.Run("text-url-entity", func(t *testing.T) { + got, ok := firstPreviewableURL("click here", []tg.MessageEntityClass{ + &tg.MessageEntityTextURL{Offset: 0, Length: 5, URL: "https://example.com/a"}, + }) + if !ok || got != "https://example.com/a" { + t.Fatalf("got (%q,%v)", got, ok) + } + }) + + t.Run("url-entity-utf16-slice", func(t *testing.T) { + // 含 4 字节 emoji(UTF-16 占 2 码元)前缀,验证按 UTF-16 偏移切片正确。 + msg := "👍 https://example.com/x done" + // "👍"=2 units, " "=1 → URL 从 offset 3 起,长度 = len16("https://example.com/x")=21。 + got, ok := firstPreviewableURL(msg, []tg.MessageEntityClass{ + &tg.MessageEntityURL{Offset: 3, Length: 21}, + }) + if !ok || got != "https://example.com/x" { + t.Fatalf("got (%q,%v), want https://example.com/x", got, ok) + } + }) + + t.Run("first-of-many", func(t *testing.T) { + got, ok := firstPreviewableURL("a b", []tg.MessageEntityClass{ + &tg.MessageEntityBold{Offset: 0, Length: 1}, + &tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "https://first.example/"}, + &tg.MessageEntityTextURL{Offset: 2, Length: 1, URL: "https://second.example/"}, + }) + if !ok || got != "https://first.example/" { + t.Fatalf("got (%q,%v)", got, ok) + } + }) + + t.Run("raw-text-fallback-no-entities", func(t *testing.T) { + // TDesktop 不带 url 实体,依赖服务端扫原始文本。 + got, ok := firstPreviewableURL("check https://example.com/x bare text", nil) + if !ok || got != "https://example.com/x" { + t.Fatalf("raw-text fallback got (%q,%v), want https://example.com/x", got, ok) + } + }) + + t.Run("raw-text-trim-trailing-punct", func(t *testing.T) { + got, ok := firstPreviewableURL("见 https://example.com/x。", nil) + if !ok || got != "https://example.com/x" { + t.Fatalf("trailing punct trim got (%q,%v)", got, ok) + } + }) + + t.Run("raw-text-bare-url", func(t *testing.T) { + got, ok := firstPreviewableURL("https://github.com/golang/go", nil) + if !ok || got != "https://github.com/golang/go" { + t.Fatalf("bare url got (%q,%v)", got, ok) + } + }) + + t.Run("no-url-no-extract", func(t *testing.T) { + if got, ok := firstPreviewableURL("plain text without any link", nil); ok { + t.Fatalf("text without url should not extract, got %q", got) + } + }) + + t.Run("non-http-entity-skipped", func(t *testing.T) { + if _, ok := firstPreviewableURL("x", []tg.MessageEntityClass{ + &tg.MessageEntityTextURL{Offset: 0, Length: 1, URL: "ftp://example.com/x"}, + }); ok { + t.Fatalf("ftp URL should be rejected") + } + }) +} + +func urlEntity(t *testing.T, e tg.MessageEntityClass) *tg.MessageEntityURL { + t.Helper() + u, ok := e.(*tg.MessageEntityURL) + if !ok { + t.Fatalf("entity = %T, want *tg.MessageEntityURL", e) + } + return u +} + +// TestAugmentAutoEntitiesURL 验证服务端在客户端未带 url 实体时检测文本链接补 MessageEntityURL +// (高亮),UTF-16 偏移正确,多链接全检测,客户端已带 url 实体则不重复。 +func TestAugmentAutoEntitiesURL(t *testing.T) { + t.Run("detect-when-no-client-entities", func(t *testing.T) { + got := augmentAutoEntities("see https://example.com/x now", nil) + if len(got) != 1 { + t.Fatalf("entities = %d, want 1", len(got)) + } + e := urlEntity(t, got[0]) + if e.Offset != 4 || e.Length != 21 { + t.Errorf("offset/length = %d/%d, want 4/21", e.Offset, e.Length) + } + }) + t.Run("utf16-offset-with-emoji", func(t *testing.T) { + got := augmentAutoEntities("\U0001f44d https://x", nil) // 👍=2 units, space=1 → url at 3 + if len(got) != 1 { + t.Fatalf("entities = %d, want 1", len(got)) + } + if e := urlEntity(t, got[0]); e.Offset != 3 || e.Length != 9 { + t.Errorf("offset/length = %d/%d, want 3/9", e.Offset, e.Length) + } + }) + t.Run("multiple-urls", func(t *testing.T) { + if got := augmentAutoEntities("https://a.com and https://b.com", nil); len(got) != 2 { + t.Fatalf("entities = %d, want 2", len(got)) + } + }) + t.Run("respect-client-url-entity", func(t *testing.T) { + ents := []tg.MessageEntityClass{&tg.MessageEntityURL{Offset: 0, Length: 9}} + if got := augmentAutoEntities("https://x more https://y", ents); len(got) != 1 { + t.Fatalf("client url entity present → no server detection, got %d", len(got)) + } + }) + t.Run("trailing-punct-not-in-entity", func(t *testing.T) { + got := augmentAutoEntities("见 https://example.com。", nil) // 见 ...。 + e := urlEntity(t, got[0]) + if e.Length != utf16CodeUnitLen("https://example.com") { + t.Errorf("length = %d, want %d (trailing 。 excluded)", e.Length, utf16CodeUnitLen("https://example.com")) + } + }) + t.Run("no-url-no-entities", func(t *testing.T) { + if got := augmentAutoEntities("plain text", nil); len(got) != 0 { + t.Fatalf("entities = %d, want 0", len(got)) + } + }) +} + +// BenchmarkAugmentAutoEntities 量化发送热路径:纯文本(无触发字符)应零分配走快路径短路; +// 含 @mention/#hashtag/url 时才进入检测+分配。 +func BenchmarkAugmentAutoEntities(b *testing.B) { + b.Run("plain-text", func(b *testing.B) { + msg := "hey everyone, just wanted to share some thoughts about the meeting today and tomorrow" + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = augmentAutoEntities(msg, nil) + } + }) + b.Run("with-mention-hashtag-url", func(b *testing.B) { + msg := "hi @alice please check #golang docs at https://example.com/x thanks" + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = augmentAutoEntities(msg, nil) + } + }) +} + +// firstEntityOfType 返回切片里首个匹配类型的实体(找不到则 fail)。 +func mentionAt(t *testing.T, got []tg.MessageEntityClass, off, ln int) { + t.Helper() + for _, e := range got { + if m, ok := e.(*tg.MessageEntityMention); ok && m.Offset == off && m.Length == ln { + return + } + } + t.Fatalf("no MessageEntityMention at offset/length %d/%d in %#v", off, ln, got) +} + +// TestAugmentAutoEntitiesMention 验证 @mention/#hashtag/$cashtag/bot command 的服务端检测、 +// UTF-16 偏移、边界(email 不误判)与区间不重叠(不打进客户端富文本实体内部)。 +func TestAugmentAutoEntitiesMention(t *testing.T) { + t.Run("bare-mentions", func(t *testing.T) { + // 对齐官方抓包:纯 "@G0ldenMods\n@NGame_Official" → 两个裸 messageEntityMention,含前导 @。 + got := augmentAutoEntities("@G0ldenMods\n@NGame_Official", nil) + mentionAt(t, got, 0, 11) // @G0ldenMods = 10+1 + mentionAt(t, got, 12, 15) // @NGame_Official = 14+1 + }) + t.Run("mention-mid-text", func(t *testing.T) { + got := augmentAutoEntities("hi @alice see you", nil) + mentionAt(t, got, 3, 6) // @alice + }) + t.Run("email-not-a-mention", func(t *testing.T) { + for _, e := range augmentAutoEntities("mail me at bob@example.com please", nil) { + if _, ok := e.(*tg.MessageEntityMention); ok { + t.Fatalf("email local@domain must not yield a mention: %#v", e) + } + } + }) + t.Run("utf16-offset-with-emoji", func(t *testing.T) { + // 👍(2 units) + space(1) → @bob 起于 offset 3。 + got := augmentAutoEntities("\U0001f44d @bob", nil) + mentionAt(t, got, 3, 4) + }) + t.Run("no-overlap-with-client-entity", func(t *testing.T) { + // 客户端把 "@bob" 区间标成 textUrl([offset 0,len 4)),服务端不得再补 mention。 + ents := []tg.MessageEntityClass{&tg.MessageEntityTextURL{Offset: 0, Length: 4, URL: "https://x"}} + for _, e := range augmentAutoEntities("@bob", ents) { + if _, ok := e.(*tg.MessageEntityMention); ok { + t.Fatalf("mention must not overlap client entity: %#v", e) + } + } + }) + t.Run("no-mention-inside-raw-url-with-client-url-entity", func(t *testing.T) { + // 防回归:客户端带了某个 textUrl 实体(hasClientURL=true)但未包裹另一条裸 URL; + // 裸 URL 路径里的 @scam / #frag 不得被误标成 mention/hashtag(钓鱼风险)。 + msg := "Docs see https://t.me/@scam and #promo" + ents := []tg.MessageEntityClass{&tg.MessageEntityTextURL{Offset: 0, Length: 4, URL: "https://x"}} + got := augmentAutoEntities(msg, ents) + for _, e := range got { + if m, ok := e.(*tg.MessageEntityMention); ok { + t.Fatalf("mention must not be synthesised inside a raw URL: offset=%d len=%d", m.Offset, m.Length) + } + } + // URL 之外的 #promo 仍应高亮。 + var hasPromo bool + for _, e := range got { + if _, ok := e.(*tg.MessageEntityHashtag); ok { + hasPromo = true + } + } + if !hasPromo { + t.Fatalf("#promo outside the URL should still be a hashtag: %#v", got) + } + }) + t.Run("hashtag", func(t *testing.T) { + var found bool + for _, e := range augmentAutoEntities("love #golang here", nil) { + if h, ok := e.(*tg.MessageEntityHashtag); ok && h.Offset == 5 && h.Length == 7 { + found = true + } + } + if !found { + t.Fatalf("expected #golang hashtag at 5/7") + } + }) + t.Run("hashtag-all-digits-skipped", func(t *testing.T) { + for _, e := range augmentAutoEntities("number #123 here", nil) { + if _, ok := e.(*tg.MessageEntityHashtag); ok { + t.Fatalf("leading-digit hashtag must be skipped: %#v", e) + } + } + }) + t.Run("bot-command", func(t *testing.T) { + var found bool + for _, e := range augmentAutoEntities("/start@MyBot now", nil) { + if c, ok := e.(*tg.MessageEntityBotCommand); ok && c.Offset == 0 && c.Length == 12 { + found = true + } + } + if !found { + t.Fatalf("expected /start@MyBot bot command at 0/12") + } + }) + t.Run("slash-in-path-not-command", func(t *testing.T) { + for _, e := range augmentAutoEntities("see and/or maybe", nil) { + if _, ok := e.(*tg.MessageEntityBotCommand); ok { + t.Fatalf("and/or must not be a bot command: %#v", e) + } + } + }) + t.Run("cashtag", func(t *testing.T) { + var found bool + for _, e := range augmentAutoEntities("buy $USD now", nil) { + if c, ok := e.(*tg.MessageEntityCashtag); ok && c.Offset == 4 && c.Length == 4 { + found = true + } + } + if !found { + t.Fatalf("expected $USD cashtag at 4/4") + } + }) +} diff --git a/internal/rpc/webview_registry.go b/internal/rpc/webview_registry.go new file mode 100644 index 00000000..3e7b3a3e --- /dev/null +++ b/internal/rpc/webview_registry.go @@ -0,0 +1,169 @@ +package rpc + +import ( + "context" + "strconv" + "sync" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const webViewSessionTTL = 2 * time.Minute + +type webViewRegistry struct { + mu sync.Mutex + byQueryID map[int64]registeredWebViewSession + byBotID map[string]int64 + ttl time.Duration + shared store.InlineRegistryStore +} + +type registeredWebViewSession struct { + session store.WebViewSession + expiresAt time.Time +} + +func newWebViewRegistry(ttl time.Duration, shared ...store.InlineRegistryStore) *webViewRegistry { + if ttl <= 0 { + ttl = webViewSessionTTL + } + var sharedStore store.InlineRegistryStore + if len(shared) > 0 { + sharedStore = shared[0] + } + return &webViewRegistry{ + byQueryID: make(map[int64]registeredWebViewSession), + byBotID: make(map[string]int64), + ttl: ttl, + shared: sharedStore, + } +} + +func (r *webViewRegistry) registerContext(ctx context.Context, now time.Time, session store.WebViewSession) store.WebViewSession { + r.mu.Lock() + r.pruneLocked(now) + for { + session.QueryID = randomNonZeroInt64() + if _, exists := r.byQueryID[session.QueryID]; !exists { + break + } + } + session.BotQueryID = strconv.FormatInt(session.QueryID, 10) + session.CreatedAt = now + session.ExpiresAt = now.Add(r.ttl) + r.byQueryID[session.QueryID] = registeredWebViewSession{session: cloneWebViewSession(session), expiresAt: session.ExpiresAt} + r.byBotID[session.BotQueryID] = session.QueryID + r.mu.Unlock() + r.putShared(ctx, session) + return cloneWebViewSession(session) +} + +func (r *webViewRegistry) prolongContext(ctx context.Context, now time.Time, queryID int64, userID, botUserID int64, peer domain.Peer, silent bool, replyTo *domain.MessageReply, sendAs *domain.Peer) bool { + r.mu.Lock() + r.pruneLocked(now) + registered, ok := r.byQueryID[queryID] + if ok && registered.session.UserID == userID && registered.session.BotUserID == botUserID && registered.session.Peer == peer { + registered.session.Silent = silent + registered.session.ReplyTo = cloneMessageReply(replyTo) + registered.session.SendAs = clonePeerPtr(sendAs) + registered.expiresAt = now.Add(r.ttl) + registered.session.ExpiresAt = registered.expiresAt + r.byQueryID[queryID] = registered + r.mu.Unlock() + r.putShared(ctx, registered.session) + return true + } + r.mu.Unlock() + if r.shared == nil { + return false + } + session, found, err := r.shared.GetWebViewSession(ctx, queryID) + if err != nil || !found || session.UserID != userID || session.BotUserID != botUserID || session.Peer != peer { + return false + } + session.Silent = silent + session.ReplyTo = cloneMessageReply(replyTo) + session.SendAs = clonePeerPtr(sendAs) + session.ExpiresAt = now.Add(r.ttl) + r.putShared(ctx, session) + return true +} + +func (r *webViewRegistry) sessionForBotQueryContext(ctx context.Context, now time.Time, botUserID int64, botQueryID string) (store.WebViewSession, bool) { + r.mu.Lock() + r.pruneLocked(now) + if queryID, ok := r.byBotID[botQueryID]; ok { + registered, found := r.byQueryID[queryID] + if found && registered.session.BotUserID == botUserID { + out := cloneWebViewSession(registered.session) + r.mu.Unlock() + return out, true + } + } + r.mu.Unlock() + if r.shared == nil { + return store.WebViewSession{}, false + } + session, found, err := r.shared.GetWebViewSessionByBotQuery(ctx, botQueryID) + if err != nil || !found || session.BotUserID != botUserID { + return store.WebViewSession{}, false + } + return cloneWebViewSession(session), true +} + +func (r *webViewRegistry) consumeContext(ctx context.Context, queryID int64, botQueryID string) { + r.mu.Lock() + delete(r.byQueryID, queryID) + delete(r.byBotID, botQueryID) + r.mu.Unlock() + if r.shared != nil { + _ = r.shared.DeleteWebViewSession(ctx, queryID, botQueryID) + } +} + +func (r *webViewRegistry) size() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.byQueryID) +} + +func (r *webViewRegistry) putShared(ctx context.Context, session store.WebViewSession) { + if r.shared == nil || r.ttl <= 0 { + return + } + _ = r.shared.PutWebViewSession(ctx, cloneWebViewSession(session), r.ttl) +} + +func (r *webViewRegistry) pruneLocked(now time.Time) { + for queryID, registered := range r.byQueryID { + if !registered.expiresAt.After(now) { + delete(r.byQueryID, queryID) + delete(r.byBotID, registered.session.BotQueryID) + } + } +} + +func cloneWebViewSession(in store.WebViewSession) store.WebViewSession { + in.ReplyTo = cloneMessageReply(in.ReplyTo) + in.SendAs = clonePeerPtr(in.SendAs) + return in +} + +func clonePeerPtr(in *domain.Peer) *domain.Peer { + if in == nil { + return nil + } + out := *in + return &out +} + +func cloneMessageReply(in *domain.MessageReply) *domain.MessageReply { + if in == nil { + return nil + } + out := *in + out.QuoteEntities = append([]domain.MessageEntity(nil), in.QuoteEntities...) + return &out +} diff --git a/internal/seed/appearance/default_appearance_seed.json b/internal/seed/appearance/default_appearance_seed.json new file mode 100644 index 00000000..722aa782 --- /dev/null +++ b/internal/seed/appearance/default_appearance_seed.json @@ -0,0 +1,5837 @@ +{ + "source": "official telegram (appearancefetch)", + "exported_at": "2026-06-25T14:04:03Z", + "notes": { + "server": "official", + "themes_total": 0, + "chat_themes_total": 8, + "chat_theme_seed": "", + "wallpaper_documents_bucket": "documents", + "wallpaper_thumbs_bucket": "thumbs" + }, + "chat_themes": [ + { + "id": 5431620009950445886, + "access_hash": -5518302383155679890, + "slug": "xNY3PO2sJMrmvTQO", + "title": "🐥 Chat Theme", + "emoticon": "🐥", + "default": false, + "settings": [ + { + "base_theme": "classic", + "accent_color": 2732145, + "outbox_accent_color": 5025892, + "message_colors": [ + 15136213, + 15791825 + ], + "wallpaper": { + "id": 5784882925472317444, + "access_hash": 6373237550699652717, + "type": 0, + "default": false, + "pattern": true, + "dark": false, + "slug": "lp0prF8ISFAEAAAA_p385_CvG0w", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 7977128, + "second_background_color": 12834723, + "third_background_color": 14013845, + "fourth_background_color": 10866066, + "intensity": 42, + "rotation": 0 + }, + "document": { + "id": 5924650004621823334, + "access_hash": -6085381500874222887, + "date": 1623675210, + "mime_type": "application/x-tgwallpattern", + "size": 51194, + "dc_id": 4, + "path": "default_wallpapers/documents/5924650004621823334.dat", + "sha256": "d4f22426a343881c41b6c2121ca8ce580c6fcb5a9a2d6a5a8447a3627b6a63e4", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 20067, + "path": "default_wallpapers/thumbs/m/5924650004621823334.dat", + "sha256": "ba7dc8d04fe5d76d185c34933544304891a7da9ee0f83cbbe5bcddda3a526586" + } + ] + } + } + }, + { + "base_theme": "tinted", + "accent_color": 5674079, + "outbox_accent_color": 0, + "message_colors": [ + 6192443, + 2128220 + ], + "wallpaper": { + "id": 5784882925472317444, + "access_hash": 6373237550699652717, + "type": 0, + "default": false, + "pattern": true, + "dark": true, + "slug": "lp0prF8ISFAEAAAA_p385_CvG0w", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 2310725, + "second_background_color": 7174476, + "third_background_color": 2047025, + "fourth_background_color": 5541474, + "intensity": -58, + "rotation": 0 + }, + "document": { + "id": 5924650004621823334, + "access_hash": -6085381500874222887, + "date": 1623675210, + "mime_type": "application/x-tgwallpattern", + "size": 51194, + "dc_id": 4, + "path": "default_wallpapers/documents/5924650004621823334.dat", + "sha256": "d4f22426a343881c41b6c2121ca8ce580c6fcb5a9a2d6a5a8447a3627b6a63e4", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 20067, + "path": "default_wallpapers/thumbs/m/5924650004621823334.dat", + "sha256": "ba7dc8d04fe5d76d185c34933544304891a7da9ee0f83cbbe5bcddda3a526586" + } + ] + } + } + } + ] + }, + { + "id": 5431729175134208256, + "access_hash": 7029015570293832486, + "slug": "A4lfIVsZKoXA8B2Q", + "title": "⛄️ Chat Theme", + "emoticon": "⛄", + "default": false, + "settings": [ + { + "base_theme": "classic", + "accent_color": 3317463, + "outbox_accent_color": 4367839, + "message_colors": [ + 15727612, + 15333371 + ], + "wallpaper": { + "id": 5785021373743104005, + "access_hash": -1374597781576365315, + "type": 0, + "default": false, + "pattern": true, + "dark": false, + "slug": "MIo6r0qGSFAFAAAAtL8TsDzNX60", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 8497648, + "second_background_color": 10343668, + "third_background_color": 11460562, + "fourth_background_color": 12304367, + "intensity": 41, + "rotation": 0 + }, + "document": { + "id": 5782630687571969871, + "access_hash": 5918281820082603968, + "date": 1577635137, + "mime_type": "application/x-tgwallpattern", + "size": 100992, + "dc_id": 4, + "path": "default_wallpapers/documents/5782630687571969871.dat", + "sha256": "14ca7c554f48041b0b2a9f7f39034f9b255e5fcac966f07b4d5b80292f806836", + "attributes": [ + { + "kind": "image_size", + "w": 1125, + "h": 2436 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 148, + "h": 320, + "size": 27697, + "path": "default_wallpapers/thumbs/m/5782630687571969871.dat", + "sha256": "f1cd490a3d14ea50c80a3a7a279e350f1337b5a49eef4cd7fa612a9285fe0b92" + } + ] + } + } + }, + { + "base_theme": "tinted", + "accent_color": 5019631, + "outbox_accent_color": 0, + "message_colors": [ + 2261394, + 3305130 + ], + "wallpaper": { + "id": 5785021373743104005, + "access_hash": -1374597781576365315, + "type": 0, + "default": false, + "pattern": true, + "dark": true, + "slug": "MIo6r0qGSFAFAAAAtL8TsDzNX60", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 5274253, + "second_background_color": 2109771, + "third_background_color": 2122357, + "fourth_background_color": 1582382, + "intensity": -57, + "rotation": 0 + }, + "document": { + "id": 5782630687571969871, + "access_hash": 5918281820082603968, + "date": 1577635137, + "mime_type": "application/x-tgwallpattern", + "size": 100992, + "dc_id": 4, + "path": "default_wallpapers/documents/5782630687571969871.dat", + "sha256": "14ca7c554f48041b0b2a9f7f39034f9b255e5fcac966f07b4d5b80292f806836", + "attributes": [ + { + "kind": "image_size", + "w": 1125, + "h": 2436 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 148, + "h": 320, + "size": 27697, + "path": "default_wallpapers/thumbs/m/5782630687571969871.dat", + "sha256": "f1cd490a3d14ea50c80a3a7a279e350f1337b5a49eef4cd7fa612a9285fe0b92" + } + ] + } + } + } + ] + }, + { + "id": 5431859523096674543, + "access_hash": 3246772735563347235, + "slug": "gTeHBidjxfjMZI6q", + "title": "💎 Chat Theme", + "emoticon": "💎", + "default": false, + "settings": [ + { + "base_theme": "classic", + "accent_color": 10776791, + "outbox_accent_color": 5550056, + "message_colors": [ + 15136767 + ], + "wallpaper": { + "id": 5951625624761139201, + "access_hash": 9167834799975181299, + "type": 0, + "default": false, + "pattern": true, + "dark": false, + "slug": "p-pXcflrmFIBAAAAvXYQk-mCwZU", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 9944811, + "second_background_color": 11659754, + "third_background_color": 13021679, + "fourth_background_color": 15710172, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5926965009174236185, + "access_hash": 7051802135375127993, + "date": 1623675237, + "mime_type": "application/x-tgwallpattern", + "size": 93075, + "dc_id": 4, + "path": "default_wallpapers/documents/5926965009174236185.dat", + "sha256": "46a09e7c981b59cc7cfb2e6af49eff13c8766547f7fddebf92c95c6614dadd03", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 21763, + "path": "default_wallpapers/thumbs/m/5926965009174236185.dat", + "sha256": "3b5452b3686bac30db7fcfe6b7ac1d497d4a6a0c083b1a362a41836412bd55f3" + } + ] + } + } + }, + { + "base_theme": "tinted", + "accent_color": 8806573, + "outbox_accent_color": 0, + "message_colors": [ + 2456746, + 4869798 + ], + "wallpaper": { + "id": 5951625624761139201, + "access_hash": 9167834799975181299, + "type": 0, + "default": false, + "pattern": true, + "dark": true, + "slug": "p-pXcflrmFIBAAAAvXYQk-mCwZU", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 2499660, + "second_background_color": 4286356, + "third_background_color": 1713474, + "fourth_background_color": 7295121, + "intensity": -58, + "rotation": 0 + }, + "document": { + "id": 5926965009174236185, + "access_hash": 7051802135375127993, + "date": 1623675237, + "mime_type": "application/x-tgwallpattern", + "size": 93075, + "dc_id": 4, + "path": "default_wallpapers/documents/5926965009174236185.dat", + "sha256": "46a09e7c981b59cc7cfb2e6af49eff13c8766547f7fddebf92c95c6614dadd03", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 21763, + "path": "default_wallpapers/thumbs/m/5926965009174236185.dat", + "sha256": "3b5452b3686bac30db7fcfe6b7ac1d497d4a6a0c083b1a362a41836412bd55f3" + } + ] + } + } + } + ] + }, + { + "id": 5431737687759388684, + "access_hash": -4556517254481605588, + "slug": "Bv3x7OfgnH5vHB5s", + "title": "👨‍🏫 Chat Theme", + "emoticon": "👨‍🏫", + "default": false, + "settings": [ + { + "base_theme": "classic", + "accent_color": 2143654, + "outbox_accent_color": 3452533, + "message_colors": [ + 16055267 + ], + "wallpaper": { + "id": 5785191424383254532, + "access_hash": 6428855567842967483, + "type": 0, + "default": false, + "pattern": true, + "dark": false, + "slug": "CJNyxPMgSVAEAAAAvW9sMwc51cw", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 13755285, + "second_background_color": 9556684, + "third_background_color": 7780582, + "fourth_background_color": 9557658, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5785343895722264360, + "access_hash": -1349142750469150145, + "date": 1577635126, + "mime_type": "application/x-tgwallpattern", + "size": 312605, + "dc_id": 4, + "path": "default_wallpapers/documents/5785343895722264360.dat", + "sha256": "aac3993d60e148cf79cd5eba29ed4e83cf1d25c08e9d704f729de60398fb77cf", + "attributes": [ + { + "kind": "image_size", + "w": 1125, + "h": 2436 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 148, + "h": 320, + "size": 22921, + "path": "default_wallpapers/thumbs/m/5785343895722264360.dat", + "sha256": "5c474207324d26b5b42a31aee61f251f775b1bae89df6087db6255fe2c050adb" + } + ] + } + } + }, + { + "base_theme": "tinted", + "accent_color": 5083299, + "outbox_accent_color": 0, + "message_colors": [ + 5732947, + 3697773 + ], + "wallpaper": { + "id": 5785191424383254532, + "access_hash": 6428855567842967483, + "type": 0, + "default": false, + "pattern": true, + "dark": true, + "slug": "CJNyxPMgSVAEAAAAvW9sMwc51cw", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 4223869, + "second_background_color": 1916771, + "third_background_color": 5797203, + "fourth_background_color": 1656130, + "intensity": -61, + "rotation": 0 + }, + "document": { + "id": 5785343895722264360, + "access_hash": -1349142750469150145, + "date": 1577635126, + "mime_type": "application/x-tgwallpattern", + "size": 312605, + "dc_id": 4, + "path": "default_wallpapers/documents/5785343895722264360.dat", + "sha256": "aac3993d60e148cf79cd5eba29ed4e83cf1d25c08e9d704f729de60398fb77cf", + "attributes": [ + { + "kind": "image_size", + "w": 1125, + "h": 2436 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 148, + "h": 320, + "size": 22921, + "path": "default_wallpapers/thumbs/m/5785343895722264360.dat", + "sha256": "5c474207324d26b5b42a31aee61f251f775b1bae89df6087db6255fe2c050adb" + } + ] + } + } + } + ] + }, + { + "id": 5431380243401146580, + "access_hash": -3467763718429009737, + "slug": "bi2FDC2vpdwo68nV", + "title": "🌷 Chat Theme", + "emoticon": "🌷", + "default": false, + "settings": [ + { + "base_theme": "classic", + "accent_color": 13658513, + "outbox_accent_color": 13933382, + "message_colors": [ + 16773603 + ], + "wallpaper": { + "id": 5785068300555780101, + "access_hash": -4335874468273472323, + "type": 0, + "default": false, + "pattern": true, + "dark": false, + "slug": "mP3FG_iwSFAFAAAA2AklJO978pA", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 15511182, + "second_background_color": 15254915, + "third_background_color": 15442113, + "fourth_background_color": 13087211, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5924664689115007842, + "access_hash": 1915920747143758593, + "date": 1623675212, + "mime_type": "application/x-tgwallpattern", + "size": 51705, + "dc_id": 4, + "path": "default_wallpapers/documents/5924664689115007842.dat", + "sha256": "13fa80d11e6efff9cfab49499d51ce9eb44236d105a2333f85783b443ca3be2c", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 23099, + "path": "default_wallpapers/thumbs/m/5924664689115007842.dat", + "sha256": "e8e7ed0e76fc2250aca1c70b0bdd12fe5f7d0e697925acdde353b5d4251bd5af" + } + ] + } + } + }, + { + "base_theme": "tinted", + "accent_color": 12411780, + "outbox_accent_color": 0, + "message_colors": [ + 12151869, + 10044247 + ], + "wallpaper": { + "id": 5785068300555780101, + "access_hash": -4335874468273472323, + "type": 0, + "default": false, + "pattern": true, + "dark": true, + "slug": "mP3FG_iwSFAFAAAA2AklJO978pA", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 8085921, + "second_background_color": 5254203, + "third_background_color": 10644559, + "fourth_background_color": 6042922, + "intensity": -58, + "rotation": 0 + }, + "document": { + "id": 5924664689115007842, + "access_hash": 1915920747143758593, + "date": 1623675212, + "mime_type": "application/x-tgwallpattern", + "size": 51705, + "dc_id": 4, + "path": "default_wallpapers/documents/5924664689115007842.dat", + "sha256": "13fa80d11e6efff9cfab49499d51ce9eb44236d105a2333f85783b443ca3be2c", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 23099, + "path": "default_wallpapers/thumbs/m/5924664689115007842.dat", + "sha256": "e8e7ed0e76fc2250aca1c70b0bdd12fe5f7d0e697925acdde353b5d4251bd5af" + } + ] + } + } + } + ] + }, + { + "id": 5431868061491658948, + "access_hash": -2820428965927352199, + "slug": "RL9GYdQPmt4vLDlc", + "title": "💜 Chat Theme", + "emoticon": "💜", + "default": false, + "settings": [ + { + "base_theme": "classic", + "accent_color": 10776791, + "outbox_accent_color": 14248063, + "message_colors": [ + 16772078 + ], + "wallpaper": { + "id": 5784961613568147459, + "access_hash": -6471405065895252908, + "type": 0, + "default": false, + "pattern": true, + "dark": false, + "slug": "O-wmAfBPSFADAAAA4zINVfD_bro", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 15308995, + "second_background_color": 15312086, + "third_background_color": 12034025, + "fourth_background_color": 15511668, + "intensity": 57, + "rotation": 0 + }, + "document": { + "id": 5924577350955046421, + "access_hash": -7660463103919126028, + "date": 1623675168, + "mime_type": "application/x-tgwallpattern", + "size": 64363, + "dc_id": 4, + "path": "default_wallpapers/documents/5924577350955046421.dat", + "sha256": "e6685d07e85a212f8a5ee6cb2c570983291ffd404a937fc896e7608a5bf79387", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 21485, + "path": "default_wallpapers/thumbs/m/5924577350955046421.dat", + "sha256": "a55b07964ba24344e2288d6f4c245e9944ee011c0952a52b20769624266844a7" + } + ] + } + } + }, + { + "base_theme": "tinted", + "accent_color": 8676013, + "outbox_accent_color": 0, + "message_colors": [ + 10834805, + 6701434 + ], + "wallpaper": { + "id": 5784961613568147459, + "access_hash": -6471405065895252908, + "type": 0, + "default": false, + "pattern": true, + "dark": true, + "slug": "O-wmAfBPSFADAAAA4zINVfD_bro", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 7230895, + "second_background_color": 3616344, + "third_background_color": 8671107, + "fourth_background_color": 5193521, + "intensity": -59, + "rotation": 0 + }, + "document": { + "id": 5924577350955046421, + "access_hash": -7660463103919126028, + "date": 1623675168, + "mime_type": "application/x-tgwallpattern", + "size": 64363, + "dc_id": 4, + "path": "default_wallpapers/documents/5924577350955046421.dat", + "sha256": "e6685d07e85a212f8a5ee6cb2c570983291ffd404a937fc896e7608a5bf79387", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 21485, + "path": "default_wallpapers/thumbs/m/5924577350955046421.dat", + "sha256": "a55b07964ba24344e2288d6f4c245e9944ee011c0952a52b20769624266844a7" + } + ] + } + } + } + ] + }, + { + "id": 5431640436814905452, + "access_hash": 327550698806611630, + "slug": "nfCx0YBFOvxIX8D2", + "title": "🎄 Chat Theme", + "emoticon": "🎄", + "default": false, + "settings": [ + { + "base_theme": "classic", + "accent_color": 5612377, + "outbox_accent_color": 13539151, + "message_colors": [ + 16775136 + ], + "wallpaper": { + "id": 6041986402319597570, + "access_hash": -8909137552203056986, + "type": 0, + "default": false, + "pattern": true, + "dark": false, + "slug": "-Xc-np9y2VMCAAAARKr0yNNPYW0", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 14721899, + "second_background_color": 15449723, + "third_background_color": 14718336, + "fourth_background_color": 16308350, + "intensity": 64, + "rotation": 0 + }, + "document": { + "id": 5789856918507882132, + "access_hash": 8543131078280161834, + "date": 1577783794, + "mime_type": "application/x-tgwallpattern", + "size": 104932, + "dc_id": 4, + "path": "default_wallpapers/documents/5789856918507882132.dat", + "sha256": "639ffa8ecf6cdae952ac6888bdf6d2a2aad73fbaaa84ae5eb60e3e280dd3f44d", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 19643, + "path": "default_wallpapers/thumbs/m/5789856918507882132.dat", + "sha256": "cd5051b69177d544ea65a016d43907e34fef6fb9b2804c444c317353ee5ff84d" + } + ] + } + } + }, + { + "base_theme": "tinted", + "accent_color": 5866826, + "outbox_accent_color": 0, + "message_colors": [ + 11371840, + 11431746 + ], + "wallpaper": { + "id": 6041986402319597570, + "access_hash": -8909137552203056986, + "type": 0, + "default": false, + "pattern": true, + "dark": true, + "slug": "-Xc-np9y2VMCAAAARKr0yNNPYW0", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 4155708, + "second_background_color": 4666134, + "third_background_color": 7495993, + "fourth_background_color": 2439462, + "intensity": -60, + "rotation": 0 + }, + "document": { + "id": 5789856918507882132, + "access_hash": 8543131078280161834, + "date": 1577783794, + "mime_type": "application/x-tgwallpattern", + "size": 104932, + "dc_id": 4, + "path": "default_wallpapers/documents/5789856918507882132.dat", + "sha256": "639ffa8ecf6cdae952ac6888bdf6d2a2aad73fbaaa84ae5eb60e3e280dd3f44d", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 19643, + "path": "default_wallpapers/thumbs/m/5789856918507882132.dat", + "sha256": "cd5051b69177d544ea65a016d43907e34fef6fb9b2804c444c317353ee5ff84d" + } + ] + } + } + } + ] + }, + { + "id": 5431418395595636796, + "access_hash": 1870041133551048436, + "slug": "Nu0Fj2QDIwv7mg7O", + "title": "🎮 Chat Theme", + "emoticon": "🎮", + "default": false, + "settings": [ + { + "base_theme": "classic", + "accent_color": 6916326, + "outbox_accent_color": 13410377, + "message_colors": [ + 16774101 + ], + "wallpaper": { + "id": 5785007509588672513, + "access_hash": 8437532349638900210, + "type": 0, + "default": false, + "pattern": true, + "dark": false, + "slug": "kO4jyq55SFABAAAA0WEpcLfahXk", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 4247006, + "second_background_color": 11306733, + "third_background_color": 15303896, + "fourth_background_color": 14724429, + "intensity": 52, + "rotation": 0 + }, + "document": { + "id": 5925009274341165314, + "access_hash": 4887297134680621548, + "date": 1623675173, + "mime_type": "application/x-tgwallpattern", + "size": 78338, + "dc_id": 4, + "path": "default_wallpapers/documents/5925009274341165314.dat", + "sha256": "508de399917ee40d301f5fee2ad47ee4a3e075dc46fda5747a761ab81c64394e", + "attributes": [ + { + "kind": "image_size", + "w": 1125, + "h": 2313 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 21843, + "path": "default_wallpapers/thumbs/m/5925009274341165314.dat", + "sha256": "1c92f7b25aa632bd9d75e99ba4d6de465b6af14b432f67b1bef4672b787f61fe" + } + ] + } + } + }, + { + "base_theme": "tinted", + "accent_color": 7370188, + "outbox_accent_color": 0, + "message_colors": [ + 11958341, + 9848425, + 6375839, + 2981550 + ], + "wallpaper": { + "id": 5785007509588672513, + "access_hash": 8437532349638900210, + "type": 0, + "default": false, + "pattern": true, + "dark": true, + "slug": "kO4jyq55SFABAAAA0WEpcLfahXk", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 4226963, + "second_background_color": 1326682, + "third_background_color": 6708886, + "fourth_background_color": 3878219, + "intensity": -58, + "rotation": 0 + }, + "document": { + "id": 5925009274341165314, + "access_hash": 4887297134680621548, + "date": 1623675173, + "mime_type": "application/x-tgwallpattern", + "size": 78338, + "dc_id": 4, + "path": "default_wallpapers/documents/5925009274341165314.dat", + "sha256": "508de399917ee40d301f5fee2ad47ee4a3e075dc46fda5747a761ab81c64394e", + "attributes": [ + { + "kind": "image_size", + "w": 1125, + "h": 2313 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 21843, + "path": "default_wallpapers/thumbs/m/5925009274341165314.dat", + "sha256": "1c92f7b25aa632bd9d75e99ba4d6de465b6af14b432f67b1bef4672b787f61fe" + } + ] + } + } + } + ] + } + ], + "wallpapers": [ + { + "id": 5911321041770119175, + "access_hash": -2688071502994270847, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "aHrFIr08CFILAAAAkrBxhl8fIIs", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 14409147, + "second_background_color": 7054727, + "third_background_color": 14014605, + "fourth_background_color": 8960132, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5911348641229966652, + "access_hash": -1930209429829551338, + "date": 1631642470, + "mime_type": "application/x-tgwallpattern", + "size": 95615, + "dc_id": 4, + "path": "default_wallpapers/documents/5911348641229966652.dat", + "sha256": "0d746c6e7c789d911b891b69b9400101195d6fb7af6da609e7f13994d37f20ea", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 18590, + "path": "default_wallpapers/thumbs/m/5911348641229966652.dat", + "sha256": "7d1de4346651358d67757dd6653329d004c53abf382f0f8c9179c8e2520bb301" + } + ] + } + }, + { + "id": 5938378820338843661, + "access_hash": -2248763628030422934, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "5emFQzCUaVIGAAAA9SKr-_wL9BM", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 4247006, + "second_background_color": 11306733, + "third_background_color": 15303896, + "fourth_background_color": 15717209, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5935909802554165816, + "access_hash": 5333956258143399216, + "date": 1640787934, + "mime_type": "application/x-tgwallpattern", + "size": 79251, + "dc_id": 4, + "path": "default_wallpapers/documents/5935909802554165816.dat", + "sha256": "8d604e4f1c068f0145e5cb1ad8cae5320aa4264fe29d35244e24736adcf3f3af", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 26958, + "path": "default_wallpapers/thumbs/m/5935909802554165816.dat", + "sha256": "ae9a188bef9b0553a1db9973dcd508592d165eca99c562339833247d1bdf8edb" + } + ] + } + }, + { + "id": 5938313824598753287, + "access_hash": -8493643182239006494, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "XGwsGme_YFIJAAAAz8s_a_KjI2w", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 15377262, + "second_background_color": 15787142, + "third_background_color": 15900351, + "fourth_background_color": 15253614, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5936306687597087418, + "access_hash": 9198000018341170754, + "date": 1640787840, + "mime_type": "application/x-tgwallpattern", + "size": 64706, + "dc_id": 4, + "path": "default_wallpapers/documents/5936306687597087418.dat", + "sha256": "312003f138cdf5020f3fbfbe07e9d405219f1fcb66361605ca9aae1b02af450a", + "attributes": [ + { + "kind": "image_size", + "w": 1441, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 23799, + "path": "default_wallpapers/thumbs/m/5936306687597087418.dat", + "sha256": "35cee22f1f619fb7304842b9579e67ce652ef1321db3ada81f3b970e401dcb15" + } + ] + } + }, + { + "id": 5911475832391467014, + "access_hash": 5692462314232488857, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "wXkbd5fNCVIIAAAAEovnjGauGRs", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 14346955, + "second_background_color": 10663167, + "third_background_color": 15518719, + "fourth_background_color": 12182271, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5911275789994690585, + "access_hash": -3865232324647444154, + "date": 1631642081, + "mime_type": "application/x-tgwallpattern", + "size": 116856, + "dc_id": 4, + "path": "default_wallpapers/documents/5911275789994690585.dat", + "sha256": "60fc4a57e0a6a40fcffd2c2af22d1dff13ea3548f3a07fc111905b080ecfc421", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 27229, + "path": "default_wallpapers/thumbs/m/5911275789994690585.dat", + "sha256": "caaa7db0ffcb2e793a4873fcc7c75e2e1f1ec0c3006fc902642327201e6a1eb1" + } + ] + } + }, + { + "id": 5935870413409091596, + "access_hash": -7969814683699429140, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "qFcjc2DRYFIIAAAASlvGEqEL6cM", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 8372873, + "second_background_color": 14996851, + "third_background_color": 11523703, + "fourth_background_color": 15777914, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5935983444063423035, + "access_hash": -1103248837830423175, + "date": 1640787967, + "mime_type": "application/x-tgwallpattern", + "size": 69640, + "dc_id": 4, + "path": "default_wallpapers/documents/5935983444063423035.dat", + "sha256": "ca43fe321447af2ec80f9fd4877a2c3d520e29871869e7ae5f0470dc2421508e", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 26745, + "path": "default_wallpapers/thumbs/m/5935983444063423035.dat", + "sha256": "8045087f94706cd3d25cb19c4e8bc31336412f4b048b2ba3f3217d151dc85eec" + } + ] + } + }, + { + "id": 5936023047956856843, + "access_hash": 2595292550379615670, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "y-kz9Pn5YFISAAAAKzTRUSqr5nE", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 11710958, + "second_background_color": 13936585, + "third_background_color": 7113940, + "fourth_background_color": 0, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5935747671833709896, + "access_hash": 2638583687556510258, + "date": 1640788016, + "mime_type": "application/x-tgwallpattern", + "size": 103005, + "dc_id": 4, + "path": "default_wallpapers/documents/5935747671833709896.dat", + "sha256": "afc4d414d8aa5ad3b15ff07913b521175ae0ffac7af4c5a215722b931f4f8b07", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 28957, + "path": "default_wallpapers/thumbs/m/5935747671833709896.dat", + "sha256": "f3cbf547c4077d3cc85936623cfbb45c0aeaab10a14e82973f385a5699dc61bb" + } + ] + } + }, + { + "id": 5938464629490450443, + "access_hash": -4091120853939648078, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "DRaa0SbvYVIjAAAAWv3uHfEiYyI", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 10773748, + "second_background_color": 15576140, + "third_background_color": 15297711, + "fourth_background_color": 16244077, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5938052961170098801, + "access_hash": -4124189991439125849, + "date": 1640788085, + "mime_type": "application/x-tgwallpattern", + "size": 153409, + "dc_id": 4, + "path": "default_wallpapers/documents/5938052961170098801.dat", + "sha256": "49f561c9fcf166572827e48c3164f0749ca14461491794d772384213ceee24c0", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 29658, + "path": "default_wallpapers/thumbs/m/5938052961170098801.dat", + "sha256": "57cf7983a52fa51c626ba2b2bd60b0b453e2aa05ea93417af7b850bb3ef3a360" + } + ] + } + }, + { + "id": 5931598497202569229, + "access_hash": 7632322745452415286, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "CJNyxPMgSVAEAAAAvW9sMwc51cw", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 14478226, + "second_background_color": 9429462, + "third_background_color": 6792178, + "fourth_background_color": 8771205, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5785343895722264360, + "access_hash": -1349142750469150145, + "date": 1577635126, + "mime_type": "application/x-tgwallpattern", + "size": 312605, + "dc_id": 4, + "path": "default_wallpapers/documents/5785343895722264360.dat", + "sha256": "aac3993d60e148cf79cd5eba29ed4e83cf1d25c08e9d704f729de60398fb77cf", + "attributes": [ + { + "kind": "image_size", + "w": 1125, + "h": 2436 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 148, + "h": 320, + "size": 22921, + "path": "default_wallpapers/thumbs/m/5785343895722264360.dat", + "sha256": "5c474207324d26b5b42a31aee61f251f775b1bae89df6087db6255fe2c050adb" + } + ] + } + }, + { + "id": 5938093982402740236, + "access_hash": 3408558803277330093, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "DRaa0SbvYVIjAAAAWv3uHfEiYyI", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 16696470, + "second_background_color": 14511289, + "third_background_color": 9842623, + "fourth_background_color": 5200853, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5938052961170098801, + "access_hash": -4124189991439125849, + "date": 1640788085, + "mime_type": "application/x-tgwallpattern", + "size": 153409, + "dc_id": 4, + "path": "default_wallpapers/documents/5938052961170098801.dat", + "sha256": "49f561c9fcf166572827e48c3164f0749ca14461491794d772384213ceee24c0", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 29658, + "path": "default_wallpapers/thumbs/m/5938052961170098801.dat", + "sha256": "57cf7983a52fa51c626ba2b2bd60b0b453e2aa05ea93417af7b850bb3ef3a360" + } + ] + } + }, + { + "id": 5936246858702651395, + "access_hash": 8251351816501427870, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "4fz0g1CFYVIJAAAAbTViJ9W4tCw", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 7588607, + "second_background_color": 14022655, + "third_background_color": 16489189, + "fourth_background_color": 16505318, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5935950531729033666, + "access_hash": -6764473010899792693, + "date": 1640787228, + "mime_type": "application/x-tgwallpattern", + "size": 84240, + "dc_id": 4, + "path": "default_wallpapers/documents/5935950531729033666.dat", + "sha256": "2c57f3c16dca071c8d582139fe7ac9a04e67425ff4ea086ca11b6f21805cbb6d", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 27443, + "path": "default_wallpapers/thumbs/m/5935950531729033666.dat", + "sha256": "9d8afa412c01da7b08946dfe4f984eec76512a00dc0efdee9e43e2a060650d24" + } + ] + } + }, + { + "id": 5938306707837943827, + "access_hash": 8623705823865873892, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "8u8Y1ggMYVITAAAAluQYztxHp6s", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 9100274, + "second_background_color": 8949228, + "third_background_color": 14917610, + "fourth_background_color": 6790381, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5938102022581521601, + "access_hash": 6636732675680556029, + "date": 1640787993, + "mime_type": "application/x-tgwallpattern", + "size": 89269, + "dc_id": 4, + "path": "default_wallpapers/documents/5938102022581521601.dat", + "sha256": "f39613ee0de73fc3cd970afa66199f2322a2b83465fc75e373f003e875deb0ed", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 27601, + "path": "default_wallpapers/thumbs/m/5938102022581521601.dat", + "sha256": "dc2111e533e8cf88f04b906be91a6ad0f289321270b6ec5c70f1ded4630fa0db" + } + ] + } + }, + { + "id": 5933728358599753732, + "access_hash": -189089280568048496, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "MIo6r0qGSFAFAAAAtL8TsDzNX60", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 11587051, + "second_background_color": 10465514, + "third_background_color": 12315349, + "fourth_background_color": 11723741, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5782630687571969871, + "access_hash": 5918281820082603968, + "date": 1577635137, + "mime_type": "application/x-tgwallpattern", + "size": 100992, + "dc_id": 4, + "path": "default_wallpapers/documents/5782630687571969871.dat", + "sha256": "14ca7c554f48041b0b2a9f7f39034f9b255e5fcac966f07b4d5b80292f806836", + "attributes": [ + { + "kind": "image_size", + "w": 1125, + "h": 2436 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 148, + "h": 320, + "size": 27697, + "path": "default_wallpapers/thumbs/m/5782630687571969871.dat", + "sha256": "f1cd490a3d14ea50c80a3a7a279e350f1337b5a49eef4cd7fa612a9285fe0b92" + } + ] + } + }, + { + "id": 5938044233796550666, + "access_hash": -4155123133797581134, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "a6R_AF-8aVILAAAAXJ02JEsgvwM", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 16761778, + "second_background_color": 14860543, + "third_background_color": 16770994, + "fourth_background_color": 0, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5938359140798696416, + "access_hash": 5207849304615058380, + "date": 1640788043, + "mime_type": "application/x-tgwallpattern", + "size": 68050, + "dc_id": 4, + "path": "default_wallpapers/documents/5938359140798696416.dat", + "sha256": "87b66a852f9994b1a328cbe01e15a0d7072b2e6d30145724312e4420d807a63d", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 25997, + "path": "default_wallpapers/thumbs/m/5938359140798696416.dat", + "sha256": "7d7779c938f3a0edd0532b76a19694cd31ce58db073fb2084df589145c9112ff" + } + ] + } + }, + { + "id": 5936015802347028490, + "access_hash": -2463593253645958003, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "BbkPzy4qaVINAAAAaB2Pd5Iy7Zg", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 9944811, + "second_background_color": 11659754, + "third_background_color": 13021679, + "fourth_background_color": 15710172, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5938515597867354374, + "access_hash": -1100914462292174654, + "date": 1640788062, + "mime_type": "application/x-tgwallpattern", + "size": 129956, + "dc_id": 4, + "path": "default_wallpapers/documents/5938515597867354374.dat", + "sha256": "c0ae697329c4c3c57c4b3c016f1f4ab8757be10217c19ab9b6c0873ac8e8d497", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 26373, + "path": "default_wallpapers/thumbs/m/5938515597867354374.dat", + "sha256": "af5d43f2ac554edfcd03ed085fcdcfd4dca5fc551597273d67bd5cbfcb2f06cd" + } + ] + } + }, + { + "id": 5935904068772823057, + "access_hash": -6028246774677063340, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "bJcwphEAYVINAAAA5jpWNRMqilA", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 14988010, + "second_background_color": 8615618, + "third_background_color": 15383001, + "fourth_background_color": 11834342, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5938074474661284850, + "access_hash": -977544060216020041, + "date": 1640788123, + "mime_type": "application/x-tgwallpattern", + "size": 227751, + "dc_id": 4, + "path": "default_wallpapers/documents/5938074474661284850.dat", + "sha256": "42bbce746ed5e073ceb80a01eafa332d0d5ac077662359bac0d851e24176a19b", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 27263, + "path": "default_wallpapers/thumbs/m/5938074474661284850.dat", + "sha256": "ea93809e3fecaa61d77ed489441ce816582dea64c2db2c20af63af1aa503b670" + } + ] + } + }, + { + "id": 5935976911418163210, + "access_hash": -7726220695213426137, + "type": 0, + "default": true, + "pattern": true, + "dark": false, + "slug": "4fz0g1CFYVIJAAAAbTViJ9W4tCw", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 13738978, + "second_background_color": 15586708, + "third_background_color": 15049168, + "fourth_background_color": 15521939, + "intensity": 50, + "rotation": 0 + }, + "document": { + "id": 5935950531729033666, + "access_hash": -6764473010899792693, + "date": 1640787228, + "mime_type": "application/x-tgwallpattern", + "size": 84240, + "dc_id": 4, + "path": "default_wallpapers/documents/5935950531729033666.dat", + "sha256": "2c57f3c16dca071c8d582139fe7ac9a04e67425ff4ea086ca11b6f21805cbb6d", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 27443, + "path": "default_wallpapers/thumbs/m/5935950531729033666.dat", + "sha256": "9d8afa412c01da7b08946dfe4f984eec76512a00dc0efdee9e43e2a060650d24" + } + ] + } + }, + { + "id": 3388302328334385153, + "access_hash": 0, + "type": 1, + "default": true, + "pattern": false, + "dark": false, + "slug": "", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 14409147, + "second_background_color": 7054727, + "third_background_color": 14014605, + "fourth_background_color": 8960132, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 0, + "access_hash": 0, + "date": 0, + "mime_type": "", + "size": 0, + "dc_id": 0, + "path": "", + "sha256": "", + "attributes": null, + "thumbs": null + } + }, + { + "id": 3388302328334385154, + "access_hash": 0, + "type": 1, + "default": true, + "pattern": false, + "dark": false, + "slug": "", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 4430591, + "second_background_color": 16697475, + "third_background_color": 16706764, + "fourth_background_color": 12505855, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 0, + "access_hash": 0, + "date": 0, + "mime_type": "", + "size": 0, + "dc_id": 0, + "path": "", + "sha256": "", + "attributes": null, + "thumbs": null + } + }, + { + "id": 3388302328334385155, + "access_hash": 0, + "type": 1, + "default": true, + "pattern": false, + "dark": false, + "slug": "", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 9399223, + "second_background_color": 13920675, + "third_background_color": 16367995, + "fourth_background_color": 16506533, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 0, + "access_hash": 0, + "date": 0, + "mime_type": "", + "size": 0, + "dc_id": 0, + "path": "", + "sha256": "", + "attributes": null, + "thumbs": null + } + }, + { + "id": 3388302328334385156, + "access_hash": 0, + "type": 1, + "default": true, + "pattern": false, + "dark": false, + "slug": "", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 16703019, + "second_background_color": 3121110, + "third_background_color": 9661932, + "fourth_background_color": 16681655, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 0, + "access_hash": 0, + "date": 0, + "mime_type": "", + "size": 0, + "dc_id": 0, + "path": "", + "sha256": "", + "attributes": null, + "thumbs": null + } + }, + { + "id": 3388302328334385157, + "access_hash": 0, + "type": 1, + "default": true, + "pattern": false, + "dark": false, + "slug": "", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 14873550, + "second_background_color": 11133125, + "third_background_color": 14216397, + "fourth_background_color": 10406787, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 0, + "access_hash": 0, + "date": 0, + "mime_type": "", + "size": 0, + "dc_id": 0, + "path": "", + "sha256": "", + "attributes": null, + "thumbs": null + } + }, + { + "id": 3388302328334385158, + "access_hash": 0, + "type": 1, + "default": true, + "pattern": false, + "dark": false, + "slug": "", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 15124338, + "second_background_color": 15394762, + "third_background_color": 15238186, + "fourth_background_color": 9610078, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 0, + "access_hash": 0, + "date": 0, + "mime_type": "", + "size": 0, + "dc_id": 0, + "path": "", + "sha256": "", + "attributes": null, + "thumbs": null + } + }, + { + "id": 5906963690074144769, + "access_hash": 6307177556558034616, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "sp-xMi7A-VEBAAAABRn6rGsUKFs", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5042099534566523019, + "access_hash": 6346778478953150322, + "date": 1547647530, + "mime_type": "image/jpeg", + "size": 1683976, + "dc_id": 4, + "path": "default_wallpapers/documents/5042099534566523019.dat", + "sha256": "dec22303be724bb1e35884e3c4c4cb71340f6f465c3723568473f972ba14dcd5", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_2.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 19710, + "path": "default_wallpapers/thumbs/m/5042099534566523019.dat", + "sha256": "fffa5ddc8613e85a619f2a4366bbe591c406ca8cd3ba699a52e03a1a4e425019" + } + ] + } + }, + { + "id": 5906980552115748865, + "access_hash": 7075764375678803071, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "CiwwsoTP-VEBAAAAmDYEizr71BQ", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695163, + "access_hash": 2179539804932815256, + "date": 1547647544, + "mime_type": "image/jpeg", + "size": 751789, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695163.dat", + "sha256": "3cc2cd55f217e7de78ebc98fbf1ef2cc4a2413a23452d6d924245d5d38f9f809", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_6.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 14865, + "path": "default_wallpapers/thumbs/m/5044527775931695163.dat", + "sha256": "c35ccc975d6fb1d0de7e55833fc7682a39039ecd420fc87d621fef23845bdb91" + } + ] + } + }, + { + "id": 5906617365386231809, + "access_hash": -1477962611863535366, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "wtsScjOF-FEBAAAAS0QwhEvmk64", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695182, + "access_hash": 4533227118990297232, + "date": 1547647665, + "mime_type": "image/jpeg", + "size": 1034175, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695182.dat", + "sha256": "2b2b75821430b8305793fb953466ab693e09573151b292bfd5d6cd50ec02b16b", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_29.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 15643, + "path": "default_wallpapers/thumbs/m/5044527775931695182.dat", + "sha256": "1f0bd0f5b0f95bfe14fac6f4da62dd7ca1dd4253017e385a3e047bd8234c02de" + } + ] + } + }, + { + "id": 5909098808511299585, + "access_hash": -7216078663825665324, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "Br6nNA9WAVIBAAAAe6AHvL7eOMM", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695166, + "access_hash": -3115126729592645754, + "date": 1547647567, + "mime_type": "image/jpeg", + "size": 822953, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695166.dat", + "sha256": "c8842545f12139e389f21a52c2876dddae8b7d351de7d1861696c9d6d2c9c88c", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_10.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 7005, + "path": "default_wallpapers/thumbs/m/5044527775931695166.dat", + "sha256": "e37d32cc3343089423ebba98edf13013915b113fa2a6929f0a73d66f42d41a88" + } + ] + } + }, + { + "id": 5906862337435893761, + "access_hash": -115765806677602819, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "7wznfgBk-VEBAAAAncxYg0vokZY", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695159, + "access_hash": 5494410093174499136, + "date": 1547647524, + "mime_type": "image/jpeg", + "size": 299202, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695159.dat", + "sha256": "be7baef46cfd43022ead056f680ff93a2aee1731da9cfac0b29bc93e22c44224", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_1.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 4260, + "path": "default_wallpapers/thumbs/m/5044527775931695159.dat", + "sha256": "34000547567bced6ac4c5ad6a94718ad107474e148e9c5e58fc497f530380c01" + } + ] + } + }, + { + "id": 5906605219218718721, + "access_hash": -421643281251802232, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "G87eMCd6-FEBAAAApSBi5CBqx0c", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695169, + "access_hash": -3946358003188961287, + "date": 1547647584, + "mime_type": "image/jpeg", + "size": 880035, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695169.dat", + "sha256": "96a8ae61eb78b7f7937a5b6a2924471c5571acf46fb15d7d2da0fd7129e38703", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_13.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 13850, + "path": "default_wallpapers/thumbs/m/5044527775931695169.dat", + "sha256": "c48d33f0db8e35dfc917ebe8e2001e513386c5a3e3d279c2739d16395d2fc8fb" + } + ] + } + }, + { + "id": 5909175138670084097, + "access_hash": 4662070867142164276, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "MzLRSHubAVIBAAAAqKgYQTObnhw", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695162, + "access_hash": 7315334016384115895, + "date": 1547647540, + "mime_type": "image/jpeg", + "size": 1369353, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695162.dat", + "sha256": "af63d80025cc44f6f053c6b5255652058ef793f9c72bd3699816ae2741e63da8", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_5.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 20464, + "path": "default_wallpapers/thumbs/m/5044527775931695162.dat", + "sha256": "dc26c520a8d1b4fed7e679640825a5379fe533d7db47caa85fe80db5fc8133ed" + } + ] + } + }, + { + "id": 5908854944563200001, + "access_hash": -952088141090634256, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "MiE64ER4AFIBAAAAHQZRZyDCfu0", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695165, + "access_hash": 6033022357441024734, + "date": 1547647561, + "mime_type": "image/jpeg", + "size": 459884, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695165.dat", + "sha256": "556afc1e91226faf4233129385ab67fc826be49fa81bee5e85c67eaf847e2e9e", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_9.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 16210, + "path": "default_wallpapers/thumbs/m/5044527775931695165.dat", + "sha256": "ec3ec7010f9ec0a71ad362afae7d5765eb3085ef17944782d0046601c88add6a" + } + ] + } + }, + { + "id": 5908757629194207233, + "access_hash": -5235315307632943189, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "76IgaMIfAFIBAAAAXDx-2oiEJIM", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5042099534566523021, + "access_hash": -1644594290434818563, + "date": 1547647556, + "mime_type": "image/jpeg", + "size": 736915, + "dc_id": 4, + "path": "default_wallpapers/documents/5042099534566523021.dat", + "sha256": "143e2d4b41d32ae7e29c94159e32211c1471be38de5748cec6d8ceccf997677f", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_8.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 8119, + "path": "default_wallpapers/thumbs/m/5042099534566523021.dat", + "sha256": "ffe0654471a10c2b05aead75e7019c64110ab4ca315a4acb507fc35d1575e8b5" + } + ] + } + }, + { + "id": 5906796650206068737, + "access_hash": -8074771640988720146, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "0k-hpUIo-VEBAAAAN_yVCPEg9mI", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5042099534566523022, + "access_hash": 8311787528024794147, + "date": 1547647599, + "mime_type": "image/jpeg", + "size": 402156, + "dc_id": 4, + "path": "default_wallpapers/documents/5042099534566523022.dat", + "sha256": "c5627c588de7f6b0295988c772e2e7efc09c6248edb57e817bdd78c95fe03daf", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_16.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 6803, + "path": "default_wallpapers/thumbs/m/5042099534566523022.dat", + "sha256": "b29901d35c7a8c9c2f210fb0494a52ccc1b8680979ba3d9b5e831bb775ce6edc" + } + ] + } + }, + { + "id": 5906572457208184833, + "access_hash": 677917931819878915, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "X76DK1tc-FEBAAAAxL3Wn2tkGOE", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695174, + "access_hash": -109650940417309951, + "date": 1547647612, + "mime_type": "image/jpeg", + "size": 662364, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695174.dat", + "sha256": "3fec6beb13c6959abcf23851cf54f107c1fa9220ca84d9d6702de1cd424acaf9", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_19.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 10460, + "path": "default_wallpapers/thumbs/m/5044527775931695174.dat", + "sha256": "cef557c71edf3ef28517a8039ca357d400cec84434d3112ae02dfb1b0eb40d83" + } + ] + } + }, + { + "id": 5906660078835990529, + "access_hash": -7028595783543356197, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "EhCMFgys-FEBAAAA04qJyrs1G6M", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695175, + "access_hash": 6331306202479670213, + "date": 1547647617, + "mime_type": "image/jpeg", + "size": 869465, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695175.dat", + "sha256": "bbc8bed48490521862097edb878ca5f3ca3f43b97d5c4dc5940d6d3c92a19193", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_20.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 7858, + "path": "default_wallpapers/thumbs/m/5044527775931695175.dat", + "sha256": "8f973c83b82d983f3f11e519f56e457cfc6ac602bf56f8f36bbc57349daae5bc" + } + ] + } + }, + { + "id": 5908878811696463873, + "access_hash": 1024493119738104593, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "QYRXjvmNAFIBAAAAeuJm6ng1wZo", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695176, + "access_hash": -681013058644387109, + "date": 1547647630, + "mime_type": "image/jpeg", + "size": 624150, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695176.dat", + "sha256": "0b7e36afe07e03adfc740cc259ee4a6bdbb137e3e4dc37edb0fb0f49bddab540", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_22.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 5782, + "path": "default_wallpapers/thumbs/m/5044527775931695176.dat", + "sha256": "f8177f278e643a556212b84399fdef2601125ed679a8ed47f9dc985c4a0d218c" + } + ] + } + }, + { + "id": 5908888127480528897, + "access_hash": 6970896434205883168, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "5LkIrHKWAFIBAAAAM0Fy4n1W65E", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695173, + "access_hash": -3214675526652754374, + "date": 1547647606, + "mime_type": "image/jpeg", + "size": 332375, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695173.dat", + "sha256": "913073b3195474d50f17cce1da15193432fade88c60d193905604bb68bed6de2", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_18.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 4659, + "path": "default_wallpapers/thumbs/m/5044527775931695173.dat", + "sha256": "eb392106b38d4e852c5a8abb1c3258a823915efe11c32e3119661e62f0de3d49" + } + ] + } + }, + { + "id": 5906510446470365185, + "access_hash": 1031700063035428973, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "ExhVI_Uj-FEBAAAA9CC2f4neVfc", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5042099534566523023, + "access_hash": 2542507906135149955, + "date": 1547647623, + "mime_type": "image/jpeg", + "size": 645339, + "dc_id": 4, + "path": "default_wallpapers/documents/5042099534566523023.dat", + "sha256": "5fe06a988fdd31060164514a712a30032e2df82b3effa57470435322afd46316", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_21.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 5481, + "path": "default_wallpapers/thumbs/m/5042099534566523023.dat", + "sha256": "c11d88815a3e77dba4afc5cc441513d6b97f190d1147f97e51a917e5f5c07747" + } + ] + } + }, + { + "id": 5909134061602865153, + "access_hash": 3110071787701744670, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "05yxsh92AVIBAAAAnlGKl96c0OA", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695172, + "access_hash": 7971796553911658246, + "date": 1547647602, + "mime_type": "image/jpeg", + "size": 500225, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695172.dat", + "sha256": "6f65d80232512ff055d4bcb28fdd2297cfaaba687774547ffd6e188531fc094c", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_17.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 6641, + "path": "default_wallpapers/thumbs/m/5044527775931695172.dat", + "sha256": "48039489d37653b217762a99fe2e1fa9f6d4563345909e9140c66839b5041f74" + } + ] + } + }, + { + "id": 5907025052271902721, + "access_hash": 7740473653469511359, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "c87UFf33-VEBAAAAbg2Ohm6CkiE", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695183, + "access_hash": 3677821063729684702, + "date": 1547647672, + "mime_type": "image/jpeg", + "size": 1312284, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695183.dat", + "sha256": "63c8e21572b40e8833b22ede67814e9cd13ec7103a2d89a08ab6a77e2c799d2e", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_30.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 17710, + "path": "default_wallpapers/thumbs/m/5044527775931695183.dat", + "sha256": "9b0c4f3b967b325af99fbab02b5a6c7f2432b5cdd54e9957b4fe67175eab0e49" + } + ] + } + }, + { + "id": 5906660856225071105, + "access_hash": 6669894076167893009, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "pATdUMGs-FEBAAAA0r1Q5aJbg2w", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695181, + "access_hash": -5012333834865047847, + "date": 1547647657, + "mime_type": "image/jpeg", + "size": 296142, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695181.dat", + "sha256": "8fc74f635218692cc38a9824b8a413bcd77dc9e00b0d79afd6fb180a6f08f008", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_28.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 7894, + "path": "default_wallpapers/thumbs/m/5044527775931695181.dat", + "sha256": "b0e091fe6894c4899b017fbe1ea917921b7c12cd4cadc545c8111e73acfb564d" + } + ] + } + }, + { + "id": 5906729176269848577, + "access_hash": 7957379044104307977, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "4MDgxOTq-FEBAAAAiALXfrEitA4", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5042099534566523024, + "access_hash": -1112276296641392999, + "date": 1547647640, + "mime_type": "image/jpeg", + "size": 1217537, + "dc_id": 4, + "path": "default_wallpapers/documents/5042099534566523024.dat", + "sha256": "9f3d2966116d57845a1cdaff3e9ab38deae3ca6753595ff83fad108f9bf16089", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_24.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 13717, + "path": "default_wallpapers/thumbs/m/5042099534566523024.dat", + "sha256": "0a4942ccfb003cd7f5a179d1cdf6ea4363e4d4fb77d4a2ba6ce793080cae65b0" + } + ] + } + }, + { + "id": 5906839586994126849, + "access_hash": 4001527922462946535, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "VYTeBE9P-VEBAAAAbFWkh0TrPw4", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695164, + "access_hash": -2363827856125297497, + "date": 1547647549, + "mime_type": "image/jpeg", + "size": 766685, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695164.dat", + "sha256": "1411a4c6a755d594451184dd6fd58d5f7664a472fb48108724c38b583ab784fd", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_7.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 13713, + "path": "default_wallpapers/thumbs/m/5044527775931695164.dat", + "sha256": "fe7dd7fda2dd550cb82b399a899e62a50d0a9e48225567732ff8a8ad1f16db39" + } + ] + } + }, + { + "id": 5909153199977136129, + "access_hash": -2918381668755838638, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "maIM-YeHAVIBAAAAkvnFt2M-tiQ", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695167, + "access_hash": 4413147977800198350, + "date": 1547647574, + "mime_type": "image/jpeg", + "size": 894302, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695167.dat", + "sha256": "b680f91aa63854cab360010818dad8ba73113933cb9cfa78b4908bd03cb7080a", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_11.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 12238, + "path": "default_wallpapers/thumbs/m/5044527775931695167.dat", + "sha256": "31c09a2380597c1d318b205f6f1a7c2335a9b677106fd841e9421cbaa3ff95f5" + } + ] + } + }, + { + "id": 5906560238026227713, + "access_hash": 9035244907891895455, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "MQFtCj5R-FEBAAAAxZqkAmtgSA8", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695161, + "access_hash": -4270067470471168753, + "date": 1547647536, + "mime_type": "image/jpeg", + "size": 530254, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695161.dat", + "sha256": "989eb7d0dafc8d66cb467e3d2ea8fa863496a7765ff2d66eb7fca67258773e73", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_4.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 7115, + "path": "default_wallpapers/thumbs/m/5044527775931695161.dat", + "sha256": "57faef833f0988d738bd5e78262b842092cb752e773c38857823ecabe081f68f" + } + ] + } + }, + { + "id": 5906790705971331073, + "access_hash": 7086041727214022883, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "Ynp3Jtoi-VEBAAAASU1vdCYmVto", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695170, + "access_hash": 5170774567897136344, + "date": 1547647590, + "mime_type": "image/jpeg", + "size": 1298518, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695170.dat", + "sha256": "c31a6054b1750b536e92c94015dc649d9d29f849ead77739ebe2effb6061c2ed", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_14.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 10020, + "path": "default_wallpapers/thumbs/m/5044527775931695170.dat", + "sha256": "38f80e36909fe00e7b98e863cd4919105e141e76e68b00ee210f55f387925b44" + } + ] + } + }, + { + "id": 5906587107341631489, + "access_hash": 2588100083382753451, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "9F4oTa5p-FEBAAAA9A2ENoEIiHA", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695177, + "access_hash": -8049085184258763830, + "date": 1547647635, + "mime_type": "image/jpeg", + "size": 733630, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695177.dat", + "sha256": "3104cfb44cba76ca7e3ddcbb66a95418b1489dbf5fbbaef2e54301162a1fec61", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_23.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 9283, + "path": "default_wallpapers/thumbs/m/5044527775931695177.dat", + "sha256": "875325afe19139fa1675fcec19076a54f4de96e139eaa116e94fba4c4b079089" + } + ] + } + }, + { + "id": 5906932109179617281, + "access_hash": -9018863911906668113, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "zketRHWj-VEBAAAA8kqzFtu3ZBg", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695179, + "access_hash": -2606153386373558526, + "date": 1547647649, + "mime_type": "image/jpeg", + "size": 844864, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695179.dat", + "sha256": "dbead0f14e133db9536fa11541453b68103555c0322ccb0468a746826e0937f9", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_26.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 10519, + "path": "default_wallpapers/thumbs/m/5044527775931695179.dat", + "sha256": "3117bfcb9d6e557c443f6a273991d1b03d40a75b334e80255e613b73c55435c5" + } + ] + } + }, + { + "id": 5908956086748053505, + "access_hash": 1604750920666690454, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "JzHJF0HUAFIBAAAAYqIF-a2x4DY", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695160, + "access_hash": -1293198148036480010, + "date": 1547647531, + "mime_type": "image/jpeg", + "size": 169426, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695160.dat", + "sha256": "de192db9400bf7ba41c6df813b4e112c55e1f6b6592e783fd9761e8762cc31b9", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_3.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 2469, + "path": "default_wallpapers/thumbs/m/5044527775931695160.dat", + "sha256": "c634839577d258b8b2ac02f6a8d3878a05b416268e800a91e51e3d4c1d785ff2" + } + ] + } + }, + { + "id": 5947530738516623361, + "access_hash": -1323498699799167915, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "Qe9IiLLfiVIBAAAAn_BDUKSYaCs", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5082456438849142866, + "access_hash": 1887178836834096349, + "date": 1548791769, + "mime_type": "image/jpeg", + "size": 126661, + "dc_id": 4, + "path": "default_wallpapers/documents/5082456438849142866.dat", + "sha256": "cadd6d28f39205e53b943335f539126ba6e8570d5fedf5a0904ae963b8be2583", + "attributes": [ + { + "kind": "image_size", + "w": 640, + "h": 1136 + }, + { + "kind": "filename", + "file_name": "105.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 180, + "h": 320, + "size": 6164, + "path": "default_wallpapers/thumbs/m/5082456438849142866.dat", + "sha256": "9f9518d53b2d74a1002d3aec07ad9b93644567004d52d7098f78f66033daad6e" + } + ] + } + }, + { + "id": 5947424468140818433, + "access_hash": 8054422047755582033, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "FJIYygt_iVIBAAAA8tzV8Ju0QvM", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5080412794625458315, + "access_hash": -7609954468143954815, + "date": 1548791920, + "mime_type": "image/jpeg", + "size": 463393, + "dc_id": 4, + "path": "default_wallpapers/documents/5080412794625458315.dat", + "sha256": "9aeaa09f4280f6dfbe3142d4f117447553e1c5bcf4dc7d8cea23b7b78892fafd", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_33.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 11590, + "path": "default_wallpapers/thumbs/m/5080412794625458315.dat", + "sha256": "133669098b5eea92384a99a2a7674d56c3c04552ab60afb613de3a02beed5bbc" + } + ] + } + }, + { + "id": 5945255028620001281, + "access_hash": -6340952200004482343, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "7F-AWfPJgVIBAAAA5NdzN7l5zWM", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5080412794625458316, + "access_hash": -1055626910199919769, + "date": 1548791948, + "mime_type": "image/jpeg", + "size": 107363, + "dc_id": 4, + "path": "default_wallpapers/documents/5080412794625458316.dat", + "sha256": "d5c90d54f2404e3031fb7ca0eb2f57edcc2959cfee264f2a220bb57f63dc94aa", + "attributes": [ + { + "kind": "image_size", + "w": 640, + "h": 1136 + }, + { + "kind": "filename", + "file_name": "104.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 180, + "h": 320, + "size": 4582, + "path": "default_wallpapers/thumbs/m/5080412794625458316.dat", + "sha256": "4111fc7f4f5a8de2867dc4914659eb0c17555f1cb3fd668dda763baa8b4f06fe" + } + ] + } + }, + { + "id": 5947340690508742657, + "access_hash": 3528794991276120255, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "vG0wx9kyiVIBAAAAkhlpL_sW9dg", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5080412794625458317, + "access_hash": 4958528404570961573, + "date": 1548791970, + "mime_type": "image/jpeg", + "size": 85361, + "dc_id": 4, + "path": "default_wallpapers/documents/5080412794625458317.dat", + "sha256": "5b82af8daef53736fffaf191ead6247767d1733b6c2816ea7202c853c62e8238", + "attributes": [ + { + "kind": "image_size", + "w": 1080, + "h": 1920 + }, + { + "kind": "filename", + "file_name": "13.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 180, + "h": 320, + "size": 6677, + "path": "default_wallpapers/thumbs/m/5080412794625458317.dat", + "sha256": "8caa46d5e3503a6a45c0276068a606c2d2e05987c10e30fc44935832ad430f7b" + } + ] + } + }, + { + "id": 5945087215657811969, + "access_hash": -3527358213864975963, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "SJTGO1MxgVIBAAAA-AChMYdDH58", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5080412794625458318, + "access_hash": 2369452392113531512, + "date": 1548791979, + "mime_type": "image/jpeg", + "size": 110374, + "dc_id": 4, + "path": "default_wallpapers/documents/5080412794625458318.dat", + "sha256": "9b3b054cff5f57cd192fc24135cf7726ce3ca430b3ed5ca1b2667f55af881641", + "attributes": [ + { + "kind": "image_size", + "w": 1080, + "h": 1920 + }, + { + "kind": "filename", + "file_name": "21.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 180, + "h": 320, + "size": 2667, + "path": "default_wallpapers/thumbs/m/5080412794625458318.dat", + "sha256": "dab86229bf89f0e138b048c080784603d3652b7e98c4e221fe21c5a5fd21f909" + } + ] + } + }, + { + "id": 5776065172930035719, + "access_hash": -7362949919804434019, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "o2q2Nay0KFAHAAAAXRLnCthYEFk", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5199407101239101638, + "access_hash": -5653740151686081832, + "date": 1602591923, + "mime_type": "image/jpeg", + "size": 159666, + "dc_id": 2, + "path": "default_wallpapers/documents/5199407101239101638.dat", + "sha256": "99c5a36af2464fb5cc22968b3066bca58e9e17994b4125903ebd04732f8384a7", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "bg.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 3718, + "path": "default_wallpapers/thumbs/m/5199407101239101638.dat", + "sha256": "8d8a9ec5103204a9c17e8c8b8d96ca6103657248713b61f2e5c53a761e31220a" + } + ] + } + }, + { + "id": 5778236420632084488, + "access_hash": 949223703553116467, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "ByxGo2lrMFAIAAAAmkJxZabh8eM", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5199690504656128578, + "access_hash": 5645681656852275705, + "date": 1602592307, + "mime_type": "image/jpeg", + "size": 345121, + "dc_id": 2, + "path": "default_wallpapers/documents/5199690504656128578.dat", + "sha256": "a8cc7d1dad601dee253ab0a907c709276eb0b4e8e4b5d6ae4960422506507ea9", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "background telegram 003.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 6137, + "path": "default_wallpapers/thumbs/m/5199690504656128578.dat", + "sha256": "8433ca27db5c87480f3d30f5aaa81036e2a36b7462648136efe34754db05b0ac" + } + ] + } + }, + { + "id": 5778594457695813635, + "access_hash": 3115290389581146509, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "xs56jwuxMVADAAAAat-PqhNBaxA", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5404435181954140582, + "access_hash": 5935110866834074163, + "date": 1600092959, + "mime_type": "image/jpeg", + "size": 656301, + "dc_id": 2, + "path": "default_wallpapers/documents/5404435181954140582.dat", + "sha256": "651d53e017e16416135cbddf59da7143d256ceb79294fc61701d239c3ba44aa6", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "IMG_0426.JPG" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 4461, + "path": "default_wallpapers/thumbs/m/5404435181954140582.dat", + "sha256": "134569abb5786aa2f9e93c92aa34dd6a0057b7c993db706cf1b9f03d2076bd1c" + } + ] + } + }, + { + "id": 5915807066391314436, + "access_hash": 8170105533609711628, + "type": 0, + "default": true, + "pattern": false, + "dark": false, + "slug": "qeZWES8rGVIEAAAARfWlK1lnfiI", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5051205286140838230, + "access_hash": -5205710826537592622, + "date": 1623413088, + "mime_type": "image/jpeg", + "size": 125151, + "dc_id": 4, + "path": "default_wallpapers/documents/5051205286140838230.dat", + "sha256": "56b3d0f81f9535b8fa8d6f4d45c006d4c3db04d35eb91ddc0f2a522288f3fadf", + "attributes": [ + { + "kind": "image_size", + "w": 1280, + "h": 2002 + }, + { + "kind": "filename", + "file_name": "background_hd (1).jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 205, + "h": 320, + "size": 3350, + "path": "default_wallpapers/thumbs/m/5051205286140838230.dat", + "sha256": "d338e5bfed22be80f5083aa81a7257398859203ca8402c3bae3be228f619085e" + } + ] + } + }, + { + "id": 5911062875580923918, + "access_hash": -4613741187406712612, + "type": 0, + "default": true, + "pattern": true, + "dark": true, + "slug": "FOks2P6KCFIMAAAAyFz5S74pfKo", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 9100274, + "second_background_color": 8949228, + "third_background_color": 14917610, + "fourth_background_color": 6790381, + "intensity": -30, + "rotation": 0 + }, + "document": { + "id": 5911510226489576101, + "access_hash": 6263020380199454416, + "date": 1631642212, + "mime_type": "application/x-tgwallpattern", + "size": 107339, + "dc_id": 4, + "path": "default_wallpapers/documents/5911510226489576101.dat", + "sha256": "dd04df5877fdd54c53009a1b480f4b36b7975b12cc72dfc18edf08c603257bab", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 31153, + "path": "default_wallpapers/thumbs/m/5911510226489576101.dat", + "sha256": "e181357ad799b1069c9492a0b5683b0c40a4f108d237038651ee3113133af84c" + } + ] + } + }, + { + "id": 5933808000178323465, + "access_hash": -601550650252728109, + "type": 0, + "default": true, + "pattern": true, + "dark": true, + "slug": "CJNyxPMgSVAEAAAAvW9sMwc51cw", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 8364929, + "second_background_color": 16774597, + "third_background_color": 3370837, + "fourth_background_color": 16507773, + "intensity": -40, + "rotation": 0 + }, + "document": { + "id": 5785343895722264360, + "access_hash": -1349142750469150145, + "date": 1577635126, + "mime_type": "application/x-tgwallpattern", + "size": 312605, + "dc_id": 4, + "path": "default_wallpapers/documents/5785343895722264360.dat", + "sha256": "aac3993d60e148cf79cd5eba29ed4e83cf1d25c08e9d704f729de60398fb77cf", + "attributes": [ + { + "kind": "image_size", + "w": 1125, + "h": 2436 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 148, + "h": 320, + "size": 22921, + "path": "default_wallpapers/thumbs/m/5785343895722264360.dat", + "sha256": "5c474207324d26b5b42a31aee61f251f775b1bae89df6087db6255fe2c050adb" + } + ] + } + }, + { + "id": 5938255172525359124, + "access_hash": -7312806799754817281, + "type": 0, + "default": true, + "pattern": true, + "dark": true, + "slug": "DRaa0SbvYVIjAAAAWv3uHfEiYyI", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 16696470, + "second_background_color": 14511289, + "third_background_color": 9842623, + "fourth_background_color": 5200853, + "intensity": -45, + "rotation": 0 + }, + "document": { + "id": 5938052961170098801, + "access_hash": -4124189991439125849, + "date": 1640788085, + "mime_type": "application/x-tgwallpattern", + "size": 153409, + "dc_id": 4, + "path": "default_wallpapers/documents/5938052961170098801.dat", + "sha256": "49f561c9fcf166572827e48c3164f0749ca14461491794d772384213ceee24c0", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 29658, + "path": "default_wallpapers/thumbs/m/5938052961170098801.dat", + "sha256": "57cf7983a52fa51c626ba2b2bd60b0b453e2aa05ea93417af7b850bb3ef3a360" + } + ] + } + }, + { + "id": 5936133776508715013, + "access_hash": -2074011678611450119, + "type": 0, + "default": true, + "pattern": true, + "dark": true, + "slug": "bJcwphEAYVINAAAA5jpWNRMqilA", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 16446674, + "second_background_color": 13543016, + "third_background_color": 14529901, + "fourth_background_color": 12231009, + "intensity": -40, + "rotation": 0 + }, + "document": { + "id": 5938074474661284850, + "access_hash": -977544060216020041, + "date": 1640788123, + "mime_type": "application/x-tgwallpattern", + "size": 227751, + "dc_id": 4, + "path": "default_wallpapers/documents/5938074474661284850.dat", + "sha256": "42bbce746ed5e073ceb80a01eafa332d0d5ac077662359bac0d851e24176a19b", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 27263, + "path": "default_wallpapers/thumbs/m/5938074474661284850.dat", + "sha256": "ea93809e3fecaa61d77ed489441ce816582dea64c2db2c20af63af1aa503b670" + } + ] + } + }, + { + "id": 5938145586434801689, + "access_hash": 6450978822767769585, + "type": 0, + "default": true, + "pattern": true, + "dark": true, + "slug": "8u8Y1ggMYVITAAAAluQYztxHp6s", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 2621187, + "second_background_color": 16527871, + "third_background_color": 65279, + "fourth_background_color": 16776192, + "intensity": -35, + "rotation": 0 + }, + "document": { + "id": 5938102022581521601, + "access_hash": 6636732675680556029, + "date": 1640787993, + "mime_type": "application/x-tgwallpattern", + "size": 89269, + "dc_id": 4, + "path": "default_wallpapers/documents/5938102022581521601.dat", + "sha256": "f39613ee0de73fc3cd970afa66199f2322a2b83465fc75e373f003e875deb0ed", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 27601, + "path": "default_wallpapers/thumbs/m/5938102022581521601.dat", + "sha256": "dc2111e533e8cf88f04b906be91a6ad0f289321270b6ec5c70f1ded4630fa0db" + } + ] + } + }, + { + "id": 5931428472332222485, + "access_hash": 3164770212021344150, + "type": 0, + "default": true, + "pattern": true, + "dark": true, + "slug": "MIo6r0qGSFAFAAAAtL8TsDzNX60", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 10804223, + "second_background_color": 40925, + "third_background_color": 5405661, + "fourth_background_color": 0, + "intensity": -35, + "rotation": 0 + }, + "document": { + "id": 5782630687571969871, + "access_hash": 5918281820082603968, + "date": 1577635137, + "mime_type": "application/x-tgwallpattern", + "size": 100992, + "dc_id": 4, + "path": "default_wallpapers/documents/5782630687571969871.dat", + "sha256": "14ca7c554f48041b0b2a9f7f39034f9b255e5fcac966f07b4d5b80292f806836", + "attributes": [ + { + "kind": "image_size", + "w": 1125, + "h": 2436 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 148, + "h": 320, + "size": 27697, + "path": "default_wallpapers/thumbs/m/5782630687571969871.dat", + "sha256": "f1cd490a3d14ea50c80a3a7a279e350f1337b5a49eef4cd7fa612a9285fe0b92" + } + ] + } + }, + { + "id": 5935974716689874953, + "access_hash": 3372853595062823910, + "type": 0, + "default": true, + "pattern": true, + "dark": true, + "slug": "T7LjEHVuYVIFAAAAS7NH4xQl6jY", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 15377262, + "second_background_color": 15787142, + "third_background_color": 15900351, + "fourth_background_color": 15253614, + "intensity": -30, + "rotation": 0 + }, + "document": { + "id": 5938195764537723401, + "access_hash": -3534919302544114908, + "date": 1640788247, + "mime_type": "application/x-tgwallpattern", + "size": 109753, + "dc_id": 4, + "path": "default_wallpapers/documents/5938195764537723401.dat", + "sha256": "37dc773b072b5985055d2b1276c6aeeb1b26cd8a44298e55bf41405523e6482c", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 53566, + "path": "default_wallpapers/thumbs/m/5938195764537723401.dat", + "sha256": "fb4588941bb1a958286053c20d11cf46c60f5a956cd0f41697d5feae5ec16334" + } + ] + } + }, + { + "id": 5938317256277622790, + "access_hash": -874409272990115000, + "type": 0, + "default": true, + "pattern": true, + "dark": true, + "slug": "rF5kQBMSYFICAAAAUCWVFDNCLnU", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 14988010, + "second_background_color": 8615618, + "third_background_color": 15383001, + "fourth_background_color": 11834342, + "intensity": -35, + "rotation": 0 + }, + "document": { + "id": 5938141815453518446, + "access_hash": -4248017281558775131, + "date": 1640788187, + "mime_type": "application/x-tgwallpattern", + "size": 73997, + "dc_id": 4, + "path": "default_wallpapers/documents/5938141815453518446.dat", + "sha256": "8a77f2049bc3932007cbf615735f1cccee20cfd75c3c1a8912a3704a82432448", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 26402, + "path": "default_wallpapers/thumbs/m/5938141815453518446.dat", + "sha256": "b8c49e6fc4c91263ebbe2c4aa22304f606152fa6d5c14f7edb947ea38ef5d181" + } + ] + } + }, + { + "id": 5935767978439081993, + "access_hash": -4908738408710949341, + "type": 0, + "default": true, + "pattern": true, + "dark": true, + "slug": "aiuT0cIzaVIHAAAAjS-ebiVKLtU", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 14988010, + "second_background_color": 8615618, + "third_background_color": 15383001, + "fourth_background_color": 11834342, + "intensity": -35, + "rotation": 0 + }, + "document": { + "id": 5938158372552444547, + "access_hash": 3151911047396064341, + "date": 1640788206, + "mime_type": "application/x-tgwallpattern", + "size": 113639, + "dc_id": 4, + "path": "default_wallpapers/documents/5938158372552444547.dat", + "sha256": "cf7a462195b3ab0464b257055f2484e0d243e04360b1863be436ee07bc1eb1ae", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "pattern.tgv" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 155, + "h": 320, + "size": 28852, + "path": "default_wallpapers/thumbs/m/5938158372552444547.dat", + "sha256": "5d3a55bf7076f9c816fe79d821c5f12076a53caff6f019cd322ca2c28b4606b0" + } + ] + } + }, + { + "id": 3388302328334385159, + "access_hash": 0, + "type": 1, + "default": true, + "pattern": false, + "dark": true, + "slug": "", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 1441900, + "second_background_color": 5441578, + "third_background_color": 985908, + "fourth_background_color": 4790859, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 0, + "access_hash": 0, + "date": 0, + "mime_type": "", + "size": 0, + "dc_id": 0, + "path": "", + "sha256": "", + "attributes": null, + "thumbs": null + } + }, + { + "id": 3388302328334385160, + "access_hash": 0, + "type": 1, + "default": true, + "pattern": false, + "dark": true, + "slug": "", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 1464934, + "second_background_color": 4983891, + "third_background_color": 531764, + "fourth_background_color": 5249899, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 0, + "access_hash": 0, + "date": 0, + "mime_type": "", + "size": 0, + "dc_id": 0, + "path": "", + "sha256": "", + "attributes": null, + "thumbs": null + } + }, + { + "id": 5906743109143756801, + "access_hash": 1179850607156107804, + "type": 0, + "default": true, + "pattern": false, + "dark": true, + "slug": "wRHnDZD3-FEBAAAAjIIJrlTu1xg", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695178, + "access_hash": 4332443815406376788, + "date": 1547647645, + "mime_type": "image/jpeg", + "size": 723971, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695178.dat", + "sha256": "52b180888d661d8b643e4b57ab84a4408e6c740c0546a60123f11dd5538be527", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_25.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 8431, + "path": "default_wallpapers/thumbs/m/5044527775931695178.dat", + "sha256": "1d9b39206ae26a21bc62cbf7eca9e0c99e0111aa3374f4ad8763f9856615ca8f" + } + ] + } + }, + { + "id": 5906653864018313217, + "access_hash": 4878711556025903343, + "type": 0, + "default": true, + "pattern": false, + "dark": true, + "slug": "yf9PpmWm-FEBAAAAapSVTMR9AmA", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695171, + "access_hash": -7286245232726123572, + "date": 1547647596, + "mime_type": "image/jpeg", + "size": 1644794, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695171.dat", + "sha256": "c17aaae208039aa3e09923bbb9e41bdea910d3f223da55e013478487278c1d2b", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_15.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 11487, + "path": "default_wallpapers/thumbs/m/5044527775931695171.dat", + "sha256": "5a9c1b6e79d3358aa6edf758021f3c571916c706e343ddcf89ca043f1e15a2a1" + } + ] + } + }, + { + "id": 5906481103253798913, + "access_hash": 2670465458012141336, + "type": 0, + "default": true, + "pattern": false, + "dark": true, + "slug": "CK2cG0UJ-FEBAAAAeI0d9Vuj2CQ", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695180, + "access_hash": -7402189952328260961, + "date": 1547647653, + "mime_type": "image/jpeg", + "size": 633450, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695180.dat", + "sha256": "1e5501689e14e78906c38715f2af077dd53528a62a5a489eaf915f9b0f4828a0", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_27.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 6368, + "path": "default_wallpapers/thumbs/m/5044527775931695180.dat", + "sha256": "c4852aba00952fdfb943951c06cc6707d95c2f16260d91fd8eb315d46b78b960" + } + ] + } + }, + { + "id": 5908833349467635713, + "access_hash": -8951912792455934035, + "type": 0, + "default": true, + "pattern": false, + "dark": true, + "slug": "dfa_O6BkAFIBAAAArunsysEw-X8", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5044527775931695168, + "access_hash": -5722131371998346976, + "date": 1547647578, + "mime_type": "image/jpeg", + "size": 876992, + "dc_id": 4, + "path": "default_wallpapers/documents/5044527775931695168.dat", + "sha256": "d647a90506a760bf3f9bfa64a8b223b846483a32ec59af0e8cd64fdbc5886253", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_12.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 18793, + "path": "default_wallpapers/thumbs/m/5044527775931695168.dat", + "sha256": "bdfc1f358bb2f63e6409d7f0abeb7da99ea5ce7ea2746b5ba743cd3090aa9ae8" + } + ] + } + }, + { + "id": 5945196943482290177, + "access_hash": -413282519204142775, + "type": 0, + "default": true, + "pattern": false, + "dark": true, + "slug": "YRZSyB-VgVIBAAAAsaNJPdNxxpM", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5082456438849142867, + "access_hash": 6703986920102523631, + "date": 1548791939, + "mime_type": "image/jpeg", + "size": 226273, + "dc_id": 4, + "path": "default_wallpapers/documents/5082456438849142867.dat", + "sha256": "3b9695f0c0dc6fa8b25b0a5231192da5e015fc6588287665e277d805ff4db7b5", + "attributes": [ + { + "kind": "image_size", + "w": 1080, + "h": 1920 + }, + { + "kind": "filename", + "file_name": "02.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 180, + "h": 320, + "size": 8149, + "path": "default_wallpapers/thumbs/m/5082456438849142867.dat", + "sha256": "9713e7176100b54aaa267f22e4d4570e9d3838ec7a1ca2cea8d9d8649f696391" + } + ] + } + }, + { + "id": 5947232783750397953, + "access_hash": -684737460100279653, + "type": 0, + "default": true, + "pattern": false, + "dark": true, + "slug": "I9m3arXQiFIBAAAAyYGQ6povw_E", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5080412794625458320, + "access_hash": -1932239856236363103, + "date": 1548797674, + "mime_type": "image/jpeg", + "size": 773269, + "dc_id": 4, + "path": "default_wallpapers/documents/5080412794625458320.dat", + "sha256": "c2b8b1c995997794e2a893f381a162f6ed11b140aed07642fad8b0d1177da5ab", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_N9.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 5037, + "path": "default_wallpapers/thumbs/m/5080412794625458320.dat", + "sha256": "97ac1546528ed1b0a9916c97518736f21e20139f7ebe8df7a18e710c2da2009d" + } + ] + } + }, + { + "id": 5947391152079503361, + "access_hash": 8343188857812786678, + "type": 0, + "default": true, + "pattern": false, + "dark": true, + "slug": "RKoGzb5giVIBAAAAW_S7sn3YmKc", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5080412794625458321, + "access_hash": 7241195381711291042, + "date": 1548797675, + "mime_type": "image/jpeg", + "size": 391262, + "dc_id": 4, + "path": "default_wallpapers/documents/5080412794625458321.dat", + "sha256": "77f9d264b7c9e9f2e089776ba01bd94fc18235b2012303e4a96f58731eb52735", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_N2.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 10217, + "path": "default_wallpapers/thumbs/m/5080412794625458321.dat", + "sha256": "45f2c73ae3f62fd5ab4aeca8f603f6e6c6c0bc2fc37d144c297c6047caa12497" + } + ] + } + }, + { + "id": 5945059242535813121, + "access_hash": -2555527803353833195, + "type": 0, + "default": true, + "pattern": false, + "dark": true, + "slug": "Gbi3zeIXgVIBAAAAbHXUth5xfMc", + "settings": { + "id": 0, + "blur": false, + "motion": false, + "background_color": 0, + "second_background_color": 0, + "third_background_color": 0, + "fourth_background_color": 0, + "intensity": 0, + "rotation": 0 + }, + "document": { + "id": 5080412794625458322, + "access_hash": -5665410597438349866, + "date": 1548797677, + "mime_type": "image/jpeg", + "size": 724988, + "dc_id": 4, + "path": "default_wallpapers/documents/5080412794625458322.dat", + "sha256": "2eec797f40fbdce84dcb9411ecc17294c50056c78cd7287898180e57b824b5b0", + "attributes": [ + { + "kind": "image_size", + "w": 1440, + "h": 2960 + }, + { + "kind": "filename", + "file_name": "BG_N4.jpg" + } + ], + "thumbs": [ + { + "kind": "size", + "type": "m", + "w": 156, + "h": 320, + "size": 3108, + "path": "default_wallpapers/thumbs/m/5080412794625458322.dat", + "sha256": "b4bb329c4d88967c049c26fefcbe320cfeb76cdc6dd3bb5a76195c94320e3342" + } + ] + } + } + ], + "peer_colors": [ + { + "id": 5, + "hidden": false, + "channel_min_level": 1, + "colors": null, + "dark_colors": null + }, + { + "id": 3, + "hidden": false, + "channel_min_level": 1, + "colors": null, + "dark_colors": null + }, + { + "id": 1, + "hidden": false, + "channel_min_level": 1, + "colors": null, + "dark_colors": null + }, + { + "id": 0, + "hidden": false, + "channel_min_level": 1, + "colors": null, + "dark_colors": null + }, + { + "id": 2, + "hidden": false, + "channel_min_level": 1, + "colors": null, + "dark_colors": null + }, + { + "id": 4, + "hidden": false, + "channel_min_level": 1, + "colors": null, + "dark_colors": null + }, + { + "id": 6, + "hidden": false, + "channel_min_level": 1, + "colors": null, + "dark_colors": null + }, + { + "id": 12, + "hidden": false, + "channel_min_level": 2, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 3379668, + 8246256 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 5423103, + 742548 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 10, + "hidden": false, + "channel_min_level": 2, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 2599184, + 11000919 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 11004782, + 1474093 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 8, + "hidden": false, + "channel_min_level": 2, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 14712875, + 16434484 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 15511630, + 12801812 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 7, + "hidden": false, + "channel_min_level": 2, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 14766162, + 16363107 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 16749440, + 10039095 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 9, + "hidden": false, + "channel_min_level": 2, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 10510323, + 16027647 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 13015039, + 6173128 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 11, + "hidden": false, + "channel_min_level": 2, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 2600142, + 8579286 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 4249808, + 285823 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 13, + "hidden": false, + "channel_min_level": 2, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 14500721, + 16760479 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 16746150, + 9320046 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 14, + "hidden": false, + "channel_min_level": 3, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 2391021, + 15747158, + 16777215 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 4170494, + 15024719, + 16777215 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 15, + "hidden": false, + "channel_min_level": 3, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 14055202, + 2007057, + 16777215 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 16748638, + 3319079, + 16777215 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 16, + "hidden": false, + "channel_min_level": 3, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 1547842, + 15223359, + 16777215 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 6738788, + 13976655, + 16777215 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 17, + "hidden": false, + "channel_min_level": 3, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 2659503, + 7324758, + 16777215 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 2276578, + 4039232, + 16777215 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 18, + "hidden": false, + "channel_min_level": 3, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 826035, + 16756117, + 16770741 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 2276578, + 16750456, + 16767595 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 19, + "hidden": false, + "channel_min_level": 3, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 7821270, + 16225808, + 16768654 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 9933311, + 15889181, + 16767833 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + }, + { + "id": 20, + "hidden": false, + "channel_min_level": 3, + "colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 1410511, + 15903517, + 16777215 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + }, + "dark_colors": { + "predicate_name": "help.peerColorSet", + "colors": [ + 4040427, + 15639837, + 16777215 + ], + "palette_colors": null, + "bg_colors": null, + "story_colors": null + } + } + ], + "peer_profile_colors": [ + { + "id": 5, + "hidden": false, + "channel_min_level": 5, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 4888278 + ], + "bg_colors": [ + 5935035 + ], + "story_colors": [ + 7264511, + 7405535 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 3375297 + ], + "bg_colors": [ + 4682132 + ], + "story_colors": [ + 9029631, + 7536638 + ] + } + }, + { + "id": 3, + "hidden": false, + "channel_min_level": 5, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 5485111 + ], + "bg_colors": [ + 4825941 + ], + "story_colors": [ + 7991418, + 13299018 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 3972130 + ], + "bg_colors": [ + 3371323 + ], + "story_colors": [ + 7919501, + 12703080 + ] + } + }, + { + "id": 1, + "hidden": false, + "channel_min_level": 5, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 14386489 + ], + "bg_colors": [ + 12745790 + ], + "story_colors": [ + 16756531, + 16240230 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 12807972 + ], + "bg_colors": [ + 9723436 + ], + "story_colors": [ + 16756531, + 16240230 + ] + } + }, + { + "id": 0, + "hidden": false, + "channel_min_level": 5, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 13722204 + ], + "bg_colors": [ + 12211792 + ], + "story_colors": [ + 16752253, + 16758622 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 12209223 + ], + "bg_colors": [ + 10241344 + ], + "story_colors": [ + 16744573, + 16745797 + ] + } + }, + { + "id": 2, + "hidden": false, + "channel_min_level": 5, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 10513887 + ], + "bg_colors": [ + 9792200 + ], + "story_colors": [ + 16030463, + 16753387 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 9000906 + ], + "bg_colors": [ + 7426201 + ], + "story_colors": [ + 15366399, + 16755185 + ] + } + }, + { + "id": 4, + "hidden": false, + "channel_min_level": 5, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 4565185 + ], + "bg_colors": [ + 4102061 + ], + "story_colors": [ + 5036799, + 4325314 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 3646897 + ], + "bg_colors": [ + 3702407 + ], + "story_colors": [ + 4708863, + 3342285 + ] + } + }, + { + "id": 6, + "hidden": false, + "channel_min_level": 5, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 13460119 + ], + "bg_colors": [ + 12079992 + ], + "story_colors": [ + 16746153, + 16754323 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 11947138 + ], + "bg_colors": [ + 9717603 + ], + "story_colors": [ + 16675727, + 16751486 + ] + } + }, + { + "id": 7, + "hidden": false, + "channel_min_level": 5, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 9477803 + ], + "bg_colors": [ + 8358805 + ], + "story_colors": [ + 12834019, + 15726847 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 7964822 + ], + "bg_colors": [ + 4412001 + ], + "story_colors": [ + 10071235, + 14871283 + ] + } + }, + { + "id": 13, + "hidden": false, + "channel_min_level": 6, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 3574481, + 8246256 + ], + "bg_colors": [ + 5475266, + 5089469 + ], + "story_colors": [ + 7264511, + 7405535 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 5148620, + 7525869 + ], + "bg_colors": [ + 3694988, + 4557729 + ], + "story_colors": [ + 9029631, + 7536638 + ] + } + }, + { + "id": 11, + "hidden": false, + "channel_min_level": 6, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 2599184, + 11000919 + ], + "bg_colors": [ + 4036437, + 9021008 + ], + "story_colors": [ + 7991418, + 13299018 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 4036437, + 10932055 + ], + "bg_colors": [ + 2714179, + 6262596 + ], + "story_colors": [ + 7919501, + 12703080 + ] + } + }, + { + "id": 9, + "hidden": false, + "channel_min_level": 6, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 14712875, + 15842348 + ], + "bg_colors": [ + 13595204, + 13407283 + ], + "story_colors": [ + 16756531, + 16240230 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 13595204, + 15247677 + ], + "bg_colors": [ + 9393455, + 10580530 + ], + "story_colors": [ + 16756531, + 16240230 + ] + } + }, + { + "id": 8, + "hidden": false, + "channel_min_level": 6, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 14966882, + 16363107 + ], + "bg_colors": [ + 13194845, + 14253143 + ], + "story_colors": [ + 16752253, + 16758622 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 13194845, + 16486759 + ], + "bg_colors": [ + 10044227, + 11294782 + ], + "story_colors": [ + 16744573, + 16745797 + ] + } + }, + { + "id": 10, + "hidden": false, + "channel_min_level": 6, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 10510323, + 16027647 + ], + "bg_colors": [ + 9855700, + 12150454 + ], + "story_colors": [ + 16030463, + 16753387 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 9855700, + 15236580 + ], + "bg_colors": [ + 6506129, + 9588898 + ], + "story_colors": [ + 15366399, + 16755185 + ] + } + }, + { + "id": 12, + "hidden": false, + "channel_min_level": 6, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 2600142, + 9234906 + ], + "bg_colors": [ + 4036026, + 5287320 + ], + "story_colors": [ + 5036799, + 4325314 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 3774400, + 7986629 + ], + "bg_colors": [ + 3173500, + 4102270 + ], + "story_colors": [ + 4708863, + 3342285 + ] + } + }, + { + "id": 14, + "hidden": false, + "channel_min_level": 6, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 13715826, + 16760479 + ], + "bg_colors": [ + 11554676, + 13723245 + ], + "story_colors": [ + 16746153, + 16754323 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 12865666, + 15830166 + ], + "bg_colors": [ + 8929632, + 10900057 + ], + "story_colors": [ + 16675727, + 16751486 + ] + } + }, + { + "id": 15, + "hidden": false, + "channel_min_level": 6, + "colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 7108740, + 11384769 + ], + "bg_colors": [ + 6517890, + 8096407 + ], + "story_colors": [ + 12834019, + 15726847 + ] + }, + "dark_colors": { + "predicate_name": "help.peerColorProfileSet", + "colors": null, + "palette_colors": [ + 7108740, + 11384769 + ], + "bg_colors": [ + 5464174, + 3688020 + ], + "story_colors": [ + 10071235, + 14871283 + ] + } + } + ] +} \ No newline at end of file diff --git a/internal/seed/appearance/default_wallpapers/documents/5042099534566523019.dat b/internal/seed/appearance/default_wallpapers/documents/5042099534566523019.dat new file mode 100644 index 00000000..b14fbf1b Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5042099534566523019.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5042099534566523021.dat b/internal/seed/appearance/default_wallpapers/documents/5042099534566523021.dat new file mode 100644 index 00000000..fa2c088a Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5042099534566523021.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5042099534566523022.dat b/internal/seed/appearance/default_wallpapers/documents/5042099534566523022.dat new file mode 100644 index 00000000..b8530af1 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5042099534566523022.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5042099534566523023.dat b/internal/seed/appearance/default_wallpapers/documents/5042099534566523023.dat new file mode 100644 index 00000000..657f7945 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5042099534566523023.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5042099534566523024.dat b/internal/seed/appearance/default_wallpapers/documents/5042099534566523024.dat new file mode 100644 index 00000000..0064f9d9 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5042099534566523024.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695159.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695159.dat new file mode 100644 index 00000000..fffa8d73 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695159.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695160.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695160.dat new file mode 100644 index 00000000..4b715593 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695160.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695161.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695161.dat new file mode 100644 index 00000000..bcc5de70 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695161.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695162.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695162.dat new file mode 100644 index 00000000..b1f47b50 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695162.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695163.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695163.dat new file mode 100644 index 00000000..9009b95b Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695163.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695164.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695164.dat new file mode 100644 index 00000000..e968f02e Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695164.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695165.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695165.dat new file mode 100644 index 00000000..1cfa1873 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695165.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695166.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695166.dat new file mode 100644 index 00000000..ff443156 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695166.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695167.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695167.dat new file mode 100644 index 00000000..969d3bf2 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695167.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695168.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695168.dat new file mode 100644 index 00000000..6b162b75 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695168.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695169.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695169.dat new file mode 100644 index 00000000..0236b1f2 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695169.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695170.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695170.dat new file mode 100644 index 00000000..7065f3c8 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695170.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695171.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695171.dat new file mode 100644 index 00000000..80ea7f26 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695171.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695172.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695172.dat new file mode 100644 index 00000000..14415b3b Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695172.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695173.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695173.dat new file mode 100644 index 00000000..dfa100f3 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695173.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695174.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695174.dat new file mode 100644 index 00000000..6c703817 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695174.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695175.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695175.dat new file mode 100644 index 00000000..d5fe5000 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695175.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695176.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695176.dat new file mode 100644 index 00000000..5b0b17c9 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695176.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695177.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695177.dat new file mode 100644 index 00000000..76720b01 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695177.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695178.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695178.dat new file mode 100644 index 00000000..54a2922c Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695178.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695179.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695179.dat new file mode 100644 index 00000000..a78ad4ee Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695179.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695180.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695180.dat new file mode 100644 index 00000000..74dc661f Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695180.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695181.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695181.dat new file mode 100644 index 00000000..e5730ffb Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695181.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695182.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695182.dat new file mode 100644 index 00000000..181c04dc Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695182.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5044527775931695183.dat b/internal/seed/appearance/default_wallpapers/documents/5044527775931695183.dat new file mode 100644 index 00000000..99a7e99d Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5044527775931695183.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5051205286140838230.dat b/internal/seed/appearance/default_wallpapers/documents/5051205286140838230.dat new file mode 100644 index 00000000..022fb001 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5051205286140838230.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5080412794625458315.dat b/internal/seed/appearance/default_wallpapers/documents/5080412794625458315.dat new file mode 100644 index 00000000..a9c58387 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5080412794625458315.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5080412794625458316.dat b/internal/seed/appearance/default_wallpapers/documents/5080412794625458316.dat new file mode 100644 index 00000000..d7477861 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5080412794625458316.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5080412794625458317.dat b/internal/seed/appearance/default_wallpapers/documents/5080412794625458317.dat new file mode 100644 index 00000000..ec4b833f Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5080412794625458317.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5080412794625458318.dat b/internal/seed/appearance/default_wallpapers/documents/5080412794625458318.dat new file mode 100644 index 00000000..324a3175 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5080412794625458318.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5080412794625458320.dat b/internal/seed/appearance/default_wallpapers/documents/5080412794625458320.dat new file mode 100644 index 00000000..dd115287 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5080412794625458320.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5080412794625458321.dat b/internal/seed/appearance/default_wallpapers/documents/5080412794625458321.dat new file mode 100644 index 00000000..2ccc2a52 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5080412794625458321.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5080412794625458322.dat b/internal/seed/appearance/default_wallpapers/documents/5080412794625458322.dat new file mode 100644 index 00000000..53adac88 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5080412794625458322.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5082456438849142866.dat b/internal/seed/appearance/default_wallpapers/documents/5082456438849142866.dat new file mode 100644 index 00000000..aebec765 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5082456438849142866.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5082456438849142867.dat b/internal/seed/appearance/default_wallpapers/documents/5082456438849142867.dat new file mode 100644 index 00000000..5792463c Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5082456438849142867.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5199407101239101638.dat b/internal/seed/appearance/default_wallpapers/documents/5199407101239101638.dat new file mode 100644 index 00000000..8a14939b Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5199407101239101638.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5199690504656128578.dat b/internal/seed/appearance/default_wallpapers/documents/5199690504656128578.dat new file mode 100644 index 00000000..307b5d27 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5199690504656128578.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5404435181954140582.dat b/internal/seed/appearance/default_wallpapers/documents/5404435181954140582.dat new file mode 100644 index 00000000..a9236ed6 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5404435181954140582.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5782630687571969871.dat b/internal/seed/appearance/default_wallpapers/documents/5782630687571969871.dat new file mode 100644 index 00000000..d475066f Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5782630687571969871.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5785343895722264360.dat b/internal/seed/appearance/default_wallpapers/documents/5785343895722264360.dat new file mode 100644 index 00000000..ccbbc872 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5785343895722264360.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5789856918507882132.dat b/internal/seed/appearance/default_wallpapers/documents/5789856918507882132.dat new file mode 100644 index 00000000..0b073a5a Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5789856918507882132.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5911275789994690585.dat b/internal/seed/appearance/default_wallpapers/documents/5911275789994690585.dat new file mode 100644 index 00000000..1239c855 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5911275789994690585.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5911348641229966652.dat b/internal/seed/appearance/default_wallpapers/documents/5911348641229966652.dat new file mode 100644 index 00000000..f3ed194a Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5911348641229966652.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5911510226489576101.dat b/internal/seed/appearance/default_wallpapers/documents/5911510226489576101.dat new file mode 100644 index 00000000..85cc6f9e Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5911510226489576101.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5924577350955046421.dat b/internal/seed/appearance/default_wallpapers/documents/5924577350955046421.dat new file mode 100644 index 00000000..18f25a93 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5924577350955046421.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5924650004621823334.dat b/internal/seed/appearance/default_wallpapers/documents/5924650004621823334.dat new file mode 100644 index 00000000..80f0ef8a Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5924650004621823334.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5924664689115007842.dat b/internal/seed/appearance/default_wallpapers/documents/5924664689115007842.dat new file mode 100644 index 00000000..5f7a3919 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5924664689115007842.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5925009274341165314.dat b/internal/seed/appearance/default_wallpapers/documents/5925009274341165314.dat new file mode 100644 index 00000000..d1dfcf91 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5925009274341165314.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5926965009174236185.dat b/internal/seed/appearance/default_wallpapers/documents/5926965009174236185.dat new file mode 100644 index 00000000..1b25b779 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5926965009174236185.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5935747671833709896.dat b/internal/seed/appearance/default_wallpapers/documents/5935747671833709896.dat new file mode 100644 index 00000000..a7b98660 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5935747671833709896.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5935909802554165816.dat b/internal/seed/appearance/default_wallpapers/documents/5935909802554165816.dat new file mode 100644 index 00000000..67a977fe Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5935909802554165816.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5935950531729033666.dat b/internal/seed/appearance/default_wallpapers/documents/5935950531729033666.dat new file mode 100644 index 00000000..f59617b2 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5935950531729033666.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5935983444063423035.dat b/internal/seed/appearance/default_wallpapers/documents/5935983444063423035.dat new file mode 100644 index 00000000..12738992 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5935983444063423035.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5936306687597087418.dat b/internal/seed/appearance/default_wallpapers/documents/5936306687597087418.dat new file mode 100644 index 00000000..c4988ccd Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5936306687597087418.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5938052961170098801.dat b/internal/seed/appearance/default_wallpapers/documents/5938052961170098801.dat new file mode 100644 index 00000000..66ed00c4 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5938052961170098801.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5938074474661284850.dat b/internal/seed/appearance/default_wallpapers/documents/5938074474661284850.dat new file mode 100644 index 00000000..b452e3f0 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5938074474661284850.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5938102022581521601.dat b/internal/seed/appearance/default_wallpapers/documents/5938102022581521601.dat new file mode 100644 index 00000000..55a8cc8b Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5938102022581521601.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5938141815453518446.dat b/internal/seed/appearance/default_wallpapers/documents/5938141815453518446.dat new file mode 100644 index 00000000..06d40c85 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5938141815453518446.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5938158372552444547.dat b/internal/seed/appearance/default_wallpapers/documents/5938158372552444547.dat new file mode 100644 index 00000000..58bba0f3 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5938158372552444547.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5938195764537723401.dat b/internal/seed/appearance/default_wallpapers/documents/5938195764537723401.dat new file mode 100644 index 00000000..ae19563f Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5938195764537723401.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5938359140798696416.dat b/internal/seed/appearance/default_wallpapers/documents/5938359140798696416.dat new file mode 100644 index 00000000..0ab0c4bd Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5938359140798696416.dat differ diff --git a/internal/seed/appearance/default_wallpapers/documents/5938515597867354374.dat b/internal/seed/appearance/default_wallpapers/documents/5938515597867354374.dat new file mode 100644 index 00000000..adc9d57f Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/documents/5938515597867354374.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523019.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523019.dat new file mode 100644 index 00000000..cb29e33b Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523019.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523021.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523021.dat new file mode 100644 index 00000000..36cdadac Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523021.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523022.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523022.dat new file mode 100644 index 00000000..09ff38f9 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523022.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523023.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523023.dat new file mode 100644 index 00000000..7994fcd6 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523023.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523024.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523024.dat new file mode 100644 index 00000000..42b2961b Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5042099534566523024.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695159.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695159.dat new file mode 100644 index 00000000..0b940fc8 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695159.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695160.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695160.dat new file mode 100644 index 00000000..4d9fa77d Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695160.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695161.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695161.dat new file mode 100644 index 00000000..cfc0a55f Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695161.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695162.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695162.dat new file mode 100644 index 00000000..8664cbbf Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695162.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695163.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695163.dat new file mode 100644 index 00000000..8b13d20b Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695163.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695164.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695164.dat new file mode 100644 index 00000000..074e28e0 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695164.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695165.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695165.dat new file mode 100644 index 00000000..d1df98f5 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695165.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695166.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695166.dat new file mode 100644 index 00000000..70e6ba99 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695166.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695167.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695167.dat new file mode 100644 index 00000000..ea90b23c Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695167.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695168.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695168.dat new file mode 100644 index 00000000..8bfa41d5 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695168.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695169.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695169.dat new file mode 100644 index 00000000..e14ebf79 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695169.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695170.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695170.dat new file mode 100644 index 00000000..ddb180ab Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695170.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695171.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695171.dat new file mode 100644 index 00000000..72a68724 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695171.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695172.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695172.dat new file mode 100644 index 00000000..d3548eb5 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695172.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695173.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695173.dat new file mode 100644 index 00000000..7e3b56d6 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695173.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695174.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695174.dat new file mode 100644 index 00000000..bb1b1907 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695174.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695175.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695175.dat new file mode 100644 index 00000000..c96bc021 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695175.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695176.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695176.dat new file mode 100644 index 00000000..38d01342 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695176.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695177.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695177.dat new file mode 100644 index 00000000..cacaf71a Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695177.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695178.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695178.dat new file mode 100644 index 00000000..a0a80b6d Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695178.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695179.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695179.dat new file mode 100644 index 00000000..dfc9c743 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695179.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695180.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695180.dat new file mode 100644 index 00000000..9605da84 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695180.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695181.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695181.dat new file mode 100644 index 00000000..850951c2 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695181.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695182.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695182.dat new file mode 100644 index 00000000..a8af2e2c Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695182.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695183.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695183.dat new file mode 100644 index 00000000..d27c0d4a Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5044527775931695183.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5051205286140838230.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5051205286140838230.dat new file mode 100644 index 00000000..15184495 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5051205286140838230.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458315.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458315.dat new file mode 100644 index 00000000..ab68c99b Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458315.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458316.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458316.dat new file mode 100644 index 00000000..39ad22be Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458316.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458317.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458317.dat new file mode 100644 index 00000000..7655b8cb Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458317.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458318.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458318.dat new file mode 100644 index 00000000..0bfe7862 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458318.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458320.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458320.dat new file mode 100644 index 00000000..29fd4d49 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458320.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458321.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458321.dat new file mode 100644 index 00000000..f071bad8 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458321.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458322.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458322.dat new file mode 100644 index 00000000..ece9defd Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5080412794625458322.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5082456438849142866.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5082456438849142866.dat new file mode 100644 index 00000000..896e9e5e Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5082456438849142866.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5082456438849142867.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5082456438849142867.dat new file mode 100644 index 00000000..be057dee Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5082456438849142867.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5199407101239101638.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5199407101239101638.dat new file mode 100644 index 00000000..d647f6a9 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5199407101239101638.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5199690504656128578.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5199690504656128578.dat new file mode 100644 index 00000000..fc850e64 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5199690504656128578.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5404435181954140582.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5404435181954140582.dat new file mode 100644 index 00000000..3661a1c2 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5404435181954140582.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5782630687571969871.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5782630687571969871.dat new file mode 100644 index 00000000..47f495f3 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5782630687571969871.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5785343895722264360.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5785343895722264360.dat new file mode 100644 index 00000000..e4a65b3e Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5785343895722264360.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5789856918507882132.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5789856918507882132.dat new file mode 100644 index 00000000..3a5f9c8d Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5789856918507882132.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5911275789994690585.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5911275789994690585.dat new file mode 100644 index 00000000..746a62ac Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5911275789994690585.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5911348641229966652.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5911348641229966652.dat new file mode 100644 index 00000000..2f12fb0c Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5911348641229966652.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5911510226489576101.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5911510226489576101.dat new file mode 100644 index 00000000..89732579 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5911510226489576101.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5924577350955046421.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5924577350955046421.dat new file mode 100644 index 00000000..58eea396 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5924577350955046421.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5924650004621823334.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5924650004621823334.dat new file mode 100644 index 00000000..648c35ce Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5924650004621823334.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5924664689115007842.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5924664689115007842.dat new file mode 100644 index 00000000..04b57e0b Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5924664689115007842.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5925009274341165314.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5925009274341165314.dat new file mode 100644 index 00000000..751f8405 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5925009274341165314.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5926965009174236185.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5926965009174236185.dat new file mode 100644 index 00000000..35725ec3 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5926965009174236185.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5935747671833709896.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5935747671833709896.dat new file mode 100644 index 00000000..a97b2318 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5935747671833709896.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5935909802554165816.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5935909802554165816.dat new file mode 100644 index 00000000..1a87ca9e Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5935909802554165816.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5935950531729033666.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5935950531729033666.dat new file mode 100644 index 00000000..b65bbfe8 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5935950531729033666.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5935983444063423035.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5935983444063423035.dat new file mode 100644 index 00000000..3eee59fd Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5935983444063423035.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5936306687597087418.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5936306687597087418.dat new file mode 100644 index 00000000..490bcb85 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5936306687597087418.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5938052961170098801.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5938052961170098801.dat new file mode 100644 index 00000000..80e5c184 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5938052961170098801.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5938074474661284850.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5938074474661284850.dat new file mode 100644 index 00000000..379b222e Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5938074474661284850.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5938102022581521601.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5938102022581521601.dat new file mode 100644 index 00000000..dbc484d4 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5938102022581521601.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5938141815453518446.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5938141815453518446.dat new file mode 100644 index 00000000..297f1855 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5938141815453518446.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5938158372552444547.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5938158372552444547.dat new file mode 100644 index 00000000..abcdcd8a Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5938158372552444547.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5938195764537723401.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5938195764537723401.dat new file mode 100644 index 00000000..3cb76fdf Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5938195764537723401.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5938359140798696416.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5938359140798696416.dat new file mode 100644 index 00000000..89a5aa6c Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5938359140798696416.dat differ diff --git a/internal/seed/appearance/default_wallpapers/thumbs/m/5938515597867354374.dat b/internal/seed/appearance/default_wallpapers/thumbs/m/5938515597867354374.dat new file mode 100644 index 00000000..e3520559 Binary files /dev/null and b/internal/seed/appearance/default_wallpapers/thumbs/m/5938515597867354374.dat differ diff --git a/internal/seed/appearance/seed.go b/internal/seed/appearance/seed.go new file mode 100644 index 00000000..2b621a24 --- /dev/null +++ b/internal/seed/appearance/seed.go @@ -0,0 +1,142 @@ +package appearance + +import ( + "embed" + "encoding/json" + "sync" +) + +// FS contains the default appearance catalog and wallpaper blobs. +// +//go:embed default_appearance_seed.json default_wallpapers/documents/* default_wallpapers/thumbs/m/* +var FS embed.FS + +var ( + loadOnce sync.Once + defaultCatalog Catalog + loadErr error +) + +// Default returns the bundled appearance seed. +func Default() Catalog { + loadOnce.Do(func() { + raw, err := FS.ReadFile("default_appearance_seed.json") + if err != nil { + loadErr = err + return + } + loadErr = json.Unmarshal(raw, &defaultCatalog) + }) + if loadErr != nil { + panic(loadErr) + } + return defaultCatalog +} + +type Catalog struct { + Source string `json:"source"` + ExportedAt string `json:"exported_at"` + Notes Notes `json:"notes"` + ChatThemes []ChatTheme `json:"chat_themes"` + Wallpapers []Wallpaper `json:"wallpapers"` + PeerColors []ColorOption `json:"peer_colors"` + PeerProfileColors []ColorOption `json:"peer_profile_colors"` +} + +type Notes struct { + Server string `json:"server"` + ThemesTotal int `json:"themes_total"` + ChatThemesTotal int `json:"chat_themes_total"` + ChatThemeSeed string `json:"chat_theme_seed"` + WallpaperDocumentsBucket string `json:"wallpaper_documents_bucket"` + WallpaperThumbsBucket string `json:"wallpaper_thumbs_bucket"` +} + +type ChatTheme struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + Slug string `json:"slug"` + Title string `json:"title"` + Emoticon string `json:"emoticon"` + Default bool `json:"default"` + Settings []ThemeSettings `json:"settings"` +} + +type ThemeSettings struct { + BaseTheme string `json:"base_theme"` + AccentColor int `json:"accent_color"` + OutboxAccentColor int `json:"outbox_accent_color"` + MessageColors []int `json:"message_colors"` + Wallpaper Wallpaper `json:"wallpaper"` +} + +type Wallpaper struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + Type int `json:"type"` + Default bool `json:"default"` + Pattern bool `json:"pattern"` + Dark bool `json:"dark"` + Slug string `json:"slug"` + Settings WallpaperSettings `json:"settings"` + Document Document `json:"document"` +} + +type WallpaperSettings struct { + ID int64 `json:"id"` + Blur bool `json:"blur"` + Motion bool `json:"motion"` + BackgroundColor int `json:"background_color"` + SecondBackgroundColor int `json:"second_background_color"` + ThirdBackgroundColor int `json:"third_background_color"` + FourthBackgroundColor int `json:"fourth_background_color"` + Intensity int `json:"intensity"` + Rotation int `json:"rotation"` +} + +type Document struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + Date int `json:"date"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` + DCID int `json:"dc_id"` + Path string `json:"path"` + SHA256 string `json:"sha256"` + Attributes []DocumentAttribute `json:"attributes"` + Thumbs []PhotoSize `json:"thumbs"` +} + +type DocumentAttribute struct { + Kind string `json:"kind"` + W int `json:"w,omitempty"` + H int `json:"h,omitempty"` + FileName string `json:"file_name,omitempty"` +} + +type PhotoSize struct { + Kind string `json:"kind"` + Type string `json:"type"` + W int `json:"w,omitempty"` + H int `json:"h,omitempty"` + Size int `json:"size,omitempty"` + Path string `json:"path,omitempty"` + SHA256 string `json:"sha256,omitempty"` +} + +type ColorOption struct { + ID int `json:"id"` + Hidden bool `json:"hidden"` + ChannelMinLevel int `json:"channel_min_level"` + GroupMinLevel int `json:"group_min_level"` + Colors *ColorSet `json:"colors"` + DarkColors *ColorSet `json:"dark_colors"` +} + +type ColorSet struct { + PredicateName string `json:"predicate_name"` + Colors []int `json:"colors"` + PaletteColors []int `json:"palette_colors"` + BgColors []int `json:"bg_colors"` + StoryColors []int `json:"story_colors"` +} diff --git a/internal/seed/appearance/seed_test.go b/internal/seed/appearance/seed_test.go new file mode 100644 index 00000000..e7644e54 --- /dev/null +++ b/internal/seed/appearance/seed_test.go @@ -0,0 +1,72 @@ +package appearance + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +func TestDefaultCatalogShape(t *testing.T) { + catalog := Default() + if catalog.Source == "" { + t.Fatalf("Source is empty") + } + if len(catalog.ChatThemes) == 0 { + t.Fatalf("ChatThemes empty, want official chat themes") + } + if len(catalog.Wallpapers) == 0 { + t.Fatalf("Wallpapers empty, want official wallpapers") + } + if len(catalog.PeerColors) != 21 { + t.Fatalf("PeerColors length = %d, want carried-over count 21", len(catalog.PeerColors)) + } + if len(catalog.PeerProfileColors) != 16 { + t.Fatalf("PeerProfileColors length = %d, want carried-over count 16", len(catalog.PeerProfileColors)) + } + + // 至少有一张带可下载文档+缩略图的墙纸,且嵌入字节 sha256 对得上。 + var withDoc *Wallpaper + for i := range catalog.Wallpapers { + if catalog.Wallpapers[i].Document.ID != 0 && catalog.Wallpapers[i].Document.Path != "" { + withDoc = &catalog.Wallpapers[i] + break + } + } + if withDoc == nil { + t.Fatalf("no wallpaper with a downloadable document") + } + assertEmbeddedSHA(t, withDoc.Document.Path, withDoc.Document.SHA256) + if len(withDoc.Document.Thumbs) > 0 { + assertEmbeddedSHA(t, withDoc.Document.Thumbs[0].Path, withDoc.Document.Thumbs[0].SHA256) + } + + // 每个聊天主题至少一个带墙纸文档的设置(官方主题都带主题背景)。 + for _, ct := range catalog.ChatThemes { + if len(ct.Settings) == 0 { + t.Fatalf("chat theme %q has no settings", ct.Emoticon) + } + } + + // 沿用的配色调色板结构保持不变。 + if catalog.PeerColors[0].Colors != nil { + t.Fatalf("PeerColors[0].Colors = %+v, want nil default palette", catalog.PeerColors[0].Colors) + } + if catalog.PeerColors[7].Colors == nil || len(catalog.PeerColors[7].Colors.Colors) == 0 { + t.Fatalf("PeerColors[7].Colors = %+v, want explicit palette", catalog.PeerColors[7].Colors) + } + if catalog.PeerProfileColors[0].Colors == nil || len(catalog.PeerProfileColors[0].Colors.StoryColors) != 2 { + t.Fatalf("PeerProfileColors[0].Colors = %+v, want profile palette", catalog.PeerProfileColors[0].Colors) + } +} + +func assertEmbeddedSHA(t *testing.T, path, want string) { + t.Helper() + data, err := FS.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + sum := sha256.Sum256(data) + if got := hex.EncodeToString(sum[:]); got != want { + t.Fatalf("%s sha256 = %s, want %s", path, got, want) + } +} diff --git a/internal/seed/catalog/catalog.go b/internal/seed/catalog/catalog.go new file mode 100644 index 00000000..12cff445 --- /dev/null +++ b/internal/seed/catalog/catalog.go @@ -0,0 +1,166 @@ +// Package catalog 提供从官方 Telegram 拉取并固化的「目录/参考数据」种子: +// 国家区号(help.getCountriesList)、时区(help.getTimezonesList)、emoji 分组 +// (messages.getEmojiGroups)、emoji 关键词(messages.getEmojiKeywords)。 +// +// 数据由 cmd/catalogfetch 经官方 API 拉取后写入本目录的 JSON, +// 经 go:embed 进二进制。改了 JSON 即改了内容 hash,客户端发旧 hash 会被驱动重取。 +// 各 accessor 在数据为空(未 seed)时返回空集,由调用方决定是否回退到内置最小数据。 +package catalog + +import ( + _ "embed" + "encoding/json" + "hash/fnv" + "sync" + + "telesrv/internal/domain" +) + +//go:embed countries.json +var countriesJSON []byte + +//go:embed timezones.json +var timezonesJSON []byte + +//go:embed emoji_groups.json +var emojiGroupsJSON []byte + +//go:embed emoji_keywords_en.json +var emojiKeywordsENJSON []byte + +type countriesFile struct { + Countries []countryJSON `json:"countries"` +} +type countryJSON struct { + ISO2 string `json:"iso2"` + DefaultName string `json:"default_name"` + Name string `json:"name"` + Hidden bool `json:"hidden"` + CountryCodes []countryCodeJSON `json:"country_codes"` +} +type countryCodeJSON struct { + CountryCode string `json:"country_code"` + Prefixes []string `json:"prefixes"` + Patterns []string `json:"patterns"` +} + +// Timezone 是 help.getTimezonesList 的一条时区。 +type Timezone struct { + ID string `json:"id"` + Name string `json:"name"` + UTCOffset int `json:"utc_offset"` +} +type timezonesFile struct { + Timezones []Timezone `json:"timezones"` +} + +// EmojiGroup 是 messages.getEmojiGroups 的一个分类。IconEmojiID 指向一个 custom-emoji +// 文档(本地若无对应文档,客户端分类图标回退为占位,不影响分组本身)。 +type EmojiGroup struct { + Title string `json:"title"` + IconEmojiID int64 `json:"icon_emoji_id"` + Emoticons []string `json:"emoticons"` +} +type emojiGroupsFile struct { + Groups []EmojiGroup `json:"groups"` +} + +// EmojiKeyword 是 emoji 关键词词典的一条:一个关键词 → 一组 emoji。 +type EmojiKeyword struct { + Keyword string `json:"keyword"` + Emoticons []string `json:"emoticons"` +} + +// EmojiKeywordSet 是某语言的整份 emoji 关键词词典。 +type EmojiKeywordSet struct { + LangCode string `json:"lang_code"` + Version int `json:"version"` + Keywords []EmojiKeyword `json:"keywords"` +} + +var ( + once sync.Once + countries domain.CountriesList + timezones []Timezone + timezonesHash int + emojiGroups []EmojiGroup + emojiGroupsHash int + emojiKeywordSets map[string]EmojiKeywordSet +) + +func load() { + once.Do(func() { + var cf countriesFile + _ = json.Unmarshal(countriesJSON, &cf) + list := make([]domain.Country, 0, len(cf.Countries)) + for _, c := range cf.Countries { + codes := make([]domain.CountryCode, 0, len(c.CountryCodes)) + for _, cc := range c.CountryCodes { + codes = append(codes, domain.CountryCode{ + CountryCode: cc.CountryCode, + Prefixes: cc.Prefixes, + Patterns: cc.Patterns, + }) + } + list = append(list, domain.Country{ + ISO2: c.ISO2, + DefaultName: c.DefaultName, + Name: c.Name, + Hidden: c.Hidden, + CountryCodes: codes, + }) + } + countries = domain.CountriesList{Hash: hashBytes(countriesJSON), Countries: list} + + var tf timezonesFile + _ = json.Unmarshal(timezonesJSON, &tf) + timezones = tf.Timezones + timezonesHash = hashBytes(timezonesJSON) + + var ef emojiGroupsFile + _ = json.Unmarshal(emojiGroupsJSON, &ef) + emojiGroups = ef.Groups + emojiGroupsHash = hashBytes(emojiGroupsJSON) + + emojiKeywordSets = map[string]EmojiKeywordSet{} + for _, raw := range [][]byte{emojiKeywordsENJSON} { + var ks EmojiKeywordSet + if err := json.Unmarshal(raw, &ks); err == nil && ks.LangCode != "" && len(ks.Keywords) > 0 { + emojiKeywordSets[ks.LangCode] = ks + } + } + }) +} + +// hashBytes 把内容映射为一个稳定的正整数 hash(FNV-1a 32),供 NotModified 协商。 +// 内容变了 hash 必变,客户端发旧 hash 即不命中→重取。 +func hashBytes(b []byte) int { + h := fnv.New32a() + _, _ = h.Write(b) + return int(h.Sum32() & 0x7fffffff) +} + +// Countries 返回固化的国家区号目录(空表示未 seed,调用方应回退内置最小集)。 +func Countries() domain.CountriesList { + load() + return countries +} + +// Timezones 返回时区目录与其内容 hash(空表示未 seed)。 +func Timezones() ([]Timezone, int) { + load() + return timezones, timezonesHash +} + +// EmojiGroups 返回 emoji 分类目录与其内容 hash(空表示未 seed)。 +func EmojiGroups() ([]EmojiGroup, int) { + load() + return emojiGroups, emojiGroupsHash +} + +// EmojiKeywords 返回指定语言的 emoji 关键词词典;ok=false 表示该语言未 seed。 +func EmojiKeywords(lang string) (EmojiKeywordSet, bool) { + load() + ks, ok := emojiKeywordSets[lang] + return ks, ok +} diff --git a/internal/seed/catalog/catalog_test.go b/internal/seed/catalog/catalog_test.go new file mode 100644 index 00000000..d36ab988 --- /dev/null +++ b/internal/seed/catalog/catalog_test.go @@ -0,0 +1,49 @@ +package catalog + +import "testing" + +// TestSeededCatalogsLoaded 验证 go:embed 的官方目录数据被正确解析、非空。 +// 内容由 cmd/catalogfetch 拉取;数字是下界,重拉变多不会让测试失败。 +func TestSeededCatalogsLoaded(t *testing.T) { + cs := Countries() + if len(cs.Countries) < 200 { + t.Fatalf("countries = %d, want >= 200 (official ~235)", len(cs.Countries)) + } + if cs.Hash == 0 { + t.Fatalf("countries hash = 0, want stable nonzero") + } + var cnHasPattern bool + for _, c := range cs.Countries { + if c.ISO2 == "CN" { + for _, cc := range c.CountryCodes { + if cc.CountryCode == "86" && len(cc.Patterns) > 0 { + cnHasPattern = true + } + } + } + } + if !cnHasPattern { + t.Fatalf("CN(+86) missing phone pattern") + } + + tzs, tzHash := Timezones() + if len(tzs) < 100 { + t.Fatalf("timezones = %d, want >= 100 (official ~419)", len(tzs)) + } + if tzHash == 0 { + t.Fatalf("timezones hash = 0") + } + + groups, gHash := EmojiGroups() + if len(groups) == 0 || gHash == 0 { + t.Fatalf("emoji groups = %d hash=%d, want non-empty", len(groups), gHash) + } + if len(groups[0].Emoticons) == 0 { + t.Fatalf("emoji group %q has no emoticons", groups[0].Title) + } + + set, ok := EmojiKeywords("en") + if !ok || set.Version == 0 || len(set.Keywords) < 1000 { + t.Fatalf("en emoji keywords ok=%v version=%d count=%d, want seeded (official ~4286)", ok, set.Version, len(set.Keywords)) + } +} diff --git a/internal/seed/catalog/countries.json b/internal/seed/catalog/countries.json new file mode 100644 index 00000000..46c3a45f --- /dev/null +++ b/internal/seed/catalog/countries.json @@ -0,0 +1,2794 @@ +{ + "countries": [ + { + "iso2": "AD", + "default_name": "Andorra", + "country_codes": [ + { + "country_code": "376", + "patterns": [ + "XXXXXX" + ] + } + ] + }, + { + "iso2": "AE", + "default_name": "United Arab Emirates", + "country_codes": [ + { + "country_code": "971", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "AF", + "default_name": "Afghanistan", + "country_codes": [ + { + "country_code": "93", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "AG", + "default_name": "Antigua \u0026 Barbuda", + "country_codes": [ + { + "country_code": "1268", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "AI", + "default_name": "Anguilla", + "country_codes": [ + { + "country_code": "1264", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "AL", + "default_name": "Albania", + "country_codes": [ + { + "country_code": "355", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "AM", + "default_name": "Armenia", + "country_codes": [ + { + "country_code": "374", + "patterns": [ + "XX XXXXXX" + ] + } + ] + }, + { + "iso2": "AO", + "default_name": "Angola", + "country_codes": [ + { + "country_code": "244", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "AR", + "default_name": "Argentina", + "country_codes": [ + { + "country_code": "54" + } + ] + }, + { + "iso2": "AS", + "default_name": "American Samoa", + "country_codes": [ + { + "country_code": "1684", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "AT", + "default_name": "Austria", + "country_codes": [ + { + "country_code": "43" + } + ] + }, + { + "iso2": "AU", + "default_name": "Australia", + "country_codes": [ + { + "country_code": "61", + "patterns": [ + "X XXXX XXXX" + ] + } + ] + }, + { + "iso2": "AW", + "default_name": "Aruba", + "country_codes": [ + { + "country_code": "297", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "AZ", + "default_name": "Azerbaijan", + "country_codes": [ + { + "country_code": "994", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "BA", + "default_name": "Bosnia \u0026 Herzegovina", + "country_codes": [ + { + "country_code": "387", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "BB", + "default_name": "Barbados", + "country_codes": [ + { + "country_code": "1246", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "BD", + "default_name": "Bangladesh", + "country_codes": [ + { + "country_code": "880", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "BE", + "default_name": "Belgium", + "country_codes": [ + { + "country_code": "32", + "patterns": [ + "XXX XX XX XX" + ] + } + ] + }, + { + "iso2": "BF", + "default_name": "Burkina Faso", + "country_codes": [ + { + "country_code": "226", + "patterns": [ + "XX XX XX XX" + ] + } + ] + }, + { + "iso2": "BG", + "default_name": "Bulgaria", + "country_codes": [ + { + "country_code": "359" + } + ] + }, + { + "iso2": "BH", + "default_name": "Bahrain", + "country_codes": [ + { + "country_code": "973", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "BI", + "default_name": "Burundi", + "country_codes": [ + { + "country_code": "257", + "patterns": [ + "XX XX XXXX" + ] + } + ] + }, + { + "iso2": "BJ", + "default_name": "Benin", + "country_codes": [ + { + "country_code": "229", + "patterns": [ + "XX XX XX XX XX" + ] + } + ] + }, + { + "iso2": "BM", + "default_name": "Bermuda", + "country_codes": [ + { + "country_code": "1441", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "BN", + "default_name": "Brunei Darussalam", + "country_codes": [ + { + "country_code": "673", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "BO", + "default_name": "Bolivia", + "country_codes": [ + { + "country_code": "591", + "patterns": [ + "X XXX XXXX" + ] + } + ] + }, + { + "iso2": "BQ", + "default_name": "Bonaire, Sint Eustatius \u0026 Saba", + "country_codes": [ + { + "country_code": "599" + } + ] + }, + { + "iso2": "BR", + "default_name": "Brazil", + "country_codes": [ + { + "country_code": "55", + "patterns": [ + "XX XXXXX XXXX" + ] + } + ] + }, + { + "iso2": "BS", + "default_name": "Bahamas", + "country_codes": [ + { + "country_code": "1242", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "BT", + "default_name": "Bhutan", + "country_codes": [ + { + "country_code": "975", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "BW", + "default_name": "Botswana", + "country_codes": [ + { + "country_code": "267", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "BY", + "default_name": "Belarus", + "country_codes": [ + { + "country_code": "375", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "BZ", + "default_name": "Belize", + "country_codes": [ + { + "country_code": "501" + } + ] + }, + { + "iso2": "CA", + "default_name": "Canada", + "country_codes": [ + { + "country_code": "1", + "prefixes": [ + "403", + "587", + "780", + "825", + "236", + "250", + "604", + "672", + "778", + "204", + "431", + "506", + "709", + "902", + "782", + "226", + "249", + "289", + "343", + "365", + "416", + "437", + "519", + "548", + "613", + "647", + "705", + "807", + "905", + "418", + "438", + "450", + "514", + "579", + "581", + "819", + "873", + "306", + "639", + "867" + ], + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "CD", + "default_name": "Congo (Dem. Rep.)", + "country_codes": [ + { + "country_code": "243", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "CF", + "default_name": "Central African Rep.", + "country_codes": [ + { + "country_code": "236", + "patterns": [ + "XX XX XX XX" + ] + } + ] + }, + { + "iso2": "CG", + "default_name": "Congo (Rep.)", + "country_codes": [ + { + "country_code": "242", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "CH", + "default_name": "Switzerland", + "country_codes": [ + { + "country_code": "41", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "CI", + "default_name": "Côte d'Ivoire", + "country_codes": [ + { + "country_code": "225", + "patterns": [ + "XX XX XX XXXX" + ] + } + ] + }, + { + "iso2": "CK", + "default_name": "Cook Islands", + "country_codes": [ + { + "country_code": "682" + } + ] + }, + { + "iso2": "CL", + "default_name": "Chile", + "country_codes": [ + { + "country_code": "56", + "patterns": [ + "X XXXX XXXX" + ] + } + ] + }, + { + "iso2": "CM", + "default_name": "Cameroon", + "country_codes": [ + { + "country_code": "237", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "CN", + "default_name": "China", + "country_codes": [ + { + "country_code": "86", + "patterns": [ + "XXX XXXX XXXX" + ] + } + ] + }, + { + "iso2": "CO", + "default_name": "Colombia", + "country_codes": [ + { + "country_code": "57", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "CR", + "default_name": "Costa Rica", + "country_codes": [ + { + "country_code": "506", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "CU", + "default_name": "Cuba", + "country_codes": [ + { + "country_code": "53", + "patterns": [ + "X XXX XXXX" + ] + } + ] + }, + { + "iso2": "CV", + "default_name": "Cape Verde", + "country_codes": [ + { + "country_code": "238", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "CW", + "default_name": "Curaçao", + "country_codes": [ + { + "country_code": "599", + "prefixes": [ + "9" + ] + } + ] + }, + { + "iso2": "CY", + "default_name": "Cyprus", + "country_codes": [ + { + "country_code": "357", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "CZ", + "default_name": "Czech Republic", + "country_codes": [ + { + "country_code": "420", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "DE", + "default_name": "Germany", + "country_codes": [ + { + "country_code": "49" + } + ] + }, + { + "iso2": "DJ", + "default_name": "Djibouti", + "country_codes": [ + { + "country_code": "253", + "patterns": [ + "XX XX XX XX" + ] + } + ] + }, + { + "iso2": "DK", + "default_name": "Denmark", + "country_codes": [ + { + "country_code": "45", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "DM", + "default_name": "Dominica", + "country_codes": [ + { + "country_code": "1767", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "DO", + "default_name": "Dominican Rep.", + "country_codes": [ + { + "country_code": "1809", + "patterns": [ + "XXX XXXX" + ] + }, + { + "country_code": "1829", + "patterns": [ + "XXX XXXX" + ] + }, + { + "country_code": "1849", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "DZ", + "default_name": "Algeria", + "country_codes": [ + { + "country_code": "213", + "patterns": [ + "XXX XX XX XX" + ] + } + ] + }, + { + "iso2": "EC", + "default_name": "Ecuador", + "country_codes": [ + { + "country_code": "593", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "EE", + "default_name": "Estonia", + "country_codes": [ + { + "country_code": "372", + "patterns": [ + "XXXX XXX" + ] + } + ] + }, + { + "iso2": "EG", + "default_name": "Egypt", + "country_codes": [ + { + "country_code": "20", + "patterns": [ + "XX XXXX XXXX" + ] + } + ] + }, + { + "iso2": "ER", + "default_name": "Eritrea", + "country_codes": [ + { + "country_code": "291", + "patterns": [ + "X XXX XXX" + ] + } + ] + }, + { + "iso2": "ES", + "default_name": "Spain", + "country_codes": [ + { + "country_code": "34", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "ET", + "default_name": "Ethiopia", + "country_codes": [ + { + "country_code": "251", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "FI", + "default_name": "Finland", + "country_codes": [ + { + "country_code": "358" + } + ] + }, + { + "iso2": "FJ", + "default_name": "Fiji", + "country_codes": [ + { + "country_code": "679", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "FK", + "default_name": "Falkland Islands", + "country_codes": [ + { + "country_code": "500" + } + ] + }, + { + "iso2": "FM", + "default_name": "Micronesia", + "country_codes": [ + { + "country_code": "691" + } + ] + }, + { + "iso2": "FO", + "default_name": "Faroe Islands", + "country_codes": [ + { + "country_code": "298", + "patterns": [ + "XXX XXX" + ] + } + ] + }, + { + "iso2": "FR", + "default_name": "France", + "country_codes": [ + { + "country_code": "33", + "patterns": [ + "X XX XX XX XX" + ] + } + ] + }, + { + "iso2": "FT", + "default_name": "Anonymous Numbers", + "country_codes": [ + { + "country_code": "888", + "patterns": [ + "8 XXX", + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "GA", + "default_name": "Gabon", + "country_codes": [ + { + "country_code": "241", + "patterns": [ + "X XX XX XX" + ] + } + ] + }, + { + "iso2": "GB", + "default_name": "United Kingdom", + "country_codes": [ + { + "country_code": "44", + "patterns": [ + "XX XXXX XXXX" + ] + } + ] + }, + { + "iso2": "GD", + "default_name": "Grenada", + "country_codes": [ + { + "country_code": "1473", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "GE", + "default_name": "Georgia", + "country_codes": [ + { + "country_code": "995", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "GF", + "default_name": "French Guiana", + "country_codes": [ + { + "country_code": "594", + "patterns": [ + "XXX XX XX XX" + ] + } + ] + }, + { + "iso2": "GH", + "default_name": "Ghana", + "country_codes": [ + { + "country_code": "233", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "GI", + "default_name": "Gibraltar", + "country_codes": [ + { + "country_code": "350", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "GL", + "default_name": "Greenland", + "country_codes": [ + { + "country_code": "299", + "patterns": [ + "XXX XXX" + ] + } + ] + }, + { + "iso2": "GM", + "default_name": "Gambia", + "country_codes": [ + { + "country_code": "220", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "GN", + "default_name": "Guinea", + "country_codes": [ + { + "country_code": "224", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "GP", + "default_name": "Guadeloupe", + "country_codes": [ + { + "country_code": "590", + "patterns": [ + "XXX XX XX XX" + ] + } + ] + }, + { + "iso2": "GQ", + "default_name": "Equatorial Guinea", + "country_codes": [ + { + "country_code": "240", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "GR", + "default_name": "Greece", + "country_codes": [ + { + "country_code": "30", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "GT", + "default_name": "Guatemala", + "country_codes": [ + { + "country_code": "502", + "patterns": [ + "X XXX XXXX" + ] + } + ] + }, + { + "iso2": "GU", + "default_name": "Guam", + "country_codes": [ + { + "country_code": "1671", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "GW", + "default_name": "Guinea-Bissau", + "country_codes": [ + { + "country_code": "245", + "patterns": [ + "XXX XX XX XX" + ] + } + ] + }, + { + "iso2": "GY", + "default_name": "Guyana", + "country_codes": [ + { + "country_code": "592", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "HK", + "default_name": "Hong Kong", + "country_codes": [ + { + "country_code": "852", + "patterns": [ + "X XXX XXXX" + ] + } + ] + }, + { + "iso2": "HN", + "default_name": "Honduras", + "country_codes": [ + { + "country_code": "504", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "HR", + "default_name": "Croatia", + "country_codes": [ + { + "country_code": "385", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "HT", + "default_name": "Haiti", + "country_codes": [ + { + "country_code": "509", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "HU", + "default_name": "Hungary", + "country_codes": [ + { + "country_code": "36", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "ID", + "default_name": "Indonesia", + "country_codes": [ + { + "country_code": "62", + "patterns": [ + "XXX XXXXXX" + ] + } + ] + }, + { + "iso2": "IE", + "default_name": "Ireland", + "country_codes": [ + { + "country_code": "353", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "IL", + "default_name": "Israel", + "country_codes": [ + { + "country_code": "972", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "IN", + "default_name": "India", + "country_codes": [ + { + "country_code": "91", + "patterns": [ + "XXXXX XXXXX" + ] + } + ] + }, + { + "iso2": "IO", + "default_name": "Diego Garcia", + "country_codes": [ + { + "country_code": "246", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "IQ", + "default_name": "Iraq", + "country_codes": [ + { + "country_code": "964", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "IR", + "default_name": "Iran", + "country_codes": [ + { + "country_code": "98", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "IS", + "default_name": "Iceland", + "country_codes": [ + { + "country_code": "354", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "IT", + "default_name": "Italy", + "country_codes": [ + { + "country_code": "39", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "JM", + "default_name": "Jamaica", + "country_codes": [ + { + "country_code": "1876", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "JO", + "default_name": "Jordan", + "country_codes": [ + { + "country_code": "962", + "patterns": [ + "X XXXX XXXX" + ] + } + ] + }, + { + "iso2": "JP", + "default_name": "Japan", + "country_codes": [ + { + "country_code": "81", + "patterns": [ + "XX XXXX XXXX" + ] + } + ] + }, + { + "iso2": "KE", + "default_name": "Kenya", + "country_codes": [ + { + "country_code": "254", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "KG", + "default_name": "Kyrgyzstan", + "country_codes": [ + { + "country_code": "996", + "patterns": [ + "XXX XXXXXX" + ] + } + ] + }, + { + "iso2": "KH", + "default_name": "Cambodia", + "country_codes": [ + { + "country_code": "855", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "KI", + "default_name": "Kiribati", + "country_codes": [ + { + "country_code": "686", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "KM", + "default_name": "Comoros", + "country_codes": [ + { + "country_code": "269", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "KN", + "default_name": "Saint Kitts \u0026 Nevis", + "country_codes": [ + { + "country_code": "1869", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "KP", + "default_name": "North Korea", + "country_codes": [ + { + "country_code": "850" + } + ] + }, + { + "iso2": "KR", + "default_name": "South Korea", + "country_codes": [ + { + "country_code": "82", + "patterns": [ + "XX XXXX XXX" + ] + } + ] + }, + { + "iso2": "KW", + "default_name": "Kuwait", + "country_codes": [ + { + "country_code": "965", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "KY", + "default_name": "Cayman Islands", + "country_codes": [ + { + "country_code": "1345", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "KZ", + "default_name": "Kazakhstan", + "country_codes": [ + { + "country_code": "7", + "prefixes": [ + "6", + "7" + ], + "patterns": [ + "XXX XXX XX XX" + ] + } + ] + }, + { + "iso2": "LA", + "default_name": "Laos", + "country_codes": [ + { + "country_code": "856", + "patterns": [ + "XX XX XXX XXX" + ] + } + ] + }, + { + "iso2": "LB", + "default_name": "Lebanon", + "country_codes": [ + { + "country_code": "961" + } + ] + }, + { + "iso2": "LC", + "default_name": "Saint Lucia", + "country_codes": [ + { + "country_code": "1758", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "LI", + "default_name": "Liechtenstein", + "country_codes": [ + { + "country_code": "423", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "LK", + "default_name": "Sri Lanka", + "country_codes": [ + { + "country_code": "94", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "LR", + "default_name": "Liberia", + "country_codes": [ + { + "country_code": "231", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "LS", + "default_name": "Lesotho", + "country_codes": [ + { + "country_code": "266", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "LT", + "default_name": "Lithuania", + "country_codes": [ + { + "country_code": "370", + "patterns": [ + "XXX XXXXX" + ] + } + ] + }, + { + "iso2": "LU", + "default_name": "Luxembourg", + "country_codes": [ + { + "country_code": "352", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "LV", + "default_name": "Latvia", + "country_codes": [ + { + "country_code": "371", + "patterns": [ + "XXX XXXXX" + ] + } + ] + }, + { + "iso2": "LY", + "default_name": "Libya", + "country_codes": [ + { + "country_code": "218", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "MA", + "default_name": "Morocco", + "country_codes": [ + { + "country_code": "212", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "MC", + "default_name": "Monaco", + "country_codes": [ + { + "country_code": "377", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "MD", + "default_name": "Moldova", + "country_codes": [ + { + "country_code": "373", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "ME", + "default_name": "Montenegro", + "country_codes": [ + { + "country_code": "382" + } + ] + }, + { + "iso2": "MG", + "default_name": "Madagascar", + "country_codes": [ + { + "country_code": "261", + "patterns": [ + "XX XX XXX XX" + ] + } + ] + }, + { + "iso2": "MH", + "default_name": "Marshall Islands", + "country_codes": [ + { + "country_code": "692" + } + ] + }, + { + "iso2": "MK", + "default_name": "North Macedonia", + "country_codes": [ + { + "country_code": "389", + "patterns": [ + "XX XXXXXX" + ] + } + ] + }, + { + "iso2": "ML", + "default_name": "Mali", + "country_codes": [ + { + "country_code": "223", + "patterns": [ + "XX XX XX XX" + ] + } + ] + }, + { + "iso2": "MM", + "default_name": "Myanmar", + "country_codes": [ + { + "country_code": "95" + } + ] + }, + { + "iso2": "MN", + "default_name": "Mongolia", + "country_codes": [ + { + "country_code": "976", + "patterns": [ + "XX XX XXXX" + ] + } + ] + }, + { + "iso2": "MO", + "default_name": "Macau", + "country_codes": [ + { + "country_code": "853", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "MP", + "default_name": "Northern Mariana Islands", + "country_codes": [ + { + "country_code": "1670", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "MQ", + "default_name": "Martinique", + "country_codes": [ + { + "country_code": "596" + } + ] + }, + { + "iso2": "MR", + "default_name": "Mauritania", + "country_codes": [ + { + "country_code": "222", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "MS", + "default_name": "Montserrat", + "country_codes": [ + { + "country_code": "1664", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "MT", + "default_name": "Malta", + "country_codes": [ + { + "country_code": "356", + "patterns": [ + "XX XX XX XX" + ] + } + ] + }, + { + "iso2": "MU", + "default_name": "Mauritius", + "country_codes": [ + { + "country_code": "230", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "MV", + "default_name": "Maldives", + "country_codes": [ + { + "country_code": "960", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "MW", + "default_name": "Malawi", + "country_codes": [ + { + "country_code": "265", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "MX", + "default_name": "Mexico", + "country_codes": [ + { + "country_code": "52" + } + ] + }, + { + "iso2": "MY", + "default_name": "Malaysia", + "country_codes": [ + { + "country_code": "60" + } + ] + }, + { + "iso2": "MZ", + "default_name": "Mozambique", + "country_codes": [ + { + "country_code": "258", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "NA", + "default_name": "Namibia", + "country_codes": [ + { + "country_code": "264", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "NC", + "default_name": "New Caledonia", + "country_codes": [ + { + "country_code": "687", + "patterns": [ + "XX XX XX" + ] + } + ] + }, + { + "iso2": "NE", + "default_name": "Niger", + "country_codes": [ + { + "country_code": "227", + "patterns": [ + "XX XX XX XX" + ] + } + ] + }, + { + "iso2": "NF", + "default_name": "Norfolk Island", + "country_codes": [ + { + "country_code": "672" + } + ] + }, + { + "iso2": "NG", + "default_name": "Nigeria", + "country_codes": [ + { + "country_code": "234", + "patterns": [ + "XX XXXX XXXX" + ] + } + ] + }, + { + "iso2": "NI", + "default_name": "Nicaragua", + "country_codes": [ + { + "country_code": "505", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "NL", + "default_name": "Netherlands", + "country_codes": [ + { + "country_code": "31", + "patterns": [ + "X XX XX XX XX" + ] + } + ] + }, + { + "iso2": "NO", + "default_name": "Norway", + "country_codes": [ + { + "country_code": "47", + "patterns": [ + "XXX XX XXX" + ] + } + ] + }, + { + "iso2": "NP", + "default_name": "Nepal", + "country_codes": [ + { + "country_code": "977", + "patterns": [ + "XX XXXX XXXX" + ] + } + ] + }, + { + "iso2": "NR", + "default_name": "Nauru", + "country_codes": [ + { + "country_code": "674" + } + ] + }, + { + "iso2": "NU", + "default_name": "Niue", + "country_codes": [ + { + "country_code": "683" + } + ] + }, + { + "iso2": "NZ", + "default_name": "New Zealand", + "country_codes": [ + { + "country_code": "64", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "OM", + "default_name": "Oman", + "country_codes": [ + { + "country_code": "968", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "PA", + "default_name": "Panama", + "country_codes": [ + { + "country_code": "507", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "PE", + "default_name": "Peru", + "country_codes": [ + { + "country_code": "51", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "PF", + "default_name": "French Polynesia", + "country_codes": [ + { + "country_code": "689" + } + ] + }, + { + "iso2": "PG", + "default_name": "Papua New Guinea", + "country_codes": [ + { + "country_code": "675", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "PH", + "default_name": "Philippines", + "country_codes": [ + { + "country_code": "63", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "PK", + "default_name": "Pakistan", + "country_codes": [ + { + "country_code": "92", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "PL", + "default_name": "Poland", + "country_codes": [ + { + "country_code": "48", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "PM", + "default_name": "Saint Pierre \u0026 Miquelon", + "country_codes": [ + { + "country_code": "508" + } + ] + }, + { + "iso2": "PR", + "default_name": "Puerto Rico", + "country_codes": [ + { + "country_code": "1787", + "patterns": [ + "XXX XXXX" + ] + }, + { + "country_code": "1939", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "PS", + "default_name": "Palestine", + "country_codes": [ + { + "country_code": "970", + "patterns": [ + "XXX XX XXXX" + ] + } + ] + }, + { + "iso2": "PT", + "default_name": "Portugal", + "country_codes": [ + { + "country_code": "351", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "PW", + "default_name": "Palau", + "country_codes": [ + { + "country_code": "680" + } + ] + }, + { + "iso2": "PY", + "default_name": "Paraguay", + "country_codes": [ + { + "country_code": "595", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "QA", + "default_name": "Qatar", + "country_codes": [ + { + "country_code": "974", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "RE", + "default_name": "Réunion", + "country_codes": [ + { + "country_code": "262", + "patterns": [ + "XXX XX XX XX" + ] + } + ] + }, + { + "iso2": "RO", + "default_name": "Romania", + "country_codes": [ + { + "country_code": "40", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "RS", + "default_name": "Serbia", + "country_codes": [ + { + "country_code": "381", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "RU", + "default_name": "Russian Federation", + "country_codes": [ + { + "country_code": "7", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "RW", + "default_name": "Rwanda", + "country_codes": [ + { + "country_code": "250", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "SA", + "default_name": "Saudi Arabia", + "country_codes": [ + { + "country_code": "966", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "SB", + "default_name": "Solomon Islands", + "country_codes": [ + { + "country_code": "677" + } + ] + }, + { + "iso2": "SC", + "default_name": "Seychelles", + "country_codes": [ + { + "country_code": "248", + "patterns": [ + "X XX XX XX" + ] + } + ] + }, + { + "iso2": "SD", + "default_name": "Sudan", + "country_codes": [ + { + "country_code": "249", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "SE", + "default_name": "Sweden", + "country_codes": [ + { + "country_code": "46", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "SG", + "default_name": "Singapore", + "country_codes": [ + { + "country_code": "65", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "SH", + "default_name": "Saint Helena", + "country_codes": [ + { + "country_code": "247" + }, + { + "country_code": "290", + "patterns": [ + "XX XXX" + ] + } + ] + }, + { + "iso2": "SI", + "default_name": "Slovenia", + "country_codes": [ + { + "country_code": "386", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "SK", + "default_name": "Slovakia", + "country_codes": [ + { + "country_code": "421", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "SL", + "default_name": "Sierra Leone", + "country_codes": [ + { + "country_code": "232", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "SM", + "default_name": "San Marino", + "country_codes": [ + { + "country_code": "378" + } + ] + }, + { + "iso2": "SN", + "default_name": "Senegal", + "country_codes": [ + { + "country_code": "221", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "SO", + "default_name": "Somalia", + "country_codes": [ + { + "country_code": "252", + "patterns": [ + "XX XXXXXXX" + ] + } + ] + }, + { + "iso2": "SR", + "default_name": "Suriname", + "country_codes": [ + { + "country_code": "597", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "SS", + "default_name": "South Sudan", + "country_codes": [ + { + "country_code": "211", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "ST", + "default_name": "São Tomé \u0026 Príncipe", + "country_codes": [ + { + "country_code": "239", + "patterns": [ + "XX XXXXX" + ] + } + ] + }, + { + "iso2": "SV", + "default_name": "El Salvador", + "country_codes": [ + { + "country_code": "503", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "SX", + "default_name": "Sint Maarten", + "country_codes": [ + { + "country_code": "1721", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "SY", + "default_name": "Syria", + "country_codes": [ + { + "country_code": "963", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "SZ", + "default_name": "Eswatini", + "country_codes": [ + { + "country_code": "268", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "TC", + "default_name": "Turks \u0026 Caicos Islands", + "country_codes": [ + { + "country_code": "1649", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "TD", + "default_name": "Chad", + "country_codes": [ + { + "country_code": "235", + "patterns": [ + "XX XX XX XX" + ] + } + ] + }, + { + "iso2": "TG", + "default_name": "Togo", + "country_codes": [ + { + "country_code": "228", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "TH", + "default_name": "Thailand", + "country_codes": [ + { + "country_code": "66", + "patterns": [ + "X XXXX XXXX" + ] + } + ] + }, + { + "iso2": "TJ", + "default_name": "Tajikistan", + "country_codes": [ + { + "country_code": "992", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "TK", + "default_name": "Tokelau", + "country_codes": [ + { + "country_code": "690" + } + ] + }, + { + "iso2": "TL", + "default_name": "Timor-Leste", + "country_codes": [ + { + "country_code": "670" + } + ] + }, + { + "iso2": "TM", + "default_name": "Turkmenistan", + "country_codes": [ + { + "country_code": "993", + "patterns": [ + "XX XXXXXX" + ] + } + ] + }, + { + "iso2": "TN", + "default_name": "Tunisia", + "country_codes": [ + { + "country_code": "216", + "patterns": [ + "XX XXX XXX" + ] + } + ] + }, + { + "iso2": "TO", + "default_name": "Tonga", + "country_codes": [ + { + "country_code": "676" + } + ] + }, + { + "iso2": "TR", + "default_name": "Turkey", + "country_codes": [ + { + "country_code": "90", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "TT", + "default_name": "Trinidad \u0026 Tobago", + "country_codes": [ + { + "country_code": "1868", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "TV", + "default_name": "Tuvalu", + "country_codes": [ + { + "country_code": "688" + } + ] + }, + { + "iso2": "TW", + "default_name": "Taiwan", + "country_codes": [ + { + "country_code": "886", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "TZ", + "default_name": "Tanzania", + "country_codes": [ + { + "country_code": "255", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "UA", + "default_name": "Ukraine", + "country_codes": [ + { + "country_code": "380", + "patterns": [ + "XX XXX XX XX" + ] + } + ] + }, + { + "iso2": "UG", + "default_name": "Uganda", + "country_codes": [ + { + "country_code": "256", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "US", + "default_name": "USA", + "country_codes": [ + { + "country_code": "1", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "UY", + "default_name": "Uruguay", + "country_codes": [ + { + "country_code": "598", + "patterns": [ + "X XXX XXXX" + ] + } + ] + }, + { + "iso2": "UZ", + "default_name": "Uzbekistan", + "country_codes": [ + { + "country_code": "998", + "patterns": [ + "XX XXX XX XX" + ] + } + ] + }, + { + "iso2": "VC", + "default_name": "Saint Vincent \u0026 the Grenadines", + "country_codes": [ + { + "country_code": "1784", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "VE", + "default_name": "Venezuela", + "country_codes": [ + { + "country_code": "58", + "patterns": [ + "XXX XXX XXXX" + ] + } + ] + }, + { + "iso2": "VG", + "default_name": "British Virgin Islands", + "country_codes": [ + { + "country_code": "1284", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "VI", + "default_name": "US Virgin Islands", + "country_codes": [ + { + "country_code": "1340", + "patterns": [ + "XXX XXXX" + ] + } + ] + }, + { + "iso2": "VN", + "default_name": "Vietnam", + "country_codes": [ + { + "country_code": "84" + } + ] + }, + { + "iso2": "VU", + "default_name": "Vanuatu", + "country_codes": [ + { + "country_code": "678" + } + ] + }, + { + "iso2": "WF", + "default_name": "Wallis \u0026 Futuna", + "country_codes": [ + { + "country_code": "681", + "patterns": [ + "XX XX XX" + ] + } + ] + }, + { + "iso2": "WS", + "default_name": "Samoa", + "country_codes": [ + { + "country_code": "685" + } + ] + }, + { + "iso2": "XG", + "default_name": "Global Mobile Satellite System", + "hidden": true, + "country_codes": [ + { + "country_code": "881" + } + ] + }, + { + "iso2": "XK", + "default_name": "Kosovo", + "country_codes": [ + { + "country_code": "383", + "patterns": [ + "XXXX XXXX" + ] + } + ] + }, + { + "iso2": "XV", + "default_name": "International Networks", + "hidden": true, + "country_codes": [ + { + "country_code": "882" + }, + { + "country_code": "883" + } + ] + }, + { + "iso2": "YE", + "default_name": "Yemen", + "country_codes": [ + { + "country_code": "967", + "patterns": [ + "XXX XXX XXX" + ] + } + ] + }, + { + "iso2": "YL", + "default_name": "Y-land", + "hidden": true, + "country_codes": [ + { + "country_code": "42", + "prefixes": [ + "4", + "7" + ] + } + ] + }, + { + "iso2": "ZA", + "default_name": "South Africa", + "country_codes": [ + { + "country_code": "27", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "ZM", + "default_name": "Zambia", + "country_codes": [ + { + "country_code": "260", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + }, + { + "iso2": "ZW", + "default_name": "Zimbabwe", + "country_codes": [ + { + "country_code": "263", + "patterns": [ + "XX XXX XXXX" + ] + } + ] + } + ] +} diff --git a/internal/seed/catalog/emoji_groups.json b/internal/seed/catalog/emoji_groups.json new file mode 100644 index 00000000..3fe443fd --- /dev/null +++ b/internal/seed/catalog/emoji_groups.json @@ -0,0 +1,209 @@ +{ + "groups": [ + { + "title": "Love", + "icon_emoji_id": 5355088357070218139, + "emoticons": [ + "❤", + "😍", + "🥰", + "😘", + "☺", + "🤗", + "😚", + "😙", + "💋", + "🍑", + "👄", + "🫦", + "😻", + "❤‍🔥", + "💗", + "💞", + "💕", + "💖", + "🧡", + "💛", + "💚", + "💙", + "💜", + "🖤", + "🤍", + "🤎", + "💓", + "💘", + "💝", + "💟", + "👩‍❤‍👨", + "👩‍❤‍👩", + "💑", + "👨‍❤‍👨", + "👩‍❤‍💋‍👨", + "👩‍❤‍💋‍👩", + "💏", + "👨‍❤‍💋‍👨" + ] + }, + { + "title": "Approval", + "icon_emoji_id": 5357465415310124060, + "emoticons": [ + "👍", + "👏", + "👌", + "💪", + "😌", + "✌", + "🤝", + "✔", + "🆗", + "✅" + ] + }, + { + "title": "Disapproval", + "icon_emoji_id": 5357216259962315463, + "emoticons": [ + "👎", + "😒", + "🤮", + "🤢", + "🤦‍♂", + "🤦‍♀", + "🙅‍♀", + "🙅‍♂" + ] + }, + { + "title": "Cheers", + "icon_emoji_id": 5357257143756005264, + "emoticons": [ + "🎉", + "🥳", + "🤩", + "🎊", + "🥂", + "🍾", + "🌟", + "🍻", + "🎂", + "💃", + "🎁" + ] + }, + { + "title": "Laughter", + "icon_emoji_id": 5354785574760753200, + "emoticons": [ + "😄", + "😁", + "😆", + "😅", + "😂", + "🤣", + "😀", + "🤭", + "😃", + "😬", + "😈" + ] + }, + { + "title": "Astonishment", + "icon_emoji_id": 5354978715145085779, + "emoticons": [ + "😨", + "😦", + "😱", + "😯", + "😧", + "😮", + "😲", + "🤯", + "😵", + "😳", + "🫣", + "😰" + ] + }, + { + "title": "Sadness", + "icon_emoji_id": 5355181227148059369, + "emoticons": [ + "😔", + "😪", + "😭", + "😢", + "😣", + "😞", + "🥺", + "💔", + "☹", + "😕", + "🙁", + "🥲", + "😒", + "😥", + "😫", + "😮‍💨", + "🫤", + "😟" + ] + }, + { + "title": "Anger", + "icon_emoji_id": 5355125405458113381, + "emoticons": [ + "😡", + "🤬", + "👿", + "😠", + "😤", + "🖕", + "💢" + ] + }, + { + "title": "Neutral", + "icon_emoji_id": 5357252827313874903, + "emoticons": [ + "😐", + "😑", + "😕", + "😶", + "🙃", + "🙂", + "🫠", + "🤐", + "🥶" + ] + }, + { + "title": "Doubt", + "icon_emoji_id": 5354848362887653592, + "emoticons": [ + "🤔", + "🤨", + "🧐", + "🙄", + "🥸", + "😵‍💫", + "❓", + "🤷‍♀", + "🤷‍♂" + ] + }, + { + "title": "Silly", + "icon_emoji_id": 5354832952544993830, + "emoticons": [ + "🤪", + "😜", + "😝", + "😛", + "🤡", + "🥴" + ] + } + ] +} diff --git a/internal/seed/catalog/emoji_keywords_en.json b/internal/seed/catalog/emoji_keywords_en.json new file mode 100644 index 00000000..cdb00ea1 --- /dev/null +++ b/internal/seed/catalog/emoji_keywords_en.json @@ -0,0 +1,29968 @@ +{ + "lang_code": "en", + "version": 13310, + "keywords": [ + { + "keyword": "lion", + "emoticons": [ + "🦁" + ] + }, + { + "keyword": "bitcoin", + "emoticons": [ + "💸", + "💵", + "💲", + "💰" + ] + }, + { + "keyword": "psychic", + "emoticons": [ + "🎱", + "🔮" + ] + }, + { + "keyword": "ai", + "emoticons": [ + "🇦🇮" + ] + }, + { + "keyword": "pen", + "emoticons": [ + "✒", + "✍", + "🖊", + "🖋" + ] + }, + { + "keyword": "kiss", + "emoticons": [ + "😙", + "💋", + "😽", + "😗", + "👩‍❤‍💋‍👩", + "👨‍❤‍💋‍👨", + "💏", + "👄", + "😘", + "😚", + "👩‍❤‍💋‍👨" + ] + }, + { + "keyword": "bagel", + "emoticons": [ + "🥨", + "🥯" + ] + }, + { + "keyword": "fuck", + "emoticons": [ + "😡", + "🤬", + "👌", + "🖕", + "😠" + ] + }, + { + "keyword": "astronaut", + "emoticons": [ + "👩‍🚀", + "👨‍🚀", + "🧑‍🚀" + ] + }, + { + "keyword": "earth americas", + "emoticons": [ + "🌎" + ] + }, + { + "keyword": "blue cap", + "emoticons": [ + "🧢" + ] + }, + { + "keyword": "flag_jm", + "emoticons": [ + "🇯🇲" + ] + }, + { + "keyword": "red square", + "emoticons": [ + "🟥" + ] + }, + { + "keyword": "backstabber", + "emoticons": [ + "🐍" + ] + }, + { + "keyword": "relieved face", + "emoticons": [ + "😌" + ] + }, + { + "keyword": "spurt", + "emoticons": [ + "🐳" + ] + }, + { + "keyword": "croc", + "emoticons": [ + "🐊" + ] + }, + { + "keyword": "morning", + "emoticons": [ + "🌅", + "🌄" + ] + }, + { + "keyword": "kuwait", + "emoticons": [ + "🇰🇼" + ] + }, + { + "keyword": "couple", + "emoticons": [ + "👩‍❤‍💋‍👨", + "👩‍❤‍👨", + "👫" + ] + }, + { + "keyword": "snoozing", + "emoticons": [ + "😴", + "😪" + ] + }, + { + "keyword": "high heels", + "emoticons": [ + "👠" + ] + }, + { + "keyword": "pig", + "emoticons": [ + "🐖", + "🐷", + "🐽" + ] + }, + { + "keyword": "fist", + "emoticons": [ + "🤜", + "👊", + "🤛", + "✊" + ] + }, + { + "keyword": "blood", + "emoticons": [ + "🅾", + "🆑", + "🅱", + "🆎", + "🩸", + "🅰" + ] + }, + { + "keyword": "flag_mk", + "emoticons": [ + "🇲🇰" + ] + }, + { + "keyword": "office", + "emoticons": [ + "👨‍💼", + "🗑", + "👩‍💼", + "📇", + "📊", + "📁", + "📌", + "📋", + "💼", + "✂", + "📈", + "👨‍💻", + "📏", + "🗃", + "📂", + "🗓", + "🖇", + "📍", + "🗒", + "🏢", + "🗂", + "👩‍💻", + "📐", + "📉", + "📅", + "📎", + "🗄", + "📆" + ] + }, + { + "keyword": "nut and bolt", + "emoticons": [ + "🔩" + ] + }, + { + "keyword": "date", + "emoticons": [ + "🍷", + "🌹", + "💕", + "👒", + "👫", + "💖", + "📅" + ] + }, + { + "keyword": "woman factory worker", + "emoticons": [ + "👩‍🏭" + ] + }, + { + "keyword": "baboon", + "emoticons": [ + "🐒", + "🐵", + "🦍" + ] + }, + { + "keyword": "lots of love", + "emoticons": [ + "💓", + "💕", + "♥", + "😍", + "💚", + "💙", + "😘", + "💖", + "💞", + "🧡", + "❣", + "🥰", + "💘", + "❤", + "💝", + "🖤", + "💛", + "💗", + "💜", + "💟" + ] + }, + { + "keyword": "bird", + "emoticons": [ + "🦆", + "🦢", + "🦚", + "🐔", + "🐧", + "🦃", + "🐣", + "👐", + "🐥", + "🦩", + "🐦", + "🐓", + "🦅", + "🦤", + "🦉", + "🦜", + "🐤", + "🕊" + ] + }, + { + "keyword": "man golfing", + "emoticons": [ + "🏌‍♂" + ] + }, + { + "keyword": "massage", + "emoticons": [ + "💆", + "💆‍♂", + "💆‍♀" + ] + }, + { + "keyword": "mage", + "emoticons": [ + "🧙‍♀", + "🧙", + "🧙‍♂" + ] + }, + { + "keyword": "clapboard", + "emoticons": [ + "🎬" + ] + }, + { + "keyword": "three o'clock", + "emoticons": [ + "🕒" + ] + }, + { + "keyword": "theater", + "emoticons": [ + "👩‍🎤", + "👨‍🎤", + "🎭" + ] + }, + { + "keyword": "beverage", + "emoticons": [ + "🍹", + "🥂", + "🍻", + "🍶", + "🥃", + "🍷", + "🍸", + "🍺", + "🧃", + "☕", + "🍵", + "🍾", + "🥛", + "🥤", + "🍼" + ] + }, + { + "keyword": "monster", + "emoticons": [ + "👻", + "👾", + "👹", + "👺", + "🦖" + ] + }, + { + "keyword": "sport", + "emoticons": [ + "🤾‍♀", + "🚴‍♀", + "🤸‍♀", + "⛳", + "🏌‍♀", + "🏌‍♂", + "🏃‍♂", + "🤸‍♂", + "🤾", + "🏄‍♂", + "🧘‍♀", + "👟", + "🏑", + "💪", + "🥅", + "🤾‍♂", + "🏇", + "🏀", + "🤼", + "⚾", + "⛹‍♂", + "🥍", + "🛷", + "🎾", + "🤽‍♀", + "🎱", + "🏸", + "🚵‍♀", + "🏆", + "🏉", + "🏐", + "🏓", + "🤺", + "🤼‍♂", + "🏒", + "🏋‍♂", + "🏃‍♀", + "🥉", + "🏊‍♂", + "🎿", + "🏅", + "🤼‍♀", + "🥈", + "🏊‍♀", + "🚣‍♀", + "🤽‍♂", + "🏈", + "🚴‍♂", + "🎯", + "🥎", + "🏋‍♀", + "🥋", + "🤸", + "🏄‍♀", + "🎣", + "⛷", + "🏏", + "🎳", + "🎽", + "🥇", + "🚣‍♂", + "🏂", + "🧘‍♂", + "⛹‍♀", + "🥊", + "⛸", + "🥌" + ] + }, + { + "keyword": "outsider", + "emoticons": [ + "👾", + "👽" + ] + }, + { + "keyword": "sweetheart", + "emoticons": [ + "💕", + "💟", + "❤", + "😘", + "💘", + "💚", + "💖", + "😍", + "🖤", + "💙", + "💓", + "🥰", + "♥", + "💞", + "💗", + "🧡", + "💝", + "💛", + "💜", + "❣" + ] + }, + { + "keyword": "high five", + "emoticons": [ + "🙌" + ] + }, + { + "keyword": "blond", + "emoticons": [ + "👱‍♂", + "👱‍♀" + ] + }, + { + "keyword": "ht", + "emoticons": [ + "🇭🇹" + ] + }, + { + "keyword": "starry night", + "emoticons": [ + "🌃" + ] + }, + { + "keyword": "flag", + "emoticons": [ + "🇸🇿", + "🇸🇸", + "🇱🇾", + "🇹🇨", + "🇨🇻", + "🇬🇧", + "🇷🇼", + "🇰🇼", + "🇰🇲", + "🇲🇦", + "🇵🇷", + "🇬🇳", + "🇪🇸", + "🇮🇩", + "🇻🇬", + "🇹🇦", + "🇦🇸", + "🇬🇪", + "🇦🇺", + "🇬🇵", + "🇮🇪", + "🏴󠁧󠁢󠁳󠁣󠁴󠁿", + "🇿🇦", + "🇸🇰", + "🇲🇾", + "🇨🇼", + "🇨🇲", + "🇵🇦", + "🇺🇿", + "🇩🇴", + "🇭🇳", + "🇵🇱", + "🇨🇨", + "🇦🇮", + "🇹🇲", + "🇦🇷", + "🏴󠁧󠁢󠁷󠁬󠁳󠁿", + "🇱🇷", + "🇳🇪", + "🇦🇹", + "🇧🇪", + "🇬🇼", + "🇽🇰", + "🏁", + "⛳", + "🇵🇬", + "🇨🇱", + "🇮🇱", + "🏴", + "🇳🇬", + "🇧🇧", + "🇱🇰", + "🇫🇴", + "🇮🇴", + "🇳🇴", + "🇹🇱", + "🇱🇧", + "🇸🇳", + "🇹🇹", + "🇧🇩", + "🇦🇬", + "🇾🇹", + "🇧🇱", + "🇧🇼", + "🇪🇭", + "🇨🇴", + "🇧🇳", + "🇨🇾", + "🇸🇴", + "🇧🇫", + "🇲🇬", + "🇨🇭", + "🇹🇩", + "🇫🇲", + "🇨🇮", + "🇹🇯", + "🇪🇺", + "🇹🇬", + "🇨🇷", + "🇯🇵", + "🇨🇺", + "🇫🇷", + "🇫🇮", + "🇯🇪", + "🇸🇪", + "🇲🇽", + "🇹🇭", + "🇺🇦", + "🇬🇾", + "🇦🇿", + "🇸🇭", + "🇵🇸", + "🇧🇮", + "🇱🇻", + "🇳🇿", + "🇩🇬", + "🇨🇫", + "🇰🇳", + "🏳", + "🇻🇦", + "🇵🇲", + "🇲🇿", + "🇲🇺", + "🇲🇹", + "🇨🇿", + "🇵🇳", + "🇮🇹", + "🇦🇱", + "🇲🇵", + "🇨🇰", + "🇯🇲", + "🇬🇲", + "🇦🇴", + "🇳🇷", + "🇿🇲", + "🇰🇷", + "🇦🇪", + "🇲🇱", + "🇻🇪", + "🇵🇰", + "🇰🇿", + "🇼🇫", + "🇷🇪", + "🇲🇴", + "🇹🇳", + "🇱🇦", + "🇨🇽", + "🇹🇼", + "🇾🇪", + "🇵🇫", + "🇳🇮", + "🇷🇸", + "🇺🇾", + "🇩🇪", + "🇻🇺", + "🇮🇸", + "🏳‍🌈", + "🇲🇷", + "🇷🇴", + "🇨🇩", + "🇩🇰", + "🇲🇶", + "🇶🇦", + "🇫🇯", + "🇻🇨", + "🇹🇴", + "🇸🇩", + "🇮🇶", + "🇻🇳", + "🇰🇾", + "🇧🇷", + "🇦🇫", + "🇧🇭", + "🇩🇿", + "🇧🇲", + "🇦🇩", + "🇺🇳", + "🇧🇹", + "🇪🇷", + "🇬🇦", + "🏴‍☠", + "🇱🇸", + "🇦🇶", + "🇧🇴", + "🚩", + "🇬🇷", + "🇪🇨", + "🇦🇼", + "🇧🇶", + "🇸🇦", + "🇳🇫", + "🇰🇵", + "🇳🇨", + "🇲🇨", + "🇹🇰", + "🇧🇬", + "🇭🇷", + "🇭🇰", + "🇸🇬", + "🇬🇹", + "🇬🇭", + "🇻🇮", + "🇦🇲", + "🇰🇮", + "🇲🇪", + "🎌", + "🇲🇰", + "🇬🇶", + "🇲🇩", + "🇩🇲", + "🇵🇪", + "🇮🇨", + "🇺🇬", + "🇬🇸", + "🇧🇸", + "🇸🇷", + "🇱🇹", + "🇴🇲", + "🇪🇪", + "🇱🇺", + "🇸🇾", + "🇮🇲", + "🇱🇨", + "🇪🇹", + "🇮🇷", + "🇱🇮", + "🇸🇨", + "🇲🇸", + "🇨🇬", + "🇵🇼", + "🇬🇱", + "🇧🇾", + "🇦🇽", + "🇮🇳", + "🇭🇹", + "🇸🇹", + "🇫🇰", + "🇨🇦", + "🇵🇹", + "🇰🇭", + "🇧🇿", + "🇧🇯", + "🇧🇦", + "🇨🇳", + "🇦🇨", + "🇸🇽", + "🇲🇳", + "🇸🇱", + "🇸🇻", + "🇼🇸", + "🇵🇾", + "🇷🇺", + "🇬🇺", + "🇬🇮", + "🇬🇫", + "🇰🇪", + "🇲🇲", + "🇺🇸", + "🇲🇭", + "🇳🇦", + "🇸🇮", + "🇲🇼", + "🇯🇴", + "🇰🇬", + "🇳🇵", + "🇬🇩", + "🇪🇬", + "🇪🇦", + "🇸🇲", + "🇿🇼", + "🇹🇻", + "🇬🇬", + "🇹🇫", + "🇭🇺", + "🇩🇯", + "🇳🇺", + "🇸🇧", + "🇲🇫", + "🇹🇷", + "🇳🇱", + "🇹🇿", + "🇲🇻", + "🇵🇭" + ] + }, + { + "keyword": "food", + "emoticons": [ + "🍫", + "🍟", + "🥓", + "🍍", + "🥙", + "🫑", + "🍯", + "🥐", + "🍬", + "🥟", + "🥦", + "🍙", + "🍣", + "🥩", + "🥗", + "🍩", + "🍊", + "🍨", + "🥔", + "🍛", + "🍌", + "🍮", + "🌽", + "🍧", + "🍓", + "🥜", + "🌯", + "🍚", + "🍐", + "🥨", + "🍒", + "🍢", + "🍋", + "🥚", + "🍖", + "🍡", + "🍈", + "🌶", + "🍞", + "🥬", + "🦑", + "🥞", + "🍦", + "🦀", + "🍭", + "🍘", + "🌭", + "🍰", + "🥡", + "🥮", + "🍜", + "🌮", + "🦐", + "🍏", + "🍿", + "🍠", + "🍱", + "🥫", + "🍝", + "🥯", + "🎂", + "🥑", + "🌰", + "🫕", + "🥥", + "🍆", + "🧀", + "🍇", + "🍉", + "🥘", + "🍕", + "🥝", + "🍤", + "🍪", + "🦞", + "🥠", + "🍲", + "🍳", + "🍎", + "🍅", + "🥣", + "🥖", + "🍗", + "🍥", + "🥕", + "🍑", + "🧂", + "🥭", + "🥧", + "🥪", + "🍔", + "🍄", + "🧁", + "🥒" + ] + }, + { + "keyword": "travelling", + "emoticons": [ + "🏝", + "🛩", + "🌞", + "🗺", + "✈", + "🛬", + "🛸", + "🚂", + "📍", + "🛤", + "🛫", + "🛴", + "🚅", + "🌎", + "🏞", + "🚢", + "🚍", + "🛣", + "🚞", + "🛵", + "🚗", + "🚊", + "🚌", + "🧳" + ] + }, + { + "keyword": "blind", + "emoticons": [ + "🦮", + "🧑‍🦯", + "👩‍🦯", + "👨‍🦯" + ] + }, + { + "keyword": "hand", + "emoticons": [ + "🤳", + "👈", + "🤙", + "👇", + "✋", + "☝", + "💅", + "🖕", + "👍", + "🤞", + "🤟", + "👋", + "✍", + "🙋", + "🖖", + "👌", + "👉", + "💁‍♀", + "💁", + "👎", + "👆", + "🤚", + "🤘", + "🙌", + "💁‍♂", + "🖐", + "✌" + ] + }, + { + "keyword": "lady", + "emoticons": [ + "👩‍🦰", + "👩", + "👵", + "👩‍🦱", + "👱‍♀", + "👩‍🦳", + "👩‍🦲" + ] + }, + { + "keyword": "bicycle", + "emoticons": [ + "🚲", + "🚴" + ] + }, + { + "keyword": "up-down arrow", + "emoticons": [ + "↕" + ] + }, + { + "keyword": "money", + "emoticons": [ + "💸", + "💴", + "💷", + "💶", + "🤑", + "💲", + "💳", + "🪙", + "💰", + "🫰", + "💵" + ] + }, + { + "keyword": "win", + "emoticons": [ + "🏆", + "🥇" + ] + }, + { + "keyword": "person juggling", + "emoticons": [ + "🤹‍♀", + "🤹‍♂" + ] + }, + { + "keyword": "melody", + "emoticons": [ + "🎼", + "🎵", + "🎸", + "🎺", + "🎷", + "🎧", + "🎹", + "🎻", + "🎶" + ] + }, + { + "keyword": "ill", + "emoticons": [ + "🤕", + "🤧", + "🤢", + "🤒", + "🤮", + "😷" + ] + }, + { + "keyword": "kimono", + "emoticons": [ + "🥋", + "👘" + ] + }, + { + "keyword": "hatched chick", + "emoticons": [ + "🐥" + ] + }, + { + "keyword": "illness", + "emoticons": [ + "🤕", + "🤒", + "😷" + ] + }, + { + "keyword": "leader", + "emoticons": [ + "🏆" + ] + }, + { + "keyword": "dress", + "emoticons": [ + "👗", + "💃", + "👰" + ] + }, + { + "keyword": "lick", + "emoticons": [ + "😛", + "👅" + ] + }, + { + "keyword": "universe", + "emoticons": [ + "🪐", + "🌙", + "🌕", + "🌟", + "⭐", + "🌏" + ] + }, + { + "keyword": "crap", + "emoticons": [ + "💩" + ] + }, + { + "keyword": "man artist", + "emoticons": [ + "👨‍🎨" + ] + }, + { + "keyword": "radiation", + "emoticons": [ + "☣", + "☢" + ] + }, + { + "keyword": "couple with heart", + "emoticons": [ + "👩‍❤‍💋‍👩", + "💑", + "👨‍❤‍💋‍👨", + "👩‍❤‍👩", + "💏", + "👨‍❤‍👨" + ] + }, + { + "keyword": "love", + "emoticons": [ + "💛", + "👨‍❤‍👨", + "😚", + "🖤", + "👩‍❤‍👨", + "👩‍❤‍👩", + "💚", + "😻", + "💝", + "👩‍❤‍💋‍👨", + "💋", + "💙", + "💟", + "♥", + "💞", + "💌", + "💘", + "😽", + "💔", + "❣", + "😗", + "💓", + "💗", + "❤", + "💕", + "😍", + "🥰", + "❤‍🔥", + "💑", + "💖", + "😙", + "💜", + "🧡", + "😘" + ] + }, + { + "keyword": "eat", + "emoticons": [ + "🥢", + "🍴", + "😋", + "🍽" + ] + }, + { + "keyword": "departure", + "emoticons": [ + "🛫" + ] + }, + { + "keyword": "person", + "emoticons": [ + "👳‍♂", + "👨‍🦰", + "🧓", + "👳‍♀", + "🧑", + "👴", + "🧍‍♂", + "👩‍🦱", + "👲", + "👨‍🦳", + "👵", + "👩‍🦳", + "👩‍🦰", + "👦", + "👥", + "👨‍🦲", + "👨‍🦱", + "👩", + "🧍", + "👧", + "🧒", + "👱‍♀", + "🧕", + "🧔", + "🧍‍♀", + "👤", + "👱‍♂", + "👨", + "👩‍🦲" + ] + }, + { + "keyword": "punch", + "emoticons": [ + "👊", + "🤜", + "🤛", + "✊" + ] + }, + { + "keyword": "surprise", + "emoticons": [ + "🎈", + "😮", + "😲", + "😯" + ] + }, + { + "keyword": "nose", + "emoticons": [ + "👃", + "🐽", + "😤", + "🥸" + ] + }, + { + "keyword": "melancholy", + "emoticons": [ + "🙁", + "☹", + "😔", + "🥺", + "😟", + "😕", + "😞" + ] + }, + { + "keyword": "car", + "emoticons": [ + "🚚", + "🚓", + "🚖", + "🚍", + "🚒", + "🚙", + "🚘", + "🚔", + "🚕", + "🚜", + "🚛", + "🏎", + "🚐", + "🚗", + "🚑" + ] + }, + { + "keyword": "qibla", + "emoticons": [ + "🕋" + ] + }, + { + "keyword": "button", + "emoticons": [ + "◀", + "🔂", + "▶", + "🔁", + "⏪", + "⏩", + "⏹", + "🔀", + "🔄", + "🔼", + "⏫", + "🔽", + "⏏", + "⏬" + ] + }, + { + "keyword": "coffee", + "emoticons": [ + "☕" + ] + }, + { + "keyword": "wtf", + "emoticons": [ + "😡", + "😱", + "🥴", + "⁉", + "😐", + "😶", + "😠", + "😵", + "😲", + "🤨", + "😑" + ] + }, + { + "keyword": "dodo", + "emoticons": [ + "🦤" + ] + }, + { + "keyword": "heart exclamation", + "emoticons": [ + "💘", + "👩‍❤‍👩", + "💔", + "💚", + "💙", + "😻", + "💓", + "💟", + "💝", + "💖", + "❣", + "💜", + "♥", + "💛", + "💞", + "💌", + "👨‍❤‍👨", + "💑", + "🖤", + "💕", + "💗", + "❤", + "🧡" + ] + }, + { + "keyword": "drug", + "emoticons": [ + "☕", + "🥃", + "🍄", + "🥂", + "🍺", + "💉", + "🚬", + "🍸", + "💊", + "🍷", + "🍻", + "🧪", + "🔞" + ] + }, + { + "keyword": "girl", + "emoticons": [ + "👧", + "👩‍🦱" + ] + }, + { + "keyword": "rides", + "emoticons": [ + "🎢" + ] + }, + { + "keyword": "glove", + "emoticons": [ + "🥊" + ] + }, + { + "keyword": "money mouth", + "emoticons": [ + "🤑" + ] + }, + { + "keyword": "scissors", + "emoticons": [ + "💇‍♂", + "✂", + "💇", + "💇‍♀" + ] + }, + { + "keyword": "keycap digit nine", + "emoticons": [ + "9⃣" + ] + }, + { + "keyword": "family", + "emoticons": [ + "👩‍👩‍👦‍👦", + "👨‍👩‍👧‍👧", + "👨‍👩‍👦‍👦", + "👨‍👦", + "👩‍👩‍👧", + "👩‍👧", + "👨‍👧", + "👩‍👩‍👧‍👧", + "👨", + "👨‍👨‍👦", + "👨‍👧‍👦", + "👨‍👩‍👧‍👦", + "👩‍👧‍👧", + "👨‍👨‍👦‍👦", + "👨‍👩‍👦", + "👨‍👩‍👧", + "👩‍👩‍👧‍👦", + "👨‍👨‍👧‍👧", + "👩‍👦‍👦", + "👨‍👨‍👧", + "👩‍👩‍👦", + "👨‍👧‍👧", + "👩‍👦", + "👪", + "👨‍👦‍👦", + "👩‍👧‍👦", + "👨‍👨‍👧‍👦" + ] + }, + { + "keyword": "building", + "emoticons": [ + "🏡", + "🏢", + "🏠", + "🕍", + "💒", + "🏰", + "🏦", + "🏬", + "🏗", + "🏭", + "🏥", + "🏨", + "🏛", + "🗼", + "🕌", + "🏩", + "🏪", + "🏙", + "🏚", + "🏯", + "🏣", + "⛪", + "🕋", + "🏫", + "🏤", + "🏘" + ] + }, + { + "keyword": "dissatisfied", + "emoticons": [ + "😕", + "😏", + "🙁", + "😞", + "😒", + "😓" + ] + }, + { + "keyword": "carrot", + "emoticons": [ + "🥕" + ] + }, + { + "keyword": "om", + "emoticons": [ + "🇴🇲", + "🕉" + ] + }, + { + "keyword": "acceptable", + "emoticons": [ + "👍", + "🉑", + "☑", + "✅", + "✔", + "👌" + ] + }, + { + "keyword": "pleading", + "emoticons": [ + "🥺" + ] + }, + { + "keyword": "plants", + "emoticons": [ + "☘", + "🌱", + "🍃", + "🍀", + "🌴", + "🍂", + "🌿", + "🌵", + "🍁", + "🌾", + "🌳", + "🌲" + ] + }, + { + "keyword": "rf", + "emoticons": [ + "🇷🇺" + ] + }, + { + "keyword": "flag_gf", + "emoticons": [ + "🇬🇫" + ] + }, + { + "keyword": "livestock", + "emoticons": [ + "🐷", + "🐮" + ] + }, + { + "keyword": "white large square", + "emoticons": [ + "⬜" + ] + }, + { + "keyword": "victory hand", + "emoticons": [ + "✌" + ] + }, + { + "keyword": "tsunami", + "emoticons": [ + "🌊" + ] + }, + { + "keyword": "event", + "emoticons": [ + "🎇", + "🎗", + "🧨", + "🎆", + "🧧", + "✨", + "🎏", + "🎐", + "🎉", + "🎫", + "🎄", + "🎎", + "🎑", + "🎟", + "🎁", + "🎀", + "🎊", + "🎋", + "🎍" + ] + }, + { + "keyword": "work", + "emoticons": [ + "👨‍💻", + "👷‍♀", + "👷‍♂", + "👩‍💻", + "🗂", + "🗄", + "🗃", + "💪", + "🧑‍💻", + "👷", + "📎" + ] + }, + { + "keyword": "izakaya lantern", + "emoticons": [ + "🏮" + ] + }, + { + "keyword": "hands", + "emoticons": [ + "🙏", + "🤝", + "🤲", + "💆‍♂", + "🙌", + "👏", + "🤗", + "💆‍♀", + "👐" + ] + }, + { + "keyword": "scrutiny", + "emoticons": [ + "🧐" + ] + }, + { + "keyword": "search", + "emoticons": [ + "🔎", + "🔍" + ] + }, + { + "keyword": "\u003c3", + "emoticons": [ + "🥰", + "💟", + "💗", + "😍", + "💝", + "🖤", + "😘", + "♥", + "💕", + "❤", + "❣", + "🧡", + "💜", + "💘", + "💞", + "💓", + "💚", + "💛", + "💙", + "💖" + ] + }, + { + "keyword": "marine", + "emoticons": [ + "🦐", + "🦑", + "🦞", + "🦀" + ] + }, + { + "keyword": "charge", + "emoticons": [ + "🦏", + "🔌", + "🔋" + ] + }, + { + "keyword": "hurts", + "emoticons": [ + "🤕", + "😖", + "💔" + ] + }, + { + "keyword": "symbols", + "emoticons": [ + "☸", + "✝", + "🛐", + "☦", + "♿", + "↙", + "🔞", + "⤴", + "☯", + "🔯", + "🚾", + "🔜", + "🔚", + "🔃", + "↗", + "↔", + "🚸", + "☢", + "↪", + "↕", + "⚛", + "☮", + "⬅", + "🕉", + "🔄", + "🕎", + "🛃", + "📵", + "🚺", + "🚳", + "☪", + "⚠", + "🛄", + "🚼", + "⬆", + "🚻", + "🛂", + "🚷", + "🛅", + "↘", + "🚹", + "🔙", + "🚮", + "➡", + "🚭", + "↩", + "🚯", + "↖", + "🔛", + "🚱", + "☣", + "🚰", + "✡", + "🚫", + "🏧", + "⬇", + "⛔", + "🔝", + "🔣", + "⤵" + ] + }, + { + "keyword": "fingers", + "emoticons": [ + "👉", + "👍", + "👆", + "👈", + "☝", + "🖕", + "👌", + "🤘", + "👎", + "💅", + "🤞", + "🤙", + "✌", + "🤟", + "👇" + ] + }, + { + "keyword": "lovely", + "emoticons": [ + "😇", + "🥰", + "😊", + "😍", + "☺" + ] + }, + { + "keyword": "zambia", + "emoticons": [ + "🇿🇲" + ] + }, + { + "keyword": "flag_io", + "emoticons": [ + "🇮🇴" + ] + }, + { + "keyword": "soap", + "emoticons": [ + "🧼" + ] + }, + { + "keyword": "pear", + "emoticons": [ + "🍐" + ] + }, + { + "keyword": "trumpet", + "emoticons": [ + "🎺" + ] + }, + { + "keyword": "rain", + "emoticons": [ + "💧", + "🌦", + "🌧", + "☔", + "⛈", + "💦" + ] + }, + { + "keyword": "sorrow", + "emoticons": [ + "😞", + "😟", + "😫", + "😢", + "😓", + "😩", + "😰", + "😔", + "😖", + "🙁", + "😪", + "😕", + "😣", + "😥" + ] + }, + { + "keyword": "pickup ", + "emoticons": [ + "🛻" + ] + }, + { + "keyword": "dishware", + "emoticons": [ + "🥢", + "🍴", + "🔪", + "🏺", + "🥄", + "🍽" + ] + }, + { + "keyword": "tired", + "emoticons": [ + "😮‍💨", + "😣", + "😩", + "😖", + "😑", + "😫" + ] + }, + { + "keyword": "heart", + "emoticons": [ + "🧡", + "🫶", + "👨‍❤‍💋‍👨", + "👩‍❤‍💋‍👨", + "❣", + "👨‍❤‍👨", + "💓", + "🖤", + "😻", + "♥", + "❤", + "💏", + "👩‍❤‍👩", + "💞", + "🤎", + "❤‍🩹", + "🫰", + "😘", + "🥰", + "💕", + "😍", + "💑", + "💝", + "💘", + "💖", + "💗", + "🤍", + "💚", + "💙", + "👩‍❤‍💋‍👩", + "💜", + "🫀", + "💔", + "👩‍❤‍👨", + "💛", + "💌", + "💟" + ] + }, + { + "keyword": "symbol", + "emoticons": [ + "🛅", + "🚷", + "☣", + "🛂", + "⛔", + "🚳", + "↘", + "✡", + "⤵", + "🚱", + "⬇", + "🔙", + "⚠", + "🔚", + "↪", + "⚛", + "🔯", + "🚮", + "🔃", + "☦", + "↗", + "🚯", + "☯", + "↖", + "♿", + "🛄", + "🏧", + "⬆", + "☸", + "🛐", + "🕉", + "↙", + "↩", + "☢", + "🚭", + "🚰", + "🚸", + "🚼", + "⬅", + "🔝", + "🔞", + "🚺", + "🚻", + "🚹", + "↔", + "⤴", + "➡", + "🕎", + "✝", + "↕", + "☪", + "☮", + "🛃", + "🚾", + "🔛", + "🔜", + "🔄", + "🚫", + "📵" + ] + }, + { + "keyword": "zm", + "emoticons": [ + "🇿🇲" + ] + }, + { + "keyword": "confused", + "emoticons": [ + "😕" + ] + }, + { + "keyword": "stink", + "emoticons": [ + "🦨" + ] + }, + { + "keyword": "left luggage", + "emoticons": [ + "🛅" + ] + }, + { + "keyword": "snow capped mountain", + "emoticons": [ + "🏔" + ] + }, + { + "keyword": "hand splayed", + "emoticons": [ + "🖐" + ] + }, + { + "keyword": "montenegro", + "emoticons": [ + "🇲🇪" + ] + }, + { + "keyword": "yawn", + "emoticons": [ + "🤭", + "💤", + "🥱" + ] + }, + { + "keyword": "flaming heart", + "emoticons": [ + "❤‍🔥" + ] + }, + { + "keyword": "egg", + "emoticons": [ + "🍳", + "🥚", + "🐣" + ] + }, + { + "keyword": "umbrella", + "emoticons": [ + "⛱", + "☔", + "☂", + "🏖", + "🌂" + ] + }, + { + "keyword": "palau", + "emoticons": [ + "🇵🇼" + ] + }, + { + "keyword": "cat", + "emoticons": [ + "😹", + "😺", + "😿", + "😾", + "🙀", + "😽", + "🐱", + "🐈", + "😻", + "😸", + "🐈‍⬛", + "😼" + ] + }, + { + "keyword": "storm", + "emoticons": [ + "⛈", + "🌩", + "🌪" + ] + }, + { + "keyword": "footage", + "emoticons": [ + "🎞" + ] + }, + { + "keyword": "hotel", + "emoticons": [ + "🛎", + "🏨", + "🧳", + "🏩" + ] + }, + { + "keyword": "ci", + "emoticons": [ + "🇨🇮" + ] + }, + { + "keyword": "loot box", + "emoticons": [ + "🎰" + ] + }, + { + "keyword": "face in cloud", + "emoticons": [ + "😶‍🌫" + ] + }, + { + "keyword": "deaf woman", + "emoticons": [ + "🧏‍♀" + ] + }, + { + "keyword": "holding hands", + "emoticons": [ + "👭", + "👫", + "👬" + ] + }, + { + "keyword": "leopard", + "emoticons": [ + "🐆" + ] + }, + { + "keyword": "sadness", + "emoticons": [ + "😓", + "😮", + "😭", + "😞", + "🤕", + "😫", + "😥", + "😪", + "😣", + "🙁", + "😬", + "😔", + "☹", + "😨", + "😖", + "😩", + "😢", + "😰", + "😧", + "😕", + "😟", + "😿" + ] + }, + { + "keyword": "boot", + "emoticons": [ + "👢" + ] + }, + { + "keyword": "flag_tj", + "emoticons": [ + "🇹🇯" + ] + }, + { + "keyword": "christmas", + "emoticons": [ + "🎅", + "🌲", + "⛄", + "🤶", + "🎄", + "☃", + "🎁", + "❄" + ] + }, + { + "keyword": "reward", + "emoticons": [ + "🏆" + ] + }, + { + "keyword": "auto", + "emoticons": [ + "🚚", + "🚓", + "🚗", + "🚑", + "🚕", + "🚙", + "🚒", + "🏎", + "🚘", + "🚖", + "🚛", + "🚐", + "🚔", + "🚍" + ] + }, + { + "keyword": "minus", + "emoticons": [ + "➖" + ] + }, + { + "keyword": ":*", + "emoticons": [ + "😘", + "💋", + "😍", + "😗", + "😙", + "🥰", + "😚", + "😽" + ] + }, + { + "keyword": "thumbs down", + "emoticons": [ + "👎" + ] + }, + { + "keyword": "dad", + "emoticons": [ + "👨‍👩‍👦‍👦", + "👨‍👧‍👧", + "👩‍👩‍👧", + "👨‍👨‍👦", + "👨‍👧", + "👪", + "👩‍👦", + "👨‍👦‍👦", + "👨‍👨‍👧‍👧", + "👨‍👩‍👧‍👧", + "👨‍👨‍👧", + "👨‍👩‍👧‍👦", + "👩‍👩‍👦", + "👩‍👩‍👧‍👧", + "👨‍👨‍👦‍👦", + "👨‍👨‍👧‍👦", + "👨‍👩‍👧", + "👩‍👩‍👦‍👦", + "👨‍👧‍👦", + "👩‍👩‍👧‍👦" + ] + }, + { + "keyword": "throw", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "cutlery", + "emoticons": [ + "🍽", + "🥄", + "🔪", + "🍴", + "🥢" + ] + }, + { + "keyword": "transport sign", + "emoticons": [ + "🚾", + "🚹", + "🛃", + "🚺", + "🚧", + "♿", + "🛅", + "🛄", + "🚥", + "🚰", + "🚮", + "🚦", + "🛑", + "🚳", + "🛂", + "⛔", + "🚼", + "🏧", + "🚻" + ] + }, + { + "keyword": "gay", + "emoticons": [ + "👨‍👨‍👧‍👧", + "👨‍❤‍👨", + "👨‍👨‍👧‍👦", + "👨‍👨‍👦‍👦", + "👨‍👨‍👦", + "👨‍👨‍👧", + "🏳‍🌈", + "👬", + "👨‍❤‍💋‍👨" + ] + }, + { + "keyword": "flag_no", + "emoticons": [ + "🇳🇴" + ] + }, + { + "keyword": "ears", + "emoticons": [ + "👯‍♂", + "👯‍♀" + ] + }, + { + "keyword": "boating", + "emoticons": [ + "🚣‍♂", + "🚣‍♀" + ] + }, + { + "keyword": "injury", + "emoticons": [ + "🩸" + ] + }, + { + "keyword": "aq", + "emoticons": [ + "🇦🇶" + ] + }, + { + "keyword": "encryption", + "emoticons": [ + "🔏", + "🔐" + ] + }, + { + "keyword": "flag_pw", + "emoticons": [ + "🇵🇼" + ] + }, + { + "keyword": "fly", + "emoticons": [ + "🪰" + ] + }, + { + "keyword": "summer", + "emoticons": [ + "🌈", + "🌞", + "✈", + "💐", + "🌄", + "🛵", + "🌴", + "⛱", + "🏝", + "🏖", + "🌅", + "🏜", + "☀", + "🌤" + ] + }, + { + "keyword": "ticket", + "emoticons": [ + "🎫", + "🎟" + ] + }, + { + "keyword": "shh", + "emoticons": [ + "🙊", + "🤐", + "🤭", + "😶", + "🤫" + ] + }, + { + "keyword": "thumbs up", + "emoticons": [ + "👍" + ] + }, + { + "keyword": "thermometer", + "emoticons": [ + "🤒", + "🌡" + ] + }, + { + "keyword": "shame", + "emoticons": [ + "🤦‍♀", + "🤦‍♂" + ] + }, + { + "keyword": "surf", + "emoticons": [ + "🏄", + "🏄‍♂", + "🏄‍♀" + ] + }, + { + "keyword": "lunch", + "emoticons": [ + "🥗", + "🍝", + "🍽", + "🍲", + "🍜", + "🍛", + "🍔" + ] + }, + { + "keyword": "sweet", + "emoticons": [ + "🧋", + "🍫", + "🍯", + "🍧", + "🍨", + "🍰", + "🥧", + "🍮", + "🧁", + "🎂", + "🍦", + "🍩", + "🍬", + "🍪", + "😋", + "🍭" + ] + }, + { + "keyword": "ball", + "emoticons": [ + "🏉", + "🏀", + "🎾", + "🤽‍♀", + "🏈", + "🤾‍♂", + "⛹‍♀", + "⛹‍♂", + "⛹", + "⚽", + "🤾‍♀", + "🏐", + "🎱", + "🤽‍♂", + "🥎", + "⚾", + "🎳" + ] + }, + { + "keyword": "enjoyment", + "emoticons": [ + "🍹", + "✨", + "🌟", + "😎", + "🎇", + "🎭", + "🎆", + "🎢", + "🎡", + "😜", + "😂", + "💫", + "🥂" + ] + }, + { + "keyword": "neko", + "emoticons": [ + "😺", + "🐱" + ] + }, + { + "keyword": "pc", + "emoticons": [ + "🖥", + "💻" + ] + }, + { + "keyword": "no pedestrians", + "emoticons": [ + "🚷" + ] + }, + { + "keyword": "asian", + "emoticons": [ + "🍝", + "🍠", + "🍘", + "🥠", + "🍙", + "🍛", + "🥮", + "🍡", + "🍥", + "🍢", + "🍜", + "🥟", + "🥡", + "🍚", + "🍤", + "🍱", + "🍣" + ] + }, + { + "keyword": "kayak", + "emoticons": [ + "🛶" + ] + }, + { + "keyword": "hockey", + "emoticons": [ + "🏒" + ] + }, + { + "keyword": "omg", + "emoticons": [ + "😨", + "😲", + "😱", + "🙀", + "😰", + "😯", + "😮", + "🫢" + ] + }, + { + "keyword": "dj", + "emoticons": [ + "🎚", + "🎤", + "🎛", + "💽", + "🎧", + "🇩🇯", + "🔊", + "💿", + "📀", + "🎶", + "💥", + "🪩" + ] + }, + { + "keyword": "ky", + "emoticons": [ + "🇰🇾" + ] + }, + { + "keyword": "eyeglass", + "emoticons": [ + "🧐" + ] + }, + { + "keyword": "suspicious", + "emoticons": [ + "🤨", + "🤔", + "😑" + ] + }, + { + "keyword": "tray", + "emoticons": [ + "📥", + "📤" + ] + }, + { + "keyword": "flag_eh", + "emoticons": [ + "🇪🇭" + ] + }, + { + "keyword": "reverse button", + "emoticons": [ + "◀" + ] + }, + { + "keyword": "blush", + "emoticons": [ + "😳", + "😚", + "😊", + "☺", + "🥰" + ] + }, + { + "keyword": "sir", + "emoticons": [ + "🧐" + ] + }, + { + "keyword": "sick", + "emoticons": [ + "😵", + "🤮", + "🤢", + "🤧", + "😷", + "🤒", + "🤕" + ] + }, + { + "keyword": "human", + "emoticons": [ + "👨", + "👱‍♂", + "🧍‍♂", + "👨‍🦰", + "🧍‍♀", + "👨‍🦲", + "👨‍🦱", + "🧔", + "👨‍🦳", + "🧍", + "🧑" + ] + }, + { + "keyword": "flag black", + "emoticons": [ + "🏴" + ] + }, + { + "keyword": "flag_lb", + "emoticons": [ + "🇱🇧" + ] + }, + { + "keyword": "golfer", + "emoticons": [ + "🏌‍♂", + "🏌‍♀", + "🏌" + ] + }, + { + "keyword": "birthday", + "emoticons": [ + "🎁", + "🎈", + "🎉", + "🙌", + "🥳", + "🎂" + ] + }, + { + "keyword": "moon", + "emoticons": [ + "🌘", + "🌗", + "🌜", + "🌛", + "🌖", + "🌑", + "🌓", + "🌔", + "🌒", + "🌙", + "🌕", + "🌝", + "🌚" + ] + }, + { + "keyword": "cf", + "emoticons": [ + "🇨🇫" + ] + }, + { + "keyword": "busy", + "emoticons": [ + "🗄", + "👩‍💻", + "🧑‍💻", + "📎", + "💪", + "🗂", + "👨‍💻", + "🗃", + "👷", + "👷‍♂", + "👷‍♀" + ] + }, + { + "keyword": "space", + "emoticons": [ + "👽", + "🪐", + "👩‍🚀", + "🌌", + "🌠", + "🛸", + "🛰", + "👨‍🚀", + "🚀" + ] + }, + { + "keyword": "vegan", + "emoticons": [ + "🍇", + "🍍", + "🥝", + "🍌", + "🍏", + "🌰", + "🥑", + "🍎", + "🍒", + "🍊", + "🥥", + "🍐", + "🥔", + "🥕", + "🍋", + "🍉", + "🍈", + "🥭", + "🥜", + "🍑", + "🌶", + "🍅", + "🥬", + "🍆", + "🥒", + "🍓", + "🌽", + "🥦", + "🍄" + ] + }, + { + "keyword": "stick", + "emoticons": [ + "🏒", + "🏑", + "🦯", + "🥍", + "🏏", + "🩼" + ] + }, + { + "keyword": "enjoy", + "emoticons": [ + "🎡", + "😜", + "🎆", + "😎", + "✨", + "🌟", + "🎢", + "💫", + "🍹", + "🥂", + "🎭", + "🎇" + ] + }, + { + "keyword": "fire", + "emoticons": [ + "👨‍🚒", + "👩‍🚒", + "🔥", + "❤‍🔥" + ] + }, + { + "keyword": "nest", + "emoticons": [ + "🪹", + "🪺" + ] + }, + { + "keyword": ":-|", + "emoticons": [ + "😐", + "😑" + ] + }, + { + "keyword": "wheelchair", + "emoticons": [ + "👩‍🦼", + "👨‍🦽", + "👩‍🦽", + "🦽", + "♿", + "🧑‍🦼", + "👨‍🦼", + "🧑‍🦽", + "🦼" + ] + }, + { + "keyword": "jet", + "emoticons": [ + "🛫", + "✈", + "🛩", + "🛬" + ] + }, + { + "keyword": "bug", + "emoticons": [ + "🪲", + "🐞", + "🐛" + ] + }, + { + "keyword": "sierra leone", + "emoticons": [ + "🇸🇱" + ] + }, + { + "keyword": "low energy", + "emoticons": [ + "🪫" + ] + }, + { + "keyword": "entertainment", + "emoticons": [ + "🎷", + "🔉", + "🎶", + "🎻", + "🎸", + "🎺", + "👂", + "🎼", + "🎹", + "🎧", + "🎵" + ] + }, + { + "keyword": "flowers", + "emoticons": [ + "🏵", + "🌼", + "🌺", + "🌸", + "🌻", + "🌹", + "💐", + "🥀", + "💮", + "🌷" + ] + }, + { + "keyword": "frowning", + "emoticons": [ + "🙍‍♂", + "🙁", + "😦", + "🙍‍♀", + "☹" + ] + }, + { + "keyword": "end arrow", + "emoticons": [ + "🔚" + ] + }, + { + "keyword": "flag_cu", + "emoticons": [ + "🇨🇺" + ] + }, + { + "keyword": "bunny", + "emoticons": [ + "🐰", + "👯‍♂", + "👯‍♀", + "🐇" + ] + }, + { + "keyword": "hatchet", + "emoticons": [ + "🪓" + ] + }, + { + "keyword": "winter", + "emoticons": [ + "🎄", + "🛷", + "🎅", + "❄", + "🤶", + "☃", + "🧑‍🎄", + "⛸", + "⛄", + "🌨" + ] + }, + { + "keyword": "clover", + "emoticons": [ + "🍀" + ] + }, + { + "keyword": "right arrow curving left", + "emoticons": [ + "↩" + ] + }, + { + "keyword": "dollar", + "emoticons": [ + "💰", + "💵", + "💸", + "💲" + ] + }, + { + "keyword": "clock1030", + "emoticons": [ + "🕥" + ] + }, + { + "keyword": "cute", + "emoticons": [ + "🥺", + "🥰", + "☺", + "😊", + "😇" + ] + }, + { + "keyword": "rice", + "emoticons": [ + "🌾", + "🍣", + "🍚", + "🍙", + "🍛", + "🍘" + ] + }, + { + "keyword": "headstone", + "emoticons": [ + "🪦" + ] + }, + { + "keyword": "angola", + "emoticons": [ + "🇦🇴" + ] + }, + { + "keyword": "flag_ba", + "emoticons": [ + "🇧🇦" + ] + }, + { + "keyword": "flavourful", + "emoticons": [ + "🤤", + "😋" + ] + }, + { + "keyword": "sad", + "emoticons": [ + "😨", + "😕", + "😓", + "☹", + "😣", + "😞", + "😩", + "😧", + "😰", + "😖", + "😮", + "😫", + "🙍", + "🙁", + "🖤", + "😬", + "😪", + "🤕", + "😢", + "😟", + "😥", + "😔" + ] + }, + { + "keyword": "grenada", + "emoticons": [ + "🇬🇩" + ] + }, + { + "keyword": "debian", + "emoticons": [ + "🍥" + ] + }, + { + "keyword": "majong", + "emoticons": [ + "🀄" + ] + }, + { + "keyword": "person surfing", + "emoticons": [ + "🏄‍♂", + "🏄‍♀" + ] + }, + { + "keyword": "prosper", + "emoticons": [ + "🖖" + ] + }, + { + "keyword": "woman vampire", + "emoticons": [ + "🧛‍♀" + ] + }, + { + "keyword": "pistol", + "emoticons": [ + "🔫" + ] + }, + { + "keyword": "scooter", + "emoticons": [ + "🛵" + ] + }, + { + "keyword": "fed up", + "emoticons": [ + "🙎" + ] + }, + { + "keyword": "speak no evil", + "emoticons": [ + "🙊" + ] + }, + { + "keyword": "rofl", + "emoticons": [ + "🤣", + "😂" + ] + }, + { + "keyword": "niger", + "emoticons": [ + "🇳🇪" + ] + }, + { + "keyword": "peace", + "emoticons": [ + "🧘‍♂", + "🏳", + "🙏", + "🕊", + "✌", + "🧘‍♀", + "🤝", + "☮" + ] + }, + { + "keyword": "zodiac", + "emoticons": [ + "⛎", + "♊", + "♌", + "♏", + "♐", + "♒", + "♍", + "♈", + "♉", + "♑", + "♓", + "♎", + "♋" + ] + }, + { + "keyword": "grimacing", + "emoticons": [ + "😬" + ] + }, + { + "keyword": "gp", + "emoticons": [ + "🇬🇵" + ] + }, + { + "keyword": "bunny dancers", + "emoticons": [ + "👯‍♂", + "👯‍♀" + ] + }, + { + "keyword": "lebanon", + "emoticons": [ + "🇱🇧" + ] + }, + { + "keyword": "rich", + "emoticons": [ + "💸", + "💴", + "💶", + "💰", + "💵", + "🤑", + "💷", + "💳", + "🎩", + "💎" + ] + }, + { + "keyword": "flight", + "emoticons": [ + "✈", + "🛩", + "🛫", + "🛬" + ] + }, + { + "keyword": "annoyed", + "emoticons": [ + "😫", + "😪", + "🙄", + "😒" + ] + }, + { + "keyword": "malawi", + "emoticons": [ + "🇲🇼" + ] + }, + { + "keyword": "capital abcd", + "emoticons": [ + "🔠" + ] + }, + { + "keyword": "arrows", + "emoticons": [ + "🔃", + "➡", + "⬇", + "🔚", + "↕", + "↔", + "🔙", + "↗", + "↪", + "🔝", + "↖", + "⤵", + "⤴", + "↩", + "🔄", + "↘", + "⬆", + "🔜", + "↙", + "🔛", + "⬅" + ] + }, + { + "keyword": "bandage", + "emoticons": [ + "🤕", + "🩹" + ] + }, + { + "keyword": "harmony", + "emoticons": [ + "🎺", + "🎹", + "🎶", + "🎧", + "🎷", + "🎵", + "🎼", + "🎻", + "🎸" + ] + }, + { + "keyword": "affirmative", + "emoticons": [ + "🤝", + "👍", + "🙌", + "✊" + ] + }, + { + "keyword": "esteghlal f.c.", + "emoticons": [ + "📘", + "🔷", + "💙", + "🔵", + "🏆", + "🔹", + "💠", + "👑", + "⚽" + ] + }, + { + "keyword": "fishing", + "emoticons": [ + "🎣" + ] + }, + { + "keyword": "satellite", + "emoticons": [ + "🛰", + "📡" + ] + }, + { + "keyword": "fortune", + "emoticons": [ + "🥠", + "🔮" + ] + }, + { + "keyword": "diego garcia", + "emoticons": [ + "🇩🇬" + ] + }, + { + "keyword": "cough", + "emoticons": [ + "😮‍💨", + "💨", + "🤧", + "😷" + ] + }, + { + "keyword": "flags", + "emoticons": [ + "🎌", + "🎏" + ] + }, + { + "keyword": "congrats", + "emoticons": [ + "👏", + "🥳", + "🙌" + ] + }, + { + "keyword": "star", + "emoticons": [ + "💫", + "✡", + "☄", + "🌠", + "⭐", + "✴", + "🌟" + ] + }, + { + "keyword": "bear", + "emoticons": [ + "🐻", + "🐼", + "🐨", + "🧸" + ] + }, + { + "keyword": "freezing", + "emoticons": [ + "❄", + "🥶" + ] + }, + { + "keyword": "flag_gy", + "emoticons": [ + "🇬🇾" + ] + }, + { + "keyword": "va", + "emoticons": [ + "🇻🇦" + ] + }, + { + "keyword": "story", + "emoticons": [ + "📙", + "📖", + "📕", + "📝", + "📗", + "📘" + ] + }, + { + "keyword": "man firefighter", + "emoticons": [ + "👨‍🚒" + ] + }, + { + "keyword": "creepy", + "emoticons": [ + "👽", + "👾" + ] + }, + { + "keyword": "woman police officer", + "emoticons": [ + "👮‍♀" + ] + }, + { + "keyword": "pls", + "emoticons": [ + "🙏", + "🙄" + ] + }, + { + "keyword": "$", + "emoticons": [ + "💰", + "🤑", + "🏧", + "🏦", + "💲", + "🫰", + "💸", + "💵" + ] + }, + { + "keyword": "berry", + "emoticons": [ + "🥝", + "🍒", + "🫐", + "🍇", + "🍓" + ] + }, + { + "keyword": "ship", + "emoticons": [ + "🛳", + "⛵", + "🛥", + "🚢", + "🚤", + "⛴" + ] + }, + { + "keyword": "united kingdom", + "emoticons": [ + "🇬🇧" + ] + }, + { + "keyword": "saint barthelemy", + "emoticons": [ + "🇧🇱" + ] + }, + { + "keyword": "peanuts", + "emoticons": [ + "🥜" + ] + }, + { + "keyword": "keycap: 10", + "emoticons": [ + "🔟" + ] + }, + { + "keyword": "five", + "emoticons": [ + "5⃣", + "👋", + "🤚", + "🖐", + "✋" + ] + }, + { + "keyword": "ice cream", + "emoticons": [ + "🍨", + "🍦" + ] + }, + { + "keyword": "woman dancing", + "emoticons": [ + "💃" + ] + }, + { + "keyword": "shoes", + "emoticons": [ + "👞", + "👠", + "🥾", + "👢", + "👡", + "🥿", + "👟" + ] + }, + { + "keyword": "barber shop", + "emoticons": [ + "💈" + ] + }, + { + "keyword": "flag_bg", + "emoticons": [ + "🇧🇬" + ] + }, + { + "keyword": "haha", + "emoticons": [ + "😺", + "😀", + "😄", + "🤣", + "😂", + "😸", + "😹", + "😅" + ] + }, + { + "keyword": "stone face", + "emoticons": [ + "🗿" + ] + }, + { + "keyword": "knife", + "emoticons": [ + "🔪", + "🍴", + "🍽" + ] + }, + { + "keyword": "yummy", + "emoticons": [ + "😋" + ] + }, + { + "keyword": "rescue worker’s helmet", + "emoticons": [ + "⛑" + ] + }, + { + "keyword": "hbd", + "emoticons": [ + "💝", + "🎁" + ] + }, + { + "keyword": "pregant", + "emoticons": [ + "🤰" + ] + }, + { + "keyword": "study", + "emoticons": [ + "🧑‍🎓", + "👩‍🏫", + "🧑‍🏫", + "👨‍🏫", + "👨‍🎓", + "👩‍🎓" + ] + }, + { + "keyword": "i love u", + "emoticons": [ + "💓", + "💜", + "💖", + "🖤", + "😘", + "💕", + "💘", + "❤", + "💚", + "💛", + "💗", + "💝", + "🧡", + "❣", + "💙", + "💞", + "💟", + "♥" + ] + }, + { + "keyword": "small orange diamond", + "emoticons": [ + "🔸" + ] + }, + { + "keyword": "flag_ir", + "emoticons": [ + "🇮🇷" + ] + }, + { + "keyword": "flag_bo", + "emoticons": [ + "🇧🇴" + ] + }, + { + "keyword": "sugar", + "emoticons": [ + "🍯", + "🍨", + "😋", + "🧁", + "🍫", + "🍪", + "🍭", + "🍰", + "🍦", + "🧋", + "🍩" + ] + }, + { + "keyword": "happy", + "emoticons": [ + "😋", + "🙂", + "☺", + "🤩", + "😹", + "😀", + "😈", + "😺", + "😁", + "😊", + "😄", + "😸", + "😂", + "😃" + ] + }, + { + "keyword": "huff", + "emoticons": [ + "😤" + ] + }, + { + "keyword": "fruits", + "emoticons": [ + "🍈", + "🥭", + "🍋", + "🥥", + "🍉", + "🍓", + "🍏", + "🍒", + "🍐", + "🍑", + "🥝", + "🍊", + "🍍", + "🍅", + "🍌", + "🍇", + "🍎" + ] + }, + { + "keyword": "bike", + "emoticons": [ + "🚴", + "🚲", + "🚴‍♂", + "🚵", + "🏍", + "🚴‍♀", + "🚵‍♀", + "🚵‍♂", + "🛵" + ] + }, + { + "keyword": "masquerade", + "emoticons": [ + "🎭" + ] + }, + { + "keyword": "two", + "emoticons": [ + "2⃣" + ] + }, + { + "keyword": "woman gesturing no", + "emoticons": [ + "🙅‍♀" + ] + }, + { + "keyword": "thunder", + "emoticons": [ + "⛈", + "⚡" + ] + }, + { + "keyword": "pointing left", + "emoticons": [ + "👈" + ] + }, + { + "keyword": "kissing", + "emoticons": [ + "😚", + "😽", + "💋", + "😘", + "😗", + "👩‍❤‍💋‍👨", + "😙" + ] + }, + { + "keyword": "events", + "emoticons": [ + "🧨", + "🧧", + "🎁", + "🎀", + "✨", + "🎊", + "🎟", + "🎐", + "🎇", + "🎗", + "🎄", + "🎏", + "🎫", + "🎋", + "🎑", + "🎍", + "🎎", + "🎉" + ] + }, + { + "keyword": "halo", + "emoticons": [ + "😇", + "💫", + "👼" + ] + }, + { + "keyword": "keep quiet", + "emoticons": [ + "🤫" + ] + }, + { + "keyword": "smirk", + "emoticons": [ + "😏", + "😼" + ] + }, + { + "keyword": "praying", + "emoticons": [ + "🧎", + "🧎‍♂", + "🧎‍♀" + ] + }, + { + "keyword": "down", + "emoticons": [ + "🔽" + ] + }, + { + "keyword": "really", + "emoticons": [ + "🧐", + "🤨", + "🤔" + ] + }, + { + "keyword": "bronze", + "emoticons": [ + "🥉" + ] + }, + { + "keyword": "vg", + "emoticons": [ + "🇻🇬" + ] + }, + { + "keyword": "fox", + "emoticons": [ + "🦊" + ] + }, + { + "keyword": "darts", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "kisses", + "emoticons": [ + "💜", + "💙", + "💗", + "❤", + "💟", + "🧡", + "💞", + "♥", + "💘", + "💚", + "😘", + "🥰", + "💓", + "💖", + "💝", + "💕", + "😍", + "💛", + "❣", + "🖤" + ] + }, + { + "keyword": "clock930", + "emoticons": [ + "🕤" + ] + }, + { + "keyword": "here", + "emoticons": [ + "🖕", + "👆", + "👇", + "👈", + "👉", + "☝", + "🈁" + ] + }, + { + "keyword": "fear", + "emoticons": [ + "😣", + "😮", + "😰", + "😱", + "😨", + "😬", + "😟", + "☠", + "⚠", + "👻", + "💀", + "🙀" + ] + }, + { + "keyword": "time", + "emoticons": [ + "🕔", + "🕧", + "🕐", + "🕠", + "🕡", + "🕦", + "🕓", + "🕛", + "🕗", + "⏳", + "⏲", + "🕥", + "🕖", + "🕣", + "🕒", + "🕑", + "🕘", + "⏱", + "⌚", + "🕟", + "⏰", + "🕜", + "🕢", + "🕙", + "🕤", + "🕝", + "🕕", + "⌛", + "🕰", + "🕞" + ] + }, + { + "keyword": "bar", + "emoticons": [ + "🍻", + "🍫", + "🧼" + ] + }, + { + "keyword": "tipping", + "emoticons": [ + "💁‍♂", + "💁‍♀" + ] + }, + { + "keyword": "octopus", + "emoticons": [ + "🐙" + ] + }, + { + "keyword": "ma", + "emoticons": [ + "🇲🇦" + ] + }, + { + "keyword": "anger", + "emoticons": [ + "👺", + "😡", + "🤨", + "😠", + "😤", + "🤦", + "🤬", + "👿", + "😾", + "💥", + "🔥", + "💢" + ] + }, + { + "keyword": "australia", + "emoticons": [ + "🇦🇺", + "🌏", + "🦘" + ] + }, + { + "keyword": "ouch", + "emoticons": [ + "😮", + "😦", + "😧", + "😨", + "😯", + "😣", + "😳" + ] + }, + { + "keyword": "box", + "emoticons": [ + "🥡", + "🥊", + "📦" + ] + }, + { + "keyword": "emergency", + "emoticons": [ + "👮‍♀", + "👩‍⚕", + "👨‍⚕", + "👮‍♂", + "👩‍🚒", + "👨‍🚒", + "🚨" + ] + }, + { + "keyword": "shine", + "emoticons": [ + "✨", + "⭐" + ] + }, + { + "keyword": "pisces", + "emoticons": [ + "♓" + ] + }, + { + "keyword": "crossed", + "emoticons": [ + "🤞" + ] + }, + { + "keyword": "gf", + "emoticons": [ + "🇬🇫" + ] + }, + { + "keyword": "sorry", + "emoticons": [ + "🥺", + "😥", + "😢", + "🙇‍♀", + "😕", + "😓", + "😐", + "😭", + "😰", + "🙇‍♂" + ] + }, + { + "keyword": "equal", + "emoticons": [ + "🟰", + "⚖" + ] + }, + { + "keyword": "autumn", + "emoticons": [ + "💦", + "🌧", + "🌫", + "🍂", + "🍃", + "🍁" + ] + }, + { + "keyword": "miss you", + "emoticons": [ + "🥺" + ] + }, + { + "keyword": ":s", + "emoticons": [ + "🥴" + ] + }, + { + "keyword": "exclamation mark", + "emoticons": [ + "❗", + "❕" + ] + }, + { + "keyword": "bath", + "emoticons": [ + "🧖", + "🧖‍♂", + "🛁", + "🧖‍♀", + "🧼", + "🛀", + "🧽" + ] + }, + { + "keyword": "yawning", + "emoticons": [ + "🥱" + ] + }, + { + "keyword": "hahaha", + "emoticons": [ + "😄" + ] + }, + { + "keyword": "contest", + "emoticons": [ + "🏆", + "🏅", + "🎽", + "🥉", + "🥇", + "🥈" + ] + }, + { + "keyword": "geometry", + "emoticons": [ + "📏", + "📐" + ] + }, + { + "keyword": "dumpling", + "emoticons": [ + "🥠", + "🥡", + "🥟" + ] + }, + { + "keyword": "laos", + "emoticons": [ + "🇱🇦" + ] + }, + { + "keyword": "mwah", + "emoticons": [ + "😙", + "🤗", + "😗" + ] + }, + { + "keyword": "chain", + "emoticons": [ + "⛓", + "🔗" + ] + }, + { + "keyword": "board", + "emoticons": [ + "🎛", + "🛹" + ] + }, + { + "keyword": "page", + "emoticons": [ + "📑", + "📜", + "📃", + "📰", + "📄" + ] + }, + { + "keyword": "xmas", + "emoticons": [ + "🧑‍🎄", + "☃", + "🎄", + "❄", + "🎅", + "🤶", + "🌲", + "⛄", + "🎁" + ] + }, + { + "keyword": "finger", + "emoticons": [ + "👌", + "👈", + "👇", + "👆", + "👍", + "🤙", + "👎", + "✌", + "👉", + "🖕", + "🤞", + "🤟", + "🤘", + "☝" + ] + }, + { + "keyword": "microphone", + "emoticons": [ + "🎙", + "🎤" + ] + }, + { + "keyword": "custard", + "emoticons": [ + "🍮" + ] + }, + { + "keyword": "syria", + "emoticons": [ + "🇸🇾" + ] + }, + { + "keyword": "flag_gm", + "emoticons": [ + "🇬🇲" + ] + }, + { + "keyword": "mournful", + "emoticons": [ + "😖", + "😩", + "😫", + "😣" + ] + }, + { + "keyword": "safety vest", + "emoticons": [ + "🦺" + ] + }, + { + "keyword": "fiery", + "emoticons": [ + "❤‍🔥" + ] + }, + { + "keyword": "cuba", + "emoticons": [ + "🇨🇺" + ] + }, + { + "keyword": "evergreen tree", + "emoticons": [ + "🌲" + ] + }, + { + "keyword": "japan", + "emoticons": [ + "🍱", + "🥋", + "🎎", + "🇯🇵", + "⛩", + "🍘", + "🍙", + "🍣" + ] + }, + { + "keyword": "woozy", + "emoticons": [ + "🥴" + ] + }, + { + "keyword": "performing arts", + "emoticons": [ + "🎭" + ] + }, + { + "keyword": "argentina", + "emoticons": [ + "🇦🇷" + ] + }, + { + "keyword": "glowing star", + "emoticons": [ + "🌟" + ] + }, + { + "keyword": "math", + "emoticons": [ + "➕", + "➖", + "♾", + "🟰", + "✖", + "➗" + ] + }, + { + "keyword": "butter", + "emoticons": [ + "🧈" + ] + }, + { + "keyword": "haze", + "emoticons": [ + "😶‍🌫", + "🌫" + ] + }, + { + "keyword": "poultry leg", + "emoticons": [ + "🍗" + ] + }, + { + "keyword": "trinidad \u0026 tobago", + "emoticons": [ + "🇹🇹" + ] + }, + { + "keyword": "santa claus", + "emoticons": [ + "🎅", + "🤶" + ] + }, + { + "keyword": "8", + "emoticons": [ + "🎱", + "8⃣" + ] + }, + { + "keyword": "gaming", + "emoticons": [ + "👾", + "🎮", + "💻", + "🕹" + ] + }, + { + "keyword": "roll", + "emoticons": [ + "🙄" + ] + }, + { + "keyword": "builder", + "emoticons": [ + "👷" + ] + }, + { + "keyword": "sheriff", + "emoticons": [ + "🤠", + "⭐" + ] + }, + { + "keyword": "customs", + "emoticons": [ + "🛂" + ] + }, + { + "keyword": "oops", + "emoticons": [ + "🫢", + "😖", + "😓", + "🤭", + "😅", + "😬", + "😣", + "😥", + "😐" + ] + }, + { + "keyword": "french guiana", + "emoticons": [ + "🇬🇫" + ] + }, + { + "keyword": "lgbt", + "emoticons": [ + "🌈", + "🏳‍🌈" + ] + }, + { + "keyword": "ss", + "emoticons": [ + "🇸🇸" + ] + }, + { + "keyword": "bubble", + "emoticons": [ + "🫧", + "🧼", + "💭" + ] + }, + { + "keyword": "hungry", + "emoticons": [ + "🤤", + "😋" + ] + }, + { + "keyword": "videogame", + "emoticons": [ + "🎰", + "🎮", + "🕹" + ] + }, + { + "keyword": "diamond shape with a dot inside", + "emoticons": [ + "💠" + ] + }, + { + "keyword": "counterclockwise arrows button", + "emoticons": [ + "🔄" + ] + }, + { + "keyword": "bed", + "emoticons": [ + "🛌", + "🛏" + ] + }, + { + "keyword": "♡", + "emoticons": [ + "💝", + "❤", + "💚", + "💘", + "💛", + "🧡", + "💗", + "💞", + "💓", + "💜", + "😘", + "💖", + "❣", + "💕", + "♥", + "🖤", + "💙", + "💟" + ] + }, + { + "keyword": "face", + "emoticons": [ + "👨", + "👨‍🦲", + "👱‍♂", + "👨‍🦰", + "🧑", + "🧔", + "👨‍🦱", + "👨‍🦳" + ] + }, + { + "keyword": "envelope", + "emoticons": [ + "📨", + "💌", + "🧧", + "📩" + ] + }, + { + "keyword": "black medium-small square", + "emoticons": [ + "◾" + ] + }, + { + "keyword": "wrench", + "emoticons": [ + "🔧", + "🛠" + ] + }, + { + "keyword": "zombie", + "emoticons": [ + "🧟‍♂", + "🧟‍♀", + "🧟" + ] + }, + { + "keyword": "important", + "emoticons": [ + "❗" + ] + }, + { + "keyword": "instrument", + "emoticons": [ + "🎹" + ] + }, + { + "keyword": "mom", + "emoticons": [ + "👨‍👩‍👧‍👧", + "👨‍👩‍👧‍👦", + "👩‍👦", + "👩‍👩‍👦", + "🤰", + "👩‍👩‍👧", + "👨‍👨‍👧", + "👩‍👦‍👦", + "👩‍👧", + "👨‍👨‍👧‍👦", + "👨‍👨‍👦", + "👨‍👩‍👧", + "👨‍👩‍👦‍👦", + "👩‍👧‍👧", + "👩‍👩‍👧‍👧", + "👨‍👨‍👦‍👦", + "👩‍👩‍👦‍👦", + "👩‍👩‍👧‍👦", + "👨", + "👩‍👧‍👦", + "👪", + "👨‍👨‍👧‍👧" + ] + }, + { + "keyword": "aww", + "emoticons": [ + "🤩", + "☺" + ] + }, + { + "keyword": "cloud with snow", + "emoticons": [ + "🌨" + ] + }, + { + "keyword": "lips", + "emoticons": [ + "💋", + "👄", + "🫦" + ] + }, + { + "keyword": "woman surfing", + "emoticons": [ + "🏄‍♀" + ] + }, + { + "keyword": "giggles", + "emoticons": [ + "🤭" + ] + }, + { + "keyword": "perfect", + "emoticons": [ + "💯", + "👌" + ] + }, + { + "keyword": "hash", + "emoticons": [ + "#⃣" + ] + }, + { + "keyword": "verified", + "emoticons": [ + "✅" + ] + }, + { + "keyword": "stupidity", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "guard", + "emoticons": [ + "💂‍♀", + "💂", + "💂‍♂" + ] + }, + { + "keyword": "transpire", + "emoticons": [ + "😰", + "😥", + "😓", + "😅" + ] + }, + { + "keyword": "print", + "emoticons": [ + "👣", + "🐾", + "🖨" + ] + }, + { + "keyword": "cash", + "emoticons": [ + "💴", + "💸", + "💷", + "🤑", + "💰", + "💶", + "💵" + ] + }, + { + "keyword": "flower", + "emoticons": [ + "🌷", + "🥀", + "🌻", + "🌺", + "🏵", + "🌼", + "💐", + "💮", + "🌸", + "🪷", + "🌹" + ] + }, + { + "keyword": "mechanic", + "emoticons": [ + "🔧", + "👩‍🔧", + "🧑‍🔧", + "👨‍🔧" + ] + }, + { + "keyword": ":o", + "emoticons": [ + "😮", + "😯", + "😲" + ] + }, + { + "keyword": "hat", + "emoticons": [ + "👲", + "🤠", + "👨‍🎓", + "💂‍♀", + "🎩", + "🧙‍♀", + "👒", + "🕴", + "🧙‍♂", + "💂‍♂", + "👩‍🎓" + ] + }, + { + "keyword": "cross", + "emoticons": [ + "❌", + "✝", + "✖", + "🤞", + "❎", + "☦" + ] + }, + { + "keyword": "owo", + "emoticons": [ + "😲", + "😯", + "😳" + ] + }, + { + "keyword": "low volume", + "emoticons": [ + "🔈" + ] + }, + { + "keyword": "photo", + "emoticons": [ + "📷", + "📸" + ] + }, + { + "keyword": "sv", + "emoticons": [ + "🇸🇻" + ] + }, + { + "keyword": "frog", + "emoticons": [ + "🐸" + ] + }, + { + "keyword": "person wearing turban", + "emoticons": [ + "👳‍♀", + "👳‍♂" + ] + }, + { + "keyword": "synagogue", + "emoticons": [ + "🕍" + ] + }, + { + "keyword": "tongue", + "emoticons": [ + "😜", + "🤪", + "😛", + "🤑", + "😝", + "👅" + ] + }, + { + "keyword": "md", + "emoticons": [ + "👨‍⚕", + "👩‍⚕", + "🇲🇩" + ] + }, + { + "keyword": "couple kiss ww", + "emoticons": [ + "👩‍❤‍💋‍👩" + ] + }, + { + "keyword": "poo", + "emoticons": [ + "💩" + ] + }, + { + "keyword": "blood b", + "emoticons": [ + "🅱" + ] + }, + { + "keyword": "cyprus", + "emoticons": [ + "🇨🇾" + ] + }, + { + "keyword": "disgust", + "emoticons": [ + "😒", + "😨", + "😵", + "😫", + "🤢", + "😬", + "😖", + "😱", + "🦨", + "😣", + "😩", + "🤮", + "😠", + "💩" + ] + }, + { + "keyword": "mayotte", + "emoticons": [ + "🇾🇹" + ] + }, + { + "keyword": "oh", + "emoticons": [ + "😯", + "😱", + "😵", + "😒", + "😮", + "😧", + "😲" + ] + }, + { + "keyword": "musical instrument", + "emoticons": [ + "🎸", + "🎷", + "🎻", + "🎺", + "🎹" + ] + }, + { + "keyword": "meow", + "emoticons": [ + "🐱" + ] + }, + { + "keyword": "text", + "emoticons": [ + "📝", + "💬" + ] + }, + { + "keyword": "statue of liberty", + "emoticons": [ + "🗽" + ] + }, + { + "keyword": "yellow book", + "emoticons": [ + "📒" + ] + }, + { + "keyword": "ninja", + "emoticons": [ + "🥷" + ] + }, + { + "keyword": "medical", + "emoticons": [ + "💊", + "💉" + ] + }, + { + "keyword": "next", + "emoticons": [ + "➡", + "🔜" + ] + }, + { + "keyword": "cookie", + "emoticons": [ + "🍪", + "🥠" + ] + }, + { + "keyword": "steam", + "emoticons": [ + "😤", + "💨", + "♨" + ] + }, + { + "keyword": "clock330", + "emoticons": [ + "🕞" + ] + }, + { + "keyword": "doctor", + "emoticons": [ + "🧑‍⚕", + "👨‍⚕", + "💉", + "👩‍⚕", + "🥼", + "💊" + ] + }, + { + "keyword": "left arrow curving right", + "emoticons": [ + "↪" + ] + }, + { + "keyword": "penguin", + "emoticons": [ + "🐧" + ] + }, + { + "keyword": "devil", + "emoticons": [ + "👹", + "😈", + "👿" + ] + }, + { + "keyword": "ag", + "emoticons": [ + "🇦🇬" + ] + }, + { + "keyword": "hiker", + "emoticons": [ + "🏞", + "🧭", + "⛰", + "🚶", + "🥾", + "👣", + "⛺", + "🏕", + "🎒" + ] + }, + { + "keyword": "tm", + "emoticons": [ + "™", + "🇹🇲" + ] + }, + { + "keyword": "kazakhstan", + "emoticons": [ + "🇰🇿" + ] + }, + { + "keyword": "celebration", + "emoticons": [ + "🎆", + "🎃", + "🥳" + ] + }, + { + "keyword": "query", + "emoticons": [ + "🙋‍♀", + "🙋", + "🙋‍♂" + ] + }, + { + "keyword": "trooper", + "emoticons": [ + "👮" + ] + }, + { + "keyword": "deaf", + "emoticons": [ + "🧏", + "🦻", + "🧏‍♀", + "🧏‍♂" + ] + }, + { + "keyword": "nepal", + "emoticons": [ + "🇳🇵" + ] + }, + { + "keyword": "fruit", + "emoticons": [ + "🍋", + "🥥", + "🍈", + "🍑", + "🍎", + "🍅", + "🍐", + "🍍", + "🍓", + "🍊", + "🥭", + "🍏", + "🍒", + "🍌", + "🍇", + "🥝" + ] + }, + { + "keyword": "antenna", + "emoticons": [ + "📡" + ] + }, + { + "keyword": "wax", + "emoticons": [ + "🐝" + ] + }, + { + "keyword": "drink", + "emoticons": [ + "🍷", + "🥂", + "🍾", + "🍻", + "🍼", + "🥃", + "🫖", + "🍸", + "🍶", + "🍵", + "🍺", + "☕", + "🥤", + "🥛", + "🍹" + ] + }, + { + "keyword": "repeat single", + "emoticons": [ + "🔂" + ] + }, + { + "keyword": "handball", + "emoticons": [ + "🤾‍♀", + "🤾‍♂", + "🤾" + ] + }, + { + "keyword": "rock", + "emoticons": [ + "🤟", + "🧗‍♂", + "🤘", + "🧗‍♀", + "🧗", + "🪨" + ] + }, + { + "keyword": "white heart", + "emoticons": [ + "🤍" + ] + }, + { + "keyword": "hindu temple", + "emoticons": [ + "🛕" + ] + }, + { + "keyword": "no one under eighteen", + "emoticons": [ + "🔞" + ] + }, + { + "keyword": "tire", + "emoticons": [ + "🛞" + ] + }, + { + "keyword": "wine", + "emoticons": [ + "🍷", + "🍾" + ] + }, + { + "keyword": "children", + "emoticons": [ + "👨‍👨‍👦", + "👨‍👨‍👧", + "👨‍👩‍👧", + "👩‍👩‍👧", + "👨‍👨‍👧‍👧", + "👩‍👦", + "👨‍👨‍👧‍👦", + "👩‍👩‍👧‍👦", + "👪", + "👩‍👩‍👦‍👦", + "👨‍👩‍👧‍👦", + "👨‍👩‍👧‍👧", + "👩‍👩‍👦", + "👨‍👩‍👦‍👦", + "👩‍👩‍👧‍👧", + "👨", + "👨‍👨‍👦‍👦" + ] + }, + { + "keyword": "hong kong sar china", + "emoticons": [ + "🇭🇰" + ] + }, + { + "keyword": "palette", + "emoticons": [ + "🎨" + ] + }, + { + "keyword": "o button (blood type)", + "emoticons": [ + "🅾" + ] + }, + { + "keyword": "genie", + "emoticons": [ + "🧞‍♂", + "🧞‍♀", + "🧞" + ] + }, + { + "keyword": "tepes", + "emoticons": [ + "🧛" + ] + }, + { + "keyword": "warnings", + "emoticons": [ + "🚫", + "☢", + "🔞", + "☣", + "🚸", + "⛔", + "🚯", + "🚱", + "🚳", + "🚷", + "🚭", + "📵", + "⚠" + ] + }, + { + "keyword": "horse", + "emoticons": [ + "🐎", + "🐴", + "🏇", + "🎠" + ] + }, + { + "keyword": "orange circle", + "emoticons": [ + "🟠" + ] + }, + { + "keyword": "burger", + "emoticons": [ + "🍔" + ] + }, + { + "keyword": "hundred", + "emoticons": [ + "💯" + ] + }, + { + "keyword": "piñata", + "emoticons": [ + "🪅" + ] + }, + { + "keyword": "sadface", + "emoticons": [ + "😰", + "😟", + "😕", + "😥", + "😖", + "😞", + "😢", + "😪", + "😣", + "😫", + "🙁", + "😩", + "😔", + "😓" + ] + }, + { + "keyword": "crown", + "emoticons": [ + "🫅", + "👸", + "👑", + "🤴" + ] + }, + { + "keyword": "gun", + "emoticons": [ + "🔫" + ] + }, + { + "keyword": "bored", + "emoticons": [ + "🥱", + "🙄" + ] + }, + { + "keyword": "cheerful", + "emoticons": [ + "😃", + "😁", + "☺", + "😊", + "😄", + "😀" + ] + }, + { + "keyword": "beard", + "emoticons": [ + "🧙‍♂", + "🧔", + "🧔‍♀", + "🧔‍♂", + "🎅" + ] + }, + { + "keyword": "travel", + "emoticons": [ + "🛣", + "🌞", + "🚊", + "🏝", + "🛸", + "🛫", + "🛩", + "🗺", + "🚞", + "🛵", + "🏞", + "🚅", + "🛴", + "🚌", + "🚗", + "📍", + "🛤", + "🚍", + "🚂", + "🧳", + "✈", + "🛬", + "🚢", + "🌎" + ] + }, + { + "keyword": "monkey", + "emoticons": [ + "🐒", + "🦧", + "🦍", + "🐵", + "🙈", + "🙊", + "🙉" + ] + }, + { + "keyword": "typing", + "emoticons": [ + "⌨", + "💬" + ] + }, + { + "keyword": "perserving face", + "emoticons": [ + "😣" + ] + }, + { + "keyword": "radio", + "emoticons": [ + "📻", + "🎙" + ] + }, + { + "keyword": "eyes", + "emoticons": [ + "👁", + "👀" + ] + }, + { + "keyword": "done", + "emoticons": [ + "✅", + "☑", + "✔", + "👍", + "👌" + ] + }, + { + "keyword": "hush", + "emoticons": [ + "🤫", + "🤐", + "😶", + "🙊" + ] + }, + { + "keyword": "climb", + "emoticons": [ + "🧗", + "🧗‍♂", + "🧗‍♀" + ] + }, + { + "keyword": "palm up", + "emoticons": [ + "🫴" + ] + }, + { + "keyword": "verify", + "emoticons": [ + "✅" + ] + }, + { + "keyword": "wow", + "emoticons": [ + "😯", + "😮", + "😰", + "😳", + "😲", + "😱", + "😨" + ] + }, + { + "keyword": "log", + "emoticons": [ + "🪵" + ] + }, + { + "keyword": "arrow", + "emoticons": [ + "⬇", + "↖", + "⬅", + "↗", + "↩", + "↙", + "↕", + "↘", + "🔜", + "⬆", + "⤴", + "⤵", + "↪", + "🔝", + "🏹", + "🔙", + "🔃", + "🔛", + "↔", + "➡", + "🔚", + "🔄" + ] + }, + { + "keyword": "celebrate", + "emoticons": [ + "🎉", + "🍾", + "🎊", + "🎁", + "🥳" + ] + }, + { + "keyword": "dice", + "emoticons": [ + "🎲" + ] + }, + { + "keyword": "+", + "emoticons": [ + "✅", + "➕" + ] + }, + { + "keyword": "lift", + "emoticons": [ + "🏋", + "🛗", + "🏋‍♀", + "🚡", + "🏋‍♂" + ] + }, + { + "keyword": "mother", + "emoticons": [ + "👨‍👨‍👧‍👧", + "👩‍👩‍👦‍👦", + "👩‍👦", + "👩‍👧", + "👨‍👩‍👧", + "👨‍👩‍👦‍👦", + "👩‍👩‍👧‍👦", + "👨", + "👩‍👩‍👧‍👧", + "👨‍👨‍👦‍👦", + "👨‍👨‍👧‍👦", + "👨‍👩‍👧‍👧", + "👩‍👩‍👧", + "👨‍👨‍👦", + "👨‍👩‍👧‍👦", + "👪", + "👩‍👦‍👦", + "👩‍👧‍👧", + "👨‍👩‍👦", + "🤰", + "👩‍👩‍👦", + "👨‍👨‍👧", + "👩‍👧‍👦" + ] + }, + { + "keyword": "arms", + "emoticons": [ + "⚔", + "🏹", + "🗡", + "🔫", + "💣", + "🛡", + "🔪", + "🧨" + ] + }, + { + "keyword": "flag_bd", + "emoticons": [ + "🇧🇩" + ] + }, + { + "keyword": "television", + "emoticons": [ + "📺" + ] + }, + { + "keyword": "monocle", + "emoticons": [ + "🧐" + ] + }, + { + "keyword": "dunno", + "emoticons": [ + "🤷‍♂", + "🤷‍♀" + ] + }, + { + "keyword": "donut", + "emoticons": [ + "🍩" + ] + }, + { + "keyword": "comrade", + "emoticons": [ + "✊" + ] + }, + { + "keyword": "mended", + "emoticons": [ + "❤‍🩹" + ] + }, + { + "keyword": "baseball cap", + "emoticons": [ + "🧢" + ] + }, + { + "keyword": "look", + "emoticons": [ + "👀", + "👁" + ] + }, + { + "keyword": "flag_lr", + "emoticons": [ + "🇱🇷" + ] + }, + { + "keyword": "flag_bb", + "emoticons": [ + "🇧🇧" + ] + }, + { + "keyword": "crescent moon", + "emoticons": [ + "🌙" + ] + }, + { + "keyword": "huawei", + "emoticons": [ + "📱" + ] + }, + { + "keyword": "maldives", + "emoticons": [ + "🇲🇻" + ] + }, + { + "keyword": "black nib", + "emoticons": [ + "✒" + ] + }, + { + "keyword": "me", + "emoticons": [ + "🇲🇪" + ] + }, + { + "keyword": "heart eyes", + "emoticons": [ + "😻", + "😍" + ] + }, + { + "keyword": "clock6", + "emoticons": [ + "🕕" + ] + }, + { + "keyword": "potato", + "emoticons": [ + "🥔", + "🍠" + ] + }, + { + "keyword": "accepted", + "emoticons": [ + "☑", + "👍", + "👌", + "✔", + "✅" + ] + }, + { + "keyword": "breakfast", + "emoticons": [ + "🍳", + "🧇", + "🥞", + "☕", + "🥣", + "🥪", + "🥚", + "😋", + "🥐", + "🍌", + "🥑", + "🍓", + "🥛", + "🥓", + "🥯", + "🍽" + ] + }, + { + "keyword": "night", + "emoticons": [ + "🌙", + "🌃", + "🌠", + "🎇", + "🌉", + "🎆" + ] + }, + { + "keyword": "pleasure", + "emoticons": [ + "😜", + "✨", + "😎", + "🥂", + "🎭", + "🌟", + "🎇", + "🎡", + "🎆", + "💫", + "🎢", + "🍹" + ] + }, + { + "keyword": "spades", + "emoticons": [ + "♠" + ] + }, + { + "keyword": "bz", + "emoticons": [ + "🇧🇿" + ] + }, + { + "keyword": "student", + "emoticons": [ + "👩‍🎓", + "🧑‍🎓", + "👨‍🎓" + ] + }, + { + "keyword": "end", + "emoticons": [ + "🔚" + ] + }, + { + "keyword": "heavy division sign", + "emoticons": [ + "➗" + ] + }, + { + "keyword": "wheelchair man", + "emoticons": [ + "👨‍🦽" + ] + }, + { + "keyword": "hurrah", + "emoticons": [ + "🙌", + "👏" + ] + }, + { + "keyword": "noble", + "emoticons": [ + "🧐" + ] + }, + { + "keyword": "bald", + "emoticons": [ + "🧑‍🦲", + "👩‍🦲", + "👨‍🦲" + ] + }, + { + "keyword": "thank you", + "emoticons": [ + "☺" + ] + }, + { + "keyword": "wilting", + "emoticons": [ + "🥀" + ] + }, + { + "keyword": "isle of man", + "emoticons": [ + "🇮🇲" + ] + }, + { + "keyword": "sweet potato", + "emoticons": [ + "🍠" + ] + }, + { + "keyword": "closed umbrella", + "emoticons": [ + "🌂" + ] + }, + { + "keyword": "devotion", + "emoticons": [ + "🙇", + "🙇‍♀" + ] + }, + { + "keyword": "arrow down small", + "emoticons": [ + "🔽" + ] + }, + { + "keyword": "sandwich", + "emoticons": [ + "🥪" + ] + }, + { + "keyword": "blue", + "emoticons": [ + "🟦" + ] + }, + { + "keyword": "usd", + "emoticons": [ + "💸", + "💲", + "💰", + "💵" + ] + }, + { + "keyword": "snowflake", + "emoticons": [ + "❄" + ] + }, + { + "keyword": "spaceship", + "emoticons": [ + "🚀" + ] + }, + { + "keyword": "pizza", + "emoticons": [ + "🍕" + ] + }, + { + "keyword": "grumpy", + "emoticons": [ + "😾" + ] + }, + { + "keyword": "clock8", + "emoticons": [ + "🕗" + ] + }, + { + "keyword": "art", + "emoticons": [ + "👨‍🎨", + "🖼", + "🎨", + "👩‍🎤", + "👩‍🎨", + "👨‍🎤", + "🎭" + ] + }, + { + "keyword": "wallet", + "emoticons": [ + "👝", + "👛" + ] + }, + { + "keyword": "weightlifter", + "emoticons": [ + "🏋" + ] + }, + { + "keyword": "banned", + "emoticons": [ + "🚷", + "⛔", + "🚫", + "🚳", + "🔞", + "🛑", + "❌", + "🚭", + "📵", + "🚯", + "🚱" + ] + }, + { + "keyword": "am", + "emoticons": [ + "🇦🇲" + ] + }, + { + "keyword": "wavy dash", + "emoticons": [ + "〰" + ] + }, + { + "keyword": "bitch", + "emoticons": [ + "🖕", + "🤬", + "😡", + "😐", + "😑" + ] + }, + { + "keyword": "5", + "emoticons": [ + "5⃣" + ] + }, + { + "keyword": "lol", + "emoticons": [ + "😄", + "🤣", + "😁", + "😹", + "😂", + "😃", + "😀", + "😆", + "😸" + ] + }, + { + "keyword": "new zealand", + "emoticons": [ + "🇳🇿" + ] + }, + { + "keyword": "smell", + "emoticons": [ + "👃" + ] + }, + { + "keyword": "tt", + "emoticons": [ + "🇹🇹" + ] + }, + { + "keyword": "wolf", + "emoticons": [ + "🐺" + ] + }, + { + "keyword": "hike", + "emoticons": [ + "⛺", + "🏕" + ] + }, + { + "keyword": "herd", + "emoticons": [ + "🦓", + "🐑", + "🦬" + ] + }, + { + "keyword": "circle", + "emoticons": [ + "🟣", + "⚫", + "🔴", + "🟡", + "🟠", + "🙆", + "🔵", + "🔘", + "🟢", + "⚪", + "🟤" + ] + }, + { + "keyword": "laugh", + "emoticons": [ + "😄", + "😹", + "😬", + "😂", + "😁", + "😃", + "😅", + "😺", + "🤣", + "😸", + "😻", + "😆", + "🤩", + "😀" + ] + }, + { + "keyword": "eight", + "emoticons": [ + "🎱", + "8⃣" + ] + }, + { + "keyword": "hut", + "emoticons": [ + "🛖" + ] + }, + { + "keyword": "castle", + "emoticons": [ + "🏯", + "🏰" + ] + }, + { + "keyword": "my", + "emoticons": [ + "🇲🇾" + ] + }, + { + "keyword": "vacation", + "emoticons": [ + "🏝", + "✈", + "🏖", + "⛱", + "🚅", + "🚗", + "🛳", + "🥂" + ] + }, + { + "keyword": "hear", + "emoticons": [ + "🙉", + "👂" + ] + }, + { + "keyword": "garlic", + "emoticons": [ + "🧄" + ] + }, + { + "keyword": "im", + "emoticons": [ + "🇮🇲" + ] + }, + { + "keyword": "above", + "emoticons": [ + "☝", + "👆" + ] + }, + { + "keyword": "emirate", + "emoticons": [ + "🇦🇪" + ] + }, + { + "keyword": "err", + "emoticons": [ + "😬" + ] + }, + { + "keyword": "okee", + "emoticons": [ + "😉" + ] + }, + { + "keyword": "person pouting", + "emoticons": [ + "🙎‍♂", + "🙎‍♀" + ] + }, + { + "keyword": "check", + "emoticons": [ + "✅", + "☑", + "✔" + ] + }, + { + "keyword": "point", + "emoticons": [ + "☝", + "👈", + "👇", + "🖕", + "👉", + "👆" + ] + }, + { + "keyword": "chart with upward trend", + "emoticons": [ + "📈" + ] + }, + { + "keyword": "magnify", + "emoticons": [ + "🔍", + "🔎" + ] + }, + { + "keyword": "flag_co", + "emoticons": [ + "🇨🇴" + ] + }, + { + "keyword": "curly", + "emoticons": [ + "👨‍🦱", + "🧑‍🦱", + "👩‍🦱" + ] + }, + { + "keyword": "green apple", + "emoticons": [ + "🍏" + ] + }, + { + "keyword": "ily", + "emoticons": [ + "😍" + ] + }, + { + "keyword": "haircut", + "emoticons": [ + "👩‍🦲", + "👨‍🦰", + "👱‍♂", + "🧑‍🦲", + "🧑‍🦱", + "👱", + "💇", + "👵", + "🧔", + "👨‍🦱", + "👨‍🦳", + "🧓", + "💇‍♂", + "🧑‍🦰", + "👩‍🦳", + "👨", + "👩‍🦰", + "👨‍🦲", + "👱‍♀", + "👩", + "💇‍♀", + "🧑‍🦳", + "🧑", + "👩‍🦱" + ] + }, + { + "keyword": "heartbeat", + "emoticons": [ + "💓", + "💗" + ] + }, + { + "keyword": "blood type", + "emoticons": [ + "🅰", + "🅱", + "🆎", + "🅾" + ] + }, + { + "keyword": "lamp", + "emoticons": [ + "🪔", + "🛋", + "🧞", + "💡" + ] + }, + { + "keyword": "mz", + "emoticons": [ + "🇲🇿" + ] + }, + { + "keyword": "....", + "emoticons": [ + "😯", + "😲", + "😮" + ] + }, + { + "keyword": "versus", + "emoticons": [ + "🆚" + ] + }, + { + "keyword": "horse face", + "emoticons": [ + "🐴" + ] + }, + { + "keyword": "second place medal", + "emoticons": [ + "🥈" + ] + }, + { + "keyword": "mantelpiece clock", + "emoticons": [ + "🕰" + ] + }, + { + "keyword": "deejay", + "emoticons": [ + "💥", + "🎶", + "🎤", + "🎛", + "🎧", + "🔊", + "📀", + "💽", + "💿", + "🪩", + "🎚" + ] + }, + { + "keyword": "soft ice cream", + "emoticons": [ + "🍦" + ] + }, + { + "keyword": "sz", + "emoticons": [ + "🇸🇿" + ] + }, + { + "keyword": "confetti popper", + "emoticons": [ + "🎉" + ] + }, + { + "keyword": "e-mail", + "emoticons": [ + "📧" + ] + }, + { + "keyword": "fish", + "emoticons": [ + "🍥", + "🐠", + "🐡", + "🐟" + ] + }, + { + "keyword": "designer", + "emoticons": [ + "👨‍💻", + "🧑‍💻", + "👩‍🎨", + "🖌", + "👨‍🎨", + "👩‍💻", + "🧑‍🎨", + "🎨" + ] + }, + { + "keyword": "flag_so", + "emoticons": [ + "🇸🇴" + ] + }, + { + "keyword": "kh", + "emoticons": [ + "🇰🇭" + ] + }, + { + "keyword": "rage", + "emoticons": [ + "😡", + "🤬", + "😠" + ] + }, + { + "keyword": "diamond with a dot", + "emoticons": [ + "💠" + ] + }, + { + "keyword": "satellite antenna", + "emoticons": [ + "📡" + ] + }, + { + "keyword": "verification", + "emoticons": [ + "✅" + ] + }, + { + "keyword": "folder", + "emoticons": [ + "📂", + "🗂", + "📁" + ] + }, + { + "keyword": "barber pole", + "emoticons": [ + "💈" + ] + }, + { + "keyword": "cry", + "emoticons": [ + "😹", + "💦", + "😂", + "😿", + "😭", + "😢", + "🤣" + ] + }, + { + "keyword": "marriage", + "emoticons": [ + "💏", + "💍", + "🤵", + "👰" + ] + }, + { + "keyword": "la", + "emoticons": [ + "🇱🇦" + ] + }, + { + "keyword": "salute", + "emoticons": [ + "🤚", + "✋", + "🫡", + "👋", + "🖖" + ] + }, + { + "keyword": "treat", + "emoticons": [ + "💊", + "💉" + ] + }, + { + "keyword": "grandfather", + "emoticons": [ + "👴" + ] + }, + { + "keyword": "magic ", + "emoticons": [ + "🪄" + ] + }, + { + "keyword": "screw", + "emoticons": [ + "🪛", + "🔩" + ] + }, + { + "keyword": "star and crescent", + "emoticons": [ + "☪" + ] + }, + { + "keyword": "gn", + "emoticons": [ + "🇬🇳" + ] + }, + { + "keyword": "circuis", + "emoticons": [ + "🤹‍♂", + "🤹‍♀", + "🤡" + ] + }, + { + "keyword": "newborn", + "emoticons": [ + "🤰", + "🤱", + "🍼", + "👶", + "👼" + ] + }, + { + "keyword": "bravo", + "emoticons": [ + "🙌", + "👏" + ] + }, + { + "keyword": "mail", + "emoticons": [ + "📬", + "📦", + "🗳", + "📥", + "📤", + "📮", + "📩", + "📧", + "📭", + "✉", + "📫", + "📪", + "📨" + ] + }, + { + "keyword": "crossed flags", + "emoticons": [ + "🎌" + ] + }, + { + "keyword": "random", + "emoticons": [ + "🎲" + ] + }, + { + "keyword": "soldier", + "emoticons": [ + "🧑‍🚀", + "👨‍🚀", + "👩‍🚀" + ] + }, + { + "keyword": "motorbike", + "emoticons": [ + "🛵" + ] + }, + { + "keyword": "flag_pm", + "emoticons": [ + "🇵🇲" + ] + }, + { + "keyword": "bf", + "emoticons": [ + "🧑‍🤝‍🧑", + "🇧🇫" + ] + }, + { + "keyword": "curse", + "emoticons": [ + "🤬" + ] + }, + { + "keyword": "love you", + "emoticons": [ + "💛", + "💖", + "❤", + "🖤", + "💞", + "💚", + "♥", + "💘", + "💟", + "💜", + "❣", + "💓", + "🧡", + "💝", + "💙", + "💕", + "💗" + ] + }, + { + "keyword": "smile", + "emoticons": [ + "🙃", + "😀", + "😄", + "🙂", + "😊", + "😈", + "😎", + "😆", + "😁", + "🤩", + "😅", + "😃", + "😼", + "😇", + "😉", + "😺", + "😸", + "😌", + "😻" + ] + }, + { + "keyword": "congo", + "emoticons": [ + "🇨🇬", + "🇨🇩" + ] + }, + { + "keyword": "men's room", + "emoticons": [ + "🚹" + ] + }, + { + "keyword": "russia", + "emoticons": [ + "☦", + "🪆", + "🇷🇺" + ] + }, + { + "keyword": "?!", + "emoticons": [ + "⁉" + ] + }, + { + "keyword": "painting", + "emoticons": [ + "👨‍🎨", + "🎨", + "🖍", + "🖼", + "🖌", + "👩‍🎨", + "💅" + ] + }, + { + "keyword": "razor", + "emoticons": [ + "🪒" + ] + }, + { + "keyword": "white flag", + "emoticons": [ + "🏳" + ] + }, + { + "keyword": "eswatini", + "emoticons": [ + "🇸🇿" + ] + }, + { + "keyword": "wedding", + "emoticons": [ + "💍", + "💐", + "🎩", + "🤵", + "👰", + "💒" + ] + }, + { + "keyword": "lgbtqia+", + "emoticons": [ + "🌈" + ] + }, + { + "keyword": "swear", + "emoticons": [ + "🤬" + ] + }, + { + "keyword": "window", + "emoticons": [ + "🪟" + ] + }, + { + "keyword": "ae", + "emoticons": [ + "🇦🇪" + ] + }, + { + "keyword": "hair", + "emoticons": [ + "🧑", + "🧔", + "👩‍🦰", + "👨‍🦳", + "🧑‍🦱", + "🧑‍🦰", + "👩‍🦱", + "👩‍🦳", + "🧓", + "👨‍🦱", + "👱‍♀", + "🧑‍🦲", + "👩", + "🧑‍🦳", + "👩‍🦲", + "👨‍🦰", + "👵", + "👨", + "👱‍♂", + "👱", + "👨‍🦲" + ] + }, + { + "keyword": "no", + "emoticons": [ + "🙅‍♂", + "🙅‍♀", + "🚫", + "🇳🇴", + "🙅", + "👎" + ] + }, + { + "keyword": "tuxedo", + "emoticons": [ + "🤵", + "🤵‍♂", + "🤵‍♀" + ] + }, + { + "keyword": "two o'clock", + "emoticons": [ + "🕑" + ] + }, + { + "keyword": "steaming", + "emoticons": [ + "🍜" + ] + }, + { + "keyword": "pump", + "emoticons": [ + "⛽" + ] + }, + { + "keyword": "silver", + "emoticons": [ + "🥈" + ] + }, + { + "keyword": "square", + "emoticons": [ + "🟫", + "🟪", + "🟩", + "🟧", + "🟨", + "🟥", + "🟦" + ] + }, + { + "keyword": "mobile", + "emoticons": [ + "📲", + "📱" + ] + }, + { + "keyword": "takeout", + "emoticons": [ + "🥡" + ] + }, + { + "keyword": "puke", + "emoticons": [ + "🤮" + ] + }, + { + "keyword": "ke", + "emoticons": [ + "🇰🇪" + ] + }, + { + "keyword": "stop sign", + "emoticons": [ + "🛑" + ] + }, + { + "keyword": "ironical", + "emoticons": [ + "😏" + ] + }, + { + "keyword": "detective", + "emoticons": [ + "🕵‍♀", + "🕵", + "🕵‍♂" + ] + }, + { + "keyword": "family_mwg", + "emoticons": [ + "👨‍👩‍👧" + ] + }, + { + "keyword": "sale", + "emoticons": [ + "🛒", + "🎁", + "💥", + "🛍", + "💲", + "🏷", + "🤑", + "🔥", + "💸" + ] + }, + { + "keyword": "tipping hand person", + "emoticons": [ + "💁‍♂", + "💁‍♀" + ] + }, + { + "keyword": "family mwbb", + "emoticons": [ + "👨‍👩‍👦‍👦" + ] + }, + { + "keyword": "bubbletea", + "emoticons": [ + "🧋" + ] + }, + { + "keyword": "pointing", + "emoticons": [ + "👇", + "👈", + "👆", + "👉", + "☝", + "🖕" + ] + }, + { + "keyword": "stuck out tongue", + "emoticons": [ + "😛" + ] + }, + { + "keyword": "rewind", + "emoticons": [ + "⏪" + ] + }, + { + "keyword": "peru", + "emoticons": [ + "🇵🇪" + ] + }, + { + "keyword": "aye", + "emoticons": [ + "✌", + "🤘", + "🤛", + "👋", + "🤙", + "🤜" + ] + }, + { + "keyword": "hamster face", + "emoticons": [ + "🐹" + ] + }, + { + "keyword": "swim", + "emoticons": [ + "🏊‍♂", + "🏊‍♀", + "🏊" + ] + }, + { + "keyword": "jp", + "emoticons": [ + "🇯🇵" + ] + }, + { + "keyword": "law", + "emoticons": [ + "⚖" + ] + }, + { + "keyword": "wrapped gift", + "emoticons": [ + "🎁" + ] + }, + { + "keyword": "black", + "emoticons": [ + "⬛", + "🖤", + "⚫" + ] + }, + { + "keyword": "alien face", + "emoticons": [ + "👽", + "👾" + ] + }, + { + "keyword": "probing cane", + "emoticons": [ + "👩‍🦯", + "🦯", + "🧑‍🦯", + "👨‍🦯" + ] + }, + { + "keyword": "run", + "emoticons": [ + "🏃", + "🏃‍♂", + "🏃‍♀" + ] + }, + { + "keyword": "royal", + "emoticons": [ + "👑" + ] + }, + { + "keyword": "war", + "emoticons": [ + "🕊" + ] + }, + { + "keyword": "pushpin", + "emoticons": [ + "📌", + "📍" + ] + }, + { + "keyword": "mag right", + "emoticons": [ + "🔎" + ] + }, + { + "keyword": "wilted flower", + "emoticons": [ + "🥀" + ] + }, + { + "keyword": "poultry", + "emoticons": [ + "🍗" + ] + }, + { + "keyword": "cross mark", + "emoticons": [ + "❌", + "❎", + "✖" + ] + }, + { + "keyword": "ocean", + "emoticons": [ + "🌊", + "🐳" + ] + }, + { + "keyword": "atom", + "emoticons": [ + "⚛" + ] + }, + { + "keyword": "do", + "emoticons": [ + "🇩🇴" + ] + }, + { + "keyword": "religion", + "emoticons": [ + "☯", + "🕎", + "⛪", + "⚛", + "📿", + "✡", + "🕋", + "🔯", + "☮", + "☪", + "⛩", + "🕌", + "✝", + "🛐", + "🕍", + "☦", + "🕉", + "☸" + ] + }, + { + "keyword": "wew", + "emoticons": [ + "😨", + "😲", + "😯", + "😰", + "😳", + "😮", + "😱" + ] + }, + { + "keyword": "leaves", + "emoticons": [ + "🍃" + ] + }, + { + "keyword": "lattern", + "emoticons": [ + "🏮" + ] + }, + { + "keyword": "gravestone", + "emoticons": [ + "🪦" + ] + }, + { + "keyword": "beach", + "emoticons": [ + "🏖", + "🏝" + ] + }, + { + "keyword": "princess", + "emoticons": [ + "👸" + ] + }, + { + "keyword": "flag_ma", + "emoticons": [ + "🇲🇦" + ] + }, + { + "keyword": "major tom", + "emoticons": [ + "🧑‍🚀" + ] + }, + { + "keyword": "-\u003e", + "emoticons": [ + "➡", + "🔜" + ] + }, + { + "keyword": "metro", + "emoticons": [ + "🚇", + "Ⓜ" + ] + }, + { + "keyword": "amharic", + "emoticons": [ + "🇪🇹" + ] + }, + { + "keyword": "anaconda", + "emoticons": [ + "🐍" + ] + }, + { + "keyword": "ballot box with ballot", + "emoticons": [ + "🗳" + ] + }, + { + "keyword": "poker face", + "emoticons": [ + "😐" + ] + }, + { + "keyword": "nordic cross", + "emoticons": [ + "🇳🇴", + "🇦🇽", + "🇸🇪", + "🇫🇴", + "🇫🇮", + "🇮🇸", + "🇩🇰" + ] + }, + { + "keyword": "water buffalo", + "emoticons": [ + "🐃" + ] + }, + { + "keyword": "bikini", + "emoticons": [ + "👙" + ] + }, + { + "keyword": "blue circle", + "emoticons": [ + "🔵" + ] + }, + { + "keyword": "flag_jo", + "emoticons": [ + "🇯🇴" + ] + }, + { + "keyword": "speak", + "emoticons": [ + "🙊", + "🗣" + ] + }, + { + "keyword": "spy", + "emoticons": [ + "🕵‍♂", + "🕵‍♀" + ] + }, + { + "keyword": "angry", + "emoticons": [ + "👿", + "😤", + "😡", + "🤬", + "💢", + "😾", + "😠" + ] + }, + { + "keyword": "afraid", + "emoticons": [ + "😱", + "😰", + "🙀", + "😨", + "🫣" + ] + }, + { + "keyword": "tusk", + "emoticons": [ + "🐘", + "🦣" + ] + }, + { + "keyword": "yo-yo", + "emoticons": [ + "🪀" + ] + }, + { + "keyword": "spring", + "emoticons": [ + "🌞", + "🌱", + "☀", + "🌿", + "🌤" + ] + }, + { + "keyword": "seafood", + "emoticons": [ + "🦑", + "🦀", + "🦐", + "🦞" + ] + }, + { + "keyword": "rice bowl", + "emoticons": [ + "🍚" + ] + }, + { + "keyword": "1234", + "emoticons": [ + "🔢" + ] + }, + { + "keyword": "warning", + "emoticons": [ + "🚸", + "📵", + "⚠", + "🚱", + "🚷", + "🚫", + "☢", + "☣", + "🚳", + "🔞", + "🚯", + "🚭", + "⛔" + ] + }, + { + "keyword": "boots", + "emoticons": [ + "🥾", + "👞" + ] + }, + { + "keyword": "litter", + "emoticons": [ + "🚮" + ] + }, + { + "keyword": "flag_bt", + "emoticons": [ + "🇧🇹" + ] + }, + { + "keyword": "signal", + "emoticons": [ + "🌐", + "📡", + "📶" + ] + }, + { + "keyword": "tamale", + "emoticons": [ + "🫔" + ] + }, + { + "keyword": "toothbrush", + "emoticons": [ + "🪥" + ] + }, + { + "keyword": "brokenhearted", + "emoticons": [ + "💔" + ] + }, + { + "keyword": "palm down", + "emoticons": [ + "🫳" + ] + }, + { + "keyword": "ring", + "emoticons": [ + "💍" + ] + }, + { + "keyword": "sauna", + "emoticons": [ + "🧖‍♂", + "🧖‍♀", + "🧖" + ] + }, + { + "keyword": "lecturer", + "emoticons": [ + "🧑‍🏫" + ] + }, + { + "keyword": "write", + "emoticons": [ + "✒", + "📝", + "🖋", + "✍", + "✏", + "🖌", + "🖊", + "🖍" + ] + }, + { + "keyword": "je", + "emoticons": [ + "🇯🇪" + ] + }, + { + "keyword": "traffic light", + "emoticons": [ + "🚦", + "🚥" + ] + }, + { + "keyword": "hand saw", + "emoticons": [ + "🪚" + ] + }, + { + "keyword": "lacrosse", + "emoticons": [ + "🥍" + ] + }, + { + "keyword": "flag_bn", + "emoticons": [ + "🇧🇳" + ] + }, + { + "keyword": "science", + "emoticons": [ + "⚗", + "👩‍🔬", + "⚛", + "👨‍🔬", + "🧬", + "🔬", + "📡", + "🔭", + "🧪", + "🧫" + ] + }, + { + "keyword": "person getting haircut", + "emoticons": [ + "💇‍♀", + "💇‍♂" + ] + }, + { + "keyword": "information desk person", + "emoticons": [ + "💁‍♀" + ] + }, + { + "keyword": "hot", + "emoticons": [ + "🌋", + "🏜", + "🌶", + "❤‍🔥", + "🥵", + "😏", + "🔥", + "🌞", + "🫑", + "☕", + "☀", + "🍵" + ] + }, + { + "keyword": "flu", + "emoticons": [ + "🤒", + "🤮", + "😷", + "🤢", + "🤧", + "🤕" + ] + }, + { + "keyword": "crazy face", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "muslimah", + "emoticons": [ + "🧕" + ] + }, + { + "keyword": "ski", + "emoticons": [ + "⛷" + ] + }, + { + "keyword": "flag_id", + "emoticons": [ + "🇮🇩" + ] + }, + { + "keyword": "tipping hand", + "emoticons": [ + "💁" + ] + }, + { + "keyword": "man facepalming", + "emoticons": [ + "🤦‍♂" + ] + }, + { + "keyword": "bank", + "emoticons": [ + "🏧", + "🤑", + "💸", + "🏦", + "💰", + "💵", + "💲" + ] + }, + { + "keyword": "fun", + "emoticons": [ + "🥳" + ] + }, + { + "keyword": "rose", + "emoticons": [ + "🌹" + ] + }, + { + "keyword": "cloud", + "emoticons": [ + "🌦", + "🌨", + "⛅", + "☁", + "🌤", + "🌧", + "⛈", + "😶‍🌫", + "🌩", + "🌥" + ] + }, + { + "keyword": "flag_mp", + "emoticons": [ + "🇲🇵" + ] + }, + { + "keyword": "ok", + "emoticons": [ + "👍", + "👌", + "😉", + "🙆", + "🆗" + ] + }, + { + "keyword": "biking", + "emoticons": [ + "🚵‍♂", + "🚴‍♂", + "🚵‍♀", + "🚴‍♀" + ] + }, + { + "keyword": "flashlight", + "emoticons": [ + "🔦" + ] + }, + { + "keyword": "drop", + "emoticons": [ + "💧", + "🩸" + ] + }, + { + "keyword": "cd", + "emoticons": [ + "💿", + "🇨🇩" + ] + }, + { + "keyword": "bunny ears", + "emoticons": [ + "👯‍♂", + "👯‍♀", + "👯" + ] + }, + { + "keyword": "golden gate", + "emoticons": [ + "🌉" + ] + }, + { + "keyword": "anguished", + "emoticons": [ + "😧" + ] + }, + { + "keyword": "poop", + "emoticons": [ + "💩" + ] + }, + { + "keyword": "double exclamation mark", + "emoticons": [ + "‼" + ] + }, + { + "keyword": "man gesturing ok", + "emoticons": [ + "🙆‍♂" + ] + }, + { + "keyword": "book", + "emoticons": [ + "📕", + "📖", + "📓", + "📔", + "📘", + "📚", + "📙", + "📗", + "📒" + ] + }, + { + "keyword": "wallis \u0026 futuna", + "emoticons": [ + "🇼🇫" + ] + }, + { + "keyword": "cars", + "emoticons": [ + "🚙", + "🚑", + "🚓", + "🚍", + "🚚", + "🚒", + "🚔", + "🚕", + "🚖", + "🚘", + "🏎", + "🚜", + "🚐", + "🚗", + "🚛" + ] + }, + { + "keyword": "flag_iq", + "emoticons": [ + "🇮🇶" + ] + }, + { + "keyword": "kid", + "emoticons": [ + "👶", + "👦", + "🧒", + "👨‍👩‍👦", + "👧" + ] + }, + { + "keyword": "ladybug", + "emoticons": [ + "🐞" + ] + }, + { + "keyword": "faerie", + "emoticons": [ + "🧚", + "🧚‍♀", + "🧚‍♂" + ] + }, + { + "keyword": "app", + "emoticons": [ + "🈸" + ] + }, + { + "keyword": "camera with flash", + "emoticons": [ + "📸" + ] + }, + { + "keyword": "monkey face", + "emoticons": [ + "🐵" + ] + }, + { + "keyword": "flag_mm", + "emoticons": [ + "🇲🇲" + ] + }, + { + "keyword": "gamer", + "emoticons": [ + "🎮", + "🕹", + "👾" + ] + }, + { + "keyword": "cake", + "emoticons": [ + "🍰", + "🍥", + "🎂", + "🥮" + ] + }, + { + "keyword": "ie", + "emoticons": [ + "🇮🇪" + ] + }, + { + "keyword": "keycap ten", + "emoticons": [ + "🔟" + ] + }, + { + "keyword": "last quarter moon", + "emoticons": [ + "🌗" + ] + }, + { + "keyword": "flag_bq", + "emoticons": [ + "🇧🇶" + ] + }, + { + "keyword": "kite", + "emoticons": [ + "🪁" + ] + }, + { + "keyword": "two-hump camel", + "emoticons": [ + "🐫" + ] + }, + { + "keyword": "arrival", + "emoticons": [ + "🛬" + ] + }, + { + "keyword": "play pause button", + "emoticons": [ + "⏯" + ] + }, + { + "keyword": "high-heeled shoe", + "emoticons": [ + "👠" + ] + }, + { + "keyword": "dainty", + "emoticons": [ + "🤤", + "😋" + ] + }, + { + "keyword": "south korea", + "emoticons": [ + "🇰🇷" + ] + }, + { + "keyword": "fuel", + "emoticons": [ + "⛽" + ] + }, + { + "keyword": "family mwg", + "emoticons": [ + "👨‍👩‍👧" + ] + }, + { + "keyword": "desert", + "emoticons": [ + "🏝", + "🏜" + ] + }, + { + "keyword": "woman singer", + "emoticons": [ + "👩‍🎤" + ] + }, + { + "keyword": "green circle", + "emoticons": [ + "🟢" + ] + }, + { + "keyword": "cards", + "emoticons": [ + "♠", + "🃏", + "♣", + "♥", + "♦" + ] + }, + { + "keyword": "bull's eye", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "mexican", + "emoticons": [ + "🥑", + "🥙", + "🇲🇽", + "🤠", + "🌶", + "🌮", + "🌵", + "🌯" + ] + }, + { + "keyword": "hehe", + "emoticons": [ + "🤭" + ] + }, + { + "keyword": "family_mwgb", + "emoticons": [ + "👨‍👩‍👧‍👦" + ] + }, + { + "keyword": "wc", + "emoticons": [ + "🚾" + ] + }, + { + "keyword": "tree", + "emoticons": [ + "🌳", + "🌲", + "🌴" + ] + }, + { + "keyword": "baggage claim", + "emoticons": [ + "🛄" + ] + }, + { + "keyword": "woman gesturing ok", + "emoticons": [ + "🙆‍♀" + ] + }, + { + "keyword": "primate", + "emoticons": [ + "🐵", + "🐒" + ] + }, + { + "keyword": "winking", + "emoticons": [ + "😉", + "😜" + ] + }, + { + "keyword": "tram car", + "emoticons": [ + "🚋" + ] + }, + { + "keyword": "pray", + "emoticons": [ + "🤲", + "🧎", + "🧎‍♂", + "📿", + "🧎‍♀", + "🙏" + ] + }, + { + "keyword": "prohibited", + "emoticons": [ + "🈲", + "🚫" + ] + }, + { + "keyword": "sunglasses", + "emoticons": [ + "😎" + ] + }, + { + "keyword": "competition", + "emoticons": [ + "🏆", + "🥈", + "🎽", + "🥇", + "🏅", + "🥉" + ] + }, + { + "keyword": "child", + "emoticons": [ + "🍼", + "👧", + "🤰", + "👶", + "🧒", + "👼", + "🤱", + "👦" + ] + }, + { + "keyword": "romantic", + "emoticons": [ + "🥰" + ] + }, + { + "keyword": "weapon", + "emoticons": [ + "🔫", + "🗡", + "🏹", + "🛡", + "⚔" + ] + }, + { + "keyword": "sign on post", + "emoticons": [ + "🪧" + ] + }, + { + "keyword": "lifter", + "emoticons": [ + "🏋‍♀", + "🏋‍♂", + "🏋" + ] + }, + { + "keyword": "surfing", + "emoticons": [ + "🏄", + "🏄‍♂", + "🏄‍♀" + ] + }, + { + "keyword": "old", + "emoticons": [ + "👴", + "🧓", + "👨‍🦳", + "👵", + "👩‍🦳" + ] + }, + { + "keyword": "nib", + "emoticons": [ + "✒", + "🖋" + ] + }, + { + "keyword": "hurricane", + "emoticons": [ + "🌪", + "🌀" + ] + }, + { + "keyword": "video camera", + "emoticons": [ + "📹" + ] + }, + { + "keyword": "european castle", + "emoticons": [ + "🏰" + ] + }, + { + "keyword": "cocktail glass", + "emoticons": [ + "🍸" + ] + }, + { + "keyword": "cool", + "emoticons": [ + "🤘", + "😎", + "🗿", + "🤟", + "👊" + ] + }, + { + "keyword": "see", + "emoticons": [ + "🫣", + "🙈", + "👀", + "👁‍🗨", + "👁" + ] + }, + { + "keyword": "train", + "emoticons": [ + "🚉", + "🚇", + "🚆", + "🚂", + "🚃", + "🚞", + "🚊", + "🚝", + "🛤", + "🚅", + "🚈", + "🚄" + ] + }, + { + "keyword": "gambia", + "emoticons": [ + "🇬🇲" + ] + }, + { + "keyword": "speech", + "emoticons": [ + "🗯", + "💭", + "💬" + ] + }, + { + "keyword": "wish", + "emoticons": [ + "🧞‍♀" + ] + }, + { + "keyword": "silhouette", + "emoticons": [ + "👤", + "👥", + "🗣" + ] + }, + { + "keyword": "flag_al", + "emoticons": [ + "🇦🇱" + ] + }, + { + "keyword": "flag_tw", + "emoticons": [ + "🇹🇼" + ] + }, + { + "keyword": "gibraltar", + "emoticons": [ + "🇬🇮" + ] + }, + { + "keyword": "cv", + "emoticons": [ + "🇨🇻" + ] + }, + { + "keyword": "camcorder", + "emoticons": [ + "📹" + ] + }, + { + "keyword": "waxing gibbous moon", + "emoticons": [ + "🌔" + ] + }, + { + "keyword": "hurray", + "emoticons": [ + "👏", + "🙌" + ] + }, + { + "keyword": "floppy", + "emoticons": [ + "💾" + ] + }, + { + "keyword": "card", + "emoticons": [ + "♦", + "💳", + "🎴", + "🃏", + "♠", + "🀄", + "♥", + "♣" + ] + }, + { + "keyword": "skull", + "emoticons": [ + "☠", + "💀" + ] + }, + { + "keyword": "passport", + "emoticons": [ + "🛂" + ] + }, + { + "keyword": "flex", + "emoticons": [ + "💪" + ] + }, + { + "keyword": "boring", + "emoticons": [ + "😑", + "😒", + "🥱", + "😐", + "😴" + ] + }, + { + "keyword": "cameroon", + "emoticons": [ + "🇨🇲" + ] + }, + { + "keyword": "kneel", + "emoticons": [ + "🧎‍♂", + "🧎‍♀", + "🧎" + ] + }, + { + "keyword": "artist palette", + "emoticons": [ + "🎨" + ] + }, + { + "keyword": "ponder", + "emoticons": [ + "😮", + "😯", + "😲" + ] + }, + { + "keyword": "hi", + "emoticons": [ + "✋", + "👋", + "🤙", + "🖖", + "🤚" + ] + }, + { + "keyword": "person shrugging", + "emoticons": [ + "🤷‍♀", + "🤷‍♂" + ] + }, + { + "keyword": "cop", + "emoticons": [ + "👮‍♀", + "👮", + "👮‍♂" + ] + }, + { + "keyword": "flushed", + "emoticons": [ + "😳", + "☺", + "😊" + ] + }, + { + "keyword": "dentist", + "emoticons": [ + "🦷" + ] + }, + { + "keyword": "oppo", + "emoticons": [ + "📱" + ] + }, + { + "keyword": "lady beetle", + "emoticons": [ + "🐞" + ] + }, + { + "keyword": "british virgin islands", + "emoticons": [ + "🇻🇬" + ] + }, + { + "keyword": "earth", + "emoticons": [ + "🌎", + "🌐", + "🌍", + "🌏" + ] + }, + { + "keyword": "bleh", + "emoticons": [ + "🤮" + ] + }, + { + "keyword": "fond", + "emoticons": [ + "❤" + ] + }, + { + "keyword": "artist", + "emoticons": [ + "👨‍🎤", + "👨‍🎨", + "🧑‍🎨", + "👩‍🎨", + "🎨", + "🧑‍🎤", + "👩‍🎤" + ] + }, + { + "keyword": "clock530", + "emoticons": [ + "🕠" + ] + }, + { + "keyword": "luggage", + "emoticons": [ + "🛅", + "🧳" + ] + }, + { + "keyword": "plug", + "emoticons": [ + "🔌" + ] + }, + { + "keyword": "ksa", + "emoticons": [ + "🇸🇦" + ] + }, + { + "keyword": "frog face", + "emoticons": [ + "🐸" + ] + }, + { + "keyword": "worker", + "emoticons": [ + "👨‍💼", + "🛠", + "👷‍♀", + "💻", + "🧑‍🏭", + "👷‍♂", + "👷", + "👨‍🏭", + "👩‍💼", + "✍", + "👩‍🏭", + "💼", + "👨‍🔧", + "🐜", + "👩‍🔧" + ] + }, + { + "keyword": "unicorn", + "emoticons": [ + "🦄" + ] + }, + { + "keyword": "sk", + "emoticons": [ + "🇸🇰" + ] + }, + { + "keyword": "amphora", + "emoticons": [ + "🏺" + ] + }, + { + "keyword": "flag_aq", + "emoticons": [ + "🇦🇶" + ] + }, + { + "keyword": "spin", + "emoticons": [ + "🌀" + ] + }, + { + "keyword": "late", + "emoticons": [ + "🏃", + "⌚", + "🏃‍♀", + "🏃‍♂", + "⏰" + ] + }, + { + "keyword": "honeybee", + "emoticons": [ + "🐝" + ] + }, + { + "keyword": "albania", + "emoticons": [ + "🇦🇱" + ] + }, + { + "keyword": "macao", + "emoticons": [ + "🇲🇴" + ] + }, + { + "keyword": "ocean wave", + "emoticons": [ + "🌊" + ] + }, + { + "keyword": "shirt", + "emoticons": [ + "👔", + "👚", + "🎽" + ] + }, + { + "keyword": "caribbean netherlands", + "emoticons": [ + "🇧🇶" + ] + }, + { + "keyword": "macedonia", + "emoticons": [ + "🇲🇰" + ] + }, + { + "keyword": "person frowning", + "emoticons": [ + "🙍‍♂", + "🙍‍♀" + ] + }, + { + "keyword": "ve venezuela", + "emoticons": [ + "🇻🇪" + ] + }, + { + "keyword": "drumstick", + "emoticons": [ + "🍗" + ] + }, + { + "keyword": "drugs", + "emoticons": [ + "🥃", + "🍄", + "💊", + "🚬", + "🧪", + "🔞", + "🍷", + "💉", + "🥂", + "🍺", + "🍻", + "🍸", + "☕" + ] + }, + { + "keyword": "nauseated face", + "emoticons": [ + "🤢" + ] + }, + { + "keyword": "holiday", + "emoticons": [ + "🏝", + "🛳", + "🏖", + "🚗", + "✈", + "🚌", + "🚅", + "🚕", + "🧳", + "⛱" + ] + }, + { + "keyword": "skateboard", + "emoticons": [ + "🛹" + ] + }, + { + "keyword": "infant", + "emoticons": [ + "🤰", + "🤱", + "🍼", + "👼" + ] + }, + { + "keyword": "north korea", + "emoticons": [ + "🇰🇵" + ] + }, + { + "keyword": "question", + "emoticons": [ + "🙋‍♀", + "🙋‍♂", + "❔", + "🙋", + "❓" + ] + }, + { + "keyword": "man", + "emoticons": [ + "👨‍🦳", + "🧔", + "👨‍🦱", + "🧑", + "👨‍🦰", + "👨‍🦲", + "🤵‍♂", + "🧍‍♂", + "👨", + "🤵", + "👱‍♂", + "🧔‍♂" + ] + }, + { + "keyword": "turtle", + "emoticons": [ + "🐢" + ] + }, + { + "keyword": "martial arts uniform", + "emoticons": [ + "🥋" + ] + }, + { + "keyword": "play button", + "emoticons": [ + "▶" + ] + }, + { + "keyword": "beast mode", + "emoticons": [ + "🦍", + "🏋‍♀", + "🤼‍♂", + "🤼‍♀", + "🏋‍♂" + ] + }, + { + "keyword": "kissing smiling eyes", + "emoticons": [ + "😙" + ] + }, + { + "keyword": "brown", + "emoticons": [ + "🟫", + "🟤" + ] + }, + { + "keyword": "railway", + "emoticons": [ + "🚇", + "🚅", + "🚃", + "🚊", + "🚟", + "🚝", + "🚈", + "🚄", + "🚆", + "🚉" + ] + }, + { + "keyword": "vegetable", + "emoticons": [ + "🥬", + "🥑", + "🥔", + "🥜", + "🌶", + "🌽", + "🥒", + "🍄", + "🥕", + "🍆", + "🌰", + "🥦" + ] + }, + { + "keyword": "adult", + "emoticons": [ + "👅", + "🔞", + "🫦", + "🫣" + ] + }, + { + "keyword": "eject button", + "emoticons": [ + "⏏" + ] + }, + { + "keyword": "runner", + "emoticons": [ + "🏃‍♂", + "🏃‍♀" + ] + }, + { + "keyword": "amazing", + "emoticons": [ + "🤩" + ] + }, + { + "keyword": "alien monster", + "emoticons": [ + "👾" + ] + }, + { + "keyword": "mask", + "emoticons": [ + "🤿", + "😷", + "👺" + ] + }, + { + "keyword": "bow", + "emoticons": [ + "🙇", + "🏹", + "🎻" + ] + }, + { + "keyword": "wonder", + "emoticons": [ + "😲", + "😯", + "😮" + ] + }, + { + "keyword": "roller skate", + "emoticons": [ + "🛼" + ] + }, + { + "keyword": "input latin lowercase", + "emoticons": [ + "🔡" + ] + }, + { + "keyword": "atoms", + "emoticons": [ + "⚛" + ] + }, + { + "keyword": "villain", + "emoticons": [ + "🦹‍♀", + "🦹‍♂", + "🦹" + ] + }, + { + "keyword": "cryptocurrency", + "emoticons": [ + "💰", + "💸", + "💵", + "💲" + ] + }, + { + "keyword": "cuckoo", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "folders", + "emoticons": [ + "🗂" + ] + }, + { + "keyword": "dusk", + "emoticons": [ + "🌆", + "🌇" + ] + }, + { + "keyword": "citrus", + "emoticons": [ + "🍊", + "🍋" + ] + }, + { + "keyword": "heart above dot", + "emoticons": [ + "❣" + ] + }, + { + "keyword": "juggling", + "emoticons": [ + "🤹‍♂", + "🤹", + "🤹‍♀" + ] + }, + { + "keyword": "recycling symbol", + "emoticons": [ + "♻" + ] + }, + { + "keyword": "merman", + "emoticons": [ + "🧜‍♂" + ] + }, + { + "keyword": "yo", + "emoticons": [ + "🤘", + "🤟" + ] + }, + { + "keyword": "russian federation", + "emoticons": [ + "🇷🇺" + ] + }, + { + "keyword": "japanese not free of charge button", + "emoticons": [ + "🈶" + ] + }, + { + "keyword": "finished", + "emoticons": [ + "👍", + "👌", + "✔", + "☑" + ] + }, + { + "keyword": "heavy large circle", + "emoticons": [ + "⭕" + ] + }, + { + "keyword": "bride", + "emoticons": [ + "👰" + ] + }, + { + "keyword": "xk", + "emoticons": [ + "🇽🇰" + ] + }, + { + "keyword": "egypt", + "emoticons": [ + "🇪🇬" + ] + }, + { + "keyword": "glass", + "emoticons": [ + "🍷", + "🫙", + "🥃", + "🥂", + "🍸" + ] + }, + { + "keyword": "sound", + "emoticons": [ + "🔉", + "🎧", + "👂" + ] + }, + { + "keyword": "lv", + "emoticons": [ + "🇱🇻" + ] + }, + { + "keyword": "asterisk symbol", + "emoticons": [ + "✳", + "*⃣" + ] + }, + { + "keyword": "military medal", + "emoticons": [ + "🎖" + ] + }, + { + "keyword": "tk", + "emoticons": [ + "🇹🇰" + ] + }, + { + "keyword": "observe", + "emoticons": [ + "👁‍🗨", + "👀", + "👁" + ] + }, + { + "keyword": "turkmenistan", + "emoticons": [ + "🇹🇲" + ] + }, + { + "keyword": "water", + "emoticons": [ + "💧", + "🚰" + ] + }, + { + "keyword": "phone", + "emoticons": [ + "📟", + "🤳", + "☎", + "📠", + "📞", + "📱", + "📲" + ] + }, + { + "keyword": "(=", + "emoticons": [ + "🙃" + ] + }, + { + "keyword": "venom", + "emoticons": [ + "🦂", + "🦟", + "🐍", + "🕷" + ] + }, + { + "keyword": "insect", + "emoticons": [ + "🪰", + "🦂", + "🦟", + "🕷", + "🐜", + "🦗", + "🦋", + "🐞", + "🐝", + "🕸", + "🪳", + "🐛" + ] + }, + { + "keyword": "apple", + "emoticons": [ + "🍏", + "🍎" + ] + }, + { + "keyword": "lt", + "emoticons": [ + "🇱🇹" + ] + }, + { + "keyword": "fishing pole", + "emoticons": [ + "🎣" + ] + }, + { + "keyword": "white sun with small cloud", + "emoticons": [ + "🌤" + ] + }, + { + "keyword": "lorry", + "emoticons": [ + "🚛", + "🚚" + ] + }, + { + "keyword": "seal", + "emoticons": [ + "🦭" + ] + }, + { + "keyword": "woman mechanic", + "emoticons": [ + "👩‍🔧" + ] + }, + { + "keyword": "flag_yt", + "emoticons": [ + "🇾🇹" + ] + }, + { + "keyword": "cp", + "emoticons": [ + "🇫🇷" + ] + }, + { + "keyword": "camera", + "emoticons": [ + "📸", + "📷", + "📼", + "📹", + "🎥" + ] + }, + { + "keyword": "flag_fi", + "emoticons": [ + "🇫🇮" + ] + }, + { + "keyword": "lmao", + "emoticons": [ + "🤣", + "😆", + "😄", + "😂", + "😅" + ] + }, + { + "keyword": "goofy face", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "snooze", + "emoticons": [ + "😪", + "😴" + ] + }, + { + "keyword": "za", + "emoticons": [ + "🇿🇦" + ] + }, + { + "keyword": "woman guard", + "emoticons": [ + "💂‍♂" + ] + }, + { + "keyword": "correct", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "pointe", + "emoticons": [ + "🩰" + ] + }, + { + "keyword": "standing", + "emoticons": [ + "🧍‍♂", + "🧍" + ] + }, + { + "keyword": "rotating light", + "emoticons": [ + "🚨" + ] + }, + { + "keyword": "blue eye", + "emoticons": [ + "🧿" + ] + }, + { + "keyword": "coke", + "emoticons": [ + "🥤" + ] + }, + { + "keyword": "snow", + "emoticons": [ + "❄", + "☃" + ] + }, + { + "keyword": "bouncing", + "emoticons": [ + "⛹", + "⛹‍♂", + "⛹‍♀" + ] + }, + { + "keyword": "diamonds", + "emoticons": [ + "♦" + ] + }, + { + "keyword": "yoga", + "emoticons": [ + "🧘", + "🧘‍♂", + "🧘‍♀" + ] + }, + { + "keyword": "man gesturing no", + "emoticons": [ + "🙅‍♂" + ] + }, + { + "keyword": "elephant", + "emoticons": [ + "🐘" + ] + }, + { + "keyword": "mailbox", + "emoticons": [ + "📪", + "📫", + "📬", + "📭" + ] + }, + { + "keyword": "cancer", + "emoticons": [ + "♋" + ] + }, + { + "keyword": "guernsey", + "emoticons": [ + "🇬🇬" + ] + }, + { + "keyword": "scandinavian cross", + "emoticons": [ + "🇩🇰", + "🇦🇽", + "🇫🇮", + "🇮🇸", + "🇸🇪", + "🇳🇴", + "🇫🇴" + ] + }, + { + "keyword": "explosion", + "emoticons": [ + "💥", + "🧨", + "💣" + ] + }, + { + "keyword": "covid19", + "emoticons": [ + "🦠" + ] + }, + { + "keyword": "goal", + "emoticons": [ + "🥅" + ] + }, + { + "keyword": "officer", + "emoticons": [ + "👮", + "👮‍♂", + "👮‍♀" + ] + }, + { + "keyword": "bitter", + "emoticons": [ + "😖", + "😣", + "😩", + "😔", + "☹", + "😫" + ] + }, + { + "keyword": "chef", + "emoticons": [ + "👨‍🍳", + "🧑‍🍳" + ] + }, + { + "keyword": "station", + "emoticons": [ + "🚉", + "🚏" + ] + }, + { + "keyword": "profile", + "emoticons": [ + "👤" + ] + }, + { + "keyword": "poke", + "emoticons": [ + "👉", + "🫵" + ] + }, + { + "keyword": "man lifting weights", + "emoticons": [ + "🏋‍♂" + ] + }, + { + "keyword": "motorised wheelchair", + "emoticons": [ + "🦼" + ] + }, + { + "keyword": "cart", + "emoticons": [ + "🛒" + ] + }, + { + "keyword": "open book", + "emoticons": [ + "📖" + ] + }, + { + "keyword": "fairy", + "emoticons": [ + "🧚‍♂", + "🧚‍♀", + "🧚" + ] + }, + { + "keyword": "bull trap", + "emoticons": [ + "〽" + ] + }, + { + "keyword": "croco", + "emoticons": [ + "🐊" + ] + }, + { + "keyword": "yay", + "emoticons": [ + "😄", + "🎉", + "😁" + ] + }, + { + "keyword": "heels", + "emoticons": [ + "👡" + ] + }, + { + "keyword": "flag_aw", + "emoticons": [ + "🇦🇼" + ] + }, + { + "keyword": "unamused", + "emoticons": [ + "😒" + ] + }, + { + "keyword": "white square button", + "emoticons": [ + "🔳" + ] + }, + { + "keyword": "fk", + "emoticons": [ + "🇫🇰" + ] + }, + { + "keyword": "hare", + "emoticons": [ + "🐰", + "🐇" + ] + }, + { + "keyword": "cartwheel", + "emoticons": [ + "🤸", + "🤸‍♂", + "🤸‍♀" + ] + }, + { + "keyword": "uz", + "emoticons": [ + "🇺🇿" + ] + }, + { + "keyword": "flag_re", + "emoticons": [ + "🇷🇪" + ] + }, + { + "keyword": "italy", + "emoticons": [ + "🍝", + "🍕", + "🇮🇹" + ] + }, + { + "keyword": "hairdresser", + "emoticons": [ + "💈", + "💇" + ] + }, + { + "keyword": "mountain", + "emoticons": [ + "⛰", + "🏔", + "🌄", + "🌋", + "🗻" + ] + }, + { + "keyword": "doge", + "emoticons": [ + "🐕", + "🐶", + "🐩" + ] + }, + { + "keyword": "highway", + "emoticons": [ + "🛣" + ] + }, + { + "keyword": "water polo", + "emoticons": [ + "🤽‍♀", + "🤽‍♂", + "🤽" + ] + }, + { + "keyword": "bathroom", + "emoticons": [ + "🧽", + "🛀", + "🧼", + "🚽", + "🛁", + "🚿" + ] + }, + { + "keyword": "roasted", + "emoticons": [ + "🍠" + ] + }, + { + "keyword": "champion", + "emoticons": [ + "🥉", + "🥇", + "🥈", + "🎖", + "🏅", + "🏆" + ] + }, + { + "keyword": "light", + "emoticons": [ + "🕯", + "🏮", + "🔦", + "💡" + ] + }, + { + "keyword": "climbing", + "emoticons": [ + "🧗‍♂", + "🧗", + "🧗‍♀" + ] + }, + { + "keyword": "woman judge", + "emoticons": [ + "👩‍⚖" + ] + }, + { + "keyword": "face with steam from nose", + "emoticons": [ + "😤" + ] + }, + { + "keyword": "right palm", + "emoticons": [ + "🫴" + ] + }, + { + "keyword": "blossom", + "emoticons": [ + "🌼", + "🌸" + ] + }, + { + "keyword": "nile", + "emoticons": [ + "🐊" + ] + }, + { + "keyword": "dancers", + "emoticons": [ + "👯‍♀" + ] + }, + { + "keyword": "\u003c-", + "emoticons": [ + "🔚", + "⬅" + ] + }, + { + "keyword": "worm", + "emoticons": [ + "🪱" + ] + }, + { + "keyword": "chicken leg", + "emoticons": [ + "🍗" + ] + }, + { + "keyword": "hamster", + "emoticons": [ + "🐹" + ] + }, + { + "keyword": "oups", + "emoticons": [ + "😕" + ] + }, + { + "keyword": "magnifier", + "emoticons": [ + "🔍", + "🔎" + ] + }, + { + "keyword": "legal", + "emoticons": [ + "⚖" + ] + }, + { + "keyword": "drinks", + "emoticons": [ + "🥃", + "🍸", + "🍺", + "🍻", + "🍾", + "🍵", + "🍶", + "🥛", + "☕", + "🥂", + "🍷", + "🍼", + "🍹", + "🥤" + ] + }, + { + "keyword": "scientist", + "emoticons": [ + "👨‍🔬", + "🥼", + "🧑‍🔬", + "👩‍🔬" + ] + }, + { + "keyword": "one-thirty", + "emoticons": [ + "🕜" + ] + }, + { + "keyword": "baguette", + "emoticons": [ + "🥖" + ] + }, + { + "keyword": "music", + "emoticons": [ + "🎹", + "🎷", + "🎵", + "🎸", + "🎧", + "🎺", + "🎶", + "🎼", + "🎻" + ] + }, + { + "keyword": "electricity", + "emoticons": [ + "⚡" + ] + }, + { + "keyword": "new caledonia", + "emoticons": [ + "🇳🇨" + ] + }, + { + "keyword": "sun with face", + "emoticons": [ + "🌞" + ] + }, + { + "keyword": "jazz", + "emoticons": [ + "🎺", + "🎷", + "🎸", + "🥁" + ] + }, + { + "keyword": "graduation cap", + "emoticons": [ + "🎓" + ] + }, + { + "keyword": "welp", + "emoticons": [ + "💁‍♀", + "💁‍♂" + ] + }, + { + "keyword": "head", + "emoticons": [ + "💆‍♀", + "🗿", + "🗣", + "💆‍♂" + ] + }, + { + "keyword": "drunk", + "emoticons": [ + "🤮", + "🥴" + ] + }, + { + "keyword": "european post office", + "emoticons": [ + "🏤" + ] + }, + { + "keyword": "er", + "emoticons": [ + "🇪🇷" + ] + }, + { + "keyword": "plain", + "emoticons": [ + "😐", + "😑" + ] + }, + { + "keyword": "ml", + "emoticons": [ + "🇲🇱" + ] + }, + { + "keyword": "you", + "emoticons": [ + "🫵" + ] + }, + { + "keyword": "são tome \u0026 principe", + "emoticons": [ + "🇸🇹" + ] + }, + { + "keyword": "elevator", + "emoticons": [ + "🛗" + ] + }, + { + "keyword": "jeans", + "emoticons": [ + "👖" + ] + }, + { + "keyword": "wand", + "emoticons": [ + "🪄" + ] + }, + { + "keyword": "spectacles", + "emoticons": [ + "👓" + ] + }, + { + "keyword": "design", + "emoticons": [ + "👨‍🎨", + "🎨", + "🖌", + "👨‍💻", + "🧑‍🎨", + "🧑‍💻", + "👩‍💻", + "👩‍🎨" + ] + }, + { + "keyword": "pw", + "emoticons": [ + "🇵🇼" + ] + }, + { + "keyword": "deny", + "emoticons": [ + "🙅‍♂" + ] + }, + { + "keyword": "red triangle pointed down", + "emoticons": [ + "🔻" + ] + }, + { + "keyword": "broken heart", + "emoticons": [ + "💔" + ] + }, + { + "keyword": "beg", + "emoticons": [ + "🙏", + "🤲", + "🫴" + ] + }, + { + "keyword": "dividers", + "emoticons": [ + "🗂" + ] + }, + { + "keyword": "sun", + "emoticons": [ + "🌤", + "🌥", + "☀", + "⛅", + "🌦", + "🌞" + ] + }, + { + "keyword": "trouble", + "emoticons": [ + "🤨", + "👿", + "😦", + "😟" + ] + }, + { + "keyword": "woman with hijab", + "emoticons": [ + "🧕" + ] + }, + { + "keyword": "kissing closed eyes", + "emoticons": [ + "😚" + ] + }, + { + "keyword": "guide dog", + "emoticons": [ + "🦮" + ] + }, + { + "keyword": "note down", + "emoticons": [ + "✍" + ] + }, + { + "keyword": "brown heart", + "emoticons": [ + "🤎" + ] + }, + { + "keyword": "christmas island", + "emoticons": [ + "🇨🇽" + ] + }, + { + "keyword": "lb", + "emoticons": [ + "🇱🇧" + ] + }, + { + "keyword": "bus stop", + "emoticons": [ + "🚏" + ] + }, + { + "keyword": "chat", + "emoticons": [ + "🗯", + "💭", + "👁", + "💬" + ] + }, + { + "keyword": "good luck", + "emoticons": [ + "👍", + "😉" + ] + }, + { + "keyword": "pup", + "emoticons": [ + "🐶" + ] + }, + { + "keyword": "flag_az", + "emoticons": [ + "🇦🇿" + ] + }, + { + "keyword": "torch", + "emoticons": [ + "🔦" + ] + }, + { + "keyword": "smart", + "emoticons": [ + "🧠" + ] + }, + { + "keyword": "hah", + "emoticons": [ + "😄" + ] + }, + { + "keyword": "hey", + "emoticons": [ + "👋", + "😉" + ] + }, + { + "keyword": "emirates", + "emoticons": [ + "🇦🇪" + ] + }, + { + "keyword": "flag_tr", + "emoticons": [ + "🇹🇷" + ] + }, + { + "keyword": "ni", + "emoticons": [ + "🇳🇮" + ] + }, + { + "keyword": "rosette", + "emoticons": [ + "🏵" + ] + }, + { + "keyword": "skeleton", + "emoticons": [ + "☠", + "💀", + "🦴" + ] + }, + { + "keyword": "first quarter moon", + "emoticons": [ + "🌓" + ] + }, + { + "keyword": "europe", + "emoticons": [ + "🇪🇺", + "🌍" + ] + }, + { + "keyword": "family_wwbb", + "emoticons": [ + "👩‍👩‍👦‍👦" + ] + }, + { + "keyword": "bricks", + "emoticons": [ + "🧱" + ] + }, + { + "keyword": "wolf face", + "emoticons": [ + "🐺" + ] + }, + { + "keyword": "top place", + "emoticons": [ + "🥈", + "🥇" + ] + }, + { + "keyword": "pool 8 ball", + "emoticons": [ + "🎱" + ] + }, + { + "keyword": "document", + "emoticons": [ + "📄", + "📃" + ] + }, + { + "keyword": "white small square", + "emoticons": [ + "▫" + ] + }, + { + "keyword": "alligator", + "emoticons": [ + "🐊" + ] + }, + { + "keyword": "flame", + "emoticons": [ + "❤‍🔥", + "🔥" + ] + }, + { + "keyword": "later", + "emoticons": [ + "🦥" + ] + }, + { + "keyword": "daughter", + "emoticons": [ + "👨‍👨‍👧‍👦", + "👨‍👩‍👧", + "👨‍👧‍👦", + "👨‍👩‍👧‍👦", + "👩‍👧", + "👩‍👩‍👧", + "👨‍👨‍👧", + "👩‍👧‍👧", + "👨‍👧", + "👨‍👩‍👧‍👧", + "👩‍👧‍👦", + "👩‍👩‍👧‍👦", + "👨‍👧‍👧", + "👨‍👨‍👧‍👧", + "👩‍👩‍👧‍👧" + ] + }, + { + "keyword": "sudan", + "emoticons": [ + "🇸🇩" + ] + }, + { + "keyword": "gift heart", + "emoticons": [ + "💝" + ] + }, + { + "keyword": "peafowl", + "emoticons": [ + "🦚" + ] + }, + { + "keyword": "flag_je", + "emoticons": [ + "🇯🇪" + ] + }, + { + "keyword": "silly", + "emoticons": [ + "🙃" + ] + }, + { + "keyword": "couple_mm", + "emoticons": [ + "👨‍❤‍👨" + ] + }, + { + "keyword": "bowing", + "emoticons": [ + "🙇", + "🙇‍♂", + "🙇‍♀" + ] + }, + { + "keyword": "volcano", + "emoticons": [ + "🌋" + ] + }, + { + "keyword": "si", + "emoticons": [ + "🇸🇮" + ] + }, + { + "keyword": "womens", + "emoticons": [ + "🚺" + ] + }, + { + "keyword": "helmet", + "emoticons": [ + "👷‍♀", + "👨‍🚀", + "👩‍🚀", + "👷‍♂", + "⛑", + "👨‍🚒" + ] + }, + { + "keyword": "film projector", + "emoticons": [ + "📽" + ] + }, + { + "keyword": "floppy disk", + "emoticons": [ + "💾" + ] + }, + { + "keyword": "fitness", + "emoticons": [ + "🧘‍♀", + "💪", + "🤸", + "🧘", + "🏃‍♀", + "🚴", + "🏃", + "🏋", + "👟", + "🧘‍♂", + "🏋‍♀" + ] + }, + { + "keyword": "assistance", + "emoticons": [ + "🦮" + ] + }, + { + "keyword": "blueberries", + "emoticons": [ + "🫐" + ] + }, + { + "keyword": "tunisia", + "emoticons": [ + "🇹🇳" + ] + }, + { + "keyword": "stonks", + "emoticons": [ + "📈" + ] + }, + { + "keyword": "flag_cz", + "emoticons": [ + "🇨🇿" + ] + }, + { + "keyword": "whatever", + "emoticons": [ + "🙄", + "😒", + "😏", + "🤷‍♂" + ] + }, + { + "keyword": "clipboard", + "emoticons": [ + "📋" + ] + }, + { + "keyword": "weight", + "emoticons": [ + "💪", + "🏋‍♂", + "🏋‍♀", + "⚖", + "🏋" + ] + }, + { + "keyword": "réunion", + "emoticons": [ + "🇷🇪" + ] + }, + { + "keyword": "dinosaur", + "emoticons": [ + "🦕", + "🦖" + ] + }, + { + "keyword": "lemon", + "emoticons": [ + "🍋" + ] + }, + { + "keyword": "bang", + "emoticons": [ + "💥", + "💣" + ] + }, + { + "keyword": "heartbreak", + "emoticons": [ + "😔", + "😣", + "💔", + "🥺", + "😭" + ] + }, + { + "keyword": "flag_lt", + "emoticons": [ + "🇱🇹" + ] + }, + { + "keyword": "person golfing", + "emoticons": [ + "🏌‍♂", + "🏌‍♀" + ] + }, + { + "keyword": "awesome", + "emoticons": [ + "😎", + "🤘", + "👍", + "🤩" + ] + }, + { + "keyword": "arrow heading up", + "emoticons": [ + "⤴" + ] + }, + { + "keyword": "person swimming", + "emoticons": [ + "🏊‍♀", + "🏊‍♂" + ] + }, + { + "keyword": "islam", + "emoticons": [ + "🕋", + "☪" + ] + }, + { + "keyword": "listen", + "emoticons": [ + "👂", + "🎧", + "🔉" + ] + }, + { + "keyword": "eleven-thirty", + "emoticons": [ + "🕦" + ] + }, + { + "keyword": "signal strength", + "emoticons": [ + "📶" + ] + }, + { + "keyword": "male", + "emoticons": [ + "🚹" + ] + }, + { + "keyword": "gi", + "emoticons": [ + "🇬🇮" + ] + }, + { + "keyword": "bro", + "emoticons": [ + "👊" + ] + }, + { + "keyword": "refrigerator", + "emoticons": [ + "🥶", + "🧊", + "❄" + ] + }, + { + "keyword": "bread", + "emoticons": [ + "🥪", + "🥖", + "🍞" + ] + }, + { + "keyword": "spiral", + "emoticons": [ + "🌀" + ] + }, + { + "keyword": "labrador", + "emoticons": [ + "🦮" + ] + }, + { + "keyword": "kiribati", + "emoticons": [ + "🇰🇮" + ] + }, + { + "keyword": "timer", + "emoticons": [ + "⏲" + ] + }, + { + "keyword": "pregnant person", + "emoticons": [ + "🫄" + ] + }, + { + "keyword": "transsexuality", + "emoticons": [ + "🏳‍⚧", + "⚧" + ] + }, + { + "keyword": "cunning", + "emoticons": [ + "😏", + "😼" + ] + }, + { + "keyword": "world map", + "emoticons": [ + "🗺" + ] + }, + { + "keyword": "application", + "emoticons": [ + "🈸" + ] + }, + { + "keyword": "intoxicated", + "emoticons": [ + "🥴" + ] + }, + { + "keyword": "ladder", + "emoticons": [ + "🪜" + ] + }, + { + "keyword": "mermaid", + "emoticons": [ + "🧜‍♀", + "🧜" + ] + }, + { + "keyword": "china", + "emoticons": [ + "🇨🇳" + ] + }, + { + "keyword": "up", + "emoticons": [ + "👆", + "🆙", + "🔼" + ] + }, + { + "keyword": "parents", + "emoticons": [ + "👨‍👨‍👧", + "👩‍👩‍👧‍👦", + "👩‍👩‍👦‍👦", + "👪", + "👩‍👦", + "👨‍👨‍👧‍👦", + "👨‍👩‍👧‍👦", + "👨‍👩‍👧", + "👩‍👩‍👧‍👧", + "👨‍👩‍👦‍👦", + "👨‍👨‍👧‍👧", + "👩‍👩‍👧", + "👩‍👩‍👦", + "👨", + "👨‍👨‍👦", + "👨‍👩‍👧‍👧", + "👨‍👨‍👦‍👦" + ] + }, + { + "keyword": "larva", + "emoticons": [ + "🐛" + ] + }, + { + "keyword": "watching", + "emoticons": [ + "👁", + "👀" + ] + }, + { + "keyword": "woman astronaut", + "emoticons": [ + "👩‍🚀" + ] + }, + { + "keyword": "last quarter moon with face", + "emoticons": [ + "🌜" + ] + }, + { + "keyword": "id card", + "emoticons": [ + "🪪" + ] + }, + { + "keyword": "bowl", + "emoticons": [ + "🥣", + "🍜" + ] + }, + { + "keyword": "woman cartwheeling", + "emoticons": [ + "🤸‍♀" + ] + }, + { + "keyword": "flag_tg", + "emoticons": [ + "🇹🇬" + ] + }, + { + "keyword": "pissed", + "emoticons": [ + "😡" + ] + }, + { + "keyword": "rabbit", + "emoticons": [ + "🐰", + "🐇" + ] + }, + { + "keyword": "boat", + "emoticons": [ + "⛵", + "🚣", + "🚣‍♂", + "🚣‍♀", + "🛶", + "🚤" + ] + }, + { + "keyword": "recharge", + "emoticons": [ + "🔋", + "🔌" + ] + }, + { + "keyword": "bunny girl", + "emoticons": [ + "👯", + "👯‍♀" + ] + }, + { + "keyword": "noo", + "emoticons": [ + "😰", + "🙅", + "🙅‍♀", + "🙅‍♂" + ] + }, + { + "keyword": "mneh", + "emoticons": [ + "😑" + ] + }, + { + "keyword": "blue heart", + "emoticons": [ + "💙" + ] + }, + { + "keyword": "flag_ro", + "emoticons": [ + "🇷🇴" + ] + }, + { + "keyword": "ck", + "emoticons": [ + "🇨🇰" + ] + }, + { + "keyword": "closed book", + "emoticons": [ + "📕" + ] + }, + { + "keyword": "kosovo", + "emoticons": [ + "🇽🇰" + ] + }, + { + "keyword": "lingerie", + "emoticons": [ + "👙" + ] + }, + { + "keyword": "inbox", + "emoticons": [ + "📫", + "📥", + "📬" + ] + }, + { + "keyword": "person gesturing no", + "emoticons": [ + "🙅‍♂", + "🙅‍♀" + ] + }, + { + "keyword": "songs", + "emoticons": [ + "🎺", + "🎻", + "🎸", + "🎵", + "🎼", + "🎹", + "🎶", + "🎧", + "🎷" + ] + }, + { + "keyword": "rainbow", + "emoticons": [ + "🌈", + "🏳‍🌈" + ] + }, + { + "keyword": "man walking", + "emoticons": [ + "🚶‍♂" + ] + }, + { + "keyword": "grey question mark", + "emoticons": [ + "❔" + ] + }, + { + "keyword": "trolling", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "dance", + "emoticons": [ + "🕺", + "💃" + ] + }, + { + "keyword": "chestnut", + "emoticons": [ + "🌰" + ] + }, + { + "keyword": "award", + "emoticons": [ + "🥈", + "🏆", + "🎖", + "🥇", + "🥉", + "🏅" + ] + }, + { + "keyword": "funeral urn", + "emoticons": [ + "⚱" + ] + }, + { + "keyword": "disk", + "emoticons": [ + "💽", + "💾", + "📀", + "💿" + ] + }, + { + "keyword": "it", + "emoticons": [ + "👩‍💻", + "🇮🇹", + "👨‍💻" + ] + }, + { + "keyword": "movie stub", + "emoticons": [ + "🎟" + ] + }, + { + "keyword": "insanity", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "paddles", + "emoticons": [ + "🚣" + ] + }, + { + "keyword": "golfing", + "emoticons": [ + "🏌‍♂", + "🏌‍♀" + ] + }, + { + "keyword": "flag_ps", + "emoticons": [ + "🇵🇸" + ] + }, + { + "keyword": "tears", + "emoticons": [ + "🤣", + "😥", + "😂", + "😢", + "😿", + "😹", + "😭", + "💦" + ] + }, + { + "keyword": "coconut tree", + "emoticons": [ + "🌴" + ] + }, + { + "keyword": "keycap digit zero", + "emoticons": [ + "0⃣" + ] + }, + { + "keyword": "baby", + "emoticons": [ + "👼", + "🍼", + "👶", + "🤰", + "🤱" + ] + }, + { + "keyword": "globe", + "emoticons": [ + "🌍", + "🌏", + "🌎", + "🌐" + ] + }, + { + "keyword": "dead", + "emoticons": [ + "☠", + "⚰", + "💀", + "😵", + "⚱" + ] + }, + { + "keyword": "rooster", + "emoticons": [ + "🐔", + "🐓" + ] + }, + { + "keyword": "clock630", + "emoticons": [ + "🕡" + ] + }, + { + "keyword": "spud", + "emoticons": [ + "🥔" + ] + }, + { + "keyword": "botswana", + "emoticons": [ + "🇧🇼" + ] + }, + { + "keyword": "sweat", + "emoticons": [ + "💦", + "😓", + "😅" + ] + }, + { + "keyword": "there", + "emoticons": [ + "👇", + "👆", + "🖕", + "👈", + "👉", + "☝" + ] + }, + { + "keyword": "broccoli", + "emoticons": [ + "🥦" + ] + }, + { + "keyword": "yes", + "emoticons": [ + "👌", + "👍", + "💪", + "🆗" + ] + }, + { + "keyword": "expressionless", + "emoticons": [ + "😐", + "😑" + ] + }, + { + "keyword": "think", + "emoticons": [ + "💭", + "🤔" + ] + }, + { + "keyword": "dog", + "emoticons": [ + "🦮", + "🐶", + "🐕", + "🐩", + "🐕‍🦺" + ] + }, + { + "keyword": "death", + "emoticons": [ + "☠", + "⚰" + ] + }, + { + "keyword": "barf", + "emoticons": [ + "🤢", + "🤮" + ] + }, + { + "keyword": "flag_cl", + "emoticons": [ + "🇨🇱" + ] + }, + { + "keyword": "clown face", + "emoticons": [ + "🤡" + ] + }, + { + "keyword": "parking", + "emoticons": [ + "🅿" + ] + }, + { + "keyword": "mic", + "emoticons": [ + "🎤", + "🎙" + ] + }, + { + "keyword": "twister", + "emoticons": [ + "🌪" + ] + }, + { + "keyword": "coat", + "emoticons": [ + "🧥", + "🥼" + ] + }, + { + "keyword": "fury", + "emoticons": [ + "🤬", + "😡" + ] + }, + { + "keyword": "ta", + "emoticons": [ + "🇹🇦" + ] + }, + { + "keyword": "shrug", + "emoticons": [ + "🤷‍♀", + "🤷", + "🤷‍♂" + ] + }, + { + "keyword": "aquarium", + "emoticons": [ + "🐟" + ] + }, + { + "keyword": "blue square", + "emoticons": [ + "🟦" + ] + }, + { + "keyword": "godzilla", + "emoticons": [ + "🦖" + ] + }, + { + "keyword": "left", + "emoticons": [ + "⬅", + "👈", + "🛅", + "◀" + ] + }, + { + "keyword": "cup", + "emoticons": [ + "🏆", + "🥤" + ] + }, + { + "keyword": "hero", + "emoticons": [ + "🦸", + "🦸‍♂", + "🦸‍♀" + ] + }, + { + "keyword": "letter", + "emoticons": [ + "💌", + "📩", + "📨", + "✉", + "✍" + ] + }, + { + "keyword": "baby chick", + "emoticons": [ + "🐤" + ] + }, + { + "keyword": "police", + "emoticons": [ + "👮", + "👮‍♂", + "👮‍♀" + ] + }, + { + "keyword": "belize", + "emoticons": [ + "🇧🇿" + ] + }, + { + "keyword": "flag_kz", + "emoticons": [ + "🇰🇿" + ] + }, + { + "keyword": "mecca", + "emoticons": [ + "🕋" + ] + }, + { + "keyword": "cow", + "emoticons": [ + "🐮", + "🐄" + ] + }, + { + "keyword": "menstruation", + "emoticons": [ + "🩸" + ] + }, + { + "keyword": "white question mark", + "emoticons": [ + "❔" + ] + }, + { + "keyword": "lc", + "emoticons": [ + "🇱🇨" + ] + }, + { + "keyword": "troll", + "emoticons": [ + "🧌", + "🤪" + ] + }, + { + "keyword": "nazar", + "emoticons": [ + "🧿" + ] + }, + { + "keyword": "shit", + "emoticons": [ + "💩" + ] + }, + { + "keyword": "clock1230", + "emoticons": [ + "🕧" + ] + }, + { + "keyword": "bomb", + "emoticons": [ + "💣" + ] + }, + { + "keyword": "man scientist", + "emoticons": [ + "👨‍🔬" + ] + }, + { + "keyword": "ph", + "emoticons": [ + "🇵🇭" + ] + }, + { + "keyword": "orangutan", + "emoticons": [ + "🦧" + ] + }, + { + "keyword": "hypnotoad", + "emoticons": [ + "😵‍💫" + ] + }, + { + "keyword": "huge", + "emoticons": [ + "🐘" + ] + }, + { + "keyword": "pie", + "emoticons": [ + "🥧" + ] + }, + { + "keyword": "connection", + "emoticons": [ + "🌐", + "📡", + "📶" + ] + }, + { + "keyword": "last quarter moon face", + "emoticons": [ + "🌜" + ] + }, + { + "keyword": "matryoshka", + "emoticons": [ + "🪆" + ] + }, + { + "keyword": "strong", + "emoticons": [ + "💪", + "🦍", + "🦾" + ] + }, + { + "keyword": "bucket", + "emoticons": [ + "🪣" + ] + }, + { + "keyword": "dash", + "emoticons": [ + "💨" + ] + }, + { + "keyword": "fluttered", + "emoticons": [ + "🍃" + ] + }, + { + "keyword": "flowering", + "emoticons": [ + "🌸", + "🌼" + ] + }, + { + "keyword": "well done", + "emoticons": [ + "👍", + "✌" + ] + }, + { + "keyword": "roasted sweet potato", + "emoticons": [ + "🍠" + ] + }, + { + "keyword": "woman artist", + "emoticons": [ + "👩‍🎨" + ] + }, + { + "keyword": "flag_pa", + "emoticons": [ + "🇵🇦" + ] + }, + { + "keyword": "king", + "emoticons": [ + "🤴", + "🦁", + "👑" + ] + }, + { + "keyword": "flag_nr", + "emoticons": [ + "🇳🇷" + ] + }, + { + "keyword": "oink", + "emoticons": [ + "🐷", + "🐖", + "🐽" + ] + }, + { + "keyword": "woman's sandal", + "emoticons": [ + "👡" + ] + }, + { + "keyword": "waving hand", + "emoticons": [ + "👋" + ] + }, + { + "keyword": "bye", + "emoticons": [ + "👋", + "🖖" + ] + }, + { + "keyword": "stone", + "emoticons": [ + "🥌" + ] + }, + { + "keyword": "trip", + "emoticons": [ + "🚕", + "🏖", + "🛳", + "🚗", + "🚅", + "⛱", + "🏝", + "✈", + "🚌" + ] + }, + { + "keyword": "ne", + "emoticons": [ + "🇳🇪" + ] + }, + { + "keyword": "greenland", + "emoticons": [ + "🇬🇱" + ] + }, + { + "keyword": "hope", + "emoticons": [ + "🕊", + "🤞" + ] + }, + { + "keyword": "stew", + "emoticons": [ + "🍲" + ] + }, + { + "keyword": "bue", + "emoticons": [ + "🤮" + ] + }, + { + "keyword": "banknote", + "emoticons": [ + "💴", + "💶", + "💸", + "💷", + "💵" + ] + }, + { + "keyword": "judge", + "emoticons": [ + "👩‍⚖", + "👨‍⚖", + "🧑‍⚖" + ] + }, + { + "keyword": "fork", + "emoticons": [ + "🍽", + "🍴" + ] + }, + { + "keyword": "snorkeling", + "emoticons": [ + "🤿" + ] + }, + { + "keyword": "pessimistic", + "emoticons": [ + "😔", + "😞", + "🥲", + "😢", + "🥺", + "😭", + "☹" + ] + }, + { + "keyword": "flying saucer", + "emoticons": [ + "🛸" + ] + }, + { + "keyword": "slow", + "emoticons": [ + "🐢", + "🐌" + ] + }, + { + "keyword": "fistbump", + "emoticons": [ + "🤛", + "👊", + "🤜" + ] + }, + { + "keyword": "italian", + "emoticons": [ + "🍦", + "🍕", + "🇮🇹", + "🛵", + "🍝" + ] + }, + { + "keyword": "mooncake", + "emoticons": [ + "🥮" + ] + }, + { + "keyword": "bubbles", + "emoticons": [ + "🫧" + ] + }, + { + "keyword": "wth", + "emoticons": [ + "🤨", + "😲", + "🧐", + "🫢" + ] + }, + { + "keyword": "down arrow", + "emoticons": [ + "⬇" + ] + }, + { + "keyword": "fast reverse button", + "emoticons": [ + "⏪" + ] + }, + { + "keyword": "hiking", + "emoticons": [ + "🥾", + "🧭", + "⛺", + "🎒", + "🏞", + "👣", + "🚶", + "⛰", + "🏕" + ] + }, + { + "keyword": "stopwatch", + "emoticons": [ + "⏱" + ] + }, + { + "keyword": "steam room", + "emoticons": [ + "🧖" + ] + }, + { + "keyword": "pretzel", + "emoticons": [ + "🥨" + ] + }, + { + "keyword": "8 ball", + "emoticons": [ + "🎱" + ] + }, + { + "keyword": "andorra", + "emoticons": [ + "🇦🇩" + ] + }, + { + "keyword": "helicopter", + "emoticons": [ + "🚁" + ] + }, + { + "keyword": "flag_ls", + "emoticons": [ + "🇱🇸" + ] + }, + { + "keyword": "squirrel", + "emoticons": [ + "🐿" + ] + }, + { + "keyword": "pudding", + "emoticons": [ + "🍮" + ] + }, + { + "keyword": "flag_cg", + "emoticons": [ + "🇨🇬" + ] + }, + { + "keyword": "magic", + "emoticons": [ + "✨", + "🧚‍♂", + "🪄", + "💫", + "🔮" + ] + }, + { + "keyword": "twinkle", + "emoticons": [ + "✨", + "🤩" + ] + }, + { + "keyword": "fj", + "emoticons": [ + "🇫🇯" + ] + }, + { + "keyword": "bento", + "emoticons": [ + "🍱" + ] + }, + { + "keyword": "man cartwhelling", + "emoticons": [ + "🤸‍♂" + ] + }, + { + "keyword": "lmfao", + "emoticons": [ + "😆", + "😅", + "🤣", + "😂", + "😄" + ] + }, + { + "keyword": "biohazard", + "emoticons": [ + "☣" + ] + }, + { + "keyword": "zzz", + "emoticons": [ + "🛏", + "🥱", + "🛌", + "😴", + "💤" + ] + }, + { + "keyword": "cockroach", + "emoticons": [ + "🪳" + ] + }, + { + "keyword": "co", + "emoticons": [ + "🇨🇴" + ] + }, + { + "keyword": "superwoman", + "emoticons": [ + "🦸‍♀" + ] + }, + { + "keyword": "mauritius", + "emoticons": [ + "🇲🇺" + ] + }, + { + "keyword": "medal", + "emoticons": [ + "🥉", + "🥇", + "🎖", + "🥈", + "🏅", + "🏆" + ] + }, + { + "keyword": "poll", + "emoticons": [ + "🎱", + "📊" + ] + }, + { + "keyword": "leaf", + "emoticons": [ + "🍁", + "🍃", + "🍀", + "🍂", + "☘", + "🌱", + "🌿" + ] + }, + { + "keyword": "scottish", + "emoticons": [ + "🏴󠁧󠁢󠁳󠁣󠁴󠁿" + ] + }, + { + "keyword": "leo", + "emoticons": [ + "♌" + ] + }, + { + "keyword": "lunar", + "emoticons": [ + "🌕", + "🌓", + "🌗", + "🌙", + "🌑", + "🌔", + "🌖", + "🌘", + "🌒" + ] + }, + { + "keyword": "palms", + "emoticons": [ + "👐", + "🤲" + ] + }, + { + "keyword": "moneybag", + "emoticons": [ + "💰" + ] + }, + { + "keyword": "mailbox with no mail", + "emoticons": [ + "📭" + ] + }, + { + "keyword": "speedboat", + "emoticons": [ + "🚤" + ] + }, + { + "keyword": "tropical fish", + "emoticons": [ + "🐠" + ] + }, + { + "keyword": "deaf man", + "emoticons": [ + "🧏‍♂" + ] + }, + { + "keyword": "togo", + "emoticons": [ + "🇹🇬" + ] + }, + { + "keyword": "cowboy hat face", + "emoticons": [ + "🤠" + ] + }, + { + "keyword": "superman", + "emoticons": [ + "🦸‍♂" + ] + }, + { + "keyword": "game", + "emoticons": [ + "🎴", + "🎲", + "♠", + "♥", + "🧩", + "♟", + "🃏", + "♣", + "♦" + ] + }, + { + "keyword": "gd", + "emoticons": [ + "🇬🇩" + ] + }, + { + "keyword": "sword", + "emoticons": [ + "⚔" + ] + }, + { + "keyword": "flag_mz", + "emoticons": [ + "🇲🇿" + ] + }, + { + "keyword": "card box", + "emoticons": [ + "🗃" + ] + }, + { + "keyword": "100", + "emoticons": [ + "💯" + ] + }, + { + "keyword": "smiley", + "emoticons": [ + "😃" + ] + }, + { + "keyword": "new orleans saints", + "emoticons": [ + "⚜" + ] + }, + { + "keyword": "woman running", + "emoticons": [ + "🏃‍♀" + ] + }, + { + "keyword": "bullseye", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "right fist", + "emoticons": [ + "🤜" + ] + }, + { + "keyword": "man guard", + "emoticons": [ + "💂‍♀" + ] + }, + { + "keyword": "blow", + "emoticons": [ + "🤯", + "💨" + ] + }, + { + "keyword": "step", + "emoticons": [ + "🦶" + ] + }, + { + "keyword": "dracula", + "emoticons": [ + "🧛" + ] + }, + { + "keyword": "gym", + "emoticons": [ + "🏋‍♂", + "🏃", + "🧘‍♂", + "👟", + "💪", + "🏃‍♀", + "🧘‍♀", + "🤸", + "🚴", + "🧘", + "🏋‍♀" + ] + }, + { + "keyword": "swimming", + "emoticons": [ + "🏊", + "🏊‍♂", + "🏊‍♀" + ] + }, + { + "keyword": "shrugging", + "emoticons": [ + "🤷‍♀", + "🤷‍♂", + "🤷" + ] + }, + { + "keyword": "flag_ge", + "emoticons": [ + "🇬🇪" + ] + }, + { + "keyword": "alien", + "emoticons": [ + "👽", + "👾" + ] + }, + { + "keyword": "baby bottle", + "emoticons": [ + "🍼" + ] + }, + { + "keyword": "cigarette", + "emoticons": [ + "🚬" + ] + }, + { + "keyword": "growing heart", + "emoticons": [ + "💗" + ] + }, + { + "keyword": "lion face", + "emoticons": [ + "🦁" + ] + }, + { + "keyword": "yoyo", + "emoticons": [ + "🪀" + ] + }, + { + "keyword": "left-right arrow", + "emoticons": [ + "↔" + ] + }, + { + "keyword": "delivery truck", + "emoticons": [ + "🚚" + ] + }, + { + "keyword": "rng", + "emoticons": [ + "🎰" + ] + }, + { + "keyword": "green", + "emoticons": [ + "🟩", + "🍏", + "🟢" + ] + }, + { + "keyword": "sunset", + "emoticons": [ + "🌇", + "🌆" + ] + }, + { + "keyword": "diya lamp", + "emoticons": [ + "🪔" + ] + }, + { + "keyword": "disappointed", + "emoticons": [ + "😩", + "😟", + "😫", + "😔", + "😥", + "😞" + ] + }, + { + "keyword": "speaker", + "emoticons": [ + "🔈", + "🔇", + "🔊", + "🔉" + ] + }, + { + "keyword": "ukraine", + "emoticons": [ + "🇺🇦" + ] + }, + { + "keyword": "deer", + "emoticons": [ + "🦌" + ] + }, + { + "keyword": "tool", + "emoticons": [ + "🔗", + "⚙", + "🧰", + "🔩", + "⛏", + "🗜", + "🔨", + "🧲", + "⚖", + "🪛", + "⚒", + "🔧", + "🛠", + "⛓" + ] + }, + { + "keyword": "cg", + "emoticons": [ + "🇨🇬" + ] + }, + { + "keyword": "funeral", + "emoticons": [ + "⚰", + "⚱" + ] + }, + { + "keyword": "swirl", + "emoticons": [ + "🌀" + ] + }, + { + "keyword": "performer", + "emoticons": [ + "🧑‍🎤" + ] + }, + { + "keyword": "flag_tn", + "emoticons": [ + "🇹🇳" + ] + }, + { + "keyword": "bento box", + "emoticons": [ + "🍱" + ] + }, + { + "keyword": "checkbox", + "emoticons": [ + "✅", + "✔", + "☑" + ] + }, + { + "keyword": "softball", + "emoticons": [ + "🥎" + ] + }, + { + "keyword": "dinner", + "emoticons": [ + "🍽", + "🍝", + "🍴" + ] + }, + { + "keyword": "jersey", + "emoticons": [ + "🇯🇪" + ] + }, + { + "keyword": "ab button (blood type)", + "emoticons": [ + "🆎" + ] + }, + { + "keyword": "flag_et", + "emoticons": [ + "🇪🇹" + ] + }, + { + "keyword": "ohh", + "emoticons": [ + "😮", + "😯", + "😲" + ] + }, + { + "keyword": "woman student", + "emoticons": [ + "👩‍🎓" + ] + }, + { + "keyword": "drinking", + "emoticons": [ + "🥂" + ] + }, + { + "keyword": "plead", + "emoticons": [ + "🥺" + ] + }, + { + "keyword": "slient", + "emoticons": [ + "🫥" + ] + }, + { + "keyword": "upside down", + "emoticons": [ + "🙃" + ] + }, + { + "keyword": "cooked rice", + "emoticons": [ + "🍚" + ] + }, + { + "keyword": "es", + "emoticons": [ + "🇪🇸" + ] + }, + { + "keyword": "woman teacher", + "emoticons": [ + "👩‍🏫" + ] + }, + { + "keyword": "fix", + "emoticons": [ + "🪛", + "🔧" + ] + }, + { + "keyword": "gabon", + "emoticons": [ + "🇬🇦" + ] + }, + { + "keyword": "heartbroken", + "emoticons": [ + "😔", + "🥺", + "😭", + "😣", + "💔" + ] + }, + { + "keyword": "pinch", + "emoticons": [ + "🤏" + ] + }, + { + "keyword": "vermin", + "emoticons": [ + "🐭" + ] + }, + { + "keyword": "cactus", + "emoticons": [ + "🌵" + ] + }, + { + "keyword": "angst", + "emoticons": [ + "🙀", + "😰", + "😨", + "😱" + ] + }, + { + "keyword": "raised hands", + "emoticons": [ + "🙌" + ] + }, + { + "keyword": "flag_bh", + "emoticons": [ + "🇧🇭" + ] + }, + { + "keyword": "falafel", + "emoticons": [ + "🧆" + ] + }, + { + "keyword": "vote", + "emoticons": [ + "🗳" + ] + }, + { + "keyword": "face with raised eyebrow", + "emoticons": [ + "🤨" + ] + }, + { + "keyword": "square academic cap", + "emoticons": [ + "🎓" + ] + }, + { + "keyword": "banana", + "emoticons": [ + "🍌" + ] + }, + { + "keyword": "mailbox with mail", + "emoticons": [ + "📬" + ] + }, + { + "keyword": "man police officer", + "emoticons": [ + "👮‍♂" + ] + }, + { + "keyword": "thinking face", + "emoticons": [ + "🤔" + ] + }, + { + "keyword": "fast food", + "emoticons": [ + "🍟", + "🌭" + ] + }, + { + "keyword": "plus sign", + "emoticons": [ + "➕" + ] + }, + { + "keyword": "dz", + "emoticons": [ + "🇩🇿" + ] + }, + { + "keyword": "shooting star", + "emoticons": [ + "🌠" + ] + }, + { + "keyword": "legolas", + "emoticons": [ + "🧝" + ] + }, + { + "keyword": "arts", + "emoticons": [ + "🎭", + "🖼" + ] + }, + { + "keyword": "trans", + "emoticons": [ + "🏳‍⚧" + ] + }, + { + "keyword": "airplane", + "emoticons": [ + "🛫", + "✈", + "🛬", + "🛩" + ] + }, + { + "keyword": "safari", + "emoticons": [ + "🐘", + "🦒", + "🦓", + "🦏" + ] + }, + { + "keyword": "fire engine", + "emoticons": [ + "🚒" + ] + }, + { + "keyword": "houseplant", + "emoticons": [ + "🪴" + ] + }, + { + "keyword": "head bandage", + "emoticons": [ + "🤕" + ] + }, + { + "keyword": "flag_ky", + "emoticons": [ + "🇰🇾" + ] + }, + { + "keyword": "singaporean", + "emoticons": [ + "🇸🇬" + ] + }, + { + "keyword": "canned", + "emoticons": [ + "🥫" + ] + }, + { + "keyword": "cursing", + "emoticons": [ + "🤬" + ] + }, + { + "keyword": "blargh", + "emoticons": [ + "🤮" + ] + }, + { + "keyword": "parallels", + "emoticons": [ + "🌐" + ] + }, + { + "keyword": "head massage", + "emoticons": [ + "💆" + ] + }, + { + "keyword": "pregnant", + "emoticons": [ + "🫃", + "🫄", + "🤰" + ] + }, + { + "keyword": "footprint", + "emoticons": [ + "👣" + ] + }, + { + "keyword": "fork and knife", + "emoticons": [ + "🍴" + ] + }, + { + "keyword": "khmer", + "emoticons": [ + "🇰🇭" + ] + }, + { + "keyword": "megaphone", + "emoticons": [ + "📣" + ] + }, + { + "keyword": "seriously", + "emoticons": [ + "🤨", + "😐", + "😑" + ] + }, + { + "keyword": "flag_gt", + "emoticons": [ + "🇬🇹" + ] + }, + { + "keyword": "tractor", + "emoticons": [ + "🚜" + ] + }, + { + "keyword": "flag_nc", + "emoticons": [ + "🇳🇨" + ] + }, + { + "keyword": "rowing", + "emoticons": [ + "🚣", + "🚣‍♂", + "🚣‍♀" + ] + }, + { + "keyword": "pot", + "emoticons": [ + "🍲", + "🍯" + ] + }, + { + "keyword": "flag_dz", + "emoticons": [ + "🇩🇿" + ] + }, + { + "keyword": "steamy room", + "emoticons": [ + "🧖‍♂", + "🧖‍♀" + ] + }, + { + "keyword": "vest", + "emoticons": [ + "🦺" + ] + }, + { + "keyword": "rw", + "emoticons": [ + "🇷🇼" + ] + }, + { + "keyword": "lying", + "emoticons": [ + "🤥" + ] + }, + { + "keyword": "sob", + "emoticons": [ + "😭" + ] + }, + { + "keyword": "oncoming bus", + "emoticons": [ + "🚍" + ] + }, + { + "keyword": "onigiri", + "emoticons": [ + "🍙" + ] + }, + { + "keyword": "hug", + "emoticons": [ + "🤗", + "🙆‍♀", + "🙆‍♂", + "🫂" + ] + }, + { + "keyword": "dragon", + "emoticons": [ + "🐲", + "🐉" + ] + }, + { + "keyword": "man student", + "emoticons": [ + "👨‍🎓" + ] + }, + { + "keyword": "piggy", + "emoticons": [ + "🐽" + ] + }, + { + "keyword": "melt", + "emoticons": [ + "🫠" + ] + }, + { + "keyword": "kyrgyzstan", + "emoticons": [ + "🇰🇬" + ] + }, + { + "keyword": "office worker", + "emoticons": [ + "👩‍💼", + "🧑‍💼", + "👨‍💼" + ] + }, + { + "keyword": "no entry sign", + "emoticons": [ + "🚫" + ] + }, + { + "keyword": "walk", + "emoticons": [ + "🚶‍♀", + "🦶", + "🚶", + "🚶‍♂" + ] + }, + { + "keyword": "shocked", + "emoticons": [ + "😦", + "🙀", + "😰", + "😨", + "😳", + "😱" + ] + }, + { + "keyword": "ru", + "emoticons": [ + "🇷🇺" + ] + }, + { + "keyword": "i am a witness", + "emoticons": [ + "👁‍🗨" + ] + }, + { + "keyword": "poland", + "emoticons": [ + "🇵🇱" + ] + }, + { + "keyword": "crying cat face", + "emoticons": [ + "😿" + ] + }, + { + "keyword": "ass", + "emoticons": [ + "🍑" + ] + }, + { + "keyword": "congratulations", + "emoticons": [ + "🎇", + "🎆", + "🎉", + "㊗", + "🥳", + "👏" + ] + }, + { + "keyword": "shuffle", + "emoticons": [ + "🔀" + ] + }, + { + "keyword": "shopping", + "emoticons": [ + "🛒" + ] + }, + { + "keyword": "man health worker", + "emoticons": [ + "👨‍⚕" + ] + }, + { + "keyword": "rus", + "emoticons": [ + "🇷🇺" + ] + }, + { + "keyword": "flag_me", + "emoticons": [ + "🇲🇪" + ] + }, + { + "keyword": "arcade games", + "emoticons": [ + "👾" + ] + }, + { + "keyword": "corn", + "emoticons": [ + "🌽" + ] + }, + { + "keyword": "blowfish", + "emoticons": [ + "🐡" + ] + }, + { + "keyword": "plane", + "emoticons": [ + "✈", + "🛫", + "🛩", + "🛬" + ] + }, + { + "keyword": "pinoy", + "emoticons": [ + "🇵🇭" + ] + }, + { + "keyword": "play", + "emoticons": [ + "▶" + ] + }, + { + "keyword": "tamal", + "emoticons": [ + "🫔" + ] + }, + { + "keyword": "io", + "emoticons": [ + "🇮🇴" + ] + }, + { + "keyword": "oyster", + "emoticons": [ + "🦪" + ] + }, + { + "keyword": "armenians", + "emoticons": [ + "🇦🇲" + ] + }, + { + "keyword": "sneeze", + "emoticons": [ + "🤧" + ] + }, + { + "keyword": "wifi", + "emoticons": [ + "🌐", + "📶", + "📡" + ] + }, + { + "keyword": "flag_sa", + "emoticons": [ + "🇸🇦" + ] + }, + { + "keyword": "flag_mv", + "emoticons": [ + "🇲🇻" + ] + }, + { + "keyword": "weed", + "emoticons": [ + "🤙" + ] + }, + { + "keyword": "chicken", + "emoticons": [ + "🐓", + "🐥", + "🐤", + "🐔", + "🐣" + ] + }, + { + "keyword": "aim", + "emoticons": [ + "🎯", + "🏹" + ] + }, + { + "keyword": "red circle", + "emoticons": [ + "⭕", + "🔴" + ] + }, + { + "keyword": "glad", + "emoticons": [ + "🙂", + "😊", + "😌" + ] + }, + { + "keyword": "birthday cake", + "emoticons": [ + "🎂" + ] + }, + { + "keyword": "fixed", + "emoticons": [ + "✅", + "❤‍🩹" + ] + }, + { + "keyword": "party popper", + "emoticons": [ + "🎉" + ] + }, + { + "keyword": "world", + "emoticons": [ + "🌍", + "🌎" + ] + }, + { + "keyword": "funny", + "emoticons": [ + "🤣", + "😁", + "😆", + "😅", + "😄", + "😂" + ] + }, + { + "keyword": "grapes", + "emoticons": [ + "🍇" + ] + }, + { + "keyword": "km", + "emoticons": [ + "🇰🇲" + ] + }, + { + "keyword": "flag_de", + "emoticons": [ + "🇩🇪" + ] + }, + { + "keyword": "venezuela", + "emoticons": [ + "🇻🇪" + ] + }, + { + "keyword": "gw", + "emoticons": [ + "🇬🇼" + ] + }, + { + "keyword": "ballon", + "emoticons": [ + "🎈" + ] + }, + { + "keyword": "chipmunk", + "emoticons": [ + "🐿" + ] + }, + { + "keyword": "silent", + "emoticons": [ + "🤫", + "🤐", + "😐" + ] + }, + { + "keyword": "flag_ms", + "emoticons": [ + "🇲🇸" + ] + }, + { + "keyword": "evil", + "emoticons": [ + "👿", + "😈" + ] + }, + { + "keyword": "rolling on the floor laughing", + "emoticons": [ + "🤣" + ] + }, + { + "keyword": "person gesturing ok", + "emoticons": [ + "🙆‍♀", + "🙆‍♂" + ] + }, + { + "keyword": "man climbing", + "emoticons": [ + "🧗‍♂" + ] + }, + { + "keyword": "student cap", + "emoticons": [ + "🎓" + ] + }, + { + "keyword": "hn", + "emoticons": [ + "🇭🇳" + ] + }, + { + "keyword": "bust in silhouette", + "emoticons": [ + "👤" + ] + }, + { + "keyword": "hide", + "emoticons": [ + "🫣" + ] + }, + { + "keyword": "syringe", + "emoticons": [ + "💉" + ] + }, + { + "keyword": "secret", + "emoticons": [ + "㊙" + ] + }, + { + "keyword": "dashing away", + "emoticons": [ + "💨" + ] + }, + { + "keyword": "cambodia", + "emoticons": [ + "🇰🇭" + ] + }, + { + "keyword": "protect", + "emoticons": [ + "🛡" + ] + }, + { + "keyword": "glasses", + "emoticons": [ + "🥂", + "🥽", + "😎", + "🤓", + "🧐", + "👩‍🔬", + "🕶", + "👓", + "👨‍🔬" + ] + }, + { + "keyword": "fog", + "emoticons": [ + "🌫", + "🌁" + ] + }, + { + "keyword": "low battery", + "emoticons": [ + "🪫" + ] + }, + { + "keyword": "tennis ball", + "emoticons": [ + "🎾" + ] + }, + { + "keyword": "phew", + "emoticons": [ + "😮‍💨" + ] + }, + { + "keyword": "dynamite", + "emoticons": [ + "🧨" + ] + }, + { + "keyword": "kiwi", + "emoticons": [ + "🥝" + ] + }, + { + "keyword": "sunny", + "emoticons": [ + "☀" + ] + }, + { + "keyword": "tc", + "emoticons": [ + "🇹🇨" + ] + }, + { + "keyword": "flag_hu", + "emoticons": [ + "🇭🇺" + ] + }, + { + "keyword": "yellow square", + "emoticons": [ + "🟨" + ] + }, + { + "keyword": "casette", + "emoticons": [ + "📼" + ] + }, + { + "keyword": "flag_bi", + "emoticons": [ + "🇧🇮" + ] + }, + { + "keyword": "georgia", + "emoticons": [ + "🇬🇪" + ] + }, + { + "keyword": "gem", + "emoticons": [ + "💎" + ] + }, + { + "keyword": "ca", + "emoticons": [ + "🇨🇦" + ] + }, + { + "keyword": "shortcake", + "emoticons": [ + "🍰" + ] + }, + { + "keyword": "schedule", + "emoticons": [ + "📅", + "🗓", + "📆" + ] + }, + { + "keyword": "man vampire", + "emoticons": [ + "🧛‍♂" + ] + }, + { + "keyword": "landing", + "emoticons": [ + "🛬" + ] + }, + { + "keyword": "côte d’ivoire", + "emoticons": [ + "🇨🇮" + ] + }, + { + "keyword": "web", + "emoticons": [ + "🕸" + ] + }, + { + "keyword": "atom symbol", + "emoticons": [ + "⚛" + ] + }, + { + "keyword": "gy", + "emoticons": [ + "🇬🇾" + ] + }, + { + "keyword": "shy", + "emoticons": [ + "🙈" + ] + }, + { + "keyword": "map of japan", + "emoticons": [ + "🗾" + ] + }, + { + "keyword": "put litter in its place", + "emoticons": [ + "🚮" + ] + }, + { + "keyword": "az", + "emoticons": [ + "🇦🇿" + ] + }, + { + "keyword": "thumbs", + "emoticons": [ + "👎", + "👍" + ] + }, + { + "keyword": "antigua \u0026 barbuda", + "emoticons": [ + "🇦🇬" + ] + }, + { + "keyword": "hooray", + "emoticons": [ + "🎉" + ] + }, + { + "keyword": "spider web", + "emoticons": [ + "🕸" + ] + }, + { + "keyword": "morocco", + "emoticons": [ + "🇲🇦" + ] + }, + { + "keyword": "dharma", + "emoticons": [ + "☸" + ] + }, + { + "keyword": "wrong", + "emoticons": [ + "🙅", + "🙅‍♂", + "🙅‍♀" + ] + }, + { + "keyword": "family mmgb", + "emoticons": [ + "👨‍👨‍👧‍👦" + ] + }, + { + "keyword": "onion", + "emoticons": [ + "🧅" + ] + }, + { + "keyword": "hajj", + "emoticons": [ + "🕋" + ] + }, + { + "keyword": "straight ruler", + "emoticons": [ + "📏" + ] + }, + { + "keyword": "writing", + "emoticons": [ + "🖊", + "🖍", + "✏", + "🖌", + "✒", + "📝", + "🖋" + ] + }, + { + "keyword": "magic 8 ball", + "emoticons": [ + "🎱" + ] + }, + { + "keyword": "lock", + "emoticons": [ + "🔓", + "🔏", + "🔒", + "🔐" + ] + }, + { + "keyword": "farmer", + "emoticons": [ + "👨‍🌾", + "🧑‍🌾", + "👩‍🌾" + ] + }, + { + "keyword": "eur", + "emoticons": [ + "💶" + ] + }, + { + "keyword": "benin", + "emoticons": [ + "🇧🇯" + ] + }, + { + "keyword": "televison", + "emoticons": [ + "📺" + ] + }, + { + "keyword": "attracted", + "emoticons": [ + "🧲" + ] + }, + { + "keyword": "clock", + "emoticons": [ + "⏰" + ] + }, + { + "keyword": "flag_pt", + "emoticons": [ + "🇵🇹" + ] + }, + { + "keyword": "beer", + "emoticons": [ + "🍻", + "🍺" + ] + }, + { + "keyword": "up arrow", + "emoticons": [ + "⬆" + ] + }, + { + "keyword": "orange", + "emoticons": [ + "🍊", + "🟧", + "🟠" + ] + }, + { + "keyword": "gemini", + "emoticons": [ + "♊" + ] + }, + { + "keyword": "thinking", + "emoticons": [ + "🤔" + ] + }, + { + "keyword": "alien invader", + "emoticons": [ + "👾", + "👽" + ] + }, + { + "keyword": "spooky", + "emoticons": [ + "👻" + ] + }, + { + "keyword": "six o'clock", + "emoticons": [ + "🕕" + ] + }, + { + "keyword": "evergreen", + "emoticons": [ + "🌲" + ] + }, + { + "keyword": "pregnant man", + "emoticons": [ + "🫃" + ] + }, + { + "keyword": "underwear", + "emoticons": [ + "🩲" + ] + }, + { + "keyword": "kp", + "emoticons": [ + "🇰🇵" + ] + }, + { + "keyword": "card index", + "emoticons": [ + "📇" + ] + }, + { + "keyword": "flag_mg", + "emoticons": [ + "🇲🇬" + ] + }, + { + "keyword": "bawl", + "emoticons": [ + "😱" + ] + }, + { + "keyword": "medicine", + "emoticons": [ + "💊", + "💉" + ] + }, + { + "keyword": "japanese ogre", + "emoticons": [ + "👹" + ] + }, + { + "keyword": "huh", + "emoticons": [ + "😯", + "😮", + "😲" + ] + }, + { + "keyword": "clock12", + "emoticons": [ + "🕛" + ] + }, + { + "keyword": "burma", + "emoticons": [ + "🇲🇲" + ] + }, + { + "keyword": "lap", + "emoticons": [ + "🏁" + ] + }, + { + "keyword": "tg", + "emoticons": [ + "🇹🇬" + ] + }, + { + "keyword": "ki", + "emoticons": [ + "🇰🇮" + ] + }, + { + "keyword": "oden", + "emoticons": [ + "🍢" + ] + }, + { + "keyword": "liberia", + "emoticons": [ + "🇱🇷" + ] + }, + { + "keyword": "sweep", + "emoticons": [ + "🧹" + ] + }, + { + "keyword": "afganistan", + "emoticons": [ + "🇦🇫" + ] + }, + { + "keyword": "mailbox closed", + "emoticons": [ + "📫", + "📪" + ] + }, + { + "keyword": "camel", + "emoticons": [ + "🐫", + "🐪" + ] + }, + { + "keyword": "hotdog", + "emoticons": [ + "🌭" + ] + }, + { + "keyword": "blood cl", + "emoticons": [ + "🆑" + ] + }, + { + "keyword": "animal", + "emoticons": [ + "🐻‍❄", + "🦭", + "🦣", + "🐈‍⬛", + "🦬", + "🦫" + ] + }, + { + "keyword": "blood donation", + "emoticons": [ + "🩸" + ] + }, + { + "keyword": "br", + "emoticons": [ + "🇧🇷" + ] + }, + { + "keyword": "flag: england", + "emoticons": [ + "🏴󠁧󠁢󠁥󠁮󠁧󠁿" + ] + }, + { + "keyword": "ten-thirty", + "emoticons": [ + "🕥" + ] + }, + { + "keyword": "fire tag", + "emoticons": [ + "📛" + ] + }, + { + "keyword": "compass", + "emoticons": [ + "🧭" + ] + }, + { + "keyword": "sx", + "emoticons": [ + "🇸🇽" + ] + }, + { + "keyword": "grandstand", + "emoticons": [ + "🏟" + ] + }, + { + "keyword": "confounded", + "emoticons": [ + "😖" + ] + }, + { + "keyword": "gymnastics", + "emoticons": [ + "🤸", + "🤸‍♂", + "🤸‍♀" + ] + }, + { + "keyword": "peaceful", + "emoticons": [ + "😌" + ] + }, + { + "keyword": "meh", + "emoticons": [ + "🫤", + "😐", + "😒" + ] + }, + { + "keyword": "x-ray", + "emoticons": [ + "🩻" + ] + }, + { + "keyword": "flag_ng", + "emoticons": [ + "🇳🇬" + ] + }, + { + "keyword": "reptile", + "emoticons": [ + "🐍", + "🐲", + "🐉", + "🦎", + "🦖", + "🐢", + "🐸", + "🐊" + ] + }, + { + "keyword": "earth africa", + "emoticons": [ + "🌍" + ] + }, + { + "keyword": "bangbang", + "emoticons": [ + "‼" + ] + }, + { + "keyword": "mcdonalds", + "emoticons": [ + "〽" + ] + }, + { + "keyword": "knot", + "emoticons": [ + "🪢" + ] + }, + { + "keyword": "grade", + "emoticons": [ + "🈴" + ] + }, + { + "keyword": "skis", + "emoticons": [ + "🎿" + ] + }, + { + "keyword": "rhinoceros", + "emoticons": [ + "🦏" + ] + }, + { + "keyword": "aaaa", + "emoticons": [ + "😱" + ] + }, + { + "keyword": "lettuce", + "emoticons": [ + "🥬" + ] + }, + { + "keyword": "sofa", + "emoticons": [ + "🛋" + ] + }, + { + "keyword": "pouting cat", + "emoticons": [ + "😾" + ] + }, + { + "keyword": "racing car", + "emoticons": [ + "🏎" + ] + }, + { + "keyword": "homes", + "emoticons": [ + "🏘" + ] + }, + { + "keyword": "update", + "emoticons": [ + "🔄" + ] + }, + { + "keyword": "mind blown", + "emoticons": [ + "🤯" + ] + }, + { + "keyword": "protest sign ", + "emoticons": [ + "🪧" + ] + }, + { + "keyword": "flag_tc", + "emoticons": [ + "🇹🇨" + ] + }, + { + "keyword": "disco", + "emoticons": [ + "🕺", + "🪩" + ] + }, + { + "keyword": "new moon", + "emoticons": [ + "🌑", + "🌚" + ] + }, + { + "keyword": "flag_bl", + "emoticons": [ + "🇧🇱" + ] + }, + { + "keyword": "dog face", + "emoticons": [ + "🐶" + ] + }, + { + "keyword": "sleep", + "emoticons": [ + "💤", + "🛏", + "🥱", + "🛌", + "😴" + ] + }, + { + "keyword": "curiousity", + "emoticons": [ + "🤔" + ] + }, + { + "keyword": "india", + "emoticons": [ + "🇮🇳" + ] + }, + { + "keyword": "wastebasket", + "emoticons": [ + "🗑" + ] + }, + { + "keyword": "nine-thirty", + "emoticons": [ + "🕤" + ] + }, + { + "keyword": "firecracker", + "emoticons": [ + "🧨" + ] + }, + { + "keyword": "toast", + "emoticons": [ + "🥂" + ] + }, + { + "keyword": "man singer", + "emoticons": [ + "👨‍🎤" + ] + }, + { + "keyword": "persian", + "emoticons": [ + "☀", + "🦁", + "🇮🇷" + ] + }, + { + "keyword": "oh god", + "emoticons": [ + "🤦‍♀", + "😖", + "🤷‍♀" + ] + }, + { + "keyword": "lgtm", + "emoticons": [ + "👍", + "👌", + "✅", + "✔", + "☑" + ] + }, + { + "keyword": "bridge at night", + "emoticons": [ + "🌉" + ] + }, + { + "keyword": "writing hand", + "emoticons": [ + "✍" + ] + }, + { + "keyword": "women wrestling", + "emoticons": [ + "🤼‍♀" + ] + }, + { + "keyword": "tl", + "emoticons": [ + "🇹🇱" + ] + }, + { + "keyword": "thunder cloud and rain", + "emoticons": [ + "⛈" + ] + }, + { + "keyword": "video", + "emoticons": [ + "🎞", + "📹" + ] + }, + { + "keyword": "flag_dm", + "emoticons": [ + "🇩🇲" + ] + }, + { + "keyword": "vaccine", + "emoticons": [ + "💉" + ] + }, + { + "keyword": "mountain cableway", + "emoticons": [ + "🚠" + ] + }, + { + "keyword": "pop", + "emoticons": [ + "🍾" + ] + }, + { + "keyword": "fallen leaf", + "emoticons": [ + "🍂" + ] + }, + { + "keyword": "flag_ga", + "emoticons": [ + "🇬🇦" + ] + }, + { + "keyword": "rugby", + "emoticons": [ + "🏉" + ] + }, + { + "keyword": "milk", + "emoticons": [ + "🥛", + "🍼" + ] + }, + { + "keyword": "goblin", + "emoticons": [ + "👺" + ] + }, + { + "keyword": "easter bunny", + "emoticons": [ + "🐰" + ] + }, + { + "keyword": "tv", + "emoticons": [ + "📺", + "🇹🇻" + ] + }, + { + "keyword": "handbag", + "emoticons": [ + "👜" + ] + }, + { + "keyword": "teddy", + "emoticons": [ + "🧸" + ] + }, + { + "keyword": "chinese cap", + "emoticons": [ + "👲" + ] + }, + { + "keyword": "transgenderness", + "emoticons": [ + "⚧", + "🏳‍⚧" + ] + }, + { + "keyword": "ahh", + "emoticons": [ + "😱" + ] + }, + { + "keyword": "antarctica", + "emoticons": [ + "🇦🇶" + ] + }, + { + "keyword": "antenna bars", + "emoticons": [ + "📶" + ] + }, + { + "keyword": "seat", + "emoticons": [ + "💺", + "🛋" + ] + }, + { + "keyword": "ufo", + "emoticons": [ + "🛸", + "👽", + "👾" + ] + }, + { + "keyword": "record button", + "emoticons": [ + "⏺" + ] + }, + { + "keyword": "raptor", + "emoticons": [ + "🦖" + ] + }, + { + "keyword": "man juggling", + "emoticons": [ + "🤹‍♂" + ] + }, + { + "keyword": "pornography", + "emoticons": [ + "🔞" + ] + }, + { + "keyword": "frame photo", + "emoticons": [ + "🖼" + ] + }, + { + "keyword": "spaceman", + "emoticons": [ + "🧑‍🚀" + ] + }, + { + "keyword": "white cane", + "emoticons": [ + "🧑‍🦯", + "🦯", + "👨‍🦯", + "👩‍🦯" + ] + }, + { + "keyword": "man astronaut", + "emoticons": [ + "👨‍🚀" + ] + }, + { + "keyword": "money bag", + "emoticons": [ + "💰" + ] + }, + { + "keyword": "easter", + "emoticons": [ + "🐇", + "🐰", + "🥚", + "🐣" + ] + }, + { + "keyword": "keyboard", + "emoticons": [ + "⌨", + "🎹" + ] + }, + { + "keyword": "flag_mu", + "emoticons": [ + "🇲🇺" + ] + }, + { + "keyword": "mouth", + "emoticons": [ + "👄" + ] + }, + { + "keyword": "djibouti", + "emoticons": [ + "🇩🇯" + ] + }, + { + "keyword": "raining", + "emoticons": [ + "🌧" + ] + }, + { + "keyword": "solomon islands", + "emoticons": [ + "🇸🇧" + ] + }, + { + "keyword": "mobile phone off", + "emoticons": [ + "📴" + ] + }, + { + "keyword": "tester", + "emoticons": [ + "🧑‍💻" + ] + }, + { + "keyword": "upset", + "emoticons": [ + "☹", + "🙁", + "😟" + ] + }, + { + "keyword": "paella", + "emoticons": [ + "🥘" + ] + }, + { + "keyword": "sos", + "emoticons": [ + "🆘" + ] + }, + { + "keyword": "hammer and pick", + "emoticons": [ + "⚒" + ] + }, + { + "keyword": "zoom", + "emoticons": [ + "🔍", + "🔎" + ] + }, + { + "keyword": "cocktail", + "emoticons": [ + "🍹", + "🍸" + ] + }, + { + "keyword": "cook", + "emoticons": [ + "👨‍🍳", + "🧑‍🍳", + "👩‍🍳" + ] + }, + { + "keyword": "park", + "emoticons": [ + "🏞" + ] + }, + { + "keyword": "helmet with cross", + "emoticons": [ + "⛑" + ] + }, + { + "keyword": "flag_cx", + "emoticons": [ + "🇨🇽" + ] + }, + { + "keyword": "woman_shrugging", + "emoticons": [ + "🤷‍♀" + ] + }, + { + "keyword": "curry", + "emoticons": [ + "🍛" + ] + }, + { + "keyword": "flag_py", + "emoticons": [ + "🇵🇾" + ] + }, + { + "keyword": "perserve", + "emoticons": [ + "😣" + ] + }, + { + "keyword": "holland", + "emoticons": [ + "🇳🇱" + ] + }, + { + "keyword": "liberty", + "emoticons": [ + "🗽" + ] + }, + { + "keyword": "sg", + "emoticons": [ + "🇸🇬" + ] + }, + { + "keyword": "hibiscus", + "emoticons": [ + "🌺" + ] + }, + { + "keyword": "firefighter", + "emoticons": [ + "🧑‍🚒", + "👩‍🚒", + "👨‍🚒" + ] + }, + { + "keyword": "file folder", + "emoticons": [ + "📁" + ] + }, + { + "keyword": "heart hands", + "emoticons": [ + "🫶" + ] + }, + { + "keyword": "graduated", + "emoticons": [ + "🧑‍🎓" + ] + }, + { + "keyword": "joystick", + "emoticons": [ + "🕹", + "🎮" + ] + }, + { + "keyword": "red", + "emoticons": [ + "🟥", + "👩‍🦰", + "🍎", + "👨‍🦰" + ] + }, + { + "keyword": "man construction worker", + "emoticons": [ + "👷‍♂" + ] + }, + { + "keyword": "movie camera", + "emoticons": [ + "🎥" + ] + }, + { + "keyword": "bot", + "emoticons": [ + "🤖" + ] + }, + { + "keyword": "woman fairy", + "emoticons": [ + "🧚‍♀" + ] + }, + { + "keyword": "green book", + "emoticons": [ + "📗" + ] + }, + { + "keyword": "clock830", + "emoticons": [ + "🕣" + ] + }, + { + "keyword": "teapot", + "emoticons": [ + "🫖" + ] + }, + { + "keyword": "medic", + "emoticons": [ + "🧑‍⚕" + ] + }, + { + "keyword": "man teacher", + "emoticons": [ + "👨‍🏫" + ] + }, + { + "keyword": "man frowning", + "emoticons": [ + "🙍‍♂" + ] + }, + { + "keyword": "flag_kp", + "emoticons": [ + "🇰🇵" + ] + }, + { + "keyword": "type", + "emoticons": [ + "⌨" + ] + }, + { + "keyword": "neutral", + "emoticons": [ + "😐" + ] + }, + { + "keyword": "running", + "emoticons": [ + "🏃‍♀", + "🏃", + "🏃‍♂" + ] + }, + { + "keyword": "brightness", + "emoticons": [ + "🔆", + "🔅" + ] + }, + { + "keyword": "snake", + "emoticons": [ + "🐍" + ] + }, + { + "keyword": "waves", + "emoticons": [ + "👋" + ] + }, + { + "keyword": "appetite", + "emoticons": [ + "🤤", + "😋" + ] + }, + { + "keyword": "western sahara", + "emoticons": [ + "🇪🇭" + ] + }, + { + "keyword": "golf", + "emoticons": [ + "🏌", + "🏌‍♂", + "⛳", + "🏌‍♀" + ] + }, + { + "keyword": "flag_rw", + "emoticons": [ + "🇷🇼" + ] + }, + { + "keyword": "wave", + "emoticons": [ + "👋", + "🏄‍♀", + "🌊", + "🏄‍♂", + "🏄" + ] + }, + { + "keyword": "yawing", + "emoticons": [ + "🥱" + ] + }, + { + "keyword": "lotion bottle", + "emoticons": [ + "🧴" + ] + }, + { + "keyword": "free button", + "emoticons": [ + "🆓" + ] + }, + { + "keyword": "flag_eu", + "emoticons": [ + "🇪🇺" + ] + }, + { + "keyword": "mechanical", + "emoticons": [ + "🦿", + "🦾" + ] + }, + { + "keyword": "kiwifruit", + "emoticons": [ + "🥝" + ] + }, + { + "keyword": "comet", + "emoticons": [ + "☄" + ] + }, + { + "keyword": "flag_at", + "emoticons": [ + "🇦🇹" + ] + }, + { + "keyword": "exhale", + "emoticons": [ + "😮‍💨" + ] + }, + { + "keyword": "scorching", + "emoticons": [ + "🥵" + ] + }, + { + "keyword": "kr", + "emoticons": [ + "🇰🇷" + ] + }, + { + "keyword": "marathon", + "emoticons": [ + "🏃" + ] + }, + { + "keyword": "horn", + "emoticons": [ + "😈", + "🦄", + "🐚", + "👿", + "📯" + ] + }, + { + "keyword": "doc", + "emoticons": [ + "👩‍⚕", + "👨‍⚕" + ] + }, + { + "keyword": "white exclamation mark", + "emoticons": [ + "❕" + ] + }, + { + "keyword": "seychelles", + "emoticons": [ + "🇸🇨" + ] + }, + { + "keyword": "grandma", + "emoticons": [ + "👵" + ] + }, + { + "keyword": "copyright", + "emoticons": [ + "©" + ] + }, + { + "keyword": "slot", + "emoticons": [ + "🎰" + ] + }, + { + "keyword": "first quarter moon face", + "emoticons": [ + "🌛" + ] + }, + { + "keyword": "suitcase", + "emoticons": [ + "🧳" + ] + }, + { + "keyword": "astrology", + "emoticons": [ + "🔮" + ] + }, + { + "keyword": "hurt", + "emoticons": [ + "💔", + "😣", + "🤕", + "😖" + ] + }, + { + "keyword": "burundi", + "emoticons": [ + "🇧🇮" + ] + }, + { + "keyword": "flag_vn", + "emoticons": [ + "🇻🇳" + ] + }, + { + "keyword": "woman", + "emoticons": [ + "👩‍🦰", + "👵", + "👩", + "👰", + "🤵‍♀", + "👱‍♀", + "👩‍🦳", + "🧍‍♀", + "👩‍🦱", + "🤰", + "👩‍🦲" + ] + }, + { + "keyword": "heh", + "emoticons": [ + "😏", + "😼" + ] + }, + { + "keyword": "melon", + "emoticons": [ + "🍈" + ] + }, + { + "keyword": "juice", + "emoticons": [ + "🧃" + ] + }, + { + "keyword": "satellite orbital", + "emoticons": [ + "🛰" + ] + }, + { + "keyword": "technologist", + "emoticons": [ + "🧑‍💻" + ] + }, + { + "keyword": "disappointment", + "emoticons": [ + "😮‍💨" + ] + }, + { + "keyword": "flag_to", + "emoticons": [ + "🇹🇴" + ] + }, + { + "keyword": "mozambique", + "emoticons": [ + "🇲🇿" + ] + }, + { + "keyword": "laughing", + "emoticons": [ + "😆", + "🤣" + ] + }, + { + "keyword": "walking", + "emoticons": [ + "🚶‍♀", + "🚶", + "🚶‍♂" + ] + }, + { + "keyword": "tear in eyes", + "emoticons": [ + "🥹" + ] + }, + { + "keyword": "m", + "emoticons": [ + "Ⓜ" + ] + }, + { + "keyword": "paraguay", + "emoticons": [ + "🇵🇾" + ] + }, + { + "keyword": "sherlock", + "emoticons": [ + "🕵‍♂", + "🕵", + "🕵‍♀" + ] + }, + { + "keyword": "military helmet", + "emoticons": [ + "🪖" + ] + }, + { + "keyword": "four o'clock", + "emoticons": [ + "🕓" + ] + }, + { + "keyword": "meat on bone", + "emoticons": [ + "🍖" + ] + }, + { + "keyword": "flag_zm", + "emoticons": [ + "🇿🇲" + ] + }, + { + "keyword": "low brightness", + "emoticons": [ + "🔅" + ] + }, + { + "keyword": "hole", + "emoticons": [ + "🕳", + "⛳" + ] + }, + { + "keyword": "get well soon", + "emoticons": [ + "🤕", + "🤒", + "😷" + ] + }, + { + "keyword": "flatbread", + "emoticons": [ + "🥙", + "🫓" + ] + }, + { + "keyword": "coin", + "emoticons": [ + "🪙" + ] + }, + { + "keyword": "underage", + "emoticons": [ + "🔞" + ] + }, + { + "keyword": "transsexual", + "emoticons": [ + "🏳‍⚧", + "⚧" + ] + }, + { + "keyword": "justice", + "emoticons": [ + "⚖" + ] + }, + { + "keyword": "specter", + "emoticons": [ + "👻" + ] + }, + { + "keyword": "tropical", + "emoticons": [ + "🍹" + ] + }, + { + "keyword": "couple different races", + "emoticons": [ + "👫" + ] + }, + { + "keyword": "pick", + "emoticons": [ + "⛏", + "⚒" + ] + }, + { + "keyword": "okay", + "emoticons": [ + "🆗", + "👌", + "👍", + "😉" + ] + }, + { + "keyword": "danger", + "emoticons": [ + "⚠", + "🐍", + "☠" + ] + }, + { + "keyword": "family_mmgg", + "emoticons": [ + "👨‍👨‍👧‍👧" + ] + }, + { + "keyword": "earphones", + "emoticons": [ + "🎧" + ] + }, + { + "keyword": "obedience", + "emoticons": [ + "🫡" + ] + }, + { + "keyword": "tomato", + "emoticons": [ + "🍅" + ] + }, + { + "keyword": "crying", + "emoticons": [ + "😭", + "😢" + ] + }, + { + "keyword": "timer clock", + "emoticons": [ + "⏲" + ] + }, + { + "keyword": "idunno", + "emoticons": [ + "🤷" + ] + }, + { + "keyword": "right", + "emoticons": [ + "▶", + "👉" + ] + }, + { + "keyword": "^_^", + "emoticons": [ + "☺", + "😊" + ] + }, + { + "keyword": "eucalypt", + "emoticons": [ + "🐨" + ] + }, + { + "keyword": "tada", + "emoticons": [ + "🎉" + ] + }, + { + "keyword": "mortar board", + "emoticons": [ + "🎓" + ] + }, + { + "keyword": "fireworks", + "emoticons": [ + "🎆" + ] + }, + { + "keyword": "disco ball", + "emoticons": [ + "🪩" + ] + }, + { + "keyword": "professor", + "emoticons": [ + "👩‍🏫", + "👨‍🏫" + ] + }, + { + "keyword": "negative", + "emoticons": [ + "👎", + "🙅" + ] + }, + { + "keyword": "disc jockey", + "emoticons": [ + "💿", + "📀", + "🪩", + "🎶", + "🎚", + "💽", + "🎧", + "💥", + "🎤", + "🔊", + "🎛" + ] + }, + { + "keyword": "flag_gw", + "emoticons": [ + "🇬🇼" + ] + }, + { + "keyword": "bermuda", + "emoticons": [ + "🇧🇲" + ] + }, + { + "keyword": "postbox", + "emoticons": [ + "📮" + ] + }, + { + "keyword": "face palm", + "emoticons": [ + "🤦" + ] + }, + { + "keyword": "cold breath", + "emoticons": [ + "🌬" + ] + }, + { + "keyword": "diamond ring", + "emoticons": [ + "💍" + ] + }, + { + "keyword": "flag_lv", + "emoticons": [ + "🇱🇻" + ] + }, + { + "keyword": "graph", + "emoticons": [ + "📊" + ] + }, + { + "keyword": "zw", + "emoticons": [ + "🇿🇼" + ] + }, + { + "keyword": "serbia", + "emoticons": [ + "🇷🇸" + ] + }, + { + "keyword": "gesture", + "emoticons": [ + "🙋", + "🙆", + "🙅" + ] + }, + { + "keyword": "shopping bags", + "emoticons": [ + "🛍" + ] + }, + { + "keyword": "black medium square", + "emoticons": [ + "◼" + ] + }, + { + "keyword": "bruh", + "emoticons": [ + "🧐", + "🙄", + "😑" + ] + }, + { + "keyword": "sing", + "emoticons": [ + "👨‍🎤", + "🎤", + "👩‍🎤" + ] + }, + { + "keyword": "?", + "emoticons": [ + "⁉", + "❓", + "❔" + ] + }, + { + "keyword": "swimsuit", + "emoticons": [ + "👙", + "🩱" + ] + }, + { + "keyword": "throne", + "emoticons": [ + "🪑" + ] + }, + { + "keyword": "clock130", + "emoticons": [ + "🕜" + ] + }, + { + "keyword": "honey pot", + "emoticons": [ + "🍯" + ] + }, + { + "keyword": "flag_kh", + "emoticons": [ + "🇰🇭" + ] + }, + { + "keyword": "smoke", + "emoticons": [ + "😮‍💨", + "😶‍🌫", + "🚬" + ] + }, + { + "keyword": "furry", + "emoticons": [ + "🐺", + "🦊" + ] + }, + { + "keyword": "woman facepalming", + "emoticons": [ + "🤦‍♀" + ] + }, + { + "keyword": "hello", + "emoticons": [ + "✋", + "👋" + ] + }, + { + "keyword": "minidisk", + "emoticons": [ + "💽" + ] + }, + { + "keyword": "groom", + "emoticons": [ + "🤵" + ] + }, + { + "keyword": "mouse three button", + "emoticons": [ + "🖱" + ] + }, + { + "keyword": "xo", + "emoticons": [ + "💋" + ] + }, + { + "keyword": "spiral notepad", + "emoticons": [ + "🗒" + ] + }, + { + "keyword": "calendar", + "emoticons": [ + "🗓", + "📆", + "📅" + ] + }, + { + "keyword": "man pouting", + "emoticons": [ + "🙎‍♂" + ] + }, + { + "keyword": "energy", + "emoticons": [ + "🔌", + "🔋" + ] + }, + { + "keyword": "gondola", + "emoticons": [ + "🚡" + ] + }, + { + "keyword": "ab blood", + "emoticons": [ + "🆎" + ] + }, + { + "keyword": "hurry", + "emoticons": [ + "🏃‍♀", + "🏃" + ] + }, + { + "keyword": "sprout", + "emoticons": [ + "🌱" + ] + }, + { + "keyword": "whiskey", + "emoticons": [ + "🥃" + ] + }, + { + "keyword": "guinea-bissau", + "emoticons": [ + "🇬🇼" + ] + }, + { + "keyword": "uae", + "emoticons": [ + "🇦🇪" + ] + }, + { + "keyword": "toolbox", + "emoticons": [ + "🧰" + ] + }, + { + "keyword": "euro", + "emoticons": [ + "💶" + ] + }, + { + "keyword": "ad", + "emoticons": [ + "🇦🇩" + ] + }, + { + "keyword": "bark", + "emoticons": [ + "🐶" + ] + }, + { + "keyword": "2", + "emoticons": [ + "2⃣" + ] + }, + { + "keyword": "flag_sn", + "emoticons": [ + "🇸🇳" + ] + }, + { + "keyword": "surprised", + "emoticons": [ + "😮", + "😯" + ] + }, + { + "keyword": "preggo", + "emoticons": [ + "🤰" + ] + }, + { + "keyword": "excited", + "emoticons": [ + "🤗" + ] + }, + { + "keyword": "alphabet", + "emoticons": [ + "🔡", + "🔤" + ] + }, + { + "keyword": "basketballer", + "emoticons": [ + "⛹" + ] + }, + { + "keyword": "bowie", + "emoticons": [ + "🧑‍🎤", + "👨‍🎤", + "👩‍🎤" + ] + }, + { + "keyword": "lesbian", + "emoticons": [ + "👩‍👩‍👦‍👦", + "👩‍👩‍👧‍👧", + "👩‍👩‍👧‍👦", + "👩‍👩‍👦", + "👩‍👩‍👧", + "👩‍❤‍👩", + "👩‍❤‍💋‍👩", + "🏳‍🌈" + ] + }, + { + "keyword": "flag_ci", + "emoticons": [ + "🇨🇮" + ] + }, + { + "keyword": "cry smile", + "emoticons": [ + "🥹" + ] + }, + { + "keyword": "€", + "emoticons": [ + "💶" + ] + }, + { + "keyword": "ten o'clock", + "emoticons": [ + "🕙" + ] + }, + { + "keyword": "bag", + "emoticons": [ + "👛", + "👝", + "👜", + "🎒" + ] + }, + { + "keyword": "flag_sr", + "emoticons": [ + "🇸🇷" + ] + }, + { + "keyword": "like", + "emoticons": [ + "👍" + ] + }, + { + "keyword": "pm", + "emoticons": [ + "🇵🇲" + ] + }, + { + "keyword": "superhero", + "emoticons": [ + "🦸‍♂", + "🦸‍♀", + "🦸" + ] + }, + { + "keyword": "deal", + "emoticons": [ + "🤝" + ] + }, + { + "keyword": "purple", + "emoticons": [ + "🟣", + "🟪" + ] + }, + { + "keyword": "basket", + "emoticons": [ + "🧺", + "🗑" + ] + }, + { + "keyword": "pencil", + "emoticons": [ + "✏", + "📝" + ] + }, + { + "keyword": "man detective", + "emoticons": [ + "🕵‍♂" + ] + }, + { + "keyword": "movie", + "emoticons": [ + "📺", + "🎥", + "📽", + "🎞", + "🎦", + "🎬" + ] + }, + { + "keyword": "trophy", + "emoticons": [ + "🏆" + ] + }, + { + "keyword": "blouse", + "emoticons": [ + "👚" + ] + }, + { + "keyword": "crash", + "emoticons": [ + "💥" + ] + }, + { + "keyword": "cricket", + "emoticons": [ + "🏏", + "🦗" + ] + }, + { + "keyword": "on", + "emoticons": [ + "🔛" + ] + }, + { + "keyword": "greetings", + "emoticons": [ + "🤝" + ] + }, + { + "keyword": "sushi", + "emoticons": [ + "🍣" + ] + }, + { + "keyword": "gbp", + "emoticons": [ + "💷" + ] + }, + { + "keyword": "calm", + "emoticons": [ + "🧘‍♀", + "😌", + "🙏", + "🧘‍♂" + ] + }, + { + "keyword": "scroll", + "emoticons": [ + "📜" + ] + }, + { + "keyword": "bottle", + "emoticons": [ + "🍾", + "🍼" + ] + }, + { + "keyword": "spaghetti", + "emoticons": [ + "🍝" + ] + }, + { + "keyword": "bowling", + "emoticons": [ + "🎳" + ] + }, + { + "keyword": "flag_np", + "emoticons": [ + "🇳🇵" + ] + }, + { + "keyword": "clerk", + "emoticons": [ + "🧑‍💼" + ] + }, + { + "keyword": "santa", + "emoticons": [ + "🧑‍🎄", + "🎅" + ] + }, + { + "keyword": "blood o", + "emoticons": [ + "🅾" + ] + }, + { + "keyword": "japanese monthly amount button", + "emoticons": [ + "🈷" + ] + }, + { + "keyword": "vaccination", + "emoticons": [ + "💉" + ] + }, + { + "keyword": "hahah", + "emoticons": [ + "😄" + ] + }, + { + "keyword": "flag_gd", + "emoticons": [ + "🇬🇩" + ] + }, + { + "keyword": "golf club", + "emoticons": [ + "🏌" + ] + }, + { + "keyword": "rightward", + "emoticons": [ + "🫱" + ] + }, + { + "keyword": "eye in speech bubble", + "emoticons": [ + "👁‍🗨" + ] + }, + { + "keyword": "ve", + "emoticons": [ + "🇻🇪" + ] + }, + { + "keyword": "vampire", + "emoticons": [ + "🧛", + "🦇", + "🧛‍♂", + "🧛‍♀" + ] + }, + { + "keyword": "squirt", + "emoticons": [ + "🔫", + "💦" + ] + }, + { + "keyword": "jogging", + "emoticons": [ + "🏃" + ] + }, + { + "keyword": "telephone", + "emoticons": [ + "📱", + "☎", + "📞" + ] + }, + { + "keyword": "spouting whale", + "emoticons": [ + "🐳" + ] + }, + { + "keyword": "browser", + "emoticons": [ + "🌐" + ] + }, + { + "keyword": "oncoming automobile", + "emoticons": [ + "🚘" + ] + }, + { + "keyword": "microscope", + "emoticons": [ + "🔬" + ] + }, + { + "keyword": "carp streamer", + "emoticons": [ + "🎏" + ] + }, + { + "keyword": "poker", + "emoticons": [ + "😐" + ] + }, + { + "keyword": "ms", + "emoticons": [ + "🇲🇸" + ] + }, + { + "keyword": ";-)", + "emoticons": [ + "😉" + ] + }, + { + "keyword": "flag_sv", + "emoticons": [ + "🇸🇻" + ] + }, + { + "keyword": "fries", + "emoticons": [ + "🍟" + ] + }, + { + "keyword": "vn", + "emoticons": [ + "🇻🇳" + ] + }, + { + "keyword": "flag_bv", + "emoticons": [ + "🇳🇴" + ] + }, + { + "keyword": "as", + "emoticons": [ + "🇦🇸" + ] + }, + { + "keyword": "wind chime", + "emoticons": [ + "🎐" + ] + }, + { + "keyword": "programmer", + "emoticons": [ + "🧑‍💻", + "👩‍💻", + "👨‍💻" + ] + }, + { + "keyword": "speaking head is silhouette", + "emoticons": [ + "🗣" + ] + }, + { + "keyword": "blood 0", + "emoticons": [ + "🅾" + ] + }, + { + "keyword": "white medium-small square", + "emoticons": [ + "◽" + ] + }, + { + "keyword": "flag_mn", + "emoticons": [ + "🇲🇳" + ] + }, + { + "keyword": "lifting", + "emoticons": [ + "🏋‍♀", + "🏋‍♂" + ] + }, + { + "keyword": "chili", + "emoticons": [ + "🌶" + ] + }, + { + "keyword": "smirking", + "emoticons": [ + "😏", + "😼", + "😉" + ] + }, + { + "keyword": "man pilot", + "emoticons": [ + "👨‍✈" + ] + }, + { + "keyword": "chili pepper", + "emoticons": [ + "🌶" + ] + }, + { + "keyword": "hokusai", + "emoticons": [ + "🌊" + ] + }, + { + "keyword": "steam locomotive", + "emoticons": [ + "🚂" + ] + }, + { + "keyword": "clock3", + "emoticons": [ + "🕒" + ] + }, + { + "keyword": "bear face", + "emoticons": [ + "🐼", + "🐻" + ] + }, + { + "keyword": "computer", + "emoticons": [ + "⌨", + "🖱", + "💻", + "🖥" + ] + }, + { + "keyword": "powers", + "emoticons": [ + "💪" + ] + }, + { + "keyword": "polo", + "emoticons": [ + "🤽‍♂", + "🤽‍♀", + "🤽" + ] + }, + { + "keyword": "yt", + "emoticons": [ + "🇾🇹" + ] + }, + { + "keyword": "construction worker", + "emoticons": [ + "👷" + ] + }, + { + "keyword": "flag_mo", + "emoticons": [ + "🇲🇴" + ] + }, + { + "keyword": "leg", + "emoticons": [ + "🦿", + "🦵" + ] + }, + { + "keyword": "flag_pg", + "emoticons": [ + "🇵🇬" + ] + }, + { + "keyword": "loudspeaker", + "emoticons": [ + "📢" + ] + }, + { + "keyword": "female", + "emoticons": [ + "👱‍♀", + "👩‍🦳", + "👩‍🦱", + "👩", + "👩‍🦰" + ] + }, + { + "keyword": "negative squared cross mark", + "emoticons": [ + "❎" + ] + }, + { + "keyword": "vex", + "emoticons": [ + "😤", + "🤬", + "😡" + ] + }, + { + "keyword": "astonished", + "emoticons": [ + "🤭", + "😲" + ] + }, + { + "keyword": "shinto shrine", + "emoticons": [ + "⛩" + ] + }, + { + "keyword": "carpentry saw", + "emoticons": [ + "🪚" + ] + }, + { + "keyword": "teacher", + "emoticons": [ + "👩‍🏫", + "🧑‍🏫", + "👨‍🏫" + ] + }, + { + "keyword": "frown", + "emoticons": [ + "🙁", + "😦", + "☹" + ] + }, + { + "keyword": "hamsa", + "emoticons": [ + "🪬" + ] + }, + { + "keyword": "gift", + "emoticons": [ + "🎁" + ] + }, + { + "keyword": "parachute", + "emoticons": [ + "🪂" + ] + }, + { + "keyword": "gb", + "emoticons": [ + "🇬🇧" + ] + }, + { + "keyword": "man mechanic", + "emoticons": [ + "👨‍🔧" + ] + }, + { + "keyword": "gamepad", + "emoticons": [ + "🎮", + "🕹" + ] + }, + { + "keyword": "toilet", + "emoticons": [ + "🧻", + "🚽" + ] + }, + { + "keyword": "cosmonaut", + "emoticons": [ + "🧑‍🚀" + ] + }, + { + "keyword": "blagh", + "emoticons": [ + "🤮" + ] + }, + { + "keyword": "flag_tm", + "emoticons": [ + "🇹🇲" + ] + }, + { + "keyword": "flag_kg", + "emoticons": [ + "🇰🇬" + ] + }, + { + "keyword": "jog", + "emoticons": [ + "🏃" + ] + }, + { + "keyword": "barber's stripes", + "emoticons": [ + "💈" + ] + }, + { + "keyword": "emirati", + "emoticons": [ + "🇦🇪" + ] + }, + { + "keyword": "hmm", + "emoticons": [ + "🧐", + "🤨", + "🫤", + "🤔" + ] + }, + { + "keyword": "black square button", + "emoticons": [ + "🔲" + ] + }, + { + "keyword": "omelette", + "emoticons": [ + "🥚", + "🍳" + ] + }, + { + "keyword": "bin", + "emoticons": [ + "🚮", + "🗑", + "♻" + ] + }, + { + "keyword": "burn", + "emoticons": [ + "❤‍🔥", + "🔥" + ] + }, + { + "keyword": "exclamation", + "emoticons": [ + "❗" + ] + }, + { + "keyword": "man with veil", + "emoticons": [ + "👰‍♂" + ] + }, + { + "keyword": "bj", + "emoticons": [ + "🇧🇯" + ] + }, + { + "keyword": "volume", + "emoticons": [ + "🎚", + "🔊", + "🔈", + "🔉" + ] + }, + { + "keyword": "shower", + "emoticons": [ + "🧼", + "🚿" + ] + }, + { + "keyword": "mushroom", + "emoticons": [ + "🍄" + ] + }, + { + "keyword": "shining", + "emoticons": [ + "💎" + ] + }, + { + "keyword": "=*", + "emoticons": [ + "💋", + "😘", + "😚", + "😽" + ] + }, + { + "keyword": "samsung", + "emoticons": [ + "📱" + ] + }, + { + "keyword": "smooch", + "emoticons": [ + "😽", + "💋" + ] + }, + { + "keyword": "kick scooter", + "emoticons": [ + "🛴" + ] + }, + { + "keyword": "nirvana", + "emoticons": [ + "🧘" + ] + }, + { + "keyword": "åland islands", + "emoticons": [ + "🇦🇽" + ] + }, + { + "keyword": "flag_ye", + "emoticons": [ + "🇾🇪" + ] + }, + { + "keyword": "smog", + "emoticons": [ + "😶‍🌫", + "🌫", + "😵‍💫" + ] + }, + { + "keyword": "open for business", + "emoticons": [ + "🈺" + ] + }, + { + "keyword": "champagne", + "emoticons": [ + "🥂", + "🍾" + ] + }, + { + "keyword": "best", + "emoticons": [ + "🥇", + "💯" + ] + }, + { + "keyword": "waffle", + "emoticons": [ + "🧇" + ] + }, + { + "keyword": "double curly loop", + "emoticons": [ + "➿" + ] + }, + { + "keyword": "dev", + "emoticons": [ + "🧑‍💻" + ] + }, + { + "keyword": "grimace", + "emoticons": [ + "😬" + ] + }, + { + "keyword": "biting lips", + "emoticons": [ + "🫦" + ] + }, + { + "keyword": "stressed", + "emoticons": [ + "😪" + ] + }, + { + "keyword": "cz", + "emoticons": [ + "🇨🇿" + ] + }, + { + "keyword": "dope", + "emoticons": [ + "🤘" + ] + }, + { + "keyword": "clothes", + "emoticons": [ + "👕", + "👚" + ] + }, + { + "keyword": "blonde", + "emoticons": [ + "👱" + ] + }, + { + "keyword": "bn", + "emoticons": [ + "🇧🇳" + ] + }, + { + "keyword": "scorpion", + "emoticons": [ + "🦂" + ] + }, + { + "keyword": "flag_ao", + "emoticons": [ + "🇦🇴" + ] + }, + { + "keyword": "seven o'clock", + "emoticons": [ + "🕖" + ] + }, + { + "keyword": "keycap digit seven", + "emoticons": [ + "7⃣" + ] + }, + { + "keyword": "save our souls", + "emoticons": [ + "🆘" + ] + }, + { + "keyword": "goat", + "emoticons": [ + "🐐" + ] + }, + { + "keyword": "gratz", + "emoticons": [ + "🎉", + "🎊" + ] + }, + { + "keyword": "house", + "emoticons": [ + "🏘", + "🏠", + "🏚", + "🏡" + ] + }, + { + "keyword": "flag_ec", + "emoticons": [ + "🇪🇨" + ] + }, + { + "keyword": "grandpa", + "emoticons": [ + "👨‍🦳" + ] + }, + { + "keyword": "ga", + "emoticons": [ + "🇬🇦" + ] + }, + { + "keyword": "qatar", + "emoticons": [ + "🇶🇦" + ] + }, + { + "keyword": "macos", + "emoticons": [ + "💻" + ] + }, + { + "keyword": "flag_za", + "emoticons": [ + "🇿🇦" + ] + }, + { + "keyword": "top arrow", + "emoticons": [ + "🔝" + ] + }, + { + "keyword": "dr", + "emoticons": [ + "🧑‍⚕" + ] + }, + { + "keyword": "jamaica", + "emoticons": [ + "🇯🇲" + ] + }, + { + "keyword": "arcade", + "emoticons": [ + "👾" + ] + }, + { + "keyword": "wf", + "emoticons": [ + "🇼🇫" + ] + }, + { + "keyword": "bulb", + "emoticons": [ + "💡" + ] + }, + { + "keyword": "nz", + "emoticons": [ + "🇳🇿" + ] + }, + { + "keyword": "purple heart", + "emoticons": [ + "💜" + ] + }, + { + "keyword": "seconds", + "emoticons": [ + "⌛" + ] + }, + { + "keyword": "flag_au", + "emoticons": [ + "🇦🇺" + ] + }, + { + "keyword": "chill", + "emoticons": [ + "🤟" + ] + }, + { + "keyword": "vomit", + "emoticons": [ + "🤮", + "🤢" + ] + }, + { + "keyword": "stallion", + "emoticons": [ + "🐴" + ] + }, + { + "keyword": "chart", + "emoticons": [ + "📉", + "📈", + "📊" + ] + }, + { + "keyword": "inline skate ", + "emoticons": [ + "🛼" + ] + }, + { + "keyword": "elvis", + "emoticons": [ + "🕺" + ] + }, + { + "keyword": "ice", + "emoticons": [ + "🥶", + "🧊" + ] + }, + { + "keyword": "fast", + "emoticons": [ + "💨", + "⚡" + ] + }, + { + "keyword": "woman firefighter", + "emoticons": [ + "👩‍🚒" + ] + }, + { + "keyword": "french fries", + "emoticons": [ + "🍟" + ] + }, + { + "keyword": "luck", + "emoticons": [ + "🤞", + "🎰", + "🍀" + ] + }, + { + "keyword": "champ", + "emoticons": [ + "🏆" + ] + }, + { + "keyword": "soon", + "emoticons": [ + "🔜" + ] + }, + { + "keyword": "melted", + "emoticons": [ + "🫠" + ] + }, + { + "keyword": "dumbo", + "emoticons": [ + "🐘" + ] + }, + { + "keyword": "flag_in", + "emoticons": [ + "🇮🇳" + ] + }, + { + "keyword": "lizard", + "emoticons": [ + "🦎" + ] + }, + { + "keyword": "no mouth", + "emoticons": [ + "😶" + ] + }, + { + "keyword": "plunger", + "emoticons": [ + "🪠" + ] + }, + { + "keyword": "railway track", + "emoticons": [ + "🛤" + ] + }, + { + "keyword": "montserrat", + "emoticons": [ + "🇲🇸" + ] + }, + { + "keyword": "man running", + "emoticons": [ + "🏃‍♂" + ] + }, + { + "keyword": "pill", + "emoticons": [ + "💊" + ] + }, + { + "keyword": "knee", + "emoticons": [ + "🦵" + ] + }, + { + "keyword": "briefcase", + "emoticons": [ + "💼" + ] + }, + { + "keyword": "il", + "emoticons": [ + "🇮🇱" + ] + }, + { + "keyword": "interrobang", + "emoticons": [ + "⁉" + ] + }, + { + "keyword": "pineapple", + "emoticons": [ + "🍍" + ] + }, + { + "keyword": "gear wheel", + "emoticons": [ + "⚙" + ] + }, + { + "keyword": "man cook", + "emoticons": [ + "👨‍🍳" + ] + }, + { + "keyword": "coffin", + "emoticons": [ + "⚰" + ] + }, + { + "keyword": "nerd face", + "emoticons": [ + "🤓" + ] + }, + { + "keyword": "blood a", + "emoticons": [ + "🅰" + ] + }, + { + "keyword": "flipper", + "emoticons": [ + "🐬" + ] + }, + { + "keyword": "ginger", + "emoticons": [ + "🧑‍🦰" + ] + }, + { + "keyword": "unlock", + "emoticons": [ + "🔓" + ] + }, + { + "keyword": "cartwheeling", + "emoticons": [ + "🤸‍♀", + "🤸" + ] + }, + { + "keyword": "back", + "emoticons": [ + "◀", + "⬅", + "🔙" + ] + }, + { + "keyword": "slot machine", + "emoticons": [ + "🎰" + ] + }, + { + "keyword": "racing", + "emoticons": [ + "🏇", + "🏎" + ] + }, + { + "keyword": "estonia", + "emoticons": [ + "🇪🇪" + ] + }, + { + "keyword": "smirk cat", + "emoticons": [ + "😼" + ] + }, + { + "keyword": "sole", + "emoticons": [ + "🦶" + ] + }, + { + "keyword": "funicular", + "emoticons": [ + "🚠" + ] + }, + { + "keyword": "growl", + "emoticons": [ + "🐺" + ] + }, + { + "keyword": "bat", + "emoticons": [ + "🦇" + ] + }, + { + "keyword": "white hair", + "emoticons": [ + "👱", + "🧑‍🦳" + ] + }, + { + "keyword": "de", + "emoticons": [ + "🇩🇪" + ] + }, + { + "keyword": "post office", + "emoticons": [ + "🏣", + "🏤" + ] + }, + { + "keyword": "in love", + "emoticons": [ + "❤", + "🥰", + "💕" + ] + }, + { + "keyword": "green square", + "emoticons": [ + "🟩" + ] + }, + { + "keyword": "space invaders", + "emoticons": [ + "👾" + ] + }, + { + "keyword": "moai", + "emoticons": [ + "🗿" + ] + }, + { + "keyword": "flag_kn", + "emoticons": [ + "🇰🇳" + ] + }, + { + "keyword": "airplane arrival", + "emoticons": [ + "🛬", + "🛩" + ] + }, + { + "keyword": "cracker", + "emoticons": [ + "🍘" + ] + }, + { + "keyword": "orthodox cross", + "emoticons": [ + "☦" + ] + }, + { + "keyword": "rice ball", + "emoticons": [ + "🍙" + ] + }, + { + "keyword": "lotus position", + "emoticons": [ + "🧘‍♀", + "🧘‍♂" + ] + }, + { + "keyword": "shave", + "emoticons": [ + "🪒" + ] + }, + { + "keyword": "researcher", + "emoticons": [ + "🧑‍🔬" + ] + }, + { + "keyword": "waning gibbous moon", + "emoticons": [ + "🌖" + ] + }, + { + "keyword": "snout", + "emoticons": [ + "🐷" + ] + }, + { + "keyword": "candy", + "emoticons": [ + "🍬" + ] + }, + { + "keyword": "unlocked", + "emoticons": [ + "🔓" + ] + }, + { + "keyword": "flag_dj", + "emoticons": [ + "🇩🇯" + ] + }, + { + "keyword": "shamrock", + "emoticons": [ + "☘" + ] + }, + { + "keyword": "geek", + "emoticons": [ + "🤓" + ] + }, + { + "keyword": "man genie", + "emoticons": [ + "🧞‍♂" + ] + }, + { + "keyword": "stand", + "emoticons": [ + "🧍‍♀" + ] + }, + { + "keyword": "dm", + "emoticons": [ + "🇩🇲" + ] + }, + { + "keyword": "fake smile", + "emoticons": [ + "🙃" + ] + }, + { + "keyword": "singer", + "emoticons": [ + "👩‍🎤", + "🧑‍🎤", + "👨‍🎤" + ] + }, + { + "keyword": "arm", + "emoticons": [ + "💪", + "🤳", + "🦾" + ] + }, + { + "keyword": "sparkle", + "emoticons": [ + "❇" + ] + }, + { + "keyword": "bible", + "emoticons": [ + "📖" + ] + }, + { + "keyword": "taxi", + "emoticons": [ + "🚕", + "🚖" + ] + }, + { + "keyword": "sn", + "emoticons": [ + "🇸🇳" + ] + }, + { + "keyword": "wheelchair person", + "emoticons": [ + "🧑‍🦽" + ] + }, + { + "keyword": "man zombie", + "emoticons": [ + "🧟‍♂" + ] + }, + { + "keyword": "backhand index pointing left", + "emoticons": [ + "👈" + ] + }, + { + "keyword": "weary", + "emoticons": [ + "😩" + ] + }, + { + "keyword": "bad words", + "emoticons": [ + "🤬" + ] + }, + { + "keyword": "woman cook", + "emoticons": [ + "👩‍🍳" + ] + }, + { + "keyword": "confused face", + "emoticons": [ + "😕" + ] + }, + { + "keyword": "caterpillar", + "emoticons": [ + "🐛" + ] + }, + { + "keyword": "admission tickets", + "emoticons": [ + "🎟" + ] + }, + { + "keyword": "sax", + "emoticons": [ + "🎷" + ] + }, + { + "keyword": "houses", + "emoticons": [ + "🏘" + ] + }, + { + "keyword": "mech", + "emoticons": [ + "🦾", + "🦿" + ] + }, + { + "keyword": "macao sar china", + "emoticons": [ + "🇲🇴" + ] + }, + { + "keyword": "hijab", + "emoticons": [ + "🧕" + ] + }, + { + "keyword": "transgender flag", + "emoticons": [ + "🏳‍⚧" + ] + }, + { + "keyword": "athletic shoe", + "emoticons": [ + "👟" + ] + }, + { + "keyword": "japanese castle", + "emoticons": [ + "🏯" + ] + }, + { + "keyword": "collegue", + "emoticons": [ + "👩‍💼", + "👨‍💼", + "👩‍💻", + "👨‍💻" + ] + }, + { + "keyword": "clock2", + "emoticons": [ + "🕑" + ] + }, + { + "keyword": "metal", + "emoticons": [ + "🤘" + ] + }, + { + "keyword": "shout", + "emoticons": [ + "🗣" + ] + }, + { + "keyword": "clipperton island", + "emoticons": [ + "🇫🇷" + ] + }, + { + "keyword": "backhand index pointing up", + "emoticons": [ + "👆" + ] + }, + { + "keyword": "cycling", + "emoticons": [ + "🚲", + "🚴‍♀", + "🚵‍♀", + "🚴‍♂", + "🚵‍♂" + ] + }, + { + "keyword": ":\\", + "emoticons": [ + "🫤" + ] + }, + { + "keyword": "flag_pl", + "emoticons": [ + "🇵🇱" + ] + }, + { + "keyword": "tornado", + "emoticons": [ + "🌀", + "🌪" + ] + }, + { + "keyword": "irony", + "emoticons": [ + "😏" + ] + }, + { + "keyword": "pot of food", + "emoticons": [ + "🍲" + ] + }, + { + "keyword": "treatment", + "emoticons": [ + "💉", + "💊" + ] + }, + { + "keyword": "flying disc", + "emoticons": [ + "🥏" + ] + }, + { + "keyword": "ogre", + "emoticons": [ + "👹" + ] + }, + { + "keyword": "burkina faso", + "emoticons": [ + "🇧🇫" + ] + }, + { + "keyword": "lk", + "emoticons": [ + "🇱🇰" + ] + }, + { + "keyword": "labourer", + "emoticons": [ + "🧑‍🏭" + ] + }, + { + "keyword": "elf", + "emoticons": [ + "🧝‍♀", + "🧝‍♂", + "🧝" + ] + }, + { + "keyword": "water wave", + "emoticons": [ + "🌊" + ] + }, + { + "keyword": "night with stars", + "emoticons": [ + "🌃" + ] + }, + { + "keyword": "ribbon", + "emoticons": [ + "🎀" + ] + }, + { + "keyword": "white check mark", + "emoticons": [ + "✅" + ] + }, + { + "keyword": "elated", + "emoticons": [ + "😜" + ] + }, + { + "keyword": "kawaii", + "emoticons": [ + "💠" + ] + }, + { + "keyword": "sy", + "emoticons": [ + "🇸🇾" + ] + }, + { + "keyword": "uzbekistan", + "emoticons": [ + "🇺🇿" + ] + }, + { + "keyword": "eagle", + "emoticons": [ + "🦅" + ] + }, + { + "keyword": "loud sound", + "emoticons": [ + "🔊" + ] + }, + { + "keyword": "mad", + "emoticons": [ + "😡", + "🤬", + "😾", + "😠", + "😤", + "💢" + ] + }, + { + "keyword": "cabo verde", + "emoticons": [ + "🇨🇻" + ] + }, + { + "keyword": "gamble", + "emoticons": [ + "🎰" + ] + }, + { + "keyword": "family_wwgb", + "emoticons": [ + "👩‍👩‍👧‍👦" + ] + }, + { + "keyword": "voting", + "emoticons": [ + "🗳" + ] + }, + { + "keyword": "thanks", + "emoticons": [ + "🙏" + ] + }, + { + "keyword": "quarantine", + "emoticons": [ + "🤒", + "🦠", + "😷" + ] + }, + { + "keyword": "ghost", + "emoticons": [ + "👻" + ] + }, + { + "keyword": "mug", + "emoticons": [ + "🍻", + "🍺" + ] + }, + { + "keyword": "pouch", + "emoticons": [ + "👝" + ] + }, + { + "keyword": "nu", + "emoticons": [ + "🇳🇺" + ] + }, + { + "keyword": "small red triangle down", + "emoticons": [ + "🔻" + ] + }, + { + "keyword": "cloud with tornado", + "emoticons": [ + "🌪" + ] + }, + { + "keyword": "potted plant", + "emoticons": [ + "🪴" + ] + }, + { + "keyword": "reminder ribbon", + "emoticons": [ + "🎗" + ] + }, + { + "keyword": "flag_fj", + "emoticons": [ + "🇫🇯" + ] + }, + { + "keyword": "heavy multiplication mark", + "emoticons": [ + "✖" + ] + }, + { + "keyword": "right arrow curving down", + "emoticons": [ + "⤵" + ] + }, + { + "keyword": "woah", + "emoticons": [ + "😮", + "😲", + "😯" + ] + }, + { + "keyword": "shock", + "emoticons": [ + "😲", + "😮", + "💥", + "😯" + ] + }, + { + "keyword": "british guard", + "emoticons": [ + "💂" + ] + }, + { + "keyword": "south georgia \u0026 south sandwich islands", + "emoticons": [ + "🇬🇸" + ] + }, + { + "keyword": "brazil", + "emoticons": [ + "🇧🇷" + ] + }, + { + "keyword": "vatican city", + "emoticons": [ + "🇻🇦" + ] + }, + { + "keyword": "dragon face", + "emoticons": [ + "🐲" + ] + }, + { + "keyword": "cheers", + "emoticons": [ + "🍾", + "🍻", + "🥂" + ] + }, + { + "keyword": "fencing", + "emoticons": [ + "🤺" + ] + }, + { + "keyword": "man feeding baby", + "emoticons": [ + "👨‍🍼" + ] + }, + { + "keyword": "dromedary camel", + "emoticons": [ + "🐪" + ] + }, + { + "keyword": "turban", + "emoticons": [ + "👳‍♂", + "👳‍♀", + "👳" + ] + }, + { + "keyword": "=)", + "emoticons": [ + "🙂" + ] + }, + { + "keyword": "hospital", + "emoticons": [ + "🏥" + ] + }, + { + "keyword": "trash", + "emoticons": [ + "♻", + "🚮", + "🗑" + ] + }, + { + "keyword": "supermarket", + "emoticons": [ + "🏪", + "🏬" + ] + }, + { + "keyword": "four-thirty", + "emoticons": [ + "🕟" + ] + }, + { + "keyword": "zoom in", + "emoticons": [ + "🔎", + "🔍" + ] + }, + { + "keyword": ":(", + "emoticons": [ + "☹" + ] + }, + { + "keyword": "repeat button", + "emoticons": [ + "🔁" + ] + }, + { + "keyword": "idc", + "emoticons": [ + "🤷‍♂" + ] + }, + { + "keyword": "monaco", + "emoticons": [ + "🇲🇨" + ] + }, + { + "keyword": "robot face", + "emoticons": [ + "🤖" + ] + }, + { + "keyword": "samoa", + "emoticons": [ + "🇼🇸" + ] + }, + { + "keyword": "negligee", + "emoticons": [ + "👙" + ] + }, + { + "keyword": "women's room", + "emoticons": [ + "🚺" + ] + }, + { + "keyword": "rip", + "emoticons": [ + "⚰", + "🪦" + ] + }, + { + "keyword": "woman bowing", + "emoticons": [ + "🙇‍♀" + ] + }, + { + "keyword": "10", + "emoticons": [ + "🔟" + ] + }, + { + "keyword": "unicorn face", + "emoticons": [ + "🦄" + ] + }, + { + "keyword": "id", + "emoticons": [ + "🇮🇩" + ] + }, + { + "keyword": "queen", + "emoticons": [ + "👸", + "🫅" + ] + }, + { + "keyword": "soda", + "emoticons": [ + "🥤" + ] + }, + { + "keyword": "upside down face", + "emoticons": [ + "🙃" + ] + }, + { + "keyword": "cupid", + "emoticons": [ + "💘" + ] + }, + { + "keyword": "scarf", + "emoticons": [ + "🧣" + ] + }, + { + "keyword": "spook", + "emoticons": [ + "👻" + ] + }, + { + "keyword": "innocent", + "emoticons": [ + "😇" + ] + }, + { + "keyword": "feather", + "emoticons": [ + "🪶" + ] + }, + { + "keyword": "shush", + "emoticons": [ + "😶", + "🤫", + "🤭" + ] + }, + { + "keyword": "fax", + "emoticons": [ + "🖨", + "📠" + ] + }, + { + "keyword": "family_wwb", + "emoticons": [ + "👩‍👩‍👦" + ] + }, + { + "keyword": "prize", + "emoticons": [ + "🥇", + "🥉", + "🏆", + "🏅", + "🥈" + ] + }, + { + "keyword": "sailboat", + "emoticons": [ + "⛵" + ] + }, + { + "keyword": "meat", + "emoticons": [ + "🍖", + "🥩" + ] + }, + { + "keyword": "curly loop", + "emoticons": [ + "➰" + ] + }, + { + "keyword": "city", + "emoticons": [ + "🌆" + ] + }, + { + "keyword": "wink", + "emoticons": [ + "😉", + "😜" + ] + }, + { + "keyword": "drool", + "emoticons": [ + "🤤" + ] + }, + { + "keyword": "lithuania", + "emoticons": [ + "🇱🇹" + ] + }, + { + "keyword": "flag_pe", + "emoticons": [ + "🇵🇪" + ] + }, + { + "keyword": "high volume", + "emoticons": [ + "🔊" + ] + }, + { + "keyword": "two men holding hands", + "emoticons": [ + "👬" + ] + }, + { + "keyword": "manicure", + "emoticons": [ + "💅" + ] + }, + { + "keyword": "keycap asterisk", + "emoticons": [ + "*⃣" + ] + }, + { + "keyword": "one", + "emoticons": [ + "☝", + "1⃣" + ] + }, + { + "keyword": "white cross", + "emoticons": [ + "❎" + ] + }, + { + "keyword": "junk food", + "emoticons": [ + "🍟", + "🍕", + "🌭", + "🍔" + ] + }, + { + "keyword": "raised back of hand", + "emoticons": [ + "🤚" + ] + }, + { + "keyword": "flag_mt", + "emoticons": [ + "🇲🇹" + ] + }, + { + "keyword": "present", + "emoticons": [ + "🎁", + "🎈" + ] + }, + { + "keyword": "ice cube", + "emoticons": [ + "🧊" + ] + }, + { + "keyword": "dribble", + "emoticons": [ + "⛹" + ] + }, + { + "keyword": "cityscape", + "emoticons": [ + "🏙" + ] + }, + { + "keyword": "quiet", + "emoticons": [ + "🤫" + ] + }, + { + "keyword": "musical note", + "emoticons": [ + "🎵" + ] + }, + { + "keyword": "collision", + "emoticons": [ + "💥" + ] + }, + { + "keyword": "muscle", + "emoticons": [ + "💪" + ] + }, + { + "keyword": "soon arrow", + "emoticons": [ + "🔜" + ] + }, + { + "keyword": "beaver", + "emoticons": [ + "🦫" + ] + }, + { + "keyword": "clock1130", + "emoticons": [ + "🕦" + ] + }, + { + "keyword": "pk", + "emoticons": [ + "🇵🇰" + ] + }, + { + "keyword": "twelve-thirty", + "emoticons": [ + "🕧" + ] + }, + { + "keyword": "joy", + "emoticons": [ + "😹", + "😂" + ] + }, + { + "keyword": "full moon", + "emoticons": [ + "🌝", + "🌕" + ] + }, + { + "keyword": "motorized wheelchair", + "emoticons": [ + "🦼" + ] + }, + { + "keyword": "service change", + "emoticons": [ + "🈂" + ] + }, + { + "keyword": "nature", + "emoticons": [ + "🏕" + ] + }, + { + "keyword": "muslims", + "emoticons": [ + "☪" + ] + }, + { + "keyword": "asterisk", + "emoticons": [ + "✳", + "*⃣" + ] + }, + { + "keyword": "family_mmgb", + "emoticons": [ + "👨‍👨‍👧‍👦" + ] + }, + { + "keyword": "freeze", + "emoticons": [ + "🥶" + ] + }, + { + "keyword": "prescription", + "emoticons": [ + "💊" + ] + }, + { + "keyword": "micronesia", + "emoticons": [ + "🇫🇲" + ] + }, + { + "keyword": "ballet", + "emoticons": [ + "🩰" + ] + }, + { + "keyword": "musical notes", + "emoticons": [ + "🎶" + ] + }, + { + "keyword": "wool", + "emoticons": [ + "🐑" + ] + }, + { + "keyword": "woman lifting weights", + "emoticons": [ + "🏋‍♀" + ] + }, + { + "keyword": "no mail", + "emoticons": [ + "📭" + ] + }, + { + "keyword": "atm sign", + "emoticons": [ + "🏧" + ] + }, + { + "keyword": "aruba", + "emoticons": [ + "🇦🇼" + ] + }, + { + "keyword": "supervillain", + "emoticons": [ + "🦹‍♀", + "🦹", + "🦹‍♂" + ] + }, + { + "keyword": "arriving", + "emoticons": [ + "🛬" + ] + }, + { + "keyword": "magnet", + "emoticons": [ + "🧲" + ] + }, + { + "keyword": "casino", + "emoticons": [ + "🎰" + ] + }, + { + "keyword": "dart", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "running shirt", + "emoticons": [ + "🎽" + ] + }, + { + "keyword": "heavy minus sign", + "emoticons": [ + "➖" + ] + }, + { + "keyword": "scared", + "emoticons": [ + "😨", + "🙀" + ] + }, + { + "keyword": "rhino", + "emoticons": [ + "🦏" + ] + }, + { + "keyword": "joyous", + "emoticons": [ + "😃", + "😁", + "😄", + "😀", + "☺" + ] + }, + { + "keyword": "evil eye", + "emoticons": [ + "🧿" + ] + }, + { + "keyword": "woman genie", + "emoticons": [ + "🧞‍♀" + ] + }, + { + "keyword": "dizzy", + "emoticons": [ + "😵", + "💫", + "🥴" + ] + }, + { + "keyword": "brazzaville", + "emoticons": [ + "🇨🇬" + ] + }, + { + "keyword": "bus", + "emoticons": [ + "🚍", + "🚌" + ] + }, + { + "keyword": "left arrow", + "emoticons": [ + "⬅" + ] + }, + { + "keyword": "axe", + "emoticons": [ + "🪓" + ] + }, + { + "keyword": "cx", + "emoticons": [ + "🇨🇽" + ] + }, + { + "keyword": "shaver", + "emoticons": [ + "🪒" + ] + }, + { + "keyword": "cool button", + "emoticons": [ + "🆒" + ] + }, + { + "keyword": "church", + "emoticons": [ + "💒", + "⛪" + ] + }, + { + "keyword": "slobber", + "emoticons": [ + "🤤" + ] + }, + { + "keyword": "pouting", + "emoticons": [ + "🙎‍♂", + "🙎", + "🙎‍♀" + ] + }, + { + "keyword": "keycap digit four", + "emoticons": [ + "4⃣" + ] + }, + { + "keyword": "palm tree", + "emoticons": [ + "🌴" + ] + }, + { + "keyword": "person bouncing ball", + "emoticons": [ + "⛹‍♀", + "⛹‍♂" + ] + }, + { + "keyword": "eleven o'clock", + "emoticons": [ + "🕚" + ] + }, + { + "keyword": "pf", + "emoticons": [ + "🇵🇫" + ] + }, + { + "keyword": "keycap digit six", + "emoticons": [ + "6⃣" + ] + }, + { + "keyword": "roller derby", + "emoticons": [ + "🛼" + ] + }, + { + "keyword": "poseidon", + "emoticons": [ + "🧜" + ] + }, + { + "keyword": "ferris wheel", + "emoticons": [ + "🎡" + ] + }, + { + "keyword": "rude", + "emoticons": [ + "🖕" + ] + }, + { + "keyword": "graze", + "emoticons": [ + "🐮" + ] + }, + { + "keyword": "bd", + "emoticons": [ + "🇧🇩" + ] + }, + { + "keyword": "eight-thirty", + "emoticons": [ + "🕣" + ] + }, + { + "keyword": "vs button", + "emoticons": [ + "🆚" + ] + }, + { + "keyword": "grandmother", + "emoticons": [ + "👵" + ] + }, + { + "keyword": "gloomy", + "emoticons": [ + "☹", + "🙁" + ] + }, + { + "keyword": "flag_gh", + "emoticons": [ + "🇬🇭" + ] + }, + { + "keyword": "pointing down", + "emoticons": [ + "👇" + ] + }, + { + "keyword": "thought ballon", + "emoticons": [ + "💭" + ] + }, + { + "keyword": "delicious", + "emoticons": [ + "😋" + ] + }, + { + "keyword": "sexy", + "emoticons": [ + "😍", + "😻" + ] + }, + { + "keyword": "grinning face", + "emoticons": [ + "😁" + ] + }, + { + "keyword": "bolt", + "emoticons": [ + "🔩" + ] + }, + { + "keyword": "hushed", + "emoticons": [ + "😯" + ] + }, + { + "keyword": "spider", + "emoticons": [ + "🕷" + ] + }, + { + "keyword": "school", + "emoticons": [ + "🏫" + ] + }, + { + "keyword": "dark sunglasses", + "emoticons": [ + "🕶" + ] + }, + { + "keyword": "flag_ua", + "emoticons": [ + "🇺🇦" + ] + }, + { + "keyword": "na", + "emoticons": [ + "🇳🇦" + ] + }, + { + "keyword": "thumbnail", + "emoticons": [ + "🎥" + ] + }, + { + "keyword": "clock4", + "emoticons": [ + "🕓" + ] + }, + { + "keyword": "lollipop", + "emoticons": [ + "🍭" + ] + }, + { + "keyword": "beer mug", + "emoticons": [ + "🍺", + "🍻" + ] + }, + { + "keyword": "black large square", + "emoticons": [ + "⬛" + ] + }, + { + "keyword": "zero", + "emoticons": [ + "0⃣" + ] + }, + { + "keyword": "graduation", + "emoticons": [ + "🧑‍🎓" + ] + }, + { + "keyword": "radio button", + "emoticons": [ + "🔘" + ] + }, + { + "keyword": "hugging", + "emoticons": [ + "🤗", + "🫂" + ] + }, + { + "keyword": "wrestlers", + "emoticons": [ + "🤼‍♂", + "🤼‍♀" + ] + }, + { + "keyword": "predict", + "emoticons": [ + "🎱" + ] + }, + { + "keyword": "rwanda", + "emoticons": [ + "🇷🇼" + ] + }, + { + "keyword": "free of charge", + "emoticons": [ + "🈚" + ] + }, + { + "keyword": "pepper", + "emoticons": [ + "🫑", + "🌶" + ] + }, + { + "keyword": "lr", + "emoticons": [ + "🇱🇷" + ] + }, + { + "keyword": "never", + "emoticons": [ + "🙅" + ] + }, + { + "keyword": "delete", + "emoticons": [ + "❌", + "🗑" + ] + }, + { + "keyword": "service dog", + "emoticons": [ + "🐕‍🦺" + ] + }, + { + "keyword": "lip", + "emoticons": [ + "🫦" + ] + }, + { + "keyword": "windows", + "emoticons": [ + "💻" + ] + }, + { + "keyword": "investigator", + "emoticons": [ + "🕵" + ] + }, + { + "keyword": "mv", + "emoticons": [ + "🇲🇻" + ] + }, + { + "keyword": "libya", + "emoticons": [ + "🇱🇾" + ] + }, + { + "keyword": "game die", + "emoticons": [ + "🎲" + ] + }, + { + "keyword": "diya", + "emoticons": [ + "🪔" + ] + }, + { + "keyword": "butterfly", + "emoticons": [ + "🦋" + ] + }, + { + "keyword": "author", + "emoticons": [ + "✍" + ] + }, + { + "keyword": "ant", + "emoticons": [ + "🐜" + ] + }, + { + "keyword": "scream cat", + "emoticons": [ + "🙀" + ] + }, + { + "keyword": "spider spider web", + "emoticons": [ + "🕸", + "🕷" + ] + }, + { + "keyword": "radioactive", + "emoticons": [ + "☢" + ] + }, + { + "keyword": "sign", + "emoticons": [ + "🙆", + "🛑" + ] + }, + { + "keyword": "left-facing fist", + "emoticons": [ + "🤛" + ] + }, + { + "keyword": "chocolate", + "emoticons": [ + "🍫" + ] + }, + { + "keyword": "scouts", + "emoticons": [ + "⚜" + ] + }, + { + "keyword": "flag_rs", + "emoticons": [ + "🇷🇸" + ] + }, + { + "keyword": "flag_cf", + "emoticons": [ + "🇨🇫" + ] + }, + { + "keyword": "open-eyed", + "emoticons": [ + "😮" + ] + }, + { + "keyword": "sint maarten", + "emoticons": [ + "🇸🇽" + ] + }, + { + "keyword": "flag_mx", + "emoticons": [ + "🇲🇽" + ] + }, + { + "keyword": "rest", + "emoticons": [ + "🛋" + ] + }, + { + "keyword": "greece", + "emoticons": [ + "🇬🇷" + ] + }, + { + "keyword": "moto", + "emoticons": [ + "🛵", + "🏍" + ] + }, + { + "keyword": "truck", + "emoticons": [ + "🚛", + "🛻", + "🚚" + ] + }, + { + "keyword": "en", + "emoticons": [ + "🏴󠁧󠁢󠁥󠁮󠁧󠁿" + ] + }, + { + "keyword": "jandals ", + "emoticons": [ + "🩴" + ] + }, + { + "keyword": "thunderbolt", + "emoticons": [ + "⚡" + ] + }, + { + "keyword": "equatorial guinea", + "emoticons": [ + "🇬🇶" + ] + }, + { + "keyword": "keycap digit three", + "emoticons": [ + "3⃣" + ] + }, + { + "keyword": "person running", + "emoticons": [ + "🏃‍♂", + "🏃‍♀" + ] + }, + { + "keyword": "slider", + "emoticons": [ + "🎚" + ] + }, + { + "keyword": "deutschland", + "emoticons": [ + "🇩🇪" + ] + }, + { + "keyword": "raising", + "emoticons": [ + "🙋‍♀", + "🙋‍♂" + ] + }, + { + "keyword": "trolleybus", + "emoticons": [ + "🚎" + ] + }, + { + "keyword": "biceps", + "emoticons": [ + "💪", + "🦾" + ] + }, + { + "keyword": "broken", + "emoticons": [ + "💔" + ] + }, + { + "keyword": "relaxed", + "emoticons": [ + "😊", + "☺" + ] + }, + { + "keyword": "!", + "emoticons": [ + "❕", + "‼" + ] + }, + { + "keyword": "baggage", + "emoticons": [ + "🛄" + ] + }, + { + "keyword": "puppy", + "emoticons": [ + "🐶" + ] + }, + { + "keyword": "road", + "emoticons": [ + "🛣" + ] + }, + { + "keyword": "banjo", + "emoticons": [ + "🪕" + ] + }, + { + "keyword": "hourglass with flowing sand", + "emoticons": [ + "⏳" + ] + }, + { + "keyword": "iranian", + "emoticons": [ + "🇮🇷" + ] + }, + { + "keyword": "camping", + "emoticons": [ + "🏕" + ] + }, + { + "keyword": "ch", + "emoticons": [ + "🇨🇭" + ] + }, + { + "keyword": "clock5", + "emoticons": [ + "🕔" + ] + }, + { + "keyword": "purple square", + "emoticons": [ + "🟪" + ] + }, + { + "keyword": "headscarf", + "emoticons": [ + "🧕" + ] + }, + { + "keyword": "ruler", + "emoticons": [ + "📐", + "📏" + ] + }, + { + "keyword": "brunei", + "emoticons": [ + "🇧🇳" + ] + }, + { + "keyword": "ugh", + "emoticons": [ + "😐", + "🫤", + "😑" + ] + }, + { + "keyword": "afrika", + "emoticons": [ + "🌍" + ] + }, + { + "keyword": "san marino", + "emoticons": [ + "🇸🇲" + ] + }, + { + "keyword": "tanzania", + "emoticons": [ + "🇹🇿" + ] + }, + { + "keyword": "nesting dolls", + "emoticons": [ + "🪆" + ] + }, + { + "keyword": "man in steamy room", + "emoticons": [ + "🧖‍♂" + ] + }, + { + "keyword": "coder", + "emoticons": [ + "👩‍💻", + "🧑‍💻", + "👨‍💻" + ] + }, + { + "keyword": "muted speaker", + "emoticons": [ + "🔇" + ] + }, + { + "keyword": "smirk face", + "emoticons": [ + "😏" + ] + }, + { + "keyword": "gr", + "emoticons": [ + "🇬🇷" + ] + }, + { + "keyword": "laundry", + "emoticons": [ + "🧺" + ] + }, + { + "keyword": "please", + "emoticons": [ + "🥺", + "🙏" + ] + }, + { + "keyword": "keycap digit eight", + "emoticons": [ + "8⃣" + ] + }, + { + "keyword": "left hand", + "emoticons": [ + "🫳" + ] + }, + { + "keyword": "mosque", + "emoticons": [ + "🕌" + ] + }, + { + "keyword": "victory", + "emoticons": [ + "✌" + ] + }, + { + "keyword": "clue", + "emoticons": [ + "🔎", + "🔍" + ] + }, + { + "keyword": "magician", + "emoticons": [ + "🧙" + ] + }, + { + "keyword": "three-thirty", + "emoticons": [ + "🕞" + ] + }, + { + "keyword": "dancing", + "emoticons": [ + "💃", + "🕺" + ] + }, + { + "keyword": "be", + "emoticons": [ + "🇧🇪" + ] + }, + { + "keyword": "globe with meridians", + "emoticons": [ + "🌐" + ] + }, + { + "keyword": "skunk", + "emoticons": [ + "🦨" + ] + }, + { + "keyword": "bookmark tabs", + "emoticons": [ + "📑" + ] + }, + { + "keyword": "panda face", + "emoticons": [ + "🐼" + ] + }, + { + "keyword": "tortoise", + "emoticons": [ + "🐢" + ] + }, + { + "keyword": "below", + "emoticons": [ + "👇" + ] + }, + { + "keyword": "previous", + "emoticons": [ + "◀" + ] + }, + { + "keyword": "woman raising hand", + "emoticons": [ + "🙋‍♀" + ] + }, + { + "keyword": "fast down button", + "emoticons": [ + "⏬" + ] + }, + { + "keyword": "panama", + "emoticons": [ + "🇵🇦" + ] + }, + { + "keyword": "py", + "emoticons": [ + "🇵🇾" + ] + }, + { + "keyword": "incoming envelope", + "emoticons": [ + "📨" + ] + }, + { + "keyword": "couple mm", + "emoticons": [ + "👨‍❤‍👨" + ] + }, + { + "keyword": "man mage", + "emoticons": [ + "🧙‍♂" + ] + }, + { + "keyword": "abort", + "emoticons": [ + "🙅‍♀", + "🙅", + "🙅‍♂" + ] + }, + { + "keyword": "jigsaw", + "emoticons": [ + "🧩" + ] + }, + { + "keyword": "wi-fi", + "emoticons": [ + "🌐", + "📡", + "📶" + ] + }, + { + "keyword": "factory worker", + "emoticons": [ + "🧑‍🏭" + ] + }, + { + "keyword": "stadium", + "emoticons": [ + "🏟" + ] + }, + { + "keyword": "autorickshaw", + "emoticons": [ + "🛺" + ] + }, + { + "keyword": "sport stadium", + "emoticons": [ + "🏟" + ] + }, + { + "keyword": "grin", + "emoticons": [ + "😬", + "😁" + ] + }, + { + "keyword": "alarm", + "emoticons": [ + "🚨" + ] + }, + { + "keyword": "idk", + "emoticons": [ + "🤷‍♀", + "🤷‍♂" + ] + }, + { + "keyword": "breathe out", + "emoticons": [ + "😮‍💨" + ] + }, + { + "keyword": "owl", + "emoticons": [ + "🦉" + ] + }, + { + "keyword": "people hugging", + "emoticons": [ + "🫂" + ] + }, + { + "keyword": "detect", + "emoticons": [ + "🔍" + ] + }, + { + "keyword": "people wrestling", + "emoticons": [ + "🤼‍♂" + ] + }, + { + "keyword": "forever", + "emoticons": [ + "♾" + ] + }, + { + "keyword": "keycap number", + "emoticons": [ + "#⃣" + ] + }, + { + "keyword": "=(", + "emoticons": [ + "🙁", + "☹" + ] + }, + { + "keyword": "person lifting weights", + "emoticons": [ + "🏋‍♀", + "🏋‍♂" + ] + }, + { + "keyword": "speaker high volume", + "emoticons": [ + "🔊" + ] + }, + { + "keyword": "ear of corn", + "emoticons": [ + "🌽" + ] + }, + { + "keyword": "bt", + "emoticons": [ + "🇧🇹" + ] + }, + { + "keyword": "keycap digit two", + "emoticons": [ + "2⃣" + ] + }, + { + "keyword": "rabbit face", + "emoticons": [ + "🐰" + ] + }, + { + "keyword": "ye", + "emoticons": [ + "🇾🇪" + ] + }, + { + "keyword": "teacup", + "emoticons": [ + "🍵" + ] + }, + { + "keyword": "flag_lc", + "emoticons": [ + "🇱🇨" + ] + }, + { + "keyword": "uy", + "emoticons": [ + "🇺🇾" + ] + }, + { + "keyword": "prayer", + "emoticons": [ + "🙏" + ] + }, + { + "keyword": "fountain", + "emoticons": [ + "🐳", + "⛲" + ] + }, + { + "keyword": "tea", + "emoticons": [ + "☕", + "🍵" + ] + }, + { + "keyword": "bosnia \u0026 herzegovina", + "emoticons": [ + "🇧🇦" + ] + }, + { + "keyword": "flag_nf", + "emoticons": [ + "🇳🇫" + ] + }, + { + "keyword": "mouth watering", + "emoticons": [ + "😋", + "🤤" + ] + }, + { + "keyword": "martinique", + "emoticons": [ + "🇲🇶" + ] + }, + { + "keyword": "scorpio", + "emoticons": [ + "♏" + ] + }, + { + "keyword": "dango", + "emoticons": [ + "🍡" + ] + }, + { + "keyword": "jo", + "emoticons": [ + "🇯🇴" + ] + }, + { + "keyword": "black joker", + "emoticons": [ + "🃏" + ] + }, + { + "keyword": "czechia", + "emoticons": [ + "🇨🇿" + ] + }, + { + "keyword": "tophat", + "emoticons": [ + "🎩" + ] + }, + { + "keyword": "american samoa", + "emoticons": [ + "🇦🇸" + ] + }, + { + "keyword": "clown", + "emoticons": [ + "🤡", + "🤹" + ] + }, + { + "keyword": "cyclist", + "emoticons": [ + "🚵", + "🚴" + ] + }, + { + "keyword": "battery", + "emoticons": [ + "🔋" + ] + }, + { + "keyword": "mn", + "emoticons": [ + "🇲🇳" + ] + }, + { + "keyword": "come on", + "emoticons": [ + "🤌" + ] + }, + { + "keyword": "kangaroo", + "emoticons": [ + "🦘" + ] + }, + { + "keyword": "abandoned house", + "emoticons": [ + "🏚" + ] + }, + { + "keyword": "david", + "emoticons": [ + "✡" + ] + }, + { + "keyword": "chart with downward trend", + "emoticons": [ + "📉" + ] + }, + { + "keyword": "pedestrian", + "emoticons": [ + "🚶" + ] + }, + { + "keyword": "sc", + "emoticons": [ + "🇸🇨" + ] + }, + { + "keyword": "mandarin", + "emoticons": [ + "🍊" + ] + }, + { + "keyword": "game dice", + "emoticons": [ + "🎲" + ] + }, + { + "keyword": "six pointed star", + "emoticons": [ + "🔯" + ] + }, + { + "keyword": "yep", + "emoticons": [ + "👌" + ] + }, + { + "keyword": "film", + "emoticons": [ + "📽", + "🎞", + "🎬", + "📺", + "🎥" + ] + }, + { + "keyword": "pin", + "emoticons": [ + "📍", + "📌", + "🧷" + ] + }, + { + "keyword": "sloth", + "emoticons": [ + "🦥" + ] + }, + { + "keyword": "japanese bargain button", + "emoticons": [ + "🉐" + ] + }, + { + "keyword": "latin cross", + "emoticons": [ + "✝" + ] + }, + { + "keyword": "trident", + "emoticons": [ + "🔱" + ] + }, + { + "keyword": "flag_hn", + "emoticons": [ + "🇭🇳" + ] + }, + { + "keyword": "blegh", + "emoticons": [ + "🤮" + ] + }, + { + "keyword": "milktea", + "emoticons": [ + "🧋" + ] + }, + { + "keyword": "peach", + "emoticons": [ + "🍑" + ] + }, + { + "keyword": "ascension island", + "emoticons": [ + "🇦🇨" + ] + }, + { + "keyword": "no good", + "emoticons": [ + "🙅‍♀", + "🙅" + ] + }, + { + "keyword": "cy", + "emoticons": [ + "🇨🇾" + ] + }, + { + "keyword": "flag_nl", + "emoticons": [ + "🇳🇱" + ] + }, + { + "keyword": "shrugs", + "emoticons": [ + "🤷‍♂" + ] + }, + { + "keyword": "pen ballpoint", + "emoticons": [ + "🖊" + ] + }, + { + "keyword": "information source", + "emoticons": [ + "ℹ" + ] + }, + { + "keyword": "cartwhelling", + "emoticons": [ + "🤸‍♂" + ] + }, + { + "keyword": "fearful", + "emoticons": [ + "🙀", + "😨" + ] + }, + { + "keyword": "cayman islands", + "emoticons": [ + "🇰🇾" + ] + }, + { + "keyword": "pilot", + "emoticons": [ + "🧑‍✈", + "👩‍✈", + "👨‍✈" + ] + }, + { + "keyword": "rolling eyes face", + "emoticons": [ + "🙄" + ] + }, + { + "keyword": "man shrugging", + "emoticons": [ + "🤷‍♂" + ] + }, + { + "keyword": "smartphone", + "emoticons": [ + "📱", + "📲" + ] + }, + { + "keyword": "construction site", + "emoticons": [ + "🏗" + ] + }, + { + "keyword": "arrows counterclockwise", + "emoticons": [ + "🔄" + ] + }, + { + "keyword": "bearded lady", + "emoticons": [ + "🧔‍♀" + ] + }, + { + "keyword": "angel", + "emoticons": [ + "😇", + "👼" + ] + }, + { + "keyword": "bh", + "emoticons": [ + "🇧🇭" + ] + }, + { + "keyword": "telephone reciever", + "emoticons": [ + "📞" + ] + }, + { + "keyword": "mouse", + "emoticons": [ + "🖱", + "🐭", + "🐁" + ] + }, + { + "keyword": "man in tuxedo", + "emoticons": [ + "🤵" + ] + }, + { + "keyword": "family_mwgg", + "emoticons": [ + "👨‍👩‍👧‍👧" + ] + }, + { + "keyword": "=|", + "emoticons": [ + "😐" + ] + }, + { + "keyword": "flag_sg", + "emoticons": [ + "🇸🇬" + ] + }, + { + "keyword": "forgive", + "emoticons": [ + "🙏" + ] + }, + { + "keyword": "top", + "emoticons": [ + "👆", + "💯", + "🔝", + "🏆" + ] + }, + { + "keyword": "pause button", + "emoticons": [ + "⏸" + ] + }, + { + "keyword": "attack", + "emoticons": [ + "🤬", + "👊" + ] + }, + { + "keyword": "notebook", + "emoticons": [ + "📔", + "📓" + ] + }, + { + "keyword": "european union", + "emoticons": [ + "🇪🇺" + ] + }, + { + "keyword": "brown circle", + "emoticons": [ + "🟤" + ] + }, + { + "keyword": "grooming", + "emoticons": [ + "🐩" + ] + }, + { + "keyword": "tropical drink", + "emoticons": [ + "🍹" + ] + }, + { + "keyword": "bodybuilder", + "emoticons": [ + "🏋" + ] + }, + { + "keyword": "bouquet", + "emoticons": [ + "💐" + ] + }, + { + "keyword": "canoe", + "emoticons": [ + "🛶" + ] + }, + { + "keyword": "fm", + "emoticons": [ + "🇫🇲" + ] + }, + { + "keyword": "flag_im", + "emoticons": [ + "🇮🇲" + ] + }, + { + "keyword": "wait what", + "emoticons": [ + "😮", + "😲", + "😯" + ] + }, + { + "keyword": "ethiopia", + "emoticons": [ + "🇪🇹" + ] + }, + { + "keyword": "graduate", + "emoticons": [ + "🧑‍🎓" + ] + }, + { + "keyword": "female athlete", + "emoticons": [ + "🏃‍♀" + ] + }, + { + "keyword": "brain", + "emoticons": [ + "🧠" + ] + }, + { + "keyword": "cinema", + "emoticons": [ + "🎦" + ] + }, + { + "keyword": "fluffy", + "emoticons": [ + "🐇" + ] + }, + { + "keyword": "incoming", + "emoticons": [ + "📫", + "📥", + "📬" + ] + }, + { + "keyword": "saturn", + "emoticons": [ + "🪐" + ] + }, + { + "keyword": "shoot", + "emoticons": [ + "🔫" + ] + }, + { + "keyword": "raised eyebrow", + "emoticons": [ + "🤨" + ] + }, + { + "keyword": "race car", + "emoticons": [ + "🏎" + ] + }, + { + "keyword": "sihouette", + "emoticons": [ + "🗣" + ] + }, + { + "keyword": "middle finger", + "emoticons": [ + "🖕" + ] + }, + { + "keyword": "ski lift", + "emoticons": [ + "🚡" + ] + }, + { + "keyword": "noodles", + "emoticons": [ + "🍜" + ] + }, + { + "keyword": "family mwgg", + "emoticons": [ + "👨‍👩‍👧‍👧" + ] + }, + { + "keyword": "taiwan", + "emoticons": [ + "🇹🇼" + ] + }, + { + "keyword": "downwards button", + "emoticons": [ + "🔽" + ] + }, + { + "keyword": "reef", + "emoticons": [ + "🪸" + ] + }, + { + "keyword": "boom", + "emoticons": [ + "💣", + "💥", + "🤯" + ] + }, + { + "keyword": "deciduous tree", + "emoticons": [ + "🌳" + ] + }, + { + "keyword": "slide", + "emoticons": [ + "🛝" + ] + }, + { + "keyword": "speech balloon", + "emoticons": [ + "💬" + ] + }, + { + "keyword": "squid", + "emoticons": [ + "🦑" + ] + }, + { + "keyword": "broom", + "emoticons": [ + "🧹" + ] + }, + { + "keyword": "white sun rain cloud", + "emoticons": [ + "🌦" + ] + }, + { + "keyword": "envelope with arrow", + "emoticons": [ + "📩" + ] + }, + { + "keyword": "blade", + "emoticons": [ + "🪒" + ] + }, + { + "keyword": "kz", + "emoticons": [ + "🇰🇿" + ] + }, + { + "keyword": "100%", + "emoticons": [ + "💯" + ] + }, + { + "keyword": "giant", + "emoticons": [ + "🐘" + ] + }, + { + "keyword": "relieved", + "emoticons": [ + "😌", + "🥲" + ] + }, + { + "keyword": "woman playing water polo", + "emoticons": [ + "🤽‍♀" + ] + }, + { + "keyword": "high voltage", + "emoticons": [ + "⚡" + ] + }, + { + "keyword": "best of luck", + "emoticons": [ + "🤞" + ] + }, + { + "keyword": "kaaba", + "emoticons": [ + "🕋" + ] + }, + { + "keyword": "sweat smile", + "emoticons": [ + "😅" + ] + }, + { + "keyword": "uganda", + "emoticons": [ + "🇺🇬" + ] + }, + { + "keyword": "baa", + "emoticons": [ + "🐑" + ] + }, + { + "keyword": "tokelau", + "emoticons": [ + "🇹🇰" + ] + }, + { + "keyword": "first place medal", + "emoticons": [ + "🥇" + ] + }, + { + "keyword": "st. pierre \u0026 miquelon", + "emoticons": [ + "🇵🇲" + ] + }, + { + "keyword": "pound", + "emoticons": [ + "💷" + ] + }, + { + "keyword": "flag_ne", + "emoticons": [ + "🇳🇪" + ] + }, + { + "keyword": "stuck out tongue winking eye", + "emoticons": [ + "😜" + ] + }, + { + "keyword": "let hand", + "emoticons": [ + "🫱" + ] + }, + { + "keyword": "football", + "emoticons": [ + "⚽", + "🏈" + ] + }, + { + "keyword": "mens", + "emoticons": [ + "🚹" + ] + }, + { + "keyword": "sarcasm", + "emoticons": [ + "🙃" + ] + }, + { + "keyword": "comoros", + "emoticons": [ + "🇰🇲" + ] + }, + { + "keyword": "mali", + "emoticons": [ + "🇲🇱" + ] + }, + { + "keyword": "heart eyes cat", + "emoticons": [ + "😻" + ] + }, + { + "keyword": "return", + "emoticons": [ + "⬅" + ] + }, + { + "keyword": "ask", + "emoticons": [ + "🫴" + ] + }, + { + "keyword": "bw", + "emoticons": [ + "🇧🇼" + ] + }, + { + "keyword": "tooth", + "emoticons": [ + "🦷" + ] + }, + { + "keyword": "pastor", + "emoticons": [ + "⛪", + "✝" + ] + }, + { + "keyword": "no bell", + "emoticons": [ + "🔕" + ] + }, + { + "keyword": "busts in silhouette", + "emoticons": [ + "👥" + ] + }, + { + "keyword": "place", + "emoticons": [ + "📍" + ] + }, + { + "keyword": "cherry", + "emoticons": [ + "🌸", + "🍒" + ] + }, + { + "keyword": "cocos islands", + "emoticons": [ + "🇨🇨" + ] + }, + { + "keyword": "tw", + "emoticons": [ + "🇹🇼" + ] + }, + { + "keyword": "dribbling", + "emoticons": [ + "⛹" + ] + }, + { + "keyword": "ac", + "emoticons": [ + "🇦🇨" + ] + }, + { + "keyword": "meditate", + "emoticons": [ + "🧘‍♀", + "🧘‍♂" + ] + }, + { + "keyword": "boxing glove", + "emoticons": [ + "🥊" + ] + }, + { + "keyword": "find", + "emoticons": [ + "🧐" + ] + }, + { + "keyword": "pigeon", + "emoticons": [ + "🐦" + ] + }, + { + "keyword": "st. barthelemy", + "emoticons": [ + "🇧🇱" + ] + }, + { + "keyword": "clock11", + "emoticons": [ + "🕚" + ] + }, + { + "keyword": "family mmg", + "emoticons": [ + "👨‍👨‍👧" + ] + }, + { + "keyword": "relief", + "emoticons": [ + "😌" + ] + }, + { + "keyword": "stethoscope", + "emoticons": [ + "🩺" + ] + }, + { + "keyword": "right arrow", + "emoticons": [ + "➡" + ] + }, + { + "keyword": "brinjal", + "emoticons": [ + "🍆" + ] + }, + { + "keyword": "flip flops", + "emoticons": [ + "🩴" + ] + }, + { + "keyword": "un", + "emoticons": [ + "🇺🇳" + ] + }, + { + "keyword": "linux", + "emoticons": [ + "💻", + "🐧" + ] + }, + { + "keyword": "japanese dolls", + "emoticons": [ + "🎎" + ] + }, + { + "keyword": "paw prints", + "emoticons": [ + "🐾" + ] + }, + { + "keyword": "abc", + "emoticons": [ + "🔤" + ] + }, + { + "keyword": "mouthless", + "emoticons": [ + "😶" + ] + }, + { + "keyword": "purple circle", + "emoticons": [ + "🟣" + ] + }, + { + "keyword": "shiny", + "emoticons": [ + "✨" + ] + }, + { + "keyword": "belarus", + "emoticons": [ + "🇧🇾" + ] + }, + { + "keyword": "fondue", + "emoticons": [ + "🫕" + ] + }, + { + "keyword": "frame", + "emoticons": [ + "🖼" + ] + }, + { + "keyword": "pa", + "emoticons": [ + "🇵🇦" + ] + }, + { + "keyword": "hugs", + "emoticons": [ + "🤗" + ] + }, + { + "keyword": "moustache", + "emoticons": [ + "👨‍🦳", + "👨‍🦰" + ] + }, + { + "keyword": "bahamas", + "emoticons": [ + "🇧🇸" + ] + }, + { + "keyword": "us", + "emoticons": [ + "🇺🇸" + ] + }, + { + "keyword": "handshake", + "emoticons": [ + "🤝" + ] + }, + { + "keyword": "gm", + "emoticons": [ + "🇬🇲" + ] + }, + { + "keyword": "heartwarming", + "emoticons": [ + "❤‍🔥" + ] + }, + { + "keyword": "vanuatu", + "emoticons": [ + "🇻🇺" + ] + }, + { + "keyword": "point left", + "emoticons": [ + "👈" + ] + }, + { + "keyword": "six", + "emoticons": [ + "6⃣" + ] + }, + { + "keyword": "family_wwg", + "emoticons": [ + "👩‍👩‍👧" + ] + }, + { + "keyword": "calling", + "emoticons": [ + "📲" + ] + }, + { + "keyword": "motor boat", + "emoticons": [ + "🛥" + ] + }, + { + "keyword": "i love you", + "emoticons": [ + "😗", + "😚", + "💋", + "😙", + "😽", + "😘" + ] + }, + { + "keyword": "ballet shoe", + "emoticons": [ + "🩰" + ] + }, + { + "keyword": "cut", + "emoticons": [ + "✂" + ] + }, + { + "keyword": "libra", + "emoticons": [ + "♎" + ] + }, + { + "keyword": "basketball", + "emoticons": [ + "⛹‍♂", + "⛹", + "🏀", + "⛹‍♀" + ] + }, + { + "keyword": "dove", + "emoticons": [ + "🕊" + ] + }, + { + "keyword": "aubergine", + "emoticons": [ + "🍆" + ] + }, + { + "keyword": "christmas tree", + "emoticons": [ + "🎄" + ] + }, + { + "keyword": "jm", + "emoticons": [ + "🇯🇲" + ] + }, + { + "keyword": "flag_sd", + "emoticons": [ + "🇸🇩" + ] + }, + { + "keyword": "america", + "emoticons": [ + "🇺🇸", + "🦅", + "🗽" + ] + }, + { + "keyword": "bone", + "emoticons": [ + "💀", + "🦴", + "☠" + ] + }, + { + "keyword": "upwards button", + "emoticons": [ + "🔼" + ] + }, + { + "keyword": "flag_sm", + "emoticons": [ + "🇸🇲" + ] + }, + { + "keyword": "robot", + "emoticons": [ + "🤖" + ] + }, + { + "keyword": "heavy check mark", + "emoticons": [ + "✔" + ] + }, + { + "keyword": "flag_gu", + "emoticons": [ + "🇬🇺" + ] + }, + { + "keyword": "saudi arabia", + "emoticons": [ + "🇸🇦" + ] + }, + { + "keyword": "eye", + "emoticons": [ + "👀", + "👁", + "👁‍🗨" + ] + }, + { + "keyword": "moldova", + "emoticons": [ + "🇲🇩" + ] + }, + { + "keyword": "mrs claus", + "emoticons": [ + "🤶" + ] + }, + { + "keyword": "beetle", + "emoticons": [ + "🪲", + "🐞" + ] + }, + { + "keyword": "fuck off", + "emoticons": [ + "😐", + "😑" + ] + }, + { + "keyword": "bud", + "emoticons": [ + "🌾" + ] + }, + { + "keyword": "sled", + "emoticons": [ + "🛷" + ] + }, + { + "keyword": "family mmgg", + "emoticons": [ + "👨‍👨‍👧‍👧" + ] + }, + { + "keyword": "flag_sx", + "emoticons": [ + "🇸🇽" + ] + }, + { + "keyword": "flag_ac", + "emoticons": [ + "🇦🇨" + ] + }, + { + "keyword": "panel", + "emoticons": [ + "🎛" + ] + }, + { + "keyword": "convenience store", + "emoticons": [ + "🏪" + ] + }, + { + "keyword": "splash", + "emoticons": [ + "💦" + ] + }, + { + "keyword": "four leaf clover", + "emoticons": [ + "🍀" + ] + }, + { + "keyword": "praying beads", + "emoticons": [ + "📿" + ] + }, + { + "keyword": "joy cat", + "emoticons": [ + "😹" + ] + }, + { + "keyword": "flag_ae", + "emoticons": [ + "🇦🇪" + ] + }, + { + "keyword": "rickshaw", + "emoticons": [ + "🛺" + ] + }, + { + "keyword": "city dusk", + "emoticons": [ + "🌆" + ] + }, + { + "keyword": "flag_ic", + "emoticons": [ + "🇮🇨" + ] + }, + { + "keyword": "pony", + "emoticons": [ + "🦄" + ] + }, + { + "keyword": "afghanistan", + "emoticons": [ + "🇦🇫" + ] + }, + { + "keyword": "flag_my", + "emoticons": [ + "🇲🇾" + ] + }, + { + "keyword": "brrr", + "emoticons": [ + "🥶" + ] + }, + { + "keyword": "woman’s boot", + "emoticons": [ + "👢" + ] + }, + { + "keyword": "guadeloupe", + "emoticons": [ + "🇬🇵" + ] + }, + { + "keyword": "selfie", + "emoticons": [ + "🤳" + ] + }, + { + "keyword": "family_mwbb", + "emoticons": [ + "👨‍👩‍👦‍👦" + ] + }, + { + "keyword": "flag_mc", + "emoticons": [ + "🇲🇨" + ] + }, + { + "keyword": "pitcairn islands", + "emoticons": [ + "🇵🇳" + ] + }, + { + "keyword": "man swimming", + "emoticons": [ + "🏊‍♂" + ] + }, + { + "keyword": "rocket", + "emoticons": [ + "🚀" + ] + }, + { + "keyword": "flag_be", + "emoticons": [ + "🇧🇪" + ] + }, + { + "keyword": "sea", + "emoticons": [ + "🐳" + ] + }, + { + "keyword": "clock9", + "emoticons": [ + "🕘" + ] + }, + { + "keyword": "flag_fo", + "emoticons": [ + "🇫🇴" + ] + }, + { + "keyword": "man technologist", + "emoticons": [ + "👨‍💻" + ] + }, + { + "keyword": "bl", + "emoticons": [ + "🇧🇱" + ] + }, + { + "keyword": "flag_ie", + "emoticons": [ + "🇮🇪" + ] + }, + { + "keyword": "see no evil", + "emoticons": [ + "🙈" + ] + }, + { + "keyword": "england", + "emoticons": [ + "🏴󠁧󠁢󠁥󠁮󠁧󠁿" + ] + }, + { + "keyword": "romania", + "emoticons": [ + "🇷🇴" + ] + }, + { + "keyword": "stop button", + "emoticons": [ + "⏹" + ] + }, + { + "keyword": "woman juggling", + "emoticons": [ + "🤹‍♀" + ] + }, + { + "keyword": "hypnotized", + "emoticons": [ + "😵‍💫" + ] + }, + { + "keyword": "woman zombie", + "emoticons": [ + "🧟‍♀" + ] + }, + { + "keyword": "bicyclist", + "emoticons": [ + "🚵", + "🚴" + ] + }, + { + "keyword": "grief", + "emoticons": [ + "😔", + "😞" + ] + }, + { + "keyword": "older man", + "emoticons": [ + "👨‍🦳" + ] + }, + { + "keyword": "llap", + "emoticons": [ + "🖖" + ] + }, + { + "keyword": "flag_qa", + "emoticons": [ + "🇶🇦" + ] + }, + { + "keyword": "flag_si", + "emoticons": [ + "🇸🇮" + ] + }, + { + "keyword": "attention", + "emoticons": [ + "⚠" + ] + }, + { + "keyword": "neutral face", + "emoticons": [ + "😐" + ] + }, + { + "keyword": ";p", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "kn", + "emoticons": [ + "🇰🇳" + ] + }, + { + "keyword": "clairvoyance", + "emoticons": [ + "🔮" + ] + }, + { + "keyword": "tombstone", + "emoticons": [ + "🪦" + ] + }, + { + "keyword": "bosnia and herzegovina", + "emoticons": [ + "🇧🇦" + ] + }, + { + "keyword": "sh", + "emoticons": [ + "🇸🇭" + ] + }, + { + "keyword": "bq", + "emoticons": [ + "🇧🇶" + ] + }, + { + "keyword": "nice", + "emoticons": [ + "👍", + "👌", + "😉" + ] + }, + { + "keyword": "jackpot", + "emoticons": [ + "🎰" + ] + }, + { + "keyword": "mp", + "emoticons": [ + "🇲🇵" + ] + }, + { + "keyword": "sneaker", + "emoticons": [ + "👟" + ] + }, + { + "keyword": "iceland", + "emoticons": [ + "🇮🇸" + ] + }, + { + "keyword": "netherlands", + "emoticons": [ + "🇳🇱" + ] + }, + { + "keyword": "cook islands", + "emoticons": [ + "🇨🇰" + ] + }, + { + "keyword": "books", + "emoticons": [ + "📚" + ] + }, + { + "keyword": "full moon with face", + "emoticons": [ + "🌝" + ] + }, + { + "keyword": "grave", + "emoticons": [ + "⚰", + "🪦" + ] + }, + { + "keyword": "knuckles", + "emoticons": [ + "👊" + ] + }, + { + "keyword": "man raising hand", + "emoticons": [ + "🙋‍♂" + ] + }, + { + "keyword": "up! button", + "emoticons": [ + "🆙" + ] + }, + { + "keyword": "indonesia", + "emoticons": [ + "🇮🇩" + ] + }, + { + "keyword": "lightning", + "emoticons": [ + "⛈", + "🌩", + "⚡" + ] + }, + { + "keyword": "eritrea", + "emoticons": [ + "🇪🇷" + ] + }, + { + "keyword": "gasoline", + "emoticons": [ + "⛽" + ] + }, + { + "keyword": "passport control", + "emoticons": [ + "🛂" + ] + }, + { + "keyword": "give me five", + "emoticons": [ + "✋" + ] + }, + { + "keyword": "iraq", + "emoticons": [ + "🇮🇶" + ] + }, + { + "keyword": "rainbow flag", + "emoticons": [ + "🏳‍🌈" + ] + }, + { + "keyword": "flag_gp", + "emoticons": [ + "🇬🇵" + ] + }, + { + "keyword": "cricket game", + "emoticons": [ + "🏏" + ] + }, + { + "keyword": "catholic", + "emoticons": [ + "✝" + ] + }, + { + "keyword": "ly", + "emoticons": [ + "🇱🇾" + ] + }, + { + "keyword": "sparkles", + "emoticons": [ + "✨" + ] + }, + { + "keyword": "lantern", + "emoticons": [ + "🎃" + ] + }, + { + "keyword": "deaf person", + "emoticons": [ + "🧏" + ] + }, + { + "keyword": "family_mmb", + "emoticons": [ + "👨‍👨‍👦" + ] + }, + { + "keyword": "point up", + "emoticons": [ + "☝" + ] + }, + { + "keyword": "cheese wedge", + "emoticons": [ + "🧀" + ] + }, + { + "keyword": "sari", + "emoticons": [ + "🥻" + ] + }, + { + "keyword": "chess", + "emoticons": [ + "♟" + ] + }, + { + "keyword": "popper", + "emoticons": [ + "🎉" + ] + }, + { + "keyword": "zoom out", + "emoticons": [ + "🔎", + "🔍" + ] + }, + { + "keyword": "clapping hands", + "emoticons": [ + "👏" + ] + }, + { + "keyword": "bait", + "emoticons": [ + "🪱" + ] + }, + { + "keyword": "namibia", + "emoticons": [ + "🇳🇦" + ] + }, + { + "keyword": "page with curl", + "emoticons": [ + "📃" + ] + }, + { + "keyword": "flag_bf", + "emoticons": [ + "🇧🇫" + ] + }, + { + "keyword": "bm", + "emoticons": [ + "🇧🇲" + ] + }, + { + "keyword": "baseball", + "emoticons": [ + "⚾" + ] + }, + { + "keyword": "forum", + "emoticons": [ + "🏛" + ] + }, + { + "keyword": "white circle", + "emoticons": [ + "⚪" + ] + }, + { + "keyword": "flag_af", + "emoticons": [ + "🇦🇫" + ] + }, + { + "keyword": "not good", + "emoticons": [ + "🆖" + ] + }, + { + "keyword": "red paper lantern", + "emoticons": [ + "🏮" + ] + }, + { + "keyword": "pole", + "emoticons": [ + "🎣" + ] + }, + { + "keyword": "ear", + "emoticons": [ + "👂", + "🦻" + ] + }, + { + "keyword": "moo", + "emoticons": [ + "🐄", + "🐮" + ] + }, + { + "keyword": "fatima hand", + "emoticons": [ + "🪬" + ] + }, + { + "keyword": "sweden", + "emoticons": [ + "🇸🇪" + ] + }, + { + "keyword": "frustrated", + "emoticons": [ + "🤬" + ] + }, + { + "keyword": "paintbrush", + "emoticons": [ + "🖌" + ] + }, + { + "keyword": "file cabinet", + "emoticons": [ + "🗄" + ] + }, + { + "keyword": "person bowing", + "emoticons": [ + "🙇‍♂" + ] + }, + { + "keyword": "seedling", + "emoticons": [ + "🌱" + ] + }, + { + "keyword": "flag_zw", + "emoticons": [ + "🇿🇼" + ] + }, + { + "keyword": "buffalo", + "emoticons": [ + "🐂", + "🐃" + ] + }, + { + "keyword": "tuk-tuk", + "emoticons": [ + "🛺" + ] + }, + { + "keyword": "senegal", + "emoticons": [ + "🇸🇳" + ] + }, + { + "keyword": "wrestle", + "emoticons": [ + "🤼‍♀", + "🤼‍♂", + "🤼" + ] + }, + { + "keyword": "disagree", + "emoticons": [ + "👎" + ] + }, + { + "keyword": "flag_tl", + "emoticons": [ + "🇹🇱" + ] + }, + { + "keyword": "weather", + "emoticons": [ + "☀", + "⛅", + "🌤", + "☁" + ] + }, + { + "keyword": "mountain bike", + "emoticons": [ + "🚵" + ] + }, + { + "keyword": "man farmer", + "emoticons": [ + "👨‍🌾" + ] + }, + { + "keyword": "woman golfing", + "emoticons": [ + "🏌‍♀" + ] + }, + { + "keyword": "flag_na", + "emoticons": [ + "🇳🇦" + ] + }, + { + "keyword": "4", + "emoticons": [ + "4⃣" + ] + }, + { + "keyword": "eh", + "emoticons": [ + "🇪🇭" + ] + }, + { + "keyword": "flag_sk", + "emoticons": [ + "🇸🇰" + ] + }, + { + "keyword": "treble clef", + "emoticons": [ + "🎼" + ] + }, + { + "keyword": "uhh", + "emoticons": [ + "🥴" + ] + }, + { + "keyword": "www", + "emoticons": [ + "🌐" + ] + }, + { + "keyword": "hong kong", + "emoticons": [ + "🇭🇰" + ] + }, + { + "keyword": "adore", + "emoticons": [ + "❤" + ] + }, + { + "keyword": "race", + "emoticons": [ + "🏇" + ] + }, + { + "keyword": "cold", + "emoticons": [ + "❄", + "🧊", + "🥶" + ] + }, + { + "keyword": "monkeys", + "emoticons": [ + "🦍", + "🦧" + ] + }, + { + "keyword": "kneeling", + "emoticons": [ + "🧎", + "🧎‍♀", + "🧎‍♂" + ] + }, + { + "keyword": "iran", + "emoticons": [ + "🇮🇷" + ] + }, + { + "keyword": "mx claus", + "emoticons": [ + "🧑‍🎄" + ] + }, + { + "keyword": "ejaculate", + "emoticons": [ + "💦" + ] + }, + { + "keyword": "checkered flag", + "emoticons": [ + "🏁" + ] + }, + { + "keyword": "talkative", + "emoticons": [ + "🦜" + ] + }, + { + "keyword": "playboy", + "emoticons": [ + "👯‍♀", + "👯", + "👯‍♂" + ] + }, + { + "keyword": "flag_as", + "emoticons": [ + "🇦🇸" + ] + }, + { + "keyword": "vegetables", + "emoticons": [ + "🫑" + ] + }, + { + "keyword": "phd", + "emoticons": [ + "👨‍🔬", + "👩‍🔬" + ] + }, + { + "keyword": "sm", + "emoticons": [ + "🇸🇲" + ] + }, + { + "keyword": "slowpoke", + "emoticons": [ + "🦥" + ] + }, + { + "keyword": "blue book", + "emoticons": [ + "📘" + ] + }, + { + "keyword": ";)", + "emoticons": [ + "😉" + ] + }, + { + "keyword": "curls", + "emoticons": [ + "🧑‍🦱" + ] + }, + { + "keyword": "chief", + "emoticons": [ + "👨‍🍳", + "👩‍🍳" + ] + }, + { + "keyword": "dork", + "emoticons": [ + "🤓" + ] + }, + { + "keyword": "mongolia", + "emoticons": [ + "🇲🇳" + ] + }, + { + "keyword": "nine o'clock", + "emoticons": [ + "🕘" + ] + }, + { + "keyword": "face mask", + "emoticons": [ + "😷" + ] + }, + { + "keyword": "black cat", + "emoticons": [ + "🐈‍⬛" + ] + }, + { + "keyword": "papua new guinea", + "emoticons": [ + "🇵🇬" + ] + }, + { + "keyword": "english flag", + "emoticons": [ + "🏴󠁧󠁢󠁥󠁮󠁧󠁿" + ] + }, + { + "keyword": "flag_xk", + "emoticons": [ + "🇽🇰" + ] + }, + { + "keyword": "motorcycle", + "emoticons": [ + "🏍" + ] + }, + { + "keyword": "o", + "emoticons": [ + "😮" + ] + }, + { + "keyword": "theatre", + "emoticons": [ + "🎭" + ] + }, + { + "keyword": "ecstatic", + "emoticons": [ + "🤩" + ] + }, + { + "keyword": "scales", + "emoticons": [ + "⚖" + ] + }, + { + "keyword": "crab", + "emoticons": [ + "🦀" + ] + }, + { + "keyword": "rice cracker", + "emoticons": [ + "🍘" + ] + }, + { + "keyword": "no evil", + "emoticons": [ + "🙈", + "🙊", + "🙉" + ] + }, + { + "keyword": "straw", + "emoticons": [ + "🥤" + ] + }, + { + "keyword": "circus tent", + "emoticons": [ + "🎪" + ] + }, + { + "keyword": "carousel horse", + "emoticons": [ + "🎠" + ] + }, + { + "keyword": "turkey", + "emoticons": [ + "🦃", + "🇹🇷" + ] + }, + { + "keyword": "lumberjack", + "emoticons": [ + "🪓" + ] + }, + { + "keyword": "tuvalu", + "emoticons": [ + "🇹🇻" + ] + }, + { + "keyword": "hanafuda", + "emoticons": [ + "🎴" + ] + }, + { + "keyword": "tumbler glass", + "emoticons": [ + "🥃" + ] + }, + { + "keyword": "rugby football", + "emoticons": [ + "🏉" + ] + }, + { + "keyword": "sparkling heart", + "emoticons": [ + "💖" + ] + }, + { + "keyword": "clock1", + "emoticons": [ + "🕐" + ] + }, + { + "keyword": "liechtenstein", + "emoticons": [ + "🇱🇮" + ] + }, + { + "keyword": "man's shoe", + "emoticons": [ + "👞" + ] + }, + { + "keyword": "sandal", + "emoticons": [ + "👡" + ] + }, + { + "keyword": "difference", + "emoticons": [ + "🆚" + ] + }, + { + "keyword": "xiaomi", + "emoticons": [ + "📱" + ] + }, + { + "keyword": "spain", + "emoticons": [ + "🇪🇸" + ] + }, + { + "keyword": "desktop", + "emoticons": [ + "🖥" + ] + }, + { + "keyword": "flag_la", + "emoticons": [ + "🇱🇦" + ] + }, + { + "keyword": "shut up", + "emoticons": [ + "😐", + "😑" + ] + }, + { + "keyword": "woman scientist", + "emoticons": [ + "👩‍🔬" + ] + }, + { + "keyword": "no entry", + "emoticons": [ + "🚫", + "⛔" + ] + }, + { + "keyword": "chopsticks", + "emoticons": [ + "🥢" + ] + }, + { + "keyword": "url", + "emoticons": [ + "🔗", + "⛓" + ] + }, + { + "keyword": "trademark", + "emoticons": [ + "™" + ] + }, + { + "keyword": "united arab emirates", + "emoticons": [ + "🇦🇪" + ] + }, + { + "keyword": "woman elf", + "emoticons": [ + "🧝‍♀" + ] + }, + { + "keyword": "sheep", + "emoticons": [ + "🐑", + "🐏" + ] + }, + { + "keyword": "guyana", + "emoticons": [ + "🇬🇾" + ] + }, + { + "keyword": "trunk", + "emoticons": [ + "🐘", + "🦣" + ] + }, + { + "keyword": "nigeria", + "emoticons": [ + "🇳🇬" + ] + }, + { + "keyword": "heavy plus sign", + "emoticons": [ + "➕" + ] + }, + { + "keyword": "console", + "emoticons": [ + "🎛" + ] + }, + { + "keyword": "couple_ww", + "emoticons": [ + "👩‍❤‍👩" + ] + }, + { + "keyword": "lotus", + "emoticons": [ + "🧘‍♂", + "🪷", + "🧘", + "🧘‍♀" + ] + }, + { + "keyword": "id button", + "emoticons": [ + "🆔" + ] + }, + { + "keyword": "no littering", + "emoticons": [ + "🚯" + ] + }, + { + "keyword": "laziness", + "emoticons": [ + "🦥" + ] + }, + { + "keyword": "painter", + "emoticons": [ + "🧑‍🎨" + ] + }, + { + "keyword": "shake", + "emoticons": [ + "🤝" + ] + }, + { + "keyword": "touched", + "emoticons": [ + "🥲" + ] + }, + { + "keyword": "lust", + "emoticons": [ + "🤑" + ] + }, + { + "keyword": "kk", + "emoticons": [ + "👍" + ] + }, + { + "keyword": "curious", + "emoticons": [ + "🧐", + "🤔" + ] + }, + { + "keyword": ":)", + "emoticons": [ + "🙂" + ] + }, + { + "keyword": "tools", + "emoticons": [ + "🪛" + ] + }, + { + "keyword": "cow face", + "emoticons": [ + "🐮" + ] + }, + { + "keyword": "werewolf", + "emoticons": [ + "🐺" + ] + }, + { + "keyword": "veil", + "emoticons": [ + "👰‍♀" + ] + }, + { + "keyword": "tennis", + "emoticons": [ + "🎾", + "🏓" + ] + }, + { + "keyword": "cape verde", + "emoticons": [ + "🇨🇻" + ] + }, + { + "keyword": "hammer", + "emoticons": [ + "🔨", + "⚒", + "🛠" + ] + }, + { + "keyword": "man judge", + "emoticons": [ + "👨‍⚖" + ] + }, + { + "keyword": "b", + "emoticons": [ + "🅱" + ] + }, + { + "keyword": "mind-blowing", + "emoticons": [ + "🤯" + ] + }, + { + "keyword": "python", + "emoticons": [ + "🐍" + ] + }, + { + "keyword": "brown square", + "emoticons": [ + "🟫" + ] + }, + { + "keyword": "drum", + "emoticons": [ + "🥁" + ] + }, + { + "keyword": "juggler", + "emoticons": [ + "🤹", + "🤹‍♂", + "🤹‍♀" + ] + }, + { + "keyword": "bell pepper", + "emoticons": [ + "🫑" + ] + }, + { + "keyword": "bearded woman", + "emoticons": [ + "🧔‍♀" + ] + }, + { + "keyword": "temperature", + "emoticons": [ + "🌡", + "🤒" + ] + }, + { + "keyword": "oman", + "emoticons": [ + "🇴🇲" + ] + }, + { + "keyword": "boy", + "emoticons": [ + "👦" + ] + }, + { + "keyword": "worried", + "emoticons": [ + "😟" + ] + }, + { + "keyword": "puzzle", + "emoticons": [ + "🧩" + ] + }, + { + "keyword": "flag_ai", + "emoticons": [ + "🇦🇮" + ] + }, + { + "keyword": "vietnam", + "emoticons": [ + "🇻🇳" + ] + }, + { + "keyword": "notes", + "emoticons": [ + "✍" + ] + }, + { + "keyword": "information desk", + "emoticons": [ + "💁" + ] + }, + { + "keyword": "v", + "emoticons": [ + "✌" + ] + }, + { + "keyword": "woman shrugging", + "emoticons": [ + "🤷‍♀" + ] + }, + { + "keyword": "speechless", + "emoticons": [ + "😶", + "🤭" + ] + }, + { + "keyword": "heavy exclamation mark symbol", + "emoticons": [ + "❗" + ] + }, + { + "keyword": "encourage", + "emoticons": [ + "👏" + ] + }, + { + "keyword": "equine", + "emoticons": [ + "🐴" + ] + }, + { + "keyword": "sauropod", + "emoticons": [ + "🦕" + ] + }, + { + "keyword": "ambulance", + "emoticons": [ + "🚑" + ] + }, + { + "keyword": "roach", + "emoticons": [ + "🪳" + ] + }, + { + "keyword": "vulcan", + "emoticons": [ + "🖖" + ] + }, + { + "keyword": "woman frowning", + "emoticons": [ + "🙍‍♀" + ] + }, + { + "keyword": "pinocchio", + "emoticons": [ + "🤥" + ] + }, + { + "keyword": "blink", + "emoticons": [ + "😉" + ] + }, + { + "keyword": "petri dish", + "emoticons": [ + "🧫" + ] + }, + { + "keyword": "wrestling", + "emoticons": [ + "🤼‍♂", + "🤼‍♀", + "🤼" + ] + }, + { + "keyword": "airplane departure", + "emoticons": [ + "🛫" + ] + }, + { + "keyword": "shaka", + "emoticons": [ + "🤙" + ] + }, + { + "keyword": "yessir", + "emoticons": [ + "🫡" + ] + }, + { + "keyword": "menstruate", + "emoticons": [ + "🩸" + ] + }, + { + "keyword": "rat", + "emoticons": [ + "🐀" + ] + }, + { + "keyword": "shades", + "emoticons": [ + "🕶" + ] + }, + { + "keyword": "azerbaijan", + "emoticons": [ + "🇦🇿" + ] + }, + { + "keyword": "man surfing", + "emoticons": [ + "🏄‍♂" + ] + }, + { + "keyword": "u.s. virgin islands", + "emoticons": [ + "🇻🇮" + ] + }, + { + "keyword": "calendar spiral", + "emoticons": [ + "🗓" + ] + }, + { + "keyword": "breath", + "emoticons": [ + "🌬", + "😮‍💨" + ] + }, + { + "keyword": "cl button", + "emoticons": [ + "🆑" + ] + }, + { + "keyword": "blond man", + "emoticons": [ + "👱‍♂" + ] + }, + { + "keyword": "man fairy", + "emoticons": [ + "🧚‍♂" + ] + }, + { + "keyword": "transgender", + "emoticons": [ + "🏳‍🌈", + "🏳‍⚧", + "⚧" + ] + }, + { + "keyword": "sl", + "emoticons": [ + "🇸🇱" + ] + }, + { + "keyword": "peace symbol", + "emoticons": [ + "☮" + ] + }, + { + "keyword": "pl", + "emoticons": [ + "🇵🇱" + ] + }, + { + "keyword": "stop", + "emoticons": [ + "🤚", + "🛑", + "✋" + ] + }, + { + "keyword": "microbe", + "emoticons": [ + "🦠" + ] + }, + { + "keyword": "vipassana", + "emoticons": [ + "🧘", + "🧘‍♂" + ] + }, + { + "keyword": "scary house", + "emoticons": [ + "🏚" + ] + }, + { + "keyword": "clinking", + "emoticons": [ + "🥂", + "🍻" + ] + }, + { + "keyword": "flag_sz", + "emoticons": [ + "🇸🇿" + ] + }, + { + "keyword": "rosary", + "emoticons": [ + "📿" + ] + }, + { + "keyword": "japanese reserved button", + "emoticons": [ + "🈯" + ] + }, + { + "keyword": "mx", + "emoticons": [ + "🇲🇽" + ] + }, + { + "keyword": "projector", + "emoticons": [ + "📽" + ] + }, + { + "keyword": "corona", + "emoticons": [ + "🦠" + ] + }, + { + "keyword": "puerto rico", + "emoticons": [ + "🇵🇷" + ] + }, + { + "keyword": "balance", + "emoticons": [ + "⚖", + "🦩" + ] + }, + { + "keyword": "necktie", + "emoticons": [ + "👔" + ] + }, + { + "keyword": "cane", + "emoticons": [ + "🦯" + ] + }, + { + "keyword": "meteor", + "emoticons": [ + "☄" + ] + }, + { + "keyword": "bison", + "emoticons": [ + "🦬" + ] + }, + { + "keyword": "whoops", + "emoticons": [ + "😅", + "🙈" + ] + }, + { + "keyword": "aroma", + "emoticons": [ + "👃" + ] + }, + { + "keyword": "building construction", + "emoticons": [ + "🏗" + ] + }, + { + "keyword": "bow and arrow", + "emoticons": [ + "🏹" + ] + }, + { + "keyword": "flag_cm", + "emoticons": [ + "🇨🇲" + ] + }, + { + "keyword": "clap", + "emoticons": [ + "👏" + ] + }, + { + "keyword": "flag_tv", + "emoticons": [ + "🇹🇻" + ] + }, + { + "keyword": "tz", + "emoticons": [ + "🇹🇿" + ] + }, + { + "keyword": "placard", + "emoticons": [ + "🪧" + ] + }, + { + "keyword": "aries", + "emoticons": [ + "♈" + ] + }, + { + "keyword": "newspaper", + "emoticons": [ + "🗞", + "📰" + ] + }, + { + "keyword": "showgirl", + "emoticons": [ + "👯" + ] + }, + { + "keyword": "gas", + "emoticons": [ + "⛽" + ] + }, + { + "keyword": "flag_mw", + "emoticons": [ + "🇲🇼" + ] + }, + { + "keyword": "happytear", + "emoticons": [ + "🥲" + ] + }, + { + "keyword": "zipper", + "emoticons": [ + "🤐" + ] + }, + { + "keyword": "suv", + "emoticons": [ + "🚙" + ] + }, + { + "keyword": "eggplant", + "emoticons": [ + "🍆" + ] + }, + { + "keyword": "woman walking", + "emoticons": [ + "🚶‍♀" + ] + }, + { + "keyword": "white", + "emoticons": [ + "👨‍🦳", + "👩‍🦳" + ] + }, + { + "keyword": "scary", + "emoticons": [ + "🎃" + ] + }, + { + "keyword": "closed mailbox with lowered flag", + "emoticons": [ + "📪" + ] + }, + { + "keyword": "crossed swords", + "emoticons": [ + "⚔" + ] + }, + { + "keyword": "fist bump", + "emoticons": [ + "🤛", + "🤜" + ] + }, + { + "keyword": "baby symbol", + "emoticons": [ + "🚼" + ] + }, + { + "keyword": "piece", + "emoticons": [ + "🧩" + ] + }, + { + "keyword": "flag_ss", + "emoticons": [ + "🇸🇸" + ] + }, + { + "keyword": "sammich", + "emoticons": [ + "🥪" + ] + }, + { + "keyword": "tristan da cunha", + "emoticons": [ + "🇹🇦" + ] + }, + { + "keyword": "gosh", + "emoticons": [ + "🫢" + ] + }, + { + "keyword": "tobacco", + "emoticons": [ + "🚬" + ] + }, + { + "keyword": "carousel", + "emoticons": [ + "🎠" + ] + }, + { + "keyword": "favourite", + "emoticons": [ + "⭐" + ] + }, + { + "keyword": "uh", + "emoticons": [ + "😮", + "😯", + "😲" + ] + }, + { + "keyword": "pr", + "emoticons": [ + "🇵🇷" + ] + }, + { + "keyword": "mount fuji", + "emoticons": [ + "🗻" + ] + }, + { + "keyword": "sr", + "emoticons": [ + "🇸🇷" + ] + }, + { + "keyword": "denied", + "emoticons": [ + "🙅‍♀", + "🙅‍♂" + ] + }, + { + "keyword": "passing grade", + "emoticons": [ + "🈴" + ] + }, + { + "keyword": "boba", + "emoticons": [ + "🧋" + ] + }, + { + "keyword": "luxembourg", + "emoticons": [ + "🇱🇺" + ] + }, + { + "keyword": "malta", + "emoticons": [ + "🇲🇹" + ] + }, + { + "keyword": "potable water", + "emoticons": [ + "🚰" + ] + }, + { + "keyword": "af", + "emoticons": [ + "🇦🇫" + ] + }, + { + "keyword": "flag_ke", + "emoticons": [ + "🇰🇪" + ] + }, + { + "keyword": "black check", + "emoticons": [ + "✔" + ] + }, + { + "keyword": "extravagant", + "emoticons": [ + "🦚" + ] + }, + { + "keyword": "christian", + "emoticons": [ + "☦", + "✝" + ] + }, + { + "keyword": "monthly", + "emoticons": [ + "🈷" + ] + }, + { + "keyword": "hook", + "emoticons": [ + "🪝" + ] + }, + { + "keyword": "flag_ht", + "emoticons": [ + "🇭🇹" + ] + }, + { + "keyword": "witch", + "emoticons": [ + "🧙‍♀" + ] + }, + { + "keyword": "ro", + "emoticons": [ + "🇷🇴" + ] + }, + { + "keyword": "liquid", + "emoticons": [ + "💧" + ] + }, + { + "keyword": "waxing crescent moon", + "emoticons": [ + "🌒" + ] + }, + { + "keyword": "drop of blood", + "emoticons": [ + "🩸" + ] + }, + { + "keyword": "bullettrain side", + "emoticons": [ + "🚄" + ] + }, + { + "keyword": "sceptic", + "emoticons": [ + "🤨", + "😐" + ] + }, + { + "keyword": "elven", + "emoticons": [ + "🧝‍♂", + "🧝‍♀", + "🧝" + ] + }, + { + "keyword": "flag_mq", + "emoticons": [ + "🇲🇶" + ] + }, + { + "keyword": "belgium", + "emoticons": [ + "🇧🇪" + ] + }, + { + "keyword": "flag of england", + "emoticons": [ + "🏴󠁧󠁢󠁥󠁮󠁧󠁿" + ] + }, + { + "keyword": "nuts", + "emoticons": [ + "🌰", + "🥜" + ] + }, + { + "keyword": "arepa", + "emoticons": [ + "🫓" + ] + }, + { + "keyword": "question mark", + "emoticons": [ + "❓" + ] + }, + { + "keyword": "sd", + "emoticons": [ + "🇸🇩" + ] + }, + { + "keyword": "boo", + "emoticons": [ + "👻" + ] + }, + { + "keyword": "weirdo", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "zebra", + "emoticons": [ + "🦓" + ] + }, + { + "keyword": "flag_lu", + "emoticons": [ + "🇱🇺" + ] + }, + { + "keyword": "family wwbb", + "emoticons": [ + "👩‍👩‍👦‍👦" + ] + }, + { + "keyword": "high brightness", + "emoticons": [ + "🔆" + ] + }, + { + "keyword": "volleyball", + "emoticons": [ + "🏐" + ] + }, + { + "keyword": "fireman", + "emoticons": [ + "👨‍🚒", + "🧑‍🚒" + ] + }, + { + "keyword": "wraith", + "emoticons": [ + "👻" + ] + }, + { + "keyword": "on arrow", + "emoticons": [ + "🔛" + ] + }, + { + "keyword": "net", + "emoticons": [ + "🥅" + ] + }, + { + "keyword": "flag_ax", + "emoticons": [ + "🇦🇽" + ] + }, + { + "keyword": "flag_vi", + "emoticons": [ + "🇻🇮" + ] + }, + { + "keyword": "unbelievable", + "emoticons": [ + "🤦‍♂", + "🤦‍♀", + "🤌", + "🤦" + ] + }, + { + "keyword": "stars", + "emoticons": [ + "🌃", + "✨" + ] + }, + { + "keyword": "droplet", + "emoticons": [ + "💧" + ] + }, + { + "keyword": "tajikistan", + "emoticons": [ + "🇹🇯" + ] + }, + { + "keyword": "keeling islands", + "emoticons": [ + "🇨🇨" + ] + }, + { + "keyword": "racing motorcycle", + "emoticons": [ + "🏍" + ] + }, + { + "keyword": "purse", + "emoticons": [ + "👛" + ] + }, + { + "keyword": "pointe shoe", + "emoticons": [ + "🩰" + ] + }, + { + "keyword": "ar", + "emoticons": [ + "🇦🇷" + ] + }, + { + "keyword": "organ", + "emoticons": [ + "🫁" + ] + }, + { + "keyword": "maple leaf", + "emoticons": [ + "🍁" + ] + }, + { + "keyword": "deepa", + "emoticons": [ + "🪔" + ] + }, + { + "keyword": "repair", + "emoticons": [ + "🪛" + ] + }, + { + "keyword": "film frames", + "emoticons": [ + "🎞" + ] + }, + { + "keyword": "vacancy", + "emoticons": [ + "🈳" + ] + }, + { + "keyword": "tp", + "emoticons": [ + "🧻" + ] + }, + { + "keyword": "halloween", + "emoticons": [ + "🎃" + ] + }, + { + "keyword": "brick", + "emoticons": [ + "🧱" + ] + }, + { + "keyword": "flag_uz", + "emoticons": [ + "🇺🇿" + ] + }, + { + "keyword": "mu", + "emoticons": [ + "🇲🇺" + ] + }, + { + "keyword": "pork", + "emoticons": [ + "🐷" + ] + }, + { + "keyword": "nine", + "emoticons": [ + "9⃣" + ] + }, + { + "keyword": "grace", + "emoticons": [ + "🦢" + ] + }, + { + "keyword": "woman's clothes", + "emoticons": [ + "👚" + ] + }, + { + "keyword": "woman farmer", + "emoticons": [ + "👩‍🌾" + ] + }, + { + "keyword": "white check", + "emoticons": [ + "✅" + ] + }, + { + "keyword": "clutch", + "emoticons": [ + "👝" + ] + }, + { + "keyword": "department store", + "emoticons": [ + "🏬" + ] + }, + { + "keyword": "gear", + "emoticons": [ + "⚙" + ] + }, + { + "keyword": "rolled up newspaper", + "emoticons": [ + "🗞" + ] + }, + { + "keyword": "input latin letters", + "emoticons": [ + "🔤" + ] + }, + { + "keyword": "defense", + "emoticons": [ + "🛡" + ] + }, + { + "keyword": "playing cards", + "emoticons": [ + "🎴" + ] + }, + { + "keyword": "bacteria", + "emoticons": [ + "🧫", + "🦠" + ] + }, + { + "keyword": "shopping trolley", + "emoticons": [ + "🛒" + ] + }, + { + "keyword": "6", + "emoticons": [ + "6⃣" + ] + }, + { + "keyword": "martian", + "emoticons": [ + "👽", + "👾" + ] + }, + { + "keyword": "mouse trap", + "emoticons": [ + "🪤" + ] + }, + { + "keyword": "bleeding", + "emoticons": [ + "🩸" + ] + }, + { + "keyword": "clinking beer mugs", + "emoticons": [ + "🍻" + ] + }, + { + "keyword": "sunrise", + "emoticons": [ + "🌅", + "🌄" + ] + }, + { + "keyword": "woman’s hat", + "emoticons": [ + "👒" + ] + }, + { + "keyword": "older woman", + "emoticons": [ + "👵" + ] + }, + { + "keyword": "heart with ribbon", + "emoticons": [ + "💝" + ] + }, + { + "keyword": "currency exchange", + "emoticons": [ + "💱" + ] + }, + { + "keyword": "teeth", + "emoticons": [ + "🦷", + "😬" + ] + }, + { + "keyword": "flag_gg", + "emoticons": [ + "🇬🇬" + ] + }, + { + "keyword": "quite", + "emoticons": [ + "🙊" + ] + }, + { + "keyword": "amulet", + "emoticons": [ + "🪬", + "🧿", + "📿" + ] + }, + { + "keyword": "i don't know", + "emoticons": [ + "🤷" + ] + }, + { + "keyword": "mute", + "emoticons": [ + "🔇", + "🔕" + ] + }, + { + "keyword": "tiger", + "emoticons": [ + "🐅", + "🐯" + ] + }, + { + "keyword": "sponge", + "emoticons": [ + "🧽" + ] + }, + { + "keyword": "bra", + "emoticons": [ + "👙" + ] + }, + { + "keyword": "curacao", + "emoticons": [ + "🇨🇼" + ] + }, + { + "keyword": "swimmer", + "emoticons": [ + "🏊‍♀", + "🏊‍♂" + ] + }, + { + "keyword": "strawberry", + "emoticons": [ + "🍓" + ] + }, + { + "keyword": "pinata", + "emoticons": [ + "🪅" + ] + }, + { + "keyword": "wizard", + "emoticons": [ + "🧙‍♂", + "🧙" + ] + }, + { + "keyword": "sausage", + "emoticons": [ + "🌭" + ] + }, + { + "keyword": "awful", + "emoticons": [ + "😖" + ] + }, + { + "keyword": "walker", + "emoticons": [ + "🚶" + ] + }, + { + "keyword": "canada", + "emoticons": [ + "🇨🇦" + ] + }, + { + "keyword": "!!", + "emoticons": [ + "❕", + "‼" + ] + }, + { + "keyword": "swords", + "emoticons": [ + "⚔" + ] + }, + { + "keyword": "confetti ball", + "emoticons": [ + "🎊" + ] + }, + { + "keyword": "hatching chick", + "emoticons": [ + "🐣" + ] + }, + { + "keyword": "pig nose", + "emoticons": [ + "🐽" + ] + }, + { + "keyword": "accordion", + "emoticons": [ + "🪗" + ] + }, + { + "keyword": "five-thirty", + "emoticons": [ + "🕠" + ] + }, + { + "keyword": "honduras", + "emoticons": [ + "🇭🇳" + ] + }, + { + "keyword": "wine glass", + "emoticons": [ + "🍷" + ] + }, + { + "keyword": "partly sunny", + "emoticons": [ + "⛅" + ] + }, + { + "keyword": "ramen", + "emoticons": [ + "🍜" + ] + }, + { + "keyword": "guam", + "emoticons": [ + "🇬🇺" + ] + }, + { + "keyword": "yoshi", + "emoticons": [ + "🦖" + ] + }, + { + "keyword": "starfish", + "emoticons": [ + "⭐" + ] + }, + { + "keyword": "woman wearing turban", + "emoticons": [ + "👳‍♀" + ] + }, + { + "keyword": "oncoming police car", + "emoticons": [ + "🚔" + ] + }, + { + "keyword": "melting face", + "emoticons": [ + "🫠" + ] + }, + { + "keyword": "scream", + "emoticons": [ + "😱" + ] + }, + { + "keyword": "medium volume", + "emoticons": [ + "🔉" + ] + }, + { + "keyword": ":-)", + "emoticons": [ + "😊", + "🙂" + ] + }, + { + "keyword": "tehran", + "emoticons": [ + "🇮🇷" + ] + }, + { + "keyword": "up-right arrow", + "emoticons": [ + "↗" + ] + }, + { + "keyword": "hugging face", + "emoticons": [ + "🤗" + ] + }, + { + "keyword": "children crossing", + "emoticons": [ + "🚸" + ] + }, + { + "keyword": "curry rice", + "emoticons": [ + "🍛" + ] + }, + { + "keyword": "wind", + "emoticons": [ + "🌬", + "💨" + ] + }, + { + "keyword": "pinching", + "emoticons": [ + "🤏" + ] + }, + { + "keyword": "heeled shoe", + "emoticons": [ + "👡", + "👠" + ] + }, + { + "keyword": "keys", + "emoticons": [ + "🎹" + ] + }, + { + "keyword": "chinese lantern", + "emoticons": [ + "🏮" + ] + }, + { + "keyword": "pager", + "emoticons": [ + "📟" + ] + }, + { + "keyword": "family wwgb", + "emoticons": [ + "👩‍👩‍👧‍👦" + ] + }, + { + "keyword": "boiling", + "emoticons": [ + "🥵" + ] + }, + { + "keyword": "eg", + "emoticons": [ + "🇪🇬" + ] + }, + { + "keyword": "bacon", + "emoticons": [ + "🥓", + "🐷" + ] + }, + { + "keyword": "oof", + "emoticons": [ + "😮‍💨" + ] + }, + { + "keyword": "dk", + "emoticons": [ + "🇩🇰" + ] + }, + { + "keyword": "flag_li", + "emoticons": [ + "🇱🇮" + ] + }, + { + "keyword": "tube", + "emoticons": [ + "🧪" + ] + }, + { + "keyword": "backhand index pointing down", + "emoticons": [ + "👇" + ] + }, + { + "keyword": "flag_ve", + "emoticons": [ + "🇻🇪" + ] + }, + { + "keyword": "school satchel", + "emoticons": [ + "🎒" + ] + }, + { + "keyword": "clock7", + "emoticons": [ + "🕖" + ] + }, + { + "keyword": "flag_ag", + "emoticons": [ + "🇦🇬" + ] + }, + { + "keyword": "flag_sb", + "emoticons": [ + "🇸🇧" + ] + }, + { + "keyword": "hourglass", + "emoticons": [ + "⌛", + "⏳" + ] + }, + { + "keyword": "flag_pn", + "emoticons": [ + "🇵🇳" + ] + }, + { + "keyword": "studio microphone", + "emoticons": [ + "🎙" + ] + }, + { + "keyword": "facepalm", + "emoticons": [ + "🤦‍♂", + "🤦", + "🤦‍♀" + ] + }, + { + "keyword": "bi", + "emoticons": [ + "🇧🇮" + ] + }, + { + "keyword": "hk", + "emoticons": [ + "🇭🇰" + ] + }, + { + "keyword": "why", + "emoticons": [ + "❔", + "🤔", + "❓" + ] + }, + { + "keyword": "paperclip", + "emoticons": [ + "🖇", + "📎" + ] + }, + { + "keyword": "badger", + "emoticons": [ + "🦡" + ] + }, + { + "keyword": "hurry up", + "emoticons": [ + "🏃" + ] + }, + { + "keyword": "grampa", + "emoticons": [ + "👨‍🦳" + ] + }, + { + "keyword": "kg", + "emoticons": [ + "🇰🇬" + ] + }, + { + "keyword": "thongs", + "emoticons": [ + "🩴" + ] + }, + { + "keyword": "palestinian territories", + "emoticons": [ + "🇵🇸" + ] + }, + { + "keyword": "sake", + "emoticons": [ + "🍶" + ] + }, + { + "keyword": "kenya", + "emoticons": [ + "🇰🇪" + ] + }, + { + "keyword": "bangladesh", + "emoticons": [ + "🇧🇩" + ] + }, + { + "keyword": "thermometer face", + "emoticons": [ + "🤒" + ] + }, + { + "keyword": "toe shoes", + "emoticons": [ + "🩰" + ] + }, + { + "keyword": "swine", + "emoticons": [ + "🐖" + ] + }, + { + "keyword": "crystall ball", + "emoticons": [ + "🔮" + ] + }, + { + "keyword": "grey", + "emoticons": [ + "👩‍🦳", + "👨‍🦳" + ] + }, + { + "keyword": "maple", + "emoticons": [ + "🍁" + ] + }, + { + "keyword": "dumbfounded", + "emoticons": [ + "😦" + ] + }, + { + "keyword": "suberb", + "emoticons": [ + "🤩", + "😋" + ] + }, + { + "keyword": "hiking boot", + "emoticons": [ + "🥾" + ] + }, + { + "keyword": "pinched fingers", + "emoticons": [ + "🤌" + ] + }, + { + "keyword": "zombi", + "emoticons": [ + "🧟‍♀", + "🧟‍♂" + ] + }, + { + "keyword": "virus", + "emoticons": [ + "😷", + "🦠" + ] + }, + { + "keyword": "small airplane", + "emoticons": [ + "🛩" + ] + }, + { + "keyword": "repeat one", + "emoticons": [ + "🔂" + ] + }, + { + "keyword": "no idea", + "emoticons": [ + "🤷‍♂" + ] + }, + { + "keyword": "herbivore", + "emoticons": [ + "🦒", + "🐑", + "🦬" + ] + }, + { + "keyword": "smiling imp", + "emoticons": [ + "😈" + ] + }, + { + "keyword": "sigh", + "emoticons": [ + "😮‍💨" + ] + }, + { + "keyword": "hr", + "emoticons": [ + "🇭🇷" + ] + }, + { + "keyword": "ua", + "emoticons": [ + "🇺🇦" + ] + }, + { + "keyword": "receipt", + "emoticons": [ + "🧾" + ] + }, + { + "keyword": "worship", + "emoticons": [ + "🛐" + ] + }, + { + "keyword": "snail", + "emoticons": [ + "🐌" + ] + }, + { + "keyword": "8ball", + "emoticons": [ + "🎱" + ] + }, + { + "keyword": "motorway", + "emoticons": [ + "🛣" + ] + }, + { + "keyword": "woman biking", + "emoticons": [ + "🚴‍♀" + ] + }, + { + "keyword": "oncoming fist", + "emoticons": [ + "👊" + ] + }, + { + "keyword": "deya", + "emoticons": [ + "🪔" + ] + }, + { + "keyword": "rejected", + "emoticons": [ + "🙅‍♂" + ] + }, + { + "keyword": "man playing handball", + "emoticons": [ + "🤾‍♂" + ] + }, + { + "keyword": "british indian ocean territory", + "emoticons": [ + "🇩🇬", + "🇮🇴" + ] + }, + { + "keyword": "test tube", + "emoticons": [ + "🧪" + ] + }, + { + "keyword": "person feeding baby", + "emoticons": [ + "🧑‍🍼" + ] + }, + { + "keyword": "guardsman", + "emoticons": [ + "💂" + ] + }, + { + "keyword": "flag_bj", + "emoticons": [ + "🇧🇯" + ] + }, + { + "keyword": "fo", + "emoticons": [ + "🇫🇴" + ] + }, + { + "keyword": "factory", + "emoticons": [ + "🏭" + ] + }, + { + "keyword": "undead", + "emoticons": [ + "🧟" + ] + }, + { + "keyword": "watch", + "emoticons": [ + "👁", + "⌚", + "👀" + ] + }, + { + "keyword": "turd", + "emoticons": [ + "💩" + ] + }, + { + "keyword": "mg", + "emoticons": [ + "🇲🇬" + ] + }, + { + "keyword": "skier", + "emoticons": [ + "⛷" + ] + }, + { + "keyword": "exhaling", + "emoticons": [ + "😮‍💨" + ] + }, + { + "keyword": "third place medal", + "emoticons": [ + "🥉" + ] + }, + { + "keyword": "cat face", + "emoticons": [ + "🐱" + ] + }, + { + "keyword": "woman tipping hand", + "emoticons": [ + "💁‍♀" + ] + }, + { + "keyword": "arrow up small", + "emoticons": [ + "🔼" + ] + }, + { + "keyword": "new moon with face", + "emoticons": [ + "🌚" + ] + }, + { + "keyword": "satan", + "emoticons": [ + "😈" + ] + }, + { + "keyword": "peasant", + "emoticons": [ + "🧑‍🌾" + ] + }, + { + "keyword": "confetti ", + "emoticons": [ + "🎉" + ] + }, + { + "keyword": "skiing", + "emoticons": [ + "⛷" + ] + }, + { + "keyword": "woman kneeling", + "emoticons": [ + "🙇‍♀" + ] + }, + { + "keyword": "garden", + "emoticons": [ + "🏡" + ] + }, + { + "keyword": "slight frown", + "emoticons": [ + "🙁" + ] + }, + { + "keyword": "coconut", + "emoticons": [ + "🥥" + ] + }, + { + "keyword": "anguilla", + "emoticons": [ + "🇦🇮" + ] + }, + { + "keyword": "at", + "emoticons": [ + "🇦🇹" + ] + }, + { + "keyword": "hu", + "emoticons": [ + "🇭🇺" + ] + }, + { + "keyword": "gl", + "emoticons": [ + "🇬🇱" + ] + }, + { + "keyword": "helmet with white cross", + "emoticons": [ + "⛑" + ] + }, + { + "keyword": "rolling eyes", + "emoticons": [ + "🙄" + ] + }, + { + "keyword": "ilness", + "emoticons": [ + "🤕", + "😷", + "🤒" + ] + }, + { + "keyword": "drooling", + "emoticons": [ + "🤤" + ] + }, + { + "keyword": "tick", + "emoticons": [ + "✔", + "✅", + "☑" + ] + }, + { + "keyword": "tasty", + "emoticons": [ + "😋" + ] + }, + { + "keyword": "circus", + "emoticons": [ + "🎪" + ] + }, + { + "keyword": "bubble tea", + "emoticons": [ + "🧋" + ] + }, + { + "keyword": "twelve o'clock", + "emoticons": [ + "🕛" + ] + }, + { + "keyword": "smiley cat", + "emoticons": [ + "😺" + ] + }, + { + "keyword": "foggy", + "emoticons": [ + "😶‍🌫", + "🌫", + "🌁" + ] + }, + { + "keyword": "gh", + "emoticons": [ + "🇬🇭" + ] + }, + { + "keyword": "oasis", + "emoticons": [ + "🏝" + ] + }, + { + "keyword": "spilled", + "emoticons": [ + "🫗" + ] + }, + { + "keyword": "green check", + "emoticons": [ + "✅" + ] + }, + { + "keyword": "costa rica", + "emoticons": [ + "🇨🇷" + ] + }, + { + "keyword": "national park", + "emoticons": [ + "🏞" + ] + }, + { + "keyword": "no bicycles", + "emoticons": [ + "🚳" + ] + }, + { + "keyword": "dam", + "emoticons": [ + "🦫" + ] + }, + { + "keyword": "pt", + "emoticons": [ + "🇵🇹" + ] + }, + { + "keyword": "dick", + "emoticons": [ + "🍆" + ] + }, + { + "keyword": "st. helena", + "emoticons": [ + "🇸🇭" + ] + }, + { + "keyword": "running shoe", + "emoticons": [ + "👟" + ] + }, + { + "keyword": "t-rex", + "emoticons": [ + "🦖" + ] + }, + { + "keyword": "mollusk", + "emoticons": [ + "🦪" + ] + }, + { + "keyword": "cw", + "emoticons": [ + "🇨🇼" + ] + }, + { + "keyword": "germany", + "emoticons": [ + "🇩🇪" + ] + }, + { + "keyword": "is", + "emoticons": [ + "🇮🇸" + ] + }, + { + "keyword": "exploding head", + "emoticons": [ + "🤯" + ] + }, + { + "keyword": "p button", + "emoticons": [ + "🅿" + ] + }, + { + "keyword": "feet", + "emoticons": [ + "🐾" + ] + }, + { + "keyword": "group", + "emoticons": [ + "👥" + ] + }, + { + "keyword": "good night", + "emoticons": [ + "🥱", + "😴" + ] + }, + { + "keyword": "good morning", + "emoticons": [ + "🌅", + "🌄" + ] + }, + { + "keyword": "pew", + "emoticons": [ + "🔫" + ] + }, + { + "keyword": "t-shirt", + "emoticons": [ + "👕" + ] + }, + { + "keyword": "cross mark button", + "emoticons": [ + "❎" + ] + }, + { + "keyword": "candle", + "emoticons": [ + "🕯" + ] + }, + { + "keyword": "chemistry", + "emoticons": [ + "🧪" + ] + }, + { + "keyword": "nauru", + "emoticons": [ + "🇳🇷" + ] + }, + { + "keyword": "sweat drops", + "emoticons": [ + "💦" + ] + }, + { + "keyword": "royalty", + "emoticons": [ + "👑" + ] + }, + { + "keyword": "flag_am", + "emoticons": [ + "🇦🇲" + ] + }, + { + "keyword": "thailand", + "emoticons": [ + "🇹🇭" + ] + }, + { + "keyword": "flag_by", + "emoticons": [ + "🇧🇾" + ] + }, + { + "keyword": "pride", + "emoticons": [ + "🏳‍🌈" + ] + }, + { + "keyword": "cupcake", + "emoticons": [ + "🧁" + ] + }, + { + "keyword": "honey", + "emoticons": [ + "🍯" + ] + }, + { + "keyword": "what", + "emoticons": [ + "🤔" + ] + }, + { + "keyword": "flag_md", + "emoticons": [ + "🇲🇩" + ] + }, + { + "keyword": "pixy", + "emoticons": [ + "🧚‍♀", + "🧚‍♂" + ] + }, + { + "keyword": "waning crescent moon", + "emoticons": [ + "🌘" + ] + }, + { + "keyword": "flag_gl", + "emoticons": [ + "🇬🇱" + ] + }, + { + "keyword": "drooling face", + "emoticons": [ + "🤤" + ] + }, + { + "keyword": "clock730", + "emoticons": [ + "🕢" + ] + }, + { + "keyword": "back arrow", + "emoticons": [ + "🔙" + ] + }, + { + "keyword": "flag_nz", + "emoticons": [ + "🇳🇿" + ] + }, + { + "keyword": "top hat", + "emoticons": [ + "🎩" + ] + }, + { + "keyword": "input latin uppercase", + "emoticons": [ + "🔠" + ] + }, + { + "keyword": "wind blowing face", + "emoticons": [ + "🌬" + ] + }, + { + "keyword": "mm", + "emoticons": [ + "🇲🇲" + ] + }, + { + "keyword": "flower playing cards", + "emoticons": [ + "🎴" + ] + }, + { + "keyword": "st. lucia", + "emoticons": [ + "🇱🇨" + ] + }, + { + "keyword": "ice hockey", + "emoticons": [ + "🏒" + ] + }, + { + "keyword": "pound symbol", + "emoticons": [ + "💷" + ] + }, + { + "keyword": "monorail", + "emoticons": [ + "🚝" + ] + }, + { + "keyword": "frame with picture", + "emoticons": [ + "🖼" + ] + }, + { + "keyword": "nerd", + "emoticons": [ + "🤓" + ] + }, + { + "keyword": "classical building", + "emoticons": [ + "🏛" + ] + }, + { + "keyword": "flag_dk", + "emoticons": [ + "🇩🇰" + ] + }, + { + "keyword": "left palm", + "emoticons": [ + "🫳" + ] + }, + { + "keyword": "police car", + "emoticons": [ + "🚔", + "🚓" + ] + }, + { + "keyword": "cm", + "emoticons": [ + "🇨🇲" + ] + }, + { + "keyword": "flag_hr", + "emoticons": [ + "🇭🇷" + ] + }, + { + "keyword": "bust", + "emoticons": [ + "👤", + "👥" + ] + }, + { + "keyword": "st", + "emoticons": [ + "🇸🇹" + ] + }, + { + "keyword": "equality", + "emoticons": [ + "⚖" + ] + }, + { + "keyword": "mo", + "emoticons": [ + "🇲🇴" + ] + }, + { + "keyword": "prince", + "emoticons": [ + "🤴" + ] + }, + { + "keyword": "violin", + "emoticons": [ + "🎻" + ] + }, + { + "keyword": "signature", + "emoticons": [ + "✍" + ] + }, + { + "keyword": "hedgehog", + "emoticons": [ + "🦔" + ] + }, + { + "keyword": "place of worship", + "emoticons": [ + "🛐" + ] + }, + { + "keyword": "rowboat", + "emoticons": [ + "🚣" + ] + }, + { + "keyword": "chart increasing with yen", + "emoticons": [ + "💹" + ] + }, + { + "keyword": "armenia", + "emoticons": [ + "🇦🇲" + ] + }, + { + "keyword": "україна", + "emoticons": [ + "🇺🇦" + ] + }, + { + "keyword": "flat shoe", + "emoticons": [ + "🥿" + ] + }, + { + "keyword": "location", + "emoticons": [ + "📍" + ] + }, + { + "keyword": "flag_sy", + "emoticons": [ + "🇸🇾" + ] + }, + { + "keyword": "ram", + "emoticons": [ + "🐏" + ] + }, + { + "keyword": "fleur-de-lis", + "emoticons": [ + "⚜" + ] + }, + { + "keyword": "large orange diamond", + "emoticons": [ + "🔶" + ] + }, + { + "keyword": "infinity", + "emoticons": [ + "♾" + ] + }, + { + "keyword": "hot-dog", + "emoticons": [ + "🌭" + ] + }, + { + "keyword": "police car light", + "emoticons": [ + "🚨" + ] + }, + { + "keyword": "sri lanka", + "emoticons": [ + "🇱🇰" + ] + }, + { + "keyword": "two-thirty", + "emoticons": [ + "🕜" + ] + }, + { + "keyword": "orange book", + "emoticons": [ + "📙" + ] + }, + { + "keyword": "bulgaria", + "emoticons": [ + "🇧🇬" + ] + }, + { + "keyword": "face massage", + "emoticons": [ + "💆" + ] + }, + { + "keyword": "haiti", + "emoticons": [ + "🇭🇹" + ] + }, + { + "keyword": "loop", + "emoticons": [ + "➿" + ] + }, + { + "keyword": "flag_pf", + "emoticons": [ + "🇵🇫" + ] + }, + { + "keyword": "beating heart", + "emoticons": [ + "💓" + ] + }, + { + "keyword": "tent", + "emoticons": [ + "🏕", + "⛺" + ] + }, + { + "keyword": "trackball", + "emoticons": [ + "🖲" + ] + }, + { + "keyword": "globe showing asia-australia", + "emoticons": [ + "🌏" + ] + }, + { + "keyword": "snap", + "emoticons": [ + "🫰" + ] + }, + { + "keyword": "nail", + "emoticons": [ + "💅" + ] + }, + { + "keyword": "vi", + "emoticons": [ + "🇻🇮" + ] + }, + { + "keyword": "ophiuchus", + "emoticons": [ + "⛎" + ] + }, + { + "keyword": "liar", + "emoticons": [ + "🤥" + ] + }, + { + "keyword": ":|", + "emoticons": [ + "😑", + "😐" + ] + }, + { + "keyword": "plate", + "emoticons": [ + "🍽" + ] + }, + { + "keyword": "ape", + "emoticons": [ + "🐵", + "🦍", + "🦧", + "🐒" + ] + }, + { + "keyword": "goodbye", + "emoticons": [ + "👋" + ] + }, + { + "keyword": "one o'clock", + "emoticons": [ + "🕐" + ] + }, + { + "keyword": "gold", + "emoticons": [ + "🥇" + ] + }, + { + "keyword": "cc", + "emoticons": [ + "🇨🇨" + ] + }, + { + "keyword": "sagittarius", + "emoticons": [ + "♐" + ] + }, + { + "keyword": "expecting woman", + "emoticons": [ + "🤰" + ] + }, + { + "keyword": "martini", + "emoticons": [ + "🍸" + ] + }, + { + "keyword": "fi", + "emoticons": [ + "🇫🇮" + ] + }, + { + "keyword": "otter", + "emoticons": [ + "🦦" + ] + }, + { + "keyword": "field hockey", + "emoticons": [ + "🏑" + ] + }, + { + "keyword": "grinning", + "emoticons": [ + "😃", + "😀" + ] + }, + { + "keyword": "lock with ink pen", + "emoticons": [ + "🔏" + ] + }, + { + "keyword": "passenger ship", + "emoticons": [ + "🛳" + ] + }, + { + "keyword": "el salvador", + "emoticons": [ + "🇸🇻" + ] + }, + { + "keyword": "couple ww", + "emoticons": [ + "👩‍❤‍👩" + ] + }, + { + "keyword": "party", + "emoticons": [ + "🎉", + "🥳", + "🪩" + ] + }, + { + "keyword": "policewoman", + "emoticons": [ + "👮" + ] + }, + { + "keyword": "kissing heart", + "emoticons": [ + "😘" + ] + }, + { + "keyword": "in", + "emoticons": [ + "🇮🇳" + ] + }, + { + "keyword": "non-potable water", + "emoticons": [ + "🚱" + ] + }, + { + "keyword": "niue", + "emoticons": [ + "🇳🇺" + ] + }, + { + "keyword": "curling", + "emoticons": [ + "🥌" + ] + }, + { + "keyword": "lu", + "emoticons": [ + "🇱🇺" + ] + }, + { + "keyword": "vision", + "emoticons": [ + "👀", + "👁‍🗨", + "👁" + ] + }, + { + "keyword": "flag_cy", + "emoticons": [ + "🇨🇾" + ] + }, + { + "keyword": "flag_do", + "emoticons": [ + "🇩🇴" + ] + }, + { + "keyword": "camouflage", + "emoticons": [ + "🦎" + ] + }, + { + "keyword": "part alternation mark", + "emoticons": [ + "〽" + ] + }, + { + "keyword": "sparkling", + "emoticons": [ + "🍾" + ] + }, + { + "keyword": "roller coaster", + "emoticons": [ + "🎢" + ] + }, + { + "keyword": "up-left arrow", + "emoticons": [ + "↖" + ] + }, + { + "keyword": "level slider", + "emoticons": [ + "🎚" + ] + }, + { + "keyword": "label", + "emoticons": [ + "🏷" + ] + }, + { + "keyword": "korea", + "emoticons": [ + "🇰🇷", + "🇰🇵" + ] + }, + { + "keyword": "chick", + "emoticons": [ + "🐤", + "🐣", + "🐥" + ] + }, + { + "keyword": "maybe", + "emoticons": [ + "😏" + ] + }, + { + "keyword": "9", + "emoticons": [ + "9⃣" + ] + }, + { + "keyword": "edit", + "emoticons": [ + "📝", + "✏" + ] + }, + { + "keyword": "mmm", + "emoticons": [ + "🫦" + ] + }, + { + "keyword": "mh", + "emoticons": [ + "🇲🇭" + ] + }, + { + "keyword": "turks \u0026 caicos islands", + "emoticons": [ + "🇹🇨" + ] + }, + { + "keyword": "dang", + "emoticons": [ + "😳", + "😯", + "😲", + "😨", + "😰", + "😮", + "😱" + ] + }, + { + "keyword": "happy tear", + "emoticons": [ + "🥲" + ] + }, + { + "keyword": "man factory worker", + "emoticons": [ + "👨‍🏭" + ] + }, + { + "keyword": "crutch", + "emoticons": [ + "🩼" + ] + }, + { + "keyword": "censor", + "emoticons": [ + "🤬" + ] + }, + { + "keyword": "dutch", + "emoticons": [ + "🇳🇱" + ] + }, + { + "keyword": "st. vincent \u0026 grenadines", + "emoticons": [ + "🇻🇨" + ] + }, + { + "keyword": "arrow heading down", + "emoticons": [ + "⤵" + ] + }, + { + "keyword": "red hair", + "emoticons": [ + "🧑‍🦰" + ] + }, + { + "keyword": "athlete", + "emoticons": [ + "🏃" + ] + }, + { + "keyword": "norway", + "emoticons": [ + "🇳🇴" + ] + }, + { + "keyword": "open mouth", + "emoticons": [ + "😮" + ] + }, + { + "keyword": "portugal", + "emoticons": [ + "🇵🇹" + ] + }, + { + "keyword": "lung", + "emoticons": [ + "🫁" + ] + }, + { + "keyword": "man bowing", + "emoticons": [ + "🙇‍♂" + ] + }, + { + "keyword": "bell", + "emoticons": [ + "🔕", + "🔔", + "🛎" + ] + }, + { + "keyword": "flag_tf", + "emoticons": [ + "🇹🇫" + ] + }, + { + "keyword": "call me hand", + "emoticons": [ + "🤙" + ] + }, + { + "keyword": "betray", + "emoticons": [ + "🐍" + ] + }, + { + "keyword": "flag_gr", + "emoticons": [ + "🇬🇷" + ] + }, + { + "keyword": "moon viewing ceremony", + "emoticons": [ + "🎑" + ] + }, + { + "keyword": "flag_ta", + "emoticons": [ + "🇹🇦" + ] + }, + { + "keyword": "badminton", + "emoticons": [ + "🏸" + ] + }, + { + "keyword": "vibration mode", + "emoticons": [ + "📳" + ] + }, + { + "keyword": "yellow", + "emoticons": [ + "🟡", + "🟨" + ] + }, + { + "keyword": "jordan", + "emoticons": [ + "🇯🇴" + ] + }, + { + "keyword": "japan map", + "emoticons": [ + "🗾" + ] + }, + { + "keyword": "flag_ml", + "emoticons": [ + "🇲🇱" + ] + }, + { + "keyword": "palm", + "emoticons": [ + "🌴" + ] + }, + { + "keyword": "brains", + "emoticons": [ + "🧟" + ] + }, + { + "keyword": "backpack", + "emoticons": [ + "🎒" + ] + }, + { + "keyword": "magnifying glass", + "emoticons": [ + "🔍", + "🔎" + ] + }, + { + "keyword": "koko", + "emoticons": [ + "🈁" + ] + }, + { + "keyword": "japanese goblin", + "emoticons": [ + "👺" + ] + }, + { + "keyword": "circled m", + "emoticons": [ + "Ⓜ" + ] + }, + { + "keyword": "disappointed but relieved face", + "emoticons": [ + "😥" + ] + }, + { + "keyword": "dislike", + "emoticons": [ + "👎" + ] + }, + { + "keyword": "giraffe", + "emoticons": [ + "🦒" + ] + }, + { + "keyword": "flag_cw", + "emoticons": [ + "🇨🇼" + ] + }, + { + "keyword": "sleeping", + "emoticons": [ + "😴" + ] + }, + { + "keyword": "heavy", + "emoticons": [ + "🐘" + ] + }, + { + "keyword": "trade mark", + "emoticons": [ + "™" + ] + }, + { + "keyword": "israel", + "emoticons": [ + "🇮🇱" + ] + }, + { + "keyword": "table tennis", + "emoticons": [ + "🏓" + ] + }, + { + "keyword": "united states of america", + "emoticons": [ + "🇺🇸" + ] + }, + { + "keyword": "clock230", + "emoticons": [ + "🕜" + ] + }, + { + "keyword": "smoking", + "emoticons": [ + "🚬" + ] + }, + { + "keyword": "planet", + "emoticons": [ + "🪐" + ] + }, + { + "keyword": "teacup without handle", + "emoticons": [ + "🍵" + ] + }, + { + "keyword": "on! arrow", + "emoticons": [ + "🔛" + ] + }, + { + "keyword": "talk", + "emoticons": [ + "🙊" + ] + }, + { + "keyword": "family mwgb", + "emoticons": [ + "👨‍👩‍👧‍👦" + ] + }, + { + "keyword": "ug", + "emoticons": [ + "🇺🇬" + ] + }, + { + "keyword": "setting", + "emoticons": [ + "⚙" + ] + }, + { + "keyword": "printer", + "emoticons": [ + "🖨" + ] + }, + { + "keyword": "cure", + "emoticons": [ + "💊" + ] + }, + { + "keyword": "spirit", + "emoticons": [ + "👻" + ] + }, + { + "keyword": "hourglass not done", + "emoticons": [ + "⏳" + ] + }, + { + "keyword": "marry", + "emoticons": [ + "💍" + ] + }, + { + "keyword": "flag_nu", + "emoticons": [ + "🇳🇺" + ] + }, + { + "keyword": "777", + "emoticons": [ + "🎰" + ] + }, + { + "keyword": "mirror", + "emoticons": [ + "🪞" + ] + }, + { + "keyword": "nope", + "emoticons": [ + "🙅‍♀", + "🙅‍♂", + "👎" + ] + }, + { + "keyword": "whale", + "emoticons": [ + "🐋", + "🐳" + ] + }, + { + "keyword": "water closet", + "emoticons": [ + "🚾" + ] + }, + { + "keyword": "invaders", + "emoticons": [ + "👾" + ] + }, + { + "keyword": "foot", + "emoticons": [ + "👣", + "🦶" + ] + }, + { + "keyword": "hindu", + "emoticons": [ + "🕉", + "🛕" + ] + }, + { + "keyword": "sparkler", + "emoticons": [ + "🎇" + ] + }, + { + "keyword": "japanese post office", + "emoticons": [ + "🏣" + ] + }, + { + "keyword": "buggy", + "emoticons": [ + "🛒" + ] + }, + { + "keyword": "registered", + "emoticons": [ + "®" + ] + }, + { + "keyword": "barbados", + "emoticons": [ + "🇧🇧" + ] + }, + { + "keyword": "man with chinese cap", + "emoticons": [ + "👲" + ] + }, + { + "keyword": "policeman", + "emoticons": [ + "👮" + ] + }, + { + "keyword": "sport utility vehicle", + "emoticons": [ + "🚙" + ] + }, + { + "keyword": "guitar", + "emoticons": [ + "🎸" + ] + }, + { + "keyword": "person getting massage", + "emoticons": [ + "💆‍♂", + "💆‍♀" + ] + }, + { + "keyword": "olive", + "emoticons": [ + "🫒" + ] + }, + { + "keyword": "menorah", + "emoticons": [ + "🕎" + ] + }, + { + "keyword": "notebook with decorative cover", + "emoticons": [ + "📔" + ] + }, + { + "keyword": "control", + "emoticons": [ + "🛂" + ] + }, + { + "keyword": "curaçao", + "emoticons": [ + "🇨🇼" + ] + }, + { + "keyword": "np", + "emoticons": [ + "🇳🇵" + ] + }, + { + "keyword": "chad", + "emoticons": [ + "🇹🇩" + ] + }, + { + "keyword": "patch", + "emoticons": [ + "🩹" + ] + }, + { + "keyword": "repeat", + "emoticons": [ + "🔁", + "🔄" + ] + }, + { + "keyword": "white medium square", + "emoticons": [ + "◻" + ] + }, + { + "keyword": "bearded man", + "emoticons": [ + "🧔‍♂" + ] + }, + { + "keyword": "musical keyboard", + "emoticons": [ + "🎹" + ] + }, + { + "keyword": "lipstick", + "emoticons": [ + "💄" + ] + }, + { + "keyword": "mq", + "emoticons": [ + "🇲🇶" + ] + }, + { + "keyword": "gg", + "emoticons": [ + "🇬🇬" + ] + }, + { + "keyword": "aureole", + "emoticons": [ + "😇" + ] + }, + { + "keyword": "ng button", + "emoticons": [ + "🆖" + ] + }, + { + "keyword": "guinea", + "emoticons": [ + "🇬🇳" + ] + }, + { + "keyword": "ng", + "emoticons": [ + "🇳🇬" + ] + }, + { + "keyword": "cthulhu", + "emoticons": [ + "🐙" + ] + }, + { + "keyword": "red car", + "emoticons": [ + "🚘" + ] + }, + { + "keyword": "tangerine", + "emoticons": [ + "🍊" + ] + }, + { + "keyword": "rock climbing", + "emoticons": [ + "🧗" + ] + }, + { + "keyword": "i miss you", + "emoticons": [ + "🥺" + ] + }, + { + "keyword": "shaved", + "emoticons": [ + "🧑‍🦲" + ] + }, + { + "keyword": "7", + "emoticons": [ + "7⃣" + ] + }, + { + "keyword": "bee", + "emoticons": [ + "🐝" + ] + }, + { + "keyword": "five o'clock", + "emoticons": [ + "🕔" + ] + }, + { + "keyword": "flag_ad", + "emoticons": [ + "🇦🇩" + ] + }, + { + "keyword": "men wrestling", + "emoticons": [ + "🤼‍♂" + ] + }, + { + "keyword": "map", + "emoticons": [ + "🗺" + ] + }, + { + "keyword": "flag_fk", + "emoticons": [ + "🇫🇰" + ] + }, + { + "keyword": "blond-haired woman", + "emoticons": [ + "👱‍♀" + ] + }, + { + "keyword": "large blue diamond", + "emoticons": [ + "🔷" + ] + }, + { + "keyword": "eye roll", + "emoticons": [ + "🙄" + ] + }, + { + "keyword": "message", + "emoticons": [ + "✍" + ] + }, + { + "keyword": "rubbish", + "emoticons": [ + "🗑" + ] + }, + { + "keyword": "ok hand", + "emoticons": [ + "👌" + ] + }, + { + "keyword": "ec", + "emoticons": [ + "🇪🇨" + ] + }, + { + "keyword": "woman’s sandal", + "emoticons": [ + "👡" + ] + }, + { + "keyword": "vc", + "emoticons": [ + "🇻🇨" + ] + }, + { + "keyword": "boba tea", + "emoticons": [ + "🧋" + ] + }, + { + "keyword": "flag_lk", + "emoticons": [ + "🇱🇰" + ] + }, + { + "keyword": "atm", + "emoticons": [ + "🏧" + ] + }, + { + "keyword": "aland islands", + "emoticons": [ + "🇦🇽" + ] + }, + { + "keyword": "flag_pr", + "emoticons": [ + "🇵🇷" + ] + }, + { + "keyword": "huzzah", + "emoticons": [ + "👏", + "🙌" + ] + }, + { + "keyword": "teddy bear", + "emoticons": [ + "🧸" + ] + }, + { + "keyword": "courier", + "emoticons": [ + "📦" + ] + }, + { + "keyword": "deciduous", + "emoticons": [ + "🌳" + ] + }, + { + "keyword": "bookmarks", + "emoticons": [ + "📑" + ] + }, + { + "keyword": "gq", + "emoticons": [ + "🇬🇶" + ] + }, + { + "keyword": "family_mmbb", + "emoticons": [ + "👨‍👨‍👦‍👦" + ] + }, + { + "keyword": "railway car", + "emoticons": [ + "🚃" + ] + }, + { + "keyword": "clockwise vertical arrows", + "emoticons": [ + "🔃" + ] + }, + { + "keyword": "brasil", + "emoticons": [ + "🇧🇷" + ] + }, + { + "keyword": "watergun", + "emoticons": [ + "🔫" + ] + }, + { + "keyword": "ledger", + "emoticons": [ + "📒" + ] + }, + { + "keyword": "hayastan", + "emoticons": [ + "🇦🇲" + ] + }, + { + "keyword": "credit card", + "emoticons": [ + "💳" + ] + }, + { + "keyword": "raccoon", + "emoticons": [ + "🦝" + ] + }, + { + "keyword": "tf", + "emoticons": [ + "🇹🇫" + ] + }, + { + "keyword": "smirking cat", + "emoticons": [ + "😼" + ] + }, + { + "keyword": "grateful", + "emoticons": [ + "🥲" + ] + }, + { + "keyword": "woman bouncing ball", + "emoticons": [ + "⛹‍♀" + ] + }, + { + "keyword": "wood", + "emoticons": [ + "🪵" + ] + }, + { + "keyword": "shopping bag", + "emoticons": [ + "🛍" + ] + }, + { + "keyword": "small red triangle", + "emoticons": [ + "🔺" + ] + }, + { + "keyword": "flag_gs", + "emoticons": [ + "🇬🇸" + ] + }, + { + "keyword": "chair", + "emoticons": [ + "🪑" + ] + }, + { + "keyword": "ghoul", + "emoticons": [ + "🧟‍♂", + "🧟‍♀", + "🧟" + ] + }, + { + "keyword": "calabash", + "emoticons": [ + "🧉" + ] + }, + { + "keyword": "favorite", + "emoticons": [ + "⭐" + ] + }, + { + "keyword": "flag_va", + "emoticons": [ + "🇻🇦" + ] + }, + { + "keyword": "sign ", + "emoticons": [ + "🪧" + ] + }, + { + "keyword": "vhs", + "emoticons": [ + "📼" + ] + }, + { + "keyword": "balloon", + "emoticons": [ + "🎈" + ] + }, + { + "keyword": "package", + "emoticons": [ + "📦" + ] + }, + { + "keyword": "brush", + "emoticons": [ + "👨‍🎨", + "👩‍🎨" + ] + }, + { + "keyword": "et", + "emoticons": [ + "🇪🇹" + ] + }, + { + "keyword": "name badge", + "emoticons": [ + "📛" + ] + }, + { + "keyword": "bbq", + "emoticons": [ + "♨" + ] + }, + { + "keyword": "cn", + "emoticons": [ + "🇨🇳" + ] + }, + { + "keyword": "giggle", + "emoticons": [ + "🤭" + ] + }, + { + "keyword": "speedos", + "emoticons": [ + "🩲" + ] + }, + { + "keyword": "hippo", + "emoticons": [ + "🦛" + ] + }, + { + "keyword": "headphone", + "emoticons": [ + "🎧" + ] + }, + { + "keyword": "right anger bubble", + "emoticons": [ + "🗯" + ] + }, + { + "keyword": "meditation", + "emoticons": [ + "🧘" + ] + }, + { + "keyword": "flabbergasted", + "emoticons": [ + "🙀" + ] + }, + { + "keyword": "hamburger", + "emoticons": [ + "🍔" + ] + }, + { + "keyword": "wheel", + "emoticons": [ + "🛞" + ] + }, + { + "keyword": "tape", + "emoticons": [ + "📼" + ] + }, + { + "keyword": "down-left arrow", + "emoticons": [ + "↙" + ] + }, + { + "keyword": "witness", + "emoticons": [ + "👁‍🗨" + ] + }, + { + "keyword": "glove with meridians", + "emoticons": [ + "🌐" + ] + }, + { + "keyword": "magic eight ball", + "emoticons": [ + "🎱" + ] + }, + { + "keyword": "queen's guard", + "emoticons": [ + "💂" + ] + }, + { + "keyword": "st. martin", + "emoticons": [ + "🇲🇫" + ] + }, + { + "keyword": "tj", + "emoticons": [ + "🇹🇯" + ] + }, + { + "keyword": "direct hit", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "wasp", + "emoticons": [ + "🐝" + ] + }, + { + "keyword": "beans", + "emoticons": [ + "🫘" + ] + }, + { + "keyword": "elbow", + "emoticons": [ + "💪" + ] + }, + { + "keyword": "shorts", + "emoticons": [ + "🩳" + ] + }, + { + "keyword": "virgo", + "emoticons": [ + "♍" + ] + }, + { + "keyword": "tshirt", + "emoticons": [ + "👕" + ] + }, + { + "keyword": "sb", + "emoticons": [ + "🇸🇧" + ] + }, + { + "keyword": "dvd", + "emoticons": [ + "📀" + ] + }, + { + "keyword": "manual wheelchair", + "emoticons": [ + "🦽" + ] + }, + { + "keyword": "malaysia", + "emoticons": [ + "🇲🇾" + ] + }, + { + "keyword": "lavender", + "emoticons": [ + "💜" + ] + }, + { + "keyword": "flag_ch", + "emoticons": [ + "🇨🇭" + ] + }, + { + "keyword": "mahjong", + "emoticons": [ + "🀄" + ] + }, + { + "keyword": "family_mmg", + "emoticons": [ + "👨‍👨‍👧" + ] + }, + { + "keyword": "jaws", + "emoticons": [ + "🦈" + ] + }, + { + "keyword": "sos button", + "emoticons": [ + "🆘" + ] + }, + { + "keyword": "japanese service charge button", + "emoticons": [ + "🈂" + ] + }, + { + "keyword": "pickle", + "emoticons": [ + "🥒" + ] + }, + { + "keyword": "pine decoration", + "emoticons": [ + "🎍" + ] + }, + { + "keyword": "dolls", + "emoticons": [ + "🎎" + ] + }, + { + "keyword": "agree", + "emoticons": [ + "👍", + "👌" + ] + }, + { + "keyword": "ao", + "emoticons": [ + "🇦🇴" + ] + }, + { + "keyword": "melting", + "emoticons": [ + "🫠" + ] + }, + { + "keyword": "ax", + "emoticons": [ + "🇦🇽" + ] + }, + { + "keyword": "dominica", + "emoticons": [ + "🇩🇲" + ] + }, + { + "keyword": "raising hand", + "emoticons": [ + "🙋‍♂", + "🙋", + "🙋‍♀" + ] + }, + { + "keyword": "guatemala", + "emoticons": [ + "🇬🇹" + ] + }, + { + "keyword": "disease", + "emoticons": [ + "🦠" + ] + }, + { + "keyword": "flag_tt", + "emoticons": [ + "🇹🇹" + ] + }, + { + "keyword": "crocodile", + "emoticons": [ + "🐊" + ] + }, + { + "keyword": "flag_uy", + "emoticons": [ + "🇺🇾" + ] + }, + { + "keyword": "avocado", + "emoticons": [ + "🥑" + ] + }, + { + "keyword": "horse racing", + "emoticons": [ + "🐎" + ] + }, + { + "keyword": "arrows clockwise", + "emoticons": [ + "🔃" + ] + }, + { + "keyword": "mag", + "emoticons": [ + "🔍" + ] + }, + { + "keyword": "a button (blood type)", + "emoticons": [ + "🅰" + ] + }, + { + "keyword": "salad", + "emoticons": [ + "🥗" + ] + }, + { + "keyword": "croatia", + "emoticons": [ + "🇭🇷" + ] + }, + { + "keyword": "content", + "emoticons": [ + "😌" + ] + }, + { + "keyword": "raised hand", + "emoticons": [ + "✋" + ] + }, + { + "keyword": "rna", + "emoticons": [ + "🧬" + ] + }, + { + "keyword": "flag_ee", + "emoticons": [ + "🇪🇪" + ] + }, + { + "keyword": "shrimp", + "emoticons": [ + "🦐", + "🍤" + ] + }, + { + "keyword": "growth", + "emoticons": [ + "📈" + ] + }, + { + "keyword": "zipper mouth face", + "emoticons": [ + "🤐" + ] + }, + { + "keyword": "fierce", + "emoticons": [ + "🦁" + ] + }, + { + "keyword": "stay home", + "emoticons": [ + "🏡" + ] + }, + { + "keyword": "slovakia", + "emoticons": [ + "🇸🇰" + ] + }, + { + "keyword": "lying face", + "emoticons": [ + "🤥" + ] + }, + { + "keyword": "mt", + "emoticons": [ + "🇲🇹" + ] + }, + { + "keyword": "flag_cd", + "emoticons": [ + "🇨🇩" + ] + }, + { + "keyword": "flag_kw", + "emoticons": [ + "🇰🇼" + ] + }, + { + "keyword": "emotional", + "emoticons": [ + "🥹" + ] + }, + { + "keyword": "polar bear", + "emoticons": [ + "🐻‍❄" + ] + }, + { + "keyword": "flag_td", + "emoticons": [ + "🇹🇩" + ] + }, + { + "keyword": "draft", + "emoticons": [ + "✍" + ] + }, + { + "keyword": "cheeseburger", + "emoticons": [ + "🍔" + ] + }, + { + "keyword": "duty", + "emoticons": [ + "🫡" + ] + }, + { + "keyword": "flamingo", + "emoticons": [ + "🦩" + ] + }, + { + "keyword": "shake hands", + "emoticons": [ + "🤝" + ] + }, + { + "keyword": "no vacancy", + "emoticons": [ + "🈵" + ] + }, + { + "keyword": "red envelope", + "emoticons": [ + "🧧" + ] + }, + { + "keyword": "down-right arrow", + "emoticons": [ + "↘" + ] + }, + { + "keyword": "yellow circle", + "emoticons": [ + "🟡" + ] + }, + { + "keyword": "flag_is", + "emoticons": [ + "🇮🇸" + ] + }, + { + "keyword": "chile", + "emoticons": [ + "🇨🇱" + ] + }, + { + "keyword": "man rowing boat", + "emoticons": [ + "🚣‍♂" + ] + }, + { + "keyword": "hatch", + "emoticons": [ + "🐣" + ] + }, + { + "keyword": "st george's cross", + "emoticons": [ + "🏴󠁧󠁢󠁥󠁮󠁧󠁿" + ] + }, + { + "keyword": "star of david", + "emoticons": [ + "✡" + ] + }, + { + "keyword": "marsupial", + "emoticons": [ + "🦘" + ] + }, + { + "keyword": "six-thirty", + "emoticons": [ + "🕡" + ] + }, + { + "keyword": "light rail", + "emoticons": [ + "🚈" + ] + }, + { + "keyword": "flag_gn", + "emoticons": [ + "🇬🇳" + ] + }, + { + "keyword": "future", + "emoticons": [ + "🎱", + "🔮" + ] + }, + { + "keyword": "skate", + "emoticons": [ + "🛹" + ] + }, + { + "keyword": "pen fountain", + "emoticons": [ + "✒" + ] + }, + { + "keyword": "money mouth face", + "emoticons": [ + "🤑" + ] + }, + { + "keyword": "tiger face", + "emoticons": [ + "🐯" + ] + }, + { + "keyword": "covid", + "emoticons": [ + "🦠" + ] + }, + { + "keyword": "tram", + "emoticons": [ + "🚊" + ] + }, + { + "keyword": "duh", + "emoticons": [ + "🙄" + ] + }, + { + "keyword": "orange square", + "emoticons": [ + "🟧" + ] + }, + { + "keyword": "claim", + "emoticons": [ + "🛄" + ] + }, + { + "keyword": "mountain railway", + "emoticons": [ + "🚞" + ] + }, + { + "keyword": "flag_eg", + "emoticons": [ + "🇪🇬" + ] + }, + { + "keyword": "swiss", + "emoticons": [ + "🇨🇭" + ] + }, + { + "keyword": "crazy", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "sun behind cloud", + "emoticons": [ + "🌤", + "⛅" + ] + }, + { + "keyword": "link", + "emoticons": [ + "⛓", + "🔗" + ] + }, + { + "keyword": "sewing needle", + "emoticons": [ + "🪡" + ] + }, + { + "keyword": "dim button", + "emoticons": [ + "🔅" + ] + }, + { + "keyword": "french polynesia", + "emoticons": [ + "🇵🇫" + ] + }, + { + "keyword": "queer", + "emoticons": [ + "🏳‍🌈" + ] + }, + { + "keyword": "cl", + "emoticons": [ + "🇨🇱" + ] + }, + { + "keyword": "sun behind small cloud", + "emoticons": [ + "🌤" + ] + }, + { + "keyword": "tie", + "emoticons": [ + "👔" + ] + }, + { + "keyword": "voltage", + "emoticons": [ + "⚡" + ] + }, + { + "keyword": "faroe islands", + "emoticons": [ + "🇫🇴" + ] + }, + { + "keyword": "capricorn", + "emoticons": [ + "♑" + ] + }, + { + "keyword": "cyclone", + "emoticons": [ + "🌀" + ] + }, + { + "keyword": "family mmbb", + "emoticons": [ + "👨‍👨‍👦‍👦" + ] + }, + { + "keyword": "incognito", + "emoticons": [ + "🥸" + ] + }, + { + "keyword": "suit", + "emoticons": [ + "🤵", + "🕴" + ] + }, + { + "keyword": "finish", + "emoticons": [ + "🏁" + ] + }, + { + "keyword": "kinshasa", + "emoticons": [ + "🇨🇩" + ] + }, + { + "keyword": "flag in hole", + "emoticons": [ + "⛳" + ] + }, + { + "keyword": "last track button", + "emoticons": [ + "⏮" + ] + }, + { + "keyword": "take-off", + "emoticons": [ + "🛫" + ] + }, + { + "keyword": "clouds", + "emoticons": [ + "😶‍🌫" + ] + }, + { + "keyword": "flag_bs", + "emoticons": [ + "🇧🇸" + ] + }, + { + "keyword": "brilliant", + "emoticons": [ + "💎" + ] + }, + { + "keyword": "laptop", + "emoticons": [ + "💻" + ] + }, + { + "keyword": "person rowing boat", + "emoticons": [ + "🚣‍♂" + ] + }, + { + "keyword": "croissant", + "emoticons": [ + "🥐" + ] + }, + { + "keyword": "flag_ck", + "emoticons": [ + "🇨🇰" + ] + }, + { + "keyword": "small blue diamond", + "emoticons": [ + "🔹" + ] + }, + { + "keyword": "gymnast", + "emoticons": [ + "🤸" + ] + }, + { + "keyword": "right arrow curving up", + "emoticons": [ + "⤴" + ] + }, + { + "keyword": "two women holding hands", + "emoticons": [ + "👭" + ] + }, + { + "keyword": "woman mage", + "emoticons": [ + "🧙‍♀" + ] + }, + { + "keyword": "tear", + "emoticons": [ + "🥲" + ] + }, + { + "keyword": "pearl", + "emoticons": [ + "🦪" + ] + }, + { + "keyword": "flag_ni", + "emoticons": [ + "🇳🇮" + ] + }, + { + "keyword": "(^_^)", + "emoticons": [ + "☺", + "😊" + ] + }, + { + "keyword": "mas", + "emoticons": [ + "🇲🇾" + ] + }, + { + "keyword": "peacock", + "emoticons": [ + "🦚" + ] + }, + { + "keyword": "suspension railway", + "emoticons": [ + "🚟" + ] + }, + { + "keyword": "mauritania", + "emoticons": [ + "🇲🇷" + ] + }, + { + "keyword": "cherry blossom", + "emoticons": [ + "🌸" + ] + }, + { + "keyword": "flash", + "emoticons": [ + "📸" + ] + }, + { + "keyword": "forgetful", + "emoticons": [ + "😶‍🌫" + ] + }, + { + "keyword": "flag_ar", + "emoticons": [ + "🇦🇷" + ] + }, + { + "keyword": "face savouring delicious food", + "emoticons": [ + "😋" + ] + }, + { + "keyword": "help", + "emoticons": [ + "🆘" + ] + }, + { + "keyword": "3", + "emoticons": [ + "3⃣" + ] + }, + { + "keyword": "urn", + "emoticons": [ + "⚱" + ] + }, + { + "keyword": "do not litter", + "emoticons": [ + "🚯" + ] + }, + { + "keyword": "moyai", + "emoticons": [ + "🗿" + ] + }, + { + "keyword": "wheel of dharma", + "emoticons": [ + "☸" + ] + }, + { + "keyword": "cheese", + "emoticons": [ + "🧀" + ] + }, + { + "keyword": "czech republic", + "emoticons": [ + "🇨🇿" + ] + }, + { + "keyword": "tn", + "emoticons": [ + "🇹🇳" + ] + }, + { + "keyword": "light bulb", + "emoticons": [ + "💡" + ] + }, + { + "keyword": "al", + "emoticons": [ + "🇦🇱" + ] + }, + { + "keyword": "extinguisher", + "emoticons": [ + "🧯" + ] + }, + { + "keyword": "snowing", + "emoticons": [ + "🌨" + ] + }, + { + "keyword": "stats", + "emoticons": [ + "📊" + ] + }, + { + "keyword": "man in business suit levitating", + "emoticons": [ + "🕴" + ] + }, + { + "keyword": "philippines", + "emoticons": [ + "🇵🇭" + ] + }, + { + "keyword": "mexico", + "emoticons": [ + "🇲🇽" + ] + }, + { + "keyword": "mosquito", + "emoticons": [ + "🦟" + ] + }, + { + "keyword": "ceuta \u0026 melilla", + "emoticons": [ + "🇪🇦" + ] + }, + { + "keyword": "pawn", + "emoticons": [ + "♟" + ] + }, + { + "keyword": "nl", + "emoticons": [ + "🇳🇱" + ] + }, + { + "keyword": "closed lock with key", + "emoticons": [ + "🔐" + ] + }, + { + "keyword": "gem stone", + "emoticons": [ + "💎" + ] + }, + { + "keyword": "red triangle pointed up", + "emoticons": [ + "🔺" + ] + }, + { + "keyword": "lawn sign ", + "emoticons": [ + "🪧" + ] + }, + { + "keyword": "bs", + "emoticons": [ + "🇧🇸" + ] + }, + { + "keyword": "california", + "emoticons": [ + "🌉" + ] + }, + { + "keyword": "false", + "emoticons": [ + "👎" + ] + }, + { + "keyword": "hm", + "emoticons": [ + "😐" + ] + }, + { + "keyword": "bhutan", + "emoticons": [ + "🇧🇹" + ] + }, + { + "keyword": "humm", + "emoticons": [ + "🤨" + ] + }, + { + "keyword": "frisbee", + "emoticons": [ + "🥏" + ] + }, + { + "keyword": "discount", + "emoticons": [ + "🈹" + ] + }, + { + "keyword": "man getting face massage", + "emoticons": [ + "💆‍♂" + ] + }, + { + "keyword": "south sudan", + "emoticons": [ + "🇸🇸" + ] + }, + { + "keyword": "flag_bz", + "emoticons": [ + "🇧🇿" + ] + }, + { + "keyword": "woman detective", + "emoticons": [ + "🕵‍♀" + ] + }, + { + "keyword": "bandage face", + "emoticons": [ + "🤕" + ] + }, + { + "keyword": "space invader", + "emoticons": [ + "👾" + ] + }, + { + "keyword": "mk", + "emoticons": [ + "🇲🇰" + ] + }, + { + "keyword": "man elf", + "emoticons": [ + "🧝‍♂" + ] + }, + { + "keyword": "division", + "emoticons": [ + "➗" + ] + }, + { + "keyword": "red heart", + "emoticons": [ + "❤" + ] + }, + { + "keyword": "red apple", + "emoticons": [ + "🍎" + ] + }, + { + "keyword": "school backpack", + "emoticons": [ + "🎒" + ] + }, + { + "keyword": "farsi", + "emoticons": [ + "🇮🇷" + ] + }, + { + "keyword": "woman rowing boat", + "emoticons": [ + "🚣‍♀" + ] + }, + { + "keyword": "sleeping accommodation", + "emoticons": [ + "🛌" + ] + }, + { + "keyword": "pumpkin", + "emoticons": [ + "🎃" + ] + }, + { + "keyword": "screwdriver", + "emoticons": [ + "🪛" + ] + }, + { + "keyword": "toy", + "emoticons": [ + "🧸" + ] + }, + { + "keyword": "timor-leste", + "emoticons": [ + "🇹🇱" + ] + }, + { + "keyword": "fr", + "emoticons": [ + "🇫🇷" + ] + }, + { + "keyword": "zap", + "emoticons": [ + "⚡" + ] + }, + { + "keyword": "kw", + "emoticons": [ + "🇰🇼" + ] + }, + { + "keyword": "destination", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "soccer", + "emoticons": [ + "⚽" + ] + }, + { + "keyword": "flag_cc", + "emoticons": [ + "🇨🇨" + ] + }, + { + "keyword": "bun", + "emoticons": [ + "🌭" + ] + }, + { + "keyword": "ireland", + "emoticons": [ + "🇮🇪" + ] + }, + { + "keyword": "bridge", + "emoticons": [ + "🌉" + ] + }, + { + "keyword": "pointing right", + "emoticons": [ + "👉" + ] + }, + { + "keyword": "warrior", + "emoticons": [ + "🥷" + ] + }, + { + "keyword": "alert", + "emoticons": [ + "⚠" + ] + }, + { + "keyword": "couple kiss mm", + "emoticons": [ + "👨‍❤‍💋‍👨" + ] + }, + { + "keyword": "canary islands", + "emoticons": [ + "🇮🇨" + ] + }, + { + "keyword": "myanmar", + "emoticons": [ + "🇲🇲" + ] + }, + { + "keyword": "nsfw", + "emoticons": [ + "🔞" + ] + }, + { + "keyword": "pig face", + "emoticons": [ + "🐷" + ] + }, + { + "keyword": "office building", + "emoticons": [ + "🏢" + ] + }, + { + "keyword": "joker", + "emoticons": [ + "🃏", + "🤡" + ] + }, + { + "keyword": "mobile phones prohibited", + "emoticons": [ + "📵" + ] + }, + { + "keyword": "onsen", + "emoticons": [ + "♨" + ] + }, + { + "keyword": "backhand index pointing right", + "emoticons": [ + "👉" + ] + }, + { + "keyword": "roo", + "emoticons": [ + "🦘" + ] + }, + { + "keyword": "singapore", + "emoticons": [ + "🇸🇬" + ] + }, + { + "keyword": "period", + "emoticons": [ + "🩸" + ] + }, + { + "keyword": "tulip", + "emoticons": [ + "🌷" + ] + }, + { + "keyword": "piano", + "emoticons": [ + "🎹" + ] + }, + { + "keyword": "behemoth", + "emoticons": [ + "🦛" + ] + }, + { + "keyword": "muted", + "emoticons": [ + "🔕" + ] + }, + { + "keyword": "crayfish", + "emoticons": [ + "🦞" + ] + }, + { + "keyword": "new", + "emoticons": [ + "🆕" + ] + }, + { + "keyword": "grasshopper", + "emoticons": [ + "🦗" + ] + }, + { + "keyword": "cloud with rain", + "emoticons": [ + "🌧" + ] + }, + { + "keyword": "dotted six-pointed star", + "emoticons": [ + "🔯" + ] + }, + { + "keyword": "cowboy", + "emoticons": [ + "🤠" + ] + }, + { + "keyword": "cop car", + "emoticons": [ + "🚔" + ] + }, + { + "keyword": "ezay", + "emoticons": [ + "😕" + ] + }, + { + "keyword": "dominican republic", + "emoticons": [ + "🇩🇴" + ] + }, + { + "keyword": "prediction", + "emoticons": [ + "🔮", + "🎱" + ] + }, + { + "keyword": "repeat single button", + "emoticons": [ + "🔂" + ] + }, + { + "keyword": "white flower", + "emoticons": [ + "💮" + ] + }, + { + "keyword": "yesmaam", + "emoticons": [ + "🫡" + ] + }, + { + "keyword": "good", + "emoticons": [ + "👍", + "🥉" + ] + }, + { + "keyword": "nf", + "emoticons": [ + "🇳🇫" + ] + }, + { + "keyword": "saw", + "emoticons": [ + "🪚" + ] + }, + { + "keyword": "bean", + "emoticons": [ + "🫘" + ] + }, + { + "keyword": "ox", + "emoticons": [ + "🐂", + "🐃" + ] + }, + { + "keyword": "saxophone", + "emoticons": [ + "🎷" + ] + }, + { + "keyword": "coaster", + "emoticons": [ + "🎢" + ] + }, + { + "keyword": "snowman", + "emoticons": [ + "⛄", + "☃" + ] + }, + { + "keyword": "screen", + "emoticons": [ + "📺" + ] + }, + { + "keyword": "gorilla", + "emoticons": [ + "🦍" + ] + }, + { + "keyword": "keycap digit one", + "emoticons": [ + "1⃣" + ] + }, + { + "keyword": "cote d'ivoire", + "emoticons": [ + "🇨🇮" + ] + }, + { + "keyword": "call", + "emoticons": [ + "🤙", + "📞" + ] + }, + { + "keyword": "fried shrimp", + "emoticons": [ + "🍤" + ] + }, + { + "keyword": "ice skate", + "emoticons": [ + "⛸" + ] + }, + { + "keyword": "point right", + "emoticons": [ + "👉" + ] + }, + { + "keyword": "bamboo", + "emoticons": [ + "🎍" + ] + }, + { + "keyword": "check mark", + "emoticons": [ + "✔" + ] + }, + { + "keyword": "aladdin", + "emoticons": [ + "🧞" + ] + }, + { + "keyword": "clapper board", + "emoticons": [ + "🎬" + ] + }, + { + "keyword": "cloud with lightning", + "emoticons": [ + "🌩" + ] + }, + { + "keyword": "clapper", + "emoticons": [ + "🎬" + ] + }, + { + "keyword": "flag_ly", + "emoticons": [ + "🇱🇾" + ] + }, + { + "keyword": "thread", + "emoticons": [ + "🧵" + ] + }, + { + "keyword": "stuffed pita", + "emoticons": [ + "🥙" + ] + }, + { + "keyword": "wheelchair symbol", + "emoticons": [ + "♿" + ] + }, + { + "keyword": "taurus", + "emoticons": [ + "♉" + ] + }, + { + "keyword": "diving", + "emoticons": [ + "🤿" + ] + }, + { + "keyword": "bowl with spoon", + "emoticons": [ + "🥣" + ] + }, + { + "keyword": "escargot", + "emoticons": [ + "🐌" + ] + }, + { + "keyword": "bookmark", + "emoticons": [ + "🔖" + ] + }, + { + "keyword": "clock10", + "emoticons": [ + "🕙" + ] + }, + { + "keyword": "black circle", + "emoticons": [ + "⚫" + ] + }, + { + "keyword": "chains", + "emoticons": [ + "⛓" + ] + }, + { + "keyword": "woman feeding baby", + "emoticons": [ + "👩‍🍼" + ] + }, + { + "keyword": "farm", + "emoticons": [ + "🐮", + "🐷" + ] + }, + { + "keyword": "prayer beads", + "emoticons": [ + "📿" + ] + }, + { + "keyword": "glasses ", + "emoticons": [ + "🥸" + ] + }, + { + "keyword": "long drum", + "emoticons": [ + "🪘" + ] + }, + { + "keyword": "red book", + "emoticons": [ + "📕" + ] + }, + { + "keyword": "flag_cr", + "emoticons": [ + "🇨🇷" + ] + }, + { + "keyword": "flag_cv", + "emoticons": [ + "🇨🇻" + ] + }, + { + "keyword": "surfer", + "emoticons": [ + "🏄‍♂", + "🏄‍♀", + "🏄" + ] + }, + { + "keyword": "france", + "emoticons": [ + "🇫🇷" + ] + }, + { + "keyword": "pakistan", + "emoticons": [ + "🇵🇰" + ] + }, + { + "keyword": "man bouncing ball", + "emoticons": [ + "⛹‍♂" + ] + }, + { + "keyword": "transgender sign", + "emoticons": [ + "⚧" + ] + }, + { + "keyword": "keycap digit five", + "emoticons": [ + "5⃣" + ] + }, + { + "keyword": "man office worker", + "emoticons": [ + "👨‍💼" + ] + }, + { + "keyword": "north macedonia", + "emoticons": [ + "🇲🇰" + ] + }, + { + "keyword": "black heart", + "emoticons": [ + "🖤" + ] + }, + { + "keyword": "record", + "emoticons": [ + "🎤" + ] + }, + { + "keyword": "iq", + "emoticons": [ + "🇮🇶" + ] + }, + { + "keyword": "usa", + "emoticons": [ + "🇺🇸" + ] + }, + { + "keyword": "fish cake", + "emoticons": [ + "🍥" + ] + }, + { + "keyword": "tyre", + "emoticons": [ + "🛞" + ] + }, + { + "keyword": "bv", + "emoticons": [ + "🇳🇴" + ] + }, + { + "keyword": "oncoming taxi", + "emoticons": [ + "🚖" + ] + }, + { + "keyword": "cherries", + "emoticons": [ + "🍒" + ] + }, + { + "keyword": "family wwg", + "emoticons": [ + "👩‍👩‍👧" + ] + }, + { + "keyword": "nut", + "emoticons": [ + "🔩" + ] + }, + { + "keyword": "family wwb", + "emoticons": [ + "👩‍👩‍👦" + ] + }, + { + "keyword": "heart with arrow", + "emoticons": [ + "💘" + ] + }, + { + "keyword": "herb", + "emoticons": [ + "🌿" + ] + }, + { + "keyword": "bo", + "emoticons": [ + "🇧🇴" + ] + }, + { + "keyword": "flag_fm", + "emoticons": [ + "🇫🇲" + ] + }, + { + "keyword": "thought", + "emoticons": [ + "💭" + ] + }, + { + "keyword": "bolivia", + "emoticons": [ + "🇧🇴" + ] + }, + { + "keyword": "switzerland", + "emoticons": [ + "🇨🇭" + ] + }, + { + "keyword": "alpha", + "emoticons": [ + "🦍", + "🐺" + ] + }, + { + "keyword": "hungary", + "emoticons": [ + "🇭🇺" + ] + }, + { + "keyword": "mandir", + "emoticons": [ + "🛕" + ] + }, + { + "keyword": "goggles", + "emoticons": [ + "🥽" + ] + }, + { + "keyword": "swaziland", + "emoticons": [ + "🇸🇿" + ] + }, + { + "keyword": "ok button", + "emoticons": [ + "🆗" + ] + }, + { + "keyword": "duck", + "emoticons": [ + "🦆" + ] + }, + { + "keyword": "eight-pointed star", + "emoticons": [ + "✴" + ] + }, + { + "keyword": "aliens", + "emoticons": [ + "👽", + "👾" + ] + }, + { + "keyword": "milky way", + "emoticons": [ + "🌌" + ] + }, + { + "keyword": "clinking glasses", + "emoticons": [ + "🥂" + ] + }, + { + "keyword": "woman technologist", + "emoticons": [ + "👩‍💻" + ] + }, + { + "keyword": "meridians", + "emoticons": [ + "🌐" + ] + }, + { + "keyword": "man wearing turban", + "emoticons": [ + "👳‍♂" + ] + }, + { + "keyword": "ecuador", + "emoticons": [ + "🇪🇨" + ] + }, + { + "keyword": "bright button", + "emoticons": [ + "🔆" + ] + }, + { + "keyword": "construction", + "emoticons": [ + "🚧" + ] + }, + { + "keyword": "flag_hk", + "emoticons": [ + "🇭🇰" + ] + }, + { + "keyword": "man tipping hand", + "emoticons": [ + "💁‍♂" + ] + }, + { + "keyword": "flamenco", + "emoticons": [ + "💃" + ] + }, + { + "keyword": "beginner", + "emoticons": [ + "🔰" + ] + }, + { + "keyword": "boomerang", + "emoticons": [ + "🪃" + ] + }, + { + "keyword": "aligator", + "emoticons": [ + "🐊" + ] + }, + { + "keyword": "not free", + "emoticons": [ + "🈶" + ] + }, + { + "keyword": "flag_bw", + "emoticons": [ + "🇧🇼" + ] + }, + { + "keyword": "stuffed", + "emoticons": [ + "🥙" + ] + }, + { + "keyword": "twisted rightwards arrows", + "emoticons": [ + "🔀" + ] + }, + { + "keyword": "clutch bag", + "emoticons": [ + "👝" + ] + }, + { + "keyword": "a", + "emoticons": [ + "🅰" + ] + }, + { + "keyword": "b button (blood type)", + "emoticons": [ + "🅱" + ] + }, + { + "keyword": "lesotho", + "emoticons": [ + "🇱🇸" + ] + }, + { + "keyword": "flag_th", + "emoticons": [ + "🇹🇭" + ] + }, + { + "keyword": "spilt", + "emoticons": [ + "🫗" + ] + }, + { + "keyword": "american football", + "emoticons": [ + "🏈" + ] + }, + { + "keyword": "cooking", + "emoticons": [ + "🍳" + ] + }, + { + "keyword": "orly", + "emoticons": [ + "🤨" + ] + }, + { + "keyword": "restroom", + "emoticons": [ + "🚻" + ] + }, + { + "keyword": "eight-spoked asterisk", + "emoticons": [ + "✳" + ] + }, + { + "keyword": "lied", + "emoticons": [ + "🤥" + ] + }, + { + "keyword": "desert island", + "emoticons": [ + "🏝" + ] + }, + { + "keyword": "koala", + "emoticons": [ + "🐨" + ] + }, + { + "keyword": "uk", + "emoticons": [ + "🇬🇧" + ] + }, + { + "keyword": "page facing up", + "emoticons": [ + "📄" + ] + }, + { + "keyword": "st. kitts \u0026 nevis", + "emoticons": [ + "🇰🇳" + ] + }, + { + "keyword": "mate", + "emoticons": [ + "🧉" + ] + }, + { + "keyword": "explain", + "emoticons": [ + "🤔" + ] + }, + { + "keyword": "trap", + "emoticons": [ + "🕸" + ] + }, + { + "keyword": "retriever", + "emoticons": [ + "🦮" + ] + }, + { + "keyword": "flag_st", + "emoticons": [ + "🇸🇹" + ] + }, + { + "keyword": "baking", + "emoticons": [ + "🥵" + ] + }, + { + "keyword": "postal horn", + "emoticons": [ + "📯" + ] + }, + { + "keyword": "shield", + "emoticons": [ + "🛡" + ] + }, + { + "keyword": "mountain biking", + "emoticons": [ + "🚵‍♂", + "🚵‍♀" + ] + }, + { + "keyword": "pregnant woman", + "emoticons": [ + "🤰" + ] + }, + { + "keyword": "french southern territories", + "emoticons": [ + "🇹🇫" + ] + }, + { + "keyword": "colombia", + "emoticons": [ + "🇨🇴" + ] + }, + { + "keyword": "speaker low volume", + "emoticons": [ + "🔈" + ] + }, + { + "keyword": "bb", + "emoticons": [ + "🇧🇧" + ] + }, + { + "keyword": "safety ring", + "emoticons": [ + "🛟" + ] + }, + { + "keyword": "dagger", + "emoticons": [ + "🗡" + ] + }, + { + "keyword": "norfolk island", + "emoticons": [ + "🇳🇫" + ] + }, + { + "keyword": "skyscrapper", + "emoticons": [ + "🏙" + ] + }, + { + "keyword": "fast up button", + "emoticons": [ + "⏫" + ] + }, + { + "keyword": "ws", + "emoticons": [ + "🇼🇸" + ] + }, + { + "keyword": "tonga", + "emoticons": [ + "🇹🇴" + ] + }, + { + "keyword": "baguette bread", + "emoticons": [ + "🥖" + ] + }, + { + "keyword": "pan", + "emoticons": [ + "🥘" + ] + }, + { + "keyword": "frames", + "emoticons": [ + "🎞" + ] + }, + { + "keyword": "couch", + "emoticons": [ + "🛋" + ] + }, + { + "keyword": "flag_mh", + "emoticons": [ + "🇲🇭" + ] + }, + { + "keyword": "li", + "emoticons": [ + "🇱🇮" + ] + }, + { + "keyword": "father", + "emoticons": [ + "👨‍👩‍👦" + ] + }, + { + "keyword": "new button", + "emoticons": [ + "🆕" + ] + }, + { + "keyword": "crossed fingers", + "emoticons": [ + "🤞" + ] + }, + { + "keyword": "massaging", + "emoticons": [ + "💆" + ] + }, + { + "keyword": "bride with veil", + "emoticons": [ + "👰" + ] + }, + { + "keyword": "disguised face", + "emoticons": [ + "🥸" + ] + }, + { + "keyword": "arrow backward", + "emoticons": [ + "◀" + ] + }, + { + "keyword": "flag_tz", + "emoticons": [ + "🇹🇿" + ] + }, + { + "keyword": "unsettled", + "emoticons": [ + "🫤" + ] + }, + { + "keyword": "ba", + "emoticons": [ + "🇧🇦" + ] + }, + { + "keyword": "woman in steamy room", + "emoticons": [ + "🧖‍♀" + ] + }, + { + "keyword": "suriname", + "emoticons": [ + "🇸🇷" + ] + }, + { + "keyword": "hurried", + "emoticons": [ + "🏃" + ] + }, + { + "keyword": "!?", + "emoticons": [ + "⁉" + ] + }, + { + "keyword": "billed cap", + "emoticons": [ + "🧢" + ] + }, + { + "keyword": "braai", + "emoticons": [ + "♨" + ] + }, + { + "keyword": "heartpulse", + "emoticons": [ + "💗" + ] + }, + { + "keyword": "seven", + "emoticons": [ + "7⃣" + ] + }, + { + "keyword": "tiny", + "emoticons": [ + "🤏" + ] + }, + { + "keyword": "aw", + "emoticons": [ + "🇦🇼" + ] + }, + { + "keyword": "yen", + "emoticons": [ + "💴" + ] + }, + { + "keyword": "transgender symbol", + "emoticons": [ + "⚧" + ] + }, + { + "keyword": "scot", + "emoticons": [ + "🏴󠁧󠁢󠁳󠁣󠁴󠁿" + ] + }, + { + "keyword": "ferry", + "emoticons": [ + "⛴" + ] + }, + { + "keyword": "yum", + "emoticons": [ + "😋" + ] + }, + { + "keyword": "masks", + "emoticons": [ + "🎭" + ] + }, + { + "keyword": "tipsy", + "emoticons": [ + "🥴" + ] + }, + { + "keyword": "frozen", + "emoticons": [ + "🧊", + "🥶" + ] + }, + { + "keyword": "stealth", + "emoticons": [ + "🥷" + ] + }, + { + "keyword": "hot springs", + "emoticons": [ + "♨" + ] + }, + { + "keyword": "bg", + "emoticons": [ + "🇧🇬" + ] + }, + { + "keyword": "pee", + "emoticons": [ + "🚹", + "🚺" + ] + }, + { + "keyword": "lobster", + "emoticons": [ + "🦞" + ] + }, + { + "keyword": "asia", + "emoticons": [ + "🌏" + ] + }, + { + "keyword": "woman office worker", + "emoticons": [ + "👩‍💼" + ] + }, + { + "keyword": "flag_mr", + "emoticons": [ + "🇲🇷" + ] + }, + { + "keyword": "expensive", + "emoticons": [ + "🫰" + ] + }, + { + "keyword": "dolphin", + "emoticons": [ + "🐬" + ] + }, + { + "keyword": "stupid", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "fencer", + "emoticons": [ + "🤺" + ] + }, + { + "keyword": "flag_sh", + "emoticons": [ + "🇸🇭" + ] + }, + { + "keyword": "woman getting face massage", + "emoticons": [ + "💆‍♀" + ] + }, + { + "keyword": "latvia", + "emoticons": [ + "🇱🇻" + ] + }, + { + "keyword": "arrow double up", + "emoticons": [ + "⏫" + ] + }, + { + "keyword": "tanabata tree", + "emoticons": [ + "🎋" + ] + }, + { + "keyword": "woman pilot", + "emoticons": [ + "👩‍✈" + ] + }, + { + "keyword": "house with garden", + "emoticons": [ + "🏡" + ] + }, + { + "keyword": "sa", + "emoticons": [ + "🇸🇦" + ] + }, + { + "keyword": "gecko", + "emoticons": [ + "🦎" + ] + }, + { + "keyword": "shoshinsha", + "emoticons": [ + "🔰" + ] + }, + { + "keyword": "abusing", + "emoticons": [ + "🤬" + ] + }, + { + "keyword": "bachelor", + "emoticons": [ + "🧑‍🎓" + ] + }, + { + "keyword": "frustration", + "emoticons": [ + "🤬" + ] + }, + { + "keyword": "mr", + "emoticons": [ + "🇲🇷" + ] + }, + { + "keyword": "diyo", + "emoticons": [ + "🪔" + ] + }, + { + "keyword": "man playing water polo", + "emoticons": [ + "🤽‍♂" + ] + }, + { + "keyword": "pandemic", + "emoticons": [ + "🦠" + ] + }, + { + "keyword": "cleaning", + "emoticons": [ + "🧹" + ] + }, + { + "keyword": "point down", + "emoticons": [ + "👇" + ] + }, + { + "keyword": "articulated lorry", + "emoticons": [ + "🚛" + ] + }, + { + "keyword": "northern mariana islands", + "emoticons": [ + "🇲🇵" + ] + }, + { + "keyword": "oh my", + "emoticons": [ + "🫢" + ] + }, + { + "keyword": "sure", + "emoticons": [ + "👌" + ] + }, + { + "keyword": "blond-haired man", + "emoticons": [ + "👱‍♂" + ] + }, + { + "keyword": "pepe", + "emoticons": [ + "🐸" + ] + }, + { + "keyword": "tokyo tower", + "emoticons": [ + "🗼" + ] + }, + { + "keyword": "red cross", + "emoticons": [ + "❌" + ] + }, + { + "keyword": "pg", + "emoticons": [ + "🇵🇬" + ] + }, + { + "keyword": "mw", + "emoticons": [ + "🇲🇼" + ] + }, + { + "keyword": "demon", + "emoticons": [ + "👹" + ] + }, + { + "keyword": "open hands", + "emoticons": [ + "🤲" + ] + }, + { + "keyword": "aaa", + "emoticons": [ + "😱" + ] + }, + { + "keyword": "pensive", + "emoticons": [ + "😔" + ] + }, + { + "keyword": "so", + "emoticons": [ + "🇸🇴" + ] + }, + { + "keyword": "ee", + "emoticons": [ + "🇪🇪" + ] + }, + { + "keyword": "covid-19", + "emoticons": [ + "🦠" + ] + }, + { + "keyword": "revolving hearts", + "emoticons": [ + "💞" + ] + }, + { + "keyword": "gambling", + "emoticons": [ + "🎰" + ] + }, + { + "keyword": "central african republic", + "emoticons": [ + "🇨🇫" + ] + }, + { + "keyword": "oil drum", + "emoticons": [ + "🛢" + ] + }, + { + "keyword": "hearing aid", + "emoticons": [ + "🦻" + ] + }, + { + "keyword": "algeria", + "emoticons": [ + "🇩🇿" + ] + }, + { + "keyword": "glitter", + "emoticons": [ + "✨" + ] + }, + { + "keyword": "uruguay", + "emoticons": [ + "🇺🇾" + ] + }, + { + "keyword": "trolls", + "emoticons": [ + "🧌" + ] + }, + { + "keyword": "ten", + "emoticons": [ + "🔟" + ] + }, + { + "keyword": "shark", + "emoticons": [ + "🦈" + ] + }, + { + "keyword": "flag_vg", + "emoticons": [ + "🇻🇬" + ] + }, + { + "keyword": "clamp", + "emoticons": [ + "🗜" + ] + }, + { + "keyword": "salt shaker", + "emoticons": [ + "🧂" + ] + }, + { + "keyword": "cucumber", + "emoticons": [ + "🥒" + ] + }, + { + "keyword": "dartboard", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "expressionless face", + "emoticons": [ + "😑" + ] + }, + { + "keyword": "orange heart", + "emoticons": [ + "🧡" + ] + }, + { + "keyword": "first", + "emoticons": [ + "🥇" + ] + }, + { + "keyword": "photo camera", + "emoticons": [ + "📸" + ] + }, + { + "keyword": "nicaragua", + "emoticons": [ + "🇳🇮" + ] + }, + { + "keyword": "key", + "emoticons": [ + "🔑", + "🗝" + ] + }, + { + "keyword": "cooker", + "emoticons": [ + "🎛" + ] + }, + { + "keyword": "exclamation question mark", + "emoticons": [ + "⁉" + ] + }, + { + "keyword": "eek", + "emoticons": [ + "😣" + ] + }, + { + "keyword": "two hearts", + "emoticons": [ + "💕" + ] + }, + { + "keyword": "black small square", + "emoticons": [ + "▪" + ] + }, + { + "keyword": "beckon", + "emoticons": [ + "🫴" + ] + }, + { + "keyword": "flag_br", + "emoticons": [ + "🇧🇷" + ] + }, + { + "keyword": "somalia", + "emoticons": [ + "🇸🇴" + ] + }, + { + "keyword": "hear no evil", + "emoticons": [ + "🙉" + ] + }, + { + "keyword": "flag_pk", + "emoticons": [ + "🇵🇰" + ] + }, + { + "keyword": "abcd", + "emoticons": [ + "🔡" + ] + }, + { + "keyword": "nc", + "emoticons": [ + "🇳🇨" + ] + }, + { + "keyword": "se", + "emoticons": [ + "🇸🇪" + ] + }, + { + "keyword": "bahrain", + "emoticons": [ + "🇧🇭" + ] + }, + { + "keyword": "reject", + "emoticons": [ + "🙅‍♂" + ] + }, + { + "keyword": "earth asia", + "emoticons": [ + "🌏" + ] + }, + { + "keyword": "flag_il", + "emoticons": [ + "🇮🇱" + ] + }, + { + "keyword": "water lily", + "emoticons": [ + "🪷" + ] + }, + { + "keyword": "love hotel", + "emoticons": [ + "🏩" + ] + }, + { + "keyword": "sneezing face", + "emoticons": [ + "🤧" + ] + }, + { + "keyword": "heart beat", + "emoticons": [ + "💓" + ] + }, + { + "keyword": "money with wings", + "emoticons": [ + "💸" + ] + }, + { + "keyword": "green heart", + "emoticons": [ + "💚" + ] + }, + { + "keyword": "woman pouting", + "emoticons": [ + "🙎‍♀" + ] + }, + { + "keyword": "clubs", + "emoticons": [ + "♣" + ] + }, + { + "keyword": "gs", + "emoticons": [ + "🇬🇸" + ] + }, + { + "keyword": "yarn", + "emoticons": [ + "🧶" + ] + }, + { + "keyword": "monitor", + "emoticons": [ + "🖥" + ] + }, + { + "keyword": "briefs", + "emoticons": [ + "🩲" + ] + }, + { + "keyword": "christians", + "emoticons": [ + "☦", + "✝" + ] + }, + { + "keyword": "scooty", + "emoticons": [ + "🛵" + ] + }, + { + "keyword": "coral", + "emoticons": [ + "🪸" + ] + }, + { + "keyword": "input numbers", + "emoticons": [ + "🔢" + ] + }, + { + "keyword": "bullettrain nose", + "emoticons": [ + "🚆" + ] + }, + { + "keyword": "fiji", + "emoticons": [ + "🇫🇯" + ] + }, + { + "keyword": "socks", + "emoticons": [ + "🧦" + ] + }, + { + "keyword": "farewell", + "emoticons": [ + "🫡" + ] + }, + { + "keyword": "yikes", + "emoticons": [ + "🙈" + ] + }, + { + "keyword": "three", + "emoticons": [ + "3⃣" + ] + }, + { + "keyword": "austria", + "emoticons": [ + "🇦🇹" + ] + }, + { + "keyword": "pointing up", + "emoticons": [ + "☝" + ] + }, + { + "keyword": "equals", + "emoticons": [ + "🟰" + ] + }, + { + "keyword": "open file folder", + "emoticons": [ + "📂" + ] + }, + { + "keyword": "man biking", + "emoticons": [ + "🚴‍♂" + ] + }, + { + "keyword": "leaf fluttering in the wind", + "emoticons": [ + "🍃" + ] + }, + { + "keyword": "no mobile phones", + "emoticons": [ + "📵" + ] + }, + { + "keyword": "pink shirt", + "emoticons": [ + "👚" + ] + }, + { + "keyword": "imp", + "emoticons": [ + "👿" + ] + }, + { + "keyword": "finger purse", + "emoticons": [ + "🤌" + ] + }, + { + "keyword": "woman climbing", + "emoticons": [ + "🧗‍♀" + ] + }, + { + "keyword": "flag_vu", + "emoticons": [ + "🇻🇺" + ] + }, + { + "keyword": "waste", + "emoticons": [ + "🗑" + ] + }, + { + "keyword": "anchor", + "emoticons": [ + "⚓" + ] + }, + { + "keyword": "information", + "emoticons": [ + "ℹ" + ] + }, + { + "keyword": "cr", + "emoticons": [ + "🇨🇷" + ] + }, + { + "keyword": "racoon", + "emoticons": [ + "🦡" + ] + }, + { + "keyword": "iphone", + "emoticons": [ + "📱" + ] + }, + { + "keyword": "ge", + "emoticons": [ + "🇬🇪" + ] + }, + { + "keyword": "flag_ca", + "emoticons": [ + "🇨🇦" + ] + }, + { + "keyword": "japanese symbol for beginner", + "emoticons": [ + "🔰" + ] + }, + { + "keyword": "salt", + "emoticons": [ + "🧂" + ] + }, + { + "keyword": "woman playing handball", + "emoticons": [ + "🤾‍♀" + ] + }, + { + "keyword": "tr", + "emoticons": [ + "🇹🇷" + ] + }, + { + "keyword": "hot dog", + "emoticons": [ + "🌭" + ] + }, + { + "keyword": "stuffed flatbread", + "emoticons": [ + "🥙" + ] + }, + { + "keyword": "eye-roll", + "emoticons": [ + "🙄" + ] + }, + { + "keyword": "epson", + "emoticons": [ + "🖨" + ] + }, + { + "keyword": "pn", + "emoticons": [ + "🇵🇳" + ] + }, + { + "keyword": "ghana", + "emoticons": [ + "🇬🇭" + ] + }, + { + "keyword": "ceremony", + "emoticons": [ + "💍" + ] + }, + { + "keyword": "woman getting haircut", + "emoticons": [ + "💇‍♀" + ] + }, + { + "keyword": "flag_ki", + "emoticons": [ + "🇰🇮" + ] + }, + { + "keyword": "gown", + "emoticons": [ + "👗" + ] + }, + { + "keyword": "explosives", + "emoticons": [ + "💣", + "🧨" + ] + }, + { + "keyword": "flag_ph", + "emoticons": [ + "🇵🇭" + ] + }, + { + "keyword": "au", + "emoticons": [ + "🇦🇺" + ] + }, + { + "keyword": "heavy dollar sign", + "emoticons": [ + "💲" + ] + }, + { + "keyword": "clear", + "emoticons": [ + "🆑" + ] + }, + { + "keyword": "income", + "emoticons": [ + "💸" + ] + }, + { + "keyword": "smoking prohibited", + "emoticons": [ + "🚭" + ] + }, + { + "keyword": "1", + "emoticons": [ + "1⃣" + ] + }, + { + "keyword": "next track button", + "emoticons": [ + "⏭" + ] + }, + { + "keyword": "gu", + "emoticons": [ + "🇬🇺" + ] + }, + { + "keyword": "eight o'clock", + "emoticons": [ + "🕗" + ] + }, + { + "keyword": "sneezing", + "emoticons": [ + "🤧" + ] + }, + { + "keyword": "litter in bin sign", + "emoticons": [ + "🚮" + ] + }, + { + "keyword": "shop", + "emoticons": [ + "🏬", + "🏪" + ] + }, + { + "keyword": "fox face", + "emoticons": [ + "🦊" + ] + }, + { + "keyword": "compression", + "emoticons": [ + "🗜" + ] + }, + { + "keyword": "triumph", + "emoticons": [ + "😤" + ] + }, + { + "keyword": "sleepy", + "emoticons": [ + "😪" + ] + }, + { + "keyword": "africa", + "emoticons": [ + "🌍" + ] + }, + { + "keyword": "alarm clock", + "emoticons": [ + "⏰" + ] + }, + { + "keyword": "woman mountain biking", + "emoticons": [ + "🚵‍♀" + ] + }, + { + "keyword": "breathing", + "emoticons": [ + "🫁" + ] + }, + { + "keyword": "bell with slash", + "emoticons": [ + "🔕" + ] + }, + { + "keyword": "sheaf of rice", + "emoticons": [ + "🌾" + ] + }, + { + "keyword": "unamused face", + "emoticons": [ + "😒" + ] + }, + { + "keyword": "wut", + "emoticons": [ + "🤨" + ] + }, + { + "keyword": "insane", + "emoticons": [ + "🤪" + ] + }, + { + "keyword": "draw", + "emoticons": [ + "✍" + ] + }, + { + "keyword": "star struck", + "emoticons": [ + "🤩" + ] + }, + { + "keyword": "th", + "emoticons": [ + "🇹🇭" + ] + }, + { + "keyword": "bouvet island", + "emoticons": [ + "🇳🇴" + ] + }, + { + "keyword": "flag_bm", + "emoticons": [ + "🇧🇲" + ] + }, + { + "keyword": "breast", + "emoticons": [ + "🤱" + ] + }, + { + "keyword": "americas", + "emoticons": [ + "🌎" + ] + }, + { + "keyword": "by", + "emoticons": [ + "🇧🇾" + ] + }, + { + "keyword": "telescope", + "emoticons": [ + "🔭" + ] + }, + { + "keyword": "united nations", + "emoticons": [ + "🇺🇳" + ] + }, + { + "keyword": "plaster", + "emoticons": [ + "🩹" + ] + }, + { + "keyword": "no way", + "emoticons": [ + "😮" + ] + }, + { + "keyword": "no smoking", + "emoticons": [ + "🚭" + ] + }, + { + "keyword": "triangular flag on post", + "emoticons": [ + "🚩" + ] + }, + { + "keyword": "hourglass done", + "emoticons": [ + "⌛" + ] + }, + { + "keyword": "round pushpin", + "emoticons": [ + "📍" + ] + }, + { + "keyword": "alembic", + "emoticons": [ + "⚗" + ] + }, + { + "keyword": "true", + "emoticons": [ + "✅" + ] + }, + { + "keyword": "scots", + "emoticons": [ + "🏴󠁧󠁢󠁳󠁣󠁴󠁿" + ] + }, + { + "keyword": "clock430", + "emoticons": [ + "🕟" + ] + }, + { + "keyword": "qa", + "emoticons": [ + "🇶🇦" + ] + }, + { + "keyword": "muslim", + "emoticons": [ + "🕋" + ] + }, + { + "keyword": "man getting haircut", + "emoticons": [ + "💇‍♂" + ] + }, + { + "keyword": "pancakes", + "emoticons": [ + "🥞" + ] + }, + { + "keyword": "flag_om", + "emoticons": [ + "🇴🇲" + ] + }, + { + "keyword": "flag_gi", + "emoticons": [ + "🇬🇮" + ] + }, + { + "keyword": "man in lotus position", + "emoticons": [ + "🧘‍♂" + ] + }, + { + "keyword": "flag_ws", + "emoticons": [ + "🇼🇸" + ] + }, + { + "keyword": "uh-oh", + "emoticons": [ + "🙈" + ] + }, + { + "keyword": "closed mailbox with raised flag", + "emoticons": [ + "📫" + ] + }, + { + "keyword": "yelling", + "emoticons": [ + "🤬" + ] + }, + { + "keyword": "videocassette", + "emoticons": [ + "📼" + ] + }, + { + "keyword": "madagascar", + "emoticons": [ + "🇲🇬" + ] + }, + { + "keyword": "popcorn", + "emoticons": [ + "🍿" + ] + }, + { + "keyword": "dna", + "emoticons": [ + "🧬" + ] + }, + { + "keyword": "shaved ice", + "emoticons": [ + "🍧" + ] + }, + { + "keyword": "bar chart", + "emoticons": [ + "📊" + ] + }, + { + "keyword": "divaa", + "emoticons": [ + "🪔" + ] + }, + { + "keyword": "=/", + "emoticons": [ + "😕" + ] + }, + { + "keyword": "gee", + "emoticons": [ + "🫢" + ] + }, + { + "keyword": "swan", + "emoticons": [ + "🦢" + ] + }, + { + "keyword": "woman health worker", + "emoticons": [ + "👩‍⚕" + ] + }, + { + "keyword": "denmark", + "emoticons": [ + "🇩🇰" + ] + }, + { + "keyword": "ughh", + "emoticons": [ + "🥴" + ] + }, + { + "keyword": "japanese here button", + "emoticons": [ + "🈁" + ] + }, + { + "keyword": "marshall islands", + "emoticons": [ + "🇲🇭" + ] + }, + { + "keyword": "spoon", + "emoticons": [ + "🥄" + ] + }, + { + "keyword": "red exclamation mark", + "emoticons": [ + "❗" + ] + }, + { + "keyword": "person facepalming", + "emoticons": [ + "🤦‍♂", + "🤦‍♀" + ] + }, + { + "keyword": "beers", + "emoticons": [ + "🍻" + ] + }, + { + "keyword": "comment", + "emoticons": [ + "💬" + ] + }, + { + "keyword": "flag_wf", + "emoticons": [ + "🇼🇫" + ] + }, + { + "keyword": "arrow forward", + "emoticons": [ + "▶" + ] + }, + { + "keyword": "idea", + "emoticons": [ + "💡" + ] + }, + { + "keyword": "paw", + "emoticons": [ + "🐾" + ] + }, + { + "keyword": "locomotive", + "emoticons": [ + "🚂" + ] + }, + { + "keyword": "shot", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "yin yang", + "emoticons": [ + "☯" + ] + }, + { + "keyword": "south africa", + "emoticons": [ + "🇿🇦" + ] + }, + { + "keyword": "mango", + "emoticons": [ + "🥭" + ] + }, + { + "keyword": "flag_sl", + "emoticons": [ + "🇸🇱" + ] + }, + { + "keyword": "fast-forward button", + "emoticons": [ + "⏩" + ] + }, + { + "keyword": "imac", + "emoticons": [ + "🖥" + ] + }, + { + "keyword": "ps", + "emoticons": [ + "🇵🇸" + ] + }, + { + "keyword": "covering mouth", + "emoticons": [ + "🫢" + ] + }, + { + "keyword": "lazy", + "emoticons": [ + "🦥" + ] + }, + { + "keyword": "ir", + "emoticons": [ + "🇮🇷" + ] + }, + { + "keyword": "recycle", + "emoticons": [ + "♻" + ] + }, + { + "keyword": "french bread", + "emoticons": [ + "🥖" + ] + }, + { + "keyword": "flag_tk", + "emoticons": [ + "🇹🇰" + ] + }, + { + "keyword": "family mmb", + "emoticons": [ + "👨‍👨‍👦" + ] + }, + { + "keyword": "a blood", + "emoticons": [ + "🅰" + ] + }, + { + "keyword": "shell", + "emoticons": [ + "🐚" + ] + }, + { + "keyword": "rice scene", + "emoticons": [ + "🎑" + ] + }, + { + "keyword": "mammoth", + "emoticons": [ + "🦣" + ] + }, + { + "keyword": "tumbler", + "emoticons": [ + "🥃" + ] + }, + { + "keyword": "mc", + "emoticons": [ + "🇲🇨" + ] + }, + { + "keyword": "third", + "emoticons": [ + "🥉" + ] + }, + { + "keyword": "shushing", + "emoticons": [ + "🤫" + ] + }, + { + "keyword": "hot pepper", + "emoticons": [ + "🌶" + ] + }, + { + "keyword": "applause", + "emoticons": [ + "👏" + ] + }, + { + "keyword": "cock", + "emoticons": [ + "🐓" + ] + }, + { + "keyword": "second", + "emoticons": [ + "🥈" + ] + }, + { + "keyword": "outdoors tent", + "emoticons": [ + "🏕" + ] + }, + { + "keyword": "td", + "emoticons": [ + "🇹🇩" + ] + }, + { + "keyword": "man mountain biking", + "emoticons": [ + "🚵‍♂" + ] + }, + { + "keyword": "flag_se", + "emoticons": [ + "🇸🇪" + ] + }, + { + "keyword": "door", + "emoticons": [ + "🚪" + ] + }, + { + "keyword": "mother christmas", + "emoticons": [ + "🤶" + ] + }, + { + "keyword": "ls", + "emoticons": [ + "🇱🇸" + ] + }, + { + "keyword": "zimbabwe", + "emoticons": [ + "🇿🇼" + ] + }, + { + "keyword": "canon", + "emoticons": [ + "🖨" + ] + }, + { + "keyword": "diamond", + "emoticons": [ + "💎", + "♦" + ] + }, + { + "keyword": "hotsprings", + "emoticons": [ + "♨" + ] + }, + { + "keyword": "re", + "emoticons": [ + "🇷🇪" + ] + }, + { + "keyword": "sunflower", + "emoticons": [ + "🌻" + ] + }, + { + "keyword": "brofist", + "emoticons": [ + "👊" + ] + }, + { + "keyword": "four", + "emoticons": [ + "4⃣" + ] + }, + { + "keyword": "yellow heart", + "emoticons": [ + "💛" + ] + }, + { + "keyword": "hug gesture", + "emoticons": [ + "🫂" + ] + }, + { + "keyword": "falkland islands", + "emoticons": [ + "🇫🇰" + ] + }, + { + "keyword": "automobile", + "emoticons": [ + "🚘", + "🚗" + ] + }, + { + "keyword": "sunrise over mountains", + "emoticons": [ + "🌄" + ] + }, + { + "keyword": "abacus", + "emoticons": [ + "🧮" + ] + }, + { + "keyword": "locked", + "emoticons": [ + "🔒" + ] + }, + { + "keyword": "email", + "emoticons": [ + "📧" + ] + }, + { + "keyword": "taco", + "emoticons": [ + "🌮" + ] + }, + { + "keyword": "pirate", + "emoticons": [ + "🏴‍☠" + ] + }, + { + "keyword": "fight", + "emoticons": [ + "👊" + ] + }, + { + "keyword": "karate uniform", + "emoticons": [ + "🥋" + ] + }, + { + "keyword": "parrot", + "emoticons": [ + "🦜" + ] + }, + { + "keyword": "flag_er", + "emoticons": [ + "🇪🇷" + ] + }, + { + "keyword": "ping pong", + "emoticons": [ + "🏓" + ] + }, + { + "keyword": "rolled-up newspape", + "emoticons": [ + "🗞" + ] + }, + { + "keyword": "dream", + "emoticons": [ + "😌" + ] + }, + { + "keyword": "steaming bowl", + "emoticons": [ + "🍜" + ] + }, + { + "keyword": "lama", + "emoticons": [ + "🦙" + ] + }, + { + "keyword": "aerial tramway", + "emoticons": [ + "🚡" + ] + }, + { + "keyword": "tear-off calendar", + "emoticons": [ + "📆" + ] + }, + { + "keyword": "reserved", + "emoticons": [ + "🈯" + ] + }, + { + "keyword": "yemen", + "emoticons": [ + "🇾🇪" + ] + }, + { + "keyword": "right-facing fist", + "emoticons": [ + "🤜" + ] + }, + { + "keyword": "flag_sc", + "emoticons": [ + "🇸🇨" + ] + }, + { + "keyword": "woman construction worker", + "emoticons": [ + "👷‍♀" + ] + }, + { + "keyword": "amazed", + "emoticons": [ + "🤭", + "🤩" + ] + }, + { + "keyword": "raised fist", + "emoticons": [ + "✊" + ] + }, + { + "keyword": "shushing face", + "emoticons": [ + "🤫" + ] + }, + { + "keyword": "musical score", + "emoticons": [ + "🎼" + ] + }, + { + "keyword": "rs", + "emoticons": [ + "🇷🇸" + ] + }, + { + "keyword": "shallow pan of food", + "emoticons": [ + "🥘" + ] + }, + { + "keyword": "running shirt with sash", + "emoticons": [ + "🎽" + ] + }, + { + "keyword": "villager", + "emoticons": [ + "🧑‍🌾" + ] + }, + { + "keyword": "mindblowing", + "emoticons": [ + "🤯" + ] + }, + { + "keyword": "temple", + "emoticons": [ + "🛕" + ] + }, + { + "keyword": "finland", + "emoticons": [ + "🇫🇮" + ] + }, + { + "keyword": "vu", + "emoticons": [ + "🇻🇺" + ] + }, + { + "keyword": "panda", + "emoticons": [ + "🐼" + ] + }, + { + "keyword": "snowboard", + "emoticons": [ + "🏂" + ] + }, + { + "keyword": "minibus", + "emoticons": [ + "🚐" + ] + }, + { + "keyword": "watermelon", + "emoticons": [ + "🍉" + ] + }, + { + "keyword": "input symbols", + "emoticons": [ + "🔣" + ] + }, + { + "keyword": "flag_ug", + "emoticons": [ + "🇺🇬" + ] + }, + { + "keyword": "o_o", + "emoticons": [ + "😮" + ] + }, + { + "keyword": "siren", + "emoticons": [ + "🚨" + ] + }, + { + "keyword": "arrow right", + "emoticons": [ + "➡" + ] + }, + { + "keyword": "bounce", + "emoticons": [ + "⛹" + ] + }, + { + "keyword": "apricot", + "emoticons": [ + "🍑" + ] + }, + { + "keyword": "flag_vc", + "emoticons": [ + "🇻🇨" + ] + }, + { + "keyword": "clef", + "emoticons": [ + "🎼" + ] + }, + { + "keyword": "seven-thirty", + "emoticons": [ + "🕢" + ] + }, + { + "keyword": "cruise ship", + "emoticons": [ + "🛳" + ] + }, + { + "keyword": "wilted", + "emoticons": [ + "🥀" + ] + }, + { + "keyword": "woman in lotus position", + "emoticons": [ + "🧘‍♀" + ] + }, + { + "keyword": "green salad", + "emoticons": [ + "🥗" + ] + }, + { + "keyword": "slovenia", + "emoticons": [ + "🇸🇮" + ] + }, + { + "keyword": "heat", + "emoticons": [ + "♨" + ] + }, + { + "keyword": "internet", + "emoticons": [ + "🌐" + ] + }, + { + "keyword": "scrying", + "emoticons": [ + "🔮" + ] + }, + { + "keyword": "sports medal", + "emoticons": [ + "🏅" + ] + }, + { + "keyword": "pe", + "emoticons": [ + "🇵🇪" + ] + }, + { + "keyword": "boar", + "emoticons": [ + "🐗" + ] + }, + { + "keyword": "flag_cp", + "emoticons": [ + "🇫🇷" + ] + }, + { + "keyword": "thong sandal", + "emoticons": [ + "🩴" + ] + }, + { + "keyword": "woof", + "emoticons": [ + "🐶" + ] + }, + { + "keyword": "ballot box with check", + "emoticons": [ + "☑" + ] + }, + { + "keyword": "wtff", + "emoticons": [ + "🤬" + ] + }, + { + "keyword": "jack-o-lantern", + "emoticons": [ + "🎃" + ] + }, + { + "keyword": "velociraptor", + "emoticons": [ + "🦖" + ] + }, + { + "keyword": "nr", + "emoticons": [ + "🇳🇷" + ] + }, + { + "keyword": "pout", + "emoticons": [ + "🙎" + ] + }, + { + "keyword": "oke", + "emoticons": [ + "😉" + ] + }, + { + "keyword": "lotion", + "emoticons": [ + "🧴" + ] + }, + { + "keyword": "eu", + "emoticons": [ + "🇪🇺" + ] + }, + { + "keyword": "man dancing", + "emoticons": [ + "🕺" + ] + }, + { + "keyword": "target", + "emoticons": [ + "🎯" + ] + }, + { + "keyword": "lab coat", + "emoticons": [ + "🥼" + ] + }, + { + "keyword": "woman swimming", + "emoticons": [ + "🏊‍♀" + ] + }, + { + "keyword": "coronavirus", + "emoticons": [ + "🦠" + ] + }, + { + "keyword": "gloves", + "emoticons": [ + "🧤" + ] + }, + { + "keyword": "heart decoration", + "emoticons": [ + "💟" + ] + }, + { + "keyword": "aquarius", + "emoticons": [ + "♒" + ] + }, + { + "keyword": "lungs", + "emoticons": [ + "🫁" + ] + }, + { + "keyword": "flag_gq", + "emoticons": [ + "🇬🇶" + ] + }, + { + "keyword": "memo", + "emoticons": [ + "📝" + ] + } + ] +} diff --git a/internal/seed/catalog/timezones.json b/internal/seed/catalog/timezones.json new file mode 100644 index 00000000..5a2d9f9a --- /dev/null +++ b/internal/seed/catalog/timezones.json @@ -0,0 +1,2099 @@ +{ + "timezones": [ + { + "id": "Africa/Abidjan", + "name": "Africa/Abidjan", + "utc_offset": 0 + }, + { + "id": "Africa/Accra", + "name": "Africa/Accra", + "utc_offset": 0 + }, + { + "id": "Africa/Addis_Ababa", + "name": "Africa/Addis Ababa", + "utc_offset": 10800 + }, + { + "id": "Africa/Algiers", + "name": "Africa/Algiers", + "utc_offset": 3600 + }, + { + "id": "Africa/Asmara", + "name": "Africa/Asmara", + "utc_offset": 10800 + }, + { + "id": "Africa/Bamako", + "name": "Africa/Bamako", + "utc_offset": 0 + }, + { + "id": "Africa/Bangui", + "name": "Africa/Bangui", + "utc_offset": 3600 + }, + { + "id": "Africa/Banjul", + "name": "Africa/Banjul", + "utc_offset": 0 + }, + { + "id": "Africa/Bissau", + "name": "Africa/Bissau", + "utc_offset": 0 + }, + { + "id": "Africa/Blantyre", + "name": "Africa/Blantyre", + "utc_offset": 7200 + }, + { + "id": "Africa/Brazzaville", + "name": "Africa/Brazzaville", + "utc_offset": 3600 + }, + { + "id": "Africa/Bujumbura", + "name": "Africa/Bujumbura", + "utc_offset": 7200 + }, + { + "id": "Africa/Cairo", + "name": "Africa/Cairo", + "utc_offset": 10800 + }, + { + "id": "Africa/Casablanca", + "name": "Africa/Casablanca", + "utc_offset": 3600 + }, + { + "id": "Africa/Ceuta", + "name": "Africa/Ceuta", + "utc_offset": 7200 + }, + { + "id": "Africa/Conakry", + "name": "Africa/Conakry", + "utc_offset": 0 + }, + { + "id": "Africa/Dakar", + "name": "Africa/Dakar", + "utc_offset": 0 + }, + { + "id": "Africa/Dar_es_Salaam", + "name": "Africa/Dar es Salaam", + "utc_offset": 10800 + }, + { + "id": "Africa/Djibouti", + "name": "Africa/Djibouti", + "utc_offset": 10800 + }, + { + "id": "Africa/Douala", + "name": "Africa/Douala", + "utc_offset": 3600 + }, + { + "id": "Africa/El_Aaiun", + "name": "Africa/El Aaiun", + "utc_offset": 3600 + }, + { + "id": "Africa/Freetown", + "name": "Africa/Freetown", + "utc_offset": 0 + }, + { + "id": "Africa/Gaborone", + "name": "Africa/Gaborone", + "utc_offset": 7200 + }, + { + "id": "Africa/Harare", + "name": "Africa/Harare", + "utc_offset": 7200 + }, + { + "id": "Africa/Johannesburg", + "name": "Africa/Johannesburg", + "utc_offset": 7200 + }, + { + "id": "Africa/Juba", + "name": "Africa/Juba", + "utc_offset": 7200 + }, + { + "id": "Africa/Kampala", + "name": "Africa/Kampala", + "utc_offset": 10800 + }, + { + "id": "Africa/Khartoum", + "name": "Africa/Khartoum", + "utc_offset": 7200 + }, + { + "id": "Africa/Kigali", + "name": "Africa/Kigali", + "utc_offset": 7200 + }, + { + "id": "Africa/Kinshasa", + "name": "Africa/Kinshasa", + "utc_offset": 3600 + }, + { + "id": "Africa/Lagos", + "name": "Africa/Lagos", + "utc_offset": 3600 + }, + { + "id": "Africa/Libreville", + "name": "Africa/Libreville", + "utc_offset": 3600 + }, + { + "id": "Africa/Lome", + "name": "Africa/Lome", + "utc_offset": 0 + }, + { + "id": "Africa/Luanda", + "name": "Africa/Luanda", + "utc_offset": 3600 + }, + { + "id": "Africa/Lubumbashi", + "name": "Africa/Lubumbashi", + "utc_offset": 7200 + }, + { + "id": "Africa/Lusaka", + "name": "Africa/Lusaka", + "utc_offset": 7200 + }, + { + "id": "Africa/Malabo", + "name": "Africa/Malabo", + "utc_offset": 3600 + }, + { + "id": "Africa/Maputo", + "name": "Africa/Maputo", + "utc_offset": 7200 + }, + { + "id": "Africa/Maseru", + "name": "Africa/Maseru", + "utc_offset": 7200 + }, + { + "id": "Africa/Mbabane", + "name": "Africa/Mbabane", + "utc_offset": 7200 + }, + { + "id": "Africa/Mogadishu", + "name": "Africa/Mogadishu", + "utc_offset": 10800 + }, + { + "id": "Africa/Monrovia", + "name": "Africa/Monrovia", + "utc_offset": 0 + }, + { + "id": "Africa/Nairobi", + "name": "Africa/Nairobi", + "utc_offset": 10800 + }, + { + "id": "Africa/Ndjamena", + "name": "Africa/Ndjamena", + "utc_offset": 3600 + }, + { + "id": "Africa/Niamey", + "name": "Africa/Niamey", + "utc_offset": 3600 + }, + { + "id": "Africa/Nouakchott", + "name": "Africa/Nouakchott", + "utc_offset": 0 + }, + { + "id": "Africa/Ouagadougou", + "name": "Africa/Ouagadougou", + "utc_offset": 0 + }, + { + "id": "Africa/Porto-Novo", + "name": "Africa/Porto-Novo", + "utc_offset": 3600 + }, + { + "id": "Africa/Sao_Tome", + "name": "Africa/Sao Tome", + "utc_offset": 0 + }, + { + "id": "Africa/Tripoli", + "name": "Africa/Tripoli", + "utc_offset": 7200 + }, + { + "id": "Africa/Tunis", + "name": "Africa/Tunis", + "utc_offset": 3600 + }, + { + "id": "Africa/Windhoek", + "name": "Africa/Windhoek", + "utc_offset": 7200 + }, + { + "id": "America/Adak", + "name": "America/Adak", + "utc_offset": -32400 + }, + { + "id": "America/Anchorage", + "name": "America/Anchorage", + "utc_offset": -28800 + }, + { + "id": "America/Anguilla", + "name": "America/Anguilla", + "utc_offset": -14400 + }, + { + "id": "America/Antigua", + "name": "America/Antigua", + "utc_offset": -14400 + }, + { + "id": "America/Araguaina", + "name": "America/Araguaina", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/Buenos_Aires", + "name": "America/Argentina/Buenos Aires", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/Catamarca", + "name": "America/Argentina/Catamarca", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/Cordoba", + "name": "America/Argentina/Cordoba", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/Jujuy", + "name": "America/Argentina/Jujuy", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/La_Rioja", + "name": "America/Argentina/La Rioja", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/Mendoza", + "name": "America/Argentina/Mendoza", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/Rio_Gallegos", + "name": "America/Argentina/Rio Gallegos", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/Salta", + "name": "America/Argentina/Salta", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/San_Juan", + "name": "America/Argentina/San Juan", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/San_Luis", + "name": "America/Argentina/San Luis", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/Tucuman", + "name": "America/Argentina/Tucuman", + "utc_offset": -10800 + }, + { + "id": "America/Argentina/Ushuaia", + "name": "America/Argentina/Ushuaia", + "utc_offset": -10800 + }, + { + "id": "America/Aruba", + "name": "America/Aruba", + "utc_offset": -14400 + }, + { + "id": "America/Asuncion", + "name": "America/Asuncion", + "utc_offset": -10800 + }, + { + "id": "America/Atikokan", + "name": "America/Atikokan", + "utc_offset": -18000 + }, + { + "id": "America/Bahia", + "name": "America/Bahia", + "utc_offset": -10800 + }, + { + "id": "America/Bahia_Banderas", + "name": "America/Bahia Banderas", + "utc_offset": -21600 + }, + { + "id": "America/Barbados", + "name": "America/Barbados", + "utc_offset": -14400 + }, + { + "id": "America/Belem", + "name": "America/Belem", + "utc_offset": -10800 + }, + { + "id": "America/Belize", + "name": "America/Belize", + "utc_offset": -21600 + }, + { + "id": "America/Blanc-Sablon", + "name": "America/Blanc-Sablon", + "utc_offset": -14400 + }, + { + "id": "America/Boa_Vista", + "name": "America/Boa Vista", + "utc_offset": -14400 + }, + { + "id": "America/Bogota", + "name": "America/Bogota", + "utc_offset": -18000 + }, + { + "id": "America/Boise", + "name": "America/Boise", + "utc_offset": -21600 + }, + { + "id": "America/Cambridge_Bay", + "name": "America/Cambridge Bay", + "utc_offset": -21600 + }, + { + "id": "America/Campo_Grande", + "name": "America/Campo Grande", + "utc_offset": -14400 + }, + { + "id": "America/Cancun", + "name": "America/Cancun", + "utc_offset": -18000 + }, + { + "id": "America/Caracas", + "name": "America/Caracas", + "utc_offset": -14400 + }, + { + "id": "America/Cayenne", + "name": "America/Cayenne", + "utc_offset": -10800 + }, + { + "id": "America/Cayman", + "name": "America/Cayman", + "utc_offset": -18000 + }, + { + "id": "America/Chicago", + "name": "America/Chicago", + "utc_offset": -18000 + }, + { + "id": "America/Chihuahua", + "name": "America/Chihuahua", + "utc_offset": -21600 + }, + { + "id": "America/Ciudad_Juarez", + "name": "America/Ciudad Juarez", + "utc_offset": -21600 + }, + { + "id": "America/Costa_Rica", + "name": "America/Costa Rica", + "utc_offset": -21600 + }, + { + "id": "America/Coyhaique", + "name": "America/Coyhaique", + "utc_offset": -10800 + }, + { + "id": "America/Creston", + "name": "America/Creston", + "utc_offset": -25200 + }, + { + "id": "America/Cuiaba", + "name": "America/Cuiaba", + "utc_offset": -14400 + }, + { + "id": "America/Curacao", + "name": "America/Curacao", + "utc_offset": -14400 + }, + { + "id": "America/Danmarkshavn", + "name": "America/Danmarkshavn", + "utc_offset": 0 + }, + { + "id": "America/Dawson", + "name": "America/Dawson", + "utc_offset": -25200 + }, + { + "id": "America/Dawson_Creek", + "name": "America/Dawson Creek", + "utc_offset": -25200 + }, + { + "id": "America/Denver", + "name": "America/Denver", + "utc_offset": -21600 + }, + { + "id": "America/Detroit", + "name": "America/Detroit", + "utc_offset": -14400 + }, + { + "id": "America/Dominica", + "name": "America/Dominica", + "utc_offset": -14400 + }, + { + "id": "America/Edmonton", + "name": "America/Edmonton", + "utc_offset": -21600 + }, + { + "id": "America/Eirunepe", + "name": "America/Eirunepe", + "utc_offset": -18000 + }, + { + "id": "America/El_Salvador", + "name": "America/El Salvador", + "utc_offset": -21600 + }, + { + "id": "America/Fort_Nelson", + "name": "America/Fort Nelson", + "utc_offset": -25200 + }, + { + "id": "America/Fortaleza", + "name": "America/Fortaleza", + "utc_offset": -10800 + }, + { + "id": "America/Glace_Bay", + "name": "America/Glace Bay", + "utc_offset": -10800 + }, + { + "id": "America/Goose_Bay", + "name": "America/Goose Bay", + "utc_offset": -10800 + }, + { + "id": "America/Grand_Turk", + "name": "America/Grand Turk", + "utc_offset": -14400 + }, + { + "id": "America/Grenada", + "name": "America/Grenada", + "utc_offset": -14400 + }, + { + "id": "America/Guadeloupe", + "name": "America/Guadeloupe", + "utc_offset": -14400 + }, + { + "id": "America/Guatemala", + "name": "America/Guatemala", + "utc_offset": -21600 + }, + { + "id": "America/Guayaquil", + "name": "America/Guayaquil", + "utc_offset": -18000 + }, + { + "id": "America/Guyana", + "name": "America/Guyana", + "utc_offset": -14400 + }, + { + "id": "America/Halifax", + "name": "America/Halifax", + "utc_offset": -10800 + }, + { + "id": "America/Havana", + "name": "America/Havana", + "utc_offset": -14400 + }, + { + "id": "America/Hermosillo", + "name": "America/Hermosillo", + "utc_offset": -25200 + }, + { + "id": "America/Indiana/Indianapolis", + "name": "America/Indiana/Indianapolis", + "utc_offset": -14400 + }, + { + "id": "America/Indiana/Knox", + "name": "America/Indiana/Knox", + "utc_offset": -18000 + }, + { + "id": "America/Indiana/Marengo", + "name": "America/Indiana/Marengo", + "utc_offset": -14400 + }, + { + "id": "America/Indiana/Petersburg", + "name": "America/Indiana/Petersburg", + "utc_offset": -14400 + }, + { + "id": "America/Indiana/Tell_City", + "name": "America/Indiana/Tell City", + "utc_offset": -18000 + }, + { + "id": "America/Indiana/Vevay", + "name": "America/Indiana/Vevay", + "utc_offset": -14400 + }, + { + "id": "America/Indiana/Vincennes", + "name": "America/Indiana/Vincennes", + "utc_offset": -14400 + }, + { + "id": "America/Indiana/Winamac", + "name": "America/Indiana/Winamac", + "utc_offset": -14400 + }, + { + "id": "America/Inuvik", + "name": "America/Inuvik", + "utc_offset": -21600 + }, + { + "id": "America/Iqaluit", + "name": "America/Iqaluit", + "utc_offset": -14400 + }, + { + "id": "America/Jamaica", + "name": "America/Jamaica", + "utc_offset": -18000 + }, + { + "id": "America/Juneau", + "name": "America/Juneau", + "utc_offset": -28800 + }, + { + "id": "America/Kentucky/Louisville", + "name": "America/Kentucky/Louisville", + "utc_offset": -14400 + }, + { + "id": "America/Kentucky/Monticello", + "name": "America/Kentucky/Monticello", + "utc_offset": -14400 + }, + { + "id": "America/Kralendijk", + "name": "America/Kralendijk", + "utc_offset": -14400 + }, + { + "id": "America/La_Paz", + "name": "America/La Paz", + "utc_offset": -14400 + }, + { + "id": "America/Lima", + "name": "America/Lima", + "utc_offset": -18000 + }, + { + "id": "America/Los_Angeles", + "name": "America/Los Angeles", + "utc_offset": -25200 + }, + { + "id": "America/Lower_Princes", + "name": "America/Lower Princes", + "utc_offset": -14400 + }, + { + "id": "America/Maceio", + "name": "America/Maceio", + "utc_offset": -10800 + }, + { + "id": "America/Managua", + "name": "America/Managua", + "utc_offset": -21600 + }, + { + "id": "America/Manaus", + "name": "America/Manaus", + "utc_offset": -14400 + }, + { + "id": "America/Marigot", + "name": "America/Marigot", + "utc_offset": -14400 + }, + { + "id": "America/Martinique", + "name": "America/Martinique", + "utc_offset": -14400 + }, + { + "id": "America/Matamoros", + "name": "America/Matamoros", + "utc_offset": -18000 + }, + { + "id": "America/Mazatlan", + "name": "America/Mazatlan", + "utc_offset": -25200 + }, + { + "id": "America/Menominee", + "name": "America/Menominee", + "utc_offset": -18000 + }, + { + "id": "America/Merida", + "name": "America/Merida", + "utc_offset": -21600 + }, + { + "id": "America/Metlakatla", + "name": "America/Metlakatla", + "utc_offset": -28800 + }, + { + "id": "America/Mexico_City", + "name": "America/Mexico City", + "utc_offset": -21600 + }, + { + "id": "America/Miquelon", + "name": "America/Miquelon", + "utc_offset": -7200 + }, + { + "id": "America/Moncton", + "name": "America/Moncton", + "utc_offset": -10800 + }, + { + "id": "America/Monterrey", + "name": "America/Monterrey", + "utc_offset": -21600 + }, + { + "id": "America/Montevideo", + "name": "America/Montevideo", + "utc_offset": -10800 + }, + { + "id": "America/Montserrat", + "name": "America/Montserrat", + "utc_offset": -14400 + }, + { + "id": "America/Nassau", + "name": "America/Nassau", + "utc_offset": -14400 + }, + { + "id": "America/New_York", + "name": "America/New York", + "utc_offset": -14400 + }, + { + "id": "America/Nome", + "name": "America/Nome", + "utc_offset": -28800 + }, + { + "id": "America/Noronha", + "name": "America/Noronha", + "utc_offset": -7200 + }, + { + "id": "America/North_Dakota/Beulah", + "name": "America/North Dakota/Beulah", + "utc_offset": -18000 + }, + { + "id": "America/North_Dakota/Center", + "name": "America/North Dakota/Center", + "utc_offset": -18000 + }, + { + "id": "America/North_Dakota/New_Salem", + "name": "America/North Dakota/New Salem", + "utc_offset": -18000 + }, + { + "id": "America/Nuuk", + "name": "America/Nuuk", + "utc_offset": -3600 + }, + { + "id": "America/Ojinaga", + "name": "America/Ojinaga", + "utc_offset": -18000 + }, + { + "id": "America/Panama", + "name": "America/Panama", + "utc_offset": -18000 + }, + { + "id": "America/Paramaribo", + "name": "America/Paramaribo", + "utc_offset": -10800 + }, + { + "id": "America/Phoenix", + "name": "America/Phoenix", + "utc_offset": -25200 + }, + { + "id": "America/Port-au-Prince", + "name": "America/Port-au-Prince", + "utc_offset": -14400 + }, + { + "id": "America/Port_of_Spain", + "name": "America/Port of Spain", + "utc_offset": -14400 + }, + { + "id": "America/Porto_Velho", + "name": "America/Porto Velho", + "utc_offset": -14400 + }, + { + "id": "America/Puerto_Rico", + "name": "America/Puerto Rico", + "utc_offset": -14400 + }, + { + "id": "America/Punta_Arenas", + "name": "America/Punta Arenas", + "utc_offset": -10800 + }, + { + "id": "America/Rankin_Inlet", + "name": "America/Rankin Inlet", + "utc_offset": -18000 + }, + { + "id": "America/Recife", + "name": "America/Recife", + "utc_offset": -10800 + }, + { + "id": "America/Regina", + "name": "America/Regina", + "utc_offset": -21600 + }, + { + "id": "America/Resolute", + "name": "America/Resolute", + "utc_offset": -18000 + }, + { + "id": "America/Rio_Branco", + "name": "America/Rio Branco", + "utc_offset": -18000 + }, + { + "id": "America/Santarem", + "name": "America/Santarem", + "utc_offset": -10800 + }, + { + "id": "America/Santiago", + "name": "America/Santiago", + "utc_offset": -14400 + }, + { + "id": "America/Santo_Domingo", + "name": "America/Santo Domingo", + "utc_offset": -14400 + }, + { + "id": "America/Sao_Paulo", + "name": "America/Sao Paulo", + "utc_offset": -10800 + }, + { + "id": "America/Scoresbysund", + "name": "America/Scoresbysund", + "utc_offset": -3600 + }, + { + "id": "America/Sitka", + "name": "America/Sitka", + "utc_offset": -28800 + }, + { + "id": "America/St_Barthelemy", + "name": "America/St Barthelemy", + "utc_offset": -14400 + }, + { + "id": "America/St_Johns", + "name": "America/St Johns", + "utc_offset": -9000 + }, + { + "id": "America/St_Kitts", + "name": "America/St Kitts", + "utc_offset": -14400 + }, + { + "id": "America/St_Lucia", + "name": "America/St Lucia", + "utc_offset": -14400 + }, + { + "id": "America/St_Thomas", + "name": "America/St Thomas", + "utc_offset": -14400 + }, + { + "id": "America/St_Vincent", + "name": "America/St Vincent", + "utc_offset": -14400 + }, + { + "id": "America/Swift_Current", + "name": "America/Swift Current", + "utc_offset": -21600 + }, + { + "id": "America/Tegucigalpa", + "name": "America/Tegucigalpa", + "utc_offset": -21600 + }, + { + "id": "America/Thule", + "name": "America/Thule", + "utc_offset": -10800 + }, + { + "id": "America/Tijuana", + "name": "America/Tijuana", + "utc_offset": -25200 + }, + { + "id": "America/Toronto", + "name": "America/Toronto", + "utc_offset": -14400 + }, + { + "id": "America/Tortola", + "name": "America/Tortola", + "utc_offset": -14400 + }, + { + "id": "America/Vancouver", + "name": "America/Vancouver", + "utc_offset": -25200 + }, + { + "id": "America/Whitehorse", + "name": "America/Whitehorse", + "utc_offset": -25200 + }, + { + "id": "America/Winnipeg", + "name": "America/Winnipeg", + "utc_offset": -18000 + }, + { + "id": "America/Yakutat", + "name": "America/Yakutat", + "utc_offset": -28800 + }, + { + "id": "Antarctica/Casey", + "name": "Antarctica/Casey", + "utc_offset": 28800 + }, + { + "id": "Antarctica/Davis", + "name": "Antarctica/Davis", + "utc_offset": 25200 + }, + { + "id": "Antarctica/DumontDUrville", + "name": "Antarctica/DumontDUrville", + "utc_offset": 36000 + }, + { + "id": "Antarctica/Macquarie", + "name": "Antarctica/Macquarie", + "utc_offset": 36000 + }, + { + "id": "Antarctica/Mawson", + "name": "Antarctica/Mawson", + "utc_offset": 18000 + }, + { + "id": "Antarctica/McMurdo", + "name": "Antarctica/McMurdo", + "utc_offset": 43200 + }, + { + "id": "Antarctica/Palmer", + "name": "Antarctica/Palmer", + "utc_offset": -10800 + }, + { + "id": "Antarctica/Rothera", + "name": "Antarctica/Rothera", + "utc_offset": -10800 + }, + { + "id": "Antarctica/Syowa", + "name": "Antarctica/Syowa", + "utc_offset": 10800 + }, + { + "id": "Antarctica/Troll", + "name": "Antarctica/Troll", + "utc_offset": 7200 + }, + { + "id": "Antarctica/Vostok", + "name": "Antarctica/Vostok", + "utc_offset": 18000 + }, + { + "id": "Arctic/Longyearbyen", + "name": "Arctic/Longyearbyen", + "utc_offset": 7200 + }, + { + "id": "Asia/Aden", + "name": "Asia/Aden", + "utc_offset": 10800 + }, + { + "id": "Asia/Almaty", + "name": "Asia/Almaty", + "utc_offset": 18000 + }, + { + "id": "Asia/Amman", + "name": "Asia/Amman", + "utc_offset": 10800 + }, + { + "id": "Asia/Anadyr", + "name": "Asia/Anadyr", + "utc_offset": 43200 + }, + { + "id": "Asia/Aqtau", + "name": "Asia/Aqtau", + "utc_offset": 18000 + }, + { + "id": "Asia/Aqtobe", + "name": "Asia/Aqtobe", + "utc_offset": 18000 + }, + { + "id": "Asia/Ashgabat", + "name": "Asia/Ashgabat", + "utc_offset": 18000 + }, + { + "id": "Asia/Atyrau", + "name": "Asia/Atyrau", + "utc_offset": 18000 + }, + { + "id": "Asia/Baghdad", + "name": "Asia/Baghdad", + "utc_offset": 10800 + }, + { + "id": "Asia/Bahrain", + "name": "Asia/Bahrain", + "utc_offset": 10800 + }, + { + "id": "Asia/Baku", + "name": "Asia/Baku", + "utc_offset": 14400 + }, + { + "id": "Asia/Bangkok", + "name": "Asia/Bangkok", + "utc_offset": 25200 + }, + { + "id": "Asia/Barnaul", + "name": "Asia/Barnaul", + "utc_offset": 25200 + }, + { + "id": "Asia/Beirut", + "name": "Asia/Beirut", + "utc_offset": 10800 + }, + { + "id": "Asia/Bishkek", + "name": "Asia/Bishkek", + "utc_offset": 21600 + }, + { + "id": "Asia/Brunei", + "name": "Asia/Brunei", + "utc_offset": 28800 + }, + { + "id": "Asia/Chita", + "name": "Asia/Chita", + "utc_offset": 32400 + }, + { + "id": "Asia/Colombo", + "name": "Asia/Colombo", + "utc_offset": 19800 + }, + { + "id": "Asia/Damascus", + "name": "Asia/Damascus", + "utc_offset": 10800 + }, + { + "id": "Asia/Dhaka", + "name": "Asia/Dhaka", + "utc_offset": 21600 + }, + { + "id": "Asia/Dili", + "name": "Asia/Dili", + "utc_offset": 32400 + }, + { + "id": "Asia/Dubai", + "name": "Asia/Dubai", + "utc_offset": 14400 + }, + { + "id": "Asia/Dushanbe", + "name": "Asia/Dushanbe", + "utc_offset": 18000 + }, + { + "id": "Asia/Famagusta", + "name": "Asia/Famagusta", + "utc_offset": 10800 + }, + { + "id": "Asia/Gaza", + "name": "Asia/Gaza", + "utc_offset": 10800 + }, + { + "id": "Asia/Hebron", + "name": "Asia/Hebron", + "utc_offset": 10800 + }, + { + "id": "Asia/Ho_Chi_Minh", + "name": "Asia/Ho Chi Minh", + "utc_offset": 25200 + }, + { + "id": "Asia/Hong_Kong", + "name": "Asia/Hong Kong", + "utc_offset": 28800 + }, + { + "id": "Asia/Hovd", + "name": "Asia/Hovd", + "utc_offset": 25200 + }, + { + "id": "Asia/Irkutsk", + "name": "Asia/Irkutsk", + "utc_offset": 28800 + }, + { + "id": "Asia/Jakarta", + "name": "Asia/Jakarta", + "utc_offset": 25200 + }, + { + "id": "Asia/Jayapura", + "name": "Asia/Jayapura", + "utc_offset": 32400 + }, + { + "id": "Asia/Jerusalem", + "name": "Asia/Jerusalem", + "utc_offset": 10800 + }, + { + "id": "Asia/Kabul", + "name": "Asia/Kabul", + "utc_offset": 16200 + }, + { + "id": "Asia/Kamchatka", + "name": "Asia/Kamchatka", + "utc_offset": 43200 + }, + { + "id": "Asia/Karachi", + "name": "Asia/Karachi", + "utc_offset": 18000 + }, + { + "id": "Asia/Kathmandu", + "name": "Asia/Kathmandu", + "utc_offset": 20700 + }, + { + "id": "Asia/Khandyga", + "name": "Asia/Khandyga", + "utc_offset": 32400 + }, + { + "id": "Asia/Kolkata", + "name": "Asia/Kolkata", + "utc_offset": 19800 + }, + { + "id": "Asia/Krasnoyarsk", + "name": "Asia/Krasnoyarsk", + "utc_offset": 25200 + }, + { + "id": "Asia/Kuala_Lumpur", + "name": "Asia/Kuala Lumpur", + "utc_offset": 28800 + }, + { + "id": "Asia/Kuching", + "name": "Asia/Kuching", + "utc_offset": 28800 + }, + { + "id": "Asia/Kuwait", + "name": "Asia/Kuwait", + "utc_offset": 10800 + }, + { + "id": "Asia/Macau", + "name": "Asia/Macau", + "utc_offset": 28800 + }, + { + "id": "Asia/Magadan", + "name": "Asia/Magadan", + "utc_offset": 39600 + }, + { + "id": "Asia/Makassar", + "name": "Asia/Makassar", + "utc_offset": 28800 + }, + { + "id": "Asia/Manila", + "name": "Asia/Manila", + "utc_offset": 28800 + }, + { + "id": "Asia/Muscat", + "name": "Asia/Muscat", + "utc_offset": 14400 + }, + { + "id": "Asia/Nicosia", + "name": "Asia/Nicosia", + "utc_offset": 10800 + }, + { + "id": "Asia/Novokuznetsk", + "name": "Asia/Novokuznetsk", + "utc_offset": 25200 + }, + { + "id": "Asia/Novosibirsk", + "name": "Asia/Novosibirsk", + "utc_offset": 25200 + }, + { + "id": "Asia/Omsk", + "name": "Asia/Omsk", + "utc_offset": 21600 + }, + { + "id": "Asia/Oral", + "name": "Asia/Oral", + "utc_offset": 18000 + }, + { + "id": "Asia/Phnom_Penh", + "name": "Asia/Phnom Penh", + "utc_offset": 25200 + }, + { + "id": "Asia/Pontianak", + "name": "Asia/Pontianak", + "utc_offset": 25200 + }, + { + "id": "Asia/Pyongyang", + "name": "Asia/Pyongyang", + "utc_offset": 32400 + }, + { + "id": "Asia/Qatar", + "name": "Asia/Qatar", + "utc_offset": 10800 + }, + { + "id": "Asia/Qostanay", + "name": "Asia/Qostanay", + "utc_offset": 18000 + }, + { + "id": "Asia/Qyzylorda", + "name": "Asia/Qyzylorda", + "utc_offset": 18000 + }, + { + "id": "Asia/Riyadh", + "name": "Asia/Riyadh", + "utc_offset": 10800 + }, + { + "id": "Asia/Sakhalin", + "name": "Asia/Sakhalin", + "utc_offset": 39600 + }, + { + "id": "Asia/Samarkand", + "name": "Asia/Samarkand", + "utc_offset": 18000 + }, + { + "id": "Asia/Seoul", + "name": "Asia/Seoul", + "utc_offset": 32400 + }, + { + "id": "Asia/Shanghai", + "name": "Asia/Shanghai", + "utc_offset": 28800 + }, + { + "id": "Asia/Singapore", + "name": "Asia/Singapore", + "utc_offset": 28800 + }, + { + "id": "Asia/Srednekolymsk", + "name": "Asia/Srednekolymsk", + "utc_offset": 39600 + }, + { + "id": "Asia/Taipei", + "name": "Asia/Taipei", + "utc_offset": 28800 + }, + { + "id": "Asia/Tashkent", + "name": "Asia/Tashkent", + "utc_offset": 18000 + }, + { + "id": "Asia/Tbilisi", + "name": "Asia/Tbilisi", + "utc_offset": 14400 + }, + { + "id": "Asia/Tehran", + "name": "Asia/Tehran", + "utc_offset": 12600 + }, + { + "id": "Asia/Thimphu", + "name": "Asia/Thimphu", + "utc_offset": 21600 + }, + { + "id": "Asia/Tokyo", + "name": "Asia/Tokyo", + "utc_offset": 32400 + }, + { + "id": "Asia/Tomsk", + "name": "Asia/Tomsk", + "utc_offset": 25200 + }, + { + "id": "Asia/Ulaanbaatar", + "name": "Asia/Ulaanbaatar", + "utc_offset": 28800 + }, + { + "id": "Asia/Urumqi", + "name": "Asia/Urumqi", + "utc_offset": 21600 + }, + { + "id": "Asia/Ust-Nera", + "name": "Asia/Ust-Nera", + "utc_offset": 36000 + }, + { + "id": "Asia/Vientiane", + "name": "Asia/Vientiane", + "utc_offset": 25200 + }, + { + "id": "Asia/Vladivostok", + "name": "Asia/Vladivostok", + "utc_offset": 36000 + }, + { + "id": "Asia/Yakutsk", + "name": "Asia/Yakutsk", + "utc_offset": 32400 + }, + { + "id": "Asia/Yangon", + "name": "Asia/Yangon", + "utc_offset": 23400 + }, + { + "id": "Asia/Yekaterinburg", + "name": "Asia/Yekaterinburg", + "utc_offset": 18000 + }, + { + "id": "Asia/Yerevan", + "name": "Asia/Yerevan", + "utc_offset": 14400 + }, + { + "id": "Atlantic/Azores", + "name": "Atlantic/Azores", + "utc_offset": 0 + }, + { + "id": "Atlantic/Bermuda", + "name": "Atlantic/Bermuda", + "utc_offset": -10800 + }, + { + "id": "Atlantic/Canary", + "name": "Atlantic/Canary", + "utc_offset": 3600 + }, + { + "id": "Atlantic/Cape_Verde", + "name": "Atlantic/Cape Verde", + "utc_offset": -3600 + }, + { + "id": "Atlantic/Faroe", + "name": "Atlantic/Faroe", + "utc_offset": 3600 + }, + { + "id": "Atlantic/Madeira", + "name": "Atlantic/Madeira", + "utc_offset": 3600 + }, + { + "id": "Atlantic/Reykjavik", + "name": "Atlantic/Reykjavik", + "utc_offset": 0 + }, + { + "id": "Atlantic/South_Georgia", + "name": "Atlantic/South Georgia", + "utc_offset": -7200 + }, + { + "id": "Atlantic/St_Helena", + "name": "Atlantic/St Helena", + "utc_offset": 0 + }, + { + "id": "Atlantic/Stanley", + "name": "Atlantic/Stanley", + "utc_offset": -10800 + }, + { + "id": "Australia/Adelaide", + "name": "Australia/Adelaide", + "utc_offset": 34200 + }, + { + "id": "Australia/Brisbane", + "name": "Australia/Brisbane", + "utc_offset": 36000 + }, + { + "id": "Australia/Broken_Hill", + "name": "Australia/Broken Hill", + "utc_offset": 34200 + }, + { + "id": "Australia/Darwin", + "name": "Australia/Darwin", + "utc_offset": 34200 + }, + { + "id": "Australia/Eucla", + "name": "Australia/Eucla", + "utc_offset": 31500 + }, + { + "id": "Australia/Hobart", + "name": "Australia/Hobart", + "utc_offset": 36000 + }, + { + "id": "Australia/Lindeman", + "name": "Australia/Lindeman", + "utc_offset": 36000 + }, + { + "id": "Australia/Lord_Howe", + "name": "Australia/Lord Howe", + "utc_offset": 37800 + }, + { + "id": "Australia/Melbourne", + "name": "Australia/Melbourne", + "utc_offset": 36000 + }, + { + "id": "Australia/Perth", + "name": "Australia/Perth", + "utc_offset": 28800 + }, + { + "id": "Australia/Sydney", + "name": "Australia/Sydney", + "utc_offset": 36000 + }, + { + "id": "Europe/Amsterdam", + "name": "Europe/Amsterdam", + "utc_offset": 7200 + }, + { + "id": "Europe/Andorra", + "name": "Europe/Andorra", + "utc_offset": 7200 + }, + { + "id": "Europe/Astrakhan", + "name": "Europe/Astrakhan", + "utc_offset": 14400 + }, + { + "id": "Europe/Athens", + "name": "Europe/Athens", + "utc_offset": 10800 + }, + { + "id": "Europe/Belgrade", + "name": "Europe/Belgrade", + "utc_offset": 7200 + }, + { + "id": "Europe/Berlin", + "name": "Europe/Berlin", + "utc_offset": 7200 + }, + { + "id": "Europe/Bratislava", + "name": "Europe/Bratislava", + "utc_offset": 7200 + }, + { + "id": "Europe/Brussels", + "name": "Europe/Brussels", + "utc_offset": 7200 + }, + { + "id": "Europe/Bucharest", + "name": "Europe/Bucharest", + "utc_offset": 10800 + }, + { + "id": "Europe/Budapest", + "name": "Europe/Budapest", + "utc_offset": 7200 + }, + { + "id": "Europe/Busingen", + "name": "Europe/Busingen", + "utc_offset": 7200 + }, + { + "id": "Europe/Chisinau", + "name": "Europe/Chisinau", + "utc_offset": 10800 + }, + { + "id": "Europe/Copenhagen", + "name": "Europe/Copenhagen", + "utc_offset": 7200 + }, + { + "id": "Europe/Dublin", + "name": "Europe/Dublin", + "utc_offset": 3600 + }, + { + "id": "Europe/Gibraltar", + "name": "Europe/Gibraltar", + "utc_offset": 7200 + }, + { + "id": "Europe/Guernsey", + "name": "Europe/Guernsey", + "utc_offset": 3600 + }, + { + "id": "Europe/Helsinki", + "name": "Europe/Helsinki", + "utc_offset": 10800 + }, + { + "id": "Europe/Isle_of_Man", + "name": "Europe/Isle of Man", + "utc_offset": 3600 + }, + { + "id": "Europe/Istanbul", + "name": "Europe/Istanbul", + "utc_offset": 10800 + }, + { + "id": "Europe/Jersey", + "name": "Europe/Jersey", + "utc_offset": 3600 + }, + { + "id": "Europe/Kaliningrad", + "name": "Europe/Kaliningrad", + "utc_offset": 7200 + }, + { + "id": "Europe/Kirov", + "name": "Europe/Kirov", + "utc_offset": 10800 + }, + { + "id": "Europe/Kyiv", + "name": "Europe/Kyiv", + "utc_offset": 10800 + }, + { + "id": "Europe/Lisbon", + "name": "Europe/Lisbon", + "utc_offset": 3600 + }, + { + "id": "Europe/Ljubljana", + "name": "Europe/Ljubljana", + "utc_offset": 7200 + }, + { + "id": "Europe/London", + "name": "Europe/London", + "utc_offset": 3600 + }, + { + "id": "Europe/Luxembourg", + "name": "Europe/Luxembourg", + "utc_offset": 7200 + }, + { + "id": "Europe/Madrid", + "name": "Europe/Madrid", + "utc_offset": 7200 + }, + { + "id": "Europe/Malta", + "name": "Europe/Malta", + "utc_offset": 7200 + }, + { + "id": "Europe/Mariehamn", + "name": "Europe/Mariehamn", + "utc_offset": 10800 + }, + { + "id": "Europe/Minsk", + "name": "Europe/Minsk", + "utc_offset": 10800 + }, + { + "id": "Europe/Monaco", + "name": "Europe/Monaco", + "utc_offset": 7200 + }, + { + "id": "Europe/Moscow", + "name": "Europe/Moscow", + "utc_offset": 10800 + }, + { + "id": "Europe/Oslo", + "name": "Europe/Oslo", + "utc_offset": 7200 + }, + { + "id": "Europe/Paris", + "name": "Europe/Paris", + "utc_offset": 7200 + }, + { + "id": "Europe/Podgorica", + "name": "Europe/Podgorica", + "utc_offset": 7200 + }, + { + "id": "Europe/Prague", + "name": "Europe/Prague", + "utc_offset": 7200 + }, + { + "id": "Europe/Riga", + "name": "Europe/Riga", + "utc_offset": 10800 + }, + { + "id": "Europe/Rome", + "name": "Europe/Rome", + "utc_offset": 7200 + }, + { + "id": "Europe/Samara", + "name": "Europe/Samara", + "utc_offset": 14400 + }, + { + "id": "Europe/San_Marino", + "name": "Europe/San Marino", + "utc_offset": 7200 + }, + { + "id": "Europe/Sarajevo", + "name": "Europe/Sarajevo", + "utc_offset": 7200 + }, + { + "id": "Europe/Saratov", + "name": "Europe/Saratov", + "utc_offset": 14400 + }, + { + "id": "Europe/Simferopol", + "name": "Europe/Simferopol", + "utc_offset": 10800 + }, + { + "id": "Europe/Skopje", + "name": "Europe/Skopje", + "utc_offset": 7200 + }, + { + "id": "Europe/Sofia", + "name": "Europe/Sofia", + "utc_offset": 10800 + }, + { + "id": "Europe/Stockholm", + "name": "Europe/Stockholm", + "utc_offset": 7200 + }, + { + "id": "Europe/Tallinn", + "name": "Europe/Tallinn", + "utc_offset": 10800 + }, + { + "id": "Europe/Tirane", + "name": "Europe/Tirane", + "utc_offset": 7200 + }, + { + "id": "Europe/Ulyanovsk", + "name": "Europe/Ulyanovsk", + "utc_offset": 14400 + }, + { + "id": "Europe/Vaduz", + "name": "Europe/Vaduz", + "utc_offset": 7200 + }, + { + "id": "Europe/Vatican", + "name": "Europe/Vatican", + "utc_offset": 7200 + }, + { + "id": "Europe/Vienna", + "name": "Europe/Vienna", + "utc_offset": 7200 + }, + { + "id": "Europe/Vilnius", + "name": "Europe/Vilnius", + "utc_offset": 10800 + }, + { + "id": "Europe/Volgograd", + "name": "Europe/Volgograd", + "utc_offset": 10800 + }, + { + "id": "Europe/Warsaw", + "name": "Europe/Warsaw", + "utc_offset": 7200 + }, + { + "id": "Europe/Zagreb", + "name": "Europe/Zagreb", + "utc_offset": 7200 + }, + { + "id": "Europe/Zurich", + "name": "Europe/Zurich", + "utc_offset": 7200 + }, + { + "id": "Indian/Antananarivo", + "name": "Indian/Antananarivo", + "utc_offset": 10800 + }, + { + "id": "Indian/Chagos", + "name": "Indian/Chagos", + "utc_offset": 21600 + }, + { + "id": "Indian/Christmas", + "name": "Indian/Christmas", + "utc_offset": 25200 + }, + { + "id": "Indian/Cocos", + "name": "Indian/Cocos", + "utc_offset": 23400 + }, + { + "id": "Indian/Comoro", + "name": "Indian/Comoro", + "utc_offset": 10800 + }, + { + "id": "Indian/Kerguelen", + "name": "Indian/Kerguelen", + "utc_offset": 18000 + }, + { + "id": "Indian/Mahe", + "name": "Indian/Mahe", + "utc_offset": 14400 + }, + { + "id": "Indian/Maldives", + "name": "Indian/Maldives", + "utc_offset": 18000 + }, + { + "id": "Indian/Mauritius", + "name": "Indian/Mauritius", + "utc_offset": 14400 + }, + { + "id": "Indian/Mayotte", + "name": "Indian/Mayotte", + "utc_offset": 10800 + }, + { + "id": "Indian/Reunion", + "name": "Indian/Reunion", + "utc_offset": 14400 + }, + { + "id": "Pacific/Apia", + "name": "Pacific/Apia", + "utc_offset": 46800 + }, + { + "id": "Pacific/Auckland", + "name": "Pacific/Auckland", + "utc_offset": 43200 + }, + { + "id": "Pacific/Bougainville", + "name": "Pacific/Bougainville", + "utc_offset": 39600 + }, + { + "id": "Pacific/Chatham", + "name": "Pacific/Chatham", + "utc_offset": 45900 + }, + { + "id": "Pacific/Chuuk", + "name": "Pacific/Chuuk", + "utc_offset": 36000 + }, + { + "id": "Pacific/Easter", + "name": "Pacific/Easter", + "utc_offset": -21600 + }, + { + "id": "Pacific/Efate", + "name": "Pacific/Efate", + "utc_offset": 39600 + }, + { + "id": "Pacific/Fakaofo", + "name": "Pacific/Fakaofo", + "utc_offset": 46800 + }, + { + "id": "Pacific/Fiji", + "name": "Pacific/Fiji", + "utc_offset": 43200 + }, + { + "id": "Pacific/Funafuti", + "name": "Pacific/Funafuti", + "utc_offset": 43200 + }, + { + "id": "Pacific/Galapagos", + "name": "Pacific/Galapagos", + "utc_offset": -21600 + }, + { + "id": "Pacific/Gambier", + "name": "Pacific/Gambier", + "utc_offset": -32400 + }, + { + "id": "Pacific/Guadalcanal", + "name": "Pacific/Guadalcanal", + "utc_offset": 39600 + }, + { + "id": "Pacific/Guam", + "name": "Pacific/Guam", + "utc_offset": 36000 + }, + { + "id": "Pacific/Honolulu", + "name": "Pacific/Honolulu", + "utc_offset": -36000 + }, + { + "id": "Pacific/Kanton", + "name": "Pacific/Kanton", + "utc_offset": 46800 + }, + { + "id": "Pacific/Kiritimati", + "name": "Pacific/Kiritimati", + "utc_offset": 50400 + }, + { + "id": "Pacific/Kosrae", + "name": "Pacific/Kosrae", + "utc_offset": 39600 + }, + { + "id": "Pacific/Kwajalein", + "name": "Pacific/Kwajalein", + "utc_offset": 43200 + }, + { + "id": "Pacific/Majuro", + "name": "Pacific/Majuro", + "utc_offset": 43200 + }, + { + "id": "Pacific/Marquesas", + "name": "Pacific/Marquesas", + "utc_offset": -34200 + }, + { + "id": "Pacific/Midway", + "name": "Pacific/Midway", + "utc_offset": -39600 + }, + { + "id": "Pacific/Nauru", + "name": "Pacific/Nauru", + "utc_offset": 43200 + }, + { + "id": "Pacific/Niue", + "name": "Pacific/Niue", + "utc_offset": -39600 + }, + { + "id": "Pacific/Norfolk", + "name": "Pacific/Norfolk", + "utc_offset": 39600 + }, + { + "id": "Pacific/Noumea", + "name": "Pacific/Noumea", + "utc_offset": 39600 + }, + { + "id": "Pacific/Pago_Pago", + "name": "Pacific/Pago Pago", + "utc_offset": -39600 + }, + { + "id": "Pacific/Palau", + "name": "Pacific/Palau", + "utc_offset": 32400 + }, + { + "id": "Pacific/Pitcairn", + "name": "Pacific/Pitcairn", + "utc_offset": -28800 + }, + { + "id": "Pacific/Pohnpei", + "name": "Pacific/Pohnpei", + "utc_offset": 39600 + }, + { + "id": "Pacific/Port_Moresby", + "name": "Pacific/Port Moresby", + "utc_offset": 36000 + }, + { + "id": "Pacific/Rarotonga", + "name": "Pacific/Rarotonga", + "utc_offset": -36000 + }, + { + "id": "Pacific/Saipan", + "name": "Pacific/Saipan", + "utc_offset": 36000 + }, + { + "id": "Pacific/Tahiti", + "name": "Pacific/Tahiti", + "utc_offset": -36000 + }, + { + "id": "Pacific/Tarawa", + "name": "Pacific/Tarawa", + "utc_offset": 43200 + }, + { + "id": "Pacific/Tongatapu", + "name": "Pacific/Tongatapu", + "utc_offset": 46800 + }, + { + "id": "Pacific/Wake", + "name": "Pacific/Wake", + "utc_offset": 43200 + }, + { + "id": "Pacific/Wallis", + "name": "Pacific/Wallis", + "utc_offset": 43200 + }, + { + "id": "UTC", + "name": "UTC", + "utc_offset": 0 + } + ] +} diff --git a/internal/sfu/cert.go b/internal/sfu/cert.go new file mode 100644 index 00000000..5d52630e --- /dev/null +++ b/internal/sfu/cert.go @@ -0,0 +1,43 @@ +package sfu + +import ( + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "fmt" + "strings" + + "github.com/pion/dtls/v3/pkg/crypto/selfsign" +) + +// newDTLSCertificate 生成进程级自签证书(DTLS 身份不靠 CA,靠信令面下发的指纹)。 +func newDTLSCertificate() (tls.Certificate, string, error) { + cert, err := selfsign.GenerateSelfSigned() + if err != nil { + return tls.Certificate{}, "", fmt.Errorf("sfu: self-signed cert: %w", err) + } + fp, err := certificateFingerprint(cert.Certificate[0]) + if err != nil { + return tls.Certificate{}, "", err + } + return cert, fp, nil +} + +// certificateFingerprint 计算 RFC 4572 风格 sha-256 指纹("AA:BB:...")。 +func certificateFingerprint(der []byte) (string, error) { + if _, err := x509.ParseCertificate(der); err != nil { + return "", fmt.Errorf("sfu: parse cert: %w", err) + } + sum := sha256.Sum256(der) + parts := make([]string, len(sum)) + for i, b := range sum { + parts[i] = strings.ToUpper(hex.EncodeToString([]byte{b})) + } + return strings.Join(parts, ":"), nil +} + +// normalizeFingerprint 去除大小写/分隔差异后比较用。 +func normalizeFingerprint(fp string) string { + return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(fp), ":", "")) +} diff --git a/internal/sfu/demux.go b/internal/sfu/demux.go new file mode 100644 index 00000000..22598d0d --- /dev/null +++ b/internal/sfu/demux.go @@ -0,0 +1,108 @@ +package sfu + +import ( + "errors" + "io" + "net" + "time" + + "github.com/pion/transport/v4/packetio" +) + +// demuxer 把 ICE conn 上的混合流量按 RFC 7983 首字节分发: +// - [20,63] → DTLS(握手与应用数据) +// - [128,191] → RTP/RTCP(再按 RTCP PT 200..207 细分) +// +// 写方向全部直写底层 ICE conn(同一 5 元组,RTCP-mux)。 +type demuxer struct { + base net.Conn + dtls *packetio.Buffer + srtp *packetio.Buffer + srtcp *packetio.Buffer + done chan struct{} +} + +func newDemuxer(base net.Conn) *demuxer { + d := &demuxer{ + base: base, + dtls: packetio.NewBuffer(), + srtp: packetio.NewBuffer(), + srtcp: packetio.NewBuffer(), + done: make(chan struct{}), + } + go d.readLoop() + return d +} + +func (d *demuxer) readLoop() { + defer close(d.done) + buf := make([]byte, 1500) + for { + n, err := d.base.Read(buf) + if err != nil { + _ = d.dtls.Close() + _ = d.srtp.Close() + _ = d.srtcp.Close() + return + } + if n == 0 { + continue + } + first := buf[0] + switch { + case first >= 20 && first <= 63: + _, _ = d.dtls.Write(buf[:n]) + case first >= 128 && first <= 191: + if n >= 2 && isRTCPPayloadType(buf[1]) { + _, _ = d.srtcp.Write(buf[:n]) + } else { + _, _ = d.srtp.Write(buf[:n]) + } + default: + // STUN 已被 ICE 层消费;其余丢弃。 + } + } +} + +func isRTCPPayloadType(pt byte) bool { + // RTCP packet type 范围(SR/RR/SDES/BYE/APP/RTPFB/PSFB...)。 + return pt >= 192 && pt <= 223 +} + +func (d *demuxer) Close() { + _ = d.dtls.Close() + _ = d.srtp.Close() + _ = d.srtcp.Close() +} + +// demuxConn 把单一 buffer 包成 net.Conn:读取自 buffer,写直达底层。 +type demuxConn struct { + buf *packetio.Buffer + base net.Conn +} + +func (c *demuxConn) Read(b []byte) (int, error) { + n, err := c.buf.Read(b) + if errors.Is(err, io.EOF) || errors.Is(err, packetio.ErrFull) { + return n, err + } + return n, err +} + +func (c *demuxConn) Write(b []byte) (int, error) { return c.base.Write(b) } +func (c *demuxConn) Close() error { return c.buf.Close() } +func (c *demuxConn) LocalAddr() net.Addr { return c.base.LocalAddr() } +func (c *demuxConn) RemoteAddr() net.Addr { return c.base.RemoteAddr() } + +// SetDeadline/SetReadDeadline 透传给 packetio.Buffer:DTLS 握手依赖读超时 +// 推进重传与失败退出(pion/dtls v3 默认 Handshake 无限期阻塞,调用方必须 +// 在握手期间设硬上限,否则停滞的握手会把 goroutine 挂死)。 +func (c *demuxConn) SetDeadline(t time.Time) error { return c.buf.SetReadDeadline(t) } +func (c *demuxConn) SetReadDeadline(t time.Time) error { return c.buf.SetReadDeadline(t) } + +// 写方向直达底层 ICE conn,无队列可超时。 +func (c *demuxConn) SetWriteDeadline(t time.Time) error { return nil } + +func (d *demuxer) dtlsConn() net.Conn { return &demuxConn{buf: d.dtls, base: d.base} } +func (d *demuxer) srtpConn() net.Conn { return &demuxConn{buf: d.srtp, base: d.base} } +func (d *demuxer) srtcpConn() net.Conn { return &demuxConn{buf: d.srtcp, base: d.base} } diff --git a/internal/sfu/mediaplan_test.go b/internal/sfu/mediaplan_test.go new file mode 100644 index 00000000..4c0d1c97 --- /dev/null +++ b/internal/sfu/mediaplan_test.go @@ -0,0 +1,51 @@ +package sfu + +import "testing" + +// 选层契约:订阅端只为 SIM[0](最低层)与其 FID RTX 伙伴建解码 sink, +// SFU 只转发这两个视频 ssrc,其余层在 SFU 终结。 +func TestBuildMediaPlanSimulcast(t *testing.T) { + offer := ClientOffer{ + AudioSSRC: 1000, + SsrcGroups: []SsrcGroup{ + {Semantics: "SIM", Sources: []uint32{1001, 1003, 1005}}, + {Semantics: "FID", Sources: []uint32{1001, 1002}}, + {Semantics: "FID", Sources: []uint32{1003, 1004}}, + {Semantics: "FID", Sources: []uint32{1005, 1006}}, + }, + } + plan := buildMediaPlan(offer) + for _, ssrc := range []uint32{1000, 1001, 1002} { + if !plan.shouldForward(ssrc) { + t.Fatalf("ssrc %d must be forwarded (audio / SIM[0] / its RTX)", ssrc) + } + } + for _, ssrc := range []uint32{1003, 1004, 1005, 1006} { + if plan.shouldForward(ssrc) { + t.Fatalf("ssrc %d must be dropped (higher simulcast layer)", ssrc) + } + } + // 未声明 ssrc(探测/未来扩展)放行,订阅端安全丢弃。 + if !plan.shouldForward(9999) { + t.Fatalf("undeclared ssrc must pass through") + } +} + +// 单层发布(conference 模式):无 SIM 组、唯一 FID 组的第一个 ssrc 即主流。 +func TestBuildMediaPlanSingleLayer(t *testing.T) { + plan := buildMediaPlan(ClientOffer{ + AudioSSRC: 2000, + SsrcGroups: []SsrcGroup{{Semantics: "FID", Sources: []uint32{2001, 2002}}}, + }) + if !plan.shouldForward(2001) || !plan.shouldForward(2002) { + t.Fatalf("single-layer media+rtx must be forwarded") + } +} + +// 纯音频(无视频组):一切放行。 +func TestBuildMediaPlanAudioOnly(t *testing.T) { + plan := buildMediaPlan(ClientOffer{AudioSSRC: 3000}) + if !plan.shouldForward(3000) || !plan.shouldForward(3001) { + t.Fatalf("audio-only plan must forward everything") + } +} diff --git a/internal/sfu/pion.go b/internal/sfu/pion.go new file mode 100644 index 00000000..299768e9 --- /dev/null +++ b/internal/sfu/pion.go @@ -0,0 +1,735 @@ +package sfu + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "sync" + "time" + + "github.com/pion/datachannel" + "github.com/pion/dtls/v3" + dtlsnet "github.com/pion/dtls/v3/pkg/net" + "github.com/pion/ice/v4" + "github.com/pion/logging" + "github.com/pion/rtcp" + "github.com/pion/sctp" + "github.com/pion/srtp/v3" + "go.uber.org/zap" +) + +// PionConfig 是内嵌 SFU 的运行参数。 +type PionConfig struct { + // UDPPort 是单 UDP 监听端口(pion ICE UDPMux,全部 endpoint 复用)。 + UDPPort int + // AdvertiseIP 是写进下行 candidate 的客户端可达地址。⚠ 127.0.0.1 会让 + // 真机 ICE 永远连不上且无任何 RPC 错误(纯媒体面静默失败)。 + AdvertiseIP string + Logger *zap.Logger + // Touch 是媒体面活性回报钩子:对仍存活(近期 SRTP 收包/ICE 连通)的 + // endpoint 周期性刷新 group_call_participants.last_check_date,使 sweeper 的 + // 单一水位同时承载「心跳 ∨ 媒体活性」(P0-2 双过期判据的实现方式)。 + Touch func(callID, userID int64) + // LivenessInterval 是活性回报周期(默认 15s,远小于 sweeper 的 45s TTL)。 + LivenessInterval time.Duration + // ActivityWindow 判定 endpoint 媒体面存活的最近收包窗口(默认 25s)。 + ActivityWindow time.Duration +} + +// pionSFU 是 Service 的 pion 实现:full-ICE CONTROLLING + DTLS 主动握手(client +// 角色,对应下行 setup:"active")+ SRTP 房间内原样转发(不重写 SSRC、保留 +// audio-level 等 RTP 扩展,speaking 指示由客户端渲染)。 +type pionSFU struct { + cfg PionConfig + log *zap.Logger + udpConn net.PacketConn + mux *ice.UDPMuxDefault + cert tls.Certificate + fingerprint string + + mu sync.Mutex + rooms map[int64]*room + + cancel context.CancelFunc +} + +type epKey struct { + userID int64 + kind EndpointKind +} + +type room struct { + callID int64 + endpoints map[epKey]*endpoint +} + +// mediaPlan 是一个发布 endpoint 的 ssrc 转发计划(Join 时从 ssrc-groups 算定): +// 订阅端只为 SIM[0](最低层)建解码 sink——只转发该层与其 FID RTX 伙伴, +// 其余 simulcast 层在 SFU 侧丢弃(客户端反正会丢,转发纯浪费下行)。 +type mediaPlan struct { + audioSSRC uint32 + forward map[uint32]bool // 转发的视频 ssrc(SIM[0] 层 + 其 RTX) + drop map[uint32]bool // 其余已声明视频 ssrc(高层及其 RTX) +} + +func buildMediaPlan(offer ClientOffer) mediaPlan { + plan := mediaPlan{ + audioSSRC: offer.AudioSSRC, + forward: make(map[uint32]bool), + drop: make(map[uint32]bool), + } + var primary uint32 + for _, g := range offer.SsrcGroups { + if g.Semantics == "SIM" && len(g.Sources) > 0 { + primary = g.Sources[0] + break + } + } + // 单层发布(conference 模式)没有 SIM 组:唯一组的第一个 ssrc 即主流。 + if primary == 0 && len(offer.SsrcGroups) == 1 && len(offer.SsrcGroups[0].Sources) > 0 { + primary = offer.SsrcGroups[0].Sources[0] + } + for _, g := range offer.SsrcGroups { + for i, ssrc := range g.Sources { + keep := ssrc == primary || + (g.Semantics == "FID" && i == 1 && len(g.Sources) == 2 && g.Sources[0] == primary) + if keep { + plan.forward[ssrc] = true + } else if !plan.forward[ssrc] { + plan.drop[ssrc] = true + } + } + } + for ssrc := range plan.forward { + delete(plan.drop, ssrc) + } + return plan +} + +// shouldForward 报告该 ssrc 的流是否进入扇出(true)或在 SFU 终结(false)。 +// 未声明的 ssrc 一律转发:音频、未来的探测流——订阅端对无 sink 的包安全丢弃。 +func (p mediaPlan) shouldForward(ssrc uint32) bool { + return !p.drop[ssrc] +} + +type endpoint struct { + callID int64 + userID int64 + kind EndpointKind + plan mediaPlan + sfu *pionSFU + + agent *ice.Agent + demux *demuxer + closed chan struct{} + + mu sync.Mutex + writeStream *srtp.WriteStreamSRTP + rtcpWriter *srtp.WriteStreamSRTCP + lastActivity time.Time + connected bool + closeOnce sync.Once +} + +// NewPion 启动内嵌 SFU:绑定 UDP 端口、生成进程级 DTLS 证书。 +func NewPion(cfg PionConfig) (Service, error) { + if cfg.UDPPort <= 0 { + return nil, fmt.Errorf("sfu: invalid udp port %d", cfg.UDPPort) + } + if cfg.AdvertiseIP == "" { + cfg.AdvertiseIP = "127.0.0.1" + } + if cfg.Logger == nil { + cfg.Logger = zap.NewNop() + } + if cfg.LivenessInterval <= 0 { + cfg.LivenessInterval = 15 * time.Second + } + if cfg.ActivityWindow <= 0 { + cfg.ActivityWindow = 25 * time.Second + } + udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: cfg.UDPPort}) + if err != nil { + return nil, fmt.Errorf("sfu: listen udp %d: %w", cfg.UDPPort, err) + } + cert, fingerprint, err := newDTLSCertificate() + if err != nil { + _ = udpConn.Close() + return nil, err + } + s := &pionSFU{ + cfg: cfg, + log: cfg.Logger, + udpConn: udpConn, + mux: ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: udpConn}), + cert: cert, + fingerprint: fingerprint, + rooms: make(map[int64]*room), + } + ctx, cancel := context.WithCancel(context.Background()) + s.cancel = cancel + go s.livenessLoop(ctx) + s.log.Info("sfu listening", + zap.Int("udp_port", cfg.UDPPort), + zap.String("advertise_ip", cfg.AdvertiseIP)) + if cfg.AdvertiseIP == "127.0.0.1" { + s.log.Warn("TELESRV_SFU_ADVERTISE_IP 为 127.0.0.1:真机 ICE 将无法连接(纯媒体面静默失败),多设备联调必须设为宿主机 LAN IP") + } + return s, nil +} + +func (s *pionSFU) Enabled() bool { return true } + +// Join 为参与者建立媒体 endpoint:本端生成独立 ufrag/pwd,ICE CONTROLLING 等待 +// 客户端 Binding Request(上行 JSON 无 candidates,对端地址靠 peer-reflexive 学得), +// 连通后以 DTLS client 主动握手并建 SRTP 会话。 +func (s *pionSFU) Join(ctx context.Context, callID, userID int64, kind EndpointKind, offer ClientOffer) (ServerAnswer, error) { + if offer.Ufrag == "" || offer.Pwd == "" { + return ServerAnswer{}, fmt.Errorf("sfu: empty remote ice credentials") + } + localUfrag, err := randomICEString(8) + if err != nil { + return ServerAnswer{}, err + } + localPwd, err := randomICEString(24) + if err != nil { + return ServerAnswer{}, err + } + agent, err := ice.NewAgent(&ice.AgentConfig{ + NetworkTypes: []ice.NetworkType{ice.NetworkTypeUDP4}, + CandidateTypes: []ice.CandidateType{ice.CandidateTypeHost}, + UDPMux: s.mux, + LocalUfrag: localUfrag, + LocalPwd: localPwd, + IncludeLoopback: true, + LoggerFactory: &pionLoggerFactory{log: s.log.Named("ice")}, + }) + if err != nil { + return ServerAnswer{}, fmt.Errorf("sfu: new ice agent: %w", err) + } + // pion 要求注册 OnCandidate 才能收集;候选地址由我们直接以 + // AdvertiseIP:UDPPort 写进下行 JSON,回调本身无需消费。 + if err := agent.OnCandidate(func(ice.Candidate) {}); err != nil { + _ = agent.Close() + return ServerAnswer{}, fmt.Errorf("sfu: on candidate: %w", err) + } + if err := agent.GatherCandidates(); err != nil { + _ = agent.Close() + return ServerAnswer{}, fmt.Errorf("sfu: gather candidates: %w", err) + } + ep := &endpoint{ + callID: callID, + userID: userID, + kind: kind, + plan: buildMediaPlan(offer), + sfu: s, + agent: agent, + closed: make(chan struct{}), + } + s.attachEndpoint(ep) + go ep.run(offer) + return ServerAnswer{ + Ufrag: localUfrag, + Pwd: localPwd, + FingerprintSHA256: s.fingerprint, + Candidates: []Candidate{{ + IP: s.cfg.AdvertiseIP, + Port: s.cfg.UDPPort, + Protocol: "udp", + Type: "host", + }}, + }, nil +} + +func (s *pionSFU) attachEndpoint(ep *endpoint) { + key := epKey{userID: ep.userID, kind: ep.kind} + s.mu.Lock() + rm, ok := s.rooms[ep.callID] + if !ok { + rm = &room{callID: ep.callID, endpoints: make(map[epKey]*endpoint)} + s.rooms[ep.callID] = rm + } + old := rm.endpoints[key] + rm.endpoints[key] = ep + var oldPresentation *endpoint + if ep.kind == EndpointMain { + // 主连接 rejoin:旧 presentation 登记一并作废(信令侧同样清 presentation_json, + // 客户端随后重发 joinGroupCallPresentation)。 + pk := epKey{userID: ep.userID, kind: EndpointPresentation} + oldPresentation = rm.endpoints[pk] + delete(rm.endpoints, pk) + } + s.mu.Unlock() + if old != nil { + old.close() // rejoin:替换旧 endpoint + } + if oldPresentation != nil { + oldPresentation.close() + } +} + +func (s *pionSFU) Leave(_ context.Context, callID, userID int64, kind EndpointKind) error { + var victims []*endpoint + s.mu.Lock() + if rm, ok := s.rooms[callID]; ok { + keys := []epKey{{userID: userID, kind: kind}} + if kind == EndpointMain { + // 整体离会从不补发 leaveGroupCallPresentation:主 endpoint 离开联动拆屏幕。 + keys = append(keys, epKey{userID: userID, kind: EndpointPresentation}) + } + for _, key := range keys { + if ep := rm.endpoints[key]; ep != nil { + victims = append(victims, ep) + delete(rm.endpoints, key) + } + } + if len(rm.endpoints) == 0 { + delete(s.rooms, callID) + } + } + s.mu.Unlock() + for _, ep := range victims { + ep.close() + } + return nil +} + +func (s *pionSFU) CloseRoom(_ context.Context, callID int64) error { + s.mu.Lock() + rm := s.rooms[callID] + delete(s.rooms, callID) + s.mu.Unlock() + if rm != nil { + for _, ep := range rm.endpoints { + ep.close() + } + } + return nil +} + +func (s *pionSFU) AliveUserIDs(callID int64) []int64 { + cutoff := time.Now().Add(-s.cfg.ActivityWindow) + s.mu.Lock() + defer s.mu.Unlock() + rm, ok := s.rooms[callID] + if !ok { + return nil + } + seen := make(map[int64]bool) + var out []int64 + for key, ep := range rm.endpoints { + // 任一连接(主/屏幕)存活即视为该参与者媒体面存活。 + if !seen[key.userID] && ep.aliveSince(cutoff) { + seen[key.userID] = true + out = append(out, key.userID) + } + } + return out +} + +// livenessLoop 周期性把媒体面存活回报给信令侧(刷新保活水位)。 +func (s *pionSFU) livenessLoop(ctx context.Context) { + ticker := time.NewTicker(s.cfg.LivenessInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if s.cfg.Touch == nil { + continue + } + cutoff := time.Now().Add(-s.cfg.ActivityWindow) + type key struct{ callID, userID int64 } + seen := make(map[key]bool) + var alive []key + s.mu.Lock() + for callID, rm := range s.rooms { + for ek, ep := range rm.endpoints { + k := key{callID, ek.userID} + if !seen[k] && ep.aliveSince(cutoff) { + seen[k] = true + alive = append(alive, k) + } + } + } + s.mu.Unlock() + for _, k := range alive { + s.cfg.Touch(k.callID, k.userID) + } + } + } +} + +// forwardRTP 把一条解密后的 RTP 包原样转发给房间内其他参与者的**主** endpoint +// (不重写 SSRC,扩展头随包透传——audio-level 即 speaking 指示数据源; +// 观看屏幕共享也走观看者的主连接,presentation 连接不收任何远端流)。 +func (s *pionSFU) forwardRTP(from *endpoint, packet []byte) { + for _, ep := range s.mainTargets(from) { + ep.writeRTP(packet) + } +} + +// mainTargets 返回房间内除 from 所属参与者外的全部主 endpoint。 +func (s *pionSFU) mainTargets(from *endpoint) []*endpoint { + s.mu.Lock() + defer s.mu.Unlock() + rm, ok := s.rooms[from.callID] + if !ok { + return nil + } + targets := make([]*endpoint, 0, len(rm.endpoints)) + for key, ep := range rm.endpoints { + if key.userID != from.userID && key.kind == EndpointMain { + targets = append(targets, ep) + } + } + return targets +} + +// publisherBySSRC 找到房间内声明了该 ssrc(音频/任一视频层/RTX)的发布 endpoint, +// 供 RTCP 反馈(PLI/NACK)按 media ssrc 路由回发布端。房间规模小,直接遍历。 +func (s *pionSFU) publisherBySSRC(callID int64, ssrc uint32) *endpoint { + s.mu.Lock() + defer s.mu.Unlock() + rm, ok := s.rooms[callID] + if !ok { + return nil + } + for _, ep := range rm.endpoints { + if ep.plan.audioSSRC == ssrc || ep.plan.forward[ssrc] || ep.plan.drop[ssrc] { + return ep + } + } + return nil +} + +// ---- endpoint ---- + +func (ep *endpoint) run(offer ClientOffer) { + s := ep.sfu + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // CONTROLLING:主动发起连通性检查;对端候选靠带 ufrag/pwd 认证的 + // Binding Request 学得(peer-reflexive)。 + conn, err := ep.agent.Dial(ctx, offer.Ufrag, offer.Pwd) + if err != nil { + s.log.Debug("sfu ice dial", zap.Int64("call_id", ep.callID), zap.Int64("user_id", ep.userID), zap.Error(err)) + ep.close() + return + } + ep.markActivity() + ep.demux = newDemuxer(conn) + + dtlsConfig := &dtls.Config{ + Certificates: []tls.Certificate{s.cert}, + InsecureSkipVerify: true, // 身份校验走指纹(信令面 commit),非 CA 链 + ExtendedMasterSecret: dtls.RequireExtendedMasterSecret, + SRTPProtectionProfiles: []dtls.SRTPProtectionProfile{ + dtls.SRTP_AEAD_AES_128_GCM, + dtls.SRTP_AES128_CM_HMAC_SHA1_80, + }, + } + dtlsRaw := ep.demux.dtlsConn() + dtlsConn, err := dtls.Client(dtlsnet.PacketConnFromConn(dtlsRaw), dtlsRaw.RemoteAddr(), dtlsConfig) + if err != nil { + s.log.Debug("sfu dtls client", zap.Int64("user_id", ep.userID), zap.Error(err)) + ep.close() + return + } + // pion/dtls v3 构造器是惰性握手(首次读写才握手):必须显式 HandshakeContext + // 完成并设硬上限,否则 ConnectionState 拿不到状态、停滞的握手会挂死 goroutine。 + hsCtx, hsCancel := context.WithTimeout(context.Background(), 20*time.Second) + err = dtlsConn.HandshakeContext(hsCtx) + hsCancel() + if err != nil { + s.log.Debug("sfu dtls handshake", zap.Int64("user_id", ep.userID), zap.Error(err)) + ep.close() + return + } + // 指纹核验:DTLS 证书必须与 join JSON 承诺的 sha-256 指纹一致。 + state, ok := dtlsConn.ConnectionState() + if !ok || len(state.PeerCertificates) == 0 { + ep.close() + return + } + gotFP, err := certificateFingerprint(state.PeerCertificates[0]) + if err != nil || normalizeFingerprint(gotFP) != normalizeFingerprint(offer.FingerprintSHA256) { + s.log.Warn("sfu dtls fingerprint mismatch", zap.Int64("user_id", ep.userID)) + ep.close() + return + } + profile, ok := dtlsConn.SelectedSRTPProtectionProfile() + if !ok { + ep.close() + return + } + srtpConfig := &srtp.Config{} + switch profile { + case dtls.SRTP_AEAD_AES_128_GCM: + srtpConfig.Profile = srtp.ProtectionProfileAeadAes128Gcm + case dtls.SRTP_AES128_CM_HMAC_SHA1_80: + srtpConfig.Profile = srtp.ProtectionProfileAes128CmHmacSha1_80 + default: + ep.close() + return + } + // SFU 是 DTLS client:isClient=true 决定 SRTP 读写密钥方向。 + if err := srtpConfig.ExtractSessionKeysFromDTLS(&state, true); err != nil { + s.log.Debug("sfu srtp keys", zap.Error(err)) + ep.close() + return + } + session, err := srtp.NewSessionSRTP(ep.demux.srtpConn(), srtpConfig) + if err != nil { + s.log.Debug("sfu srtp session", zap.Error(err)) + ep.close() + return + } + writeStream, err := session.OpenWriteStream() + if err != nil { + ep.close() + return + } + // RTCP 升级为 SRTCP session(同一份 srtpConfig 复用):读侧做 PLI/NACK 路由, + // 写侧供把反馈转投给发布端。注意:建了 SessionSRTCP 后绝不能再裸读 + // srtcpConn(双读互偷包)。 + rtcpSession, err := srtp.NewSessionSRTCP(ep.demux.srtcpConn(), srtpConfig) + if err != nil { + s.log.Debug("sfu srtcp session", zap.Error(err)) + ep.close() + return + } + rtcpWriter, err := rtcpSession.OpenWriteStream() + if err != nil { + ep.close() + return + } + ep.mu.Lock() + ep.writeStream = writeStream + ep.rtcpWriter = rtcpWriter + ep.connected = true + ep.mu.Unlock() + ep.markActivity() + s.log.Info("sfu endpoint connected", + zap.Int64("call_id", ep.callID), + zap.Int64("user_id", ep.userID), + zap.Int("kind", int(ep.kind))) + + go ep.serveRTCP(rtcpSession) + // SCTP 数据通道骑在同一条 DTLS 连接上(握手后的 application data 即 SCTP 包)。 + go ep.serveData(dtlsConn) + + // 入站 SRTP:按 SSRC 接收流。转发/终结决策在流粒度一次性做出 + //(AcceptStream 即按 SSRC 分流,selective forwarding 零每包开销)。 + for { + stream, ssrc, err := session.AcceptStream() + if err != nil { + ep.close() + return + } + if ep.plan.shouldForward(ssrc) { + go ep.readStream(stream, ssrc) + } else { + // 非转发层(高层 simulcast 及其 RTX):必须持续排空防 buffer 堆积。 + go ep.drainStream(stream) + } + } +} + +func (ep *endpoint) readStream(stream *srtp.ReadStreamSRTP, ssrc uint32) { + buf := make([]byte, 1500) + for { + n, err := stream.Read(buf) + if err != nil { + return + } + ep.markActivity() + packet := make([]byte, n) + copy(packet, buf[:n]) + ep.sfu.forwardRTP(ep, packet) + } +} + +func (ep *endpoint) drainStream(stream *srtp.ReadStreamSRTP) { + buf := make([]byte, 1500) + for { + if _, err := stream.Read(buf); err != nil { + return + } + ep.markActivity() + } +} + +// serveRTCP 消费本 endpoint 的入站 RTCP 并路由: +// - PLI / FIR / NACK(订阅端反馈)→ 按 media ssrc 找到发布 endpoint 转投—— +// 恒等转发下订阅端反馈里的 ssrc 就是发布端的原始 ssrc,无需改写; +// 发布端自带 RTX 重传与 300ms keyframe 限流; +// - SR(发布端报告)→ 转发给其他主 endpoint(A/V 同步的 NTP↔RTP 映射来源); +// - RR / REMB / transport-cc:丢弃(v1 不做带宽自适应;layer0 所需码率 +// 远低于发布端 400kbps 起始值,不会饿死)。 +func (ep *endpoint) serveRTCP(session *srtp.SessionSRTCP) { + for { + stream, _, err := session.AcceptStream() + if err != nil { + return + } + go func() { + buf := make([]byte, 1500) + for { + n, err := stream.Read(buf) + if err != nil { + return + } + ep.markActivity() + pkts, err := rtcp.Unmarshal(buf[:n]) + if err != nil { + continue + } + ep.routeRTCP(pkts) + } + }() + } +} + +func (ep *endpoint) routeRTCP(pkts []rtcp.Packet) { + for _, pkt := range pkts { + switch fb := pkt.(type) { + case *rtcp.PictureLossIndication: + ep.sfu.writeRTCPToPublisher(ep.callID, fb.MediaSSRC, pkt) + case *rtcp.FullIntraRequest: + ep.sfu.writeRTCPToPublisher(ep.callID, fb.MediaSSRC, pkt) + case *rtcp.TransportLayerNack: + ep.sfu.writeRTCPToPublisher(ep.callID, fb.MediaSSRC, pkt) + case *rtcp.SenderReport: + for _, target := range ep.sfu.mainTargets(ep) { + target.writeRTCP(pkt) + } + } + } +} + +func (s *pionSFU) writeRTCPToPublisher(callID int64, mediaSSRC uint32, pkt rtcp.Packet) { + publisher := s.publisherBySSRC(callID, mediaSSRC) + if publisher != nil { + publisher.writeRTCP(pkt) + } +} + +func (ep *endpoint) writeRTCP(pkt rtcp.Packet) { + ep.mu.Lock() + w := ep.rtcpWriter + ep.mu.Unlock() + if w == nil { + return + } + raw, err := rtcp.Marshal([]rtcp.Packet{pkt}) + if err != nil { + return + } + _, _ = w.Write(raw) +} + +// serveData 在 DTLS 连接上被动承接 SCTP 关联与 DCEP data channel: +// - 客户端总是 SCTP INIT 与 DATA_CHANNEL_OPEN 的发起方(sid=0, label="data"), +// SFU 绝不主动 OPEN(客户端没有 accept 入站 OPEN 的逻辑); +// - 关联被客户端重建(restartDataChannel)时在同一 DTLS 上重新 accept; +// - 数据通道上没有任何消息是音视频工作的硬前置:v1 只消费(记录) +// ReceiverVideoConstraints,其余忽略。 +func (ep *endpoint) serveData(conn *dtls.Conn) { + logf := logging.NewDefaultLoggerFactory() + logf.DefaultLogLevel = logging.LogLevelError + for { + select { + case <-ep.closed: + return + default: + } + assoc, err := sctp.Server(sctp.Config{ + NetConn: conn, + LoggerFactory: logf, + }) + if err != nil { + return // DTLS 关闭/endpoint 拆除 + } + for { + dc, err := datachannel.Accept(assoc, &datachannel.Config{LoggerFactory: logf}) + if err != nil { + break // 关联结束:外层重试承接客户端重建的新关联 + } + go ep.serveDataChannel(dc) + } + _ = assoc.Close() + } +} + +func (ep *endpoint) serveDataChannel(dc *datachannel.DataChannel) { + defer func() { _ = dc.Close() }() + buf := make([]byte, 65536) + for { + n, isString, err := dc.ReadDataChannel(buf) + if err != nil { + return + } + if !isString || n == 0 { + continue + } + ep.markActivity() + var msg struct { + ColibriClass string `json:"colibriClass"` + } + if err := json.Unmarshal(buf[:n], &msg); err != nil { + continue + } + // v1:ReceiverVideoConstraints 仅记录(转发策略固定为 SIM[0] 层广播, + // 客户端对未订阅 endpoint 的包安全丢弃);选层/按订阅裁剪留下一轮。 + ep.sfu.log.Debug("sfu data channel message", + zap.Int64("call_id", ep.callID), + zap.Int64("user_id", ep.userID), + zap.String("colibri_class", msg.ColibriClass)) + } +} + +func (ep *endpoint) writeRTP(packet []byte) { + ep.mu.Lock() + ws := ep.writeStream + ep.mu.Unlock() + if ws == nil { + return + } + _, _ = ws.Write(packet) +} + +func (ep *endpoint) markActivity() { + ep.mu.Lock() + ep.lastActivity = time.Now() + ep.mu.Unlock() +} + +func (ep *endpoint) aliveSince(cutoff time.Time) bool { + ep.mu.Lock() + defer ep.mu.Unlock() + return ep.connected && ep.lastActivity.After(cutoff) +} + +func (ep *endpoint) close() { + ep.closeOnce.Do(func() { + close(ep.closed) + if ep.demux != nil { + ep.demux.Close() + } + _ = ep.agent.Close() + ep.mu.Lock() + ep.connected = false + ep.mu.Unlock() + }) +} + +// pionLoggerFactory 把 pion 日志降为静默(按需可接 zap)。 +type pionLoggerFactory struct{ log *zap.Logger } + +func (f *pionLoggerFactory) NewLogger(scope string) logging.LeveledLogger { + return logging.NewDefaultLoggerFactory().NewLogger(scope) +} diff --git a/internal/sfu/pion_e2e_test.go b/internal/sfu/pion_e2e_test.go new file mode 100644 index 00000000..1d299b9d --- /dev/null +++ b/internal/sfu/pion_e2e_test.go @@ -0,0 +1,445 @@ +package sfu + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "testing" + "time" + + "github.com/pion/dtls/v3" + "github.com/pion/dtls/v3/pkg/crypto/selfsign" + dtlsnet "github.com/pion/dtls/v3/pkg/net" + "github.com/pion/ice/v4" + "github.com/pion/rtp" + "github.com/pion/srtp/v3" + "go.uber.org/zap/zaptest" +) + +var errClientDTLSStateMissing = errors.New("client dtls state missing") + +// fakeTgcallsClient 按 tgcalls GroupNetworkManager 的角色契约模拟客户端: +// ICE CONTROLLED(等待 SFU 发起连通性检查)、DTLS setup="passive"(server 角色, +// 等 SFU 主动握手)、SRTP isClient=false。 +type fakeTgcallsClient struct { + t *testing.T + ufrag, pwd string + cert tls.Certificate + fingerprint string + ssrc uint32 + groups []SsrcGroup + + session *srtp.SessionSRTP + write *srtp.WriteStreamSRTP + closeFn []func() +} + +func newFakeTgcallsClient(t *testing.T, ssrc uint32) *fakeTgcallsClient { + t.Helper() + cert, err := selfsign.GenerateSelfSigned() + if err != nil { + t.Fatalf("client cert: %v", err) + } + fp, err := certificateFingerprint(cert.Certificate[0]) + if err != nil { + t.Fatalf("client fingerprint: %v", err) + } + ufrag, _ := randomICEString(8) + pwd, _ := randomICEString(24) + return &fakeTgcallsClient{t: t, ufrag: ufrag, pwd: pwd, cert: cert, fingerprint: fp, ssrc: ssrc} +} + +func (c *fakeTgcallsClient) offer() ClientOffer { + return ClientOffer{ + AudioSSRC: c.ssrc, + Ufrag: c.ufrag, + Pwd: c.pwd, + FingerprintSHA256: c.fingerprint, + SsrcGroups: c.groups, + } +} + +// connect 完成 ICE(controlled)+DTLS(server)+SRTP 建链。 +func (c *fakeTgcallsClient) connect(ctx context.Context, answer ServerAnswer) error { + agent, err := ice.NewAgent(&ice.AgentConfig{ + NetworkTypes: []ice.NetworkType{ice.NetworkTypeUDP4}, + CandidateTypes: []ice.CandidateType{ice.CandidateTypeHost}, + LocalUfrag: c.ufrag, + LocalPwd: c.pwd, + IncludeLoopback: true, + }) + if err != nil { + return err + } + c.closeFn = append(c.closeFn, func() { _ = agent.Close() }) + if err := agent.OnCandidate(func(ice.Candidate) {}); err != nil { + return err + } + if err := agent.GatherCandidates(); err != nil { + return err + } + for _, cand := range answer.Candidates { + remote, err := ice.NewCandidateHost(&ice.CandidateHostConfig{ + Network: "udp", + Address: cand.IP, + Port: cand.Port, + Component: 1, + }) + if err != nil { + return err + } + if err := agent.AddRemoteCandidate(remote); err != nil { + return err + } + } + conn, err := agent.Accept(ctx, answer.Ufrag, answer.Pwd) // CONTROLLED + if err != nil { + return err + } + demux := newDemuxer(conn) + c.closeFn = append(c.closeFn, demux.Close) + dtlsRaw := demux.dtlsConn() + dtlsConn, err := dtls.Server(dtlsnet.PacketConnFromConn(dtlsRaw), dtlsRaw.RemoteAddr(), &dtls.Config{ + Certificates: []tls.Certificate{c.cert}, + ClientAuth: dtls.RequireAnyClientCert, + InsecureSkipVerify: true, + ExtendedMasterSecret: dtls.RequireExtendedMasterSecret, + SRTPProtectionProfiles: []dtls.SRTPProtectionProfile{ + dtls.SRTP_AEAD_AES_128_GCM, + dtls.SRTP_AES128_CM_HMAC_SHA1_80, + }, + }) + if err != nil { + return err + } + hsCtx, hsCancel := context.WithTimeout(ctx, 20*time.Second) + err = dtlsConn.HandshakeContext(hsCtx) + hsCancel() + if err != nil { + return err + } + state, ok := dtlsConn.ConnectionState() + if !ok { + return errClientDTLSStateMissing + } + // 服务端证书指纹应等于信令面下发的 answer.FingerprintSHA256。 + gotFP, err := certificateFingerprint(state.PeerCertificates[0]) + if err != nil || normalizeFingerprint(gotFP) != normalizeFingerprint(answer.FingerprintSHA256) { + return fmt.Errorf("sfu fingerprint mismatch: %v / %s vs %s", err, gotFP, answer.FingerprintSHA256) + } + profile, _ := dtlsConn.SelectedSRTPProtectionProfile() + srtpConfig := &srtp.Config{} + switch profile { + case dtls.SRTP_AEAD_AES_128_GCM: + srtpConfig.Profile = srtp.ProtectionProfileAeadAes128Gcm + case dtls.SRTP_AES128_CM_HMAC_SHA1_80: + srtpConfig.Profile = srtp.ProtectionProfileAes128CmHmacSha1_80 + default: + return fmt.Errorf("unexpected srtp profile %v", profile) + } + if err := srtpConfig.ExtractSessionKeysFromDTLS(&state, false); err != nil { + return err + } + c.session, err = srtp.NewSessionSRTP(demux.srtpConn(), srtpConfig) + if err != nil { + return err + } + c.write, err = c.session.OpenWriteStream() + return err +} + +func (c *fakeTgcallsClient) close() { + for i := len(c.closeFn) - 1; i >= 0; i-- { + c.closeFn[i]() + } +} + +// sendOpusPacket 构造带 audio-level 扩展(one-byte header,id=1)的 RTP 包。 +func (c *fakeTgcallsClient) sendOpusPacket(seq uint16, payload []byte, audioLevel byte) error { + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: 111, // opus + SequenceNumber: seq, + Timestamp: uint32(seq) * 960, + SSRC: c.ssrc, + }, + Payload: payload, + } + pkt.Header.Extension = true + pkt.Header.ExtensionProfile = 0xBEDE + if err := pkt.Header.SetExtension(1, []byte{audioLevel}); err != nil { + return err + } + raw, err := pkt.Marshal() + if err != nil { + return err + } + _, err = c.write.Write(raw) + return err +} + +// sendVideoPacket 构造指定 ssrc 的视频 RTP 包(VP8 PT=100)。 +func (c *fakeTgcallsClient) sendVideoPacket(ssrc uint32, seq uint16) error { + pkt := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: 100, // VP8 + SequenceNumber: seq, + Timestamp: uint32(seq) * 3000, + SSRC: ssrc, + }, + Payload: []byte{0x90, 0x00, byte(seq)}, + } + raw, err := pkt.Marshal() + if err != nil { + return err + } + _, err = c.write.Write(raw) + return err +} + +// 视频选层:发布端三层 simulcast,SFU 只转发 SIM[0] 层(订阅端只为该 ssrc 建 +// 解码 sink,高层包客户端会丢弃,SFU 侧直接终结省下行)。 +func TestPionSFUForwardsOnlyBaseSimulcastLayer(t *testing.T) { + if testing.Short() { + t.Skip("e2e sfu test") + } + port := pickUDPPort(t) + svc, err := NewPion(PionConfig{UDPPort: port, AdvertiseIP: "127.0.0.1", Logger: zaptest.NewLogger(t)}) + if err != nil { + t.Fatalf("new pion sfu: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const callID = int64(901) + alice := newFakeTgcallsClient(t, 0x500) + base := alice.ssrc + 1 + alice.groups = []SsrcGroup{ + {Semantics: "SIM", Sources: []uint32{base, base + 2, base + 4}}, + {Semantics: "FID", Sources: []uint32{base, base + 1}}, + {Semantics: "FID", Sources: []uint32{base + 2, base + 3}}, + {Semantics: "FID", Sources: []uint32{base + 4, base + 5}}, + } + bob := newFakeTgcallsClient(t, 0x600) + defer alice.close() + defer bob.close() + + answerA, err := svc.Join(ctx, callID, 1, EndpointMain, alice.offer()) + if err != nil { + t.Fatalf("join alice: %v", err) + } + answerB, err := svc.Join(ctx, callID, 2, EndpointMain, bob.offer()) + if err != nil { + t.Fatalf("join bob: %v", err) + } + errCh := make(chan error, 2) + go func() { errCh <- alice.connect(ctx, answerA) }() + go func() { errCh <- bob.connect(ctx, answerB) }() + for i := 0; i < 2; i++ { + if err := <-errCh; err != nil { + t.Fatalf("client connect: %v", err) + } + } + + // alice 同时在 layer0(应转发)与 layer1(应被 SFU 终结)发包。 + stop := make(chan struct{}) + defer close(stop) + go func() { + seq := uint16(1) + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + _ = alice.sendVideoPacket(base, seq) + _ = alice.sendVideoPacket(base+2, seq) + seq++ + } + } + }() + + // bob 第一条转发流必须是 layer0;观察窗口内绝不能出现 layer1。 + type accepted struct{ ssrc uint32 } + got := make(chan accepted, 4) + go func() { + for { + stream, ssrc, err := bob.session.AcceptStream() + if err != nil { + return + } + got <- accepted{ssrc} + go func() { + buf := make([]byte, 1500) + for { + if _, err := stream.Read(buf); err != nil { + return + } + } + }() + } + }() + deadline := time.After(10 * time.Second) + sawBase := false + for !sawBase { + select { + case in := <-got: + if in.ssrc == base+2 { + t.Fatalf("higher simulcast layer %#x leaked through SFU", in.ssrc) + } + if in.ssrc == base { + sawBase = true + } + case <-deadline: + t.Fatalf("bob never received base layer stream") + } + } + // 再观察一段时间确认高层不漏。 + quiet := time.After(2 * time.Second) + for { + select { + case in := <-got: + if in.ssrc == base+2 { + t.Fatalf("higher simulcast layer %#x leaked through SFU", in.ssrc) + } + case <-quiet: + _ = svc.CloseRoom(ctx, callID) + return + } + } +} + +func TestPionSFUForwardsOpusBetweenClients(t *testing.T) { + if testing.Short() { + t.Skip("e2e sfu test") + } + port := pickUDPPort(t) + svc, err := NewPion(PionConfig{ + UDPPort: port, + AdvertiseIP: "127.0.0.1", + Logger: zaptest.NewLogger(t), + }) + if err != nil { + t.Fatalf("new pion sfu: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + const callID = int64(900) + alice := newFakeTgcallsClient(t, 0xA11CE) + bob := newFakeTgcallsClient(t, 0xB0B) + defer alice.close() + defer bob.close() + + answerA, err := svc.Join(ctx, callID, 1, EndpointMain, alice.offer()) + if err != nil { + t.Fatalf("join alice: %v", err) + } + if len(answerA.Candidates) != 1 || answerA.Candidates[0].Port != port { + t.Fatalf("answer candidates = %+v", answerA.Candidates) + } + answerB, err := svc.Join(ctx, callID, 2, EndpointMain, bob.offer()) + if err != nil { + t.Fatalf("join bob: %v", err) + } + // 两端建链(ICE prflx → DTLS 由 SFU 主动握手 → SRTP)。 + errCh := make(chan error, 2) + go func() { errCh <- alice.connect(ctx, answerA) }() + go func() { errCh <- bob.connect(ctx, answerB) }() + for i := 0; i < 2; i++ { + if err := <-errCh; err != nil { + t.Fatalf("client connect: %v", err) + } + } + + // alice 持续发包(SFU AcceptStream 在首包后建立读流)。 + stop := make(chan struct{}) + defer close(stop) + go func() { + seq := uint16(1) + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + _ = alice.sendOpusPacket(seq, []byte{0xDE, 0xAD, byte(seq)}, 0x7F) + seq++ + } + } + }() + + // bob 收到来自 alice 的转发流:SSRC 不重写、扩展保留。 + acceptCtx, acceptCancel := context.WithTimeout(ctx, 15*time.Second) + defer acceptCancel() + type accepted struct { + stream *srtp.ReadStreamSRTP + ssrc uint32 + } + got := make(chan accepted, 1) + go func() { + stream, ssrc, err := bob.session.AcceptStream() + if err == nil { + got <- accepted{stream, ssrc} + } + }() + var inbound accepted + select { + case inbound = <-got: + case <-acceptCtx.Done(): + t.Fatalf("bob never received forwarded stream") + } + if inbound.ssrc != alice.ssrc { + t.Fatalf("forwarded ssrc = %#x, want alice %#x(SFU 不得重写 SSRC)", inbound.ssrc, alice.ssrc) + } + buf := make([]byte, 1500) + n, err := inbound.stream.Read(buf) + if err != nil { + t.Fatalf("bob read: %v", err) + } + var pkt rtp.Packet + if err := pkt.Unmarshal(buf[:n]); err != nil { + t.Fatalf("unmarshal forwarded packet: %v", err) + } + if pkt.PayloadType != 111 || pkt.SSRC != alice.ssrc { + t.Fatalf("forwarded packet = %+v", pkt.Header) + } + if ext := pkt.Header.GetExtension(1); len(ext) != 1 || ext[0] != 0x7F { + t.Fatalf("audio-level extension lost: %v(speaking 指示依赖逐包透传)", ext) + } + + // 媒体面活性:双端都应在 alive 集合(alice 发包、bob 收包均计活性…bob 仅收不发, + // 其活性来自 SFU 写出?写出不计;bob 至少 connected+握手活性在窗口内)。 + alive := svc.AliveUserIDs(callID) + aliveSet := map[int64]bool{} + for _, id := range alive { + aliveSet[id] = true + } + if !aliveSet[1] { + t.Fatalf("alice must be media-alive, got %v", alive) + } + + if err := svc.Leave(ctx, callID, 1, EndpointMain); err != nil { + t.Fatalf("leave: %v", err) + } + if err := svc.CloseRoom(ctx, callID); err != nil { + t.Fatalf("close room: %v", err) + } +} + +func pickUDPPort(t *testing.T) int { + t.Helper() + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("pick port: %v", err) + } + port := conn.LocalAddr().(*net.UDPAddr).Port + _ = conn.Close() + return port +} diff --git a/internal/sfu/sfu.go b/internal/sfu/sfu.go new file mode 100644 index 00000000..4ccbdba8 --- /dev/null +++ b/internal/sfu/sfu.go @@ -0,0 +1,140 @@ +// Package sfu 是群通话媒体面(Selective Forwarding Unit)的接口与实现。 +// +// 角色契约(来自 tgcalls GroupNetworkManager 的硬性要求):客户端 ICE=CONTROLLED、 +// DTLS setup="passive"(等待握手);SFU 必须跑 full-ICE CONTROLLING(ICE-Lite 不可用) +// 并以 DTLS client(setup="active")主动发起握手。上行 join JSON 不含 ICE candidates, +// SFU 只能凭 ufrag/pwd 认证的 STUN Binding Request 学到客户端地址(peer-reflexive)。 +// +// M0 提供 Disabled 实现(纯信令联调):下发语法完备的 ufrag/pwd/sha-256 指纹与空 +// candidates——客户端解析成功后停留在 Connecting 态(持续 4s checkGroupCall 心跳), +// 属预期行为;M1 换 pion 实现后客户端无感切换。 +package sfu + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" +) + +// SsrcGroup 是上行 join JSON 的 ssrc-groups 条目:semantics ∈ {"SIM","FID"}。 +// SIM 的 sources 按质量低→高排列;FID 是 [媒体 ssrc, RTX 重传 ssrc] 对。 +type SsrcGroup struct { + Semantics string + Sources []uint32 +} + +// EndpointKind 区分同一参与者的两条媒体连接(M4:屏幕共享是独立的第二连接, +// 独立 ICE/DTLS/ssrc 集,但 TL 层归属同一 participant)。 +type EndpointKind int + +const ( + EndpointMain EndpointKind = iota + EndpointPresentation +) + +// ClientOffer 解析自 phone.joinGroupCall / joinGroupCallPresentation 的上行 params JSON。 +type ClientOffer struct { + AudioSSRC uint32 + Ufrag string + Pwd string + FingerprintSHA256 string // 形如 "AA:BB:..."(RFC 4572 hex) + // SsrcGroups 是视频 simulcast/RTX 源组(主 join 即带,无论摄像头开关)。 + // SFU 据此做层选择:订阅端只为 SIM[0](最低层)建解码 sink,发到其它层 + // 原始 ssrc 的包会被客户端静默丢弃——v1 转发策略=只转 SIM[0] 层与其 FID + // RTX 伙伴,其余层在 SFU 侧丢弃(省下行且行为正确)。 + SsrcGroups []SsrcGroup +} + +// Candidate 是下发给客户端的 ICE 候选(updateGroupCallConnection JSON 的 +// transport.candidates 条目,字段语义照抄 tgcalls 解析代码)。 +type Candidate struct { + IP string + Port int + Protocol string // "udp" + Type string // "host" +} + +// ServerAnswer 是 SFU 端的传输参数,由 rpc 层组装进下行 JSON。 +type ServerAnswer struct { + Ufrag string + Pwd string + FingerprintSHA256 string + Candidates []Candidate +} + +// Service 是信令层⇄媒体面的边界(未来横向扩展/远端 SFU 的替换点)。 +type Service interface { + // Enabled 报告媒体面是否真实可用(false=M0 纯信令模式)。 + Enabled() bool + // Join 为参与者分配/重建媒体 endpoint,返回 SFU 端传输参数。 + // kind=EndpointPresentation 时是同一参与者的第二连接(独立 ICE/DTLS)。 + Join(ctx context.Context, callID, userID int64, kind EndpointKind, offer ClientOffer) (ServerAnswer, error) + // Leave 拆除参与者的 endpoint;kind=EndpointMain 时联动拆其 presentation + //(客户端整体离会从不补发 leaveGroupCallPresentation)。 + Leave(ctx context.Context, callID, userID int64, kind EndpointKind) error + CloseRoom(ctx context.Context, callID int64) error + // AliveUserIDs 返回 callID 房间内媒体面仍存活(ICE consent/近期 SRTP 收包) + // 的参与者。sweeper 的判死条件 = 心跳过期 ∧ 媒体面不存活(双过期)。 + AliveUserIDs(callID int64) []int64 +} + +// disabled 是 M0 纯信令实现。 +type disabled struct{} + +// Disabled 返回纯信令模式的 SFU。 +func Disabled() Service { + return disabled{} +} + +func (disabled) Enabled() bool { return false } + +func (disabled) Join(_ context.Context, _, _ int64, _ EndpointKind, _ ClientOffer) (ServerAnswer, error) { + ufrag, err := randomICEString(8) + if err != nil { + return ServerAnswer{}, err + } + pwd, err := randomICEString(24) + if err != nil { + return ServerAnswer{}, err + } + fp, err := randomFingerprint() + if err != nil { + return ServerAnswer{}, err + } + // 空 candidates:客户端无可连地址,保持 Connecting(M0 预期)。 + return ServerAnswer{Ufrag: ufrag, Pwd: pwd, FingerprintSHA256: fp, Candidates: nil}, nil +} + +func (disabled) Leave(context.Context, int64, int64, EndpointKind) error { return nil } +func (disabled) CloseRoom(context.Context, int64) error { return nil } +func (disabled) AliveUserIDs(int64) []int64 { return nil } + +const iceAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func randomICEString(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("sfu: random ice string: %w", err) + } + var b strings.Builder + for _, v := range buf { + b.WriteByte(iceAlphabet[int(v)%len(iceAlphabet)]) + } + return b.String(), nil +} + +// randomFingerprint 生成语法合法的 sha-256 指纹串(32 字节 hex,冒号分隔)。 +// M0 无 DTLS 端点,指纹值不会被验证(客户端连不上任何 candidate)。 +func randomFingerprint() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("sfu: random fingerprint: %w", err) + } + parts := make([]string, len(buf)) + for i, v := range buf { + parts[i] = strings.ToUpper(hex.EncodeToString([]byte{v})) + } + return strings.Join(parts, ":"), nil +} diff --git a/internal/store/account.go b/internal/store/account.go index f339cebe..bc2b3da8 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -17,3 +17,69 @@ type AccountReactionSettingsStore interface { GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error) SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error } + +// AccountSettingsStore persists account-level singleton settings (global privacy, +// account TTL, sensitive-content toggle, contact-signup notification). +type AccountSettingsStore interface { + GetAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, bool, error) + SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error +} + +// NotifySettingsStore persists per-scope notification settings (specific peer / +// forum topic / the three category defaults: users, chats, broadcasts). +type NotifySettingsStore interface { + GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, bool, error) + SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error + ResetNotifySettings(ctx context.Context, ownerUserID int64) error + // GetPeerNotifySettings batch-loads whole-peer (topic 0) settings for the dialog list. + GetPeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) + // AllPeerNotifySettings loads every whole-peer (topic 0) setting for one owner in a single + // owner-scoped indexed query, for the per-user notify cache (dialog list + Full overlays). + AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) + // ListNotifyExceptions lists all per-peer non-default settings (account.getNotifyExceptions). + ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error) +} + +// StickerCollectionStore persists per-user personal sticker/GIF collections +// (faved / recent / recent-attached stickers, saved GIFs). +type StickerCollectionStore interface { + // SaveStickerCollectionItem adds (move-to-front, capped at max) or removes a document. + SaveStickerCollectionItem(ctx context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now, max int) error + ListStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) + ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error +} + +// SavedMusicStore persists one account's ordered profile music list. +type SavedMusicStore interface { + SaveMusic(ctx context.Context, req domain.SaveMusicRequest) error + ListSavedMusicIDs(ctx context.Context, userID int64, limit int) ([]int64, error) + ListSavedMusic(ctx context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) + GetSavedMusicByIDs(ctx context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) +} + +// BusinessAutomationStore persists account-local Telegram Business settings. +type BusinessAutomationStore interface { + GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error) + SaveBusinessProfile(ctx context.Context, profile domain.BusinessProfile) error + ListBusinessChatLinks(ctx context.Context, ownerUserID int64) ([]domain.BusinessChatLink, error) + CreateBusinessChatLink(ctx context.Context, link domain.BusinessChatLink) (domain.BusinessChatLink, error) + UpdateBusinessChatLink(ctx context.Context, ownerUserID int64, slug string, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) + DeleteBusinessChatLink(ctx context.Context, ownerUserID int64, slug string) (bool, error) + ResolveBusinessChatLink(ctx context.Context, slug string, bumpViews bool) (domain.BusinessChatLink, bool, error) + ListQuickReplies(ctx context.Context, ownerUserID int64, includeTopMessages bool) (domain.QuickReplyList, error) + CheckQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcut string) (bool, error) + SaveQuickReplyText(ctx context.Context, ownerUserID int64, shortcut string, msg domain.QuickReplyMessage) (domain.QuickReplyMutation, error) + GetQuickReplyMessages(ctx context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMessages, error) + RenameQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcutID int, shortcut string) (domain.QuickReplyMutation, error) + ReorderQuickReplies(ctx context.Context, ownerUserID int64, order []int) (domain.QuickReplyMutation, error) + DeleteQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcutID int) (domain.QuickReplyMutation, error) + DeleteQuickReplyMessages(ctx context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMutation, error) + ReserveBusinessAutomationDelivery(ctx context.Context, delivery domain.BusinessAutomationDelivery) (bool, error) + LastBusinessAutomationDelivery(ctx context.Context, ownerUserID, peerUserID int64, kind domain.BusinessAutomationKind) (domain.BusinessAutomationDelivery, bool, error) + GetConnectedBusinessBot(ctx context.Context, ownerUserID int64) (domain.ConnectedBusinessBot, bool, error) + SaveConnectedBusinessBot(ctx context.Context, bot domain.ConnectedBusinessBot) (domain.ConnectedBusinessBot, error) + DeleteConnectedBusinessBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) + SetConnectedBusinessBotPaused(ctx context.Context, ownerUserID, peerUserID int64, paused bool) (domain.ConnectedBusinessBotPeerState, error) + DisableConnectedBusinessBotForPeer(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, error) + GetConnectedBusinessBotPeerState(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, bool, error) +} diff --git a/internal/store/allocator.go b/internal/store/allocator.go index c15e3f9b..46b03298 100644 --- a/internal/store/allocator.go +++ b/internal/store/allocator.go @@ -2,18 +2,6 @@ package store import "context" -// PtsAllocator 分配用户级 pts。实现必须保证同一 user 内单调递增。 -type PtsAllocator interface { - NextPts(ctx context.Context, userID int64) (int, error) - CurrentPts(ctx context.Context, userID int64) (int, error) -} - -// PtsRangeAllocator 可一次性分配一段连续 pts,返回该段最终 pts。 -// 批量事件(例如 updateDeleteMessages)必须用 pts_count 推进多步时使用它。 -type PtsRangeAllocator interface { - NextPtsN(ctx context.Context, userID int64, count int) (int, error) -} - // BoxIDAllocator 分配用户视角 message box id。box_id 允许空洞,但不能回退。 type BoxIDAllocator interface { NextBoxID(ctx context.Context, userID int64) (int, error) diff --git a/internal/store/authkey.go b/internal/store/authkey.go index 3d40979f..3b05da16 100644 --- a/internal/store/authkey.go +++ b/internal/store/authkey.go @@ -19,4 +19,7 @@ type AuthKeyStore interface { Save(ctx context.Context, k AuthKeyData) error // Get 按 auth_key_id 查询;不存在时 found=false。 Get(ctx context.Context, id [8]byte) (data AuthKeyData, found bool, err error) + // Delete 删除一条 auth key 记录(destroy_auth_key)。不存在时静默成功。 + // 连接层每帧按 auth_key_id 回查本接口,删除后该 key 的入站帧立即失效。 + Delete(ctx context.Context, id [8]byte) error } diff --git a/internal/store/authorization.go b/internal/store/authorization.go index 46c009c9..548d0a02 100644 --- a/internal/store/authorization.go +++ b/internal/store/authorization.go @@ -14,4 +14,6 @@ type AuthorizationStore interface { Delete(ctx context.Context, authKeyID [8]byte) error DeleteByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) + // MarkPasswordPassed 清除 auth_key 的 password_pending 标记,使其转为完全授权(两步验证通过后调用)。 + MarkPasswordPassed(ctx context.Context, authKeyID [8]byte) error } diff --git a/internal/store/bot.go b/internal/store/bot.go new file mode 100644 index 00000000..6e224538 --- /dev/null +++ b/internal/store/bot.go @@ -0,0 +1,65 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// BotStore 持久化 bot 账号元数据(bots 表)与内置 bot 对话状态(bot_chat_states 表)。 +// +// CreateBotAccount 必须原子地创建 users 行(is_bot=true, bot_info_version=1, phone 空) +// 与 bots 行;username 冲突返回 domain.ErrUsernameOccupied。 +type BotStore interface { + CreateBotAccount(ctx context.Context, user domain.User, profile domain.BotProfile) (domain.User, domain.BotProfile, error) + GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) + ListBotsByOwner(ctx context.Context, ownerUserID int64) ([]domain.BotProfile, error) + CountBotsByOwner(ctx context.Context, ownerUserID int64) (int, error) + // UpdateBotTokenSecret 写入新 token 随机段;旧 token 立即失效(无缓存层)。 + UpdateBotTokenSecret(ctx context.Context, botUserID int64, secret string) error + + // 以下 P2 元数据写入:每个都在单事务内同时 bump 该 bot 对应 users 行的 + // bot_info_version(客户端据此感知变更并重拉 getFullUser),返回 bump 后的新 + // version。UpdateBotInfo 跨 users(first_name/about)与 bots(description)两表, + // 因此放在同一原子方法里。 + UpdateBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (newVersion int, err error) + UpdateBotInfo(ctx context.Context, botUserID int64, upd domain.BotInfoUpdate) (newVersion int, err error) + UpdateBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (newVersion int, err error) + SetBotInlinePlaceholder(ctx context.Context, botUserID int64, placeholder string) (newVersion int, err error) + SetBotInlineGeo(ctx context.Context, botUserID int64, inlineGeo bool) (newVersion int, err error) + SetBotNochats(ctx context.Context, botUserID int64, nochats bool) (newVersion int, err error) + SetBotChatHistory(ctx context.Context, botUserID int64, chatHistory bool) (newVersion int, err error) + CanBotSendMessage(ctx context.Context, botUserID, userID int64) (bool, error) + AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (created bool, err error) + + UpsertBotApp(ctx context.Context, app domain.BotApp) (domain.BotApp, int, error) + GetBotAppByID(ctx context.Context, appID, accessHash int64) (domain.BotApp, bool, error) + GetBotAppByShortName(ctx context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) + GetMainBotApp(ctx context.Context, botUserID int64) (domain.BotApp, bool, error) + ListBotApps(ctx context.Context, botUserID int64) ([]domain.BotApp, error) + GetBotAppSettings(ctx context.Context, botUserID int64) (domain.BotAppSettings, bool, error) + UpsertBotAppSettings(ctx context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) + ListBotAppPreviewMedia(ctx context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) + UpsertBotAppPreviewMedia(ctx context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error) + DeleteBotAppPreviewMedia(ctx context.Context, botUserID, appID, mediaID int64) (int, error) + ReorderBotAppPreviewMedia(ctx context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) + + UpsertAttachMenuBot(ctx context.Context, bot domain.BotAttachMenuBot) (int, error) + GetAttachMenuBot(ctx context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) + ListAttachMenuBots(ctx context.Context) ([]domain.BotAttachMenuBot, error) + GetAttachMenuState(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) + SetAttachMenuState(ctx context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) + + SaveRequestedWebViewButton(ctx context.Context, button domain.BotRequestedWebViewButton) error + GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) + DeleteRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) error + + SetBotEmojiStatusPermission(ctx context.Context, botUserID, userID int64, allowed bool) error + BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) + + PutWebViewCustomMethodQuery(ctx context.Context, query domain.BotWebViewCustomMethodQuery) error + + GetBotChatState(ctx context.Context, botUserID, userID int64) (domain.BotChatState, bool, error) + UpsertBotChatState(ctx context.Context, state domain.BotChatState) error + DeleteBotChatState(ctx context.Context, botUserID, userID int64) error +} diff --git a/internal/store/channel.go b/internal/store/channel.go index 310a68c1..06d2adb2 100644 --- a/internal/store/channel.go +++ b/internal/store/channel.go @@ -10,22 +10,32 @@ import ( type ChannelStore interface { CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) + // ResolveChannel 是 GetChannel 的轻量版:只做访问校验并返回 Channel(含 access_hash)+Self, + // 跳过 dialog top/读态/boost 这 3 条额外查询。供只需 access_hash/频道标志的纯解析路径用。 + ResolveChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) + GetChannels(ctx context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error) GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) SaveChannelDefaultSendAs(ctx context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) + FutureCreatorAfterLeave(ctx context.Context, channelID, userID int64) (domain.ChannelMember, error) InviteToChannel(ctx context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) JoinChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) LeaveChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) + SetChannelWallpaper(ctx context.Context, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error) EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) + EditChannelMemberRank(ctx context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) DeleteChannel(ctx context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) + SetChannelVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) + ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) + ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) @@ -35,9 +45,10 @@ type ChannelStore interface { SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) - SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) + SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.ChannelPaidMessagesPriceResult, error) SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) + SetBoostsToUnblockRestrictions(ctx context.Context, userID, channelID int64, boosts int) (domain.Channel, error) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) @@ -47,13 +58,21 @@ type ChannelStore interface { ListAdminLog(ctx context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) GetChannelMessageViews(ctx context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) SetChannelMessageReactions(ctx context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) + AddChannelMessagePaidReaction(ctx context.Context, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) + VoteChannelMessagePoll(ctx context.Context, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) + CloseChannelMessagePoll(ctx context.Context, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) ListChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) ListTopMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) ListRecentMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) ClearRecentMessageReactions(ctx context.Context, userID int64) error ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error + GetPremiumBoostStatus(ctx context.Context, viewerUserID, channelID int64, now int) (domain.PremiumBoostStatus, error) + ListPremiumBoosts(ctx context.Context, viewerUserID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error) + GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) + ApplyPremiumBoost(ctx context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error) + GetPremiumUserBoosts(ctx context.Context, viewerUserID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error) CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) UpdatePinnedForumTopic(ctx context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) @@ -62,12 +81,21 @@ type ChannelStore interface { ListForumTopics(ctx context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) GetForumTopicsByID(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) + // SendMonoforumMessage 向频道私信(monoforum)发消息,按 saved_peer 分订阅者子会话;不要求成员身份。 + SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) DeleteChannelMessages(ctx context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) DeleteChannelHistory(ctx context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) DeleteChannelParticipantHistory(ctx context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) UpdatePinnedMessage(ctx context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) + UnpinAllChannelMessages(ctx context.Context, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error) + ClearDanglingPinnedMessage(ctx context.Context, channelID int64, messageID int) error ExportInvite(ctx context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) + // EnsurePermanentInvite 幂等返回 (channel, admin) 当前未撤销的永久邀请,缺失则创建。 + // 官方语义:每个有邀请权限的管理员都持有自己的主链接(DrKLO 建频道后 + // getExportedChatInvites(limit=1) 直接取 invites[0],空列表即客户端闪退)。 + // date==0 时由实现取当前时间。 + EnsurePermanentInvite(ctx context.Context, channelID, adminUserID int64, date int) (domain.ChannelInvite, error) CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) ImportInvite(ctx context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) ListExportedInvites(ctx context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) @@ -88,16 +116,36 @@ type ChannelStore interface { ListChannelRecommendations(ctx context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) ListDiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) - SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, error) - ReorderChannelPinnedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error + // SetChannelDialogPinned 置顶/取消置顶频道会话;order 在会话当前 folder + // 内分配,返回 (changed, 该会话所在 folder_id)。 + SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, int, error) + // ReorderChannelPinnedDialogs 重排指定 folder 内的频道置顶;force 只清除 + // 该 folder 内不在 order 中的置顶。 + ReorderChannelPinnedDialogs(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) SetChannelDialogUnreadMark(ctx context.Context, userID, channelID int64, unread bool) (bool, error) SetChannelViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) ListChannelUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) EditChannelPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error + // CountChannelArchiveUnread 统计归档中有未读(含手动标记)的频道会话数 + // 与未读消息总数,供主列表 dialogFolder 条目聚合。 + CountChannelArchiveUnread(ctx context.Context, userID int64) (peers int, messages int, err error) ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) + // ListMonoforumHistory 拉取某订阅者在频道私信(monoforum)内的历史。 + ListMonoforumHistory(ctx context.Context, filter domain.MonoforumHistoryFilter) (domain.ChannelHistory, error) + // ListMonoforumDialogs 列出 monoforum 的订阅者子会话(管理员视角的私信列表)。 + ListMonoforumDialogs(ctx context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error) + // ResolveMonoforumSend 按 id 取 monoforum 频道(不要求成员身份)并返回调用者是否为其母频道管理员。 + ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) SearchPublicPosts(ctx context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) GetChannelMessages(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) + // SearchChannelMedia 返回某频道中属于给定媒体类别的消息(共享媒体标签页),newest-first 分页。 + SearchChannelMedia(ctx context.Context, viewerUserID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error) + // CountChannelMediaCategories 返回某频道对当前 viewer 可见消息按基础媒体类别聚合的精确计数。 + CountChannelMediaCategories(ctx context.Context, viewerUserID, channelID int64) (domain.MediaCategoryCounts, error) + ChannelPollFanoutViews(ctx context.Context, channelID int64, msgID int, viewers []int64, now int) (domain.ChannelPollFanoutViews, error) + ListStoryMessageForwards(ctx context.Context, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) + GetChannelMessageForInlineBot(ctx context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) ReadChannelMessageContents(ctx context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) ListChannelUnreadMentions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) @@ -106,15 +154,27 @@ type ChannelStore interface { ReadChannelReactions(ctx context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) GetDiscussionMessage(ctx context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) ReadChannelHistory(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) + // ReadChannelTopicHistory 推进 forum 单话题的 per-viewer 已读水位(不碰频道级),返回需推进 outbox 的发送者。 + ReadChannelTopicHistory(ctx context.Context, req domain.ReadChannelTopicHistoryRequest) (domain.ReadChannelTopicHistoryResult, error) + // GeneralForumTopic 现算 forum General 话题(id=1)对 viewer 的状态(per-topic 水位,不被普通话题串扰)。 + GeneralForumTopic(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelForumTopic, error) ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) ListDirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) + ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) MaxChannelPts(ctx context.Context, channelID int64) (int, error) - MaxChannelMessageID(ctx context.Context, channelID int64) (int, error) + // SetActiveCall 写入/清除(callID=0)channel 行上的活跃群通话关联 + //(channel.call_active/call_not_empty flag 与 channelFull.call 的数据源)。 + SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error) + // AppendCallServiceMessage 生成群通话服务消息(started/ended/invite,带频道 + // pts),Recipients 为活跃成员(rpc 据此扇出 updateNewChannelMessage)。 + AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) + // AppendStarGiftAdminLog 记录频道 Star gift 的 Recent Actions 快照,不插入频道历史、不推进 pts。 + AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error } // ChannelIDAllocator allocates channel IDs. @@ -123,17 +183,6 @@ type ChannelIDAllocator interface { CurrentChannelID(ctx context.Context) (int64, error) } -// ChannelPtsAllocator allocates channel-scoped pts. -type ChannelPtsAllocator interface { - NextChannelPts(ctx context.Context, channelID int64) (int, error) - CurrentChannelPts(ctx context.Context, channelID int64) (int, error) -} - -// ChannelPtsRangeAllocator allocates a range of channel pts and returns the final pts. -type ChannelPtsRangeAllocator interface { - NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) -} - // ChannelMessageIDAllocator allocates channel-scoped message IDs. type ChannelMessageIDAllocator interface { NextChannelMessageID(ctx context.Context, channelID int64) (int, error) diff --git a/internal/store/contact.go b/internal/store/contact.go index cb7bd220..653e3110 100644 --- a/internal/store/contact.go +++ b/internal/store/contact.go @@ -15,6 +15,7 @@ type ContactStore interface { Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) + SetCloseFriends(ctx context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) diff --git a/internal/store/dialog.go b/internal/store/dialog.go index 3872cf3b..2e60c3d4 100644 --- a/internal/store/dialog.go +++ b/internal/store/dialog.go @@ -11,15 +11,24 @@ type DialogStore interface { ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) ListByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) Upsert(ctx context.Context, userID int64, dialog domain.Dialog) error + // UpsertInbox records a newly received private message in a dialog without + // overwriting existing read watermarks or pinned/folder metadata. + UpsertInbox(ctx context.Context, userID int64, dialog domain.Dialog) error SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error + GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) ListDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) ClearDrafts(ctx context.Context, userID int64, limit int) ([]domain.DialogDraft, error) MarkRead(ctx context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) - SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) - ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error + // SetPinned 置顶/取消置顶一条会话;order 在会话当前 folder 内分配, + // 返回 (changed, 该会话所在 folder_id)。 + SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) + // ReorderPinned 重排指定 folder(0 主列表/1 归档)内的置顶顺序; + // force 只清除该 folder 内不在 order 中的置顶。 + ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) ListUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) + SetChatTheme(ctx context.Context, userID int64, peer domain.Peer, emoticon string) (bool, error) SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) PeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) ListFolders(ctx context.Context, userID int64) (domain.DialogFolderList, error) @@ -29,4 +38,12 @@ type DialogStore interface { ReorderFolders(ctx context.Context, userID int64, order []int) error SetFolderTagsEnabled(ctx context.Context, userID int64, enabled bool) error EditPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error + // CountArchiveUnread 统计归档(folder_id=1)中有未读(含手动标记)的 + // 会话数与未读消息总数,供主列表 dialogFolder 条目聚合。 + CountArchiveUnread(ctx context.Context, userID int64) (peers int, messages int, err error) + // SetArchivePinned 设置 archive folder 行本身的置顶状态 + // (toggleDialogPin(inputDialogPeerFolder)),返回是否发生变化。 + SetArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error) + // ArchivePinned 返回 archive folder 行置顶状态;从未设置过时官方默认 true。 + ArchivePinned(ctx context.Context, userID int64) (bool, error) } diff --git a/internal/store/groupcall.go b/internal/store/groupcall.go new file mode 100644 index 00000000..955695bb --- /dev/null +++ b/internal/store/groupcall.go @@ -0,0 +1,48 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// GroupCallStore 持久化群通话信令真值(memory/postgres 双实现,行为契约由 +// 共享 contract test 钉死;version 单调性见 domain/groupcall.go 注释)。 +type GroupCallStore interface { + // CreateGroupCall 建会;同频道已有活跃通话返回 domain.ErrGroupCallAlreadyStarted。 + CreateGroupCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error) + GetGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error) + // JoinGroupCall 加入/重进(同主键 upsert 换新 ssrc);ssrc 与他人撞活跃唯一 + // 约束返回 domain.ErrGroupCallSSRCDuplicate;version++。 + JoinGroupCall(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) + // LeaveGroupCall 置 left+version++;未在会返回 domain.ErrGroupCallNotJoined。 + LeaveGroupCall(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error) + // DiscardGroupCall 终结通话并清空参与者,返回终态 call 与此前活跃的参与者。 + DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) + // TouchParticipant 刷新 checkGroupCall 保活水位,返回该用户当前活跃 ssrc 集合 + //(joined=false 表示未在会,客户端据空集自动 rejoin)。 + TouchParticipant(ctx context.Context, callID, userID int64, now int) (activeSSRCs []int64, joined bool, err error) + GetParticipant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error) + // ListParticipants 按 (join_date, user_id) 游标分页;offset 为上次返回的 + // NextOffset(空=从头)。响应携带当前 version(客户端跳号全量 reload 依赖)。 + ListParticipants(ctx context.Context, callID int64, offset string, limit int) (domain.GroupCallParticipantPage, error) + // UpdateParticipant 应用字段级更新;changed=false 表示无有效变化(version 不动)。 + UpdateParticipant(ctx context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) + // SetGroupCallTitle / SetGroupCallJoinMuted 只动 call 行,不动参与者 version。 + SetGroupCallTitle(ctx context.Context, callID int64, title string) (domain.GroupCall, bool, error) + SetGroupCallJoinMuted(ctx context.Context, callID int64, joinMuted bool) (domain.GroupCall, bool, error) + SetStartedMessageID(ctx context.Context, callID int64, msgID int) error + // SweepStaleParticipants 清理保活水位早于 checkOlderThan 的活跃参与者 + //(每清一人 version++)。注意调用方必须叠加 SFU 媒体面活性做双过期判定。 + SweepStaleParticipants(ctx context.Context, checkOlderThan, now int, limit int) ([]domain.GroupCallMutation, error) + // ResetAllParticipants 服务端重启恢复:把全部活跃通话的参与者批量置 left + //(每通话 version++),返回受影响的通话。 + ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error) + // NextRaiseHandRating 分配全局单调递增的举手序号(举手排序用)。 + NextRaiseHandRating(ctx context.Context, callID int64) (int64, error) + // SetParticipantOverride 写入 setter 对 target 的 per-viewer 覆盖(本地静音/音量), + // 仅影响 setter 自己的视图。clear=true 删除覆盖。 + SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error + // GetParticipantOverride 取某 setter 对某 target 的覆盖。 + GetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error) +} diff --git a/internal/store/inline_registry.go b/internal/store/inline_registry.go new file mode 100644 index 00000000..270e8bc0 --- /dev/null +++ b/internal/store/inline_registry.go @@ -0,0 +1,116 @@ +package store + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +type InlineCacheKey struct { + BotUserID int64 + UserID int64 + Peer domain.Peer + Query string + Offset string + HasGeo bool + GeoLat float64 + GeoLong float64 + GeoAccuracy int +} + +type InlinePending struct { + QueryID int64 + BotUserID int64 + UserID int64 + Peer domain.Peer + CacheKey InlineCacheKey + CreatedAt time.Time +} + +type InlineWebDocumentKey struct { + URL string + AccessHash int64 +} + +type InlineWebDocumentEntry struct { + Document domain.BotInlineWebDocument + Bytes []byte + MimeType string +} + +type PreparedInlineMessage struct { + ID string + BotUserID int64 + UserID int64 + Results domain.BotInlineResults + ExpiresAt time.Time +} + +type WebViewSession struct { + QueryID int64 + BotQueryID string + BotUserID int64 + UserID int64 + Peer domain.Peer + AppID int64 + Source string + StartParam string + WriteAllowed bool + Silent bool + ReplyTo *domain.MessageReply + SendAs *domain.Peer + CreatedAt time.Time + ExpiresAt time.Time +} + +const ( + InlineQueryPeerTypeSameBotPM = "same_bot_pm" + InlineQueryPeerTypePM = "pm" + InlineQueryPeerTypeChat = "chat" + InlineQueryPeerTypeMegagroup = "megagroup" + InlineQueryPeerTypeBroadcast = "broadcast" + InlineQueryPeerTypeBotPM = "bot_pm" +) + +type BotInlineQueryPush struct { + SourceID string + QueryID int64 + BotUserID int64 + UserID int64 + Query string + Offset string + PeerType string + Geo *domain.MessageGeoPoint + Date int +} + +type InlineRegistryStore interface { + PutInlinePending(ctx context.Context, pending InlinePending, ttl time.Duration) error + GetInlinePending(ctx context.Context, queryID int64) (InlinePending, bool, error) + DeleteInlinePending(ctx context.Context, queryID int64) error + + PutInlineResult(ctx context.Context, results domain.BotInlineResults, ttl time.Duration) error + GetInlineResult(ctx context.Context, queryID int64) (domain.BotInlineResults, bool, error) + DeleteInlineResult(ctx context.Context, queryID int64) error + + PutInlineCache(ctx context.Context, key InlineCacheKey, results domain.BotInlineResults, ttl time.Duration) error + GetInlineCache(ctx context.Context, key InlineCacheKey) (domain.BotInlineResults, bool, time.Duration, error) + + PutInlineWebDocument(ctx context.Context, document domain.BotInlineWebDocument, ttl time.Duration) error + GetInlineWebDocument(ctx context.Context, key InlineWebDocumentKey) (InlineWebDocumentEntry, bool, error) + PutInlineWebDocumentBytes(ctx context.Context, key InlineWebDocumentKey, data []byte, mimeType string, ttl time.Duration) error + + PutPreparedInlineMessage(ctx context.Context, msg PreparedInlineMessage, ttl time.Duration) error + GetPreparedInlineMessage(ctx context.Context, id string) (PreparedInlineMessage, bool, error) + + PutWebViewSession(ctx context.Context, session WebViewSession, ttl time.Duration) error + GetWebViewSession(ctx context.Context, queryID int64) (WebViewSession, bool, error) + GetWebViewSessionByBotQuery(ctx context.Context, botQueryID string) (WebViewSession, bool, error) + DeleteWebViewSession(ctx context.Context, queryID int64, botQueryID string) error +} + +type BotInlineQueryPushBroker interface { + PublishBotInlineQuery(ctx context.Context, event BotInlineQueryPush) error + SubscribeBotInlineQueries(ctx context.Context, handle func(context.Context, BotInlineQueryPush)) error +} diff --git a/internal/store/media.go b/internal/store/media.go index 0f02abfc..8050da68 100644 --- a/internal/store/media.go +++ b/internal/store/media.go @@ -2,6 +2,7 @@ package store import ( "context" + "time" "telesrv/internal/domain" ) @@ -12,12 +13,23 @@ import ( type MediaStore interface { // 上传分片(transient:组装成 blob 后即清理)。 SaveFilePart(ctx context.Context, part domain.UploadPart) error + UploadPartUsage(ctx context.Context, ownerUserID int64) (domain.UploadPartUsage, error) + UploadPartSlot(ctx context.Context, ownerUserID, fileID int64, part int) (domain.UploadPartSlot, error) LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) - DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) error + DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) ([]string, error) + DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) ([]string, error) // blob 索引。 PutFileBlob(ctx context.Context, blob domain.FileBlob) error GetFileBlob(ctx context.Context, locationKey string) (domain.FileBlob, bool, error) + // GetFileBlobs 批量按 location_key 取 FileBlob 元数据(缺失的 key 不出现在返回 map 中)。 + // 供启动预热等需一次性加载大量 blob 的路径,替代逐个 GetFileBlob 的 N+1 往返。 + GetFileBlobs(ctx context.Context, locationKeys []string) (map[string]domain.FileBlob, error) + + // seed 状态。只记录静态资源 catalog 的内容 hash,用于启动时跳过未变化的重复导入; + // 真实可服务性仍由 documents/file_blobs 校验保证,不能只相信这里的 hash。 + GetSeedState(ctx context.Context, key string) (hash string, found bool, err error) + PutSeedState(ctx context.Context, key, hash string) error // 文档 / 照片注册表。 PutDocument(ctx context.Context, doc domain.Document) error @@ -26,6 +38,10 @@ type MediaStore interface { PutPhoto(ctx context.Context, photo domain.Photo) error GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) + // 链接预览解析缓存(web_pages)。按规范化 URL 哈希去重,snapshot 为完整快照。 + PutWebPage(ctx context.Context, urlHash int64, page domain.MessageWebPage, now int) error + GetWebPageByURLHash(ctx context.Context, urlHash int64) (page domain.MessageWebPage, refreshedAt int, found bool, err error) + // 贴纸集 / 可用 reaction。 PutStickerSet(ctx context.Context, set domain.StickerSet) error GetStickerSetByID(ctx context.Context, id int64) (domain.StickerSet, bool, error) @@ -38,14 +54,12 @@ type MediaStore interface { CountAvailableReactions(ctx context.Context) (int, error) // 头像历史(owner = user/channel;current = active 中 sort_order 最大者)。 - AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error AddProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error - CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) - ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (ids []int64, total int, err error) ListProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) (ids []int64, total int, err error) + ListProfilePhotoDetailsKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) (photos []domain.Photo, total int, err error) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error) } diff --git a/internal/store/memory/auth.go b/internal/store/memory/auth.go new file mode 100644 index 00000000..01fe3f38 --- /dev/null +++ b/internal/store/memory/auth.go @@ -0,0 +1,250 @@ +package memory + +import ( + "context" + "encoding/binary" + "sync" + "telesrv/internal/domain" + "telesrv/internal/store" + "time" +) + +// AuthKeyStore 是 store.AuthKeyStore 的内存实现。 +type AuthKeyStore struct { + mu sync.RWMutex + keys map[[8]byte]store.AuthKeyData +} + +// NewAuthKeyStore 创建内存 AuthKeyStore。 +func NewAuthKeyStore() *AuthKeyStore { + return &AuthKeyStore{keys: make(map[[8]byte]store.AuthKeyData)} +} + +func (s *AuthKeyStore) Save(_ context.Context, k store.AuthKeyData) error { + s.mu.Lock() + s.keys[k.ID] = k + s.mu.Unlock() + return nil +} + +func (s *AuthKeyStore) Get(_ context.Context, id [8]byte) (store.AuthKeyData, bool, error) { + s.mu.RLock() + k, ok := s.keys[id] + s.mu.RUnlock() + return k, ok, nil +} + +func (s *AuthKeyStore) Delete(_ context.Context, id [8]byte) error { + s.mu.Lock() + delete(s.keys, id) + s.mu.Unlock() + return nil +} + +// SessionStore 是 store.SessionStore 的内存实现。 +type SessionStore struct { + mu sync.RWMutex + sessions map[int64]store.SessionData +} + +// NewSessionStore 创建内存 SessionStore。 +func NewSessionStore() *SessionStore { + return &SessionStore{sessions: make(map[int64]store.SessionData)} +} + +func (s *SessionStore) Save(_ context.Context, d store.SessionData) error { + s.mu.Lock() + s.sessions[d.ID] = d + s.mu.Unlock() + return nil +} + +func (s *SessionStore) Get(_ context.Context, id int64) (store.SessionData, bool, error) { + s.mu.RLock() + d, ok := s.sessions[id] + s.mu.RUnlock() + return d, ok, nil +} + +func (s *SessionStore) Delete(_ context.Context, id int64) error { + s.mu.Lock() + delete(s.sessions, id) + s.mu.Unlock() + return nil +} + +// TempAuthKeyBindingStore 是 store.TempAuthKeyBindingStore 的内存实现。 +type TempAuthKeyBindingStore struct { + mu sync.RWMutex + m map[[8]byte]domain.TempAuthKeyBinding +} + +// NewTempAuthKeyBindingStore 创建内存 TempAuthKeyBindingStore。 +func NewTempAuthKeyBindingStore() *TempAuthKeyBindingStore { + return &TempAuthKeyBindingStore{m: make(map[[8]byte]domain.TempAuthKeyBinding)} +} + +func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBinding) error { + b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...) + s.mu.Lock() + s.m[b.TempAuthKeyID] = b + s.mu.Unlock() + return nil +} + +func (s *TempAuthKeyBindingStore) GetByTemp(_ context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) { + s.mu.RLock() + b, ok := s.m[tempAuthKeyID] + s.mu.RUnlock() + if !ok { + return domain.TempAuthKeyBinding{}, false, nil + } + b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...) + return b, true, nil +} + +func (s *TempAuthKeyBindingStore) DeleteExpired(_ context.Context, expiredBefore int64, limit int) (int, error) { + if limit <= 0 { + return 0, nil + } + s.mu.Lock() + defer s.mu.Unlock() + deleted := 0 + for id, b := range s.m { + if deleted >= limit { + break + } + if int64(b.ExpiresAt) < expiredBefore { + delete(s.m, id) + deleted++ + } + } + return deleted, nil +} + +// AuthorizationStore 是 store.AuthorizationStore 的内存实现。 +type AuthorizationStore struct { + mu sync.RWMutex + m map[[8]byte]domain.Authorization +} + +// NewAuthorizationStore 创建内存 AuthorizationStore。 +func NewAuthorizationStore() *AuthorizationStore { + return &AuthorizationStore{m: make(map[[8]byte]domain.Authorization)} +} + +func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) error { + now := time.Now() + if a.Hash == 0 { + a.Hash = int64(binary.LittleEndian.Uint64(a.AuthKeyID[:])) + } + if a.CreatedAt.IsZero() { + a.CreatedAt = now + } + a.ActiveAt = now + s.mu.Lock() + if existing, ok := s.m[a.AuthKeyID]; ok && !existing.CreatedAt.IsZero() { + a.CreatedAt = existing.CreatedAt + } + s.m[a.AuthKeyID] = a + s.mu.Unlock() + return nil +} + +func (s *AuthorizationStore) ByAuthKey(_ context.Context, id [8]byte) (domain.Authorization, bool, error) { + s.mu.RLock() + a, ok := s.m[id] + s.mu.RUnlock() + return a, ok, nil +} + +func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte) error { + s.mu.Lock() + if a, ok := s.m[id]; ok { + a.PasswordPending = false + a.ActiveAt = time.Now() + s.m[id] = a + } + s.mu.Unlock() + return nil +} + +func (s *AuthorizationStore) ListByUser(_ context.Context, userID int64) ([]domain.Authorization, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Authorization, 0) + for _, a := range s.m { + if a.UserID == userID { + out = append(out, a) + } + } + return out, nil +} + +func (s *AuthorizationStore) Delete(_ context.Context, id [8]byte) error { + s.mu.Lock() + delete(s.m, id) + s.mu.Unlock() + return nil +} + +func (s *AuthorizationStore) DeleteByHash(_ context.Context, userID, hash int64) (domain.Authorization, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + for id, a := range s.m { + if a.UserID == userID && a.Hash == hash { + delete(s.m, id) + return a, true, nil + } + } + return domain.Authorization{}, false, nil +} + +func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]domain.Authorization, 0) + for id, a := range s.m { + if a.UserID != userID || id == keepAuthKeyID { + continue + } + delete(s.m, id) + out = append(out, a) + } + return out, nil +} + +// CodeStore 是 store.CodeStore 的内存实现(带 TTL)。 +type CodeStore struct { + mu sync.Mutex + m map[string]codeEntry +} + +// NewCodeStore 创建内存 CodeStore。 +func NewCodeStore() *CodeStore { + return &CodeStore{m: make(map[string]codeEntry)} +} + +func (s *CodeStore) Set(_ context.Context, hash string, code store.PhoneCode, ttl time.Duration) error { + s.mu.Lock() + s.m[hash] = codeEntry{code: code, expires: time.Now().Add(ttl)} + s.mu.Unlock() + return nil +} + +func (s *CodeStore) Get(_ context.Context, hash string) (store.PhoneCode, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.m[hash] + if !ok || time.Now().After(e.expires) { + return store.PhoneCode{}, false, nil + } + return e.code, true, nil +} + +func (s *CodeStore) Del(_ context.Context, hash string) error { + s.mu.Lock() + delete(s.m, hash) + s.mu.Unlock() + return nil +} diff --git a/internal/store/memory/bot.go b/internal/store/memory/bot.go new file mode 100644 index 00000000..5d534037 --- /dev/null +++ b/internal/store/memory/bot.go @@ -0,0 +1,769 @@ +package memory + +import ( + "context" + "strconv" + "strings" + "sync" + "time" + + "telesrv/internal/domain" +) + +// BotStore 是 store.BotStore 的内存实现。bot 的 users 行经注入的 UserStore 创建, +// 与 postgres 实现(单事务建 users+bots 两行)保持可见性一致。 +// 内置 BotFather 的 bots 行预置(token 为空 = 不可登录),对齐迁移 0090 种子。 +type BotStore struct { + mu sync.RWMutex + users *UserStore + byID map[int64]domain.BotProfile + states map[[2]int64]domain.BotChatState + permissions map[[2]int64]bool + appsByID map[int64]domain.BotApp + appShortNames map[botAppShortKey]int64 + appSettings map[int64]domain.BotAppSettings + previewMedia map[int64]map[int64]domain.BotAppPreviewMedia + previewSeq int64 + attachMenu map[int64]domain.BotAttachMenuBot + attachStates map[[2]int64]domain.BotAttachMenuState + requestButtons map[requestedButtonKey]domain.BotRequestedWebViewButton + emojiPerms map[[2]int64]bool + customMethod map[string]domain.BotWebViewCustomMethodQuery +} + +type botAppShortKey struct { + botUserID int64 + shortName string +} + +type requestedButtonKey struct { + botUserID int64 + userID int64 + reqID string +} + +// NewBotStore 创建内存 BotStore。 +func NewBotStore(users *UserStore) *BotStore { + s := &BotStore{ + users: users, + byID: make(map[int64]domain.BotProfile), + states: make(map[[2]int64]domain.BotChatState), + permissions: make(map[[2]int64]bool), + appsByID: make(map[int64]domain.BotApp), + appShortNames: make(map[botAppShortKey]int64), + appSettings: make(map[int64]domain.BotAppSettings), + previewMedia: make(map[int64]map[int64]domain.BotAppPreviewMedia), + attachMenu: make(map[int64]domain.BotAttachMenuBot), + attachStates: make(map[[2]int64]domain.BotAttachMenuState), + requestButtons: make(map[requestedButtonKey]domain.BotRequestedWebViewButton), + emojiPerms: make(map[[2]int64]bool), + customMethod: make(map[string]domain.BotWebViewCustomMethodQuery), + } + s.byID[domain.BotFatherUserID] = domain.BotProfile{ + BotUserID: domain.BotFatherUserID, + OwnerUserID: domain.BotFatherUserID, + Description: "BotFather is the one bot to rule them all. Use it to create new bot accounts and manage your existing bots.", + Commands: []domain.BotCommand{ + {Command: "newbot", Description: "create a new bot"}, + {Command: "mybots", Description: "list your bots"}, + {Command: "token", Description: "show a bot's token"}, + {Command: "revoke", Description: "revoke a bot's token"}, + {Command: "cancel", Description: "cancel the current operation"}, + {Command: "help", Description: "show help"}, + }, + } + return s +} + +func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profile domain.BotProfile) (domain.User, domain.BotProfile, error) { + user.Phone = "" + user.Username = strings.TrimSpace(strings.TrimPrefix(user.Username, "@")) + user.Bot = true + if user.BotInfoVersion < 1 { + user.BotInfoVersion = 1 + } + // 持 BotStore.mu 跨「复核计数 → 建 users 行 → 写 profile」,与 postgres 的 + // advisory lock + 事务内复核对齐,封死 count-then-insert TOCTOU。锁顺序 + // BotStore.mu → UserStore.mu(UserStore 不反向引用 BotStore,无死锁)。 + s.mu.Lock() + defer s.mu.Unlock() + owned := 0 + for _, p := range s.byID { + if p.OwnerUserID == profile.OwnerUserID && p.BotUserID != p.OwnerUserID { + owned++ + } + } + if owned >= domain.MaxBotsPerOwner { + return domain.User{}, domain.BotProfile{}, domain.ErrBotsTooMany + } + created, err := s.users.Create(ctx, user) + if err != nil { + return domain.User{}, domain.BotProfile{}, err + } + profile.BotUserID = created.ID + s.byID[created.ID] = cloneBotProfile(profile) + return created, profile, nil +} + +func (s *BotStore) GetBot(_ context.Context, botUserID int64) (domain.BotProfile, bool, error) { + s.mu.RLock() + p, ok := s.byID[botUserID] + if ok { + p = s.enrichBotProfileLocked(p) + } + s.mu.RUnlock() + if !ok { + return domain.BotProfile{}, false, nil + } + return cloneBotProfile(p), true, nil +} + +func (s *BotStore) GetBots(_ context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) { + if len(botUserIDs) == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[int64]domain.BotProfile, len(botUserIDs)) + for _, id := range botUserIDs { + if id == 0 { + continue + } + if _, ok := out[id]; ok { + continue + } + if p, ok := s.byID[id]; ok { + out[id] = cloneBotProfile(s.enrichBotProfileLocked(p)) + } + } + return out, nil +} + +func (s *BotStore) ListBotsByOwner(_ context.Context, ownerUserID int64) ([]domain.BotProfile, error) { + if ownerUserID == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.BotProfile, 0) + for _, p := range s.byID { + if p.OwnerUserID == ownerUserID && p.BotUserID != p.OwnerUserID { + out = append(out, cloneBotProfile(s.enrichBotProfileLocked(p))) + } + } + sortBotProfiles(out) + return out, nil +} + +func (s *BotStore) CountBotsByOwner(_ context.Context, ownerUserID int64) (int, error) { + if ownerUserID == 0 { + return 0, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + n := 0 + for _, p := range s.byID { + if p.OwnerUserID == ownerUserID && p.BotUserID != p.OwnerUserID { + n++ + } + } + return n, nil +} + +func (s *BotStore) UpdateBotTokenSecret(_ context.Context, botUserID int64, secret string) error { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.byID[botUserID] + if !ok { + return domain.ErrBotNotFound + } + p.TokenSecret = secret + s.byID[botUserID] = p + return nil +} + +// editProfile 在 BotStore.mu 内改 bots 行字段,随后 bump users 的 bot_info_version。 +// 与 postgres 不同(非单事务),但 memory 仅测试替身、元数据更新低频,可接受。 +func (s *BotStore) editProfile(botUserID int64, fn func(p *domain.BotProfile)) (int, error) { + s.mu.Lock() + p, ok := s.byID[botUserID] + if !ok { + s.mu.Unlock() + return 0, domain.ErrBotNotFound + } + fn(&p) + s.byID[botUserID] = p + s.mu.Unlock() + ver, ok := s.users.bumpBotInfoVersion(botUserID) + if !ok { + return 0, domain.ErrBotNotFound + } + return ver, nil +} + +func (s *BotStore) UpdateBotCommands(_ context.Context, botUserID int64, commands []domain.BotCommand) (int, error) { + return s.editProfile(botUserID, func(p *domain.BotProfile) { + p.Commands = append([]domain.BotCommand(nil), commands...) + }) +} + +func (s *BotStore) UpdateBotInfo(_ context.Context, botUserID int64, upd domain.BotInfoUpdate) (int, error) { + if _, ok := s.GetBotProfile(botUserID); !ok { + return 0, domain.ErrBotNotFound + } + if upd.SetName || upd.SetAbout { + if !s.users.updateBotProfile(botUserID, upd.SetName, upd.Name, upd.SetAbout, upd.About) { + return 0, domain.ErrBotNotFound + } + } + return s.editProfile(botUserID, func(p *domain.BotProfile) { + if upd.SetDescription { + p.Description = upd.Description + } + }) +} + +func (s *BotStore) UpdateBotMenuButton(_ context.Context, botUserID int64, button domain.BotMenuButton) (int, error) { + return s.editProfile(botUserID, func(p *domain.BotProfile) { + p.MenuButton = button + }) +} + +func (s *BotStore) SetBotInlinePlaceholder(_ context.Context, botUserID int64, placeholder string) (int, error) { + return s.editProfile(botUserID, func(p *domain.BotProfile) { + p.InlinePlaceholder = placeholder + }) +} + +func (s *BotStore) SetBotInlineGeo(_ context.Context, botUserID int64, inlineGeo bool) (int, error) { + return s.editProfile(botUserID, func(p *domain.BotProfile) { p.InlineGeo = inlineGeo }) +} + +func (s *BotStore) SetBotNochats(_ context.Context, botUserID int64, nochats bool) (int, error) { + return s.editProfile(botUserID, func(p *domain.BotProfile) { p.Nochats = nochats }) +} + +func (s *BotStore) SetBotChatHistory(_ context.Context, botUserID int64, chatHistory bool) (int, error) { + return s.editProfile(botUserID, func(p *domain.BotProfile) { p.ChatHistory = chatHistory }) +} + +func (s *BotStore) CanBotSendMessage(_ context.Context, botUserID, userID int64) (bool, error) { + if botUserID == 0 || userID == 0 || botUserID == userID { + return false, nil + } + s.mu.RLock() + allowed := s.permissions[[2]int64{botUserID, userID}] + s.mu.RUnlock() + return allowed, nil +} + +func (s *BotStore) AllowBotSendMessage(_ context.Context, botUserID, userID int64, _ bool) (bool, error) { + if botUserID == 0 || userID == 0 || botUserID == userID { + return false, domain.ErrBotNotFound + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[botUserID]; !ok { + return false, domain.ErrBotNotFound + } + key := [2]int64{botUserID, userID} + if s.permissions[key] { + return false, nil + } + s.permissions[key] = true + return true, nil +} + +func (s *BotStore) UpsertBotApp(_ context.Context, app domain.BotApp) (domain.BotApp, int, error) { + if app.BotUserID == 0 || app.ID == 0 || app.AccessHash == 0 || app.ShortName == "" || app.URL == "" { + return domain.BotApp{}, 0, domain.ErrBotAppInvalid + } + app.ShortName = strings.ToLower(strings.TrimSpace(app.ShortName)) + s.mu.Lock() + if _, ok := s.byID[app.BotUserID]; !ok { + s.mu.Unlock() + return domain.BotApp{}, 0, domain.ErrBotNotFound + } + if existing, ok := s.appShortNames[botAppShortKey{botUserID: app.BotUserID, shortName: app.ShortName}]; ok && existing != app.ID { + s.mu.Unlock() + return domain.BotApp{}, 0, domain.ErrBotAppInvalid + } + if old, ok := s.appsByID[app.ID]; ok && old.BotUserID != app.BotUserID { + s.mu.Unlock() + return domain.BotApp{}, 0, domain.ErrBotAppInvalid + } + s.appsByID[app.ID] = app + s.appShortNames[botAppShortKey{botUserID: app.BotUserID, shortName: app.ShortName}] = app.ID + p := s.byID[app.BotUserID] + if app.Main { + for id, item := range s.appsByID { + if item.BotUserID == app.BotUserID && id != app.ID && item.Main { + item.Main = false + s.appsByID[id] = item + } + } + p.HasMainApp = true + } + if app.HasSettings { + p.HasMainApp = p.HasMainApp || app.Main + } + s.byID[app.BotUserID] = p + s.mu.Unlock() + ver, ok := s.users.bumpBotInfoVersion(app.BotUserID) + if !ok { + return domain.BotApp{}, 0, domain.ErrBotNotFound + } + return cloneBotApp(app), ver, nil +} + +func (s *BotStore) GetBotAppByID(_ context.Context, appID, accessHash int64) (domain.BotApp, bool, error) { + if appID == 0 || accessHash == 0 { + return domain.BotApp{}, false, nil + } + s.mu.RLock() + app, ok := s.appsByID[appID] + s.mu.RUnlock() + if !ok || app.AccessHash != accessHash { + return domain.BotApp{}, false, nil + } + return cloneBotApp(app), true, nil +} + +func (s *BotStore) GetBotAppByShortName(_ context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) { + key := botAppShortKey{botUserID: botUserID, shortName: strings.ToLower(strings.TrimSpace(shortName))} + s.mu.RLock() + id, ok := s.appShortNames[key] + var app domain.BotApp + if ok { + app, ok = s.appsByID[id] + } + s.mu.RUnlock() + if !ok { + return domain.BotApp{}, false, nil + } + return cloneBotApp(app), true, nil +} + +func (s *BotStore) GetMainBotApp(_ context.Context, botUserID int64) (domain.BotApp, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, app := range s.appsByID { + if app.BotUserID == botUserID && app.Main { + return cloneBotApp(app), true, nil + } + } + return domain.BotApp{}, false, nil +} + +func (s *BotStore) ListBotApps(_ context.Context, botUserID int64) ([]domain.BotApp, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.BotApp, 0) + for _, app := range s.appsByID { + if app.BotUserID == botUserID { + out = append(out, cloneBotApp(app)) + } + } + sortBotApps(out) + return out, nil +} + +func (s *BotStore) GetBotAppSettings(_ context.Context, botUserID int64) (domain.BotAppSettings, bool, error) { + s.mu.RLock() + settings, ok := s.appSettings[botUserID] + s.mu.RUnlock() + if !ok { + return domain.BotAppSettings{}, false, nil + } + return cloneBotAppSettings(settings), true, nil +} + +func (s *BotStore) UpsertBotAppSettings(_ context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) { + s.mu.Lock() + if _, ok := s.byID[botUserID]; !ok { + s.mu.Unlock() + return 0, domain.ErrBotNotFound + } + s.appSettings[botUserID] = cloneBotAppSettings(settings) + p := s.byID[botUserID] + c := cloneBotAppSettings(settings) + p.AppSettings = &c + s.byID[botUserID] = p + s.mu.Unlock() + ver, ok := s.users.bumpBotInfoVersion(botUserID) + if !ok { + return 0, domain.ErrBotNotFound + } + return ver, nil +} + +func (s *BotStore) ListBotAppPreviewMedia(_ context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) { + s.mu.RLock() + defer s.mu.RUnlock() + items := s.previewMedia[appID] + out := make([]domain.BotAppPreviewMedia, 0, len(items)) + for _, media := range items { + if media.BotUserID == botUserID { + out = append(out, media) + } + } + sortBotPreviewMedia(out) + return out, nil +} + +func (s *BotStore) UpsertBotAppPreviewMedia(_ context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error) { + if media.BotUserID == 0 || media.AppID == 0 || (media.PhotoID == 0 && media.DocumentID == 0) { + return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid + } + s.mu.Lock() + if app, ok := s.appsByID[media.AppID]; !ok || app.BotUserID != media.BotUserID { + s.mu.Unlock() + return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid + } + if s.previewMedia[media.AppID] == nil { + s.previewMedia[media.AppID] = make(map[int64]domain.BotAppPreviewMedia) + } + if media.ID == 0 { + s.previewSeq++ + media.ID = s.previewSeq + } + if media.Position <= 0 { + media.Position = len(s.previewMedia[media.AppID]) + 1 + } + s.previewMedia[media.AppID][media.ID] = media + p := s.byID[media.BotUserID] + p.HasPreviewMedias = true + s.byID[media.BotUserID] = p + s.mu.Unlock() + ver, ok := s.users.bumpBotInfoVersion(media.BotUserID) + if !ok { + return domain.BotAppPreviewMedia{}, 0, domain.ErrBotNotFound + } + return media, ver, nil +} + +func (s *BotStore) DeleteBotAppPreviewMedia(_ context.Context, botUserID, appID, mediaID int64) (int, error) { + s.mu.Lock() + items := s.previewMedia[appID] + if mediaID == 0 || items == nil { + s.mu.Unlock() + return 0, domain.ErrBotAppInvalid + } + media, ok := items[mediaID] + if !ok || media.BotUserID != botUserID { + s.mu.Unlock() + return 0, domain.ErrBotAppInvalid + } + delete(items, mediaID) + has := false + for _, items := range s.previewMedia { + for _, media := range items { + if media.BotUserID == botUserID { + has = true + break + } + } + } + p := s.byID[botUserID] + p.HasPreviewMedias = has + s.byID[botUserID] = p + s.mu.Unlock() + ver, ok := s.users.bumpBotInfoVersion(botUserID) + if !ok { + return 0, domain.ErrBotNotFound + } + return ver, nil +} + +func (s *BotStore) ReorderBotAppPreviewMedia(_ context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) { + s.mu.Lock() + items := s.previewMedia[appID] + if items == nil { + s.mu.Unlock() + return 0, domain.ErrBotAppInvalid + } + for pos, id := range mediaIDs { + media, ok := items[id] + if !ok || media.BotUserID != botUserID { + s.mu.Unlock() + return 0, domain.ErrBotAppInvalid + } + media.Position = pos + 1 + items[id] = media + } + s.mu.Unlock() + ver, ok := s.users.bumpBotInfoVersion(botUserID) + if !ok { + return 0, domain.ErrBotNotFound + } + return ver, nil +} + +func (s *BotStore) UpsertAttachMenuBot(_ context.Context, bot domain.BotAttachMenuBot) (int, error) { + if bot.BotUserID == 0 || bot.ShortName == "" { + return 0, domain.ErrBotAttachMenuInvalid + } + bot.ShortName = strings.ToLower(strings.TrimSpace(bot.ShortName)) + s.mu.Lock() + if _, ok := s.byID[bot.BotUserID]; !ok { + s.mu.Unlock() + return 0, domain.ErrBotNotFound + } + s.attachMenu[bot.BotUserID] = cloneAttachMenuBot(bot) + p := s.byID[bot.BotUserID] + p.HasAttachMenu = true + s.byID[bot.BotUserID] = p + s.mu.Unlock() + ver, ok := s.users.bumpBotInfoVersion(bot.BotUserID) + if !ok { + return 0, domain.ErrBotNotFound + } + return ver, nil +} + +func (s *BotStore) GetAttachMenuBot(_ context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) { + s.mu.RLock() + bot, ok := s.attachMenu[botUserID] + s.mu.RUnlock() + if !ok { + return domain.BotAttachMenuBot{}, false, nil + } + return cloneAttachMenuBot(bot), true, nil +} + +func (s *BotStore) ListAttachMenuBots(_ context.Context) ([]domain.BotAttachMenuBot, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.BotAttachMenuBot, 0, len(s.attachMenu)) + for _, bot := range s.attachMenu { + out = append(out, cloneAttachMenuBot(bot)) + } + sortAttachMenuBots(out) + return out, nil +} + +func (s *BotStore) GetAttachMenuState(_ context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) { + s.mu.RLock() + state, ok := s.attachStates[[2]int64{userID, botUserID}] + s.mu.RUnlock() + if !ok { + return domain.BotAttachMenuState{}, false, nil + } + return state, true, nil +} + +func (s *BotStore) SetAttachMenuState(_ context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) { + if state.UserID == 0 || state.BotUserID == 0 || state.UserID == state.BotUserID { + return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid + } + s.mu.Lock() + if _, ok := s.attachMenu[state.BotUserID]; !ok { + s.mu.Unlock() + return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid + } + s.attachStates[[2]int64{state.UserID, state.BotUserID}] = state + s.mu.Unlock() + return state, nil +} + +func (s *BotStore) SaveRequestedWebViewButton(_ context.Context, button domain.BotRequestedWebViewButton) error { + if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ButtonID == 0 { + return domain.ErrBotRequestedButtonInvalid + } + s.mu.Lock() + s.requestButtons[requestedButtonKey{botUserID: button.BotUserID, userID: button.UserID, reqID: button.WebAppReqID}] = button + s.mu.Unlock() + return nil +} + +func (s *BotStore) GetRequestedWebViewButton(_ context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) { + key := requestedButtonKey{botUserID: botUserID, userID: userID, reqID: webAppReqID} + s.mu.Lock() + button, ok := s.requestButtons[key] + if ok && !button.ExpiresAt.IsZero() && !button.ExpiresAt.After(time.Now()) { + delete(s.requestButtons, key) + ok = false + } + s.mu.Unlock() + if !ok { + return domain.BotRequestedWebViewButton{}, false, nil + } + return button, true, nil +} + +func (s *BotStore) DeleteRequestedWebViewButton(_ context.Context, botUserID, userID int64, webAppReqID string) error { + s.mu.Lock() + delete(s.requestButtons, requestedButtonKey{botUserID: botUserID, userID: userID, reqID: webAppReqID}) + s.mu.Unlock() + return nil +} + +func (s *BotStore) SetBotEmojiStatusPermission(_ context.Context, botUserID, userID int64, allowed bool) error { + if botUserID == 0 || userID == 0 || botUserID == userID { + return domain.ErrBotNotFound + } + s.mu.Lock() + if _, ok := s.byID[botUserID]; !ok { + s.mu.Unlock() + return domain.ErrBotNotFound + } + if allowed { + s.emojiPerms[[2]int64{botUserID, userID}] = true + } else { + delete(s.emojiPerms, [2]int64{botUserID, userID}) + } + s.mu.Unlock() + return nil +} + +func (s *BotStore) BotEmojiStatusPermission(_ context.Context, botUserID, userID int64) (bool, error) { + s.mu.RLock() + allowed := s.emojiPerms[[2]int64{botUserID, userID}] + s.mu.RUnlock() + return allowed, nil +} + +func (s *BotStore) PutWebViewCustomMethodQuery(_ context.Context, query domain.BotWebViewCustomMethodQuery) error { + if query.ID == "" || query.BotUserID == 0 || query.UserID == 0 || query.CustomMethod == "" { + return domain.ErrBotCustomMethodUnavailable + } + s.mu.Lock() + s.customMethod[query.ID] = query + s.mu.Unlock() + return nil +} + +// GetBotProfile 是 GetBot 的同步只读快照(editProfile 前置存在性检查用)。 +func (s *BotStore) GetBotProfile(botUserID int64) (domain.BotProfile, bool) { + s.mu.RLock() + p, ok := s.byID[botUserID] + s.mu.RUnlock() + return p, ok +} + +func (s *BotStore) GetBotChatState(_ context.Context, botUserID, userID int64) (domain.BotChatState, bool, error) { + s.mu.RLock() + state, ok := s.states[[2]int64{botUserID, userID}] + s.mu.RUnlock() + if !ok { + return domain.BotChatState{}, false, nil + } + return cloneBotChatState(state), true, nil +} + +func (s *BotStore) UpsertBotChatState(_ context.Context, state domain.BotChatState) error { + s.mu.Lock() + s.states[[2]int64{state.BotUserID, state.UserID}] = cloneBotChatState(state) + s.mu.Unlock() + return nil +} + +func (s *BotStore) DeleteBotChatState(_ context.Context, botUserID, userID int64) error { + s.mu.Lock() + delete(s.states, [2]int64{botUserID, userID}) + s.mu.Unlock() + return nil +} + +func cloneBotProfile(p domain.BotProfile) domain.BotProfile { + out := p + out.Commands = append([]domain.BotCommand(nil), p.Commands...) + if p.AppSettings != nil { + settings := cloneBotAppSettings(*p.AppSettings) + out.AppSettings = &settings + } + return out +} + +func (s *BotStore) enrichBotProfileLocked(p domain.BotProfile) domain.BotProfile { + if _, ok := s.appSettings[p.BotUserID]; ok { + settings := cloneBotAppSettings(s.appSettings[p.BotUserID]) + p.AppSettings = &settings + } + for _, app := range s.appsByID { + if app.BotUserID == p.BotUserID && app.Main { + p.HasMainApp = true + break + } + } + if _, ok := s.attachMenu[p.BotUserID]; ok { + p.HasAttachMenu = true + } + for _, items := range s.previewMedia { + for _, media := range items { + if media.BotUserID == p.BotUserID { + p.HasPreviewMedias = true + return p + } + } + } + return p +} + +func cloneBotApp(app domain.BotApp) domain.BotApp { + return app +} + +func cloneBotAppSettings(settings domain.BotAppSettings) domain.BotAppSettings { + out := settings + out.PlaceholderPath = append([]byte(nil), settings.PlaceholderPath...) + return out +} + +func cloneAttachMenuBot(bot domain.BotAttachMenuBot) domain.BotAttachMenuBot { + out := bot + out.PeerTypes = append([]string(nil), bot.PeerTypes...) + out.Icons = make([]domain.BotAttachMenuIcon, len(bot.Icons)) + for i, icon := range bot.Icons { + out.Icons[i] = icon + out.Icons[i].Colors = append([]domain.BotAttachMenuIconColor(nil), icon.Colors...) + } + return out +} + +func cloneBotChatState(state domain.BotChatState) domain.BotChatState { + out := state + if state.Draft != nil { + out.Draft = make(map[string]string, len(state.Draft)) + for k, v := range state.Draft { + out.Draft[k] = v + } + } + return out +} + +func sortBotProfiles(list []domain.BotProfile) { + for i := 1; i < len(list); i++ { + for j := i; j > 0 && list[j].BotUserID < list[j-1].BotUserID; j-- { + list[j], list[j-1] = list[j-1], list[j] + } + } +} + +func sortBotApps(list []domain.BotApp) { + for i := 1; i < len(list); i++ { + for j := i; j > 0 && appSortKey(list[j]) < appSortKey(list[j-1]); j-- { + list[j], list[j-1] = list[j-1], list[j] + } + } +} + +func appSortKey(app domain.BotApp) string { + return strconv.FormatInt(app.BotUserID, 10) + ":" + app.ShortName +} + +func sortBotPreviewMedia(list []domain.BotAppPreviewMedia) { + for i := 1; i < len(list); i++ { + for j := i; j > 0 && (list[j].Position < list[j-1].Position || (list[j].Position == list[j-1].Position && list[j].ID < list[j-1].ID)); j-- { + list[j], list[j-1] = list[j-1], list[j] + } + } +} + +func sortAttachMenuBots(list []domain.BotAttachMenuBot) { + for i := 1; i < len(list); i++ { + for j := i; j > 0 && list[j].BotUserID < list[j-1].BotUserID; j-- { + list[j], list[j-1] = list[j-1], list[j] + } + } +} diff --git a/internal/store/memory/business.go b/internal/store/memory/business.go new file mode 100644 index 00000000..f24eb4df --- /dev/null +++ b/internal/store/memory/business.go @@ -0,0 +1,593 @@ +package memory + +import ( + "context" + "hash/fnv" + "sort" + "strings" + + "telesrv/internal/domain" +) + +func (s *PasswordStore) GetBusinessProfile(_ context.Context, userID int64) (domain.BusinessProfile, bool, error) { + s.mu.RLock() + profile, ok := s.businessProfiles[userID] + s.mu.RUnlock() + return cloneBusinessProfile(profile), ok, nil +} + +func (s *PasswordStore) SaveBusinessProfile(_ context.Context, profile domain.BusinessProfile) error { + if profile.UserID == 0 { + return domain.ErrBusinessProfileInvalid + } + s.mu.Lock() + s.businessProfiles[profile.UserID] = cloneBusinessProfile(profile) + s.mu.Unlock() + return nil +} + +func (s *PasswordStore) ListBusinessChatLinks(_ context.Context, ownerUserID int64) ([]domain.BusinessChatLink, error) { + s.mu.RLock() + slugs := append([]string(nil), s.businessChatLinkSlugs[ownerUserID]...) + out := make([]domain.BusinessChatLink, 0, len(slugs)) + for _, slug := range slugs { + if link, ok := s.businessChatLinks[slug]; ok { + out = append(out, cloneBusinessChatLink(link)) + } + } + s.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt == out[j].CreatedAt { + return out[i].Slug < out[j].Slug + } + return out[i].CreatedAt < out[j].CreatedAt + }) + return out, nil +} + +func (s *PasswordStore) CreateBusinessChatLink(_ context.Context, link domain.BusinessChatLink) (domain.BusinessChatLink, error) { + if link.OwnerUserID == 0 || link.Slug == "" || link.Link == "" || link.Message == "" { + return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.businessChatLinks[link.Slug]; exists { + return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkInvalid + } + if len(s.businessChatLinkSlugs[link.OwnerUserID]) >= domain.MaxBusinessChatLinks { + return domain.BusinessChatLink{}, domain.ErrBusinessChatLinksTooMuch + } + link = cloneBusinessChatLink(link) + s.businessChatLinks[link.Slug] = link + s.businessChatLinkSlugs[link.OwnerUserID] = append(s.businessChatLinkSlugs[link.OwnerUserID], link.Slug) + return cloneBusinessChatLink(link), nil +} + +func (s *PasswordStore) UpdateBusinessChatLink(_ context.Context, ownerUserID int64, slug string, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) { + s.mu.Lock() + defer s.mu.Unlock() + link, ok := s.businessChatLinks[slug] + if !ok || link.OwnerUserID != ownerUserID { + return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkNotFound + } + link.Message = input.Message + link.Entities = append([]domain.MessageEntity(nil), input.Entities...) + link.Title = input.Title + s.businessChatLinks[slug] = cloneBusinessChatLink(link) + return cloneBusinessChatLink(link), nil +} + +func (s *PasswordStore) DeleteBusinessChatLink(_ context.Context, ownerUserID int64, slug string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + link, ok := s.businessChatLinks[slug] + if !ok || link.OwnerUserID != ownerUserID { + return false, nil + } + delete(s.businessChatLinks, slug) + slugs := s.businessChatLinkSlugs[ownerUserID] + next := slugs[:0] + for _, item := range slugs { + if item != slug { + next = append(next, item) + } + } + s.businessChatLinkSlugs[ownerUserID] = next + return true, nil +} + +func (s *PasswordStore) ResolveBusinessChatLink(_ context.Context, slug string, bumpViews bool) (domain.BusinessChatLink, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + link, ok := s.businessChatLinks[slug] + if !ok { + return domain.BusinessChatLink{}, false, nil + } + if bumpViews { + link.Views++ + s.businessChatLinks[slug] = link + } + return cloneBusinessChatLink(link), true, nil +} + +func (s *PasswordStore) ListQuickReplies(_ context.Context, ownerUserID int64, includeTopMessages bool) (domain.QuickReplyList, error) { + s.mu.RLock() + out := s.quickReplyListLocked(ownerUserID, includeTopMessages) + s.mu.RUnlock() + return out, nil +} + +func (s *PasswordStore) CheckQuickReplyShortcut(_ context.Context, ownerUserID int64, shortcut string) (bool, error) { + shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut) + if err != nil { + return false, err + } + s.mu.RLock() + _, ok := s.quickReplyByShortcut[ownerUserID][quickReplyShortcutKey(shortcut)] + s.mu.RUnlock() + return !ok, nil +} + +func (s *PasswordStore) SaveQuickReplyText(_ context.Context, ownerUserID int64, shortcut string, msg domain.QuickReplyMessage) (domain.QuickReplyMutation, error) { + shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut) + if err != nil { + return domain.QuickReplyMutation{}, err + } + if msg.Message == "" || len(msg.Entities) > domain.MaxMessageEntityCount { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + s.ensureQuickReplyMapsLocked(ownerUserID) + key := quickReplyShortcutKey(shortcut) + replyID, found := s.quickReplyByShortcut[ownerUserID][key] + created := false + if !found { + if len(s.quickReplies[ownerUserID]) >= domain.MaxQuickReplies { + return domain.QuickReplyMutation{}, domain.ErrQuickRepliesTooMuch + } + replyID = s.nextQuickReplyID[ownerUserID] + 1 + s.nextQuickReplyID[ownerUserID] = replyID + s.quickReplyByShortcut[ownerUserID][key] = replyID + s.quickReplies[ownerUserID][replyID] = domain.QuickReply{ + OwnerUserID: ownerUserID, + ID: replyID, + Shortcut: shortcut, + SortOrder: len(s.quickReplies[ownerUserID]) + 1, + } + s.quickReplyMessages[ownerUserID][replyID] = make(map[int]domain.QuickReplyMessage) + created = true + } + if len(s.quickReplyMessages[ownerUserID][replyID]) >= domain.MaxQuickReplyMessages { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + msg.OwnerUserID = ownerUserID + msg.ShortcutID = replyID + msg.ID = s.nextQuickReplyMessageID[ownerUserID] + 1 + s.nextQuickReplyMessageID[ownerUserID] = msg.ID + msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) + s.quickReplyMessages[ownerUserID][replyID][msg.ID] = msg + s.refreshQuickReplyLocked(ownerUserID, replyID) + reply := cloneQuickReply(s.quickReplies[ownerUserID][replyID]) + kind := domain.QuickReplyMutationMessage + if created { + kind = domain.QuickReplyMutationNew + } + return domain.QuickReplyMutation{ + Kind: kind, + List: s.quickReplyListLocked(ownerUserID, true), + QuickReply: reply, + ShortcutID: replyID, + Message: cloneQuickReplyMessage(msg), + }, nil +} + +func (s *PasswordStore) GetQuickReplyMessages(_ context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMessages, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if _, ok := s.quickReplies[ownerUserID][shortcutID]; !ok { + return domain.QuickReplyMessages{}, domain.ErrShortcutInvalid + } + msgs := s.quickReplyMessages[ownerUserID][shortcutID] + selected := make([]domain.QuickReplyMessage, 0, len(msgs)) + if len(ids) == 0 { + for _, msg := range msgs { + selected = append(selected, cloneQuickReplyMessage(msg)) + } + } else { + for _, id := range ids { + msg, ok := msgs[id] + if !ok { + return domain.QuickReplyMessages{}, domain.ErrShortcutInvalid + } + selected = append(selected, cloneQuickReplyMessage(msg)) + } + } + sortQuickReplyMessages(selected) + return domain.QuickReplyMessages{ + OwnerUserID: ownerUserID, + ShortcutID: shortcutID, + Messages: selected, + Count: len(msgs), + Hash: quickReplyMessagesHash(selected), + }, nil +} + +func (s *PasswordStore) RenameQuickReplyShortcut(_ context.Context, ownerUserID int64, shortcutID int, shortcut string) (domain.QuickReplyMutation, error) { + shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut) + if err != nil { + return domain.QuickReplyMutation{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + reply, ok := s.quickReplies[ownerUserID][shortcutID] + if !ok { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + key := quickReplyShortcutKey(shortcut) + if existingID, exists := s.quickReplyByShortcut[ownerUserID][key]; exists && existingID != shortcutID { + return domain.QuickReplyMutation{}, domain.ErrShortcutOccupied + } + delete(s.quickReplyByShortcut[ownerUserID], quickReplyShortcutKey(reply.Shortcut)) + reply.Shortcut = shortcut + s.quickReplyByShortcut[ownerUserID][key] = shortcutID + s.quickReplies[ownerUserID][shortcutID] = reply + return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationList, List: s.quickReplyListLocked(ownerUserID, true)}, nil +} + +func (s *PasswordStore) ReorderQuickReplies(_ context.Context, ownerUserID int64, order []int) (domain.QuickReplyMutation, error) { + s.mu.Lock() + defer s.mu.Unlock() + replies := s.quickReplies[ownerUserID] + if len(order) != len(replies) { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + seen := make(map[int]struct{}, len(order)) + for i, id := range order { + reply, ok := replies[id] + if !ok { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + if _, dup := seen[id]; dup { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + seen[id] = struct{}{} + reply.SortOrder = i + 1 + replies[id] = reply + } + return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationList, List: s.quickReplyListLocked(ownerUserID, true)}, nil +} + +func (s *PasswordStore) DeleteQuickReplyShortcut(_ context.Context, ownerUserID int64, shortcutID int) (domain.QuickReplyMutation, error) { + s.mu.Lock() + defer s.mu.Unlock() + reply, ok := s.quickReplies[ownerUserID][shortcutID] + if !ok { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + delete(s.quickReplyByShortcut[ownerUserID], quickReplyShortcutKey(reply.Shortcut)) + delete(s.quickReplies[ownerUserID], shortcutID) + delete(s.quickReplyMessages[ownerUserID], shortcutID) + normalizeQuickReplyOrderLocked(s.quickReplies[ownerUserID]) + return domain.QuickReplyMutation{ + Kind: domain.QuickReplyMutationDelete, + List: s.quickReplyListLocked(ownerUserID, true), + ShortcutID: shortcutID, + }, nil +} + +func (s *PasswordStore) DeleteQuickReplyMessages(_ context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMutation, error) { + if len(ids) == 0 { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.quickReplies[ownerUserID][shortcutID]; !ok { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + msgs := s.quickReplyMessages[ownerUserID][shortcutID] + deleted := make([]int, 0, len(ids)) + for _, id := range ids { + if _, ok := msgs[id]; !ok { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + delete(msgs, id) + deleted = append(deleted, id) + } + s.refreshQuickReplyLocked(ownerUserID, shortcutID) + return domain.QuickReplyMutation{ + Kind: domain.QuickReplyMutationIDs, + List: s.quickReplyListLocked(ownerUserID, true), + ShortcutID: shortcutID, + MessageIDs: deleted, + }, nil +} + +func (s *PasswordStore) ReserveBusinessAutomationDelivery(_ context.Context, delivery domain.BusinessAutomationDelivery) (bool, error) { + if delivery.OwnerUserID == 0 || delivery.PeerUserID == 0 || delivery.Kind == "" || delivery.TriggerMessageID == 0 { + return false, domain.ErrBusinessProfileInvalid + } + key := businessAutomationDeliveryKey{ + ownerUserID: delivery.OwnerUserID, + peerUserID: delivery.PeerUserID, + kind: delivery.Kind, + triggerMessageID: delivery.TriggerMessageID, + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.businessDeliveries[key]; ok { + return false, nil + } + s.businessDeliveries[key] = delivery + return true, nil +} + +func (s *PasswordStore) LastBusinessAutomationDelivery(_ context.Context, ownerUserID, peerUserID int64, kind domain.BusinessAutomationKind) (domain.BusinessAutomationDelivery, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + var out domain.BusinessAutomationDelivery + found := false + for _, delivery := range s.businessDeliveries { + if delivery.OwnerUserID != ownerUserID || delivery.PeerUserID != peerUserID || delivery.Kind != kind { + continue + } + if !found || delivery.SentAt > out.SentAt || delivery.SentAt == out.SentAt && delivery.TriggerMessageID > out.TriggerMessageID { + out = delivery + found = true + } + } + return out, found, nil +} + +func (s *PasswordStore) GetConnectedBusinessBot(_ context.Context, ownerUserID int64) (domain.ConnectedBusinessBot, bool, error) { + s.mu.RLock() + bot, ok := s.connectedBusinessBots[ownerUserID] + s.mu.RUnlock() + return cloneConnectedBusinessBot(bot), ok, nil +} + +func (s *PasswordStore) SaveConnectedBusinessBot(_ context.Context, bot domain.ConnectedBusinessBot) (domain.ConnectedBusinessBot, error) { + if bot.OwnerUserID == 0 || bot.BotUserID == 0 { + return domain.ConnectedBusinessBot{}, domain.ErrBotBusinessMissing + } + s.mu.Lock() + if prev, ok := s.connectedBusinessBots[bot.OwnerUserID]; ok && bot.CreatedAtUnix == 0 { + bot.CreatedAtUnix = prev.CreatedAtUnix + } + s.connectedBusinessBots[bot.OwnerUserID] = cloneConnectedBusinessBot(bot) + s.mu.Unlock() + return cloneConnectedBusinessBot(bot), nil +} + +func (s *PasswordStore) DeleteConnectedBusinessBot(_ context.Context, ownerUserID, botUserID int64) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + bot, ok := s.connectedBusinessBots[ownerUserID] + if !ok || bot.BotUserID != botUserID { + return false, nil + } + delete(s.connectedBusinessBots, ownerUserID) + for key := range s.connectedBusinessBotPeerStates { + if key.ownerUserID == ownerUserID { + delete(s.connectedBusinessBotPeerStates, key) + } + } + return true, nil +} + +func (s *PasswordStore) SetConnectedBusinessBotPaused(_ context.Context, ownerUserID, peerUserID int64, paused bool) (domain.ConnectedBusinessBotPeerState, error) { + if ownerUserID == 0 || peerUserID == 0 { + return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing + } + key := connectedBusinessBotPeerKey{ownerUserID: ownerUserID, peerUserID: peerUserID} + s.mu.Lock() + state := s.connectedBusinessBotPeerStates[key] + state.OwnerUserID = ownerUserID + state.PeerUserID = peerUserID + state.Paused = paused + s.connectedBusinessBotPeerStates[key] = state + s.mu.Unlock() + return state, nil +} + +func (s *PasswordStore) DisableConnectedBusinessBotForPeer(_ context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, error) { + if ownerUserID == 0 || peerUserID == 0 { + return domain.ConnectedBusinessBotPeerState{}, domain.ErrBotBusinessMissing + } + key := connectedBusinessBotPeerKey{ownerUserID: ownerUserID, peerUserID: peerUserID} + s.mu.Lock() + state := s.connectedBusinessBotPeerStates[key] + state.OwnerUserID = ownerUserID + state.PeerUserID = peerUserID + state.Paused = false + state.Disabled = true + s.connectedBusinessBotPeerStates[key] = state + s.mu.Unlock() + return state, nil +} + +func (s *PasswordStore) GetConnectedBusinessBotPeerState(_ context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, bool, error) { + key := connectedBusinessBotPeerKey{ownerUserID: ownerUserID, peerUserID: peerUserID} + s.mu.RLock() + state, ok := s.connectedBusinessBotPeerStates[key] + s.mu.RUnlock() + return state, ok, nil +} + +func (s *PasswordStore) ensureQuickReplyMapsLocked(ownerUserID int64) { + if s.quickReplies[ownerUserID] == nil { + s.quickReplies[ownerUserID] = make(map[int]domain.QuickReply) + } + if s.quickReplyByShortcut[ownerUserID] == nil { + s.quickReplyByShortcut[ownerUserID] = make(map[string]int) + } + if s.quickReplyMessages[ownerUserID] == nil { + s.quickReplyMessages[ownerUserID] = make(map[int]map[int]domain.QuickReplyMessage) + } + for id := range s.quickReplies[ownerUserID] { + if s.quickReplyMessages[ownerUserID][id] == nil { + s.quickReplyMessages[ownerUserID][id] = make(map[int]domain.QuickReplyMessage) + } + } +} + +func (s *PasswordStore) refreshQuickReplyLocked(ownerUserID int64, shortcutID int) { + s.ensureQuickReplyMapsLocked(ownerUserID) + reply := s.quickReplies[ownerUserID][shortcutID] + reply.Count = len(s.quickReplyMessages[ownerUserID][shortcutID]) + reply.TopMessage = 0 + for id := range s.quickReplyMessages[ownerUserID][shortcutID] { + if id > reply.TopMessage { + reply.TopMessage = id + } + } + s.quickReplies[ownerUserID][shortcutID] = reply +} + +func (s *PasswordStore) quickReplyListLocked(ownerUserID int64, includeTopMessages bool) domain.QuickReplyList { + replies := make([]domain.QuickReply, 0, len(s.quickReplies[ownerUserID])) + for id := range s.quickReplies[ownerUserID] { + s.refreshQuickReplyLocked(ownerUserID, id) + replies = append(replies, cloneQuickReply(s.quickReplies[ownerUserID][id])) + } + sortQuickReplies(replies) + messages := make([]domain.QuickReplyMessage, 0, len(replies)) + if includeTopMessages { + for _, reply := range replies { + if reply.TopMessage == 0 { + continue + } + if msg, ok := s.quickReplyMessages[ownerUserID][reply.ID][reply.TopMessage]; ok { + messages = append(messages, cloneQuickReplyMessage(msg)) + } + } + sortQuickReplyMessages(messages) + } + return domain.QuickReplyList{ + OwnerUserID: ownerUserID, + QuickReplies: replies, + Messages: messages, + Hash: quickReplyListHash(replies), + } +} + +func normalizeQuickReplyOrderLocked(replies map[int]domain.QuickReply) { + list := make([]domain.QuickReply, 0, len(replies)) + for _, reply := range replies { + list = append(list, reply) + } + sortQuickReplies(list) + for i, reply := range list { + reply.SortOrder = i + 1 + replies[reply.ID] = reply + } +} + +func sortQuickReplies(items []domain.QuickReply) { + sort.Slice(items, func(i, j int) bool { + if items[i].SortOrder == items[j].SortOrder { + return items[i].ID < items[j].ID + } + return items[i].SortOrder < items[j].SortOrder + }) +} + +func sortQuickReplyMessages(items []domain.QuickReplyMessage) { + sort.Slice(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) +} + +func quickReplyShortcutKey(shortcut string) string { + return strings.ToLower(shortcut) +} + +func quickReplyListHash(items []domain.QuickReply) int64 { + h := fnv.New64a() + for _, item := range items { + writeHashInt(h, item.ID) + writeHashString(h, item.Shortcut) + writeHashInt(h, item.TopMessage) + writeHashInt(h, item.Count) + } + return int64(h.Sum64() & 0x7fffffffffffffff) +} + +func quickReplyMessagesHash(items []domain.QuickReplyMessage) int64 { + h := fnv.New64a() + for _, item := range items { + writeHashInt(h, item.ID) + writeHashString(h, item.Message) + writeHashInt(h, item.Date) + } + return int64(h.Sum64() & 0x7fffffffffffffff) +} + +func writeHashInt(h interface{ Write([]byte) (int, error) }, v int) { + _, _ = h.Write([]byte{ + byte(v >> 24), + byte(v >> 16), + byte(v >> 8), + byte(v), + }) +} + +func writeHashString(h interface{ Write([]byte) (int, error) }, v string) { + _, _ = h.Write([]byte(v)) + _, _ = h.Write([]byte{0}) +} + +func cloneBusinessProfile(in domain.BusinessProfile) domain.BusinessProfile { + out := in + if in.WorkHours != nil { + v := *in.WorkHours + v.WeeklyOpen = append([]domain.BusinessWeeklyOpen(nil), in.WorkHours.WeeklyOpen...) + out.WorkHours = &v + } + if in.Location != nil { + v := *in.Location + if in.Location.Geo != nil { + geo := *in.Location.Geo + v.Geo = &geo + } + out.Location = &v + } + if in.Intro != nil { + v := *in.Intro + out.Intro = &v + } + if in.Greeting != nil { + v := *in.Greeting + v.Recipients.Users = append([]int64(nil), in.Greeting.Recipients.Users...) + out.Greeting = &v + } + if in.Away != nil { + v := *in.Away + v.Recipients.Users = append([]int64(nil), in.Away.Recipients.Users...) + out.Away = &v + } + return out +} + +func cloneBusinessChatLink(in domain.BusinessChatLink) domain.BusinessChatLink { + out := in + out.Entities = append([]domain.MessageEntity(nil), in.Entities...) + return out +} + +func cloneQuickReply(in domain.QuickReply) domain.QuickReply { + return in +} + +func cloneQuickReplyMessage(in domain.QuickReplyMessage) domain.QuickReplyMessage { + out := in + out.Entities = append([]domain.MessageEntity(nil), in.Entities...) + return out +} + +func cloneConnectedBusinessBot(in domain.ConnectedBusinessBot) domain.ConnectedBusinessBot { + out := in + out.Recipients.Users = append([]int64(nil), in.Recipients.Users...) + out.Recipients.ExcludeUsers = append([]int64(nil), in.Recipients.ExcludeUsers...) + return out +} diff --git a/internal/store/memory/channel.go b/internal/store/memory/channel.go deleted file mode 100644 index d59d027a..00000000 --- a/internal/store/memory/channel.go +++ /dev/null @@ -1,6865 +0,0 @@ -package memory - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/binary" - "errors" - "sort" - "strconv" - "strings" - "sync" - "time" - - "telesrv/internal/domain" -) - -const firstMemoryChannelID int64 = 2000000000 - -type channelRandomKey struct { - channelID int64 - userID int64 - randomID int64 -} - -// ChannelStore is an in-memory channel/supergroup store for tests and local development. -type ChannelStore struct { - mu sync.RWMutex - nextID int64 - nextHash int64 - channels map[int64]domain.Channel - members map[int64]map[int64]domain.ChannelMember - dialogs map[int64]map[int64]domain.ChannelDialog - topics map[int64]map[int]domain.ChannelForumTopic - messages map[int64][]domain.ChannelMessage - reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction - top map[int64]map[string]domain.TopMessageReaction - recent map[int64]map[string]domain.RecentMessageReaction - savedTags map[int64]map[string]domain.SavedReactionTag - mentions map[int64]map[int64]map[int]int - msgViews map[int64]map[int]int - msgViewers map[int64]map[int]map[int64]struct{} - events map[int64][]domain.ChannelUpdateEvent - adminLogs map[int64][]domain.ChannelAdminLogEvent - invites map[string]domain.ChannelInvite - importers map[int64]map[int64]domain.ChannelInviteImporter - msgSeq map[int64]int - ptsSeq map[int64]int - logSeq map[int64]int64 - randomToID map[channelRandomKey]int -} - -// NewChannelStore creates an in-memory ChannelStore. -func NewChannelStore() *ChannelStore { - return &ChannelStore{ - nextID: firstMemoryChannelID, - nextHash: 900000000000, - channels: make(map[int64]domain.Channel), - members: make(map[int64]map[int64]domain.ChannelMember), - dialogs: make(map[int64]map[int64]domain.ChannelDialog), - topics: make(map[int64]map[int]domain.ChannelForumTopic), - messages: make(map[int64][]domain.ChannelMessage), - reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction), - top: make(map[int64]map[string]domain.TopMessageReaction), - recent: make(map[int64]map[string]domain.RecentMessageReaction), - savedTags: make(map[int64]map[string]domain.SavedReactionTag), - mentions: make(map[int64]map[int64]map[int]int), - msgViews: make(map[int64]map[int]int), - msgViewers: make(map[int64]map[int]map[int64]struct{}), - events: make(map[int64][]domain.ChannelUpdateEvent), - adminLogs: make(map[int64][]domain.ChannelAdminLogEvent), - invites: make(map[string]domain.ChannelInvite), - importers: make(map[int64]map[int64]domain.ChannelInviteImporter), - msgSeq: make(map[int64]int), - ptsSeq: make(map[int64]int), - logSeq: make(map[int64]int64), - randomToID: make(map[channelRandomKey]int), - } -} - -func (s *ChannelStore) CreateChannel(_ context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { - if req.CreatorUserID == 0 || strings.TrimSpace(req.Title) == "" { - return domain.CreateChannelResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - - channelID := s.nextChannelIDLocked() - channel := domain.Channel{ - ID: channelID, - AccessHash: s.nextAccessHashLocked(), - CreatorUserID: req.CreatorUserID, - Title: strings.TrimSpace(req.Title), - About: req.About, - Broadcast: req.Broadcast, - Megagroup: req.Megagroup, - Forum: req.Forum, - ForumTabs: req.ForumTabs, - ParticipantsCount: 1, - AdminsCount: 1, - TTLPeriod: req.TTLPeriod, - Date: req.Date, - } - if !channel.Broadcast && !channel.Megagroup { - channel.Broadcast = true - } - creator := domain.ChannelMember{ - ChannelID: channelID, - UserID: req.CreatorUserID, - Role: domain.ChannelRoleCreator, - Status: domain.ChannelMemberActive, - JoinedAt: req.Date, - AdminRights: domain.ChannelAdminRights{ - ChangeInfo: true, - PostMessages: true, - EditMessages: true, - DeleteMessages: true, - BanUsers: true, - InviteUsers: true, - PinMessages: true, - AddAdmins: true, - ManageCall: true, - }, - } - s.channels[channelID] = channel - s.members[channelID] = map[int64]domain.ChannelMember{creator.UserID: creator} - members := []domain.ChannelMember{creator} - for _, userID := range uniqueNonZero(req.MemberUserIDs, req.CreatorUserID) { - member := domain.ChannelMember{ - ChannelID: channelID, - UserID: userID, - InviterUserID: req.CreatorUserID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberActive, - JoinedAt: req.Date, - } - s.members[channelID][userID] = member - members = append(members, member) - channel.ParticipantsCount++ - } - msg, event := s.appendChannelServiceMessageLocked(channelID, req.CreatorUserID, req.Date, domain.ChannelMessageAction{ - Type: domain.ChannelActionCreate, - Title: channel.Title, - }) - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - s.channels[channelID] = channel - for _, member := range members { - s.upsertChannelDialogLocked(member.UserID, channel, msg, member.UserID == req.CreatorUserID) - } - return domain.CreateChannelResult{ - Channel: channel, - Members: cloneChannelMembers(members), - Message: cloneChannelMessage(msg), - Event: cloneChannelEvent(event), - Recipients: s.activeMemberIDsLocked(channelID, 0, 0), - }, nil -} - -func (s *ChannelStore) GetChannel(_ context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, member, preview, err := s.channelForViewerLocked(viewerUserID, channelID) - if err != nil { - return domain.ChannelView{}, err - } - dialog := s.dialogForUserLocked(viewerUserID, channel) - if preview { - dialog = previewChannelDialog(viewerUserID, channel, member) - } - return domain.ChannelView{ - Channel: cloneChannel(channel), - Self: member, - Dialog: dialog, - }, nil -} - -func (s *ChannelStore) SaveChannelDefaultSendAs(_ context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ChannelView{}, domain.ErrChannelInvalid - } - if req.SendAs != nil && req.SendAs.Type != domain.PeerTypeUser && req.SendAs.Type != domain.PeerTypeChannel { - return domain.ChannelView{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelView{}, err - } - dialog := s.dialogForUserLocked(req.UserID, channel) - if req.SendAs != nil { - p := *req.SendAs - dialog.DefaultSendAs = &p - } else { - dialog.DefaultSendAs = nil - } - if s.dialogs[req.UserID] == nil { - s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) - } - s.dialogs[req.UserID][req.ChannelID] = dialog - member := s.members[req.ChannelID][req.UserID] - return domain.ChannelView{Channel: cloneChannel(channel), Self: member, Dialog: dialog}, nil -} - -func (s *ChannelStore) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, ok := s.channels[channelID] - if !ok || channel.Deleted { - return domain.Channel{}, domain.ErrChannelInvalid - } - return cloneChannel(channel), nil -} - -func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, err := s.channelForMemberLocked(viewerUserID, channelID) - if err != nil { - return domain.ChannelParticipantList{}, err - } - if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { - limit = domain.MaxChannelParticipantsLimit - } - viewer := s.members[channelID][viewerUserID] - if channel.ParticipantsHidden && !isChannelAdmin(viewer) { - switch filter.Kind { - case domain.ChannelParticipantsAdmins: - case domain.ChannelParticipantsBots: - return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil - default: - return domain.ChannelParticipantList{Channel: channel, Count: channel.ParticipantsCount}, nil - } - } - if (filter.Kind == domain.ChannelParticipantsBanned || filter.Kind == domain.ChannelParticipantsKicked) && !isChannelAdmin(viewer) { - return domain.ChannelParticipantList{Channel: channel}, nil - } - query := strings.ToLower(strings.TrimSpace(filter.Query)) - items := make([]domain.ChannelMember, 0, len(s.members[channelID])) - for _, member := range s.members[channelID] { - if !channelParticipantMatchesFilter(member, filter.Kind, query) { - continue - } - items = append(items, member) - } - sort.Slice(items, func(i, j int) bool { - if items[i].Role != items[j].Role { - return channelRoleOrder(items[i].Role) < channelRoleOrder(items[j].Role) - } - return items[i].UserID < items[j].UserID - }) - count := len(items) - if offset < 0 { - offset = 0 - } - if offset > domain.MaxChannelParticipantsOffset { - offset = domain.MaxChannelParticipantsOffset - } - if offset >= len(items) { - items = nil - } else { - end := offset + limit - if end > len(items) { - end = len(items) - } - items = items[offset:end] - } - return domain.ChannelParticipantList{ - Channel: channel, - Participants: cloneChannelMembers(items), - Count: count, - }, nil -} - -func (s *ChannelStore) GetParticipant(_ context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { - s.mu.RLock() - defer s.mu.RUnlock() - if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { - return domain.ChannelMember{}, err - } - member, ok := s.members[channelID][participantUserID] - if !ok { - return domain.ChannelMember{}, domain.ErrChannelPrivate - } - return member, nil -} - -func (s *ChannelStore) InviteToChannel(_ context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) { - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(inviterUserID, channelID) - if err != nil { - return domain.CreateChannelResult{}, err - } - inviter := s.members[channelID][inviterUserID] - if !canInviteToChannel(channel, inviter) { - return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired - } - requested := uniqueNonZero(userIDs, 0) - inviteOne := len(requested) == 1 - canRestoreKicked := canBanChannelUsers(inviter) - added := make([]int64, 0, len(requested)) - members := make([]domain.ChannelMember, 0, len(requested)) - restoredKicked := 0 - for _, userID := range requested { - if existing, ok := s.members[channelID][userID]; ok { - if existing.Status == domain.ChannelMemberActive { - if inviteOne { - return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant - } - continue - } - if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { - if !canRestoreKicked { - if inviteOne { - return domain.CreateChannelResult{}, domain.ErrUserKicked - } - continue - } - if existing.Status == domain.ChannelMemberKicked { - restoredKicked++ - } - } - } - member := domain.ChannelMember{ - ChannelID: channelID, - UserID: userID, - InviterUserID: inviterUserID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberActive, - JoinedAt: date, - AvailableMinID: channelInitialAvailableMinID(channel), - AvailableMinPts: channelInitialAvailableMinPts(channel), - ReadInboxMaxID: channel.TopMessageID, - } - s.members[channelID][userID] = member - members = append(members, member) - added = append(added, userID) - channel.ParticipantsCount++ - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: inviterUserID, - Date: date, - Type: domain.ChannelAdminLogParticipantInvite, - Participant: ptrChannelMember(member), - }) - } - if restoredKicked > 0 { - channel.KickedCount = maxInt(channel.KickedCount-restoredKicked, 0) - } - s.channels[channelID] = channel - var msg domain.ChannelMessage - var event domain.ChannelUpdateEvent - if len(added) > 0 && channel.Megagroup { - msg, event = s.appendChannelServiceMessageLocked(channelID, inviterUserID, date, domain.ChannelMessageAction{ - Type: domain.ChannelActionChatAddUser, - UserIDs: append([]int64(nil), added...), - }) - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - s.channels[channelID] = channel - } - for _, member := range members { - s.upsertChannelDialogLocked(member.UserID, channel, msg, false) - } - return domain.CreateChannelResult{ - Channel: channel, - Members: cloneChannelMembers(members), - Message: cloneChannelMessage(msg), - Event: cloneChannelEvent(event), - Recipients: s.activeMemberIDsLocked(channelID, 0, 0), - }, nil -} - -func (s *ChannelStore) JoinChannel(_ context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { - s.mu.Lock() - defer s.mu.Unlock() - channel, ok := s.channels[channelID] - if !ok || channel.Deleted { - return domain.CreateChannelResult{}, domain.ErrChannelInvalid - } - preJoinTopID := channel.TopMessageID - if existing, ok := s.members[channelID][userID]; ok { - if existing.Status == domain.ChannelMemberActive { - return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant - } - if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { - return domain.CreateChannelResult{}, domain.ErrChannelUserBanned - } - } - if channel.JoinRequest { - if err := s.recordPublicJoinRequestLocked(channel, userID, date); err != nil { - return domain.CreateChannelResult{}, err - } - return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent - } - member := domain.ChannelMember{ - ChannelID: channelID, - UserID: userID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberActive, - JoinedAt: date, - } - if existing, ok := s.members[channelID][userID]; ok { - member = existing - member.Status = domain.ChannelMemberActive - member.LeftAt = 0 - if minID := channelInitialAvailableMinID(channel); minID > member.AvailableMinID { - member.AvailableMinID = minID - member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, minID) - } - member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, preJoinTopID) - if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts { - member.AvailableMinPts = minPts - } - channel.ParticipantsCount++ - } else { - member.AvailableMinID = channelInitialAvailableMinID(channel) - member.AvailableMinPts = channelInitialAvailableMinPts(channel) - member.ReadInboxMaxID = maxInt(member.AvailableMinID, preJoinTopID) - channel.ParticipantsCount++ - } - if s.members[channelID] == nil { - s.members[channelID] = make(map[int64]domain.ChannelMember) - } - s.members[channelID][userID] = member - s.channels[channelID] = channel - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: date, - Type: domain.ChannelAdminLogParticipantJoin, - }) - var msg domain.ChannelMessage - var event domain.ChannelUpdateEvent - if channel.Megagroup { - msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{ - Type: domain.ChannelActionChatJoined, - UserIDs: []int64{userID}, - }) - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - s.channels[channelID] = channel - } - member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) - if msg.ID != 0 && msg.SenderUserID == userID { - member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) - } - s.members[channelID][userID] = member - s.upsertChannelDialogLocked(userID, channel, msg, true) - return domain.CreateChannelResult{ - Channel: channel, - Members: []domain.ChannelMember{member}, - Message: cloneChannelMessage(msg), - Event: cloneChannelEvent(event), - Recipients: s.activeMemberIDsLocked(channelID, 0, 0), - }, nil -} - -func (s *ChannelStore) LeaveChannel(_ context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.CreateChannelResult{}, err - } - member := s.members[channelID][userID] - member.Status = domain.ChannelMemberLeft - member.LeftAt = date - s.members[channelID][userID] = member - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: date, - Type: domain.ChannelAdminLogParticipantLeave, - }) - if channel.ParticipantsCount > 0 { - channel.ParticipantsCount-- - } - var msg domain.ChannelMessage - var event domain.ChannelUpdateEvent - if channel.Megagroup { - msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{ - Type: domain.ChannelActionChatDelete, - UserIDs: []int64{userID}, - }) - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - } - s.channels[channelID] = channel - return domain.CreateChannelResult{ - Channel: channel, - Members: []domain.ChannelMember{member}, - Message: cloneChannelMessage(msg), - Event: cloneChannelEvent(event), - Recipients: append(s.activeMemberIDsLocked(channelID, 0, 0), userID), - }, nil -} - -func (s *ChannelStore) EditChannelTitle(_ context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" { - return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.EditChannelTitleResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canChangeChannelInfo(member) { - return domain.EditChannelTitleResult{}, domain.ErrChannelAdminRequired - } - title := strings.TrimSpace(req.Title) - if channel.Title == title { - return domain.EditChannelTitleResult{}, domain.ErrChannelNotModified - } - prevTitle := channel.Title - channel.Title = title - msg, event := s.appendChannelServiceMessageLocked(req.ChannelID, req.UserID, req.Date, domain.ChannelMessageAction{ - Type: domain.ChannelActionEditTitle, - Title: title, - }) - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - s.channels[req.ChannelID] = channel - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.Date, - Type: domain.ChannelAdminLogChangeTitle, - PrevString: prevTitle, - NewString: title, - }) - s.upsertChannelDialogLocked(req.UserID, channel, msg, true) - return domain.EditChannelTitleResult{ - Channel: channel, - Message: cloneChannelMessage(msg), - Event: cloneChannelEvent(event), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - }, nil -} - -func (s *ChannelStore) EditChannelAbout(_ context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - channel.About = req.About - s.channels[req.ChannelID] = channel - return channel, nil -} - -func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 { - return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.EditChannelAdminResult{}, err - } - actor := s.members[req.ChannelID][req.UserID] - if !canAddChannelAdmins(actor) { - return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired - } - if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) { - return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden - } - previous, ok := s.members[req.ChannelID][req.MemberID] - if !ok { - previous = domain.ChannelMember{ - ChannelID: req.ChannelID, - UserID: req.MemberID, - InviterUserID: req.UserID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberActive, - JoinedAt: req.Date, - AvailableMinID: channelInitialAvailableMinID(channel), - AvailableMinPts: channelInitialAvailableMinPts(channel), - ReadInboxMaxID: channel.TopMessageID, - } - } - if previous.Role == domain.ChannelRoleCreator { - return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator - } - member := previous - member.InviterUserID = req.UserID - member.Status = domain.ChannelMemberActive - member.LeftAt = 0 - if previous.Status != domain.ChannelMemberActive { - if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts { - member.AvailableMinPts = minPts - } - } - member.AdminRights = req.AdminRights - member.Rank = req.Rank - if zeroChannelAdminRights(req.AdminRights) { - member.Role = domain.ChannelRoleMember - member.Rank = "" - } else { - member.Role = domain.ChannelRoleAdmin - } - s.members[req.ChannelID][req.MemberID] = member - logType := domain.ChannelAdminLogParticipantPromote - if member.Role != domain.ChannelRoleAdmin { - logType = domain.ChannelAdminLogParticipantDemote - } - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.Date, - Type: logType, - PrevParticipant: ptrChannelMember(previous), - NewParticipant: ptrChannelMember(member), - }) - s.refreshChannelCountsLocked(req.ChannelID) - channel = s.channels[req.ChannelID] - event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) - if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok { - s.upsertChannelDialogLocked(member.UserID, channel, msg, false) - } - recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) - recipients = append(recipients, req.MemberID) - return domain.EditChannelAdminResult{ - Channel: channel, - Previous: previous, - Participant: member, - Event: event, - Recipients: recipients, - Date: req.Date, - }, nil -} - -func (s *ChannelStore) EditChannelBanned(_ context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 { - return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.EditChannelBannedResult{}, err - } - actor := s.members[req.ChannelID][req.UserID] - if !canBanChannelUsers(actor) { - return domain.EditChannelBannedResult{}, domain.ErrChannelAdminRequired - } - previous, ok := s.members[req.ChannelID][req.Participant.ID] - if !ok { - previous = domain.ChannelMember{ChannelID: req.ChannelID, UserID: req.Participant.ID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberLeft} - } - if previous.Role == domain.ChannelRoleCreator { - return domain.EditChannelBannedResult{}, domain.ErrChannelUserCreator - } - member := previous - member.Role = domain.ChannelRoleMember - member.BannedRights = req.BannedRights - switch { - case req.BannedRights.ViewMessages: - member.InviterUserID = req.UserID - member.Status = domain.ChannelMemberKicked - member.LeftAt = req.Date - case zeroChannelBannedRights(req.BannedRights): - if previous.Status == domain.ChannelMemberActive { - member.Status = domain.ChannelMemberActive - } else { - member.Status = domain.ChannelMemberLeft - } - member.LeftAt = 0 - default: - member.InviterUserID = req.UserID - if previous.Status == domain.ChannelMemberActive { - member.Status = domain.ChannelMemberActive - } else { - member.Status = domain.ChannelMemberBanned - } - } - if member.JoinedAt == 0 && member.Status == domain.ChannelMemberActive { - member.JoinedAt = req.Date - } - s.members[req.ChannelID][req.Participant.ID] = member - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.Date, - Type: adminLogBanType(previous, member), - PrevParticipant: ptrChannelMember(previous), - NewParticipant: ptrChannelMember(member), - }) - s.refreshChannelCountsLocked(req.ChannelID) - channel = s.channels[req.ChannelID] - event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) - if member.Status == domain.ChannelMemberActive { - if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok { - s.upsertChannelDialogLocked(member.UserID, channel, msg, false) - } - } - recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) - recipients = append(recipients, req.Participant.ID) - return domain.EditChannelBannedResult{ - Channel: channel, - Previous: previous, - Participant: member, - Event: event, - Recipients: recipients, - Date: req.Date, - }, nil -} - -func (s *ChannelStore) EditChannelDefaultBannedRights(_ context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.Channel{}, err - } - actor := s.members[req.ChannelID][req.UserID] - if !canBanChannelUsers(actor) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if channel.DefaultBannedRights == req.BannedRights { - return domain.Channel{}, domain.ErrChannelNotModified - } - channel.DefaultBannedRights = req.BannedRights - s.channels[req.ChannelID] = channel - return channel, nil -} - -func (s *ChannelStore) DeleteChannel(_ context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.DeleteChannelResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if member.Role != domain.ChannelRoleCreator { - return domain.DeleteChannelResult{}, domain.ErrChannelAdminRequired - } - recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) - channel.Deleted = true - s.channels[req.ChannelID] = channel - return domain.DeleteChannelResult{Channel: channel, Recipients: recipients}, nil -} - -func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64, username string) (bool, error) { - if userID == 0 || channelID == 0 || strings.TrimSpace(username) == "" { - return false, domain.ErrChannelInvalid - } - s.mu.RLock() - defer s.mu.RUnlock() - if _, err := s.channelForMemberLocked(userID, channelID); err != nil { - return false, err - } - usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) - for id, channel := range s.channels { - if channel.Deleted || channel.Username == "" { - continue - } - if strings.ToLower(channel.Username) == usernameLower && id != channelID { - return false, nil - } - } - return true, nil -} - -func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[req.ChannelID][req.UserID] - if member.Role != domain.ChannelRoleCreator { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) - usernameLower := strings.ToLower(username) - if strings.EqualFold(channel.Username, username) { - return domain.Channel{}, domain.ErrChannelNotModified - } - if usernameLower != "" { - for id, existing := range s.channels { - if existing.Deleted || existing.Username == "" { - continue - } - if strings.ToLower(existing.Username) == usernameLower && id != req.ChannelID { - return domain.Channel{}, domain.ErrUsernameOccupied - } - } - } - prevUsername := channel.Username - channel.Username = username - s.channels[req.ChannelID] = channel - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogChangeUsername, - PrevString: prevUsername, - NewString: username, - }) - return channel, nil -} - -func (s *ChannelStore) ListAdminedPublicChannels(_ context.Context, userID int64) ([]domain.Channel, error) { - if userID == 0 { - return nil, nil - } - s.mu.RLock() - defer s.mu.RUnlock() - out := make([]domain.Channel, 0) - for channelID, members := range s.members { - member := members[userID] - if member.Status != domain.ChannelMemberActive || !isChannelAdmin(member) { - continue - } - channel, ok := s.channels[channelID] - if !ok || channel.Deleted || channel.Username == "" { - continue - } - out = append(out, channel) - } - sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) - if len(out) > domain.MaxAdminedPublicChannels { - out = out[:domain.MaxAdminedPublicChannels] - } - return append([]domain.Channel(nil), out...), nil -} - -func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) { - if viewerUserID == 0 { - return domain.Channel{}, false, domain.ErrChannelInvalid - } - username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) - if username == "" { - return domain.Channel{}, false, nil - } - s.mu.RLock() - defer s.mu.RUnlock() - - for _, channel := range s.channels { - if !publicSearchableChannel(channel) { - continue - } - if strings.ToLower(channel.Username) == username { - return cloneChannel(channel), true, nil - } - } - return domain.Channel{}, false, nil -} - -func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) { - if viewerUserID == 0 { - return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxPublicChannelSearchLimit { - limit = domain.MaxPublicChannelSearchLimit - } - query = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(query, "@"))) - if query == "" { - return domain.PublicChannelSearchResult{}, nil - } - s.mu.RLock() - defer s.mu.RUnlock() - - type item struct { - channel domain.Channel - joined bool - rank int - } - items := make([]item, 0, limit) - for channelID, channel := range s.channels { - rank, ok := publicChannelSearchRank(channel, query) - if !ok { - continue - } - member, joined := s.members[channelID][viewerUserID] - joined = joined && member.Status == domain.ChannelMemberActive - items = append(items, item{ - channel: cloneChannel(channel), - joined: joined, - rank: rank, - }) - } - sort.SliceStable(items, func(i, j int) bool { - if items[i].rank != items[j].rank { - return items[i].rank < items[j].rank - } - if items[i].joined != items[j].joined { - return items[i].joined - } - if items[i].channel.ParticipantsCount != items[j].channel.ParticipantsCount { - return items[i].channel.ParticipantsCount > items[j].channel.ParticipantsCount - } - if items[i].channel.Date != items[j].channel.Date { - return items[i].channel.Date > items[j].channel.Date - } - return items[i].channel.ID > items[j].channel.ID - }) - - out := domain.PublicChannelSearchResult{} - for _, item := range items { - if len(out.MyResults)+len(out.Results) >= limit { - break - } - if item.joined { - out.MyResults = append(out.MyResults, item.channel) - } else { - out.Results = append(out.Results, item.channel) - } - } - return out, nil -} - -func (s *ChannelStore) SetChannelPhoto(_ context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if photo != nil && photo.ID != 0 { - channel.PhotoID = photo.ID - channel.PhotoDCID = photo.DCID - channel.PhotoStripped = domain.StrippedFromSizes(photo.Sizes) - } else { - channel.PhotoID = 0 - channel.PhotoDCID = 0 - channel.PhotoStripped = nil - } - s.channels[channelID] = channel - return channel, nil -} - -func (s *ChannelStore) SetSignatures(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - prev := channel.Signatures - channel.Signatures = enabled - s.channels[channelID] = channel - if prev != enabled { - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogToggleSignatures, - PrevBool: prev, - NewBool: enabled, - }) - } - return channel, nil -} - -func (s *ChannelStore) SetPreHistoryHidden(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if member.Role != domain.ChannelRoleCreator { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - prev := channel.PreHistoryHidden - channel.PreHistoryHidden = enabled - s.channels[channelID] = channel - if prev != enabled { - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogTogglePreHistoryHidden, - PrevBool: prev, - NewBool: enabled, - }) - } - return channel, nil -} - -func (s *ChannelStore) SetParticipantsHidden(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !channel.Megagroup || !canBanChannelUsers(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - channel.ParticipantsHidden = enabled - s.channels[channelID] = channel - return channel, nil -} - -func (s *ChannelStore) SetForum(_ context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !channel.Megagroup || channel.Broadcast { - return domain.Channel{}, domain.ErrChannelNotModified - } - if member.Role != domain.ChannelRoleCreator { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if enabled && channel.LinkedChatID != 0 { - return domain.Channel{}, domain.ErrChatDiscussionUnallowed - } - prevForum := channel.Forum - prevTabs := channel.ForumTabs - channel.Forum = enabled - channel.ForumTabs = enabled && tabs - s.channels[channelID] = channel - if prevForum != channel.Forum || prevTabs != channel.ForumTabs { - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogToggleForum, - PrevBool: prevForum, - NewBool: enabled, - }) - } - return cloneChannel(channel), nil -} - -func (s *ChannelStore) SetAutotranslation(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - prev := channel.Autotranslation - channel.Autotranslation = enabled - s.channels[channelID] = channel - if prev != enabled { - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogToggleAutotranslation, - PrevBool: prev, - NewBool: enabled, - }) - } - return cloneChannel(channel), nil -} - -func (s *ChannelStore) SetRestrictedSponsored(_ context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - channel.RestrictedSponsored = restricted - s.channels[channelID] = channel - return cloneChannel(channel), nil -} - -func (s *ChannelStore) SetPaidMessagesPrice(_ context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 || stars < 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - channel.SendPaidMessagesStars = stars - channel.BroadcastMessagesAllowed = channel.Broadcast && broadcastMessagesAllowed - s.channels[channelID] = channel - return cloneChannel(channel), nil -} - -func (s *ChannelStore) SetAntiSpam(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !channel.Megagroup || !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - prev := channel.AntiSpam - channel.AntiSpam = enabled - s.channels[channelID] = channel - if prev != enabled { - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogToggleAntiSpam, - PrevBool: prev, - NewBool: enabled, - }) - } - return cloneChannel(channel), nil -} - -func (s *ChannelStore) SetSlowMode(_ context.Context, userID, channelID int64, seconds int) (domain.Channel, error) { - if userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - prev := channel.SlowmodeSeconds - channel.SlowmodeSeconds = seconds - s.channels[channelID] = channel - if prev != seconds { - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogToggleSlowMode, - PrevInt: prev, - NewInt: seconds, - }) - } - return channel, nil -} - -func (s *ChannelStore) SetNoForwards(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - channel.NoForwards = enabled - s.channels[channelID] = channel - return channel, nil -} - -func (s *ChannelStore) SetJoinToSend(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !channel.Megagroup || !canExportChannelInvite(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - channel.JoinToSend = enabled - s.channels[channelID] = channel - return channel, nil -} - -func (s *ChannelStore) SetJoinRequest(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !channel.Megagroup || !canExportChannelInvite(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if enabled && strings.TrimSpace(channel.Username) == "" { - return domain.Channel{}, domain.ErrChatPublicRequired - } - channel.JoinRequest = enabled - s.channels[channelID] = channel - return channel, nil -} - -func (s *ChannelStore) SetAvailableReactions(_ context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - channel.ReactionPolicy = copyChannelReactionPolicy(policy) - s.channels[channelID] = channel - return cloneChannel(channel), nil -} - -func (s *ChannelStore) SetColor(_ context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if forProfile { - channel.ProfileColor = color - } else { - channel.Color = color - } - s.channels[channelID] = channel - return cloneChannel(channel), nil -} - -func (s *ChannelStore) SetEmojiStatus(_ context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return domain.Channel{}, err - } - member := s.members[channelID][userID] - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - channel.EmojiStatus = status - s.channels[channelID] = channel - return cloneChannel(channel), nil -} - -func (s *ChannelStore) ListAdminLog(_ context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 { - return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid - } - s.mu.RLock() - defer s.mu.RUnlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelAdminLogResult{}, err - } - if !isChannelAdmin(s.members[req.ChannelID][req.UserID]) { - return domain.ChannelAdminLogResult{}, domain.ErrChannelAdminRequired - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelAdminLogLimit { - limit = domain.MaxChannelAdminLogLimit - } - admins := int64Set(req.AdminUserIDs) - query := strings.ToLower(strings.TrimSpace(req.Query)) - out := make([]domain.ChannelAdminLogEvent, 0, limit) - events := s.adminLogs[req.ChannelID] - for i := len(events) - 1; i >= 0 && len(out) < limit; i-- { - event := events[i] - if req.MaxID > 0 && event.ID >= req.MaxID { - continue - } - if req.MinID > 0 && event.ID <= req.MinID { - continue - } - if len(admins) > 0 { - if _, ok := admins[event.UserID]; !ok { - continue - } - } - if !adminLogEventMatchesFilter(event.Type, req.Filter) { - continue - } - if query != "" && !adminLogEventMatchesQuery(event, query) { - continue - } - out = append(out, cloneChannelAdminLogEvent(event)) - } - return domain.ChannelAdminLogResult{Channel: channel, Events: out}, nil -} - -func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid - } - if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() { - return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.SendChannelMessageResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canSendChannelMessage(channel, member) { - return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden - } - if req.RandomID != 0 { - if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok { - msg, ok := s.findMessageLocked(req.ChannelID, id) - if ok { - event := s.eventForMessageLocked(req.ChannelID, id) - if event.Message.ID != 0 { - msg = event.Message - } - return domain.SendChannelMessageResult{ - Channel: channel, - Message: cloneChannelMessage(msg), - Event: event, - Duplicate: true, - }, nil - } - } - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 { - return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait) - } - replyTo, err := s.resolveChannelReplyLocked(req, member, channel) - if err != nil { - return domain.SendChannelMessageResult{}, err - } - var sendAs *domain.Peer - if req.SendAs != nil { - p := *req.SendAs - sendAs = &p - } - pts := s.nextChannelPtsLocked(req.ChannelID) - msgID := s.nextChannelMessageIDLocked(req.ChannelID) - var discussion *domain.SendChannelDiscussionResult - var discussionRef *domain.ChannelDiscussionRef - if channel.Broadcast && channel.LinkedChatID != 0 { - if linked, ok := s.channels[channel.LinkedChatID]; ok && !linked.Deleted && linked.Megagroup { - discussionPts := s.nextChannelPtsLocked(linked.ID) - discussionMsgID := s.nextChannelMessageIDLocked(linked.ID) - discussionRef = &domain.ChannelDiscussionRef{ChannelID: linked.ID, MessageID: discussionMsgID} - discussionMsg := domain.ChannelMessage{ - ChannelID: linked.ID, - ID: discussionMsgID, - SenderUserID: req.UserID, - From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, - Date: req.Date, - Silent: req.Silent, - NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, - Body: req.Message, - Entities: append([]domain.MessageEntity(nil), req.Entities...), - Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, - Pts: discussionPts, - } - discussionEvent := domain.ChannelUpdateEvent{ - ChannelID: linked.ID, - Type: domain.ChannelUpdateNewMessage, - Pts: discussionPts, - PtsCount: 1, - Date: req.Date, - Message: cloneChannelMessage(discussionMsg), - } - s.messages[linked.ID] = append(s.messages[linked.ID], discussionMsg) - s.events[linked.ID] = append(s.events[linked.ID], discussionEvent) - linked.TopMessageID = discussionMsgID - linked.Pts = discussionPts - s.channels[linked.ID] = linked - s.addChannelUnreadMentionsLocked(linked.ID, discussionMsg, req.UserID, req.MentionUserIDs) - for userID, member := range s.members[linked.ID] { - if member.Status == domain.ChannelMemberActive { - s.upsertChannelDialogLocked(userID, linked, discussionMsg, false) - } - } - discussion = &domain.SendChannelDiscussionResult{ - Channel: cloneChannel(linked), - Message: cloneChannelMessage(discussionMsg), - Event: cloneChannelEvent(discussionEvent), - Recipients: s.activeMemberIDsLocked(linked.ID, 0, 0), - } - } - } - msg := domain.ChannelMessage{ - ChannelID: req.ChannelID, - ID: msgID, - RandomID: req.RandomID, - SenderUserID: req.UserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, - Date: req.Date, - Post: channel.Broadcast, - Silent: req.Silent, - NoForwards: req.NoForwards || channel.NoForwards, - Body: req.Message, - Entities: append([]domain.MessageEntity(nil), req.Entities...), - Media: req.Media, - ReplyTo: replyTo, - Forward: cloneMessageForward(req.Forward), - SendAs: sendAs, - Discussion: discussionRef, - Action: cloneChannelMessageAction(req.Action), - Pts: pts, - } - msg.Replies = s.channelMessageRepliesLocked(req.UserID, req.ChannelID, msg) - event := domain.ChannelUpdateEvent{ - ChannelID: req.ChannelID, - Type: domain.ChannelUpdateNewMessage, - Pts: pts, - PtsCount: 1, - Date: req.Date, - Message: cloneChannelMessage(msg), - SenderUserID: req.UserID, - } - s.messages[req.ChannelID] = append(s.messages[req.ChannelID], msg) - s.events[req.ChannelID] = append(s.events[req.ChannelID], event) - s.addChannelUnreadMentionsLocked(req.ChannelID, msg, req.UserID, req.MentionUserIDs) - s.updateForumTopicTopMessageLocked(req.ChannelID, msg) - if channel.Broadcast { - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.Date, - Type: domain.ChannelAdminLogSendMessage, - Message: ptrChannelMessage(msg), - Query: msg.Body, - }) - } - if req.RandomID != 0 { - s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}] = msg.ID - } - channel.TopMessageID = msg.ID - channel.Pts = pts - s.channels[req.ChannelID] = channel - member.SlowmodeLastSendDate = req.Date - s.members[req.ChannelID][req.UserID] = member - for userID, member := range s.members[req.ChannelID] { - if member.Status == domain.ChannelMemberActive { - s.upsertChannelDialogLocked(userID, channel, msg, userID == req.UserID) - } - } - return domain.SendChannelMessageResult{ - Channel: channel, - Message: cloneChannelMessage(msg), - Event: cloneChannelEvent(event), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - Discussion: discussion, - }, nil -} - -func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || strings.TrimSpace(req.Message) == "" { - return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.EditChannelMessageResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - idx, ok := s.findMessageIndexLocked(req.ChannelID, req.ID) - if !ok || s.messages[req.ChannelID][idx].Deleted || s.messages[req.ChannelID][idx].Action != nil { - return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid - } - prevMsg := s.messages[req.ChannelID][idx] - msg := prevMsg - if msg.SenderUserID != req.UserID && !canEditChannelMessage(member) { - return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired - } - if msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { - return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified - } - pts := s.nextChannelPtsLocked(req.ChannelID) - msg.Body = req.Message - msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) - msg.EditDate = req.EditDate - msg.Pts = pts - s.messages[req.ChannelID][idx] = msg - channel.Pts = pts - s.channels[req.ChannelID] = channel - event := domain.ChannelUpdateEvent{ - ChannelID: req.ChannelID, - Type: domain.ChannelUpdateEditMessage, - Pts: pts, - PtsCount: 1, - Date: req.EditDate, - Message: cloneChannelMessage(msg), - SenderUserID: req.UserID, - } - s.events[req.ChannelID] = append(s.events[req.ChannelID], event) - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.EditDate, - Type: domain.ChannelAdminLogEditMessage, - PrevMessage: ptrChannelMessage(prevMsg), - NewMessage: ptrChannelMessage(msg), - Query: msg.Body, - }) - return domain.EditChannelMessageResult{ - Channel: channel, - Message: cloneChannelMessage(msg), - Event: cloneChannelEvent(event), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - }, nil -} - -func (s *ChannelStore) DeleteChannelMessages(_ context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || len(req.IDs) == 0 { - return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid - } - if len(req.IDs) > domain.MaxDeleteMessageIDs { - return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelMessagesResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, req.IDs, req.UserID, req.Date) - if err != nil { - return domain.DeleteChannelMessagesResult{}, err - } - return domain.DeleteChannelMessagesResult{ - Channel: channel, - Event: cloneChannelEvent(event), - DeletedIDs: append([]int(nil), deleted...), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - }, nil -} - -func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - maxID := req.MaxID - if maxID <= 0 || maxID > channel.TopMessageID { - maxID = channel.TopMessageID - } - member := s.members[req.ChannelID][req.UserID] - if !req.ForEveryone { - appliedMinID := maxInt(member.AvailableMinID, maxID) - member.AvailableMinID = appliedMinID - member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, appliedMinID) - member.UnreadMark = false - s.members[req.ChannelID][req.UserID] = member - s.deleteChannelUnreadMentionsUpToLocked(req.UserID, req.ChannelID, appliedMinID) - if s.dialogs[req.UserID] == nil { - s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) - } - s.dialogs[req.UserID][req.ChannelID] = s.dialogForUserLocked(req.UserID, channel) - return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil - } - if !canDeleteAnyChannelMessage(member) { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired - } - ids := make([]int, 0, domain.MaxDeleteHistoryBatch) - for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- { - msg := s.messages[req.ChannelID][i] - if msg.Deleted || msg.ID > maxID { - continue - } - ids = append(ids, msg.ID) - if len(ids) >= domain.MaxDeleteHistoryBatch { - break - } - } - deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - offset := 0 - if len(deleted) == domain.MaxDeleteHistoryBatch { - offset = 1 - } - return domain.DeleteChannelHistoryResult{ - Channel: channel, - Event: cloneChannelEvent(event), - DeletedIDs: append([]int(nil), deleted...), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - Offset: offset, - }, nil -} - -func (s *ChannelStore) DeleteChannelParticipantHistory(_ context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canDeleteAnyChannelMessage(member) { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired - } - ids := make([]int, 0, domain.MaxDeleteHistoryBatch) - for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- { - msg := s.messages[req.ChannelID][i] - if msg.Deleted || msg.SenderUserID != req.ParticipantUserID { - continue - } - ids = append(ids, msg.ID) - if len(ids) >= domain.MaxDeleteHistoryBatch { - break - } - } - deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - offset := 0 - if len(deleted) == domain.MaxDeleteHistoryBatch { - offset = 1 - } - return domain.DeleteChannelHistoryResult{ - Channel: channel, - Event: cloneChannelEvent(event), - DeletedIDs: append([]int(nil), deleted...), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - Offset: offset, - }, nil -} - -func (s *ChannelStore) UpdatePinnedMessage(_ context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { - return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.UpdateChannelPinnedMessageResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canPinChannelMessages(channel, member) { - return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired - } - msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) - if !ok || msg.Deleted { - return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid - } - pinnedID := 0 - if req.Pinned { - pinnedID = req.MessageID - } - if channel.PinnedMessageID == pinnedID { - return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified - } - pts := s.nextChannelPtsLocked(req.ChannelID) - channel.PinnedMessageID = pinnedID - channel.Pts = pts - s.channels[req.ChannelID] = channel - event := domain.ChannelUpdateEvent{ - ChannelID: req.ChannelID, - Type: domain.ChannelUpdatePinnedMessages, - Pts: pts, - PtsCount: 1, - Date: req.Date, - MessageIDs: []int{req.MessageID}, - SenderUserID: req.UserID, - Pinned: req.Pinned, - } - s.events[req.ChannelID] = append(s.events[req.ChannelID], event) - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.Date, - Type: domain.ChannelAdminLogUpdatePinned, - Message: ptrChannelMessage(msg), - Query: msg.Body, - }) - return domain.UpdateChannelPinnedMessageResult{ - Channel: channel, - Event: cloneChannelEvent(event), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - }, nil -} - -func (s *ChannelStore) ExportInvite(_ context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ExportChannelInviteResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canExportChannelInvite(member) { - return domain.ExportChannelInviteResult{}, domain.ErrChannelAdminRequired - } - if req.LegacyRevokePermanent { - for hash, invite := range s.invites { - if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.UserID && invite.Permanent { - invite.Revoked = true - s.invites[hash] = invite - } - } - } - inviteID, err := randomMemoryPositiveInt64() - if err != nil { - return domain.ExportChannelInviteResult{}, err - } - hash, err := randomMemoryInviteHash() - if err != nil { - return domain.ExportChannelInviteResult{}, err - } - invite := domain.ChannelInvite{ - ChannelID: req.ChannelID, - InviteID: inviteID, - Hash: hash, - AdminUserID: req.UserID, - Title: req.Title, - Permanent: req.ExpireDate == 0 && req.UsageLimit == 0 && !req.RequestNeeded && req.Title == "", - RequestNeeded: req.RequestNeeded, - ExpireDate: req.ExpireDate, - UsageLimit: req.UsageLimit, - Date: req.Date, - } - s.invites[hash] = invite - return domain.ExportChannelInviteResult{Channel: channel, Invite: invite}, nil -} - -func (s *ChannelStore) CheckInvite(_ context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) { - if userID == 0 || strings.TrimSpace(hash) == "" { - return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty - } - s.mu.RLock() - defer s.mu.RUnlock() - invite, ok := s.invites[strings.TrimSpace(hash)] - if !ok || invite.Revoked { - return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid - } - if invite.ExpireDate > 0 && invite.ExpireDate < date { - return domain.CheckChannelInviteResult{}, domain.ErrInviteHashExpired - } - channel, ok := s.channels[invite.ChannelID] - if !ok || channel.Deleted { - return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid - } - member := s.members[invite.ChannelID][userID] - if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages { - return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid - } - return domain.CheckChannelInviteResult{ - Channel: channel, - Invite: invite, - Already: member.Status == domain.ChannelMemberActive, - Self: member, - }, nil -} - -func (s *ChannelStore) ImportInvite(_ context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) { - if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" { - return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty - } - s.mu.Lock() - defer s.mu.Unlock() - invite, ok := s.invites[strings.TrimSpace(req.Hash)] - if !ok || invite.Revoked { - return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid - } - if invite.ExpireDate > 0 && invite.ExpireDate < req.Date { - return domain.CreateChannelResult{}, domain.ErrInviteHashExpired - } - channel, ok := s.channels[invite.ChannelID] - if !ok || channel.Deleted { - return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid - } - if invite.RequestNeeded { - if err := s.recordPendingInviteRequestLocked(invite, req.UserID, req.Date); err != nil { - return domain.CreateChannelResult{}, err - } - return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent - } - return s.approveInviteImporterLocked(channel, invite, req.UserID, 0, req.Date) -} - -func (s *ChannelStore) ListExportedInvites(_ context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { - return domain.ChannelInviteList{}, domain.ErrChannelInvalid - } - s.mu.RLock() - defer s.mu.RUnlock() - if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { - return domain.ChannelInviteList{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canExportChannelInvite(member) { - return domain.ChannelInviteList{}, domain.ErrChannelAdminRequired - } - all := make([]domain.ChannelInvite, 0) - for _, invite := range s.invites { - if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.AdminUserID && invite.Revoked == req.Revoked { - all = append(all, invite) - } - } - sort.Slice(all, func(i, j int) bool { - if all[i].Date != all[j].Date { - return all[i].Date > all[j].Date - } - return all[i].Hash > all[j].Hash - }) - total := len(all) - start := 0 - if req.OffsetDate > 0 || req.OffsetHash != "" { - start = len(all) - for i, invite := range all { - if invite.Date == req.OffsetDate && invite.Hash == req.OffsetHash { - start = i + 1 - break - } - } - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelInviteListLimit { - limit = domain.MaxChannelInviteListLimit - } - if start > len(all) { - start = len(all) - } - end := start + limit - if end > len(all) { - end = len(all) - } - return domain.ChannelInviteList{Count: total, Invites: cloneChannelInvites(all[start:end])}, nil -} - -func (s *ChannelStore) GetExportedInvite(_ context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) { - if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { - return domain.ChannelInvite{}, domain.ErrInviteHashEmpty - } - s.mu.RLock() - defer s.mu.RUnlock() - if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { - return domain.ChannelInvite{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canExportChannelInvite(member) { - return domain.ChannelInvite{}, domain.ErrChannelAdminRequired - } - return s.inviteByChannelHashLocked(req.ChannelID, req.Hash) -} - -func (s *ChannelStore) EditExportedInvite(_ context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { - return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty - } - s.mu.Lock() - defer s.mu.Unlock() - if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { - return domain.EditChannelInviteResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canExportChannelInvite(member) { - return domain.EditChannelInviteResult{}, domain.ErrChannelAdminRequired - } - invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) - if err != nil { - return domain.EditChannelInviteResult{}, err - } - if req.Revoked { - if invite.Revoked { - return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing - } - invite.Revoked = true - s.invites[invite.Hash] = invite - if !invite.Permanent { - return domain.EditChannelInviteResult{Invite: invite}, nil - } - newInvite, err := s.newReplacementInviteLocked(invite, req.Date) - if err != nil { - return domain.EditChannelInviteResult{}, err - } - s.invites[newInvite.Hash] = newInvite - return domain.EditChannelInviteResult{Invite: invite, NewInvite: &newInvite}, nil - } - if invite.Permanent && ((req.HasExpireDate && req.ExpireDate > 0) || (req.HasUsageLimit && req.UsageLimit > 0) || (req.HasRequestNeeded && req.RequestNeeded)) { - return domain.EditChannelInviteResult{}, domain.ErrInvitePermanent - } - if req.HasExpireDate { - invite.ExpireDate = req.ExpireDate - } - if req.HasUsageLimit { - invite.UsageLimit = req.UsageLimit - } - if req.HasRequestNeeded { - invite.RequestNeeded = req.RequestNeeded - } - if req.HasTitle { - invite.Title = req.Title - } - invite.Permanent = invite.ExpireDate == 0 && invite.UsageLimit == 0 && !invite.RequestNeeded && invite.Title == "" - s.invites[invite.Hash] = invite - return domain.EditChannelInviteResult{Invite: invite}, nil -} - -func (s *ChannelStore) DeleteExportedInvite(_ context.Context, req domain.DeleteChannelInviteRequest) error { - if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { - return domain.ErrInviteHashEmpty - } - s.mu.Lock() - defer s.mu.Unlock() - if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { - return err - } - member := s.members[req.ChannelID][req.UserID] - if !canExportChannelInvite(member) { - return domain.ErrChannelAdminRequired - } - invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) - if err != nil { - return err - } - delete(s.invites, invite.Hash) - return nil -} - -func (s *ChannelStore) DeleteRevokedExportedInvites(_ context.Context, req domain.DeleteRevokedChannelInvitesRequest) error { - if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { - return domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { - return err - } - member := s.members[req.ChannelID][req.UserID] - if !canExportChannelInvite(member) { - return domain.ErrChannelAdminRequired - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { - limit = domain.MaxChannelHideJoinRequests - } - deleted := 0 - for hash, invite := range s.invites { - if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.AdminUserID && invite.Revoked { - delete(s.invites, hash) - deleted++ - if deleted >= limit { - break - } - } - } - return nil -} - -func (s *ChannelStore) ListAdminsWithInvites(_ context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) { - if userID == 0 || channelID == 0 { - return nil, domain.ErrChannelInvalid - } - s.mu.RLock() - defer s.mu.RUnlock() - if _, err := s.channelForMemberLocked(userID, channelID); err != nil { - return nil, err - } - member := s.members[channelID][userID] - if !canExportChannelInvite(member) { - return nil, domain.ErrChannelAdminRequired - } - byAdmin := map[int64]*domain.ChannelAdminInviteCount{} - for _, invite := range s.invites { - if invite.ChannelID != channelID { - continue - } - count := byAdmin[invite.AdminUserID] - if count == nil { - count = &domain.ChannelAdminInviteCount{AdminUserID: invite.AdminUserID} - byAdmin[invite.AdminUserID] = count - } - if invite.Revoked { - count.RevokedInvitesCount++ - } else { - count.InvitesCount++ - } - } - out := make([]domain.ChannelAdminInviteCount, 0, len(byAdmin)) - for _, count := range byAdmin { - out = append(out, *count) - } - sort.Slice(out, func(i, j int) bool { return out[i].AdminUserID < out[j].AdminUserID }) - return out, nil -} - -func (s *ChannelStore) ListInviteImporters(_ context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid - } - s.mu.RLock() - defer s.mu.RUnlock() - if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { - return domain.ChannelInviteImporterList{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canExportChannelInvite(member) { - return domain.ChannelInviteImporterList{}, domain.ErrChannelAdminRequired - } - var inviteID int64 - if req.Hash != "" { - invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) - if err != nil { - return domain.ChannelInviteImporterList{}, err - } - inviteID = invite.InviteID - } - if req.Query != "" { - return domain.ChannelInviteImporterList{}, nil - } - all := make([]domain.ChannelInviteImporter, 0) - for _, importer := range s.importers[req.ChannelID] { - if importer.Requested != req.Requested { - continue - } - if inviteID != 0 && importer.InviteID != inviteID { - continue - } - all = append(all, importer) - } - sort.Slice(all, func(i, j int) bool { - if all[i].Date != all[j].Date { - return all[i].Date > all[j].Date - } - return all[i].UserID > all[j].UserID - }) - total := len(all) - start := 0 - if req.OffsetDate > 0 || req.OffsetUserID != 0 { - start = len(all) - for i, importer := range all { - if importer.Date == req.OffsetDate && importer.UserID == req.OffsetUserID { - start = i + 1 - break - } - } - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelInviteListLimit { - limit = domain.MaxChannelInviteListLimit - } - if start > len(all) { - start = len(all) - } - end := start + limit - if end > len(all) { - end = len(all) - } - return domain.ChannelInviteImporterList{Count: total, Importers: cloneChannelInviteImporters(all[start:end])}, nil -} - -func (s *ChannelStore) PendingJoinRequests(_ context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) { - if channelID == 0 { - return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid - } - s.mu.RLock() - defer s.mu.RUnlock() - channel, ok := s.channels[channelID] - if !ok || channel.Deleted { - return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid - } - all := make([]domain.ChannelInviteImporter, 0) - for _, importer := range s.importers[channelID] { - if importer.Requested { - all = append(all, importer) - } - } - sort.Slice(all, func(i, j int) bool { - if all[i].Date != all[j].Date { - return all[i].Date > all[j].Date - } - return all[i].UserID > all[j].UserID - }) - if limit <= 0 || limit > domain.MaxChannelPendingJoinRecentRequesters { - limit = domain.MaxChannelPendingJoinRecentRequesters - } - if len(all) < limit { - limit = len(all) - } - recent := make([]int64, 0, limit) - for _, importer := range all[:limit] { - recent = append(recent, importer.UserID) - } - return domain.ChannelPendingJoinRequests{ - ChannelID: channelID, - Count: len(all), - RecentRequesters: recent, - }, nil -} - -func (s *ChannelStore) HideChatJoinRequest(_ context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.TargetUserID == 0 { - return domain.CreateChannelResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.CreateChannelResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canExportChannelInvite(member) { - return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired - } - importer, ok := s.importers[req.ChannelID][req.TargetUserID] - if !ok || !importer.Requested { - return domain.CreateChannelResult{}, domain.ErrHideRequesterMissing - } - invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID} - if importer.InviteID != 0 { - var err error - invite, err = s.inviteByIDLocked(req.ChannelID, importer.InviteID) - if err != nil { - return domain.CreateChannelResult{}, err - } - } - if !req.Approved { - s.deletePendingInviteImporterLocked(invite, req.TargetUserID) - return domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, req.TargetUserID, 0)}, nil - } - return s.approveInviteImporterLocked(channel, invite, req.TargetUserID, req.UserID, req.Date) -} - -func (s *ChannelStore) HideAllChatJoinRequests(_ context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.CreateChannelResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.CreateChannelResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !canExportChannelInvite(member) { - return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired - } - var inviteID int64 - if req.Hash != "" { - invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) - if err != nil { - return domain.CreateChannelResult{}, err - } - inviteID = invite.InviteID - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { - limit = domain.MaxChannelHideJoinRequests - } - targets := make([]domain.ChannelInviteImporter, 0, limit) - for _, importer := range s.importers[req.ChannelID] { - if !importer.Requested { - continue - } - if inviteID != 0 && importer.InviteID != inviteID { - continue - } - targets = append(targets, importer) - if len(targets) >= limit { - break - } - } - var result domain.CreateChannelResult - for _, importer := range targets { - invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID} - if importer.InviteID != 0 { - var err error - invite, err = s.inviteByIDLocked(req.ChannelID, importer.InviteID) - if err != nil { - return domain.CreateChannelResult{}, err - } - } - if !req.Approved { - s.deletePendingInviteImporterLocked(invite, importer.UserID) - result = domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, importer.UserID, 0)} - continue - } - result, err = s.approveInviteImporterLocked(channel, invite, importer.UserID, req.UserID, req.Date) - if err != nil { - return domain.CreateChannelResult{}, err - } - channel = result.Channel - } - if result.Channel.ID == 0 { - result = domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0)} - } - return result, nil -} - -func (s *ChannelStore) approveInviteImporterLocked(channel domain.Channel, invite domain.ChannelInvite, userID, approvedBy int64, date int) (domain.CreateChannelResult, error) { - if invite.InviteID != 0 && invite.UsageLimit > 0 && invite.UsageCount >= invite.UsageLimit { - return domain.CreateChannelResult{}, domain.ErrUsersTooMuch - } - channelID := channel.ID - if channelID == 0 { - channelID = invite.ChannelID - } - if existing, ok := s.members[channelID][userID]; ok { - if existing.Status == domain.ChannelMemberActive { - return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant - } - if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { - return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid - } - } - preJoinTopID := channel.TopMessageID - minID := channelInitialAvailableMinID(channel) - inviterID := invite.AdminUserID - if inviterID == 0 { - inviterID = approvedBy - } - member := domain.ChannelMember{ - ChannelID: channelID, - UserID: userID, - InviterUserID: inviterID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberActive, - JoinedAt: date, - AvailableMinID: minID, - AvailableMinPts: channelInitialAvailableMinPts(channel), - ReadInboxMaxID: maxInt(minID, preJoinTopID), - } - if s.members[channelID] == nil { - s.members[channelID] = make(map[int64]domain.ChannelMember) - } - s.members[channelID][userID] = member - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: date, - Type: domain.ChannelAdminLogParticipantJoin, - }) - if importer, ok := s.importers[channelID][userID]; ok && importer.Requested { - if importer.InviteID == invite.InviteID { - if invite.InviteID != 0 && invite.RequestedCount > 0 { - invite.RequestedCount-- - } - } else if importer.InviteID != 0 { - if pendingInvite, err := s.inviteByIDLocked(channelID, importer.InviteID); err == nil && pendingInvite.RequestedCount > 0 { - pendingInvite.RequestedCount-- - s.invites[pendingInvite.Hash] = pendingInvite - } - } - } - if invite.InviteID != 0 && invite.Hash != "" { - invite.UsageCount++ - s.invites[invite.Hash] = invite - } - s.refreshChannelCountsLocked(channelID) - channel = s.channels[channelID] - var msg domain.ChannelMessage - var event domain.ChannelUpdateEvent - if channel.Megagroup { - msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{ - Type: domain.ChannelActionChatJoined, - UserIDs: []int64{userID}, - }) - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - s.channels[channelID] = channel - } - member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) - if msg.ID != 0 { - member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) - } - s.members[channelID][userID] = member - s.upsertChannelDialogLocked(userID, channel, msg, true) - if s.importers[channelID] == nil { - s.importers[channelID] = make(map[int64]domain.ChannelInviteImporter) - } - s.importers[channelID][userID] = domain.ChannelInviteImporter{ - ChannelID: channelID, - InviteID: invite.InviteID, - UserID: userID, - Date: date, - ApprovedBy: approvedBy, - } - return domain.CreateChannelResult{ - Channel: channel, - Members: []domain.ChannelMember{member}, - Message: cloneChannelMessage(msg), - Event: cloneChannelEvent(event), - Recipients: s.activeMemberIDsLocked(channelID, 0, 0), - }, nil -} - -func (s *ChannelStore) recordPublicJoinRequestLocked(channel domain.Channel, userID int64, date int) error { - if existing, ok := s.members[channel.ID][userID]; ok { - if existing.Status == domain.ChannelMemberActive { - return domain.ErrUserAlreadyParticipant - } - if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { - return domain.ErrInviteHashInvalid - } - } - if s.importers[channel.ID] == nil { - s.importers[channel.ID] = make(map[int64]domain.ChannelInviteImporter) - } - if existing, ok := s.importers[channel.ID][userID]; ok && existing.Requested { - return domain.ErrInviteRequestSent - } - s.importers[channel.ID][userID] = domain.ChannelInviteImporter{ - ChannelID: channel.ID, - UserID: userID, - Date: date, - Requested: true, - } - return nil -} - -func (s *ChannelStore) recordPendingInviteRequestLocked(invite domain.ChannelInvite, userID int64, date int) error { - if existing, ok := s.members[invite.ChannelID][userID]; ok { - if existing.Status == domain.ChannelMemberActive { - return domain.ErrUserAlreadyParticipant - } - if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { - return domain.ErrInviteHashInvalid - } - } - if s.importers[invite.ChannelID] == nil { - s.importers[invite.ChannelID] = make(map[int64]domain.ChannelInviteImporter) - } - if existing, ok := s.importers[invite.ChannelID][userID]; ok && existing.Requested { - return domain.ErrInviteRequestSent - } - s.importers[invite.ChannelID][userID] = domain.ChannelInviteImporter{ - ChannelID: invite.ChannelID, - InviteID: invite.InviteID, - UserID: userID, - Date: date, - Requested: true, - } - invite.RequestedCount++ - s.invites[invite.Hash] = invite - return nil -} - -func (s *ChannelStore) deletePendingInviteImporterLocked(invite domain.ChannelInvite, userID int64) { - if existing, ok := s.importers[invite.ChannelID][userID]; ok && existing.Requested { - delete(s.importers[invite.ChannelID], userID) - if invite.InviteID != 0 && invite.Hash != "" && invite.RequestedCount > 0 { - invite.RequestedCount-- - s.invites[invite.Hash] = invite - } - } -} - -func (s *ChannelStore) inviteByChannelHashLocked(channelID int64, hash string) (domain.ChannelInvite, error) { - hash = strings.TrimSpace(hash) - if hash == "" { - return domain.ChannelInvite{}, domain.ErrInviteHashEmpty - } - invite, ok := s.invites[hash] - if !ok || invite.ChannelID != channelID { - return domain.ChannelInvite{}, domain.ErrInviteHashInvalid - } - return invite, nil -} - -func (s *ChannelStore) inviteByIDLocked(channelID, inviteID int64) (domain.ChannelInvite, error) { - for _, invite := range s.invites { - if invite.ChannelID == channelID && invite.InviteID == inviteID { - return invite, nil - } - } - return domain.ChannelInvite{}, domain.ErrInviteHashInvalid -} - -func (s *ChannelStore) newReplacementInviteLocked(old domain.ChannelInvite, date int) (domain.ChannelInvite, error) { - inviteID, err := randomMemoryPositiveInt64() - if err != nil { - return domain.ChannelInvite{}, err - } - hash, err := randomMemoryInviteHash() - if err != nil { - return domain.ChannelInvite{}, err - } - if date == 0 { - date = int(time.Now().Unix()) - } - return domain.ChannelInvite{ - ChannelID: old.ChannelID, - InviteID: inviteID, - Hash: hash, - AdminUserID: old.AdminUserID, - Permanent: old.Permanent, - Date: date, - }, nil -} - -func cloneChannelInvites(in []domain.ChannelInvite) []domain.ChannelInvite { - if len(in) == 0 { - return nil - } - out := make([]domain.ChannelInvite, len(in)) - copy(out, in) - return out -} - -func cloneChannelInviteImporters(in []domain.ChannelInviteImporter) []domain.ChannelInviteImporter { - if len(in) == 0 { - return nil - } - out := make([]domain.ChannelInviteImporter, len(in)) - copy(out, in) - return out -} - -func (s *ChannelStore) ListChannelDialogs(_ context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { - if viewerUserID == 0 { - return domain.ChannelDialogList{}, nil - } - limit := filter.Limit - if limit <= 0 || limit > 100 { - limit = 100 - } - s.mu.RLock() - defer s.mu.RUnlock() - - channelIDs := make([]int64, 0, len(s.dialogs[viewerUserID])) - seen := make(map[int64]struct{}, len(s.dialogs[viewerUserID])) - for channelID := range s.dialogs[viewerUserID] { - channelIDs = append(channelIDs, channelID) - seen[channelID] = struct{}{} - } - for channelID, members := range s.members { - if _, ok := seen[channelID]; ok { - continue - } - if member, ok := members[viewerUserID]; ok && member.Status == domain.ChannelMemberActive { - channelIDs = append(channelIDs, channelID) - seen[channelID] = struct{}{} - } - } - - items := make([]domain.Dialog, 0, len(channelIDs)) - for _, channelID := range channelIDs { - channel, ok := s.channels[channelID] - if !ok || channel.Deleted { - continue - } - if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { - continue - } - item := channelDialogToDialog(s.dialogForUserLocked(viewerUserID, channel)) - if !channelDialogMatchesFilter(item, channel, filter) { - continue - } - items = append(items, item) - } - sort.Slice(items, func(i, j int) bool { - if items[i].Pinned != items[j].Pinned { - return items[i].Pinned - } - if items[i].PinnedOrder != items[j].PinnedOrder { - return items[i].PinnedOrder > items[j].PinnedOrder - } - if items[i].TopMessageDate != items[j].TopMessageDate { - return items[i].TopMessageDate > items[j].TopMessageDate - } - if items[i].TopMessage != items[j].TopMessage { - return items[i].TopMessage > items[j].TopMessage - } - return items[i].Peer.ID > items[j].Peer.ID - }) - out := domain.ChannelDialogList{Count: len(items)} - for _, dialog := range items { - if len(out.Dialogs) >= limit { - break - } - out.Dialogs = append(out.Dialogs, dialog) - channel := s.channels[dialog.Peer.ID] - out.Channels = append(out.Channels, channel) - if msg, ok := s.findMessageLocked(dialog.Peer.ID, dialog.TopMessage); ok && !msg.Deleted { - out.Messages = append(out.Messages, cloneChannelMessage(msg)) - } - } - return out, nil -} - -func (s *ChannelStore) GetChannelDialogs(_ context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) { - if viewerUserID == 0 || len(channelIDs) == 0 { - return domain.ChannelDialogList{}, nil - } - s.mu.RLock() - defer s.mu.RUnlock() - out := domain.ChannelDialogList{} - seen := make(map[int64]struct{}, len(channelIDs)) - for _, channelID := range channelIDs { - if channelID == 0 { - continue - } - if _, ok := seen[channelID]; ok { - continue - } - seen[channelID] = struct{}{} - channel, err := s.channelForMemberLocked(viewerUserID, channelID) - if err != nil { - continue - } - dialog := channelDialogToDialog(s.dialogForUserLocked(viewerUserID, channel)) - out.Dialogs = append(out.Dialogs, dialog) - out.Channels = append(out.Channels, channel) - if msg, ok := s.findMessageLocked(channelID, dialog.TopMessage); ok && !msg.Deleted { - out.Messages = append(out.Messages, cloneChannelMessage(msg)) - } - } - out.Count = len(out.Dialogs) - return out, nil -} - -func (s *ChannelStore) ListCommonChannels(_ context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { - if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 { - return domain.CommonChannelsResult{}, domain.ErrChannelInvalid - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxCommonChannelsLimit { - limit = domain.MaxCommonChannelsLimit - } - s.mu.RLock() - defer s.mu.RUnlock() - - ids := make([]int64, 0) - for channelID, members := range s.members { - self, selfOK := members[req.UserID] - target, targetOK := members[req.TargetUserID] - if !selfOK || !targetOK || self.Status != domain.ChannelMemberActive || target.Status != domain.ChannelMemberActive { - continue - } - channel, ok := s.channels[channelID] - if !ok || channel.Deleted || !channel.Megagroup || channel.Broadcast { - continue - } - ids = append(ids, channelID) - } - sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) - out := domain.CommonChannelsResult{Count: len(ids)} - if req.CountOnly { - return out, nil - } - for _, channelID := range ids { - if req.MaxID > 0 && channelID <= req.MaxID { - continue - } - out.Channels = append(out.Channels, cloneChannel(s.channels[channelID])) - if len(out.Channels) >= limit { - break - } - } - return out, nil -} - -func (s *ChannelStore) ListLeftChannels(_ context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) { - if userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset { - return domain.LeftChannelsResult{}, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxLeftChannelsLimit { - limit = domain.MaxLeftChannelsLimit - } - s.mu.RLock() - defer s.mu.RUnlock() - - all := make([]domain.LeftChannel, 0) - for channelID, members := range s.members { - member, ok := members[userID] - if !ok || member.Status != domain.ChannelMemberLeft { - continue - } - channel, ok := s.channels[channelID] - if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) { - continue - } - all = append(all, domain.LeftChannel{ - Channel: cloneChannel(channel), - Self: member, - }) - } - sort.Slice(all, func(i, j int) bool { - if all[i].Self.LeftAt != all[j].Self.LeftAt { - return all[i].Self.LeftAt > all[j].Self.LeftAt - } - return all[i].Channel.ID > all[j].Channel.ID - }) - - out := domain.LeftChannelsResult{Count: len(all)} - if offset >= len(all) { - return out, nil - } - end := offset + limit - if end > len(all) { - end = len(all) - } - out.Channels = append(out.Channels, all[offset:end]...) - return out, nil -} - -func (s *ChannelStore) ListInactiveChannels(_ context.Context, userID int64, limit int) (domain.ChannelDialogList, error) { - if userID == 0 { - return domain.ChannelDialogList{}, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxInactiveChannelsLimit { - limit = domain.MaxInactiveChannelsLimit - } - s.mu.RLock() - defer s.mu.RUnlock() - - type item struct { - channel domain.Channel - dialog domain.Dialog - } - items := make([]item, 0, limit) - for channelID, members := range s.members { - member, ok := members[userID] - if !ok || member.Status != domain.ChannelMemberActive { - continue - } - channel, ok := s.channels[channelID] - if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) { - continue - } - if _, err := s.channelForMemberLocked(userID, channelID); err != nil { - continue - } - dialog := channelDialogToDialog(s.dialogForUserLocked(userID, channel)) - dialog.TopMessageDate = inactiveChannelDate(dialog, channel, member) - items = append(items, item{channel: cloneChannel(channel), dialog: dialog}) - } - sort.Slice(items, func(i, j int) bool { - if items[i].dialog.TopMessageDate != items[j].dialog.TopMessageDate { - return items[i].dialog.TopMessageDate < items[j].dialog.TopMessageDate - } - if items[i].dialog.TopMessage != items[j].dialog.TopMessage { - return items[i].dialog.TopMessage < items[j].dialog.TopMessage - } - return items[i].channel.ID < items[j].channel.ID - }) - if len(items) > limit { - items = items[:limit] - } - out := domain.ChannelDialogList{Count: len(items)} - for _, item := range items { - out.Dialogs = append(out.Dialogs, item.dialog) - out.Channels = append(out.Channels, item.channel) - } - return out, nil -} - -func (s *ChannelStore) ListChannelRecommendations(_ context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) { - if req.UserID == 0 || req.SourceChannelID < 0 { - return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelRecommendationsLimit { - limit = domain.DefaultChannelRecommendationsLimit - } - s.mu.RLock() - defer s.mu.RUnlock() - - items := make([]domain.Channel, 0, limit) - for channelID, channel := range s.channels { - if !recommendableChannel(channel) || channelID == req.SourceChannelID { - continue - } - if req.SourceChannelID == 0 { - if member, ok := s.members[channelID][req.UserID]; ok && member.Status == domain.ChannelMemberActive { - continue - } - } - items = append(items, cloneChannel(channel)) - } - sort.Slice(items, func(i, j int) bool { - if items[i].ParticipantsCount != items[j].ParticipantsCount { - return items[i].ParticipantsCount > items[j].ParticipantsCount - } - if items[i].Date != items[j].Date { - return items[i].Date > items[j].Date - } - return items[i].ID > items[j].ID - }) - out := domain.ChannelRecommendationsResult{Count: len(items)} - if len(items) > limit { - items = items[:limit] - } - out.Channels = append(out.Channels, items...) - return out, nil -} - -func (s *ChannelStore) ListDiscussionGroups(_ context.Context, userID int64, limit int) ([]domain.Channel, error) { - if userID == 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxDiscussionGroupsLimit { - limit = domain.MaxDiscussionGroupsLimit - } - s.mu.RLock() - defer s.mu.RUnlock() - - items := make([]domain.Channel, 0, limit) - for channelID, channel := range s.channels { - if !validDiscussionGroup(channel) || channel.Deleted { - continue - } - member := s.members[channelID][userID] - if member.Status != domain.ChannelMemberActive || !canManageDiscussionGroup(member) { - continue - } - items = append(items, cloneChannel(channel)) - } - sort.Slice(items, func(i, j int) bool { - return items[i].ID > items[j].ID - }) - if len(items) > limit { - items = items[:limit] - } - return items, nil -} - -func (s *ChannelStore) SetDiscussionGroup(_ context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) { - if userID == 0 { - return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid - } - if broadcastID == 0 && groupID == 0 { - return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified - } - s.mu.Lock() - defer s.mu.Unlock() - - changed := make(map[int64]domain.Channel) - markChanged := func(channel domain.Channel) { - if channel.ID != 0 { - changed[channel.ID] = cloneChannel(channel) - } - } - setLinked := func(channelID, linkedID int64) (domain.Channel, bool) { - channel, ok := s.channels[channelID] - if !ok || channel.Deleted { - return domain.Channel{}, false - } - if channel.LinkedChatID == linkedID { - return channel, true - } - channel.LinkedChatID = linkedID - s.channels[channelID] = channel - markChanged(channel) - return channel, true - } - - if broadcastID == 0 { - group, groupMember, err := s.channelAndMemberLocked(userID, groupID) - if err != nil || !validDiscussionGroup(group) { - return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid - } - if !canManageDiscussionGroup(groupMember) { - return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired - } - oldBroadcastID := group.LinkedChatID - if oldBroadcastID == 0 { - return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified - } - if oldBroadcast, ok := s.channels[oldBroadcastID]; ok && oldBroadcast.LinkedChatID == groupID { - if updated, ok := setLinked(oldBroadcastID, 0); ok { - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: updated.ID, - UserID: userID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogChangeLinkedChat, - PrevInt: int(groupID), - NewInt: 0, - }) - } - } - setLinked(groupID, 0) - return discussionGroupUpdateResult(changed), nil - } - - broadcast, broadcastMember, err := s.channelAndMemberLocked(userID, broadcastID) - if err != nil || !broadcast.Broadcast || broadcast.Megagroup { - return domain.DiscussionGroupUpdateResult{}, domain.ErrBroadcastIDInvalid - } - if !canManageDiscussionBroadcast(broadcastMember) { - return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired - } - oldGroupID := broadcast.LinkedChatID - if groupID == 0 { - if oldGroupID == 0 { - return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified - } - updated, _ := setLinked(broadcastID, 0) - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: updated.ID, - UserID: userID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogChangeLinkedChat, - PrevInt: int(oldGroupID), - NewInt: 0, - }) - if oldGroup, ok := s.channels[oldGroupID]; ok && oldGroup.LinkedChatID == broadcastID { - setLinked(oldGroupID, 0) - } - return discussionGroupUpdateResult(changed), nil - } - - group, groupMember, err := s.channelAndMemberLocked(userID, groupID) - if err != nil || !validDiscussionGroup(group) { - return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid - } - if group.PreHistoryHidden { - return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupPrehistoryHidden - } - if !canManageDiscussionGroup(groupMember) { - return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired - } - if oldGroupID == groupID && group.LinkedChatID == broadcastID { - return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified - } - oldBroadcastID := group.LinkedChatID - if oldGroupID != 0 && oldGroupID != groupID { - if oldGroup, ok := s.channels[oldGroupID]; ok && oldGroup.LinkedChatID == broadcastID { - setLinked(oldGroupID, 0) - } - } - if oldBroadcastID != 0 && oldBroadcastID != broadcastID { - if oldBroadcast, ok := s.channels[oldBroadcastID]; ok && oldBroadcast.LinkedChatID == groupID { - if updated, ok := setLinked(oldBroadcastID, 0); ok { - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: updated.ID, - UserID: userID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogChangeLinkedChat, - PrevInt: int(groupID), - NewInt: 0, - }) - } - } - } - updatedBroadcast, _ := setLinked(broadcastID, groupID) - setLinked(groupID, broadcastID) - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: updatedBroadcast.ID, - UserID: userID, - Date: int(time.Now().Unix()), - Type: domain.ChannelAdminLogChangeLinkedChat, - PrevInt: int(oldGroupID), - NewInt: int(groupID), - }) - return discussionGroupUpdateResult(changed), nil -} - -func (s *ChannelStore) SetChannelDialogPinned(_ context.Context, userID, channelID int64, pinned bool) (bool, error) { - if userID == 0 || channelID == 0 { - return false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return false, nil - } - nextOrder := 1 - for _, dialog := range s.dialogs[userID] { - if dialog.Pinned && dialog.PinnedOrder >= nextOrder { - nextOrder = dialog.PinnedOrder + 1 - } - } - dialog := s.dialogForUserLocked(userID, channel) - changed := dialog.Pinned != pinned || (pinned && dialog.PinnedOrder == 0) - dialog.Pinned = pinned - if pinned { - if dialog.PinnedOrder == 0 { - dialog.PinnedOrder = nextOrder - } - } else { - dialog.PinnedOrder = 0 - } - if s.dialogs[userID] == nil { - s.dialogs[userID] = make(map[int64]domain.ChannelDialog) - } - s.dialogs[userID][channelID] = dialog - return changed, nil -} - -func (s *ChannelStore) ReorderChannelPinnedDialogs(_ context.Context, userID int64, order []domain.Peer, force bool) error { - if userID == 0 { - return nil - } - positions := make(map[int64]int, len(order)) - for i, peer := range order { - if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { - continue - } - if _, ok := positions[peer.ID]; ok { - continue - } - positions[peer.ID] = len(order) - i - } - s.mu.Lock() - defer s.mu.Unlock() - for channelID, dialog := range s.dialogs[userID] { - if pos, ok := positions[channelID]; ok { - dialog.Pinned = true - dialog.PinnedOrder = pos - s.dialogs[userID][channelID] = dialog - continue - } - if force && dialog.Pinned { - dialog.Pinned = false - dialog.PinnedOrder = 0 - s.dialogs[userID][channelID] = dialog - } - } - return nil -} - -func (s *ChannelStore) SetChannelDialogUnreadMark(_ context.Context, userID, channelID int64, unread bool) (bool, error) { - if userID == 0 || channelID == 0 { - return false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return false, nil - } - dialog := s.dialogForUserLocked(userID, channel) - changed := dialog.UnreadMark != unread - dialog.UnreadMark = unread - member := s.members[channelID][userID] - member.UnreadMark = unread - s.members[channelID][userID] = member - if s.dialogs[userID] == nil { - s.dialogs[userID] = make(map[int64]domain.ChannelDialog) - } - s.dialogs[userID][channelID] = dialog - return changed, nil -} - -func (s *ChannelStore) SetChannelViewForumAsMessages(_ context.Context, userID, channelID int64, enabled bool) (bool, error) { - if userID == 0 || channelID == 0 { - return false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - return false, nil - } - dialog := s.dialogForUserLocked(userID, channel) - changed := dialog.ViewForumAsMessages != enabled - dialog.ViewForumAsMessages = enabled - if s.dialogs[userID] == nil { - s.dialogs[userID] = make(map[int64]domain.ChannelDialog) - } - s.dialogs[userID][channelID] = dialog - return changed, nil -} - -func (s *ChannelStore) ListChannelUnreadMarked(_ context.Context, userID int64) ([]domain.Peer, error) { - if userID == 0 { - return nil, nil - } - s.mu.RLock() - defer s.mu.RUnlock() - out := make([]domain.Peer, 0, len(s.dialogs[userID])) - for channelID, dialog := range s.dialogs[userID] { - if !dialog.UnreadMark { - continue - } - if _, err := s.channelForMemberLocked(userID, channelID); err != nil { - continue - } - out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) - } - sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) - return out, nil -} - -func (s *ChannelStore) EditChannelPeerFolders(_ context.Context, userID int64, peers []domain.FolderPeerUpdate) error { - if userID == 0 || len(peers) == 0 { - return nil - } - updates := make(map[int64]int, len(peers)) - for _, item := range peers { - if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { - continue - } - if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { - continue - } - updates[item.Peer.ID] = item.FolderID - } - if len(updates) == 0 { - return nil - } - s.mu.Lock() - defer s.mu.Unlock() - for channelID, folderID := range updates { - channel, err := s.channelForMemberLocked(userID, channelID) - if err != nil { - continue - } - dialog := s.dialogForUserLocked(userID, channel) - dialog.FolderID = folderID - if s.dialogs[userID] == nil { - s.dialogs[userID] = make(map[int64]domain.ChannelDialog) - } - s.dialogs[userID][channelID] = dialog - } - return nil -} - -func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, member, _, err := s.channelForViewerLocked(viewerUserID, filter.ChannelID) - if err != nil { - return domain.ChannelHistory{}, err - } - limit := filter.Limit - if limit <= 0 || limit > 100 { - limit = 100 - } - items := append([]domain.ChannelMessage(nil), s.messages[filter.ChannelID]...) - sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID }) - out := make([]domain.ChannelMessage, 0, limit) - query := strings.ToLower(strings.TrimSpace(filter.Query)) - matched := 0 - for _, msg := range items { - if msg.Deleted { - continue - } - if msg.ID <= member.AvailableMinID { - continue - } - if filter.PinnedOnly && msg.ID != channel.PinnedMessageID { - continue - } - if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) { - continue - } - if filter.SenderUserID != 0 && msg.SenderUserID != filter.SenderUserID { - continue - } - if filter.MinDate > 0 && msg.Date <= filter.MinDate { - continue - } - if filter.MaxDate > 0 && msg.Date >= filter.MaxDate { - continue - } - if filter.OffsetID > 0 && msg.ID >= filter.OffsetID { - continue - } - if filter.OffsetID <= 0 && filter.OffsetDate > 0 && msg.Date >= filter.OffsetDate { - continue - } - if filter.MaxID > 0 && msg.ID > filter.MaxID { - continue - } - if filter.MinID > 0 && msg.ID <= filter.MinID { - continue - } - matched++ - if len(out) < limit { - item := cloneChannelMessage(msg) - item.Pinned = channel.PinnedMessageID != 0 && item.ID == channel.PinnedMessageID - out = append(out, item) - } - } - s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) - s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) - return domain.ChannelHistory{ - Channel: channel, - Self: member, - Messages: out, - Count: matched, - }, nil -} - -func (s *ChannelStore) SearchPublicPosts(_ context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) { - query := strings.ToLower(strings.TrimSpace(req.Query)) - hashtag := strings.ToLower(strings.TrimSpace(req.Hashtag)) - if (query == "") == (hashtag == "") { - return domain.ChannelHistory{}, domain.ErrChannelInvalid - } - if req.Limit <= 0 || req.Limit > domain.MaxChannelSearchPostsLimit { - req.Limit = domain.MaxChannelSearchPostsLimit - } - type hit struct { - channel domain.Channel - message domain.ChannelMessage - } - s.mu.RLock() - defer s.mu.RUnlock() - hits := make([]hit, 0, req.Limit+1) - for channelID, channel := range s.channels { - if channel.Deleted || strings.TrimSpace(channel.Username) == "" { - continue - } - for _, msg := range s.messages[channelID] { - if msg.Deleted || strings.TrimSpace(msg.Body) == "" { - continue - } - if !channelSearchPostAfterCursor(msg, req) { - continue - } - body := strings.ToLower(msg.Body) - if query != "" && !strings.Contains(body, query) { - continue - } - if hashtag != "" && !strings.Contains(body, "#"+hashtag) { - continue - } - hits = append(hits, hit{channel: channel, message: cloneChannelMessage(msg)}) - } - } - sort.Slice(hits, func(i, j int) bool { - a, b := hits[i].message, hits[j].message - if a.Date != b.Date { - return a.Date > b.Date - } - if a.ChannelID != b.ChannelID { - return a.ChannelID > b.ChannelID - } - return a.ID > b.ID - }) - out := domain.ChannelHistory{Count: len(hits)} - if out.Count > req.Limit { - out.Count = req.Limit + 1 - hits = hits[:req.Limit] - } - channelSeen := make(map[int64]struct{}, len(hits)) - for _, h := range hits { - out.Messages = append(out.Messages, h.message) - if _, ok := channelSeen[h.channel.ID]; ok { - continue - } - channelSeen[h.channel.ID] = struct{}{} - out.Channels = append(out.Channels, h.channel) - } - s.populateChannelMessagesReactionsLocked(viewerUserID, out.Channels, out.Messages) - return out, nil -} - -func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) { - query := strings.ToLower(strings.TrimSpace(req.Query)) - if viewerUserID == 0 || query == "" { - return domain.ChannelHistory{}, domain.ErrChannelInvalid - } - if req.Limit <= 0 || req.Limit > domain.MaxChannelGlobalSearchLimit { - req.Limit = domain.MaxChannelGlobalSearchLimit - } - type hit struct { - channel domain.Channel - message domain.ChannelMessage - } - s.mu.RLock() - defer s.mu.RUnlock() - hits := make([]hit, 0, req.Limit+1) - for channelID, channel := range s.channels { - if channel.Deleted { - continue - } - if req.BroadcastsOnly && (!channel.Broadcast || channel.Megagroup) { - continue - } - if req.GroupsOnly && !channel.Megagroup { - continue - } - member, ok := s.members[channelID][viewerUserID] - if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { - continue - } - if req.HasFolderID { - dialog, ok := s.dialogs[viewerUserID][channelID] - if !ok || dialog.FolderID != req.FolderID { - continue - } - } - for _, msg := range s.messages[channelID] { - if msg.Deleted || strings.TrimSpace(msg.Body) == "" { - continue - } - if member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID { - continue - } - if req.MinDate > 0 && msg.Date <= req.MinDate { - continue - } - if req.MaxDate > 0 && msg.Date >= req.MaxDate { - continue - } - if !channelGlobalSearchAfterCursor(msg, req) { - continue - } - if !strings.Contains(strings.ToLower(msg.Body), query) { - continue - } - hits = append(hits, hit{channel: channel, message: cloneChannelMessage(msg)}) - } - } - sort.Slice(hits, func(i, j int) bool { - a, b := hits[i].message, hits[j].message - if a.Date != b.Date { - return a.Date > b.Date - } - if a.ChannelID != b.ChannelID { - return a.ChannelID > b.ChannelID - } - return a.ID > b.ID - }) - out := domain.ChannelHistory{Count: len(hits)} - if out.Count > req.Limit { - out.Count = req.Limit + 1 - hits = hits[:req.Limit] - } - channelSeen := make(map[int64]struct{}, len(hits)) - for _, h := range hits { - out.Messages = append(out.Messages, h.message) - if _, ok := channelSeen[h.channel.ID]; ok { - continue - } - channelSeen[h.channel.ID] = struct{}{} - out.Channels = append(out.Channels, h.channel) - } - s.populateChannelMessagesReactionsLocked(viewerUserID, out.Channels, out.Messages) - return out, nil -} - -func channelSearchPostAfterCursor(msg domain.ChannelMessage, req domain.ChannelSearchPostsRequest) bool { - if req.OffsetRate <= 0 && req.OffsetChannelID <= 0 && req.OffsetID <= 0 { - return true - } - if req.OffsetRate > 0 { - if msg.Date < req.OffsetRate { - return true - } - if msg.Date > req.OffsetRate { - return false - } - } - if req.OffsetChannelID > 0 { - if msg.ChannelID < req.OffsetChannelID { - return true - } - if msg.ChannelID > req.OffsetChannelID { - return false - } - } - if req.OffsetID > 0 { - return msg.ID < req.OffsetID - } - return false -} - -func channelGlobalSearchAfterCursor(msg domain.ChannelMessage, req domain.ChannelGlobalSearchRequest) bool { - if req.OffsetRate <= 0 && req.OffsetChannelID <= 0 && req.OffsetID <= 0 { - return true - } - if req.OffsetRate > 0 { - if msg.Date < req.OffsetRate { - return true - } - if msg.Date > req.OffsetRate { - return false - } - } - if req.OffsetChannelID > 0 { - if msg.ChannelID < req.OffsetChannelID { - return true - } - if msg.ChannelID > req.OffsetChannelID { - return false - } - } - if req.OffsetID > 0 { - return msg.ID < req.OffsetID - } - return false -} - -func (s *ChannelStore) GetChannelMessages(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID) - if err != nil { - return domain.ChannelHistory{}, err - } - if len(ids) == 0 { - return domain.ChannelHistory{Channel: channel, Self: member}, nil - } - if len(ids) > domain.MaxGetMessageIDs { - return domain.ChannelHistory{}, domain.ErrChannelInvalid - } - wanted := make(map[int]struct{}, len(ids)) - for _, id := range ids { - if id <= 0 || id > domain.MaxMessageBoxID { - return domain.ChannelHistory{}, domain.ErrMessageIDInvalid - } - wanted[id] = struct{}{} - } - messages := make([]domain.ChannelMessage, 0, len(wanted)) - for _, msg := range s.messages[channelID] { - if _, ok := wanted[msg.ID]; !ok { - continue - } - if msg.Deleted || msg.ID <= member.AvailableMinID { - continue - } - item := cloneChannelMessage(msg) - item.Pinned = channel.PinnedMessageID != 0 && item.ID == channel.PinnedMessageID - messages = append(messages, item) - } - sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) - s.populateChannelMessageRepliesLocked(viewerUserID, channelID, messages) - s.populateChannelMessageReactionsLocked(viewerUserID, channel, messages) - return domain.ChannelHistory{Channel: channel, Self: member, Messages: messages, Count: len(messages)}, nil -} - -func (s *ChannelStore) ReadChannelMessageContents(_ context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ReadChannelMessageContentsResult{}, err - } - if len(req.IDs) == 0 { - return domain.ReadChannelMessageContentsResult{Channel: channel}, nil - } - if len(req.IDs) > domain.MaxGetMessageIDs { - return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid - } - wanted := make(map[int]struct{}, len(req.IDs)) - for _, id := range req.IDs { - if id <= 0 || id > domain.MaxMessageBoxID { - return domain.ReadChannelMessageContentsResult{}, domain.ErrMessageIDInvalid - } - wanted[id] = struct{}{} - } - messages := make([]domain.ChannelMessage, 0, len(wanted)) - for _, msg := range s.messages[req.ChannelID] { - if _, ok := wanted[msg.ID]; !ok { - continue - } - if msg.Deleted || msg.ID <= member.AvailableMinID { - continue - } - messages = append(messages, cloneChannelMessage(msg)) - } - sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) - clearedSet := make(map[int]struct{}) - for _, msg := range messages { - byUser := s.reactions[req.ChannelID][msg.ID] - if len(byUser) == 0 { - continue - } - for reactedUserID, rows := range byUser { - changed := false - for i := range rows { - if rows[i].SenderUserID == req.UserID && rows[i].UserID != req.UserID && rows[i].Unread { - rows[i].Unread = false - changed = true - clearedSet[msg.ID] = struct{}{} - } - } - if changed { - byUser[reactedUserID] = rows - } - } - } - cleared := make([]int, 0, len(clearedSet)) - for id := range clearedSet { - cleared = append(cleared, id) - } - sort.Sort(sort.Reverse(sort.IntSlice(cleared))) - if len(cleared) > 0 { - s.refreshChannelUnreadReactionsDialogLocked(req.UserID, req.ChannelID) - } - s.populateChannelMessageRepliesLocked(req.UserID, req.ChannelID, messages) - s.populateChannelMessageReactionsLocked(req.UserID, channel, messages) - return domain.ReadChannelMessageContentsResult{ - Channel: channel, - Messages: messages, - ClearedUnreadReactionMessageIDs: cleared, - }, nil -} - -func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid - } - if len(req.IDs) == 0 { - return domain.ChannelMessageViewsResult{Views: map[int]int{}}, nil - } - if len(req.IDs) > domain.MaxGetMessageIDs { - return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - _, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelMessageViewsResult{}, err - } - wanted := make(map[int]struct{}, len(req.IDs)) - for _, id := range req.IDs { - if id <= 0 || id > domain.MaxMessageBoxID { - return domain.ChannelMessageViewsResult{}, domain.ErrMessageIDInvalid - } - wanted[id] = struct{}{} - } - visible := make(map[int]struct{}, len(wanted)) - for _, msg := range s.messages[req.ChannelID] { - if _, ok := wanted[msg.ID]; !ok { - continue - } - if msg.Deleted || msg.ID <= member.AvailableMinID { - continue - } - visible[msg.ID] = struct{}{} - } - if s.msgViews[req.ChannelID] == nil { - s.msgViews[req.ChannelID] = make(map[int]int) - } - if s.msgViewers[req.ChannelID] == nil { - s.msgViewers[req.ChannelID] = make(map[int]map[int64]struct{}) - } - for id := range visible { - if req.Increment { - if s.msgViewers[req.ChannelID][id] == nil { - s.msgViewers[req.ChannelID][id] = make(map[int64]struct{}) - } - if _, seen := s.msgViewers[req.ChannelID][id][req.UserID]; !seen { - s.msgViewers[req.ChannelID][id][req.UserID] = struct{}{} - s.msgViews[req.ChannelID][id]++ - } - } - } - out := make(map[int]int, len(visible)) - for id := range visible { - out[id] = s.msgViews[req.ChannelID][id] - } - return domain.ChannelMessageViewsResult{Views: out}, nil -} - -func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - s.mu.Lock() - defer s.mu.Unlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - idx, ok := s.findMessageIndexLocked(req.ChannelID, req.MessageID) - if !ok { - return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - msg := s.messages[req.ChannelID][idx] - if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { - return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - if s.reactions[req.ChannelID] == nil { - s.reactions[req.ChannelID] = make(map[int]map[int64][]domain.ChannelMessagePeerReaction) - } - if s.reactions[req.ChannelID][req.MessageID] == nil { - s.reactions[req.ChannelID][req.MessageID] = make(map[int64][]domain.ChannelMessagePeerReaction) - } - if len(req.Reactions) == 0 { - delete(s.reactions[req.ChannelID][req.MessageID], req.UserID) - } else { - rows := make([]domain.ChannelMessagePeerReaction, 0, len(req.Reactions)) - for i, reaction := range req.Reactions { - rows = append(rows, domain.ChannelMessagePeerReaction{ - ChannelID: req.ChannelID, - MessageID: req.MessageID, - SenderUserID: msg.SenderUserID, - UserID: req.UserID, - Reaction: reaction, - Big: req.Big, - Unread: msg.SenderUserID != 0 && msg.SenderUserID != req.UserID, - ChosenOrder: i + 1, - Date: req.Date, - }) - } - s.reactions[req.ChannelID][req.MessageID][req.UserID] = rows - if s.top[req.UserID] == nil { - s.top[req.UserID] = make(map[string]domain.TopMessageReaction) - } - for _, reaction := range req.Reactions { - key := messageReactionKey(reaction) - row := s.top[req.UserID][key] - row.UserID = req.UserID - row.Reaction = reaction - row.Count++ - row.Date = req.Date - s.top[req.UserID][key] = row - } - if req.AddToRecent { - if s.recent[req.UserID] == nil { - s.recent[req.UserID] = make(map[string]domain.RecentMessageReaction) - } - for _, reaction := range req.Reactions { - s.recent[req.UserID][messageReactionKey(reaction)] = domain.RecentMessageReaction{ - UserID: req.UserID, - Reaction: reaction, - Date: req.Date, - } - } - } - } - s.refreshChannelUnreadReactionsDialogLocked(msg.SenderUserID, req.ChannelID) - reactions := s.channelMessageReactionsLocked(req.UserID, channel, req.MessageID) - msg = cloneChannelMessage(msg) - msg.Reactions = cloneChannelMessageReactionsPtr(&reactions) - return domain.ChannelMessageReactionsResult{ - Channel: cloneChannel(channel), - Message: msg, - Messages: []domain.ChannelMessage{msg}, - Reactions: cloneChannelMessageReactions(reactions), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - }, nil -} - -func (s *ChannelStore) DeleteChannelParticipantReaction(_ context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - if !canDeleteAnyChannelMessage(member) { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelAdminRequired - } - msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) - if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { - return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - if s.reactions[req.ChannelID] != nil && s.reactions[req.ChannelID][req.MessageID] != nil { - delete(s.reactions[req.ChannelID][req.MessageID], req.ParticipantUserID) - } - s.refreshChannelUnreadReactionsDialogLocked(msg.SenderUserID, req.ChannelID) - reactions := s.channelMessageReactionsLocked(req.UserID, channel, req.MessageID) - outMsg := cloneChannelMessage(msg) - outMsg.Reactions = cloneChannelMessageReactionsPtr(&reactions) - return domain.ChannelMessageReactionsResult{ - Channel: cloneChannel(channel), - Message: outMsg, - Messages: []domain.ChannelMessage{outMsg}, - Reactions: cloneChannelMessageReactions(reactions), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - }, nil -} - -func (s *ChannelStore) DeleteChannelParticipantReactions(_ context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { - return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid - } - if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch { - req.Limit = domain.MaxDeleteParticipantReactionsBatch - } - s.mu.Lock() - defer s.mu.Unlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelParticipantReactionsResult{}, err - } - if !canDeleteAnyChannelMessage(member) { - return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelAdminRequired - } - type reactionMsg struct { - id int - sender int64 - date int - } - candidates := make([]reactionMsg, 0) - for msgID, byUser := range s.reactions[req.ChannelID] { - rows := byUser[req.ParticipantUserID] - if len(rows) == 0 { - continue - } - msg, ok := s.findMessageLocked(req.ChannelID, msgID) - if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { - continue - } - item := reactionMsg{id: msgID, sender: msg.SenderUserID} - for _, row := range rows { - if row.Date > item.date { - item.date = row.Date - } - } - candidates = append(candidates, item) - } - sort.Slice(candidates, func(i, j int) bool { - if candidates[i].date != candidates[j].date { - return candidates[i].date > candidates[j].date - } - return candidates[i].id > candidates[j].id - }) - if len(candidates) > req.Limit { - candidates = candidates[:req.Limit] - } - owners := make(map[int64]struct{}) - ids := make([]int, 0, len(candidates)) - for _, item := range candidates { - if s.reactions[req.ChannelID] != nil && s.reactions[req.ChannelID][item.id] != nil { - delete(s.reactions[req.ChannelID][item.id], req.ParticipantUserID) - } - if item.sender != 0 { - owners[item.sender] = struct{}{} - } - ids = append(ids, item.id) - } - for ownerID := range owners { - s.refreshChannelUnreadReactionsDialogLocked(ownerID, req.ChannelID) - } - messages := make([]domain.ChannelMessage, 0, len(ids)) - for _, id := range ids { - msg, ok := s.findMessageLocked(req.ChannelID, id) - if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { - continue - } - reactions := s.channelMessageReactionsLocked(req.UserID, channel, id) - outMsg := cloneChannelMessage(msg) - outMsg.Reactions = cloneChannelMessageReactionsPtr(&reactions) - messages = append(messages, outMsg) - } - sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) - return domain.DeleteChannelParticipantReactionsResult{ - Channel: cloneChannel(channel), - Messages: messages, - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - Deleted: len(ids), - }, nil -} - -func (s *ChannelStore) GetChannelMessageReactions(_ context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - if len(req.IDs) > domain.MaxGetMessageIDs { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - s.mu.RLock() - defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - wanted := make(map[int]struct{}, len(req.IDs)) - for _, id := range req.IDs { - if id <= 0 || id > domain.MaxMessageBoxID { - return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - wanted[id] = struct{}{} - } - messages := make([]domain.ChannelMessage, 0, len(wanted)) - for _, msg := range s.messages[req.ChannelID] { - if _, ok := wanted[msg.ID]; !ok { - continue - } - if msg.Deleted || msg.ID <= member.AvailableMinID { - continue - } - item := cloneChannelMessage(msg) - reactions := s.channelMessageReactionsLocked(req.UserID, channel, msg.ID) - item.Reactions = cloneChannelMessageReactionsPtr(&reactions) - messages = append(messages, item) - } - sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) - res := domain.ChannelMessageReactionsResult{ - Channel: cloneChannel(channel), - Messages: messages, - } - if len(messages) == 1 { - res.Message = messages[0] - if messages[0].Reactions != nil { - res.Reactions = cloneChannelMessageReactions(*messages[0].Reactions) - } - } - return res, nil -} - -func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { - return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid - } - if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit { - req.Limit = domain.MaxChannelMessageReactionListLimit - } - s.mu.RLock() - defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelMessageReactionsList{}, err - } - if channel.Broadcast && !channel.Megagroup { - return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden - } - msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) - if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { - return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid - } - rows := s.channelMessageReactionRowsLocked(req.ChannelID, req.MessageID, req.UserID, req.Reaction) - sort.Slice(rows, func(i, j int) bool { - if rows[i].Date != rows[j].Date { - return rows[i].Date > rows[j].Date - } - if rows[i].UserID != rows[j].UserID { - return rows[i].UserID > rows[j].UserID - } - return rows[i].Reaction.Emoticon < rows[j].Reaction.Emoticon - }) - total := len(rows) - if req.Offset != "" { - if offset, ok := parseMemoryReactionOffset(req.Offset); ok { - filtered := rows[:0] - for _, row := range rows { - if memoryReactionAfterOffset(row, offset) { - filtered = append(filtered, row) - } - } - rows = filtered - } - } - next := "" - if len(rows) > req.Limit { - rows = rows[:req.Limit] - next = memoryReactionOffset(rows[len(rows)-1]) - } - return domain.ChannelMessageReactionsList{ - Channel: cloneChannel(channel), - Message: cloneChannelMessage(msg), - Count: total, - Reactions: cloneChannelPeerReactions(rows), - NextOffset: next, - }, nil -} - -func (s *ChannelStore) RecordMessageReactionUse(_ context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { - if userID == 0 || len(reactions) == 0 { - return nil - } - if date == 0 { - date = int(time.Now().Unix()) - } - s.mu.Lock() - defer s.mu.Unlock() - if s.top[userID] == nil { - s.top[userID] = make(map[string]domain.TopMessageReaction) - } - if addToRecent && s.recent[userID] == nil { - s.recent[userID] = make(map[string]domain.RecentMessageReaction) - } - for _, reaction := range reactions { - if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" { - continue - } - key := messageReactionKey(reaction) - row := s.top[userID][key] - row.UserID = userID - row.Reaction = reaction - row.Count++ - row.Date = date - s.top[userID][key] = row - if addToRecent { - s.recent[userID][key] = domain.RecentMessageReaction{ - UserID: userID, - Reaction: reaction, - Date: date, - } - } - } - return nil -} - -func (s *ChannelStore) ListTopMessageReactions(_ context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { - if userID == 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 { - return []domain.MessageReaction{}, nil - } - if limit > domain.MaxTopMessageReactions { - limit = domain.MaxTopMessageReactions - } - s.mu.RLock() - defer s.mu.RUnlock() - rows := make([]domain.TopMessageReaction, 0, len(s.top[userID])) - for _, row := range s.top[userID] { - rows = append(rows, row) - } - sort.Slice(rows, func(i, j int) bool { - if rows[i].Count != rows[j].Count { - return rows[i].Count > rows[j].Count - } - if rows[i].Date != rows[j].Date { - return rows[i].Date > rows[j].Date - } - if rows[i].Reaction.Type != rows[j].Reaction.Type { - return rows[i].Reaction.Type < rows[j].Reaction.Type - } - return rows[i].Reaction.Emoticon < rows[j].Reaction.Emoticon - }) - if len(rows) > limit { - rows = rows[:limit] - } - out := make([]domain.MessageReaction, 0, len(rows)) - for _, row := range rows { - out = append(out, row.Reaction) - } - return out, nil -} - -func (s *ChannelStore) ListRecentMessageReactions(_ context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { - if userID == 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 { - return []domain.MessageReaction{}, nil - } - if limit > domain.MaxRecentMessageReactions { - limit = domain.MaxRecentMessageReactions - } - s.mu.RLock() - defer s.mu.RUnlock() - rows := make([]domain.RecentMessageReaction, 0, len(s.recent[userID])) - for _, row := range s.recent[userID] { - rows = append(rows, row) - } - sort.Slice(rows, func(i, j int) bool { - if rows[i].Date != rows[j].Date { - return rows[i].Date > rows[j].Date - } - if rows[i].Reaction.Type != rows[j].Reaction.Type { - return rows[i].Reaction.Type < rows[j].Reaction.Type - } - return rows[i].Reaction.Emoticon < rows[j].Reaction.Emoticon - }) - if len(rows) > limit { - rows = rows[:limit] - } - out := make([]domain.MessageReaction, 0, len(rows)) - for _, row := range rows { - out = append(out, row.Reaction) - } - return out, nil -} - -func (s *ChannelStore) ClearRecentMessageReactions(_ context.Context, userID int64) error { - if userID == 0 { - return domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - delete(s.recent, userID) - return nil -} - -func (s *ChannelStore) ListSavedReactionTags(_ context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) { - if userID == 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 { - return []domain.SavedReactionTag{}, nil - } - if limit > domain.MaxSavedReactionTags { - limit = domain.MaxSavedReactionTags - } - s.mu.RLock() - defer s.mu.RUnlock() - rows := make([]domain.SavedReactionTag, 0, len(s.savedTags[userID])) - for _, row := range s.savedTags[userID] { - rows = append(rows, row) - } - sort.Slice(rows, func(i, j int) bool { - if rows[i].Count != rows[j].Count { - return rows[i].Count > rows[j].Count - } - if rows[i].Reaction.Type != rows[j].Reaction.Type { - return rows[i].Reaction.Type < rows[j].Reaction.Type - } - return rows[i].Reaction.Emoticon < rows[j].Reaction.Emoticon - }) - if len(rows) > limit { - rows = rows[:limit] - } - return rows, nil -} - -func (s *ChannelStore) UpsertSavedReactionTag(_ context.Context, tag domain.SavedReactionTag) error { - if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(tag.Reaction.Emoticon) == "" { - return domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - if s.savedTags[tag.UserID] == nil { - s.savedTags[tag.UserID] = make(map[string]domain.SavedReactionTag) - } - tag.Reaction.Emoticon = strings.TrimSpace(tag.Reaction.Emoticon) - if tag.Count < 0 { - tag.Count = 0 - } - s.savedTags[tag.UserID][messageReactionKey(tag.Reaction)] = tag - return nil -} - -func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.RandomID == 0 { - return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid - } - title := strings.TrimSpace(req.Title) - if title == "" && !req.TitleMissing { - return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid - } - if req.IconColor == 0 { - req.IconColor = domain.DefaultForumTopicIconColor - } - s.mu.Lock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - s.mu.Unlock() - return domain.CreateChannelForumTopicResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !channel.Forum || channel.Broadcast || !channel.Megagroup { - s.mu.Unlock() - return domain.CreateChannelForumTopicResult{}, domain.ErrChannelForumMissing - } - if !canSendChannelMessage(channel, member) { - s.mu.Unlock() - return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden - } - if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok { - if topic, ok := s.topics[req.ChannelID][id]; ok { - msg, _ := s.findMessageLocked(req.ChannelID, id) - event := s.eventForMessageLocked(req.ChannelID, id) - recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) - s.mu.Unlock() - return domain.CreateChannelForumTopicResult{ - Channel: cloneChannel(channel), - Topic: cloneChannelForumTopic(topic), - Message: cloneChannelMessage(msg), - Event: cloneChannelEvent(event), - Recipients: recipients, - Duplicate: true, - }, nil - } - } - s.mu.Unlock() - - res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: req.UserID, - ChannelID: req.ChannelID, - RandomID: req.RandomID, - SendAs: req.SendAs, - Action: &domain.ChannelMessageAction{ - Type: domain.ChannelActionTopicCreate, - Title: title, - IconColor: req.IconColor, - IconEmojiID: req.IconEmojiID, - TitleMissing: req.TitleMissing, - }, - Date: req.Date, - }) - if err != nil { - return domain.CreateChannelForumTopicResult{}, err - } - if res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionTopicCreate { - return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid - } - - s.mu.Lock() - defer s.mu.Unlock() - channel = s.channels[req.ChannelID] - if s.topics[req.ChannelID] == nil { - s.topics[req.ChannelID] = make(map[int]domain.ChannelForumTopic) - } - topic, ok := s.topics[req.ChannelID][res.Message.ID] - if !ok { - topic = domain.ChannelForumTopic{ - ChannelID: req.ChannelID, - TopicID: res.Message.ID, - CreatorUserID: req.UserID, - Title: title, - IconColor: req.IconColor, - IconEmojiID: req.IconEmojiID, - TitleMissing: req.TitleMissing, - Date: res.Message.Date, - TopMessageID: res.Message.ID, - ReadInboxMaxID: res.Message.ID, - ReadOutboxMaxID: res.Message.ID, - } - s.topics[req.ChannelID][topic.TopicID] = topic - } - return domain.CreateChannelForumTopicResult{ - Channel: cloneChannel(channel), - Topic: cloneChannelForumTopic(topic), - Message: cloneChannelMessage(res.Message), - Event: cloneChannelEvent(res.Event), - Recipients: append([]int64(nil), res.Recipients...), - Duplicate: res.Duplicate, - }, nil -} - -func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { - return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - s.mu.Unlock() - return domain.EditChannelForumTopicResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - topic, ok := s.topics[req.ChannelID][req.TopicID] - if !channel.Forum { - s.mu.Unlock() - return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing - } - if !ok { - s.mu.Unlock() - return domain.EditChannelForumTopicResult{}, domain.ErrMessageIDInvalid - } - if !canManageForumTopic(channel, member, topic, req.UserID) { - s.mu.Unlock() - return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired - } - next := topic - action := domain.ChannelMessageAction{Type: domain.ChannelActionTopicEdit} - changed := false - if req.Title != nil { - title := strings.TrimSpace(*req.Title) - if title == "" { - s.mu.Unlock() - return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid - } - if next.Title != title { - next.Title = title - action.Title = title - changed = true - } - } - if req.IconEmojiID != nil && next.IconEmojiID != *req.IconEmojiID { - next.IconEmojiID = *req.IconEmojiID - action.IconEmojiID = *req.IconEmojiID - action.IconEmojiIDSet = true - changed = true - } - if req.Closed != nil && next.Closed != *req.Closed { - next.Closed = *req.Closed - action.Closed = boolPtr(*req.Closed) - changed = true - } - if req.Hidden != nil && next.Hidden != *req.Hidden { - next.Hidden = *req.Hidden - action.Hidden = boolPtr(*req.Hidden) - changed = true - } - s.mu.Unlock() - if !changed { - return domain.EditChannelForumTopicResult{}, domain.ErrChannelNotModified - } - - res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: req.UserID, - ChannelID: req.ChannelID, - ReplyTo: &domain.MessageReply{ - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, - MessageID: req.TopicID, - TopMessageID: req.TopicID, - }, - Action: &action, - Date: req.Date, - }) - if err != nil { - return domain.EditChannelForumTopicResult{}, err - } - - s.mu.Lock() - defer s.mu.Unlock() - channel = s.channels[req.ChannelID] - if _, ok := s.topics[req.ChannelID][req.TopicID]; !ok { - return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing - } - next.TopMessageID = maxInt(next.TopMessageID, res.Message.ID) - s.topics[req.ChannelID][req.TopicID] = next - return domain.EditChannelForumTopicResult{ - Channel: cloneChannel(channel), - Topic: cloneChannelForumTopic(next), - Message: cloneChannelMessage(res.Message), - Event: cloneChannelEvent(res.Event), - Recipients: append([]int64(nil), res.Recipients...), - }, nil -} - -func (s *ChannelStore) UpdatePinnedForumTopic(_ context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { - return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.UpdateChannelForumTopicPinnedResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - topic, ok := s.topics[req.ChannelID][req.TopicID] - if !channel.Forum { - return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelForumMissing - } - if !ok { - return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrMessageIDInvalid - } - if !canPinChannelMessages(channel, member) { - return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelAdminRequired - } - if topic.Pinned == req.Pinned { - return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelNotModified - } - topic.Pinned = req.Pinned - if req.Pinned && topic.PinnedOrder == 0 { - topic.PinnedOrder = s.nextForumTopicPinnedOrderLocked(req.ChannelID) - } - if !req.Pinned { - topic.PinnedOrder = 0 - } - s.topics[req.ChannelID][req.TopicID] = topic - return domain.UpdateChannelForumTopicPinnedResult{ - Channel: cloneChannel(channel), - Topic: cloneChannelForumTopic(topic), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - }, nil -} - -func (s *ChannelStore) ReorderPinnedForumTopics(_ context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs { - return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ReorderChannelPinnedForumTopicsResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - if !channel.Forum { - return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelForumMissing - } - if !canPinChannelMessages(channel, member) { - return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelAdminRequired - } - seen := make(map[int]struct{}, len(req.Order)) - order := make([]int, 0, len(req.Order)) - for _, id := range req.Order { - if id <= 0 || id > domain.MaxMessageBoxID { - return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid - } - if _, ok := seen[id]; ok { - continue - } - topic, ok := s.topics[req.ChannelID][id] - if !ok || !topic.Pinned { - if req.Force { - continue - } - return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid - } - seen[id] = struct{}{} - order = append(order, id) - } - for i, id := range order { - topic := s.topics[req.ChannelID][id] - topic.PinnedOrder = len(order) - i - s.topics[req.ChannelID][id] = topic - } - return domain.ReorderChannelPinnedForumTopicsResult{ - Channel: cloneChannel(channel), - Order: append([]int(nil), order...), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - }, nil -} - -func (s *ChannelStore) DeleteForumTopicHistory(_ context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - topic, ok := s.topics[req.ChannelID][req.TopicID] - if !channel.Forum { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelForumMissing - } - if !ok { - return domain.DeleteChannelHistoryResult{}, domain.ErrMessageIDInvalid - } - if !canManageForumTopic(channel, member, topic, req.UserID) && !canDeleteAnyChannelMessage(member) { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired - } - ids := make([]int, 0, domain.MaxDeleteHistoryBatch) - for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- { - msg := s.messages[req.ChannelID][i] - if msg.Deleted { - continue - } - if msg.ID != req.TopicID && (msg.ReplyTo == nil || msg.ReplyTo.TopMessageID != req.TopicID) { - continue - } - ids = append(ids, msg.ID) - if len(ids) >= domain.MaxDeleteHistoryBatch { - break - } - } - deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - offset := 0 - if s.topicHasVisibleMessagesLocked(req.ChannelID, req.TopicID) { - offset = 1 - } else { - delete(s.topics[req.ChannelID], req.TopicID) - } - return domain.DeleteChannelHistoryResult{ - Channel: cloneChannel(channel), - Event: cloneChannelEvent(event), - DeletedIDs: append([]int(nil), deleted...), - Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), - Offset: offset, - }, nil -} - -func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) - if err != nil { - return domain.ChannelForumTopicList{}, err - } - if !channel.Forum { - return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing - } - limit := filter.Limit - if limit <= 0 || limit > domain.MaxChannelForumTopicsLimit { - limit = domain.MaxChannelForumTopicsLimit - } - query := strings.TrimSpace(strings.ToLower(filter.Query)) - all := make([]domain.ChannelForumTopic, 0, len(s.topics[filter.ChannelID])) - for _, topic := range s.topics[filter.ChannelID] { - if topic.TopicID <= member.AvailableMinID { - continue - } - if query != "" && !strings.Contains(strings.ToLower(topic.Title), query) { - continue - } - if forumTopicBeforeOrAtOffset(topic, filter) { - continue - } - all = append(all, s.topicWithViewerCountersLocked(viewerUserID, filter.ChannelID, topic, member)) - } - sortForumTopics(all) - count := len(all) - if len(all) > limit { - all = all[:limit] - } - messages := s.forumTopicRootMessagesLocked(filter.ChannelID, all, member.AvailableMinID) - return domain.ChannelForumTopicList{ - Channel: cloneChannel(channel), - Dialog: s.dialogForUserLocked(viewerUserID, channel), - Topics: all, - Messages: messages, - Count: count, - }, nil -} - -func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID) - if err != nil { - return domain.ChannelForumTopicList{}, err - } - if !channel.Forum { - return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing - } - wanted := make(map[int]struct{}, len(ids)) - for _, id := range ids { - if id <= 0 || id > domain.MaxMessageBoxID { - return domain.ChannelForumTopicList{}, domain.ErrMessageIDInvalid - } - wanted[id] = struct{}{} - } - topics := make([]domain.ChannelForumTopic, 0, len(wanted)) - for id := range wanted { - topic, ok := s.topics[channelID][id] - if !ok || topic.TopicID <= member.AvailableMinID { - continue - } - topics = append(topics, s.topicWithViewerCountersLocked(viewerUserID, channelID, topic, member)) - } - sortForumTopics(topics) - messages := s.forumTopicRootMessagesLocked(channelID, topics, member.AvailableMinID) - return domain.ChannelForumTopicList{ - Channel: cloneChannel(channel), - Dialog: s.dialogForUserLocked(viewerUserID, channel), - Topics: topics, - Messages: messages, - Count: len(topics), - }, nil -} - -func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { - s.mu.RLock() - defer s.mu.RUnlock() - source, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) - if err != nil { - return domain.ChannelHistory{}, err - } - root, ok := s.findMessageLocked(filter.ChannelID, filter.RootMessageID) - if !ok || root.Deleted || root.ID <= member.AvailableMinID { - return domain.ChannelHistory{}, domain.ErrMessageIDInvalid - } - targetChannel := source - targetMember := member - rootID := root.ID - extraChannels := []domain.Channel(nil) - if source.Broadcast { - if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 { - return domain.ChannelHistory{Channel: source, Count: 0}, nil - } - linked, ok := s.channels[root.Discussion.ChannelID] - if !ok || linked.Deleted { - return domain.ChannelHistory{Channel: source, Count: 0}, nil - } - targetChannel = linked - rootID = root.Discussion.MessageID - if linkedMember, ok := s.members[linked.ID][viewerUserID]; ok { - targetMember = linkedMember - } else { - targetMember = domain.ChannelMember{} - } - extraChannels = append(extraChannels, source) - } - if targetRoot, ok := s.findMessageLocked(targetChannel.ID, rootID); !ok || targetRoot.Deleted { - return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Count: 0}, nil - } - limit := filter.Limit - if limit <= 0 || limit > domain.MaxChannelRepliesLimit { - limit = domain.MaxChannelRepliesLimit - } - filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) - base := make([]domain.ChannelMessage, 0, limit) - for _, msg := range s.messages[targetChannel.ID] { - if msg.Deleted || msg.ID <= targetMember.AvailableMinID { - continue - } - if !channelReplyBelongsToRoot(msg, targetChannel.ID, rootID) { - continue - } - if filter.MaxID > 0 && msg.ID >= filter.MaxID { - continue - } - if filter.MinID > 0 && msg.ID <= filter.MinID { - continue - } - if filter.OffsetDate > 0 && msg.Date == 0 { - continue - } - base = append(base, msg) - } - sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) }) - page := pageChannelMessageHistory(base, filter, limit) - out := make([]domain.ChannelMessage, 0, len(page)) - for _, msg := range page { - out = append(out, cloneChannelMessage(msg)) - } - s.populateChannelMessageRepliesLocked(viewerUserID, targetChannel.ID, out) - s.populateChannelMessageReactionsLocked(viewerUserID, targetChannel, out) - topics := []domain.ChannelForumTopic(nil) - if targetChannel.Forum { - if topic, ok := s.topics[targetChannel.ID][rootID]; ok && !topic.Hidden { - topic = s.topicWithViewerCountersLocked(viewerUserID, targetChannel.ID, topic, targetMember) - topics = append(topics, cloneChannelForumTopic(topic)) - } - } - return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Topics: topics, Messages: out, Count: len(base)}, nil -} - -func (s *ChannelStore) ListChannelUnreadMentions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) - if err != nil { - return domain.ChannelHistory{}, err - } - limit := filter.Limit - if limit <= 0 || limit > domain.MaxChannelUnreadMentionsLimit { - limit = domain.MaxChannelUnreadMentionsLimit - } - filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) - base := make([]domain.ChannelMessage, 0, limit) - for msgID, topID := range s.mentions[viewerUserID][filter.ChannelID] { - if filter.TopMsgID > 0 && topID != filter.TopMsgID { - continue - } - msg, ok := s.findMessageLocked(filter.ChannelID, msgID) - if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { - continue - } - if filter.MaxID > 0 && msg.ID >= filter.MaxID { - continue - } - if filter.MinID > 0 && msg.ID <= filter.MinID { - continue - } - base = append(base, msg) - } - sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) }) - page := pageChannelMessageHistory(base, domain.ChannelRepliesFilter{ - OffsetID: filter.OffsetID, - OffsetDate: filter.OffsetDate, - AddOffset: filter.AddOffset, - Limit: limit, - MaxID: filter.MaxID, - MinID: filter.MinID, - }, limit) - out := make([]domain.ChannelMessage, 0, len(page)) - for _, msg := range page { - out = append(out, cloneChannelMessage(msg)) - } - s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) - s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) - return domain.ChannelHistory{Channel: channel, Messages: out, Count: len(base)}, nil -} - -func (s *ChannelStore) ReadChannelMentions(_ context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ReadChannelMentionsResult{}, err - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelReadMentionsBatch { - limit = domain.MaxChannelReadMentionsBatch - } - msgIDs := make([]int, 0, limit) - for msgID, topID := range s.mentions[req.UserID][req.ChannelID] { - if req.TopMsgID > 0 && topID != req.TopMsgID { - continue - } - msgIDs = append(msgIDs, msgID) - } - sort.Sort(sort.Reverse(sort.IntSlice(msgIDs))) - if len(msgIDs) > limit { - msgIDs = msgIDs[:limit] - } - for _, msgID := range msgIDs { - delete(s.mentions[req.UserID][req.ChannelID], msgID) - } - remaining := s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, req.TopMsgID) - if dialogs := s.dialogs[req.UserID]; dialogs != nil { - dialog := dialogs[req.ChannelID] - dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, 0) - dialog.UserID = req.UserID - dialog.ChannelID = req.ChannelID - dialogs[req.ChannelID] = dialog - } - offset := 0 - if remaining > 0 { - offset = 1 - } - return domain.ReadChannelMentionsResult{ - Channel: channel, - Cleared: len(msgIDs), - Remaining: remaining, - Offset: offset, - ChannelPts: channel.Pts, - }, nil -} - -func (s *ChannelStore) ListChannelUnreadReactions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) - if err != nil { - return domain.ChannelHistory{}, err - } - limit := filter.Limit - if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit { - limit = domain.MaxChannelUnreadReactionsLimit - } - filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) - base := make([]domain.ChannelMessage, 0, limit) - for msgID, byUser := range s.reactions[filter.ChannelID] { - msg, ok := s.findMessageLocked(filter.ChannelID, msgID) - if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { - continue - } - if filter.TopMsgID > 0 && msg.ID != filter.TopMsgID && channelMentionTopID(msg) != filter.TopMsgID { - continue - } - if filter.MaxID > 0 && msg.ID >= filter.MaxID { - continue - } - if filter.MinID > 0 && msg.ID <= filter.MinID { - continue - } - if !channelMessageHasUnreadReactionForUser(byUser, viewerUserID) { - continue - } - base = append(base, msg) - } - sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) }) - page := pageChannelMessageHistory(base, domain.ChannelRepliesFilter{ - OffsetID: filter.OffsetID, - AddOffset: filter.AddOffset, - Limit: limit, - MaxID: filter.MaxID, - MinID: filter.MinID, - }, limit) - out := make([]domain.ChannelMessage, 0, len(page)) - for _, msg := range page { - out = append(out, cloneChannelMessage(msg)) - } - s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) - s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) - return domain.ChannelHistory{Channel: channel, Messages: out, Count: len(base)}, nil -} - -func (s *ChannelStore) ReadChannelReactions(_ context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid - } - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ReadChannelReactionsResult{}, err - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelReadReactionsBatch { - limit = domain.MaxChannelReadReactionsBatch - } - msgIDs := make([]int, 0, limit) - for msgID, byUser := range s.reactions[req.ChannelID] { - msg, ok := s.findMessageLocked(req.ChannelID, msgID) - if !ok || msg.Deleted { - continue - } - if req.TopMsgID > 0 && msg.ID != req.TopMsgID && channelMentionTopID(msg) != req.TopMsgID { - continue - } - if channelMessageHasUnreadReactionForUser(byUser, req.UserID) { - msgIDs = append(msgIDs, msgID) - } - } - sort.Sort(sort.Reverse(sort.IntSlice(msgIDs))) - if len(msgIDs) > limit { - msgIDs = msgIDs[:limit] - } - for _, msgID := range msgIDs { - for reactedUserID, rows := range s.reactions[req.ChannelID][msgID] { - changed := false - for i := range rows { - if rows[i].SenderUserID == req.UserID && rows[i].Unread { - rows[i].Unread = false - changed = true - } - } - if changed { - s.reactions[req.ChannelID][msgID][reactedUserID] = rows - } - } - } - remaining := s.countChannelUnreadReactionsLocked(req.UserID, req.ChannelID, req.TopMsgID) - s.refreshChannelUnreadReactionsDialogLocked(req.UserID, req.ChannelID) - offset := 0 - if remaining > 0 { - offset = 1 - } - return domain.ReadChannelReactionsResult{ - Channel: channel, - Cleared: len(msgIDs), - Remaining: remaining, - Offset: offset, - ChannelPts: channel.Pts, - }, nil -} - -func (s *ChannelStore) GetDiscussionMessage(_ context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) { - s.mu.RLock() - defer s.mu.RUnlock() - source, member, err := s.channelAndMemberLocked(viewerUserID, channelID) - if err != nil { - return domain.ChannelDiscussionMessage{}, err - } - msg, ok := s.findMessageLocked(channelID, msgID) - if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { - return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid - } - result := domain.ChannelDiscussionMessage{PostChannel: source, DiscussionChannel: source, Channels: []domain.Channel{source}} - targetChannel := source - targetMsg := msg - targetMember := member - if source.Broadcast { - if msg.Discussion == nil || msg.Discussion.ChannelID == 0 || msg.Discussion.MessageID == 0 { - return result, nil - } - linked, ok := s.channels[msg.Discussion.ChannelID] - if !ok || linked.Deleted { - return result, nil - } - linkedMsg, ok := s.findMessageLocked(linked.ID, msg.Discussion.MessageID) - if !ok || linkedMsg.Deleted { - return result, nil - } - targetChannel = linked - targetMsg = linkedMsg - if linkedMember, ok := s.members[linked.ID][viewerUserID]; ok { - targetMember = linkedMember - } else { - targetMember = domain.ChannelMember{} - } - result.DiscussionChannel = linked - result.Channels = []domain.Channel{source, linked} - } - items := []domain.ChannelMessage{cloneChannelMessage(targetMsg)} - s.populateChannelMessageRepliesLocked(viewerUserID, targetChannel.ID, items) - s.populateChannelMessageReactionsLocked(viewerUserID, targetChannel, items) - if stats := s.channelMessageRepliesLocked(viewerUserID, targetChannel.ID, targetMsg); stats != nil { - result.MaxID = stats.MaxID - } - result.ReadInboxMaxID = targetMember.ReadInboxMaxID - result.ReadOutboxMaxID = targetMember.ReadOutboxMaxID - result.UnreadCount = s.channelThreadUnreadCountLocked(viewerUserID, targetChannel.ID, targetMsg.ID, targetMember.ReadInboxMaxID) - result.Messages = items - return result, nil -} - -func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { - s.mu.Lock() - defer s.mu.Unlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ReadChannelHistoryResult{}, err - } - maxID := req.MaxID - if maxID <= 0 || maxID > channel.TopMessageID { - maxID = channel.TopMessageID - } - member := s.members[req.ChannelID][req.UserID] - previous := member.ReadInboxMaxID - changed := maxID > member.ReadInboxMaxID - var outboxUpdates []domain.ChannelReadOutboxUpdate - if changed { - member.ReadInboxMaxID = maxID - member.ReadInboxDate = req.Date - member.UnreadMark = false - s.members[req.ChannelID][req.UserID] = member - outboxUpdates = s.advanceChannelReadOutboxLocked(req.ChannelID, req.UserID, previous, maxID) - } - dialog := s.dialogForUserLocked(req.UserID, channel) - dialog.ReadInboxMaxID = member.ReadInboxMaxID - dialog.UnreadCount = s.channelUnreadCountLocked(req.UserID, channel.ID, member.ReadInboxMaxID, dialog.TopMessageID) - dialog.UnreadMark = false - if s.dialogs[req.UserID] == nil { - s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) - } - s.dialogs[req.UserID][req.ChannelID] = dialog - return domain.ReadChannelHistoryResult{ - ChannelID: req.ChannelID, - MaxID: maxID, - StillUnreadCount: dialog.UnreadCount, - Changed: changed, - Pts: channel.Pts, - Dialog: dialog, - OutboxUpdates: outboxUpdates, - }, nil -} - -func (s *ChannelStore) advanceChannelReadOutboxLocked(channelID, readerUserID int64, previous, maxID int) []domain.ChannelReadOutboxUpdate { - if maxID <= previous { - return nil - } - lowerID := previous - if maxID-lowerID > domain.MaxChannelReadOutboxScanMessages { - lowerID = maxID - domain.MaxChannelReadOutboxScanMessages - } - bySender := make(map[int64]int, domain.MaxChannelReadOutboxFanout) - messages := s.messages[channelID] - for i := len(messages) - 1; i >= 0; i-- { - msg := messages[i] - if msg.ID <= lowerID { - break - } - if msg.ID > maxID || msg.Deleted || msg.SenderUserID == 0 || msg.SenderUserID == readerUserID { - continue - } - if _, ok := bySender[msg.SenderUserID]; ok { - continue - } - bySender[msg.SenderUserID] = msg.ID - if len(bySender) >= domain.MaxChannelReadOutboxFanout { - break - } - } - if len(bySender) == 0 { - return nil - } - senderIDs := make([]int64, 0, len(bySender)) - for userID := range bySender { - senderIDs = append(senderIDs, userID) - } - sort.Slice(senderIDs, func(i, j int) bool { return senderIDs[i] < senderIDs[j] }) - channel := s.channels[channelID] - out := make([]domain.ChannelReadOutboxUpdate, 0, len(senderIDs)) - for _, userID := range senderIDs { - maxForSender := bySender[userID] - member, ok := s.members[channelID][userID] - if !ok || member.Status != domain.ChannelMemberActive || maxForSender <= member.ReadOutboxMaxID { - continue - } - member.ReadOutboxMaxID = maxForSender - s.members[channelID][userID] = member - dialog := s.dialogForUserLocked(userID, channel) - if dialog.ReadOutboxMaxID < maxForSender { - dialog.ReadOutboxMaxID = maxForSender - } - if s.dialogs[userID] == nil { - s.dialogs[userID] = make(map[int64]domain.ChannelDialog) - } - s.dialogs[userID][channelID] = dialog - out = append(out, domain.ChannelReadOutboxUpdate{UserID: userID, MaxID: maxForSender}) - } - return out -} - -func (s *ChannelStore) ListMessageReadParticipants(_ context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelReadParticipantsResult{}, err - } - member := s.members[req.ChannelID][req.UserID] - msg, found := s.findMessageLocked(req.ChannelID, req.MessageID) - if !found || msg.Deleted || msg.ID <= member.AvailableMinID { - return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid - } - result := domain.ChannelReadParticipantsResult{ - Channel: channel, - Message: cloneChannelMessage(msg), - } - if !channel.Megagroup || channel.ParticipantsHidden || channel.ParticipantsCount > domain.MaxChannelReadParticipants { - return result, nil - } - now := req.Date - if now > 0 && msg.Date+domain.ChannelReadMarkExpirePeriod <= now { - return result, nil - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelReadParticipants { - limit = domain.MaxChannelReadParticipants - } - for _, reader := range s.members[req.ChannelID] { - if reader.UserID == req.UserID || reader.Status != domain.ChannelMemberActive || reader.BannedRights.ViewMessages { - continue - } - if reader.ReadInboxDate <= 0 { - continue - } - if reader.AvailableMinID >= req.MessageID || reader.ReadInboxMaxID < req.MessageID { - continue - } - result.Participants = append(result.Participants, domain.ChannelReadParticipant{ - UserID: reader.UserID, - Date: reader.ReadInboxDate, - }) - if len(result.Participants) >= limit { - break - } - } - sort.Slice(result.Participants, func(i, j int) bool { - if result.Participants[i].Date == result.Participants[j].Date { - return result.Participants[i].UserID < result.Participants[j].UserID - } - return result.Participants[i].Date < result.Participants[j].Date - }) - return result, nil -} - -func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, member, preview, err := s.channelForViewerLocked(req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelDifference{}, err - } - if req.Pts < 0 || req.Pts > channel.Pts { - return domain.ChannelDifference{}, domain.ErrPersistentTimestamp - } - if !preview && member.AvailableMinPts > req.Pts { - req.Pts = minInt(member.AvailableMinPts, channel.Pts) - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { - limit = domain.MaxChannelDifferenceLimit - } - dialog := s.dialogForUserLocked(req.UserID, channel) - if preview { - dialog = previewChannelDialog(req.UserID, channel, member) - } - if channel.Pts-req.Pts > limit { - messages := make([]domain.ChannelMessage, 0, domain.MaxChannelDifferenceTooLongMessages) - for i := len(s.messages[req.ChannelID]) - 1; i >= 0 && len(messages) < domain.MaxChannelDifferenceTooLongMessages; i-- { - msg := s.messages[req.ChannelID][i] - if msg.Deleted { - continue - } - if msg.ID <= member.AvailableMinID { - continue - } - messages = append(messages, cloneChannelMessage(msg)) - } - s.populateChannelMessageUnreadFlagsLocked(req.UserID, messages) - return domain.ChannelDifference{ - Channel: channel, - Self: member, - NewMessages: messages, - Pts: channel.Pts, - Final: true, - TooLong: true, - Timeout: 30, - Dialog: dialog, - }, nil - } - events := make([]domain.ChannelUpdateEvent, 0, limit) - lastPts := req.Pts - for _, event := range s.events[req.ChannelID] { - if event.Pts <= req.Pts { - continue - } - lastPts = event.Pts - visible, ok := domain.FilterChannelUpdateEventForAvailableMinID(cloneChannelEvent(event), member.AvailableMinID) - if !ok { - continue - } - if preview && visible.Type == domain.ChannelUpdateParticipant { - continue - } - events = append(events, visible) - } - if len(events) == 0 { - return domain.ChannelDifference{ - Channel: channel, - Self: member, - Pts: maxInt(lastPts, req.Pts), - Final: true, - Timeout: 30, - Dialog: dialog, - }, nil - } - diff := domain.ChannelDifference{ - Channel: channel, - Self: member, - Events: events, - Pts: lastPts, - Final: lastPts >= channel.Pts, - Timeout: 30, - Dialog: dialog, - } - for _, event := range events { - switch event.Type { - case domain.ChannelUpdateNewMessage: - diff.NewMessages = append(diff.NewMessages, cloneChannelMessage(event.Message)) - default: - diff.OtherUpdates = append(diff.OtherUpdates, cloneChannelEvent(event)) - } - } - s.populateChannelMessageUnreadFlagsLocked(req.UserID, diff.NewMessages) - for i := range diff.OtherUpdates { - if diff.OtherUpdates[i].Message.ID == 0 { - continue - } - messages := []domain.ChannelMessage{diff.OtherUpdates[i].Message} - s.populateChannelMessageUnreadFlagsLocked(req.UserID, messages) - diff.OtherUpdates[i].Message = messages[0] - } - return diff, nil -} - -func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - if userID == 0 || afterChannelID < 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { - limit = domain.MaxSynchronousChannelDialogFanout - } - out := make([]int64, 0, limit) - for channelID, members := range s.members { - if channelID <= afterChannelID { - continue - } - channel, ok := s.channels[channelID] - if !ok || channel.Deleted { - continue - } - member, ok := members[userID] - if !ok || member.Status != domain.ChannelMemberActive { - continue - } - out = append(out, channelID) - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - if len(out) > limit { - out = out[:limit] - } - return out, nil -} - -func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) { - s.mu.RLock() - defer s.mu.RUnlock() - if userID == 0 || sinceDate <= 0 || afterChannelID < 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { - limit = domain.MaxChannelDifferenceLimit - } - out := make([]domain.DirtyChannel, 0, limit) - for channelID, members := range s.members { - if channelID <= afterChannelID { - continue - } - channel, ok := s.channels[channelID] - if !ok || channel.Deleted { - continue - } - member, ok := members[userID] - if !ok || member.Status != domain.ChannelMemberActive { - continue - } - dirty := false - for _, event := range s.events[channelID] { - if event.Date > sinceDate { - dirty = true - break - } - } - if dirty { - out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}) - } - } - sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID }) - if len(out) > limit { - out = out[:limit] - } - return out, nil -} - -func (s *ChannelStore) ListActiveChannelMemberIDs(_ context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { - return nil, err - } - return s.activeMemberIDsLocked(channelID, 0, limit), nil -} - -func (s *ChannelStore) ListChannelInviteAdminMemberIDs(_ context.Context, channelID int64, limit int) ([]int64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - channel, ok := s.channels[channelID] - if channelID == 0 || !ok || channel.Deleted { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { - limit = domain.MaxChannelRealtimeFanout - } - members := s.members[channelID] - out := make([]int64, 0, minInt(len(members), limit)) - for _, member := range members { - if member.Status != domain.ChannelMemberActive { - continue - } - if member.Role == domain.ChannelRoleCreator { - out = append(out, member.UserID) - if len(out) >= limit { - break - } - continue - } - if member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo) { - out = append(out, member.UserID) - if len(out) >= limit { - break - } - } - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out, nil -} - -func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - if channelID == 0 || len(userIDs) == 0 { - return nil, nil - } - members := s.members[channelID] - if len(members) == 0 { - return nil, nil - } - out := make([]int64, 0, len(userIDs)) - seen := make(map[int64]struct{}, len(userIDs)) - for _, userID := range userIDs { - if userID == 0 { - continue - } - if _, ok := seen[userID]; ok { - continue - } - seen[userID] = struct{}{} - member, ok := members[userID] - if !ok || member.Status != domain.ChannelMemberActive { - continue - } - out = append(out, userID) - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out, nil -} - -func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.ptsSeq[channelID], nil -} - -func (s *ChannelStore) MaxChannelMessageID(_ context.Context, channelID int64) (int, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.msgSeq[channelID], nil -} - -func (s *ChannelStore) nextChannelIDLocked() int64 { - id := s.nextID - s.nextID++ - return id -} - -func (s *ChannelStore) nextAccessHashLocked() int64 { - hash := s.nextHash - s.nextHash += 17 - return hash -} - -func (s *ChannelStore) nextChannelMessageIDLocked(channelID int64) int { - s.msgSeq[channelID]++ - return s.msgSeq[channelID] -} - -func (s *ChannelStore) nextChannelPtsLocked(channelID int64) int { - s.ptsSeq[channelID]++ - return s.ptsSeq[channelID] -} - -func (s *ChannelStore) nextChannelPtsNLocked(channelID int64, count int) int { - if count <= 0 { - return s.ptsSeq[channelID] - } - s.ptsSeq[channelID] += count - return s.ptsSeq[channelID] -} - -func (s *ChannelStore) appendChannelServiceMessageLocked(channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent) { - channel := s.channels[channelID] - pts := s.nextChannelPtsLocked(channelID) - msg := domain.ChannelMessage{ - ChannelID: channelID, - ID: s.nextChannelMessageIDLocked(channelID), - SenderUserID: senderUserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, - Date: date, - Post: channel.Broadcast, - Action: &action, - Pts: pts, - } - event := domain.ChannelUpdateEvent{ - ChannelID: channelID, - Type: domain.ChannelUpdateNewMessage, - Pts: pts, - PtsCount: 1, - Date: date, - Message: cloneChannelMessage(msg), - SenderUserID: senderUserID, - UserIDs: append([]int64(nil), action.UserIDs...), - } - s.messages[channelID] = append(s.messages[channelID], msg) - s.events[channelID] = append(s.events[channelID], event) - return msg, event -} - -func transientChannelParticipantEvent(channelID, actorUserID int64, previous, participant domain.ChannelMember, date int) domain.ChannelUpdateEvent { - return domain.ChannelUpdateEvent{ - ChannelID: channelID, - Type: domain.ChannelUpdateParticipant, - Date: date, - SenderUserID: actorUserID, - UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID), - Previous: previous, - Participant: participant, - } -} - -func (s *ChannelStore) channelForMemberLocked(userID, channelID int64) (domain.Channel, error) { - channel, _, err := s.channelAndMemberLocked(userID, channelID) - return channel, err -} - -func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, bool, error) { - channel, member, err := s.channelAndMemberLocked(userID, channelID) - if err == nil { - return channel, member, false, nil - } - if !errors.Is(err, domain.ErrChannelPrivate) { - return domain.Channel{}, domain.ChannelMember{}, false, err - } - channel, ok := s.channels[channelID] - if !ok || channel.Deleted { - return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelInvalid - } - existing, found := s.members[channelID][userID] - if found && (existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages) { - return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelUserBanned - } - if !publicPreviewableChannel(channel) { - return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate - } - return channel, publicPreviewMember(channel, userID, existing, found), true, nil -} - -func (s *ChannelStore) channelAndMemberLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, error) { - channel, ok := s.channels[channelID] - if !ok || channel.Deleted { - return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelInvalid - } - member, ok := s.members[channelID][userID] - if !ok || member.Status == domain.ChannelMemberLeft { - return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelPrivate - } - if member.Status == domain.ChannelMemberBanned || member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages { - return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelUserBanned - } - return channel, member, nil -} - -func (s *ChannelStore) upsertChannelDialogLocked(userID int64, channel domain.Channel, top domain.ChannelMessage, selfAction bool) { - if s.dialogs[userID] == nil { - s.dialogs[userID] = make(map[int64]domain.ChannelDialog) - } - dialog := s.dialogs[userID][channel.ID] - dialog.UserID = userID - dialog.ChannelID = channel.ID - dialog.TopMessageID = s.visibleTopMessageIDLocked(userID, channel) - if top.ID != 0 { - dialog.TopMessageDate = top.Date - } - member := s.members[channel.ID][userID] - if member.ReadInboxMaxID > dialog.ReadInboxMaxID { - dialog.ReadInboxMaxID = member.ReadInboxMaxID - } - if selfAction { - if channel.TopMessageID > dialog.ReadInboxMaxID { - dialog.ReadInboxMaxID = channel.TopMessageID - } - dialog.ReadOutboxMaxID = channel.TopMessageID - } - dialog.UnreadCount = s.channelUnreadCountLocked(userID, channel.ID, dialog.ReadInboxMaxID, dialog.TopMessageID) - dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, channel.ID, 0) - dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channel.ID, 0) - s.dialogs[userID][channel.ID] = dialog -} - -func (s *ChannelStore) dialogForUserLocked(userID int64, channel domain.Channel) domain.ChannelDialog { - dialog := s.dialogs[userID][channel.ID] - dialog.UserID = userID - dialog.ChannelID = channel.ID - member := s.members[channel.ID][userID] - dialog.TopMessageID = s.visibleTopMessageIDForMemberLocked(channel, member) - if member.ReadInboxMaxID > dialog.ReadInboxMaxID { - dialog.ReadInboxMaxID = member.ReadInboxMaxID - } - dialog.UnreadCount = s.channelUnreadCountLocked(userID, channel.ID, dialog.ReadInboxMaxID, dialog.TopMessageID) - dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, channel.ID, 0) - dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channel.ID, 0) - return dialog -} - -func (s *ChannelStore) findMessageLocked(channelID int64, id int) (domain.ChannelMessage, bool) { - for _, msg := range s.messages[channelID] { - if msg.ID == id { - return msg, true - } - } - return domain.ChannelMessage{}, false -} - -func (s *ChannelStore) findMessageIndexLocked(channelID int64, id int) (int, bool) { - for i, msg := range s.messages[channelID] { - if msg.ID == id { - return i, true - } - } - return 0, false -} - -func (s *ChannelStore) populateChannelMessageRepliesLocked(viewerUserID, channelID int64, messages []domain.ChannelMessage) { - for i := range messages { - messages[i].Replies = s.channelMessageRepliesLocked(viewerUserID, channelID, messages[i]) - } -} - -func (s *ChannelStore) channelMessageRepliesLocked(viewerUserID, channelID int64, msg domain.ChannelMessage) *domain.ChannelMessageReplies { - targetChannelID := channelID - rootID := msg.ID - stats := domain.ChannelMessageReplies{} - if msg.Discussion != nil && msg.Discussion.ChannelID != 0 && msg.Discussion.MessageID != 0 { - targetChannelID = msg.Discussion.ChannelID - rootID = msg.Discussion.MessageID - stats.Comments = true - stats.ChannelID = msg.Discussion.ChannelID - } else if channel, ok := s.channels[channelID]; ok && channel.Broadcast && channel.LinkedChatID != 0 && msg.Post { - stats.Comments = true - stats.ChannelID = channel.LinkedChatID - } - if rootID <= 0 { - return nil - } - if member, ok := s.members[targetChannelID][viewerUserID]; ok { - stats.ReadMaxID = member.ReadInboxMaxID - } - seenRecent := map[domain.Peer]struct{}{} - for i := len(s.messages[targetChannelID]) - 1; i >= 0; i-- { - reply := s.messages[targetChannelID][i] - if reply.Deleted || !channelReplyBelongsToRoot(reply, targetChannelID, rootID) { - continue - } - stats.Replies++ - if stats.MaxID == 0 || reply.ID > stats.MaxID { - stats.MaxID = reply.ID - stats.RepliesPts = reply.Pts - } - if len(stats.RecentRepliers) < 3 { - peer := reply.From - if peer.ID == 0 && reply.SenderUserID != 0 { - peer = domain.Peer{Type: domain.PeerTypeUser, ID: reply.SenderUserID} - } - if peer.ID != 0 { - if _, ok := seenRecent[peer]; !ok { - seenRecent[peer] = struct{}{} - stats.RecentRepliers = append(stats.RecentRepliers, peer) - } - } - } - } - if stats.Comments && stats.RepliesPts == 0 { - if root, ok := s.findMessageLocked(targetChannelID, rootID); ok { - stats.RepliesPts = root.Pts - } - } - if !stats.Comments && stats.Replies == 0 { - return nil - } - return &stats -} - -func (s *ChannelStore) channelThreadUnreadCountLocked(viewerUserID, channelID int64, rootID, readMaxID int) int { - unread := 0 - for _, msg := range s.messages[channelID] { - if msg.Deleted || msg.ID <= readMaxID || msg.SenderUserID == viewerUserID { - continue - } - if channelReplyBelongsToRoot(msg, channelID, rootID) { - unread++ - } - } - return unread -} - -func (s *ChannelStore) channelUnreadCountLocked(viewerUserID, channelID int64, readMaxID, topID int) int { - if viewerUserID == 0 || channelID == 0 || topID <= readMaxID { - return 0 - } - unread := 0 - for _, msg := range s.messages[channelID] { - if msg.Deleted || msg.ID <= readMaxID || msg.ID > topID || msg.SenderUserID == viewerUserID { - continue - } - unread++ - } - return unread -} - -func (s *ChannelStore) addChannelUnreadMentionsLocked(channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) { - if len(userIDs) == 0 || msg.ID == 0 { - return - } - seen := make(map[int64]struct{}, len(userIDs)) - written := 0 - topID := channelMentionTopID(msg) - for _, userID := range userIDs { - if userID == 0 || userID == senderUserID { - continue - } - if _, ok := seen[userID]; ok { - continue - } - seen[userID] = struct{}{} - member, ok := s.members[channelID][userID] - if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { - continue - } - if msg.ID <= member.AvailableMinID || msg.ID <= member.ReadInboxMaxID { - continue - } - if s.mentions[userID] == nil { - s.mentions[userID] = make(map[int64]map[int]int) - } - if s.mentions[userID][channelID] == nil { - s.mentions[userID][channelID] = make(map[int]int) - } - s.mentions[userID][channelID][msg.ID] = topID - written++ - if written == domain.MaxChannelMentionRecipients { - return - } - } -} - -func (s *ChannelStore) countChannelUnreadMentionsLocked(userID, channelID int64, topMsgID int) int { - count := 0 - for _, mentionTopID := range s.mentions[userID][channelID] { - if topMsgID == 0 || mentionTopID == topMsgID { - count++ - } - } - return count -} - -func (s *ChannelStore) countChannelUnreadReactionsLocked(userID, channelID int64, topMsgID int) int { - count := 0 - availableMinID := 0 - if member, ok := s.members[channelID][userID]; ok { - availableMinID = member.AvailableMinID - } - for msgID, byUser := range s.reactions[channelID] { - msg, ok := s.findMessageLocked(channelID, msgID) - if !ok || msg.Deleted || msg.ID <= availableMinID { - continue - } - if topMsgID > 0 && msg.ID != topMsgID && channelMentionTopID(msg) != topMsgID { - continue - } - if channelMessageHasUnreadReactionForUser(byUser, userID) { - count++ - } - } - return count -} - -func channelMessageHasUnreadReactionForUser(byUser map[int64][]domain.ChannelMessagePeerReaction, userID int64) bool { - for _, rows := range byUser { - for _, row := range rows { - if row.SenderUserID == userID && row.UserID != userID && row.Unread { - return true - } - } - } - return false -} - -func (s *ChannelStore) refreshChannelUnreadReactionsDialogLocked(userID, channelID int64) { - if userID == 0 || channelID == 0 { - return - } - channel, ok := s.channels[channelID] - if !ok { - return - } - member, ok := s.members[channelID][userID] - if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { - return - } - dialog := s.dialogForUserLocked(userID, channel) - dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channelID, 0) - if s.dialogs[userID] == nil { - s.dialogs[userID] = make(map[int64]domain.ChannelDialog) - } - s.dialogs[userID][channelID] = dialog -} - -func (s *ChannelStore) deleteChannelUnreadMentionsLocked(channelID int64, ids []int) { - if len(ids) == 0 { - return - } - set := make(map[int]struct{}, len(ids)) - for _, id := range ids { - set[id] = struct{}{} - } - for userID, byChannel := range s.mentions { - mentions := byChannel[channelID] - if len(mentions) == 0 { - continue - } - for id := range set { - delete(mentions, id) - } - if len(mentions) == 0 { - delete(byChannel, channelID) - } - if len(byChannel) == 0 { - delete(s.mentions, userID) - } - } -} - -func (s *ChannelStore) deleteChannelUnreadMentionsUpToLocked(userID, channelID int64, maxID int) { - if maxID <= 0 || len(s.mentions[userID][channelID]) == 0 { - return - } - for id := range s.mentions[userID][channelID] { - if id <= maxID { - delete(s.mentions[userID][channelID], id) - } - } - if len(s.mentions[userID][channelID]) == 0 { - delete(s.mentions[userID], channelID) - } - if len(s.mentions[userID]) == 0 { - delete(s.mentions, userID) - } -} - -func channelMentionTopID(msg domain.ChannelMessage) int { - if msg.ReplyTo == nil { - return 0 - } - if msg.ReplyTo.TopMessageID > 0 { - return msg.ReplyTo.TopMessageID - } - return msg.ReplyTo.MessageID -} - -func channelReplyBelongsToRoot(msg domain.ChannelMessage, channelID int64, rootID int) bool { - if msg.ReplyTo == nil || rootID <= 0 { - return false - } - if msg.ReplyTo.Peer.ID != 0 && msg.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) { - return false - } - return msg.ReplyTo.TopMessageID == rootID || (msg.ReplyTo.TopMessageID == 0 && msg.ReplyTo.MessageID == rootID) -} - -func pageChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int) []domain.ChannelMessage { - if limit <= 0 || len(base) == 0 { - return nil - } - switch messageHistoryLoadType(filter.AddOffset, limit) { - case messageHistoryLoadForward: - return forwardChannelMessageHistory(base, filter, limit) - case messageHistoryLoadAround: - forwardLimit := -filter.AddOffset - if forwardLimit > limit { - forwardLimit = limit - } - backwardLimit := limit + filter.AddOffset - if backwardLimit < 0 { - backwardLimit = 0 - } - page := make([]domain.ChannelMessage, 0, limit) - page = append(page, forwardChannelMessageHistory(base, filter, forwardLimit)...) - page = append(page, backwardChannelMessageHistory(base, filter, backwardLimit, true)...) - sort.SliceStable(page, func(i, j int) bool { return channelMessageLess(page[i], page[j]) }) - return page - default: - start := filter.AddOffset - if start < 0 { - start = 0 - } - candidates := backwardChannelMessageHistory(base, filter, limit+start, false) - if start >= len(candidates) { - return nil - } - return candidates[start:] - } -} - -func backwardChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int, includeOffset bool) []domain.ChannelMessage { - if limit <= 0 { - return nil - } - out := make([]domain.ChannelMessage, 0, limit) - for _, msg := range base { - if !channelMessageBeforeHistoryOffset(msg, filter, includeOffset) { - continue - } - out = append(out, msg) - if len(out) == limit { - break - } - } - return out -} - -func forwardChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int) []domain.ChannelMessage { - if limit <= 0 { - return nil - } - out := make([]domain.ChannelMessage, 0, limit) - for i := len(base) - 1; i >= 0; i-- { - msg := base[i] - if !channelMessageAfterHistoryOffset(msg, filter) { - continue - } - out = append(out, msg) - if len(out) == limit { - break - } - } - sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) - return out -} - -func channelMessageBeforeHistoryOffset(msg domain.ChannelMessage, filter domain.ChannelRepliesFilter, includeOffset bool) bool { - if filter.OffsetDate > 0 { - if includeOffset { - return msg.Date <= filter.OffsetDate - } - return msg.Date < filter.OffsetDate - } - if filter.OffsetID <= 0 { - return true - } - if includeOffset { - return msg.ID <= filter.OffsetID - } - return msg.ID < filter.OffsetID -} - -func channelMessageAfterHistoryOffset(msg domain.ChannelMessage, filter domain.ChannelRepliesFilter) bool { - if filter.OffsetDate > 0 { - return msg.Date >= filter.OffsetDate - } - if filter.OffsetID <= 0 { - return false - } - return msg.ID > filter.OffsetID -} - -func channelMessageLess(a, b domain.ChannelMessage) bool { - if a.Date != b.Date { - return a.Date > b.Date - } - return a.ID > b.ID -} - -func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel) (*domain.MessageReply, error) { - if req.ReplyTo == nil { - return nil, nil - } - if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { - return nil, err - } - peer := req.ReplyTo.Peer - channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID} - if peer.ID == 0 { - peer = channelPeer - } - if peer != channelPeer { - return nil, domain.ErrReplyMessageIDInvalid - } - if req.ReplyTo.MessageID == 0 { - if req.ReplyTo.TopMessageID <= 0 || !channel.Forum { - return nil, domain.ErrReplyMessageIDInvalid - } - topic, ok := s.topics[req.ChannelID][req.ReplyTo.TopMessageID] - if !ok || topic.Hidden { - return nil, domain.ErrReplyMessageIDInvalid - } - if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { - return nil, domain.ErrChannelWriteForbidden - } - reply := cloneMessageReply(req.ReplyTo) - reply.MessageID = 0 - reply.Peer = channelPeer - reply.TopMessageID = topic.TopicID - reply.ForumTopic = true - return reply, nil - } - target, ok := s.findMessageLocked(req.ChannelID, req.ReplyTo.MessageID) - if !ok || target.Deleted || target.ID <= member.AvailableMinID { - return nil, domain.ErrReplyMessageIDInvalid - } - reply := cloneMessageReply(req.ReplyTo) - reply.MessageID = target.ID - reply.Peer = channelPeer - reply.TopMessageID = target.ID - if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 { - reply.TopMessageID = target.ReplyTo.TopMessageID - } - if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID { - return nil, domain.ErrReplyMessageIDInvalid - } - if channel.Forum && reply.TopMessageID > 0 { - if topic, ok := s.topics[req.ChannelID][reply.TopMessageID]; ok && !topic.Hidden { - if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { - return nil, domain.ErrChannelWriteForbidden - } - reply.ForumTopic = true - } - } - return reply, nil -} - -func (s *ChannelStore) visibleTopMessageIDLocked(userID int64, channel domain.Channel) int { - return s.visibleTopMessageIDForMemberLocked(channel, s.members[channel.ID][userID]) -} - -func (s *ChannelStore) visibleTopMessageIDForMemberLocked(channel domain.Channel, member domain.ChannelMember) int { - for i := len(s.messages[channel.ID]) - 1; i >= 0; i-- { - msg := s.messages[channel.ID][i] - if !msg.Deleted && msg.ID > member.AvailableMinID { - return msg.ID - } - } - return 0 -} - -func (s *ChannelStore) deleteChannelMessagesLocked(channel domain.Channel, member domain.ChannelMember, ids []int, actorUserID int64, date int) ([]int, domain.ChannelUpdateEvent, domain.Channel, error) { - if len(ids) == 0 { - return nil, domain.ChannelUpdateEvent{}, channel, nil - } - seen := make(map[int]struct{}, len(ids)) - deleted := make([]int, 0, len(ids)) - for _, id := range ids { - if id <= 0 || id > domain.MaxMessageBoxID { - return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrMessageIDInvalid - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - idx, ok := s.findMessageIndexLocked(channel.ID, id) - if !ok || s.messages[channel.ID][idx].Deleted { - continue - } - msg := s.messages[channel.ID][idx] - if msg.SenderUserID != actorUserID && !canDeleteAnyChannelMessage(member) { - return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrChannelAdminRequired - } - msg.Deleted = true - s.messages[channel.ID][idx] = msg - deleted = append(deleted, id) - s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ - ChannelID: channel.ID, - UserID: actorUserID, - Date: date, - Type: domain.ChannelAdminLogDeleteMessage, - Message: ptrChannelMessage(msg), - Query: msg.Body, - }) - } - if len(deleted) == 0 { - return nil, domain.ChannelUpdateEvent{}, channel, nil - } - s.deleteChannelUnreadMentionsLocked(channel.ID, deleted) - pts := s.nextChannelPtsNLocked(channel.ID, len(deleted)) - channel.Pts = pts - channel.TopMessageID = s.topNonDeletedMessageIDLocked(channel.ID) - s.channels[channel.ID] = channel - for userID, member := range s.members[channel.ID] { - if member.Status != domain.ChannelMemberActive { - continue - } - if s.dialogs[userID] == nil { - s.dialogs[userID] = make(map[int64]domain.ChannelDialog) - } - dialog := s.dialogForUserLocked(userID, channel) - s.dialogs[userID][channel.ID] = dialog - } - event := domain.ChannelUpdateEvent{ - ChannelID: channel.ID, - Type: domain.ChannelUpdateDeleteMessages, - Pts: pts, - PtsCount: len(deleted), - Date: date, - MessageIDs: append([]int(nil), deleted...), - SenderUserID: actorUserID, - } - s.events[channel.ID] = append(s.events[channel.ID], event) - return deleted, event, channel, nil -} - -func (s *ChannelStore) topNonDeletedMessageIDLocked(channelID int64) int { - for i := len(s.messages[channelID]) - 1; i >= 0; i-- { - if !s.messages[channelID][i].Deleted { - return s.messages[channelID][i].ID - } - } - return 0 -} - -func (s *ChannelStore) eventForMessageLocked(channelID int64, id int) domain.ChannelUpdateEvent { - for _, event := range s.events[channelID] { - if event.Message.ID == id { - return cloneChannelEvent(event) - } - } - return domain.ChannelUpdateEvent{} -} - -func (s *ChannelStore) appendChannelAdminLogLocked(event domain.ChannelAdminLogEvent) { - if event.ChannelID == 0 || event.UserID == 0 || event.Type == "" { - return - } - s.logSeq[event.ChannelID]++ - event.ID = s.logSeq[event.ChannelID] - event.Query = adminLogSearchText(event) - s.adminLogs[event.ChannelID] = append(s.adminLogs[event.ChannelID], cloneChannelAdminLogEvent(event)) -} - -func (s *ChannelStore) activeMemberIDsLocked(channelID, excludeUserID int64, limit int) []int64 { - members := s.members[channelID] - if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { - limit = domain.MaxChannelRealtimeFanout - } - capacity := limit - if len(members) < capacity { - capacity = len(members) - } - out := make([]int64, 0, capacity) - for userID, member := range members { - if userID == excludeUserID || member.Status != domain.ChannelMemberActive { - continue - } - out = append(out, userID) - if len(out) >= limit { - break - } - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -func channelDialogToDialog(dialog domain.ChannelDialog) domain.Dialog { - return domain.Dialog{ - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID}, - FolderID: dialog.FolderID, - TopMessage: dialog.TopMessageID, - TopMessageDate: dialog.TopMessageDate, - ReadInboxMaxID: dialog.ReadInboxMaxID, - ReadOutboxMaxID: dialog.ReadOutboxMaxID, - UnreadCount: dialog.UnreadCount, - UnreadMentions: dialog.UnreadMentions, - UnreadReactions: dialog.UnreadReactions, - Pinned: dialog.Pinned, - PinnedOrder: dialog.PinnedOrder, - UnreadMark: dialog.UnreadMark, - ViewForumAsMessages: dialog.ViewForumAsMessages, - } -} - -func inactiveChannelDate(dialog domain.Dialog, channel domain.Channel, member domain.ChannelMember) int { - if dialog.TopMessageDate > 0 { - return dialog.TopMessageDate - } - date := channel.Date - if member.JoinedAt > date { - date = member.JoinedAt - } - return date -} - -func recommendableChannel(channel domain.Channel) bool { - return !channel.Deleted && - channel.Broadcast && - !channel.Megagroup && - strings.TrimSpace(channel.Username) != "" -} - -func publicSearchableChannel(channel domain.Channel) bool { - return !channel.Deleted && - (channel.Broadcast || channel.Megagroup) && - strings.TrimSpace(channel.Username) != "" -} - -func publicPreviewableChannel(channel domain.Channel) bool { - return publicSearchableChannel(channel) -} - -func publicPreviewMember(channel domain.Channel, userID int64, existing domain.ChannelMember, found bool) domain.ChannelMember { - member := domain.ChannelMember{ - ChannelID: channel.ID, - UserID: userID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberLeft, - AvailableMinID: channelInitialAvailableMinID(channel), - AvailableMinPts: channelInitialAvailableMinPts(channel), - ReadInboxMaxID: channel.TopMessageID, - ReadOutboxMaxID: channel.TopMessageID, - } - if found { - member.InviterUserID = existing.InviterUserID - member.JoinedAt = existing.JoinedAt - member.LeftAt = existing.LeftAt - member.AvailableMinID = maxInt(member.AvailableMinID, existing.AvailableMinID) - member.AvailableMinPts = maxInt(member.AvailableMinPts, existing.AvailableMinPts) - member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, existing.ReadInboxMaxID) - member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, existing.ReadOutboxMaxID) - } - return member -} - -func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { - topMessageID := channel.TopMessageID - if topMessageID <= member.AvailableMinID { - topMessageID = 0 - } - return domain.ChannelDialog{ - UserID: userID, - ChannelID: channel.ID, - TopMessageID: topMessageID, - TopMessageDate: channel.Date, - ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID), - ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID), - } -} - -func publicChannelSearchRank(channel domain.Channel, queryLower string) (int, bool) { - if !publicSearchableChannel(channel) { - return 0, false - } - username := strings.ToLower(strings.TrimSpace(channel.Username)) - title := strings.ToLower(strings.TrimSpace(channel.Title)) - switch { - case username == queryLower: - return 0, true - case strings.HasPrefix(username, queryLower): - return 1, true - case strings.Contains(username, queryLower): - return 2, true - case strings.HasPrefix(title, queryLower): - return 3, true - case strings.Contains(title, queryLower): - return 4, true - default: - return 0, false - } -} - -func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool { - if filter.HasFolderID { - if filter.FolderID < domain.DialogCustomFolderMinID { - if dialog.FolderID != filter.FolderID { - return false - } - } else if filter.Folder == nil { - return false - } - } - if filter.PinnedOnly && !dialog.Pinned { - return false - } - if filter.ExcludePinned && dialog.Pinned { - return false - } - if !channelDialogAfterOffset(dialog, filter) { - return false - } - if filter.Folder == nil { - return true - } - folder := filter.Folder - if peerInFolderList(dialog.Peer, folder.ExcludePeers) { - return false - } - if folder.ExcludeRead && dialog.UnreadCount == 0 && !dialog.UnreadMark { - return false - } - if folder.ExcludeArchived && dialog.FolderID == domain.DialogArchiveFolderID { - return false - } - if peerInFolderList(dialog.Peer, folder.PinnedPeers) || peerInFolderList(dialog.Peer, folder.IncludePeers) { - return true - } - if channel.Megagroup && folder.Groups { - return true - } - if channel.Broadcast && folder.Broadcasts { - return true - } - return !folder.Groups && !folder.Broadcasts && len(folder.IncludePeers) == 0 -} - -func channelDialogAfterOffset(dialog domain.Dialog, filter domain.DialogFilter) bool { - if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { - if filter.HasOffsetPeer && filter.OffsetPeer == dialog.Peer { - return false - } - return true - } - if filter.OffsetDate > 0 { - if dialog.TopMessageDate != filter.OffsetDate { - return dialog.TopMessageDate < filter.OffsetDate - } - if filter.OffsetID <= 0 { - return false - } - if dialog.TopMessage != filter.OffsetID { - return dialog.TopMessage < filter.OffsetID - } - if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { - return dialog.Peer.ID < filter.OffsetPeer.ID - } - return false - } - if dialog.TopMessage != filter.OffsetID { - return dialog.TopMessage < filter.OffsetID - } - if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { - return dialog.Peer.ID < filter.OffsetPeer.ID - } - return false -} - -func peerInFolderList(peer domain.Peer, items []domain.DialogFolderPeer) bool { - for _, item := range items { - if item.Peer == peer { - return true - } - } - return false -} - -func canPostToBroadcast(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages) -} - -func canSendChannelMessage(channel domain.Channel, member domain.ChannelMember) bool { - if channel.Broadcast { - return canPostToBroadcast(member) - } - if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { - return true - } - return !channel.DefaultBannedRights.SendMessages && !member.BannedRights.SendMessages -} - -func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool { - if member.Role == domain.ChannelRoleCreator || - (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) { - return true - } - return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !member.BannedRights.InviteUsers -} - -func isChannelAdmin(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin -} - -func channelParticipantMatchesFilter(member domain.ChannelMember, kind domain.ChannelParticipantsFilterKind, query string) bool { - if query != "" && !strings.Contains(strconv.FormatInt(member.UserID, 10), query) { - return false - } - switch kind { - case "", domain.ChannelParticipantsRecent, domain.ChannelParticipantsContacts, domain.ChannelParticipantsMentions, domain.ChannelParticipantsSearch: - return member.Status == domain.ChannelMemberActive - case domain.ChannelParticipantsAdmins: - return member.Status == domain.ChannelMemberActive && isChannelAdmin(member) - case domain.ChannelParticipantsKicked: - return member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages - case domain.ChannelParticipantsBanned: - return member.Status != domain.ChannelMemberKicked && !zeroChannelBannedRights(member.BannedRights) - case domain.ChannelParticipantsBots: - return false - default: - return member.Status == domain.ChannelMemberActive - } -} - -func channelRoleOrder(role domain.ChannelMemberRole) int { - switch role { - case domain.ChannelRoleCreator: - return 0 - case domain.ChannelRoleAdmin: - return 1 - default: - return 2 - } -} - -func canChangeChannelInfo(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo) -} - -func canManageDiscussionBroadcast(member domain.ChannelMember) bool { - return canChangeChannelInfo(member) -} - -func canManageDiscussionGroup(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) -} - -func validDiscussionGroup(channel domain.Channel) bool { - return channel.Megagroup && !channel.Broadcast && !channel.Forum && !channel.Deleted -} - -func canAddChannelAdmins(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.AddAdmins) -} - -func canBanChannelUsers(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.BanUsers) -} - -func canExportChannelInvite(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || - (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) -} - -func canPinChannelMessages(channel domain.Channel, member domain.ChannelMember) bool { - if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) { - return true - } - return channel.Megagroup && !channel.DefaultBannedRights.PinMessages && !member.BannedRights.PinMessages -} - -func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64) bool { - if topic.CreatorUserID == userID { - return true - } - return canPinChannelMessages(channel, member) -} - -func canEditChannelMessage(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) -} - -func canDeleteAnyChannelMessage(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.DeleteMessages) -} - -func channelSlowModeWait(channel domain.Channel, member domain.ChannelMember, now int) int { - if channel.SlowmodeSeconds <= 0 || member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { - return 0 - } - next := member.SlowmodeLastSendDate + channel.SlowmodeSeconds - if now >= next { - return 0 - } - return next - now -} - -func boolPtr(v bool) *bool { - return &v -} - -func channelInitialAvailableMinID(channel domain.Channel) int { - if channel.PreHistoryHidden { - return channel.TopMessageID - } - return 0 -} - -func channelInitialAvailableMinPts(channel domain.Channel) int { - return channel.Pts -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} - -func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool { - return rights == domain.ChannelAdminRights{} -} - -func adminRightsSubset(want, have domain.ChannelAdminRights) bool { - return (!want.ChangeInfo || have.ChangeInfo) && - (!want.PostMessages || have.PostMessages) && - (!want.EditMessages || have.EditMessages) && - (!want.DeleteMessages || have.DeleteMessages) && - (!want.BanUsers || have.BanUsers) && - (!want.InviteUsers || have.InviteUsers) && - (!want.PinMessages || have.PinMessages) && - (!want.AddAdmins || have.AddAdmins) && - (!want.ManageCall || have.ManageCall) && - (!want.Anonymous || have.Anonymous) -} - -func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool { - return rights == domain.ChannelBannedRights{} -} - -func adminLogBanType(previous, next domain.ChannelMember) domain.ChannelAdminLogEventType { - if next.Status == domain.ChannelMemberKicked || next.BannedRights.ViewMessages { - return domain.ChannelAdminLogParticipantKick - } - if previous.Status == domain.ChannelMemberKicked || previous.BannedRights.ViewMessages { - return domain.ChannelAdminLogParticipantUnkick - } - if !zeroChannelBannedRights(next.BannedRights) { - return domain.ChannelAdminLogParticipantBan - } - return domain.ChannelAdminLogParticipantUnban -} - -func adminLogEventMatchesFilter(typ domain.ChannelAdminLogEventType, filter domain.ChannelAdminLogFilter) bool { - if filter.Empty() { - return true - } - switch typ { - case domain.ChannelAdminLogParticipantJoin: - return filter.Join - case domain.ChannelAdminLogParticipantLeave: - return filter.Leave - case domain.ChannelAdminLogParticipantInvite: - return filter.Invite || filter.Invites - case domain.ChannelAdminLogParticipantBan: - return filter.Ban - case domain.ChannelAdminLogParticipantUnban: - return filter.Unban - case domain.ChannelAdminLogParticipantKick: - return filter.Kick - case domain.ChannelAdminLogParticipantUnkick: - return filter.Unkick - case domain.ChannelAdminLogParticipantPromote: - return filter.Promote - case domain.ChannelAdminLogParticipantDemote: - return filter.Demote - case domain.ChannelAdminLogChangeTitle, domain.ChannelAdminLogChangeUsername, domain.ChannelAdminLogChangeLinkedChat, domain.ChannelAdminLogToggleSlowMode: - return filter.Info - case domain.ChannelAdminLogToggleSignatures, domain.ChannelAdminLogTogglePreHistoryHidden, domain.ChannelAdminLogToggleAntiSpam, domain.ChannelAdminLogToggleAutotranslation: - return filter.Settings - case domain.ChannelAdminLogToggleForum: - return filter.Settings || filter.Forums - case domain.ChannelAdminLogUpdatePinned: - return filter.Pinned - case domain.ChannelAdminLogEditMessage: - return filter.Edit - case domain.ChannelAdminLogDeleteMessage: - return filter.Delete - case domain.ChannelAdminLogSendMessage: - return filter.Send - default: - return false - } -} - -func adminLogEventMatchesQuery(event domain.ChannelAdminLogEvent, query string) bool { - if strings.Contains(strings.ToLower(event.PrevString), query) || - strings.Contains(strings.ToLower(event.NewString), query) || - strings.Contains(event.Query, query) { - return true - } - for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { - if msg != nil && strings.Contains(strings.ToLower(msg.Body), query) { - return true - } - } - return false -} - -func adminLogSearchText(event domain.ChannelAdminLogEvent) string { - parts := []string{ - event.Query, - event.PrevString, - event.NewString, - } - for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { - if msg != nil { - parts = append(parts, msg.Body) - } - } - return strings.ToLower(strings.TrimSpace(strings.Join(parts, " "))) -} - -func int64Set(items []int64) map[int64]struct{} { - if len(items) == 0 { - return nil - } - out := make(map[int64]struct{}, len(items)) - for _, item := range items { - if item != 0 { - out[item] = struct{}{} - } - } - return out -} - -func (s *ChannelStore) refreshChannelCountsLocked(channelID int64) { - channel := s.channels[channelID] - var participants, admins, kicked, banned int - for _, member := range s.members[channelID] { - if member.Status == domain.ChannelMemberKicked { - kicked++ - } - if member.Status != domain.ChannelMemberActive { - continue - } - participants++ - if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { - admins++ - } - if !zeroChannelBannedRights(member.BannedRights) { - banned++ - } - } - channel.ParticipantsCount = participants - channel.AdminsCount = admins - channel.KickedCount = kicked - channel.BannedCount = banned - s.channels[channelID] = channel -} - -func sameMessageEntities(a, b []domain.MessageEntity) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func diffFinal(returned, all []domain.ChannelUpdateEvent) bool { - if len(returned) == 0 { - return true - } - return returned[len(returned)-1].Pts >= all[len(all)-1].Pts -} - -func uniqueNonZero(ids []int64, exclude int64) []int64 { - seen := make(map[int64]struct{}, len(ids)) - out := make([]int64, 0, len(ids)) - for _, id := range ids { - if id == 0 || id == exclude { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} - -func randomMemoryPositiveInt64() (int64, error) { - var b [8]byte - if _, err := rand.Read(b[:]); err != nil { - return 0, err - } - return int64(binary.LittleEndian.Uint64(b[:]) & ((1 << 63) - 1)), nil -} - -func randomMemoryInviteHash() (string, error) { - var b [18]byte - if _, err := rand.Read(b[:]); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(b[:]), nil -} - -func uniqueNonZeroInt64s(items ...int64) []int64 { - seen := make(map[int64]struct{}, len(items)) - out := make([]int64, 0, len(items)) - for _, item := range items { - if item == 0 { - continue - } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} - out = append(out, item) - } - return out -} - -func cloneChannelMembers(in []domain.ChannelMember) []domain.ChannelMember { - return append([]domain.ChannelMember(nil), in...) -} - -func discussionGroupUpdateResult(changed map[int64]domain.Channel) domain.DiscussionGroupUpdateResult { - ids := make([]int64, 0, len(changed)) - for id := range changed { - ids = append(ids, id) - } - sort.Slice(ids, func(i, j int) bool { - return ids[i] < ids[j] - }) - out := domain.DiscussionGroupUpdateResult{Channels: make([]domain.Channel, 0, len(ids))} - for _, id := range ids { - out.Channels = append(out.Channels, cloneChannel(changed[id])) - } - return out -} - -func cloneChannel(in domain.Channel) domain.Channel { - in.ReactionPolicy = copyChannelReactionPolicy(in.ReactionPolicy) - return in -} - -func copyChannelReactionPolicy(in domain.ChannelReactionPolicy) domain.ChannelReactionPolicy { - in.Emoticons = append([]string(nil), in.Emoticons...) - in.CustomEmojiIDs = append([]int64(nil), in.CustomEmojiIDs...) - return in -} - -func cloneChannelEvent(in domain.ChannelUpdateEvent) domain.ChannelUpdateEvent { - in.Message = cloneChannelMessage(in.Message) - in.MessageIDs = append([]int(nil), in.MessageIDs...) - in.UserIDs = append([]int64(nil), in.UserIDs...) - return in -} - -func cloneChannelForumTopic(in domain.ChannelForumTopic) domain.ChannelForumTopic { - return in -} - -func (s *ChannelStore) topicWithViewerCountersLocked(viewerUserID, channelID int64, topic domain.ChannelForumTopic, member domain.ChannelMember) domain.ChannelForumTopic { - out := cloneChannelForumTopic(topic) - out.UnreadCount = s.channelThreadUnreadCountLocked(viewerUserID, channelID, topic.TopicID, member.ReadInboxMaxID) - out.UnreadMentionsCount = s.countChannelUnreadMentionsLocked(viewerUserID, channelID, topic.TopicID) - out.UnreadReactionsCount = s.countChannelUnreadReactionsLocked(viewerUserID, channelID, topic.TopicID) - return out -} - -func (s *ChannelStore) updateForumTopicTopMessageLocked(channelID int64, msg domain.ChannelMessage) { - if msg.ReplyTo == nil || !msg.ReplyTo.ForumTopic || msg.ReplyTo.TopMessageID <= 0 { - return - } - topic, ok := s.topics[channelID][msg.ReplyTo.TopMessageID] - if !ok { - return - } - topic.TopMessageID = msg.ID - topic.Date = msg.Date - s.topics[channelID][topic.TopicID] = topic -} - -func sortForumTopics(topics []domain.ChannelForumTopic) { - sort.Slice(topics, func(i, j int) bool { - a, b := topics[i], topics[j] - if a.Pinned != b.Pinned { - return a.Pinned - } - if a.PinnedOrder != b.PinnedOrder { - return a.PinnedOrder > b.PinnedOrder - } - if a.Date != b.Date { - return a.Date > b.Date - } - return a.TopicID > b.TopicID - }) -} - -func forumTopicBeforeOrAtOffset(topic domain.ChannelForumTopic, filter domain.ChannelForumTopicFilter) bool { - if filter.OffsetDate == 0 && filter.OffsetID == 0 && filter.OffsetTopic == 0 { - return false - } - offsetID := filter.OffsetTopic - if offsetID == 0 { - offsetID = filter.OffsetID - } - if filter.OffsetDate != 0 { - if topic.Date < filter.OffsetDate { - return false - } - if topic.Date > filter.OffsetDate { - return true - } - } - if offsetID == 0 { - return false - } - return topic.TopicID >= offsetID -} - -func (s *ChannelStore) forumTopicRootMessagesLocked(channelID int64, topics []domain.ChannelForumTopic, availableMinID int) []domain.ChannelMessage { - if len(topics) == 0 { - return nil - } - wanted := make(map[int]struct{}, len(topics)) - for _, topic := range topics { - if topic.TopMessageID > 0 { - wanted[topic.TopMessageID] = struct{}{} - } - } - messages := make([]domain.ChannelMessage, 0, len(wanted)) - for _, msg := range s.messages[channelID] { - if _, ok := wanted[msg.ID]; !ok { - continue - } - if msg.Deleted || msg.ID <= availableMinID { - continue - } - messages = append(messages, cloneChannelMessage(msg)) - } - sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) - return messages -} - -func (s *ChannelStore) populateChannelMessageReactionsLocked(viewerUserID int64, channel domain.Channel, messages []domain.ChannelMessage) { - if len(messages) == 0 || channel.ID == 0 { - return - } - s.populateChannelMessageUnreadFlagsLocked(viewerUserID, messages) - for i := range messages { - if messages[i].ChannelID != channel.ID || messages[i].ID <= 0 { - continue - } - reactions := s.channelMessageReactionsLocked(viewerUserID, channel, messages[i].ID) - if len(reactions.Results) == 0 && len(reactions.Recent) == 0 { - continue - } - messages[i].Reactions = cloneChannelMessageReactionsPtr(&reactions) - } -} - -func (s *ChannelStore) populateChannelMessagesReactionsLocked(viewerUserID int64, channels []domain.Channel, messages []domain.ChannelMessage) { - if len(messages) == 0 { - return - } - s.populateChannelMessageUnreadFlagsLocked(viewerUserID, messages) - channelsByID := make(map[int64]domain.Channel, len(channels)) - for _, ch := range channels { - if ch.ID != 0 { - channelsByID[ch.ID] = ch - } - } - for i := range messages { - ch := channelsByID[messages[i].ChannelID] - if ch.ID == 0 { - ch = s.channels[messages[i].ChannelID] - } - if ch.ID == 0 { - continue - } - reactions := s.channelMessageReactionsLocked(viewerUserID, ch, messages[i].ID) - if len(reactions.Results) == 0 && len(reactions.Recent) == 0 { - continue - } - messages[i].Reactions = cloneChannelMessageReactionsPtr(&reactions) - } -} - -func (s *ChannelStore) populateChannelMessageUnreadFlagsLocked(viewerUserID int64, messages []domain.ChannelMessage) { - if viewerUserID == 0 || len(messages) == 0 { - return - } - for i := range messages { - if messages[i].ChannelID == 0 || messages[i].ID <= 0 { - continue - } - if _, ok := s.mentions[viewerUserID][messages[i].ChannelID][messages[i].ID]; !ok { - continue - } - messages[i].Mentioned = true - messages[i].MediaUnread = !messages[i].Media.IsZero() - } -} - -type memoryReactionCursor struct { - date int - userID int64 - emoticon string -} - -func (s *ChannelStore) channelMessageReactionsLocked(viewerUserID int64, channel domain.Channel, messageID int) domain.ChannelMessageReactions { - rows := s.channelMessageReactionRowsLocked(channel.ID, messageID, viewerUserID, nil) - out := domain.ChannelMessageReactions{ - CanSeeList: !channel.Broadcast || channel.Megagroup, - Results: []domain.ChannelMessageReactionCount{}, - Recent: []domain.ChannelMessagePeerReaction{}, - } - if len(rows) == 0 { - return out - } - type aggregate struct { - reaction domain.MessageReaction - count int - chosenOrder int - latestDate int - } - aggregates := make(map[string]*aggregate) - for _, row := range rows { - key := string(row.Reaction.Type) + "\x00" + row.Reaction.Emoticon - item := aggregates[key] - if item == nil { - item = &aggregate{reaction: row.Reaction} - aggregates[key] = item - } - item.count++ - if row.My && row.ChosenOrder > 0 { - item.chosenOrder = row.ChosenOrder - } - if row.Date > item.latestDate { - item.latestDate = row.Date - } - } - items := make([]aggregate, 0, len(aggregates)) - for _, item := range aggregates { - items = append(items, *item) - } - sort.Slice(items, func(i, j int) bool { - if items[i].count != items[j].count { - return items[i].count > items[j].count - } - if items[i].latestDate != items[j].latestDate { - return items[i].latestDate > items[j].latestDate - } - return items[i].reaction.Emoticon < items[j].reaction.Emoticon - }) - for _, item := range items { - out.Results = append(out.Results, domain.ChannelMessageReactionCount{ - Reaction: item.reaction, - Count: item.count, - ChosenOrder: item.chosenOrder, - }) - } - sort.Slice(rows, func(i, j int) bool { - if rows[i].Date != rows[j].Date { - return rows[i].Date > rows[j].Date - } - if rows[i].UserID != rows[j].UserID { - return rows[i].UserID > rows[j].UserID - } - return rows[i].Reaction.Emoticon < rows[j].Reaction.Emoticon - }) - if len(rows) > domain.MaxChannelMessageReactionRecent { - rows = rows[:domain.MaxChannelMessageReactionRecent] - } - out.Recent = cloneChannelPeerReactions(rows) - return out -} - -func (s *ChannelStore) channelMessageReactionRowsLocked(channelID int64, messageID int, viewerUserID int64, filter *domain.MessageReaction) []domain.ChannelMessagePeerReaction { - byMessage := s.reactions[channelID] - if byMessage == nil { - return nil - } - byUser := byMessage[messageID] - if byUser == nil { - return nil - } - rows := make([]domain.ChannelMessagePeerReaction, 0, len(byUser)) - for _, userRows := range byUser { - for _, row := range userRows { - if filter != nil && (row.Reaction.Type != filter.Type || row.Reaction.Emoticon != filter.Emoticon) { - continue - } - row.My = row.UserID == viewerUserID - rows = append(rows, row) - } - } - return rows -} - -func memoryReactionOffset(row domain.ChannelMessagePeerReaction) string { - return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + row.Reaction.Emoticon -} - -func messageReactionKey(reaction domain.MessageReaction) string { - return string(reaction.Type) + "\x00" + reaction.Emoticon -} - -func parseMemoryReactionOffset(offset string) (memoryReactionCursor, bool) { - parts := strings.SplitN(offset, ":", 3) - if len(parts) != 3 { - return memoryReactionCursor{}, false - } - date, err := strconv.Atoi(parts[0]) - if err != nil || date < 0 { - return memoryReactionCursor{}, false - } - userID, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil || userID < 0 { - return memoryReactionCursor{}, false - } - return memoryReactionCursor{date: date, userID: userID, emoticon: parts[2]}, true -} - -func memoryReactionAfterOffset(row domain.ChannelMessagePeerReaction, cursor memoryReactionCursor) bool { - if row.Date != cursor.date { - return row.Date < cursor.date - } - if row.UserID != cursor.userID { - return row.UserID < cursor.userID - } - return row.Reaction.Emoticon > cursor.emoticon -} - -func (s *ChannelStore) nextForumTopicPinnedOrderLocked(channelID int64) int { - next := 1 - for _, topic := range s.topics[channelID] { - if topic.PinnedOrder >= next { - next = topic.PinnedOrder + 1 - } - } - return next -} - -func (s *ChannelStore) topicHasVisibleMessagesLocked(channelID int64, topicID int) bool { - for _, msg := range s.messages[channelID] { - if msg.Deleted { - continue - } - if msg.ID == topicID || (msg.ReplyTo != nil && msg.ReplyTo.TopMessageID == topicID) { - return true - } - } - return false -} - -func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage { - in.Entities = append([]domain.MessageEntity(nil), in.Entities...) - in.ReplyTo = cloneMessageReply(in.ReplyTo) - in.Forward = cloneMessageForward(in.Forward) - in.Discussion = cloneChannelDiscussionRef(in.Discussion) - in.Replies = cloneChannelMessageReplies(in.Replies) - in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions) - if in.SendAs != nil { - p := *in.SendAs - in.SendAs = &p - } - if in.Action != nil { - in.Action = cloneChannelMessageAction(in.Action) - } - return in -} - -func cloneChannelMessageAction(in *domain.ChannelMessageAction) *domain.ChannelMessageAction { - if in == nil { - return nil - } - out := *in - out.UserIDs = append([]int64(nil), in.UserIDs...) - if in.Closed != nil { - v := *in.Closed - out.Closed = &v - } - if in.Hidden != nil { - v := *in.Hidden - out.Hidden = &v - } - return &out -} - -func cloneChannelDiscussionRef(in *domain.ChannelDiscussionRef) *domain.ChannelDiscussionRef { - if in == nil { - return nil - } - out := *in - return &out -} - -func cloneChannelMessageReplies(in *domain.ChannelMessageReplies) *domain.ChannelMessageReplies { - if in == nil { - return nil - } - out := *in - out.RecentRepliers = append([]domain.Peer(nil), in.RecentRepliers...) - return &out -} - -func cloneChannelMessageReactionsPtr(in *domain.ChannelMessageReactions) *domain.ChannelMessageReactions { - if in == nil { - return nil - } - out := cloneChannelMessageReactions(*in) - return &out -} - -func cloneChannelMessageReactions(in domain.ChannelMessageReactions) domain.ChannelMessageReactions { - in.Results = append([]domain.ChannelMessageReactionCount(nil), in.Results...) - in.Recent = cloneChannelPeerReactions(in.Recent) - return in -} - -func cloneChannelPeerReactions(in []domain.ChannelMessagePeerReaction) []domain.ChannelMessagePeerReaction { - if len(in) == 0 { - return nil - } - return append([]domain.ChannelMessagePeerReaction(nil), in...) -} - -func ptrChannelMember(in domain.ChannelMember) *domain.ChannelMember { - out := in - return &out -} - -func ptrChannelMessage(in domain.ChannelMessage) *domain.ChannelMessage { - out := cloneChannelMessage(in) - return &out -} - -func cloneChannelAdminLogEvent(in domain.ChannelAdminLogEvent) domain.ChannelAdminLogEvent { - if in.PrevParticipant != nil { - in.PrevParticipant = ptrChannelMember(*in.PrevParticipant) - } - if in.NewParticipant != nil { - in.NewParticipant = ptrChannelMember(*in.NewParticipant) - } - if in.Participant != nil { - in.Participant = ptrChannelMember(*in.Participant) - } - if in.Message != nil { - in.Message = ptrChannelMessage(*in.Message) - } - if in.PrevMessage != nil { - in.PrevMessage = ptrChannelMessage(*in.PrevMessage) - } - if in.NewMessage != nil { - in.NewMessage = ptrChannelMessage(*in.NewMessage) - } - return in -} diff --git a/internal/store/memory/channel_boosts.go b/internal/store/memory/channel_boosts.go new file mode 100644 index 00000000..11d3a22b --- /dev/null +++ b/internal/store/memory/channel_boosts.go @@ -0,0 +1,246 @@ +package memory + +import ( + "context" + "sort" + "strconv" + "telesrv/internal/domain" +) + +func (s *ChannelStore) GetPremiumBoostStatus(_ context.Context, viewerUserID, channelID int64, now int) (domain.PremiumBoostStatus, error) { + if viewerUserID == 0 || channelID == 0 { + return domain.PremiumBoostStatus{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, _, _, err := s.channelForViewerLocked(viewerUserID, channelID); err != nil { + return domain.PremiumBoostStatus{}, err + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} + total := 0 + my := make([]domain.PremiumBoostSlot, 0, 1) + for _, slot := range s.boostSlots { + if slot.Peer != peer { + continue + } + total += slot.Weight(now) + if slot.UserID == viewerUserID && slot.Assigned(now) { + my = append(my, clonePremiumBoostSlot(slot)) + } + } + sortPremiumBoostSlots(my) + return domain.PremiumBoostStatusForCount(peer, total, my), nil +} + +func (s *ChannelStore) ListPremiumBoosts(_ context.Context, viewerUserID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error) { + if viewerUserID == 0 || channelID == 0 { + return domain.PremiumBoostList{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxPremiumBoostsListLimit { + limit = domain.MaxPremiumBoostsListLimit + } + start, err := boostOffsetIndex(offset) + if err != nil { + return domain.PremiumBoostList{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + _, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.PremiumBoostList{}, err + } + if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin { + return domain.PremiumBoostList{}, domain.ErrChannelAdminRequired + } + items := s.activeBoostSlotsForPeerLocked(domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, now, func(slot domain.PremiumBoostSlot) bool { + return !gifts || slot.Gift || slot.Giveaway + }) + return premiumBoostPage(items, start, limit), nil +} + +func (s *ChannelStore) GetPremiumMyBoosts(_ context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) { + if userID == 0 { + return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.premiumMyBoostsLocked(userID, now, premiumUntil), nil +} + +func (s *ChannelStore) ApplyPremiumBoost(_ context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error) { + if userID == 0 || channelID == 0 || len(slots) == 0 || premiumUntil <= now { + if premiumUntil <= now { + return domain.PremiumMyBoosts{}, domain.ErrPremiumRequired + } + return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, _, err := s.channelAndMemberLocked(userID, channelID); err != nil { + return domain.PremiumMyBoosts{}, err + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} + for _, slotID := range slots { + if slotID != domain.DefaultPremiumBoostSlotID { + return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid + } + key := boostSlotKey{userID: userID, slot: slotID} + slot, changed, err := domain.ApplyPremiumBoostSlot( + s.boostSlots[key], + userID, + slotID, + peer, + now, + premiumUntil, + domain.DefaultPremiumBoostReassignCooldownSeconds, + ) + if err != nil { + return domain.PremiumMyBoosts{}, err + } + if !changed { + continue + } + s.boostSlots[key] = slot + } + return s.premiumMyBoostsLocked(userID, now, premiumUntil), nil +} + +func (s *ChannelStore) GetPremiumUserBoosts(_ context.Context, viewerUserID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error) { + if viewerUserID == 0 || channelID == 0 || targetUserID == 0 { + return domain.PremiumBoostList{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + _, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.PremiumBoostList{}, err + } + if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin { + return domain.PremiumBoostList{}, domain.ErrChannelAdminRequired + } + items := s.activeBoostSlotsForPeerLocked(domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, now, func(slot domain.PremiumBoostSlot) bool { + return slot.UserID == targetUserID + }) + return premiumBoostPage(items, 0, len(items)), nil +} + +func (s *ChannelStore) activeBoostSlotsForPeerLocked(peer domain.Peer, now int, keep func(domain.PremiumBoostSlot) bool) []domain.PremiumBoostSlot { + items := make([]domain.PremiumBoostSlot, 0) + for _, slot := range s.boostSlots { + if slot.Peer != peer || !slot.Assigned(now) { + continue + } + if keep != nil && !keep(slot) { + continue + } + items = append(items, clonePremiumBoostSlot(slot)) + } + sortPremiumBoostSlots(items) + return items +} + +func (s *ChannelStore) selfBoostsAppliedLocked(userID, channelID int64, now int) int { + if userID == 0 || channelID == 0 { + return 0 + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} + total := 0 + for _, slot := range s.boostSlots { + if slot.UserID == userID && slot.Peer == peer { + total += slot.Weight(now) + } + } + return total +} + +func (s *ChannelStore) premiumMyBoostsLocked(userID int64, now, premiumUntil int) domain.PremiumMyBoosts { + out := domain.PremiumMyBoosts{} + if premiumUntil <= now { + return out + } + foundBase := false + for _, slot := range s.boostSlots { + if slot.UserID != userID || !slot.Active(now) { + continue + } + if slot.Slot == domain.DefaultPremiumBoostSlotID { + foundBase = true + if slot.Expires < premiumUntil { + slot.Expires = premiumUntil + } + } + out.Slots = append(out.Slots, clonePremiumBoostSlot(slot)) + } + if !foundBase { + out.Slots = append(out.Slots, domain.PremiumBoostSlot{ + UserID: userID, + Slot: domain.DefaultPremiumBoostSlotID, + Multiplier: 1, + }) + } + sortPremiumBoostSlots(out.Slots) + channelIDs := make(map[int64]struct{}) + for _, slot := range out.Slots { + if slot.Peer.Type == domain.PeerTypeChannel && slot.Peer.ID != 0 { + channelIDs[slot.Peer.ID] = struct{}{} + } + } + for channelID := range channelIDs { + if channel, ok := s.channels[channelID]; ok && !channel.Deleted { + out.Channels = append(out.Channels, cloneChannel(channel)) + } + } + sort.Slice(out.Channels, func(i, j int) bool { return out.Channels[i].ID < out.Channels[j].ID }) + return out +} + +func premiumBoostPage(items []domain.PremiumBoostSlot, start, limit int) domain.PremiumBoostList { + if start < 0 || start > len(items) { + start = len(items) + } + if limit < 0 { + limit = 0 + } + end := start + limit + if end > len(items) { + end = len(items) + } + out := domain.PremiumBoostList{ + Count: len(items), + Boosts: append([]domain.PremiumBoostSlot(nil), items[start:end]...), + } + if end < len(items) { + out.NextOffset = strconv.Itoa(end) + } + return out +} + +func boostOffsetIndex(offset string) (int, error) { + if offset == "" { + return 0, nil + } + n, err := strconv.Atoi(offset) + if err != nil { + return 0, err + } + if n < 0 { + return 0, strconv.ErrSyntax + } + return n, nil +} + +func sortPremiumBoostSlots(items []domain.PremiumBoostSlot) { + sort.Slice(items, func(i, j int) bool { + if items[i].Date != items[j].Date { + return items[i].Date > items[j].Date + } + if items[i].UserID != items[j].UserID { + return items[i].UserID < items[j].UserID + } + return items[i].Slot < items[j].Slot + }) +} + +func clonePremiumBoostSlot(in domain.PremiumBoostSlot) domain.PremiumBoostSlot { + return in +} diff --git a/internal/store/memory/channel_core.go b/internal/store/memory/channel_core.go new file mode 100644 index 00000000..9907b700 --- /dev/null +++ b/internal/store/memory/channel_core.go @@ -0,0 +1,260 @@ +package memory + +import ( + "context" + "errors" + "strings" + "telesrv/internal/domain" + "time" +) + +func (s *ChannelStore) CreateChannel(_ context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { + if req.CreatorUserID == 0 || strings.TrimSpace(req.Title) == "" { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + + channelID := s.nextChannelIDLocked() + channel := domain.Channel{ + ID: channelID, + AccessHash: s.nextAccessHashLocked(), + CreatorUserID: req.CreatorUserID, + Title: strings.TrimSpace(req.Title), + About: req.About, + Broadcast: req.Broadcast, + Megagroup: req.Megagroup, + Forum: req.Forum, + ForumTabs: req.ForumTabs, + ParticipantsCount: 1, + AdminsCount: 1, + TTLPeriod: req.TTLPeriod, + Date: req.Date, + } + if !channel.Broadcast && !channel.Megagroup { + channel.Broadcast = true + } + inviteID, err := randomMemoryPositiveInt64() + if err != nil { + return domain.CreateChannelResult{}, err + } + inviteHash, err := randomMemoryInviteHash() + if err != nil { + return domain.CreateChannelResult{}, err + } + inviteDate := channel.Date + if inviteDate == 0 { + inviteDate = int(time.Now().Unix()) + } + channel.HasLink = true + creator := domain.ChannelMember{ + ChannelID: channelID, + UserID: req.CreatorUserID, + Role: domain.ChannelRoleCreator, + Status: domain.ChannelMemberActive, + JoinedAt: req.Date, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + PostMessages: true, + EditMessages: true, + DeleteMessages: true, + PostStories: true, + EditStories: true, + DeleteStories: true, + BanUsers: true, + InviteUsers: true, + PinMessages: true, + AddAdmins: true, + ManageCall: true, + }, + } + s.channels[channelID] = channel + s.invites[inviteHash] = domain.ChannelInvite{ + ChannelID: channelID, + InviteID: inviteID, + Hash: inviteHash, + AdminUserID: req.CreatorUserID, + Permanent: true, + Date: inviteDate, + } + s.members[channelID] = map[int64]domain.ChannelMember{creator.UserID: creator} + members := []domain.ChannelMember{creator} + for _, userID := range uniqueNonZero(req.MemberUserIDs, req.CreatorUserID) { + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: req.CreatorUserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: req.Date, + } + s.members[channelID][userID] = member + members = append(members, member) + channel.ParticipantsCount++ + } + msg, event := s.appendChannelServiceMessageLocked(channelID, req.CreatorUserID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionCreate, + Title: channel.Title, + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[channelID] = channel + for _, member := range members { + s.upsertChannelDialogLocked(member.UserID, channel, msg, member.UserID == req.CreatorUserID) + } + return domain.CreateChannelResult{ + Channel: channel, + Members: cloneChannelMembers(members), + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(channelID, 0, 0), + }, nil +} + +func (s *ChannelStore) GetChannel(_ context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, preview, err := s.channelForViewerLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + dialog := s.dialogForUserLocked(viewerUserID, channel) + if preview { + dialog = previewChannelDialog(viewerUserID, channel, member) + } + var exportedInvite *domain.ChannelInvite + if !preview && canExportChannelInvite(member) { + if invite, ok := s.permanentInviteForAdminLocked(channelID, viewerUserID); ok { + exportedInvite = &invite + } + } + return domain.ChannelView{ + Channel: cloneChannel(channel), + Self: member, + Dialog: dialog, + SelfBoostsApplied: s.selfBoostsAppliedLocked(viewerUserID, channelID, int(time.Now().Unix())), + ExportedInvite: exportedInvite, + }, nil +} + +// ResolveChannel 是 GetChannel 的轻量版:只做访问校验并返回 Channel+Self,跳过 dialog/boost。 +// 与 postgres 实现语义一致(内存侧 dialog/boost 本就便宜,但保持接口行为对齐)。 +func (s *ChannelStore) ResolveChannel(_ context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, preview, err := s.channelForViewerLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + view := domain.ChannelView{Channel: cloneChannel(channel), Self: member} + if preview { + view.Dialog = previewChannelDialog(viewerUserID, channel, member) + } + return view, nil +} + +func (s *ChannelStore) GetChannels(_ context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error) { + if viewerUserID == 0 || len(channelIDs) == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.ChannelView, 0, len(channelIDs)) + seen := make(map[int64]struct{}, len(channelIDs)) + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + if _, ok := seen[channelID]; ok { + continue + } + seen[channelID] = struct{}{} + channel, member, preview, err := s.channelForViewerLocked(viewerUserID, channelID) + if err != nil { + if errors.Is(err, domain.ErrChannelUserBanned) { + if banned, ok := s.channels[channelID]; ok && !banned.Deleted { + out = append(out, domain.ChannelView{ + Channel: cloneChannel(banned), + Self: s.members[channelID][viewerUserID], + Forbidden: true, + }) + } + continue + } + if errors.Is(err, domain.ErrChannelInvalid) || errors.Is(err, domain.ErrChannelPrivate) { + continue + } + return nil, err + } + dialog := s.dialogForUserLocked(viewerUserID, channel) + if preview { + dialog = previewChannelDialog(viewerUserID, channel, member) + } + out = append(out, domain.ChannelView{ + Channel: cloneChannel(channel), + Self: member, + Dialog: dialog, + SelfBoostsApplied: s.selfBoostsAppliedLocked(viewerUserID, channelID, int(time.Now().Unix())), + }) + } + return out, nil +} + +func (s *ChannelStore) GetChannelByID(_ context.Context, channelID int64) (domain.Channel, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.Channel{}, domain.ErrChannelInvalid + } + return cloneChannel(channel), nil +} + +func publicPreviewableChannel(channel domain.Channel) bool { + return publicSearchableChannel(channel) +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func uniqueNonZero(ids []int64, exclude int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 || id == exclude { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func uniqueNonZeroInt64s(items ...int64) []int64 { + seen := make(map[int64]struct{}, len(items)) + out := make([]int64, 0, len(items)) + for _, item := range items { + if item == 0 { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} + +func cloneChannel(in domain.Channel) domain.Channel { + in.ReactionPolicy = copyChannelReactionPolicy(in.ReactionPolicy) + in.Wallpaper = domain.CloneWallpaperPtr(in.Wallpaper) + return in +} diff --git a/internal/store/memory/channel_delete_monoforum_test.go b/internal/store/memory/channel_delete_monoforum_test.go new file mode 100644 index 00000000..0ad86415 --- /dev/null +++ b/internal/store/memory/channel_delete_monoforum_test.go @@ -0,0 +1,75 @@ +package memory + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestDeleteChannelCascadesLinkedMonoforum 验证删除开启了 Direct Messages 的母广播频道时,关联的 +// monoforum 虚拟频道被一并软删并随结果返回。不级联会留下 monoforum=true 但 linked_monoforum_id 指向 +// 已删父频道的孤儿(web 客户端渲染崩 + DB 垃圾随删除累积)。 +func TestDeleteChannelCascadesLinkedMonoforum(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, Title: "DM Broadcast", Broadcast: true, Date: 1_700_000_900, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + parentID := broadcast.Channel.ID + enabled, err := store.SetPaidMessagesPrice(ctx, 1, parentID, 0, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + if monoID == 0 { + t.Fatalf("no monoforum created") + } + + res, err := store.DeleteChannel(ctx, domain.DeleteChannelRequest{UserID: 1, ChannelID: parentID, Date: 1_700_001_000}) + if err != nil { + t.Fatalf("delete parent: %v", err) + } + if !res.Channel.Deleted { + t.Fatalf("parent not marked deleted: %+v", res.Channel) + } + if res.LinkedMonoforum == nil || res.LinkedMonoforum.ID != monoID || !res.LinkedMonoforum.Deleted { + t.Fatalf("result LinkedMonoforum = %+v, want deleted mono %d", res.LinkedMonoforum, monoID) + } + if mono := store.channels[monoID]; !mono.Deleted { + t.Fatalf("monoforum %d not soft-deleted in store: %+v", monoID, mono) + } + // 删后 getDialogs 不应再返回母频道或 mono。 + dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 100}) + if err != nil { + t.Fatalf("list dialogs after delete: %v", err) + } + for _, d := range dialogs.Dialogs { + if d.Peer.ID == monoID || d.Peer.ID == parentID { + t.Fatalf("dialogs after delete still include parent/mono: %+v", d) + } + } +} + +// TestDeleteChannelNoMonoforumNoCascade 锁定不开私信的普通频道删除不受影响:LinkedMonoforum 为 nil, +// 不会误触级联。 +func TestDeleteChannelNoMonoforumNoCascade(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + plain, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, Title: "Plain Group", Megagroup: true, Date: 1_700_000_900, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + res, err := store.DeleteChannel(ctx, domain.DeleteChannelRequest{UserID: 1, ChannelID: plain.Channel.ID, Date: 1_700_001_000}) + if err != nil { + t.Fatalf("delete channel: %v", err) + } + if res.LinkedMonoforum != nil { + t.Fatalf("plain channel delete returned LinkedMonoforum %+v, want nil", res.LinkedMonoforum) + } +} diff --git a/internal/store/memory/channel_dialogs.go b/internal/store/memory/channel_dialogs.go new file mode 100644 index 00000000..5c8e682b --- /dev/null +++ b/internal/store/memory/channel_dialogs.go @@ -0,0 +1,577 @@ +package memory + +import ( + "context" + "sort" + "telesrv/internal/domain" +) + +func (s *ChannelStore) ListChannelDialogs(_ context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { + if viewerUserID == 0 { + return domain.ChannelDialogList{}, nil + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + s.mu.RLock() + defer s.mu.RUnlock() + + channelIDs := make([]int64, 0, len(s.dialogs[viewerUserID])) + seen := make(map[int64]struct{}, len(s.dialogs[viewerUserID])) + for channelID := range s.dialogs[viewerUserID] { + channelIDs = append(channelIDs, channelID) + seen[channelID] = struct{}{} + } + for channelID, members := range s.members { + if _, ok := seen[channelID]; ok { + continue + } + if member, ok := members[viewerUserID]; ok && member.Status == domain.ChannelMemberActive { + channelIDs = append(channelIDs, channelID) + seen[channelID] = struct{}{} + } + } + syntheticMembers := make(map[int64]domain.ChannelMember) + for channelID, channel := range s.channels { + if _, ok := seen[channelID]; ok { + continue + } + if !channel.Monoforum || channel.LinkedMonoforumID == 0 || channel.Deleted { + continue + } + parentMember, ok := s.members[channel.LinkedMonoforumID][viewerUserID] + if !ok || parentMember.Status != domain.ChannelMemberActive || !isChannelAdmin(parentMember) { + continue + } + channelIDs = append(channelIDs, channelID) + seen[channelID] = struct{}{} + syntheticMembers[channelID] = syntheticMonoforumAdminMember(channel, parentMember) + } + + items := make([]domain.Dialog, 0, len(channelIDs)) + for _, channelID := range channelIDs { + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + continue + } + member, synthetic := syntheticMembers[channelID] + if !synthetic { + if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { + continue + } + member = s.members[channelID][viewerUserID] + } + if member.Status != domain.ChannelMemberActive { + continue + } + item := channelDialogToDialog(s.dialogForMemberLocked(viewerUserID, channel, member), channel.Pts, member.Status) + if !channelDialogMatchesFilter(item, channel, filter) { + continue + } + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Pinned != items[j].Pinned { + return items[i].Pinned + } + if items[i].PinnedOrder != items[j].PinnedOrder { + return items[i].PinnedOrder > items[j].PinnedOrder + } + if items[i].TopMessageDate != items[j].TopMessageDate { + return items[i].TopMessageDate > items[j].TopMessageDate + } + if items[i].TopMessage != items[j].TopMessage { + return items[i].TopMessage > items[j].TopMessage + } + return items[i].Peer.ID > items[j].Peer.ID + }) + out := domain.ChannelDialogList{Count: len(items)} + for _, dialog := range items { + if len(out.Dialogs) >= limit { + break + } + out.Dialogs = append(out.Dialogs, dialog) + channel := s.channels[dialog.Peer.ID] + out.Channels = append(out.Channels, channel) + if msg, ok := s.findMessageLocked(dialog.Peer.ID, dialog.TopMessage); ok && !msg.Deleted { + out.Messages = append(out.Messages, cloneChannelMessage(msg)) + } + } + // 与 PG 同因:getDialogs top message 按 viewer 补未读提及标志。 + s.populateChannelMessageUnreadFlagsLocked(viewerUserID, out.Messages) + return out, nil +} + +func (s *ChannelStore) GetChannelDialogs(_ context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) { + if viewerUserID == 0 || len(channelIDs) == 0 { + return domain.ChannelDialogList{}, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := domain.ChannelDialogList{} + seen := make(map[int64]struct{}, len(channelIDs)) + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + if _, ok := seen[channelID]; ok { + continue + } + seen[channelID] = struct{}{} + channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID) + if err != nil { + continue + } + dialog := channelDialogToDialog(s.dialogForMemberLocked(viewerUserID, channel, member), channel.Pts, member.Status) + out.Dialogs = append(out.Dialogs, dialog) + out.Channels = append(out.Channels, channel) + // 与 postgres GetChannelDialogs 一致:monoforum 私信会话须同批下发母广播频道,客户端 + // 才能 resolve linked_monoforum_id 并派生 MonoforumAdmin 渲染 Direct-Messages 容器;否则 + // 退化成普通 megagroup。到达此处的 monoforum 必为管理员预览(非管理员在 + // channelForViewerLocked 已返 ErrChannelPrivate 被 skip),不泄漏给无关用户。 + if channel.Monoforum && channel.LinkedMonoforumID != 0 { + if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted { + out.Channels = append(out.Channels, cloneChannel(parent)) + } + } + if msg, ok := s.findMessageLocked(channelID, dialog.TopMessage); ok && !msg.Deleted { + out.Messages = append(out.Messages, cloneChannelMessage(msg)) + } + } + out.Count = len(out.Dialogs) + s.populateChannelMessageUnreadFlagsLocked(viewerUserID, out.Messages) + return out, nil +} + +func (s *ChannelStore) ListCommonChannels(_ context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { + if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 { + return domain.CommonChannelsResult{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxCommonChannelsLimit { + limit = domain.MaxCommonChannelsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + + ids := make([]int64, 0) + for channelID, members := range s.members { + self, selfOK := members[req.UserID] + target, targetOK := members[req.TargetUserID] + if !selfOK || !targetOK || self.Status != domain.ChannelMemberActive || target.Status != domain.ChannelMemberActive { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || !channel.Megagroup || channel.Broadcast { + continue + } + ids = append(ids, channelID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + out := domain.CommonChannelsResult{Count: len(ids)} + if req.CountOnly { + return out, nil + } + for _, channelID := range ids { + if req.MaxID > 0 && channelID <= req.MaxID { + continue + } + out.Channels = append(out.Channels, cloneChannel(s.channels[channelID])) + if len(out.Channels) >= limit { + break + } + } + return out, nil +} + +func (s *ChannelStore) ListLeftChannels(_ context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) { + if userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset { + return domain.LeftChannelsResult{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxLeftChannelsLimit { + limit = domain.MaxLeftChannelsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + + all := make([]domain.LeftChannel, 0) + for channelID, members := range s.members { + member, ok := members[userID] + if !ok || member.Status != domain.ChannelMemberLeft { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) { + continue + } + all = append(all, domain.LeftChannel{ + Channel: cloneChannel(channel), + Self: member, + }) + } + sort.Slice(all, func(i, j int) bool { + if all[i].Self.LeftAt != all[j].Self.LeftAt { + return all[i].Self.LeftAt > all[j].Self.LeftAt + } + return all[i].Channel.ID > all[j].Channel.ID + }) + + out := domain.LeftChannelsResult{Count: len(all)} + if offset >= len(all) { + return out, nil + } + end := offset + limit + if end > len(all) { + end = len(all) + } + out.Channels = append(out.Channels, all[offset:end]...) + return out, nil +} + +func (s *ChannelStore) ListInactiveChannels(_ context.Context, userID int64, limit int) (domain.ChannelDialogList, error) { + if userID == 0 { + return domain.ChannelDialogList{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxInactiveChannelsLimit { + limit = domain.MaxInactiveChannelsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + + type item struct { + channel domain.Channel + dialog domain.Dialog + } + items := make([]item, 0, limit) + for channelID, members := range s.members { + member, ok := members[userID] + if !ok || member.Status != domain.ChannelMemberActive { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) { + continue + } + if _, err := s.channelForMemberLocked(userID, channelID); err != nil { + continue + } + dialog := channelDialogToDialog(s.dialogForUserLocked(userID, channel), channel.Pts, member.Status) + dialog.TopMessageDate = inactiveChannelDate(dialog, channel, member) + items = append(items, item{channel: cloneChannel(channel), dialog: dialog}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].dialog.TopMessageDate != items[j].dialog.TopMessageDate { + return items[i].dialog.TopMessageDate < items[j].dialog.TopMessageDate + } + if items[i].dialog.TopMessage != items[j].dialog.TopMessage { + return items[i].dialog.TopMessage < items[j].dialog.TopMessage + } + return items[i].channel.ID < items[j].channel.ID + }) + if len(items) > limit { + items = items[:limit] + } + out := domain.ChannelDialogList{Count: len(items)} + for _, item := range items { + out.Dialogs = append(out.Dialogs, item.dialog) + out.Channels = append(out.Channels, item.channel) + } + return out, nil +} + +func (s *ChannelStore) SetChannelDialogPinned(_ context.Context, userID, channelID int64, pinned bool) (bool, int, error) { + if userID == 0 || channelID == 0 { + return false, 0, nil + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return false, 0, nil + } + dialog := s.dialogForUserLocked(userID, channel) + targetFolderID := dialog.FolderID + // order 仅在目标会话所在 folder 内分配;memory 双 store 互不可见, + // 与 postgres 跨表统一 order 空间相比此处只看 channel 表(reorder 会统一重排)。 + nextOrder := 1 + for _, d := range s.dialogs[userID] { + if d.Pinned && d.FolderID == targetFolderID && d.PinnedOrder >= nextOrder { + nextOrder = d.PinnedOrder + 1 + } + } + changed := dialog.Pinned != pinned || (pinned && dialog.PinnedOrder == 0) + dialog.Pinned = pinned + if pinned { + if dialog.PinnedOrder == 0 { + dialog.PinnedOrder = nextOrder + } + } else { + dialog.PinnedOrder = 0 + } + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog + return changed, targetFolderID, nil +} + +func (s *ChannelStore) ReorderChannelPinnedDialogs(_ context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) { + if userID == 0 { + return false, nil + } + positions := make(map[int64]int, len(order)) + for i, peer := range order { + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + continue + } + if _, ok := positions[peer.ID]; ok { + continue + } + positions[peer.ID] = len(order) - i + } + s.mu.Lock() + defer s.mu.Unlock() + changed := false + for channelID, dialog := range s.dialogs[userID] { + if dialog.FolderID != folderID { + continue + } + if pos, ok := positions[channelID]; ok { + if !dialog.Pinned || dialog.PinnedOrder != pos { + changed = true + } + dialog.Pinned = true + dialog.PinnedOrder = pos + s.dialogs[userID][channelID] = dialog + continue + } + if force && dialog.Pinned { + changed = true + dialog.Pinned = false + dialog.PinnedOrder = 0 + s.dialogs[userID][channelID] = dialog + } + } + return changed, nil +} + +func (s *ChannelStore) EditChannelPeerFolders(_ context.Context, userID int64, peers []domain.FolderPeerUpdate) error { + if userID == 0 || len(peers) == 0 { + return nil + } + updates := make(map[int64]int, len(peers)) + for _, item := range peers { + if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { + continue + } + if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { + continue + } + updates[item.Peer.ID] = item.FolderID + } + if len(updates) == 0 { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + for channelID, folderID := range updates { + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + continue + } + dialog := s.dialogForUserLocked(userID, channel) + // 换 folder 时清 pinned:与私聊 EditPeerFolders 一致,TDesktop + // 在归档/还原时本地无条件 unpin,服务端保留旧 pin 会造成状态漂移。 + if dialog.FolderID != folderID { + dialog.Pinned = false + dialog.PinnedOrder = 0 + } + dialog.FolderID = folderID + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog + } + return nil +} + +func (s *ChannelStore) CountChannelArchiveUnread(_ context.Context, userID int64) (int, int, error) { + if userID == 0 { + return 0, 0, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + peers, messages := 0, 0 + for channelID, dialog := range s.dialogs[userID] { + if dialog.FolderID != domain.DialogArchiveFolderID { + continue + } + // 与 postgres 一致:仅统计 active 成员的会话。 + if _, err := s.channelForMemberLocked(userID, channelID); err != nil { + continue + } + if dialog.UnreadCount > 0 || dialog.UnreadMark { + peers++ + } + messages += dialog.UnreadCount + } + return peers, messages, nil +} + +func (s *ChannelStore) upsertChannelDialogLocked(userID int64, channel domain.Channel, top domain.ChannelMessage, selfAction bool) { + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + dialog := s.dialogs[userID][channel.ID] + dialog.UserID = userID + dialog.ChannelID = channel.ID + dialog.TopMessageID = s.visibleTopMessageIDLocked(userID, channel) + if top.ID != 0 { + dialog.TopMessageDate = top.Date + } + member := s.members[channel.ID][userID] + if member.ReadInboxMaxID > dialog.ReadInboxMaxID { + dialog.ReadInboxMaxID = member.ReadInboxMaxID + } + if selfAction { + // 只推进 inbox:自己动作产生的消息不算自己的未读。outbox 回执水位 + // 表示"已被其他成员读到的最大 ID",绝不随自己的发送/动作推进, + // 否则同账号另一设备会把自己的消息渲染成对端已读。 + if channel.TopMessageID > dialog.ReadInboxMaxID { + dialog.ReadInboxMaxID = channel.TopMessageID + } + // 发送方向清手动未读标记,对齐 postgres 发送路径双表清除。 + dialog.UnreadMark = false + } + dialog.UnreadCount = s.channelUnreadCountLocked(userID, channel.ID, dialog.ReadInboxMaxID, dialog.TopMessageID) + dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, channel.ID, 0) + dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channel.ID, 0) + s.dialogs[userID][channel.ID] = dialog +} + +func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int, memberStatus domain.ChannelMemberStatus) domain.Dialog { + return domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID}, + // 非成员预览(publicPreviewMember/被踢)须标记 ChannelLeft,客户端据此把频道渲染为只读 left 预览。 + ChannelLeft: memberStatus == domain.ChannelMemberLeft, + FolderID: dialog.FolderID, + TopMessage: dialog.TopMessageID, + TopMessageDate: dialog.TopMessageDate, + ReadInboxMaxID: dialog.ReadInboxMaxID, + ReadOutboxMaxID: dialog.ReadOutboxMaxID, + UnreadCount: dialog.UnreadCount, + UnreadMentions: dialog.UnreadMentions, + UnreadReactions: dialog.UnreadReactions, + Pinned: dialog.Pinned, + PinnedOrder: dialog.PinnedOrder, + UnreadMark: dialog.UnreadMark, + ViewForumAsMessages: dialog.ViewForumAsMessages, + HasScheduled: dialog.HasScheduled, + Pts: channelPts, + } +} + +func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { + topMessageID := channel.TopMessageID + if topMessageID <= member.AvailableMinID { + topMessageID = 0 + } + return domain.ChannelDialog{ + UserID: userID, + ChannelID: channel.ID, + TopMessageID: topMessageID, + TopMessageDate: channel.Date, + ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID), + ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID), + } +} + +func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool { + if filter.HasFolderID { + if filter.FolderID < domain.DialogCustomFolderMinID { + if dialog.FolderID != filter.FolderID { + return false + } + } else if filter.Folder == nil { + return false + } + } else if dialog.FolderID != domain.DialogMainFolderID { + // 不带 folder_id 视为主列表(folder 0),与私聊侧/官方语义一致。 + return false + } + if filter.PinnedOnly && !dialog.Pinned { + return false + } + if filter.ExcludePinned && dialog.Pinned { + return false + } + if !channelDialogAfterOffset(dialog, filter) { + return false + } + if filter.Folder == nil { + return true + } + folder := filter.Folder + if peerInFolderList(dialog.Peer, folder.ExcludePeers) { + return false + } + if folder.ExcludeRead && dialog.UnreadCount == 0 && !dialog.UnreadMark { + return false + } + if folder.ExcludeArchived && dialog.FolderID == domain.DialogArchiveFolderID { + return false + } + if peerInFolderList(dialog.Peer, folder.PinnedPeers) || peerInFolderList(dialog.Peer, folder.IncludePeers) { + return true + } + if channel.Megagroup && folder.Groups { + return true + } + if channel.Broadcast && folder.Broadcasts { + return true + } + // 群/频道只能经 Groups/Broadcasts 开关或显式 include/pinned 进入自定义文件夹; + // 仅勾 Contacts/NonContacts/Bots 的文件夹不含任何群/频道(与 postgres 对齐)。 + return false +} + +func channelDialogAfterOffset(dialog domain.Dialog, filter domain.DialogFilter) bool { + if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { + if filter.HasOffsetPeer && filter.OffsetPeer == dialog.Peer { + return false + } + return true + } + if filter.OffsetDate > 0 { + if dialog.TopMessageDate != filter.OffsetDate { + return dialog.TopMessageDate < filter.OffsetDate + } + if filter.OffsetID <= 0 { + return false + } + if dialog.TopMessage != filter.OffsetID { + return dialog.TopMessage < filter.OffsetID + } + if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { + return dialog.Peer.ID < filter.OffsetPeer.ID + } + return false + } + if dialog.TopMessage != filter.OffsetID { + return dialog.TopMessage < filter.OffsetID + } + if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { + return dialog.Peer.ID < filter.OffsetPeer.ID + } + return false +} + +func peerInFolderList(peer domain.Peer, items []domain.DialogFolderPeer) bool { + for _, item := range items { + if item.Peer == peer { + return true + } + } + return false +} diff --git a/internal/store/memory/channel_groupcall.go b/internal/store/memory/channel_groupcall.go new file mode 100644 index 00000000..b4c1a219 --- /dev/null +++ b/internal/store/memory/channel_groupcall.go @@ -0,0 +1,83 @@ +package memory + +import ( + "context" + + "telesrv/internal/domain" +) + +// SetActiveCall 写入/清除 channel 行上的活跃群通话关联。 +func (s *ChannelStore) SetActiveCall(_ context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error) { + s.mu.Lock() + defer s.mu.Unlock() + ch, ok := s.channels[channelID] + if !ok || ch.Deleted { + return domain.Channel{}, domain.ErrChannelInvalid + } + ch.ActiveCallID = callID + ch.ActiveCallAccessHash = callAccessHash + ch.ActiveCallNotEmpty = notEmpty && callID != 0 + s.channels[channelID] = ch + return ch, nil +} + +// AppendCallServiceMessage 生成群通话服务消息(带频道 pts)。 +func (s *ChannelStore) AppendCallServiceMessage(_ context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) { + return s.appendServiceMessageLocked(channelID, senderUserID, date, action) +} + +// AppendStarGiftAdminLog 记录频道 Star gift 到 Recent Actions,不进入频道消息历史。 +func (s *ChannelStore) AppendStarGiftAdminLog(_ context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error { + if channelID == 0 || senderUserID == 0 || savedID <= 0 { + return domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + ch, ok := s.channels[channelID] + if !ok || ch.Deleted { + return domain.ErrChannelInvalid + } + messageID := int(savedID) + if savedID > int64(domain.MaxMessageBoxID) { + messageID = domain.MaxMessageBoxID + } + action = channelServiceActionForMessage(channelID, messageID, action) + msg := domain.ChannelMessage{ + ChannelID: channelID, + ID: messageID, + SenderUserID: senderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + Date: date, + Post: ch.Broadcast, + Action: &action, + Pts: ch.Pts, + } + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: senderUserID, + Date: date, + Type: domain.ChannelAdminLogSendMessage, + Message: &msg, + }) + return nil +} + +func (s *ChannelStore) appendServiceMessageLocked(channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + ch, ok := s.channels[channelID] + if !ok || ch.Deleted { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + msg, event := s.appendChannelServiceMessageLocked(channelID, senderUserID, date, action) + ch = s.channels[channelID] + ch.TopMessageID = msg.ID + ch.Pts = event.Pts + s.channels[channelID] = ch + return domain.SendChannelMessageResult{ + Channel: ch, + Message: msg, + Event: event, + Recipients: s.activeMemberIDsLocked(channelID, 0, 0), + }, nil +} diff --git a/internal/store/memory/channel_helpers.go b/internal/store/memory/channel_helpers.go new file mode 100644 index 00000000..5c702094 --- /dev/null +++ b/internal/store/memory/channel_helpers.go @@ -0,0 +1,703 @@ +package memory + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "sort" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) SaveChannelDefaultSendAs(_ context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + if req.SendAs != nil && req.SendAs.Type != domain.PeerTypeUser && req.SendAs.Type != domain.PeerTypeChannel { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelView{}, err + } + dialog := s.dialogForUserLocked(req.UserID, channel) + if req.SendAs != nil { + p := *req.SendAs + dialog.DefaultSendAs = &p + } else { + dialog.DefaultSendAs = nil + } + if s.dialogs[req.UserID] == nil { + s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[req.UserID][req.ChannelID] = dialog + member := s.members[req.ChannelID][req.UserID] + return domain.ChannelView{Channel: cloneChannel(channel), Self: member, Dialog: dialog}, nil +} + +func (s *ChannelStore) DeleteChannel(_ context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.DeleteChannelResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if member.Role != domain.ChannelRoleCreator { + return domain.DeleteChannelResult{}, domain.ErrChannelAdminRequired + } + recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) + channel.Deleted = true + channel.Username = "" + s.channels[req.ChannelID] = channel + // 连带软删关联 monoforum(频道私信容器),与 PG store 等价:仅当 counterpart 是 monoforum 时级联, + // 防止删 mono 反向误删真实母频道。不级联会留下指向已删父频道的孤儿 mono。 + var linkedMono *domain.Channel + if channel.LinkedMonoforumID != 0 { + if mono, ok := s.channels[channel.LinkedMonoforumID]; ok && mono.Monoforum && !mono.Deleted { + mono.Deleted = true + mono.Username = "" + s.channels[mono.ID] = mono + clone := cloneChannel(mono) + linkedMono = &clone + } + } + return domain.DeleteChannelResult{Channel: channel, Recipients: recipients, LinkedMonoforum: linkedMono}, nil +} + +func (s *ChannelStore) SearchPublicChannels(_ context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) { + if viewerUserID == 0 { + return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxPublicChannelSearchLimit { + limit = domain.MaxPublicChannelSearchLimit + } + query = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(query, "@"))) + if query == "" { + return domain.PublicChannelSearchResult{}, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + + type item struct { + channel domain.Channel + joined bool + rank int + } + items := make([]item, 0, limit) + for channelID, channel := range s.channels { + rank, ok := publicChannelSearchRank(channel, query) + if !ok { + continue + } + member, joined := s.members[channelID][viewerUserID] + joined = joined && member.Status == domain.ChannelMemberActive + items = append(items, item{ + channel: cloneChannel(channel), + joined: joined, + rank: rank, + }) + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].rank != items[j].rank { + return items[i].rank < items[j].rank + } + if items[i].joined != items[j].joined { + return items[i].joined + } + if items[i].channel.ParticipantsCount != items[j].channel.ParticipantsCount { + return items[i].channel.ParticipantsCount > items[j].channel.ParticipantsCount + } + if items[i].channel.Date != items[j].channel.Date { + return items[i].channel.Date > items[j].channel.Date + } + return items[i].channel.ID > items[j].channel.ID + }) + + out := domain.PublicChannelSearchResult{} + for _, item := range items { + if len(out.MyResults)+len(out.Results) >= limit { + break + } + if item.joined { + out.MyResults = append(out.MyResults, item.channel) + } else { + out.Results = append(out.Results, item.channel) + } + } + return out, nil +} + +func (s *ChannelStore) inviteByChannelHashLocked(channelID int64, hash string) (domain.ChannelInvite, error) { + hash = strings.TrimSpace(hash) + if hash == "" { + return domain.ChannelInvite{}, domain.ErrInviteHashEmpty + } + invite, ok := s.invites[hash] + if !ok || invite.ChannelID != channelID { + return domain.ChannelInvite{}, domain.ErrInviteHashInvalid + } + return invite, nil +} + +func (s *ChannelStore) inviteByIDLocked(channelID, inviteID int64) (domain.ChannelInvite, error) { + for _, invite := range s.invites { + if invite.ChannelID == channelID && invite.InviteID == inviteID { + return invite, nil + } + } + return domain.ChannelInvite{}, domain.ErrInviteHashInvalid +} + +func (s *ChannelStore) SearchPublicPosts(_ context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) { + query := strings.ToLower(strings.TrimSpace(req.Query)) + hashtag := strings.ToLower(strings.TrimSpace(req.Hashtag)) + if (query == "") == (hashtag == "") { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxChannelSearchPostsLimit { + req.Limit = domain.MaxChannelSearchPostsLimit + } + type hit struct { + channel domain.Channel + message domain.ChannelMessage + } + s.mu.RLock() + defer s.mu.RUnlock() + hits := make([]hit, 0, req.Limit+1) + for channelID, channel := range s.channels { + if channel.Deleted || strings.TrimSpace(channel.Username) == "" { + continue + } + for _, msg := range s.messages[channelID] { + if msg.Deleted || strings.TrimSpace(msg.Body) == "" { + continue + } + if !channelSearchPostAfterCursor(msg, req) { + continue + } + body := strings.ToLower(msg.Body) + if query != "" && !strings.Contains(body, query) { + continue + } + if hashtag != "" && !strings.Contains(body, "#"+hashtag) { + continue + } + hits = append(hits, hit{channel: channel, message: cloneChannelMessage(msg)}) + } + } + sort.Slice(hits, func(i, j int) bool { + a, b := hits[i].message, hits[j].message + if a.Date != b.Date { + return a.Date > b.Date + } + if a.ChannelID != b.ChannelID { + return a.ChannelID > b.ChannelID + } + return a.ID > b.ID + }) + out := domain.ChannelHistory{Count: len(hits)} + if out.Count > req.Limit { + out.Count = req.Limit + 1 + hits = hits[:req.Limit] + } + channelSeen := make(map[int64]struct{}, len(hits)) + for _, h := range hits { + out.Messages = append(out.Messages, h.message) + if _, ok := channelSeen[h.channel.ID]; ok { + continue + } + channelSeen[h.channel.ID] = struct{}{} + out.Channels = append(out.Channels, h.channel) + } + s.populateChannelMessagesReactionsLocked(viewerUserID, out.Channels, out.Messages) + return out, nil +} + +func channelSearchPostAfterCursor(msg domain.ChannelMessage, req domain.ChannelSearchPostsRequest) bool { + if req.OffsetRate <= 0 && req.OffsetChannelID <= 0 && req.OffsetID <= 0 { + return true + } + if req.OffsetRate > 0 { + if msg.Date < req.OffsetRate { + return true + } + if msg.Date > req.OffsetRate { + return false + } + } + if req.OffsetChannelID > 0 { + if msg.ChannelID < req.OffsetChannelID { + return true + } + if msg.ChannelID > req.OffsetChannelID { + return false + } + } + if req.OffsetID > 0 { + return msg.ID < req.OffsetID + } + return false +} + +func channelGlobalSearchAfterCursor(msg domain.ChannelMessage, req domain.ChannelGlobalSearchRequest) bool { + if req.OffsetRate <= 0 && req.OffsetChannelID <= 0 && req.OffsetID <= 0 { + return true + } + if req.OffsetRate > 0 { + if msg.Date < req.OffsetRate { + return true + } + if msg.Date > req.OffsetRate { + return false + } + } + if req.OffsetChannelID > 0 { + if msg.ChannelID < req.OffsetChannelID { + return true + } + if msg.ChannelID > req.OffsetChannelID { + return false + } + } + if req.OffsetID > 0 { + return msg.ID < req.OffsetID + } + return false +} + +func (s *ChannelStore) ListActiveChannelIDsForUser(_ context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if userID == 0 || afterChannelID < 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { + limit = domain.MaxSynchronousChannelDialogFanout + } + out := make([]int64, 0, limit) + for channelID, members := range s.members { + if channelID <= afterChannelID { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + continue + } + member, ok := members[userID] + if !ok || member.Status != domain.ChannelMemberActive { + continue + } + out = append(out, channelID) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (s *ChannelStore) ListDirtyActiveChannelsForUser(_ context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if userID == 0 || sinceDate <= 0 || afterChannelID < 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { + limit = domain.MaxChannelDifferenceLimit + } + out := make([]domain.DirtyChannel, 0, limit) + for channelID, members := range s.members { + if channelID <= afterChannelID { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + continue + } + member, ok := members[userID] + if !ok || member.Status != domain.ChannelMemberActive { + continue + } + dirty := false + for _, event := range s.events[channelID] { + if event.Date > sinceDate { + dirty = true + break + } + } + if dirty { + out = append(out, domain.DirtyChannel{ChannelID: channelID, Pts: channel.Pts}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ChannelID < out[j].ChannelID }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (s *ChannelStore) nextChannelIDLocked() int64 { + id := s.nextID + s.nextID++ + return id +} + +func (s *ChannelStore) nextAccessHashLocked() int64 { + hash := s.nextHash + s.nextHash += 17 + return hash +} + +func (s *ChannelStore) channelForViewerLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, bool, error) { + channel, member, err := s.channelAndMemberLocked(userID, channelID) + if err == nil { + return channel, member, false, nil + } + if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.Channel{}, domain.ChannelMember{}, false, err + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelInvalid + } + existing, found := s.members[channelID][userID] + if found && (existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages) { + return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelUserBanned + } + if channel.Monoforum && channel.LinkedMonoforumID != 0 { + parentMember, ok := s.members[channel.LinkedMonoforumID][userID] + if ok && parentMember.Status == domain.ChannelMemberActive && isChannelAdmin(parentMember) { + return channel, syntheticMonoforumAdminMember(channel, parentMember), true, nil + } + } + if !publicPreviewableChannel(channel) { + return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate + } + return channel, publicPreviewMember(channel, userID, existing, found), true, nil +} + +func (s *ChannelStore) dialogForUserLocked(userID int64, channel domain.Channel) domain.ChannelDialog { + return s.dialogForMemberLocked(userID, channel, s.members[channel.ID][userID]) +} + +func (s *ChannelStore) dialogForMemberLocked(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { + dialog := s.dialogs[userID][channel.ID] + dialog.UserID = userID + dialog.ChannelID = channel.ID + dialog.TopMessageID = s.visibleTopMessageIDForMemberLocked(channel, member) + // TopMessageDate 必须从可见 top 消息派生(不能继承空缓存的 0),否则会话排序/分页与预览 + // dialog 的日期全错。与 postgres GetChannelDialogs 用 getChannelMessage 设 date 对齐。 + dialog.TopMessageDate = 0 + if dialog.TopMessageID > 0 { + if top, ok := s.findMessageLocked(channel.ID, dialog.TopMessageID); ok { + dialog.TopMessageDate = top.Date + } + } + if member.ReadInboxMaxID > dialog.ReadInboxMaxID { + dialog.ReadInboxMaxID = member.ReadInboxMaxID + } + if member.ReadOutboxMaxID > dialog.ReadOutboxMaxID { + dialog.ReadOutboxMaxID = member.ReadOutboxMaxID + } + // 公共已读水位派生:即使实时 fanout 被截断,read_outbox 真值仍前进。 + if derived := s.readMarks[channel.ID].forSender(userID); derived > dialog.ReadOutboxMaxID { + dialog.ReadOutboxMaxID = derived + } + if top := channel.TopMessageID; dialog.ReadOutboxMaxID > top { + dialog.ReadOutboxMaxID = top + } + dialog.UnreadCount = s.channelUnreadCountLocked(userID, channel.ID, dialog.ReadInboxMaxID, dialog.TopMessageID) + dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, channel.ID, 0) + dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channel.ID, 0) + return dialog +} + +func channelReplyBelongsToRoot(msg domain.ChannelMessage, channelID int64, rootID int) bool { + if msg.ReplyTo == nil || rootID <= 0 { + return false + } + if msg.ReplyTo.Peer.ID != 0 && msg.ReplyTo.Peer != (domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) { + return false + } + return msg.ReplyTo.TopMessageID == rootID || (msg.ReplyTo.TopMessageID == 0 && msg.ReplyTo.MessageID == rootID) +} + +func (s *ChannelStore) resolveChannelReplyLocked(req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel) (*domain.MessageReply, error) { + if req.ReplyTo == nil { + return nil, nil + } + if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { + return nil, err + } + peer := req.ReplyTo.Peer + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID} + if peer.ID == 0 { + peer = channelPeer + } + if peer != channelPeer { + return nil, domain.ErrReplyMessageIDInvalid + } + if req.ReplyTo.MessageID == 0 { + if req.ReplyTo.TopMessageID <= 0 || !channel.Forum { + return nil, domain.ErrReplyMessageIDInvalid + } + topic, ok := s.topics[req.ChannelID][req.ReplyTo.TopMessageID] + if !ok || topic.Hidden { + return nil, domain.ErrReplyMessageIDInvalid + } + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + return nil, domain.ErrChannelWriteForbidden + } + reply := cloneMessageReply(req.ReplyTo) + reply.MessageID = 0 + reply.Peer = channelPeer + reply.TopMessageID = topic.TopicID + reply.ForumTopic = true + return reply, nil + } + target, ok := s.findMessageLocked(req.ChannelID, req.ReplyTo.MessageID) + if !ok || target.Deleted || target.ID <= member.AvailableMinID { + return nil, domain.ErrReplyMessageIDInvalid + } + reply := cloneMessageReply(req.ReplyTo) + reply.MessageID = target.ID + reply.Peer = channelPeer + reply.TopMessageID = target.ID + if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 { + reply.TopMessageID = target.ReplyTo.TopMessageID + } + if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID { + return nil, domain.ErrReplyMessageIDInvalid + } + if channel.Forum && reply.TopMessageID > 0 { + if topic, ok := s.topics[req.ChannelID][reply.TopMessageID]; ok && !topic.Hidden { + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + return nil, domain.ErrChannelWriteForbidden + } + reply.ForumTopic = true + } + } + return reply, nil +} + +func inactiveChannelDate(dialog domain.Dialog, channel domain.Channel, member domain.ChannelMember) int { + if dialog.TopMessageDate > 0 { + return dialog.TopMessageDate + } + date := channel.Date + if member.JoinedAt > date { + date = member.JoinedAt + } + return date +} + +func recommendableChannel(channel domain.Channel) bool { + return !channel.Deleted && + channel.Broadcast && + !channel.Megagroup && + strings.TrimSpace(channel.Username) != "" +} + +func publicSearchableChannel(channel domain.Channel) bool { + return !channel.Deleted && + (channel.Broadcast || channel.Megagroup) && + strings.TrimSpace(channel.Username) != "" +} + +func channelRoleOrder(role domain.ChannelMemberRole) int { + switch role { + case domain.ChannelRoleCreator: + return 0 + case domain.ChannelRoleAdmin: + return 1 + default: + return 2 + } +} + +func canChangeChannelInfo(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo) +} + +func boolPtr(v bool) *bool { + return &v +} + +func channelInitialAvailableMinID(channel domain.Channel) int { + if channel.PreHistoryHidden { + return channel.TopMessageID + } + return 0 +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func adminRightsSubset(want, have domain.ChannelAdminRights) bool { + return (!want.ChangeInfo || have.ChangeInfo) && + (!want.PostMessages || have.PostMessages) && + (!want.EditMessages || have.EditMessages) && + (!want.DeleteMessages || have.DeleteMessages) && + (!want.PostStories || have.PostStories) && + (!want.EditStories || have.EditStories) && + (!want.DeleteStories || have.DeleteStories) && + (!want.BanUsers || have.BanUsers) && + (!want.InviteUsers || have.InviteUsers) && + (!want.PinMessages || have.PinMessages) && + (!want.AddAdmins || have.AddAdmins) && + (!want.ManageCall || have.ManageCall) && + (!want.Anonymous || have.Anonymous) && + (!want.ManageRanks || have.ManageRanks) +} + +// checkEditMemberRank validates a rank-only (member tag) edit: creator edits +// anyone but no one else edits the creator; admins always edit their own tag, +// and with ManageRanks edit plain members plus admins they promoted; plain +// members edit only their own tag and only while neither the channel default +// nor their personal banned rights set edit_rank. Member tags exist only in +// megagroups: broadcast participants must keep an empty rank so the admins +// participant filter stays a pure admin list there. +func checkEditMemberRank(channel domain.Channel, actor, target domain.ChannelMember) error { + if !channel.Megagroup { + return domain.ErrMegagroupIDInvalid + } + if actor.UserID == target.UserID { + if actor.Role == domain.ChannelRoleCreator || actor.Role == domain.ChannelRoleAdmin { + return nil + } + if channel.DefaultBannedRights.EditRank || actor.BannedRights.EditRank { + return domain.ErrChannelRightForbidden + } + return nil + } + if target.Role == domain.ChannelRoleCreator { + return domain.ErrChannelUserCreator + } + if actor.Role == domain.ChannelRoleCreator { + return nil + } + if actor.Role != domain.ChannelRoleAdmin || !actor.AdminRights.ManageRanks { + return domain.ErrChannelAdminRequired + } + if target.Role == domain.ChannelRoleAdmin && target.InviterUserID != actor.UserID { + return domain.ErrChannelRightForbidden + } + return nil +} + +func adminLogBanType(previous, next domain.ChannelMember) domain.ChannelAdminLogEventType { + if next.Status == domain.ChannelMemberKicked || next.BannedRights.ViewMessages { + return domain.ChannelAdminLogParticipantKick + } + if previous.Status == domain.ChannelMemberKicked || previous.BannedRights.ViewMessages { + return domain.ChannelAdminLogParticipantUnkick + } + if !zeroChannelBannedRights(next.BannedRights) { + return domain.ChannelAdminLogParticipantBan + } + return domain.ChannelAdminLogParticipantUnban +} + +func adminLogSearchText(event domain.ChannelAdminLogEvent) string { + parts := []string{ + event.Query, + event.PrevString, + event.NewString, + } + for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { + if msg != nil { + parts = append(parts, msg.Body) + } + } + return strings.ToLower(strings.TrimSpace(strings.Join(parts, " "))) +} + +func int64Set(items []int64) map[int64]struct{} { + if len(items) == 0 { + return nil + } + out := make(map[int64]struct{}, len(items)) + for _, item := range items { + if item != 0 { + out[item] = struct{}{} + } + } + return out +} + +func (s *ChannelStore) refreshChannelCountsLocked(channelID int64) { + channel := s.channels[channelID] + var participants, admins, kicked, banned int + for _, member := range s.members[channelID] { + if member.Status == domain.ChannelMemberKicked { + kicked++ + } + if member.Status != domain.ChannelMemberActive { + continue + } + participants++ + if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + admins++ + } + if !zeroChannelBannedRights(member.BannedRights) { + banned++ + } + } + channel.ParticipantsCount = participants + channel.AdminsCount = admins + channel.KickedCount = kicked + channel.BannedCount = banned + s.channels[channelID] = channel +} + +func diffFinal(returned, all []domain.ChannelUpdateEvent) bool { + if len(returned) == 0 { + return true + } + return returned[len(returned)-1].Pts >= all[len(all)-1].Pts +} + +func randomMemoryPositiveInt64() (int64, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, err + } + return int64(binary.LittleEndian.Uint64(b[:]) & ((1 << 63) - 1)), nil +} + +func discussionGroupUpdateResult(changed map[int64]domain.Channel) domain.DiscussionGroupUpdateResult { + ids := make([]int64, 0, len(changed)) + for id := range changed { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { + return ids[i] < ids[j] + }) + out := domain.DiscussionGroupUpdateResult{Channels: make([]domain.Channel, 0, len(ids))} + for _, id := range ids { + out.Channels = append(out.Channels, cloneChannel(changed[id])) + } + return out +} + +func (s *ChannelStore) topicWithViewerCountersLocked(viewerUserID, channelID int64, topic domain.ChannelForumTopic, member domain.ChannelMember) domain.ChannelForumTopic { + out := cloneChannelForumTopic(topic) + water := s.channelTopicReadInboxLocked(channelID, viewerUserID, topic.TopicID, member.AvailableMinID) + out.UnreadCount = s.channelTopicUnreadCountLocked(viewerUserID, channelID, topic.TopicID, water) + out.ReadInboxMaxID = water + out.ReadOutboxMaxID = s.channelTopicReadOutboxLocked(channelID, viewerUserID, topic.TopicID) + out.UnreadMentionsCount = s.countChannelUnreadMentionsLocked(viewerUserID, channelID, topic.TopicID) + out.UnreadReactionsCount = s.countChannelUnreadReactionsLocked(viewerUserID, channelID, topic.TopicID) + return out +} diff --git a/internal/store/memory/channel_invites.go b/internal/store/memory/channel_invites.go new file mode 100644 index 00000000..f55d542b --- /dev/null +++ b/internal/store/memory/channel_invites.go @@ -0,0 +1,988 @@ +package memory + +import ( + "context" + "crypto/rand" + "encoding/base64" + "sort" + "strings" + "telesrv/internal/domain" + "time" +) + +func (s *ChannelStore) InviteToChannel(_ context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(inviterUserID, channelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + inviter := s.members[channelID][inviterUserID] + if !canInviteToChannel(channel, inviter) { + return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired + } + requested := uniqueNonZero(userIDs, 0) + inviteOne := len(requested) == 1 + canRestoreKicked := canBanChannelUsers(inviter) + added := make([]int64, 0, len(requested)) + members := make([]domain.ChannelMember, 0, len(requested)) + restoredKicked := 0 + for _, userID := range requested { + if existing, ok := s.members[channelID][userID]; ok { + if existing.Status == domain.ChannelMemberActive { + if inviteOne { + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + } + continue + } + if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { + if !canRestoreKicked { + if inviteOne { + return domain.CreateChannelResult{}, domain.ErrUserKicked + } + continue + } + if existing.Status == domain.ChannelMemberKicked { + restoredKicked++ + } + } + } + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: inviterUserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + } + s.members[channelID][userID] = member + members = append(members, member) + added = append(added, userID) + channel.ParticipantsCount++ + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: inviterUserID, + Date: date, + Type: domain.ChannelAdminLogParticipantInvite, + Participant: ptrChannelMember(member), + }) + } + if restoredKicked > 0 { + channel.KickedCount = maxInt(channel.KickedCount-restoredKicked, 0) + } + s.channels[channelID] = channel + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if len(added) > 0 && channel.Megagroup { + msg, event = s.appendChannelServiceMessageLocked(channelID, inviterUserID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatAddUser, + UserIDs: append([]int64(nil), added...), + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[channelID] = channel + } + for _, member := range members { + s.upsertChannelDialogLocked(member.UserID, channel, msg, false) + } + return domain.CreateChannelResult{ + Channel: channel, + Members: cloneChannelMembers(members), + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(channelID, 0, 0), + }, nil +} + +func (s *ChannelStore) ListAdminLog(_ context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 { + return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelAdminLogResult{}, err + } + if !isChannelAdmin(s.members[req.ChannelID][req.UserID]) { + return domain.ChannelAdminLogResult{}, domain.ErrChannelAdminRequired + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelAdminLogLimit { + limit = domain.MaxChannelAdminLogLimit + } + admins := int64Set(req.AdminUserIDs) + query := strings.ToLower(strings.TrimSpace(req.Query)) + out := make([]domain.ChannelAdminLogEvent, 0, limit) + events := s.adminLogs[req.ChannelID] + for i := len(events) - 1; i >= 0 && len(out) < limit; i-- { + event := events[i] + if req.MaxID > 0 && event.ID >= req.MaxID { + continue + } + if req.MinID > 0 && event.ID <= req.MinID { + continue + } + if len(admins) > 0 { + if _, ok := admins[event.UserID]; !ok { + continue + } + } + if !adminLogEventMatchesFilter(event.Type, req.Filter) { + continue + } + if query != "" && !adminLogEventMatchesQuery(event, query) { + continue + } + out = append(out, cloneChannelAdminLogEvent(event)) + } + return domain.ChannelAdminLogResult{Channel: channel, Events: out}, nil +} + +func (s *ChannelStore) ExportInvite(_ context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ExportChannelInviteResult{}, domain.ErrChannelAdminRequired + } + if req.LegacyRevokePermanent { + for hash, invite := range s.invites { + if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.UserID && invite.Permanent { + invite.Revoked = true + s.invites[hash] = invite + } + } + } + inviteID, err := randomMemoryPositiveInt64() + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + hash, err := randomMemoryInviteHash() + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + invite := domain.ChannelInvite{ + ChannelID: req.ChannelID, + InviteID: inviteID, + Hash: hash, + AdminUserID: req.UserID, + Title: req.Title, + Permanent: req.ExpireDate == 0 && req.UsageLimit == 0 && !req.RequestNeeded && req.Title == "", + RequestNeeded: req.RequestNeeded, + ExpireDate: req.ExpireDate, + UsageLimit: req.UsageLimit, + Date: req.Date, + } + s.invites[hash] = invite + channel = s.refreshChannelHasLinkLocked(req.ChannelID) + return domain.ExportChannelInviteResult{Channel: channel, Invite: invite}, nil +} + +// EnsurePermanentInvite 幂等返回 (channel, admin) 当前未撤销的永久邀请;缺失则创建。 +// 与 postgres 实现语义对齐(官方语义:邀请权限管理员必有主链接)。 +func (s *ChannelStore) EnsurePermanentInvite(_ context.Context, channelID, adminUserID int64, date int) (domain.ChannelInvite, error) { + if channelID == 0 || adminUserID == 0 { + return domain.ChannelInvite{}, domain.ErrChannelInvalid + } + if date == 0 { + date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.channelForMemberLocked(adminUserID, channelID); err != nil { + return domain.ChannelInvite{}, err + } + member := s.members[channelID][adminUserID] + if !canExportChannelInvite(member) { + return domain.ChannelInvite{}, domain.ErrChannelAdminRequired + } + var existing *domain.ChannelInvite + for hash := range s.invites { + invite := s.invites[hash] + if invite.ChannelID != channelID || invite.AdminUserID != adminUserID || !invite.Permanent || invite.Revoked { + continue + } + // map 遍历无序:取最早创建的一条,对齐 postgres ORDER BY created_at ASC。 + if existing == nil || invite.Date < existing.Date || (invite.Date == existing.Date && invite.Hash < existing.Hash) { + copied := invite + existing = &copied + } + } + if existing != nil { + s.setChannelHasLinkLocked(channelID, true) + return *existing, nil + } + inviteID, err := randomMemoryPositiveInt64() + if err != nil { + return domain.ChannelInvite{}, err + } + hash, err := randomMemoryInviteHash() + if err != nil { + return domain.ChannelInvite{}, err + } + invite := domain.ChannelInvite{ + ChannelID: channelID, + InviteID: inviteID, + Hash: hash, + AdminUserID: adminUserID, + Permanent: true, + Date: date, + } + s.invites[hash] = invite + s.setChannelHasLinkLocked(channelID, true) + return invite, nil +} + +func (s *ChannelStore) CheckInvite(_ context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) { + if userID == 0 || strings.TrimSpace(hash) == "" { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty + } + s.mu.RLock() + defer s.mu.RUnlock() + invite, ok := s.invites[strings.TrimSpace(hash)] + if !ok || invite.Revoked { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid + } + if invite.ExpireDate > 0 && invite.ExpireDate < date { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashExpired + } + channel, ok := s.channels[invite.ChannelID] + if !ok || channel.Deleted { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid + } + member := s.members[invite.ChannelID][userID] + if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid + } + return domain.CheckChannelInviteResult{ + Channel: channel, + Invite: invite, + Already: member.Status == domain.ChannelMemberActive, + Self: member, + }, nil +} + +func (s *ChannelStore) ImportInvite(_ context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty + } + s.mu.Lock() + defer s.mu.Unlock() + invite, ok := s.invites[strings.TrimSpace(req.Hash)] + if !ok || invite.Revoked { + return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid + } + if invite.ExpireDate > 0 && invite.ExpireDate < req.Date { + return domain.CreateChannelResult{}, domain.ErrInviteHashExpired + } + channel, ok := s.channels[invite.ChannelID] + if !ok || channel.Deleted { + return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid + } + if invite.RequestNeeded { + if err := s.recordPendingInviteRequestLocked(invite, req.UserID, req.Date); err != nil { + return domain.CreateChannelResult{}, err + } + return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent + } + return s.approveInviteImporterLocked(channel, invite, req.UserID, 0, req.Date) +} + +func (s *ChannelStore) ListExportedInvites(_ context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { + return domain.ChannelInviteList{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return domain.ChannelInviteList{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ChannelInviteList{}, domain.ErrChannelAdminRequired + } + all := make([]domain.ChannelInvite, 0) + for _, invite := range s.invites { + if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.AdminUserID && invite.Revoked == req.Revoked { + all = append(all, invite) + } + } + sort.Slice(all, func(i, j int) bool { + if all[i].Date != all[j].Date { + return all[i].Date > all[j].Date + } + return all[i].Hash > all[j].Hash + }) + total := len(all) + start := 0 + if req.OffsetDate > 0 || req.OffsetHash != "" { + start = len(all) + for i, invite := range all { + if invite.Date == req.OffsetDate && invite.Hash == req.OffsetHash { + start = i + 1 + break + } + } + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelInviteListLimit { + limit = domain.MaxChannelInviteListLimit + } + if start > len(all) { + start = len(all) + } + end := start + limit + if end > len(all) { + end = len(all) + } + return domain.ChannelInviteList{Count: total, Invites: cloneChannelInvites(all[start:end])}, nil +} + +func (s *ChannelStore) permanentInviteForAdminLocked(channelID, adminUserID int64) (domain.ChannelInvite, bool) { + var ( + best domain.ChannelInvite + ok bool + ) + for _, invite := range s.invites { + if invite.ChannelID != channelID || invite.AdminUserID != adminUserID || !invite.Permanent || invite.Revoked { + continue + } + if !ok || invite.Date < best.Date || (invite.Date == best.Date && invite.Hash < best.Hash) { + best = invite + ok = true + } + } + return best, ok +} + +func (s *ChannelStore) GetExportedInvite(_ context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.ChannelInvite{}, domain.ErrInviteHashEmpty + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return domain.ChannelInvite{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ChannelInvite{}, domain.ErrChannelAdminRequired + } + return s.inviteByChannelHashLocked(req.ChannelID, req.Hash) +} + +func (s *ChannelStore) EditExportedInvite(_ context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty + } + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return domain.EditChannelInviteResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.EditChannelInviteResult{}, domain.ErrChannelAdminRequired + } + invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) + if err != nil { + return domain.EditChannelInviteResult{}, err + } + if req.Revoked { + if invite.Revoked { + return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing + } + invite.Revoked = true + s.invites[invite.Hash] = invite + if !invite.Permanent { + s.refreshChannelHasLinkLocked(req.ChannelID) + return domain.EditChannelInviteResult{Invite: invite}, nil + } + newInvite, err := s.newReplacementInviteLocked(invite, req.Date) + if err != nil { + return domain.EditChannelInviteResult{}, err + } + s.invites[newInvite.Hash] = newInvite + s.refreshChannelHasLinkLocked(req.ChannelID) + return domain.EditChannelInviteResult{Invite: invite, NewInvite: &newInvite}, nil + } + if invite.Permanent && ((req.HasExpireDate && req.ExpireDate > 0) || (req.HasUsageLimit && req.UsageLimit > 0) || (req.HasRequestNeeded && req.RequestNeeded)) { + return domain.EditChannelInviteResult{}, domain.ErrInvitePermanent + } + if req.HasExpireDate { + invite.ExpireDate = req.ExpireDate + } + if req.HasUsageLimit { + invite.UsageLimit = req.UsageLimit + } + if req.HasRequestNeeded { + invite.RequestNeeded = req.RequestNeeded + } + if req.HasTitle { + invite.Title = req.Title + } + invite.Permanent = invite.ExpireDate == 0 && invite.UsageLimit == 0 && !invite.RequestNeeded && invite.Title == "" + s.invites[invite.Hash] = invite + s.refreshChannelHasLinkLocked(req.ChannelID) + return domain.EditChannelInviteResult{Invite: invite}, nil +} + +func (s *ChannelStore) DeleteExportedInvite(_ context.Context, req domain.DeleteChannelInviteRequest) error { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.ErrInviteHashEmpty + } + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ErrChannelAdminRequired + } + invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) + if err != nil { + return err + } + delete(s.invites, invite.Hash) + s.refreshChannelHasLinkLocked(req.ChannelID) + return nil +} + +func (s *ChannelStore) DeleteRevokedExportedInvites(_ context.Context, req domain.DeleteRevokedChannelInvitesRequest) error { + if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { + return domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ErrChannelAdminRequired + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { + limit = domain.MaxChannelHideJoinRequests + } + deleted := 0 + for hash, invite := range s.invites { + if invite.ChannelID == req.ChannelID && invite.AdminUserID == req.AdminUserID && invite.Revoked { + delete(s.invites, hash) + deleted++ + if deleted >= limit { + break + } + } + } + return nil +} + +func (s *ChannelStore) ListAdminsWithInvites(_ context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) { + if userID == 0 || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(userID, channelID); err != nil { + return nil, err + } + member := s.members[channelID][userID] + if !canExportChannelInvite(member) { + return nil, domain.ErrChannelAdminRequired + } + byAdmin := map[int64]*domain.ChannelAdminInviteCount{} + for _, invite := range s.invites { + if invite.ChannelID != channelID { + continue + } + count := byAdmin[invite.AdminUserID] + if count == nil { + count = &domain.ChannelAdminInviteCount{AdminUserID: invite.AdminUserID} + byAdmin[invite.AdminUserID] = count + } + if invite.Revoked { + count.RevokedInvitesCount++ + } else { + count.InvitesCount++ + } + } + out := make([]domain.ChannelAdminInviteCount, 0, len(byAdmin)) + for _, count := range byAdmin { + out = append(out, *count) + } + sort.Slice(out, func(i, j int) bool { return out[i].AdminUserID < out[j].AdminUserID }) + return out, nil +} + +func (s *ChannelStore) ListInviteImporters(_ context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(req.UserID, req.ChannelID); err != nil { + return domain.ChannelInviteImporterList{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.ChannelInviteImporterList{}, domain.ErrChannelAdminRequired + } + var inviteID int64 + if req.Hash != "" { + invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) + if err != nil { + return domain.ChannelInviteImporterList{}, err + } + inviteID = invite.InviteID + } + if req.Query != "" { + return domain.ChannelInviteImporterList{}, nil + } + all := make([]domain.ChannelInviteImporter, 0) + for _, importer := range s.importers[req.ChannelID] { + if importer.Requested != req.Requested { + continue + } + if inviteID != 0 && importer.InviteID != inviteID { + continue + } + all = append(all, importer) + } + sort.Slice(all, func(i, j int) bool { + if all[i].Date != all[j].Date { + return all[i].Date > all[j].Date + } + return all[i].UserID > all[j].UserID + }) + total := len(all) + start := 0 + if req.OffsetDate > 0 || req.OffsetUserID != 0 { + start = len(all) + for i, importer := range all { + if importer.Date == req.OffsetDate && importer.UserID == req.OffsetUserID { + start = i + 1 + break + } + } + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelInviteListLimit { + limit = domain.MaxChannelInviteListLimit + } + if start > len(all) { + start = len(all) + } + end := start + limit + if end > len(all) { + end = len(all) + } + return domain.ChannelInviteImporterList{Count: total, Importers: cloneChannelInviteImporters(all[start:end])}, nil +} + +func (s *ChannelStore) PendingJoinRequests(_ context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) { + if channelID == 0 { + return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid + } + all := make([]domain.ChannelInviteImporter, 0) + for _, importer := range s.importers[channelID] { + if importer.Requested { + all = append(all, importer) + } + } + sort.Slice(all, func(i, j int) bool { + if all[i].Date != all[j].Date { + return all[i].Date > all[j].Date + } + return all[i].UserID > all[j].UserID + }) + if limit <= 0 || limit > domain.MaxChannelPendingJoinRecentRequesters { + limit = domain.MaxChannelPendingJoinRecentRequesters + } + if len(all) < limit { + limit = len(all) + } + recent := make([]int64, 0, limit) + for _, importer := range all[:limit] { + recent = append(recent, importer.UserID) + } + return domain.ChannelPendingJoinRequests{ + ChannelID: channelID, + Count: len(all), + RecentRequesters: recent, + }, nil +} + +func (s *ChannelStore) HideAllChatJoinRequests(_ context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired + } + var inviteID int64 + if req.Hash != "" { + invite, err := s.inviteByChannelHashLocked(req.ChannelID, req.Hash) + if err != nil { + return domain.CreateChannelResult{}, err + } + inviteID = invite.InviteID + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { + limit = domain.MaxChannelHideJoinRequests + } + targets := make([]domain.ChannelInviteImporter, 0, limit) + for _, importer := range s.importers[req.ChannelID] { + if !importer.Requested { + continue + } + if inviteID != 0 && importer.InviteID != inviteID { + continue + } + targets = append(targets, importer) + if len(targets) >= limit { + break + } + } + var result domain.CreateChannelResult + for _, importer := range targets { + invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID} + if importer.InviteID != 0 { + var err error + invite, err = s.inviteByIDLocked(req.ChannelID, importer.InviteID) + if err != nil { + return domain.CreateChannelResult{}, err + } + } + if !req.Approved { + s.deletePendingInviteImporterLocked(invite, importer.UserID) + result = domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, importer.UserID, 0)} + continue + } + result, err = s.approveInviteImporterLocked(channel, invite, importer.UserID, req.UserID, req.Date) + if err != nil { + return domain.CreateChannelResult{}, err + } + channel = result.Channel + } + if result.Channel.ID == 0 { + result = domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0)} + } + return result, nil +} + +func (s *ChannelStore) approveInviteImporterLocked(channel domain.Channel, invite domain.ChannelInvite, userID, approvedBy int64, date int) (domain.CreateChannelResult, error) { + if invite.InviteID != 0 && invite.UsageLimit > 0 && invite.UsageCount >= invite.UsageLimit { + return domain.CreateChannelResult{}, domain.ErrUsersTooMuch + } + channelID := channel.ID + if channelID == 0 { + channelID = invite.ChannelID + } + if existing, ok := s.members[channelID][userID]; ok { + if existing.Status == domain.ChannelMemberActive { + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid + } + } + preJoinTopID := channel.TopMessageID + minID := channelInitialAvailableMinID(channel) + inviterID := invite.AdminUserID + if inviterID == 0 { + inviterID = approvedBy + } + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: inviterID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + AvailableMinID: minID, + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: maxInt(minID, preJoinTopID), + } + if s.members[channelID] == nil { + s.members[channelID] = make(map[int64]domain.ChannelMember) + } + s.members[channelID][userID] = member + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantJoin, + }) + if importer, ok := s.importers[channelID][userID]; ok && importer.Requested { + if importer.InviteID == invite.InviteID { + if invite.InviteID != 0 && invite.RequestedCount > 0 { + invite.RequestedCount-- + } + } else if importer.InviteID != 0 { + if pendingInvite, err := s.inviteByIDLocked(channelID, importer.InviteID); err == nil && pendingInvite.RequestedCount > 0 { + pendingInvite.RequestedCount-- + s.invites[pendingInvite.Hash] = pendingInvite + } + } + } + if invite.InviteID != 0 && invite.Hash != "" { + invite.UsageCount++ + s.invites[invite.Hash] = invite + } + s.refreshChannelCountsLocked(channelID) + channel = s.channels[channelID] + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatJoinedByLink, + InviterUserID: invite.AdminUserID, + UserIDs: []int64{userID}, + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[channelID] = channel + } + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) + if msg.ID != 0 { + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) + } + s.members[channelID][userID] = member + s.upsertChannelDialogLocked(userID, channel, msg, true) + if s.importers[channelID] == nil { + s.importers[channelID] = make(map[int64]domain.ChannelInviteImporter) + } + s.importers[channelID][userID] = domain.ChannelInviteImporter{ + ChannelID: channelID, + InviteID: invite.InviteID, + UserID: userID, + Date: date, + ApprovedBy: approvedBy, + } + return domain.CreateChannelResult{ + Channel: channel, + Members: []domain.ChannelMember{member}, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(channelID, 0, 0), + }, nil +} + +func (s *ChannelStore) recordPendingInviteRequestLocked(invite domain.ChannelInvite, userID int64, date int) error { + if existing, ok := s.members[invite.ChannelID][userID]; ok { + if existing.Status == domain.ChannelMemberActive { + return domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.ErrInviteHashInvalid + } + } + if s.importers[invite.ChannelID] == nil { + s.importers[invite.ChannelID] = make(map[int64]domain.ChannelInviteImporter) + } + if existing, ok := s.importers[invite.ChannelID][userID]; ok && existing.Requested { + return domain.ErrInviteRequestSent + } + s.importers[invite.ChannelID][userID] = domain.ChannelInviteImporter{ + ChannelID: invite.ChannelID, + InviteID: invite.InviteID, + UserID: userID, + Date: date, + Requested: true, + } + invite.RequestedCount++ + s.invites[invite.Hash] = invite + return nil +} + +func (s *ChannelStore) deletePendingInviteImporterLocked(invite domain.ChannelInvite, userID int64) { + if existing, ok := s.importers[invite.ChannelID][userID]; ok && existing.Requested { + delete(s.importers[invite.ChannelID], userID) + if invite.InviteID != 0 && invite.Hash != "" && invite.RequestedCount > 0 { + invite.RequestedCount-- + s.invites[invite.Hash] = invite + } + } +} + +func (s *ChannelStore) newReplacementInviteLocked(old domain.ChannelInvite, date int) (domain.ChannelInvite, error) { + inviteID, err := randomMemoryPositiveInt64() + if err != nil { + return domain.ChannelInvite{}, err + } + hash, err := randomMemoryInviteHash() + if err != nil { + return domain.ChannelInvite{}, err + } + if date == 0 { + date = int(time.Now().Unix()) + } + return domain.ChannelInvite{ + ChannelID: old.ChannelID, + InviteID: inviteID, + Hash: hash, + AdminUserID: old.AdminUserID, + Permanent: old.Permanent, + Date: date, + }, nil +} + +func (s *ChannelStore) setChannelHasLinkLocked(channelID int64, hasLink bool) { + channel, ok := s.channels[channelID] + if !ok { + return + } + channel.HasLink = hasLink + s.channels[channelID] = channel +} + +func (s *ChannelStore) refreshChannelHasLinkLocked(channelID int64) domain.Channel { + channel, ok := s.channels[channelID] + if !ok { + return domain.Channel{} + } + channel.HasLink = s.channelHasNonRevokedInviteLocked(channelID) + s.channels[channelID] = channel + return channel +} + +func (s *ChannelStore) channelHasNonRevokedInviteLocked(channelID int64) bool { + for _, invite := range s.invites { + if invite.ChannelID == channelID && !invite.Revoked { + return true + } + } + return false +} + +func cloneChannelInvites(in []domain.ChannelInvite) []domain.ChannelInvite { + if len(in) == 0 { + return nil + } + out := make([]domain.ChannelInvite, len(in)) + copy(out, in) + return out +} + +func cloneChannelInviteImporters(in []domain.ChannelInviteImporter) []domain.ChannelInviteImporter { + if len(in) == 0 { + return nil + } + out := make([]domain.ChannelInviteImporter, len(in)) + copy(out, in) + return out +} + +func (s *ChannelStore) ListChannelInviteAdminMemberIDs(_ context.Context, channelID int64, limit int) ([]int64, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, ok := s.channels[channelID] + if channelID == 0 || !ok || channel.Deleted { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + members := s.members[channelID] + out := make([]int64, 0, minInt(len(members), limit)) + for _, member := range members { + if member.Status != domain.ChannelMemberActive { + continue + } + if member.Role == domain.ChannelRoleCreator { + out = append(out, member.UserID) + if len(out) >= limit { + break + } + continue + } + if member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo) { + out = append(out, member.UserID) + if len(out) >= limit { + break + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out, nil +} + +func (s *ChannelStore) appendChannelAdminLogLocked(event domain.ChannelAdminLogEvent) { + if event.ChannelID == 0 || event.UserID == 0 || event.Type == "" { + return + } + s.logSeq[event.ChannelID]++ + event.ID = s.logSeq[event.ChannelID] + event.Query = adminLogSearchText(event) + s.adminLogs[event.ChannelID] = append(s.adminLogs[event.ChannelID], cloneChannelAdminLogEvent(event)) +} + +func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool { + if member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) { + return true + } + return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !member.BannedRights.InviteUsers +} + +func canExportChannelInvite(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) +} + +func randomMemoryInviteHash() (string, error) { + var b [18]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} + +func cloneChannelAdminLogEvent(in domain.ChannelAdminLogEvent) domain.ChannelAdminLogEvent { + if in.PrevParticipant != nil { + in.PrevParticipant = ptrChannelMember(*in.PrevParticipant) + } + if in.NewParticipant != nil { + in.NewParticipant = ptrChannelMember(*in.NewParticipant) + } + if in.Participant != nil { + in.Participant = ptrChannelMember(*in.Participant) + } + if in.Message != nil { + in.Message = ptrChannelMessage(*in.Message) + } + if in.PrevMessage != nil { + in.PrevMessage = ptrChannelMessage(*in.PrevMessage) + } + if in.NewMessage != nil { + in.NewMessage = ptrChannelMessage(*in.NewMessage) + } + return in +} diff --git a/internal/store/memory/channel_members.go b/internal/store/memory/channel_members.go new file mode 100644 index 00000000..0bc22c3a --- /dev/null +++ b/internal/store/memory/channel_members.go @@ -0,0 +1,992 @@ +package memory + +import ( + "context" + "sort" + "strconv" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) GetParticipants(_ context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, err := s.channelForMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelParticipantList{}, err + } + if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { + limit = domain.MaxChannelParticipantsLimit + } + viewer := s.members[channelID][viewerUserID] + // 广播频道订阅者列表仅管理员可枚举(与隐藏成员同一门控):admins filter 仍放行(徽章数据源)。 + if channel.MembersListAdminOnly() && !isChannelAdmin(viewer) { + switch filter.Kind { + case domain.ChannelParticipantsAdmins: + case domain.ChannelParticipantsBots: + return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil + default: + return domain.ChannelParticipantList{Channel: channel, Count: channel.ParticipantsCount}, nil + } + } + if (filter.Kind == domain.ChannelParticipantsBanned || filter.Kind == domain.ChannelParticipantsKicked) && !isChannelAdmin(viewer) { + return domain.ChannelParticipantList{Channel: channel}, nil + } + query := strings.ToLower(strings.TrimSpace(filter.Query)) + items := make([]domain.ChannelMember, 0, len(s.members[channelID])) + for _, member := range s.members[channelID] { + if !channelParticipantMatchesFilter(member, filter.Kind, query) { + continue + } + if shouldHideAnonymousAdminFromParticipantList(viewer, member) { + continue + } + items = append(items, member) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Role != items[j].Role { + return channelRoleOrder(items[i].Role) < channelRoleOrder(items[j].Role) + } + return items[i].UserID < items[j].UserID + }) + count := len(items) + if offset < 0 { + offset = 0 + } + if offset > domain.MaxChannelParticipantsOffset { + offset = domain.MaxChannelParticipantsOffset + } + if offset >= len(items) { + items = nil + } else { + end := offset + limit + if end > len(items) { + end = len(items) + } + items = items[offset:end] + } + return domain.ChannelParticipantList{ + Channel: channel, + Participants: cloneChannelMembers(items), + Count: count, + }, nil +} + +func (s *ChannelStore) GetParticipant(_ context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { + return domain.ChannelMember{}, err + } + member, ok := s.members[channelID][participantUserID] + if !ok { + return domain.ChannelMember{}, domain.ErrChannelPrivate + } + return member, nil +} + +func (s *ChannelStore) FutureCreatorAfterLeave(_ context.Context, channelID, userID int64) (domain.ChannelMember, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(userID, channelID) + if err != nil { + return domain.ChannelMember{}, err + } + if channel.CreatorUserID != userID || member.Role != domain.ChannelRoleCreator { + return domain.ChannelMember{}, domain.ErrChannelAdminRequired + } + return s.futureCreatorAfterLeaveLocked(channelID, userID) +} + +func (s *ChannelStore) JoinChannel(_ context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + preJoinTopID := channel.TopMessageID + if existing, ok := s.members[channelID][userID]; ok { + if existing.Status == domain.ChannelMemberActive { + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { + return domain.CreateChannelResult{}, domain.ErrChannelUserBanned + } + } + if channel.JoinRequest { + if err := s.recordPublicJoinRequestLocked(channel, userID, date); err != nil { + return domain.CreateChannelResult{}, err + } + return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent + } + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + // 自加入:inviter 即本人(对齐官方 channelParticipantSelf.inviter_id == user_id), + // 客户端据此生成本地「您加入了此频道」服务消息把广播频道补进会话列表。见 postgres 同处注释。 + InviterUserID: userID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + } + if existing, ok := s.members[channelID][userID]; ok { + member = existing + member.Status = domain.ChannelMemberActive + member.LeftAt = 0 + // 重进是全新 participant:不保留旧的角色/管理权/Tag;inviter 重置为本人(自加入)。 + // 只有 channel 当前 owner 仍是该账号时,才保留 creator 身份。 + if existing.Role != domain.ChannelRoleCreator || channel.CreatorUserID != userID { + member.Role = domain.ChannelRoleMember + member.AdminRights = domain.ChannelAdminRights{} + member.Rank = "" + member.InviterUserID = userID + member.JoinedAt = date + } + if minID := channelInitialAvailableMinID(channel); minID > member.AvailableMinID { + member.AvailableMinID = minID + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, minID) + } + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, preJoinTopID) + if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts { + member.AvailableMinPts = minPts + } + channel.ParticipantsCount++ + } else { + member.AvailableMinID = channelInitialAvailableMinID(channel) + member.AvailableMinPts = channelInitialAvailableMinPts(channel) + member.ReadInboxMaxID = maxInt(member.AvailableMinID, preJoinTopID) + channel.ParticipantsCount++ + } + if s.members[channelID] == nil { + s.members[channelID] = make(map[int64]domain.ChannelMember) + } + s.members[channelID][userID] = member + s.channels[channelID] = channel + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantJoin, + }) + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatJoined, + UserIDs: []int64{userID}, + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[channelID] = channel + } + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) + if msg.ID != 0 && msg.SenderUserID == userID { + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) + } + s.members[channelID][userID] = member + s.upsertChannelDialogLocked(userID, channel, msg, true) + return domain.CreateChannelResult{ + Channel: channel, + Members: []domain.ChannelMember{member}, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(channelID, 0, 0), + }, nil +} + +func (s *ChannelStore) LeaveChannel(_ context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(userID, channelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + members := []domain.ChannelMember{} + adminsDelta := 0 + if isChannelAdmin(member) { + adminsDelta-- + } + if channel.CreatorUserID == userID || member.Role == domain.ChannelRoleCreator { + if channel.CreatorUserID != userID || member.Role != domain.ChannelRoleCreator { + return domain.CreateChannelResult{}, domain.ErrChannelUserCreator + } + future, err := s.futureCreatorAfterLeaveLocked(channelID, userID) + if err != nil { + return domain.CreateChannelResult{}, domain.ErrChannelUserCreator + } + if !isChannelAdmin(future) { + adminsDelta++ + } + future.Role = domain.ChannelRoleCreator + future.AdminRights = creatorChannelAdminRights() + future.Rank = "" + future.Status = domain.ChannelMemberActive + future.LeftAt = 0 + s.members[channelID][future.UserID] = future + channel.CreatorUserID = future.UserID + members = append(members, future) + member.Role = domain.ChannelRoleMember + member.AdminRights = domain.ChannelAdminRights{} + member.Rank = "" + } + member.Status = domain.ChannelMemberLeft + member.LeftAt = date + s.members[channelID][userID] = member + members = append([]domain.ChannelMember{member}, members...) + s.clearChannelMentionsForUserLocked(channelID, userID) + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantLeave, + }) + if channel.ParticipantsCount > 0 { + channel.ParticipantsCount-- + } + channel.AdminsCount += adminsDelta + if channel.AdminsCount < 0 { + channel.AdminsCount = 0 + } + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event = s.appendChannelServiceMessageLocked(channelID, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatDelete, + UserIDs: []int64{userID}, + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + } + s.channels[channelID] = channel + return domain.CreateChannelResult{ + Channel: channel, + Members: cloneChannelMembers(members), + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: append(s.activeMemberIDsLocked(channelID, 0, 0), userID), + }, nil +} + +func (s *ChannelStore) EditChannelAdmin(_ context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 { + return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelAdminResult{}, err + } + actor := s.members[req.ChannelID][req.UserID] + if !canAddChannelAdmins(actor) { + return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired + } + if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) { + return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden + } + previous, ok := s.members[req.ChannelID][req.MemberID] + if !ok { + previous = domain.ChannelMember{ + ChannelID: req.ChannelID, + UserID: req.MemberID, + InviterUserID: req.UserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: req.Date, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + } + } + if previous.Role == domain.ChannelRoleCreator { + return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator + } + member := previous + member.InviterUserID = req.UserID + member.Status = domain.ChannelMemberActive + member.LeftAt = 0 + if previous.Status != domain.ChannelMemberActive { + if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts { + member.AvailableMinPts = minPts + } + } + member.AdminRights = req.AdminRights + member.Rank = req.Rank + if zeroChannelAdminRights(req.AdminRights) { + member.Role = domain.ChannelRoleMember + member.Rank = "" + } else { + member.Role = domain.ChannelRoleAdmin + } + s.members[req.ChannelID][req.MemberID] = member + logType := domain.ChannelAdminLogParticipantPromote + if member.Role != domain.ChannelRoleAdmin { + logType = domain.ChannelAdminLogParticipantDemote + } + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: logType, + PrevParticipant: ptrChannelMember(previous), + NewParticipant: ptrChannelMember(member), + }) + s.refreshChannelCountsLocked(req.ChannelID) + channel = s.channels[req.ChannelID] + event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) + if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok { + s.upsertChannelDialogLocked(member.UserID, channel, msg, false) + } + recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) + recipients = append(recipients, req.MemberID) + return domain.EditChannelAdminResult{ + Channel: channel, + Previous: previous, + Participant: member, + Event: event, + Recipients: recipients, + Date: req.Date, + }, nil +} + +func (s *ChannelStore) EditChannelMemberRank(_ context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 { + return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelAdminResult{}, err + } + actor := s.members[req.ChannelID][req.UserID] + previous, ok := s.members[req.ChannelID][req.MemberID] + if !ok || previous.Status != domain.ChannelMemberActive { + return domain.EditChannelAdminResult{}, domain.ErrUserNotParticipant + } + if err := checkEditMemberRank(channel, actor, previous); err != nil { + return domain.EditChannelAdminResult{}, err + } + member := previous + member.Rank = req.Rank + s.members[req.ChannelID][req.MemberID] = member + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogParticipantEditRank, + PrevString: previous.Rank, + NewString: member.Rank, + Participant: ptrChannelMember(member), + }) + event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) + recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) + recipients = append(recipients, req.MemberID) + return domain.EditChannelAdminResult{ + Channel: channel, + Previous: previous, + Participant: member, + Event: event, + Recipients: recipients, + Date: req.Date, + }, nil +} + +func (s *ChannelStore) EditChannelBanned(_ context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 { + return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelBannedResult{}, err + } + actor := s.members[req.ChannelID][req.UserID] + if !canBanChannelUsers(actor) { + return domain.EditChannelBannedResult{}, domain.ErrChannelAdminRequired + } + previous, ok := s.members[req.ChannelID][req.Participant.ID] + if !ok { + previous = domain.ChannelMember{ChannelID: req.ChannelID, UserID: req.Participant.ID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberLeft} + } + if previous.Role == domain.ChannelRoleCreator { + return domain.EditChannelBannedResult{}, domain.ErrChannelUserCreator + } + member := previous + member.Role = domain.ChannelRoleMember + member.BannedRights = req.BannedRights + switch { + case req.BannedRights.ViewMessages: + member.InviterUserID = req.UserID + member.Status = domain.ChannelMemberKicked + member.LeftAt = req.Date + case zeroChannelBannedRights(req.BannedRights): + if previous.Status == domain.ChannelMemberActive { + member.Status = domain.ChannelMemberActive + } else { + member.Status = domain.ChannelMemberLeft + } + member.LeftAt = 0 + default: + member.InviterUserID = req.UserID + if previous.Status == domain.ChannelMemberActive { + member.Status = domain.ChannelMemberActive + } else { + member.Status = domain.ChannelMemberBanned + } + } + if member.JoinedAt == 0 && member.Status == domain.ChannelMemberActive { + member.JoinedAt = req.Date + } + s.members[req.ChannelID][req.Participant.ID] = member + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: adminLogBanType(previous, member), + PrevParticipant: ptrChannelMember(previous), + NewParticipant: ptrChannelMember(member), + }) + s.refreshChannelCountsLocked(req.ChannelID) + channel = s.channels[req.ChannelID] + event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) + if member.Status == domain.ChannelMemberActive { + if msg, ok := s.findMessageLocked(req.ChannelID, channel.TopMessageID); ok { + s.upsertChannelDialogLocked(member.UserID, channel, msg, false) + } + } + if member.Status == domain.ChannelMemberKicked && previous.Status == domain.ChannelMemberActive { + s.clearChannelMentionsForUserLocked(req.ChannelID, req.Participant.ID) + } + var serviceMsg domain.ChannelMessage + var serviceEvent domain.ChannelUpdateEvent + if channel.Megagroup && previous.Status == domain.ChannelMemberActive && member.Status == domain.ChannelMemberKicked { + serviceMsg, serviceEvent = s.appendChannelServiceMessageLocked(req.ChannelID, req.UserID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatDelete, + UserIDs: []int64{req.Participant.ID}, + }) + channel.TopMessageID = serviceMsg.ID + channel.Pts = serviceEvent.Pts + s.channels[req.ChannelID] = channel + } + recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) + recipients = append(recipients, req.Participant.ID) + return domain.EditChannelBannedResult{ + Channel: channel, + Previous: previous, + Participant: member, + Event: event, + Recipients: recipients, + Date: req.Date, + Message: cloneChannelMessage(serviceMsg), + ServiceEvent: cloneChannelEvent(serviceEvent), + }, nil +} + +func (s *ChannelStore) EditChannelDefaultBannedRights(_ context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + actor := s.members[req.ChannelID][req.UserID] + if !canBanChannelUsers(actor) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if channel.DefaultBannedRights == req.BannedRights { + return domain.Channel{}, domain.ErrChannelNotModified + } + channel.DefaultBannedRights = req.BannedRights + s.channels[req.ChannelID] = channel + return channel, nil +} + +func (s *ChannelStore) ListAdminedPublicChannels(_ context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Channel, 0) + for channelID, members := range s.members { + member := members[userID] + if member.Status != domain.ChannelMemberActive || !isChannelAdmin(member) { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || channel.Username == "" { + continue + } + out = append(out, channel) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if len(out) > domain.MaxAdminedPublicChannels { + out = out[:domain.MaxAdminedPublicChannels] + } + return append([]domain.Channel(nil), out...), nil +} + +func (s *ChannelStore) ListStoryPostableChannels(_ context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Channel, 0) + for channelID, members := range s.members { + member := members[userID] + if member.Status != domain.ChannelMemberActive { + continue + } + if member.Role != domain.ChannelRoleCreator && !(member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostStories) { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || (!channel.Broadcast && !channel.Megagroup) { + continue + } + out = append(out, channel) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if len(out) > domain.MaxStorySendAsChannels { + out = out[:domain.MaxStorySendAsChannels] + } + return append([]domain.Channel(nil), out...), nil +} + +func (s *ChannelStore) ListSendAsChannels(_ context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Channel, 0) + for channelID, members := range s.members { + member := members[userID] + if member.Status != domain.ChannelMemberActive { + continue + } + if member.Role != domain.ChannelRoleCreator && !(member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages) { + continue + } + channel, ok := s.channels[channelID] + if !ok || channel.Deleted || !channel.Broadcast { + continue + } + out = append(out, channel) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if len(out) > domain.MaxSendAsChannels { + out = out[:domain.MaxSendAsChannels] + } + return append([]domain.Channel(nil), out...), nil +} + +func (s *ChannelStore) SetParticipantsHidden(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !channel.Megagroup || !canBanChannelUsers(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.ParticipantsHidden = enabled + s.channels[channelID] = channel + return channel, nil +} + +func (s *ChannelStore) SetJoinToSend(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !channel.Megagroup || !canExportChannelInvite(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.JoinToSend = enabled + s.channels[channelID] = channel + return channel, nil +} + +func (s *ChannelStore) SetJoinRequest(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !channel.Megagroup || !canExportChannelInvite(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if enabled && strings.TrimSpace(channel.Username) == "" { + return domain.Channel{}, domain.ErrChatPublicRequired + } + channel.JoinRequest = enabled + s.channels[channelID] = channel + return channel, nil +} + +func (s *ChannelStore) HideChatJoinRequest(_ context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TargetUserID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canExportChannelInvite(member) { + return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired + } + importer, ok := s.importers[req.ChannelID][req.TargetUserID] + if !ok || !importer.Requested { + return domain.CreateChannelResult{}, domain.ErrHideRequesterMissing + } + invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID} + if importer.InviteID != 0 { + var err error + invite, err = s.inviteByIDLocked(req.ChannelID, importer.InviteID) + if err != nil { + return domain.CreateChannelResult{}, err + } + } + if !req.Approved { + s.deletePendingInviteImporterLocked(invite, req.TargetUserID) + return domain.CreateChannelResult{Channel: channel, Recipients: s.activeMemberIDsLocked(req.ChannelID, req.TargetUserID, 0)}, nil + } + return s.approveInviteImporterLocked(channel, invite, req.TargetUserID, req.UserID, req.Date) +} + +func (s *ChannelStore) recordPublicJoinRequestLocked(channel domain.Channel, userID int64, date int) error { + if existing, ok := s.members[channel.ID][userID]; ok { + if existing.Status == domain.ChannelMemberActive { + return domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.ErrInviteHashInvalid + } + } + if s.importers[channel.ID] == nil { + s.importers[channel.ID] = make(map[int64]domain.ChannelInviteImporter) + } + if existing, ok := s.importers[channel.ID][userID]; ok && existing.Requested { + return domain.ErrInviteRequestSent + } + s.importers[channel.ID][userID] = domain.ChannelInviteImporter{ + ChannelID: channel.ID, + UserID: userID, + Date: date, + Requested: true, + } + return nil +} + +func (s *ChannelStore) ListActiveChannelMemberIDs(_ context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(viewerUserID, channelID); err != nil { + return nil, err + } + return s.activeMemberIDsLocked(channelID, 0, limit), nil +} + +func (s *ChannelStore) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if channelID == 0 || len(userIDs) == 0 { + return nil, nil + } + members := s.members[channelID] + if len(members) == 0 { + return nil, nil + } + out := make([]int64, 0, len(userIDs)) + seen := make(map[int64]struct{}, len(userIDs)) + for _, userID := range userIDs { + if userID == 0 { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + member, ok := members[userID] + if !ok || member.Status != domain.ChannelMemberActive { + continue + } + out = append(out, userID) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out, nil +} + +func (s *ChannelStore) ListActiveChannelMembers(_ context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, viewer, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, nil, err + } + if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { + limit = domain.MaxSynchronousChannelDialogFanout + } + members := s.members[channelID] + out := make([]domain.ChannelMember, 0, minInt(limit, len(members))) + for _, member := range members { + if member.Status != domain.ChannelMemberActive { + continue + } + out = append(out, member) + if len(out) >= limit { + break + } + } + sort.Slice(out, func(i, j int) bool { return out[i].UserID < out[j].UserID }) + return channel, viewer, cloneChannelMembers(out), nil +} + +func (s *ChannelStore) channelForMemberLocked(userID, channelID int64) (domain.Channel, error) { + channel, _, err := s.channelAndMemberLocked(userID, channelID) + return channel, err +} + +func (s *ChannelStore) channelAndMemberLocked(userID, channelID int64) (domain.Channel, domain.ChannelMember, error) { + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelInvalid + } + member, ok := s.members[channelID][userID] + if !ok || member.Status == domain.ChannelMemberLeft { + return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelPrivate + } + if member.Status == domain.ChannelMemberBanned || member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages { + return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelUserBanned + } + return channel, member, nil +} + +func (s *ChannelStore) activeMemberIDsLocked(channelID, excludeUserID int64, limit int) []int64 { + members := s.members[channelID] + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + capacity := limit + if len(members) < capacity { + capacity = len(members) + } + out := make([]int64, 0, capacity) + for userID, member := range members { + if userID == excludeUserID || member.Status != domain.ChannelMemberActive { + continue + } + out = append(out, userID) + if len(out) >= limit { + break + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func (s *ChannelStore) futureCreatorAfterLeaveLocked(channelID, userID int64) (domain.ChannelMember, error) { + members := s.members[channelID] + var selected domain.ChannelMember + found := false + for _, member := range members { + if member.UserID == userID || member.Status != domain.ChannelMemberActive || member.Role == domain.ChannelRoleCreator || member.BannedRights.ViewMessages { + continue + } + if !found || futureCreatorCandidateLess(member, selected) { + selected = member + found = true + } + } + if !found { + return domain.ChannelMember{}, domain.ErrUserNotParticipant + } + return selected, nil +} + +func futureCreatorCandidateLess(a, b domain.ChannelMember) bool { + if channelRoleOrder(a.Role) != channelRoleOrder(b.Role) { + return channelRoleOrder(a.Role) < channelRoleOrder(b.Role) + } + return a.UserID < b.UserID +} + +func publicPreviewMember(channel domain.Channel, userID int64, existing domain.ChannelMember, found bool) domain.ChannelMember { + member := domain.ChannelMember{ + ChannelID: channel.ID, + UserID: userID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberLeft, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + ReadOutboxMaxID: channel.TopMessageID, + } + if found { + member.InviterUserID = existing.InviterUserID + member.JoinedAt = existing.JoinedAt + member.LeftAt = existing.LeftAt + member.AvailableMinID = maxInt(member.AvailableMinID, existing.AvailableMinID) + member.AvailableMinPts = maxInt(member.AvailableMinPts, existing.AvailableMinPts) + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, existing.ReadInboxMaxID) + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, existing.ReadOutboxMaxID) + } + return member +} + +func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.ChannelMember) domain.ChannelMember { + member := parentMember + member.ChannelID = mono.ID + member.Status = domain.ChannelMemberActive + if mono.CreatorUserID == parentMember.UserID { + member.Role = domain.ChannelRoleCreator + } else { + member.Role = domain.ChannelRoleAdmin + } + member.AvailableMinID = 0 + member.AvailableMinPts = 0 + member.ReadInboxMaxID = mono.TopMessageID + member.ReadOutboxMaxID = mono.TopMessageID + member.UnreadMark = false + member.SlowmodeLastSendDate = 0 + return member +} + +func publicChannelSearchRank(channel domain.Channel, queryLower string) (int, bool) { + if !publicSearchableChannel(channel) { + return 0, false + } + username := strings.ToLower(strings.TrimSpace(channel.Username)) + title := strings.ToLower(strings.TrimSpace(channel.Title)) + switch { + case username == queryLower: + return 0, true + case strings.HasPrefix(username, queryLower): + return 1, true + case strings.Contains(username, queryLower): + return 2, true + case strings.HasPrefix(title, queryLower): + return 3, true + case strings.Contains(title, queryLower): + return 4, true + default: + return 0, false + } +} + +func canPostToBroadcast(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages) +} + +func isChannelAdmin(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin +} + +func shouldHideAnonymousAdminFromParticipantList(viewer, member domain.ChannelMember) bool { + if isChannelAdmin(viewer) { + return false + } + if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin { + return false + } + return member.AdminRights.Anonymous +} + +func channelParticipantMatchesFilter(member domain.ChannelMember, kind domain.ChannelParticipantsFilterKind, query string) bool { + if query != "" && !strings.Contains(strconv.FormatInt(member.UserID, 10), query) { + return false + } + switch kind { + case "", domain.ChannelParticipantsRecent, domain.ChannelParticipantsContacts, domain.ChannelParticipantsMentions, domain.ChannelParticipantsSearch: + return member.Status == domain.ChannelMemberActive + case domain.ChannelParticipantsAdmins: + // Layer 225 起 admins filter 同时是消息徽章数据源:客户端把整个返回 + // (含 rank)灌进 badge 缓存,因此带成员 Tag 的普通成员也必须返回。 + return member.Status == domain.ChannelMemberActive && (isChannelAdmin(member) || member.Rank != "") + case domain.ChannelParticipantsKicked: + return member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages + case domain.ChannelParticipantsBanned: + return member.Status != domain.ChannelMemberKicked && !zeroChannelBannedRights(member.BannedRights) + case domain.ChannelParticipantsBots: + return false + default: + return member.Status == domain.ChannelMemberActive + } +} + +func canManageDiscussionBroadcast(member domain.ChannelMember) bool { + return canChangeChannelInfo(member) +} + +func canManageDiscussionGroup(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) +} + +func canAddChannelAdmins(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.AddAdmins) +} + +func canBanChannelUsers(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.BanUsers) +} + +func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool { + return rights == domain.ChannelAdminRights{} +} + +func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool { + return rights == domain.ChannelBannedRights{} +} + +func creatorChannelAdminRights() domain.ChannelAdminRights { + return domain.ChannelAdminRights{ + ChangeInfo: true, + PostMessages: true, + EditMessages: true, + DeleteMessages: true, + PostStories: true, + EditStories: true, + DeleteStories: true, + BanUsers: true, + InviteUsers: true, + PinMessages: true, + AddAdmins: true, + ManageCall: true, + } +} + +func cloneChannelMembers(in []domain.ChannelMember) []domain.ChannelMember { + return append([]domain.ChannelMember(nil), in...) +} + +func ptrChannelMember(in domain.ChannelMember) *domain.ChannelMember { + out := in + return &out +} diff --git a/internal/store/memory/channel_message_delete.go b/internal/store/memory/channel_message_delete.go new file mode 100644 index 00000000..03355c25 --- /dev/null +++ b/internal/store/memory/channel_message_delete.go @@ -0,0 +1,258 @@ +package memory + +import ( + "context" + "telesrv/internal/domain" +) + +func (s *ChannelStore) DeleteChannelMessages(_ context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || len(req.IDs) == 0 { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxDeleteMessageIDs { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelMessagesResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + refs := make(map[int]domain.ChannelDiscussionRef, len(req.IDs)) + for _, id := range req.IDs { + if msg, ok := s.findMessageLocked(req.ChannelID, id); ok && !msg.Deleted && msg.Discussion != nil { + refs[id] = *msg.Discussion + } + } + deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, req.IDs, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelMessagesResult{}, err + } + // 被删 broadcast post 的讨论组转发根级联删除(服务端动作,creator 权限)。 + var cascades []domain.ChannelCascadeDelete + byChannel := make(map[int64][]int) + for _, id := range deleted { + if ref, ok := refs[id]; ok && ref.ChannelID != 0 && ref.MessageID != 0 { + byChannel[ref.ChannelID] = append(byChannel[ref.ChannelID], ref.MessageID) + } + } + for groupID, rootIDs := range byChannel { + group, ok := s.channels[groupID] + if !ok || group.Deleted { + continue + } + systemMember := domain.ChannelMember{ChannelID: groupID, UserID: req.UserID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive} + groupDeleted, groupEvent, group, err := s.deleteChannelMessagesLocked(group, systemMember, rootIDs, req.UserID, req.Date) + if err != nil || len(groupDeleted) == 0 { + continue + } + cascades = append(cascades, domain.ChannelCascadeDelete{ + Channel: group, + Event: cloneChannelEvent(groupEvent), + Recipients: s.activeMemberIDsLocked(groupID, 0, 0), + }) + } + return domain.DeleteChannelMessagesResult{ + Channel: channel, + Event: cloneChannelEvent(event), + DeletedIDs: append([]int(nil), deleted...), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + DiscussionDeletes: cascades, + }, nil +} + +func (s *ChannelStore) DeleteChannelHistory(_ context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + maxID := req.MaxID + if maxID <= 0 || maxID > channel.TopMessageID { + maxID = channel.TopMessageID + } + member := s.members[req.ChannelID][req.UserID] + if !req.ForEveryone { + appliedMinID := maxInt(member.AvailableMinID, maxID) + member.AvailableMinID = appliedMinID + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, appliedMinID) + member.UnreadMark = false + s.members[req.ChannelID][req.UserID] = member + s.deleteChannelUnreadMentionsUpToLocked(req.UserID, req.ChannelID, appliedMinID) + if dialog, ok := s.dialogs[req.UserID][req.ChannelID]; ok { + dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(req.UserID, req.ChannelID, 0) + s.dialogs[req.UserID][req.ChannelID] = dialog + } + if s.dialogs[req.UserID] == nil { + s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[req.UserID][req.ChannelID] = s.dialogForUserLocked(req.UserID, channel) + return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil + } + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + // id=1 是建群服务消息,全员清空必须保留:它是清空后会话仅剩的 + // top message,没有它客户端会把 lastMessage 视为空并从聊天列表 + // 隐藏该会话(成员资格仍在,但会话条目对全员消失)。 + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- { + msg := s.messages[req.ChannelID][i] + if msg.Deleted || msg.ID > maxID || msg.ID <= 1 { + continue + } + ids = append(ids, msg.ID) + if len(ids) >= domain.MaxDeleteHistoryBatch { + break + } + } + deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + offset := 0 + if len(deleted) == domain.MaxDeleteHistoryBatch { + offset = 1 + } + return domain.DeleteChannelHistoryResult{ + Channel: channel, + Event: cloneChannelEvent(event), + DeletedIDs: append([]int(nil), deleted...), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Offset: offset, + }, nil +} + +func (s *ChannelStore) DeleteChannelParticipantHistory(_ context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + // 同全员清空:id=1 建群服务消息不随发送者(创建者)历史一起删除, + // 否则会话会因 lastMessage 为空从全员聊天列表隐藏。 + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- { + msg := s.messages[req.ChannelID][i] + if msg.Deleted || msg.SenderUserID != req.ParticipantUserID || msg.ID <= 1 { + continue + } + ids = append(ids, msg.ID) + if len(ids) >= domain.MaxDeleteHistoryBatch { + break + } + } + deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + offset := 0 + if len(deleted) == domain.MaxDeleteHistoryBatch { + offset = 1 + } + return domain.DeleteChannelHistoryResult{ + Channel: channel, + Event: cloneChannelEvent(event), + DeletedIDs: append([]int(nil), deleted...), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Offset: offset, + }, nil +} + +func (s *ChannelStore) deleteChannelMessagesLocked(channel domain.Channel, member domain.ChannelMember, ids []int, actorUserID int64, date int) ([]int, domain.ChannelUpdateEvent, domain.Channel, error) { + if len(ids) == 0 { + return nil, domain.ChannelUpdateEvent{}, channel, nil + } + seen := make(map[int]struct{}, len(ids)) + deleted := make([]int, 0, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + idx, ok := s.findMessageIndexLocked(channel.ID, id) + if !ok || s.messages[channel.ID][idx].Deleted { + continue + } + msg := s.messages[channel.ID][idx] + if msg.SenderUserID != actorUserID && !canDeleteAnyChannelMessage(member) { + return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrChannelAdminRequired + } + if id <= 1 { + // id=1 建群服务消息是清空后会话仅剩的兜底 top message,所有 + // 删除入口统一静默跳过(官方客户端对它禁用删除)。 + continue + } + msg.Deleted = true + s.messages[channel.ID][idx] = msg + deleted = append(deleted, id) + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channel.ID, + UserID: actorUserID, + Date: date, + Type: domain.ChannelAdminLogDeleteMessage, + Message: ptrChannelMessage(msg), + Query: msg.Body, + }) + } + if len(deleted) == 0 { + return nil, domain.ChannelUpdateEvent{}, channel, nil + } + s.deleteChannelUnreadMentionsLocked(channel.ID, deleted) + pts := s.nextChannelPtsNLocked(channel.ID, len(deleted)) + channel.Pts = pts + channel.TopMessageID = s.topNonDeletedMessageIDLocked(channel.ID) + // 删除即从置顶集合移除(读路径过滤 NOT deleted),重算最新置顶缓存。 + channel.PinnedMessageID = s.latestPinnedMessageIDLocked(channel.ID) + s.channels[channel.ID] = channel + for userID, member := range s.members[channel.ID] { + if member.Status != domain.ChannelMemberActive { + continue + } + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + dialog := s.dialogForUserLocked(userID, channel) + s.dialogs[userID][channel.ID] = dialog + } + event := domain.ChannelUpdateEvent{ + ChannelID: channel.ID, + Type: domain.ChannelUpdateDeleteMessages, + Pts: pts, + PtsCount: len(deleted), + Date: date, + MessageIDs: append([]int(nil), deleted...), + SenderUserID: actorUserID, + } + s.events[channel.ID] = append(s.events[channel.ID], event) + return deleted, event, channel, nil +} + +func (s *ChannelStore) topNonDeletedMessageIDLocked(channelID int64) int { + for i := len(s.messages[channelID]) - 1; i >= 0; i-- { + if !s.messages[channelID][i].Deleted { + return s.messages[channelID][i].ID + } + } + return 0 +} + +func canDeleteAnyChannelMessage(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.DeleteMessages) +} diff --git a/internal/store/memory/channel_message_edit.go b/internal/store/memory/channel_message_edit.go new file mode 100644 index 00000000..17cb1810 --- /dev/null +++ b/internal/store/memory/channel_message_edit.go @@ -0,0 +1,396 @@ +package memory + +import ( + "context" + "sort" + "strings" + + "telesrv/internal/domain" +) + +func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { + // 空文本只在媒体替换(live location 续报/停止)时合法。 + if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelMessageResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + idx, ok := s.findMessageIndexLocked(req.ChannelID, req.ID) + if !ok || s.messages[req.ChannelID][idx].Deleted || s.messages[req.ChannelID][idx].Action != nil { + return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid + } + prevMsg := s.messages[req.ChannelID][idx] + msg := prevMsg + // WebPageResolve:频道链接预览就地替换(服务端内部,幂等守卫即授权)。只换 media、 + // 不碰 body/entities/edit_date,事件为 channel_web_page。 + if req.WebPageResolve { + if req.Media == nil || !domain.IsPendingWebPageMedia(msg.Media, req.ExpectedWebPageID) { + return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified + } + pts := s.nextChannelPtsLocked(req.ChannelID) + media := *req.Media + msg.Media = &media + msg.Pts = pts + s.messages[req.ChannelID][idx] = msg + channel.Pts = pts + s.channels[req.ChannelID] = channel + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateWebPage, + Pts: pts, + PtsCount: 1, + Date: msg.Date, + Message: cloneChannelMessage(msg), + SenderUserID: req.UserID, + } + s.events[req.ChannelID] = append(s.events[req.ChannelID], event) + return domain.EditChannelMessageResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil + } + // participant todo 协作须满足与正常发消息相同的权限,禁言成员/无发帖权订阅者 + // 不得借 OthersCanComplete/Append 绕过发言限制(与 postgres 对齐)。 + participantTodoEdit := isChannelTodoParticipantEdit(req, msg) && canSendChannelMessage(channel, member) + viaBotEditRequested := req.ViaBotEditBotID != 0 + viaBotEdit := viaBotEditRequested && msg.ViaBotID == req.ViaBotEditBotID + if viaBotEditRequested && !viaBotEdit { + return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired + } + if !viaBotEdit && msg.SenderUserID != req.UserID && !canEditChannelMessage(member) && !participantTodoEdit { + return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired + } + if req.Media == nil && !req.SetReplyMarkup && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { + return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified + } + pts := s.nextChannelPtsLocked(req.ChannelID) + msg.Body = req.Message + msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) + if req.Media != nil { + media := *req.Media + msg.Media = &media + } + if req.SetReplyMarkup { + msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup) + } + msg.EditDate = req.EditDate + msg.Pts = pts + s.messages[req.ChannelID][idx] = msg + channel.Pts = pts + s.channels[req.ChannelID] = channel + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateEditMessage, + Pts: pts, + PtsCount: 1, + Date: req.EditDate, + Message: cloneChannelMessage(msg), + SenderUserID: req.UserID, + } + s.events[req.ChannelID] = append(s.events[req.ChannelID], event) + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.EditDate, + Type: domain.ChannelAdminLogEditMessage, + PrevMessage: ptrChannelMessage(prevMsg), + NewMessage: ptrChannelMessage(msg), + Query: msg.Body, + }) + var serviceMsg domain.ChannelMessage + var serviceEvent domain.ChannelUpdateEvent + if req.TodoServiceAction != nil { + action := cloneChannelMessageAction(req.TodoServiceAction) + if action == nil { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + replyTo := channelTodoServiceReply(msg) + servicePts := s.nextChannelPtsLocked(req.ChannelID) + serviceMsg = domain.ChannelMessage{ + ChannelID: req.ChannelID, + ID: s.nextChannelMessageIDLocked(req.ChannelID), + SenderUserID: req.UserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, + Date: req.EditDate, + Post: channel.Broadcast, + Silent: msg.Silent, + NoForwards: msg.NoForwards || channel.NoForwards, + ReplyTo: replyTo, + Action: action, + Pts: servicePts, + } + serviceEvent = domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateNewMessage, + Pts: servicePts, + PtsCount: 1, + Date: req.EditDate, + Message: cloneChannelMessage(serviceMsg), + SenderUserID: req.UserID, + } + s.messages[req.ChannelID] = append(s.messages[req.ChannelID], serviceMsg) + s.events[req.ChannelID] = append(s.events[req.ChannelID], serviceEvent) + s.updateForumTopicTopMessageLocked(req.ChannelID, serviceMsg) + channel.TopMessageID = serviceMsg.ID + channel.Pts = servicePts + s.channels[req.ChannelID] = channel + for userID, member := range s.members[req.ChannelID] { + if member.Status == domain.ChannelMemberActive { + s.upsertChannelDialogLocked(userID, channel, serviceMsg, userID == req.UserID) + } + } + } + // 编辑对账 @ 集合:新增者补提及,被移除的实体提及删除;reply 隐式提及保留。 + // 仅在 body/entities 实际变化时执行:media-only 编辑(geolive/todo/poll)不带 + // MentionUserIDs,无条件对账会误删 caption 里仍未读的 @ 提及(与 postgres 对齐)。 + textChanged := prevMsg.Body != req.Message || !sameMessageEntities(prevMsg.Entities, req.Entities) + if textChanged && (!channel.Broadcast || channel.Megagroup) { + keep := make(map[int64]struct{}, len(req.MentionUserIDs)+1) + for _, id := range req.MentionUserIDs { + if id != 0 { + keep[id] = struct{}{} + } + } + if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 { + if target, ok := s.findMessageLocked(req.ChannelID, msg.ReplyTo.MessageID); ok && target.SenderUserID != 0 { + keep[target.SenderUserID] = struct{}{} + } + } + added := make([]int64, 0, len(req.MentionUserIDs)) + for _, id := range req.MentionUserIDs { + if id == 0 { + continue + } + if _, ok := s.mentions[id][req.ChannelID][msg.ID]; !ok { + added = append(added, id) + } + } + for userID, byChannel := range s.mentions { + if _, ok := byChannel[req.ChannelID][msg.ID]; !ok { + continue + } + if _, ok := keep[userID]; ok { + continue + } + delete(byChannel[req.ChannelID], msg.ID) + if dialogs := s.dialogs[userID]; dialogs != nil { + if dialog, ok := dialogs[req.ChannelID]; ok { + dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(userID, req.ChannelID, 0) + dialogs[req.ChannelID] = dialog + } + } + } + if len(added) > 0 { + s.addChannelUnreadMentionsLocked(req.ChannelID, msg, req.UserID, added) + for _, id := range added { + if dialogs := s.dialogs[id]; dialogs != nil { + if dialog, ok := dialogs[req.ChannelID]; ok { + dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(id, req.ChannelID, 0) + dialogs[req.ChannelID] = dialog + } + } + } + } + } + return domain.EditChannelMessageResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + ServiceMessage: cloneChannelMessage(serviceMsg), + ServiceEvent: cloneChannelEvent(serviceEvent), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func isChannelTodoParticipantEdit(req domain.EditChannelMessageRequest, msg domain.ChannelMessage) bool { + if !req.AllowTodoParticipantMutation || req.SetReplyMarkup || req.Media == nil || req.Media.Kind != domain.MessageMediaKindTodo || req.Media.Todo == nil { + return false + } + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindTodo || msg.Media.Todo == nil { + return false + } + if req.TodoServiceAction == nil { + return false + } + switch req.TodoServiceAction.Type { + case domain.ChannelActionTodoCompletions: + if !msg.Media.Todo.OthersCanComplete { + return false + } + case domain.ChannelActionTodoAppendTasks: + if !msg.Media.Todo.OthersCanAppend { + return false + } + default: + return false + } + return msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) +} + +func channelTodoServiceReply(msg domain.ChannelMessage) *domain.MessageReply { + reply := &domain.MessageReply{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID}, + MessageID: msg.ID, + } + if msg.ReplyTo != nil { + reply.TopMessageID = msg.ReplyTo.TopMessageID + reply.ForumTopic = msg.ReplyTo.ForumTopic + } + return reply +} + +func (s *ChannelStore) UpdatePinnedMessage(_ context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canPinChannelMessages(channel, member) { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired + } + msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) + if !ok || msg.Deleted { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid + } + // 多置顶模型:pin/unpin 只翻转目标消息自身的 pinned flag,不影响其它 + // 置顶;与官方一致不设数量上限。 + if msg.Pinned == req.Pinned { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified + } + for i := range s.messages[req.ChannelID] { + if s.messages[req.ChannelID][i].ID == req.MessageID { + s.messages[req.ChannelID][i].Pinned = req.Pinned + break + } + } + pts := s.nextChannelPtsLocked(req.ChannelID) + channel.PinnedMessageID = s.latestPinnedMessageIDLocked(req.ChannelID) + channel.Pts = pts + s.channels[req.ChannelID] = channel + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdatePinnedMessages, + Pts: pts, + PtsCount: 1, + Date: req.Date, + MessageIDs: []int{req.MessageID}, + SenderUserID: req.UserID, + Pinned: req.Pinned, + } + s.events[req.ChannelID] = append(s.events[req.ChannelID], event) + logMsg := msg + logMsg.Pinned = req.Pinned + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogUpdatePinned, + Message: ptrChannelMessage(logMsg), + Query: msg.Body, + }) + return domain.UpdateChannelPinnedMessageResult{ + Channel: channel, + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +// latestPinnedMessageIDLocked 返回最新置顶消息 id(channelFull.pinned_msg_id 缓存语义)。 +func (s *ChannelStore) latestPinnedMessageIDLocked(channelID int64) int { + latest := 0 + for _, m := range s.messages[channelID] { + if m.Pinned && !m.Deleted && m.ID > latest { + latest = m.ID + } + } + return latest +} + +// UnpinAllChannelMessages 清空全部置顶并以单条 channel 事件携带全部 id。 +func (s *ChannelStore) UnpinAllChannelMessages(_ context.Context, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canPinChannelMessages(channel, member) { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired + } + cleared := []int(nil) + for i := range s.messages[req.ChannelID] { + if s.messages[req.ChannelID][i].Pinned && !s.messages[req.ChannelID][i].Deleted { + s.messages[req.ChannelID][i].Pinned = false + cleared = append(cleared, s.messages[req.ChannelID][i].ID) + } + } + if len(cleared) == 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified + } + sort.Ints(cleared) + pts := s.nextChannelPtsLocked(req.ChannelID) + channel.PinnedMessageID = 0 + channel.Pts = pts + s.channels[req.ChannelID] = channel + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdatePinnedMessages, + Pts: pts, + PtsCount: 1, + Date: req.Date, + MessageIDs: cleared, + SenderUserID: req.UserID, + Pinned: false, + } + s.events[req.ChannelID] = append(s.events[req.ChannelID], event) + return domain.UpdateChannelPinnedMessageResult{ + Channel: channel, + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +// ClearDanglingPinnedMessage 把指向已删除消息的置顶值清零(unpinAll 自愈)。 +func (s *ChannelStore) ClearDanglingPinnedMessage(_ context.Context, channelID int64, messageID int) error { + if channelID == 0 || messageID <= 0 { + return domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, ok := s.channels[channelID] + if !ok { + return domain.ErrChannelInvalid + } + if channel.PinnedMessageID == messageID { + channel.PinnedMessageID = 0 + s.channels[channelID] = channel + } + return nil +} + +func canPinChannelMessages(channel domain.Channel, member domain.ChannelMember) bool { + if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) { + return true + } + return channel.Megagroup && !channel.DefaultBannedRights.PinMessages && !member.BannedRights.PinMessages +} + +func canEditChannelMessage(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) +} diff --git a/internal/store/memory/channel_message_helpers.go b/internal/store/memory/channel_message_helpers.go new file mode 100644 index 00000000..5ed0cbc1 --- /dev/null +++ b/internal/store/memory/channel_message_helpers.go @@ -0,0 +1,78 @@ +package memory + +import ( + "telesrv/internal/domain" +) + +func (s *ChannelStore) eventForMessageLocked(channelID int64, id int) domain.ChannelUpdateEvent { + for _, event := range s.events[channelID] { + if event.Message.ID == id { + return cloneChannelEvent(event) + } + } + return domain.ChannelUpdateEvent{} +} + +func sameMessageEntities(a, b []domain.MessageEntity) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage { + in.Entities = append([]domain.MessageEntity(nil), in.Entities...) + in.ReplyTo = cloneMessageReply(in.ReplyTo) + in.Forward = cloneMessageForward(in.Forward) + in.ReplyMarkup = cloneReplyMarkup(in.ReplyMarkup) + in.Discussion = cloneChannelDiscussionRef(in.Discussion) + in.Replies = cloneChannelMessageReplies(in.Replies) + in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions) + if in.SendAs != nil { + p := *in.SendAs + in.SendAs = &p + } + if in.Action != nil { + in.Action = cloneChannelMessageAction(in.Action) + } + return in +} + +func cloneChannelMessageAction(in *domain.ChannelMessageAction) *domain.ChannelMessageAction { + if in == nil { + return nil + } + out := *in + out.UserIDs = append([]int64(nil), in.UserIDs...) + out.Completed = append([]int(nil), in.Completed...) + out.Incompleted = append([]int(nil), in.Incompleted...) + out.TodoItems = append([]domain.MessageTodoItem(nil), in.TodoItems...) + if in.Closed != nil { + v := *in.Closed + out.Closed = &v + } + if in.Hidden != nil { + v := *in.Hidden + out.Hidden = &v + } + if in.StarGift != nil { + g := *in.StarGift + if in.StarGift.Sticker != nil { + sticker := *in.StarGift.Sticker + g.Sticker = &sticker + } + out.StarGift = &g + } + out.Wallpaper = domain.CloneWallpaperPtr(in.Wallpaper) + return &out +} + +func ptrChannelMessage(in domain.ChannelMessage) *domain.ChannelMessage { + out := cloneChannelMessage(in) + return &out +} diff --git a/internal/store/memory/channel_message_history.go b/internal/store/memory/channel_message_history.go new file mode 100644 index 00000000..d386281e --- /dev/null +++ b/internal/store/memory/channel_message_history.go @@ -0,0 +1,561 @@ +package memory + +import ( + "context" + "sort" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) ListChannelHistory(_ context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, _, err := s.channelForViewerLocked(viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + items := append([]domain.ChannelMessage(nil), s.messages[filter.ChannelID]...) + sort.Slice(items, func(i, j int) bool { return items[i].ID > items[j].ID }) + // 静态过滤(不含 offset 锚点的方向条件),结果保持 id 降序。 + query := strings.ToLower(strings.TrimSpace(filter.Query)) + matched := make([]domain.ChannelMessage, 0, len(items)) + for _, msg := range items { + if msg.Deleted { + continue + } + if channel.Monoforum && msg.SavedPeer.ID != 0 { + continue + } + if msg.ID <= member.AvailableMinID { + continue + } + if filter.PinnedOnly && !msg.Pinned { + continue + } + if filter.MusicOnly && !msg.Media.IsMusic() { + continue + } + if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) { + continue + } + if filter.SenderUserID != 0 && msg.SenderUserID != filter.SenderUserID { + continue + } + if filter.MinDate > 0 && msg.Date <= filter.MinDate { + continue + } + if filter.MaxDate > 0 && msg.Date >= filter.MaxDate { + continue + } + if filter.MaxID > 0 && msg.ID > filter.MaxID { + continue + } + if filter.MinID > 0 && msg.ID <= filter.MinID { + continue + } + matched = append(matched, msg) + } + // add_offset 决定加载方向(对齐 postgres ListChannelHistory): + // >= 0 backward:锚点更旧方向(不含锚点),先跳过 add_offset 条 + // < 0 且 +limit>0 around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条 + // 否则 forward:仅锚点更新方向 + newerThanAnchor := func(msg domain.ChannelMessage) bool { + if filter.OffsetDate > 0 { + return msg.Date >= filter.OffsetDate + } + if filter.OffsetID > 0 { + return msg.ID > filter.OffsetID + } + return false + } + olderThanAnchor := func(msg domain.ChannelMessage, includeAnchor bool) bool { + if filter.OffsetDate > 0 { + return msg.Date < filter.OffsetDate + } + if filter.OffsetID > 0 { + if includeAnchor { + return msg.ID <= filter.OffsetID + } + return msg.ID < filter.OffsetID + } + return true + } + takeNewer := func(limit int) []domain.ChannelMessage { + if limit <= 0 { + return nil + } + // 升序收集锚点更新方向最近的 limit 条,再反转回降序。 + asc := make([]domain.ChannelMessage, 0, limit) + for i := len(matched) - 1; i >= 0; i-- { + if !newerThanAnchor(matched[i]) { + continue + } + asc = append(asc, matched[i]) + if len(asc) == limit { + break + } + } + out := make([]domain.ChannelMessage, 0, len(asc)) + for i := len(asc) - 1; i >= 0; i-- { + out = append(out, asc[i]) + } + return out + } + takeOlder := func(skip, limit int, includeAnchor bool) ([]domain.ChannelMessage, bool) { + if limit <= 0 { + return nil, false + } + out := make([]domain.ChannelMessage, 0, limit) + skipped := 0 + for _, msg := range matched { + if !olderThanAnchor(msg, includeAnchor) { + continue + } + if skipped < skip { + skipped++ + continue + } + if len(out) == limit { + return out, true + } + out = append(out, msg) + } + return out, false + } + addOffset := filter.AddOffset + var page []domain.ChannelMessage + hasMoreOlder := false + switch { + case addOffset < 0 && addOffset+limit > 0: + fwdLimit := -addOffset + if fwdLimit > limit { + fwdLimit = limit + } + bwdLimit := limit + addOffset + page = takeNewer(fwdLimit) + older, more := takeOlder(0, bwdLimit, true) + page = append(page, older...) + hasMoreOlder = more + case addOffset < 0: + page = takeNewer(limit) + default: + page, hasMoreOlder = takeOlder(addOffset, limit, false) + } + out := make([]domain.ChannelMessage, 0, len(page)) + for _, msg := range page { + out = append(out, cloneChannelMessage(msg)) + } + count := len(out) + if hasMoreOlder { + count = len(out) + 1 + } + s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) + s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) + extraChannels := []domain.Channel(nil) + if channel.Monoforum && channel.LinkedMonoforumID != 0 { + if parent, ok := s.channels[channel.LinkedMonoforumID]; ok && !parent.Deleted { + extraChannels = append(extraChannels, cloneChannel(parent)) + } + } + return domain.ChannelHistory{ + Channel: channel, + Self: member, + Channels: extraChannels, + Messages: out, + Count: count, + }, nil +} + +func (s *ChannelStore) SearchJoinedMessages(_ context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) { + query := strings.ToLower(strings.TrimSpace(req.Query)) + if viewerUserID == 0 || (query == "" && !req.MusicOnly) { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxChannelGlobalSearchLimit { + req.Limit = domain.MaxChannelGlobalSearchLimit + } + type hit struct { + channel domain.Channel + message domain.ChannelMessage + } + s.mu.RLock() + defer s.mu.RUnlock() + hits := make([]hit, 0, req.Limit+1) + for channelID, channel := range s.channels { + if channel.Deleted { + continue + } + if req.BroadcastsOnly && (!channel.Broadcast || channel.Megagroup) { + continue + } + if req.GroupsOnly && !channel.Megagroup { + continue + } + member, ok := s.members[channelID][viewerUserID] + if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { + continue + } + if req.HasFolderID { + dialog, ok := s.dialogs[viewerUserID][channelID] + if !ok || dialog.FolderID != req.FolderID { + continue + } + } + for _, msg := range s.messages[channelID] { + if msg.Deleted { + continue + } + if query == "" && !req.MusicOnly || query != "" && strings.TrimSpace(msg.Body) == "" { + continue + } + if member.AvailableMinID > 0 && msg.ID <= member.AvailableMinID { + continue + } + if req.MinDate > 0 && msg.Date <= req.MinDate { + continue + } + if req.MaxDate > 0 && msg.Date >= req.MaxDate { + continue + } + if !channelGlobalSearchAfterCursor(msg, req) { + continue + } + if req.MusicOnly && !msg.Media.IsMusic() { + continue + } + if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) { + continue + } + hits = append(hits, hit{channel: channel, message: cloneChannelMessage(msg)}) + } + } + sort.Slice(hits, func(i, j int) bool { + a, b := hits[i].message, hits[j].message + if a.Date != b.Date { + return a.Date > b.Date + } + if a.ChannelID != b.ChannelID { + return a.ChannelID > b.ChannelID + } + return a.ID > b.ID + }) + out := domain.ChannelHistory{Count: len(hits)} + if out.Count > req.Limit { + out.Count = req.Limit + 1 + hits = hits[:req.Limit] + } + channelSeen := make(map[int64]struct{}, len(hits)) + for _, h := range hits { + out.Messages = append(out.Messages, h.message) + if _, ok := channelSeen[h.channel.ID]; ok { + continue + } + channelSeen[h.channel.ID] = struct{}{} + out.Channels = append(out.Channels, h.channel) + } + s.populateChannelMessagesReactionsLocked(viewerUserID, out.Channels, out.Messages) + return out, nil +} + +func (s *ChannelStore) GetChannelMessages(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) { + s.mu.RLock() + defer s.mu.RUnlock() + // viewer 口径:公开频道非成员可预览读取(与 ListChannelHistory 一致 + 与 PG 实现对齐), + // 否则查看他人公开「个人频道」时 channels.getMessages 被拒、资料页整块不显示。 + channel, member, _, err := s.channelForViewerLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelHistory{}, err + } + if len(ids) == 0 { + return domain.ChannelHistory{Channel: channel, Self: member}, nil + } + if len(ids) > domain.MaxGetMessageIDs { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + wanted := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + messages := make([]domain.ChannelMessage, 0, len(wanted)) + for _, msg := range s.messages[channelID] { + if _, ok := wanted[msg.ID]; !ok { + continue + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + messages = append(messages, cloneChannelMessage(msg)) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) + s.populateChannelMessageRepliesLocked(viewerUserID, channelID, messages) + s.populateChannelMessageReactionsLocked(viewerUserID, channel, messages) + return domain.ChannelHistory{Channel: channel, Self: member, Messages: messages, Count: len(messages)}, nil +} + +func (s *ChannelStore) ListStoryMessageForwards(_ context.Context, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) { + if req.ViewerUserID == 0 || req.Owner.ID == 0 || req.StoryID <= 0 || req.StoryID > domain.MaxStoryID { + return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid + } + if req.Owner.Type != domain.PeerTypeUser && req.Owner.Type != domain.PeerTypeChannel { + return domain.StoryMessageForwardList{}, domain.ErrStoryPeerInvalid + } + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return domain.StoryMessageForwardList{}, err + } + limit := clampStoryInteractionLimit(req.Limit) + cursor := parseStoryInteractionCursor(req.Offset) + s.mu.RLock() + views := make([]domain.StoryView, 0) + for channelID, channel := range s.channels { + if channel.Deleted || strings.TrimSpace(channel.Username) == "" || (!channel.Broadcast && !channel.Megagroup) { + continue + } + for _, msg := range s.messages[channelID] { + if !messageSharesStory(msg, req.Owner, req.StoryID) { + continue + } + views = append(views, domain.StoryView{ + Owner: req.Owner, + StoryID: req.StoryID, + Date: msg.Date, + PublicForward: &domain.StoryPublicForward{ + Message: cloneChannelMessage(msg), + }, + }) + } + } + s.mu.RUnlock() + sortStoryViewsForList(views, req.ReactionsFirst, req.ForwardsFirst) + page, nextOffset := pageStoryViews(views, limit, cursor, req.ReactionsFirst, req.ForwardsFirst) + return domain.StoryMessageForwardList{Count: len(views), Forwards: page, NextOffset: nextOffset}, nil +} + +func messageSharesStory(msg domain.ChannelMessage, owner domain.Peer, storyID int) bool { + return !msg.Deleted && + msg.Media != nil && + msg.Media.Kind == domain.MessageMediaKindStory && + msg.Media.Story != nil && + msg.Media.Story.Peer == owner && + msg.Media.Story.ID == storyID +} + +func (s *ChannelStore) GetChannelMessageForInlineBot(_ context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) { + if botID == 0 || channelID == 0 || id <= 0 || id > domain.MaxMessageBoxID { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + msg, ok := s.findMessageLocked(channelID, id) + if !ok || msg.Deleted || msg.Action != nil || msg.ViaBotID != botID { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + return channel, cloneChannelMessage(msg), true, nil +} + +func (s *ChannelStore) GetDiscussionMessage(_ context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) { + s.mu.RLock() + defer s.mu.RUnlock() + source, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelDiscussionMessage{}, err + } + msg, ok := s.findMessageLocked(channelID, msgID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid + } + result := domain.ChannelDiscussionMessage{PostChannel: source, DiscussionChannel: source, Channels: []domain.Channel{source}} + targetChannel := source + targetMsg := msg + targetMember := member + if source.Broadcast { + if msg.Discussion == nil || msg.Discussion.ChannelID == 0 || msg.Discussion.MessageID == 0 { + return result, nil + } + linked, ok := s.channels[msg.Discussion.ChannelID] + if !ok || linked.Deleted { + return result, nil + } + linkedMsg, ok := s.findMessageLocked(linked.ID, msg.Discussion.MessageID) + if !ok || linkedMsg.Deleted { + return result, nil + } + targetChannel = linked + targetMsg = linkedMsg + if linkedMember, ok := s.members[linked.ID][viewerUserID]; ok { + targetMember = linkedMember + } else { + targetMember = domain.ChannelMember{} + } + result.DiscussionChannel = linked + result.Channels = []domain.Channel{source, linked} + } + items := []domain.ChannelMessage{cloneChannelMessage(targetMsg)} + s.populateChannelMessageRepliesLocked(viewerUserID, targetChannel.ID, items) + s.populateChannelMessageReactionsLocked(viewerUserID, targetChannel, items) + if stats := s.channelMessageRepliesLocked(viewerUserID, targetChannel.ID, targetMsg); stats != nil { + result.MaxID = stats.MaxID + } + result.ReadInboxMaxID = targetMember.ReadInboxMaxID + result.ReadOutboxMaxID = targetMember.ReadOutboxMaxID + result.UnreadCount = s.channelThreadUnreadCountLocked(viewerUserID, targetChannel.ID, targetMsg.ID, targetMember.ReadInboxMaxID) + result.Messages = items + return result, nil +} + +func (s *ChannelStore) findMessageLocked(channelID int64, id int) (domain.ChannelMessage, bool) { + for _, msg := range s.messages[channelID] { + if msg.ID == id { + return msg, true + } + } + return domain.ChannelMessage{}, false +} + +func (s *ChannelStore) findMessageIndexLocked(channelID int64, id int) (int, bool) { + for i, msg := range s.messages[channelID] { + if msg.ID == id { + return i, true + } + } + return 0, false +} + +func pageChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int) []domain.ChannelMessage { + if limit <= 0 || len(base) == 0 { + return nil + } + switch messageHistoryLoadType(filter.AddOffset, limit) { + case messageHistoryLoadForward: + return forwardChannelMessageHistory(base, filter, limit) + case messageHistoryLoadAround: + forwardLimit := -filter.AddOffset + if forwardLimit > limit { + forwardLimit = limit + } + backwardLimit := limit + filter.AddOffset + if backwardLimit < 0 { + backwardLimit = 0 + } + page := make([]domain.ChannelMessage, 0, limit) + page = append(page, forwardChannelMessageHistory(base, filter, forwardLimit)...) + page = append(page, backwardChannelMessageHistory(base, filter, backwardLimit, true)...) + sort.SliceStable(page, func(i, j int) bool { return channelMessageLess(page[i], page[j]) }) + return page + default: + start := filter.AddOffset + if start < 0 { + start = 0 + } + candidates := backwardChannelMessageHistory(base, filter, limit+start, false) + if start >= len(candidates) { + return nil + } + return candidates[start:] + } +} + +func backwardChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int, includeOffset bool) []domain.ChannelMessage { + if limit <= 0 { + return nil + } + out := make([]domain.ChannelMessage, 0, limit) + for _, msg := range base { + if !channelMessageBeforeHistoryOffset(msg, filter, includeOffset) { + continue + } + out = append(out, msg) + if len(out) == limit { + break + } + } + return out +} + +func forwardChannelMessageHistory(base []domain.ChannelMessage, filter domain.ChannelRepliesFilter, limit int) []domain.ChannelMessage { + if limit <= 0 { + return nil + } + out := make([]domain.ChannelMessage, 0, limit) + for i := len(base) - 1; i >= 0; i-- { + msg := base[i] + if !channelMessageAfterHistoryOffset(msg, filter) { + continue + } + out = append(out, msg) + if len(out) == limit { + break + } + } + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out +} + +func channelMessageBeforeHistoryOffset(msg domain.ChannelMessage, filter domain.ChannelRepliesFilter, includeOffset bool) bool { + if filter.OffsetDate > 0 { + if includeOffset { + return msg.Date <= filter.OffsetDate + } + return msg.Date < filter.OffsetDate + } + if filter.OffsetID <= 0 { + return true + } + if includeOffset { + return msg.ID <= filter.OffsetID + } + return msg.ID < filter.OffsetID +} + +func channelMessageAfterHistoryOffset(msg domain.ChannelMessage, filter domain.ChannelRepliesFilter) bool { + if filter.OffsetDate > 0 { + return msg.Date >= filter.OffsetDate + } + if filter.OffsetID <= 0 { + return false + } + return msg.ID > filter.OffsetID +} + +func channelMessageLess(a, b domain.ChannelMessage) bool { + if a.Date != b.Date { + return a.Date > b.Date + } + return a.ID > b.ID +} + +func (s *ChannelStore) visibleTopMessageIDLocked(userID int64, channel domain.Channel) int { + return s.visibleTopMessageIDForMemberLocked(channel, s.members[channel.ID][userID]) +} + +func (s *ChannelStore) visibleTopMessageIDForMemberLocked(channel domain.Channel, member domain.ChannelMember) int { + for i := len(s.messages[channel.ID]) - 1; i >= 0; i-- { + msg := s.messages[channel.ID][i] + if !msg.Deleted && msg.ID > member.AvailableMinID { + return msg.ID + } + } + return 0 +} + +func (s *ChannelStore) topicHasVisibleMessagesLocked(channelID int64, topicID int) bool { + for _, msg := range s.messages[channelID] { + if msg.Deleted { + continue + } + if msg.ID == topicID || (msg.ReplyTo != nil && msg.ReplyTo.TopMessageID == topicID) { + return true + } + } + return false +} diff --git a/internal/store/memory/channel_message_send.go b/internal/store/memory/channel_message_send.go new file mode 100644 index 00000000..d12343f2 --- /dev/null +++ b/internal/store/memory/channel_message_send.go @@ -0,0 +1,309 @@ +package memory + +import ( + "context" + "strings" + "telesrv/internal/domain" + "time" +) + +func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + fromBoostsApplied := 0 + if channel.Megagroup { + fromBoostsApplied = s.selfBoostsAppliedLocked(req.UserID, req.ChannelID, req.Date) + } + if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) { + return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden + } + if req.RandomID != 0 { + if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok { + msg, ok := s.findMessageLocked(req.ChannelID, id) + if ok { + event := s.eventForMessageLocked(req.ChannelID, id) + if event.Message.ID != 0 { + msg = event.Message + } + return domain.SendChannelMessageResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + Event: event, + Duplicate: true, + }, nil + } + } + } + if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 { + return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait) + } + replyTo, err := s.resolveChannelReplyLocked(req, member, channel) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + var sendAs *domain.Peer + if req.SendAs != nil { + p := *req.SendAs + sendAs = &p + } + pts := s.nextChannelPtsLocked(req.ChannelID) + msgID := s.nextChannelMessageIDLocked(req.ChannelID) + skipDelivery := channelDeliverySkipSet(req.SkipDeliveryUserIDs) + var discussion *domain.SendChannelDiscussionResult + var discussionRef *domain.ChannelDiscussionRef + if channel.Broadcast && channel.LinkedChatID != 0 { + if linked, ok := s.channels[channel.LinkedChatID]; ok && !linked.Deleted && linked.Megagroup { + discussionPts := s.nextChannelPtsLocked(linked.ID) + discussionMsgID := s.nextChannelMessageIDLocked(linked.ID) + discussionRef = &domain.ChannelDiscussionRef{ChannelID: linked.ID, MessageID: discussionMsgID} + discussionMsg := domain.ChannelMessage{ + ChannelID: linked.ID, + ID: discussionMsgID, + SenderUserID: req.UserID, + From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, + Date: req.Date, + Silent: req.Silent, + NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, + ViaBotID: req.ViaBotID, + GroupedID: req.GroupedID, + ReplyMarkup: cloneReplyMarkup(req.ReplyMarkup), + Pts: discussionPts, + } + discussionEvent := domain.ChannelUpdateEvent{ + ChannelID: linked.ID, + Type: domain.ChannelUpdateNewMessage, + Pts: discussionPts, + PtsCount: 1, + Date: req.Date, + Message: cloneChannelMessage(discussionMsg), + } + s.messages[linked.ID] = append(s.messages[linked.ID], discussionMsg) + s.events[linked.ID] = append(s.events[linked.ID], discussionEvent) + linked.TopMessageID = discussionMsgID + linked.Pts = discussionPts + s.channels[linked.ID] = linked + s.addChannelUnreadMentionsLocked(linked.ID, discussionMsg, req.UserID, req.MentionUserIDs) + for userID, member := range s.members[linked.ID] { + if member.Status == domain.ChannelMemberActive { + s.upsertChannelDialogLocked(userID, linked, discussionMsg, false) + } + } + discussion = &domain.SendChannelDiscussionResult{ + Channel: cloneChannel(linked), + Message: cloneChannelMessage(discussionMsg), + Event: cloneChannelEvent(discussionEvent), + Recipients: s.activeMemberIDsLocked(linked.ID, 0, 0), + MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), + } + } + } + msg := domain.ChannelMessage{ + ChannelID: req.ChannelID, + ID: msgID, + RandomID: req.RandomID, + SenderUserID: req.UserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, + Date: req.Date, + Post: channel.Broadcast, + PostAuthor: memoryChannelPostAuthor(channel, req.PostAuthor), + Silent: req.Silent, + NoForwards: req.NoForwards || channel.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ReplyTo: replyTo, + Forward: cloneMessageForward(req.Forward), + ViaBotID: req.ViaBotID, + GroupedID: req.GroupedID, + ReplyMarkup: cloneReplyMarkup(req.ReplyMarkup), + SendAs: sendAs, + Discussion: discussionRef, + Action: cloneChannelMessageAction(req.Action), + FromBoostsApplied: fromBoostsApplied, + Pts: pts, + } + msg.Replies = s.channelMessageRepliesLocked(req.UserID, req.ChannelID, msg) + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: req.Date, + Message: cloneChannelMessage(msg), + SenderUserID: req.UserID, + } + s.messages[req.ChannelID] = append(s.messages[req.ChannelID], msg) + s.events[req.ChannelID] = append(s.events[req.ChannelID], event) + if !channel.Broadcast || channel.Megagroup { + mentionTargets := req.MentionUserIDs + if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 { + if target, ok := s.findMessageLocked(req.ChannelID, msg.ReplyTo.MessageID); ok && + target.SenderUserID != 0 && target.SenderUserID != req.UserID { + mentionTargets = append(append([]int64(nil), mentionTargets...), target.SenderUserID) + } + } + s.addChannelUnreadMentionsLocked(req.ChannelID, msg, req.UserID, mentionTargets) + } + s.updateForumTopicTopMessageLocked(req.ChannelID, msg) + if channel.Broadcast { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogSendMessage, + Message: ptrChannelMessage(msg), + Query: msg.Body, + }) + } + if req.RandomID != 0 { + s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}] = msg.ID + } + channel.TopMessageID = msg.ID + channel.Pts = pts + s.channels[req.ChannelID] = channel + member.SlowmodeLastSendDate = req.Date + s.members[req.ChannelID][req.UserID] = member + for userID, member := range s.members[req.ChannelID] { + if member.Status == domain.ChannelMemberActive { + if _, skip := skipDelivery[userID]; skip && userID != req.UserID { + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, msg.ID) + member.UnreadMark = false + s.members[req.ChannelID][userID] = member + continue + } + s.upsertChannelDialogLocked(userID, channel, msg, userID == req.UserID) + } + } + recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) + recipients = filterSkippedChannelRecipients(recipients, skipDelivery) + return domain.SendChannelMessageResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: recipients, + Discussion: discussion, + MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), + SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...), + }, nil +} + +func channelDeliverySkipSet(ids []int64) map[int64]struct{} { + if len(ids) == 0 { + return nil + } + out := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id != 0 { + out[id] = struct{}{} + } + } + return out +} + +func filterSkippedChannelRecipients(recipients []int64, skip map[int64]struct{}) []int64 { + if len(recipients) == 0 || len(skip) == 0 { + return recipients + } + out := recipients[:0] + for _, id := range recipients { + if _, hidden := skip[id]; hidden { + continue + } + out = append(out, id) + } + return out +} + +func (s *ChannelStore) nextChannelMessageIDLocked(channelID int64) int { + s.msgSeq[channelID]++ + return s.msgSeq[channelID] +} + +func (s *ChannelStore) appendChannelServiceMessageLocked(channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent) { + channel := s.channels[channelID] + msgID := s.nextChannelMessageIDLocked(channelID) + action = channelServiceActionForMessage(channelID, msgID, action) + pts := s.nextChannelPtsLocked(channelID) + msg := domain.ChannelMessage{ + ChannelID: channelID, + ID: msgID, + SenderUserID: senderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + Date: date, + Post: channel.Broadcast, + Action: &action, + Pts: pts, + } + event := domain.ChannelUpdateEvent{ + ChannelID: channelID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: date, + Message: cloneChannelMessage(msg), + SenderUserID: senderUserID, + UserIDs: append([]int64(nil), action.UserIDs...), + } + s.messages[channelID] = append(s.messages[channelID], msg) + s.events[channelID] = append(s.events[channelID], event) + return msg, event +} + +func channelServiceActionForMessage(channelID int64, msgID int, action domain.ChannelMessageAction) domain.ChannelMessageAction { + if action.Type == domain.ChannelActionStarGift && action.StarGift != nil { + g := *action.StarGift + if g.PeerChannelID == 0 { + g.PeerChannelID = channelID + } + if g.SavedID == 0 { + g.SavedID = int64(msgID) + } + action.StarGift = &g + } + return action +} + +func canSendChannelMessage(channel domain.Channel, member domain.ChannelMember) bool { + return canSendChannelMessageWithBoost(channel, member, 0) +} + +func canSendChannelMessageWithBoost(channel domain.Channel, member domain.ChannelMember, selfBoostsApplied int) bool { + if channel.Broadcast { + return canPostToBroadcast(member) + } + if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + return true + } + if member.BannedRights.SendMessages { + return false + } + if !channel.DefaultBannedRights.SendMessages { + return true + } + return channel.BoostsUnrestrict > 0 && selfBoostsApplied >= channel.BoostsUnrestrict +} + +// memoryChannelPostAuthor 仅在 signatures 开启的 broadcast post 上保留签名。 +func memoryChannelPostAuthor(channel domain.Channel, author string) string { + if !channel.Broadcast || !channel.Signatures { + return "" + } + return author +} diff --git a/internal/store/memory/channel_message_settings.go b/internal/store/memory/channel_message_settings.go new file mode 100644 index 00000000..c9be14fb --- /dev/null +++ b/internal/store/memory/channel_message_settings.go @@ -0,0 +1,125 @@ +package memory + +import ( + "context" + "telesrv/internal/domain" + "time" +) + +func (s *ChannelStore) SetPreHistoryHidden(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.PreHistoryHidden + channel.PreHistoryHidden = enabled + s.channels[channelID] = channel + if prev != enabled { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogTogglePreHistoryHidden, + PrevBool: prev, + NewBool: enabled, + }) + } + return channel, nil +} + +func (s *ChannelStore) SetPaidMessagesPrice(_ context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.ChannelPaidMessagesPriceResult, error) { + if userID == 0 || channelID == 0 || stars < 0 { + return domain.ChannelPaidMessagesPriceResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.ChannelPaidMessagesPriceResult{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.ChannelPaidMessagesPriceResult{}, domain.ErrChannelAdminRequired + } + out := domain.ChannelPaidMessagesPriceResult{} + prevAllowed := channel.BroadcastMessagesAllowed + prevStars := channel.SendPaidMessagesStars + prevLinked := channel.LinkedMonoforumID + channel.SendPaidMessagesStars = stars + channel.BroadcastMessagesAllowed = channel.Broadcast && broadcastMessagesAllowed + changed := channel.Broadcast && (prevAllowed != channel.BroadcastMessagesAllowed || prevStars != stars || (channel.BroadcastMessagesAllowed && prevLinked == 0)) + // 开启频道私信时关联 monoforum 虚拟频道(与 postgres 行为一致);复用既有则只同步价格。 + if channel.BroadcastMessagesAllowed { + if channel.LinkedMonoforumID == 0 { + monoID := s.nextChannelIDLocked() + s.channels[monoID] = domain.Channel{ + ID: monoID, + AccessHash: s.nextAccessHashLocked(), + CreatorUserID: channel.CreatorUserID, + Title: channel.Title, + // Keep monoforum as a megagroup-like saved sublist container. TDesktop hangs when + // a monoforum peer is projected as both broadcast and megagroup. + Megagroup: true, + Monoforum: true, + LinkedMonoforumID: channelID, + SendPaidMessagesStars: stars, + Date: int(time.Now().Unix()), + } + channel.LinkedMonoforumID = monoID + } else if mono, ok := s.channels[channel.LinkedMonoforumID]; ok { + // 复用既有 monoforum 只同步价格。mono 恒以 megagroup 形态创建、无路径会翻成 broadcast/forum, + // 故无需再规范化 kind(那是对从未发布的 broadcast+monoforum 实验形态的历史数据修复死代码)。 + // 与 postgres 保持一致。 + mono.SendPaidMessagesStars = stars + s.channels[channel.LinkedMonoforumID] = mono + } + // monoforum 首条(也是唯一)服务消息=创建消息(messageActionChannelCreate),客户端对 + // monoforum 渲染为 "Direct messages were enabled in this channel."。开关/价格变更的 + // paid_messages_price 只进母广播频道(渲染 "Channel enabled/disabled Direct Messages"), + // **绝不进 mono**——mono 是 megagroup(非 broadcast),同一 action 会被渲染成"消息免费/ + // 设为 N 星"的错误文案;关闭只靠 monoforumDisabled 状态显示停用页脚。与 postgres 一致。 + if existing := s.channels[channel.LinkedMonoforumID]; existing.ID != 0 && existing.TopMessageID == 0 { + createMsg, createEvent := s.appendChannelServiceMessageLocked(channel.LinkedMonoforumID, userID, int(time.Now().Unix()), domain.ChannelMessageAction{Type: domain.ChannelActionCreate, Title: existing.Title}) + mono := s.channels[channel.LinkedMonoforumID] + mono.TopMessageID = createMsg.ID + mono.Pts = createEvent.Pts + s.channels[channel.LinkedMonoforumID] = mono + out.ServiceMessages = append(out.ServiceMessages, domain.SendChannelMessageResult{Channel: cloneChannel(mono), Message: cloneChannelMessage(createMsg), Event: cloneChannelEvent(createEvent)}) + } + } + // monoforum 镜像母频道 DM 启用状态到自己的 BroadcastMessagesAllowed(与 postgres 一致):投影时据此 + // 决定是否下发 mono 的 linked_monoforum_id,关闭时双方都隐藏 link → 打开 monoforum 重拉不会清掉 + // MonoforumDisabled,停用页脚保持。内部关联行仍保留以便重新开启复用。 + if channel.LinkedMonoforumID != 0 { + if mono, ok := s.channels[channel.LinkedMonoforumID]; ok { + mono.BroadcastMessagesAllowed = channel.BroadcastMessagesAllowed + s.channels[channel.LinkedMonoforumID] = mono + } + } + if changed && channel.LinkedMonoforumID != 0 { + action := domain.ChannelMessageAction{ + Type: domain.ChannelActionPaidMessagesPrice, + BroadcastMessagesAllowed: channel.BroadcastMessagesAllowed, + Stars: stars, + } + parentMsg, parentEvent := s.appendChannelServiceMessageLocked(channelID, userID, int(time.Now().Unix()), action) + channel.TopMessageID = parentMsg.ID + channel.Pts = parentEvent.Pts + out.ServiceMessages = append(out.ServiceMessages, domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(parentMsg), Event: cloneChannelEvent(parentEvent)}) + } + if len(out.ServiceMessages) > 0 { + out.ServiceMessage = &out.ServiceMessages[0] + } + s.channels[channelID] = channel + out.Channel = cloneChannel(channel) + return out, nil +} diff --git a/internal/store/memory/channel_message_views.go b/internal/store/memory/channel_message_views.go new file mode 100644 index 00000000..3fbaf15d --- /dev/null +++ b/internal/store/memory/channel_message_views.go @@ -0,0 +1,84 @@ +package memory + +import ( + "context" + "telesrv/internal/domain" +) + +func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) == 0 { + return domain.ChannelMessageViewsResult{Views: map[int]int{}, Replies: map[int]*domain.ChannelMessageReplies{}}, nil + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageViewsResult{}, err + } + wanted := make(map[int]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelMessageViewsResult{}, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + visible := make(map[int]struct{}, len(wanted)) + for _, msg := range s.messages[req.ChannelID] { + if _, ok := wanted[msg.ID]; !ok { + continue + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + visible[msg.ID] = struct{}{} + } + if s.msgViews[req.ChannelID] == nil { + s.msgViews[req.ChannelID] = make(map[int]int) + } + if s.msgViewers[req.ChannelID] == nil { + s.msgViewers[req.ChannelID] = make(map[int]map[int64]struct{}) + } + for id := range visible { + if req.Increment { + if s.msgViewers[req.ChannelID][id] == nil { + s.msgViewers[req.ChannelID][id] = make(map[int64]struct{}) + } + if _, seen := s.msgViewers[req.ChannelID][id][req.UserID]; !seen { + s.msgViewers[req.ChannelID][id][req.UserID] = struct{}{} + s.msgViews[req.ChannelID][id]++ + } + } + } + out := make(map[int]int, len(visible)) + replies := make(map[int]*domain.ChannelMessageReplies, len(visible)) + peerSeen := make(map[domain.Peer]struct{}, len(visible)) + peers := make([]domain.Peer, 0, len(visible)) + for id := range visible { + out[id] = s.msgViews[req.ChannelID][id] + } + for _, msg := range s.messages[req.ChannelID] { + if _, ok := visible[msg.ID]; !ok { + continue + } + peer := msg.From + if peer.ID == 0 && msg.SenderUserID != 0 { + peer = domain.Peer{Type: domain.PeerTypeUser, ID: msg.SenderUserID} + } + if peer.ID != 0 { + if _, ok := peerSeen[peer]; !ok { + peerSeen[peer] = struct{}{} + peers = append(peers, peer) + } + } + if reply := s.channelMessageRepliesLocked(req.UserID, req.ChannelID, msg); reply != nil { + replies[msg.ID] = reply + } + } + return domain.ChannelMessageViewsResult{Channel: channel, Views: out, Replies: replies, Peers: peers}, nil +} diff --git a/internal/store/memory/channel_monoforum.go b/internal/store/memory/channel_monoforum.go new file mode 100644 index 00000000..70010d26 --- /dev/null +++ b/internal/store/memory/channel_monoforum.go @@ -0,0 +1,179 @@ +package memory + +import ( + "context" + "sort" + "strings" + "time" + + "telesrv/internal/domain" +) + +// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。 +// 与 postgres 行为一致:复用 channel pts/事件;只校验 monoforum 存在,不要求发件人是成员。 +func (s *ChannelStore) SendMonoforumMessage(_ context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) { + if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 || + req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, ok := s.channels[req.MonoforumID] + if !ok || channel.Deleted || !channel.Monoforum { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + if req.RandomID != 0 { + // 去重维度 = (sender, saved_peer, random_id),与 postgres 迁移 0022 的唯一索引一致; + // 不复用账号级 randomToID(其按 channel+sender+random_id 三元组,会被跨子会话同 random_id 互相覆盖)。 + if dup, ok := s.findMonoforumDuplicateLocked(req.MonoforumID, req.SenderUserID, req.SavedPeer, req.RandomID); ok { + event := s.eventForMessageLocked(req.MonoforumID, dup.ID) + if event.Message.ID != 0 { + dup = event.Message + } + return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(dup), Event: event, Duplicate: true}, nil + } + } + pts := s.nextChannelPtsLocked(req.MonoforumID) + msgID := s.nextChannelMessageIDLocked(req.MonoforumID) + msg := domain.ChannelMessage{ + ChannelID: req.MonoforumID, + ID: msgID, + RandomID: req.RandomID, + SenderUserID: req.SenderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, + SavedPeer: req.SavedPeer, + Date: req.Date, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Pts: pts, + } + event := domain.ChannelUpdateEvent{ + ChannelID: req.MonoforumID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: req.Date, + Message: cloneChannelMessage(msg), + SenderUserID: req.SenderUserID, + } + s.messages[req.MonoforumID] = append(s.messages[req.MonoforumID], msg) + s.events[req.MonoforumID] = append(s.events[req.MonoforumID], event) + channel.TopMessageID = msgID + channel.Pts = pts + s.channels[req.MonoforumID] = channel + return domain.SendChannelMessageResult{Channel: cloneChannel(channel), Message: cloneChannelMessage(msg), Event: cloneChannelEvent(event)}, nil +} + +// findMonoforumDuplicateLocked 按 (sender, saved_peer, random_id) 查 monoforum 子会话内的重发消息。 +func (s *ChannelStore) findMonoforumDuplicateLocked(monoforumID, senderUserID int64, savedPeer domain.Peer, randomID int64) (domain.ChannelMessage, bool) { + if randomID == 0 { + return domain.ChannelMessage{}, false + } + msgs := s.messages[monoforumID] + for i := len(msgs) - 1; i >= 0; i-- { + m := msgs[i] + if !m.Deleted && m.RandomID == randomID && m.SenderUserID == senderUserID && m.SavedPeer == savedPeer { + return m, true + } + } + return domain.ChannelMessage{}, false +} + +// ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。 +func (s *ChannelStore) ListMonoforumHistory(_ context.Context, filter domain.MonoforumHistoryFilter) (domain.ChannelHistory, error) { + if filter.MonoforumID == 0 || filter.SavedPeer.ID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, ok := s.channels[filter.MonoforumID] + if !ok || !channel.Monoforum { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + all := s.messages[filter.MonoforumID] + var msgs []domain.ChannelMessage + count := 0 + for i := len(all) - 1; i >= 0; i-- { + m := all[i] + if m.Deleted || m.SavedPeer != filter.SavedPeer { + continue + } + count++ + if filter.OffsetID > 0 && m.ID >= filter.OffsetID { + continue + } + if len(msgs) < limit { + msgs = append(msgs, cloneChannelMessage(m)) + } + } + return domain.ChannelHistory{Messages: msgs, Count: count, Channel: cloneChannel(channel)}, nil +} + +// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求调用者是 monoforum 成员——订阅者私信频道时 +// 并非 monoforum 成员),并返回调用者是否为其母广播频道的创建者/管理员。非 monoforum/不存在 → ErrChannelInvalid。 +func (s *ChannelStore) ResolveMonoforumSend(_ context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) { + if viewerUserID == 0 || monoforumID == 0 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + mono, ok := s.channels[monoforumID] + if !ok || mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + member, ok := s.members[mono.LinkedMonoforumID][viewerUserID] + isAdmin := ok && member.Status == domain.ChannelMemberActive && + (member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin) + return cloneChannel(mono), isAdmin, nil +} + +// ListMonoforumDialogs 列出 monoforum 的订阅者子会话(每个 saved_peer 一条,取其 top 消息), +// 按 top 消息 id 倒序分页。 +func (s *ChannelStore) ListMonoforumDialogs(_ context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error) { + if filter.MonoforumID == 0 { + return domain.MonoforumDialogList{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, ok := s.channels[filter.MonoforumID] + if !ok || !channel.Monoforum { + return domain.MonoforumDialogList{}, domain.ErrChannelInvalid + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + tops := map[domain.Peer]domain.ChannelMessage{} + for _, m := range s.messages[filter.MonoforumID] { + if m.Deleted || m.SavedPeer.ID == 0 { + continue + } + if cur, ok := tops[m.SavedPeer]; !ok || m.ID > cur.ID { + tops[m.SavedPeer] = m + } + } + ordered := make([]domain.ChannelMessage, 0, len(tops)) + for _, m := range tops { + ordered = append(ordered, m) + } + sort.Slice(ordered, func(i, j int) bool { return ordered[i].ID > ordered[j].ID }) + out := domain.MonoforumDialogList{MonoforumID: filter.MonoforumID, Channel: cloneChannel(channel), Count: len(ordered)} + for _, m := range ordered { + if filter.OffsetID > 0 && m.ID >= filter.OffsetID { + continue + } + if len(out.Dialogs) >= limit { + break + } + out.Dialogs = append(out.Dialogs, domain.MonoforumDialog{SavedPeer: m.SavedPeer, TopMessageID: m.ID, TopMessageDate: m.Date}) + out.Messages = append(out.Messages, cloneChannelMessage(m)) + } + return out, nil +} diff --git a/internal/store/memory/channel_monoforum_send_test.go b/internal/store/memory/channel_monoforum_send_test.go new file mode 100644 index 00000000..0f2fe432 --- /dev/null +++ b/internal/store/memory/channel_monoforum_send_test.go @@ -0,0 +1,134 @@ +package memory + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestSendMonoforumMessageAndHistory 验证频道私信(monoforum)发送+按订阅者读历史: +// 订阅者发、管理员回复同进一个 saved_peer 子会话;幂等;不同订阅者互不串会话。 +func TestSendMonoforumMessageAndHistory(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "DM", Broadcast: true, Date: 1_700_001_000}) + if err != nil { + t.Fatalf("create: %v", err) + } + enabled, err := store.SetPaidMessagesPrice(ctx, 1, broadcast.Channel.ID, 0, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + if monoID == 0 { + t.Fatalf("no monoforum created") + } + + sub := domain.Peer{Type: domain.PeerTypeUser, ID: 42} + + m1, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Date: 1_700_001_001}) + if err != nil { + t.Fatalf("subscriber send 1: %v", err) + } + if m1.Message.SavedPeer != sub || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 { + t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message) + } + if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 112, Message: "again", Date: 1_700_001_002}); err != nil { + t.Fatalf("subscriber send 2: %v", err) + } + // 管理员回复:发件人是 creator,saved_peer 仍是该订阅者(同一子会话)。 + if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 113, Message: "reply", Date: 1_700_001_003}); err != nil { + t.Fatalf("admin reply: %v", err) + } + + mainHist, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}) + if err != nil { + t.Fatalf("main monoforum history: %v", err) + } + if mainHist.Count != 1 || len(mainHist.Messages) != 1 { + t.Fatalf("main monoforum history count=%d len=%d, want only service message", mainHist.Count, len(mainHist.Messages)) + } + // monoforum 的服务消息是创建消息(渲染 "Direct messages were enabled in this channel."), + // paid_messages_price 只进母广播频道。 + if action := mainHist.Messages[0].Action; action == nil || action.Type != domain.ChannelActionCreate { + t.Fatalf("main monoforum action = %+v, want channel_create", action) + } + if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID { + t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID) + } + if _, err := store.ListChannelHistory(ctx, 42, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil { + t.Fatalf("subscriber main monoforum history = nil err, want denied") + } + + // 幂等:相同 randomID 返回原消息、不重复。 + dup, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 42, SavedPeer: sub, RandomID: 111, Message: "hi", Date: 1_700_001_004}) + if err != nil { + t.Fatalf("dup send: %v", err) + } + if !dup.Duplicate || dup.Message.ID != m1.Message.ID { + t.Fatalf("dup = %+v, want duplicate of m1 id %d", dup.Message, m1.Message.ID) + } + + hist, err := store.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: sub, Limit: 10}) + if err != nil { + t.Fatalf("history: %v", err) + } + if hist.Count != 3 || len(hist.Messages) != 3 { + t.Fatalf("history count=%d len=%d, want 3", hist.Count, len(hist.Messages)) + } + if hist.Messages[0].Body != "reply" { + t.Fatalf("history[0] = %q, want newest 'reply'", hist.Messages[0].Body) + } + for _, m := range hist.Messages { + if m.SavedPeer != sub { + t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer) + } + } + + // 另一个订阅者的私信不串会话。 + other := domain.Peer{Type: domain.PeerTypeUser, ID: 99} + if _, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 99, SavedPeer: other, RandomID: 201, Message: "other", Date: 1_700_001_005}); err != nil { + t.Fatalf("other subscriber send: %v", err) + } + subHist, _ := store.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: sub, Limit: 10}) + if subHist.Count != 3 { + t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count) + } + + // 去重按订阅者子会话维度:同一发件人(此处管理员)用相同 random_id 向两个不同订阅者发,不得互相去重。 + a, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_010}) + if err != nil { + t.Fatalf("dedup send A: %v", err) + } + b, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: other, RandomID: 9001, Message: "to other", Date: 1_700_001_011}) + if err != nil { + t.Fatalf("dedup send B: %v", err) + } + if b.Duplicate || b.Message.ID == a.Message.ID { + t.Fatalf("cross-sublist same random_id wrongly deduped: a=%d b=%d dup=%v", a.Message.ID, b.Message.ID, b.Duplicate) + } + // 同一子会话真重发(相同 random_id)仍去重。 + again, err := store.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: 1, SavedPeer: sub, RandomID: 9001, Message: "to sub", Date: 1_700_001_012}) + if err != nil { + t.Fatalf("dedup resend A: %v", err) + } + if !again.Duplicate || again.Message.ID != a.Message.ID { + t.Fatalf("same-sublist retry not deduped: again=%+v want dup of %d", again.Message, a.Message.ID) + } + + // 订阅者子会话列表:两个订阅者,按 top 消息 id 倒序(other 最后发,排首)。 + dialogs, err := store.ListMonoforumDialogs(ctx, domain.MonoforumDialogsFilter{MonoforumID: monoID, Limit: 10}) + if err != nil { + t.Fatalf("list dialogs: %v", err) + } + if dialogs.Count != 2 || len(dialogs.Dialogs) != 2 { + t.Fatalf("dialogs count=%d len=%d, want 2 subscribers", dialogs.Count, len(dialogs.Dialogs)) + } + if dialogs.Dialogs[0].SavedPeer != other { + t.Fatalf("dialogs[0] saved_peer = %+v, want newest 'other'", dialogs.Dialogs[0].SavedPeer) + } + if dialogs.Dialogs[1].SavedPeer != sub || dialogs.Dialogs[1].TopMessageID == 0 { + t.Fatalf("dialogs[1] = %+v, want sub with top message", dialogs.Dialogs[1]) + } +} diff --git a/internal/store/memory/channel_monoforum_test.go b/internal/store/memory/channel_monoforum_test.go new file mode 100644 index 00000000..37b48a8b --- /dev/null +++ b/internal/store/memory/channel_monoforum_test.go @@ -0,0 +1,205 @@ +package memory + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestSetPaidMessagesPriceCreatesAndReusesMonoforum 验证开启频道私信(Direct Messages)时 +// 为广播频道关联一个 monoforum 虚拟频道:首次建、再次复用并同步价格;超级群(非广播)不建。 +// 与 postgres 实现行为对齐。 +func TestSetPaidMessagesPriceCreatesAndReusesMonoforum(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, Title: "DM Broadcast", Broadcast: true, Date: 1_700_000_900, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + channelID := broadcast.Channel.ID + + updatedResult, err := store.SetPaidMessagesPrice(ctx, 1, channelID, 5, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + updated := updatedResult.Channel + if updated.LinkedMonoforumID == 0 || !updated.BroadcastMessagesAllowed || updated.SendPaidMessagesStars != 5 { + t.Fatalf("after enable = %+v, want linked monoforum + broadcast allowed + 5 stars", updated) + } + monoID := updated.LinkedMonoforumID + + mono, ok := store.channels[monoID] + if !ok { + t.Fatalf("monoforum channel %d not created", monoID) + } + if !mono.Monoforum || mono.Broadcast || !mono.Megagroup || mono.LinkedMonoforumID != channelID || mono.SendPaidMessagesStars != 5 || mono.CreatorUserID != 1 { + t.Fatalf("monoforum channel = %+v, want megagroup monoforum + back-link %d + 5 stars + creator 1", mono, channelID) + } + if mono.TopMessageID == 0 || mono.Pts == 0 { + t.Fatalf("monoforum top/pts = %d/%d, want service top message", mono.TopMessageID, mono.Pts) + } + dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 100}) + if err != nil { + t.Fatalf("list dialogs after enable: %v", err) + } + var foundMono bool + for _, dialog := range dialogs.Dialogs { + if dialog.Peer == (domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}) { + foundMono = true + if dialog.TopMessage != mono.TopMessageID || dialog.UnreadCount != 0 || dialog.ReadInboxMaxID < mono.TopMessageID { + t.Fatalf("monoforum dialog = %+v, want top %d read/no-unread", dialog, mono.TopMessageID) + } + } + } + if !foundMono { + t.Fatalf("dialogs after enable do not include admin-visible monoforum %d: %+v", monoID, dialogs.Dialogs) + } + msgs := store.messages[monoID] + if len(msgs) != 1 { + t.Fatalf("monoforum messages = %d, want exactly one creation service message", len(msgs)) + } + // monoforum 的服务消息是创建消息(渲染 "Direct messages were enabled in this channel."), + // 不是 paid_messages_price(后者只进母频道,在 megagroup 里会渲染成错误的"消息免费"文案)。 + if action := msgs[0].Action; action == nil || action.Type != domain.ChannelActionCreate { + t.Fatalf("monoforum service action = %+v, want channel_create", action) + } + parentMsgs := store.messages[channelID] + if len(parentMsgs) != 2 { + t.Fatalf("parent channel messages = %d, want create + paid_messages_price service", len(parentMsgs)) + } + if action := parentMsgs[1].Action; action == nil || action.Type != domain.ChannelActionPaidMessagesPrice || !action.BroadcastMessagesAllowed || action.Stars != 5 { + t.Fatalf("parent service action = %+v, want paid_messages_price allowed stars=5", action) + } + mono.TopMessageID = 0 + mono.Pts = 0 + store.channels[monoID] = mono + store.messages[monoID] = nil + repairedResult, err := store.SetPaidMessagesPrice(ctx, 1, channelID, 5, true) + if err != nil { + t.Fatalf("repair legacy DM: %v", err) + } + repaired := repairedResult.Channel + if repaired.LinkedMonoforumID != monoID { + t.Fatalf("repair linked = %d, want stable %d", repaired.LinkedMonoforumID, monoID) + } + msgs = store.messages[monoID] + if len(msgs) != 1 { + t.Fatalf("monoforum messages after legacy repair = %d, want one creation service message", len(msgs)) + } + if action := msgs[0].Action; action == nil || action.Type != domain.ChannelActionCreate { + t.Fatalf("monoforum repaired service action = %+v, want channel_create", action) + } + + // 幂等:再次开启不新建,仅同步价格。 + againResult, err := store.SetPaidMessagesPrice(ctx, 1, channelID, 8, true) + if err != nil { + t.Fatalf("re-enable: %v", err) + } + again := againResult.Channel + if again.LinkedMonoforumID != monoID { + t.Fatalf("re-enable linked = %d, want stable %d (no second monoforum)", again.LinkedMonoforumID, monoID) + } + if got := store.channels[monoID].SendPaidMessagesStars; got != 8 { + t.Fatalf("monoforum price = %d, want synced 8", got) + } + // 价格变更只进母广播频道;monoforum 仍只有创建消息那一条。 + if msgs = store.messages[monoID]; len(msgs) != 1 { + t.Fatalf("monoforum messages after price update = %d, want still one (price change goes to parent only)", len(msgs)) + } + if pm := store.messages[channelID]; len(pm) == 0 || pm[len(pm)-1].Action == nil || pm[len(pm)-1].Action.Type != domain.ChannelActionPaidMessagesPrice || !pm[len(pm)-1].Action.BroadcastMessagesAllowed || pm[len(pm)-1].Action.Stars != 8 { + t.Fatalf("parent latest action = %+v, want paid_messages_price allowed stars=8", store.messages[channelID]) + } + disabledResult, err := store.SetPaidMessagesPrice(ctx, 1, channelID, 0, false) + if err != nil { + t.Fatalf("disable DM: %v", err) + } + disabled := disabledResult.Channel + if disabled.LinkedMonoforumID != monoID || disabled.BroadcastMessagesAllowed { + t.Fatalf("disable linked/allowed = %d/%v, want stable %d and disabled", disabled.LinkedMonoforumID, disabled.BroadcastMessagesAllowed, monoID) + } + if disabledResult.ServiceMessage == nil || disabledResult.ServiceMessage.Event.Pts == 0 { + t.Fatalf("disable service result = %+v, want paid_messages_price event", disabledResult.ServiceMessage) + } + // 关闭只进母广播频道(+monoforumDisabled 状态显示停用页脚);monoforum 仍只有创建消息那一条。 + if msgs = store.messages[monoID]; len(msgs) != 1 { + t.Fatalf("monoforum messages after disable = %d, want still one (disable goes to parent only)", len(msgs)) + } + if pm := store.messages[channelID]; len(pm) == 0 || pm[len(pm)-1].Action == nil || pm[len(pm)-1].Action.Type != domain.ChannelActionPaidMessagesPrice || pm[len(pm)-1].Action.BroadcastMessagesAllowed || pm[len(pm)-1].Action.Stars != 0 { + t.Fatalf("parent latest action = %+v, want paid_messages_price disabled stars=0", store.messages[channelID]) + } + + // 超级群(非广播)即便 broadcastMessagesAllowed=true 也不建 monoforum:私信是广播频道专属。 + group, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, Title: "SG", Megagroup: true, Date: 1_700_000_901, + }) + if err != nil { + t.Fatalf("create group: %v", err) + } + sgResult, err := store.SetPaidMessagesPrice(ctx, 1, group.Channel.ID, 3, true) + if err != nil { + t.Fatalf("group paid: %v", err) + } + sg := sgResult.Channel + if sg.LinkedMonoforumID != 0 || sg.BroadcastMessagesAllowed { + t.Fatalf("megagroup = linked %d broadcastAllowed %v, want no monoforum", sg.LinkedMonoforumID, sg.BroadcastMessagesAllowed) + } +} + +// TestGetChannelDialogsCoDeliversMonoforumParent 锁定 Direct-Messages 渲染的"同批下发"不变量: +// 管理员按 id 拉取 monoforum 私信会话时,响应必须同时带母广播频道(作为 chats[] 附带,而非额外 +// dialog),客户端才能 resolve linked_monoforum_id 并派生 MonoforumAdmin;缺父对象会让 mono 退化为 +// 普通 megagroup。非管理员既拿不到 monoforum 也不会被泄漏母频道。 +func TestGetChannelDialogsCoDeliversMonoforumParent(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + broadcast, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: 1, Title: "DM Broadcast", Broadcast: true, Date: 1_700_000_900}) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + parentID := broadcast.Channel.ID + enabled, err := store.SetPaidMessagesPrice(ctx, 1, parentID, 0, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + if monoID == 0 { + t.Fatalf("no monoforum created") + } + + list, err := store.GetChannelDialogs(ctx, 1, []int64{monoID}) + if err != nil { + t.Fatalf("get channel dialogs: %v", err) + } + var hasMono, hasParent bool + for _, ch := range list.Channels { + switch ch.ID { + case monoID: + hasMono = true + case parentID: + hasParent = true + } + } + if !hasMono { + t.Fatalf("GetChannelDialogs([mono]) channels missing mono %d: %+v", monoID, list.Channels) + } + if !hasParent { + t.Fatalf("GetChannelDialogs([mono]) did NOT co-deliver parent broadcast %d (TDesktop can't derive MonoforumAdmin): %+v", parentID, list.Channels) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != monoID { + t.Fatalf("dialogs = %+v, want exactly the mono dialog (parent only as chats[])", list.Dialogs) + } + + // 非管理员(非母频道成员)拿不到 monoforum,也不会被泄漏母频道。 + deniedList, err := store.GetChannelDialogs(ctx, 999, []int64{monoID}) + if err != nil { + t.Fatalf("get channel dialogs (non-admin): %v", err) + } + for _, ch := range deniedList.Channels { + if ch.ID == monoID || ch.ID == parentID { + t.Fatalf("non-admin leaked channel %d: %+v", ch.ID, deniedList.Channels) + } + } +} diff --git a/internal/store/memory/channel_poll_fanout_test.go b/internal/store/memory/channel_poll_fanout_test.go new file mode 100644 index 00000000..d0974cc7 --- /dev/null +++ b/internal/store/memory/channel_poll_fanout_test.go @@ -0,0 +1,139 @@ +package memory + +import ( + "context" + "reflect" + "testing" + + "telesrv/internal/domain" +) + +// TestChannelPollFanoutViewsByteEquivalentToGetMessages 锁定 Phase 4 poll 模板化的安全网:批量 +// ChannelPollFanoutViews 对每个 viewer 产出的 enrich poll,必须与逐 viewer GetChannelMessages(旧 +// fan-out 路径)字节等价;且可见性判定一致(不可见 viewer 在批量里为 nil、在旧路径里取不到消息)。 +// 覆盖:作者(创建者)、已投票非作者、未投票非作者、hide_results 期非创建者、late-joiner(AvailableMinID)、 +// 被封成员、ViewMessages 受限成员、非成员。 +func TestChannelPollFanoutViewsByteEquivalentToGetMessages(t *testing.T) { + ctx := context.Background() + const ( + channelID = int64(8000000001) + msgID = 50 + pollID = int64(9000000001) + creator = int64(7001) + votedA = int64(7002) + votedB = int64(7003) + unvoted = int64(7004) + late = int64(7005) // AvailableMinID >= msgID(pre-history 隐藏) + banned = int64(7006) + restricted = int64(7007) // active 但 BannedRights.ViewMessages + nonMember = int64(7008) + ) + opt1, opt2, opt3 := []byte{1}, []byte{2}, []byte{3} + + for _, hideResults := range []bool{false, true} { + t.Run(map[bool]string{false: "visible_counts", true: "hide_results"}[hideResults], func(t *testing.T) { + channels := NewChannelStore() + polls := NewPollStore() + channels.AttachPollStore(polls) + + channels.channels[channelID] = domain.Channel{ID: channelID, CreatorUserID: creator, Megagroup: true, PreHistoryHidden: true} + channels.members[channelID] = map[int64]domain.ChannelMember{ + creator: {UserID: creator, Status: domain.ChannelMemberActive, Role: domain.ChannelRoleCreator}, + votedA: {UserID: votedA, Status: domain.ChannelMemberActive}, + votedB: {UserID: votedB, Status: domain.ChannelMemberActive}, + unvoted: {UserID: unvoted, Status: domain.ChannelMemberActive}, + late: {UserID: late, Status: domain.ChannelMemberActive, AvailableMinID: msgID + 10}, + banned: {UserID: banned, Status: domain.ChannelMemberBanned}, + restricted: {UserID: restricted, Status: domain.ChannelMemberActive, BannedRights: domain.ChannelBannedRights{ViewMessages: true}}, + } + + def := domain.PollDefinition{ + ID: pollID, + CreatorUserID: creator, + Options: [][]byte{opt1, opt2, opt3}, + PublicVoters: true, + MultipleChoice: false, + HideResultsUntilClose: hideResults, + } + if err := polls.CreatePoll(ctx, def); err != nil { + t.Fatalf("create poll: %v", err) + } + basePoll := &domain.MessagePoll{ + ID: pollID, + Question: "Q?", + Answers: []domain.MessagePollAnswer{{Text: "A", Option: opt1}, {Text: "B", Option: opt2}, {Text: "C", Option: opt3}}, + PublicVoters: true, + HideResultsUntilClose: hideResults, + } + channels.messages[channelID] = []domain.ChannelMessage{{ + ChannelID: channelID, + ID: msgID, + SenderUserID: creator, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPoll, Poll: basePoll}, + }} + if err := polls.Vote(pollID, votedA, [][]byte{opt1}, 100); err != nil { + t.Fatalf("vote A: %v", err) + } + if err := polls.Vote(pollID, votedB, [][]byte{opt2}, 101); err != nil { + t.Fatalf("vote B: %v", err) + } + + viewers := []int64{creator, votedA, votedB, unvoted, late, banned, restricted, nonMember} + views, err := channels.ChannelPollFanoutViews(ctx, channelID, msgID, viewers, 200) + if err != nil { + t.Fatalf("ChannelPollFanoutViews: %v", err) + } + if !views.Found { + t.Fatal("poll fan-out views Found=false, want true") + } + + for _, viewer := range viewers { + got, evaluated := views.Polls[viewer] + if !evaluated { + t.Fatalf("viewer %d not evaluated in batch (all passed viewers must be evaluated)", viewer) + } + // 旧路径参考:GetChannelMessages 按 viewer 取消息(含可见性 + per-viewer poll enrich)。 + ref, err := channels.GetChannelMessages(ctx, viewer, channelID, []int{msgID}) + var refPoll *domain.MessagePoll + if err == nil { + for _, m := range ref.Messages { + if m.ID == msgID && m.Media != nil { + refPoll = m.Media.Poll + } + } + } + if (got != nil) != (refPoll != nil) { + t.Fatalf("viewer %d visibility mismatch: batch nil=%v, GetMessages nil=%v (err=%v)", viewer, got == nil, refPoll == nil, err) + } + if got != nil && !reflect.DeepEqual(got.Results, refPoll.Results) { + t.Fatalf("viewer %d poll Results mismatch:\n batch=%+v\n old=%+v", viewer, got.Results, refPoll.Results) + } + } + + // 显式断言可见性分类,防 GetMessages 与批量同时漏判。 + if views.Polls[creator] == nil || views.Polls[votedA] == nil || views.Polls[votedB] == nil || views.Polls[unvoted] == nil { + t.Fatalf("active members should be visible: creator/votedA/votedB/unvoted") + } + if views.Polls[late] != nil || views.Polls[banned] != nil || views.Polls[restricted] != nil || views.Polls[nonMember] != nil { + t.Fatalf("late/banned/restricted/nonMember must be invisible (nil)") + } + // hide_results 期:非创建者已投票者计数应被隐藏(Voters=0),创建者可见真实计数。 + if hideResults { + for _, v := range views.Polls[votedA].Results.Voters { + if v.Voters != 0 { + t.Fatalf("hide_results: non-creator votedA should see Voters=0, got %d", v.Voters) + } + } + sawCount := false + for _, v := range views.Polls[creator].Results.Voters { + if v.Voters > 0 { + sawCount = true + } + } + if !sawCount { + t.Fatal("hide_results: creator should see real counts") + } + } + }) + } +} diff --git a/internal/store/memory/channel_polls.go b/internal/store/memory/channel_polls.go new file mode 100644 index 00000000..d9af6b1f --- /dev/null +++ b/internal/store/memory/channel_polls.go @@ -0,0 +1,116 @@ +package memory + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// 频道/超级群消息 poll 投票与关闭:成员资格与消息可见性沿用 reaction 同款校验, +// poll 级语义委托共享 PollStore。 + +func (s *ChannelStore) VoteChannelMessagePoll(_ context.Context, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) { + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + channel, msg, err := s.pollChannelMessageTarget(req.UserID, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelMessagePollResult{}, err + } + if err := s.polls.Vote(msg.Media.Poll.ID, req.UserID, req.Options, req.Date); err != nil { + return domain.ChannelMessagePollResult{}, err + } + return s.channelPollResult(channel, msg, req.UserID, req.Date), nil +} + +func (s *ChannelStore) CloseChannelMessagePoll(_ context.Context, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) { + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + channel, msg, err := s.pollChannelMessageTarget(req.UserID, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelMessagePollResult{}, err + } + if err := s.polls.Close(msg.Media.Poll.ID, req.UserID); err != nil { + return domain.ChannelMessagePollResult{}, err + } + return s.channelPollResult(channel, msg, req.UserID, req.Date), nil +} + +func (s *ChannelStore) pollChannelMessageTarget(userID, channelID int64, messageID int) (domain.Channel, domain.ChannelMessage, error) { + if s == nil || s.polls == nil { + return domain.Channel{}, domain.ChannelMessage{}, domain.ErrMessageIDInvalid + } + if userID == 0 || channelID == 0 || messageID <= 0 || messageID > domain.MaxMessageBoxID { + return domain.Channel{}, domain.ChannelMessage{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, domain.ChannelMessage{}, err + } + msg, ok := s.findMessageLocked(channelID, messageID) + if !ok || msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { + return domain.Channel{}, domain.ChannelMessage{}, domain.ErrMessageIDInvalid + } + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPoll || msg.Media.Poll == nil || msg.Media.Poll.ID == 0 { + return domain.Channel{}, domain.ChannelMessage{}, domain.ErrMessageIDInvalid + } + return cloneChannel(channel), cloneChannelMessage(msg), nil +} + +// ChannelPollFanoutViews 批量加载一条 poll 消息对一组 viewer 的 per-viewer enrich(与 postgres 同口径, +// 消除 fan-out 逐 viewer 重载 N+1):成员/AvailableMinID 可见性复刻 channelAndMemberLocked + +// pollChannelMessageTarget;poll enrich 委托 PollStore.EnrichPollForViewers(模板一次)。bot 历史过滤 +// 在 app 层叠加。Polls:key 存在=已评估;nil=不可见;非 nil=可见 enrich poll。 +func (s *ChannelStore) ChannelPollFanoutViews(_ context.Context, channelID int64, msgID int, viewers []int64, now int) (domain.ChannelPollFanoutViews, error) { + out := domain.ChannelPollFanoutViews{Polls: map[int64]*domain.MessagePoll{}} + if s == nil || s.polls == nil || channelID == 0 || msgID <= 0 || len(viewers) == 0 { + return out, nil + } + if now == 0 { + now = int(time.Now().Unix()) + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return out, nil + } + msg, ok := s.findMessageLocked(channelID, msgID) + if !ok || msg.Deleted || msg.Action != nil || msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPoll || msg.Media.Poll == nil || msg.Media.Poll.ID == 0 { + return out, nil + } + out.Found = true + out.Message = cloneChannelMessage(msg) + visible := make([]int64, 0, len(viewers)) + for _, viewer := range viewers { + if viewer == 0 { + continue + } + member, ok := s.members[channelID][viewer] + if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages || (member.AvailableMinID > 0 && msgID <= member.AvailableMinID) { + out.Polls[viewer] = nil // 已评估但不可见 + continue + } + visible = append(visible, viewer) + } + enriched := s.polls.EnrichPollForViewers(msg.Media.Poll, visible, now) + for viewer, poll := range enriched { + out.Polls[viewer] = poll + } + return out, nil +} + +// channelPollResult 为投票者视角组装结果;实时 fan-out 与 reaction 同款由 rpc 层按 viewer 重建。 +func (s *ChannelStore) channelPollResult(channel domain.Channel, msg domain.ChannelMessage, viewerUserID int64, now int) domain.ChannelMessagePollResult { + msg.Media = enrichPollMediaForViewer(s.polls, msg.Media, viewerUserID, now) + return domain.ChannelMessagePollResult{ + PollID: msg.Media.Poll.ID, + Channel: channel, + Message: msg, + Recipients: []int64{viewerUserID, msg.SenderUserID}, + } +} diff --git a/internal/store/memory/channel_reactions.go b/internal/store/memory/channel_reactions.go new file mode 100644 index 00000000..65c14647 --- /dev/null +++ b/internal/store/memory/channel_reactions.go @@ -0,0 +1,1030 @@ +package memory + +import ( + "context" + "sort" + "strconv" + "strings" + "telesrv/internal/domain" + "time" +) + +func (s *ChannelStore) SetAvailableReactions(_ context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.ReactionPolicy = copyChannelReactionPolicy(policy) + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetChannelMessageReactions(_ context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + for _, reaction := range req.Reactions { + if !reaction.Valid() { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + } + req.Reactions = domain.TrimMessageReactionsToUserMax(req.Reactions, req.ReactionsPerUserMax) + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + idx, ok := s.findMessageIndexLocked(req.ChannelID, req.MessageID) + if !ok { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + msg := s.messages[req.ChannelID][idx] + if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + // 仅新增/替换受策略约束;空向量是撤销,策略收紧后也必须允许撤销存量 reaction。 + for _, reaction := range req.Reactions { + if !channel.ReactionPolicy.AllowsReaction(reaction) { + return domain.ChannelMessageReactionsResult{}, domain.ErrReactionInvalid + } + } + if len(req.Reactions) > 0 { + // 官方 REACTIONS_TOO_MANY 只挡「引入消息上尚不存在的新种类」:存量已超限 + //(管理员调低 reactions_limit / 部署前数据)时,重发自己的 reaction 或给 + // 已有种类投票必须放行,否则客户端点击合法 chip 也会收到 400。 + existing := make(map[string]struct{}) + final := make(map[string]struct{}) + for userID, rows := range s.reactions[req.ChannelID][req.MessageID] { + for _, row := range rows { + key := messageReactionKey(row.Reaction) + existing[key] = struct{}{} + if userID != req.UserID { + final[key] = struct{}{} + } + } + } + newKind := false + for _, reaction := range req.Reactions { + key := messageReactionKey(reaction) + if _, ok := existing[key]; !ok { + newKind = true + } + final[key] = struct{}{} + } + if newKind && len(final) > channel.ReactionPolicy.UniqueReactionsLimit() { + return domain.ChannelMessageReactionsResult{}, domain.ErrReactionsTooMany + } + } + if s.reactions[req.ChannelID] == nil { + s.reactions[req.ChannelID] = make(map[int]map[int64][]domain.ChannelMessagePeerReaction) + } + if s.reactions[req.ChannelID][req.MessageID] == nil { + s.reactions[req.ChannelID][req.MessageID] = make(map[int64][]domain.ChannelMessagePeerReaction) + } + // 广播频道 reaction 匿名(官方语义),作者不收 unread 角标,不写 unread 簿记。 + unreadEligible := !channel.Broadcast || channel.Megagroup + if len(req.Reactions) == 0 { + delete(s.reactions[req.ChannelID][req.MessageID], req.UserID) + } else { + rows := make([]domain.ChannelMessagePeerReaction, 0, len(req.Reactions)) + for i, reaction := range req.Reactions { + rows = append(rows, domain.ChannelMessagePeerReaction{ + ChannelID: req.ChannelID, + MessageID: req.MessageID, + SenderUserID: msg.SenderUserID, + UserID: req.UserID, + Reaction: reaction, + Big: req.Big, + Unread: unreadEligible && msg.SenderUserID != 0 && msg.SenderUserID != req.UserID, + ChosenOrder: i + 1, + Date: req.Date, + }) + } + s.reactions[req.ChannelID][req.MessageID][req.UserID] = rows + if s.top[req.UserID] == nil { + s.top[req.UserID] = make(map[string]domain.TopMessageReaction) + } + for _, reaction := range req.Reactions { + key := messageReactionKey(reaction) + row := s.top[req.UserID][key] + row.UserID = req.UserID + row.Reaction = reaction + row.Count++ + row.Date = req.Date + s.top[req.UserID][key] = row + } + if req.AddToRecent { + if s.recent[req.UserID] == nil { + s.recent[req.UserID] = make(map[string]domain.RecentMessageReaction) + } + for _, reaction := range req.Reactions { + s.recent[req.UserID][messageReactionKey(reaction)] = domain.RecentMessageReaction{ + UserID: req.UserID, + Reaction: reaction, + Date: req.Date, + } + } + } + } + if unreadEligible { + s.refreshChannelUnreadReactionsDialogLocked(msg.SenderUserID, req.ChannelID) + } + reactions := s.channelMessageReactionsLocked(req.UserID, channel, req.MessageID) + msg = cloneChannelMessage(msg) + msg.Reactions = cloneChannelMessageReactionsPtr(&reactions) + // sendReaction 实时推送走在线 viewer scope(rpc 层封顶),不预热全量成员列表。 + return domain.ChannelMessageReactionsResult{ + Channel: cloneChannel(channel), + Message: msg, + Messages: []domain.ChannelMessage{msg}, + Reactions: cloneChannelMessageReactions(reactions), + Recipients: []int64{req.UserID}, + }, nil +} + +type memoryPaidReaction struct { + stars int64 + anonymous bool + date int +} + +// AddChannelMessagePaidReaction 累计 viewer 对一条广播频道消息的付费 reaction 星数(内存镜像)。 +func (s *ChannelStore) AddChannelMessagePaidReaction(_ context.Context, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid + } + if req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest { + return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessagePaidReactionResult{}, err + } + if !channel.Broadcast || channel.Megagroup { + return domain.ChannelMessagePaidReactionResult{}, domain.ErrReactionInvalid + } + msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) + if !ok || msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { + return domain.ChannelMessagePaidReactionResult{}, domain.ErrMessageIDInvalid + } + if s.paidReactions[req.ChannelID] == nil { + s.paidReactions[req.ChannelID] = make(map[int]map[int64]memoryPaidReaction) + } + if s.paidReactions[req.ChannelID][req.MessageID] == nil { + s.paidReactions[req.ChannelID][req.MessageID] = make(map[int64]memoryPaidReaction) + } + prev := s.paidReactions[req.ChannelID][req.MessageID][req.UserID] + s.paidReactions[req.ChannelID][req.MessageID][req.UserID] = memoryPaidReaction{ + stars: prev.stars + req.Stars, + anonymous: req.Anonymous, + date: req.Date, + } + paid := s.aggregatePaidReactionsLocked(req.ChannelID, req.MessageID, req.UserID) + outMsg := cloneChannelMessage(msg) + reactions := s.channelMessageReactionsLocked(req.UserID, channel, req.MessageID) + outMsg.Reactions = cloneChannelMessageReactionsPtr(&reactions) + return domain.ChannelMessagePaidReactionResult{ + Channel: cloneChannel(channel), + Message: outMsg, + Paid: paid, + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) aggregatePaidReactionsLocked(channelID int64, messageID int, viewerUserID int64) domain.ChannelMessagePaidReactions { + byUser := s.paidReactions[channelID][messageID] + reactors := make([]domain.PaidReactor, 0, len(byUser)) + var out domain.ChannelMessagePaidReactions + for userID, entry := range byUser { + r := domain.PaidReactor{UserID: userID, Stars: entry.stars, Anonymous: entry.anonymous, My: userID == viewerUserID} + out.TotalStars += entry.stars + if r.My { + out.MyStars = entry.stars + out.MyAnonymous = entry.anonymous + } + reactors = append(reactors, r) + } + sort.Slice(reactors, func(i, j int) bool { + if reactors[i].Stars != reactors[j].Stars { + return reactors[i].Stars > reactors[j].Stars + } + return reactors[i].UserID < reactors[j].UserID + }) + myInTop := false + for i, r := range reactors { + if i >= domain.MaxPaidReactionTopReactors { + break + } + out.TopReactors = append(out.TopReactors, r) + if r.My { + myInTop = true + } + } + if out.MyStars > 0 && !myInTop { + for _, r := range reactors { + if r.My { + out.TopReactors = append(out.TopReactors, r) + break + } + } + } + return out +} + +func (s *ChannelStore) DeleteChannelParticipantReaction(_ context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if !canDeleteAnyChannelMessage(member) { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelAdminRequired + } + msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if s.reactions[req.ChannelID] != nil && s.reactions[req.ChannelID][req.MessageID] != nil { + delete(s.reactions[req.ChannelID][req.MessageID], req.ParticipantUserID) + } + s.refreshChannelUnreadReactionsDialogLocked(msg.SenderUserID, req.ChannelID) + reactions := s.channelMessageReactionsLocked(req.UserID, channel, req.MessageID) + outMsg := cloneChannelMessage(msg) + outMsg.Reactions = cloneChannelMessageReactionsPtr(&reactions) + return domain.ChannelMessageReactionsResult{ + Channel: cloneChannel(channel), + Message: outMsg, + Messages: []domain.ChannelMessage{outMsg}, + Reactions: cloneChannelMessageReactions(reactions), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) DeleteChannelParticipantReactions(_ context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch { + req.Limit = domain.MaxDeleteParticipantReactionsBatch + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, err + } + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelAdminRequired + } + type reactionMsg struct { + id int + sender int64 + date int + } + candidates := make([]reactionMsg, 0) + for msgID, byUser := range s.reactions[req.ChannelID] { + rows := byUser[req.ParticipantUserID] + if len(rows) == 0 { + continue + } + msg, ok := s.findMessageLocked(req.ChannelID, msgID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + item := reactionMsg{id: msgID, sender: msg.SenderUserID} + for _, row := range rows { + if row.Date > item.date { + item.date = row.Date + } + } + candidates = append(candidates, item) + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].date != candidates[j].date { + return candidates[i].date > candidates[j].date + } + return candidates[i].id > candidates[j].id + }) + if len(candidates) > req.Limit { + candidates = candidates[:req.Limit] + } + owners := make(map[int64]struct{}) + ids := make([]int, 0, len(candidates)) + for _, item := range candidates { + if s.reactions[req.ChannelID] != nil && s.reactions[req.ChannelID][item.id] != nil { + delete(s.reactions[req.ChannelID][item.id], req.ParticipantUserID) + } + if item.sender != 0 { + owners[item.sender] = struct{}{} + } + ids = append(ids, item.id) + } + for ownerID := range owners { + s.refreshChannelUnreadReactionsDialogLocked(ownerID, req.ChannelID) + } + messages := make([]domain.ChannelMessage, 0, len(ids)) + for _, id := range ids { + msg, ok := s.findMessageLocked(req.ChannelID, id) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + reactions := s.channelMessageReactionsLocked(req.UserID, channel, id) + outMsg := cloneChannelMessage(msg) + outMsg.Reactions = cloneChannelMessageReactionsPtr(&reactions) + messages = append(messages, outMsg) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) + return domain.DeleteChannelParticipantReactionsResult{ + Channel: cloneChannel(channel), + Messages: messages, + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Deleted: len(ids), + }, nil +} + +func (s *ChannelStore) GetChannelMessageReactions(_ context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + wanted := make(map[int]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + messages := make([]domain.ChannelMessage, 0, len(wanted)) + for _, msg := range s.messages[req.ChannelID] { + if _, ok := wanted[msg.ID]; !ok { + continue + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + item := cloneChannelMessage(msg) + reactions := s.channelMessageReactionsLocked(req.UserID, channel, msg.ID) + item.Reactions = cloneChannelMessageReactionsPtr(&reactions) + messages = append(messages, item) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) + res := domain.ChannelMessageReactionsResult{ + Channel: cloneChannel(channel), + Messages: messages, + } + if len(messages) == 1 { + res.Message = messages[0] + if messages[0].Reactions != nil { + res.Reactions = cloneChannelMessageReactions(*messages[0].Reactions) + } + } + return res, nil +} + +func (s *ChannelStore) ListChannelMessageReactions(_ context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit { + req.Limit = domain.MaxChannelMessageReactionListLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsList{}, err + } + if channel.Broadcast && !channel.Megagroup { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden + } + msg, ok := s.findMessageLocked(req.ChannelID, req.MessageID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid + } + rows := s.channelMessageReactionRowsLocked(req.ChannelID, req.MessageID, req.UserID, req.Reaction) + sort.Slice(rows, func(i, j int) bool { + if rows[i].Date != rows[j].Date { + return rows[i].Date > rows[j].Date + } + if rows[i].UserID != rows[j].UserID { + return rows[i].UserID > rows[j].UserID + } + return messageReactionKey(rows[i].Reaction) < messageReactionKey(rows[j].Reaction) + }) + total := len(rows) + if req.Offset != "" { + if offset, ok := parseMemoryReactionOffset(req.Offset); ok { + filtered := rows[:0] + for _, row := range rows { + if memoryReactionAfterOffset(row, offset) { + filtered = append(filtered, row) + } + } + rows = filtered + } + } + next := "" + if len(rows) > req.Limit { + rows = rows[:req.Limit] + next = memoryReactionOffset(rows[len(rows)-1]) + } + return domain.ChannelMessageReactionsList{ + Channel: cloneChannel(channel), + Message: cloneChannelMessage(msg), + Count: total, + Reactions: cloneChannelPeerReactions(rows), + NextOffset: next, + }, nil +} + +func (s *ChannelStore) RecordMessageReactionUse(_ context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { + if userID == 0 || len(reactions) == 0 { + return nil + } + if date == 0 { + date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.top[userID] == nil { + s.top[userID] = make(map[string]domain.TopMessageReaction) + } + if addToRecent && s.recent[userID] == nil { + s.recent[userID] = make(map[string]domain.RecentMessageReaction) + } + for _, reaction := range reactions { + if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" { + continue + } + key := messageReactionKey(reaction) + row := s.top[userID][key] + row.UserID = userID + row.Reaction = reaction + row.Count++ + row.Date = date + s.top[userID][key] = row + if addToRecent { + s.recent[userID][key] = domain.RecentMessageReaction{ + UserID: userID, + Reaction: reaction, + Date: date, + } + } + } + return nil +} + +func (s *ChannelStore) ListTopMessageReactions(_ context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxTopMessageReactions { + limit = domain.MaxTopMessageReactions + } + s.mu.RLock() + defer s.mu.RUnlock() + rows := make([]domain.TopMessageReaction, 0, len(s.top[userID])) + for _, row := range s.top[userID] { + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Count != rows[j].Count { + return rows[i].Count > rows[j].Count + } + if rows[i].Date != rows[j].Date { + return rows[i].Date > rows[j].Date + } + if rows[i].Reaction.Type != rows[j].Reaction.Type { + return rows[i].Reaction.Type < rows[j].Reaction.Type + } + return rows[i].Reaction.Value() < rows[j].Reaction.Value() + }) + if len(rows) > limit { + rows = rows[:limit] + } + out := make([]domain.MessageReaction, 0, len(rows)) + for _, row := range rows { + out = append(out, row.Reaction) + } + return out, nil +} + +func (s *ChannelStore) ListRecentMessageReactions(_ context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxRecentMessageReactions { + limit = domain.MaxRecentMessageReactions + } + s.mu.RLock() + defer s.mu.RUnlock() + rows := make([]domain.RecentMessageReaction, 0, len(s.recent[userID])) + for _, row := range s.recent[userID] { + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Date != rows[j].Date { + return rows[i].Date > rows[j].Date + } + if rows[i].Reaction.Type != rows[j].Reaction.Type { + return rows[i].Reaction.Type < rows[j].Reaction.Type + } + return rows[i].Reaction.Value() < rows[j].Reaction.Value() + }) + if len(rows) > limit { + rows = rows[:limit] + } + out := make([]domain.MessageReaction, 0, len(rows)) + for _, row := range rows { + out = append(out, row.Reaction) + } + return out, nil +} + +func (s *ChannelStore) ClearRecentMessageReactions(_ context.Context, userID int64) error { + if userID == 0 { + return domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + delete(s.recent, userID) + return nil +} + +func (s *ChannelStore) ListSavedReactionTags(_ context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.SavedReactionTag{}, nil + } + if limit > domain.MaxSavedReactionTags { + limit = domain.MaxSavedReactionTags + } + s.mu.RLock() + defer s.mu.RUnlock() + rows := make([]domain.SavedReactionTag, 0, len(s.savedTags[userID])) + for _, row := range s.savedTags[userID] { + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Count != rows[j].Count { + return rows[i].Count > rows[j].Count + } + if rows[i].Reaction.Type != rows[j].Reaction.Type { + return rows[i].Reaction.Type < rows[j].Reaction.Type + } + return rows[i].Reaction.Value() < rows[j].Reaction.Value() + }) + if len(rows) > limit { + rows = rows[:limit] + } + return rows, nil +} + +func (s *ChannelStore) UpsertSavedReactionTag(_ context.Context, tag domain.SavedReactionTag) error { + if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(tag.Reaction.Emoticon) == "" { + return domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if s.savedTags[tag.UserID] == nil { + s.savedTags[tag.UserID] = make(map[string]domain.SavedReactionTag) + } + tag.Reaction.Emoticon = strings.TrimSpace(tag.Reaction.Emoticon) + if tag.Count < 0 { + tag.Count = 0 + } + s.savedTags[tag.UserID][messageReactionKey(tag.Reaction)] = tag + return nil +} + +func (s *ChannelStore) ListChannelUnreadReactions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit { + limit = domain.MaxChannelUnreadReactionsLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + base := make([]domain.ChannelMessage, 0, limit) + for msgID, byUser := range s.reactions[filter.ChannelID] { + msg, ok := s.findMessageLocked(filter.ChannelID, msgID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + if filter.TopMsgID > 0 && msg.ID != filter.TopMsgID && channelMentionTopID(msg) != filter.TopMsgID { + continue + } + if filter.MaxID > 0 && msg.ID >= filter.MaxID { + continue + } + if filter.MinID > 0 && msg.ID <= filter.MinID { + continue + } + if !channelMessageHasUnreadReactionForUser(byUser, viewerUserID) { + continue + } + base = append(base, msg) + } + sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) }) + page := pageChannelMessageHistory(base, domain.ChannelRepliesFilter{ + OffsetID: filter.OffsetID, + AddOffset: filter.AddOffset, + Limit: limit, + MaxID: filter.MaxID, + MinID: filter.MinID, + }, limit) + out := make([]domain.ChannelMessage, 0, len(page)) + for _, msg := range page { + out = append(out, cloneChannelMessage(msg)) + } + s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) + s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) + return domain.ChannelHistory{Channel: channel, Messages: out, Count: len(base)}, nil +} + +func (s *ChannelStore) ReadChannelReactions(_ context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelReactionsResult{}, err + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadReactionsBatch { + limit = domain.MaxChannelReadReactionsBatch + } + msgIDs := make([]int, 0, limit) + for msgID, byUser := range s.reactions[req.ChannelID] { + msg, ok := s.findMessageLocked(req.ChannelID, msgID) + if !ok || msg.Deleted { + continue + } + if req.TopMsgID > 0 && msg.ID != req.TopMsgID && channelMentionTopID(msg) != req.TopMsgID { + continue + } + if channelMessageHasUnreadReactionForUser(byUser, req.UserID) { + msgIDs = append(msgIDs, msgID) + } + } + sort.Sort(sort.Reverse(sort.IntSlice(msgIDs))) + if len(msgIDs) > limit { + msgIDs = msgIDs[:limit] + } + for _, msgID := range msgIDs { + for reactedUserID, rows := range s.reactions[req.ChannelID][msgID] { + changed := false + for i := range rows { + if rows[i].SenderUserID == req.UserID && rows[i].Unread { + rows[i].Unread = false + changed = true + } + } + if changed { + s.reactions[req.ChannelID][msgID][reactedUserID] = rows + } + } + } + remaining := s.countChannelUnreadReactionsLocked(req.UserID, req.ChannelID, req.TopMsgID) + s.refreshChannelUnreadReactionsDialogLocked(req.UserID, req.ChannelID) + offset := 0 + if remaining > 0 { + offset = 1 + } + return domain.ReadChannelReactionsResult{ + Channel: channel, + Cleared: len(msgIDs), + Remaining: remaining, + Offset: offset, + ChannelPts: channel.Pts, + }, nil +} + +func (s *ChannelStore) countChannelUnreadReactionsLocked(userID, channelID int64, topMsgID int) int { + count := 0 + availableMinID := 0 + if member, ok := s.members[channelID][userID]; ok { + availableMinID = member.AvailableMinID + } + for msgID, byUser := range s.reactions[channelID] { + msg, ok := s.findMessageLocked(channelID, msgID) + if !ok || msg.Deleted || msg.ID <= availableMinID { + continue + } + if topMsgID > 0 && msg.ID != topMsgID && channelMentionTopID(msg) != topMsgID { + continue + } + if channelMessageHasUnreadReactionForUser(byUser, userID) { + count++ + } + } + return count +} + +func channelMessageHasUnreadReactionForUser(byUser map[int64][]domain.ChannelMessagePeerReaction, userID int64) bool { + for _, rows := range byUser { + for _, row := range rows { + if row.SenderUserID == userID && row.UserID != userID && row.Unread { + return true + } + } + } + return false +} + +func (s *ChannelStore) refreshChannelUnreadReactionsDialogLocked(userID, channelID int64) { + if userID == 0 || channelID == 0 { + return + } + channel, ok := s.channels[channelID] + if !ok { + return + } + member, ok := s.members[channelID][userID] + if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { + return + } + dialog := s.dialogForUserLocked(userID, channel) + dialog.UnreadReactions = s.countChannelUnreadReactionsLocked(userID, channelID, 0) + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog +} + +func copyChannelReactionPolicy(in domain.ChannelReactionPolicy) domain.ChannelReactionPolicy { + in.Emoticons = append([]string(nil), in.Emoticons...) + in.CustomEmojiIDs = append([]int64(nil), in.CustomEmojiIDs...) + return in +} + +func (s *ChannelStore) populateChannelMessageReactionsLocked(viewerUserID int64, channel domain.Channel, messages []domain.ChannelMessage) { + if len(messages) == 0 || channel.ID == 0 { + return + } + s.populateChannelMessageUnreadFlagsLocked(viewerUserID, messages) + now := int(time.Now().Unix()) + for i := range messages { + if messages[i].ChannelID != channel.ID || messages[i].ID <= 0 { + continue + } + // poll 与 reaction 同点位 enrich:所有频道消息读路径经此填充 viewer 态。 + messages[i].Media = enrichPollMediaForViewer(s.polls, messages[i].Media, viewerUserID, now) + reactions := s.channelMessageReactionsLocked(viewerUserID, channel, messages[i].ID) + if len(reactions.Results) == 0 && len(reactions.Recent) == 0 { + continue + } + messages[i].Reactions = cloneChannelMessageReactionsPtr(&reactions) + } +} + +func (s *ChannelStore) populateChannelMessagesReactionsLocked(viewerUserID int64, channels []domain.Channel, messages []domain.ChannelMessage) { + if len(messages) == 0 { + return + } + s.populateChannelMessageUnreadFlagsLocked(viewerUserID, messages) + now := int(time.Now().Unix()) + channelsByID := make(map[int64]domain.Channel, len(channels)) + for _, ch := range channels { + if ch.ID != 0 { + channelsByID[ch.ID] = ch + } + } + for i := range messages { + messages[i].Media = enrichPollMediaForViewer(s.polls, messages[i].Media, viewerUserID, now) + ch := channelsByID[messages[i].ChannelID] + if ch.ID == 0 { + ch = s.channels[messages[i].ChannelID] + } + if ch.ID == 0 { + continue + } + reactions := s.channelMessageReactionsLocked(viewerUserID, ch, messages[i].ID) + if len(reactions.Results) == 0 && len(reactions.Recent) == 0 { + continue + } + messages[i].Reactions = cloneChannelMessageReactionsPtr(&reactions) + } +} + +type memoryReactionCursor struct { + date int + userID int64 + reactionType domain.MessageReactionType + value string + legacyValue bool +} + +func (s *ChannelStore) channelMessageReactionsLocked(viewerUserID int64, channel domain.Channel, messageID int) domain.ChannelMessageReactions { + rows := s.channelMessageReactionRowsLocked(channel.ID, messageID, viewerUserID, nil) + out := domain.ChannelMessageReactions{ + CanSeeList: !channel.Broadcast || channel.Megagroup, + Results: []domain.ChannelMessageReactionCount{}, + Recent: []domain.ChannelMessagePeerReaction{}, + } + // 付费 reaction 与普通 reaction 分表:即便无普通 reaction 也要回显 ReactionPaid。 + if paid := s.aggregatePaidReactionsLocked(channel.ID, messageID, viewerUserID); paid.TotalStars > 0 { + out.Paid = &paid + } + if len(rows) == 0 { + return out + } + type aggregate struct { + reaction domain.MessageReaction + count int + chosenOrder int + latestDate int + } + aggregates := make(map[string]*aggregate) + for _, row := range rows { + key := messageReactionKey(row.Reaction) + item := aggregates[key] + if item == nil { + item = &aggregate{reaction: row.Reaction} + aggregates[key] = item + } + item.count++ + if row.My && row.ChosenOrder > 0 { + item.chosenOrder = row.ChosenOrder + } + if row.Date > item.latestDate { + item.latestDate = row.Date + } + } + items := make([]aggregate, 0, len(aggregates)) + for _, item := range aggregates { + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].count != items[j].count { + return items[i].count > items[j].count + } + if items[i].latestDate != items[j].latestDate { + return items[i].latestDate > items[j].latestDate + } + return messageReactionKey(items[i].reaction) < messageReactionKey(items[j].reaction) + }) + for _, item := range items { + out.Results = append(out.Results, domain.ChannelMessageReactionCount{ + Reaction: item.reaction, + Count: item.count, + ChosenOrder: item.chosenOrder, + }) + } + // 广播频道 reaction 匿名:与官方一致只下发计数,不暴露 recent 反应者身份。 + if channel.Broadcast && !channel.Megagroup { + return out + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Date != rows[j].Date { + return rows[i].Date > rows[j].Date + } + if rows[i].UserID != rows[j].UserID { + return rows[i].UserID > rows[j].UserID + } + return messageReactionKey(rows[i].Reaction) < messageReactionKey(rows[j].Reaction) + }) + if len(rows) > domain.MaxChannelMessageReactionRecent { + rows = rows[:domain.MaxChannelMessageReactionRecent] + } + out.Recent = cloneChannelPeerReactions(rows) + return out +} + +func (s *ChannelStore) channelMessageReactionRowsLocked(channelID int64, messageID int, viewerUserID int64, filter *domain.MessageReaction) []domain.ChannelMessagePeerReaction { + byMessage := s.reactions[channelID] + if byMessage == nil { + return nil + } + byUser := byMessage[messageID] + if byUser == nil { + return nil + } + rows := make([]domain.ChannelMessagePeerReaction, 0, len(byUser)) + for _, userRows := range byUser { + for _, row := range userRows { + if filter != nil && row.Reaction.Key() != filter.Key() { + continue + } + row.My = row.UserID == viewerUserID + rows = append(rows, row) + } + } + return rows +} + +func memoryReactionOffset(row domain.ChannelMessagePeerReaction) string { + return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + string(row.Reaction.Type) + ":" + row.Reaction.Value() +} + +func messageReactionKey(reaction domain.MessageReaction) string { + return reaction.Key() +} + +func parseMemoryReactionOffset(offset string) (memoryReactionCursor, bool) { + parts := strings.SplitN(offset, ":", 4) + if len(parts) != 3 && len(parts) != 4 { + return memoryReactionCursor{}, false + } + date, err := strconv.Atoi(parts[0]) + if err != nil || date < 0 { + return memoryReactionCursor{}, false + } + userID, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || userID < 0 { + return memoryReactionCursor{}, false + } + if len(parts) == 3 { + return memoryReactionCursor{date: date, userID: userID, value: parts[2], legacyValue: true}, true + } + return memoryReactionCursor{date: date, userID: userID, reactionType: domain.MessageReactionType(parts[2]), value: parts[3]}, true +} + +func memoryReactionAfterOffset(row domain.ChannelMessagePeerReaction, cursor memoryReactionCursor) bool { + if row.Date != cursor.date { + return row.Date < cursor.date + } + if row.UserID != cursor.userID { + return row.UserID < cursor.userID + } + if cursor.legacyValue { + return row.Reaction.Value() > cursor.value + } + if row.Reaction.Type != cursor.reactionType { + return row.Reaction.Type > cursor.reactionType + } + return row.Reaction.Value() > cursor.value +} + +func cloneChannelMessageReactionsPtr(in *domain.ChannelMessageReactions) *domain.ChannelMessageReactions { + if in == nil { + return nil + } + out := cloneChannelMessageReactions(*in) + return &out +} + +func cloneChannelMessageReactions(in domain.ChannelMessageReactions) domain.ChannelMessageReactions { + in.Results = append([]domain.ChannelMessageReactionCount(nil), in.Results...) + in.Recent = cloneChannelPeerReactions(in.Recent) + if in.Paid != nil { + paid := *in.Paid + paid.TopReactors = append([]domain.PaidReactor(nil), in.Paid.TopReactors...) + in.Paid = &paid + } + return in +} + +func cloneChannelPeerReactions(in []domain.ChannelMessagePeerReaction) []domain.ChannelMessagePeerReaction { + if len(in) == 0 { + return nil + } + return append([]domain.ChannelMessagePeerReaction(nil), in...) +} diff --git a/internal/store/memory/channel_read.go b/internal/store/memory/channel_read.go new file mode 100644 index 00000000..b69ae7ae --- /dev/null +++ b/internal/store/memory/channel_read.go @@ -0,0 +1,573 @@ +package memory + +import ( + "context" + "sort" + "telesrv/internal/domain" +) + +func (s *ChannelStore) SetChannelDialogUnreadMark(_ context.Context, userID, channelID int64, unread bool) (bool, error) { + if userID == 0 || channelID == 0 { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return false, nil + } + dialog := s.dialogForUserLocked(userID, channel) + changed := dialog.UnreadMark != unread + dialog.UnreadMark = unread + member := s.members[channelID][userID] + member.UnreadMark = unread + s.members[channelID][userID] = member + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog + return changed, nil +} + +func (s *ChannelStore) ListChannelUnreadMarked(_ context.Context, userID int64) ([]domain.Peer, error) { + if userID == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Peer, 0, len(s.dialogs[userID])) + for channelID, dialog := range s.dialogs[userID] { + if !dialog.UnreadMark { + continue + } + if _, err := s.channelForMemberLocked(userID, channelID); err != nil { + continue + } + out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (s *ChannelStore) ReadChannelMessageContents(_ context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + if len(req.IDs) == 0 { + return domain.ReadChannelMessageContentsResult{Channel: channel}, nil + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + wanted := make(map[int]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ReadChannelMessageContentsResult{}, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + messages := make([]domain.ChannelMessage, 0, len(wanted)) + for _, msg := range s.messages[req.ChannelID] { + if _, ok := wanted[msg.ID]; !ok { + continue + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + messages = append(messages, cloneChannelMessage(msg)) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) + clearedSet := make(map[int]struct{}) + for _, msg := range messages { + byUser := s.reactions[req.ChannelID][msg.ID] + if len(byUser) == 0 { + continue + } + for reactedUserID, rows := range byUser { + changed := false + for i := range rows { + if rows[i].SenderUserID == req.UserID && rows[i].UserID != req.UserID && rows[i].Unread { + rows[i].Unread = false + changed = true + clearedSet[msg.ID] = struct{}{} + } + } + if changed { + byUser[reactedUserID] = rows + } + } + } + cleared := make([]int, 0, len(clearedSet)) + for id := range clearedSet { + cleared = append(cleared, id) + } + sort.Sort(sort.Reverse(sort.IntSlice(cleared))) + if len(cleared) > 0 { + s.refreshChannelUnreadReactionsDialogLocked(req.UserID, req.ChannelID) + } + // 视口内容已读同步翻转 mention 未读:客户端本地已减计数,服务端 + // 不落库角标会在下一次 getDialogs 复活。 + clearedMentions := make([]int, 0, len(messages)) + mentionFlipped := false + for _, msg := range messages { + mention, ok := s.mentions[req.UserID][req.ChannelID][msg.ID] + if !ok || !mention.unread { + continue + } + mention.unread = false + s.mentions[req.UserID][req.ChannelID][msg.ID] = mention + clearedMentions = append(clearedMentions, msg.ID) + mentionFlipped = true + } + sort.Ints(clearedMentions) + if mentionFlipped { + if dialogs := s.dialogs[req.UserID]; dialogs != nil { + dialog := dialogs[req.ChannelID] + dialog.UserID = req.UserID + dialog.ChannelID = req.ChannelID + dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, 0) + dialogs[req.ChannelID] = dialog + } + } + s.populateChannelMessageRepliesLocked(req.UserID, req.ChannelID, messages) + s.populateChannelMessageReactionsLocked(req.UserID, channel, messages) + return domain.ReadChannelMessageContentsResult{ + Channel: channel, + Messages: messages, + ClearedUnreadReactionMessageIDs: cleared, + ClearedUnreadMentionMessageIDs: clearedMentions, + }, nil +} + +func (s *ChannelStore) ListChannelUnreadMentions(_ context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelUnreadMentionsLimit { + limit = domain.MaxChannelUnreadMentionsLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + base := make([]domain.ChannelMessage, 0, limit) + for msgID, mention := range s.mentions[viewerUserID][filter.ChannelID] { + if !mention.unread { + continue + } + if filter.TopMsgID > 0 && mention.topID != filter.TopMsgID && !(filter.TopMsgID == 1 && mention.topID == 0) { + continue + } + msg, ok := s.findMessageLocked(filter.ChannelID, msgID) + if !ok || msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + if filter.MaxID > 0 && msg.ID >= filter.MaxID { + continue + } + if filter.MinID > 0 && msg.ID <= filter.MinID { + continue + } + base = append(base, msg) + } + sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) }) + page := pageChannelMessageHistory(base, domain.ChannelRepliesFilter{ + OffsetID: filter.OffsetID, + OffsetDate: filter.OffsetDate, + AddOffset: filter.AddOffset, + Limit: limit, + MaxID: filter.MaxID, + MinID: filter.MinID, + }, limit) + out := make([]domain.ChannelMessage, 0, len(page)) + for _, msg := range page { + out = append(out, cloneChannelMessage(msg)) + } + s.populateChannelMessageRepliesLocked(viewerUserID, filter.ChannelID, out) + s.populateChannelMessageReactionsLocked(viewerUserID, channel, out) + return domain.ChannelHistory{Channel: channel, Messages: out, Count: len(base)}, nil +} + +func (s *ChannelStore) ReadChannelMentions(_ context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelMentionsResult{}, err + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadMentionsBatch { + limit = domain.MaxChannelReadMentionsBatch + } + msgIDs := make([]int, 0, limit) + for msgID, mention := range s.mentions[req.UserID][req.ChannelID] { + if !mention.unread { + continue + } + if req.TopMsgID > 0 && mention.topID != req.TopMsgID && !(req.TopMsgID == 1 && mention.topID == 0) { + continue + } + msgIDs = append(msgIDs, msgID) + } + sort.Sort(sort.Reverse(sort.IntSlice(msgIDs))) + if len(msgIDs) > limit { + msgIDs = msgIDs[:limit] + } + for _, msgID := range msgIDs { + mention := s.mentions[req.UserID][req.ChannelID][msgID] + mention.unread = false + s.mentions[req.UserID][req.ChannelID][msgID] = mention + } + remaining := s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, req.TopMsgID) + if dialogs := s.dialogs[req.UserID]; dialogs != nil { + dialog := dialogs[req.ChannelID] + dialog.UnreadMentions = s.countChannelUnreadMentionsLocked(req.UserID, req.ChannelID, 0) + dialog.UserID = req.UserID + dialog.ChannelID = req.ChannelID + dialogs[req.ChannelID] = dialog + } + offset := 0 + if remaining > 0 { + offset = 1 + } + return domain.ReadChannelMentionsResult{ + Channel: channel, + Cleared: len(msgIDs), + Remaining: remaining, + Offset: offset, + ChannelPts: channel.Pts, + }, nil +} + +func (s *ChannelStore) ReadChannelHistory(_ context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelHistoryResult{}, err + } + maxID := req.MaxID + if maxID <= 0 || maxID > channel.TopMessageID { + maxID = channel.TopMessageID + } + member := s.members[req.ChannelID][req.UserID] + previous := member.ReadInboxMaxID + changed := maxID > member.ReadInboxMaxID + var outboxUpdates []domain.ChannelReadOutboxUpdate + if changed { + member.ReadInboxMaxID = maxID + member.ReadInboxDate = req.Date + member.UnreadMark = false + s.members[req.ChannelID][req.UserID] = member + s.readMarks[req.ChannelID] = s.readMarks[req.ChannelID].advance(req.UserID, maxID) + outboxUpdates = s.advanceChannelReadOutboxLocked(req.ChannelID, req.UserID, previous, maxID) + } + dialog := s.dialogForUserLocked(req.UserID, channel) + dialog.ReadInboxMaxID = member.ReadInboxMaxID + dialog.UnreadCount = s.channelUnreadCountLocked(req.UserID, channel.ID, member.ReadInboxMaxID, dialog.TopMessageID) + dialog.UnreadMark = false + if s.dialogs[req.UserID] == nil { + s.dialogs[req.UserID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[req.UserID][req.ChannelID] = dialog + return domain.ReadChannelHistoryResult{ + ChannelID: req.ChannelID, + MaxID: maxID, + StillUnreadCount: dialog.UnreadCount, + Changed: changed, + Pts: channel.Pts, + Forum: channel.Forum, + Dialog: dialog, + OutboxUpdates: outboxUpdates, + }, nil +} + +func (s *ChannelStore) advanceChannelReadOutboxLocked(channelID, readerUserID int64, previous, maxID int) []domain.ChannelReadOutboxUpdate { + if maxID <= previous { + return nil + } + lowerID := previous + if maxID-lowerID > domain.MaxChannelReadOutboxScanMessages { + lowerID = maxID - domain.MaxChannelReadOutboxScanMessages + } + bySender := make(map[int64]int, domain.MaxChannelReadOutboxFanout) + messages := s.messages[channelID] + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.ID <= lowerID { + break + } + if msg.ID > maxID || msg.Deleted || msg.SenderUserID == 0 || msg.SenderUserID == readerUserID { + continue + } + if _, ok := bySender[msg.SenderUserID]; ok { + continue + } + bySender[msg.SenderUserID] = msg.ID + if len(bySender) >= domain.MaxChannelReadOutboxFanout { + break + } + } + if len(bySender) == 0 { + return nil + } + senderIDs := make([]int64, 0, len(bySender)) + for userID := range bySender { + senderIDs = append(senderIDs, userID) + } + sort.Slice(senderIDs, func(i, j int) bool { return senderIDs[i] < senderIDs[j] }) + channel := s.channels[channelID] + out := make([]domain.ChannelReadOutboxUpdate, 0, len(senderIDs)) + for _, userID := range senderIDs { + maxForSender := bySender[userID] + member, ok := s.members[channelID][userID] + if !ok || member.Status != domain.ChannelMemberActive || maxForSender <= member.ReadOutboxMaxID { + continue + } + member.ReadOutboxMaxID = maxForSender + s.members[channelID][userID] = member + dialog := s.dialogForUserLocked(userID, channel) + if dialog.ReadOutboxMaxID < maxForSender { + dialog.ReadOutboxMaxID = maxForSender + } + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog + out = append(out, domain.ChannelReadOutboxUpdate{UserID: userID, MaxID: maxForSender}) + } + return out +} + +func (s *ChannelStore) ListMessageReadParticipants(_ context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelReadParticipantsResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + msg, found := s.findMessageLocked(req.ChannelID, req.MessageID) + if !found || msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid + } + result := domain.ChannelReadParticipantsResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + } + if !channel.Megagroup || channel.ParticipantsHidden || channel.ParticipantsCount > domain.MaxChannelReadParticipants { + return result, nil + } + now := req.Date + if now > 0 && msg.Date+domain.ChannelReadMarkExpirePeriod <= now { + return result, nil + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadParticipants { + limit = domain.MaxChannelReadParticipants + } + for _, reader := range s.members[req.ChannelID] { + if reader.UserID == req.UserID || reader.Status != domain.ChannelMemberActive || reader.BannedRights.ViewMessages { + continue + } + if reader.ReadInboxDate <= 0 { + continue + } + if reader.AvailableMinID >= req.MessageID || reader.ReadInboxMaxID < req.MessageID { + continue + } + result.Participants = append(result.Participants, domain.ChannelReadParticipant{ + UserID: reader.UserID, + Date: reader.ReadInboxDate, + }) + if len(result.Participants) >= limit { + break + } + } + sort.Slice(result.Participants, func(i, j int) bool { + if result.Participants[i].Date == result.Participants[j].Date { + return result.Participants[i].UserID < result.Participants[j].UserID + } + return result.Participants[i].Date < result.Participants[j].Date + }) + return result, nil +} + +func (s *ChannelStore) channelThreadUnreadCountLocked(viewerUserID, channelID int64, rootID, readMaxID int) int { + unread := 0 + for _, msg := range s.messages[channelID] { + if msg.Deleted || msg.ID <= readMaxID || msg.SenderUserID == viewerUserID { + continue + } + if channelReplyBelongsToRoot(msg, channelID, rootID) { + unread++ + // 钳到 MaxDialogUnreadCount(P1-v),与 postgres 的 LIMIT 子查询同 min(actual,cap) 语义。 + if unread >= domain.MaxDialogUnreadCount { + break + } + } + } + return unread +} + +func (s *ChannelStore) channelUnreadCountLocked(viewerUserID, channelID int64, readMaxID, topID int) int { + if viewerUserID == 0 || channelID == 0 || topID <= readMaxID { + return 0 + } + unread := 0 + for _, msg := range s.messages[channelID] { + if msg.Deleted || msg.ID <= readMaxID || msg.ID > topID || msg.SenderUserID == viewerUserID { + continue + } + unread++ + // 钳到 MaxDialogUnreadCount(P1-v),与 postgres 的 LIMIT 子查询同 min(actual,cap) 语义。 + if unread >= domain.MaxDialogUnreadCount { + break + } + } + return unread +} + +func (s *ChannelStore) addChannelUnreadMentionsLocked(channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) { + if len(userIDs) == 0 || msg.ID == 0 { + return + } + seen := make(map[int64]struct{}, len(userIDs)) + written := 0 + topID := channelMentionTopID(msg) + for _, userID := range userIDs { + if userID == 0 || userID == senderUserID { + continue + } + if _, ok := seen[userID]; ok { + continue + } + seen[userID] = struct{}{} + member, ok := s.members[channelID][userID] + if !ok || member.Status != domain.ChannelMemberActive || member.BannedRights.ViewMessages { + continue + } + if msg.ID <= member.AvailableMinID || msg.ID <= member.ReadInboxMaxID { + continue + } + if s.mentions[userID] == nil { + s.mentions[userID] = make(map[int64]map[int]memoryMention) + } + if s.mentions[userID][channelID] == nil { + s.mentions[userID][channelID] = make(map[int]memoryMention) + } + s.mentions[userID][channelID][msg.ID] = memoryMention{topID: topID, unread: true} + written++ + if written == domain.MaxChannelMentionRecipients { + return + } + } +} + +func (s *ChannelStore) countChannelUnreadMentionsLocked(userID, channelID int64, topMsgID int) int { + count := 0 + for _, mention := range s.mentions[userID][channelID] { + if !mention.unread { + continue + } + if topMsgID == 0 || mention.topID == topMsgID || (topMsgID == 1 && mention.topID == 0) { + count++ + } + } + return count +} + +func (s *ChannelStore) deleteChannelUnreadMentionsLocked(channelID int64, ids []int) { + if len(ids) == 0 { + return + } + set := make(map[int]struct{}, len(ids)) + for _, id := range ids { + set[id] = struct{}{} + } + for userID, byChannel := range s.mentions { + mentions := byChannel[channelID] + if len(mentions) == 0 { + continue + } + for id := range set { + delete(mentions, id) + } + if len(mentions) == 0 { + delete(byChannel, channelID) + } + if len(byChannel) == 0 { + delete(s.mentions, userID) + } + } +} + +func (s *ChannelStore) deleteChannelUnreadMentionsUpToLocked(userID, channelID int64, maxID int) { + if maxID <= 0 || len(s.mentions[userID][channelID]) == 0 { + return + } + for id := range s.mentions[userID][channelID] { + if id <= maxID { + delete(s.mentions[userID][channelID], id) + } + } + if len(s.mentions[userID][channelID]) == 0 { + delete(s.mentions[userID], channelID) + } + if len(s.mentions[userID]) == 0 { + delete(s.mentions, userID) + } +} + +func channelMentionTopID(msg domain.ChannelMessage) int { + if msg.ReplyTo == nil { + return 0 + } + if msg.ReplyTo.TopMessageID > 0 { + return msg.ReplyTo.TopMessageID + } + return msg.ReplyTo.MessageID +} + +func (s *ChannelStore) populateChannelMessageUnreadFlagsLocked(viewerUserID int64, messages []domain.ChannelMessage) { + if viewerUserID == 0 || len(messages) == 0 { + return + } + for i := range messages { + if messages[i].ChannelID == 0 || messages[i].ID <= 0 { + continue + } + mention, ok := s.mentions[viewerUserID][messages[i].ChannelID][messages[i].ID] + if !ok { + continue + } + messages[i].Mentioned = true + messages[i].MediaUnread = mention.unread + } +} + +// clearChannelMentionsForUserLocked 在离开/被踢时清空该用户的提及状态。 +func (s *ChannelStore) clearChannelMentionsForUserLocked(channelID, userID int64) { + if byChannel := s.mentions[userID]; byChannel != nil { + delete(byChannel, channelID) + if len(byChannel) == 0 { + delete(s.mentions, userID) + } + } + if dialogs := s.dialogs[userID]; dialogs != nil { + if dialog, ok := dialogs[channelID]; ok { + dialog.UnreadMentions = 0 + dialogs[channelID] = dialog + } + } +} diff --git a/internal/store/memory/channel_read_clamp_test.go b/internal/store/memory/channel_read_clamp_test.go new file mode 100644 index 00000000..f06b754f --- /dev/null +++ b/internal/store/memory/channel_read_clamp_test.go @@ -0,0 +1,49 @@ +package memory + +import ( + "testing" + + "telesrv/internal/domain" +) + +// TestChannelUnreadCountClampedToMax 锁定 P1-v:未读 COUNT 钳到 MaxDialogUnreadCount(min(actual,cap)), +// 与 postgres 的 LIMIT 子查询同语义。直接喂 s.messages 避免 1000+ 次 send 建链路。 +func TestChannelUnreadCountClampedToMax(t *testing.T) { + const ( + channelID = int64(7000000001) + viewer = int64(5001) + sender = int64(5002) + ) + s := NewChannelStore() + + over := domain.MaxDialogUnreadCount + 25 + msgs := make([]domain.ChannelMessage, 0, over) + for i := 1; i <= over; i++ { + msgs = append(msgs, domain.ChannelMessage{ChannelID: channelID, ID: i, SenderUserID: sender, ReplyTo: &domain.MessageReply{TopMessageID: 100}}) + } + s.mu.Lock() + s.messages[channelID] = msgs + s.mu.Unlock() + + // 全量未读(readMaxID=0, topID 覆盖全部)→ 应钳到 cap,而非 over。 + if got := s.channelUnreadCountLocked(viewer, channelID, 0, over); got != domain.MaxDialogUnreadCount { + t.Fatalf("channelUnreadCountLocked = %d, want clamped %d", got, domain.MaxDialogUnreadCount) + } + // thread 未读同样钳到 cap(全部消息挂在 root=100 线程)。 + if got := s.channelThreadUnreadCountLocked(viewer, channelID, 100, 0); got != domain.MaxDialogUnreadCount { + t.Fatalf("channelThreadUnreadCountLocked = %d, want clamped %d", got, domain.MaxDialogUnreadCount) + } + + // 未超上界时返回精确值(cap 内不被钳)。 + const fewChannel = int64(7000000002) + few := make([]domain.ChannelMessage, 0, 3) + for i := 1; i <= 3; i++ { + few = append(few, domain.ChannelMessage{ChannelID: fewChannel, ID: i, SenderUserID: sender}) + } + s.mu.Lock() + s.messages[fewChannel] = few + s.mu.Unlock() + if got := s.channelUnreadCountLocked(viewer, fewChannel, 0, 3); got != 3 { + t.Fatalf("channelUnreadCountLocked(few) = %d, want exact 3 (below cap unaffected)", got) + } +} diff --git a/internal/store/memory/channel_settings.go b/internal/store/memory/channel_settings.go new file mode 100644 index 00000000..8f9cd114 --- /dev/null +++ b/internal/store/memory/channel_settings.go @@ -0,0 +1,690 @@ +package memory + +import ( + "context" + "sort" + "strings" + "telesrv/internal/domain" + "time" +) + +func (s *ChannelStore) EditChannelTitle(_ context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" { + return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelTitleResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canChangeChannelInfo(member) { + return domain.EditChannelTitleResult{}, domain.ErrChannelAdminRequired + } + title := strings.TrimSpace(req.Title) + if channel.Title == title { + return domain.EditChannelTitleResult{}, domain.ErrChannelNotModified + } + prevTitle := channel.Title + channel.Title = title + msg, event := s.appendChannelServiceMessageLocked(req.ChannelID, req.UserID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionEditTitle, + Title: title, + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[req.ChannelID] = channel + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogChangeTitle, + PrevString: prevTitle, + NewString: title, + }) + s.upsertChannelDialogLocked(req.UserID, channel, msg, true) + return domain.EditChannelTitleResult{ + Channel: channel, + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) SetChannelWallpaper(_ context.Context, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + wallpaper := domain.CloneWallpaperPtr(req.Wallpaper) + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.SetChannelWallpaperResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canChangeChannelInfo(member) { + return domain.SetChannelWallpaperResult{}, domain.ErrChannelAdminRequired + } + if domain.WallpaperEqual(channel.Wallpaper, wallpaper) { + return domain.SetChannelWallpaperResult{Channel: cloneChannel(channel)}, nil + } + channel.Wallpaper = domain.CloneWallpaperPtr(wallpaper) + if wallpaper == nil { + s.channels[req.ChannelID] = channel + return domain.SetChannelWallpaperResult{ + Channel: cloneChannel(channel), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Changed: true, + }, nil + } + msg, event := s.appendChannelServiceMessageLocked(req.ChannelID, req.UserID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionSetChatWallpaper, + Wallpaper: domain.CloneWallpaperPtr(wallpaper), + }) + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + s.channels[req.ChannelID] = channel + s.upsertChannelDialogLocked(req.UserID, channel, msg, true) + return domain.SetChannelWallpaperResult{ + Channel: cloneChannel(channel), + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Changed: true, + }, nil +} + +func (s *ChannelStore) EditChannelAbout(_ context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.About = req.About + s.channels[req.ChannelID] = channel + return channel, nil +} + +func (s *ChannelStore) CheckUsername(_ context.Context, userID, channelID int64, username string) (bool, error) { + if userID == 0 || channelID == 0 || strings.TrimSpace(username) == "" { + return false, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, err := s.channelForMemberLocked(userID, channelID); err != nil { + return false, err + } + usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + for id, channel := range s.channels { + if channel.Deleted || channel.Username == "" { + continue + } + if strings.ToLower(channel.Username) == usernameLower && id != channelID { + return false, nil + } + } + return true, nil +} + +func (s *ChannelStore) UpdateUsername(_ context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[req.ChannelID][req.UserID] + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) + usernameLower := strings.ToLower(username) + if strings.EqualFold(channel.Username, username) { + return domain.Channel{}, domain.ErrChannelNotModified + } + if usernameLower != "" { + for id, existing := range s.channels { + if existing.Deleted || existing.Username == "" { + continue + } + if strings.ToLower(existing.Username) == usernameLower && id != req.ChannelID { + return domain.Channel{}, domain.ErrUsernameOccupied + } + } + } + prevUsername := channel.Username + channel.Username = username + s.channels[req.ChannelID] = channel + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogChangeUsername, + PrevString: prevUsername, + NewString: username, + }) + return channel, nil +} + +func (s *ChannelStore) SetChannelVerified(_ context.Context, channelID int64, verified bool) (domain.Channel, error) { + if channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.Channel{}, domain.ErrChannelInvalid + } + channel.Verified = verified + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) ResolvePublicChannelUsername(_ context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) { + if viewerUserID == 0 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + if username == "" { + return domain.Channel{}, false, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + + for _, channel := range s.channels { + if !publicSearchableChannel(channel) { + continue + } + if strings.ToLower(channel.Username) == username { + return cloneChannel(channel), true, nil + } + } + return domain.Channel{}, false, nil +} + +func (s *ChannelStore) SetChannelPhoto(_ context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if photo != nil && photo.ID != 0 { + channel.PhotoID = photo.ID + channel.PhotoDCID = photo.DCID + channel.PhotoStripped = domain.StrippedFromSizes(photo.Sizes) + } else { + channel.PhotoID = 0 + channel.PhotoDCID = 0 + channel.PhotoStripped = nil + } + s.channels[channelID] = channel + return channel, nil +} + +func (s *ChannelStore) SetSignatures(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.Signatures + channel.Signatures = enabled + s.channels[channelID] = channel + if prev != enabled { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogToggleSignatures, + PrevBool: prev, + NewBool: enabled, + }) + } + return channel, nil +} + +func (s *ChannelStore) SetAutotranslation(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.Autotranslation + channel.Autotranslation = enabled + s.channels[channelID] = channel + if prev != enabled { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogToggleAutotranslation, + PrevBool: prev, + NewBool: enabled, + }) + } + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetRestrictedSponsored(_ context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.RestrictedSponsored = restricted + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetAntiSpam(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !channel.Megagroup || !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.AntiSpam + channel.AntiSpam = enabled + s.channels[channelID] = channel + if prev != enabled { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogToggleAntiSpam, + PrevBool: prev, + NewBool: enabled, + }) + } + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetSlowMode(_ context.Context, userID, channelID int64, seconds int) (domain.Channel, error) { + if userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.SlowmodeSeconds + channel.SlowmodeSeconds = seconds + s.channels[channelID] = channel + if prev != seconds { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogToggleSlowMode, + PrevInt: prev, + NewInt: seconds, + }) + } + return channel, nil +} + +func (s *ChannelStore) SetBoostsToUnblockRestrictions(_ context.Context, userID, channelID int64, boosts int) (domain.Channel, error) { + if userID == 0 || channelID == 0 || boosts < 0 || boosts > domain.MaxChannelBoostsToUnblockRestrictions { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.BoostsUnrestrict = boosts + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetNoForwards(_ context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.NoForwards = enabled + s.channels[channelID] = channel + return channel, nil +} + +func (s *ChannelStore) SetColor(_ context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if forProfile { + channel.ProfileColor = color + } else { + channel.Color = color + } + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetEmojiStatus(_ context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + channel.EmojiStatus = status + s.channels[channelID] = channel + return cloneChannel(channel), nil +} + +func (s *ChannelStore) ListChannelRecommendations(_ context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) { + if req.UserID == 0 || req.SourceChannelID < 0 { + return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelRecommendationsLimit { + limit = domain.DefaultChannelRecommendationsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + + items := make([]domain.Channel, 0, limit) + for channelID, channel := range s.channels { + if !recommendableChannel(channel) || channelID == req.SourceChannelID { + continue + } + if req.SourceChannelID == 0 { + if member, ok := s.members[channelID][req.UserID]; ok && member.Status == domain.ChannelMemberActive { + continue + } + } + items = append(items, cloneChannel(channel)) + } + sort.Slice(items, func(i, j int) bool { + if items[i].ParticipantsCount != items[j].ParticipantsCount { + return items[i].ParticipantsCount > items[j].ParticipantsCount + } + if items[i].Date != items[j].Date { + return items[i].Date > items[j].Date + } + return items[i].ID > items[j].ID + }) + out := domain.ChannelRecommendationsResult{Count: len(items)} + if len(items) > limit { + items = items[:limit] + } + out.Channels = append(out.Channels, items...) + return out, nil +} + +func (s *ChannelStore) ListDiscussionGroups(_ context.Context, userID int64, limit int) ([]domain.Channel, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxDiscussionGroupsLimit { + limit = domain.MaxDiscussionGroupsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + + items := make([]domain.Channel, 0, limit) + for channelID, channel := range s.channels { + if !validDiscussionGroup(channel) || channel.Deleted { + continue + } + member := s.members[channelID][userID] + if member.Status != domain.ChannelMemberActive || !canManageDiscussionGroup(member) { + continue + } + items = append(items, cloneChannel(channel)) + } + sort.Slice(items, func(i, j int) bool { + return items[i].ID > items[j].ID + }) + if len(items) > limit { + items = items[:limit] + } + return items, nil +} + +func (s *ChannelStore) SetDiscussionGroup(_ context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) { + if userID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid + } + if broadcastID == 0 && groupID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + s.mu.Lock() + defer s.mu.Unlock() + + changed := make(map[int64]domain.Channel) + markChanged := func(channel domain.Channel) { + if channel.ID != 0 { + changed[channel.ID] = cloneChannel(channel) + } + } + setLinked := func(channelID, linkedID int64) (domain.Channel, bool) { + channel, ok := s.channels[channelID] + if !ok || channel.Deleted { + return domain.Channel{}, false + } + if channel.LinkedChatID == linkedID { + return channel, true + } + channel.LinkedChatID = linkedID + s.channels[channelID] = channel + markChanged(channel) + return channel, true + } + + if broadcastID == 0 { + group, groupMember, err := s.channelAndMemberLocked(userID, groupID) + if err != nil || !validDiscussionGroup(group) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid + } + if !canManageDiscussionGroup(groupMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + oldBroadcastID := group.LinkedChatID + if oldBroadcastID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + if oldBroadcast, ok := s.channels[oldBroadcastID]; ok && oldBroadcast.LinkedChatID == groupID { + if updated, ok := setLinked(oldBroadcastID, 0); ok { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: updated.ID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogChangeLinkedChat, + PrevInt: int(groupID), + NewInt: 0, + }) + } + } + setLinked(groupID, 0) + return discussionGroupUpdateResult(changed), nil + } + + broadcast, broadcastMember, err := s.channelAndMemberLocked(userID, broadcastID) + if err != nil || !broadcast.Broadcast || broadcast.Megagroup { + return domain.DiscussionGroupUpdateResult{}, domain.ErrBroadcastIDInvalid + } + if !canManageDiscussionBroadcast(broadcastMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + oldGroupID := broadcast.LinkedChatID + if groupID == 0 { + if oldGroupID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + updated, _ := setLinked(broadcastID, 0) + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: updated.ID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogChangeLinkedChat, + PrevInt: int(oldGroupID), + NewInt: 0, + }) + if oldGroup, ok := s.channels[oldGroupID]; ok && oldGroup.LinkedChatID == broadcastID { + setLinked(oldGroupID, 0) + } + return discussionGroupUpdateResult(changed), nil + } + + group, groupMember, err := s.channelAndMemberLocked(userID, groupID) + if err != nil || !validDiscussionGroup(group) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid + } + if group.PreHistoryHidden { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupPrehistoryHidden + } + if !canManageDiscussionGroup(groupMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + if oldGroupID == groupID && group.LinkedChatID == broadcastID { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + oldBroadcastID := group.LinkedChatID + if oldGroupID != 0 && oldGroupID != groupID { + if oldGroup, ok := s.channels[oldGroupID]; ok && oldGroup.LinkedChatID == broadcastID { + setLinked(oldGroupID, 0) + } + } + if oldBroadcastID != 0 && oldBroadcastID != broadcastID { + if oldBroadcast, ok := s.channels[oldBroadcastID]; ok && oldBroadcast.LinkedChatID == groupID { + if updated, ok := setLinked(oldBroadcastID, 0); ok { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: updated.ID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogChangeLinkedChat, + PrevInt: int(groupID), + NewInt: 0, + }) + } + } + } + updatedBroadcast, _ := setLinked(broadcastID, groupID) + setLinked(groupID, broadcastID) + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: updatedBroadcast.ID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogChangeLinkedChat, + PrevInt: int(oldGroupID), + NewInt: int(groupID), + }) + return discussionGroupUpdateResult(changed), nil +} + +func validDiscussionGroup(channel domain.Channel) bool { + return channel.Megagroup && !channel.Broadcast && !channel.Forum && !channel.Deleted +} + +func channelSlowModeWait(channel domain.Channel, member domain.ChannelMember, now int) int { + if channel.SlowmodeSeconds <= 0 || member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + return 0 + } + next := member.SlowmodeLastSendDate + channel.SlowmodeSeconds + if now >= next { + return 0 + } + return next - now +} + +func cloneChannelDiscussionRef(in *domain.ChannelDiscussionRef) *domain.ChannelDiscussionRef { + if in == nil { + return nil + } + out := *in + return &out +} diff --git a/internal/store/memory/channel_store.go b/internal/store/memory/channel_store.go new file mode 100644 index 00000000..af8fbae7 --- /dev/null +++ b/internal/store/memory/channel_store.go @@ -0,0 +1,129 @@ +package memory + +import ( + "sync" + "telesrv/internal/domain" +) + +const firstMemoryChannelID int64 = 2000000000 + +type channelRandomKey struct { + channelID int64 + userID int64 + randomID int64 +} + +type boostSlotKey struct { + userID int64 + slot int +} + +// channelReadWatermark 是 channel 级公共已读水位:任一成员推进过的最高两个 +// read_inbox。sender 的 read_outbox 由它派生(top1 持有者本人取 top2)。 +// memoryMention 是 owner 视角一条 mention 的状态:topID 支持 topic 过滤, +// unread 翻转为 false 表示已读但 mentioned 高亮永久保留。 +type memoryMention struct { + topID int + unread bool +} + +type channelReadWatermark struct { + top1User int64 + top1 int + top2 int +} + +func (w channelReadWatermark) forSender(userID int64) int { + if w.top1User == userID { + return w.top2 + } + return w.top1 +} + +func (w channelReadWatermark) advance(userID int64, maxID int) channelReadWatermark { + switch { + case w.top1User == userID: + if maxID > w.top1 { + w.top1 = maxID + } + case maxID >= w.top1: + w.top2 = w.top1 + w.top1User = userID + w.top1 = maxID + case maxID > w.top2: + w.top2 = maxID + } + return w +} + +// ChannelStore is an in-memory channel/supergroup store for tests and local development. +type ChannelStore struct { + mu sync.RWMutex + nextID int64 + nextHash int64 + channels map[int64]domain.Channel + members map[int64]map[int64]domain.ChannelMember + dialogs map[int64]map[int64]domain.ChannelDialog + topics map[int64]map[int]domain.ChannelForumTopic + messages map[int64][]domain.ChannelMessage + reactions map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction + // paidReactions 是 per-(channel,message,user) 付费 reaction 累计星数 + 匿名标志。 + paidReactions map[int64]map[int]map[int64]memoryPaidReaction + top map[int64]map[string]domain.TopMessageReaction + recent map[int64]map[string]domain.RecentMessageReaction + savedTags map[int64]map[string]domain.SavedReactionTag + mentions map[int64]map[int64]map[int]memoryMention + msgViews map[int64]map[int]int + msgViewers map[int64]map[int]map[int64]struct{} + events map[int64][]domain.ChannelUpdateEvent + adminLogs map[int64][]domain.ChannelAdminLogEvent + invites map[string]domain.ChannelInvite + importers map[int64]map[int64]domain.ChannelInviteImporter + msgSeq map[int64]int + ptsSeq map[int64]int + logSeq map[int64]int64 + randomToID map[channelRandomKey]int + boostSlots map[boostSlotKey]domain.PremiumBoostSlot + readMarks map[int64]channelReadWatermark + // topicReads 是 per-(channel,user,topic) 已读水位(forum 话题独立已读,不碰频道级 member 水位)。 + topicReads map[int64]map[int64]map[int]memoryTopicRead + // polls 是共享 poll 权威(与 MessageStore 同一实例);nil 时 poll 链路按未接入处理。 + polls *PollStore +} + +// AttachPollStore 注入共享 poll 权威。 +func (s *ChannelStore) AttachPollStore(polls *PollStore) { + s.polls = polls +} + +// NewChannelStore creates an in-memory ChannelStore. +func NewChannelStore() *ChannelStore { + return &ChannelStore{ + nextID: firstMemoryChannelID, + nextHash: 900000000000, + channels: make(map[int64]domain.Channel), + members: make(map[int64]map[int64]domain.ChannelMember), + dialogs: make(map[int64]map[int64]domain.ChannelDialog), + topics: make(map[int64]map[int]domain.ChannelForumTopic), + messages: make(map[int64][]domain.ChannelMessage), + reactions: make(map[int64]map[int]map[int64][]domain.ChannelMessagePeerReaction), + paidReactions: make(map[int64]map[int]map[int64]memoryPaidReaction), + top: make(map[int64]map[string]domain.TopMessageReaction), + recent: make(map[int64]map[string]domain.RecentMessageReaction), + savedTags: make(map[int64]map[string]domain.SavedReactionTag), + mentions: make(map[int64]map[int64]map[int]memoryMention), + msgViews: make(map[int64]map[int]int), + msgViewers: make(map[int64]map[int]map[int64]struct{}), + events: make(map[int64][]domain.ChannelUpdateEvent), + adminLogs: make(map[int64][]domain.ChannelAdminLogEvent), + invites: make(map[string]domain.ChannelInvite), + importers: make(map[int64]map[int64]domain.ChannelInviteImporter), + msgSeq: make(map[int64]int), + ptsSeq: make(map[int64]int), + logSeq: make(map[int64]int64), + randomToID: make(map[channelRandomKey]int), + boostSlots: make(map[boostSlotKey]domain.PremiumBoostSlot), + readMarks: make(map[int64]channelReadWatermark), + topicReads: make(map[int64]map[int64]map[int]memoryTopicRead), + } +} diff --git a/internal/store/memory/channel_test.go b/internal/store/memory/channel_test.go index 2e90eb93..ebed6fc6 100644 --- a/internal/store/memory/channel_test.go +++ b/internal/store/memory/channel_test.go @@ -1,6 +1,7 @@ package memory import ( + "bytes" "context" "errors" "reflect" @@ -9,6 +10,74 @@ import ( "telesrv/internal/domain" ) +func TestChannelCreateCreatesPermanentInviteAndHasLink(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "private group with main link", + Megagroup: true, + Date: 1_700_000_090, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if !created.Channel.HasLink { + t.Fatalf("created channel HasLink = false, want true") + } + view, err := store.GetChannel(ctx, 1, created.Channel.ID) + if err != nil { + t.Fatalf("get channel: %v", err) + } + if !view.Channel.HasLink { + t.Fatalf("stored channel HasLink = false, want true") + } + if view.ExportedInvite == nil || !view.ExportedInvite.Permanent || view.ExportedInvite.Revoked || view.ExportedInvite.AdminUserID != 1 { + t.Fatalf("owner view exported invite = %+v, want creator permanent main link", view.ExportedInvite) + } + invites, err := store.ListExportedInvites(ctx, domain.ChannelInviteListRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + AdminUserID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list exported invites: %v", err) + } + if invites.Count != 1 || len(invites.Invites) != 1 || !invites.Invites[0].Permanent || invites.Invites[0].Revoked { + t.Fatalf("invites = %+v, want one active permanent main link", invites) + } +} + +func TestChannelViewExportedInviteIsAdminOnly(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "private group with regular member", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_091, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + ownerView, err := store.GetChannel(ctx, 1, created.Channel.ID) + if err != nil { + t.Fatalf("get owner channel: %v", err) + } + if ownerView.ExportedInvite == nil || !ownerView.ExportedInvite.Permanent { + t.Fatalf("owner exported invite = %+v, want permanent main link", ownerView.ExportedInvite) + } + memberView, err := store.GetChannel(ctx, 2, created.Channel.ID) + if err != nil { + t.Fatalf("get member channel: %v", err) + } + if memberView.ExportedInvite != nil { + t.Fatalf("member exported invite = %+v, want nil", memberView.ExportedInvite) + } +} + func TestChannelRealtimeRecipientsAreCapped(t *testing.T) { store := NewChannelStore() memberIDs := make([]int64, domain.MaxChannelRealtimeFanout+25) @@ -39,6 +108,71 @@ func TestChannelRealtimeRecipientsAreCapped(t *testing.T) { } } +func TestChannelCreatorLeaveTransfersOwner(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "creator transfer", + Megagroup: true, + MemberUserIDs: []int64{2, 3}, + Date: 1_700_000_110, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := store.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + MemberID: 3, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + AddAdmins: true, + }, + Date: 1_700_000_111, + }); err != nil { + t.Fatalf("promote admin: %v", err) + } + future, err := store.FutureCreatorAfterLeave(ctx, created.Channel.ID, 1) + if err != nil { + t.Fatalf("future creator: %v", err) + } + if future.UserID != 3 { + t.Fatalf("future creator = %+v, want admin user 3", future) + } + left, err := store.LeaveChannel(ctx, created.Channel.ID, 1, 1_700_000_112) + if err != nil { + t.Fatalf("creator leave: %v", err) + } + if left.Channel.CreatorUserID != 3 || left.Channel.ParticipantsCount != 2 || left.Channel.AdminsCount != 1 { + t.Fatalf("channel after creator leave = %+v, want owner=3 participants=2 admins=1", left.Channel) + } + newOwner, err := store.GetParticipant(ctx, 3, created.Channel.ID, 3) + if err != nil { + t.Fatalf("get new owner: %v", err) + } + if newOwner.Role != domain.ChannelRoleCreator || newOwner.Status != domain.ChannelMemberActive { + t.Fatalf("new owner = %+v, want active creator", newOwner) + } + oldOwner, err := store.GetParticipant(ctx, 3, created.Channel.ID, 1) + if err != nil { + t.Fatalf("get old owner: %v", err) + } + if oldOwner.Status != domain.ChannelMemberLeft || oldOwner.Role == domain.ChannelRoleCreator { + t.Fatalf("old owner = %+v, want left non-creator", oldOwner) + } + if _, err := store.JoinChannel(ctx, created.Channel.ID, 1, 1_700_000_113); err != nil { + t.Fatalf("old owner rejoin: %v", err) + } + rejoined, err := store.GetParticipant(ctx, 3, created.Channel.ID, 1) + if err != nil { + t.Fatalf("get rejoined old owner: %v", err) + } + if rejoined.Role != domain.ChannelRoleMember || rejoined.Status != domain.ChannelMemberActive || rejoined.Rank != "" { + t.Fatalf("rejoined old owner = %+v, want active plain member", rejoined) + } +} + func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) { ctx := context.Background() store := NewChannelStore() @@ -71,7 +205,24 @@ func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) { t.Fatalf("edit admin pts = event(%d,%d) channel %d, want unchanged %d", promoted.Event.Pts, promoted.Event.PtsCount, promoted.Channel.Pts, ptsFloor) } - banned, err := store.EditChannelBanned(ctx, domain.EditChannelBannedRequest{ + muted, err := store.EditChannelBanned(ctx, domain.EditChannelBannedRequest{ + UserID: 1, + ChannelID: channelID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, + BannedRights: domain.ChannelBannedRights{ + SendMessages: true, + UntilDate: 1_700_001_121, + }, + Date: 1_700_000_122, + }) + if err != nil { + t.Fatalf("edit banned mute: %v", err) + } + if muted.Event.Pts != 0 || muted.Event.PtsCount != 0 || muted.Channel.Pts != ptsFloor || muted.ServiceEvent.Pts != 0 { + t.Fatalf("mute pts = event(%d,%d) channel %d, want unchanged %d without service message", muted.Event.Pts, muted.Event.PtsCount, muted.Channel.Pts, ptsFloor) + } + + kicked, err := store.EditChannelBanned(ctx, domain.EditChannelBannedRequest{ UserID: 1, ChannelID: channelID, Participant: domain.Peer{Type: domain.PeerTypeUser, ID: 2}, @@ -79,13 +230,21 @@ func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) { ViewMessages: true, UntilDate: 1_700_001_121, }, - Date: 1_700_000_122, + Date: 1_700_000_123, }) if err != nil { - t.Fatalf("edit banned: %v", err) + t.Fatalf("edit banned kick: %v", err) } - if banned.Event.Pts != 0 || banned.Event.PtsCount != 0 || banned.Channel.Pts != ptsFloor { - t.Fatalf("edit banned pts = event(%d,%d) channel %d, want unchanged %d", banned.Event.Pts, banned.Event.PtsCount, banned.Channel.Pts, ptsFloor) + // megagroup 踢人产生可见 "X removed Y" 服务消息并占 channel pts; + // participant update 本身仍不占 pts。 + if kicked.Event.Pts != 0 || kicked.Event.PtsCount != 0 { + t.Fatalf("kick participant event = (%d,%d), must stay transient", kicked.Event.Pts, kicked.Event.PtsCount) + } + if kicked.ServiceEvent.Pts != ptsFloor+1 || kicked.Message.Action == nil || kicked.Message.Action.Type != domain.ChannelActionChatDelete { + t.Fatalf("kick service = event %+v message %+v, want ChatDelete service message at pts %d", kicked.ServiceEvent, kicked.Message, ptsFloor+1) + } + if kicked.Channel.Pts != ptsFloor+1 { + t.Fatalf("kick channel pts = %d, want %d", kicked.Channel.Pts, ptsFloor+1) } diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ @@ -97,8 +256,145 @@ func TestChannelAdminAndBanDoNotAdvanceChannelPts(t *testing.T) { if err != nil { t.Fatalf("list difference: %v", err) } - if len(diff.Events) != 0 || diff.Pts != ptsFloor { - t.Fatalf("difference after participant state change = %+v, want no durable events at pts %d", diff, ptsFloor) + if len(diff.Events) != 1 || diff.Pts != ptsFloor+1 { + t.Fatalf("difference after kick = %+v, want only the kick service message at pts %d", diff, ptsFloor+1) + } +} + +func TestChannelStoreGetMessagesNonMemberPublicPreview(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + const owner, outsider int64 = 1, 99 + + // 公开广播频道:非成员可读取消息(查看他人资料里的公开「个人频道」时,DrKLO 经 + // channels.getMessages 拉最新一帖依赖此;否则资料页个人频道整块不显示)。 + pub, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "public", Broadcast: true, Date: 1_700_000_000}) + if err != nil { + t.Fatalf("create public: %v", err) + } + if _, err := store.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{UserID: owner, ChannelID: pub.Channel.ID, Username: "pub_preview_mem"}); err != nil { + t.Fatalf("make public: %v", err) + } + sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{UserID: owner, ChannelID: pub.Channel.ID, RandomID: 111, Message: "hello", Date: 1_700_000_001}) + if err != nil { + t.Fatalf("send public: %v", err) + } + got, err := store.GetChannelMessages(ctx, outsider, pub.Channel.ID, []int{sent.Message.ID}) + if err != nil { + t.Fatalf("non-member getMessages on public channel: %v", err) + } + if len(got.Messages) != 1 || got.Messages[0].ID != sent.Message.ID { + t.Fatalf("non-member should read public channel message, got %+v", got.Messages) + } + + // 私有广播频道(无 username):非成员仍被拒(ErrChannelPrivate)。 + priv, err := store.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "private", Broadcast: true, Date: 1_700_000_002}) + if err != nil { + t.Fatalf("create private: %v", err) + } + privSent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{UserID: owner, ChannelID: priv.Channel.ID, RandomID: 222, Message: "secret", Date: 1_700_000_003}) + if err != nil { + t.Fatalf("send private: %v", err) + } + if _, err := store.GetChannelMessages(ctx, outsider, priv.Channel.ID, []int{privSent.Message.ID}); !errors.Is(err, domain.ErrChannelPrivate) { + t.Fatalf("non-member getMessages on private channel = %v, want ErrChannelPrivate", err) + } +} + +func TestChannelStoreStoryMessageForwardsPublicOnlyAndDeleteRollback(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + source := domain.Peer{Type: domain.PeerTypeUser, ID: 42} + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindStory, + Story: &domain.MessageStory{ + Peer: source, + ID: 7, + Story: &domain.Story{ + Owner: source, + ID: 7, + }, + }, + } + publicCreated, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "public story forwards", + Broadcast: true, + Date: 1_700_000_130, + }) + if err != nil { + t.Fatalf("create public channel: %v", err) + } + publicChannel, err := store.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: 1, + ChannelID: publicCreated.Channel.ID, + Username: "story_forward_memory", + }) + if err != nil { + t.Fatalf("make public channel: %v", err) + } + privateCreated, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "private story forwards", + Broadcast: true, + Date: 1_700_000_131, + }) + if err != nil { + t.Fatalf("create private channel: %v", err) + } + publicSent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: publicChannel.ID, + RandomID: 9141301, + Media: media, + Date: 1_700_000_132, + }) + if err != nil { + t.Fatalf("send public story message: %v", err) + } + if _, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: privateCreated.Channel.ID, + RandomID: 9141302, + Media: media, + Date: 1_700_000_133, + }); err != nil { + t.Fatalf("send private story message: %v", err) + } + list, err := store.ListStoryMessageForwards(ctx, domain.StoryMessageForwardListRequest{ + ViewerUserID: 42, + Owner: source, + StoryID: 7, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story message forwards: %v", err) + } + if list.Count != 1 || len(list.Forwards) != 1 { + t.Fatalf("story message forwards = %+v, want one public forward", list) + } + if got := list.Forwards[0].PublicForward.Message.ChannelID; got != publicChannel.ID { + t.Fatalf("forward channel = %d, want public channel %d", got, publicChannel.ID) + } + if _, err := store.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: 1, + ChannelID: publicChannel.ID, + IDs: []int{publicSent.Message.ID}, + Date: 1_700_000_134, + }); err != nil { + t.Fatalf("delete public story message: %v", err) + } + empty, err := store.ListStoryMessageForwards(ctx, domain.StoryMessageForwardListRequest{ + ViewerUserID: 42, + Owner: source, + StoryID: 7, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story message forwards after delete: %v", err) + } + if empty.Count != 0 || len(empty.Forwards) != 0 { + t.Fatalf("story message forwards after delete = %+v, want empty", empty) } } @@ -493,8 +789,18 @@ func TestChannelDeleteHistoryCapsHugeMaxID(t *testing.T) { if err != nil { t.Fatalf("delete second batch: %v", err) } - if second.Offset != 0 || len(second.DeletedIDs) != 3 || second.Event.PtsCount != 3 { - t.Fatalf("second batch = %+v, want final bounded page", second) + if second.Offset != 0 || len(second.DeletedIDs) != 2 || second.Event.PtsCount != 2 { + t.Fatalf("second batch = %+v, want final bounded page keeping create service message", second) + } + if second.Channel.TopMessageID != created.Message.ID { + t.Fatalf("top after full clear = %d, want create service message %d", second.Channel.TopMessageID, created.Message.ID) + } + history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("list history after full clear: %v", err) + } + if len(history.Messages) != 1 || history.Messages[0].ID != created.Message.ID || history.Messages[0].Action == nil { + t.Fatalf("history after full clear = %+v, want only create service message", history.Messages) } } @@ -678,6 +984,230 @@ func TestChannelUnreadExcludesOwnOutgoing(t *testing.T) { } } +func TestChannelMessageReplyMarkupSurvivesReadPaths(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "channel reply markup", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_380, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + markup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ + {Type: domain.MarkupButtonCallback, Text: "Open", Data: []byte{0x00, 0xff, 0x42}}, + }}} + assertMarkup := func(name string, got *domain.MessageReplyMarkup) { + t.Helper() + if got == nil || len(got.Inline) != 1 || len(got.Inline[0]) != 1 { + t.Fatalf("%s markup = %+v, want one callback button", name, got) + } + btn := got.Inline[0][0] + if btn.Type != domain.MarkupButtonCallback || btn.Text != "Open" || !bytes.Equal(btn.Data, []byte{0x00, 0xff, 0x42}) { + t.Fatalf("%s button = %+v, want callback Open bytes", name, btn) + } + } + sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 38_001, + Message: "via inline keyboard", + ViaBotID: 99, + ReplyMarkup: markup, + Date: 1_700_000_381, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + markup.Inline[0][0].Text = "Changed" + markup.Inline[0][0].Data[0] = 0x7f + assertMarkup("send result", sent.Message.ReplyMarkup) + assertMarkup("send event", sent.Event.Message.ReplyMarkup) + + duplicate, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 38_001, + Message: "duplicate must not replace", + Date: 1_700_000_382, + }) + if err != nil { + t.Fatalf("duplicate send: %v", err) + } + if !duplicate.Duplicate || duplicate.Message.ViaBotID != 99 { + t.Fatalf("duplicate = %+v, want original via bot", duplicate.Message) + } + assertMarkup("duplicate message", duplicate.Message.ReplyMarkup) + assertMarkup("duplicate event", duplicate.Event.Message.ReplyMarkup) + + history, err := store.ListChannelHistory(ctx, 1, domain.ChannelHistoryFilter{ChannelID: created.Channel.ID, Limit: 10}) + if err != nil { + t.Fatalf("list history: %v", err) + } + if len(history.Messages) == 0 || history.Messages[0].ViaBotID != 99 { + t.Fatalf("history messages = %+v, want via bot 99", history.Messages) + } + assertMarkup("history message", history.Messages[0].ReplyMarkup) + + byID, err := store.GetChannelMessages(ctx, 1, created.Channel.ID, []int{sent.Message.ID}) + if err != nil { + t.Fatalf("get channel messages: %v", err) + } + if len(byID.Messages) != 1 || byID.Messages[0].ViaBotID != 99 { + t.Fatalf("get messages = %+v, want via bot 99", byID.Messages) + } + assertMarkup("get messages", byID.Messages[0].ReplyMarkup) + + diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 1, ChannelID: created.Channel.ID, Pts: created.Channel.Pts, Limit: 10}) + if err != nil { + t.Fatalf("list difference: %v", err) + } + if len(diff.NewMessages) != 1 || diff.NewMessages[0].ViaBotID != 99 { + t.Fatalf("difference messages = %+v, want via bot 99", diff.NewMessages) + } + assertMarkup("difference message", diff.NewMessages[0].ReplyMarkup) + + dialogs, err := store.ListChannelDialogs(ctx, 1, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list dialogs: %v", err) + } + if len(dialogs.Messages) == 0 || dialogs.Messages[0].ViaBotID != 99 { + t.Fatalf("dialog messages = %+v, want via bot 99", dialogs.Messages) + } + assertMarkup("dialog top message", dialogs.Messages[0].ReplyMarkup) +} + +func TestChannelMessageViaBotEditUpdatesReplyMarkup(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "channel via bot edit", + Megagroup: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_390, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 39_001, + Message: "before", + ViaBotID: 99, + Date: 1_700_000_391, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + markup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ + {Type: domain.MarkupButtonCallback, Text: "Done", Data: []byte("v2")}, + }}} + if _, err := store.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + ID: sent.Message.ID, + Message: "wrong", + ViaBotEditBotID: 100, + EditDate: 1_700_000_392, + SetReplyMarkup: true, + ReplyMarkup: markup, + }); err != domain.ErrMessageAuthorRequired { + t.Fatalf("wrong via bot edit err = %v, want ErrMessageAuthorRequired", err) + } + edited, err := store.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + ID: sent.Message.ID, + Message: "after", + ViaBotEditBotID: 99, + EditDate: 1_700_000_393, + SetReplyMarkup: true, + ReplyMarkup: markup, + }) + if err != nil { + t.Fatalf("via bot edit: %v", err) + } + if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Message.Body != "after" || edited.Message.ViaBotID != 99 { + t.Fatalf("edited = %+v event=%+v, want edit via bot", edited.Message, edited.Event) + } + if edited.Message.ReplyMarkup == nil || edited.Message.ReplyMarkup.Inline[0][0].Text != "Done" || !bytes.Equal(edited.Message.ReplyMarkup.Inline[0][0].Data, []byte("v2")) { + t.Fatalf("edited markup = %+v, want Done/v2", edited.Message.ReplyMarkup) + } + diff, err := store.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: 1, ChannelID: created.Channel.ID, Pts: sent.Event.Pts, Limit: 10}) + if err != nil { + t.Fatalf("list edit difference: %v", err) + } + if len(diff.OtherUpdates) != 1 || diff.OtherUpdates[0].Message.Body != "after" { + t.Fatalf("edit difference = %+v, want one edited message", diff.OtherUpdates) + } +} + +func TestBroadcastChannelReactionsAreAnonymousAndSkipUnread(t *testing.T) { + ctx := context.Background() + store := NewChannelStore() + created, err := store.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: 1, + Title: "broadcast reaction", + Broadcast: true, + MemberUserIDs: []int64{2}, + Date: 1_700_000_500, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := store.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: 1, + ChannelID: created.Channel.ID, + RandomID: 50_001, + Message: "broadcast post", + Date: 1_700_000_501, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + res, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ + UserID: 2, + ChannelID: created.Channel.ID, + MessageID: sent.Message.ID, + Reactions: []domain.MessageReaction{{ + Type: domain.MessageReactionEmoji, + Emoticon: "\U0001f44d", + }}, + Date: 1_700_000_502, + }) + if err != nil { + t.Fatalf("set channel reaction: %v", err) + } + if len(res.Reactions.Results) != 1 || res.Reactions.Results[0].Count != 1 { + t.Fatalf("broadcast reaction results = %+v, want count-only aggregate", res.Reactions.Results) + } + if len(res.Reactions.Recent) != 0 { + t.Fatalf("broadcast recent reactors = %+v, want anonymous (empty)", res.Reactions.Recent) + } + if res.Reactions.CanSeeList { + t.Fatalf("broadcast can_see_list = true, want false") + } + for _, rows := range store.reactions[created.Channel.ID][sent.Message.ID] { + for _, row := range rows { + if row.Unread { + t.Fatalf("broadcast reaction row = %+v, want unread bookkeeping skipped", row) + } + } + } + dialogs, err := store.GetChannelDialogs(ctx, 1, []int64{created.Channel.ID}) + if err != nil { + t.Fatalf("get owner channel dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 0 { + t.Fatalf("owner dialogs = %+v, want no unread reaction badge on broadcast", dialogs.Dialogs) + } +} + func TestChannelReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) { ctx := context.Background() store := NewChannelStore() diff --git a/internal/store/memory/channel_topic_read.go b/internal/store/memory/channel_topic_read.go new file mode 100644 index 00000000..310c1ede --- /dev/null +++ b/internal/store/memory/channel_topic_read.go @@ -0,0 +1,200 @@ +package memory + +import ( + "context" + + "telesrv/internal/domain" +) + +// memoryTopicRead 是单个 (channel,user,topic) 的已读水位。 +type memoryTopicRead struct { + ReadInbox int + ReadOutbox int +} + +func topicReadFallbackInboxMem(topicID, availableMinID int) int { + base := topicID - 1 + if availableMinID > base { + base = availableMinID + } + if base < 0 { + base = 0 + } + return base +} + +// channelMessageInTopicMem 判断消息是否属于某 topic,与 postgres channelTopicMessageCond 严格对齐: +// 普通 topic 仅 reply_to_top_id==topicID(不含 root 服务消息,其 reply_to_top_id=0); +// General(=1) 归并 reply_to_top_id∈{0,1}。 +func (s *ChannelStore) channelMessageInTopicLocked(channelID int64, msg domain.ChannelMessage, topicID int) bool { + top := 0 + if msg.ReplyTo != nil { + top = msg.ReplyTo.TopMessageID + } + if topicID == domain.ForumGeneralTopicID { + if top != domain.ForumGeneralTopicID && top != 0 { + return false + } + // 排除其它话题的根服务消息(reply_to_top_id=0 但 id 是某话题根,不属于 General)。 + if _, isRoot := s.topics[channelID][msg.ID]; isRoot { + return false + } + return true + } + return top == topicID +} + +func (s *ChannelStore) channelTopicReadInboxLocked(channelID, userID int64, topicID, availableMinID int) int { + fb := topicReadFallbackInboxMem(topicID, availableMinID) + if tr, ok := s.topicReads[channelID][userID][topicID]; ok && tr.ReadInbox > fb { + return tr.ReadInbox + } + return fb +} + +func (s *ChannelStore) channelTopicReadOutboxLocked(channelID, userID int64, topicID int) int { + if tr, ok := s.topicReads[channelID][userID][topicID]; ok { + return tr.ReadOutbox + } + return 0 +} + +func (s *ChannelStore) setTopicReadLocked(channelID, userID int64, topicID int, mutate func(*memoryTopicRead)) { + if s.topicReads[channelID] == nil { + s.topicReads[channelID] = make(map[int64]map[int]memoryTopicRead) + } + if s.topicReads[channelID][userID] == nil { + s.topicReads[channelID][userID] = make(map[int]memoryTopicRead) + } + tr := s.topicReads[channelID][userID][topicID] + mutate(&tr) + s.topicReads[channelID][userID][topicID] = tr +} + +func (s *ChannelStore) channelTopicTopMessageIDLocked(channelID int64, topicID, availableMinID int) int { + maxID := 0 + for _, msg := range s.messages[channelID] { + if msg.Deleted || msg.ID <= availableMinID { + continue + } + if s.channelMessageInTopicLocked(channelID, msg, topicID) && msg.ID > maxID { + maxID = msg.ID + } + } + return maxID +} + +// channelTopicUnreadCountLocked 现算某 topic 对 viewer 的未读消息数(per-topic 水位 readMaxID)。 +// 与 postgres populateForumTopicUnreadCounts 同口径:reply_to_top_id==topicID、id>water、sender≠viewer。 +func (s *ChannelStore) channelTopicUnreadCountLocked(viewerUserID, channelID int64, topicID, readMaxID int) int { + unread := 0 + for _, msg := range s.messages[channelID] { + if msg.Deleted || msg.ID <= readMaxID || msg.SenderUserID == viewerUserID { + continue + } + if s.channelMessageInTopicLocked(channelID, msg, topicID) { + unread++ + if unread >= domain.MaxDialogUnreadCount { + break + } + } + } + return unread +} + +// ReadChannelTopicHistory 推进 viewer 在 forum 单话题的 per-topic 已读水位(不碰频道级)。 +func (s *ChannelStore) ReadChannelTopicHistory(_ context.Context, req domain.ReadChannelTopicHistoryRequest) (domain.ReadChannelTopicHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelTopicHistoryResult{}, err + } + if !channel.Forum { + return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelForumMissing + } + topMax := s.channelTopicTopMessageIDLocked(req.ChannelID, req.TopicID, member.AvailableMinID) + maxID := req.MaxID + if maxID <= 0 || maxID > topMax { + maxID = topMax + } + prev := s.channelTopicReadInboxLocked(req.ChannelID, req.UserID, req.TopicID, member.AvailableMinID) + if maxID <= prev { + return domain.ReadChannelTopicHistoryResult{Channel: cloneChannel(channel), TopicID: req.TopicID, MaxID: prev, Changed: false, Pts: channel.Pts}, nil + } + s.setTopicReadLocked(req.ChannelID, req.UserID, req.TopicID, func(tr *memoryTopicRead) { + if maxID > tr.ReadInbox { + tr.ReadInbox = maxID + } + }) + outbox := s.advanceTopicReadOutboxLocked(req.ChannelID, req.UserID, req.TopicID, prev, maxID) + return domain.ReadChannelTopicHistoryResult{Channel: cloneChannel(channel), TopicID: req.TopicID, MaxID: maxID, Changed: true, Pts: channel.Pts, OutboxUpdates: outbox}, nil +} + +func (s *ChannelStore) advanceTopicReadOutboxLocked(channelID, readerUserID int64, topicID, prev, maxID int) []domain.ChannelReadOutboxUpdate { + senders := make(map[int64]struct{}) + order := make([]int64, 0, 8) + for _, msg := range s.messages[channelID] { + if msg.Deleted || msg.ID <= prev || msg.ID > maxID { + continue + } + if msg.SenderUserID == 0 || msg.SenderUserID == readerUserID { + continue + } + if !s.channelMessageInTopicLocked(channelID, msg, topicID) { + continue + } + if _, ok := senders[msg.SenderUserID]; ok { + continue + } + senders[msg.SenderUserID] = struct{}{} + order = append(order, msg.SenderUserID) + } + if len(order) == 0 { + return nil + } + updates := make([]domain.ChannelReadOutboxUpdate, 0, len(order)) + for _, sender := range order { + s.setTopicReadLocked(channelID, sender, topicID, func(tr *memoryTopicRead) { + if maxID > tr.ReadOutbox { + tr.ReadOutbox = maxID + } + }) + updates = append(updates, domain.ChannelReadOutboxUpdate{UserID: sender, MaxID: maxID}) + } + return updates +} + +// GeneralForumTopic 现算 General 话题(id=1)对 viewer 的状态(per-topic 水位,排除其它话题根消息)。 +func (s *ChannelStore) GeneralForumTopic(_ context.Context, viewerUserID, channelID int64) (domain.ChannelForumTopic, error) { + if viewerUserID == 0 || channelID == 0 { + return domain.ChannelForumTopic{}, domain.ErrChannelInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelForumTopic{}, err + } + if !channel.Forum { + return domain.ChannelForumTopic{}, domain.ErrChannelForumMissing + } + gid := domain.ForumGeneralTopicID + water := s.channelTopicReadInboxLocked(channelID, viewerUserID, gid, member.AvailableMinID) + return domain.ChannelForumTopic{ + ChannelID: channelID, + TopicID: gid, + Title: "General", + CreatorUserID: channel.CreatorUserID, + Date: channel.Date, + TopMessageID: s.channelTopicTopMessageIDLocked(channelID, gid, member.AvailableMinID), + ReadInboxMaxID: water, + ReadOutboxMaxID: s.channelTopicReadOutboxLocked(channelID, viewerUserID, gid), + UnreadCount: s.channelTopicUnreadCountLocked(viewerUserID, channelID, gid, water), + UnreadMentionsCount: s.countChannelUnreadMentionsLocked(viewerUserID, channelID, gid), + UnreadReactionsCount: s.countChannelUnreadReactionsLocked(viewerUserID, channelID, gid), + }, nil +} diff --git a/internal/store/memory/channel_topics.go b/internal/store/memory/channel_topics.go new file mode 100644 index 00000000..ecc69272 --- /dev/null +++ b/internal/store/memory/channel_topics.go @@ -0,0 +1,719 @@ +package memory + +import ( + "context" + "sort" + "strings" + "telesrv/internal/domain" + "time" +) + +func (s *ChannelStore) SetForum(_ context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return domain.Channel{}, err + } + member := s.members[channelID][userID] + if !channel.Megagroup || channel.Broadcast { + return domain.Channel{}, domain.ErrChannelNotModified + } + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if enabled && channel.LinkedChatID != 0 { + return domain.Channel{}, domain.ErrChatDiscussionUnallowed + } + prevForum := channel.Forum + prevTabs := channel.ForumTabs + channel.Forum = enabled + channel.ForumTabs = enabled && tabs + s.channels[channelID] = channel + if prevForum != channel.Forum || prevTabs != channel.ForumTabs { + s.appendChannelAdminLogLocked(domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: int(time.Now().Unix()), + Type: domain.ChannelAdminLogToggleForum, + PrevBool: prevForum, + NewBool: enabled, + }) + } + return cloneChannel(channel), nil +} + +func (s *ChannelStore) SetChannelViewForumAsMessages(_ context.Context, userID, channelID int64, enabled bool) (bool, error) { + if userID == 0 || channelID == 0 { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(userID, channelID) + if err != nil { + return false, nil + } + dialog := s.dialogForUserLocked(userID, channel) + changed := dialog.ViewForumAsMessages != enabled + dialog.ViewForumAsMessages = enabled + if s.dialogs[userID] == nil { + s.dialogs[userID] = make(map[int64]domain.ChannelDialog) + } + s.dialogs[userID][channelID] = dialog + return changed, nil +} + +func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.RandomID == 0 { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + title := strings.TrimSpace(req.Title) + if title == "" && !req.TitleMissing { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if req.IconColor == 0 { + req.IconColor = domain.DefaultForumTopicIconColor + } + s.mu.Lock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + s.mu.Unlock() + return domain.CreateChannelForumTopicResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !channel.Forum || channel.Broadcast || !channel.Megagroup { + s.mu.Unlock() + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelForumMissing + } + if !canSendChannelMessage(channel, member) { + s.mu.Unlock() + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden + } + if id, ok := s.randomToID[channelRandomKey{channelID: req.ChannelID, userID: req.UserID, randomID: req.RandomID}]; ok { + if topic, ok := s.topics[req.ChannelID][id]; ok { + msg, _ := s.findMessageLocked(req.ChannelID, id) + event := s.eventForMessageLocked(req.ChannelID, id) + recipients := s.activeMemberIDsLocked(req.ChannelID, 0, 0) + s.mu.Unlock() + return domain.CreateChannelForumTopicResult{ + Channel: cloneChannel(channel), + Topic: cloneChannelForumTopic(topic), + Message: cloneChannelMessage(msg), + Event: cloneChannelEvent(event), + Recipients: recipients, + Duplicate: true, + }, nil + } + } + s.mu.Unlock() + + res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + RandomID: req.RandomID, + SendAs: req.SendAs, + Action: &domain.ChannelMessageAction{ + Type: domain.ChannelActionTopicCreate, + Title: title, + IconColor: req.IconColor, + IconEmojiID: req.IconEmojiID, + TitleMissing: req.TitleMissing, + }, + Date: req.Date, + }) + if err != nil { + return domain.CreateChannelForumTopicResult{}, err + } + if res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionTopicCreate { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + + s.mu.Lock() + defer s.mu.Unlock() + channel = s.channels[req.ChannelID] + if s.topics[req.ChannelID] == nil { + s.topics[req.ChannelID] = make(map[int]domain.ChannelForumTopic) + } + topic, ok := s.topics[req.ChannelID][res.Message.ID] + if !ok { + topic = domain.ChannelForumTopic{ + ChannelID: req.ChannelID, + TopicID: res.Message.ID, + CreatorUserID: req.UserID, + Title: title, + IconColor: req.IconColor, + IconEmojiID: req.IconEmojiID, + TitleMissing: req.TitleMissing, + Date: res.Message.Date, + TopMessageID: res.Message.ID, + ReadInboxMaxID: res.Message.ID, + ReadOutboxMaxID: res.Message.ID, + } + s.topics[req.ChannelID][topic.TopicID] = topic + } + return domain.CreateChannelForumTopicResult{ + Channel: cloneChannel(channel), + Topic: cloneChannelForumTopic(topic), + Message: cloneChannelMessage(res.Message), + Event: cloneChannelEvent(res.Event), + Recipients: append([]int64(nil), res.Recipients...), + Duplicate: res.Duplicate, + }, nil +} + +func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + s.mu.Unlock() + return domain.EditChannelForumTopicResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + topic, ok := s.topics[req.ChannelID][req.TopicID] + if !channel.Forum { + s.mu.Unlock() + return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing + } + if !ok { + s.mu.Unlock() + return domain.EditChannelForumTopicResult{}, domain.ErrMessageIDInvalid + } + if !canManageForumTopic(channel, member, topic, req.UserID) { + s.mu.Unlock() + return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired + } + next := topic + action := domain.ChannelMessageAction{Type: domain.ChannelActionTopicEdit} + changed := false + if req.Title != nil { + title := strings.TrimSpace(*req.Title) + if title == "" { + s.mu.Unlock() + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if next.Title != title { + next.Title = title + action.Title = title + changed = true + } + } + if req.IconEmojiID != nil && next.IconEmojiID != *req.IconEmojiID { + next.IconEmojiID = *req.IconEmojiID + action.IconEmojiID = *req.IconEmojiID + action.IconEmojiIDSet = true + changed = true + } + if req.Closed != nil && next.Closed != *req.Closed { + next.Closed = *req.Closed + action.Closed = boolPtr(*req.Closed) + changed = true + } + if req.Hidden != nil && next.Hidden != *req.Hidden { + next.Hidden = *req.Hidden + action.Hidden = boolPtr(*req.Hidden) + changed = true + } + s.mu.Unlock() + if !changed { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelNotModified + } + + res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + ReplyTo: &domain.MessageReply{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, + MessageID: req.TopicID, + TopMessageID: req.TopicID, + }, + Action: &action, + Date: req.Date, + }) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + + s.mu.Lock() + defer s.mu.Unlock() + channel = s.channels[req.ChannelID] + if _, ok := s.topics[req.ChannelID][req.TopicID]; !ok { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing + } + next.TopMessageID = maxInt(next.TopMessageID, res.Message.ID) + s.topics[req.ChannelID][req.TopicID] = next + return domain.EditChannelForumTopicResult{ + Channel: cloneChannel(channel), + Topic: cloneChannelForumTopic(next), + Message: cloneChannelMessage(res.Message), + Event: cloneChannelEvent(res.Event), + Recipients: append([]int64(nil), res.Recipients...), + }, nil +} + +func (s *ChannelStore) UpdatePinnedForumTopic(_ context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + topic, ok := s.topics[req.ChannelID][req.TopicID] + if !channel.Forum { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelForumMissing + } + if !ok { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrMessageIDInvalid + } + if !canPinChannelMessages(channel, member) { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelAdminRequired + } + if topic.Pinned == req.Pinned { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelNotModified + } + topic.Pinned = req.Pinned + if req.Pinned && topic.PinnedOrder == 0 { + topic.PinnedOrder = s.nextForumTopicPinnedOrderLocked(req.ChannelID) + } + if !req.Pinned { + topic.PinnedOrder = 0 + } + s.topics[req.ChannelID][req.TopicID] = topic + return domain.UpdateChannelForumTopicPinnedResult{ + Channel: cloneChannel(channel), + Topic: cloneChannelForumTopic(topic), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) ReorderPinnedForumTopics(_ context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ReorderChannelPinnedForumTopicsResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + if !channel.Forum { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelForumMissing + } + if !canPinChannelMessages(channel, member) { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelAdminRequired + } + seen := make(map[int]struct{}, len(req.Order)) + order := make([]int, 0, len(req.Order)) + for _, id := range req.Order { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + topic, ok := s.topics[req.ChannelID][id] + if !ok || !topic.Pinned { + if req.Force { + continue + } + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid + } + seen[id] = struct{}{} + order = append(order, id) + } + for i, id := range order { + topic := s.topics[req.ChannelID][id] + topic.PinnedOrder = len(order) - i + s.topics[req.ChannelID][id] = topic + } + return domain.ReorderChannelPinnedForumTopicsResult{ + Channel: cloneChannel(channel), + Order: append([]int(nil), order...), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + }, nil +} + +func (s *ChannelStore) DeleteForumTopicHistory(_ context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + channel, err := s.channelForMemberLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + member := s.members[req.ChannelID][req.UserID] + topic, ok := s.topics[req.ChannelID][req.TopicID] + if !channel.Forum { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelForumMissing + } + if !ok { + return domain.DeleteChannelHistoryResult{}, domain.ErrMessageIDInvalid + } + if !canManageForumTopic(channel, member, topic, req.UserID) && !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for i := len(s.messages[req.ChannelID]) - 1; i >= 0; i-- { + msg := s.messages[req.ChannelID][i] + if msg.Deleted { + continue + } + if msg.ID != req.TopicID && (msg.ReplyTo == nil || msg.ReplyTo.TopMessageID != req.TopicID) { + continue + } + ids = append(ids, msg.ID) + if len(ids) >= domain.MaxDeleteHistoryBatch { + break + } + } + deleted, event, channel, err := s.deleteChannelMessagesLocked(channel, member, ids, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + offset := 0 + if s.topicHasVisibleMessagesLocked(req.ChannelID, req.TopicID) { + offset = 1 + } else { + delete(s.topics[req.ChannelID], req.TopicID) + } + return domain.DeleteChannelHistoryResult{ + Channel: cloneChannel(channel), + Event: cloneChannelEvent(event), + DeletedIDs: append([]int(nil), deleted...), + Recipients: s.activeMemberIDsLocked(req.ChannelID, 0, 0), + Offset: offset, + }, nil +} + +func (s *ChannelStore) ListForumTopics(_ context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + if !channel.Forum { + return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelForumTopicsLimit { + limit = domain.MaxChannelForumTopicsLimit + } + query := strings.TrimSpace(strings.ToLower(filter.Query)) + all := make([]domain.ChannelForumTopic, 0, len(s.topics[filter.ChannelID])) + for _, topic := range s.topics[filter.ChannelID] { + if topic.TopicID <= member.AvailableMinID { + continue + } + if query != "" && !strings.Contains(strings.ToLower(topic.Title), query) { + continue + } + if forumTopicBeforeOrAtOffset(topic, filter) { + continue + } + all = append(all, s.topicWithViewerCountersLocked(viewerUserID, filter.ChannelID, topic, member)) + } + sortForumTopics(all) + count := len(all) + if len(all) > limit { + all = all[:limit] + } + messages := s.forumTopicRootMessagesLocked(filter.ChannelID, all, member.AvailableMinID) + return domain.ChannelForumTopicList{ + Channel: cloneChannel(channel), + Dialog: s.dialogForUserLocked(viewerUserID, channel), + Topics: all, + Messages: messages, + Count: count, + }, nil +} + +func (s *ChannelStore) GetForumTopicsByID(_ context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + if !channel.Forum { + return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing + } + wanted := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ChannelForumTopicList{}, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + topics := make([]domain.ChannelForumTopic, 0, len(wanted)) + for id := range wanted { + topic, ok := s.topics[channelID][id] + if !ok || topic.TopicID <= member.AvailableMinID { + continue + } + topics = append(topics, s.topicWithViewerCountersLocked(viewerUserID, channelID, topic, member)) + } + sortForumTopics(topics) + messages := s.forumTopicRootMessagesLocked(channelID, topics, member.AvailableMinID) + return domain.ChannelForumTopicList{ + Channel: cloneChannel(channel), + Dialog: s.dialogForUserLocked(viewerUserID, channel), + Topics: topics, + Messages: messages, + Count: len(topics), + }, nil +} + +func (s *ChannelStore) ListChannelReplies(_ context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { + s.mu.RLock() + defer s.mu.RUnlock() + source, member, err := s.channelAndMemberLocked(viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + root, ok := s.findMessageLocked(filter.ChannelID, filter.RootMessageID) + if !ok || root.Deleted || root.ID <= member.AvailableMinID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + targetChannel := source + targetMember := member + rootID := root.ID + extraChannels := []domain.Channel(nil) + if source.Broadcast { + if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 { + return domain.ChannelHistory{Channel: source, Count: 0}, nil + } + linked, ok := s.channels[root.Discussion.ChannelID] + if !ok || linked.Deleted { + return domain.ChannelHistory{Channel: source, Count: 0}, nil + } + targetChannel = linked + rootID = root.Discussion.MessageID + if linkedMember, ok := s.members[linked.ID][viewerUserID]; ok { + targetMember = linkedMember + } else { + targetMember = domain.ChannelMember{} + } + extraChannels = append(extraChannels, source) + } + if targetRoot, ok := s.findMessageLocked(targetChannel.ID, rootID); !ok || targetRoot.Deleted { + return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Count: 0}, nil + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelRepliesLimit { + limit = domain.MaxChannelRepliesLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + base := make([]domain.ChannelMessage, 0, limit) + for _, msg := range s.messages[targetChannel.ID] { + if msg.Deleted || msg.ID <= targetMember.AvailableMinID { + continue + } + if !channelReplyBelongsToRoot(msg, targetChannel.ID, rootID) { + continue + } + if filter.MaxID > 0 && msg.ID >= filter.MaxID { + continue + } + if filter.MinID > 0 && msg.ID <= filter.MinID { + continue + } + if filter.OffsetDate > 0 && msg.Date == 0 { + continue + } + base = append(base, msg) + } + sort.SliceStable(base, func(i, j int) bool { return channelMessageLess(base[i], base[j]) }) + page := pageChannelMessageHistory(base, filter, limit) + out := make([]domain.ChannelMessage, 0, len(page)) + for _, msg := range page { + out = append(out, cloneChannelMessage(msg)) + } + s.populateChannelMessageRepliesLocked(viewerUserID, targetChannel.ID, out) + s.populateChannelMessageReactionsLocked(viewerUserID, targetChannel, out) + topics := []domain.ChannelForumTopic(nil) + if targetChannel.Forum { + if topic, ok := s.topics[targetChannel.ID][rootID]; ok && !topic.Hidden { + topic = s.topicWithViewerCountersLocked(viewerUserID, targetChannel.ID, topic, targetMember) + topics = append(topics, cloneChannelForumTopic(topic)) + } + } + return domain.ChannelHistory{Channel: targetChannel, Channels: extraChannels, Topics: topics, Messages: out, Count: len(base)}, nil +} + +func (s *ChannelStore) populateChannelMessageRepliesLocked(viewerUserID, channelID int64, messages []domain.ChannelMessage) { + for i := range messages { + messages[i].Replies = s.channelMessageRepliesLocked(viewerUserID, channelID, messages[i]) + } +} + +func (s *ChannelStore) channelMessageRepliesLocked(viewerUserID, channelID int64, msg domain.ChannelMessage) *domain.ChannelMessageReplies { + targetChannelID := channelID + rootID := msg.ID + stats := domain.ChannelMessageReplies{} + if msg.Discussion != nil && msg.Discussion.ChannelID != 0 && msg.Discussion.MessageID != 0 { + targetChannelID = msg.Discussion.ChannelID + rootID = msg.Discussion.MessageID + stats.Comments = true + stats.ChannelID = msg.Discussion.ChannelID + } else if channel, ok := s.channels[channelID]; ok && channel.Broadcast && channel.LinkedChatID != 0 && msg.Post { + stats.Comments = true + stats.ChannelID = channel.LinkedChatID + } + if rootID <= 0 { + return nil + } + if member, ok := s.members[targetChannelID][viewerUserID]; ok { + stats.ReadMaxID = member.ReadInboxMaxID + } + seenRecent := map[domain.Peer]struct{}{} + for i := len(s.messages[targetChannelID]) - 1; i >= 0; i-- { + reply := s.messages[targetChannelID][i] + if reply.Deleted || !channelReplyBelongsToRoot(reply, targetChannelID, rootID) { + continue + } + stats.Replies++ + if stats.MaxID == 0 || reply.ID > stats.MaxID { + stats.MaxID = reply.ID + stats.RepliesPts = reply.Pts + } + if len(stats.RecentRepliers) < 3 { + peer := reply.From + if peer.ID == 0 && reply.SenderUserID != 0 { + peer = domain.Peer{Type: domain.PeerTypeUser, ID: reply.SenderUserID} + } + if peer.ID != 0 { + if _, ok := seenRecent[peer]; !ok { + seenRecent[peer] = struct{}{} + stats.RecentRepliers = append(stats.RecentRepliers, peer) + } + } + } + } + if stats.Comments && stats.RepliesPts == 0 { + if root, ok := s.findMessageLocked(targetChannelID, rootID); ok { + stats.RepliesPts = root.Pts + } + } + if !stats.Comments && stats.Replies == 0 { + return nil + } + return &stats +} + +func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64) bool { + if topic.CreatorUserID == userID { + return true + } + return canPinChannelMessages(channel, member) +} + +func cloneChannelForumTopic(in domain.ChannelForumTopic) domain.ChannelForumTopic { + return in +} + +func (s *ChannelStore) updateForumTopicTopMessageLocked(channelID int64, msg domain.ChannelMessage) { + if msg.ReplyTo == nil || !msg.ReplyTo.ForumTopic || msg.ReplyTo.TopMessageID <= 0 { + return + } + topic, ok := s.topics[channelID][msg.ReplyTo.TopMessageID] + if !ok { + return + } + topic.TopMessageID = msg.ID + topic.Date = msg.Date + s.topics[channelID][topic.TopicID] = topic +} + +func sortForumTopics(topics []domain.ChannelForumTopic) { + sort.Slice(topics, func(i, j int) bool { + a, b := topics[i], topics[j] + if a.Pinned != b.Pinned { + return a.Pinned + } + if a.PinnedOrder != b.PinnedOrder { + return a.PinnedOrder > b.PinnedOrder + } + if a.Date != b.Date { + return a.Date > b.Date + } + return a.TopicID > b.TopicID + }) +} + +func forumTopicBeforeOrAtOffset(topic domain.ChannelForumTopic, filter domain.ChannelForumTopicFilter) bool { + if filter.OffsetDate == 0 && filter.OffsetID == 0 && filter.OffsetTopic == 0 { + return false + } + offsetID := filter.OffsetTopic + if offsetID == 0 { + offsetID = filter.OffsetID + } + if filter.OffsetDate != 0 { + if topic.Date < filter.OffsetDate { + return false + } + if topic.Date > filter.OffsetDate { + return true + } + } + if offsetID == 0 { + return false + } + return topic.TopicID >= offsetID +} + +func (s *ChannelStore) forumTopicRootMessagesLocked(channelID int64, topics []domain.ChannelForumTopic, availableMinID int) []domain.ChannelMessage { + if len(topics) == 0 { + return nil + } + wanted := make(map[int]struct{}, len(topics)) + for _, topic := range topics { + if topic.TopMessageID > 0 { + wanted[topic.TopMessageID] = struct{}{} + } + } + messages := make([]domain.ChannelMessage, 0, len(wanted)) + for _, msg := range s.messages[channelID] { + if _, ok := wanted[msg.ID]; !ok { + continue + } + if msg.Deleted || msg.ID <= availableMinID { + continue + } + messages = append(messages, cloneChannelMessage(msg)) + } + sort.Slice(messages, func(i, j int) bool { return messages[i].ID > messages[j].ID }) + return messages +} + +func (s *ChannelStore) nextForumTopicPinnedOrderLocked(channelID int64) int { + next := 1 + for _, topic := range s.topics[channelID] { + if topic.PinnedOrder >= next { + next = topic.PinnedOrder + 1 + } + } + return next +} + +func cloneChannelMessageReplies(in *domain.ChannelMessageReplies) *domain.ChannelMessageReplies { + if in == nil { + return nil + } + out := *in + out.RecentRepliers = append([]domain.Peer(nil), in.RecentRepliers...) + return &out +} diff --git a/internal/store/memory/channel_updates.go b/internal/store/memory/channel_updates.go new file mode 100644 index 00000000..d5470ff1 --- /dev/null +++ b/internal/store/memory/channel_updates.go @@ -0,0 +1,207 @@ +package memory + +import ( + "context" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) ListChannelDifference(_ context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) { + s.mu.RLock() + defer s.mu.RUnlock() + channel, member, preview, err := s.channelForViewerLocked(req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelDifference{}, err + } + if req.Pts < 0 || req.Pts > channel.Pts { + return domain.ChannelDifference{}, domain.ErrPersistentTimestamp + } + if !preview && member.AvailableMinPts > req.Pts { + req.Pts = minInt(member.AvailableMinPts, channel.Pts) + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { + limit = domain.MaxChannelDifferenceLimit + } + dialog := s.dialogForUserLocked(req.UserID, channel) + if preview { + dialog = previewChannelDialog(req.UserID, channel, member) + } + if channel.Pts-req.Pts > limit { + messages := make([]domain.ChannelMessage, 0, domain.MaxChannelDifferenceTooLongMessages) + for i := len(s.messages[req.ChannelID]) - 1; i >= 0 && len(messages) < domain.MaxChannelDifferenceTooLongMessages; i-- { + msg := s.messages[req.ChannelID][i] + if msg.Deleted { + continue + } + if msg.ID <= member.AvailableMinID { + continue + } + messages = append(messages, cloneChannelMessage(msg)) + } + s.populateChannelMessageUnreadFlagsLocked(req.UserID, messages) + return domain.ChannelDifference{ + Channel: channel, + Self: member, + NewMessages: messages, + Pts: channel.Pts, + Final: true, + TooLong: true, + Timeout: 30, + Dialog: dialog, + }, nil + } + events := make([]domain.ChannelUpdateEvent, 0, limit) + lastPts := req.Pts + for _, event := range s.events[req.ChannelID] { + if event.Pts <= req.Pts { + continue + } + lastPts = event.Pts + visible, ok := domain.FilterChannelUpdateEventForAvailableMinID(cloneChannelEvent(event), member.AvailableMinID) + if !ok { + continue + } + if preview && visible.Type == domain.ChannelUpdateParticipant { + continue + } + events = append(events, visible) + } + if len(events) == 0 { + return domain.ChannelDifference{ + Channel: channel, + Self: member, + Pts: maxInt(lastPts, req.Pts), + Final: true, + Timeout: 30, + Dialog: dialog, + }, nil + } + diff := domain.ChannelDifference{ + Channel: channel, + Self: member, + Events: events, + Pts: lastPts, + Final: lastPts >= channel.Pts, + Timeout: 30, + Dialog: dialog, + } + for _, event := range events { + switch event.Type { + case domain.ChannelUpdateNewMessage: + diff.NewMessages = append(diff.NewMessages, cloneChannelMessage(event.Message)) + default: + diff.OtherUpdates = append(diff.OtherUpdates, cloneChannelEvent(event)) + } + } + s.populateChannelMessageUnreadFlagsLocked(req.UserID, diff.NewMessages) + for i := range diff.OtherUpdates { + if diff.OtherUpdates[i].Message.ID == 0 { + continue + } + messages := []domain.ChannelMessage{diff.OtherUpdates[i].Message} + s.populateChannelMessageUnreadFlagsLocked(req.UserID, messages) + diff.OtherUpdates[i].Message = messages[0] + } + return diff, nil +} + +func (s *ChannelStore) MaxChannelPts(_ context.Context, channelID int64) (int, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.ptsSeq[channelID], nil +} + +func (s *ChannelStore) nextChannelPtsLocked(channelID int64) int { + s.ptsSeq[channelID]++ + return s.ptsSeq[channelID] +} + +func (s *ChannelStore) nextChannelPtsNLocked(channelID int64, count int) int { + if count <= 0 { + return s.ptsSeq[channelID] + } + s.ptsSeq[channelID] += count + return s.ptsSeq[channelID] +} + +func transientChannelParticipantEvent(channelID, actorUserID int64, previous, participant domain.ChannelMember, date int) domain.ChannelUpdateEvent { + return domain.ChannelUpdateEvent{ + ChannelID: channelID, + Type: domain.ChannelUpdateParticipant, + Date: date, + SenderUserID: actorUserID, + UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID), + Previous: previous, + Participant: participant, + } +} + +func channelInitialAvailableMinPts(channel domain.Channel) int { + return channel.Pts +} + +func adminLogEventMatchesFilter(typ domain.ChannelAdminLogEventType, filter domain.ChannelAdminLogFilter) bool { + if filter.Empty() { + return true + } + switch typ { + case domain.ChannelAdminLogParticipantJoin: + return filter.Join + case domain.ChannelAdminLogParticipantLeave: + return filter.Leave + case domain.ChannelAdminLogParticipantInvite: + return filter.Invite || filter.Invites + case domain.ChannelAdminLogParticipantBan: + return filter.Ban + case domain.ChannelAdminLogParticipantUnban: + return filter.Unban + case domain.ChannelAdminLogParticipantKick: + return filter.Kick + case domain.ChannelAdminLogParticipantUnkick: + return filter.Unkick + case domain.ChannelAdminLogParticipantPromote: + return filter.Promote + case domain.ChannelAdminLogParticipantDemote: + return filter.Demote + case domain.ChannelAdminLogParticipantEditRank: + return filter.EditRank + case domain.ChannelAdminLogChangeTitle, domain.ChannelAdminLogChangeUsername, domain.ChannelAdminLogChangeLinkedChat, domain.ChannelAdminLogToggleSlowMode: + return filter.Info + case domain.ChannelAdminLogToggleSignatures, domain.ChannelAdminLogTogglePreHistoryHidden, domain.ChannelAdminLogToggleAntiSpam, domain.ChannelAdminLogToggleAutotranslation: + return filter.Settings + case domain.ChannelAdminLogToggleForum: + return filter.Settings || filter.Forums + case domain.ChannelAdminLogUpdatePinned: + return filter.Pinned + case domain.ChannelAdminLogEditMessage: + return filter.Edit + case domain.ChannelAdminLogDeleteMessage: + return filter.Delete + case domain.ChannelAdminLogSendMessage: + return filter.Send + default: + return false + } +} + +func adminLogEventMatchesQuery(event domain.ChannelAdminLogEvent, query string) bool { + if strings.Contains(strings.ToLower(event.PrevString), query) || + strings.Contains(strings.ToLower(event.NewString), query) || + strings.Contains(event.Query, query) { + return true + } + for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { + if msg != nil && strings.Contains(strings.ToLower(msg.Body), query) { + return true + } + } + return false +} + +func cloneChannelEvent(in domain.ChannelUpdateEvent) domain.ChannelUpdateEvent { + in.Message = cloneChannelMessage(in.Message) + in.MessageIDs = append([]int(nil), in.MessageIDs...) + in.UserIDs = append([]int64(nil), in.UserIDs...) + return in +} diff --git a/internal/store/memory/channel_webpage_resolve_test.go b/internal/store/memory/channel_webpage_resolve_test.go new file mode 100644 index 00000000..b89d1604 --- /dev/null +++ b/internal/store/memory/channel_webpage_resolve_test.go @@ -0,0 +1,68 @@ +package memory + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +// TestChannelStoreResolveWebPage 验证频道 WebPageResolve 模式:只换 media、不碰 body/edit_date, +// 事件为 channel_web_page,幂等。 +func TestChannelStoreResolveWebPage(t *testing.T) { + st := NewChannelStore() + ctx := context.Background() + const creator = int64(1000000301) + const url = "https://example.com/c" + urlHash := domain.WebPageURLHash(url) + + created, err := st.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: creator, Title: "WP", Megagroup: true, Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID := created.Channel.ID + + sent, err := st.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: creator, ChannelID: channelID, Message: "see " + url, Date: 1700000000, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStatePending, ID: urlHash, URL: url}}, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + + done := &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: urlHash, URL: url, Title: "Example"}} + resolveReq := domain.EditChannelMessageRequest{ + UserID: creator, ChannelID: channelID, ID: sent.Message.ID, + Media: done, WebPageResolve: true, ExpectedWebPageID: urlHash, + } + res, err := st.EditChannelMessage(ctx, resolveReq) + if err != nil { + t.Fatalf("EditChannelMessage(WebPageResolve): %v", err) + } + if res.Event.Type != domain.ChannelUpdateWebPage { + t.Fatalf("event type = %q, want channel_web_page", res.Event.Type) + } + if res.Message.Media == nil || res.Message.Media.WebPage == nil || res.Message.Media.WebPage.State != domain.MessageWebPageStateDone { + t.Fatalf("message media not resolved: %+v", res.Message.Media) + } + if res.Message.Body != "see "+url || res.Message.EditDate != 0 { + t.Fatalf("body/edit_date changed: body=%q edit_date=%d", res.Message.Body, res.Message.EditDate) + } + + if _, err := st.EditChannelMessage(ctx, resolveReq); !errors.Is(err, domain.ErrMessageNotModified) { + t.Fatalf("re-resolve err = %v, want ErrMessageNotModified", err) + } + + // 错误的 expectedID → 不替换。 + _, err = st.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: creator, ChannelID: channelID, ID: sent.Message.ID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: 999, URL: url}}, + WebPageResolve: true, ExpectedWebPageID: 999, + }) + if !errors.Is(err, domain.ErrMessageNotModified) { + t.Fatalf("wrong-id resolve err = %v, want ErrMessageNotModified (already resolved, not pending)", err) + } +} diff --git a/internal/store/memory/contacts.go b/internal/store/memory/contacts.go new file mode 100644 index 00000000..cb688ad4 --- /dev/null +++ b/internal/store/memory/contacts.go @@ -0,0 +1,429 @@ +package memory + +import ( + "context" + "encoding/binary" + "hash/fnv" + "sort" + "sync" + "telesrv/internal/domain" +) + +// ContactStore 是 store.ContactStore 的内存实现。 +type ContactStore struct { + mu sync.RWMutex + m map[int64]domain.ContactList + blocks map[int64]map[int64]domain.BlockedContact +} + +// NewContactStore 创建内存 ContactStore。 +func NewContactStore() *ContactStore { + return &ContactStore{ + m: make(map[int64]domain.ContactList), + blocks: make(map[int64]map[int64]domain.BlockedContact), + } +} + +func (s *ContactStore) ListByUser(_ context.Context, userID int64) (domain.ContactList, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + list.Contacts = cloneContacts(list.Contacts) + list.Hash = contactListHash(list.Contacts) + return list, nil +} + +func (s *ContactStore) Get(_ context.Context, userID, contactUserID int64) (domain.Contact, bool, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + for _, contact := range list.Contacts { + if contact.User.ID == contactUserID { + return cloneContact(contact), true, nil + } + } + return domain.Contact{}, false, nil +} + +func (s *ContactStore) GetMany(_ context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) { + out := make(map[int64]domain.Contact, len(contactUserIDs)) + if userID == 0 || len(contactUserIDs) == 0 { + return out, nil + } + want := make(map[int64]struct{}, len(contactUserIDs)) + for _, id := range contactUserIDs { + if id != 0 { + want[id] = struct{}{} + } + } + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + for _, contact := range list.Contacts { + if _, ok := want[contact.User.ID]; ok { + out[contact.User.ID] = cloneContact(contact) + } + } + return out, nil +} + +func (s *ContactStore) GetReverseContacts(_ context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) { + out := make(map[int64]domain.Contact, len(ownerUserIDs)) + if userID == 0 || len(ownerUserIDs) == 0 { + return out, nil + } + want := make(map[int64]struct{}, len(ownerUserIDs)) + for _, id := range ownerUserIDs { + if id != 0 { + want[id] = struct{}{} + } + } + s.mu.RLock() + defer s.mu.RUnlock() + for ownerID := range want { + for _, contact := range s.m[ownerID].Contacts { + if contact.User.ID == userID { + out[ownerID] = cloneContact(contact) + break + } + } + } + return out, nil +} + +func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { + contact := domain.Contact{ + User: domain.User{ + ID: input.ContactUserID, + Phone: input.Phone, + FirstName: input.FirstName, + LastName: input.LastName, + Contact: true, + }, + FirstName: input.FirstName, + LastName: input.LastName, + Phone: input.Phone, + Note: input.Note, + NoteEntities: append([]domain.MessageEntity(nil), input.NoteEntities...), + } + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + reverse := s.m[input.ContactUserID] + for i := range reverse.Contacts { + if reverse.Contacts[i].User.ID == userID { + reverse.Contacts[i].Mutual = true + reverse.Contacts[i].User.Mutual = true + contact.Mutual = true + contact.User.Mutual = true + s.m[input.ContactUserID] = reverse + break + } + } + for i, existing := range list.Contacts { + if existing.User.ID != input.ContactUserID { + continue + } + contact.User.AccessHash = existing.User.AccessHash + contact.User.Username = existing.User.Username + contact.User.CountryCode = existing.User.CountryCode + contact.User.Verified = existing.User.Verified + contact.User.Support = existing.User.Support + // premium/emoji/bot 列随快照保留(postgres 路径 JOIN users 始终带真实值; + // 双 store 行为对齐,防止重复 Upsert 抹掉已知状态)。 + contact.User.Bot = existing.User.Bot + contact.User.BotInfoVersion = existing.User.BotInfoVersion + contact.User.PremiumUntil = existing.User.PremiumUntil + contact.User.EmojiStatusDocumentID = existing.User.EmojiStatusDocumentID + contact.User.EmojiStatusUntil = existing.User.EmojiStatusUntil + contact.CloseFriend = existing.CloseFriend + contact.User.CloseFriend = existing.CloseFriend || existing.User.CloseFriend + if contact.Phone == "" { + contact.User.Phone = existing.User.Phone + } + if contact.FirstName == "" { + contact.User.FirstName = existing.User.FirstName + } + if contact.LastName == "" { + contact.User.LastName = existing.User.LastName + } + list.Contacts[i] = contact + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + return cloneContact(contact), nil + } + list.Contacts = append(list.Contacts, contact) + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + return cloneContact(contact), nil +} + +func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) { + if len(inputs) == 0 { + return nil, nil + } + out := make([]domain.Contact, 0, len(inputs)) + for _, input := range inputs { + contact, err := s.Upsert(ctx, userID, input) + if err != nil { + return nil, err + } + out = append(out, contact) + } + return out, nil +} + +func (s *ContactStore) UpdateNote(_ context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i := range list.Contacts { + if list.Contacts[i].User.ID != contactUserID { + continue + } + list.Contacts[i].Note = note + list.Contacts[i].NoteEntities = append([]domain.MessageEntity(nil), entities...) + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + return cloneContact(list.Contacts[i]), true, nil + } + return domain.Contact{}, false, nil +} + +func (s *ContactStore) SetCloseFriends(_ context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) { + want := make(map[int64]struct{}, len(contactUserIDs)) + for _, id := range contactUserIDs { + if id > 0 && id != userID { + want[id] = struct{}{} + } + } + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + changed := false + var result domain.CloseFriendsEditResult + for i := range list.Contacts { + wasCloseFriend := list.Contacts[i].CloseFriend || list.Contacts[i].User.CloseFriend + _, closeFriend := want[list.Contacts[i].User.ID] + if list.Contacts[i].CloseFriend == closeFriend && list.Contacts[i].User.CloseFriend == closeFriend { + continue + } + list.Contacts[i].CloseFriend = closeFriend + list.Contacts[i].User.CloseFriend = closeFriend + switch { + case !wasCloseFriend && closeFriend: + result.AddedUserIDs = append(result.AddedUserIDs, list.Contacts[i].User.ID) + case wasCloseFriend && !closeFriend: + result.RemovedUserIDs = append(result.RemovedUserIDs, list.Contacts[i].User.ID) + } + changed = true + } + if changed { + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + } + sort.Slice(result.AddedUserIDs, func(i, j int) bool { return result.AddedUserIDs[i] < result.AddedUserIDs[j] }) + sort.Slice(result.RemovedUserIDs, func(i, j int) bool { return result.RemovedUserIDs[i] < result.RemovedUserIDs[j] }) + return result, nil +} + +func (s *ContactStore) SetPersonalPhoto(_ context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) { + _ = date + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i := range list.Contacts { + if list.Contacts[i].User.ID != contactUserID { + continue + } + list.Contacts[i].User.PhotoID = photoID + list.Contacts[i].User.PhotoPersonal = photoID != 0 + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + return cloneContact(list.Contacts[i]), true, nil + } + return domain.Contact{}, false, nil +} + +func (s *ContactStore) PersonalPhotos(_ context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { + out := make(map[int64]domain.ProfilePhotoRef, len(contactUserIDs)) + if userID == 0 || len(contactUserIDs) == 0 { + return out, nil + } + want := make(map[int64]struct{}, len(contactUserIDs)) + for _, id := range contactUserIDs { + if id != 0 { + want[id] = struct{}{} + } + } + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + for _, contact := range list.Contacts { + if _, ok := want[contact.User.ID]; !ok || contact.User.PhotoID == 0 { + continue + } + out[contact.User.ID] = domain.ProfilePhotoRef{ + PhotoID: contact.User.PhotoID, + DCID: contact.User.PhotoDCID, + Stripped: append([]byte(nil), contact.User.PhotoStripped...), + Personal: true, + HasVideo: contact.User.PhotoHasVideo, + } + } + return out, nil +} + +func (s *ContactStore) Delete(_ context.Context, userID int64, contactUserIDs []int64) (int, error) { + remove := make(map[int64]struct{}, len(contactUserIDs)) + for _, id := range contactUserIDs { + if id != 0 { + remove[id] = struct{}{} + } + } + if len(remove) == 0 { + return 0, nil + } + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + out := list.Contacts[:0] + deleted := 0 + for _, contact := range list.Contacts { + if _, ok := remove[contact.User.ID]; ok { + deleted++ + if reverse := s.m[contact.User.ID]; len(reverse.Contacts) > 0 { + for i := range reverse.Contacts { + if reverse.Contacts[i].User.ID == userID { + reverse.Contacts[i].Mutual = false + reverse.Contacts[i].User.Mutual = false + } + } + reverse.Hash = contactListHash(reverse.Contacts) + s.m[contact.User.ID] = reverse + } + continue + } + out = append(out, contact) + } + list.Contacts = out + list.Hash = contactListHash(list.Contacts) + s.m[userID] = list + return deleted, nil +} + +func (s *ContactStore) Block(_ context.Context, userID, blockedUserID int64, date int) (bool, error) { + if userID == 0 || blockedUserID == 0 || userID == blockedUserID { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.blocks[userID] == nil { + s.blocks[userID] = make(map[int64]domain.BlockedContact) + } + _, existed := s.blocks[userID][blockedUserID] + s.blocks[userID][blockedUserID] = domain.BlockedContact{ + User: domain.User{ID: blockedUserID}, + Date: date, + } + return !existed, nil +} + +func (s *ContactStore) Unblock(_ context.Context, userID, blockedUserID int64) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.blocks[userID] == nil { + return false, nil + } + _, existed := s.blocks[userID][blockedUserID] + delete(s.blocks[userID], blockedUserID) + return existed, nil +} + +func (s *ContactStore) IsBlocked(_ context.Context, userID, blockedUserID int64) (bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + _, blocked := s.blocks[userID][blockedUserID] + return blocked, nil +} + +func (s *ContactStore) ListBlocked(_ context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + items := make([]domain.BlockedContact, 0, len(s.blocks[userID])) + for _, item := range s.blocks[userID] { + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Date == items[j].Date { + return items[i].User.ID > items[j].User.ID + } + return items[i].Date > items[j].Date + }) + total := len(items) + if offset < 0 { + offset = 0 + } + if offset >= len(items) { + return domain.BlockedContactList{Count: total}, nil + } + if limit <= 0 || limit > len(items)-offset { + limit = len(items) - offset + } + out := append([]domain.BlockedContact(nil), items[offset:offset+limit]...) + return domain.BlockedContactList{Blocked: out, Count: total}, nil +} + +// SaveList 保存一份用户通讯录,供测试和本地替身使用。 +func (s *ContactStore) SaveList(_ context.Context, userID int64, list domain.ContactList) error { + list.Contacts = cloneContacts(list.Contacts) + list.Hash = contactListHash(list.Contacts) + s.mu.Lock() + s.m[userID] = list + s.mu.Unlock() + return nil +} + +func cloneContacts(contacts []domain.Contact) []domain.Contact { + out := append([]domain.Contact(nil), contacts...) + for i := range out { + out[i] = cloneContact(out[i]) + } + return out +} + +func cloneContact(contact domain.Contact) domain.Contact { + contact.NoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...) + return contact +} + +func contactListHash(contacts []domain.Contact) int64 { + if len(contacts) == 0 { + return 0 + } + h := fnv.New64a() + var buf [16]byte + for _, contact := range contacts { + binary.LittleEndian.PutUint64(buf[:8], uint64(contact.User.ID)) + if contact.Mutual { + buf[8] = 1 + } else { + buf[8] = 0 + } + if contact.CloseFriend || contact.User.CloseFriend { + buf[9] = 1 + } else { + buf[9] = 0 + } + _, _ = h.Write(buf[:10]) + _, _ = h.Write([]byte(contact.FirstName)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(contact.LastName)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(contact.Phone)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(contact.Note)) + _, _ = h.Write([]byte{0}) + } + return int64(h.Sum64()) +} diff --git a/internal/store/memory/dialog_test.go b/internal/store/memory/dialog_test.go index 72629375..1ba20f52 100644 --- a/internal/store/memory/dialog_test.go +++ b/internal/store/memory/dialog_test.go @@ -51,6 +51,42 @@ func TestDialogStoreFiltersAndPaginates(t *testing.T) { } } +func TestDialogStorePinnedOrderSortsHighestFirst(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + oldPinned := domain.Peer{Type: domain.PeerTypeUser, ID: 1} + newPinned := domain.Peer{Type: domain.PeerTypeUser, ID: 2} + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{ + { + Peer: oldPinned, + TopMessage: 20, + TopMessageDate: 2000, + Pinned: true, + PinnedOrder: 1, + }, + { + Peer: newPinned, + TopMessage: 10, + TopMessageDate: 1000, + Pinned: true, + PinnedOrder: 2, + }, + }, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + got, err := store.ListByUser(ctx, userID, domain.DialogFilter{PinnedOnly: true, Limit: 10}) + if err != nil { + t.Fatalf("ListByUser pinned: %v", err) + } + if len(got.Dialogs) != 2 || got.Dialogs[0].Peer != newPinned || got.Dialogs[1].Peer != oldPinned { + t.Fatalf("pinned dialogs = %+v, want highest pinned_order first", got.Dialogs) + } +} + func TestDialogStoreFoldersAndCustomFilters(t *testing.T) { ctx := context.Background() store := NewDialogStore() @@ -227,3 +263,88 @@ func TestDialogStoreListByPeersReturnsExistingAndPlaceholders(t *testing.T) { t.Fatalf("users = %+v, want official user", got.Users) } } + +func TestDialogStoreSetUnreadMarkOnlyReportsRealChanges(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 200} + missing := domain.Peer{Type: domain.PeerTypeUser, ID: 999} + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{{Peer: peer, TopMessage: 10, TopMessageDate: 1000}}, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + // 首次置位为真变化。 + if changed, err := store.SetUnreadMark(ctx, userID, peer, true); err != nil || !changed { + t.Fatalf("first mark = (%v, %v), want (true, nil)", changed, err) + } + // 重复标记同值不应算 changed(值守卫;否则上层记幽灵 durable 事件 + 多推 update)。 + if changed, err := store.SetUnreadMark(ctx, userID, peer, true); err != nil || changed { + t.Fatalf("repeat same value = (%v, %v), want (false, nil)", changed, err) + } + // 改回相反值是真变化。 + if changed, err := store.SetUnreadMark(ctx, userID, peer, false); err != nil || !changed { + t.Fatalf("flip value = (%v, %v), want (true, nil)", changed, err) + } + // 不存在的会话行无法标记。 + if changed, err := store.SetUnreadMark(ctx, userID, missing, true); err != nil || changed { + t.Fatalf("missing peer = (%v, %v), want (false, nil)", changed, err) + } +} + +func TestDialogStoreMarkReadClampsFutureMaxID(t *testing.T) { + ctx := context.Background() + store := NewDialogStore() + userID := int64(100) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 200} + if err := store.SaveList(ctx, userID, domain.DialogList{ + Dialogs: []domain.Dialog{{ + Peer: peer, + TopMessage: 10, + TopMessageDate: 1000, + ReadInboxMaxID: 4, + UnreadCount: 2, + UnreadMentions: 1, + UnreadReactions: 1, + UnreadMark: true, + }}, + }); err != nil { + t.Fatalf("SaveList: %v", err) + } + + read, err := store.MarkRead(ctx, userID, peer, 8) + if err != nil { + t.Fatalf("MarkRead partial: %v", err) + } + if read.MaxID != 8 || read.StillUnreadCount != 2 { + t.Fatalf("partial read = %+v, want max 8 with existing unread count preserved", read) + } + list, err := store.ListByPeers(ctx, userID, []domain.Peer{peer}) + if err != nil { + t.Fatalf("ListByPeers partial: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].ReadInboxMaxID != 8 || list.Dialogs[0].UnreadCount != 2 { + t.Fatalf("dialog after partial read = %+v, want read 8 and unread preserved", list.Dialogs) + } + + read, err = store.MarkRead(ctx, userID, peer, domain.MaxMessageBoxID) + if err != nil { + t.Fatalf("MarkRead: %v", err) + } + if read.MaxID != 10 { + t.Fatalf("read max = %d, want top message 10", read.MaxID) + } + list, err = store.ListByPeers(ctx, userID, []domain.Peer{peer}) + if err != nil { + t.Fatalf("ListByPeers: %v", err) + } + if len(list.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want one dialog", list.Dialogs) + } + dialog := list.Dialogs[0] + if dialog.ReadInboxMaxID != 10 || dialog.UnreadCount != 0 || dialog.UnreadMentions != 0 || dialog.UnreadReactions != 0 || dialog.UnreadMark { + t.Fatalf("dialog after read = %+v, want read clamped to top and unread cleared", dialog) + } +} diff --git a/internal/store/memory/dialogs.go b/internal/store/memory/dialogs.go new file mode 100644 index 00000000..bbba34be --- /dev/null +++ b/internal/store/memory/dialogs.go @@ -0,0 +1,924 @@ +package memory + +import ( + "context" + "encoding/binary" + "hash/fnv" + "sort" + "sync" + "telesrv/internal/domain" +) + +// DialogStore 是 store.DialogStore 的内存实现。 +type DialogStore struct { + mu sync.RWMutex + m map[int64]domain.DialogList + drafts map[int64]map[dialogDraftKey]domain.DialogDraft + folders map[int64]map[int]domain.DialogFolder + folderOrder map[int64][]int + folderTags map[int64]bool + // archivePinned 记录 archive folder 行置顶状态;无记录时官方默认 true。 + archivePinned map[int64]bool +} + +type dialogDraftKey struct { + peerType domain.PeerType + peerID int64 + topMessageID int +} + +// NewDialogStore 创建内存 DialogStore。 +func NewDialogStore() *DialogStore { + return &DialogStore{ + m: make(map[int64]domain.DialogList), + drafts: make(map[int64]map[dialogDraftKey]domain.DialogDraft), + folders: make(map[int64]map[int]domain.DialogFolder), + folderOrder: make(map[int64][]int), + folderTags: make(map[int64]bool), + archivePinned: make(map[int64]bool), + } +} + +func (s *DialogStore) ListByUser(_ context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + list.Dialogs = cloneDialogs(list.Dialogs) + list.Messages = cloneMessages(list.Messages) + list.Users = append([]domain.User(nil), list.Users...) + return filterDialogList(list, filter), nil +} + +func (s *DialogStore) ListByPeers(_ context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + list.Dialogs = cloneDialogs(list.Dialogs) + list.Messages = cloneMessages(list.Messages) + list.Users = append([]domain.User(nil), list.Users...) + + byPeer := make(map[domain.Peer]domain.Dialog, len(list.Dialogs)) + for _, dialog := range list.Dialogs { + byPeer[dialog.Peer] = dialog + } + out := domain.DialogList{ + Dialogs: make([]domain.Dialog, 0, len(peers)), + Users: make([]domain.User, 0, len(peers)), + } + seenPeers := make(map[domain.Peer]struct{}, len(peers)) + seenUsers := map[int64]struct{}{} + for _, peer := range peers { + if _, ok := seenPeers[peer]; ok { + continue + } + seenPeers[peer] = struct{}{} + dialog := byPeer[peer] + if dialog.Peer.ID == 0 { + dialog.Peer = peer + } + out.Dialogs = append(out.Dialogs, dialog) + if peer.Type == domain.PeerTypeUser { + if user, ok := findDialogUser(list.Users, peer.ID); ok { + appendDialogUser(&out, seenUsers, user) + } else if u, ok := domain.SystemUserByID(peer.ID); ok { + appendDialogUser(&out, seenUsers, u) + } + } + } + out.Messages = keepDialogMessages(list.Messages, out.Dialogs) + out.Count = len(out.Dialogs) + out.Hash = dialogListHash(out.Dialogs) + return out, nil +} + +// SaveList 保存一份用户会话列表,供测试和本地替身使用。 +func (s *DialogStore) SaveList(_ context.Context, userID int64, list domain.DialogList) error { + list.Dialogs = cloneDialogs(list.Dialogs) + list.Messages = cloneMessages(list.Messages) + list.Users = append([]domain.User(nil), list.Users...) + s.mu.Lock() + s.m[userID] = list + s.mu.Unlock() + return nil +} + +func (s *DialogStore) Upsert(_ context.Context, userID int64, dialog domain.Dialog) error { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i, existing := range list.Dialogs { + if existing.Peer == dialog.Peer { + if dialog.FolderID == domain.DialogMainFolderID && existing.FolderID != domain.DialogMainFolderID { + dialog.FolderID = existing.FolderID + } + list.Dialogs[i] = dialog + s.m[userID] = list + return nil + } + } + list.Dialogs = append(list.Dialogs, dialog) + s.m[userID] = list + return nil +} + +func (s *DialogStore) UpsertInbox(_ context.Context, userID int64, dialog domain.Dialog) error { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i, existing := range list.Dialogs { + if existing.Peer != dialog.Peer { + continue + } + if dialog.TopMessage >= existing.TopMessage { + existing.TopMessage = dialog.TopMessage + existing.TopMessageDate = dialog.TopMessageDate + } + existing.UnreadCount = countInboxUnread(list, existing.Peer, existing.ReadInboxMaxID, existing.TopMessage) + list.Dialogs[i] = existing + s.m[userID] = list + return nil + } + dialog.UnreadCount = countInboxUnread(list, dialog.Peer, dialog.ReadInboxMaxID, dialog.TopMessage) + if dialog.UnreadCount == 0 { + dialog.UnreadCount = 1 + } + list.Dialogs = append(list.Dialogs, dialog) + s.m[userID] = list + return nil +} + +func countInboxUnread(list domain.DialogList, peer domain.Peer, readMax, topMessage int) int { + unread := 0 + for _, msg := range list.Messages { + if msg.Peer == peer && !msg.Out && msg.ID > readMax && (topMessage <= 0 || msg.ID <= topMessage) { + unread++ + } + } + return unread +} + +func (s *DialogStore) SaveDraft(_ context.Context, userID int64, draft domain.DialogDraft) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.drafts[userID] == nil { + s.drafts[userID] = make(map[dialogDraftKey]domain.DialogDraft) + } + s.drafts[userID][draftKey(draft.Peer, draft.TopMessageID)] = cloneDialogDraft(draft) + return nil +} + +func (s *DialogStore) GetDraft(_ context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + draft, ok := s.drafts[userID][draftKey(peer, topMessageID)] + if !ok { + return domain.DialogDraft{}, false, nil + } + return cloneDialogDraft(draft), true, nil +} + +func (s *DialogStore) DeleteDraft(_ context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + items := s.drafts[userID] + if len(items) == 0 { + return false, nil + } + key := draftKey(peer, topMessageID) + if _, ok := items[key]; !ok { + return false, nil + } + delete(items, key) + return true, nil +} + +func (s *DialogStore) ListDrafts(_ context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + s.mu.RLock() + items := s.drafts[userID] + out := make([]domain.DialogDraft, 0, len(items)) + for _, draft := range items { + out = append(out, cloneDialogDraft(draft)) + } + s.mu.RUnlock() + sortDialogDrafts(out) + if limit <= 0 || limit > domain.MaxDialogDraftsPerUser { + limit = domain.MaxDialogDraftsPerUser + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (s *DialogStore) ClearDrafts(_ context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { + if limit <= 0 || limit > domain.MaxDialogDraftsPerUser { + limit = domain.MaxDialogDraftsPerUser + } + s.mu.Lock() + defer s.mu.Unlock() + items := s.drafts[userID] + if len(items) == 0 { + return nil, nil + } + out := make([]domain.DialogDraft, 0, len(items)) + for _, draft := range items { + out = append(out, cloneDialogDraft(draft)) + } + sortDialogDrafts(out) + if len(out) > limit { + out = out[:limit] + } + for _, draft := range out { + delete(items, draftKey(draft.Peer, draft.TopMessageID)) + } + return out, nil +} + +func (s *DialogStore) MarkRead(_ context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) { + result := domain.ReadHistoryResult{OwnerUserID: userID, Peer: peer, MaxID: maxID} + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i, dialog := range list.Dialogs { + if dialog.Peer != peer { + continue + } + readMax := maxID + if readMax <= 0 { + readMax = dialog.TopMessage + } + if readMax > dialog.TopMessage { + readMax = dialog.TopMessage + } + result.MaxID = readMax + result.Changed = dialog.UnreadCount > 0 || readMax > dialog.ReadInboxMaxID + if readMax > dialog.ReadInboxMaxID { + dialog.ReadInboxMaxID = readMax + } + if readMax >= dialog.TopMessage { + dialog.UnreadCount = 0 + dialog.UnreadMentions = 0 + dialog.UnreadReactions = 0 + } + dialog.UnreadMark = false + result.StillUnreadCount = dialog.UnreadCount + list.Dialogs[i] = dialog + s.m[userID] = list + return result, nil + } + return result, nil +} + +func (s *DialogStore) SetPinned(_ context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + targetFolderID := domain.DialogMainFolderID + for _, dialog := range list.Dialogs { + if dialog.Peer == peer { + targetFolderID = dialog.FolderID + break + } + } + // order 仅在目标会话所在 folder 内分配;memory 双 store 互不可见, + // 与 postgres 跨表统一 order 空间相比此处只看私聊表(reorder 会统一重排)。 + nextOrder := 1 + for _, dialog := range list.Dialogs { + if dialog.Pinned && dialog.FolderID == targetFolderID && dialog.PinnedOrder >= nextOrder { + nextOrder = dialog.PinnedOrder + 1 + } + } + for i := range list.Dialogs { + if list.Dialogs[i].Peer != peer { + continue + } + list.Dialogs[i].Pinned = pinned + if pinned { + if list.Dialogs[i].PinnedOrder == 0 { + list.Dialogs[i].PinnedOrder = nextOrder + } + } else { + list.Dialogs[i].PinnedOrder = 0 + } + s.m[userID] = list + return true, targetFolderID, nil + } + return false, 0, nil +} + +func (s *DialogStore) ReorderPinned(_ context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + changed := false + positions := make(map[domain.Peer]int, len(order)) + for i, peer := range order { + if peer.Type == "" || peer.ID == 0 { + continue + } + if _, ok := positions[peer]; ok { + continue + } + positions[peer] = len(order) - i + } + for i := range list.Dialogs { + if list.Dialogs[i].FolderID != folderID { + continue + } + pos, ok := positions[list.Dialogs[i].Peer] + if ok { + if !list.Dialogs[i].Pinned || list.Dialogs[i].PinnedOrder != pos { + changed = true + } + list.Dialogs[i].Pinned = true + list.Dialogs[i].PinnedOrder = pos + continue + } + if force && list.Dialogs[i].Pinned { + changed = true + list.Dialogs[i].Pinned = false + list.Dialogs[i].PinnedOrder = 0 + } + } + s.m[userID] = list + return changed, nil +} + +func (s *DialogStore) SetUnreadMark(_ context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i := range list.Dialogs { + if list.Dialogs[i].Peer != peer { + continue + } + // 值守卫对齐 postgres:重复标记同值返回 changed=false,避免幽灵事件。 + if list.Dialogs[i].UnreadMark == unread { + return false, nil + } + list.Dialogs[i].UnreadMark = unread + s.m[userID] = list + return true, nil + } + return false, nil +} + +func (s *DialogStore) ListUnreadMarked(_ context.Context, userID int64) ([]domain.Peer, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + out := make([]domain.Peer, 0, len(list.Dialogs)) + for _, dialog := range list.Dialogs { + if dialog.UnreadMark { + out = append(out, dialog.Peer) + } + } + return out, nil +} + +func (s *DialogStore) SetChatTheme(_ context.Context, userID int64, peer domain.Peer, emoticon string) (bool, error) { + if userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i := range list.Dialogs { + if list.Dialogs[i].Peer != peer { + continue + } + if list.Dialogs[i].ThemeEmoticon == emoticon { + return false, nil + } + list.Dialogs[i].ThemeEmoticon = emoticon + s.m[userID] = list + return true, nil + } + if emoticon == "" { + return false, nil + } + list.Dialogs = append(list.Dialogs, domain.Dialog{Peer: peer, ThemeEmoticon: emoticon}) + s.m[userID] = list + return true, nil +} + +func (s *DialogStore) SetPeerSettingsBarHidden(_ context.Context, userID int64, peer domain.Peer) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + for i := range list.Dialogs { + if list.Dialogs[i].Peer != peer { + continue + } + list.Dialogs[i].PeerSettingsBarHidden = true + s.m[userID] = list + return true, nil + } + return false, nil +} + +func (s *DialogStore) PeerSettingsBarHidden(_ context.Context, userID int64, peer domain.Peer) (bool, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + for _, dialog := range list.Dialogs { + if dialog.Peer == peer { + return dialog.PeerSettingsBarHidden, nil + } + } + return false, nil +} + +func (s *DialogStore) ListFolders(_ context.Context, userID int64) (domain.DialogFolderList, error) { + s.mu.RLock() + defer s.mu.RUnlock() + byID := s.folders[userID] + order := append([]int(nil), s.folderOrder[userID]...) + seen := make(map[int]struct{}, len(byID)) + out := domain.DialogFolderList{ + TagsEnabled: s.folderTags[userID], + Folders: make([]domain.DialogFolder, 0, len(byID)), + } + for _, id := range order { + folder, ok := byID[id] + if !ok { + continue + } + seen[id] = struct{}{} + out.Folders = append(out.Folders, cloneDialogFolder(folder)) + } + remaining := make([]int, 0, len(byID)) + for id := range byID { + if _, ok := seen[id]; !ok { + remaining = append(remaining, id) + } + } + sort.Ints(remaining) + for _, id := range remaining { + out.Folders = append(out.Folders, cloneDialogFolder(byID[id])) + } + return out, nil +} + +func (s *DialogStore) GetFolder(_ context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + folder, ok := s.folders[userID][folderID] + if !ok { + return domain.DialogFolder{}, false, nil + } + return cloneDialogFolder(folder), true, nil +} + +func (s *DialogStore) UpsertFolder(_ context.Context, userID int64, folder domain.DialogFolder) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.folders[userID] == nil { + s.folders[userID] = make(map[int]domain.DialogFolder) + } + s.folders[userID][folder.ID] = cloneDialogFolder(folder) + if !containsInt(s.folderOrder[userID], folder.ID) { + s.folderOrder[userID] = append(s.folderOrder[userID], folder.ID) + } + return nil +} + +func (s *DialogStore) DeleteFolder(_ context.Context, userID int64, folderID int) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.folders[userID], folderID) + s.folderOrder[userID] = removeInt(s.folderOrder[userID], folderID) + return nil +} + +func (s *DialogStore) ReorderFolders(_ context.Context, userID int64, order []int) error { + s.mu.Lock() + defer s.mu.Unlock() + byID := s.folders[userID] + seen := make(map[int]struct{}, len(order)) + next := make([]int, 0, len(byID)) + for _, id := range order { + if id < domain.DialogCustomFolderMinID { + continue + } + if _, ok := byID[id]; !ok { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + next = append(next, id) + } + remaining := make([]int, 0, len(byID)) + for id := range byID { + if _, ok := seen[id]; !ok { + remaining = append(remaining, id) + } + } + sort.Ints(remaining) + next = append(next, remaining...) + s.folderOrder[userID] = next + return nil +} + +func (s *DialogStore) SetFolderTagsEnabled(_ context.Context, userID int64, enabled bool) error { + s.mu.Lock() + s.folderTags[userID] = enabled + s.mu.Unlock() + return nil +} + +func (s *DialogStore) EditPeerFolders(_ context.Context, userID int64, peers []domain.FolderPeerUpdate) error { + s.mu.Lock() + defer s.mu.Unlock() + list := s.m[userID] + updates := make(map[domain.Peer]int, len(peers)) + for _, item := range peers { + if item.Peer.Type == "" || item.Peer.ID == 0 { + continue + } + if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { + continue + } + updates[item.Peer] = item.FolderID + } + for i := range list.Dialogs { + folderID, ok := updates[list.Dialogs[i].Peer] + if !ok { + continue + } + // 换 folder 时清 pinned:TDesktop 在归档/还原时本地无条件 unpin, + // 服务端保留旧 pin 会在下次 getDialogs 时把状态漂移回来。 + if list.Dialogs[i].FolderID != folderID { + list.Dialogs[i].Pinned = false + list.Dialogs[i].PinnedOrder = 0 + } + list.Dialogs[i].FolderID = folderID + } + s.m[userID] = list + return nil +} + +func (s *DialogStore) SetArchivePinned(_ context.Context, userID int64, pinned bool) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + current, ok := s.archivePinned[userID] + if !ok { + current = true + } + s.archivePinned[userID] = pinned + return current != pinned, nil +} + +func (s *DialogStore) ArchivePinned(_ context.Context, userID int64) (bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + if pinned, ok := s.archivePinned[userID]; ok { + return pinned, nil + } + return true, nil +} + +func (s *DialogStore) CountArchiveUnread(_ context.Context, userID int64) (int, int, error) { + s.mu.RLock() + list := s.m[userID] + s.mu.RUnlock() + peers, messages := 0, 0 + for _, dialog := range list.Dialogs { + if dialog.FolderID != domain.DialogArchiveFolderID { + continue + } + if dialog.UnreadCount > 0 || dialog.UnreadMark { + peers++ + } + messages += dialog.UnreadCount + } + return peers, messages, nil +} + +func upsertMemoryDialog(list domain.DialogList, dialog domain.Dialog) domain.DialogList { + for i := range list.Dialogs { + if list.Dialogs[i].Peer == dialog.Peer { + if dialog.ReadInboxMaxID == 0 { + dialog.ReadInboxMaxID = list.Dialogs[i].ReadInboxMaxID + } + if dialog.ReadOutboxMaxID == 0 { + dialog.ReadOutboxMaxID = list.Dialogs[i].ReadOutboxMaxID + } + if dialog.FolderID == domain.DialogMainFolderID && list.Dialogs[i].FolderID != domain.DialogMainFolderID { + dialog.FolderID = list.Dialogs[i].FolderID + } + if dialog.UnreadCount == 0 { + dialog.UnreadCount = list.Dialogs[i].UnreadCount + } + if dialog.UnreadMentions == 0 { + dialog.UnreadMentions = list.Dialogs[i].UnreadMentions + } + if dialog.UnreadReactions == 0 { + dialog.UnreadReactions = list.Dialogs[i].UnreadReactions + } + if !dialog.UnreadMark { + dialog.UnreadMark = list.Dialogs[i].UnreadMark + } + // 与 postgres 的 ON CONFLICT 局部更新对齐:消息路径的 upsert + // 不得抹掉既有置顶/设置类状态。 + if !dialog.Pinned && list.Dialogs[i].Pinned { + dialog.Pinned = list.Dialogs[i].Pinned + dialog.PinnedOrder = list.Dialogs[i].PinnedOrder + } + if dialog.TTLPeriod == 0 { + dialog.TTLPeriod = list.Dialogs[i].TTLPeriod + } + if dialog.ThemeEmoticon == "" { + dialog.ThemeEmoticon = list.Dialogs[i].ThemeEmoticon + } + if !dialog.HasScheduled { + dialog.HasScheduled = list.Dialogs[i].HasScheduled + } + if !dialog.PeerSettingsBarHidden { + dialog.PeerSettingsBarHidden = list.Dialogs[i].PeerSettingsBarHidden + } + if !dialog.ViewForumAsMessages { + dialog.ViewForumAsMessages = list.Dialogs[i].ViewForumAsMessages + } + if dialog.Draft == nil { + dialog.Draft = list.Dialogs[i].Draft + } + list.Dialogs[i] = dialog + return list + } + } + list.Dialogs = append(list.Dialogs, dialog) + return list +} + +func filterDialogList(list domain.DialogList, filter domain.DialogFilter) domain.DialogList { + sort.SliceStable(list.Dialogs, func(i, j int) bool { + return dialogLess(list.Dialogs[i], list.Dialogs[j]) + }) + + base := make([]domain.Dialog, 0, len(list.Dialogs)) + for _, d := range list.Dialogs { + if !dialogMatchesFolder(d, list.Users, filter) { + continue + } + if filter.PinnedOnly && !d.Pinned { + continue + } + if filter.ExcludePinned && d.Pinned { + continue + } + base = append(base, d) + } + + list.Count = len(base) + list.Hash = dialogListHash(base) + limit := filter.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + page := make([]domain.Dialog, 0, len(base)) + for _, d := range base { + if !afterDialogOffset(d, filter) { + continue + } + page = append(page, d) + if len(page) >= limit { + break + } + } + list.Dialogs = page + list.Messages = keepDialogMessages(list.Messages, page) + return list +} + +func dialogMatchesFolder(d domain.Dialog, users []domain.User, filter domain.DialogFilter) bool { + if !filter.HasFolderID { + // 不带 folder_id 视为主列表(folder 0):归档对话只以 dialogFolder + // 聚合条目出现,DrKLO Android 主列表请求不设 flag。 + return d.FolderID == domain.DialogMainFolderID + } + if filter.FolderID < domain.DialogCustomFolderMinID { + return d.FolderID == filter.FolderID + } + if filter.Folder == nil { + return false + } + folder := filter.Folder + if folder.ExcludeArchived && d.FolderID == domain.DialogArchiveFolderID { + return false + } + if folder.ExcludeRead && d.UnreadCount == 0 && !d.UnreadMark { + return false + } + if hasFolderPeer(folder.ExcludePeers, d.Peer) { + return false + } + if hasFolderPeer(folder.IncludePeers, d.Peer) || hasFolderPeer(folder.PinnedPeers, d.Peer) { + return true + } + if d.Peer.Type == domain.PeerTypeUser { + user, ok := findDialogUser(users, d.Peer.ID) + if ok && user.Contact && folder.Contacts { + return true + } + if (!ok || !user.Contact) && folder.NonContacts { + return true + } + } + return false +} + +func hasFolderPeer(peers []domain.DialogFolderPeer, peer domain.Peer) bool { + for _, item := range peers { + if item.Peer == peer { + return true + } + } + return false +} + +func dialogLess(a, b domain.Dialog) bool { + if a.Pinned != b.Pinned { + return a.Pinned && !b.Pinned + } + if a.Pinned && b.Pinned && a.PinnedOrder != b.PinnedOrder { + if a.PinnedOrder == 0 { + return false + } + if b.PinnedOrder == 0 { + return true + } + return a.PinnedOrder > b.PinnedOrder + } + if a.TopMessageDate != b.TopMessageDate { + return a.TopMessageDate > b.TopMessageDate + } + if a.TopMessage != b.TopMessage { + return a.TopMessage > b.TopMessage + } + return a.Peer.ID > b.Peer.ID +} + +func afterDialogOffset(d domain.Dialog, filter domain.DialogFilter) bool { + if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { + return true + } + if filter.OffsetDate > 0 { + if d.TopMessageDate != filter.OffsetDate { + return d.TopMessageDate < filter.OffsetDate + } + if filter.OffsetID <= 0 { + return false + } + if d.TopMessage != filter.OffsetID { + return d.TopMessage < filter.OffsetID + } + if filter.HasOffsetPeer { + return d.Peer.ID < filter.OffsetPeer.ID + } + return false + } + return d.TopMessage < filter.OffsetID +} + +func keepDialogMessages(messages []domain.Message, dialogs []domain.Dialog) []domain.Message { + want := make(map[int]struct{}, len(dialogs)) + for _, d := range dialogs { + if d.TopMessage != 0 { + want[d.TopMessage] = struct{}{} + } + } + out := make([]domain.Message, 0, len(want)) + for _, msg := range messages { + if _, ok := want[msg.ID]; ok { + out = append(out, msg) + } + } + return out +} + +func findDialogUser(users []domain.User, id int64) (domain.User, bool) { + for _, user := range users { + if user.ID == id { + return user, true + } + } + return domain.User{}, false +} + +func appendDialogUser(list *domain.DialogList, seen map[int64]struct{}, user domain.User) { + if user.ID == 0 { + return + } + if _, ok := seen[user.ID]; ok { + return + } + seen[user.ID] = struct{}{} + list.Users = append(list.Users, user) +} + +func cloneDialogs(dialogs []domain.Dialog) []domain.Dialog { + out := append([]domain.Dialog(nil), dialogs...) + for i := range out { + if out[i].Draft != nil { + draft := cloneDialogDraft(*out[i].Draft) + out[i].Draft = &draft + } + } + return out +} + +func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft { + draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...) + draft.ReplyTo = cloneMessageReply(draft.ReplyTo) + if draft.WebPage != nil { + webpage := *draft.WebPage + draft.WebPage = &webpage + } + return draft +} + +func sortDialogDrafts(drafts []domain.DialogDraft) { + sort.SliceStable(drafts, func(i, j int) bool { + if drafts[i].Date != drafts[j].Date { + return drafts[i].Date > drafts[j].Date + } + if drafts[i].Peer.Type != drafts[j].Peer.Type { + return drafts[i].Peer.Type < drafts[j].Peer.Type + } + if drafts[i].Peer.ID != drafts[j].Peer.ID { + return drafts[i].Peer.ID > drafts[j].Peer.ID + } + return drafts[i].TopMessageID > drafts[j].TopMessageID + }) +} + +func cloneDialogFolder(folder domain.DialogFolder) domain.DialogFolder { + folder.TitleEntities = append([]domain.MessageEntity(nil), folder.TitleEntities...) + folder.PinnedPeers = append([]domain.DialogFolderPeer(nil), folder.PinnedPeers...) + folder.IncludePeers = append([]domain.DialogFolderPeer(nil), folder.IncludePeers...) + folder.ExcludePeers = append([]domain.DialogFolderPeer(nil), folder.ExcludePeers...) + return folder +} + +func containsInt(items []int, value int) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} + +func removeInt(items []int, value int) []int { + out := items[:0] + for _, item := range items { + if item != value { + out = append(out, item) + } + } + return out +} + +func dialogListHash(dialogs []domain.Dialog) int64 { + if len(dialogs) == 0 { + return 0 + } + h := fnv.New64a() + var buf [47]byte + for _, d := range dialogs { + binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(d.FolderID)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(d.TopMessage)) + binary.LittleEndian.PutUint32(buf[16:20], uint32(d.TopMessageDate)) + binary.LittleEndian.PutUint32(buf[20:24], uint32(d.ReadInboxMaxID)) + binary.LittleEndian.PutUint32(buf[24:28], uint32(d.ReadOutboxMaxID)) + binary.LittleEndian.PutUint32(buf[28:32], uint32(d.UnreadCount)) + binary.LittleEndian.PutUint32(buf[32:36], uint32(d.UnreadMentions)) + binary.LittleEndian.PutUint32(buf[36:40], uint32(d.UnreadReactions)) + if d.Pinned { + buf[40] = 1 + } else { + buf[40] = 0 + } + binary.LittleEndian.PutUint32(buf[41:45], uint32(d.PinnedOrder)) + if d.UnreadMark { + buf[45] = 1 + } else { + buf[45] = 0 + } + if d.PeerSettingsBarHidden { + buf[46] = 1 + } else { + buf[46] = 0 + } + _, _ = h.Write(buf[:]) + _, _ = h.Write([]byte(d.ThemeEmoticon)) + } + return int64(h.Sum64()) +} diff --git a/internal/store/memory/doc.go b/internal/store/memory/doc.go new file mode 100644 index 00000000..a9674314 --- /dev/null +++ b/internal/store/memory/doc.go @@ -0,0 +1,5 @@ +// Package memory 提供 store 各接口的内存实现,用作测试替身与本地开发兜底。 +// +// 与 store/postgres、store/redisstore 对称:store 主包只定义接口与 DTO, +// 三种后端实现各自独立成包。 +package memory diff --git a/internal/store/memory/groupcall.go b/internal/store/memory/groupcall.go new file mode 100644 index 00000000..f200518d --- /dev/null +++ b/internal/store/memory/groupcall.go @@ -0,0 +1,465 @@ +package memory + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "sync" + + "telesrv/internal/domain" +) + +// GroupCallStore 是 store.GroupCallStore 的进程内实现(rpc 单测 fixture 用)。 +// 行为契约与 postgres 实现由共享 contract test 钉死(groupcall contract test), +// 凡动 version/参与者语义两边必须同步并跑 PG 集成。 +type overrideKey struct { + callID, setter, target int64 +} + +type GroupCallStore struct { + mu sync.Mutex + calls map[int64]domain.GroupCall + activeByChan map[int64]int64 // channelID → active callID + participants map[int64]map[int64]domain.GroupCallParticipant // callID → userID → row + overrides map[overrideKey]domain.GroupCallParticipantOverride + raiseHandSeq map[int64]int64 // callID → 单调举手序号 + nextSyntheticID int64 +} + +// NewGroupCallStore 创建内存实现。 +func NewGroupCallStore() *GroupCallStore { + return &GroupCallStore{ + calls: make(map[int64]domain.GroupCall), + activeByChan: make(map[int64]int64), + participants: make(map[int64]map[int64]domain.GroupCallParticipant), + overrides: make(map[overrideKey]domain.GroupCallParticipantOverride), + raiseHandSeq: make(map[int64]int64), + } +} + +func (s *GroupCallStore) CreateGroupCall(_ context.Context, call domain.GroupCall) (domain.GroupCall, error) { + if call.ID == 0 || call.ChannelID == 0 || call.AccessHash == 0 { + return domain.GroupCall{}, domain.ErrGroupCallInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if activeID, ok := s.activeByChan[call.ChannelID]; ok { + if existing, ok := s.calls[activeID]; ok && existing.Active() { + return domain.GroupCall{}, domain.ErrGroupCallAlreadyStarted + } + } + if _, exists := s.calls[call.ID]; exists { + return domain.GroupCall{}, domain.ErrGroupCallInvalid + } + call.State = domain.GroupCallStateActive + if call.Version <= 0 { + call.Version = 1 + } + call.ParticipantsCount = 0 + s.calls[call.ID] = call + s.activeByChan[call.ChannelID] = call.ID + s.participants[call.ID] = make(map[int64]domain.GroupCallParticipant) + return call, nil +} + +func (s *GroupCallStore) GetGroupCall(_ context.Context, callID int64) (domain.GroupCall, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + call, ok := s.calls[callID] + return call, ok, nil +} + +func (s *GroupCallStore) JoinGroupCall(_ context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) { + if req.SSRC == 0 { + return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + call, ok := s.calls[req.CallID] + if !ok { + return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid + } + if !call.Active() { + return domain.GroupCallMutation{}, domain.ErrGroupCallDiscarded + } + rows := s.participants[req.CallID] + for userID, p := range rows { + if !p.Left && p.SSRC == req.SSRC && userID != req.UserID { + return domain.GroupCallMutation{}, domain.ErrGroupCallSSRCDuplicate + } + } + existing, rejoining := rows[req.UserID] + wasActive := rejoining && !existing.Left + p := domain.GroupCallParticipant{ + CallID: req.CallID, + UserID: req.UserID, + SSRC: req.SSRC, + JoinDate: req.Now, + ActiveDate: req.Now, + LastCheckDate: req.Now, + // VideoJSON 整体替换、PresentationJSON 随全新行清空(rejoin 后客户端 + // 会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。 + VideoJSON: append([]byte(nil), req.VideoJSON...), + } + if rejoining && wasActive { + // 同设备换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。 + p.JoinDate = existing.JoinDate + } + // join_muted 策略:普通成员入会即静音且不可自行开麦(muted_by_admin)。 + if call.JoinMuted && !req.IsAdmin { + p.Muted = true + p.MutedByAdmin = true + } + rows[req.UserID] = p + if !wasActive { + call.ParticipantsCount++ + } + call.Version++ + s.calls[req.CallID] = call + return domain.GroupCallMutation{Call: call, Participant: p}, nil +} + +func (s *GroupCallStore) LeaveGroupCall(_ context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error) { + s.mu.Lock() + defer s.mu.Unlock() + call, ok := s.calls[callID] + if !ok { + return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid + } + p, ok := s.participants[callID][userID] + if !ok || p.Left { + return domain.GroupCallMutation{}, domain.ErrGroupCallNotJoined + } + p.Left = true + p.ActiveDate = now + s.participants[callID][userID] = p + if call.ParticipantsCount > 0 { + call.ParticipantsCount-- + } + call.Version++ + s.calls[callID] = call + return domain.GroupCallMutation{Call: call, Participant: p}, nil +} + +func (s *GroupCallStore) DiscardGroupCall(_ context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) { + s.mu.Lock() + defer s.mu.Unlock() + call, ok := s.calls[callID] + if !ok { + return domain.GroupCall{}, nil, domain.ErrGroupCallInvalid + } + if !call.Active() { + return domain.GroupCall{}, nil, domain.ErrGroupCallDiscarded + } + var active []domain.GroupCallParticipant + for userID, p := range s.participants[callID] { + if p.Left { + continue + } + active = append(active, p) + p.Left = true + p.ActiveDate = now + s.participants[callID][userID] = p + } + call.State = domain.GroupCallStateDiscarded + call.DiscardedAt = now + call.Duration = max(0, now-call.CreatedAt) + call.ParticipantsCount = 0 + call.Version++ + s.calls[callID] = call + if s.activeByChan[call.ChannelID] == callID { + delete(s.activeByChan, call.ChannelID) + } + sort.Slice(active, func(i, j int) bool { return active[i].UserID < active[j].UserID }) + return call, active, nil +} + +func (s *GroupCallStore) TouchParticipant(_ context.Context, callID, userID int64, now int) ([]int64, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + call, ok := s.calls[callID] + if !ok { + return nil, false, domain.ErrGroupCallInvalid + } + if !call.Active() { + return nil, false, nil + } + p, ok := s.participants[callID][userID] + if !ok || p.Left { + return nil, false, nil + } + p.LastCheckDate = now + p.ActiveDate = now + s.participants[callID][userID] = p + return []int64{p.SSRC}, true, nil +} + +func (s *GroupCallStore) GetParticipant(_ context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.participants[callID][userID] + return p, ok, nil +} + +func (s *GroupCallStore) ListParticipants(_ context.Context, callID int64, offset string, limit int) (domain.GroupCallParticipantPage, error) { + if limit <= 0 || limit > 200 { + limit = 200 + } + s.mu.Lock() + defer s.mu.Unlock() + call, ok := s.calls[callID] + if !ok { + return domain.GroupCallParticipantPage{}, domain.ErrGroupCallInvalid + } + var rows []domain.GroupCallParticipant + for _, p := range s.participants[callID] { + if !p.Left { + rows = append(rows, p) + } + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].JoinDate != rows[j].JoinDate { + return rows[i].JoinDate < rows[j].JoinDate + } + return rows[i].UserID < rows[j].UserID + }) + page := domain.GroupCallParticipantPage{Count: len(rows), Version: call.Version} + offDate, offUser, hasOffset := parseGroupCallOffset(offset) + for _, p := range rows { + if hasOffset && (p.JoinDate < offDate || (p.JoinDate == offDate && p.UserID <= offUser)) { + continue + } + page.Participants = append(page.Participants, p) + if len(page.Participants) == limit { + break + } + } + if n := len(page.Participants); n == limit && n < page.Count { + last := page.Participants[n-1] + page.NextOffset = formatGroupCallOffset(last.JoinDate, last.UserID) + } + return page, nil +} + +func (s *GroupCallStore) UpdateParticipant(_ context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + call, ok := s.calls[callID] + if !ok { + return domain.GroupCallMutation{}, false, domain.ErrGroupCallInvalid + } + if !call.Active() { + return domain.GroupCallMutation{}, false, domain.ErrGroupCallDiscarded + } + p, ok := s.participants[callID][userID] + if !ok || p.Left { + return domain.GroupCallMutation{}, false, domain.ErrGroupCallNotJoined + } + changed := applyGroupCallParticipantUpdate(&p, update) + if !changed { + return domain.GroupCallMutation{Call: call, Participant: p}, false, nil + } + if update.Now > 0 { + p.ActiveDate = update.Now + } + s.participants[callID][userID] = p + call.Version++ + s.calls[callID] = call + return domain.GroupCallMutation{Call: call, Participant: p}, true, nil +} + +func (s *GroupCallStore) SetGroupCallTitle(_ context.Context, callID int64, title string) (domain.GroupCall, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + call, ok := s.calls[callID] + if !ok { + return domain.GroupCall{}, false, domain.ErrGroupCallInvalid + } + if call.Title == title { + return call, false, nil + } + call.Title = title + s.calls[callID] = call + return call, true, nil +} + +func (s *GroupCallStore) SetGroupCallJoinMuted(_ context.Context, callID int64, joinMuted bool) (domain.GroupCall, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + call, ok := s.calls[callID] + if !ok { + return domain.GroupCall{}, false, domain.ErrGroupCallInvalid + } + if call.JoinMuted == joinMuted { + return call, false, nil + } + call.JoinMuted = joinMuted + s.calls[callID] = call + return call, true, nil +} + +func (s *GroupCallStore) SetStartedMessageID(_ context.Context, callID int64, msgID int) error { + s.mu.Lock() + defer s.mu.Unlock() + call, ok := s.calls[callID] + if !ok { + return domain.ErrGroupCallInvalid + } + call.StartedMsgID = msgID + s.calls[callID] = call + return nil +} + +func (s *GroupCallStore) SweepStaleParticipants(_ context.Context, checkOlderThan, now, limit int) ([]domain.GroupCallMutation, error) { + if limit <= 0 { + limit = 100 + } + s.mu.Lock() + defer s.mu.Unlock() + var out []domain.GroupCallMutation + for callID, rows := range s.participants { + call := s.calls[callID] + if !call.Active() { + continue + } + userIDs := make([]int64, 0, len(rows)) + for userID := range rows { + userIDs = append(userIDs, userID) + } + sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] }) + for _, userID := range userIDs { + p := rows[userID] + if p.Left || p.LastCheckDate >= checkOlderThan { + continue + } + p.Left = true + p.ActiveDate = now + rows[userID] = p + if call.ParticipantsCount > 0 { + call.ParticipantsCount-- + } + call.Version++ + out = append(out, domain.GroupCallMutation{Call: call, Participant: p}) + if len(out) == limit { + s.calls[callID] = call + return out, nil + } + } + s.calls[callID] = call + } + return out, nil +} + +func (s *GroupCallStore) ResetAllParticipants(_ context.Context, now int) ([]domain.GroupCall, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []domain.GroupCall + for callID, rows := range s.participants { + call := s.calls[callID] + if !call.Active() { + continue + } + cleared := false + for userID, p := range rows { + if p.Left { + continue + } + p.Left = true + p.ActiveDate = now + rows[userID] = p + cleared = true + } + if !cleared { + continue + } + call.ParticipantsCount = 0 + call.Version++ + s.calls[callID] = call + out = append(out, call) + } + return out, nil +} + +func applyGroupCallParticipantUpdate(p *domain.GroupCallParticipant, u domain.GroupCallParticipantUpdate) bool { + changed := false + if u.Muted != nil && p.Muted != *u.Muted { + p.Muted = *u.Muted + changed = true + } + if u.MutedByAdmin != nil && p.MutedByAdmin != *u.MutedByAdmin { + p.MutedByAdmin = *u.MutedByAdmin + changed = true + } + if u.VolumeByAdmin != nil && p.VolumeByAdmin != *u.VolumeByAdmin { + p.VolumeByAdmin = *u.VolumeByAdmin + changed = true + } + if u.RaiseHandRating != nil && p.RaiseHandRating != *u.RaiseHandRating { + p.RaiseHandRating = *u.RaiseHandRating + changed = true + } + if u.VideoJSON != nil && string(p.VideoJSON) != string(*u.VideoJSON) { + p.VideoJSON = append([]byte(nil), *u.VideoJSON...) + changed = true + } + if u.PresentationJSON != nil && string(p.PresentationJSON) != string(*u.PresentationJSON) { + p.PresentationJSON = append([]byte(nil), *u.PresentationJSON...) + changed = true + } + return changed +} + +func parseGroupCallOffset(offset string) (joinDate int, userID int64, ok bool) { + if offset == "" { + return 0, 0, false + } + parts := strings.Split(offset, ":") + if len(parts) != 3 || parts[0] != "j" { + return 0, 0, false + } + d, err1 := strconv.Atoi(parts[1]) + u, err2 := strconv.ParseInt(parts[2], 10, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return d, u, true +} + +func formatGroupCallOffset(joinDate int, userID int64) string { + return fmt.Sprintf("j:%d:%d", joinDate, userID) +} + +func (s *GroupCallStore) NextRaiseHandRating(_ context.Context, callID int64) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.raiseHandSeq[callID]++ + return s.raiseHandSeq[callID], nil +} + +func (s *GroupCallStore) SetParticipantOverride(_ context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error { + s.mu.Lock() + defer s.mu.Unlock() + key := overrideKey{callID: callID, setter: setterUserID, target: targetUserID} + if clear { + delete(s.overrides, key) + return nil + } + s.overrides[key] = override + return nil +} + +func (s *GroupCallStore) GetParticipantOverride(_ context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + ov, ok := s.overrides[overrideKey{callID: callID, setter: setterUserID, target: targetUserID}] + return ov, ok, nil +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/store/memory/groupcall_test.go b/internal/store/memory/groupcall_test.go new file mode 100644 index 00000000..eff832ab --- /dev/null +++ b/internal/store/memory/groupcall_test.go @@ -0,0 +1,25 @@ +package memory_test + +import ( + "testing" + + "telesrv/internal/store" + "telesrv/internal/store/memory" + "telesrv/internal/store/storetest" +) + +func TestGroupCallStoreContract(t *testing.T) { + var nextChannel int64 = 5000 + storetest.RunGroupCallStoreContract(t, func(t *testing.T) (store.GroupCallStore, int64) { + nextChannel++ + return memory.NewGroupCallStore(), nextChannel + }) +} + +func TestGroupCallStoreM2Contract(t *testing.T) { + var nextChannel int64 = 6000 + storetest.RunGroupCallStoreM2Contract(t, func(t *testing.T) (store.GroupCallStore, int64) { + nextChannel++ + return memory.NewGroupCallStore(), nextChannel + }) +} diff --git a/internal/store/memory/help.go b/internal/store/memory/help.go new file mode 100644 index 00000000..96b4eaf6 --- /dev/null +++ b/internal/store/memory/help.go @@ -0,0 +1,57 @@ +package memory + +import ( + "context" + "sync" + "telesrv/internal/domain" +) + +// HelpStore 是 store.AppConfigStore 和 store.CountryStore 的内存实现。 +type HelpStore struct { + mu sync.RWMutex + appConfig map[string]domain.AppConfig + countries domain.CountriesList +} + +// NewHelpStore 创建内存 HelpStore。 +func NewHelpStore() *HelpStore { + return &HelpStore{appConfig: make(map[string]domain.AppConfig)} +} + +func (s *HelpStore) GetAppConfig(_ context.Context, client string) (domain.AppConfig, bool, error) { + s.mu.RLock() + cfg, ok := s.appConfig[client] + s.mu.RUnlock() + cfg.JSON = append([]byte(nil), cfg.JSON...) + return cfg, ok, nil +} + +func (s *HelpStore) UpsertAppConfig(_ context.Context, cfg domain.AppConfig) error { + cfg.JSON = append([]byte(nil), cfg.JSON...) + s.mu.Lock() + s.appConfig[cfg.Client] = cfg + s.mu.Unlock() + return nil +} + +func (s *HelpStore) ListCountries(_ context.Context, _ string) (domain.CountriesList, error) { + s.mu.RLock() + list := s.countries + s.mu.RUnlock() + list.Countries = append([]domain.Country(nil), list.Countries...) + for i := range list.Countries { + list.Countries[i].CountryCodes = append([]domain.CountryCode(nil), list.Countries[i].CountryCodes...) + } + return list, nil +} + +func (s *HelpStore) UpsertCountries(_ context.Context, countries []domain.Country) error { + list := domain.CountriesList{Hash: 1, Countries: append([]domain.Country(nil), countries...)} + for i := range list.Countries { + list.Countries[i].CountryCodes = append([]domain.CountryCode(nil), list.Countries[i].CountryCodes...) + } + s.mu.Lock() + s.countries = list + s.mu.Unlock() + return nil +} diff --git a/internal/store/memory/helpers.go b/internal/store/memory/helpers.go new file mode 100644 index 00000000..f5312c63 --- /dev/null +++ b/internal/store/memory/helpers.go @@ -0,0 +1,16 @@ +package memory + +import ( + "telesrv/internal/domain" + "telesrv/internal/store" + "time" +) + +func draftKey(peer domain.Peer, topMessageID int) dialogDraftKey { + return dialogDraftKey{peerType: peer.Type, peerID: peer.ID, topMessageID: topMessageID} +} + +type codeEntry struct { + code store.PhoneCode + expires time.Time +} diff --git a/internal/store/memory/langpack.go b/internal/store/memory/langpack.go new file mode 100644 index 00000000..4c3f0455 --- /dev/null +++ b/internal/store/memory/langpack.go @@ -0,0 +1,70 @@ +package memory + +import ( + "context" + "sync" + "telesrv/internal/domain" +) + +// LangPackStore 是 store.LangPackStore 的内存实现。 +type LangPackStore struct { + mu sync.RWMutex + m map[string]domain.LangPack +} + +// NewLangPackStore 创建内存 LangPackStore。 +func NewLangPackStore() *LangPackStore { + return &LangPackStore{m: make(map[string]domain.LangPack)} +} + +func (s *LangPackStore) GetPack(_ context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { + s.mu.RLock() + pack := s.m[langPackKey(langPack, langCode)] + s.mu.RUnlock() + if pack.LangPack == "" { + return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil + } + pack.FromVersion = fromVersion + if pack.Version <= fromVersion { + pack.Strings = nil + } else { + pack.Strings = append([]domain.LangPackString(nil), pack.Strings...) + } + return pack, nil +} + +func (s *LangPackStore) GetStrings(_ context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) { + s.mu.RLock() + pack := s.m[langPackKey(langPack, langCode)] + s.mu.RUnlock() + if pack.LangPack == "" { + return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil + } + if len(keys) == 0 { + pack.Strings = append([]domain.LangPackString(nil), pack.Strings...) + return pack, nil + } + want := make(map[string]struct{}, len(keys)) + for _, key := range keys { + want[key] = struct{}{} + } + out := domain.LangPack{LangPack: pack.LangPack, LangCode: pack.LangCode, Version: pack.Version} + for _, item := range pack.Strings { + if _, ok := want[item.Key]; ok { + out.Strings = append(out.Strings, item) + } + } + return out, nil +} + +func (s *LangPackStore) UpsertPack(_ context.Context, pack domain.LangPack) error { + pack.Strings = append([]domain.LangPackString(nil), pack.Strings...) + s.mu.Lock() + s.m[langPackKey(pack.LangPack, pack.LangCode)] = pack + s.mu.Unlock() + return nil +} + +func langPackKey(langPack, langCode string) string { + return langPack + "\x00" + langCode +} diff --git a/internal/store/memory/media_search.go b/internal/store/memory/media_search.go new file mode 100644 index 00000000..1bd42dde --- /dev/null +++ b/internal/store/memory/media_search.go @@ -0,0 +1,198 @@ +package memory + +import ( + "context" + "sort" + + "telesrv/internal/domain" +) + +// 共享媒体标签页读路径(memory 实现):直接扫内存消息按 domain.ClassifyMediaCategories 分类过滤, +// 无需索引表(数据量小)。分类真值与 postgres 写路径/回填同源。 + +func mediaCategorySet(cats []domain.MediaCategory) map[domain.MediaCategory]bool { + set := make(map[domain.MediaCategory]bool, len(cats)) + for _, c := range cats { + if c != domain.MediaCategoryNone { + set[c] = true + } + } + return set +} + +func mediaCategoryMatches(media *domain.MessageMedia, entities []domain.MessageEntity, set map[domain.MediaCategory]bool) bool { + for _, c := range domain.ClassifyMediaCategories(media, entities) { + if set[c] { + return true + } + } + return false +} + +// pageMediaIDs 把全部匹配 id 按 newest-first 分页(返回本页 id + 满足 max/min 的总数)。 +func pageMediaIDs(ids []int, req domain.MediaSearchRequest) ([]int, int) { + sort.Sort(sort.Reverse(sort.IntSlice(ids))) + inRange := make([]int, 0, len(ids)) + for _, id := range ids { + if req.MaxID != 0 && id > req.MaxID { + continue + } + if req.MinID != 0 && id < req.MinID { + continue + } + inRange = append(inRange, id) + } + count := len(inRange) + page := make([]int, 0, len(inRange)) + for _, id := range inRange { + if req.OffsetID != 0 && id >= req.OffsetID { + continue + } + page = append(page, id) + } + off := req.AddOffset + if off < 0 { + off = 0 + } + if off > len(page) { + off = len(page) + } + page = page[off:] + limit := req.Limit + if limit == 0 { + return nil, count + } + if limit < 0 || limit > 100 { + limit = 100 + } + if len(page) > limit { + page = page[:limit] + } + return page, count +} + +// SearchPrivateMedia 实现 store.MessageStore。 +func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peerID int64, req domain.MediaSearchRequest) (domain.MessageList, error) { + set := mediaCategorySet(req.Categories) + if ownerUserID == 0 || peerID == 0 || len(set) == 0 { + return domain.MessageList{}, nil + } + s.mu.RLock() + matched := make([]int, 0, len(s.m[ownerUserID])) + for _, msg := range s.m[ownerUserID] { + if msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID { + continue + } + if mediaCategoryMatches(msg.Media, msg.Entities, set) { + matched = append(matched, msg.ID) + } + } + s.mu.RUnlock() + + ids, count := pageMediaIDs(matched, req) + if req.HasKnownCount { + count = req.KnownCount + } + list, err := s.GetByIDs(ctx, ownerUserID, ids) + if err != nil { + return domain.MessageList{}, err + } + list.Count = count + return list, nil +} + +// CountPrivateMediaCategories 实现 store.MessageStore。 +func (s *MessageStore) CountPrivateMediaCategories(_ context.Context, ownerUserID, peerID int64) (domain.MediaCategoryCounts, error) { + if ownerUserID == 0 || peerID == 0 { + return domain.MediaCategoryCounts{}, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := domain.MediaCategoryCounts{} + for _, msg := range s.m[ownerUserID] { + if msg.Peer.Type != domain.PeerTypeUser || msg.Peer.ID != peerID { + continue + } + for _, category := range domain.ClassifyMediaCategories(msg.Media, msg.Entities) { + if category != domain.MediaCategoryNone { + out[category]++ + } + } + } + return out, nil +} + +// SearchChannelMedia 实现 store.ChannelStore。 +func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error) { + set := mediaCategorySet(req.Categories) + if viewerUserID == 0 || channelID == 0 || len(set) == 0 { + return domain.ChannelHistory{}, nil + } + s.mu.RLock() + _, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + s.mu.RUnlock() + return domain.ChannelHistory{}, err + } + matched := make([]int, 0, len(s.messages[channelID])) + for _, msg := range s.messages[channelID] { + if msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + if mediaCategoryMatches(msg.Media, msg.Entities, set) { + matched = append(matched, msg.ID) + } + } + s.mu.RUnlock() + + ids, count := pageMediaIDs(matched, req) + if req.HasKnownCount { + count = req.KnownCount + } + hist, err := s.GetChannelMessages(ctx, viewerUserID, channelID, ids) + if err != nil { + return domain.ChannelHistory{}, err + } + hist.Messages = reorderChannelMessagesByMediaOrder(hist.Messages, ids) + hist.Count = count + return hist, nil +} + +// CountChannelMediaCategories 实现 store.ChannelStore。 +func (s *ChannelStore) CountChannelMediaCategories(_ context.Context, viewerUserID, channelID int64) (domain.MediaCategoryCounts, error) { + if viewerUserID == 0 || channelID == 0 { + return domain.MediaCategoryCounts{}, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + _, member, err := s.channelAndMemberLocked(viewerUserID, channelID) + if err != nil { + return domain.MediaCategoryCounts{}, err + } + out := domain.MediaCategoryCounts{} + for _, msg := range s.messages[channelID] { + if msg.Deleted || msg.ID <= member.AvailableMinID { + continue + } + for _, category := range domain.ClassifyMediaCategories(msg.Media, msg.Entities) { + if category != domain.MediaCategoryNone { + out[category]++ + } + } + } + return out, nil +} + +func reorderChannelMessagesByMediaOrder(msgs []domain.ChannelMessage, order []int) []domain.ChannelMessage { + byID := make(map[int]domain.ChannelMessage, len(msgs)) + for _, m := range msgs { + byID[m.ID] = m + } + out := make([]domain.ChannelMessage, 0, len(order)) + for _, id := range order { + if m, ok := byID[id]; ok { + out = append(out, m) + } + } + return out +} diff --git a/internal/store/memory/memory.go b/internal/store/memory/memory.go deleted file mode 100644 index fd7fa63e..00000000 --- a/internal/store/memory/memory.go +++ /dev/null @@ -1,3175 +0,0 @@ -// Package memory 提供 store 各接口的内存实现,用作测试替身与本地开发兜底。 -// -// 与 store/postgres、store/redisstore 对称:store 主包只定义接口与 DTO, -// 三种后端实现各自独立成包。 -package memory - -import ( - "context" - "encoding/binary" - "fmt" - "hash" - "hash/fnv" - "sort" - "strings" - "sync" - "time" - - "telesrv/internal/domain" - "telesrv/internal/store" -) - -// AuthKeyStore 是 store.AuthKeyStore 的内存实现。 -type AuthKeyStore struct { - mu sync.RWMutex - keys map[[8]byte]store.AuthKeyData -} - -// NewAuthKeyStore 创建内存 AuthKeyStore。 -func NewAuthKeyStore() *AuthKeyStore { - return &AuthKeyStore{keys: make(map[[8]byte]store.AuthKeyData)} -} - -func (s *AuthKeyStore) Save(_ context.Context, k store.AuthKeyData) error { - s.mu.Lock() - s.keys[k.ID] = k - s.mu.Unlock() - return nil -} - -func (s *AuthKeyStore) Get(_ context.Context, id [8]byte) (store.AuthKeyData, bool, error) { - s.mu.RLock() - k, ok := s.keys[id] - s.mu.RUnlock() - return k, ok, nil -} - -// SessionStore 是 store.SessionStore 的内存实现。 -type SessionStore struct { - mu sync.RWMutex - sessions map[int64]store.SessionData -} - -// NewSessionStore 创建内存 SessionStore。 -func NewSessionStore() *SessionStore { - return &SessionStore{sessions: make(map[int64]store.SessionData)} -} - -func (s *SessionStore) Save(_ context.Context, d store.SessionData) error { - s.mu.Lock() - s.sessions[d.ID] = d - s.mu.Unlock() - return nil -} - -func (s *SessionStore) Get(_ context.Context, id int64) (store.SessionData, bool, error) { - s.mu.RLock() - d, ok := s.sessions[id] - s.mu.RUnlock() - return d, ok, nil -} - -func (s *SessionStore) Delete(_ context.Context, id int64) error { - s.mu.Lock() - delete(s.sessions, id) - s.mu.Unlock() - return nil -} - -// UpdateStateStore 是 store.UpdateStateStore 的内存实现。 -type UpdateStateStore struct { - mu sync.RWMutex - states map[updateStateKey]domain.UpdateState -} - -// UpdateEventStore 是 store.UpdateEventStore 的内存实现。 -type UpdateEventStore struct { - mu sync.RWMutex - events map[int64][]domain.UpdateEvent -} - -type updateStateKey struct { - authKeyID [8]byte - userID int64 -} - -// NewUpdateEventStore 创建内存 UpdateEventStore。 -func NewUpdateEventStore() *UpdateEventStore { - return &UpdateEventStore{events: make(map[int64][]domain.UpdateEvent)} -} - -func (s *UpdateEventStore) Append(_ context.Context, userID int64, event domain.UpdateEvent) error { - event.UserID = userID - event.Message = cloneMessage(event.Message) - event.MessageIDs = append([]int(nil), event.MessageIDs...) - event.Peers = append([]domain.Peer(nil), event.Peers...) - event.Users = append([]domain.User(nil), event.Users...) - event.Channels = append([]domain.Channel(nil), event.Channels...) - s.mu.Lock() - s.events[userID] = append(s.events[userID], event) - s.mu.Unlock() - return nil -} - -func (s *UpdateEventStore) ListAfter(_ context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) { - s.mu.RLock() - items := append([]domain.UpdateEvent(nil), s.events[userID]...) - s.mu.RUnlock() - out := make([]domain.UpdateEvent, 0, len(items)) - for _, event := range items { - if event.Pts <= pts { - continue - } - event.Message = cloneMessage(event.Message) - event.MessageIDs = append([]int(nil), event.MessageIDs...) - event.Peers = append([]domain.Peer(nil), event.Peers...) - event.Users = append([]domain.User(nil), event.Users...) - event.Channels = append([]domain.Channel(nil), event.Channels...) - out = append(out, event) - if limit > 0 && len(out) >= limit { - break - } - } - return out, nil -} - -func (s *UpdateEventStore) Current(_ context.Context, userID int64) (int, error) { - s.mu.RLock() - items := s.events[userID] - s.mu.RUnlock() - max := 0 - for _, event := range items { - if event.Pts > max { - max = event.Pts - } - } - return max, nil -} - -// MaxContiguousPts 返回从 1 起无空洞的最大 pts(内存版按 pts_count 连续扫描)。 -func (s *UpdateEventStore) MaxContiguousPts(_ context.Context, userID int64) (int, error) { - s.mu.RLock() - nextByStart := make(map[int]int, len(s.events[userID])) - for _, event := range s.events[userID] { - count := event.PtsCount - if count <= 0 { - count = 1 - } - nextByStart[event.Pts-count] = event.Pts - } - s.mu.RUnlock() - contiguous := 0 - for { - next, ok := nextByStart[contiguous] - if !ok { - break - } - contiguous = next - } - return contiguous, nil -} - -func (s *UpdateEventStore) AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) { - return s.MaxContiguousPts(ctx, userID) -} - -// NewUpdateStateStore 创建内存 UpdateStateStore。 -func NewUpdateStateStore() *UpdateStateStore { - return &UpdateStateStore{states: make(map[updateStateKey]domain.UpdateState)} -} - -func (s *UpdateStateStore) Get(_ context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) { - s.mu.RLock() - st, ok := s.states[updateStateKey{authKeyID: id, userID: userID}] - s.mu.RUnlock() - return st, ok, nil -} - -func (s *UpdateStateStore) Save(_ context.Context, id [8]byte, userID int64, st domain.UpdateState) error { - s.mu.Lock() - s.states[updateStateKey{authKeyID: id, userID: userID}] = st - s.mu.Unlock() - return nil -} - -func (s *UpdateStateStore) Delete(_ context.Context, id [8]byte, userID int64) error { - s.mu.Lock() - delete(s.states, updateStateKey{authKeyID: id, userID: userID}) - s.mu.Unlock() - return nil -} - -func (s *UpdateStateStore) DeleteAuthKey(_ context.Context, id [8]byte) error { - s.mu.Lock() - for k := range s.states { - if k.authKeyID == id { - delete(s.states, k) - } - } - s.mu.Unlock() - return nil -} - -// TempAuthKeyBindingStore 是 store.TempAuthKeyBindingStore 的内存实现。 -type TempAuthKeyBindingStore struct { - mu sync.RWMutex - m map[[8]byte]domain.TempAuthKeyBinding -} - -// NewTempAuthKeyBindingStore 创建内存 TempAuthKeyBindingStore。 -func NewTempAuthKeyBindingStore() *TempAuthKeyBindingStore { - return &TempAuthKeyBindingStore{m: make(map[[8]byte]domain.TempAuthKeyBinding)} -} - -func (s *TempAuthKeyBindingStore) Save(_ context.Context, b domain.TempAuthKeyBinding) error { - b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...) - s.mu.Lock() - s.m[b.TempAuthKeyID] = b - s.mu.Unlock() - return nil -} - -func (s *TempAuthKeyBindingStore) GetByTemp(_ context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) { - s.mu.RLock() - b, ok := s.m[tempAuthKeyID] - s.mu.RUnlock() - if !ok { - return domain.TempAuthKeyBinding{}, false, nil - } - b.EncryptedMessage = append([]byte(nil), b.EncryptedMessage...) - return b, true, nil -} - -// ContactStore 是 store.ContactStore 的内存实现。 -type ContactStore struct { - mu sync.RWMutex - m map[int64]domain.ContactList - blocks map[int64]map[int64]domain.BlockedContact -} - -// NewContactStore 创建内存 ContactStore。 -func NewContactStore() *ContactStore { - return &ContactStore{ - m: make(map[int64]domain.ContactList), - blocks: make(map[int64]map[int64]domain.BlockedContact), - } -} - -func (s *ContactStore) ListByUser(_ context.Context, userID int64) (domain.ContactList, error) { - s.mu.RLock() - list := s.m[userID] - s.mu.RUnlock() - list.Contacts = cloneContacts(list.Contacts) - list.Hash = contactListHash(list.Contacts) - return list, nil -} - -func (s *ContactStore) Get(_ context.Context, userID, contactUserID int64) (domain.Contact, bool, error) { - s.mu.RLock() - list := s.m[userID] - s.mu.RUnlock() - for _, contact := range list.Contacts { - if contact.User.ID == contactUserID { - return cloneContact(contact), true, nil - } - } - return domain.Contact{}, false, nil -} - -func (s *ContactStore) GetMany(_ context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) { - out := make(map[int64]domain.Contact, len(contactUserIDs)) - if userID == 0 || len(contactUserIDs) == 0 { - return out, nil - } - want := make(map[int64]struct{}, len(contactUserIDs)) - for _, id := range contactUserIDs { - if id != 0 { - want[id] = struct{}{} - } - } - s.mu.RLock() - list := s.m[userID] - s.mu.RUnlock() - for _, contact := range list.Contacts { - if _, ok := want[contact.User.ID]; ok { - out[contact.User.ID] = cloneContact(contact) - } - } - return out, nil -} - -func (s *ContactStore) GetReverseContacts(_ context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) { - out := make(map[int64]domain.Contact, len(ownerUserIDs)) - if userID == 0 || len(ownerUserIDs) == 0 { - return out, nil - } - want := make(map[int64]struct{}, len(ownerUserIDs)) - for _, id := range ownerUserIDs { - if id != 0 { - want[id] = struct{}{} - } - } - s.mu.RLock() - defer s.mu.RUnlock() - for ownerID := range want { - for _, contact := range s.m[ownerID].Contacts { - if contact.User.ID == userID { - out[ownerID] = cloneContact(contact) - break - } - } - } - return out, nil -} - -func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { - contact := domain.Contact{ - User: domain.User{ - ID: input.ContactUserID, - Phone: input.Phone, - FirstName: input.FirstName, - LastName: input.LastName, - Contact: true, - }, - FirstName: input.FirstName, - LastName: input.LastName, - Phone: input.Phone, - Note: input.Note, - NoteEntities: append([]domain.MessageEntity(nil), input.NoteEntities...), - } - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - reverse := s.m[input.ContactUserID] - for i := range reverse.Contacts { - if reverse.Contacts[i].User.ID == userID { - reverse.Contacts[i].Mutual = true - reverse.Contacts[i].User.Mutual = true - contact.Mutual = true - contact.User.Mutual = true - s.m[input.ContactUserID] = reverse - break - } - } - for i, existing := range list.Contacts { - if existing.User.ID != input.ContactUserID { - continue - } - contact.User.AccessHash = existing.User.AccessHash - contact.User.Username = existing.User.Username - contact.User.CountryCode = existing.User.CountryCode - contact.User.Verified = existing.User.Verified - contact.User.Support = existing.User.Support - if contact.Phone == "" { - contact.User.Phone = existing.User.Phone - } - if contact.FirstName == "" { - contact.User.FirstName = existing.User.FirstName - } - if contact.LastName == "" { - contact.User.LastName = existing.User.LastName - } - list.Contacts[i] = contact - list.Hash = contactListHash(list.Contacts) - s.m[userID] = list - return cloneContact(contact), nil - } - list.Contacts = append(list.Contacts, contact) - list.Hash = contactListHash(list.Contacts) - s.m[userID] = list - return cloneContact(contact), nil -} - -func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error) { - if len(inputs) == 0 { - return nil, nil - } - out := make([]domain.Contact, 0, len(inputs)) - for _, input := range inputs { - contact, err := s.Upsert(ctx, userID, input) - if err != nil { - return nil, err - } - out = append(out, contact) - } - return out, nil -} - -func (s *ContactStore) UpdateNote(_ context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - for i := range list.Contacts { - if list.Contacts[i].User.ID != contactUserID { - continue - } - list.Contacts[i].Note = note - list.Contacts[i].NoteEntities = append([]domain.MessageEntity(nil), entities...) - list.Hash = contactListHash(list.Contacts) - s.m[userID] = list - return cloneContact(list.Contacts[i]), true, nil - } - return domain.Contact{}, false, nil -} - -func (s *ContactStore) SetPersonalPhoto(_ context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) { - _ = date - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - for i := range list.Contacts { - if list.Contacts[i].User.ID != contactUserID { - continue - } - list.Contacts[i].User.PhotoID = photoID - list.Contacts[i].User.PhotoPersonal = photoID != 0 - list.Hash = contactListHash(list.Contacts) - s.m[userID] = list - return cloneContact(list.Contacts[i]), true, nil - } - return domain.Contact{}, false, nil -} - -func (s *ContactStore) PersonalPhotos(_ context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { - out := make(map[int64]domain.ProfilePhotoRef, len(contactUserIDs)) - if userID == 0 || len(contactUserIDs) == 0 { - return out, nil - } - want := make(map[int64]struct{}, len(contactUserIDs)) - for _, id := range contactUserIDs { - if id != 0 { - want[id] = struct{}{} - } - } - s.mu.RLock() - list := s.m[userID] - s.mu.RUnlock() - for _, contact := range list.Contacts { - if _, ok := want[contact.User.ID]; !ok || contact.User.PhotoID == 0 { - continue - } - out[contact.User.ID] = domain.ProfilePhotoRef{ - PhotoID: contact.User.PhotoID, - DCID: contact.User.PhotoDCID, - Stripped: append([]byte(nil), contact.User.PhotoStripped...), - Personal: true, - } - } - return out, nil -} - -func (s *ContactStore) Delete(_ context.Context, userID int64, contactUserIDs []int64) (int, error) { - remove := make(map[int64]struct{}, len(contactUserIDs)) - for _, id := range contactUserIDs { - if id != 0 { - remove[id] = struct{}{} - } - } - if len(remove) == 0 { - return 0, nil - } - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - out := list.Contacts[:0] - deleted := 0 - for _, contact := range list.Contacts { - if _, ok := remove[contact.User.ID]; ok { - deleted++ - if reverse := s.m[contact.User.ID]; len(reverse.Contacts) > 0 { - for i := range reverse.Contacts { - if reverse.Contacts[i].User.ID == userID { - reverse.Contacts[i].Mutual = false - reverse.Contacts[i].User.Mutual = false - } - } - reverse.Hash = contactListHash(reverse.Contacts) - s.m[contact.User.ID] = reverse - } - continue - } - out = append(out, contact) - } - list.Contacts = out - list.Hash = contactListHash(list.Contacts) - s.m[userID] = list - return deleted, nil -} - -func (s *ContactStore) Block(_ context.Context, userID, blockedUserID int64, date int) (bool, error) { - if userID == 0 || blockedUserID == 0 || userID == blockedUserID { - return false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - if s.blocks[userID] == nil { - s.blocks[userID] = make(map[int64]domain.BlockedContact) - } - _, existed := s.blocks[userID][blockedUserID] - s.blocks[userID][blockedUserID] = domain.BlockedContact{ - User: domain.User{ID: blockedUserID}, - Date: date, - } - return !existed, nil -} - -func (s *ContactStore) Unblock(_ context.Context, userID, blockedUserID int64) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.blocks[userID] == nil { - return false, nil - } - _, existed := s.blocks[userID][blockedUserID] - delete(s.blocks[userID], blockedUserID) - return existed, nil -} - -func (s *ContactStore) IsBlocked(_ context.Context, userID, blockedUserID int64) (bool, error) { - s.mu.RLock() - defer s.mu.RUnlock() - _, blocked := s.blocks[userID][blockedUserID] - return blocked, nil -} - -func (s *ContactStore) ListBlocked(_ context.Context, userID int64, offset, limit int) (domain.BlockedContactList, error) { - s.mu.RLock() - defer s.mu.RUnlock() - items := make([]domain.BlockedContact, 0, len(s.blocks[userID])) - for _, item := range s.blocks[userID] { - items = append(items, item) - } - sort.Slice(items, func(i, j int) bool { - if items[i].Date == items[j].Date { - return items[i].User.ID > items[j].User.ID - } - return items[i].Date > items[j].Date - }) - total := len(items) - if offset < 0 { - offset = 0 - } - if offset >= len(items) { - return domain.BlockedContactList{Count: total}, nil - } - if limit <= 0 || limit > len(items)-offset { - limit = len(items) - offset - } - out := append([]domain.BlockedContact(nil), items[offset:offset+limit]...) - return domain.BlockedContactList{Blocked: out, Count: total}, nil -} - -// SaveList 保存一份用户通讯录,供测试和本地替身使用。 -func (s *ContactStore) SaveList(_ context.Context, userID int64, list domain.ContactList) error { - list.Contacts = cloneContacts(list.Contacts) - list.Hash = contactListHash(list.Contacts) - s.mu.Lock() - s.m[userID] = list - s.mu.Unlock() - return nil -} - -// DialogStore 是 store.DialogStore 的内存实现。 -type DialogStore struct { - mu sync.RWMutex - m map[int64]domain.DialogList - drafts map[int64]map[dialogDraftKey]domain.DialogDraft - folders map[int64]map[int]domain.DialogFolder - folderOrder map[int64][]int - folderTags map[int64]bool -} - -type dialogDraftKey struct { - peerType domain.PeerType - peerID int64 - topMessageID int -} - -// NewDialogStore 创建内存 DialogStore。 -func NewDialogStore() *DialogStore { - return &DialogStore{ - m: make(map[int64]domain.DialogList), - drafts: make(map[int64]map[dialogDraftKey]domain.DialogDraft), - folders: make(map[int64]map[int]domain.DialogFolder), - folderOrder: make(map[int64][]int), - folderTags: make(map[int64]bool), - } -} - -func (s *DialogStore) ListByUser(_ context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { - s.mu.RLock() - list := s.m[userID] - s.mu.RUnlock() - list.Dialogs = cloneDialogs(list.Dialogs) - list.Messages = cloneMessages(list.Messages) - list.Users = append([]domain.User(nil), list.Users...) - return filterDialogList(list, filter), nil -} - -func (s *DialogStore) ListByPeers(_ context.Context, userID int64, peers []domain.Peer) (domain.DialogList, error) { - s.mu.RLock() - list := s.m[userID] - s.mu.RUnlock() - list.Dialogs = cloneDialogs(list.Dialogs) - list.Messages = cloneMessages(list.Messages) - list.Users = append([]domain.User(nil), list.Users...) - - byPeer := make(map[domain.Peer]domain.Dialog, len(list.Dialogs)) - for _, dialog := range list.Dialogs { - byPeer[dialog.Peer] = dialog - } - out := domain.DialogList{ - Dialogs: make([]domain.Dialog, 0, len(peers)), - Users: make([]domain.User, 0, len(peers)), - } - seenPeers := make(map[domain.Peer]struct{}, len(peers)) - seenUsers := map[int64]struct{}{} - for _, peer := range peers { - if _, ok := seenPeers[peer]; ok { - continue - } - seenPeers[peer] = struct{}{} - dialog := byPeer[peer] - if dialog.Peer.ID == 0 { - dialog.Peer = peer - } - out.Dialogs = append(out.Dialogs, dialog) - if peer.Type == domain.PeerTypeUser { - if user, ok := findDialogUser(list.Users, peer.ID); ok { - appendDialogUser(&out, seenUsers, user) - } else if peer.ID == domain.OfficialSystemUserID { - appendDialogUser(&out, seenUsers, domain.OfficialSystemUser()) - } - } - } - out.Messages = keepDialogMessages(list.Messages, out.Dialogs) - out.Count = len(out.Dialogs) - out.Hash = dialogListHash(out.Dialogs) - return out, nil -} - -// SaveList 保存一份用户会话列表,供测试和本地替身使用。 -func (s *DialogStore) SaveList(_ context.Context, userID int64, list domain.DialogList) error { - list.Dialogs = cloneDialogs(list.Dialogs) - list.Messages = cloneMessages(list.Messages) - list.Users = append([]domain.User(nil), list.Users...) - s.mu.Lock() - s.m[userID] = list - s.mu.Unlock() - return nil -} - -func (s *DialogStore) Upsert(_ context.Context, userID int64, dialog domain.Dialog) error { - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - for i, existing := range list.Dialogs { - if existing.Peer == dialog.Peer { - if dialog.FolderID == domain.DialogMainFolderID && existing.FolderID != domain.DialogMainFolderID { - dialog.FolderID = existing.FolderID - } - list.Dialogs[i] = dialog - s.m[userID] = list - return nil - } - } - list.Dialogs = append(list.Dialogs, dialog) - s.m[userID] = list - return nil -} - -func (s *DialogStore) SaveDraft(_ context.Context, userID int64, draft domain.DialogDraft) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.drafts[userID] == nil { - s.drafts[userID] = make(map[dialogDraftKey]domain.DialogDraft) - } - s.drafts[userID][draftKey(draft.Peer, draft.TopMessageID)] = cloneDialogDraft(draft) - return nil -} - -func (s *DialogStore) DeleteDraft(_ context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - items := s.drafts[userID] - if len(items) == 0 { - return false, nil - } - key := draftKey(peer, topMessageID) - if _, ok := items[key]; !ok { - return false, nil - } - delete(items, key) - return true, nil -} - -func (s *DialogStore) ListDrafts(_ context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { - s.mu.RLock() - items := s.drafts[userID] - out := make([]domain.DialogDraft, 0, len(items)) - for _, draft := range items { - out = append(out, cloneDialogDraft(draft)) - } - s.mu.RUnlock() - sortDialogDrafts(out) - if limit <= 0 || limit > domain.MaxDialogDraftsPerUser { - limit = domain.MaxDialogDraftsPerUser - } - if len(out) > limit { - out = out[:limit] - } - return out, nil -} - -func (s *DialogStore) ClearDrafts(_ context.Context, userID int64, limit int) ([]domain.DialogDraft, error) { - if limit <= 0 || limit > domain.MaxDialogDraftsPerUser { - limit = domain.MaxDialogDraftsPerUser - } - s.mu.Lock() - defer s.mu.Unlock() - items := s.drafts[userID] - if len(items) == 0 { - return nil, nil - } - out := make([]domain.DialogDraft, 0, len(items)) - for _, draft := range items { - out = append(out, cloneDialogDraft(draft)) - } - sortDialogDrafts(out) - if len(out) > limit { - out = out[:limit] - } - for _, draft := range out { - delete(items, draftKey(draft.Peer, draft.TopMessageID)) - } - return out, nil -} - -func (s *DialogStore) MarkRead(_ context.Context, userID int64, peer domain.Peer, maxID int) (domain.ReadHistoryResult, error) { - result := domain.ReadHistoryResult{OwnerUserID: userID, Peer: peer, MaxID: maxID} - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - for i, dialog := range list.Dialogs { - if dialog.Peer != peer { - continue - } - readMax := maxID - if readMax <= 0 { - readMax = dialog.TopMessage - } - result.MaxID = readMax - result.Changed = dialog.UnreadCount > 0 || readMax > dialog.ReadInboxMaxID - if readMax > dialog.ReadInboxMaxID { - dialog.ReadInboxMaxID = readMax - } - dialog.UnreadCount = 0 - dialog.UnreadMentions = 0 - dialog.UnreadReactions = 0 - dialog.UnreadMark = false - result.StillUnreadCount = dialog.UnreadCount - list.Dialogs[i] = dialog - s.m[userID] = list - return result, nil - } - return result, nil -} - -func (s *DialogStore) SetPinned(_ context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - nextOrder := 1 - for _, dialog := range list.Dialogs { - if dialog.Pinned && dialog.PinnedOrder >= nextOrder { - nextOrder = dialog.PinnedOrder + 1 - } - } - for i := range list.Dialogs { - if list.Dialogs[i].Peer != peer { - continue - } - list.Dialogs[i].Pinned = pinned - if pinned { - if list.Dialogs[i].PinnedOrder == 0 { - list.Dialogs[i].PinnedOrder = nextOrder - } - } else { - list.Dialogs[i].PinnedOrder = 0 - } - s.m[userID] = list - return true, nil - } - return false, nil -} - -func (s *DialogStore) ReorderPinned(_ context.Context, userID int64, order []domain.Peer, force bool) error { - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - positions := make(map[domain.Peer]int, len(order)) - for i, peer := range order { - if peer.Type == "" || peer.ID == 0 { - continue - } - if _, ok := positions[peer]; ok { - continue - } - positions[peer] = len(order) - i - } - for i := range list.Dialogs { - pos, ok := positions[list.Dialogs[i].Peer] - if ok { - list.Dialogs[i].Pinned = true - list.Dialogs[i].PinnedOrder = pos - continue - } - if force && list.Dialogs[i].Pinned { - list.Dialogs[i].Pinned = false - list.Dialogs[i].PinnedOrder = 0 - } - } - s.m[userID] = list - return nil -} - -func (s *DialogStore) SetUnreadMark(_ context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - for i := range list.Dialogs { - if list.Dialogs[i].Peer != peer { - continue - } - list.Dialogs[i].UnreadMark = unread - s.m[userID] = list - return true, nil - } - return false, nil -} - -func (s *DialogStore) ListUnreadMarked(_ context.Context, userID int64) ([]domain.Peer, error) { - s.mu.RLock() - list := s.m[userID] - s.mu.RUnlock() - out := make([]domain.Peer, 0, len(list.Dialogs)) - for _, dialog := range list.Dialogs { - if dialog.UnreadMark { - out = append(out, dialog.Peer) - } - } - return out, nil -} - -func (s *DialogStore) SetPeerSettingsBarHidden(_ context.Context, userID int64, peer domain.Peer) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - for i := range list.Dialogs { - if list.Dialogs[i].Peer != peer { - continue - } - list.Dialogs[i].PeerSettingsBarHidden = true - s.m[userID] = list - return true, nil - } - return false, nil -} - -func (s *DialogStore) PeerSettingsBarHidden(_ context.Context, userID int64, peer domain.Peer) (bool, error) { - s.mu.RLock() - list := s.m[userID] - s.mu.RUnlock() - for _, dialog := range list.Dialogs { - if dialog.Peer == peer { - return dialog.PeerSettingsBarHidden, nil - } - } - return false, nil -} - -func (s *DialogStore) ListFolders(_ context.Context, userID int64) (domain.DialogFolderList, error) { - s.mu.RLock() - defer s.mu.RUnlock() - byID := s.folders[userID] - order := append([]int(nil), s.folderOrder[userID]...) - seen := make(map[int]struct{}, len(byID)) - out := domain.DialogFolderList{ - TagsEnabled: s.folderTags[userID], - Folders: make([]domain.DialogFolder, 0, len(byID)), - } - for _, id := range order { - folder, ok := byID[id] - if !ok { - continue - } - seen[id] = struct{}{} - out.Folders = append(out.Folders, cloneDialogFolder(folder)) - } - remaining := make([]int, 0, len(byID)) - for id := range byID { - if _, ok := seen[id]; !ok { - remaining = append(remaining, id) - } - } - sort.Ints(remaining) - for _, id := range remaining { - out.Folders = append(out.Folders, cloneDialogFolder(byID[id])) - } - return out, nil -} - -func (s *DialogStore) GetFolder(_ context.Context, userID int64, folderID int) (domain.DialogFolder, bool, error) { - s.mu.RLock() - defer s.mu.RUnlock() - folder, ok := s.folders[userID][folderID] - if !ok { - return domain.DialogFolder{}, false, nil - } - return cloneDialogFolder(folder), true, nil -} - -func (s *DialogStore) UpsertFolder(_ context.Context, userID int64, folder domain.DialogFolder) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.folders[userID] == nil { - s.folders[userID] = make(map[int]domain.DialogFolder) - } - s.folders[userID][folder.ID] = cloneDialogFolder(folder) - if !containsInt(s.folderOrder[userID], folder.ID) { - s.folderOrder[userID] = append(s.folderOrder[userID], folder.ID) - } - return nil -} - -func (s *DialogStore) DeleteFolder(_ context.Context, userID int64, folderID int) error { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.folders[userID], folderID) - s.folderOrder[userID] = removeInt(s.folderOrder[userID], folderID) - return nil -} - -func (s *DialogStore) ReorderFolders(_ context.Context, userID int64, order []int) error { - s.mu.Lock() - defer s.mu.Unlock() - byID := s.folders[userID] - seen := make(map[int]struct{}, len(order)) - next := make([]int, 0, len(byID)) - for _, id := range order { - if id < domain.DialogCustomFolderMinID { - continue - } - if _, ok := byID[id]; !ok { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - next = append(next, id) - } - remaining := make([]int, 0, len(byID)) - for id := range byID { - if _, ok := seen[id]; !ok { - remaining = append(remaining, id) - } - } - sort.Ints(remaining) - next = append(next, remaining...) - s.folderOrder[userID] = next - return nil -} - -func (s *DialogStore) SetFolderTagsEnabled(_ context.Context, userID int64, enabled bool) error { - s.mu.Lock() - s.folderTags[userID] = enabled - s.mu.Unlock() - return nil -} - -func (s *DialogStore) EditPeerFolders(_ context.Context, userID int64, peers []domain.FolderPeerUpdate) error { - s.mu.Lock() - defer s.mu.Unlock() - list := s.m[userID] - updates := make(map[domain.Peer]int, len(peers)) - for _, item := range peers { - if item.Peer.Type == "" || item.Peer.ID == 0 { - continue - } - updates[item.Peer] = item.FolderID - } - for i := range list.Dialogs { - if folderID, ok := updates[list.Dialogs[i].Peer]; ok { - list.Dialogs[i].FolderID = folderID - } - } - s.m[userID] = list - return nil -} - -// MessageStore 是 store.MessageStore 的内存实现。 -type MessageStore struct { - mu sync.RWMutex - m map[int64][]domain.Message - nextUID int64 - nextBox map[int64]int - nextPts map[int64]int - readOutboxDates map[readOutboxDateKey]int - privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction - dialogs *DialogStore -} - -type readOutboxDateKey struct { - ownerUserID int64 - peerID int64 - msgID int -} - -// NewMessageStore 创建内存 MessageStore。 -func NewMessageStore(dialogs ...*DialogStore) *MessageStore { - s := &MessageStore{ - m: make(map[int64][]domain.Message), - nextUID: 1, - nextBox: make(map[int64]int), - nextPts: make(map[int64]int), - readOutboxDates: make(map[readOutboxDateKey]int), - privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction), - } - if len(dialogs) > 0 { - s.dialogs = dialogs[0] - } - return s -} - -func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Message, error) { - s.mu.Lock() - defer s.mu.Unlock() - msg.ID = s.nextBoxIDLocked(msg.OwnerUserID) - msg.UID = s.nextUID - s.nextUID++ - msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) - s.m[msg.OwnerUserID] = append(s.m[msg.OwnerUserID], msg) - if s.dialogs != nil { - s.dialogs.mu.Lock() - list := s.dialogs.m[msg.OwnerUserID] - list.Messages = append(list.Messages, msg) - if msg.Peer.Type == domain.PeerTypeUser && msg.Peer.ID == domain.OfficialSystemUserID && !hasUser(list.Users, domain.OfficialSystemUserID) { - list.Users = append(list.Users, domain.OfficialSystemUser()) - } - s.dialogs.m[msg.OwnerUserID] = list - s.dialogs.mu.Unlock() - } - return msg, nil -} - -func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { - s.mu.Lock() - defer s.mu.Unlock() - for _, msg := range s.m[req.SenderUserID] { - if msg.RandomID != 0 && msg.RandomID == req.RandomID { - recipient := domain.Message{} - if req.SenderUserID != req.RecipientUserID { - for _, peerMsg := range s.m[req.RecipientUserID] { - if peerMsg.UID == msg.UID { - recipient = peerMsg - break - } - } - } else { - recipient = msg - } - return domain.SendPrivateTextResult{ - SenderMessage: cloneMessage(msg), - RecipientMessage: cloneMessage(recipient), - SenderEvent: newMessageEvent(msg), - RecipientEvent: newMessageEvent(recipient), - Duplicate: true, - }, nil - } - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - senderReply, recipientReply, err := s.resolveMemoryReplyLocked(req) - if err != nil { - return domain.SendPrivateTextResult{}, err - } - uid := s.nextUID - s.nextUID++ - sender := domain.Message{ - ID: s.nextBoxIDLocked(req.SenderUserID), - UID: uid, - RandomID: req.RandomID, - OwnerUserID: req.SenderUserID, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, - Date: req.Date, - Out: true, - Silent: req.Silent, - NoForwards: req.NoForwards, - Body: req.Message, - Entities: append([]domain.MessageEntity(nil), req.Entities...), - Media: req.Media, - ReplyTo: cloneMessageReply(senderReply), - Forward: cloneMessageForward(req.Forward), - Pts: s.nextPtsLocked(req.SenderUserID), - } - recipient := domain.Message{} - if req.SenderUserID == req.RecipientUserID { - recipient = sender - } - if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked { - recipient = sender - recipient.ID = s.nextBoxIDLocked(req.RecipientUserID) - recipient.OwnerUserID = req.RecipientUserID - recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} - recipient.Out = false - recipient.ReplyTo = cloneMessageReply(recipientReply) - recipient.Pts = s.nextPtsLocked(req.RecipientUserID) - recipient.MediaUnread = !req.Media.IsZero() - } - s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender) - if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked { - s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient) - } - if s.dialogs != nil { - if recipient.ID != 0 { - s.upsertMemoryDialogsLocked(sender, recipient) - } else { - s.upsertMemoryDialogsLocked(sender, sender) - } - } - return domain.SendPrivateTextResult{ - SenderMessage: cloneMessage(sender), - RecipientMessage: cloneMessage(recipient), - SenderEvent: newMessageEvent(sender), - RecipientEvent: newMessageEvent(recipient), - }, nil -} - -func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) { - if req.ReplyTo == nil { - return nil, nil, nil - } - if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { - return nil, nil, err - } - peer := req.ReplyTo.Peer - if peer.ID == 0 { - peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID} - } - if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID { - return nil, nil, domain.ErrReplyMessageIDInvalid - } - var target domain.Message - for _, msg := range s.m[req.SenderUserID] { - if msg.Peer == peer && msg.ID == req.ReplyTo.MessageID { - target = msg - break - } - } - if target.ID == 0 { - return nil, nil, domain.ErrReplyMessageIDInvalid - } - senderReply := cloneMessageReply(req.ReplyTo) - senderReply.MessageID = target.ID - senderReply.Peer = peer - if req.SenderUserID == req.RecipientUserID { - return senderReply, cloneMessageReply(senderReply), nil - } - for _, msg := range s.m[req.RecipientUserID] { - if msg.UID == target.UID { - recipientReply := cloneMessageReply(senderReply) - recipientReply.MessageID = msg.ID - recipientReply.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} - return senderReply, recipientReply, nil - } - } - return senderReply, nil, nil -} - -func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { - res := domain.ForwardPrivateMessagesResult{OwnerUserID: req.OwnerUserID} - if req.OwnerUserID == 0 || req.ToUserID == 0 || req.FromPeer.Type != domain.PeerTypeUser || req.FromPeer.ID == 0 { - return res, domain.ErrMessageIDInvalid - } - if len(req.MessageIDs) == 0 || len(req.MessageIDs) != len(req.RandomIDs) { - return res, domain.ErrMessageIDInvalid - } - if len(req.MessageIDs) > domain.MaxForwardMessageIDs { - return res, domain.ErrMessageIDInvalid - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - s.mu.RLock() - sources := make([]domain.Message, 0, len(req.MessageIDs)) - for _, id := range req.MessageIDs { - if id <= 0 || id > domain.MaxMessageBoxID { - s.mu.RUnlock() - return res, domain.ErrMessageIDInvalid - } - var source domain.Message - for _, msg := range s.m[req.OwnerUserID] { - if msg.Peer == req.FromPeer && msg.ID == id { - source = cloneMessage(msg) - break - } - } - if source.ID == 0 { - s.mu.RUnlock() - return res, domain.ErrMessageIDInvalid - } - if source.NoForwards { - s.mu.RUnlock() - return res, domain.ErrChatForwardsRestricted - } - sources = append(sources, source) - } - s.mu.RUnlock() - - res.SenderMessages = make([]domain.Message, 0, len(sources)) - res.RecipientMessages = make([]domain.Message, 0, len(sources)) - res.SenderEvents = make([]domain.UpdateEvent, 0, len(sources)) - res.RecipientEvents = make([]domain.UpdateEvent, 0, len(sources)) - res.Duplicates = make([]bool, 0, len(sources)) - for i, source := range sources { - if req.RandomIDs[i] == 0 { - return res, domain.ErrMessageIDInvalid - } - var forward *domain.MessageForward - if !req.DropAuthor { - forward = cloneMessageForward(source.Forward) - if forward == nil { - forward = &domain.MessageForward{From: source.From, Date: source.Date} - } - } - sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{ - SenderUserID: req.OwnerUserID, - RecipientUserID: req.ToUserID, - RandomID: req.RandomIDs[i], - Message: source.Body, - Entities: append([]domain.MessageEntity(nil), source.Entities...), - Silent: req.Silent, - NoForwards: req.NoForwards, - ReplyTo: req.ReplyTo, - Forward: forward, - Date: req.Date, - OriginAuthKeyID: req.OriginAuthKeyID, - OriginSessionID: req.OriginSessionID, - RecipientBlocked: req.RecipientBlocked, - }) - if err != nil { - return res, err - } - res.SenderMessages = append(res.SenderMessages, sent.SenderMessage) - res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage) - res.SenderEvents = append(res.SenderEvents, sent.SenderEvent) - res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent) - res.Duplicates = append(res.Duplicates, sent.Duplicate) - } - return res, nil -} - -func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (domain.MessageList, error) { - if userID == 0 || len(ids) == 0 { - return domain.MessageList{}, nil - } - s.mu.RLock() - byID := make(map[int]domain.Message, len(s.m[userID])) - for _, msg := range s.m[userID] { - item := cloneMessage(msg) - reactions := s.privateMessageReactionsLocked(item.UID, item.OwnerUserID) - if len(reactions.Results) > 0 || len(reactions.Recent) > 0 { - item.Reactions = cloneChannelMessageReactionsPtr(&reactions) - } - byID[msg.ID] = item - } - s.mu.RUnlock() - out := domain.MessageList{Messages: make([]domain.Message, 0, len(ids))} - for _, id := range ids { - if msg, ok := byID[id]; ok { - out.Messages = append(out.Messages, msg) - } - } - out.Users = usersForMessages(out.Messages) - out.Hash = messageListHash(out.Messages) - return out, nil -} - -func (s *MessageStore) ListByUser(_ context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { - s.mu.RLock() - messages := cloneMessages(s.m[userID]) - for i := range messages { - reactions := s.privateMessageReactionsLocked(messages[i].UID, messages[i].OwnerUserID) - if len(reactions.Results) > 0 || len(reactions.Recent) > 0 { - messages[i].Reactions = cloneChannelMessageReactionsPtr(&reactions) - } - } - s.mu.RUnlock() - return filterMessageList(messages, filter), nil -} - -func (s *MessageStore) ReadHistory(_ context.Context, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) { - res := domain.ReadHistoryResult{OwnerUserID: req.OwnerUserID, Peer: req.Peer, MaxID: req.MaxID} - if req.OwnerUserID == 0 || req.Peer.ID == 0 { - return res, nil - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - s.mu.Lock() - defer s.mu.Unlock() - if s.dialogs == nil { - return res, nil - } - s.dialogs.mu.Lock() - defer s.dialogs.mu.Unlock() - list := s.dialogs.m[req.OwnerUserID] - for i, dialog := range list.Dialogs { - if dialog.Peer != req.Peer { - continue - } - readMax := req.MaxID - if readMax <= 0 { - readMax = dialog.TopMessage - } - if readMax > domain.MaxMessageBoxID { - readMax = domain.MaxMessageBoxID - } - oldRead := dialog.ReadInboxMaxID - res.MaxID = readMax - res.Changed = dialog.UnreadCount > 0 || readMax > oldRead - if !res.Changed { - return res, nil - } - var latestIncoming domain.Message - unread := 0 - for _, msg := range s.m[req.OwnerUserID] { - if msg.Peer != req.Peer || msg.Out { - continue - } - if msg.ID > readMax { - unread++ - continue - } - if msg.ID > oldRead && msg.ID > latestIncoming.ID { - latestIncoming = msg - } - } - if readMax > dialog.ReadInboxMaxID { - dialog.ReadInboxMaxID = readMax - } - dialog.UnreadCount = unread - dialog.UnreadMentions = 0 - dialog.UnreadReactions = 0 - dialog.UnreadMark = false - res.StillUnreadCount = unread - pts := s.nextPtsLocked(req.OwnerUserID) - res.InboxEvent = domain.UpdateEvent{ - UserID: req.OwnerUserID, - Type: domain.UpdateEventReadHistoryInbox, - Pts: pts, - PtsCount: 1, - Date: req.Date, - Peer: req.Peer, - MaxID: readMax, - StillUnreadCount: unread, - } - list.Dialogs[i] = dialog - s.dialogs.m[req.OwnerUserID] = list - - if latestIncoming.ID != 0 && latestIncoming.From.ID != 0 && latestIncoming.From.ID != req.OwnerUserID { - senderUserID := latestIncoming.From.ID - senderBoxID := 0 - for _, msg := range s.m[senderUserID] { - if msg.UID == latestIncoming.UID && msg.Out { - senderBoxID = msg.ID - break - } - } - if senderBoxID > 0 { - senderList := s.dialogs.m[senderUserID] - for j, senderDialog := range senderList.Dialogs { - if senderDialog.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) { - continue - } - if senderBoxID <= senderDialog.ReadOutboxMaxID { - break - } - oldOutbox := senderDialog.ReadOutboxMaxID - senderDialog.ReadOutboxMaxID = senderBoxID - senderList.Dialogs[j] = senderDialog - s.dialogs.m[senderUserID] = senderList - for _, msg := range s.m[senderUserID] { - if msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) && msg.Out && msg.ID > oldOutbox && msg.ID <= senderBoxID { - s.readOutboxDates[readOutboxDateKey{ownerUserID: senderUserID, peerID: req.OwnerUserID, msgID: msg.ID}] = req.Date - } - } - outPts := s.nextPtsLocked(senderUserID) - res.OutboxChanged = true - res.OutboxUserID = senderUserID - res.OutboxEvent = domain.UpdateEvent{ - UserID: senderUserID, - Type: domain.UpdateEventReadHistoryOutbox, - Pts: outPts, - PtsCount: 1, - Date: req.Date, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, - MaxID: senderBoxID, - } - break - } - } - } - return res, nil - } - return res, nil -} - -func (s *MessageStore) ReadMessageContents(_ context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { - res := domain.ReadMessageContentsResult{OwnerUserID: req.OwnerUserID} - if req.OwnerUserID == 0 { - return res, fmt.Errorf("read message contents: missing owner user id") - } - if len(req.IDs) > domain.MaxGetMessageIDs { - return res, domain.ErrMessageIDInvalid - } - wanted := make(map[int]struct{}, len(req.IDs)) - for _, id := range req.IDs { - if id <= 0 || id > domain.MaxMessageBoxID { - return res, domain.ErrMessageIDInvalid - } - wanted[id] = struct{}{} - } - if len(wanted) == 0 { - return res, nil - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - s.mu.Lock() - defer s.mu.Unlock() - affectedPeers := make(map[domain.Peer]struct{}) - for i := range s.m[req.OwnerUserID] { - msg := &s.m[req.OwnerUserID][i] - if _, ok := wanted[msg.ID]; !ok { - continue - } - if !msg.MediaUnread && !msg.ReactionUnread { - continue - } - if msg.ReactionUnread && msg.Peer.ID != 0 { - affectedPeers[msg.Peer] = struct{}{} - } - msg.MediaUnread = false - msg.ReactionUnread = false - res.MessageIDs = append(res.MessageIDs, msg.ID) - } - sort.Ints(res.MessageIDs) - if len(res.MessageIDs) == 0 { - return res, nil - } - if s.dialogs != nil && len(affectedPeers) > 0 { - s.dialogs.mu.Lock() - list := s.dialogs.m[req.OwnerUserID] - for i := range list.Dialogs { - if _, ok := affectedPeers[list.Dialogs[i].Peer]; !ok { - continue - } - list.Dialogs[i].UnreadReactions = s.countPrivateUnreadReactionsLocked(req.OwnerUserID, list.Dialogs[i].Peer) - } - s.dialogs.m[req.OwnerUserID] = list - s.dialogs.mu.Unlock() - } - pts := s.nextPtsNLocked(req.OwnerUserID, len(res.MessageIDs)) - res.Event = domain.UpdateEvent{ - UserID: req.OwnerUserID, - Type: domain.UpdateEventReadMessageContents, - Pts: pts, - PtsCount: len(res.MessageIDs), - Date: req.Date, - MessageIDs: append([]int(nil), res.MessageIDs...), - } - return res, nil -} - -func (s *MessageStore) GetOutboxReadDate(_ context.Context, req domain.OutboxReadDateRequest) (int, error) { - if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return 0, domain.ErrMessageIDInvalid - } - s.mu.RLock() - defer s.mu.RUnlock() - found := false - for _, msg := range s.m[req.OwnerUserID] { - if msg.ID == req.ID && msg.Peer == req.Peer && msg.Out { - found = true - break - } - } - if !found { - return 0, domain.ErrMessageIDInvalid - } - date := s.readOutboxDates[readOutboxDateKey{ownerUserID: req.OwnerUserID, peerID: req.Peer.ID, msgID: req.ID}] - if date == 0 { - return 0, domain.ErrMessageNotReadYet - } - return date, nil -} - -func (s *MessageStore) SetMessageReactions(_ context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { - if req.UserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - s.mu.Lock() - defer s.mu.Unlock() - var target domain.Message - for _, msg := range s.m[req.UserID] { - if msg.ID == req.MessageID && msg.Peer == req.Peer { - target = msg - break - } - } - if target.ID == 0 || target.UID == 0 { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - if _, ok := s.privateReactions[target.UID]; !ok { - s.privateReactions[target.UID] = make(map[int64][]domain.ChannelMessagePeerReaction) - } - rows := make([]domain.ChannelMessagePeerReaction, 0, len(req.Reactions)) - for i, reaction := range req.Reactions { - if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - rows = append(rows, domain.ChannelMessagePeerReaction{ - UserID: req.UserID, - Reaction: reaction, - Big: req.Big, - My: true, - ChosenOrder: i + 1, - Date: req.Date, - }) - } - if len(rows) == 0 { - delete(s.privateReactions[target.UID], req.UserID) - } else { - s.privateReactions[target.UID][req.UserID] = rows - } - if target.From.ID != 0 && target.From.ID != req.UserID { - for i := range s.m[target.From.ID] { - if s.m[target.From.ID][i].UID != target.UID { - continue - } - s.m[target.From.ID][i].ReactionUnread = len(rows) > 0 - if s.dialogs != nil { - s.dialogs.mu.Lock() - list := s.dialogs.m[target.From.ID] - peer := domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID} - for j := range list.Dialogs { - if list.Dialogs[j].Peer == peer { - list.Dialogs[j].UnreadReactions = s.countPrivateUnreadReactionsLocked(target.From.ID, peer) - } - } - s.dialogs.m[target.From.ID] = list - s.dialogs.mu.Unlock() - } - break - } - } - return s.privateReactionResultLocked(target.UID), nil -} - -func (s *MessageStore) GetMessageReactions(_ context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { - if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - ids := make(map[int]struct{}, len(req.IDs)) - for _, id := range req.IDs { - if id <= 0 || id > domain.MaxMessageBoxID { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - ids[id] = struct{}{} - } - s.mu.RLock() - defer s.mu.RUnlock() - out := domain.PrivateMessageReactionsResult{} - for _, msg := range s.m[req.OwnerUserID] { - if msg.Peer != req.Peer { - continue - } - if _, ok := ids[msg.ID]; !ok { - continue - } - item := cloneMessage(msg) - reactions := s.privateMessageReactionsLocked(msg.UID, msg.OwnerUserID) - item.Reactions = cloneChannelMessageReactionsPtr(&reactions) - out.Messages = append(out.Messages, item) - if len(out.Reactions.Results) == 0 && len(out.Reactions.Recent) == 0 { - out.Reactions = reactions - } - } - return out, nil -} - -func (s *MessageStore) privateReactionResultLocked(uid int64) domain.PrivateMessageReactionsResult { - out := domain.PrivateMessageReactionsResult{} - for _, messages := range s.m { - for _, msg := range messages { - if msg.UID != uid { - continue - } - item := cloneMessage(msg) - reactions := s.privateMessageReactionsLocked(uid, msg.OwnerUserID) - item.Reactions = cloneChannelMessageReactionsPtr(&reactions) - out.Messages = append(out.Messages, item) - if len(out.Reactions.Results) == 0 && len(out.Reactions.Recent) == 0 { - out.Reactions = reactions - } - } - } - return out -} - -func (s *MessageStore) privateMessageReactionsLocked(uid, viewerUserID int64) domain.ChannelMessageReactions { - byUser := s.privateReactions[uid] - out := domain.ChannelMessageReactions{CanSeeList: true} - if len(byUser) == 0 { - return out - } - counts := make(map[string]int) - recent := make([]domain.ChannelMessagePeerReaction, 0, len(byUser)) - for userID, rows := range byUser { - for _, row := range rows { - key := string(row.Reaction.Type) + "\x00" + row.Reaction.Emoticon - index, ok := counts[key] - if !ok { - out.Results = append(out.Results, domain.ChannelMessageReactionCount{Reaction: row.Reaction}) - index = len(out.Results) - 1 - counts[key] = index - } - out.Results[index].Count++ - if userID == viewerUserID && (out.Results[index].ChosenOrder == 0 || row.ChosenOrder < out.Results[index].ChosenOrder) { - out.Results[index].ChosenOrder = row.ChosenOrder - } - item := row - item.UserID = userID - item.My = userID == viewerUserID - recent = append(recent, item) - } - } - sort.Slice(out.Results, func(i, j int) bool { - if out.Results[i].Count != out.Results[j].Count { - return out.Results[i].Count > out.Results[j].Count - } - return out.Results[i].Reaction.Emoticon < out.Results[j].Reaction.Emoticon - }) - sort.Slice(recent, func(i, j int) bool { - if recent[i].Date != recent[j].Date { - return recent[i].Date > recent[j].Date - } - return recent[i].UserID < recent[j].UserID - }) - if len(recent) > domain.MaxChannelMessageReactionRecent { - recent = recent[:domain.MaxChannelMessageReactionRecent] - } - out.Recent = recent - return out -} - -func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, error) { - res := domain.EditMessageResult{OwnerUserID: req.OwnerUserID} - if req.OwnerUserID == 0 || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return res, domain.ErrMessageIDInvalid - } - if req.EditDate == 0 { - req.EditDate = int(time.Now().Unix()) - } - s.mu.Lock() - defer s.mu.Unlock() - targetIndex := -1 - var target domain.Message - for i, msg := range s.m[req.OwnerUserID] { - if msg.ID == req.ID && msg.Peer == req.Peer { - targetIndex = i - target = msg - break - } - } - if targetIndex < 0 { - return res, domain.ErrMessageIDInvalid - } - if !target.Out || target.From.ID != req.OwnerUserID { - return res, domain.ErrMessageAuthorRequired - } - if target.Body == req.Message && equalMessageEntities(target.Entities, req.Entities) { - return res, domain.ErrMessageNotModified - } - for userID, messages := range s.m { - for i, msg := range messages { - if msg.UID == target.UID && msg.From.ID == req.OwnerUserID { - msg.Body = req.Message - msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) - msg.EditDate = req.EditDate - msg.Pts = s.nextPtsLocked(userID) - s.m[userID][i] = msg - event := editMessageEvent(msg) - res.Edited = append(res.Edited, domain.EditedMessageForUser{ - UserID: userID, - Message: cloneMessage(msg), - Event: event, - }) - } - } - } - if s.dialogs != nil { - s.dialogs.mu.Lock() - for userID := range s.dialogs.m { - list := s.dialogs.m[userID] - list.Messages = cloneMessages(s.m[userID]) - s.dialogs.m[userID] = list - } - s.dialogs.mu.Unlock() - } - sort.Slice(res.Edited, func(i, j int) bool { return res.Edited[i].UserID < res.Edited[j].UserID }) - return res, nil -} - -func (s *MessageStore) DeleteMessages(_ context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { - res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} - ids := normalizeMemoryMessageIDs(req.IDs) - if req.OwnerUserID == 0 || len(ids) == 0 { - return res, nil - } - if len(ids) > domain.MaxDeleteMessageIDs { - return res, fmt.Errorf("delete messages: too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs) - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - idSet := make(map[int]struct{}, len(ids)) - for _, id := range ids { - idSet[id] = struct{}{} - } - s.mu.Lock() - defer s.mu.Unlock() - deleted, revokeUIDs, _ := s.deleteMemoryMessagesLocked(req.OwnerUserID, 0, func(msg domain.Message) bool { - _, ok := idSet[msg.ID] - return ok - }) - if req.Revoke && len(revokeUIDs) > 0 { - deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...) - } - return s.finishMemoryDeleteLocked(res, deleted, req.Date, false), nil -} - -func (s *MessageStore) DeleteHistory(_ context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { - res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} - if req.OwnerUserID == 0 || req.Peer.ID == 0 { - return res, nil - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - s.mu.Lock() - defer s.mu.Unlock() - deleted, revokeUIDs, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool { - return msg.Peer == req.Peer && (req.MaxID <= 0 || msg.ID <= req.MaxID) - }) - if req.Revoke && len(revokeUIDs) > 0 { - deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...) - } - res = s.finishMemoryDeleteLocked(res, deleted, req.Date, req.JustClear) - if more { - res.Offset = 1 - } - return res, nil -} - -func (s *MessageStore) nextBoxIDLocked(userID int64) int { - next := s.nextBox[userID] + 1 - s.nextBox[userID] = next - return next -} - -func (s *MessageStore) nextPtsLocked(userID int64) int { - next := s.nextPts[userID] + 1 - s.nextPts[userID] = next - return next -} - -func (s *MessageStore) nextPtsNLocked(userID int64, count int) int { - if count <= 0 { - count = 1 - } - next := s.nextPts[userID] + count - s.nextPts[userID] = next - return next -} - -func (s *MessageStore) countPrivateUnreadReactionsLocked(ownerUserID int64, peer domain.Peer) int { - count := 0 - for _, msg := range s.m[ownerUserID] { - if msg.Peer == peer && msg.ReactionUnread { - count++ - } - } - return count -} - -func (s *MessageStore) upsertMemoryDialogsLocked(sender, recipient domain.Message) { - s.dialogs.mu.Lock() - defer s.dialogs.mu.Unlock() - list := s.dialogs.m[sender.OwnerUserID] - list = upsertMemoryDialog(list, domain.Dialog{Peer: sender.Peer, TopMessage: sender.ID, TopMessageDate: sender.Date}) - list.Messages = append(list.Messages, sender) - s.dialogs.m[sender.OwnerUserID] = list - if recipient.OwnerUserID != sender.OwnerUserID { - peerList := s.dialogs.m[recipient.OwnerUserID] - peerList = upsertMemoryDialog(peerList, domain.Dialog{Peer: recipient.Peer, TopMessage: recipient.ID, TopMessageDate: recipient.Date, UnreadCount: 1}) - peerList.Messages = append(peerList.Messages, recipient) - s.dialogs.m[recipient.OwnerUserID] = peerList - } -} - -func upsertMemoryDialog(list domain.DialogList, dialog domain.Dialog) domain.DialogList { - for i := range list.Dialogs { - if list.Dialogs[i].Peer == dialog.Peer { - if dialog.ReadInboxMaxID == 0 { - dialog.ReadInboxMaxID = list.Dialogs[i].ReadInboxMaxID - } - if dialog.ReadOutboxMaxID == 0 { - dialog.ReadOutboxMaxID = list.Dialogs[i].ReadOutboxMaxID - } - if dialog.FolderID == domain.DialogMainFolderID && list.Dialogs[i].FolderID != domain.DialogMainFolderID { - dialog.FolderID = list.Dialogs[i].FolderID - } - if dialog.UnreadCount != 0 { - dialog.UnreadCount += list.Dialogs[i].UnreadCount - } - list.Dialogs[i] = dialog - return list - } - } - list.Dialogs = append(list.Dialogs, dialog) - return list -} - -type deletedMemoryMessage struct { - userID int64 - peer domain.Peer - id int -} - -func (s *MessageStore) deleteMemoryMessagesLocked(userID int64, limit int, match func(domain.Message) bool) ([]deletedMemoryMessage, map[int64]struct{}, bool) { - messages := s.m[userID] - kept := messages[:0] - deleted := make([]deletedMemoryMessage, 0) - revokeUIDs := make(map[int64]struct{}) - more := false - for _, msg := range messages { - if match(msg) { - if limit > 0 && len(deleted) >= limit { - kept = append(kept, msg) - more = true - continue - } - deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID}) - if msg.UID != 0 { - revokeUIDs[msg.UID] = struct{}{} - } - continue - } - kept = append(kept, msg) - } - s.m[userID] = kept - return deleted, revokeUIDs, more -} - -func (s *MessageStore) deleteMemoryMessagesByUIDLocked(uids map[int64]struct{}, excludeUserID int64) []deletedMemoryMessage { - if len(uids) == 0 { - return nil - } - deleted := make([]deletedMemoryMessage, 0) - for userID, messages := range s.m { - if userID == excludeUserID { - continue - } - kept := messages[:0] - for _, msg := range messages { - if _, ok := uids[msg.UID]; ok { - deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID}) - continue - } - kept = append(kept, msg) - } - s.m[userID] = kept - } - return deleted -} - -func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, preserveEmptyDialogs bool) domain.DeleteMessagesResult { - if len(deleted) == 0 { - return res - } - idsByOwner := make(map[int64][]int) - peersByOwner := make(map[int64]map[domain.Peer]struct{}) - for _, row := range deleted { - idsByOwner[row.userID] = append(idsByOwner[row.userID], row.id) - if peersByOwner[row.userID] == nil { - peersByOwner[row.userID] = make(map[domain.Peer]struct{}) - } - peersByOwner[row.userID][row.peer] = struct{}{} - } - if s.dialogs != nil { - s.dialogs.mu.Lock() - for userID, peers := range peersByOwner { - for peer := range peers { - s.rebuildMemoryDialogLocked(userID, peer, preserveEmptyDialogs) - } - } - s.dialogs.mu.Unlock() - } - ownerIDs := make([]int64, 0, len(idsByOwner)) - for userID := range idsByOwner { - ownerIDs = append(ownerIDs, userID) - } - sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] }) - for _, userID := range ownerIDs { - ids := normalizeMemoryMessageIDs(idsByOwner[userID]) - if len(ids) == 0 { - continue - } - pts := s.nextPtsNLocked(userID, len(ids)) - event := domain.UpdateEvent{ - UserID: userID, - Type: domain.UpdateEventDeleteMessages, - Pts: pts, - PtsCount: len(ids), - Date: date, - MessageIDs: ids, - } - res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{ - UserID: userID, - MessageIDs: ids, - Event: event, - }) - } - return res -} - -func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer, preserveEmpty bool) { - list := s.dialogs.m[userID] - topID := 0 - topDate := 0 - unread := 0 - for _, msg := range s.m[userID] { - if msg.Peer != peer { - continue - } - if msg.ID > topID { - topID = msg.ID - topDate = msg.Date - } - } - dialogs := list.Dialogs[:0] - for _, dialog := range list.Dialogs { - if dialog.Peer != peer { - dialogs = append(dialogs, dialog) - continue - } - if topID == 0 { - if preserveEmpty { - oldTop := dialog.TopMessage - dialog.TopMessage = 0 - dialog.TopMessageDate = 0 - if dialog.ReadInboxMaxID < oldTop { - dialog.ReadInboxMaxID = oldTop - } - if dialog.ReadOutboxMaxID < oldTop { - dialog.ReadOutboxMaxID = oldTop - } - dialog.UnreadCount = 0 - dialog.UnreadMark = false - dialog.UnreadMentions = 0 - dialog.UnreadReactions = 0 - dialogs = append(dialogs, dialog) - } - continue - } - for _, msg := range s.m[userID] { - if msg.Peer == peer && !msg.Out && msg.ID > dialog.ReadInboxMaxID { - unread++ - } - } - dialog.TopMessage = topID - dialog.TopMessageDate = topDate - dialog.UnreadCount = unread - dialog.UnreadMentions = 0 - dialog.UnreadReactions = 0 - dialogs = append(dialogs, dialog) - } - list.Dialogs = dialogs - list.Messages = cloneMessages(s.m[userID]) - s.dialogs.m[userID] = list -} - -func normalizeMemoryMessageIDs(ids []int) []int { - if len(ids) == 0 { - return nil - } - out := make([]int, 0, len(ids)) - seen := make(map[int]struct{}, len(ids)) - for _, id := range ids { - if id <= 0 || id > domain.MaxMessageBoxID { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - sort.Ints(out) - return out -} - -func cloneMessage(msg domain.Message) domain.Message { - msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) - msg.ReplyTo = cloneMessageReply(msg.ReplyTo) - msg.Forward = cloneMessageForward(msg.Forward) - msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions) - return msg -} - -func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { - if reply == nil { - return nil - } - clone := *reply - clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...) - return &clone -} - -func cloneMessageForward(forward *domain.MessageForward) *domain.MessageForward { - if forward == nil { - return nil - } - clone := *forward - return &clone -} - -func newMessageEvent(msg domain.Message) domain.UpdateEvent { - if msg.ID == 0 { - return domain.UpdateEvent{} - } - return domain.UpdateEvent{ - UserID: msg.OwnerUserID, - Type: domain.UpdateEventNewMessage, - Pts: msg.Pts, - PtsCount: 1, - Date: msg.Date, - Message: cloneMessage(msg), - } -} - -func editMessageEvent(msg domain.Message) domain.UpdateEvent { - if msg.ID == 0 { - return domain.UpdateEvent{} - } - return domain.UpdateEvent{ - UserID: msg.OwnerUserID, - Type: domain.UpdateEventEditMessage, - Pts: msg.Pts, - PtsCount: 1, - Date: msg.EditDate, - Message: cloneMessage(msg), - } -} - -func equalMessageEntities(a, b []domain.MessageEntity) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func hasUser(users []domain.User, id int64) bool { - for _, u := range users { - if u.ID == id { - return true - } - } - return false -} - -func filterMessageList(messages []domain.Message, filter domain.MessageFilter) domain.MessageList { - filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) - sort.SliceStable(messages, func(i, j int) bool { - return messageLess(messages[i], messages[j]) - }) - - query := strings.ToLower(filter.Query) - base := make([]domain.Message, 0, len(messages)) - for _, msg := range messages { - if filter.HasPeer && msg.Peer != filter.Peer { - continue - } - if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) { - continue - } - if filter.MaxID > 0 && msg.ID >= filter.MaxID { - continue - } - if filter.MinID > 0 && msg.ID <= filter.MinID { - continue - } - base = append(base, msg) - } - - limit := filter.Limit - if limit <= 0 { - limit = 100 - } - if limit > 500 { - limit = 500 - } - page := pageMessageHistory(base, filter, limit) - return domain.MessageList{ - Messages: page, - Users: usersForMessages(page), - Count: len(base), - Hash: messageListHash(base), - } -} - -func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message { - if limit <= 0 || len(base) == 0 { - return nil - } - switch messageHistoryLoadType(filter.AddOffset, limit) { - case messageHistoryLoadForward: - return cloneMessages(forwardMessageHistory(base, filter, limit)) - case messageHistoryLoadAround: - forwardLimit := -filter.AddOffset - if forwardLimit > limit { - forwardLimit = limit - } - backwardLimit := limit + filter.AddOffset - if backwardLimit < 0 { - backwardLimit = 0 - } - page := make([]domain.Message, 0, limit) - page = append(page, forwardMessageHistory(base, filter, forwardLimit)...) - page = append(page, backwardMessageHistory(base, filter, backwardLimit, true)...) - sort.SliceStable(page, func(i, j int) bool { - return messageLess(page[i], page[j]) - }) - return cloneMessages(page) - default: - start := filter.AddOffset - if start < 0 { - start = 0 - } - candidates := backwardMessageHistory(base, filter, limit+start, false) - if start >= len(candidates) { - return nil - } - return cloneMessages(candidates[start:]) - } -} - -type messageHistoryLoad int - -const ( - messageHistoryLoadBackward messageHistoryLoad = iota - messageHistoryLoadForward - messageHistoryLoadAround -) - -func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad { - if addOffset >= 0 { - return messageHistoryLoadBackward - } - if addOffset+limit > 0 { - return messageHistoryLoadAround - } - return messageHistoryLoadForward -} - -func backwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int, includeOffset bool) []domain.Message { - if limit <= 0 { - return nil - } - out := make([]domain.Message, 0, limit) - for _, msg := range base { - if !messageBeforeHistoryOffset(msg, filter, includeOffset) { - continue - } - out = append(out, msg) - if len(out) == limit { - break - } - } - return out -} - -func forwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message { - if limit <= 0 { - return nil - } - out := make([]domain.Message, 0, limit) - for i := len(base) - 1; i >= 0; i-- { - msg := base[i] - if !messageAfterHistoryOffset(msg, filter) { - continue - } - out = append(out, msg) - if len(out) == limit { - break - } - } - sort.SliceStable(out, func(i, j int) bool { - return messageLess(out[i], out[j]) - }) - return out -} - -func messageBeforeHistoryOffset(msg domain.Message, filter domain.MessageFilter, includeOffset bool) bool { - if filter.OffsetDate > 0 { - if includeOffset { - return msg.Date <= filter.OffsetDate - } - return msg.Date < filter.OffsetDate - } - if filter.OffsetID <= 0 { - return true - } - if includeOffset { - return msg.ID <= filter.OffsetID - } - return msg.ID < filter.OffsetID -} - -func messageAfterHistoryOffset(msg domain.Message, filter domain.MessageFilter) bool { - if filter.OffsetDate > 0 { - return msg.Date >= filter.OffsetDate - } - if filter.OffsetID <= 0 { - return false - } - return msg.ID > filter.OffsetID -} - -func messageLess(a, b domain.Message) bool { - if a.Date != b.Date { - return a.Date > b.Date - } - return a.ID > b.ID -} - -func usersForMessages(messages []domain.Message) []domain.User { - seen := map[int64]struct{}{} - users := make([]domain.User, 0, 1) - for _, msg := range messages { - for _, peer := range []domain.Peer{msg.Peer, msg.From} { - if peer.Type != domain.PeerTypeUser { - continue - } - if _, ok := seen[peer.ID]; ok { - continue - } - seen[peer.ID] = struct{}{} - if peer.ID == domain.OfficialSystemUserID { - users = append(users, domain.OfficialSystemUser()) - } - } - } - return users -} - -func messageListHash(messages []domain.Message) int64 { - if len(messages) == 0 { - return 0 - } - h := fnv.New64a() - var buf [16]byte - for _, msg := range messages { - binary.LittleEndian.PutUint32(buf[:4], uint32(msg.ID)) - binary.LittleEndian.PutUint32(buf[4:8], uint32(msg.Date)) - binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.From.ID)) - _, _ = h.Write(buf[:]) - writeMessageReactionsHash(h, msg.Reactions) - } - return int64(h.Sum64()) -} - -func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageReactions) { - if reactions == nil { - _, _ = h.Write([]byte{0}) - return - } - var buf [16]byte - for _, item := range reactions.Results { - _, _ = h.Write([]byte(item.Reaction.Type)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(item.Reaction.Emoticon)) - _, _ = h.Write([]byte{0}) - binary.LittleEndian.PutUint32(buf[:4], uint32(item.Count)) - binary.LittleEndian.PutUint32(buf[4:8], uint32(item.ChosenOrder)) - _, _ = h.Write(buf[:8]) - } - _, _ = h.Write([]byte{0xfe}) - for _, item := range reactions.Recent { - _, _ = h.Write([]byte(item.Reaction.Type)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(item.Reaction.Emoticon)) - _, _ = h.Write([]byte{0}) - binary.LittleEndian.PutUint64(buf[:8], uint64(item.UserID)) - binary.LittleEndian.PutUint32(buf[8:12], uint32(item.Date)) - binary.LittleEndian.PutUint32(buf[12:16], uint32(item.ChosenOrder)) - _, _ = h.Write(buf[:]) - } -} - -func cloneContacts(contacts []domain.Contact) []domain.Contact { - out := append([]domain.Contact(nil), contacts...) - for i := range out { - out[i] = cloneContact(out[i]) - } - return out -} - -func cloneContact(contact domain.Contact) domain.Contact { - contact.NoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...) - return contact -} - -func contactListHash(contacts []domain.Contact) int64 { - if len(contacts) == 0 { - return 0 - } - h := fnv.New64a() - var buf [16]byte - for _, contact := range contacts { - binary.LittleEndian.PutUint64(buf[:8], uint64(contact.User.ID)) - if contact.Mutual { - buf[8] = 1 - } else { - buf[8] = 0 - } - _, _ = h.Write(buf[:9]) - _, _ = h.Write([]byte(contact.FirstName)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(contact.LastName)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(contact.Phone)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(contact.Note)) - _, _ = h.Write([]byte{0}) - } - return int64(h.Sum64()) -} - -func filterDialogList(list domain.DialogList, filter domain.DialogFilter) domain.DialogList { - sort.SliceStable(list.Dialogs, func(i, j int) bool { - return dialogLess(list.Dialogs[i], list.Dialogs[j]) - }) - - base := make([]domain.Dialog, 0, len(list.Dialogs)) - for _, d := range list.Dialogs { - if !dialogMatchesFolder(d, list.Users, filter) { - continue - } - if filter.PinnedOnly && !d.Pinned { - continue - } - if filter.ExcludePinned && d.Pinned { - continue - } - base = append(base, d) - } - - list.Count = len(base) - list.Hash = dialogListHash(base) - limit := filter.Limit - if limit <= 0 { - limit = 100 - } - if limit > 500 { - limit = 500 - } - page := make([]domain.Dialog, 0, len(base)) - for _, d := range base { - if !afterDialogOffset(d, filter) { - continue - } - page = append(page, d) - if len(page) >= limit { - break - } - } - list.Dialogs = page - list.Messages = keepDialogMessages(list.Messages, page) - return list -} - -func dialogMatchesFolder(d domain.Dialog, users []domain.User, filter domain.DialogFilter) bool { - if !filter.HasFolderID { - return true - } - if filter.FolderID < domain.DialogCustomFolderMinID { - return d.FolderID == filter.FolderID - } - if filter.Folder == nil { - return false - } - folder := filter.Folder - if folder.ExcludeArchived && d.FolderID == domain.DialogArchiveFolderID { - return false - } - if folder.ExcludeRead && d.UnreadCount == 0 && !d.UnreadMark { - return false - } - if hasFolderPeer(folder.ExcludePeers, d.Peer) { - return false - } - if hasFolderPeer(folder.IncludePeers, d.Peer) || hasFolderPeer(folder.PinnedPeers, d.Peer) { - return true - } - if d.Peer.Type == domain.PeerTypeUser { - user, ok := findDialogUser(users, d.Peer.ID) - if ok && user.Contact && folder.Contacts { - return true - } - if (!ok || !user.Contact) && folder.NonContacts { - return true - } - } - return false -} - -func hasFolderPeer(peers []domain.DialogFolderPeer, peer domain.Peer) bool { - for _, item := range peers { - if item.Peer == peer { - return true - } - } - return false -} - -func dialogLess(a, b domain.Dialog) bool { - if a.Pinned != b.Pinned { - return a.Pinned && !b.Pinned - } - if a.Pinned && b.Pinned && a.PinnedOrder != b.PinnedOrder { - if a.PinnedOrder == 0 { - return false - } - if b.PinnedOrder == 0 { - return true - } - return a.PinnedOrder < b.PinnedOrder - } - if a.TopMessageDate != b.TopMessageDate { - return a.TopMessageDate > b.TopMessageDate - } - if a.TopMessage != b.TopMessage { - return a.TopMessage > b.TopMessage - } - return a.Peer.ID > b.Peer.ID -} - -func afterDialogOffset(d domain.Dialog, filter domain.DialogFilter) bool { - if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { - return true - } - if filter.OffsetDate > 0 { - if d.TopMessageDate != filter.OffsetDate { - return d.TopMessageDate < filter.OffsetDate - } - if filter.OffsetID <= 0 { - return false - } - if d.TopMessage != filter.OffsetID { - return d.TopMessage < filter.OffsetID - } - if filter.HasOffsetPeer { - return d.Peer.ID < filter.OffsetPeer.ID - } - return false - } - return d.TopMessage < filter.OffsetID -} - -func keepDialogMessages(messages []domain.Message, dialogs []domain.Dialog) []domain.Message { - want := make(map[int]struct{}, len(dialogs)) - for _, d := range dialogs { - if d.TopMessage != 0 { - want[d.TopMessage] = struct{}{} - } - } - out := make([]domain.Message, 0, len(want)) - for _, msg := range messages { - if _, ok := want[msg.ID]; ok { - out = append(out, msg) - } - } - return out -} - -func findDialogUser(users []domain.User, id int64) (domain.User, bool) { - for _, user := range users { - if user.ID == id { - return user, true - } - } - return domain.User{}, false -} - -func appendDialogUser(list *domain.DialogList, seen map[int64]struct{}, user domain.User) { - if user.ID == 0 { - return - } - if _, ok := seen[user.ID]; ok { - return - } - seen[user.ID] = struct{}{} - list.Users = append(list.Users, user) -} - -func cloneMessages(messages []domain.Message) []domain.Message { - out := append([]domain.Message(nil), messages...) - for i := range out { - out[i] = cloneMessage(out[i]) - } - return out -} - -func cloneDialogs(dialogs []domain.Dialog) []domain.Dialog { - out := append([]domain.Dialog(nil), dialogs...) - for i := range out { - if out[i].Draft != nil { - draft := cloneDialogDraft(*out[i].Draft) - out[i].Draft = &draft - } - } - return out -} - -func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft { - draft.Entities = append([]domain.MessageEntity(nil), draft.Entities...) - draft.ReplyTo = cloneMessageReply(draft.ReplyTo) - if draft.WebPage != nil { - webpage := *draft.WebPage - draft.WebPage = &webpage - } - return draft -} - -func draftKey(peer domain.Peer, topMessageID int) dialogDraftKey { - return dialogDraftKey{peerType: peer.Type, peerID: peer.ID, topMessageID: topMessageID} -} - -func sortDialogDrafts(drafts []domain.DialogDraft) { - sort.SliceStable(drafts, func(i, j int) bool { - if drafts[i].Date != drafts[j].Date { - return drafts[i].Date > drafts[j].Date - } - if drafts[i].Peer.Type != drafts[j].Peer.Type { - return drafts[i].Peer.Type < drafts[j].Peer.Type - } - if drafts[i].Peer.ID != drafts[j].Peer.ID { - return drafts[i].Peer.ID > drafts[j].Peer.ID - } - return drafts[i].TopMessageID > drafts[j].TopMessageID - }) -} - -func cloneDialogFolder(folder domain.DialogFolder) domain.DialogFolder { - folder.TitleEntities = append([]domain.MessageEntity(nil), folder.TitleEntities...) - folder.PinnedPeers = append([]domain.DialogFolderPeer(nil), folder.PinnedPeers...) - folder.IncludePeers = append([]domain.DialogFolderPeer(nil), folder.IncludePeers...) - folder.ExcludePeers = append([]domain.DialogFolderPeer(nil), folder.ExcludePeers...) - return folder -} - -func containsInt(items []int, value int) bool { - for _, item := range items { - if item == value { - return true - } - } - return false -} - -func removeInt(items []int, value int) []int { - out := items[:0] - for _, item := range items { - if item != value { - out = append(out, item) - } - } - return out -} - -func dialogListHash(dialogs []domain.Dialog) int64 { - if len(dialogs) == 0 { - return 0 - } - h := fnv.New64a() - var buf [47]byte - for _, d := range dialogs { - binary.LittleEndian.PutUint64(buf[:8], uint64(d.Peer.ID)) - binary.LittleEndian.PutUint32(buf[8:12], uint32(d.FolderID)) - binary.LittleEndian.PutUint32(buf[12:16], uint32(d.TopMessage)) - binary.LittleEndian.PutUint32(buf[16:20], uint32(d.TopMessageDate)) - binary.LittleEndian.PutUint32(buf[20:24], uint32(d.ReadInboxMaxID)) - binary.LittleEndian.PutUint32(buf[24:28], uint32(d.ReadOutboxMaxID)) - binary.LittleEndian.PutUint32(buf[28:32], uint32(d.UnreadCount)) - binary.LittleEndian.PutUint32(buf[32:36], uint32(d.UnreadMentions)) - binary.LittleEndian.PutUint32(buf[36:40], uint32(d.UnreadReactions)) - if d.Pinned { - buf[40] = 1 - } else { - buf[40] = 0 - } - binary.LittleEndian.PutUint32(buf[41:45], uint32(d.PinnedOrder)) - if d.UnreadMark { - buf[45] = 1 - } else { - buf[45] = 0 - } - if d.PeerSettingsBarHidden { - buf[46] = 1 - } else { - buf[46] = 0 - } - _, _ = h.Write(buf[:]) - } - return int64(h.Sum64()) -} - -// LangPackStore 是 store.LangPackStore 的内存实现。 -type LangPackStore struct { - mu sync.RWMutex - m map[string]domain.LangPack -} - -// NewLangPackStore 创建内存 LangPackStore。 -func NewLangPackStore() *LangPackStore { - return &LangPackStore{m: make(map[string]domain.LangPack)} -} - -func (s *LangPackStore) GetPack(_ context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { - s.mu.RLock() - pack := s.m[langPackKey(langPack, langCode)] - s.mu.RUnlock() - if pack.LangPack == "" { - return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil - } - pack.FromVersion = fromVersion - if pack.Version <= fromVersion { - pack.Strings = nil - } else { - pack.Strings = append([]domain.LangPackString(nil), pack.Strings...) - } - return pack, nil -} - -func (s *LangPackStore) GetStrings(_ context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) { - s.mu.RLock() - pack := s.m[langPackKey(langPack, langCode)] - s.mu.RUnlock() - if pack.LangPack == "" { - return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil - } - if len(keys) == 0 { - pack.Strings = append([]domain.LangPackString(nil), pack.Strings...) - return pack, nil - } - want := make(map[string]struct{}, len(keys)) - for _, key := range keys { - want[key] = struct{}{} - } - out := domain.LangPack{LangPack: pack.LangPack, LangCode: pack.LangCode, Version: pack.Version} - for _, item := range pack.Strings { - if _, ok := want[item.Key]; ok { - out.Strings = append(out.Strings, item) - } - } - return out, nil -} - -func (s *LangPackStore) UpsertPack(_ context.Context, pack domain.LangPack) error { - pack.Strings = append([]domain.LangPackString(nil), pack.Strings...) - s.mu.Lock() - s.m[langPackKey(pack.LangPack, pack.LangCode)] = pack - s.mu.Unlock() - return nil -} - -func langPackKey(langPack, langCode string) string { - return langPack + "\x00" + langCode -} - -// PasswordStore 是 store.PasswordStore 的内存实现。 -type PasswordStore struct { - mu sync.RWMutex - m map[int64]domain.PasswordSettings - reactions map[int64]domain.AccountReactionSettings -} - -// NewPasswordStore 创建内存 PasswordStore。 -func NewPasswordStore() *PasswordStore { - return &PasswordStore{ - m: make(map[int64]domain.PasswordSettings), - reactions: make(map[int64]domain.AccountReactionSettings), - } -} - -func (s *PasswordStore) GetByUser(_ context.Context, userID int64) (domain.PasswordSettings, bool, error) { - s.mu.RLock() - settings, ok := s.m[userID] - s.mu.RUnlock() - return clonePasswordSettings(settings), ok, nil -} - -func (s *PasswordStore) Save(_ context.Context, userID int64, settings domain.PasswordSettings) error { - s.mu.Lock() - s.m[userID] = clonePasswordSettings(settings) - s.mu.Unlock() - return nil -} - -func clonePasswordSettings(in domain.PasswordSettings) domain.PasswordSettings { - out := in - if in.CurrentAlgo != nil { - algo := *in.CurrentAlgo - algo.Salt1 = append([]byte(nil), algo.Salt1...) - algo.Salt2 = append([]byte(nil), algo.Salt2...) - algo.P = append([]byte(nil), algo.P...) - out.CurrentAlgo = &algo - } - out.SRPB = append([]byte(nil), in.SRPB...) - out.NewAlgo.Salt1 = append([]byte(nil), in.NewAlgo.Salt1...) - out.NewAlgo.Salt2 = append([]byte(nil), in.NewAlgo.Salt2...) - out.NewAlgo.P = append([]byte(nil), in.NewAlgo.P...) - out.NewSecureAlgo.Salt = append([]byte(nil), in.NewSecureAlgo.Salt...) - out.SecureRandom = append([]byte(nil), in.SecureRandom...) - out.SRPVerifier = append([]byte(nil), in.SRPVerifier...) - out.SRPBSecret = append([]byte(nil), in.SRPBSecret...) - return out -} - -func (s *PasswordStore) GetReactionSettings(_ context.Context, userID int64) (domain.AccountReactionSettings, bool, error) { - s.mu.RLock() - settings, ok := s.reactions[userID] - s.mu.RUnlock() - return cloneAccountReactionSettings(settings), ok, nil -} - -func (s *PasswordStore) SaveReactionSettings(_ context.Context, userID int64, settings domain.AccountReactionSettings) error { - s.mu.Lock() - s.reactions[userID] = cloneAccountReactionSettings(settings) - s.mu.Unlock() - return nil -} - -func cloneAccountReactionSettings(in domain.AccountReactionSettings) domain.AccountReactionSettings { - out := in - if in.PaidPrivacy.Peer != nil { - peer := *in.PaidPrivacy.Peer - out.PaidPrivacy.Peer = &peer - } - return out -} - -// HelpStore 是 store.AppConfigStore 和 store.CountryStore 的内存实现。 -type HelpStore struct { - mu sync.RWMutex - appConfig map[string]domain.AppConfig - countries domain.CountriesList -} - -// NewHelpStore 创建内存 HelpStore。 -func NewHelpStore() *HelpStore { - return &HelpStore{appConfig: make(map[string]domain.AppConfig)} -} - -func (s *HelpStore) GetAppConfig(_ context.Context, client string) (domain.AppConfig, bool, error) { - s.mu.RLock() - cfg, ok := s.appConfig[client] - s.mu.RUnlock() - cfg.JSON = append([]byte(nil), cfg.JSON...) - return cfg, ok, nil -} - -func (s *HelpStore) UpsertAppConfig(_ context.Context, cfg domain.AppConfig) error { - cfg.JSON = append([]byte(nil), cfg.JSON...) - s.mu.Lock() - s.appConfig[cfg.Client] = cfg - s.mu.Unlock() - return nil -} - -func (s *HelpStore) ListCountries(_ context.Context, _ string) (domain.CountriesList, error) { - s.mu.RLock() - list := s.countries - s.mu.RUnlock() - list.Countries = append([]domain.Country(nil), list.Countries...) - for i := range list.Countries { - list.Countries[i].CountryCodes = append([]domain.CountryCode(nil), list.Countries[i].CountryCodes...) - } - return list, nil -} - -func (s *HelpStore) UpsertCountries(_ context.Context, countries []domain.Country) error { - list := domain.CountriesList{Hash: 1, Countries: append([]domain.Country(nil), countries...)} - for i := range list.Countries { - list.Countries[i].CountryCodes = append([]domain.CountryCode(nil), list.Countries[i].CountryCodes...) - } - s.mu.Lock() - s.countries = list - s.mu.Unlock() - return nil -} - -// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。 -type UserStore struct { - mu sync.RWMutex - byID map[int64]domain.User - nextID int64 -} - -// NewUserStore 创建内存 UserStore。 -func NewUserStore() *UserStore { - return &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase} -} - -func (s *UserStore) ByID(_ context.Context, id int64) (domain.User, bool, error) { - s.mu.RLock() - u, ok := s.byID[id] - s.mu.RUnlock() - return u, ok, nil -} - -func (s *UserStore) ByIDs(_ context.Context, ids []int64) ([]domain.User, error) { - if len(ids) == 0 { - return nil, nil - } - s.mu.RLock() - defer s.mu.RUnlock() - out := make([]domain.User, 0, len(ids)) - seen := make(map[int64]struct{}, len(ids)) - for _, id := range ids { - if id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - if u, ok := s.byID[id]; ok { - out = append(out, u) - } - } - return out, nil -} - -func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool, error) { - s.mu.RLock() - defer s.mu.RUnlock() - for _, u := range s.byID { - if u.Phone == phone { - return u, true, nil - } - } - return domain.User{}, false, nil -} - -func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User, error) { - if len(phones) == 0 { - return nil, nil - } - s.mu.RLock() - defer s.mu.RUnlock() - want := make(map[string]struct{}, len(phones)) - for _, phone := range phones { - if phone != "" { - want[phone] = struct{}{} - } - } - out := make([]domain.User, 0, len(want)) - seenIDs := map[int64]struct{}{} - for _, u := range s.byID { - if _, ok := want[u.Phone]; !ok { - continue - } - if _, ok := seenIDs[u.ID]; ok { - continue - } - seenIDs[u.ID] = struct{}{} - out = append(out, u) - } - sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) - return out, nil -} - -func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User, bool, error) { - username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) - if username == "" { - return domain.User{}, false, nil - } - s.mu.RLock() - defer s.mu.RUnlock() - for _, u := range s.byID { - if strings.ToLower(u.Username) == username { - return u, true, nil - } - } - return domain.User{}, false, nil -} - -func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) { - if limit <= 0 { - limit = 50 - } - query = strings.ToLower(strings.TrimSpace(query)) - phoneQuery = strings.TrimSpace(phoneQuery) - if query == "" { - return domain.UserSearchResult{}, nil - } - s.mu.RLock() - defer s.mu.RUnlock() - users := make([]domain.User, 0) - for _, u := range s.byID { - if u.ID == currentUserID { - continue - } - if userMatchesSearch(u, query, phoneQuery) { - users = append(users, u) - } - } - sort.SliceStable(users, func(i, j int) bool { - return users[i].ID < users[j].ID - }) - if len(users) > limit { - users = users[:limit] - } - return domain.UserSearchResult{Results: users}, nil -} - -func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) { - username = strings.TrimSpace(strings.TrimPrefix(username, "@")) - usernameLower := strings.ToLower(username) - s.mu.Lock() - defer s.mu.Unlock() - u, ok := s.byID[userID] - if !ok { - return domain.User{}, domain.ErrUsernameNotOccupied - } - if usernameLower != "" { - for id, existing := range s.byID { - if id != userID && strings.ToLower(existing.Username) == usernameLower { - return domain.User{}, domain.ErrUsernameOccupied - } - } - } - u.Username = username - s.byID[userID] = u - return u, nil -} - -func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, lastName, about string) (domain.User, error) { - s.mu.Lock() - defer s.mu.Unlock() - u, ok := s.byID[userID] - if !ok { - return domain.User{}, domain.ErrUsernameNotOccupied - } - u.FirstName = firstName - u.LastName = lastName - u.About = about - s.byID[userID] = u - return u, nil -} - -func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt int) error { - if lastSeenAt <= 0 { - return nil - } - s.mu.Lock() - defer s.mu.Unlock() - u, ok := s.byID[userID] - if !ok { - return domain.ErrUsernameNotOccupied - } - if lastSeenAt > u.LastSeenAt { - u.LastSeenAt = lastSeenAt - s.byID[userID] = u - } - return nil -} - -func userMatchesSearch(u domain.User, query, phoneQuery string) bool { - if phoneQuery != "" && strings.HasPrefix(u.Phone, phoneQuery) { - return true - } - first := strings.ToLower(u.FirstName) - last := strings.ToLower(u.LastName) - username := strings.ToLower(u.Username) - fullName := strings.TrimSpace(first + " " + last) - return strings.Contains(first, query) || - strings.Contains(last, query) || - strings.Contains(fullName, query) || - strings.Contains(username, query) -} - -func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error) { - s.mu.Lock() - defer s.mu.Unlock() - username := strings.ToLower(strings.TrimSpace(u.Username)) - if username != "" { - for _, existing := range s.byID { - if strings.ToLower(existing.Username) == username { - return domain.User{}, domain.ErrUsernameOccupied - } - } - } - u.ID = s.nextID - s.nextID++ - s.byID[u.ID] = u - return u, nil -} - -// AuthorizationStore 是 store.AuthorizationStore 的内存实现。 -type AuthorizationStore struct { - mu sync.RWMutex - m map[[8]byte]domain.Authorization -} - -// NewAuthorizationStore 创建内存 AuthorizationStore。 -func NewAuthorizationStore() *AuthorizationStore { - return &AuthorizationStore{m: make(map[[8]byte]domain.Authorization)} -} - -func (s *AuthorizationStore) Bind(_ context.Context, a domain.Authorization) error { - now := time.Now() - if a.Hash == 0 { - a.Hash = int64(binary.LittleEndian.Uint64(a.AuthKeyID[:])) - } - if a.CreatedAt.IsZero() { - a.CreatedAt = now - } - a.ActiveAt = now - s.mu.Lock() - if existing, ok := s.m[a.AuthKeyID]; ok && !existing.CreatedAt.IsZero() { - a.CreatedAt = existing.CreatedAt - } - s.m[a.AuthKeyID] = a - s.mu.Unlock() - return nil -} - -func (s *AuthorizationStore) ByAuthKey(_ context.Context, id [8]byte) (domain.Authorization, bool, error) { - s.mu.RLock() - a, ok := s.m[id] - s.mu.RUnlock() - return a, ok, nil -} - -func (s *AuthorizationStore) ListByUser(_ context.Context, userID int64) ([]domain.Authorization, error) { - s.mu.RLock() - defer s.mu.RUnlock() - out := make([]domain.Authorization, 0) - for _, a := range s.m { - if a.UserID == userID { - out = append(out, a) - } - } - return out, nil -} - -func (s *AuthorizationStore) Delete(_ context.Context, id [8]byte) error { - s.mu.Lock() - delete(s.m, id) - s.mu.Unlock() - return nil -} - -func (s *AuthorizationStore) DeleteByHash(_ context.Context, userID, hash int64) (domain.Authorization, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - for id, a := range s.m { - if a.UserID == userID && a.Hash == hash { - delete(s.m, id) - return a, true, nil - } - } - return domain.Authorization{}, false, nil -} - -func (s *AuthorizationStore) DeleteByUserExcept(_ context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) { - s.mu.Lock() - defer s.mu.Unlock() - out := make([]domain.Authorization, 0) - for id, a := range s.m { - if a.UserID != userID || id == keepAuthKeyID { - continue - } - delete(s.m, id) - out = append(out, a) - } - return out, nil -} - -// CodeStore 是 store.CodeStore 的内存实现(带 TTL)。 -type CodeStore struct { - mu sync.Mutex - m map[string]codeEntry -} - -type codeEntry struct { - code store.PhoneCode - expires time.Time -} - -// NewCodeStore 创建内存 CodeStore。 -func NewCodeStore() *CodeStore { - return &CodeStore{m: make(map[string]codeEntry)} -} - -func (s *CodeStore) Set(_ context.Context, hash string, code store.PhoneCode, ttl time.Duration) error { - s.mu.Lock() - s.m[hash] = codeEntry{code: code, expires: time.Now().Add(ttl)} - s.mu.Unlock() - return nil -} - -func (s *CodeStore) Get(_ context.Context, hash string) (store.PhoneCode, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - e, ok := s.m[hash] - if !ok || time.Now().After(e.expires) { - return store.PhoneCode{}, false, nil - } - return e.code, true, nil -} - -func (s *CodeStore) Del(_ context.Context, hash string) error { - s.mu.Lock() - delete(s.m, hash) - s.mu.Unlock() - return nil -} diff --git a/internal/store/memory/message_allocators.go b/internal/store/memory/message_allocators.go new file mode 100644 index 00000000..375b1b8e --- /dev/null +++ b/internal/store/memory/message_allocators.go @@ -0,0 +1,22 @@ +package memory + +func (s *MessageStore) nextBoxIDLocked(userID int64) int { + next := s.nextBox[userID] + 1 + s.nextBox[userID] = next + return next +} + +func (s *MessageStore) nextPtsLocked(userID int64) int { + next := s.nextPts[userID] + 1 + s.nextPts[userID] = next + return next +} + +func (s *MessageStore) nextPtsNLocked(userID int64, count int) int { + if count <= 0 { + count = 1 + } + next := s.nextPts[userID] + count + s.nextPts[userID] = next + return next +} diff --git a/internal/store/memory/message_delete.go b/internal/store/memory/message_delete.go new file mode 100644 index 00000000..0e4a0980 --- /dev/null +++ b/internal/store/memory/message_delete.go @@ -0,0 +1,149 @@ +package memory + +import ( + "context" + "fmt" + "sort" + "telesrv/internal/domain" + "time" +) + +func (s *MessageStore) DeleteMessages(_ context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { + res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} + ids := normalizeMemoryMessageIDs(req.IDs) + if req.OwnerUserID == 0 || len(ids) == 0 { + return res, nil + } + if len(ids) > domain.MaxDeleteMessageIDs { + return res, fmt.Errorf("delete messages: too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs) + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + idSet := make(map[int]struct{}, len(ids)) + for _, id := range ids { + idSet[id] = struct{}{} + } + s.mu.Lock() + defer s.mu.Unlock() + deleted, revokeUIDs, _ := s.deleteMemoryMessagesLocked(req.OwnerUserID, 0, func(msg domain.Message) bool { + _, ok := idSet[msg.ID] + return ok + }) + if req.Revoke && len(revokeUIDs) > 0 { + deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...) + } + return s.finishMemoryDeleteLocked(res, deleted, req.Date, false), nil +} + +type deletedMemoryMessage struct { + userID int64 + peer domain.Peer + id int +} + +func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, preserveEmptyDialogs bool) domain.DeleteMessagesResult { + if len(deleted) == 0 { + return res + } + idsByOwner := make(map[int64][]int) + peersByOwner := make(map[int64]map[domain.Peer]struct{}) + for _, row := range deleted { + idsByOwner[row.userID] = append(idsByOwner[row.userID], row.id) + if peersByOwner[row.userID] == nil { + peersByOwner[row.userID] = make(map[domain.Peer]struct{}) + } + peersByOwner[row.userID][row.peer] = struct{}{} + } + if s.dialogs != nil { + s.dialogs.mu.Lock() + for userID, peers := range peersByOwner { + for peer := range peers { + s.rebuildMemoryDialogLocked(userID, peer, preserveEmptyDialogs) + } + } + s.dialogs.mu.Unlock() + } + ownerIDs := make([]int64, 0, len(idsByOwner)) + for userID := range idsByOwner { + ownerIDs = append(ownerIDs, userID) + } + sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] }) + for _, userID := range ownerIDs { + ids := normalizeMemoryMessageIDs(idsByOwner[userID]) + if len(ids) == 0 { + continue + } + pts := s.nextPtsNLocked(userID, len(ids)) + event := domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventDeleteMessages, + Pts: pts, + PtsCount: len(ids), + Date: date, + MessageIDs: ids, + } + res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{ + UserID: userID, + MessageIDs: ids, + Event: event, + }) + } + return res +} + +func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer, preserveEmpty bool) { + list := s.dialogs.m[userID] + topID := 0 + topDate := 0 + unread := 0 + for _, msg := range s.m[userID] { + if msg.Peer != peer { + continue + } + if msg.ID > topID { + topID = msg.ID + topDate = msg.Date + } + } + dialogs := list.Dialogs[:0] + for _, dialog := range list.Dialogs { + if dialog.Peer != peer { + dialogs = append(dialogs, dialog) + continue + } + if topID == 0 { + if preserveEmpty { + oldTop := dialog.TopMessage + dialog.TopMessage = 0 + dialog.TopMessageDate = 0 + if dialog.ReadInboxMaxID < oldTop { + dialog.ReadInboxMaxID = oldTop + } + if dialog.ReadOutboxMaxID < oldTop { + dialog.ReadOutboxMaxID = oldTop + } + dialog.UnreadCount = 0 + dialog.UnreadMark = false + dialog.UnreadMentions = 0 + dialog.UnreadReactions = 0 + dialogs = append(dialogs, dialog) + } + continue + } + for _, msg := range s.m[userID] { + if msg.Peer == peer && !msg.Out && msg.ID > dialog.ReadInboxMaxID { + unread++ + } + } + dialog.TopMessage = topID + dialog.TopMessageDate = topDate + dialog.UnreadCount = unread + dialog.UnreadMentions = 0 + dialog.UnreadReactions = 0 + dialogs = append(dialogs, dialog) + } + list.Dialogs = dialogs + list.Messages = cloneMessages(s.m[userID]) + s.dialogs.m[userID] = list +} diff --git a/internal/store/memory/message_edit.go b/internal/store/memory/message_edit.go new file mode 100644 index 00000000..1504ea34 --- /dev/null +++ b/internal/store/memory/message_edit.go @@ -0,0 +1,115 @@ +package memory + +import ( + "context" + "sort" + "telesrv/internal/domain" + "time" +) + +func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, error) { + res := domain.EditMessageResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + if req.EditDate == 0 { + req.EditDate = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + targetIndex := -1 + var target domain.Message + for i, msg := range s.m[req.OwnerUserID] { + if msg.ID == req.ID && msg.Peer == req.Peer { + targetIndex = i + target = msg + break + } + } + if targetIndex < 0 { + return res, domain.ErrMessageIDInvalid + } + authorEdit := target.Out && target.From.ID == req.OwnerUserID + viaBotEdit := req.ViaBotEditBotID != 0 && target.ViaBotID == req.ViaBotEditBotID + if !authorEdit && !viaBotEdit && !req.WebPageResolve && !validMemoryTodoParticipantEdit(req, target) { + return res, domain.ErrMessageAuthorRequired + } + // WebPageResolve:服务端内部链接预览就地替换。幂等——仅当目标当前 media 仍是匹配 id 的 + // pending 占位才替换;只换 media、不碰 body/entities/edit_date,事件为 web_page。 + if req.WebPageResolve { + if req.Media == nil || !domain.IsPendingWebPageMedia(target.Media, req.ExpectedWebPageID) { + return res, domain.ErrMessageNotModified + } + } + if req.Message == "" && req.Media == nil && target.Media.IsZero() { + return res, domain.ErrMessageEmpty + } + if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && equalMessageEntities(target.Entities, req.Entities) { + return res, domain.ErrMessageNotModified + } + messageSenderID := target.From.ID + for userID, messages := range s.m { + for i, msg := range messages { + if msg.UID == target.UID && msg.From.ID == messageSenderID { + if req.WebPageResolve { + media := *req.Media + msg.Media = &media + msg.Pts = s.nextPtsLocked(userID) + s.m[userID][i] = msg + res.Edited = append(res.Edited, domain.EditedMessageForUser{ + UserID: userID, + Message: cloneMessage(msg), + Event: webPageEvent(msg), + }) + continue + } + msg.Body = req.Message + msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) + if req.Media != nil { + media := *req.Media + msg.Media = &media + } + if req.SetReplyMarkup { + // 替换 markup(nil/空 = 清空键盘);双盒一致。 + msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup) + } + msg.EditDate = req.EditDate + msg.Pts = s.nextPtsLocked(userID) + s.m[userID][i] = msg + event := editMessageEvent(msg) + res.Edited = append(res.Edited, domain.EditedMessageForUser{ + UserID: userID, + Message: cloneMessage(msg), + Event: event, + }) + } + } + } + if s.dialogs != nil { + s.dialogs.mu.Lock() + for userID := range s.dialogs.m { + list := s.dialogs.m[userID] + list.Messages = cloneMessages(s.m[userID]) + s.dialogs.m[userID] = list + } + s.dialogs.mu.Unlock() + } + sort.Slice(res.Edited, func(i, j int) bool { return res.Edited[i].UserID < res.Edited[j].UserID }) + return res, nil +} + +func validMemoryTodoParticipantEdit(req domain.EditMessageRequest, target domain.Message) bool { + if !req.AllowTodoParticipantMutation || req.SetReplyMarkup || req.Media == nil || req.Media.Kind != domain.MessageMediaKindTodo || req.Media.Todo == nil { + return false + } + if target.From.ID == req.OwnerUserID { + return false + } + if target.Body != req.Message || !equalMessageEntities(target.Entities, req.Entities) { + return false + } + if target.Media == nil || target.Media.Kind != domain.MessageMediaKindTodo || target.Media.Todo == nil { + return false + } + return true +} diff --git a/internal/store/memory/message_forward.go b/internal/store/memory/message_forward.go new file mode 100644 index 00000000..52baf693 --- /dev/null +++ b/internal/store/memory/message_forward.go @@ -0,0 +1,95 @@ +package memory + +import ( + "context" + "telesrv/internal/domain" + "time" +) + +func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { + res := domain.ForwardPrivateMessagesResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.ToUserID == 0 || req.FromPeer.Type != domain.PeerTypeUser || req.FromPeer.ID == 0 { + return res, domain.ErrMessageIDInvalid + } + if len(req.MessageIDs) == 0 || len(req.MessageIDs) != len(req.RandomIDs) { + return res, domain.ErrMessageIDInvalid + } + if len(req.MessageIDs) > domain.MaxForwardMessageIDs { + return res, domain.ErrMessageIDInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.RLock() + sources := make([]domain.Message, 0, len(req.MessageIDs)) + for _, id := range req.MessageIDs { + if id <= 0 || id > domain.MaxMessageBoxID { + s.mu.RUnlock() + return res, domain.ErrMessageIDInvalid + } + var source domain.Message + for _, msg := range s.m[req.OwnerUserID] { + if msg.Peer == req.FromPeer && msg.ID == id { + source = cloneMessage(msg) + break + } + } + if source.ID == 0 { + s.mu.RUnlock() + return res, domain.ErrMessageIDInvalid + } + if source.NoForwards { + s.mu.RUnlock() + return res, domain.ErrChatForwardsRestricted + } + sources = append(sources, source) + } + s.mu.RUnlock() + + res.SenderMessages = make([]domain.Message, 0, len(sources)) + res.RecipientMessages = make([]domain.Message, 0, len(sources)) + res.SenderEvents = make([]domain.UpdateEvent, 0, len(sources)) + res.RecipientEvents = make([]domain.UpdateEvent, 0, len(sources)) + res.Duplicates = make([]bool, 0, len(sources)) + for i, source := range sources { + if req.RandomIDs[i] == 0 { + return res, domain.ErrMessageIDInvalid + } + var forward *domain.MessageForward + if !req.DropAuthor { + forward = cloneMessageForward(source.Forward) + if forward == nil { + forward = &domain.MessageForward{From: source.From, Date: source.Date} + } + if req.ToUserID == req.OwnerUserID { + forward.SavedFrom = req.FromPeer + forward.SavedFromMsgID = req.MessageIDs[i] + } + } + sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: req.OwnerUserID, + RecipientUserID: req.ToUserID, + RandomID: req.RandomIDs[i], + Message: source.Body, + Entities: append([]domain.MessageEntity(nil), source.Entities...), + Media: source.Media, + Silent: req.Silent, + NoForwards: req.NoForwards, + ReplyTo: req.ReplyTo, + Forward: forward, + Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, + OriginSessionID: req.OriginSessionID, + RecipientBlocked: req.RecipientBlocked, + }) + if err != nil { + return res, err + } + res.SenderMessages = append(res.SenderMessages, sent.SenderMessage) + res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage) + res.SenderEvents = append(res.SenderEvents, sent.SenderEvent) + res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent) + res.Duplicates = append(res.Duplicates, sent.Duplicate) + } + return res, nil +} diff --git a/internal/store/memory/message_helpers.go b/internal/store/memory/message_helpers.go new file mode 100644 index 00000000..ce2b2ca2 --- /dev/null +++ b/internal/store/memory/message_helpers.go @@ -0,0 +1,224 @@ +package memory + +import ( + "encoding/binary" + "hash/fnv" + "sort" + "telesrv/internal/domain" +) + +func (s *MessageStore) deleteMemoryMessagesLocked(userID int64, limit int, match func(domain.Message) bool) ([]deletedMemoryMessage, map[int64]struct{}, bool) { + messages := s.m[userID] + kept := messages[:0] + deleted := make([]deletedMemoryMessage, 0) + revokeUIDs := make(map[int64]struct{}) + more := false + for _, msg := range messages { + if match(msg) { + if limit > 0 && len(deleted) >= limit { + kept = append(kept, msg) + more = true + continue + } + deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID}) + if msg.UID != 0 { + revokeUIDs[msg.UID] = struct{}{} + } + continue + } + kept = append(kept, msg) + } + s.m[userID] = kept + return deleted, revokeUIDs, more +} + +func (s *MessageStore) deleteMemoryMessagesByUIDLocked(uids map[int64]struct{}, excludeUserID int64) []deletedMemoryMessage { + if len(uids) == 0 { + return nil + } + deleted := make([]deletedMemoryMessage, 0) + for userID, messages := range s.m { + if userID == excludeUserID { + continue + } + kept := messages[:0] + for _, msg := range messages { + if _, ok := uids[msg.UID]; ok { + deleted = append(deleted, deletedMemoryMessage{userID: userID, peer: msg.Peer, id: msg.ID}) + continue + } + kept = append(kept, msg) + } + s.m[userID] = kept + } + return deleted +} + +func normalizeMemoryMessageIDs(ids []int) []int { + if len(ids) == 0 { + return nil + } + out := make([]int, 0, len(ids)) + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Ints(out) + return out +} + +func cloneMessage(msg domain.Message) domain.Message { + msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) + msg.ReplyTo = cloneMessageReply(msg.ReplyTo) + msg.Forward = cloneMessageForward(msg.Forward) + msg.Reactions = cloneChannelMessageReactionsPtr(msg.Reactions) + msg.ReplyMarkup = cloneReplyMarkup(msg.ReplyMarkup) + msg.RichMessage = cloneRichMessage(msg.RichMessage) + return msg +} + +// cloneReplyMarkup 深拷 inline keyboard 快照:与 postgres 每盒独立 decode 对齐 +// (双 store 行为一致),避免发送方/接收方两行共享底层 rows/Data 切片。 +func cloneReplyMarkup(m *domain.MessageReplyMarkup) *domain.MessageReplyMarkup { + if m == nil { + return nil + } + clone := domain.MessageReplyMarkup{} + if m.Inline != nil { + clone.Inline = make([][]domain.MarkupButton, len(m.Inline)) + for i, row := range m.Inline { + cloneRow := make([]domain.MarkupButton, len(row)) + for j, btn := range row { + cloneRow[j] = btn + cloneRow[j].Data = append([]byte(nil), btn.Data...) + } + clone.Inline[i] = cloneRow + } + } + return &clone +} + +// cloneRichMessage 深拷 Layer 227 富文本快照:复制不透明 blocks 字节与内嵌媒体切片, +// 避免发送方/接收方两行共享底层切片(与 postgres 每盒独立 decode 对齐)。 +func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage { + if m == nil { + return nil + } + clone := *m + clone.Blocks = append([]byte(nil), m.Blocks...) + clone.Photos = append([]domain.Photo(nil), m.Photos...) + clone.Documents = append([]domain.Document(nil), m.Documents...) + return &clone +} + +func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { + if reply == nil { + return nil + } + clone := *reply + clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...) + return &clone +} + +func cloneMessageForward(forward *domain.MessageForward) *domain.MessageForward { + if forward == nil { + return nil + } + clone := *forward + return &clone +} + +func newMessageEvent(msg domain.Message) domain.UpdateEvent { + if msg.ID == 0 { + return domain.UpdateEvent{} + } + return domain.UpdateEvent{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: cloneMessage(msg), + } +} + +func editMessageEvent(msg domain.Message) domain.UpdateEvent { + if msg.ID == 0 { + return domain.UpdateEvent{} + } + return domain.UpdateEvent{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventEditMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.EditDate, + Message: cloneMessage(msg), + } +} + +// webPageEvent 是链接预览就地替换事件(Date 取消息发送时间,不引入 edit_date)。 +func webPageEvent(msg domain.Message) domain.UpdateEvent { + if msg.ID == 0 { + return domain.UpdateEvent{} + } + return domain.UpdateEvent{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventWebPage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: cloneMessage(msg), + } +} + +func equalMessageEntities(a, b []domain.MessageEntity) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func hasUser(users []domain.User, id int64) bool { + for _, u := range users { + if u.ID == id { + return true + } + } + return false +} + +func messageListHash(messages []domain.Message) int64 { + if len(messages) == 0 { + return 0 + } + h := fnv.New64a() + var buf [16]byte + for _, msg := range messages { + binary.LittleEndian.PutUint32(buf[:4], uint32(msg.ID)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(msg.Date)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.From.ID)) + _, _ = h.Write(buf[:]) + writeMessageReactionsHash(h, msg.Reactions) + } + return int64(h.Sum64()) +} + +func cloneMessages(messages []domain.Message) []domain.Message { + out := append([]domain.Message(nil), messages...) + for i := range out { + out[i] = cloneMessage(out[i]) + } + return out +} diff --git a/internal/store/memory/message_history.go b/internal/store/memory/message_history.go new file mode 100644 index 00000000..14692cc4 --- /dev/null +++ b/internal/store/memory/message_history.go @@ -0,0 +1,423 @@ +package memory + +import ( + "context" + "sort" + "strings" + "telesrv/internal/domain" + "time" +) + +func (s *MessageStore) GetByIDs(_ context.Context, userID int64, ids []int) (domain.MessageList, error) { + if userID == 0 || len(ids) == 0 { + return domain.MessageList{}, nil + } + s.mu.RLock() + byID := make(map[int]domain.Message, len(s.m[userID])) + for _, msg := range s.m[userID] { + item := cloneMessage(msg) + reactions := s.privateMessageReactionsForMessageLocked(item) + if len(reactions.Results) > 0 || len(reactions.Recent) > 0 { + item.Reactions = cloneChannelMessageReactionsPtr(&reactions) + } + byID[msg.ID] = item + } + s.mu.RUnlock() + out := domain.MessageList{Messages: make([]domain.Message, 0, len(ids))} + for _, id := range ids { + if msg, ok := byID[id]; ok { + out.Messages = append(out.Messages, msg) + } + } + s.enrichPrivateMessagePolls(out.Messages, int(time.Now().Unix())) + out.Users = usersForMessages(out.Messages) + out.Hash = messageListHash(out.Messages) + return out, nil +} + +func (s *MessageStore) ListByUser(_ context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { + s.mu.RLock() + messages := cloneMessages(s.m[userID]) + for i := range messages { + reactions := s.privateMessageReactionsForMessageLocked(messages[i]) + if len(reactions.Results) > 0 || len(reactions.Recent) > 0 { + messages[i].Reactions = cloneChannelMessageReactionsPtr(&reactions) + } + } + s.mu.RUnlock() + s.enrichPrivateMessagePolls(messages, int(time.Now().Unix())) + return filterMessageList(messages, filter), nil +} + +func (s *MessageStore) ReadHistory(_ context.Context, req domain.ReadHistoryRequest) (domain.ReadHistoryResult, error) { + res := domain.ReadHistoryResult{OwnerUserID: req.OwnerUserID, Peer: req.Peer, MaxID: req.MaxID} + if req.OwnerUserID == 0 || req.Peer.ID == 0 { + return res, nil + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.dialogs == nil { + return res, nil + } + s.dialogs.mu.Lock() + defer s.dialogs.mu.Unlock() + list := s.dialogs.m[req.OwnerUserID] + for i, dialog := range list.Dialogs { + if dialog.Peer != req.Peer { + continue + } + readMax := req.MaxID + if readMax <= 0 { + readMax = dialog.TopMessage + } + if readMax > dialog.TopMessage { + readMax = dialog.TopMessage + } + oldRead := dialog.ReadInboxMaxID + res.MaxID = readMax + advancesRead := readMax > oldRead + if !advancesRead { + if dialog.UnreadCount > 0 { + unread := 0 + for _, msg := range s.m[req.OwnerUserID] { + if msg.Peer == req.Peer && !msg.Out && msg.ID > oldRead { + unread++ + } + } + dialog.UnreadCount = unread + dialog.UnreadMentions = 0 + // readHistory 不清 reaction 角标(与 PG 一致;reaction 未读由 + // readReactions/readMessageContents 单独清,否则角标数与 getUnreadReactions + // 跳转列表对不上)。 + dialog.UnreadMark = false + res.MaxID = dialog.ReadInboxMaxID + res.StillUnreadCount = unread + list.Dialogs[i] = dialog + s.dialogs.m[req.OwnerUserID] = list + } + return res, nil + } + res.Changed = true + var latestIncoming domain.Message + unread := 0 + for _, msg := range s.m[req.OwnerUserID] { + if msg.Peer != req.Peer || msg.Out { + continue + } + if msg.ID > readMax { + unread++ + continue + } + if msg.ID > oldRead && msg.ID > latestIncoming.ID { + latestIncoming = msg + } + } + if readMax > dialog.ReadInboxMaxID { + dialog.ReadInboxMaxID = readMax + } + dialog.UnreadCount = unread + dialog.UnreadMentions = 0 + // readHistory 不清 reaction 角标(与 PG 一致,见上)。 + dialog.UnreadMark = false + res.StillUnreadCount = unread + pts := s.nextPtsLocked(req.OwnerUserID) + res.InboxEvent = domain.UpdateEvent{ + UserID: req.OwnerUserID, + Type: domain.UpdateEventReadHistoryInbox, + Pts: pts, + PtsCount: 1, + Date: req.Date, + Peer: req.Peer, + MaxID: readMax, + StillUnreadCount: unread, + } + list.Dialogs[i] = dialog + s.dialogs.m[req.OwnerUserID] = list + + if latestIncoming.ID != 0 && latestIncoming.From.ID != 0 && latestIncoming.From.ID != req.OwnerUserID { + senderUserID := latestIncoming.From.ID + senderBoxID := 0 + for _, msg := range s.m[senderUserID] { + if msg.UID == latestIncoming.UID && msg.Out { + senderBoxID = msg.ID + break + } + } + if senderBoxID > 0 { + senderList := s.dialogs.m[senderUserID] + for j, senderDialog := range senderList.Dialogs { + if senderDialog.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) { + continue + } + if senderBoxID <= senderDialog.ReadOutboxMaxID { + break + } + oldOutbox := senderDialog.ReadOutboxMaxID + senderDialog.ReadOutboxMaxID = senderBoxID + senderList.Dialogs[j] = senderDialog + s.dialogs.m[senderUserID] = senderList + for _, msg := range s.m[senderUserID] { + if msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) && msg.Out && msg.ID > oldOutbox && msg.ID <= senderBoxID { + s.readOutboxDates[readOutboxDateKey{ownerUserID: senderUserID, peerID: req.OwnerUserID, msgID: msg.ID}] = req.Date + } + } + outPts := s.nextPtsLocked(senderUserID) + res.OutboxChanged = true + res.OutboxUserID = senderUserID + res.OutboxEvent = domain.UpdateEvent{ + UserID: senderUserID, + Type: domain.UpdateEventReadHistoryOutbox, + Pts: outPts, + PtsCount: 1, + Date: req.Date, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + MaxID: senderBoxID, + } + break + } + } + } + return res, nil + } + return res, nil +} + +func (s *MessageStore) DeleteHistory(_ context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { + res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.Peer.ID == 0 { + return res, nil + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + inDateRange := func(msg domain.Message) bool { + if req.MinDate > 0 && msg.Date < req.MinDate { + return false + } + if req.MaxDate > 0 && msg.Date > req.MaxDate { + return false + } + return true + } + deleted, revokeUIDs, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool { + return msg.Peer == req.Peer && (req.MaxID <= 0 || msg.ID <= req.MaxID) && inDateRange(msg) + }) + if req.Revoke { + if len(revokeUIDs) > 0 { + deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...) + } + // 与 PG 同语义:全量/按日期的双向清史直扫对端残余,我方早已 + // 单向删除的消息不能在对端残留。 + if req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID { + peerDeleted, _, peerMore := s.deleteMemoryMessagesLocked(req.Peer.ID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool { + return msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) && inDateRange(msg) + }) + deleted = append(deleted, peerDeleted...) + more = more || peerMore + } + } + res = s.finishMemoryDeleteLocked(res, deleted, req.Date, req.JustClear) + if more { + res.Offset = 1 + } + return res, nil +} + +func filterMessageList(messages []domain.Message, filter domain.MessageFilter) domain.MessageList { + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + sort.SliceStable(messages, func(i, j int) bool { + return messageLess(messages[i], messages[j]) + }) + + query := strings.ToLower(filter.Query) + base := make([]domain.Message, 0, len(messages)) + for _, msg := range messages { + if filter.HasPeer && msg.Peer != filter.Peer { + continue + } + if query != "" && !strings.Contains(strings.ToLower(msg.Body), query) { + continue + } + if filter.MaxID > 0 && msg.ID >= filter.MaxID { + continue + } + if filter.MinID > 0 && msg.ID <= filter.MinID { + continue + } + if filter.PinnedOnly && !msg.Pinned { + continue + } + if filter.MusicOnly && !msg.Media.IsMusic() { + continue + } + if filter.SavedPeer.ID != 0 && msg.SavedPeer != filter.SavedPeer { + continue + } + base = append(base, msg) + } + + limit := filter.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + page := pageMessageHistory(base, filter, limit) + return domain.MessageList{ + Messages: page, + Users: usersForMessages(page), + Count: len(base), + Hash: messageListHash(base), + } +} + +func pageMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message { + if limit <= 0 || len(base) == 0 { + return nil + } + switch messageHistoryLoadType(filter.AddOffset, limit) { + case messageHistoryLoadForward: + return cloneMessages(forwardMessageHistory(base, filter, limit)) + case messageHistoryLoadAround: + forwardLimit := -filter.AddOffset + if forwardLimit > limit { + forwardLimit = limit + } + backwardLimit := limit + filter.AddOffset + if backwardLimit < 0 { + backwardLimit = 0 + } + page := make([]domain.Message, 0, limit) + page = append(page, forwardMessageHistory(base, filter, forwardLimit)...) + page = append(page, backwardMessageHistory(base, filter, backwardLimit, true)...) + sort.SliceStable(page, func(i, j int) bool { + return messageLess(page[i], page[j]) + }) + return cloneMessages(page) + default: + start := filter.AddOffset + if start < 0 { + start = 0 + } + candidates := backwardMessageHistory(base, filter, limit+start, false) + if start >= len(candidates) { + return nil + } + return cloneMessages(candidates[start:]) + } +} + +type messageHistoryLoad int + +const ( + messageHistoryLoadBackward messageHistoryLoad = iota + messageHistoryLoadForward + messageHistoryLoadAround +) + +func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad { + if addOffset >= 0 { + return messageHistoryLoadBackward + } + if addOffset+limit > 0 { + return messageHistoryLoadAround + } + return messageHistoryLoadForward +} + +func backwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int, includeOffset bool) []domain.Message { + if limit <= 0 { + return nil + } + out := make([]domain.Message, 0, limit) + for _, msg := range base { + if !messageBeforeHistoryOffset(msg, filter, includeOffset) { + continue + } + out = append(out, msg) + if len(out) == limit { + break + } + } + return out +} + +func forwardMessageHistory(base []domain.Message, filter domain.MessageFilter, limit int) []domain.Message { + if limit <= 0 { + return nil + } + out := make([]domain.Message, 0, limit) + for i := len(base) - 1; i >= 0; i-- { + msg := base[i] + if !messageAfterHistoryOffset(msg, filter) { + continue + } + out = append(out, msg) + if len(out) == limit { + break + } + } + sort.SliceStable(out, func(i, j int) bool { + return messageLess(out[i], out[j]) + }) + return out +} + +func messageBeforeHistoryOffset(msg domain.Message, filter domain.MessageFilter, includeOffset bool) bool { + if filter.OffsetDate > 0 { + if includeOffset { + return msg.Date <= filter.OffsetDate + } + return msg.Date < filter.OffsetDate + } + if filter.OffsetID <= 0 { + return true + } + if includeOffset { + return msg.ID <= filter.OffsetID + } + return msg.ID < filter.OffsetID +} + +func messageAfterHistoryOffset(msg domain.Message, filter domain.MessageFilter) bool { + if filter.OffsetDate > 0 { + return msg.Date >= filter.OffsetDate + } + if filter.OffsetID <= 0 { + return false + } + return msg.ID > filter.OffsetID +} + +func messageLess(a, b domain.Message) bool { + if a.Date != b.Date { + return a.Date > b.Date + } + return a.ID > b.ID +} + +func usersForMessages(messages []domain.Message) []domain.User { + seen := map[int64]struct{}{} + users := make([]domain.User, 0, 1) + for _, msg := range messages { + for _, peer := range []domain.Peer{msg.Peer, msg.From} { + if peer.Type != domain.PeerTypeUser { + continue + } + if _, ok := seen[peer.ID]; ok { + continue + } + seen[peer.ID] = struct{}{} + if u, ok := domain.SystemUserByID(peer.ID); ok { + users = append(users, u) + } + } + } + return users +} diff --git a/internal/store/memory/message_pin.go b/internal/store/memory/message_pin.go new file mode 100644 index 00000000..f4e0e382 --- /dev/null +++ b/internal/store/memory/message_pin.go @@ -0,0 +1,157 @@ +package memory + +import ( + "context" + "sort" + "time" + + "telesrv/internal/domain" +) + +// PinPrivateMessage 与 PG 实现同语义:非 pm_oneside 的 pin 双侧置位, +// unpin 恒双侧清除,状态未变化时幂等 no-op;服务消息不可置顶。 +func (s *MessageStore) PinPrivateMessage(_ context.Context, req domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) { + res := domain.PinPrivateMessageResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { + return res, domain.ErrMessageIDInvalid + } + if req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + ownIdx := -1 + for i, msg := range s.m[req.OwnerUserID] { + if msg.Peer == req.Peer && msg.ID == req.MessageID { + ownIdx = i + break + } + } + if ownIdx < 0 { + return res, domain.ErrMessageIDInvalid + } + owned := s.m[req.OwnerUserID][ownIdx] + if owned.Media != nil && owned.Media.Kind == domain.MessageMediaKindService { + return res, domain.ErrMessageIDInvalid + } + if owned.Pinned == req.Pinned && (!req.Pinned || req.PmOneside) { + // 与 PG 同语义:unpin/oneside pin 幂等短路;共享 pin 仍需检查 + // 对端传播。 + return res, nil + } + type pinSide struct { + userID int64 + peer domain.Peer + idx int + } + sides := []pinSide{{userID: req.OwnerUserID, peer: req.Peer, idx: ownIdx}} + if req.Peer.ID != req.OwnerUserID && (!req.Pinned || !req.PmOneside) { + for i, msg := range s.m[req.Peer.ID] { + if msg.UID == owned.UID { + sides = append(sides, pinSide{ + userID: req.Peer.ID, + peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + idx: i, + }) + break + } + } + } + for _, side := range sides { + if s.m[side.userID][side.idx].Pinned == req.Pinned { + continue + } + s.m[side.userID][side.idx].Pinned = req.Pinned + boxID := s.m[side.userID][side.idx].ID + res.Updated = append(res.Updated, domain.PinnedMessagesForUser{ + UserID: side.userID, + Peer: side.peer, + MessageIDs: []int{boxID}, + Pinned: req.Pinned, + Event: domain.UpdateEvent{ + UserID: side.userID, + Type: domain.UpdateEventPinnedMessages, + Pts: s.nextPtsLocked(side.userID), + PtsCount: 1, + Date: req.Date, + Peer: side.peer, + Bool: req.Pinned, + MessageIDs: []int{boxID}, + }, + }) + } + return res, nil +} + +// UnpinAllPrivateMessages 与 PG 实现同语义:本侧整批清除并经 UID 同步 +// 清除对端共享置顶。 +func (s *MessageStore) UnpinAllPrivateMessages(_ context.Context, req domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) { + res := domain.PinPrivateMessageResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { + return res, domain.ErrMessageIDInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + // 与 PG 同语义:按 box_id 降序取一批清除,剩余批次以 Offset=1 续清。 + pinnedIdx := make([]int, 0) + for i, msg := range s.m[req.OwnerUserID] { + if msg.Peer == req.Peer && msg.Pinned { + pinnedIdx = append(pinnedIdx, i) + } + } + sort.Slice(pinnedIdx, func(a, b int) bool { + return s.m[req.OwnerUserID][pinnedIdx[a]].ID > s.m[req.OwnerUserID][pinnedIdx[b]].ID + }) + if len(pinnedIdx) > domain.MaxUnpinAllBatch { + pinnedIdx = pinnedIdx[:domain.MaxUnpinAllBatch] + res.Offset = 1 + } + ownIDs := make([]int, 0, len(pinnedIdx)) + uids := make(map[int64]struct{}) + for _, i := range pinnedIdx { + s.m[req.OwnerUserID][i].Pinned = false + ownIDs = append(ownIDs, s.m[req.OwnerUserID][i].ID) + uids[s.m[req.OwnerUserID][i].UID] = struct{}{} + } + if len(ownIDs) == 0 { + return res, nil + } + appendSide := func(userID int64, peer domain.Peer, ids []int) { + res.Updated = append(res.Updated, domain.PinnedMessagesForUser{ + UserID: userID, + Peer: peer, + MessageIDs: ids, + Pinned: false, + Event: domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventPinnedMessages, + Pts: s.nextPtsLocked(userID), + PtsCount: 1, + Date: req.Date, + Peer: peer, + Bool: false, + MessageIDs: ids, + }, + }) + } + appendSide(req.OwnerUserID, req.Peer, ownIDs) + if req.Peer.ID != req.OwnerUserID { + peerIDs := make([]int, 0) + for i, msg := range s.m[req.Peer.ID] { + if _, ok := uids[msg.UID]; ok && msg.Pinned { + s.m[req.Peer.ID][i].Pinned = false + peerIDs = append(peerIDs, msg.ID) + } + } + if len(peerIDs) > 0 { + appendSide(req.Peer.ID, domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, peerIDs) + } + } + return res, nil +} diff --git a/internal/store/memory/message_polls.go b/internal/store/memory/message_polls.go new file mode 100644 index 00000000..273c070d --- /dev/null +++ b/internal/store/memory/message_polls.go @@ -0,0 +1,89 @@ +package memory + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// 私聊消息 poll 投票/关闭:消息可见性在本 store 校验,poll 级校验与状态全部 +// 委托共享 PollStore(与 postgres 实现共用 domain 纯函数语义)。 + +func (s *MessageStore) VoteMessagePoll(_ context.Context, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) { + target, err := s.pollMessageTarget(req.UserID, req.Peer, req.MessageID) + if err != nil { + return domain.PrivateMessagePollResult{}, err + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + if err := s.polls.Vote(target.Media.Poll.ID, req.UserID, req.Options, req.Date); err != nil { + return domain.PrivateMessagePollResult{}, err + } + return s.privatePollResult(target.UID, target.Media.Poll.ID, req.Date), nil +} + +func (s *MessageStore) CloseMessagePoll(_ context.Context, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) { + target, err := s.pollMessageTarget(req.UserID, req.Peer, req.MessageID) + if err != nil { + return domain.PrivateMessagePollResult{}, err + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + if err := s.polls.Close(target.Media.Poll.ID, req.UserID); err != nil { + return domain.PrivateMessagePollResult{}, err + } + return s.privatePollResult(target.UID, target.Media.Poll.ID, req.Date), nil +} + +// pollMessageTarget 定位 viewer box 中带 poll 的目标消息。 +func (s *MessageStore) pollMessageTarget(userID int64, peer domain.Peer, messageID int) (domain.Message, error) { + if s == nil || s.polls == nil { + return domain.Message{}, domain.ErrMessageIDInvalid + } + if userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || messageID <= 0 || messageID > domain.MaxMessageBoxID { + return domain.Message{}, domain.ErrMessageIDInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + for _, msg := range s.m[userID] { + if msg.ID != messageID || msg.Peer != peer { + continue + } + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPoll || msg.Media.Poll == nil || msg.Media.Poll.ID == 0 { + return domain.Message{}, domain.ErrMessageIDInvalid + } + return msg, nil + } + return domain.Message{}, domain.ErrMessageIDInvalid +} + +// privatePollResult 收集同一 UID 的全部 owner 副本,并按各自 owner 视角 enrich poll。 +func (s *MessageStore) privatePollResult(uid, pollID int64, now int) domain.PrivateMessagePollResult { + out := domain.PrivateMessagePollResult{PollID: pollID} + s.mu.RLock() + defer s.mu.RUnlock() + for _, messages := range s.m { + for _, msg := range messages { + if msg.UID != uid { + continue + } + item := cloneMessage(msg) + item.Media = enrichPollMediaForViewer(s.polls, item.Media, item.OwnerUserID, now) + out.Messages = append(out.Messages, item) + } + } + return out +} + +// enrichPrivateMessagePolls 是私聊读路径的 poll enrichment 入口(与 reactions 填充并列)。 +func (s *MessageStore) enrichPrivateMessagePolls(messages []domain.Message, now int) { + if s == nil || s.polls == nil { + return + } + for i := range messages { + messages[i].Media = enrichPollMediaForViewer(s.polls, messages[i].Media, messages[i].OwnerUserID, now) + } +} diff --git a/internal/store/memory/message_reactions.go b/internal/store/memory/message_reactions.go new file mode 100644 index 00000000..98528780 --- /dev/null +++ b/internal/store/memory/message_reactions.go @@ -0,0 +1,255 @@ +package memory + +import ( + "context" + "encoding/binary" + "hash" + "sort" + "telesrv/internal/domain" + "time" +) + +func (s *MessageStore) SetMessageReactions(_ context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if req.UserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + req.Reactions = domain.TrimMessageReactionsToUserMax(req.Reactions, req.ReactionsPerUserMax) + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + var target domain.Message + for _, msg := range s.m[req.UserID] { + if msg.ID == req.MessageID && msg.Peer == req.Peer { + target = msg + break + } + } + if target.ID == 0 || target.UID == 0 { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if _, ok := s.privateReactions[target.UID]; !ok { + s.privateReactions[target.UID] = make(map[int64][]domain.ChannelMessagePeerReaction) + } + rows := make([]domain.ChannelMessagePeerReaction, 0, len(req.Reactions)) + for i, reaction := range req.Reactions { + if !reaction.Valid() { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + rows = append(rows, domain.ChannelMessagePeerReaction{ + UserID: req.UserID, + Reaction: reaction, + Big: req.Big, + My: true, + ChosenOrder: i + 1, + Date: req.Date, + }) + } + if len(rows) == 0 { + delete(s.privateReactions[target.UID], req.UserID) + } else { + s.privateReactions[target.UID][req.UserID] = rows + } + if target.From.ID != 0 && target.From.ID != req.UserID { + for i := range s.m[target.From.ID] { + if s.m[target.From.ID][i].UID != target.UID { + continue + } + s.m[target.From.ID][i].ReactionUnread = len(rows) > 0 + break + } + } + s.refreshPrivateReactionDialogSnapshotsLocked(target.UID) + return s.privateReactionResultLocked(target.UID), nil +} + +func (s *MessageStore) GetMessageReactions(_ context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + ids := make(map[int]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + ids[id] = struct{}{} + } + s.mu.RLock() + defer s.mu.RUnlock() + out := domain.PrivateMessageReactionsResult{} + for _, msg := range s.m[req.OwnerUserID] { + if msg.Peer != req.Peer { + continue + } + if _, ok := ids[msg.ID]; !ok { + continue + } + item := cloneMessage(msg) + reactions := s.privateMessageReactionsForMessageLocked(item) + item.Reactions = cloneChannelMessageReactionsPtr(&reactions) + out.Messages = append(out.Messages, item) + if len(out.Reactions.Results) == 0 && len(out.Reactions.Recent) == 0 { + out.Reactions = reactions + } + } + return out, nil +} + +func (s *MessageStore) privateReactionResultLocked(uid int64) domain.PrivateMessageReactionsResult { + out := domain.PrivateMessageReactionsResult{} + for _, messages := range s.m { + for _, msg := range messages { + if msg.UID != uid { + continue + } + item := cloneMessage(msg) + reactions := s.privateMessageReactionsForMessageLocked(item) + item.Reactions = cloneChannelMessageReactionsPtr(&reactions) + out.Messages = append(out.Messages, item) + if len(out.Reactions.Results) == 0 && len(out.Reactions.Recent) == 0 { + out.Reactions = reactions + } + } + } + return out +} + +func (s *MessageStore) privateMessageReactionsForMessageLocked(msg domain.Message) domain.ChannelMessageReactions { + reactions := s.privateMessageReactionsLocked(msg.UID, msg.OwnerUserID) + if len(reactions.Recent) == 0 || msg.From.ID == 0 { + return reactions + } + for i := range reactions.Recent { + reactions.Recent[i].SenderUserID = msg.From.ID + if msg.ReactionUnread && msg.From.ID == msg.OwnerUserID && reactions.Recent[i].UserID != msg.OwnerUserID { + reactions.Recent[i].Unread = true + } + } + return reactions +} + +func (s *MessageStore) privateMessageReactionsLocked(uid, viewerUserID int64) domain.ChannelMessageReactions { + byUser := s.privateReactions[uid] + out := domain.ChannelMessageReactions{CanSeeList: true} + if len(byUser) == 0 { + return out + } + counts := make(map[string]int) + recent := make([]domain.ChannelMessagePeerReaction, 0, len(byUser)) + for userID, rows := range byUser { + for _, row := range rows { + key := row.Reaction.Key() + index, ok := counts[key] + if !ok { + out.Results = append(out.Results, domain.ChannelMessageReactionCount{Reaction: row.Reaction}) + index = len(out.Results) - 1 + counts[key] = index + } + out.Results[index].Count++ + if userID == viewerUserID && (out.Results[index].ChosenOrder == 0 || row.ChosenOrder < out.Results[index].ChosenOrder) { + out.Results[index].ChosenOrder = row.ChosenOrder + } + item := row + item.UserID = userID + item.My = userID == viewerUserID + recent = append(recent, item) + } + } + sort.Slice(out.Results, func(i, j int) bool { + if out.Results[i].Count != out.Results[j].Count { + return out.Results[i].Count > out.Results[j].Count + } + return out.Results[i].Reaction.Key() < out.Results[j].Reaction.Key() + }) + sort.Slice(recent, func(i, j int) bool { + if recent[i].Date != recent[j].Date { + return recent[i].Date > recent[j].Date + } + return recent[i].UserID < recent[j].UserID + }) + if len(recent) > domain.MaxChannelMessageReactionRecent { + recent = recent[:domain.MaxChannelMessageReactionRecent] + } + out.Recent = recent + return out +} + +func (s *MessageStore) countPrivateUnreadReactionsLocked(ownerUserID int64, peer domain.Peer) int { + count := 0 + for _, msg := range s.m[ownerUserID] { + if msg.Peer == peer && msg.ReactionUnread { + count++ + } + } + return count +} + +func (s *MessageStore) refreshPrivateReactionDialogSnapshotsLocked(uid int64) { + if s.dialogs == nil || uid == 0 { + return + } + s.dialogs.mu.Lock() + defer s.dialogs.mu.Unlock() + for ownerID, messages := range s.m { + list := s.dialogs.m[ownerID] + changed := false + for _, msg := range messages { + if msg.UID != uid { + continue + } + enriched := cloneMessage(msg) + reactions := s.privateMessageReactionsForMessageLocked(enriched) + if len(reactions.Results) > 0 || len(reactions.Recent) > 0 { + enriched.Reactions = cloneChannelMessageReactionsPtr(&reactions) + } + for i := range list.Messages { + if list.Messages[i].UID == uid && list.Messages[i].OwnerUserID == ownerID { + list.Messages[i] = enriched + changed = true + } + } + for i := range list.Dialogs { + if list.Dialogs[i].Peer == msg.Peer { + list.Dialogs[i].UnreadReactions = s.countPrivateUnreadReactionsLocked(ownerID, msg.Peer) + changed = true + break + } + } + } + if changed { + s.dialogs.m[ownerID] = list + } + } +} + +func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageReactions) { + if reactions == nil { + _, _ = h.Write([]byte{0}) + return + } + var buf [16]byte + for _, item := range reactions.Results { + _, _ = h.Write([]byte(item.Reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(item.Reaction.Value())) + _, _ = h.Write([]byte{0}) + binary.LittleEndian.PutUint32(buf[:4], uint32(item.Count)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(item.ChosenOrder)) + _, _ = h.Write(buf[:8]) + } + _, _ = h.Write([]byte{0xfe}) + for _, item := range reactions.Recent { + _, _ = h.Write([]byte(item.Reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(item.Reaction.Value())) + _, _ = h.Write([]byte{0}) + binary.LittleEndian.PutUint64(buf[:8], uint64(item.UserID)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(item.Date)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(item.ChosenOrder)) + _, _ = h.Write(buf[:]) + } +} diff --git a/internal/store/memory/message_read.go b/internal/store/memory/message_read.go new file mode 100644 index 00000000..0e54c512 --- /dev/null +++ b/internal/store/memory/message_read.go @@ -0,0 +1,173 @@ +package memory + +import ( + "context" + "fmt" + "sort" + "telesrv/internal/domain" + "time" +) + +func (s *MessageStore) ReadMessageContents(_ context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { + res := domain.ReadMessageContentsResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("read message contents: missing owner user id") + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return res, domain.ErrMessageIDInvalid + } + wanted := make(map[int]struct{}, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + wanted[id] = struct{}{} + } + if len(wanted) == 0 { + return res, nil + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + reactionUIDs := make(map[int64]struct{}) + senderUIDs := make(map[int64]map[int64]struct{}) + for i := range s.m[req.OwnerUserID] { + msg := &s.m[req.OwnerUserID][i] + if _, ok := wanted[msg.ID]; !ok { + continue + } + if !msg.MediaUnread && !msg.ReactionUnread { + continue + } + if msg.ReactionUnread && msg.Peer.ID != 0 { + reactionUIDs[msg.UID] = struct{}{} + } + if msg.MediaUnread && !msg.Out && msg.From.Type == domain.PeerTypeUser && msg.From.ID != 0 && msg.From.ID != req.OwnerUserID { + if senderUIDs[msg.From.ID] == nil { + senderUIDs[msg.From.ID] = make(map[int64]struct{}) + } + senderUIDs[msg.From.ID][msg.UID] = struct{}{} + } + msg.MediaUnread = false + msg.ReactionUnread = false + res.MessageIDs = append(res.MessageIDs, msg.ID) + } + sort.Ints(res.MessageIDs) + if len(res.MessageIDs) == 0 { + return res, nil + } + senderIDs := make([]int64, 0, len(senderUIDs)) + for senderID := range senderUIDs { + senderIDs = append(senderIDs, senderID) + } + sort.Slice(senderIDs, func(i, j int) bool { return senderIDs[i] < senderIDs[j] }) + for _, senderID := range senderIDs { + boxIDs := make([]int, 0, len(senderUIDs[senderID])) + for i := range s.m[senderID] { + msg := &s.m[senderID][i] + if _, ok := senderUIDs[senderID][msg.UID]; !ok || !msg.Out || !msg.MediaUnread { + continue + } + msg.MediaUnread = false + boxIDs = append(boxIDs, msg.ID) + } + if len(boxIDs) == 0 { + continue + } + sort.Ints(boxIDs) + res.SenderEvents = append(res.SenderEvents, domain.UpdateEvent{ + UserID: senderID, + Type: domain.UpdateEventReadMessageContents, + Pts: s.nextPtsNLocked(senderID, len(boxIDs)), + PtsCount: len(boxIDs), + Date: req.Date, + MessageIDs: boxIDs, + }) + } + for uid := range reactionUIDs { + s.refreshPrivateReactionDialogSnapshotsLocked(uid) + } + pts := s.nextPtsNLocked(req.OwnerUserID, len(res.MessageIDs)) + res.Event = domain.UpdateEvent{ + UserID: req.OwnerUserID, + Type: domain.UpdateEventReadMessageContents, + Pts: pts, + PtsCount: len(res.MessageIDs), + Date: req.Date, + MessageIDs: append([]int(nil), res.MessageIDs...), + } + return res, nil +} + +func (s *MessageStore) GetOutboxReadDate(_ context.Context, req domain.OutboxReadDateRequest) (int, error) { + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return 0, domain.ErrMessageIDInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + found := false + for _, msg := range s.m[req.OwnerUserID] { + if msg.ID == req.ID && msg.Peer == req.Peer && msg.Out { + found = true + break + } + } + if !found { + return 0, domain.ErrMessageIDInvalid + } + date := s.readOutboxDates[readOutboxDateKey{ownerUserID: req.OwnerUserID, peerID: req.Peer.ID, msgID: req.ID}] + if date == 0 { + return 0, domain.ErrMessageNotReadYet + } + return date, nil +} + +// ListUnreadReactionMessages 返回当前 owner 在该 peer 下 reaction_unread 的消息。 +func (s *MessageStore) ListUnreadReactionMessages(_ context.Context, ownerUserID int64, peer domain.Peer, limit int) ([]domain.Message, error) { + if ownerUserID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return nil, nil + } + if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit { + limit = domain.MaxChannelUnreadReactionsLimit + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.Message, 0, limit) + messages := s.m[ownerUserID] + for i := len(messages) - 1; i >= 0 && len(out) < limit; i-- { + msg := messages[i] + if msg.Peer != peer || !msg.ReactionUnread { + continue + } + out = append(out, cloneMessage(msg)) + } + return out, nil +} + +// ReadPeerReactions 清理当前 owner 在该 peer 下的全部未读 reaction 状态。 +func (s *MessageStore) ReadPeerReactions(_ context.Context, ownerUserID int64, peer domain.Peer) (int, error) { + if ownerUserID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return 0, nil + } + s.mu.Lock() + defer s.mu.Unlock() + cleared := 0 + reactionUIDs := make(map[int64]struct{}) + for i := range s.m[ownerUserID] { + msg := &s.m[ownerUserID][i] + if msg.Peer != peer || !msg.ReactionUnread { + continue + } + msg.ReactionUnread = false + reactionUIDs[msg.UID] = struct{}{} + cleared++ + } + if cleared > 0 { + for uid := range reactionUIDs { + s.refreshPrivateReactionDialogSnapshotsLocked(uid) + } + } + return cleared, nil +} diff --git a/internal/store/memory/message_send.go b/internal/store/memory/message_send.go new file mode 100644 index 00000000..1a1e3f3f --- /dev/null +++ b/internal/store/memory/message_send.go @@ -0,0 +1,227 @@ +package memory + +import ( + "context" + "telesrv/internal/domain" + "time" +) + +func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Message, error) { + s.mu.Lock() + defer s.mu.Unlock() + msg.ID = s.nextBoxIDLocked(msg.OwnerUserID) + msg.UID = s.nextUID + s.nextUID++ + msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) + s.m[msg.OwnerUserID] = append(s.m[msg.OwnerUserID], msg) + if s.dialogs != nil { + s.dialogs.mu.Lock() + list := s.dialogs.m[msg.OwnerUserID] + list.Messages = append(list.Messages, msg) + if msg.Peer.Type == domain.PeerTypeUser && !hasUser(list.Users, msg.Peer.ID) { + if u, ok := domain.SystemUserByID(msg.Peer.ID); ok { + list.Users = append(list.Users, u) + } + } + s.dialogs.m[msg.OwnerUserID] = list + s.dialogs.mu.Unlock() + } + return msg, nil +} + +func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, msg := range s.m[req.SenderUserID] { + if msg.RandomID != 0 && msg.RandomID == req.RandomID { + recipient := domain.Message{} + if req.SenderUserID != req.RecipientUserID { + for _, peerMsg := range s.m[req.RecipientUserID] { + if peerMsg.UID == msg.UID { + recipient = peerMsg + break + } + } + } else { + recipient = msg + } + return domain.SendPrivateTextResult{ + SenderMessage: cloneMessage(msg), + RecipientMessage: cloneMessage(recipient), + SenderEvent: newMessageEvent(msg), + RecipientEvent: newMessageEvent(recipient), + Duplicate: true, + }, nil + } + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + senderReply, recipientReply, err := s.resolveMemoryReplyLocked(req) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + uid := s.nextUID + s.nextUID++ + sender := domain.Message{ + ID: s.nextBoxIDLocked(req.SenderUserID), + UID: uid, + RandomID: req.RandomID, + OwnerUserID: req.SenderUserID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, + Date: req.Date, + Out: true, + Silent: req.Silent, + NoForwards: req.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ViaBotID: req.ViaBotID, + GroupedID: req.GroupedID, + Effect: req.Effect, + ReplyMarkup: cloneReplyMarkup(req.ReplyMarkup), + RichMessage: cloneRichMessage(req.RichMessage), + ReplyTo: cloneMessageReply(senderReply), + Forward: cloneMessageForward(req.Forward), + Pts: s.nextPtsLocked(req.SenderUserID), + // voice/round 在发送者副本上同样保持"未听",由对端内容已读清除。 + MediaUnread: req.Media.HasUnreadPayload() && req.SenderUserID != req.RecipientUserID, + } + if req.SenderUserID == req.RecipientUserID { + sender.SavedPeer = domain.SavedPeerForSelfChat(req.SenderUserID, req.Forward) + } + recipient := domain.Message{} + if req.SenderUserID == req.RecipientUserID { + recipient = sender + } + if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked { + recipient = sender + recipient.ID = s.nextBoxIDLocked(req.RecipientUserID) + recipient.OwnerUserID = req.RecipientUserID + recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} + recipient.Out = false + recipient.ReplyTo = cloneMessageReply(recipientReply) + // recipient = sender 是值拷贝,共享 sender.ReplyMarkup 指针/Data 切片——深拷 + // 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐,I3/I2)。 + recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup) + recipient.RichMessage = cloneRichMessage(sender.RichMessage) + recipient.Pts = s.nextPtsLocked(req.RecipientUserID) + recipient.MediaUnread = req.Media.HasUnreadPayload() + } + s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender) + if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked { + s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient) + } + if s.dialogs != nil { + if recipient.ID != 0 { + s.upsertMemoryDialogsLocked(sender, recipient) + } else { + s.upsertMemoryDialogsLocked(sender, sender) + } + } + return domain.SendPrivateTextResult{ + SenderMessage: cloneMessage(sender), + RecipientMessage: cloneMessage(recipient), + SenderEvent: newMessageEvent(sender), + RecipientEvent: newMessageEvent(recipient), + }, nil +} + +func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) { + if req.ReplyTo == nil { + return nil, nil, nil + } + if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { + return nil, nil, err + } + if req.ReplyTo.StoryID > 0 { + // story 回复(评论):无源消息可查;story 作者就是会话对端,双盒同持。 + reply := &domain.MessageReply{ + StoryID: req.ReplyTo.StoryID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, + } + return cloneMessageReply(reply), cloneMessageReply(reply), nil + } + peer := req.ReplyTo.Peer + if peer.ID == 0 { + peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID} + } + if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + var target domain.Message + for _, msg := range s.m[req.SenderUserID] { + if msg.Peer == peer && msg.ID == req.ReplyTo.MessageID { + target = msg + break + } + } + if target.ID == 0 { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + senderReply := cloneMessageReply(req.ReplyTo) + senderReply.MessageID = target.ID + senderReply.Peer = peer + if req.SenderUserID == req.RecipientUserID { + return senderReply, cloneMessageReply(senderReply), nil + } + for _, msg := range s.m[req.RecipientUserID] { + if msg.UID == target.UID { + recipientReply := cloneMessageReply(senderReply) + recipientReply.MessageID = msg.ID + recipientReply.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} + return senderReply, recipientReply, nil + } + } + return senderReply, nil, nil +} + +func (s *MessageStore) upsertMemoryDialogsLocked(sender, recipient domain.Message) { + s.dialogs.mu.Lock() + defer s.dialogs.mu.Unlock() + list := s.dialogs.m[sender.OwnerUserID] + list = upsertMemoryDialog(list, domain.Dialog{Peer: sender.Peer, TopMessage: sender.ID, TopMessageDate: sender.Date}) + // 发送方向清手动未读标记(对齐 postgres UpsertOutboxDialog 与 + // channel 发送路径:向会话发出消息即视为已知晓内容)。 + for i := range list.Dialogs { + if list.Dialogs[i].Peer == sender.Peer { + list.Dialogs[i].UnreadMark = false + break + } + } + list.Messages = append(list.Messages, sender) + s.dialogs.m[sender.OwnerUserID] = list + if recipient.OwnerUserID != sender.OwnerUserID { + peerList := s.dialogs.m[recipient.OwnerUserID] + peerList = upsertMemoryDialog(peerList, domain.Dialog{ + Peer: recipient.Peer, + TopMessage: recipient.ID, + TopMessageDate: recipient.Date, + UnreadCount: s.privateUnreadCountLocked(recipient.OwnerUserID, recipient.Peer), + }) + peerList.Messages = append(peerList.Messages, recipient) + s.dialogs.m[recipient.OwnerUserID] = peerList + } +} + +func (s *MessageStore) privateUnreadCountLocked(ownerUserID int64, peer domain.Peer) int { + readMax := 0 + if s.dialogs != nil { + if list, ok := s.dialogs.m[ownerUserID]; ok { + for _, dialog := range list.Dialogs { + if dialog.Peer == peer { + readMax = dialog.ReadInboxMaxID + break + } + } + } + } + unread := 0 + for _, msg := range s.m[ownerUserID] { + if msg.Peer == peer && !msg.Out && msg.ID > readMax { + unread++ + } + } + return unread +} diff --git a/internal/store/memory/message_store.go b/internal/store/memory/message_store.go new file mode 100644 index 00000000..aab44e49 --- /dev/null +++ b/internal/store/memory/message_store.go @@ -0,0 +1,50 @@ +package memory + +import ( + "sync" + "telesrv/internal/domain" +) + +// MessageStore 是 store.MessageStore 的内存实现。 +type MessageStore struct { + mu sync.RWMutex + m map[int64][]domain.Message + nextUID int64 + nextBox map[int64]int + nextPts map[int64]int + readOutboxDates map[readOutboxDateKey]int + privateReactions map[int64]map[int64][]domain.ChannelMessagePeerReaction + dialogs *DialogStore + // polls 是共享 poll 权威(投票校验与读路径 enrichment);nil 时 poll 链路按未接入处理。 + polls *PollStore + // savedPins 是收藏夹子会话置顶顺序(下标即 pinned_order,越小越前)。 + savedPins map[int64][]domain.Peer +} + +// AttachPollStore 注入共享 poll 权威(与 ChannelStore 共用同一实例)。 +func (s *MessageStore) AttachPollStore(polls *PollStore) { + s.polls = polls +} + +type readOutboxDateKey struct { + ownerUserID int64 + peerID int64 + msgID int +} + +// NewMessageStore 创建内存 MessageStore。 +func NewMessageStore(dialogs ...*DialogStore) *MessageStore { + s := &MessageStore{ + m: make(map[int64][]domain.Message), + nextUID: 1, + nextBox: make(map[int64]int), + nextPts: make(map[int64]int), + readOutboxDates: make(map[readOutboxDateKey]int), + privateReactions: make(map[int64]map[int64][]domain.ChannelMessagePeerReaction), + savedPins: make(map[int64][]domain.Peer), + } + if len(dialogs) > 0 { + s.dialogs = dialogs[0] + } + return s +} diff --git a/internal/store/memory/message_test.go b/internal/store/memory/message_test.go index 33b8bb1b..452bd9c8 100644 --- a/internal/store/memory/message_test.go +++ b/internal/store/memory/message_test.go @@ -57,6 +57,20 @@ func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) { t.Fatalf("duplicate = %+v, want original message boxes", dup) } + afterDup, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: req.SenderUserID, + RecipientUserID: req.RecipientUserID, + RandomID: 101, + Message: "after duplicate", + Date: 1700000111, + }) + if err != nil { + t.Fatalf("SendPrivateText after duplicate: %v", err) + } + if afterDup.SenderMessage.ID != 3 || afterDup.SenderMessage.Pts != 3 || afterDup.RecipientMessage.ID != 3 || afterDup.RecipientMessage.Pts != 3 { + t.Fatalf("send after duplicate = %+v/%+v, want next contiguous box_id and pts", afterDup.SenderMessage, afterDup.RecipientMessage) + } + senderHistory, err := messages.ListByUser(ctx, req.SenderUserID, domain.MessageFilter{HasPeer: true, Peer: got.SenderMessage.Peer, Limit: 10}) if err != nil { t.Fatalf("sender history: %v", err) @@ -65,11 +79,83 @@ func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) { if err != nil { t.Fatalf("recipient history: %v", err) } - if len(senderHistory.Messages) != 2 || len(recipientHistory.Messages) != 2 { + if len(senderHistory.Messages) != 3 || len(recipientHistory.Messages) != 3 { t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages)) } } +func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + req := domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, + RecipientUserID: 1000000002, + RandomID: 199, + Date: 1700000120, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionWebViewDataSent, + WebViewData: &domain.MessageWebViewDataAction{ + ButtonText: "Open", + Data: `{"ok":true}`, + }, + }, + }, + } + + got, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + assertWebViewData := func(name string, msg domain.Message) { + if msg.Media == nil || msg.Media.ServiceAction == nil || + msg.Media.ServiceAction.Kind != domain.MessageServiceActionWebViewDataSent || + msg.Media.ServiceAction.WebViewData == nil { + t.Fatalf("%s media = %+v, want webview data service action", name, msg.Media) + } + if data := msg.Media.ServiceAction.WebViewData; data.ButtonText != "Open" || data.Data != `{"ok":true}` { + t.Fatalf("%s webview data = %+v, want Open/data", name, data) + } + } + assertWebViewData("sender", got.SenderMessage) + assertWebViewData("recipient", got.RecipientMessage) + + dup, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: req.SenderUserID, + RecipientUserID: req.RecipientUserID, + RandomID: req.RandomID, + Date: 1700000121, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionWebViewDataSent, + WebViewData: &domain.MessageWebViewDataAction{ + ButtonText: "Changed", + Data: `{"ok":false}`, + }, + }, + }, + }) + if err != nil { + t.Fatalf("SendPrivateText duplicate: %v", err) + } + if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID { + t.Fatalf("duplicate = %+v, want original boxes", dup) + } + assertWebViewData("duplicate sender", dup.SenderMessage) + + recipientHistory, err := messages.ListByUser(ctx, req.RecipientUserID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, + Limit: 10, + }) + if err != nil || len(recipientHistory.Messages) != 1 { + t.Fatalf("recipient history = %+v err=%v, want one message", recipientHistory, err) + } + assertWebViewData("recipient history", recipientHistory.Messages[0]) +} + func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing.T) { ctx := context.Background() messages := NewMessageStore() @@ -117,7 +203,7 @@ func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing if got := aliceReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].Reaction != reaction || got[0].Count != 1 || got[0].ChosenOrder != 0 { t.Fatalf("alice reaction counts = %+v, want one peer reaction without chosen order", got) } - if got := aliceReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || !got[0].Big || got[0].My { + if got := aliceReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || got[0].SenderUserID != aliceID || !got[0].Unread || !got[0].Big || got[0].My { t.Fatalf("alice recent reactions = %+v, want bob non-my big reaction", got) } aliceBox, err := messages.GetByIDs(ctx, aliceID, []int{sent.SenderMessage.ID}) @@ -145,6 +231,9 @@ func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing if len(aliceBox.Messages) != 1 || aliceBox.Messages[0].ReactionUnread { t.Fatalf("alice box after read reaction = %+v, want reaction_unread cleared", aliceBox.Messages) } + if got := aliceBox.Messages[0].Reactions.Recent; len(got) != 1 || got[0].Unread || got[0].SenderUserID != aliceID { + t.Fatalf("alice recent reactions after read = %+v, want unread flag cleared", got) + } bobReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{ OwnerUserID: bobID, @@ -157,11 +246,84 @@ func TestMessageStorePrivateMessageReactionsAreSharedAcrossOwnerBoxes(t *testing if got := bobReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].ChosenOrder != 1 { t.Fatalf("bob reaction counts = %+v, want own chosen order", got) } - if got := bobReactions.Messages[0].Reactions.Recent; len(got) != 1 || !got[0].My { + if got := bobReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || got[0].SenderUserID != aliceID || got[0].Unread || !got[0].My { t.Fatalf("bob recent reactions = %+v, want my reaction", got) } } +func TestMessageStorePrivateReactionEnrichesDialogTopMessages(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + aliceID := int64(1000000001) + bobID := int64(1000000002) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: aliceID, + RecipientUserID: bobID, + RandomID: 111, + Message: "latest from alice", + Date: 1700000300, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\u2764"} + if _, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{ + UserID: bobID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: aliceID}, + MessageID: sent.RecipientMessage.ID, + Reactions: []domain.MessageReaction{ + reaction, + }, + Date: 1700000310, + }); err != nil { + t.Fatalf("SetMessageReactions: %v", err) + } + + aliceDialogs, err := dialogs.ListByUser(ctx, aliceID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("alice ListByUser: %v", err) + } + if len(aliceDialogs.Dialogs) != 1 || aliceDialogs.Dialogs[0].TopMessage != sent.SenderMessage.ID || aliceDialogs.Dialogs[0].UnreadReactions != 1 { + t.Fatalf("alice dialog = %+v, want top message with one unread reaction", aliceDialogs.Dialogs) + } + if len(aliceDialogs.Messages) != 1 || aliceDialogs.Messages[0].ID != sent.SenderMessage.ID || aliceDialogs.Messages[0].Reactions == nil { + t.Fatalf("alice dialog messages = %+v, want enriched top message", aliceDialogs.Messages) + } + if got := aliceDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || got[0].SenderUserID != aliceID || !got[0].Unread || got[0].My { + t.Fatalf("alice dialog recent reactions = %+v, want bob unread non-my reaction", got) + } + + bobDialogs, err := dialogs.ListByPeers(ctx, bobID, []domain.Peer{{Type: domain.PeerTypeUser, ID: aliceID}}) + if err != nil { + t.Fatalf("bob ListByPeers: %v", err) + } + if len(bobDialogs.Messages) != 1 || bobDialogs.Messages[0].Reactions == nil { + t.Fatalf("bob peer dialog messages = %+v, want enriched top message", bobDialogs.Messages) + } + if got := bobDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bobID || got[0].SenderUserID != aliceID || got[0].Unread || !got[0].My { + t.Fatalf("bob peer dialog recent reactions = %+v, want my read reaction", got) + } + + if _, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{ + OwnerUserID: aliceID, + IDs: []int{sent.SenderMessage.ID}, + Date: 1700000320, + }); err != nil { + t.Fatalf("ReadMessageContents: %v", err) + } + aliceDialogs, err = dialogs.ListByUser(ctx, aliceID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("alice ListByUser after read: %v", err) + } + if len(aliceDialogs.Dialogs) != 1 || aliceDialogs.Dialogs[0].UnreadReactions != 0 { + t.Fatalf("alice dialog after read = %+v, want no unread reactions", aliceDialogs.Dialogs) + } + if got := aliceDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].Unread { + t.Fatalf("alice dialog recent reactions after read = %+v, want unread cleared", got) + } +} + func TestMessageStoreSendPrivateTextReplyAndForwardMetadata(t *testing.T) { ctx := context.Background() messages := NewMessageStore() @@ -369,6 +531,270 @@ func TestMessageStoreReadHistoryEmitsInboxAndOutboxReceipts(t *testing.T) { } } +func TestMessageStoreReadHistoryStaleUnreadRepairDoesNotAdvancePts(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000101) + recipientID := int64(1000000102) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 201, + Message: "hello", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID} + if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipientID, + Peer: peer, + MaxID: sent.RecipientMessage.ID, + Date: 1700000200, + }); err != nil { + t.Fatalf("first ReadHistory: %v", err) + } + + dialogs.mu.Lock() + list := dialogs.m[recipientID] + for i := range list.Dialogs { + if list.Dialogs[i].Peer != peer { + continue + } + list.Dialogs[i].UnreadCount = 1 + list.Dialogs[i].UnreadMentions = 1 + list.Dialogs[i].UnreadReactions = 1 + list.Dialogs[i].UnreadMark = true + } + dialogs.m[recipientID] = list + dialogs.mu.Unlock() + + read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipientID, + Peer: peer, + MaxID: sent.RecipientMessage.ID, + Date: 1700000300, + }) + if err != nil { + t.Fatalf("second ReadHistory: %v", err) + } + if read.Changed || read.InboxEvent.Pts != 0 || read.OutboxChanged { + t.Fatalf("stale unread repair = %+v, want no read pts/outbox event", read) + } + + dialogs.mu.RLock() + repaired := dialogs.m[recipientID].Dialogs[0] + dialogs.mu.RUnlock() + // readHistory 重算 UnreadCount、清 mentions/mark,但不清 reaction 角标(与 PG + // 对齐;reaction 未读由 readReactions/readMessageContents 单独清),故 UnreadReactions + // 保留为 1。 + if repaired.UnreadCount != 0 || repaired.UnreadMentions != 0 || repaired.UnreadReactions != 1 || repaired.UnreadMark { + t.Fatalf("dialog after repair = %+v, want unread/mentions/mark cleared and reactions preserved", repaired) + } + next, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 202, + Message: "next", + Date: 1700000400, + }) + if err != nil { + t.Fatalf("next SendPrivateText: %v", err) + } + if next.RecipientMessage.Pts != 3 { + t.Fatalf("next recipient pts = %d, want 3 after no-op read repair", next.RecipientMessage.Pts) + } +} + +func TestMessageStoreSendPrivateTextRecomputesInboxUnreadFromReadMax(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000201) + recipientID := int64(1000000202) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID} + + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 301, + Message: "already read", + Date: 1700000500, + }) + if err != nil { + t.Fatalf("SendPrivateText first: %v", err) + } + if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipientID, + Peer: peer, + MaxID: first.RecipientMessage.ID, + Date: 1700000510, + }); err != nil { + t.Fatalf("ReadHistory: %v", err) + } + + dialogs.mu.Lock() + list := dialogs.m[recipientID] + for i := range list.Dialogs { + if list.Dialogs[i].Peer == peer { + list.Dialogs[i].UnreadCount = 2 + } + } + dialogs.m[recipientID] = list + dialogs.mu.Unlock() + + bodies := []string{"one", "two", "three"} + var last domain.SendPrivateTextResult + for i, body := range bodies { + last, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 310 + int64(i), + Message: body, + Date: 1700000520 + i, + }) + if err != nil { + t.Fatalf("SendPrivateText %q: %v", body, err) + } + } + + dialogs.mu.RLock() + var got domain.Dialog + for _, dialog := range dialogs.m[recipientID].Dialogs { + if dialog.Peer == peer { + got = dialog + break + } + } + dialogs.mu.RUnlock() + if got.UnreadCount != len(bodies) || got.TopMessage != last.RecipientMessage.ID || got.ReadInboxMaxID != first.RecipientMessage.ID { + t.Fatalf("dialog = %+v, want unread=3 top=%d read=%d", got, last.RecipientMessage.ID, first.RecipientMessage.ID) + } +} + +func TestMessageStoreDeleteMessagesRecomputesRecipientUnreadAndTop(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000301) + recipientID := int64(1000000302) + recipientPeer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID} + + bodies := []string{"one", "two", "three"} + sent := make([]domain.SendPrivateTextResult, 0, len(bodies)) + for i, body := range bodies { + got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 330 + int64(i), + Message: body, + Date: 1700000600 + i, + }) + if err != nil { + t.Fatalf("SendPrivateText %q: %v", body, err) + } + sent = append(sent, got) + } + + deleted, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ + OwnerUserID: senderID, + IDs: []int{sent[2].SenderMessage.ID}, + Revoke: true, + Date: 1700000610, + }) + if err != nil { + t.Fatalf("DeleteMessages revoke latest: %v", err) + } + if len(deleted.Deleted) != 2 || !deleted.Changed() { + t.Fatalf("deleted = %+v, want both owner boxes revoked", deleted) + } + + dialogList, err := dialogs.ListByUser(ctx, recipientID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("recipient dialogs: %v", err) + } + var got domain.Dialog + for _, dialog := range dialogList.Dialogs { + if dialog.Peer == recipientPeer { + got = dialog + break + } + } + if got.UnreadCount != 2 || got.TopMessage != sent[1].RecipientMessage.ID { + t.Fatalf("recipient dialog after revoke = %+v, want unread=2 top=%d", got, sent[1].RecipientMessage.ID) + } +} + +func TestMessageStoreDeleteMiddleUnreadKeepsRecipientTop(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000401) + recipientID := int64(1000000402) + recipientPeer := domain.Peer{Type: domain.PeerTypeUser, ID: senderID} + + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 429, + Message: "already read", + Date: 1700000690, + }) + if err != nil { + t.Fatalf("SendPrivateText first: %v", err) + } + if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipientID, + Peer: recipientPeer, + MaxID: first.RecipientMessage.ID, + Date: 1700000695, + }); err != nil { + t.Fatalf("ReadHistory first: %v", err) + } + + bodies := []string{"one", "two", "three"} + sent := make([]domain.SendPrivateTextResult, 0, len(bodies)) + for i, body := range bodies { + got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 430 + int64(i), + Message: body, + Date: 1700000700 + i, + }) + if err != nil { + t.Fatalf("SendPrivateText %q: %v", body, err) + } + sent = append(sent, got) + } + + if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ + OwnerUserID: senderID, + IDs: []int{sent[1].SenderMessage.ID}, + Revoke: true, + Date: 1700000710, + }); err != nil { + t.Fatalf("DeleteMessages revoke middle: %v", err) + } + + dialogList, err := dialogs.ListByUser(ctx, recipientID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("recipient dialogs: %v", err) + } + var got domain.Dialog + for _, dialog := range dialogList.Dialogs { + if dialog.Peer == recipientPeer { + got = dialog + break + } + } + if got.UnreadCount != 2 || got.TopMessage != sent[2].RecipientMessage.ID || got.ReadInboxMaxID != first.RecipientMessage.ID { + t.Fatalf("recipient dialog after middle revoke = %+v, want unread=2 top=%d read=%d", got, sent[2].RecipientMessage.ID, first.RecipientMessage.ID) + } +} + func TestMessageStoreReadMessageContentsClearsUnreadContentOnce(t *testing.T) { ctx := context.Background() messages := NewMessageStore() @@ -419,6 +845,72 @@ func TestMessageStoreReadMessageContentsClearsUnreadContentOnce(t *testing.T) { } } +func TestMessageStoreReadMessageContentsNotifiesVoiceSender(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1001, + RecipientUserID: 1002, + RandomID: 99, + Message: "", + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Voice: true}, + Date: 1700000300, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + if !sent.SenderMessage.MediaUnread { + t.Fatalf("sender voice MediaUnread = false, want true until the peer listens") + } + photo, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1001, + RecipientUserID: 1002, + RandomID: 100, + Message: "", + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 7}}, + Date: 1700000301, + }) + if err != nil { + t.Fatalf("SendPrivateText photo: %v", err) + } + if photo.SenderMessage.MediaUnread || photo.RecipientMessage.MediaUnread { + t.Fatalf("photo media_unread sender=%v recipient=%v, want false: only voice/round carry unread payload", + photo.SenderMessage.MediaUnread, photo.RecipientMessage.MediaUnread) + } + read, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{ + OwnerUserID: 1002, + IDs: []int{sent.RecipientMessage.ID}, + Date: 1700000400, + }) + if err != nil { + t.Fatalf("ReadMessageContents: %v", err) + } + if len(read.SenderEvents) != 1 { + t.Fatalf("SenderEvents = %+v, want one sender receipt", read.SenderEvents) + } + receipt := read.SenderEvents[0] + if receipt.UserID != 1001 || receipt.Type != domain.UpdateEventReadMessageContents || receipt.Pts == 0 { + t.Fatalf("receipt = %+v, want sender-side read_message_contents", receipt) + } + if !reflect.DeepEqual(receipt.MessageIDs, []int{sent.SenderMessage.ID}) { + t.Fatalf("receipt ids = %v, want sender box id %d", receipt.MessageIDs, sent.SenderMessage.ID) + } + if receipt.Date != 1700000400 { + t.Fatalf("receipt date = %d, want read time 1700000400", receipt.Date) + } + repeat, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{ + OwnerUserID: 1002, + IDs: []int{sent.RecipientMessage.ID}, + Date: 1700000500, + }) + if err != nil { + t.Fatalf("ReadMessageContents repeat: %v", err) + } + if len(repeat.SenderEvents) != 0 { + t.Fatalf("repeat SenderEvents = %+v, want none", repeat.SenderEvents) + } +} + func messageIDs(messages []domain.Message) []int { out := make([]int, 0, len(messages)) for _, msg := range messages { @@ -478,6 +970,59 @@ func TestMessageStoreEditMessageUpdatesBothBoxes(t *testing.T) { } } +func TestMessageStoreEditViaBotMessageUpdatesBothBoxes(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000001) + recipientID := int64(1000000002) + viaBotID := int64(1000000900) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, + RecipientUserID: recipientID, + RandomID: 103, + Message: "via before", + ViaBotID: viaBotID, + Date: 1700000100, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + + if _, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: recipientID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, + ID: sent.RecipientMessage.ID, + Message: "bad bot", + EditDate: 1700000190, + ViaBotEditBotID: viaBotID + 1, + }); err != domain.ErrMessageAuthorRequired { + t.Fatalf("EditMessage wrong via bot err = %v, want ErrMessageAuthorRequired", err) + } + + edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: recipientID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, + ID: sent.RecipientMessage.ID, + Message: "via after", + EditDate: 1700000200, + ViaBotEditBotID: viaBotID, + }) + if err != nil { + t.Fatalf("EditMessage via bot: %v", err) + } + if len(edited.Edited) != 2 { + t.Fatalf("edited boxes = %d, want 2", len(edited.Edited)) + } + senderHistory, err := messages.ListByUser(ctx, senderID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, Limit: 10}) + if err != nil { + t.Fatalf("sender history: %v", err) + } + if len(senderHistory.Messages) != 1 || senderHistory.Messages[0].Body != "via after" || senderHistory.Messages[0].ViaBotID != viaBotID { + t.Fatalf("sender history = %+v, want via after with via bot", senderHistory.Messages) + } +} + func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing.T) { ctx := context.Background() dialogs := NewDialogStore() @@ -573,3 +1118,185 @@ func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing t.Fatalf("preserved dialogs = %+v messages=%+v, want empty dialog kept after just_clear", preservedDialogs.Dialogs, preservedDialogs.Messages) } } + +func TestMessageStoreRevokeHistorySweepsPeerSideAfterLocalClear(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + for i := 0; i < 3; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1001, + RecipientUserID: 1002, + RandomID: int64(700 + i), + Message: "history", + Date: 1700000600 + i, + }); err != nil { + t.Fatalf("SendPrivateText %d: %v", i, err) + } + } + // 先单向清空自己侧。 + if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: 1001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + Date: 1700000700, + }); err != nil { + t.Fatalf("local clear: %v", err) + } + // 再双向清史:反查模型对"我方已无 box"的消息失效,必须直扫对端。 + res, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: 1001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, + Revoke: true, + Date: 1700000800, + }) + if err != nil { + t.Fatalf("revoke clear: %v", err) + } + var peerEvent bool + for _, d := range res.Deleted { + if d.UserID == 1002 && len(d.MessageIDs) == 3 { + peerEvent = true + } + } + if !peerEvent { + t.Fatalf("revoke deleted = %+v, want peer-side sweep of all 3 messages", res.Deleted) + } + peerHistory, err := messages.ListByUser(ctx, 1002, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1001}, + Limit: 10, + }) + if err != nil { + t.Fatalf("peer history: %v", err) + } + if len(peerHistory.Messages) != 0 { + t.Fatalf("peer history after revoke = %+v, want empty", peerHistory.Messages) + } +} + +func TestReorderPinnedForceScopedToFolder(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + mainPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2001} + archivedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: 2002} + if err := dialogs.Upsert(ctx, 1001, domain.Dialog{Peer: mainPeer, TopMessage: 1, TopMessageDate: 10}); err != nil { + t.Fatalf("upsert main: %v", err) + } + if err := dialogs.Upsert(ctx, 1001, domain.Dialog{Peer: archivedPeer, FolderID: domain.DialogArchiveFolderID, TopMessage: 2, TopMessageDate: 20}); err != nil { + t.Fatalf("upsert archived: %v", err) + } + if _, _, err := dialogs.SetPinned(ctx, 1001, mainPeer, true); err != nil { + t.Fatalf("pin main: %v", err) + } + if _, folderID, err := dialogs.SetPinned(ctx, 1001, archivedPeer, true); err != nil || folderID != domain.DialogArchiveFolderID { + t.Fatalf("pin archived folder = %d err %v, want archive", folderID, err) + } + // 归档列表内 force 重排:绝不允许清掉主列表的置顶。 + if changed, err := dialogs.ReorderPinned(ctx, 1001, domain.DialogArchiveFolderID, []domain.Peer{archivedPeer}, true); err != nil || changed { + t.Fatalf("reorder archive = changed %v err %v, want no-op", changed, err) + } + list, err := dialogs.ListByUser(ctx, 1001, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list main: %v", err) + } + for _, dialog := range list.Dialogs { + if dialog.Peer == mainPeer && !dialog.Pinned { + t.Fatalf("main pin cleared by archive force reorder: %+v", dialog) + } + } + // 主列表 force 重排同样不得波及归档置顶。 + if changed, err := dialogs.ReorderPinned(ctx, 1001, domain.DialogMainFolderID, []domain.Peer{mainPeer}, true); err != nil || changed { + t.Fatalf("reorder main = changed %v err %v, want no-op", changed, err) + } + archived, err := dialogs.ListByUser(ctx, 1001, domain.DialogFilter{HasFolderID: true, FolderID: domain.DialogArchiveFolderID, Limit: 10}) + if err != nil { + t.Fatalf("list archive: %v", err) + } + for _, dialog := range archived.Dialogs { + if dialog.Peer == archivedPeer && !dialog.Pinned { + t.Fatalf("archive pin cleared by main force reorder: %+v", dialog) + } + } +} + +func TestSendPrivateTextClearsSenderUnreadMark(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, + RecipientUserID: 1000000002, + RandomID: 1, + Message: "hi", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("seed send: %v", err) + } + peer := first.SenderMessage.Peer + if _, err := dialogs.SetUnreadMark(ctx, 1000000001, peer, true); err != nil { + t.Fatalf("mark unread: %v", err) + } + // 发送方向发出消息即清手动未读标记(对齐 postgres UpsertOutboxDialog + // 与 channel 发送路径)。 + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, + RecipientUserID: 1000000002, + RandomID: 2, + Message: "again", + Date: 1700000200, + }); err != nil { + t.Fatalf("send after mark: %v", err) + } + marks, err := dialogs.ListUnreadMarked(ctx, 1000000001) + if err != nil { + t.Fatalf("list unread marks: %v", err) + } + if len(marks) != 0 { + t.Fatalf("unread marks after send = %+v, want cleared", marks) + } +} + +func TestSendPrivateTextPreservesPinnedDialog(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, + RecipientUserID: 1000000002, + RandomID: 1, + Message: "hi", + Date: 1700000100, + }) + if err != nil { + t.Fatalf("seed send: %v", err) + } + peer := first.SenderMessage.Peer + if _, _, err := dialogs.SetPinned(ctx, 1000000001, peer, true); err != nil { + t.Fatalf("pin: %v", err) + } + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, + RecipientUserID: 1000000002, + RandomID: 2, + Message: "again", + Date: 1700000200, + }); err != nil { + t.Fatalf("send after pin: %v", err) + } + list, err := dialogs.ListByUser(ctx, 1000000001, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list: %v", err) + } + found := false + for _, dialog := range list.Dialogs { + if dialog.Peer == peer { + found = true + if !dialog.Pinned || dialog.PinnedOrder == 0 { + t.Fatalf("dialog after send = %+v, want pinned preserved", dialog) + } + } + } + if !found { + t.Fatalf("dialog not found after send") + } +} diff --git a/internal/store/memory/message_webpage_resolve_test.go b/internal/store/memory/message_webpage_resolve_test.go new file mode 100644 index 00000000..6e8d7cda --- /dev/null +++ b/internal/store/memory/message_webpage_resolve_test.go @@ -0,0 +1,111 @@ +package memory + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +// TestMessageStoreResolveWebPageSwapsMediaOnly 验证 WebPageResolve 模式只换 media、不碰 +// body/entities/edit_date(不标记「已编辑」),双盒一致,事件为 web_page,且幂等。 +func TestMessageStoreResolveWebPageSwapsMediaOnly(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000201) + recipientID := int64(1000000202) + const url = "https://example.com/x" + urlHash := domain.WebPageURLHash(url) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipientID} + + pending := &domain.MessageMedia{ + Kind: domain.MessageMediaKindWebPage, + WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStatePending, ID: urlHash, URL: url}, + } + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, RecipientUserID: recipientID, RandomID: 201, + Message: "see " + url, Date: 1700000100, Media: pending, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + + done := &domain.MessageMedia{ + Kind: domain.MessageMediaKindWebPage, + WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: urlHash, URL: url, Title: "Example"}, + } + resolveReq := domain.EditMessageRequest{ + OwnerUserID: senderID, + Peer: peer, + ID: sent.SenderMessage.ID, + Media: done, + WebPageResolve: true, + ExpectedWebPageID: urlHash, + } + res, err := messages.EditMessage(ctx, resolveReq) + if err != nil { + t.Fatalf("EditMessage(WebPageResolve): %v", err) + } + if len(res.Edited) != 2 { + t.Fatalf("edited boxes = %d, want 2", len(res.Edited)) + } + self := res.Self() + if self.Event.Type != domain.UpdateEventWebPage { + t.Fatalf("event type = %q, want web_page", self.Event.Type) + } + if self.Message.Media == nil || self.Message.Media.WebPage == nil || self.Message.Media.WebPage.State != domain.MessageWebPageStateDone { + t.Fatalf("self media not resolved: %+v", self.Message.Media) + } + if self.Message.Body != "see "+url { + t.Fatalf("body changed to %q, want unchanged", self.Message.Body) + } + if self.Message.EditDate != 0 { + t.Fatalf("edit_date = %d, want 0 (no 'edited' marker)", self.Message.EditDate) + } + + // 接收方盒子也被替换为 done。 + rh, err := messages.ListByUser(ctx, recipientID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: senderID}, Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(rh.Messages) != 1 || rh.Messages[0].Media == nil || rh.Messages[0].Media.WebPage == nil || rh.Messages[0].Media.WebPage.State != domain.MessageWebPageStateDone { + t.Fatalf("recipient media not resolved: %+v", rh.Messages) + } + + // 幂等:已解析后再次解析 → ErrMessageNotModified。 + if _, err := messages.EditMessage(ctx, resolveReq); !errors.Is(err, domain.ErrMessageNotModified) { + t.Fatalf("re-resolve err = %v, want ErrMessageNotModified", err) + } +} + +// TestMessageStoreResolveWebPageWrongIDNoop 验证 expectedID 不匹配时不替换。 +func TestMessageStoreResolveWebPageWrongIDNoop(t *testing.T) { + ctx := context.Background() + dialogs := NewDialogStore() + messages := NewMessageStore(dialogs) + senderID := int64(1000000211) + recipientID := int64(1000000212) + const url = "https://example.com/y" + urlHash := domain.WebPageURLHash(url) + + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: senderID, RecipientUserID: recipientID, RandomID: 211, Message: url, Date: 1700000100, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStatePending, ID: urlHash, URL: url}}, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + _, err = messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: senderID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipientID}, + ID: sent.SenderMessage.ID, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: 999, URL: url}}, + WebPageResolve: true, + ExpectedWebPageID: 999, // 与占位 id 不符 + }) + if !errors.Is(err, domain.ErrMessageNotModified) { + t.Fatalf("wrong-id resolve err = %v, want ErrMessageNotModified", err) + } +} diff --git a/internal/store/memory/paid_reaction_test.go b/internal/store/memory/paid_reaction_test.go new file mode 100644 index 00000000..84cfa594 --- /dev/null +++ b/internal/store/memory/paid_reaction_test.go @@ -0,0 +1,122 @@ +package memory + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func seedBroadcastPost(t *testing.T, st *ChannelStore, creator int64, broadcast bool) (channelID int64, msgID int) { + t.Helper() + ctx := context.Background() + created, err := st.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: creator, + Title: "Paid", + Broadcast: broadcast, + Megagroup: !broadcast, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := st.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: creator, + ChannelID: created.Channel.ID, + Message: "post", + Date: 1700000000, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + return created.Channel.ID, sent.Message.ID +} + +// 付费 reaction 累计 + 聚合:同一 reactor 多次增投累加,TopReactors 含本人带 My。 +func TestAddChannelMessagePaidReactionAccumulates(t *testing.T) { + st := NewChannelStore() + ctx := context.Background() + const creator = int64(1000000001) + channelID, msgID := seedBroadcastPost(t, st, creator, true) + + res, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{ + UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 100, Date: 1700000001, + }) + if err != nil { + t.Fatalf("first paid reaction: %v", err) + } + if res.Paid.TotalStars != 100 || res.Paid.MyStars != 100 { + t.Fatalf("after 100 = total %d my %d, want 100/100", res.Paid.TotalStars, res.Paid.MyStars) + } + + res, err = st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{ + UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 50, Date: 1700000002, + }) + if err != nil { + t.Fatalf("second paid reaction: %v", err) + } + if res.Paid.TotalStars != 150 || res.Paid.MyStars != 150 { + t.Fatalf("after +50 = total %d my %d, want 150/150 (accumulated)", res.Paid.TotalStars, res.Paid.MyStars) + } + if len(res.Paid.TopReactors) != 1 || res.Paid.TopReactors[0].Stars != 150 || !res.Paid.TopReactors[0].My { + t.Fatalf("top reactors = %+v, want one My 150", res.Paid.TopReactors) + } +} + +// 多 reactor:TopReactors 按星数降序,本人始终在列。 +func TestAddChannelMessagePaidReactionTopReactors(t *testing.T) { + st := NewChannelStore() + ctx := context.Background() + const creator = int64(1000000001) + channelID, msgID := seedBroadcastPost(t, st, creator, true) + // 让另外两个用户成为成员并增投(直接写 store 累计,绕过成员校验仅测聚合)。 + for _, c := range []struct { + user int64 + stars int64 + }{{creator, 30}, {2000000002, 200}, {2000000003, 80}} { + // 仅 creator 经正式路径;其他用户直接累计以构造排行。 + if c.user == creator { + if _, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{ + UserID: c.user, ChannelID: channelID, MessageID: msgID, Stars: c.stars, Date: 1700000010, + }); err != nil { + t.Fatalf("creator paid reaction: %v", err) + } + continue + } + st.mu.Lock() + st.paidReactions[channelID][msgID][c.user] = memoryPaidReaction{stars: c.stars, date: 1700000010} + st.mu.Unlock() + } + res, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{ + UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 0 + 1, Date: 1700000011, + }) + // creator 现在 31+? 重新算:creator 30 + 这次 1 = 31。 + if err != nil { + t.Fatalf("paid reaction: %v", err) + } + if res.Paid.TotalStars != 31+200+80 { + t.Fatalf("total = %d, want %d", res.Paid.TotalStars, 31+200+80) + } + // 降序:200, 80, 31。 + if len(res.Paid.TopReactors) != 3 || res.Paid.TopReactors[0].Stars != 200 || res.Paid.TopReactors[1].Stars != 80 || res.Paid.TopReactors[2].Stars != 31 { + t.Fatalf("top reactors = %+v, want 200/80/31 desc", res.Paid.TopReactors) + } + if !res.Paid.TopReactors[2].My { + t.Fatalf("creator (31) must carry My flag, got %+v", res.Paid.TopReactors[2]) + } +} + +// 非广播频道拒绝付费 reaction。 +func TestAddChannelMessagePaidReactionRejectsMegagroup(t *testing.T) { + st := NewChannelStore() + ctx := context.Background() + const creator = int64(1000000001) + channelID, msgID := seedBroadcastPost(t, st, creator, false) + _, err := st.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{ + UserID: creator, ChannelID: channelID, MessageID: msgID, Stars: 10, Date: 1700000001, + }) + if !errors.Is(err, domain.ErrReactionInvalid) { + t.Fatalf("megagroup paid reaction err = %v, want ErrReactionInvalid", err) + } +} diff --git a/internal/store/memory/passkey.go b/internal/store/memory/passkey.go new file mode 100644 index 00000000..1c0bb3c2 --- /dev/null +++ b/internal/store/memory/passkey.go @@ -0,0 +1,117 @@ +package memory + +import ( + "context" + "sync" + "time" + + "telesrv/internal/domain" +) + +// PasskeyStore 是 store.PasskeyStore 的内存实现。 +type PasskeyStore struct { + mu sync.RWMutex + byID map[string]domain.PasskeyCredential // key = string(credentialID) +} + +// NewPasskeyStore 创建内存 passkey 凭据 store。 +func NewPasskeyStore() *PasskeyStore { + return &PasskeyStore{byID: make(map[string]domain.PasskeyCredential)} +} + +func (s *PasskeyStore) InsertPasskey(_ context.Context, cred domain.PasskeyCredential) error { + if len(cred.CredentialID) == 0 || cred.UserID == 0 { + return domain.ErrPasskeyInvalid + } + s.mu.Lock() + s.byID[string(cred.CredentialID)] = cred.Clone() + s.mu.Unlock() + return nil +} + +func (s *PasskeyStore) GetPasskeyByCredentialID(_ context.Context, credentialID []byte) (domain.PasskeyCredential, bool, error) { + s.mu.RLock() + cred, ok := s.byID[string(credentialID)] + s.mu.RUnlock() + if !ok { + return domain.PasskeyCredential{}, false, nil + } + return cred.Clone(), true, nil +} + +func (s *PasskeyStore) ListPasskeysByUser(_ context.Context, userID int64) ([]domain.PasskeyCredential, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.PasskeyCredential, 0) + for _, cred := range s.byID { + if cred.UserID == userID { + out = append(out, cred.Clone()) + } + } + return out, nil +} + +func (s *PasskeyStore) UpdatePasskeyUsage(_ context.Context, credentialID []byte, signCount uint32, lastUsedAt int64) error { + s.mu.Lock() + defer s.mu.Unlock() + cred, ok := s.byID[string(credentialID)] + if !ok { + return domain.ErrPasskeyNotFound + } + cred.SignCount = signCount + cred.LastUsedAt = lastUsedAt + s.byID[string(credentialID)] = cred + return nil +} + +func (s *PasskeyStore) DeletePasskey(_ context.Context, userID int64, credentialID []byte) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + cred, ok := s.byID[string(credentialID)] + if !ok || cred.UserID != userID { + return false, nil + } + delete(s.byID, string(credentialID)) + return true, nil +} + +// PasskeyChallengeStore 是 store.PasskeyChallengeStore 的内存实现(进程内、短 TTL)。 +// 与 QR 登录 token 同属进程内一次性凭据,不跨实例。 +type PasskeyChallengeStore struct { + mu sync.Mutex + m map[string]challengeEntry // key = string(challenge) +} + +type challengeEntry struct { + c domain.PasskeyChallenge + expiresAt time.Time +} + +// NewPasskeyChallengeStore 创建内存挑战 store。 +func NewPasskeyChallengeStore() *PasskeyChallengeStore { + return &PasskeyChallengeStore{m: make(map[string]challengeEntry)} +} + +func (s *PasskeyChallengeStore) SavePasskeyChallenge(_ context.Context, challenge []byte, c domain.PasskeyChallenge, ttl time.Duration) error { + if len(challenge) == 0 { + return domain.ErrPasskeyChallengeInvalid + } + s.mu.Lock() + s.m[string(challenge)] = challengeEntry{c: c, expiresAt: time.Now().Add(ttl)} + s.mu.Unlock() + return nil +} + +func (s *PasskeyChallengeStore) ConsumePasskeyChallenge(_ context.Context, challenge []byte) (domain.PasskeyChallenge, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.m[string(challenge)] + if !ok { + return domain.PasskeyChallenge{}, false, nil + } + delete(s.m, string(challenge)) // 一次性:无论是否过期都删除 + if time.Now().After(entry.expiresAt) { + return domain.PasskeyChallenge{}, false, nil + } + return entry.c, true, nil +} diff --git a/internal/store/memory/password.go b/internal/store/memory/password.go new file mode 100644 index 00000000..55d5714a --- /dev/null +++ b/internal/store/memory/password.go @@ -0,0 +1,413 @@ +package memory + +import ( + "context" + "sync" + "telesrv/internal/domain" +) + +// PasswordStore 是 store.PasswordStore 的内存实现。 +type PasswordStore struct { + mu sync.RWMutex + m map[int64]domain.PasswordSettings + reactions map[int64]domain.AccountReactionSettings + accountSettings map[int64]domain.AccountSettings + notifySettings map[notifySettingsKey]domain.PeerNotifySettings + stickerCollections map[stickerCollectionKey][]domain.StickerCollectionItem + savedMusic map[int64][]domain.Document + businessProfiles map[int64]domain.BusinessProfile + businessChatLinks map[string]domain.BusinessChatLink + businessChatLinkSlugs map[int64][]string + quickReplies map[int64]map[int]domain.QuickReply + quickReplyByShortcut map[int64]map[string]int + quickReplyMessages map[int64]map[int]map[int]domain.QuickReplyMessage + businessDeliveries map[businessAutomationDeliveryKey]domain.BusinessAutomationDelivery + connectedBusinessBots map[int64]domain.ConnectedBusinessBot + connectedBusinessBotPeerStates map[connectedBusinessBotPeerKey]domain.ConnectedBusinessBotPeerState + nextQuickReplyID map[int64]int + nextQuickReplyMessageID map[int64]int +} + +type businessAutomationDeliveryKey struct { + ownerUserID int64 + peerUserID int64 + kind domain.BusinessAutomationKind + triggerMessageID int +} + +type connectedBusinessBotPeerKey struct { + ownerUserID int64 + peerUserID int64 +} + +// NewPasswordStore 创建内存 PasswordStore。 +func NewPasswordStore() *PasswordStore { + return &PasswordStore{ + m: make(map[int64]domain.PasswordSettings), + reactions: make(map[int64]domain.AccountReactionSettings), + accountSettings: make(map[int64]domain.AccountSettings), + notifySettings: make(map[notifySettingsKey]domain.PeerNotifySettings), + stickerCollections: make(map[stickerCollectionKey][]domain.StickerCollectionItem), + savedMusic: make(map[int64][]domain.Document), + businessProfiles: make(map[int64]domain.BusinessProfile), + businessChatLinks: make(map[string]domain.BusinessChatLink), + businessChatLinkSlugs: make(map[int64][]string), + quickReplies: make(map[int64]map[int]domain.QuickReply), + quickReplyByShortcut: make(map[int64]map[string]int), + quickReplyMessages: make(map[int64]map[int]map[int]domain.QuickReplyMessage), + businessDeliveries: make(map[businessAutomationDeliveryKey]domain.BusinessAutomationDelivery), + connectedBusinessBots: make(map[int64]domain.ConnectedBusinessBot), + connectedBusinessBotPeerStates: make(map[connectedBusinessBotPeerKey]domain.ConnectedBusinessBotPeerState), + nextQuickReplyID: make(map[int64]int), + nextQuickReplyMessageID: make(map[int64]int), + } +} + +func (s *PasswordStore) GetByUser(_ context.Context, userID int64) (domain.PasswordSettings, bool, error) { + s.mu.RLock() + settings, ok := s.m[userID] + s.mu.RUnlock() + return clonePasswordSettings(settings), ok, nil +} + +func (s *PasswordStore) Save(_ context.Context, userID int64, settings domain.PasswordSettings) error { + s.mu.Lock() + s.m[userID] = clonePasswordSettings(settings) + s.mu.Unlock() + return nil +} + +func clonePasswordSettings(in domain.PasswordSettings) domain.PasswordSettings { + out := in + if in.CurrentAlgo != nil { + algo := *in.CurrentAlgo + algo.Salt1 = append([]byte(nil), algo.Salt1...) + algo.Salt2 = append([]byte(nil), algo.Salt2...) + algo.P = append([]byte(nil), algo.P...) + out.CurrentAlgo = &algo + } + out.SRPB = append([]byte(nil), in.SRPB...) + out.NewAlgo.Salt1 = append([]byte(nil), in.NewAlgo.Salt1...) + out.NewAlgo.Salt2 = append([]byte(nil), in.NewAlgo.Salt2...) + out.NewAlgo.P = append([]byte(nil), in.NewAlgo.P...) + out.NewSecureAlgo.Salt = append([]byte(nil), in.NewSecureAlgo.Salt...) + out.SecureRandom = append([]byte(nil), in.SecureRandom...) + out.SRPVerifier = append([]byte(nil), in.SRPVerifier...) + out.SRPBSecret = append([]byte(nil), in.SRPBSecret...) + return out +} + +func (s *PasswordStore) GetReactionSettings(_ context.Context, userID int64) (domain.AccountReactionSettings, bool, error) { + s.mu.RLock() + settings, ok := s.reactions[userID] + s.mu.RUnlock() + return cloneAccountReactionSettings(settings), ok, nil +} + +func (s *PasswordStore) SaveReactionSettings(_ context.Context, userID int64, settings domain.AccountReactionSettings) error { + s.mu.Lock() + s.reactions[userID] = cloneAccountReactionSettings(settings) + s.mu.Unlock() + return nil +} + +func cloneAccountReactionSettings(in domain.AccountReactionSettings) domain.AccountReactionSettings { + out := in + if in.PaidPrivacy.Peer != nil { + peer := *in.PaidPrivacy.Peer + out.PaidPrivacy.Peer = &peer + } + return out +} + +func (s *PasswordStore) GetAccountSettings(_ context.Context, userID int64) (domain.AccountSettings, bool, error) { + s.mu.RLock() + settings, ok := s.accountSettings[userID] + s.mu.RUnlock() + return settings, ok, nil // AccountSettings 全是值类型,无需深拷贝 +} + +func (s *PasswordStore) SaveAccountSettings(_ context.Context, userID int64, settings domain.AccountSettings) error { + s.mu.Lock() + s.accountSettings[userID] = settings + s.mu.Unlock() + return nil +} + +type notifySettingsKey struct { + owner int64 + kind domain.NotifyScopeKind + peerType domain.PeerType + peerID int64 + topicID int +} + +func notifySettingsKeyOf(owner int64, scope domain.NotifyScope) notifySettingsKey { + key := notifySettingsKey{owner: owner, kind: scope.Kind} + if scope.Kind == domain.NotifyScopePeer { + key.peerType = scope.Peer.Type + key.peerID = scope.Peer.ID + key.topicID = scope.TopicID + } + return key +} + +func (s *PasswordStore) GetNotifySettings(_ context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, bool, error) { + s.mu.RLock() + settings, ok := s.notifySettings[notifySettingsKeyOf(ownerUserID, scope)] + s.mu.RUnlock() + return settings.Clone(), ok, nil +} + +func (s *PasswordStore) SaveNotifySettings(_ context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error { + s.mu.Lock() + s.notifySettings[notifySettingsKeyOf(ownerUserID, scope)] = settings.Clone() + s.mu.Unlock() + return nil +} + +func (s *PasswordStore) ResetNotifySettings(_ context.Context, ownerUserID int64) error { + s.mu.Lock() + for key := range s.notifySettings { + if key.owner == ownerUserID { + delete(s.notifySettings, key) + } + } + s.mu.Unlock() + return nil +} + +type stickerCollectionKey struct { + owner int64 + kind domain.StickerCollectionKind +} + +func (s *PasswordStore) SaveStickerCollectionItem(_ context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now, max int) error { + if userID == 0 || documentID == 0 { + return domain.ErrStickerInvalid + } + if max <= 0 { + max = domain.MaxStickerCollectionItems(kind) + } + s.mu.Lock() + defer s.mu.Unlock() + key := stickerCollectionKey{owner: userID, kind: kind} + cur := s.stickerCollections[key] + // 移除既有同 id 项。 + next := make([]domain.StickerCollectionItem, 0, len(cur)+1) + for _, it := range cur { + if it.DocumentID == documentID { + continue + } + next = append(next, it) + } + if unsave { + s.stickerCollections[key] = next + return nil + } + // 最新置顶 + 截断。 + next = append([]domain.StickerCollectionItem{{DocumentID: documentID, Date: now}}, next...) + if len(next) > max { + next = next[:max] + } + s.stickerCollections[key] = next + return nil +} + +func (s *PasswordStore) ListStickerCollection(_ context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) { + if limit <= 0 || limit > domain.MaxStickerCollectionItems(kind) { + limit = domain.MaxStickerCollectionItems(kind) + } + s.mu.RLock() + cur := s.stickerCollections[stickerCollectionKey{owner: userID, kind: kind}] + s.mu.RUnlock() + if len(cur) > limit { + cur = cur[:limit] + } + return append([]domain.StickerCollectionItem(nil), cur...), nil +} + +func (s *PasswordStore) ClearStickerCollection(_ context.Context, userID int64, kind domain.StickerCollectionKind) error { + s.mu.Lock() + delete(s.stickerCollections, stickerCollectionKey{owner: userID, kind: kind}) + s.mu.Unlock() + return nil +} + +func (s *PasswordStore) ListNotifyExceptions(_ context.Context, ownerUserID int64) ([]domain.NotifyException, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.NotifyException, 0) + for key, settings := range s.notifySettings { + if key.owner != ownerUserID || key.kind != domain.NotifyScopePeer || settings.IsZero() { + continue + } + out = append(out, domain.NotifyException{ + Peer: domain.Peer{Type: key.peerType, ID: key.peerID}, + TopicID: key.topicID, + Settings: settings.Clone(), + }) + } + return out, nil +} + +func (s *PasswordStore) AllPeerNotifySettings(_ context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) { + out := make(map[domain.Peer]domain.PeerNotifySettings) + s.mu.RLock() + defer s.mu.RUnlock() + for key, settings := range s.notifySettings { + if key.owner != ownerUserID || key.kind != domain.NotifyScopePeer || key.topicID != 0 { + continue + } + out[domain.Peer{Type: key.peerType, ID: key.peerID}] = settings.Clone() + } + return out, nil +} + +func (s *PasswordStore) GetPeerNotifySettings(_ context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) { + out := make(map[domain.Peer]domain.PeerNotifySettings, len(peers)) + s.mu.RLock() + defer s.mu.RUnlock() + for _, p := range peers { + key := notifySettingsKey{owner: ownerUserID, kind: domain.NotifyScopePeer, peerType: p.Type, peerID: p.ID, topicID: 0} + if settings, ok := s.notifySettings[key]; ok { + out[p] = settings.Clone() + } + } + return out, nil +} + +func (s *PasswordStore) SaveMusic(_ context.Context, req domain.SaveMusicRequest) error { + if req.UserID == 0 || req.Document.ID == 0 || !req.Document.IsMusic() { + return domain.ErrDocumentInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + current := s.savedMusic[req.UserID] + if req.Unsave { + s.savedMusic[req.UserID] = removeSavedMusicDocument(current, req.Document.ID) + return nil + } + if req.AfterDocumentID == req.Document.ID { + for _, doc := range current { + if doc.ID == req.Document.ID { + return nil + } + } + return domain.ErrDocumentInvalid + } + next := make([]domain.Document, 0, len(current)+1) + afterIndex := -1 + for _, doc := range current { + if doc.ID == req.Document.ID { + continue + } + if doc.ID == req.AfterDocumentID { + afterIndex = len(next) + } + next = append(next, cloneDocument(doc)) + } + insert := cloneDocument(req.Document) + if req.AfterDocumentID != 0 { + if afterIndex < 0 { + return domain.ErrDocumentInvalid + } + next = append(next, domain.Document{}) + copy(next[afterIndex+2:], next[afterIndex+1:]) + next[afterIndex+1] = insert + } else { + next = append([]domain.Document{insert}, next...) + } + if len(next) > domain.MaxSavedMusicItems { + next = next[:domain.MaxSavedMusicItems] + } + s.savedMusic[req.UserID] = next + return nil +} + +func (s *PasswordStore) ListSavedMusicIDs(_ context.Context, userID int64, limit int) ([]int64, error) { + s.mu.RLock() + current := s.savedMusic[userID] + s.mu.RUnlock() + if limit <= 0 || limit > domain.MaxSavedMusicItems { + limit = domain.MaxSavedMusicItems + } + if len(current) > limit { + current = current[:limit] + } + out := make([]int64, 0, len(current)) + for _, doc := range current { + out = append(out, doc.ID) + } + return out, nil +} + +func (s *PasswordStore) ListSavedMusic(_ context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) { + s.mu.RLock() + current := cloneDocuments(s.savedMusic[userID]) + s.mu.RUnlock() + out := domain.SavedMusicList{UserID: userID, Count: len(current)} + if offset < 0 || offset >= len(current) || limit <= 0 { + return out, nil + } + end := offset + limit + if end > len(current) { + end = len(current) + } + out.Documents = cloneDocuments(current[offset:end]) + return out, nil +} + +func (s *PasswordStore) GetSavedMusicByIDs(_ context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) { + s.mu.RLock() + current := cloneDocuments(s.savedMusic[userID]) + s.mu.RUnlock() + byID := make(map[int64]domain.Document, len(current)) + for _, doc := range current { + byID[doc.ID] = doc + } + out := domain.SavedMusicList{UserID: userID, Count: len(current)} + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + if doc, ok := byID[id]; ok { + out.Documents = append(out.Documents, cloneDocument(doc)) + } + } + return out, nil +} + +func removeSavedMusicDocument(in []domain.Document, id int64) []domain.Document { + if len(in) == 0 { + return nil + } + out := make([]domain.Document, 0, len(in)) + for _, doc := range in { + if doc.ID != id { + out = append(out, cloneDocument(doc)) + } + } + return out +} + +func cloneDocument(in domain.Document) domain.Document { + out := in + out.FileReference = append([]byte(nil), in.FileReference...) + out.Attributes = append([]domain.DocumentAttribute(nil), in.Attributes...) + out.Thumbs = append([]domain.PhotoSize(nil), in.Thumbs...) + return out +} + +func cloneDocuments(in []domain.Document) []domain.Document { + out := make([]domain.Document, 0, len(in)) + for _, doc := range in { + out = append(out, cloneDocument(doc)) + } + return out +} diff --git a/internal/store/memory/poll.go b/internal/store/memory/poll.go new file mode 100644 index 00000000..aafcad6d --- /dev/null +++ b/internal/store/memory/poll.go @@ -0,0 +1,284 @@ +package memory + +import ( + "context" + "sort" + "sync" + "time" + + "telesrv/internal/domain" +) + +// PollStore 是 store.PollStore 的内存实现,同时充当 MessageStore/ChannelStore 投票与 +// 读路径 enrichment 的共享权威(postgres 侧对应 polls / poll_votes 两张表)。 +// 校验与门控复用 domain.ValidatePollVote / ResolvePollResults,保证与 postgres 行为一致。 +type PollStore struct { + mu sync.RWMutex + polls map[int64]domain.PollDefinition + votes map[int64]map[int64]domain.PollVote +} + +// NewPollStore 创建内存 poll 权威存储。 +func NewPollStore() *PollStore { + return &PollStore{ + polls: make(map[int64]domain.PollDefinition), + votes: make(map[int64]map[int64]domain.PollVote), + } +} + +func (s *PollStore) CreatePoll(_ context.Context, def domain.PollDefinition) error { + if def.ID == 0 || len(def.Options) == 0 { + return domain.ErrPollInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.polls[def.ID]; exists { + return domain.ErrPollInvalid + } + s.polls[def.ID] = clonePollDefinition(def) + return nil +} + +func (s *PollStore) GetPollDefinition(_ context.Context, pollID int64) (domain.PollDefinition, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + def, ok := s.polls[pollID] + if !ok { + return domain.PollDefinition{}, false, nil + } + return clonePollDefinition(def), true, nil +} + +func (s *PollStore) ListPollVotes(_ context.Context, req domain.PollVotesListRequest) (domain.PollVotesList, error) { + if req.PollID == 0 || req.Limit <= 0 { + return domain.PollVotesList{}, domain.ErrPollInvalid + } + s.mu.RLock() + defer s.mu.RUnlock() + if _, ok := s.polls[req.PollID]; !ok { + return domain.PollVotesList{}, domain.ErrPollNotFound + } + rows := make([]domain.PollVote, 0, len(s.votes[req.PollID])) + for _, vote := range s.votes[req.PollID] { + if len(req.Option) > 0 && !voteHasOption(vote, req.Option) { + continue + } + rows = append(rows, clonePollVote(vote)) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Date != rows[j].Date { + return rows[i].Date > rows[j].Date + } + return rows[i].UserID > rows[j].UserID + }) + out := domain.PollVotesList{Count: len(rows)} + start := 0 + if req.OffsetDate > 0 || req.OffsetUserID > 0 { + for i, row := range rows { + if row.Date < req.OffsetDate || (row.Date == req.OffsetDate && row.UserID < req.OffsetUserID) { + start = i + break + } + start = i + 1 + } + } + end := start + req.Limit + if end > len(rows) { + end = len(rows) + } + if start < end { + out.Votes = rows[start:end] + } + out.HasMore = end < len(rows) + return out, nil +} + +// Vote 校验并落一票(options 为空 = 撤票)。校验逻辑全部来自 domain.ValidatePollVote。 +func (s *PollStore) Vote(pollID, userID int64, options [][]byte, date int) error { + if pollID == 0 || userID == 0 { + return domain.ErrPollNotFound + } + if date == 0 { + date = int(time.Now().Unix()) + } + s.mu.Lock() + defer s.mu.Unlock() + def, ok := s.polls[pollID] + if !ok { + return domain.ErrPollNotFound + } + var existing [][]byte + if vote, voted := s.votes[pollID][userID]; voted { + existing = vote.Options + } + if err := domain.ValidatePollVote(def, existing, options, date); err != nil { + return err + } + if len(options) == 0 { + delete(s.votes[pollID], userID) + return nil + } + if s.votes[pollID] == nil { + s.votes[pollID] = make(map[int64]domain.PollVote) + } + s.votes[pollID][userID] = clonePollVote(domain.PollVote{PollID: pollID, UserID: userID, Options: options, Date: date}) + return nil +} + +// Close 关闭 poll;仅创建者可关,重复关闭幂等。 +func (s *PollStore) Close(pollID, byUserID int64) error { + if pollID == 0 || byUserID == 0 { + return domain.ErrPollNotFound + } + s.mu.Lock() + defer s.mu.Unlock() + def, ok := s.polls[pollID] + if !ok { + return domain.ErrPollNotFound + } + if def.CreatorUserID != byUserID { + return domain.ErrPollNotCreator + } + def.Closed = true + s.polls[pollID] = def + return nil +} + +// EnrichPoll 把权威态 + viewer 视角聚合写回 media 定义快照;poll 不存在时保持快照原样。 +func (s *PollStore) EnrichPoll(poll *domain.MessagePoll, viewerUserID int64, now int) { + if s == nil || poll == nil || poll.ID == 0 { + return + } + s.mu.RLock() + defer s.mu.RUnlock() + def, ok := s.polls[poll.ID] + if !ok { + return + } + agg := domain.PollAggregates{Counts: make(map[string]int)} + recent := make([]domain.PollVote, 0, len(s.votes[poll.ID])) + for userID, vote := range s.votes[poll.ID] { + agg.TotalVoters++ + for _, option := range vote.Options { + agg.Counts[string(option)]++ + } + if userID == viewerUserID { + agg.ViewerOptions = append([][]byte(nil), vote.Options...) + } + recent = append(recent, vote) + } + sort.Slice(recent, func(i, j int) bool { + if recent[i].Date != recent[j].Date { + return recent[i].Date > recent[j].Date + } + return recent[i].UserID > recent[j].UserID + }) + for i, vote := range recent { + if i >= domain.MaxPollRecentVoters { + break + } + agg.RecentVoters = append(agg.RecentVoters, vote.UserID) + } + results := domain.ResolvePollResults(def, agg, viewerUserID, now) + domain.ApplyPollState(poll, def, results, now) +} + +// EnrichPollForViewers 批量为一组 viewer 返回 per-viewer enrich 的 poll(fan-out 模板化): +// viewer-invariant 聚合(counts/total/recent)只遍历一次 + per-viewer ViewerOptions,每 viewer 用与 +// 单 viewer EnrichPoll 完全相同的 ResolvePollResults/ApplyPollState 合成(字节同源)。返回 map[viewer] +// 各自的 poll 克隆;def 不存在时返回空 map(与 EnrichPoll 的 no-op 一致)。 +func (s *PollStore) EnrichPollForViewers(basePoll *domain.MessagePoll, viewers []int64, now int) map[int64]*domain.MessagePoll { + out := make(map[int64]*domain.MessagePoll, len(viewers)) + if s == nil || basePoll == nil || basePoll.ID == 0 || len(viewers) == 0 { + return out + } + s.mu.RLock() + defer s.mu.RUnlock() + def, ok := s.polls[basePoll.ID] + if !ok { + return out + } + counts := make(map[string]int) + total := 0 + optionsByUser := make(map[int64][][]byte, len(s.votes[basePoll.ID])) + recent := make([]domain.PollVote, 0, len(s.votes[basePoll.ID])) + for userID, vote := range s.votes[basePoll.ID] { + total++ + for _, option := range vote.Options { + counts[string(option)]++ + } + optionsByUser[userID] = append([][]byte(nil), vote.Options...) + recent = append(recent, vote) + } + sort.Slice(recent, func(i, j int) bool { + if recent[i].Date != recent[j].Date { + return recent[i].Date > recent[j].Date + } + return recent[i].UserID > recent[j].UserID + }) + recentVoters := make([]int64, 0, domain.MaxPollRecentVoters) + for i, vote := range recent { + if i >= domain.MaxPollRecentVoters { + break + } + recentVoters = append(recentVoters, vote.UserID) + } + for _, viewer := range viewers { + agg := domain.PollAggregates{ + Counts: counts, + TotalVoters: total, + RecentVoters: recentVoters, + ViewerOptions: optionsByUser[viewer], + } + results := domain.ResolvePollResults(def, agg, viewer, now) + pollCopy := *basePoll + domain.ApplyPollState(&pollCopy, def, results, now) + out[viewer] = &pollCopy + } + return out +} + +// enrichPollMediaForViewer 克隆 media(避免共享指针把 viewer 态写进 store 本体)并 enrich。 +func enrichPollMediaForViewer(polls *PollStore, media *domain.MessageMedia, viewerUserID int64, now int) *domain.MessageMedia { + if polls == nil || media == nil || media.Kind != domain.MessageMediaKindPoll || media.Poll == nil { + return media + } + cloned := *media + poll := *media.Poll + poll.Answers = append([]domain.MessagePollAnswer(nil), media.Poll.Answers...) + cloned.Poll = &poll + polls.EnrichPoll(cloned.Poll, viewerUserID, now) + return &cloned +} + +func clonePollDefinition(def domain.PollDefinition) domain.PollDefinition { + def.Options = cloneOptionList(def.Options) + def.CorrectOptions = cloneOptionList(def.CorrectOptions) + def.SolutionEntities = append([]domain.MessageEntity(nil), def.SolutionEntities...) + return def +} + +func clonePollVote(vote domain.PollVote) domain.PollVote { + vote.Options = cloneOptionList(vote.Options) + return vote +} + +func cloneOptionList(options [][]byte) [][]byte { + if options == nil { + return nil + } + out := make([][]byte, 0, len(options)) + for _, option := range options { + out = append(out, append([]byte(nil), option...)) + } + return out +} + +func voteHasOption(vote domain.PollVote, option []byte) bool { + for _, candidate := range vote.Options { + if string(candidate) == string(option) { + return true + } + } + return false +} diff --git a/internal/store/memory/saved_dialog.go b/internal/store/memory/saved_dialog.go new file mode 100644 index 00000000..952a68d8 --- /dev/null +++ b/internal/store/memory/saved_dialog.go @@ -0,0 +1,270 @@ +package memory + +import ( + "context" + "fmt" + "sort" + "time" + + "telesrv/internal/domain" +) + +// savedDialogTopsLocked 聚合 self-chat 按 saved_peer 分组的 top message。 +// 返回按 top box id 降序。 +func (s *MessageStore) savedDialogTopsLocked(userID int64) []domain.Message { + tops := make(map[domain.Peer]domain.Message) + selfPeer := domain.Peer{Type: domain.PeerTypeUser, ID: userID} + for _, msg := range s.m[userID] { + if msg.Peer != selfPeer || msg.SavedPeer.ID == 0 { + continue + } + if cur, ok := tops[msg.SavedPeer]; !ok || msg.ID > cur.ID { + tops[msg.SavedPeer] = msg + } + } + out := make([]domain.Message, 0, len(tops)) + for _, msg := range tops { + out = append(out, msg) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + return out +} + +func (s *MessageStore) savedPinIndexLocked(userID int64, peer domain.Peer) int { + for i, p := range s.savedPins[userID] { + if p == peer { + return i + } + } + return -1 +} + +func (s *MessageStore) appendSavedDialogLocked(out *domain.SavedDialogList, msg domain.Message, pinned bool) { + out.Dialogs = append(out.Dialogs, domain.SavedDialog{ + Peer: msg.SavedPeer, + TopMessage: msg.ID, + Pinned: pinned, + }) + out.Messages = append(out.Messages, cloneMessage(msg)) +} + +func (s *MessageStore) ListSavedDialogs(_ context.Context, userID int64, filter domain.SavedDialogsFilter) (domain.SavedDialogList, error) { + out := domain.SavedDialogList{} + if userID == 0 { + return out, fmt.Errorf("list saved dialogs: missing user id") + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxSavedDialogsLimit { + limit = domain.MaxSavedDialogsLimit + } + offsetID := filter.OffsetID + // DrKLO Android 首页发 offset_id = MaxMessageBoxID(int32 max),用 >= 命中首页 + // 分支,避免被误判为续页跳过置顶块(与 postgres 对齐)。 + firstPage := offsetID <= 0 || offsetID >= domain.MaxMessageBoxID + s.mu.RLock() + defer s.mu.RUnlock() + tops := s.savedDialogTopsLocked(userID) + topByPeer := make(map[domain.Peer]domain.Message, len(tops)) + for _, msg := range tops { + topByPeer[msg.SavedPeer] = msg + } + // 首页且不排除置顶:置顶块按 pinned_order 在前。 + if firstPage && !filter.ExcludePinned { + for _, peer := range s.savedPins[userID] { + if len(out.Dialogs) >= limit { + break + } + if msg, ok := topByPeer[peer]; ok { + s.appendSavedDialogLocked(&out, msg, true) + } + } + } + // 普通块恒排除置顶(置顶只随首页返回)。 + hasMore := false + for _, msg := range tops { + if len(out.Dialogs) >= limit { + if s.savedPinIndexLocked(userID, msg.SavedPeer) < 0 && + (firstPage || msg.ID < offsetID) { + hasMore = true + } + continue + } + if s.savedPinIndexLocked(userID, msg.SavedPeer) >= 0 { + continue + } + if !firstPage && msg.ID >= offsetID { + continue + } + s.appendSavedDialogLocked(&out, msg, false) + } + total := 0 + for _, msg := range tops { + if filter.ExcludePinned && s.savedPinIndexLocked(userID, msg.SavedPeer) >= 0 { + continue + } + total++ + } + out.Count = total + out.Full = !hasMore + return out, nil +} + +func (s *MessageStore) ListPinnedSavedDialogs(_ context.Context, userID int64) (domain.SavedDialogList, error) { + out := domain.SavedDialogList{Full: true} + if userID == 0 { + return out, fmt.Errorf("list pinned saved dialogs: missing user id") + } + s.mu.RLock() + defer s.mu.RUnlock() + tops := s.savedDialogTopsLocked(userID) + topByPeer := make(map[domain.Peer]domain.Message, len(tops)) + for _, msg := range tops { + topByPeer[msg.SavedPeer] = msg + } + for _, peer := range s.savedPins[userID] { + if msg, ok := topByPeer[peer]; ok { + s.appendSavedDialogLocked(&out, msg, true) + } + } + out.Count = len(out.Dialogs) + return out, nil +} + +func (s *MessageStore) ListSavedDialogsByPeers(_ context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error) { + out := domain.SavedDialogList{Full: true} + if userID == 0 { + return out, fmt.Errorf("list saved dialogs by peers: missing user id") + } + s.mu.RLock() + defer s.mu.RUnlock() + tops := s.savedDialogTopsLocked(userID) + topByPeer := make(map[domain.Peer]domain.Message, len(tops)) + for _, msg := range tops { + topByPeer[msg.SavedPeer] = msg + } + seen := make(map[domain.Peer]struct{}, len(peers)) + for _, peer := range peers { + if peer.Type == "" || peer.ID == 0 { + continue + } + if _, ok := seen[peer]; ok { + continue + } + seen[peer] = struct{}{} + if msg, ok := topByPeer[peer]; ok { + s.appendSavedDialogLocked(&out, msg, s.savedPinIndexLocked(userID, peer) >= 0) + } + } + out.Count = len(out.Dialogs) + return out, nil +} + +func (s *MessageStore) ToggleSavedDialogPin(_ context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) { + if userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, fmt.Errorf("toggle saved dialog pin: invalid input") + } + s.mu.Lock() + defer s.mu.Unlock() + idx := s.savedPinIndexLocked(userID, peer) + if !pinned { + if idx < 0 { + return false, nil + } + s.savedPins[userID] = append(s.savedPins[userID][:idx], s.savedPins[userID][idx+1:]...) + return true, nil + } + if idx >= 0 { + return false, nil + } + if len(s.savedPins[userID]) >= domain.MaxPinnedSavedDialogs { + return false, domain.ErrPinnedSavedDialogsTooMuch + } + s.savedPins[userID] = append([]domain.Peer{peer}, s.savedPins[userID]...) + return true, nil +} + +func (s *MessageStore) ReorderPinnedSavedDialogs(_ context.Context, userID int64, order []domain.Peer, force bool) error { + if userID == 0 { + return fmt.Errorf("reorder pinned saved dialogs: missing user id") + } + if len(order) > domain.MaxPinnedSavedDialogs { + return domain.ErrPinnedSavedDialogsTooMuch + } + s.mu.Lock() + defer s.mu.Unlock() + next := make([]domain.Peer, 0, len(order)) + seen := make(map[domain.Peer]struct{}, len(order)) + for _, peer := range order { + if peer.Type == "" || peer.ID == 0 { + continue + } + if _, ok := seen[peer]; ok { + continue + } + seen[peer] = struct{}{} + next = append(next, peer) + } + if !force { + // 非 force:order 之外的既有置顶保持原相对顺序排在后面。 + for _, peer := range s.savedPins[userID] { + if _, ok := seen[peer]; !ok { + next = append(next, peer) + } + } + } + s.savedPins[userID] = next + return nil +} + +func (s *MessageStore) DeleteSavedHistory(_ context.Context, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) { + res := domain.DeleteSavedHistoryResult{} + if req.OwnerUserID == 0 || req.SavedPeer.Type == "" || req.SavedPeer.ID == 0 { + return res, fmt.Errorf("delete saved history: invalid input") + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + selfPeer := domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID} + s.mu.Lock() + defer s.mu.Unlock() + match := func(msg domain.Message) bool { + if msg.Peer != selfPeer || msg.SavedPeer != req.SavedPeer { + return false + } + if req.MaxID > 0 && msg.ID > req.MaxID { + return false + } + if req.MinDate > 0 && msg.Date < req.MinDate { + return false + } + if req.MaxDate > 0 && msg.Date > req.MaxDate { + return false + } + return true + } + deleted, _, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, match) + delRes := s.finishMemoryDeleteLocked(domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}, deleted, req.Date, false) + res.More = more + for _, d := range delRes.Deleted { + if d.UserID == req.OwnerUserID { + res.MessageIDs = d.MessageIDs + res.Event = d.Event + } + } + if len(deleted) > 0 && !more { + // 子会话删空时清掉它的置顶行(与 PG 实现同语义)。 + alive := false + for _, msg := range s.m[req.OwnerUserID] { + if msg.Peer == selfPeer && msg.SavedPeer == req.SavedPeer { + alive = true + break + } + } + if !alive { + if idx := s.savedPinIndexLocked(req.OwnerUserID, req.SavedPeer); idx >= 0 { + s.savedPins[req.OwnerUserID] = append(s.savedPins[req.OwnerUserID][:idx], s.savedPins[req.OwnerUserID][idx+1:]...) + } + } + } + return res, nil +} diff --git a/internal/store/memory/secretchat.go b/internal/store/memory/secretchat.go new file mode 100644 index 00000000..569a5dea --- /dev/null +++ b/internal/store/memory/secretchat.go @@ -0,0 +1,296 @@ +package memory + +import ( + "context" + "sort" + "sync" + + "telesrv/internal/domain" +) + +// SecretChatStore 是 store.SecretChatStore 的进程内实现(rpc/app 单测 fixture 用)。 +// 行为契约与 postgres 实现由 storetest 共享 contract test 钉死;凡动握手态迁移 +// 语义两边必须同步。 +type SecretChatStore struct { + mu sync.Mutex + chats map[int]domain.SecretChat +} + +// NewSecretChatStore 创建内存实现。 +func NewSecretChatStore() *SecretChatStore { + return &SecretChatStore{chats: make(map[int]domain.SecretChat)} +} + +func cloneSecretChat(c domain.SecretChat) domain.SecretChat { + c.GA = append([]byte(nil), c.GA...) + c.GB = append([]byte(nil), c.GB...) + return c +} + +func (s *SecretChatStore) CreateSecretChat(_ context.Context, chat domain.SecretChat) error { + if chat.ID == 0 { + return domain.ErrSecretChatNotFound + } + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.chats[chat.ID]; exists { + return domain.ErrSecretChatIDConflict + } + if chat.State == "" { + chat.State = domain.SecretChatStateRequested + } + s.chats[chat.ID] = cloneSecretChat(chat) + return nil +} + +func (s *SecretChatStore) GetSecretChat(_ context.Context, chatID int) (domain.SecretChat, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.chats[chatID] + if !ok { + return domain.SecretChat{}, false, nil + } + return cloneSecretChat(c), true, nil +} + +func (s *SecretChatStore) GetByAdminRandom(_ context.Context, adminAuthKeyID int64, randomID int32) (domain.SecretChat, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + // 仅返回非终态匹配(与部分唯一索引 WHERE state <> 'discarded' 一致)。 + for _, c := range s.chats { + if c.AdminAuthKeyID == adminAuthKeyID && c.RandomID == randomID && !c.Terminal() { + return cloneSecretChat(c), true, nil + } + } + return domain.SecretChat{}, false, nil +} + +func (s *SecretChatStore) AcceptSecretChat(_ context.Context, chatID int, participantAuthKeyID int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.chats[chatID] + if !ok { + return domain.SecretChat{}, domain.ErrSecretChatNotFound + } + switch c.State { + case domain.SecretChatStateNormal: + return domain.SecretChat{}, domain.ErrSecretChatAlreadyAccepted + case domain.SecretChatStateDiscarded: + return domain.SecretChat{}, domain.ErrSecretChatAlreadyDeclined + } + // requested 且未绑定接受设备:CAS 成功。 + if c.ParticipantAuthKeyID != 0 { + return domain.SecretChat{}, domain.ErrSecretChatAlreadyAccepted + } + c.State = domain.SecretChatStateNormal + c.ParticipantAuthKeyID = participantAuthKeyID + c.GB = append([]byte(nil), gb...) + c.KeyFingerprint = keyFingerprint + s.chats[chatID] = c + return cloneSecretChat(c), nil +} + +func (s *SecretChatStore) DiscardSecretChat(_ context.Context, chatID int, historyDeleted bool) (domain.SecretChat, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.chats[chatID] + if !ok { + return domain.SecretChat{}, false, domain.ErrSecretChatNotFound + } + if c.State == domain.SecretChatStateDiscarded { + return cloneSecretChat(c), true, nil + } + c.State = domain.SecretChatStateDiscarded + c.HistoryDeleted = historyDeleted + s.chats[chatID] = c + return cloneSecretChat(c), false, nil +} + +func (s *SecretChatStore) ListActiveSecretChatsByAuthKey(_ context.Context, authKeyID int64) ([]domain.SecretChat, error) { + if authKeyID == 0 { + return nil, nil + } + s.mu.Lock() + defer s.mu.Unlock() + var out []domain.SecretChat + for _, c := range s.chats { + if c.Terminal() { + continue + } + if c.AdminAuthKeyID == authKeyID || c.ParticipantAuthKeyID == authKeyID { + out = append(out, cloneSecretChat(c)) + } + } + // map 遍历无序:按 chat_id 升序与 postgres ORDER BY 对齐,确定性供测试断言。 + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (s *SecretChatStore) MaxSecretChatID(_ context.Context) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + max := 0 + for id := range s.chats { + if id > max { + max = id + } + } + return max, nil +} + +// EncryptedQueueStore 是 store.EncryptedQueueStore 的进程内实现。 +type EncryptedQueueStore struct { + mu sync.Mutex + byDevice map[int64][]domain.SecretChatMessage // receiverAuthKeyID → qts 升序消息 + reserved map[int64]int + confirmed map[int64]int + dedup map[emqDedupKey]int // → qts + stateEvents []domain.EncryptedStateEvent + delivered map[int64]map[int64]bool // eventID → deviceAuthKeyID → true + nextEventID int64 + files map[int64]domain.EncryptedFileRef // file id → 快照 +} + +type emqDedupKey struct { + receiver int64 + chat int + random int64 +} + +// NewEncryptedQueueStore 创建内存实现。 +func NewEncryptedQueueStore() *EncryptedQueueStore { + return &EncryptedQueueStore{ + byDevice: make(map[int64][]domain.SecretChatMessage), + reserved: make(map[int64]int), + confirmed: make(map[int64]int), + dedup: make(map[emqDedupKey]int), + delivered: make(map[int64]map[int64]bool), + } +} + +func cloneSecretMessage(m domain.SecretChatMessage) domain.SecretChatMessage { + m.Bytes = append([]byte(nil), m.Bytes...) + if m.File != nil { + f := *m.File + m.File = &f + } + return m +} + +func (s *EncryptedQueueStore) AppendEncryptedMessage(_ context.Context, msg domain.SecretChatMessage) (domain.SecretChatMessage, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + key := emqDedupKey{msg.ReceiverAuthKeyID, msg.ChatID, msg.RandomID} + if qts, ok := s.dedup[key]; ok { + for _, m := range s.byDevice[msg.ReceiverAuthKeyID] { + if m.Qts == qts { + return cloneSecretMessage(m), true, nil + } + } + } + s.reserved[msg.ReceiverAuthKeyID]++ + msg.Qts = s.reserved[msg.ReceiverAuthKeyID] + stored := cloneSecretMessage(msg) + s.byDevice[msg.ReceiverAuthKeyID] = append(s.byDevice[msg.ReceiverAuthKeyID], stored) + s.dedup[key] = msg.Qts + return cloneSecretMessage(stored), false, nil +} + +func (s *EncryptedQueueStore) ListEncryptedMessagesSince(_ context.Context, receiverAuthKeyID int64, sinceQts, limit int) ([]domain.SecretChatMessage, error) { + s.mu.Lock() + defer s.mu.Unlock() + if limit <= 0 { + limit = 1000 + } + var out []domain.SecretChatMessage + for _, m := range s.byDevice[receiverAuthKeyID] { + if m.Qts > sinceQts { + out = append(out, cloneSecretMessage(m)) + if len(out) >= limit { + break + } + } + } + return out, nil +} + +func (s *EncryptedQueueStore) ReservedQts(_ context.Context, receiverAuthKeyID int64) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.reserved[receiverAuthKeyID], nil +} + +func (s *EncryptedQueueStore) AckEncryptedMessages(_ context.Context, receiverAuthKeyID int64, maxQts int) error { + s.mu.Lock() + defer s.mu.Unlock() + if maxQts > s.confirmed[receiverAuthKeyID] { + s.confirmed[receiverAuthKeyID] = maxQts + } + return nil +} + +func (s *EncryptedQueueStore) AppendStateEvent(_ context.Context, ev domain.EncryptedStateEvent) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.nextEventID++ + ev.ID = s.nextEventID + s.stateEvents = append(s.stateEvents, ev) + return ev.ID, nil +} + +func (s *EncryptedQueueStore) ListUndeliveredStateEvents(_ context.Context, targetUserID, deviceAuthKeyID int64, limit int) ([]domain.EncryptedStateEvent, error) { + s.mu.Lock() + defer s.mu.Unlock() + if limit <= 0 { + limit = 1000 + } + var out []domain.EncryptedStateEvent + for _, ev := range s.stateEvents { + if ev.TargetUserID != targetUserID { + continue + } + if ev.TargetAuthKeyID != 0 && ev.TargetAuthKeyID != deviceAuthKeyID { + continue + } + if s.delivered[ev.ID][deviceAuthKeyID] { + continue + } + out = append(out, ev) + if len(out) >= limit { + break + } + } + return out, nil +} + +func (s *EncryptedQueueStore) MarkStateEventsDelivered(_ context.Context, deviceAuthKeyID int64, eventIDs []int64) error { + s.mu.Lock() + defer s.mu.Unlock() + for _, id := range eventIDs { + if s.delivered[id] == nil { + s.delivered[id] = make(map[int64]bool) + } + s.delivered[id][deviceAuthKeyID] = true + } + return nil +} + +func (s *EncryptedQueueStore) PutEncryptedFile(_ context.Context, _ int64, ref domain.EncryptedFileRef) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.files == nil { + s.files = make(map[int64]domain.EncryptedFileRef) + } + s.files[ref.ID] = ref + return nil +} + +func (s *EncryptedQueueStore) GetEncryptedFile(_ context.Context, id, accessHash int64) (domain.EncryptedFileRef, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + ref, ok := s.files[id] + if !ok || ref.AccessHash != accessHash { + return domain.EncryptedFileRef{}, false, nil + } + return ref, true, nil +} diff --git a/internal/store/memory/star_gift.go b/internal/store/memory/star_gift.go new file mode 100644 index 00000000..0ef0b47b --- /dev/null +++ b/internal/store/memory/star_gift.go @@ -0,0 +1,178 @@ +package memory + +import ( + "context" + "sort" + "sync" + + "telesrv/internal/domain" +) + +// StarGiftStore 是 store.StarGiftStore 的内存实现。 +type StarGiftStore struct { + mu sync.Mutex + nextID int64 + gifts []domain.SavedStarGift // 追加序 +} + +// NewStarGiftStore 创建内存 StarGiftStore。 +func NewStarGiftStore() *StarGiftStore { + return &StarGiftStore{} +} + +func (s *StarGiftStore) Create(_ context.Context, gift domain.SavedStarGift) (int64, error) { + if !validSavedStarGift(gift) { + return 0, domain.ErrStarGiftInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + s.nextID++ + gift.ID = s.nextID + if gift.Owner.Type == domain.PeerTypeChannel && gift.SavedID == 0 { + gift.SavedID = gift.ID + } + gift.Converted = false + s.gifts = append(s.gifts, gift) + return gift.ID, nil +} + +func (s *StarGiftStore) ListByOwner(_ context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) { + if !validStarGiftOwner(owner) { + return domain.SavedStarGiftPage{}, nil + } + if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit { + limit = domain.MaxSavedStarGiftsLimit + } + s.mu.Lock() + defer s.mu.Unlock() + matched := make([]domain.SavedStarGift, 0) + for _, g := range s.gifts { + if g.Owner != owner || g.Converted { + continue + } + if excludeUnsaved && g.Unsaved { + continue + } + matched = append(matched, g) + } + sort.Slice(matched, func(i, j int) bool { return matched[i].ID > matched[j].ID }) + page := domain.SavedStarGiftPage{Count: len(matched)} + cursor, hasCursor := domain.DecodeStarGiftCursor(offset) + out := make([]domain.SavedStarGift, 0, limit) + for _, g := range matched { + if hasCursor && g.ID >= cursor { + continue + } + out = append(out, g) + if len(out) == limit { + break + } + } + if len(out) == limit { + // 还有更早的则给下一页游标。 + last := out[len(out)-1].ID + for _, g := range matched { + if g.ID < last { + page.NextOffset = domain.EncodeStarGiftCursor(last) + break + } + } + } + page.Gifts = out + return page, nil +} + +func (s *StarGiftStore) GetByRef(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) { + if !ref.Valid() { + return domain.SavedStarGift{}, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + for _, g := range s.gifts { + if savedStarGiftMatchesRef(g, ref) { + return g, true, nil + } + } + return domain.SavedStarGift{}, false, nil +} + +func (s *StarGiftStore) CountByOwner(_ context.Context, owner domain.Peer) (int, error) { + if !validStarGiftOwner(owner) { + return 0, nil + } + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for _, g := range s.gifts { + if g.Owner == owner && !g.Converted && !g.Unsaved { + n++ + } + } + return n, nil +} + +func (s *StarGiftStore) SetUnsaved(_ context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) { + if !ref.Valid() { + return false, domain.ErrStarGiftNotFound + } + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.gifts { + if savedStarGiftMatchesRef(s.gifts[i], ref) && !s.gifts[i].Converted { + s.gifts[i].Unsaved = unsaved + return true, nil + } + } + return false, nil +} + +func (s *StarGiftStore) MarkConverted(_ context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { + if !ref.Valid() { + return domain.SavedStarGift{}, domain.ErrStarGiftNotFound + } + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.gifts { + if savedStarGiftMatchesRef(s.gifts[i], ref) { + if s.gifts[i].Converted { + return domain.SavedStarGift{}, domain.ErrStarGiftAlreadyConverted + } + s.gifts[i].Converted = true + s.gifts[i].Unsaved = true + return s.gifts[i], nil + } + } + return domain.SavedStarGift{}, domain.ErrStarGiftNotFound +} + +func validSavedStarGift(g domain.SavedStarGift) bool { + if g.GiftID == 0 || !validStarGiftOwner(g.Owner) { + return false + } + switch g.Owner.Type { + case domain.PeerTypeUser: + return g.MsgID > 0 && g.SavedID == 0 + case domain.PeerTypeChannel: + return g.MsgID == 0 && g.SavedID >= 0 + default: + return false + } +} + +func validStarGiftOwner(owner domain.Peer) bool { + return owner.ID != 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel) +} + +func savedStarGiftMatchesRef(g domain.SavedStarGift, ref domain.SavedStarGiftRef) bool { + if g.Owner != ref.Owner { + return false + } + switch ref.Owner.Type { + case domain.PeerTypeUser: + return g.MsgID == ref.MsgID + case domain.PeerTypeChannel: + return g.SavedID == ref.SavedID + default: + return false + } +} diff --git a/internal/store/memory/stars.go b/internal/store/memory/stars.go new file mode 100644 index 00000000..2cb001de --- /dev/null +++ b/internal/store/memory/stars.go @@ -0,0 +1,143 @@ +package memory + +import ( + "context" + "sync" + + "telesrv/internal/domain" +) + +// StarsStore 是 store.StarsStore 的内存实现,复刻 postgres 版的原子语义 +// (在单个互斥锁下完成读-检查-写,等价于 SELECT ... FOR UPDATE)。 +type StarsStore struct { + mu sync.Mutex + states map[int64]*starsState + nextID int64 +} + +type starsState struct { + balance int64 + granted bool + txns []domain.StarsTransaction // 追加序,读时倒序 +} + +// NewStarsStore 创建内存 StarsStore。 +func NewStarsStore() *StarsStore { + return &StarsStore{states: make(map[int64]*starsState)} +} + +func (s *StarsStore) GetBalance(_ context.Context, userID int64) (domain.StarsBalance, error) { + if userID == 0 { + return domain.StarsBalance{}, nil + } + s.mu.Lock() + defer s.mu.Unlock() + st := s.states[userID] + if st == nil { + return domain.StarsBalance{UserID: userID}, nil + } + return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil +} + +func (s *StarsStore) EnsureGrant(_ context.Context, userID, amount int64, date int) (domain.StarsBalance, bool, error) { + if userID == 0 { + return domain.StarsBalance{}, false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + st := s.states[userID] + if st == nil { + st = &starsState{} + s.states[userID] = st + } + if amount <= 0 { + return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, false, nil + } + if st.granted { + return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: true}, false, nil + } + st.balance += amount + st.granted = true + s.appendTxn(st, userID, amount, domain.StarsReasonGrant, domain.Peer{}, date, "", "") + return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: true}, true, nil +} + +func (s *StarsStore) Credit(_ context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) { + if userID == 0 || amount <= 0 { + return domain.StarsBalance{}, domain.ErrStarsInvalidAmount + } + s.mu.Lock() + defer s.mu.Unlock() + st := s.states[userID] + if st == nil { + st = &starsState{} + s.states[userID] = st + } + st.balance += amount + s.appendTxn(st, userID, amount, reason, peer, date, title, desc) + return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil +} + +func (s *StarsStore) Debit(_ context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) { + if userID == 0 || amount <= 0 { + return domain.StarsBalance{}, domain.ErrStarsInvalidAmount + } + s.mu.Lock() + defer s.mu.Unlock() + st := s.states[userID] + if st == nil || st.balance < amount { + return domain.StarsBalance{}, domain.ErrStarsInsufficient + } + st.balance -= amount + s.appendTxn(st, userID, -amount, reason, peer, date, title, desc) + return domain.StarsBalance{UserID: userID, Balance: st.balance, Granted: st.granted}, nil +} + +func (s *StarsStore) ListTransactions(_ context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) { + if userID == 0 { + return domain.StarsTransactionPage{}, nil + } + if limit <= 0 || limit > domain.MaxStarsTransactionsLimit { + limit = domain.MaxStarsTransactionsLimit + } + s.mu.Lock() + defer s.mu.Unlock() + st := s.states[userID] + if st == nil { + return domain.StarsTransactionPage{}, nil + } + page := domain.StarsTransactionPage{Balance: st.balance} + cursor, hasCursor := domain.DecodeStarsCursor(offset) + // 倒序遍历(id DESC)。 + out := make([]domain.StarsTransaction, 0, limit) + for i := len(st.txns) - 1; i >= 0; i-- { + t := st.txns[i] + if hasCursor && t.ID >= cursor { + continue + } + out = append(out, t) + if len(out) == limit { + // 还有更早的流水则给出下一页游标。 + if i-1 >= 0 { + page.NextOffset = domain.EncodeStarsCursor(t.ID) + } + break + } + } + page.Transactions = out + return page, nil +} + +func (s *StarsStore) appendTxn(st *starsState, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) { + s.nextID++ + st.txns = append(st.txns, domain.StarsTransaction{ + ID: s.nextID, + UserID: userID, + Peer: peer, + Amount: amount, + Date: date, + Reason: reason, + Title: title, + Description: desc, + }) +} diff --git a/internal/store/memory/story.go b/internal/store/memory/story.go new file mode 100644 index 00000000..945b3450 --- /dev/null +++ b/internal/store/memory/story.go @@ -0,0 +1,1723 @@ +package memory + +import ( + "context" + "reflect" + "sort" + "strconv" + "strings" + "sync" + + "telesrv/internal/domain" +) + +// StoryStore is the in-memory implementation of store.StoryStore. +type StoryStore struct { + mu sync.RWMutex + stories map[storyKey]domain.Story + read map[storyReadKey]domain.StoryReadState + views map[storyViewKey]domain.StoryView + exposures map[storyViewKey]int + hidden map[storyHiddenKey]bool + profiles map[int64]domain.User + contacts map[int64]map[int64]domain.Contact + blocked map[int64]map[int64]bool + channels *ChannelStore + members map[int64]map[int64]bool +} + +type storyKey struct { + peerType domain.PeerType + peerID int64 + storyID int +} + +type storyReadKey struct { + viewerID int64 + peerType domain.PeerType + peerID int64 +} + +type storyViewKey struct { + peerType domain.PeerType + peerID int64 + storyID int + viewerID int64 +} + +type storyHiddenKey struct { + viewerID int64 + peerType domain.PeerType + peerID int64 +} + +// NewStoryStore creates an empty StoryStore. +func NewStoryStore(channels ...*ChannelStore) *StoryStore { + var channelStore *ChannelStore + if len(channels) > 0 { + channelStore = channels[0] + } + return &StoryStore{ + stories: make(map[storyKey]domain.Story), + read: make(map[storyReadKey]domain.StoryReadState), + views: make(map[storyViewKey]domain.StoryView), + exposures: make(map[storyViewKey]int), + hidden: make(map[storyHiddenKey]bool), + profiles: make(map[int64]domain.User), + contacts: make(map[int64]map[int64]domain.Contact), + blocked: make(map[int64]map[int64]bool), + channels: channelStore, + members: make(map[int64]map[int64]bool), + } +} + +// SetStoryViewerProfiles seeds owner-visible user metadata used by +// ListStoryViews q filtering in the memory store. +func (s *StoryStore) SetStoryViewerProfiles(users ...domain.User) { + s.mu.Lock() + defer s.mu.Unlock() + if s.profiles == nil { + s.profiles = make(map[int64]domain.User) + } + for _, user := range users { + if user.ID == 0 { + continue + } + s.profiles[user.ID] = user + } +} + +// SetStoryViewerContacts seeds owner-scoped contact metadata used by +// ListStoryViews just_contacts and q filtering in the memory store. +func (s *StoryStore) SetStoryViewerContacts(ownerUserID int64, contacts ...domain.Contact) { + s.mu.Lock() + defer s.mu.Unlock() + if s.contacts == nil { + s.contacts = make(map[int64]map[int64]domain.Contact) + } + if _, ok := s.contacts[ownerUserID]; !ok { + s.contacts[ownerUserID] = make(map[int64]domain.Contact) + } + for _, contact := range contacts { + if contact.User.ID == 0 { + continue + } + s.contacts[ownerUserID][contact.User.ID] = contact + } +} + +// SetStoryBlockedUsers seeds owner-scoped story blocklist membership used by +// memory-store story visibility tests. +func (s *StoryStore) SetStoryBlockedUsers(ownerUserID int64, viewerUserIDs ...int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.blocked == nil { + s.blocked = make(map[int64]map[int64]bool) + } + if len(viewerUserIDs) == 0 { + delete(s.blocked, ownerUserID) + return + } + blocked := make(map[int64]bool, len(viewerUserIDs)) + for _, viewerUserID := range viewerUserIDs { + if viewerUserID == 0 || viewerUserID == ownerUserID { + continue + } + blocked[viewerUserID] = true + } + if len(blocked) == 0 { + delete(s.blocked, ownerUserID) + return + } + s.blocked[ownerUserID] = blocked +} + +// SetStoryChannelMembers seeds active channel story viewers for memory-store +// story visibility tests when no ChannelStore is attached. +func (s *StoryStore) SetStoryChannelMembers(channelID int64, viewerUserIDs ...int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.members == nil { + s.members = make(map[int64]map[int64]bool) + } + members := make(map[int64]bool, len(viewerUserIDs)) + for _, viewerUserID := range viewerUserIDs { + if viewerUserID == 0 { + continue + } + members[viewerUserID] = true + } + s.members[channelID] = members +} + +func (s *StoryStore) CreateStory(_ context.Context, req domain.StoryCreateRequest) (domain.StoryCreateResult, error) { + if err := validateStoryPeer(req.Owner); err != nil { + return domain.StoryCreateResult{}, err + } + if req.RandomID == 0 { + return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + for _, existing := range s.stories { + if existing.Owner == req.Owner && existing.RandomID == req.RandomID { + return domain.StoryCreateResult{Story: cloneStory(existing), Duplicate: true}, nil + } + } + nextID := 1 + for _, existing := range s.stories { + if existing.Owner == req.Owner && existing.ID >= nextID { + nextID = existing.ID + 1 + } + } + if nextID <= 0 || nextID > domain.MaxStoryID { + return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid + } + story := domain.Story{ + Owner: req.Owner, + ID: nextID, + RandomID: req.RandomID, + Date: req.Date, + ExpireDate: req.Date + req.Period, + Pinned: req.Pinned, + Public: req.Public, + CloseFriends: req.CloseFriends, + Contacts: req.Contacts, + SelectedContacts: req.SelectedContacts, + NoForwards: req.NoForwards, + Out: true, + PrivacyRules: cloneStoryPrivacyRules(req.PrivacyRules), + AllowUserIDs: append([]int64(nil), req.AllowUserIDs...), + DisallowUserIDs: append([]int64(nil), req.DisallowUserIDs...), + Caption: req.Caption, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + MediaAreas: cloneStoryMediaAreas(req.MediaAreas), + Forward: cloneStoryForward(req.Forward), + } + s.stories[keyForStory(story.Owner, story.ID)] = story + if story.Owner.Type == domain.PeerTypeUser { + s.read[storyReadKey{viewerID: story.Owner.ID, peerType: story.Owner.Type, peerID: story.Owner.ID}] = domain.StoryReadState{ + ViewerID: story.Owner.ID, + Peer: story.Owner, + MaxReadID: story.ID, + Date: story.Date, + } + } + return domain.StoryCreateResult{Story: cloneStory(story)}, nil +} + +func (s *StoryStore) UpsertStory(_ context.Context, req domain.UpsertStoryRequest) (domain.Story, error) { + story := cloneStory(req.Story) + if err := validateStoryIdentity(story.Owner, story.ID); err != nil { + return domain.Story{}, err + } + s.mu.Lock() + s.stories[keyForStory(story.Owner, story.ID)] = story + s.mu.Unlock() + return cloneStory(story), nil +} + +func (s *StoryStore) ListActiveStories(ctx context.Context, viewerUserID int64, hidden bool, now, limit int) (domain.StoryList, error) { + return s.ListActiveStoriesPage(ctx, viewerUserID, hidden, now, domain.StoryListCursor{}, limit) +} + +func (s *StoryStore) ListActiveStoriesPage(_ context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) (domain.StoryList, error) { + if limit <= 0 || limit > domain.MaxStoryListLimit { + limit = domain.MaxStoryListLimit + } + pageItems, reads := s.activeStoryPeerPagesForViewer(viewerUserID, hidden, now) + total := len(pageItems) + if cursor.Set { + pageItems = activeStoryPeerPagesAfter(pageItems, cursor) + } + hasMore := len(pageItems) > limit + if hasMore { + pageItems = pageItems[:limit] + } + peers := activeStoryPeerStories(pageItems, reads) + stories := make([]domain.Story, 0) + for _, item := range pageItems { + stories = append(stories, item.stories...) + } + s.recordStoryExposures(viewerUserID, stories) + return domain.StoryList{ + Count: total, + HasMore: hasMore, + Stories: stories, + Peers: peers, + }, nil +} + +func (s *StoryStore) ActiveStoriesDigest(_ context.Context, viewerUserID int64, hidden bool, now int) (domain.StoryListDigest, error) { + pageItems, reads := s.activeStoryPeerPagesForViewer(viewerUserID, hidden, now) + return domain.DigestStoryPeerList(activeStoryPeerStories(pageItems, reads)), nil +} + +func (s *StoryStore) activeStoryPeerPagesForViewer(viewerUserID int64, hidden bool, now int) ([]activeStoryPeerPage, []domain.StoryReadState) { + s.mu.RLock() + byPeer := make(map[domain.Peer][]domain.Story) + for _, story := range s.stories { + if !story.Active(now) || !s.storyVisibleLocked(story, viewerUserID) { + continue + } + isHidden := s.hidden[storyHiddenKey{viewerID: viewerUserID, peerType: story.Owner.Type, peerID: story.Owner.ID}] + if hidden != isHidden { + continue + } + byPeer[story.Owner] = append(byPeer[story.Owner], cloneStory(story)) + } + for peer := range byPeer { + s.populateStoryForwardCountsLocked(byPeer[peer], viewerUserID) + } + reads := cloneReadStatesLocked(s.read, viewerUserID) + s.mu.RUnlock() + pageItems := make([]activeStoryPeerPage, 0, len(byPeer)) + for peer, stories := range byPeer { + sortStoriesOldestFirst(stories) + pageItems = append(pageItems, activeStoryPeerPage{ + peer: peer, + maxDate: latestStoryDate(stories), + stories: stories, + }) + } + sortActiveStoryPeerPages(pageItems) + return pageItems, reads +} + +func (s *StoryStore) ListOwnerActiveStories(_ context.Context, owner domain.Peer, now, limit int) (domain.StoryList, error) { + if err := validateStoryPeer(owner); err != nil { + return domain.StoryList{}, err + } + if limit <= 0 || limit > domain.MaxStoryListLimit { + limit = domain.MaxStoryListLimit + } + s.mu.RLock() + stories := make([]domain.Story, 0, len(s.stories)) + for _, story := range s.stories { + if story.Owner != owner || !story.Active(now) { + continue + } + stories = append(stories, fanoutStorySnapshot(story)) + } + s.mu.RUnlock() + sortStoriesOldestFirst(stories) + if len(stories) > limit { + stories = stories[:limit] + } + return domain.StoryList{Count: len(stories), Stories: stories}, nil +} + +func (s *StoryStore) GetPeerStories(_ context.Context, viewerUserID int64, peer domain.Peer, now int) (domain.PeerStories, error) { + if err := validateStoryPeer(peer); err != nil { + return domain.PeerStories{}, err + } + s.mu.RLock() + var stories []domain.Story + for _, story := range s.stories { + if story.Owner != peer || !story.Active(now) || !s.storyVisibleLocked(story, viewerUserID) { + continue + } + stories = append(stories, cloneStory(story)) + } + s.populateStoryForwardCountsLocked(stories, viewerUserID) + read := s.read[storyReadKey{viewerID: viewerUserID, peerType: peer.Type, peerID: peer.ID}] + s.mu.RUnlock() + sortStoriesOldestFirst(stories) + s.recordStoryExposures(viewerUserID, stories) + return domain.PeerStories{Peer: peer, MaxReadID: read.MaxReadID, Stories: stories}, nil +} + +func (s *StoryStore) GetStoriesByID(_ context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error) { + if err := validateStoryPeer(peer); err != nil { + return domain.StoryList{}, err + } + ids, err := normalizeStoryIDListNonEmpty(ids) + if err != nil { + return domain.StoryList{}, err + } + out := make([]domain.Story, 0, len(ids)) + s.mu.RLock() + for _, id := range ids { + story, ok := s.stories[keyForStory(peer, id)] + if !ok || story.Deleted || !s.storyVisibleLocked(story, viewerUserID) { + continue + } + // Exact lookups may return expired pinned/profile stories; non-pinned expired + // stories are still returned as snapshots for share/reply resolution. + _ = now + item := cloneStory(story) + item.Out = item.Owner.Type == domain.PeerTypeUser && item.Owner.ID == viewerUserID + out = append(out, item) + } + s.populateStoryForwardCountsLocked(out, viewerUserID) + s.mu.RUnlock() + s.recordStoryExposures(viewerUserID, out) + return domain.StoryList{Count: len(out), Stories: out}, nil +} + +func (s *StoryStore) ListPinnedStories(_ context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) { + _ = now + if err := validateStoryPeer(peer); err != nil { + return domain.StoryList{}, err + } + if limit <= 0 || limit > domain.MaxStoryListLimit { + limit = domain.MaxStoryListLimit + } + s.mu.RLock() + out := make([]domain.Story, 0) + pinnedToTop := make([]domain.Story, 0) + total := 0 + for _, story := range s.stories { + if story.Owner != peer || story.Deleted || !story.Pinned || !s.storyVisibleLocked(story, viewerUserID) { + continue + } + total++ + if story.PinnedToTopOrder > 0 { + pinnedToTop = append(pinnedToTop, cloneStory(story)) + } + if offsetID > 0 && story.ID >= offsetID { + continue + } + out = append(out, cloneStory(story)) + } + s.populateStoryForwardCountsLocked(out, viewerUserID) + s.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { + if out[i].ID != out[j].ID { + return out[i].ID > out[j].ID + } + return out[i].Date > out[j].Date + }) + sort.Slice(pinnedToTop, func(i, j int) bool { + if pinnedToTop[i].PinnedToTopOrder != pinnedToTop[j].PinnedToTopOrder { + return pinnedToTop[i].PinnedToTopOrder < pinnedToTop[j].PinnedToTopOrder + } + return pinnedToTop[i].ID > pinnedToTop[j].ID + }) + topIDs := make([]int, 0, len(pinnedToTop)) + for _, story := range pinnedToTop { + topIDs = append(topIDs, story.ID) + } + if len(out) > limit { + out = out[:limit] + } + s.recordStoryExposures(viewerUserID, out) + return domain.StoryList{Count: total, Stories: out, PinnedToTop: topIDs}, nil +} + +func (s *StoryStore) HasPinnedStories(_ context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) { + _ = now + if err := validateStoryPeer(peer); err != nil { + return false, err + } + s.mu.RLock() + defer s.mu.RUnlock() + for _, story := range s.stories { + if story.Owner == peer && !story.Deleted && story.Pinned && s.storyVisibleLocked(story, viewerUserID) { + return true, nil + } + } + return false, nil +} + +func (s *StoryStore) ListStoriesArchive(_ context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) { + if err := validateStoryPeer(peer); err != nil { + return domain.StoryList{}, err + } + if limit < 0 || limit > domain.MaxStoryListLimit { + limit = domain.MaxStoryListLimit + } + if offsetID < 0 { + offsetID = 0 + } + s.mu.RLock() + total := 0 + out := make([]domain.Story, 0) + for _, story := range s.stories { + if story.Owner != peer || story.Deleted || story.ExpireDate > now { + continue + } + total++ + if limit == 0 || (offsetID > 0 && story.ID >= offsetID) { + continue + } + item := cloneStory(story) + item.Out = item.Owner.Type == domain.PeerTypeUser && item.Owner.ID == viewerUserID + out = append(out, item) + } + s.populateStoryForwardCountsLocked(out, viewerUserID) + s.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { + if out[i].ID != out[j].ID { + return out[i].ID > out[j].ID + } + return out[i].Date > out[j].Date + }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + s.recordStoryExposures(viewerUserID, out) + return domain.StoryList{Count: total, Stories: out}, nil +} + +func (s *StoryStore) ListReadStates(_ context.Context, viewerUserID int64) ([]domain.StoryReadState, error) { + s.mu.RLock() + out := cloneReadStatesLocked(s.read, viewerUserID) + s.mu.RUnlock() + sort.Slice(out, func(i, j int) bool { + if out[i].Peer.Type != out[j].Peer.Type { + return out[i].Peer.Type < out[j].Peer.Type + } + return out[i].Peer.ID < out[j].Peer.ID + }) + return out, nil +} + +func (s *StoryStore) GetPeerMaxIDs(_ context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) { + if len(peers) > domain.MaxStoryIDs { + return nil, domain.ErrStoryIDInvalid + } + out := make([]domain.RecentStory, len(peers)) + s.mu.RLock() + for i, peer := range peers { + if err := validateStoryPeer(peer); err != nil { + s.mu.RUnlock() + return nil, err + } + maxID := 0 + for _, story := range s.stories { + if story.Owner == peer && story.Active(now) && s.storyVisibleLocked(story, viewerUserID) && story.ID > maxID { + maxID = story.ID + } + } + out[i] = domain.RecentStory{Peer: peer, MaxID: maxID} + } + s.mu.RUnlock() + return out, nil +} + +func (s *StoryStore) GetPeerHiddenStates(_ context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) { + if len(peers) > domain.MaxStoryIDs { + return nil, domain.ErrStoryIDInvalid + } + if viewerUserID == 0 { + return nil, domain.ErrStoryPeerInvalid + } + out := make(map[domain.Peer]bool, len(peers)) + s.mu.RLock() + for _, peer := range peers { + if err := validateStoryPeer(peer); err != nil { + s.mu.RUnlock() + return nil, err + } + key := storyHiddenKey{viewerID: viewerUserID, peerType: peer.Type, peerID: peer.ID} + out[peer] = s.hidden[key] + } + s.mu.RUnlock() + return out, nil +} + +func (s *StoryStore) GetPeerStoryProjections(_ context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) { + if len(peers) > domain.MaxStoryIDs { + return nil, domain.ErrStoryIDInvalid + } + if viewerUserID == 0 { + return nil, domain.ErrStoryPeerInvalid + } + out := make([]domain.PeerStoryProjection, len(peers)) + s.mu.RLock() + for i, peer := range peers { + if err := validateStoryPeer(peer); err != nil { + s.mu.RUnlock() + return nil, err + } + maxID := 0 + for _, story := range s.stories { + if story.Owner == peer && story.Active(now) && s.storyVisibleLocked(story, viewerUserID) && story.ID > maxID { + maxID = story.ID + } + } + key := storyHiddenKey{viewerID: viewerUserID, peerType: peer.Type, peerID: peer.ID} + out[i] = domain.PeerStoryProjection{ + Peer: peer, + Recent: domain.RecentStory{Peer: peer, MaxID: maxID}, + Hidden: s.hidden[key], + } + } + s.mu.RUnlock() + return out, nil +} + +func (s *StoryStore) MarkRead(_ context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) { + if viewerUserID == 0 { + return domain.StoryReadResult{}, domain.ErrStoryPeerInvalid + } + if err := validateStoryIdentity(peer, maxID); err != nil { + return domain.StoryReadResult{}, err + } + key := storyReadKey{viewerID: viewerUserID, peerType: peer.Type, peerID: peer.ID} + s.mu.Lock() + state := s.read[key] + advanced := maxID > state.MaxReadID + if advanced { + state = domain.StoryReadState{ViewerID: viewerUserID, Peer: peer, MaxReadID: maxID, Date: date} + s.read[key] = state + } + s.mu.Unlock() + return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, MaxReadID: state.MaxReadID, Advanced: advanced, Date: date}, nil +} + +func (s *StoryStore) IncrementViews(_ context.Context, viewerUserID int64, peer domain.Peer, ids []int, date int) (int, error) { + if viewerUserID == 0 { + return 0, domain.ErrStoryPeerInvalid + } + if err := validateStoryPeer(peer); err != nil { + return 0, err + } + ids, err := normalizeStoryIDListNonEmpty(ids) + if err != nil { + return 0, err + } + if peer.IsSelfUser(viewerUserID) { + return 0, nil + } + created := 0 + s.mu.Lock() + for _, id := range ids { + story, ok := s.stories[keyForStory(peer, id)] + if !ok || !story.Interactable(date) || !s.storyVisibleLocked(story, viewerUserID) { + continue + } + key := storyViewKey{peerType: peer.Type, peerID: peer.ID, storyID: id, viewerID: viewerUserID} + if _, ok := s.views[key]; ok { + continue + } + s.views[key] = domain.StoryView{Owner: peer, StoryID: id, ViewerID: viewerUserID, Date: date} + story.Views.ViewsCount++ + story.Views.HasViewers = true + story.Views.RecentViewers = prependRecentViewer(story.Views.RecentViewers, viewerUserID) + s.stories[keyForStory(peer, id)] = story + created++ + } + s.mu.Unlock() + return created, nil +} + +func (s *StoryStore) SetReaction(_ context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction *domain.MessageReaction, date int) (domain.StoryReactionResult, error) { + if viewerUserID == 0 { + return domain.StoryReactionResult{}, domain.ErrStoryPeerInvalid + } + if err := validateStoryIdentity(peer, storyID); err != nil { + return domain.StoryReactionResult{}, err + } + if peer.IsSelfUser(viewerUserID) { + return domain.StoryReactionResult{}, domain.ErrStoryPeerInvalid + } + s.mu.Lock() + story, ok := s.stories[keyForStory(peer, storyID)] + if !ok || !story.Interactable(date) || !s.storyVisibleLocked(story, viewerUserID) { + s.mu.Unlock() + return domain.StoryReactionResult{}, domain.ErrStoryNotFound + } + key := storyViewKey{peerType: peer.Type, peerID: peer.ID, storyID: storyID, viewerID: viewerUserID} + view := s.views[key] + existingView := view.ViewerID != 0 + if view.ViewerID == 0 { + view = domain.StoryView{Owner: peer, StoryID: storyID, ViewerID: viewerUserID, Date: date} + story.Views.ViewsCount++ + story.Views.HasViewers = true + story.Views.RecentViewers = prependRecentViewer(story.Views.RecentViewers, viewerUserID) + } + changed := !sameReaction(view.Reaction, reaction) + if existingView && !changed { + story.SentReaction = cloneReactionPtr(reaction) + s.mu.Unlock() + return domain.StoryReactionResult{ + ViewerID: viewerUserID, + Peer: peer, + StoryID: storyID, + Reaction: cloneReactionPtr(reaction), + Story: cloneStory(story), + Changed: false, + Date: view.Date, + }, nil + } + if changed { + story.Views = adjustStoryReactionCounts(story.Views, view.Reaction, reaction) + } + view.Reaction = cloneReactionPtr(reaction) + view.Date = date + s.views[key] = view + story.SentReaction = cloneReactionPtr(reaction) + s.stories[keyForStory(peer, storyID)] = story + s.mu.Unlock() + return domain.StoryReactionResult{ + ViewerID: viewerUserID, + Peer: peer, + StoryID: storyID, + Reaction: cloneReactionPtr(reaction), + Story: cloneStory(story), + Changed: changed, + Date: date, + }, nil +} + +func (s *StoryStore) ListStoryViews(_ context.Context, req domain.StoryViewListRequest) (domain.StoryViewList, error) { + if req.ViewerUserID == 0 { + return domain.StoryViewList{}, domain.ErrStoryPeerInvalid + } + if err := validateStoryIdentity(req.Owner, req.StoryID); err != nil { + return domain.StoryViewList{}, err + } + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return domain.StoryViewList{}, err + } + limit := clampStoryInteractionLimit(req.Limit) + cursor := parseStoryInteractionCursor(req.Offset) + s.mu.RLock() + story, ok := s.stories[keyForStory(req.Owner, req.StoryID)] + if !ok || story.Deleted { + s.mu.RUnlock() + return domain.StoryViewList{}, domain.ErrStoryNotFound + } + views := s.storyViewsLocked(req.Owner, req.StoryID) + for i := range views { + views[i].BlockedMyStoriesFrom = s.storyViewerBlockedLocked(req.Owner, views[i].ViewerID) + } + reposts := s.storyRepostViewsLocked(req.Owner, req.StoryID, req.ViewerUserID) + profiles := cloneStoryViewerProfiles(s.profiles) + contacts := cloneStoryViewerContacts(s.contacts[req.ViewerUserID]) + s.mu.RUnlock() + filteredViews := filterStoryViewsForList(views, req, profiles, contacts) + display := filteredViews + if req.Query == "" && !req.JustContacts { + display = append(display, reposts...) + } + sortStoryViewsForList(display, req.ReactionsFirst, req.ForwardsFirst) + _, viewsCount, reactionsCount := storyViewCounts(views) + count := len(filteredViews) + if req.Query == "" && !req.JustContacts { + count += len(reposts) + } + if req.Query != "" || req.JustContacts { + allViews := s.storyViewsSnapshot(req.Owner, req.StoryID) + _, viewsCount, reactionsCount = storyViewCounts(allViews) + } + page, nextOffset := pageStoryViews(display, limit, cursor, req.ReactionsFirst, req.ForwardsFirst) + return domain.StoryViewList{ + Count: count, + ViewsCount: viewsCount, + ForwardsCount: len(reposts), + ReactionsCount: reactionsCount, + Views: page, + NextOffset: nextOffset, + }, nil +} + +func (s *StoryStore) storyViewsSnapshot(owner domain.Peer, storyID int) []domain.StoryView { + s.mu.RLock() + defer s.mu.RUnlock() + return s.storyViewsLocked(owner, storyID) +} + +func (s *StoryStore) ListStoryReactions(_ context.Context, req domain.StoryReactionListRequest) (domain.StoryReactionList, error) { + if req.ViewerUserID == 0 { + return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid + } + if err := validateStoryIdentity(req.Owner, req.StoryID); err != nil { + return domain.StoryReactionList{}, err + } + if err := domain.ValidateStoryReactionInteractionOffset(req.Offset, req.ForwardsFirst); err != nil { + return domain.StoryReactionList{}, err + } + limit := clampStoryInteractionLimit(req.Limit) + cursor := parseStoryInteractionCursor(req.Offset) + s.mu.RLock() + story, ok := s.stories[keyForStory(req.Owner, req.StoryID)] + if !ok || story.Deleted { + s.mu.RUnlock() + return domain.StoryReactionList{}, domain.ErrStoryNotFound + } + views := s.storyViewsLocked(req.Owner, req.StoryID) + reposts := []domain.StoryView(nil) + if req.Reaction == nil { + reposts = s.storyRepostViewsLocked(req.Owner, req.StoryID, req.ViewerUserID) + } + s.mu.RUnlock() + reactions := make([]domain.StoryView, 0, len(views)+len(reposts)) + for _, view := range views { + if view.Reaction == nil { + continue + } + if req.Reaction != nil && !sameReaction(view.Reaction, req.Reaction) { + continue + } + reactions = append(reactions, view) + } + reactions = append(reactions, reposts...) + sortStoryViewsForList(reactions, false, req.ForwardsFirst) + page, nextOffset := pageStoryViews(reactions, limit, cursor, false, req.ForwardsFirst) + return domain.StoryReactionList{ + Count: len(reactions), + Reactions: page, + NextOffset: nextOffset, + }, nil +} + +func (s *StoryStore) ListStoryPublicForwards(_ context.Context, req domain.StoryPublicForwardListRequest) (domain.StoryPublicForwardList, error) { + if req.ViewerUserID == 0 { + return domain.StoryPublicForwardList{}, domain.ErrStoryPeerInvalid + } + if err := validateStoryIdentity(req.Owner, req.StoryID); err != nil { + return domain.StoryPublicForwardList{}, err + } + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return domain.StoryPublicForwardList{}, err + } + limit := clampStoryInteractionLimit(req.Limit) + cursor := parseStoryInteractionCursor(req.Offset) + s.mu.RLock() + story, ok := s.stories[keyForStory(req.Owner, req.StoryID)] + if !ok || story.Deleted { + s.mu.RUnlock() + return domain.StoryPublicForwardList{}, domain.ErrStoryNotFound + } + reposts := s.storyRepostViewsLocked(req.Owner, req.StoryID, req.ViewerUserID) + s.mu.RUnlock() + sortStoryViewsForList(reposts, false, true) + page, nextOffset := pageStoryViews(reposts, limit, cursor, false, true) + return domain.StoryPublicForwardList{Count: len(reposts), Forwards: page, NextOffset: nextOffset}, nil +} + +func (s *StoryStore) ListStoryViewerIDs(_ context.Context, owner domain.Peer, storyID, limit int) ([]int64, error) { + if err := validateStoryIdentity(owner, storyID); err != nil { + return nil, err + } + limit = clampStoryPrivacyFanoutLimit(limit) + s.mu.RLock() + if _, ok := s.stories[keyForStory(owner, storyID)]; !ok { + s.mu.RUnlock() + return nil, domain.ErrStoryNotFound + } + ids := make([]int64, 0) + seen := make(map[int64]struct{}) + for key := range s.views { + if key.peerType != owner.Type || key.peerID != owner.ID || key.storyID != storyID || key.viewerID == 0 { + continue + } + if _, ok := seen[key.viewerID]; ok { + continue + } + seen[key.viewerID] = struct{}{} + ids = append(ids, key.viewerID) + } + for key := range s.exposures { + if key.peerType != owner.Type || key.peerID != owner.ID || key.storyID != storyID || key.viewerID == 0 { + continue + } + if _, ok := seen[key.viewerID]; ok { + continue + } + seen[key.viewerID] = struct{}{} + ids = append(ids, key.viewerID) + } + s.mu.RUnlock() + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + if len(ids) > limit { + ids = ids[:limit] + } + return ids, nil +} + +func (s *StoryStore) EditStory(_ context.Context, req domain.StoryEditRequest) (domain.StoryEditResult, error) { + if err := validateStoryIdentity(req.Owner, req.ID); err != nil { + return domain.StoryEditResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + key := keyForStory(req.Owner, req.ID) + story, ok := s.stories[key] + if !ok || story.Deleted { + return domain.StoryEditResult{}, domain.ErrStoryNotFound + } + updated := cloneStory(story) + if req.UpdateMedia { + updated.Media = req.Media + } + if req.UpdateCaption { + updated.Caption = req.Caption + updated.Entities = append([]domain.MessageEntity(nil), req.Entities...) + } + if req.UpdatePrivacy { + updated.Public = req.Public + updated.CloseFriends = req.CloseFriends + updated.Contacts = req.Contacts + updated.SelectedContacts = req.SelectedContacts + updated.PrivacyRules = cloneStoryPrivacyRules(req.PrivacyRules) + updated.AllowUserIDs = append([]int64(nil), req.AllowUserIDs...) + updated.DisallowUserIDs = append([]int64(nil), req.DisallowUserIDs...) + } + if req.UpdateMediaAreas { + updated.MediaAreas = cloneStoryMediaAreas(req.MediaAreas) + } + if reflect.DeepEqual(story, updated) { + return domain.StoryEditResult{}, domain.ErrStoryNotModified + } + updated.Edited = true + s.stories[key] = updated + return domain.StoryEditResult{Story: cloneStory(updated), Previous: cloneStory(story)}, nil +} + +func (s *StoryStore) DeleteStories(_ context.Context, peer domain.Peer, ids []int, date int) (domain.StoryMutationResult, error) { + _ = date + if err := validateStoryPeer(peer); err != nil { + return domain.StoryMutationResult{}, err + } + ids, err := normalizeStoryIDListNonEmpty(ids) + if err != nil { + return domain.StoryMutationResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + out := domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)} + for _, id := range ids { + key := keyForStory(peer, id) + story, ok := s.stories[key] + if !ok { + continue + } + if story.Deleted { + continue + } + out.Previous = append(out.Previous, cloneStory(story)) + story.Deleted = true + story.Pinned = false + story.PinnedToTopOrder = 0 + s.stories[key] = story + out.Stories = append(out.Stories, cloneStory(story)) + } + return out, nil +} + +func (s *StoryStore) TogglePinned(_ context.Context, peer domain.Peer, ids []int, pinned bool, date int) (domain.StoryMutationResult, error) { + _ = date + if err := validateStoryPeer(peer); err != nil { + return domain.StoryMutationResult{}, err + } + ids, err := normalizeStoryIDList(ids) + if err != nil { + return domain.StoryMutationResult{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + out := domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...)} + for _, id := range ids { + key := keyForStory(peer, id) + story, ok := s.stories[key] + if !ok || story.Deleted { + continue + } + if story.Pinned == pinned { + continue + } + out.Previous = append(out.Previous, cloneStory(story)) + story.Pinned = pinned + if !pinned { + story.PinnedToTopOrder = 0 + } + s.stories[key] = story + out.Stories = append(out.Stories, cloneStory(story)) + } + return out, nil +} + +func (s *StoryStore) TogglePinnedToTop(_ context.Context, peer domain.Peer, ids []int) error { + if err := validateStoryPeer(peer); err != nil { + return err + } + ids, err := normalizeStoryPinnedToTopIDs(ids) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + for _, id := range ids { + story, ok := s.stories[keyForStory(peer, id)] + if !ok || story.Deleted || !story.Pinned { + return domain.ErrStoryIDInvalid + } + } + for key, story := range s.stories { + if story.Owner == peer && story.PinnedToTopOrder != 0 { + story.PinnedToTopOrder = 0 + s.stories[key] = story + } + } + for i, id := range ids { + key := keyForStory(peer, id) + story := s.stories[key] + story.PinnedToTopOrder = i + 1 + s.stories[key] = story + } + return nil +} + +func (s *StoryStore) SetPeerHidden(_ context.Context, viewerUserID int64, peer domain.Peer, hidden bool) error { + if viewerUserID == 0 { + return domain.ErrStoryPeerInvalid + } + if err := validateStoryPeer(peer); err != nil { + return err + } + key := storyHiddenKey{viewerID: viewerUserID, peerType: peer.Type, peerID: peer.ID} + s.mu.Lock() + if hidden { + s.hidden[key] = true + } else { + delete(s.hidden, key) + } + s.mu.Unlock() + return nil +} + +func validateStoryIdentity(peer domain.Peer, id int) error { + if err := validateStoryPeer(peer); err != nil { + return err + } + if id <= 0 || id > domain.MaxStoryID { + return domain.ErrStoryIDInvalid + } + return nil +} + +func validateStoryPeer(peer domain.Peer) error { + switch peer.Type { + case domain.PeerTypeUser, domain.PeerTypeChannel: + if peer.ID > 0 { + return nil + } + } + return domain.ErrStoryPeerInvalid +} + +func validateStoryIDList(ids []int) error { + if len(ids) > domain.MaxStoryIDs { + return domain.ErrStoryIDInvalid + } + for _, id := range ids { + if id <= 0 || id > domain.MaxStoryID { + return domain.ErrStoryIDInvalid + } + } + return nil +} + +func validateStoryIDListNonEmpty(ids []int) error { + if len(ids) == 0 { + return domain.ErrStoryIDInvalid + } + return validateStoryIDList(ids) +} + +func normalizeStoryIDList(ids []int) ([]int, error) { + if err := validateStoryIDList(ids); err != nil { + return nil, err + } + return normalizeStoryIDListUnchecked(ids), nil +} + +func normalizeStoryPinnedToTopIDs(ids []int) ([]int, error) { + ids, err := normalizeStoryIDList(ids) + if err != nil { + return nil, err + } + if len(ids) > domain.MaxStoryPinnedToTop { + return nil, domain.ErrStoryIDInvalid + } + return ids, nil +} + +func normalizeStoryIDListNonEmpty(ids []int) ([]int, error) { + if err := validateStoryIDListNonEmpty(ids); err != nil { + return nil, err + } + return normalizeStoryIDListUnchecked(ids), nil +} + +func normalizeStoryIDListUnchecked(ids []int) []int { + seen := make(map[int]struct{}, len(ids)) + out := make([]int, 0, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func (s *StoryStore) storyViewsLocked(peer domain.Peer, storyID int) []domain.StoryView { + out := make([]domain.StoryView, 0) + for key, view := range s.views { + if key.peerType != peer.Type || key.peerID != peer.ID || key.storyID != storyID { + continue + } + out = append(out, cloneStoryView(view)) + } + return out +} + +func (s *StoryStore) storyRepostViewsLocked(peer domain.Peer, storyID int, viewerUserID int64) []domain.StoryView { + out := make([]domain.StoryView, 0) + for _, repost := range s.stories { + if !s.storyCountsAsPublicRepostLocked(repost, peer, storyID, viewerUserID) { + continue + } + item := cloneStory(repost) + out = append(out, domain.StoryView{ + Owner: peer, + StoryID: storyID, + ViewerID: storyPeerCursorKey(item.Owner), + Date: item.Date, + Repost: &item, + }) + } + return out +} + +func (s *StoryStore) populateStoryForwardCountsLocked(stories []domain.Story, viewerUserID int64) { + for i := range stories { + stories[i].Views.ForwardsCount = s.storyForwardCountLocked(stories[i].Owner, stories[i].ID, viewerUserID) + } +} + +func (s *StoryStore) storyForwardCountLocked(peer domain.Peer, storyID int, viewerUserID int64) int { + count := 0 + for _, repost := range s.stories { + if s.storyCountsAsPublicRepostLocked(repost, peer, storyID, viewerUserID) { + count++ + } + } + return count +} + +func (s *StoryStore) storyCountsAsPublicRepostLocked(repost domain.Story, peer domain.Peer, storyID int, viewerUserID int64) bool { + if repost.Forward == nil || repost.Deleted || !repost.Public { + return false + } + if storyForwardSourcePeer(repost.Forward) != peer || repost.Forward.StoryID != storyID { + return false + } + return s.storyBaseVisibleLocked(repost, viewerUserID) +} + +func storyForwardSourcePeer(forward *domain.StoryForward) domain.Peer { + if forward == nil { + return domain.Peer{} + } + if forward.Source.ID != 0 { + return forward.Source + } + return forward.From +} + +func (s *StoryStore) recordStoryExposures(viewerUserID int64, stories []domain.Story) { + if viewerUserID == 0 || len(stories) == 0 { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.exposures == nil { + s.exposures = make(map[storyViewKey]int) + } + for _, story := range stories { + if story.ID <= 0 || story.Owner.ID == 0 { + continue + } + if story.Owner.Type == domain.PeerTypeUser && story.Owner.ID == viewerUserID { + continue + } + key := storyViewKey{ + peerType: story.Owner.Type, + peerID: story.Owner.ID, + storyID: story.ID, + viewerID: viewerUserID, + } + if previous, ok := s.exposures[key]; !ok || story.Date > previous { + s.exposures[key] = story.Date + } + } +} + +func (s *StoryStore) storyVisibleLocked(story domain.Story, viewerUserID int64) bool { + if story.Owner.Type == domain.PeerTypeChannel && !s.storyViewerChannelMemberLocked(story.Owner.ID, viewerUserID) { + return false + } + return s.storyBaseVisibleLocked(story, viewerUserID) +} + +func (s *StoryStore) storyBaseVisibleLocked(story domain.Story, viewerUserID int64) bool { + contact, viewerIsContact := s.storyViewerContactLocked(story.Owner, viewerUserID) + return story.VisibleToWithStoryFacts( + viewerUserID, + viewerIsContact, + contact.CloseFriend || contact.User.CloseFriend, + s.storyViewerBlockedLocked(story.Owner, viewerUserID), + ) +} + +func (s *StoryStore) storyViewerChannelMemberLocked(channelID, viewerUserID int64) bool { + if channelID == 0 || viewerUserID == 0 { + return false + } + if s.channels != nil { + s.channels.mu.RLock() + defer s.channels.mu.RUnlock() + channel, ok := s.channels.channels[channelID] + if !ok || channel.Deleted { + return false + } + member, ok := s.channels.members[channelID][viewerUserID] + return ok && member.Status == domain.ChannelMemberActive && !member.BannedRights.ViewMessages + } + if members, ok := s.members[channelID]; ok { + return members[viewerUserID] + } + return true +} + +func (s *StoryStore) storyViewerIsContactLocked(owner domain.Peer, viewerUserID int64) bool { + _, ok := s.storyViewerContactLocked(owner, viewerUserID) + return ok +} + +func (s *StoryStore) storyViewerContactLocked(owner domain.Peer, viewerUserID int64) (domain.Contact, bool) { + if owner.Type != domain.PeerTypeUser || owner.ID == 0 || viewerUserID == 0 { + return domain.Contact{}, false + } + contacts := s.contacts[owner.ID] + if contacts == nil { + return domain.Contact{}, false + } + contact, ok := contacts[viewerUserID] + return contact, ok +} + +func (s *StoryStore) storyViewerBlockedLocked(owner domain.Peer, viewerUserID int64) bool { + if owner.Type != domain.PeerTypeUser || owner.ID == 0 || viewerUserID == 0 || owner.ID == viewerUserID { + return false + } + blocked := s.blocked[owner.ID] + return blocked != nil && blocked[viewerUserID] +} + +func keyForStory(peer domain.Peer, id int) storyKey { + return storyKey{peerType: peer.Type, peerID: peer.ID, storyID: id} +} + +func cloneReadStatesLocked(read map[storyReadKey]domain.StoryReadState, viewerUserID int64) []domain.StoryReadState { + out := make([]domain.StoryReadState, 0, len(read)) + for _, state := range read { + if state.ViewerID == viewerUserID { + out = append(out, state) + } + } + return out +} + +func groupPeerStories(stories []domain.Story, reads []domain.StoryReadState) []domain.PeerStories { + readByPeer := make(map[domain.Peer]int, len(reads)) + for _, read := range reads { + readByPeer[read.Peer] = read.MaxReadID + } + index := make(map[domain.Peer]int) + out := make([]domain.PeerStories, 0) + for _, story := range stories { + i, ok := index[story.Owner] + if !ok { + i = len(out) + index[story.Owner] = i + out = append(out, domain.PeerStories{Peer: story.Owner, MaxReadID: readByPeer[story.Owner]}) + } + out[i].Stories = append(out[i].Stories, cloneStory(story)) + } + for i := range out { + sortStoriesOldestFirst(out[i].Stories) + } + return out +} + +type activeStoryPeerPage struct { + peer domain.Peer + maxDate int + stories []domain.Story +} + +func activeStoryPeerStories(items []activeStoryPeerPage, reads []domain.StoryReadState) []domain.PeerStories { + readByPeer := make(map[domain.Peer]int, len(reads)) + for _, read := range reads { + readByPeer[read.Peer] = read.MaxReadID + } + out := make([]domain.PeerStories, 0, len(items)) + for _, item := range items { + stories := make([]domain.Story, 0, len(item.stories)) + for _, story := range item.stories { + stories = append(stories, cloneStory(story)) + } + sortStoriesOldestFirst(stories) + out = append(out, domain.PeerStories{ + Peer: item.peer, + MaxReadID: readByPeer[item.peer], + Stories: stories, + }) + } + return out +} + +func sortActiveStoryPeerPages(items []activeStoryPeerPage) { + sort.Slice(items, func(i, j int) bool { + if items[i].maxDate != items[j].maxDate { + return items[i].maxDate > items[j].maxDate + } + if items[i].peer.Type != items[j].peer.Type { + return items[i].peer.Type < items[j].peer.Type + } + return items[i].peer.ID < items[j].peer.ID + }) +} + +func activeStoryPeerPagesAfter(items []activeStoryPeerPage, cursor domain.StoryListCursor) []activeStoryPeerPage { + for i, item := range items { + if activeStoryPeerPageAfter(item, cursor) { + return items[i:] + } + } + return nil +} + +func activeStoryPeerPageAfter(item activeStoryPeerPage, cursor domain.StoryListCursor) bool { + if !cursor.Set { + return true + } + if item.maxDate != cursor.Date { + return item.maxDate < cursor.Date + } + if item.peer.Type != cursor.Peer.Type { + return item.peer.Type > cursor.Peer.Type + } + return item.peer.ID > cursor.Peer.ID +} + +func latestStoryDate(stories []domain.Story) int { + maxDate := 0 + for _, story := range stories { + if story.Date > maxDate { + maxDate = story.Date + } + } + return maxDate +} + +func sortStoriesNewestFirst(stories []domain.Story) { + sort.Slice(stories, func(i, j int) bool { + if stories[i].Date != stories[j].Date { + return stories[i].Date > stories[j].Date + } + if stories[i].Owner != stories[j].Owner { + if stories[i].Owner.Type != stories[j].Owner.Type { + return stories[i].Owner.Type < stories[j].Owner.Type + } + return stories[i].Owner.ID < stories[j].Owner.ID + } + return stories[i].ID > stories[j].ID + }) +} + +func sortStoriesOldestFirst(stories []domain.Story) { + sort.Slice(stories, func(i, j int) bool { + if stories[i].ID != stories[j].ID { + return stories[i].ID < stories[j].ID + } + return stories[i].Date < stories[j].Date + }) +} + +func cloneStory(story domain.Story) domain.Story { + story.PrivacyRules = cloneStoryPrivacyRules(story.PrivacyRules) + story.AllowUserIDs = append([]int64(nil), story.AllowUserIDs...) + story.DisallowUserIDs = append([]int64(nil), story.DisallowUserIDs...) + story.Entities = append([]domain.MessageEntity(nil), story.Entities...) + story.MediaAreas = cloneStoryMediaAreas(story.MediaAreas) + story.Forward = cloneStoryForward(story.Forward) + story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...) + story.Views.RecentViewers = append([]int64(nil), story.Views.RecentViewers...) + story.SentReaction = cloneReactionPtr(story.SentReaction) + return story +} + +func cloneStoryForward(in *domain.StoryForward) *domain.StoryForward { + if in == nil { + return nil + } + out := *in + return &out +} + +func cloneStoryMediaAreas(in []domain.StoryMediaArea) []domain.StoryMediaArea { + if len(in) == 0 { + return nil + } + out := make([]domain.StoryMediaArea, len(in)) + for i, area := range in { + out[i] = area + out[i].Reaction = cloneReactionPtr(area.Reaction) + if area.Geo != nil { + geo := *area.Geo + out[i].Geo = &geo + } + if area.GeoAddress != nil { + address := *area.GeoAddress + out[i].GeoAddress = &address + } + if area.Venue != nil { + venue := *area.Venue + out[i].Venue = &venue + } + } + return out +} + +func fanoutStorySnapshot(story domain.Story) domain.Story { + story = cloneStory(story) + story.Out = false + story.Views = domain.StoryViews{} + story.SentReaction = nil + return story +} + +func cloneStoryPrivacyRules(in []domain.PrivacyRule) []domain.PrivacyRule { + if len(in) == 0 { + return nil + } + out := make([]domain.PrivacyRule, len(in)) + for i, rule := range in { + out[i] = rule + out[i].UserIDs = append([]int64(nil), rule.UserIDs...) + out[i].ChatIDs = append([]int64(nil), rule.ChatIDs...) + } + return out +} + +func cloneReactionPtr(in *domain.MessageReaction) *domain.MessageReaction { + if in == nil { + return nil + } + out := *in + return &out +} + +func cloneStoryView(view domain.StoryView) domain.StoryView { + view.Reaction = cloneReactionPtr(view.Reaction) + if view.Repost != nil { + repost := cloneStory(*view.Repost) + view.Repost = &repost + } + if view.PublicForward != nil { + forward := *view.PublicForward + forward.Message = cloneChannelMessage(forward.Message) + view.PublicForward = &forward + } + return view +} + +func sameReaction(a, b *domain.MessageReaction) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +type storyInteractionCursor struct { + set bool + group int + date int + viewerID int64 + messageID int +} + +func parseStoryInteractionCursor(offset string) storyInteractionCursor { + if offset == "" { + return storyInteractionCursor{} + } + parts := strings.Split(offset, ":") + if len(parts) != 3 && len(parts) != 4 { + return storyInteractionCursor{} + } + group, err1 := strconv.Atoi(parts[0]) + date, err2 := strconv.Atoi(parts[1]) + viewerID, err3 := strconv.ParseInt(parts[2], 10, 64) + var messageID int + var err4 error + if len(parts) == 4 { + messageID, err4 = strconv.Atoi(parts[3]) + } + if err1 != nil || err2 != nil || err3 != nil || err4 != nil || group < 0 || viewerID == 0 || messageID < 0 { + return storyInteractionCursor{} + } + return storyInteractionCursor{set: true, group: group, date: date, viewerID: viewerID, messageID: messageID} +} + +func formatStoryInteractionCursor(view domain.StoryView, reactionsFirst, forwardsFirst bool) string { + group := storyViewSortGroup(view, reactionsFirst, forwardsFirst) + out := strconv.Itoa(group) + ":" + strconv.Itoa(view.Date) + ":" + strconv.FormatInt(storyViewCursorKey(view), 10) + if id := storyViewCursorMessageID(view); id > 0 { + out += ":" + strconv.Itoa(id) + } + return out +} + +func storyViewSortGroup(view domain.StoryView, reactionsFirst, forwardsFirst bool) int { + if forwardsFirst { + if view.Repost != nil || view.PublicForward != nil { + return 0 + } + return 1 + } + if reactionsFirst && view.Reaction == nil && view.Repost == nil && view.PublicForward == nil { + return 1 + } + return 0 +} + +func storyViewCursorKey(view domain.StoryView) int64 { + if view.PublicForward != nil { + return storyPeerCursorKey(domain.Peer{Type: domain.PeerTypeChannel, ID: view.PublicForward.Message.ChannelID}) + } + if view.Repost != nil { + return storyPeerCursorKey(view.Repost.Owner) + } + return view.ViewerID +} + +func storyViewCursorMessageID(view domain.StoryView) int { + if view.PublicForward != nil { + return view.PublicForward.Message.ID + } + return 0 +} + +func storyPeerCursorKey(peer domain.Peer) int64 { + if peer.Type == domain.PeerTypeChannel { + return -peer.ID + } + return peer.ID +} + +func sortStoryViewsForList(views []domain.StoryView, reactionsFirst, forwardsFirst bool) { + sort.Slice(views, func(i, j int) bool { + gi := storyViewSortGroup(views[i], reactionsFirst, forwardsFirst) + gj := storyViewSortGroup(views[j], reactionsFirst, forwardsFirst) + if gi != gj { + return gi < gj + } + if views[i].Date != views[j].Date { + return views[i].Date > views[j].Date + } + if storyViewCursorKey(views[i]) != storyViewCursorKey(views[j]) { + return storyViewCursorKey(views[i]) > storyViewCursorKey(views[j]) + } + return storyViewCursorMessageID(views[i]) > storyViewCursorMessageID(views[j]) + }) +} + +func pageStoryViews(views []domain.StoryView, limit int, cursor storyInteractionCursor, reactionsFirst, forwardsFirst bool) ([]domain.StoryView, string) { + if limit <= 0 || limit > domain.MaxStoryInteractionListLimit { + limit = domain.MaxStoryInteractionListLimit + } + out := make([]domain.StoryView, 0, min(limit, len(views))) + for _, view := range views { + if !storyViewAfterCursor(view, cursor, reactionsFirst, forwardsFirst) { + continue + } + if len(out) == limit { + return out, formatStoryInteractionCursor(out[len(out)-1], reactionsFirst, forwardsFirst) + } + out = append(out, cloneStoryView(view)) + } + return out, "" +} + +func storyViewAfterCursor(view domain.StoryView, cursor storyInteractionCursor, reactionsFirst, forwardsFirst bool) bool { + if !cursor.set { + return true + } + group := storyViewSortGroup(view, reactionsFirst, forwardsFirst) + if group != cursor.group { + return group > cursor.group + } + if view.Date != cursor.date { + return view.Date < cursor.date + } + key := storyViewCursorKey(view) + if key != cursor.viewerID { + return key < cursor.viewerID + } + return storyViewCursorMessageID(view) < cursor.messageID +} + +func filterStoryViewsForList(views []domain.StoryView, req domain.StoryViewListRequest, profiles map[int64]domain.User, contacts map[int64]domain.Contact) []domain.StoryView { + query := strings.ToLower(strings.TrimSpace(req.Query)) + if query == "" && !req.JustContacts { + return views + } + out := make([]domain.StoryView, 0, len(views)) + for _, view := range views { + contact, isContact := contacts[view.ViewerID] + if req.JustContacts && !isContact { + continue + } + if query != "" && !storyViewerMatchesQuery(view.ViewerID, query, profiles[view.ViewerID], contact, isContact) { + continue + } + out = append(out, view) + } + return out +} + +func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User, contact domain.Contact, isContact bool) bool { + query = strings.TrimSpace(strings.ToLower(query)) + if query == "" { + return true + } + trimmedAt := strings.TrimPrefix(query, "@") + candidates := []string{ + profile.FirstName, + profile.LastName, + strings.TrimSpace(profile.FirstName + " " + profile.LastName), + profile.Username, + profile.Phone, + strconv.FormatInt(viewerID, 10), + } + if isContact { + candidates = append(candidates, + contact.FirstName, + contact.LastName, + strings.TrimSpace(contact.FirstName+" "+contact.LastName), + contact.Phone, + contact.User.FirstName, + contact.User.LastName, + strings.TrimSpace(contact.User.FirstName+" "+contact.User.LastName), + contact.User.Username, + contact.User.Phone, + ) + } + for _, candidate := range candidates { + value := strings.ToLower(strings.TrimSpace(candidate)) + if value == "" { + continue + } + if strings.Contains(value, query) || (trimmedAt != query && strings.Contains(value, trimmedAt)) { + return true + } + } + return false +} + +func cloneStoryViewerProfiles(in map[int64]domain.User) map[int64]domain.User { + if len(in) == 0 { + return nil + } + out := make(map[int64]domain.User, len(in)) + for id, user := range in { + out[id] = user + } + return out +} + +func cloneStoryViewerContacts(in map[int64]domain.Contact) map[int64]domain.Contact { + if len(in) == 0 { + return nil + } + out := make(map[int64]domain.Contact, len(in)) + for id, contact := range in { + out[id] = cloneContact(contact) + } + return out +} + +func storyViewCounts(views []domain.StoryView) (count, viewsCount, reactionsCount int) { + for _, view := range views { + if view.Repost != nil || view.ViewerID == 0 { + continue + } + count++ + viewsCount++ + if view.Reaction != nil { + reactionsCount++ + } + } + return count, viewsCount, reactionsCount +} + +func clampStoryInteractionLimit(limit int) int { + if limit <= 0 || limit > domain.MaxStoryInteractionListLimit { + return domain.MaxStoryInteractionListLimit + } + return limit +} + +func clampStoryPrivacyFanoutLimit(limit int) int { + if limit <= 0 || limit > domain.MaxStoryPrivacyFanoutTargets { + return domain.MaxStoryPrivacyFanoutTargets + } + return limit +} + +func adjustStoryReactionCounts(views domain.StoryViews, old, next *domain.MessageReaction) domain.StoryViews { + if old != nil { + for i := range views.Reactions { + if views.Reactions[i].Reaction == *old { + views.Reactions[i].Count-- + if views.Reactions[i].Count <= 0 { + views.Reactions = append(views.Reactions[:i], views.Reactions[i+1:]...) + } + break + } + } + if views.ReactionsCount > 0 { + views.ReactionsCount-- + } + } + if next != nil { + found := false + for i := range views.Reactions { + if views.Reactions[i].Reaction == *next { + views.Reactions[i].Count++ + found = true + break + } + } + if !found { + views.Reactions = append(views.Reactions, domain.ChannelMessageReactionCount{Reaction: *next, Count: 1, ChosenOrder: len(views.Reactions)}) + } + views.ReactionsCount++ + } + return views +} + +func prependRecentViewer(in []int64, viewerID int64) []int64 { + out := make([]int64, 0, min(3, len(in)+1)) + out = append(out, viewerID) + for _, id := range in { + if id == viewerID { + continue + } + out = append(out, id) + if len(out) == 3 { + break + } + } + return out +} diff --git a/internal/store/memory/story_test.go b/internal/store/memory/story_test.go new file mode 100644 index 00000000..705cfa95 --- /dev/null +++ b/internal/store/memory/story_test.go @@ -0,0 +1,2020 @@ +package memory + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func TestStoryStoreReadMaxAndPeerMaxIDs(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + for _, id := range []int{1, 2} { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: id, + Date: 100 + id, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %d: %v", id, err) + } + } + + read, err := store.MarkRead(ctx, 2001, owner, 2, 200) + if err != nil { + t.Fatalf("mark read: %v", err) + } + if !read.Advanced || read.MaxReadID != 2 { + t.Fatalf("read = %+v, want advanced max 2", read) + } + read, err = store.MarkRead(ctx, 2001, owner, 1, 201) + if err != nil { + t.Fatalf("mark read lower: %v", err) + } + if read.Advanced || read.MaxReadID != 2 { + t.Fatalf("lower read = %+v, want unchanged max 2", read) + } + + peerStories, err := store.GetPeerStories(ctx, 2001, owner, 300) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + if peerStories.MaxReadID != 2 || len(peerStories.Stories) != 2 { + t.Fatalf("peer stories = %+v, want max read 2 and two stories", peerStories) + } + recent, err := store.GetPeerMaxIDs(ctx, 2001, []domain.Peer{owner}, 300) + if err != nil { + t.Fatalf("peer max ids: %v", err) + } + if len(recent) != 1 || recent[0].MaxID != 2 { + t.Fatalf("recent = %+v, want max id 2", recent) + } + projections, err := store.GetPeerStoryProjections(ctx, 2001, []domain.Peer{owner}, 300) + if err != nil { + t.Fatalf("peer projections: %v", err) + } + if len(projections) != 1 || projections[0].Peer != owner || projections[0].Recent.MaxID != 2 || projections[0].Hidden { + t.Fatalf("projections = %+v, want max id 2 and visible owner", projections) + } +} + +func TestStoryStorePeerHiddenStates(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + viewerID := int64(2001) + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 3001} + + states, err := store.GetPeerHiddenStates(ctx, viewerID, []domain.Peer{owner, channel}) + if err != nil { + t.Fatalf("initial hidden states: %v", err) + } + if states[owner] || states[channel] { + t.Fatalf("initial hidden states = %+v, want both false", states) + } + if err := store.SetPeerHidden(ctx, viewerID, owner, true); err != nil { + t.Fatalf("set owner hidden: %v", err) + } + states, err = store.GetPeerHiddenStates(ctx, viewerID, []domain.Peer{owner, channel}) + if err != nil { + t.Fatalf("hidden states after set: %v", err) + } + if !states[owner] || states[channel] { + t.Fatalf("hidden states after set = %+v, want owner true channel false", states) + } + projections, err := store.GetPeerStoryProjections(ctx, viewerID, []domain.Peer{owner, channel}, 300) + if err != nil { + t.Fatalf("story projections after set: %v", err) + } + if len(projections) != 2 || !projections[0].Hidden || projections[1].Hidden { + t.Fatalf("story projections after set = %+v, want owner hidden channel visible", projections) + } + if err := store.SetPeerHidden(ctx, viewerID, owner, false); err != nil { + t.Fatalf("clear owner hidden: %v", err) + } + states, err = store.GetPeerHiddenStates(ctx, viewerID, []domain.Peer{owner}) + if err != nil { + t.Fatalf("hidden states after clear: %v", err) + } + if states[owner] { + t.Fatalf("hidden states after clear = %+v, want owner false", states) + } +} + +func TestStoryStoreListActiveStoriesPaginatesByPeer(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owners := []domain.Peer{ + {Type: domain.PeerTypeUser, ID: 1001}, + {Type: domain.PeerTypeUser, ID: 1002}, + {Type: domain.PeerTypeUser, ID: 1003}, + } + for i, owner := range owners { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 300 - i*100, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert owner %d story: %v", owner.ID, err) + } + } + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owners[0], + ID: 2, + Date: 250, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert second story for first owner: %v", err) + } + + first, err := store.ListActiveStoriesPage(ctx, 2001, false, 400, domain.StoryListCursor{}, 2) + if err != nil { + t.Fatalf("first page: %v", err) + } + if first.Count != 3 || !first.HasMore || len(first.Peers) != 2 { + t.Fatalf("first page = count %d more %v peers %d, want count 3 more true peers 2", first.Count, first.HasMore, len(first.Peers)) + } + if first.Peers[0].Peer != owners[0] || len(first.Peers[0].Stories) != 2 { + t.Fatalf("first peer = %+v stories %d, want owner 1001 with two stories", first.Peers[0].Peer, len(first.Peers[0].Stories)) + } + + next, err := store.ListActiveStoriesPage(ctx, 2001, false, 400, domain.StoryListCursor{ + Set: true, + Date: 200, + Peer: owners[1], + }, 2) + if err != nil { + t.Fatalf("next page: %v", err) + } + if next.Count != 3 || next.HasMore || len(next.Peers) != 1 || next.Peers[0].Peer != owners[2] { + t.Fatalf("next page = %+v, want final page with owner 1003", next) + } + digest, err := store.ActiveStoriesDigest(ctx, 2001, false, 400) + if err != nil { + t.Fatalf("digest active stories: %v", err) + } + if digest.Count != 3 { + t.Fatalf("digest count = %d, want 3", digest.Count) + } + if _, err := store.MarkRead(ctx, 2001, owners[0], 2, 401); err != nil { + t.Fatalf("mark read for digest: %v", err) + } + changed, err := store.ActiveStoriesDigest(ctx, 2001, false, 400) + if err != nil { + t.Fatalf("changed digest active stories: %v", err) + } + if changed.Hash == digest.Hash { + t.Fatalf("digest hash unchanged after read boundary: %#x", changed.Hash) + } +} + +func TestStoryStoreSelfUserViewAndReactionDoNotPolluteOwnerInteractions(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 100, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + + created, err := store.IncrementViews(ctx, owner.ID, owner, []int{1}, 200) + if err != nil { + t.Fatalf("increment self view: %v", err) + } + if created != 0 { + t.Fatalf("created self views = %d, want 0", created) + } + if _, err := store.SetReaction(ctx, owner.ID, owner, 1, &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "🔥", + }, 201); err != domain.ErrStoryPeerInvalid { + t.Fatalf("self reaction err = %v, want ErrStoryPeerInvalid", err) + } + list, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story views: %v", err) + } + if list.Count != 0 || list.ViewsCount != 0 || list.ReactionsCount != 0 || len(list.Views) != 0 { + t.Fatalf("self view list = %+v, want empty counters", list) + } +} + +func TestStoryStoreBlocklistHidesOwnerStoriesFromViewer(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + blockedViewerID := int64(2001) + otherViewerID := int64(2002) + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 100, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + if created, err := store.IncrementViews(ctx, blockedViewerID, owner, []int{1}, 105); err != nil || created != 1 { + t.Fatalf("pre-block increment = %d, %v, want 1 nil", created, err) + } + store.SetStoryBlockedUsers(owner.ID, blockedViewerID) + + peerStories, err := store.GetPeerStories(ctx, blockedViewerID, owner, 200) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + if len(peerStories.Stories) != 0 { + t.Fatalf("blocked peer stories = %+v, want empty", peerStories.Stories) + } + exact, err := store.GetStoriesByID(ctx, blockedViewerID, owner, []int{1}, 200) + if err != nil { + t.Fatalf("get stories by id: %v", err) + } + if len(exact.Stories) != 0 { + t.Fatalf("blocked exact stories = %+v, want empty", exact.Stories) + } + recent, err := store.GetPeerMaxIDs(ctx, blockedViewerID, []domain.Peer{owner}, 200) + if err != nil { + t.Fatalf("get peer max ids: %v", err) + } + if len(recent) != 1 || recent[0].MaxID != 0 { + t.Fatalf("blocked recent = %+v, want max id 0", recent) + } + active, err := store.ListActiveStories(ctx, blockedViewerID, false, 200, 100) + if err != nil { + t.Fatalf("list active stories: %v", err) + } + if active.Count != 0 || len(active.Stories) != 0 { + t.Fatalf("blocked active stories = %+v, want empty", active) + } + if created, err := store.IncrementViews(ctx, blockedViewerID, owner, []int{1}, 210); err != nil || created != 0 { + t.Fatalf("blocked increment = %d, %v, want 0 nil", created, err) + } + if _, err := store.SetReaction(ctx, blockedViewerID, owner, 1, &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "🔥", + }, 211); err != domain.ErrStoryNotFound { + t.Fatalf("blocked reaction err = %v, want ErrStoryNotFound", err) + } + + ownerStories, err := store.GetPeerStories(ctx, owner.ID, owner, 200) + if err != nil { + t.Fatalf("owner get peer stories: %v", err) + } + if len(ownerStories.Stories) != 1 { + t.Fatalf("owner peer stories = %+v, want one story", ownerStories.Stories) + } + otherStories, err := store.GetPeerStories(ctx, otherViewerID, owner, 200) + if err != nil { + t.Fatalf("other get peer stories: %v", err) + } + if len(otherStories.Stories) != 1 { + t.Fatalf("other peer stories = %+v, want one story", otherStories.Stories) + } + if created, err := store.IncrementViews(ctx, otherViewerID, owner, []int{1}, 220); err != nil || created != 1 { + t.Fatalf("other increment = %d, %v, want 1 nil", created, err) + } + list, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story views: %v", err) + } + got := map[int64]domain.StoryView{} + for _, view := range list.Views { + got[view.ViewerID] = view + } + if list.Count != 2 || len(list.Views) != 2 { + t.Fatalf("view list = %+v, want historical blocked viewer and other viewer", list) + } + if !got[blockedViewerID].BlockedMyStoriesFrom { + t.Fatalf("blocked viewer row = %+v, want blocked_my_stories_from", got[blockedViewerID]) + } + if got[otherViewerID].BlockedMyStoriesFrom { + t.Fatalf("other viewer row = %+v, want not blocked", got[otherViewerID]) + } +} + +func TestStoryStoreChannelStoriesRequireActiveMember(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + channel := domain.Peer{Type: domain.PeerTypeChannel, ID: 3001} + memberID := int64(2001) + outsiderID := int64(2002) + store.SetStoryChannelMembers(channel.ID, memberID) + + if _, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: channel, + RandomID: 300101, + Date: 100, + Period: 86400, + Public: true, + }); err != nil { + t.Fatalf("create channel story: %v", err) + } + + memberStories, err := store.GetPeerStories(ctx, memberID, channel, 200) + if err != nil { + t.Fatalf("member get peer stories: %v", err) + } + if len(memberStories.Stories) != 1 || memberStories.Stories[0].Owner != channel { + t.Fatalf("member stories = %+v, want channel story", memberStories.Stories) + } + outsiderStories, err := store.GetPeerStories(ctx, outsiderID, channel, 200) + if err != nil { + t.Fatalf("outsider get peer stories: %v", err) + } + if len(outsiderStories.Stories) != 0 { + t.Fatalf("outsider stories = %+v, want empty", outsiderStories.Stories) + } + exact, err := store.GetStoriesByID(ctx, outsiderID, channel, []int{1}, 200) + if err != nil { + t.Fatalf("outsider exact story: %v", err) + } + if len(exact.Stories) != 0 { + t.Fatalf("outsider exact = %+v, want empty", exact.Stories) + } + recent, err := store.GetPeerMaxIDs(ctx, outsiderID, []domain.Peer{channel}, 200) + if err != nil { + t.Fatalf("outsider peer max ids: %v", err) + } + if len(recent) != 1 || recent[0].MaxID != 0 { + t.Fatalf("outsider recent = %+v, want max id 0", recent) + } + active, err := store.ListActiveStories(ctx, outsiderID, false, 200, 100) + if err != nil { + t.Fatalf("outsider active stories: %v", err) + } + if active.Count != 0 || len(active.Stories) != 0 { + t.Fatalf("outsider active stories = %+v, want empty", active) + } + if created, err := store.IncrementViews(ctx, outsiderID, channel, []int{1}, 201); err != nil || created != 0 { + t.Fatalf("outsider increment = %d, %v, want 0 nil", created, err) + } + if _, err := store.SetReaction(ctx, outsiderID, channel, 1, &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "like", + }, 202); !errors.Is(err, domain.ErrStoryNotFound) { + t.Fatalf("outsider reaction err = %v, want ErrStoryNotFound", err) + } +} + +func TestStoryStoreMediaAreasRoundTripEditClearAndClone(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + created, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: owner, + RandomID: 101, + Date: 100, + Period: 900, + Public: true, + MediaAreas: []domain.StoryMediaArea{testDomainStoryMediaArea("🔥", 10)}, + }) + if err != nil { + t.Fatalf("create story: %v", err) + } + assertDomainStoryMediaArea(t, created.Story, "🔥", 10) + created.Story.MediaAreas[0].Reaction.Emoticon = "mutated" + + list, err := store.GetStoriesByID(ctx, owner.ID, owner, []int{created.Story.ID, created.Story.ID}, 200) + if err != nil { + t.Fatalf("get stories by id: %v", err) + } + if len(list.Stories) != 1 { + t.Fatalf("stories by duplicate ids = %+v, want one story", list.Stories) + } + assertDomainStoryMediaArea(t, list.Stories[0], "🔥", 10) + if _, err := store.GetStoriesByID(ctx, owner.ID, owner, nil, 200); !errors.Is(err, domain.ErrStoryIDInvalid) { + t.Fatalf("empty get stories by id err = %v, want ErrStoryIDInvalid", err) + } + + edited, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: owner, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testDomainStoryURLMediaArea("https://example.com/story/link", 25)}, + }) + if err != nil { + t.Fatalf("edit story media areas: %v", err) + } + assertDomainStoryURLMediaArea(t, edited.Story, "https://example.com/story/link", 25) + edited.Story.MediaAreas[0].URL = "https://mutated.invalid" + + list, err = store.GetStoriesByID(ctx, owner.ID, owner, []int{created.Story.ID}, 201) + if err != nil { + t.Fatalf("get edited story by id: %v", err) + } + assertDomainStoryURLMediaArea(t, list.Stories[0], "https://example.com/story/link", 25) + + geoEdited, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: owner, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testDomainStoryGeoPointMediaArea(31.2304, 121.4737, 35)}, + }) + if err != nil { + t.Fatalf("edit story geo media area: %v", err) + } + assertDomainStoryGeoPointMediaArea(t, geoEdited.Story, 31.2304, 121.4737, 35) + geoEdited.Story.MediaAreas[0].Geo.Lat = 0 + geoEdited.Story.MediaAreas[0].GeoAddress.City = "Mutated" + + list, err = store.GetStoriesByID(ctx, owner.ID, owner, []int{created.Story.ID}, 202) + if err != nil { + t.Fatalf("get geo edited story by id: %v", err) + } + assertDomainStoryGeoPointMediaArea(t, list.Stories[0], 31.2304, 121.4737, 35) + + venueEdited, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: owner, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testDomainStoryVenueMediaArea("Inline Cafe", 31.231, 121.474, 40)}, + }) + if err != nil { + t.Fatalf("edit story venue media area: %v", err) + } + assertDomainStoryVenueMediaArea(t, venueEdited.Story, "Inline Cafe", 31.231, 121.474, 40) + venueEdited.Story.MediaAreas[0].Venue.Title = "Mutated" + + list, err = store.GetStoriesByID(ctx, owner.ID, owner, []int{created.Story.ID}, 203) + if err != nil { + t.Fatalf("get venue edited story by id: %v", err) + } + assertDomainStoryVenueMediaArea(t, list.Stories[0], "Inline Cafe", 31.231, 121.474, 40) + + weatherEdited, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: owner, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testDomainStoryWeatherMediaArea("☀️", 22.5, 0x00cc6600, 45)}, + }) + if err != nil { + t.Fatalf("edit story weather media area: %v", err) + } + assertDomainStoryWeatherMediaArea(t, weatherEdited.Story, "☀️", 22.5, 0x00cc6600, 45) + weatherEdited.Story.MediaAreas[0].WeatherEmoji = "mutated" + weatherEdited.Story.MediaAreas[0].TemperatureC = -100 + weatherEdited.Story.MediaAreas[0].Color = 0 + + list, err = store.GetStoriesByID(ctx, owner.ID, owner, []int{created.Story.ID}, 204) + if err != nil { + t.Fatalf("get weather edited story by id: %v", err) + } + assertDomainStoryWeatherMediaArea(t, list.Stories[0], "☀️", 22.5, 0x00cc6600, 45) + + channelPostEdited, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: owner, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testDomainStoryChannelPostMediaArea(777001, 42, 50)}, + }) + if err != nil { + t.Fatalf("edit story channel post media area: %v", err) + } + assertDomainStoryChannelPostMediaArea(t, channelPostEdited.Story, 777001, 42, 50) + channelPostEdited.Story.MediaAreas[0].ChannelID = 0 + channelPostEdited.Story.MediaAreas[0].MsgID = 0 + + list, err = store.GetStoriesByID(ctx, owner.ID, owner, []int{created.Story.ID}, 205) + if err != nil { + t.Fatalf("get channel post edited story by id: %v", err) + } + assertDomainStoryChannelPostMediaArea(t, list.Stories[0], 777001, 42, 50) + + starGiftEdited, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: owner, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testDomainStoryStarGiftMediaArea("Gift.Series_01-42", 55)}, + }) + if err != nil { + t.Fatalf("edit story star gift media area: %v", err) + } + assertDomainStoryStarGiftMediaArea(t, starGiftEdited.Story, "Gift.Series_01-42", 55) + starGiftEdited.Story.MediaAreas[0].StarGiftSlug = "mutated" + + list, err = store.GetStoriesByID(ctx, owner.ID, owner, []int{created.Story.ID}, 206) + if err != nil { + t.Fatalf("get star gift edited story by id: %v", err) + } + assertDomainStoryStarGiftMediaArea(t, list.Stories[0], "Gift.Series_01-42", 55) + + cleared, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: owner, + ID: created.Story.ID, + UpdateMediaAreas: true, + }) + if err != nil { + t.Fatalf("clear story media areas: %v", err) + } + if len(cleared.Story.MediaAreas) != 0 { + t.Fatalf("cleared story media areas = %+v, want none", cleared.Story.MediaAreas) + } +} + +func TestStoryStoreForwardRoundTripEditAndClone(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1002} + source := domain.Peer{Type: domain.PeerTypeUser, ID: 2002} + created, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: owner, + RandomID: 202, + Date: 200, + Period: 900, + Public: true, + Forward: &domain.StoryForward{ + From: source, + StoryID: 7, + Modified: true, + }, + }) + if err != nil { + t.Fatalf("create story: %v", err) + } + assertDomainStoryForward(t, created.Story, source, 7, true) + created.Story.Forward.From.ID = 9999 + created.Story.Forward.StoryID = 99 + + list, err := store.GetStoriesByID(ctx, owner.ID, owner, []int{created.Story.ID}, 201) + if err != nil { + t.Fatalf("get story by id: %v", err) + } + if len(list.Stories) != 1 { + t.Fatalf("stories by id = %+v, want one story", list.Stories) + } + assertDomainStoryForward(t, list.Stories[0], source, 7, true) + + edited, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: owner, + ID: created.Story.ID, + UpdateCaption: true, + Caption: "edited repost caption", + }) + if err != nil { + t.Fatalf("edit story caption: %v", err) + } + assertDomainStoryForward(t, edited.Story, source, 7, true) + edited.Story.Forward.From.ID = 9998 + + list, err = store.GetStoriesByID(ctx, owner.ID, owner, []int{created.Story.ID}, 202) + if err != nil { + t.Fatalf("get edited story by id: %v", err) + } + assertDomainStoryForward(t, list.Stories[0], source, 7, true) + + hidden, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: owner, + RandomID: 203, + Date: 203, + Period: 900, + Public: true, + Forward: &domain.StoryForward{ + FromName: "Alice Hidden", + StoryID: 8, + }, + }) + if err != nil { + t.Fatalf("create hidden-author story: %v", err) + } + assertDomainStoryForwardName(t, hidden.Story, "Alice Hidden", 8, false) + hidden.Story.Forward.From = source + hidden.Story.Forward.FromName = "mutated" + hidden.Story.Forward.StoryID = 88 + + list, err = store.GetStoriesByID(ctx, owner.ID, owner, []int{hidden.Story.ID}, 204) + if err != nil { + t.Fatalf("get hidden-author story by id: %v", err) + } + if len(list.Stories) != 1 { + t.Fatalf("hidden-author stories by id = %+v, want one story", list.Stories) + } + assertDomainStoryForwardName(t, list.Stories[0], "Alice Hidden", 8, false) + + editedHidden, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: owner, + ID: hidden.Story.ID, + UpdateCaption: true, + Caption: "edited hidden repost caption", + }) + if err != nil { + t.Fatalf("edit hidden-author story caption: %v", err) + } + assertDomainStoryForwardName(t, editedHidden.Story, "Alice Hidden", 8, false) +} + +func TestStoryStorePublicRepostForwardCountAndViewsList(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + sourceOwner := domain.Peer{Type: domain.PeerTypeUser, ID: 1101} + repostOwner := domain.Peer{Type: domain.PeerTypeUser, ID: 2202} + source, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: sourceOwner, + RandomID: 1101, + Date: 100, + Period: 86400, + Public: true, + }) + if err != nil { + t.Fatalf("create source story: %v", err) + } + repost, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: repostOwner, + RandomID: 2202, + Date: 200, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + From: sourceOwner, + StoryID: source.Story.ID, + }, + }) + if err != nil { + t.Fatalf("create repost story: %v", err) + } + sourceList, err := store.GetStoriesByID(ctx, sourceOwner.ID, sourceOwner, []int{source.Story.ID}, 201) + if err != nil { + t.Fatalf("get source story: %v", err) + } + if len(sourceList.Stories) != 1 || sourceList.Stories[0].Views.ForwardsCount != 1 { + t.Fatalf("source story = %+v, want forwards_count=1", sourceList.Stories) + } + views, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: sourceOwner.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + ForwardsFirst: true, + }) + if err != nil { + t.Fatalf("list story views: %v", err) + } + if views.Count != 1 || views.ForwardsCount != 1 || len(views.Views) != 1 || views.Views[0].Repost == nil { + t.Fatalf("views list = %+v, want one repost interaction", views) + } + if views.Views[0].Repost.Owner != repostOwner || views.Views[0].Repost.ID != repost.Story.ID { + t.Fatalf("repost interaction = %+v, want owner %+v story %d", views.Views[0].Repost, repostOwner, repost.Story.ID) + } + if _, err := store.DeleteStories(ctx, repostOwner, []int{repost.Story.ID}, 202); err != nil { + t.Fatalf("delete repost: %v", err) + } + sourceList, err = store.GetStoriesByID(ctx, sourceOwner.ID, sourceOwner, []int{source.Story.ID}, 202) + if err != nil { + t.Fatalf("get source story after delete: %v", err) + } + if len(sourceList.Stories) != 1 || sourceList.Stories[0].Views.ForwardsCount != 0 { + t.Fatalf("source story after delete = %+v, want forwards_count=0", sourceList.Stories) + } +} + +func TestStoryStorePublicForwardListReturnsRepostsOnly(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + sourceOwner := domain.Peer{Type: domain.PeerTypeChannel, ID: 3311} + repostOwner := domain.Peer{Type: domain.PeerTypeChannel, ID: 4412} + privateRepostOwner := domain.Peer{Type: domain.PeerTypeChannel, ID: 4413} + source, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: sourceOwner, + RandomID: 331101, + Date: 100, + Period: 86400, + Public: true, + }) + if err != nil { + t.Fatalf("create source story: %v", err) + } + repost, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: repostOwner, + RandomID: 441201, + Date: 200, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + From: sourceOwner, + StoryID: source.Story.ID, + }, + }) + if err != nil { + t.Fatalf("create public repost: %v", err) + } + hiddenRepost, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: repostOwner, + RandomID: 441202, + Date: 202, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + Source: sourceOwner, + FromName: "Hidden Source", + StoryID: source.Story.ID, + }, + }) + if err != nil { + t.Fatalf("create hidden-source public repost: %v", err) + } + if _, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: privateRepostOwner, + RandomID: 441301, + Date: 201, + Period: 86400, + Public: false, + Forward: &domain.StoryForward{ + From: sourceOwner, + StoryID: source.Story.ID, + }, + }); err != nil { + t.Fatalf("create private repost: %v", err) + } + list, err := store.ListStoryPublicForwards(ctx, domain.StoryPublicForwardListRequest{ + ViewerUserID: sourceOwner.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + }) + if err != nil { + t.Fatalf("list story public forwards: %v", err) + } + if list.Count != 2 || len(list.Forwards) != 2 || list.Forwards[0].Repost == nil || list.Forwards[1].Repost == nil { + t.Fatalf("public forwards = %+v, want two public reposts", list) + } + if list.Forwards[0].Repost.ID != hiddenRepost.Story.ID || + list.Forwards[0].Repost.Forward == nil || + list.Forwards[0].Repost.Forward.From != (domain.Peer{}) || + list.Forwards[0].Repost.Forward.Source != sourceOwner || + list.Forwards[0].Repost.Forward.FromName != "Hidden Source" { + t.Fatalf("hidden public forward repost = %+v, want from_name-only header with source %+v", list.Forwards[0].Repost, sourceOwner) + } + if list.Forwards[1].Repost.Owner != repostOwner || list.Forwards[1].Repost.ID != repost.Story.ID { + t.Fatalf("public forward repost = %+v, want owner %+v story %d", list.Forwards[1].Repost, repostOwner, repost.Story.ID) + } + if _, err := store.DeleteStories(ctx, repostOwner, []int{repost.Story.ID}, 202); err != nil { + t.Fatalf("delete repost: %v", err) + } + afterVisibleDelete, err := store.ListStoryPublicForwards(ctx, domain.StoryPublicForwardListRequest{ + ViewerUserID: sourceOwner.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + }) + if err != nil { + t.Fatalf("list story public forwards after delete: %v", err) + } + if afterVisibleDelete.Count != 1 || len(afterVisibleDelete.Forwards) != 1 || afterVisibleDelete.Forwards[0].Repost == nil || + afterVisibleDelete.Forwards[0].Repost.ID != hiddenRepost.Story.ID { + t.Fatalf("public forwards after visible delete = %+v, want hidden repost only", afterVisibleDelete) + } + if _, err := store.DeleteStories(ctx, repostOwner, []int{hiddenRepost.Story.ID}, 203); err != nil { + t.Fatalf("delete hidden repost: %v", err) + } + empty, err := store.ListStoryPublicForwards(ctx, domain.StoryPublicForwardListRequest{ + ViewerUserID: sourceOwner.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + }) + if err != nil { + t.Fatalf("list story public forwards after hidden delete: %v", err) + } + if empty.Count != 0 || len(empty.Forwards) != 0 { + t.Fatalf("public forwards after delete = %+v, want empty", empty) + } +} + +func TestStoryStorePublicRepostStoryReactionsList(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + sourceOwner := domain.Peer{Type: domain.PeerTypeChannel, ID: 3301} + repostOwner := domain.Peer{Type: domain.PeerTypeUser, ID: 4402} + source, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: sourceOwner, + RandomID: 3301, + Date: 100, + Period: 86400, + Public: true, + }) + if err != nil { + t.Fatalf("create source story: %v", err) + } + if _, err := store.SetReaction(ctx, 5503, sourceOwner, source.Story.ID, &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "🔥", + }, 150); err != nil { + t.Fatalf("set reaction: %v", err) + } + repost, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: repostOwner, + RandomID: 4402, + Date: 200, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + From: sourceOwner, + StoryID: source.Story.ID, + }, + }) + if err != nil { + t.Fatalf("create repost story: %v", err) + } + list, err := store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: sourceOwner.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + ForwardsFirst: true, + }) + if err != nil { + t.Fatalf("list story reactions: %v", err) + } + if list.Count != 2 || len(list.Reactions) != 2 || list.Reactions[0].Repost == nil { + t.Fatalf("reaction list = %+v, want repost first plus reaction", list) + } + if list.Reactions[0].Repost.Owner != repostOwner || list.Reactions[0].Repost.ID != repost.Story.ID { + t.Fatalf("repost reaction = %+v, want owner %+v story %d", list.Reactions[0].Repost, repostOwner, repost.Story.ID) + } + filtered, err := store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: sourceOwner.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + Reaction: &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "🔥", + }, + }) + if err != nil { + t.Fatalf("list filtered story reactions: %v", err) + } + if filtered.Count != 1 || len(filtered.Reactions) != 1 || filtered.Reactions[0].Repost != nil || filtered.Reactions[0].ViewerID != 5503 { + t.Fatalf("filtered reaction list = %+v, want only emoji reactor", filtered) + } + if _, err := store.DeleteStories(ctx, repostOwner, []int{repost.Story.ID}, 202); err != nil { + t.Fatalf("delete repost: %v", err) + } + list, err = store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: sourceOwner.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + ForwardsFirst: true, + }) + if err != nil { + t.Fatalf("list story reactions after delete: %v", err) + } + if list.Count != 1 || len(list.Reactions) != 1 || list.Reactions[0].Repost != nil { + t.Fatalf("reaction list after delete = %+v, want only durable reaction", list) + } +} + +func TestStoryStoreListStoriesArchiveCountAndSeek(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + fixtures := []domain.Story{ + {Owner: owner, ID: 1, Date: 101, ExpireDate: 900, Public: true}, + {Owner: owner, ID: 2, Date: 102, ExpireDate: 1200, Public: true}, + {Owner: owner, ID: 3, Date: 103, ExpireDate: 900, Public: true, Pinned: true}, + {Owner: owner, ID: 4, Date: 104, ExpireDate: 900, Public: true, Deleted: true}, + {Owner: owner, ID: 5, Date: 105, ExpireDate: 900, Public: true}, + {Owner: domain.Peer{Type: domain.PeerTypeUser, ID: 1002}, ID: 1, Date: 106, ExpireDate: 900, Public: true}, + } + for _, story := range fixtures { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story %d/%d: %v", story.Owner.ID, story.ID, err) + } + } + + countOnly, err := store.ListStoriesArchive(ctx, owner.ID, owner, 0, 0, 1000) + if err != nil { + t.Fatalf("count archive stories: %v", err) + } + if countOnly.Count != 3 || len(countOnly.Stories) != 0 { + t.Fatalf("count-only archive = %+v, want count 3 and no page", countOnly) + } + first, err := store.ListStoriesArchive(ctx, owner.ID, owner, 0, 2, 1000) + if err != nil { + t.Fatalf("list archive first page: %v", err) + } + if first.Count != 3 || !sameStoryIDs(storyIDs(first.Stories), []int{5, 3}) { + t.Fatalf("first archive page = count %d ids %v, want count 3 ids 5,3", first.Count, storyIDs(first.Stories)) + } + if !first.Stories[1].Pinned || !first.Stories[0].Out || !first.Stories[1].Out { + t.Fatalf("first archive stories = %+v, want pinned expired retained and owner out=true", first.Stories) + } + second, err := store.ListStoriesArchive(ctx, owner.ID, owner, 3, 2, 1000) + if err != nil { + t.Fatalf("list archive second page: %v", err) + } + if second.Count != 3 || !sameStoryIDs(storyIDs(second.Stories), []int{1}) { + t.Fatalf("second archive page = count %d ids %v, want count 3 ids 1", second.Count, storyIDs(second.Stories)) + } +} + +func TestStoryStoreListOwnerActiveStoriesReturnsFanoutSnapshots(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + other := domain.Peer{Type: domain.PeerTypeUser, ID: 1002} + reaction := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"} + for _, story := range []domain.Story{ + { + Owner: owner, + ID: 1, + Date: 100, + ExpireDate: 1000, + CloseFriends: true, + Out: true, + Views: domain.StoryViews{ViewsCount: 1, HasViewers: true, RecentViewers: []int64{2001}}, + SentReaction: reaction, + }, + {Owner: owner, ID: 2, Date: 101, ExpireDate: 90, CloseFriends: true}, + {Owner: owner, ID: 3, Date: 102, ExpireDate: 1000, CloseFriends: true, Deleted: true}, + {Owner: other, ID: 1, Date: 103, ExpireDate: 1000, CloseFriends: true}, + } { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story %d/%d: %v", story.Owner.ID, story.ID, err) + } + } + + list, err := store.ListOwnerActiveStories(ctx, owner, 200, 100) + if err != nil { + t.Fatalf("list owner active stories: %v", err) + } + if len(list.Stories) != 1 || list.Stories[0].ID != 1 { + t.Fatalf("owner active stories = %+v, want only story 1", list.Stories) + } + story := list.Stories[0] + if story.Out || story.Views.HasViewers || story.Views.ViewsCount != 0 || story.SentReaction != nil { + t.Fatalf("fanout snapshot = %+v, want no out/views/reaction", story) + } +} + +func testDomainStoryMediaArea(emoticon string, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaSuggestedReaction, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 10, + W: 10, + H: 10, + Rotation: 15, + Radius: 8, + HasRadius: true, + }, + Dark: true, + Flipped: true, + Reaction: &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}, + } +} + +func assertDomainStoryMediaArea(t *testing.T, story domain.Story, wantEmoticon string, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaSuggestedReaction || area.Reaction == nil { + t.Fatalf("story media area = %+v, want suggested reaction", area) + } + if area.Reaction.Type != domain.MessageReactionEmoji || area.Reaction.Emoticon != wantEmoticon { + t.Fatalf("story media area reaction = %+v, want emoji %q", area.Reaction, wantEmoticon) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 10 || area.Coordinates.W != 10 || area.Coordinates.H != 10 || + area.Coordinates.Rotation != 15 || !area.Coordinates.HasRadius || area.Coordinates.Radius != 8 { + t.Fatalf("story media area coordinates = %+v, want x %v y/w/h 10 rotation 15 radius 8", area.Coordinates, wantX) + } + if !area.Dark || !area.Flipped { + t.Fatalf("story media area flags = dark %v flipped %v, want true/true", area.Dark, area.Flipped) + } +} + +func testDomainStoryURLMediaArea(url string, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaURL, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 10, + W: 20, + H: 10, + Rotation: 15, + }, + URL: url, + } +} + +func assertDomainStoryURLMediaArea(t *testing.T, story domain.Story, wantURL string, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one url area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaURL || area.URL != wantURL { + t.Fatalf("story media area = %+v, want url %q", area, wantURL) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 10 || area.Coordinates.W != 20 || area.Coordinates.H != 10 || + area.Coordinates.Rotation != 15 { + t.Fatalf("story media area coordinates = %+v, want x %v y 10 w 20 h 10 rotation 15", area.Coordinates, wantX) + } +} + +func testDomainStoryGeoPointMediaArea(lat, long, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaGeoPoint, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 12, + W: 22, + H: 11, + Rotation: 20, + }, + Geo: &domain.MessageGeoPoint{ + Lat: lat, + Long: long, + AccessHash: 123456, + }, + GeoAddress: &domain.StoryGeoPointAddress{ + CountryISO2: "CN", + State: "Shanghai", + City: "Shanghai", + Street: "People Square", + }, + } +} + +func assertDomainStoryGeoPointMediaArea(t *testing.T, story domain.Story, wantLat, wantLong, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one geo area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaGeoPoint || area.Geo == nil { + t.Fatalf("story media area = %+v, want geo point", area) + } + if area.Geo.Lat != wantLat || area.Geo.Long != wantLong || area.Geo.AccessHash != 123456 { + t.Fatalf("story media area geo = %+v, want lat %v long %v access_hash 123456", area.Geo, wantLat, wantLong) + } + if area.GeoAddress == nil || area.GeoAddress.CountryISO2 != "CN" || area.GeoAddress.State != "Shanghai" || area.GeoAddress.City != "Shanghai" || area.GeoAddress.Street != "People Square" { + t.Fatalf("story media area address = %+v, want CN/Shanghai/Shanghai/People Square", area.GeoAddress) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 12 || area.Coordinates.W != 22 || area.Coordinates.H != 11 || + area.Coordinates.Rotation != 20 { + t.Fatalf("story media area coordinates = %+v, want x %v y 12 w 22 h 11 rotation 20", area.Coordinates, wantX) + } +} + +func testDomainStoryVenueMediaArea(title string, lat, long, x float64) domain.StoryMediaArea { + venue := &domain.MessageVenue{ + Geo: domain.MessageGeoPoint{Lat: lat, Long: long, AccessHash: 123456}, + Title: title, + Address: "Inline Street", + Provider: "gplaces", + VenueID: "venue-id", + VenueType: "cafe", + } + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaVenue, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 13, + W: 23, + H: 12, + Rotation: 22, + }, + Geo: &venue.Geo, + Venue: venue, + } +} + +func assertDomainStoryVenueMediaArea(t *testing.T, story domain.Story, wantTitle string, wantLat, wantLong, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one venue area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaVenue || area.Venue == nil || area.Geo == nil { + t.Fatalf("story media area = %+v, want venue", area) + } + if area.Venue.Title != wantTitle || area.Venue.Address != "Inline Street" || area.Venue.Provider != "gplaces" || area.Venue.VenueID != "venue-id" || area.Venue.VenueType != "cafe" { + t.Fatalf("story venue = %+v, want %q/Inline Street/gplaces/venue-id/cafe", area.Venue, wantTitle) + } + if area.Venue.Geo.Lat != wantLat || area.Venue.Geo.Long != wantLong || area.Venue.Geo.AccessHash != 123456 || + area.Geo.Lat != wantLat || area.Geo.Long != wantLong || area.Geo.AccessHash != 123456 { + t.Fatalf("story venue geo = venue %+v area %+v, want lat %v long %v access_hash 123456", area.Venue.Geo, area.Geo, wantLat, wantLong) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 13 || area.Coordinates.W != 23 || area.Coordinates.H != 12 || + area.Coordinates.Rotation != 22 { + t.Fatalf("story media area coordinates = %+v, want x %v y 13 w 23 h 12 rotation 22", area.Coordinates, wantX) + } +} + +func testDomainStoryWeatherMediaArea(emoji string, temperatureC float64, color int, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaWeather, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 14, + W: 24, + H: 12, + Rotation: 25, + }, + WeatherEmoji: emoji, + TemperatureC: temperatureC, + Color: color, + } +} + +func assertDomainStoryWeatherMediaArea(t *testing.T, story domain.Story, wantEmoji string, wantTemperatureC float64, wantColor int, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one weather area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaWeather { + t.Fatalf("story media area = %+v, want weather", area) + } + if area.WeatherEmoji != wantEmoji || area.TemperatureC != wantTemperatureC || area.Color != wantColor { + t.Fatalf("story weather area = emoji %q temp %v color %d, want %q/%v/%d", area.WeatherEmoji, area.TemperatureC, area.Color, wantEmoji, wantTemperatureC, wantColor) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 14 || area.Coordinates.W != 24 || area.Coordinates.H != 12 || + area.Coordinates.Rotation != 25 { + t.Fatalf("story media area coordinates = %+v, want x %v y 14 w 24 h 12 rotation 25", area.Coordinates, wantX) + } +} + +func testDomainStoryChannelPostMediaArea(channelID int64, msgID int, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaChannelPost, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 15, + W: 25, + H: 12, + Rotation: 27, + }, + ChannelID: channelID, + MsgID: msgID, + } +} + +func assertDomainStoryChannelPostMediaArea(t *testing.T, story domain.Story, wantChannelID int64, wantMsgID int, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one channel post area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaChannelPost || area.ChannelID != wantChannelID || area.MsgID != wantMsgID { + t.Fatalf("story channel post area = %+v, want channel %d msg %d", area, wantChannelID, wantMsgID) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 15 || area.Coordinates.W != 25 || area.Coordinates.H != 12 || + area.Coordinates.Rotation != 27 { + t.Fatalf("story media area coordinates = %+v, want x %v y 15 w 25 h 12 rotation 27", area.Coordinates, wantX) + } +} + +func testDomainStoryStarGiftMediaArea(slug string, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaStarGift, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 16, + W: 26, + H: 12, + Rotation: 29, + }, + StarGiftSlug: slug, + } +} + +func assertDomainStoryStarGiftMediaArea(t *testing.T, story domain.Story, wantSlug string, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one star gift area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaStarGift || area.StarGiftSlug != wantSlug { + t.Fatalf("story star gift area = %+v, want slug %q", area, wantSlug) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 16 || area.Coordinates.W != 26 || area.Coordinates.H != 12 || + area.Coordinates.Rotation != 29 { + t.Fatalf("story media area coordinates = %+v, want x %v y 16 w 26 h 12 rotation 29", area.Coordinates, wantX) + } +} + +func assertDomainStoryForward(t *testing.T, story domain.Story, wantSource domain.Peer, wantStoryID int, wantModified bool) { + t.Helper() + if story.Forward == nil { + t.Fatalf("story forward is nil, want source %+v story %d", wantSource, wantStoryID) + } + if story.Forward.From != wantSource || story.Forward.StoryID != wantStoryID || story.Forward.Modified != wantModified { + t.Fatalf("story forward = %+v, want source %+v story %d modified %v", story.Forward, wantSource, wantStoryID, wantModified) + } +} + +func assertDomainStoryForwardName(t *testing.T, story domain.Story, wantName string, wantStoryID int, wantModified bool) { + t.Helper() + if story.Forward == nil { + t.Fatalf("story forward is nil, want from_name %q story %d", wantName, wantStoryID) + } + if story.Forward.From != (domain.Peer{}) || story.Forward.FromName != wantName || + story.Forward.StoryID != wantStoryID || story.Forward.Modified != wantModified { + t.Fatalf("story forward = %+v, want from_name %q story %d modified %v", story.Forward, wantName, wantStoryID, wantModified) + } +} + +func TestStoryStoreViewsIdempotentAndReactionReplace(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 100, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + + if created, err := store.IncrementViews(ctx, 2001, owner, []int{1, 1}, 200); err != nil || created != 1 { + t.Fatalf("increment views first = %d, %v; want 1 nil", created, err) + } + if created, err := store.IncrementViews(ctx, 2001, owner, []int{1}, 201); err != nil || created != 0 { + t.Fatalf("increment views duplicate = %d, %v; want 0 nil", created, err) + } + if _, err := store.IncrementViews(ctx, 2001, owner, nil, 201); !errors.Is(err, domain.ErrStoryIDInvalid) { + t.Fatalf("empty increment views err = %v, want ErrStoryIDInvalid", err) + } + like := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "👍"} + fire := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "🔥"} + res, err := store.SetReaction(ctx, 2001, owner, 1, like, 202) + if err != nil { + t.Fatalf("set reaction: %v", err) + } + if !res.Changed || res.Story.Views.ViewsCount != 1 || res.Story.Views.ReactionsCount != 1 { + t.Fatalf("reaction result = %+v, want one view and one reaction", res) + } + res, err = store.SetReaction(ctx, 2001, owner, 1, fire, 203) + if err != nil { + t.Fatalf("replace reaction: %v", err) + } + if !res.Changed || res.Story.Views.ReactionsCount != 1 || len(res.Story.Views.Reactions) != 1 || res.Story.Views.Reactions[0].Reaction.Emoticon != "🔥" { + t.Fatalf("replace result = %+v, want only fire reaction", res) + } + res, err = store.SetReaction(ctx, 2001, owner, 1, fire, 204) + if err != nil { + t.Fatalf("retry same reaction: %v", err) + } + if res.Changed || res.Date != 203 || res.Story.Views.ReactionsCount != 1 || len(res.Story.Views.Reactions) != 1 || res.Story.Views.Reactions[0].Reaction.Emoticon != "🔥" { + t.Fatalf("retry same reaction result = %+v, want unchanged fire reaction at original date", res) + } + reactions, err := store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list reactions after retry: %v", err) + } + if reactions.Count != 1 || len(reactions.Reactions) != 1 || reactions.Reactions[0].ViewerID != 2001 || reactions.Reactions[0].Date != 203 { + t.Fatalf("reactions after retry = %+v, want original reaction date", reactions) + } + custom := &domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: 12345} + res, err = store.SetReaction(ctx, 2001, owner, 1, custom, 204) + if err != nil { + t.Fatalf("replace with custom reaction: %v", err) + } + if !res.Changed || res.Story.Views.ReactionsCount != 1 || len(res.Story.Views.Reactions) != 1 || res.Story.Views.Reactions[0].Reaction.DocumentID != 12345 { + t.Fatalf("custom replace result = %+v, want one custom reaction", res) + } + res, err = store.SetReaction(ctx, 2001, owner, 1, custom, 205) + if err != nil { + t.Fatalf("retry same custom reaction: %v", err) + } + if res.Changed || res.Date != 204 || res.Story.Views.ReactionsCount != 1 || len(res.Story.Views.Reactions) != 1 || res.Story.Views.Reactions[0].Reaction.DocumentID != 12345 { + t.Fatalf("retry same custom reaction result = %+v, want unchanged custom reaction at original date", res) + } + reactions, err = store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Reaction: custom, + Limit: 10, + }) + if err != nil { + t.Fatalf("list custom reactions: %v", err) + } + if reactions.Count != 1 || len(reactions.Reactions) != 1 || reactions.Reactions[0].ViewerID != 2001 || reactions.Reactions[0].Date != 204 || reactions.Reactions[0].Reaction == nil || reactions.Reactions[0].Reaction.DocumentID != 12345 { + t.Fatalf("custom reactions = %+v, want original custom reaction date", reactions) + } + if _, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Offset: "bad", + Limit: 10, + }); !errors.Is(err, domain.ErrStoryOffsetInvalid) { + t.Fatalf("bad story views offset err = %v, want ErrStoryOffsetInvalid", err) + } + if _, err := store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Offset: "1:203:2001", + Limit: 10, + }); !errors.Is(err, domain.ErrStoryOffsetInvalid) { + t.Fatalf("bad story reactions offset err = %v, want ErrStoryOffsetInvalid", err) + } + res, err = store.SetReaction(ctx, 2001, owner, 1, nil, 206) + if err != nil { + t.Fatalf("clear reaction: %v", err) + } + if !res.Changed || res.Story.Views.ReactionsCount != 0 || len(res.Story.Views.Reactions) != 0 { + t.Fatalf("clear result = %+v, want no reactions", res) + } +} + +func TestStoryStoreExpiredUnpinnedStoriesDoNotAcceptNewInteractions(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + for _, story := range []domain.Story{ + {Owner: owner, ID: 1, Date: 100, ExpireDate: 150, Public: true}, + {Owner: owner, ID: 2, Date: 101, ExpireDate: 150, Public: true, Pinned: true}, + } { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story %d: %v", story.ID, err) + } + } + + if created, err := store.IncrementViews(ctx, 2001, owner, []int{1}, 200); err != nil || created != 0 { + t.Fatalf("expired unpinned increment = %d, %v; want 0 nil", created, err) + } + if _, err := store.SetReaction(ctx, 2001, owner, 1, &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "like"}, 200); !errors.Is(err, domain.ErrStoryNotFound) { + t.Fatalf("expired unpinned reaction err = %v, want ErrStoryNotFound", err) + } + if created, err := store.IncrementViews(ctx, 2001, owner, []int{2}, 200); err != nil || created != 1 { + t.Fatalf("expired pinned increment = %d, %v; want 1 nil", created, err) + } + res, err := store.SetReaction(ctx, 2002, owner, 2, &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "fire"}, 201) + if err != nil { + t.Fatalf("expired pinned reaction: %v", err) + } + if !res.Changed || res.Story.Views.ViewsCount != 2 || res.Story.Views.ReactionsCount != 1 { + t.Fatalf("expired pinned reaction result = %+v, want accepted profile interaction", res) + } +} + +func TestStoryStoreListsViewsAndReactions(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 100, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + + if _, err := store.IncrementViews(ctx, 2001, owner, []int{1}, 201); err != nil { + t.Fatalf("increment viewer 2001: %v", err) + } + if _, err := store.IncrementViews(ctx, 2004, owner, []int{1}, 204); err != nil { + t.Fatalf("increment viewer 2004: %v", err) + } + like := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "like"} + fire := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "fire"} + if _, err := store.SetReaction(ctx, 2002, owner, 1, like, 202); err != nil { + t.Fatalf("set like reaction: %v", err) + } + if _, err := store.SetReaction(ctx, 2003, owner, 1, fire, 203); err != nil { + t.Fatalf("set fire reaction: %v", err) + } + + viewerIDs, err := store.ListStoryViewerIDs(ctx, owner, 1, 3) + if err != nil { + t.Fatalf("list story viewer ids: %v", err) + } + if len(viewerIDs) != 3 || viewerIDs[0] != 2001 || viewerIDs[1] != 2002 || viewerIDs[2] != 2003 { + t.Fatalf("viewer ids = %v, want first three ascending ids", viewerIDs) + } + + first, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 2, + ReactionsFirst: true, + }) + if err != nil { + t.Fatalf("list views first page: %v", err) + } + if first.Count != 4 || first.ViewsCount != 4 || first.ReactionsCount != 2 || len(first.Views) != 2 || first.NextOffset == "" { + t.Fatalf("first page = %+v, want 4 total, 2 reactions, 2 rows and next offset", first) + } + if first.Views[0].ViewerID != 2003 || first.Views[1].ViewerID != 2002 { + t.Fatalf("first page viewers = %+v, want reactions newest first", first.Views) + } + second, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 2, + ReactionsFirst: true, + Offset: first.NextOffset, + }) + if err != nil { + t.Fatalf("list views second page: %v", err) + } + if len(second.Views) != 2 || second.NextOffset != "" || second.Views[0].ViewerID != 2004 || second.Views[1].ViewerID != 2001 { + t.Fatalf("second page = %+v, want remaining non-reaction viewers", second) + } + + reactions, err := store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list reactions: %v", err) + } + if reactions.Count != 2 || len(reactions.Reactions) != 2 || reactions.Reactions[0].ViewerID != 2003 { + t.Fatalf("reactions = %+v, want two newest reactions", reactions) + } + filtered, err := store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Reaction: fire, + Limit: 10, + }) + if err != nil { + t.Fatalf("list filtered reactions: %v", err) + } + if filtered.Count != 1 || len(filtered.Reactions) != 1 || filtered.Reactions[0].ViewerID != 2003 { + t.Fatalf("filtered reactions = %+v, want viewer 2003 only", filtered) + } + if _, err := store.SetReaction(ctx, 2003, owner, 1, nil, 205); err != nil { + t.Fatalf("clear reaction: %v", err) + } + reactions, err = store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list reactions after clear: %v", err) + } + if reactions.Count != 1 || len(reactions.Reactions) != 1 || reactions.Reactions[0].ViewerID != 2002 { + t.Fatalf("reactions after clear = %+v, want only viewer 2002", reactions) + } + if _, err := store.DeleteStories(ctx, owner, []int{1}, 206); err != nil { + t.Fatalf("delete story: %v", err) + } + deletedViewerIDs, err := store.ListStoryViewerIDs(ctx, owner, 1, 10) + if err != nil { + t.Fatalf("list deleted story viewer ids: %v", err) + } + if len(deletedViewerIDs) != 4 || deletedViewerIDs[0] != 2001 || deletedViewerIDs[1] != 2002 || deletedViewerIDs[2] != 2003 || deletedViewerIDs[3] != 2004 { + t.Fatalf("deleted story viewer ids = %v, want all durable viewers ascending", deletedViewerIDs) + } +} + +func TestStoryStoreViewerIDsIncludeCachedStoryExposure(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 100, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + if list, err := store.ListActiveStories(ctx, 2005, false, 200, 10); err != nil || len(list.Stories) != 1 { + t.Fatalf("list active exposure = %+v err=%v, want one story", list, err) + } + if peerStories, err := store.GetPeerStories(ctx, 2006, owner, 200); err != nil || len(peerStories.Stories) != 1 { + t.Fatalf("peer stories exposure = %+v err=%v, want one story", peerStories, err) + } + if exact, err := store.GetStoriesByID(ctx, 2007, owner, []int{1}, 200); err != nil || len(exact.Stories) != 1 { + t.Fatalf("exact exposure = %+v err=%v, want one story", exact, err) + } + + viewerIDs, err := store.ListStoryViewerIDs(ctx, owner, 1, 10) + if err != nil { + t.Fatalf("list story viewer ids: %v", err) + } + if len(viewerIDs) != 3 || viewerIDs[0] != 2005 || viewerIDs[1] != 2006 || viewerIDs[2] != 2007 { + t.Fatalf("viewer ids = %v, want exposure viewers ascending", viewerIDs) + } + views, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story views: %v", err) + } + if views.Count != 0 || views.ViewsCount != 0 || len(views.Views) != 0 { + t.Fatalf("views = %+v, exposure must not count as a real view", views) + } +} + +func TestStoryStoreListStoryViewsFiltersByContactsAndQuery(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: 1, + Date: 100, + ExpireDate: 1000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + store.SetStoryViewerProfiles( + domain.User{ID: 2001, FirstName: "Alice", LastName: "Viewer", Username: "alicev", Phone: "155501"}, + domain.User{ID: 2002, FirstName: "Bob", LastName: "Stranger", Username: "bobstory", Phone: "155502"}, + domain.User{ID: 2003, FirstName: "Carol", LastName: "Contact", Username: "carolstory", Phone: "155503"}, + ) + store.SetStoryViewerContacts(owner.ID, + domain.Contact{User: domain.User{ID: 2001, FirstName: "Alice", Username: "alicev"}, FirstName: "Close", LastName: "Friend", Phone: "7001"}, + domain.Contact{User: domain.User{ID: 2003, FirstName: "Carol", Username: "carolstory"}, FirstName: "Work", LastName: "Carol", Phone: "7003"}, + ) + for _, item := range []struct { + viewerID int64 + date int + }{ + {viewerID: 2001, date: 201}, + {viewerID: 2002, date: 202}, + {viewerID: 2003, date: 203}, + } { + if _, err := store.IncrementViews(ctx, item.viewerID, owner, []int{1}, item.date); err != nil { + t.Fatalf("increment viewer %d: %v", item.viewerID, err) + } + } + + contactsOnly, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + JustContacts: true, + }) + if err != nil { + t.Fatalf("list contacts-only: %v", err) + } + if contactsOnly.Count != 2 || contactsOnly.ViewsCount != 3 || len(contactsOnly.Views) != 2 || contactsOnly.Views[0].ViewerID != 2003 || contactsOnly.Views[1].ViewerID != 2001 { + t.Fatalf("contacts-only = %+v, want two contacts and total views 3", contactsOnly) + } + + remark, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + Query: "close", + }) + if err != nil { + t.Fatalf("list query remark: %v", err) + } + if remark.Count != 1 || len(remark.Views) != 1 || remark.Views[0].ViewerID != 2001 { + t.Fatalf("remark query = %+v, want viewer 2001", remark) + } + + stranger, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + Query: "@bobstory", + }) + if err != nil { + t.Fatalf("list query username: %v", err) + } + if stranger.Count != 1 || len(stranger.Views) != 1 || stranger.Views[0].ViewerID != 2002 { + t.Fatalf("username query = %+v, want viewer 2002", stranger) + } + + intersection, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + Query: "bob", + JustContacts: true, + }) + if err != nil { + t.Fatalf("list query contacts intersection: %v", err) + } + if intersection.Count != 0 || len(intersection.Views) != 0 { + t.Fatalf("intersection = %+v, want no contact match for bob", intersection) + } +} + +func TestStoryStorePrivacyVisibility(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + selectedViewer := int64(2001) + stranger := int64(2002) + contactViewer := int64(2003) + excludedContact := int64(2004) + closeFriendViewer := int64(2005) + store.SetStoryViewerContacts(owner.ID, + domain.Contact{User: domain.User{ID: contactViewer, FirstName: "Contact"}}, + domain.Contact{User: domain.User{ID: excludedContact, FirstName: "Excluded"}}, + domain.Contact{User: domain.User{ID: closeFriendViewer, FirstName: "Close"}, CloseFriend: true}, + ) + stories := []domain.Story{ + { + Owner: owner, + ID: 1, + Date: 100, + ExpireDate: 1000, + Public: true, + DisallowUserIDs: []int64{stranger}, + PrivacyRules: []domain.PrivacyRule{ + {Kind: domain.PrivacyRuleAllowAll}, + {Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{stranger}}, + }, + }, + { + Owner: owner, + ID: 2, + Date: 101, + ExpireDate: 1000, + SelectedContacts: true, + AllowUserIDs: []int64{selectedViewer}, + PrivacyRules: []domain.PrivacyRule{ + {Kind: domain.PrivacyRuleAllowUsers, UserIDs: []int64{selectedViewer}}, + }, + }, + { + Owner: owner, + ID: 3, + Date: 102, + ExpireDate: 1000, + Contacts: true, + DisallowUserIDs: []int64{excludedContact}, + PrivacyRules: []domain.PrivacyRule{ + {Kind: domain.PrivacyRuleAllowContacts}, + {Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{excludedContact}}, + }, + }, + { + Owner: owner, + ID: 4, + Date: 103, + ExpireDate: 1000, + CloseFriends: true, + PrivacyRules: []domain.PrivacyRule{ + {Kind: domain.PrivacyRuleAllowCloseFriends}, + }, + }, + } + for _, story := range stories { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story %d: %v", story.ID, err) + } + } + + selected, err := store.GetStoriesByID(ctx, selectedViewer, owner, []int{1, 2, 3, 4}, 200) + if err != nil { + t.Fatalf("selected viewer get stories: %v", err) + } + if gotIDs := storyIDs(selected.Stories); !sameStoryIDs(gotIDs, []int{1, 2}) { + t.Fatalf("selected viewer story ids = %v, want [1 2]", gotIDs) + } + hidden, err := store.GetStoriesByID(ctx, stranger, owner, []int{1, 2, 3, 4}, 200) + if err != nil { + t.Fatalf("stranger get stories: %v", err) + } + if len(hidden.Stories) != 0 { + t.Fatalf("stranger stories = %+v, want none", hidden.Stories) + } + contacts, err := store.GetStoriesByID(ctx, contactViewer, owner, []int{1, 2, 3, 4}, 200) + if err != nil { + t.Fatalf("contact get stories: %v", err) + } + if gotIDs := storyIDs(contacts.Stories); !sameStoryIDs(gotIDs, []int{1, 3}) { + t.Fatalf("contact story ids = %v, want [1 3]", gotIDs) + } + excluded, err := store.GetStoriesByID(ctx, excludedContact, owner, []int{3}, 200) + if err != nil { + t.Fatalf("excluded contact get stories: %v", err) + } + if len(excluded.Stories) != 0 { + t.Fatalf("excluded contact stories = %+v, want none", excluded.Stories) + } + closeFriend, err := store.GetStoriesByID(ctx, closeFriendViewer, owner, []int{1, 2, 3, 4}, 200) + if err != nil { + t.Fatalf("close friend get stories: %v", err) + } + if gotIDs := storyIDs(closeFriend.Stories); !sameStoryIDs(gotIDs, []int{1, 3, 4}) { + t.Fatalf("close friend story ids = %v, want [1 3 4]", gotIDs) + } + if created, err := store.IncrementViews(ctx, stranger, owner, []int{2}, 210); err != nil || created != 0 { + t.Fatalf("hidden increment views = %d, %v; want 0 nil", created, err) + } + if _, err := store.SetReaction(ctx, stranger, owner, 2, &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "like"}, 211); err != domain.ErrStoryNotFound { + t.Fatalf("hidden set reaction err = %v, want ErrStoryNotFound", err) + } +} + +func TestStoryStoreCreateEditDeleteAndPinned(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + media := &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 77, AccessHash: 7, DCID: 2}} + + created, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: owner, + RandomID: 9001, + Date: 100, + Period: 86400, + Public: true, + Pinned: true, + Caption: "first", + Media: media, + NoForwards: true, + }) + if err != nil { + t.Fatalf("create story: %v", err) + } + if created.Duplicate || created.Story.ID != 1 || created.Story.ExpireDate != 86500 || !created.Story.Pinned { + t.Fatalf("created = %+v, want new pinned story id 1", created) + } + dup, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: owner, + RandomID: 9001, + Date: 101, + Period: 86400, + Public: true, + Caption: "retry must not win", + Media: media, + }) + if err != nil { + t.Fatalf("create duplicate: %v", err) + } + if !dup.Duplicate || dup.Story.ID != created.Story.ID || dup.Story.Caption != "first" { + t.Fatalf("duplicate = %+v, want original story", dup) + } + read, err := store.GetPeerStories(ctx, owner.ID, owner, 102) + if err != nil { + t.Fatalf("get self stories: %v", err) + } + if read.MaxReadID != 1 { + t.Fatalf("self max read = %d, want created story id", read.MaxReadID) + } + + edited, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: owner, + ID: created.Story.ID, + Caption: "edited", + UpdateCaption: true, + }) + if err != nil { + t.Fatalf("edit story: %v", err) + } + if edited.Previous.Caption != "first" || edited.Previous.Edited { + t.Fatalf("previous = %+v, want pre-edit story", edited.Previous) + } + if !edited.Story.Edited || edited.Story.Caption != "edited" || !edited.Story.Pinned { + t.Fatalf("edited = %+v, want edited caption preserving pin", edited) + } + pinned, err := store.ListPinnedStories(ctx, owner.ID, owner, 0, 10, 103) + if err != nil { + t.Fatalf("list pinned: %v", err) + } + if len(pinned.Stories) != 1 || pinned.Stories[0].ID != created.Story.ID { + t.Fatalf("pinned = %+v, want created story", pinned) + } + + deleted, err := store.DeleteStories(ctx, owner, []int{created.Story.ID}, 104) + if err != nil { + t.Fatalf("delete story: %v", err) + } + if len(deleted.Stories) != 1 || !deleted.Stories[0].Deleted || deleted.Stories[0].Pinned { + t.Fatalf("deleted = %+v, want deleted unpinned snapshot", deleted) + } + if len(deleted.Previous) != 1 || deleted.Previous[0].Deleted || !deleted.Previous[0].Pinned { + t.Fatalf("deleted previous = %+v, want pre-delete pinned snapshot", deleted.Previous) + } + active, err := store.GetPeerStories(ctx, owner.ID, owner, 105) + if err != nil { + t.Fatalf("get active after delete: %v", err) + } + if len(active.Stories) != 0 { + t.Fatalf("active after delete = %+v, want empty", active) + } +} + +func TestStoryMutationIDsAreDeduped(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + media := &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 1}} + created, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: owner, + RandomID: 9101, + Date: 100, + Period: 86400, + Public: true, + Media: media, + }) + if err != nil { + t.Fatalf("create story: %v", err) + } + + toggled, err := store.TogglePinned(ctx, owner, []int{created.Story.ID, created.Story.ID}, true, 101) + if err != nil { + t.Fatalf("toggle pinned: %v", err) + } + if len(toggled.IDs) != 1 || toggled.IDs[0] != created.Story.ID || len(toggled.Stories) != 1 || !toggled.Stories[0].Pinned { + t.Fatalf("toggled = %+v, want one pinned story", toggled) + } + if len(toggled.Previous) != 1 || toggled.Previous[0].Pinned { + t.Fatalf("toggled previous = %+v, want pre-toggle unpinned snapshot", toggled.Previous) + } + retryToggled, err := store.TogglePinned(ctx, owner, []int{created.Story.ID}, true, 102) + if err != nil { + t.Fatalf("retry toggle pinned: %v", err) + } + if len(retryToggled.IDs) != 1 || retryToggled.IDs[0] != created.Story.ID || len(retryToggled.Stories) != 0 || len(retryToggled.Previous) != 0 { + t.Fatalf("retry toggled = %+v, want id echo without mutation snapshot", retryToggled) + } + unpinned, err := store.TogglePinned(ctx, owner, []int{created.Story.ID}, false, 103) + if err != nil { + t.Fatalf("toggle unpinned: %v", err) + } + if len(unpinned.Stories) != 1 || unpinned.Stories[0].Pinned || len(unpinned.Previous) != 1 || !unpinned.Previous[0].Pinned { + t.Fatalf("unpinned = %+v, want unpinned story with pre-toggle pinned snapshot", unpinned) + } + retryUnpinned, err := store.TogglePinned(ctx, owner, []int{created.Story.ID}, false, 104) + if err != nil { + t.Fatalf("retry toggle unpinned: %v", err) + } + if len(retryUnpinned.Stories) != 0 || len(retryUnpinned.Previous) != 0 { + t.Fatalf("retry unpinned = %+v, want no mutation snapshots", retryUnpinned) + } + + emptyToggle, err := store.TogglePinned(ctx, owner, nil, false, 101) + if err != nil { + t.Fatalf("empty toggle pinned: %v", err) + } + if len(emptyToggle.IDs) != 0 || len(emptyToggle.Stories) != 0 { + t.Fatalf("empty toggle = %+v, want no-op", emptyToggle) + } + + deleted, err := store.DeleteStories(ctx, owner, []int{created.Story.ID, created.Story.ID}, 102) + if err != nil { + t.Fatalf("delete story: %v", err) + } + if len(deleted.IDs) != 1 || deleted.IDs[0] != created.Story.ID || len(deleted.Stories) != 1 || !deleted.Stories[0].Deleted { + t.Fatalf("deleted = %+v, want one deleted story", deleted) + } + retryDeleted, err := store.DeleteStories(ctx, owner, []int{created.Story.ID}, 103) + if err != nil { + t.Fatalf("retry delete story: %v", err) + } + if len(retryDeleted.IDs) != 1 || retryDeleted.IDs[0] != created.Story.ID || len(retryDeleted.Stories) != 0 { + t.Fatalf("retry deleted = %+v, want id echo without mutation snapshot", retryDeleted) + } +} + +func TestStoryStorePinnedToTopOrder(t *testing.T) { + ctx := context.Background() + store := NewStoryStore() + owner := domain.Peer{Type: domain.PeerTypeUser, ID: 1001} + for _, id := range []int{1, 2, 3, 4} { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: owner, + ID: id, + Date: 100 + id, + ExpireDate: 1000, + Pinned: true, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %d: %v", id, err) + } + } + if err := store.TogglePinnedToTop(ctx, owner, []int{3, 1, 3}); err != nil { + t.Fatalf("toggle pinned to top: %v", err) + } + pinned, err := store.ListPinnedStories(ctx, owner.ID, owner, 0, 2, 200) + if err != nil { + t.Fatalf("list pinned stories: %v", err) + } + if pinned.Count != 4 || !sameStoryIDs(storyIDs(pinned.Stories), []int{4, 3}) || !sameStoryIDs(pinned.PinnedToTop, []int{3, 1}) { + t.Fatalf("pinned = count %d stories %v top %v, want count 4 stories 4,3 top 3,1", pinned.Count, storyIDs(pinned.Stories), pinned.PinnedToTop) + } + androidInitial, err := store.ListPinnedStories(ctx, owner.ID, owner, -1, 2, 200) + if err != nil { + t.Fatalf("list pinned stories with android sentinel: %v", err) + } + if androidInitial.Count != pinned.Count || !sameStoryIDs(storyIDs(androidInitial.Stories), []int{4, 3}) || !sameStoryIDs(androidInitial.PinnedToTop, []int{3, 1}) { + t.Fatalf("android sentinel pinned = count %d stories %v top %v, want same first page 4,3 top 3,1", androidInitial.Count, storyIDs(androidInitial.Stories), androidInitial.PinnedToTop) + } + if err := store.TogglePinnedToTop(ctx, owner, []int{2, 1, 4}); err != nil { + t.Fatalf("replace pinned to top: %v", err) + } + pinned, err = store.ListPinnedStories(ctx, owner.ID, owner, 0, 10, 201) + if err != nil { + t.Fatalf("list replaced pinned stories: %v", err) + } + if !sameStoryIDs(pinned.PinnedToTop, []int{2, 1, 4}) { + t.Fatalf("replaced top = %v, want 2,1,4", pinned.PinnedToTop) + } + pageAfterOffset, err := store.ListPinnedStories(ctx, owner.ID, owner, 3, 10, 201) + if err != nil { + t.Fatalf("list pinned page after offset: %v", err) + } + if !sameStoryIDs(storyIDs(pageAfterOffset.Stories), []int{2, 1}) || !sameStoryIDs(pageAfterOffset.PinnedToTop, []int{2, 1, 4}) { + t.Fatalf("offset page stories %v top %v, want stories 2,1 and full top 2,1,4", storyIDs(pageAfterOffset.Stories), pageAfterOffset.PinnedToTop) + } + if _, err := store.TogglePinned(ctx, owner, []int{2}, false, 202); err != nil { + t.Fatalf("unpin story: %v", err) + } + pinned, err = store.ListPinnedStories(ctx, owner.ID, owner, 0, 10, 203) + if err != nil { + t.Fatalf("list after unpin: %v", err) + } + if !sameStoryIDs(pinned.PinnedToTop, []int{1, 4}) { + t.Fatalf("top after unpin = %v, want 1,4", pinned.PinnedToTop) + } + if err := store.TogglePinnedToTop(ctx, owner, []int{2}); !errors.Is(err, domain.ErrStoryIDInvalid) { + t.Fatalf("unpinned top err = %v, want ErrStoryIDInvalid", err) + } + if _, err := store.DeleteStories(ctx, owner, []int{1}, 204); err != nil { + t.Fatalf("delete story: %v", err) + } + pinned, err = store.ListPinnedStories(ctx, owner.ID, owner, 0, 10, 205) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if !sameStoryIDs(pinned.PinnedToTop, []int{4}) { + t.Fatalf("top after delete = %v, want 4", pinned.PinnedToTop) + } + if err := store.TogglePinnedToTop(ctx, owner, nil); err != nil { + t.Fatalf("clear pinned to top: %v", err) + } + pinned, err = store.ListPinnedStories(ctx, owner.ID, owner, 0, 10, 206) + if err != nil { + t.Fatalf("list after clear: %v", err) + } + if len(pinned.PinnedToTop) != 0 { + t.Fatalf("top after clear = %v, want empty", pinned.PinnedToTop) + } +} + +func storyIDs(stories []domain.Story) []int { + out := make([]int, 0, len(stories)) + for _, story := range stories { + out = append(out, story.ID) + } + return out +} + +func sameStoryIDs(got, want []int) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/internal/store/memory/themes.go b/internal/store/memory/themes.go new file mode 100644 index 00000000..ad49a9d1 --- /dev/null +++ b/internal/store/memory/themes.go @@ -0,0 +1,210 @@ +package memory + +import ( + "context" + "sort" + "sync" + + "telesrv/internal/domain" +) + +// ThemeStore 是 store.ThemeStore 的内存实现(测试/单实例)。 +type ThemeStore struct { + mu sync.RWMutex + byID map[int64]domain.Theme + bySlug map[string]int64 + installs map[int64]map[int64]themeInstall // userID -> themeID -> 安装项 + seq int64 // 单调序,模拟 postgres installed_at 排序 +} + +type themeInstall struct { + dark bool + order int64 +} + +// NewThemeStore 创建内存主题 store。 +func NewThemeStore() *ThemeStore { + return &ThemeStore{ + byID: make(map[int64]domain.Theme), + bySlug: make(map[string]int64), + installs: make(map[int64]map[int64]themeInstall), + } +} + +func (s *ThemeStore) CreateTheme(_ context.Context, t domain.Theme) error { + if t.ID == 0 || t.CreatorUserID == 0 { + return domain.ErrThemeInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[t.ID]; ok { + return domain.ErrThemeInvalid + } + if t.Slug != "" { + if _, ok := s.bySlug[t.Slug]; ok { + return domain.ErrThemeSlugTaken + } + } + s.byID[t.ID] = t.Clone() + if t.Slug != "" { + s.bySlug[t.Slug] = t.ID + } + return nil +} + +func (s *ThemeStore) GetThemeByID(_ context.Context, id int64) (domain.Theme, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + t, ok := s.byID[id] + if !ok { + return domain.Theme{}, false, nil + } + return t.Clone(), true, nil +} + +func (s *ThemeStore) GetThemeBySlug(_ context.Context, slug string) (domain.Theme, bool, error) { + if slug == "" { + return domain.Theme{}, false, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + id, ok := s.bySlug[slug] + if !ok { + return domain.Theme{}, false, nil + } + t, ok := s.byID[id] + if !ok { + return domain.Theme{}, false, nil + } + return t.Clone(), true, nil +} + +func (s *ThemeStore) SlugExists(_ context.Context, slug string) (bool, error) { + if slug == "" { + return false, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + _, ok := s.bySlug[slug] + return ok, nil +} + +func (s *ThemeStore) UpdateTheme(_ context.Context, t domain.Theme) error { + s.mu.Lock() + defer s.mu.Unlock() + prev, ok := s.byID[t.ID] + if !ok { + return domain.ErrThemeNotFound + } + if t.Slug != "" && t.Slug != prev.Slug { + if _, taken := s.bySlug[t.Slug]; taken { + return domain.ErrThemeSlugTaken + } + } + if prev.Slug != "" && prev.Slug != t.Slug { + delete(s.bySlug, prev.Slug) + } + s.byID[t.ID] = t.Clone() + if t.Slug != "" { + s.bySlug[t.Slug] = t.ID + } + return nil +} + +func (s *ThemeStore) IncrementInstalls(_ context.Context, id int64) error { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.byID[id] + if !ok { + return domain.ErrThemeNotFound + } + t.InstallsCount++ + s.byID[id] = t + return nil +} + +func (s *ThemeStore) SetInstalled(_ context.Context, userID, themeID int64, dark bool) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.byID[themeID]; !ok { + return domain.ErrThemeNotFound + } + byUser := s.installs[userID] + if byUser == nil { + byUser = make(map[int64]themeInstall) + s.installs[userID] = byUser + } + if prev, ok := byUser[themeID]; ok { + byUser[themeID] = themeInstall{dark: dark, order: prev.order} + return nil + } + s.seq++ + byUser[themeID] = themeInstall{dark: dark, order: s.seq} + return nil +} + +func (s *ThemeStore) RemoveInstalled(_ context.Context, userID, themeID int64) error { + s.mu.Lock() + defer s.mu.Unlock() + if byUser := s.installs[userID]; byUser != nil { + delete(byUser, themeID) + } + return nil +} + +func (s *ThemeStore) ListInstalledByUser(_ context.Context, userID int64) ([]domain.Theme, error) { + s.mu.RLock() + defer s.mu.RUnlock() + byUser := s.installs[userID] + if len(byUser) == 0 { + return nil, nil + } + type row struct { + t domain.Theme + order int64 + } + rows := make([]row, 0, len(byUser)) + for themeID, inst := range byUser { + t, ok := s.byID[themeID] + if !ok { + continue + } + rows = append(rows, row{t: t.Clone(), order: inst.order}) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].order < rows[j].order }) + out := make([]domain.Theme, 0, len(rows)) + for _, r := range rows { + out = append(out, r.t) + } + return out, nil +} + +func (s *ThemeStore) ListThemesForUser(_ context.Context, userID int64) ([]domain.Theme, error) { + s.mu.RLock() + defer s.mu.RUnlock() + seen := make(map[int64]bool) + out := make([]domain.Theme, 0) + for _, t := range s.byID { + if t.CreatorUserID == userID { + out = append(out, t.Clone()) + seen[t.ID] = true + } + } + if byUser := s.installs[userID]; byUser != nil { + for themeID := range byUser { + if seen[themeID] { + continue + } + if t, ok := s.byID[themeID]; ok { + out = append(out, t.Clone()) + } + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt != out[j].CreatedAt { + return out[i].CreatedAt < out[j].CreatedAt + } + return out[i].ID < out[j].ID + }) + return out, nil +} diff --git a/internal/store/memory/themes_test.go b/internal/store/memory/themes_test.go new file mode 100644 index 00000000..4285e816 --- /dev/null +++ b/internal/store/memory/themes_test.go @@ -0,0 +1,109 @@ +package memory + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func sampleTheme(id, creator int64, slug string) domain.Theme { + return domain.Theme{ + ID: id, + AccessHash: id + 1, + CreatorUserID: creator, + Slug: slug, + Title: "T", + DocumentID: 9000 + id, + Settings: []domain.ThemeSettingsSpec{{ + BaseTheme: domain.ThemeBaseDay, + AccentColor: 0x112233, + MessageColors: []int{0xaa, 0xbb}, + Wallpaper: &domain.ThemeWallpaperSpec{BackgroundColors: []int{0x1, 0x2}}, + }}, + CreatedAt: 1700000000, + } +} + +func TestMemoryThemeStoreContract(t *testing.T) { + ctx := context.Background() + s := NewThemeStore() + + // 未命中读 → (zero,false,nil)。 + if _, ok, err := s.GetThemeByID(ctx, 1); ok || err != nil { + t.Fatalf("get missing = ok %v err %v", ok, err) + } + + a := sampleTheme(101, 7, "alpha") + if err := s.CreateTheme(ctx, a); err != nil { + t.Fatalf("create a: %v", err) + } + // slug 冲突。 + if err := s.CreateTheme(ctx, sampleTheme(102, 7, "alpha")); !errors.Is(err, domain.ErrThemeSlugTaken) { + t.Fatalf("dup slug err = %v, want ErrThemeSlugTaken", err) + } + + got, ok, err := s.GetThemeByID(ctx, 101) + if err != nil || !ok { + t.Fatalf("get a by id = ok %v err %v", ok, err) + } + if got.Slug != "alpha" || len(got.Settings) != 1 || got.Settings[0].BaseTheme != domain.ThemeBaseDay { + t.Fatalf("round-trip mismatch: %+v", got) + } + // Clone 隔离:改返回值不污染内部。 + got.Settings[0].MessageColors[0] = 0xffff + if again, _, _ := s.GetThemeByID(ctx, 101); again.Settings[0].MessageColors[0] != 0xaa { + t.Fatalf("clone leak: internal mutated to %#x", again.Settings[0].MessageColors[0]) + } + + if bySlug, ok, _ := s.GetThemeBySlug(ctx, "alpha"); !ok || bySlug.ID != 101 { + t.Fatalf("get by slug = ok %v id %d", ok, bySlug.ID) + } + + // update 不存在 → ErrThemeNotFound。 + if err := s.UpdateTheme(ctx, sampleTheme(999, 7, "ghost")); !errors.Is(err, domain.ErrThemeNotFound) { + t.Fatalf("update missing err = %v, want ErrThemeNotFound", err) + } + + // IncrementInstalls。 + if err := s.IncrementInstalls(ctx, 101); err != nil { + t.Fatalf("increment: %v", err) + } + if got, _, _ := s.GetThemeByID(ctx, 101); got.InstallsCount != 1 { + t.Fatalf("installs = %d, want 1", got.InstallsCount) + } + + // 安装列表顺序按安装先后。 + b := sampleTheme(103, 7, "beta") + if err := s.CreateTheme(ctx, b); err != nil { + t.Fatalf("create b: %v", err) + } + const user = int64(55) + if err := s.SetInstalled(ctx, user, 103, false); err != nil { + t.Fatalf("install 103: %v", err) + } + if err := s.SetInstalled(ctx, user, 101, true); err != nil { + t.Fatalf("install 101: %v", err) + } + list, err := s.ListInstalledByUser(ctx, user) + if err != nil || len(list) != 2 { + t.Fatalf("list installed = %d err %v, want 2", len(list), err) + } + if list[0].ID != 103 || list[1].ID != 101 { + t.Fatalf("install order = [%d,%d], want [103,101]", list[0].ID, list[1].ID) + } + + // 移除。 + if err := s.RemoveInstalled(ctx, user, 103); err != nil { + t.Fatalf("remove: %v", err) + } + if list, _ := s.ListInstalledByUser(ctx, user); len(list) != 1 || list[0].ID != 101 { + t.Fatalf("after remove = %+v, want [101]", list) + } + + // 安装不存在的主题 → ErrThemeNotFound。 + if err := s.SetInstalled(ctx, user, 88888, false); !errors.Is(err, domain.ErrThemeNotFound) { + t.Fatalf("install missing theme err = %v, want ErrThemeNotFound", err) + } +} diff --git a/internal/store/memory/updates.go b/internal/store/memory/updates.go new file mode 100644 index 00000000..a348142e --- /dev/null +++ b/internal/store/memory/updates.go @@ -0,0 +1,201 @@ +package memory + +import ( + "context" + "sync" + "telesrv/internal/domain" +) + +// UpdateStateStore 是 store.UpdateStateStore 的内存实现。 +type UpdateStateStore struct { + mu sync.RWMutex + states map[updateStateKey]domain.UpdateState +} + +// UpdateEventStore 是 store.UpdateEventStore 的内存实现。 +type UpdateEventStore struct { + mu sync.RWMutex + events map[int64][]domain.UpdateEvent +} + +type updateStateKey struct { + authKeyID [8]byte + userID int64 +} + +// NewUpdateEventStore 创建内存 UpdateEventStore。 +func NewUpdateEventStore() *UpdateEventStore { + return &UpdateEventStore{events: make(map[int64][]domain.UpdateEvent)} +} + +func (s *UpdateEventStore) Append(_ context.Context, userID int64, event domain.UpdateEvent) error { + _, err := s.append(userID, event, false) + return err +} + +func (s *UpdateEventStore) AppendAllocated(_ context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, error) { + return s.append(userID, event, true) +} + +func (s *UpdateEventStore) AppendAllocatedWithDispatch(_ context.Context, userID int64, event domain.UpdateEvent, _ [8]byte, _ int64) (domain.UpdateEvent, error) { + return s.append(userID, event, true) +} + +func (s *UpdateEventStore) append(userID int64, event domain.UpdateEvent, allocate bool) (domain.UpdateEvent, error) { + if event.PtsCount <= 0 { + event.PtsCount = 1 + } + event.UserID = userID + event.Message = cloneMessage(event.Message) + event.Story = cloneUpdateStory(event.Story) + event.MessageIDs = append([]int(nil), event.MessageIDs...) + event.Peers = append([]domain.Peer(nil), event.Peers...) + event.Users = append([]domain.User(nil), event.Users...) + event.Channels = append([]domain.Channel(nil), event.Channels...) + event.Reaction = cloneUpdateReaction(event.Reaction) + event.QuickReplies = cloneUpdateQuickReplies(event.QuickReplies) + event.QuickReplyMessage = cloneUpdateQuickReplyMessage(event.QuickReplyMessage) + s.mu.Lock() + if allocate { + current := 0 + for _, item := range s.events[userID] { + if item.Pts > current { + current = item.Pts + } + } + event.Pts = current + event.PtsCount + } + s.events[userID] = append(s.events[userID], event) + s.mu.Unlock() + return event, nil +} + +func (s *UpdateEventStore) ListAfter(_ context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) { + s.mu.RLock() + items := append([]domain.UpdateEvent(nil), s.events[userID]...) + s.mu.RUnlock() + out := make([]domain.UpdateEvent, 0, len(items)) + for _, event := range items { + if event.Pts <= pts { + continue + } + event.Message = cloneMessage(event.Message) + event.Story = cloneUpdateStory(event.Story) + event.MessageIDs = append([]int(nil), event.MessageIDs...) + event.Peers = append([]domain.Peer(nil), event.Peers...) + event.Users = append([]domain.User(nil), event.Users...) + event.Channels = append([]domain.Channel(nil), event.Channels...) + event.Reaction = cloneUpdateReaction(event.Reaction) + event.QuickReplies = cloneUpdateQuickReplies(event.QuickReplies) + event.QuickReplyMessage = cloneUpdateQuickReplyMessage(event.QuickReplyMessage) + out = append(out, event) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +func cloneUpdateStory(story domain.Story) domain.Story { + story.Entities = append([]domain.MessageEntity(nil), story.Entities...) + story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...) + story.Views.RecentViewers = append([]int64(nil), story.Views.RecentViewers...) + story.SentReaction = cloneUpdateReaction(story.SentReaction) + return story +} + +func cloneUpdateReaction(in *domain.MessageReaction) *domain.MessageReaction { + if in == nil { + return nil + } + out := *in + return &out +} + +func cloneUpdateQuickReplies(in []domain.QuickReply) []domain.QuickReply { + return append([]domain.QuickReply(nil), in...) +} + +func cloneUpdateQuickReplyMessage(in domain.QuickReplyMessage) domain.QuickReplyMessage { + out := in + out.Entities = append([]domain.MessageEntity(nil), in.Entities...) + return out +} + + +// MaxContiguousPts 返回从 1 起无空洞的最大 pts(内存版按 pts_count 连续扫描)。 +func (s *UpdateEventStore) MaxContiguousPts(_ context.Context, userID int64) (int, error) { + s.mu.RLock() + nextByStart := make(map[int]int, len(s.events[userID])) + for _, event := range s.events[userID] { + count := event.PtsCount + if count <= 0 { + count = 1 + } + nextByStart[event.Pts-count] = event.Pts + } + s.mu.RUnlock() + contiguous := 0 + for { + next, ok := nextByStart[contiguous] + if !ok { + break + } + contiguous = next + } + return contiguous, nil +} + +// NewUpdateStateStore 创建内存 UpdateStateStore。 +func NewUpdateStateStore() *UpdateStateStore { + return &UpdateStateStore{states: make(map[updateStateKey]domain.UpdateState)} +} + +func (s *UpdateStateStore) Get(_ context.Context, id [8]byte, userID int64) (domain.UpdateState, bool, error) { + s.mu.RLock() + st, ok := s.states[updateStateKey{authKeyID: id, userID: userID}] + s.mu.RUnlock() + return st, ok, nil +} + +func (s *UpdateStateStore) Save(_ context.Context, id [8]byte, userID int64, st domain.UpdateState) error { + s.mu.Lock() + key := updateStateKey{authKeyID: id, userID: userID} + // 确认水位只增不减(与 PG 的 GREATEST upsert 对齐):一个旧 from(较小 pts) + // 的 getDifference 不得把设备已确认水位回退,否则 getPeerDialogs.state 会下发 + // 倒退的 pts 基线。 + prev := s.states[key] + if st.Pts < prev.Pts { + st.Pts = prev.Pts + } + if st.Qts < prev.Qts { + st.Qts = prev.Qts + } + if st.Date < prev.Date { + st.Date = prev.Date + } + if st.Seq < prev.Seq { + st.Seq = prev.Seq + } + s.states[key] = st + s.mu.Unlock() + return nil +} + +func (s *UpdateStateStore) Delete(_ context.Context, id [8]byte, userID int64) error { + s.mu.Lock() + delete(s.states, updateStateKey{authKeyID: id, userID: userID}) + s.mu.Unlock() + return nil +} + +func (s *UpdateStateStore) DeleteAuthKey(_ context.Context, id [8]byte) error { + s.mu.Lock() + for k := range s.states { + if k.authKeyID == id { + delete(s.states, k) + } + } + s.mu.Unlock() + return nil +} diff --git a/internal/store/memory/users.go b/internal/store/memory/users.go new file mode 100644 index 00000000..192e7ec8 --- /dev/null +++ b/internal/store/memory/users.go @@ -0,0 +1,384 @@ +package memory + +import ( + "context" + "sort" + "strings" + "sync" + "telesrv/internal/domain" +) + +// UserStore 是 store.UserStore 的内存实现。ID 与 PG identity 使用同一业务起点。 +type UserStore struct { + mu sync.RWMutex + byID map[int64]domain.User + nextID int64 +} + +// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather)预置进表, +// 与 postgres 的迁移种子(0005 / 0090)保持双 store 行为一致。 +func NewUserStore() *UserStore { + s := &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase} + for _, id := range []int64{domain.OfficialSystemUserID, domain.BotFatherUserID} { + if u, ok := domain.SystemUserByID(id); ok { + s.byID[u.ID] = u + } + } + return s +} + +func (s *UserStore) ByID(_ context.Context, id int64) (domain.User, bool, error) { + s.mu.RLock() + u, ok := s.byID[id] + s.mu.RUnlock() + return u, ok, nil +} + +func (s *UserStore) ByIDs(_ context.Context, ids []int64) ([]domain.User, error) { + if len(ids) == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]domain.User, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + if u, ok := s.byID[id]; ok { + out = append(out, u) + } + } + return out, nil +} + +func (s *UserStore) ByPhone(_ context.Context, phone string) (domain.User, bool, error) { + // bot/系统账号 phone 可为空串,空查询必须判未找到(与 postgres 行为一致)。 + if phone == "" { + return domain.User{}, false, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + for _, u := range s.byID { + if u.Phone == phone { + return u, true, nil + } + } + return domain.User{}, false, nil +} + +func (s *UserStore) ByPhones(_ context.Context, phones []string) ([]domain.User, error) { + if len(phones) == 0 { + return nil, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + want := make(map[string]struct{}, len(phones)) + for _, phone := range phones { + if phone != "" { + want[phone] = struct{}{} + } + } + out := make([]domain.User, 0, len(want)) + seenIDs := map[int64]struct{}{} + for _, u := range s.byID { + if _, ok := want[u.Phone]; !ok { + continue + } + if _, ok := seenIDs[u.ID]; ok { + continue + } + seenIDs[u.ID] = struct{}{} + out = append(out, u) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (s *UserStore) ByUsername(_ context.Context, username string) (domain.User, bool, error) { + username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + if username == "" { + return domain.User{}, false, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + for _, u := range s.byID { + if strings.ToLower(u.Username) == username { + return u, true, nil + } + } + return domain.User{}, false, nil +} + +func (s *UserStore) CheckUsername(_ context.Context, userID int64, username string) (bool, error) { + username = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + if username == "" { + return true, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + for id, u := range s.byID { + if strings.ToLower(u.Username) == username && id != userID { + return false, nil + } + } + return true, nil +} + +func (s *UserStore) Search(_ context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) { + if limit <= 0 { + limit = 50 + } + query = strings.ToLower(strings.TrimSpace(query)) + phoneQuery = strings.TrimSpace(phoneQuery) + if query == "" { + return domain.UserSearchResult{}, nil + } + s.mu.RLock() + defer s.mu.RUnlock() + users := make([]domain.User, 0) + for _, u := range s.byID { + if u.ID == currentUserID { + continue + } + if userMatchesSearch(u, query, phoneQuery) { + users = append(users, u) + } + } + sort.SliceStable(users, func(i, j int) bool { + return users[i].ID < users[j].ID + }) + if len(users) > limit { + users = users[:limit] + } + return domain.UserSearchResult{Results: users}, nil +} + +func (s *UserStore) UpdateUsername(_ context.Context, userID int64, username string) (domain.User, error) { + username = strings.TrimSpace(strings.TrimPrefix(username, "@")) + usernameLower := strings.ToLower(username) + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.User{}, domain.ErrUsernameNotOccupied + } + if usernameLower != "" { + for id, existing := range s.byID { + if id != userID && strings.ToLower(existing.Username) == usernameLower { + return domain.User{}, domain.ErrUsernameOccupied + } + } + } + u.Username = username + s.byID[userID] = u + return u, nil +} + +func (s *UserStore) UpdateProfile(_ context.Context, userID int64, firstName, lastName, about string) (domain.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.User{}, domain.ErrUsernameNotOccupied + } + u.FirstName = firstName + u.LastName = lastName + u.About = about + s.byID[userID] = u + return u, nil +} + +func (s *UserStore) UpdateBirthday(_ context.Context, userID int64, birthday domain.Birthday) (domain.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.User{}, domain.ErrUserNotFound + } + u.Birthday = birthday + s.byID[userID] = u + return u, nil +} + +func (s *UserStore) UpdatePersonalChannel(_ context.Context, userID int64, channelID int64) (domain.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.User{}, domain.ErrUserNotFound + } + u.PersonalChannelID = channelID + s.byID[userID] = u + return u, nil +} + +// bumpBotInfoVersion 递增 bot 的 bot_info_version(仅 bot 行),返回新值。供同包 +// BotStore 元数据更新调用,与 postgres 的事务内 bump 对齐。 +func (s *UserStore) bumpBotInfoVersion(userID int64) (int, bool) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok || !u.Bot { + return 0, false + } + u.BotInfoVersion++ + s.byID[userID] = u + return u.BotInfoVersion, true +} + +// updateBotProfile 部分更新 bot 的 first_name/about(setBotInfo 的 name/about)。 +func (s *UserStore) updateBotProfile(userID int64, setName bool, name string, setAbout bool, about string) bool { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok || !u.Bot { + return false + } + if setName { + u.FirstName = name + } + if setAbout { + u.About = about + } + s.byID[userID] = u + return true +} + +// SetPremiumUntil 把会员到期时间设为绝对 Unix 秒(0 = 清除会员)。 +func (s *UserStore) SetPremiumUntil(_ context.Context, userID int64, until int) (domain.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.User{}, domain.ErrUserNotFound + } + if until < 0 { + until = 0 + } + u.PremiumUntil = until + s.byID[userID] = u + return u, nil +} + +// SetVerified 设置/取消用户认证标记。 +func (s *UserStore) SetVerified(_ context.Context, userID int64, verified bool) (domain.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.User{}, domain.ErrUserNotFound + } + u.Verified = verified + s.byID[userID] = u + return u, nil +} + +// SweepExpiredPremium 清空到期会员行并返回清理后的用户(与 postgres 语义一致)。 +func (s *UserStore) SweepExpiredPremium(_ context.Context, now int64, limit int) ([]domain.User, error) { + if limit <= 0 { + return nil, nil + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]domain.User, 0) + for id, u := range s.byID { + if u.PremiumUntil <= 0 || int64(u.PremiumUntil) > now { + continue + } + u.PremiumUntil = 0 + s.byID[id] = u + out = append(out, u) + if len(out) >= limit { + break + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。 +func (s *UserStore) UpdateEmojiStatus(_ context.Context, userID int64, documentID int64, until int) (domain.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.User{}, domain.ErrUserNotFound + } + if documentID == 0 { + until = 0 + } + u.EmojiStatusDocumentID = documentID + u.EmojiStatusUntil = until + s.byID[userID] = u + return u, nil +} + +func (s *UserStore) UpdateColor(_ context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.User{}, domain.ErrUserNotFound + } + if forProfile { + u.ProfileColor = color + } else { + u.Color = color + } + s.byID[userID] = u + return u, nil +} + +func (s *UserStore) UpdateLastSeen(_ context.Context, userID int64, lastSeenAt int) error { + if lastSeenAt <= 0 { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byID[userID] + if !ok { + return domain.ErrUsernameNotOccupied + } + if lastSeenAt > u.LastSeenAt { + u.LastSeenAt = lastSeenAt + s.byID[userID] = u + } + return nil +} + +func userMatchesSearch(u domain.User, query, phoneQuery string) bool { + if phoneQuery != "" && strings.HasPrefix(u.Phone, phoneQuery) { + return true + } + first := strings.ToLower(u.FirstName) + last := strings.ToLower(u.LastName) + username := strings.ToLower(u.Username) + fullName := strings.TrimSpace(first + " " + last) + return strings.Contains(first, query) || + strings.Contains(last, query) || + strings.Contains(fullName, query) || + strings.Contains(username, query) +} + +func (s *UserStore) Create(_ context.Context, u domain.User) (domain.User, error) { + s.mu.Lock() + defer s.mu.Unlock() + username := strings.ToLower(strings.TrimSpace(u.Username)) + if username != "" { + for _, existing := range s.byID { + if strings.ToLower(existing.Username) == username { + return domain.User{}, domain.ErrUsernameOccupied + } + } + } + u.ID = s.nextID + s.nextID++ + s.byID[u.ID] = u + return u, nil +} diff --git a/internal/store/memory/users_test.go b/internal/store/memory/users_test.go new file mode 100644 index 00000000..7e057a79 --- /dev/null +++ b/internal/store/memory/users_test.go @@ -0,0 +1,55 @@ +package memory + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestUserStoreUpdateColorRoundTrip(t *testing.T) { + ctx := context.Background() + store := NewUserStore() + user, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550003301", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + + updated, err := store.UpdateColor(ctx, user.ID, false, domain.PeerColor{ + HasColor: true, + Color: 0, + BackgroundEmojiID: 100, + }) + if err != nil { + t.Fatalf("update message color: %v", err) + } + if !updated.Color.HasColor || updated.Color.Color != 0 || updated.Color.BackgroundEmojiID != 100 { + t.Fatalf("message color = %+v, want explicit color=0 bg=100", updated.Color) + } + if !updated.ProfileColor.Empty() { + t.Fatalf("profile color = %+v, want unchanged empty", updated.ProfileColor) + } + + updated, err = store.UpdateColor(ctx, user.ID, true, domain.PeerColor{ + HasColor: true, + Color: 3, + BackgroundEmojiID: 200, + }) + if err != nil { + t.Fatalf("update profile color: %v", err) + } + if updated.Color.Color != 0 || updated.Color.BackgroundEmojiID != 100 { + t.Fatalf("message color after profile update = %+v, want unchanged", updated.Color) + } + if !updated.ProfileColor.HasColor || updated.ProfileColor.Color != 3 || updated.ProfileColor.BackgroundEmojiID != 200 { + t.Fatalf("profile color = %+v, want color=3 bg=200", updated.ProfileColor) + } + + updated, err = store.UpdateColor(ctx, user.ID, true, domain.PeerColor{}) + if err != nil { + t.Fatalf("clear profile color: %v", err) + } + if !updated.ProfileColor.Empty() { + t.Fatalf("profile color after clear = %+v, want empty", updated.ProfileColor) + } +} diff --git a/internal/store/message.go b/internal/store/message.go index ea842f8e..7709c15b 100644 --- a/internal/store/message.go +++ b/internal/store/message.go @@ -16,9 +16,24 @@ type MessageStore interface { GetOutboxReadDate(ctx context.Context, req domain.OutboxReadDateRequest) (int, error) SetMessageReactions(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) GetMessageReactions(ctx context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) + VoteMessagePoll(ctx context.Context, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) + CloseMessagePoll(ctx context.Context, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) EditMessage(ctx context.Context, req domain.EditMessageRequest) (domain.EditMessageResult, error) + PinPrivateMessage(ctx context.Context, req domain.PinPrivateMessageRequest) (domain.PinPrivateMessageResult, error) + UnpinAllPrivateMessages(ctx context.Context, req domain.UnpinAllPrivateMessagesRequest) (domain.PinPrivateMessageResult, error) DeleteMessages(ctx context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) DeleteHistory(ctx context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) GetByIDs(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) + // SearchPrivateMedia 返回某私聊会话中属于给定媒体类别的消息(共享媒体标签页),newest-first 分页。 + SearchPrivateMedia(ctx context.Context, ownerUserID, peerID int64, req domain.MediaSearchRequest) (domain.MessageList, error) + // CountPrivateMediaCategories 返回某私聊会话按基础媒体类别聚合的精确计数。 + CountPrivateMediaCategories(ctx context.Context, ownerUserID, peerID int64) (domain.MediaCategoryCounts, error) + // Saved Messages 分会话(self-chat 按 saved_peer 分组的子会话)。 + ListSavedDialogs(ctx context.Context, userID int64, filter domain.SavedDialogsFilter) (domain.SavedDialogList, error) + ListPinnedSavedDialogs(ctx context.Context, userID int64) (domain.SavedDialogList, error) + ListSavedDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error) + ToggleSavedDialogPin(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) + ReorderPinnedSavedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error + DeleteSavedHistory(ctx context.Context, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) } diff --git a/internal/store/passkey.go b/internal/store/passkey.go new file mode 100644 index 00000000..07769435 --- /dev/null +++ b/internal/store/passkey.go @@ -0,0 +1,24 @@ +package store + +import ( + "context" + "time" + + "telesrv/internal/domain" +) + +// PasskeyStore 持久化已注册的 passkey 凭据(durable)。 +type PasskeyStore interface { + InsertPasskey(ctx context.Context, cred domain.PasskeyCredential) error + GetPasskeyByCredentialID(ctx context.Context, credentialID []byte) (domain.PasskeyCredential, bool, error) + ListPasskeysByUser(ctx context.Context, userID int64) ([]domain.PasskeyCredential, error) + UpdatePasskeyUsage(ctx context.Context, credentialID []byte, signCount uint32, lastUsedAt int64) error + DeletePasskey(ctx context.Context, userID int64, credentialID []byte) (bool, error) +} + +// PasskeyChallengeStore 暂存一次性 WebAuthn 挑战(短 TTL、用后即焚)。 +type PasskeyChallengeStore interface { + SavePasskeyChallenge(ctx context.Context, challenge []byte, c domain.PasskeyChallenge, ttl time.Duration) error + // ConsumePasskeyChallenge 取出并删除挑战;过期或不存在返回 found=false。 + ConsumePasskeyChallenge(ctx context.Context, challenge []byte) (domain.PasskeyChallenge, bool, error) +} diff --git a/internal/store/poll.go b/internal/store/poll.go new file mode 100644 index 00000000..079d57f1 --- /dev/null +++ b/internal/store/poll.go @@ -0,0 +1,16 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// PollStore 持久化 poll 权威态(定义 + closed + 机密)与投票人列表查询。 +// 投票/关闭走 MessageStore.VoteMessagePoll / ChannelStore.VoteChannelMessagePoll 等 +// 消息侧入口(需要消息可见性校验);本接口只承担发送时建 poll 与 getPollVotes 列表。 +type PollStore interface { + CreatePoll(ctx context.Context, def domain.PollDefinition) error + GetPollDefinition(ctx context.Context, pollID int64) (domain.PollDefinition, bool, error) + ListPollVotes(ctx context.Context, req domain.PollVotesListRequest) (domain.PollVotesList, error) +} diff --git a/internal/store/postgres/account.go b/internal/store/postgres/account.go index 0a34930d..ff30cb15 100644 --- a/internal/store/postgres/account.go +++ b/internal/store/postgres/account.go @@ -31,7 +31,7 @@ SELECT email_unconfirmed_pattern, login_email_pattern, secure_random, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_id, srp_verifier, srp_b_secret, srp_b, - recovery_email, recovery_code, recovery_code_expires_at + recovery_email, recovery_code, recovery_code_expires_at, login_email FROM account_passwords WHERE user_id = $1`, userID) var settings domain.PasswordSettings @@ -42,7 +42,7 @@ WHERE user_id = $1`, userID) &settings.EmailUnconfirmedPattern, &settings.LoginEmailPattern, &settings.SecureRandom, &salt1, &salt2, &settings.NewAlgo.G, &p, &settings.SRPID, &settings.SRPVerifier, &settings.SRPBSecret, &settings.SRPB, - &settings.RecoveryEmail, &settings.RecoveryCode, &recoveryExpires, + &settings.RecoveryEmail, &settings.RecoveryCode, &recoveryExpires, &settings.LoginEmail, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.PasswordSettings{}, false, nil @@ -85,9 +85,9 @@ INSERT INTO account_passwords ( email_unconfirmed_pattern, login_email_pattern, secure_random, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_id, srp_verifier, srp_b_secret, srp_b, - recovery_email, recovery_code, recovery_code_expires_at + recovery_email, recovery_code, recovery_code_expires_at, login_email ) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) ON CONFLICT (user_id) DO UPDATE SET has_recovery = EXCLUDED.has_recovery, has_secure_values = EXCLUDED.has_secure_values, @@ -107,13 +107,14 @@ ON CONFLICT (user_id) DO UPDATE SET recovery_email = EXCLUDED.recovery_email, recovery_code = EXCLUDED.recovery_code, recovery_code_expires_at = EXCLUDED.recovery_code_expires_at, + login_email = EXCLUDED.login_email, updated_at = now()`, userID, settings.HasRecovery, settings.HasSecureValues, settings.HasPassword, settings.Hint, - settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, settings.SecureRandom, - algo.Salt1, algo.Salt2, algo.G, algo.P, - settings.SRPID, settings.SRPVerifier, settings.SRPBSecret, settings.SRPB, - settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires, + settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, nonNilBytea(settings.SecureRandom), + nonNilBytea(algo.Salt1), nonNilBytea(algo.Salt2), algo.G, nonNilBytea(algo.P), + settings.SRPID, nonNilBytea(settings.SRPVerifier), nonNilBytea(settings.SRPBSecret), nonNilBytea(settings.SRPB), + settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires, settings.LoginEmail, ) if err != nil { return fmt.Errorf("upsert account password: %w", err) @@ -121,6 +122,13 @@ ON CONFLICT (user_id) DO UPDATE SET return nil } +func nonNilBytea(in []byte) []byte { + if in != nil { + return in + } + return []byte{} +} + func (s *PasswordStore) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error) { row := s.db.QueryRow(ctx, ` SELECT messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews, @@ -146,7 +154,9 @@ WHERE user_id = $1`, userID) settings.Notify.MessagesFrom = domain.ReactionNotifyFrom(messagesFrom) settings.Notify.StoriesFrom = domain.ReactionNotifyFrom(storiesFrom) settings.Notify.PollVotesFrom = domain.ReactionNotifyFrom(pollVotesFrom) - settings.DefaultReaction = domain.MessageReaction{Type: domain.MessageReactionType(defaultType), Emoticon: defaultValue} + if reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(defaultType), defaultValue); ok { + settings.DefaultReaction = reaction + } settings.PaidPrivacy = domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyKind(paidKind)} if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && paidPeerType.Valid && paidPeerID.Valid { peer := domain.Peer{Type: domain.PeerType(paidPeerType.String), ID: paidPeerID.Int64} @@ -180,10 +190,601 @@ ON CONFLICT (user_id) DO UPDATE SET updated_at = now()`, userID, string(settings.Notify.MessagesFrom), string(settings.Notify.StoriesFrom), string(settings.Notify.PollVotesFrom), settings.Notify.ShowPreviews, - string(settings.DefaultReaction.Type), settings.DefaultReaction.Emoticon, + string(settings.DefaultReaction.Type), settings.DefaultReaction.Value(), string(settings.PaidPrivacy.Kind), paidPeerType, paidPeerID, ); err != nil { return fmt.Errorf("save account reaction settings: %w", err) } return nil } + +func (s *PasswordStore) GetAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, bool, error) { + row := s.db.QueryRow(ctx, ` +SELECT archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders, + hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button, + noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent +FROM account_settings +WHERE user_id = $1`, userID) + settings := domain.DefaultAccountSettings() + gp := &settings.GlobalPrivacy + if err := row.Scan( + &gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders, + &gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton, + &gp.NoncontactPeersPaidStars, &settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.AccountSettings{}, false, nil + } + return domain.AccountSettings{}, false, fmt.Errorf("get account settings: %w", err) + } + return settings, true, nil +} + +func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error { + gp := settings.GlobalPrivacy + if _, err := s.db.Exec(ctx, ` +INSERT INTO account_settings ( + user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders, + hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button, + noncontact_peers_paid_stars, account_ttl_days, sensitive_content_enabled, contact_signup_silent +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) +ON CONFLICT (user_id) DO UPDATE SET + archive_and_mute_new_noncontact_peers = EXCLUDED.archive_and_mute_new_noncontact_peers, + keep_archived_unmuted = EXCLUDED.keep_archived_unmuted, + keep_archived_folders = EXCLUDED.keep_archived_folders, + hide_read_marks = EXCLUDED.hide_read_marks, + new_noncontact_peers_require_premium = EXCLUDED.new_noncontact_peers_require_premium, + display_gifts_button = EXCLUDED.display_gifts_button, + noncontact_peers_paid_stars = EXCLUDED.noncontact_peers_paid_stars, + account_ttl_days = EXCLUDED.account_ttl_days, + sensitive_content_enabled = EXCLUDED.sensitive_content_enabled, + contact_signup_silent = EXCLUDED.contact_signup_silent, + updated_at = now()`, + userID, + gp.ArchiveAndMuteNewNoncontactPeers, gp.KeepArchivedUnmuted, gp.KeepArchivedFolders, + gp.HideReadMarks, gp.NewNoncontactPeersRequirePremium, gp.DisplayGiftsButton, + gp.NoncontactPeersPaidStars, settings.NormalizedTTLDays(), settings.SensitiveContentEnabled, settings.ContactSignUpSilent, + ); err != nil { + return fmt.Errorf("save account settings: %w", err) + } + return nil +} + +func notifyScopeColumns(scope domain.NotifyScope) (kind, peerType string, peerID int64, topicID int) { + kind = string(scope.Kind) + if scope.Kind == domain.NotifyScopePeer { + return kind, string(scope.Peer.Type), scope.Peer.ID, scope.TopicID + } + return kind, "", 0, 0 +} + +func scanNotifySettings(row pgx.Row) (domain.PeerNotifySettings, error) { + var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool + var muteUntil sql.NullInt32 + if err := row.Scan(&showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil { + return domain.PeerNotifySettings{}, err + } + out := domain.PeerNotifySettings{} + if showPreviews.Valid { + out.ShowPreviews = &showPreviews.Bool + } + if silent.Valid { + out.Silent = &silent.Bool + } + if muteUntil.Valid { + v := int(muteUntil.Int32) + out.MuteUntil = &v + } + if storiesMuted.Valid { + out.StoriesMuted = &storiesMuted.Bool + } + if storiesHideSender.Valid { + out.StoriesHideSender = &storiesHideSender.Bool + } + return out, nil +} + +func (s *PasswordStore) GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, bool, error) { + kind, peerType, peerID, topicID := notifyScopeColumns(scope) + row := s.db.QueryRow(ctx, ` +SELECT show_previews, silent, mute_until, stories_muted, stories_hide_sender +FROM notify_settings +WHERE owner_user_id = $1 AND scope_kind = $2 AND peer_type = $3 AND peer_id = $4 AND topic_id = $5`, + ownerUserID, kind, peerType, peerID, topicID) + settings, err := scanNotifySettings(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PeerNotifySettings{}, false, nil + } + return domain.PeerNotifySettings{}, false, fmt.Errorf("get notify settings: %w", err) + } + return settings, true, nil +} + +func (s *PasswordStore) SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error { + kind, peerType, peerID, topicID := notifyScopeColumns(scope) + if _, err := s.db.Exec(ctx, ` +INSERT INTO notify_settings ( + owner_user_id, scope_kind, peer_type, peer_id, topic_id, + show_previews, silent, mute_until, stories_muted, stories_hide_sender +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) +ON CONFLICT (owner_user_id, scope_kind, peer_type, peer_id, topic_id) DO UPDATE SET + show_previews = EXCLUDED.show_previews, + silent = EXCLUDED.silent, + mute_until = EXCLUDED.mute_until, + stories_muted = EXCLUDED.stories_muted, + stories_hide_sender = EXCLUDED.stories_hide_sender, + updated_at = now()`, + ownerUserID, kind, peerType, peerID, topicID, + nullableBool(settings.ShowPreviews), nullableBool(settings.Silent), nullableInt(settings.MuteUntil), + nullableBool(settings.StoriesMuted), nullableBool(settings.StoriesHideSender), + ); err != nil { + return fmt.Errorf("save notify settings: %w", err) + } + return nil +} + +func (s *PasswordStore) ResetNotifySettings(ctx context.Context, ownerUserID int64) error { + if _, err := s.db.Exec(ctx, `DELETE FROM notify_settings WHERE owner_user_id = $1`, ownerUserID); err != nil { + return fmt.Errorf("reset notify settings: %w", err) + } + return nil +} + +func (s *PasswordStore) GetPeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) { + out := make(map[domain.Peer]domain.PeerNotifySettings, len(peers)) + if len(peers) == 0 { + return out, nil + } + types := make([]string, 0, len(peers)) + ids := make([]int64, 0, len(peers)) + for _, p := range peers { + types = append(types, string(p.Type)) + ids = append(ids, p.ID) + } + rows, err := s.db.Query(ctx, ` +SELECT peer_type, peer_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender +FROM notify_settings +WHERE owner_user_id = $1 AND scope_kind = 'peer' AND topic_id = 0 + AND (peer_type, peer_id) IN (SELECT * FROM unnest($2::text[], $3::bigint[]))`, + ownerUserID, types, ids) + if err != nil { + return nil, fmt.Errorf("batch get notify settings: %w", err) + } + defer rows.Close() + for rows.Next() { + var peerType string + var peerID int64 + var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool + var muteUntil sql.NullInt32 + if err := rows.Scan(&peerType, &peerID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil { + return nil, fmt.Errorf("scan notify settings: %w", err) + } + settings := domain.PeerNotifySettings{} + if showPreviews.Valid { + settings.ShowPreviews = &showPreviews.Bool + } + if silent.Valid { + settings.Silent = &silent.Bool + } + if muteUntil.Valid { + v := int(muteUntil.Int32) + settings.MuteUntil = &v + } + if storiesMuted.Valid { + settings.StoriesMuted = &storiesMuted.Bool + } + if storiesHideSender.Valid { + settings.StoriesHideSender = &storiesHideSender.Bool + } + out[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = settings + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate notify settings: %w", err) + } + return out, nil +} + +func (s *PasswordStore) AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) { + // owner-scoped 单查询,走部分索引 notify_settings_owner_peer_idx(scope_kind='peer' AND topic_id=0)。 + rows, err := s.db.Query(ctx, ` +SELECT peer_type, peer_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender +FROM notify_settings +WHERE owner_user_id = $1 AND scope_kind = 'peer' AND topic_id = 0`, ownerUserID) + if err != nil { + return nil, fmt.Errorf("all peer notify settings: %w", err) + } + defer rows.Close() + out := make(map[domain.Peer]domain.PeerNotifySettings) + for rows.Next() { + var peerType string + var peerID int64 + var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool + var muteUntil sql.NullInt32 + if err := rows.Scan(&peerType, &peerID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil { + return nil, fmt.Errorf("scan all peer notify settings: %w", err) + } + settings := domain.PeerNotifySettings{} + if showPreviews.Valid { + settings.ShowPreviews = &showPreviews.Bool + } + if silent.Valid { + settings.Silent = &silent.Bool + } + if muteUntil.Valid { + v := int(muteUntil.Int32) + settings.MuteUntil = &v + } + if storiesMuted.Valid { + settings.StoriesMuted = &storiesMuted.Bool + } + if storiesHideSender.Valid { + settings.StoriesHideSender = &storiesHideSender.Bool + } + out[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = settings + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate all peer notify settings: %w", err) + } + return out, nil +} + +func (s *PasswordStore) ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error) { + rows, err := s.db.Query(ctx, ` +SELECT peer_type, peer_id, topic_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender +FROM notify_settings +WHERE owner_user_id = $1 AND scope_kind = 'peer' +ORDER BY peer_type, peer_id, topic_id`, ownerUserID) + if err != nil { + return nil, fmt.Errorf("list notify exceptions: %w", err) + } + defer rows.Close() + out := make([]domain.NotifyException, 0) + for rows.Next() { + var peerType string + var peerID int64 + var topicID int + var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool + var muteUntil sql.NullInt32 + if err := rows.Scan(&peerType, &peerID, &topicID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil { + return nil, fmt.Errorf("scan notify exception: %w", err) + } + settings := domain.PeerNotifySettings{} + if showPreviews.Valid { + settings.ShowPreviews = &showPreviews.Bool + } + if silent.Valid { + settings.Silent = &silent.Bool + } + if muteUntil.Valid { + v := int(muteUntil.Int32) + settings.MuteUntil = &v + } + if storiesMuted.Valid { + settings.StoriesMuted = &storiesMuted.Bool + } + if storiesHideSender.Valid { + settings.StoriesHideSender = &storiesHideSender.Bool + } + if settings.IsZero() { + continue // 全默认行(如曾静音后取消)不算异常 + } + out = append(out, domain.NotifyException{ + Peer: domain.Peer{Type: domain.PeerType(peerType), ID: peerID}, + TopicID: topicID, + Settings: settings, + }) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate notify exceptions: %w", err) + } + return out, nil +} + +func (s *PasswordStore) SaveStickerCollectionItem(ctx context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now, max int) error { + if userID == 0 || documentID == 0 { + return domain.ErrStickerInvalid + } + if unsave { + if _, err := s.db.Exec(ctx, `DELETE FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2 AND document_id = $3`, + userID, string(kind), documentID); err != nil { + return fmt.Errorf("unsave sticker collection item: %w", err) + } + return nil + } + if max <= 0 { + max = domain.MaxStickerCollectionItems(kind) + } + return withTx(ctx, s.db, "save sticker collection item", func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, ` +INSERT INTO user_sticker_collections (owner_user_id, kind, document_id, used_at) +VALUES ($1, $2, $3, $4) +ON CONFLICT (owner_user_id, kind, document_id) DO UPDATE SET used_at = EXCLUDED.used_at`, + userID, string(kind), documentID, now); err != nil { + return fmt.Errorf("upsert sticker collection item: %w", err) + } + // 截断超上界:单次有序窗口扫描(索引 user_sticker_collections_order_idx 服务 + // used_at DESC 排序),按 ctid 删除排名 > max 的旧项,避免 NOT IN 双扫全集。 + if _, err := tx.Exec(ctx, ` +DELETE FROM user_sticker_collections +WHERE ctid IN ( + SELECT ctid FROM ( + SELECT ctid, ROW_NUMBER() OVER (ORDER BY used_at DESC, document_id DESC) AS rn + FROM user_sticker_collections + WHERE owner_user_id = $1 AND kind = $2 + ) t WHERE rn > $3 +)`, userID, string(kind), max); err != nil { + return fmt.Errorf("trim sticker collection: %w", err) + } + return nil + }) +} + +func (s *PasswordStore) ListStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) { + if userID == 0 { + return nil, nil + } + if limit <= 0 || limit > domain.MaxStickerCollectionItems(kind) { + limit = domain.MaxStickerCollectionItems(kind) + } + rows, err := s.db.Query(ctx, ` +SELECT document_id, used_at +FROM user_sticker_collections +WHERE owner_user_id = $1 AND kind = $2 +ORDER BY used_at DESC, document_id DESC +LIMIT $3`, userID, string(kind), limit) + if err != nil { + return nil, fmt.Errorf("list sticker collection: %w", err) + } + defer rows.Close() + out := make([]domain.StickerCollectionItem, 0, limit) + for rows.Next() { + var item domain.StickerCollectionItem + if err := rows.Scan(&item.DocumentID, &item.Date); err != nil { + return nil, fmt.Errorf("scan sticker collection item: %w", err) + } + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate sticker collection: %w", err) + } + return out, nil +} + +func (s *PasswordStore) ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error { + if _, err := s.db.Exec(ctx, `DELETE FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2`, + userID, string(kind)); err != nil { + return fmt.Errorf("clear sticker collection: %w", err) + } + return nil +} + +func nullableBool(v *bool) any { + if v == nil { + return nil + } + return *v +} + +func nullableInt(v *int) any { + if v == nil { + return nil + } + return *v +} + +func (s *PasswordStore) SaveMusic(ctx context.Context, req domain.SaveMusicRequest) error { + if req.UserID == 0 || req.Document.ID == 0 || !req.Document.IsMusic() { + return domain.ErrDocumentInvalid + } + return withTx(ctx, s.db, "save account music", func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` +SELECT document_id +FROM saved_music +WHERE user_id = $1 +ORDER BY sort_order ASC, document_id ASC +FOR UPDATE`, req.UserID) + if err != nil { + return fmt.Errorf("lock saved music: %w", err) + } + defer rows.Close() + current := make([]int64, 0, domain.MaxSavedMusicItems) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return fmt.Errorf("scan saved music id: %w", err) + } + current = append(current, id) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("scan saved music ids: %w", err) + } + if req.Unsave { + if _, err := tx.Exec(ctx, `DELETE FROM saved_music WHERE user_id = $1 AND document_id = $2`, req.UserID, req.Document.ID); err != nil { + return fmt.Errorf("delete saved music: %w", err) + } + return nil + } + exists := false + for _, id := range current { + if id == req.Document.ID { + exists = true + break + } + } + if req.AfterDocumentID == req.Document.ID { + if exists { + return nil + } + return domain.ErrDocumentInvalid + } + next := make([]int64, 0, len(current)+1) + afterIndex := -1 + for _, id := range current { + if id == req.Document.ID { + continue + } + if id == req.AfterDocumentID { + afterIndex = len(next) + } + next = append(next, id) + } + if req.AfterDocumentID != 0 { + if afterIndex < 0 { + return domain.ErrDocumentInvalid + } + next = append(next, 0) + copy(next[afterIndex+2:], next[afterIndex+1:]) + next[afterIndex+1] = req.Document.ID + } else { + next = append([]int64{req.Document.ID}, next...) + } + if len(next) > domain.MaxSavedMusicItems { + next = next[:domain.MaxSavedMusicItems] + } + if _, err := tx.Exec(ctx, `DELETE FROM saved_music WHERE user_id = $1`, req.UserID); err != nil { + return fmt.Errorf("clear saved music order: %w", err) + } + for i, id := range next { + if _, err := tx.Exec(ctx, ` +INSERT INTO saved_music (user_id, document_id, sort_order, created_at, updated_at) +VALUES ($1, $2, $3, now(), now())`, req.UserID, id, i+1); err != nil { + return fmt.Errorf("insert saved music: %w", err) + } + } + return nil + }) +} + +func (s *PasswordStore) ListSavedMusicIDs(ctx context.Context, userID int64, limit int) ([]int64, error) { + if userID == 0 { + return nil, nil + } + if limit <= 0 || limit > domain.MaxSavedMusicItems { + limit = domain.MaxSavedMusicItems + } + rows, err := s.db.Query(ctx, ` +SELECT document_id +FROM saved_music +WHERE user_id = $1 +ORDER BY sort_order ASC, document_id ASC +LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list saved music ids: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, limit) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan saved music id: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan saved music ids: %w", err) + } + return ids, nil +} + +func (s *PasswordStore) ListSavedMusic(ctx context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) { + out := domain.SavedMusicList{UserID: userID} + if userID == 0 { + return out, nil + } + if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM saved_music WHERE user_id = $1`, userID).Scan(&out.Count); err != nil { + return domain.SavedMusicList{}, fmt.Errorf("count saved music: %w", err) + } + if limit <= 0 || offset < 0 || offset >= out.Count { + return out, nil + } + if limit > domain.MaxSavedMusicItems { + limit = domain.MaxSavedMusicItems + } + docs, err := s.querySavedMusicDocuments(ctx, ` +SELECT d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, + d.attributes::text AS attributes_json, d.thumbs::text AS thumbs_json +FROM saved_music sm +JOIN documents d ON d.id = sm.document_id +WHERE sm.user_id = $1 +ORDER BY sm.sort_order ASC, sm.document_id ASC +OFFSET $2 +LIMIT $3`, userID, offset, limit) + if err != nil { + return domain.SavedMusicList{}, err + } + out.Documents = docs + return out, nil +} + +func (s *PasswordStore) GetSavedMusicByIDs(ctx context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) { + out := domain.SavedMusicList{UserID: userID} + if userID == 0 || len(ids) == 0 { + return out, nil + } + if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM saved_music WHERE user_id = $1`, userID).Scan(&out.Count); err != nil { + return domain.SavedMusicList{}, fmt.Errorf("count saved music: %w", err) + } + docs, err := s.querySavedMusicDocuments(ctx, ` +SELECT d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id, + d.attributes::text AS attributes_json, d.thumbs::text AS thumbs_json +FROM saved_music sm +JOIN documents d ON d.id = sm.document_id +WHERE sm.user_id = $1 + AND sm.document_id = ANY($2::bigint[]) +ORDER BY sm.sort_order ASC, sm.document_id ASC`, userID, ids) + if err != nil { + return domain.SavedMusicList{}, err + } + byID := make(map[int64]domain.Document, len(docs)) + for _, doc := range docs { + byID[doc.ID] = doc + } + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + if doc, ok := byID[id]; ok { + out.Documents = append(out.Documents, doc) + } + } + return out, nil +} + +func (s *PasswordStore) querySavedMusicDocuments(ctx context.Context, sql string, args ...any) ([]domain.Document, error) { + rows, err := s.db.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("query saved music documents: %w", err) + } + defer rows.Close() + docs := make([]domain.Document, 0) + for rows.Next() { + var row sqlcgen.GetDocumentRow + if err := rows.Scan( + &row.ID, + &row.AccessHash, + &row.FileReference, + &row.Date, + &row.MimeType, + &row.Size, + &row.DcID, + &row.AttributesJson, + &row.ThumbsJson, + ); err != nil { + return nil, fmt.Errorf("scan saved music document: %w", err) + } + doc, err := documentFromRow(row) + if err != nil { + return nil, err + } + if doc.IsMusic() { + docs = append(docs, doc) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan saved music documents: %w", err) + } + return docs, nil +} diff --git a/internal/store/postgres/account_notify_integration_test.go b/internal/store/postgres/account_notify_integration_test.go new file mode 100644 index 00000000..1d6a5e9b --- /dev/null +++ b/internal/store/postgres/account_notify_integration_test.go @@ -0,0 +1,128 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestNotifySettingsRoundTripPostgres 回归迁移 0005:per-scope 通知设置真实持久化 +// (peer / 类别默认 / 批量 / reset),含可空字段=未设置语义。 +func TestNotifySettingsRoundTripPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + store := NewPasswordStore(pool) + + users := NewUserStore(pool) + suffix := randomSuffix(t) + u, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1663" + suffix + "01", FirstName: "NotifyOwner"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM notify_settings WHERE owner_user_id = $1", u.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID) + }) + + peerA := domain.Peer{Type: domain.PeerTypeUser, ID: 7001} + peerB := domain.Peer{Type: domain.PeerTypeChannel, ID: 7002} + scopeA := domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: peerA} + + // not found. + if _, found, err := store.GetNotifySettings(ctx, u.ID, scopeA); err != nil || found { + t.Fatalf("get before save = found %v err %v", found, err) + } + + // peer A:只设 mute_until + silent(其余 nil=未设置)。 + mute := 2000000000 + silent := true + if err := store.SaveNotifySettings(ctx, u.ID, scopeA, domain.PeerNotifySettings{MuteUntil: &mute, Silent: &silent}); err != nil { + t.Fatalf("save scope A: %v", err) + } + got, found, err := store.GetNotifySettings(ctx, u.ID, scopeA) + if err != nil || !found { + t.Fatalf("get scope A = found %v err %v", found, err) + } + if got.MuteUntil == nil || *got.MuteUntil != mute || got.Silent == nil || !*got.Silent { + t.Fatalf("scope A = %+v, want mute=%d silent=true", got, mute) + } + if got.ShowPreviews != nil || got.StoriesMuted != nil { + t.Fatalf("unset fields must stay nil, got %+v", got) + } + + // 类别默认(users)独立。 + usersScope := domain.NotifyScope{Kind: domain.NotifyScopeUsers} + dmute := 100 + if err := store.SaveNotifySettings(ctx, u.ID, usersScope, domain.PeerNotifySettings{MuteUntil: &dmute}); err != nil { + t.Fatalf("save users default: %v", err) + } + if g, found, _ := store.GetNotifySettings(ctx, u.ID, usersScope); !found || g.MuteUntil == nil || *g.MuteUntil != dmute { + t.Fatalf("users default = %+v found %v, want mute=%d", g, found, dmute) + } + // peer A 不受类别默认影响。 + if g, _, _ := store.GetNotifySettings(ctx, u.ID, scopeA); g.MuteUntil == nil || *g.MuteUntil != mute { + t.Fatalf("scope A polluted by users default: %+v", g) + } + + // upsert 覆盖 + 批量(mute_until 是 TL int32,取接近上限的合法值)。 + mute2 := 2100000000 + if err := store.SaveNotifySettings(ctx, u.ID, domain.NotifyScope{Kind: domain.NotifyScopePeer, Peer: peerB}, domain.PeerNotifySettings{MuteUntil: &mute2}); err != nil { + t.Fatalf("save scope B: %v", err) + } + batch, err := store.GetPeerNotifySettings(ctx, u.ID, []domain.Peer{peerA, peerB, {Type: domain.PeerTypeUser, ID: 9999}}) + if err != nil { + t.Fatalf("batch: %v", err) + } + if len(batch) != 2 { + t.Fatalf("batch len = %d, want 2 (peerA+peerB, 9999 unset)", len(batch)) + } + if a, ok := batch[peerA]; !ok || a.MuteUntil == nil || *a.MuteUntil != mute { + t.Fatalf("batch peerA = %+v ok %v", a, ok) + } + if b, ok := batch[peerB]; !ok || b.MuteUntil == nil || *b.MuteUntil != mute2 { + t.Fatalf("batch peerB = %+v ok %v", b, ok) + } + + // AllPeerNotifySettings:返全部整-peer 设置(peerA+peerB),排除类别默认(per-user notify 缓存的加载源)。 + all, err := store.AllPeerNotifySettings(ctx, u.ID) + if err != nil { + t.Fatalf("all peer notify settings: %v", err) + } + if len(all) != 2 { + t.Fatalf("AllPeerNotifySettings = %d, want 2 (peerA+peerB, 类别默认不计)", len(all)) + } + if a, ok := all[peerA]; !ok || a.MuteUntil == nil || *a.MuteUntil != mute { + t.Fatalf("AllPeerNotifySettings peerA = %+v ok %v", a, ok) + } + + // ListNotifyExceptions:只返 per-peer 非默认(peerA+peerB),排除类别默认。 + exceptions, err := store.ListNotifyExceptions(ctx, u.ID) + if err != nil { + t.Fatalf("list exceptions: %v", err) + } + if len(exceptions) != 2 { + t.Fatalf("exceptions = %d, want 2 (peerA+peerB, 类别默认不计)", len(exceptions)) + } + seen := map[domain.Peer]bool{} + for _, ex := range exceptions { + seen[ex.Peer] = true + } + if !seen[peerA] || !seen[peerB] { + t.Fatalf("exceptions peers = %v, want peerA+peerB", seen) + } + + // reset 清空全部作用域。 + if err := store.ResetNotifySettings(ctx, u.ID); err != nil { + t.Fatalf("reset: %v", err) + } + if ex, _ := store.ListNotifyExceptions(ctx, u.ID); len(ex) != 0 { + t.Fatalf("exceptions after reset = %d, want 0", len(ex)) + } + if _, found, _ := store.GetNotifySettings(ctx, u.ID, scopeA); found { + t.Fatalf("scope A must be gone after reset") + } + if _, found, _ := store.GetNotifySettings(ctx, u.ID, usersScope); found { + t.Fatalf("users default must be gone after reset") + } +} diff --git a/internal/store/postgres/account_settings_integration_test.go b/internal/store/postgres/account_settings_integration_test.go new file mode 100644 index 00000000..d00e7bec --- /dev/null +++ b/internal/store/postgres/account_settings_integration_test.go @@ -0,0 +1,69 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestAccountSettingsRoundTripPostgres 回归迁移 0004:账号级单例设置真实持久化 +// (globalPrivacy/accountTTL/sensitive/contactSignUp),含 not-found→默认、upsert 覆盖。 +func TestAccountSettingsRoundTripPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + store := NewPasswordStore(pool) + + users := NewUserStore(pool) + suffix := randomSuffix(t) + u, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1664" + suffix + "01", FirstName: "AcctSettings"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM account_settings WHERE user_id = $1", u.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID) + }) + + // 未持久化:not found。 + if _, found, err := store.GetAccountSettings(ctx, u.ID); err != nil || found { + t.Fatalf("get before save = found %v err %v, want not found", found, err) + } + + want := domain.AccountSettings{ + GlobalPrivacy: domain.GlobalPrivacy{ + ArchiveAndMuteNewNoncontactPeers: true, + HideReadMarks: true, + DisplayGiftsButton: true, + NoncontactPeersPaidStars: 75, + }, + AccountTTLDays: 30, + SensitiveContentEnabled: true, + ContactSignUpSilent: true, + } + if err := store.SaveAccountSettings(ctx, u.ID, want); err != nil { + t.Fatalf("save account settings: %v", err) + } + got, found, err := store.GetAccountSettings(ctx, u.ID) + if err != nil || !found { + t.Fatalf("get after save = found %v err %v", found, err) + } + if got != want { + t.Fatalf("account settings round-trip = %+v, want %+v", got, want) + } + + // upsert 覆盖:改若干字段再读回。 + want.GlobalPrivacy.HideReadMarks = false + want.AccountTTLDays = 365 + want.SensitiveContentEnabled = false + if err := store.SaveAccountSettings(ctx, u.ID, want); err != nil { + t.Fatalf("re-save account settings: %v", err) + } + got, found, err = store.GetAccountSettings(ctx, u.ID) + if err != nil || !found { + t.Fatalf("get after re-save = found %v err %v", found, err) + } + if got != want { + t.Fatalf("account settings after upsert = %+v, want %+v", got, want) + } +} diff --git a/internal/store/postgres/account_stickers_integration_test.go b/internal/store/postgres/account_stickers_integration_test.go new file mode 100644 index 00000000..a88f116e --- /dev/null +++ b/internal/store/postgres/account_stickers_integration_test.go @@ -0,0 +1,90 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestStickerCollectionsRoundTripPostgres 回归迁移 0006:个人贴纸/GIF 集合持久化 +// (最新置顶 / 截断 / unsave / clear / 类别隔离)。store 仅存 document_id(无 FK)。 +func TestStickerCollectionsRoundTripPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + store := NewPasswordStore(pool) + + const owner = int64(778899001) + t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM user_sticker_collections WHERE owner_user_id = $1", owner) }) + _, _ = pool.Exec(ctx, "DELETE FROM user_sticker_collections WHERE owner_user_id = $1", owner) + + faved := domain.StickerCollectionFaved + // fave 101 then 102 → 最新在前。 + if err := store.SaveStickerCollectionItem(ctx, owner, faved, 101, false, 1000, 100); err != nil { + t.Fatalf("fave 101: %v", err) + } + if err := store.SaveStickerCollectionItem(ctx, owner, faved, 102, false, 1001, 100); err != nil { + t.Fatalf("fave 102: %v", err) + } + if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 2 || ids[0] != 102 || ids[1] != 101 { + t.Fatalf("faved = %v, want [102 101]", ids) + } + + // 重 fave 101(更新 used_at)→ 移到最前。 + if err := store.SaveStickerCollectionItem(ctx, owner, faved, 101, false, 1002, 100); err != nil { + t.Fatalf("re-fave 101: %v", err) + } + if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 2 || ids[0] != 101 { + t.Fatalf("faved after re-fave = %v, want 101 first", ids) + } + + // 截断:max=2,加第 3 个挤掉最旧。 + if err := store.SaveStickerCollectionItem(ctx, owner, faved, 103, false, 1003, 2); err != nil { + t.Fatalf("fave 103 (max 2): %v", err) + } + if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 2 || ids[0] != 103 || ids[1] != 101 { + t.Fatalf("faved after trim = %v, want [103 101]", ids) + } + + // 类别隔离:recent 与 faved 独立。 + if err := store.SaveStickerCollectionItem(ctx, owner, domain.StickerCollectionRecent, 999, false, 2000, 30); err != nil { + t.Fatalf("save recent: %v", err) + } + if ids := stickerIDs(t, store, ctx, owner, domain.StickerCollectionRecent); len(ids) != 1 || ids[0] != 999 { + t.Fatalf("recent = %v, want [999]", ids) + } + if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 2 { + t.Fatalf("faved polluted by recent: %v", ids) + } + + // unsave + clear。 + if err := store.SaveStickerCollectionItem(ctx, owner, faved, 101, true, 0, 100); err != nil { + t.Fatalf("unsave 101: %v", err) + } + if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 1 || ids[0] != 103 { + t.Fatalf("faved after unsave = %v, want [103]", ids) + } + if err := store.ClearStickerCollection(ctx, owner, faved); err != nil { + t.Fatalf("clear faved: %v", err) + } + if ids := stickerIDs(t, store, ctx, owner, faved); len(ids) != 0 { + t.Fatalf("faved after clear = %v, want empty", ids) + } + // clear 只清该类别,recent 仍在。 + if ids := stickerIDs(t, store, ctx, owner, domain.StickerCollectionRecent); len(ids) != 1 { + t.Fatalf("recent gone after clearing faved: %v", ids) + } +} + +func stickerIDs(t *testing.T, store *PasswordStore, ctx context.Context, owner int64, kind domain.StickerCollectionKind) []int64 { + t.Helper() + items, err := store.ListStickerCollection(ctx, owner, kind, 100) + if err != nil { + t.Fatalf("list %s: %v", kind, err) + } + ids := make([]int64, 0, len(items)) + for _, it := range items { + ids = append(ids, it.DocumentID) + } + return ids +} diff --git a/internal/store/postgres/admin.go b/internal/store/postgres/admin.go new file mode 100644 index 00000000..2d0a06cd --- /dev/null +++ b/internal/store/postgres/admin.go @@ -0,0 +1,223 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +type AdminStore struct { + db sqlcgen.DBTX +} + +func NewAdminStore(db sqlcgen.DBTX) *AdminStore { + return &AdminStore{db: db} +} + +func (s *AdminStore) BeginCommand(ctx context.Context, cmd domain.AdminCommand) (domain.AdminCommand, bool, error) { + inserted, err := scanAdminCommand(s.db.QueryRow(ctx, ` +INSERT INTO admin_commands ( + command_id, actor, action, target_user_id, target_peer_type, target_peer_id, + dry_run, reason, request, result, status, error, created_at +) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,'{}'::jsonb,$10,'',$11 +) +ON CONFLICT (command_id) DO NOTHING +RETURNING command_id, actor, action, target_user_id, target_peer_type, target_peer_id, + dry_run, reason, request, result, status, error, created_at, completed_at`, + cmd.CommandID, cmd.Actor, cmd.Action, cmd.TargetUserID, string(cmd.TargetPeer.Type), cmd.TargetPeer.ID, + cmd.DryRun, cmd.Reason, string(cmd.RequestJSON), string(cmd.Status), cmd.CreatedAt, + )) + if err == nil { + return inserted, true, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return domain.AdminCommand{}, false, fmt.Errorf("insert admin command: %w", err) + } + existing, err := s.commandByID(ctx, cmd.CommandID) + if err != nil { + return domain.AdminCommand{}, false, err + } + return existing, false, nil +} + +func (s *AdminStore) FinishCommand(ctx context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) { + if len(resultJSON) == 0 { + resultJSON = []byte("{}") + } + beginner, ok := s.db.(txBeginner) + if !ok { + return s.finishCommandNoTx(ctx, commandID, status, resultJSON, errorText) + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.AdminCommand{}, fmt.Errorf("begin finish admin command tx: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + cmd, err := finishAdminCommand(ctx, tx, commandID, status, resultJSON, errorText) + if err != nil { + return domain.AdminCommand{}, err + } + if err := appendAdminAuditLog(ctx, tx, commandID); err != nil { + return domain.AdminCommand{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.AdminCommand{}, fmt.Errorf("commit finish admin command tx: %w", err) + } + committed = true + return cmd, nil +} + +func (s *AdminStore) finishCommandNoTx(ctx context.Context, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) { + cmd, err := finishAdminCommand(ctx, s.db, commandID, status, resultJSON, errorText) + if err != nil { + return domain.AdminCommand{}, err + } + if err := appendAdminAuditLog(ctx, s.db, commandID); err != nil { + return domain.AdminCommand{}, err + } + return cmd, nil +} + +func finishAdminCommand(ctx context.Context, db sqlcgen.DBTX, commandID string, status domain.AdminCommandStatus, resultJSON []byte, errorText string) (domain.AdminCommand, error) { + cmd, err := scanAdminCommand(db.QueryRow(ctx, ` +UPDATE admin_commands +SET status = $2, result = $3::jsonb, error = $4, completed_at = now() +WHERE command_id = $1 +RETURNING command_id, actor, action, target_user_id, target_peer_type, target_peer_id, + dry_run, reason, request, result, status, error, created_at, completed_at`, + commandID, string(status), string(resultJSON), errorText, + )) + if err != nil { + return domain.AdminCommand{}, fmt.Errorf("finish admin command: %w", err) + } + return cmd, nil +} + +func appendAdminAuditLog(ctx context.Context, db sqlcgen.DBTX, commandID string) error { + if _, err := db.Exec(ctx, ` +INSERT INTO admin_audit_logs ( + command_id, actor, action, target_user_id, target_peer_type, target_peer_id, + dry_run, reason, request, result, status, error, created_at +) +SELECT command_id, actor, action, target_user_id, target_peer_type, target_peer_id, + dry_run, reason, request, result, status, error, now() +FROM admin_commands +WHERE command_id = $1 +ON CONFLICT (command_id) DO NOTHING`, commandID); err != nil { + return fmt.Errorf("append admin audit log: %w", err) + } + return nil +} + +func (s *AdminStore) commandByID(ctx context.Context, commandID string) (domain.AdminCommand, error) { + cmd, err := scanAdminCommand(s.db.QueryRow(ctx, ` +SELECT command_id, actor, action, target_user_id, target_peer_type, target_peer_id, + dry_run, reason, request, result, status, error, created_at, completed_at +FROM admin_commands +WHERE command_id = $1`, commandID)) + if err != nil { + return domain.AdminCommand{}, fmt.Errorf("get admin command: %w", err) + } + return cmd, nil +} + +func scanAdminCommand(row pgx.Row) (domain.AdminCommand, error) { + var cmd domain.AdminCommand + var peerType string + var status string + var completed pgtype.Timestamptz + if err := row.Scan( + &cmd.CommandID, + &cmd.Actor, + &cmd.Action, + &cmd.TargetUserID, + &peerType, + &cmd.TargetPeer.ID, + &cmd.DryRun, + &cmd.Reason, + &cmd.RequestJSON, + &cmd.ResultJSON, + &status, + &cmd.Error, + &cmd.CreatedAt, + &completed, + ); err != nil { + return domain.AdminCommand{}, err + } + cmd.TargetPeer.Type = domain.PeerType(peerType) + cmd.Status = domain.AdminCommandStatus(status) + if completed.Valid { + t := completed.Time + cmd.CompletedAt = &t + } + return cmd, nil +} + +func (s *AdminStore) GetSendRestriction(ctx context.Context, userID int64) (domain.AccountSendRestriction, bool, error) { + row := s.db.QueryRow(ctx, ` +SELECT user_id, frozen, reason, actor, command_id, updated_at +FROM account_send_restrictions +WHERE user_id = $1`, userID) + r, err := scanSendRestriction(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.AccountSendRestriction{}, false, nil + } + return domain.AccountSendRestriction{}, false, fmt.Errorf("get send restriction: %w", err) + } + return r, true, nil +} + +func (s *AdminStore) SetSendRestriction(ctx context.Context, restriction domain.AccountSendRestriction) (domain.AccountSendRestriction, error) { + row := s.db.QueryRow(ctx, ` +INSERT INTO account_send_restrictions (user_id, frozen, reason, actor, command_id, updated_at) +VALUES ($1,$2,$3,$4,$5,now()) +ON CONFLICT (user_id) DO UPDATE SET + frozen = EXCLUDED.frozen, + reason = EXCLUDED.reason, + actor = EXCLUDED.actor, + command_id = EXCLUDED.command_id, + updated_at = now() +RETURNING user_id, frozen, reason, actor, command_id, updated_at`, + restriction.UserID, restriction.Frozen, restriction.Reason, restriction.Actor, restriction.CommandID, + ) + out, err := scanSendRestriction(row) + if err != nil { + return domain.AccountSendRestriction{}, fmt.Errorf("set send restriction: %w", err) + } + return out, nil +} + +func (s *AdminStore) IsSendFrozen(ctx context.Context, userID int64) (bool, error) { + var frozen bool + if err := s.db.QueryRow(ctx, `SELECT frozen FROM account_send_restrictions WHERE user_id = $1`, userID).Scan(&frozen); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("check send restriction: %w", err) + } + return frozen, nil +} + +func scanSendRestriction(row pgx.Row) (domain.AccountSendRestriction, error) { + var r domain.AccountSendRestriction + var updated time.Time + if err := row.Scan(&r.UserID, &r.Frozen, &r.Reason, &r.Actor, &r.CommandID, &updated); err != nil { + return domain.AccountSendRestriction{}, err + } + r.UpdatedAt = updated + return r, nil +} diff --git a/internal/store/postgres/auth_login_integration_test.go b/internal/store/postgres/auth_login_integration_test.go index a760b33e..c075da8c 100644 --- a/internal/store/postgres/auth_login_integration_test.go +++ b/internal/store/postgres/auth_login_integration_test.go @@ -84,3 +84,115 @@ func TestAuthSignUpWritesOfficialLoginMessagePostgres(t *testing.T) { t.Fatalf("users = %+v, want official support user", list.Users) } } + +func TestAuthSignInOfficialLoginMessagePreservesReadWatermarkPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + phone := fmt.Sprintf("1555%d32", time.Now().UnixNano()) + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE phone = $1", phone) + }) + + users := NewUserStore(pool) + dialogs := NewDialogStore(pool) + messages := NewMessageStore(pool) + svc := appauth.NewService( + users, + NewAuthorizationStore(pool), + memory.NewCodeStore(), + nil, + nil, + "12345", + appauth.WithLoginMessages(messages, dialogs), + ) + + var authKeyID [8]byte + var authKeyBody [256]byte + if _, err := rand.Read(authKeyID[:]); err != nil { + t.Fatal(err) + } + if _, err := rand.Read(authKeyBody[:]); err != nil { + t.Fatal(err) + } + if err := NewAuthKeyStore(pool).Save(ctx, store.AuthKeyData{ID: authKeyID, Value: authKeyBody}); err != nil { + t.Fatalf("save auth key: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authKeyID)) + }) + + hash, err := svc.SendCode(ctx, phone) + if err != nil { + t.Fatalf("SendCode signup: %v", err) + } + u, first, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "PgLogin", "Read") + if err != nil { + t.Fatalf("SignUp: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID} + read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: u.ID, + Peer: peer, + MaxID: domain.MaxMessageBoxID, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("ReadHistory first login message: %v", err) + } + if read.MaxID != first.ID || read.StillUnreadCount != 0 { + t.Fatalf("read first login message = %+v, want max_id %d unread 0", read, first.ID) + } + + assertOfficialDialog := func(wantTop, wantRead, wantUnread int) { + t.Helper() + var top, readMax, unread, computed int + if err := pool.QueryRow(ctx, ` +WITH target AS ( + SELECT top_message_id, read_inbox_max_id, unread_count + FROM dialogs + WHERE user_id = $1 AND peer_type = 'user' AND peer_id = $2 +) +SELECT + target.top_message_id, + target.read_inbox_max_id, + target.unread_count, + ( + SELECT count(*)::int + FROM message_boxes m + WHERE m.owner_user_id = $1 + AND m.peer_type = 'user' + AND m.peer_id = $2 + AND NOT m.outgoing + AND NOT m.deleted + AND m.box_id > target.read_inbox_max_id + ) AS computed_unread +FROM target`, u.ID, domain.OfficialSystemUserID).Scan(&top, &readMax, &unread, &computed); err != nil { + t.Fatalf("query official dialog state: %v", err) + } + if top != wantTop || readMax != wantRead || unread != wantUnread || computed != wantUnread { + t.Fatalf("official dialog top=%d read=%d unread=%d computed=%d, want top=%d read=%d unread=%d", + top, readMax, unread, computed, wantTop, wantRead, wantUnread) + } + } + + hash, err = svc.SendCode(ctx, phone) + if err != nil { + t.Fatalf("SendCode signin second: %v", err) + } + _, second, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345") + if err != nil || needSignUp { + t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err) + } + assertOfficialDialog(second.ID, first.ID, 1) + + hash, err = svc.SendCode(ctx, phone) + if err != nil { + t.Fatalf("SendCode signin third: %v", err) + } + _, third, needSignUp, err := svc.SignIn(ctx, domain.Authorization{AuthKeyID: authKeyID}, phone, hash, "12345") + if err != nil || needSignUp { + t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err) + } + assertOfficialDialog(third.ID, first.ID, 2) +} diff --git a/internal/store/postgres/authkey.go b/internal/store/postgres/authkey.go index faba897c..3ea71f40 100644 --- a/internal/store/postgres/authkey.go +++ b/internal/store/postgres/authkey.go @@ -14,12 +14,13 @@ import ( // AuthKeyStore 用 PostgreSQL 实现 store.AuthKeyStore。 type AuthKeyStore struct { - q *sqlcgen.Queries + q *sqlcgen.Queries + db sqlcgen.DBTX } // NewAuthKeyStore 基于 pgx 连接池(或事务)创建 AuthKeyStore。 func NewAuthKeyStore(db sqlcgen.DBTX) *AuthKeyStore { - return &AuthKeyStore{q: sqlcgen.New(db)} + return &AuthKeyStore{q: sqlcgen.New(db), db: db} } // Save 实现 store.AuthKeyStore。auth_key_id 以小端解释为 int64 存入 BIGINT; @@ -55,6 +56,32 @@ func (s *AuthKeyStore) Get(ctx context.Context, id [8]byte) (store.AuthKeyData, return data, true, nil } +// Delete 实现 store.AuthKeyStore。不存在时静默成功。 +// 手写 SQL 而非 sqlc 生成:避免触碰 sqlcgen 再生成链路。 +// +// 同时清理把本 key 当作 perm key 的 temp auth key 行:temp_auth_key_bindings.temp_auth_key_id +// 侧有外键 ON DELETE CASCADE,删除 temp key 会自动清绑定;perm_auth_key_id 列无外键, +// 因此被踢/登出删除 perm key 时必须先把关联 temp key 一并删掉。否则 Web/上传连接用 +// raw temp key 重连时仍能进入 RPC 层,只得到 AUTH_KEY_UNREGISTERED,而不是连接层 404。 +func (s *AuthKeyStore) Delete(ctx context.Context, id [8]byte) error { + keyID := authKeyIDToInt64(id) + if _, err := s.db.Exec(ctx, ` +WITH doomed_temp AS ( + SELECT temp_auth_key_id + FROM temp_auth_key_bindings + WHERE perm_auth_key_id = $1 +), deleted_temp AS ( + DELETE FROM auth_keys + WHERE auth_key_id IN (SELECT temp_auth_key_id FROM doomed_temp) +) +DELETE FROM auth_keys +WHERE auth_key_id = $1 +`, keyID); err != nil { + return fmt.Errorf("delete auth key and temp bindings: %w", err) + } + return nil +} + // authKeyIDToInt64 把 [8]byte 的 auth_key_id 按小端解释为 int64(MTProto 定义即 SHA1 低 64 位)。 func authKeyIDToInt64(id [8]byte) int64 { return int64(binary.LittleEndian.Uint64(id[:])) diff --git a/internal/store/postgres/authorization.go b/internal/store/postgres/authorization.go index 55973a50..1cc310dd 100644 --- a/internal/store/postgres/authorization.go +++ b/internal/store/postgres/authorization.go @@ -29,8 +29,8 @@ func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) e a.Hash = authorizationHash(a.AuthKeyID) } _, err := s.db.Exec(ctx, ` -INSERT INTO authorizations (auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip) -VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) +INSERT INTO authorizations (auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (auth_key_id) DO UPDATE SET user_id = EXCLUDED.user_id, hash = EXCLUDED.hash, @@ -41,8 +41,9 @@ ON CONFLICT (auth_key_id) DO UPDATE SET api_id = EXCLUDED.api_id, app_version = EXCLUDED.app_version, ip = EXCLUDED.ip, + password_pending = EXCLUDED.password_pending, active_at = now()`, - authKeyIDToInt64(a.AuthKeyID), a.UserID, a.Hash, int32(a.Layer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, + authKeyIDToInt64(a.AuthKeyID), a.UserID, a.Hash, int32(a.Layer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP, a.PasswordPending, ) if err != nil { return fmt.Errorf("upsert authorization: %w", err) @@ -51,24 +52,29 @@ ON CONFLICT (auth_key_id) DO UPDATE SET } func (s *AuthorizationStore) ByAuthKey(ctx context.Context, id [8]byte) (domain.Authorization, bool, error) { - row, err := s.q.GetAuthorizationByAuthKey(ctx, authKeyIDToInt64(id)) - if err != nil { + row := s.db.QueryRow(ctx, ` +SELECT user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at +FROM authorizations WHERE auth_key_id = $1`, authKeyIDToInt64(id)) + a := domain.Authorization{AuthKeyID: id} + if err := row.Scan( + &a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion, + &a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt, + ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return domain.Authorization{}, false, nil } return domain.Authorization{}, false, fmt.Errorf("get authorization: %w", err) } - return domain.Authorization{ - AuthKeyID: id, - UserID: row.UserID, - Layer: int(row.Layer), - DeviceModel: row.DeviceModel, - Platform: row.Platform, - SystemVersion: row.SystemVersion, - APIID: int(row.ApiID), - AppVersion: row.AppVersion, - IP: row.Ip, - }, true, nil + return a, true, nil +} + +// MarkPasswordPassed 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。 +func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte) error { + if _, err := s.db.Exec(ctx, ` +UPDATE authorizations SET password_pending = false, active_at = now() WHERE auth_key_id = $1`, authKeyIDToInt64(id)); err != nil { + return fmt.Errorf("mark authorization password passed: %w", err) + } + return nil } func (s *AuthorizationStore) ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error) { @@ -110,6 +116,42 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers return a, true, nil } +// RevokeByHash 删除协议 auth_key 作为远程踢设备的持久化事实入口。 +// authorizations/update_states 通过 FK cascade 删除;关联 temp auth key 显式删除,避免 raw temp key 重连。 +func (s *AuthorizationStore) RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) { + row := s.db.QueryRow(ctx, ` +WITH target AS MATERIALIZED ( + SELECT auth_key_id, user_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 AND hash = $2 +), deleted_temp AS ( + DELETE FROM auth_keys + WHERE auth_key_id IN ( + SELECT temp_auth_key_id + FROM temp_auth_key_bindings + WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target) + ) + RETURNING auth_key_id +), deleted_keys AS ( + DELETE FROM auth_keys + WHERE auth_key_id IN (SELECT auth_key_id FROM target) + RETURNING auth_key_id +), touched AS ( + SELECT count(*) FROM deleted_temp +) +SELECT target.auth_key_id, target.user_id, target.hash, target.layer, target.device_model, target.platform, + target.system_version, target.api_id, target.app_version, target.ip, target.password_pending, + target.created_at, target.active_at +FROM target +JOIN deleted_keys USING (auth_key_id) +CROSS JOIN touched`, userID, hash) + a, found, err := scanRevokedAuthorization(row) + if err != nil { + return domain.Authorization{}, false, fmt.Errorf("revoke authorization by hash: %w", err) + } + return a, found, nil +} + func (s *AuthorizationStore) DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) { rows, err := s.db.Query(ctx, ` DELETE FROM authorizations @@ -138,6 +180,53 @@ RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_vers return out, nil } +// RevokeByUserExcept 批量删除协议 auth_key,保留 keepAuthKeyID 对应的当前设备。 +func (s *AuthorizationStore) RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) { + rows, err := s.db.Query(ctx, ` +WITH target AS MATERIALIZED ( + SELECT auth_key_id, user_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 AND auth_key_id <> $2 +), deleted_temp AS ( + DELETE FROM auth_keys + WHERE auth_key_id IN ( + SELECT temp_auth_key_id + FROM temp_auth_key_bindings + WHERE perm_auth_key_id IN (SELECT auth_key_id FROM target) + ) + RETURNING auth_key_id +), deleted_keys AS ( + DELETE FROM auth_keys + WHERE auth_key_id IN (SELECT auth_key_id FROM target) + RETURNING auth_key_id +), touched AS ( + SELECT count(*) FROM deleted_temp +) +SELECT target.auth_key_id, target.user_id, target.hash, target.layer, target.device_model, target.platform, + target.system_version, target.api_id, target.app_version, target.ip, target.password_pending, + target.created_at, target.active_at +FROM target +JOIN deleted_keys USING (auth_key_id) +CROSS JOIN touched +ORDER BY target.created_at, target.auth_key_id`, userID, authKeyIDToInt64(keepAuthKeyID)) + if err != nil { + return nil, fmt.Errorf("revoke authorizations by user: %w", err) + } + defer rows.Close() + out := make([]domain.Authorization, 0) + for rows.Next() { + a, err := scanRevokedAuthorizationRow(rows) + if err != nil { + return nil, err + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate revoked authorizations: %w", err) + } + return out, nil +} + func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization { return domain.Authorization{ AuthKeyID: authKeyIDFromInt64(row.AuthKeyID), @@ -155,6 +244,34 @@ func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization { } } +type authorizationScanner interface { + Scan(dest ...any) error +} + +func scanRevokedAuthorization(row authorizationScanner) (domain.Authorization, bool, error) { + a, err := scanRevokedAuthorizationRow(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Authorization{}, false, nil + } + return domain.Authorization{}, false, err + } + return a, true, nil +} + +func scanRevokedAuthorizationRow(row authorizationScanner) (domain.Authorization, error) { + var a domain.Authorization + var authKeyID int64 + if err := row.Scan( + &authKeyID, &a.UserID, &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 domain.Authorization{}, err + } + a.AuthKeyID = authKeyIDFromInt64(authKeyID) + return a, nil +} + func authorizationHash(authKeyID [8]byte) int64 { sum := sha256.Sum256(authKeyID[:]) hash := int64(binary.LittleEndian.Uint64(sum[:8])) diff --git a/internal/store/postgres/authorization_revoke_integration_test.go b/internal/store/postgres/authorization_revoke_integration_test.go new file mode 100644 index 00000000..93f054f9 --- /dev/null +++ b/internal/store/postgres/authorization_revoke_integration_test.go @@ -0,0 +1,180 @@ +package postgres + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestAuthorizationStoreRevokeByHashDeletesProtocolKeyCascadePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + userID := createRevokeTestUser(t, ctx, pool, "hash") + perm := revokeTestAuthKeyID(0x91) + temp := revokeTestAuthKeyID(0x92) + keys := NewAuthKeyStore(pool) + auths := NewAuthorizationStore(pool) + + saveRevokeTestAuthKey(t, ctx, keys, perm) + saveRevokeTestAuthKey(t, ctx, keys, temp) + if err := auths.Bind(ctx, domain.Authorization{ + AuthKeyID: perm, + UserID: userID, + Hash: 9001, + Layer: 225, + DeviceModel: "WebA", + Platform: "web", + SystemVersion: "test", + APIID: 100, + AppVersion: "test", + IP: "127.0.0.1", + PasswordPending: true, + }); err != nil { + t.Fatalf("bind authorization: %v", err) + } + if err := NewUpdateStateStore(pool).Save(ctx, perm, userID, domain.UpdateState{Pts: 11, Date: int(time.Now().Unix())}); err != nil { + t.Fatalf("save update state: %v", err) + } + if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{ + TempAuthKeyID: temp, + PermAuthKeyID: authKeyIDToInt64(perm), + Nonce: 1, + TempSessionID: 2, + ExpiresAt: int(time.Now().Add(time.Hour).Unix()), + EncryptedMessage: []byte{1, 2, 3, 4}, + }); err != nil { + t.Fatalf("save temp binding: %v", err) + } + + deleted, found, err := auths.RevokeByHash(ctx, userID, 9001) + if err != nil || !found { + t.Fatalf("RevokeByHash found=%v err=%v, want found", found, err) + } + if deleted.AuthKeyID != perm || !deleted.PasswordPending { + t.Fatalf("deleted authorization = %+v, want perm key and password_pending", deleted) + } + assertRevokeTestMissingAuthKey(t, ctx, keys, perm) + assertRevokeTestMissingAuthKey(t, ctx, keys, temp) + assertRevokeTestNoAuthorization(t, ctx, auths, perm) + assertRevokeTestTableCount(t, ctx, pool, "update_states", "auth_key_id", authKeyIDToInt64(perm), 0) + assertRevokeTestTableCount(t, ctx, pool, "temp_auth_key_bindings", "temp_auth_key_id", authKeyIDToInt64(temp), 0) +} + +func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + userID := createRevokeTestUser(t, ctx, pool, "bulk") + keys := NewAuthKeyStore(pool) + auths := NewAuthorizationStore(pool) + keep := revokeTestAuthKeyID(0xa1) + revokedOne := revokeTestAuthKeyID(0xa2) + revokedTwo := revokeTestAuthKeyID(0xa3) + tempForTwo := revokeTestAuthKeyID(0xa4) + + for i, key := range [][8]byte{keep, revokedOne, revokedTwo, tempForTwo} { + saveRevokeTestAuthKey(t, ctx, keys, key) + if i < 3 { + if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: key, UserID: userID, Hash: int64(9100 + i)}); err != nil { + t.Fatalf("bind auth %x: %v", key, err) + } + } + } + if err := NewTempAuthKeyBindingStore(pool).Save(ctx, domain.TempAuthKeyBinding{ + TempAuthKeyID: tempForTwo, + PermAuthKeyID: authKeyIDToInt64(revokedTwo), + Nonce: 3, + TempSessionID: 4, + ExpiresAt: int(time.Now().Add(time.Hour).Unix()), + EncryptedMessage: []byte{5, 6, 7, 8}, + }); err != nil { + t.Fatalf("save temp binding: %v", err) + } + + deleted, err := auths.RevokeByUserExcept(ctx, userID, keep) + if err != nil { + t.Fatalf("RevokeByUserExcept: %v", err) + } + if len(deleted) != 2 { + t.Fatalf("deleted len = %d, want 2 (%+v)", len(deleted), deleted) + } + assertRevokeTestPresentAuthKey(t, ctx, keys, keep) + assertRevokeTestPresentAuthorization(t, ctx, auths, keep) + assertRevokeTestMissingAuthKey(t, ctx, keys, revokedOne) + assertRevokeTestMissingAuthKey(t, ctx, keys, revokedTwo) + assertRevokeTestMissingAuthKey(t, ctx, keys, tempForTwo) +} + +func createRevokeTestUser(t *testing.T, ctx context.Context, db *pgxpool.Pool, suffix string) int64 { + t.Helper() + phone := fmt.Sprintf("+1555%09d", time.Now().UnixNano()%1_000_000_000) + var userID int64 + if err := db.QueryRow(ctx, ` +INSERT INTO users (access_hash, phone, first_name) +VALUES ($1, $2, $3) +RETURNING id`, time.Now().UnixNano(), phone, "revoke-"+suffix).Scan(&userID); err != nil { + t.Fatalf("insert user: %v", err) + } + t.Cleanup(func() { + _, _ = db.Exec(ctx, "DELETE FROM users WHERE id = $1", userID) + }) + return userID +} + +func revokeTestAuthKeyID(seed byte) [8]byte { + return [8]byte{seed, seed, seed, seed, seed, seed, seed, seed} +} + +func saveRevokeTestAuthKey(t *testing.T, ctx context.Context, keys store.AuthKeyStore, id [8]byte) { + t.Helper() + if err := keys.Save(ctx, store.AuthKeyData{ID: id, ServerSalt: int64(id[0])}); err != nil { + t.Fatalf("save auth key %x: %v", id, err) + } + t.Cleanup(func() { + _ = keys.Delete(ctx, id) + }) +} + +func assertRevokeTestMissingAuthKey(t *testing.T, ctx context.Context, keys store.AuthKeyStore, id [8]byte) { + t.Helper() + if _, found, err := keys.Get(ctx, id); err != nil || found { + t.Fatalf("auth key %x found=%v err=%v, want missing", id, found, err) + } +} + +func assertRevokeTestPresentAuthKey(t *testing.T, ctx context.Context, keys store.AuthKeyStore, id [8]byte) { + t.Helper() + if _, found, err := keys.Get(ctx, id); err != nil || !found { + t.Fatalf("auth key %x found=%v err=%v, want present", id, found, err) + } +} + +func assertRevokeTestNoAuthorization(t *testing.T, ctx context.Context, auths *AuthorizationStore, id [8]byte) { + t.Helper() + if _, found, err := auths.ByAuthKey(ctx, id); err != nil || found { + t.Fatalf("authorization %x found=%v err=%v, want missing", id, found, err) + } +} + +func assertRevokeTestPresentAuthorization(t *testing.T, ctx context.Context, auths *AuthorizationStore, id [8]byte) { + t.Helper() + if _, found, err := auths.ByAuthKey(ctx, id); err != nil || !found { + t.Fatalf("authorization %x found=%v err=%v, want present", id, found, err) + } +} + +func assertRevokeTestTableCount(t *testing.T, ctx context.Context, db *pgxpool.Pool, table, column string, value int64, want int) { + t.Helper() + var got int + if err := db.QueryRow(ctx, fmt.Sprintf("SELECT count(*) FROM %s WHERE %s = $1", table, column), value).Scan(&got); err != nil { + t.Fatalf("count %s.%s: %v", table, column, err) + } + if got != want { + t.Fatalf("count %s.%s = %d, want %d", table, column, got, want) + } +} diff --git a/internal/store/postgres/bot.go b/internal/store/postgres/bot.go new file mode 100644 index 00000000..2810ad0e --- /dev/null +++ b/internal/store/postgres/bot.go @@ -0,0 +1,441 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// BotStore 用 PostgreSQL 实现 store.BotStore。 +type BotStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries +} + +// NewBotStore 基于 pgx 连接池(或事务)创建 BotStore。 +func NewBotStore(db sqlcgen.DBTX) *BotStore { + return &BotStore{db: db, q: sqlcgen.New(db)} +} + +// CreateBotAccount 在单事务内创建 users 行(is_bot=true, phone 空)与 bots 行。 +func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profile domain.BotProfile) (domain.User, domain.BotProfile, error) { + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.q.WithTx(tx) + + // 事务内对 owner 取 advisory lock 后复核计数,封死 service 层 count-then-insert + // 的 TOCTOU(多设备/并发 /newbot 各自读到 count owner_user_id", + profile.OwnerUserID).Scan(&owned); err != nil { + return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: count: %w", err) + } + if owned >= int64(domain.MaxBotsPerOwner) { + return domain.User{}, domain.BotProfile{}, domain.ErrBotsTooMany + } + + row, err := q.InsertBotUser(ctx, sqlcgen.InsertBotUserParams{ + AccessHash: user.AccessHash, + FirstName: user.FirstName, + Username: strings.TrimSpace(strings.TrimPrefix(user.Username, "@")), + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == "users_username_lower_unique_idx" { + return domain.User{}, domain.BotProfile{}, domain.ErrUsernameOccupied + } + return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: insert user: %w", err) + } + if usernameLower := strings.ToLower(row.Username); usernameLower != "" { + if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil { + return domain.User{}, domain.BotProfile{}, err + } + } + if err := q.InsertBot(ctx, sqlcgen.InsertBotParams{ + BotUserID: row.ID, + OwnerUserID: profile.OwnerUserID, + TokenSecret: profile.TokenSecret, + }); err != nil { + return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: insert bot: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.User{}, domain.BotProfile{}, fmt.Errorf("create bot account: commit: %w", err) + } + profile.BotUserID = row.ID + return userFromModel(row), profile, nil +} + +func (s *BotStore) GetBot(ctx context.Context, botUserID int64) (domain.BotProfile, bool, error) { + if botUserID == 0 { + return domain.BotProfile{}, false, nil + } + row, err := s.q.GetBot(ctx, botUserID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BotProfile{}, false, nil + } + return domain.BotProfile{}, false, fmt.Errorf("get bot: %w", err) + } + profile, err := s.enrichBotProfile(ctx, botProfileFromModel(row)) + if err != nil { + return domain.BotProfile{}, false, err + } + return profile, true, nil +} + +func (s *BotStore) GetBots(ctx context.Context, botUserIDs []int64) (map[int64]domain.BotProfile, error) { + ids := uniqueNonZeroInt64s(botUserIDs...) + if len(ids) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT bot_user_id, owner_user_id, token_secret, description, commands, bot_chat_history, + bot_nochats, inline_placeholder, created_at, updated_at, menu_button_type, + menu_button_text, menu_button_url, bot_inline_geo +FROM bots +WHERE bot_user_id = ANY($1::bigint[])`, ids) + if err != nil { + return nil, fmt.Errorf("get bots: %w", err) + } + defer rows.Close() + out := make(map[int64]domain.BotProfile, len(ids)) + for rows.Next() { + var row sqlcgen.Bot + if err := rows.Scan( + &row.BotUserID, + &row.OwnerUserID, + &row.TokenSecret, + &row.Description, + &row.Commands, + &row.BotChatHistory, + &row.BotNochats, + &row.InlinePlaceholder, + &row.CreatedAt, + &row.UpdatedAt, + &row.MenuButtonType, + &row.MenuButtonText, + &row.MenuButtonUrl, + &row.BotInlineGeo, + ); err != nil { + return nil, err + } + out[row.BotUserID] = botProfileFromModel(row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("get bots: %w", err) + } + for id, profile := range out { + enriched, err := s.enrichBotProfile(ctx, profile) + if err != nil { + return nil, err + } + out[id] = enriched + } + return out, nil +} + +func (s *BotStore) ListBotsByOwner(ctx context.Context, ownerUserID int64) ([]domain.BotProfile, error) { + if ownerUserID == 0 { + return nil, nil + } + rows, err := s.q.ListBotsByOwner(ctx, ownerUserID) + if err != nil { + return nil, fmt.Errorf("list bots by owner: %w", err) + } + out := make([]domain.BotProfile, 0, len(rows)) + for _, row := range rows { + profile, err := s.enrichBotProfile(ctx, botProfileFromModel(row)) + if err != nil { + return nil, err + } + out = append(out, profile) + } + return out, nil +} + +func (s *BotStore) CountBotsByOwner(ctx context.Context, ownerUserID int64) (int, error) { + if ownerUserID == 0 { + return 0, nil + } + n, err := s.q.CountBotsByOwner(ctx, ownerUserID) + if err != nil { + return 0, fmt.Errorf("count bots by owner: %w", err) + } + return int(n), nil +} + +func (s *BotStore) UpdateBotTokenSecret(ctx context.Context, botUserID int64, secret string) error { + n, err := s.q.UpdateBotTokenSecret(ctx, sqlcgen.UpdateBotTokenSecretParams{ + BotUserID: botUserID, + TokenSecret: secret, + }) + if err != nil { + return fmt.Errorf("update bot token secret: %w", err) + } + if n == 0 { + return domain.ErrBotNotFound + } + return nil +} + +// withBumpTx 在单事务内执行 update 闭包后 bump bot 的 bot_info_version,返回新版本。 +func (s *BotStore) withBumpTx(ctx context.Context, botUserID int64, fn func(q *sqlcgen.Queries) error) (int, error) { + beginner, ok := s.db.(txBeginner) + if !ok { + return 0, fmt.Errorf("bot update: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("bot update: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + q := s.q.WithTx(tx) + if err := fn(q); err != nil { + return 0, err + } + ver, err := q.BumpBotInfoVersion(ctx, botUserID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, domain.ErrBotNotFound + } + return 0, fmt.Errorf("bot update: bump version: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("bot update: commit: %w", err) + } + return int(ver), nil +} + +func (s *BotStore) UpdateBotCommands(ctx context.Context, botUserID int64, commands []domain.BotCommand) (int, error) { + payload, err := json.Marshal(commands) + if err != nil { + return 0, fmt.Errorf("update bot commands: encode: %w", err) + } + if len(commands) == 0 { + payload = []byte("[]") + } + return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error { + if _, err := q.UpdateBotCommandsRow(ctx, sqlcgen.UpdateBotCommandsRowParams{ + BotUserID: botUserID, + Commands: payload, + }); err != nil { + return fmt.Errorf("update bot commands: %w", err) + } + return nil + }) +} + +func (s *BotStore) UpdateBotInfo(ctx context.Context, botUserID int64, upd domain.BotInfoUpdate) (int, error) { + return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error { + if upd.SetName || upd.SetAbout { + params := sqlcgen.UpdateBotProfileFieldsParams{ID: botUserID} + if upd.SetName { + name := upd.Name + params.FirstName = &name + } + if upd.SetAbout { + about := upd.About + params.About = &about + } + if _, err := q.UpdateBotProfileFields(ctx, params); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrBotNotFound + } + return fmt.Errorf("update bot profile: %w", err) + } + } + if upd.SetDescription { + if _, err := q.UpdateBotDescriptionRow(ctx, sqlcgen.UpdateBotDescriptionRowParams{ + BotUserID: botUserID, + Description: upd.Description, + }); err != nil { + return fmt.Errorf("update bot description: %w", err) + } + } + return nil + }) +} + +func (s *BotStore) UpdateBotMenuButton(ctx context.Context, botUserID int64, button domain.BotMenuButton) (int, error) { + return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error { + if _, err := q.UpdateBotMenuButtonRow(ctx, sqlcgen.UpdateBotMenuButtonRowParams{ + BotUserID: botUserID, + MenuButtonType: int16(button.Type), + MenuButtonText: button.Text, + MenuButtonUrl: button.URL, + }); err != nil { + return fmt.Errorf("update bot menu button: %w", err) + } + return nil + }) +} + +func (s *BotStore) SetBotInlinePlaceholder(ctx context.Context, botUserID int64, placeholder string) (int, error) { + return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error { + if _, err := q.SetBotInlinePlaceholderRow(ctx, sqlcgen.SetBotInlinePlaceholderRowParams{ + BotUserID: botUserID, + InlinePlaceholder: placeholder, + }); err != nil { + return fmt.Errorf("set bot inline placeholder: %w", err) + } + return nil + }) +} + +func (s *BotStore) SetBotInlineGeo(ctx context.Context, botUserID int64, inlineGeo bool) (int, error) { + return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error { + if _, err := q.SetBotInlineGeoRow(ctx, sqlcgen.SetBotInlineGeoRowParams{ + BotUserID: botUserID, + BotInlineGeo: inlineGeo, + }); err != nil { + return fmt.Errorf("set bot inline geo: %w", err) + } + return nil + }) +} + +func (s *BotStore) SetBotNochats(ctx context.Context, botUserID int64, nochats bool) (int, error) { + return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error { + if _, err := q.SetBotNochatsRow(ctx, sqlcgen.SetBotNochatsRowParams{ + BotUserID: botUserID, + BotNochats: nochats, + }); err != nil { + return fmt.Errorf("set bot nochats: %w", err) + } + return nil + }) +} + +func (s *BotStore) SetBotChatHistory(ctx context.Context, botUserID int64, chatHistory bool) (int, error) { + return s.withBumpTx(ctx, botUserID, func(q *sqlcgen.Queries) error { + if _, err := q.SetBotChatHistoryRow(ctx, sqlcgen.SetBotChatHistoryRowParams{ + BotUserID: botUserID, + BotChatHistory: chatHistory, + }); err != nil { + return fmt.Errorf("set bot chat history: %w", err) + } + return nil + }) +} + +func (s *BotStore) CanBotSendMessage(ctx context.Context, botUserID, userID int64) (bool, error) { + if botUserID == 0 || userID == 0 || botUserID == userID { + return false, nil + } + allowed, err := s.q.CanBotSendMessage(ctx, sqlcgen.CanBotSendMessageParams{ + BotUserID: botUserID, + UserID: userID, + }) + if err != nil { + return false, fmt.Errorf("can bot send message: %w", err) + } + return allowed, nil +} + +func (s *BotStore) AllowBotSendMessage(ctx context.Context, botUserID, userID int64, fromRequest bool) (bool, error) { + if botUserID == 0 || userID == 0 || botUserID == userID { + return false, domain.ErrBotNotFound + } + created, err := s.q.AllowBotSendMessage(ctx, sqlcgen.AllowBotSendMessageParams{ + BotUserID: botUserID, + UserID: userID, + FromRequest: fromRequest, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.ForeignKeyViolation { + return false, domain.ErrBotNotFound + } + return false, fmt.Errorf("allow bot send message: %w", err) + } + return created, nil +} + +func (s *BotStore) GetBotChatState(ctx context.Context, botUserID, userID int64) (domain.BotChatState, bool, error) { + row, err := s.q.GetBotChatState(ctx, sqlcgen.GetBotChatStateParams{ + BotUserID: botUserID, + UserID: userID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BotChatState{}, false, nil + } + return domain.BotChatState{}, false, fmt.Errorf("get bot chat state: %w", err) + } + state := domain.BotChatState{BotUserID: botUserID, UserID: userID} + if err := json.Unmarshal(row.State, &state); err != nil { + return domain.BotChatState{}, false, fmt.Errorf("get bot chat state: decode: %w", err) + } + state.BotUserID, state.UserID = botUserID, userID + return state, true, nil +} + +func (s *BotStore) UpsertBotChatState(ctx context.Context, state domain.BotChatState) error { + payload, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("upsert bot chat state: encode: %w", err) + } + if err := s.q.UpsertBotChatState(ctx, sqlcgen.UpsertBotChatStateParams{ + BotUserID: state.BotUserID, + UserID: state.UserID, + State: payload, + }); err != nil { + return fmt.Errorf("upsert bot chat state: %w", err) + } + return nil +} + +func (s *BotStore) DeleteBotChatState(ctx context.Context, botUserID, userID int64) error { + if err := s.q.DeleteBotChatState(ctx, sqlcgen.DeleteBotChatStateParams{ + BotUserID: botUserID, + UserID: userID, + }); err != nil { + return fmt.Errorf("delete bot chat state: %w", err) + } + return nil +} + +func botProfileFromModel(r sqlcgen.Bot) domain.BotProfile { + p := domain.BotProfile{ + BotUserID: r.BotUserID, + OwnerUserID: r.OwnerUserID, + TokenSecret: r.TokenSecret, + Description: r.Description, + ChatHistory: r.BotChatHistory, + Nochats: r.BotNochats, + InlineGeo: r.BotInlineGeo, + InlinePlaceholder: r.InlinePlaceholder, + MenuButton: domain.BotMenuButton{ + Type: domain.BotMenuButtonType(r.MenuButtonType), + Text: r.MenuButtonText, + URL: r.MenuButtonUrl, + }, + } + if len(r.Commands) > 0 { + // commands 列损坏不阻断读路径:botInfo 退化为空命令列表。 + _ = json.Unmarshal(r.Commands, &p.Commands) + } + return p +} diff --git a/internal/store/postgres/bot_integration_test.go b/internal/store/postgres/bot_integration_test.go new file mode 100644 index 00000000..ba01343c --- /dev/null +++ b/internal/store/postgres/bot_integration_test.go @@ -0,0 +1,235 @@ +package postgres + +import ( + "context" + "fmt" + "testing" + "time" + + appauth "telesrv/internal/app/auth" + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +// TestBotStoreRoundTripPostgres 验证迁移 0090 的 bot 模型:BotFather 种子行、 +// CreateBotAccount 双行事务、空 phone 不撞唯一索引、token 轮换、对话状态。 +func TestBotStoreRoundTripPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + users := NewUserStore(pool) + bots := NewBotStore(pool) + + // BotFather 种子:users 行 + bots 行 + 不可登录(token 为空)。 + bf, found, err := users.ByID(ctx, domain.BotFatherUserID) + if err != nil || !found { + t.Fatalf("BotFather user not seeded: found=%v err=%v", found, err) + } + if !bf.Bot || bf.BotInfoVersion < 1 || bf.Username != "BotFather" || !bf.Verified { + t.Fatalf("BotFather user = %+v, want verified bot with bot_info_version>=1", bf) + } + bfProfile, found, err := bots.GetBot(ctx, domain.BotFatherUserID) + if err != nil || !found { + t.Fatalf("BotFather bots row not seeded: found=%v err=%v", found, err) + } + if bfProfile.TokenSecret != "" || len(bfProfile.Commands) == 0 { + t.Fatalf("BotFather profile = %+v, want empty token with seeded commands", bfProfile) + } + // 空 phone 查询不得命中任何行。 + if _, found, err := users.ByPhone(ctx, ""); err != nil || found { + t.Fatalf("ByPhone('') found=%v err=%v, want not found", found, err) + } + + // owner 用户 + 两个空 phone 的 bot(验证部分唯一索引)。 + suffix := time.Now().UnixNano() % 1_000_000_000 + ownerPhone := fmt.Sprintf("1666%d", suffix) + owner, err := users.Create(ctx, domain.User{AccessHash: 7, Phone: ownerPhone, FirstName: "BotOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + makeBot := func(i int) (domain.User, domain.BotProfile) { + u, p, err := bots.CreateBotAccount(ctx, domain.User{ + AccessHash: int64(100 + i), + FirstName: fmt.Sprintf("IT Bot %d", i), + Username: fmt.Sprintf("it%d_%d_bot", i, suffix), + }, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: fmt.Sprintf("secret-%d-%d", i, suffix)}) + if err != nil { + t.Fatalf("create bot %d: %v", i, err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID) // bots 行随 FK CASCADE + }) + return u, p + } + bot1, profile1 := makeBot(1) + bot2, _ := makeBot(2) + if bot1.Phone != "" || bot2.Phone != "" { + t.Fatalf("bot phones = %q/%q, want empty", bot1.Phone, bot2.Phone) + } + if !bot1.Bot || bot1.BotInfoVersion != 1 { + t.Fatalf("bot1 = %+v, want is_bot with bot_info_version=1", bot1) + } + + // username 冲突映射 ErrUsernameOccupied,且不留 users 孤儿行。 + if _, _, err := bots.CreateBotAccount(ctx, domain.User{ + AccessHash: 999, + FirstName: "Dup", + Username: bot1.Username, + }, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "x"}); err != domain.ErrUsernameOccupied { + t.Fatalf("duplicate username err = %v, want ErrUsernameOccupied", err) + } + + if n, err := bots.CountBotsByOwner(ctx, owner.ID); err != nil || n != 2 { + t.Fatalf("CountBotsByOwner = %d err=%v, want 2", n, err) + } + list, err := bots.ListBotsByOwner(ctx, owner.ID) + if err != nil || len(list) != 2 { + t.Fatalf("ListBotsByOwner = %d err=%v, want 2", len(list), err) + } + + // token 轮换。 + if err := bots.UpdateBotTokenSecret(ctx, bot1.ID, "rotated-secret"); err != nil { + t.Fatalf("rotate token: %v", err) + } + rotated, _, err := bots.GetBot(ctx, bot1.ID) + if err != nil || rotated.TokenSecret != "rotated-secret" { + t.Fatalf("rotated profile = %+v err=%v", rotated, err) + } + if err := bots.UpdateBotTokenSecret(ctx, 424242, "x"); err != domain.ErrBotNotFound { + t.Fatalf("rotate missing bot err = %v, want ErrBotNotFound", err) + } + + // 对话状态 upsert/get/delete。 + state := domain.BotChatState{ + BotUserID: domain.BotFatherUserID, + UserID: owner.ID, + Command: "newbot", + Step: "username", + Draft: map[string]string{"name": "IT Bot"}, + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM bot_chat_states WHERE user_id = $1", owner.ID) + }) + if err := bots.UpsertBotChatState(ctx, state); err != nil { + t.Fatalf("upsert state: %v", err) + } + got, found, err := bots.GetBotChatState(ctx, domain.BotFatherUserID, owner.ID) + if err != nil || !found { + t.Fatalf("get state: found=%v err=%v", found, err) + } + if got.Command != "newbot" || got.Step != "username" || got.Draft["name"] != "IT Bot" { + t.Fatalf("state = %+v, want round-trip", got) + } + if err := bots.DeleteBotChatState(ctx, domain.BotFatherUserID, owner.ID); err != nil { + t.Fatalf("delete state: %v", err) + } + if _, found, _ := bots.GetBotChatState(ctx, domain.BotFatherUserID, owner.ID); found { + t.Fatal("state still present after delete") + } + + // P2 元数据写入 + bot_info_version bump(postgres 单事务)。 + uBefore, _, _ := users.ByID(ctx, bot1.ID) + v1, err := bots.UpdateBotCommands(ctx, bot1.ID, []domain.BotCommand{{Command: "start", Description: "begin"}}) + if err != nil || v1 <= uBefore.BotInfoVersion { + t.Fatalf("UpdateBotCommands version = %d err=%v, want > %d", v1, err, uBefore.BotInfoVersion) + } + gotBot, _, _ := bots.GetBot(ctx, bot1.ID) + if len(gotBot.Commands) != 1 || gotBot.Commands[0].Command != "start" { + t.Fatalf("commands = %+v, want [start]", gotBot.Commands) + } + v2, err := bots.UpdateBotInfo(ctx, bot1.ID, domain.BotInfoUpdate{ + SetName: true, Name: "PG Bot", SetAbout: true, About: "pg about", SetDescription: true, Description: "pg desc", + }) + if err != nil || v2 <= v1 { + t.Fatalf("UpdateBotInfo version = %d err=%v, want > %d", v2, err, v1) + } + uAfter, _, _ := users.ByID(ctx, bot1.ID) + if uAfter.FirstName != "PG Bot" || uAfter.About != "pg about" { + t.Fatalf("bot user after setInfo = first_name=%q about=%q", uAfter.FirstName, uAfter.About) + } + if descBot, _, _ := bots.GetBot(ctx, bot1.ID); descBot.Description != "pg desc" { + t.Fatalf("description = %q, want 'pg desc'", descBot.Description) + } + v3, err := bots.UpdateBotMenuButton(ctx, bot1.ID, domain.BotMenuButton{Type: domain.BotMenuButtonWebView, Text: "Open", URL: "https://pg.example/app"}) + if err != nil || v3 <= v2 { + t.Fatalf("UpdateBotMenuButton version = %d err=%v, want > %d", v3, err, v2) + } + if mbBot, _, _ := bots.GetBot(ctx, bot1.ID); mbBot.MenuButton.Type != domain.BotMenuButtonWebView || mbBot.MenuButton.URL != "https://pg.example/app" { + t.Fatalf("menu button = %+v", mbBot.MenuButton) + } + v4, err := bots.SetBotInlinePlaceholder(ctx, bot1.ID, "Search PG") + if err != nil || v4 <= v3 { + t.Fatalf("SetBotInlinePlaceholder version = %d err=%v, want > %d", v4, err, v3) + } + if inlineBot, _, _ := bots.GetBot(ctx, bot1.ID); inlineBot.InlinePlaceholder != "Search PG" { + t.Fatalf("inline placeholder = %q, want Search PG", inlineBot.InlinePlaceholder) + } + v5, err := bots.SetBotInlineGeo(ctx, bot1.ID, true) + if err != nil || v5 <= v4 { + t.Fatalf("SetBotInlineGeo version = %d err=%v, want > %d", v5, err, v4) + } + if inlineBot, _, _ := bots.GetBot(ctx, bot1.ID); !inlineBot.InlineGeo { + t.Fatalf("inline geo = false, want true") + } + if _, err := bots.SetBotNochats(ctx, bot1.ID, true); err != nil { + t.Fatalf("SetBotNochats: %v", err) + } + if _, err := bots.SetBotChatHistory(ctx, bot1.ID, true); err != nil { + t.Fatalf("SetBotChatHistory: %v", err) + } + if flagBot, _, _ := bots.GetBot(ctx, bot1.ID); !flagBot.Nochats || !flagBot.ChatHistory { + t.Fatalf("flags = nochats=%v chat_history=%v, want both true", flagBot.Nochats, flagBot.ChatHistory) + } + if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || can { + t.Fatalf("CanBotSendMessage before allow = %v,%v, want false,nil", can, err) + } + if created, err := bots.AllowBotSendMessage(ctx, bot1.ID, owner.ID, true); err != nil || !created { + t.Fatalf("AllowBotSendMessage first = %v,%v, want true,nil", created, err) + } + if can, err := bots.CanBotSendMessage(ctx, bot1.ID, owner.ID); err != nil || !can { + t.Fatalf("CanBotSendMessage after allow = %v,%v, want true,nil", can, err) + } + if created, err := bots.AllowBotSendMessage(ctx, bot1.ID, owner.ID, true); err != nil || created { + t.Fatalf("AllowBotSendMessage repeat = %v,%v, want false,nil", created, err) + } + // 不存在的 bot → ErrBotNotFound。 + if _, err := bots.UpdateBotCommands(ctx, 424243, nil); err != domain.ErrBotNotFound { + t.Fatalf("update missing bot err = %v, want ErrBotNotFound", err) + } + + // SignInBot:PG 全链路 token 校验 + authorizations 绑定。 + authSvc := appauth.NewService(users, NewAuthorizationStore(pool), memory.NewCodeStore(), nil, nil, "12345", + appauth.WithBotLogin(bots)) + var authKeyID [8]byte + copy(authKeyID[:], fmt.Sprintf("%08d", suffix%100000000)) + if _, err := pool.Exec(ctx, "INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, $2, 0) ON CONFLICT DO NOTHING", + authKeyIDToInt64(authKeyID), make([]byte, 256)); err != nil { + t.Fatalf("seed auth key: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM auth_keys WHERE auth_key_id = $1", authKeyIDToInt64(authKeyID)) + }) + token := domain.FormatBotToken(profile1.BotUserID, "rotated-secret") + u, err := authSvc.SignInBot(ctx, domain.Authorization{AuthKeyID: authKeyID}, token) + if err != nil { + t.Fatalf("SignInBot: %v", err) + } + if u.ID != bot1.ID || !u.Bot { + t.Fatalf("SignInBot user = %+v, want bot %d", u, bot1.ID) + } + if uid, ok, err := authSvc.UserID(ctx, authKeyID); err != nil || !ok || uid != bot1.ID { + t.Fatalf("UserID after SignInBot = %d,%v,%v, want %d", uid, ok, err, bot1.ID) + } + // 旧 token(轮换前 secret)必须失效。 + oldToken := domain.FormatBotToken(profile1.BotUserID, profile1.TokenSecret) + if _, err := authSvc.SignInBot(ctx, domain.Authorization{AuthKeyID: authKeyID}, oldToken); err != domain.ErrBotTokenInvalid { + t.Fatalf("old token err = %v, want ErrBotTokenInvalid", err) + } + // BotFather 空 token 永不可登录。 + if _, err := authSvc.SignInBot(ctx, domain.Authorization{AuthKeyID: authKeyID}, domain.FormatBotToken(domain.BotFatherUserID, "")); err != domain.ErrBotTokenInvalid { + t.Fatalf("BotFather token err = %v, want ErrBotTokenInvalid", err) + } +} diff --git a/internal/store/postgres/bot_miniapp.go b/internal/store/postgres/bot_miniapp.go new file mode 100644 index 00000000..6e1b99f3 --- /dev/null +++ b/internal/store/postgres/bot_miniapp.go @@ -0,0 +1,585 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func (s *BotStore) withBotInfoBumpRawTx(ctx context.Context, botUserID int64, fn func(pgx.Tx) error) (int, error) { + beginner, ok := s.db.(txBeginner) + if !ok { + return 0, fmt.Errorf("bot mini app update: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("bot mini app update: begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := fn(tx); err != nil { + return 0, err + } + ver, err := s.q.WithTx(tx).BumpBotInfoVersion(ctx, botUserID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, domain.ErrBotNotFound + } + return 0, fmt.Errorf("bot mini app update: bump version: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("bot mini app update: commit: %w", err) + } + return int(ver), nil +} + +func (s *BotStore) enrichBotProfile(ctx context.Context, profile domain.BotProfile) (domain.BotProfile, error) { + if profile.BotUserID == 0 { + return profile, nil + } + var hasMain, hasAttach, hasPreview bool + if err := s.db.QueryRow(ctx, ` +SELECT + EXISTS(SELECT 1 FROM bot_apps WHERE bot_user_id=$1 AND is_main), + EXISTS(SELECT 1 FROM attach_menu_bots WHERE bot_user_id=$1), + EXISTS(SELECT 1 FROM bot_app_preview_media WHERE bot_user_id=$1)`, profile.BotUserID). + Scan(&hasMain, &hasAttach, &hasPreview); err != nil { + return profile, fmt.Errorf("enrich bot profile: flags: %w", err) + } + profile.HasMainApp = hasMain + profile.HasAttachMenu = hasAttach + profile.HasPreviewMedias = hasPreview + settings, found, err := s.GetBotAppSettings(ctx, profile.BotUserID) + if err != nil { + return profile, err + } + if found { + profile.AppSettings = &settings + } + return profile, nil +} + +func (s *BotStore) UpsertBotApp(ctx context.Context, app domain.BotApp) (domain.BotApp, int, error) { + if app.BotUserID == 0 || app.ID == 0 || app.AccessHash == 0 || strings.TrimSpace(app.ShortName) == "" || strings.TrimSpace(app.URL) == "" { + return domain.BotApp{}, 0, domain.ErrBotAppInvalid + } + app.ShortName = strings.ToLower(strings.TrimSpace(app.ShortName)) + version, err := s.withBotInfoBumpRawTx(ctx, app.BotUserID, func(tx pgx.Tx) error { + if app.Main { + if _, err := tx.Exec(ctx, `UPDATE bot_apps SET is_main=false, updated_at=now() WHERE bot_user_id=$1 AND id<>$2 AND is_main`, app.BotUserID, app.ID); err != nil { + return fmt.Errorf("upsert bot app: clear main: %w", err) + } + } + _, err := tx.Exec(ctx, ` +INSERT INTO bot_apps ( + id, bot_user_id, short_name, title, description, url, photo_id, document_id, + access_hash, hash, inactive, request_write_access, has_settings, is_main +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) +ON CONFLICT (id) DO UPDATE SET + short_name=EXCLUDED.short_name, + title=EXCLUDED.title, + description=EXCLUDED.description, + url=EXCLUDED.url, + photo_id=EXCLUDED.photo_id, + document_id=EXCLUDED.document_id, + access_hash=EXCLUDED.access_hash, + hash=EXCLUDED.hash, + inactive=EXCLUDED.inactive, + request_write_access=EXCLUDED.request_write_access, + has_settings=EXCLUDED.has_settings, + is_main=EXCLUDED.is_main, + updated_at=now()`, + app.ID, app.BotUserID, app.ShortName, app.Title, app.Description, app.URL, app.PhotoID, app.DocumentID, + app.AccessHash, app.Hash, app.Inactive, app.RequestWriteAccess, app.HasSettings, app.Main) + if err != nil { + if isUniqueViolation(err) { + return domain.ErrBotAppInvalid + } + return fmt.Errorf("upsert bot app: %w", err) + } + return nil + }) + if err != nil { + return domain.BotApp{}, 0, err + } + return app, version, nil +} + +func (s *BotStore) GetBotAppByID(ctx context.Context, appID, accessHash int64) (domain.BotApp, bool, error) { + if appID == 0 || accessHash == 0 { + return domain.BotApp{}, false, nil + } + row := s.db.QueryRow(ctx, botAppSelectSQL()+` WHERE id=$1 AND access_hash=$2`, appID, accessHash) + return scanBotApp(row) +} + +func (s *BotStore) GetBotAppByShortName(ctx context.Context, botUserID int64, shortName string) (domain.BotApp, bool, error) { + shortName = strings.ToLower(strings.TrimSpace(shortName)) + if botUserID == 0 || shortName == "" { + return domain.BotApp{}, false, nil + } + row := s.db.QueryRow(ctx, botAppSelectSQL()+` WHERE bot_user_id=$1 AND lower(short_name)=lower($2)`, botUserID, shortName) + return scanBotApp(row) +} + +func (s *BotStore) GetMainBotApp(ctx context.Context, botUserID int64) (domain.BotApp, bool, error) { + if botUserID == 0 { + return domain.BotApp{}, false, nil + } + row := s.db.QueryRow(ctx, botAppSelectSQL()+` WHERE bot_user_id=$1 AND is_main LIMIT 1`, botUserID) + return scanBotApp(row) +} + +func (s *BotStore) ListBotApps(ctx context.Context, botUserID int64) ([]domain.BotApp, error) { + rows, err := s.db.Query(ctx, botAppSelectSQL()+` WHERE bot_user_id=$1 ORDER BY is_main DESC, short_name, id`, botUserID) + if err != nil { + return nil, fmt.Errorf("list bot apps: %w", err) + } + defer rows.Close() + var out []domain.BotApp + for rows.Next() { + app, err := scanBotAppRows(rows) + if err != nil { + return nil, err + } + out = append(out, app) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list bot apps: %w", err) + } + return out, nil +} + +func (s *BotStore) GetBotAppSettings(ctx context.Context, botUserID int64) (domain.BotAppSettings, bool, error) { + var settings domain.BotAppSettings + var bg, bgDark, header, headerDark sql.NullInt64 + err := s.db.QueryRow(ctx, ` +SELECT placeholder_path, background_color, background_dark_color, header_color, header_dark_color +FROM bot_app_settings WHERE bot_user_id=$1`, botUserID). + Scan(&settings.PlaceholderPath, &bg, &bgDark, &header, &headerDark) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BotAppSettings{}, false, nil + } + return domain.BotAppSettings{}, false, fmt.Errorf("get bot app settings: %w", err) + } + settings.HasBackgroundColor = bg.Valid + settings.HasBackgroundDark = bgDark.Valid + settings.HasHeaderColor = header.Valid + settings.HasHeaderDarkColor = headerDark.Valid + if bg.Valid { + settings.BackgroundColor = int(bg.Int64) + } + if bgDark.Valid { + settings.BackgroundDarkColor = int(bgDark.Int64) + } + if header.Valid { + settings.HeaderColor = int(header.Int64) + } + if headerDark.Valid { + settings.HeaderDarkColor = int(headerDark.Int64) + } + return settings, true, nil +} + +func (s *BotStore) UpsertBotAppSettings(ctx context.Context, botUserID int64, settings domain.BotAppSettings) (int, error) { + return s.withBotInfoBumpRawTx(ctx, botUserID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` +INSERT INTO bot_app_settings ( + bot_user_id, placeholder_path, background_color, background_dark_color, header_color, header_dark_color +) VALUES ($1,$2,$3,$4,$5,$6) +ON CONFLICT (bot_user_id) DO UPDATE SET + placeholder_path=EXCLUDED.placeholder_path, + background_color=EXCLUDED.background_color, + background_dark_color=EXCLUDED.background_dark_color, + header_color=EXCLUDED.header_color, + header_dark_color=EXCLUDED.header_dark_color, + updated_at=now()`, + botUserID, settings.PlaceholderPath, + nullableBotAppInt(settings.BackgroundColor, settings.HasBackgroundColor), + nullableBotAppInt(settings.BackgroundDarkColor, settings.HasBackgroundDark), + nullableBotAppInt(settings.HeaderColor, settings.HasHeaderColor), + nullableBotAppInt(settings.HeaderDarkColor, settings.HasHeaderDarkColor)) + if err != nil { + return fmt.Errorf("upsert bot app settings: %w", err) + } + return nil + }) +} + +func (s *BotStore) ListBotAppPreviewMedia(ctx context.Context, botUserID, appID int64) ([]domain.BotAppPreviewMedia, error) { + rows, err := s.db.Query(ctx, ` +SELECT id, bot_user_id, app_id, position, photo_id, document_id +FROM bot_app_preview_media +WHERE bot_user_id=$1 AND app_id=$2 +ORDER BY position, id`, botUserID, appID) + if err != nil { + return nil, fmt.Errorf("list bot app preview media: %w", err) + } + defer rows.Close() + var out []domain.BotAppPreviewMedia + for rows.Next() { + var media domain.BotAppPreviewMedia + if err := rows.Scan(&media.ID, &media.BotUserID, &media.AppID, &media.Position, &media.PhotoID, &media.DocumentID); err != nil { + return nil, err + } + out = append(out, media) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list bot app preview media: %w", err) + } + return out, nil +} + +func (s *BotStore) UpsertBotAppPreviewMedia(ctx context.Context, media domain.BotAppPreviewMedia) (domain.BotAppPreviewMedia, int, error) { + if media.BotUserID == 0 || media.AppID == 0 || (media.PhotoID == 0 && media.DocumentID == 0) { + return domain.BotAppPreviewMedia{}, 0, domain.ErrBotAppInvalid + } + version, err := s.withBotInfoBumpRawTx(ctx, media.BotUserID, func(tx pgx.Tx) error { + if media.Position <= 0 { + if err := tx.QueryRow(ctx, `SELECT COALESCE(MAX(position), 0) + 1 FROM bot_app_preview_media WHERE app_id=$1`, media.AppID).Scan(&media.Position); err != nil { + return fmt.Errorf("upsert bot preview media: next position: %w", err) + } + } + if media.ID == 0 { + return tx.QueryRow(ctx, ` +INSERT INTO bot_app_preview_media (bot_user_id, app_id, position, photo_id, document_id) +VALUES ($1,$2,$3,$4,$5) +RETURNING id`, media.BotUserID, media.AppID, media.Position, media.PhotoID, media.DocumentID).Scan(&media.ID) + } + _, err := tx.Exec(ctx, ` +INSERT INTO bot_app_preview_media (id, bot_user_id, app_id, position, photo_id, document_id) +VALUES ($1,$2,$3,$4,$5,$6) +ON CONFLICT (id) DO UPDATE SET + position=EXCLUDED.position, + photo_id=EXCLUDED.photo_id, + document_id=EXCLUDED.document_id, + updated_at=now()`, + media.ID, media.BotUserID, media.AppID, media.Position, media.PhotoID, media.DocumentID) + return err + }) + if err != nil { + return domain.BotAppPreviewMedia{}, 0, err + } + return media, version, nil +} + +func (s *BotStore) DeleteBotAppPreviewMedia(ctx context.Context, botUserID, appID, mediaID int64) (int, error) { + return s.withBotInfoBumpRawTx(ctx, botUserID, func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, `DELETE FROM bot_app_preview_media WHERE bot_user_id=$1 AND app_id=$2 AND id=$3`, botUserID, appID, mediaID) + if err != nil { + return fmt.Errorf("delete bot app preview media: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrBotAppInvalid + } + return nil + }) +} + +func (s *BotStore) ReorderBotAppPreviewMedia(ctx context.Context, botUserID, appID int64, mediaIDs []int64) (int, error) { + return s.withBotInfoBumpRawTx(ctx, botUserID, func(tx pgx.Tx) error { + for i, id := range mediaIDs { + tag, err := tx.Exec(ctx, `UPDATE bot_app_preview_media SET position=$1, updated_at=now() WHERE bot_user_id=$2 AND app_id=$3 AND id=$4`, -(i + 1), botUserID, appID, id) + if err != nil { + return fmt.Errorf("reorder bot preview media: stage: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrBotAppInvalid + } + } + for i, id := range mediaIDs { + tag, err := tx.Exec(ctx, `UPDATE bot_app_preview_media SET position=$1, updated_at=now() WHERE bot_user_id=$2 AND app_id=$3 AND id=$4`, i+1, botUserID, appID, id) + if err != nil { + return fmt.Errorf("reorder bot preview media: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrBotAppInvalid + } + } + return nil + }) +} + +func (s *BotStore) UpsertAttachMenuBot(ctx context.Context, bot domain.BotAttachMenuBot) (int, error) { + if bot.BotUserID == 0 || strings.TrimSpace(bot.ShortName) == "" { + return 0, domain.ErrBotAttachMenuInvalid + } + bot.ShortName = strings.ToLower(strings.TrimSpace(bot.ShortName)) + icons, err := json.Marshal(bot.Icons) + if err != nil { + return 0, fmt.Errorf("upsert attach menu bot: icons: %w", err) + } + return s.withBotInfoBumpRawTx(ctx, bot.BotUserID, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` +INSERT INTO attach_menu_bots ( + bot_user_id, app_id, short_name, inactive, has_settings, request_write_access, + show_in_attach_menu, show_in_side_menu, side_menu_disclaimer_needed, peer_types, icons +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) +ON CONFLICT (bot_user_id) DO UPDATE SET + app_id=EXCLUDED.app_id, + short_name=EXCLUDED.short_name, + inactive=EXCLUDED.inactive, + has_settings=EXCLUDED.has_settings, + request_write_access=EXCLUDED.request_write_access, + show_in_attach_menu=EXCLUDED.show_in_attach_menu, + show_in_side_menu=EXCLUDED.show_in_side_menu, + side_menu_disclaimer_needed=EXCLUDED.side_menu_disclaimer_needed, + peer_types=EXCLUDED.peer_types, + icons=EXCLUDED.icons, + updated_at=now()`, + bot.BotUserID, nullableInt64(bot.AppID), bot.ShortName, bot.Inactive, bot.HasSettings, bot.RequestWriteAccess, + bot.ShowInAttachMenu, bot.ShowInSideMenu, bot.SideMenuDisclaimerNeeded, bot.PeerTypes, icons) + if err != nil { + return fmt.Errorf("upsert attach menu bot: %w", err) + } + return nil + }) +} + +func (s *BotStore) GetAttachMenuBot(ctx context.Context, botUserID int64) (domain.BotAttachMenuBot, bool, error) { + row := s.db.QueryRow(ctx, attachMenuBotSelectSQL()+` WHERE bot_user_id=$1`, botUserID) + return scanAttachMenuBot(row) +} + +func (s *BotStore) ListAttachMenuBots(ctx context.Context) ([]domain.BotAttachMenuBot, error) { + rows, err := s.db.Query(ctx, attachMenuBotSelectSQL()+` ORDER BY inactive, bot_user_id`) + if err != nil { + return nil, fmt.Errorf("list attach menu bots: %w", err) + } + defer rows.Close() + var out []domain.BotAttachMenuBot + for rows.Next() { + bot, err := scanAttachMenuBotRows(rows) + if err != nil { + return nil, err + } + out = append(out, bot) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list attach menu bots: %w", err) + } + return out, nil +} + +func (s *BotStore) GetAttachMenuState(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, bool, error) { + var state domain.BotAttachMenuState + err := s.db.QueryRow(ctx, ` +SELECT user_id, bot_user_id, enabled, write_allowed +FROM attach_menu_user_states WHERE user_id=$1 AND bot_user_id=$2`, userID, botUserID). + Scan(&state.UserID, &state.BotUserID, &state.Enabled, &state.WriteAllowed) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BotAttachMenuState{}, false, nil + } + return domain.BotAttachMenuState{}, false, fmt.Errorf("get attach menu state: %w", err) + } + return state, true, nil +} + +func (s *BotStore) SetAttachMenuState(ctx context.Context, state domain.BotAttachMenuState) (domain.BotAttachMenuState, error) { + if state.UserID == 0 || state.BotUserID == 0 || state.UserID == state.BotUserID { + return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid + } + _, err := s.db.Exec(ctx, ` +INSERT INTO attach_menu_user_states (user_id, bot_user_id, enabled, write_allowed) +VALUES ($1,$2,$3,$4) +ON CONFLICT (user_id, bot_user_id) DO UPDATE SET + enabled=EXCLUDED.enabled, + write_allowed=attach_menu_user_states.write_allowed OR EXCLUDED.write_allowed, + updated_at=now()`, + state.UserID, state.BotUserID, state.Enabled, state.WriteAllowed) + if err != nil { + return domain.BotAttachMenuState{}, fmt.Errorf("set attach menu state: %w", err) + } + return s.GetAttachMenuStateValue(ctx, state.UserID, state.BotUserID) +} + +func (s *BotStore) GetAttachMenuStateValue(ctx context.Context, userID, botUserID int64) (domain.BotAttachMenuState, error) { + state, found, err := s.GetAttachMenuState(ctx, userID, botUserID) + if err != nil { + return domain.BotAttachMenuState{}, err + } + if !found { + return domain.BotAttachMenuState{}, domain.ErrBotAttachMenuInvalid + } + return state, nil +} + +func (s *BotStore) SaveRequestedWebViewButton(ctx context.Context, button domain.BotRequestedWebViewButton) error { + if button.BotUserID == 0 || button.UserID == 0 || button.WebAppReqID == "" || button.ExpiresAt.IsZero() { + return domain.ErrBotRequestedButtonInvalid + } + _, err := s.db.Exec(ctx, ` +INSERT INTO webview_requested_buttons (webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) +ON CONFLICT (webapp_req_id) DO UPDATE SET + button_id=EXCLUDED.button_id, + text=EXCLUDED.text, + peer_type=EXCLUDED.peer_type, + max_quantity=EXCLUDED.max_quantity, + expires_at=EXCLUDED.expires_at`, + button.WebAppReqID, button.BotUserID, button.UserID, button.ButtonID, button.Text, button.PeerType, button.MaxQuantity, button.CreatedAt, button.ExpiresAt) + if err != nil { + return fmt.Errorf("save requested webview button: %w", err) + } + return nil +} + +func (s *BotStore) GetRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) (domain.BotRequestedWebViewButton, bool, error) { + _, _ = s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE expires_at <= now()`) + var button domain.BotRequestedWebViewButton + err := s.db.QueryRow(ctx, ` +SELECT webapp_req_id, bot_user_id, user_id, button_id, text, peer_type, max_quantity, created_at, expires_at +FROM webview_requested_buttons +WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3 AND expires_at > now()`, + botUserID, userID, webAppReqID). + Scan(&button.WebAppReqID, &button.BotUserID, &button.UserID, &button.ButtonID, &button.Text, &button.PeerType, &button.MaxQuantity, &button.CreatedAt, &button.ExpiresAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BotRequestedWebViewButton{}, false, nil + } + return domain.BotRequestedWebViewButton{}, false, fmt.Errorf("get requested webview button: %w", err) + } + return button, true, nil +} + +func (s *BotStore) DeleteRequestedWebViewButton(ctx context.Context, botUserID, userID int64, webAppReqID string) error { + _, err := s.db.Exec(ctx, `DELETE FROM webview_requested_buttons WHERE bot_user_id=$1 AND user_id=$2 AND webapp_req_id=$3`, botUserID, userID, webAppReqID) + if err != nil { + return fmt.Errorf("delete requested webview button: %w", err) + } + return nil +} + +func (s *BotStore) SetBotEmojiStatusPermission(ctx context.Context, botUserID, userID int64, allowed bool) error { + if botUserID == 0 || userID == 0 || botUserID == userID { + return domain.ErrBotNotFound + } + if !allowed { + _, err := s.db.Exec(ctx, `DELETE FROM bot_emoji_status_permissions WHERE bot_user_id=$1 AND user_id=$2`, botUserID, userID) + return err + } + _, err := s.db.Exec(ctx, ` +INSERT INTO bot_emoji_status_permissions (bot_user_id, user_id, allowed) +VALUES ($1,$2,true) +ON CONFLICT (bot_user_id, user_id) DO UPDATE SET allowed=true, updated_at=now()`, botUserID, userID) + if err != nil { + return fmt.Errorf("set bot emoji status permission: %w", err) + } + return nil +} + +func (s *BotStore) BotEmojiStatusPermission(ctx context.Context, botUserID, userID int64) (bool, error) { + var allowed bool + err := s.db.QueryRow(ctx, `SELECT allowed FROM bot_emoji_status_permissions WHERE bot_user_id=$1 AND user_id=$2`, botUserID, userID).Scan(&allowed) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return false, fmt.Errorf("bot emoji status permission: %w", err) + } + return allowed, nil +} + +func (s *BotStore) PutWebViewCustomMethodQuery(ctx context.Context, query domain.BotWebViewCustomMethodQuery) error { + if query.ID == "" || query.BotUserID == 0 || query.UserID == 0 || query.CustomMethod == "" || query.ExpiresAt.IsZero() { + return domain.ErrBotCustomMethodUnavailable + } + var params any = json.RawMessage(query.ParamsJSON) + if query.ParamsJSON == "" { + params = json.RawMessage(`{}`) + } + _, err := s.db.Exec(ctx, ` +INSERT INTO webview_custom_method_queries (id, bot_user_id, user_id, custom_method, params, created_at, expires_at) +VALUES ($1,$2,$3,$4,$5,$6,$7) +ON CONFLICT (id) DO UPDATE SET + params=EXCLUDED.params, + expires_at=EXCLUDED.expires_at`, + query.ID, query.BotUserID, query.UserID, query.CustomMethod, params, query.CreatedAt, query.ExpiresAt) + if err != nil { + return fmt.Errorf("put webview custom method query: %w", err) + } + return nil +} + +func botAppSelectSQL() string { + return `SELECT id, bot_user_id, short_name, title, description, url, photo_id, document_id, +access_hash, hash, inactive, request_write_access, has_settings, is_main FROM bot_apps` +} + +func scanBotApp(row rowScanner) (domain.BotApp, bool, error) { + app, err := scanBotAppAny(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BotApp{}, false, nil + } + return domain.BotApp{}, false, err + } + return app, true, nil +} + +func scanBotAppRows(rows pgx.Rows) (domain.BotApp, error) { + return scanBotAppAny(rows) +} + +func scanBotAppAny(row rowScanner) (domain.BotApp, error) { + var app domain.BotApp + if err := row.Scan(&app.ID, &app.BotUserID, &app.ShortName, &app.Title, &app.Description, &app.URL, &app.PhotoID, &app.DocumentID, &app.AccessHash, &app.Hash, &app.Inactive, &app.RequestWriteAccess, &app.HasSettings, &app.Main); err != nil { + return domain.BotApp{}, err + } + return app, nil +} + +func attachMenuBotSelectSQL() string { + return `SELECT bot_user_id, COALESCE(app_id,0), short_name, inactive, has_settings, +request_write_access, show_in_attach_menu, show_in_side_menu, side_menu_disclaimer_needed, +peer_types, icons FROM attach_menu_bots` +} + +func scanAttachMenuBot(row rowScanner) (domain.BotAttachMenuBot, bool, error) { + bot, err := scanAttachMenuBotAny(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BotAttachMenuBot{}, false, nil + } + return domain.BotAttachMenuBot{}, false, err + } + return bot, true, nil +} + +func scanAttachMenuBotRows(rows pgx.Rows) (domain.BotAttachMenuBot, error) { + return scanAttachMenuBotAny(rows) +} + +func scanAttachMenuBotAny(row rowScanner) (domain.BotAttachMenuBot, error) { + var bot domain.BotAttachMenuBot + var icons []byte + if err := row.Scan(&bot.BotUserID, &bot.AppID, &bot.ShortName, &bot.Inactive, &bot.HasSettings, &bot.RequestWriteAccess, &bot.ShowInAttachMenu, &bot.ShowInSideMenu, &bot.SideMenuDisclaimerNeeded, &bot.PeerTypes, &icons); err != nil { + return domain.BotAttachMenuBot{}, err + } + if len(icons) > 0 { + _ = json.Unmarshal(icons, &bot.Icons) + } + return bot, nil +} + +func nullableBotAppInt(value int, valid bool) any { + if !valid { + return nil + } + return value +} + +func nullableInt64(value int64) any { + if value == 0 { + return nil + } + return value +} diff --git a/internal/store/postgres/business.go b/internal/store/postgres/business.go new file mode 100644 index 00000000..ef08e18f --- /dev/null +++ b/internal/store/postgres/business.go @@ -0,0 +1,851 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func (s *PasswordStore) GetBusinessProfile(ctx context.Context, userID int64) (domain.BusinessProfile, bool, error) { + row := s.db.QueryRow(ctx, ` +SELECT + COALESCE(work_hours::text, '{}')::text, + COALESCE(location::text, '{}')::text, + COALESCE(intro::text, '{}')::text, + COALESCE(greeting_message::text, '{}')::text, + COALESCE(away_message::text, '{}')::text, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint +FROM user_business_profiles +WHERE user_id = $1`, userID) + var workHoursJSON, locationJSON, introJSON, greetingJSON, awayJSON string + profile := domain.BusinessProfile{UserID: userID} + if err := row.Scan(&workHoursJSON, &locationJSON, &introJSON, &greetingJSON, &awayJSON, &profile.UpdatedAtUnix); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BusinessProfile{}, false, nil + } + return domain.BusinessProfile{}, false, fmt.Errorf("get business profile: %w", err) + } + if err := decodeNullableJSON(workHoursJSON, &profile.WorkHours); err != nil { + return domain.BusinessProfile{}, false, fmt.Errorf("decode work hours: %w", err) + } + if err := decodeNullableJSON(locationJSON, &profile.Location); err != nil { + return domain.BusinessProfile{}, false, fmt.Errorf("decode location: %w", err) + } + if err := decodeNullableJSON(introJSON, &profile.Intro); err != nil { + return domain.BusinessProfile{}, false, fmt.Errorf("decode intro: %w", err) + } + if err := decodeNullableJSON(greetingJSON, &profile.Greeting); err != nil { + return domain.BusinessProfile{}, false, fmt.Errorf("decode greeting: %w", err) + } + if err := decodeNullableJSON(awayJSON, &profile.Away); err != nil { + return domain.BusinessProfile{}, false, fmt.Errorf("decode away: %w", err) + } + return profile, true, nil +} + +func (s *PasswordStore) SaveBusinessProfile(ctx context.Context, profile domain.BusinessProfile) error { + workHours, err := encodeNullableJSON(profile.WorkHours) + if err != nil { + return err + } + location, err := encodeNullableJSON(profile.Location) + if err != nil { + return err + } + intro, err := encodeNullableJSON(profile.Intro) + if err != nil { + return err + } + greeting, err := encodeNullableJSON(profile.Greeting) + if err != nil { + return err + } + away, err := encodeNullableJSON(profile.Away) + if err != nil { + return err + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO user_business_profiles ( + user_id, work_hours, location, intro, greeting_message, away_message, updated_at +) VALUES ($1,$2::jsonb,$3::jsonb,$4::jsonb,$5::jsonb,$6::jsonb,now()) +ON CONFLICT (user_id) DO UPDATE SET + work_hours = EXCLUDED.work_hours, + location = EXCLUDED.location, + intro = EXCLUDED.intro, + greeting_message = EXCLUDED.greeting_message, + away_message = EXCLUDED.away_message, + updated_at = now()`, profile.UserID, string(workHours), string(location), string(intro), string(greeting), string(away)); err != nil { + return fmt.Errorf("save business profile: %w", err) + } + return nil +} + +func (s *PasswordStore) ListBusinessChatLinks(ctx context.Context, ownerUserID int64) ([]domain.BusinessChatLink, error) { + rows, err := s.db.Query(ctx, ` +SELECT slug, owner_user_id, link, message, COALESCE(entities::text, '[]')::text, title, views, + COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint +FROM business_chat_links +WHERE owner_user_id = $1 +ORDER BY created_at ASC, slug ASC +LIMIT $2`, ownerUserID, domain.MaxBusinessChatLinks) + if err != nil { + return nil, fmt.Errorf("list business chat links: %w", err) + } + defer rows.Close() + out := make([]domain.BusinessChatLink, 0) + for rows.Next() { + link, err := scanBusinessChatLink(rows) + if err != nil { + return nil, err + } + out = append(out, link) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan business chat links: %w", err) + } + return out, nil +} + +func (s *PasswordStore) CreateBusinessChatLink(ctx context.Context, link domain.BusinessChatLink) (domain.BusinessChatLink, error) { + return link, withTx(ctx, s.db, "create business chat link", func(tx pgx.Tx) error { + var count int + if err := tx.QueryRow(ctx, `SELECT COUNT(*)::int FROM business_chat_links WHERE owner_user_id = $1`, link.OwnerUserID).Scan(&count); err != nil { + return fmt.Errorf("count business chat links: %w", err) + } + if count >= domain.MaxBusinessChatLinks { + return domain.ErrBusinessChatLinksTooMuch + } + entities, err := encodeMessageEntities(link.Entities) + if err != nil { + return err + } + row := tx.QueryRow(ctx, ` +INSERT INTO business_chat_links (slug, owner_user_id, link, message, entities, title, views, created_at, updated_at) +VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7,now(),now()) +RETURNING slug, owner_user_id, link, message, COALESCE(entities::text, '[]')::text, title, views, + COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`, + link.Slug, link.OwnerUserID, link.Link, link.Message, string(entities), link.Title, link.Views) + saved, err := scanBusinessChatLink(row) + if err != nil { + return fmt.Errorf("insert business chat link: %w", err) + } + link = saved + return nil + }) +} + +func (s *PasswordStore) UpdateBusinessChatLink(ctx context.Context, ownerUserID int64, slug string, input domain.BusinessChatLinkInput) (domain.BusinessChatLink, error) { + entities, err := encodeMessageEntities(input.Entities) + if err != nil { + return domain.BusinessChatLink{}, err + } + row := s.db.QueryRow(ctx, ` +UPDATE business_chat_links +SET message = $3, + entities = $4::jsonb, + title = $5, + updated_at = now() +WHERE owner_user_id = $1 + AND slug = $2 +RETURNING slug, owner_user_id, link, message, COALESCE(entities::text, '[]')::text, title, views, + COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`, ownerUserID, slug, input.Message, string(entities), input.Title) + link, err := scanBusinessChatLink(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BusinessChatLink{}, domain.ErrBusinessChatLinkNotFound + } + return domain.BusinessChatLink{}, fmt.Errorf("update business chat link: %w", err) + } + return link, nil +} + +func (s *PasswordStore) DeleteBusinessChatLink(ctx context.Context, ownerUserID int64, slug string) (bool, error) { + tag, err := s.db.Exec(ctx, `DELETE FROM business_chat_links WHERE owner_user_id = $1 AND slug = $2`, ownerUserID, slug) + if err != nil { + return false, fmt.Errorf("delete business chat link: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +func (s *PasswordStore) ResolveBusinessChatLink(ctx context.Context, slug string, bumpViews bool) (domain.BusinessChatLink, bool, error) { + sqlText := ` +SELECT slug, owner_user_id, link, message, COALESCE(entities::text, '[]')::text, title, views, + COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint +FROM business_chat_links +WHERE slug = $1` + if bumpViews { + sqlText = ` +UPDATE business_chat_links +SET views = views + 1, + updated_at = now() +WHERE slug = $1 +RETURNING slug, owner_user_id, link, message, COALESCE(entities::text, '[]')::text, title, views, + COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint` + } + link, err := scanBusinessChatLink(s.db.QueryRow(ctx, sqlText, slug)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BusinessChatLink{}, false, nil + } + return domain.BusinessChatLink{}, false, fmt.Errorf("resolve business chat link: %w", err) + } + return link, true, nil +} + +func (s *PasswordStore) ListQuickReplies(ctx context.Context, ownerUserID int64, includeTopMessages bool) (domain.QuickReplyList, error) { + replies, err := s.listQuickReplies(ctx, ownerUserID) + if err != nil { + return domain.QuickReplyList{}, err + } + messages := make([]domain.QuickReplyMessage, 0, len(replies)) + if includeTopMessages { + for _, reply := range replies { + if reply.TopMessage == 0 { + continue + } + msgs, err := s.GetQuickReplyMessages(ctx, ownerUserID, reply.ID, []int{reply.TopMessage}) + if err != nil { + return domain.QuickReplyList{}, err + } + messages = append(messages, msgs.Messages...) + } + } + return domain.QuickReplyList{ + OwnerUserID: ownerUserID, + QuickReplies: replies, + Messages: messages, + Hash: postgresQuickReplyListHash(replies), + }, nil +} + +func (s *PasswordStore) CheckQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcut string) (bool, error) { + shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut) + if err != nil { + return false, err + } + var id int + err = s.db.QueryRow(ctx, `SELECT shortcut_id FROM quick_replies WHERE owner_user_id = $1 AND lower(shortcut) = lower($2)`, ownerUserID, shortcut).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return true, nil + } + if err != nil { + return false, fmt.Errorf("check quick reply shortcut: %w", err) + } + return false, nil +} + +func (s *PasswordStore) SaveQuickReplyText(ctx context.Context, ownerUserID int64, shortcut string, msg domain.QuickReplyMessage) (domain.QuickReplyMutation, error) { + shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut) + if err != nil { + return domain.QuickReplyMutation{}, err + } + var mutation domain.QuickReplyMutation + err = withTx(ctx, s.db, "save quick reply text", func(tx pgx.Tx) error { + shortcutID, created, err := ensureQuickReplyShortcut(ctx, tx, ownerUserID, shortcut) + if err != nil { + return err + } + var count int + if err := tx.QueryRow(ctx, `SELECT COUNT(*)::int FROM quick_reply_messages WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, shortcutID).Scan(&count); err != nil { + return fmt.Errorf("count quick reply messages: %w", err) + } + if count >= domain.MaxQuickReplyMessages { + return domain.ErrShortcutInvalid + } + var messageID int + if err := tx.QueryRow(ctx, `SELECT COALESCE(MAX(message_id), 0)::int + 1 FROM quick_reply_messages WHERE owner_user_id = $1`, ownerUserID).Scan(&messageID); err != nil { + return fmt.Errorf("allocate quick reply message id: %w", err) + } + entities, err := encodeMessageEntities(msg.Entities) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO quick_reply_messages (owner_user_id, shortcut_id, message_id, random_id, message_date, body, entities, created_at, updated_at) +VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,now(),now())`, + ownerUserID, shortcutID, messageID, msg.RandomID, msg.Date, msg.Message, string(entities)); err != nil { + return fmt.Errorf("insert quick reply message: %w", err) + } + replies, err := listQuickRepliesTx(ctx, tx, ownerUserID) + if err != nil { + return err + } + message := domain.QuickReplyMessage{ + OwnerUserID: ownerUserID, + ShortcutID: shortcutID, + ID: messageID, + RandomID: msg.RandomID, + Date: msg.Date, + Message: msg.Message, + Entities: append([]domain.MessageEntity(nil), msg.Entities...), + } + reply := quickReplyByID(replies, shortcutID) + kind := domain.QuickReplyMutationMessage + if created { + kind = domain.QuickReplyMutationNew + } + mutation = domain.QuickReplyMutation{ + Kind: kind, + List: domain.QuickReplyList{OwnerUserID: ownerUserID, QuickReplies: replies, Messages: []domain.QuickReplyMessage{message}, Hash: postgresQuickReplyListHash(replies)}, + QuickReply: reply, + ShortcutID: shortcutID, + Message: message, + } + return nil + }) + if err != nil { + return domain.QuickReplyMutation{}, err + } + return mutation, nil +} + +func (s *PasswordStore) GetQuickReplyMessages(ctx context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMessages, error) { + if err := s.ensureQuickReplyExists(ctx, ownerUserID, shortcutID); err != nil { + return domain.QuickReplyMessages{}, err + } + args := []any{ownerUserID, shortcutID} + filter := "" + if len(ids) > 0 { + ids32 := make([]int32, len(ids)) + for i, id := range ids { + ids32[i] = int32(id) + } + filter = " AND message_id = ANY($3::int[])" + args = append(args, ids32) + } + rows, err := s.db.Query(ctx, ` +SELECT message_id, random_id, message_date, body, COALESCE(entities::text, '[]')::text +FROM quick_reply_messages +WHERE owner_user_id = $1 + AND shortcut_id = $2`+filter+` +ORDER BY message_id ASC`, args...) + if err != nil { + return domain.QuickReplyMessages{}, fmt.Errorf("list quick reply messages: %w", err) + } + defer rows.Close() + out := domain.QuickReplyMessages{OwnerUserID: ownerUserID, ShortcutID: shortcutID} + for rows.Next() { + msg, err := scanQuickReplyMessage(ownerUserID, shortcutID, rows) + if err != nil { + return domain.QuickReplyMessages{}, err + } + out.Messages = append(out.Messages, msg) + } + if err := rows.Err(); err != nil { + return domain.QuickReplyMessages{}, fmt.Errorf("scan quick reply messages: %w", err) + } + var total int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM quick_reply_messages WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, shortcutID).Scan(&total); err != nil { + return domain.QuickReplyMessages{}, fmt.Errorf("count quick reply messages: %w", err) + } + if len(ids) > 0 && len(out.Messages) != len(ids) { + return domain.QuickReplyMessages{}, domain.ErrShortcutInvalid + } + out.Count = total + out.Hash = postgresQuickReplyMessagesHash(out.Messages) + return out, nil +} + +func (s *PasswordStore) RenameQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcutID int, shortcut string) (domain.QuickReplyMutation, error) { + shortcut, err := domain.NormalizeQuickReplyShortcut(shortcut) + if err != nil { + return domain.QuickReplyMutation{}, err + } + tag, err := s.db.Exec(ctx, ` +UPDATE quick_replies +SET shortcut = $3, + updated_at = now() +WHERE owner_user_id = $1 + AND shortcut_id = $2`, ownerUserID, shortcutID, shortcut) + if err != nil { + return domain.QuickReplyMutation{}, quickReplyPGErr(err) + } + if tag.RowsAffected() == 0 { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + list, err := s.ListQuickReplies(ctx, ownerUserID, true) + if err != nil { + return domain.QuickReplyMutation{}, err + } + return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationList, List: list}, nil +} + +func (s *PasswordStore) ReorderQuickReplies(ctx context.Context, ownerUserID int64, order []int) (domain.QuickReplyMutation, error) { + err := withTx(ctx, s.db, "reorder quick replies", func(tx pgx.Tx) error { + replies, err := listQuickRepliesTx(ctx, tx, ownerUserID) + if err != nil { + return err + } + if len(order) != len(replies) { + return domain.ErrShortcutInvalid + } + seen := make(map[int]struct{}, len(order)) + byID := make(map[int]struct{}, len(replies)) + for _, reply := range replies { + byID[reply.ID] = struct{}{} + } + for i, id := range order { + if _, ok := byID[id]; !ok { + return domain.ErrShortcutInvalid + } + if _, ok := seen[id]; ok { + return domain.ErrShortcutInvalid + } + seen[id] = struct{}{} + if _, err := tx.Exec(ctx, `UPDATE quick_replies SET sort_order = $3, updated_at = now() WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, id, i+1); err != nil { + return fmt.Errorf("update quick reply order: %w", err) + } + } + return nil + }) + if err != nil { + return domain.QuickReplyMutation{}, err + } + list, err := s.ListQuickReplies(ctx, ownerUserID, true) + if err != nil { + return domain.QuickReplyMutation{}, err + } + return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationList, List: list}, nil +} + +func (s *PasswordStore) DeleteQuickReplyShortcut(ctx context.Context, ownerUserID int64, shortcutID int) (domain.QuickReplyMutation, error) { + tag, err := s.db.Exec(ctx, `DELETE FROM quick_replies WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, shortcutID) + if err != nil { + return domain.QuickReplyMutation{}, fmt.Errorf("delete quick reply shortcut: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + _ = s.normalizeQuickReplyOrder(ctx, ownerUserID) + list, err := s.ListQuickReplies(ctx, ownerUserID, true) + if err != nil { + return domain.QuickReplyMutation{}, err + } + return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationDelete, List: list, ShortcutID: shortcutID}, nil +} + +func (s *PasswordStore) DeleteQuickReplyMessages(ctx context.Context, ownerUserID int64, shortcutID int, ids []int) (domain.QuickReplyMutation, error) { + if len(ids) == 0 { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + if err := s.ensureQuickReplyExists(ctx, ownerUserID, shortcutID); err != nil { + return domain.QuickReplyMutation{}, err + } + ids32 := make([]int32, len(ids)) + for i, id := range ids { + ids32[i] = int32(id) + } + tag, err := s.db.Exec(ctx, `DELETE FROM quick_reply_messages WHERE owner_user_id = $1 AND shortcut_id = $2 AND message_id = ANY($3::int[])`, ownerUserID, shortcutID, ids32) + if err != nil { + return domain.QuickReplyMutation{}, fmt.Errorf("delete quick reply messages: %w", err) + } + if int(tag.RowsAffected()) != len(ids) { + return domain.QuickReplyMutation{}, domain.ErrShortcutInvalid + } + list, err := s.ListQuickReplies(ctx, ownerUserID, true) + if err != nil { + return domain.QuickReplyMutation{}, err + } + return domain.QuickReplyMutation{Kind: domain.QuickReplyMutationIDs, List: list, ShortcutID: shortcutID, MessageIDs: append([]int(nil), ids...)}, nil +} + +func (s *PasswordStore) ReserveBusinessAutomationDelivery(ctx context.Context, delivery domain.BusinessAutomationDelivery) (bool, error) { + if delivery.OwnerUserID == 0 || delivery.PeerUserID == 0 || delivery.Kind == "" || delivery.TriggerMessageID == 0 { + return false, domain.ErrBusinessProfileInvalid + } + tag, err := s.db.Exec(ctx, ` +INSERT INTO business_automation_deliveries ( + owner_user_id, peer_user_id, kind, trigger_message_id, shortcut_id, sent_at, created_at +) VALUES ($1,$2,$3,$4,$5,$6,now()) +ON CONFLICT (owner_user_id, peer_user_id, kind, trigger_message_id) DO NOTHING`, + delivery.OwnerUserID, delivery.PeerUserID, string(delivery.Kind), delivery.TriggerMessageID, delivery.ShortcutID, delivery.SentAt) + if err != nil { + return false, fmt.Errorf("reserve business automation delivery: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +func (s *PasswordStore) LastBusinessAutomationDelivery(ctx context.Context, ownerUserID, peerUserID int64, kind domain.BusinessAutomationKind) (domain.BusinessAutomationDelivery, bool, error) { + row := s.db.QueryRow(ctx, ` +SELECT owner_user_id, peer_user_id, kind, trigger_message_id, shortcut_id, sent_at +FROM business_automation_deliveries +WHERE owner_user_id = $1 + AND peer_user_id = $2 + AND kind = $3 +ORDER BY sent_at DESC, trigger_message_id DESC +LIMIT 1`, ownerUserID, peerUserID, string(kind)) + var out domain.BusinessAutomationDelivery + var kindText string + if err := row.Scan(&out.OwnerUserID, &out.PeerUserID, &kindText, &out.TriggerMessageID, &out.ShortcutID, &out.SentAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.BusinessAutomationDelivery{}, false, nil + } + return domain.BusinessAutomationDelivery{}, false, fmt.Errorf("last business automation delivery: %w", err) + } + out.Kind = domain.BusinessAutomationKind(kindText) + return out, true, nil +} + +func (s *PasswordStore) GetConnectedBusinessBot(ctx context.Context, ownerUserID int64) (domain.ConnectedBusinessBot, bool, error) { + row := s.db.QueryRow(ctx, ` +SELECT owner_user_id, bot_user_id, COALESCE(recipients::text, '{}')::text, COALESCE(rights::text, '{}')::text, + COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint +FROM business_connected_bots +WHERE owner_user_id = $1`, ownerUserID) + bot, err := scanConnectedBusinessBot(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ConnectedBusinessBot{}, false, nil + } + return domain.ConnectedBusinessBot{}, false, fmt.Errorf("get connected business bot: %w", err) + } + return bot, true, nil +} + +func (s *PasswordStore) SaveConnectedBusinessBot(ctx context.Context, bot domain.ConnectedBusinessBot) (domain.ConnectedBusinessBot, error) { + recipients, err := json.Marshal(bot.Recipients) + if err != nil { + return domain.ConnectedBusinessBot{}, fmt.Errorf("encode connected business bot recipients: %w", err) + } + rights, err := json.Marshal(bot.Rights) + if err != nil { + return domain.ConnectedBusinessBot{}, fmt.Errorf("encode connected business bot rights: %w", err) + } + row := s.db.QueryRow(ctx, ` +INSERT INTO business_connected_bots (owner_user_id, bot_user_id, recipients, rights, created_at, updated_at) +VALUES ($1,$2,$3::jsonb,$4::jsonb,now(),now()) +ON CONFLICT (owner_user_id) DO UPDATE SET + bot_user_id = EXCLUDED.bot_user_id, + recipients = EXCLUDED.recipients, + rights = EXCLUDED.rights, + updated_at = now() +RETURNING owner_user_id, bot_user_id, COALESCE(recipients::text, '{}')::text, COALESCE(rights::text, '{}')::text, + COALESCE(EXTRACT(EPOCH FROM created_at), 0)::bigint, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`, bot.OwnerUserID, bot.BotUserID, string(recipients), string(rights)) + saved, err := scanConnectedBusinessBot(row) + if err != nil { + return domain.ConnectedBusinessBot{}, fmt.Errorf("save connected business bot: %w", err) + } + return saved, nil +} + +func (s *PasswordStore) DeleteConnectedBusinessBot(ctx context.Context, ownerUserID, botUserID int64) (bool, error) { + deleted := false + err := withTx(ctx, s.db, "delete connected business bot", func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, `DELETE FROM business_connected_bots WHERE owner_user_id = $1 AND bot_user_id = $2`, ownerUserID, botUserID) + if err != nil { + return fmt.Errorf("delete connected business bot: %w", err) + } + deleted = tag.RowsAffected() > 0 + if !deleted { + return nil + } + if _, err := tx.Exec(ctx, `DELETE FROM business_connected_bot_peer_states WHERE owner_user_id = $1`, ownerUserID); err != nil { + return fmt.Errorf("delete connected business bot peer states: %w", err) + } + return nil + }) + if err != nil { + return false, err + } + return deleted, nil +} + +func (s *PasswordStore) SetConnectedBusinessBotPaused(ctx context.Context, ownerUserID, peerUserID int64, paused bool) (domain.ConnectedBusinessBotPeerState, error) { + row := s.db.QueryRow(ctx, ` +INSERT INTO business_connected_bot_peer_states (owner_user_id, peer_user_id, paused, disabled, created_at, updated_at) +VALUES ($1,$2,$3,false,now(),now()) +ON CONFLICT (owner_user_id, peer_user_id) DO UPDATE SET + paused = EXCLUDED.paused, + updated_at = now() +RETURNING owner_user_id, peer_user_id, paused, disabled, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`, ownerUserID, peerUserID, paused) + state, err := scanConnectedBusinessBotPeerState(row) + if err != nil { + return domain.ConnectedBusinessBotPeerState{}, fmt.Errorf("set connected business bot paused: %w", err) + } + return state, nil +} + +func (s *PasswordStore) DisableConnectedBusinessBotForPeer(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, error) { + row := s.db.QueryRow(ctx, ` +INSERT INTO business_connected_bot_peer_states (owner_user_id, peer_user_id, paused, disabled, created_at, updated_at) +VALUES ($1,$2,false,true,now(),now()) +ON CONFLICT (owner_user_id, peer_user_id) DO UPDATE SET + paused = false, + disabled = true, + updated_at = now() +RETURNING owner_user_id, peer_user_id, paused, disabled, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint`, ownerUserID, peerUserID) + state, err := scanConnectedBusinessBotPeerState(row) + if err != nil { + return domain.ConnectedBusinessBotPeerState{}, fmt.Errorf("disable connected business bot for peer: %w", err) + } + return state, nil +} + +func (s *PasswordStore) GetConnectedBusinessBotPeerState(ctx context.Context, ownerUserID, peerUserID int64) (domain.ConnectedBusinessBotPeerState, bool, error) { + row := s.db.QueryRow(ctx, ` +SELECT owner_user_id, peer_user_id, paused, disabled, + COALESCE(EXTRACT(EPOCH FROM updated_at), 0)::bigint +FROM business_connected_bot_peer_states +WHERE owner_user_id = $1 + AND peer_user_id = $2`, ownerUserID, peerUserID) + state, err := scanConnectedBusinessBotPeerState(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ConnectedBusinessBotPeerState{}, false, nil + } + return domain.ConnectedBusinessBotPeerState{}, false, fmt.Errorf("get connected business bot peer state: %w", err) + } + return state, true, nil +} + +func (s *PasswordStore) listQuickReplies(ctx context.Context, ownerUserID int64) ([]domain.QuickReply, error) { + return listQuickRepliesDB(ctx, s.db, ownerUserID) +} + +func listQuickRepliesTx(ctx context.Context, tx pgx.Tx, ownerUserID int64) ([]domain.QuickReply, error) { + return listQuickRepliesDB(ctx, tx, ownerUserID) +} + +func listQuickRepliesDB(ctx context.Context, db interface { + Query(context.Context, string, ...interface{}) (pgx.Rows, error) +}, ownerUserID int64) ([]domain.QuickReply, error) { + rows, err := db.Query(ctx, ` +SELECT qr.shortcut_id, qr.shortcut, qr.sort_order, + COALESCE(MAX(qm.message_id), 0)::int AS top_message, + COUNT(qm.message_id)::int AS message_count, + COALESCE(EXTRACT(EPOCH FROM qr.created_at), 0)::bigint, + COALESCE(EXTRACT(EPOCH FROM qr.updated_at), 0)::bigint +FROM quick_replies qr +LEFT JOIN quick_reply_messages qm + ON qm.owner_user_id = qr.owner_user_id + AND qm.shortcut_id = qr.shortcut_id +WHERE qr.owner_user_id = $1 +GROUP BY qr.shortcut_id, qr.shortcut, qr.sort_order, qr.created_at, qr.updated_at +ORDER BY qr.sort_order ASC, qr.shortcut_id ASC +LIMIT $2`, ownerUserID, domain.MaxQuickReplies) + if err != nil { + return nil, fmt.Errorf("list quick replies: %w", err) + } + defer rows.Close() + out := make([]domain.QuickReply, 0) + for rows.Next() { + var item domain.QuickReply + item.OwnerUserID = ownerUserID + if err := rows.Scan(&item.ID, &item.Shortcut, &item.SortOrder, &item.TopMessage, &item.Count, &item.CreatedAt, &item.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan quick reply: %w", err) + } + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan quick replies: %w", err) + } + return out, nil +} + +func ensureQuickReplyShortcut(ctx context.Context, tx pgx.Tx, ownerUserID int64, shortcut string) (int, bool, error) { + var id int + err := tx.QueryRow(ctx, `SELECT shortcut_id FROM quick_replies WHERE owner_user_id = $1 AND lower(shortcut) = lower($2)`, ownerUserID, shortcut).Scan(&id) + if err == nil { + return id, false, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return 0, false, fmt.Errorf("get quick reply shortcut: %w", err) + } + var count int + if err := tx.QueryRow(ctx, `SELECT COUNT(*)::int FROM quick_replies WHERE owner_user_id = $1`, ownerUserID).Scan(&count); err != nil { + return 0, false, fmt.Errorf("count quick replies: %w", err) + } + if count >= domain.MaxQuickReplies { + return 0, false, domain.ErrQuickRepliesTooMuch + } + if err := tx.QueryRow(ctx, `SELECT COALESCE(MAX(shortcut_id), 0)::int + 1 FROM quick_replies WHERE owner_user_id = $1`, ownerUserID).Scan(&id); err != nil { + return 0, false, fmt.Errorf("allocate quick reply shortcut id: %w", err) + } + if _, err := tx.Exec(ctx, ` +INSERT INTO quick_replies (owner_user_id, shortcut_id, shortcut, sort_order, created_at, updated_at) +VALUES ($1,$2,$3,$4,now(),now())`, ownerUserID, id, shortcut, count+1); err != nil { + return 0, false, quickReplyPGErr(err) + } + return id, true, nil +} + +func (s *PasswordStore) ensureQuickReplyExists(ctx context.Context, ownerUserID int64, shortcutID int) error { + var id int + err := s.db.QueryRow(ctx, `SELECT shortcut_id FROM quick_replies WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, shortcutID).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrShortcutInvalid + } + if err != nil { + return fmt.Errorf("get quick reply shortcut: %w", err) + } + return nil +} + +func (s *PasswordStore) normalizeQuickReplyOrder(ctx context.Context, ownerUserID int64) error { + return withTx(ctx, s.db, "normalize quick reply order", func(tx pgx.Tx) error { + replies, err := listQuickRepliesTx(ctx, tx, ownerUserID) + if err != nil { + return err + } + for i, reply := range replies { + if _, err := tx.Exec(ctx, `UPDATE quick_replies SET sort_order = $3 WHERE owner_user_id = $1 AND shortcut_id = $2`, ownerUserID, reply.ID, i+1); err != nil { + return fmt.Errorf("normalize quick reply order: %w", err) + } + } + return nil + }) +} + +func scanBusinessChatLink(row interface { + Scan(dest ...any) error +}) (domain.BusinessChatLink, error) { + var link domain.BusinessChatLink + var entitiesJSON string + if err := row.Scan(&link.Slug, &link.OwnerUserID, &link.Link, &link.Message, &entitiesJSON, &link.Title, &link.Views, &link.CreatedAt, &link.UpdatedAt); err != nil { + return domain.BusinessChatLink{}, err + } + entities, err := decodeMessageEntities(entitiesJSON) + if err != nil { + return domain.BusinessChatLink{}, fmt.Errorf("decode business chat link entities: %w", err) + } + link.Entities = entities + return link, nil +} + +func scanQuickReplyMessage(ownerUserID int64, shortcutID int, row interface { + Scan(dest ...any) error +}) (domain.QuickReplyMessage, error) { + var msg domain.QuickReplyMessage + var entitiesJSON string + msg.OwnerUserID = ownerUserID + msg.ShortcutID = shortcutID + if err := row.Scan(&msg.ID, &msg.RandomID, &msg.Date, &msg.Message, &entitiesJSON); err != nil { + return domain.QuickReplyMessage{}, fmt.Errorf("scan quick reply message: %w", err) + } + entities, err := decodeMessageEntities(entitiesJSON) + if err != nil { + return domain.QuickReplyMessage{}, fmt.Errorf("decode quick reply entities: %w", err) + } + msg.Entities = entities + return msg, nil +} + +func scanConnectedBusinessBot(row interface { + Scan(dest ...any) error +}) (domain.ConnectedBusinessBot, error) { + var bot domain.ConnectedBusinessBot + var recipientsJSON, rightsJSON string + if err := row.Scan(&bot.OwnerUserID, &bot.BotUserID, &recipientsJSON, &rightsJSON, &bot.CreatedAtUnix, &bot.UpdatedAtUnix); err != nil { + return domain.ConnectedBusinessBot{}, err + } + if recipientsJSON != "" && recipientsJSON != "{}" { + if err := json.Unmarshal([]byte(recipientsJSON), &bot.Recipients); err != nil { + return domain.ConnectedBusinessBot{}, fmt.Errorf("decode connected business bot recipients: %w", err) + } + } + if rightsJSON != "" && rightsJSON != "{}" { + if err := json.Unmarshal([]byte(rightsJSON), &bot.Rights); err != nil { + return domain.ConnectedBusinessBot{}, fmt.Errorf("decode connected business bot rights: %w", err) + } + } + return bot, nil +} + +func scanConnectedBusinessBotPeerState(row interface { + Scan(dest ...any) error +}) (domain.ConnectedBusinessBotPeerState, error) { + var state domain.ConnectedBusinessBotPeerState + if err := row.Scan(&state.OwnerUserID, &state.PeerUserID, &state.Paused, &state.Disabled, &state.UpdatedAtUnix); err != nil { + return domain.ConnectedBusinessBotPeerState{}, err + } + return state, nil +} + +func encodeNullableJSON[T any](value *T) ([]byte, error) { + if value == nil { + return []byte("{}"), nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("encode business json: %w", err) + } + return raw, nil +} + +func decodeNullableJSON[T any](raw string, out **T) error { + if raw == "" || raw == "{}" { + *out = nil + return nil + } + var value T + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return err + } + *out = &value + return nil +} + +func quickReplyByID(replies []domain.QuickReply, id int) domain.QuickReply { + for _, reply := range replies { + if reply.ID == id { + return reply + } + } + return domain.QuickReply{} +} + +func postgresQuickReplyListHash(items []domain.QuickReply) int64 { + h := fnv.New64a() + for _, item := range items { + postgresWriteHashInt(h, item.ID) + postgresWriteHashString(h, item.Shortcut) + postgresWriteHashInt(h, item.TopMessage) + postgresWriteHashInt(h, item.Count) + } + return int64(h.Sum64() & 0x7fffffffffffffff) +} + +func postgresQuickReplyMessagesHash(items []domain.QuickReplyMessage) int64 { + h := fnv.New64a() + for _, item := range items { + postgresWriteHashInt(h, item.ID) + postgresWriteHashString(h, item.Message) + postgresWriteHashInt(h, item.Date) + } + return int64(h.Sum64() & 0x7fffffffffffffff) +} + +func postgresWriteHashInt(h interface{ Write([]byte) (int, error) }, v int) { + _, _ = h.Write([]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}) +} + +func postgresWriteHashString(h interface{ Write([]byte) (int, error) }, v string) { + _, _ = h.Write([]byte(v)) + _, _ = h.Write([]byte{0}) +} + +func quickReplyPGErr(err error) error { + if err == nil { + return nil + } + if strings.Contains(err.Error(), "quick_replies_owner_shortcut_lower_idx") { + return domain.ErrShortcutOccupied + } + return err +} diff --git a/internal/store/postgres/channel.go b/internal/store/postgres/channel.go deleted file mode 100644 index 8b16a253..00000000 --- a/internal/store/postgres/channel.go +++ /dev/null @@ -1,10338 +0,0 @@ -package postgres - -import ( - "context" - "crypto/rand" - "database/sql" - "encoding/base64" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "sort" - "strconv" - "strings" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - - "telesrv/internal/domain" - "telesrv/internal/store" - "telesrv/internal/store/postgres/sqlcgen" -) - -const channelDialogQueryLimit = 500 -const channelMemberFilterBatch = 1000 -const retryableChannelTxAttempts = 3 - -// ChannelStore 用 PostgreSQL 实现 store.ChannelStore。 -type ChannelStore struct { - db sqlcgen.DBTX - ids store.ChannelIDAllocator - pts store.ChannelPtsAllocator - msgIDs store.ChannelMessageIDAllocator -} - -// ChannelStoreOption 调整 PostgreSQL ChannelStore 依赖。 -type ChannelStoreOption func(*ChannelStore) - -// WithChannelAllocators 注入 Redis-backed channel id / pts / message id allocator。 -func WithChannelAllocators(ids store.ChannelIDAllocator, pts store.ChannelPtsAllocator, msgIDs store.ChannelMessageIDAllocator) ChannelStoreOption { - return func(s *ChannelStore) { - s.ids = ids - s.pts = pts - s.msgIDs = msgIDs - } -} - -// NewChannelStore 基于 pgx 连接池(或事务)创建 ChannelStore。 -func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore { - s := &ChannelStore{db: db} - for _, opt := range opts { - opt(s) - } - if s.ids == nil { - s.ids = pgChannelIDAllocator{db: db} - } - if s.pts == nil { - s.pts = pgChannelPtsAllocator{db: db} - } - if s.msgIDs == nil { - s.msgIDs = pgChannelMessageIDAllocator{db: db} - } - return s -} - -func (s *ChannelStore) CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { - if req.CreatorUserID == 0 || strings.TrimSpace(req.Title) == "" { - return domain.CreateChannelResult{}, domain.ErrChannelInvalid - } - if !req.Broadcast && !req.Megagroup { - req.Broadcast = true - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.CreateChannelResult{}, fmt.Errorf("create channel: db does not support transactions") - } - channelID, err := s.ids.NextChannelID(ctx) - if err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("allocate channel id: %w", err) - } - accessHash, err := randomChannelAccessHash() - if err != nil { - return domain.CreateChannelResult{}, err - } - msgID, err := s.msgIDs.NextChannelMessageID(ctx, channelID) - if err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("allocate channel message id: %w", err) - } - pts, err := s.pts.NextChannelPts(ctx, channelID) - if err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("allocate channel pts: %w", err) - } - date := req.Date - if date == 0 { - date = nowUnix() - } - members := []domain.ChannelMember{creatorChannelMember(channelID, req.CreatorUserID, date)} - for _, userID := range uniqueChannelUserIDs(req.MemberUserIDs, req.CreatorUserID) { - members = append(members, domain.ChannelMember{ - ChannelID: channelID, - UserID: userID, - InviterUserID: req.CreatorUserID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberActive, - JoinedAt: date, - }) - } - channel := domain.Channel{ - ID: channelID, - AccessHash: accessHash, - CreatorUserID: req.CreatorUserID, - Title: strings.TrimSpace(req.Title), - About: req.About, - Broadcast: req.Broadcast, - Megagroup: req.Megagroup, - Forum: req.Forum, - ForumTabs: req.ForumTabs, - ParticipantsCount: len(members), - AdminsCount: 1, - TopMessageID: msgID, - Pts: pts, - TTLPeriod: req.TTLPeriod, - Date: date, - } - msg := domain.ChannelMessage{ - ChannelID: channelID, - ID: msgID, - SenderUserID: req.CreatorUserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.CreatorUserID}, - Date: date, - Post: channel.Broadcast, - Action: &domain.ChannelMessageAction{Type: domain.ChannelActionCreate, Title: channel.Title}, - Pts: pts, - } - event := domain.ChannelUpdateEvent{ - ChannelID: channelID, - Type: domain.ChannelUpdateNewMessage, - Pts: pts, - PtsCount: 1, - Date: date, - Message: msg, - SenderUserID: req.CreatorUserID, - } - - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("begin create channel: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, req.Date) - } - }() - if err := insertChannelTx(ctx, tx, channel); err != nil { - return domain.CreateChannelResult{}, err - } - for _, member := range members { - if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { - return domain.CreateChannelResult{}, err - } - } - if err := insertChannelMessageTx(ctx, tx, msg); err != nil { - return domain.CreateChannelResult{}, err - } - if err := insertChannelEventTx(ctx, tx, event); err != nil { - return domain.CreateChannelResult{}, err - } - for _, member := range members { - readMax := 0 - if member.UserID == req.CreatorUserID { - readMax = msgID - } - if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, readMax, readMax); err != nil { - return domain.CreateChannelResult{}, err - } - } - if err := tx.Commit(ctx); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("commit create channel: %w", err) - } - committed = true - return domain.CreateChannelResult{ - Channel: channel, - Members: append([]domain.ChannelMember(nil), members...), - Message: msg, - Event: event, - Recipients: channelMemberIDs(members), - }, nil -} - -func (s *ChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { - channel, member, preview, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) - if err != nil { - return domain.ChannelView{}, err - } - if preview { - return domain.ChannelView{ - Channel: channel, - Self: member, - Dialog: previewChannelDialog(viewerUserID, channel, member), - }, nil - } - dialog, err := s.getChannelDialog(ctx, s.db, viewerUserID, channel) - if err != nil { - return domain.ChannelView{}, err - } - return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog}, nil -} - -func (s *ChannelStore) SaveChannelDefaultSendAs(ctx context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ChannelView{}, domain.ErrChannelInvalid - } - var sendAsType sql.NullString - var sendAsID sql.NullInt64 - if req.SendAs != nil { - if req.SendAs.Type != domain.PeerTypeUser && req.SendAs.Type != domain.PeerTypeChannel { - return domain.ChannelView{}, domain.ErrChannelInvalid - } - sendAsType = sql.NullString{String: string(req.SendAs.Type), Valid: true} - sendAsID = sql.NullInt64{Int64: req.SendAs.ID, Valid: true} - } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelView{}, err - } - topMessageID := channel.TopMessageID - if topMessageID <= member.AvailableMinID { - topMessageID = 0 - } - if _, err := s.db.Exec(ctx, ` -INSERT INTO channel_dialogs ( - user_id, channel_id, top_message_id, top_message_date, - read_inbox_max_id, read_outbox_max_id, - default_send_as_peer_type, default_send_as_peer_id -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -ON CONFLICT (user_id, channel_id) DO UPDATE SET - default_send_as_peer_type = EXCLUDED.default_send_as_peer_type, - default_send_as_peer_id = EXCLUDED.default_send_as_peer_id, - updated_at = now()`, - req.UserID, - req.ChannelID, - topMessageID, - channel.Date, - member.ReadInboxMaxID, - member.ReadOutboxMaxID, - sendAsType, - sendAsID, - ); err != nil { - return domain.ChannelView{}, fmt.Errorf("save channel default send as: %w", err) - } - dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) - if err != nil { - return domain.ChannelView{}, err - } - return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog}, nil -} - -func (s *ChannelStore) GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) { - if channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - return getChannelByID(ctx, s.db, channelID) -} - -func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { - channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) - if err != nil { - return domain.ChannelParticipantList{}, err - } - if offset < 0 { - offset = 0 - } - if offset > domain.MaxChannelParticipantsOffset { - offset = domain.MaxChannelParticipantsOffset - } - if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { - limit = domain.MaxChannelParticipantsLimit - } - count := channel.ParticipantsCount - if channel.ParticipantsHidden && !isChannelAdmin(viewer) { - switch filter.Kind { - case domain.ChannelParticipantsAdmins: - case domain.ChannelParticipantsBots: - return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil - default: - return domain.ChannelParticipantList{Channel: channel, Count: channel.ParticipantsCount}, nil - } - } - where := []string{"m.channel_id = $1"} - args := []any{channelID} - joinUsers := false - query := strings.TrimSpace(filter.Query) - switch filter.Kind { - case "", domain.ChannelParticipantsRecent, domain.ChannelParticipantsContacts, domain.ChannelParticipantsMentions: - where = append(where, "m.status = 'active'") - case domain.ChannelParticipantsAdmins: - where = append(where, "m.status = 'active'", "m.role IN ('creator','admin')") - count = channel.AdminsCount - case domain.ChannelParticipantsKicked: - count = channel.KickedCount - if !isChannelAdmin(viewer) { - return domain.ChannelParticipantList{Channel: channel, Count: channel.KickedCount}, nil - } - where = append(where, "(m.status = 'kicked' OR (m.banned_rights->>'ViewMessages')::boolean IS TRUE)") - case domain.ChannelParticipantsBanned: - count = channel.BannedCount - if !isChannelAdmin(viewer) { - return domain.ChannelParticipantList{Channel: channel, Count: channel.BannedCount}, nil - } - where = append(where, "m.status <> 'kicked'", `( - m.status = 'banned' OR - (m.banned_rights->>'SendMessages')::boolean IS TRUE OR - (m.banned_rights->>'SendMedia')::boolean IS TRUE OR - (m.banned_rights->>'SendStickers')::boolean IS TRUE OR - (m.banned_rights->>'SendGifs')::boolean IS TRUE OR - (m.banned_rights->>'SendGames')::boolean IS TRUE OR - (m.banned_rights->>'SendInline')::boolean IS TRUE OR - (m.banned_rights->>'EmbedLinks')::boolean IS TRUE OR - (m.banned_rights->>'SendPolls')::boolean IS TRUE OR - (m.banned_rights->>'ChangeInfo')::boolean IS TRUE OR - (m.banned_rights->>'InviteUsers')::boolean IS TRUE OR - (m.banned_rights->>'PinMessages')::boolean IS TRUE -)`) - case domain.ChannelParticipantsSearch: - where = append(where, "m.status = 'active'") - count = 0 - case domain.ChannelParticipantsBots: - return domain.ChannelParticipantList{Channel: channel}, nil - default: - where = append(where, "m.status = 'active'") - } - if query != "" { - joinUsers = true - count = 0 - args = append(args, "%"+strings.ToLower(query)+"%") - placeholder := fmt.Sprintf("$%d", len(args)) - where = append(where, fmt.Sprintf(`( - lower(COALESCE(u.first_name, '')) LIKE %s OR - lower(COALESCE(u.last_name, '')) LIKE %s OR - lower(COALESCE(u.username, '')) LIKE %s OR - COALESCE(u.phone, '') LIKE %s OR - m.user_id::text LIKE %s -)`, placeholder, placeholder, placeholder, placeholder, placeholder)) - } - args = append(args, offset, limit) - offsetArg := fmt.Sprintf("$%d", len(args)-1) - limitArg := fmt.Sprintf("$%d", len(args)) - from := "FROM channel_members m" - if joinUsers { - from += " JOIN users u ON u.id = m.user_id" - } - rows, err := s.db.Query(ctx, ` -SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, - rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date -`+from+` -WHERE `+strings.Join(where, " AND ")+` -ORDER BY CASE role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, user_id -OFFSET `+offsetArg+` LIMIT `+limitArg, args...) - if err != nil { - return domain.ChannelParticipantList{}, fmt.Errorf("list channel participants: %w", err) - } - defer rows.Close() - out := domain.ChannelParticipantList{Channel: channel, Count: count} - for rows.Next() { - member, err := scanChannelMember(rows) - if err != nil { - return domain.ChannelParticipantList{}, err - } - out.Participants = append(out.Participants, member) - } - if err := rows.Err(); err != nil { - return domain.ChannelParticipantList{}, err - } - if out.Count == 0 { - out.Count = len(out.Participants) - } - return out, nil -} - -func (s *ChannelStore) GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { - if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { - return domain.ChannelMember{}, err - } - return s.getChannelMember(ctx, s.db, channelID, participantUserID) -} - -func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) { - if channelID == 0 || inviterUserID == 0 || len(userIDs) == 0 { - return domain.CreateChannelResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.CreateChannelResult{}, fmt.Errorf("invite channel: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("begin invite channel: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, date) - } - }() - channel, inviter, err := s.getChannelForMember(ctx, tx, inviterUserID, channelID) - if err != nil { - return domain.CreateChannelResult{}, err - } - if !canInviteToChannel(channel, inviter) { - return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired - } - if date == 0 { - date = nowUnix() - } - requested := uniqueChannelUserIDs(userIDs, 0) - inviteOne := len(requested) == 1 - canRestoreKicked := canBanChannelUsers(inviter) - invitedIDs := make([]int64, 0, len(requested)) - members := make([]domain.ChannelMember, 0, len(requested)) - restoredKicked := 0 - for _, userID := range requested { - if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { - if existing.Status == domain.ChannelMemberActive { - if inviteOne { - return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant - } - continue - } - if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { - if !canRestoreKicked { - if inviteOne { - return domain.CreateChannelResult{}, domain.ErrUserKicked - } - continue - } - if existing.Status == domain.ChannelMemberKicked { - restoredKicked++ - } - } - } else if !errors.Is(err, domain.ErrChannelPrivate) { - return domain.CreateChannelResult{}, err - } - member := domain.ChannelMember{ - ChannelID: channelID, - UserID: userID, - InviterUserID: inviterUserID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberActive, - JoinedAt: date, - AvailableMinID: channelInitialAvailableMinID(channel), - AvailableMinPts: channelInitialAvailableMinPts(channel), - ReadInboxMaxID: channel.TopMessageID, - } - if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { - return domain.CreateChannelResult{}, err - } - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: inviterUserID, - Date: date, - Type: domain.ChannelAdminLogParticipantInvite, - Participant: &member, - }); err != nil { - return domain.CreateChannelResult{}, err - } - members = append(members, member) - invitedIDs = append(invitedIDs, userID) - } - if len(members) > 0 { - if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + $2, kicked_count = GREATEST(kicked_count - $3, 0), updated_at = now() WHERE id = $1`, channelID, len(members), restoredKicked); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) - } - channel.ParticipantsCount += len(members) - channel.KickedCount = maxInt(channel.KickedCount-restoredKicked, 0) - } - var msg domain.ChannelMessage - var event domain.ChannelUpdateEvent - if len(members) > 0 && channel.Megagroup { - msg, event, err = s.insertServiceMessage(ctx, tx, channel, inviterUserID, date, domain.ChannelMessageAction{ - Type: domain.ChannelActionChatAddUser, - UserIDs: invitedIDs, - }, &reserved) - if err != nil { - return domain.CreateChannelResult{}, err - } - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - } - for _, member := range members { - if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { - return domain.CreateChannelResult{}, err - } - } - if err := tx.Commit(ctx); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0) - return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil -} - -func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { - if channelID == 0 || userID == 0 { - return domain.CreateChannelResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.CreateChannelResult{}, fmt.Errorf("join channel: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("begin join channel: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, date) - } - }() - channel, err := getChannelByID(ctx, tx, channelID) - if err != nil { - return domain.CreateChannelResult{}, err - } - if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { - switch { - case existing.Status == domain.ChannelMemberActive: - return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant - case existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages: - return domain.CreateChannelResult{}, domain.ErrChannelUserBanned - } - } - if date == 0 { - date = nowUnix() - } - if channel.JoinRequest { - if err := s.recordPublicJoinRequestTx(ctx, tx, channel, userID, date); err != nil { - return domain.CreateChannelResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("commit public channel join request: %w", err) - } - committed = true - return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent - } - preJoinTopID := channel.TopMessageID - minID := channelInitialAvailableMinID(channel) - member := domain.ChannelMember{ChannelID: channelID, UserID: userID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive, JoinedAt: date, AvailableMinID: minID, AvailableMinPts: channelInitialAvailableMinPts(channel), ReadInboxMaxID: maxInt(minID, preJoinTopID)} - if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { - return domain.CreateChannelResult{}, err - } - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: date, - Type: domain.ChannelAdminLogParticipantJoin, - }); err != nil { - return domain.CreateChannelResult{}, err - } - if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + 1, updated_at = now() WHERE id = $1`, channelID); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) - } - channel.ParticipantsCount++ - var msg domain.ChannelMessage - var event domain.ChannelUpdateEvent - if channel.Megagroup { - msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ - Type: domain.ChannelActionChatJoined, - UserIDs: []int64{userID}, - }, &reserved) - if err != nil { - return domain.CreateChannelResult{}, err - } - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - } - member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) - if msg.ID != 0 && msg.SenderUserID == userID { - member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) - } - if _, err := tx.Exec(ctx, ` -UPDATE channel_members -SET read_inbox_max_id = GREATEST(read_inbox_max_id, $3), - read_outbox_max_id = GREATEST(read_outbox_max_id, $4), - unread_mark = false, - updated_at = now() -WHERE channel_id = $1 AND user_id = $2`, channelID, userID, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("update joined channel read watermarks: %w", err) - } - if err := upsertChannelDialogTx(ctx, tx, userID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { - return domain.CreateChannelResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("commit join channel: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0) - return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil -} - -func (s *ChannelStore) LeaveChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { - if channelID == 0 || userID == 0 { - return domain.CreateChannelResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.CreateChannelResult{}, fmt.Errorf("leave channel: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("begin leave channel: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, date) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.CreateChannelResult{}, err - } - if date == 0 { - date = nowUnix() - } - if _, err := tx.Exec(ctx, `UPDATE channel_members SET status = 'left', left_at = $3, updated_at = now() WHERE channel_id = $1 AND user_id = $2`, channelID, userID, date); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("leave channel member: %w", err) - } - if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = GREATEST(participants_count - 1, 0), updated_at = now() WHERE id = $1`, channelID); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) - } - member.Status = domain.ChannelMemberLeft - member.LeftAt = date - if err := upsertUserChannelMemberIndexTx(ctx, tx, channel, member); err != nil { - return domain.CreateChannelResult{}, err - } - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: date, - Type: domain.ChannelAdminLogParticipantLeave, - }); err != nil { - return domain.CreateChannelResult{}, err - } - channel.ParticipantsCount-- - if channel.ParticipantsCount < 0 { - channel.ParticipantsCount = 0 - } - var msg domain.ChannelMessage - var event domain.ChannelUpdateEvent - if channel.Megagroup { - msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ - Type: domain.ChannelActionChatDelete, - UserIDs: []int64{userID}, - }, &reserved) - if err != nil { - return domain.CreateChannelResult{}, err - } - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - } - if err := tx.Commit(ctx); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("commit leave channel: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0) - recipients = append(recipients, userID) - return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil -} - -func (s *ChannelStore) EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" { - return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.EditChannelTitleResult{}, fmt.Errorf("edit channel title: db does not support transactions") - } - if req.Date == 0 { - req.Date = nowUnix() - } - title := strings.TrimSpace(req.Title) - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.EditChannelTitleResult{}, fmt.Errorf("begin edit channel title: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, req.Date) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.EditChannelTitleResult{}, err - } - if !canChangeChannelInfo(member) { - return domain.EditChannelTitleResult{}, domain.ErrChannelAdminRequired - } - if channel.Title == title { - return domain.EditChannelTitleResult{}, domain.ErrChannelNotModified - } - prevTitle := channel.Title - if _, err := tx.Exec(ctx, `UPDATE channels SET title = $2, updated_at = now() WHERE id = $1`, req.ChannelID, title); err != nil { - return domain.EditChannelTitleResult{}, fmt.Errorf("update channel title: %w", err) - } - channel.Title = title - msg, event, err := s.insertServiceMessage(ctx, tx, channel, req.UserID, req.Date, domain.ChannelMessageAction{ - Type: domain.ChannelActionEditTitle, - Title: title, - }, &reserved) - if err != nil { - return domain.EditChannelTitleResult{}, err - } - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, msg.ID, msg.ID); err != nil { - return domain.EditChannelTitleResult{}, err - } - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.Date, - Type: domain.ChannelAdminLogChangeTitle, - PrevString: prevTitle, - NewString: title, - }); err != nil { - return domain.EditChannelTitleResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.EditChannelTitleResult{}, fmt.Errorf("commit edit channel title: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - return domain.EditChannelTitleResult{Channel: channel, Message: msg, Event: event, Recipients: recipients}, nil -} - -func (s *ChannelStore) EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("edit channel about: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin edit channel about: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.Channel{}, err - } - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if _, err := tx.Exec(ctx, `UPDATE channels SET about = $2, updated_at = now() WHERE id = $1`, req.ChannelID, req.About); err != nil { - return domain.Channel{}, fmt.Errorf("update channel about: %w", err) - } - channel.About = req.About - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit edit channel about: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 { - return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.EditChannelAdminResult{}, fmt.Errorf("edit channel admin: db does not support transactions") - } - if req.Date == 0 { - req.Date = nowUnix() - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.EditChannelAdminResult{}, fmt.Errorf("begin edit channel admin: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, actor, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.EditChannelAdminResult{}, err - } - if !canAddChannelAdmins(actor) { - return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired - } - if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) { - return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden - } - previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.MemberID) - if err != nil { - if !errors.Is(err, domain.ErrChannelPrivate) { - return domain.EditChannelAdminResult{}, err - } - previous = domain.ChannelMember{ - ChannelID: req.ChannelID, - UserID: req.MemberID, - InviterUserID: req.UserID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberActive, - JoinedAt: req.Date, - AvailableMinID: channelInitialAvailableMinID(channel), - AvailableMinPts: channelInitialAvailableMinPts(channel), - ReadInboxMaxID: channel.TopMessageID, - } - } - if previous.Role == domain.ChannelRoleCreator { - return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator - } - member := previous - member.InviterUserID = req.UserID - member.Status = domain.ChannelMemberActive - member.LeftAt = 0 - member.Rank = req.Rank - if previous.Status != domain.ChannelMemberActive { - if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts { - member.AvailableMinPts = minPts - } - } - member.AdminRights = req.AdminRights - if zeroChannelAdminRights(req.AdminRights) { - member.Role = domain.ChannelRoleMember - member.Rank = "" - } else { - member.Role = domain.ChannelRoleAdmin - } - if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { - return domain.EditChannelAdminResult{}, err - } - logType := domain.ChannelAdminLogParticipantPromote - if member.Role != domain.ChannelRoleAdmin { - logType = domain.ChannelAdminLogParticipantDemote - } - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.Date, - Type: logType, - PrevParticipant: &previous, - NewParticipant: &member, - }); err != nil { - return domain.EditChannelAdminResult{}, err - } - channel, err = refreshChannelCountsTx(ctx, tx, channel) - if err != nil { - return domain.EditChannelAdminResult{}, err - } - event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) - msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) - if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { - return domain.EditChannelAdminResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.EditChannelAdminResult{}, fmt.Errorf("commit edit channel admin: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - recipients = append(recipients, req.MemberID) - return domain.EditChannelAdminResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil -} - -func (s *ChannelStore) EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 { - return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.EditChannelBannedResult{}, fmt.Errorf("edit channel banned: db does not support transactions") - } - if req.Date == 0 { - req.Date = nowUnix() - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.EditChannelBannedResult{}, fmt.Errorf("begin edit channel banned: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, actor, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.EditChannelBannedResult{}, err - } - if !canBanChannelUsers(actor) { - return domain.EditChannelBannedResult{}, domain.ErrChannelAdminRequired - } - previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.Participant.ID) - if err != nil { - if !errors.Is(err, domain.ErrChannelPrivate) { - return domain.EditChannelBannedResult{}, err - } - previous = domain.ChannelMember{ - ChannelID: req.ChannelID, - UserID: req.Participant.ID, - InviterUserID: req.UserID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberLeft, - } - } - if previous.Role == domain.ChannelRoleCreator { - return domain.EditChannelBannedResult{}, domain.ErrChannelUserCreator - } - member := previous - member.BannedRights = req.BannedRights - member.Role = domain.ChannelRoleMember - switch { - case req.BannedRights.ViewMessages: - member.InviterUserID = req.UserID - member.Status = domain.ChannelMemberKicked - member.LeftAt = req.Date - case zeroChannelBannedRights(req.BannedRights): - if previous.Status == domain.ChannelMemberActive { - member.Status = domain.ChannelMemberActive - } else { - member.Status = domain.ChannelMemberLeft - } - member.LeftAt = 0 - default: - member.InviterUserID = req.UserID - if previous.Status == domain.ChannelMemberActive { - member.Status = domain.ChannelMemberActive - } else { - member.Status = domain.ChannelMemberBanned - } - } - if member.JoinedAt == 0 && member.Status == domain.ChannelMemberActive { - member.JoinedAt = req.Date - } - if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { - return domain.EditChannelBannedResult{}, err - } - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.Date, - Type: adminLogBanType(previous, member), - PrevParticipant: &previous, - NewParticipant: &member, - }); err != nil { - return domain.EditChannelBannedResult{}, err - } - channel, err = refreshChannelCountsTx(ctx, tx, channel) - if err != nil { - return domain.EditChannelBannedResult{}, err - } - event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) - if member.Status == domain.ChannelMemberActive { - msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) - if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { - return domain.EditChannelBannedResult{}, err - } - } - if err := tx.Commit(ctx); err != nil { - return domain.EditChannelBannedResult{}, fmt.Errorf("commit edit channel banned: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - recipients = append(recipients, req.Participant.ID) - return domain.EditChannelBannedResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil -} - -func (s *ChannelStore) EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - channel, actor, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.Channel{}, err - } - if !canBanChannelUsers(actor) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if channel.DefaultBannedRights == req.BannedRights { - return domain.Channel{}, domain.ErrChannelNotModified - } - rights, err := marshalJSON(req.BannedRights, "{}") - if err != nil { - return domain.Channel{}, err - } - if _, err := s.db.Exec(ctx, ` -UPDATE channels -SET default_banned_rights = $2::jsonb, updated_at = now() -WHERE id = $1 AND NOT deleted`, req.ChannelID, rights); err != nil { - return domain.Channel{}, fmt.Errorf("edit channel default banned rights: %w", err) - } - channel.DefaultBannedRights = req.BannedRights - return channel, nil -} - -func (s *ChannelStore) DeleteChannel(ctx context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.DeleteChannelResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.DeleteChannelResult{}, fmt.Errorf("delete channel: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.DeleteChannelResult{}, fmt.Errorf("begin delete channel: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelResult{}, err - } - if member.Role != domain.ChannelRoleCreator { - return domain.DeleteChannelResult{}, domain.ErrChannelAdminRequired - } - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - if _, err := tx.Exec(ctx, `UPDATE channels SET deleted = true, updated_at = now() WHERE id = $1`, req.ChannelID); err != nil { - return domain.DeleteChannelResult{}, fmt.Errorf("mark channel deleted: %w", err) - } - if err := markUserChannelMemberIndexDeletedTx(ctx, tx, req.ChannelID, true); err != nil { - return domain.DeleteChannelResult{}, err - } - channel.Deleted = true - if err := tx.Commit(ctx); err != nil { - return domain.DeleteChannelResult{}, fmt.Errorf("commit delete channel: %w", err) - } - committed = true - return domain.DeleteChannelResult{Channel: channel, Recipients: recipients}, nil -} - -func (s *ChannelStore) CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) { - if userID == 0 || channelID == 0 || strings.TrimSpace(username) == "" { - return false, domain.ErrChannelInvalid - } - if _, _, err := s.getChannelForMember(ctx, s.db, userID, channelID); err != nil { - return false, err - } - usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) - var existingChannelID int64 - err := s.db.QueryRow(ctx, `SELECT channel_id FROM channel_usernames WHERE username_lower = $1`, usernameLower).Scan(&existingChannelID) - if err == nil { - return existingChannelID == channelID, nil - } - if !errors.Is(err, pgx.ErrNoRows) { - return false, fmt.Errorf("check channel username: %w", err) - } - var userIDWithUsername int64 - err = s.db.QueryRow(ctx, `SELECT id FROM users WHERE lower(username) = $1 AND username <> '' LIMIT 1`, usernameLower).Scan(&userIDWithUsername) - if err == nil { - return false, nil - } - if !errors.Is(err, pgx.ErrNoRows) { - return false, fmt.Errorf("check channel username user collision: %w", err) - } - return true, nil -} - -func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("update channel username: db does not support transactions") - } - username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) - usernameLower := strings.ToLower(username) - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin update channel username: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.Channel{}, err - } - if member.Role != domain.ChannelRoleCreator { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if strings.EqualFold(channel.Username, username) { - return domain.Channel{}, domain.ErrChannelNotModified - } - if usernameLower != "" { - var userIDWithUsername int64 - err := tx.QueryRow(ctx, `SELECT id FROM users WHERE lower(username) = $1 AND username <> '' LIMIT 1`, usernameLower).Scan(&userIDWithUsername) - if err == nil { - return domain.Channel{}, domain.ErrUsernameOccupied - } - if !errors.Is(err, pgx.ErrNoRows) { - return domain.Channel{}, fmt.Errorf("check user username collision: %w", err) - } - var existingChannelID int64 - err = tx.QueryRow(ctx, `SELECT channel_id FROM channel_usernames WHERE username_lower = $1 FOR UPDATE`, usernameLower).Scan(&existingChannelID) - if err == nil && existingChannelID != req.ChannelID { - return domain.Channel{}, domain.ErrUsernameOccupied - } - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return domain.Channel{}, fmt.Errorf("lock channel username: %w", err) - } - } - if _, err := tx.Exec(ctx, `DELETE FROM channel_usernames WHERE channel_id = $1`, req.ChannelID); err != nil { - return domain.Channel{}, fmt.Errorf("delete old channel username: %w", err) - } - if usernameLower != "" { - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_usernames (username_lower, channel_id) -VALUES ($1,$2) -ON CONFLICT (username_lower) DO UPDATE SET channel_id = EXCLUDED.channel_id, updated_at = now() -WHERE channel_usernames.channel_id = EXCLUDED.channel_id`, usernameLower, req.ChannelID); err != nil { - if isUniqueViolation(err) { - return domain.Channel{}, domain.ErrUsernameOccupied - } - return domain.Channel{}, fmt.Errorf("insert channel username: %w", err) - } - } - if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil { - return domain.Channel{}, fmt.Errorf("update channel username: %w", err) - } - prevUsername := channel.Username - channel.Username = username - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: nowUnix(), - Type: domain.ChannelAdminLogChangeUsername, - PrevString: prevUsername, - NewString: username, - }); err != nil { - return domain.Channel{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit update channel username: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { - if userID == 0 { - return nil, nil - } - rows, err := s.db.Query(ctx, ` -SELECT `+channelColumns+` -FROM channel_members m -JOIN channels c ON c.id = m.channel_id AND NOT c.deleted -WHERE m.user_id = $1 - AND m.status = 'active' - AND m.role IN ('creator','admin') - AND COALESCE(c.username, '') <> '' -ORDER BY c.id DESC -LIMIT $2`, userID, domain.MaxAdminedPublicChannels) - if err != nil { - return nil, fmt.Errorf("list admined public channels: %w", err) - } - defer rows.Close() - out := make([]domain.Channel, 0) - for rows.Next() { - ch, err := scanChannel(rows) - if err != nil { - return nil, err - } - out = append(out, ch) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) { - if viewerUserID == 0 { - return domain.Channel{}, false, domain.ErrChannelInvalid - } - usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) - if usernameLower == "" { - return domain.Channel{}, false, nil - } - ch, err := scanChannel(s.db.QueryRow(ctx, ` -SELECT `+channelColumns+` -FROM channel_usernames u -JOIN channels c ON c.id = u.channel_id -WHERE u.username_lower = $1 - AND NOT c.deleted - AND (c.broadcast OR c.megagroup) - AND COALESCE(c.username, '') <> '' -LIMIT 1`, usernameLower)) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.Channel{}, false, nil - } - return domain.Channel{}, false, fmt.Errorf("resolve public channel username: %w", err) - } - return ch, true, nil -} - -func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) { - if viewerUserID == 0 { - return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxPublicChannelSearchLimit { - limit = domain.MaxPublicChannelSearchLimit - } - queryLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(query, "@"))) - if queryLower == "" { - return domain.PublicChannelSearchResult{}, nil - } - queryPrefix := escapeLike(queryLower) + "%" - queryLike := "%" + escapeLike(queryLower) + "%" - rows, err := s.db.Query(ctx, ` -SELECT `+channelColumns+`, - EXISTS ( - SELECT 1 - FROM channel_members m - WHERE m.channel_id = c.id - AND m.user_id = $1 - AND m.status = 'active' - ) AS viewer_member -FROM channels c -WHERE NOT c.deleted - AND (c.broadcast OR c.megagroup) - AND COALESCE(c.username, '') <> '' - AND ( - lower(c.username) = $2 - OR lower(c.username) LIKE $3 ESCAPE '\' - OR lower(c.title) LIKE $3 ESCAPE '\' - OR lower(c.username) LIKE $4 ESCAPE '\' - OR lower(c.title) LIKE $4 ESCAPE '\' - ) -ORDER BY CASE - WHEN lower(c.username) = $2 THEN 0 - WHEN lower(c.username) LIKE $3 ESCAPE '\' THEN 1 - WHEN lower(c.username) LIKE $4 ESCAPE '\' THEN 2 - WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3 - ELSE 4 - END, - viewer_member DESC, - c.participants_count DESC, - c.date DESC, - c.id DESC -LIMIT $5`, viewerUserID, queryLower, queryPrefix, queryLike, limit) - if err != nil { - return domain.PublicChannelSearchResult{}, fmt.Errorf("search public channels: %w", err) - } - defer rows.Close() - out := domain.PublicChannelSearchResult{ - MyResults: make([]domain.Channel, 0), - Results: make([]domain.Channel, 0, limit), - } - for rows.Next() { - ch, viewerMember, err := scanChannelWithViewerMember(rows) - if err != nil { - return domain.PublicChannelSearchResult{}, err - } - if viewerMember { - out.MyResults = append(out.MyResults, ch) - } else { - out.Results = append(out.Results, ch) - } - } - if err := rows.Err(); err != nil { - return domain.PublicChannelSearchResult{}, err - } - return out, nil -} - -func (s *ChannelStore) SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel signatures: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel signatures: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - prev := channel.Signatures - if _, err := tx.Exec(ctx, `UPDATE channels SET signatures = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { - return domain.Channel{}, fmt.Errorf("update channel signatures: %w", err) - } - channel.Signatures = enabled - if prev != enabled { - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: nowUnix(), - Type: domain.ChannelAdminLogToggleSignatures, - PrevBool: prev, - NewBool: enabled, - }); err != nil { - return domain.Channel{}, err - } - } - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel signatures: %w", err) - } - committed = true - return channel, nil -} - -// SetChannelPhoto 设置/清除频道头像(反范式列)。photo==nil 表示清除。 -func (s *ChannelStore) SetChannelPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("set channel photo: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin set channel photo: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - var ( - photoID int64 - dcID int - stripped []byte - ) - if photo != nil && photo.ID != 0 { - photoID = photo.ID - dcID = photo.DCID - stripped = domain.StrippedFromSizes(photo.Sizes) - } - if stripped == nil { - stripped = []byte{} - } - if _, err := tx.Exec(ctx, `UPDATE channels SET photo_id = $2, photo_dc_id = $3, photo_stripped = $4, updated_at = now() WHERE id = $1`, - channelID, photoID, dcID, stripped); err != nil { - return domain.Channel{}, fmt.Errorf("update channel photo: %w", err) - } - channel.PhotoID = photoID - channel.PhotoDCID = dcID - channel.PhotoStripped = stripped - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit set channel photo: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel prehistory: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel prehistory: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if member.Role != domain.ChannelRoleCreator { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - prev := channel.PreHistoryHidden - if _, err := tx.Exec(ctx, `UPDATE channels SET pre_history_hidden = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { - return domain.Channel{}, fmt.Errorf("update channel prehistory: %w", err) - } - channel.PreHistoryHidden = enabled - if prev != enabled { - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: nowUnix(), - Type: domain.ChannelAdminLogTogglePreHistoryHidden, - PrevBool: prev, - NewBool: enabled, - }); err != nil { - return domain.Channel{}, err - } - } - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel prehistory: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel participants hidden: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel participants hidden: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !channel.Megagroup || !canBanChannelUsers(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if _, err := tx.Exec(ctx, `UPDATE channels SET participants_hidden = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { - return domain.Channel{}, fmt.Errorf("update channel participants hidden: %w", err) - } - channel.ParticipantsHidden = enabled - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel participants hidden: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel forum: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel forum: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !channel.Megagroup || channel.Broadcast { - return domain.Channel{}, domain.ErrChannelNotModified - } - if member.Role != domain.ChannelRoleCreator { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if enabled && channel.LinkedChatID != 0 { - return domain.Channel{}, domain.ErrChatDiscussionUnallowed - } - prevForum := channel.Forum - prevTabs := channel.ForumTabs - nextTabs := enabled && tabs - if _, err := tx.Exec(ctx, ` -UPDATE channels -SET forum = $2, - forum_tabs = $3, - updated_at = now() -WHERE id = $1`, channelID, enabled, nextTabs); err != nil { - return domain.Channel{}, fmt.Errorf("update channel forum: %w", err) - } - channel.Forum = enabled - channel.ForumTabs = nextTabs - if prevForum != channel.Forum || prevTabs != channel.ForumTabs { - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: nowUnix(), - Type: domain.ChannelAdminLogToggleForum, - PrevBool: prevForum, - NewBool: enabled, - }); err != nil { - return domain.Channel{}, err - } - } - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel forum: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel autotranslation: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel autotranslation: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - prev := channel.Autotranslation - if _, err := tx.Exec(ctx, `UPDATE channels SET autotranslation = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { - return domain.Channel{}, fmt.Errorf("update channel autotranslation: %w", err) - } - channel.Autotranslation = enabled - if prev != enabled { - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: nowUnix(), - Type: domain.ChannelAdminLogToggleAutotranslation, - PrevBool: prev, - NewBool: enabled, - }); err != nil { - return domain.Channel{}, err - } - } - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel autotranslation: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel restricted sponsored: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel restricted sponsored: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if _, err := tx.Exec(ctx, `UPDATE channels SET restricted_sponsored = $2, updated_at = now() WHERE id = $1`, channelID, restricted); err != nil { - return domain.Channel{}, fmt.Errorf("update channel restricted sponsored: %w", err) - } - channel.RestrictedSponsored = restricted - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel restricted sponsored: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 || stars < 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("update channel paid messages price: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin update channel paid messages price: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - broadcastAllowed := channel.Broadcast && broadcastMessagesAllowed - if _, err := tx.Exec(ctx, `UPDATE channels SET send_paid_messages_stars = $2, broadcast_messages_allowed = $3, updated_at = now() WHERE id = $1`, channelID, stars, broadcastAllowed); err != nil { - return domain.Channel{}, fmt.Errorf("update channel paid messages price: %w", err) - } - channel.SendPaidMessagesStars = stars - channel.BroadcastMessagesAllowed = broadcastAllowed - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit update channel paid messages price: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel antispam: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel antispam: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !channel.Megagroup || !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - prev := channel.AntiSpam - if _, err := tx.Exec(ctx, `UPDATE channels SET antispam = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { - return domain.Channel{}, fmt.Errorf("update channel antispam: %w", err) - } - channel.AntiSpam = enabled - if prev != enabled { - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: nowUnix(), - Type: domain.ChannelAdminLogToggleAntiSpam, - PrevBool: prev, - NewBool: enabled, - }); err != nil { - return domain.Channel{}, err - } - } - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel antispam: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) { - if userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel slowmode: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel slowmode: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !channel.Megagroup || !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - prev := channel.SlowmodeSeconds - if _, err := tx.Exec(ctx, `UPDATE channels SET slowmode_seconds = $2, updated_at = now() WHERE id = $1`, channelID, seconds); err != nil { - return domain.Channel{}, fmt.Errorf("update channel slowmode: %w", err) - } - channel.SlowmodeSeconds = seconds - if prev != seconds { - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: nowUnix(), - Type: domain.ChannelAdminLogToggleSlowMode, - PrevInt: prev, - NewInt: seconds, - }); err != nil { - return domain.Channel{}, err - } - } - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel slowmode: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel noforwards: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel noforwards: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if _, err := tx.Exec(ctx, `UPDATE channels SET noforwards = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { - return domain.Channel{}, fmt.Errorf("update channel noforwards: %w", err) - } - channel.NoForwards = enabled - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel noforwards: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel join_to_send: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel join_to_send: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !channel.Megagroup || !canExportChannelInvite(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if _, err := tx.Exec(ctx, `UPDATE channels SET join_to_send = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { - return domain.Channel{}, fmt.Errorf("update channel join_to_send: %w", err) - } - channel.JoinToSend = enabled - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel join_to_send: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("toggle channel join_request: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin toggle channel join_request: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !channel.Megagroup || !canExportChannelInvite(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if enabled && strings.TrimSpace(channel.Username) == "" { - return domain.Channel{}, domain.ErrChatPublicRequired - } - if _, err := tx.Exec(ctx, `UPDATE channels SET join_request = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { - return domain.Channel{}, fmt.Errorf("update channel join_request: %w", err) - } - channel.JoinRequest = enabled - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit toggle channel join_request: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("set channel available reactions: db does not support transactions") - } - policyJSON, err := marshalJSON(policy, "{}") - if err != nil { - return domain.Channel{}, err - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin set channel available reactions: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if _, err := tx.Exec(ctx, `UPDATE channels SET available_reactions = $2, updated_at = now() WHERE id = $1`, channelID, policyJSON); err != nil { - return domain.Channel{}, fmt.Errorf("update channel available reactions: %w", err) - } - channel.ReactionPolicy = policy - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit set channel available reactions: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("set channel color: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin set channel color: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if forProfile { - if _, err := tx.Exec(ctx, `UPDATE channels SET profile_color_set = $2, profile_color = $3, profile_color_background_emoji_id = $4, updated_at = now() WHERE id = $1`, - channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil { - return domain.Channel{}, fmt.Errorf("update channel profile color: %w", err) - } - channel.ProfileColor = color - } else { - if _, err := tx.Exec(ctx, `UPDATE channels SET color_set = $2, color = $3, color_background_emoji_id = $4, updated_at = now() WHERE id = $1`, - channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil { - return domain.Channel{}, fmt.Errorf("update channel color: %w", err) - } - channel.Color = color - } - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit set channel color: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) { - if userID == 0 || channelID == 0 { - return domain.Channel{}, domain.ErrChannelInvalid - } - if status.DocumentID == 0 { - status.Until = 0 - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.Channel{}, fmt.Errorf("set channel emoji status: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.Channel{}, fmt.Errorf("begin set channel emoji status: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) - if err != nil { - return domain.Channel{}, err - } - if !canChangeChannelInfo(member) { - return domain.Channel{}, domain.ErrChannelAdminRequired - } - if _, err := tx.Exec(ctx, `UPDATE channels SET emoji_status_document_id = $2, emoji_status_until = $3, updated_at = now() WHERE id = $1`, - channelID, status.DocumentID, status.Until); err != nil { - return domain.Channel{}, fmt.Errorf("update channel emoji status: %w", err) - } - channel.EmojiStatus = status - if err := tx.Commit(ctx); err != nil { - return domain.Channel{}, fmt.Errorf("commit set channel emoji status: %w", err) - } - committed = true - return channel, nil -} - -func (s *ChannelStore) ListAdminLog(ctx context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 { - return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid - } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelAdminLogResult{}, err - } - if !isChannelAdmin(member) { - return domain.ChannelAdminLogResult{}, domain.ErrChannelAdminRequired - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelAdminLogLimit { - limit = domain.MaxChannelAdminLogLimit - } - where := []string{"channel_id = $1"} - args := []any{req.ChannelID} - nextArg := func(v any) string { - args = append(args, v) - return fmt.Sprintf("$%d", len(args)) - } - if req.MaxID > 0 { - where = append(where, "id < "+nextArg(req.MaxID)) - } - if req.MinID > 0 { - where = append(where, "id > "+nextArg(req.MinID)) - } - if len(req.AdminUserIDs) > 0 { - where = append(where, "actor_user_id = ANY("+nextArg(int64s(req.AdminUserIDs))+"::bigint[])") - } - if types := adminLogEventTypesForFilter(req.Filter); len(types) > 0 { - where = append(where, "event_type = ANY("+nextArg(types)+"::text[])") - } else if !req.Filter.Empty() { - return domain.ChannelAdminLogResult{Channel: channel}, nil - } - query := strings.ToLower(strings.TrimSpace(req.Query)) - if query != "" { - like := adminLogLikePattern(query) - where = append(where, `(lower(prev_string) LIKE `+nextArg(like)+` ESCAPE '\' OR lower(new_string) LIKE `+nextArg(like)+` ESCAPE '\' OR lower(query) LIKE `+nextArg(like)+` ESCAPE '\')`) - } - args = append(args, limit) - rows, err := s.db.Query(ctx, ` -SELECT channel_id, id, actor_user_id, event_date, event_type, prev_string, new_string, prev_bool, new_bool, prev_int, new_int, - prev_participant::text, new_participant::text, participant::text, message::text, prev_message::text, new_message::text, query -FROM channel_admin_log_events -WHERE `+strings.Join(where, " AND ")+` -ORDER BY id DESC -LIMIT $`+fmt.Sprint(len(args)), args...) - if err != nil { - return domain.ChannelAdminLogResult{}, fmt.Errorf("list channel admin log: %w", err) - } - defer rows.Close() - events := make([]domain.ChannelAdminLogEvent, 0, limit) - for rows.Next() { - event, err := scanChannelAdminLogEvent(rows) - if err != nil { - return domain.ChannelAdminLogResult{}, err - } - events = append(events, event) - } - if err := rows.Err(); err != nil { - return domain.ChannelAdminLogResult{}, err - } - return domain.ChannelAdminLogResult{Channel: channel, Events: events}, nil -} - -func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero()) { - return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid - } - if req.Date == 0 { - req.Date = nowUnix() - } - var lastErr error - for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ { - res, err := s.sendChannelMessageOnce(ctx, req) - if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil { - return res, err - } - lastErr = err - } - return domain.SendChannelMessageResult{}, lastErr -} - -func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { - if req.RandomID != 0 { - if dup, found, err := s.duplicateChannelMessage(ctx, req.ChannelID, req.UserID, req.RandomID); err != nil { - return domain.SendChannelMessageResult{}, err - } else if found { - return dup, nil - } - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.SendChannelMessageResult{}, fmt.Errorf("send channel message: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.SendChannelMessageResult{}, fmt.Errorf("begin send channel: %w", err) - } - var reserved []reservedChannelPts - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - if len(reserved) > 0 { - s.recordChannelPtsGaps(ctx, reserved, req.Date) - } - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.SendChannelMessageResult{}, err - } - if !canSendChannelMessage(channel, member) { - return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden - } - replyTo, err := s.resolveChannelReply(ctx, tx, req, member, channel) - if err != nil { - return domain.SendChannelMessageResult{}, err - } - if _, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, replyTo, req.Forward); err != nil { - return domain.SendChannelMessageResult{}, err - } - if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 { - return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait) - } - var sendAs *domain.Peer - if req.SendAs != nil { - p := *req.SendAs - sendAs = &p - } - msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.ChannelID) - if err != nil { - return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel message id: %w", err) - } - pts, err := s.pts.NextChannelPts(ctx, req.ChannelID) - if err != nil { - return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel pts: %w", err) - } - reserved = []reservedChannelPts{{channelID: req.ChannelID, pts: pts, count: 1}} - var discussion *domain.SendChannelDiscussionResult - var discussionRef *domain.ChannelDiscussionRef - if channel.Broadcast && channel.LinkedChatID != 0 { - linked, err := getChannelByID(ctx, tx, channel.LinkedChatID) - if err == nil && !linked.Deleted && linked.Megagroup { - discussionMsgID, err := s.msgIDs.NextChannelMessageID(ctx, linked.ID) - if err != nil { - return domain.SendChannelMessageResult{}, fmt.Errorf("allocate discussion message id: %w", err) - } - discussionPts, err := s.pts.NextChannelPts(ctx, linked.ID) - if err != nil { - return domain.SendChannelMessageResult{}, fmt.Errorf("allocate discussion pts: %w", err) - } - reserved = append(reserved, reservedChannelPts{channelID: linked.ID, pts: discussionPts, count: 1}) - discussionRef = &domain.ChannelDiscussionRef{ChannelID: linked.ID, MessageID: discussionMsgID} - discussionMsg := domain.ChannelMessage{ - ChannelID: linked.ID, - ID: discussionMsgID, - SenderUserID: req.UserID, - From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, - Date: req.Date, - Silent: req.Silent, - NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, - Body: req.Message, - Entities: append([]domain.MessageEntity(nil), req.Entities...), - Media: req.Media, - Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, - Pts: discussionPts, - } - discussionEvent := domain.ChannelUpdateEvent{ - ChannelID: linked.ID, - Type: domain.ChannelUpdateNewMessage, - Pts: discussionPts, - PtsCount: 1, - Date: req.Date, - Message: discussionMsg, - } - if err := insertChannelMessageTx(ctx, tx, discussionMsg); err != nil { - return domain.SendChannelMessageResult{}, err - } - if err := insertChannelEventTx(ctx, tx, discussionEvent); err != nil { - return domain.SendChannelMessageResult{}, err - } - if err := insertChannelUnreadMentionsTx(ctx, tx, linked.ID, discussionMsg, req.UserID, req.MentionUserIDs); err != nil { - return domain.SendChannelMessageResult{}, err - } - if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, linked.ID, discussionMsgID, discussionPts); err != nil { - return domain.SendChannelMessageResult{}, fmt.Errorf("update discussion channel top: %w", err) - } - linked.TopMessageID = discussionMsgID - linked.Pts = discussionPts - if err := upsertChannelDialogsForMessageTx(ctx, tx, linked, discussionMsg, 0); err != nil { - return domain.SendChannelMessageResult{}, err - } - discussion = &domain.SendChannelDiscussionResult{ - Channel: linked, - Message: discussionMsg, - Event: discussionEvent, - } - } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { - return domain.SendChannelMessageResult{}, err - } - } - msg := domain.ChannelMessage{ - ChannelID: req.ChannelID, - ID: msgID, - RandomID: req.RandomID, - SenderUserID: req.UserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, - Date: req.Date, - Post: channel.Broadcast, - Silent: req.Silent, - NoForwards: req.NoForwards || channel.NoForwards, - Body: req.Message, - Entities: append([]domain.MessageEntity(nil), req.Entities...), - Media: req.Media, - ReplyTo: replyTo, - Forward: cloneMessageForward(req.Forward), - SendAs: sendAs, - Discussion: discussionRef, - Action: cloneChannelMessageAction(req.Action), - Pts: pts, - } - if discussionRef != nil { - msg.Replies = &domain.ChannelMessageReplies{Comments: true, ChannelID: discussionRef.ChannelID, RepliesPts: discussion.Event.Pts} - } - event := domain.ChannelUpdateEvent{ - ChannelID: req.ChannelID, - Type: domain.ChannelUpdateNewMessage, - Pts: pts, - PtsCount: 1, - Date: req.Date, - Message: msg, - SenderUserID: req.UserID, - } - if err := insertChannelMessageTx(ctx, tx, msg); err != nil { - if isUniqueViolation(err) { - dup, found, dupErr := s.duplicateChannelMessage(ctx, req.ChannelID, req.UserID, req.RandomID) - if dupErr != nil || !found { - return domain.SendChannelMessageResult{}, dupErr - } - dup.Duplicate = true - return dup, nil - } - return domain.SendChannelMessageResult{}, err - } - if err := insertChannelEventTx(ctx, tx, event); err != nil { - return domain.SendChannelMessageResult{}, err - } - if err := insertChannelUnreadMentionsTx(ctx, tx, req.ChannelID, msg, req.UserID, req.MentionUserIDs); err != nil { - return domain.SendChannelMessageResult{}, err - } - if err := updateForumTopicTopMessageTx(ctx, tx, req.ChannelID, msg); err != nil { - return domain.SendChannelMessageResult{}, err - } - if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.ChannelID, msgID, pts); err != nil { - return domain.SendChannelMessageResult{}, fmt.Errorf("update channel top: %w", err) - } - channel.TopMessageID = msgID - channel.Pts = pts - if _, err := tx.Exec(ctx, ` -UPDATE channel_members -SET slowmode_last_send_date = $3, - read_inbox_max_id = GREATEST(read_inbox_max_id, $4), - unread_mark = false, - updated_at = now() -WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, req.Date, msgID); err != nil { - return domain.SendChannelMessageResult{}, fmt.Errorf("update channel member slowmode send date: %w", err) - } - if err := upsertChannelDialogsForMessageTx(ctx, tx, channel, msg, req.UserID); err != nil { - return domain.SendChannelMessageResult{}, err - } - if channel.Broadcast { - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.Date, - Type: domain.ChannelAdminLogSendMessage, - Message: &msg, - Query: msg.Body, - }); err != nil { - return domain.SendChannelMessageResult{}, err - } - } - if err := tx.Commit(ctx); err != nil { - return domain.SendChannelMessageResult{}, fmt.Errorf("commit send channel: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - if discussion != nil { - discussion.Recipients, _ = s.ListActiveChannelMemberIDs(ctx, req.UserID, discussion.Channel.ID, 0) - } - return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients, Discussion: discussion}, nil -} - -func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || strings.TrimSpace(req.Message) == "" { - return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.EditChannelMessageResult{}, fmt.Errorf("edit channel message: db does not support transactions") - } - pts, err := s.pts.NextChannelPts(ctx, req.ChannelID) - if err != nil { - return domain.EditChannelMessageResult{}, fmt.Errorf("allocate channel edit pts: %w", err) - } - reserved := []reservedChannelPts{{channelID: req.ChannelID, pts: pts, count: 1}} - if req.EditDate == 0 { - req.EditDate = nowUnix() - } - entities, err := encodeMessageEntities(req.Entities) - if err != nil { - s.recordChannelPtsGaps(ctx, reserved, req.EditDate) - return domain.EditChannelMessageResult{}, err - } - tx, err := beginner.Begin(ctx) - if err != nil { - s.recordChannelPtsGaps(ctx, reserved, req.EditDate) - return domain.EditChannelMessageResult{}, fmt.Errorf("begin edit channel message: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, req.EditDate) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.EditChannelMessageResult{}, err - } - msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.ID) - if err != nil { - return domain.EditChannelMessageResult{}, err - } - if msg.Deleted || msg.Action != nil { - return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid - } - if msg.SenderUserID != req.UserID && !canEditChannelMessage(member) { - return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired - } - if msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { - return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified - } - prevMsg := msg - if _, err := tx.Exec(ctx, ` -UPDATE channel_messages -SET body = $4, entities = $5, edit_date = $6, pts = $7, updated_at = now() -WHERE channel_id = $1 AND id = $2 AND NOT deleted AND sender_user_id = $3 OR ( - channel_id = $1 AND id = $2 AND NOT deleted AND $8 -)`, - req.ChannelID, req.ID, req.UserID, req.Message, entities, req.EditDate, pts, canEditChannelMessage(member)); err != nil { - return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit: %w", err) - } - msg.Body = req.Message - msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) - msg.EditDate = req.EditDate - msg.Pts = pts - event := domain.ChannelUpdateEvent{ - ChannelID: req.ChannelID, - Type: domain.ChannelUpdateEditMessage, - Pts: pts, - PtsCount: 1, - Date: req.EditDate, - Message: msg, - SenderUserID: req.UserID, - } - if err := insertChannelEventTx(ctx, tx, event); err != nil { - return domain.EditChannelMessageResult{}, err - } - if _, err := tx.Exec(ctx, `UPDATE channels SET pts = $2, updated_at = now() WHERE id = $1`, req.ChannelID, pts); err != nil { - return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit pts: %w", err) - } - channel.Pts = pts - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.EditDate, - Type: domain.ChannelAdminLogEditMessage, - PrevMessage: &prevMsg, - NewMessage: &msg, - Query: msg.Body, - }); err != nil { - return domain.EditChannelMessageResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.EditChannelMessageResult{}, fmt.Errorf("commit edit channel message: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - return domain.EditChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients}, nil -} - -func (s *ChannelStore) DeleteChannelMessages(ctx context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || len(req.IDs) == 0 { - return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid - } - if len(req.IDs) > domain.MaxDeleteMessageIDs { - return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid - } - if req.Date == 0 { - req.Date = nowUnix() - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.DeleteChannelMessagesResult{}, fmt.Errorf("delete channel messages: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.DeleteChannelMessagesResult{}, fmt.Errorf("begin delete channel messages: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, req.Date) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelMessagesResult{}, err - } - deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, req.IDs, req.UserID, req.Date, &reserved) - if err != nil { - return domain.DeleteChannelMessagesResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.DeleteChannelMessagesResult{}, fmt.Errorf("commit delete channel messages: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - return domain.DeleteChannelMessagesResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients}, nil -} - -func (s *ChannelStore) DeleteChannelHistory(ctx context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid - } - if req.Date == 0 { - req.Date = nowUnix() - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete channel history: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete channel history: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, req.Date) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - maxID := req.MaxID - if maxID <= 0 || maxID > channel.TopMessageID { - maxID = channel.TopMessageID - } - if !req.ForEveryone { - appliedMinID := maxInt(member.AvailableMinID, maxID) - topID, topDate, err := visibleChannelTopAfter(ctx, tx, req.ChannelID, appliedMinID, channel.Date) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - if _, err := tx.Exec(ctx, ` -UPDATE channel_members -SET available_min_id = GREATEST(available_min_id, $3), - read_inbox_max_id = GREATEST(read_inbox_max_id, $3), - unread_mark = false, - updated_at = now() -WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID); err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear member: %w", err) - } - if err := deleteChannelUnreadMentionsUpToTx(ctx, tx, req.UserID, req.ChannelID, appliedMinID); err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - if err := refreshChannelUnreadReactionsCountTx(ctx, tx, req.UserID, req.ChannelID); err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_dialogs ( - user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark -) VALUES ($1,$2,$3,$4,$5,0,0,false) -ON CONFLICT (user_id, channel_id) DO UPDATE SET - top_message_id = EXCLUDED.top_message_id, - top_message_date = EXCLUDED.top_message_date, - read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), - unread_count = 0, - unread_mark = false, - updated_at = now()`, req.UserID, req.ChannelID, topID, topDate, appliedMinID); err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("upsert channel local clear dialog: %w", err) - } - if err := tx.Commit(ctx); err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit local clear channel history: %w", err) - } - committed = true - return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil - } - if !canDeleteAnyChannelMessage(member) { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired - } - rows, err := tx.Query(ctx, ` -SELECT id -FROM channel_messages -WHERE channel_id = $1 AND id <= $2 AND NOT deleted -ORDER BY id DESC -LIMIT $3`, req.ChannelID, maxID, domain.MaxDeleteHistoryBatch) - if err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list channel history delete ids: %w", err) - } - ids := make([]int, 0, domain.MaxDeleteHistoryBatch) - for rows.Next() { - var id int - if err := rows.Scan(&id); err != nil { - rows.Close() - return domain.DeleteChannelHistoryResult{}, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - rows.Close() - return domain.DeleteChannelHistoryResult{}, err - } - rows.Close() - deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date, &reserved) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete channel history: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - offset := 0 - if len(deleted) == domain.MaxDeleteHistoryBatch { - offset = 1 - } - return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil -} - -func (s *ChannelStore) DeleteChannelParticipantHistory(ctx context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid - } - if req.Date == 0 { - req.Date = nowUnix() - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete participant channel history: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete participant channel history: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, req.Date) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - if !canDeleteAnyChannelMessage(member) { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired - } - rows, err := tx.Query(ctx, ` -SELECT id -FROM channel_messages -WHERE channel_id = $1 AND sender_user_id = $2 AND NOT deleted -ORDER BY id DESC -LIMIT $3`, req.ChannelID, req.ParticipantUserID, domain.MaxDeleteHistoryBatch) - if err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list participant channel history delete ids: %w", err) - } - ids := make([]int, 0, domain.MaxDeleteHistoryBatch) - for rows.Next() { - var id int - if err := rows.Scan(&id); err != nil { - rows.Close() - return domain.DeleteChannelHistoryResult{}, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - rows.Close() - return domain.DeleteChannelHistoryResult{}, err - } - rows.Close() - deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date, &reserved) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete participant channel history: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - offset := 0 - if len(deleted) == domain.MaxDeleteHistoryBatch { - offset = 1 - } - return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil -} - -func (s *ChannelStore) UpdatePinnedMessage(ctx context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { - return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("pin channel message: db does not support transactions") - } - if req.Date == 0 { - req.Date = nowUnix() - } - pts, err := s.pts.NextChannelPts(ctx, req.ChannelID) - if err != nil { - return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("allocate channel pin pts: %w", err) - } - reserved := []reservedChannelPts{{channelID: req.ChannelID, pts: pts, count: 1}} - tx, err := beginner.Begin(ctx) - if err != nil { - s.recordChannelPtsGaps(ctx, reserved, req.Date) - return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("begin pin channel message: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, req.Date) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.UpdateChannelPinnedMessageResult{}, err - } - if !canPinChannelMessages(channel, member) { - return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired - } - msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) - if err != nil || msg.Deleted { - return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid - } - pinnedID := 0 - if req.Pinned { - pinnedID = req.MessageID - } - if channel.PinnedMessageID == pinnedID { - return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified - } - if _, err := tx.Exec(ctx, ` -UPDATE channels -SET pinned_message_id = $2, pts = $3, updated_at = now() -WHERE id = $1`, req.ChannelID, pinnedID, pts); err != nil { - return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("update channel pinned message: %w", err) - } - channel.PinnedMessageID = pinnedID - channel.Pts = pts - event := domain.ChannelUpdateEvent{ - ChannelID: req.ChannelID, - Type: domain.ChannelUpdatePinnedMessages, - Pts: pts, - PtsCount: 1, - Date: req.Date, - MessageIDs: []int{req.MessageID}, - SenderUserID: req.UserID, - Pinned: req.Pinned, - } - if err := insertChannelEventTx(ctx, tx, event); err != nil { - return domain.UpdateChannelPinnedMessageResult{}, err - } - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: req.ChannelID, - UserID: req.UserID, - Date: req.Date, - Type: domain.ChannelAdminLogUpdatePinned, - Message: &msg, - Query: msg.Body, - }); err != nil { - return domain.UpdateChannelPinnedMessageResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("commit pin channel message: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - return domain.UpdateChannelPinnedMessageResult{Channel: channel, Event: event, Recipients: recipients}, nil -} - -func (s *ChannelStore) ExportInvite(ctx context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.ExportChannelInviteResult{}, fmt.Errorf("export channel invite: db does not support transactions") - } - if req.Date == 0 { - req.Date = nowUnix() - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.ExportChannelInviteResult{}, fmt.Errorf("begin export channel invite: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.ExportChannelInviteResult{}, err - } - if !canExportChannelInvite(member) { - return domain.ExportChannelInviteResult{}, domain.ErrChannelAdminRequired - } - if req.LegacyRevokePermanent { - if _, err := tx.Exec(ctx, ` -UPDATE channel_invites -SET revoked = true, updated_at = now() -WHERE channel_id = $1 AND admin_user_id = $2 AND permanent AND NOT revoked`, req.ChannelID, req.UserID); err != nil { - return domain.ExportChannelInviteResult{}, fmt.Errorf("revoke permanent channel invite: %w", err) - } - } - inviteID, err := randomPositiveInt64() - if err != nil { - return domain.ExportChannelInviteResult{}, err - } - hash, err := randomInviteHash() - if err != nil { - return domain.ExportChannelInviteResult{}, err - } - invite := domain.ChannelInvite{ - ChannelID: req.ChannelID, - InviteID: inviteID, - Hash: hash, - AdminUserID: req.UserID, - Title: req.Title, - Permanent: req.ExpireDate == 0 && req.UsageLimit == 0 && !req.RequestNeeded && req.Title == "", - RequestNeeded: req.RequestNeeded, - ExpireDate: req.ExpireDate, - UsageLimit: req.UsageLimit, - Date: req.Date, - } - if err := insertChannelInviteTx(ctx, tx, invite); err != nil { - return domain.ExportChannelInviteResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.ExportChannelInviteResult{}, fmt.Errorf("commit export channel invite: %w", err) - } - committed = true - return domain.ExportChannelInviteResult{Channel: channel, Invite: invite}, nil -} - -func (s *ChannelStore) CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) { - if userID == 0 || strings.TrimSpace(hash) == "" { - return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty - } - if date == 0 { - date = nowUnix() - } - channel, invite, err := s.getInviteByHash(ctx, s.db, strings.TrimSpace(hash)) - if err != nil { - return domain.CheckChannelInviteResult{}, err - } - if invite.ExpireDate > 0 && invite.ExpireDate < date { - return domain.CheckChannelInviteResult{}, domain.ErrInviteHashExpired - } - member, err := s.getChannelMember(ctx, s.db, channel.ID, userID) - already := false - if err == nil { - if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages { - return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid - } - already = member.Status == domain.ChannelMemberActive - } else if !errors.Is(err, domain.ErrChannelPrivate) { - return domain.CheckChannelInviteResult{}, err - } - return domain.CheckChannelInviteResult{Channel: channel, Invite: invite, Already: already, Self: member}, nil -} - -func (s *ChannelStore) ImportInvite(ctx context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) { - if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" { - return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty - } - if req.Date == 0 { - req.Date = nowUnix() - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.CreateChannelResult{}, fmt.Errorf("import channel invite: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("begin import channel invite: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, req.Date) - } - }() - channel, invite, err := s.getInviteByHashForUpdate(ctx, tx, strings.TrimSpace(req.Hash)) - if err != nil { - return domain.CreateChannelResult{}, err - } - if invite.ExpireDate > 0 && invite.ExpireDate < req.Date { - return domain.CreateChannelResult{}, domain.ErrInviteHashExpired - } - if invite.RequestNeeded { - if err := s.recordPendingInviteImporterTx(ctx, tx, invite, req.UserID, req.Date); err != nil { - return domain.CreateChannelResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("commit pending channel invite request: %w", err) - } - committed = true - return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent - } - result, err := s.approveInviteImporterTx(ctx, tx, channel, invite, req.UserID, 0, req.Date, &reserved) - if err != nil { - return domain.CreateChannelResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("commit import channel invite: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, result.Channel.ID, 0) - result.Recipients = recipients - return result, nil -} - -func (s *ChannelStore) approveInviteImporterTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, invite domain.ChannelInvite, userID, approvedBy int64, date int, reserved *[]reservedChannelPts) (domain.CreateChannelResult, error) { - if invite.InviteID != 0 && invite.UsageLimit > 0 && invite.UsageCount >= invite.UsageLimit { - return domain.CreateChannelResult{}, domain.ErrUsersTooMuch - } - channelID := channel.ID - if channelID == 0 { - channelID = invite.ChannelID - } - if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { - if existing.Status == domain.ChannelMemberActive { - return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant - } - if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { - return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid - } - } else if !errors.Is(err, domain.ErrChannelPrivate) { - return domain.CreateChannelResult{}, err - } - preJoinTopID := channel.TopMessageID - minID := channelInitialAvailableMinID(channel) - inviterID := invite.AdminUserID - if inviterID == 0 { - inviterID = approvedBy - } - member := domain.ChannelMember{ - ChannelID: channelID, - UserID: userID, - InviterUserID: inviterID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberActive, - JoinedAt: date, - AvailableMinID: minID, - AvailableMinPts: channelInitialAvailableMinPts(channel), - ReadInboxMaxID: maxInt(minID, preJoinTopID), - } - if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { - return domain.CreateChannelResult{}, err - } - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: date, - Type: domain.ChannelAdminLogParticipantJoin, - }); err != nil { - return domain.CreateChannelResult{}, err - } - if invite.InviteID != 0 { - if _, err := tx.Exec(ctx, ` -UPDATE channel_invites -SET requested_count = GREATEST(requested_count - 1, 0), - updated_at = now() -WHERE channel_id = $1 - AND invite_id = ( - SELECT invite_id - FROM channel_invite_importers - WHERE channel_id = $1 AND user_id = $2 AND requested - )`, channelID, userID); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("clear pending channel invite request: %w", err) - } - if _, err := tx.Exec(ctx, ` -UPDATE channel_invites -SET usage_count = usage_count + 1, - updated_at = now() -WHERE channel_id = $1 AND invite_id = $2`, channelID, invite.InviteID); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("increment channel invite usage: %w", err) - } - } - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested, approved_by) -VALUES ($1, $2, $3, $4, false, $5) -ON CONFLICT (channel_id, user_id) DO UPDATE -SET invite_id = EXCLUDED.invite_id, - date = EXCLUDED.date, - requested = false, - approved_by = EXCLUDED.approved_by, - updated_at = now()`, channelID, invite.InviteID, userID, date, approvedBy); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("upsert channel invite importer: %w", err) - } - channel, err := refreshChannelCountsTx(ctx, tx, channel) - if err != nil { - return domain.CreateChannelResult{}, err - } - var msg domain.ChannelMessage - var event domain.ChannelUpdateEvent - if channel.Megagroup { - msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ - Type: domain.ChannelActionChatJoined, - UserIDs: []int64{userID}, - }, reserved) - if err != nil { - return domain.CreateChannelResult{}, err - } - channel.TopMessageID = msg.ID - channel.Pts = event.Pts - } - member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) - if msg.ID != 0 { - member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) - } - if _, err := tx.Exec(ctx, ` -UPDATE channel_members -SET read_inbox_max_id = $3, read_outbox_max_id = $4, updated_at = now() -WHERE channel_id = $1 AND user_id = $2`, channel.ID, userID, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("update imported member read state: %w", err) - } - if err := upsertChannelDialogTx(ctx, tx, userID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { - return domain.CreateChannelResult{}, err - } - return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event}, nil -} - -func (s *ChannelStore) recordPendingInviteImporterTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite, userID int64, date int) error { - if existing, err := s.getChannelMember(ctx, tx, invite.ChannelID, userID); err == nil { - if existing.Status == domain.ChannelMemberActive { - return domain.ErrUserAlreadyParticipant - } - if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { - return domain.ErrInviteHashInvalid - } - } else if !errors.Is(err, domain.ErrChannelPrivate) { - return err - } - tag, err := tx.Exec(ctx, ` -INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested) -VALUES ($1, $2, $3, $4, true) -ON CONFLICT (channel_id, user_id) DO UPDATE -SET invite_id = EXCLUDED.invite_id, - date = EXCLUDED.date, - requested = true, - approved_by = 0, - updated_at = now() -WHERE NOT channel_invite_importers.requested`, - invite.ChannelID, invite.InviteID, userID, date) - if err != nil { - return fmt.Errorf("record pending channel invite importer: %w", err) - } - if tag.RowsAffected() == 0 { - return domain.ErrInviteRequestSent - } - if _, err := tx.Exec(ctx, ` -UPDATE channel_invites -SET requested_count = requested_count + 1, updated_at = now() -WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { - return fmt.Errorf("increment channel invite requested count: %w", err) - } - return nil -} - -func (s *ChannelStore) recordPublicJoinRequestTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, userID int64, date int) error { - if existing, err := s.getChannelMember(ctx, tx, channel.ID, userID); err == nil { - if existing.Status == domain.ChannelMemberActive { - return domain.ErrUserAlreadyParticipant - } - if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { - return domain.ErrInviteHashInvalid - } - } else if !errors.Is(err, domain.ErrChannelPrivate) { - return err - } - if existing, err := s.getPendingInviteImporterTx(ctx, tx, channel.ID, userID, true); err == nil && existing.Requested { - return domain.ErrInviteRequestSent - } else if err != nil && !errors.Is(err, domain.ErrHideRequesterMissing) { - return err - } - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested) -VALUES ($1, 0, $2, $3, true) -ON CONFLICT (channel_id, user_id) DO UPDATE -SET invite_id = 0, - date = EXCLUDED.date, - requested = true, - approved_by = 0, - updated_at = now()`, channel.ID, userID, date); err != nil { - return fmt.Errorf("insert public channel join request: %w", err) - } - return nil -} - -func (s *ChannelStore) ListExportedInvites(ctx context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { - return domain.ChannelInviteList{}, domain.ErrChannelInvalid - } - _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelInviteList{}, err - } - if !canExportChannelInvite(member) { - return domain.ChannelInviteList{}, domain.ErrChannelAdminRequired - } - var total int - if err := s.db.QueryRow(ctx, ` -SELECT COUNT(*)::int -FROM channel_invites -WHERE channel_id = $1 AND admin_user_id = $2 AND revoked = $3`, req.ChannelID, req.AdminUserID, req.Revoked).Scan(&total); err != nil { - return domain.ChannelInviteList{}, err - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelInviteListLimit { - limit = domain.MaxChannelInviteListLimit - } - rows, err := s.db.Query(ctx, ` -SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, - COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, - EXTRACT(EPOCH FROM created_at)::int -FROM channel_invites -WHERE channel_id = $1 - AND admin_user_id = $2 - AND revoked = $3 - AND (($4::int = 0 AND $5::text = '') OR (EXTRACT(EPOCH FROM created_at)::int, hash) < ($4, $5)) -ORDER BY EXTRACT(EPOCH FROM created_at)::int DESC, hash DESC -LIMIT $6`, req.ChannelID, req.AdminUserID, req.Revoked, req.OffsetDate, req.OffsetHash, limit) - if err != nil { - return domain.ChannelInviteList{}, err - } - defer rows.Close() - invites := make([]domain.ChannelInvite, 0, limit) - for rows.Next() { - invite, err := scanChannelInvite(rows) - if err != nil { - return domain.ChannelInviteList{}, err - } - invites = append(invites, invite) - } - if err := rows.Err(); err != nil { - return domain.ChannelInviteList{}, err - } - return domain.ChannelInviteList{Count: total, Invites: invites}, nil -} - -func (s *ChannelStore) GetExportedInvite(ctx context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) { - if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { - return domain.ChannelInvite{}, domain.ErrInviteHashEmpty - } - _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelInvite{}, err - } - if !canExportChannelInvite(member) { - return domain.ChannelInvite{}, domain.ErrChannelAdminRequired - } - return s.getInviteByChannelHash(ctx, s.db, req.ChannelID, req.Hash, false) -} - -func (s *ChannelStore) EditExportedInvite(ctx context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { - return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty - } - if req.Date == 0 { - req.Date = nowUnix() - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.EditChannelInviteResult{}, fmt.Errorf("edit channel invite: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.EditChannelInviteResult{}, fmt.Errorf("begin edit channel invite: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - if _, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID); err != nil { - return domain.EditChannelInviteResult{}, err - } else if !canExportChannelInvite(member) { - return domain.EditChannelInviteResult{}, domain.ErrChannelAdminRequired - } - invite, err := s.getInviteByChannelHash(ctx, tx, req.ChannelID, req.Hash, true) - if err != nil { - return domain.EditChannelInviteResult{}, err - } - if req.Revoked { - if invite.Revoked { - return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing - } - if _, err := tx.Exec(ctx, `UPDATE channel_invites SET revoked = true, updated_at = now() WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { - return domain.EditChannelInviteResult{}, fmt.Errorf("revoke channel invite: %w", err) - } - invite.Revoked = true - result := domain.EditChannelInviteResult{Invite: invite} - if invite.Permanent { - newInvite, err := s.newPostgresReplacementInvite(invite, req.Date) - if err != nil { - return domain.EditChannelInviteResult{}, err - } - if err := insertChannelInviteTx(ctx, tx, newInvite); err != nil { - return domain.EditChannelInviteResult{}, err - } - result.NewInvite = &newInvite - } - if err := tx.Commit(ctx); err != nil { - return domain.EditChannelInviteResult{}, fmt.Errorf("commit edit channel invite: %w", err) - } - committed = true - return result, nil - } - if invite.Revoked { - return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing - } - if invite.Permanent && ((req.HasExpireDate && req.ExpireDate > 0) || (req.HasUsageLimit && req.UsageLimit > 0) || (req.HasRequestNeeded && req.RequestNeeded)) { - return domain.EditChannelInviteResult{}, domain.ErrInvitePermanent - } - if req.HasExpireDate { - invite.ExpireDate = req.ExpireDate - } - if req.HasUsageLimit { - invite.UsageLimit = req.UsageLimit - } - if req.HasRequestNeeded { - invite.RequestNeeded = req.RequestNeeded - } - if req.HasTitle { - invite.Title = req.Title - } - invite.Permanent = invite.ExpireDate == 0 && invite.UsageLimit == 0 && !invite.RequestNeeded && invite.Title == "" - if _, err := tx.Exec(ctx, ` -UPDATE channel_invites -SET title = $3, - expire_date = NULLIF($4, 0), - usage_limit = NULLIF($5, 0), - request_needed = $6, - permanent = $7, - updated_at = now() -WHERE channel_id = $1 AND invite_id = $2`, - invite.ChannelID, invite.InviteID, invite.Title, invite.ExpireDate, invite.UsageLimit, invite.RequestNeeded, invite.Permanent); err != nil { - return domain.EditChannelInviteResult{}, fmt.Errorf("update channel invite: %w", err) - } - if err := tx.Commit(ctx); err != nil { - return domain.EditChannelInviteResult{}, fmt.Errorf("commit edit channel invite: %w", err) - } - committed = true - return domain.EditChannelInviteResult{Invite: invite}, nil -} - -func (s *ChannelStore) DeleteExportedInvite(ctx context.Context, req domain.DeleteChannelInviteRequest) error { - if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { - return domain.ErrInviteHashEmpty - } - if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { - return err - } else if !canExportChannelInvite(member) { - return domain.ErrChannelAdminRequired - } - tag, err := s.db.Exec(ctx, ` -WITH deleted AS ( - DELETE FROM channel_invites - WHERE channel_id = $1 AND hash = $2 - RETURNING hash -) -DELETE FROM channel_invite_hashes h USING deleted d WHERE h.hash = d.hash`, req.ChannelID, strings.TrimSpace(req.Hash)) - if err != nil { - return fmt.Errorf("delete channel invite: %w", err) - } - if tag.RowsAffected() == 0 { - return domain.ErrInviteRevokedMissing - } - return nil -} - -func (s *ChannelStore) DeleteRevokedExportedInvites(ctx context.Context, req domain.DeleteRevokedChannelInvitesRequest) error { - if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { - return domain.ErrChannelInvalid - } - if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { - return err - } else if !canExportChannelInvite(member) { - return domain.ErrChannelAdminRequired - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { - limit = domain.MaxChannelHideJoinRequests - } - if _, err := s.db.Exec(ctx, ` -WITH deleted AS ( - DELETE FROM channel_invites - WHERE ctid IN ( - SELECT ctid FROM channel_invites - WHERE channel_id = $1 AND admin_user_id = $2 AND revoked - ORDER BY updated_at ASC - LIMIT $3 - ) - RETURNING hash -) -DELETE FROM channel_invite_hashes h USING deleted d WHERE h.hash = d.hash`, req.ChannelID, req.AdminUserID, limit); err != nil { - return fmt.Errorf("delete revoked channel invites: %w", err) - } - return nil -} - -func (s *ChannelStore) ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) { - if userID == 0 || channelID == 0 { - return nil, domain.ErrChannelInvalid - } - if _, member, err := s.getChannelForMember(ctx, s.db, userID, channelID); err != nil { - return nil, err - } else if !canExportChannelInvite(member) { - return nil, domain.ErrChannelAdminRequired - } - rows, err := s.db.Query(ctx, ` -SELECT admin_user_id, - COUNT(*) FILTER (WHERE NOT revoked)::int, - COUNT(*) FILTER (WHERE revoked)::int -FROM channel_invites -WHERE channel_id = $1 -GROUP BY admin_user_id -ORDER BY admin_user_id ASC`, channelID) - if err != nil { - return nil, err - } - defer rows.Close() - out := make([]domain.ChannelAdminInviteCount, 0) - for rows.Next() { - var count domain.ChannelAdminInviteCount - if err := rows.Scan(&count.AdminUserID, &count.InvitesCount, &count.RevokedInvitesCount); err != nil { - return nil, err - } - out = append(out, count) - } - return out, rows.Err() -} - -func (s *ChannelStore) ListInviteImporters(ctx context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid - } - if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { - return domain.ChannelInviteImporterList{}, err - } else if !canExportChannelInvite(member) { - return domain.ChannelInviteImporterList{}, domain.ErrChannelAdminRequired - } - var inviteID int64 - if req.Hash != "" { - invite, err := s.getInviteByChannelHash(ctx, s.db, req.ChannelID, req.Hash, false) - if err != nil { - return domain.ChannelInviteImporterList{}, err - } - inviteID = invite.InviteID - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelInviteListLimit { - limit = domain.MaxChannelInviteListLimit - } - args := []any{req.ChannelID, req.Requested, inviteID, req.Query, req.OffsetDate, req.OffsetUserID, limit} - where := []string{ - "i.channel_id = $1", - "i.requested = $2", - "($3::bigint = 0 OR i.invite_id = $3)", - "($4::text = '' OR lower(trim(u.username || ' ' || u.first_name || ' ' || u.last_name)) LIKE '%' || lower($4) || '%')", - "(($5::int = 0 AND $6::bigint = 0) OR (i.date, i.user_id) < ($5, $6))", - } - whereSQL := strings.Join(where, " AND ") - var total int - if err := s.db.QueryRow(ctx, ` -SELECT COUNT(*)::int -FROM channel_invite_importers i -JOIN users u ON u.id = i.user_id -WHERE `+whereSQL, args[:6]...).Scan(&total); err != nil { - return domain.ChannelInviteImporterList{}, err - } - rows, err := s.db.Query(ctx, ` -SELECT i.channel_id, i.invite_id, i.user_id, i.date, i.requested, i.approved_by, i.via_chatlist, i.about -FROM channel_invite_importers i -JOIN users u ON u.id = i.user_id -WHERE `+whereSQL+` -ORDER BY i.date DESC, i.user_id DESC -LIMIT $7`, args...) - if err != nil { - return domain.ChannelInviteImporterList{}, err - } - defer rows.Close() - importers := make([]domain.ChannelInviteImporter, 0, limit) - for rows.Next() { - var importer domain.ChannelInviteImporter - if err := rows.Scan(&importer.ChannelID, &importer.InviteID, &importer.UserID, &importer.Date, &importer.Requested, &importer.ApprovedBy, &importer.ViaChatlist, &importer.About); err != nil { - return domain.ChannelInviteImporterList{}, err - } - importers = append(importers, importer) - } - if err := rows.Err(); err != nil { - return domain.ChannelInviteImporterList{}, err - } - return domain.ChannelInviteImporterList{Count: total, Importers: importers}, nil -} - -func (s *ChannelStore) PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) { - if channelID == 0 { - return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxChannelPendingJoinRecentRequesters { - limit = domain.MaxChannelPendingJoinRecentRequesters - } - rows, err := s.db.Query(ctx, ` -SELECT user_id, COUNT(*) OVER()::int -FROM channel_invite_importers -WHERE channel_id = $1 AND requested -ORDER BY date DESC, user_id DESC -LIMIT $2`, channelID, limit) - if err != nil { - return domain.ChannelPendingJoinRequests{}, fmt.Errorf("list pending channel join requests: %w", err) - } - defer rows.Close() - out := domain.ChannelPendingJoinRequests{ - ChannelID: channelID, - RecentRequesters: make([]int64, 0, limit), - } - for rows.Next() { - var userID int64 - var count int - if err := rows.Scan(&userID, &count); err != nil { - return domain.ChannelPendingJoinRequests{}, err - } - out.Count = count - out.RecentRequesters = append(out.RecentRequesters, userID) - } - if err := rows.Err(); err != nil { - return domain.ChannelPendingJoinRequests{}, err - } - return out, nil -} - -func (s *ChannelStore) HideChatJoinRequest(ctx context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.TargetUserID == 0 { - return domain.CreateChannelResult{}, domain.ErrChannelInvalid - } - if req.Date == 0 { - req.Date = nowUnix() - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.CreateChannelResult{}, fmt.Errorf("hide channel join request: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("begin hide channel join request: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, req.Date) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.CreateChannelResult{}, err - } - if !canExportChannelInvite(member) { - return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired - } - importer, err := s.getPendingInviteImporterTx(ctx, tx, req.ChannelID, req.TargetUserID, true) - if err != nil { - return domain.CreateChannelResult{}, err - } - invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID} - if importer.InviteID != 0 { - invite, err = s.getInviteByID(ctx, tx, req.ChannelID, importer.InviteID, true) - if err != nil { - return domain.CreateChannelResult{}, err - } - } - var result domain.CreateChannelResult - if req.Approved { - result, err = s.approveInviteImporterTx(ctx, tx, channel, invite, req.TargetUserID, req.UserID, req.Date, &reserved) - if err != nil { - return domain.CreateChannelResult{}, err - } - } else if err := deletePendingInviteImporterTx(ctx, tx, invite, req.TargetUserID); err != nil { - return domain.CreateChannelResult{}, err - } else { - result = domain.CreateChannelResult{Channel: channel} - } - if err := tx.Commit(ctx); err != nil { - return domain.CreateChannelResult{}, fmt.Errorf("commit hide channel join request: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - result.Recipients = recipients - return result, nil -} - -func (s *ChannelStore) HideAllChatJoinRequests(ctx context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.CreateChannelResult{}, domain.ErrChannelInvalid - } - if req.Date == 0 { - req.Date = nowUnix() - } - var inviteID int64 - if req.Hash != "" { - invite, err := s.GetExportedInvite(ctx, domain.GetChannelInviteRequest{UserID: req.UserID, ChannelID: req.ChannelID, Hash: req.Hash}) - if err != nil { - return domain.CreateChannelResult{}, err - } - inviteID = invite.InviteID - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { - limit = domain.MaxChannelHideJoinRequests - } - rows, err := s.db.Query(ctx, ` -SELECT user_id -FROM channel_invite_importers -WHERE channel_id = $1 AND requested AND ($2::bigint = 0 OR invite_id = $2) -ORDER BY date ASC, user_id ASC -LIMIT $3`, req.ChannelID, inviteID, limit) - if err != nil { - return domain.CreateChannelResult{}, err - } - targets := make([]int64, 0, limit) - for rows.Next() { - var userID int64 - if err := rows.Scan(&userID); err != nil { - rows.Close() - return domain.CreateChannelResult{}, err - } - targets = append(targets, userID) - } - if err := rows.Err(); err != nil { - rows.Close() - return domain.CreateChannelResult{}, err - } - rows.Close() - var result domain.CreateChannelResult - for _, target := range targets { - next, err := s.HideChatJoinRequest(ctx, domain.HideChannelJoinRequestRequest{ - UserID: req.UserID, - ChannelID: req.ChannelID, - TargetUserID: target, - Approved: req.Approved, - Date: req.Date, - }) - if err != nil { - return domain.CreateChannelResult{}, err - } - result = next - } - if result.Channel.ID == 0 { - ch, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.CreateChannelResult{}, err - } - result.Channel = ch - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - result.Recipients = recipients - } - return result, nil -} - -func channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr string) string { - return fmt.Sprintf(`( - SELECT COUNT(*)::int - FROM channel_messages cm_unread - WHERE cm_unread.channel_id = c.id - AND cm_unread.id > GREATEST(%s, m.available_min_id) - AND cm_unread.id <= %s - AND NOT cm_unread.deleted - AND cm_unread.sender_user_id <> m.user_id - )`, readInboxExpr, topIDExpr) -} - -func channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr string) string { - return fmt.Sprintf(`EXISTS ( - SELECT 1 - FROM channel_messages cm_unread - WHERE cm_unread.channel_id = c.id - AND cm_unread.id > GREATEST(%s, m.available_min_id) - AND cm_unread.id <= %s - AND NOT cm_unread.deleted - AND cm_unread.sender_user_id <> m.user_id - )`, readInboxExpr, topIDExpr) -} - -func channelDialogVisibleUnreadCountSQL(readInboxExpr, topIDExpr string) string { - dynamicCount := channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr) - return fmt.Sprintf(`CASE - WHEN c.broadcast OR c.participants_count > %d THEN %s - ELSE COALESCE(d.unread_count, %s) - END`, domain.MaxSynchronousChannelDialogFanout, dynamicCount, dynamicCount) -} - -func channelDialogHasUnreadSQL(readInboxExpr, topIDExpr string) string { - dynamicUnread := channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr) - return fmt.Sprintf(`CASE - WHEN c.broadcast OR c.participants_count > %d THEN %s - ELSE COALESCE(d.unread_count > 0, %s) - END`, domain.MaxSynchronousChannelDialogFanout, dynamicUnread, dynamicUnread) -} - -func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { - if viewerUserID == 0 { - return domain.ChannelDialogList{}, nil - } - limit := filter.Limit - if limit <= 0 || limit > 100 { - limit = 100 - } - visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" - visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END" - visibleReadInbox := "COALESCE(d.read_inbox_max_id, m.read_inbox_max_id)" - visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) - args := []any{viewerUserID} - where := []string{"m.user_id = $1", "m.status = 'active'"} - if filter.HasFolderID && filter.FolderID < domain.DialogCustomFolderMinID { - args = append(args, filter.FolderID) - where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) = $%d", len(args))) - } - if filter.PinnedOnly { - where = append(where, "COALESCE(d.pinned, false)") - } - if filter.ExcludePinned { - where = append(where, "NOT COALESCE(d.pinned, false)") - } - switch { - case filter.OffsetDate > 0: - args = append(args, filter.OffsetDate, filter.OffsetID) - dateArg := fmt.Sprintf("$%d", len(args)-1) - idArg := fmt.Sprintf("$%d", len(args)) - if filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0 { - args = append(args, filter.OffsetPeer.ID) - peerArg := fmt.Sprintf("$%d", len(args)) - where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND %s < %s) OR (%s = %s AND %s = %s AND c.id < %s))", - visibleTopDate, dateArg, - visibleTopDate, dateArg, visibleTopID, idArg, - visibleTopDate, dateArg, visibleTopID, idArg, peerArg)) - } else { - where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND %s < %s))", - visibleTopDate, dateArg, - visibleTopDate, dateArg, visibleTopID, idArg)) - } - case filter.OffsetID > 0: - args = append(args, filter.OffsetID) - idArg := fmt.Sprintf("$%d", len(args)) - if filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0 { - args = append(args, filter.OffsetPeer.ID) - peerArg := fmt.Sprintf("$%d", len(args)) - where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND c.id < %s))", - visibleTopID, idArg, visibleTopID, idArg, peerArg)) - } else { - where = append(where, fmt.Sprintf("%s < %s", visibleTopID, idArg)) - } - case filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0: - args = append(args, filter.OffsetPeer.ID) - where = append(where, fmt.Sprintf("c.id <> $%d", len(args))) - } - if filter.Folder != nil { - folder := filter.Folder - if folder.ExcludeArchived { - where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) <> %d", domain.DialogArchiveFolderID)) - } - if folder.ExcludeRead { - where = append(where, fmt.Sprintf(`(COALESCE(d.unread_mark, m.unread_mark) OR %s)`, - channelDialogHasUnreadSQL(visibleReadInbox, visibleTopID))) - } - if excludeIDs := channelFolderPeerIDs(folder.ExcludePeers); len(excludeIDs) > 0 { - args = append(args, excludeIDs) - where = append(where, fmt.Sprintf("NOT (c.id = ANY($%d::bigint[]))", len(args))) - } - includeIDs := channelFolderPeerIDs(folder.IncludePeers, folder.PinnedPeers) - include := make([]string, 0, 3) - if len(includeIDs) > 0 { - args = append(args, includeIDs) - include = append(include, fmt.Sprintf("c.id = ANY($%d::bigint[])", len(args))) - } - if folder.Groups { - include = append(include, "c.megagroup") - } - if folder.Broadcasts { - include = append(include, "c.broadcast") - } - if len(include) > 0 { - where = append(where, "("+strings.Join(include, " OR ")+")") - } - } - args = append(args, channelDialogQueryLimit) - limitArg := fmt.Sprintf("$%d", len(args)) - rows, err := s.db.Query(ctx, ` -SELECT `+channelColumns+`, - `+visibleTopID+`, - `+visibleTopDate+`, - COALESCE(d.folder_id, 0), `+visibleReadInbox+`, - COALESCE(d.read_outbox_max_id, m.read_outbox_max_id), `+visibleUnreadCount+`, - COALESCE(d.pinned, false), - COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark), - COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0), - COALESCE(d.view_forum_as_messages, false) -FROM channel_members m -JOIN channels c ON c.id = m.channel_id AND NOT c.deleted -LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted -LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id -WHERE `+strings.Join(where, " AND ")+` -ORDER BY COALESCE(d.pinned, false) DESC, - COALESCE(d.pinned_order, 0) DESC, - `+visibleTopDate+` DESC, - `+visibleTopID+` DESC, - c.id DESC -LIMIT `+limitArg, args...) - if err != nil { - return domain.ChannelDialogList{}, fmt.Errorf("list channel dialogs: %w", err) - } - defer rows.Close() - type item struct { - channel domain.Channel - dialog domain.Dialog - } - items := make([]item, 0, limit) - for rows.Next() { - ch, dialog, err := scanChannelDialogRow(rows, viewerUserID) - if err != nil { - return domain.ChannelDialogList{}, err - } - if !channelDialogMatchesFilter(dialog, ch, filter) { - continue - } - items = append(items, item{channel: ch, dialog: dialog}) - } - if err := rows.Err(); err != nil { - return domain.ChannelDialogList{}, err - } - sort.SliceStable(items, func(i, j int) bool { - if items[i].dialog.Pinned != items[j].dialog.Pinned { - return items[i].dialog.Pinned - } - if items[i].dialog.PinnedOrder != items[j].dialog.PinnedOrder { - return items[i].dialog.PinnedOrder > items[j].dialog.PinnedOrder - } - if items[i].dialog.TopMessageDate != items[j].dialog.TopMessageDate { - return items[i].dialog.TopMessageDate > items[j].dialog.TopMessageDate - } - if items[i].dialog.TopMessage != items[j].dialog.TopMessage { - return items[i].dialog.TopMessage > items[j].dialog.TopMessage - } - return items[i].dialog.Peer.ID > items[j].dialog.Peer.ID - }) - out := domain.ChannelDialogList{Count: len(items)} - if len(items) > limit { - items = items[:limit] - } - for _, item := range items { - msg, _ := s.getChannelMessage(ctx, s.db, item.channel.ID, item.dialog.TopMessage) - if msg.ID != 0 { - item.dialog.TopMessageDate = msg.Date - out.Messages = append(out.Messages, msg) - } - out.Dialogs = append(out.Dialogs, item.dialog) - out.Channels = append(out.Channels, item.channel) - } - return out, nil -} - -func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) { - out := domain.ChannelDialogList{} - seen := make(map[int64]struct{}, len(channelIDs)) - for _, channelID := range channelIDs { - if channelID == 0 { - continue - } - if _, ok := seen[channelID]; ok { - continue - } - seen[channelID] = struct{}{} - channel, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) - if err != nil { - if errors.Is(err, domain.ErrChannelInvalid) || errors.Is(err, domain.ErrChannelPrivate) { - continue - } - return domain.ChannelDialogList{}, err - } - dialog, err := s.getChannelDialog(ctx, s.db, viewerUserID, channel) - if err != nil { - return domain.ChannelDialogList{}, err - } - msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID) - if msg.ID != 0 { - dialog.TopMessageDate = msg.Date - out.Messages = append(out.Messages, msg) - } - out.Dialogs = append(out.Dialogs, channelDialogToDialog(dialog)) - out.Channels = append(out.Channels, channel) - } - out.Count = len(out.Dialogs) - return out, nil -} - -func (s *ChannelStore) ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { - if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 { - return domain.CommonChannelsResult{}, domain.ErrChannelInvalid - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxCommonChannelsLimit { - limit = domain.MaxCommonChannelsLimit - } - var count int - if err := s.db.QueryRow(ctx, ` -SELECT COUNT(*)::int -FROM user_channel_member_index selfm -JOIN user_channel_member_index targetm ON targetm.channel_id = selfm.channel_id -WHERE selfm.user_id = $1 - AND targetm.user_id = $2 - AND selfm.status = 'active' - AND targetm.status = 'active' - AND selfm.megagroup - AND NOT selfm.broadcast - AND NOT selfm.deleted - AND targetm.megagroup - AND NOT targetm.broadcast - AND NOT targetm.deleted`, req.UserID, req.TargetUserID).Scan(&count); err != nil { - return domain.CommonChannelsResult{}, fmt.Errorf("count common channels: %w", err) - } - out := domain.CommonChannelsResult{Count: count} - if req.CountOnly { - return out, nil - } - rows, err := s.db.Query(ctx, ` -SELECT selfm.channel_id -FROM user_channel_member_index selfm -JOIN user_channel_member_index targetm ON targetm.channel_id = selfm.channel_id -WHERE selfm.user_id = $1 - AND targetm.user_id = $2 - AND selfm.status = 'active' - AND targetm.status = 'active' - AND selfm.megagroup - AND NOT selfm.broadcast - AND NOT selfm.deleted - AND targetm.megagroup - AND NOT targetm.broadcast - AND NOT targetm.deleted - AND ($3::bigint = 0 OR selfm.channel_id > $3) -ORDER BY selfm.channel_id ASC -LIMIT $4`, req.UserID, req.TargetUserID, req.MaxID, limit) - if err != nil { - return domain.CommonChannelsResult{}, fmt.Errorf("list common channels: %w", err) - } - defer rows.Close() - ids := make([]int64, 0, limit) - for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { - return domain.CommonChannelsResult{}, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - return domain.CommonChannelsResult{}, err - } - channels, err := listChannelsByIDs(ctx, s.db, ids) - if err != nil { - return domain.CommonChannelsResult{}, err - } - out.Channels = channels - return out, nil -} - -func (s *ChannelStore) ListLeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) { - if userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset { - return domain.LeftChannelsResult{}, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxLeftChannelsLimit { - limit = domain.MaxLeftChannelsLimit - } - var count int - if err := s.db.QueryRow(ctx, ` -SELECT COUNT(*)::int -FROM channel_members m -JOIN channels c ON c.id = m.channel_id -WHERE m.user_id = $1 - AND m.status = 'left' - AND (c.broadcast OR c.megagroup) - AND NOT c.deleted`, userID).Scan(&count); err != nil { - return domain.LeftChannelsResult{}, fmt.Errorf("count left channels: %w", err) - } - rows, err := s.db.Query(ctx, ` -SELECT `+channelColumns+`, - m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at, - m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts, - m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date -FROM channel_members m -JOIN channels c ON c.id = m.channel_id -WHERE m.user_id = $1 - AND m.status = 'left' - AND (c.broadcast OR c.megagroup) - AND NOT c.deleted -ORDER BY m.left_at DESC, c.id DESC -OFFSET $2 -LIMIT $3`, userID, offset, limit) - if err != nil { - return domain.LeftChannelsResult{}, fmt.Errorf("list left channels: %w", err) - } - defer rows.Close() - out := domain.LeftChannelsResult{Count: count, Channels: make([]domain.LeftChannel, 0, limit)} - for rows.Next() { - ch, member, err := scanChannelWithMember(rows) - if err != nil { - return domain.LeftChannelsResult{}, err - } - out.Channels = append(out.Channels, domain.LeftChannel{Channel: ch, Self: member}) - } - return out, rows.Err() -} - -func (s *ChannelStore) ListInactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) { - if userID == 0 { - return domain.ChannelDialogList{}, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxInactiveChannelsLimit { - limit = domain.MaxInactiveChannelsLimit - } - visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" - visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE GREATEST(c.date, m.joined_at) END" - visibleReadInbox := "COALESCE(d.read_inbox_max_id, m.read_inbox_max_id)" - visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) - rows, err := s.db.Query(ctx, ` -SELECT `+channelColumns+`, - `+visibleTopID+`, - `+visibleTopDate+`, - COALESCE(d.folder_id, 0), `+visibleReadInbox+`, - COALESCE(d.read_outbox_max_id, m.read_outbox_max_id), `+visibleUnreadCount+`, - COALESCE(d.pinned, false), - COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark), - COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0), - COALESCE(d.view_forum_as_messages, false) -FROM channel_members m -JOIN channels c ON c.id = m.channel_id AND NOT c.deleted -LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted -LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id -WHERE m.user_id = $1 - AND m.status = 'active' - AND (c.broadcast OR c.megagroup) -ORDER BY `+visibleTopDate+` ASC, - `+visibleTopID+` ASC, - c.id ASC -LIMIT $2`, userID, limit) - if err != nil { - return domain.ChannelDialogList{}, fmt.Errorf("list inactive channels: %w", err) - } - defer rows.Close() - out := domain.ChannelDialogList{Dialogs: make([]domain.Dialog, 0, limit), Channels: make([]domain.Channel, 0, limit)} - for rows.Next() { - ch, dialog, err := scanChannelDialogRow(rows, userID) - if err != nil { - return domain.ChannelDialogList{}, err - } - out.Dialogs = append(out.Dialogs, dialog) - out.Channels = append(out.Channels, ch) - } - if err := rows.Err(); err != nil { - return domain.ChannelDialogList{}, err - } - out.Count = len(out.Dialogs) - return out, nil -} - -func (s *ChannelStore) ListChannelRecommendations(ctx context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) { - if req.UserID == 0 || req.SourceChannelID < 0 { - return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelRecommendationsLimit { - limit = domain.DefaultChannelRecommendationsLimit - } - args := []any{req.UserID, req.SourceChannelID} - where := []string{ - "($1::bigint <> 0)", - "c.broadcast", - "NOT c.megagroup", - "NOT c.deleted", - "COALESCE(c.username, '') <> ''", - "($2::bigint = 0 OR c.id <> $2)", - } - if req.SourceChannelID == 0 { - where = append(where, `NOT EXISTS ( - SELECT 1 - FROM channel_members m - WHERE m.channel_id = c.id - AND m.user_id = $1 - AND m.status = 'active' -)`) - } - whereSQL := strings.Join(where, " AND ") - var count int - if err := s.db.QueryRow(ctx, ` -SELECT COUNT(*)::int -FROM channels c -WHERE `+whereSQL, args...).Scan(&count); err != nil { - return domain.ChannelRecommendationsResult{}, fmt.Errorf("count channel recommendations: %w", err) - } - args = append(args, limit) - rows, err := s.db.Query(ctx, ` -SELECT `+channelColumns+` -FROM channels c -WHERE `+whereSQL+` -ORDER BY c.participants_count DESC, c.date DESC, c.id DESC -LIMIT $3`, args...) - if err != nil { - return domain.ChannelRecommendationsResult{}, fmt.Errorf("list channel recommendations: %w", err) - } - defer rows.Close() - out := domain.ChannelRecommendationsResult{Count: count, Channels: make([]domain.Channel, 0, limit)} - for rows.Next() { - ch, err := scanChannel(rows) - if err != nil { - return domain.ChannelRecommendationsResult{}, err - } - out.Channels = append(out.Channels, ch) - } - if err := rows.Err(); err != nil { - return domain.ChannelRecommendationsResult{}, err - } - return out, nil -} - -func (s *ChannelStore) ListDiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) { - if userID == 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxDiscussionGroupsLimit { - limit = domain.MaxDiscussionGroupsLimit - } - rows, err := s.db.Query(ctx, ` -SELECT `+channelColumns+` -FROM channel_members m -JOIN channels c ON c.id = m.channel_id -WHERE m.user_id = $1 - AND m.status = 'active' - AND c.megagroup - AND NOT c.broadcast - AND NOT c.forum - AND NOT c.deleted - AND ( - m.role = 'creator' - OR (m.role = 'admin' AND COALESCE((m.admin_rights->>'PinMessages')::boolean, false)) - ) -ORDER BY c.id DESC -LIMIT $2`, userID, limit) - if err != nil { - return nil, fmt.Errorf("list discussion groups: %w", err) - } - defer rows.Close() - out := make([]domain.Channel, 0, limit) - for rows.Next() { - ch, err := scanChannel(rows) - if err != nil { - return nil, err - } - out = append(out, ch) - } - return out, rows.Err() -} - -func (s *ChannelStore) SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) { - if userID == 0 { - return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid - } - if broadcastID == 0 && groupID == 0 { - return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("set discussion group: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("begin set discussion group: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - - changed := make(map[int64]domain.Channel) - markChanged := func(channel domain.Channel) { - if channel.ID != 0 { - changed[channel.ID] = channel - } - } - setLinked := func(channel domain.Channel, linkedID int64) (domain.Channel, error) { - if channel.LinkedChatID == linkedID { - return channel, nil - } - if _, err := tx.Exec(ctx, `UPDATE channels SET linked_chat_id = $2, updated_at = now() WHERE id = $1`, channel.ID, linkedID); err != nil { - return domain.Channel{}, fmt.Errorf("update linked chat: %w", err) - } - channel.LinkedChatID = linkedID - markChanged(channel) - return channel, nil - } - logLinkChange := func(channelID, prev, next int64) error { - if prev == next || channelID == 0 { - return nil - } - return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channelID, - UserID: userID, - Date: nowUnix(), - Type: domain.ChannelAdminLogChangeLinkedChat, - PrevInt: int(prev), - NewInt: int(next), - }) - } - - if broadcastID == 0 { - group, groupMember, err := s.getChannelForMember(ctx, tx, userID, groupID) - if err != nil || !validDiscussionGroup(group) { - return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid - } - if !canManageDiscussionGroup(groupMember) { - return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired - } - oldBroadcastID := group.LinkedChatID - if oldBroadcastID == 0 { - return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified - } - oldBroadcast, err := getChannelByID(ctx, tx, oldBroadcastID) - if err == nil && oldBroadcast.LinkedChatID == groupID { - updated, err := setLinked(oldBroadcast, 0) - if err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - if err := logLinkChange(updated.ID, groupID, 0); err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { - return domain.DiscussionGroupUpdateResult{}, err - } - if _, err := setLinked(group, 0); err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) - } - committed = true - return discussionGroupUpdateResult(changed), nil - } - - broadcast, broadcastMember, err := s.getChannelForMember(ctx, tx, userID, broadcastID) - if err != nil || !broadcast.Broadcast || broadcast.Megagroup { - return domain.DiscussionGroupUpdateResult{}, domain.ErrBroadcastIDInvalid - } - if !canManageDiscussionBroadcast(broadcastMember) { - return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired - } - oldGroupID := broadcast.LinkedChatID - if groupID == 0 { - if oldGroupID == 0 { - return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified - } - updated, err := setLinked(broadcast, 0) - if err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - if err := logLinkChange(updated.ID, oldGroupID, 0); err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - oldGroup, err := getChannelByID(ctx, tx, oldGroupID) - if err == nil && oldGroup.LinkedChatID == broadcastID { - if _, err := setLinked(oldGroup, 0); err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { - return domain.DiscussionGroupUpdateResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) - } - committed = true - return discussionGroupUpdateResult(changed), nil - } - - group, groupMember, err := s.getChannelForMember(ctx, tx, userID, groupID) - if err != nil || !validDiscussionGroup(group) { - return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid - } - if group.PreHistoryHidden { - return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupPrehistoryHidden - } - if !canManageDiscussionGroup(groupMember) { - return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired - } - if oldGroupID == groupID && group.LinkedChatID == broadcastID { - return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified - } - oldBroadcastID := group.LinkedChatID - if oldGroupID != 0 && oldGroupID != groupID { - oldGroup, err := getChannelByID(ctx, tx, oldGroupID) - if err == nil && oldGroup.LinkedChatID == broadcastID { - if _, err := setLinked(oldGroup, 0); err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { - return domain.DiscussionGroupUpdateResult{}, err - } - } - if oldBroadcastID != 0 && oldBroadcastID != broadcastID { - oldBroadcast, err := getChannelByID(ctx, tx, oldBroadcastID) - if err == nil && oldBroadcast.LinkedChatID == groupID { - updated, err := setLinked(oldBroadcast, 0) - if err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - if err := logLinkChange(updated.ID, groupID, 0); err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { - return domain.DiscussionGroupUpdateResult{}, err - } - } - updatedBroadcast, err := setLinked(broadcast, groupID) - if err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - if _, err := setLinked(group, broadcastID); err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - if err := logLinkChange(updatedBroadcast.ID, oldGroupID, groupID); err != nil { - return domain.DiscussionGroupUpdateResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) - } - committed = true - return discussionGroupUpdateResult(changed), nil -} - -func (s *ChannelStore) SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, error) { - if userID == 0 || channelID == 0 { - return false, nil - } - var changed bool - if err := s.db.QueryRow(ctx, ` -WITH target AS ( - SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date - FROM channels c - JOIN channel_members m ON m.channel_id = c.id - WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted -), -ensured AS ( - INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date) - SELECT $1, channel_id, top_message_id, top_message_date FROM target - ON CONFLICT (user_id, channel_id) DO NOTHING -), -next_order AS ( - SELECT COALESCE(MAX(pinned_order), 0)::int + 1 AS value - FROM channel_dialogs - WHERE user_id = $1 AND pinned -), -updated AS ( - UPDATE channel_dialogs d - SET pinned = $3, - pinned_order = CASE - WHEN $3::boolean THEN CASE WHEN d.pinned_order > 0 THEN d.pinned_order ELSE next_order.value END - ELSE 0 - END, - updated_at = now() - FROM next_order - WHERE d.user_id = $1 AND d.channel_id = $2 - AND EXISTS (SELECT 1 FROM target) - AND (d.pinned IS DISTINCT FROM $3::boolean OR ($3::boolean AND d.pinned_order = 0)) - RETURNING d.user_id -) -SELECT EXISTS (SELECT 1 FROM updated)::boolean`, userID, channelID, pinned).Scan(&changed); err != nil { - return false, fmt.Errorf("set channel dialog pinned: %w", err) - } - return changed, nil -} - -func (s *ChannelStore) ReorderChannelPinnedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error { - if userID == 0 { - return nil - } - peerTypes, peerIDs := peerArrays(order) - if force { - if _, err := s.db.Exec(ctx, ` -WITH requested AS ( - SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS peer_id - FROM generate_subscripts($3::bigint[], 1) AS g(i) - WHERE i <= cardinality($2::text[]) -) -UPDATE channel_dialogs d -SET pinned = false, pinned_order = 0, updated_at = now() -WHERE d.user_id = $1 - AND d.pinned - AND NOT EXISTS ( - SELECT 1 FROM requested r - WHERE r.peer_type = 'channel' AND r.peer_id = d.channel_id - )`, userID, peerTypes, peerIDs); err != nil { - return fmt.Errorf("clear channel pinned dialogs not in order: %w", err) - } - } - if len(peerIDs) == 0 { - return nil - } - if _, err := s.db.Exec(ctx, ` -WITH requested AS ( - SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS peer_id, i::int AS pos - FROM generate_subscripts($3::bigint[], 1) AS g(i) - WHERE i <= cardinality($2::text[]) -), -deduped AS ( - SELECT DISTINCT ON (peer_id) peer_id, (cardinality($3::bigint[]) - pos + 1)::int AS ord - FROM requested - WHERE peer_type = 'channel' - ORDER BY peer_id, pos -) -UPDATE channel_dialogs d -SET pinned = true, pinned_order = deduped.ord, updated_at = now() -FROM deduped -WHERE d.user_id = $1 AND d.channel_id = deduped.peer_id`, userID, peerTypes, peerIDs); err != nil { - return fmt.Errorf("reorder channel pinned dialogs: %w", err) - } - return nil -} - -func (s *ChannelStore) SetChannelDialogUnreadMark(ctx context.Context, userID, channelID int64, unread bool) (bool, error) { - if userID == 0 || channelID == 0 { - return false, nil - } - var changed bool - if err := s.db.QueryRow(ctx, ` -WITH target AS ( - SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date - FROM channels c - JOIN channel_members m ON m.channel_id = c.id - WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted -), -ensured AS ( - INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date) - SELECT $1, channel_id, top_message_id, top_message_date FROM target - ON CONFLICT (user_id, channel_id) DO NOTHING -), -updated_dialog AS ( - UPDATE channel_dialogs d - SET unread_mark = $3, updated_at = now() - WHERE d.user_id = $1 AND d.channel_id = $2 - AND EXISTS (SELECT 1 FROM target) - AND d.unread_mark IS DISTINCT FROM $3::boolean - RETURNING d.user_id -), -updated_member AS ( - UPDATE channel_members m - SET unread_mark = $3 - WHERE m.user_id = $1 AND m.channel_id = $2 AND m.status = 'active' - RETURNING m.user_id -) -SELECT EXISTS (SELECT 1 FROM updated_dialog)::boolean`, userID, channelID, unread).Scan(&changed); err != nil { - return false, fmt.Errorf("set channel dialog unread mark: %w", err) - } - return changed, nil -} - -func (s *ChannelStore) SetChannelViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) { - if userID == 0 || channelID == 0 { - return false, nil - } - var changed bool - if err := s.db.QueryRow(ctx, ` -WITH target AS ( - SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date - FROM channels c - JOIN channel_members m ON m.channel_id = c.id - WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted -), -ensured AS ( - INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date) - SELECT $1, channel_id, top_message_id, top_message_date FROM target - ON CONFLICT (user_id, channel_id) DO NOTHING -), -updated_dialog AS ( - UPDATE channel_dialogs d - SET view_forum_as_messages = $3, updated_at = now() - WHERE d.user_id = $1 AND d.channel_id = $2 - AND EXISTS (SELECT 1 FROM target) - AND d.view_forum_as_messages IS DISTINCT FROM $3::boolean - RETURNING d.user_id -) -SELECT EXISTS (SELECT 1 FROM updated_dialog)::boolean`, userID, channelID, enabled).Scan(&changed); err != nil { - return false, fmt.Errorf("set channel view forum as messages: %w", err) - } - return changed, nil -} - -func (s *ChannelStore) ListChannelUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) { - if userID == 0 { - return nil, nil - } - rows, err := s.db.Query(ctx, ` -SELECT d.channel_id -FROM channel_dialogs d -JOIN channel_members m ON m.channel_id = d.channel_id AND m.user_id = d.user_id AND m.status = 'active' -JOIN channels c ON c.id = d.channel_id AND NOT c.deleted -WHERE d.user_id = $1 AND d.unread_mark -ORDER BY d.top_message_date DESC, d.top_message_id DESC, d.channel_id DESC -LIMIT 500`, userID) - if err != nil { - return nil, fmt.Errorf("list channel unread marks: %w", err) - } - defer rows.Close() - out := make([]domain.Peer, 0) - for rows.Next() { - var channelID int64 - if err := rows.Scan(&channelID); err != nil { - return nil, err - } - out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func (s *ChannelStore) EditChannelPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error { - if userID == 0 || len(peers) == 0 { - return nil - } - peerTypes := make([]string, 0, len(peers)) - peerIDs := make([]int64, 0, len(peers)) - folderIDs := make([]int32, 0, len(peers)) - seen := make(map[int64]struct{}, len(peers)) - for _, item := range peers { - if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { - continue - } - if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { - continue - } - if _, ok := seen[item.Peer.ID]; ok { - continue - } - seen[item.Peer.ID] = struct{}{} - peerTypes = append(peerTypes, string(item.Peer.Type)) - peerIDs = append(peerIDs, item.Peer.ID) - folderIDs = append(folderIDs, int32(item.FolderID)) - } - if len(peerIDs) == 0 { - return nil - } - if _, err := s.db.Exec(ctx, ` -WITH requested AS ( - SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS channel_id, ($4::int[])[i] AS folder_id - FROM generate_subscripts($3::bigint[], 1) AS g(i) - WHERE i <= cardinality($2::text[]) AND i <= cardinality($4::int[]) -), -deduped AS ( - SELECT DISTINCT ON (channel_id) channel_id, folder_id - FROM requested - WHERE peer_type = 'channel' AND folder_id IN (0, 1) - ORDER BY channel_id -) -UPDATE channel_dialogs d -SET folder_id = deduped.folder_id, updated_at = now() -FROM deduped -WHERE d.user_id = $1 - AND d.channel_id = deduped.channel_id - AND EXISTS ( - SELECT 1 FROM channel_members m - WHERE m.user_id = d.user_id AND m.channel_id = d.channel_id AND m.status = 'active' - )`, userID, peerTypes, peerIDs, folderIDs); err != nil { - return fmt.Errorf("edit channel peer folders: %w", err) - } - return nil -} - -func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) { - channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, filter.ChannelID) - if err != nil { - return domain.ChannelHistory{}, err - } - limit := filter.Limit - if limit <= 0 || limit > 100 { - limit = 100 - } - // 公共过滤条件(不含 offset 锚点的方向条件,供 add_offset 各模式复用) - baseArgs := []any{filter.ChannelID} - base := "channel_id = $1 AND NOT deleted" - if member.AvailableMinID > 0 { - baseArgs = append(baseArgs, member.AvailableMinID) - base += fmt.Sprintf(" AND id > $%d", len(baseArgs)) - } - if filter.PinnedOnly { - if channel.PinnedMessageID <= 0 { - return domain.ChannelHistory{Channel: channel, Self: member}, nil - } - baseArgs = append(baseArgs, channel.PinnedMessageID) - base += fmt.Sprintf(" AND id = $%d", len(baseArgs)) - } - if filter.Query != "" { - baseArgs = append(baseArgs, filter.Query) - base += fmt.Sprintf(" AND body ILIKE '%%' || $%d || '%%'", len(baseArgs)) - } - if filter.SenderUserID != 0 { - baseArgs = append(baseArgs, filter.SenderUserID) - base += fmt.Sprintf(" AND sender_user_id = $%d", len(baseArgs)) - } - if filter.MinDate > 0 { - baseArgs = append(baseArgs, filter.MinDate) - base += fmt.Sprintf(" AND message_date > $%d", len(baseArgs)) - } - if filter.MaxDate > 0 { - baseArgs = append(baseArgs, filter.MaxDate) - base += fmt.Sprintf(" AND message_date < $%d", len(baseArgs)) - } - if filter.MaxID > 0 { - baseArgs = append(baseArgs, filter.MaxID) - base += fmt.Sprintf(" AND id <= $%d", len(baseArgs)) - } - if filter.MinID > 0 { - baseArgs = append(baseArgs, filter.MinID) - base += fmt.Sprintf(" AND id > $%d", len(baseArgs)) - } - scanList := func(sql string, queryArgs []any) ([]domain.ChannelMessage, error) { - rows, err := s.db.Query(ctx, sql, queryArgs...) - if err != nil { - return nil, fmt.Errorf("list channel history: %w", err) - } - defer rows.Close() - var list []domain.ChannelMessage - for rows.Next() { - msg, scanErr := scanChannelMessage(rows) - if scanErr != nil { - return nil, scanErr - } - list = append(list, msg) - } - return list, rows.Err() - } - // add_offset 决定加载方向(对齐私聊 ListMessagesByUser): - // >= 0 backward:锚点更旧方向,先跳过 add_offset 条 - // < 0 且 +limit>0 around:以锚点为中心,向更新取 -add_offset 条 + 向更旧取 limit+add_offset 条 - // 否则 forward:仅锚点更新方向(拉未读消息) - addOffset := filter.AddOffset - out := domain.ChannelHistory{Channel: channel, Self: member} - hasMoreOlder := false - // 锚点条件:offset_date 优先按日期、否则按消息 id(对齐私聊/orange); - // 二者皆空时向更新方向退化为空、向更旧方向退化为全部(取最新)。 - forwardCond := func(args *[]any) string { - if filter.OffsetDate > 0 { - *args = append(*args, filter.OffsetDate) - return fmt.Sprintf("message_date >= $%d", len(*args)) - } - if filter.OffsetID > 0 { - *args = append(*args, filter.OffsetID) - return fmt.Sprintf("id > $%d", len(*args)) - } - return "false" - } - aroundOlderCond := func(args *[]any) string { - if filter.OffsetDate > 0 { - *args = append(*args, filter.OffsetDate) - return fmt.Sprintf("message_date < $%d", len(*args)) - } - if filter.OffsetID > 0 { - *args = append(*args, filter.OffsetID) - return fmt.Sprintf("id <= $%d", len(*args)) - } - return "true" - } - switch { - case addOffset < 0 && addOffset+limit > 0: - // around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条 - fwdLimit := minInt(-addOffset, limit) - bwdLimit := maxInt(limit+addOffset, 0) - fwdArgs := append([]any{}, baseArgs...) - fwdWhere := forwardCond(&fwdArgs) - fwdArgs = append(fwdArgs, fwdLimit) - newer, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id ASC LIMIT $%d", base, fwdWhere, len(fwdArgs)), fwdArgs) - if err != nil { - return domain.ChannelHistory{}, err - } - bwdArgs := append([]any{}, baseArgs...) - bwdWhere := aroundOlderCond(&bwdArgs) - bwdArgs = append(bwdArgs, bwdLimit+1) - older, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id DESC LIMIT $%d", base, bwdWhere, len(bwdArgs)), bwdArgs) - if err != nil { - return domain.ChannelHistory{}, err - } - if len(older) > bwdLimit { - older = older[:bwdLimit] - hasMoreOlder = true - } - for i := len(newer) - 1; i >= 0; i-- { - out.Messages = append(out.Messages, newer[i]) - } - out.Messages = append(out.Messages, older...) - case addOffset < 0: - // forward:仅锚点更新方向(拉未读/更新消息) - fwdArgs := append([]any{}, baseArgs...) - fwdWhere := forwardCond(&fwdArgs) - fwdArgs = append(fwdArgs, limit+1) - newer, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id ASC LIMIT $%d", base, fwdWhere, len(fwdArgs)), fwdArgs) - if err != nil { - return domain.ChannelHistory{}, err - } - if len(newer) > limit { - newer = newer[:limit] - } - for i := len(newer) - 1; i >= 0; i-- { - out.Messages = append(out.Messages, newer[i]) - } - default: - // backward:锚点更旧方向(不含锚点),先跳过 add_offset 条 - where := base - args := append([]any{}, baseArgs...) - if filter.OffsetDate > 0 { - args = append(args, filter.OffsetDate) - where += fmt.Sprintf(" AND message_date < $%d", len(args)) - } else if filter.OffsetID > 0 { - args = append(args, filter.OffsetID) - where += fmt.Sprintf(" AND id < $%d", len(args)) - } - args = append(args, limit+1) - limIdx := len(args) - sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where + " ORDER BY id DESC" - if addOffset > 0 { - args = append(args, addOffset) - sql += fmt.Sprintf(" OFFSET $%d", len(args)) - } - sql += fmt.Sprintf(" LIMIT $%d", limIdx) - older, err := scanList(sql, args) - if err != nil { - return domain.ChannelHistory{}, err - } - if len(older) > limit { - older = older[:limit] - hasMoreOlder = true - } - out.Messages = older - } - markPinnedChannelMessages(channel, out.Messages) - out.Count = len(out.Messages) - if hasMoreOlder { - out.Count = len(out.Messages) + 1 - } - if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil { - return domain.ChannelHistory{}, err - } - if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil { - return domain.ChannelHistory{}, err - } - return out, nil -} - -func (s *ChannelStore) SearchPublicPosts(ctx context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) { - query := strings.TrimSpace(req.Query) - hashtag := strings.TrimSpace(req.Hashtag) - if (query == "") == (hashtag == "") { - return domain.ChannelHistory{}, domain.ErrChannelInvalid - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelSearchPostsLimit { - limit = domain.MaxChannelSearchPostsLimit - } - args := []any{} - where := `NOT deleted -AND body <> '' -AND EXISTS ( - SELECT 1 - FROM channels c - WHERE c.id = channel_messages.channel_id - AND NOT c.deleted - AND COALESCE(c.username, '') <> '' - )` - if query != "" { - args = append(args, "%"+escapeLike(query)+"%") - where += fmt.Sprintf(" AND body ILIKE $%d ESCAPE '\\'", len(args)) - } - if hashtag != "" { - args = append(args, "%#"+escapeLike(hashtag)+"%") - where += fmt.Sprintf(" AND body ILIKE $%d ESCAPE '\\'", len(args)) - } - switch { - case req.OffsetRate > 0 && req.OffsetChannelID > 0 && req.OffsetID > 0: - args = append(args, req.OffsetRate, req.OffsetChannelID, req.OffsetID) - n := len(args) - where += fmt.Sprintf(" AND (message_date < $%d OR (message_date = $%d AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))))", n-2, n-2, n-1, n-1, n) - case req.OffsetRate > 0: - args = append(args, req.OffsetRate) - where += fmt.Sprintf(" AND message_date < $%d", len(args)) - case req.OffsetChannelID > 0 && req.OffsetID > 0: - args = append(args, req.OffsetChannelID, req.OffsetID) - n := len(args) - where += fmt.Sprintf(" AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))", n-1, n-1, n) - case req.OffsetID > 0: - args = append(args, req.OffsetID) - where += fmt.Sprintf(" AND id < $%d", len(args)) - } - queryLimit := limit + 1 - args = append(args, queryLimit) - rows, err := s.db.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE `+where+` -ORDER BY message_date DESC, channel_id DESC, id DESC -LIMIT $`+fmt.Sprint(len(args)), args...) - if err != nil { - return domain.ChannelHistory{}, fmt.Errorf("search public channel posts: %w", err) - } - defer rows.Close() - out := domain.ChannelHistory{} - channelRefs := make(map[int64]struct{}) - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - return domain.ChannelHistory{}, err - } - out.Messages = append(out.Messages, msg) - channelRefs[msg.ChannelID] = struct{}{} - } - if err := rows.Err(); err != nil { - return domain.ChannelHistory{}, err - } - if len(out.Messages) > limit { - out.Messages = out.Messages[:limit] - out.Count = limit + 1 - channelRefs = make(map[int64]struct{}, len(out.Messages)) - for _, msg := range out.Messages { - channelRefs[msg.ChannelID] = struct{}{} - } - } else { - out.Count = len(out.Messages) - } - channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) - if err != nil { - return domain.ChannelHistory{}, err - } - out.Channels = channels - if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { - return domain.ChannelHistory{}, err - } - return out, nil -} - -func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) { - query := strings.TrimSpace(req.Query) - if viewerUserID == 0 || query == "" { - return domain.ChannelHistory{}, domain.ErrChannelInvalid - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit { - limit = domain.MaxChannelGlobalSearchLimit - } - args := []any{viewerUserID, "%" + escapeLike(query) + "%"} - where := `NOT deleted -AND body <> '' -AND body ILIKE $2 ESCAPE '\' -AND EXISTS ( - SELECT 1 - FROM channels c - JOIN channel_members cm ON cm.channel_id = c.id - AND cm.user_id = $1 - AND cm.status = 'active' - AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false) - LEFT JOIN channel_dialogs d ON d.channel_id = c.id AND d.user_id = $1 - WHERE c.id = channel_messages.channel_id - AND NOT c.deleted - AND (cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)` - if req.BroadcastsOnly { - where += ` - AND c.broadcast AND NOT c.megagroup` - } - if req.GroupsOnly { - where += ` - AND c.megagroup` - } - if req.HasFolderID { - args = append(args, req.FolderID) - where += fmt.Sprintf(` - AND d.folder_id = $%d`, len(args)) - } - where += ` -)` - if req.MinDate > 0 { - args = append(args, req.MinDate) - where += fmt.Sprintf(" AND message_date > $%d", len(args)) - } - if req.MaxDate > 0 { - args = append(args, req.MaxDate) - where += fmt.Sprintf(" AND message_date < $%d", len(args)) - } - switch { - case req.OffsetRate > 0 && req.OffsetChannelID > 0 && req.OffsetID > 0: - args = append(args, req.OffsetRate, req.OffsetChannelID, req.OffsetID) - n := len(args) - where += fmt.Sprintf(" AND (message_date < $%d OR (message_date = $%d AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))))", n-2, n-2, n-1, n-1, n) - case req.OffsetRate > 0: - args = append(args, req.OffsetRate) - where += fmt.Sprintf(" AND message_date < $%d", len(args)) - case req.OffsetChannelID > 0 && req.OffsetID > 0: - args = append(args, req.OffsetChannelID, req.OffsetID) - n := len(args) - where += fmt.Sprintf(" AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))", n-1, n-1, n) - case req.OffsetID > 0: - args = append(args, req.OffsetID) - where += fmt.Sprintf(" AND id < $%d", len(args)) - } - queryLimit := limit + 1 - args = append(args, queryLimit) - rows, err := s.db.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE `+where+` -ORDER BY message_date DESC, channel_id DESC, id DESC -LIMIT $`+fmt.Sprint(len(args)), args...) - if err != nil { - return domain.ChannelHistory{}, fmt.Errorf("search joined channel messages: %w", err) - } - defer rows.Close() - out := domain.ChannelHistory{} - channelRefs := make(map[int64]struct{}) - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - return domain.ChannelHistory{}, err - } - out.Messages = append(out.Messages, msg) - channelRefs[msg.ChannelID] = struct{}{} - } - if err := rows.Err(); err != nil { - return domain.ChannelHistory{}, err - } - if len(out.Messages) > limit { - out.Messages = out.Messages[:limit] - out.Count = limit + 1 - channelRefs = make(map[int64]struct{}, len(out.Messages)) - for _, msg := range out.Messages { - channelRefs[msg.ChannelID] = struct{}{} - } - } else { - out.Count = len(out.Messages) - } - channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) - if err != nil { - return domain.ChannelHistory{}, err - } - out.Channels = channels - if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { - return domain.ChannelHistory{}, err - } - return out, nil -} - -func (s *ChannelStore) GetChannelMessages(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) { - channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) - if err != nil { - return domain.ChannelHistory{}, err - } - if len(ids) == 0 { - return domain.ChannelHistory{Channel: channel, Self: member}, nil - } - if len(ids) > domain.MaxGetMessageIDs { - return domain.ChannelHistory{}, domain.ErrChannelInvalid - } - id32, _, err := validUniqueChannelMessageIDs(ids) - if err != nil { - return domain.ChannelHistory{}, err - } - args := []any{channelID, id32} - where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" - if member.AvailableMinID > 0 { - args = append(args, member.AvailableMinID) - where += fmt.Sprintf(" AND id > $%d", len(args)) - } - rows, err := s.db.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE `+where+` -ORDER BY id DESC`, args...) - if err != nil { - return domain.ChannelHistory{}, fmt.Errorf("get channel messages by ids: %w", err) - } - defer rows.Close() - out := domain.ChannelHistory{Channel: channel, Self: member} - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - return domain.ChannelHistory{}, err - } - out.Messages = append(out.Messages, msg) - } - if err := rows.Err(); err != nil { - return domain.ChannelHistory{}, err - } - markPinnedChannelMessages(channel, out.Messages) - out.Count = len(out.Messages) - if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil { - return domain.ChannelHistory{}, err - } - if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil { - return domain.ChannelHistory{}, err - } - return out, nil -} - -func markPinnedChannelMessages(channel domain.Channel, messages []domain.ChannelMessage) { - if channel.PinnedMessageID <= 0 { - return - } - for i := range messages { - if messages[i].ChannelID == channel.ID && messages[i].ID == channel.PinnedMessageID { - messages[i].Pinned = true - } - } -} - -func (s *ChannelStore) ReadChannelMessageContents(ctx context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("read channel message contents: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("begin read channel message contents: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.ReadChannelMessageContentsResult{}, err - } - if len(req.IDs) == 0 { - if err := tx.Commit(ctx); err != nil { - return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("commit read channel message contents: %w", err) - } - committed = true - return domain.ReadChannelMessageContentsResult{Channel: channel}, nil - } - if len(req.IDs) > domain.MaxGetMessageIDs { - return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid - } - id32, _, err := validUniqueChannelMessageIDs(req.IDs) - if err != nil { - return domain.ReadChannelMessageContentsResult{}, err - } - args := []any{req.ChannelID, id32} - where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" - if member.AvailableMinID > 0 { - args = append(args, member.AvailableMinID) - where += fmt.Sprintf(" AND id > $%d", len(args)) - } - rows, err := tx.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE `+where+` -ORDER BY id DESC`, args...) - if err != nil { - return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("read channel messages by ids: %w", err) - } - messages := make([]domain.ChannelMessage, 0, len(id32)) - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - rows.Close() - return domain.ReadChannelMessageContentsResult{}, err - } - messages = append(messages, msg) - } - if err := rows.Err(); err != nil { - rows.Close() - return domain.ReadChannelMessageContentsResult{}, err - } - rows.Close() - visibleIDs := make([]int32, 0, len(messages)) - for _, msg := range messages { - visibleIDs = append(visibleIDs, int32(msg.ID)) - } - cleared, err := clearChannelUnreadReactionsForMessageIDsTx(ctx, tx, req.UserID, req.ChannelID, visibleIDs) - if err != nil { - return domain.ReadChannelMessageContentsResult{}, err - } - if err := s.populateChannelMessageReplies(ctx, tx, req.UserID, channel, messages); err != nil { - return domain.ReadChannelMessageContentsResult{}, err - } - if err := s.populateChannelMessagesReactions(ctx, tx, req.UserID, []domain.Channel{channel}, messages); err != nil { - return domain.ReadChannelMessageContentsResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("commit read channel message contents: %w", err) - } - committed = true - return domain.ReadChannelMessageContentsResult{ - Channel: channel, - Messages: messages, - ClearedUnreadReactionMessageIDs: cleared, - }, nil -} - -func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid - } - _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelMessageViewsResult{}, err - } - if len(req.IDs) == 0 { - return domain.ChannelMessageViewsResult{Views: map[int]int{}}, nil - } - if len(req.IDs) > domain.MaxGetMessageIDs { - return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid - } - id32, _, err := validUniqueChannelMessageIDs(req.IDs) - if err != nil { - return domain.ChannelMessageViewsResult{}, err - } - if req.Increment { - date := req.Date - if date <= 0 { - date = nowUnix() - } - rows, err := s.db.Query(ctx, ` -WITH inserted AS ( - INSERT INTO channel_message_viewers (channel_id, message_id, viewer_user_id, viewed_at) - SELECT m.channel_id, m.id, $3, $4 - FROM channel_messages m - WHERE m.channel_id = $1 - AND m.id = ANY($2::int[]) - AND NOT m.deleted - AND m.id > $5 - ON CONFLICT DO NOTHING - RETURNING message_id -), updated AS ( - UPDATE channel_messages m - SET views_count = views_count + 1, - updated_at = now() - FROM inserted i - WHERE m.channel_id = $1 - AND m.id = i.message_id - RETURNING m.id -) -SELECT i.message_id -FROM inserted i -LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, date, member.AvailableMinID) - if err != nil { - return domain.ChannelMessageViewsResult{}, fmt.Errorf("increment channel message views: %w", err) - } - for rows.Next() { - var ignored int - if err := rows.Scan(&ignored); err != nil { - rows.Close() - return domain.ChannelMessageViewsResult{}, err - } - } - if err := rows.Err(); err != nil { - rows.Close() - return domain.ChannelMessageViewsResult{}, err - } - rows.Close() - } - args := []any{req.ChannelID, id32} - where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" - if member.AvailableMinID > 0 { - args = append(args, member.AvailableMinID) - where += fmt.Sprintf(" AND id > $%d", len(args)) - } - rows, err := s.db.Query(ctx, ` -SELECT id, views_count -FROM channel_messages -WHERE `+where, args...) - if err != nil { - return domain.ChannelMessageViewsResult{}, fmt.Errorf("get channel message views: %w", err) - } - defer rows.Close() - out := make(map[int]int, len(req.IDs)) - for rows.Next() { - var id int - var views int - if err := rows.Scan(&id, &views); err != nil { - return domain.ChannelMessageViewsResult{}, err - } - out[id] = views - } - if err := rows.Err(); err != nil { - return domain.ChannelMessageViewsResult{}, err - } - return domain.ChannelMessageViewsResult{Views: out}, nil -} - -func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - for _, reaction := range req.Reactions { - if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" || len(reaction.Emoticon) > domain.MaxChannelReactionEmoticonLength { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - } - if req.Date <= 0 { - req.Date = nowUnix() - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("set channel message reactions: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("begin set channel message reactions: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) - if err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { - return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - if _, err := tx.Exec(ctx, ` -DELETE FROM channel_message_reactions -WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, req.ChannelID, req.MessageID, req.UserID); err != nil { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete channel message reactions: %w", err) - } - for i, reaction := range req.Reactions { - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_message_reactions ( - channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, - big, unread, chosen_order, reaction_date -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, - req.ChannelID, req.MessageID, req.UserID, msg.SenderUserID, string(reaction.Type), reaction.Emoticon, req.Big, msg.SenderUserID != 0 && msg.SenderUserID != req.UserID, i+1, req.Date); err != nil { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("insert channel message reaction: %w", err) - } - if req.AddToRecent { - if _, err := tx.Exec(ctx, ` -INSERT INTO user_recent_reactions (user_id, reaction_type, reaction_value, reaction_date) -VALUES ($1,$2,$3,$4) -ON CONFLICT (user_id, reaction_type, reaction_value) -DO UPDATE SET reaction_date = EXCLUDED.reaction_date, updated_at = now()`, - req.UserID, string(reaction.Type), reaction.Emoticon, req.Date); err != nil { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("upsert recent message reaction: %w", err) - } - } - if _, err := tx.Exec(ctx, ` -INSERT INTO user_top_reactions (user_id, reaction_type, reaction_value, reaction_count, reaction_date) -VALUES ($1,$2,$3,1,$4) -ON CONFLICT (user_id, reaction_type, reaction_value) -DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_date = EXCLUDED.reaction_date, updated_at = now()`, - req.UserID, string(reaction.Type), reaction.Emoticon, req.Date); err != nil { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("upsert top message reaction: %w", err) - } - } - if err := refreshChannelUnreadReactionsCountTx(ctx, tx, msg.SenderUserID, req.ChannelID); err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("commit set channel message reactions: %w", err) - } - committed = true - messages := []domain.ChannelMessage{msg} - if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - msg = messages[0] - reactions := emptyChannelMessageReactions(channel) - if msg.Reactions != nil { - reactions = *msg.Reactions - } else { - msg.Reactions = &reactions - } - recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) - if err != nil { - recipients = []int64{req.UserID} - } - return domain.ChannelMessageReactionsResult{ - Channel: channel, - Message: msg, - Messages: []domain.ChannelMessage{msg}, - Reactions: reactions, - Recipients: recipients, - }, nil -} - -func (s *ChannelStore) DeleteChannelParticipantReaction(ctx context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete channel participant reaction: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("begin delete channel participant reaction: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - if !canDeleteAnyChannelMessage(member) { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelAdminRequired - } - msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) - if err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - if msg.Deleted || msg.ID <= member.AvailableMinID { - return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - if _, err := tx.Exec(ctx, ` -DELETE FROM channel_message_reactions -WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, - req.ChannelID, req.MessageID, req.ParticipantUserID); err != nil { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete participant reaction: %w", err) - } - if err := refreshChannelUnreadReactionsCountTx(ctx, tx, msg.SenderUserID, req.ChannelID); err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("commit delete participant reaction: %w", err) - } - committed = true - messages := []domain.ChannelMessage{msg} - if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - msg = messages[0] - reactions := emptyChannelMessageReactions(channel) - if msg.Reactions != nil { - reactions = *msg.Reactions - } else { - msg.Reactions = &reactions - } - recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) - if err != nil { - recipients = []int64{req.UserID} - } - return domain.ChannelMessageReactionsResult{ - Channel: channel, - Message: msg, - Messages: []domain.ChannelMessage{msg}, - Reactions: reactions, - Recipients: recipients, - }, nil -} - -func (s *ChannelStore) DeleteChannelParticipantReactions(ctx context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { - return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid - } - if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch { - req.Limit = domain.MaxDeleteParticipantReactionsBatch - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("delete channel participant reactions: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("begin delete channel participant reactions: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelParticipantReactionsResult{}, err - } - if !canDeleteAnyChannelMessage(member) { - return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelAdminRequired - } - rows, err := tx.Query(ctx, ` -SELECT message_id, MAX(sender_user_id) -FROM channel_message_reactions -WHERE channel_id = $1 AND reacted_user_id = $2 -GROUP BY message_id -ORDER BY MAX(reaction_date) DESC, message_id DESC -LIMIT $3`, req.ChannelID, req.ParticipantUserID, req.Limit) - if err != nil { - return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("list participant reaction messages: %w", err) - } - ids := make([]int, 0, req.Limit) - owners := make(map[int64]struct{}) - for rows.Next() { - var msgID int - var senderUserID int64 - if err := rows.Scan(&msgID, &senderUserID); err != nil { - rows.Close() - return domain.DeleteChannelParticipantReactionsResult{}, err - } - ids = append(ids, msgID) - if senderUserID != 0 { - owners[senderUserID] = struct{}{} - } - } - if err := rows.Err(); err != nil { - rows.Close() - return domain.DeleteChannelParticipantReactionsResult{}, err - } - rows.Close() - if len(ids) > 0 { - if _, err := tx.Exec(ctx, ` -DELETE FROM channel_message_reactions -WHERE channel_id = $1 AND reacted_user_id = $2 AND message_id = ANY($3::int[])`, - req.ChannelID, req.ParticipantUserID, int32s(ids)); err != nil { - return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("delete participant reactions: %w", err) - } - for ownerID := range owners { - if err := refreshChannelUnreadReactionsCountTx(ctx, tx, ownerID, req.ChannelID); err != nil { - return domain.DeleteChannelParticipantReactionsResult{}, err - } - } - } - if err := tx.Commit(ctx); err != nil { - return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("commit delete participant reactions: %w", err) - } - committed = true - messages := []domain.ChannelMessage{} - if len(ids) > 0 { - res, err := s.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ - UserID: req.UserID, - ChannelID: req.ChannelID, - IDs: ids, - }) - if err != nil { - return domain.DeleteChannelParticipantReactionsResult{}, err - } - messages = res.Messages - } - recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) - if err != nil { - recipients = []int64{req.UserID} - } - return domain.DeleteChannelParticipantReactionsResult{ - Channel: channel, - Messages: messages, - Recipients: recipients, - Deleted: len(ids), - }, nil -} - -func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - if len(req.IDs) > domain.MaxGetMessageIDs { - return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid - } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - if len(req.IDs) == 0 { - return domain.ChannelMessageReactionsResult{Channel: channel}, nil - } - id32, _, err := validUniqueChannelMessageIDs(req.IDs) - if err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - args := []any{req.ChannelID, id32} - where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" - if member.AvailableMinID > 0 { - args = append(args, member.AvailableMinID) - where += fmt.Sprintf(" AND id > $%d", len(args)) - } - rows, err := s.db.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE `+where+` -ORDER BY id DESC`, args...) - if err != nil { - return domain.ChannelMessageReactionsResult{}, fmt.Errorf("get channel message reactions messages: %w", err) - } - defer rows.Close() - messages := make([]domain.ChannelMessage, 0, len(req.IDs)) - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - messages = append(messages, msg) - } - if err := rows.Err(); err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { - return domain.ChannelMessageReactionsResult{}, err - } - res := domain.ChannelMessageReactionsResult{Channel: channel, Messages: messages} - if len(messages) == 1 { - res.Message = messages[0] - res.Reactions = emptyChannelMessageReactions(channel) - if messages[0].Reactions != nil { - res.Reactions = *messages[0].Reactions - } - } - return res, nil -} - -func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { - return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid - } - if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit { - req.Limit = domain.MaxChannelMessageReactionListLimit - } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelMessageReactionsList{}, err - } - if channel.Broadcast && !channel.Megagroup { - return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden - } - msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) - if err != nil { - return domain.ChannelMessageReactionsList{}, err - } - if msg.Deleted || msg.ID <= member.AvailableMinID { - return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid - } - baseWhere := []string{"channel_id = $1", "message_id = $2"} - baseArgs := []any{req.ChannelID, req.MessageID} - if req.Reaction != nil { - if req.Reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(req.Reaction.Emoticon) == "" { - return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid - } - baseArgs = append(baseArgs, string(req.Reaction.Type), req.Reaction.Emoticon) - baseWhere = append(baseWhere, fmt.Sprintf("reaction_type = $%d AND reaction_value = $%d", len(baseArgs)-1, len(baseArgs))) - } - var count int - if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM channel_message_reactions WHERE `+strings.Join(baseWhere, " AND "), baseArgs...).Scan(&count); err != nil { - return domain.ChannelMessageReactionsList{}, fmt.Errorf("count channel message reactions: %w", err) - } - where := append([]string(nil), baseWhere...) - args := append([]any(nil), baseArgs...) - if req.Offset != "" { - cursor, ok := parseChannelReactionOffset(req.Offset) - if !ok { - return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid - } - args = append(args, cursor.date, cursor.userID, cursor.emoticon) - n := len(args) - where = append(where, fmt.Sprintf("(reaction_date < $%d OR (reaction_date = $%d AND (reacted_user_id < $%d OR (reacted_user_id = $%d AND reaction_value > $%d))))", n-2, n-2, n-1, n-1, n)) - } - args = append(args, req.Limit+1) - rows, err := s.db.Query(ctx, ` -SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, - big, unread, chosen_order, reaction_date -FROM channel_message_reactions -WHERE `+strings.Join(where, " AND ")+` -ORDER BY reaction_date DESC, reacted_user_id DESC, reaction_value ASC -LIMIT $`+fmt.Sprint(len(args)), args...) - if err != nil { - return domain.ChannelMessageReactionsList{}, fmt.Errorf("list channel message reactions: %w", err) - } - defer rows.Close() - reactions := make([]domain.ChannelMessagePeerReaction, 0, req.Limit+1) - for rows.Next() { - row, err := scanChannelMessagePeerReaction(rows, req.UserID) - if err != nil { - return domain.ChannelMessageReactionsList{}, err - } - reactions = append(reactions, row) - } - if err := rows.Err(); err != nil { - return domain.ChannelMessageReactionsList{}, err - } - next := "" - if len(reactions) > req.Limit { - reactions = reactions[:req.Limit] - next = channelReactionOffset(reactions[len(reactions)-1]) - } - return domain.ChannelMessageReactionsList{ - Channel: channel, - Message: msg, - Count: count, - Reactions: reactions, - NextOffset: next, - }, nil -} - -func (s *ChannelStore) ListTopMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { - if userID == 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 { - return []domain.MessageReaction{}, nil - } - if limit > domain.MaxTopMessageReactions { - limit = domain.MaxTopMessageReactions - } - rows, err := s.db.Query(ctx, ` -SELECT reaction_type, reaction_value -FROM user_top_reactions -WHERE user_id = $1 -ORDER BY reaction_count DESC, reaction_date DESC, updated_at DESC, reaction_value ASC -LIMIT $2`, userID, limit) - if err != nil { - return nil, fmt.Errorf("list top message reactions: %w", err) - } - defer rows.Close() - out := make([]domain.MessageReaction, 0, limit) - for rows.Next() { - var reactionType, reactionValue string - if err := rows.Scan(&reactionType, &reactionValue); err != nil { - return nil, err - } - out = append(out, domain.MessageReaction{ - Type: domain.MessageReactionType(reactionType), - Emoticon: reactionValue, - }) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func (s *ChannelStore) ListRecentMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { - if userID == 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 { - return []domain.MessageReaction{}, nil - } - if limit > domain.MaxRecentMessageReactions { - limit = domain.MaxRecentMessageReactions - } - rows, err := s.db.Query(ctx, ` -SELECT reaction_type, reaction_value -FROM user_recent_reactions -WHERE user_id = $1 -ORDER BY reaction_date DESC, updated_at DESC, reaction_value ASC -LIMIT $2`, userID, limit) - if err != nil { - return nil, fmt.Errorf("list recent message reactions: %w", err) - } - defer rows.Close() - out := make([]domain.MessageReaction, 0, limit) - for rows.Next() { - var reactionType, reactionValue string - if err := rows.Scan(&reactionType, &reactionValue); err != nil { - return nil, err - } - out = append(out, domain.MessageReaction{ - Type: domain.MessageReactionType(reactionType), - Emoticon: reactionValue, - }) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func (s *ChannelStore) RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { - if userID == 0 || len(reactions) == 0 { - return nil - } - if date <= 0 { - date = nowUnix() - } - for _, reaction := range reactions { - if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" || len(reaction.Emoticon) > domain.MaxChannelReactionEmoticonLength { - continue - } - if addToRecent { - if _, err := s.db.Exec(ctx, ` -INSERT INTO user_recent_reactions (user_id, reaction_type, reaction_value, reaction_date) -VALUES ($1,$2,$3,$4) -ON CONFLICT (user_id, reaction_type, reaction_value) -DO UPDATE SET reaction_date = EXCLUDED.reaction_date, updated_at = now()`, - userID, string(reaction.Type), reaction.Emoticon, date); err != nil { - return fmt.Errorf("record recent message reaction: %w", err) - } - } - if _, err := s.db.Exec(ctx, ` -INSERT INTO user_top_reactions (user_id, reaction_type, reaction_value, reaction_count, reaction_date) -VALUES ($1,$2,$3,1,$4) -ON CONFLICT (user_id, reaction_type, reaction_value) -DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_date = EXCLUDED.reaction_date, updated_at = now()`, - userID, string(reaction.Type), reaction.Emoticon, date); err != nil { - return fmt.Errorf("record top message reaction: %w", err) - } - } - return nil -} - -func (s *ChannelStore) ClearRecentMessageReactions(ctx context.Context, userID int64) error { - if userID == 0 { - return domain.ErrChannelInvalid - } - if _, err := s.db.Exec(ctx, `DELETE FROM user_recent_reactions WHERE user_id = $1`, userID); err != nil { - return fmt.Errorf("clear recent message reactions: %w", err) - } - return nil -} - -func (s *ChannelStore) ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) { - if userID == 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 { - return []domain.SavedReactionTag{}, nil - } - if limit > domain.MaxSavedReactionTags { - limit = domain.MaxSavedReactionTags - } - rows, err := s.db.Query(ctx, ` -SELECT reaction_type, reaction_value, title, reaction_count -FROM user_saved_reaction_tags -WHERE user_id = $1 -ORDER BY reaction_count DESC, updated_at DESC, reaction_value ASC -LIMIT $2`, userID, limit) - if err != nil { - return nil, fmt.Errorf("list saved reaction tags: %w", err) - } - defer rows.Close() - out := make([]domain.SavedReactionTag, 0, limit) - for rows.Next() { - var reactionType, reactionValue, title string - var count int - if err := rows.Scan(&reactionType, &reactionValue, &title, &count); err != nil { - return nil, err - } - out = append(out, domain.SavedReactionTag{ - UserID: userID, - Reaction: domain.MessageReaction{ - Type: domain.MessageReactionType(reactionType), - Emoticon: reactionValue, - }, - Title: title, - Count: count, - }) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func (s *ChannelStore) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error { - if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji { - return domain.ErrChannelInvalid - } - reactionValue := strings.TrimSpace(tag.Reaction.Emoticon) - if reactionValue == "" { - return domain.ErrChannelInvalid - } - if tag.Count < 0 { - tag.Count = 0 - } - if _, err := s.db.Exec(ctx, ` -INSERT INTO user_saved_reaction_tags (user_id, reaction_type, reaction_value, title, reaction_count) -VALUES ($1, $2, $3, $4, $5) -ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET - title = EXCLUDED.title, - reaction_count = GREATEST(user_saved_reaction_tags.reaction_count, EXCLUDED.reaction_count), - updated_at = now()`, tag.UserID, string(tag.Reaction.Type), reactionValue, tag.Title, tag.Count); err != nil { - return fmt.Errorf("upsert saved reaction tag: %w", err) - } - return nil -} - -func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.RandomID == 0 { - return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid - } - title := strings.TrimSpace(req.Title) - if title == "" && !req.TitleMissing { - return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid - } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.CreateChannelForumTopicResult{}, err - } - if !channel.Forum || channel.Broadcast || !channel.Megagroup { - return domain.CreateChannelForumTopicResult{}, domain.ErrChannelForumMissing - } - if !canSendChannelMessage(channel, member) { - return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden - } - if req.IconColor == 0 { - req.IconColor = domain.DefaultForumTopicIconColor - } - res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: req.UserID, - ChannelID: req.ChannelID, - RandomID: req.RandomID, - SendAs: req.SendAs, - Action: &domain.ChannelMessageAction{ - Type: domain.ChannelActionTopicCreate, - Title: title, - IconColor: req.IconColor, - IconEmojiID: req.IconEmojiID, - TitleMissing: req.TitleMissing, - }, - Date: req.Date, - }) - if err != nil { - return domain.CreateChannelForumTopicResult{}, err - } - if res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionTopicCreate { - return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid - } - if _, err := s.db.Exec(ctx, ` -INSERT INTO channel_forum_topics ( - channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, - title_missing, date, top_message_id, read_inbox_max_id, read_outbox_max_id -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $2, $2, $2) -ON CONFLICT (channel_id, topic_id) DO NOTHING`, - req.ChannelID, res.Message.ID, req.UserID, title, req.IconColor, req.IconEmojiID, req.TitleMissing, res.Message.Date); err != nil { - return domain.CreateChannelForumTopicResult{}, fmt.Errorf("insert forum topic: %w", err) - } - topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, res.Message.ID) - if err != nil { - return domain.CreateChannelForumTopicResult{}, err - } - return domain.CreateChannelForumTopicResult{ - Channel: res.Channel, - Topic: topic, - Message: res.Message, - Event: res.Event, - Recipients: res.Recipients, - Duplicate: res.Duplicate, - }, nil -} - -func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { - return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid - } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.EditChannelForumTopicResult{}, err - } - if !channel.Forum { - return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing - } - topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) - if err != nil { - return domain.EditChannelForumTopicResult{}, err - } - if !canManageForumTopic(channel, member, topic, req.UserID) { - return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired - } - next := topic - action := domain.ChannelMessageAction{Type: domain.ChannelActionTopicEdit} - changed := false - if req.Title != nil { - title := strings.TrimSpace(*req.Title) - if title == "" { - return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid - } - if next.Title != title { - next.Title = title - action.Title = title - changed = true - } - } - if req.IconEmojiID != nil && next.IconEmojiID != *req.IconEmojiID { - next.IconEmojiID = *req.IconEmojiID - action.IconEmojiID = *req.IconEmojiID - action.IconEmojiIDSet = true - changed = true - } - if req.Closed != nil && next.Closed != *req.Closed { - next.Closed = *req.Closed - action.Closed = boolPtr(*req.Closed) - changed = true - } - if req.Hidden != nil && next.Hidden != *req.Hidden { - next.Hidden = *req.Hidden - action.Hidden = boolPtr(*req.Hidden) - changed = true - } - if !changed { - return domain.EditChannelForumTopicResult{}, domain.ErrChannelNotModified - } - res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: req.UserID, - ChannelID: req.ChannelID, - ReplyTo: &domain.MessageReply{ - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, - MessageID: req.TopicID, - TopMessageID: req.TopicID, - }, - Action: &action, - Date: req.Date, - }) - if err != nil { - return domain.EditChannelForumTopicResult{}, err - } - if _, err := s.db.Exec(ctx, ` -UPDATE channel_forum_topics -SET title = $3, - icon_emoji_id = $4, - closed = $5, - hidden = $6, - top_message_id = GREATEST(top_message_id, $7), - updated_at = now() -WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, - req.ChannelID, req.TopicID, next.Title, next.IconEmojiID, next.Closed, next.Hidden, res.Message.ID); err != nil { - return domain.EditChannelForumTopicResult{}, fmt.Errorf("update forum topic: %w", err) - } - topic, err = s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) - if err != nil { - return domain.EditChannelForumTopicResult{}, err - } - return domain.EditChannelForumTopicResult{ - Channel: res.Channel, - Topic: topic, - Message: res.Message, - Event: res.Event, - Recipients: res.Recipients, - }, nil -} - -func (s *ChannelStore) UpdatePinnedForumTopic(ctx context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { - return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid - } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.UpdateChannelForumTopicPinnedResult{}, err - } - if !channel.Forum { - return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelForumMissing - } - topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) - if err != nil { - return domain.UpdateChannelForumTopicPinnedResult{}, err - } - if !canPinChannelMessages(channel, member) { - return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelAdminRequired - } - if topic.Pinned == req.Pinned { - return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelNotModified - } - pinnedOrder := 0 - if req.Pinned { - pinnedOrder = topic.PinnedOrder - if pinnedOrder == 0 { - pinnedOrder, err = s.nextForumTopicPinnedOrder(ctx, req.ChannelID) - if err != nil { - return domain.UpdateChannelForumTopicPinnedResult{}, err - } - } - } - if _, err := s.db.Exec(ctx, ` -UPDATE channel_forum_topics -SET pinned = $3, pinned_order = $4, updated_at = now() -WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, - req.ChannelID, req.TopicID, req.Pinned, pinnedOrder); err != nil { - return domain.UpdateChannelForumTopicPinnedResult{}, fmt.Errorf("update pinned forum topic: %w", err) - } - topic, err = s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) - if err != nil { - return domain.UpdateChannelForumTopicPinnedResult{}, err - } - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - return domain.UpdateChannelForumTopicPinnedResult{Channel: channel, Topic: topic, Recipients: recipients}, nil -} - -func (s *ChannelStore) ReorderPinnedForumTopics(ctx context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs { - return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid - } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ReorderChannelPinnedForumTopicsResult{}, err - } - if !channel.Forum { - return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelForumMissing - } - if !canPinChannelMessages(channel, member) { - return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelAdminRequired - } - seen := make(map[int]struct{}, len(req.Order)) - order := make([]int, 0, len(req.Order)) - for _, id := range req.Order { - if id <= 0 || id > domain.MaxMessageBoxID { - return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid - } - if _, ok := seen[id]; ok { - continue - } - topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, id) - if err != nil || !topic.Pinned { - if req.Force { - continue - } - return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid - } - seen[id] = struct{}{} - order = append(order, id) - } - for i, id := range order { - if _, err := s.db.Exec(ctx, ` -UPDATE channel_forum_topics -SET pinned_order = $3, updated_at = now() -WHERE channel_id = $1 AND topic_id = $2 AND pinned AND NOT deleted`, req.ChannelID, id, len(order)-i); err != nil { - return domain.ReorderChannelPinnedForumTopicsResult{}, fmt.Errorf("reorder pinned forum topics: %w", err) - } - } - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - return domain.ReorderChannelPinnedForumTopicsResult{Channel: channel, Order: order, Recipients: recipients}, nil -} - -func (s *ChannelStore) DeleteForumTopicHistory(ctx context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid - } - if req.Date == 0 { - req.Date = nowUnix() - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete forum topic history: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete forum topic history: %w", err) - } - committed := false - var reserved []reservedChannelPts - defer func() { - if !committed { - _ = tx.Rollback(ctx) - s.recordChannelPtsGaps(ctx, reserved, req.Date) - } - }() - channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - if !channel.Forum { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelForumMissing - } - topic, err := s.getForumTopic(ctx, tx, req.ChannelID, req.TopicID) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - if !canManageForumTopic(channel, member, topic, req.UserID) && !canDeleteAnyChannelMessage(member) { - return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired - } - rows, err := tx.Query(ctx, ` -SELECT id -FROM channel_messages -WHERE channel_id = $1 AND NOT deleted AND (id = $2 OR reply_to_top_id = $2) -ORDER BY id DESC -LIMIT $3`, req.ChannelID, req.TopicID, domain.MaxDeleteHistoryBatch) - if err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list forum topic delete ids: %w", err) - } - ids := make([]int, 0, domain.MaxDeleteHistoryBatch) - for rows.Next() { - var id int - if err := rows.Scan(&id); err != nil { - rows.Close() - return domain.DeleteChannelHistoryResult{}, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - rows.Close() - return domain.DeleteChannelHistoryResult{}, err - } - rows.Close() - deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date, &reserved) - if err != nil { - return domain.DeleteChannelHistoryResult{}, err - } - remaining := 0 - if err := tx.QueryRow(ctx, ` -SELECT COUNT(*)::int -FROM channel_messages -WHERE channel_id = $1 AND NOT deleted AND (id = $2 OR reply_to_top_id = $2)`, req.ChannelID, req.TopicID).Scan(&remaining); err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("count remaining forum topic messages: %w", err) - } - offset := 0 - if remaining > 0 { - offset = 1 - } else if _, err := tx.Exec(ctx, ` -UPDATE channel_forum_topics -SET deleted = true, updated_at = now() -WHERE channel_id = $1 AND topic_id = $2`, req.ChannelID, req.TopicID); err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("mark forum topic deleted: %w", err) - } - if err := tx.Commit(ctx); err != nil { - return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete forum topic history: %w", err) - } - committed = true - recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) - return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil -} - -func (s *ChannelStore) ListForumTopics(ctx context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { - channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) - if err != nil { - return domain.ChannelForumTopicList{}, err - } - if !channel.Forum { - return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing - } - limit := filter.Limit - if limit <= 0 || limit > domain.MaxChannelForumTopicsLimit { - limit = domain.MaxChannelForumTopicsLimit - } - query := strings.TrimSpace(strings.ToLower(filter.Query)) - countArgs := []any{filter.ChannelID, member.AvailableMinID, query} - countSQL := ` -SELECT COUNT(*)::int -FROM channel_forum_topics -WHERE channel_id = $1 AND NOT deleted AND topic_id > $2 - AND ($3 = '' OR POSITION($3 IN LOWER(title)) > 0)` - var total int - if err := s.db.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil { - return domain.ChannelForumTopicList{}, fmt.Errorf("count forum topics: %w", err) - } - args := []any{filter.ChannelID, member.AvailableMinID, query} - where := `channel_id = $1 AND NOT deleted AND topic_id > $2 AND ($3 = '' OR POSITION($3 IN LOWER(title)) > 0)` - offsetID := filter.OffsetTopic - if offsetID == 0 { - offsetID = filter.OffsetID - } - if filter.OffsetDate != 0 { - args = append(args, filter.OffsetDate, offsetID) - where += fmt.Sprintf(" AND (date, topic_id) < ($%d, $%d)", len(args)-1, len(args)) - } else if offsetID != 0 { - args = append(args, offsetID) - where += fmt.Sprintf(" AND topic_id < $%d", len(args)) - } - args = append(args, limit) - rows, err := s.db.Query(ctx, ` -SELECT `+channelForumTopicColumns+` -FROM channel_forum_topics -WHERE `+where+` -ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC -LIMIT $`+fmt.Sprint(len(args)), args...) - if err != nil { - return domain.ChannelForumTopicList{}, fmt.Errorf("list forum topics: %w", err) - } - defer rows.Close() - topics := make([]domain.ChannelForumTopic, 0, limit) - for rows.Next() { - topic, err := scanChannelForumTopic(rows) - if err != nil { - return domain.ChannelForumTopicList{}, err - } - topics = append(topics, s.topicWithViewerCounters(ctx, viewerUserID, filter.ChannelID, topic, member.ReadInboxMaxID, member.AvailableMinID)) - } - if err := rows.Err(); err != nil { - return domain.ChannelForumTopicList{}, err - } - messages, err := s.forumTopicRootMessages(ctx, filter.ChannelID, topics, member.AvailableMinID) - if err != nil { - return domain.ChannelForumTopicList{}, err - } - dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) - return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog, Topics: topics, Messages: messages, Count: total}, nil -} - -func (s *ChannelStore) GetForumTopicsByID(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { - channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) - if err != nil { - return domain.ChannelForumTopicList{}, err - } - if !channel.Forum { - return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing - } - if len(ids) == 0 { - dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) - return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog}, nil - } - id32, _, err := validUniqueChannelMessageIDs(ids) - if err != nil { - return domain.ChannelForumTopicList{}, err - } - rows, err := s.db.Query(ctx, ` -SELECT `+channelForumTopicColumns+` -FROM channel_forum_topics -WHERE channel_id = $1 AND NOT deleted AND topic_id > $2 AND topic_id = ANY($3::int[]) -ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC`, channelID, member.AvailableMinID, id32) - if err != nil { - return domain.ChannelForumTopicList{}, fmt.Errorf("get forum topics by id: %w", err) - } - defer rows.Close() - topics := make([]domain.ChannelForumTopic, 0, len(id32)) - for rows.Next() { - topic, err := scanChannelForumTopic(rows) - if err != nil { - return domain.ChannelForumTopicList{}, err - } - topics = append(topics, s.topicWithViewerCounters(ctx, viewerUserID, channelID, topic, member.ReadInboxMaxID, member.AvailableMinID)) - } - if err := rows.Err(); err != nil { - return domain.ChannelForumTopicList{}, err - } - messages, err := s.forumTopicRootMessages(ctx, channelID, topics, member.AvailableMinID) - if err != nil { - return domain.ChannelForumTopicList{}, err - } - dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) - return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog, Topics: topics, Messages: messages, Count: len(topics)}, nil -} - -func (s *ChannelStore) ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { - source, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) - if err != nil { - return domain.ChannelHistory{}, err - } - root, err := s.getChannelMessage(ctx, s.db, filter.ChannelID, filter.RootMessageID) - if err != nil || root.Deleted || root.ID <= member.AvailableMinID { - return domain.ChannelHistory{}, domain.ErrMessageIDInvalid - } - target := source - availableMinID := member.AvailableMinID - extraChannels := []domain.Channel(nil) - rootID := root.ID - if source.Broadcast { - if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 { - return domain.ChannelHistory{Channel: source}, nil - } - linked, err := getChannelByID(ctx, s.db, root.Discussion.ChannelID) - if err != nil { - return domain.ChannelHistory{Channel: source}, nil - } - target = linked - rootID = root.Discussion.MessageID - availableMinID = 0 - if linkedMember, err := s.getChannelMember(ctx, s.db, linked.ID, viewerUserID); err == nil && validateChannelMemberVisible(linkedMember) == nil { - availableMinID = linkedMember.AvailableMinID - } - extraChannels = append(extraChannels, source) - } - targetRoot, err := s.getChannelMessage(ctx, s.db, target.ID, rootID) - if err != nil || targetRoot.Deleted || targetRoot.ID <= availableMinID { - return domain.ChannelHistory{Channel: target, Channels: extraChannels}, nil - } - limit := filter.Limit - if limit <= 0 || limit > domain.MaxChannelRepliesLimit { - limit = domain.MaxChannelRepliesLimit - } - filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) - count, err := s.countChannelReplies(ctx, target.ID, rootID, availableMinID, filter) - if err != nil { - return domain.ChannelHistory{}, err - } - messages, err := s.queryChannelRepliesPage(ctx, target.ID, rootID, availableMinID, filter, limit) - if err != nil { - return domain.ChannelHistory{}, err - } - if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, target, messages); err != nil { - return domain.ChannelHistory{}, err - } - if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{target}, messages); err != nil { - return domain.ChannelHistory{}, err - } - topics := []domain.ChannelForumTopic(nil) - if target.Forum { - if topic, err := s.getForumTopic(ctx, s.db, target.ID, rootID); err == nil && !topic.Hidden { - topic = s.topicWithViewerCounters(ctx, viewerUserID, target.ID, topic, availableMinID, availableMinID) - topics = append(topics, topic) - } else if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) { - return domain.ChannelHistory{}, err - } - } - return domain.ChannelHistory{Channel: target, Channels: extraChannels, Topics: topics, Messages: messages, Count: count}, nil -} - -func (s *ChannelStore) ListChannelUnreadMentions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) { - channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) - if err != nil { - return domain.ChannelHistory{}, err - } - limit := filter.Limit - if limit <= 0 || limit > domain.MaxChannelUnreadMentionsLimit { - limit = domain.MaxChannelUnreadMentionsLimit - } - filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) - count, err := s.countChannelUnreadMentions(ctx, viewerUserID, filter, member.AvailableMinID) - if err != nil { - return domain.ChannelHistory{}, err - } - messages, err := s.queryChannelUnreadMentionsPage(ctx, viewerUserID, filter, member.AvailableMinID, limit) - if err != nil { - return domain.ChannelHistory{}, err - } - if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, messages); err != nil { - return domain.ChannelHistory{}, err - } - if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, messages); err != nil { - return domain.ChannelHistory{}, err - } - return domain.ChannelHistory{Channel: channel, Messages: messages, Count: count}, nil -} - -func (s *ChannelStore) ReadChannelMentions(ctx context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.ReadChannelMentionsResult{}, fmt.Errorf("read channel mentions: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.ReadChannelMentionsResult{}, fmt.Errorf("begin read channel mentions: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, _, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.ReadChannelMentionsResult{}, err - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelReadMentionsBatch { - limit = domain.MaxChannelReadMentionsBatch - } - cleared, remaining, err := readChannelMentionsTx(ctx, tx, req.UserID, req.ChannelID, req.TopMsgID, limit) - if err != nil { - return domain.ReadChannelMentionsResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.ReadChannelMentionsResult{}, fmt.Errorf("commit read channel mentions: %w", err) - } - committed = true - offset := 0 - if remaining > 0 { - offset = 1 - } - return domain.ReadChannelMentionsResult{ - Channel: channel, - Cleared: cleared, - Remaining: remaining, - Offset: offset, - ChannelPts: channel.Pts, - }, nil -} - -func (s *ChannelStore) ListChannelUnreadReactions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) { - channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) - if err != nil { - return domain.ChannelHistory{}, err - } - limit := filter.Limit - if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit { - limit = domain.MaxChannelUnreadReactionsLimit - } - filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) - count, err := s.countChannelUnreadReactions(ctx, viewerUserID, filter, member.AvailableMinID) - if err != nil { - return domain.ChannelHistory{}, err - } - messages, err := s.queryChannelUnreadReactionsPage(ctx, viewerUserID, filter, member.AvailableMinID, limit) - if err != nil { - return domain.ChannelHistory{}, err - } - if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, messages); err != nil { - return domain.ChannelHistory{}, err - } - if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, messages); err != nil { - return domain.ChannelHistory{}, err - } - return domain.ChannelHistory{Channel: channel, Messages: messages, Count: count}, nil -} - -func (s *ChannelStore) ReadChannelReactions(ctx context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) { - if req.UserID == 0 || req.ChannelID == 0 { - return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.ReadChannelReactionsResult{}, fmt.Errorf("read channel reactions: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.ReadChannelReactionsResult{}, fmt.Errorf("begin read channel reactions: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - channel, _, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) - if err != nil { - return domain.ReadChannelReactionsResult{}, err - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelReadReactionsBatch { - limit = domain.MaxChannelReadReactionsBatch - } - cleared, remaining, err := readChannelReactionsTx(ctx, tx, req.UserID, req.ChannelID, req.TopMsgID, limit) - if err != nil { - return domain.ReadChannelReactionsResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.ReadChannelReactionsResult{}, fmt.Errorf("commit read channel reactions: %w", err) - } - committed = true - offset := 0 - if remaining > 0 { - offset = 1 - } - return domain.ReadChannelReactionsResult{ - Channel: channel, - Cleared: cleared, - Remaining: remaining, - Offset: offset, - ChannelPts: channel.Pts, - }, nil -} - -func (s *ChannelStore) GetDiscussionMessage(ctx context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) { - source, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) - if err != nil { - return domain.ChannelDiscussionMessage{}, err - } - msg, err := s.getChannelMessage(ctx, s.db, channelID, msgID) - if err != nil || msg.Deleted || msg.ID <= member.AvailableMinID { - return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid - } - result := domain.ChannelDiscussionMessage{PostChannel: source, DiscussionChannel: source, Channels: []domain.Channel{source}} - target := source - targetMsg := msg - if source.Broadcast { - if msg.Discussion == nil || msg.Discussion.ChannelID == 0 || msg.Discussion.MessageID == 0 { - return result, nil - } - linked, err := getChannelByID(ctx, s.db, msg.Discussion.ChannelID) - if err != nil { - return result, nil - } - linkedMsg, err := s.getChannelMessage(ctx, s.db, linked.ID, msg.Discussion.MessageID) - if err != nil || linkedMsg.Deleted { - return result, nil - } - target = linked - targetMsg = linkedMsg - result.DiscussionChannel = linked - result.Channels = []domain.Channel{source, linked} - } - messages := []domain.ChannelMessage{targetMsg} - if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, target, messages); err != nil { - return domain.ChannelDiscussionMessage{}, err - } - if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{target}, messages); err != nil { - return domain.ChannelDiscussionMessage{}, err - } - readInbox, readOutbox := s.channelReadWatermarks(ctx, target.ID, viewerUserID) - result.Messages = messages - result.ReadInboxMaxID = readInbox - result.ReadOutboxMaxID = readOutbox - if messages[0].Replies != nil { - result.MaxID = messages[0].Replies.MaxID - } - result.UnreadCount = s.channelThreadUnreadCount(ctx, target.ID, targetMsg.ID, viewerUserID, readInbox) - return result, nil -} - -func (s *ChannelStore) ReadChannelHistory(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { - var lastErr error - for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ { - res, err := s.readChannelHistoryOnce(ctx, req) - if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil { - return res, err - } - lastErr = err - } - return domain.ReadChannelHistoryResult{}, lastErr -} - -func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { - channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ReadChannelHistoryResult{}, err - } - maxID := req.MaxID - if maxID <= 0 || maxID > channel.TopMessageID { - maxID = channel.TopMessageID - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel history: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.ReadChannelHistoryResult{}, fmt.Errorf("begin read channel history: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - var previous int - if err := tx.QueryRow(ctx, `SELECT read_inbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID).Scan(&previous); err != nil { - return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err) - } - changed := maxID > previous - var outboxUpdates []domain.ChannelReadOutboxUpdate - if _, err := tx.Exec(ctx, ` -UPDATE channel_members -SET read_inbox_date = CASE WHEN read_inbox_max_id < $3 THEN $4 ELSE read_inbox_date END, - read_inbox_max_id = GREATEST(read_inbox_max_id, $3), - unread_mark = false, - updated_at = now() -WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, maxID, req.Date); err != nil { - return domain.ReadChannelHistoryResult{}, fmt.Errorf("update channel member read: %w", err) - } - msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) - if changed { - outboxUpdates, err = advanceChannelReadOutboxTx(ctx, tx, channel, msg, req.UserID, previous, maxID) - if err != nil { - return domain.ReadChannelHistoryResult{}, err - } - } - if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, maxID, 0); err != nil { - return domain.ReadChannelHistoryResult{}, err - } - if err := tx.Commit(ctx); err != nil { - return domain.ReadChannelHistoryResult{}, fmt.Errorf("commit read channel history: %w", err) - } - committed = true - dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) - if err != nil { - return domain.ReadChannelHistoryResult{}, err - } - return domain.ReadChannelHistoryResult{ - ChannelID: req.ChannelID, - MaxID: maxID, - StillUnreadCount: dialog.UnreadCount, - Changed: changed, - Pts: channel.Pts, - Dialog: dialog, - OutboxUpdates: outboxUpdates, - }, nil -} - -func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, readerUserID int64, previous, maxID int) ([]domain.ChannelReadOutboxUpdate, error) { - if maxID <= previous { - return nil, nil - } - lowerID := previous - if maxID-lowerID > domain.MaxChannelReadOutboxScanMessages { - lowerID = maxID - domain.MaxChannelReadOutboxScanMessages - } - rows, err := tx.Query(ctx, ` -WITH latest_sender_messages AS ( - SELECT sender_user_id, MAX(id) AS max_id - FROM channel_messages - WHERE channel_id = $1 - AND id > $2 - AND id <= $3 - AND NOT deleted - AND sender_user_id <> $4 - GROUP BY sender_user_id - ORDER BY max_id DESC - LIMIT $5 -) -SELECT sender_user_id, max_id -FROM latest_sender_messages -ORDER BY sender_user_id ASC`, channel.ID, lowerID, maxID, readerUserID, domain.MaxChannelReadOutboxFanout) - if err != nil { - return nil, fmt.Errorf("list channel read outbox senders: %w", err) - } - defer rows.Close() - type candidate struct { - userID int64 - maxID int - } - candidates := make([]candidate, 0, domain.MaxChannelReadOutboxFanout) - for rows.Next() { - var item candidate - if err := rows.Scan(&item.userID, &item.maxID); err != nil { - return nil, err - } - candidates = append(candidates, item) - } - if err := rows.Err(); err != nil { - return nil, err - } - out := make([]domain.ChannelReadOutboxUpdate, 0, len(candidates)) - for _, item := range candidates { - var readOutboxMaxID, readInboxMaxID int - err := tx.QueryRow(ctx, ` -UPDATE channel_members -SET read_outbox_max_id = GREATEST(read_outbox_max_id, $3), - updated_at = now() -WHERE channel_id = $1 - AND user_id = $2 - AND status = 'active' - AND read_outbox_max_id < $3 -RETURNING read_outbox_max_id, read_inbox_max_id`, channel.ID, item.userID, item.maxID).Scan(&readOutboxMaxID, &readInboxMaxID) - if errors.Is(err, pgx.ErrNoRows) { - continue - } - if err != nil { - return nil, fmt.Errorf("update channel sender read outbox: %w", err) - } - if err := upsertChannelDialogTx(ctx, tx, item.userID, channel, top, readInboxMaxID, readOutboxMaxID); err != nil { - return nil, err - } - out = append(out, domain.ChannelReadOutboxUpdate{UserID: item.userID, MaxID: readOutboxMaxID}) - } - return out, nil -} - -func (s *ChannelStore) ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) { - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelReadParticipantsResult{}, err - } - if req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { - return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid - } - msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) - if err != nil { - return domain.ChannelReadParticipantsResult{}, err - } - if msg.Deleted || msg.ID <= member.AvailableMinID { - return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid - } - result := domain.ChannelReadParticipantsResult{Channel: channel, Message: msg} - if !channel.Megagroup || channel.ParticipantsHidden || channel.ParticipantsCount > domain.MaxChannelReadParticipants { - return result, nil - } - if req.Date > 0 && msg.Date+domain.ChannelReadMarkExpirePeriod <= req.Date { - return result, nil - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelReadParticipants { - limit = domain.MaxChannelReadParticipants - } - rows, err := s.db.Query(ctx, ` -SELECT user_id, read_inbox_date -FROM channel_members -WHERE channel_id = $1 - AND status = 'active' - AND user_id <> $2 - AND available_min_id < $3 - AND read_inbox_max_id >= $3 - AND read_inbox_date > 0 - AND NOT COALESCE((banned_rights->>'ViewMessages')::boolean, false) -ORDER BY read_inbox_date ASC, user_id ASC -LIMIT $4`, req.ChannelID, req.UserID, req.MessageID, limit) - if err != nil { - return domain.ChannelReadParticipantsResult{}, fmt.Errorf("list channel read participants: %w", err) - } - defer rows.Close() - for rows.Next() { - var item domain.ChannelReadParticipant - if err := rows.Scan(&item.UserID, &item.Date); err != nil { - return domain.ChannelReadParticipantsResult{}, err - } - result.Participants = append(result.Participants, item) - } - if err := rows.Err(); err != nil { - return domain.ChannelReadParticipantsResult{}, err - } - return result, nil -} - -func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) { - channel, member, preview, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID) - if err != nil { - return domain.ChannelDifference{}, err - } - if req.Pts < 0 || req.Pts > channel.Pts { - return domain.ChannelDifference{}, domain.ErrPersistentTimestamp - } - if !preview && member.AvailableMinPts > req.Pts { - req.Pts = minInt(member.AvailableMinPts, channel.Pts) - } - limit := req.Limit - if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { - limit = domain.MaxChannelDifferenceLimit - } - if channel.Pts-req.Pts > limit { - args := []any{req.ChannelID} - where := "channel_id = $1 AND NOT deleted" - if member.AvailableMinID > 0 { - args = append(args, member.AvailableMinID) - where += fmt.Sprintf(" AND id > $%d", len(args)) - } - args = append(args, domain.MaxChannelDifferenceTooLongMessages) - rows, err := s.db.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE `+where+` -ORDER BY id DESC -LIMIT $`+fmt.Sprint(len(args)), args...) - if err != nil { - return domain.ChannelDifference{}, fmt.Errorf("list channel too long messages: %w", err) - } - defer rows.Close() - diff := domain.ChannelDifference{ - Channel: channel, - Self: member, - Pts: channel.Pts, - Final: true, - TooLong: true, - Timeout: 30, - } - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - return domain.ChannelDifference{}, err - } - diff.NewMessages = append(diff.NewMessages, msg) - } - if err := rows.Err(); err != nil { - return domain.ChannelDifference{}, err - } - if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil { - return domain.ChannelDifference{}, err - } - if preview { - diff.Dialog = previewChannelDialog(req.UserID, channel, member) - } else { - dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) - if err != nil { - return domain.ChannelDifference{}, err - } - diff.Dialog = dialog - } - return diff, nil - } - rows, err := s.db.Query(ctx, ` -SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text -FROM channel_update_events -WHERE channel_id = $1 AND pts > $2 -ORDER BY pts ASC -LIMIT $3`, req.ChannelID, req.Pts, limit) - if err != nil { - return domain.ChannelDifference{}, fmt.Errorf("list channel difference: %w", err) - } - defer rows.Close() - diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30} - userRefs := make(map[int64]struct{}) - channelRefs := make(map[int64]struct{}) - lastPts := req.Pts - for rows.Next() { - event, messageID, err := scanChannelEvent(rows) - if err != nil { - return domain.ChannelDifference{}, err - } - lastPts = event.Pts - if messageID != 0 && event.Message.ID == 0 { - msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID) - if err != nil { - return domain.ChannelDifference{}, err - } - event.Message = msg - } - visibleEvent, ok := domain.FilterChannelUpdateEventForAvailableMinID(event, member.AvailableMinID) - if !ok { - continue - } - event = visibleEvent - if preview && event.Type == domain.ChannelUpdateParticipant { - continue - } - collectChannelEventRefs(event, req.ChannelID, userRefs, channelRefs) - diff.Events = append(diff.Events, event) - diff.Pts = event.Pts - switch event.Type { - case domain.ChannelUpdateNewMessage: - diff.NewMessages = append(diff.NewMessages, event.Message) - default: - diff.OtherUpdates = append(diff.OtherUpdates, event) - } - } - if err := rows.Err(); err != nil { - return domain.ChannelDifference{}, err - } - if len(diff.Events) == 0 { - diff.Pts = lastPts - } else if lastPts > diff.Pts { - diff.Pts = lastPts - } - if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil { - return domain.ChannelDifference{}, err - } - for i := range diff.OtherUpdates { - if diff.OtherUpdates[i].Message.ID == 0 { - continue - } - messages := []domain.ChannelMessage{diff.OtherUpdates[i].Message} - if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, messages); err != nil { - return domain.ChannelDifference{}, err - } - diff.OtherUpdates[i].Message = messages[0] - } - users, err := listUsersByIDs(ctx, s.db, mapKeysInt64(userRefs)) - if err != nil { - return domain.ChannelDifference{}, err - } - channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) - if err != nil { - return domain.ChannelDifference{}, err - } - diff.Users = users - diff.Channels = channels - if preview { - diff.Dialog = previewChannelDialog(req.UserID, channel, member) - } else { - dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) - if err != nil { - return domain.ChannelDifference{}, err - } - diff.Dialog = dialog - } - diff.Final = lastPts >= channel.Pts - return diff, nil -} - -func (s *ChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { - if userID == 0 || afterChannelID < 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { - limit = domain.MaxSynchronousChannelDialogFanout - } - rows, err := s.db.Query(ctx, ` -SELECT channel_id -FROM channel_members -WHERE user_id = $1 - AND status = 'active' - AND channel_id > $2 -ORDER BY channel_id -LIMIT $3`, userID, afterChannelID, limit) - if err != nil { - return nil, fmt.Errorf("list active channel ids for user: %w", err) - } - defer rows.Close() - out := make([]int64, 0, limit) - for rows.Next() { - var channelID int64 - if err := rows.Scan(&channelID); err != nil { - return nil, err - } - out = append(out, channelID) - } - return out, rows.Err() -} - -func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) { - if userID == 0 || sinceDate <= 0 || afterChannelID < 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { - limit = domain.MaxChannelDifferenceLimit - } - rows, err := s.db.Query(ctx, ` -SELECT i.channel_id, c.pts -FROM user_channel_member_index i -JOIN channels c ON c.id = i.channel_id AND NOT c.deleted -WHERE i.user_id = $1 - AND i.status = 'active' - AND NOT i.deleted - AND i.channel_id > $3 - AND EXISTS ( - SELECT 1 - FROM channel_update_events e - WHERE e.channel_id = i.channel_id - AND e.date > $2 - LIMIT 1 - ) -ORDER BY i.channel_id ASC -LIMIT $4`, userID, sinceDate, afterChannelID, limit) - if err != nil { - return nil, fmt.Errorf("list dirty active channels for user: %w", err) - } - defer rows.Close() - out := make([]domain.DirtyChannel, 0, limit) - for rows.Next() { - var item domain.DirtyChannel - if err := rows.Scan(&item.ChannelID, &item.Pts); err != nil { - return nil, err - } - out = append(out, item) - } - return out, rows.Err() -} - -func (s *ChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { - if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { - return nil, err - } - if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { - limit = domain.MaxChannelRealtimeFanout - } - rows, err := s.db.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' ORDER BY user_id LIMIT $2`, channelID, limit) - if err != nil { - return nil, fmt.Errorf("list active channel members: %w", err) - } - defer rows.Close() - out := make([]int64, 0, limit) - for rows.Next() { - var userID int64 - if err := rows.Scan(&userID); err != nil { - return nil, err - } - out = append(out, userID) - } - return out, rows.Err() -} - -func (s *ChannelStore) ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) { - if channelID == 0 { - return nil, domain.ErrChannelInvalid - } - if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { - limit = domain.MaxChannelRealtimeFanout - } - rows, err := s.db.Query(ctx, ` -SELECT user_id -FROM channel_members -WHERE channel_id = $1 - AND status = 'active' - AND ( - role = 'creator' OR - (role = 'admin' AND ( - (admin_rights->>'InviteUsers')::boolean IS TRUE OR - (admin_rights->>'ChangeInfo')::boolean IS TRUE - )) - ) -ORDER BY user_id -LIMIT $2`, channelID, limit) - if err != nil { - return nil, fmt.Errorf("list channel invite admin members: %w", err) - } - defer rows.Close() - out := make([]int64, 0, limit) - for rows.Next() { - var userID int64 - if err := rows.Scan(&userID); err != nil { - return nil, err - } - out = append(out, userID) - } - return out, rows.Err() -} - -func (s *ChannelStore) FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) { - if channelID == 0 || len(userIDs) == 0 { - return nil, nil - } - candidates := uniqueChannelUserIDs(userIDs, 0) - if len(candidates) == 0 { - return nil, nil - } - out := make([]int64, 0, len(candidates)) - for start := 0; start < len(candidates); start += channelMemberFilterBatch { - end := start + channelMemberFilterBatch - if end > len(candidates) { - end = len(candidates) - } - rows, err := s.db.Query(ctx, ` -SELECT user_id -FROM channel_members -WHERE channel_id = $1 - AND user_id = ANY($2::bigint[]) - AND status = 'active' -ORDER BY user_id`, channelID, candidates[start:end]) - if err != nil { - return nil, fmt.Errorf("filter active channel members: %w", err) - } - for rows.Next() { - var userID int64 - if err := rows.Scan(&userID); err != nil { - rows.Close() - return nil, err - } - out = append(out, userID) - } - if err := rows.Err(); err != nil { - rows.Close() - return nil, err - } - rows.Close() - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out, nil -} - -func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) { - var pts int - err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&pts) - return pts, err -} - -func (s *ChannelStore) MaxChannelMessageID(ctx context.Context, channelID int64) (int, error) { - var id int - err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id) - return id, err -} - -const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), -c.broadcast, c.megagroup, c.forum, c.forum_tabs, c.autotranslation, c.restricted_sponsored, c.broadcast_messages_allowed, c.send_paid_messages_stars, c.noforwards, c.join_to_send, c.join_request, c.signatures, c.pre_history_hidden, c.participants_hidden, c.antispam, c.linked_chat_id, c.slowmode_seconds, c.default_banned_rights::text, -c.available_reactions::text, c.color_set, c.color, c.color_background_emoji_id, c.profile_color_set, c.profile_color, c.profile_color_background_emoji_id, c.emoji_status_document_id, c.emoji_status_until, -c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts, -c.ttl_period, c.date, c.deleted, c.photo_id, c.photo_dc_id, c.photo_stripped` - -const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, -send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, -entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, -fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text` - -const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, -title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id, -read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, -unread_poll_votes_count` - -type rowScanner interface { - Scan(dest ...any) error -} - -func (s *ChannelStore) getChannelForMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, error) { - row := db.QueryRow(ctx, ` -SELECT `+channelColumns+`, - m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at, - m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts, - m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date -FROM channels c -JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 -WHERE c.id = $2 AND NOT c.deleted`, viewerUserID, channelID) - ch, member, err := scanChannelWithMember(row) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.Channel{}, domain.ChannelMember{}, domain.ErrChannelPrivate - } - return domain.Channel{}, domain.ChannelMember{}, err - } - if err := validateChannelMemberVisible(member); err != nil { - return domain.Channel{}, domain.ChannelMember{}, err - } - return ch, member, nil -} - -func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, bool, error) { - ch, member, err := s.getChannelForMember(ctx, db, viewerUserID, channelID) - if err == nil { - return ch, member, false, nil - } - if !errors.Is(err, domain.ErrChannelPrivate) { - return domain.Channel{}, domain.ChannelMember{}, false, err - } - ch, err = getChannelByID(ctx, db, channelID) - if err != nil { - return domain.Channel{}, domain.ChannelMember{}, false, err - } - if !publicPreviewableChannel(ch) { - return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate - } - member, err = getPublicPreviewMember(ctx, db, viewerUserID, ch) - if err != nil { - return domain.Channel{}, domain.ChannelMember{}, false, err - } - return ch, member, true, nil -} - -func getPublicPreviewMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, ch domain.Channel) (domain.ChannelMember, error) { - member, err := scanChannelMember(db.QueryRow(ctx, ` -SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, - admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts, - read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date -FROM channel_members -WHERE channel_id = $1 AND user_id = $2`, ch.ID, viewerUserID)) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return publicPreviewMember(ch, viewerUserID, domain.ChannelMember{}, false), nil - } - return domain.ChannelMember{}, err - } - if member.Status == domain.ChannelMemberBanned || member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages { - return domain.ChannelMember{}, domain.ErrChannelUserBanned - } - return publicPreviewMember(ch, viewerUserID, member, true), nil -} - -func getChannelByID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.Channel, error) { - ch, err := scanChannel(db.QueryRow(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = $1 AND NOT c.deleted`, channelID)) - if errors.Is(err, pgx.ErrNoRows) { - return domain.Channel{}, domain.ErrChannelInvalid - } - return ch, err -} - -func listChannelsByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.Channel, error) { - if len(ids) == 0 { - return nil, nil - } - rows, err := db.Query(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = ANY($1::bigint[]) AND NOT c.deleted ORDER BY c.id ASC`, ids) - if err != nil { - return nil, fmt.Errorf("list channels by ids: %w", err) - } - defer rows.Close() - out := make([]domain.Channel, 0, len(ids)) - for rows.Next() { - ch, err := scanChannel(rows) - if err != nil { - return nil, err - } - out = append(out, ch) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func listUsersByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.User, error) { - if len(ids) == 0 { - return nil, nil - } - rows, err := db.Query(ctx, ` -SELECT id, access_hash, phone, first_name, last_name, username, country_code, verified, support -FROM users -WHERE id = ANY($1::bigint[]) -ORDER BY id ASC`, ids) - if err != nil { - return nil, fmt.Errorf("list users by ids: %w", err) - } - defer rows.Close() - out := make([]domain.User, 0, len(ids)) - for rows.Next() { - var u domain.User - if err := rows.Scan(&u.ID, &u.AccessHash, &u.Phone, &u.FirstName, &u.LastName, &u.Username, &u.CountryCode, &u.Verified, &u.Support); err != nil { - return nil, err - } - out = append(out, u) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func (s *ChannelStore) getChannelMember(ctx context.Context, db sqlcgen.DBTX, channelID, userID int64) (domain.ChannelMember, error) { - row := db.QueryRow(ctx, ` -SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, - rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date -FROM channel_members -WHERE channel_id = $1 AND user_id = $2`, channelID, userID) - member, err := scanChannelMember(row) - if errors.Is(err, pgx.ErrNoRows) { - return domain.ChannelMember{}, domain.ErrChannelPrivate - } - return member, err -} - -func (s *ChannelStore) getChannelDialog(ctx context.Context, db sqlcgen.DBTX, userID int64, channel domain.Channel) (domain.ChannelDialog, error) { - dialog := domain.ChannelDialog{UserID: userID, ChannelID: channel.ID, TopMessageID: channel.TopMessageID} - var defaultSendAsType sql.NullString - var defaultSendAsID sql.NullInt64 - visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" - visibleReadInbox := "COALESCE(d.read_inbox_max_id, m.read_inbox_max_id)" - visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) - err := db.QueryRow(ctx, ` -SELECT `+visibleTopID+`, - COALESCE(d.top_message_date, c.date), - COALESCE(d.folder_id, 0), - `+visibleReadInbox+`, - COALESCE(d.read_outbox_max_id, m.read_outbox_max_id), - `+visibleUnreadCount+`, - COALESCE(d.pinned, false), - COALESCE(d.pinned_order, 0), - COALESCE(d.unread_mark, m.unread_mark), - COALESCE(d.unread_mentions_count, 0), - COALESCE(d.unread_reactions_count, 0), - COALESCE(d.view_forum_as_messages, false), - d.default_send_as_peer_type, - d.default_send_as_peer_id -FROM channels c -JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 -LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id -WHERE c.id = $2`, userID, channel.ID).Scan( - &dialog.TopMessageID, - &dialog.TopMessageDate, - &dialog.FolderID, - &dialog.ReadInboxMaxID, - &dialog.ReadOutboxMaxID, - &dialog.UnreadCount, - &dialog.Pinned, - &dialog.PinnedOrder, - &dialog.UnreadMark, - &dialog.UnreadMentions, - &dialog.UnreadReactions, - &dialog.ViewForumAsMessages, - &defaultSendAsType, - &defaultSendAsID, - ) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return domain.ChannelDialog{}, fmt.Errorf("get channel dialog: %w", err) - } - if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 { - dialog.DefaultSendAs = &domain.Peer{Type: domain.PeerType(defaultSendAsType.String), ID: defaultSendAsID.Int64} - } - if dialog.TopMessageID != 0 { - if msg, err := s.getChannelMessage(ctx, db, channel.ID, dialog.TopMessageID); err == nil { - dialog.TopMessageDate = msg.Date - } - } - return dialog, nil -} - -func (s *ChannelStore) getChannelMessage(ctx context.Context, db sqlcgen.DBTX, channelID int64, id int) (domain.ChannelMessage, error) { - if channelID == 0 || id == 0 { - return domain.ChannelMessage{}, pgx.ErrNoRows - } - msg, err := scanChannelMessage(db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND id = $2`, channelID, id)) - if errors.Is(err, pgx.ErrNoRows) { - return domain.ChannelMessage{}, domain.ErrMessageIDInvalid - } - return msg, err -} - -func (s *ChannelStore) getForumTopic(ctx context.Context, db sqlcgen.DBTX, channelID int64, topicID int) (domain.ChannelForumTopic, error) { - if channelID == 0 || topicID == 0 { - return domain.ChannelForumTopic{}, domain.ErrMessageIDInvalid - } - topic, err := scanChannelForumTopic(db.QueryRow(ctx, ` -SELECT `+channelForumTopicColumns+` -FROM channel_forum_topics -WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, channelID, topicID)) - if errors.Is(err, pgx.ErrNoRows) { - return domain.ChannelForumTopic{}, domain.ErrMessageIDInvalid - } - return topic, err -} - -func (s *ChannelStore) forumTopicRootMessages(ctx context.Context, channelID int64, topics []domain.ChannelForumTopic, availableMinID int) ([]domain.ChannelMessage, error) { - if len(topics) == 0 { - return nil, nil - } - ids := make([]int, 0, len(topics)) - seen := make(map[int]struct{}, len(topics)) - for _, topic := range topics { - if topic.TopMessageID <= 0 { - continue - } - if _, ok := seen[topic.TopMessageID]; ok { - continue - } - seen[topic.TopMessageID] = struct{}{} - ids = append(ids, topic.TopMessageID) - } - id32, _, err := validUniqueChannelMessageIDs(ids) - if err != nil { - return nil, err - } - if len(id32) == 0 { - return nil, nil - } - rows, err := s.db.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE channel_id = $1 AND id = ANY($2::int[]) AND id > $3 AND NOT deleted -ORDER BY id DESC`, channelID, id32, availableMinID) - if err != nil { - return nil, fmt.Errorf("list forum topic root messages: %w", err) - } - defer rows.Close() - messages := make([]domain.ChannelMessage, 0, len(id32)) - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - return nil, err - } - messages = append(messages, msg) - } - if err := rows.Err(); err != nil { - return nil, err - } - return messages, nil -} - -func (s *ChannelStore) nextForumTopicPinnedOrder(ctx context.Context, channelID int64) (int, error) { - var maxOrder int - if err := s.db.QueryRow(ctx, ` -SELECT COALESCE(MAX(pinned_order), 0)::int -FROM channel_forum_topics -WHERE channel_id = $1 AND pinned AND NOT deleted`, channelID).Scan(&maxOrder); err != nil { - return 0, fmt.Errorf("next forum topic pinned order: %w", err) - } - return maxOrder + 1, nil -} - -type channelReplyStatKey struct { - channelID int64 - rootID int -} - -type channelReactionMessageKey struct { - channelID int64 - messageID int -} - -type channelReactionCursor struct { - date int - userID int64 - emoticon string -} - -func emptyChannelMessageReactions(channel domain.Channel) domain.ChannelMessageReactions { - return domain.ChannelMessageReactions{ - CanSeeList: !channel.Broadcast || channel.Megagroup, - Results: []domain.ChannelMessageReactionCount{}, - Recent: []domain.ChannelMessagePeerReaction{}, - } -} - -func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channels []domain.Channel, messages []domain.ChannelMessage) error { - if len(messages) == 0 { - return nil - } - if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages); err != nil { - return err - } - channelsByID := make(map[int64]domain.Channel, len(channels)) - for _, ch := range channels { - if ch.ID != 0 { - channelsByID[ch.ID] = ch - } - } - indexes := make(map[channelReactionMessageKey][]int) - idsByChannel := make(map[int64][]int32) - for i := range messages { - if messages[i].ChannelID == 0 || messages[i].ID <= 0 { - continue - } - key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID} - if _, ok := indexes[key]; !ok { - idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID)) - } - indexes[key] = append(indexes[key], i) - } - for channelID, ids := range idsByChannel { - ch := channelsByID[channelID] - if ch.ID == 0 { - var err error - ch, err = getChannelByID(ctx, db, channelID) - if err != nil { - return err - } - channelsByID[channelID] = ch - } - rows, err := db.Query(ctx, ` -SELECT message_id, reaction_type, reaction_value, COUNT(*)::int, - COALESCE(MAX(CASE WHEN reacted_user_id = $3 THEN chosen_order ELSE 0 END), 0)::int, - COALESCE(MAX(reaction_date), 0)::int -FROM channel_message_reactions -WHERE channel_id = $1 AND message_id = ANY($2::int[]) -GROUP BY message_id, reaction_type, reaction_value -ORDER BY message_id ASC, COUNT(*) DESC, COALESCE(MAX(reaction_date), 0) DESC, reaction_value ASC`, channelID, ids, viewerUserID) - if err != nil { - return fmt.Errorf("load channel message reaction counts: %w", err) - } - for rows.Next() { - var msgID int - var reactionType, reactionValue string - var count, chosenOrder, latestDate int - if err := rows.Scan(&msgID, &reactionType, &reactionValue, &count, &chosenOrder, &latestDate); err != nil { - rows.Close() - return err - } - _ = latestDate - key := channelReactionMessageKey{channelID: channelID, messageID: msgID} - for _, idx := range indexes[key] { - if messages[idx].Reactions == nil { - reactions := emptyChannelMessageReactions(ch) - messages[idx].Reactions = &reactions - } - messages[idx].Reactions.Results = append(messages[idx].Reactions.Results, domain.ChannelMessageReactionCount{ - Reaction: domain.MessageReaction{ - Type: domain.MessageReactionType(reactionType), - Emoticon: reactionValue, - }, - Count: count, - ChosenOrder: chosenOrder, - }) - } - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - rows.Close() - rows, err = db.Query(ctx, ` -SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, - big, unread, chosen_order, reaction_date -FROM ( - SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, - big, unread, chosen_order, reaction_date, - row_number() OVER ( - PARTITION BY message_id - ORDER BY reaction_date DESC, reacted_user_id DESC, reaction_value ASC - ) AS rn - FROM channel_message_reactions - WHERE channel_id = $1 AND message_id = ANY($2::int[]) -) ranked -WHERE rn <= $3 -ORDER BY message_id ASC, reaction_date DESC, reacted_user_id DESC, reaction_value ASC`, channelID, ids, domain.MaxChannelMessageReactionRecent) - if err != nil { - return fmt.Errorf("load channel message recent reactions: %w", err) - } - for rows.Next() { - row, err := scanChannelMessagePeerReaction(rows, viewerUserID) - if err != nil { - rows.Close() - return err - } - key := channelReactionMessageKey{channelID: row.ChannelID, messageID: row.MessageID} - for _, idx := range indexes[key] { - if messages[idx].Reactions == nil { - reactions := emptyChannelMessageReactions(ch) - messages[idx].Reactions = &reactions - } - messages[idx].Reactions.Recent = append(messages[idx].Reactions.Recent, row) - } - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - rows.Close() - } - return nil -} - -func populateChannelMessageUnreadFlags(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, messages []domain.ChannelMessage) error { - if viewerUserID == 0 || len(messages) == 0 { - return nil - } - indexes := make(map[channelReactionMessageKey][]int) - idsByChannel := make(map[int64][]int32) - for i := range messages { - if messages[i].ChannelID == 0 || messages[i].ID <= 0 { - continue - } - key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID} - if _, ok := indexes[key]; !ok { - idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID)) - } - indexes[key] = append(indexes[key], i) - } - for channelID, ids := range idsByChannel { - rows, err := db.Query(ctx, ` -SELECT message_id, COALESCE(media_unread, false) -FROM channel_unread_mentions -WHERE user_id = $1 - AND channel_id = $2 - AND message_id = ANY($3::int[])`, viewerUserID, channelID, ids) - if err != nil { - return fmt.Errorf("load channel message unread flags: %w", err) - } - for rows.Next() { - var messageID int - var mediaUnread bool - if err := rows.Scan(&messageID, &mediaUnread); err != nil { - rows.Close() - return err - } - key := channelReactionMessageKey{channelID: channelID, messageID: messageID} - for _, idx := range indexes[key] { - messages[idx].Mentioned = true - messages[idx].MediaUnread = mediaUnread - } - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - rows.Close() - } - return nil -} - -func channelReactionOffset(row domain.ChannelMessagePeerReaction) string { - return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + row.Reaction.Emoticon -} - -func parseChannelReactionOffset(offset string) (channelReactionCursor, bool) { - parts := strings.SplitN(offset, ":", 3) - if len(parts) != 3 { - return channelReactionCursor{}, false - } - date, err := strconv.Atoi(parts[0]) - if err != nil || date < 0 { - return channelReactionCursor{}, false - } - userID, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil || userID < 0 { - return channelReactionCursor{}, false - } - return channelReactionCursor{date: date, userID: userID, emoticon: parts[2]}, true -} - -func (s *ChannelStore) populateChannelMessageReplies(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channel domain.Channel, messages []domain.ChannelMessage) error { - if len(messages) == 0 || channel.ID == 0 { - return nil - } - indexes := make(map[channelReplyStatKey][]int) - rootsByChannel := make(map[int64][]int32) - readMaxByChannel := make(map[int64]int) - for i := range messages { - targetChannelID := channel.ID - rootID := messages[i].ID - replies := &domain.ChannelMessageReplies{} - if messages[i].Discussion != nil && messages[i].Discussion.ChannelID != 0 && messages[i].Discussion.MessageID != 0 { - targetChannelID = messages[i].Discussion.ChannelID - rootID = messages[i].Discussion.MessageID - replies.Comments = true - replies.ChannelID = messages[i].Discussion.ChannelID - } else if channel.Broadcast && channel.LinkedChatID != 0 && messages[i].Post { - replies.Comments = true - replies.ChannelID = channel.LinkedChatID - } - if _, ok := readMaxByChannel[targetChannelID]; !ok { - readInbox, _ := s.channelReadWatermarks(ctx, targetChannelID, viewerUserID) - readMaxByChannel[targetChannelID] = readInbox - } - replies.ReadMaxID = readMaxByChannel[targetChannelID] - key := channelReplyStatKey{channelID: targetChannelID, rootID: rootID} - if _, ok := indexes[key]; !ok { - rootsByChannel[targetChannelID] = append(rootsByChannel[targetChannelID], int32(rootID)) - } - indexes[key] = append(indexes[key], i) - if replies.Comments { - messages[i].Replies = replies - } - } - for channelID, roots := range rootsByChannel { - rows, err := db.Query(ctx, ` -SELECT reply_to_top_id, COUNT(*)::int, COALESCE(MAX(id), 0)::int, COALESCE((array_agg(pts ORDER BY id DESC))[1], 0)::int -FROM channel_messages -WHERE channel_id = $1 AND reply_to_top_id = ANY($2::int[]) AND NOT deleted -GROUP BY reply_to_top_id`, channelID, roots) - if err != nil { - return fmt.Errorf("load channel reply stats: %w", err) - } - for rows.Next() { - var rootID, count, maxID, repliesPts int - if err := rows.Scan(&rootID, &count, &maxID, &repliesPts); err != nil { - rows.Close() - return err - } - for _, idx := range indexes[channelReplyStatKey{channelID: channelID, rootID: rootID}] { - replies := messages[idx].Replies - if replies == nil { - replies = &domain.ChannelMessageReplies{ReadMaxID: readMaxByChannel[channelID]} - } - replies.Replies = count - replies.MaxID = maxID - replies.RepliesPts = repliesPts - messages[idx].Replies = replies - } - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - rows.Close() - } - return nil -} - -func (s *ChannelStore) channelReadWatermarks(ctx context.Context, channelID, userID int64) (int, int) { - var inbox, outbox int - _ = s.db.QueryRow(ctx, `SELECT read_inbox_max_id, read_outbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, userID).Scan(&inbox, &outbox) - return inbox, outbox -} - -func (s *ChannelStore) channelThreadUnreadCount(ctx context.Context, channelID int64, rootID int, viewerUserID int64, readMaxID int) int { - var count int - _ = s.db.QueryRow(ctx, ` -SELECT COUNT(*)::int -FROM channel_messages -WHERE channel_id = $1 AND reply_to_top_id = $2 AND id > $3 AND sender_user_id <> $4 AND NOT deleted`, channelID, rootID, readMaxID, viewerUserID).Scan(&count) - return count -} - -func countChannelUnreadMessages(ctx context.Context, db sqlcgen.DBTX, userID, channelID int64, readMaxID, topID int) (int, error) { - if userID == 0 || channelID == 0 || topID <= readMaxID { - return 0, nil - } - var count int - if err := db.QueryRow(ctx, ` -SELECT COUNT(*)::int -FROM channel_messages -WHERE channel_id = $1 - AND id > $2 - AND id <= $3 - AND sender_user_id <> $4 - AND NOT deleted`, channelID, readMaxID, topID, userID).Scan(&count); err != nil { - return 0, fmt.Errorf("count channel unread messages: %w", err) - } - return count, nil -} - -func (s *ChannelStore) topicWithViewerCounters(ctx context.Context, viewerUserID, channelID int64, topic domain.ChannelForumTopic, readMaxID, availableMinID int) domain.ChannelForumTopic { - topic.UnreadCount = s.channelThreadUnreadCount(ctx, channelID, topic.TopicID, viewerUserID, readMaxID) - topic.UnreadMentionsCount = s.countChannelUnreadMentionsForTop(ctx, viewerUserID, channelID, topic.TopicID) - topic.UnreadReactionsCount = s.countChannelUnreadReactionsForTop(ctx, viewerUserID, channelID, topic.TopicID, availableMinID) - return topic -} - -func (s *ChannelStore) countChannelUnreadMentionsForTop(ctx context.Context, userID, channelID int64, topMsgID int) int { - var count int - _ = s.db.QueryRow(ctx, ` -SELECT COUNT(*)::int -FROM channel_unread_mentions -WHERE user_id = $1 AND channel_id = $2 AND top_message_id = $3`, userID, channelID, topMsgID).Scan(&count) - return count -} - -func (s *ChannelStore) countChannelUnreadReactionsForTop(ctx context.Context, userID, channelID int64, topMsgID, availableMinID int) int { - var count int - _ = s.db.QueryRow(ctx, ` -SELECT COUNT(DISTINCT r.message_id)::int -FROM channel_message_reactions r -JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id -WHERE r.sender_user_id = $1 - AND r.channel_id = $2 - AND r.unread - AND r.reacted_user_id <> $1 - AND cm.id > $4 - AND NOT cm.deleted - AND (cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3)`, userID, channelID, topMsgID, availableMinID).Scan(&count) - return count -} - -func (s *ChannelStore) countChannelReplies(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter) (int, error) { - where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) - var count int - if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM channel_messages WHERE `+where, args...).Scan(&count); err != nil { - return 0, fmt.Errorf("count channel replies: %w", err) - } - return count, nil -} - -func (s *ChannelStore) countChannelUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID int) (int, error) { - where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) - var count int - if err := s.db.QueryRow(ctx, ` -SELECT COUNT(*)::int -FROM channel_unread_mentions um -JOIN channel_messages cm ON cm.channel_id = um.channel_id AND cm.id = um.message_id -WHERE `+where, args...).Scan(&count); err != nil { - return 0, fmt.Errorf("count channel unread mentions: %w", err) - } - return count, nil -} - -func (s *ChannelStore) queryChannelUnreadMentionsPage(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { - if limit <= 0 { - return nil, nil - } - switch messageHistoryLoadType(filter.AddOffset, limit) { - case messageHistoryLoadForward: - return s.queryChannelUnreadMentionsForward(ctx, userID, filter, availableMinID, limit) - case messageHistoryLoadAround: - forwardLimit := -filter.AddOffset - if forwardLimit > limit { - forwardLimit = limit - } - backwardLimit := limit + filter.AddOffset - if backwardLimit < 0 { - backwardLimit = 0 - } - forward, err := s.queryChannelUnreadMentionsForward(ctx, userID, filter, availableMinID, forwardLimit) - if err != nil { - return nil, err - } - backward, err := s.queryChannelUnreadMentionsBackward(ctx, userID, filter, availableMinID, backwardLimit, true) - if err != nil { - return nil, err - } - out := append(forward, backward...) - sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) - return out, nil - default: - start := filter.AddOffset - if start < 0 { - start = 0 - } - items, err := s.queryChannelUnreadMentionsBackward(ctx, userID, filter, availableMinID, limit+start, false) - if err != nil || start >= len(items) { - return nil, err - } - return items[start:], nil - } -} - -func (s *ChannelStore) queryChannelUnreadMentionsBackward(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { - if limit <= 0 { - return nil, nil - } - where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) - where, args = appendChannelUnreadMentionBackwardOffset(where, args, filter, includeOffset) - args = append(args, limit) - return s.queryChannelUnreadMentions(ctx, filter.ChannelID, where, args, "DESC") -} - -func (s *ChannelStore) queryChannelUnreadMentionsForward(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { - if limit <= 0 { - return nil, nil - } - where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) - where, args = appendChannelUnreadMentionForwardOffset(where, args, filter) - args = append(args, limit) - out, err := s.queryChannelUnreadMentions(ctx, filter.ChannelID, where, args, "ASC") - if err != nil { - return nil, err - } - sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) - return out, nil -} - -func (s *ChannelStore) queryChannelUnreadMentions(ctx context.Context, channelID int64, where string, args []any, order string) ([]domain.ChannelMessage, error) { - rows, err := s.db.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE channel_id = $2 - AND id = ANY(ARRAY( - SELECT cm.id - FROM channel_unread_mentions um - JOIN channel_messages cm ON cm.channel_id = um.channel_id AND cm.id = um.message_id - WHERE `+where+` - ORDER BY cm.id `+order+` - LIMIT $`+fmt.Sprint(len(args))+` - )::int[]) -ORDER BY id `+order, args...) - _ = channelID - if err != nil { - return nil, fmt.Errorf("list channel unread mentions: %w", err) - } - defer rows.Close() - out := make([]domain.ChannelMessage, 0) - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - return nil, err - } - out = append(out, msg) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func channelUnreadMentionBaseWhere(userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID int) (string, []any) { - args := []any{userID, filter.ChannelID} - where := "um.user_id = $1 AND um.channel_id = $2 AND NOT cm.deleted" - if availableMinID > 0 { - args = append(args, availableMinID) - where += fmt.Sprintf(" AND cm.id > $%d", len(args)) - } - if filter.TopMsgID > 0 { - args = append(args, filter.TopMsgID) - where += fmt.Sprintf(" AND um.top_message_id = $%d", len(args)) - } - if filter.MaxID > 0 { - args = append(args, filter.MaxID) - where += fmt.Sprintf(" AND cm.id < $%d", len(args)) - } - if filter.MinID > 0 { - args = append(args, filter.MinID) - where += fmt.Sprintf(" AND cm.id > $%d", len(args)) - } - return where, args -} - -func appendChannelUnreadMentionBackwardOffset(where string, args []any, filter domain.ChannelUnreadMentionsFilter, include bool) (string, []any) { - if filter.OffsetDate > 0 { - args = append(args, filter.OffsetDate) - if include { - return where + fmt.Sprintf(" AND cm.message_date <= $%d", len(args)), args - } - return where + fmt.Sprintf(" AND cm.message_date < $%d", len(args)), args - } - if filter.OffsetID > 0 { - args = append(args, filter.OffsetID) - if include { - return where + fmt.Sprintf(" AND cm.id <= $%d", len(args)), args - } - return where + fmt.Sprintf(" AND cm.id < $%d", len(args)), args - } - return where, args -} - -func appendChannelUnreadMentionForwardOffset(where string, args []any, filter domain.ChannelUnreadMentionsFilter) (string, []any) { - if filter.OffsetDate > 0 { - args = append(args, filter.OffsetDate) - return where + fmt.Sprintf(" AND cm.message_date >= $%d", len(args)), args - } - if filter.OffsetID > 0 { - args = append(args, filter.OffsetID) - return where + fmt.Sprintf(" AND cm.id > $%d", len(args)), args - } - return where, args -} - -func (s *ChannelStore) countChannelUnreadReactions(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID int) (int, error) { - where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) - var count int - if err := s.db.QueryRow(ctx, ` -SELECT COUNT(DISTINCT cm.id)::int -FROM channel_message_reactions r -JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id -WHERE `+where, args...).Scan(&count); err != nil { - return 0, fmt.Errorf("count channel unread reactions: %w", err) - } - return count, nil -} - -func (s *ChannelStore) queryChannelUnreadReactionsPage(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { - if limit <= 0 { - return nil, nil - } - switch messageHistoryLoadType(filter.AddOffset, limit) { - case messageHistoryLoadForward: - return s.queryChannelUnreadReactionsForward(ctx, userID, filter, availableMinID, limit) - case messageHistoryLoadAround: - forwardLimit := -filter.AddOffset - if forwardLimit > limit { - forwardLimit = limit - } - backwardLimit := limit + filter.AddOffset - if backwardLimit < 0 { - backwardLimit = 0 - } - forward, err := s.queryChannelUnreadReactionsForward(ctx, userID, filter, availableMinID, forwardLimit) - if err != nil { - return nil, err - } - backward, err := s.queryChannelUnreadReactionsBackward(ctx, userID, filter, availableMinID, backwardLimit, true) - if err != nil { - return nil, err - } - out := append(forward, backward...) - sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) - return out, nil - default: - start := filter.AddOffset - if start < 0 { - start = 0 - } - items, err := s.queryChannelUnreadReactionsBackward(ctx, userID, filter, availableMinID, limit+start, false) - if err != nil || start >= len(items) { - return nil, err - } - return items[start:], nil - } -} - -func (s *ChannelStore) queryChannelUnreadReactionsBackward(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { - if limit <= 0 { - return nil, nil - } - where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) - where, args = appendChannelUnreadReactionBackwardOffset(where, args, filter, includeOffset) - args = append(args, limit) - return s.queryChannelUnreadReactions(ctx, filter.ChannelID, where, args, "DESC") -} - -func (s *ChannelStore) queryChannelUnreadReactionsForward(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { - if limit <= 0 { - return nil, nil - } - where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) - where, args = appendChannelUnreadReactionForwardOffset(where, args, filter) - args = append(args, limit) - out, err := s.queryChannelUnreadReactions(ctx, filter.ChannelID, where, args, "ASC") - if err != nil { - return nil, err - } - sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) - return out, nil -} - -func (s *ChannelStore) queryChannelUnreadReactions(ctx context.Context, channelID int64, where string, args []any, order string) ([]domain.ChannelMessage, error) { - rows, err := s.db.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE channel_id = $2 - AND id = ANY(ARRAY( - SELECT DISTINCT cm.id - FROM channel_message_reactions r - JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id - WHERE `+where+` - ORDER BY cm.id `+order+` - LIMIT $`+fmt.Sprint(len(args))+` - )::int[]) -ORDER BY id `+order, args...) - _ = channelID - if err != nil { - return nil, fmt.Errorf("list channel unread reactions: %w", err) - } - defer rows.Close() - out := make([]domain.ChannelMessage, 0) - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - return nil, err - } - out = append(out, msg) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func channelUnreadReactionBaseWhere(userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID int) (string, []any) { - args := []any{userID, filter.ChannelID} - where := "r.sender_user_id = $1 AND r.channel_id = $2 AND r.unread AND r.reacted_user_id <> $1 AND NOT cm.deleted" - if availableMinID > 0 { - args = append(args, availableMinID) - where += fmt.Sprintf(" AND cm.id > $%d", len(args)) - } - if filter.TopMsgID > 0 { - args = append(args, filter.TopMsgID) - where += fmt.Sprintf(" AND (cm.id = $%d OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $%d)", len(args), len(args)) - } - if filter.MaxID > 0 { - args = append(args, filter.MaxID) - where += fmt.Sprintf(" AND cm.id < $%d", len(args)) - } - if filter.MinID > 0 { - args = append(args, filter.MinID) - where += fmt.Sprintf(" AND cm.id > $%d", len(args)) - } - return where, args -} - -func appendChannelUnreadReactionBackwardOffset(where string, args []any, filter domain.ChannelUnreadReactionsFilter, include bool) (string, []any) { - if filter.OffsetID > 0 { - args = append(args, filter.OffsetID) - if include { - return where + fmt.Sprintf(" AND cm.id <= $%d", len(args)), args - } - return where + fmt.Sprintf(" AND cm.id < $%d", len(args)), args - } - return where, args -} - -func appendChannelUnreadReactionForwardOffset(where string, args []any, filter domain.ChannelUnreadReactionsFilter) (string, []any) { - if filter.OffsetID > 0 { - args = append(args, filter.OffsetID) - return where + fmt.Sprintf(" AND cm.id > $%d", len(args)), args - } - return where, args -} - -func (s *ChannelStore) queryChannelRepliesPage(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int) ([]domain.ChannelMessage, error) { - if limit <= 0 { - return nil, nil - } - switch messageHistoryLoadType(filter.AddOffset, limit) { - case messageHistoryLoadForward: - return s.queryChannelRepliesForward(ctx, channelID, rootID, availableMinID, filter, limit) - case messageHistoryLoadAround: - forwardLimit := -filter.AddOffset - if forwardLimit > limit { - forwardLimit = limit - } - backwardLimit := limit + filter.AddOffset - if backwardLimit < 0 { - backwardLimit = 0 - } - forward, err := s.queryChannelRepliesForward(ctx, channelID, rootID, availableMinID, filter, forwardLimit) - if err != nil { - return nil, err - } - backward, err := s.queryChannelRepliesBackward(ctx, channelID, rootID, availableMinID, filter, backwardLimit, true) - if err != nil { - return nil, err - } - out := append(forward, backward...) - sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) - return out, nil - default: - start := filter.AddOffset - if start < 0 { - start = 0 - } - items, err := s.queryChannelRepliesBackward(ctx, channelID, rootID, availableMinID, filter, limit+start, false) - if err != nil || start >= len(items) { - return nil, err - } - return items[start:], nil - } -} - -func (s *ChannelStore) queryChannelRepliesBackward(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { - if limit <= 0 { - return nil, nil - } - where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) - where, args = appendChannelRepliesBackwardOffset(where, args, filter, includeOffset) - args = append(args, limit) - return s.queryChannelReplies(ctx, where, args, "DESC") -} - -func (s *ChannelStore) queryChannelRepliesForward(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int) ([]domain.ChannelMessage, error) { - if limit <= 0 { - return nil, nil - } - where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) - where, args = appendChannelRepliesForwardOffset(where, args, filter) - args = append(args, limit) - out, err := s.queryChannelReplies(ctx, where, args, "ASC") - if err != nil { - return nil, err - } - sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) - return out, nil -} - -func (s *ChannelStore) queryChannelReplies(ctx context.Context, where string, args []any, order string) ([]domain.ChannelMessage, error) { - rows, err := s.db.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE `+where+` -ORDER BY id `+order+` -LIMIT $`+fmt.Sprint(len(args)), args...) - if err != nil { - return nil, fmt.Errorf("list channel replies: %w", err) - } - defer rows.Close() - out := make([]domain.ChannelMessage, 0) - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - return nil, err - } - out = append(out, msg) - } - if err := rows.Err(); err != nil { - return nil, err - } - return out, nil -} - -func channelRepliesBaseWhere(channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter) (string, []any) { - args := []any{channelID, rootID} - where := "channel_id = $1 AND reply_to_top_id = $2 AND NOT deleted" - if availableMinID > 0 { - args = append(args, availableMinID) - where += fmt.Sprintf(" AND id > $%d", len(args)) - } - if filter.MaxID > 0 { - args = append(args, filter.MaxID) - where += fmt.Sprintf(" AND id < $%d", len(args)) - } - if filter.MinID > 0 { - args = append(args, filter.MinID) - where += fmt.Sprintf(" AND id > $%d", len(args)) - } - return where, args -} - -func appendChannelRepliesBackwardOffset(where string, args []any, filter domain.ChannelRepliesFilter, include bool) (string, []any) { - if filter.OffsetDate > 0 { - args = append(args, filter.OffsetDate) - if include { - return where + fmt.Sprintf(" AND message_date <= $%d", len(args)), args - } - return where + fmt.Sprintf(" AND message_date < $%d", len(args)), args - } - if filter.OffsetID > 0 { - args = append(args, filter.OffsetID) - if include { - return where + fmt.Sprintf(" AND id <= $%d", len(args)), args - } - return where + fmt.Sprintf(" AND id < $%d", len(args)), args - } - return where, args -} - -func appendChannelRepliesForwardOffset(where string, args []any, filter domain.ChannelRepliesFilter) (string, []any) { - if filter.OffsetDate > 0 { - args = append(args, filter.OffsetDate) - return where + fmt.Sprintf(" AND message_date >= $%d", len(args)), args - } - if filter.OffsetID > 0 { - args = append(args, filter.OffsetID) - return where + fmt.Sprintf(" AND id > $%d", len(args)), args - } - return where + " AND false", args -} - -type messageHistoryLoad int - -const ( - messageHistoryLoadBackward messageHistoryLoad = iota - messageHistoryLoadForward - messageHistoryLoadAround -) - -func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad { - if addOffset >= 0 { - return messageHistoryLoadBackward - } - if addOffset+limit > 0 { - return messageHistoryLoadAround - } - return messageHistoryLoadForward -} - -func channelMessageLess(a, b domain.ChannelMessage) bool { - if a.Date != b.Date { - return a.Date > b.Date - } - return a.ID > b.ID -} - -func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX, req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel) (*domain.MessageReply, error) { - if req.ReplyTo == nil { - return nil, nil - } - if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { - return nil, err - } - peer := req.ReplyTo.Peer - channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID} - if peer.ID == 0 { - peer = channelPeer - } - if peer != channelPeer { - return nil, domain.ErrReplyMessageIDInvalid - } - if req.ReplyTo.MessageID == 0 { - if req.ReplyTo.TopMessageID <= 0 || !channel.Forum { - return nil, domain.ErrReplyMessageIDInvalid - } - topic, err := s.getForumTopic(ctx, db, req.ChannelID, req.ReplyTo.TopMessageID) - if err != nil { - return nil, domain.ErrReplyMessageIDInvalid - } - if topic.Hidden { - return nil, domain.ErrReplyMessageIDInvalid - } - if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { - return nil, domain.ErrChannelWriteForbidden - } - reply := cloneMessageReply(req.ReplyTo) - reply.MessageID = 0 - reply.Peer = channelPeer - reply.TopMessageID = topic.TopicID - reply.ForumTopic = true - return reply, nil - } - target, err := s.getChannelMessage(ctx, db, req.ChannelID, req.ReplyTo.MessageID) - if err != nil { - if errors.Is(err, domain.ErrMessageIDInvalid) || errors.Is(err, pgx.ErrNoRows) { - return nil, domain.ErrReplyMessageIDInvalid - } - return nil, err - } - if target.Deleted || target.ID <= member.AvailableMinID { - return nil, domain.ErrReplyMessageIDInvalid - } - reply := cloneMessageReply(req.ReplyTo) - reply.MessageID = target.ID - reply.Peer = channelPeer - reply.TopMessageID = target.ID - if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 { - reply.TopMessageID = target.ReplyTo.TopMessageID - } - if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID { - return nil, domain.ErrReplyMessageIDInvalid - } - if channel.Forum && reply.TopMessageID > 0 { - if topic, err := s.getForumTopic(ctx, db, req.ChannelID, reply.TopMessageID); err == nil && !topic.Hidden { - if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { - return nil, domain.ErrChannelWriteForbidden - } - reply.ForumTopic = true - } else if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) { - return nil, err - } - } - return reply, nil -} - -func (s *ChannelStore) duplicateChannelMessage(ctx context.Context, channelID, userID, randomID int64) (domain.SendChannelMessageResult, bool, error) { - row := s.db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND sender_user_id = $2 AND random_id = $3`, channelID, userID, randomID) - msg, err := scanChannelMessage(row) - if errors.Is(err, pgx.ErrNoRows) { - return domain.SendChannelMessageResult{}, false, nil - } - if err != nil { - return domain.SendChannelMessageResult{}, false, err - } - channel, err := getChannelByID(ctx, s.db, channelID) - if err != nil { - return domain.SendChannelMessageResult{}, false, err - } - event, err := s.eventForChannelMessage(ctx, channelID, msg.ID) - if err != nil { - return domain.SendChannelMessageResult{}, false, err - } - if event.Message.ID != 0 { - msg = event.Message - } - return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Duplicate: true}, true, nil -} - -func (s *ChannelStore) eventForChannelMessage(ctx context.Context, channelID int64, messageID int) (domain.ChannelUpdateEvent, error) { - row := s.db.QueryRow(ctx, ` -SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text -FROM channel_update_events -WHERE channel_id = $1 AND message_id = $2 AND event_type = $3 -ORDER BY pts ASC LIMIT 1`, channelID, messageID, string(domain.ChannelUpdateNewMessage)) - event, _, err := scanChannelEvent(row) - return event, err -} - -func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, channel domain.Channel, senderUserID int64, date int, action domain.ChannelMessageAction, reserved *[]reservedChannelPts) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) { - msgID, err := s.msgIDs.NextChannelMessageID(ctx, channel.ID) - if err != nil { - return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service message id: %w", err) - } - pts, err := s.pts.NextChannelPts(ctx, channel.ID) - if err != nil { - return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service pts: %w", err) - } - reserveChannelPts(reserved, channel.ID, pts, 1) - msg := domain.ChannelMessage{ - ChannelID: channel.ID, - ID: msgID, - SenderUserID: senderUserID, - From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, - Date: date, - Post: channel.Broadcast, - Action: &action, - Pts: pts, - } - event := domain.ChannelUpdateEvent{ - ChannelID: channel.ID, - Type: domain.ChannelUpdateNewMessage, - Pts: pts, - PtsCount: 1, - Date: date, - Message: msg, - SenderUserID: senderUserID, - UserIDs: append([]int64(nil), action.UserIDs...), - } - if err := insertChannelMessageTx(ctx, tx, msg); err != nil { - return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err - } - if err := insertChannelEventTx(ctx, tx, event); err != nil { - return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err - } - if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, channel.ID, msgID, pts); err != nil { - return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("update channel service top: %w", err) - } - return msg, event, nil -} - -func transientChannelParticipantEvent(channelID, actorUserID int64, previous, participant domain.ChannelMember, date int) domain.ChannelUpdateEvent { - return domain.ChannelUpdateEvent{ - ChannelID: channelID, - Type: domain.ChannelUpdateParticipant, - Date: date, - SenderUserID: actorUserID, - UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID), - Previous: previous, - Participant: participant, - } -} - -func (s *ChannelStore) deleteChannelMessagesTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember, ids []int, actorUserID int64, date int, reserved *[]reservedChannelPts) ([]int, domain.ChannelUpdateEvent, domain.Channel, error) { - if len(ids) == 0 { - return nil, domain.ChannelUpdateEvent{}, channel, nil - } - id32, ordered, err := validUniqueChannelMessageIDs(ids) - if err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, err - } - rows, err := tx.Query(ctx, ` -SELECT `+channelMessageColumns+` -FROM channel_messages -WHERE channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted -ORDER BY id`, channel.ID, id32) - if err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("list channel messages for delete: %w", err) - } - byID := make(map[int]domain.ChannelMessage, len(ordered)) - for rows.Next() { - msg, err := scanChannelMessage(rows) - if err != nil { - rows.Close() - return nil, domain.ChannelUpdateEvent{}, channel, err - } - byID[msg.ID] = msg - } - if err := rows.Err(); err != nil { - rows.Close() - return nil, domain.ChannelUpdateEvent{}, channel, err - } - rows.Close() - deleted := make([]int, 0, len(ordered)) - for _, id := range ordered { - msg, ok := byID[id] - if !ok { - continue - } - if msg.SenderUserID != actorUserID && !canDeleteAnyChannelMessage(member) { - return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrChannelAdminRequired - } - deleted = append(deleted, id) - if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ - ChannelID: channel.ID, - UserID: actorUserID, - Date: date, - Type: domain.ChannelAdminLogDeleteMessage, - Message: &msg, - Query: msg.Body, - }); err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, err - } - } - if len(deleted) == 0 { - return nil, domain.ChannelUpdateEvent{}, channel, nil - } - pts, err := s.nextChannelPtsN(ctx, channel.ID, len(deleted)) - if err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("allocate channel delete pts: %w", err) - } - reserveChannelPts(reserved, channel.ID, pts, len(deleted)) - deleted32 := int32s(deleted) - if _, err := tx.Exec(ctx, ` -UPDATE channel_messages -SET deleted = true, pts = $3, updated_at = now() -WHERE channel_id = $1 AND id = ANY($2::int[])`, channel.ID, deleted32, pts); err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("soft delete channel messages: %w", err) - } - if err := deleteChannelUnreadMentionsTx(ctx, tx, channel.ID, deleted); err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, err - } - if err := refreshChannelUnreadReactionsCountsForMessagesTx(ctx, tx, channel.ID, deleted); err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, err - } - topID, err := topNonDeletedChannelMessageID(ctx, tx, channel.ID) - if err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, err - } - if _, err := tx.Exec(ctx, ` -UPDATE channels -SET top_message_id = $2, pts = $3, updated_at = now() -WHERE id = $1`, channel.ID, topID, pts); err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("update channel top after delete: %w", err) - } - channel.TopMessageID = topID - channel.Pts = pts - event := domain.ChannelUpdateEvent{ - ChannelID: channel.ID, - Type: domain.ChannelUpdateDeleteMessages, - Pts: pts, - PtsCount: len(deleted), - Date: date, - MessageIDs: append([]int(nil), deleted...), - SenderUserID: actorUserID, - } - if err := insertChannelEventTx(ctx, tx, event); err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, err - } - return deleted, event, channel, nil -} - -func (s *ChannelStore) nextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) { - if count <= 1 { - return s.pts.NextChannelPts(ctx, channelID) - } - if ranges, ok := s.pts.(store.ChannelPtsRangeAllocator); ok { - return ranges.NextChannelPtsN(ctx, channelID, count) - } - var pts int - var err error - for i := 0; i < count; i++ { - pts, err = s.pts.NextChannelPts(ctx, channelID) - if err != nil { - return 0, err - } - } - return pts, nil -} - -type reservedChannelPts struct { - channelID int64 - pts int - count int -} - -func reserveChannelPts(items *[]reservedChannelPts, channelID int64, pts, count int) { - if items == nil || channelID == 0 || pts == 0 { - return - } - if count <= 0 { - count = 1 - } - *items = append(*items, reservedChannelPts{channelID: channelID, pts: pts, count: count}) -} - -func (s *ChannelStore) recordChannelPtsGaps(ctx context.Context, items []reservedChannelPts, date int) { - if len(items) == 0 { - return - } - if date == 0 { - date = nowUnix() - } - for _, item := range items { - count := item.count - if count <= 0 { - count = 1 - } - _, _ = s.db.Exec(ctx, ` -INSERT INTO channel_update_events ( - channel_id, pts, pts_count, date, event_type, message_id, message_ids, sender_user_id, user_ids, payload -) VALUES ($1,$2,$3,$4,$5,0,'[]'::jsonb,0,'[]'::jsonb,'{}'::jsonb) -ON CONFLICT (channel_id, pts) DO NOTHING`, - item.channelID, item.pts, count, date, string(domain.ChannelUpdateNoop)) - } -} - -func validUniqueChannelMessageIDs(ids []int) ([]int32, []int, error) { - seen := make(map[int]struct{}, len(ids)) - out := make([]int, 0, len(ids)) - for _, id := range ids { - if id <= 0 || id > domain.MaxMessageBoxID { - return nil, nil, domain.ErrMessageIDInvalid - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return int32s(out), out, nil -} - -func topNonDeletedChannelMessageID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (int, error) { - var id int - if err := db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&id); err != nil { - return 0, fmt.Errorf("select channel top after delete: %w", err) - } - return id, nil -} - -func visibleChannelTopAfter(ctx context.Context, db sqlcgen.DBTX, channelID int64, availableMinID int, fallbackDate int) (int, int, error) { - var id, date int - err := db.QueryRow(ctx, ` -SELECT id, message_date -FROM channel_messages -WHERE channel_id = $1 AND id > $2 AND NOT deleted -ORDER BY id DESC -LIMIT 1`, channelID, availableMinID).Scan(&id, &date) - if errors.Is(err, pgx.ErrNoRows) { - return 0, fallbackDate, nil - } - if err != nil { - return 0, 0, fmt.Errorf("select visible channel top: %w", err) - } - return id, date, nil -} - -func insertChannelTx(ctx context.Context, tx pgx.Tx, ch domain.Channel) error { - rights, err := marshalJSON(ch.DefaultBannedRights, "{}") - if err != nil { - return err - } - reactions, err := marshalJSON(ch.ReactionPolicy, "{}") - if err != nil { - return err - } - if _, err := tx.Exec(ctx, ` -INSERT INTO channels ( - id, access_hash, creator_user_id, title, about, username, broadcast, megagroup, forum, forum_tabs, - autotranslation, restricted_sponsored, broadcast_messages_allowed, send_paid_messages_stars, - noforwards, join_to_send, join_request, signatures, pre_history_hidden, participants_hidden, antispam, linked_chat_id, slowmode_seconds, default_banned_rights, available_reactions, - color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, emoji_status_document_id, emoji_status_until, - participants_count, admins_count, kicked_count, banned_count, top_message_id, pinned_message_id, pts, ttl_period, date, deleted -) VALUES ($1,$2,$3,$4,$5,NULLIF($6,''),$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,$42,$43)`, - ch.ID, ch.AccessHash, ch.CreatorUserID, ch.Title, ch.About, ch.Username, ch.Broadcast, ch.Megagroup, ch.Forum, - ch.ForumTabs, ch.Autotranslation, ch.RestrictedSponsored, ch.BroadcastMessagesAllowed, ch.SendPaidMessagesStars, ch.NoForwards, ch.JoinToSend, ch.JoinRequest, ch.Signatures, ch.PreHistoryHidden, ch.ParticipantsHidden, ch.AntiSpam, ch.LinkedChatID, ch.SlowmodeSeconds, rights, reactions, - ch.Color.HasColor, ch.Color.Color, ch.Color.BackgroundEmojiID, ch.ProfileColor.HasColor, ch.ProfileColor.Color, ch.ProfileColor.BackgroundEmojiID, ch.EmojiStatus.DocumentID, ch.EmojiStatus.Until, - ch.ParticipantsCount, ch.AdminsCount, - ch.KickedCount, ch.BannedCount, ch.TopMessageID, ch.PinnedMessageID, ch.Pts, ch.TTLPeriod, ch.Date, ch.Deleted); err != nil { - return fmt.Errorf("insert channel: %w", err) - } - return nil -} - -func upsertChannelMemberTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember) error { - adminRights, err := marshalJSON(member.AdminRights, "{}") - if err != nil { - return err - } - bannedRights, err := marshalJSON(member.BannedRights, "{}") - if err != nil { - return err - } - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_members ( - channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights, banned_rights, - rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) -ON CONFLICT (channel_id, user_id) DO UPDATE SET - inviter_user_id = EXCLUDED.inviter_user_id, - role = EXCLUDED.role, - status = EXCLUDED.status, - joined_at = EXCLUDED.joined_at, - left_at = EXCLUDED.left_at, - admin_rights = EXCLUDED.admin_rights, - banned_rights = EXCLUDED.banned_rights, - rank = EXCLUDED.rank, - available_min_id = GREATEST(channel_members.available_min_id, EXCLUDED.available_min_id), - available_min_pts = GREATEST(channel_members.available_min_pts, EXCLUDED.available_min_pts), - read_inbox_max_id = GREATEST(channel_members.read_inbox_max_id, EXCLUDED.read_inbox_max_id), - updated_at = now()`, - member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status), - member.JoinedAt, member.LeftAt, adminRights, bannedRights, member.Rank, member.AvailableMinID, - member.AvailableMinPts, member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil { - return fmt.Errorf("upsert channel member: %w", err) - } - return upsertUserChannelMemberIndexTx(ctx, tx, channel, member) -} - -func upsertUserChannelMemberIndexTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember) error { - if channel.ID == 0 || member.UserID == 0 { - return nil - } - if _, err := tx.Exec(ctx, ` -INSERT INTO user_channel_member_index ( - user_id, channel_id, status, megagroup, broadcast, deleted -) VALUES ($1,$2,$3,$4,$5,$6) -ON CONFLICT (user_id, channel_id) DO UPDATE SET - status = EXCLUDED.status, - megagroup = EXCLUDED.megagroup, - broadcast = EXCLUDED.broadcast, - deleted = EXCLUDED.deleted, - updated_at = now()`, - member.UserID, channel.ID, string(member.Status), channel.Megagroup, channel.Broadcast, channel.Deleted); err != nil { - return fmt.Errorf("upsert user channel member index: %w", err) - } - return nil -} - -func markUserChannelMemberIndexDeletedTx(ctx context.Context, tx pgx.Tx, channelID int64, deleted bool) error { - if channelID == 0 { - return nil - } - if _, err := tx.Exec(ctx, ` -UPDATE user_channel_member_index -SET deleted = $2, updated_at = now() -WHERE channel_id = $1`, channelID, deleted); err != nil { - return fmt.Errorf("mark user channel member index deleted: %w", err) - } - return nil -} - -func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage) error { - entities, err := encodeMessageEntities(msg.Entities) - if err != nil { - return err - } - reply, err := marshalJSON(msg.ReplyTo, "{}") - if err != nil { - return err - } - forward, err := marshalJSON(msg.Forward, "{}") - if err != nil { - return err - } - action, err := marshalJSON(msg.Action, "{}") - if err != nil { - return err - } - media, err := encodeMessageMedia(msg.Media) - if err != nil { - return err - } - var sendAsType sql.NullString - var sendAsID sql.NullInt64 - if msg.SendAs != nil && msg.SendAs.ID != 0 { - sendAsType = sql.NullString{String: string(msg.SendAs.Type), Valid: true} - sendAsID = sql.NullInt64{Int64: msg.SendAs.ID, Valid: true} - } - if msg.From.Type == "" { - msg.From = domain.Peer{Type: domain.PeerTypeUser, ID: msg.SenderUserID} - } - replyMsgID, replyTopID := 0, 0 - replyPeerType := "" - replyPeerID := int64(0) - if msg.ReplyTo != nil { - replyMsgID = msg.ReplyTo.MessageID - replyTopID = msg.ReplyTo.TopMessageID - replyPeerType = string(msg.ReplyTo.Peer.Type) - replyPeerID = msg.ReplyTo.Peer.ID - } - discussionChannelID, discussionMessageID := int64(0), 0 - if msg.Discussion != nil { - discussionChannelID = msg.Discussion.ChannelID - discussionMessageID = msg.Discussion.MessageID - } - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_messages ( - channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, - send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, - body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, - fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27)`, - msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID, - sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards, - msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID, - forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media); err != nil { - return fmt.Errorf("insert channel message: %w", err) - } - return nil -} - -func updateForumTopicTopMessageTx(ctx context.Context, tx pgx.Tx, channelID int64, msg domain.ChannelMessage) error { - if msg.ReplyTo == nil || !msg.ReplyTo.ForumTopic || msg.ReplyTo.TopMessageID <= 0 { - return nil - } - if _, err := tx.Exec(ctx, ` -UPDATE channel_forum_topics -SET top_message_id = $3, - date = $4, - updated_at = now() -WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, - channelID, msg.ReplyTo.TopMessageID, msg.ID, msg.Date); err != nil { - return fmt.Errorf("update forum topic top message: %w", err) - } - return nil -} - -func insertChannelEventTx(ctx context.Context, tx pgx.Tx, event domain.ChannelUpdateEvent) error { - ids, err := marshalJSON(event.MessageIDs, "[]") - if err != nil { - return err - } - userIDs, err := marshalJSON(event.UserIDs, "[]") - if err != nil { - return err - } - payloadData := map[string]any{ - "message_id": event.Message.ID, - "pinned": event.Pinned, - } - if event.Message.ID != 0 { - payloadData["message"] = event.Message - } - if event.Previous.UserID != 0 { - payloadData["previous_participant"] = event.Previous - } - if event.Participant.UserID != 0 { - payloadData["participant"] = event.Participant - } - payload, err := marshalJSON(payloadData, "{}") - if err != nil { - return err - } - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_update_events ( - channel_id, pts, pts_count, date, event_type, message_id, message_ids, sender_user_id, user_ids, payload -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, - event.ChannelID, event.Pts, event.PtsCount, event.Date, string(event.Type), event.Message.ID, - ids, event.SenderUserID, userIDs, payload); err != nil { - return fmt.Errorf("insert channel event: %w", err) - } - return nil -} - -func (s *ChannelStore) insertChannelAdminLogTx(ctx context.Context, tx pgx.Tx, event domain.ChannelAdminLogEvent) error { - if event.ChannelID == 0 || event.UserID == 0 || event.Type == "" { - return nil - } - if event.Date == 0 { - event.Date = nowUnix() - } - id, err := nextChannelAdminLogIDTx(ctx, tx, event.ChannelID) - if err != nil { - return err - } - prevParticipant, err := marshalJSON(event.PrevParticipant, "{}") - if err != nil { - return err - } - newParticipant, err := marshalJSON(event.NewParticipant, "{}") - if err != nil { - return err - } - participant, err := marshalJSON(event.Participant, "{}") - if err != nil { - return err - } - message, err := marshalJSON(event.Message, "{}") - if err != nil { - return err - } - prevMessage, err := marshalJSON(event.PrevMessage, "{}") - if err != nil { - return err - } - newMessage, err := marshalJSON(event.NewMessage, "{}") - if err != nil { - return err - } - query := adminLogSearchText(event) - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_admin_log_events ( - channel_id, id, actor_user_id, event_date, event_type, prev_string, new_string, - prev_bool, new_bool, prev_int, new_int, prev_participant, new_participant, - participant, message, prev_message, new_message, query -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, - event.ChannelID, id, event.UserID, event.Date, string(event.Type), event.PrevString, event.NewString, - event.PrevBool, event.NewBool, event.PrevInt, event.NewInt, prevParticipant, newParticipant, - participant, message, prevMessage, newMessage, query); err != nil { - return fmt.Errorf("insert channel admin log: %w", err) - } - return nil -} - -func nextChannelAdminLogIDTx(ctx context.Context, tx pgx.Tx, channelID int64) (int64, error) { - var id int64 - if err := tx.QueryRow(ctx, ` -UPDATE channels -SET admin_log_seq = admin_log_seq + 1, updated_at = now() -WHERE id = $1 -RETURNING admin_log_seq`, channelID).Scan(&id); err != nil { - return 0, fmt.Errorf("allocate channel admin log id: %w", err) - } - return id, nil -} - -func upsertChannelDialogTx(ctx context.Context, tx pgx.Tx, userID int64, channel domain.Channel, top domain.ChannelMessage, readInboxMaxID, readOutboxMaxID int) error { - topDate := top.Date - if topDate == 0 { - topDate = channel.Date - } - unread, err := countChannelUnreadMessages(ctx, tx, userID, channel.ID, readInboxMaxID, channel.TopMessageID) - if err != nil { - return err - } - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_dialogs ( - user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark -) VALUES ($1,$2,$3,$4,$5,$6,$7,false) -ON CONFLICT (user_id, channel_id) DO UPDATE SET - top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), - top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), - read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), - read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), - unread_count = ( - SELECT COUNT(*)::int - FROM channel_messages msg - WHERE msg.channel_id = channel_dialogs.channel_id - AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) - AND msg.id <= GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id) - AND NOT msg.deleted - AND msg.sender_user_id <> channel_dialogs.user_id - ), - unread_mark = false, - updated_at = now()`, - userID, channel.ID, channel.TopMessageID, topDate, readInboxMaxID, readOutboxMaxID, unread); err != nil { - return fmt.Errorf("upsert channel dialog: %w", err) - } - return nil -} - -func upsertChannelDialogsForMessageTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, selfReadUserID int64) error { - if channel.ID == 0 || top.ID == 0 { - return nil - } - if !shouldSynchronouslyUpsertChannelDialogs(channel) { - return nil - } - topDate := top.Date - if topDate == 0 { - topDate = channel.Date - } - if _, err := tx.Exec(ctx, ` -WITH active AS ( - SELECT - m.user_id, - CASE - WHEN m.user_id = $4 THEN GREATEST(m.read_inbox_max_id, $2) - ELSE m.read_inbox_max_id - END AS read_inbox_max_id, - CASE - WHEN m.user_id = $4 THEN GREATEST(m.read_outbox_max_id, $2) - ELSE m.read_outbox_max_id - END AS read_outbox_max_id - FROM channel_members m - WHERE m.channel_id = $1 - AND m.status = 'active' - AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) - AND $2 > m.available_min_id -) -INSERT INTO channel_dialogs ( - user_id, channel_id, top_message_id, top_message_date, - read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark -) -SELECT - user_id, $1, $2, $3, - read_inbox_max_id, read_outbox_max_id, - ( - SELECT COUNT(*)::int - FROM channel_messages msg - WHERE msg.channel_id = $1 - AND msg.id > active.read_inbox_max_id - AND msg.id <= $2 - AND NOT msg.deleted - AND msg.sender_user_id <> active.user_id - ), - false -FROM active -ON CONFLICT (user_id, channel_id) DO UPDATE SET - top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), - top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), - read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), - read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), - unread_count = ( - SELECT COUNT(*)::int - FROM channel_messages msg - WHERE msg.channel_id = channel_dialogs.channel_id - AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) - AND msg.id <= GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id) - AND NOT msg.deleted - AND msg.sender_user_id <> channel_dialogs.user_id - ), - unread_mark = CASE WHEN channel_dialogs.user_id = $4 THEN false ELSE channel_dialogs.unread_mark END, - updated_at = now()`, channel.ID, top.ID, topDate, selfReadUserID); err != nil { - return fmt.Errorf("upsert channel message dialogs: %w", err) - } - return nil -} - -func shouldSynchronouslyUpsertChannelDialogs(channel domain.Channel) bool { - if channel.Broadcast { - return false - } - return channel.ParticipantsCount > 0 && channel.ParticipantsCount <= domain.MaxSynchronousChannelDialogFanout -} - -func insertChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) error { - candidates := uniqueChannelUserIDs(userIDs, senderUserID) - if len(candidates) == 0 || msg.ID == 0 { - return nil - } - if len(candidates) > domain.MaxChannelMentionRecipients { - candidates = candidates[:domain.MaxChannelMentionRecipients] - } - topID := channelMentionTopID(msg) - mediaUnread := !msg.Media.IsZero() - if _, err := tx.Exec(ctx, ` -WITH input(user_id) AS ( - SELECT DISTINCT unnest($4::bigint[]) -), -active AS ( - SELECT i.user_id - FROM input i - JOIN channel_members m ON m.channel_id = $1 AND m.user_id = i.user_id - WHERE m.status = 'active' - AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) - AND $2 > m.available_min_id - AND $2 > m.read_inbox_max_id - LIMIT $6 -), -inserted AS ( - INSERT INTO channel_unread_mentions (user_id, channel_id, message_id, top_message_id, media_unread) - SELECT user_id, $1, $2, $3, $7 - FROM active - ON CONFLICT DO NOTHING - RETURNING user_id -) -INSERT INTO channel_dialogs ( - user_id, channel_id, top_message_id, top_message_date, unread_mentions_count -) -SELECT user_id, $1, $2, $5, 1 -FROM inserted -ON CONFLICT (user_id, channel_id) DO UPDATE SET - top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), - top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), - unread_mentions_count = channel_dialogs.unread_mentions_count + 1, - updated_at = now()`, channelID, msg.ID, topID, candidates, msg.Date, domain.MaxChannelMentionRecipients, mediaUnread); err != nil { - return fmt.Errorf("insert channel unread mentions: %w", err) - } - return nil -} - -func readChannelMentionsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, topMsgID, limit int) (int, int, error) { - var cleared, remaining int - if err := tx.QueryRow(ctx, ` -WITH target AS ( - SELECT user_id, channel_id, message_id - FROM channel_unread_mentions - WHERE user_id = $1 - AND channel_id = $2 - AND ($3 = 0 OR top_message_id = $3) - ORDER BY message_id DESC - LIMIT $4 -), -deleted AS ( - DELETE FROM channel_unread_mentions um - USING target t - WHERE um.user_id = t.user_id - AND um.channel_id = t.channel_id - AND um.message_id = t.message_id - RETURNING um.message_id -), -remaining_scoped AS ( - SELECT COUNT(*)::int AS count - FROM channel_unread_mentions - WHERE user_id = $1 - AND channel_id = $2 - AND ($3 = 0 OR top_message_id = $3) -), -remaining_all AS ( - SELECT COUNT(*)::int AS count - FROM channel_unread_mentions - WHERE user_id = $1 AND channel_id = $2 -), -updated_dialog AS ( - UPDATE channel_dialogs - SET unread_mentions_count = (SELECT count FROM remaining_all), - updated_at = now() - WHERE user_id = $1 AND channel_id = $2 -) -SELECT (SELECT COUNT(*)::int FROM deleted), (SELECT count FROM remaining_scoped)`, userID, channelID, topMsgID, limit).Scan(&cleared, &remaining); err != nil { - return 0, 0, fmt.Errorf("read channel mentions: %w", err) - } - return cleared, remaining, nil -} - -func readChannelReactionsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, topMsgID, limit int) (int, int, error) { - var cleared, remaining int - if err := tx.QueryRow(ctx, ` -WITH member_scope AS ( - SELECT available_min_id - FROM channel_members - WHERE user_id = $1 AND channel_id = $2 -), -target_messages AS ( - SELECT DISTINCT r.message_id - FROM channel_message_reactions r - JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id - JOIN member_scope ms ON true - WHERE r.sender_user_id = $1 - AND r.channel_id = $2 - AND r.unread - AND r.reacted_user_id <> $1 - AND cm.id > ms.available_min_id - AND NOT cm.deleted - AND ($3 = 0 OR cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3) - ORDER BY r.message_id DESC - LIMIT $4 -), -updated AS ( - UPDATE channel_message_reactions r - SET unread = false, - updated_at = now() - WHERE r.sender_user_id = $1 - AND r.channel_id = $2 - AND r.message_id IN (SELECT message_id FROM target_messages) - AND r.unread - RETURNING r.message_id -), -remaining_scoped AS ( - SELECT COUNT(DISTINCT r.message_id)::int AS count - FROM channel_message_reactions r - JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id - JOIN member_scope ms ON true - WHERE r.sender_user_id = $1 - AND r.channel_id = $2 - AND r.unread - AND r.reacted_user_id <> $1 - AND cm.id > ms.available_min_id - AND NOT cm.deleted - AND ($3 = 0 OR cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3) -), -remaining_all AS ( - SELECT COUNT(DISTINCT r.message_id)::int AS count - FROM channel_message_reactions r - JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id - JOIN member_scope ms ON true - WHERE r.sender_user_id = $1 - AND r.channel_id = $2 - AND r.unread - AND r.reacted_user_id <> $1 - AND cm.id > ms.available_min_id - AND NOT cm.deleted -), -updated_dialog AS ( - UPDATE channel_dialogs - SET unread_reactions_count = (SELECT count FROM remaining_all), - updated_at = now() - WHERE user_id = $1 AND channel_id = $2 -) -SELECT (SELECT COUNT(DISTINCT message_id)::int FROM updated), (SELECT count FROM remaining_scoped)`, userID, channelID, topMsgID, limit).Scan(&cleared, &remaining); err != nil { - return 0, 0, fmt.Errorf("read channel reactions: %w", err) - } - return cleared, remaining, nil -} - -func clearChannelUnreadReactionsForMessageIDsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, ids []int32) ([]int, error) { - if userID == 0 || channelID == 0 || len(ids) == 0 { - return nil, nil - } - rows, err := tx.Query(ctx, ` -UPDATE channel_message_reactions -SET unread = false, - updated_at = now() -WHERE sender_user_id = $1 - AND channel_id = $2 - AND message_id = ANY($3::int[]) - AND unread - AND reacted_user_id <> $1 -RETURNING message_id`, userID, channelID, ids) - if err != nil { - return nil, fmt.Errorf("clear visible channel unread reactions: %w", err) - } - clearedSet := make(map[int]struct{}) - for rows.Next() { - var id int - if err := rows.Scan(&id); err != nil { - rows.Close() - return nil, err - } - clearedSet[id] = struct{}{} - } - if err := rows.Err(); err != nil { - rows.Close() - return nil, err - } - rows.Close() - if len(clearedSet) == 0 { - return nil, nil - } - cleared := make([]int, 0, len(clearedSet)) - for id := range clearedSet { - cleared = append(cleared, id) - } - sort.Sort(sort.Reverse(sort.IntSlice(cleared))) - if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil { - return nil, err - } - return cleared, nil -} - -func refreshChannelUnreadReactionsCountTx(ctx context.Context, tx pgx.Tx, userID, channelID int64) error { - if userID == 0 || channelID == 0 { - return nil - } - if _, err := tx.Exec(ctx, ` -WITH active AS ( - SELECT m.available_min_id - FROM channel_members m - WHERE m.user_id = $1 - AND m.channel_id = $2 - AND m.status = 'active' - AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) -), -counts AS ( - SELECT COUNT(DISTINCT r.message_id)::int AS count - FROM channel_message_reactions r - JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id - JOIN active a ON true - WHERE r.sender_user_id = $1 - AND r.channel_id = $2 - AND r.unread - AND r.reacted_user_id <> $1 - AND cm.id > a.available_min_id - AND NOT cm.deleted -) -INSERT INTO channel_dialogs (user_id, channel_id, unread_reactions_count) -SELECT $1, $2, counts.count -FROM active, counts -ON CONFLICT (user_id, channel_id) DO UPDATE SET - unread_reactions_count = EXCLUDED.unread_reactions_count, - updated_at = now()`, userID, channelID); err != nil { - return fmt.Errorf("refresh channel unread reactions count: %w", err) - } - return nil -} - -func deleteChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) error { - if len(ids) == 0 { - return nil - } - if _, err := tx.Exec(ctx, ` -WITH deleted AS ( - DELETE FROM channel_unread_mentions - WHERE channel_id = $1 AND message_id = ANY($2::int[]) - RETURNING user_id -), -affected AS ( - SELECT DISTINCT user_id FROM deleted -), -counts AS ( - SELECT user_id, COUNT(*)::int AS count - FROM channel_unread_mentions - WHERE channel_id = $1 - AND user_id IN (SELECT user_id FROM affected) - GROUP BY user_id -) -UPDATE channel_dialogs d -SET unread_mentions_count = COALESCE(c.count, 0), - updated_at = now() -FROM affected a -LEFT JOIN counts c ON c.user_id = a.user_id -WHERE d.channel_id = $1 AND d.user_id = a.user_id`, channelID, int32s(ids)); err != nil { - return fmt.Errorf("delete channel unread mentions: %w", err) - } - return nil -} - -func refreshChannelUnreadReactionsCountsForMessagesTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) error { - if len(ids) == 0 { - return nil - } - rows, err := tx.Query(ctx, ` -SELECT DISTINCT sender_user_id -FROM channel_message_reactions -WHERE channel_id = $1 - AND message_id = ANY($2::int[]) - AND sender_user_id <> 0`, channelID, int32s(ids)) - if err != nil { - return fmt.Errorf("list channel unread reaction owners: %w", err) - } - defer rows.Close() - userIDs := make([]int64, 0) - for rows.Next() { - var userID int64 - if err := rows.Scan(&userID); err != nil { - return err - } - userIDs = append(userIDs, userID) - } - if err := rows.Err(); err != nil { - return err - } - for _, userID := range userIDs { - if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil { - return err - } - } - return nil -} - -func deleteChannelUnreadMentionsUpToTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, maxID int) error { - if maxID <= 0 { - return nil - } - var deleted int - if err := tx.QueryRow(ctx, ` -WITH deleted AS ( - DELETE FROM channel_unread_mentions - WHERE user_id = $1 AND channel_id = $2 AND message_id <= $3 - RETURNING message_id -), -remaining_all AS ( - SELECT COUNT(*)::int AS count - FROM channel_unread_mentions - WHERE user_id = $1 AND channel_id = $2 -), -updated_dialog AS ( - UPDATE channel_dialogs - SET unread_mentions_count = (SELECT count FROM remaining_all), - updated_at = now() - WHERE user_id = $1 AND channel_id = $2 -) -SELECT COUNT(*)::int FROM deleted`, userID, channelID, maxID).Scan(&deleted); err != nil { - return fmt.Errorf("delete channel unread mentions up to: %w", err) - } - return nil -} - -func channelMentionTopID(msg domain.ChannelMessage) int { - if msg.ReplyTo == nil { - return 0 - } - if msg.ReplyTo.TopMessageID > 0 { - return msg.ReplyTo.TopMessageID - } - return msg.ReplyTo.MessageID -} - -func scanChannelWithMember(row rowScanner) (domain.Channel, domain.ChannelMember, error) { - var ch domain.Channel - var member domain.ChannelMember - var defaultRights, reactionPolicy, adminRights, bannedRights string - var role, status string - if err := row.Scan( - &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, - &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &defaultRights, - &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, - &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, - &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, - &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, - &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, - &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, - &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, - ); err != nil { - return domain.Channel{}, domain.ChannelMember{}, err - } - member.Role = domain.ChannelMemberRole(role) - member.Status = domain.ChannelMemberStatus(status) - _ = json.Unmarshal([]byte(defaultRights), &ch.DefaultBannedRights) - _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) - _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) - _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) - return ch, member, nil -} - -func scanChannel(row rowScanner) (domain.Channel, error) { - var ch domain.Channel - var rights, reactionPolicy string - if err := row.Scan( - &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, - &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, - &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, - &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, - &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, - &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, - ); err != nil { - return domain.Channel{}, err - } - _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) - _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) - return ch, nil -} - -func scanChannelWithViewerMember(row rowScanner) (domain.Channel, bool, error) { - var ch domain.Channel - var viewerMember bool - var rights, reactionPolicy string - if err := row.Scan( - &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, - &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, - &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, - &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, - &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, - &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, - &viewerMember, - ); err != nil { - return domain.Channel{}, false, err - } - _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) - _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) - return ch, viewerMember, nil -} - -func scanChannelMember(row rowScanner) (domain.ChannelMember, error) { - var member domain.ChannelMember - var adminRights, bannedRights string - var role, status string - if err := row.Scan( - &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, - &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, - &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, - ); err != nil { - return domain.ChannelMember{}, err - } - member.Role = domain.ChannelMemberRole(role) - member.Status = domain.ChannelMemberStatus(status) - _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) - _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) - return member, nil -} - -func discussionGroupUpdateResult(changed map[int64]domain.Channel) domain.DiscussionGroupUpdateResult { - ids := make([]int64, 0, len(changed)) - for id := range changed { - ids = append(ids, id) - } - sort.Slice(ids, func(i, j int) bool { - return ids[i] < ids[j] - }) - out := domain.DiscussionGroupUpdateResult{Channels: make([]domain.Channel, 0, len(ids))} - for _, id := range ids { - out.Channels = append(out.Channels, changed[id]) - } - return out -} - -func scanChannelMemberWithCount(row rowScanner) (domain.ChannelMember, int, error) { - var member domain.ChannelMember - var adminRights, bannedRights string - var role, status string - var count int - if err := row.Scan( - &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, - &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, - &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, - &count, - ); err != nil { - return domain.ChannelMember{}, 0, err - } - member.Role = domain.ChannelMemberRole(role) - member.Status = domain.ChannelMemberStatus(status) - _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) - _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) - return member, count, nil -} - -func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.Dialog, error) { - var ch domain.Channel - var rights, reactionPolicy string - var topID, topDate, folderID, readInbox, readOutbox, unreadCount, pinnedOrder, unreadMentions, unreadReactions int - var pinned, unreadMark, viewForumAsMessages bool - if err := row.Scan( - &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, - &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, - &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, - &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, - &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, - &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, - &topID, &topDate, - &folderID, &readInbox, &readOutbox, &unreadCount, &pinned, &pinnedOrder, &unreadMark, &unreadMentions, &unreadReactions, &viewForumAsMessages, - ); err != nil { - return domain.Channel{}, domain.Dialog{}, err - } - _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) - _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) - dialog := domain.Dialog{ - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}, - FolderID: folderID, - TopMessage: topID, - TopMessageDate: topDate, - ReadInboxMaxID: readInbox, - ReadOutboxMaxID: readOutbox, - UnreadCount: unreadCount, - UnreadMentions: unreadMentions, - UnreadReactions: unreadReactions, - Pinned: pinned, - PinnedOrder: pinnedOrder, - UnreadMark: unreadMark, - ViewForumAsMessages: viewForumAsMessages, - } - _ = userID - return ch, dialog, nil -} - -func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) { - var msg domain.ChannelMessage - var fromType string - var sendAsType sql.NullString - var sendAsID sql.NullInt64 - var replyMsgID, replyTopID int - var replyPeerType string - var replyPeerID int64 - var discussionChannelID int64 - var discussionMessageID int - var entities, reply, forward, action string - var mediaJSON string - if err := row.Scan( - &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, - &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, - &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, - &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, - ); err != nil { - return domain.ChannelMessage{}, err - } - msg.From.Type = domain.PeerType(fromType) - if sendAsType.Valid && sendAsID.Valid { - msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} - } - parsedEntities, err := decodeMessageEntities(entities) - if err != nil { - return domain.ChannelMessage{}, err - } - msg.Entities = parsedEntities - msg.ReplyTo = channelMessageReplyFromColumns(decodeJSONPtr[domain.MessageReply](reply), replyMsgID, replyPeerType, replyPeerID, replyTopID) - msg.Forward = decodeJSONPtr[domain.MessageForward](forward) - if discussionChannelID != 0 && discussionMessageID != 0 { - msg.Discussion = &domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: discussionMessageID} - } - msg.Action = decodeJSONPtr[domain.ChannelMessageAction](action) - msg.Media, err = decodeMessageMedia(mediaJSON) - if err != nil { - return domain.ChannelMessage{}, err - } - return msg, nil -} - -func scanChannelForumTopic(row rowScanner) (domain.ChannelForumTopic, error) { - var topic domain.ChannelForumTopic - if err := row.Scan( - &topic.ChannelID, - &topic.TopicID, - &topic.CreatorUserID, - &topic.Title, - &topic.IconColor, - &topic.IconEmojiID, - &topic.TitleMissing, - &topic.Closed, - &topic.Hidden, - &topic.Pinned, - &topic.PinnedOrder, - &topic.Date, - &topic.TopMessageID, - &topic.ReadInboxMaxID, - &topic.ReadOutboxMaxID, - &topic.UnreadCount, - &topic.UnreadMentionsCount, - &topic.UnreadReactionsCount, - &topic.UnreadPollVotesCount, - ); err != nil { - return domain.ChannelForumTopic{}, err - } - return topic, nil -} - -func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, error) { - var msg domain.ChannelMessage - var fromType string - var sendAsType sql.NullString - var sendAsID sql.NullInt64 - var replyMsgID, replyTopID int - var replyPeerType string - var replyPeerID int64 - var discussionChannelID int64 - var discussionMessageID int - var entities, reply, forward, action string - var count int - var mediaJSON string - if err := row.Scan( - &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, - &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, - &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, - &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, &count, - ); err != nil { - return domain.ChannelMessage{}, 0, err - } - msg.From.Type = domain.PeerType(fromType) - if sendAsType.Valid && sendAsID.Valid { - msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} - } - parsedEntities, err := decodeMessageEntities(entities) - if err != nil { - return domain.ChannelMessage{}, 0, err - } - msg.Entities = parsedEntities - msg.ReplyTo = channelMessageReplyFromColumns(decodeJSONPtr[domain.MessageReply](reply), replyMsgID, replyPeerType, replyPeerID, replyTopID) - msg.Forward = decodeJSONPtr[domain.MessageForward](forward) - if discussionChannelID != 0 && discussionMessageID != 0 { - msg.Discussion = &domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: discussionMessageID} - } - msg.Action = decodeJSONPtr[domain.ChannelMessageAction](action) - msg.Media, err = decodeMessageMedia(mediaJSON) - if err != nil { - return domain.ChannelMessage{}, 0, err - } - return msg, count, nil -} - -func scanChannelMessagePeerReaction(row rowScanner, viewerUserID int64) (domain.ChannelMessagePeerReaction, error) { - var out domain.ChannelMessagePeerReaction - var reactionType, reactionValue string - if err := row.Scan( - &out.ChannelID, - &out.MessageID, - &out.UserID, - &out.SenderUserID, - &reactionType, - &reactionValue, - &out.Big, - &out.Unread, - &out.ChosenOrder, - &out.Date, - ); err != nil { - return domain.ChannelMessagePeerReaction{}, err - } - out.My = out.UserID == viewerUserID - out.Reaction = domain.MessageReaction{ - Type: domain.MessageReactionType(reactionType), - Emoticon: reactionValue, - } - return out, nil -} - -func channelMessageReplyFromColumns(reply *domain.MessageReply, msgID int, peerType string, peerID int64, topID int) *domain.MessageReply { - if reply != nil { - if reply.MessageID == 0 { - reply.MessageID = msgID - } - if reply.TopMessageID == 0 { - reply.TopMessageID = topID - } - if reply.Peer.ID == 0 && peerType != "" && peerID != 0 { - reply.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID} - } - if reply.MessageID <= 0 && reply.TopMessageID <= 0 { - return nil - } - return reply - } - if msgID <= 0 && topID <= 0 { - return nil - } - out := &domain.MessageReply{ - MessageID: msgID, - TopMessageID: topID, - } - if peerType != "" && peerID != 0 { - out.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID} - } - return out -} - -func scanChannelEvent(row rowScanner) (domain.ChannelUpdateEvent, int, error) { - var event domain.ChannelUpdateEvent - var typ string - var messageID int - var messageIDs, userIDs, payload string - if err := row.Scan( - &event.ChannelID, &event.Pts, &event.PtsCount, &event.Date, &typ, &messageID, - &messageIDs, &event.SenderUserID, &userIDs, &payload, - ); err != nil { - return domain.ChannelUpdateEvent{}, 0, err - } - event.Type = domain.ChannelUpdateEventType(typ) - _ = json.Unmarshal([]byte(messageIDs), &event.MessageIDs) - _ = json.Unmarshal([]byte(userIDs), &event.UserIDs) - var data struct { - Pinned bool `json:"pinned"` - Message domain.ChannelMessage `json:"message"` - PreviousParticipant domain.ChannelMember `json:"previous_participant"` - Participant domain.ChannelMember `json:"participant"` - } - _ = json.Unmarshal([]byte(payload), &data) - event.Pinned = data.Pinned - if data.Message.ID != 0 { - event.Message = data.Message - } - event.Previous = data.PreviousParticipant - event.Participant = data.Participant - return event, messageID, nil -} - -func scanChannelAdminLogEvent(row rowScanner) (domain.ChannelAdminLogEvent, error) { - var event domain.ChannelAdminLogEvent - var typ string - var prevParticipant, newParticipant, participant, message, prevMessage, newMessage string - if err := row.Scan( - &event.ChannelID, &event.ID, &event.UserID, &event.Date, &typ, - &event.PrevString, &event.NewString, &event.PrevBool, &event.NewBool, - &event.PrevInt, &event.NewInt, &prevParticipant, &newParticipant, &participant, - &message, &prevMessage, &newMessage, &event.Query, - ); err != nil { - return domain.ChannelAdminLogEvent{}, err - } - event.Type = domain.ChannelAdminLogEventType(typ) - event.PrevParticipant = decodeJSONPtr[domain.ChannelMember](prevParticipant) - event.NewParticipant = decodeJSONPtr[domain.ChannelMember](newParticipant) - event.Participant = decodeJSONPtr[domain.ChannelMember](participant) - event.Message = decodeJSONPtr[domain.ChannelMessage](message) - event.PrevMessage = decodeJSONPtr[domain.ChannelMessage](prevMessage) - event.NewMessage = decodeJSONPtr[domain.ChannelMessage](newMessage) - return event, nil -} - -func channelDialogToDialog(dialog domain.ChannelDialog) domain.Dialog { - return domain.Dialog{ - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID}, - FolderID: dialog.FolderID, - TopMessage: dialog.TopMessageID, - TopMessageDate: dialog.TopMessageDate, - ReadInboxMaxID: dialog.ReadInboxMaxID, - ReadOutboxMaxID: dialog.ReadOutboxMaxID, - UnreadCount: dialog.UnreadCount, - UnreadMentions: dialog.UnreadMentions, - UnreadReactions: dialog.UnreadReactions, - Pinned: dialog.Pinned, - PinnedOrder: dialog.PinnedOrder, - UnreadMark: dialog.UnreadMark, - ViewForumAsMessages: dialog.ViewForumAsMessages, - } -} - -func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool { - if filter.HasFolderID { - if filter.FolderID < domain.DialogCustomFolderMinID { - if dialog.FolderID != filter.FolderID { - return false - } - } else if filter.Folder == nil { - return false - } - } - if filter.PinnedOnly && !dialog.Pinned { - return false - } - if filter.ExcludePinned && dialog.Pinned { - return false - } - if !channelDialogAfterOffset(dialog, filter) { - return false - } - if filter.Folder == nil { - return true - } - folder := filter.Folder - if peerInDialogFolder(dialog.Peer, folder.ExcludePeers) { - return false - } - if folder.ExcludeRead && dialog.UnreadCount == 0 && !dialog.UnreadMark { - return false - } - if folder.ExcludeArchived && dialog.FolderID == domain.DialogArchiveFolderID { - return false - } - if peerInDialogFolder(dialog.Peer, folder.IncludePeers) || peerInDialogFolder(dialog.Peer, folder.PinnedPeers) { - return true - } - if channel.Megagroup && folder.Groups { - return true - } - if channel.Broadcast && folder.Broadcasts { - return true - } - return !folder.Groups && !folder.Broadcasts && len(folder.IncludePeers) == 0 -} - -func channelDialogAfterOffset(dialog domain.Dialog, filter domain.DialogFilter) bool { - if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { - if filter.HasOffsetPeer && filter.OffsetPeer == dialog.Peer { - return false - } - return true - } - if filter.OffsetDate > 0 { - if dialog.TopMessageDate != filter.OffsetDate { - return dialog.TopMessageDate < filter.OffsetDate - } - if filter.OffsetID <= 0 { - return false - } - if dialog.TopMessage != filter.OffsetID { - return dialog.TopMessage < filter.OffsetID - } - if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { - return dialog.Peer.ID < filter.OffsetPeer.ID - } - return false - } - if dialog.TopMessage != filter.OffsetID { - return dialog.TopMessage < filter.OffsetID - } - if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { - return dialog.Peer.ID < filter.OffsetPeer.ID - } - return false -} - -func peerInDialogFolder(peer domain.Peer, items []domain.DialogFolderPeer) bool { - for _, item := range items { - if item.Peer == peer { - return true - } - } - return false -} - -func channelFolderPeerIDs(primary []domain.DialogFolderPeer, rest ...[]domain.DialogFolderPeer) []int64 { - total := len(primary) - for _, items := range rest { - total += len(items) - } - seen := make(map[int64]struct{}, minInt(total, domain.MaxDialogFolderPeers)) - out := make([]int64, 0, minInt(total, domain.MaxDialogFolderPeers)) - appendOne := func(items []domain.DialogFolderPeer) { - for _, item := range items { - if len(out) >= domain.MaxDialogFolderPeers { - return - } - if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { - continue - } - if _, ok := seen[item.Peer.ID]; ok { - continue - } - seen[item.Peer.ID] = struct{}{} - out = append(out, item.Peer.ID) - } - } - appendOne(primary) - for _, items := range rest { - appendOne(items) - } - return out -} - -func validateChannelMemberVisible(member domain.ChannelMember) error { - switch member.Status { - case domain.ChannelMemberActive: - if member.BannedRights.ViewMessages { - return domain.ErrChannelUserBanned - } - return nil - case domain.ChannelMemberBanned, domain.ChannelMemberKicked: - return domain.ErrChannelUserBanned - default: - return domain.ErrChannelPrivate - } -} - -func canPostChannel(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages) -} - -func canSendChannelMessage(channel domain.Channel, member domain.ChannelMember) bool { - if channel.Broadcast { - return canPostChannel(member) - } - if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { - return true - } - return !channel.DefaultBannedRights.SendMessages && !member.BannedRights.SendMessages -} - -func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool { - if member.Role == domain.ChannelRoleCreator || - (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) { - return true - } - return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !member.BannedRights.InviteUsers -} - -func isChannelAdmin(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin -} - -func canChangeChannelInfo(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo) -} - -func canManageDiscussionBroadcast(member domain.ChannelMember) bool { - return canChangeChannelInfo(member) -} - -func canManageDiscussionGroup(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) -} - -func validDiscussionGroup(channel domain.Channel) bool { - return channel.Megagroup && !channel.Broadcast && !channel.Forum && !channel.Deleted -} - -func canAddChannelAdmins(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.AddAdmins) -} - -func canBanChannelUsers(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.BanUsers) -} - -func canExportChannelInvite(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || - (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) -} - -func canPinChannelMessages(channel domain.Channel, member domain.ChannelMember) bool { - if member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) { - return true - } - return channel.Megagroup && !channel.DefaultBannedRights.PinMessages && !member.BannedRights.PinMessages -} - -func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64) bool { - if topic.CreatorUserID == userID { - return true - } - return canPinChannelMessages(channel, member) -} - -func canEditChannelMessage(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) -} - -func canDeleteAnyChannelMessage(member domain.ChannelMember) bool { - return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.DeleteMessages) -} - -func channelSlowModeWait(channel domain.Channel, member domain.ChannelMember, now int) int { - if channel.SlowmodeSeconds <= 0 || member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { - return 0 - } - next := member.SlowmodeLastSendDate + channel.SlowmodeSeconds - if now >= next { - return 0 - } - return next - now -} - -func boolPtr(v bool) *bool { - return &v -} - -func channelInitialAvailableMinID(channel domain.Channel) int { - if channel.PreHistoryHidden { - return channel.TopMessageID - } - return 0 -} - -func channelInitialAvailableMinPts(channel domain.Channel) int { - return channel.Pts -} - -func publicPreviewableChannel(channel domain.Channel) bool { - return !channel.Deleted && - (channel.Broadcast || channel.Megagroup) && - strings.TrimSpace(channel.Username) != "" -} - -func publicPreviewMember(channel domain.Channel, userID int64, existing domain.ChannelMember, found bool) domain.ChannelMember { - member := domain.ChannelMember{ - ChannelID: channel.ID, - UserID: userID, - Role: domain.ChannelRoleMember, - Status: domain.ChannelMemberLeft, - AvailableMinID: channelInitialAvailableMinID(channel), - AvailableMinPts: channelInitialAvailableMinPts(channel), - ReadInboxMaxID: channel.TopMessageID, - ReadOutboxMaxID: channel.TopMessageID, - } - if found { - member.InviterUserID = existing.InviterUserID - member.JoinedAt = existing.JoinedAt - member.LeftAt = existing.LeftAt - member.AvailableMinID = maxInt(member.AvailableMinID, existing.AvailableMinID) - member.AvailableMinPts = maxInt(member.AvailableMinPts, existing.AvailableMinPts) - member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, existing.ReadInboxMaxID) - member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, existing.ReadOutboxMaxID) - } - return member -} - -func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { - topMessageID := channel.TopMessageID - if topMessageID <= member.AvailableMinID { - topMessageID = 0 - } - return domain.ChannelDialog{ - UserID: userID, - ChannelID: channel.ID, - TopMessageID: topMessageID, - TopMessageDate: channel.Date, - ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID), - ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID), - } -} - -func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool { - return rights == domain.ChannelAdminRights{} -} - -func adminRightsSubset(want, have domain.ChannelAdminRights) bool { - return (!want.ChangeInfo || have.ChangeInfo) && - (!want.PostMessages || have.PostMessages) && - (!want.EditMessages || have.EditMessages) && - (!want.DeleteMessages || have.DeleteMessages) && - (!want.BanUsers || have.BanUsers) && - (!want.InviteUsers || have.InviteUsers) && - (!want.PinMessages || have.PinMessages) && - (!want.AddAdmins || have.AddAdmins) && - (!want.ManageCall || have.ManageCall) && - (!want.Anonymous || have.Anonymous) -} - -func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool { - return rights == domain.ChannelBannedRights{} -} - -func adminLogBanType(previous, next domain.ChannelMember) domain.ChannelAdminLogEventType { - if next.Status == domain.ChannelMemberKicked || next.BannedRights.ViewMessages { - return domain.ChannelAdminLogParticipantKick - } - if previous.Status == domain.ChannelMemberKicked || previous.BannedRights.ViewMessages { - return domain.ChannelAdminLogParticipantUnkick - } - if !zeroChannelBannedRights(next.BannedRights) { - return domain.ChannelAdminLogParticipantBan - } - return domain.ChannelAdminLogParticipantUnban -} - -func adminLogEventTypesForFilter(filter domain.ChannelAdminLogFilter) []string { - if filter.Empty() { - return nil - } - types := make([]string, 0, 16) - add := func(enabled bool, typ domain.ChannelAdminLogEventType) { - if enabled { - types = append(types, string(typ)) - } - } - add(filter.Join, domain.ChannelAdminLogParticipantJoin) - add(filter.Leave, domain.ChannelAdminLogParticipantLeave) - add(filter.Invite || filter.Invites, domain.ChannelAdminLogParticipantInvite) - add(filter.Ban, domain.ChannelAdminLogParticipantBan) - add(filter.Unban, domain.ChannelAdminLogParticipantUnban) - add(filter.Kick, domain.ChannelAdminLogParticipantKick) - add(filter.Unkick, domain.ChannelAdminLogParticipantUnkick) - add(filter.Promote, domain.ChannelAdminLogParticipantPromote) - add(filter.Demote, domain.ChannelAdminLogParticipantDemote) - if filter.Info { - types = append(types, - string(domain.ChannelAdminLogChangeTitle), - string(domain.ChannelAdminLogChangeUsername), - string(domain.ChannelAdminLogChangeLinkedChat), - string(domain.ChannelAdminLogToggleSlowMode), - ) - } - if filter.Settings { - types = append(types, - string(domain.ChannelAdminLogToggleSignatures), - string(domain.ChannelAdminLogTogglePreHistoryHidden), - string(domain.ChannelAdminLogToggleAntiSpam), - string(domain.ChannelAdminLogToggleAutotranslation), - ) - } - add(filter.Forums || filter.Settings, domain.ChannelAdminLogToggleForum) - add(filter.Pinned, domain.ChannelAdminLogUpdatePinned) - add(filter.Edit, domain.ChannelAdminLogEditMessage) - add(filter.Delete, domain.ChannelAdminLogDeleteMessage) - add(filter.Send, domain.ChannelAdminLogSendMessage) - return types -} - -func adminLogSearchText(event domain.ChannelAdminLogEvent) string { - parts := []string{ - event.Query, - event.PrevString, - event.NewString, - } - for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { - if msg != nil { - parts = append(parts, msg.Body) - } - } - return strings.ToLower(strings.TrimSpace(strings.Join(parts, " "))) -} - -func adminLogLikePattern(query string) string { - query = strings.ReplaceAll(query, `\`, `\\`) - query = strings.ReplaceAll(query, `%`, `\%`) - query = strings.ReplaceAll(query, `_`, `\_`) - return "%" + query + "%" -} - -func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) (domain.Channel, error) { - var participants, admins, kicked, banned int - rows, err := tx.Query(ctx, ` -SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, - rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date -FROM channel_members -WHERE channel_id = $1`, channel.ID) - if err != nil { - return domain.Channel{}, fmt.Errorf("list channel members for counts: %w", err) - } - defer rows.Close() - for rows.Next() { - member, err := scanChannelMember(rows) - if err != nil { - return domain.Channel{}, err - } - if member.Status == domain.ChannelMemberKicked { - kicked++ - } - if member.Status != domain.ChannelMemberActive { - continue - } - participants++ - if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { - admins++ - } - if !zeroChannelBannedRights(member.BannedRights) { - banned++ - } - } - if err := rows.Err(); err != nil { - return domain.Channel{}, err - } - if _, err := tx.Exec(ctx, ` -UPDATE channels -SET participants_count = $2, admins_count = $3, kicked_count = $4, banned_count = $5, updated_at = now() -WHERE id = $1`, channel.ID, participants, admins, kicked, banned); err != nil { - return domain.Channel{}, fmt.Errorf("refresh channel counts: %w", err) - } - channel.ParticipantsCount = participants - channel.AdminsCount = admins - channel.KickedCount = kicked - channel.BannedCount = banned - return channel, nil -} - -func creatorChannelMember(channelID, userID int64, date int) domain.ChannelMember { - return domain.ChannelMember{ - ChannelID: channelID, - UserID: userID, - Role: domain.ChannelRoleCreator, - Status: domain.ChannelMemberActive, - JoinedAt: date, - AdminRights: domain.ChannelAdminRights{ - ChangeInfo: true, - PostMessages: true, - EditMessages: true, - DeleteMessages: true, - BanUsers: true, - InviteUsers: true, - PinMessages: true, - AddAdmins: true, - ManageCall: true, - }, - } -} - -func collectChannelEventRefs(event domain.ChannelUpdateEvent, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { - if event.SenderUserID != 0 { - userRefs[event.SenderUserID] = struct{}{} - } - for _, id := range event.UserIDs { - if id != 0 { - userRefs[id] = struct{}{} - } - } - for _, member := range []domain.ChannelMember{event.Previous, event.Participant} { - if member.UserID != 0 { - userRefs[member.UserID] = struct{}{} - } - if member.InviterUserID != 0 { - userRefs[member.InviterUserID] = struct{}{} - } - } - collectChannelMessageRefs(event.Message, currentChannelID, userRefs, channelRefs) -} - -func collectChannelMessageRefs(msg domain.ChannelMessage, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { - if msg.SenderUserID != 0 { - userRefs[msg.SenderUserID] = struct{}{} - } - addPeerRef(msg.From, currentChannelID, userRefs, channelRefs) - if msg.SendAs != nil { - addPeerRef(*msg.SendAs, currentChannelID, userRefs, channelRefs) - } - if msg.Forward != nil { - addPeerRef(msg.Forward.From, currentChannelID, userRefs, channelRefs) - } - if msg.ReplyTo != nil { - addPeerRef(msg.ReplyTo.Peer, currentChannelID, userRefs, channelRefs) - } - if msg.Action != nil { - for _, id := range msg.Action.UserIDs { - if id != 0 { - userRefs[id] = struct{}{} - } - } - } -} - -func addPeerRef(peer domain.Peer, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { - switch peer.Type { - case domain.PeerTypeUser: - if peer.ID != 0 { - userRefs[peer.ID] = struct{}{} - } - case domain.PeerTypeChannel: - if peer.ID != 0 && peer.ID != currentChannelID { - channelRefs[peer.ID] = struct{}{} - } - } -} - -func mapKeysInt64(items map[int64]struct{}) []int64 { - if len(items) == 0 { - return nil - } - out := make([]int64, 0, len(items)) - for id := range items { - if id != 0 { - out = append(out, id) - } - } - sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) - return out -} - -func uniqueChannelUserIDs(ids []int64, exclude int64) []int64 { - seen := make(map[int64]struct{}, len(ids)) - out := make([]int64, 0, len(ids)) - for _, id := range ids { - if id == 0 || id == exclude { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} - -func uniqueNonZeroInt64s(items ...int64) []int64 { - seen := make(map[int64]struct{}, len(items)) - out := make([]int64, 0, len(items)) - for _, item := range items { - if item == 0 { - continue - } - if _, ok := seen[item]; ok { - continue - } - seen[item] = struct{}{} - out = append(out, item) - } - return out -} - -func channelMemberIDs(members []domain.ChannelMember) []int64 { - out := make([]int64, 0, len(members)) - for _, member := range members { - if member.UserID != 0 { - out = append(out, member.UserID) - } - } - return out -} - -func marshalJSON(v any, empty string) ([]byte, error) { - if v == nil { - return []byte(empty), nil - } - raw, err := json.Marshal(v) - if err != nil { - return nil, err - } - if string(raw) == "null" { - return []byte(empty), nil - } - return raw, nil -} - -func int64s(ids []int64) []int64 { - return append([]int64(nil), ids...) -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} - -func insertChannelInviteTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite) error { - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_invites ( - channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, - expire_date, usage_limit, usage_count, requested_count, created_at, updated_at -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NULLIF($9,0),NULLIF($10,0),$11,$12,to_timestamp($13),to_timestamp($13))`, - invite.ChannelID, invite.InviteID, invite.Hash, invite.AdminUserID, invite.Title, - invite.Permanent, invite.Revoked, invite.RequestNeeded, invite.ExpireDate, - invite.UsageLimit, invite.UsageCount, invite.RequestedCount, invite.Date); err != nil { - return fmt.Errorf("insert channel invite: %w", err) - } - if _, err := tx.Exec(ctx, ` -INSERT INTO channel_invite_hashes (hash, channel_id, invite_id) -VALUES ($1,$2,$3) -ON CONFLICT (hash) DO UPDATE SET channel_id = EXCLUDED.channel_id, invite_id = EXCLUDED.invite_id, updated_at = now()`, - invite.Hash, invite.ChannelID, invite.InviteID); err != nil { - return fmt.Errorf("insert channel invite hash: %w", err) - } - return nil -} - -func (s *ChannelStore) getInviteByHash(ctx context.Context, db sqlcgen.DBTX, hash string) (domain.Channel, domain.ChannelInvite, error) { - return s.getInviteByHashLocked(ctx, db, hash, false) -} - -func (s *ChannelStore) getInviteByHashForUpdate(ctx context.Context, tx pgx.Tx, hash string) (domain.Channel, domain.ChannelInvite, error) { - return s.getInviteByHashLocked(ctx, tx, hash, true) -} - -func (s *ChannelStore) getInviteByHashLocked(ctx context.Context, db sqlcgen.DBTX, hash string, forUpdate bool) (domain.Channel, domain.ChannelInvite, error) { - lockClause := "" - if forUpdate { - lockClause = " FOR UPDATE OF i" - } - row := db.QueryRow(ctx, ` -SELECT `+channelColumns+`, - i.channel_id, i.invite_id, i.hash, i.admin_user_id, i.title, i.permanent, i.revoked, i.request_needed, - COALESCE(i.expire_date, 0), COALESCE(i.usage_limit, 0), i.usage_count, i.requested_count, EXTRACT(EPOCH FROM i.created_at)::int -FROM channel_invite_hashes h -JOIN channel_invites i ON i.channel_id = h.channel_id AND i.invite_id = h.invite_id -JOIN channels c ON c.id = i.channel_id AND NOT c.deleted -WHERE h.hash = $1 AND NOT i.revoked`+lockClause, hash) - var ch domain.Channel - var invite domain.ChannelInvite - var rights, reactionPolicy string - if err := row.Scan( - &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, - &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.LinkedChatID, &ch.SlowmodeSeconds, &rights, - &reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, - &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, - &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, - &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, - &invite.ChannelID, &invite.InviteID, &invite.Hash, &invite.AdminUserID, &invite.Title, - &invite.Permanent, &invite.Revoked, &invite.RequestNeeded, &invite.ExpireDate, - &invite.UsageLimit, &invite.UsageCount, &invite.RequestedCount, &invite.Date, - ); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.Channel{}, domain.ChannelInvite{}, domain.ErrInviteHashInvalid - } - return domain.Channel{}, domain.ChannelInvite{}, err - } - _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) - _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) - return ch, invite, nil -} - -func (s *ChannelStore) getInviteByChannelHash(ctx context.Context, db sqlcgen.DBTX, channelID int64, hash string, forUpdate bool) (domain.ChannelInvite, error) { - lockClause := "" - if forUpdate { - lockClause = " FOR UPDATE" - } - row := db.QueryRow(ctx, ` -SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, - COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, - EXTRACT(EPOCH FROM created_at)::int -FROM channel_invites -WHERE channel_id = $1 AND hash = $2`+lockClause, channelID, strings.TrimSpace(hash)) - invite, err := scanChannelInvite(row) - if errors.Is(err, pgx.ErrNoRows) { - return domain.ChannelInvite{}, domain.ErrInviteHashInvalid - } - return invite, err -} - -func (s *ChannelStore) getInviteByID(ctx context.Context, db sqlcgen.DBTX, channelID, inviteID int64, forUpdate bool) (domain.ChannelInvite, error) { - lockClause := "" - if forUpdate { - lockClause = " FOR UPDATE" - } - row := db.QueryRow(ctx, ` -SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, - COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, - EXTRACT(EPOCH FROM created_at)::int -FROM channel_invites -WHERE channel_id = $1 AND invite_id = $2`+lockClause, channelID, inviteID) - invite, err := scanChannelInvite(row) - if errors.Is(err, pgx.ErrNoRows) { - return domain.ChannelInvite{}, domain.ErrInviteHashInvalid - } - return invite, err -} - -func scanChannelInvite(row rowScanner) (domain.ChannelInvite, error) { - var invite domain.ChannelInvite - err := row.Scan( - &invite.ChannelID, - &invite.InviteID, - &invite.Hash, - &invite.AdminUserID, - &invite.Title, - &invite.Permanent, - &invite.Revoked, - &invite.RequestNeeded, - &invite.ExpireDate, - &invite.UsageLimit, - &invite.UsageCount, - &invite.RequestedCount, - &invite.Date, - ) - return invite, err -} - -func (s *ChannelStore) newPostgresReplacementInvite(old domain.ChannelInvite, date int) (domain.ChannelInvite, error) { - inviteID, err := randomPositiveInt64() - if err != nil { - return domain.ChannelInvite{}, err - } - hash, err := randomInviteHash() - if err != nil { - return domain.ChannelInvite{}, err - } - if date == 0 { - date = nowUnix() - } - return domain.ChannelInvite{ - ChannelID: old.ChannelID, - InviteID: inviteID, - Hash: hash, - AdminUserID: old.AdminUserID, - Permanent: old.Permanent, - Date: date, - }, nil -} - -func (s *ChannelStore) getPendingInviteImporterTx(ctx context.Context, tx pgx.Tx, channelID, userID int64, forUpdate bool) (domain.ChannelInviteImporter, error) { - lockClause := "" - if forUpdate { - lockClause = " FOR UPDATE" - } - row := tx.QueryRow(ctx, ` -SELECT channel_id, invite_id, user_id, date, requested, approved_by, via_chatlist, about -FROM channel_invite_importers -WHERE channel_id = $1 AND user_id = $2 AND requested`+lockClause, channelID, userID) - var importer domain.ChannelInviteImporter - err := row.Scan(&importer.ChannelID, &importer.InviteID, &importer.UserID, &importer.Date, &importer.Requested, &importer.ApprovedBy, &importer.ViaChatlist, &importer.About) - if errors.Is(err, pgx.ErrNoRows) { - return domain.ChannelInviteImporter{}, domain.ErrHideRequesterMissing - } - return importer, err -} - -func deletePendingInviteImporterTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite, userID int64) error { - tag, err := tx.Exec(ctx, ` -DELETE FROM channel_invite_importers -WHERE channel_id = $1 AND user_id = $2 AND requested`, invite.ChannelID, userID) - if err != nil { - return fmt.Errorf("delete pending channel invite importer: %w", err) - } - if tag.RowsAffected() == 0 { - return domain.ErrHideRequesterMissing - } - if invite.InviteID == 0 { - return nil - } - if _, err := tx.Exec(ctx, ` -UPDATE channel_invites -SET requested_count = CASE WHEN requested_count > 0 THEN requested_count - 1 ELSE 0 END, - updated_at = now() -WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { - return fmt.Errorf("decrement channel invite requested count: %w", err) - } - return nil -} - -func decodeJSONPtr[T any](raw string) *T { - if raw == "" || raw == "{}" || raw == "null" { - return nil - } - var out T - if err := json.Unmarshal([]byte(raw), &out); err != nil { - return nil - } - return &out -} - -func randomChannelAccessHash() (int64, error) { - return randomPositiveInt64() -} - -func randomPositiveInt64() (int64, error) { - var b [8]byte - if _, err := rand.Read(b[:]); err != nil { - return 0, fmt.Errorf("rand int64: %w", err) - } - return int64(binary.LittleEndian.Uint64(b[:]) & ((1 << 63) - 1)), nil -} - -func randomInviteHash() (string, error) { - var b [18]byte - if _, err := rand.Read(b[:]); err != nil { - return "", fmt.Errorf("rand invite hash: %w", err) - } - return base64.RawURLEncoding.EncodeToString(b[:]), nil -} - -func nowUnix() int { - return int(time.Now().Unix()) -} - -func isUniqueViolation(err error) bool { - var pgErr *pgconn.PgError - return errors.As(err, &pgErr) && pgErr.Code == "23505" -} - -func isRetryablePostgresTxError(err error) bool { - var pgErr *pgconn.PgError - if !errors.As(err, &pgErr) { - return false - } - return pgErr.Code == "40P01" || pgErr.Code == "40001" -} - -type pgChannelIDAllocator struct { - db sqlcgen.DBTX -} - -func (a pgChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) { - current, err := a.CurrentChannelID(ctx) - if err != nil { - return 0, err - } - return current + 1, nil -} - -func (a pgChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) { - var id int64 - err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channels`).Scan(&id) - return id, err -} - -type pgChannelPtsAllocator struct { - db sqlcgen.DBTX -} - -func (a pgChannelPtsAllocator) NextChannelPts(ctx context.Context, channelID int64) (int, error) { - current, err := a.CurrentChannelPts(ctx, channelID) - if err != nil { - return 0, err - } - return current + 1, nil -} - -func (a pgChannelPtsAllocator) NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) { - if count <= 0 { - count = 1 - } - current, err := a.CurrentChannelPts(ctx, channelID) - if err != nil { - return 0, err - } - return current + count, nil -} - -func (a pgChannelPtsAllocator) CurrentChannelPts(ctx context.Context, channelID int64) (int, error) { - var pts int - err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&pts) - return pts, err -} - -type pgChannelMessageIDAllocator struct { - db sqlcgen.DBTX -} - -func (a pgChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) { - current, err := a.CurrentChannelMessageID(ctx, channelID) - if err != nil { - return 0, err - } - return current + 1, nil -} - -func (a pgChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) { - var id int - err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id) - return id, err -} diff --git a/internal/store/postgres/channel_admin_integration_test.go b/internal/store/postgres/channel_admin_integration_test.go new file mode 100644 index 00000000..78b7b6f1 --- /dev/null +++ b/internal/store/postgres/channel_admin_integration_test.go @@ -0,0 +1,856 @@ +package postgres + +import ( + "context" + "errors" + "telesrv/internal/domain" + "testing" +) + +func TestChannelStoreEditAboutPersistsAndChecksPermission(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 131, + Phone: "+1888" + suffix + "01", + FirstName: "AboutOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 132, + Phone: "+1888" + suffix + "02", + FirstName: "AboutMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "About " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000600, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + if _, err := channels.EditChannelAbout(ctx, domain.EditChannelAboutRequest{ + UserID: member.ID, + ChannelID: channelID, + About: "member cannot edit", + Date: 1700000601, + }); !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("EditChannelAbout by member err = %v, want ErrChannelAdminRequired", err) + } + + updated, err := channels.EditChannelAbout(ctx, domain.EditChannelAboutRequest{ + UserID: owner.ID, + ChannelID: channelID, + About: "owner about", + Date: 1700000602, + }) + if err != nil { + t.Fatalf("EditChannelAbout by owner: %v", err) + } + if updated.About != "owner about" { + t.Fatalf("updated about = %q, want owner about", updated.About) + } + view, err := channels.GetChannel(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("GetChannel by member: %v", err) + } + if view.Channel.About != "owner about" { + t.Fatalf("member view about = %q, want owner about", view.Channel.About) + } +} + +func TestChannelStoreAdminLogFiltersAndSearch(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 51, + Phone: "+1999" + suffix + "01", + FirstName: "AdminLogOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 52, + Phone: "+1999" + suffix + "02", + FirstName: "AdminLogFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + invited, err := users.Create(ctx, domain.User{ + AccessHash: 53, + Phone: "+1999" + suffix + "03", + FirstName: "AdminLogInvited", + }) + if err != nil { + t.Fatalf("create invited: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, invited.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Admin Log " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000500, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: friend.ID, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + InviteUsers: true, + PinMessages: true, + }, + Rank: "ops", + Date: 1700000501, + }); err != nil { + t.Fatalf("edit admin: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 501, + Message: "needle admin log body", + Date: 1700000502, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + if _, err := channels.UpdatePinnedMessage(ctx, domain.UpdateChannelPinnedMessageRequest{ + UserID: friend.ID, + ChannelID: channelID, + MessageID: sent.Message.ID, + Pinned: true, + Date: 1700000503, + }); err != nil { + t.Fatalf("pin message: %v", err) + } + if _, err := channels.InviteToChannel(ctx, channelID, friend.ID, []int64{invited.ID}, 1700000504); err != nil { + t.Fatalf("invite to channel: %v", err) + } + + searched, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ + UserID: owner.ID, + ChannelID: channelID, + Query: "needle", + Limit: 10, + }) + if err != nil { + t.Fatalf("search admin log: %v", err) + } + if len(searched.Events) == 0 { + t.Fatalf("search admin log returned no events, want message body match") + } + + pinned, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ + UserID: owner.ID, + ChannelID: channelID, + Filter: domain.ChannelAdminLogFilter{Pinned: true}, + Limit: 10, + }) + if err != nil { + t.Fatalf("pinned admin log: %v", err) + } + if len(pinned.Events) != 1 || pinned.Events[0].Type != domain.ChannelAdminLogUpdatePinned || pinned.Events[0].Message == nil { + t.Fatalf("pinned events = %+v, want one update_pinned with message", pinned.Events) + } + + byFriend, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ + UserID: owner.ID, + ChannelID: channelID, + AdminUserIDs: []int64{friend.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("friend admin log: %v", err) + } + if len(byFriend.Events) == 0 { + t.Fatalf("friend admin log returned no events, want pin/invite") + } + for _, event := range byFriend.Events { + if event.UserID != friend.ID { + t.Fatalf("friend admin log event actor = %d, want %d in %+v", event.UserID, friend.ID, byFriend.Events) + } + } + + if _, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ + UserID: invited.ID, + ChannelID: channelID, + Limit: 10, + }); err != domain.ErrChannelAdminRequired { + t.Fatalf("member admin log err = %v, want ErrChannelAdminRequired", err) + } +} + +// TestChannelStoreListSendAsChannelsPostgres exercises the postgres ListSendAsChannels query against a +// real database: creator-owned and PostMessages-admin broadcast channels are included; an admin without +// PostMessages, an owned megagroup, and a member-only broadcast are excluded. +func TestChannelStoreListSendAsChannelsPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 211, Phone: "+1444" + suffix + "01", FirstName: "SendAsOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + host, err := users.Create(ctx, domain.User{AccessHash: 212, Phone: "+1444" + suffix + "02", FirstName: "SendAsHost"}) + if err != nil { + t.Fatalf("create host: %v", err) + } + + channels := NewChannelStore(pool) + channelIDs := make([]int64, 0, 5) + t.Cleanup(func() { + for _, id := range channelIDs { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", id) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, host.ID}) + }) + mk := func(req domain.CreateChannelRequest) domain.CreateChannelResult { + created, err := channels.CreateChannel(ctx, req) + if err != nil { + t.Fatalf("create channel %q: %v", req.Title, err) + } + channelIDs = append(channelIDs, created.Channel.ID) + return created + } + + owned := mk(domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "SA Owned " + suffix, Broadcast: true, Date: 1700000700}) + postAdmin := mk(domain.CreateChannelRequest{CreatorUserID: host.ID, Title: "SA PostAdmin " + suffix, Broadcast: true, MemberUserIDs: []int64{owner.ID}, Date: 1700000701}) + editAdmin := mk(domain.CreateChannelRequest{CreatorUserID: host.ID, Title: "SA EditAdmin " + suffix, Broadcast: true, MemberUserIDs: []int64{owner.ID}, Date: 1700000702}) + megagroup := mk(domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "SA Megagroup " + suffix, Megagroup: true, Date: 1700000703}) + memberOnly := mk(domain.CreateChannelRequest{CreatorUserID: host.ID, Title: "SA Member " + suffix, Broadcast: true, MemberUserIDs: []int64{owner.ID}, Date: 1700000704}) + + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: host.ID, + ChannelID: postAdmin.Channel.ID, + MemberID: owner.ID, + AdminRights: domain.ChannelAdminRights{PostMessages: true}, + Date: 1700000711, + }); err != nil { + t.Fatalf("edit post-messages admin: %v", err) + } + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: host.ID, + ChannelID: editAdmin.Channel.ID, + MemberID: owner.ID, + AdminRights: domain.ChannelAdminRights{EditMessages: true, DeleteMessages: true}, + Date: 1700000712, + }); err != nil { + t.Fatalf("edit edit-only admin: %v", err) + } + + list, err := channels.ListSendAsChannels(ctx, owner.ID) + if err != nil { + t.Fatalf("ListSendAsChannels: %v", err) + } + got := make(map[int64]bool, len(list)) + for _, ch := range list { + got[ch.ID] = true + } + if !got[owned.Channel.ID] || !got[postAdmin.Channel.ID] { + t.Fatalf("send-as channels %v missing creator-owned %d or post-admin %d", got, owned.Channel.ID, postAdmin.Channel.ID) + } + if got[editAdmin.Channel.ID] || got[megagroup.Channel.ID] || got[memberOnly.Channel.ID] { + t.Fatalf("send-as channels %v should exclude edit-admin %d, megagroup %d, member-only %d", got, editAdmin.Channel.ID, megagroup.Channel.ID, memberOnly.Channel.ID) + } +} + +func TestChannelStoreCommonChannelsOnlySharedMegagroups(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 51, Phone: "+1888" + suffix + "01", FirstName: "CommonOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 52, Phone: "+1888" + suffix + "02", FirstName: "CommonFriend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + other, err := users.Create(ctx, domain.User{AccessHash: 53, Phone: "+1888" + suffix + "03", FirstName: "CommonOther"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, other.ID}) + }) + + channels := NewChannelStore(pool) + create := func(title string, broadcast bool, memberIDs []int64, date int) domain.CreateChannelResult { + t.Helper() + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: title, + Broadcast: broadcast, + Megagroup: !broadcast, + MemberUserIDs: memberIDs, + Date: date, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + channelIDs = append(channelIDs, created.Channel.ID) + return created + } + first := create("common one "+suffix, false, []int64{friend.ID}, 1700000800) + second := create("common two "+suffix, false, []int64{friend.ID}, 1700000801) + create("broadcast excluded "+suffix, true, []int64{friend.ID}, 1700000802) + left := create("left excluded "+suffix, false, []int64{friend.ID}, 1700000803) + if _, err := channels.LeaveChannel(ctx, left.Channel.ID, friend.ID, 1700000804); err != nil { + t.Fatalf("leave channel: %v", err) + } + create("not shared "+suffix, false, []int64{other.ID}, 1700000805) + + page, err := channels.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: owner.ID, + TargetUserID: friend.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list common channels: %v", err) + } + if page.Count != 2 || len(page.Channels) != 2 || page.Channels[0].ID != first.Channel.ID || page.Channels[1].ID != second.Channel.ID { + t.Fatalf("common channels = %+v, want two shared megagroups in id order", page) + } + + next, err := channels.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: owner.ID, + TargetUserID: friend.ID, + MaxID: first.Channel.ID, + Limit: 1, + }) + if err != nil { + t.Fatalf("list common channels after max id: %v", err) + } + if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].ID != second.Channel.ID { + t.Fatalf("paged common channels = %+v, want second channel with full count", next) + } + + countOnly, err := channels.ListCommonChannels(ctx, domain.CommonChannelsRequest{ + UserID: owner.ID, + TargetUserID: friend.ID, + CountOnly: true, + }) + if err != nil { + t.Fatalf("count common channels: %v", err) + } + if countOnly.Count != 2 || len(countOnly.Channels) != 0 { + t.Fatalf("count-only common channels = %+v, want count without channels", countOnly) + } +} + +func TestChannelStoreListActiveChannelIDsForUserUsesMembershipIndexState(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 81, Phone: "+1888" + suffix + "41", FirstName: "ActiveIndexOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 82, Phone: "+1888" + suffix + "42", FirstName: "ActiveIndexFriend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + create := func(title string, date int) domain.CreateChannelResult { + t.Helper() + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: title, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: date, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + channelIDs = append(channelIDs, created.Channel.ID) + return created + } + activeA := create("active index one "+suffix, 1700000830) + left := create("left index excluded "+suffix, 1700000831) + activeB := create("active index two "+suffix, 1700000832) + deleted := create("deleted index excluded "+suffix, 1700000833) + if _, err := channels.LeaveChannel(ctx, left.Channel.ID, friend.ID, 1700000834); err != nil { + t.Fatalf("leave channel: %v", err) + } + if _, err := channels.DeleteChannel(ctx, domain.DeleteChannelRequest{ + UserID: owner.ID, + ChannelID: deleted.Channel.ID, + Date: 1700000835, + }); err != nil { + t.Fatalf("delete channel: %v", err) + } + + want := []int64{activeA.Channel.ID, activeB.Channel.ID} + if want[0] > want[1] { + want[0], want[1] = want[1], want[0] + } + got, err := channels.ListActiveChannelIDsForUser(ctx, friend.ID, 0, 10) + if err != nil { + t.Fatalf("list active channel ids: %v", err) + } + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("active channel ids = %v, want %v", got, want) + } + + firstPage, err := channels.ListActiveChannelIDsForUser(ctx, friend.ID, 0, 1) + if err != nil { + t.Fatalf("list first active channel id: %v", err) + } + if len(firstPage) != 1 || firstPage[0] != want[0] { + t.Fatalf("first active page = %v, want [%d]", firstPage, want[0]) + } + nextPage, err := channels.ListActiveChannelIDsForUser(ctx, friend.ID, want[0], 10) + if err != nil { + t.Fatalf("list next active channel ids: %v", err) + } + if len(nextPage) != 1 || nextPage[0] != want[1] { + t.Fatalf("next active page = %v, want [%d]", nextPage, want[1]) + } + empty, err := channels.ListActiveChannelIDsForUser(ctx, friend.ID, want[1], 10) + if err != nil { + t.Fatalf("list active channel ids after last: %v", err) + } + if len(empty) != 0 { + t.Fatalf("active ids after last = %v, want empty", empty) + } + if _, err := channels.ListActiveChannelIDsForUser(ctx, friend.ID, -1, 10); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("negative cursor err = %v, want ErrChannelInvalid", err) + } +} + +func TestChannelStoreUserChannelMemberIndexListPaths(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1888" + suffix + "51", FirstName: "IndexListOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1888" + suffix + "52", FirstName: "IndexListFriend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + publicCandidate, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "index public " + suffix, + Megagroup: true, + Date: 1700000840, + }) + if err != nil { + t.Fatalf("create public candidate: %v", err) + } + channelIDs = append(channelIDs, publicCandidate.Channel.ID) + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: publicCandidate.Channel.ID, + Username: "idxpublic" + suffix, + }); err != nil { + t.Fatalf("set public username: %v", err) + } + admined, err := channels.ListAdminedPublicChannels(ctx, owner.ID) + if err != nil { + t.Fatalf("list admined public channels: %v", err) + } + if len(admined) != 1 || admined[0].ID != publicCandidate.Channel.ID { + t.Fatalf("admined public = %+v, want public candidate", admined) + } + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: publicCandidate.Channel.ID, + Username: "", + }); err != nil { + t.Fatalf("clear public username: %v", err) + } + admined, err = channels.ListAdminedPublicChannels(ctx, owner.ID) + if err != nil { + t.Fatalf("list admined public channels after clear: %v", err) + } + if len(admined) != 0 { + t.Fatalf("admined public after clear = %+v, want empty", admined) + } + + discussionCandidate, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "index discussion " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000841, + }) + if err != nil { + t.Fatalf("create discussion candidate: %v", err) + } + channelIDs = append(channelIDs, discussionCandidate.Channel.ID) + friendGroups, err := channels.ListDiscussionGroups(ctx, friend.ID, 10) + if err != nil { + t.Fatalf("list friend discussion groups before admin: %v", err) + } + if len(friendGroups) != 0 { + t.Fatalf("friend discussion groups before admin = %+v, want empty", friendGroups) + } + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: discussionCandidate.Channel.ID, + MemberID: friend.ID, + AdminRights: domain.ChannelAdminRights{ + PinMessages: true, + }, + Date: 1700000842, + }); err != nil { + t.Fatalf("grant pin admin: %v", err) + } + friendGroups, err = channels.ListDiscussionGroups(ctx, friend.ID, 10) + if err != nil { + t.Fatalf("list friend discussion groups after admin: %v", err) + } + if len(friendGroups) != 1 || friendGroups[0].ID != discussionCandidate.Channel.ID { + t.Fatalf("friend discussion groups after admin = %+v, want candidate", friendGroups) + } + if _, err := channels.SetForum(ctx, owner.ID, discussionCandidate.Channel.ID, true, false); err != nil { + t.Fatalf("enable forum: %v", err) + } + friendGroups, err = channels.ListDiscussionGroups(ctx, friend.ID, 10) + if err != nil { + t.Fatalf("list friend discussion groups after forum: %v", err) + } + if len(friendGroups) != 0 { + t.Fatalf("friend discussion groups after forum = %+v, want empty", friendGroups) + } +} + +func TestChannelStoreListStoryPostableChannelsPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 9901, Phone: "+1888" + suffix + "41", FirstName: "StoryPostOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + admin, err := users.Create(ctx, domain.User{AccessHash: 9902, Phone: "+1888" + suffix + "42", FirstName: "StoryPostAdmin"}) + if err != nil { + t.Fatalf("create admin: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) > 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, admin.ID}) + }) + + channels := NewChannelStore(pool) + owned, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: admin.ID, + Title: "pg owned story " + suffix, + Broadcast: true, + Date: 1700003400, + }) + if err != nil { + t.Fatalf("create owned channel: %v", err) + } + channelIDs = append(channelIDs, owned.Channel.ID) + postable, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "pg postable story " + suffix, + Broadcast: true, + MemberUserIDs: []int64{admin.ID}, + Date: 1700003401, + }) + if err != nil { + t.Fatalf("create postable channel: %v", err) + } + channelIDs = append(channelIDs, postable.Channel.ID) + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + ChannelID: postable.Channel.ID, + UserID: owner.ID, + MemberID: admin.ID, + AdminRights: domain.ChannelAdminRights{ + PostStories: true, + }, + Date: 1700003402, + }); err != nil { + t.Fatalf("grant post stories: %v", err) + } + editOnly, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "pg edit-only story " + suffix, + Broadcast: true, + MemberUserIDs: []int64{admin.ID}, + Date: 1700003403, + }) + if err != nil { + t.Fatalf("create edit-only channel: %v", err) + } + channelIDs = append(channelIDs, editOnly.Channel.ID) + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + ChannelID: editOnly.Channel.ID, + UserID: owner.ID, + MemberID: admin.ID, + AdminRights: domain.ChannelAdminRights{ + EditStories: true, + }, + Date: 1700003404, + }); err != nil { + t.Fatalf("grant edit stories only: %v", err) + } + + list, err := channels.ListStoryPostableChannels(ctx, admin.ID) + if err != nil { + t.Fatalf("ListStoryPostableChannels: %v", err) + } + got := make([]int64, 0, len(list)) + for _, channel := range list { + got = append(got, channel.ID) + } + want := []int64{postable.Channel.ID, owned.Channel.ID} + if len(got) != len(want) { + t.Fatalf("story postable channel ids = %v, want %v; excluded edit-only=%d", got, want, editOnly.Channel.ID) + } + if got[0] != want[0] || got[1] != want[1] { + t.Fatalf("story postable channel ids = %v, want %v; excluded edit-only=%d", got, want, editOnly.Channel.ID) + } +} + +func TestChannelStoreLeftChannelsReturnsPagedLeftMemberships(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1889" + suffix + "01", FirstName: "LeftOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1889" + suffix + "02", FirstName: "LeftFriend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + create := func(title string, broadcast bool, date int) domain.CreateChannelResult { + t.Helper() + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: title, + Broadcast: broadcast, + Megagroup: !broadcast, + MemberUserIDs: []int64{friend.ID}, + Date: date, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + channelIDs = append(channelIDs, created.Channel.ID) + return created + } + older := create("older left "+suffix, false, 1700000810) + newer := create("newer left "+suffix, true, 1700000811) + create("active excluded "+suffix, false, 1700000812) + if _, err := channels.LeaveChannel(ctx, older.Channel.ID, friend.ID, 1700000813); err != nil { + t.Fatalf("leave older channel: %v", err) + } + if _, err := channels.LeaveChannel(ctx, newer.Channel.ID, friend.ID, 1700000814); err != nil { + t.Fatalf("leave newer channel: %v", err) + } + + page, err := channels.ListLeftChannels(ctx, friend.ID, 0, 1) + if err != nil { + t.Fatalf("list left channels: %v", err) + } + if page.Count != 2 || len(page.Channels) != 1 || page.Channels[0].Channel.ID != newer.Channel.ID || page.Channels[0].Self.Status != domain.ChannelMemberLeft { + t.Fatalf("first left page = %+v, want newest left channel and full count", page) + } + next, err := channels.ListLeftChannels(ctx, friend.ID, 1, 1) + if err != nil { + t.Fatalf("list next left channels: %v", err) + } + if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].Channel.ID != older.Channel.ID { + t.Fatalf("second left page = %+v, want older left channel", next) + } + empty, err := channels.ListLeftChannels(ctx, friend.ID, 2, 1) + if err != nil { + t.Fatalf("list empty left page: %v", err) + } + if empty.Count != 2 || len(empty.Channels) != 0 { + t.Fatalf("empty left page = %+v, want full count and no chats", empty) + } + if _, err := channels.ListLeftChannels(ctx, friend.ID, domain.MaxLeftChannelsOffset+1, 1); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("huge offset err = %v, want ErrChannelInvalid", err) + } +} + +func TestChannelStoreDiscussionGroupLinksAreBidirectional(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1890" + suffix + "01", FirstName: "DiscussionOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + create := func(title string, broadcast bool, date int) domain.CreateChannelResult { + t.Helper() + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: title, + Broadcast: broadcast, + Megagroup: !broadcast, + Date: date, + }) + if err != nil { + t.Fatalf("create %s: %v", title, err) + } + channelIDs = append(channelIDs, created.Channel.ID) + return created + } + broadcast := create("discussion broadcast "+suffix, true, 1700000820) + firstGroup := create("discussion first "+suffix, false, 1700000821) + secondGroup := create("discussion second "+suffix, false, 1700000822) + + candidates, err := channels.ListDiscussionGroups(ctx, owner.ID, 10) + if err != nil { + t.Fatalf("list discussion groups: %v", err) + } + if len(candidates) < 2 || candidates[0].ID != secondGroup.Channel.ID || candidates[1].ID != firstGroup.Channel.ID { + t.Fatalf("discussion groups = %+v, want newest creator megagroups", candidates) + } + + linked, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, firstGroup.Channel.ID) + if err != nil { + t.Fatalf("link first discussion group: %v", err) + } + if len(linked.Channels) != 2 { + t.Fatalf("linked changed channels = %+v, want broadcast and group", linked.Channels) + } + gotBroadcast, err := channels.GetChannelByID(ctx, broadcast.Channel.ID) + if err != nil { + t.Fatalf("get linked broadcast: %v", err) + } + gotFirst, err := channels.GetChannelByID(ctx, firstGroup.Channel.ID) + if err != nil { + t.Fatalf("get linked first group: %v", err) + } + if gotBroadcast.LinkedChatID != firstGroup.Channel.ID || gotFirst.LinkedChatID != broadcast.Channel.ID { + t.Fatalf("first link = broadcast %d group %d, want bidirectional", gotBroadcast.LinkedChatID, gotFirst.LinkedChatID) + } + + replaced, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, secondGroup.Channel.ID) + if err != nil { + t.Fatalf("replace discussion group: %v", err) + } + if len(replaced.Channels) != 3 { + t.Fatalf("replace changed channels = %+v, want broadcast old group new group", replaced.Channels) + } + gotBroadcast, _ = channels.GetChannelByID(ctx, broadcast.Channel.ID) + gotFirst, _ = channels.GetChannelByID(ctx, firstGroup.Channel.ID) + gotSecond, err := channels.GetChannelByID(ctx, secondGroup.Channel.ID) + if err != nil { + t.Fatalf("get linked second group: %v", err) + } + if gotBroadcast.LinkedChatID != secondGroup.Channel.ID || gotSecond.LinkedChatID != broadcast.Channel.ID || gotFirst.LinkedChatID != 0 { + t.Fatalf("replace link = broadcast %d first %d second %d, want old cleared and new bidirectional", + gotBroadcast.LinkedChatID, gotFirst.LinkedChatID, gotSecond.LinkedChatID) + } + + if _, err := channels.SetDiscussionGroup(ctx, owner.ID, 0, secondGroup.Channel.ID); err != nil { + t.Fatalf("unlink from group side: %v", err) + } + gotBroadcast, _ = channels.GetChannelByID(ctx, broadcast.Channel.ID) + gotSecond, _ = channels.GetChannelByID(ctx, secondGroup.Channel.ID) + if gotBroadcast.LinkedChatID != 0 || gotSecond.LinkedChatID != 0 { + t.Fatalf("unlink = broadcast %d group %d, want both cleared", gotBroadcast.LinkedChatID, gotSecond.LinkedChatID) + } + if _, err := channels.SetDiscussionGroup(ctx, owner.ID, 0, secondGroup.Channel.ID); !errors.Is(err, domain.ErrLinkNotModified) { + t.Fatalf("repeat unlink err = %v, want ErrLinkNotModified", err) + } + if _, err := channels.SetPreHistoryHidden(ctx, owner.ID, firstGroup.Channel.ID, true); err != nil { + t.Fatalf("hide first group prehistory: %v", err) + } + if _, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, firstGroup.Channel.ID); !errors.Is(err, domain.ErrMegagroupPrehistoryHidden) { + t.Fatalf("hidden prehistory err = %v, want ErrMegagroupPrehistoryHidden", err) + } +} diff --git a/internal/store/postgres/channel_admin_log.go b/internal/store/postgres/channel_admin_log.go new file mode 100644 index 00000000..70f6fcd8 --- /dev/null +++ b/internal/store/postgres/channel_admin_log.go @@ -0,0 +1,159 @@ +package postgres + +import ( + "context" + "fmt" + "github.com/jackc/pgx/v5" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) ListAdminLog(ctx context.Context, req domain.ChannelAdminLogRequest) (domain.ChannelAdminLogResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MaxID < 0 || req.MinID < 0 { + return domain.ChannelAdminLogResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelAdminLogResult{}, err + } + if !isChannelAdmin(member) { + return domain.ChannelAdminLogResult{}, domain.ErrChannelAdminRequired + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelAdminLogLimit { + limit = domain.MaxChannelAdminLogLimit + } + where := []string{"channel_id = $1"} + args := []any{req.ChannelID} + nextArg := func(v any) string { + args = append(args, v) + return fmt.Sprintf("$%d", len(args)) + } + if req.MaxID > 0 { + where = append(where, "id < "+nextArg(req.MaxID)) + } + if req.MinID > 0 { + where = append(where, "id > "+nextArg(req.MinID)) + } + if len(req.AdminUserIDs) > 0 { + where = append(where, "actor_user_id = ANY("+nextArg(int64s(req.AdminUserIDs))+"::bigint[])") + } + if types := adminLogEventTypesForFilter(req.Filter); len(types) > 0 { + where = append(where, "event_type = ANY("+nextArg(types)+"::text[])") + } else if !req.Filter.Empty() { + return domain.ChannelAdminLogResult{Channel: channel}, nil + } + query := strings.ToLower(strings.TrimSpace(req.Query)) + if query != "" { + like := adminLogLikePattern(query) + where = append(where, `(lower(prev_string) LIKE `+nextArg(like)+` ESCAPE '\' OR lower(new_string) LIKE `+nextArg(like)+` ESCAPE '\' OR lower(query) LIKE `+nextArg(like)+` ESCAPE '\')`) + } + args = append(args, limit) + rows, err := s.db.Query(ctx, ` +SELECT channel_id, id, actor_user_id, event_date, event_type, prev_string, new_string, prev_bool, new_bool, prev_int, new_int, + prev_participant::text, new_participant::text, participant::text, message::text, prev_message::text, new_message::text, query +FROM channel_admin_log_events +WHERE `+strings.Join(where, " AND ")+` +ORDER BY id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelAdminLogResult{}, fmt.Errorf("list channel admin log: %w", err) + } + defer rows.Close() + events := make([]domain.ChannelAdminLogEvent, 0, limit) + for rows.Next() { + event, err := scanChannelAdminLogEvent(rows) + if err != nil { + return domain.ChannelAdminLogResult{}, err + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + return domain.ChannelAdminLogResult{}, err + } + return domain.ChannelAdminLogResult{Channel: channel, Events: events}, nil +} + +func (s *ChannelStore) insertChannelAdminLogTx(ctx context.Context, tx pgx.Tx, event domain.ChannelAdminLogEvent) error { + if event.ChannelID == 0 || event.UserID == 0 || event.Type == "" { + return nil + } + if event.Date == 0 { + event.Date = nowUnix() + } + id, err := nextChannelAdminLogIDTx(ctx, tx, event.ChannelID) + if err != nil { + return err + } + prevParticipant, err := marshalJSON(event.PrevParticipant, "{}") + if err != nil { + return err + } + newParticipant, err := marshalJSON(event.NewParticipant, "{}") + if err != nil { + return err + } + participant, err := marshalJSON(event.Participant, "{}") + if err != nil { + return err + } + message, err := marshalJSON(event.Message, "{}") + if err != nil { + return err + } + prevMessage, err := marshalJSON(event.PrevMessage, "{}") + if err != nil { + return err + } + newMessage, err := marshalJSON(event.NewMessage, "{}") + if err != nil { + return err + } + query := adminLogSearchText(event) + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_admin_log_events ( + channel_id, id, actor_user_id, event_date, event_type, prev_string, new_string, + prev_bool, new_bool, prev_int, new_int, prev_participant, new_participant, + participant, message, prev_message, new_message, query +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, + event.ChannelID, id, event.UserID, event.Date, string(event.Type), event.PrevString, event.NewString, + event.PrevBool, event.NewBool, event.PrevInt, event.NewInt, prevParticipant, newParticipant, + participant, message, prevMessage, newMessage, query); err != nil { + return fmt.Errorf("insert channel admin log: %w", err) + } + return nil +} + +func nextChannelAdminLogIDTx(ctx context.Context, tx pgx.Tx, channelID int64) (int64, error) { + var id int64 + if err := tx.QueryRow(ctx, ` +UPDATE channels +SET admin_log_seq = admin_log_seq + 1, updated_at = now() +WHERE id = $1 +RETURNING admin_log_seq`, channelID).Scan(&id); err != nil { + return 0, fmt.Errorf("allocate channel admin log id: %w", err) + } + return id, nil +} + +func scanChannelAdminLogEvent(row rowScanner) (domain.ChannelAdminLogEvent, error) { + var event domain.ChannelAdminLogEvent + var typ string + var prevParticipant, newParticipant, participant, message, prevMessage, newMessage string + if err := row.Scan( + &event.ChannelID, &event.ID, &event.UserID, &event.Date, &typ, + &event.PrevString, &event.NewString, &event.PrevBool, &event.NewBool, + &event.PrevInt, &event.NewInt, &prevParticipant, &newParticipant, &participant, + &message, &prevMessage, &newMessage, &event.Query, + ); err != nil { + return domain.ChannelAdminLogEvent{}, err + } + event.Type = domain.ChannelAdminLogEventType(typ) + event.PrevParticipant = decodeJSONPtr[domain.ChannelMember](prevParticipant) + event.NewParticipant = decodeJSONPtr[domain.ChannelMember](newParticipant) + event.Participant = decodeJSONPtr[domain.ChannelMember](participant) + event.Message = decodeJSONPtr[domain.ChannelMessage](message) + event.PrevMessage = decodeJSONPtr[domain.ChannelMessage](prevMessage) + event.NewMessage = decodeJSONPtr[domain.ChannelMessage](newMessage) + return event, nil +} diff --git a/internal/store/postgres/channel_boost_cache.go b/internal/store/postgres/channel_boost_cache.go new file mode 100644 index 00000000..79477543 --- /dev/null +++ b/internal/store/postgres/channel_boost_cache.go @@ -0,0 +1,158 @@ +package postgres + +import ( + "context" + "time" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +type channelBoostCacheKey struct { + userID int64 + peerType domain.PeerType + peerID int64 +} + +type channelBoostPeerTotalCacheKey struct { + peerType domain.PeerType + peerID int64 +} + +// ChannelBoostCache 缓存 boost 读投影,由统一缓存原语 readmodelcache.Cache 承载 +// (LRU 单条驱逐 / epoch 守卫 / singleflight 内建,TTL 由原语时钟驱动)。 +// +// 它刻意只在非事务读路径使用;发送权限校验与 boost apply 事务内仍实时查 +// channel_boost_slots。selfBoosts 缓存 viewer 作用域的 SelfBoostsApplied, +// peerTotals 缓存 premium.getBoostsStatus 的频道总 active boost 数。 +// boost 自身的到期由 load 时的 SQL(expires_at > now)过滤,本缓存的 TTL 只是新鲜度兜底, +// 与调用方传入的墙钟 now 等价(运行时 now=nowUnix()),故迁移到原语内部时钟行为不变。 +type ChannelBoostCache struct { + selfBoosts *readmodelcache.Cache[channelBoostCacheKey, int] + peerTotals *readmodelcache.Cache[channelBoostPeerTotalCacheKey, int] +} + +// NewChannelBoostCache 创建容量 max、新鲜度 ttl 的 boost 缓存;max<=0 或 ttl<=0 返回 nil(禁用)。 +func NewChannelBoostCache(max int, ttl time.Duration) *ChannelBoostCache { + return newChannelBoostCache(max, ttl, nil) +} + +// newChannelBoostCache 允许注入时钟,仅供测试确定地推进 TTL;now=nil 用真实时钟。 +func newChannelBoostCache(max int, ttl time.Duration, now func() time.Time) *ChannelBoostCache { + if ttl <= 0 { + return nil + } + selfBoosts := readmodelcache.New[channelBoostCacheKey, int](readmodelcache.Config[channelBoostCacheKey, int]{ + MaxEntries: max, + TTL: ttl, + Now: now, + }) + peerTotals := readmodelcache.New[channelBoostPeerTotalCacheKey, int](readmodelcache.Config[channelBoostPeerTotalCacheKey, int]{ + MaxEntries: max, + TTL: ttl, + Now: now, + }) + if selfBoosts == nil || peerTotals == nil { + return nil + } + return &ChannelBoostCache{selfBoosts: selfBoosts, peerTotals: peerTotals} +} + +func (c *ChannelBoostCache) get(userID int64, peer domain.Peer) (int, bool) { + key, ok := newChannelBoostCacheKey(userID, peer) + if c == nil || !ok { + return 0, false + } + return c.selfBoosts.Peek(key) +} + +func (c *ChannelBoostCache) getOrLoad(ctx context.Context, userID int64, peer domain.Peer, load func() (int, error)) (int, error) { + if c == nil { + return load() + } + key, ok := newChannelBoostCacheKey(userID, peer) + if !ok { + return load() + } + return c.selfBoosts.GetOrLoad(ctx, key, load) +} + +func (c *ChannelBoostCache) put(userID int64, peer domain.Peer, boosts int) { + key, ok := newChannelBoostCacheKey(userID, peer) + if c == nil || !ok { + return + } + c.selfBoosts.Store(key, boosts) +} + +func (c *ChannelBoostCache) delete(userID int64, peer domain.Peer) { + key, ok := newChannelBoostCacheKey(userID, peer) + if c == nil || !ok { + return + } + c.selfBoosts.Invalidate(key) +} + +func (c *ChannelBoostCache) deleteUser(userID int64) { + if c == nil || userID == 0 { + return + } + c.selfBoosts.InvalidateWhere(func(k channelBoostCacheKey) bool { return k.userID == userID }) +} + +func (c *ChannelBoostCache) getPeerTotal(peer domain.Peer) (int, bool) { + key, ok := newChannelBoostPeerTotalCacheKey(peer) + if c == nil || !ok { + return 0, false + } + return c.peerTotals.Peek(key) +} + +func (c *ChannelBoostCache) getPeerTotalOrLoad(ctx context.Context, peer domain.Peer, load func() (int, error)) (int, error) { + if c == nil { + return load() + } + key, ok := newChannelBoostPeerTotalCacheKey(peer) + if !ok { + return load() + } + return c.peerTotals.GetOrLoad(ctx, key, load) +} + +func (c *ChannelBoostCache) putPeerTotal(peer domain.Peer, boosts int) { + key, ok := newChannelBoostPeerTotalCacheKey(peer) + if c == nil || !ok { + return + } + c.peerTotals.Store(key, boosts) +} + +func (c *ChannelBoostCache) deletePeerTotal(peer domain.Peer) { + key, ok := newChannelBoostPeerTotalCacheKey(peer) + if c == nil || !ok { + return + } + c.peerTotals.Invalidate(key) +} + +func (c *ChannelBoostCache) flush() { + if c == nil { + return + } + c.selfBoosts.Flush() + c.peerTotals.Flush() +} + +func newChannelBoostCacheKey(userID int64, peer domain.Peer) (channelBoostCacheKey, bool) { + if userID == 0 || peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return channelBoostCacheKey{}, false + } + return channelBoostCacheKey{userID: userID, peerType: peer.Type, peerID: peer.ID}, true +} + +func newChannelBoostPeerTotalCacheKey(peer domain.Peer) (channelBoostPeerTotalCacheKey, bool) { + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return channelBoostPeerTotalCacheKey{}, false + } + return channelBoostPeerTotalCacheKey{peerType: peer.Type, peerID: peer.ID}, true +} diff --git a/internal/store/postgres/channel_boost_cache_test.go b/internal/store/postgres/channel_boost_cache_test.go new file mode 100644 index 00000000..8d979435 --- /dev/null +++ b/internal/store/postgres/channel_boost_cache_test.go @@ -0,0 +1,325 @@ +package postgres + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "telesrv/internal/domain" +) + +// boostClock 是 boost 缓存 TTL 测试用的可推进单调时钟(注入 readmodelcache.Config.Now)。 +type boostClock struct { + mu sync.Mutex + t int64 +} + +func newBoostClock(unix int64) *boostClock { return &boostClock{t: unix} } + +func (b *boostClock) now() time.Time { + b.mu.Lock() + defer b.mu.Unlock() + return time.Unix(b.t, 0) +} + +func (b *boostClock) set(unix int64) { + b.mu.Lock() + b.t = unix + b.mu.Unlock() +} + +func TestChannelBoostCacheNilDisabled(t *testing.T) { + if c := NewChannelBoostCache(0, time.Second); c != nil { + t.Fatalf("max<=0 应返回 nil(禁用),got %v", c) + } + if c := NewChannelBoostCache(16, 0); c != nil { + t.Fatalf("ttl<=0 应返回 nil(禁用),got %v", c) + } + var nilCache *ChannelBoostCache + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 10} + nilCache.put(1, peer, 2) + nilCache.putPeerTotal(peer, 2) + nilCache.delete(1, peer) + nilCache.deletePeerTotal(peer) + nilCache.deleteUser(1) + nilCache.flush() + if _, ok := nilCache.get(1, peer); ok { + t.Fatalf("nil 缓存 get 必须返回 ok=false") + } + if _, ok := nilCache.getPeerTotal(peer); ok { + t.Fatalf("nil 缓存 getPeerTotal 必须返回 ok=false") + } +} + +func TestChannelBoostCachePutGetExpireDelete(t *testing.T) { + clk := newBoostClock(1000) + c := newChannelBoostCache(16, 10*time.Second, clk.now) + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 10} + otherPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 11} + + c.put(100, peer, 3) + c.put(100, otherPeer, 1) + + clk.set(1005) // < 1000+10:仍有效 + if got, ok := c.get(100, peer); !ok || got != 3 { + t.Fatalf("get before ttl = %d ok=%v, want 3/true", got, ok) + } + if got, ok := c.get(100, otherPeer); !ok || got != 1 { + t.Fatalf("other before ttl = %d ok=%v, want 1/true", got, ok) + } + + clk.set(1010) // == 1000+10:expiresAt<=now 视为过期 + if _, ok := c.get(100, peer); ok { + t.Fatalf("expiresAt==now 应视为过期") + } + + // delete 精确失效(在新鲜窗口内重写两个 peer,使其 expireAt=1020)。 + c.put(100, peer, 4) + c.put(100, otherPeer, 1) + c.delete(100, peer) + clk.set(1011) + if _, ok := c.get(100, peer); ok { + t.Fatalf("delete 后不应命中") + } + if got, ok := c.get(100, otherPeer); !ok || got != 1 { + t.Fatalf("delete exact 不应影响其它频道: %d ok=%v", got, ok) + } + + c.deleteUser(100) + if _, ok := c.get(100, otherPeer); ok { + t.Fatalf("deleteUser 后同用户其它频道也应失效") + } +} + +func TestChannelBoostCachePeerTotalPutGetExpireDelete(t *testing.T) { + clk := newBoostClock(1000) + c := newChannelBoostCache(16, 10*time.Second, clk.now) + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 10} + otherPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 11} + + c.putPeerTotal(peer, 30) + c.putPeerTotal(otherPeer, 11) + + clk.set(1005) + if got, ok := c.getPeerTotal(peer); !ok || got != 30 { + t.Fatalf("peer total before ttl = %d ok=%v, want 30/true", got, ok) + } + if got, ok := c.getPeerTotal(otherPeer); !ok || got != 11 { + t.Fatalf("other peer total before ttl = %d ok=%v, want 11/true", got, ok) + } + + clk.set(1010) + if _, ok := c.getPeerTotal(peer); ok { + t.Fatalf("peer total expiresAt==now 应视为过期") + } + + c.putPeerTotal(peer, 31) + c.putPeerTotal(otherPeer, 12) + c.deletePeerTotal(peer) + clk.set(1011) + if _, ok := c.getPeerTotal(peer); ok { + t.Fatalf("deletePeerTotal 后不应命中") + } + if got, ok := c.getPeerTotal(otherPeer); !ok || got != 12 { + t.Fatalf("deletePeerTotal 不应影响其它频道: %d ok=%v", got, ok) + } +} + +func TestChannelBoostCacheCapFlush(t *testing.T) { + c := NewChannelBoostCache(2, time.Minute) + ch := func(id int64) domain.Peer { return domain.Peer{Type: domain.PeerTypeChannel, ID: id} } + c.put(1, ch(1), 1) + c.put(1, ch(2), 1) + c.put(1, ch(3), 1) // 超 cap=2:LRU 单条驱逐最旧的 ch1,而非整表清空 + if _, ok := c.get(1, ch(1)); ok { + t.Fatalf("超限后最旧条目(ch1)应被驱逐") + } + if _, ok := c.get(1, ch(2)); !ok { + t.Fatalf("LRU 单条驱逐应保留次新条目(ch2),证明非整表 flush") + } + if got, ok := c.get(1, ch(3)); !ok || got != 1 { + t.Fatalf("最新条目(ch3)应在: %d ok=%v", got, ok) + } + c.flush() + if _, ok := c.get(1, ch(3)); ok { + t.Fatalf("flush 后不应命中") + } +} + +func TestChannelBoostCacheSingleflightsColdLoad(t *testing.T) { + ctx := context.Background() + c := NewChannelBoostCache(16, time.Minute) + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 42} + var calls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + load := func() (int, error) { + calls.Add(1) + once.Do(func() { close(started) }) + <-release + return 7, nil + } + + const goroutines = 16 + var wg sync.WaitGroup + errs := make(chan error, goroutines) + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + boosts, err := c.getOrLoad(ctx, 100, peer, load) + if err != nil { + errs <- err + return + } + if boosts != 7 { + errs <- fmt.Errorf("boosts = %d, want 7", boosts) + } + }() + } + <-started + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("load calls = %d, want 1", got) + } + if boosts, err := c.getOrLoad(ctx, 100, peer, load); err != nil || boosts != 7 { + t.Fatalf("cached getOrLoad = %d/%v, want 7/nil", boosts, err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("cache hit called load again: calls=%d", got) + } +} + +func TestChannelBoostCachePeerTotalSingleflightsColdLoad(t *testing.T) { + ctx := context.Background() + c := NewChannelBoostCache(16, time.Minute) + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 42} + var calls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + load := func() (int, error) { + calls.Add(1) + once.Do(func() { close(started) }) + <-release + return 70, nil + } + + const goroutines = 16 + var wg sync.WaitGroup + errs := make(chan error, goroutines) + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + boosts, err := c.getPeerTotalOrLoad(ctx, peer, load) + if err != nil { + errs <- err + return + } + if boosts != 70 { + errs <- fmt.Errorf("peer boosts = %d, want 70", boosts) + } + }() + } + <-started + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("peer total load calls = %d, want 1", got) + } + if boosts, err := c.getPeerTotalOrLoad(ctx, peer, load); err != nil || boosts != 70 { + t.Fatalf("cached peer total = %d/%v, want 70/nil", boosts, err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("peer total cache hit called load again: calls=%d", got) + } +} + +func TestReadModelChangeListenerInvalidatesChannelBoostCache(t *testing.T) { + boosts := NewChannelBoostCache(16, time.Minute) + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 7} + otherPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 8} + boosts.put(100, peer, 2) + boosts.put(100, otherPeer, 3) + boosts.put(200, peer, 4) + boosts.putPeerTotal(peer, 10) + boosts.putPeerTotal(otherPeer, 11) + + listener := NewReadModelChangeListener("", ReadModelCacheSet{ChannelBoosts: boosts}, nil) + listener.handlePayload(`{"model":"channel_self_boosts","owner_user_id":100,"peer_type":"channel","peer_id":7}`) + + if _, ok := boosts.get(100, peer); ok { + t.Fatalf("channel_self_boosts 事件应失效精确 user/channel") + } + if _, ok := boosts.getPeerTotal(peer); ok { + t.Fatalf("channel_self_boosts 事件应失效 channel total") + } + if got, ok := boosts.get(100, otherPeer); !ok || got != 3 { + t.Fatalf("其它频道不应失效: %d ok=%v", got, ok) + } + if got, ok := boosts.get(200, peer); !ok || got != 4 { + t.Fatalf("其它用户不应失效: %d ok=%v", got, ok) + } + if got, ok := boosts.getPeerTotal(otherPeer); !ok || got != 11 { + t.Fatalf("其它频道 total 不应失效: %d ok=%v", got, ok) + } +} + +func TestChannelStoreInvalidateBoostCacheForUserAndPeers(t *testing.T) { + boosts := NewChannelBoostCache(16, time.Minute) + store := &ChannelStore{boostCache: boosts} + oldPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 7} + newPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 8} + otherPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 9} + + boosts.put(100, oldPeer, 1) + boosts.put(100, newPeer, 1) + boosts.put(200, oldPeer, 1) + boosts.putPeerTotal(oldPeer, 10) + boosts.putPeerTotal(newPeer, 11) + boosts.putPeerTotal(otherPeer, 12) + + store.invalidateBoostCacheForUserAndPeers(100, map[domain.Peer]struct{}{ + oldPeer: {}, + newPeer: {}, + }) + + if _, ok := boosts.get(100, oldPeer); ok { + t.Fatalf("当前用户 old peer self boost cache 应失效") + } + if _, ok := boosts.get(100, newPeer); ok { + t.Fatalf("当前用户 new peer self boost cache 应失效") + } + if got, ok := boosts.get(200, oldPeer); !ok || got != 1 { + t.Fatalf("其它用户 self boost cache 不应失效: %d ok=%v", got, ok) + } + if _, ok := boosts.getPeerTotal(oldPeer); ok { + t.Fatalf("old peer total cache 应失效") + } + if _, ok := boosts.getPeerTotal(newPeer); ok { + t.Fatalf("new peer total cache 应失效") + } + if got, ok := boosts.getPeerTotal(otherPeer); !ok || got != 12 { + t.Fatalf("其它频道 total cache 不应失效: %d ok=%v", got, ok) + } +} diff --git a/internal/store/postgres/channel_boosts.go b/internal/store/postgres/channel_boosts.go new file mode 100644 index 00000000..9840b650 --- /dev/null +++ b/internal/store/postgres/channel_boosts.go @@ -0,0 +1,543 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *ChannelStore) GetPremiumBoostStatus(ctx context.Context, viewerUserID, channelID int64, now int) (domain.PremiumBoostStatus, error) { + if viewerUserID == 0 || channelID == 0 { + return domain.PremiumBoostStatus{}, domain.ErrChannelInvalid + } + if _, _, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID); err != nil { + return domain.PremiumBoostStatus{}, err + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} + total, err := s.countActiveBoostsForPeer(ctx, s.db, peer, now) + if err != nil { + return domain.PremiumBoostStatus{}, err + } + my, err := listActiveUserBoostsForPeer(ctx, s.db, viewerUserID, peer, now) + if err != nil { + return domain.PremiumBoostStatus{}, err + } + return domain.PremiumBoostStatusForCount(peer, total, my), nil +} + +func (s *ChannelStore) ListPremiumBoosts(ctx context.Context, viewerUserID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error) { + if viewerUserID == 0 || channelID == 0 { + return domain.PremiumBoostList{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxPremiumBoostsListLimit { + limit = domain.MaxPremiumBoostsListLimit + } + _, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.PremiumBoostList{}, err + } + if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin { + return domain.PremiumBoostList{}, domain.ErrChannelAdminRequired + } + cursor, err := parseBoostCursor(offset) + if err != nil { + return domain.PremiumBoostList{}, domain.ErrChannelInvalid + } + return listPremiumBoostsPage(ctx, s.db, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, gifts, cursor, limit, now) +} + +func (s *ChannelStore) GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) { + if userID == 0 { + return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid + } + return s.premiumMyBoosts(ctx, s.db, userID, now, premiumUntil) +} + +func (s *ChannelStore) ApplyPremiumBoost(ctx context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error) { + if userID == 0 || channelID == 0 || len(slots) == 0 { + return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid + } + if premiumUntil <= now { + return domain.PremiumMyBoosts{}, domain.ErrPremiumRequired + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.PremiumMyBoosts{}, fmt.Errorf("apply premium boost: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.PremiumMyBoosts{}, fmt.Errorf("begin apply premium boost: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if _, _, err := s.getChannelForMember(ctx, tx, userID, channelID); err != nil { + return domain.PremiumMyBoosts{}, err + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} + invalidatedPeers := make(map[domain.Peer]struct{}, len(slots)+1) + for _, slotID := range slots { + if slotID != domain.DefaultPremiumBoostSlotID { + return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid + } + current, err := getPremiumBoostSlotForUpdate(ctx, tx, userID, slotID) + if err != nil { + return domain.PremiumMyBoosts{}, err + } + next, changed, err := domain.ApplyPremiumBoostSlot( + current, + userID, + slotID, + peer, + now, + premiumUntil, + domain.DefaultPremiumBoostReassignCooldownSeconds, + ) + if err != nil { + return domain.PremiumMyBoosts{}, err + } + if !changed { + continue + } + if current.Assigned(now) { + invalidatedPeers[current.Peer] = struct{}{} + } + if next.Assigned(now) { + invalidatedPeers[next.Peer] = struct{}{} + } + if err := upsertPremiumBoostSlot(ctx, tx, next); err != nil { + return domain.PremiumMyBoosts{}, fmt.Errorf("upsert premium boost slot: %w", err) + } + } + if err := tx.Commit(ctx); err != nil { + return domain.PremiumMyBoosts{}, fmt.Errorf("commit apply premium boost: %w", err) + } + committed = true + s.invalidateBoostCacheForUserAndPeers(userID, invalidatedPeers) + return s.premiumMyBoosts(ctx, s.db, userID, now, premiumUntil) +} + +func getPremiumBoostSlotForUpdate(ctx context.Context, db sqlcgen.DBTX, userID int64, slotID int) (domain.PremiumBoostSlot, error) { + row := db.QueryRow(ctx, ` +SELECT user_id, slot, peer_type, peer_id, assigned_at, expires_at, cooldown_until, multiplier, + gift, giveaway, unclaimed, giveaway_msg_id, used_gift_slug, stars +FROM channel_boost_slots +WHERE user_id = $1 AND slot = $2 +FOR UPDATE`, userID, slotID) + slot, err := scanPremiumBoostSlot(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PremiumBoostSlot{}, nil + } + return domain.PremiumBoostSlot{}, fmt.Errorf("lock premium boost slot: %w", err) + } + return slot, nil +} + +func upsertPremiumBoostSlot(ctx context.Context, db sqlcgen.DBTX, slot domain.PremiumBoostSlot) error { + peerType := string(slot.Peer.Type) + peerID := slot.Peer.ID + if peerID == 0 { + peerType = "" + } + _, err := db.Exec(ctx, ` +INSERT INTO channel_boost_slots ( + user_id, slot, peer_type, peer_id, assigned_at, expires_at, cooldown_until, multiplier, + gift, giveaway, unclaimed, giveaway_msg_id, used_gift_slug, stars +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) +ON CONFLICT (user_id, slot) DO UPDATE SET + peer_type = EXCLUDED.peer_type, + peer_id = EXCLUDED.peer_id, + assigned_at = EXCLUDED.assigned_at, + expires_at = EXCLUDED.expires_at, + cooldown_until = EXCLUDED.cooldown_until, + multiplier = EXCLUDED.multiplier, + gift = EXCLUDED.gift, + giveaway = EXCLUDED.giveaway, + unclaimed = EXCLUDED.unclaimed, + giveaway_msg_id = EXCLUDED.giveaway_msg_id, + used_gift_slug = EXCLUDED.used_gift_slug, + stars = EXCLUDED.stars, + updated_at = now()`, + slot.UserID, + slot.Slot, + peerType, + peerID, + slot.Date, + slot.Expires, + slot.CooldownUntil, + slot.Multiplier, + slot.Gift, + slot.Giveaway, + slot.Unclaimed, + slot.GiveawayMsgID, + slot.UsedGiftSlug, + slot.Stars, + ) + return err +} + +func (s *ChannelStore) GetPremiumUserBoosts(ctx context.Context, viewerUserID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error) { + if viewerUserID == 0 || channelID == 0 || targetUserID == 0 { + return domain.PremiumBoostList{}, domain.ErrChannelInvalid + } + _, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.PremiumBoostList{}, err + } + if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin { + return domain.PremiumBoostList{}, domain.ErrChannelAdminRequired + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} + items, err := listActiveUserBoostsForPeer(ctx, s.db, targetUserID, peer, now) + if err != nil { + return domain.PremiumBoostList{}, err + } + users, err := listUsersByIDs(ctx, s.db, []int64{targetUserID}) + if err != nil { + return domain.PremiumBoostList{}, err + } + return domain.PremiumBoostList{Count: len(items), Boosts: items, Users: users}, nil +} + +func (s *ChannelStore) SetBoostsToUnblockRestrictions(ctx context.Context, userID, channelID int64, boosts int) (domain.Channel, error) { + if userID == 0 || channelID == 0 || boosts < 0 || boosts > domain.MaxChannelBoostsToUnblockRestrictions { + return domain.Channel{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := s.db.Exec(ctx, `UPDATE channels SET boosts_unrestrict = $2, updated_at = now() WHERE id = $1`, channelID, boosts); err != nil { + return domain.Channel{}, fmt.Errorf("set boosts unrestrict: %w", err) + } + channel.BoostsUnrestrict = boosts + return channel, nil +} + +func (s *ChannelStore) premiumMyBoosts(ctx context.Context, db sqlcgen.DBTX, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) { + out := domain.PremiumMyBoosts{} + if premiumUntil <= now { + return out, nil + } + rows, err := db.Query(ctx, ` +SELECT user_id, slot, peer_type, peer_id, assigned_at, expires_at, cooldown_until, multiplier, + gift, giveaway, unclaimed, giveaway_msg_id, used_gift_slug, stars +FROM channel_boost_slots +WHERE user_id = $1 + AND (expires_at = 0 OR expires_at > $2) +ORDER BY slot ASC`, userID, now) + if err != nil { + return domain.PremiumMyBoosts{}, fmt.Errorf("list my boost slots: %w", err) + } + defer rows.Close() + foundBase := false + channelRefs := make(map[int64]struct{}) + for rows.Next() { + slot, err := scanPremiumBoostSlot(rows) + if err != nil { + return domain.PremiumMyBoosts{}, err + } + if slot.Slot == domain.DefaultPremiumBoostSlotID { + foundBase = true + if slot.Expires < premiumUntil { + slot.Expires = premiumUntil + } + } + out.Slots = append(out.Slots, slot) + if slot.Peer.Type == domain.PeerTypeChannel && slot.Peer.ID != 0 { + channelRefs[slot.Peer.ID] = struct{}{} + } + } + if err := rows.Err(); err != nil { + return domain.PremiumMyBoosts{}, err + } + if !foundBase { + out.Slots = append(out.Slots, domain.PremiumBoostSlot{ + UserID: userID, + Slot: domain.DefaultPremiumBoostSlotID, + Multiplier: 1, + }) + } + if len(channelRefs) > 0 { + channels, err := listChannelsByIDsInOrder(ctx, db, mapKeysInt64(channelRefs)) + if err != nil { + return domain.PremiumMyBoosts{}, err + } + out.Channels = channels + } + return out, nil +} + +func countActiveBoostsForPeer(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer, now int) (int, error) { + if peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return 0, domain.ErrChannelInvalid + } + var total int + if err := db.QueryRow(ctx, ` +SELECT COALESCE(SUM(multiplier), 0)::int +FROM channel_boost_slots +WHERE peer_type = $1 + AND peer_id = $2 + AND (expires_at = 0 OR expires_at > $3)`, string(peer.Type), peer.ID, now).Scan(&total); err != nil { + return 0, fmt.Errorf("count peer boosts: %w", err) + } + return total, nil +} + +func (s *ChannelStore) countActiveBoostsForPeer(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer, now int) (int, error) { + if !s.boostCacheActive(db) { + return countActiveBoostsForPeer(ctx, db, peer, now) + } + return s.boostCache.getPeerTotalOrLoad(ctx, peer, func() (int, error) { + return countActiveBoostsForPeer(ctx, db, peer, now) + }) +} + +func countActiveUserBoostsForPeer(ctx context.Context, db sqlcgen.DBTX, userID int64, peer domain.Peer, now int) (int, error) { + if userID == 0 || peer.Type != domain.PeerTypeChannel || peer.ID == 0 { + return 0, domain.ErrChannelInvalid + } + var total int + if err := db.QueryRow(ctx, ` +SELECT COALESCE(SUM(multiplier), 0)::int +FROM channel_boost_slots +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND (expires_at = 0 OR expires_at > $4)`, userID, string(peer.Type), peer.ID, now).Scan(&total); err != nil { + return 0, fmt.Errorf("count user peer boosts: %w", err) + } + return total, nil +} + +func (s *ChannelStore) countActiveUserBoostsForPeer(ctx context.Context, db sqlcgen.DBTX, userID int64, peer domain.Peer, now int) (int, error) { + if !s.boostCacheActive(db) { + return countActiveUserBoostsForPeer(ctx, db, userID, peer, now) + } + return s.boostCache.getOrLoad(ctx, userID, peer, func() (int, error) { + return countActiveUserBoostsForPeer(ctx, db, userID, peer, now) + }) +} + +func countActiveUserBoostsForChannels(ctx context.Context, db sqlcgen.DBTX, userID int64, channelIDs []int64, now int) (map[int64]int, error) { + out := make(map[int64]int) + ids := uniqueNonZeroInt64s(channelIDs...) + if userID == 0 || len(ids) == 0 { + return out, nil + } + rows, err := db.Query(ctx, ` +SELECT peer_id, COALESCE(SUM(multiplier), 0)::int +FROM channel_boost_slots +WHERE user_id = $1 + AND peer_type = 'channel' + AND peer_id = ANY($2::bigint[]) + AND (expires_at = 0 OR expires_at > $3) +GROUP BY peer_id`, userID, ids, now) + if err != nil { + return nil, fmt.Errorf("count user channel boosts: %w", err) + } + defer rows.Close() + for rows.Next() { + var channelID int64 + var boosts int + if err := rows.Scan(&channelID, &boosts); err != nil { + return nil, err + } + out[channelID] = boosts + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) countActiveUserBoostsForChannels(ctx context.Context, db sqlcgen.DBTX, userID int64, channelIDs []int64, now int) (map[int64]int, error) { + if !s.boostCacheActive(db) { + return countActiveUserBoostsForChannels(ctx, db, userID, channelIDs, now) + } + out := make(map[int64]int) + misses := make([]int64, 0, len(channelIDs)) + for _, channelID := range uniqueNonZeroInt64s(channelIDs...) { + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} + if boosts, ok := s.boostCache.get(userID, peer); ok { + out[channelID] = boosts + continue + } + misses = append(misses, channelID) + } + if len(misses) == 0 { + return out, nil + } + loaded, err := countActiveUserBoostsForChannels(ctx, db, userID, misses, now) + if err != nil { + return nil, err + } + for _, channelID := range misses { + boosts := loaded[channelID] + out[channelID] = boosts + s.boostCache.put(userID, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, boosts) + } + return out, nil +} + +func (s *ChannelStore) invalidateBoostCacheForUserAndPeers(userID int64, peers map[domain.Peer]struct{}) { + if s.boostCache == nil { + return + } + s.boostCache.deleteUser(userID) + for peer := range peers { + s.boostCache.deletePeerTotal(peer) + } +} + +func listActiveUserBoostsForPeer(ctx context.Context, db sqlcgen.DBTX, userID int64, peer domain.Peer, now int) ([]domain.PremiumBoostSlot, error) { + rows, err := db.Query(ctx, ` +SELECT user_id, slot, peer_type, peer_id, assigned_at, expires_at, cooldown_until, multiplier, + gift, giveaway, unclaimed, giveaway_msg_id, used_gift_slug, stars +FROM channel_boost_slots +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND (expires_at = 0 OR expires_at > $4) +ORDER BY assigned_at DESC, user_id ASC, slot ASC`, userID, string(peer.Type), peer.ID, now) + if err != nil { + return nil, fmt.Errorf("list user boosts for peer: %w", err) + } + defer rows.Close() + return scanPremiumBoostSlots(rows) +} + +type boostCursor struct { + assignedAt int + userID int64 + slot int + set bool +} + +func listPremiumBoostsPage(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer, gifts bool, cursor boostCursor, limit, now int) (domain.PremiumBoostList, error) { + var count int + if err := db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_boost_slots +WHERE peer_type = $1 + AND peer_id = $2 + AND (expires_at = 0 OR expires_at > $3) + AND (NOT $4::boolean OR gift OR giveaway)`, string(peer.Type), peer.ID, now, gifts).Scan(&count); err != nil { + return domain.PremiumBoostList{}, fmt.Errorf("count premium boosts: %w", err) + } + args := []any{string(peer.Type), peer.ID, now, gifts, limit + 1} + cursorSQL := "" + if cursor.set { + args = append(args, cursor.assignedAt, cursor.userID, cursor.slot) + cursorSQL = fmt.Sprintf(` AND ( + assigned_at < $%d + OR (assigned_at = $%d AND user_id > $%d) + OR (assigned_at = $%d AND user_id = $%d AND slot > $%d) + )`, len(args)-2, len(args)-2, len(args)-1, len(args)-2, len(args)-1, len(args)) + } + rows, err := db.Query(ctx, ` +SELECT user_id, slot, peer_type, peer_id, assigned_at, expires_at, cooldown_until, multiplier, + gift, giveaway, unclaimed, giveaway_msg_id, used_gift_slug, stars +FROM channel_boost_slots +WHERE peer_type = $1 + AND peer_id = $2 + AND (expires_at = 0 OR expires_at > $3) + AND (NOT $4::boolean OR gift OR giveaway)`+cursorSQL+` +ORDER BY assigned_at DESC, user_id ASC, slot ASC +LIMIT $5`, args...) + if err != nil { + return domain.PremiumBoostList{}, fmt.Errorf("list premium boosts: %w", err) + } + defer rows.Close() + items, err := scanPremiumBoostSlots(rows) + if err != nil { + return domain.PremiumBoostList{}, err + } + next := "" + if len(items) > limit { + last := items[limit-1] + next = formatBoostCursor(last) + items = items[:limit] + } + userIDs := make([]int64, 0, len(items)) + for _, item := range items { + userIDs = append(userIDs, item.UserID) + } + users, err := listUsersByIDs(ctx, db, uniqueNonZeroInt64s(userIDs...)) + if err != nil { + return domain.PremiumBoostList{}, err + } + return domain.PremiumBoostList{Count: count, Boosts: items, Users: users, NextOffset: next}, nil +} + +func scanPremiumBoostSlots(rows pgx.Rows) ([]domain.PremiumBoostSlot, error) { + items := make([]domain.PremiumBoostSlot, 0) + for rows.Next() { + slot, err := scanPremiumBoostSlot(rows) + if err != nil { + return nil, err + } + items = append(items, slot) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +func scanPremiumBoostSlot(row rowScanner) (domain.PremiumBoostSlot, error) { + var slot domain.PremiumBoostSlot + var peerType string + if err := row.Scan( + &slot.UserID, &slot.Slot, &peerType, &slot.Peer.ID, &slot.Date, &slot.Expires, &slot.CooldownUntil, &slot.Multiplier, + &slot.Gift, &slot.Giveaway, &slot.Unclaimed, &slot.GiveawayMsgID, &slot.UsedGiftSlug, &slot.Stars, + ); err != nil { + return domain.PremiumBoostSlot{}, err + } + slot.Peer.Type = domain.PeerType(peerType) + if slot.Peer.Type == "" || slot.Peer.ID == 0 { + slot.Peer = domain.Peer{} + } + return slot, nil +} + +func parseBoostCursor(raw string) (boostCursor, error) { + if raw == "" { + return boostCursor{}, nil + } + parts := strings.Split(raw, ":") + if len(parts) != 3 { + return boostCursor{}, fmt.Errorf("invalid boost cursor") + } + assignedAt, err := strconv.Atoi(parts[0]) + if err != nil || assignedAt < 0 { + return boostCursor{}, fmt.Errorf("invalid boost cursor") + } + userID, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || userID <= 0 { + return boostCursor{}, fmt.Errorf("invalid boost cursor") + } + slot, err := strconv.Atoi(parts[2]) + if err != nil || slot <= 0 { + return boostCursor{}, fmt.Errorf("invalid boost cursor") + } + return boostCursor{assignedAt: assignedAt, userID: userID, slot: slot, set: true}, nil +} + +func formatBoostCursor(slot domain.PremiumBoostSlot) string { + return fmt.Sprintf("%d:%d:%d", slot.Date, slot.UserID, slot.Slot) +} diff --git a/internal/store/postgres/channel_core.go b/internal/store/postgres/channel_core.go new file mode 100644 index 00000000..565d54fd --- /dev/null +++ b/internal/store/postgres/channel_core.go @@ -0,0 +1,635 @@ +package postgres + +import ( + "context" + "crypto/rand" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "golang.org/x/sync/errgroup" + "sort" + "strings" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" + "time" +) + +// channelIDAtLeastAllocator 是 Redis 分配器的撞键自愈扩展:一次把计数器 +// 顶到 floor 之上,避免按 1 步进追赶大空洞。 +type channelIDAtLeastAllocator interface { + NextChannelIDAtLeast(ctx context.Context, floor int64) (int64, error) +} + +// allocateFreshChannelID 分配未被占用的 channel id。计数器可能落后于 +// channels 表真实最大 id(Redis 快照回退、或测试 fallback 分配器绕过 +// Redis 直写同一库),盲用会撞主键且可能污染后续 channel message id +// 分配;这里先点查预检,撞到就把分配器对账到表内最大 id 再取。 +func (s *ChannelStore) allocateFreshChannelID(ctx context.Context) (int64, error) { + const maxAttempts = 4 + channelID, err := s.ids.NextChannelID(ctx) + if err != nil { + return 0, fmt.Errorf("allocate channel id: %w", err) + } + for attempt := 0; attempt < maxAttempts; attempt++ { + var exists bool + if err := s.db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM channels WHERE id = $1)`, channelID).Scan(&exists); err != nil { + return 0, fmt.Errorf("probe channel id %d: %w", channelID, err) + } + if !exists { + return channelID, nil + } + var maxID int64 + if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channels`).Scan(&maxID); err != nil { + return 0, fmt.Errorf("load max channel id: %w", err) + } + if atLeast, ok := s.ids.(channelIDAtLeastAllocator); ok { + channelID, err = atLeast.NextChannelIDAtLeast(ctx, maxID) + } else { + channelID, err = s.ids.NextChannelID(ctx) + } + if err != nil { + return 0, fmt.Errorf("re-allocate channel id past %d: %w", maxID, err) + } + } + return 0, fmt.Errorf("allocate channel id: counter still colliding after %d attempts (id %d)", maxAttempts, channelID) +} + +func (s *ChannelStore) CreateChannel(ctx context.Context, req domain.CreateChannelRequest) (domain.CreateChannelResult, error) { + if req.CreatorUserID == 0 || strings.TrimSpace(req.Title) == "" { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if !req.Broadcast && !req.Megagroup { + req.Broadcast = true + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("create channel: db does not support transactions") + } + channelID, err := s.allocateFreshChannelID(ctx) + if err != nil { + return domain.CreateChannelResult{}, err + } + accessHash, err := randomChannelAccessHash() + if err != nil { + return domain.CreateChannelResult{}, err + } + inviteID, err := randomPositiveInt64() + if err != nil { + return domain.CreateChannelResult{}, err + } + inviteHash, err := randomInviteHash() + if err != nil { + return domain.CreateChannelResult{}, err + } + date := req.Date + if date == 0 { + date = nowUnix() + } + members := []domain.ChannelMember{creatorChannelMember(channelID, req.CreatorUserID, date)} + for _, userID := range uniqueChannelUserIDs(req.MemberUserIDs, req.CreatorUserID) { + members = append(members, domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: req.CreatorUserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + }) + } + + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin create channel: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + + msgID, err := s.msgIDs.NextChannelMessageID(ctx, channelID) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("allocate channel message id: %w", err) + } + pts := 1 + channel := domain.Channel{ + ID: channelID, + AccessHash: accessHash, + CreatorUserID: req.CreatorUserID, + Title: strings.TrimSpace(req.Title), + About: req.About, + Broadcast: req.Broadcast, + Megagroup: req.Megagroup, + Forum: req.Forum, + ForumTabs: req.ForumTabs, + ParticipantsCount: len(members), + AdminsCount: 1, + HasLink: true, + TopMessageID: msgID, + Pts: pts, + TTLPeriod: req.TTLPeriod, + Date: date, + } + msg := domain.ChannelMessage{ + ChannelID: channelID, + ID: msgID, + SenderUserID: req.CreatorUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.CreatorUserID}, + Date: date, + Post: channel.Broadcast, + Action: &domain.ChannelMessageAction{Type: domain.ChannelActionCreate, Title: channel.Title}, + Pts: pts, + } + event := domain.ChannelUpdateEvent{ + ChannelID: channelID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: date, + Message: msg, + SenderUserID: req.CreatorUserID, + } + if err := insertChannelTx(ctx, tx, channel); err != nil { + return domain.CreateChannelResult{}, err + } + for _, member := range members { + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.CreateChannelResult{}, err + } + } + if err := insertChannelMessageTx(ctx, tx, msg); err != nil { + return domain.CreateChannelResult{}, err + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.CreateChannelResult{}, err + } + for _, member := range members { + readMax := 0 + if member.UserID == req.CreatorUserID { + readMax = msgID + } + if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, readMax, 0); err != nil { + return domain.CreateChannelResult{}, err + } + } + if err := insertChannelInviteTx(ctx, tx, domain.ChannelInvite{ + ChannelID: channelID, + InviteID: inviteID, + Hash: inviteHash, + AdminUserID: req.CreatorUserID, + Permanent: true, + Date: date, + }); err != nil { + return domain.CreateChannelResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit create channel: %w", err) + } + committed = true + return domain.CreateChannelResult{ + Channel: channel, + Members: append([]domain.ChannelMember(nil), members...), + Message: msg, + Event: event, + Recipients: channelMemberIDs(members), + }, nil +} + +func (s *ChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { + channel, member, preview, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + if preview { + selfBoosts, err := s.countActiveUserBoostsForPeer(ctx, s.db, viewerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, nowUnix()) + if err != nil { + return domain.ChannelView{}, err + } + return domain.ChannelView{ + Channel: channel, + Self: member, + Dialog: previewChannelDialog(viewerUserID, channel, member), + SelfBoostsApplied: selfBoosts, + }, nil + } + // getChannelDialog 与 countActiveUserBoostsForPeer 互不依赖,并发执行省一次串行往返。 + // 两者都走 s.db(pgxpool,并发安全,各取独立连接),各写自己的返回变量。 + var ( + dialog domain.ChannelDialog + selfBoosts int + exportedInvite *domain.ChannelInvite + ) + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + var derr error + dialog, derr = s.getChannelDialog(gctx, s.db, viewerUserID, channel) + return derr + }) + g.Go(func() error { + var berr error + selfBoosts, berr = s.countActiveUserBoostsForPeer(gctx, s.db, viewerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, nowUnix()) + return berr + }) + if canExportChannelInvite(member) { + g.Go(func() error { + invite, found, ierr := s.getPermanentInviteForAdmin(gctx, s.db, channel.ID, viewerUserID) + if ierr != nil { + return ierr + } + if found { + exportedInvite = &invite + } + return nil + }) + } + if err := g.Wait(); err != nil { + return domain.ChannelView{}, err + } + return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog, SelfBoostsApplied: selfBoosts, ExportedInvite: exportedInvite}, nil +} + +// ResolveChannel 是 GetChannel 的轻量版:只做访问校验并返回 Channel(含 access_hash)+Self, +// 跳过 dialog top message / 读态 / boost 求和这 3 条额外 PG 查询。供 inputPeerFor 等只需 +// access_hash / 频道标志的纯解析路径用——它们此前为拿一个 access_hash 付了完整 4 查询投影。 +// 访问语义与 GetChannel 完全一致(私有非成员 ErrChannelPrivate、公开预览成员)。 +func (s *ChannelStore) ResolveChannel(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelView, error) { + channel, member, preview, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelView{}, err + } + view := domain.ChannelView{Channel: channel, Self: member} + if preview { + // previewChannelDialog 是纯内存构造(无额外查询),保持与 GetChannel 预览态的 Dialog 一致。 + view.Dialog = previewChannelDialog(viewerUserID, channel, member) + } + return view, nil +} + +func (s *ChannelStore) GetChannels(ctx context.Context, viewerUserID int64, channelIDs []int64) ([]domain.ChannelView, error) { + if viewerUserID == 0 || len(channelIDs) == 0 { + return nil, nil + } + ids := uniqueNonZeroInt64s(channelIDs...) + if len(ids) == 0 { + return nil, nil + } + views := make(map[int64]domain.ChannelView, len(ids)) + previewMembers := make(map[int64]domain.ChannelMember) + activeIDs := make([]int64, 0, len(ids)) + + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+`, + m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at, + m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts, + m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date +FROM channels c +JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 +WHERE c.id = ANY($2::bigint[]) AND NOT c.deleted`, viewerUserID, ids) + if err != nil { + return nil, fmt.Errorf("list channels for viewer: %w", err) + } + for rows.Next() { + channel, member, err := scanChannelWithMember(rows) + if err != nil { + rows.Close() + return nil, err + } + if err := validateChannelMemberVisible(member); err != nil { + if errors.Is(err, domain.ErrChannelUserBanned) { + views[channel.ID] = domain.ChannelView{Channel: channel, Self: member, Forbidden: true} + continue + } + if errors.Is(err, domain.ErrChannelPrivate) { + previewMembers[channel.ID] = member + continue + } + rows.Close() + return nil, err + } + views[channel.ID] = domain.ChannelView{Channel: channel, Self: member} + activeIDs = append(activeIDs, channel.ID) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, fmt.Errorf("list channels for viewer: %w", err) + } + rows.Close() + + dialogs, err := s.getChannelDialogs(ctx, s.db, viewerUserID, activeIDs) + if err != nil { + return nil, err + } + selfBoostsByChannelID, err := s.countActiveUserBoostsForChannels(ctx, s.db, viewerUserID, activeIDs, nowUnix()) + if err != nil { + return nil, err + } + for id, dialog := range dialogs { + view := views[id] + view.Dialog = dialog + view.SelfBoostsApplied = selfBoostsByChannelID[id] + views[id] = view + } + + remaining := make([]int64, 0, len(ids)-len(views)) + for _, id := range ids { + if _, ok := views[id]; ok { + continue + } + remaining = append(remaining, id) + } + channels, err := listChannelsByIDs(ctx, s.db, remaining) + if err != nil { + return nil, err + } + for _, channel := range channels { + if member, _, ok, err := s.monoforumAdminPreview(ctx, s.db, viewerUserID, channel); err != nil { + return nil, err + } else if ok { + views[channel.ID] = domain.ChannelView{ + Channel: channel, + Self: member, + Dialog: previewChannelDialog(viewerUserID, channel, member), + SelfBoostsApplied: 0, + } + continue + } + if !publicPreviewableChannel(channel) { + continue + } + existing, found := previewMembers[channel.ID] + member := publicPreviewMember(channel, viewerUserID, existing, found) + views[channel.ID] = domain.ChannelView{ + Channel: channel, + Self: member, + Dialog: previewChannelDialog(viewerUserID, channel, member), + SelfBoostsApplied: 0, + } + } + + out := make([]domain.ChannelView, 0, len(views)) + for _, id := range ids { + if view, ok := views[id]; ok { + out = append(out, view) + } + } + return out, nil +} + +func (s *ChannelStore) GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) { + if channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return s.channelByID(ctx, s.db, channelID) +} + +func insertChannelTx(ctx context.Context, tx pgx.Tx, ch domain.Channel) error { + rights, err := marshalJSON(ch.DefaultBannedRights, "{}") + if err != nil { + return err + } + reactions, err := marshalJSON(ch.ReactionPolicy, "{}") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channels ( + id, access_hash, creator_user_id, title, about, username, verified, broadcast, megagroup, forum, forum_tabs, + autotranslation, restricted_sponsored, broadcast_messages_allowed, send_paid_messages_stars, + noforwards, join_to_send, join_request, signatures, pre_history_hidden, participants_hidden, antispam, linked_chat_id, slowmode_seconds, boosts_unrestrict, default_banned_rights, available_reactions, + color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, emoji_status_document_id, emoji_status_until, + participants_count, admins_count, kicked_count, banned_count, top_message_id, pinned_message_id, pts, ttl_period, date, deleted, monoforum, linked_monoforum_id +) VALUES ($1,$2,$3,$4,$5,NULLIF($6,''),$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,$41,$42,$43,$44,$45,$46,$47)`, + ch.ID, ch.AccessHash, ch.CreatorUserID, ch.Title, ch.About, ch.Username, ch.Verified, ch.Broadcast, ch.Megagroup, ch.Forum, + ch.ForumTabs, ch.Autotranslation, ch.RestrictedSponsored, ch.BroadcastMessagesAllowed, ch.SendPaidMessagesStars, ch.NoForwards, ch.JoinToSend, ch.JoinRequest, ch.Signatures, ch.PreHistoryHidden, ch.ParticipantsHidden, ch.AntiSpam, ch.LinkedChatID, ch.SlowmodeSeconds, ch.BoostsUnrestrict, rights, reactions, + ch.Color.HasColor, ch.Color.Color, ch.Color.BackgroundEmojiID, ch.ProfileColor.HasColor, ch.ProfileColor.Color, ch.ProfileColor.BackgroundEmojiID, ch.EmojiStatus.DocumentID, ch.EmojiStatus.Until, + ch.ParticipantsCount, ch.AdminsCount, + ch.KickedCount, ch.BannedCount, ch.TopMessageID, ch.PinnedMessageID, ch.Pts, ch.TTLPeriod, ch.Date, ch.Deleted, ch.Monoforum, ch.LinkedMonoforumID); err != nil { + return fmt.Errorf("insert channel: %w", err) + } + return nil +} + +func scanChannel(row rowScanner) (domain.Channel, error) { + var ch domain.Channel + var rights, reactionPolicy string + var wallpaper *string + if err := row.Scan(channelScanDest(&ch, &rights, &reactionPolicy, &wallpaper)...); err != nil { + return domain.Channel{}, err + } + finishChannelScan(&ch, rights, reactionPolicy, wallpaper) + return ch, nil +} + +func channelScanDest(ch *domain.Channel, rights, reactionPolicy *string, wallpaper **string) []any { + return []any{ + &ch.ID, &ch.AccessHash, &ch.CreatorUserID, &ch.Title, &ch.About, &ch.Username, &ch.Verified, + &ch.Broadcast, &ch.Megagroup, &ch.Forum, &ch.ForumTabs, &ch.Autotranslation, &ch.RestrictedSponsored, &ch.BroadcastMessagesAllowed, &ch.SendPaidMessagesStars, &ch.NoForwards, &ch.JoinToSend, &ch.JoinRequest, &ch.Signatures, &ch.PreHistoryHidden, &ch.ParticipantsHidden, &ch.AntiSpam, &ch.HasLink, &ch.LinkedChatID, &ch.Monoforum, &ch.LinkedMonoforumID, &ch.SlowmodeSeconds, &ch.BoostsUnrestrict, rights, + reactionPolicy, &ch.Color.HasColor, &ch.Color.Color, &ch.Color.BackgroundEmojiID, &ch.ProfileColor.HasColor, &ch.ProfileColor.Color, &ch.ProfileColor.BackgroundEmojiID, &ch.EmojiStatus.DocumentID, &ch.EmojiStatus.Until, + wallpaper, &ch.ParticipantsCount, &ch.AdminsCount, &ch.KickedCount, &ch.BannedCount, &ch.TopMessageID, + &ch.PinnedMessageID, &ch.Pts, &ch.TTLPeriod, &ch.Date, &ch.Deleted, + &ch.PhotoID, &ch.PhotoDCID, &ch.PhotoStripped, + &ch.ActiveCallID, &ch.ActiveCallAccessHash, &ch.ActiveCallNotEmpty, + } +} + +func finishChannelScan(ch *domain.Channel, rights, reactionPolicy string, wallpaper *string) { + _ = json.Unmarshal([]byte(rights), &ch.DefaultBannedRights) + _ = json.Unmarshal([]byte(reactionPolicy), &ch.ReactionPolicy) + if wallpaper != nil { + ch.Wallpaper = decodeJSONPtr[domain.Wallpaper](*wallpaper) + } +} + +func publicPreviewableChannel(channel domain.Channel) bool { + return !channel.Deleted && + (channel.Broadcast || channel.Megagroup) && + strings.TrimSpace(channel.Username) != "" +} + +func refreshChannelCountsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) (domain.Channel, error) { + var participants, admins, kicked, banned int + rows, err := tx.Query(ctx, ` +SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, + rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +FROM channel_members +WHERE channel_id = $1`, channel.ID) + if err != nil { + return domain.Channel{}, fmt.Errorf("list channel members for counts: %w", err) + } + defer rows.Close() + for rows.Next() { + member, err := scanChannelMember(rows) + if err != nil { + return domain.Channel{}, err + } + if member.Status == domain.ChannelMemberKicked { + kicked++ + } + if member.Status != domain.ChannelMemberActive { + continue + } + participants++ + if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + admins++ + } + if !zeroChannelBannedRights(member.BannedRights) { + banned++ + } + } + if err := rows.Err(); err != nil { + return domain.Channel{}, err + } + if _, err := tx.Exec(ctx, ` +UPDATE channels +SET participants_count = $2, admins_count = $3, kicked_count = $4, banned_count = $5, updated_at = now() +WHERE id = $1`, channel.ID, participants, admins, kicked, banned); err != nil { + return domain.Channel{}, fmt.Errorf("refresh channel counts: %w", err) + } + channel.ParticipantsCount = participants + channel.AdminsCount = admins + channel.KickedCount = kicked + channel.BannedCount = banned + return channel, nil +} + +func addPeerRef(peer domain.Peer, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { + switch peer.Type { + case domain.PeerTypeUser: + if peer.ID != 0 { + userRefs[peer.ID] = struct{}{} + } + case domain.PeerTypeChannel: + if peer.ID != 0 && peer.ID != currentChannelID { + channelRefs[peer.ID] = struct{}{} + } + } +} + +func mapKeysInt64(items map[int64]struct{}) []int64 { + if len(items) == 0 { + return nil + } + out := make([]int64, 0, len(items)) + for id := range items { + if id != 0 { + out = append(out, id) + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func uniqueChannelUserIDs(ids []int64, exclude int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id == 0 || id == exclude { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func uniqueNonZeroInt64s(items ...int64) []int64 { + seen := make(map[int64]struct{}, len(items)) + out := make([]int64, 0, len(items)) + for _, item := range items { + if item == 0 { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} + +func marshalJSON(v any, empty string) ([]byte, error) { + if v == nil { + return []byte(empty), nil + } + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + if string(raw) == "null" { + return []byte(empty), nil + } + return raw, nil +} + +func int64s(ids []int64) []int64 { + return append([]int64(nil), ids...) +} + +// nonNullInt64s 返回非 nil 的 []int64 副本,供写入 NOT NULL bigint[] 列时用——nil 切片会被 +// pgx 编码成 SQL NULL 违反 NOT NULL 约束(列的 DEFAULT '{}' 只在列被省略时生效,显式传参不触发)。 +// 空列表写成 '{}' 而非 NULL;读路径仍把空数组归一回 nil,双 store 行为一致。 +func nonNullInt64s(ids []int64) []int64 { + if len(ids) == 0 { + return []int64{} + } + return append([]int64(nil), ids...) +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func decodeJSONPtr[T any](raw string) *T { + if raw == "" || raw == "{}" || raw == "null" { + return nil + } + var out T + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil + } + return &out +} + +func randomChannelAccessHash() (int64, error) { + return randomPositiveInt64() +} + +func randomPositiveInt64() (int64, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, fmt.Errorf("rand int64: %w", err) + } + return int64(binary.LittleEndian.Uint64(b[:]) & ((1 << 63) - 1)), nil +} + +func nowUnix() int { + return int(time.Now().Unix()) +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +func isRetryablePostgresTxError(err error) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return false + } + return pgErr.Code == "40P01" || pgErr.Code == "40001" +} + +type pgChannelIDAllocator struct { + db sqlcgen.DBTX +} diff --git a/internal/store/postgres/channel_core_integration_test.go b/internal/store/postgres/channel_core_integration_test.go new file mode 100644 index 00000000..10b49971 --- /dev/null +++ b/internal/store/postgres/channel_core_integration_test.go @@ -0,0 +1,125 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestChannelStoreGetChannelsBatchesVisibleAndPublicPreview(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1887"+suffix+"81", "BatchOwner", "") + member := createTestUser(t, ctx, users, "+1887"+suffix+"82", "BatchMember", "") + viewer := createTestUser(t, ctx, users, "+1887"+suffix+"83", "BatchViewer", "") + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID, viewer.ID}) + }) + + channels := NewChannelStore(pool) + joined, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + MemberUserIDs: []int64{viewer.ID}, + Title: "batch joined " + suffix, + Megagroup: true, + Date: 1700001200, + }) + if err != nil { + t.Fatalf("create joined channel: %v", err) + } + channelIDs = append(channelIDs, joined.Channel.ID) + joinedWallpaper := &domain.Wallpaper{ + ID: 8181, + NoFile: true, + Settings: domain.WallpaperSettings{ + HasBackgroundColor: true, + BackgroundColor: 0x66ccaa, + }, + } + if _, err := channels.SetChannelWallpaper(ctx, domain.SetChannelWallpaperRequest{ + UserID: owner.ID, + ChannelID: joined.Channel.ID, + Wallpaper: joinedWallpaper, + Date: 1700001201, + }); err != nil { + t.Fatalf("set joined wallpaper: %v", err) + } + publicCreated, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "batch public " + suffix, + Broadcast: true, + Date: 1700001210, + }) + if err != nil { + t.Fatalf("create public channel: %v", err) + } + channelIDs = append(channelIDs, publicCreated.Channel.ID) + publicChannel, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: publicCreated.Channel.ID, + Username: "batchpub" + suffix, + }) + if err != nil { + t.Fatalf("set public username: %v", err) + } + publicWallpaper := &domain.Wallpaper{ + ID: 8182, + NoFile: true, + Settings: domain.WallpaperSettings{ + HasBackgroundColor: true, + BackgroundColor: 0x5aa0ee, + }, + } + if _, err := channels.SetChannelWallpaper(ctx, domain.SetChannelWallpaperRequest{ + UserID: owner.ID, + ChannelID: publicCreated.Channel.ID, + Wallpaper: publicWallpaper, + Date: 1700001211, + }); err != nil { + t.Fatalf("set public wallpaper: %v", err) + } + private, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + MemberUserIDs: []int64{member.ID}, + Title: "batch private " + suffix, + Megagroup: true, + Date: 1700001220, + }) + if err != nil { + t.Fatalf("create private channel: %v", err) + } + channelIDs = append(channelIDs, private.Channel.ID) + + views, err := channels.GetChannels(ctx, viewer.ID, []int64{joined.Channel.ID, publicChannel.ID, private.Channel.ID, joined.Channel.ID}) + if err != nil { + t.Fatalf("get channels batch: %v", err) + } + if len(views) != 2 { + t.Fatalf("views len = %d, want joined + public preview", len(views)) + } + if views[0].Channel.ID != joined.Channel.ID || views[0].Self.Status != domain.ChannelMemberActive { + t.Fatalf("first view = %+v, want active joined channel", views[0]) + } + if !domain.WallpaperEqual(views[0].Channel.Wallpaper, joinedWallpaper) { + t.Fatalf("joined wallpaper = %+v, want %+v", views[0].Channel.Wallpaper, joinedWallpaper) + } + if views[0].Dialog.UserID != viewer.ID || views[0].Dialog.ChannelID != joined.Channel.ID || views[0].Dialog.TopMessageID == 0 { + t.Fatalf("joined dialog = %+v, want viewer dialog", views[0].Dialog) + } + if views[1].Channel.ID != publicChannel.ID || views[1].Self.Status != domain.ChannelMemberLeft { + t.Fatalf("second view = %+v, want public preview", views[1]) + } + if !domain.WallpaperEqual(views[1].Channel.Wallpaper, publicWallpaper) { + t.Fatalf("public wallpaper = %+v, want %+v", views[1].Channel.Wallpaper, publicWallpaper) + } + if views[1].Dialog.UserID != viewer.ID || views[1].Dialog.ChannelID != publicChannel.ID || views[1].Dialog.TopMessageID != views[1].Channel.TopMessageID { + t.Fatalf("public preview dialog = %+v, want synthetic preview dialog", views[1].Dialog) + } +} diff --git a/internal/store/postgres/channel_delete_monoforum_integration_test.go b/internal/store/postgres/channel_delete_monoforum_integration_test.go new file mode 100644 index 00000000..eba5bf70 --- /dev/null +++ b/internal/store/postgres/channel_delete_monoforum_integration_test.go @@ -0,0 +1,84 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestDeleteChannelCascadesLinkedMonoforumPostgres 回归 Risk A:删除开启了 Direct Messages 的母广播 +// 频道时,关联 monoforum 在同一事务内被软删并随结果返回。不级联会留下指向已删父频道的孤儿 mono。 +// 门控于 TELESRV_TEST_POSTGRES_DSN。 +func TestDeleteChannelCascadesLinkedMonoforumPostgres(t *testing.T) { + pool := testPool(t) // 未设 DSN 会 t.Skip + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1778" + suffix + "31", FirstName: "DelMonoOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) > 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, Title: "Del Mono BC " + suffix, Broadcast: true, Date: 1700000900, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + parentID := created.Channel.ID + channelIDs = append(channelIDs, parentID) + + enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, parentID, 0, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + if monoID == 0 { + t.Fatalf("no monoforum created") + } + channelIDs = append(channelIDs, monoID) + + res, err := channels.DeleteChannel(ctx, domain.DeleteChannelRequest{UserID: owner.ID, ChannelID: parentID, Date: 1700001000}) + if err != nil { + t.Fatalf("delete parent: %v", err) + } + if !res.Channel.Deleted { + t.Fatalf("parent not marked deleted: %+v", res.Channel) + } + if res.LinkedMonoforum == nil || res.LinkedMonoforum.ID != monoID || !res.LinkedMonoforum.Deleted { + t.Fatalf("result LinkedMonoforum = %+v, want deleted mono %d", res.LinkedMonoforum, monoID) + } + + // 两行在 DB 里都必须 deleted=true。 + var parentDeleted, monoDeleted bool + if err := pool.QueryRow(ctx, "SELECT deleted FROM channels WHERE id = $1", parentID).Scan(&parentDeleted); err != nil { + t.Fatalf("read parent deleted: %v", err) + } + if err := pool.QueryRow(ctx, "SELECT deleted FROM channels WHERE id = $1", monoID).Scan(&monoDeleted); err != nil { + t.Fatalf("read mono deleted: %v", err) + } + if !parentDeleted || !monoDeleted { + t.Fatalf("deleted flags parent=%v mono=%v, want both true (orphan monoforum left behind)", parentDeleted, monoDeleted) + } + + // 删后 getDialogs 不再返回母频道或 mono。 + dialogs, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{Limit: 100}) + if err != nil { + t.Fatalf("list dialogs after delete: %v", err) + } + for _, d := range dialogs.Dialogs { + if d.Peer.ID == monoID || d.Peer.ID == parentID { + t.Fatalf("dialogs after delete still include parent/mono: %+v", d) + } + } +} diff --git a/internal/store/postgres/channel_dialog_cache.go b/internal/store/postgres/channel_dialog_cache.go new file mode 100644 index 00000000..d92c0582 --- /dev/null +++ b/internal/store/postgres/channel_dialog_cache.go @@ -0,0 +1,100 @@ +package postgres + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +type channelDialogCacheKey struct { + userID int64 + channelID int64 +} + +// ChannelDialogCache 缓存 viewer 作用域的频道 dialog 投影,由统一缓存原语 +// readmodelcache.Cache 承载(LRU 单条驱逐 / epoch 守卫 / singleflight 内建)。 +// +// 仅在连接池(非事务)读路径使用。缓存值依赖 channel_base、channel_member(viewer,channel)、 +// dialog_light(viewer,channel) 三个 read model;ReadModelChangeListener 在写侧 NOTIFY 时 +// 失效对应键,重连时 flush。warm-from-list 经 put 回填(含 DefaultSendAs)。 +type ChannelDialogCache struct { + cache *readmodelcache.Cache[channelDialogCacheKey, domain.ChannelDialog] +} + +func NewChannelDialogCache(max int) *ChannelDialogCache { + cache := readmodelcache.New[channelDialogCacheKey, domain.ChannelDialog](readmodelcache.Config[channelDialogCacheKey, domain.ChannelDialog]{ + MaxEntries: max, + Clone: cloneChannelDialog, + }) + if cache == nil { + return nil + } + return &ChannelDialogCache{cache: cache} +} + +func (c *ChannelDialogCache) get(userID, channelID int64) (domain.ChannelDialog, bool) { + if c == nil || userID == 0 || channelID == 0 { + return domain.ChannelDialog{}, false + } + return c.cache.Peek(channelDialogCacheKey{userID: userID, channelID: channelID}) +} + +func (c *ChannelDialogCache) getOrLoad(ctx context.Context, userID, channelID int64, load func() (domain.ChannelDialog, error)) (domain.ChannelDialog, error) { + if c == nil || userID == 0 || channelID == 0 { + return load() + } + return c.cache.GetOrLoad(ctx, channelDialogCacheKey{userID: userID, channelID: channelID}, load) +} + +func (c *ChannelDialogCache) put(dialog domain.ChannelDialog) { + if c == nil || dialog.UserID == 0 || dialog.ChannelID == 0 { + return + } + c.cache.Store(channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID}, dialog) +} + +// cacheEpoch 在「列表暖写回」前快照 epoch;配合 putIfEpoch 堵住 warm-vs-invalidation +// 竞态:DB 查询返回后到写回之间若收到失效(epoch 自增),陈旧投影写回被拒。 +func (c *ChannelDialogCache) cacheEpoch() uint64 { + if c == nil { + return 0 + } + return c.cache.LoadEpoch() +} + +func (c *ChannelDialogCache) putIfEpoch(dialog domain.ChannelDialog, loadEpoch uint64) { + if c == nil || dialog.UserID == 0 || dialog.ChannelID == 0 { + return + } + c.cache.StoreIfEpoch(channelDialogCacheKey{userID: dialog.UserID, channelID: dialog.ChannelID}, dialog, loadEpoch) +} + +func (c *ChannelDialogCache) delete(userID, channelID int64) { + if c == nil || userID == 0 || channelID == 0 { + return + } + c.cache.Invalidate(channelDialogCacheKey{userID: userID, channelID: channelID}) +} + +func (c *ChannelDialogCache) deleteChannel(channelID int64) { + if c == nil || channelID == 0 { + return + } + c.cache.InvalidateWhere(func(k channelDialogCacheKey) bool { return k.channelID == channelID }) +} + +func (c *ChannelDialogCache) flush() { + if c == nil { + return + } + c.cache.Flush() +} + +func cloneChannelDialog(dialog domain.ChannelDialog) domain.ChannelDialog { + if dialog.DefaultSendAs != nil { + peer := *dialog.DefaultSendAs + dialog.DefaultSendAs = &peer + } + return dialog +} diff --git a/internal/store/postgres/channel_dialog_cache_test.go b/internal/store/postgres/channel_dialog_cache_test.go new file mode 100644 index 00000000..d61aac74 --- /dev/null +++ b/internal/store/postgres/channel_dialog_cache_test.go @@ -0,0 +1,177 @@ +package postgres + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestChannelDialogCacheNilDisabled(t *testing.T) { + if c := NewChannelDialogCache(0); c != nil { + t.Fatalf("max<=0 应返回 nil(禁用), got %v", c) + } + var nilCache *ChannelDialogCache + nilCache.put(domain.ChannelDialog{UserID: 1, ChannelID: 2}) + nilCache.delete(1, 2) + nilCache.deleteChannel(2) + nilCache.flush() + if _, ok := nilCache.get(1, 2); ok { + t.Fatalf("nil 缓存 get 必须返回 ok=false") + } +} + +func TestChannelDialogCachePutGetDeleteFlushAndClone(t *testing.T) { + c := NewChannelDialogCache(16) + dialog := domain.ChannelDialog{ + UserID: 10, + ChannelID: 20, + FolderID: 1, + TopMessageID: 9, + TopMessageDate: 100, + ReadInboxMaxID: 7, + ReadOutboxMaxID: 8, + UnreadCount: 2, + Pinned: true, + ViewForumAsMessages: true, + DefaultSendAs: &domain.Peer{Type: domain.PeerTypeUser, ID: 10}, + } + + if _, ok := c.get(10, 20); ok { + t.Fatalf("空缓存不应命中") + } + c.put(dialog) + dialog.DefaultSendAs.ID = 99 + got, ok := c.get(10, 20) + if !ok || got.UserID != 10 || got.ChannelID != 20 || got.DefaultSendAs == nil || got.DefaultSendAs.ID != 10 { + t.Fatalf("put/get 往返或写入 clone 失败: %+v ok=%v", got, ok) + } + got.DefaultSendAs.ID = 77 + again, ok := c.get(10, 20) + if !ok || again.DefaultSendAs == nil || again.DefaultSendAs.ID != 10 { + t.Fatalf("读取结果必须 clone,避免调用方污染缓存: %+v ok=%v", again, ok) + } + + c.delete(10, 20) + if _, ok := c.get(10, 20); ok { + t.Fatalf("delete 后不应命中") + } + c.put(dialog) + c.flush() + if _, ok := c.get(10, 20); ok { + t.Fatalf("flush 后不应命中") + } +} + +func TestChannelDialogCacheDeleteChannelAndCap(t *testing.T) { + c := NewChannelDialogCache(2) + c.put(domain.ChannelDialog{UserID: 1, ChannelID: 10, TopMessageID: 1}) + c.put(domain.ChannelDialog{UserID: 2, ChannelID: 10, TopMessageID: 2}) + c.put(domain.ChannelDialog{UserID: 1, ChannelID: 11, TopMessageID: 3}) + c.deleteChannel(10) + if _, ok := c.get(1, 10); ok { + t.Fatalf("deleteChannel 应失效同频道 user 1") + } + if _, ok := c.get(2, 10); ok { + t.Fatalf("deleteChannel 应失效同频道 user 2") + } + if got, ok := c.get(1, 11); !ok || got.TopMessageID != 3 { + t.Fatalf("其它频道不应失效: %+v ok=%v", got, ok) + } + + c.put(domain.ChannelDialog{UserID: 1, ChannelID: 12, TopMessageID: 4}) + c.put(domain.ChannelDialog{UserID: 1, ChannelID: 13, TopMessageID: 5}) // 超 cap=2:LRU 驱逐最旧的 (1,11) + if _, ok := c.get(1, 11); ok { + t.Fatalf("超限后最旧条目 (1,11) 应被驱逐") + } + if got, ok := c.get(1, 12); !ok || got.TopMessageID != 4 { + t.Fatalf("LRU 单条驱逐应保留次新条目 (1,12),证明非整表 flush: %+v ok=%v", got, ok) + } + if got, ok := c.get(1, 13); !ok || got.TopMessageID != 5 { + t.Fatalf("最新条目 (1,13) 应在: %+v ok=%v", got, ok) + } +} + +func TestChannelDialogCacheSingleflightsColdLoad(t *testing.T) { + c := NewChannelDialogCache(16) + var calls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + load := func() (domain.ChannelDialog, error) { + calls.Add(1) + once.Do(func() { close(started) }) + <-release + return domain.ChannelDialog{UserID: 7, ChannelID: 100, TopMessageID: 11}, nil + } + + const goroutines = 16 + var wg sync.WaitGroup + errs := make(chan error, goroutines) + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + dialog, err := c.getOrLoad(context.Background(), 7, 100, load) + if err != nil { + errs <- err + return + } + if dialog.UserID != 7 || dialog.ChannelID != 100 || dialog.TopMessageID != 11 { + errs <- fmt.Errorf("dialog = %+v, want 7/100/top11", dialog) + } + }() + } + <-started + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("load calls = %d, want 1", got) + } + if _, err := c.getOrLoad(context.Background(), 7, 100, load); err != nil { + t.Fatalf("cached getOrLoad: %v", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("cache hit called load again: calls=%d", got) + } +} + +func TestReadModelChangeListenerInvalidatesChannelDialogCache(t *testing.T) { + dialogs := NewChannelDialogCache(16) + dialogs.put(domain.ChannelDialog{UserID: 100, ChannelID: 7, TopMessageID: 1}) + dialogs.put(domain.ChannelDialog{UserID: 100, ChannelID: 8, TopMessageID: 2}) + dialogs.put(domain.ChannelDialog{UserID: 200, ChannelID: 7, TopMessageID: 3}) + + listener := NewReadModelChangeListener("", ReadModelCacheSet{ChannelDialogs: dialogs}, nil) + listener.handlePayload(`{"model":"dialog_light","owner_user_id":100,"peer_type":"channel","peer_id":7}`) + if _, ok := dialogs.get(100, 7); ok { + t.Fatalf("dialog_light 事件应失效精确 viewer/channel") + } + if got, ok := dialogs.get(100, 8); !ok || got.TopMessageID != 2 { + t.Fatalf("其它频道不应失效: %+v ok=%v", got, ok) + } + if got, ok := dialogs.get(200, 7); !ok || got.TopMessageID != 3 { + t.Fatalf("其它 viewer 不应失效: %+v ok=%v", got, ok) + } + + listener.handlePayload(`{"model":"channel_member","owner_user_id":200,"peer_type":"channel","peer_id":7}`) + if _, ok := dialogs.get(200, 7); ok { + t.Fatalf("channel_member 事件应失效对应 viewer/channel") + } + + listener.handlePayload(`{"model":"channel_base","owner_user_id":0,"peer_type":"channel","peer_id":8}`) + if _, ok := dialogs.get(100, 8); ok { + t.Fatalf("channel_base 事件应失效该频道所有 viewer dialog") + } +} diff --git a/internal/store/postgres/channel_dialog_integration_test.go b/internal/store/postgres/channel_dialog_integration_test.go new file mode 100644 index 00000000..f3e5cc75 --- /dev/null +++ b/internal/store/postgres/channel_dialog_integration_test.go @@ -0,0 +1,426 @@ +package postgres + +import ( + "context" + "telesrv/internal/domain" + "testing" +) + +func TestChannelStoreListDialogsUsesDateAndOffset(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 35, + Phone: "+1777" + suffix + "03", + FirstName: "DialogPageOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) > 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + older, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Older Dialog " + suffix, + Megagroup: true, + Date: 1700000310, + }) + if err != nil { + t.Fatalf("create older channel: %v", err) + } + channelIDs = append(channelIDs, older.Channel.ID) + newer, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Newer Dialog " + suffix, + Megagroup: true, + Date: 1700000320, + }) + if err != nil { + t.Fatalf("create newer channel: %v", err) + } + channelIDs = append(channelIDs, newer.Channel.ID) + + first, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{Limit: 1}) + if err != nil { + t.Fatalf("list first channel dialogs: %v", err) + } + if len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != newer.Channel.ID { + t.Fatalf("first page dialogs = %+v, want newer channel by top date", first.Dialogs) + } + + next, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ + OffsetDate: first.Dialogs[0].TopMessageDate, + OffsetID: first.Dialogs[0].TopMessage, + HasOffsetPeer: true, + OffsetPeer: first.Dialogs[0].Peer, + Limit: 10, + }) + if err != nil { + t.Fatalf("list next channel dialogs: %v", err) + } + if len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != older.Channel.ID { + t.Fatalf("next page dialogs = %+v, want older channel without repeating offset peer", next.Dialogs) + } +} + +func TestChannelStoreListDialogsWarmsDialogCache(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 36, + Phone: "+1777" + suffix + "13", + FirstName: "DialogWarmOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + cache := NewChannelDialogCache(16) + channels := NewChannelStore(pool, WithChannelDialogCache(cache)) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Dialog Warm " + suffix, + Megagroup: true, + Date: 1700000325, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + list, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list channel dialogs: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].Peer.ID != channelID { + t.Fatalf("dialogs = %+v, want created channel", list.Dialogs) + } + + cached, ok := cache.get(owner.ID, channelID) + if !ok { + t.Fatalf("channel dialog cache was not warmed") + } + if cached.TopMessageID != list.Dialogs[0].TopMessage || + cached.TopMessageDate != list.Dialogs[0].TopMessageDate || + cached.ReadInboxMaxID != list.Dialogs[0].ReadInboxMaxID { + t.Fatalf("cached dialog = %+v, listed dialog = %+v", cached, list.Dialogs[0]) + } +} + +func TestChannelStoreListDialogsScansChannelWallpaper(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 38, + Phone: "+1777" + suffix + "14", + FirstName: "DialogWallpaperOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Dialog Wallpaper " + suffix, + Megagroup: true, + Date: 1700000326, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + wallpaper := &domain.Wallpaper{ + ID: 9914, + NoFile: true, + Settings: domain.WallpaperSettings{ + HasBackgroundColor: true, + BackgroundColor: 0x8fd15b, + HasIntensity: true, + Intensity: 35, + }, + } + if _, err := channels.SetChannelWallpaper(ctx, domain.SetChannelWallpaperRequest{ + UserID: owner.ID, + ChannelID: channelID, + Wallpaper: wallpaper, + Date: 1700000327, + }); err != nil { + t.Fatalf("set channel wallpaper: %v", err) + } + + list, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list channel dialogs: %v", err) + } + if len(list.Channels) != 1 || list.Channels[0].ID != channelID { + t.Fatalf("channels = %+v, want wallpaper channel", list.Channels) + } + if !domain.WallpaperEqual(list.Channels[0].Wallpaper, wallpaper) { + t.Fatalf("listed wallpaper = %+v, want %+v", list.Channels[0].Wallpaper, wallpaper) + } +} + +func TestChannelStoreListDialogsDerivesRecipientTopWithoutWriteFanout(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1777" + suffix + "31", + FirstName: "DialogTopOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1777" + suffix + "32", + FirstName: "DialogTopMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Dialog Top " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000330, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000331, + }); err != nil { + t.Fatalf("read initial service message: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9331, + Message: "recipient top without write fanout", + Date: 1700000332, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + + list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list recipient channel dialogs: %v", err) + } + if len(list.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want one channel dialog", list.Dialogs) + } + dialog := list.Dialogs[0] + if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date || dialog.UnreadCount != 1 { + t.Fatalf("recipient dialog = %+v, want top sent message and unread=1", dialog) + } + if len(list.Messages) != 1 || list.Messages[0].ID != sent.Message.ID { + t.Fatalf("dialog messages = %+v, want sent top message", list.Messages) + } +} + +func TestChannelStoreListDialogsSeeksBeyondQueryWindow(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 36, + Phone: "+1777" + suffix + "04", + FirstName: "DialogSeekOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + count := channelDialogQueryLimit + 5 + ids := make([]int64, count) + baseID := owner.ID * 1000 + for i := range ids { + ids[i] = baseID + int64(i+1) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + if _, err := pool.Exec(ctx, ` +INSERT INTO channels ( + id, access_hash, creator_user_id, title, broadcast, megagroup, + participants_count, admins_count, top_message_id, pts, date +) +SELECT id, id + 900000, $2, 'Bulk Dialog ' || ord, false, true, 1, 1, 1, 1, (1700000400 + ord)::int +FROM unnest($1::bigint[]) WITH ORDINALITY AS t(id, ord)`, ids, owner.ID); err != nil { + t.Fatalf("bulk insert channels: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO channel_members (channel_id, user_id, role, status, joined_at) +SELECT id, $2, 'creator', 'active', 1700000400 +FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil { + t.Fatalf("bulk insert channel members: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO user_channel_member_index (user_id, channel_id, status, megagroup, broadcast, deleted, role) +SELECT $2, id, 'active', true, false, false, 'creator' +FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil { + t.Fatalf("bulk insert channel member index: %v", err) + } + + channels := NewChannelStore(pool) + var cursor domain.Dialog + var sixth domain.ChannelDialogList + for page := 0; page < 6; page++ { + filter := domain.DialogFilter{Limit: 100} + if page > 0 { + filter.OffsetDate = cursor.TopMessageDate + filter.OffsetID = cursor.TopMessage + filter.HasOffsetPeer = true + filter.OffsetPeer = cursor.Peer + } + got, err := channels.ListChannelDialogs(ctx, owner.ID, filter) + if err != nil { + t.Fatalf("list channel dialogs page %d: %v", page+1, err) + } + if len(got.Dialogs) == 0 { + t.Fatalf("page %d unexpectedly empty after cursor %+v", page+1, cursor) + } + cursor = got.Dialogs[len(got.Dialogs)-1] + if page == 5 { + sixth = got + } + } + if len(sixth.Dialogs) != 5 { + t.Fatalf("sixth page len = %d, want remaining 5 beyond query window", len(sixth.Dialogs)) + } + if sixth.Dialogs[0].Peer.ID != ids[4] || sixth.Dialogs[4].Peer.ID != ids[0] { + t.Fatalf("sixth page dialogs = %+v, want oldest five descending by date", sixth.Dialogs) + } + + included, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ + Folder: &domain.DialogFolder{ + IncludePeers: []domain.DialogFolderPeer{{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ids[0]}, + }}, + }, + Limit: 10, + }) + if err != nil { + t.Fatalf("list included channel dialog beyond query window: %v", err) + } + if len(included.Dialogs) != 1 || included.Dialogs[0].Peer.ID != ids[0] { + t.Fatalf("included dialogs = %+v, want oldest included channel beyond query window", included.Dialogs) + } +} + +func TestChannelStoreListDialogsFolderFiltersBeforeQueryLimit(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 37, + Phone: "+1777" + suffix + "05", + FirstName: "DialogFolderOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + count := channelDialogQueryLimit + 5 + ids := make([]int64, count) + baseID := owner.ID*1000 + 100000 + for i := range ids { + ids[i] = baseID + int64(i+1) + } + archivedID := ids[0] + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + if _, err := pool.Exec(ctx, ` +INSERT INTO channels ( + id, access_hash, creator_user_id, title, broadcast, megagroup, + participants_count, admins_count, top_message_id, pts, date +) +SELECT id, id + 910000, $2, 'Folder Dialog ' || ord, false, true, 1, 1, 1, 1, (1700000500 + ord)::int +FROM unnest($1::bigint[]) WITH ORDINALITY AS t(id, ord)`, ids, owner.ID); err != nil { + t.Fatalf("bulk insert channels: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO channel_members (channel_id, user_id, role, status, joined_at) +SELECT id, $2, 'creator', 'active', 1700000500 +FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil { + t.Fatalf("bulk insert channel members: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO user_channel_member_index (user_id, channel_id, status, megagroup, broadcast, deleted, role) +SELECT $2, id, 'active', true, false, false, 'creator' +FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil { + t.Fatalf("bulk insert channel member index: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO channel_dialogs (user_id, channel_id, folder_id, top_message_id, top_message_date) +VALUES ($1, $2, $3, 1, 1700000500)`, owner.ID, archivedID, domain.DialogArchiveFolderID); err != nil { + t.Fatalf("archive oldest channel dialog: %v", err) + } + + archive, err := NewChannelStore(pool).ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ + HasFolderID: true, + FolderID: domain.DialogArchiveFolderID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list archive channel dialogs: %v", err) + } + if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer.ID != archivedID { + t.Fatalf("archive dialogs = %+v, want archived channel beyond first query window", archive.Dialogs) + } +} diff --git a/internal/store/postgres/channel_dialog_mention_integration_test.go b/internal/store/postgres/channel_dialog_mention_integration_test.go new file mode 100644 index 00000000..e2f052a8 --- /dev/null +++ b/internal/store/postgres/channel_dialog_mention_integration_test.go @@ -0,0 +1,100 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestChannelDialogTopMessageCarriesMentionFlags 复现 @ 角标重启回潮: +// getDialogs 的 channel top message 必须按 viewer 携带 mentioned/media_unread, +// 否则 TDesktop 先入缓存的残缺版永不触发 contents-read,服务端 unread +// 提及挂死、每次冷启动 unread_mentions_count 把 @ 角标带回来。 +func TestChannelDialogTopMessageCarriesMentionFlags(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 97, Phone: "+1675" + suffix + "01", FirstName: "MentionOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 98, Phone: "+1675" + suffix + "02", FirstName: "MentionMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "MentionDialog " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000910, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: created.Channel.ID, + RandomID: 851001, + Message: "ping member", + MentionUserIDs: []int64{member.ID}, + Date: 1700000911, + }) + if err != nil { + t.Fatalf("send mention: %v", err) + } + + assertFlags := func(name string, list domain.ChannelDialogList) { + t.Helper() + found := false + for _, msg := range list.Messages { + if msg.ChannelID != created.Channel.ID || msg.ID != sent.Message.ID { + continue + } + found = true + if !msg.Mentioned || !msg.MediaUnread { + t.Fatalf("%s top message flags = mentioned %v media_unread %v, want both true(缺失会让 @ 角标重启回潮)", name, msg.Mentioned, msg.MediaUnread) + } + } + if !found { + t.Fatalf("%s lacks top message %d: %+v", name, sent.Message.ID, list.Messages) + } + } + + list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 50}) + if err != nil { + t.Fatalf("list channel dialogs: %v", err) + } + assertFlags("ListChannelDialogs", list) + + got, err := channels.GetChannelDialogs(ctx, member.ID, []int64{created.Channel.ID}) + if err != nil { + t.Fatalf("get channel dialogs: %v", err) + } + assertFlags("GetChannelDialogs", got) + + // 提及读掉后:mentioned 永久保留、media_unread 翻为 false。 + if _, err := channels.ReadChannelMentions(ctx, domain.ReadChannelMentionsRequest{ + UserID: member.ID, + ChannelID: created.Channel.ID, + }); err != nil { + t.Fatalf("read mentions: %v", err) + } + after, err := channels.GetChannelDialogs(ctx, member.ID, []int64{created.Channel.ID}) + if err != nil { + t.Fatalf("get channel dialogs after read: %v", err) + } + for _, msg := range after.Messages { + if msg.ID == sent.Message.ID && (!msg.Mentioned || msg.MediaUnread) { + t.Fatalf("after read flags = mentioned %v media_unread %v, want mentioned kept with media_unread cleared", msg.Mentioned, msg.MediaUnread) + } + } +} diff --git a/internal/store/postgres/channel_dialogs.go b/internal/store/postgres/channel_dialogs.go new file mode 100644 index 00000000..d857b8a6 --- /dev/null +++ b/internal/store/postgres/channel_dialogs.go @@ -0,0 +1,1503 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "sort" + "strings" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +type channelDialogListItem struct { + channel domain.Channel + dialog domain.Dialog + defaultSendAs *domain.Peer +} + +func (s *ChannelStore) ListChannelDialogs(ctx context.Context, viewerUserID int64, filter domain.DialogFilter) (domain.ChannelDialogList, error) { + if viewerUserID == 0 { + return domain.ChannelDialogList{}, nil + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + channelIDs, hasBroadcastAdmin, err := s.listActiveChannelDialogCandidateIDs(ctx, viewerUserID, false) + if err != nil { + return domain.ChannelDialogList{}, err + } + if len(channelIDs) == 0 { + return domain.ChannelDialogList{}, nil + } + visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" + visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END" + visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)" + visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) + args := []any{viewerUserID, channelIDs} + where := []string{"m.user_id = $1", "m.channel_id = ANY($2::bigint[])", "m.status = 'active'"} + if filter.HasFolderID && filter.FolderID < domain.DialogCustomFolderMinID { + args = append(args, filter.FolderID) + where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) = $%d", len(args))) + } else if !filter.HasFolderID { + // 不带 folder_id 视为主列表(folder 0),与私聊侧/官方语义一致。 + where = append(where, "COALESCE(d.folder_id, 0) = 0") + } + if filter.PinnedOnly { + where = append(where, "COALESCE(d.pinned, false)") + } + if filter.ExcludePinned { + where = append(where, "NOT COALESCE(d.pinned, false)") + } + switch { + case filter.OffsetDate > 0: + args = append(args, filter.OffsetDate, filter.OffsetID) + dateArg := fmt.Sprintf("$%d", len(args)-1) + idArg := fmt.Sprintf("$%d", len(args)) + if filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0 { + args = append(args, filter.OffsetPeer.ID) + peerArg := fmt.Sprintf("$%d", len(args)) + where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND %s < %s) OR (%s = %s AND %s = %s AND c.id < %s))", + visibleTopDate, dateArg, + visibleTopDate, dateArg, visibleTopID, idArg, + visibleTopDate, dateArg, visibleTopID, idArg, peerArg)) + } else { + where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND %s < %s))", + visibleTopDate, dateArg, + visibleTopDate, dateArg, visibleTopID, idArg)) + } + case filter.OffsetID > 0: + args = append(args, filter.OffsetID) + idArg := fmt.Sprintf("$%d", len(args)) + if filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0 { + args = append(args, filter.OffsetPeer.ID) + peerArg := fmt.Sprintf("$%d", len(args)) + where = append(where, fmt.Sprintf("(%s < %s OR (%s = %s AND c.id < %s))", + visibleTopID, idArg, visibleTopID, idArg, peerArg)) + } else { + where = append(where, fmt.Sprintf("%s < %s", visibleTopID, idArg)) + } + case filter.HasOffsetPeer && filter.OffsetPeer.Type == domain.PeerTypeChannel && filter.OffsetPeer.ID > 0: + args = append(args, filter.OffsetPeer.ID) + where = append(where, fmt.Sprintf("c.id <> $%d", len(args))) + } + if filter.Folder != nil { + folder := filter.Folder + if folder.ExcludeArchived { + where = append(where, fmt.Sprintf("COALESCE(d.folder_id, 0) <> %d", domain.DialogArchiveFolderID)) + } + if folder.ExcludeRead { + where = append(where, fmt.Sprintf(`(COALESCE(d.unread_mark, m.unread_mark) OR %s)`, + channelDialogHasUnreadSQL(visibleReadInbox, visibleTopID))) + } + if excludeIDs := channelFolderPeerIDs(folder.ExcludePeers); len(excludeIDs) > 0 { + args = append(args, excludeIDs) + where = append(where, fmt.Sprintf("NOT (c.id = ANY($%d::bigint[]))", len(args))) + } + includeIDs := channelFolderPeerIDs(folder.IncludePeers, folder.PinnedPeers) + include := make([]string, 0, 3) + if len(includeIDs) > 0 { + args = append(args, includeIDs) + include = append(include, fmt.Sprintf("c.id = ANY($%d::bigint[])", len(args))) + } + if folder.Groups { + include = append(include, "c.megagroup") + } + if folder.Broadcasts { + include = append(include, "c.broadcast") + } + if len(include) > 0 { + where = append(where, "("+strings.Join(include, " OR ")+")") + } else { + // 自定义 filter 未声明任何频道维度正向条件(仅 Contacts/NonContacts/Bots + // 这类私聊类别开关)时,不纳入任何群/频道——与 channelDialogMatchesFilter + // 末尾的 return false 对齐,避免把用户加入的全部群/频道灌进该文件夹。 + where = append(where, "false") + } + } + args = append(args, channelDialogQueryLimit) + limitArg := fmt.Sprintf("$%d", len(args)) + // 暖写回的 epoch 守卫:在加载前快照,写回时若期间收到失效(epoch 变更)则拒绝陈旧投影, + // 避免 scan→put 窗口内的并发失效被裸 Store 覆盖回(@角标/未读 lost-update)。 + dialogCacheActive := s.dialogCacheActive(s.db) + var dialogCacheEpoch uint64 + if dialogCacheActive { + dialogCacheEpoch = s.dialogCache.cacheEpoch() + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+`, + `+visibleTopID+`, + `+visibleTopDate+`, + COALESCE(d.folder_id, 0), `+visibleReadInbox+`, + LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)), `+visibleUnreadCount+`, + COALESCE(d.pinned, false), + COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark), + COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0), + COALESCE(d.view_forum_as_messages, false), + COALESCE(d.has_scheduled, false), + d.default_send_as_peer_type, + d.default_send_as_peer_id +FROM channel_members m +JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted +LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted +LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id +WHERE `+strings.Join(where, " AND ")+` +ORDER BY COALESCE(d.pinned, false) DESC, + COALESCE(d.pinned_order, 0) DESC, + `+visibleTopDate+` DESC, + `+visibleTopID+` DESC, + c.id DESC +LIMIT `+limitArg, args...) + if err != nil { + return domain.ChannelDialogList{}, fmt.Errorf("list channel dialogs: %w", err) + } + defer rows.Close() + items := make([]channelDialogListItem, 0, limit) + seenChannels := make(map[int64]struct{}, limit) + for rows.Next() { + ch, dialog, defaultSendAs, err := scanChannelDialogRow(rows, viewerUserID) + if err != nil { + return domain.ChannelDialogList{}, err + } + if !channelDialogMatchesFilter(dialog, ch, filter) { + continue + } + items = append(items, channelDialogListItem{channel: ch, dialog: dialog, defaultSendAs: defaultSendAs}) + seenChannels[ch.ID] = struct{}{} + } + if err := rows.Err(); err != nil { + return domain.ChannelDialogList{}, err + } + // hasBroadcastAdmin 是页无关的超集门:只有「在某广播频道任 creator/admin」的用户才可能拥有 + // monoforum 私信会话(linked_monoforum_id<>0 是该集合的子集)。绝大多数 getDialogs 调用者命不中, + // 据此跳过 listMonoforumAdminDialogItems 这条多表 JOIN 的额外往返(此前对每次 getDialogs 无条件执行)。 + // 不用主扫描循环里的行级标志,因为主查询带 offset 游标:翻到后续页时母广播频道可能已被上一页消费、 + // 不再被扫到,而其私信会话(独立排序位)仍属于本页——行级标志会漏发。超集门只读全量候选集,页无关。 + if hasBroadcastAdmin { + monoItems, err := s.listMonoforumAdminDialogItems(ctx, viewerUserID, channelIDs, filter, seenChannels) + if err != nil { + return domain.ChannelDialogList{}, err + } + items = append(items, monoItems...) + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].dialog.Pinned != items[j].dialog.Pinned { + return items[i].dialog.Pinned + } + if items[i].dialog.PinnedOrder != items[j].dialog.PinnedOrder { + return items[i].dialog.PinnedOrder > items[j].dialog.PinnedOrder + } + if items[i].dialog.TopMessageDate != items[j].dialog.TopMessageDate { + return items[i].dialog.TopMessageDate > items[j].dialog.TopMessageDate + } + if items[i].dialog.TopMessage != items[j].dialog.TopMessage { + return items[i].dialog.TopMessage > items[j].dialog.TopMessage + } + return items[i].dialog.Peer.ID > items[j].dialog.Peer.ID + }) + out := domain.ChannelDialogList{Count: len(items)} + if len(items) > limit { + items = items[:limit] + } + dialogs := make([]domain.Dialog, 0, len(items)) + for _, item := range items { + dialogs = append(dialogs, item.dialog) + } + topMessages, err := s.channelDialogTopMessages(ctx, s.db, dialogs) + if err != nil { + return domain.ChannelDialogList{}, err + } + for _, item := range items { + msg := topMessages[channelMessageLookupKey{channelID: item.channel.ID, id: item.dialog.TopMessage}] + if msg.ID != 0 { + item.dialog.TopMessageDate = msg.Date + out.Messages = append(out.Messages, msg) + } + if dialogCacheActive { + // 列表暖缓存的值必须与单频道全量读路径等价:补上仅存于 channel_dialogs 的 + // default_send_as,否则 getFullChannel/getSendAs 命中暖缓存会丢失「以频道发言」默认值。 + cd := channelDialogFromDialog(viewerUserID, item.dialog) + cd.DefaultSendAs = item.defaultSendAs + s.dialogCache.putIfEpoch(cd, dialogCacheEpoch) + } + out.Dialogs = append(out.Dialogs, item.dialog) + out.Channels = append(out.Channels, item.channel) + } + // getDialogs 的 top message 必须按 viewer 补 mentioned/media_unread 与 + // reactions:TDesktop 把它先入缓存且不被后续 difference/getHistory 的 + // 完整版覆盖,缺标志会让客户端永不上报 contents-read,@ 角标重启回潮。 + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { + return domain.ChannelDialogList{}, err + } + return out, nil +} + +func (s *ChannelStore) listMonoforumAdminDialogItems(ctx context.Context, viewerUserID int64, parentChannelIDs []int64, filter domain.DialogFilter, seen map[int64]struct{}) ([]channelDialogListItem, error) { + if viewerUserID == 0 || len(parentChannelIDs) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+`, + c.top_message_id, + COALESCE(top_msg.message_date, d.top_message_date, c.date), + COALESCE(d.folder_id, 0), + GREATEST(COALESCE(d.read_inbox_max_id, 0), c.top_message_id), + GREATEST(COALESCE(d.read_outbox_max_id, 0), c.top_message_id), + 0, + COALESCE(d.pinned, false), + COALESCE(d.pinned_order, 0), + COALESCE(d.unread_mark, false), + COALESCE(d.unread_mentions_count, 0), + COALESCE(d.unread_reactions_count, 0), + COALESCE(d.view_forum_as_messages, false), + COALESCE(d.has_scheduled, false), + d.default_send_as_peer_type, + d.default_send_as_peer_id +FROM user_channel_member_index i +JOIN channel_members pm ON pm.user_id = i.user_id AND pm.channel_id = i.channel_id +JOIN channels parent ON parent.id = i.channel_id AND parent.id = ANY($2::bigint[]) AND parent.broadcast AND parent.linked_monoforum_id <> 0 AND NOT parent.deleted +JOIN channels c ON c.id = parent.linked_monoforum_id AND c.monoforum AND NOT c.deleted +LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted +LEFT JOIN channel_dialogs d ON d.user_id = i.user_id AND d.channel_id = c.id +WHERE i.user_id = $1 + AND i.status = 'active' + AND NOT i.deleted + AND i.role IN ('creator','admin') + AND pm.status = 'active' + AND pm.role IN ('creator','admin') +ORDER BY COALESCE(d.pinned, false) DESC, + COALESCE(d.pinned_order, 0) DESC, + COALESCE(top_msg.message_date, d.top_message_date, c.date) DESC, + c.top_message_id DESC, + c.id DESC +LIMIT $3`, viewerUserID, parentChannelIDs, channelDialogQueryLimit) + if err != nil { + return nil, fmt.Errorf("list monoforum admin dialogs: %w", err) + } + defer rows.Close() + items := make([]channelDialogListItem, 0) + for rows.Next() { + ch, dialog, defaultSendAs, err := scanChannelDialogRow(rows, viewerUserID) + if err != nil { + return nil, err + } + if _, ok := seen[ch.ID]; ok { + continue + } + if !channelDialogMatchesFilter(dialog, ch, filter) { + continue + } + seen[ch.ID] = struct{}{} + items = append(items, channelDialogListItem{channel: ch, dialog: dialog, defaultSendAs: defaultSendAs}) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan monoforum admin dialogs: %w", err) + } + return items, nil +} + +type channelMessageLookupKey struct { + channelID int64 + id int +} + +func (s *ChannelStore) channelDialogTopMessages(ctx context.Context, db sqlcgen.DBTX, dialogs []domain.Dialog) (map[channelMessageLookupKey]domain.ChannelMessage, error) { + seen := make(map[channelMessageLookupKey]struct{}, len(dialogs)) + idsByChannel := make(map[int64][]int, len(dialogs)) + for _, dialog := range dialogs { + if dialog.Peer.Type != domain.PeerTypeChannel || dialog.Peer.ID == 0 || dialog.TopMessage <= 0 { + continue + } + key := channelMessageLookupKey{channelID: dialog.Peer.ID, id: dialog.TopMessage} + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + idsByChannel[dialog.Peer.ID] = append(idsByChannel[dialog.Peer.ID], dialog.TopMessage) + } + if len(idsByChannel) == 0 { + return nil, nil + } + channelIDs := make([]int64, 0, len(idsByChannel)) + for channelID := range idsByChannel { + channelIDs = append(channelIDs, channelID) + } + sort.Slice(channelIDs, func(i, j int) bool { return channelIDs[i] < channelIDs[j] }) + args := make([]any, 0, len(channelIDs)*2) + var where strings.Builder + for i, channelID := range channelIDs { + if i > 0 { + where.WriteString(" OR ") + } + args = append(args, channelID) + channelArg := len(args) + args = append(args, int32s(idsByChannel[channelID])) + idsArg := len(args) + fmt.Fprintf(&where, "(channel_id = $%d AND id = ANY($%d::int[]))", channelArg, idsArg) + } + rows, err := db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where.String(), args...) + if err != nil { + return nil, fmt.Errorf("list channel dialog top messages: %w", err) + } + defer rows.Close() + out := make(map[channelMessageLookupKey]domain.ChannelMessage, len(seen)) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + continue + } + out[channelMessageLookupKey{channelID: msg.ChannelID, id: msg.ID}] = msg + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan channel dialog top messages: %w", err) + } + return out, nil +} + +func (s *ChannelStore) GetChannelDialogs(ctx context.Context, viewerUserID int64, channelIDs []int64) (domain.ChannelDialogList, error) { + out := domain.ChannelDialogList{} + seen := make(map[int64]struct{}, len(channelIDs)) + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + if _, ok := seen[channelID]; ok { + continue + } + seen[channelID] = struct{}{} + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + synthetic := false + if err != nil { + if errors.Is(err, domain.ErrChannelInvalid) || errors.Is(err, domain.ErrChannelPrivate) { + ch, chErr := s.channelByID(ctx, s.db, channelID) + if chErr != nil { + if errors.Is(chErr, domain.ErrChannelInvalid) { + continue + } + return domain.ChannelDialogList{}, chErr + } + syntheticMember, parentChannel, ok, previewErr := s.monoforumAdminPreview(ctx, s.db, viewerUserID, ch) + if previewErr != nil { + return domain.ChannelDialogList{}, previewErr + } + if !ok { + continue + } + channel = ch + member = syntheticMember + synthetic = true + // 与 mono 同批下发母广播频道。TDesktop 的 applyMonoforumLinkedId 仅在父对象已加载时 + // 才调 setMonoforumLink 派生 MonoforumAdmin(Direct-Messages 容器渲染所需);父对象缺失 + // 会让 mono 退化成普通 megagroup("You created a group")。tgChannelsForDialogs 按 id + // 去重,重复下发无害;此分支已 gate 于 isChannelAdmin(parentMember),不泄漏给非管理员。 + if parentChannel.ID != 0 { + out.Channels = append(out.Channels, parentChannel) + } + } else { + return domain.ChannelDialogList{}, err + } + } + var dialog domain.ChannelDialog + if synthetic { + dialog = previewChannelDialog(viewerUserID, channel, member) + } else { + dialog, err = s.getChannelDialog(ctx, s.db, viewerUserID, channel) + if err != nil { + return domain.ChannelDialogList{}, err + } + } + msg, _ := s.getChannelMessage(ctx, s.db, channelID, dialog.TopMessageID) + if msg.ID != 0 { + dialog.TopMessageDate = msg.Date + out.Messages = append(out.Messages, msg) + } + out.Dialogs = append(out.Dialogs, channelDialogToDialog(dialog, channel.Pts)) + out.Channels = append(out.Channels, channel) + } + out.Count = len(out.Dialogs) + // 与 ListChannelDialogs 同因:top message 按 viewer 补未读标志与 reactions。 + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { + return domain.ChannelDialogList{}, err + } + return out, nil +} + +func (s *ChannelStore) ListCommonChannels(ctx context.Context, req domain.CommonChannelsRequest) (domain.CommonChannelsResult, error) { + if req.UserID == 0 || req.TargetUserID == 0 || req.UserID == req.TargetUserID || req.MaxID < 0 { + return domain.CommonChannelsResult{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxCommonChannelsLimit { + limit = domain.MaxCommonChannelsLimit + } + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM user_channel_member_index selfm +JOIN user_channel_member_index targetm ON targetm.channel_id = selfm.channel_id +WHERE selfm.user_id = $1 + AND targetm.user_id = $2 + AND selfm.status = 'active' + AND targetm.status = 'active' + AND selfm.megagroup + AND NOT selfm.broadcast + AND NOT selfm.deleted + AND targetm.megagroup + AND NOT targetm.broadcast + AND NOT targetm.deleted`, req.UserID, req.TargetUserID).Scan(&count); err != nil { + return domain.CommonChannelsResult{}, fmt.Errorf("count common channels: %w", err) + } + out := domain.CommonChannelsResult{Count: count} + if req.CountOnly { + return out, nil + } + rows, err := s.db.Query(ctx, ` +SELECT selfm.channel_id +FROM user_channel_member_index selfm +JOIN user_channel_member_index targetm ON targetm.channel_id = selfm.channel_id +WHERE selfm.user_id = $1 + AND targetm.user_id = $2 + AND selfm.status = 'active' + AND targetm.status = 'active' + AND selfm.megagroup + AND NOT selfm.broadcast + AND NOT selfm.deleted + AND targetm.megagroup + AND NOT targetm.broadcast + AND NOT targetm.deleted + AND ($3::bigint = 0 OR selfm.channel_id > $3) +ORDER BY selfm.channel_id ASC +LIMIT $4`, req.UserID, req.TargetUserID, req.MaxID, limit) + if err != nil { + return domain.CommonChannelsResult{}, fmt.Errorf("list common channels: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, limit) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return domain.CommonChannelsResult{}, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return domain.CommonChannelsResult{}, err + } + channels, err := listChannelsByIDs(ctx, s.db, ids) + if err != nil { + return domain.CommonChannelsResult{}, err + } + out.Channels = channels + return out, nil +} + +func (s *ChannelStore) ListLeftChannels(ctx context.Context, userID int64, offset, limit int) (domain.LeftChannelsResult, error) { + if userID == 0 || offset < 0 || offset > domain.MaxLeftChannelsOffset { + return domain.LeftChannelsResult{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxLeftChannelsLimit { + limit = domain.MaxLeftChannelsLimit + } + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM user_channel_member_index +WHERE user_id = $1 + AND status = 'left' + AND (broadcast OR megagroup) + AND NOT deleted`, userID).Scan(&count); err != nil { + return domain.LeftChannelsResult{}, fmt.Errorf("count left channels: %w", err) + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id +FROM user_channel_member_index +WHERE user_id = $1 + AND status = 'left' + AND (broadcast OR megagroup) + AND NOT deleted +ORDER BY left_at DESC, channel_id DESC +OFFSET $2 +LIMIT $3`, userID, offset, limit) + if err != nil { + return domain.LeftChannelsResult{}, fmt.Errorf("list left channels: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, limit) + for rows.Next() { + var channelID int64 + if err := rows.Scan(&channelID); err != nil { + return domain.LeftChannelsResult{}, err + } + ids = append(ids, channelID) + } + if err := rows.Err(); err != nil { + return domain.LeftChannelsResult{}, err + } + items, err := s.leftChannelsByIDs(ctx, userID, ids) + if err != nil { + return domain.LeftChannelsResult{}, err + } + return domain.LeftChannelsResult{Count: count, Channels: items}, nil +} + +func (s *ChannelStore) leftChannelsByIDs(ctx context.Context, userID int64, ids []int64) ([]domain.LeftChannel, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, + admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts, + read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +FROM channel_members +WHERE user_id = $1 + AND channel_id = ANY($2::bigint[]) + AND status = 'left'`, userID, ids) + if err != nil { + return nil, fmt.Errorf("list left channel details: %w", err) + } + defer rows.Close() + membersByID := make(map[int64]domain.ChannelMember, len(ids)) + for rows.Next() { + member, err := scanChannelMember(rows) + if err != nil { + return nil, err + } + membersByID[member.ChannelID] = member + } + if err := rows.Err(); err != nil { + return nil, err + } + channels, err := listChannelsByIDsInOrder(ctx, s.db, ids) + if err != nil { + return nil, err + } + out := make([]domain.LeftChannel, 0, len(ids)) + for _, channel := range channels { + if !channel.Broadcast && !channel.Megagroup { + continue + } + if member, ok := membersByID[channel.ID]; ok { + out = append(out, domain.LeftChannel{Channel: channel, Self: member}) + } + } + return out, nil +} + +func (s *ChannelStore) ListInactiveChannels(ctx context.Context, userID int64, limit int) (domain.ChannelDialogList, error) { + if userID == 0 { + return domain.ChannelDialogList{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxInactiveChannelsLimit { + limit = domain.MaxInactiveChannelsLimit + } + channelIDs, _, err := s.listActiveChannelDialogCandidateIDs(ctx, userID, true) + if err != nil { + return domain.ChannelDialogList{}, err + } + if len(channelIDs) == 0 { + return domain.ChannelDialogList{}, nil + } + visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" + visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE GREATEST(c.date, m.joined_at) END" + visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)" + visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+`, + `+visibleTopID+`, + `+visibleTopDate+`, + COALESCE(d.folder_id, 0), `+visibleReadInbox+`, + LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)), `+visibleUnreadCount+`, + COALESCE(d.pinned, false), + COALESCE(d.pinned_order, 0), COALESCE(d.unread_mark, m.unread_mark), + COALESCE(d.unread_mentions_count, 0), COALESCE(d.unread_reactions_count, 0), + COALESCE(d.view_forum_as_messages, false), + COALESCE(d.has_scheduled, false), + d.default_send_as_peer_type, + d.default_send_as_peer_id +FROM channel_members m +JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted +LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted +LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id +WHERE m.user_id = $1 + AND m.channel_id = ANY($2::bigint[]) + AND m.status = 'active' + AND (c.broadcast OR c.megagroup) +ORDER BY `+visibleTopDate+` ASC, + `+visibleTopID+` ASC, + c.id ASC +LIMIT $3`, userID, channelIDs, limit) + if err != nil { + return domain.ChannelDialogList{}, fmt.Errorf("list inactive channels: %w", err) + } + defer rows.Close() + out := domain.ChannelDialogList{Dialogs: make([]domain.Dialog, 0, limit), Channels: make([]domain.Channel, 0, limit)} + for rows.Next() { + ch, dialog, _, err := scanChannelDialogRow(rows, userID) + if err != nil { + return domain.ChannelDialogList{}, err + } + out.Dialogs = append(out.Dialogs, dialog) + out.Channels = append(out.Channels, ch) + } + if err := rows.Err(); err != nil { + return domain.ChannelDialogList{}, err + } + out.Count = len(out.Dialogs) + return out, nil +} + +// listActiveChannelDialogCandidateIDs 返回候选频道 id,并附带 hasBroadcastAdmin:该用户是否在任一 +// 广播频道任 creator/admin。后者是 monoforum 私信会话存在性的页无关超集判定,供 getDialogs 热路径据此 +// 跳过额外的 monoforum 管理员会话查询(见 ListChannelDialogs)。只读 user_channel_member_index 表 +// 既有的 broadcast/role 列,零额外往返。 +func (s *ChannelStore) listActiveChannelDialogCandidateIDs(ctx context.Context, userID int64, channelLikeOnly bool) ([]int64, bool, error) { + if userID == 0 { + return nil, false, nil + } + where := "user_id = $1 AND status = 'active' AND NOT deleted" + if channelLikeOnly { + where += " AND (broadcast OR megagroup)" + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id, broadcast, role +FROM user_channel_member_index +WHERE `+where+` +ORDER BY channel_id ASC +LIMIT $2`, userID, channelDialogCandidateLimit) + if err != nil { + return nil, false, fmt.Errorf("list active channel dialog candidate ids: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, minInt(channelDialogCandidateLimit, 1024)) + hasBroadcastAdmin := false + for rows.Next() { + var channelID int64 + var broadcast bool + var role string + if err := rows.Scan(&channelID, &broadcast, &role); err != nil { + return nil, false, err + } + ids = append(ids, channelID) + if broadcast && (role == "creator" || role == "admin") { + hasBroadcastAdmin = true + } + } + if err := rows.Err(); err != nil { + return nil, false, err + } + return ids, hasBroadcastAdmin, nil +} + +func (s *ChannelStore) SetChannelDialogPinned(ctx context.Context, userID, channelID int64, pinned bool) (bool, int, error) { + if userID == 0 || channelID == 0 { + return false, 0, nil + } + var changed bool + var folderID int32 + if err := s.db.QueryRow(ctx, ` +WITH target AS ( + SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date, + m.read_inbox_max_id, m.read_outbox_max_id, m.available_min_id + FROM channels c + JOIN channel_members m ON m.channel_id = c.id + WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted +), +ensured AS ( + -- 惰性建行必须带 member 真实水位与未读数:0 值缓存行一旦存在就会 + -- 遮蔽真值(小群读取信缓存列)。 + INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, unread_count) + SELECT $1, channel_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, ( + SELECT COUNT(*)::int + FROM channel_messages cm + WHERE cm.channel_id = target.channel_id + AND cm.id > GREATEST(target.read_inbox_max_id, target.available_min_id) + AND NOT cm.deleted + AND cm.sender_user_id <> $1 + ) + FROM target + ON CONFLICT (user_id, channel_id) DO NOTHING +), +-- 置顶顺序跨 dialogs/channel_dialogs 两表、但仅在该会话当前 folder 内分配, +-- 与私聊 SetDialogPinned 共用同一个 (user, folder) order 空间。 +self_folder AS ( + SELECT COALESCE(d.folder_id, 0)::int AS folder_id + FROM channel_dialogs d + WHERE d.user_id = $1 AND d.channel_id = $2 +), +next_order AS ( + SELECT GREATEST( + COALESCE(( + SELECT MAX(cd.pinned_order) + FROM channel_dialogs cd, self_folder f + WHERE cd.user_id = $1 AND cd.pinned AND COALESCE(cd.folder_id, 0) = f.folder_id + ), 0), + COALESCE(( + SELECT MAX(ud.pinned_order) + FROM dialogs ud, self_folder f + WHERE ud.user_id = $1 AND ud.pinned AND ud.folder_id = f.folder_id + ), 0) + )::int + 1 AS value +), +updated AS ( + UPDATE channel_dialogs d + SET pinned = $3, + pinned_order = CASE + WHEN $3::boolean THEN CASE WHEN d.pinned_order > 0 THEN d.pinned_order ELSE next_order.value END + ELSE 0 + END, + updated_at = now() + FROM next_order + WHERE d.user_id = $1 AND d.channel_id = $2 + AND EXISTS (SELECT 1 FROM target) + AND (d.pinned IS DISTINCT FROM $3::boolean OR ($3::boolean AND d.pinned_order = 0)) + RETURNING COALESCE(d.folder_id, 0)::int AS folder_id +) +SELECT EXISTS (SELECT 1 FROM updated)::boolean, + COALESCE((SELECT folder_id FROM updated), (SELECT folder_id FROM self_folder), 0)::int`, + userID, channelID, pinned).Scan(&changed, &folderID); err != nil { + return false, 0, fmt.Errorf("set channel dialog pinned: %w", err) + } + return changed, int(folderID), nil +} + +func (s *ChannelStore) ReorderChannelPinnedDialogs(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) { + if userID == 0 { + return false, nil + } + peerTypes, peerIDs := peerArrays(order) + changed := false + if force { + tag, err := s.db.Exec(ctx, ` +WITH requested AS ( + SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS peer_id + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::text[]) +) +UPDATE channel_dialogs d +SET pinned = false, pinned_order = 0, updated_at = now() +WHERE d.user_id = $1 + AND d.pinned + AND COALESCE(d.folder_id, 0) = $4::int + AND NOT EXISTS ( + SELECT 1 FROM requested r + WHERE r.peer_type = 'channel' AND r.peer_id = d.channel_id + )`, userID, peerTypes, peerIDs, folderID) + if err != nil { + return false, fmt.Errorf("clear channel pinned dialogs not in order: %w", err) + } + if tag.RowsAffected() > 0 { + changed = true + } + } + if len(peerIDs) == 0 { + return changed, nil + } + tag, err := s.db.Exec(ctx, ` +WITH requested AS ( + SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS peer_id, i::int AS pos + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::text[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_id) peer_id, (cardinality($3::bigint[]) - pos + 1)::int AS ord + FROM requested + WHERE peer_type = 'channel' + ORDER BY peer_id, pos +) +UPDATE channel_dialogs d +SET pinned = true, pinned_order = deduped.ord, updated_at = now() +FROM deduped +WHERE d.user_id = $1 AND d.channel_id = deduped.peer_id + AND COALESCE(d.folder_id, 0) = $4::int + AND (NOT d.pinned OR d.pinned_order IS DISTINCT FROM deduped.ord)`, userID, peerTypes, peerIDs, folderID) + if err != nil { + return false, fmt.Errorf("reorder channel pinned dialogs: %w", err) + } + if tag.RowsAffected() > 0 { + changed = true + } + return changed, nil +} + +func (s *ChannelStore) EditChannelPeerFolders(ctx context.Context, userID int64, peers []domain.FolderPeerUpdate) error { + if userID == 0 || len(peers) == 0 { + return nil + } + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + folderIDs := make([]int32, 0, len(peers)) + seen := make(map[int64]struct{}, len(peers)) + for _, item := range peers { + if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { + continue + } + if item.FolderID != domain.DialogMainFolderID && item.FolderID != domain.DialogArchiveFolderID { + continue + } + if _, ok := seen[item.Peer.ID]; ok { + continue + } + seen[item.Peer.ID] = struct{}{} + peerTypes = append(peerTypes, string(item.Peer.Type)) + peerIDs = append(peerIDs, item.Peer.ID) + folderIDs = append(folderIDs, int32(item.FolderID)) + } + if len(peerIDs) == 0 { + return nil + } + // 归档必须 ensure-INSERT:从未读过/置顶过的频道还没有 dialog 行, + // 只 UPDATE 会让归档静默丢失;新行同时带上 member 真实水位, + // 避免 0 水位缓存行遮蔽未读/已读状态。 + if _, err := s.db.Exec(ctx, ` +WITH requested AS ( + SELECT ($2::text[])[i] AS peer_type, ($3::bigint[])[i] AS channel_id, ($4::int[])[i] AS folder_id + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::text[]) AND i <= cardinality($4::int[]) +), +deduped AS ( + SELECT DISTINCT ON (channel_id) channel_id, folder_id + FROM requested + WHERE peer_type = 'channel' AND folder_id IN (0, 1) + ORDER BY channel_id +) +INSERT INTO channel_dialogs (user_id, channel_id, folder_id, top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count, updated_at) +SELECT $1, m.channel_id, deduped.folder_id, + CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END, + m.read_inbox_max_id, m.read_outbox_max_id, + ( + SELECT COUNT(*)::int + FROM channel_messages cm + WHERE cm.channel_id = m.channel_id + AND cm.id > GREATEST(m.read_inbox_max_id, m.available_min_id) + AND NOT cm.deleted + AND cm.sender_user_id <> $1 + ), + now() +FROM deduped +JOIN channel_members m ON m.user_id = $1 AND m.channel_id = deduped.channel_id AND m.status = 'active' +JOIN channels c ON c.id = m.channel_id AND NOT c.deleted +ON CONFLICT (user_id, channel_id) DO UPDATE +-- 换 folder 时清 pinned:与私聊 EditDialogPeerFolders 一致,TDesktop +-- 在归档/还原时本地无条件 unpin,服务端保留旧 pin 会造成状态漂移。 +SET folder_id = EXCLUDED.folder_id, + pinned = CASE WHEN COALESCE(channel_dialogs.folder_id, 0) <> EXCLUDED.folder_id THEN false ELSE channel_dialogs.pinned END, + pinned_order = CASE WHEN COALESCE(channel_dialogs.folder_id, 0) <> EXCLUDED.folder_id THEN 0 ELSE channel_dialogs.pinned_order END, + updated_at = now()`, userID, peerTypes, peerIDs, folderIDs); err != nil { + return fmt.Errorf("edit channel peer folders: %w", err) + } + return nil +} + +func (s *ChannelStore) CountChannelArchiveUnread(ctx context.Context, userID int64) (int, int, error) { + if userID == 0 { + return 0, 0, nil + } + var peers, messages int32 + // JOIN active member:退群残留的 channel_dialogs 行不计入归档徽章。 + if err := s.db.QueryRow(ctx, ` +SELECT + COUNT(*) FILTER (WHERE d.unread_count > 0 OR d.unread_mark)::int, + COALESCE(SUM(d.unread_count), 0)::int +FROM channel_dialogs d +JOIN channel_members m ON m.channel_id = d.channel_id AND m.user_id = d.user_id AND m.status = 'active' +WHERE d.user_id = $1 + AND COALESCE(d.folder_id, 0) = 1`, userID).Scan(&peers, &messages); err != nil { + return 0, 0, fmt.Errorf("count channel archive unread: %w", err) + } + return int(peers), int(messages), nil +} + +func (s *ChannelStore) getChannelDialog(ctx context.Context, db sqlcgen.DBTX, userID int64, channel domain.Channel) (domain.ChannelDialog, error) { + if s.dialogCacheActive(db) { + return s.dialogCache.getOrLoad(ctx, userID, channel.ID, func() (domain.ChannelDialog, error) { + return s.getChannelDialogUncached(ctx, db, userID, channel) + }) + } + return s.getChannelDialogUncached(ctx, db, userID, channel) +} + +func (s *ChannelStore) getChannelDialogUncached(ctx context.Context, db sqlcgen.DBTX, userID int64, channel domain.Channel) (domain.ChannelDialog, error) { + dialog := domain.ChannelDialog{UserID: userID, ChannelID: channel.ID, TopMessageID: channel.TopMessageID} + var defaultSendAsType sql.NullString + var defaultSendAsID sql.NullInt64 + visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" + visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)" + visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) + // 单频道 TopMessageDate 用 LEFT JOIN top_msg 直接取,替代此前对每个频道再单查一次 + // getChannelMessage 的额外往返(chf-3,实测 pg_stat "频道消息按id" 来源之一)。严格 + // 保持原语义:仅当 top 可见(top_message_id > available_min_id)且消息行存在时取其 + // message_date(不过滤 deleted,与原 getChannelMessage 一致),否则回退 + // COALESCE(d.top_message_date, c.date)。注意:与批量版 getChannelDialogs 的隐藏态 + // (ELSE 0)刻意保持各自既有差异,本改动只去往返、不改单频道输出。 + visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id AND top_msg.id IS NOT NULL THEN top_msg.message_date ELSE COALESCE(d.top_message_date, c.date) END" + err := db.QueryRow(ctx, ` +SELECT `+visibleTopID+`, + `+visibleTopDate+`, + COALESCE(d.folder_id, 0), + `+visibleReadInbox+`, + LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)), + `+visibleUnreadCount+`, + COALESCE(d.pinned, false), + COALESCE(d.pinned_order, 0), + COALESCE(d.unread_mark, m.unread_mark), + COALESCE(d.unread_mentions_count, 0), + COALESCE(d.unread_reactions_count, 0), + COALESCE(d.view_forum_as_messages, false), + COALESCE(d.has_scheduled, false), + d.default_send_as_peer_type, + d.default_send_as_peer_id +FROM channels c +JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 +LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id +LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id +WHERE c.id = $2`, userID, channel.ID).Scan( + &dialog.TopMessageID, + &dialog.TopMessageDate, + &dialog.FolderID, + &dialog.ReadInboxMaxID, + &dialog.ReadOutboxMaxID, + &dialog.UnreadCount, + &dialog.Pinned, + &dialog.PinnedOrder, + &dialog.UnreadMark, + &dialog.UnreadMentions, + &dialog.UnreadReactions, + &dialog.ViewForumAsMessages, + &dialog.HasScheduled, + &defaultSendAsType, + &defaultSendAsID, + ) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelDialog{}, fmt.Errorf("get channel dialog: %w", err) + } + if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 { + dialog.DefaultSendAs = &domain.Peer{Type: domain.PeerType(defaultSendAsType.String), ID: defaultSendAsID.Int64} + } + return dialog, nil +} + +func (s *ChannelStore) getChannelDialogs(ctx context.Context, db sqlcgen.DBTX, userID int64, channelIDs []int64) (map[int64]domain.ChannelDialog, error) { + if userID == 0 || len(channelIDs) == 0 { + return nil, nil + } + if s.dialogCacheActive(db) { + out := make(map[int64]domain.ChannelDialog, len(channelIDs)) + misses := make([]int64, 0, len(channelIDs)) + seen := make(map[int64]struct{}, len(channelIDs)) + for _, channelID := range channelIDs { + if channelID == 0 { + continue + } + if _, ok := seen[channelID]; ok { + continue + } + seen[channelID] = struct{}{} + if dialog, ok := s.dialogCache.get(userID, channelID); ok { + out[channelID] = dialog + continue + } + misses = append(misses, channelID) + } + if len(misses) == 0 { + return out, nil + } + // 暖写回前快照 epoch:miss-load 期间若收到失效则拒绝陈旧投影写回(同 ListChannelDialogs)。 + loadEpoch := s.dialogCache.cacheEpoch() + loaded, err := s.getChannelDialogsUncached(ctx, db, userID, misses) + if err != nil { + return nil, err + } + for channelID, dialog := range loaded { + s.dialogCache.putIfEpoch(dialog, loadEpoch) + out[channelID] = dialog + } + return out, nil + } + return s.getChannelDialogsUncached(ctx, db, userID, channelIDs) +} + +func (s *ChannelStore) getChannelDialogsUncached(ctx context.Context, db sqlcgen.DBTX, userID int64, channelIDs []int64) (map[int64]domain.ChannelDialog, error) { + if userID == 0 || len(channelIDs) == 0 { + return nil, nil + } + visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" + visibleTopDate := "CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END" + visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)" + visibleUnreadCount := channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID) + rows, err := db.Query(ctx, ` +SELECT c.id, + `+visibleTopID+`, + `+visibleTopDate+`, + COALESCE(d.folder_id, 0), + `+visibleReadInbox+`, + LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)), + `+visibleUnreadCount+`, + COALESCE(d.pinned, false), + COALESCE(d.pinned_order, 0), + COALESCE(d.unread_mark, m.unread_mark), + COALESCE(d.unread_mentions_count, 0), + COALESCE(d.unread_reactions_count, 0), + COALESCE(d.view_forum_as_messages, false), + COALESCE(d.has_scheduled, false), + d.default_send_as_peer_type, + d.default_send_as_peer_id +FROM channels c +JOIN channel_members m ON m.channel_id = c.id AND m.user_id = $1 +LEFT JOIN channel_messages top_msg ON top_msg.channel_id = c.id AND top_msg.id = c.top_message_id AND NOT top_msg.deleted +LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id +WHERE c.id = ANY($2::bigint[])`, userID, channelIDs) + if err != nil { + return nil, fmt.Errorf("get channel dialogs: %w", err) + } + defer rows.Close() + out := make(map[int64]domain.ChannelDialog, len(channelIDs)) + for rows.Next() { + var dialog domain.ChannelDialog + var defaultSendAsType sql.NullString + var defaultSendAsID sql.NullInt64 + if err := rows.Scan( + &dialog.ChannelID, + &dialog.TopMessageID, + &dialog.TopMessageDate, + &dialog.FolderID, + &dialog.ReadInboxMaxID, + &dialog.ReadOutboxMaxID, + &dialog.UnreadCount, + &dialog.Pinned, + &dialog.PinnedOrder, + &dialog.UnreadMark, + &dialog.UnreadMentions, + &dialog.UnreadReactions, + &dialog.ViewForumAsMessages, + &dialog.HasScheduled, + &defaultSendAsType, + &defaultSendAsID, + ); err != nil { + return nil, err + } + dialog.UserID = userID + if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 { + dialog.DefaultSendAs = &domain.Peer{Type: domain.PeerType(defaultSendAsType.String), ID: defaultSendAsID.Int64} + } + out[dialog.ChannelID] = dialog + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("get channel dialogs: %w", err) + } + return out, nil +} + +// cappedChannelUnreadCount 生成把扫描行数钳到 MaxDialogUnreadCount 的未读 COUNT 子查询, +// 与读路径 channelDialogDynamicUnreadCountSQL 口径一致。写 fan-out 路径此前是裸 COUNT 无 +// LIMIT,久未读成员(read 远低于 top)会退化为 O(未读积压) 全扫;用 LIMIT 内层子查询封顶 +// 单成员扫描工作量,写出的缓存列与读路径下发角标同为 min(实际, MaxDialogUnreadCount)。 +// whereBody 是 channel_messages 别名 msg 上的完整 WHERE 子句。 +func cappedChannelUnreadCount(whereBody string) string { + return fmt.Sprintf(`( + SELECT COUNT(*)::int + FROM ( + SELECT 1 + FROM channel_messages msg + %s + LIMIT %d + ) msg_capped + )`, whereBody, domain.MaxDialogUnreadCount) +} + +func refreshChannelDialogsAfterDeleteTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) error { + if channel.ID == 0 || !shouldSynchronouslyUpsertChannelDialogs(channel) { + return nil + } + insertUnread := cappedChannelUnreadCount(`WHERE msg.channel_id = $1 + AND msg.id > GREATEST(active.read_inbox_max_id, active.available_min_id) + AND msg.id <= active.visible_top_id + AND NOT msg.deleted + AND msg.sender_user_id <> active.user_id`) + conflictUnread := cappedChannelUnreadCount(`WHERE msg.channel_id = channel_dialogs.channel_id + AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) + AND msg.id <= EXCLUDED.top_message_id + AND NOT msg.deleted + AND msg.sender_user_id <> channel_dialogs.user_id`) + if _, err := tx.Exec(ctx, fmt.Sprintf(` +WITH active AS ( + SELECT + m.user_id, + m.available_min_id, + GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id) AS read_inbox_max_id, + LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)) AS read_outbox_max_id, + COALESCE(d.unread_mark, m.unread_mark) AS unread_mark, + CASE WHEN $2 > m.available_min_id THEN $2 ELSE 0 END AS visible_top_id + FROM channel_members m + JOIN channels c ON c.id = m.channel_id + LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id + WHERE m.channel_id = $1 + AND m.status = 'active' + AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) +) +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark +) +SELECT + user_id, $1, visible_top_id, + CASE WHEN visible_top_id > 0 THEN COALESCE(top_msg.message_date, $3) ELSE 0 END, + read_inbox_max_id, read_outbox_max_id, + %s, + unread_mark +FROM active +LEFT JOIN channel_messages top_msg ON top_msg.channel_id = $1 AND top_msg.id = active.visible_top_id AND NOT top_msg.deleted +ON CONFLICT (user_id, channel_id) DO UPDATE SET + top_message_id = EXCLUDED.top_message_id, + top_message_date = EXCLUDED.top_message_date, + read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), + read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), + unread_count = %s, + unread_mark = EXCLUDED.unread_mark, + updated_at = now()`, insertUnread, conflictUnread), channel.ID, channel.TopMessageID, channel.Date); err != nil { + return fmt.Errorf("refresh channel dialogs after delete: %w", err) + } + return nil +} + +func upsertChannelDialogTx(ctx context.Context, tx pgx.Tx, userID int64, channel domain.Channel, top domain.ChannelMessage, readInboxMaxID, readOutboxMaxID int) error { + topDate := top.Date + if topDate == 0 { + topDate = channel.Date + } + unread, err := countChannelUnreadMessages(ctx, tx, userID, channel.ID, readInboxMaxID, channel.TopMessageID) + if err != nil { + return err + } + conflictUnread := cappedChannelUnreadCount(`WHERE msg.channel_id = channel_dialogs.channel_id + AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) + AND msg.id <= GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id) + AND NOT msg.deleted + AND msg.sender_user_id <> channel_dialogs.user_id`) + if _, err := tx.Exec(ctx, fmt.Sprintf(` +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark +) VALUES ($1,$2,$3,$4,$5,$6,$7,false) +ON CONFLICT (user_id, channel_id) DO UPDATE SET + top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), + top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), + read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), + read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), + unread_count = %s, + unread_mark = false, + updated_at = now()`, conflictUnread), + userID, channel.ID, channel.TopMessageID, topDate, readInboxMaxID, readOutboxMaxID, unread); err != nil { + return fmt.Errorf("upsert channel dialog: %w", err) + } + return nil +} + +func upsertChannelDialogsForMessageTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, selfReadUserID int64, skipDeliveryUserIDs ...[]int64) error { + if channel.ID == 0 || top.ID == 0 { + return nil + } + skipIDs := []int64{} + if len(skipDeliveryUserIDs) > 0 { + skipIDs = uniqueChannelUserIDs(skipDeliveryUserIDs[0], selfReadUserID) + } + if len(skipIDs) > 0 { + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET read_inbox_max_id = GREATEST(read_inbox_max_id, $3), + unread_mark = false, + updated_at = now() +WHERE channel_id = $1 + AND user_id = ANY($2::bigint[]) + AND status = 'active' + AND NOT COALESCE((banned_rights->>'ViewMessages')::boolean, false) + AND $3 > available_min_id`, channel.ID, skipIDs, top.ID); err != nil { + return fmt.Errorf("advance skipped channel delivery boundary: %w", err) + } + } + if !shouldSynchronouslyUpsertChannelDialogs(channel) { + return nil + } + topDate := top.Date + if topDate == 0 { + topDate = channel.Date + } + // 下界用 GREATEST(read_inbox, available_min_id) 与 delete/read 路径一致:当前 + // 全局不变量 read_inbox_max_id >= available_min_id 使其为 no-op,但该不变量未由 + // schema 强制,对齐后即便未来有路径只抬 available_min 不抬 read 也不会 over-count。 + insertUnread := cappedChannelUnreadCount(`WHERE msg.channel_id = $1 + AND msg.id > GREATEST(active.read_inbox_max_id, active.available_min_id) + AND msg.id <= $2 + AND NOT msg.deleted + AND msg.sender_user_id <> active.user_id`) + conflictUnread := cappedChannelUnreadCount(`WHERE msg.channel_id = channel_dialogs.channel_id + AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) + AND msg.id <= GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id) + AND NOT msg.deleted + AND msg.sender_user_id <> channel_dialogs.user_id`) + if _, err := tx.Exec(ctx, fmt.Sprintf(` +WITH active AS ( + SELECT + m.user_id, + CASE + WHEN m.user_id = $4 THEN GREATEST(m.read_inbox_max_id, $2) + ELSE m.read_inbox_max_id + END AS read_inbox_max_id, + m.available_min_id, + -- sender 的 outbox 回执水位绝不随自己发消息推进:read_outbox_max_id + -- 语义是"已被其他成员读到的最大 ID",推进只能来自对端 readHistory。 + m.read_outbox_max_id + FROM channel_members m + WHERE m.channel_id = $1 + AND m.status = 'active' + AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) + AND $2 > m.available_min_id + AND NOT (m.user_id = ANY($5::bigint[])) +) +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark +) +SELECT + user_id, $1, $2, $3, + read_inbox_max_id, read_outbox_max_id, + %s, + false +FROM active +ON CONFLICT (user_id, channel_id) DO UPDATE SET + top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), + top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), + read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), + read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), + unread_count = %s, + unread_mark = CASE WHEN channel_dialogs.user_id = $4 THEN false ELSE channel_dialogs.unread_mark END, + updated_at = now()`, insertUnread, conflictUnread), channel.ID, top.ID, topDate, selfReadUserID, skipIDs); err != nil { + return fmt.Errorf("upsert channel message dialogs: %w", err) + } + return nil +} + +func shouldSynchronouslyUpsertChannelDialogs(channel domain.Channel) bool { + if channel.Broadcast { + return false + } + return channel.ParticipantsCount > 0 && channel.ParticipantsCount <= domain.MaxSynchronousChannelDialogFanout +} + +func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.Dialog, *domain.Peer, error) { + var ch domain.Channel + var rights, reactionPolicy string + var wallpaper *string + var topID, topDate, folderID, readInbox, readOutbox, unreadCount, pinnedOrder, unreadMentions, unreadReactions int + var pinned, unreadMark, viewForumAsMessages, hasScheduled bool + var defaultSendAsType sql.NullString + var defaultSendAsID sql.NullInt64 + dest := append(channelScanDest(&ch, &rights, &reactionPolicy, &wallpaper), + &topID, &topDate, + &folderID, &readInbox, &readOutbox, &unreadCount, &pinned, &pinnedOrder, &unreadMark, &unreadMentions, &unreadReactions, &viewForumAsMessages, &hasScheduled, + &defaultSendAsType, &defaultSendAsID, + ) + if err := row.Scan(dest...); err != nil { + return domain.Channel{}, domain.Dialog{}, nil, err + } + finishChannelScan(&ch, rights, reactionPolicy, wallpaper) + dialog := domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ch.ID}, + FolderID: folderID, + TopMessage: topID, + TopMessageDate: topDate, + ReadInboxMaxID: readInbox, + ReadOutboxMaxID: readOutbox, + UnreadCount: unreadCount, + UnreadMentions: unreadMentions, + UnreadReactions: unreadReactions, + Pinned: pinned, + PinnedOrder: pinnedOrder, + UnreadMark: unreadMark, + ViewForumAsMessages: viewForumAsMessages, + HasScheduled: hasScheduled, + Pts: ch.Pts, + } + var defaultSendAs *domain.Peer + if defaultSendAsType.Valid && defaultSendAsID.Valid && defaultSendAsID.Int64 != 0 { + defaultSendAs = &domain.Peer{Type: domain.PeerType(defaultSendAsType.String), ID: defaultSendAsID.Int64} + } + _ = userID + return ch, dialog, defaultSendAs, nil +} + +func channelDialogToDialog(dialog domain.ChannelDialog, channelPts int) domain.Dialog { + return domain.Dialog{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: dialog.ChannelID}, + FolderID: dialog.FolderID, + TopMessage: dialog.TopMessageID, + TopMessageDate: dialog.TopMessageDate, + ReadInboxMaxID: dialog.ReadInboxMaxID, + ReadOutboxMaxID: dialog.ReadOutboxMaxID, + UnreadCount: dialog.UnreadCount, + UnreadMentions: dialog.UnreadMentions, + UnreadReactions: dialog.UnreadReactions, + Pinned: dialog.Pinned, + PinnedOrder: dialog.PinnedOrder, + UnreadMark: dialog.UnreadMark, + ViewForumAsMessages: dialog.ViewForumAsMessages, + HasScheduled: dialog.HasScheduled, + Pts: channelPts, + } +} + +func channelDialogFromDialog(userID int64, dialog domain.Dialog) domain.ChannelDialog { + return domain.ChannelDialog{ + UserID: userID, + ChannelID: dialog.Peer.ID, + FolderID: dialog.FolderID, + TopMessageID: dialog.TopMessage, + TopMessageDate: dialog.TopMessageDate, + ReadInboxMaxID: dialog.ReadInboxMaxID, + ReadOutboxMaxID: dialog.ReadOutboxMaxID, + UnreadCount: dialog.UnreadCount, + UnreadMentions: dialog.UnreadMentions, + UnreadReactions: dialog.UnreadReactions, + Pinned: dialog.Pinned, + PinnedOrder: dialog.PinnedOrder, + UnreadMark: dialog.UnreadMark, + ViewForumAsMessages: dialog.ViewForumAsMessages, + HasScheduled: dialog.HasScheduled, + } +} + +func channelDialogMatchesFilter(dialog domain.Dialog, channel domain.Channel, filter domain.DialogFilter) bool { + if filter.HasFolderID { + if filter.FolderID < domain.DialogCustomFolderMinID { + if dialog.FolderID != filter.FolderID { + return false + } + } else if filter.Folder == nil { + return false + } + } + if filter.PinnedOnly && !dialog.Pinned { + return false + } + if filter.ExcludePinned && dialog.Pinned { + return false + } + if !channelDialogAfterOffset(dialog, filter) { + return false + } + if filter.Folder == nil { + return true + } + folder := filter.Folder + if peerInDialogFolder(dialog.Peer, folder.ExcludePeers) { + return false + } + if folder.ExcludeRead && dialog.UnreadCount == 0 && !dialog.UnreadMark { + return false + } + if folder.ExcludeArchived && dialog.FolderID == domain.DialogArchiveFolderID { + return false + } + if peerInDialogFolder(dialog.Peer, folder.IncludePeers) || peerInDialogFolder(dialog.Peer, folder.PinnedPeers) { + return true + } + if channel.Megagroup && folder.Groups { + return true + } + if channel.Broadcast && folder.Broadcasts { + return true + } + // 群/频道只能经 Groups/Broadcasts 类别开关或显式 include/pinned 进入自定义 + // 文件夹;仅勾 Contacts/NonContacts/Bots(私聊类别)的文件夹不含任何群/频道。 + return false +} + +func channelDialogAfterOffset(dialog domain.Dialog, filter domain.DialogFilter) bool { + if filter.OffsetDate <= 0 && filter.OffsetID <= 0 { + if filter.HasOffsetPeer && filter.OffsetPeer == dialog.Peer { + return false + } + return true + } + if filter.OffsetDate > 0 { + if dialog.TopMessageDate != filter.OffsetDate { + return dialog.TopMessageDate < filter.OffsetDate + } + if filter.OffsetID <= 0 { + return false + } + if dialog.TopMessage != filter.OffsetID { + return dialog.TopMessage < filter.OffsetID + } + if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { + return dialog.Peer.ID < filter.OffsetPeer.ID + } + return false + } + if dialog.TopMessage != filter.OffsetID { + return dialog.TopMessage < filter.OffsetID + } + if filter.HasOffsetPeer && filter.OffsetPeer.Type == dialog.Peer.Type { + return dialog.Peer.ID < filter.OffsetPeer.ID + } + return false +} + +func peerInDialogFolder(peer domain.Peer, items []domain.DialogFolderPeer) bool { + for _, item := range items { + if item.Peer == peer { + return true + } + } + return false +} + +func channelFolderPeerIDs(primary []domain.DialogFolderPeer, rest ...[]domain.DialogFolderPeer) []int64 { + total := len(primary) + for _, items := range rest { + total += len(items) + } + seen := make(map[int64]struct{}, minInt(total, domain.MaxDialogFolderPeers)) + out := make([]int64, 0, minInt(total, domain.MaxDialogFolderPeers)) + appendOne := func(items []domain.DialogFolderPeer) { + for _, item := range items { + if len(out) >= domain.MaxDialogFolderPeers { + return + } + if item.Peer.Type != domain.PeerTypeChannel || item.Peer.ID == 0 { + continue + } + if _, ok := seen[item.Peer.ID]; ok { + continue + } + seen[item.Peer.ID] = struct{}{} + out = append(out, item.Peer.ID) + } + } + appendOne(primary) + for _, items := range rest { + appendOne(items) + } + return out +} + +func previewChannelDialog(userID int64, channel domain.Channel, member domain.ChannelMember) domain.ChannelDialog { + topMessageID := channel.TopMessageID + if topMessageID <= member.AvailableMinID { + topMessageID = 0 + } + return domain.ChannelDialog{ + UserID: userID, + ChannelID: channel.ID, + TopMessageID: topMessageID, + TopMessageDate: channel.Date, + ReadInboxMaxID: maxInt(channel.TopMessageID, member.ReadInboxMaxID), + ReadOutboxMaxID: maxInt(channel.TopMessageID, member.ReadOutboxMaxID), + } +} diff --git a/internal/store/postgres/channel_difference_integration_test.go b/internal/store/postgres/channel_difference_integration_test.go new file mode 100644 index 00000000..13bba9bc --- /dev/null +++ b/internal/store/postgres/channel_difference_integration_test.go @@ -0,0 +1,586 @@ +package postgres + +import ( + "context" + "telesrv/internal/domain" + "testing" +) + +func TestChannelStoreDifferenceStartsAtMemberAvailableMinPts(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1778" + suffix + "01", + FirstName: "PtsOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1778" + suffix + "02", + FirstName: "PtsMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + joiner, err := users.Create(ctx, domain.User{ + AccessHash: 43, + Phone: "+1778" + suffix + "03", + FirstName: "PtsJoiner", + }) + if err != nil { + t.Fatalf("create joiner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID, joiner.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "PTS Floor " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000350, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + ptsFloor := created.Channel.Pts + promoted, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: member.ID, + AdminRights: domain.ChannelAdminRights{ + InviteUsers: true, + }, + Date: 1700000351, + }) + if err != nil { + t.Fatalf("edit admin: %v", err) + } + if promoted.Event.Pts != 0 || promoted.Event.PtsCount != 0 || promoted.Channel.Pts != ptsFloor { + t.Fatalf("promote affected channel pts = event(%d,%d) channel %d, want no pts advance from %d", promoted.Event.Pts, promoted.Event.PtsCount, promoted.Channel.Pts, ptsFloor) + } + adminDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: member.ID, + ChannelID: channelID, + Pts: ptsFloor, + Limit: 10, + }) + if err != nil { + t.Fatalf("difference after promote: %v", err) + } + if len(adminDiff.Events) != 0 || adminDiff.Pts != ptsFloor { + t.Fatalf("difference after promote = %+v, want no durable participant event at pts %d", adminDiff, ptsFloor) + } + joined, err := channels.JoinChannel(ctx, channelID, joiner.ID, 1700000352) + if err != nil { + t.Fatalf("join channel: %v", err) + } + if len(joined.Members) != 1 || joined.Members[0].AvailableMinPts != ptsFloor { + t.Fatalf("joined members = %+v, want available_min_pts %d", joined.Members, ptsFloor) + } + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: joiner.ID, + ChannelID: channelID, + Pts: 0, + Limit: 100, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + if diff.Pts != joined.Channel.Pts { + t.Fatalf("diff pts = %d, want current channel pts %d", diff.Pts, joined.Channel.Pts) + } + for _, msg := range diff.NewMessages { + if msg.Pts <= ptsFloor { + t.Fatalf("diff leaks pre-join message %+v at or before available_min_pts %d", msg, ptsFloor) + } + } + for _, event := range diff.OtherUpdates { + if event.Pts <= ptsFloor { + t.Fatalf("diff leaks pre-join event %+v at or before available_min_pts %d", event, ptsFloor) + } + } +} + +func TestChannelStorePublicPreviewDifferenceAllowsNonMember(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 241, + Phone: "+1778" + suffix + "41", + FirstName: "PreviewDiffOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := users.Create(ctx, domain.User{ + AccessHash: 242, + Phone: "+1778" + suffix + "42", + FirstName: "PreviewDiffViewer", + }) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, viewer.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Preview Difference " + suffix, + Broadcast: true, + Date: 1700000370, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: channelID, + Username: "preview_diff_" + suffix, + }); err != nil { + t.Fatalf("update username: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 1700000371, + Message: "public preview difference", + Date: 1700000371, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: viewer.ID, + ChannelID: channelID, + Pts: created.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("list public preview difference: %v", err) + } + if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "public preview difference" { + t.Fatalf("preview diff = %+v, want one public preview message at current pts", diff) + } + if diff.Dialog.UnreadCount != 0 || diff.Dialog.ReadInboxMaxID < sent.Message.ID { + t.Fatalf("preview diff dialog = %+v, want read-only public preview dialog", diff.Dialog) + } +} + +func TestChannelStoreDifferenceUsesDurableMessageSnapshots(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 39, + Phone: "+1778" + suffix + "01", + FirstName: "SnapshotOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 40, + Phone: "+1778" + suffix + "02", + FirstName: "SnapshotFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Snapshot Diff " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000380, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 941, + Message: "original", + Date: 1700000381, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + ID: sent.Message.ID, + Message: "first edit", + EditDate: 1700000382, + }); err != nil { + t.Fatalf("first edit: %v", err) + } + if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + ID: sent.Message.ID, + Message: "second edit", + EditDate: 1700000383, + }); err != nil { + t.Fatalf("second edit: %v", err) + } + duplicate, found, err := channels.duplicateChannelMessage(ctx, channelID, owner.ID, sent.Message.RandomID) + if err != nil { + t.Fatalf("duplicate channel message: %v", err) + } + if !found || !duplicate.Duplicate || duplicate.Event.Type != domain.ChannelUpdateNewMessage || duplicate.Message.Body != "original" || duplicate.Event.Message.Body != "original" { + t.Fatalf("duplicate after edit = %+v found=%v, want original new-message snapshot", duplicate, found) + } + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: friend.ID, + ChannelID: channelID, + Pts: created.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + if len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "original" { + t.Fatalf("new messages = %+v, want original send snapshot", diff.NewMessages) + } + if len(diff.OtherUpdates) != 2 { + t.Fatalf("other updates = %+v, want two edit snapshots", diff.OtherUpdates) + } + if diff.OtherUpdates[0].Message.Body != "first edit" || diff.OtherUpdates[1].Message.Body != "second edit" { + t.Fatalf("edit snapshots = %q/%q, want first edit/second edit", diff.OtherUpdates[0].Message.Body, diff.OtherUpdates[1].Message.Body) + } +} + +func TestChannelStoreSendFailureBeforePtsAllocationDoesNotRecordNoopGap(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1888" + suffix + "01", + FirstName: "NoopOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + outsider, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1888" + suffix + "02", + FirstName: "NoopOutsider", + }) + if err != nil { + t.Fatalf("create outsider: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, outsider.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Noop Gap " + suffix, + Megagroup: true, + Date: 1700000400, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + _, err = channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: outsider.ID, + ChannelID: channelID, + RandomID: 991, + Message: "outsider should fail", + Date: 1700000401, + }) + if err == nil { + t.Fatal("SendChannelMessage outsider unexpectedly succeeded") + } + + var gapRows int + if err := pool.QueryRow(ctx, ` +SELECT count(*)::int +FROM channel_update_events +WHERE channel_id = $1 AND pts = 2`, channelID).Scan(&gapRows); err != nil { + t.Fatalf("count events after failed send: %v", err) + } + if gapRows != 0 { + t.Fatalf("events after failed send = %d, want no pts allocation before member validation", gapRows) + } + + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 992, + Message: "after rollback check", + Date: 1700000402, + }) + if err != nil { + t.Fatalf("send owner after gap: %v", err) + } + if sent.Event.Pts != 2 { + t.Fatalf("next channel pts = %d, want 2 after failed send before pts allocation", sent.Event.Pts) + } + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: owner.ID, + ChannelID: channelID, + Pts: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + if diff.Pts != 2 || len(diff.Events) != 1 || diff.Events[0].Type != domain.ChannelUpdateNewMessage || diff.Events[0].Pts != 2 { + t.Fatalf("diff after failed send = %+v, want only message pts=2", diff) + } +} + +func TestChannelDifferenceStopsAtPtsHole(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + owner, err := NewUserStore(pool).Create(ctx, domain.User{ + AccessHash: 49, + Phone: "+1888" + suffix + "31", + FirstName: "ChannelHoleOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Channel Hole " + suffix, + Megagroup: true, + Date: 1700000460, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 995, + Message: "missing event", + Date: 1700000461, + }) + if err != nil { + t.Fatalf("send first: %v", err) + } + second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 996, + Message: "after hole", + Date: 1700000462, + }) + if err != nil { + t.Fatalf("send second: %v", err) + } + if _, err := pool.Exec(ctx, "DELETE FROM channel_update_events WHERE channel_id = $1 AND pts = $2", channelID, first.Event.Pts); err != nil { + t.Fatalf("delete channel event: %v", err) + } + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: owner.ID, + ChannelID: channelID, + Pts: created.Channel.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("list difference: %v", err) + } + if diff.Pts != created.Channel.Pts || len(diff.Events) != 0 || diff.Final { + t.Fatalf("diff across hole = %+v, second pts=%d; want stop at previous pts", diff, second.Event.Pts) + } +} + +func TestReserveChannelPtsRollsBackWithTransaction(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + owner, err := NewUserStore(pool).Create(ctx, domain.User{ + AccessHash: 50, + Phone: "+1888" + suffix + "41", + FirstName: "ChannelPtsRollback", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Channel Pts Rollback " + suffix, + Megagroup: true, + Date: 1700000470, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + allocated, err := channels.reserveChannelPts(ctx, tx, channelID) + if err != nil { + _ = tx.Rollback(ctx) + t.Fatalf("reserve channel pts: %v", err) + } + if allocated != created.Channel.Pts+1 { + _ = tx.Rollback(ctx) + t.Fatalf("allocated channel pts = %d, want %d", allocated, created.Channel.Pts+1) + } + if err := tx.Rollback(ctx); err != nil { + t.Fatalf("rollback: %v", err) + } + got, err := channels.MaxChannelPts(ctx, channelID) + if err != nil { + t.Fatalf("MaxChannelPts: %v", err) + } + if got != created.Channel.Pts { + t.Fatalf("channel pts after rollback = %d, want unchanged %d", got, created.Channel.Pts) + } +} + +func TestChannelStoreDifferenceTooLongSnapshot(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 45, + Phone: "+1889" + suffix + "01", + FirstName: "TooLongOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 46, + Phone: "+1889" + suffix + "02", + FirstName: "TooLongFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "TooLong Snapshot " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000410, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + lastPts := created.Event.Pts + for i := 0; i < 12; i++ { + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: int64(10_000 + i), + Message: "too long snapshot", + Date: 1700000411 + i, + }) + if err != nil { + t.Fatalf("send channel message %d: %v", i, err) + } + lastPts = sent.Event.Pts + } + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: friend.ID, + ChannelID: channelID, + Pts: 0, + Limit: 3, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + if !diff.TooLong || !diff.Final || diff.Pts != lastPts { + t.Fatalf("diff = %+v, want tooLong final snapshot at pts %d", diff, lastPts) + } + if len(diff.NewMessages) == 0 || len(diff.NewMessages) > domain.MaxChannelDifferenceTooLongMessages { + t.Fatalf("tooLong snapshot messages = %d, want bounded latest messages", len(diff.NewMessages)) + } + if diff.Dialog.TopMessageID == 0 || diff.Dialog.UnreadCount == 0 { + t.Fatalf("tooLong dialog = %+v, want current dialog state", diff.Dialog) + } +} diff --git a/internal/store/postgres/channel_forum_topics_integration_test.go b/internal/store/postgres/channel_forum_topics_integration_test.go new file mode 100644 index 00000000..93693b85 --- /dev/null +++ b/internal/store/postgres/channel_forum_topics_integration_test.go @@ -0,0 +1,178 @@ +package postgres + +import ( + "context" + "telesrv/internal/domain" + "testing" +) + +func TestChannelStoreForumTopicsBatchViewerCounters(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 91, + Phone: "+1777" + suffix + "91", + FirstName: "ForumOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 92, + Phone: "+1777" + suffix + "92", + FirstName: "ForumMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Forum Counters " + suffix, + Megagroup: true, + Forum: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700001100, + }) + if err != nil { + t.Fatalf("create forum channel: %v", err) + } + channelID = created.Channel.ID + + topicA, err := channels.CreateForumTopic(ctx, domain.CreateChannelForumTopicRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "Alpha " + suffix, + RandomID: 111001, + Date: 1700001101, + }) + if err != nil { + t.Fatalf("create topic A: %v", err) + } + topicB, err := channels.CreateForumTopic(ctx, domain.CreateChannelForumTopicRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "Beta " + suffix, + RandomID: 111002, + Date: 1700001102, + }) + if err != nil { + t.Fatalf("create topic B: %v", err) + } + + memberReply, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: member.ID, + ChannelID: channelID, + RandomID: 111003, + Message: "member message in alpha", + ReplyTo: &domain.MessageReply{ + TopMessageID: topicA.Topic.TopicID, + ForumTopic: true, + }, + Date: 1700001103, + }) + if err != nil { + t.Fatalf("send member topic reply: %v", err) + } + if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ + UserID: owner.ID, + ChannelID: channelID, + MessageID: memberReply.Message.ID, + Reactions: []domain.MessageReaction{{ + Type: domain.MessageReactionEmoji, + Emoticon: "\U0001f525", + }}, + Date: 1700001104, + }); err != nil { + t.Fatalf("set topic reaction: %v", err) + } + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 111005, + Message: "alpha mention", + MentionUserIDs: []int64{member.ID}, + ReplyTo: &domain.MessageReply{ + TopMessageID: topicA.Topic.TopicID, + ForumTopic: true, + }, + Date: 1700001105, + }); err != nil { + t.Fatalf("send alpha mention: %v", err) + } + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 111006, + Message: "alpha plain", + ReplyTo: &domain.MessageReply{ + TopMessageID: topicA.Topic.TopicID, + ForumTopic: true, + }, + Date: 1700001106, + }); err != nil { + t.Fatalf("send alpha plain: %v", err) + } + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 111007, + Message: "beta plain", + ReplyTo: &domain.MessageReply{ + TopMessageID: topicB.Topic.TopicID, + ForumTopic: true, + }, + Date: 1700001107, + }); err != nil { + t.Fatalf("send beta plain: %v", err) + } + + list, err := channels.ListForumTopics(ctx, member.ID, domain.ChannelForumTopicFilter{ + ChannelID: channelID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list forum topics: %v", err) + } + if list.Count != 2 || len(list.Topics) != 2 { + t.Fatalf("forum topics count=%d len=%d topics=%+v, want count 2 len 2", list.Count, len(list.Topics), list.Topics) + } + assertForumTopicCounter(t, list.Topics, topicA.Topic.TopicID, 2, 1, 1) + assertForumTopicCounter(t, list.Topics, topicB.Topic.TopicID, 1, 0, 0) + + byID, err := channels.GetForumTopicsByID(ctx, member.ID, channelID, []int{topicA.Topic.TopicID, topicB.Topic.TopicID}) + if err != nil { + t.Fatalf("get forum topics by id: %v", err) + } + if byID.Count != 2 || len(byID.Topics) != 2 { + t.Fatalf("forum topics by id count=%d len=%d topics=%+v, want count 2 len 2", byID.Count, len(byID.Topics), byID.Topics) + } + assertForumTopicCounter(t, byID.Topics, topicA.Topic.TopicID, 2, 1, 1) + assertForumTopicCounter(t, byID.Topics, topicB.Topic.TopicID, 1, 0, 0) +} + +func assertForumTopicCounter(t *testing.T, topics []domain.ChannelForumTopic, topicID, unread, mentions, reactions int) { + t.Helper() + for _, topic := range topics { + if topic.TopicID != topicID { + continue + } + if topic.UnreadCount != unread || topic.UnreadMentionsCount != mentions || topic.UnreadReactionsCount != reactions { + t.Fatalf("topic %d counters = unread %d mentions %d reactions %d, want %d/%d/%d", + topicID, topic.UnreadCount, topic.UnreadMentionsCount, topic.UnreadReactionsCount, unread, mentions, reactions) + } + return + } + t.Fatalf("topic %d not found in %+v", topicID, topics) +} diff --git a/internal/store/postgres/channel_groupcall.go b/internal/store/postgres/channel_groupcall.go new file mode 100644 index 00000000..fc08b6e7 --- /dev/null +++ b/internal/store/postgres/channel_groupcall.go @@ -0,0 +1,130 @@ +package postgres + +import ( + "context" + "fmt" + + "telesrv/internal/domain" +) + +// SetActiveCall 写入/清除 channel 行上的活跃群通话关联。 +func (s *ChannelStore) SetActiveCall(ctx context.Context, channelID, callID, callAccessHash int64, notEmpty bool) (domain.Channel, error) { + if channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if callID == 0 { + callAccessHash = 0 + notEmpty = false + } + tag, err := s.db.Exec(ctx, ` +UPDATE channels +SET active_call_id = $2, active_call_access_hash = $3, active_call_not_empty = $4, updated_at = now() +WHERE id = $1 AND NOT deleted`, channelID, callID, callAccessHash, notEmpty) + if err != nil { + return domain.Channel{}, fmt.Errorf("set channel active call: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + return getChannelByID(ctx, s.db, channelID) +} + +// AppendCallServiceMessage 生成群通话服务消息(started/ended/invite,带频道 pts)。 +func (s *ChannelStore) AppendCallServiceMessage(ctx context.Context, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) { + return s.appendServiceMessage(ctx, "call", channelID, senderUserID, date, action) +} + +// AppendStarGiftAdminLog 记录频道 Star gift 到 Recent Actions,不插入 channel_messages。 +func (s *ChannelStore) AppendStarGiftAdminLog(ctx context.Context, channelID, senderUserID int64, savedID int64, date int, action domain.ChannelMessageAction) error { + if channelID == 0 || senderUserID == 0 || savedID <= 0 { + return domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return fmt.Errorf("append star gift admin log: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return fmt.Errorf("begin star gift admin log: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, err := getChannelByID(ctx, tx, channelID) + if err != nil { + return err + } + messageID := int(savedID) + if savedID > int64(domain.MaxMessageBoxID) { + messageID = domain.MaxMessageBoxID + } + action = channelServiceActionForMessage(channelID, messageID, action) + msg := domain.ChannelMessage{ + ChannelID: channelID, + ID: messageID, + SenderUserID: senderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + Date: date, + Post: channel.Broadcast, + Action: &action, + Pts: channel.Pts, + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: senderUserID, + Date: date, + Type: domain.ChannelAdminLogSendMessage, + Message: &msg, + }); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit star gift admin log: %w", err) + } + committed = true + return nil +} + +func (s *ChannelStore) appendServiceMessage(ctx context.Context, label string, channelID, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.SendChannelMessageResult, error) { + if channelID == 0 || senderUserID == 0 { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.SendChannelMessageResult{}, fmt.Errorf("append %s service message: db does not support transactions", label) + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("begin %s service message: %w", label, err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, err := getChannelByID(ctx, tx, channelID) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + msg, event, err := s.insertServiceMessage(ctx, tx, channel, senderUserID, date, action) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + if err := tx.Commit(ctx); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("commit %s service message: %w", label, err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, 0, channelID, 0) + return domain.SendChannelMessageResult{ + Channel: channel, + Message: msg, + Event: event, + Recipients: recipients, + }, nil +} diff --git a/internal/store/postgres/channel_helpers.go b/internal/store/postgres/channel_helpers.go new file mode 100644 index 00000000..c3a3369b --- /dev/null +++ b/internal/store/postgres/channel_helpers.go @@ -0,0 +1,876 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "sort" + "strings" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *ChannelStore) SaveChannelDefaultSendAs(ctx context.Context, req domain.SaveChannelDefaultSendAsRequest) (domain.ChannelView, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + var sendAsType sql.NullString + var sendAsID sql.NullInt64 + if req.SendAs != nil { + if req.SendAs.Type != domain.PeerTypeUser && req.SendAs.Type != domain.PeerTypeChannel { + return domain.ChannelView{}, domain.ErrChannelInvalid + } + sendAsType = sql.NullString{String: string(req.SendAs.Type), Valid: true} + sendAsID = sql.NullInt64{Int64: req.SendAs.ID, Valid: true} + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelView{}, err + } + topMessageID := channel.TopMessageID + if topMessageID <= member.AvailableMinID { + topMessageID = 0 + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, + default_send_as_peer_type, default_send_as_peer_id +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (user_id, channel_id) DO UPDATE SET + default_send_as_peer_type = EXCLUDED.default_send_as_peer_type, + default_send_as_peer_id = EXCLUDED.default_send_as_peer_id, + updated_at = now()`, + req.UserID, + req.ChannelID, + topMessageID, + channel.Date, + member.ReadInboxMaxID, + member.ReadOutboxMaxID, + sendAsType, + sendAsID, + ); err != nil { + return domain.ChannelView{}, fmt.Errorf("save channel default send as: %w", err) + } + // 连接池写后立即失效本进程缓存,保证下面的 getChannelDialog 读到刚写入的值 + // (dialog_light NOTIFY 仅异步失效其它实例,本实例的 read-your-write 必须同步兜住)。 + if s.dialogCacheActive(s.db) { + s.dialogCache.delete(req.UserID, req.ChannelID) + } + dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) + if err != nil { + return domain.ChannelView{}, err + } + return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog}, nil +} + +func (s *ChannelStore) DeleteChannel(ctx context.Context, req domain.DeleteChannelRequest) (domain.DeleteChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.DeleteChannelResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelResult{}, fmt.Errorf("delete channel: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelResult{}, fmt.Errorf("begin delete channel: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelResult{}, err + } + if member.Role != domain.ChannelRoleCreator { + return domain.DeleteChannelResult{}, domain.ErrChannelAdminRequired + } + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + if _, err := tx.Exec(ctx, `UPDATE channels SET deleted = true, username = NULL, updated_at = now() WHERE id = $1`, req.ChannelID); err != nil { + return domain.DeleteChannelResult{}, fmt.Errorf("mark channel deleted: %w", err) + } + if err := deletePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID); err != nil { + return domain.DeleteChannelResult{}, err + } + if err := markUserChannelMemberIndexDeletedTx(ctx, tx, req.ChannelID, true); err != nil { + return domain.DeleteChannelResult{}, err + } + channel.Deleted = true + channel.Username = "" + // 连带软删关联 monoforum(频道私信容器)。仅当 counterpart 本身是 monoforum 时才级联——这样删母广播 + // 频道会清掉其虚拟 mono,而(防御性地)绝不会因删 mono 反向把真实母频道也删掉。不级联会留下 + // monoforum=true 指向已删父频道的孤儿(客户端渲染崩 + DB 垃圾随删除累积)。 + var linkedMono *domain.Channel + if channel.LinkedMonoforumID != 0 { + mono, err := getChannelByID(ctx, tx, channel.LinkedMonoforumID) + switch { + case errors.Is(err, domain.ErrChannelInvalid): + // 关联 mono 已不存在/已删,无需级联。 + case err != nil: + return domain.DeleteChannelResult{}, fmt.Errorf("load linked monoforum: %w", err) + case mono.Monoforum: + if _, err := tx.Exec(ctx, `UPDATE channels SET deleted = true, username = NULL, updated_at = now() WHERE id = $1`, mono.ID); err != nil { + return domain.DeleteChannelResult{}, fmt.Errorf("mark linked monoforum deleted: %w", err) + } + if err := deletePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, mono.ID); err != nil { + return domain.DeleteChannelResult{}, err + } + if err := markUserChannelMemberIndexDeletedTx(ctx, tx, mono.ID, true); err != nil { + return domain.DeleteChannelResult{}, err + } + mono.Deleted = true + mono.Username = "" + linkedMono = &mono + } + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelResult{}, fmt.Errorf("commit delete channel: %w", err) + } + committed = true + return domain.DeleteChannelResult{Channel: channel, Recipients: recipients, LinkedMonoforum: linkedMono}, nil +} + +func (s *ChannelStore) SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error) { + if viewerUserID == 0 { + return domain.PublicChannelSearchResult{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxPublicChannelSearchLimit { + limit = domain.MaxPublicChannelSearchLimit + } + queryLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(query, "@"))) + if queryLower == "" { + return domain.PublicChannelSearchResult{}, nil + } + queryPrefix := escapeLike(queryLower) + "%" + queryLike := "%" + escapeLike(queryLower) + "%" + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+`, + EXISTS ( + SELECT 1 + FROM channel_members m + WHERE m.channel_id = c.id + AND m.user_id = $1 + AND m.status = 'active' + ) AS viewer_member +FROM channels c +WHERE NOT c.deleted + AND (c.broadcast OR c.megagroup) + AND COALESCE(c.username, '') <> '' + AND ( + lower(c.username) = $2 + OR lower(c.username) LIKE $3 ESCAPE '\' + OR lower(c.title) LIKE $3 ESCAPE '\' + OR lower(c.username) LIKE $4 ESCAPE '\' + OR lower(c.title) LIKE $4 ESCAPE '\' + ) +ORDER BY CASE + WHEN lower(c.username) = $2 THEN 0 + WHEN lower(c.username) LIKE $3 ESCAPE '\' THEN 1 + WHEN lower(c.username) LIKE $4 ESCAPE '\' THEN 2 + WHEN lower(c.title) LIKE $3 ESCAPE '\' THEN 3 + ELSE 4 + END, + viewer_member DESC, + c.participants_count DESC, + c.date DESC, + c.id DESC +LIMIT $5`, viewerUserID, queryLower, queryPrefix, queryLike, limit) + if err != nil { + return domain.PublicChannelSearchResult{}, fmt.Errorf("search public channels: %w", err) + } + defer rows.Close() + out := domain.PublicChannelSearchResult{ + MyResults: make([]domain.Channel, 0), + Results: make([]domain.Channel, 0, limit), + } + for rows.Next() { + ch, viewerMember, err := scanChannelWithViewerMember(rows) + if err != nil { + return domain.PublicChannelSearchResult{}, err + } + if viewerMember { + out.MyResults = append(out.MyResults, ch) + } else { + out.Results = append(out.Results, ch) + } + } + if err := rows.Err(); err != nil { + return domain.PublicChannelSearchResult{}, err + } + return out, nil +} + +func (s *ChannelStore) SearchPublicPosts(ctx context.Context, viewerUserID int64, req domain.ChannelSearchPostsRequest) (domain.ChannelHistory, error) { + query := strings.TrimSpace(req.Query) + hashtag := strings.TrimSpace(req.Hashtag) + if (query == "") == (hashtag == "") { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelSearchPostsLimit { + limit = domain.MaxChannelSearchPostsLimit + } + args := []any{} + where := `NOT deleted +AND body <> '' +AND EXISTS ( + SELECT 1 + FROM channels c + WHERE c.id = channel_messages.channel_id + AND NOT c.deleted + AND COALESCE(c.username, '') <> '' + )` + if query != "" { + args = append(args, "%"+escapeLike(query)+"%") + where += fmt.Sprintf(" AND body ILIKE $%d ESCAPE '\\'", len(args)) + } + if hashtag != "" { + args = append(args, "%#"+escapeLike(hashtag)+"%") + where += fmt.Sprintf(" AND body ILIKE $%d ESCAPE '\\'", len(args)) + } + switch { + case req.OffsetRate > 0 && req.OffsetChannelID > 0 && req.OffsetID > 0: + args = append(args, req.OffsetRate, req.OffsetChannelID, req.OffsetID) + n := len(args) + where += fmt.Sprintf(" AND (message_date < $%d OR (message_date = $%d AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))))", n-2, n-2, n-1, n-1, n) + case req.OffsetRate > 0: + args = append(args, req.OffsetRate) + where += fmt.Sprintf(" AND message_date < $%d", len(args)) + case req.OffsetChannelID > 0 && req.OffsetID > 0: + args = append(args, req.OffsetChannelID, req.OffsetID) + n := len(args) + where += fmt.Sprintf(" AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))", n-1, n-1, n) + case req.OffsetID > 0: + args = append(args, req.OffsetID) + where += fmt.Sprintf(" AND id < $%d", len(args)) + } + queryLimit := limit + 1 + args = append(args, queryLimit) + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY message_date DESC, channel_id DESC, id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelHistory{}, fmt.Errorf("search public channel posts: %w", err) + } + defer rows.Close() + out := domain.ChannelHistory{} + channelRefs := make(map[int64]struct{}) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Messages = append(out.Messages, msg) + channelRefs[msg.ChannelID] = struct{}{} + } + if err := rows.Err(); err != nil { + return domain.ChannelHistory{}, err + } + if len(out.Messages) > limit { + out.Messages = out.Messages[:limit] + out.Count = limit + 1 + channelRefs = make(map[int64]struct{}, len(out.Messages)) + for _, msg := range out.Messages { + channelRefs[msg.ChannelID] = struct{}{} + } + } else { + out.Count = len(out.Messages) + } + channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Channels = channels + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + return out, nil +} + +func (s *ChannelStore) ListActiveChannelIDsForUser(ctx context.Context, userID, afterChannelID int64, limit int) ([]int64, error) { + if userID == 0 || afterChannelID < 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { + limit = domain.MaxSynchronousChannelDialogFanout + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id +FROM user_channel_member_index +WHERE user_id = $1 + AND status = 'active' + AND NOT deleted + AND channel_id > $2 +ORDER BY channel_id +LIMIT $3`, userID, afterChannelID, limit) + if err != nil { + return nil, fmt.Errorf("list active channel ids for user: %w", err) + } + defer rows.Close() + out := make([]int64, 0, limit) + for rows.Next() { + var channelID int64 + if err := rows.Scan(&channelID); err != nil { + return nil, err + } + out = append(out, channelID) + } + return out, rows.Err() +} + +func (s *ChannelStore) ListDirtyActiveChannelsForUser(ctx context.Context, userID int64, sinceDate int, afterChannelID int64, limit int) ([]domain.DirtyChannel, error) { + if userID == 0 || sinceDate <= 0 || afterChannelID < 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { + limit = domain.MaxChannelDifferenceLimit + } + rows, err := s.db.Query(ctx, ` +SELECT i.channel_id, c.pts +FROM user_channel_member_index i +JOIN channels c ON c.id = i.channel_id AND NOT c.deleted +WHERE i.user_id = $1 + AND i.status = 'active' + AND NOT i.deleted + AND i.channel_id > $3 + AND EXISTS ( + SELECT 1 + FROM channel_update_events e + WHERE e.channel_id = i.channel_id + AND e.date > $2 + LIMIT 1 + ) +ORDER BY i.channel_id ASC +LIMIT $4`, userID, sinceDate, afterChannelID, limit) + if err != nil { + return nil, fmt.Errorf("list dirty active channels for user: %w", err) + } + defer rows.Close() + out := make([]domain.DirtyChannel, 0, limit) + for rows.Next() { + var item domain.DirtyChannel + if err := rows.Scan(&item.ChannelID, &item.Pts); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, bool, error) { + ch, member, err := s.getChannelForMember(ctx, db, viewerUserID, channelID) + if err == nil { + return ch, member, false, nil + } + if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.Channel{}, domain.ChannelMember{}, false, err + } + ch, err = s.channelByID(ctx, db, channelID) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, false, err + } + if member, _, ok, err := s.monoforumAdminPreview(ctx, db, viewerUserID, ch); err != nil { + return domain.Channel{}, domain.ChannelMember{}, false, err + } else if ok { + return ch, member, true, nil + } + if !publicPreviewableChannel(ch) { + return domain.Channel{}, domain.ChannelMember{}, false, domain.ErrChannelPrivate + } + member, err = s.getPublicPreviewMember(ctx, db, viewerUserID, ch) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, false, err + } + return ch, member, true, nil +} + +func getChannelByID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.Channel, error) { + ch, err := scanChannel(db.QueryRow(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = $1 AND NOT c.deleted`, channelID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Channel{}, domain.ErrChannelInvalid + } + return ch, err +} + +// channelByID 是 getChannelByID 的缓存读穿版本,仅在连接池句柄上消费缓存(事务内绕过)。 +// 命中即返回缓存的频道行;未命中查 PG 并回填。失效由 ReadModelChangeListener 实时驱动。 +func (s *ChannelStore) channelByID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (domain.Channel, error) { + if s.cacheActive(db) { + return s.rowCache.getOrLoad(ctx, channelID, func() (domain.Channel, error) { + return getChannelByID(ctx, db, channelID) + }) + } + return getChannelByID(ctx, db, channelID) +} + +func listChannelsByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.Channel, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := db.Query(ctx, `SELECT `+channelColumns+` FROM channels c WHERE c.id = ANY($1::bigint[]) AND NOT c.deleted ORDER BY c.id ASC`, ids) + if err != nil { + return nil, fmt.Errorf("list channels by ids: %w", err) + } + defer rows.Close() + out := make([]domain.Channel, 0, len(ids)) + for rows.Next() { + ch, err := scanChannel(rows) + if err != nil { + return nil, err + } + out = append(out, ch) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func listChannelsByIDsInOrder(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.Channel, error) { + channels, err := listChannelsByIDs(ctx, db, ids) + if err != nil { + return nil, err + } + byID := make(map[int64]domain.Channel, len(channels)) + for _, channel := range channels { + byID[channel.ID] = channel + } + out := make([]domain.Channel, 0, len(ids)) + for _, id := range ids { + if channel, ok := byID[id]; ok { + out = append(out, channel) + } + } + return out, nil +} + +func listUsersByIDs(ctx context.Context, db sqlcgen.DBTX, ids []int64) ([]domain.User, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := db.Query(ctx, ` +SELECT id, access_hash, phone, first_name, last_name, username, country_code, verified, support, is_bot, bot_info_version, + COALESCE(EXTRACT(EPOCH FROM premium_expires_at), 0)::bigint AS premium_until, + emoji_status_document_id, + emoji_status_until +FROM users +WHERE id = ANY($1::bigint[]) +ORDER BY id ASC`, ids) + if err != nil { + return nil, fmt.Errorf("list users by ids: %w", err) + } + defer rows.Close() + out := make([]domain.User, 0, len(ids)) + for rows.Next() { + var u domain.User + var premiumUntil, emojiStatusUntil int64 + if err := rows.Scan(&u.ID, &u.AccessHash, &u.Phone, &u.FirstName, &u.LastName, &u.Username, &u.CountryCode, &u.Verified, &u.Support, &u.Bot, &u.BotInfoVersion, &premiumUntil, &u.EmojiStatusDocumentID, &emojiStatusUntil); err != nil { + return nil, err + } + u.PremiumUntil = int(premiumUntil) + u.EmojiStatusUntil = int(emojiStatusUntil) + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +type channelReplyStatKey struct { + channelID int64 + rootID int +} + +func (s *ChannelStore) topicWithViewerCounters(ctx context.Context, viewerUserID, channelID int64, topic domain.ChannelForumTopic, readMaxID, availableMinID int) domain.ChannelForumTopic { + topic.UnreadCount = s.channelThreadUnreadCount(ctx, channelID, topic.TopicID, viewerUserID, readMaxID) + topic.UnreadMentionsCount = s.countChannelUnreadMentionsForTop(ctx, viewerUserID, channelID, topic.TopicID) + topic.UnreadReactionsCount = s.countChannelUnreadReactionsForTop(ctx, viewerUserID, channelID, topic.TopicID, availableMinID) + return topic +} + +func (s *ChannelStore) populateForumTopicViewerCounters(ctx context.Context, viewerUserID, channelID int64, topics []domain.ChannelForumTopic, availableMinID int) error { + if len(topics) == 0 || viewerUserID == 0 || channelID == 0 { + return nil + } + roots := make([]int32, 0, len(topics)) + seen := make(map[int]struct{}, len(topics)) + indexes := make(map[int][]int, len(topics)) + for i := range topics { + topics[i].UnreadCount = 0 + topics[i].UnreadMentionsCount = 0 + topics[i].UnreadReactionsCount = 0 + rootID := topics[i].TopicID + indexes[rootID] = append(indexes[rootID], i) + if rootID <= 0 { + continue + } + if _, ok := seen[rootID]; ok { + continue + } + seen[rootID] = struct{}{} + roots = append(roots, int32(rootID)) + } + if len(roots) == 0 { + return nil + } + // per-topic 已读水位:每个话题用各自的 read_inbox_max_id 现算未读,并下发真实已读位(消除死列), + // 消除频道级单一水位串扰。 + waters, err := s.channelTopicReadBatch(ctx, channelID, viewerUserID, roots, availableMinID) + if err != nil { + return err + } + inbox := make(map[int]int, len(waters)) + for i := range topics { + w := waters[topics[i].TopicID] + inbox[topics[i].TopicID] = w.Inbox + topics[i].ReadInboxMaxID = w.Inbox + topics[i].ReadOutboxMaxID = w.Outbox + } + if err := s.populateForumTopicUnreadCounts(ctx, channelID, roots, viewerUserID, inbox, indexes, topics); err != nil { + return err + } + if err := s.populateForumTopicUnreadMentionCounts(ctx, viewerUserID, channelID, roots, indexes, topics); err != nil { + return err + } + if err := s.populateForumTopicUnreadReactionCounts(ctx, viewerUserID, channelID, roots, availableMinID, indexes, topics); err != nil { + return err + } + return nil +} + +func (s *ChannelStore) populateForumTopicUnreadCounts(ctx context.Context, channelID int64, roots []int32, viewerUserID int64, waters map[int]int, indexes map[int][]int, topics []domain.ChannelForumTopic) error { + // 每个 root 配对各自的 per-topic 已读水位,unnest 配对后按 topic 各自门槛 COUNT。 + readMaxes := make([]int32, len(roots)) + for i, r := range roots { + readMaxes[i] = int32(waters[int(r)]) + } + rows, err := s.db.Query(ctx, ` +SELECT cm.reply_to_top_id, COUNT(*)::int +FROM channel_messages cm +JOIN unnest($2::int[], $3::int[]) AS w(topic_id, read_max) ON cm.reply_to_top_id = w.topic_id +WHERE cm.channel_id = $1 + AND cm.id > w.read_max + AND cm.sender_user_id <> $4 + AND NOT cm.deleted +GROUP BY cm.reply_to_top_id`, channelID, roots, readMaxes, viewerUserID) + if err != nil { + return fmt.Errorf("count forum topic unread messages: %w", err) + } + defer rows.Close() + for rows.Next() { + var rootID, count int + if err := rows.Scan(&rootID, &count); err != nil { + return err + } + for _, idx := range indexes[rootID] { + topics[idx].UnreadCount = count + } + } + if err := rows.Err(); err != nil { + return err + } + return nil +} + +func (s *ChannelStore) populateForumTopicUnreadMentionCounts(ctx context.Context, userID, channelID int64, roots []int32, indexes map[int][]int, topics []domain.ChannelForumTopic) error { + rows, err := s.db.Query(ctx, ` +SELECT top_message_id, COUNT(*)::int +FROM channel_unread_mentions +WHERE user_id = $1 + AND channel_id = $2 + AND top_message_id = ANY($3::int[]) + AND unread +GROUP BY top_message_id`, userID, channelID, roots) + if err != nil { + return fmt.Errorf("count forum topic unread mentions: %w", err) + } + defer rows.Close() + for rows.Next() { + var rootID, count int + if err := rows.Scan(&rootID, &count); err != nil { + return err + } + for _, idx := range indexes[rootID] { + topics[idx].UnreadMentionsCount = count + } + } + if err := rows.Err(); err != nil { + return err + } + return nil +} + +func (s *ChannelStore) populateForumTopicUnreadReactionCounts(ctx context.Context, userID, channelID int64, roots []int32, availableMinID int, indexes map[int][]int, topics []domain.ChannelForumTopic) error { + rows, err := s.db.Query(ctx, ` +WITH reaction_messages AS ( + SELECT + r.message_id, + CASE + WHEN cm.id = ANY($3::int[]) THEN cm.id + ELSE COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) + END AS topic_id + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > $4 + AND NOT cm.deleted + AND ( + cm.id = ANY($3::int[]) + OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = ANY($3::int[]) + ) +) +SELECT topic_id, COUNT(DISTINCT message_id)::int +FROM reaction_messages +WHERE topic_id <> 0 +GROUP BY topic_id`, userID, channelID, roots, availableMinID) + if err != nil { + return fmt.Errorf("count forum topic unread reactions: %w", err) + } + defer rows.Close() + for rows.Next() { + var rootID, count int + if err := rows.Scan(&rootID, &count); err != nil { + return err + } + for _, idx := range indexes[rootID] { + topics[idx].UnreadReactionsCount = count + } + } + if err := rows.Err(); err != nil { + return err + } + return nil +} + +func (s *ChannelStore) resolveChannelReply(ctx context.Context, db sqlcgen.DBTX, req domain.SendChannelMessageRequest, member domain.ChannelMember, channel domain.Channel) (*domain.MessageReply, error) { + if req.ReplyTo == nil { + return nil, nil + } + if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { + return nil, err + } + peer := req.ReplyTo.Peer + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID} + if peer.ID == 0 { + peer = channelPeer + } + if peer != channelPeer { + return nil, domain.ErrReplyMessageIDInvalid + } + if req.ReplyTo.MessageID == 0 { + if req.ReplyTo.TopMessageID <= 0 || !channel.Forum { + return nil, domain.ErrReplyMessageIDInvalid + } + topic, err := s.getForumTopic(ctx, db, req.ChannelID, req.ReplyTo.TopMessageID) + if err != nil { + return nil, domain.ErrReplyMessageIDInvalid + } + if topic.Hidden { + return nil, domain.ErrReplyMessageIDInvalid + } + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + return nil, domain.ErrChannelWriteForbidden + } + reply := cloneMessageReply(req.ReplyTo) + reply.MessageID = 0 + reply.Peer = channelPeer + reply.TopMessageID = topic.TopicID + reply.ForumTopic = true + return reply, nil + } + target, err := s.getChannelMessage(ctx, db, req.ChannelID, req.ReplyTo.MessageID) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) || errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrReplyMessageIDInvalid + } + return nil, err + } + if target.Deleted || target.ID <= member.AvailableMinID { + return nil, domain.ErrReplyMessageIDInvalid + } + reply := cloneMessageReply(req.ReplyTo) + reply.MessageID = target.ID + reply.Peer = channelPeer + reply.TopMessageID = target.ID + if target.ReplyTo != nil && target.ReplyTo.TopMessageID > 0 { + reply.TopMessageID = target.ReplyTo.TopMessageID + } + if req.ReplyTo.TopMessageID > 0 && req.ReplyTo.TopMessageID != reply.TopMessageID { + return nil, domain.ErrReplyMessageIDInvalid + } + if channel.Forum && reply.TopMessageID > 0 { + if topic, err := s.getForumTopic(ctx, db, req.ChannelID, reply.TopMessageID); err == nil && !topic.Hidden { + if topic.Closed && !canManageForumTopic(channel, member, topic, req.UserID) { + return nil, domain.ErrChannelWriteForbidden + } + reply.ForumTopic = true + } else if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) { + return nil, err + } + } + return reply, nil +} + +func visibleChannelTopAfter(ctx context.Context, db sqlcgen.DBTX, channelID int64, availableMinID int, fallbackDate int) (int, int, error) { + var id, date int + err := db.QueryRow(ctx, ` +SELECT id, message_date +FROM channel_messages +WHERE channel_id = $1 AND id > $2 AND NOT deleted +ORDER BY id DESC +LIMIT 1`, channelID, availableMinID).Scan(&id, &date) + if errors.Is(err, pgx.ErrNoRows) { + return 0, fallbackDate, nil + } + if err != nil { + return 0, 0, fmt.Errorf("select visible channel top: %w", err) + } + return id, date, nil +} + +func discussionGroupUpdateResult(changed map[int64]domain.Channel) domain.DiscussionGroupUpdateResult { + ids := make([]int64, 0, len(changed)) + for id := range changed { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { + return ids[i] < ids[j] + }) + out := domain.DiscussionGroupUpdateResult{Channels: make([]domain.Channel, 0, len(ids))} + for _, id := range ids { + out.Channels = append(out.Channels, changed[id]) + } + return out +} + +func canChangeChannelInfo(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo) +} + +func boolPtr(v bool) *bool { + return &v +} + +func channelInitialAvailableMinID(channel domain.Channel) int { + if channel.PreHistoryHidden { + return channel.TopMessageID + } + return 0 +} + +func adminRightsSubset(want, have domain.ChannelAdminRights) bool { + return (!want.ChangeInfo || have.ChangeInfo) && + (!want.PostMessages || have.PostMessages) && + (!want.EditMessages || have.EditMessages) && + (!want.DeleteMessages || have.DeleteMessages) && + (!want.PostStories || have.PostStories) && + (!want.EditStories || have.EditStories) && + (!want.DeleteStories || have.DeleteStories) && + (!want.BanUsers || have.BanUsers) && + (!want.InviteUsers || have.InviteUsers) && + (!want.PinMessages || have.PinMessages) && + (!want.AddAdmins || have.AddAdmins) && + (!want.ManageCall || have.ManageCall) && + (!want.Anonymous || have.Anonymous) && + (!want.ManageRanks || have.ManageRanks) +} + +// checkEditMemberRank validates a rank-only (member tag) edit: creator edits +// anyone but no one else edits the creator; admins always edit their own tag, +// and with ManageRanks edit plain members plus admins they promoted; plain +// members edit only their own tag and only while neither the channel default +// nor their personal banned rights set edit_rank. Member tags exist only in +// megagroups: broadcast participants must keep an empty rank so the admins +// participant filter stays a pure admin list there. +func checkEditMemberRank(channel domain.Channel, actor, target domain.ChannelMember) error { + if !channel.Megagroup { + return domain.ErrMegagroupIDInvalid + } + if actor.UserID == target.UserID { + if actor.Role == domain.ChannelRoleCreator || actor.Role == domain.ChannelRoleAdmin { + return nil + } + if channel.DefaultBannedRights.EditRank || actor.BannedRights.EditRank { + return domain.ErrChannelRightForbidden + } + return nil + } + if target.Role == domain.ChannelRoleCreator { + return domain.ErrChannelUserCreator + } + if actor.Role == domain.ChannelRoleCreator { + return nil + } + if actor.Role != domain.ChannelRoleAdmin || !actor.AdminRights.ManageRanks { + return domain.ErrChannelAdminRequired + } + if target.Role == domain.ChannelRoleAdmin && target.InviterUserID != actor.UserID { + return domain.ErrChannelRightForbidden + } + return nil +} + +func adminLogBanType(previous, next domain.ChannelMember) domain.ChannelAdminLogEventType { + if next.Status == domain.ChannelMemberKicked || next.BannedRights.ViewMessages { + return domain.ChannelAdminLogParticipantKick + } + if previous.Status == domain.ChannelMemberKicked || previous.BannedRights.ViewMessages { + return domain.ChannelAdminLogParticipantUnkick + } + if !zeroChannelBannedRights(next.BannedRights) { + return domain.ChannelAdminLogParticipantBan + } + return domain.ChannelAdminLogParticipantUnban +} + +func adminLogSearchText(event domain.ChannelAdminLogEvent) string { + parts := []string{ + event.Query, + event.PrevString, + event.NewString, + } + for _, msg := range []*domain.ChannelMessage{event.Message, event.PrevMessage, event.NewMessage} { + if msg != nil { + parts = append(parts, msg.Body) + } + } + return strings.ToLower(strings.TrimSpace(strings.Join(parts, " "))) +} + +func adminLogLikePattern(query string) string { + query = strings.ReplaceAll(query, `\`, `\\`) + query = strings.ReplaceAll(query, `%`, `\%`) + query = strings.ReplaceAll(query, `_`, `\_`) + return "%" + query + "%" +} + +func (a pgChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) { + current, err := a.CurrentChannelID(ctx) + if err != nil { + return 0, err + } + return current + 1, nil +} + +func (a pgChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) { + var id int64 + err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channels`).Scan(&id) + return id, err +} diff --git a/internal/store/postgres/channel_history_ttl.go b/internal/store/postgres/channel_history_ttl.go new file mode 100644 index 00000000..519b7aed --- /dev/null +++ b/internal/store/postgres/channel_history_ttl.go @@ -0,0 +1,84 @@ +package postgres + +import ( + "context" + "fmt" + + "telesrv/internal/domain" +) + +func (s *ChannelStore) SetChannelHistoryTTL(ctx context.Context, userID, channelID int64, period int, date int) (domain.Channel, []int64, error) { + if userID == 0 || channelID == 0 || period < 0 { + return domain.Channel{}, nil, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, userID, channelID) + if err != nil { + return domain.Channel{}, nil, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, nil, domain.ErrChannelAdminRequired + } + if _, err := s.db.Exec(ctx, ` +UPDATE channels +SET ttl_period = $2, + updated_at = now() +WHERE id = $1`, channelID, period); err != nil { + return domain.Channel{}, nil, fmt.Errorf("set channel history ttl: %w", err) + } + channel.TTLPeriod = period + recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0) + return channel, recipients, nil +} + +func (s *ChannelStore) ClaimExpiredChannelMessages(ctx context.Context, now, limit int) ([]domain.DeleteChannelMessagesRequest, error) { + if now <= 0 || limit <= 0 { + return nil, nil + } + if limit > domain.MaxDeleteHistoryBatch { + limit = domain.MaxDeleteHistoryBatch + } + rows, err := s.db.Query(ctx, ` +SELECT c.creator_user_id, m.channel_id, m.id +FROM channel_messages m +JOIN channels c ON c.id = m.channel_id +WHERE m.expires_at > 0 + AND m.expires_at <= $1 + AND NOT m.deleted + AND NOT c.deleted +ORDER BY m.expires_at ASC, m.channel_id ASC, m.id ASC +LIMIT $2`, now, limit) + if err != nil { + return nil, fmt.Errorf("claim expired channel messages: %w", err) + } + defer rows.Close() + type key struct { + userID int64 + channelID int64 + } + index := make(map[key]int) + out := make([]domain.DeleteChannelMessagesRequest, 0) + for rows.Next() { + var userID, channelID int64 + var id int + if err := rows.Scan(&userID, &channelID, &id); err != nil { + return nil, fmt.Errorf("scan expired channel message: %w", err) + } + k := key{userID: userID, channelID: channelID} + pos, ok := index[k] + if !ok { + pos = len(out) + index[k] = pos + out = append(out, domain.DeleteChannelMessagesRequest{ + UserID: userID, + ChannelID: channelID, + Date: now, + IDs: make([]int, 0, 8), + }) + } + out[pos].IDs = append(out[pos].IDs, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate expired channel messages: %w", err) + } + return out, nil +} diff --git a/internal/store/postgres/channel_id_recovery_integration_test.go b/internal/store/postgres/channel_id_recovery_integration_test.go new file mode 100644 index 00000000..4a612037 --- /dev/null +++ b/internal/store/postgres/channel_id_recovery_integration_test.go @@ -0,0 +1,88 @@ +package postgres + +import ( + "context" + "sync/atomic" + "testing" + + "telesrv/internal/domain" +) + +// staleChannelIDAllocator 模拟落后的 channel id 计数器:从 start 起按 1 +// 步进,必然撞上已存在的 channel 主键。 +type staleChannelIDAllocator struct { + next atomic.Int64 + atLeast atomic.Int64 // 记录 NextChannelIDAtLeast 被调用时的 floor +} + +func (a *staleChannelIDAllocator) NextChannelID(_ context.Context) (int64, error) { + return a.next.Add(1), nil +} + +func (a *staleChannelIDAllocator) CurrentChannelID(_ context.Context) (int64, error) { + return a.next.Load(), nil +} + +func (a *staleChannelIDAllocator) NextChannelIDAtLeast(_ context.Context, floor int64) (int64, error) { + a.atLeast.Store(floor) + for { + cur := a.next.Load() + if cur < floor { + if !a.next.CompareAndSwap(cur, floor) { + continue + } + } + return a.next.Add(1), nil + } +} + +// TestChannelStoreCreateChannelRecoversFromStaleIDCounter 验证计数器落后 +// (Redis 快照回退 / 测试 fallback 分配器绕过 Redis 直写同一库)时, +// CreateChannel 经预检自愈拿到空闲 id,而非撞主键 500。 +func TestChannelStoreCreateChannelRecoversFromStaleIDCounter(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + creator, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1673" + suffix + "01", FirstName: "StaleIDCreator"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", creator.ID) + }) + + // 先用默认 PG fallback 分配器造一个真实 channel(占住 max id)。 + seedStore := NewChannelStore(pool) + seed, err := seedStore.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "StaleSeed " + suffix, + Megagroup: true, + Date: 1700000700, + }) + if err != nil { + t.Fatalf("seed channel: %v", err) + } + + // 落后计数器从 seed id 之前很远处开始(模拟回退),首次分配必撞。 + stale := &staleChannelIDAllocator{} + stale.next.Store(seed.Channel.ID - 3) + staleStore := NewChannelStore(pool, WithChannelAllocators(stale, nil)) + // msgIDs 传 nil 会被 fallback 覆盖;channel pts 由 PG 事务直接维护。 + created, err := staleStore.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "StaleRecovered " + suffix, + Megagroup: true, + Date: 1700000701, + }) + if err != nil { + t.Fatalf("create channel with stale counter: %v", err) + } + if created.Channel.ID <= seed.Channel.ID { + t.Fatalf("recovered channel id = %d, want > seed id %d", created.Channel.ID, seed.Channel.ID) + } + if stale.atLeast.Load() < seed.Channel.ID { + t.Fatalf("NextChannelIDAtLeast floor = %d, want >= seed id %d(应按表内最大 id 对账)", stale.atLeast.Load(), seed.Channel.ID) + } +} diff --git a/internal/store/postgres/channel_integration_test.go b/internal/store/postgres/channel_integration_test.go deleted file mode 100644 index 90388029..00000000 --- a/internal/store/postgres/channel_integration_test.go +++ /dev/null @@ -1,3198 +0,0 @@ -package postgres - -import ( - "context" - "errors" - "fmt" - "reflect" - "sync" - "testing" - "time" - - "telesrv/internal/domain" -) - -func TestChannelStoreSendMessageFansOutDialogRows(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 31, - Phone: "+1777" + suffix + "01", - FirstName: "ChannelOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{ - AccessHash: 32, - Phone: "+1777" + suffix + "02", - FirstName: "ChannelFriend", - }) - if err != nil { - t.Fatalf("create friend: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Dialog Top " + suffix, - Megagroup: true, - MemberUserIDs: []int64{friend.ID}, - Date: 1700000300, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 901, - Message: "first visible channel text", - Date: 1700000301, - }) - if err != nil { - t.Fatalf("send channel message: %v", err) - } - - var friendTop, friendReadInbox, friendUnread int - if err := pool.QueryRow(ctx, ` -SELECT top_message_id, read_inbox_max_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, friend.ID).Scan(&friendTop, &friendReadInbox, &friendUnread); err != nil { - t.Fatalf("read friend dialog row after send: %v", err) - } - if friendTop != sent.Message.ID || friendReadInbox != 0 || friendUnread != 2 { - t.Fatalf("friend dialog row top=%d read=%d unread=%d, want top %d read 0 unread 2", friendTop, friendReadInbox, friendUnread, sent.Message.ID) - } - - var ownerTop, ownerReadInbox, ownerReadOutbox, ownerUnread int - if err := pool.QueryRow(ctx, ` -SELECT top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerTop, &ownerReadInbox, &ownerReadOutbox, &ownerUnread); err != nil { - t.Fatalf("read owner dialog row after send: %v", err) - } - if ownerTop != sent.Message.ID || ownerReadInbox != sent.Message.ID || ownerReadOutbox != sent.Message.ID || ownerUnread != 0 { - t.Fatalf("owner dialog row top=%d read_in=%d read_out=%d unread=%d, want sent/read/unread 0 for %d", ownerTop, ownerReadInbox, ownerReadOutbox, ownerUnread, sent.Message.ID) - } - - var ownerMemberReadInbox, ownerMemberReadOutbox int - if err := pool.QueryRow(ctx, ` -SELECT read_inbox_max_id, read_outbox_max_id -FROM channel_members -WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberReadInbox, &ownerMemberReadOutbox); err != nil { - t.Fatalf("read owner member row after send: %v", err) - } - if ownerMemberReadInbox != sent.Message.ID || ownerMemberReadOutbox != 0 { - t.Fatalf("owner member read_in=%d read_out=%d, want read_in %d and read_out unchanged before peer read", ownerMemberReadInbox, ownerMemberReadOutbox, sent.Message.ID) - } - - dialogs, err := channels.ListChannelDialogs(ctx, friend.ID, domain.DialogFilter{Limit: 10}) - if err != nil { - t.Fatalf("list friend dialogs: %v", err) - } - if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].TopMessage != sent.Message.ID { - t.Fatalf("friend dialogs = %+v, want top message %d", dialogs.Dialogs, sent.Message.ID) - } - if len(dialogs.Messages) != 1 || dialogs.Messages[0].Body != "first visible channel text" { - t.Fatalf("friend dialog messages = %+v, want latest channel text", dialogs.Messages) - } - if dialogs.Dialogs[0].UnreadCount != 2 { - t.Fatalf("friend unread = %d, want create service + latest text", dialogs.Dialogs[0].UnreadCount) - } - - read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ - UserID: friend.ID, - ChannelID: channelID, - MaxID: sent.Message.ID, - Date: 1700000302, - }) - if err != nil { - t.Fatalf("read history: %v", err) - } - if read.Dialog.UnreadCount != 0 || read.Dialog.ReadInboxMaxID != sent.Message.ID { - t.Fatalf("read dialog = %+v, want fully read through latest", read.Dialog) - } - if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != owner.ID || read.OutboxUpdates[0].MaxID != sent.Message.ID { - t.Fatalf("read outbox updates = %+v, want owner max id %d", read.OutboxUpdates, sent.Message.ID) - } - ownerView, err := channels.GetChannel(ctx, owner.ID, channelID) - if err != nil { - t.Fatalf("get owner channel after read: %v", err) - } - if ownerView.Dialog.ReadOutboxMaxID != sent.Message.ID { - t.Fatalf("owner dialog read_outbox = %d, want %d", ownerView.Dialog.ReadOutboxMaxID, sent.Message.ID) - } - if changed, err := channels.SetChannelDialogPinned(ctx, owner.ID, channelID, true); err != nil || !changed { - t.Fatalf("set owner channel pinned = changed %v err %v, want changed", changed, err) - } - if err := channels.ReorderChannelPinnedDialogs(ctx, owner.ID, []domain.Peer{ - {Type: domain.PeerTypeChannel, ID: channelID}, - }, true); err != nil { - t.Fatalf("reorder owner channel pinned: %v", err) - } - if changed, err := channels.SetChannelDialogUnreadMark(ctx, owner.ID, channelID, true); err != nil || !changed { - t.Fatalf("set owner channel unread mark = changed %v err %v, want changed", changed, err) - } - if err := channels.EditChannelPeerFolders(ctx, owner.ID, []domain.FolderPeerUpdate{ - {Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, FolderID: domain.DialogArchiveFolderID}, - }); err != nil { - t.Fatalf("edit owner channel folder: %v", err) - } - ownerDialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) - if err != nil { - t.Fatalf("get owner channel dialogs after settings: %v", err) - } - if len(ownerDialogs.Dialogs) != 1 || !ownerDialogs.Dialogs[0].Pinned || ownerDialogs.Dialogs[0].PinnedOrder != 1 || !ownerDialogs.Dialogs[0].UnreadMark || ownerDialogs.Dialogs[0].FolderID != domain.DialogArchiveFolderID { - t.Fatalf("owner channel dialog settings = %+v, want pinned/unread/archive", ownerDialogs.Dialogs) - } - unreadMarks, err := channels.ListChannelUnreadMarked(ctx, owner.ID) - if err != nil { - t.Fatalf("list owner channel unread marks: %v", err) - } - if len(unreadMarks) != 1 || unreadMarks[0].ID != channelID || unreadMarks[0].Type != domain.PeerTypeChannel { - t.Fatalf("channel unread marks = %+v, want channel", unreadMarks) - } - - readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ - UserID: owner.ID, - ChannelID: channelID, - MessageID: sent.Message.ID, - Limit: domain.MaxChannelReadParticipants, - Date: 1700000303, - }) - if err != nil { - t.Fatalf("list message read participants: %v", err) - } - if len(readers.Participants) != 1 || readers.Participants[0].UserID != friend.ID || readers.Participants[0].Date != 1700000302 { - t.Fatalf("read participants = %+v, want friend read date", readers.Participants) - } - - cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ - UserID: friend.ID, - ChannelID: channelID, - MaxID: sent.Message.ID, - Date: 1700000302, - }) - if err != nil { - t.Fatalf("local clear history: %v", err) - } - if cleared.AvailableMinID != sent.Message.ID { - t.Fatalf("local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID) - } - staleClear, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ - UserID: friend.ID, - ChannelID: channelID, - MaxID: created.Message.ID, - Date: 1700000303, - }) - if err != nil { - t.Fatalf("stale local clear history: %v", err) - } - if staleClear.AvailableMinID != sent.Message.ID { - t.Fatalf("stale local clear available_min_id = %d, want monotonic %d", staleClear.AvailableMinID, sent.Message.ID) - } - afterClear, err := channels.GetChannel(ctx, friend.ID, channelID) - if err != nil { - t.Fatalf("get channel after clear: %v", err) - } - if afterClear.Dialog.TopMessageID != 0 { - t.Fatalf("dialog after clear = %+v, want no visible top", afterClear.Dialog) - } - - next, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 902, - Message: "after local clear", - Date: 1700000304, - }) - if err != nil { - t.Fatalf("send after clear: %v", err) - } - afterNext, err := channels.GetChannel(ctx, friend.ID, channelID) - if err != nil { - t.Fatalf("get channel after next: %v", err) - } - if afterNext.Dialog.TopMessageID != next.Message.ID || afterNext.Dialog.UnreadCount != 1 { - t.Fatalf("dialog after next = %+v, want top %d unread 1", afterNext.Dialog, next.Message.ID) - } -} - -func TestChannelStoreReadOutboxDoesNotRegressSenderDialogUnread(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 35, - Phone: "+1777" + suffix + "17", - FirstName: "ReadOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - member, err := users.Create(ctx, domain.User{ - AccessHash: 36, - Phone: "+1777" + suffix + "18", - FirstName: "ReadMember", - }) - if err != nil { - t.Fatalf("create member: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Read Outbox " + suffix, - Megagroup: true, - MemberUserIDs: []int64{member.ID}, - Date: 1700000340, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - ownerMsg, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 9341, - Message: "owner message before member reply", - Date: 1700000341, - }) - if err != nil { - t.Fatalf("send owner message: %v", err) - } - if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ - UserID: member.ID, - ChannelID: channelID, - MaxID: ownerMsg.Message.ID, - Date: 1700000342, - }); err != nil { - t.Fatalf("member read owner message: %v", err) - } - memberMsg, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: member.ID, - ChannelID: channelID, - RandomID: 9342, - Message: "member reply should stay read for sender", - Date: 1700000343, - }) - if err != nil { - t.Fatalf("send member message: %v", err) - } - - var storedReadInbox, storedUnread int - if err := pool.QueryRow(ctx, ` -SELECT read_inbox_max_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadInbox, &storedUnread); err != nil { - t.Fatalf("read member dialog after self send: %v", err) - } - if storedReadInbox != memberMsg.Message.ID || storedUnread != 0 { - t.Fatalf("member dialog after self send read=%d unread=%d, want read %d unread 0", storedReadInbox, storedUnread, memberMsg.Message.ID) - } - - read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ - UserID: owner.ID, - ChannelID: channelID, - MaxID: memberMsg.Message.ID, - Date: 1700000344, - }) - if err != nil { - t.Fatalf("owner read member message: %v", err) - } - if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != member.ID || read.OutboxUpdates[0].MaxID != memberMsg.Message.ID { - t.Fatalf("read outbox updates = %+v, want member max id %d", read.OutboxUpdates, memberMsg.Message.ID) - } - if err := pool.QueryRow(ctx, ` -SELECT read_inbox_max_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadInbox, &storedUnread); err != nil { - t.Fatalf("read member dialog after owner read: %v", err) - } - if storedReadInbox != memberMsg.Message.ID || storedUnread != 0 { - t.Fatalf("member dialog after owner read read=%d unread=%d, want read %d unread 0", storedReadInbox, storedUnread, memberMsg.Message.ID) - } -} - -func TestChannelStoreChannelUnreadExcludesOwnOutgoing(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 39, - Phone: "+1777" + suffix + "19", - FirstName: "OwnUnreadOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Own Unread " + suffix, - Megagroup: true, - Date: 1700000350, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 9351, - Message: "own outgoing should not be unread", - Date: 1700000351, - }) - if err != nil { - t.Fatalf("send owner message: %v", err) - } - readBeforeOwnMessage := sent.Message.ID - 1 - if _, err := pool.Exec(ctx, ` -UPDATE channel_members -SET read_inbox_max_id = $3, unread_mark = false -WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID, readBeforeOwnMessage); err != nil { - t.Fatalf("regress owner member read watermark: %v", err) - } - if _, err := pool.Exec(ctx, ` -UPDATE channel_dialogs -SET read_inbox_max_id = $3, unread_count = 0, unread_mark = false -WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID, readBeforeOwnMessage); err != nil { - t.Fatalf("regress owner dialog unread: %v", err) - } - - dialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) - if err != nil { - t.Fatalf("get owner channel dialogs: %v", err) - } - if len(dialogs.Dialogs) != 1 { - t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs) - } - if dialogs.Dialogs[0].UnreadCount != 0 { - t.Fatalf("owner dialog unread = %d, want own outgoing excluded", dialogs.Dialogs[0].UnreadCount) - } - unreadOnly, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ - Folder: &domain.DialogFolder{ExcludeRead: true, Groups: true}, - Limit: 10, - }) - if err != nil { - t.Fatalf("list unread-only channel dialogs: %v", err) - } - for _, dialog := range unreadOnly.Dialogs { - if dialog.Peer.ID == channelID { - t.Fatalf("unread-only dialogs include own-outgoing-only channel: %+v", unreadOnly.Dialogs) - } - } - if _, err := pool.Exec(ctx, ` -UPDATE channel_dialogs -SET unread_count = 99 -WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID); err != nil { - t.Fatalf("corrupt owner dialog unread before read repair: %v", err) - } - - read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ - UserID: owner.ID, - ChannelID: channelID, - MaxID: sent.Message.ID, - Date: 1700000352, - }) - if err != nil { - t.Fatalf("read owner channel history: %v", err) - } - if read.StillUnreadCount != 0 || read.Dialog.UnreadCount != 0 { - t.Fatalf("read result = %+v, want no unread own outgoing messages", read) - } - var storedUnread int - if err := pool.QueryRow(ctx, ` -SELECT unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&storedUnread); err != nil { - t.Fatalf("read stored owner unread: %v", err) - } - if storedUnread != 0 { - t.Fatalf("stored owner unread = %d, want repaired to 0", storedUnread) - } -} - -func TestChannelStoreConcurrentSendAndReadHistoryDoNotSurfaceDeadlock(t *testing.T) { - pool := testPool(t) - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 37, - Phone: "+1777" + suffix + "21", - FirstName: "ConcurrentOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - member, err := users.Create(ctx, domain.User{ - AccessHash: 38, - Phone: "+1777" + suffix + "22", - FirstName: "ConcurrentMember", - }) - if err != nil { - t.Fatalf("create member: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(context.Background(), "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(context.Background(), "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Send Read Race " + suffix, - Megagroup: true, - MemberUserIDs: []int64{member.ID}, - Date: 1700000450, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 1700000451, - Message: "seed", - Date: 1700000451, - }) - if err != nil { - t.Fatalf("seed send: %v", err) - } - - for i := 0; i < 20; i++ { - start := make(chan struct{}) - errs := make(chan error, 2) - var wg sync.WaitGroup - wg.Add(2) - go func(iter int) { - defer wg.Done() - <-start - _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: member.ID, - ChannelID: channelID, - RandomID: int64(1700000500 + iter), - Message: fmt.Sprintf("race send %d", iter), - Date: 1700000500 + iter, - }) - errs <- err - }(i) - go func() { - defer wg.Done() - <-start - _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ - UserID: member.ID, - ChannelID: channelID, - MaxID: first.Message.ID, - Date: 1700000600, - }) - errs <- err - }() - close(start) - wg.Wait() - close(errs) - for err := range errs { - if err != nil { - t.Fatalf("concurrent send/read iteration %d: %v", i, err) - } - } - } -} - -func TestChannelStoreJoinInitialReadWatermarkSkipsExistingHistory(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 131, - Phone: "+1777" + suffix + "11", - FirstName: "JoinOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{ - AccessHash: 132, - Phone: "+1777" + suffix + "12", - FirstName: "JoinFriend", - }) - if err != nil { - t.Fatalf("create friend: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Join Watermark " + suffix, - Megagroup: true, - Date: 1700000320, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 905, - Message: "before join", - Date: 1700000321, - }) - if err != nil { - t.Fatalf("send existing message: %v", err) - } - joined, err := channels.JoinChannel(ctx, channelID, friend.ID, 1700000322) - if err != nil { - t.Fatalf("join channel: %v", err) - } - if _, err := channels.JoinChannel(ctx, channelID, friend.ID, 1700000323); !errors.Is(err, domain.ErrUserAlreadyParticipant) { - t.Fatalf("duplicate join err = %v, want ErrUserAlreadyParticipant", err) - } - if len(joined.Members) != 1 || joined.Members[0].ReadInboxMaxID != joined.Message.ID { - t.Fatalf("joined member = %+v message=%+v, want read watermark at self join service", joined.Members, joined.Message) - } - view, err := channels.GetChannel(ctx, friend.ID, channelID) - if err != nil { - t.Fatalf("get joined channel: %v", err) - } - if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { - t.Fatalf("joined view dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) - } - readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ - UserID: owner.ID, - ChannelID: channelID, - MessageID: first.Message.ID, - Limit: domain.MaxChannelReadParticipants, - Date: 1700000323, - }) - if err != nil { - t.Fatalf("list read participants existing message: %v", err) - } - if len(readers.Participants) != 0 { - t.Fatalf("existing message readers after join = %+v, want none from initial watermark", readers.Participants) - } - future, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 906, - Message: "after join", - Date: 1700000323, - }) - if err != nil { - t.Fatalf("send future message: %v", err) - } - after, err := channels.GetChannel(ctx, friend.ID, channelID) - if err != nil { - t.Fatalf("get channel after future: %v", err) - } - if after.Dialog.TopMessageID != future.Message.ID || after.Dialog.UnreadCount != 1 { - t.Fatalf("joined dialog after future = %+v, want top %d unread 1", after.Dialog, future.Message.ID) - } -} - -func TestChannelStoreJoinRejectsKickedMember(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 33, - Phone: "+1777" + suffix + "21", - FirstName: "BanOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - member, err := users.Create(ctx, domain.User{ - AccessHash: 34, - Phone: "+1777" + suffix + "22", - FirstName: "BanMember", - }) - if err != nil { - t.Fatalf("create member: %v", err) - } - helper, err := users.Create(ctx, domain.User{ - AccessHash: 35, - Phone: "+1777" + suffix + "23", - FirstName: "BanHelper", - }) - if err != nil { - t.Fatalf("create helper: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID, helper.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Ban Join " + suffix, - Megagroup: true, - MemberUserIDs: []int64{member.ID, helper.ID}, - Date: 1700000305, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - ptsFloor := created.Channel.Pts - banned, err := channels.EditChannelBanned(ctx, domain.EditChannelBannedRequest{ - UserID: owner.ID, - ChannelID: channelID, - Participant: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, - BannedRights: domain.ChannelBannedRights{ - ViewMessages: true, - UntilDate: 1700001300, - }, - Date: 1700000306, - }) - if err != nil { - t.Fatalf("kick member: %v", err) - } - if banned.Event.Pts != 0 || banned.Event.PtsCount != 0 || banned.Channel.Pts != ptsFloor { - t.Fatalf("kick affected channel pts = event(%d,%d) channel %d, want no pts advance from %d", banned.Event.Pts, banned.Event.PtsCount, banned.Channel.Pts, ptsFloor) - } - banDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ - UserID: owner.ID, - ChannelID: channelID, - Pts: ptsFloor, - Limit: 10, - }) - if err != nil { - t.Fatalf("difference after kick: %v", err) - } - if len(banDiff.Events) != 0 || banDiff.Pts != ptsFloor { - t.Fatalf("difference after kick = %+v, want no durable participant event at pts %d", banDiff, ptsFloor) - } - if _, err := channels.JoinChannel(ctx, channelID, member.ID, 1700000307); !errors.Is(err, domain.ErrChannelUserBanned) { - t.Fatalf("kicked JoinChannel err = %v, want ErrChannelUserBanned", err) - } - if _, err := channels.InviteToChannel(ctx, channelID, helper.ID, []int64{member.ID}, 1700000308); !errors.Is(err, domain.ErrUserKicked) { - t.Fatalf("helper InviteToChannel kicked err = %v, want ErrUserKicked", err) - } - restored, err := channels.InviteToChannel(ctx, channelID, owner.ID, []int64{member.ID}, 1700000309) - if err != nil { - t.Fatalf("owner InviteToChannel kicked: %v", err) - } - if len(restored.Members) != 1 || restored.Members[0].Status != domain.ChannelMemberActive || restored.Members[0].BannedRights != (domain.ChannelBannedRights{}) { - t.Fatalf("restored members = %+v, want active unbanned member", restored.Members) - } - if restored.Channel.ParticipantsCount != 3 || restored.Channel.KickedCount != 0 { - t.Fatalf("restored counts = participants:%d kicked:%d, want 3/0", restored.Channel.ParticipantsCount, restored.Channel.KickedCount) - } - if _, err := channels.InviteToChannel(ctx, channelID, owner.ID, []int64{member.ID}, 1700000310); !errors.Is(err, domain.ErrUserAlreadyParticipant) { - t.Fatalf("duplicate InviteToChannel err = %v, want ErrUserAlreadyParticipant", err) - } -} - -func TestChannelStoreInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 41, - Phone: "+1888" + suffix + "01", - FirstName: "InviteOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - invited, err := users.Create(ctx, domain.User{ - AccessHash: 42, - Phone: "+1888" + suffix + "02", - FirstName: "InviteMember", - }) - if err != nil { - t.Fatalf("create invited: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, invited.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Invite Watermark " + suffix, - Megagroup: true, - Date: 1700000320, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - - first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 902, - Message: "already visible before invite", - Date: 1700000321, - }) - if err != nil { - t.Fatalf("send existing channel message: %v", err) - } - if _, err := channels.InviteToChannel(ctx, channelID, owner.ID, []int64{invited.ID}, 1700000322); err != nil { - t.Fatalf("invite to channel: %v", err) - } - - view, err := channels.GetChannel(ctx, invited.ID, channelID) - if err != nil { - t.Fatalf("get invited channel: %v", err) - } - if view.Self.ReadInboxMaxID != first.Message.ID || view.Dialog.ReadInboxMaxID != first.Message.ID { - t.Fatalf("invited read watermark self/dialog = %d/%d, want existing top %d", view.Self.ReadInboxMaxID, view.Dialog.ReadInboxMaxID, first.Message.ID) - } - if view.Dialog.UnreadCount != 1 { - t.Fatalf("invited unread = %d, want only invite service message unread", view.Dialog.UnreadCount) - } -} - -func TestChannelStoreImportInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 51, - Phone: "+1888" + suffix + "11", - FirstName: "ImportOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - joiner, err := users.Create(ctx, domain.User{ - AccessHash: 52, - Phone: "+1888" + suffix + "12", - FirstName: "ImportJoiner", - }) - if err != nil { - t.Fatalf("create joiner: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, joiner.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Import Watermark " + suffix, - Megagroup: true, - Date: 1700000330, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 912, - Message: "already visible before import", - Date: 1700000331, - }) - if err != nil { - t.Fatalf("send existing channel message: %v", err) - } - invite, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ - UserID: owner.ID, - ChannelID: channelID, - Title: "join", - Date: 1700000332, - }) - if err != nil { - t.Fatalf("export invite: %v", err) - } - joined, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ - UserID: joiner.ID, - Hash: invite.Invite.Hash, - Date: 1700000333, - }) - if err != nil { - t.Fatalf("import invite: %v", err) - } - if len(joined.Members) != 1 || joined.Members[0].ReadInboxMaxID != joined.Message.ID || joined.Members[0].ReadOutboxMaxID != joined.Message.ID { - t.Fatalf("imported member = %+v message=%+v, want read watermarks at self join service", joined.Members, joined.Message) - } - view, err := channels.GetChannel(ctx, joiner.ID, channelID) - if err != nil { - t.Fatalf("get imported channel: %v", err) - } - if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { - t.Fatalf("imported view dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) - } - readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ - UserID: owner.ID, - ChannelID: channelID, - MessageID: first.Message.ID, - Limit: domain.MaxChannelReadParticipants, - Date: 1700000334, - }) - if err != nil { - t.Fatalf("list read participants existing message: %v", err) - } - if len(readers.Participants) != 0 { - t.Fatalf("existing message readers after import = %+v, want none from initial watermark", readers.Participants) - } -} - -func TestChannelStoreImportInviteRequestNeededAndUsageLimitErrors(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 61, - Phone: "+1888" + suffix + "21", - FirstName: "InviteErrorOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - first, err := users.Create(ctx, domain.User{ - AccessHash: 62, - Phone: "+1888" + suffix + "22", - FirstName: "InviteErrorFirst", - }) - if err != nil { - t.Fatalf("create first: %v", err) - } - second, err := users.Create(ctx, domain.User{ - AccessHash: 63, - Phone: "+1888" + suffix + "23", - FirstName: "InviteErrorSecond", - }) - if err != nil { - t.Fatalf("create second: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, first.ID, second.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Invite Errors " + suffix, - Megagroup: true, - Date: 1700000340, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - requested, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ - UserID: owner.ID, - ChannelID: channelID, - Title: "approval", - RequestNeeded: true, - Date: 1700000341, - }) - if err != nil { - t.Fatalf("export request-needed invite: %v", err) - } - if _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ - UserID: first.ID, - Hash: requested.Invite.Hash, - Date: 1700000342, - }); !errors.Is(err, domain.ErrInviteRequestSent) { - t.Fatalf("import request-needed err = %v, want ErrInviteRequestSent", err) - } - limited, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ - UserID: owner.ID, - ChannelID: channelID, - Title: "one", - UsageLimit: 1, - Date: 1700000343, - }) - if err != nil { - t.Fatalf("export limited invite: %v", err) - } - if _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ - UserID: first.ID, - Hash: limited.Invite.Hash, - Date: 1700000344, - }); err != nil { - t.Fatalf("first import limited invite: %v", err) - } - if _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ - UserID: second.ID, - Hash: limited.Invite.Hash, - Date: 1700000345, - }); !errors.Is(err, domain.ErrUsersTooMuch) { - t.Fatalf("second import limited invite err = %v, want ErrUsersTooMuch", err) - } -} - -func TestChannelStorePendingJoinRequestsSummaryAndInviteAdmins(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - userIDs := make([]int64, 0, 11) - createUser := func(label string, accessHash int64, phoneSuffix int) domain.User { - t.Helper() - user, err := users.Create(ctx, domain.User{ - AccessHash: accessHash, - Phone: fmt.Sprintf("+1889%s%02d", suffix, phoneSuffix), - FirstName: label, - }) - if err != nil { - t.Fatalf("create %s: %v", label, err) - } - userIDs = append(userIDs, user.ID) - return user - } - owner := createUser("PendingOwner", 71, 1) - inviteAdmin := createUser("PendingInviteAdmin", 72, 2) - plainMember := createUser("PendingPlainMember", 73, 3) - changeAdmin := createUser("PendingChangeAdmin", 74, 4) - requesters := make([]domain.User, 0, domain.MaxChannelPendingJoinRecentRequesters+2) - for i := 0; i < domain.MaxChannelPendingJoinRecentRequesters+2; i++ { - requesters = append(requesters, createUser("PendingRequester", int64(80+i), 10+i)) - } - - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Pending Summary " + suffix, - Megagroup: true, - MemberUserIDs: []int64{inviteAdmin.ID, plainMember.ID, changeAdmin.ID}, - Date: 1700000360, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ - UserID: owner.ID, - ChannelID: channelID, - MemberID: inviteAdmin.ID, - AdminRights: domain.ChannelAdminRights{ - InviteUsers: true, - }, - Date: 1700000361, - }); err != nil { - t.Fatalf("promote invite admin: %v", err) - } - if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ - UserID: owner.ID, - ChannelID: channelID, - MemberID: changeAdmin.ID, - AdminRights: domain.ChannelAdminRights{ - ChangeInfo: true, - }, - Date: 1700000362, - }); err != nil { - t.Fatalf("promote change-info admin: %v", err) - } - invite, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ - UserID: owner.ID, - ChannelID: channelID, - Title: "approval", - RequestNeeded: true, - Date: 1700000363, - }) - if err != nil { - t.Fatalf("export invite: %v", err) - } - for i, requester := range requesters { - _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ - UserID: requester.ID, - Hash: invite.Invite.Hash, - Date: 1700000370 + i, - }) - if !errors.Is(err, domain.ErrInviteRequestSent) { - t.Fatalf("import pending %d err = %v, want ErrInviteRequestSent", i, err) - } - } - pending, err := channels.PendingJoinRequests(ctx, channelID, 99) - if err != nil { - t.Fatalf("pending join requests: %v", err) - } - if pending.Count != len(requesters) || len(pending.RecentRequesters) != domain.MaxChannelPendingJoinRecentRequesters { - t.Fatalf("pending summary = %+v, want bounded recent with full count", pending) - } - if pending.RecentRequesters[0] != requesters[len(requesters)-1].ID || - pending.RecentRequesters[len(pending.RecentRequesters)-1] != requesters[2].ID { - t.Fatalf("recent requesters = %+v, want newest first", pending.RecentRequesters) - } - admins, err := channels.ListChannelInviteAdminMemberIDs(ctx, channelID, 0) - if err != nil { - t.Fatalf("invite admins: %v", err) - } - want := []int64{owner.ID, inviteAdmin.ID, changeAdmin.ID} - if !reflect.DeepEqual(admins, want) { - t.Fatalf("invite admins = %+v, want %+v", admins, want) - } -} - -func TestChannelStoreImportInviteUsageLimitSeesConcurrentIncrement(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 64, - Phone: "+1888" + suffix + "31", - FirstName: "InviteLimitOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - joiner, err := users.Create(ctx, domain.User{ - AccessHash: 65, - Phone: "+1888" + suffix + "32", - FirstName: "InviteLimitJoiner", - }) - if err != nil { - t.Fatalf("create joiner: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, joiner.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Invite Limit Race " + suffix, - Megagroup: true, - Date: 1700000350, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - invite, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ - UserID: owner.ID, - ChannelID: channelID, - Title: "single", - UsageLimit: 1, - Date: 1700000351, - }) - if err != nil { - t.Fatalf("export limited invite: %v", err) - } - - lockTx, err := pool.Begin(ctx) - if err != nil { - t.Fatalf("begin lock tx: %v", err) - } - defer func() { _ = lockTx.Rollback(ctx) }() - if _, err := lockTx.Exec(ctx, ` -UPDATE channel_invites -SET usage_count = usage_limit -WHERE channel_id = $1 AND invite_id = $2`, channelID, invite.Invite.InviteID); err != nil { - t.Fatalf("lock and update invite usage: %v", err) - } - - importCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - errCh := make(chan error, 1) - go func() { - _, err := channels.ImportInvite(importCtx, domain.ImportChannelInviteRequest{ - UserID: joiner.ID, - Hash: invite.Invite.Hash, - Date: 1700000352, - }) - errCh <- err - }() - time.Sleep(100 * time.Millisecond) - if err := lockTx.Commit(ctx); err != nil { - t.Fatalf("commit lock tx: %v", err) - } - err = <-errCh - if !errors.Is(err, domain.ErrUsersTooMuch) { - t.Fatalf("concurrent import err = %v, want ErrUsersTooMuch after seeing committed usage_count", err) - } - if _, err := channels.GetChannel(ctx, joiner.ID, channelID); !errors.Is(err, domain.ErrChannelPrivate) { - t.Fatalf("joiner channel after rejected import err = %v, want ErrChannelPrivate", err) - } -} - -func TestChannelStoreListDialogsUsesDateAndOffset(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 35, - Phone: "+1777" + suffix + "03", - FirstName: "DialogPageOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - var channelIDs []int64 - t.Cleanup(func() { - if len(channelIDs) > 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) - }) - - channels := NewChannelStore(pool) - older, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Older Dialog " + suffix, - Megagroup: true, - Date: 1700000310, - }) - if err != nil { - t.Fatalf("create older channel: %v", err) - } - channelIDs = append(channelIDs, older.Channel.ID) - newer, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Newer Dialog " + suffix, - Megagroup: true, - Date: 1700000320, - }) - if err != nil { - t.Fatalf("create newer channel: %v", err) - } - channelIDs = append(channelIDs, newer.Channel.ID) - - first, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{Limit: 1}) - if err != nil { - t.Fatalf("list first channel dialogs: %v", err) - } - if len(first.Dialogs) != 1 || first.Dialogs[0].Peer.ID != newer.Channel.ID { - t.Fatalf("first page dialogs = %+v, want newer channel by top date", first.Dialogs) - } - - next, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ - OffsetDate: first.Dialogs[0].TopMessageDate, - OffsetID: first.Dialogs[0].TopMessage, - HasOffsetPeer: true, - OffsetPeer: first.Dialogs[0].Peer, - Limit: 10, - }) - if err != nil { - t.Fatalf("list next channel dialogs: %v", err) - } - if len(next.Dialogs) != 1 || next.Dialogs[0].Peer.ID != older.Channel.ID { - t.Fatalf("next page dialogs = %+v, want older channel without repeating offset peer", next.Dialogs) - } -} - -func TestChannelStoreDifferenceStartsAtMemberAvailableMinPts(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 41, - Phone: "+1778" + suffix + "01", - FirstName: "PtsOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - member, err := users.Create(ctx, domain.User{ - AccessHash: 42, - Phone: "+1778" + suffix + "02", - FirstName: "PtsMember", - }) - if err != nil { - t.Fatalf("create member: %v", err) - } - joiner, err := users.Create(ctx, domain.User{ - AccessHash: 43, - Phone: "+1778" + suffix + "03", - FirstName: "PtsJoiner", - }) - if err != nil { - t.Fatalf("create joiner: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID, joiner.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "PTS Floor " + suffix, - Megagroup: true, - MemberUserIDs: []int64{member.ID}, - Date: 1700000350, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - ptsFloor := created.Channel.Pts - promoted, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ - UserID: owner.ID, - ChannelID: channelID, - MemberID: member.ID, - AdminRights: domain.ChannelAdminRights{ - InviteUsers: true, - }, - Date: 1700000351, - }) - if err != nil { - t.Fatalf("edit admin: %v", err) - } - if promoted.Event.Pts != 0 || promoted.Event.PtsCount != 0 || promoted.Channel.Pts != ptsFloor { - t.Fatalf("promote affected channel pts = event(%d,%d) channel %d, want no pts advance from %d", promoted.Event.Pts, promoted.Event.PtsCount, promoted.Channel.Pts, ptsFloor) - } - adminDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ - UserID: member.ID, - ChannelID: channelID, - Pts: ptsFloor, - Limit: 10, - }) - if err != nil { - t.Fatalf("difference after promote: %v", err) - } - if len(adminDiff.Events) != 0 || adminDiff.Pts != ptsFloor { - t.Fatalf("difference after promote = %+v, want no durable participant event at pts %d", adminDiff, ptsFloor) - } - joined, err := channels.JoinChannel(ctx, channelID, joiner.ID, 1700000352) - if err != nil { - t.Fatalf("join channel: %v", err) - } - if len(joined.Members) != 1 || joined.Members[0].AvailableMinPts != ptsFloor { - t.Fatalf("joined members = %+v, want available_min_pts %d", joined.Members, ptsFloor) - } - diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ - UserID: joiner.ID, - ChannelID: channelID, - Pts: 0, - Limit: 100, - }) - if err != nil { - t.Fatalf("list channel difference: %v", err) - } - if diff.Pts != joined.Channel.Pts { - t.Fatalf("diff pts = %d, want current channel pts %d", diff.Pts, joined.Channel.Pts) - } - for _, msg := range diff.NewMessages { - if msg.Pts <= ptsFloor { - t.Fatalf("diff leaks pre-join message %+v at or before available_min_pts %d", msg, ptsFloor) - } - } - for _, event := range diff.OtherUpdates { - if event.Pts <= ptsFloor { - t.Fatalf("diff leaks pre-join event %+v at or before available_min_pts %d", event, ptsFloor) - } - } -} - -func TestChannelStorePublicPreviewDifferenceAllowsNonMember(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 241, - Phone: "+1778" + suffix + "41", - FirstName: "PreviewDiffOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - viewer, err := users.Create(ctx, domain.User{ - AccessHash: 242, - Phone: "+1778" + suffix + "42", - FirstName: "PreviewDiffViewer", - }) - if err != nil { - t.Fatalf("create viewer: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, viewer.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Preview Difference " + suffix, - Broadcast: true, - Date: 1700000370, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ - UserID: owner.ID, - ChannelID: channelID, - Username: "preview_diff_" + suffix, - }); err != nil { - t.Fatalf("update username: %v", err) - } - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 1700000371, - Message: "public preview difference", - Date: 1700000371, - }) - if err != nil { - t.Fatalf("send channel message: %v", err) - } - - diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ - UserID: viewer.ID, - ChannelID: channelID, - Pts: created.Event.Pts, - Limit: 10, - }) - if err != nil { - t.Fatalf("list public preview difference: %v", err) - } - if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "public preview difference" { - t.Fatalf("preview diff = %+v, want one public preview message at current pts", diff) - } - if diff.Dialog.UnreadCount != 0 || diff.Dialog.ReadInboxMaxID < sent.Message.ID { - t.Fatalf("preview diff dialog = %+v, want read-only public preview dialog", diff.Dialog) - } -} - -func TestChannelStoreListDialogsDerivesRecipientTopWithoutWriteFanout(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 41, - Phone: "+1777" + suffix + "31", - FirstName: "DialogTopOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - member, err := users.Create(ctx, domain.User{ - AccessHash: 42, - Phone: "+1777" + suffix + "32", - FirstName: "DialogTopMember", - }) - if err != nil { - t.Fatalf("create member: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Dialog Top " + suffix, - Megagroup: true, - MemberUserIDs: []int64{member.ID}, - Date: 1700000330, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ - UserID: member.ID, - ChannelID: channelID, - MaxID: created.Message.ID, - Date: 1700000331, - }); err != nil { - t.Fatalf("read initial service message: %v", err) - } - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 9331, - Message: "recipient top without write fanout", - Date: 1700000332, - }) - if err != nil { - t.Fatalf("send channel message: %v", err) - } - - list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) - if err != nil { - t.Fatalf("list recipient channel dialogs: %v", err) - } - if len(list.Dialogs) != 1 { - t.Fatalf("dialogs = %+v, want one channel dialog", list.Dialogs) - } - dialog := list.Dialogs[0] - if dialog.TopMessage != sent.Message.ID || dialog.TopMessageDate != sent.Message.Date || dialog.UnreadCount != 1 { - t.Fatalf("recipient dialog = %+v, want top sent message and unread=1", dialog) - } - if len(list.Messages) != 1 || list.Messages[0].ID != sent.Message.ID { - t.Fatalf("dialog messages = %+v, want sent top message", list.Messages) - } -} - -func TestChannelStoreBroadcastUnreadDerivesDespiteStaleCache(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 421, - Phone: "+1777" + suffix + "33", - FirstName: "BroadcastUnreadOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - member, err := users.Create(ctx, domain.User{ - AccessHash: 422, - Phone: "+1777" + suffix + "34", - FirstName: "BroadcastUnreadMember", - }) - if err != nil { - t.Fatalf("create member: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Broadcast Unread " + suffix, - Broadcast: true, - MemberUserIDs: []int64{member.ID}, - Date: 1700000333, - }) - if err != nil { - t.Fatalf("create broadcast channel: %v", err) - } - channelID = created.Channel.ID - if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ - UserID: member.ID, - ChannelID: channelID, - MaxID: created.Message.ID, - Date: 1700000334, - }); err != nil { - t.Fatalf("read initial broadcast service message: %v", err) - } - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 9333, - Message: "broadcast unread derives despite stale cache", - Date: 1700000335, - }) - if err != nil { - t.Fatalf("send broadcast message: %v", err) - } - - var storedUnread int - if err := pool.QueryRow(ctx, ` -SELECT unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedUnread); err != nil { - t.Fatalf("read stale broadcast dialog cache: %v", err) - } - if storedUnread != 0 { - t.Fatalf("stored broadcast unread cache = %d, want no send fanout", storedUnread) - } - - list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) - if err != nil { - t.Fatalf("list broadcast channel dialogs: %v", err) - } - if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != sent.Message.ID || list.Dialogs[0].UnreadCount != 1 { - t.Fatalf("broadcast dialogs = %+v, want sent top and dynamic unread=1", list.Dialogs) - } - - unreadOnly, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{ - Folder: &domain.DialogFolder{ExcludeRead: true, Broadcasts: true}, - Limit: 10, - }) - if err != nil { - t.Fatalf("list unread-only broadcast dialogs: %v", err) - } - if len(unreadOnly.Dialogs) != 1 || unreadOnly.Dialogs[0].Peer.ID != channelID { - t.Fatalf("unread-only broadcast dialogs = %+v, want stale-cache channel included", unreadOnly.Dialogs) - } - - view, err := channels.GetChannel(ctx, member.ID, channelID) - if err != nil { - t.Fatalf("get broadcast channel: %v", err) - } - if view.Dialog.UnreadCount != 1 || view.Dialog.TopMessageID != sent.Message.ID { - t.Fatalf("broadcast view dialog = %+v, want dynamic unread=1", view.Dialog) - } - dialogs, err := channels.GetChannelDialogs(ctx, member.ID, []int64{channelID}) - if err != nil { - t.Fatalf("get broadcast channel dialogs: %v", err) - } - if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 1 { - t.Fatalf("get broadcast dialogs = %+v, want dynamic unread=1", dialogs.Dialogs) - } -} - -func TestChannelStoreLargeMegagroupUnreadDerivesDespiteStaleCache(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 431, - Phone: "+1777" + suffix + "35", - FirstName: "LargeUnreadOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - member, err := users.Create(ctx, domain.User{ - AccessHash: 432, - Phone: "+1777" + suffix + "36", - FirstName: "LargeUnreadMember", - }) - if err != nil { - t.Fatalf("create member: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Large Unread " + suffix, - Megagroup: true, - MemberUserIDs: []int64{member.ID}, - Date: 1700000336, - }) - if err != nil { - t.Fatalf("create large megagroup: %v", err) - } - channelID = created.Channel.ID - if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ - UserID: member.ID, - ChannelID: channelID, - MaxID: created.Message.ID, - Date: 1700000337, - }); err != nil { - t.Fatalf("read initial large service message: %v", err) - } - if _, err := pool.Exec(ctx, ` -UPDATE channels -SET participants_count = $2 -WHERE id = $1`, channelID, domain.MaxSynchronousChannelDialogFanout+1); err != nil { - t.Fatalf("mark megagroup as over synchronous fanout threshold: %v", err) - } - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 9336, - Message: "large megagroup unread derives despite stale cache", - Date: 1700000338, - }) - if err != nil { - t.Fatalf("send large megagroup message: %v", err) - } - - var storedTop, storedUnread int - if err := pool.QueryRow(ctx, ` -SELECT top_message_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedTop, &storedUnread); err != nil { - t.Fatalf("read stale large dialog cache: %v", err) - } - if storedTop == sent.Message.ID || storedUnread != 0 { - t.Fatalf("stored large dialog cache top=%d unread=%d, want stale top and unread=0", storedTop, storedUnread) - } - - list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) - if err != nil { - t.Fatalf("list large channel dialogs: %v", err) - } - if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != sent.Message.ID || list.Dialogs[0].UnreadCount != 1 { - t.Fatalf("large dialogs = %+v, want sent top and dynamic unread=1", list.Dialogs) - } - ownerView, err := channels.GetChannel(ctx, owner.ID, channelID) - if err != nil { - t.Fatalf("get large channel for sender: %v", err) - } - if ownerView.Dialog.UnreadCount != 0 { - t.Fatalf("large sender dialog = %+v, want own outgoing excluded from dynamic unread", ownerView.Dialog) - } - - unreadOnly, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{ - Folder: &domain.DialogFolder{ExcludeRead: true, Groups: true}, - Limit: 10, - }) - if err != nil { - t.Fatalf("list unread-only large dialogs: %v", err) - } - if len(unreadOnly.Dialogs) != 1 || unreadOnly.Dialogs[0].Peer.ID != channelID { - t.Fatalf("unread-only large dialogs = %+v, want stale-cache channel included", unreadOnly.Dialogs) - } - - view, err := channels.GetChannel(ctx, member.ID, channelID) - if err != nil { - t.Fatalf("get large channel: %v", err) - } - if view.Dialog.UnreadCount != 1 || view.Dialog.TopMessageID != sent.Message.ID { - t.Fatalf("large view dialog = %+v, want dynamic unread=1", view.Dialog) - } - dialogs, err := channels.GetChannelDialogs(ctx, member.ID, []int64{channelID}) - if err != nil { - t.Fatalf("get large channel dialogs: %v", err) - } - if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 1 { - t.Fatalf("get large dialogs = %+v, want dynamic unread=1", dialogs.Dialogs) - } - - cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ - UserID: member.ID, - ChannelID: channelID, - MaxID: sent.Message.ID, - Date: 1700000339, - }) - if err != nil { - t.Fatalf("local clear large history: %v", err) - } - if cleared.AvailableMinID != sent.Message.ID { - t.Fatalf("large local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID) - } - afterClear, err := channels.GetChannel(ctx, member.ID, channelID) - if err != nil { - t.Fatalf("get large channel after local clear: %v", err) - } - if afterClear.Dialog.TopMessageID != 0 || afterClear.Dialog.UnreadCount != 0 { - t.Fatalf("large dialog after local clear = %+v, want no visible unread top", afterClear.Dialog) - } -} - -func TestChannelStoreLargeMegagroupUnreadSkipsDeletedHole(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 441, - Phone: "+1777" + suffix + "37", - FirstName: "DeletedHoleOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - member, err := users.Create(ctx, domain.User{ - AccessHash: 442, - Phone: "+1777" + suffix + "38", - FirstName: "DeletedHoleMember", - }) - if err != nil { - t.Fatalf("create member: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Deleted Hole " + suffix, - Megagroup: true, - MemberUserIDs: []int64{member.ID}, - Date: 1700000339, - }) - if err != nil { - t.Fatalf("create deleted-hole megagroup: %v", err) - } - channelID = created.Channel.ID - if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ - UserID: member.ID, - ChannelID: channelID, - MaxID: created.Message.ID, - Date: 1700000340, - }); err != nil { - t.Fatalf("read initial deleted-hole service message: %v", err) - } - if _, err := pool.Exec(ctx, ` -UPDATE channels -SET participants_count = $2 -WHERE id = $1`, channelID, domain.MaxSynchronousChannelDialogFanout+1); err != nil { - t.Fatalf("mark deleted-hole megagroup over threshold: %v", err) - } - first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 9339, - Message: "deleted unread hole", - Date: 1700000341, - }) - if err != nil { - t.Fatalf("send first large message: %v", err) - } - second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 9340, - Message: "remaining unread message", - Date: 1700000342, - }) - if err != nil { - t.Fatalf("send second large message: %v", err) - } - deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ - UserID: owner.ID, - ChannelID: channelID, - IDs: []int{first.Message.ID}, - Date: 1700000343, - }) - if err != nil { - t.Fatalf("delete non-top unread message: %v", err) - } - if len(deleted.DeletedIDs) != 1 || deleted.DeletedIDs[0] != first.Message.ID { - t.Fatalf("deleted ids = %+v, want first message only", deleted.DeletedIDs) - } - - list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) - if err != nil { - t.Fatalf("list deleted-hole dialogs: %v", err) - } - if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != second.Message.ID || list.Dialogs[0].UnreadCount != 1 { - t.Fatalf("deleted-hole dialogs = %+v, want only non-deleted unread top counted", list.Dialogs) - } -} - -func TestChannelStoreListDialogsSeeksBeyondQueryWindow(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 36, - Phone: "+1777" + suffix + "04", - FirstName: "DialogSeekOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - count := channelDialogQueryLimit + 5 - ids := make([]int64, count) - baseID := owner.ID * 1000 - for i := range ids { - ids[i] = baseID + int64(i+1) - } - t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", ids) - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) - }) - if _, err := pool.Exec(ctx, ` -INSERT INTO channels ( - id, access_hash, creator_user_id, title, broadcast, megagroup, - participants_count, admins_count, top_message_id, pts, date -) -SELECT id, id + 900000, $2, 'Bulk Dialog ' || ord, false, true, 1, 1, 1, 1, (1700000400 + ord)::int -FROM unnest($1::bigint[]) WITH ORDINALITY AS t(id, ord)`, ids, owner.ID); err != nil { - t.Fatalf("bulk insert channels: %v", err) - } - if _, err := pool.Exec(ctx, ` -INSERT INTO channel_members (channel_id, user_id, role, status, joined_at) -SELECT id, $2, 'creator', 'active', 1700000400 -FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil { - t.Fatalf("bulk insert channel members: %v", err) - } - - channels := NewChannelStore(pool) - var cursor domain.Dialog - var sixth domain.ChannelDialogList - for page := 0; page < 6; page++ { - filter := domain.DialogFilter{Limit: 100} - if page > 0 { - filter.OffsetDate = cursor.TopMessageDate - filter.OffsetID = cursor.TopMessage - filter.HasOffsetPeer = true - filter.OffsetPeer = cursor.Peer - } - got, err := channels.ListChannelDialogs(ctx, owner.ID, filter) - if err != nil { - t.Fatalf("list channel dialogs page %d: %v", page+1, err) - } - if len(got.Dialogs) == 0 { - t.Fatalf("page %d unexpectedly empty after cursor %+v", page+1, cursor) - } - cursor = got.Dialogs[len(got.Dialogs)-1] - if page == 5 { - sixth = got - } - } - if len(sixth.Dialogs) != 5 { - t.Fatalf("sixth page len = %d, want remaining 5 beyond query window", len(sixth.Dialogs)) - } - if sixth.Dialogs[0].Peer.ID != ids[4] || sixth.Dialogs[4].Peer.ID != ids[0] { - t.Fatalf("sixth page dialogs = %+v, want oldest five descending by date", sixth.Dialogs) - } - - included, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ - Folder: &domain.DialogFolder{ - IncludePeers: []domain.DialogFolderPeer{{ - Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: ids[0]}, - }}, - }, - Limit: 10, - }) - if err != nil { - t.Fatalf("list included channel dialog beyond query window: %v", err) - } - if len(included.Dialogs) != 1 || included.Dialogs[0].Peer.ID != ids[0] { - t.Fatalf("included dialogs = %+v, want oldest included channel beyond query window", included.Dialogs) - } -} - -func TestChannelStoreListDialogsFolderFiltersBeforeQueryLimit(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 37, - Phone: "+1777" + suffix + "05", - FirstName: "DialogFolderOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - count := channelDialogQueryLimit + 5 - ids := make([]int64, count) - baseID := owner.ID*1000 + 100000 - for i := range ids { - ids[i] = baseID + int64(i+1) - } - archivedID := ids[0] - t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", ids) - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) - }) - if _, err := pool.Exec(ctx, ` -INSERT INTO channels ( - id, access_hash, creator_user_id, title, broadcast, megagroup, - participants_count, admins_count, top_message_id, pts, date -) -SELECT id, id + 910000, $2, 'Folder Dialog ' || ord, false, true, 1, 1, 1, 1, (1700000500 + ord)::int -FROM unnest($1::bigint[]) WITH ORDINALITY AS t(id, ord)`, ids, owner.ID); err != nil { - t.Fatalf("bulk insert channels: %v", err) - } - if _, err := pool.Exec(ctx, ` -INSERT INTO channel_members (channel_id, user_id, role, status, joined_at) -SELECT id, $2, 'creator', 'active', 1700000500 -FROM unnest($1::bigint[]) AS t(id)`, ids, owner.ID); err != nil { - t.Fatalf("bulk insert channel members: %v", err) - } - if _, err := pool.Exec(ctx, ` -INSERT INTO channel_dialogs (user_id, channel_id, folder_id, top_message_id, top_message_date) -VALUES ($1, $2, $3, 1, 1700000500)`, owner.ID, archivedID, domain.DialogArchiveFolderID); err != nil { - t.Fatalf("archive oldest channel dialog: %v", err) - } - - archive, err := NewChannelStore(pool).ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ - HasFolderID: true, - FolderID: domain.DialogArchiveFolderID, - Limit: 10, - }) - if err != nil { - t.Fatalf("list archive channel dialogs: %v", err) - } - if len(archive.Dialogs) != 1 || archive.Dialogs[0].Peer.ID != archivedID { - t.Fatalf("archive dialogs = %+v, want archived channel beyond first query window", archive.Dialogs) - } -} - -func TestChannelStoreEditAboutPersistsAndChecksPermission(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 131, - Phone: "+1888" + suffix + "01", - FirstName: "AboutOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - member, err := users.Create(ctx, domain.User{ - AccessHash: 132, - Phone: "+1888" + suffix + "02", - FirstName: "AboutMember", - }) - if err != nil { - t.Fatalf("create member: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "About " + suffix, - Megagroup: true, - MemberUserIDs: []int64{member.ID}, - Date: 1700000600, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - - if _, err := channels.EditChannelAbout(ctx, domain.EditChannelAboutRequest{ - UserID: member.ID, - ChannelID: channelID, - About: "member cannot edit", - Date: 1700000601, - }); !errors.Is(err, domain.ErrChannelAdminRequired) { - t.Fatalf("EditChannelAbout by member err = %v, want ErrChannelAdminRequired", err) - } - - updated, err := channels.EditChannelAbout(ctx, domain.EditChannelAboutRequest{ - UserID: owner.ID, - ChannelID: channelID, - About: "owner about", - Date: 1700000602, - }) - if err != nil { - t.Fatalf("EditChannelAbout by owner: %v", err) - } - if updated.About != "owner about" { - t.Fatalf("updated about = %q, want owner about", updated.About) - } - view, err := channels.GetChannel(ctx, member.ID, channelID) - if err != nil { - t.Fatalf("GetChannel by member: %v", err) - } - if view.Channel.About != "owner about" { - t.Fatalf("member view about = %q, want owner about", view.Channel.About) - } -} - -func TestChannelStoreSendMessageResolvesReplyTopID(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 41, - Phone: "+1778" + suffix + "01", - FirstName: "ReplyOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{ - AccessHash: 42, - Phone: "+1778" + suffix + "02", - FirstName: "ReplyFriend", - }) - if err != nil { - t.Fatalf("create friend: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Reply Top " + suffix, - Megagroup: true, - MemberUserIDs: []int64{friend.ID}, - Date: 1700000350, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - root, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 911, - Message: "root", - Date: 1700000351, - }) - if err != nil { - t.Fatalf("send root: %v", err) - } - reply, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: friend.ID, - ChannelID: channelID, - RandomID: 912, - Message: "reply", - ReplyTo: &domain.MessageReply{MessageID: root.Message.ID, QuoteText: "root"}, - Date: 1700000352, - }) - if err != nil { - t.Fatalf("send reply: %v", err) - } - channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} - if reply.Message.ReplyTo == nil || reply.Message.ReplyTo.Peer != channelPeer || reply.Message.ReplyTo.TopMessageID != root.Message.ID { - t.Fatalf("reply metadata = %+v, want channel peer and top id %d", reply.Message.ReplyTo, root.Message.ID) - } - nested, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 913, - Message: "nested", - ReplyTo: &domain.MessageReply{MessageID: reply.Message.ID}, - Date: 1700000353, - }) - if err != nil { - t.Fatalf("send nested reply: %v", err) - } - if nested.Message.ReplyTo == nil || nested.Message.ReplyTo.TopMessageID != root.Message.ID { - t.Fatalf("nested reply metadata = %+v, want inherited top id %d", nested.Message.ReplyTo, root.Message.ID) - } - _, err = channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 914, - Message: "bad quote offset", - ReplyTo: &domain.MessageReply{ - MessageID: root.Message.ID, - QuoteText: "root", - QuoteOffset: domain.MaxMessageReplyQuoteOffset + 1, - }, - Date: 1700000354, - }) - if !errors.Is(err, domain.ErrReplyMessageIDInvalid) { - t.Fatalf("bad quote offset err = %v, want ErrReplyMessageIDInvalid", err) - } -} - -func TestChannelStoreHistorySupportsOffsetDateOnly(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 37, - Phone: "+1778" + suffix + "03", - FirstName: "HistoryDateOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "History Date " + suffix, - Megagroup: true, - Date: 1700000360, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 921, - Message: "old", - Date: 1700000361, - }); err != nil { - t.Fatalf("send old: %v", err) - } - if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 922, - Message: "new", - Date: 1700000362, - }); err != nil { - t.Fatalf("send new: %v", err) - } - - history, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ - ChannelID: channelID, - OffsetDate: 1700000362, - Limit: 10, - }) - if err != nil { - t.Fatalf("list channel history: %v", err) - } - if len(history.Messages) != 2 || history.Messages[0].Body != "old" || history.Messages[1].Action == nil { - t.Fatalf("history = %+v, want only messages older than offset date", history.Messages) - } -} - -func TestChannelStoreDifferenceUsesDurableMessageSnapshots(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 39, - Phone: "+1778" + suffix + "01", - FirstName: "SnapshotOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{ - AccessHash: 40, - Phone: "+1778" + suffix + "02", - FirstName: "SnapshotFriend", - }) - if err != nil { - t.Fatalf("create friend: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Snapshot Diff " + suffix, - Megagroup: true, - MemberUserIDs: []int64{friend.ID}, - Date: 1700000380, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 941, - Message: "original", - Date: 1700000381, - }) - if err != nil { - t.Fatalf("send channel message: %v", err) - } - if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - ID: sent.Message.ID, - Message: "first edit", - EditDate: 1700000382, - }); err != nil { - t.Fatalf("first edit: %v", err) - } - if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - ID: sent.Message.ID, - Message: "second edit", - EditDate: 1700000383, - }); err != nil { - t.Fatalf("second edit: %v", err) - } - duplicate, found, err := channels.duplicateChannelMessage(ctx, channelID, owner.ID, sent.Message.RandomID) - if err != nil { - t.Fatalf("duplicate channel message: %v", err) - } - if !found || !duplicate.Duplicate || duplicate.Event.Type != domain.ChannelUpdateNewMessage || duplicate.Message.Body != "original" || duplicate.Event.Message.Body != "original" { - t.Fatalf("duplicate after edit = %+v found=%v, want original new-message snapshot", duplicate, found) - } - - diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ - UserID: friend.ID, - ChannelID: channelID, - Pts: created.Event.Pts, - Limit: 10, - }) - if err != nil { - t.Fatalf("list channel difference: %v", err) - } - if len(diff.NewMessages) != 1 || diff.NewMessages[0].Body != "original" { - t.Fatalf("new messages = %+v, want original send snapshot", diff.NewMessages) - } - if len(diff.OtherUpdates) != 2 { - t.Fatalf("other updates = %+v, want two edit snapshots", diff.OtherUpdates) - } - if diff.OtherUpdates[0].Message.Body != "first edit" || diff.OtherUpdates[1].Message.Body != "second edit" { - t.Fatalf("edit snapshots = %q/%q, want first edit/second edit", diff.OtherUpdates[0].Message.Body, diff.OtherUpdates[1].Message.Body) - } -} - -func TestChannelStoreSendFailureBeforePtsAllocationDoesNotRecordNoopGap(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 41, - Phone: "+1888" + suffix + "01", - FirstName: "NoopOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - outsider, err := users.Create(ctx, domain.User{ - AccessHash: 42, - Phone: "+1888" + suffix + "02", - FirstName: "NoopOutsider", - }) - if err != nil { - t.Fatalf("create outsider: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, outsider.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Noop Gap " + suffix, - Megagroup: true, - Date: 1700000400, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - - _, err = channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: outsider.ID, - ChannelID: channelID, - RandomID: 991, - Message: "outsider should fail", - Date: 1700000401, - }) - if err == nil { - t.Fatal("SendChannelMessage outsider unexpectedly succeeded") - } - - var gapRows int - if err := pool.QueryRow(ctx, ` -SELECT count(*)::int -FROM channel_update_events -WHERE channel_id = $1 AND pts = 2`, channelID).Scan(&gapRows); err != nil { - t.Fatalf("count events after failed send: %v", err) - } - if gapRows != 0 { - t.Fatalf("events after failed send = %d, want no pts allocation before member validation", gapRows) - } - - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 992, - Message: "after noop gap", - Date: 1700000402, - }) - if err != nil { - t.Fatalf("send owner after gap: %v", err) - } - if sent.Event.Pts != 2 { - t.Fatalf("next channel pts = %d, want 2 after failed send before pts allocation", sent.Event.Pts) - } - diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ - UserID: owner.ID, - ChannelID: channelID, - Pts: 1, - Limit: 10, - }) - if err != nil { - t.Fatalf("list channel difference: %v", err) - } - if diff.Pts != 2 || len(diff.Events) != 1 || diff.Events[0].Type != domain.ChannelUpdateNewMessage || diff.Events[0].Pts != 2 { - t.Fatalf("diff after failed send = %+v, want only message pts=2", diff) - } -} - -func TestChannelStoreDifferenceTooLongSnapshot(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 45, - Phone: "+1889" + suffix + "01", - FirstName: "TooLongOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{ - AccessHash: 46, - Phone: "+1889" + suffix + "02", - FirstName: "TooLongFriend", - }) - if err != nil { - t.Fatalf("create friend: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "TooLong Snapshot " + suffix, - Megagroup: true, - MemberUserIDs: []int64{friend.ID}, - Date: 1700000410, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - - lastPts := created.Event.Pts - for i := 0; i < 12; i++ { - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: int64(10_000 + i), - Message: "too long snapshot", - Date: 1700000411 + i, - }) - if err != nil { - t.Fatalf("send channel message %d: %v", i, err) - } - lastPts = sent.Event.Pts - } - - diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ - UserID: friend.ID, - ChannelID: channelID, - Pts: 0, - Limit: 3, - }) - if err != nil { - t.Fatalf("list channel difference: %v", err) - } - if !diff.TooLong || !diff.Final || diff.Pts != lastPts { - t.Fatalf("diff = %+v, want tooLong final snapshot at pts %d", diff, lastPts) - } - if len(diff.NewMessages) == 0 || len(diff.NewMessages) > domain.MaxChannelDifferenceTooLongMessages { - t.Fatalf("tooLong snapshot messages = %d, want bounded latest messages", len(diff.NewMessages)) - } - if diff.Dialog.TopMessageID == 0 || diff.Dialog.UnreadCount == 0 { - t.Fatalf("tooLong dialog = %+v, want current dialog state", diff.Dialog) - } -} - -func TestChannelStoreAdminLogFiltersAndSearch(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 51, - Phone: "+1999" + suffix + "01", - FirstName: "AdminLogOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{ - AccessHash: 52, - Phone: "+1999" + suffix + "02", - FirstName: "AdminLogFriend", - }) - if err != nil { - t.Fatalf("create friend: %v", err) - } - invited, err := users.Create(ctx, domain.User{ - AccessHash: 53, - Phone: "+1999" + suffix + "03", - FirstName: "AdminLogInvited", - }) - if err != nil { - t.Fatalf("create invited: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, invited.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Admin Log " + suffix, - Megagroup: true, - MemberUserIDs: []int64{friend.ID}, - Date: 1700000500, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - - if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ - UserID: owner.ID, - ChannelID: channelID, - MemberID: friend.ID, - AdminRights: domain.ChannelAdminRights{ - ChangeInfo: true, - InviteUsers: true, - PinMessages: true, - }, - Rank: "ops", - Date: 1700000501, - }); err != nil { - t.Fatalf("edit admin: %v", err) - } - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 501, - Message: "needle admin log body", - Date: 1700000502, - }) - if err != nil { - t.Fatalf("send channel message: %v", err) - } - if _, err := channels.UpdatePinnedMessage(ctx, domain.UpdateChannelPinnedMessageRequest{ - UserID: friend.ID, - ChannelID: channelID, - MessageID: sent.Message.ID, - Pinned: true, - Date: 1700000503, - }); err != nil { - t.Fatalf("pin message: %v", err) - } - if _, err := channels.InviteToChannel(ctx, channelID, friend.ID, []int64{invited.ID}, 1700000504); err != nil { - t.Fatalf("invite to channel: %v", err) - } - - searched, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ - UserID: owner.ID, - ChannelID: channelID, - Query: "needle", - Limit: 10, - }) - if err != nil { - t.Fatalf("search admin log: %v", err) - } - if len(searched.Events) == 0 { - t.Fatalf("search admin log returned no events, want message body match") - } - - pinned, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ - UserID: owner.ID, - ChannelID: channelID, - Filter: domain.ChannelAdminLogFilter{Pinned: true}, - Limit: 10, - }) - if err != nil { - t.Fatalf("pinned admin log: %v", err) - } - if len(pinned.Events) != 1 || pinned.Events[0].Type != domain.ChannelAdminLogUpdatePinned || pinned.Events[0].Message == nil { - t.Fatalf("pinned events = %+v, want one update_pinned with message", pinned.Events) - } - - byFriend, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ - UserID: owner.ID, - ChannelID: channelID, - AdminUserIDs: []int64{friend.ID}, - Limit: 10, - }) - if err != nil { - t.Fatalf("friend admin log: %v", err) - } - if len(byFriend.Events) == 0 { - t.Fatalf("friend admin log returned no events, want pin/invite") - } - for _, event := range byFriend.Events { - if event.UserID != friend.ID { - t.Fatalf("friend admin log event actor = %d, want %d in %+v", event.UserID, friend.ID, byFriend.Events) - } - } - - if _, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ - UserID: invited.ID, - ChannelID: channelID, - Limit: 10, - }); err != domain.ErrChannelAdminRequired { - t.Fatalf("member admin log err = %v, want ErrChannelAdminRequired", err) - } -} - -func TestChannelStoreDeleteHistoryForEveryoneBatchesHugeMaxID(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 71, - Phone: "+1998" + suffix + "01", - FirstName: "BulkChannelOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{ - AccessHash: 72, - Phone: "+1998" + suffix + "02", - FirstName: "BulkChannelFriend", - }) - if err != nil { - t.Fatalf("create friend: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Bulk Delete " + suffix, - Megagroup: true, - MemberUserIDs: []int64{friend.ID}, - Date: 1700000600, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - - total := domain.MaxDeleteHistoryBatch + 2 - if _, err := pool.Exec(ctx, ` -WITH src AS ( - SELECT generate_series(2, $3::int + 1) AS id -), -msgs AS ( - INSERT INTO channel_messages ( - channel_id, - id, - random_id, - sender_user_id, - from_peer_type, - from_peer_id, - message_date, - body, - entities, - pts - ) - SELECT - $1::bigint, - id, - 920000000 + id, - $2::bigint, - 'user', - $2::bigint, - 1700000600 + id, - 'bulk channel history', - '[]'::jsonb, - id - FROM src - RETURNING id, message_date -) -INSERT INTO channel_update_events ( - channel_id, - pts, - pts_count, - date, - event_type, - message_id, - sender_user_id, - payload -) -SELECT - $1::bigint, - id, - 1, - message_date, - 'new_channel_message', - id, - $2::bigint, - '{}'::jsonb -FROM msgs -`, channelID, owner.ID, total); err != nil { - t.Fatalf("seed bulk channel messages: %v", err) - } - if _, err := pool.Exec(ctx, ` -UPDATE channels -SET top_message_id = $2, - pts = $2, - updated_at = now() -WHERE id = $1`, channelID, total+1); err != nil { - t.Fatalf("update channel bulk top: %v", err) - } - - first, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ - UserID: owner.ID, - ChannelID: channelID, - MaxID: int(^uint(0) >> 1), - ForEveryone: true, - Date: 1700000700, - }) - if err != nil { - t.Fatalf("DeleteChannelHistory first batch: %v", err) - } - wantFirstPts := total + 1 + domain.MaxDeleteHistoryBatch - if first.Offset != 1 || first.Event.Pts != wantFirstPts || first.Event.PtsCount != domain.MaxDeleteHistoryBatch || len(first.DeletedIDs) != domain.MaxDeleteHistoryBatch { - t.Fatalf("first batch = %+v, want offset=1 pts=%d pts_count=%d", first, wantFirstPts, domain.MaxDeleteHistoryBatch) - } - var remaining int - if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&remaining); err != nil { - t.Fatalf("count remaining after first batch: %v", err) - } - if remaining != 3 { - t.Fatalf("remaining after first batch = %d, want create service + two oldest messages", remaining) - } - - second, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ - UserID: owner.ID, - ChannelID: channelID, - MaxID: int(^uint(0) >> 1), - ForEveryone: true, - Date: 1700000701, - }) - if err != nil { - t.Fatalf("DeleteChannelHistory second batch: %v", err) - } - if second.Offset != 0 || second.Event.Pts != wantFirstPts+3 || second.Event.PtsCount != 3 || len(second.DeletedIDs) != 3 { - t.Fatalf("second batch = %+v, want final offset=0 pts=%d pts_count=3", second, wantFirstPts+3) - } -} - -func TestChannelStoreCommonChannelsOnlySharedMegagroups(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{AccessHash: 51, Phone: "+1888" + suffix + "01", FirstName: "CommonOwner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{AccessHash: 52, Phone: "+1888" + suffix + "02", FirstName: "CommonFriend"}) - if err != nil { - t.Fatalf("create friend: %v", err) - } - other, err := users.Create(ctx, domain.User{AccessHash: 53, Phone: "+1888" + suffix + "03", FirstName: "CommonOther"}) - if err != nil { - t.Fatalf("create other: %v", err) - } - var channelIDs []int64 - t.Cleanup(func() { - if len(channelIDs) != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, other.ID}) - }) - - channels := NewChannelStore(pool) - create := func(title string, broadcast bool, memberIDs []int64, date int) domain.CreateChannelResult { - t.Helper() - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: title, - Broadcast: broadcast, - Megagroup: !broadcast, - MemberUserIDs: memberIDs, - Date: date, - }) - if err != nil { - t.Fatalf("create %s: %v", title, err) - } - channelIDs = append(channelIDs, created.Channel.ID) - return created - } - first := create("common one "+suffix, false, []int64{friend.ID}, 1700000800) - second := create("common two "+suffix, false, []int64{friend.ID}, 1700000801) - create("broadcast excluded "+suffix, true, []int64{friend.ID}, 1700000802) - left := create("left excluded "+suffix, false, []int64{friend.ID}, 1700000803) - if _, err := channels.LeaveChannel(ctx, left.Channel.ID, friend.ID, 1700000804); err != nil { - t.Fatalf("leave channel: %v", err) - } - create("not shared "+suffix, false, []int64{other.ID}, 1700000805) - - page, err := channels.ListCommonChannels(ctx, domain.CommonChannelsRequest{ - UserID: owner.ID, - TargetUserID: friend.ID, - Limit: 10, - }) - if err != nil { - t.Fatalf("list common channels: %v", err) - } - if page.Count != 2 || len(page.Channels) != 2 || page.Channels[0].ID != first.Channel.ID || page.Channels[1].ID != second.Channel.ID { - t.Fatalf("common channels = %+v, want two shared megagroups in id order", page) - } - - next, err := channels.ListCommonChannels(ctx, domain.CommonChannelsRequest{ - UserID: owner.ID, - TargetUserID: friend.ID, - MaxID: first.Channel.ID, - Limit: 1, - }) - if err != nil { - t.Fatalf("list common channels after max id: %v", err) - } - if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].ID != second.Channel.ID { - t.Fatalf("paged common channels = %+v, want second channel with full count", next) - } - - countOnly, err := channels.ListCommonChannels(ctx, domain.CommonChannelsRequest{ - UserID: owner.ID, - TargetUserID: friend.ID, - CountOnly: true, - }) - if err != nil { - t.Fatalf("count common channels: %v", err) - } - if countOnly.Count != 2 || len(countOnly.Channels) != 0 { - t.Fatalf("count-only common channels = %+v, want count without channels", countOnly) - } -} - -func TestChannelStoreLeftChannelsReturnsPagedLeftMemberships(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1889" + suffix + "01", FirstName: "LeftOwner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1889" + suffix + "02", FirstName: "LeftFriend"}) - if err != nil { - t.Fatalf("create friend: %v", err) - } - var channelIDs []int64 - t.Cleanup(func() { - if len(channelIDs) != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) - }) - - channels := NewChannelStore(pool) - create := func(title string, broadcast bool, date int) domain.CreateChannelResult { - t.Helper() - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: title, - Broadcast: broadcast, - Megagroup: !broadcast, - MemberUserIDs: []int64{friend.ID}, - Date: date, - }) - if err != nil { - t.Fatalf("create %s: %v", title, err) - } - channelIDs = append(channelIDs, created.Channel.ID) - return created - } - older := create("older left "+suffix, false, 1700000810) - newer := create("newer left "+suffix, true, 1700000811) - create("active excluded "+suffix, false, 1700000812) - if _, err := channels.LeaveChannel(ctx, older.Channel.ID, friend.ID, 1700000813); err != nil { - t.Fatalf("leave older channel: %v", err) - } - if _, err := channels.LeaveChannel(ctx, newer.Channel.ID, friend.ID, 1700000814); err != nil { - t.Fatalf("leave newer channel: %v", err) - } - - page, err := channels.ListLeftChannels(ctx, friend.ID, 0, 1) - if err != nil { - t.Fatalf("list left channels: %v", err) - } - if page.Count != 2 || len(page.Channels) != 1 || page.Channels[0].Channel.ID != newer.Channel.ID || page.Channels[0].Self.Status != domain.ChannelMemberLeft { - t.Fatalf("first left page = %+v, want newest left channel and full count", page) - } - next, err := channels.ListLeftChannels(ctx, friend.ID, 1, 1) - if err != nil { - t.Fatalf("list next left channels: %v", err) - } - if next.Count != 2 || len(next.Channels) != 1 || next.Channels[0].Channel.ID != older.Channel.ID { - t.Fatalf("second left page = %+v, want older left channel", next) - } - empty, err := channels.ListLeftChannels(ctx, friend.ID, 2, 1) - if err != nil { - t.Fatalf("list empty left page: %v", err) - } - if empty.Count != 2 || len(empty.Channels) != 0 { - t.Fatalf("empty left page = %+v, want full count and no chats", empty) - } - if _, err := channels.ListLeftChannels(ctx, friend.ID, domain.MaxLeftChannelsOffset+1, 1); !errors.Is(err, domain.ErrChannelInvalid) { - t.Fatalf("huge offset err = %v, want ErrChannelInvalid", err) - } -} - -func TestChannelStoreDiscussionGroupLinksAreBidirectional(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1890" + suffix + "01", FirstName: "DiscussionOwner"}) - if err != nil { - t.Fatalf("create owner: %v", err) - } - var channelIDs []int64 - t.Cleanup(func() { - if len(channelIDs) != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) - }) - - channels := NewChannelStore(pool) - create := func(title string, broadcast bool, date int) domain.CreateChannelResult { - t.Helper() - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: title, - Broadcast: broadcast, - Megagroup: !broadcast, - Date: date, - }) - if err != nil { - t.Fatalf("create %s: %v", title, err) - } - channelIDs = append(channelIDs, created.Channel.ID) - return created - } - broadcast := create("discussion broadcast "+suffix, true, 1700000820) - firstGroup := create("discussion first "+suffix, false, 1700000821) - secondGroup := create("discussion second "+suffix, false, 1700000822) - - candidates, err := channels.ListDiscussionGroups(ctx, owner.ID, 10) - if err != nil { - t.Fatalf("list discussion groups: %v", err) - } - if len(candidates) < 2 || candidates[0].ID != secondGroup.Channel.ID || candidates[1].ID != firstGroup.Channel.ID { - t.Fatalf("discussion groups = %+v, want newest creator megagroups", candidates) - } - - linked, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, firstGroup.Channel.ID) - if err != nil { - t.Fatalf("link first discussion group: %v", err) - } - if len(linked.Channels) != 2 { - t.Fatalf("linked changed channels = %+v, want broadcast and group", linked.Channels) - } - gotBroadcast, err := channels.GetChannelByID(ctx, broadcast.Channel.ID) - if err != nil { - t.Fatalf("get linked broadcast: %v", err) - } - gotFirst, err := channels.GetChannelByID(ctx, firstGroup.Channel.ID) - if err != nil { - t.Fatalf("get linked first group: %v", err) - } - if gotBroadcast.LinkedChatID != firstGroup.Channel.ID || gotFirst.LinkedChatID != broadcast.Channel.ID { - t.Fatalf("first link = broadcast %d group %d, want bidirectional", gotBroadcast.LinkedChatID, gotFirst.LinkedChatID) - } - - replaced, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, secondGroup.Channel.ID) - if err != nil { - t.Fatalf("replace discussion group: %v", err) - } - if len(replaced.Channels) != 3 { - t.Fatalf("replace changed channels = %+v, want broadcast old group new group", replaced.Channels) - } - gotBroadcast, _ = channels.GetChannelByID(ctx, broadcast.Channel.ID) - gotFirst, _ = channels.GetChannelByID(ctx, firstGroup.Channel.ID) - gotSecond, err := channels.GetChannelByID(ctx, secondGroup.Channel.ID) - if err != nil { - t.Fatalf("get linked second group: %v", err) - } - if gotBroadcast.LinkedChatID != secondGroup.Channel.ID || gotSecond.LinkedChatID != broadcast.Channel.ID || gotFirst.LinkedChatID != 0 { - t.Fatalf("replace link = broadcast %d first %d second %d, want old cleared and new bidirectional", - gotBroadcast.LinkedChatID, gotFirst.LinkedChatID, gotSecond.LinkedChatID) - } - - if _, err := channels.SetDiscussionGroup(ctx, owner.ID, 0, secondGroup.Channel.ID); err != nil { - t.Fatalf("unlink from group side: %v", err) - } - gotBroadcast, _ = channels.GetChannelByID(ctx, broadcast.Channel.ID) - gotSecond, _ = channels.GetChannelByID(ctx, secondGroup.Channel.ID) - if gotBroadcast.LinkedChatID != 0 || gotSecond.LinkedChatID != 0 { - t.Fatalf("unlink = broadcast %d group %d, want both cleared", gotBroadcast.LinkedChatID, gotSecond.LinkedChatID) - } - if _, err := channels.SetDiscussionGroup(ctx, owner.ID, 0, secondGroup.Channel.ID); !errors.Is(err, domain.ErrLinkNotModified) { - t.Fatalf("repeat unlink err = %v, want ErrLinkNotModified", err) - } - if _, err := channels.SetPreHistoryHidden(ctx, owner.ID, firstGroup.Channel.ID, true); err != nil { - t.Fatalf("hide first group prehistory: %v", err) - } - if _, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, firstGroup.Channel.ID); !errors.Is(err, domain.ErrMegagroupPrehistoryHidden) { - t.Fatalf("hidden prehistory err = %v, want ErrMegagroupPrehistoryHidden", err) - } -} - -func TestChannelStoreReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - users := NewUserStore(pool) - owner, err := users.Create(ctx, domain.User{ - AccessHash: 81, - Phone: "+1891" + suffix + "01", - FirstName: "ReactionOwner", - }) - if err != nil { - t.Fatalf("create owner: %v", err) - } - friend, err := users.Create(ctx, domain.User{ - AccessHash: 82, - Phone: "+1891" + suffix + "02", - FirstName: "ReactionFriend", - }) - if err != nil { - t.Fatalf("create friend: %v", err) - } - var channelID int64 - t.Cleanup(func() { - if channelID != 0 { - _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) - } - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) - }) - - channels := NewChannelStore(pool) - created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ - CreatorUserID: owner.ID, - Title: "Visible Reaction " + suffix, - Megagroup: true, - MemberUserIDs: []int64{friend.ID}, - Date: 1700000900, - }) - if err != nil { - t.Fatalf("create channel: %v", err) - } - channelID = created.Channel.ID - sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ - UserID: owner.ID, - ChannelID: channelID, - RandomID: 90_001, - Message: "react to this", - Date: 1700000901, - }) - if err != nil { - t.Fatalf("send channel message: %v", err) - } - if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ - UserID: friend.ID, - ChannelID: channelID, - MessageID: sent.Message.ID, - Reactions: []domain.MessageReaction{{ - Type: domain.MessageReactionEmoji, - Emoticon: "\U0001f525", - }}, - Date: 1700000902, - }); err != nil { - t.Fatalf("set channel reaction: %v", err) - } - dialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) - if err != nil { - t.Fatalf("get owner dialogs: %v", err) - } - if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 1 { - t.Fatalf("owner dialogs = %+v, want one unread reaction", dialogs.Dialogs) - } - unread, err := channels.ListChannelUnreadReactions(ctx, owner.ID, domain.ChannelUnreadReactionsFilter{ - ChannelID: channelID, - Limit: 10, - }) - if err != nil { - t.Fatalf("list unread reactions: %v", err) - } - if len(unread.Messages) != 1 || unread.Messages[0].ID != sent.Message.ID || unread.Messages[0].Reactions == nil || !hasUnreadChannelReactionPG(*unread.Messages[0].Reactions) { - t.Fatalf("unread reactions = %+v, want unread sent message", unread.Messages) - } - - read, err := channels.ReadChannelMessageContents(ctx, domain.ReadChannelMessageContentsRequest{ - UserID: owner.ID, - ChannelID: channelID, - IDs: []int{sent.Message.ID}, - }) - if err != nil { - t.Fatalf("read channel message contents: %v", err) - } - if !reflect.DeepEqual(read.ClearedUnreadReactionMessageIDs, []int{sent.Message.ID}) { - t.Fatalf("cleared reaction ids = %+v, want [%d]", read.ClearedUnreadReactionMessageIDs, sent.Message.ID) - } - if len(read.Messages) != 1 || read.Messages[0].Reactions == nil || hasUnreadChannelReactionPG(*read.Messages[0].Reactions) { - t.Fatalf("read messages = %+v, want returned reaction marked read", read.Messages) - } - unreadAfter, err := channels.ListChannelUnreadReactions(ctx, owner.ID, domain.ChannelUnreadReactionsFilter{ - ChannelID: channelID, - Limit: 10, - }) - if err != nil { - t.Fatalf("list unread reactions after read contents: %v", err) - } - if len(unreadAfter.Messages) != 0 { - t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter.Messages) - } - dialogsAfter, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) - if err != nil { - t.Fatalf("get owner dialogs after read contents: %v", err) - } - if len(dialogsAfter.Dialogs) != 1 || dialogsAfter.Dialogs[0].UnreadReactions != 0 { - t.Fatalf("owner dialogs after read contents = %+v, want unread reactions 0", dialogsAfter.Dialogs) - } - var stillUnread bool - if err := pool.QueryRow(ctx, ` -SELECT unread -FROM channel_message_reactions -WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, - channelID, sent.Message.ID, friend.ID).Scan(&stillUnread); err != nil { - t.Fatalf("read reaction row: %v", err) - } - if stillUnread { - t.Fatal("reaction row still unread after read contents") - } -} - -func hasUnreadChannelReactionPG(reactions domain.ChannelMessageReactions) bool { - for _, recent := range reactions.Recent { - if recent.Unread { - return true - } - } - return false -} diff --git a/internal/store/postgres/channel_invite_exported.go b/internal/store/postgres/channel_invite_exported.go new file mode 100644 index 00000000..67b9bb97 --- /dev/null +++ b/internal/store/postgres/channel_invite_exported.go @@ -0,0 +1,583 @@ +package postgres + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "strings" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *ChannelStore) ExportInvite(ctx context.Context, req domain.ExportChannelInviteRequest) (domain.ExportChannelInviteResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ExportChannelInviteResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ExportChannelInviteResult{}, fmt.Errorf("export channel invite: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ExportChannelInviteResult{}, fmt.Errorf("begin export channel invite: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + if !canExportChannelInvite(member) { + return domain.ExportChannelInviteResult{}, domain.ErrChannelAdminRequired + } + if req.LegacyRevokePermanent { + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET revoked = true, updated_at = now() +WHERE channel_id = $1 AND admin_user_id = $2 AND permanent AND NOT revoked`, req.ChannelID, req.UserID); err != nil { + return domain.ExportChannelInviteResult{}, fmt.Errorf("revoke permanent channel invite: %w", err) + } + } + inviteID, err := randomPositiveInt64() + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + hash, err := randomInviteHash() + if err != nil { + return domain.ExportChannelInviteResult{}, err + } + invite := domain.ChannelInvite{ + ChannelID: req.ChannelID, + InviteID: inviteID, + Hash: hash, + AdminUserID: req.UserID, + Title: req.Title, + Permanent: req.ExpireDate == 0 && req.UsageLimit == 0 && !req.RequestNeeded && req.Title == "", + RequestNeeded: req.RequestNeeded, + ExpireDate: req.ExpireDate, + UsageLimit: req.UsageLimit, + Date: req.Date, + } + if err := insertChannelInviteTx(ctx, tx, invite); err != nil { + return domain.ExportChannelInviteResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ExportChannelInviteResult{}, fmt.Errorf("commit export channel invite: %w", err) + } + committed = true + return domain.ExportChannelInviteResult{Channel: channel, Invite: invite}, nil +} + +// EnsurePermanentInvite 幂等返回 (channel, admin) 当前未撤销的永久邀请;缺失则创建。 +// advisory lock 串行化同频道并发 ensure,防止重复主链接。 +func (s *ChannelStore) EnsurePermanentInvite(ctx context.Context, channelID, adminUserID int64, date int) (domain.ChannelInvite, error) { + if channelID == 0 || adminUserID == 0 { + return domain.ChannelInvite{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ChannelInvite{}, fmt.Errorf("ensure permanent channel invite: db does not support transactions") + } + if date == 0 { + date = nowUnix() + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ChannelInvite{}, fmt.Errorf("begin ensure permanent channel invite: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, channelID); err != nil { + return domain.ChannelInvite{}, fmt.Errorf("lock ensure permanent channel invite: %w", err) + } + _, member, err := s.getChannelForMember(ctx, tx, adminUserID, channelID) + if err != nil { + return domain.ChannelInvite{}, err + } + if !canExportChannelInvite(member) { + return domain.ChannelInvite{}, domain.ErrChannelAdminRequired + } + rows, err := tx.Query(ctx, ` +SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, + COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, + EXTRACT(EPOCH FROM created_at)::int +FROM channel_invites +WHERE channel_id = $1 AND admin_user_id = $2 AND permanent AND NOT revoked +ORDER BY EXTRACT(EPOCH FROM created_at)::int ASC, hash ASC +LIMIT 1`, channelID, adminUserID) + if err != nil { + return domain.ChannelInvite{}, err + } + var existing *domain.ChannelInvite + for rows.Next() { + invite, err := scanChannelInvite(rows) + if err != nil { + rows.Close() + return domain.ChannelInvite{}, err + } + existing = &invite + } + rows.Close() + if err := rows.Err(); err != nil { + return domain.ChannelInvite{}, err + } + if existing != nil { + if err := tx.Commit(ctx); err != nil { + return domain.ChannelInvite{}, fmt.Errorf("commit ensure permanent channel invite: %w", err) + } + committed = true + return *existing, nil + } + inviteID, err := randomPositiveInt64() + if err != nil { + return domain.ChannelInvite{}, err + } + hash, err := randomInviteHash() + if err != nil { + return domain.ChannelInvite{}, err + } + invite := domain.ChannelInvite{ + ChannelID: channelID, + InviteID: inviteID, + Hash: hash, + AdminUserID: adminUserID, + Permanent: true, + Date: date, + } + if err := insertChannelInviteTx(ctx, tx, invite); err != nil { + return domain.ChannelInvite{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ChannelInvite{}, fmt.Errorf("commit ensure permanent channel invite: %w", err) + } + committed = true + return invite, nil +} + +func (s *ChannelStore) ListExportedInvites(ctx context.Context, req domain.ChannelInviteListRequest) (domain.ChannelInviteList, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { + return domain.ChannelInviteList{}, domain.ErrChannelInvalid + } + _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelInviteList{}, err + } + if !canExportChannelInvite(member) { + return domain.ChannelInviteList{}, domain.ErrChannelAdminRequired + } + var total int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_invites +WHERE channel_id = $1 AND admin_user_id = $2 AND revoked = $3`, req.ChannelID, req.AdminUserID, req.Revoked).Scan(&total); err != nil { + return domain.ChannelInviteList{}, err + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelInviteListLimit { + limit = domain.MaxChannelInviteListLimit + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, + COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, + EXTRACT(EPOCH FROM created_at)::int +FROM channel_invites +WHERE channel_id = $1 + AND admin_user_id = $2 + AND revoked = $3 + AND (($4::int = 0 AND $5::text = '') OR (EXTRACT(EPOCH FROM created_at)::int, hash) < ($4, $5)) +ORDER BY EXTRACT(EPOCH FROM created_at)::int DESC, hash DESC +LIMIT $6`, req.ChannelID, req.AdminUserID, req.Revoked, req.OffsetDate, req.OffsetHash, limit) + if err != nil { + return domain.ChannelInviteList{}, err + } + defer rows.Close() + invites := make([]domain.ChannelInvite, 0, limit) + for rows.Next() { + invite, err := scanChannelInvite(rows) + if err != nil { + return domain.ChannelInviteList{}, err + } + invites = append(invites, invite) + } + if err := rows.Err(); err != nil { + return domain.ChannelInviteList{}, err + } + return domain.ChannelInviteList{Count: total, Invites: invites}, nil +} + +func (s *ChannelStore) getPermanentInviteForAdmin(ctx context.Context, db sqlcgen.DBTX, channelID, adminUserID int64) (domain.ChannelInvite, bool, error) { + row := db.QueryRow(ctx, ` +SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, + COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, + EXTRACT(EPOCH FROM created_at)::int +FROM channel_invites +WHERE channel_id = $1 AND admin_user_id = $2 AND permanent AND NOT revoked +ORDER BY EXTRACT(EPOCH FROM created_at)::int ASC, hash ASC +LIMIT 1`, channelID, adminUserID) + invite, err := scanChannelInvite(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelInvite{}, false, nil + } + if err != nil { + return domain.ChannelInvite{}, false, err + } + return invite, true, nil +} + +func (s *ChannelStore) GetExportedInvite(ctx context.Context, req domain.GetChannelInviteRequest) (domain.ChannelInvite, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.ChannelInvite{}, domain.ErrInviteHashEmpty + } + _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelInvite{}, err + } + if !canExportChannelInvite(member) { + return domain.ChannelInvite{}, domain.ErrChannelAdminRequired + } + return s.getInviteByChannelHash(ctx, s.db, req.ChannelID, req.Hash, false) +} + +func (s *ChannelStore) EditExportedInvite(ctx context.Context, req domain.EditChannelInviteRequest) (domain.EditChannelInviteResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.EditChannelInviteResult{}, domain.ErrInviteHashEmpty + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelInviteResult{}, fmt.Errorf("edit channel invite: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.EditChannelInviteResult{}, fmt.Errorf("begin edit channel invite: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if _, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID); err != nil { + return domain.EditChannelInviteResult{}, err + } else if !canExportChannelInvite(member) { + return domain.EditChannelInviteResult{}, domain.ErrChannelAdminRequired + } + invite, err := s.getInviteByChannelHash(ctx, tx, req.ChannelID, req.Hash, true) + if err != nil { + return domain.EditChannelInviteResult{}, err + } + if req.Revoked { + if invite.Revoked { + return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing + } + if _, err := tx.Exec(ctx, `UPDATE channel_invites SET revoked = true, updated_at = now() WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { + return domain.EditChannelInviteResult{}, fmt.Errorf("revoke channel invite: %w", err) + } + invite.Revoked = true + result := domain.EditChannelInviteResult{Invite: invite} + if invite.Permanent { + newInvite, err := s.newPostgresReplacementInvite(invite, req.Date) + if err != nil { + return domain.EditChannelInviteResult{}, err + } + if err := insertChannelInviteTx(ctx, tx, newInvite); err != nil { + return domain.EditChannelInviteResult{}, err + } + result.NewInvite = &newInvite + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelInviteResult{}, fmt.Errorf("commit edit channel invite: %w", err) + } + committed = true + return result, nil + } + if invite.Revoked { + return domain.EditChannelInviteResult{}, domain.ErrInviteRevokedMissing + } + if invite.Permanent && ((req.HasExpireDate && req.ExpireDate > 0) || (req.HasUsageLimit && req.UsageLimit > 0) || (req.HasRequestNeeded && req.RequestNeeded)) { + return domain.EditChannelInviteResult{}, domain.ErrInvitePermanent + } + if req.HasExpireDate { + invite.ExpireDate = req.ExpireDate + } + if req.HasUsageLimit { + invite.UsageLimit = req.UsageLimit + } + if req.HasRequestNeeded { + invite.RequestNeeded = req.RequestNeeded + } + if req.HasTitle { + invite.Title = req.Title + } + invite.Permanent = invite.ExpireDate == 0 && invite.UsageLimit == 0 && !invite.RequestNeeded && invite.Title == "" + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET title = $3, + expire_date = NULLIF($4, 0), + usage_limit = NULLIF($5, 0), + request_needed = $6, + permanent = $7, + updated_at = now() +WHERE channel_id = $1 AND invite_id = $2`, + invite.ChannelID, invite.InviteID, invite.Title, invite.ExpireDate, invite.UsageLimit, invite.RequestNeeded, invite.Permanent); err != nil { + return domain.EditChannelInviteResult{}, fmt.Errorf("update channel invite: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelInviteResult{}, fmt.Errorf("commit edit channel invite: %w", err) + } + committed = true + return domain.EditChannelInviteResult{Invite: invite}, nil +} + +func (s *ChannelStore) DeleteExportedInvite(ctx context.Context, req domain.DeleteChannelInviteRequest) error { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.ErrInviteHashEmpty + } + if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { + return err + } else if !canExportChannelInvite(member) { + return domain.ErrChannelAdminRequired + } + tag, err := s.db.Exec(ctx, ` +WITH deleted AS ( + DELETE FROM channel_invites + WHERE channel_id = $1 AND hash = $2 + RETURNING hash +) +DELETE FROM channel_invite_hashes h USING deleted d WHERE h.hash = d.hash`, req.ChannelID, strings.TrimSpace(req.Hash)) + if err != nil { + return fmt.Errorf("delete channel invite: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrInviteRevokedMissing + } + return nil +} + +func (s *ChannelStore) DeleteRevokedExportedInvites(ctx context.Context, req domain.DeleteRevokedChannelInvitesRequest) error { + if req.UserID == 0 || req.ChannelID == 0 || req.AdminUserID == 0 { + return domain.ErrChannelInvalid + } + if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { + return err + } else if !canExportChannelInvite(member) { + return domain.ErrChannelAdminRequired + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { + limit = domain.MaxChannelHideJoinRequests + } + // channel_invites 是普通非分区表;先在带 channel_id 的子查询里按主键 (channel_id, invite_id) + // 选出 LIMIT 条待删的已撤销邀请,再按主键删除并级联清理 channel_invite_hashes。 + if _, err := s.db.Exec(ctx, ` +WITH victims AS ( + SELECT channel_id, invite_id FROM channel_invites + WHERE channel_id = $1 AND admin_user_id = $2 AND revoked + ORDER BY updated_at ASC + LIMIT $3 +), deleted AS ( + DELETE FROM channel_invites ci + USING victims v + WHERE ci.channel_id = v.channel_id AND ci.invite_id = v.invite_id + RETURNING ci.hash +) +DELETE FROM channel_invite_hashes h USING deleted d WHERE h.hash = d.hash`, req.ChannelID, req.AdminUserID, limit); err != nil { + return fmt.Errorf("delete revoked channel invites: %w", err) + } + return nil +} + +func (s *ChannelStore) ListAdminsWithInvites(ctx context.Context, userID, channelID int64) ([]domain.ChannelAdminInviteCount, error) { + if userID == 0 || channelID == 0 { + return nil, domain.ErrChannelInvalid + } + if _, member, err := s.getChannelForMember(ctx, s.db, userID, channelID); err != nil { + return nil, err + } else if !canExportChannelInvite(member) { + return nil, domain.ErrChannelAdminRequired + } + rows, err := s.db.Query(ctx, ` +SELECT admin_user_id, + COUNT(*) FILTER (WHERE NOT revoked)::int, + COUNT(*) FILTER (WHERE revoked)::int +FROM channel_invites +WHERE channel_id = $1 +GROUP BY admin_user_id +ORDER BY admin_user_id ASC`, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]domain.ChannelAdminInviteCount, 0) + for rows.Next() { + var count domain.ChannelAdminInviteCount + if err := rows.Scan(&count.AdminUserID, &count.InvitesCount, &count.RevokedInvitesCount); err != nil { + return nil, err + } + out = append(out, count) + } + return out, rows.Err() +} + +func insertChannelInviteTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite) error { + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_invites ( + channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, + expire_date, usage_limit, usage_count, requested_count, created_at, updated_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NULLIF($9,0),NULLIF($10,0),$11,$12,to_timestamp($13),to_timestamp($13))`, + invite.ChannelID, invite.InviteID, invite.Hash, invite.AdminUserID, invite.Title, + invite.Permanent, invite.Revoked, invite.RequestNeeded, invite.ExpireDate, + invite.UsageLimit, invite.UsageCount, invite.RequestedCount, invite.Date); err != nil { + return fmt.Errorf("insert channel invite: %w", err) + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_invite_hashes (hash, channel_id, invite_id) +VALUES ($1,$2,$3) +ON CONFLICT (hash) DO UPDATE SET channel_id = EXCLUDED.channel_id, invite_id = EXCLUDED.invite_id, updated_at = now()`, + invite.Hash, invite.ChannelID, invite.InviteID); err != nil { + return fmt.Errorf("insert channel invite hash: %w", err) + } + return nil +} + +func (s *ChannelStore) getInviteByHash(ctx context.Context, db sqlcgen.DBTX, hash string) (domain.Channel, domain.ChannelInvite, error) { + return s.getInviteByHashLocked(ctx, db, hash, false) +} + +func (s *ChannelStore) getInviteByHashForUpdate(ctx context.Context, tx pgx.Tx, hash string) (domain.Channel, domain.ChannelInvite, error) { + return s.getInviteByHashLocked(ctx, tx, hash, true) +} + +func (s *ChannelStore) getInviteByHashLocked(ctx context.Context, db sqlcgen.DBTX, hash string, forUpdate bool) (domain.Channel, domain.ChannelInvite, error) { + lockClause := "" + if forUpdate { + lockClause = " FOR UPDATE OF i" + } + row := db.QueryRow(ctx, ` +SELECT `+channelColumns+`, + i.channel_id, i.invite_id, i.hash, i.admin_user_id, i.title, i.permanent, i.revoked, i.request_needed, + COALESCE(i.expire_date, 0), COALESCE(i.usage_limit, 0), i.usage_count, i.requested_count, EXTRACT(EPOCH FROM i.created_at)::int +FROM channel_invite_hashes h +JOIN channel_invites i ON i.channel_id = h.channel_id AND i.invite_id = h.invite_id +JOIN channels c ON c.id = i.channel_id AND NOT c.deleted +WHERE h.hash = $1 AND NOT i.revoked`+lockClause, hash) + var ch domain.Channel + var invite domain.ChannelInvite + var rights, reactionPolicy string + var wallpaper *string + dest := append(channelScanDest(&ch, &rights, &reactionPolicy, &wallpaper), + &invite.ChannelID, &invite.InviteID, &invite.Hash, &invite.AdminUserID, &invite.Title, + &invite.Permanent, &invite.Revoked, &invite.RequestNeeded, &invite.ExpireDate, + &invite.UsageLimit, &invite.UsageCount, &invite.RequestedCount, &invite.Date, + ) + if err := row.Scan(dest...); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Channel{}, domain.ChannelInvite{}, domain.ErrInviteHashInvalid + } + return domain.Channel{}, domain.ChannelInvite{}, err + } + finishChannelScan(&ch, rights, reactionPolicy, wallpaper) + return ch, invite, nil +} + +func (s *ChannelStore) getInviteByChannelHash(ctx context.Context, db sqlcgen.DBTX, channelID int64, hash string, forUpdate bool) (domain.ChannelInvite, error) { + lockClause := "" + if forUpdate { + lockClause = " FOR UPDATE" + } + row := db.QueryRow(ctx, ` +SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, + COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, + EXTRACT(EPOCH FROM created_at)::int +FROM channel_invites +WHERE channel_id = $1 AND hash = $2`+lockClause, channelID, strings.TrimSpace(hash)) + invite, err := scanChannelInvite(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelInvite{}, domain.ErrInviteHashInvalid + } + return invite, err +} + +func (s *ChannelStore) getInviteByID(ctx context.Context, db sqlcgen.DBTX, channelID, inviteID int64, forUpdate bool) (domain.ChannelInvite, error) { + lockClause := "" + if forUpdate { + lockClause = " FOR UPDATE" + } + row := db.QueryRow(ctx, ` +SELECT channel_id, invite_id, hash, admin_user_id, title, permanent, revoked, request_needed, + COALESCE(expire_date, 0), COALESCE(usage_limit, 0), usage_count, requested_count, + EXTRACT(EPOCH FROM created_at)::int +FROM channel_invites +WHERE channel_id = $1 AND invite_id = $2`+lockClause, channelID, inviteID) + invite, err := scanChannelInvite(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelInvite{}, domain.ErrInviteHashInvalid + } + return invite, err +} + +func scanChannelInvite(row rowScanner) (domain.ChannelInvite, error) { + var invite domain.ChannelInvite + err := row.Scan( + &invite.ChannelID, + &invite.InviteID, + &invite.Hash, + &invite.AdminUserID, + &invite.Title, + &invite.Permanent, + &invite.Revoked, + &invite.RequestNeeded, + &invite.ExpireDate, + &invite.UsageLimit, + &invite.UsageCount, + &invite.RequestedCount, + &invite.Date, + ) + return invite, err +} + +func (s *ChannelStore) newPostgresReplacementInvite(old domain.ChannelInvite, date int) (domain.ChannelInvite, error) { + inviteID, err := randomPositiveInt64() + if err != nil { + return domain.ChannelInvite{}, err + } + hash, err := randomInviteHash() + if err != nil { + return domain.ChannelInvite{}, err + } + if date == 0 { + date = nowUnix() + } + return domain.ChannelInvite{ + ChannelID: old.ChannelID, + InviteID: inviteID, + Hash: hash, + AdminUserID: old.AdminUserID, + Permanent: old.Permanent, + Date: date, + }, nil +} + +func randomInviteHash() (string, error) { + var b [18]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("rand invite hash: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} diff --git a/internal/store/postgres/channel_invite_helpers.go b/internal/store/postgres/channel_invite_helpers.go new file mode 100644 index 00000000..7bba40d0 --- /dev/null +++ b/internal/store/postgres/channel_invite_helpers.go @@ -0,0 +1,10 @@ +package postgres + +import ( + "telesrv/internal/domain" +) + +func canExportChannelInvite(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) +} diff --git a/internal/store/postgres/channel_invite_import.go b/internal/store/postgres/channel_invite_import.go new file mode 100644 index 00000000..8bc9cfdf --- /dev/null +++ b/internal/store/postgres/channel_invite_import.go @@ -0,0 +1,282 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) CheckInvite(ctx context.Context, userID int64, hash string, date int) (domain.CheckChannelInviteResult, error) { + if userID == 0 || strings.TrimSpace(hash) == "" { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashEmpty + } + if date == 0 { + date = nowUnix() + } + channel, invite, err := s.getInviteByHash(ctx, s.db, strings.TrimSpace(hash)) + if err != nil { + return domain.CheckChannelInviteResult{}, err + } + if invite.ExpireDate > 0 && invite.ExpireDate < date { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashExpired + } + member, err := s.getChannelMember(ctx, s.db, channel.ID, userID) + already := false + if err == nil { + if member.Status == domain.ChannelMemberKicked || member.Status == domain.ChannelMemberBanned || member.BannedRights.ViewMessages { + return domain.CheckChannelInviteResult{}, domain.ErrInviteHashInvalid + } + already = member.Status == domain.ChannelMemberActive + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.CheckChannelInviteResult{}, err + } + return domain.CheckChannelInviteResult{Channel: channel, Invite: invite, Already: already, Self: member}, nil +} + +func (s *ChannelStore) ImportInvite(ctx context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" { + return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("import channel invite: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin import channel invite: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, invite, err := s.getInviteByHashForUpdate(ctx, tx, strings.TrimSpace(req.Hash)) + if err != nil { + return domain.CreateChannelResult{}, err + } + if invite.ExpireDate > 0 && invite.ExpireDate < req.Date { + return domain.CreateChannelResult{}, domain.ErrInviteHashExpired + } + if invite.RequestNeeded { + if err := s.recordPendingInviteImporterTx(ctx, tx, invite, req.UserID, req.Date); err != nil { + return domain.CreateChannelResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit pending channel invite request: %w", err) + } + committed = true + return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent + } + result, err := s.approveInviteImporterTx(ctx, tx, channel, invite, req.UserID, 0, req.Date) + if err != nil { + return domain.CreateChannelResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit import channel invite: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, result.Channel.ID, 0) + result.Recipients = recipients + return result, nil +} + +func (s *ChannelStore) approveInviteImporterTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, invite domain.ChannelInvite, userID, approvedBy int64, date int) (domain.CreateChannelResult, error) { + if invite.InviteID != 0 && invite.UsageLimit > 0 && invite.UsageCount >= invite.UsageLimit { + return domain.CreateChannelResult{}, domain.ErrUsersTooMuch + } + channelID := channel.ID + if channelID == 0 { + channelID = invite.ChannelID + } + if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { + if existing.Status == domain.ChannelMemberActive { + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.CreateChannelResult{}, domain.ErrInviteHashInvalid + } + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.CreateChannelResult{}, err + } + preJoinTopID := channel.TopMessageID + minID := channelInitialAvailableMinID(channel) + inviterID := invite.AdminUserID + if inviterID == 0 { + inviterID = approvedBy + } + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: inviterID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + AvailableMinID: minID, + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: maxInt(minID, preJoinTopID), + } + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.CreateChannelResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantJoin, + }); err != nil { + return domain.CreateChannelResult{}, err + } + if invite.InviteID != 0 { + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET requested_count = GREATEST(requested_count - 1, 0), + updated_at = now() +WHERE channel_id = $1 + AND invite_id = ( + SELECT invite_id + FROM channel_invite_importers + WHERE channel_id = $1 AND user_id = $2 AND requested + )`, channelID, userID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("clear pending channel invite request: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET usage_count = usage_count + 1, + updated_at = now() +WHERE channel_id = $1 AND invite_id = $2`, channelID, invite.InviteID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("increment channel invite usage: %w", err) + } + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested, approved_by) +VALUES ($1, $2, $3, $4, false, $5) +ON CONFLICT (channel_id, user_id) DO UPDATE +SET invite_id = EXCLUDED.invite_id, + date = EXCLUDED.date, + requested = false, + approved_by = EXCLUDED.approved_by, + updated_at = now()`, channelID, invite.InviteID, userID, date, approvedBy); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("upsert channel invite importer: %w", err) + } + channel, err := refreshChannelCountsTx(ctx, tx, channel) + if err != nil { + return domain.CreateChannelResult{}, err + } + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatJoinedByLink, + InviterUserID: invite.AdminUserID, + UserIDs: []int64{userID}, + }) + if err != nil { + return domain.CreateChannelResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + } + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) + if msg.ID != 0 { + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET read_inbox_max_id = $3, read_outbox_max_id = $4, updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, channel.ID, userID, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("update imported member read state: %w", err) + } + if err := upsertChannelDialogTx(ctx, tx, userID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.CreateChannelResult{}, err + } + // 经邀请链接重新入群同样是重进:按新 available_min_id 重算未读 reaction 计数, + // 清掉离群期间残留的幽灵角标(同 JoinChannel,门控自动对 prehistory-visible 保留真实计数)。 + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channel.ID); err != nil { + return domain.CreateChannelResult{}, err + } + return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event}, nil +} + +func (s *ChannelStore) recordPendingInviteImporterTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite, userID int64, date int) error { + if existing, err := s.getChannelMember(ctx, tx, invite.ChannelID, userID); err == nil { + if existing.Status == domain.ChannelMemberActive { + return domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.ErrInviteHashInvalid + } + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return err + } + tag, err := tx.Exec(ctx, ` +INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested) +VALUES ($1, $2, $3, $4, true) +ON CONFLICT (channel_id, user_id) DO UPDATE +SET invite_id = EXCLUDED.invite_id, + date = EXCLUDED.date, + requested = true, + approved_by = 0, + updated_at = now() +WHERE NOT channel_invite_importers.requested`, + invite.ChannelID, invite.InviteID, userID, date) + if err != nil { + return fmt.Errorf("record pending channel invite importer: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrInviteRequestSent + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET requested_count = requested_count + 1, updated_at = now() +WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { + return fmt.Errorf("increment channel invite requested count: %w", err) + } + return nil +} + +func (s *ChannelStore) getPendingInviteImporterTx(ctx context.Context, tx pgx.Tx, channelID, userID int64, forUpdate bool) (domain.ChannelInviteImporter, error) { + lockClause := "" + if forUpdate { + lockClause = " FOR UPDATE" + } + row := tx.QueryRow(ctx, ` +SELECT channel_id, invite_id, user_id, date, requested, approved_by, via_chatlist, about +FROM channel_invite_importers +WHERE channel_id = $1 AND user_id = $2 AND requested`+lockClause, channelID, userID) + var importer domain.ChannelInviteImporter + err := row.Scan(&importer.ChannelID, &importer.InviteID, &importer.UserID, &importer.Date, &importer.Requested, &importer.ApprovedBy, &importer.ViaChatlist, &importer.About) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelInviteImporter{}, domain.ErrHideRequesterMissing + } + return importer, err +} + +func deletePendingInviteImporterTx(ctx context.Context, tx pgx.Tx, invite domain.ChannelInvite, userID int64) error { + tag, err := tx.Exec(ctx, ` +DELETE FROM channel_invite_importers +WHERE channel_id = $1 AND user_id = $2 AND requested`, invite.ChannelID, userID) + if err != nil { + return fmt.Errorf("delete pending channel invite importer: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrHideRequesterMissing + } + if invite.InviteID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_invites +SET requested_count = CASE WHEN requested_count > 0 THEN requested_count - 1 ELSE 0 END, + updated_at = now() +WHERE channel_id = $1 AND invite_id = $2`, invite.ChannelID, invite.InviteID); err != nil { + return fmt.Errorf("decrement channel invite requested count: %w", err) + } + return nil +} diff --git a/internal/store/postgres/channel_invite_integration_test.go b/internal/store/postgres/channel_invite_integration_test.go new file mode 100644 index 00000000..ff1db19f --- /dev/null +++ b/internal/store/postgres/channel_invite_integration_test.go @@ -0,0 +1,574 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "reflect" + "telesrv/internal/domain" + "testing" + "time" +) + +func TestChannelStoreCreateChannelCreatesPermanentInviteAndHasLink(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 30, + Phone: "+1777" + suffix + "11", + FirstName: "LinkOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Linked On Create " + suffix, + Megagroup: true, + Date: 1700000300, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if !created.Channel.HasLink { + t.Fatalf("created channel HasLink = false, want true") + } + view, err := channels.GetChannel(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("get channel: %v", err) + } + if !view.Channel.HasLink { + t.Fatalf("stored channel HasLink = false, want true") + } + if view.ExportedInvite == nil || !view.ExportedInvite.Permanent || view.ExportedInvite.Revoked || view.ExportedInvite.AdminUserID != owner.ID { + t.Fatalf("owner view exported invite = %+v, want creator permanent main link", view.ExportedInvite) + } + invites, err := channels.ListExportedInvites(ctx, domain.ChannelInviteListRequest{ + UserID: owner.ID, + ChannelID: channelID, + AdminUserID: owner.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list exported invites: %v", err) + } + if invites.Count != 1 || len(invites.Invites) != 1 || !invites.Invites[0].Permanent || invites.Invites[0].Revoked { + t.Fatalf("invites = %+v, want one active permanent main link", invites) + } +} + +func TestChannelStoreJoinRejectsKickedMember(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 33, + Phone: "+1777" + suffix + "21", + FirstName: "BanOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 34, + Phone: "+1777" + suffix + "22", + FirstName: "BanMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + helper, err := users.Create(ctx, domain.User{ + AccessHash: 35, + Phone: "+1777" + suffix + "23", + FirstName: "BanHelper", + }) + if err != nil { + t.Fatalf("create helper: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID, helper.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Ban Join " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID, helper.ID}, + Date: 1700000305, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + ptsFloor := created.Channel.Pts + banned, err := channels.EditChannelBanned(ctx, domain.EditChannelBannedRequest{ + UserID: owner.ID, + ChannelID: channelID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, + BannedRights: domain.ChannelBannedRights{ + ViewMessages: true, + UntilDate: 1700001300, + }, + Date: 1700000306, + }) + if err != nil { + t.Fatalf("kick member: %v", err) + } + // participant update 不占 channel pts,但 megagroup 踢人产生 + // "X removed Y" 服务消息占一个 pts,离线成员靠它收敛成员状态。 + if banned.Event.Pts != 0 || banned.Event.PtsCount != 0 { + t.Fatalf("kick participant event = (%d,%d), must stay transient", banned.Event.Pts, banned.Event.PtsCount) + } + if banned.ServiceEvent.Pts != ptsFloor+1 || banned.Channel.Pts != ptsFloor+1 { + t.Fatalf("kick service pts = %d channel %d, want %d", banned.ServiceEvent.Pts, banned.Channel.Pts, ptsFloor+1) + } + if banned.Message.Action == nil || banned.Message.Action.Type != domain.ChannelActionChatDelete { + t.Fatalf("kick service message = %+v, want ChatDelete action", banned.Message) + } + banDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: owner.ID, + ChannelID: channelID, + Pts: ptsFloor, + Limit: 10, + }) + if err != nil { + t.Fatalf("difference after kick: %v", err) + } + if len(banDiff.Events) != 1 || banDiff.Pts != ptsFloor+1 { + t.Fatalf("difference after kick = %+v, want only the kick service message at pts %d", banDiff, ptsFloor+1) + } + if _, err := channels.JoinChannel(ctx, channelID, member.ID, 1700000307); !errors.Is(err, domain.ErrChannelUserBanned) { + t.Fatalf("kicked JoinChannel err = %v, want ErrChannelUserBanned", err) + } + if _, err := channels.InviteToChannel(ctx, channelID, helper.ID, []int64{member.ID}, 1700000308); !errors.Is(err, domain.ErrUserKicked) { + t.Fatalf("helper InviteToChannel kicked err = %v, want ErrUserKicked", err) + } + restored, err := channels.InviteToChannel(ctx, channelID, owner.ID, []int64{member.ID}, 1700000309) + if err != nil { + t.Fatalf("owner InviteToChannel kicked: %v", err) + } + if len(restored.Members) != 1 || restored.Members[0].Status != domain.ChannelMemberActive || restored.Members[0].BannedRights != (domain.ChannelBannedRights{}) { + t.Fatalf("restored members = %+v, want active unbanned member", restored.Members) + } + if restored.Channel.ParticipantsCount != 3 || restored.Channel.KickedCount != 0 { + t.Fatalf("restored counts = participants:%d kicked:%d, want 3/0", restored.Channel.ParticipantsCount, restored.Channel.KickedCount) + } + if _, err := channels.InviteToChannel(ctx, channelID, owner.ID, []int64{member.ID}, 1700000310); !errors.Is(err, domain.ErrUserAlreadyParticipant) { + t.Fatalf("duplicate InviteToChannel err = %v, want ErrUserAlreadyParticipant", err) + } +} + +func TestChannelStoreImportInviteRequestNeededAndUsageLimitErrors(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 61, + Phone: "+1888" + suffix + "21", + FirstName: "InviteErrorOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + first, err := users.Create(ctx, domain.User{ + AccessHash: 62, + Phone: "+1888" + suffix + "22", + FirstName: "InviteErrorFirst", + }) + if err != nil { + t.Fatalf("create first: %v", err) + } + second, err := users.Create(ctx, domain.User{ + AccessHash: 63, + Phone: "+1888" + suffix + "23", + FirstName: "InviteErrorSecond", + }) + if err != nil { + t.Fatalf("create second: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, first.ID, second.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Invite Errors " + suffix, + Megagroup: true, + Date: 1700000340, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + requested, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "approval", + RequestNeeded: true, + Date: 1700000341, + }) + if err != nil { + t.Fatalf("export request-needed invite: %v", err) + } + if _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: first.ID, + Hash: requested.Invite.Hash, + Date: 1700000342, + }); !errors.Is(err, domain.ErrInviteRequestSent) { + t.Fatalf("import request-needed err = %v, want ErrInviteRequestSent", err) + } + limited, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "one", + UsageLimit: 1, + Date: 1700000343, + }) + if err != nil { + t.Fatalf("export limited invite: %v", err) + } + if _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: first.ID, + Hash: limited.Invite.Hash, + Date: 1700000344, + }); err != nil { + t.Fatalf("first import limited invite: %v", err) + } + if _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: second.ID, + Hash: limited.Invite.Hash, + Date: 1700000345, + }); !errors.Is(err, domain.ErrUsersTooMuch) { + t.Fatalf("second import limited invite err = %v, want ErrUsersTooMuch", err) + } +} + +func TestChannelStorePendingJoinRequestsSummaryAndInviteAdmins(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + userIDs := make([]int64, 0, 11) + createUser := func(label string, accessHash int64, phoneSuffix int) domain.User { + t.Helper() + user, err := users.Create(ctx, domain.User{ + AccessHash: accessHash, + Phone: fmt.Sprintf("+1889%s%02d", suffix, phoneSuffix), + FirstName: label, + }) + if err != nil { + t.Fatalf("create %s: %v", label, err) + } + userIDs = append(userIDs, user.ID) + return user + } + owner := createUser("PendingOwner", 71, 1) + inviteAdmin := createUser("PendingInviteAdmin", 72, 2) + plainMember := createUser("PendingPlainMember", 73, 3) + changeAdmin := createUser("PendingChangeAdmin", 74, 4) + requesters := make([]domain.User, 0, domain.MaxChannelPendingJoinRecentRequesters+2) + for i := 0; i < domain.MaxChannelPendingJoinRecentRequesters+2; i++ { + requesters = append(requesters, createUser("PendingRequester", int64(80+i), 10+i)) + } + + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", userIDs) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Pending Summary " + suffix, + Megagroup: true, + MemberUserIDs: []int64{inviteAdmin.ID, plainMember.ID, changeAdmin.ID}, + Date: 1700000360, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: inviteAdmin.ID, + AdminRights: domain.ChannelAdminRights{ + InviteUsers: true, + }, + Date: 1700000361, + }); err != nil { + t.Fatalf("promote invite admin: %v", err) + } + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: changeAdmin.ID, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + }, + Date: 1700000362, + }); err != nil { + t.Fatalf("promote change-info admin: %v", err) + } + invite, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "approval", + RequestNeeded: true, + Date: 1700000363, + }) + if err != nil { + t.Fatalf("export invite: %v", err) + } + for i, requester := range requesters { + _, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: requester.ID, + Hash: invite.Invite.Hash, + Date: 1700000370 + i, + }) + if !errors.Is(err, domain.ErrInviteRequestSent) { + t.Fatalf("import pending %d err = %v, want ErrInviteRequestSent", i, err) + } + } + pending, err := channels.PendingJoinRequests(ctx, channelID, 99) + if err != nil { + t.Fatalf("pending join requests: %v", err) + } + if pending.Count != len(requesters) || len(pending.RecentRequesters) != domain.MaxChannelPendingJoinRecentRequesters { + t.Fatalf("pending summary = %+v, want bounded recent with full count", pending) + } + if pending.RecentRequesters[0] != requesters[len(requesters)-1].ID || + pending.RecentRequesters[len(pending.RecentRequesters)-1] != requesters[2].ID { + t.Fatalf("recent requesters = %+v, want newest first", pending.RecentRequesters) + } + admins, err := channels.ListChannelInviteAdminMemberIDs(ctx, channelID, 0) + if err != nil { + t.Fatalf("invite admins: %v", err) + } + want := []int64{owner.ID, inviteAdmin.ID, changeAdmin.ID} + if !reflect.DeepEqual(admins, want) { + t.Fatalf("invite admins = %+v, want %+v", admins, want) + } +} + +func TestChannelStoreImportInviteUsageLimitSeesConcurrentIncrement(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 64, + Phone: "+1888" + suffix + "31", + FirstName: "InviteLimitOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + joiner, err := users.Create(ctx, domain.User{ + AccessHash: 65, + Phone: "+1888" + suffix + "32", + FirstName: "InviteLimitJoiner", + }) + if err != nil { + t.Fatalf("create joiner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, joiner.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Invite Limit Race " + suffix, + Megagroup: true, + Date: 1700000350, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + invite, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "single", + UsageLimit: 1, + Date: 1700000351, + }) + if err != nil { + t.Fatalf("export limited invite: %v", err) + } + + lockTx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin lock tx: %v", err) + } + defer func() { _ = lockTx.Rollback(ctx) }() + if _, err := lockTx.Exec(ctx, ` +UPDATE channel_invites +SET usage_count = usage_limit +WHERE channel_id = $1 AND invite_id = $2`, channelID, invite.Invite.InviteID); err != nil { + t.Fatalf("lock and update invite usage: %v", err) + } + + importCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + errCh := make(chan error, 1) + go func() { + _, err := channels.ImportInvite(importCtx, domain.ImportChannelInviteRequest{ + UserID: joiner.ID, + Hash: invite.Invite.Hash, + Date: 1700000352, + }) + errCh <- err + }() + time.Sleep(100 * time.Millisecond) + if err := lockTx.Commit(ctx); err != nil { + t.Fatalf("commit lock tx: %v", err) + } + err = <-errCh + if !errors.Is(err, domain.ErrUsersTooMuch) { + t.Fatalf("concurrent import err = %v, want ErrUsersTooMuch after seeing committed usage_count", err) + } + if _, err := channels.GetChannel(ctx, joiner.ID, channelID); !errors.Is(err, domain.ErrChannelPrivate) { + t.Fatalf("joiner channel after rejected import err = %v, want ErrChannelPrivate", err) + } +} + +// TestChannelStoreEnsurePermanentInvite 验证主链接幂等保障:缺失即建、存在复用、 +// 撤销后重建、非授权用户被拒(DrKLO 建频道后 getExportedChatInvites 直接取 +// invites[0],主链接缺失即客户端闪退)。 +func TestChannelStoreEnsurePermanentInvite(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 71, + Phone: "+1888" + suffix + "41", + FirstName: "EnsureOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + outsider, err := users.Create(ctx, domain.User{ + AccessHash: 72, + Phone: "+1888" + suffix + "42", + FirstName: "EnsureOutsider", + }) + if err != nil { + t.Fatalf("create outsider: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channel_invites WHERE channel_id = $1", channelID) + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, outsider.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Ensure Permanent " + suffix, + Broadcast: true, + Date: 1700000400, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + first, err := channels.EnsurePermanentInvite(ctx, channelID, owner.ID, 1700000401) + if err != nil { + t.Fatalf("ensure permanent invite: %v", err) + } + if !first.Permanent || first.Revoked || first.AdminUserID != owner.ID || first.Hash == "" { + t.Fatalf("ensured invite = %+v, want creator's non-revoked permanent link", first) + } + + second, err := channels.EnsurePermanentInvite(ctx, channelID, owner.ID, 1700000500) + if err != nil { + t.Fatalf("ensure permanent invite again: %v", err) + } + if second.Hash != first.Hash { + t.Fatalf("second ensure hash = %q, want stable %q (must not duplicate)", second.Hash, first.Hash) + } + + if _, err := channels.EditExportedInvite(ctx, domain.EditChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Hash: first.Hash, + Revoked: true, + Date: 1700000600, + }); err != nil { + t.Fatalf("revoke permanent invite: %v", err) + } + third, err := channels.EnsurePermanentInvite(ctx, channelID, owner.ID, 1700000601) + if err != nil { + t.Fatalf("ensure after revoke: %v", err) + } + if !third.Permanent || third.Revoked || third.Hash == first.Hash { + t.Fatalf("post-revoke ensure = %+v, want fresh non-revoked permanent link", third) + } + + if _, err := channels.EnsurePermanentInvite(ctx, channelID, outsider.ID, 0); err == nil { + t.Fatal("ensure by non-member must fail") + } + + list, err := channels.ListExportedInvites(ctx, domain.ChannelInviteListRequest{ + UserID: owner.ID, + ChannelID: channelID, + AdminUserID: owner.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list invites: %v", err) + } + found := false + for _, invite := range list.Invites { + if invite.Hash == third.Hash && invite.Permanent && !invite.Revoked { + found = true + } + } + if !found { + t.Fatalf("invite list = %+v, missing ensured permanent link %q", list.Invites, third.Hash) + } +} diff --git a/internal/store/postgres/channel_invite_members.go b/internal/store/postgres/channel_invite_members.go new file mode 100644 index 00000000..0ffb75b8 --- /dev/null +++ b/internal/store/postgres/channel_invite_members.go @@ -0,0 +1,135 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "telesrv/internal/domain" +) + +func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) { + if channelID == 0 || inviterUserID == 0 || len(userIDs) == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("invite channel: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin invite channel: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, inviter, err := s.getChannelForMember(ctx, tx, inviterUserID, channelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + if !canInviteToChannel(channel, inviter) { + return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired + } + if date == 0 { + date = nowUnix() + } + requested := uniqueChannelUserIDs(userIDs, 0) + inviteOne := len(requested) == 1 + canRestoreKicked := canBanChannelUsers(inviter) + invitedIDs := make([]int64, 0, len(requested)) + members := make([]domain.ChannelMember, 0, len(requested)) + restoredKicked := 0 + for _, userID := range requested { + if existing, err := s.getChannelMember(ctx, tx, channelID, userID); err == nil { + if existing.Status == domain.ChannelMemberActive { + if inviteOne { + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + } + continue + } + if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages { + if !canRestoreKicked { + if inviteOne { + return domain.CreateChannelResult{}, domain.ErrUserKicked + } + continue + } + if existing.Status == domain.ChannelMemberKicked { + restoredKicked++ + } + } + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.CreateChannelResult{}, err + } + member := domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + InviterUserID: inviterUserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: date, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + } + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.CreateChannelResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: inviterUserID, + Date: date, + Type: domain.ChannelAdminLogParticipantInvite, + Participant: &member, + }); err != nil { + return domain.CreateChannelResult{}, err + } + members = append(members, member) + invitedIDs = append(invitedIDs, userID) + } + if len(members) > 0 { + if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + $2, kicked_count = GREATEST(kicked_count - $3, 0), updated_at = now() WHERE id = $1`, channelID, len(members), restoredKicked); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) + } + channel.ParticipantsCount += len(members) + channel.KickedCount = maxInt(channel.KickedCount-restoredKicked, 0) + } + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if len(members) > 0 && channel.Megagroup { + msg, event, err = s.insertServiceMessage(ctx, tx, channel, inviterUserID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatAddUser, + UserIDs: invitedIDs, + }) + if err != nil { + return domain.CreateChannelResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + } + for _, member := range members { + if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.CreateChannelResult{}, err + } + // 被重新拉入群也是重进:按新 available_min_id 重算未读 reaction 计数清幽灵角标。 + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, member.UserID, channel.ID); err != nil { + return domain.CreateChannelResult{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0) + return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil +} + +func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool { + if member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) { + return true + } + return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !member.BannedRights.InviteUsers +} diff --git a/internal/store/postgres/channel_invite_requests.go b/internal/store/postgres/channel_invite_requests.go new file mode 100644 index 00000000..9e1b40c3 --- /dev/null +++ b/internal/store/postgres/channel_invite_requests.go @@ -0,0 +1,211 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) ListInviteImporters(ctx context.Context, req domain.ChannelInviteImportersRequest) (domain.ChannelInviteImporterList, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelInviteImporterList{}, domain.ErrChannelInvalid + } + if _, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID); err != nil { + return domain.ChannelInviteImporterList{}, err + } else if !canExportChannelInvite(member) { + return domain.ChannelInviteImporterList{}, domain.ErrChannelAdminRequired + } + var inviteID int64 + if req.Hash != "" { + invite, err := s.getInviteByChannelHash(ctx, s.db, req.ChannelID, req.Hash, false) + if err != nil { + return domain.ChannelInviteImporterList{}, err + } + inviteID = invite.InviteID + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelInviteListLimit { + limit = domain.MaxChannelInviteListLimit + } + args := []any{req.ChannelID, req.Requested, inviteID, req.Query, req.OffsetDate, req.OffsetUserID, limit} + where := []string{ + "i.channel_id = $1", + "i.requested = $2", + "($3::bigint = 0 OR i.invite_id = $3)", + "($4::text = '' OR lower(trim(u.username || ' ' || u.first_name || ' ' || u.last_name)) LIKE '%' || lower($4) || '%')", + "(($5::int = 0 AND $6::bigint = 0) OR (i.date, i.user_id) < ($5, $6))", + } + whereSQL := strings.Join(where, " AND ") + var total int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_invite_importers i +JOIN users u ON u.id = i.user_id +WHERE `+whereSQL, args[:6]...).Scan(&total); err != nil { + return domain.ChannelInviteImporterList{}, err + } + rows, err := s.db.Query(ctx, ` +SELECT i.channel_id, i.invite_id, i.user_id, i.date, i.requested, i.approved_by, i.via_chatlist, i.about +FROM channel_invite_importers i +JOIN users u ON u.id = i.user_id +WHERE `+whereSQL+` +ORDER BY i.date DESC, i.user_id DESC +LIMIT $7`, args...) + if err != nil { + return domain.ChannelInviteImporterList{}, err + } + defer rows.Close() + importers := make([]domain.ChannelInviteImporter, 0, limit) + for rows.Next() { + var importer domain.ChannelInviteImporter + if err := rows.Scan(&importer.ChannelID, &importer.InviteID, &importer.UserID, &importer.Date, &importer.Requested, &importer.ApprovedBy, &importer.ViaChatlist, &importer.About); err != nil { + return domain.ChannelInviteImporterList{}, err + } + importers = append(importers, importer) + } + if err := rows.Err(); err != nil { + return domain.ChannelInviteImporterList{}, err + } + return domain.ChannelInviteImporterList{Count: total, Importers: importers}, nil +} + +func (s *ChannelStore) PendingJoinRequests(ctx context.Context, channelID int64, limit int) (domain.ChannelPendingJoinRequests, error) { + if channelID == 0 { + return domain.ChannelPendingJoinRequests{}, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelPendingJoinRecentRequesters { + limit = domain.MaxChannelPendingJoinRecentRequesters + } + rows, err := s.db.Query(ctx, ` +SELECT user_id, COUNT(*) OVER()::int +FROM channel_invite_importers +WHERE channel_id = $1 AND requested +ORDER BY date DESC, user_id DESC +LIMIT $2`, channelID, limit) + if err != nil { + return domain.ChannelPendingJoinRequests{}, fmt.Errorf("list pending channel join requests: %w", err) + } + defer rows.Close() + out := domain.ChannelPendingJoinRequests{ + ChannelID: channelID, + RecentRequesters: make([]int64, 0, limit), + } + for rows.Next() { + var userID int64 + var count int + if err := rows.Scan(&userID, &count); err != nil { + return domain.ChannelPendingJoinRequests{}, err + } + out.Count = count + out.RecentRequesters = append(out.RecentRequesters, userID) + } + if err := rows.Err(); err != nil { + return domain.ChannelPendingJoinRequests{}, err + } + return out, nil +} + +func (s *ChannelStore) HideAllChatJoinRequests(ctx context.Context, req domain.HideChannelJoinRequestsRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + var inviteID int64 + if req.Hash != "" { + invite, err := s.GetExportedInvite(ctx, domain.GetChannelInviteRequest{UserID: req.UserID, ChannelID: req.ChannelID, Hash: req.Hash}) + if err != nil { + return domain.CreateChannelResult{}, err + } + inviteID = invite.InviteID + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelHideJoinRequests { + limit = domain.MaxChannelHideJoinRequests + } + rows, err := s.db.Query(ctx, ` +SELECT user_id +FROM channel_invite_importers +WHERE channel_id = $1 AND requested AND ($2::bigint = 0 OR invite_id = $2) +ORDER BY date ASC, user_id ASC +LIMIT $3`, req.ChannelID, inviteID, limit) + if err != nil { + return domain.CreateChannelResult{}, err + } + targets := make([]int64, 0, limit) + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + rows.Close() + return domain.CreateChannelResult{}, err + } + targets = append(targets, userID) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.CreateChannelResult{}, err + } + rows.Close() + var result domain.CreateChannelResult + for _, target := range targets { + next, err := s.HideChatJoinRequest(ctx, domain.HideChannelJoinRequestRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + TargetUserID: target, + Approved: req.Approved, + Date: req.Date, + }) + if err != nil { + return domain.CreateChannelResult{}, err + } + result = next + } + if result.Channel.ID == 0 { + ch, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + result.Channel = ch + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + result.Recipients = recipients + } + return result, nil +} + +func (s *ChannelStore) ListChannelInviteAdminMemberIDs(ctx context.Context, channelID int64, limit int) ([]int64, error) { + if channelID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + rows, err := s.db.Query(ctx, ` +SELECT user_id +FROM channel_members +WHERE channel_id = $1 + AND status = 'active' + AND ( + role = 'creator' OR + (role = 'admin' AND ( + (admin_rights->>'InviteUsers')::boolean IS TRUE OR + (admin_rights->>'ChangeInfo')::boolean IS TRUE + )) + ) +ORDER BY user_id +LIMIT $2`, channelID, limit) + if err != nil { + return nil, fmt.Errorf("list channel invite admin members: %w", err) + } + defer rows.Close() + out := make([]int64, 0, limit) + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + return nil, err + } + out = append(out, userID) + } + return out, rows.Err() +} diff --git a/internal/store/postgres/channel_listener.go b/internal/store/postgres/channel_listener.go new file mode 100644 index 00000000..da34868b --- /dev/null +++ b/internal/store/postgres/channel_listener.go @@ -0,0 +1,108 @@ +package postgres + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "go.uber.org/zap" +) + +// channelChangeNotifyChannel 是 channels 触发器(迁移 0117) pg_notify 的频道名,必须与 SQL 一致。 +const channelChangeNotifyChannel = "telesrv_channel_changed" + +const ( + channelListenerInitialBackoff = 500 * time.Millisecond + channelListenerMaxBackoff = 30 * time.Second +) + +// ChannelChangeListener 用一条专用 PG 连接 LISTEN channels 变更通知,实时失效 ChannelRowCache。 +// +// 强一致保证:每次 (重)连接先建立 LISTEN、再 flush 整表——任何在断连窗口内提交、其 NOTIFY 未送达 +// 的变更,都会被这次 flush 清掉;LISTEN 建立之后提交的变更则由 NOTIFY 精确 delete。两者衔接处由 +// flush 兜底,故缓存对 channels 表始终强一致。 +type ChannelChangeListener struct { + dsn string + cache *ChannelRowCache + log *zap.Logger +} + +// NewChannelChangeListener 创建监听器;cache 为 nil 时 Run 直接返回(缓存禁用即无需监听)。 +func NewChannelChangeListener(dsn string, cache *ChannelRowCache, log *zap.Logger) *ChannelChangeListener { + if log == nil { + log = zap.NewNop() + } + return &ChannelChangeListener{dsn: dsn, cache: cache, log: log} +} + +// Run 阻塞运行监听循环直到 ctx 取消(应在独立 goroutine 启动)。断连自动重连(指数退避)。 +func (l *ChannelChangeListener) Run(ctx context.Context) { + if l == nil || l.cache == nil { + return + } + backoff := channelListenerInitialBackoff + for { + if ctx.Err() != nil { + return + } + err := l.listenAndConsume(ctx) + if ctx.Err() != nil { + return + } + l.log.Warn("channel change listener disconnected; reconnecting", + zap.Error(err), zap.Duration("backoff", backoff)) + if !sleepCtx(ctx, backoff) { + return + } + backoff *= 2 + if backoff > channelListenerMaxBackoff { + backoff = channelListenerMaxBackoff + } + } +} + +// listenAndConsume 建立一条连接、LISTEN、flush,然后消费通知直到出错或 ctx 取消。 +// 成功消费到通知前不重置退避,由 Run 控制重连节奏。 +func (l *ChannelChangeListener) listenAndConsume(ctx context.Context) error { + conn, err := pgx.Connect(ctx, l.dsn) + if err != nil { + return err + } + defer conn.Close(context.Background()) + + if _, err := conn.Exec(ctx, "LISTEN "+channelChangeNotifyChannel); err != nil { + return err + } + // LISTEN 已就绪:清空缓存,丢弃断连窗口内可能已变更但通知未送达的条目。 + l.cache.flush() + l.log.Info("channel change listener ready", zap.String("notify_channel", channelChangeNotifyChannel)) + + for { + notification, err := conn.WaitForNotification(ctx) + if err != nil { + return err + } + if notification == nil { + continue + } + id, perr := strconv.ParseInt(strings.TrimSpace(notification.Payload), 10, 64) + if perr != nil || id == 0 { + continue + } + l.cache.delete(id) + } +} + +// sleepCtx 睡 d 或在 ctx 取消时提前返回;返回 false 表示 ctx 已取消。 +func sleepCtx(ctx context.Context, d time.Duration) bool { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} diff --git a/internal/store/postgres/channel_member_admin.go b/internal/store/postgres/channel_member_admin.go new file mode 100644 index 00000000..666f9ce8 --- /dev/null +++ b/internal/store/postgres/channel_member_admin.go @@ -0,0 +1,469 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "telesrv/internal/domain" +) + +func (s *ChannelStore) EditChannelAdmin(ctx context.Context, req domain.EditChannelAdminRequest) (domain.EditChannelAdminResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 { + return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelAdminResult{}, fmt.Errorf("edit channel admin: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.EditChannelAdminResult{}, fmt.Errorf("begin edit channel admin: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, actor, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelAdminResult{}, err + } + if !canAddChannelAdmins(actor) { + return domain.EditChannelAdminResult{}, domain.ErrChannelAdminRequired + } + if actor.Role != domain.ChannelRoleCreator && !adminRightsSubset(req.AdminRights, actor.AdminRights) { + return domain.EditChannelAdminResult{}, domain.ErrChannelRightForbidden + } + previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.MemberID) + if err != nil { + if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.EditChannelAdminResult{}, err + } + previous = domain.ChannelMember{ + ChannelID: req.ChannelID, + UserID: req.MemberID, + InviterUserID: req.UserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberActive, + JoinedAt: req.Date, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + } + } + if previous.Role == domain.ChannelRoleCreator { + return domain.EditChannelAdminResult{}, domain.ErrChannelUserCreator + } + member := previous + member.InviterUserID = req.UserID + member.Status = domain.ChannelMemberActive + member.LeftAt = 0 + member.Rank = req.Rank + if previous.Status != domain.ChannelMemberActive { + if minPts := channelInitialAvailableMinPts(channel); minPts > member.AvailableMinPts { + member.AvailableMinPts = minPts + } + } + member.AdminRights = req.AdminRights + if zeroChannelAdminRights(req.AdminRights) { + member.Role = domain.ChannelRoleMember + member.Rank = "" + } else { + member.Role = domain.ChannelRoleAdmin + } + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.EditChannelAdminResult{}, err + } + logType := domain.ChannelAdminLogParticipantPromote + if member.Role != domain.ChannelRoleAdmin { + logType = domain.ChannelAdminLogParticipantDemote + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: logType, + PrevParticipant: &previous, + NewParticipant: &member, + }); err != nil { + return domain.EditChannelAdminResult{}, err + } + channel, err = refreshChannelCountsTx(ctx, tx, channel) + if err != nil { + return domain.EditChannelAdminResult{}, err + } + event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) + msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) + if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.EditChannelAdminResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelAdminResult{}, fmt.Errorf("commit edit channel admin: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + recipients = append(recipients, req.MemberID) + return domain.EditChannelAdminResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil +} + +func (s *ChannelStore) EditChannelMemberRank(ctx context.Context, req domain.EditChannelMemberRankRequest) (domain.EditChannelAdminResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MemberID == 0 { + return domain.EditChannelAdminResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelAdminResult{}, fmt.Errorf("edit channel member rank: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.EditChannelAdminResult{}, fmt.Errorf("begin edit channel member rank: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, actor, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelAdminResult{}, err + } + previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.MemberID) + if err != nil { + if errors.Is(err, domain.ErrChannelPrivate) { + return domain.EditChannelAdminResult{}, domain.ErrUserNotParticipant + } + return domain.EditChannelAdminResult{}, err + } + if previous.Status != domain.ChannelMemberActive { + return domain.EditChannelAdminResult{}, domain.ErrUserNotParticipant + } + if err := checkEditMemberRank(channel, actor, previous); err != nil { + return domain.EditChannelAdminResult{}, err + } + member := previous + member.Rank = req.Rank + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.EditChannelAdminResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogParticipantEditRank, + PrevString: previous.Rank, + NewString: member.Rank, + Participant: &member, + }); err != nil { + return domain.EditChannelAdminResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelAdminResult{}, fmt.Errorf("commit edit channel member rank: %w", err) + } + committed = true + event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + recipients = append(recipients, req.MemberID) + return domain.EditChannelAdminResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date}, nil +} + +func (s *ChannelStore) EditChannelBanned(ctx context.Context, req domain.EditChannelBannedRequest) (domain.EditChannelBannedResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.Participant.Type != domain.PeerTypeUser || req.Participant.ID == 0 { + return domain.EditChannelBannedResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelBannedResult{}, fmt.Errorf("edit channel banned: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.EditChannelBannedResult{}, fmt.Errorf("begin edit channel banned: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, actor, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelBannedResult{}, err + } + if !canBanChannelUsers(actor) { + return domain.EditChannelBannedResult{}, domain.ErrChannelAdminRequired + } + previous, err := s.getChannelMember(ctx, tx, req.ChannelID, req.Participant.ID) + if err != nil { + if !errors.Is(err, domain.ErrChannelPrivate) { + return domain.EditChannelBannedResult{}, err + } + previous = domain.ChannelMember{ + ChannelID: req.ChannelID, + UserID: req.Participant.ID, + InviterUserID: req.UserID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberLeft, + } + } + if previous.Role == domain.ChannelRoleCreator { + return domain.EditChannelBannedResult{}, domain.ErrChannelUserCreator + } + member := previous + member.BannedRights = req.BannedRights + member.Role = domain.ChannelRoleMember + switch { + case req.BannedRights.ViewMessages: + member.InviterUserID = req.UserID + member.Status = domain.ChannelMemberKicked + member.LeftAt = req.Date + case zeroChannelBannedRights(req.BannedRights): + if previous.Status == domain.ChannelMemberActive { + member.Status = domain.ChannelMemberActive + } else { + member.Status = domain.ChannelMemberLeft + } + member.LeftAt = 0 + default: + member.InviterUserID = req.UserID + if previous.Status == domain.ChannelMemberActive { + member.Status = domain.ChannelMemberActive + } else { + member.Status = domain.ChannelMemberBanned + } + } + if member.JoinedAt == 0 && member.Status == domain.ChannelMemberActive { + member.JoinedAt = req.Date + } + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.EditChannelBannedResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: adminLogBanType(previous, member), + PrevParticipant: &previous, + NewParticipant: &member, + }); err != nil { + return domain.EditChannelBannedResult{}, err + } + channel, err = refreshChannelCountsTx(ctx, tx, channel) + if err != nil { + return domain.EditChannelBannedResult{}, err + } + event := transientChannelParticipantEvent(channel.ID, req.UserID, previous, member, req.Date) + if member.Status == domain.ChannelMemberActive { + msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) + if err := upsertChannelDialogTx(ctx, tx, member.UserID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.EditChannelBannedResult{}, err + } + } + if member.Status == domain.ChannelMemberKicked && previous.Status == domain.ChannelMemberActive { + if err := clearChannelMentionsForUserTx(ctx, tx, req.ChannelID, req.Participant.ID); err != nil { + return domain.EditChannelBannedResult{}, err + } + } + var serviceMsg domain.ChannelMessage + var serviceEvent domain.ChannelUpdateEvent + if channel.Megagroup && previous.Status == domain.ChannelMemberActive && member.Status == domain.ChannelMemberKicked { + // megagroup 踢人产生可见 "X removed Y" 服务消息并占 channel pts, + // 其它成员的成员面板/人数与离线差量都靠它收敛。 + serviceMsg, serviceEvent, err = s.insertServiceMessage(ctx, tx, channel, req.UserID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatDelete, + UserIDs: []int64{req.Participant.ID}, + }) + if err != nil { + return domain.EditChannelBannedResult{}, err + } + channel.TopMessageID = serviceMsg.ID + channel.Pts = serviceEvent.Pts + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelBannedResult{}, fmt.Errorf("commit edit channel banned: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + recipients = append(recipients, req.Participant.ID) + return domain.EditChannelBannedResult{Channel: channel, Previous: previous, Participant: member, Event: event, Recipients: recipients, Date: req.Date, Message: serviceMsg, ServiceEvent: serviceEvent}, nil +} + +func (s *ChannelStore) EditChannelDefaultBannedRights(ctx context.Context, req domain.EditChannelDefaultBannedRightsRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + channel, actor, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + if !canBanChannelUsers(actor) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if channel.DefaultBannedRights == req.BannedRights { + return domain.Channel{}, domain.ErrChannelNotModified + } + rights, err := marshalJSON(req.BannedRights, "{}") + if err != nil { + return domain.Channel{}, err + } + if _, err := s.db.Exec(ctx, ` +UPDATE channels +SET default_banned_rights = $2::jsonb, updated_at = now() +WHERE id = $1 AND NOT deleted`, req.ChannelID, rights); err != nil { + return domain.Channel{}, fmt.Errorf("edit channel default banned rights: %w", err) + } + channel.DefaultBannedRights = req.BannedRights + return channel, nil +} + +func (s *ChannelStore) ListAdminedPublicChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id +FROM user_channel_member_index +WHERE user_id = $1 + AND status = 'active' + AND role IN ('creator','admin') + AND public_username + AND NOT deleted +ORDER BY channel_id DESC +LIMIT $2`, userID, domain.MaxAdminedPublicChannels) + if err != nil { + return nil, fmt.Errorf("list admined public channels: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, domain.MaxAdminedPublicChannels) + for rows.Next() { + var channelID int64 + if err := rows.Scan(&channelID); err != nil { + return nil, err + } + ids = append(ids, channelID) + } + if err := rows.Err(); err != nil { + return nil, err + } + channels, err := listChannelsByIDsInOrder(ctx, s.db, ids) + if err != nil { + return nil, fmt.Errorf("list admined public channel details: %w", err) + } + out := make([]domain.Channel, 0, len(channels)) + for _, channel := range channels { + if channel.Username == "" || channel.Deleted { + continue + } + out = append(out, channel) + } + return out, nil +} + +// ListSendAsChannels lists the broadcast channels a user may post messages AS in groups +// (channels.getSendAs candidates): channels where the user is the creator, or an admin holding +// PostMessages rights. Mirrors ListStoryPostableChannels but is restricted to broadcast channels +// and the PostMessages right (sending as an owned megagroup is not a real Telegram capability). +func (s *ChannelStore) ListSendAsChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT i.channel_id +FROM user_channel_member_index i +JOIN channel_members m ON m.channel_id = i.channel_id AND m.user_id = i.user_id +WHERE i.user_id = $1 + AND i.status = 'active' + AND i.role IN ('creator', 'admin') + AND i.broadcast + AND NOT i.deleted + AND ( + i.role = 'creator' + OR COALESCE((m.admin_rights->>'PostMessages')::boolean, false) + ) +ORDER BY i.channel_id DESC +LIMIT $2`, userID, domain.MaxSendAsChannels) + if err != nil { + return nil, fmt.Errorf("list send-as channels: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, domain.MaxSendAsChannels) + for rows.Next() { + var channelID int64 + if err := rows.Scan(&channelID); err != nil { + return nil, err + } + ids = append(ids, channelID) + } + if err := rows.Err(); err != nil { + return nil, err + } + channels, err := listChannelsByIDsInOrder(ctx, s.db, ids) + if err != nil { + return nil, fmt.Errorf("list send-as channel details: %w", err) + } + out := make([]domain.Channel, 0, len(channels)) + for _, channel := range channels { + if channel.Deleted || !channel.Broadcast { + continue + } + out = append(out, channel) + } + return out, nil +} + +func (s *ChannelStore) ListStoryPostableChannels(ctx context.Context, userID int64) ([]domain.Channel, error) { + if userID == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT i.channel_id +FROM user_channel_member_index i +JOIN channel_members m ON m.channel_id = i.channel_id AND m.user_id = i.user_id +WHERE i.user_id = $1 + AND i.status = 'active' + AND i.role IN ('creator', 'admin') + AND (i.megagroup OR i.broadcast) + AND NOT i.deleted + AND ( + i.role = 'creator' + OR COALESCE((m.admin_rights->>'PostStories')::boolean, false) + ) +ORDER BY i.channel_id DESC +LIMIT $2`, userID, domain.MaxStorySendAsChannels) + if err != nil { + return nil, fmt.Errorf("list story postable channels: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, domain.MaxStorySendAsChannels) + for rows.Next() { + var channelID int64 + if err := rows.Scan(&channelID); err != nil { + return nil, err + } + ids = append(ids, channelID) + } + if err := rows.Err(); err != nil { + return nil, err + } + channels, err := listChannelsByIDsInOrder(ctx, s.db, ids) + if err != nil { + return nil, fmt.Errorf("list story postable channel details: %w", err) + } + out := make([]domain.Channel, 0, len(channels)) + for _, channel := range channels { + if channel.Deleted || (!channel.Broadcast && !channel.Megagroup) { + continue + } + out = append(out, channel) + } + return out, nil +} diff --git a/internal/store/postgres/channel_member_bot_integration_test.go b/internal/store/postgres/channel_member_bot_integration_test.go new file mode 100644 index 00000000..6506447f --- /dev/null +++ b/internal/store/postgres/channel_member_bot_integration_test.go @@ -0,0 +1,173 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestChannelStoreListActiveChannelBotMembers(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + bots := NewBotStore(pool) + channels := NewChannelStore(pool) + + owner, err := users.Create(ctx, domain.User{AccessHash: 8901, Phone: "+1888" + suffix + "01", FirstName: "BotOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 8902, Phone: "+1888" + suffix + "02", FirstName: "Human"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + bot, _, err := bots.CreateBotAccount(ctx, + domain.User{AccessHash: 8903, FirstName: "MemberBot", Username: "member_" + suffix + "_bot"}, + domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "secret_" + suffix}, + ) + if err != nil { + t.Fatalf("create bot: %v", err) + } + + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1 OR owner_user_id = $2", bot.ID, owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, bot.ID}) + }) + + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Bot Members " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID, bot.ID}, + Date: 1700008900, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + list, err := channels.ListActiveChannelBotMembers(ctx, owner.ID, channelID, 0, 20) + if err != nil { + t.Fatalf("list active bot members: %v", err) + } + if list.Count != 1 || len(list.Participants) != 1 || list.Participants[0].UserID != bot.ID { + t.Fatalf("bot members = %+v, want only bot %d", list, bot.ID) + } + + if _, err := channels.SetParticipantsHidden(ctx, owner.ID, channelID, true); err != nil { + t.Fatalf("hide participants: %v", err) + } + hidden, err := channels.ListActiveChannelBotMembers(ctx, friend.ID, channelID, 0, 20) + if err != nil { + t.Fatalf("list hidden bot members as ordinary member: %v", err) + } + if hidden.Count != 0 || len(hidden.Participants) != 0 { + t.Fatalf("hidden bot members = %+v, want empty for ordinary member", hidden) + } + internalIDs, err := channels.ListActiveChannelBotMemberIDs(ctx, friend.ID, channelID, 20) + if err != nil { + t.Fatalf("list hidden bot member ids as ordinary member: %v", err) + } + if len(internalIDs) != 1 || internalIDs[0] != bot.ID { + t.Fatalf("internal hidden bot ids = %+v, want bot %d", internalIDs, bot.ID) + } + ownerVisible, err := channels.ListActiveChannelBotMembers(ctx, owner.ID, channelID, 0, 20) + if err != nil { + t.Fatalf("list hidden bot members as owner: %v", err) + } + if ownerVisible.Count != 1 || len(ownerVisible.Participants) != 1 || ownerVisible.Participants[0].UserID != bot.ID { + t.Fatalf("owner visible bot members = %+v, want bot %d", ownerVisible, bot.ID) + } +} + +func TestChannelStoreGetParticipantsHidesAnonymousAdminFromRegularMember(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + channels := NewChannelStore(pool) + + owner, err := users.Create(ctx, domain.User{AccessHash: 8911, Phone: "+1889" + suffix + "01", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + anonymousAdmin, err := users.Create(ctx, domain.User{AccessHash: 8912, Phone: "+1889" + suffix + "02", FirstName: "Hidden"}) + if err != nil { + t.Fatalf("create anonymous admin: %v", err) + } + regular, err := users.Create(ctx, domain.User{AccessHash: 8913, Phone: "+1889" + suffix + "03", FirstName: "Regular"}) + if err != nil { + t.Fatalf("create regular: %v", err) + } + + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, anonymousAdmin.ID, regular.ID}) + }) + + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Anonymous Admin " + suffix, + Megagroup: true, + MemberUserIDs: []int64{anonymousAdmin.ID, regular.ID}, + Date: 1700008910, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: anonymousAdmin.ID, + AdminRights: domain.ChannelAdminRights{ + Anonymous: true, + ChangeInfo: true, + }, + Date: 1700008911, + }); err != nil { + t.Fatalf("edit anonymous admin: %v", err) + } + + recent, err := channels.GetParticipants(ctx, regular.ID, channelID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsRecent}, 0, 10) + if err != nil { + t.Fatalf("regular recent participants: %v", err) + } + if postgresParticipantListHasUser(recent.Participants, anonymousAdmin.ID) || recent.Count != 2 { + t.Fatalf("regular recent participants = %+v count=%d, want anonymous admin hidden and visible count 2", recent.Participants, recent.Count) + } + admins, err := channels.GetParticipants(ctx, regular.ID, channelID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}, 0, 10) + if err != nil { + t.Fatalf("regular admin participants: %v", err) + } + if postgresParticipantListHasUser(admins.Participants, anonymousAdmin.ID) || len(admins.Participants) != 1 || admins.Participants[0].UserID != owner.ID { + t.Fatalf("regular admins = %+v, want only creator visible", admins.Participants) + } + ownerAdmins, err := channels.GetParticipants(ctx, owner.ID, channelID, domain.ChannelParticipantsFilter{Kind: domain.ChannelParticipantsAdmins}, 0, 10) + if err != nil { + t.Fatalf("owner admin participants: %v", err) + } + if !postgresParticipantListHasUser(ownerAdmins.Participants, anonymousAdmin.ID) { + t.Fatalf("owner admins = %+v, want anonymous admin visible to admins", ownerAdmins.Participants) + } +} + +func postgresParticipantListHasUser(participants []domain.ChannelMember, userID int64) bool { + for _, participant := range participants { + if participant.UserID == userID { + return true + } + } + return false +} diff --git a/internal/store/postgres/channel_member_cache.go b/internal/store/postgres/channel_member_cache.go new file mode 100644 index 00000000..82a18a0d --- /dev/null +++ b/internal/store/postgres/channel_member_cache.go @@ -0,0 +1,78 @@ +package postgres + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +type channelMemberCacheKey struct { + channelID int64 + userID int64 +} + +// ChannelMemberCache 是 channel_members 表一行的进程内 read-model 缓存, +// 由统一缓存原语 readmodelcache.Cache 承载(LRU 单条驱逐 / epoch 守卫 / singleflight 内建)。 +// +// 它只在连接池读路径上使用,事务内全部绕过;一致性由 read_model trigger + LISTEN/NOTIFY +// 失效驱动。channel_members 同时承载 member access 与 read boundary 两类状态,因此 listener +// 断线重连会整表 flush,避免通知丢失时产生权限/可见性旧读。epoch 守卫确保被踢成员的失效 +// 不会被一次在飞 load 的 stale Active 行覆盖——否则被踢成员可继续读历史,构成访问控制旁路。 +type ChannelMemberCache struct { + cache *readmodelcache.Cache[channelMemberCacheKey, domain.ChannelMember] +} + +// NewChannelMemberCache 创建容量为 max 的成员缓存;max<=0 返回 nil(禁用,调用方按 nil 跳过)。 +// domain.ChannelMember 为扁平值(无共享可变字段),故不设 Clone。 +func NewChannelMemberCache(max int) *ChannelMemberCache { + cache := readmodelcache.New[channelMemberCacheKey, domain.ChannelMember](readmodelcache.Config[channelMemberCacheKey, domain.ChannelMember]{ + MaxEntries: max, + }) + if cache == nil { + return nil + } + return &ChannelMemberCache{cache: cache} +} + +func (c *ChannelMemberCache) get(channelID, userID int64) (domain.ChannelMember, bool) { + if c == nil || channelID == 0 || userID == 0 { + return domain.ChannelMember{}, false + } + return c.cache.Peek(channelMemberCacheKey{channelID: channelID, userID: userID}) +} + +func (c *ChannelMemberCache) getOrLoad(ctx context.Context, channelID, userID int64, load func() (domain.ChannelMember, error)) (domain.ChannelMember, error) { + if c == nil { + return load() + } + return c.cache.GetOrLoad(ctx, channelMemberCacheKey{channelID: channelID, userID: userID}, load) +} + +func (c *ChannelMemberCache) put(member domain.ChannelMember) { + if c == nil || member.ChannelID == 0 || member.UserID == 0 { + return + } + c.cache.Store(channelMemberCacheKey{channelID: member.ChannelID, userID: member.UserID}, member) +} + +func (c *ChannelMemberCache) delete(channelID, userID int64) { + if c == nil || channelID == 0 || userID == 0 { + return + } + c.cache.Invalidate(channelMemberCacheKey{channelID: channelID, userID: userID}) +} + +func (c *ChannelMemberCache) deleteChannel(channelID int64) { + if c == nil || channelID == 0 { + return + } + c.cache.InvalidateWhere(func(k channelMemberCacheKey) bool { return k.channelID == channelID }) +} + +func (c *ChannelMemberCache) flush() { + if c == nil { + return + } + c.cache.Flush() +} diff --git a/internal/store/postgres/channel_member_cache_integration_test.go b/internal/store/postgres/channel_member_cache_integration_test.go new file mode 100644 index 00000000..ea7cc6a4 --- /dev/null +++ b/internal/store/postgres/channel_member_cache_integration_test.go @@ -0,0 +1,523 @@ +package postgres + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/internal/domain" +) + +// TestChannelMemberCacheInvalidatesOnReadModelNotify 验证:channel_members 触发器(0120) +// → 统一 read-model LISTEN/NOTIFY → ChannelMemberCache 失效 → 下一次读回填新成员态。 +func TestChannelMemberCacheInvalidatesOnReadModelNotify(t *testing.T) { + pool := testPool(t) + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 881, + Phone: "+1888" + suffix + "01", + FirstName: "MemberCacheOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + + rowCache := NewChannelRowCache(1000) + memberCache := NewChannelMemberCache(1000) + channels := NewChannelStore(pool, + WithChannelRowCache(rowCache), + WithChannelMemberCache(memberCache)) + + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "MemberCacheChan " + suffix, + Megagroup: true, + Date: 1700001700, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + if _, err := channels.GetChannel(ctx, owner.ID, channelID); err != nil { + t.Fatalf("warm GetChannel: %v", err) + } + if _, ok := rowCache.get(channelID); !ok { + t.Fatalf("GetChannel 后频道行应已缓存") + } + if _, ok := memberCache.get(channelID, owner.ID); !ok { + t.Fatalf("GetChannel 后成员行应已缓存") + } + + lctx, cancel := context.WithCancel(ctx) + defer cancel() + listener := NewReadModelChangeListener(dsn, ReadModelCacheSet{ + ChannelRows: rowCache, + ChannelMembers: memberCache, + }, nil) + go listener.Run(lctx) + if !waitUntil(2*time.Second, func() bool { + _, rowOK := rowCache.get(channelID) + _, memberOK := memberCache.get(channelID, owner.ID) + return !rowOK && !memberOK + }) { + t.Fatalf("read model listener 未在预期内连接并 flush 缓存") + } + + if view, err := channels.GetChannel(ctx, owner.ID, channelID); err != nil { + t.Fatalf("re-warm GetChannel: %v", err) + } else if view.Self.Rank != "" { + t.Fatalf("初始 rank = %q, want empty", view.Self.Rank) + } + if _, ok := memberCache.get(channelID, owner.ID); !ok { + t.Fatalf("re-warm 后成员行应已缓存") + } + + const newRank = "cache-rank" + if _, err := pool.Exec(ctx, ` +UPDATE channel_members +SET rank = $3, updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID, newRank); err != nil { + t.Fatalf("update member rank: %v", err) + } + + if !waitUntil(3*time.Second, func() bool { + _, ok := memberCache.get(channelID, owner.ID) + return !ok + }) { + t.Fatalf("UPDATE channel_members 后成员缓存未在预期内被 read-model NOTIFY 失效") + } + + view, err := channels.GetChannel(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("post-invalidation GetChannel: %v", err) + } + if view.Self.Rank != newRank { + t.Fatalf("失效后 rank = %q, want %q", view.Self.Rank, newRank) + } +} + +func TestChannelDialogLightInvalidatesOnChannelMemberAndDialogNotify(t *testing.T) { + pool := testPool(t) + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1888"+suffix+"91", "DialogLightChannelOwner", "") + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "DialogLightChannel " + suffix, + Megagroup: true, + Date: 1700003300, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", created.Channel.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + versionBeforeMember, hashBeforeMember := readDialogLightVersionHash(t, ctx, pool, owner.ID, peer) + dialogCache := &fakeDialogReadModelCache{} + lctx, cancel := context.WithCancel(ctx) + defer cancel() + listener := NewReadModelChangeListener(dsn, ReadModelCacheSet{Dialogs: dialogCache}, nil) + go listener.Run(lctx) + if !waitUntil(2*time.Second, func() bool { return dialogCache.flushCount() == 1 }) { + t.Fatalf("read model listener 未在预期内连接并 flush dialog cache") + } + + if _, err := pool.Exec(ctx, ` +UPDATE channel_members +SET rank = $3, updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, created.Channel.ID, owner.ID, "dialog-light-rank"); err != nil { + t.Fatalf("update channel member: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + return countDialogInvalidation(dialogCache, owner.ID, peer) > 0 + }) { + owners, peers := dialogCache.entriesSnapshot() + t.Fatalf("channel member 写入未触发 dialog_light 失效: owners=%v peers=%+v want owner=%d peer=%+v", owners, peers, owner.ID, peer) + } + versionAfterMember, hashAfterMember := readDialogLightVersionHash(t, ctx, pool, owner.ID, peer) + if versionAfterMember <= versionBeforeMember { + t.Fatalf("channel member did not bump dialog_light version: before=%d after=%d", versionBeforeMember, versionAfterMember) + } + if hashAfterMember == 0 || hashAfterMember == hashBeforeMember { + t.Fatalf("channel member did not refresh dialog_light hash: before=%d after=%d", hashBeforeMember, hashAfterMember) + } + + beforeDialogInvalidation := countDialogInvalidation(dialogCache, owner.ID, peer) + versionBeforeDialog, hashBeforeDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, peer) + changed, err := channels.SetChannelDialogUnreadMark(ctx, owner.ID, created.Channel.ID, true) + if err != nil { + t.Fatalf("set channel unread mark: %v", err) + } + if !changed { + t.Fatalf("set channel unread mark changed = false, want true") + } + if !waitUntil(3*time.Second, func() bool { + return countDialogInvalidation(dialogCache, owner.ID, peer) > beforeDialogInvalidation + }) { + owners, peers := dialogCache.entriesSnapshot() + t.Fatalf("channel dialog 写入未触发 dialog_light 失效: owners=%v peers=%+v want owner=%d peer=%+v", owners, peers, owner.ID, peer) + } + versionAfterDialog, hashAfterDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, peer) + if versionAfterDialog <= versionBeforeDialog { + t.Fatalf("channel dialog did not bump dialog_light version: before=%d after=%d", versionBeforeDialog, versionAfterDialog) + } + if hashAfterDialog == 0 || hashAfterDialog == hashBeforeDialog { + t.Fatalf("channel dialog did not refresh dialog_light hash: before=%d after=%d", hashBeforeDialog, hashAfterDialog) + } +} + +// TestReadModelChangeListenerInvalidatesProjectionCachesFromNotify 验证 0120/0121 +// 的 account/profile 投影触发器能穿过统一 listener 分发到对应缓存。 +func TestReadModelChangeListenerInvalidatesProjectionCachesFromNotify(t *testing.T) { + pool := testPool(t) + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 991, + Phone: "+1991" + suffix + "01", + FirstName: "ProjectionOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + target, err := users.Create(ctx, domain.User{ + AccessHash: 992, + Phone: "+1992" + suffix + "02", + FirstName: "ProjectionTarget", + }) + if err != nil { + t.Fatalf("create target: %v", err) + } + photoID := time.Now().UnixNano() + targetPhotoID := photoID + 1 + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM account_privacy_rules WHERE owner_user_id = ANY($1::bigint[])", []int64{owner.ID, target.ID}) + _, _ = pool.Exec(ctx, "DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = ANY($1::bigint[])", []int64{owner.ID, target.ID}) + _, _ = pool.Exec(ctx, "DELETE FROM photos WHERE id = ANY($1::bigint[])", []int64{photoID, targetPhotoID}) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, target.ID}) + }) + + contactsCache := &fakeContactReadModelCache{} + dialogCache := &fakeDialogReadModelCache{} + privacyCache := &fakePrivacyReadModelCache{} + photoCache := &fakeProfilePhotoReadModelCache{} + lctx, cancel := context.WithCancel(ctx) + defer cancel() + listener := NewReadModelChangeListener(dsn, ReadModelCacheSet{ + Contacts: contactsCache, + Dialogs: dialogCache, + Privacy: privacyCache, + ProfilePhotos: photoCache, + }, nil) + go listener.Run(lctx) + if !waitUntil(2*time.Second, func() bool { + return contactsCache.flushCount() == 1 && dialogCache.flushCount() == 1 && privacyCache.flushCount() == 1 && photoCache.flushCount() == 1 + }) { + t.Fatalf("read model listener 未在预期内连接并 flush account/dialog/photo caches") + } + + dialogs := NewDialogStore(pool) + dialogPeer := domain.Peer{Type: domain.PeerTypeUser, ID: target.ID} + if err := dialogs.Upsert(ctx, owner.ID, domain.Dialog{Peer: dialogPeer, TopMessage: 1, TopMessageDate: 1700002999}); err != nil { + t.Fatalf("upsert dialog: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > 0 + }) { + owners, peers := dialogCache.entriesSnapshot() + t.Fatalf("dialog 写入未触发 dialog_light 失效: owners=%v peers=%+v want owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer) + } + versionBeforeContactDialog, hashBeforeContactDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer) + + contacts := NewContactStore(pool) + beforeContactDialogFanout := countDialogInvalidation(dialogCache, owner.ID, dialogPeer) + if _, err := contacts.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: target.ID, FirstName: "Target"}); err != nil { + t.Fatalf("upsert contact: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + return containsInt64(contactsCache.idsSnapshot(), owner.ID) + }) { + t.Fatalf("contacts 写入未触发 contact_account 失效: ids=%v owner=%d", contactsCache.idsSnapshot(), owner.ID) + } + var versionBeforeProfile, hashBeforeProfile int64 + if err := pool.QueryRow(ctx, ` +SELECT version, hash +FROM read_model_versions +WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, owner.ID).Scan(&versionBeforeProfile, &hashBeforeProfile); err != nil { + t.Fatalf("read contact_account version/hash before profile update: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > beforeContactDialogFanout + }) { + owners, peers := dialogCache.entriesSnapshot() + t.Fatalf("contact 写入未 fan-out 到 dialog_light 失效: owners=%v peers=%+v owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer) + } + versionAfterContactDialog, hashAfterContactDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer) + if versionAfterContactDialog <= versionBeforeContactDialog { + t.Fatalf("contact did not bump dialog_light version: before=%d after=%d", versionBeforeContactDialog, versionAfterContactDialog) + } + if hashAfterContactDialog == 0 || hashAfterContactDialog == hashBeforeContactDialog { + t.Fatalf("contact did not refresh dialog_light hash: before=%d after=%d", hashBeforeContactDialog, hashAfterContactDialog) + } + beforeUserFanout := countInt64(contactsCache.idsSnapshot(), owner.ID) + beforeUserDialogFanout := countDialogInvalidation(dialogCache, owner.ID, dialogPeer) + versionBeforeProfileDialog, hashBeforeProfileDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer) + if _, err := pool.Exec(ctx, "UPDATE users SET first_name = $2, updated_at = now() WHERE id = $1", target.ID, "ProjectionTargetRenamed"); err != nil { + t.Fatalf("update target user profile field: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + return countInt64(contactsCache.idsSnapshot(), owner.ID) > beforeUserFanout + }) { + t.Fatalf("users profile 写入未 fan-out 到 contact_account 失效: ids=%v owner=%d", contactsCache.idsSnapshot(), owner.ID) + } + if !waitUntil(3*time.Second, func() bool { + return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > beforeUserDialogFanout + }) { + owners, peers := dialogCache.entriesSnapshot() + t.Fatalf("users profile 写入未 fan-out 到 dialog_light 失效: owners=%v peers=%+v owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer) + } + versionAfterProfileDialog, hashAfterProfileDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer) + if versionAfterProfileDialog <= versionBeforeProfileDialog { + t.Fatalf("profile update did not bump dialog_light version: before=%d after=%d", versionBeforeProfileDialog, versionAfterProfileDialog) + } + if hashAfterProfileDialog == 0 || hashAfterProfileDialog == hashBeforeProfileDialog { + t.Fatalf("profile update did not refresh dialog_light hash: before=%d after=%d", hashBeforeProfileDialog, hashAfterProfileDialog) + } + var versionBeforeLastSeen, hashBeforeLastSeen int64 + if err := pool.QueryRow(ctx, ` +SELECT version, hash +FROM read_model_versions +WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, owner.ID).Scan(&versionBeforeLastSeen, &hashBeforeLastSeen); err != nil { + t.Fatalf("read contact_account version before last_seen update: %v", err) + } + if versionBeforeLastSeen <= versionBeforeProfile { + t.Fatalf("profile update did not bump contact_account version: before=%d after=%d", versionBeforeProfile, versionBeforeLastSeen) + } + if hashBeforeLastSeen == 0 || hashBeforeLastSeen == hashBeforeProfile { + t.Fatalf("profile update did not refresh contact_account hash: before=%d after=%d", hashBeforeProfile, hashBeforeLastSeen) + } + if _, err := pool.Exec(ctx, "UPDATE users SET last_seen_at = GREATEST(last_seen_at, $2), updated_at = now() WHERE id = $1", target.ID, int64(1700003999)); err != nil { + t.Fatalf("update target last_seen: %v", err) + } + var versionAfterLastSeen, hashAfterLastSeen int64 + if err := pool.QueryRow(ctx, ` +SELECT version, hash +FROM read_model_versions +WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, owner.ID).Scan(&versionAfterLastSeen, &hashAfterLastSeen); err != nil { + t.Fatalf("read contact_account version after last_seen update: %v", err) + } + if versionAfterLastSeen != versionBeforeLastSeen { + t.Fatalf("last_seen update bumped contact_account version: before=%d after=%d", versionBeforeLastSeen, versionAfterLastSeen) + } + if hashAfterLastSeen != hashBeforeLastSeen { + t.Fatalf("last_seen update refreshed contact_account hash: before=%d after=%d", hashBeforeLastSeen, hashAfterLastSeen) + } + if _, err := contacts.Block(ctx, owner.ID, target.ID, 1700003000); err != nil { + t.Fatalf("block contact: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + return countInt64(contactsCache.idsSnapshot(), owner.ID) >= 2 + }) { + t.Fatalf("block 写入未触发 contact_blocklist 失效: ids=%v owner=%d", contactsCache.idsSnapshot(), owner.ID) + } + + privacy := NewPrivacyStore(pool) + if err := privacy.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: owner.ID, + Key: domain.PrivacyKeyProfilePhoto, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}, + }); err != nil { + t.Fatalf("set privacy: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + return containsInt64(privacyCache.idsSnapshot(), owner.ID) + }) { + t.Fatalf("privacy 写入未触发 privacy_rules 失效: ids=%v owner=%d", privacyCache.idsSnapshot(), owner.ID) + } + + media := NewMediaStore(pool) + versionBeforeTargetPrivacy, hashBeforeTargetPrivacy := readContactAccountVersionHash(t, ctx, pool, owner.ID) + versionBeforeTargetPrivacyDialog, hashBeforeTargetPrivacyDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer) + beforeTargetPrivacyFanout := countInt64(contactsCache.idsSnapshot(), owner.ID) + beforeTargetPrivacyDialogFanout := countDialogInvalidation(dialogCache, owner.ID, dialogPeer) + if err := privacy.SetPrivacyRules(ctx, domain.PrivacyRules{ + OwnerUserID: target.ID, + Key: domain.PrivacyKeyProfilePhoto, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}, + }); err != nil { + t.Fatalf("set target privacy: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + return countInt64(contactsCache.idsSnapshot(), owner.ID) > beforeTargetPrivacyFanout + }) { + t.Fatalf("target privacy 写入未 fan-out 到 contact_account 失效: ids=%v owner=%d", contactsCache.idsSnapshot(), owner.ID) + } + if !waitUntil(3*time.Second, func() bool { + return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > beforeTargetPrivacyDialogFanout + }) { + owners, peers := dialogCache.entriesSnapshot() + t.Fatalf("target privacy 写入未 fan-out 到 dialog_light 失效: owners=%v peers=%+v owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer) + } + versionAfterTargetPrivacy, hashAfterTargetPrivacy := readContactAccountVersionHash(t, ctx, pool, owner.ID) + if versionAfterTargetPrivacy <= versionBeforeTargetPrivacy { + t.Fatalf("target privacy did not bump contact_account version: before=%d after=%d", versionBeforeTargetPrivacy, versionAfterTargetPrivacy) + } + if hashAfterTargetPrivacy == 0 || hashAfterTargetPrivacy == hashBeforeTargetPrivacy { + t.Fatalf("target privacy did not refresh contact_account hash: before=%d after=%d", hashBeforeTargetPrivacy, hashAfterTargetPrivacy) + } + versionAfterTargetPrivacyDialog, hashAfterTargetPrivacyDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer) + if versionAfterTargetPrivacyDialog <= versionBeforeTargetPrivacyDialog { + t.Fatalf("target privacy did not bump dialog_light version: before=%d after=%d", versionBeforeTargetPrivacyDialog, versionAfterTargetPrivacyDialog) + } + if hashAfterTargetPrivacyDialog == 0 || hashAfterTargetPrivacyDialog == hashBeforeTargetPrivacyDialog { + t.Fatalf("target privacy did not refresh dialog_light hash: before=%d after=%d", hashBeforeTargetPrivacyDialog, hashAfterTargetPrivacyDialog) + } + + if err := media.PutPhoto(ctx, domain.Photo{ID: targetPhotoID, AccessHash: 994, FileReference: []byte("target-ref"), Date: 1700003003, DCID: 2}); err != nil { + t.Fatalf("put target photo: %v", err) + } + versionBeforeTargetPhoto, hashBeforeTargetPhoto := readContactAccountVersionHash(t, ctx, pool, owner.ID) + versionBeforeTargetPhotoDialog, hashBeforeTargetPhotoDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer) + beforeTargetPhotoFanout := countInt64(contactsCache.idsSnapshot(), owner.ID) + beforeTargetPhotoDialogFanout := countDialogInvalidation(dialogCache, owner.ID, dialogPeer) + if err := media.AddProfilePhotoKind(ctx, domain.PeerTypeUser, target.ID, domain.ProfilePhotoKindProfile, targetPhotoID, 1700003004); err != nil { + t.Fatalf("add target profile photo: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + return countInt64(contactsCache.idsSnapshot(), owner.ID) > beforeTargetPhotoFanout + }) { + t.Fatalf("target profile photo 写入未 fan-out 到 contact_account 失效: ids=%v owner=%d", contactsCache.idsSnapshot(), owner.ID) + } + if !waitUntil(3*time.Second, func() bool { + return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > beforeTargetPhotoDialogFanout + }) { + owners, peers := dialogCache.entriesSnapshot() + t.Fatalf("target profile photo 写入未 fan-out 到 dialog_light 失效: owners=%v peers=%+v owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer) + } + versionAfterTargetPhoto, hashAfterTargetPhoto := readContactAccountVersionHash(t, ctx, pool, owner.ID) + if versionAfterTargetPhoto <= versionBeforeTargetPhoto { + t.Fatalf("target profile photo did not bump contact_account version: before=%d after=%d", versionBeforeTargetPhoto, versionAfterTargetPhoto) + } + if hashAfterTargetPhoto == 0 || hashAfterTargetPhoto == hashBeforeTargetPhoto { + t.Fatalf("target profile photo did not refresh contact_account hash: before=%d after=%d", hashBeforeTargetPhoto, hashAfterTargetPhoto) + } + versionAfterTargetPhotoDialog, hashAfterTargetPhotoDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer) + if versionAfterTargetPhotoDialog <= versionBeforeTargetPhotoDialog { + t.Fatalf("target profile photo did not bump dialog_light version: before=%d after=%d", versionBeforeTargetPhotoDialog, versionAfterTargetPhotoDialog) + } + if hashAfterTargetPhotoDialog == 0 || hashAfterTargetPhotoDialog == hashBeforeTargetPhotoDialog { + t.Fatalf("target profile photo did not refresh dialog_light hash: before=%d after=%d", hashBeforeTargetPhotoDialog, hashAfterTargetPhotoDialog) + } + + versionBeforeDraftDialog, hashBeforeDraftDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer) + beforeDraftDialogFanout := countDialogInvalidation(dialogCache, owner.ID, dialogPeer) + if err := dialogs.SaveDraft(ctx, owner.ID, domain.DialogDraft{Peer: dialogPeer, Date: 1700003005, Message: "dialog light draft"}); err != nil { + t.Fatalf("save dialog draft: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > beforeDraftDialogFanout + }) { + owners, peers := dialogCache.entriesSnapshot() + t.Fatalf("dialog draft 写入未触发 dialog_light 失效: owners=%v peers=%+v owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer) + } + versionAfterDraftDialog, hashAfterDraftDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer) + if versionAfterDraftDialog <= versionBeforeDraftDialog { + t.Fatalf("draft did not bump dialog_light version: before=%d after=%d", versionBeforeDraftDialog, versionAfterDraftDialog) + } + if hashAfterDraftDialog == 0 || hashAfterDraftDialog == hashBeforeDraftDialog { + t.Fatalf("draft did not refresh dialog_light hash: before=%d after=%d", hashBeforeDraftDialog, hashAfterDraftDialog) + } + + if err := media.PutPhoto(ctx, domain.Photo{ID: photoID, AccessHash: 993, FileReference: []byte("ref"), Date: 1700003001, DCID: 2}); err != nil { + t.Fatalf("put photo: %v", err) + } + if err := media.AddProfilePhotoKind(ctx, domain.PeerTypeUser, owner.ID, domain.ProfilePhotoKindProfile, photoID, 1700003002); err != nil { + t.Fatalf("add profile photo: %v", err) + } + if !waitUntil(3*time.Second, func() bool { + for _, ownerPeer := range photoCache.ownersSnapshot() { + if ownerPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}) { + return true + } + } + return false + }) { + t.Fatalf("profile photo 写入未触发 profile_photo 失效: owners=%+v want user %d", photoCache.ownersSnapshot(), owner.ID) + } +} + +func readContactAccountVersionHash(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ownerID int64) (int64, int64) { + t.Helper() + var version, hash int64 + if err := pool.QueryRow(ctx, ` +SELECT version, hash +FROM read_model_versions +WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, ownerID).Scan(&version, &hash); err != nil { + t.Fatalf("read contact_account version/hash: %v", err) + } + return version, hash +} + +func readDialogLightVersionHash(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ownerID int64, peer domain.Peer) (int64, int64) { + t.Helper() + var version, hash int64 + if err := pool.QueryRow(ctx, ` +SELECT version, hash +FROM read_model_versions +WHERE model = 'dialog_light' AND owner_user_id = $1 AND peer_type = $2 AND peer_id = $3`, ownerID, peer.Type, peer.ID).Scan(&version, &hash); err != nil { + t.Fatalf("read dialog_light version/hash: %v", err) + } + return version, hash +} + +func containsInt64(values []int64, target int64) bool { + return countInt64(values, target) > 0 +} + +func countInt64(values []int64, target int64) int { + n := 0 + for _, value := range values { + if value == target { + n++ + } + } + return n +} + +func countDialogInvalidation(cache *fakeDialogReadModelCache, ownerID int64, peer domain.Peer) int { + owners, peers := cache.entriesSnapshot() + n := 0 + for i, owner := range owners { + if owner == ownerID && i < len(peers) && peers[i] == peer { + n++ + } + } + return n +} diff --git a/internal/store/postgres/channel_member_cache_test.go b/internal/store/postgres/channel_member_cache_test.go new file mode 100644 index 00000000..5299d8c1 --- /dev/null +++ b/internal/store/postgres/channel_member_cache_test.go @@ -0,0 +1,493 @@ +package postgres + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestChannelMemberCacheNilDisabled(t *testing.T) { + if c := NewChannelMemberCache(0); c != nil { + t.Fatalf("max<=0 应返回 nil(禁用),got %v", c) + } + var nilCache *ChannelMemberCache + nilCache.put(domain.ChannelMember{ChannelID: 1, UserID: 2}) + nilCache.delete(1, 2) + nilCache.deleteChannel(1) + nilCache.flush() + if _, ok := nilCache.get(1, 2); ok { + t.Fatalf("nil 缓存 get 必须返回 ok=false") + } +} + +func TestChannelMemberCachePutGetDeleteFlush(t *testing.T) { + c := NewChannelMemberCache(16) + member := domain.ChannelMember{ + ChannelID: 10, + UserID: 20, + Role: domain.ChannelRoleAdmin, + Status: domain.ChannelMemberActive, + ReadInboxMaxID: 9, + } + + if _, ok := c.get(10, 20); ok { + t.Fatalf("空缓存不应命中") + } + c.put(member) + got, ok := c.get(10, 20) + if !ok || got.ChannelID != 10 || got.UserID != 20 || got.Role != domain.ChannelRoleAdmin || got.ReadInboxMaxID != 9 { + t.Fatalf("put/get 往返失败: %+v ok=%v", got, ok) + } + + c.delete(10, 20) + if _, ok := c.get(10, 20); ok { + t.Fatalf("delete 后不应命中") + } + c.put(member) + c.flush() + if _, ok := c.get(10, 20); ok { + t.Fatalf("flush 后不应命中") + } +} + +func TestChannelMemberCacheDeleteChannelAndCap(t *testing.T) { + c := NewChannelMemberCache(2) + c.put(domain.ChannelMember{ChannelID: 1, UserID: 10}) + c.put(domain.ChannelMember{ChannelID: 1, UserID: 11}) + c.deleteChannel(1) + if _, ok := c.get(1, 10); ok { + t.Fatalf("deleteChannel 应失效同频道成员") + } + if _, ok := c.get(1, 11); ok { + t.Fatalf("deleteChannel 应失效同频道成员") + } + + c.put(domain.ChannelMember{ChannelID: 2, UserID: 10}) + c.put(domain.ChannelMember{ChannelID: 2, UserID: 11}) + c.put(domain.ChannelMember{ChannelID: 2, UserID: 12}) // 超 cap=2:LRU 单条驱逐最旧的 (2,10) + if _, ok := c.get(2, 10); ok { + t.Fatalf("超限后最旧条目 (2,10) 应被驱逐") + } + if _, ok := c.get(2, 11); !ok { + t.Fatalf("LRU 单条驱逐应保留次新条目 (2,11),证明非整表 flush") + } + if _, ok := c.get(2, 12); !ok { + t.Fatalf("超限后最新写入的条目 (2,12) 应在") + } +} + +func TestChannelMemberCacheSingleflightsColdLoad(t *testing.T) { + c := NewChannelMemberCache(16) + var calls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + load := func() (domain.ChannelMember, error) { + calls.Add(1) + once.Do(func() { close(started) }) + <-release + return domain.ChannelMember{ChannelID: 7, UserID: 100, Status: domain.ChannelMemberActive}, nil + } + + const goroutines = 16 + var wg sync.WaitGroup + errs := make(chan error, goroutines) + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + member, err := c.getOrLoad(context.Background(), 7, 100, load) + if err != nil { + errs <- err + return + } + if member.ChannelID != 7 || member.UserID != 100 || member.Status != domain.ChannelMemberActive { + errs <- fmt.Errorf("member = %+v, want active 7/100", member) + } + }() + } + <-started + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("load calls = %d, want 1", got) + } + if _, err := c.getOrLoad(context.Background(), 7, 100, load); err != nil { + t.Fatalf("cached getOrLoad: %v", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("cache hit called load again: calls=%d", got) + } +} + +func TestReadModelChangeListenerInvalidatesChannelCaches(t *testing.T) { + rows := NewChannelRowCache(16) + members := NewChannelMemberCache(16) + fullBots := &fakeChannelFullBotReadModelCache{} + mediaCounts := &fakeChannelMediaCountReadModelCache{} + rows.put(domain.Channel{ID: 7, Title: "old"}) + members.put(domain.ChannelMember{ChannelID: 7, UserID: 100, Status: domain.ChannelMemberActive}) + members.put(domain.ChannelMember{ChannelID: 7, UserID: 200, Status: domain.ChannelMemberActive}) + members.put(domain.ChannelMember{ChannelID: 8, UserID: 100, Status: domain.ChannelMemberActive}) + + listener := NewReadModelChangeListener("", ReadModelCacheSet{ + ChannelRows: rows, + ChannelMembers: members, + ChannelFullBots: fullBots, + ChannelMediaCounts: mediaCounts, + }, nil) + listener.handlePayload(`{"model":"channel_member","owner_user_id":100,"peer_type":"channel","peer_id":7,"version":2}`) + if _, ok := members.get(7, 100); ok { + t.Fatalf("channel_member 事件应失效指定成员") + } + if _, ok := members.get(7, 200); !ok { + t.Fatalf("channel_member 事件不应失效同频道其它成员") + } + if got := fullBots.channelsSnapshot(); len(got) != 1 || got[0] != 7 { + t.Fatalf("channel_member 应失效 full bot info: %+v", got) + } + if got := mediaCounts.viewerSnapshot(); len(got) != 1 || got[0] != [2]int64{100, 7} { + t.Fatalf("channel_member 应失效该 viewer 的 media count: %+v", got) + } + + listener.handlePayload(`{"model":"channel_base","owner_user_id":0,"peer_type":"channel","peer_id":7,"version":3}`) + if _, ok := rows.get(7); ok { + t.Fatalf("channel_base 事件应失效频道行") + } + if _, ok := members.get(7, 200); ok { + t.Fatalf("channel_base 事件应失效该频道所有成员") + } + if _, ok := members.get(8, 100); !ok { + t.Fatalf("channel_base 事件不应失效其它频道成员") + } + if got := fullBots.channelsSnapshot(); len(got) != 2 || got[1] != 7 { + t.Fatalf("channel_base 应失效 full bot info: %+v", got) + } + + listener.handlePayload(`{"model":"channel_media_counts","owner_user_id":0,"peer_type":"channel","peer_id":7,"version":4}`) + if got := mediaCounts.channelSnapshot(); len(got) != 1 || got[0] != 7 { + t.Fatalf("channel_media_counts 应失效该频道 media count: %+v", got) + } +} + +func TestReadModelChangeListenerInvalidatesPrivateMediaCountCache(t *testing.T) { + privateCounts := &fakePrivateMediaCountReadModelCache{} + listener := NewReadModelChangeListener("", ReadModelCacheSet{ + PrivateMediaCounts: privateCounts, + }, nil) + + listener.handlePayload(`{"model":"private_media_counts","owner_user_id":100,"peer_type":"user","peer_id":200,"version":2}`) + if got := privateCounts.keysSnapshot(); len(got) != 1 || got[0] != [2]int64{100, 200} { + t.Fatalf("private_media_counts 应失效 owner+peer media count: %+v", got) + } +} + +// TestReadModelChangeListenerBotFullFlushesChannelFullBots 回归: bot 改资料(bot_full 事件, +// 迁移 0013)须 flush channelFullBotInfoCache(否则群信息页 bot 简介/命令跨实例陈旧至 TTL); +// 普通用户的 user_base 事件不得 flush(否则该缓存形同虚设)。 +func TestReadModelChangeListenerBotFullFlushesChannelFullBots(t *testing.T) { + fullBots := &fakeChannelFullBotReadModelCache{} + listener := NewReadModelChangeListener("", ReadModelCacheSet{ + ChannelFullBots: fullBots, + }, nil) + + listener.handlePayload(`{"model":"bot_full","owner_user_id":1780243210,"peer_type":"user","peer_id":1780243210,"version":2}`) + if got := fullBots.flushCount(); got != 1 { + t.Fatalf("bot_full 应 flush channelFullBotInfoCache: flushes=%d", got) + } + + listener.handlePayload(`{"model":"user_base","owner_user_id":500,"peer_type":"user","peer_id":500,"version":3}`) + if got := fullBots.flushCount(); got != 1 { + t.Fatalf("user_base(普通用户) 不应 flush channelFullBots: flushes=%d", got) + } +} + +type fakeChannelFullBotReadModelCache struct { + mu sync.Mutex + channels []int64 + flushes int +} + +func (f *fakeChannelFullBotReadModelCache) InvalidateChannelFullBotInfoReadModel(channelID int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.channels = append(f.channels, channelID) +} + +func (f *fakeChannelFullBotReadModelCache) FlushChannelFullBotInfoReadModel() { + f.mu.Lock() + defer f.mu.Unlock() + f.flushes++ +} + +func (f *fakeChannelFullBotReadModelCache) channelsSnapshot() []int64 { + f.mu.Lock() + defer f.mu.Unlock() + return append([]int64(nil), f.channels...) +} + +func (f *fakeChannelFullBotReadModelCache) flushCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.flushes +} + +type fakeChannelMediaCountReadModelCache struct { + mu sync.Mutex + channels []int64 + viewers [][2]int64 + flushes int +} + +func (f *fakeChannelMediaCountReadModelCache) InvalidateChannelMediaCountReadModel(channelID int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.channels = append(f.channels, channelID) +} + +func (f *fakeChannelMediaCountReadModelCache) InvalidateChannelMediaCountReadModelForViewer(userID, channelID int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.viewers = append(f.viewers, [2]int64{userID, channelID}) +} + +func (f *fakeChannelMediaCountReadModelCache) FlushChannelMediaCountReadModel() { + f.mu.Lock() + defer f.mu.Unlock() + f.flushes++ +} + +func (f *fakeChannelMediaCountReadModelCache) channelSnapshot() []int64 { + f.mu.Lock() + defer f.mu.Unlock() + return append([]int64(nil), f.channels...) +} + +func (f *fakeChannelMediaCountReadModelCache) viewerSnapshot() [][2]int64 { + f.mu.Lock() + defer f.mu.Unlock() + return append([][2]int64(nil), f.viewers...) +} + +func (f *fakeChannelMediaCountReadModelCache) flushCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.flushes +} + +type fakePrivateMediaCountReadModelCache struct { + mu sync.Mutex + keys [][2]int64 + flushes int +} + +func (f *fakePrivateMediaCountReadModelCache) InvalidatePrivateMediaCountReadModel(userID, peerID int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.keys = append(f.keys, [2]int64{userID, peerID}) +} + +func (f *fakePrivateMediaCountReadModelCache) FlushPrivateMediaCountReadModel() { + f.mu.Lock() + defer f.mu.Unlock() + f.flushes++ +} + +func (f *fakePrivateMediaCountReadModelCache) keysSnapshot() [][2]int64 { + f.mu.Lock() + defer f.mu.Unlock() + return append([][2]int64(nil), f.keys...) +} + +func (f *fakePrivateMediaCountReadModelCache) flushCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.flushes +} + +type fakeContactReadModelCache struct { + mu sync.Mutex + ids []int64 + flushes int +} + +func (f *fakeContactReadModelCache) InvalidateViewers(ids ...int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.ids = append(f.ids, ids...) +} + +func (f *fakeContactReadModelCache) FlushReadModelCache() { + f.mu.Lock() + defer f.mu.Unlock() + f.flushes++ +} + +func (f *fakeContactReadModelCache) idsSnapshot() []int64 { + f.mu.Lock() + defer f.mu.Unlock() + return append([]int64(nil), f.ids...) +} + +func (f *fakeContactReadModelCache) flushCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.flushes +} + +type fakeDialogReadModelCache struct { + mu sync.Mutex + keys []domain.Peer + owners []int64 + flushes int +} + +func (f *fakeDialogReadModelCache) InvalidateDialog(ownerUserID int64, peer domain.Peer) { + f.mu.Lock() + defer f.mu.Unlock() + f.owners = append(f.owners, ownerUserID) + f.keys = append(f.keys, peer) +} + +func (f *fakeDialogReadModelCache) FlushReadModelCache() { + f.mu.Lock() + defer f.mu.Unlock() + f.flushes++ +} + +func (f *fakeDialogReadModelCache) entriesSnapshot() ([]int64, []domain.Peer) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]int64(nil), f.owners...), append([]domain.Peer(nil), f.keys...) +} + +func (f *fakeDialogReadModelCache) flushCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.flushes +} + +type fakePrivacyReadModelCache struct { + mu sync.Mutex + ids []int64 + flushes int +} + +func (f *fakePrivacyReadModelCache) InvalidateOwners(ids ...int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.ids = append(f.ids, ids...) +} + +func (f *fakePrivacyReadModelCache) FlushReadModelCache() { + f.mu.Lock() + defer f.mu.Unlock() + f.flushes++ +} + +func (f *fakePrivacyReadModelCache) idsSnapshot() []int64 { + f.mu.Lock() + defer f.mu.Unlock() + return append([]int64(nil), f.ids...) +} + +func (f *fakePrivacyReadModelCache) flushCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.flushes +} + +type fakeProfilePhotoReadModelCache struct { + mu sync.Mutex + owners []domain.Peer + flushes int +} + +func (f *fakeProfilePhotoReadModelCache) InvalidateOwner(ownerType domain.PeerType, ownerID int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.owners = append(f.owners, domain.Peer{Type: ownerType, ID: ownerID}) +} + +func (f *fakeProfilePhotoReadModelCache) FlushReadModelCache() { + f.mu.Lock() + defer f.mu.Unlock() + f.flushes++ +} + +func (f *fakeProfilePhotoReadModelCache) ownersSnapshot() []domain.Peer { + f.mu.Lock() + defer f.mu.Unlock() + return append([]domain.Peer(nil), f.owners...) +} + +func (f *fakeProfilePhotoReadModelCache) flushCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.flushes +} + +func TestReadModelChangeListenerInvalidatesAccountCaches(t *testing.T) { + contacts := &fakeContactReadModelCache{} + dialogs := &fakeDialogReadModelCache{} + privacy := &fakePrivacyReadModelCache{} + photos := &fakeProfilePhotoReadModelCache{} + channelMedia := &fakeChannelMediaCountReadModelCache{} + privateMedia := &fakePrivateMediaCountReadModelCache{} + listener := NewReadModelChangeListener("", ReadModelCacheSet{ + Contacts: contacts, + Dialogs: dialogs, + Privacy: privacy, + ProfilePhotos: photos, + ChannelMediaCounts: channelMedia, + PrivateMediaCounts: privateMedia, + }, nil) + + listener.flush() + if contacts.flushes != 1 || dialogs.flushes != 1 || privacy.flushes != 1 || photos.flushes != 1 || + channelMedia.flushCount() != 1 || privateMedia.flushCount() != 1 { + t.Fatalf("flush counts contacts/dialogs/privacy/photos/channelMedia/privateMedia = %d/%d/%d/%d/%d/%d, want all 1", + contacts.flushes, dialogs.flushes, privacy.flushes, photos.flushes, channelMedia.flushCount(), privateMedia.flushCount()) + } + + listener.handlePayload(`{"model":"contact_account","owner_user_id":11,"peer_type":"user","peer_id":11,"version":2}`) + listener.handlePayload(`{"model":"contact_blocklist","owner_user_id":12,"peer_type":"user","peer_id":12,"version":3}`) + if len(contacts.ids) != 2 || contacts.ids[0] != 11 || contacts.ids[1] != 12 { + t.Fatalf("contact invalidations = %v, want [11 12]", contacts.ids) + } + + listener.handlePayload(`{"model":"privacy_rules","owner_user_id":21,"peer_type":"user","peer_id":21,"version":4}`) + if len(privacy.ids) != 1 || privacy.ids[0] != 21 { + t.Fatalf("privacy invalidations = %v, want [21]", privacy.ids) + } + + listener.handlePayload(`{"model":"dialog_light","owner_user_id":22,"peer_type":"user","peer_id":32,"version":4}`) + if len(dialogs.owners) != 1 || dialogs.owners[0] != 22 || dialogs.keys[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 32}) { + t.Fatalf("dialog invalidations owners=%v keys=%+v, want owner 22 user 32", dialogs.owners, dialogs.keys) + } + + listener.handlePayload(`{"model":"channel_member","owner_user_id":23,"peer_type":"channel","peer_id":33,"version":5}`) + if len(dialogs.owners) != 2 || dialogs.owners[1] != 23 || dialogs.keys[1] != (domain.Peer{Type: domain.PeerTypeChannel, ID: 33}) { + t.Fatalf("channel member dialog invalidations owners=%v keys=%+v, want owner 23 channel 33", dialogs.owners, dialogs.keys) + } + + listener.handlePayload(`{"model":"profile_photo","owner_user_id":0,"peer_type":"user","peer_id":31,"version":5}`) + listener.handlePayload(`{"model":"profile_photo","owner_user_id":0,"peer_type":"channel","peer_id":41,"version":6}`) + if len(photos.owners) != 2 || + photos.owners[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 31}) || + photos.owners[1] != (domain.Peer{Type: domain.PeerTypeChannel, ID: 41}) { + t.Fatalf("photo invalidations = %+v, want user 31 and channel 41", photos.owners) + } +} diff --git a/internal/store/postgres/channel_member_helpers.go b/internal/store/postgres/channel_member_helpers.go new file mode 100644 index 00000000..022ab1a9 --- /dev/null +++ b/internal/store/postgres/channel_member_helpers.go @@ -0,0 +1,384 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *ChannelStore) getChannelForMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, error) { + member, err := s.getChannelMember(ctx, db, channelID, viewerUserID) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, err + } + if err := validateChannelMemberVisible(member); err != nil { + return domain.Channel{}, domain.ChannelMember{}, err + } + ch, err := s.channelByID(ctx, db, channelID) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, err + } + return ch, member, nil +} + +func (s *ChannelStore) getPublicPreviewMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, ch domain.Channel) (domain.ChannelMember, error) { + member, err := s.getChannelMember(ctx, db, ch.ID, viewerUserID) + if err != nil { + if errors.Is(err, domain.ErrChannelPrivate) { + return publicPreviewMember(ch, viewerUserID, domain.ChannelMember{}, false), nil + } + return domain.ChannelMember{}, err + } + if member.Status == domain.ChannelMemberBanned || member.Status == domain.ChannelMemberKicked || member.BannedRights.ViewMessages { + return domain.ChannelMember{}, domain.ErrChannelUserBanned + } + return publicPreviewMember(ch, viewerUserID, member, true), nil +} + +func (s *ChannelStore) getChannelMember(ctx context.Context, db sqlcgen.DBTX, channelID, userID int64) (domain.ChannelMember, error) { + if s.memberCacheActive(db) { + return s.memberCache.getOrLoad(ctx, channelID, userID, func() (domain.ChannelMember, error) { + return getChannelMemberByID(ctx, db, channelID, userID) + }) + } + return getChannelMemberByID(ctx, db, channelID, userID) +} + +func getChannelMemberByID(ctx context.Context, db sqlcgen.DBTX, channelID, userID int64) (domain.ChannelMember, error) { + row := db.QueryRow(ctx, ` +SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, + admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts, + read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +FROM channel_members +WHERE channel_id = $1 AND user_id = $2`, channelID, userID) + member, err := scanChannelMember(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelMember{}, domain.ErrChannelPrivate + } + if err != nil { + return domain.ChannelMember{}, err + } + return member, nil +} + +func upsertChannelMemberTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember) error { + adminRights, err := marshalJSON(member.AdminRights, "{}") + if err != nil { + return err + } + bannedRights, err := marshalJSON(member.BannedRights, "{}") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_members ( + channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights, banned_rights, + rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) +ON CONFLICT (channel_id, user_id) DO UPDATE SET + inviter_user_id = EXCLUDED.inviter_user_id, + role = EXCLUDED.role, + status = EXCLUDED.status, + joined_at = EXCLUDED.joined_at, + left_at = EXCLUDED.left_at, + admin_rights = EXCLUDED.admin_rights, + banned_rights = EXCLUDED.banned_rights, + rank = EXCLUDED.rank, + available_min_id = GREATEST(channel_members.available_min_id, EXCLUDED.available_min_id), + available_min_pts = GREATEST(channel_members.available_min_pts, EXCLUDED.available_min_pts), + read_inbox_max_id = GREATEST(channel_members.read_inbox_max_id, EXCLUDED.read_inbox_max_id), + updated_at = now()`, + member.ChannelID, member.UserID, member.InviterUserID, string(member.Role), string(member.Status), + member.JoinedAt, member.LeftAt, adminRights, bannedRights, member.Rank, member.AvailableMinID, + member.AvailableMinPts, member.ReadInboxMaxID, member.ReadOutboxMaxID, member.UnreadMark, member.SlowmodeLastSendDate); err != nil { + return fmt.Errorf("upsert channel member: %w", err) + } + return upsertUserChannelMemberIndexTx(ctx, tx, channel, member) +} + +func upsertUserChannelMemberIndexTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember) error { + if channel.ID == 0 || member.UserID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +INSERT INTO user_channel_member_index ( + user_id, channel_id, status, megagroup, broadcast, deleted, + role, left_at, forum, public_username, can_pin_messages +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) +ON CONFLICT (user_id, channel_id) DO UPDATE SET + status = EXCLUDED.status, + megagroup = EXCLUDED.megagroup, + broadcast = EXCLUDED.broadcast, + deleted = EXCLUDED.deleted, + role = EXCLUDED.role, + left_at = EXCLUDED.left_at, + forum = EXCLUDED.forum, + public_username = EXCLUDED.public_username, + can_pin_messages = EXCLUDED.can_pin_messages, + updated_at = now()`, + member.UserID, channel.ID, string(member.Status), channel.Megagroup, channel.Broadcast, channel.Deleted, + string(member.Role), member.LeftAt, channel.Forum, channel.Username != "", channelMemberCanPinMessages(member)); err != nil { + return fmt.Errorf("upsert user channel member index: %w", err) + } + return nil +} + +func channelMemberCanPinMessages(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) +} + +func markUserChannelMemberIndexDeletedTx(ctx context.Context, tx pgx.Tx, channelID int64, deleted bool) error { + if channelID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +UPDATE user_channel_member_index +SET deleted = $2, updated_at = now() +WHERE channel_id = $1`, channelID, deleted); err != nil { + return fmt.Errorf("mark user channel member index deleted: %w", err) + } + return nil +} + +func markUserChannelMemberIndexPublicTx(ctx context.Context, tx pgx.Tx, channelID int64, public bool) error { + if channelID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +UPDATE user_channel_member_index +SET public_username = $2, updated_at = now() +WHERE channel_id = $1`, channelID, public); err != nil { + return fmt.Errorf("mark user channel member index public: %w", err) + } + return nil +} + +func markUserChannelMemberIndexForumTx(ctx context.Context, tx pgx.Tx, channelID int64, forum bool) error { + if channelID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +UPDATE user_channel_member_index +SET forum = $2, updated_at = now() +WHERE channel_id = $1`, channelID, forum); err != nil { + return fmt.Errorf("mark user channel member index forum: %w", err) + } + return nil +} + +func scanChannelWithMember(row rowScanner) (domain.Channel, domain.ChannelMember, error) { + var ch domain.Channel + var member domain.ChannelMember + var defaultRights, reactionPolicy, adminRights, bannedRights string + var wallpaper *string + var role, status string + dest := append(channelScanDest(&ch, &defaultRights, &reactionPolicy, &wallpaper), + &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, + &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, + &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, + ) + if err := row.Scan(dest...); err != nil { + return domain.Channel{}, domain.ChannelMember{}, err + } + member.Role = domain.ChannelMemberRole(role) + member.Status = domain.ChannelMemberStatus(status) + finishChannelScan(&ch, defaultRights, reactionPolicy, wallpaper) + _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) + _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) + return ch, member, nil +} + +func scanChannelWithViewerMember(row rowScanner) (domain.Channel, bool, error) { + var ch domain.Channel + var viewerMember bool + var rights, reactionPolicy string + var wallpaper *string + dest := append(channelScanDest(&ch, &rights, &reactionPolicy, &wallpaper), + &viewerMember, + ) + if err := row.Scan(dest...); err != nil { + return domain.Channel{}, false, err + } + finishChannelScan(&ch, rights, reactionPolicy, wallpaper) + return ch, viewerMember, nil +} + +func scanChannelMember(row rowScanner) (domain.ChannelMember, error) { + var member domain.ChannelMember + var adminRights, bannedRights string + var role, status string + if err := row.Scan( + &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, + &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, + &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, + ); err != nil { + return domain.ChannelMember{}, err + } + member.Role = domain.ChannelMemberRole(role) + member.Status = domain.ChannelMemberStatus(status) + _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) + _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) + return member, nil +} + +func scanChannelMemberWithCount(row rowScanner) (domain.ChannelMember, int, error) { + var member domain.ChannelMember + var adminRights, bannedRights string + var role, status string + var count int + if err := row.Scan( + &member.ChannelID, &member.UserID, &member.InviterUserID, &role, &status, + &member.JoinedAt, &member.LeftAt, &adminRights, &bannedRights, &member.Rank, + &member.AvailableMinID, &member.AvailableMinPts, &member.ReadInboxMaxID, &member.ReadOutboxMaxID, &member.UnreadMark, &member.SlowmodeLastSendDate, + &count, + ); err != nil { + return domain.ChannelMember{}, 0, err + } + member.Role = domain.ChannelMemberRole(role) + member.Status = domain.ChannelMemberStatus(status) + _ = json.Unmarshal([]byte(adminRights), &member.AdminRights) + _ = json.Unmarshal([]byte(bannedRights), &member.BannedRights) + return member, count, nil +} + +func validateChannelMemberVisible(member domain.ChannelMember) error { + switch member.Status { + case domain.ChannelMemberActive: + if member.BannedRights.ViewMessages { + return domain.ErrChannelUserBanned + } + return nil + case domain.ChannelMemberBanned, domain.ChannelMemberKicked: + return domain.ErrChannelUserBanned + default: + return domain.ErrChannelPrivate + } +} + +func canPostChannel(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PostMessages) +} + +func isChannelAdmin(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin +} + +func canManageDiscussionBroadcast(member domain.ChannelMember) bool { + return canChangeChannelInfo(member) +} + +func canManageDiscussionGroup(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.PinMessages) +} + +func canAddChannelAdmins(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.AddAdmins) +} + +func canBanChannelUsers(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.BanUsers) +} + +func publicPreviewMember(channel domain.Channel, userID int64, existing domain.ChannelMember, found bool) domain.ChannelMember { + member := domain.ChannelMember{ + ChannelID: channel.ID, + UserID: userID, + Role: domain.ChannelRoleMember, + Status: domain.ChannelMemberLeft, + AvailableMinID: channelInitialAvailableMinID(channel), + AvailableMinPts: channelInitialAvailableMinPts(channel), + ReadInboxMaxID: channel.TopMessageID, + ReadOutboxMaxID: channel.TopMessageID, + } + if found { + member.InviterUserID = existing.InviterUserID + member.JoinedAt = existing.JoinedAt + member.LeftAt = existing.LeftAt + member.AvailableMinID = maxInt(member.AvailableMinID, existing.AvailableMinID) + member.AvailableMinPts = maxInt(member.AvailableMinPts, existing.AvailableMinPts) + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, existing.ReadInboxMaxID) + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, existing.ReadOutboxMaxID) + } + return member +} + +func (s *ChannelStore) monoforumAdminPreview(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, mono domain.Channel) (domain.ChannelMember, domain.Channel, bool, error) { + if viewerUserID == 0 || !mono.Monoforum || mono.LinkedMonoforumID == 0 { + return domain.ChannelMember{}, domain.Channel{}, false, nil + } + parent, parentMember, err := s.getChannelForMember(ctx, db, viewerUserID, mono.LinkedMonoforumID) + if err != nil { + if errors.Is(err, domain.ErrChannelPrivate) { + return domain.ChannelMember{}, domain.Channel{}, false, nil + } + return domain.ChannelMember{}, domain.Channel{}, false, err + } + if !isChannelAdmin(parentMember) { + return domain.ChannelMember{}, domain.Channel{}, false, nil + } + return syntheticMonoforumAdminMember(mono, parentMember), parent, true, nil +} + +func syntheticMonoforumAdminMember(mono domain.Channel, parentMember domain.ChannelMember) domain.ChannelMember { + member := parentMember + member.ChannelID = mono.ID + member.Status = domain.ChannelMemberActive + if mono.CreatorUserID == parentMember.UserID { + member.Role = domain.ChannelRoleCreator + } else { + member.Role = domain.ChannelRoleAdmin + } + member.AvailableMinID = 0 + member.AvailableMinPts = 0 + member.ReadInboxMaxID = mono.TopMessageID + member.ReadOutboxMaxID = mono.TopMessageID + member.UnreadMark = false + member.SlowmodeLastSendDate = 0 + return member +} + +func zeroChannelAdminRights(rights domain.ChannelAdminRights) bool { + return rights == domain.ChannelAdminRights{} +} + +func zeroChannelBannedRights(rights domain.ChannelBannedRights) bool { + return rights == domain.ChannelBannedRights{} +} + +func creatorChannelMember(channelID, userID int64, date int) domain.ChannelMember { + return domain.ChannelMember{ + ChannelID: channelID, + UserID: userID, + Role: domain.ChannelRoleCreator, + Status: domain.ChannelMemberActive, + JoinedAt: date, + AdminRights: domain.ChannelAdminRights{ + ChangeInfo: true, + PostMessages: true, + EditMessages: true, + DeleteMessages: true, + PostStories: true, + EditStories: true, + DeleteStories: true, + BanUsers: true, + InviteUsers: true, + PinMessages: true, + AddAdmins: true, + ManageCall: true, + }, + } +} + +func channelMemberIDs(members []domain.ChannelMember) []int64 { + out := make([]int64, 0, len(members)) + for _, member := range members { + if member.UserID != 0 { + out = append(out, member.UserID) + } + } + return out +} diff --git a/internal/store/postgres/channel_member_index_plan_integration_test.go b/internal/store/postgres/channel_member_index_plan_integration_test.go new file mode 100644 index 00000000..4e3bd926 --- /dev/null +++ b/internal/store/postgres/channel_member_index_plan_integration_test.go @@ -0,0 +1,290 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestChannelMemberIndexListPathPlansUseUserIndex(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1887"+suffix+"61", "PlanIndexOwner", "") + member := createTestUser(t, ctx, users, "+1887"+suffix+"62", "PlanIndexMember", "") + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + publicAdmined, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "plan admined " + suffix, + Megagroup: true, + Date: 1700000850, + }) + if err != nil { + t.Fatalf("create public admined: %v", err) + } + channelIDs = append(channelIDs, publicAdmined.Channel.ID) + publicUsername := "planadmined" + suffix + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: publicAdmined.Channel.ID, + Username: publicUsername, + }); err != nil { + t.Fatalf("set public username: %v", err) + } + left, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "plan left " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000851, + }) + if err != nil { + t.Fatalf("create left: %v", err) + } + channelIDs = append(channelIDs, left.Channel.ID) + if _, err := channels.LeaveChannel(ctx, left.Channel.ID, member.ID, 1700000852); err != nil { + t.Fatalf("leave channel: %v", err) + } + discussion, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "plan discussion " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000853, + }) + if err != nil { + t.Fatalf("create discussion: %v", err) + } + channelIDs = append(channelIDs, discussion.Channel.ID) + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: discussion.Channel.ID, + MemberID: member.ID, + AdminRights: domain.ChannelAdminRights{ + PinMessages: true, + }, + Date: 1700000854, + }); err != nil { + t.Fatalf("grant discussion admin: %v", err) + } + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin explain tx: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, "SET LOCAL enable_seqscan = off"); err != nil { + t.Fatalf("disable seqscan: %v", err) + } + + leftPlan := explainText(t, ctx, tx, ` +SELECT channel_id +FROM user_channel_member_index +WHERE user_id = $1 + AND status = 'left' + AND (broadcast OR megagroup) + AND NOT deleted +ORDER BY left_at DESC, channel_id DESC +OFFSET $2 +LIMIT $3 +`, member.ID, 0, domain.MaxLeftChannelsLimit) + requirePlanContains(t, leftPlan, "user_channel_member_index_left_idx") + requirePlanNotContains(t, leftPlan, "channel_members_p") + + leftDetailPlan := explainText(t, ctx, tx, ` +SELECT channel_id +FROM channel_members +WHERE user_id = $1 + AND channel_id = ANY($2::bigint[]) + AND status = 'left' +`, member.ID, []int64{left.Channel.ID}) + requireUniquePartitionCountAtMost(t, leftDetailPlan, `channel_members_p\d+`, 1) + + channelDetailPlan := explainText(t, ctx, tx, ` +SELECT id +FROM channels +WHERE id = ANY($1::bigint[]) + AND NOT deleted +`, []int64{left.Channel.ID}) + requireUniquePartitionCountAtMost(t, channelDetailPlan, `channels_p\d+`, 1) + + usernameLookupPlan := explainText(t, ctx, tx, ` +SELECT peer_id +FROM peer_usernames +WHERE username_lower = $1 AND peer_type = 'channel' +`, publicUsername) + requirePlanContains(t, usernameLookupPlan, "peer_usernames_pkey") + requirePlanNotMatches(t, usernameLookupPlan, `channels_p\d+`) + + usernameChannelDetailPlan := explainText(t, ctx, tx, ` +SELECT id +FROM channels c +WHERE c.id = $1 + AND NOT c.deleted +`, publicAdmined.Channel.ID) + requireUniquePartitionCountAtMost(t, usernameChannelDetailPlan, `channels_p\d+`, 1) + requirePlanNotContains(t, usernameChannelDetailPlan, "Append") + + adminedPlan := explainText(t, ctx, tx, ` +SELECT channel_id +FROM user_channel_member_index +WHERE user_id = $1 + AND status = 'active' + AND role IN ('creator','admin') + AND public_username + AND NOT deleted +ORDER BY channel_id DESC +LIMIT $2 +`, owner.ID, domain.MaxAdminedPublicChannels) + requirePlanContains(t, adminedPlan, "user_channel_member_index_admined_public_idx") + requirePlanNotContains(t, adminedPlan, "channel_members_p") + + discussionPlan := explainText(t, ctx, tx, ` +SELECT channel_id +FROM user_channel_member_index +WHERE user_id = $1 + AND status = 'active' + AND megagroup + AND NOT broadcast + AND NOT forum + AND NOT deleted + AND (role = 'creator' OR can_pin_messages) +ORDER BY channel_id DESC +LIMIT $2 +`, member.ID, domain.MaxDiscussionGroupsLimit) + requirePlanContains(t, discussionPlan, "user_channel_member_index_discussion_idx") + requirePlanNotContains(t, discussionPlan, "channel_members_p") + + dialogCandidatePlan := explainText(t, ctx, tx, ` +SELECT channel_id +FROM user_channel_member_index +WHERE user_id = $1 + AND status = 'active' + AND NOT deleted +ORDER BY channel_id ASC +LIMIT $2 +`, owner.ID, channelDialogCandidateLimit) + requirePlanContains(t, dialogCandidatePlan, "user_channel_member_index") + requirePlanNotContains(t, dialogCandidatePlan, "channel_members_p") + + dialogDetailPlan := explainText(t, ctx, tx, ` +SELECT c.id, + CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END AS visible_top_id, + CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE 0 END AS visible_top_date +FROM channel_members m +JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted +LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted +LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id +WHERE m.user_id = $1 + AND m.channel_id = ANY($2::bigint[]) + AND m.status = 'active' +ORDER BY visible_top_date DESC, + visible_top_id DESC, + c.id DESC +LIMIT $3 +`, owner.ID, []int64{publicAdmined.Channel.ID}, channelDialogQueryLimit) + requireUniquePartitionCountAtMost(t, dialogDetailPlan, `channel_members_p\d+`, 1) + requireUniquePartitionCountAtMost(t, dialogDetailPlan, `channels_p\d+`, 1) + requireUniquePartitionCountAtMost(t, dialogDetailPlan, `channel_messages_p\d+`, 1) + + inactiveCandidatePlan := explainText(t, ctx, tx, ` +SELECT channel_id +FROM user_channel_member_index +WHERE user_id = $1 + AND status = 'active' + AND (broadcast OR megagroup) + AND NOT deleted +ORDER BY channel_id ASC +LIMIT $2 +`, owner.ID, channelDialogCandidateLimit) + requirePlanContains(t, inactiveCandidatePlan, "user_channel_member_index") + requirePlanNotContains(t, inactiveCandidatePlan, "channel_members_p") + + inactiveDetailPlan := explainText(t, ctx, tx, ` +SELECT c.id, + CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END AS visible_top_id, + CASE WHEN c.top_message_id > m.available_min_id THEN COALESCE(top_msg.message_date, d.top_message_date, c.date) ELSE GREATEST(c.date, m.joined_at) END AS visible_top_date +FROM channel_members m +JOIN channels c ON c.id = m.channel_id AND c.id = ANY($2::bigint[]) AND NOT c.deleted +LEFT JOIN channel_messages top_msg ON top_msg.channel_id = m.channel_id AND top_msg.channel_id = ANY($2::bigint[]) AND top_msg.id = c.top_message_id AND NOT top_msg.deleted +LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id +WHERE m.user_id = $1 + AND m.channel_id = ANY($2::bigint[]) + AND m.status = 'active' + AND (c.broadcast OR c.megagroup) +ORDER BY visible_top_date ASC, + visible_top_id ASC, + c.id ASC +LIMIT $3 +`, owner.ID, []int64{publicAdmined.Channel.ID}, domain.MaxInactiveChannelsLimit) + requireUniquePartitionCountAtMost(t, inactiveDetailPlan, `channel_members_p\d+`, 1) + requireUniquePartitionCountAtMost(t, inactiveDetailPlan, `channels_p\d+`, 1) + requireUniquePartitionCountAtMost(t, inactiveDetailPlan, `channel_messages_p\d+`, 1) + + mentionAffectedPlan := explainText(t, ctx, tx, ` +SELECT DISTINCT user_id +FROM channel_unread_mention_index +WHERE channel_id = $1 + AND message_id = ANY($2::int[]) +ORDER BY user_id ASC +`, publicAdmined.Channel.ID, []int32{1, 2}) + requirePlanContains(t, mentionAffectedPlan, "Index") + requireUniquePartitionCountAtMost(t, mentionAffectedPlan, `channel_unread_mention_index_p\d+`, 1) + requirePlanNotMatches(t, mentionAffectedPlan, `channel_unread_mentions_p\d+`) + + mentionDeleteBatchPlan := explainText(t, ctx, tx, ` +WITH affected AS ( + SELECT DISTINCT unnest($3::bigint[]) AS user_id +), +deleted_mentions AS ( + DELETE FROM channel_unread_mentions um + USING affected a + WHERE um.user_id = ANY($3::bigint[]) + AND um.user_id = a.user_id + AND um.channel_id = $1 + AND um.message_id = ANY($2::int[]) + RETURNING um.user_id, um.channel_id, um.message_id +), +deleted_index AS ( + DELETE FROM channel_unread_mention_index i + USING affected a + WHERE i.channel_id = $1 + AND i.user_id = a.user_id + AND i.message_id = ANY($2::int[]) +), +counts_before AS ( + SELECT user_id, COUNT(*)::int AS count + FROM channel_unread_mentions + WHERE channel_id = $1 + AND user_id = ANY($3::bigint[]) + GROUP BY user_id +), +deleted_counts AS ( + SELECT user_id, COUNT(*)::int AS count + FROM deleted_mentions + GROUP BY user_id +) +UPDATE channel_dialogs d +SET unread_mentions_count = GREATEST(COALESCE(c.count, 0) - COALESCE(dc.count, 0), 0), + updated_at = now() +FROM affected a +LEFT JOIN counts_before c ON c.user_id = a.user_id +LEFT JOIN deleted_counts dc ON dc.user_id = a.user_id +WHERE d.user_id = ANY($3::bigint[]) + AND d.channel_id = $1 + AND d.user_id = a.user_id +`, publicAdmined.Channel.ID, []int32{1, 2}, []int64{member.ID}) + requireUniquePartitionCountAtMost(t, mentionDeleteBatchPlan, `channel_unread_mentions_p\d+`, 1) + requireUniquePartitionCountAtMost(t, mentionDeleteBatchPlan, `channel_dialogs_p\d+`, 1) + requireUniquePartitionCountAtMost(t, mentionDeleteBatchPlan, `channel_unread_mention_index_p\d+`, 1) +} diff --git a/internal/store/postgres/channel_member_join.go b/internal/store/postgres/channel_member_join.go new file mode 100644 index 00000000..3676d0e6 --- /dev/null +++ b/internal/store/postgres/channel_member_join.go @@ -0,0 +1,303 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { + if channelID == 0 || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("join channel: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin join channel: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, err := getChannelByID(ctx, tx, channelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + existing, existingErr := s.getChannelMember(ctx, tx, channelID, userID) + if existingErr == nil { + switch { + case existing.Status == domain.ChannelMemberActive: + return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant + case existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages: + return domain.CreateChannelResult{}, domain.ErrChannelUserBanned + } + } + if date == 0 { + date = nowUnix() + } + if channel.JoinRequest { + if err := s.recordPublicJoinRequestTx(ctx, tx, channel, userID, date); err != nil { + return domain.CreateChannelResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit public channel join request: %w", err) + } + committed = true + return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent + } + preJoinTopID := channel.TopMessageID + minID := channelInitialAvailableMinID(channel) + // 自加入:inviter 即本人(对齐官方 channelParticipantSelf.inviter_id == user_id)。客户端据此 + // (TDesktop requestSelf→getParticipant→channel->inviter)生成本地「您加入了此频道」服务消息, + // 进而把广播频道补进会话列表(广播加入无服务端服务消息,靠这条本地消息物化会话)。 + member := domain.ChannelMember{ChannelID: channelID, UserID: userID, InviterUserID: userID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive, JoinedAt: date, AvailableMinID: minID, AvailableMinPts: channelInitialAvailableMinPts(channel), ReadInboxMaxID: maxInt(minID, preJoinTopID)} + if existingErr == nil { + // 重进是全新 participant,但部分限制名单独立于在群与否、重进后仍生效; + // 只有 channel 当前 owner 仍是该账号时,才保留 creator 身份。 + member.BannedRights = existing.BannedRights + if existing.Role == domain.ChannelRoleCreator && channel.CreatorUserID == userID { + member.Role = domain.ChannelRoleCreator + member.AdminRights = existing.AdminRights + member.Rank = existing.Rank + } + } + if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil { + return domain.CreateChannelResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantJoin, + }); err != nil { + return domain.CreateChannelResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + 1, updated_at = now() WHERE id = $1`, channelID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err) + } + channel.ParticipantsCount++ + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatJoined, + UserIDs: []int64{userID}, + }) + if err != nil { + return domain.CreateChannelResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + } + member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID) + if msg.ID != 0 && msg.SenderUserID == userID { + member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID) + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET read_inbox_max_id = GREATEST(read_inbox_max_id, $3), + read_outbox_max_id = GREATEST(read_outbox_max_id, $4), + unread_mark = false, + updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, channelID, userID, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("update joined channel read watermarks: %w", err) + } + if err := upsertChannelDialogTx(ctx, tx, userID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil { + return domain.CreateChannelResult{}, err + } + // 按新的 available_min_id 重算未读 reaction 计数:重进是全新 participant,旧 + // channel_dialogs.unread_reactions_count 是从不在 join 路径重算的存储列, + // PreHistoryHidden/broadcast 重进后 available_min_id 越过旧 reaction,陈旧计数会残留 + // 成幽灵角标。refreshChannelUnreadReactionsCountTx 门控 cm.id>available_min_id,对 + // PreHistoryHidden/broadcast 算出 0(清幽灵)、对 prehistory-visible(min_id 仍 0)保留真实计数。 + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil { + return domain.CreateChannelResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit join channel: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0) + return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil +} + +func (s *ChannelStore) FutureCreatorAfterLeave(ctx context.Context, channelID, userID int64) (domain.ChannelMember, error) { + if channelID == 0 || userID == 0 { + return domain.ChannelMember{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, userID, channelID) + if err != nil { + return domain.ChannelMember{}, err + } + if channel.CreatorUserID != userID || member.Role != domain.ChannelRoleCreator { + return domain.ChannelMember{}, domain.ErrChannelAdminRequired + } + return s.futureCreatorAfterLeave(ctx, s.db, channelID, userID) +} + +func (s *ChannelStore) LeaveChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) { + if channelID == 0 || userID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("leave channel: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin leave channel: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + if date == 0 { + date = nowUnix() + } + members := []domain.ChannelMember{} + adminsDelta := 0 + if isChannelAdmin(member) { + adminsDelta-- + } + if channel.CreatorUserID == userID || member.Role == domain.ChannelRoleCreator { + if channel.CreatorUserID != userID || member.Role != domain.ChannelRoleCreator { + return domain.CreateChannelResult{}, domain.ErrChannelUserCreator + } + future, err := s.futureCreatorAfterLeave(ctx, tx, channelID, userID) + if err != nil { + if errors.Is(err, domain.ErrUserNotParticipant) { + return domain.CreateChannelResult{}, domain.ErrChannelUserCreator + } + return domain.CreateChannelResult{}, err + } + if !isChannelAdmin(future) { + adminsDelta++ + } + future.Role = domain.ChannelRoleCreator + future.AdminRights = creatorChannelMember(channelID, future.UserID, date).AdminRights + future.Rank = "" + future.Status = domain.ChannelMemberActive + future.LeftAt = 0 + channel.CreatorUserID = future.UserID + members = append(members, future) + member.Role = domain.ChannelRoleMember + member.AdminRights = domain.ChannelAdminRights{} + member.Rank = "" + } + member.Status = domain.ChannelMemberLeft + member.LeftAt = date + members = append([]domain.ChannelMember{member}, members...) + if _, err := tx.Exec(ctx, ` +UPDATE channels +SET creator_user_id = $2, + participants_count = GREATEST(participants_count - 1, 0), + admins_count = GREATEST(admins_count + $3, 0), + updated_at = now() +WHERE id = $1`, channelID, channel.CreatorUserID, adminsDelta); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("update channel leave state: %w", err) + } + if channel.ParticipantsCount > 0 { + channel.ParticipantsCount-- + } + channel.AdminsCount += adminsDelta + if channel.AdminsCount < 0 { + channel.AdminsCount = 0 + } + for _, changed := range members { + if err := upsertChannelMemberTx(ctx, tx, channel, changed); err != nil { + return domain.CreateChannelResult{}, err + } + } + recipients, err := s.listActiveChannelMemberIDs(ctx, tx, channelID, 0) + if err != nil { + return domain.CreateChannelResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: date, + Type: domain.ChannelAdminLogParticipantLeave, + }); err != nil { + return domain.CreateChannelResult{}, err + } + if err := clearChannelMentionsForUserTx(ctx, tx, channelID, userID); err != nil { + return domain.CreateChannelResult{}, err + } + var msg domain.ChannelMessage + var event domain.ChannelUpdateEvent + if channel.Megagroup { + msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{ + Type: domain.ChannelActionChatDelete, + UserIDs: []int64{userID}, + }) + if err != nil { + return domain.CreateChannelResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit leave channel: %w", err) + } + committed = true + recipients = append(recipients, userID) + return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil +} + +func (s *ChannelStore) futureCreatorAfterLeave(ctx context.Context, db sqlcgen.DBTX, channelID, userID int64) (domain.ChannelMember, error) { + member, err := scanChannelMember(db.QueryRow(ctx, ` +SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, + admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts, + read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +FROM channel_members +WHERE channel_id = $1 + AND user_id <> $2 + AND status = 'active' + AND role <> 'creator' + AND COALESCE((banned_rights->>'ViewMessages')::boolean, false) = false +ORDER BY CASE role WHEN 'admin' THEN 0 ELSE 1 END, user_id +LIMIT 1`, channelID, userID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelMember{}, domain.ErrUserNotParticipant + } + return domain.ChannelMember{}, err + } + return member, nil +} + +func (s *ChannelStore) listActiveChannelMemberIDs(ctx context.Context, db sqlcgen.DBTX, channelID int64, limit int) ([]int64, error) { + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + rows, err := db.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' ORDER BY user_id LIMIT $2`, channelID, limit) + if err != nil { + return nil, fmt.Errorf("list active channel members: %w", err) + } + defer rows.Close() + out := make([]int64, 0, limit) + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + return nil, err + } + out = append(out, userID) + } + return out, rows.Err() +} diff --git a/internal/store/postgres/channel_member_join_requests.go b/internal/store/postgres/channel_member_join_requests.go new file mode 100644 index 00000000..34ef5fc2 --- /dev/null +++ b/internal/store/postgres/channel_member_join_requests.go @@ -0,0 +1,180 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) SetParticipantsHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + var channel domain.Channel + if err := withTx(ctx, s.db, "toggle channel participants hidden", func(tx pgx.Tx) error { + var err error + var member domain.ChannelMember + channel, member, err = s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return err + } + if !channel.Megagroup || !canBanChannelUsers(member) { + return domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET participants_hidden = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return fmt.Errorf("update channel participants hidden: %w", err) + } + channel.ParticipantsHidden = enabled + return nil + }); err != nil { + return domain.Channel{}, err + } + return channel, nil +} + +func (s *ChannelStore) SetJoinToSend(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + var channel domain.Channel + if err := withTx(ctx, s.db, "toggle channel join_to_send", func(tx pgx.Tx) error { + var err error + var member domain.ChannelMember + channel, member, err = s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return err + } + if !channel.Megagroup || !canExportChannelInvite(member) { + return domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET join_to_send = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return fmt.Errorf("update channel join_to_send: %w", err) + } + channel.JoinToSend = enabled + return nil + }); err != nil { + return domain.Channel{}, err + } + return channel, nil +} + +func (s *ChannelStore) SetJoinRequest(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + var channel domain.Channel + if err := withTx(ctx, s.db, "toggle channel join_request", func(tx pgx.Tx) error { + var err error + var member domain.ChannelMember + channel, member, err = s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return err + } + if !channel.Megagroup || !canExportChannelInvite(member) { + return domain.ErrChannelAdminRequired + } + if enabled && strings.TrimSpace(channel.Username) == "" { + return domain.ErrChatPublicRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET join_request = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return fmt.Errorf("update channel join_request: %w", err) + } + channel.JoinRequest = enabled + return nil + }); err != nil { + return domain.Channel{}, err + } + return channel, nil +} + +func (s *ChannelStore) recordPublicJoinRequestTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, userID int64, date int) error { + if existing, err := s.getChannelMember(ctx, tx, channel.ID, userID); err == nil { + if existing.Status == domain.ChannelMemberActive { + return domain.ErrUserAlreadyParticipant + } + if existing.Status == domain.ChannelMemberKicked || existing.Status == domain.ChannelMemberBanned || existing.BannedRights.ViewMessages { + return domain.ErrInviteHashInvalid + } + } else if !errors.Is(err, domain.ErrChannelPrivate) { + return err + } + if existing, err := s.getPendingInviteImporterTx(ctx, tx, channel.ID, userID, true); err == nil && existing.Requested { + return domain.ErrInviteRequestSent + } else if err != nil && !errors.Is(err, domain.ErrHideRequesterMissing) { + return err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_invite_importers (channel_id, invite_id, user_id, date, requested) +VALUES ($1, 0, $2, $3, true) +ON CONFLICT (channel_id, user_id) DO UPDATE +SET invite_id = 0, + date = EXCLUDED.date, + requested = true, + approved_by = 0, + updated_at = now()`, channel.ID, userID, date); err != nil { + return fmt.Errorf("insert public channel join request: %w", err) + } + return nil +} + +func (s *ChannelStore) HideChatJoinRequest(ctx context.Context, req domain.HideChannelJoinRequestRequest) (domain.CreateChannelResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TargetUserID == 0 { + return domain.CreateChannelResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.CreateChannelResult{}, fmt.Errorf("hide channel join request: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("begin hide channel join request: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.CreateChannelResult{}, err + } + if !canExportChannelInvite(member) { + return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired + } + importer, err := s.getPendingInviteImporterTx(ctx, tx, req.ChannelID, req.TargetUserID, true) + if err != nil { + return domain.CreateChannelResult{}, err + } + invite := domain.ChannelInvite{ChannelID: req.ChannelID, AdminUserID: req.UserID} + if importer.InviteID != 0 { + invite, err = s.getInviteByID(ctx, tx, req.ChannelID, importer.InviteID, true) + if err != nil { + return domain.CreateChannelResult{}, err + } + } + var result domain.CreateChannelResult + if req.Approved { + result, err = s.approveInviteImporterTx(ctx, tx, channel, invite, req.TargetUserID, req.UserID, req.Date) + if err != nil { + return domain.CreateChannelResult{}, err + } + } else if err := deletePendingInviteImporterTx(ctx, tx, invite, req.TargetUserID); err != nil { + return domain.CreateChannelResult{}, err + } else { + result = domain.CreateChannelResult{Channel: channel} + } + if err := tx.Commit(ctx); err != nil { + return domain.CreateChannelResult{}, fmt.Errorf("commit hide channel join request: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + result.Recipients = recipients + return result, nil +} diff --git a/internal/store/postgres/channel_member_list.go b/internal/store/postgres/channel_member_list.go new file mode 100644 index 00000000..c3d03984 --- /dev/null +++ b/internal/store/postgres/channel_member_list.go @@ -0,0 +1,340 @@ +package postgres + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) { + channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelParticipantList{}, err + } + if offset < 0 { + offset = 0 + } + if offset > domain.MaxChannelParticipantsOffset { + offset = domain.MaxChannelParticipantsOffset + } + if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { + limit = domain.MaxChannelParticipantsLimit + } + count := channel.ParticipantsCount + // 广播频道订阅者列表仅管理员可枚举(与隐藏成员同一门控):非管理员只拿到计数, + // admins filter 仍放行(消息管理员徽章数据源),bots 返回空。 + if channel.MembersListAdminOnly() && !isChannelAdmin(viewer) { + switch filter.Kind { + case domain.ChannelParticipantsAdmins: + case domain.ChannelParticipantsBots: + return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil + default: + return domain.ChannelParticipantList{Channel: channel, Count: channel.ParticipantsCount}, nil + } + } + where := []string{"m.channel_id = $1"} + args := []any{channelID} + joinUsers := false + query := strings.TrimSpace(filter.Query) + switch filter.Kind { + case "", domain.ChannelParticipantsRecent, domain.ChannelParticipantsContacts, domain.ChannelParticipantsMentions: + where = append(where, "m.status = 'active'") + case domain.ChannelParticipantsAdmins: + // Layer 225 起 admins filter 同时是消息徽章数据源:客户端把整个返回 + // (含 rank)灌进 badge 缓存,因此带成员 Tag 的普通成员也必须返回。 + where = append(where, "m.status = 'active'", "(m.role IN ('creator','admin') OR m.rank <> '')") + count = 0 + case domain.ChannelParticipantsKicked: + count = channel.KickedCount + if !isChannelAdmin(viewer) { + return domain.ChannelParticipantList{Channel: channel, Count: channel.KickedCount}, nil + } + where = append(where, "(m.status = 'kicked' OR (m.banned_rights->>'ViewMessages')::boolean IS TRUE)") + case domain.ChannelParticipantsBanned: + count = channel.BannedCount + if !isChannelAdmin(viewer) { + return domain.ChannelParticipantList{Channel: channel, Count: channel.BannedCount}, nil + } + where = append(where, "m.status <> 'kicked'", `( + m.status = 'banned' OR + (m.banned_rights->>'SendMessages')::boolean IS TRUE OR + (m.banned_rights->>'SendMedia')::boolean IS TRUE OR + (m.banned_rights->>'SendStickers')::boolean IS TRUE OR + (m.banned_rights->>'SendGifs')::boolean IS TRUE OR + (m.banned_rights->>'SendGames')::boolean IS TRUE OR + (m.banned_rights->>'SendInline')::boolean IS TRUE OR + (m.banned_rights->>'EmbedLinks')::boolean IS TRUE OR + (m.banned_rights->>'SendPolls')::boolean IS TRUE OR + (m.banned_rights->>'ChangeInfo')::boolean IS TRUE OR + (m.banned_rights->>'InviteUsers')::boolean IS TRUE OR + (m.banned_rights->>'PinMessages')::boolean IS TRUE +)`) + case domain.ChannelParticipantsSearch: + where = append(where, "m.status = 'active'") + count = 0 + case domain.ChannelParticipantsBots: + return domain.ChannelParticipantList{Channel: channel}, nil + default: + where = append(where, "m.status = 'active'") + } + if !isChannelAdmin(viewer) { + where = append(where, "NOT (m.role IN ('creator','admin') AND COALESCE((m.admin_rights->>'Anonymous')::boolean, false))") + if count == channel.ParticipantsCount { + hiddenCount, err := countHiddenAnonymousChannelAdmins(ctx, s.db, channelID) + if err != nil { + return domain.ChannelParticipantList{}, err + } + count -= hiddenCount + if count < 0 { + count = 0 + } + } + } + if query != "" { + joinUsers = true + count = 0 + args = append(args, "%"+strings.ToLower(query)+"%") + placeholder := fmt.Sprintf("$%d", len(args)) + where = append(where, fmt.Sprintf(`( + lower(COALESCE(u.first_name, '')) LIKE %s OR + lower(COALESCE(u.last_name, '')) LIKE %s OR + lower(COALESCE(u.username, '')) LIKE %s OR + COALESCE(u.phone, '') LIKE %s OR + m.user_id::text LIKE %s +)`, placeholder, placeholder, placeholder, placeholder, placeholder)) + } + args = append(args, offset, limit) + offsetArg := fmt.Sprintf("$%d", len(args)-1) + limitArg := fmt.Sprintf("$%d", len(args)) + from := "FROM channel_members m" + if joinUsers { + from += " JOIN users u ON u.id = m.user_id" + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, + rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +`+from+` +WHERE `+strings.Join(where, " AND ")+` +ORDER BY CASE role WHEN 'creator' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, user_id +OFFSET `+offsetArg+` LIMIT `+limitArg, args...) + if err != nil { + return domain.ChannelParticipantList{}, fmt.Errorf("list channel participants: %w", err) + } + defer rows.Close() + out := domain.ChannelParticipantList{Channel: channel, Count: count} + for rows.Next() { + member, err := scanChannelMember(rows) + if err != nil { + return domain.ChannelParticipantList{}, err + } + out.Participants = append(out.Participants, member) + } + if err := rows.Err(); err != nil { + return domain.ChannelParticipantList{}, err + } + if out.Count == 0 { + out.Count = len(out.Participants) + } + return out, nil +} + +func countHiddenAnonymousChannelAdmins(ctx context.Context, db interface { + QueryRow(context.Context, string, ...any) pgx.Row +}, channelID int64) (int, error) { + var count int + if err := db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_members +WHERE channel_id = $1 + AND status = 'active' + AND role IN ('creator','admin') + AND COALESCE((admin_rights->>'Anonymous')::boolean, false)`, channelID).Scan(&count); err != nil { + return 0, fmt.Errorf("count hidden anonymous channel admins: %w", err) + } + return count, nil +} + +func (s *ChannelStore) GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) { + if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { + return domain.ChannelMember{}, err + } + return s.getChannelMember(ctx, s.db, channelID, participantUserID) +} + +func (s *ChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { + if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { + return nil, err + } + if limit <= 0 || limit > domain.MaxChannelRealtimeFanout { + limit = domain.MaxChannelRealtimeFanout + } + rows, err := s.db.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' ORDER BY user_id LIMIT $2`, channelID, limit) + if err != nil { + return nil, fmt.Errorf("list active channel members: %w", err) + } + defer rows.Close() + out := make([]int64, 0, limit) + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + return nil, err + } + out = append(out, userID) + } + return out, rows.Err() +} + +func (s *ChannelStore) ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) { + channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, nil, err + } + if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { + limit = domain.MaxSynchronousChannelDialogFanout + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at, admin_rights::text, banned_rights::text, + rank, available_min_id, available_min_pts, read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date +FROM channel_members +WHERE channel_id = $1 AND status = 'active' +ORDER BY user_id +LIMIT $2`, channelID, limit) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, nil, fmt.Errorf("list active channel members: %w", err) + } + defer rows.Close() + out := make([]domain.ChannelMember, 0, minInt(limit, channel.ParticipantsCount)) + for rows.Next() { + member, err := scanChannelMember(rows) + if err != nil { + return domain.Channel{}, domain.ChannelMember{}, nil, err + } + out = append(out, member) + } + if err := rows.Err(); err != nil { + return domain.Channel{}, domain.ChannelMember{}, nil, err + } + return channel, viewer, out, nil +} + +func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUserID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error) { + channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelParticipantList{}, err + } + if channel.ParticipantsHidden && !isChannelAdmin(viewer) { + return domain.ChannelParticipantList{Channel: channel, Count: 0}, nil + } + if offset < 0 { + offset = 0 + } + if offset > domain.MaxChannelParticipantsOffset { + offset = domain.MaxChannelParticipantsOffset + } + if limit <= 0 || limit > domain.MaxChannelParticipantsLimit { + limit = domain.MaxChannelParticipantsLimit + } + rows, err := s.db.Query(ctx, ` +SELECT m.channel_id, m.user_id, m.inviter_user_id, m.role, m.status, m.joined_at, m.left_at, + m.admin_rights::text, m.banned_rights::text, m.rank, m.available_min_id, m.available_min_pts, + m.read_inbox_max_id, m.read_outbox_max_id, m.unread_mark, m.slowmode_last_send_date, + COUNT(*) OVER()::int +FROM bots b +JOIN channel_members m ON m.user_id = b.bot_user_id +WHERE m.channel_id = $1 AND m.status = 'active' +ORDER BY m.user_id +OFFSET $2 LIMIT $3`, channelID, offset, limit) + if err != nil { + return domain.ChannelParticipantList{}, fmt.Errorf("list active channel bot members: %w", err) + } + defer rows.Close() + out := domain.ChannelParticipantList{Channel: channel} + for rows.Next() { + member, count, err := scanChannelMemberWithCount(rows) + if err != nil { + return domain.ChannelParticipantList{}, err + } + out.Participants = append(out.Participants, member) + out.Count = count + } + if err := rows.Err(); err != nil { + return domain.ChannelParticipantList{}, err + } + return out, nil +} + +func (s *ChannelStore) ListActiveChannelBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) { + if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil { + return nil, err + } + if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout { + limit = domain.MaxSynchronousChannelDialogFanout + } + rows, err := s.db.Query(ctx, ` +SELECT m.user_id +FROM bots b +JOIN channel_members m ON m.user_id = b.bot_user_id +WHERE m.channel_id = $1 AND m.status = 'active' +ORDER BY m.user_id +LIMIT $2`, channelID, limit) + if err != nil { + return nil, fmt.Errorf("list active channel bot member ids: %w", err) + } + defer rows.Close() + out := make([]int64, 0, minInt(limit, domain.MaxChannelParticipantsLimit)) + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + return nil, err + } + out = append(out, userID) + } + return out, rows.Err() +} + +func (s *ChannelStore) FilterActiveChannelMemberIDs(ctx context.Context, channelID int64, userIDs []int64) ([]int64, error) { + if channelID == 0 || len(userIDs) == 0 { + return nil, nil + } + candidates := uniqueChannelUserIDs(userIDs, 0) + if len(candidates) == 0 { + return nil, nil + } + out := make([]int64, 0, len(candidates)) + for start := 0; start < len(candidates); start += channelMemberFilterBatch { + end := start + channelMemberFilterBatch + if end > len(candidates) { + end = len(candidates) + } + rows, err := s.db.Query(ctx, ` +SELECT user_id +FROM channel_members +WHERE channel_id = $1 + AND user_id = ANY($2::bigint[]) + AND status = 'active' +ORDER BY user_id`, channelID, candidates[start:end]) + if err != nil { + return nil, fmt.Errorf("filter active channel members: %w", err) + } + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + rows.Close() + return nil, err + } + out = append(out, userID) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out, nil +} diff --git a/internal/store/postgres/channel_member_rank_integration_test.go b/internal/store/postgres/channel_member_rank_integration_test.go new file mode 100644 index 00000000..cb5ba80c --- /dev/null +++ b/internal/store/postgres/channel_member_rank_integration_test.go @@ -0,0 +1,256 @@ +package postgres + +import ( + "context" + "errors" + "telesrv/internal/domain" + "testing" +) + +// TestChannelStoreEditMemberRank 验证 rank-only 编辑在 PG 上的持久化、权限矩阵、 +// 以及 participant_edit_rank 通过 channel_admin_log_events_type_check 约束(0086)。 +func TestChannelStoreEditMemberRank(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 141, Phone: "+1889" + suffix + "01", FirstName: "RankOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 142, Phone: "+1889" + suffix + "02", FirstName: "RankMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Rank Integration", + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700009000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + // creator 给普通成员设 tag:角色不变、rank 持久化。 + res, err := channels.EditChannelMemberRank(ctx, domain.EditChannelMemberRankRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: member.ID, + Rank: "navigator", + Date: 1700009001, + }) + if err != nil { + t.Fatalf("creator edits member rank: %v", err) + } + if res.Participant.Role != domain.ChannelRoleMember || res.Participant.Rank != "navigator" { + t.Fatalf("participant after rank edit = %+v, want member role with navigator rank", res.Participant) + } + stored, err := channels.GetParticipant(ctx, owner.ID, channelID, member.ID) + if err != nil { + t.Fatalf("get participant: %v", err) + } + if stored.Role != domain.ChannelRoleMember || stored.Rank != "navigator" { + t.Fatalf("stored participant = %+v, want member role with navigator rank", stored) + } + + // 普通成员改自己:开关开 → 成功;default_banned_rights.edit_rank=true → 拒绝。 + if _, err := channels.EditChannelMemberRank(ctx, domain.EditChannelMemberRankRequest{ + UserID: member.ID, + ChannelID: channelID, + MemberID: member.ID, + Rank: "pilot", + Date: 1700009002, + }); err != nil { + t.Fatalf("member edits own rank: %v", err) + } + if _, err := channels.EditChannelDefaultBannedRights(ctx, domain.EditChannelDefaultBannedRightsRequest{ + UserID: owner.ID, + ChannelID: channelID, + BannedRights: domain.ChannelBannedRights{EditRank: true}, + Date: 1700009003, + }); err != nil { + t.Fatalf("disable member tags: %v", err) + } + view, err := channels.GetChannel(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("get channel: %v", err) + } + if !view.Channel.DefaultBannedRights.EditRank { + t.Fatalf("default banned rights = %+v, want edit_rank=true round-trip", view.Channel.DefaultBannedRights) + } + if _, err := channels.EditChannelMemberRank(ctx, domain.EditChannelMemberRankRequest{ + UserID: member.ID, + ChannelID: channelID, + MemberID: member.ID, + Rank: "pilot2", + Date: 1700009004, + }); !errors.Is(err, domain.ErrChannelRightForbidden) { + t.Fatalf("member self edit with switch off err = %v, want ErrChannelRightForbidden", err) + } + // 普通成员改别人 → 需要管理权限。 + if _, err := channels.EditChannelMemberRank(ctx, domain.EditChannelMemberRankRequest{ + UserID: member.ID, + ChannelID: channelID, + MemberID: owner.ID, + Rank: "x", + Date: 1700009005, + }); !errors.Is(err, domain.ErrChannelUserCreator) && !errors.Is(err, domain.ErrChannelAdminRequired) { + t.Fatalf("member edits creator err = %v, want creator/admin-required rejection", err) + } + + // manage_ranks 管理员权限位经 PG round-trip。 + if _, err := channels.EditChannelAdmin(ctx, domain.EditChannelAdminRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: member.ID, + AdminRights: domain.ChannelAdminRights{ChangeInfo: true, ManageRanks: true}, + Date: 1700009006, + }); err != nil { + t.Fatalf("promote with manage_ranks: %v", err) + } + promoted, err := channels.GetParticipant(ctx, owner.ID, channelID, member.ID) + if err != nil { + t.Fatalf("get promoted participant: %v", err) + } + if !promoted.AdminRights.ManageRanks { + t.Fatalf("promoted admin rights = %+v, want manage_ranks=true round-trip", promoted.AdminRights) + } + + // admin log:participant_edit_rank 行已通过 0086 CHECK 落库,filter 可检索。 + log, err := channels.ListAdminLog(ctx, domain.ChannelAdminLogRequest{ + UserID: owner.ID, + ChannelID: channelID, + Limit: 20, + Filter: domain.ChannelAdminLogFilter{EditRank: true}, + }) + if err != nil { + t.Fatalf("list admin log: %v", err) + } + if len(log.Events) != 2 { + t.Fatalf("edit rank admin log events = %d (%+v), want 2", len(log.Events), log.Events) + } + for _, event := range log.Events { + if event.Type != domain.ChannelAdminLogParticipantEditRank { + t.Fatalf("admin log event type = %s, want participant_edit_rank", event.Type) + } + if event.Participant == nil || event.Participant.UserID == 0 { + t.Fatalf("admin log event participant missing: %+v", event) + } + } + if log.Events[0].NewString != "pilot" || log.Events[1].NewString != "navigator" { + t.Fatalf("admin log ranks = %q,%q, want pilot,navigator (desc order)", log.Events[0].NewString, log.Events[1].NewString) + } + + // 重进是全新 participant:admin(manage_ranks + rank)退群重进后为普通成员、 + // 无 admin rights、无 rank;部分限制名单独立于在群与否、重进后仍生效。 + if _, err := channels.EditChannelBanned(ctx, domain.EditChannelBannedRequest{ + UserID: owner.ID, + ChannelID: channelID, + Participant: domain.Peer{Type: domain.PeerTypeUser, ID: member.ID}, + BannedRights: domain.ChannelBannedRights{SendMessages: true}, + Date: 1700009007, + }); err != nil { + t.Fatalf("restrict member: %v", err) + } + if _, err := channels.LeaveChannel(ctx, channelID, member.ID, 1700009008); err != nil { + t.Fatalf("member leaves: %v", err) + } + if _, err := channels.JoinChannel(ctx, channelID, member.ID, 1700009009); err != nil { + t.Fatalf("member rejoins: %v", err) + } + rejoined, err := channels.GetParticipant(ctx, owner.ID, channelID, member.ID) + if err != nil { + t.Fatalf("get rejoined member: %v", err) + } + if rejoined.Role != domain.ChannelRoleMember || rejoined.Rank != "" || rejoined.AdminRights != (domain.ChannelAdminRights{}) { + t.Fatalf("rejoined member = %+v, want plain member without rank/admin rights", rejoined) + } + if !rejoined.BannedRights.SendMessages { + t.Fatalf("rejoined member banned rights = %+v, want partial restriction kept", rejoined.BannedRights) + } + if rejoined.JoinedAt != 1700009009 || rejoined.InviterUserID != 0 { + t.Fatalf("rejoined member joined_at/inviter = %d/%d, want fresh join date and no inviter", rejoined.JoinedAt, rejoined.InviterUserID) + } + + // creator 离开会把 owner 交给其他活跃成员;旧 creator 重进是普通成员, + // 不保留 creator tag。 + if _, err := channels.EditChannelMemberRank(ctx, domain.EditChannelMemberRankRequest{ + UserID: owner.ID, + ChannelID: channelID, + MemberID: owner.ID, + Rank: "captain", + Date: 1700009010, + }); err != nil { + t.Fatalf("creator sets own rank: %v", err) + } + if _, err := channels.LeaveChannel(ctx, channelID, owner.ID, 1700009011); err != nil { + t.Fatalf("creator leaves: %v", err) + } + newCreator, err := channels.GetParticipant(ctx, member.ID, channelID, member.ID) + if err != nil { + t.Fatalf("get future creator: %v", err) + } + if newCreator.Role != domain.ChannelRoleCreator { + t.Fatalf("future creator = %+v, want creator role", newCreator) + } + if _, err := channels.JoinChannel(ctx, channelID, owner.ID, 1700009012); err != nil { + t.Fatalf("creator rejoins: %v", err) + } + creatorBack, err := channels.GetParticipant(ctx, member.ID, channelID, owner.ID) + if err != nil { + t.Fatalf("get rejoined old creator: %v", err) + } + if creatorBack.Role != domain.ChannelRoleMember || creatorBack.Rank != "" { + t.Fatalf("rejoined old creator = %+v, want plain member without rank", creatorBack) + } + + // 成员 Tag 是 megagroup 专属:broadcast 上 creator/self 一律 MEGAGROUP_ID_INVALID。 + var broadcastID int64 + t.Cleanup(func() { + if broadcastID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", broadcastID) + } + }) + broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Rank Broadcast", + Broadcast: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700009100, + }) + if err != nil { + t.Fatalf("create broadcast: %v", err) + } + broadcastID = broadcast.Channel.ID + if _, err := channels.EditChannelMemberRank(ctx, domain.EditChannelMemberRankRequest{ + UserID: owner.ID, + ChannelID: broadcastID, + MemberID: member.ID, + Rank: "vip", + Date: 1700009101, + }); !errors.Is(err, domain.ErrMegagroupIDInvalid) { + t.Fatalf("creator tags broadcast subscriber err = %v, want ErrMegagroupIDInvalid", err) + } + if _, err := channels.EditChannelMemberRank(ctx, domain.EditChannelMemberRankRequest{ + UserID: member.ID, + ChannelID: broadcastID, + MemberID: member.ID, + Rank: "fan", + Date: 1700009102, + }); !errors.Is(err, domain.ErrMegagroupIDInvalid) { + t.Fatalf("broadcast subscriber tags self err = %v, want ErrMegagroupIDInvalid", err) + } +} diff --git a/internal/store/postgres/channel_message_delete.go b/internal/store/postgres/channel_message_delete.go new file mode 100644 index 00000000..593e2db9 --- /dev/null +++ b/internal/store/postgres/channel_message_delete.go @@ -0,0 +1,458 @@ +package postgres + +import ( + "context" + "fmt" + "github.com/jackc/pgx/v5" + "sort" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *ChannelStore) DeleteChannelMessages(ctx context.Context, req domain.DeleteChannelMessagesRequest) (domain.DeleteChannelMessagesResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || len(req.IDs) == 0 { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxDeleteMessageIDs { + return domain.DeleteChannelMessagesResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelMessagesResult{}, fmt.Errorf("delete channel messages: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelMessagesResult{}, fmt.Errorf("begin delete channel messages: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelMessagesResult{}, err + } + refs, err := s.discussionRefsForMessages(ctx, tx, channel.ID, req.IDs) + if err != nil { + return domain.DeleteChannelMessagesResult{}, err + } + deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, req.IDs, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelMessagesResult{}, err + } + cascades, err := s.cascadeDeleteDiscussionRootsTx(ctx, tx, refs, deleted, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelMessagesResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelMessagesResult{}, fmt.Errorf("commit delete channel messages: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + for i := range cascades { + cascades[i].Recipients, _ = s.ListActiveChannelMemberIDs(ctx, 0, cascades[i].Channel.ID, 0) + } + return domain.DeleteChannelMessagesResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, DiscussionDeletes: cascades}, nil +} + +// discussionRefsForMessages 取待删消息携带的讨论组转发根引用。 +func (s *ChannelStore) discussionRefsForMessages(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) (map[int]domain.ChannelDiscussionRef, error) { + id32, _, err := validUniqueChannelMessageIDs(ids) + if err != nil { + return nil, err + } + rows, err := tx.Query(ctx, ` +SELECT id, discussion_channel_id, discussion_message_id +FROM channel_messages +WHERE channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted + AND discussion_channel_id <> 0 AND discussion_message_id <> 0`, channelID, id32) + if err != nil { + return nil, fmt.Errorf("list discussion refs for delete: %w", err) + } + defer rows.Close() + out := make(map[int]domain.ChannelDiscussionRef) + for rows.Next() { + var id, msgID int + var discussionChannelID int64 + if err := rows.Scan(&id, &discussionChannelID, &msgID); err != nil { + return nil, err + } + out[id] = domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: msgID} + } + return out, rows.Err() +} + +// cascadeDeleteDiscussionRootsTx 随 broadcast post 删除其在 linked 讨论组的 +// 转发根(官方为服务端级联);锁序固定 post channel → discussion channel, +// 讨论组侧删根不反向级联,无交叉死锁路径。 +func (s *ChannelStore) cascadeDeleteDiscussionRootsTx(ctx context.Context, tx pgx.Tx, refs map[int]domain.ChannelDiscussionRef, deleted []int, actorUserID int64, date int) ([]domain.ChannelCascadeDelete, error) { + if len(refs) == 0 || len(deleted) == 0 { + return nil, nil + } + byChannel := make(map[int64][]int) + for _, id := range deleted { + ref, ok := refs[id] + if !ok || ref.ChannelID == 0 || ref.MessageID == 0 { + continue + } + byChannel[ref.ChannelID] = append(byChannel[ref.ChannelID], ref.MessageID) + } + if len(byChannel) == 0 { + return nil, nil + } + channelIDs := make([]int64, 0, len(byChannel)) + for id := range byChannel { + channelIDs = append(channelIDs, id) + } + sort.Slice(channelIDs, func(i, j int) bool { return channelIDs[i] < channelIDs[j] }) + out := make([]domain.ChannelCascadeDelete, 0, len(channelIDs)) + for _, discussionChannelID := range channelIDs { + group, err := getChannelByID(ctx, tx, discussionChannelID) + if err != nil || group.Deleted { + continue + } + // 级联是服务端动作,按 creator 权限执行(频道 admin 未必是讨论组成员)。 + systemMember := domain.ChannelMember{ChannelID: discussionChannelID, UserID: actorUserID, Role: domain.ChannelRoleCreator, Status: domain.ChannelMemberActive} + groupDeleted, groupEvent, group, err := s.deleteChannelMessagesTx(ctx, tx, group, systemMember, byChannel[discussionChannelID], actorUserID, date) + if err != nil { + return nil, fmt.Errorf("cascade delete discussion roots: %w", err) + } + if len(groupDeleted) == 0 { + continue + } + out = append(out, domain.ChannelCascadeDelete{Channel: group, Event: groupEvent}) + } + return out, nil +} + +func (s *ChannelStore) DeleteChannelHistory(ctx context.Context, req domain.DeleteChannelHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete channel history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete channel history: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + maxID := req.MaxID + if maxID <= 0 || maxID > channel.TopMessageID { + maxID = channel.TopMessageID + } + if !req.ForEveryone { + appliedMinID := maxInt(member.AvailableMinID, maxID) + topID, topDate, err := visibleChannelTopAfter(ctx, tx, req.ChannelID, appliedMinID, channel.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET available_min_id = GREATEST(available_min_id, $3), + read_inbox_max_id = GREATEST(read_inbox_max_id, $3), + unread_mark = false, + updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, appliedMinID); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("update channel local clear member: %w", err) + } + if err := deleteChannelUnreadMentionsUpToTx(ctx, tx, req.UserID, req.ChannelID, appliedMinID); err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, req.UserID, req.ChannelID); err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark +) VALUES ($1,$2,$3,$4,$5,0,0,false) +ON CONFLICT (user_id, channel_id) DO UPDATE SET + top_message_id = EXCLUDED.top_message_id, + top_message_date = EXCLUDED.top_message_date, + read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), + unread_count = 0, + unread_mark = false, + updated_at = now()`, req.UserID, req.ChannelID, topID, topDate, appliedMinID); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("upsert channel local clear dialog: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit local clear channel history: %w", err) + } + committed = true + return domain.DeleteChannelHistoryResult{Channel: channel, AvailableMinID: appliedMinID}, nil + } + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + // id=1 是建群服务消息,全员清空必须保留:它是清空后会话仅剩的 + // top message,没有它客户端会把 lastMessage 视为空并从聊天列表 + // 隐藏该会话(成员资格仍在,但会话条目对全员消失)。 + rows, err := tx.Query(ctx, ` +SELECT id +FROM channel_messages +WHERE channel_id = $1 AND id <= $2 AND id > 1 AND NOT deleted +ORDER BY id DESC +LIMIT $3`, req.ChannelID, maxID, domain.MaxDeleteHistoryBatch) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list channel history delete ids: %w", err) + } + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + rows.Close() + deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete channel history: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + offset := 0 + if len(deleted) == domain.MaxDeleteHistoryBatch { + offset = 1 + } + return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil +} + +func (s *ChannelStore) DeleteChannelParticipantHistory(ctx context.Context, req domain.DeleteChannelParticipantHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete participant channel history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete participant channel history: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + // 同全员清空:id=1 建群服务消息不随发送者(创建者)历史一起删除, + // 否则会话会因 lastMessage 为空从全员聊天列表隐藏。 + rows, err := tx.Query(ctx, ` +SELECT id +FROM channel_messages +WHERE channel_id = $1 AND sender_user_id = $2 AND id > 1 AND NOT deleted +ORDER BY id DESC +LIMIT $3`, req.ChannelID, req.ParticipantUserID, domain.MaxDeleteHistoryBatch) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list participant channel history delete ids: %w", err) + } + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + rows.Close() + deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete participant channel history: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + offset := 0 + if len(deleted) == domain.MaxDeleteHistoryBatch { + offset = 1 + } + return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil +} + +func (s *ChannelStore) deleteChannelMessagesTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, member domain.ChannelMember, ids []int, actorUserID int64, date int) ([]int, domain.ChannelUpdateEvent, domain.Channel, error) { + if len(ids) == 0 { + return nil, domain.ChannelUpdateEvent{}, channel, nil + } + id32, ordered, err := validUniqueChannelMessageIDs(ids) + if err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + rows, err := tx.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted +ORDER BY id`, channel.ID, id32) + if err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("list channel messages for delete: %w", err) + } + byID := make(map[int]domain.ChannelMessage, len(ordered)) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + rows.Close() + return nil, domain.ChannelUpdateEvent{}, channel, err + } + byID[msg.ID] = msg + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, domain.ChannelUpdateEvent{}, channel, err + } + rows.Close() + deleted := make([]int, 0, len(ordered)) + for _, id := range ordered { + msg, ok := byID[id] + if !ok { + continue + } + if msg.SenderUserID != actorUserID && !canDeleteAnyChannelMessage(member) { + return nil, domain.ChannelUpdateEvent{}, channel, domain.ErrChannelAdminRequired + } + if id <= 1 { + // id=1 建群服务消息是清空后会话仅剩的兜底 top message,所有 + // 删除入口统一静默跳过(官方客户端对它禁用删除)。 + continue + } + deleted = append(deleted, id) + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channel.ID, + UserID: actorUserID, + Date: date, + Type: domain.ChannelAdminLogDeleteMessage, + Message: &msg, + Query: msg.Body, + }); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + } + if len(deleted) == 0 { + return nil, domain.ChannelUpdateEvent{}, channel, nil + } + pts, err := s.reserveChannelPtsN(ctx, tx, channel.ID, len(deleted)) + if err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("allocate channel delete pts: %w", err) + } + deleted32 := int32s(deleted) + if _, err := tx.Exec(ctx, ` +UPDATE channel_messages +SET deleted = true, pts = $3, updated_at = now() +WHERE channel_id = $1 AND id = ANY($2::int[])`, channel.ID, deleted32, pts); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("soft delete channel messages: %w", err) + } + if err := deleteChannelUnreadMentionsTx(ctx, tx, channel.ID, deleted); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + if err := refreshChannelUnreadReactionsCountsForMessagesTx(ctx, tx, channel.ID, deleted); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + topID, err := topNonDeletedChannelMessageID(ctx, tx, channel.ID) + if err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + var latestPinned int + if err := tx.QueryRow(ctx, ` +UPDATE channels +SET top_message_id = $2, pts = $3, + -- 删除即从置顶集合移除(pinned 查询过滤 NOT deleted 自动免疫), + -- 这里同步重算「最新置顶 id」缓存避免悬挂。 + pinned_message_id = COALESCE(( + SELECT MAX(id) FROM channel_messages + WHERE channel_id = $1 AND pinned AND NOT deleted + ), 0), + updated_at = now() +WHERE id = $1 +RETURNING pinned_message_id`, channel.ID, topID, pts).Scan(&latestPinned); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, fmt.Errorf("update channel top after delete: %w", err) + } + channel.TopMessageID = topID + channel.Pts = pts + channel.PinnedMessageID = latestPinned + if err := refreshChannelDialogsAfterDeleteTx(ctx, tx, channel); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + event := domain.ChannelUpdateEvent{ + ChannelID: channel.ID, + Type: domain.ChannelUpdateDeleteMessages, + Pts: pts, + PtsCount: len(deleted), + Date: date, + MessageIDs: append([]int(nil), deleted...), + SenderUserID: actorUserID, + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return nil, domain.ChannelUpdateEvent{}, channel, err + } + return deleted, event, channel, nil +} + +func validUniqueChannelMessageIDs(ids []int) ([]int32, []int, error) { + seen := make(map[int]struct{}, len(ids)) + out := make([]int, 0, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + return nil, nil, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return int32s(out), out, nil +} + +func topNonDeletedChannelMessageID(ctx context.Context, db sqlcgen.DBTX, channelID int64) (int, error) { + var id int + if err := db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&id); err != nil { + return 0, fmt.Errorf("select channel top after delete: %w", err) + } + return id, nil +} + +func canDeleteAnyChannelMessage(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.DeleteMessages) +} diff --git a/internal/store/postgres/channel_message_edit.go b/internal/store/postgres/channel_message_edit.go new file mode 100644 index 00000000..f5794272 --- /dev/null +++ b/internal/store/postgres/channel_message_edit.go @@ -0,0 +1,584 @@ +package postgres + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { + // 空文本只在媒体替换(live location 续报/停止)时合法。 + if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelMessageResult{}, fmt.Errorf("edit channel message: db does not support transactions") + } + if req.EditDate == 0 { + req.EditDate = nowUnix() + } + entities, err := encodeMessageEntities(req.Entities) + if err != nil { + return domain.EditChannelMessageResult{}, err + } + replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel edit reply markup: %w", err) + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("begin edit channel message: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelMessageResult{}, err + } + msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.ID) + if err != nil { + return domain.EditChannelMessageResult{}, err + } + if msg.Deleted || msg.Action != nil { + return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid + } + if req.WebPageResolve { + // 频道链接预览就地替换:只换 media(不碰 body/entities/edit_date)+ reserve 频道 pts + + // channel_web_page 事件。幂等守卫:仅当前 media 仍是匹配 id 的 pending 占位才换。 + if req.Media == nil || !domain.IsPendingWebPageMedia(msg.Media, req.ExpectedWebPageID) { + return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified + } + mediaJSON, err := encodeMessageMedia(req.Media) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel web page media: %w", err) + } + pts, err := s.reserveChannelPts(ctx, tx, req.ChannelID) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("allocate channel web page pts: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_messages SET media = $3, pts = $4, updated_at = now() +WHERE channel_id = $1 AND id = $2`, req.ChannelID, req.ID, mediaJSON, pts); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("update channel web page media: %w", err) + } + media := *req.Media + msg.Media = &media + msg.Pts = pts + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateWebPage, + Pts: pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + SenderUserID: req.UserID, + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.EditChannelMessageResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET pts = $2, updated_at = now() WHERE id = $1`, req.ChannelID, pts); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("update channel web page pts: %w", err) + } + channel.Pts = pts + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("commit channel web page resolve: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.EditChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients}, nil + } + // participant todo 协作(append/toggle)等同于在频道产生新内容/服务消息, + // 必须满足与正常发消息相同的权限:被 send_messages 禁言的成员、或无发帖权的 + // broadcast 订阅者不得借 OthersCanComplete/Append 绕过发言限制。 + participantTodoEdit := isChannelTodoParticipantEdit(req, msg) && canSendChannelMessage(channel, member) + viaBotEditRequested := req.ViaBotEditBotID != 0 + viaBotEdit := viaBotEditRequested && msg.ViaBotID == req.ViaBotEditBotID + if viaBotEditRequested && !viaBotEdit { + return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired + } + canWriteEdit := viaBotEdit || msg.SenderUserID == req.UserID || canEditChannelMessage(member) || participantTodoEdit + if !canWriteEdit { + return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired + } + if req.Media == nil && !req.SetReplyMarkup && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { + return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified + } + ptsCount := 1 + if req.TodoServiceAction != nil { + ptsCount = 2 + } + finalPts, err := s.reserveChannelPtsN(ctx, tx, req.ChannelID, ptsCount) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("allocate channel edit pts: %w", err) + } + editPts := finalPts - ptsCount + 1 + prevMsg := msg + if _, err := tx.Exec(ctx, ` +UPDATE channel_messages +SET body = $4, + entities = $5, + edit_date = $6, + pts = $7, + reply_markup = CASE WHEN $9 THEN $10::jsonb ELSE reply_markup END, + updated_at = now() +WHERE channel_id = $1 AND id = $2 AND NOT deleted AND (sender_user_id = $3 OR $8)`, + req.ChannelID, req.ID, req.UserID, req.Message, entities, req.EditDate, editPts, canWriteEdit, req.SetReplyMarkup, string(replyMarkupJSON)); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit: %w", err) + } + if req.Media != nil { + mediaJSON, err := encodeMessageMedia(req.Media) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel edit media: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_messages SET media = $3, updated_at = now() +WHERE channel_id = $1 AND id = $2`, req.ChannelID, req.ID, mediaJSON); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit media: %w", err) + } + media := *req.Media + msg.Media = &media + } + msg.Body = req.Message + msg.Entities = append([]domain.MessageEntity(nil), req.Entities...) + // 共享媒体索引(0118):编辑可换媒体、也可改文本里的链接实体 → 按编辑后的有效媒体+实体重建索引。 + if err := replaceChannelMediaIndexTx(ctx, tx, req.ChannelID, req.ID, msg.Date, msg.Media, msg.Entities); err != nil { + return domain.EditChannelMessageResult{}, err + } + if req.SetReplyMarkup { + msg.ReplyMarkup, err = decodeReplyMarkup(string(replyMarkupJSON)) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("decode channel edit reply markup: %w", err) + } + } + msg.EditDate = req.EditDate + msg.Pts = editPts + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateEditMessage, + Pts: editPts, + PtsCount: 1, + Date: req.EditDate, + Message: msg, + SenderUserID: req.UserID, + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.EditChannelMessageResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET pts = $2, updated_at = now() WHERE id = $1`, req.ChannelID, editPts); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit pts: %w", err) + } + channel.Pts = editPts + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.EditDate, + Type: domain.ChannelAdminLogEditMessage, + PrevMessage: &prevMsg, + NewMessage: &msg, + Query: msg.Body, + }); err != nil { + return domain.EditChannelMessageResult{}, err + } + // 编辑后的 @ 集合与现有未读提及对账:新增者补提及(仍按"未读到该 + // 消息"的写入边界),被移除的实体提及删除;reply 隐式提及保留。 + // 仅在 body/entities 实际变化时执行:geolive 续报、todo append/toggle、poll + // 关闭等 media-only 编辑保持正文与 @ 实体不变却不携带 MentionUserIDs(=nil), + // 无条件对账会把 caption 里仍未读的 @ 提及当成"被移除"误删,导致未读提及计数 + // 无法由可见消息重算。 + if prevMsg.Body != req.Message || !sameMessageEntities(prevMsg.Entities, req.Entities) { + if err := s.reconcileChannelEditMentionsTx(ctx, tx, channel, msg, req.UserID, req.MentionUserIDs); err != nil { + return domain.EditChannelMessageResult{}, err + } + } + var serviceMsg domain.ChannelMessage + var serviceEvent domain.ChannelUpdateEvent + if req.TodoServiceAction != nil { + action := cloneChannelMessageAction(req.TodoServiceAction) + if action == nil { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } + serviceMsgID, err := s.msgIDs.NextChannelMessageID(ctx, req.ChannelID) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("allocate channel todo service message id: %w", err) + } + servicePts := finalPts + serviceMsg = domain.ChannelMessage{ + ChannelID: req.ChannelID, + ID: serviceMsgID, + SenderUserID: req.UserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, + Date: req.EditDate, + Post: channel.Broadcast, + Silent: msg.Silent, + NoForwards: msg.NoForwards || channel.NoForwards, + ReplyTo: channelTodoServiceReply(msg), + Action: action, + Pts: servicePts, + } + serviceEvent = domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateNewMessage, + Pts: servicePts, + PtsCount: 1, + Date: req.EditDate, + Message: serviceMsg, + SenderUserID: req.UserID, + } + if err := insertChannelMessageTx(ctx, tx, serviceMsg); err != nil { + return domain.EditChannelMessageResult{}, err + } + if err := insertChannelEventTx(ctx, tx, serviceEvent); err != nil { + return domain.EditChannelMessageResult{}, err + } + if err := updateForumTopicTopMessageTx(ctx, tx, req.ChannelID, serviceMsg); err != nil { + return domain.EditChannelMessageResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.ChannelID, serviceMsgID, servicePts); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("update channel todo service top: %w", err) + } + channel.TopMessageID = serviceMsgID + channel.Pts = servicePts + if err := upsertChannelDialogsForMessageTx(ctx, tx, channel, serviceMsg, req.UserID); err != nil { + return domain.EditChannelMessageResult{}, err + } + } + + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("commit edit channel message: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.EditChannelMessageResult{Channel: channel, Message: msg, Event: event, ServiceMessage: serviceMsg, ServiceEvent: serviceEvent, Recipients: recipients}, nil +} + +func isChannelTodoParticipantEdit(req domain.EditChannelMessageRequest, msg domain.ChannelMessage) bool { + if !req.AllowTodoParticipantMutation || req.SetReplyMarkup || req.Media == nil || req.Media.Kind != domain.MessageMediaKindTodo || req.Media.Todo == nil { + return false + } + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindTodo || msg.Media.Todo == nil { + return false + } + if req.TodoServiceAction == nil { + return false + } + switch req.TodoServiceAction.Type { + case domain.ChannelActionTodoCompletions: + if !msg.Media.Todo.OthersCanComplete { + return false + } + case domain.ChannelActionTodoAppendTasks: + if !msg.Media.Todo.OthersCanAppend { + return false + } + default: + return false + } + return msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) +} + +func channelTodoServiceReply(msg domain.ChannelMessage) *domain.MessageReply { + reply := &domain.MessageReply{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: msg.ChannelID}, + MessageID: msg.ID, + } + if msg.ReplyTo != nil { + reply.TopMessageID = msg.ReplyTo.TopMessageID + reply.ForumTopic = msg.ReplyTo.ForumTopic + } + return reply +} + +func (s *ChannelStore) UpdatePinnedMessage(ctx context.Context, req domain.UpdateChannelPinnedMessageRequest) (domain.UpdateChannelPinnedMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("pin channel message: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("begin pin channel message: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + if !canPinChannelMessages(channel, member) { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired + } + msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) + if err != nil || msg.Deleted { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrMessageIDInvalid + } + // 多置顶模型:pin/unpin 只翻转目标消息自身的 pinned flag,不影响其它 + // 置顶;与官方一致不设数量上限。 + if msg.Pinned == req.Pinned { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified + } + pts, err := s.reserveChannelPts(ctx, tx, req.ChannelID) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("allocate channel pin pts: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_messages SET pinned = $3 +WHERE channel_id = $1 AND id = $2`, req.ChannelID, req.MessageID, req.Pinned); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("update channel message pinned: %w", err) + } + // channels.pinned_message_id 维护为最新置顶 id(channelFull.pinned_msg_id 输出语义)。 + var latestPinned int + if err := tx.QueryRow(ctx, ` +UPDATE channels +SET pinned_message_id = COALESCE(( + SELECT MAX(id) FROM channel_messages + WHERE channel_id = $1 AND pinned AND NOT deleted + ), 0), + pts = $2, updated_at = now() +WHERE id = $1 +RETURNING pinned_message_id`, req.ChannelID, pts).Scan(&latestPinned); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("update channel pinned message: %w", err) + } + channel.PinnedMessageID = latestPinned + channel.Pts = pts + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdatePinnedMessages, + Pts: pts, + PtsCount: 1, + Date: req.Date, + MessageIDs: []int{req.MessageID}, + SenderUserID: req.UserID, + Pinned: req.Pinned, + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + logMsg := msg + logMsg.Pinned = req.Pinned + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogUpdatePinned, + Message: &logMsg, + Query: msg.Body, + }); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("commit pin channel message: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.UpdateChannelPinnedMessageResult{Channel: channel, Event: event, Recipients: recipients}, nil +} + +// UnpinAllChannelMessages clears every pinned message in the channel with one +// channel-pts event carrying all cleared ids. +func (s *ChannelStore) UnpinAllChannelMessages(ctx context.Context, req domain.UnpinAllChannelMessagesRequest) (domain.UpdateChannelPinnedMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("unpin all channel messages: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("begin unpin all channel messages: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + if !canPinChannelMessages(channel, member) { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelAdminRequired + } + rows, err := tx.Query(ctx, ` +UPDATE channel_messages SET pinned = false +WHERE channel_id = $1 AND pinned AND NOT deleted +RETURNING id`, req.ChannelID) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("unpin all channel messages: %w", err) + } + cleared := []int(nil) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + rows.Close() + return domain.UpdateChannelPinnedMessageResult{}, err + } + cleared = append(cleared, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + if len(cleared) == 0 { + return domain.UpdateChannelPinnedMessageResult{}, domain.ErrChannelNotModified + } + sort.Ints(cleared) + pts, err := s.reserveChannelPts(ctx, tx, req.ChannelID) + if err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("allocate channel unpin-all pts: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE channels SET pinned_message_id = 0, pts = $2, updated_at = now() +WHERE id = $1`, req.ChannelID, pts); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("clear channel pinned message: %w", err) + } + channel.PinnedMessageID = 0 + channel.Pts = pts + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdatePinnedMessages, + Pts: pts, + PtsCount: 1, + Date: req.Date, + MessageIDs: cleared, + SenderUserID: req.UserID, + Pinned: false, + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.UpdateChannelPinnedMessageResult{}, fmt.Errorf("commit unpin all channel messages: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.UpdateChannelPinnedMessageResult{Channel: channel, Event: event, Recipients: recipients}, nil +} + +func canPinChannelMessages(channel domain.Channel, member domain.ChannelMember) bool { + if member.Role == domain.ChannelRoleCreator { + return true + } + if member.Role == domain.ChannelRoleAdmin { + // broadcast 频道管理员按 edit_messages 判定(频道的权限编辑 UI + // 没有 pin_messages 位);megagroup 管理员按 pin_messages。 + if channel.Broadcast && !channel.Megagroup { + return member.AdminRights.EditMessages + } + return member.AdminRights.PinMessages + } + // 公开 megagroup 普通成员不可 pin;私有 megagroup 按默认/个人禁言位。 + if !channel.Megagroup || channel.Username != "" { + return false + } + return !channel.DefaultBannedRights.PinMessages && !member.BannedRights.PinMessages +} + +func canEditChannelMessage(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || (member.Role == domain.ChannelRoleAdmin && member.AdminRights.EditMessages) +} + +// ClearDanglingPinnedMessage 把指向已删除消息的置顶值清零;不占 channel +// pts、不发事件(官方删除即从置顶集合移除,无对应 update)。 +func (s *ChannelStore) ClearDanglingPinnedMessage(ctx context.Context, channelID int64, messageID int) error { + if channelID == 0 || messageID <= 0 { + return domain.ErrChannelInvalid + } + _, err := s.db.Exec(ctx, ` +UPDATE channels +SET pinned_message_id = 0, updated_at = now() +WHERE id = $1 AND pinned_message_id = $2`, channelID, messageID) + if err != nil { + return fmt.Errorf("clear dangling pinned message: %w", err) + } + return nil +} + +// reconcileChannelEditMentionsTx 在文本编辑后维护 channel_unread_mentions: +// 新出现的 @ 目标按发送时的写入边界补行;不再被 @ 的用户删除其行并重算 +// dialog 计数。被回复消息作者的隐式提及不在实体集合内,显式排除。 +func (s *ChannelStore) reconcileChannelEditMentionsTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, msg domain.ChannelMessage, actorUserID int64, newTargets []int64) error { + if channel.Broadcast && !channel.Megagroup { + return nil + } + keep := make(map[int64]struct{}, len(newTargets)+1) + for _, id := range newTargets { + if id != 0 { + keep[id] = struct{}{} + } + } + if msg.ReplyTo != nil && msg.ReplyTo.MessageID > 0 { + if target, err := s.getChannelMessage(ctx, tx, channel.ID, msg.ReplyTo.MessageID); err == nil && target.SenderUserID != 0 { + keep[target.SenderUserID] = struct{}{} + } + } + rows, err := tx.Query(ctx, ` +SELECT user_id FROM channel_unread_mention_index +WHERE channel_id = $1 AND message_id = $2`, channel.ID, msg.ID) + if err != nil { + return fmt.Errorf("list edit mention owners: %w", err) + } + existing := make(map[int64]struct{}) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + existing[id] = struct{}{} + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + removed := make([]int64, 0) + for id := range existing { + if _, ok := keep[id]; !ok { + removed = append(removed, id) + } + } + added := make([]int64, 0, len(newTargets)) + for _, id := range newTargets { + if id == 0 { + continue + } + if _, ok := existing[id]; !ok { + added = append(added, id) + } + } + if len(removed) > 0 { + if err := deleteChannelUnreadMentionsForUsersTx(ctx, tx, channel.ID, []int{msg.ID}, removed); err != nil { + return err + } + } + if len(added) > 0 { + if err := insertChannelUnreadMentionsTx(ctx, tx, channel.ID, msg, actorUserID, added); err != nil { + return err + } + } + return nil +} diff --git a/internal/store/postgres/channel_message_helpers.go b/internal/store/postgres/channel_message_helpers.go new file mode 100644 index 00000000..2d6c4374 --- /dev/null +++ b/internal/store/postgres/channel_message_helpers.go @@ -0,0 +1,194 @@ +package postgres + +import ( + "context" + "database/sql" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *ChannelStore) eventForChannelMessage(ctx context.Context, channelID int64, messageID int) (domain.ChannelUpdateEvent, error) { + row := s.db.QueryRow(ctx, ` +SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text +FROM channel_update_events +WHERE channel_id = $1 AND message_id = $2 AND event_type = $3 +ORDER BY pts ASC LIMIT 1`, channelID, messageID, string(domain.ChannelUpdateNewMessage)) + event, _, err := scanChannelEvent(row) + return event, err +} + +func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) { + var msg domain.ChannelMessage + var fromType string + var sendAsType sql.NullString + var sendAsID sql.NullInt64 + var replyMsgID, replyTopID int + var replyPeerType string + var replyPeerID int64 + var discussionChannelID int64 + var discussionMessageID int + var entities, reply, forward, action string + var mediaJSON string + var replyMarkupJSON string + var savedPeerType string + var savedPeerID int64 + if err := row.Scan( + &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, + &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, + &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, + &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, + &replyMarkupJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, + ); err != nil { + return domain.ChannelMessage{}, err + } + msg.From.Type = domain.PeerType(fromType) + msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID} + if sendAsType.Valid && sendAsID.Valid { + msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} + } + parsedEntities, err := decodeMessageEntities(entities) + if err != nil { + return domain.ChannelMessage{}, err + } + msg.Entities = parsedEntities + msg.ReplyTo = channelMessageReplyFromColumns(decodeJSONPtr[domain.MessageReply](reply), replyMsgID, replyPeerType, replyPeerID, replyTopID) + msg.Forward = decodeJSONPtr[domain.MessageForward](forward) + if discussionChannelID != 0 && discussionMessageID != 0 { + msg.Discussion = &domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: discussionMessageID} + } + msg.Action = decodeJSONPtr[domain.ChannelMessageAction](action) + msg.Media, err = decodeMessageMedia(mediaJSON) + if err != nil { + return domain.ChannelMessage{}, err + } + msg.ReplyMarkup, err = decodeReplyMarkup(replyMarkupJSON) + if err != nil { + return domain.ChannelMessage{}, err + } + return msg, nil +} + +func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, error) { + var msg domain.ChannelMessage + var fromType string + var sendAsType sql.NullString + var sendAsID sql.NullInt64 + var replyMsgID, replyTopID int + var replyPeerType string + var replyPeerID int64 + var discussionChannelID int64 + var discussionMessageID int + var entities, reply, forward, action string + var count int + var mediaJSON string + var replyMarkupJSON string + var savedPeerType string + var savedPeerID int64 + if err := row.Scan( + &msg.ChannelID, &msg.ID, &msg.RandomID, &msg.SenderUserID, &fromType, &msg.From.ID, + &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, + &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, + &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, + &replyMarkupJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count, + ); err != nil { + return domain.ChannelMessage{}, 0, err + } + msg.From.Type = domain.PeerType(fromType) + msg.SavedPeer = domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID} + if sendAsType.Valid && sendAsID.Valid { + msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsType.String), ID: sendAsID.Int64} + } + parsedEntities, err := decodeMessageEntities(entities) + if err != nil { + return domain.ChannelMessage{}, 0, err + } + msg.Entities = parsedEntities + msg.ReplyTo = channelMessageReplyFromColumns(decodeJSONPtr[domain.MessageReply](reply), replyMsgID, replyPeerType, replyPeerID, replyTopID) + msg.Forward = decodeJSONPtr[domain.MessageForward](forward) + if discussionChannelID != 0 && discussionMessageID != 0 { + msg.Discussion = &domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: discussionMessageID} + } + msg.Action = decodeJSONPtr[domain.ChannelMessageAction](action) + msg.Media, err = decodeMessageMedia(mediaJSON) + if err != nil { + return domain.ChannelMessage{}, 0, err + } + msg.ReplyMarkup, err = decodeReplyMarkup(replyMarkupJSON) + if err != nil { + return domain.ChannelMessage{}, 0, err + } + return msg, count, nil +} + +func channelMessageReplyFromColumns(reply *domain.MessageReply, msgID int, peerType string, peerID int64, topID int) *domain.MessageReply { + if reply != nil { + if reply.MessageID == 0 { + reply.MessageID = msgID + } + if reply.TopMessageID == 0 { + reply.TopMessageID = topID + } + if reply.Peer.ID == 0 && peerType != "" && peerID != 0 { + reply.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID} + } + if reply.MessageID <= 0 && reply.TopMessageID <= 0 { + return nil + } + return reply + } + if msgID <= 0 && topID <= 0 { + return nil + } + out := &domain.MessageReply{ + MessageID: msgID, + TopMessageID: topID, + } + if peerType != "" && peerID != 0 { + out.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID} + } + return out +} + +func collectChannelMessageRefs(msg domain.ChannelMessage, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { + if msg.SenderUserID != 0 { + userRefs[msg.SenderUserID] = struct{}{} + } + addPeerRef(msg.From, currentChannelID, userRefs, channelRefs) + if msg.SendAs != nil { + addPeerRef(*msg.SendAs, currentChannelID, userRefs, channelRefs) + } + if msg.Forward != nil { + addPeerRef(msg.Forward.From, currentChannelID, userRefs, channelRefs) + } + if msg.ViaBotID != 0 { + userRefs[msg.ViaBotID] = struct{}{} + } + if msg.ReplyTo != nil { + addPeerRef(msg.ReplyTo.Peer, currentChannelID, userRefs, channelRefs) + } + if msg.Action != nil { + for _, id := range msg.Action.UserIDs { + if id != 0 { + userRefs[id] = struct{}{} + } + } + } +} + +type pgChannelMessageIDAllocator struct { + db sqlcgen.DBTX +} + +func (a pgChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) { + current, err := a.CurrentChannelMessageID(ctx, channelID) + if err != nil { + return 0, err + } + return current + 1, nil +} + +func (a pgChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) { + var id int + err := a.db.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) FROM channel_messages WHERE channel_id = $1`, channelID).Scan(&id) + return id, err +} diff --git a/internal/store/postgres/channel_message_history.go b/internal/store/postgres/channel_message_history.go new file mode 100644 index 00000000..4bccdff2 --- /dev/null +++ b/internal/store/postgres/channel_message_history.go @@ -0,0 +1,713 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "strconv" + "strings" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *ChannelStore) ListChannelHistory(ctx context.Context, viewerUserID int64, filter domain.ChannelHistoryFilter) (domain.ChannelHistory, error) { + channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + // 公共过滤条件(不含 offset 锚点的方向条件,供 add_offset 各模式复用) + baseArgs := []any{filter.ChannelID} + base := "channel_id = $1 AND NOT deleted" + extraChannels := []domain.Channel(nil) + if channel.Monoforum { + base += " AND saved_peer_id = 0" + if channel.LinkedMonoforumID != 0 { + if parent, parentErr := s.channelByID(ctx, s.db, channel.LinkedMonoforumID); parentErr == nil { + extraChannels = append(extraChannels, parent) + } else { + return domain.ChannelHistory{}, parentErr + } + } + } + if member.AvailableMinID > 0 { + baseArgs = append(baseArgs, member.AvailableMinID) + base += fmt.Sprintf(" AND id > $%d", len(baseArgs)) + } + if filter.PinnedOnly { + base += " AND pinned" + } + if filter.MusicOnly { + base += ` AND media->>'kind' = 'document' +AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(media #> '{document,attributes}', '[]'::jsonb)) AS attr + WHERE attr->>'kind' = 'audio' + AND COALESCE((attr->>'voice')::boolean, false) = false +)` + } + if filter.Query != "" { + baseArgs = append(baseArgs, filter.Query) + base += fmt.Sprintf(" AND body ILIKE '%%' || $%d || '%%'", len(baseArgs)) + } + if filter.SenderUserID != 0 { + baseArgs = append(baseArgs, filter.SenderUserID) + base += fmt.Sprintf(" AND sender_user_id = $%d", len(baseArgs)) + } + if filter.MinDate > 0 { + baseArgs = append(baseArgs, filter.MinDate) + base += fmt.Sprintf(" AND message_date > $%d", len(baseArgs)) + } + if filter.MaxDate > 0 { + baseArgs = append(baseArgs, filter.MaxDate) + base += fmt.Sprintf(" AND message_date < $%d", len(baseArgs)) + } + if filter.MaxID > 0 { + baseArgs = append(baseArgs, filter.MaxID) + base += fmt.Sprintf(" AND id <= $%d", len(baseArgs)) + } + if filter.MinID > 0 { + baseArgs = append(baseArgs, filter.MinID) + base += fmt.Sprintf(" AND id > $%d", len(baseArgs)) + } + scanList := func(sql string, queryArgs []any) ([]domain.ChannelMessage, error) { + rows, err := s.db.Query(ctx, sql, queryArgs...) + if err != nil { + return nil, fmt.Errorf("list channel history: %w", err) + } + defer rows.Close() + var list []domain.ChannelMessage + for rows.Next() { + msg, scanErr := scanChannelMessage(rows) + if scanErr != nil { + return nil, scanErr + } + list = append(list, msg) + } + return list, rows.Err() + } + // add_offset 决定加载方向(对齐私聊 ListMessagesByUser): + // >= 0 backward:锚点更旧方向,先跳过 add_offset 条 + // < 0 且 +limit>0 around:以锚点为中心,向更新取 -add_offset 条 + 向更旧取 limit+add_offset 条 + // 否则 forward:仅锚点更新方向(拉未读消息) + // store 层二次钳制 add_offset 到 [-100,100](与私聊 ListMessagesByUser 对齐): + // 即便某个 caller 漏在 RPC 层钳制,也不会把客户端巨大值变成大 SQL OFFSET 跳扫。 + addOffset := domain.ClampMessageHistoryAddOffset(filter.AddOffset) + out := domain.ChannelHistory{Channel: channel, Self: member, Channels: extraChannels} + hasMoreOlder := false + // 锚点条件:offset_date 优先按日期、否则按消息 id(对齐私聊); + // 二者皆空时向更新方向退化为空、向更旧方向退化为全部(取最新)。 + forwardCond := func(args *[]any) string { + if filter.OffsetDate > 0 { + *args = append(*args, filter.OffsetDate) + return fmt.Sprintf("message_date >= $%d", len(*args)) + } + if filter.OffsetID > 0 { + *args = append(*args, filter.OffsetID) + return fmt.Sprintf("id > $%d", len(*args)) + } + return "false" + } + aroundOlderCond := func(args *[]any) string { + if filter.OffsetDate > 0 { + *args = append(*args, filter.OffsetDate) + return fmt.Sprintf("message_date < $%d", len(*args)) + } + if filter.OffsetID > 0 { + *args = append(*args, filter.OffsetID) + return fmt.Sprintf("id <= $%d", len(*args)) + } + return "true" + } + switch { + case addOffset < 0 && addOffset+limit > 0: + // around:以锚点为中心,向更新取 -add_offset 条 + 向更旧(含锚点)取 limit+add_offset 条 + fwdLimit := minInt(-addOffset, limit) + bwdLimit := maxInt(limit+addOffset, 0) + fwdArgs := append([]any{}, baseArgs...) + fwdWhere := forwardCond(&fwdArgs) + fwdArgs = append(fwdArgs, fwdLimit) + newer, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id ASC LIMIT $%d", base, fwdWhere, len(fwdArgs)), fwdArgs) + if err != nil { + return domain.ChannelHistory{}, err + } + bwdArgs := append([]any{}, baseArgs...) + bwdWhere := aroundOlderCond(&bwdArgs) + bwdArgs = append(bwdArgs, bwdLimit+1) + older, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id DESC LIMIT $%d", base, bwdWhere, len(bwdArgs)), bwdArgs) + if err != nil { + return domain.ChannelHistory{}, err + } + if len(older) > bwdLimit { + older = older[:bwdLimit] + hasMoreOlder = true + } + for i := len(newer) - 1; i >= 0; i-- { + out.Messages = append(out.Messages, newer[i]) + } + out.Messages = append(out.Messages, older...) + case addOffset < 0: + // forward:仅锚点更新方向(拉未读/更新消息) + fwdArgs := append([]any{}, baseArgs...) + fwdWhere := forwardCond(&fwdArgs) + fwdArgs = append(fwdArgs, limit+1) + newer, err := scanList(fmt.Sprintf("SELECT "+channelMessageColumns+" FROM channel_messages WHERE %s AND %s ORDER BY id ASC LIMIT $%d", base, fwdWhere, len(fwdArgs)), fwdArgs) + if err != nil { + return domain.ChannelHistory{}, err + } + if len(newer) > limit { + newer = newer[:limit] + } + for i := len(newer) - 1; i >= 0; i-- { + out.Messages = append(out.Messages, newer[i]) + } + default: + // backward:锚点更旧方向(不含锚点),先跳过 add_offset 条 + where := base + args := append([]any{}, baseArgs...) + if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + where += fmt.Sprintf(" AND message_date < $%d", len(args)) + } else if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + where += fmt.Sprintf(" AND id < $%d", len(args)) + } + args = append(args, limit+1) + limIdx := len(args) + sql := "SELECT " + channelMessageColumns + " FROM channel_messages WHERE " + where + " ORDER BY id DESC" + if addOffset > 0 { + args = append(args, addOffset) + sql += fmt.Sprintf(" OFFSET $%d", len(args)) + } + sql += fmt.Sprintf(" LIMIT $%d", limIdx) + older, err := scanList(sql, args) + if err != nil { + return domain.ChannelHistory{}, err + } + if len(older) > limit { + older = older[:limit] + hasMoreOlder = true + } + out.Messages = older + } + out.Count = len(out.Messages) + if hasMoreOlder { + out.Count = len(out.Messages) + 1 + } + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + return out, nil +} + +func (s *ChannelStore) SearchJoinedMessages(ctx context.Context, viewerUserID int64, req domain.ChannelGlobalSearchRequest) (domain.ChannelHistory, error) { + query := strings.TrimSpace(req.Query) + if viewerUserID == 0 || (query == "" && !req.MusicOnly) { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelGlobalSearchLimit { + limit = domain.MaxChannelGlobalSearchLimit + } + args := []any{viewerUserID} + where := `NOT deleted` + if query != "" { + args = append(args, "%"+escapeLike(query)+"%") + where += fmt.Sprintf(` +AND body <> '' +AND body ILIKE $%d ESCAPE '\'`, len(args)) + } + if req.MusicOnly { + where += ` +AND channel_messages.media->>'kind' = 'document' +AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(channel_messages.media #> '{document,attributes}', '[]'::jsonb)) AS attr + WHERE attr->>'kind' = 'audio' + AND COALESCE((attr->>'voice')::boolean, false) = false +)` + } + where += ` +AND EXISTS ( + SELECT 1 + FROM channels c + JOIN channel_members cm ON cm.channel_id = c.id + AND cm.user_id = $1 + AND cm.status = 'active' + AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false) + LEFT JOIN channel_dialogs d ON d.channel_id = c.id AND d.user_id = $1 + WHERE c.id = channel_messages.channel_id + AND NOT c.deleted + AND (cm.available_min_id <= 0 OR channel_messages.id > cm.available_min_id)` + if req.BroadcastsOnly { + where += ` + AND c.broadcast AND NOT c.megagroup` + } + if req.GroupsOnly { + where += ` + AND c.megagroup` + } + if req.HasFolderID { + args = append(args, req.FolderID) + where += fmt.Sprintf(` + AND d.folder_id = $%d`, len(args)) + } + where += ` +)` + if req.MinDate > 0 { + args = append(args, req.MinDate) + where += fmt.Sprintf(" AND message_date > $%d", len(args)) + } + if req.MaxDate > 0 { + args = append(args, req.MaxDate) + where += fmt.Sprintf(" AND message_date < $%d", len(args)) + } + switch { + case req.OffsetRate > 0 && req.OffsetChannelID > 0 && req.OffsetID > 0: + args = append(args, req.OffsetRate, req.OffsetChannelID, req.OffsetID) + n := len(args) + where += fmt.Sprintf(" AND (message_date < $%d OR (message_date = $%d AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))))", n-2, n-2, n-1, n-1, n) + case req.OffsetRate > 0: + args = append(args, req.OffsetRate) + where += fmt.Sprintf(" AND message_date < $%d", len(args)) + case req.OffsetChannelID > 0 && req.OffsetID > 0: + args = append(args, req.OffsetChannelID, req.OffsetID) + n := len(args) + where += fmt.Sprintf(" AND (channel_id < $%d OR (channel_id = $%d AND id < $%d))", n-1, n-1, n) + case req.OffsetID > 0: + args = append(args, req.OffsetID) + where += fmt.Sprintf(" AND id < $%d", len(args)) + } + queryLimit := limit + 1 + args = append(args, queryLimit) + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY message_date DESC, channel_id DESC, id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelHistory{}, fmt.Errorf("search joined channel messages: %w", err) + } + defer rows.Close() + out := domain.ChannelHistory{} + channelRefs := make(map[int64]struct{}) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Messages = append(out.Messages, msg) + channelRefs[msg.ChannelID] = struct{}{} + } + if err := rows.Err(); err != nil { + return domain.ChannelHistory{}, err + } + if len(out.Messages) > limit { + out.Messages = out.Messages[:limit] + out.Count = limit + 1 + channelRefs = make(map[int64]struct{}, len(out.Messages)) + for _, msg := range out.Messages { + channelRefs[msg.ChannelID] = struct{}{} + } + } else { + out.Count = len(out.Messages) + } + channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Channels = channels + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, out.Channels, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + return out, nil +} + +func (s *ChannelStore) GetChannelMessages(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelHistory, error) { + // viewer 口径(非严格 member):公开频道的非成员可预览读取消息(与 ListChannelHistory 一致)。 + // 否则查看他人资料里设置的公开「个人频道」时,DrKLO 经 channels.getMessages 拉最新一帖会被拒, + // 资料页个人频道整块不显示。私有频道非成员仍返回 ErrChannelPrivate。 + channel, member, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelHistory{}, err + } + return s.getChannelMessagesForMember(ctx, viewerUserID, channel, member, ids) +} + +func (s *ChannelStore) getChannelMessagesForMember(ctx context.Context, viewerUserID int64, channel domain.Channel, member domain.ChannelMember, ids []int) (domain.ChannelHistory, error) { + if len(ids) == 0 { + return domain.ChannelHistory{Channel: channel, Self: member}, nil + } + if len(ids) > domain.MaxGetMessageIDs { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + id32, _, err := validUniqueChannelMessageIDs(ids) + if err != nil { + return domain.ChannelHistory{}, err + } + // AvailableMinID 用固定哨兵($3<=0 表示无下限)而非条件追加,使 by-id 补拉恒为 + // 单一 query-shape(2→1),计划可复用。这里安全无副作用:索引由 id=ANY($2) 驱动 + // (Index Cond),哨兵仅作残余 Filter——EXPLAIN 实测仍走 channel_messages_history_idx、 + // 执行不变。注意:这种"OR 哨兵"只对【非排序锚点】的残余过滤安全;ListChannelHistory + // 的方向/anchor 条件若同样哨兵化会让规划器无法用索引顺序做 LIMIT、退化为全表扫+排序 + // (实测 0.06ms→23ms),故那里【刻意保留】动态 SQL。 + args := []any{channel.ID, id32, member.AvailableMinID} + where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted AND ($3 <= 0 OR id > $3)" + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id DESC`, args...) + if err != nil { + return domain.ChannelHistory{}, fmt.Errorf("get channel messages by ids: %w", err) + } + defer rows.Close() + out := domain.ChannelHistory{Channel: channel, Self: member} + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelHistory{}, err + } + out.Messages = append(out.Messages, msg) + } + if err := rows.Err(); err != nil { + return domain.ChannelHistory{}, err + } + out.Count = len(out.Messages) + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, out.Messages); err != nil { + return domain.ChannelHistory{}, err + } + return out, nil +} + +func (s *ChannelStore) ListStoryMessageForwards(ctx context.Context, req domain.StoryMessageForwardListRequest) (domain.StoryMessageForwardList, error) { + if req.ViewerUserID == 0 || req.Owner.ID == 0 || req.StoryID <= 0 || req.StoryID > domain.MaxStoryID { + return domain.StoryMessageForwardList{}, domain.ErrStoryIDInvalid + } + if req.Owner.Type != domain.PeerTypeUser && req.Owner.Type != domain.PeerTypeChannel { + return domain.StoryMessageForwardList{}, domain.ErrStoryPeerInvalid + } + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return domain.StoryMessageForwardList{}, err + } + ownerType := string(req.Owner.Type) + ownerID := strconv.FormatInt(req.Owner.ID, 10) + storyID := strconv.Itoa(req.StoryID) + where := ` +NOT deleted +AND media->>'kind' = 'story' +-- domain.Peer 无 json tag → 序列化为大写 Type/ID(与 repost 查询 ->>'Type'/'ID' 同口径); +-- 用小写 type/id 会永远匹配不到 → story message forward 计数恒 0(postgres-only,memory 读 struct 不受影响)。 +AND media #>> '{story,peer,Type}' = $1 +AND media #>> '{story,peer,ID}' = $2 +AND media #>> '{story,id}' = $3 +AND EXISTS ( + SELECT 1 + FROM channels c + WHERE c.id = channel_messages.channel_id + AND NOT c.deleted + AND (c.broadcast OR c.megagroup) + AND btrim(COALESCE(c.username, '')) <> '' +)` + var count int + if err := s.db.QueryRow(ctx, `SELECT count(*)::int FROM channel_messages WHERE `+where, ownerType, ownerID, storyID).Scan(&count); err != nil { + return domain.StoryMessageForwardList{}, fmt.Errorf("count story message forwards: %w", err) + } + limit := clampPGStoryInteractionLimit(req.Limit) + cursor := parsePGStoryInteractionCursor(req.Offset) + args := []any{ownerType, ownerID, storyID} + cursorClause := "" + group := 0 + if cursor.set { + cursorChannelID := int64(0) + if cursor.viewerID < 0 { + cursorChannelID = -cursor.viewerID + if cursorChannelID < 0 { + return domain.StoryMessageForwardList{}, domain.ErrStoryOffsetInvalid + } + } + args = append(args, int32(group), int32(cursor.group), int32(cursor.date), cursorChannelID, int32(cursor.messageID)) + cursorClause = fmt.Sprintf(` +AND ( + $%d::int > $%d::int + OR ( + $%d::int = $%d::int + AND ( + message_date < $%d::int + OR ( + message_date = $%d::int + AND ( + channel_id > $%d::bigint + OR (channel_id = $%d::bigint AND id < $%d::int) + ) + ) + ) + ) +)`, len(args)-4, len(args)-3, len(args)-4, len(args)-3, len(args)-2, len(args)-2, len(args)-1, len(args)-1, len(args)) + } + args = append(args, int32(limit+1)) + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+cursorClause+` +ORDER BY message_date DESC, channel_id ASC, id DESC +LIMIT $`+strconv.Itoa(len(args)), args...) + if err != nil { + return domain.StoryMessageForwardList{}, fmt.Errorf("list story message forwards: %w", err) + } + defer rows.Close() + views := make([]domain.StoryView, 0) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.StoryMessageForwardList{}, err + } + views = append(views, domain.StoryView{ + Owner: req.Owner, + StoryID: req.StoryID, + Date: msg.Date, + PublicForward: &domain.StoryPublicForward{ + Message: msg, + }, + }) + } + if err := rows.Err(); err != nil { + return domain.StoryMessageForwardList{}, err + } + sortPGStoryViewsForList(views, req.ReactionsFirst, req.ForwardsFirst) + nextOffset := "" + if len(views) > limit { + views = views[:limit] + nextOffset = formatPGStoryInteractionCursor(views[len(views)-1], req.ReactionsFirst, req.ForwardsFirst) + } + return domain.StoryMessageForwardList{Count: count, Forwards: views, NextOffset: nextOffset}, nil +} + +func (s *ChannelStore) GetChannelMessageForInlineBot(ctx context.Context, botID, channelID int64, id int) (domain.Channel, domain.ChannelMessage, bool, error) { + if botID == 0 || channelID == 0 || id <= 0 || id > domain.MaxMessageBoxID { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + channel, err := getChannelByID(ctx, s.db, channelID) + if err != nil { + if errors.Is(err, domain.ErrChannelInvalid) { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + return domain.Channel{}, domain.ChannelMessage{}, false, err + } + msg, err := s.getChannelMessage(ctx, s.db, channelID, id) + if err != nil { + if errors.Is(err, domain.ErrMessageIDInvalid) { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + return domain.Channel{}, domain.ChannelMessage{}, false, err + } + if msg.Deleted || msg.Action != nil || msg.ViaBotID != botID { + return domain.Channel{}, domain.ChannelMessage{}, false, nil + } + return channel, msg, true, nil +} + +func (s *ChannelStore) GetDiscussionMessage(ctx context.Context, viewerUserID, channelID int64, msgID int) (domain.ChannelDiscussionMessage, error) { + source, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelDiscussionMessage{}, err + } + msg, err := s.getChannelMessage(ctx, s.db, channelID, msgID) + if err != nil || msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelDiscussionMessage{}, domain.ErrMessageIDInvalid + } + result := domain.ChannelDiscussionMessage{PostChannel: source, DiscussionChannel: source, Channels: []domain.Channel{source}} + target := source + targetMsg := msg + if source.Broadcast { + if msg.Discussion == nil || msg.Discussion.ChannelID == 0 || msg.Discussion.MessageID == 0 { + return result, nil + } + linked, err := getChannelByID(ctx, s.db, msg.Discussion.ChannelID) + if err != nil { + return result, nil + } + linkedMsg, err := s.getChannelMessage(ctx, s.db, linked.ID, msg.Discussion.MessageID) + if err != nil || linkedMsg.Deleted { + return result, nil + } + target = linked + targetMsg = linkedMsg + result.DiscussionChannel = linked + result.Channels = []domain.Channel{source, linked} + } + messages := []domain.ChannelMessage{targetMsg} + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, target, messages); err != nil { + return domain.ChannelDiscussionMessage{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{target}, messages); err != nil { + return domain.ChannelDiscussionMessage{}, err + } + readInbox, readOutbox := s.channelReadWatermarks(ctx, target.ID, viewerUserID) + result.Messages = messages + result.ReadInboxMaxID = readInbox + result.ReadOutboxMaxID = readOutbox + if messages[0].Replies != nil { + result.MaxID = messages[0].Replies.MaxID + } + result.UnreadCount = s.channelThreadUnreadCount(ctx, target.ID, targetMsg.ID, viewerUserID, readInbox) + return result, nil +} + +func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { + channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelHistoryResult{}, err + } + maxID := req.MaxID + if maxID <= 0 || maxID > channel.TopMessageID { + maxID = channel.TopMessageID + } + previous, unreadMark, err := s.channelReadHistoryState(ctx, req.ChannelID, req.UserID) + if err != nil { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err) + } + if maxID <= previous && !unreadMark { + return domain.ReadChannelHistoryResult{ + ChannelID: req.ChannelID, + MaxID: maxID, + Changed: false, + Pts: channel.Pts, + Forum: channel.Forum, + }, nil + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("begin read channel history: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if err := tx.QueryRow(ctx, `SELECT read_inbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID).Scan(&previous); err != nil { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("read channel member state: %w", err) + } + changed := maxID > previous + var outboxUpdates []domain.ChannelReadOutboxUpdate + if changed { + // 先碰 channels 行再碰 channel_members:send 路径的顺序是 + // channels→members,read 路径必须同序,否则并发 send+read 形成 + // AB-BA 死锁(PG 1s 检测击杀后整个事务回滚)。 + // channel 级公共已读水位:top1/top2 是任一成员推进过的最高两个 + // read_inbox。sender 的 read_outbox 由它派生(top1 持有者本人取 + // top2),即使下面的实时 fanout 被截断,回执真值也不会停滞。 + if _, err := tx.Exec(ctx, ` +UPDATE channels +SET read_inbox_top2 = CASE + WHEN read_inbox_top1_user_id = $2 THEN read_inbox_top2 + WHEN $3 >= read_inbox_top1 THEN read_inbox_top1 + ELSE GREATEST(read_inbox_top2, $3) + END, + read_inbox_top1_user_id = CASE + WHEN read_inbox_top1_user_id = $2 THEN read_inbox_top1_user_id + WHEN $3 >= read_inbox_top1 THEN $2 + ELSE read_inbox_top1_user_id + END, + read_inbox_top1 = CASE + WHEN read_inbox_top1_user_id = $2 THEN GREATEST(read_inbox_top1, $3) + WHEN $3 >= read_inbox_top1 THEN $3 + ELSE read_inbox_top1 + END, + updated_at = now() +WHERE id = $1`, req.ChannelID, req.UserID, maxID); err != nil { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("advance channel read watermarks: %w", err) + } + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET read_inbox_date = CASE WHEN read_inbox_max_id < $3 THEN $4 ELSE read_inbox_date END, + read_inbox_max_id = GREATEST(read_inbox_max_id, $3), + unread_mark = false, + updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, maxID, req.Date); err != nil { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("update channel member read: %w", err) + } + msg, _ := s.getChannelMessage(ctx, tx, req.ChannelID, channel.TopMessageID) + if changed { + outboxUpdates, err = advanceChannelReadOutboxTx(ctx, tx, channel, msg, req.UserID, previous, maxID) + if err != nil { + return domain.ReadChannelHistoryResult{}, err + } + } + if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, maxID, 0); err != nil { + return domain.ReadChannelHistoryResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ReadChannelHistoryResult{}, fmt.Errorf("commit read channel history: %w", err) + } + committed = true + dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) + if err != nil { + return domain.ReadChannelHistoryResult{}, err + } + return domain.ReadChannelHistoryResult{ + ChannelID: req.ChannelID, + MaxID: maxID, + StillUnreadCount: dialog.UnreadCount, + Changed: changed, + Pts: channel.Pts, + Forum: channel.Forum, + Dialog: dialog, + OutboxUpdates: outboxUpdates, + }, nil +} + +func (s *ChannelStore) channelReadHistoryState(ctx context.Context, channelID, userID int64) (readInboxMaxID int, unreadMark bool, err error) { + err = s.db.QueryRow(ctx, ` +SELECT read_inbox_max_id, unread_mark +FROM channel_members +WHERE channel_id = $1 AND user_id = $2`, channelID, userID).Scan(&readInboxMaxID, &unreadMark) + return readInboxMaxID, unreadMark, err +} + +func (s *ChannelStore) getChannelMessage(ctx context.Context, db sqlcgen.DBTX, channelID int64, id int) (domain.ChannelMessage, error) { + if channelID == 0 || id == 0 { + return domain.ChannelMessage{}, pgx.ErrNoRows + } + msg, err := scanChannelMessage(db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND id = $2`, channelID, id)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelMessage{}, domain.ErrMessageIDInvalid + } + return msg, err +} + +type messageHistoryLoad int + +func messageHistoryLoadType(addOffset, limit int) messageHistoryLoad { + if addOffset >= 0 { + return messageHistoryLoadBackward + } + if addOffset+limit > 0 { + return messageHistoryLoadAround + } + return messageHistoryLoadForward +} + +func channelMessageLess(a, b domain.ChannelMessage) bool { + if a.Date != b.Date { + return a.Date > b.Date + } + return a.ID > b.ID +} diff --git a/internal/store/postgres/channel_message_integration_test.go b/internal/store/postgres/channel_message_integration_test.go new file mode 100644 index 00000000..e873116f --- /dev/null +++ b/internal/store/postgres/channel_message_integration_test.go @@ -0,0 +1,1325 @@ +package postgres + +import ( + "bytes" + "context" + "errors" + "fmt" + "sync" + "telesrv/internal/domain" + "testing" + "time" +) + +func TestChannelStoreSendMessageFansOutDialogRows(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 31, + Phone: "+1777" + suffix + "01", + FirstName: "ChannelOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 32, + Phone: "+1777" + suffix + "02", + FirstName: "ChannelFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Dialog Top " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000300, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 901, + Message: "first visible channel text", + Date: 1700000301, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + + var friendTop, friendReadInbox, friendUnread int + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, read_inbox_max_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, friend.ID).Scan(&friendTop, &friendReadInbox, &friendUnread); err != nil { + t.Fatalf("read friend dialog row after send: %v", err) + } + if friendTop != sent.Message.ID || friendReadInbox != 0 || friendUnread != 2 { + t.Fatalf("friend dialog row top=%d read=%d unread=%d, want top %d read 0 unread 2", friendTop, friendReadInbox, friendUnread, sent.Message.ID) + } + + var ownerTop, ownerReadInbox, ownerReadOutbox, ownerUnread int + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerTop, &ownerReadInbox, &ownerReadOutbox, &ownerUnread); err != nil { + t.Fatalf("read owner dialog row after send: %v", err) + } + // 发送者自己的 outbox 回执水位不得随发送推进:同账号另一设备会把它当成 + // "对端已读"渲染双勾。推进只能来自 peer 的 readHistory(见下方断言)。 + if ownerTop != sent.Message.ID || ownerReadInbox != sent.Message.ID || ownerReadOutbox != 0 || ownerUnread != 0 { + t.Fatalf("owner dialog row top=%d read_in=%d read_out=%d unread=%d, want top/read_in %d, read_out 0 before peer read, unread 0", ownerTop, ownerReadInbox, ownerReadOutbox, ownerUnread, sent.Message.ID) + } + + var ownerMemberReadInbox, ownerMemberReadOutbox int + if err := pool.QueryRow(ctx, ` +SELECT read_inbox_max_id, read_outbox_max_id +FROM channel_members +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerMemberReadInbox, &ownerMemberReadOutbox); err != nil { + t.Fatalf("read owner member row after send: %v", err) + } + if ownerMemberReadInbox != sent.Message.ID || ownerMemberReadOutbox != 0 { + t.Fatalf("owner member read_in=%d read_out=%d, want read_in %d and read_out unchanged before peer read", ownerMemberReadInbox, ownerMemberReadOutbox, sent.Message.ID) + } + + dialogs, err := channels.ListChannelDialogs(ctx, friend.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list friend dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].TopMessage != sent.Message.ID { + t.Fatalf("friend dialogs = %+v, want top message %d", dialogs.Dialogs, sent.Message.ID) + } + if len(dialogs.Messages) != 1 || dialogs.Messages[0].Body != "first visible channel text" { + t.Fatalf("friend dialog messages = %+v, want latest channel text", dialogs.Messages) + } + if dialogs.Dialogs[0].UnreadCount != 2 { + t.Fatalf("friend unread = %d, want create service + latest text", dialogs.Dialogs[0].UnreadCount) + } + + read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: friend.ID, + ChannelID: channelID, + MaxID: sent.Message.ID, + Date: 1700000302, + }) + if err != nil { + t.Fatalf("read history: %v", err) + } + if read.Dialog.UnreadCount != 0 || read.Dialog.ReadInboxMaxID != sent.Message.ID { + t.Fatalf("read dialog = %+v, want fully read through latest", read.Dialog) + } + if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != owner.ID || read.OutboxUpdates[0].MaxID != sent.Message.ID { + t.Fatalf("read outbox updates = %+v, want owner max id %d", read.OutboxUpdates, sent.Message.ID) + } + ownerView, err := channels.GetChannel(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("get owner channel after read: %v", err) + } + if ownerView.Dialog.ReadOutboxMaxID != sent.Message.ID { + t.Fatalf("owner dialog read_outbox = %d, want %d", ownerView.Dialog.ReadOutboxMaxID, sent.Message.ID) + } + if changed, folderID, err := channels.SetChannelDialogPinned(ctx, owner.ID, channelID, true); err != nil || !changed || folderID != domain.DialogMainFolderID { + t.Fatalf("set owner channel pinned = changed %v folder %d err %v, want changed in main folder", changed, folderID, err) + } + if changed, err := channels.ReorderChannelPinnedDialogs(ctx, owner.ID, domain.DialogMainFolderID, []domain.Peer{ + {Type: domain.PeerTypeChannel, ID: channelID}, + }, true); err != nil || changed { + t.Fatalf("reorder owner channel pinned = changed %v err %v, want no-op", changed, err) + } + if changed, err := channels.SetChannelDialogUnreadMark(ctx, owner.ID, channelID, true); err != nil || !changed { + t.Fatalf("set owner channel unread mark = changed %v err %v, want changed", changed, err) + } + ownerDialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get owner channel dialogs before archive: %v", err) + } + if len(ownerDialogs.Dialogs) != 1 || !ownerDialogs.Dialogs[0].Pinned || ownerDialogs.Dialogs[0].PinnedOrder != 1 || !ownerDialogs.Dialogs[0].UnreadMark { + t.Fatalf("owner channel dialog settings = %+v, want pinned order 1 with unread mark", ownerDialogs.Dialogs) + } + if err := channels.EditChannelPeerFolders(ctx, owner.ID, []domain.FolderPeerUpdate{ + {Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, FolderID: domain.DialogArchiveFolderID}, + }); err != nil { + t.Fatalf("edit owner channel folder: %v", err) + } + ownerDialogs, err = channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get owner channel dialogs after archive: %v", err) + } + // 归档清除 pinned(对齐 TDesktop History::setFolderPointer 的本地 + // unpin),unread_mark 与 folder_id 保留。 + if len(ownerDialogs.Dialogs) != 1 || ownerDialogs.Dialogs[0].Pinned || ownerDialogs.Dialogs[0].PinnedOrder != 0 || !ownerDialogs.Dialogs[0].UnreadMark || ownerDialogs.Dialogs[0].FolderID != domain.DialogArchiveFolderID { + t.Fatalf("owner channel dialog settings = %+v, want unpinned after archive with unread mark kept", ownerDialogs.Dialogs) + } + unreadMarks, err := channels.ListChannelUnreadMarked(ctx, owner.ID) + if err != nil { + t.Fatalf("list owner channel unread marks: %v", err) + } + if len(unreadMarks) != 1 || unreadMarks[0].ID != channelID || unreadMarks[0].Type != domain.PeerTypeChannel { + t.Fatalf("channel unread marks = %+v, want channel", unreadMarks) + } + + readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ + UserID: owner.ID, + ChannelID: channelID, + MessageID: sent.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 1700000303, + }) + if err != nil { + t.Fatalf("list message read participants: %v", err) + } + if len(readers.Participants) != 1 || readers.Participants[0].UserID != friend.ID || readers.Participants[0].Date != 1700000302 { + t.Fatalf("read participants = %+v, want friend read date", readers.Participants) + } + + cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: friend.ID, + ChannelID: channelID, + MaxID: sent.Message.ID, + Date: 1700000302, + }) + if err != nil { + t.Fatalf("local clear history: %v", err) + } + if cleared.AvailableMinID != sent.Message.ID { + t.Fatalf("local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID) + } + staleClear, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: friend.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000303, + }) + if err != nil { + t.Fatalf("stale local clear history: %v", err) + } + if staleClear.AvailableMinID != sent.Message.ID { + t.Fatalf("stale local clear available_min_id = %d, want monotonic %d", staleClear.AvailableMinID, sent.Message.ID) + } + afterClear, err := channels.GetChannel(ctx, friend.ID, channelID) + if err != nil { + t.Fatalf("get channel after clear: %v", err) + } + if afterClear.Dialog.TopMessageID != 0 { + t.Fatalf("dialog after clear = %+v, want no visible top", afterClear.Dialog) + } + + next, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 902, + Message: "after local clear", + Date: 1700000304, + }) + if err != nil { + t.Fatalf("send after clear: %v", err) + } + afterNext, err := channels.GetChannel(ctx, friend.ID, channelID) + if err != nil { + t.Fatalf("get channel after next: %v", err) + } + if afterNext.Dialog.TopMessageID != next.Message.ID || afterNext.Dialog.UnreadCount != 1 { + t.Fatalf("dialog after next = %+v, want top %d unread 1", afterNext.Dialog, next.Message.ID) + } +} + +func TestChannelStoreStoryMessageForwardsPublicOnlyAndDeleteRollbackPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 914130, + Phone: "+1777" + suffix + "91", + FirstName: "StoryForwardOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channels := NewChannelStore(pool) + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) > 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + publicCreated, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "PG Story Forward Public " + suffix, + Broadcast: true, + Date: 1700000330, + }) + if err != nil { + t.Fatalf("create public channel: %v", err) + } + channelIDs = append(channelIDs, publicCreated.Channel.ID) + publicChannel, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: publicCreated.Channel.ID, + Username: "storyfw" + suffix, + }) + if err != nil { + t.Fatalf("make public channel: %v", err) + } + privateCreated, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "PG Story Forward Private " + suffix, + Broadcast: true, + Date: 1700000331, + }) + if err != nil { + t.Fatalf("create private channel: %v", err) + } + channelIDs = append(channelIDs, privateCreated.Channel.ID) + source := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindStory, + Story: &domain.MessageStory{ + Peer: source, + ID: 71, + Story: &domain.Story{ + Owner: source, + ID: 71, + }, + }, + } + publicSent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: publicChannel.ID, + RandomID: 9141301, + Media: media, + Date: 1700000332, + }) + if err != nil { + t.Fatalf("send public story message: %v", err) + } + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: privateCreated.Channel.ID, + RandomID: 9141302, + Media: media, + Date: 1700000333, + }); err != nil { + t.Fatalf("send private story message: %v", err) + } + list, err := channels.ListStoryMessageForwards(ctx, domain.StoryMessageForwardListRequest{ + ViewerUserID: owner.ID, + Owner: source, + StoryID: 71, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story message forwards: %v", err) + } + if list.Count != 1 || len(list.Forwards) != 1 { + t.Fatalf("story message forwards = %+v, want one public forward", list) + } + if got := list.Forwards[0].PublicForward.Message.ChannelID; got != publicChannel.ID { + t.Fatalf("forward channel = %d, want public channel %d", got, publicChannel.ID) + } + if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: owner.ID, + ChannelID: publicChannel.ID, + IDs: []int{publicSent.Message.ID}, + Date: 1700000334, + }); err != nil { + t.Fatalf("delete public story message: %v", err) + } + empty, err := channels.ListStoryMessageForwards(ctx, domain.StoryMessageForwardListRequest{ + ViewerUserID: owner.ID, + Owner: source, + StoryID: 71, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story message forwards after delete: %v", err) + } + if empty.Count != 0 || len(empty.Forwards) != 0 { + t.Fatalf("story message forwards after delete = %+v, want empty", empty) + } +} + +func TestChannelStoreSendMessageViaBotIDSurvivesReadPaths(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 3101, Phone: "+1888" + suffix + "01", FirstName: "InlineChannelOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 3102, Phone: "+1888" + suffix + "02", FirstName: "InlineChannelFriend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + bot, err := users.Create(ctx, domain.User{AccessHash: 3103, FirstName: "InlineViaBot", Username: "inline_via_pg_" + suffix + "_bot", Bot: true, BotInfoVersion: 1}) + if err != nil { + t.Fatalf("create bot user: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, bot.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Inline Via Channel " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700001100, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + markup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ + {Type: domain.MarkupButtonCallback, Text: "Open", Data: []byte{0x00, 0xff, 0x42}}, + }}} + assertMarkup := func(name string, got *domain.MessageReplyMarkup) { + t.Helper() + if got == nil || len(got.Inline) != 1 || len(got.Inline[0]) != 1 { + t.Fatalf("%s markup = %+v, want one callback button", name, got) + } + btn := got.Inline[0][0] + if btn.Type != domain.MarkupButtonCallback || btn.Text != "Open" || !bytes.Equal(btn.Data, []byte{0x00, 0xff, 0x42}) { + t.Fatalf("%s button = %+v, want callback Open bytes", name, btn) + } + } + + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 990301, + Message: "inline channel via", + ViaBotID: bot.ID, + ReplyMarkup: markup, + Date: 1700001101, + }) + if err != nil { + t.Fatalf("send channel via: %v", err) + } + if sent.Message.ViaBotID != bot.ID || sent.Event.Message.ViaBotID != bot.ID { + t.Fatalf("sent via_bot_id = msg %d event %d, want %d", sent.Message.ViaBotID, sent.Event.Message.ViaBotID, bot.ID) + } + assertMarkup("sent message", sent.Message.ReplyMarkup) + assertMarkup("sent event", sent.Event.Message.ReplyMarkup) + + duplicate, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 990301, + Message: "changed text must not win", + Date: 1700001102, + }) + if err != nil { + t.Fatalf("duplicate channel via: %v", err) + } + if !duplicate.Duplicate || duplicate.Message.ViaBotID != bot.ID || duplicate.Event.Message.ViaBotID != bot.ID { + t.Fatalf("duplicate = %+v event=%+v, want original via bot %d", duplicate.Message, duplicate.Event, bot.ID) + } + assertMarkup("duplicate message", duplicate.Message.ReplyMarkup) + assertMarkup("duplicate event", duplicate.Event.Message.ReplyMarkup) + + history, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ChannelID: channelID, Limit: 10}) + if err != nil { + t.Fatalf("history channel via: %v", err) + } + if len(history.Messages) == 0 || history.Messages[0].ViaBotID != bot.ID { + t.Fatalf("history messages = %+v, want top via bot %d", history.Messages, bot.ID) + } + assertMarkup("history message", history.Messages[0].ReplyMarkup) + + byID, err := channels.GetChannelMessages(ctx, owner.ID, channelID, []int{sent.Message.ID}) + if err != nil { + t.Fatalf("get channel messages via: %v", err) + } + if len(byID.Messages) != 1 || byID.Messages[0].ViaBotID != bot.ID { + t.Fatalf("get messages = %+v, want via bot %d", byID.Messages, bot.ID) + } + assertMarkup("get messages", byID.Messages[0].ReplyMarkup) + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: owner.ID, ChannelID: channelID, Pts: created.Channel.Pts, Limit: 10}) + if err != nil { + t.Fatalf("channel difference via: %v", err) + } + if len(diff.NewMessages) != 1 || diff.NewMessages[0].ViaBotID != bot.ID { + t.Fatalf("difference messages = %+v, want via bot %d", diff.NewMessages, bot.ID) + } + assertMarkup("difference message", diff.NewMessages[0].ReplyMarkup) + + dialogs, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list dialogs via: %v", err) + } + if len(dialogs.Messages) == 0 || dialogs.Messages[0].ViaBotID != bot.ID { + t.Fatalf("dialog top messages = %+v, want via bot %d", dialogs.Messages, bot.ID) + } + assertMarkup("dialog top message", dialogs.Messages[0].ReplyMarkup) + + editedMarkup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ + {Type: domain.MarkupButtonCallback, Text: "Done", Data: []byte("v2")}, + }}} + if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + ID: sent.Message.ID, + Message: "wrong bot edit", + ViaBotEditBotID: bot.ID + 1, + EditDate: 1700001103, + SetReplyMarkup: true, + ReplyMarkup: editedMarkup, + }); err != domain.ErrMessageAuthorRequired { + t.Fatalf("wrong channel via bot edit err = %v, want ErrMessageAuthorRequired", err) + } + edited, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + ID: sent.Message.ID, + Message: "inline channel edited", + ViaBotEditBotID: bot.ID, + EditDate: 1700001104, + SetReplyMarkup: true, + ReplyMarkup: editedMarkup, + }) + if err != nil { + t.Fatalf("channel via bot edit: %v", err) + } + if edited.Event.Type != domain.ChannelUpdateEditMessage || edited.Message.Body != "inline channel edited" || edited.Message.ViaBotID != bot.ID { + t.Fatalf("edited = %+v event=%+v, want edit via bot", edited.Message, edited.Event) + } + if edited.Message.ReplyMarkup == nil || edited.Message.ReplyMarkup.Inline[0][0].Text != "Done" || !bytes.Equal(edited.Message.ReplyMarkup.Inline[0][0].Data, []byte("v2")) { + t.Fatalf("edited markup = %+v, want Done/v2", edited.Message.ReplyMarkup) + } + editDiff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{UserID: owner.ID, ChannelID: channelID, Pts: sent.Event.Pts, Limit: 10}) + if err != nil { + t.Fatalf("channel edit difference: %v", err) + } + if len(editDiff.OtherUpdates) != 1 || editDiff.OtherUpdates[0].Type != domain.ChannelUpdateEditMessage { + t.Fatalf("channel edit difference = %+v, want one edit", editDiff.OtherUpdates) + } + if editDiff.OtherUpdates[0].Message.ReplyMarkup == nil || editDiff.OtherUpdates[0].Message.ReplyMarkup.Inline[0][0].Text != "Done" { + t.Fatalf("channel edit difference markup = %+v, want Done", editDiff.OtherUpdates[0].Message.ReplyMarkup) + } +} + +func TestChannelStoreSendMessageSkipDeliveryAdvancesReadBoundary(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 731, Phone: "+1778" + suffix + "01", FirstName: "SkipOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 732, Phone: "+1778" + suffix + "02", FirstName: "SkipFriend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + bot, err := users.Create(ctx, domain.User{AccessHash: 733, Phone: "+1778" + suffix + "03", FirstName: "SkipBot", Bot: true, BotInfoVersion: 1}) + if err != nil { + t.Fatalf("create bot-like user: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID, bot.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Skip Delivery " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID, bot.ID}, + Date: 1700000400, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + hidden, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 941, + Message: "hidden for privacy bot", + SkipDeliveryUserIDs: []int64{bot.ID}, + Date: 1700000401, + }) + if err != nil { + t.Fatalf("send hidden: %v", err) + } + if containsInt64ForPostgresTest(hidden.Recipients, bot.ID) { + t.Fatalf("hidden recipients = %+v, want bot skipped", hidden.Recipients) + } + + var botDialogTop, botReadInbox, botUnread int + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, read_inbox_max_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, bot.ID).Scan(&botDialogTop, &botReadInbox, &botUnread); err != nil { + t.Fatalf("read bot dialog after hidden: %v", err) + } + if botDialogTop != created.Message.ID { + t.Fatalf("bot dialog after hidden top=%d, want unchanged create top %d", botDialogTop, created.Message.ID) + } + var memberReadInbox int + if err := pool.QueryRow(ctx, ` +SELECT read_inbox_max_id +FROM channel_members +WHERE channel_id = $1 AND user_id = $2`, channelID, bot.ID).Scan(&memberReadInbox); err != nil { + t.Fatalf("read bot member boundary: %v", err) + } + if memberReadInbox != hidden.Message.ID { + t.Fatalf("bot member read_inbox = %d, want hidden message id %d", memberReadInbox, hidden.Message.ID) + } + + visible, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 942, + Message: "/visible", + Date: 1700000402, + }) + if err != nil { + t.Fatalf("send visible: %v", err) + } + if !containsInt64ForPostgresTest(visible.Recipients, bot.ID) { + t.Fatalf("visible recipients = %+v, want bot", visible.Recipients) + } + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, read_inbox_max_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, bot.ID).Scan(&botDialogTop, &botReadInbox, &botUnread); err != nil { + t.Fatalf("read bot dialog after visible: %v", err) + } + if botDialogTop != visible.Message.ID || botReadInbox != hidden.Message.ID || botUnread != 1 { + t.Fatalf("bot dialog after visible top=%d read=%d unread=%d, want top %d read hidden %d unread visible-only", + botDialogTop, botReadInbox, botUnread, visible.Message.ID, hidden.Message.ID) + } +} + +func containsInt64ForPostgresTest(ids []int64, target int64) bool { + for _, id := range ids { + if id == target { + return true + } + } + return false +} + +func TestChannelStoreConcurrentSendAndReadHistoryDoNotSurfaceDeadlock(t *testing.T) { + pool := testPool(t) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 37, + Phone: "+1777" + suffix + "21", + FirstName: "ConcurrentOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 38, + Phone: "+1777" + suffix + "22", + FirstName: "ConcurrentMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(context.Background(), "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(context.Background(), "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Send Read Race " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000450, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 1700000451, + Message: "seed", + Date: 1700000451, + }) + if err != nil { + t.Fatalf("seed send: %v", err) + } + + for i := 0; i < 20; i++ { + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + wg.Add(2) + go func(iter int) { + defer wg.Done() + <-start + _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: member.ID, + ChannelID: channelID, + RandomID: int64(1700000500 + iter), + Message: fmt.Sprintf("race send %d", iter), + Date: 1700000500 + iter, + }) + errs <- err + }(i) + go func() { + defer wg.Done() + <-start + _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: first.Message.ID, + Date: 1700000600, + }) + errs <- err + }() + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("concurrent send/read iteration %d: %v", i, err) + } + } + } +} + +func TestChannelStoreJoinInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 131, + Phone: "+1777" + suffix + "11", + FirstName: "JoinOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 132, + Phone: "+1777" + suffix + "12", + FirstName: "JoinFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Join Watermark " + suffix, + Megagroup: true, + Date: 1700000320, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 905, + Message: "before join", + Date: 1700000321, + }) + if err != nil { + t.Fatalf("send existing message: %v", err) + } + joined, err := channels.JoinChannel(ctx, channelID, friend.ID, 1700000322) + if err != nil { + t.Fatalf("join channel: %v", err) + } + if _, err := channels.JoinChannel(ctx, channelID, friend.ID, 1700000323); !errors.Is(err, domain.ErrUserAlreadyParticipant) { + t.Fatalf("duplicate join err = %v, want ErrUserAlreadyParticipant", err) + } + if len(joined.Members) != 1 || joined.Members[0].ReadInboxMaxID != joined.Message.ID { + t.Fatalf("joined member = %+v message=%+v, want read watermark at self join service", joined.Members, joined.Message) + } + view, err := channels.GetChannel(ctx, friend.ID, channelID) + if err != nil { + t.Fatalf("get joined channel: %v", err) + } + if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { + t.Fatalf("joined view dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) + } + readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ + UserID: owner.ID, + ChannelID: channelID, + MessageID: first.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 1700000323, + }) + if err != nil { + t.Fatalf("list read participants existing message: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("existing message readers after join = %+v, want none from initial watermark", readers.Participants) + } + future, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 906, + Message: "after join", + Date: 1700000323, + }) + if err != nil { + t.Fatalf("send future message: %v", err) + } + after, err := channels.GetChannel(ctx, friend.ID, channelID) + if err != nil { + t.Fatalf("get channel after future: %v", err) + } + if after.Dialog.TopMessageID != future.Message.ID || after.Dialog.UnreadCount != 1 { + t.Fatalf("joined dialog after future = %+v, want top %d unread 1", after.Dialog, future.Message.ID) + } +} + +func TestChannelStoreInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1888" + suffix + "01", + FirstName: "InviteOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + invited, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1888" + suffix + "02", + FirstName: "InviteMember", + }) + if err != nil { + t.Fatalf("create invited: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, invited.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Invite Watermark " + suffix, + Megagroup: true, + Date: 1700000320, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 902, + Message: "already visible before invite", + Date: 1700000321, + }) + if err != nil { + t.Fatalf("send existing channel message: %v", err) + } + if _, err := channels.InviteToChannel(ctx, channelID, owner.ID, []int64{invited.ID}, 1700000322); err != nil { + t.Fatalf("invite to channel: %v", err) + } + + view, err := channels.GetChannel(ctx, invited.ID, channelID) + if err != nil { + t.Fatalf("get invited channel: %v", err) + } + if view.Self.ReadInboxMaxID != first.Message.ID || view.Dialog.ReadInboxMaxID != first.Message.ID { + t.Fatalf("invited read watermark self/dialog = %d/%d, want existing top %d", view.Self.ReadInboxMaxID, view.Dialog.ReadInboxMaxID, first.Message.ID) + } + if view.Dialog.UnreadCount != 1 { + t.Fatalf("invited unread = %d, want only invite service message unread", view.Dialog.UnreadCount) + } +} + +func TestChannelStoreImportInviteInitialReadWatermarkSkipsExistingHistory(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 51, + Phone: "+1888" + suffix + "11", + FirstName: "ImportOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + joiner, err := users.Create(ctx, domain.User{ + AccessHash: 52, + Phone: "+1888" + suffix + "12", + FirstName: "ImportJoiner", + }) + if err != nil { + t.Fatalf("create joiner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, joiner.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Import Watermark " + suffix, + Megagroup: true, + Date: 1700000330, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 912, + Message: "already visible before import", + Date: 1700000331, + }) + if err != nil { + t.Fatalf("send existing channel message: %v", err) + } + invite, err := channels.ExportInvite(ctx, domain.ExportChannelInviteRequest{ + UserID: owner.ID, + ChannelID: channelID, + Title: "join", + Date: 1700000332, + }) + if err != nil { + t.Fatalf("export invite: %v", err) + } + joined, err := channels.ImportInvite(ctx, domain.ImportChannelInviteRequest{ + UserID: joiner.ID, + Hash: invite.Invite.Hash, + Date: 1700000333, + }) + if err != nil { + t.Fatalf("import invite: %v", err) + } + if len(joined.Members) != 1 || joined.Members[0].ReadInboxMaxID != joined.Message.ID || joined.Members[0].ReadOutboxMaxID != joined.Message.ID { + t.Fatalf("imported member = %+v message=%+v, want read watermarks at self join service", joined.Members, joined.Message) + } + view, err := channels.GetChannel(ctx, joiner.ID, channelID) + if err != nil { + t.Fatalf("get imported channel: %v", err) + } + if view.Dialog.UnreadCount != 0 || view.Self.ReadInboxMaxID != joined.Message.ID { + t.Fatalf("imported view dialog/self = %+v / %+v, want no unread and read at join service", view.Dialog, view.Self) + } + readers, err := channels.ListMessageReadParticipants(ctx, domain.ChannelReadParticipantsRequest{ + UserID: owner.ID, + ChannelID: channelID, + MessageID: first.Message.ID, + Limit: domain.MaxChannelReadParticipants, + Date: 1700000334, + }) + if err != nil { + t.Fatalf("list read participants existing message: %v", err) + } + if len(readers.Participants) != 0 { + t.Fatalf("existing message readers after import = %+v, want none from initial watermark", readers.Participants) + } +} + +func TestChannelStoreSendMessageResolvesReplyTopID(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1778" + suffix + "01", + FirstName: "ReplyOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1778" + suffix + "02", + FirstName: "ReplyFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Reply Top " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000350, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + root, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 911, + Message: "root", + Date: 1700000351, + }) + if err != nil { + t.Fatalf("send root: %v", err) + } + reply, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: friend.ID, + ChannelID: channelID, + RandomID: 912, + Message: "reply", + ReplyTo: &domain.MessageReply{MessageID: root.Message.ID, QuoteText: "root"}, + Date: 1700000352, + }) + if err != nil { + t.Fatalf("send reply: %v", err) + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID} + if reply.Message.ReplyTo == nil || reply.Message.ReplyTo.Peer != channelPeer || reply.Message.ReplyTo.TopMessageID != root.Message.ID { + t.Fatalf("reply metadata = %+v, want channel peer and top id %d", reply.Message.ReplyTo, root.Message.ID) + } + nested, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 913, + Message: "nested", + ReplyTo: &domain.MessageReply{MessageID: reply.Message.ID}, + Date: 1700000353, + }) + if err != nil { + t.Fatalf("send nested reply: %v", err) + } + if nested.Message.ReplyTo == nil || nested.Message.ReplyTo.TopMessageID != root.Message.ID { + t.Fatalf("nested reply metadata = %+v, want inherited top id %d", nested.Message.ReplyTo, root.Message.ID) + } + _, err = channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 914, + Message: "bad quote offset", + ReplyTo: &domain.MessageReply{ + MessageID: root.Message.ID, + QuoteText: "root", + QuoteOffset: domain.MaxMessageReplyQuoteOffset + 1, + }, + Date: 1700000354, + }) + if !errors.Is(err, domain.ErrReplyMessageIDInvalid) { + t.Fatalf("bad quote offset err = %v, want ErrReplyMessageIDInvalid", err) + } +} + +func TestChannelStoreHistorySupportsOffsetDateOnly(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 37, + Phone: "+1778" + suffix + "03", + FirstName: "HistoryDateOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "History Date " + suffix, + Megagroup: true, + Date: 1700000360, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 921, + Message: "old", + Date: 1700000361, + }); err != nil { + t.Fatalf("send old: %v", err) + } + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 922, + Message: "new", + Date: 1700000362, + }); err != nil { + t.Fatalf("send new: %v", err) + } + + history, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ + ChannelID: channelID, + OffsetDate: 1700000362, + Limit: 10, + }) + if err != nil { + t.Fatalf("list channel history: %v", err) + } + if len(history.Messages) != 2 || history.Messages[0].Body != "old" || history.Messages[1].Action == nil { + t.Fatalf("history = %+v, want only messages older than offset date", history.Messages) + } +} + +func TestChannelStoreDeleteHistoryForEveryoneBatchesHugeMaxID(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 71, + Phone: "+1998" + suffix + "01", + FirstName: "BulkChannelOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 72, + Phone: "+1998" + suffix + "02", + FirstName: "BulkChannelFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Bulk Delete " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000600, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + total := domain.MaxDeleteHistoryBatch + 2 + if _, err := pool.Exec(ctx, ` +WITH src AS ( + SELECT generate_series(2, $3::int + 1) AS id +), +msgs AS ( + INSERT INTO channel_messages ( + channel_id, + id, + random_id, + sender_user_id, + from_peer_type, + from_peer_id, + message_date, + body, + entities, + pts + ) + SELECT + $1::bigint, + id, + 920000000 + id, + $2::bigint, + 'user', + $2::bigint, + 1700000600 + id, + 'bulk channel history', + '[]'::jsonb, + id + FROM src + RETURNING id, message_date +) +INSERT INTO channel_update_events ( + channel_id, + pts, + pts_count, + date, + event_type, + message_id, + sender_user_id, + payload +) +SELECT + $1::bigint, + id, + 1, + message_date, + 'new_channel_message', + id, + $2::bigint, + '{}'::jsonb +FROM msgs +`, channelID, owner.ID, total); err != nil { + t.Fatalf("seed bulk channel messages: %v", err) + } + if _, err := pool.Exec(ctx, ` +UPDATE channels +SET top_message_id = $2, + pts = $2, + updated_at = now() +WHERE id = $1`, channelID, total+1); err != nil { + t.Fatalf("update channel bulk top: %v", err) + } + + first, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: owner.ID, + ChannelID: channelID, + MaxID: int(^uint(0) >> 1), + ForEveryone: true, + Date: 1700000700, + }) + if err != nil { + t.Fatalf("DeleteChannelHistory first batch: %v", err) + } + wantFirstPts := total + 1 + domain.MaxDeleteHistoryBatch + if first.Offset != 1 || first.Event.Pts != wantFirstPts || first.Event.PtsCount != domain.MaxDeleteHistoryBatch || len(first.DeletedIDs) != domain.MaxDeleteHistoryBatch { + t.Fatalf("first batch = %+v, want offset=1 pts=%d pts_count=%d", first, wantFirstPts, domain.MaxDeleteHistoryBatch) + } + var remaining int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&remaining); err != nil { + t.Fatalf("count remaining after first batch: %v", err) + } + if remaining != 3 { + t.Fatalf("remaining after first batch = %d, want create service + two oldest messages", remaining) + } + + second, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: owner.ID, + ChannelID: channelID, + MaxID: int(^uint(0) >> 1), + ForEveryone: true, + Date: 1700000701, + }) + if err != nil { + t.Fatalf("DeleteChannelHistory second batch: %v", err) + } + if second.Offset != 0 || second.Event.Pts != wantFirstPts+2 || second.Event.PtsCount != 2 || len(second.DeletedIDs) != 2 { + t.Fatalf("second batch = %+v, want final offset=0 pts=%d pts_count=2 keeping create service message", second, wantFirstPts+2) + } + if second.Channel.TopMessageID != 1 { + t.Fatalf("top after full clear = %d, want create service message 1", second.Channel.TopMessageID) + } + var keptID int + if err := pool.QueryRow(ctx, `SELECT id FROM channel_messages WHERE channel_id = $1 AND NOT deleted`, channelID).Scan(&keptID); err != nil { + t.Fatalf("query kept message after full clear: %v", err) + } + if keptID != 1 { + t.Fatalf("kept message id = %d, want create service message 1", keptID) + } + var dialogTops []int + rows, err := pool.Query(ctx, `SELECT top_message_id FROM channel_dialogs WHERE channel_id = $1 ORDER BY user_id`, channelID) + if err != nil { + t.Fatalf("query member dialogs after full clear: %v", err) + } + for rows.Next() { + var top int + if err := rows.Scan(&top); err != nil { + rows.Close() + t.Fatalf("scan member dialog top: %v", err) + } + dialogTops = append(dialogTops, top) + } + rows.Close() + if len(dialogTops) != 2 || dialogTops[0] != 1 || dialogTops[1] != 1 { + t.Fatalf("member dialog tops after full clear = %v, want create service message visible for both members", dialogTops) + } +} diff --git a/internal/store/postgres/channel_message_send.go b/internal/store/postgres/channel_message_send.go new file mode 100644 index 00000000..d0ebfe04 --- /dev/null +++ b/internal/store/postgres/channel_message_send.go @@ -0,0 +1,491 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero()) { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + var lastErr error + for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ { + res, err := s.sendChannelMessageOnce(ctx, req) + if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil { + return res, err + } + lastErr = err + } + return domain.SendChannelMessageResult{}, lastErr +} + +func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { + if req.RandomID != 0 { + if dup, found, err := s.duplicateChannelMessage(ctx, req.ChannelID, req.UserID, req.RandomID); err != nil { + return domain.SendChannelMessageResult{}, err + } else if found { + return dup, nil + } + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.SendChannelMessageResult{}, fmt.Errorf("send channel message: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("begin send channel: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + fromBoostsApplied := 0 + if channel.Megagroup { + fromBoostsApplied, err = countActiveUserBoostsForPeer(ctx, tx, req.UserID, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, req.Date) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + } + if !canSendChannelMessageWithBoost(channel, member, fromBoostsApplied) { + return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden + } + replyTo, err := s.resolveChannelReply(ctx, tx, req, member, channel) + if err != nil { + return domain.SendChannelMessageResult{}, err + } + if _, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, replyTo, req.Forward); err != nil { + return domain.SendChannelMessageResult{}, err + } + if wait := channelSlowModeWait(channel, member, req.Date); wait > 0 { + return domain.SendChannelMessageResult{}, domain.NewSlowModeWaitError(wait) + } + ttlPeriod := req.TTLPeriod + if ttlPeriod == 0 && req.Action == nil { + ttlPeriod = channel.TTLPeriod + } + expiresAt := 0 + if ttlPeriod > 0 && req.Action == nil { + expiresAt = req.Date + ttlPeriod + } + var sendAs *domain.Peer + if req.SendAs != nil { + p := *req.SendAs + sendAs = &p + } + msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.ChannelID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel message id: %w", err) + } + pts, err := s.reserveChannelPts(ctx, tx, req.ChannelID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("allocate channel pts: %w", err) + } + var discussion *domain.SendChannelDiscussionResult + var discussionRef *domain.ChannelDiscussionRef + if channel.Broadcast && channel.LinkedChatID != 0 { + linked, err := getChannelByID(ctx, tx, channel.LinkedChatID) + if err == nil && !linked.Deleted && linked.Megagroup { + discussionMsgID, err := s.msgIDs.NextChannelMessageID(ctx, linked.ID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("allocate discussion message id: %w", err) + } + discussionPts, err := s.reserveChannelPts(ctx, tx, linked.ID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("allocate discussion pts: %w", err) + } + discussionRef = &domain.ChannelDiscussionRef{ChannelID: linked.ID, MessageID: discussionMsgID} + discussionMsg := domain.ChannelMessage{ + ChannelID: linked.ID, + ID: discussionMsgID, + SenderUserID: req.UserID, + From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, + Date: req.Date, + Silent: req.Silent, + NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ViaBotID: req.ViaBotID, + GroupedID: req.GroupedID, + ReplyMarkup: req.ReplyMarkup, + TTLPeriod: ttlPeriod, + ExpiresAt: expiresAt, + Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, + Pts: discussionPts, + } + discussionEvent := domain.ChannelUpdateEvent{ + ChannelID: linked.ID, + Type: domain.ChannelUpdateNewMessage, + Pts: discussionPts, + PtsCount: 1, + Date: req.Date, + Message: discussionMsg, + } + if err := insertChannelMessageTx(ctx, tx, discussionMsg); err != nil { + return domain.SendChannelMessageResult{}, err + } + if err := insertChannelEventTx(ctx, tx, discussionEvent); err != nil { + return domain.SendChannelMessageResult{}, err + } + if err := insertChannelUnreadMentionsTx(ctx, tx, linked.ID, discussionMsg, req.UserID, req.MentionUserIDs); err != nil { + return domain.SendChannelMessageResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, linked.ID, discussionMsgID, discussionPts); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("update discussion channel top: %w", err) + } + linked.TopMessageID = discussionMsgID + linked.Pts = discussionPts + if err := upsertChannelDialogsForMessageTx(ctx, tx, linked, discussionMsg, 0); err != nil { + return domain.SendChannelMessageResult{}, err + } + discussion = &domain.SendChannelDiscussionResult{ + Channel: linked, + Message: discussionMsg, + Event: discussionEvent, + MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), + } + } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { + return domain.SendChannelMessageResult{}, err + } + } + msg := domain.ChannelMessage{ + ChannelID: req.ChannelID, + ID: msgID, + RandomID: req.RandomID, + SenderUserID: req.UserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.UserID}, + Date: req.Date, + Post: channel.Broadcast, + PostAuthor: channelPostAuthor(channel, req.PostAuthor), + Silent: req.Silent, + NoForwards: req.NoForwards || channel.NoForwards, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Media: req.Media, + ViaBotID: req.ViaBotID, + GroupedID: req.GroupedID, + ReplyMarkup: req.ReplyMarkup, + TTLPeriod: ttlPeriod, + ExpiresAt: expiresAt, + ReplyTo: replyTo, + Forward: cloneMessageForward(req.Forward), + SendAs: sendAs, + Discussion: discussionRef, + Action: cloneChannelMessageAction(req.Action), + FromBoostsApplied: fromBoostsApplied, + Pts: pts, + } + if discussionRef != nil { + msg.Replies = &domain.ChannelMessageReplies{Comments: true, ChannelID: discussionRef.ChannelID, RepliesPts: discussion.Event.Pts} + } + event := domain.ChannelUpdateEvent{ + ChannelID: req.ChannelID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: req.Date, + Message: msg, + SenderUserID: req.UserID, + } + if err := insertChannelMessageTx(ctx, tx, msg); err != nil { + if isUniqueViolation(err) { + dup, found, dupErr := s.duplicateChannelMessage(ctx, req.ChannelID, req.UserID, req.RandomID) + if dupErr != nil || !found { + return domain.SendChannelMessageResult{}, dupErr + } + dup.Duplicate = true + return dup, nil + } + return domain.SendChannelMessageResult{}, err + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.SendChannelMessageResult{}, err + } + mentionTargets := req.MentionUserIDs + // 回复某人 = 隐式 mention 该消息作者(官方语义:被回复者收到 @ 角标 + // 且通知穿透群静音)。 + if replyTo != nil && replyTo.MessageID > 0 { + if target, err := s.getChannelMessage(ctx, tx, req.ChannelID, replyTo.MessageID); err == nil && + target.SenderUserID != 0 && target.SenderUserID != req.UserID { + mentionTargets = append(append([]int64(nil), mentionTargets...), target.SenderUserID) + } + } + if channel.Broadcast && !channel.Megagroup { + // broadcast 没有 @ 角标/readMentions UI,写入只会造成永远清不掉的 + // 提及角标;讨论组联动消息走 megagroup 路径。 + mentionTargets = nil + } + if err := insertChannelUnreadMentionsTx(ctx, tx, req.ChannelID, msg, req.UserID, mentionTargets); err != nil { + return domain.SendChannelMessageResult{}, err + } + if err := updateForumTopicTopMessageTx(ctx, tx, req.ChannelID, msg); err != nil { + return domain.SendChannelMessageResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.ChannelID, msgID, pts); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("update channel top: %w", err) + } + channel.TopMessageID = msgID + channel.Pts = pts + if _, err := tx.Exec(ctx, ` +UPDATE channel_members +SET slowmode_last_send_date = $3, + read_inbox_max_id = GREATEST(read_inbox_max_id, $4), + unread_mark = false, + updated_at = now() +WHERE channel_id = $1 AND user_id = $2`, req.ChannelID, req.UserID, req.Date, msgID); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("update channel member slowmode send date: %w", err) + } + // channel_dialogs.unread_mark 非 NULL 时会在读取路径遮蔽 members 的值 + // (COALESCE(d.unread_mark, m.unread_mark)),发送清除必须双表同步。 + if _, err := tx.Exec(ctx, ` +UPDATE channel_dialogs +SET unread_mark = false, updated_at = now() +WHERE channel_id = $1 AND user_id = $2 AND unread_mark`, req.ChannelID, req.UserID); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("clear channel dialog unread mark on send: %w", err) + } + if err := upsertChannelDialogsForMessageTx(ctx, tx, channel, msg, req.UserID, req.SkipDeliveryUserIDs); err != nil { + return domain.SendChannelMessageResult{}, err + } + if channel.Broadcast { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogSendMessage, + Message: &msg, + Query: msg.Body, + }); err != nil { + return domain.SendChannelMessageResult{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("commit send channel: %w", err) + } + committed = true + var recipients []int64 + if !req.SkipRecipientLookup { + recipients, _ = s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + recipients = filterSkippedChannelRecipients(recipients, channelDeliverySkipSet(req.SkipDeliveryUserIDs)) + if discussion != nil { + discussion.Recipients, _ = s.ListActiveChannelMemberIDs(ctx, req.UserID, discussion.Channel.ID, 0) + } + } + return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Recipients: recipients, Discussion: discussion, MentionUserIDs: append([]int64(nil), req.MentionUserIDs...), SkipDeliveryUserIDs: append([]int64(nil), req.SkipDeliveryUserIDs...)}, nil +} + +func channelDeliverySkipSet(ids []int64) map[int64]struct{} { + if len(ids) == 0 { + return nil + } + out := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id != 0 { + out[id] = struct{}{} + } + } + return out +} + +func filterSkippedChannelRecipients(recipients []int64, skip map[int64]struct{}) []int64 { + if len(recipients) == 0 || len(skip) == 0 { + return recipients + } + out := recipients[:0] + for _, id := range recipients { + if _, hidden := skip[id]; hidden { + continue + } + out = append(out, id) + } + return out +} + +func (s *ChannelStore) duplicateChannelMessage(ctx context.Context, channelID, userID, randomID int64) (domain.SendChannelMessageResult, bool, error) { + row := s.db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND sender_user_id = $2 AND random_id = $3`, channelID, userID, randomID) + msg, err := scanChannelMessage(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SendChannelMessageResult{}, false, nil + } + if err != nil { + return domain.SendChannelMessageResult{}, false, err + } + channel, err := getChannelByID(ctx, s.db, channelID) + if err != nil { + return domain.SendChannelMessageResult{}, false, err + } + event, err := s.eventForChannelMessage(ctx, channelID, msg.ID) + if err != nil { + return domain.SendChannelMessageResult{}, false, err + } + if event.Message.ID != 0 { + msg = event.Message + } + return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Duplicate: true}, true, nil +} + +func (s *ChannelStore) insertServiceMessage(ctx context.Context, tx pgx.Tx, channel domain.Channel, senderUserID int64, date int, action domain.ChannelMessageAction) (domain.ChannelMessage, domain.ChannelUpdateEvent, error) { + msgID, err := s.msgIDs.NextChannelMessageID(ctx, channel.ID) + if err != nil { + return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service message id: %w", err) + } + action = channelServiceActionForMessage(channel.ID, msgID, action) + pts, err := s.reserveChannelPts(ctx, tx, channel.ID) + if err != nil { + return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("allocate channel service pts: %w", err) + } + msg := domain.ChannelMessage{ + ChannelID: channel.ID, + ID: msgID, + SenderUserID: senderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID}, + Date: date, + Post: channel.Broadcast, + Action: &action, + Pts: pts, + } + event := domain.ChannelUpdateEvent{ + ChannelID: channel.ID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: date, + Message: msg, + SenderUserID: senderUserID, + UserIDs: append([]int64(nil), action.UserIDs...), + } + if err := insertChannelMessageTx(ctx, tx, msg); err != nil { + return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, channel.ID, msgID, pts); err != nil { + return domain.ChannelMessage{}, domain.ChannelUpdateEvent{}, fmt.Errorf("update channel service top: %w", err) + } + return msg, event, nil +} + +func channelServiceActionForMessage(channelID int64, msgID int, action domain.ChannelMessageAction) domain.ChannelMessageAction { + if action.Type == domain.ChannelActionStarGift && action.StarGift != nil { + g := *action.StarGift + if g.PeerChannelID == 0 { + g.PeerChannelID = channelID + } + if g.SavedID == 0 { + g.SavedID = int64(msgID) + } + action.StarGift = &g + } + return action +} + +func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMessage) error { + entities, err := encodeMessageEntities(msg.Entities) + if err != nil { + return err + } + reply, err := marshalJSON(msg.ReplyTo, "{}") + if err != nil { + return err + } + forward, err := marshalJSON(msg.Forward, "{}") + if err != nil { + return err + } + action, err := marshalJSON(msg.Action, "{}") + if err != nil { + return err + } + media, err := encodeMessageMedia(msg.Media) + if err != nil { + return err + } + replyMarkup, err := encodeReplyMarkup(msg.ReplyMarkup) + if err != nil { + return err + } + var sendAsType sql.NullString + var sendAsID sql.NullInt64 + if msg.SendAs != nil && msg.SendAs.ID != 0 { + sendAsType = sql.NullString{String: string(msg.SendAs.Type), Valid: true} + sendAsID = sql.NullInt64{Int64: msg.SendAs.ID, Valid: true} + } + if msg.From.Type == "" { + msg.From = domain.Peer{Type: domain.PeerTypeUser, ID: msg.SenderUserID} + } + replyMsgID, replyTopID := 0, 0 + replyPeerType := "" + replyPeerID := int64(0) + if msg.ReplyTo != nil { + replyMsgID = msg.ReplyTo.MessageID + replyTopID = msg.ReplyTo.TopMessageID + replyPeerType = string(msg.ReplyTo.Peer.Type) + replyPeerID = msg.ReplyTo.Peer.ID + } + discussionChannelID, discussionMessageID := int64(0), 0 + if msg.Discussion != nil { + discussionChannelID = msg.Discussion.ChannelID + discussionMessageID = msg.Discussion.MessageID + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_messages ( + channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, + send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, + body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36)`, + msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID, + sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards, + msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID, + forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID); err != nil { + return fmt.Errorf("insert channel message: %w", err) + } + // 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。 + if err := insertChannelMediaIndexTx(ctx, tx, msg.ChannelID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil { + return err + } + return nil +} + +// channelPostAuthor 仅在 signatures 开启的 broadcast post 上保留作者签名。 +func channelPostAuthor(channel domain.Channel, author string) string { + if !channel.Broadcast || !channel.Signatures { + return "" + } + return author +} + +func canSendChannelMessage(channel domain.Channel, member domain.ChannelMember) bool { + return canSendChannelMessageWithBoost(channel, member, 0) +} + +func canSendChannelMessageWithBoost(channel domain.Channel, member domain.ChannelMember, selfBoostsApplied int) bool { + if channel.Broadcast { + return canPostChannel(member) + } + if member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + return true + } + if member.BannedRights.SendMessages { + return false + } + if !channel.DefaultBannedRights.SendMessages { + return true + } + return channel.BoostsUnrestrict > 0 && selfBoostsApplied >= channel.BoostsUnrestrict +} diff --git a/internal/store/postgres/channel_message_settings.go b/internal/store/postgres/channel_message_settings.go new file mode 100644 index 00000000..aaaf8177 --- /dev/null +++ b/internal/store/postgres/channel_message_settings.go @@ -0,0 +1,178 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func (s *ChannelStore) SetPreHistoryHidden(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + var channel domain.Channel + if err := withTx(ctx, s.db, "toggle channel prehistory", func(tx pgx.Tx) error { + var err error + var member domain.ChannelMember + channel, member, err = s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return err + } + if member.Role != domain.ChannelRoleCreator { + return domain.ErrChannelAdminRequired + } + prev := channel.PreHistoryHidden + if _, err := tx.Exec(ctx, `UPDATE channels SET pre_history_hidden = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return fmt.Errorf("update channel prehistory: %w", err) + } + channel.PreHistoryHidden = enabled + if prev != enabled { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogTogglePreHistoryHidden, + PrevBool: prev, + NewBool: enabled, + }); err != nil { + return err + } + } + return nil + }); err != nil { + return domain.Channel{}, err + } + return channel, nil +} + +func (s *ChannelStore) SetPaidMessagesPrice(ctx context.Context, userID, channelID int64, stars int64, broadcastMessagesAllowed bool) (domain.ChannelPaidMessagesPriceResult, error) { + if userID == 0 || channelID == 0 || stars < 0 { + return domain.ChannelPaidMessagesPriceResult{}, domain.ErrChannelInvalid + } + var channel domain.Channel + services := make([]domain.SendChannelMessageResult, 0, 2) + if err := withTx(ctx, s.db, "update channel paid messages price", func(tx pgx.Tx) error { + var err error + var member domain.ChannelMember + channel, member, err = s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return err + } + if !canChangeChannelInfo(member) { + return domain.ErrChannelAdminRequired + } + prevAllowed := channel.BroadcastMessagesAllowed + prevStars := channel.SendPaidMessagesStars + prevLinked := channel.LinkedMonoforumID + broadcastAllowed := channel.Broadcast && broadcastMessagesAllowed + if _, err := tx.Exec(ctx, `UPDATE channels SET send_paid_messages_stars = $2, broadcast_messages_allowed = $3, updated_at = now() WHERE id = $1`, channelID, stars, broadcastAllowed); err != nil { + return fmt.Errorf("update channel paid messages price: %w", err) + } + channel.SendPaidMessagesStars = stars + channel.BroadcastMessagesAllowed = broadcastAllowed + changed := channel.Broadcast && (prevAllowed != broadcastAllowed || prevStars != stars || (broadcastAllowed && prevLinked == 0)) + // 开启频道私信(Direct Messages)时为母频道关联一个 monoforum 虚拟频道;再次开启复用既有 + // monoforum,仅同步价格。关闭(broadcastAllowed=false)保留 monoforum 以便重新开启时复用。 + var mono domain.Channel + if broadcastAllowed { + if channel.LinkedMonoforumID == 0 { + monoID, err := s.allocateFreshChannelID(ctx) + if err != nil { + return err + } + accessHash, err := randomChannelAccessHash() + if err != nil { + return err + } + mono = domain.Channel{ + ID: monoID, + AccessHash: accessHash, + CreatorUserID: channel.CreatorUserID, + Title: channel.Title, + // Keep monoforum as a megagroup-like saved sublist container. TDesktop hangs when + // a monoforum peer is projected as both broadcast and megagroup. + Megagroup: true, + Monoforum: true, + LinkedMonoforumID: channelID, + SendPaidMessagesStars: stars, + Date: nowUnix(), + } + if err := insertChannelTx(ctx, tx, mono); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET linked_monoforum_id = $2, updated_at = now() WHERE id = $1`, channelID, monoID); err != nil { + return fmt.Errorf("link channel monoforum: %w", err) + } + channel.LinkedMonoforumID = monoID + } else { + // 复用既有 monoforum 只同步价格。创建路径恒以 megagroup 形态建 mono,无任何路径会把它 + // 翻成 broadcast/forum,故此处无需(也不应)再规范化 kind——那是对从未发布的 broadcast+ + // monoforum 实验形态的历史数据修复,在无遗留数据的项目里是死代码。 + if _, err := tx.Exec(ctx, `UPDATE channels SET send_paid_messages_stars = $2, updated_at = now() WHERE id = $1`, channel.LinkedMonoforumID, stars); err != nil { + return fmt.Errorf("sync monoforum paid messages price: %w", err) + } + mono, err = getChannelByID(ctx, tx, channel.LinkedMonoforumID) + if err != nil { + return fmt.Errorf("load monoforum for paid messages service: %w", err) + } + } + // monoforum 的首条(也是唯一)服务消息是它的"创建"消息(messageActionChannelCreate): + // 客户端对 monoforum 专门把创建消息渲染为 "Direct messages were enabled in this channel." + // (TDesktop lng_action_created_monoforum)。开关/价格变更的 paid_messages_price 服务消息 + // 只进母广播频道(在 broadcast 历史里渲染为 "Channel enabled/disabled Direct Messages"), + // **绝不进 mono**——mono 是 megagroup(非 broadcast),同一 action 会被渲染成"消息免费/ + // 设为 N 星"的错误文案。关闭仅靠 monoforumDisabled 状态(隐藏母频道 linked_monoforum_id) + // 显示停用页脚,不再往 mono 追加气泡。 + if mono.ID != 0 && mono.TopMessageID == 0 { + createMsg, createEvent, err := s.insertServiceMessage(ctx, tx, mono, userID, nowUnix(), domain.ChannelMessageAction{Type: domain.ChannelActionCreate, Title: mono.Title}) + if err != nil { + return fmt.Errorf("append monoforum creation service: %w", err) + } + mono.TopMessageID = createMsg.ID + mono.Pts = createEvent.Pts + services = append(services, domain.SendChannelMessageResult{Channel: mono, Message: createMsg, Event: createEvent}) + } + } + // monoforum 把母频道的 DM 启用状态镜像到自己的 broadcast_messages_allowed 列。投影时据此决定 + // 是否下发 mono 自身的 linked_monoforum_id:开启=下发,关闭=隐藏。关闭时母频道与 mono 双方都 + // 隐藏 link,客户端打开 monoforum 重新拉取时不会重新 setMonoforumLink(parent) 而清掉 MonoforumDisabled, + // 停用页脚得以保持。内部关联行(linked_monoforum_id)仍保留以便重新开启复用。 + // 仅在 mono 的 DM 启用状态实际变化时才写镜像列:避免每次 SetPaidMessagesPrice(含未变更的 + // 再次开启)都对 mono 行白写一遍并触发 channels 表多余的 LISTEN/NOTIFY 跨实例失效。开启分支已 + // 把 mono 经 getChannelByID 读入(mono.ID!=0,字段反映库内当前值);关闭分支不加载 mono(mono.ID==0) + // 故无法廉价比对、保持照写以确保停用页脚状态正确。 + if channel.LinkedMonoforumID != 0 && (mono.ID == 0 || mono.BroadcastMessagesAllowed != broadcastAllowed) { + if _, err := tx.Exec(ctx, `UPDATE channels SET broadcast_messages_allowed = $2, updated_at = now() WHERE id = $1`, channel.LinkedMonoforumID, broadcastAllowed); err != nil { + return fmt.Errorf("sync monoforum dm-enabled state: %w", err) + } + if mono.ID != 0 { + mono.BroadcastMessagesAllowed = broadcastAllowed + } + } + if changed && channel.LinkedMonoforumID != 0 { + action := domain.ChannelMessageAction{ + Type: domain.ChannelActionPaidMessagesPrice, + BroadcastMessagesAllowed: broadcastAllowed, + Stars: stars, + } + parentMsg, parentEvent, err := s.insertServiceMessage(ctx, tx, channel, userID, nowUnix(), action) + if err != nil { + return fmt.Errorf("append parent paid messages price service: %w", err) + } + channel.TopMessageID = parentMsg.ID + channel.Pts = parentEvent.Pts + services = append(services, domain.SendChannelMessageResult{Channel: channel, Message: parentMsg, Event: parentEvent}) + } + return nil + }); err != nil { + return domain.ChannelPaidMessagesPriceResult{}, err + } + var first *domain.SendChannelMessageResult + if len(services) > 0 { + first = &services[0] + } + return domain.ChannelPaidMessagesPriceResult{Channel: channel, ServiceMessage: first, ServiceMessages: services}, nil +} diff --git a/internal/store/postgres/channel_message_views.go b/internal/store/postgres/channel_message_views.go new file mode 100644 index 00000000..715fa2cc --- /dev/null +++ b/internal/store/postgres/channel_message_views.go @@ -0,0 +1,242 @@ +package postgres + +import ( + "context" + "fmt" + + "telesrv/internal/domain" +) + +type channelMessageViewSummary struct { + ID int + Views int + Post bool + Discussion *domain.ChannelDiscussionRef + Sender domain.Peer +} + +type channelMessageViewReplyRef struct { + messageID int + replies *domain.ChannelMessageReplies +} + +func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.ChannelMessageViewsRequest) (domain.ChannelMessageViewsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageViewsResult{}, err + } + if len(req.IDs) == 0 { + return domain.ChannelMessageViewsResult{Channel: channel, Views: map[int]int{}, Replies: map[int]*domain.ChannelMessageReplies{}}, nil + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid + } + id32, _, err := validUniqueChannelMessageIDs(req.IDs) + if err != nil { + return domain.ChannelMessageViewsResult{}, err + } + if req.Increment { + date := req.Date + if date <= 0 { + date = nowUnix() + } + rows, err := s.db.Query(ctx, ` +WITH inserted AS ( + INSERT INTO channel_message_viewers (channel_id, message_id, viewer_user_id, viewed_at) + SELECT m.channel_id, m.id, $3, $4 + FROM channel_messages m + WHERE m.channel_id = $1 + AND m.id = ANY($2::int[]) + AND NOT m.deleted + AND m.id > $5 + ON CONFLICT DO NOTHING + RETURNING message_id +), updated AS ( + UPDATE channel_messages m + SET views_count = views_count + 1, + updated_at = now() + FROM inserted i + WHERE m.channel_id = $1 + AND m.id = i.message_id + RETURNING m.id +) +SELECT i.message_id +FROM inserted i +LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, date, member.AvailableMinID) + if err != nil { + return domain.ChannelMessageViewsResult{}, fmt.Errorf("increment channel message views: %w", err) + } + for rows.Next() { + var ignored int + if err := rows.Scan(&ignored); err != nil { + rows.Close() + return domain.ChannelMessageViewsResult{}, err + } + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.ChannelMessageViewsResult{}, err + } + rows.Close() + } + summaries, err := s.listChannelMessageViewSummaries(ctx, req.ChannelID, id32, member.AvailableMinID) + if err != nil { + return domain.ChannelMessageViewsResult{}, err + } + views := make(map[int]int, len(summaries)) + peers := make([]domain.Peer, 0, len(summaries)) + peerSeen := make(map[domain.Peer]struct{}, len(summaries)) + for _, summary := range summaries { + views[summary.ID] = summary.Views + if summary.Sender.ID != 0 { + if _, ok := peerSeen[summary.Sender]; !ok { + peerSeen[summary.Sender] = struct{}{} + peers = append(peers, summary.Sender) + } + } + } + replies, err := s.channelMessageViewReplies(ctx, req.UserID, channel, summaries) + if err != nil { + return domain.ChannelMessageViewsResult{}, err + } + return domain.ChannelMessageViewsResult{ + Channel: channel, + Views: views, + Replies: replies, + Peers: peers, + }, nil +} + +func (s *ChannelStore) listChannelMessageViewSummaries(ctx context.Context, channelID int64, ids []int32, availableMinID int) ([]channelMessageViewSummary, error) { + args := []any{channelID, ids} + where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" + if availableMinID > 0 { + args = append(args, availableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + rows, err := s.db.Query(ctx, ` +SELECT id, views_count, post, discussion_channel_id, discussion_message_id, sender_user_id, from_peer_type, from_peer_id +FROM channel_messages +WHERE `+where, args...) + if err != nil { + return nil, fmt.Errorf("get channel message view summaries: %w", err) + } + defer rows.Close() + out := make([]channelMessageViewSummary, 0, len(ids)) + for rows.Next() { + var summary channelMessageViewSummary + var discussionChannelID int64 + var discussionMessageID int + var senderUserID int64 + var fromPeerType string + var fromPeerID int64 + if err := rows.Scan(&summary.ID, &summary.Views, &summary.Post, &discussionChannelID, &discussionMessageID, &senderUserID, &fromPeerType, &fromPeerID); err != nil { + return nil, err + } + if discussionChannelID != 0 && discussionMessageID != 0 { + summary.Discussion = &domain.ChannelDiscussionRef{ChannelID: discussionChannelID, MessageID: discussionMessageID} + } + summary.Sender = domain.Peer{Type: domain.PeerType(fromPeerType), ID: fromPeerID} + if summary.Sender.ID == 0 && senderUserID != 0 { + summary.Sender = domain.Peer{Type: domain.PeerTypeUser, ID: senderUserID} + } + out = append(out, summary) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) channelMessageViewReplies(ctx context.Context, viewerUserID int64, channel domain.Channel, summaries []channelMessageViewSummary) (map[int]*domain.ChannelMessageReplies, error) { + if len(summaries) == 0 || channel.ID == 0 { + return map[int]*domain.ChannelMessageReplies{}, nil + } + indexes := make(map[channelReplyStatKey][]channelMessageViewReplyRef) + channelIDs := make([]int64, 0, 2) + rootIDs := make([]int32, 0, len(summaries)) + addRoot := func(messageID int, targetChannelID int64, rootID int, replies *domain.ChannelMessageReplies) { + if targetChannelID == 0 || rootID <= 0 { + return + } + key := channelReplyStatKey{channelID: targetChannelID, rootID: rootID} + if _, ok := indexes[key]; !ok { + channelIDs = append(channelIDs, targetChannelID) + rootIDs = append(rootIDs, int32(rootID)) + } + indexes[key] = append(indexes[key], channelMessageViewReplyRef{messageID: messageID, replies: replies}) + } + out := make(map[int]*domain.ChannelMessageReplies) + for _, summary := range summaries { + targetChannelID := channel.ID + rootID := summary.ID + replies := &domain.ChannelMessageReplies{} + if summary.Discussion != nil && summary.Discussion.ChannelID != 0 && summary.Discussion.MessageID != 0 { + targetChannelID = summary.Discussion.ChannelID + rootID = summary.Discussion.MessageID + replies.Comments = true + replies.ChannelID = summary.Discussion.ChannelID + } else if channel.Broadcast && channel.LinkedChatID != 0 && summary.Post { + targetChannelID = channel.LinkedChatID + replies.Comments = true + replies.ChannelID = channel.LinkedChatID + } + if replies.Comments { + out[summary.ID] = replies + } + addRoot(summary.ID, targetChannelID, rootID, replies) + } + if len(channelIDs) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, ` +WITH roots AS ( + SELECT * + FROM unnest($1::bigint[], $2::int[]) AS r(channel_id, root_id) +) +SELECT r.channel_id, + r.root_id, + COUNT(m.id)::int, + COALESCE(MAX(m.id), 0)::int, + COALESCE((array_agg(m.pts ORDER BY m.id DESC) FILTER (WHERE m.id IS NOT NULL))[1], 0)::int, + COALESCE(cm.read_inbox_max_id, 0)::int +FROM roots r +LEFT JOIN channel_members cm + ON cm.channel_id = r.channel_id + AND cm.user_id = $3 +LEFT JOIN channel_messages m + ON m.channel_id = r.channel_id + AND m.reply_to_top_id = r.root_id + AND NOT m.deleted +GROUP BY r.channel_id, r.root_id, cm.read_inbox_max_id`, channelIDs, rootIDs, viewerUserID) + if err != nil { + return nil, fmt.Errorf("load channel message view reply stats: %w", err) + } + defer rows.Close() + for rows.Next() { + var channelID int64 + var rootID, count, maxID, repliesPts, readMaxID int + if err := rows.Scan(&channelID, &rootID, &count, &maxID, &repliesPts, &readMaxID); err != nil { + return nil, err + } + for _, ref := range indexes[channelReplyStatKey{channelID: channelID, rootID: rootID}] { + replies := ref.replies + replies.ReadMaxID = readMaxID + if count > 0 { + replies.Replies = count + replies.MaxID = maxID + replies.RepliesPts = repliesPts + out[ref.messageID] = replies + } else if replies.Comments { + out[ref.messageID] = replies + } + } + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/store/postgres/channel_monoforum.go b/internal/store/postgres/channel_monoforum.go new file mode 100644 index 00000000..f47397ee --- /dev/null +++ b/internal/store/postgres/channel_monoforum.go @@ -0,0 +1,310 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +// SendMonoforumMessage 向 monoforum(频道私信)虚拟频道发一条消息,按 saved_peer 分订阅者子会话。 +// 私信消息存进 channel_messages(复用 channel pts/事件/difference);发件权限(订阅者身份/管理员) +// 由 RPC 层校验,store 只校验 monoforum 频道存在,不要求发件人是成员(订阅者不是 monoforum 成员)。 +func (s *ChannelStore) SendMonoforumMessage(ctx context.Context, req domain.SendMonoforumMessageRequest) (domain.SendChannelMessageResult, error) { + if req.MonoforumID == 0 || req.SenderUserID == 0 || req.SavedPeer.ID == 0 || + req.SavedPeer.Type != domain.PeerTypeUser || strings.TrimSpace(req.Message) == "" { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + if req.RandomID != 0 { + if dup, found, err := s.duplicateMonoforumMessage(ctx, req.MonoforumID, req.SenderUserID, req.SavedPeer, req.RandomID); err != nil { + return domain.SendChannelMessageResult{}, err + } else if found { + return dup, nil + } + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.SendChannelMessageResult{}, fmt.Errorf("send monoforum message: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("begin send monoforum: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, err := getChannelByID(ctx, tx, req.MonoforumID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + return domain.SendChannelMessageResult{}, err + } + if channel.Deleted || !channel.Monoforum { + return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid + } + msgID, err := s.msgIDs.NextChannelMessageID(ctx, req.MonoforumID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum message id: %w", err) + } + pts, err := s.reserveChannelPts(ctx, tx, req.MonoforumID) + if err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("allocate monoforum pts: %w", err) + } + msg := domain.ChannelMessage{ + ChannelID: req.MonoforumID, + ID: msgID, + RandomID: req.RandomID, + SenderUserID: req.SenderUserID, + From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, + SavedPeer: req.SavedPeer, + Date: req.Date, + Body: req.Message, + Entities: append([]domain.MessageEntity(nil), req.Entities...), + Pts: pts, + } + event := domain.ChannelUpdateEvent{ + ChannelID: req.MonoforumID, + Type: domain.ChannelUpdateNewMessage, + Pts: pts, + PtsCount: 1, + Date: req.Date, + Message: msg, + SenderUserID: req.SenderUserID, + } + if err := insertChannelMessageTx(ctx, tx, msg); err != nil { + if isUniqueViolation(err) { + // 唯一约束按 (channel,sender,random_id) 三元组;只有同一订阅者子会话的真重发才算重复。 + // 跨子会话复用同一 random_id(异常客户端)按 saved_peer 过滤后命中不到 → 干净返错,不串消息。 + dup, found, dupErr := s.duplicateMonoforumMessage(ctx, req.MonoforumID, req.SenderUserID, req.SavedPeer, req.RandomID) + if dupErr != nil || !found { + return domain.SendChannelMessageResult{}, dupErr + } + dup.Duplicate = true + return dup, nil + } + return domain.SendChannelMessageResult{}, err + } + if err := insertChannelEventTx(ctx, tx, event); err != nil { + return domain.SendChannelMessageResult{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET top_message_id = $2, pts = $3, updated_at = now() WHERE id = $1`, req.MonoforumID, msgID, pts); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("update monoforum top: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.SendChannelMessageResult{}, fmt.Errorf("commit send monoforum: %w", err) + } + committed = true + channel.TopMessageID = msgID + channel.Pts = pts + return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event}, nil +} + +// ListMonoforumHistory 拉取某订阅者(saved_peer)在 monoforum 内的私信历史,id 倒序分页。 +func (s *ChannelStore) ListMonoforumHistory(ctx context.Context, filter domain.MonoforumHistoryFilter) (domain.ChannelHistory, error) { + if filter.MonoforumID == 0 || filter.SavedPeer.ID == 0 { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + channel, err := getChannelByID(ctx, s.db, filter.MonoforumID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + return domain.ChannelHistory{}, err + } + if !channel.Monoforum { + return domain.ChannelHistory{}, domain.ErrChannelInvalid + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + args := []any{filter.MonoforumID, string(filter.SavedPeer.Type), filter.SavedPeer.ID} + where := `channel_id = $1 AND saved_peer_type = $2 AND saved_peer_id = $3 AND NOT deleted` + if filter.OffsetID > 0 { + where += fmt.Sprintf(` AND id < $%d`, len(args)+1) + args = append(args, filter.OffsetID) + } + rows, err := s.db.Query(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE `+where+fmt.Sprintf(` ORDER BY id DESC LIMIT $%d`, len(args)+1), append(args, limit)...) + if err != nil { + return domain.ChannelHistory{}, fmt.Errorf("list monoforum history: %w", err) + } + defer rows.Close() + var msgs []domain.ChannelMessage + for rows.Next() { + m, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelHistory{}, err + } + msgs = append(msgs, m) + } + if err := rows.Err(); err != nil { + return domain.ChannelHistory{}, err + } + var count int + if err := s.db.QueryRow(ctx, `SELECT count(*)::int FROM channel_messages WHERE channel_id = $1 AND saved_peer_type = $2 AND saved_peer_id = $3 AND NOT deleted`, + filter.MonoforumID, string(filter.SavedPeer.Type), filter.SavedPeer.ID).Scan(&count); err != nil { + return domain.ChannelHistory{}, fmt.Errorf("count monoforum history: %w", err) + } + return domain.ChannelHistory{Messages: msgs, Count: count, Channel: channel}, nil +} + +// ResolveMonoforumSend 按 id 取 monoforum 频道(不要求调用者是 monoforum 成员),并返回调用者是否为 +// 其母广播频道的创建者/管理员。非 monoforum/不存在 → ErrChannelInvalid。 +func (s *ChannelStore) ResolveMonoforumSend(ctx context.Context, viewerUserID, monoforumID int64) (domain.Channel, bool, error) { + if viewerUserID == 0 || monoforumID == 0 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + mono, err := getChannelByID(ctx, s.db, monoforumID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + return domain.Channel{}, false, err + } + if mono.Deleted || !mono.Monoforum || mono.LinkedMonoforumID == 0 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + isAdmin := false + if _, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, mono.LinkedMonoforumID); err == nil { + isAdmin = member.Status == domain.ChannelMemberActive && + (member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin) + } + return mono, isAdmin, nil +} + +// duplicateMonoforumMessage 按 (channel,sender,saved_peer,random_id) 查重发,确保同一发件人向不同 +// 订阅者子会话用相同 random_id 时不会互相误判为重复。 +func (s *ChannelStore) duplicateMonoforumMessage(ctx context.Context, channelID, senderUserID int64, savedPeer domain.Peer, randomID int64) (domain.SendChannelMessageResult, bool, error) { + row := s.db.QueryRow(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages +WHERE channel_id = $1 AND sender_user_id = $2 AND saved_peer_type = $3 AND saved_peer_id = $4 AND random_id = $5`, + channelID, senderUserID, string(savedPeer.Type), savedPeer.ID, randomID) + msg, err := scanChannelMessage(row) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SendChannelMessageResult{}, false, nil + } + if err != nil { + return domain.SendChannelMessageResult{}, false, err + } + channel, err := getChannelByID(ctx, s.db, channelID) + if err != nil { + return domain.SendChannelMessageResult{}, false, err + } + event, err := s.eventForChannelMessage(ctx, channelID, msg.ID) + if err != nil { + return domain.SendChannelMessageResult{}, false, err + } + if event.Message.ID != 0 { + msg = event.Message + } + return domain.SendChannelMessageResult{Channel: channel, Message: msg, Event: event, Duplicate: true}, true, nil +} + +// ListMonoforumDialogs 列出 monoforum 的订阅者子会话(每个 saved_peer 一条,取其 top 消息), +// 按 top 消息 id 倒序分页。走部分索引 channel_messages_monoforum_sublist_idx。 +func (s *ChannelStore) ListMonoforumDialogs(ctx context.Context, filter domain.MonoforumDialogsFilter) (domain.MonoforumDialogList, error) { + if filter.MonoforumID == 0 { + return domain.MonoforumDialogList{}, domain.ErrChannelInvalid + } + channel, err := getChannelByID(ctx, s.db, filter.MonoforumID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.MonoforumDialogList{}, domain.ErrChannelInvalid + } + return domain.MonoforumDialogList{}, err + } + if !channel.Monoforum { + return domain.MonoforumDialogList{}, domain.ErrChannelInvalid + } + limit := filter.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + args := []any{filter.MonoforumID} + outerWhere := "" + if filter.OffsetID > 0 { + outerWhere = fmt.Sprintf(` WHERE top_id < $%d`, len(args)+1) + args = append(args, filter.OffsetID) + } + // 按 saved_peer_id DISTINCT ON 直接命中部分索引 channel_messages_monoforum_sublist_idx + // (channel_id, saved_peer_id, id DESC),避免对全频道私信做内存全排序;saved_peer_type 对 monoforum + // 恒为 'user'(发送时强校验),取每组 top 行的值即可,与按 (type,id) 分组结果一致。 + q := ` +SELECT saved_peer_type, saved_peer_id, top_id FROM ( + SELECT DISTINCT ON (saved_peer_id) saved_peer_type, saved_peer_id, id AS top_id + FROM channel_messages + WHERE channel_id = $1 AND saved_peer_id <> 0 AND NOT deleted + ORDER BY saved_peer_id, id DESC +) t` + outerWhere + fmt.Sprintf(` ORDER BY top_id DESC LIMIT $%d`, len(args)+1) + args = append(args, limit) + rows, err := s.db.Query(ctx, q, args...) + if err != nil { + return domain.MonoforumDialogList{}, fmt.Errorf("list monoforum dialogs: %w", err) + } + type subRef struct { + peer domain.Peer + topID int + } + var refs []subRef + var topIDs []int + for rows.Next() { + var spType string + var spID int64 + var topID int + if err := rows.Scan(&spType, &spID, &topID); err != nil { + rows.Close() + return domain.MonoforumDialogList{}, err + } + refs = append(refs, subRef{peer: domain.Peer{Type: domain.PeerType(spType), ID: spID}, topID: topID}) + topIDs = append(topIDs, topID) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.MonoforumDialogList{}, err + } + rows.Close() + + msgByID := make(map[int]domain.ChannelMessage, len(topIDs)) + if len(topIDs) > 0 { + mrows, err := s.db.Query(ctx, `SELECT `+channelMessageColumns+` FROM channel_messages WHERE channel_id = $1 AND id = ANY($2::int[])`, filter.MonoforumID, topIDs) + if err != nil { + return domain.MonoforumDialogList{}, fmt.Errorf("load monoforum dialog top messages: %w", err) + } + for mrows.Next() { + m, err := scanChannelMessage(mrows) + if err != nil { + mrows.Close() + return domain.MonoforumDialogList{}, err + } + msgByID[m.ID] = m + } + if err := mrows.Err(); err != nil { + mrows.Close() + return domain.MonoforumDialogList{}, err + } + mrows.Close() + } + + out := domain.MonoforumDialogList{MonoforumID: filter.MonoforumID, Channel: channel} + for _, r := range refs { + m := msgByID[r.topID] + out.Dialogs = append(out.Dialogs, domain.MonoforumDialog{SavedPeer: r.peer, TopMessageID: r.topID, TopMessageDate: m.Date}) + if m.ID != 0 { + out.Messages = append(out.Messages, m) + } + } + if err := s.db.QueryRow(ctx, `SELECT count(DISTINCT saved_peer_id)::int FROM channel_messages WHERE channel_id = $1 AND saved_peer_id <> 0 AND NOT deleted`, filter.MonoforumID).Scan(&out.Count); err != nil { + return domain.MonoforumDialogList{}, fmt.Errorf("count monoforum dialogs: %w", err) + } + return out, nil +} diff --git a/internal/store/postgres/channel_monoforum_integration_test.go b/internal/store/postgres/channel_monoforum_integration_test.go new file mode 100644 index 00000000..149d0f43 --- /dev/null +++ b/internal/store/postgres/channel_monoforum_integration_test.go @@ -0,0 +1,223 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestChannelStoreEnablingDirectMessagesCreatesMonoforum 回归迁移 0020 + monoforum 生命周期: +// 开启频道私信(Direct Messages)为广播母频道事务内建/关联一个 monoforum 虚拟频道, +// 新列(monoforum/linked_monoforum_id)经共享 channelColumns + scan 正确往返;再次开启复用、 +// 同步价格;非广播不建。门控于 TELESRV_TEST_POSTGRES_DSN。 +func TestChannelStoreEnablingDirectMessagesCreatesMonoforum(t *testing.T) { + pool := testPool(t) // 未设 DSN 会 t.Skip + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 81, Phone: "+1779" + suffix + "31", FirstName: "MonoOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) > 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, Title: "Mono Broadcast " + suffix, Broadcast: true, Date: 1700000900, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelIDs = append(channelIDs, created.Channel.ID) + + updatedResult, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 5, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + updated := updatedResult.Channel + if updated.LinkedMonoforumID == 0 || !updated.BroadcastMessagesAllowed || updated.SendPaidMessagesStars != 5 { + t.Fatalf("after enable = %+v, want linked monoforum + broadcast allowed + 5 stars", updated) + } + monoID := updated.LinkedMonoforumID + channelIDs = append(channelIDs, monoID) + + // 母频道新列经 scan 读回(scanChannelWithMember 路径)。 + view, err := channels.GetChannel(ctx, owner.ID, created.Channel.ID) + if err != nil { + t.Fatalf("get parent: %v", err) + } + if view.Channel.LinkedMonoforumID != monoID || view.Channel.Monoforum { + t.Fatalf("parent view = monoforum %v linked %d, want not-monoforum linked %d", view.Channel.Monoforum, view.Channel.LinkedMonoforumID, monoID) + } + + // monoforum 行:monoforum=true、反向关联母频道、价格、creator。 + var mfMono, mfBroadcast, mfMegagroup bool + var mfLinked, mfStars, mfCreator int64 + var mfTop, mfPts int + if err := pool.QueryRow(ctx, `SELECT monoforum, broadcast, megagroup, linked_monoforum_id, send_paid_messages_stars, creator_user_id, top_message_id, pts FROM channels WHERE id = $1`, monoID).Scan(&mfMono, &mfBroadcast, &mfMegagroup, &mfLinked, &mfStars, &mfCreator, &mfTop, &mfPts); err != nil { + t.Fatalf("read monoforum row: %v", err) + } + if !mfMono || mfBroadcast || !mfMegagroup || mfLinked != created.Channel.ID || mfStars != 5 || mfCreator != owner.ID { + t.Fatalf("monoforum row = mono %v broadcast %v megagroup %v linked %d stars %d creator %d, want true/false/true/%d/5/%d", mfMono, mfBroadcast, mfMegagroup, mfLinked, mfStars, mfCreator, created.Channel.ID, owner.ID) + } + if mfTop == 0 || mfPts == 0 { + t.Fatalf("monoforum top/pts = %d/%d, want paid-messages service top", mfTop, mfPts) + } + // 同批下发母广播频道(TDesktop 据此 resolve linked_monoforum_id 并派生 MonoforumAdmin + // 渲染 Direct-Messages 容器):GetChannelDialogs([mono]) 的 chats[] 必须同时带 mono 与母频道。 + coDelivery, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{monoID}) + if err != nil { + t.Fatalf("get channel dialogs: %v", err) + } + var hasMono, hasParent bool + for _, ch := range coDelivery.Channels { + switch ch.ID { + case monoID: + hasMono = true + case created.Channel.ID: + hasParent = true + } + } + if !hasMono || !hasParent { + t.Fatalf("GetChannelDialogs([mono]) hasMono=%v hasParent=%v, want both (parent co-delivered): %+v", hasMono, hasParent, coDelivery.Channels) + } + dialogs, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{Limit: 100}) + if err != nil { + t.Fatalf("list dialogs after enable: %v", err) + } + var foundMono bool + for _, dialog := range dialogs.Dialogs { + if dialog.Peer == (domain.Peer{Type: domain.PeerTypeChannel, ID: monoID}) { + foundMono = true + if dialog.TopMessage != mfTop || dialog.UnreadCount != 0 || dialog.ReadInboxMaxID < mfTop { + t.Fatalf("monoforum dialog = %+v, want top %d read/no-unread", dialog, mfTop) + } + } + } + if !foundMono { + t.Fatalf("dialogs after enable do not include admin-visible monoforum %d: %+v", monoID, dialogs.Dialogs) + } + // monoforum 只有一条创建服务消息(TDesktop 渲染 "Direct messages were enabled in this channel.", + // lng_action_created_monoforum)。开关/价格变更的 paid_messages_price 只进母广播频道(在 broadcast + // 历史里渲染 "Channel enabled/disabled Direct Messages"),绝不进 mono——否则 mono 是 megagroup, + // 同一 action 会渲染成错误的"消息免费/设价"文案。 + var actionType string + var monoMsgCount int + assertMonoSoleCreation := func(label string) { + t.Helper() + if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM channel_messages WHERE channel_id = $1`, monoID).Scan(&monoMsgCount); err != nil { + t.Fatalf("count monoforum messages (%s): %v", label, err) + } + if monoMsgCount != 1 { + t.Fatalf("monoforum messages (%s) = %d, want exactly one creation service", label, monoMsgCount) + } + if err := pool.QueryRow(ctx, `SELECT action->>'Type' FROM channel_messages WHERE channel_id = $1 ORDER BY id DESC LIMIT 1`, monoID).Scan(&actionType); err != nil { + t.Fatalf("read monoforum service action (%s): %v", label, err) + } + if actionType != string(domain.ChannelActionCreate) { + t.Fatalf("monoforum service action (%s) = %q, want channel_create", label, actionType) + } + } + assertMonoSoleCreation("after enable") + + var parentActionType string + var parentActionAllowed bool + var parentActionStars int64 + readParentAction := func() { + t.Helper() + if err := pool.QueryRow(ctx, ` +SELECT action->>'Type', (action->>'BroadcastMessagesAllowed')::boolean, (action->>'Stars')::bigint +FROM channel_messages +WHERE channel_id = $1 +ORDER BY id DESC +LIMIT 1`, created.Channel.ID).Scan(&parentActionType, &parentActionAllowed, &parentActionStars); err != nil { + t.Fatalf("read parent paid messages service action: %v", err) + } + } + readParentAction() + if parentActionType != string(domain.ChannelActionPaidMessagesPrice) || !parentActionAllowed || parentActionStars != 5 { + t.Fatalf("parent service action = %q/%v/%d, want paid_messages_price/true/5", parentActionType, parentActionAllowed, parentActionStars) + } + if _, err := pool.Exec(ctx, `DELETE FROM channel_messages WHERE channel_id = $1`, monoID); err != nil { + t.Fatalf("delete monoforum service message for legacy simulation: %v", err) + } + if _, err := pool.Exec(ctx, `DELETE FROM channel_update_events WHERE channel_id = $1`, monoID); err != nil { + t.Fatalf("delete monoforum service event for legacy simulation: %v", err) + } + if _, err := pool.Exec(ctx, `UPDATE channels SET top_message_id = 0, pts = 0 WHERE id = $1`, monoID); err != nil { + t.Fatalf("reset monoforum top for legacy simulation: %v", err) + } + repairedResult, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 5, true) + if err != nil { + t.Fatalf("repair legacy DM: %v", err) + } + repaired := repairedResult.Channel + if repaired.LinkedMonoforumID != monoID { + t.Fatalf("repair linked = %d, want stable %d", repaired.LinkedMonoforumID, monoID) + } + // 修复后 monoforum 重新获得一条创建消息。 + assertMonoSoleCreation("after legacy repair") + + // 幂等 + 价格同步:再次开启不新建 monoforum,价格改 8;变更只进母频道。 + againResult, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 8, true) + if err != nil { + t.Fatalf("re-enable: %v", err) + } + again := againResult.Channel + if again.LinkedMonoforumID != monoID { + t.Fatalf("re-enable linked = %d, want stable %d (no second monoforum)", again.LinkedMonoforumID, monoID) + } + if err := pool.QueryRow(ctx, `SELECT send_paid_messages_stars FROM channels WHERE id = $1`, monoID).Scan(&mfStars); err != nil { + t.Fatalf("read monoforum price: %v", err) + } + if mfStars != 8 { + t.Fatalf("monoforum price = %d, want synced 8", mfStars) + } + assertMonoSoleCreation("after price update") + readParentAction() + if parentActionType != string(domain.ChannelActionPaidMessagesPrice) || !parentActionAllowed || parentActionStars != 8 { + t.Fatalf("parent latest action = %q/%v/%d, want paid_messages_price/true/8", parentActionType, parentActionAllowed, parentActionStars) + } + disabledResult, err := channels.SetPaidMessagesPrice(ctx, owner.ID, created.Channel.ID, 0, false) + if err != nil { + t.Fatalf("disable DM: %v", err) + } + disabled := disabledResult.Channel + if disabled.LinkedMonoforumID != monoID || disabled.BroadcastMessagesAllowed { + t.Fatalf("disable linked/allowed = %d/%v, want stable %d and disabled", disabled.LinkedMonoforumID, disabled.BroadcastMessagesAllowed, monoID) + } + if disabledResult.ServiceMessage == nil || disabledResult.ServiceMessage.Event.Pts == 0 { + t.Fatalf("disable service result = %+v, want paid_messages_price event", disabledResult.ServiceMessage) + } + // 关闭只进母频道(+monoforumDisabled 状态);monoforum 仍只有创建消息那一条。 + assertMonoSoleCreation("after disable") + readParentAction() + if parentActionType != string(domain.ChannelActionPaidMessagesPrice) || parentActionAllowed || parentActionStars != 0 { + t.Fatalf("parent latest action after disable = %q/%v/%d, want paid_messages_price/false/0", parentActionType, parentActionAllowed, parentActionStars) + } + + // 超级群(非广播)开 paid messages 不建 monoforum。 + group, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, Title: "Mono SG " + suffix, Megagroup: true, Date: 1700000902, + }) + if err != nil { + t.Fatalf("create group: %v", err) + } + channelIDs = append(channelIDs, group.Channel.ID) + sgResult, err := channels.SetPaidMessagesPrice(ctx, owner.ID, group.Channel.ID, 3, true) + if err != nil { + t.Fatalf("group paid: %v", err) + } + sg := sgResult.Channel + if sg.LinkedMonoforumID != 0 || sg.BroadcastMessagesAllowed { + t.Fatalf("megagroup = linked %d broadcastAllowed %v, want no monoforum", sg.LinkedMonoforumID, sg.BroadcastMessagesAllowed) + } +} diff --git a/internal/store/postgres/channel_monoforum_send_integration_test.go b/internal/store/postgres/channel_monoforum_send_integration_test.go new file mode 100644 index 00000000..edbc0928 --- /dev/null +++ b/internal/store/postgres/channel_monoforum_send_integration_test.go @@ -0,0 +1,162 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestSendMonoforumMessageAndHistoryPostgres 回归频道私信(monoforum)发送+读历史的 PG 实现: +// 私信存进 channel_messages 的 saved_peer 维度、复用 channel pts;按订阅者分子会话、幂等、无串话。 +// 门控于 TELESRV_TEST_POSTGRES_DSN。 +func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1779" + suffix + "41", FirstName: "MonoMsgOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + sub, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1779" + suffix + "42", FirstName: "MonoSub"}) + if err != nil { + t.Fatalf("create sub: %v", err) + } + other, err := users.Create(ctx, domain.User{AccessHash: 93, Phone: "+1779" + suffix + "43", FirstName: "MonoOther"}) + if err != nil { + t.Fatalf("create other: %v", err) + } + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) > 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, sub.ID, other.ID}) + }) + + channels := NewChannelStore(pool) + broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "Mono Msg " + suffix, Broadcast: true, Date: 1700001000}) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelIDs = append(channelIDs, broadcast.Channel.ID) + enabled, err := channels.SetPaidMessagesPrice(ctx, owner.ID, broadcast.Channel.ID, 0, true) + if err != nil { + t.Fatalf("enable DM: %v", err) + } + monoID := enabled.Channel.LinkedMonoforumID + if monoID == 0 { + t.Fatalf("no monoforum created") + } + channelIDs = append(channelIDs, monoID) + + subPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sub.ID} + + m1, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001001}) + if err != nil { + t.Fatalf("subscriber send 1: %v", err) + } + if m1.Message.SavedPeer != subPeer || m1.Message.ChannelID != monoID || m1.Message.Pts == 0 { + t.Fatalf("m1 = %+v, want saved_peer sub + channel mono + pts>0", m1.Message) + } + if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 112, Message: "again", Date: 1700001002}); err != nil { + t.Fatalf("subscriber send 2: %v", err) + } + if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 113, Message: "reply", Date: 1700001003}); err != nil { + t.Fatalf("admin reply: %v", err) + } + + mainHist, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}) + if err != nil { + t.Fatalf("main monoforum history: %v", err) + } + if mainHist.Count != 1 || len(mainHist.Messages) != 1 { + t.Fatalf("main monoforum history count=%d len=%d, want only service message", mainHist.Count, len(mainHist.Messages)) + } + // monoforum 的服务消息是创建消息(渲染 "Direct messages were enabled in this channel."), + // paid_messages_price 只进母广播频道。 + if action := mainHist.Messages[0].Action; action == nil || action.Type != domain.ChannelActionCreate { + t.Fatalf("main monoforum action = %+v, want channel_create", action) + } + if len(mainHist.Channels) != 1 || mainHist.Channels[0].ID != broadcast.Channel.ID { + t.Fatalf("main monoforum extra channels = %+v, want parent %d", mainHist.Channels, broadcast.Channel.ID) + } + if _, err := channels.ListChannelHistory(ctx, sub.ID, domain.ChannelHistoryFilter{ChannelID: monoID, Limit: 10}); err == nil { + t.Fatalf("subscriber main monoforum history = nil err, want denied") + } + + // 幂等。 + dup, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: sub.ID, SavedPeer: subPeer, RandomID: 111, Message: "hi", Date: 1700001004}) + if err != nil { + t.Fatalf("dup send: %v", err) + } + if !dup.Duplicate || dup.Message.ID != m1.Message.ID { + t.Fatalf("dup = %+v, want duplicate of m1 id %d", dup.Message, m1.Message.ID) + } + + // 历史(经 scanChannelMessage 读回 saved_peer)。 + hist, err := channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: subPeer, Limit: 10}) + if err != nil { + t.Fatalf("history: %v", err) + } + if hist.Count != 3 || len(hist.Messages) != 3 { + t.Fatalf("history count=%d len=%d, want 3", hist.Count, len(hist.Messages)) + } + if hist.Messages[0].Body != "reply" { + t.Fatalf("history[0] = %q, want newest 'reply'", hist.Messages[0].Body) + } + for _, m := range hist.Messages { + if m.SavedPeer != subPeer { + t.Fatalf("history msg saved_peer = %+v, want sub", m.SavedPeer) + } + } + + // 另一个订阅者不串会话。 + otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID} + if _, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: other.ID, SavedPeer: otherPeer, RandomID: 201, Message: "other", Date: 1700001005}); err != nil { + t.Fatalf("other subscriber send: %v", err) + } + subHist, _ := channels.ListMonoforumHistory(ctx, domain.MonoforumHistoryFilter{MonoforumID: monoID, SavedPeer: subPeer, Limit: 10}) + if subHist.Count != 3 { + t.Fatalf("sub history after other subscriber = %d, want still 3 (no cross-talk)", subHist.Count) + } + + // 去重按订阅者子会话维度(迁移 0022 唯一索引含 saved_peer_id):管理员用相同 random_id 向两个不同 + // 订阅者发,不得互相去重(与 memory 行为一致)。 + a, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 9001, Message: "to sub", Date: 1700001010}) + if err != nil { + t.Fatalf("dedup send A: %v", err) + } + b, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: otherPeer, RandomID: 9001, Message: "to other", Date: 1700001011}) + if err != nil { + t.Fatalf("dedup send B (cross-sublist same random_id must not collide): %v", err) + } + if b.Duplicate || b.Message.ID == a.Message.ID { + t.Fatalf("cross-sublist same random_id wrongly deduped: a=%d b=%d dup=%v", a.Message.ID, b.Message.ID, b.Duplicate) + } + // 同一子会话真重发仍去重。 + again, err := channels.SendMonoforumMessage(ctx, domain.SendMonoforumMessageRequest{MonoforumID: monoID, SenderUserID: owner.ID, SavedPeer: subPeer, RandomID: 9001, Message: "to sub", Date: 1700001012}) + if err != nil { + t.Fatalf("dedup resend A: %v", err) + } + if !again.Duplicate || again.Message.ID != a.Message.ID { + t.Fatalf("same-sublist retry not deduped: again=%+v want dup of %d", again.Message, a.Message.ID) + } + + // 订阅者子会话列表:两个订阅者,按 top 消息 id 倒序(other 最后发,排首)。 + dialogs, err := channels.ListMonoforumDialogs(ctx, domain.MonoforumDialogsFilter{MonoforumID: monoID, Limit: 10}) + if err != nil { + t.Fatalf("list dialogs: %v", err) + } + if dialogs.Count != 2 || len(dialogs.Dialogs) != 2 { + t.Fatalf("dialogs count=%d len=%d, want 2 subscribers", dialogs.Count, len(dialogs.Dialogs)) + } + if dialogs.Dialogs[0].SavedPeer != otherPeer { + t.Fatalf("dialogs[0] saved_peer = %+v, want newest 'other'", dialogs.Dialogs[0].SavedPeer) + } + if dialogs.Dialogs[1].SavedPeer != subPeer || dialogs.Dialogs[1].TopMessageID == 0 { + t.Fatalf("dialogs[1] = %+v, want sub with top message", dialogs.Dialogs[1]) + } +} diff --git a/internal/store/postgres/channel_multi_pin_integration_test.go b/internal/store/postgres/channel_multi_pin_integration_test.go new file mode 100644 index 00000000..2edee1e3 --- /dev/null +++ b/internal/store/postgres/channel_multi_pin_integration_test.go @@ -0,0 +1,186 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +// TestChannelMultiPin 验证超级群多置顶模型:多条共存、filterPinned 搜索、 +// 上限、unpinAll 批量清除、删除被置顶消息后的缓存重算。 +func TestChannelMultiPin(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1676" + suffix + "01", FirstName: "MultiPinOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1676" + suffix + "02", FirstName: "MultiPinMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "MultiPin " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000920, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID := created.Channel.ID + + const pinCount = 11 + ids := make([]int, 0, pinCount) + for i := 0; i < pinCount; i++ { + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: int64(852000 + i), + Message: "pin-target", + Date: 1700000921 + i, + }) + if err != nil { + t.Fatalf("send %d: %v", i, err) + } + ids = append(ids, sent.Message.ID) + } + + pin := func(id int, pinned bool) (domain.UpdateChannelPinnedMessageResult, error) { + return channels.UpdatePinnedMessage(ctx, domain.UpdateChannelPinnedMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + MessageID: id, + Pinned: pinned, + Date: 1700000950, + }) + } + + // 多条共存:pin 前三条,互不替代。 + for _, id := range ids[:3] { + res, err := pin(id, true) + if err != nil { + t.Fatalf("pin %d: %v", id, err) + } + if !res.Event.Pinned || len(res.Event.MessageIDs) != 1 || res.Event.MessageIDs[0] != id { + t.Fatalf("pin event = %+v, want pinned [%d]", res.Event, id) + } + } + view, err := channels.GetChannel(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("get channel: %v", err) + } + if view.Channel.PinnedMessageID != ids[2] { + t.Fatalf("pinned_message_id = %d, want latest pinned %d", view.Channel.PinnedMessageID, ids[2]) + } + history, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ + ChannelID: channelID, PinnedOnly: true, Limit: 50, + }) + if err != nil { + t.Fatalf("filterPinned search: %v", err) + } + if len(history.Messages) != 3 { + t.Fatalf("filterPinned messages = %d, want 3 coexisting pins: %+v", len(history.Messages), history.Messages) + } + for _, msg := range history.Messages { + if !msg.Pinned { + t.Fatalf("filterPinned message %d lacks pinned flag", msg.ID) + } + } + // 普通历史页的消息行直接携带 pinned 标志(多置顶都标,不只最新)。 + page, err := channels.ListChannelHistory(ctx, member.ID, domain.ChannelHistoryFilter{ChannelID: channelID, Limit: 50}) + if err != nil { + t.Fatalf("list history: %v", err) + } + flagged := 0 + for _, msg := range page.Messages { + if msg.Pinned { + flagged++ + } + } + if flagged != 3 { + t.Fatalf("history pinned flags = %d, want 3", flagged) + } + + // unpin 最新一条:其它置顶保留,缓存回落到次新。 + if _, err := pin(ids[2], false); err != nil { + t.Fatalf("unpin latest: %v", err) + } + view, err = channels.GetChannel(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("get channel after unpin: %v", err) + } + if view.Channel.PinnedMessageID != ids[1] { + t.Fatalf("pinned_message_id after unpin = %d, want %d", view.Channel.PinnedMessageID, ids[1]) + } + + // 无数量上限(对齐官方):剩余全部 pin 上,共 pinCount 条同时置顶。 + for _, id := range ids[2:] { + if _, err := pin(id, true); err != nil { + t.Fatalf("pin %d: %v", id, err) + } + } + history, err = channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ + ChannelID: channelID, PinnedOnly: true, Limit: 50, + }) + if err != nil { + t.Fatalf("filterPinned all pinned: %v", err) + } + if len(history.Messages) != pinCount { + t.Fatalf("filterPinned messages = %d, want %d coexisting pins", len(history.Messages), pinCount) + } + + // 删除被置顶的最新消息:置顶集合自动排除,缓存重算。 + latest := ids[pinCount-1] + if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: owner.ID, ChannelID: channelID, IDs: []int{latest}, Date: 1700000961, + }); err != nil { + t.Fatalf("delete pinned message: %v", err) + } + view, err = channels.GetChannel(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("get channel after delete: %v", err) + } + if view.Channel.PinnedMessageID != ids[pinCount-2] { + t.Fatalf("pinned_message_id after delete = %d, want %d", view.Channel.PinnedMessageID, ids[pinCount-2]) + } + + // unpinAll:一条事件批量携带剩余全部置顶 id,缓存清零,再次调用 no-op。 + res, err := channels.UnpinAllChannelMessages(ctx, domain.UnpinAllChannelMessagesRequest{ + UserID: owner.ID, ChannelID: channelID, Date: 1700000962, + }) + if err != nil { + t.Fatalf("unpin all: %v", err) + } + if res.Event.Pinned || len(res.Event.MessageIDs) != pinCount-1 { + t.Fatalf("unpin all event = %+v, want pinned=false with %d ids", res.Event, pinCount-1) + } + if res.Channel.PinnedMessageID != 0 { + t.Fatalf("pinned_message_id after unpin all = %d, want 0", res.Channel.PinnedMessageID) + } + if _, err := channels.UnpinAllChannelMessages(ctx, domain.UnpinAllChannelMessagesRequest{ + UserID: owner.ID, ChannelID: channelID, Date: 1700000963, + }); !errors.Is(err, domain.ErrChannelNotModified) { + t.Fatalf("unpin all again err = %v, want ErrChannelNotModified", err) + } + history, err = channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ + ChannelID: channelID, PinnedOnly: true, Limit: 50, + }) + if err != nil { + t.Fatalf("filterPinned after unpin all: %v", err) + } + if len(history.Messages) != 0 { + t.Fatalf("filterPinned after unpin all = %+v, want empty", history.Messages) + } +} diff --git a/internal/store/postgres/channel_poll_fanout_integration_test.go b/internal/store/postgres/channel_poll_fanout_integration_test.go new file mode 100644 index 00000000..c594e901 --- /dev/null +++ b/internal/store/postgres/channel_poll_fanout_integration_test.go @@ -0,0 +1,140 @@ +package postgres + +import ( + "context" + "reflect" + "testing" + + "telesrv/internal/domain" +) + +// TestChannelPollFanoutViewsPostgres 验证 Phase 4 poll 模板化的 postgres 路径:新批量 SQL +// (batchPollViewerOptions / batchChannelMemberAvailableMinID + pollViewerAggregates(viewer=0)) +// 正确运行,且 ChannelPollFanoutViews 对每个 viewer 与逐 viewer GetChannelMessages(旧 fan-out 路径) +// 的 enrich poll 字节等价、可见性一致。门控于 TELESRV_TEST_POSTGRES_DSN。 +func TestChannelPollFanoutViewsPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + + owner, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1893" + suffix + "01", FirstName: "PollOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + voted, err := users.Create(ctx, domain.User{AccessHash: 72, Phone: "+1893" + suffix + "02", FirstName: "PollVoted"}) + if err != nil { + t.Fatalf("create voted: %v", err) + } + unvoted, err := users.Create(ctx, domain.User{AccessHash: 73, Phone: "+1893" + suffix + "03", FirstName: "PollUnvoted"}) + if err != nil { + t.Fatalf("create unvoted: %v", err) + } + stranger, err := users.Create(ctx, domain.User{AccessHash: 74, Phone: "+1893" + suffix + "04", FirstName: "PollStranger"}) + if err != nil { + t.Fatalf("create stranger: %v", err) + } + + channels := NewChannelStore(pool) + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, voted.ID, unvoted.ID, stranger.ID}) + }) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Poll Fanout " + suffix, + Megagroup: true, + MemberUserIDs: []int64{voted.ID, unvoted.ID}, + Date: 1700002000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + pollID := int64(9300000000) + (channelID % 1000) + opt1, opt2 := []byte{1}, []byte{2} + polls := NewPollStore(pool) + if err := polls.CreatePoll(ctx, domain.PollDefinition{ + ID: pollID, + CreatorUserID: owner.ID, + Options: [][]byte{opt1, opt2}, + PublicVoters: true, + }); err != nil { + t.Fatalf("create poll: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 93_001, + Date: 1700002001, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindPoll, + Poll: &domain.MessagePoll{ + ID: pollID, + Question: "Q?", + Answers: []domain.MessagePollAnswer{{Text: "A", Option: opt1}, {Text: "B", Option: opt2}}, + PublicVoters: true, + }, + }, + }) + if err != nil { + t.Fatalf("send poll message: %v", err) + } + msgID := sent.Message.ID + if _, err := channels.VoteChannelMessagePoll(ctx, domain.VoteChannelMessagePollRequest{ + UserID: voted.ID, ChannelID: channelID, MessageID: msgID, Options: [][]byte{opt1}, Date: 1700002002, + }); err != nil { + t.Fatalf("vote: %v", err) + } + + viewers := []int64{owner.ID, voted.ID, unvoted.ID, stranger.ID} + views, err := channels.ChannelPollFanoutViews(ctx, channelID, msgID, viewers, 1700002100) + if err != nil { + t.Fatalf("ChannelPollFanoutViews: %v", err) + } + if !views.Found { + t.Fatal("Found=false, want true") + } + for _, viewer := range viewers { + got, evaluated := views.Polls[viewer] + if !evaluated { + t.Fatalf("viewer %d not evaluated", viewer) + } + ref, err := channels.GetChannelMessages(ctx, viewer, channelID, []int{msgID}) + var refPoll *domain.MessagePoll + if err == nil { + for _, m := range ref.Messages { + if m.ID == msgID && m.Media != nil { + refPoll = m.Media.Poll + } + } + } + if (got != nil) != (refPoll != nil) { + t.Fatalf("viewer %d visibility mismatch: batch nil=%v old nil=%v (err=%v)", viewer, got == nil, refPoll == nil, err) + } + if got != nil && !reflect.DeepEqual(got.Results, refPoll.Results) { + t.Fatalf("viewer %d Results mismatch:\n batch=%+v\n old=%+v", viewer, got.Results, refPoll.Results) + } + } + // members 可见、stranger(非成员)不可见。 + if views.Polls[owner.ID] == nil || views.Polls[voted.ID] == nil || views.Polls[unvoted.ID] == nil { + t.Fatal("members should be visible") + } + if views.Polls[stranger.ID] != nil { + t.Fatal("non-member stranger must be invisible (nil)") + } + // voted 视角应见自己 chosen。 + sawChosen := false + for _, v := range views.Polls[voted.ID].Results.Voters { + if v.Chosen { + sawChosen = true + } + } + if !sawChosen { + t.Fatal("voted viewer should see own chosen option") + } +} diff --git a/internal/store/postgres/channel_polls.go b/internal/store/postgres/channel_polls.go new file mode 100644 index 00000000..b37eb4e1 --- /dev/null +++ b/internal/store/postgres/channel_polls.go @@ -0,0 +1,238 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// 频道/超级群消息 poll 投票/关闭:成员资格与消息可见性沿用 reaction 同款校验, +// poll 级语义委托 poll.go 的共享 SQL。 + +func (s *ChannelStore) VoteChannelMessagePoll(ctx context.Context, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) { + return s.mutateChannelMessagePoll(ctx, req.UserID, req.ChannelID, req.MessageID, req.Date, func(ctx context.Context, tx pgx.Tx, def domain.PollDefinition, date int) error { + return applyPollVote(ctx, tx, def, req.UserID, req.Options, date) + }) +} + +func (s *ChannelStore) CloseChannelMessagePoll(ctx context.Context, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error) { + return s.mutateChannelMessagePoll(ctx, req.UserID, req.ChannelID, req.MessageID, req.Date, func(ctx context.Context, tx pgx.Tx, def domain.PollDefinition, _ int) error { + return closePollAsCreator(ctx, tx, def, req.UserID) + }) +} + +func (s *ChannelStore) mutateChannelMessagePoll( + ctx context.Context, + userID, channelID int64, + messageID int, + date int, + mutate func(ctx context.Context, tx pgx.Tx, def domain.PollDefinition, date int) error, +) (domain.ChannelMessagePollResult, error) { + if userID == 0 || channelID == 0 || messageID <= 0 || messageID > domain.MaxMessageBoxID { + return domain.ChannelMessagePollResult{}, domain.ErrChannelInvalid + } + if date == 0 { + date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ChannelMessagePollResult{}, fmt.Errorf("mutate channel message poll: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ChannelMessagePollResult{}, fmt.Errorf("begin channel poll tx: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.ChannelMessagePollResult{}, err + } + msg, err := s.getChannelMessage(ctx, tx, channelID, messageID) + if err != nil { + return domain.ChannelMessagePollResult{}, err + } + if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { + return domain.ChannelMessagePollResult{}, domain.ErrMessageIDInvalid + } + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPoll || msg.Media.Poll == nil || msg.Media.Poll.ID == 0 { + return domain.ChannelMessagePollResult{}, domain.ErrMessageIDInvalid + } + pollID := msg.Media.Poll.ID + defs, err := loadPollDefinitions(ctx, tx, []int64{pollID}, true) + if err != nil { + return domain.ChannelMessagePollResult{}, err + } + def, ok := defs[pollID] + if !ok { + return domain.ChannelMessagePollResult{}, domain.ErrPollNotFound + } + if err := mutate(ctx, tx, def, date); err != nil { + return domain.ChannelMessagePollResult{}, err + } + if err := enrichPollMediaRefs(ctx, tx, []pollMediaRef{{media: msg.Media, viewer: userID}}); err != nil { + return domain.ChannelMessagePollResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ChannelMessagePollResult{}, fmt.Errorf("commit channel poll tx: %w", err) + } + committed = true + return domain.ChannelMessagePollResult{ + PollID: pollID, + Channel: channel, + Message: msg, + Recipients: []int64{userID, msg.SenderUserID}, + }, nil +} + +// ChannelPollFanoutViews 批量加载一条 poll 消息对一组 viewer 的 per-viewer enrich(消除 fan-out +// 逐 viewer GetChannelMessages 的 N+1):viewer-invariant 聚合(counts/total/recent)经 +// pollViewerAggregates(viewer=0) 只算一次 + 批量 viewerOptions + 批量成员可见性,每 viewer 用与逐 +// viewer 路径完全相同的 domain.ResolvePollResults 合成,故字节同源。成员/AvailableMinID 可见性在此 +// 复刻 GetChannelMessages(active member && msgID>available_min_id);bot 历史过滤由 app 层叠加。 +func (s *ChannelStore) ChannelPollFanoutViews(ctx context.Context, channelID int64, msgID int, viewers []int64, now int) (domain.ChannelPollFanoutViews, error) { + out := domain.ChannelPollFanoutViews{Polls: map[int64]*domain.MessagePoll{}} + if channelID == 0 || msgID <= 0 || len(viewers) == 0 { + return out, nil + } + msg, err := s.getChannelMessage(ctx, s.db, channelID, msgID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return out, nil + } + return domain.ChannelPollFanoutViews{}, err + } + if msg.Deleted || msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindPoll || msg.Media.Poll == nil || msg.Media.Poll.ID == 0 { + return out, nil + } + pollID := msg.Media.Poll.ID + defs, err := loadPollDefinitions(ctx, s.db, []int64{pollID}, false) + if err != nil { + return domain.ChannelPollFanoutViews{}, err + } + def, ok := defs[pollID] + if !ok { + return out, nil + } + out.Found = true + out.Message = msg + // viewer-invariant 模板:viewerUserID=0 → pollViewerAggregates 跳过 ViewerOptions 块,只出 counts/total/recent。 + tmplAggs, err := pollViewerAggregates(ctx, s.db, 0, []int64{pollID}) + if err != nil { + return domain.ChannelPollFanoutViews{}, err + } + tmpl := tmplAggs[pollID] + viewerOpts, err := s.batchPollViewerOptions(ctx, pollID, viewers) + if err != nil { + return domain.ChannelPollFanoutViews{}, err + } + members, err := s.batchChannelMemberAvailableMinID(ctx, channelID, viewers) + if err != nil { + return domain.ChannelPollFanoutViews{}, err + } + for _, viewer := range viewers { + if viewer == 0 { + continue + } + availMinID, isMember := members[viewer] + if !isMember || (availMinID > 0 && msgID <= availMinID) { + out.Polls[viewer] = nil // 已评估但不可见(非活跃成员 / pre-history 隐藏) + continue + } + // agg.Counts/RecentVoters 与 tmpl 共享(ResolvePollResults 只读、RecentVoters 内部再 copy),安全。 + agg := domain.PollAggregates{ + Counts: tmpl.Counts, + TotalVoters: tmpl.TotalVoters, + RecentVoters: tmpl.RecentVoters, + ViewerOptions: viewerOpts[viewer], + } + results := domain.ResolvePollResults(def, agg, viewer, now) + pollCopy := *msg.Media.Poll // 浅拷:ApplyPollState 仅写 Closed + 新 Results 指针,不动共享 def 切片 + domain.ApplyPollState(&pollCopy, def, results, now) + out.Polls[viewer] = &pollCopy + } + return out, nil +} + +// batchPollViewerOptions 一次取一组 viewer 在某 poll 的投票选项(替代逐 viewer 单查)。 +func (s *ChannelStore) batchPollViewerOptions(ctx context.Context, pollID int64, viewers []int64) (map[int64][][]byte, error) { + out := make(map[int64][][]byte, len(viewers)) + if pollID == 0 || len(viewers) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, `SELECT user_id, options::text FROM poll_votes WHERE poll_id = $1 AND user_id = ANY($2)`, pollID, viewers) + if err != nil { + return nil, fmt.Errorf("batch poll viewer options: %w", err) + } + defer rows.Close() + for rows.Next() { + var userID int64 + var optionsJSON string + if err := rows.Scan(&userID, &optionsJSON); err != nil { + return nil, fmt.Errorf("scan poll viewer option: %w", err) + } + options, err := decodePollOptions(optionsJSON) + if err != nil { + return nil, err + } + out[userID] = options + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate poll viewer options: %w", err) + } + return out, nil +} + +// batchChannelMemberAvailableMinID 一次取一组 viewer 的可见成员资格 + available_min_id;map 中存在 +// = 对该频道消息可见(active 成员 && 未被 ViewMessages 限制,复刻 validateChannelMemberVisible), +// 值为其 available_min_id;缺失 = 不可见(非 active / 离开 / 被封 / ViewMessages 受限)。 +func (s *ChannelStore) batchChannelMemberAvailableMinID(ctx context.Context, channelID int64, viewers []int64) (map[int64]int, error) { + out := make(map[int64]int, len(viewers)) + if channelID == 0 || len(viewers) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, `SELECT user_id, available_min_id, banned_rights::text FROM channel_members WHERE channel_id = $1 AND user_id = ANY($2) AND status = 'active'`, channelID, viewers) + if err != nil { + return nil, fmt.Errorf("batch channel member available_min_id: %w", err) + } + defer rows.Close() + for rows.Next() { + var userID int64 + var availableMinID int + var bannedRights string + if err := rows.Scan(&userID, &availableMinID, &bannedRights); err != nil { + return nil, fmt.Errorf("scan channel member available_min_id: %w", err) + } + var rights domain.ChannelBannedRights + _ = json.Unmarshal([]byte(bannedRights), &rights) + if rights.ViewMessages { + continue // active 但被禁止查看消息 → 不可见(与 validateChannelMemberVisible 一致) + } + out[userID] = availableMinID + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate channel member available_min_id: %w", err) + } + return out, nil +} + +// populateChannelMessagesPolls 把页内全部 poll media 按请求 viewer 视角 enrich; +// 由 populateChannelMessagesReactions 统一挂载(所有频道消息读路径共用一个 choke point)。 +func (s *ChannelStore) populateChannelMessagesPolls(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, messages []domain.ChannelMessage) error { + refs := make([]pollMediaRef, 0, 2) + for i := range messages { + refs = append(refs, pollMediaRef{media: messages[i].Media, viewer: viewerUserID}) + } + return enrichPollMediaRefs(ctx, db, refs) +} diff --git a/internal/store/postgres/channel_reaction_catalog.go b/internal/store/postgres/channel_reaction_catalog.go new file mode 100644 index 00000000..92f33d04 --- /dev/null +++ b/internal/store/postgres/channel_reaction_catalog.go @@ -0,0 +1,190 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) ListTopMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxTopMessageReactions { + limit = domain.MaxTopMessageReactions + } + rows, err := s.db.Query(ctx, ` +SELECT reaction_type, reaction_value +FROM user_top_reactions +WHERE user_id = $1 +ORDER BY reaction_count DESC, reaction_date DESC, updated_at DESC, reaction_type ASC, reaction_value ASC +LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list top message reactions: %w", err) + } + defer rows.Close() + out := make([]domain.MessageReaction, 0, limit) + for rows.Next() { + var reactionType, reactionValue string + if err := rows.Scan(&reactionType, &reactionValue); err != nil { + return nil, err + } + if reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue); ok { + out = append(out, reaction) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) ListRecentMessageReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.MessageReaction{}, nil + } + if limit > domain.MaxRecentMessageReactions { + limit = domain.MaxRecentMessageReactions + } + rows, err := s.db.Query(ctx, ` +SELECT reaction_type, reaction_value +FROM user_recent_reactions +WHERE user_id = $1 +ORDER BY reaction_date DESC, updated_at DESC, reaction_type ASC, reaction_value ASC +LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list recent message reactions: %w", err) + } + defer rows.Close() + out := make([]domain.MessageReaction, 0, limit) + for rows.Next() { + var reactionType, reactionValue string + if err := rows.Scan(&reactionType, &reactionValue); err != nil { + return nil, err + } + if reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue); ok { + out = append(out, reaction) + } + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) RecordMessageReactionUse(ctx context.Context, userID int64, reactions []domain.MessageReaction, addToRecent bool, date int) error { + if userID == 0 || len(reactions) == 0 { + return nil + } + if date <= 0 { + date = nowUnix() + } + for _, reaction := range reactions { + if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" || len(reaction.Emoticon) > domain.MaxChannelReactionEmoticonLength { + continue + } + if addToRecent { + if _, err := s.db.Exec(ctx, ` +INSERT INTO user_recent_reactions (user_id, reaction_type, reaction_value, reaction_date) +VALUES ($1,$2,$3,$4) +ON CONFLICT (user_id, reaction_type, reaction_value) +DO UPDATE SET reaction_date = EXCLUDED.reaction_date, updated_at = now()`, + userID, string(reaction.Type), reaction.Value(), date); err != nil { + return fmt.Errorf("record recent message reaction: %w", err) + } + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO user_top_reactions (user_id, reaction_type, reaction_value, reaction_count, reaction_date) +VALUES ($1,$2,$3,1,$4) +ON CONFLICT (user_id, reaction_type, reaction_value) +DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_date = EXCLUDED.reaction_date, updated_at = now()`, + userID, string(reaction.Type), reaction.Value(), date); err != nil { + return fmt.Errorf("record top message reaction: %w", err) + } + } + return nil +} + +func (s *ChannelStore) ClearRecentMessageReactions(ctx context.Context, userID int64) error { + if userID == 0 { + return domain.ErrChannelInvalid + } + if _, err := s.db.Exec(ctx, `DELETE FROM user_recent_reactions WHERE user_id = $1`, userID); err != nil { + return fmt.Errorf("clear recent message reactions: %w", err) + } + return nil +} + +func (s *ChannelStore) ListSavedReactionTags(ctx context.Context, userID int64, limit int) ([]domain.SavedReactionTag, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 { + return []domain.SavedReactionTag{}, nil + } + if limit > domain.MaxSavedReactionTags { + limit = domain.MaxSavedReactionTags + } + rows, err := s.db.Query(ctx, ` +SELECT reaction_type, reaction_value, title, reaction_count +FROM user_saved_reaction_tags +WHERE user_id = $1 +ORDER BY reaction_count DESC, updated_at DESC, reaction_type ASC, reaction_value ASC +LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list saved reaction tags: %w", err) + } + defer rows.Close() + out := make([]domain.SavedReactionTag, 0, limit) + for rows.Next() { + var reactionType, reactionValue, title string + var count int + if err := rows.Scan(&reactionType, &reactionValue, &title, &count); err != nil { + return nil, err + } + reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue) + if !ok { + continue + } + out = append(out, domain.SavedReactionTag{ + UserID: userID, + Reaction: reaction, + Title: title, + Count: count, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) UpsertSavedReactionTag(ctx context.Context, tag domain.SavedReactionTag) error { + if tag.UserID == 0 || tag.Reaction.Type != domain.MessageReactionEmoji { + return domain.ErrChannelInvalid + } + reactionValue := strings.TrimSpace(tag.Reaction.Emoticon) + if reactionValue == "" { + return domain.ErrChannelInvalid + } + if tag.Count < 0 { + tag.Count = 0 + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO user_saved_reaction_tags (user_id, reaction_type, reaction_value, title, reaction_count) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (user_id, reaction_type, reaction_value) DO UPDATE SET + title = EXCLUDED.title, + reaction_count = GREATEST(user_saved_reaction_tags.reaction_count, EXCLUDED.reaction_count), + updated_at = now()`, tag.UserID, string(tag.Reaction.Type), reactionValue, tag.Title, tag.Count); err != nil { + return fmt.Errorf("upsert saved reaction tag: %w", err) + } + return nil +} diff --git a/internal/store/postgres/channel_reaction_helpers.go b/internal/store/postgres/channel_reaction_helpers.go new file mode 100644 index 00000000..29f7f048 --- /dev/null +++ b/internal/store/postgres/channel_reaction_helpers.go @@ -0,0 +1,316 @@ +package postgres + +import ( + "context" + "fmt" + "strconv" + "strings" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +type channelReactionMessageKey struct { + channelID int64 + messageID int +} + +// channelMessagePairs 把 {channelID: [messageID...]} 摊平成两条等长并行数组,供 +// `WHERE (channel_id, message_id) IN (SELECT * FROM unnest($a::bigint[], $b::int[]))` +// 跨频道一次批量取,消除「每频道一条 SQL」的 N+1。 +func channelMessagePairs(idsByChannel map[int64][]int32) ([]int64, []int32) { + var channels []int64 + var messages []int32 + for channelID, ids := range idsByChannel { + for _, id := range ids { + channels = append(channels, channelID) + messages = append(messages, id) + } + } + return channels, messages +} + +type channelReactionCursor struct { + date int + userID int64 + reactionType domain.MessageReactionType + value string + legacyValue bool +} + +func emptyChannelMessageReactions(channel domain.Channel) domain.ChannelMessageReactions { + return domain.ChannelMessageReactions{ + CanSeeList: !channel.Broadcast || channel.Megagroup, + Results: []domain.ChannelMessageReactionCount{}, + Recent: []domain.ChannelMessagePeerReaction{}, + } +} + +func (s *ChannelStore) populateChannelMessagesReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channels []domain.Channel, messages []domain.ChannelMessage) error { + if len(messages) == 0 { + return nil + } + // poll enrichment 与 reactions 同点位挂载:所有频道消息读路径都经过本函数(见 channel_polls.go)。 + if err := s.populateChannelMessagesPolls(ctx, db, viewerUserID, messages); err != nil { + return err + } + if err := populateChannelMessageUnreadFlags(ctx, db, viewerUserID, messages); err != nil { + return err + } + channelsByID := make(map[int64]domain.Channel, len(channels)) + for _, ch := range channels { + if ch.ID != 0 { + channelsByID[ch.ID] = ch + } + } + indexes := make(map[channelReactionMessageKey][]int) + idsByChannel := make(map[int64][]int32) + for i := range messages { + if messages[i].ChannelID == 0 || messages[i].ID <= 0 { + continue + } + key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID} + if _, ok := indexes[key]; !ok { + idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID)) + } + indexes[key] = append(indexes[key], i) + } + if len(idsByChannel) == 0 { + return nil + } + // 缺失的频道元数据一次批量补齐(emptyChannelMessageReactions 需要 Broadcast/Megagroup), + // 替代原来「每个未知频道一条 getChannelByID」的 N+1。 + var missing []int64 + for channelID := range idsByChannel { + if _, ok := channelsByID[channelID]; !ok { + missing = append(missing, channelID) + } + } + if len(missing) > 0 { + fetched, err := listChannelsByIDs(ctx, db, missing) + if err != nil { + return err + } + for _, ch := range fetched { + channelsByID[ch.ID] = ch + } + } + // 跨频道 (channel, message) 对;recent 反应仅对「非广播或超级群」频道下发(广播 reaction + // 匿名,与官方一致只给计数,不暴露反应者身份)。 + pairChannels, pairMessages := channelMessagePairs(idsByChannel) + var recentChannels []int64 + var recentMessages []int32 + for channelID, ids := range idsByChannel { + ch := channelsByID[channelID] + if ch.Broadcast && !ch.Megagroup { + continue + } + for _, id := range ids { + recentChannels = append(recentChannels, channelID) + recentMessages = append(recentMessages, id) + } + } + + // 1) 反应计数:跨频道一次 GROUP BY。 + countRows, err := db.Query(ctx, ` +SELECT channel_id, message_id, reaction_type, reaction_value, COUNT(*)::int, + COALESCE(MAX(CASE WHEN reacted_user_id = $1 THEN chosen_order ELSE 0 END), 0)::int, + COALESCE(MAX(reaction_date), 0)::int +FROM channel_message_reactions +WHERE (channel_id, message_id) IN (SELECT * FROM unnest($2::bigint[], $3::int[])) +GROUP BY channel_id, message_id, reaction_type, reaction_value +ORDER BY channel_id ASC, message_id ASC, COUNT(*) DESC, COALESCE(MAX(reaction_date), 0) DESC, reaction_type ASC, reaction_value ASC`, viewerUserID, pairChannels, pairMessages) + if err != nil { + return fmt.Errorf("load channel message reaction counts: %w", err) + } + for countRows.Next() { + var channelID int64 + var msgID int + var reactionType, reactionValue string + var count, chosenOrder, latestDate int + if err := countRows.Scan(&channelID, &msgID, &reactionType, &reactionValue, &count, &chosenOrder, &latestDate); err != nil { + countRows.Close() + return err + } + _ = latestDate + ch := channelsByID[channelID] + key := channelReactionMessageKey{channelID: channelID, messageID: msgID} + for _, idx := range indexes[key] { + if messages[idx].Reactions == nil { + reactions := emptyChannelMessageReactions(ch) + messages[idx].Reactions = &reactions + } + reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue) + if !ok { + continue + } + messages[idx].Reactions.Results = append(messages[idx].Reactions.Results, domain.ChannelMessageReactionCount{ + Reaction: reaction, + Count: count, + ChosenOrder: chosenOrder, + }) + } + } + if err := countRows.Err(); err != nil { + countRows.Close() + return err + } + countRows.Close() + + // 2) recent 反应(仅超级群/非广播):跨频道一次 window 查询,PARTITION BY (channel,message)。 + if len(recentChannels) > 0 { + recentRows, err := db.Query(ctx, ` +SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, + big, unread, chosen_order, reaction_date +FROM ( + SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, + big, unread, chosen_order, reaction_date, + row_number() OVER ( + PARTITION BY channel_id, message_id + ORDER BY reaction_date DESC, reacted_user_id DESC, reaction_type ASC, reaction_value ASC + ) AS rn + FROM channel_message_reactions + WHERE (channel_id, message_id) IN (SELECT * FROM unnest($1::bigint[], $2::int[])) +) ranked +WHERE rn <= $3 +ORDER BY channel_id ASC, message_id ASC, reaction_date DESC, reacted_user_id DESC, reaction_type ASC, reaction_value ASC`, recentChannels, recentMessages, domain.MaxChannelMessageReactionRecent) + if err != nil { + return fmt.Errorf("load channel message recent reactions: %w", err) + } + for recentRows.Next() { + row, err := scanChannelMessagePeerReaction(recentRows, viewerUserID) + if err != nil { + recentRows.Close() + return err + } + ch := channelsByID[row.ChannelID] + key := channelReactionMessageKey{channelID: row.ChannelID, messageID: row.MessageID} + for _, idx := range indexes[key] { + if messages[idx].Reactions == nil { + reactions := emptyChannelMessageReactions(ch) + messages[idx].Reactions = &reactions + } + messages[idx].Reactions.Recent = append(messages[idx].Reactions.Recent, row) + } + } + if err := recentRows.Err(); err != nil { + recentRows.Close() + return err + } + recentRows.Close() + } + + // 3) 付费 reaction(Stars):跨频道一次取所有 reactor 行,Go 内按 (channel,message) 聚合 + // 总星数 + viewer 自身 + top reactors,挂到 message.Reactions.Paid(tg 转换注入 ReactionPaid)。 + // 绝大多数消息无付费 reaction(索引扫描即返回空);总星数须含全部 reactor 故取全行。 + if err := populateChannelMessagesPaidReactions(ctx, db, viewerUserID, channelsByID, indexes, messages, pairChannels, pairMessages); err != nil { + return err + } + return nil +} + +func populateChannelMessagesPaidReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channelsByID map[int64]domain.Channel, indexes map[channelReactionMessageKey][]int, messages []domain.ChannelMessage, pairChannels []int64, pairMessages []int32) error { + rows, err := db.Query(ctx, ` +SELECT channel_id, message_id, reactor_user_id, stars, anonymous +FROM channel_message_paid_reactions +WHERE (channel_id, message_id) IN (SELECT * FROM unnest($1::bigint[], $2::int[])) +ORDER BY channel_id ASC, message_id ASC, stars DESC, reactor_user_id ASC`, pairChannels, pairMessages) + if err != nil { + return fmt.Errorf("load channel message paid reactions: %w", err) + } + defer rows.Close() + aggByKey := make(map[channelReactionMessageKey]*domain.ChannelMessagePaidReactions) + for rows.Next() { + var channelID int64 + var msgID int + var r domain.PaidReactor + if err := rows.Scan(&channelID, &msgID, &r.UserID, &r.Stars, &r.Anonymous); err != nil { + return err + } + key := channelReactionMessageKey{channelID: channelID, messageID: msgID} + agg := aggByKey[key] + if agg == nil { + agg = &domain.ChannelMessagePaidReactions{} + aggByKey[key] = agg + } + agg.TotalStars += r.Stars + r.My = r.UserID == viewerUserID + if r.My { + agg.MyStars = r.Stars + agg.MyAnonymous = r.Anonymous + } + // top reactors 取前 N(已按 stars DESC);viewer 自身若不在前 N 也补一条(始终在列)。 + if len(agg.TopReactors) < domain.MaxPaidReactionTopReactors { + agg.TopReactors = append(agg.TopReactors, r) + } else if r.My { + agg.TopReactors = append(agg.TopReactors, r) + } + } + if err := rows.Err(); err != nil { + return err + } + for key, agg := range aggByKey { + if agg.TotalStars <= 0 { + continue + } + ch := channelsByID[key.channelID] + for _, idx := range indexes[key] { + if messages[idx].Reactions == nil { + reactions := emptyChannelMessageReactions(ch) + messages[idx].Reactions = &reactions + } + paidCopy := *agg + paidCopy.TopReactors = append([]domain.PaidReactor(nil), agg.TopReactors...) + messages[idx].Reactions.Paid = &paidCopy + } + } + return nil +} + +func channelReactionOffset(row domain.ChannelMessagePeerReaction) string { + return strconv.Itoa(row.Date) + ":" + strconv.FormatInt(row.UserID, 10) + ":" + string(row.Reaction.Type) + ":" + row.Reaction.Value() +} + +func parseChannelReactionOffset(offset string) (channelReactionCursor, bool) { + parts := strings.SplitN(offset, ":", 4) + if len(parts) != 3 && len(parts) != 4 { + return channelReactionCursor{}, false + } + date, err := strconv.Atoi(parts[0]) + if err != nil || date < 0 { + return channelReactionCursor{}, false + } + userID, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || userID < 0 { + return channelReactionCursor{}, false + } + if len(parts) == 3 { + return channelReactionCursor{date: date, userID: userID, value: parts[2], legacyValue: true}, true + } + return channelReactionCursor{date: date, userID: userID, reactionType: domain.MessageReactionType(parts[2]), value: parts[3]}, true +} + +func scanChannelMessagePeerReaction(row rowScanner, viewerUserID int64) (domain.ChannelMessagePeerReaction, error) { + var out domain.ChannelMessagePeerReaction + var reactionType, reactionValue string + if err := row.Scan( + &out.ChannelID, + &out.MessageID, + &out.UserID, + &out.SenderUserID, + &reactionType, + &reactionValue, + &out.Big, + &out.Unread, + &out.ChosenOrder, + &out.Date, + ); err != nil { + return domain.ChannelMessagePeerReaction{}, err + } + out.My = out.UserID == viewerUserID + reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), reactionValue) + if !ok { + return domain.ChannelMessagePeerReaction{}, fmt.Errorf("invalid channel message reaction value") + } + out.Reaction = reaction + return out, nil +} diff --git a/internal/store/postgres/channel_reaction_messages.go b/internal/store/postgres/channel_reaction_messages.go new file mode 100644 index 00000000..69d14894 --- /dev/null +++ b/internal/store/postgres/channel_reaction_messages.go @@ -0,0 +1,602 @@ +package postgres + +import ( + "context" + "fmt" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) SetChannelMessageReactions(ctx context.Context, req domain.SetChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + for _, reaction := range req.Reactions { + if !reaction.Valid() { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + } + req.Reactions = domain.TrimMessageReactionsToUserMax(req.Reactions, req.ReactionsPerUserMax) + if req.Date <= 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("set channel message reactions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("begin set channel message reactions: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + // 仅新增/替换受策略约束;空向量是撤销,策略收紧后也必须允许撤销存量 reaction。 + for _, reaction := range req.Reactions { + if !channel.ReactionPolicy.AllowsReaction(reaction) { + return domain.ChannelMessageReactionsResult{}, domain.ErrReactionInvalid + } + } + if len(req.Reactions) > 0 { + // READ COMMITTED 下并发新增不同新种类互不可见、会同时通过去重闸门, + // 事务级 advisory lock 按 (channel, message) 串行化带闸门的写入(独立于 + // lockUsersForUpdate 的单参数键空间);撤销不经闸门,无需上锁。 + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashint8($1::bigint), $2::int)`, req.ChannelID, req.MessageID); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("advisory lock channel message reactions: %w", err) + } + // 官方 REACTIONS_TOO_MANY 只挡「引入消息上尚不存在的新种类」:存量已超限 + //(管理员调低 reactions_limit / 部署前数据)时,重发自己的 reaction 或给 + // 已有种类投票必须放行,否则客户端点击合法 chip 也会收到 400。 + existing := make(map[string]struct{}) + final := make(map[string]struct{}) + rows, err := tx.Query(ctx, ` +SELECT reaction_type, reaction_value, BOOL_OR(reacted_user_id <> $3) +FROM channel_message_reactions +WHERE channel_id = $1 AND message_id = $2 +GROUP BY reaction_type, reaction_value`, req.ChannelID, req.MessageID, req.UserID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("list channel message reaction values: %w", err) + } + for rows.Next() { + var reactionType, value string + var byOthers bool + if err := rows.Scan(&reactionType, &value, &byOthers); err != nil { + rows.Close() + return domain.ChannelMessageReactionsResult{}, err + } + key := string(reactionType) + "\x00" + value + existing[key] = struct{}{} + if byOthers { + final[key] = struct{}{} + } + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.ChannelMessageReactionsResult{}, err + } + rows.Close() + newKind := false + for _, reaction := range req.Reactions { + key := reaction.Key() + if _, ok := existing[key]; !ok { + newKind = true + } + final[key] = struct{}{} + } + if newKind && len(final) > channel.ReactionPolicy.UniqueReactionsLimit() { + return domain.ChannelMessageReactionsResult{}, domain.ErrReactionsTooMany + } + } + if _, err := tx.Exec(ctx, ` +DELETE FROM channel_message_reactions +WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, req.ChannelID, req.MessageID, req.UserID); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete channel message reactions: %w", err) + } + // 广播频道 reaction 匿名(官方语义),作者不收 unread 角标,不写 unread 簿记。 + unreadEligible := !channel.Broadcast || channel.Megagroup + for i, reaction := range req.Reactions { + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_message_reactions ( + channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, + big, unread, chosen_order, reaction_date +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + req.ChannelID, req.MessageID, req.UserID, msg.SenderUserID, string(reaction.Type), reaction.Value(), req.Big, unreadEligible && msg.SenderUserID != 0 && msg.SenderUserID != req.UserID, i+1, req.Date); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("insert channel message reaction: %w", err) + } + if req.AddToRecent { + if _, err := tx.Exec(ctx, ` +INSERT INTO user_recent_reactions (user_id, reaction_type, reaction_value, reaction_date) +VALUES ($1,$2,$3,$4) +ON CONFLICT (user_id, reaction_type, reaction_value) +DO UPDATE SET reaction_date = EXCLUDED.reaction_date, updated_at = now()`, + req.UserID, string(reaction.Type), reaction.Value(), req.Date); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("upsert recent message reaction: %w", err) + } + } + if _, err := tx.Exec(ctx, ` +INSERT INTO user_top_reactions (user_id, reaction_type, reaction_value, reaction_count, reaction_date) +VALUES ($1,$2,$3,1,$4) +ON CONFLICT (user_id, reaction_type, reaction_value) +DO UPDATE SET reaction_count = user_top_reactions.reaction_count + 1, reaction_date = EXCLUDED.reaction_date, updated_at = now()`, + req.UserID, string(reaction.Type), reaction.Value(), req.Date); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("upsert top message reaction: %w", err) + } + } + if unreadEligible { + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, msg.SenderUserID, req.ChannelID); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("commit set channel message reactions: %w", err) + } + committed = true + messages := []domain.ChannelMessage{msg} + if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + msg = messages[0] + reactions := emptyChannelMessageReactions(channel) + if msg.Reactions != nil { + reactions = *msg.Reactions + } else { + msg.Reactions = &reactions + } + // sendReaction 实时推送走在线 viewer scope(rpc 层封顶),不预热全量成员列表。 + return domain.ChannelMessageReactionsResult{ + Channel: channel, + Message: msg, + Messages: []domain.ChannelMessage{msg}, + Reactions: reactions, + Recipients: []int64{req.UserID}, + }, nil +} + +// AddChannelMessagePaidReaction 为一条广播频道消息增投付费 reaction 星数(累计),返回聚合 +// 状态供 rpc 投影与扇出。扣费在 rpc 层经 Stars 账本 Debit 完成,本方法只负责累计与聚合。 +func (s *ChannelStore) AddChannelMessagePaidReaction(ctx context.Context, req domain.SendChannelPaidReactionRequest) (domain.ChannelMessagePaidReactionResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid + } + if req.Stars <= 0 || req.Stars > domain.MaxPaidReactionStarsPerRequest { + return domain.ChannelMessagePaidReactionResult{}, domain.ErrChannelInvalid + } + if req.Date <= 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("add channel paid reaction: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("begin add channel paid reaction: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessagePaidReactionResult{}, err + } + // 付费 reaction 仅用于广播频道帖子(官方语义)。 + if !channel.Broadcast || channel.Megagroup { + return domain.ChannelMessagePaidReactionResult{}, domain.ErrReactionInvalid + } + msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelMessagePaidReactionResult{}, err + } + if msg.Deleted || msg.Action != nil || msg.ID <= member.AvailableMinID { + return domain.ChannelMessagePaidReactionResult{}, domain.ErrMessageIDInvalid + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_message_paid_reactions (channel_id, message_id, reactor_user_id, stars, anonymous, reaction_date) +VALUES ($1,$2,$3,$4,$5,$6) +ON CONFLICT (channel_id, message_id, reactor_user_id) +DO UPDATE SET stars = channel_message_paid_reactions.stars + EXCLUDED.stars, + anonymous = EXCLUDED.anonymous, + reaction_date = EXCLUDED.reaction_date`, + req.ChannelID, req.MessageID, req.UserID, req.Stars, req.Anonymous, req.Date); err != nil { + return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("upsert channel paid reaction: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.ChannelMessagePaidReactionResult{}, fmt.Errorf("commit add channel paid reaction: %w", err) + } + committed = true + messages := []domain.ChannelMessage{msg} + if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelMessagePaidReactionResult{}, err + } + msg = messages[0] + paid, err := s.aggregateChannelPaidReactions(ctx, req.ChannelID, req.MessageID, req.UserID) + if err != nil { + return domain.ChannelMessagePaidReactionResult{}, err + } + recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) + if err != nil || len(recipients) == 0 { + recipients = []int64{req.UserID} + } + return domain.ChannelMessagePaidReactionResult{ + Channel: channel, + Message: msg, + Paid: paid, + Recipients: recipients, + }, nil +} + +// aggregateChannelPaidReactions 汇总一条消息的付费 reaction:总星数 + viewer 自身 + top reactors。 +func (s *ChannelStore) aggregateChannelPaidReactions(ctx context.Context, channelID int64, messageID int, viewerUserID int64) (domain.ChannelMessagePaidReactions, error) { + rows, err := s.db.Query(ctx, ` +SELECT reactor_user_id, stars, anonymous +FROM channel_message_paid_reactions +WHERE channel_id = $1 AND message_id = $2 +ORDER BY stars DESC, reactor_user_id ASC`, channelID, messageID) + if err != nil { + return domain.ChannelMessagePaidReactions{}, fmt.Errorf("aggregate channel paid reactions: %w", err) + } + defer rows.Close() + var out domain.ChannelMessagePaidReactions + var myReactor domain.PaidReactor + myInTop := false + for rows.Next() { + var r domain.PaidReactor + if err := rows.Scan(&r.UserID, &r.Stars, &r.Anonymous); err != nil { + return domain.ChannelMessagePaidReactions{}, err + } + out.TotalStars += r.Stars + r.My = r.UserID == viewerUserID + if r.My { + out.MyStars = r.Stars + out.MyAnonymous = r.Anonymous + myReactor = r + } + if len(out.TopReactors) < domain.MaxPaidReactionTopReactors { + out.TopReactors = append(out.TopReactors, r) + if r.My { + myInTop = true + } + } + } + if err := rows.Err(); err != nil { + return domain.ChannelMessagePaidReactions{}, err + } + // viewer 自身始终出现在 top reactors(官方:你的条目总在列表里,带 My 标志)。 + if out.MyStars > 0 && !myInTop { + out.TopReactors = append(out.TopReactors, myReactor) + } + return out, nil +} + +func (s *ChannelStore) DeleteChannelParticipantReaction(ctx context.Context, req domain.DeleteChannelParticipantReactionRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID || req.ParticipantUserID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete channel participant reaction: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("begin delete channel participant reaction: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if !canDeleteAnyChannelMessage(member) { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelAdminRequired + } + msg, err := s.getChannelMessage(ctx, tx, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if _, err := tx.Exec(ctx, ` +DELETE FROM channel_message_reactions +WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, + req.ChannelID, req.MessageID, req.ParticipantUserID); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("delete participant reaction: %w", err) + } + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, msg.SenderUserID, req.ChannelID); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("commit delete participant reaction: %w", err) + } + committed = true + messages := []domain.ChannelMessage{msg} + if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + msg = messages[0] + reactions := emptyChannelMessageReactions(channel) + if msg.Reactions != nil { + reactions = *msg.Reactions + } else { + msg.Reactions = &reactions + } + recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) + if err != nil { + recipients = []int64{req.UserID} + } + return domain.ChannelMessageReactionsResult{ + Channel: channel, + Message: msg, + Messages: []domain.ChannelMessage{msg}, + Reactions: reactions, + Recipients: recipients, + }, nil +} + +func (s *ChannelStore) DeleteChannelParticipantReactions(ctx context.Context, req domain.DeleteChannelParticipantReactionsRequest) (domain.DeleteChannelParticipantReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.ParticipantUserID == 0 { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxDeleteParticipantReactionsBatch { + req.Limit = domain.MaxDeleteParticipantReactionsBatch + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("delete channel participant reactions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("begin delete channel participant reactions: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, err + } + if !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelParticipantReactionsResult{}, domain.ErrChannelAdminRequired + } + rows, err := tx.Query(ctx, ` +SELECT message_id, MAX(sender_user_id) +FROM channel_message_reactions +WHERE channel_id = $1 AND reacted_user_id = $2 +GROUP BY message_id +ORDER BY MAX(reaction_date) DESC, message_id DESC +LIMIT $3`, req.ChannelID, req.ParticipantUserID, req.Limit) + if err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("list participant reaction messages: %w", err) + } + ids := make([]int, 0, req.Limit) + owners := make(map[int64]struct{}) + for rows.Next() { + var msgID int + var senderUserID int64 + if err := rows.Scan(&msgID, &senderUserID); err != nil { + rows.Close() + return domain.DeleteChannelParticipantReactionsResult{}, err + } + ids = append(ids, msgID) + if senderUserID != 0 { + owners[senderUserID] = struct{}{} + } + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.DeleteChannelParticipantReactionsResult{}, err + } + rows.Close() + if len(ids) > 0 { + if _, err := tx.Exec(ctx, ` +DELETE FROM channel_message_reactions +WHERE channel_id = $1 AND reacted_user_id = $2 AND message_id = ANY($3::int[])`, + req.ChannelID, req.ParticipantUserID, int32s(ids)); err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("delete participant reactions: %w", err) + } + for ownerID := range owners { + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, ownerID, req.ChannelID); err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, err + } + } + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, fmt.Errorf("commit delete participant reactions: %w", err) + } + committed = true + messages := []domain.ChannelMessage{} + if len(ids) > 0 { + res, err := s.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + IDs: ids, + }) + if err != nil { + return domain.DeleteChannelParticipantReactionsResult{}, err + } + messages = res.Messages + } + recipients, err := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, domain.MaxChannelRealtimeFanout) + if err != nil { + recipients = []int64{req.UserID} + } + return domain.DeleteChannelParticipantReactionsResult{ + Channel: channel, + Messages: messages, + Recipients: recipients, + Deleted: len(ids), + }, nil +} + +func (s *ChannelStore) GetChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsRequest) (domain.ChannelMessageReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ChannelMessageReactionsResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if len(req.IDs) == 0 { + return domain.ChannelMessageReactionsResult{Channel: channel}, nil + } + id32, _, err := validUniqueChannelMessageIDs(req.IDs) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + args := []any{req.ChannelID, id32} + where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" + if member.AvailableMinID > 0 { + args = append(args, member.AvailableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id DESC`, args...) + if err != nil { + return domain.ChannelMessageReactionsResult{}, fmt.Errorf("get channel message reactions messages: %w", err) + } + defer rows.Close() + messages := make([]domain.ChannelMessage, 0, len(req.IDs)) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + messages = append(messages, msg) + } + if err := rows.Err(); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, req.UserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelMessageReactionsResult{}, err + } + res := domain.ChannelMessageReactionsResult{Channel: channel, Messages: messages} + if len(messages) == 1 { + res.Message = messages[0] + res.Reactions = emptyChannelMessageReactions(channel) + if messages[0].Reactions != nil { + res.Reactions = *messages[0].Reactions + } + } + return res, nil +} + +func (s *ChannelStore) ListChannelMessageReactions(ctx context.Context, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + if req.Limit <= 0 || req.Limit > domain.MaxChannelMessageReactionListLimit { + req.Limit = domain.MaxChannelMessageReactionListLimit + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelMessageReactionsList{}, err + } + if channel.Broadcast && !channel.Megagroup { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelRightForbidden + } + msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelMessageReactionsList{}, err + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelMessageReactionsList{}, domain.ErrMessageIDInvalid + } + baseWhere := []string{"channel_id = $1", "message_id = $2"} + baseArgs := []any{req.ChannelID, req.MessageID} + if req.Reaction != nil { + if !req.Reaction.Valid() { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + baseArgs = append(baseArgs, string(req.Reaction.Type), req.Reaction.Value()) + baseWhere = append(baseWhere, fmt.Sprintf("reaction_type = $%d AND reaction_value = $%d", len(baseArgs)-1, len(baseArgs))) + } + var count int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM channel_message_reactions WHERE `+strings.Join(baseWhere, " AND "), baseArgs...).Scan(&count); err != nil { + return domain.ChannelMessageReactionsList{}, fmt.Errorf("count channel message reactions: %w", err) + } + where := append([]string(nil), baseWhere...) + args := append([]any(nil), baseArgs...) + if req.Offset != "" { + cursor, ok := parseChannelReactionOffset(req.Offset) + if !ok { + return domain.ChannelMessageReactionsList{}, domain.ErrChannelInvalid + } + if cursor.legacyValue { + args = append(args, cursor.date, cursor.userID, cursor.value) + n := len(args) + where = append(where, fmt.Sprintf("(reaction_date < $%d OR (reaction_date = $%d AND (reacted_user_id < $%d OR (reacted_user_id = $%d AND reaction_value > $%d))))", n-2, n-2, n-1, n-1, n)) + } else { + args = append(args, cursor.date, cursor.userID, string(cursor.reactionType), cursor.value) + n := len(args) + where = append(where, fmt.Sprintf("(reaction_date < $%d OR (reaction_date = $%d AND (reacted_user_id < $%d OR (reacted_user_id = $%d AND (reaction_type > $%d OR (reaction_type = $%d AND reaction_value > $%d))))))", n-3, n-3, n-2, n-2, n-1, n-1, n)) + } + } + args = append(args, req.Limit+1) + rows, err := s.db.Query(ctx, ` +SELECT channel_id, message_id, reacted_user_id, sender_user_id, reaction_type, reaction_value, + big, unread, chosen_order, reaction_date +FROM channel_message_reactions +WHERE `+strings.Join(where, " AND ")+` +ORDER BY reaction_date DESC, reacted_user_id DESC, reaction_type ASC, reaction_value ASC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelMessageReactionsList{}, fmt.Errorf("list channel message reactions: %w", err) + } + defer rows.Close() + reactions := make([]domain.ChannelMessagePeerReaction, 0, req.Limit+1) + for rows.Next() { + row, err := scanChannelMessagePeerReaction(rows, req.UserID) + if err != nil { + return domain.ChannelMessageReactionsList{}, err + } + reactions = append(reactions, row) + } + if err := rows.Err(); err != nil { + return domain.ChannelMessageReactionsList{}, err + } + next := "" + if len(reactions) > req.Limit { + reactions = reactions[:req.Limit] + next = channelReactionOffset(reactions[len(reactions)-1]) + } + return domain.ChannelMessageReactionsList{ + Channel: channel, + Message: msg, + Count: count, + Reactions: reactions, + NextOffset: next, + }, nil +} diff --git a/internal/store/postgres/channel_reaction_policy_integration_test.go b/internal/store/postgres/channel_reaction_policy_integration_test.go new file mode 100644 index 00000000..f62748a8 --- /dev/null +++ b/internal/store/postgres/channel_reaction_policy_integration_test.go @@ -0,0 +1,259 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +type reactionPolicyTestEnv struct { + channels *ChannelStore + channelID int64 + messageID int + ownerID int64 + memberID int64 + member2ID int64 +} + +func newReactionPolicyTestEnv(t *testing.T, broadcast bool) reactionPolicyTestEnv { + t.Helper() + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1892" + suffix + "01", FirstName: "PolicyOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1892" + suffix + "02", FirstName: "PolicyMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + member2, err := users.Create(ctx, domain.User{AccessHash: 93, Phone: "+1892" + suffix + "03", FirstName: "PolicyMember2"}) + if err != nil { + t.Fatalf("create member2: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID, member2.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Reaction Policy " + suffix, + Broadcast: broadcast, + Megagroup: !broadcast, + MemberUserIDs: []int64{member.ID, member2.ID}, + Date: 1700001000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 91_001, + Message: "react to this", + Date: 1700001001, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + return reactionPolicyTestEnv{ + channels: channels, + channelID: channelID, + messageID: sent.Message.ID, + ownerID: owner.ID, + memberID: member.ID, + member2ID: member2.ID, + } +} + +func (e reactionPolicyTestEnv) react(t *testing.T, userID int64, emoticons ...string) (domain.ChannelMessageReactionsResult, error) { + t.Helper() + reactions := make([]domain.MessageReaction, 0, len(emoticons)) + for _, emoticon := range emoticons { + reactions = append(reactions, domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}) + } + return e.reactDomain(t, userID, reactions...) +} + +func (e reactionPolicyTestEnv) reactDomain(t *testing.T, userID int64, reactions ...domain.MessageReaction) (domain.ChannelMessageReactionsResult, error) { + t.Helper() + return e.channels.SetChannelMessageReactions(context.Background(), domain.SetChannelMessageReactionsRequest{ + UserID: userID, + ChannelID: e.channelID, + MessageID: e.messageID, + Reactions: reactions, + Date: 1700001002, + }) +} + +func TestChannelStoreReactionPolicyEnforcedOnWrite(t *testing.T) { + env := newReactionPolicyTestEnv(t, false) + ctx := context.Background() + + if _, err := env.channels.SetAvailableReactions(ctx, env.ownerID, env.channelID, domain.ChannelReactionPolicy{ + Type: domain.ChannelReactionPolicySome, + Emoticons: []string{"\U0001f44d"}, + }); err != nil { + t.Fatalf("set whitelist policy: %v", err) + } + if _, err := env.react(t, env.memberID, "❤"); !errors.Is(err, domain.ErrReactionInvalid) { + t.Fatalf("off-whitelist reaction err = %v, want ErrReactionInvalid", err) + } + if _, err := env.react(t, env.memberID, "\U0001f44d"); err != nil { + t.Fatalf("whitelisted reaction: %v", err) + } + + if _, err := env.channels.SetAvailableReactions(ctx, env.ownerID, env.channelID, domain.ChannelReactionPolicy{ + Type: domain.ChannelReactionPolicyNone, + }); err != nil { + t.Fatalf("set none policy: %v", err) + } + if _, err := env.react(t, env.ownerID, "\U0001f44d"); !errors.Is(err, domain.ErrReactionInvalid) { + t.Fatalf("reaction under none policy err = %v, want ErrReactionInvalid", err) + } + // 策略收紧后撤销存量 reaction 必须仍然可行。 + if _, err := env.react(t, env.memberID); err != nil { + t.Fatalf("retract reaction under none policy: %v", err) + } +} + +func TestChannelStoreCustomEmojiReactionPolicyRoundTrips(t *testing.T) { + env := newReactionPolicyTestEnv(t, false) + ctx := context.Background() + const customDocumentID int64 = 8800007 + + if _, err := env.channels.SetAvailableReactions(ctx, env.ownerID, env.channelID, domain.ChannelReactionPolicy{ + Type: domain.ChannelReactionPolicySome, + CustomEmojiIDs: []int64{customDocumentID}, + }); err != nil { + t.Fatalf("set custom whitelist policy: %v", err) + } + if _, err := env.react(t, env.memberID, "\U0001f44d"); !errors.Is(err, domain.ErrReactionInvalid) { + t.Fatalf("off-whitelist emoji err = %v, want ErrReactionInvalid", err) + } + res, err := env.reactDomain(t, env.memberID, domain.MessageReaction{ + Type: domain.MessageReactionCustomEmoji, + DocumentID: customDocumentID, + }) + if err != nil { + t.Fatalf("custom emoji reaction: %v", err) + } + if len(res.Reactions.Results) != 1 || res.Reactions.Results[0].Reaction.Type != domain.MessageReactionCustomEmoji || res.Reactions.Results[0].Reaction.DocumentID != customDocumentID { + t.Fatalf("custom reaction aggregate = %+v, want document %d", res.Reactions.Results, customDocumentID) + } + list, err := env.channels.ListChannelMessageReactions(ctx, domain.ChannelMessageReactionsListRequest{ + UserID: env.ownerID, + ChannelID: env.channelID, + MessageID: env.messageID, + Reaction: &domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: customDocumentID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("list custom reactions: %v", err) + } + if list.Count != 1 || len(list.Reactions) != 1 || list.Reactions[0].Reaction.DocumentID != customDocumentID { + t.Fatalf("custom reactions list = %+v, want one document %d", list, customDocumentID) + } + var reactionValue string + if err := env.channels.db.QueryRow(ctx, ` +SELECT reaction_value FROM channel_message_reactions +WHERE channel_id = $1 AND message_id = $2 AND reaction_type = $3`, env.channelID, env.messageID, string(domain.MessageReactionCustomEmoji)).Scan(&reactionValue); err != nil { + t.Fatalf("query custom reaction row: %v", err) + } + if reactionValue != "8800007" { + t.Fatalf("reaction_value = %q, want custom document id string", reactionValue) + } +} + +func TestChannelStoreUniqueReactionsLimitOnlyBlocksNewKinds(t *testing.T) { + env := newReactionPolicyTestEnv(t, false) + ctx := context.Background() + + // 默认策略下先造出 {👍, ❤},再调低 reactions_limit 模拟存量超限。 + if _, err := env.react(t, env.ownerID, "\U0001f44d"); err != nil { + t.Fatalf("seed owner reaction: %v", err) + } + if _, err := env.react(t, env.memberID, "❤"); err != nil { + t.Fatalf("seed member reaction: %v", err) + } + if _, err := env.channels.SetAvailableReactions(ctx, env.ownerID, env.channelID, domain.ChannelReactionPolicy{ + Type: domain.ChannelReactionPolicyAll, + Limit: 1, + }); err != nil { + t.Fatalf("lower uniq limit: %v", err) + } + + if _, err := env.react(t, env.ownerID, "\U0001f44d"); err != nil { + t.Fatalf("owner re-send own reaction on over-limit message: %v", err) + } + if _, err := env.react(t, env.memberID, "❤"); err != nil { + t.Fatalf("member re-send own reaction on over-limit message: %v", err) + } + if _, err := env.react(t, env.member2ID, "\U0001f44d"); err != nil { + t.Fatalf("third user piles onto existing kind on over-limit message: %v", err) + } + if _, err := env.react(t, env.member2ID, "\U0001f525"); !errors.Is(err, domain.ErrReactionsTooMany) { + t.Fatalf("new kind on over-limit message err = %v, want ErrReactionsTooMany", err) + } +} + +func TestChannelStoreReactionVectorTrimsToPerUserMax(t *testing.T) { + env := newReactionPolicyTestEnv(t, false) + + // 超出 reactions_user_max_default 的向量保留尾部最新项,不报错。 + res, err := env.react(t, env.memberID, "\U0001f44d", "❤") + if err != nil { + t.Fatalf("send oversized reaction vector: %v", err) + } + if len(res.Reactions.Results) != 1 { + t.Fatalf("results = %+v, want single trimmed reaction", res.Reactions.Results) + } + if res.Reactions.Results[0].Reaction.Emoticon != "❤" || res.Reactions.Results[0].ChosenOrder != 1 { + t.Fatalf("kept reaction = %+v, want newest vector entry with chosen_order 1", res.Reactions.Results[0]) + } +} + +func TestChannelStoreBroadcastReactionsAnonymousAndSkipUnread(t *testing.T) { + env := newReactionPolicyTestEnv(t, true) + ctx := context.Background() + + res, err := env.react(t, env.memberID, "\U0001f44d") + if err != nil { + t.Fatalf("send broadcast reaction: %v", err) + } + if len(res.Reactions.Results) != 1 || res.Reactions.Results[0].Count != 1 { + t.Fatalf("broadcast results = %+v, want count-only aggregate", res.Reactions.Results) + } + if len(res.Reactions.Recent) != 0 { + t.Fatalf("broadcast recent reactors = %+v, want anonymous (empty)", res.Reactions.Recent) + } + if res.Reactions.CanSeeList { + t.Fatalf("broadcast can_see_list = true, want false") + } + var unreadRows int + if err := env.channels.db.QueryRow(ctx, ` +SELECT COUNT(*) FROM channel_message_reactions +WHERE channel_id = $1 AND message_id = $2 AND unread`, env.channelID, env.messageID).Scan(&unreadRows); err != nil { + t.Fatalf("count unread reaction rows: %v", err) + } + if unreadRows != 0 { + t.Fatalf("broadcast unread reaction rows = %d, want unread bookkeeping skipped", unreadRows) + } + dialogs, err := env.channels.GetChannelDialogs(ctx, env.ownerID, []int64{env.channelID}) + if err != nil { + t.Fatalf("get owner dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 0 { + t.Fatalf("owner dialogs = %+v, want no unread reaction badge on broadcast", dialogs.Dialogs) + } +} diff --git a/internal/store/postgres/channel_reaction_settings.go b/internal/store/postgres/channel_reaction_settings.go new file mode 100644 index 00000000..9d6b5d11 --- /dev/null +++ b/internal/store/postgres/channel_reaction_settings.go @@ -0,0 +1,40 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +func (s *ChannelStore) SetAvailableReactions(ctx context.Context, userID, channelID int64, policy domain.ChannelReactionPolicy) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + policyJSON, err := marshalJSON(policy, "{}") + if err != nil { + return domain.Channel{}, err + } + var channel domain.Channel + if err := withTx(ctx, s.db, "set channel available reactions", func(tx pgx.Tx) error { + var err error + var member domain.ChannelMember + channel, member, err = s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return err + } + if !canChangeChannelInfo(member) { + return domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET available_reactions = $2, updated_at = now() WHERE id = $1`, channelID, policyJSON); err != nil { + return fmt.Errorf("update channel available reactions: %w", err) + } + channel.ReactionPolicy = policy + return nil + }); err != nil { + return domain.Channel{}, err + } + return channel, nil +} diff --git a/internal/store/postgres/channel_reaction_unread.go b/internal/store/postgres/channel_reaction_unread.go new file mode 100644 index 00000000..8419629d --- /dev/null +++ b/internal/store/postgres/channel_reaction_unread.go @@ -0,0 +1,435 @@ +package postgres + +import ( + "context" + "fmt" + "github.com/jackc/pgx/v5" + "sort" + "telesrv/internal/domain" +) + +func (s *ChannelStore) ListChannelUnreadReactions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadReactionsFilter) (domain.ChannelHistory, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit { + limit = domain.MaxChannelUnreadReactionsLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + count, err := s.countChannelUnreadReactions(ctx, viewerUserID, filter, member.AvailableMinID) + if err != nil { + return domain.ChannelHistory{}, err + } + messages, err := s.queryChannelUnreadReactionsPage(ctx, viewerUserID, filter, member.AvailableMinID, limit) + if err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, messages); err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelHistory{}, err + } + return domain.ChannelHistory{Channel: channel, Messages: messages, Count: count}, nil +} + +func (s *ChannelStore) ReadChannelReactions(ctx context.Context, req domain.ReadChannelReactionsRequest) (domain.ReadChannelReactionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelReactionsResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ReadChannelReactionsResult{}, fmt.Errorf("read channel reactions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ReadChannelReactionsResult{}, fmt.Errorf("begin read channel reactions: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, _, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelReactionsResult{}, err + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadReactionsBatch { + limit = domain.MaxChannelReadReactionsBatch + } + cleared, remaining, err := readChannelReactionsTx(ctx, tx, req.UserID, req.ChannelID, req.TopMsgID, limit) + if err != nil { + return domain.ReadChannelReactionsResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ReadChannelReactionsResult{}, fmt.Errorf("commit read channel reactions: %w", err) + } + committed = true + offset := 0 + if remaining > 0 { + offset = 1 + } + return domain.ReadChannelReactionsResult{ + Channel: channel, + Cleared: cleared, + Remaining: remaining, + Offset: offset, + ChannelPts: channel.Pts, + }, nil +} + +func (s *ChannelStore) countChannelUnreadReactionsForTop(ctx context.Context, userID, channelID int64, topMsgID, availableMinID int) int { + var count int + _ = s.db.QueryRow(ctx, ` +SELECT COUNT(DISTINCT r.message_id)::int +FROM channel_message_reactions r +JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id +WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > $4 + AND NOT cm.deleted + AND (cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3)`, userID, channelID, topMsgID, availableMinID).Scan(&count) + return count +} + +func (s *ChannelStore) countChannelUnreadReactions(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID int) (int, error) { + where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(DISTINCT cm.id)::int +FROM channel_message_reactions r +JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id +WHERE `+where, args...).Scan(&count); err != nil { + return 0, fmt.Errorf("count channel unread reactions: %w", err) + } + return count, nil +} + +func (s *ChannelStore) queryChannelUnreadReactionsPage(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + switch messageHistoryLoadType(filter.AddOffset, limit) { + case messageHistoryLoadForward: + return s.queryChannelUnreadReactionsForward(ctx, userID, filter, availableMinID, limit) + case messageHistoryLoadAround: + forwardLimit := -filter.AddOffset + if forwardLimit > limit { + forwardLimit = limit + } + backwardLimit := limit + filter.AddOffset + if backwardLimit < 0 { + backwardLimit = 0 + } + forward, err := s.queryChannelUnreadReactionsForward(ctx, userID, filter, availableMinID, forwardLimit) + if err != nil { + return nil, err + } + backward, err := s.queryChannelUnreadReactionsBackward(ctx, userID, filter, availableMinID, backwardLimit, true) + if err != nil { + return nil, err + } + out := append(forward, backward...) + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil + default: + start := filter.AddOffset + if start < 0 { + start = 0 + } + items, err := s.queryChannelUnreadReactionsBackward(ctx, userID, filter, availableMinID, limit+start, false) + if err != nil || start >= len(items) { + return nil, err + } + return items[start:], nil + } +} + +func (s *ChannelStore) queryChannelUnreadReactionsBackward(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) + where, args = appendChannelUnreadReactionBackwardOffset(where, args, filter, includeOffset) + args = append(args, limit) + return s.queryChannelUnreadReactions(ctx, filter.ChannelID, where, args, "DESC") +} + +func (s *ChannelStore) queryChannelUnreadReactionsForward(ctx context.Context, userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelUnreadReactionBaseWhere(userID, filter, availableMinID) + where, args = appendChannelUnreadReactionForwardOffset(where, args, filter) + args = append(args, limit) + out, err := s.queryChannelUnreadReactions(ctx, filter.ChannelID, where, args, "ASC") + if err != nil { + return nil, err + } + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil +} + +func (s *ChannelStore) queryChannelUnreadReactions(ctx context.Context, channelID int64, where string, args []any, order string) ([]domain.ChannelMessage, error) { + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE channel_id = $2 + AND id = ANY(ARRAY( + SELECT DISTINCT cm.id + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + WHERE `+where+` + ORDER BY cm.id `+order+` + LIMIT $`+fmt.Sprint(len(args))+` + )::int[]) +ORDER BY id `+order, args...) + _ = channelID + if err != nil { + return nil, fmt.Errorf("list channel unread reactions: %w", err) + } + defer rows.Close() + out := make([]domain.ChannelMessage, 0) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return nil, err + } + out = append(out, msg) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func channelUnreadReactionBaseWhere(userID int64, filter domain.ChannelUnreadReactionsFilter, availableMinID int) (string, []any) { + args := []any{userID, filter.ChannelID} + where := "r.sender_user_id = $1 AND r.channel_id = $2 AND r.unread AND r.reacted_user_id <> $1 AND NOT cm.deleted" + if availableMinID > 0 { + args = append(args, availableMinID) + where += fmt.Sprintf(" AND cm.id > $%d", len(args)) + } + if filter.TopMsgID > 0 { + args = append(args, filter.TopMsgID) + where += fmt.Sprintf(" AND (cm.id = $%d OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $%d)", len(args), len(args)) + } + if filter.MaxID > 0 { + args = append(args, filter.MaxID) + where += fmt.Sprintf(" AND cm.id < $%d", len(args)) + } + if filter.MinID > 0 { + args = append(args, filter.MinID) + where += fmt.Sprintf(" AND cm.id > $%d", len(args)) + } + return where, args +} + +func appendChannelUnreadReactionBackwardOffset(where string, args []any, filter domain.ChannelUnreadReactionsFilter, include bool) (string, []any) { + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + if include { + return where + fmt.Sprintf(" AND cm.id <= $%d", len(args)), args + } + return where + fmt.Sprintf(" AND cm.id < $%d", len(args)), args + } + return where, args +} + +func appendChannelUnreadReactionForwardOffset(where string, args []any, filter domain.ChannelUnreadReactionsFilter) (string, []any) { + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + return where + fmt.Sprintf(" AND cm.id > $%d", len(args)), args + } + return where, args +} + +func readChannelReactionsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, topMsgID, limit int) (int, int, error) { + var cleared, remaining int + if err := tx.QueryRow(ctx, ` +WITH member_scope AS ( + SELECT available_min_id + FROM channel_members + WHERE user_id = $1 AND channel_id = $2 +), +target_messages AS ( + SELECT DISTINCT r.message_id + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + JOIN member_scope ms ON true + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > ms.available_min_id + AND NOT cm.deleted + AND ($3 = 0 OR cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3) + ORDER BY r.message_id DESC + LIMIT $4 +), +updated AS ( + UPDATE channel_message_reactions r + SET unread = false, + updated_at = now() + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.message_id IN (SELECT message_id FROM target_messages) + AND r.unread + RETURNING r.message_id +), +remaining_scoped AS ( + SELECT COUNT(DISTINCT r.message_id)::int AS count + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + JOIN member_scope ms ON true + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > ms.available_min_id + AND NOT cm.deleted + AND ($3 = 0 OR cm.id = $3 OR COALESCE(NULLIF(cm.reply_to_top_id, 0), NULLIF(cm.reply_to_msg_id, 0), 0) = $3) +), +remaining_all AS ( + SELECT COUNT(DISTINCT r.message_id)::int AS count + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + JOIN member_scope ms ON true + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > ms.available_min_id + AND NOT cm.deleted +), +updated_dialog AS ( + UPDATE channel_dialogs + SET unread_reactions_count = (SELECT count FROM remaining_all), + updated_at = now() + WHERE user_id = $1 AND channel_id = $2 +) +SELECT (SELECT COUNT(DISTINCT message_id)::int FROM updated), (SELECT count FROM remaining_scoped)`, userID, channelID, topMsgID, limit).Scan(&cleared, &remaining); err != nil { + return 0, 0, fmt.Errorf("read channel reactions: %w", err) + } + return cleared, remaining, nil +} + +func clearChannelUnreadReactionsForMessageIDsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, ids []int32) ([]int, error) { + if userID == 0 || channelID == 0 || len(ids) == 0 { + return nil, nil + } + rows, err := tx.Query(ctx, ` +UPDATE channel_message_reactions +SET unread = false, + updated_at = now() +WHERE sender_user_id = $1 + AND channel_id = $2 + AND message_id = ANY($3::int[]) + AND unread + AND reacted_user_id <> $1 +RETURNING message_id`, userID, channelID, ids) + if err != nil { + return nil, fmt.Errorf("clear visible channel unread reactions: %w", err) + } + clearedSet := make(map[int]struct{}) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + clearedSet[id] = struct{}{} + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + if len(clearedSet) == 0 { + return nil, nil + } + cleared := make([]int, 0, len(clearedSet)) + for id := range clearedSet { + cleared = append(cleared, id) + } + sort.Sort(sort.Reverse(sort.IntSlice(cleared))) + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil { + return nil, err + } + return cleared, nil +} + +func refreshChannelUnreadReactionsCountTx(ctx context.Context, tx pgx.Tx, userID, channelID int64) error { + if userID == 0 || channelID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +WITH active AS ( + SELECT m.available_min_id + FROM channel_members m + WHERE m.user_id = $1 + AND m.channel_id = $2 + AND m.status = 'active' + AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) +), +counts AS ( + SELECT COUNT(DISTINCT r.message_id)::int AS count + FROM channel_message_reactions r + JOIN channel_messages cm ON cm.channel_id = r.channel_id AND cm.id = r.message_id + JOIN active a ON true + WHERE r.sender_user_id = $1 + AND r.channel_id = $2 + AND r.unread + AND r.reacted_user_id <> $1 + AND cm.id > a.available_min_id + AND NOT cm.deleted +) +INSERT INTO channel_dialogs (user_id, channel_id, unread_reactions_count) +SELECT $1, $2, counts.count +FROM active, counts +ON CONFLICT (user_id, channel_id) DO UPDATE SET + unread_reactions_count = EXCLUDED.unread_reactions_count, + updated_at = now()`, userID, channelID); err != nil { + return fmt.Errorf("refresh channel unread reactions count: %w", err) + } + return nil +} + +func refreshChannelUnreadReactionsCountsForMessagesTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) error { + if len(ids) == 0 { + return nil + } + rows, err := tx.Query(ctx, ` +SELECT DISTINCT sender_user_id +FROM channel_message_reactions +WHERE channel_id = $1 + AND message_id = ANY($2::int[]) + AND sender_user_id <> 0`, channelID, int32s(ids)) + if err != nil { + return fmt.Errorf("list channel unread reaction owners: %w", err) + } + defer rows.Close() + userIDs := make([]int64, 0) + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + return err + } + userIDs = append(userIDs, userID) + } + if err := rows.Err(); err != nil { + return err + } + for _, userID := range userIDs { + if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil { + return err + } + } + return nil +} diff --git a/internal/store/postgres/channel_read.go b/internal/store/postgres/channel_read.go new file mode 100644 index 00000000..6502d35a --- /dev/null +++ b/internal/store/postgres/channel_read.go @@ -0,0 +1,1054 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "sort" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr string) string { + // LIMIT 子查询把动态未读 COUNT 钳到 MaxDialogUnreadCount(P1-v):既限定扫描工作量 + // (最多 cap 行,避免广播/大群积压时 O(积压) 扫描),又把下发角标钳到上界。 + return fmt.Sprintf(`( + SELECT COUNT(*)::int + FROM ( + SELECT 1 + FROM channel_messages cm_unread + WHERE cm_unread.channel_id = c.id + AND cm_unread.id > GREATEST(%s, m.available_min_id) + AND cm_unread.id <= %s + AND NOT cm_unread.deleted + AND cm_unread.sender_user_id <> m.user_id + LIMIT %d + ) cm_unread_capped + )`, readInboxExpr, topIDExpr, domain.MaxDialogUnreadCount) +} + +func channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr string) string { + return fmt.Sprintf(`EXISTS ( + SELECT 1 + FROM channel_messages cm_unread + WHERE cm_unread.channel_id = c.id + AND cm_unread.id > GREATEST(%s, m.available_min_id) + AND cm_unread.id <= %s + AND NOT cm_unread.deleted + AND cm_unread.sender_user_id <> m.user_id + )`, readInboxExpr, topIDExpr) +} + +func channelDialogVisibleUnreadCountSQL(readInboxExpr, topIDExpr string) string { + dynamicCount := channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr) + // LEAST 钳制缓存分支 d.unread_count(小群走缓存列,可能因逐条自增长过上界)到 P1-v 上界; + // 动态分支已被 LIMIT 子查询钳过,外层 LEAST 对两分支统一封顶,保证下发角标 ≤ 上界。 + return fmt.Sprintf(`LEAST(CASE + WHEN c.broadcast OR c.participants_count > %d THEN %s + ELSE COALESCE(d.unread_count, %s) + END, %d)`, domain.MaxSynchronousChannelDialogFanout, dynamicCount, dynamicCount, domain.MaxDialogUnreadCount) +} + +func channelDialogHasUnreadSQL(readInboxExpr, topIDExpr string) string { + dynamicUnread := channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr) + return fmt.Sprintf(`CASE + WHEN c.broadcast OR c.participants_count > %d THEN %s + ELSE COALESCE(d.unread_count > 0, %s) + END`, domain.MaxSynchronousChannelDialogFanout, dynamicUnread, dynamicUnread) +} + +func (s *ChannelStore) SetChannelDialogUnreadMark(ctx context.Context, userID, channelID int64, unread bool) (bool, error) { + if userID == 0 || channelID == 0 { + return false, nil + } + var changed bool + if err := s.db.QueryRow(ctx, ` +WITH target AS ( + SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date, + m.read_inbox_max_id, m.read_outbox_max_id, m.available_min_id + FROM channels c + JOIN channel_members m ON m.channel_id = c.id + WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted +), +ensured AS ( + -- 惰性建行必须带 member 真实水位与未读数:0 值缓存行一旦存在就会 + -- 遮蔽真值(小群读取信缓存列)。 + INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, unread_count) + SELECT $1, channel_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, ( + SELECT COUNT(*)::int + FROM channel_messages cm + WHERE cm.channel_id = target.channel_id + AND cm.id > GREATEST(target.read_inbox_max_id, target.available_min_id) + AND NOT cm.deleted + AND cm.sender_user_id <> $1 + ) + FROM target + ON CONFLICT (user_id, channel_id) DO NOTHING +), +updated_dialog AS ( + UPDATE channel_dialogs d + SET unread_mark = $3, updated_at = now() + WHERE d.user_id = $1 AND d.channel_id = $2 + AND EXISTS (SELECT 1 FROM target) + AND d.unread_mark IS DISTINCT FROM $3::boolean + RETURNING d.user_id +), +updated_member AS ( + UPDATE channel_members m + SET unread_mark = $3 + WHERE m.user_id = $1 AND m.channel_id = $2 AND m.status = 'active' + RETURNING m.user_id +) +SELECT EXISTS (SELECT 1 FROM updated_dialog)::boolean`, userID, channelID, unread).Scan(&changed); err != nil { + return false, fmt.Errorf("set channel dialog unread mark: %w", err) + } + // 连接池写后同步失效本进程缓存(dialog 的 unread_mark 与 member 的 unread_mark 都被改), + // 保证同实例 read-your-write;跨实例由 dialog_light / channel_member NOTIFY 异步兜底。 + if changed && s.dialogCacheActive(s.db) { + s.dialogCache.delete(userID, channelID) + s.memberCache.delete(channelID, userID) + } + return changed, nil +} + +func (s *ChannelStore) ListChannelUnreadMarked(ctx context.Context, userID int64) ([]domain.Peer, error) { + if userID == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT d.channel_id +FROM channel_dialogs d +JOIN channel_members m ON m.channel_id = d.channel_id AND m.user_id = d.user_id AND m.status = 'active' +JOIN channels c ON c.id = d.channel_id AND NOT c.deleted +WHERE d.user_id = $1 AND d.unread_mark +ORDER BY d.top_message_date DESC, d.top_message_id DESC, d.channel_id DESC +LIMIT 500`, userID) + if err != nil { + return nil, fmt.Errorf("list channel unread marks: %w", err) + } + defer rows.Close() + out := make([]domain.Peer, 0) + for rows.Next() { + var channelID int64 + if err := rows.Scan(&channelID); err != nil { + return nil, err + } + out = append(out, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) ReadChannelMessageContents(ctx context.Context, req domain.ReadChannelMessageContentsRequest) (domain.ReadChannelMessageContentsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("read channel message contents: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("begin read channel message contents: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + if len(req.IDs) == 0 { + if err := tx.Commit(ctx); err != nil { + return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("commit read channel message contents: %w", err) + } + committed = true + return domain.ReadChannelMessageContentsResult{Channel: channel}, nil + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return domain.ReadChannelMessageContentsResult{}, domain.ErrChannelInvalid + } + id32, _, err := validUniqueChannelMessageIDs(req.IDs) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + args := []any{req.ChannelID, id32} + where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" + if member.AvailableMinID > 0 { + args = append(args, member.AvailableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + rows, err := tx.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id DESC`, args...) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("read channel messages by ids: %w", err) + } + messages := make([]domain.ChannelMessage, 0, len(id32)) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + rows.Close() + return domain.ReadChannelMessageContentsResult{}, err + } + messages = append(messages, msg) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.ReadChannelMessageContentsResult{}, err + } + rows.Close() + visibleIDs := make([]int32, 0, len(messages)) + for _, msg := range messages { + visibleIDs = append(visibleIDs, int32(msg.ID)) + } + cleared, err := clearChannelUnreadReactionsForMessageIDsTx(ctx, tx, req.UserID, req.ChannelID, visibleIDs) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + clearedMentions, err := readChannelMentionsForMessageIDsTx(ctx, tx, req.UserID, req.ChannelID, visibleIDs) + if err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + if err := s.populateChannelMessageReplies(ctx, tx, req.UserID, channel, messages); err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + if err := s.populateChannelMessagesReactions(ctx, tx, req.UserID, []domain.Channel{channel}, messages); err != nil { + return domain.ReadChannelMessageContentsResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ReadChannelMessageContentsResult{}, fmt.Errorf("commit read channel message contents: %w", err) + } + committed = true + return domain.ReadChannelMessageContentsResult{ + Channel: channel, + Messages: messages, + ClearedUnreadReactionMessageIDs: cleared, + ClearedUnreadMentionMessageIDs: clearedMentions, + }, nil +} + +// readChannelMentionsForMessageIDsTx 把指定可见消息上的未读 mention 翻转为 +// 已读并重算 dialog 计数;视口内容已读(readMessageContents)的主路径。 +func readChannelMentionsForMessageIDsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, ids []int32) ([]int, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := tx.Query(ctx, ` +WITH flipped AS ( + UPDATE channel_unread_mentions + SET unread = false + WHERE user_id = $1 AND channel_id = $2 AND message_id = ANY($3::int[]) AND unread + RETURNING message_id +), +updated_dialog AS ( + UPDATE channel_dialogs d + SET unread_mentions_count = ( + SELECT COUNT(*)::int + FROM channel_unread_mentions um + WHERE um.user_id = $1 AND um.channel_id = $2 AND um.unread + AND NOT EXISTS (SELECT 1 FROM flipped f WHERE f.message_id = um.message_id) + ), + updated_at = now() + WHERE d.user_id = $1 AND d.channel_id = $2 + AND EXISTS (SELECT 1 FROM flipped) +) +SELECT message_id FROM flipped ORDER BY message_id`, userID, channelID, ids) + if err != nil { + return nil, fmt.Errorf("read channel mentions by contents: %w", err) + } + defer rows.Close() + out := make([]int, 0, len(ids)) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +func (s *ChannelStore) ListChannelUnreadMentions(ctx context.Context, viewerUserID int64, filter domain.ChannelUnreadMentionsFilter) (domain.ChannelHistory, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelUnreadMentionsLimit { + limit = domain.MaxChannelUnreadMentionsLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + count, err := s.countChannelUnreadMentions(ctx, viewerUserID, filter, member.AvailableMinID) + if err != nil { + return domain.ChannelHistory{}, err + } + messages, err := s.queryChannelUnreadMentionsPage(ctx, viewerUserID, filter, member.AvailableMinID, limit) + if err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, channel, messages); err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{channel}, messages); err != nil { + return domain.ChannelHistory{}, err + } + return domain.ChannelHistory{Channel: channel, Messages: messages, Count: count}, nil +} + +func (s *ChannelStore) ReadChannelMentions(ctx context.Context, req domain.ReadChannelMentionsRequest) (domain.ReadChannelMentionsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.ReadChannelMentionsResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ReadChannelMentionsResult{}, fmt.Errorf("read channel mentions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ReadChannelMentionsResult{}, fmt.Errorf("begin read channel mentions: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, _, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelMentionsResult{}, err + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadMentionsBatch { + limit = domain.MaxChannelReadMentionsBatch + } + cleared, remaining, err := readChannelMentionsTx(ctx, tx, req.UserID, req.ChannelID, req.TopMsgID, limit) + if err != nil { + return domain.ReadChannelMentionsResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ReadChannelMentionsResult{}, fmt.Errorf("commit read channel mentions: %w", err) + } + committed = true + offset := 0 + if remaining > 0 { + offset = 1 + } + return domain.ReadChannelMentionsResult{ + Channel: channel, + Cleared: cleared, + Remaining: remaining, + Offset: offset, + ChannelPts: channel.Pts, + }, nil +} + +func (s *ChannelStore) ReadChannelHistory(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) { + var lastErr error + for attempt := 0; attempt < retryableChannelTxAttempts; attempt++ { + res, err := s.readChannelHistoryOnce(ctx, req) + if err == nil || !isRetryablePostgresTxError(err) || ctx.Err() != nil { + return res, err + } + lastErr = err + } + return domain.ReadChannelHistoryResult{}, lastErr +} + +func advanceChannelReadOutboxTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, readerUserID int64, previous, maxID int) ([]domain.ChannelReadOutboxUpdate, error) { + if maxID <= previous { + return nil, nil + } + lowerID := previous + if maxID-lowerID > domain.MaxChannelReadOutboxScanMessages { + lowerID = maxID - domain.MaxChannelReadOutboxScanMessages + } + rows, err := tx.Query(ctx, ` +WITH latest_sender_messages AS ( + SELECT sender_user_id, MAX(id) AS max_id + FROM channel_messages + WHERE channel_id = $1 + AND id > $2 + AND id <= $3 + AND NOT deleted + AND sender_user_id <> $4 + GROUP BY sender_user_id + ORDER BY max_id DESC + LIMIT $5 +) +SELECT sender_user_id, max_id +FROM latest_sender_messages +ORDER BY sender_user_id ASC`, channel.ID, lowerID, maxID, readerUserID, domain.MaxChannelReadOutboxFanout) + if err != nil { + return nil, fmt.Errorf("list channel read outbox senders: %w", err) + } + defer rows.Close() + type candidate struct { + userID int64 + maxID int + } + candidates := make([]candidate, 0, domain.MaxChannelReadOutboxFanout) + for rows.Next() { + var item candidate + if err := rows.Scan(&item.userID, &item.maxID); err != nil { + return nil, err + } + candidates = append(candidates, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + out := make([]domain.ChannelReadOutboxUpdate, 0, len(candidates)) + for _, item := range candidates { + var readOutboxMaxID, readInboxMaxID int + err := tx.QueryRow(ctx, ` +UPDATE channel_members +SET read_outbox_max_id = GREATEST(read_outbox_max_id, $3), + updated_at = now() +WHERE channel_id = $1 + AND user_id = $2 + AND status = 'active' + AND read_outbox_max_id < $3 +RETURNING read_outbox_max_id, read_inbox_max_id`, channel.ID, item.userID, item.maxID).Scan(&readOutboxMaxID, &readInboxMaxID) + if errors.Is(err, pgx.ErrNoRows) { + continue + } + if err != nil { + return nil, fmt.Errorf("update channel sender read outbox: %w", err) + } + if err := upsertChannelDialogTx(ctx, tx, item.userID, channel, top, readInboxMaxID, readOutboxMaxID); err != nil { + return nil, err + } + out = append(out, domain.ChannelReadOutboxUpdate{UserID: item.userID, MaxID: readOutboxMaxID}) + } + return out, nil +} + +func (s *ChannelStore) ListMessageReadParticipants(ctx context.Context, req domain.ChannelReadParticipantsRequest) (domain.ChannelReadParticipantsResult, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelReadParticipantsResult{}, err + } + if req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid + } + msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, req.MessageID) + if err != nil { + return domain.ChannelReadParticipantsResult{}, err + } + if msg.Deleted || msg.ID <= member.AvailableMinID { + return domain.ChannelReadParticipantsResult{}, domain.ErrMessageIDInvalid + } + result := domain.ChannelReadParticipantsResult{Channel: channel, Message: msg} + if !channel.Megagroup || channel.ParticipantsHidden || channel.ParticipantsCount > domain.MaxChannelReadParticipants { + return result, nil + } + if req.Date > 0 && msg.Date+domain.ChannelReadMarkExpirePeriod <= req.Date { + return result, nil + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelReadParticipants { + limit = domain.MaxChannelReadParticipants + } + rows, err := s.db.Query(ctx, ` +SELECT user_id, read_inbox_date +FROM channel_members +WHERE channel_id = $1 + AND status = 'active' + AND user_id <> $2 + AND available_min_id < $3 + AND read_inbox_max_id >= $3 + AND read_inbox_date > 0 + AND NOT COALESCE((banned_rights->>'ViewMessages')::boolean, false) +ORDER BY read_inbox_date ASC, user_id ASC +LIMIT $4`, req.ChannelID, req.UserID, req.MessageID, limit) + if err != nil { + return domain.ChannelReadParticipantsResult{}, fmt.Errorf("list channel read participants: %w", err) + } + defer rows.Close() + for rows.Next() { + var item domain.ChannelReadParticipant + if err := rows.Scan(&item.UserID, &item.Date); err != nil { + return domain.ChannelReadParticipantsResult{}, err + } + result.Participants = append(result.Participants, item) + } + if err := rows.Err(); err != nil { + return domain.ChannelReadParticipantsResult{}, err + } + return result, nil +} + +func populateChannelMessageUnreadFlags(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, messages []domain.ChannelMessage) error { + if viewerUserID == 0 || len(messages) == 0 { + return nil + } + indexes := make(map[channelReactionMessageKey][]int) + idsByChannel := make(map[int64][]int32) + for i := range messages { + if messages[i].ChannelID == 0 || messages[i].ID <= 0 { + continue + } + key := channelReactionMessageKey{channelID: messages[i].ChannelID, messageID: messages[i].ID} + if _, ok := indexes[key]; !ok { + idsByChannel[messages[i].ChannelID] = append(idsByChannel[messages[i].ChannelID], int32(messages[i].ID)) + } + indexes[key] = append(indexes[key], i) + } + if len(idsByChannel) == 0 { + return nil + } + // 跨频道一次批量取,消除「每个频道一条 SQL」的 N+1(getDialogs/getChannelDifference 多频道页放大)。 + pairChannels, pairMessages := channelMessagePairs(idsByChannel) + { + rows, err := db.Query(ctx, ` +SELECT channel_id, message_id, unread +FROM channel_unread_mentions +WHERE user_id = $1 + AND (channel_id, message_id) IN (SELECT * FROM unnest($2::bigint[], $3::int[]))`, viewerUserID, pairChannels, pairMessages) + if err != nil { + return fmt.Errorf("load channel message unread flags: %w", err) + } + for rows.Next() { + var channelID int64 + var messageID int + var unread bool + if err := rows.Scan(&channelID, &messageID, &unread); err != nil { + rows.Close() + return err + } + key := channelReactionMessageKey{channelID: channelID, messageID: messageID} + for _, idx := range indexes[key] { + // mentioned 永久保留(官方语义);客户端的未读提及判定要求 + // mentioned 与 media_unread 同时置位,media_unread 跟随 mention + // 的未读状态而非消息是否含媒体。 + messages[idx].Mentioned = true + messages[idx].MediaUnread = unread + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + } + return nil +} + +func (s *ChannelStore) channelReadWatermarks(ctx context.Context, channelID, userID int64) (int, int) { + var inbox, outbox int + _ = s.db.QueryRow(ctx, `SELECT read_inbox_max_id, read_outbox_max_id FROM channel_members WHERE channel_id = $1 AND user_id = $2`, channelID, userID).Scan(&inbox, &outbox) + return inbox, outbox +} + +func (s *ChannelStore) channelThreadUnreadCount(ctx context.Context, channelID int64, rootID int, viewerUserID int64, readMaxID int) int { + var count int + // LIMIT 子查询把 thread 未读 COUNT 钳到上界(P1-v):限定扫描工作量与下发角标。 + _ = s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM ( + SELECT 1 + FROM channel_messages + WHERE channel_id = $1 AND reply_to_top_id = $2 AND id > $3 AND sender_user_id <> $4 AND NOT deleted + LIMIT $5 +) thread_unread_capped`, channelID, rootID, readMaxID, viewerUserID, domain.MaxDialogUnreadCount).Scan(&count) + return count +} + +func countChannelUnreadMessages(ctx context.Context, db sqlcgen.DBTX, userID, channelID int64, readMaxID, topID int) (int, error) { + if userID == 0 || channelID == 0 || topID <= readMaxID { + return 0, nil + } + var count int + // LIMIT 子查询把未读 COUNT 钳到上界(P1-v):该值会写回 channel_dialogs.unread_count 缓存, + // 钳制既限定扫描工作量也限定缓存列上界,与动态/可见投影口径一致。 + if err := db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM ( + SELECT 1 + FROM channel_messages + WHERE channel_id = $1 + AND id > $2 + AND id <= $3 + AND sender_user_id <> $4 + AND NOT deleted + LIMIT $5 +) unread_capped`, channelID, readMaxID, topID, userID, domain.MaxDialogUnreadCount).Scan(&count); err != nil { + return 0, fmt.Errorf("count channel unread messages: %w", err) + } + return count, nil +} + +func (s *ChannelStore) countChannelUnreadMentionsForTop(ctx context.Context, userID, channelID int64, topMsgID int) int { + var count int + _ = s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_unread_mentions +WHERE user_id = $1 AND channel_id = $2 AND unread + AND (top_message_id = $3 OR ($3 = 1 AND top_message_id = 0))`, userID, channelID, topMsgID).Scan(&count) + return count +} + +func (s *ChannelStore) countChannelUnreadMentions(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID int) (int, error) { + where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_unread_mentions um +JOIN channel_messages cm ON cm.channel_id = um.channel_id AND cm.id = um.message_id +WHERE `+where, args...).Scan(&count); err != nil { + return 0, fmt.Errorf("count channel unread mentions: %w", err) + } + return count, nil +} + +func (s *ChannelStore) queryChannelUnreadMentionsPage(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + switch messageHistoryLoadType(filter.AddOffset, limit) { + case messageHistoryLoadForward: + return s.queryChannelUnreadMentionsForward(ctx, userID, filter, availableMinID, limit) + case messageHistoryLoadAround: + forwardLimit := -filter.AddOffset + if forwardLimit > limit { + forwardLimit = limit + } + backwardLimit := limit + filter.AddOffset + if backwardLimit < 0 { + backwardLimit = 0 + } + forward, err := s.queryChannelUnreadMentionsForward(ctx, userID, filter, availableMinID, forwardLimit) + if err != nil { + return nil, err + } + backward, err := s.queryChannelUnreadMentionsBackward(ctx, userID, filter, availableMinID, backwardLimit, true) + if err != nil { + return nil, err + } + out := append(forward, backward...) + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil + default: + start := filter.AddOffset + if start < 0 { + start = 0 + } + items, err := s.queryChannelUnreadMentionsBackward(ctx, userID, filter, availableMinID, limit+start, false) + if err != nil || start >= len(items) { + return nil, err + } + return items[start:], nil + } +} + +func (s *ChannelStore) queryChannelUnreadMentionsBackward(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) + where, args = appendChannelUnreadMentionBackwardOffset(where, args, filter, includeOffset) + args = append(args, limit) + return s.queryChannelUnreadMentions(ctx, filter.ChannelID, where, args, "DESC") +} + +func (s *ChannelStore) queryChannelUnreadMentionsForward(ctx context.Context, userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelUnreadMentionBaseWhere(userID, filter, availableMinID) + where, args = appendChannelUnreadMentionForwardOffset(where, args, filter) + args = append(args, limit) + out, err := s.queryChannelUnreadMentions(ctx, filter.ChannelID, where, args, "ASC") + if err != nil { + return nil, err + } + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil +} + +func (s *ChannelStore) queryChannelUnreadMentions(ctx context.Context, channelID int64, where string, args []any, order string) ([]domain.ChannelMessage, error) { + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE channel_id = $2 + AND id = ANY(ARRAY( + SELECT cm.id + FROM channel_unread_mentions um + JOIN channel_messages cm ON cm.channel_id = um.channel_id AND cm.id = um.message_id + WHERE `+where+` + ORDER BY cm.id `+order+` + LIMIT $`+fmt.Sprint(len(args))+` + )::int[]) +ORDER BY id `+order, args...) + _ = channelID + if err != nil { + return nil, fmt.Errorf("list channel unread mentions: %w", err) + } + defer rows.Close() + out := make([]domain.ChannelMessage, 0) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return nil, err + } + out = append(out, msg) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func channelUnreadMentionBaseWhere(userID int64, filter domain.ChannelUnreadMentionsFilter, availableMinID int) (string, []any) { + args := []any{userID, filter.ChannelID} + where := "um.user_id = $1 AND um.channel_id = $2 AND um.unread AND NOT cm.deleted" + if availableMinID > 0 { + args = append(args, availableMinID) + where += fmt.Sprintf(" AND cm.id > $%d", len(args)) + } + if filter.TopMsgID > 0 { + args = append(args, filter.TopMsgID) + // forum General(root=1)的无主题消息存储 top=0。 + where += fmt.Sprintf(" AND (um.top_message_id = $%d OR ($%d = 1 AND um.top_message_id = 0))", len(args), len(args)) + } + if filter.MaxID > 0 { + args = append(args, filter.MaxID) + where += fmt.Sprintf(" AND cm.id < $%d", len(args)) + } + if filter.MinID > 0 { + args = append(args, filter.MinID) + where += fmt.Sprintf(" AND cm.id > $%d", len(args)) + } + return where, args +} + +func appendChannelUnreadMentionBackwardOffset(where string, args []any, filter domain.ChannelUnreadMentionsFilter, include bool) (string, []any) { + if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + if include { + return where + fmt.Sprintf(" AND cm.message_date <= $%d", len(args)), args + } + return where + fmt.Sprintf(" AND cm.message_date < $%d", len(args)), args + } + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + if include { + return where + fmt.Sprintf(" AND cm.id <= $%d", len(args)), args + } + return where + fmt.Sprintf(" AND cm.id < $%d", len(args)), args + } + return where, args +} + +func appendChannelUnreadMentionForwardOffset(where string, args []any, filter domain.ChannelUnreadMentionsFilter) (string, []any) { + if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + return where + fmt.Sprintf(" AND cm.message_date >= $%d", len(args)), args + } + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + return where + fmt.Sprintf(" AND cm.id > $%d", len(args)), args + } + return where, args +} + +func insertChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int64, msg domain.ChannelMessage, senderUserID int64, userIDs []int64) error { + candidates := uniqueChannelUserIDs(userIDs, senderUserID) + if len(candidates) == 0 || msg.ID == 0 { + return nil + } + if len(candidates) > domain.MaxChannelMentionRecipients { + candidates = candidates[:domain.MaxChannelMentionRecipients] + } + topID := channelMentionTopID(msg) + mediaUnread := !msg.Media.IsZero() + if _, err := tx.Exec(ctx, ` +WITH input(user_id) AS ( + SELECT DISTINCT unnest($4::bigint[]) +), +active AS ( + SELECT i.user_id + FROM input i + JOIN channel_members m ON m.channel_id = $1 AND m.user_id = i.user_id + WHERE m.status = 'active' + AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) + AND $2 > m.available_min_id + AND $2 > m.read_inbox_max_id + LIMIT $6 +), +inserted AS ( + INSERT INTO channel_unread_mentions (user_id, channel_id, message_id, top_message_id, media_unread) + SELECT user_id, $1, $2, $3, $7 + FROM active + ON CONFLICT DO NOTHING + RETURNING user_id, channel_id, message_id, created_at +), +indexed AS ( + INSERT INTO channel_unread_mention_index (channel_id, message_id, user_id, created_at) + SELECT channel_id, message_id, user_id, created_at + FROM inserted + ON CONFLICT DO NOTHING +) +INSERT INTO channel_dialogs ( + user_id, channel_id, top_message_id, top_message_date, + read_inbox_max_id, read_outbox_max_id, unread_count, unread_mentions_count +) +SELECT i.user_id, $1, $2, $5, + m.read_inbox_max_id, m.read_outbox_max_id, + ( + SELECT COUNT(*)::int + FROM channel_messages cm + WHERE cm.channel_id = $1 + AND cm.id > GREATEST(m.read_inbox_max_id, m.available_min_id) + AND NOT cm.deleted + AND cm.sender_user_id <> i.user_id + ), + 1 +FROM inserted i +JOIN channel_members m ON m.channel_id = $1 AND m.user_id = i.user_id +ON CONFLICT (user_id, channel_id) DO UPDATE SET + top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), + top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), + unread_mentions_count = channel_dialogs.unread_mentions_count + 1, + updated_at = now()`, channelID, msg.ID, topID, candidates, msg.Date, domain.MaxChannelMentionRecipients, mediaUnread); err != nil { + return fmt.Errorf("insert channel unread mentions: %w", err) + } + return nil +} + +func readChannelMentionsTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, topMsgID, limit int) (int, int, error) { + var cleared, remaining int + if err := tx.QueryRow(ctx, ` +WITH target AS ( + SELECT user_id, channel_id, message_id + FROM channel_unread_mentions + WHERE user_id = $1 + AND channel_id = $2 + AND unread + AND ($3 = 0 OR top_message_id = $3 OR ($3 = 1 AND top_message_id = 0)) + ORDER BY message_id DESC + LIMIT $4 +), +deleted AS ( + -- 已读=翻转标记而非删行:mentioned 高亮在历史回放中永久保留。 + UPDATE channel_unread_mentions um + SET unread = false + FROM target t + WHERE um.user_id = t.user_id + AND um.channel_id = t.channel_id + AND um.message_id = t.message_id + RETURNING um.user_id, um.channel_id, um.message_id +), +scoped_before AS ( + SELECT COUNT(*)::int AS count + FROM channel_unread_mentions + WHERE user_id = $1 + AND channel_id = $2 + AND unread + AND ($3 = 0 OR top_message_id = $3 OR ($3 = 1 AND top_message_id = 0)) +), +all_before AS ( + SELECT COUNT(*)::int AS count + FROM channel_unread_mentions + WHERE user_id = $1 AND channel_id = $2 AND unread +), +deleted_count AS ( + SELECT COUNT(*)::int AS count FROM deleted +), +updated_dialog AS ( + UPDATE channel_dialogs + SET unread_mentions_count = GREATEST((SELECT count FROM all_before) - (SELECT count FROM deleted_count), 0), + updated_at = now() + WHERE user_id = $1 AND channel_id = $2 +) +SELECT + (SELECT count FROM deleted_count), + GREATEST((SELECT count FROM scoped_before) - (SELECT count FROM deleted_count), 0)`, userID, channelID, topMsgID, limit).Scan(&cleared, &remaining); err != nil { + return 0, 0, fmt.Errorf("read channel mentions: %w", err) + } + return cleared, remaining, nil +} + +func deleteChannelUnreadMentionsTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) error { + if len(ids) == 0 { + return nil + } + affected, err := channelUnreadMentionAffectedUsersTx(ctx, tx, channelID, ids) + if err != nil { + return err + } + for start := 0; start < len(affected); start += channelUnreadMentionDeleteUserBatch { + end := start + channelUnreadMentionDeleteUserBatch + if end > len(affected) { + end = len(affected) + } + if err := deleteChannelUnreadMentionsForUsersTx(ctx, tx, channelID, ids, affected[start:end]); err != nil { + return err + } + } + return nil +} + +const channelUnreadMentionDeleteUserBatch = 1000 + +func channelUnreadMentionAffectedUsersTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int) ([]int64, error) { + rows, err := tx.Query(ctx, ` +SELECT DISTINCT user_id +FROM channel_unread_mention_index +WHERE channel_id = $1 + AND message_id = ANY($2::int[]) +ORDER BY user_id ASC`, channelID, int32s(ids)) + if err != nil { + return nil, fmt.Errorf("list channel unread mention affected users: %w", err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var userID int64 + if err := rows.Scan(&userID); err != nil { + return nil, fmt.Errorf("scan channel unread mention affected user: %w", err) + } + out = append(out, userID) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read channel unread mention affected users: %w", err) + } + return out, nil +} + +func deleteChannelUnreadMentionsForUsersTx(ctx context.Context, tx pgx.Tx, channelID int64, ids []int, userIDs []int64) error { + if len(ids) == 0 || len(userIDs) == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +WITH affected AS ( + SELECT DISTINCT unnest($3::bigint[]) AS user_id +), +deleted_mentions AS ( + DELETE FROM channel_unread_mentions um + USING affected a + WHERE um.user_id = ANY($3::bigint[]) + AND um.user_id = a.user_id + AND um.channel_id = $1 + AND um.message_id = ANY($2::int[]) + RETURNING um.user_id, um.channel_id, um.message_id, um.unread +), +deleted_index AS ( + DELETE FROM channel_unread_mention_index i + USING affected a + WHERE i.channel_id = $1 + AND i.user_id = a.user_id + AND i.message_id = ANY($2::int[]) +), +counts_before AS ( + SELECT user_id, COUNT(*)::int AS count + FROM channel_unread_mentions + WHERE channel_id = $1 + AND user_id = ANY($3::bigint[]) + AND unread + GROUP BY user_id +), +deleted_counts AS ( + SELECT user_id, COUNT(*)::int AS count + FROM deleted_mentions + WHERE unread + GROUP BY user_id +) +UPDATE channel_dialogs d +SET unread_mentions_count = GREATEST(COALESCE(c.count, 0) - COALESCE(dc.count, 0), 0), + updated_at = now() +FROM affected a +LEFT JOIN counts_before c ON c.user_id = a.user_id +LEFT JOIN deleted_counts dc ON dc.user_id = a.user_id +WHERE d.user_id = ANY($3::bigint[]) + AND d.channel_id = $1 + AND d.user_id = a.user_id`, channelID, int32s(ids), int64s(userIDs)); err != nil { + return fmt.Errorf("delete channel unread mentions: %w", err) + } + return nil +} + +func deleteChannelUnreadMentionsUpToTx(ctx context.Context, tx pgx.Tx, userID, channelID int64, maxID int) error { + if maxID <= 0 { + return nil + } + var deleted int + if err := tx.QueryRow(ctx, ` +WITH deleted AS ( + DELETE FROM channel_unread_mentions + WHERE user_id = $1 AND channel_id = $2 AND message_id <= $3 + RETURNING user_id, channel_id, message_id, unread +), +deleted_index AS ( + DELETE FROM channel_unread_mention_index i + USING deleted d + WHERE i.channel_id = d.channel_id + AND i.user_id = d.user_id + AND i.message_id = d.message_id +), +all_before AS ( + SELECT COUNT(*)::int AS count + FROM channel_unread_mentions + WHERE user_id = $1 AND channel_id = $2 AND unread +), +deleted_count AS ( + SELECT COUNT(*)::int AS count FROM deleted WHERE unread +), +updated_dialog AS ( + UPDATE channel_dialogs + SET unread_mentions_count = GREATEST((SELECT count FROM all_before) - (SELECT count FROM deleted_count), 0), + updated_at = now() + WHERE user_id = $1 AND channel_id = $2 +) +SELECT count FROM deleted_count`, userID, channelID, maxID).Scan(&deleted); err != nil { + return fmt.Errorf("delete channel unread mentions up to: %w", err) + } + return nil +} + +func channelMentionTopID(msg domain.ChannelMessage) int { + if msg.ReplyTo == nil { + return 0 + } + if msg.ReplyTo.TopMessageID > 0 { + return msg.ReplyTo.TopMessageID + } + return msg.ReplyTo.MessageID +} + +// clearChannelMentionsForUserTx 在成员离开/被踢时清空其该频道的全部提及 +// 状态,避免重新加入后出现指向入群前消息的 @ 角标。 +func clearChannelMentionsForUserTx(ctx context.Context, tx pgx.Tx, channelID, userID int64) error { + if channelID == 0 || userID == 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +WITH deleted AS ( + DELETE FROM channel_unread_mentions + WHERE user_id = $2 AND channel_id = $1 + RETURNING message_id +), +deleted_index AS ( + DELETE FROM channel_unread_mention_index i + USING deleted d + WHERE i.channel_id = $1 AND i.user_id = $2 AND i.message_id = d.message_id +) +UPDATE channel_dialogs +SET unread_mentions_count = 0, updated_at = now() +WHERE user_id = $2 AND channel_id = $1`, channelID, userID); err != nil { + return fmt.Errorf("clear channel mentions on leave: %w", err) + } + return nil +} diff --git a/internal/store/postgres/channel_read_integration_test.go b/internal/store/postgres/channel_read_integration_test.go new file mode 100644 index 00000000..0b7c8b72 --- /dev/null +++ b/internal/store/postgres/channel_read_integration_test.go @@ -0,0 +1,1188 @@ +package postgres + +import ( + "context" + "reflect" + "telesrv/internal/domain" + "testing" + "time" +) + +func TestChannelStoreReadOutboxDoesNotRegressSenderDialogUnread(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 35, + Phone: "+1777" + suffix + "17", + FirstName: "ReadOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 36, + Phone: "+1777" + suffix + "18", + FirstName: "ReadMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Read Outbox " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000340, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + ownerMsg, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9341, + Message: "owner message before member reply", + Date: 1700000341, + }) + if err != nil { + t.Fatalf("send owner message: %v", err) + } + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: ownerMsg.Message.ID, + Date: 1700000342, + }); err != nil { + t.Fatalf("member read owner message: %v", err) + } + memberMsg, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: member.ID, + ChannelID: channelID, + RandomID: 9342, + Message: "member reply should stay read for sender", + Date: 1700000343, + }) + if err != nil { + t.Fatalf("send member message: %v", err) + } + + var storedReadInbox, storedUnread int + if err := pool.QueryRow(ctx, ` +SELECT read_inbox_max_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadInbox, &storedUnread); err != nil { + t.Fatalf("read member dialog after self send: %v", err) + } + if storedReadInbox != memberMsg.Message.ID || storedUnread != 0 { + t.Fatalf("member dialog after self send read=%d unread=%d, want read %d unread 0", storedReadInbox, storedUnread, memberMsg.Message.ID) + } + + read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: owner.ID, + ChannelID: channelID, + MaxID: memberMsg.Message.ID, + Date: 1700000344, + }) + if err != nil { + t.Fatalf("owner read member message: %v", err) + } + if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != member.ID || read.OutboxUpdates[0].MaxID != memberMsg.Message.ID { + t.Fatalf("read outbox updates = %+v, want member max id %d", read.OutboxUpdates, memberMsg.Message.ID) + } + if err := pool.QueryRow(ctx, ` +SELECT read_inbox_max_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadInbox, &storedUnread); err != nil { + t.Fatalf("read member dialog after owner read: %v", err) + } + if storedReadInbox != memberMsg.Message.ID || storedUnread != 0 { + t.Fatalf("member dialog after owner read read=%d unread=%d, want read %d unread 0", storedReadInbox, storedUnread, memberMsg.Message.ID) + } +} + +func TestChannelStoreReadHistoryNoopDoesNotRewriteDialogState(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 351, + Phone: "+1777" + suffix + "41", + FirstName: "ReadNoopOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 352, + Phone: "+1777" + suffix + "42", + FirstName: "ReadNoopMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Read Noop " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000440, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9441, + Message: "read once then repeat", + Date: 1700000441, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + first, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: sent.Message.ID, + Date: 1700000442, + }) + if err != nil { + t.Fatalf("first read history: %v", err) + } + if !first.Changed { + t.Fatalf("first read changed = false, want true") + } + + stableUpdatedAt := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + if _, err := pool.Exec(ctx, ` +UPDATE channel_members +SET updated_at = $3, unread_mark = false +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID, stableUpdatedAt); err != nil { + t.Fatalf("stabilize member updated_at: %v", err) + } + if _, err := pool.Exec(ctx, ` +UPDATE channel_dialogs +SET updated_at = $3, unread_mark = false +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID, stableUpdatedAt); err != nil { + t.Fatalf("stabilize dialog updated_at: %v", err) + } + + repeat, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: sent.Message.ID, + Date: 1700000443, + }) + if err != nil { + t.Fatalf("repeat read history: %v", err) + } + if repeat.Changed { + t.Fatalf("repeat read changed = true, want false") + } + var memberUpdatedAt, dialogUpdatedAt time.Time + if err := pool.QueryRow(ctx, ` +SELECT updated_at +FROM channel_members +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&memberUpdatedAt); err != nil { + t.Fatalf("read member updated_at: %v", err) + } + if err := pool.QueryRow(ctx, ` +SELECT updated_at +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&dialogUpdatedAt); err != nil { + t.Fatalf("read dialog updated_at: %v", err) + } + if !memberUpdatedAt.Equal(stableUpdatedAt) { + t.Fatalf("member updated_at = %v, want no-op timestamp %v", memberUpdatedAt, stableUpdatedAt) + } + if !dialogUpdatedAt.Equal(stableUpdatedAt) { + t.Fatalf("dialog updated_at = %v, want no-op timestamp %v", dialogUpdatedAt, stableUpdatedAt) + } +} + +func TestChannelStoreChannelUnreadExcludesOwnOutgoing(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 39, + Phone: "+1777" + suffix + "19", + FirstName: "OwnUnreadOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Own Unread " + suffix, + Megagroup: true, + Date: 1700000350, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9351, + Message: "own outgoing should not be unread", + Date: 1700000351, + }) + if err != nil { + t.Fatalf("send owner message: %v", err) + } + readBeforeOwnMessage := sent.Message.ID - 1 + if _, err := pool.Exec(ctx, ` +UPDATE channel_members +SET read_inbox_max_id = $3, unread_mark = false +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID, readBeforeOwnMessage); err != nil { + t.Fatalf("regress owner member read watermark: %v", err) + } + if _, err := pool.Exec(ctx, ` +UPDATE channel_dialogs +SET read_inbox_max_id = $3, unread_count = 0, unread_mark = false +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID, readBeforeOwnMessage); err != nil { + t.Fatalf("regress owner dialog unread: %v", err) + } + + dialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get owner channel dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 { + t.Fatalf("dialogs = %+v, want one dialog", dialogs.Dialogs) + } + if dialogs.Dialogs[0].UnreadCount != 0 { + t.Fatalf("owner dialog unread = %d, want own outgoing excluded", dialogs.Dialogs[0].UnreadCount) + } + unreadOnly, err := channels.ListChannelDialogs(ctx, owner.ID, domain.DialogFilter{ + Folder: &domain.DialogFolder{ExcludeRead: true, Groups: true}, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread-only channel dialogs: %v", err) + } + for _, dialog := range unreadOnly.Dialogs { + if dialog.Peer.ID == channelID { + t.Fatalf("unread-only dialogs include own-outgoing-only channel: %+v", unreadOnly.Dialogs) + } + } + if _, err := pool.Exec(ctx, ` +UPDATE channel_dialogs +SET unread_count = 99 +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID); err != nil { + t.Fatalf("corrupt owner dialog unread before read repair: %v", err) + } + + read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: owner.ID, + ChannelID: channelID, + MaxID: sent.Message.ID, + Date: 1700000352, + }) + if err != nil { + t.Fatalf("read owner channel history: %v", err) + } + if read.StillUnreadCount != 0 || read.Dialog.UnreadCount != 0 { + t.Fatalf("read result = %+v, want no unread own outgoing messages", read) + } + var storedUnread int + if err := pool.QueryRow(ctx, ` +SELECT unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&storedUnread); err != nil { + t.Fatalf("read stored owner unread: %v", err) + } + if storedUnread != 0 { + t.Fatalf("stored owner unread = %d, want repaired to 0", storedUnread) + } +} + +func TestChannelStoreBroadcastUnreadDerivesDespiteStaleCache(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 421, + Phone: "+1777" + suffix + "33", + FirstName: "BroadcastUnreadOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 422, + Phone: "+1777" + suffix + "34", + FirstName: "BroadcastUnreadMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Broadcast Unread " + suffix, + Broadcast: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000333, + }) + if err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000334, + }); err != nil { + t.Fatalf("read initial broadcast service message: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9333, + Message: "broadcast unread derives despite stale cache", + Date: 1700000335, + }) + if err != nil { + t.Fatalf("send broadcast message: %v", err) + } + + var storedUnread int + if err := pool.QueryRow(ctx, ` +SELECT unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedUnread); err != nil { + t.Fatalf("read stale broadcast dialog cache: %v", err) + } + if storedUnread != 0 { + t.Fatalf("stored broadcast unread cache = %d, want no send fanout", storedUnread) + } + + list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list broadcast channel dialogs: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != sent.Message.ID || list.Dialogs[0].UnreadCount != 1 { + t.Fatalf("broadcast dialogs = %+v, want sent top and dynamic unread=1", list.Dialogs) + } + + unreadOnly, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{ + Folder: &domain.DialogFolder{ExcludeRead: true, Broadcasts: true}, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread-only broadcast dialogs: %v", err) + } + if len(unreadOnly.Dialogs) != 1 || unreadOnly.Dialogs[0].Peer.ID != channelID { + t.Fatalf("unread-only broadcast dialogs = %+v, want stale-cache channel included", unreadOnly.Dialogs) + } + + view, err := channels.GetChannel(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("get broadcast channel: %v", err) + } + if view.Dialog.UnreadCount != 1 || view.Dialog.TopMessageID != sent.Message.ID { + t.Fatalf("broadcast view dialog = %+v, want dynamic unread=1", view.Dialog) + } + dialogs, err := channels.GetChannelDialogs(ctx, member.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get broadcast channel dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 1 { + t.Fatalf("get broadcast dialogs = %+v, want dynamic unread=1", dialogs.Dialogs) + } +} + +func TestChannelStoreLargeMegagroupUnreadDerivesDespiteStaleCache(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 431, + Phone: "+1777" + suffix + "35", + FirstName: "LargeUnreadOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 432, + Phone: "+1777" + suffix + "36", + FirstName: "LargeUnreadMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Large Unread " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000336, + }) + if err != nil { + t.Fatalf("create large megagroup: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000337, + }); err != nil { + t.Fatalf("read initial large service message: %v", err) + } + if _, err := pool.Exec(ctx, ` +UPDATE channels +SET participants_count = $2 +WHERE id = $1`, channelID, domain.MaxSynchronousChannelDialogFanout+1); err != nil { + t.Fatalf("mark megagroup as over synchronous fanout threshold: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9336, + Message: "large megagroup unread derives despite stale cache", + Date: 1700000338, + }) + if err != nil { + t.Fatalf("send large megagroup message: %v", err) + } + + var storedTop, storedUnread int + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedTop, &storedUnread); err != nil { + t.Fatalf("read stale large dialog cache: %v", err) + } + if storedTop == sent.Message.ID || storedUnread != 0 { + t.Fatalf("stored large dialog cache top=%d unread=%d, want stale top and unread=0", storedTop, storedUnread) + } + + list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list large channel dialogs: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != sent.Message.ID || list.Dialogs[0].UnreadCount != 1 { + t.Fatalf("large dialogs = %+v, want sent top and dynamic unread=1", list.Dialogs) + } + ownerView, err := channels.GetChannel(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("get large channel for sender: %v", err) + } + if ownerView.Dialog.UnreadCount != 0 { + t.Fatalf("large sender dialog = %+v, want own outgoing excluded from dynamic unread", ownerView.Dialog) + } + + unreadOnly, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{ + Folder: &domain.DialogFolder{ExcludeRead: true, Groups: true}, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread-only large dialogs: %v", err) + } + if len(unreadOnly.Dialogs) != 1 || unreadOnly.Dialogs[0].Peer.ID != channelID { + t.Fatalf("unread-only large dialogs = %+v, want stale-cache channel included", unreadOnly.Dialogs) + } + + view, err := channels.GetChannel(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("get large channel: %v", err) + } + if view.Dialog.UnreadCount != 1 || view.Dialog.TopMessageID != sent.Message.ID { + t.Fatalf("large view dialog = %+v, want dynamic unread=1", view.Dialog) + } + dialogs, err := channels.GetChannelDialogs(ctx, member.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get large channel dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadCount != 1 { + t.Fatalf("get large dialogs = %+v, want dynamic unread=1", dialogs.Dialogs) + } + + cleared, err := channels.DeleteChannelHistory(ctx, domain.DeleteChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: sent.Message.ID, + Date: 1700000339, + }) + if err != nil { + t.Fatalf("local clear large history: %v", err) + } + if cleared.AvailableMinID != sent.Message.ID { + t.Fatalf("large local clear available_min_id = %d, want %d", cleared.AvailableMinID, sent.Message.ID) + } + afterClear, err := channels.GetChannel(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("get large channel after local clear: %v", err) + } + if afterClear.Dialog.TopMessageID != 0 || afterClear.Dialog.UnreadCount != 0 { + t.Fatalf("large dialog after local clear = %+v, want no visible unread top", afterClear.Dialog) + } +} + +func TestChannelStoreLargeMegagroupUnreadSkipsDeletedHole(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 441, + Phone: "+1777" + suffix + "37", + FirstName: "DeletedHoleOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 442, + Phone: "+1777" + suffix + "38", + FirstName: "DeletedHoleMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Deleted Hole " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000339, + }) + if err != nil { + t.Fatalf("create deleted-hole megagroup: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000340, + }); err != nil { + t.Fatalf("read initial deleted-hole service message: %v", err) + } + if _, err := pool.Exec(ctx, ` +UPDATE channels +SET participants_count = $2 +WHERE id = $1`, channelID, domain.MaxSynchronousChannelDialogFanout+1); err != nil { + t.Fatalf("mark deleted-hole megagroup over threshold: %v", err) + } + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9339, + Message: "deleted unread hole", + Date: 1700000341, + }) + if err != nil { + t.Fatalf("send first large message: %v", err) + } + second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9340, + Message: "remaining unread message", + Date: 1700000342, + }) + if err != nil { + t.Fatalf("send second large message: %v", err) + } + deleted, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: owner.ID, + ChannelID: channelID, + IDs: []int{first.Message.ID}, + Date: 1700000343, + }) + if err != nil { + t.Fatalf("delete non-top unread message: %v", err) + } + if len(deleted.DeletedIDs) != 1 || deleted.DeletedIDs[0] != first.Message.ID { + t.Fatalf("deleted ids = %+v, want first message only", deleted.DeletedIDs) + } + + list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list deleted-hole dialogs: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != second.Message.ID || list.Dialogs[0].UnreadCount != 1 { + t.Fatalf("deleted-hole dialogs = %+v, want only non-deleted unread top counted", list.Dialogs) + } +} + +func TestChannelStoreSmallMegagroupDeleteRefreshesDialogUnreadCache(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 451, + Phone: "+1777" + suffix + "39", + FirstName: "SmallDeleteOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 452, + Phone: "+1777" + suffix + "40", + FirstName: "SmallDeleteMember", + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Small Delete Cache " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000344, + }) + if err != nil { + t.Fatalf("create small megagroup: %v", err) + } + channelID = created.Channel.ID + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + MaxID: created.Message.ID, + Date: 1700000345, + }); err != nil { + t.Fatalf("read initial service message: %v", err) + } + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9341, + Message: "remaining first unread", + Date: 1700000346, + }) + if err != nil { + t.Fatalf("send first message: %v", err) + } + second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9342, + Message: "deleted middle unread", + Date: 1700000347, + }) + if err != nil { + t.Fatalf("send second message: %v", err) + } + third, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9343, + Message: "deleted top unread", + Date: 1700000348, + }) + if err != nil { + t.Fatalf("send third message: %v", err) + } + + var cachedTop, cachedUnread int + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, &cachedUnread); err != nil { + t.Fatalf("read cached dialog before delete: %v", err) + } + if cachedTop != third.Message.ID || cachedUnread != 3 { + t.Fatalf("cached dialog before delete top=%d unread=%d, want top %d unread 3", cachedTop, cachedUnread, third.Message.ID) + } + + if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: owner.ID, + ChannelID: channelID, + IDs: []int{second.Message.ID}, + Date: 1700000349, + }); err != nil { + t.Fatalf("delete middle unread message: %v", err) + } + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, &cachedUnread); err != nil { + t.Fatalf("read cached dialog after middle delete: %v", err) + } + if cachedTop != third.Message.ID || cachedUnread != 2 { + t.Fatalf("cached dialog after middle delete top=%d unread=%d, want top %d unread 2", cachedTop, cachedUnread, third.Message.ID) + } + list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list dialogs after middle delete: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != third.Message.ID || list.Dialogs[0].UnreadCount != 2 { + t.Fatalf("dialogs after middle delete = %+v, want top third unread 2", list.Dialogs) + } + + if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: owner.ID, + ChannelID: channelID, + IDs: []int{third.Message.ID}, + Date: 1700000350, + }); err != nil { + t.Fatalf("delete top unread message: %v", err) + } + if err := pool.QueryRow(ctx, ` +SELECT top_message_id, unread_count +FROM channel_dialogs +WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, &cachedUnread); err != nil { + t.Fatalf("read cached dialog after top delete: %v", err) + } + if cachedTop != first.Message.ID || cachedUnread != 1 { + t.Fatalf("cached dialog after top delete top=%d unread=%d, want top %d unread 1", cachedTop, cachedUnread, first.Message.ID) + } + list, err = channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list dialogs after top delete: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].TopMessage != first.Message.ID || list.Dialogs[0].UnreadCount != 1 { + t.Fatalf("dialogs after top delete = %+v, want top first unread 1", list.Dialogs) + } + if len(list.Messages) != 1 || list.Messages[0].ID != first.Message.ID { + t.Fatalf("dialog messages after top delete = %+v, want first message", list.Messages) + } +} + +func TestChannelStoreDeleteMessagesMaintainsUnreadMentionIndex(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 453, + Phone: "+1777" + suffix + "41", + FirstName: "MentionDeleteOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{ + AccessHash: 454, + Phone: "+1777" + suffix + "42", + FirstName: "MentionDeleteMember", + Username: "mention_delete_" + suffix, + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Mention Delete " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000351, + }) + if err != nil { + t.Fatalf("create mention delete channel: %v", err) + } + channelID = created.Channel.ID + + first, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9352, + Message: "first mention", + MentionUserIDs: []int64{member.ID}, + Date: 1700000352, + }) + if err != nil { + t.Fatalf("send first mention: %v", err) + } + second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9353, + Message: "second mention", + MentionUserIDs: []int64{member.ID}, + Date: 1700000353, + }) + if err != nil { + t.Fatalf("send second mention: %v", err) + } + plain, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9354, + Message: "plain message with stale mention index only", + Date: 1700000354, + }) + if err != nil { + t.Fatalf("send plain message: %v", err) + } + requireUnreadMentionRows := func(messageID int, wantMentionRows, wantIndexRows int) { + t.Helper() + var mentionRows, indexRows int + if err := pool.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_unread_mentions +WHERE user_id = $1 AND channel_id = $2 AND message_id = $3`, member.ID, channelID, messageID).Scan(&mentionRows); err != nil { + t.Fatalf("count mention rows for %d: %v", messageID, err) + } + if err := pool.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_unread_mention_index +WHERE channel_id = $1 AND message_id = $2 AND user_id = $3`, channelID, messageID, member.ID).Scan(&indexRows); err != nil { + t.Fatalf("count mention index rows for %d: %v", messageID, err) + } + if mentionRows != wantMentionRows || indexRows != wantIndexRows { + t.Fatalf("mention rows for msg %d = mention:%d index:%d, want mention:%d index:%d", messageID, mentionRows, indexRows, wantMentionRows, wantIndexRows) + } + } + requireDialogMentions := func(want int) { + t.Helper() + var got int + if err := pool.QueryRow(ctx, ` +SELECT unread_mentions_count +FROM channel_dialogs +WHERE user_id = $1 AND channel_id = $2`, member.ID, channelID).Scan(&got); err != nil { + t.Fatalf("read dialog mention count: %v", err) + } + if got != want { + t.Fatalf("dialog unread_mentions_count = %d, want %d", got, want) + } + } + + requireUnreadMentionRows(first.Message.ID, 1, 1) + requireUnreadMentionRows(second.Message.ID, 1, 1) + requireDialogMentions(2) + + if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: owner.ID, + ChannelID: channelID, + IDs: []int{first.Message.ID}, + Date: 1700000355, + }); err != nil { + t.Fatalf("delete first mention: %v", err) + } + requireUnreadMentionRows(first.Message.ID, 0, 0) + requireUnreadMentionRows(second.Message.ID, 1, 1) + requireDialogMentions(1) + mentions, err := channels.ListChannelUnreadMentions(ctx, member.ID, domain.ChannelUnreadMentionsFilter{ + ChannelID: channelID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread mentions after first delete: %v", err) + } + if len(mentions.Messages) != 1 || mentions.Messages[0].ID != second.Message.ID || !mentions.Messages[0].Mentioned { + t.Fatalf("unread mentions after first delete = %+v, want only second mentioned message", mentions.Messages) + } + + if _, err := pool.Exec(ctx, ` +INSERT INTO channel_unread_mention_index (channel_id, message_id, user_id) +VALUES ($1, $2, $3) +ON CONFLICT DO NOTHING`, channelID, plain.Message.ID, member.ID); err != nil { + t.Fatalf("insert stale mention index: %v", err) + } + requireUnreadMentionRows(plain.Message.ID, 0, 1) + if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: owner.ID, + ChannelID: channelID, + IDs: []int{plain.Message.ID}, + Date: 1700000356, + }); err != nil { + t.Fatalf("delete stale-index plain message: %v", err) + } + requireUnreadMentionRows(plain.Message.ID, 0, 0) + requireDialogMentions(1) + + if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: owner.ID, + ChannelID: channelID, + IDs: []int{second.Message.ID}, + Date: 1700000357, + }); err != nil { + t.Fatalf("delete second mention: %v", err) + } + requireUnreadMentionRows(second.Message.ID, 0, 0) + requireDialogMentions(0) +} + +func TestChannelStoreReadMessageContentsClearsVisibleUnreadReactions(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 81, + Phone: "+1891" + suffix + "01", + FirstName: "ReactionOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{ + AccessHash: 82, + Phone: "+1891" + suffix + "02", + FirstName: "ReactionFriend", + }) + if err != nil { + t.Fatalf("create friend: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, friend.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Visible Reaction " + suffix, + Megagroup: true, + MemberUserIDs: []int64{friend.ID}, + Date: 1700000900, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 90_001, + Message: "react to this", + Date: 1700000901, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ + UserID: friend.ID, + ChannelID: channelID, + MessageID: sent.Message.ID, + Reactions: []domain.MessageReaction{{ + Type: domain.MessageReactionEmoji, + Emoticon: "\U0001f525", + }}, + Date: 1700000902, + }); err != nil { + t.Fatalf("set channel reaction: %v", err) + } + dialogs, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get owner dialogs: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].UnreadReactions != 1 { + t.Fatalf("owner dialogs = %+v, want one unread reaction", dialogs.Dialogs) + } + unread, err := channels.ListChannelUnreadReactions(ctx, owner.ID, domain.ChannelUnreadReactionsFilter{ + ChannelID: channelID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread reactions: %v", err) + } + if len(unread.Messages) != 1 || unread.Messages[0].ID != sent.Message.ID || unread.Messages[0].Reactions == nil || !hasUnreadChannelReactionPG(*unread.Messages[0].Reactions) { + t.Fatalf("unread reactions = %+v, want unread sent message", unread.Messages) + } + + read, err := channels.ReadChannelMessageContents(ctx, domain.ReadChannelMessageContentsRequest{ + UserID: owner.ID, + ChannelID: channelID, + IDs: []int{sent.Message.ID}, + }) + if err != nil { + t.Fatalf("read channel message contents: %v", err) + } + if !reflect.DeepEqual(read.ClearedUnreadReactionMessageIDs, []int{sent.Message.ID}) { + t.Fatalf("cleared reaction ids = %+v, want [%d]", read.ClearedUnreadReactionMessageIDs, sent.Message.ID) + } + if len(read.Messages) != 1 || read.Messages[0].Reactions == nil || hasUnreadChannelReactionPG(*read.Messages[0].Reactions) { + t.Fatalf("read messages = %+v, want returned reaction marked read", read.Messages) + } + unreadAfter, err := channels.ListChannelUnreadReactions(ctx, owner.ID, domain.ChannelUnreadReactionsFilter{ + ChannelID: channelID, + Limit: 10, + }) + if err != nil { + t.Fatalf("list unread reactions after read contents: %v", err) + } + if len(unreadAfter.Messages) != 0 { + t.Fatalf("unread reactions after read contents = %+v, want empty", unreadAfter.Messages) + } + dialogsAfter, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get owner dialogs after read contents: %v", err) + } + if len(dialogsAfter.Dialogs) != 1 || dialogsAfter.Dialogs[0].UnreadReactions != 0 { + t.Fatalf("owner dialogs after read contents = %+v, want unread reactions 0", dialogsAfter.Dialogs) + } + var stillUnread bool + if err := pool.QueryRow(ctx, ` +SELECT unread +FROM channel_message_reactions +WHERE channel_id = $1 AND message_id = $2 AND reacted_user_id = $3`, + channelID, sent.Message.ID, friend.ID).Scan(&stillUnread); err != nil { + t.Fatalf("read reaction row: %v", err) + } + if stillUnread { + t.Fatal("reaction row still unread after read contents") + } +} + +func TestChannelReadOutboxDerivesFromPublicWatermark(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 45, Phone: "+1777" + suffix + "45", FirstName: "WmOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + sender, err := users.Create(ctx, domain.User{AccessHash: 46, Phone: "+1777" + suffix + "46", FirstName: "WmSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Watermark " + suffix, + Megagroup: true, + MemberUserIDs: []int64{sender.ID}, + Date: 1700000400, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: sender.ID, + ChannelID: created.Channel.ID, + RandomID: 4601, + Message: "read me", + Date: 1700000401, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + if _, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ + UserID: owner.ID, + ChannelID: created.Channel.ID, + MaxID: sent.Message.ID, + Date: 1700000402, + }); err != nil { + t.Fatalf("owner read history: %v", err) + } + + // 模拟实时 fanout 截断:抹掉 sender 的 per-member outbox 水位与 dialog + // 缓存,read_outbox 必须仍能从 channel 级公共水位派生出来。 + if _, err := pool.Exec(ctx, `UPDATE channel_members SET read_outbox_max_id = 0 WHERE channel_id = $1 AND user_id = $2`, created.Channel.ID, sender.ID); err != nil { + t.Fatalf("reset sender member outbox: %v", err) + } + if _, err := pool.Exec(ctx, `DELETE FROM channel_dialogs WHERE channel_id = $1 AND user_id = $2`, created.Channel.ID, sender.ID); err != nil { + t.Fatalf("reset sender dialog cache: %v", err) + } + + view, err := channels.GetChannel(ctx, sender.ID, created.Channel.ID) + if err != nil { + t.Fatalf("sender view: %v", err) + } + if view.Dialog.ReadOutboxMaxID != sent.Message.ID { + t.Fatalf("sender read_outbox = %d, want %d derived from public watermark even when fanout state is missing", view.Dialog.ReadOutboxMaxID, sent.Message.ID) + } + // top1 持有者(owner)自己派生用 top2:sender 未读过任何消息时回退 0, + // 不能把自己的已读水位当成对方的回执。 + ownerView, err := channels.GetChannel(ctx, owner.ID, created.Channel.ID) + if err != nil { + t.Fatalf("owner view: %v", err) + } + if ownerView.Dialog.ReadOutboxMaxID > ownerView.Dialog.ReadInboxMaxID { + t.Fatalf("owner read_outbox = %+v, must not exceed peers' actual reads", ownerView.Dialog) + } +} diff --git a/internal/store/postgres/channel_recent_repliers_integration_test.go b/internal/store/postgres/channel_recent_repliers_integration_test.go new file mode 100644 index 00000000..d8c1d174 --- /dev/null +++ b/internal/store/postgres/channel_recent_repliers_integration_test.go @@ -0,0 +1,100 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestChannelStoreRecentRepliersPostgres 回归 populateChannelMessageReplies:此前它只算 +// Replies/MaxID/RepliesPts,从不填 RecentRepliers(恒 nil),导致 Postgres(生产)下频道帖 +// 评论入口拿不到「最近回复者头像」——而 memory store 一直在填,属双 store 不一致。 +// 这里验证 Postgres 也按「各回复者最新一条回复 id 倒序、去重、取前 3」回填,与 memory 对齐。 +func TestChannelStoreRecentRepliersPostgres(t *testing.T) { + pool := testPool(t) // 未设 TELESRV_TEST_POSTGRES_DSN 会 t.Skip + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1779" + suffix + "01", FirstName: "ReplyStatOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + alice, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1779" + suffix + "02", FirstName: "ReplyStatAlice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := users.Create(ctx, domain.User{AccessHash: 63, Phone: "+1779" + suffix + "03", FirstName: "ReplyStatBob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, alice.ID, bob.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Recent Repliers " + suffix, + Megagroup: true, + MemberUserIDs: []int64{alice.ID, bob.ID}, + Date: 1700000800, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + root, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, ChannelID: channelID, RandomID: 9301, Message: "root post", Date: 1700000801, + }) + if err != nil { + t.Fatalf("send root: %v", err) + } + reply := func(u, rid int64, date int) { + t.Helper() + if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: u, ChannelID: channelID, RandomID: rid, Message: "c", + ReplyTo: &domain.MessageReply{MessageID: root.Message.ID}, Date: date, + }); err != nil { + t.Fatalf("reply from %d: %v", u, err) + } + } + reply(alice.ID, 9302, 1700000802) // alice 最旧 + reply(bob.ID, 9303, 1700000803) // bob 居中 + reply(alice.ID, 9304, 1700000804) // alice 最新 + + got, err := channels.GetChannelMessages(ctx, owner.ID, channelID, []int{root.Message.ID}) + if err != nil { + t.Fatalf("get channel messages: %v", err) + } + if len(got.Messages) != 1 { + t.Fatalf("get messages = %d, want 1", len(got.Messages)) + } + replies := got.Messages[0].Replies + if replies == nil { + t.Fatalf("root.Replies == nil, want reply stats") + } + if replies.Replies != 3 { + t.Fatalf("replies count = %d, want 3", replies.Replies) + } + // 去重 + newest-first:alice(最新一条 9304)排首、bob(9303)次之;alice 那条更旧的(9302) + // 因 alice 已出现被去重,故只剩两个去重回复者。 + want := []domain.Peer{ + {Type: domain.PeerTypeUser, ID: alice.ID}, + {Type: domain.PeerTypeUser, ID: bob.ID}, + } + if len(replies.RecentRepliers) != len(want) { + t.Fatalf("recent repliers = %+v, want %+v", replies.RecentRepliers, want) + } + for i := range want { + if replies.RecentRepliers[i] != want[i] { + t.Fatalf("recent repliers[%d] = %+v, want %+v (full=%+v)", i, replies.RecentRepliers[i], want[i], replies.RecentRepliers) + } + } +} diff --git a/internal/store/postgres/channel_recommendations_integration_test.go b/internal/store/postgres/channel_recommendations_integration_test.go new file mode 100644 index 00000000..42b40758 --- /dev/null +++ b/internal/store/postgres/channel_recommendations_integration_test.go @@ -0,0 +1,111 @@ +package postgres + +import ( + "context" + "fmt" + "testing" + + "telesrv/internal/domain" +) + +func TestChannelRecommendationsUsesBoundedCountAndMemberIndex(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 911, + Phone: "+1889" + suffix + "01", + FirstName: "RecommendOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + creator, err := users.Create(ctx, domain.User{ + AccessHash: 912, + Phone: "+1889" + suffix + "02", + FirstName: "RecommendCreator", + }) + if err != nil { + t.Fatalf("create creator: %v", err) + } + + var baseID int64 + if err := pool.QueryRow(ctx, `SELECT COALESCE(MAX(id), 0) + 1000 FROM channels`).Scan(&baseID); err != nil { + t.Fatalf("allocate channel id range: %v", err) + } + const total = domain.MaxChannelRecommendationsLimit + 5 + sourceID := baseID + joinedID := baseID + 1 + firstCandidateID := baseID + 2 + lastChannelID := firstCandidateID + total - 1 + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM user_channel_member_index WHERE user_id = $1 AND channel_id BETWEEN $2 AND $3", owner.ID, sourceID, lastChannelID) + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id BETWEEN $1 AND $2", sourceID, lastChannelID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, creator.ID}) + }) + + insertChannel := func(id int64, username string, participants, date int) { + t.Helper() + if _, err := pool.Exec(ctx, ` +INSERT INTO channels ( + id, access_hash, creator_user_id, title, username, broadcast, megagroup, + participants_count, admins_count, top_message_id, pts, date +) VALUES ($1, $2, $3, $4, $5, true, false, $6, 1, 1, 1, $7)`, + id, 90_000+id, creator.ID, "Recommendation "+username, username, participants, date); err != nil { + t.Fatalf("insert channel %d: %v", id, err) + } + } + + insertChannel(sourceID, "source_"+suffix, 10_000, 1700000000) + insertChannel(joinedID, "joined_"+suffix, 9_999, 1700000001) + for i := 0; i < total; i++ { + id := firstCandidateID + int64(i) + insertChannel(id, fmt.Sprintf("candidate_%s_%03d", suffix, i), total-i, 1700000100+i) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO user_channel_member_index (user_id, channel_id, status, megagroup, broadcast, deleted) +VALUES + ($1, $2, 'active', false, true, false), + ($1, $3, 'active', false, true, false)`, + owner.ID, sourceID, joinedID); err != nil { + t.Fatalf("insert membership index: %v", err) + } + + store := NewChannelStore(pool) + source, err := store.ListChannelRecommendations(ctx, domain.ChannelRecommendationsRequest{ + UserID: owner.ID, + SourceChannelID: sourceID, + Limit: domain.DefaultChannelRecommendationsLimit, + }) + if err != nil { + t.Fatalf("source recommendations: %v", err) + } + if source.Count != domain.MaxChannelRecommendationsLimit || len(source.Channels) != domain.DefaultChannelRecommendationsLimit { + t.Fatalf("source recommendations count=%d len=%d, want bounded count %d len %d", + source.Count, len(source.Channels), domain.MaxChannelRecommendationsLimit, domain.DefaultChannelRecommendationsLimit) + } + for _, ch := range source.Channels { + if ch.ID == sourceID || !ch.Broadcast || ch.Megagroup || ch.Username == "" { + t.Fatalf("source recommendation = %+v, want public broadcast excluding source", ch) + } + } + + global, err := store.ListChannelRecommendations(ctx, domain.ChannelRecommendationsRequest{ + UserID: owner.ID, + Limit: domain.DefaultChannelRecommendationsLimit, + }) + if err != nil { + t.Fatalf("global recommendations: %v", err) + } + for _, ch := range global.Channels { + if ch.ID == sourceID || ch.ID == joinedID { + t.Fatalf("global recommendation included joined channel %d", ch.ID) + } + } + if global.Count != domain.MaxChannelRecommendationsLimit || len(global.Channels) != domain.DefaultChannelRecommendationsLimit { + t.Fatalf("global recommendations count=%d len=%d, want bounded count %d len %d", + global.Count, len(global.Channels), domain.MaxChannelRecommendationsLimit, domain.DefaultChannelRecommendationsLimit) + } +} diff --git a/internal/store/postgres/channel_row_cache.go b/internal/store/postgres/channel_row_cache.go new file mode 100644 index 00000000..814f7af5 --- /dev/null +++ b/internal/store/postgres/channel_row_cache.go @@ -0,0 +1,88 @@ +package postgres + +import ( + "context" + + "telesrv/internal/domain" + "telesrv/internal/readmodelcache" +) + +// ChannelRowCache 是「共享频道行」(channels 表一行 → domain.Channel) 的进程内强一致缓存, +// 由统一缓存原语 readmodelcache.Cache 承载(LRU 单条驱逐 / epoch 守卫 / singleflight 内建)。 +// +// 只缓存全 viewer 通用、变更不频繁的频道行;per-viewer 的 member(权限/读态) 由 +// ChannelMemberCache 以 (channel_id,user_id) 单独隔离,dialog(top/未读)、boost 仍实时查 PG。 +// +// 一致性靠统一 read-model PG LISTEN/NOTIFY 实时失效(见 ReadModelChangeListener)而非 TTL: +// channels 表上的 read-model 触发器在提交时发布 channel_base 事件,listener 收到即 delete +// 对应条目;listener (重)连接时整表 flush,兜住断连窗口内丢失的通知。无 TTL,故 epoch 守卫 +// 是防永久 stale 的唯一保障(已内建于原语)。 +// +// 仅在连接池句柄(非事务)上消费(见 ChannelStore.cacheActive):事务内一律实时读,保证读己写。 +type ChannelRowCache struct { + cache *readmodelcache.Cache[int64, domain.Channel] +} + +// NewChannelRowCache 创建容量为 max 的频道行缓存;max<=0 返回 nil(表示禁用,调用方按 nil 跳过)。 +func NewChannelRowCache(max int) *ChannelRowCache { + cache := readmodelcache.New[int64, domain.Channel](readmodelcache.Config[int64, domain.Channel]{ + MaxEntries: max, + Clone: cloneChannelRow, + }) + if cache == nil { + return nil + } + return &ChannelRowCache{cache: cache} +} + +func (c *ChannelRowCache) get(id int64) (domain.Channel, bool) { + if c == nil || id == 0 { + return domain.Channel{}, false + } + return c.cache.Peek(id) +} + +func (c *ChannelRowCache) getOrLoad(ctx context.Context, id int64, load func() (domain.Channel, error)) (domain.Channel, error) { + if c == nil { + return load() + } + return c.cache.GetOrLoad(ctx, id, load) +} + +func (c *ChannelRowCache) put(ch domain.Channel) { + if c == nil || ch.ID == 0 { + return + } + c.cache.Store(ch.ID, ch) +} + +// delete 失效单个频道(listener 收到该 id 的变更通知时调用)。 +func (c *ChannelRowCache) delete(id int64) { + if c == nil || id == 0 { + return + } + c.cache.Invalidate(id) +} + +// flush 清空整表(listener 每次 (重)连接、建立 LISTEN 后调用,兜住断连窗口内丢失的通知)。 +func (c *ChannelRowCache) flush() { + if c == nil { + return + } + c.cache.Flush() +} + +// cloneChannelRow 深拷频道行的可变字段(切片),使缓存与调用方互不别名污染。 +func cloneChannelRow(ch domain.Channel) domain.Channel { + if ch.PhotoStripped != nil { + ch.PhotoStripped = append([]byte(nil), ch.PhotoStripped...) + } + if ch.ReactionPolicy.Emoticons != nil { + ch.ReactionPolicy.Emoticons = append([]string(nil), ch.ReactionPolicy.Emoticons...) + } + if ch.ReactionPolicy.CustomEmojiIDs != nil { + ch.ReactionPolicy.CustomEmojiIDs = append([]int64(nil), ch.ReactionPolicy.CustomEmojiIDs...) + } + ch.Wallpaper = domain.CloneWallpaperPtr(ch.Wallpaper) + return ch +} diff --git a/internal/store/postgres/channel_row_cache_integration_test.go b/internal/store/postgres/channel_row_cache_integration_test.go new file mode 100644 index 00000000..e0d9121a --- /dev/null +++ b/internal/store/postgres/channel_row_cache_integration_test.go @@ -0,0 +1,110 @@ +package postgres + +import ( + "context" + "os" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestChannelRowCacheInvalidatesOnNotify 端到端验证:channels 触发器(0117) → LISTEN/NOTIFY → +// ChannelChangeListener → ChannelRowCache 失效闭环。门控于 TELESRV_TEST_POSTGRES_DSN。 +// +// 用「listener 连接时的 flush」作为就绪信号(轮询缓存被清空即确认 listener 已 LISTEN),避免靠 +// sleep 猜时序导致竞态。 +func TestChannelRowCacheInvalidatesOnNotify(t *testing.T) { + pool := testPool(t) // 未设 DSN 会 t.Skip + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{ + AccessHash: 451, + Phone: "+1889" + suffix + "01", + FirstName: "CacheOwner", + }) + if err != nil { + t.Fatalf("create owner: %v", err) + } + + cache := NewChannelRowCache(1000) + channels := NewChannelStore(pool, WithChannelRowCache(cache)) + + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "CacheChan " + suffix, + Megagroup: true, + Date: 1700000700, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + // 先填充缓存(此时 listener 未起)。 + if _, err := channels.GetChannel(ctx, owner.ID, channelID); err != nil { + t.Fatalf("warm GetChannel: %v", err) + } + if _, ok := cache.get(channelID); !ok { + t.Fatalf("GetChannel 后频道行应已缓存") + } + + // 起 listener;它连接后会 flush 整表——用缓存被清空作为「listener 已就绪」的信号。 + lctx, cancel := context.WithCancel(ctx) + defer cancel() + listener := NewChannelChangeListener(dsn, cache, nil) + go listener.Run(lctx) + if !waitUntil(2*time.Second, func() bool { _, ok := cache.get(channelID); return !ok }) { + t.Fatalf("listener 未在预期内连接并 flush 缓存") + } + + // listener 就绪后重新填充缓存。 + if _, err := channels.GetChannel(ctx, owner.ID, channelID); err != nil { + t.Fatalf("re-warm GetChannel: %v", err) + } + if _, ok := cache.get(channelID); !ok { + t.Fatalf("re-warm 后频道行应已缓存") + } + + // 模拟任意写点直接改 channels 表(触发器会 NOTIFY)。 + const newTitle = "Renamed By Trigger" + if _, err := pool.Exec(ctx, "UPDATE channels SET title = $2, updated_at = now() WHERE id = $1", channelID, newTitle); err != nil { + t.Fatalf("update title: %v", err) + } + + // 触发器 → NOTIFY → listener 应实时失效该频道。 + if !waitUntil(3*time.Second, func() bool { _, ok := cache.get(channelID); return !ok }) { + t.Fatalf("UPDATE 后缓存未在预期内被 NOTIFY 失效") + } + + // 失效后再读应拿到新值(并回填缓存)。 + view, err := channels.GetChannel(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("post-invalidation GetChannel: %v", err) + } + if view.Channel.Title != newTitle { + t.Fatalf("失效后标题 = %q, want %q", view.Channel.Title, newTitle) + } +} + +func waitUntil(timeout time.Duration, cond func() bool) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(20 * time.Millisecond) + } + return cond() +} diff --git a/internal/store/postgres/channel_row_cache_test.go b/internal/store/postgres/channel_row_cache_test.go new file mode 100644 index 00000000..133fe22e --- /dev/null +++ b/internal/store/postgres/channel_row_cache_test.go @@ -0,0 +1,166 @@ +package postgres + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestChannelRowCacheNilDisabled(t *testing.T) { + if c := NewChannelRowCache(0); c != nil { + t.Fatalf("max<=0 应返回 nil(禁用),got %v", c) + } + if c := NewChannelRowCache(-5); c != nil { + t.Fatalf("负容量应返回 nil(禁用),got %v", c) + } + // nil 缓存上的所有操作都必须安全(no-op)。 + var nilCache *ChannelRowCache + nilCache.put(domain.Channel{ID: 1}) + nilCache.delete(1) + nilCache.flush() + if _, ok := nilCache.get(1); ok { + t.Fatalf("nil 缓存 get 必须返回 ok=false") + } +} + +func TestChannelRowCachePutGetDeleteFlush(t *testing.T) { + c := NewChannelRowCache(16) + ch := domain.Channel{ID: 42, Title: "hello", AccessHash: 7} + + if _, ok := c.get(42); ok { + t.Fatalf("空缓存不应命中") + } + c.put(ch) + got, ok := c.get(42) + if !ok || got.ID != 42 || got.Title != "hello" || got.AccessHash != 7 { + t.Fatalf("put/get 往返失败: %+v ok=%v", got, ok) + } + + c.delete(42) + if _, ok := c.get(42); ok { + t.Fatalf("delete 后不应命中") + } + + c.put(ch) + c.flush() + if _, ok := c.get(42); ok { + t.Fatalf("flush 后不应命中") + } + + // id==0 既不存也不查。 + c.put(domain.Channel{ID: 0, Title: "zero"}) + if _, ok := c.get(0); ok { + t.Fatalf("id==0 不应缓存/命中") + } +} + +func TestChannelRowCacheReturnsIsolatedClone(t *testing.T) { + c := NewChannelRowCache(8) + orig := domain.Channel{ + ID: 9, + PhotoStripped: []byte{1, 2, 3}, + } + orig.ReactionPolicy.Emoticons = []string{"👍"} + orig.ReactionPolicy.CustomEmojiIDs = []int64{100} + c.put(orig) + + // 改写 put 入参的底层切片不得污染缓存(put 须自持副本)。 + orig.PhotoStripped[0] = 99 + orig.ReactionPolicy.Emoticons[0] = "💩" + orig.ReactionPolicy.CustomEmojiIDs[0] = 999 + + got, ok := c.get(9) + if !ok { + t.Fatalf("应命中") + } + if got.PhotoStripped[0] != 1 || got.ReactionPolicy.Emoticons[0] != "👍" || got.ReactionPolicy.CustomEmojiIDs[0] != 100 { + t.Fatalf("缓存被 put 入参后续改写污染: %+v", got) + } + + // 改写 get 返回值的切片也不得污染缓存(get 须返回副本)。 + got.PhotoStripped[0] = 77 + got.ReactionPolicy.Emoticons[0] = "🔥" + got.ReactionPolicy.CustomEmojiIDs[0] = 777 + again, _ := c.get(9) + if again.PhotoStripped[0] != 1 || again.ReactionPolicy.Emoticons[0] != "👍" || again.ReactionPolicy.CustomEmojiIDs[0] != 100 { + t.Fatalf("缓存被 get 返回值改写污染: %+v", again) + } +} + +func TestChannelRowCacheCapEviction(t *testing.T) { + c := NewChannelRowCache(2) + c.put(domain.Channel{ID: 1}) + c.put(domain.Channel{ID: 2}) + // 第三个新 id 超 cap=2:LRU 单条驱逐最旧的 id1,而非整表清空。 + c.put(domain.Channel{ID: 3}) + if _, ok := c.get(1); ok { + t.Fatalf("超限后最旧条目 id1 应被驱逐") + } + if _, ok := c.get(2); !ok { + t.Fatalf("LRU 单条驱逐应保留次新条目 id2,证明非整表 flush") + } + if _, ok := c.get(3); !ok { + t.Fatalf("最新写入的条目 id3 应在") + } + // 已存在 id 的更新原地生效,不触发驱逐。 + c.put(domain.Channel{ID: 3, Title: "v2"}) + if got, ok := c.get(3); !ok || got.Title != "v2" { + t.Fatalf("已存在 id 的更新应原地生效: %+v ok=%v", got, ok) + } +} + +func TestChannelRowCacheSingleflightsColdLoad(t *testing.T) { + c := NewChannelRowCache(16) + var calls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + load := func() (domain.Channel, error) { + calls.Add(1) + once.Do(func() { close(started) }) + <-release + return domain.Channel{ID: 42, Title: "loaded"}, nil + } + + const goroutines = 16 + var wg sync.WaitGroup + errs := make(chan error, goroutines) + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + ch, err := c.getOrLoad(context.Background(), 42, load) + if err != nil { + errs <- err + return + } + if ch.ID != 42 || ch.Title != "loaded" { + errs <- fmt.Errorf("channel = %+v, want loaded 42", ch) + } + }() + } + <-started + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("load calls = %d, want 1", got) + } + if _, err := c.getOrLoad(context.Background(), 42, load); err != nil { + t.Fatalf("cached getOrLoad: %v", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("cache hit called load again: calls=%d", got) + } +} diff --git a/internal/store/postgres/channel_settings.go b/internal/store/postgres/channel_settings.go new file mode 100644 index 00000000..bd510820 --- /dev/null +++ b/internal/store/postgres/channel_settings.go @@ -0,0 +1,1017 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "telesrv/internal/domain" +) + +func (s *ChannelStore) EditChannelTitle(ctx context.Context, req domain.EditChannelTitleRequest) (domain.EditChannelTitleResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || strings.TrimSpace(req.Title) == "" { + return domain.EditChannelTitleResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.EditChannelTitleResult{}, fmt.Errorf("edit channel title: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + title := strings.TrimSpace(req.Title) + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.EditChannelTitleResult{}, fmt.Errorf("begin edit channel title: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelTitleResult{}, err + } + if !canChangeChannelInfo(member) { + return domain.EditChannelTitleResult{}, domain.ErrChannelAdminRequired + } + if channel.Title == title { + return domain.EditChannelTitleResult{}, domain.ErrChannelNotModified + } + prevTitle := channel.Title + if _, err := tx.Exec(ctx, `UPDATE channels SET title = $2, updated_at = now() WHERE id = $1`, req.ChannelID, title); err != nil { + return domain.EditChannelTitleResult{}, fmt.Errorf("update channel title: %w", err) + } + channel.Title = title + msg, event, err := s.insertServiceMessage(ctx, tx, channel, req.UserID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionEditTitle, + Title: title, + }) + if err != nil { + return domain.EditChannelTitleResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + // read_outbox 由公共已读水位派生;服务消息只自读(inbox),不能把 + // actor 的 outbox 水位顶到自己刚发的消息上造成虚假双勾。 + if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, msg.ID, 0); err != nil { + return domain.EditChannelTitleResult{}, err + } + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: req.Date, + Type: domain.ChannelAdminLogChangeTitle, + PrevString: prevTitle, + NewString: title, + }); err != nil { + return domain.EditChannelTitleResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.EditChannelTitleResult{}, fmt.Errorf("commit edit channel title: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.EditChannelTitleResult{Channel: channel, Message: msg, Event: event, Recipients: recipients}, nil +} + +func (s *ChannelStore) SetChannelWallpaper(ctx context.Context, req domain.SetChannelWallpaperRequest) (domain.SetChannelWallpaperResult, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.SetChannelWallpaperResult{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.SetChannelWallpaperResult{}, fmt.Errorf("set channel wallpaper: db does not support transactions") + } + if req.Date == 0 { + req.Date = nowUnix() + } + wallpaper := domain.CloneWallpaperPtr(req.Wallpaper) + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.SetChannelWallpaperResult{}, fmt.Errorf("begin set channel wallpaper: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.SetChannelWallpaperResult{}, err + } + if !canChangeChannelInfo(member) { + return domain.SetChannelWallpaperResult{}, domain.ErrChannelAdminRequired + } + if domain.WallpaperEqual(channel.Wallpaper, wallpaper) { + if err := tx.Commit(ctx); err != nil { + return domain.SetChannelWallpaperResult{}, fmt.Errorf("commit unchanged channel wallpaper: %w", err) + } + committed = true + return domain.SetChannelWallpaperResult{Channel: channel}, nil + } + var wallpaperParam any + if wallpaper != nil { + raw, err := json.Marshal(wallpaper) + if err != nil { + return domain.SetChannelWallpaperResult{}, fmt.Errorf("encode channel wallpaper: %w", err) + } + wallpaperParam = string(raw) + } + if _, err := tx.Exec(ctx, `UPDATE channels SET wallpaper = $2::jsonb, updated_at = now() WHERE id = $1`, req.ChannelID, wallpaperParam); err != nil { + return domain.SetChannelWallpaperResult{}, fmt.Errorf("update channel wallpaper: %w", err) + } + channel.Wallpaper = domain.CloneWallpaperPtr(wallpaper) + var ( + msg domain.ChannelMessage + event domain.ChannelUpdateEvent + ) + if wallpaper != nil { + msg, event, err = s.insertServiceMessage(ctx, tx, channel, req.UserID, req.Date, domain.ChannelMessageAction{ + Type: domain.ChannelActionSetChatWallpaper, + Wallpaper: domain.CloneWallpaperPtr(wallpaper), + }) + if err != nil { + return domain.SetChannelWallpaperResult{}, err + } + channel.TopMessageID = msg.ID + channel.Pts = event.Pts + if err := upsertChannelDialogTx(ctx, tx, req.UserID, channel, msg, msg.ID, 0); err != nil { + return domain.SetChannelWallpaperResult{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.SetChannelWallpaperResult{}, fmt.Errorf("commit set channel wallpaper: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.SetChannelWallpaperResult{ + Channel: channel, + Message: msg, + Event: event, + Recipients: recipients, + Changed: true, + }, nil +} + +func (s *ChannelStore) EditChannelAbout(ctx context.Context, req domain.EditChannelAboutRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("edit channel about: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin edit channel about: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET about = $2, updated_at = now() WHERE id = $1`, req.ChannelID, req.About); err != nil { + return domain.Channel{}, fmt.Errorf("update channel about: %w", err) + } + channel.About = req.About + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit edit channel about: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) CheckUsername(ctx context.Context, userID, channelID int64, username string) (bool, error) { + if userID == 0 || channelID == 0 || strings.TrimSpace(username) == "" { + return false, domain.ErrChannelInvalid + } + if _, _, err := s.getChannelForMember(ctx, s.db, userID, channelID); err != nil { + return false, err + } + usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + return peerUsernameAvailable(ctx, s.db, usernameLower, peerUsernameTypeChannel, channelID) +} + +func (s *ChannelStore) UpdateUsername(ctx context.Context, req domain.UpdateChannelUsernameRequest) (domain.Channel, error) { + if req.UserID == 0 || req.ChannelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("update channel username: db does not support transactions") + } + username := strings.TrimSpace(strings.TrimPrefix(req.Username, "@")) + usernameLower := strings.ToLower(username) + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin update channel username: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.Channel{}, err + } + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if strings.EqualFold(channel.Username, username) { + return domain.Channel{}, domain.ErrChannelNotModified + } + if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeChannel, req.ChannelID, usernameLower); err != nil { + return domain.Channel{}, err + } + if _, err := tx.Exec(ctx, `UPDATE channels SET username = NULLIF($2,''), updated_at = now() WHERE id = $1`, req.ChannelID, username); err != nil { + return domain.Channel{}, fmt.Errorf("update channel username: %w", err) + } + if err := markUserChannelMemberIndexPublicTx(ctx, tx, req.ChannelID, username != ""); err != nil { + return domain.Channel{}, err + } + prevUsername := channel.Username + channel.Username = username + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: req.ChannelID, + UserID: req.UserID, + Date: nowUnix(), + Type: domain.ChannelAdminLogChangeUsername, + PrevString: prevUsername, + NewString: username, + }); err != nil { + return domain.Channel{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit update channel username: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetChannelVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error) { + if channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + channel, err := s.channelByID(ctx, s.db, channelID) + if err != nil { + return domain.Channel{}, err + } + if channel.Verified == verified { + return channel, nil + } + if _, err := s.db.Exec(ctx, `UPDATE channels SET verified = $2, updated_at = now() WHERE id = $1 AND NOT deleted`, channelID, verified); err != nil { + return domain.Channel{}, fmt.Errorf("set channel verified: %w", err) + } + if s.rowCache != nil { + s.rowCache.delete(channelID) + } + channel.Verified = verified + return channel, nil +} + +func (s *ChannelStore) ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) { + if viewerUserID == 0 { + return domain.Channel{}, false, domain.ErrChannelInvalid + } + usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + if usernameLower == "" { + return domain.Channel{}, false, nil + } + owner, found, err := getPeerUsernameOwner(ctx, s.db, usernameLower, false) + if err != nil { + return domain.Channel{}, false, fmt.Errorf("resolve public channel username: %w", err) + } + if !found || owner.peerType != peerUsernameTypeChannel { + return domain.Channel{}, false, nil + } + ch, err := getChannelByID(ctx, s.db, owner.peerID) + if err != nil { + if errors.Is(err, domain.ErrChannelInvalid) { + return domain.Channel{}, false, nil + } + return domain.Channel{}, false, fmt.Errorf("resolve public channel username channel: %w", err) + } + if !publicPreviewableChannel(ch) || !strings.EqualFold(ch.Username, usernameLower) { + return domain.Channel{}, false, nil + } + return ch, true, nil +} + +func (s *ChannelStore) SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel signatures: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel signatures: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.Signatures + if _, err := tx.Exec(ctx, `UPDATE channels SET signatures = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel signatures: %w", err) + } + channel.Signatures = enabled + if prev != enabled { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogToggleSignatures, + PrevBool: prev, + NewBool: enabled, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel signatures: %w", err) + } + committed = true + return channel, nil +} + +// SetChannelPhoto 设置/清除频道头像(反范式列)。photo==nil 表示清除。 +func (s *ChannelStore) SetChannelPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("set channel photo: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin set channel photo: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + var ( + photoID int64 + dcID int + stripped []byte + ) + if photo != nil && photo.ID != 0 { + photoID = photo.ID + dcID = photo.DCID + stripped = domain.StrippedFromSizes(photo.Sizes) + } + if stripped == nil { + stripped = []byte{} + } + if _, err := tx.Exec(ctx, `UPDATE channels SET photo_id = $2, photo_dc_id = $3, photo_stripped = $4, updated_at = now() WHERE id = $1`, + channelID, photoID, dcID, stripped); err != nil { + return domain.Channel{}, fmt.Errorf("update channel photo: %w", err) + } + channel.PhotoID = photoID + channel.PhotoDCID = dcID + channel.PhotoStripped = stripped + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit set channel photo: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetAutotranslation(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel autotranslation: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel autotranslation: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.Autotranslation + if _, err := tx.Exec(ctx, `UPDATE channels SET autotranslation = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel autotranslation: %w", err) + } + channel.Autotranslation = enabled + if prev != enabled { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogToggleAutotranslation, + PrevBool: prev, + NewBool: enabled, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel autotranslation: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetRestrictedSponsored(ctx context.Context, userID, channelID int64, restricted bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel restricted sponsored: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel restricted sponsored: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET restricted_sponsored = $2, updated_at = now() WHERE id = $1`, channelID, restricted); err != nil { + return domain.Channel{}, fmt.Errorf("update channel restricted sponsored: %w", err) + } + channel.RestrictedSponsored = restricted + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel restricted sponsored: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetAntiSpam(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel antispam: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel antispam: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !channel.Megagroup || !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.AntiSpam + if _, err := tx.Exec(ctx, `UPDATE channels SET antispam = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel antispam: %w", err) + } + channel.AntiSpam = enabled + if prev != enabled { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogToggleAntiSpam, + PrevBool: prev, + NewBool: enabled, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel antispam: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetSlowMode(ctx context.Context, userID, channelID int64, seconds int) (domain.Channel, error) { + if userID == 0 || channelID == 0 || !domain.ValidChannelSlowModeSeconds(seconds) { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel slowmode: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel slowmode: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !channel.Megagroup || !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + prev := channel.SlowmodeSeconds + if _, err := tx.Exec(ctx, `UPDATE channels SET slowmode_seconds = $2, updated_at = now() WHERE id = $1`, channelID, seconds); err != nil { + return domain.Channel{}, fmt.Errorf("update channel slowmode: %w", err) + } + channel.SlowmodeSeconds = seconds + if prev != seconds { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogToggleSlowMode, + PrevInt: prev, + NewInt: seconds, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel slowmode: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetNoForwards(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel noforwards: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel noforwards: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET noforwards = $2, updated_at = now() WHERE id = $1`, channelID, enabled); err != nil { + return domain.Channel{}, fmt.Errorf("update channel noforwards: %w", err) + } + channel.NoForwards = enabled + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel noforwards: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetColor(ctx context.Context, userID, channelID int64, forProfile bool, color domain.ChannelPeerColor) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("set channel color: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin set channel color: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if forProfile { + if _, err := tx.Exec(ctx, `UPDATE channels SET profile_color_set = $2, profile_color = $3, profile_color_background_emoji_id = $4, updated_at = now() WHERE id = $1`, + channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil { + return domain.Channel{}, fmt.Errorf("update channel profile color: %w", err) + } + channel.ProfileColor = color + } else { + if _, err := tx.Exec(ctx, `UPDATE channels SET color_set = $2, color = $3, color_background_emoji_id = $4, updated_at = now() WHERE id = $1`, + channelID, color.HasColor, color.Color, color.BackgroundEmojiID); err != nil { + return domain.Channel{}, fmt.Errorf("update channel color: %w", err) + } + channel.Color = color + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit set channel color: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetEmojiStatus(ctx context.Context, userID, channelID int64, status domain.ChannelEmojiStatus) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + if status.DocumentID == 0 { + status.Until = 0 + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("set channel emoji status: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin set channel emoji status: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !canChangeChannelInfo(member) { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if _, err := tx.Exec(ctx, `UPDATE channels SET emoji_status_document_id = $2, emoji_status_until = $3, updated_at = now() WHERE id = $1`, + channelID, status.DocumentID, status.Until); err != nil { + return domain.Channel{}, fmt.Errorf("update channel emoji status: %w", err) + } + channel.EmojiStatus = status + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit set channel emoji status: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) ListChannelRecommendations(ctx context.Context, req domain.ChannelRecommendationsRequest) (domain.ChannelRecommendationsResult, error) { + if req.UserID == 0 || req.SourceChannelID < 0 { + return domain.ChannelRecommendationsResult{}, domain.ErrChannelInvalid + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelRecommendationsLimit { + limit = domain.DefaultChannelRecommendationsLimit + } + probeLimit := domain.MaxChannelRecommendationsLimit + if probeLimit < limit { + probeLimit = limit + } + args := []any{req.UserID, req.SourceChannelID} + where := []string{ + "($1::bigint <> 0)", + "c.broadcast", + "NOT c.megagroup", + "NOT c.deleted", + "COALESCE(c.username, '') <> ''", + "($2::bigint = 0 OR c.id <> $2)", + } + if req.SourceChannelID == 0 { + where = append(where, `NOT EXISTS ( + SELECT 1 + FROM user_channel_member_index i + WHERE i.user_id = $1 + AND i.channel_id = c.id + AND i.status = 'active' +)`) + } + whereSQL := strings.Join(where, " AND ") + args = append(args, probeLimit) + rows, err := s.db.Query(ctx, ` +SELECT `+channelColumns+` +FROM channels c +WHERE `+whereSQL+` +ORDER BY c.participants_count DESC, c.date DESC, c.id DESC +LIMIT $3`, args...) + if err != nil { + return domain.ChannelRecommendationsResult{}, fmt.Errorf("list channel recommendations: %w", err) + } + defer rows.Close() + out := domain.ChannelRecommendationsResult{Channels: make([]domain.Channel, 0, limit)} + for rows.Next() { + ch, err := scanChannel(rows) + if err != nil { + return domain.ChannelRecommendationsResult{}, err + } + out.Count++ + if len(out.Channels) < limit { + out.Channels = append(out.Channels, ch) + } + } + if err := rows.Err(); err != nil { + return domain.ChannelRecommendationsResult{}, err + } + return out, nil +} + +func (s *ChannelStore) ListDiscussionGroups(ctx context.Context, userID int64, limit int) ([]domain.Channel, error) { + if userID == 0 { + return nil, domain.ErrChannelInvalid + } + if limit <= 0 || limit > domain.MaxDiscussionGroupsLimit { + limit = domain.MaxDiscussionGroupsLimit + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id +FROM user_channel_member_index +WHERE user_id = $1 + AND status = 'active' + AND megagroup + AND NOT broadcast + AND NOT forum + AND NOT deleted + AND (role = 'creator' OR can_pin_messages) +ORDER BY channel_id DESC +LIMIT $2`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list discussion groups: %w", err) + } + defer rows.Close() + ids := make([]int64, 0, limit) + for rows.Next() { + var channelID int64 + if err := rows.Scan(&channelID); err != nil { + return nil, err + } + ids = append(ids, channelID) + } + if err := rows.Err(); err != nil { + return nil, err + } + channels, err := listChannelsByIDsInOrder(ctx, s.db, ids) + if err != nil { + return nil, fmt.Errorf("list discussion group details: %w", err) + } + out := make([]domain.Channel, 0, len(channels)) + for _, channel := range channels { + if !channel.Megagroup || channel.Broadcast || channel.Forum || channel.Deleted { + continue + } + out = append(out, channel) + } + return out, nil +} + +func (s *ChannelStore) SetDiscussionGroup(ctx context.Context, userID, broadcastID, groupID int64) (domain.DiscussionGroupUpdateResult, error) { + if userID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelInvalid + } + if broadcastID == 0 && groupID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("set discussion group: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("begin set discussion group: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + + changed := make(map[int64]domain.Channel) + markChanged := func(channel domain.Channel) { + if channel.ID != 0 { + changed[channel.ID] = channel + } + } + setLinked := func(channel domain.Channel, linkedID int64) (domain.Channel, error) { + if channel.LinkedChatID == linkedID { + return channel, nil + } + if _, err := tx.Exec(ctx, `UPDATE channels SET linked_chat_id = $2, updated_at = now() WHERE id = $1`, channel.ID, linkedID); err != nil { + return domain.Channel{}, fmt.Errorf("update linked chat: %w", err) + } + channel.LinkedChatID = linkedID + markChanged(channel) + return channel, nil + } + logLinkChange := func(channelID, prev, next int64) error { + if prev == next || channelID == 0 { + return nil + } + return s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogChangeLinkedChat, + PrevInt: int(prev), + NewInt: int(next), + }) + } + + if broadcastID == 0 { + group, groupMember, err := s.getChannelForMember(ctx, tx, userID, groupID) + if err != nil || !validDiscussionGroup(group) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid + } + if !canManageDiscussionGroup(groupMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + oldBroadcastID := group.LinkedChatID + if oldBroadcastID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + oldBroadcast, err := getChannelByID(ctx, tx, oldBroadcastID) + if err == nil && oldBroadcast.LinkedChatID == groupID { + updated, err := setLinked(oldBroadcast, 0) + if err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := logLinkChange(updated.ID, groupID, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { + return domain.DiscussionGroupUpdateResult{}, err + } + if _, err := setLinked(group, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) + } + committed = true + return discussionGroupUpdateResult(changed), nil + } + + broadcast, broadcastMember, err := s.getChannelForMember(ctx, tx, userID, broadcastID) + if err != nil || !broadcast.Broadcast || broadcast.Megagroup { + return domain.DiscussionGroupUpdateResult{}, domain.ErrBroadcastIDInvalid + } + if !canManageDiscussionBroadcast(broadcastMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + oldGroupID := broadcast.LinkedChatID + if groupID == 0 { + if oldGroupID == 0 { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + updated, err := setLinked(broadcast, 0) + if err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := logLinkChange(updated.ID, oldGroupID, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + oldGroup, err := getChannelByID(ctx, tx, oldGroupID) + if err == nil && oldGroup.LinkedChatID == broadcastID { + if _, err := setLinked(oldGroup, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) + } + committed = true + return discussionGroupUpdateResult(changed), nil + } + + group, groupMember, err := s.getChannelForMember(ctx, tx, userID, groupID) + if err != nil || !validDiscussionGroup(group) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupIDInvalid + } + if group.PreHistoryHidden { + return domain.DiscussionGroupUpdateResult{}, domain.ErrMegagroupPrehistoryHidden + } + if !canManageDiscussionGroup(groupMember) { + return domain.DiscussionGroupUpdateResult{}, domain.ErrChannelAdminRequired + } + if oldGroupID == groupID && group.LinkedChatID == broadcastID { + return domain.DiscussionGroupUpdateResult{}, domain.ErrLinkNotModified + } + oldBroadcastID := group.LinkedChatID + if oldGroupID != 0 && oldGroupID != groupID { + oldGroup, err := getChannelByID(ctx, tx, oldGroupID) + if err == nil && oldGroup.LinkedChatID == broadcastID { + if _, err := setLinked(oldGroup, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { + return domain.DiscussionGroupUpdateResult{}, err + } + } + if oldBroadcastID != 0 && oldBroadcastID != broadcastID { + oldBroadcast, err := getChannelByID(ctx, tx, oldBroadcastID) + if err == nil && oldBroadcast.LinkedChatID == groupID { + updated, err := setLinked(oldBroadcast, 0) + if err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := logLinkChange(updated.ID, groupID, 0); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + } else if err != nil && !errors.Is(err, domain.ErrChannelInvalid) { + return domain.DiscussionGroupUpdateResult{}, err + } + } + updatedBroadcast, err := setLinked(broadcast, groupID) + if err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if _, err := setLinked(group, broadcastID); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := logLinkChange(updatedBroadcast.ID, oldGroupID, groupID); err != nil { + return domain.DiscussionGroupUpdateResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.DiscussionGroupUpdateResult{}, fmt.Errorf("commit set discussion group: %w", err) + } + committed = true + return discussionGroupUpdateResult(changed), nil +} + +func validDiscussionGroup(channel domain.Channel) bool { + return channel.Megagroup && !channel.Broadcast && !channel.Forum && !channel.Deleted +} + +func channelSlowModeWait(channel domain.Channel, member domain.ChannelMember, now int) int { + if channel.SlowmodeSeconds <= 0 || member.Role == domain.ChannelRoleCreator || member.Role == domain.ChannelRoleAdmin { + return 0 + } + next := member.SlowmodeLastSendDate + channel.SlowmodeSeconds + if now >= next { + return 0 + } + return next - now +} diff --git a/internal/store/postgres/channel_store.go b/internal/store/postgres/channel_store.go new file mode 100644 index 00000000..92a16291 --- /dev/null +++ b/internal/store/postgres/channel_store.go @@ -0,0 +1,140 @@ +package postgres + +import ( + "go.uber.org/zap" + + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +const channelDialogQueryLimit = 500 + +const channelDialogCandidateLimit = 10000 + +const channelMemberFilterBatch = 1000 + +const retryableChannelTxAttempts = 3 + +// ChannelStore 用 PostgreSQL 实现 store.ChannelStore。 +type ChannelStore struct { + db sqlcgen.DBTX + ids store.ChannelIDAllocator + msgIDs store.ChannelMessageIDAllocator + log *zap.Logger + rowCache *ChannelRowCache + memberCache *ChannelMemberCache + dialogCache *ChannelDialogCache + boostCache *ChannelBoostCache +} + +// ChannelStoreOption 调整 PostgreSQL ChannelStore 依赖。 +type ChannelStoreOption func(*ChannelStore) + +// WithChannelAllocators 注入 Redis-backed channel id / message id allocator。 +func WithChannelAllocators(ids store.ChannelIDAllocator, msgIDs store.ChannelMessageIDAllocator) ChannelStoreOption { + return func(s *ChannelStore) { + s.ids = ids + s.msgIDs = msgIDs + } +} + +// WithChannelLogger 注入频道 store 日志器,用于追踪 channel pts 分配。 +func WithChannelLogger(log *zap.Logger) ChannelStoreOption { + return func(s *ChannelStore) { + s.log = log + } +} + +// WithChannelRowCache 注入「共享频道行」进程内缓存(由 ReadModelChangeListener 实时失效)。 +// 传 nil 等于禁用,不影响任何读路径行为。 +func WithChannelRowCache(cache *ChannelRowCache) ChannelStoreOption { + return func(s *ChannelStore) { + s.rowCache = cache + } +} + +// WithChannelMemberCache 注入「频道成员/访问态」进程内缓存。 +// 传 nil 等于禁用;事务内仍绕过,提交后由 read model listener 失效。 +func WithChannelMemberCache(cache *ChannelMemberCache) ChannelStoreOption { + return func(s *ChannelStore) { + s.memberCache = cache + } +} + +// WithChannelDialogCache 注入 viewer-scoped 频道 dialog 投影缓存。 +// 传 nil 等于禁用;事务内仍绕过,避免写后读旧 dialog/read boundary。 +func WithChannelDialogCache(cache *ChannelDialogCache) ChannelStoreOption { + return func(s *ChannelStore) { + s.dialogCache = cache + } +} + +// WithChannelBoostCache 注入频道 boost 读投影缓存(SelfBoostsApplied + peer total)。 +// 传 nil 等于禁用;事务内仍绕过,避免 send 权限判断读到旧值。 +func WithChannelBoostCache(cache *ChannelBoostCache) ChannelStoreOption { + return func(s *ChannelStore) { + s.boostCache = cache + } +} + +// cacheActive 报告当前句柄是否可用频道行缓存:仅启用缓存且走连接池(非事务)时。 +// 事务内(db != s.db)一律绕过缓存实时读,保证事务读己写。 +func (s *ChannelStore) cacheActive(db sqlcgen.DBTX) bool { + return s.rowCache != nil && db == s.db +} + +func (s *ChannelStore) memberCacheActive(db sqlcgen.DBTX) bool { + return s.memberCache != nil && db == s.db +} + +func (s *ChannelStore) dialogCacheActive(db sqlcgen.DBTX) bool { + return s.dialogCache != nil && db == s.db +} + +func (s *ChannelStore) boostCacheActive(db sqlcgen.DBTX) bool { + return s.boostCache != nil && db == s.db +} + +// NewChannelStore 基于 pgx 连接池(或事务)创建 ChannelStore。 +func NewChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore { + s := &ChannelStore{db: db} + for _, opt := range opts { + opt(s) + } + if s.log == nil { + s.log = zap.NewNop() + } + if s.ids == nil { + s.ids = pgChannelIDAllocator{db: db} + } + if s.msgIDs == nil { + s.msgIDs = pgChannelMessageIDAllocator{db: db} + } + return s +} + +const channelColumns = `c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(c.username, ''), c.verified, +c.broadcast, c.megagroup, c.forum, c.forum_tabs, c.autotranslation, c.restricted_sponsored, c.broadcast_messages_allowed, c.send_paid_messages_stars, c.noforwards, c.join_to_send, c.join_request, c.signatures, c.pre_history_hidden, c.participants_hidden, c.antispam, +EXISTS (SELECT 1 FROM channel_invites ci WHERE ci.channel_id = c.id AND NOT ci.revoked) AS has_link, +c.linked_chat_id, c.monoforum, c.linked_monoforum_id, c.slowmode_seconds, c.boosts_unrestrict, c.default_banned_rights::text, +c.available_reactions::text, c.color_set, c.color, c.color_background_emoji_id, c.profile_color_set, c.profile_color, c.profile_color_background_emoji_id, c.emoji_status_document_id, c.emoji_status_until, +c.wallpaper::text, c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts, +c.ttl_period, c.date, c.deleted, c.photo_id, c.photo_dc_id, c.photo_stripped, +c.active_call_id, c.active_call_access_hash, c.active_call_not_empty` + +const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, +send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, +entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, +fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text, +reply_markup::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id` + +const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, +title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id, +read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, +unread_poll_votes_count` + +const ( + messageHistoryLoadBackward messageHistoryLoad = iota + messageHistoryLoadForward + messageHistoryLoadAround +) diff --git a/internal/store/postgres/channel_store_testkit_test.go b/internal/store/postgres/channel_store_testkit_test.go new file mode 100644 index 00000000..88c3e068 --- /dev/null +++ b/internal/store/postgres/channel_store_testkit_test.go @@ -0,0 +1,14 @@ +package postgres + +import ( + "telesrv/internal/domain" +) + +func hasUnreadChannelReactionPG(reactions domain.ChannelMessageReactions) bool { + for _, recent := range reactions.Recent { + if recent.Unread { + return true + } + } + return false +} diff --git a/internal/store/postgres/channel_topic_read.go b/internal/store/postgres/channel_topic_read.go new file mode 100644 index 00000000..370b58b2 --- /dev/null +++ b/internal/store/postgres/channel_topic_read.go @@ -0,0 +1,241 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// topicReadFallbackInbox 是 viewer 在某 topic 无已读行时的初始已读水位:话题根之前的消息 +// 视为已读(root-1),且不低于 available_min(裁剪线之前不可见)。General(topic 1) 的 +// root-1=0,即“从头未读”。这样迁移上线时不会把存量话题整体翻成未读暴涨。 +func topicReadFallbackInbox(topicID, availableMinID int) int { + base := topicID - 1 + if availableMinID > base { + base = availableMinID + } + if base < 0 { + base = 0 + } + return base +} + +// channelTopicMessageCond 返回某 topic 的消息归属条件,占位符承载 topicID。General(=1) 归并 +// reply_to_top_id IN (placeholder, 0):General 内新消息(=1)与开 forum 前历史消息(=0)都算 General。 +func channelTopicMessageCond(topicID int, placeholder string) string { + if topicID == domain.ForumGeneralTopicID { + // General 归并 reply_to_top_id ∈ {placeholder,0},但排除其它话题的根服务消息 + // (它们 reply_to_top_id=0,但 id 是某话题根,不属于 General)。 + return "(cm.reply_to_top_id = " + placeholder + " OR cm.reply_to_top_id = 0)" + + " AND NOT EXISTS (SELECT 1 FROM channel_forum_topics gt WHERE gt.channel_id = cm.channel_id AND gt.topic_id = cm.id AND NOT gt.deleted)" + } + return "cm.reply_to_top_id = " + placeholder +} + +// channelTopicReadInbox 取 viewer 在某 topic 的已读水位,无行时回落 fallback。 +func (s *ChannelStore) channelTopicReadInbox(ctx context.Context, db sqlcgen.DBTX, channelID, userID int64, topicID, availableMinID int) (int, error) { + var v int + err := db.QueryRow(ctx, `SELECT read_inbox_max_id FROM channel_topic_read WHERE channel_id = $1 AND user_id = $2 AND topic_id = $3`, channelID, userID, topicID).Scan(&v) + if errors.Is(err, pgx.ErrNoRows) { + return topicReadFallbackInbox(topicID, availableMinID), nil + } + if err != nil { + return 0, fmt.Errorf("select channel topic read: %w", err) + } + if fb := topicReadFallbackInbox(topicID, availableMinID); v < fb { + v = fb + } + return v, nil +} + +// topicReadWater 是 viewer 在某 topic 的 inbox/outbox 已读水位。 +type topicReadWater struct { + Inbox int + Outbox int +} + +// channelTopicReadBatch 批量取多个 topic 的 per-viewer 已读水位(getForumTopics 现算与下发用), +// 无行的 topic 回落 fallback(inbox=root-1,outbox=0)。返回 topicID -> 水位。 +func (s *ChannelStore) channelTopicReadBatch(ctx context.Context, channelID, userID int64, topicIDs []int32, availableMinID int) (map[int]topicReadWater, error) { + out := make(map[int]topicReadWater, len(topicIDs)) + for _, t := range topicIDs { + out[int(t)] = topicReadWater{Inbox: topicReadFallbackInbox(int(t), availableMinID)} + } + if len(topicIDs) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, `SELECT topic_id, read_inbox_max_id, read_outbox_max_id FROM channel_topic_read WHERE channel_id = $1 AND user_id = $2 AND topic_id = ANY($3::int[])`, channelID, userID, topicIDs) + if err != nil { + return nil, fmt.Errorf("batch channel topic read: %w", err) + } + defer rows.Close() + for rows.Next() { + var topicID, inbox, outbox int + if err := rows.Scan(&topicID, &inbox, &outbox); err != nil { + return nil, err + } + w := out[topicID] + if inbox > w.Inbox { + w.Inbox = inbox + } + w.Outbox = outbox + out[topicID] = w + } + return out, rows.Err() +} + +// channelTopicTopMessageID 取某 topic 当前最新可见消息 id,作为 readDiscussion 推进上界。 +func (s *ChannelStore) channelTopicTopMessageID(ctx context.Context, channelID int64, topicID, availableMinID int) (int, error) { + var v int + err := s.db.QueryRow(ctx, ` +SELECT COALESCE(MAX(cm.id), 0)::int +FROM channel_messages cm +WHERE cm.channel_id = $1 AND `+channelTopicMessageCond(topicID, "$2")+` AND cm.id > $3 AND NOT cm.deleted`, + channelID, topicID, availableMinID).Scan(&v) + if err != nil { + return 0, fmt.Errorf("channel topic top message: %w", err) + } + return v, nil +} + +// ReadChannelTopicHistory 推进 viewer 在 forum 单话题的 per-topic 已读水位(messages.readDiscussion), +// 不碰频道级 channel_members.read_inbox_max_id(消除话题间已读串扰),并返回需推进 outbox 的发送者。 +func (s *ChannelStore) ReadChannelTopicHistory(ctx context.Context, req domain.ReadChannelTopicHistoryRequest) (domain.ReadChannelTopicHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ReadChannelTopicHistoryResult{}, err + } + if !channel.Forum { + return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelForumMissing + } + topMax, err := s.channelTopicTopMessageID(ctx, req.ChannelID, req.TopicID, member.AvailableMinID) + if err != nil { + return domain.ReadChannelTopicHistoryResult{}, err + } + maxID := req.MaxID + if maxID <= 0 || maxID > topMax { + maxID = topMax + } + prev, err := s.channelTopicReadInbox(ctx, s.db, req.ChannelID, req.UserID, req.TopicID, member.AvailableMinID) + if err != nil { + return domain.ReadChannelTopicHistoryResult{}, err + } + if maxID <= prev { + return domain.ReadChannelTopicHistoryResult{Channel: channel, TopicID: req.TopicID, MaxID: prev, Changed: false, Pts: channel.Pts}, nil + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO channel_topic_read (channel_id, user_id, topic_id, read_inbox_max_id, read_inbox_date, updated_at) +VALUES ($1, $2, $3, $4, $5, now()) +ON CONFLICT (channel_id, user_id, topic_id) DO UPDATE SET + read_inbox_date = CASE WHEN channel_topic_read.read_inbox_max_id < EXCLUDED.read_inbox_max_id THEN EXCLUDED.read_inbox_date ELSE channel_topic_read.read_inbox_date END, + read_inbox_max_id = GREATEST(channel_topic_read.read_inbox_max_id, EXCLUDED.read_inbox_max_id), + updated_at = now()`, + req.ChannelID, req.UserID, req.TopicID, maxID, req.Date); err != nil { + return domain.ReadChannelTopicHistoryResult{}, fmt.Errorf("upsert channel topic read inbox: %w", err) + } + outbox, err := s.advanceChannelTopicReadOutbox(ctx, req.ChannelID, req.UserID, req.TopicID, prev, maxID) + if err != nil { + return domain.ReadChannelTopicHistoryResult{}, err + } + return domain.ReadChannelTopicHistoryResult{Channel: channel, TopicID: req.TopicID, MaxID: maxID, Changed: true, Pts: channel.Pts, OutboxUpdates: outbox}, nil +} + +// advanceChannelTopicReadOutbox 推进话题内被本次已读覆盖到的发送者的 per-topic read_outbox 水位, +// 返回这些发送者用于在线下发 updateReadChannelDiscussionOutbox(已读回执 ✓✓)。回执 UPSERT 幂等, +// 无需与 inbox 强原子。 +func (s *ChannelStore) advanceChannelTopicReadOutbox(ctx context.Context, channelID, readerUserID int64, topicID, prev, maxID int) ([]domain.ChannelReadOutboxUpdate, error) { + rows, err := s.db.Query(ctx, ` +SELECT DISTINCT cm.sender_user_id +FROM channel_messages cm +WHERE cm.channel_id = $1 AND `+channelTopicMessageCond(topicID, "$2")+` + AND cm.id > $3 AND cm.id <= $4 AND cm.sender_user_id <> $5 AND cm.sender_user_id <> 0 AND NOT cm.deleted`, + channelID, topicID, prev, maxID, readerUserID) + if err != nil { + return nil, fmt.Errorf("scan channel topic outbox senders: %w", err) + } + senders := make([]int64, 0, 8) + for rows.Next() { + var sender int64 + if err := rows.Scan(&sender); err != nil { + rows.Close() + return nil, err + } + senders = append(senders, sender) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + updates := make([]domain.ChannelReadOutboxUpdate, 0, len(senders)) + for _, sender := range senders { + if _, err := s.db.Exec(ctx, ` +INSERT INTO channel_topic_read (channel_id, user_id, topic_id, read_outbox_max_id, updated_at) +VALUES ($1, $2, $3, $4, now()) +ON CONFLICT (channel_id, user_id, topic_id) DO UPDATE SET + read_outbox_max_id = GREATEST(channel_topic_read.read_outbox_max_id, EXCLUDED.read_outbox_max_id), + updated_at = now()`, + channelID, sender, topicID, maxID); err != nil { + return nil, fmt.Errorf("upsert channel topic read outbox: %w", err) + } + updates = append(updates, domain.ChannelReadOutboxUpdate{UserID: sender, MaxID: maxID}) + } + return updates, nil +} + +// GeneralForumTopic 现算 General 话题(id=1)对 viewer 的状态:归属 reply_to_top_id ∈ {0,1} 且 +// 排除其它话题根服务消息,未读用 General 自己的 per-topic 水位(不被普通话题已读串扰)。 +func (s *ChannelStore) GeneralForumTopic(ctx context.Context, viewerUserID, channelID int64) (domain.ChannelForumTopic, error) { + if viewerUserID == 0 || channelID == 0 { + return domain.ChannelForumTopic{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelForumTopic{}, err + } + if !channel.Forum { + return domain.ChannelForumTopic{}, domain.ErrChannelForumMissing + } + gid := domain.ForumGeneralTopicID + waters, err := s.channelTopicReadBatch(ctx, channelID, viewerUserID, []int32{int32(gid)}, member.AvailableMinID) + if err != nil { + return domain.ChannelForumTopic{}, err + } + water := waters[gid] + cond := channelTopicMessageCond(gid, "$2") + var topMsg int + if err := s.db.QueryRow(ctx, ` +SELECT COALESCE(MAX(cm.id), 0)::int FROM channel_messages cm +WHERE cm.channel_id = $1 AND `+cond+` AND cm.id > $3 AND NOT cm.deleted`, + channelID, gid, member.AvailableMinID).Scan(&topMsg); err != nil { + return domain.ChannelForumTopic{}, fmt.Errorf("general top message: %w", err) + } + var unread int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int FROM ( + SELECT 1 FROM channel_messages cm + WHERE cm.channel_id = $1 AND `+cond+` AND cm.id > $3 AND cm.sender_user_id <> $4 AND NOT cm.deleted + LIMIT $5 +) g`, channelID, gid, water.Inbox, viewerUserID, domain.MaxDialogUnreadCount).Scan(&unread); err != nil { + return domain.ChannelForumTopic{}, fmt.Errorf("general unread: %w", err) + } + return domain.ChannelForumTopic{ + ChannelID: channelID, + TopicID: gid, + Title: "General", + CreatorUserID: channel.CreatorUserID, + Date: channel.Date, + TopMessageID: topMsg, + ReadInboxMaxID: water.Inbox, + ReadOutboxMaxID: water.Outbox, + UnreadCount: unread, + UnreadMentionsCount: s.countChannelUnreadMentionsForTop(ctx, viewerUserID, channelID, gid), + UnreadReactionsCount: s.countChannelUnreadReactionsForTop(ctx, viewerUserID, channelID, gid, member.AvailableMinID), + }, nil +} diff --git a/internal/store/postgres/channel_topic_read_integration_test.go b/internal/store/postgres/channel_topic_read_integration_test.go new file mode 100644 index 00000000..08b0bed0 --- /dev/null +++ b/internal/store/postgres/channel_topic_read_integration_test.go @@ -0,0 +1,263 @@ +package postgres + +import ( + "context" + "telesrv/internal/domain" + "testing" +) + +// TestChannelTopicReadIsolation 验证 forum 话题级已读不被频道级单一水位串扰: +// 交错发送使 topicB 的消息 id 都小于 topicA 的最新消息 id,读 topicA 到最新后, +// 旧的频道级水位会把 topicB 误判已读,而 per-topic 水位下 topicB 未读保持不变。 +// 同时验证 outbox 已读回执(S6)。 +func TestChannelTopicReadIsolation(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 81, Phone: "+1778" + suffix + "81", FirstName: "TROwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 82, Phone: "+1778" + suffix + "82", FirstName: "TRMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "TopicRead " + suffix, + Megagroup: true, + Forum: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700002100, + }) + if err != nil { + t.Fatalf("create forum channel: %v", err) + } + channelID = created.Channel.ID + + topicA, err := channels.CreateForumTopic(ctx, domain.CreateChannelForumTopicRequest{UserID: owner.ID, ChannelID: channelID, Title: "A " + suffix, RandomID: 222001, Date: 1700002101}) + if err != nil { + t.Fatalf("topic A: %v", err) + } + topicB, err := channels.CreateForumTopic(ctx, domain.CreateChannelForumTopicRequest{UserID: owner.ID, ChannelID: channelID, Title: "B " + suffix, RandomID: 222002, Date: 1700002102}) + if err != nil { + t.Fatalf("topic B: %v", err) + } + + send := func(rid int64, topicID, date int) domain.ChannelMessage { + t.Helper() + res, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: rid, + Message: "m", + ReplyTo: &domain.MessageReply{TopMessageID: topicID, ForumTopic: true}, + Date: date, + }) + if err != nil { + t.Fatalf("send to topic %d: %v", topicID, err) + } + return res.Message + } + // 交错发送:topicB 两条都在 topicA 最新一条之前,id 更小。 + _ = send(222003, topicA.Topic.TopicID, 1700002103) // A1 + _ = send(222004, topicB.Topic.TopicID, 1700002104) // B1 + _ = send(222005, topicB.Topic.TopicID, 1700002105) // B2 + a2 := send(222006, topicA.Topic.TopicID, 1700002106) // A2(全局最大 id) + + if u := topicUnread(t, channels, ctx, member.ID, channelID, topicA.Topic.TopicID); u != 2 { + t.Fatalf("before read: topicA unread = %d, want 2", u) + } + if u := topicUnread(t, channels, ctx, member.ID, channelID, topicB.Topic.TopicID); u != 2 { + t.Fatalf("before read: topicB unread = %d, want 2", u) + } + + res, err := channels.ReadChannelTopicHistory(ctx, domain.ReadChannelTopicHistoryRequest{ + UserID: member.ID, + ChannelID: channelID, + TopicID: topicA.Topic.TopicID, + MaxID: a2.ID, + Date: 1700002110, + }) + if err != nil { + t.Fatalf("read topic A: %v", err) + } + if !res.Changed || res.MaxID != a2.ID { + t.Fatalf("read topic A result = %+v, want changed with maxID %d", res, a2.ID) + } + + // 关键断言:读 topicA 后 topicA 未读=0,topicB 未读仍=2(不被频道级高水位污染)。 + if u := topicUnread(t, channels, ctx, member.ID, channelID, topicA.Topic.TopicID); u != 0 { + t.Fatalf("after read: topicA unread = %d, want 0", u) + } + if u := topicUnread(t, channels, ctx, member.ID, channelID, topicB.Topic.TopicID); u != 2 { + t.Fatalf("after read: topicB unread = %d, want 2 (cross-topic contamination)", u) + } + + // 幂等:再读一次同水位 → Changed=false。 + again, err := channels.ReadChannelTopicHistory(ctx, domain.ReadChannelTopicHistoryRequest{ + UserID: member.ID, ChannelID: channelID, TopicID: topicA.Topic.TopicID, MaxID: a2.ID, Date: 1700002111, + }) + if err != nil || again.Changed { + t.Fatalf("re-read topic A = %+v err %v, want not changed", again, err) + } + + // outbox 回执(S6):owner 在 topicA 的消息被 member 读到 a2 → 回执含 owner。 + found := false + for _, o := range res.OutboxUpdates { + if o.UserID == owner.ID && o.MaxID == a2.ID { + found = true + } + } + if !found { + t.Fatalf("outbox updates = %+v, want owner %d at %d", res.OutboxUpdates, owner.ID, a2.ID) + } +} + +func topicUnread(t *testing.T, channels *ChannelStore, ctx context.Context, userID, channelID int64, topicID int) int { + t.Helper() + list, err := channels.GetForumTopicsByID(ctx, userID, channelID, []int{topicID}) + if err != nil { + t.Fatalf("get topic %d: %v", topicID, err) + } + for _, topic := range list.Topics { + if topic.TopicID == topicID { + return topic.UnreadCount + } + } + t.Fatalf("topic %d not found in %+v", topicID, list.Topics) + return -1 +} + +// TestChannelGeneralTopicRead 验证 General 话题(id=1)独立已读:General 未读排除其它话题的根 +// 服务消息,且 ReadChannelTopicHistory(General) 只推进 General 自己的水位。 +func TestChannelGeneralTopicRead(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 83, Phone: "+1778" + suffix + "83", FirstName: "GenOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 84, Phone: "+1778" + suffix + "84", FirstName: "GenMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, Title: "General " + suffix, Megagroup: true, Forum: true, + MemberUserIDs: []int64{member.ID}, Date: 1700003100, + }) + if err != nil { + t.Fatalf("create forum channel: %v", err) + } + channelID = created.Channel.ID + + // 建一个普通话题:其根服务消息 reply_to_top_id=0,必须被 General 现算排除。 + if _, err := channels.CreateForumTopic(ctx, domain.CreateChannelForumTopicRequest{ + UserID: owner.ID, ChannelID: channelID, Title: "Topic " + suffix, RandomID: 333009, Date: 1700003101, + }); err != nil { + t.Fatalf("create topic: %v", err) + } + + sendGeneral := func(rid int64, date int) domain.ChannelMessage { + t.Helper() + // General 消息按真实客户端行为不带 top_msg_id(reply_to_top_id=0),由 General 现算归并。 + res, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: rid, + Message: "g", + Date: date, + }) + if err != nil { + t.Fatalf("send general: %v", err) + } + return res.Message + } + // 基线:建话题后、发 General 消息前的 General 未读(话题根已排除,仅含频道创建系统消息)。 + base, err := channels.GeneralForumTopic(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("general baseline: %v", err) + } + + _ = sendGeneral(333010, 1700003110) + g2 := sendGeneral(333011, 1700003111) + + // 基线之后新建话题 A2 并发一条消息:A2 的根服务消息与普通消息都绝不能计入 General。 + topicA2, err := channels.CreateForumTopic(ctx, domain.CreateChannelForumTopicRequest{ + UserID: owner.ID, ChannelID: channelID, Title: "A2 " + suffix, RandomID: 333012, Date: 1700003112, + }) + if err != nil { + t.Fatalf("create topic A2: %v", err) + } + a1, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, ChannelID: channelID, RandomID: 333013, Message: "a", + ReplyTo: &domain.MessageReply{TopMessageID: topicA2.Topic.TopicID, ForumTopic: true}, Date: 1700003113, + }) + if err != nil { + t.Fatalf("send topic A2 message: %v", err) + } + + gen, err := channels.GeneralForumTopic(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("general topic: %v", err) + } + // 只多 2 条 General 消息;A2 根服务消息与 a1 都不计入(否则差为 4)。 + if gen.UnreadCount != base.UnreadCount+2 { + t.Fatalf("general unread = %d, want baseline %d + 2 (A2 root+message excluded)", gen.UnreadCount, base.UnreadCount) + } + if gen.TopMessageID != g2.ID { + t.Fatalf("general top message = %d, want %d (A2 root/message excluded)", gen.TopMessageID, g2.ID) + } + + // 读话题 A2 不污染 General。 + if _, err := channels.ReadChannelTopicHistory(ctx, domain.ReadChannelTopicHistoryRequest{ + UserID: member.ID, ChannelID: channelID, TopicID: topicA2.Topic.TopicID, MaxID: a1.Message.ID, Date: 1700003120, + }); err != nil { + t.Fatalf("read topic A2: %v", err) + } + genAfterTopic, err := channels.GeneralForumTopic(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("general after topic read: %v", err) + } + if genAfterTopic.UnreadCount != gen.UnreadCount { + t.Fatalf("reading topic A2 changed general unread %d -> %d (contamination)", gen.UnreadCount, genAfterTopic.UnreadCount) + } + + // 读 General 清零并推进 General 自己的水位。 + res, err := channels.ReadChannelTopicHistory(ctx, domain.ReadChannelTopicHistoryRequest{ + UserID: member.ID, ChannelID: channelID, TopicID: domain.ForumGeneralTopicID, MaxID: g2.ID, Date: 1700003121, + }) + if err != nil || !res.Changed { + t.Fatalf("read general = %+v err %v, want changed", res, err) + } + genAfter, err := channels.GeneralForumTopic(ctx, member.ID, channelID) + if err != nil { + t.Fatalf("general after read: %v", err) + } + if genAfter.UnreadCount != 0 || genAfter.ReadInboxMaxID != g2.ID { + t.Fatalf("general after read = %+v, want unread 0 read_inbox %d", genAfter, g2.ID) + } +} diff --git a/internal/store/postgres/channel_topics.go b/internal/store/postgres/channel_topics.go new file mode 100644 index 00000000..fbc30489 --- /dev/null +++ b/internal/store/postgres/channel_topics.go @@ -0,0 +1,1031 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "sort" + "strings" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *ChannelStore) SetForum(ctx context.Context, userID, channelID int64, enabled, tabs bool) (domain.Channel, error) { + if userID == 0 || channelID == 0 { + return domain.Channel{}, domain.ErrChannelInvalid + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.Channel{}, fmt.Errorf("toggle channel forum: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.Channel{}, fmt.Errorf("begin toggle channel forum: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID) + if err != nil { + return domain.Channel{}, err + } + if !channel.Megagroup || channel.Broadcast { + return domain.Channel{}, domain.ErrChannelNotModified + } + if member.Role != domain.ChannelRoleCreator { + return domain.Channel{}, domain.ErrChannelAdminRequired + } + if enabled && channel.LinkedChatID != 0 { + return domain.Channel{}, domain.ErrChatDiscussionUnallowed + } + prevForum := channel.Forum + prevTabs := channel.ForumTabs + nextTabs := enabled && tabs + if _, err := tx.Exec(ctx, ` +UPDATE channels +SET forum = $2, + forum_tabs = $3, + updated_at = now() +WHERE id = $1`, channelID, enabled, nextTabs); err != nil { + return domain.Channel{}, fmt.Errorf("update channel forum: %w", err) + } + channel.Forum = enabled + channel.ForumTabs = nextTabs + if err := markUserChannelMemberIndexForumTx(ctx, tx, channelID, enabled); err != nil { + return domain.Channel{}, err + } + if prevForum != channel.Forum || prevTabs != channel.ForumTabs { + if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{ + ChannelID: channelID, + UserID: userID, + Date: nowUnix(), + Type: domain.ChannelAdminLogToggleForum, + PrevBool: prevForum, + NewBool: enabled, + }); err != nil { + return domain.Channel{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.Channel{}, fmt.Errorf("commit toggle channel forum: %w", err) + } + committed = true + return channel, nil +} + +func (s *ChannelStore) SetChannelViewForumAsMessages(ctx context.Context, userID, channelID int64, enabled bool) (bool, error) { + if userID == 0 || channelID == 0 { + return false, nil + } + var changed bool + if err := s.db.QueryRow(ctx, ` +WITH target AS ( + SELECT c.id AS channel_id, c.top_message_id, c.date AS top_message_date + FROM channels c + JOIN channel_members m ON m.channel_id = c.id + WHERE c.id = $2 AND m.user_id = $1 AND m.status = 'active' AND NOT c.deleted +), +ensured AS ( + INSERT INTO channel_dialogs (user_id, channel_id, top_message_id, top_message_date) + SELECT $1, channel_id, top_message_id, top_message_date FROM target + ON CONFLICT (user_id, channel_id) DO NOTHING +), +updated_dialog AS ( + UPDATE channel_dialogs d + SET view_forum_as_messages = $3, updated_at = now() + WHERE d.user_id = $1 AND d.channel_id = $2 + AND EXISTS (SELECT 1 FROM target) + AND d.view_forum_as_messages IS DISTINCT FROM $3::boolean + RETURNING d.user_id +) +SELECT EXISTS (SELECT 1 FROM updated_dialog)::boolean`, userID, channelID, enabled).Scan(&changed); err != nil { + return false, fmt.Errorf("set channel view forum as messages: %w", err) + } + // 连接池写后同步失效本进程 dialog 缓存,保证同实例 read-your-write; + // 跨实例由 dialog_light NOTIFY 异步兜底。 + if changed && s.dialogCacheActive(s.db) { + s.dialogCache.delete(userID, channelID) + } + return changed, nil +} + +func (s *ChannelStore) CreateForumTopic(ctx context.Context, req domain.CreateChannelForumTopicRequest) (domain.CreateChannelForumTopicResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.RandomID == 0 { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + title := strings.TrimSpace(req.Title) + if title == "" && !req.TitleMissing { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.CreateChannelForumTopicResult{}, err + } + if !channel.Forum || channel.Broadcast || !channel.Megagroup { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelForumMissing + } + if !canSendChannelMessage(channel, member) { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelWriteForbidden + } + if req.IconColor == 0 { + req.IconColor = domain.DefaultForumTopicIconColor + } + res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + RandomID: req.RandomID, + SendAs: req.SendAs, + Action: &domain.ChannelMessageAction{ + Type: domain.ChannelActionTopicCreate, + Title: title, + IconColor: req.IconColor, + IconEmojiID: req.IconEmojiID, + TitleMissing: req.TitleMissing, + }, + Date: req.Date, + }) + if err != nil { + return domain.CreateChannelForumTopicResult{}, err + } + if res.Message.Action == nil || res.Message.Action.Type != domain.ChannelActionTopicCreate { + return domain.CreateChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO channel_forum_topics ( + channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, + title_missing, date, top_message_id, read_inbox_max_id, read_outbox_max_id +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $2, $2, $2) +ON CONFLICT (channel_id, topic_id) DO NOTHING`, + req.ChannelID, res.Message.ID, req.UserID, title, req.IconColor, req.IconEmojiID, req.TitleMissing, res.Message.Date); err != nil { + return domain.CreateChannelForumTopicResult{}, fmt.Errorf("insert forum topic: %w", err) + } + topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, res.Message.ID) + if err != nil { + return domain.CreateChannelForumTopicResult{}, err + } + return domain.CreateChannelForumTopicResult{ + Channel: res.Channel, + Topic: topic, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + Duplicate: res.Duplicate, + }, nil +} + +func (s *ChannelStore) EditForumTopic(ctx context.Context, req domain.EditChannelForumTopicRequest) (domain.EditChannelForumTopicResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + if !channel.Forum { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelForumMissing + } + topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + if !canManageForumTopic(channel, member, topic, req.UserID) { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelAdminRequired + } + next := topic + action := domain.ChannelMessageAction{Type: domain.ChannelActionTopicEdit} + changed := false + if req.Title != nil { + title := strings.TrimSpace(*req.Title) + if title == "" { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelInvalid + } + if next.Title != title { + next.Title = title + action.Title = title + changed = true + } + } + if req.IconEmojiID != nil && next.IconEmojiID != *req.IconEmojiID { + next.IconEmojiID = *req.IconEmojiID + action.IconEmojiID = *req.IconEmojiID + action.IconEmojiIDSet = true + changed = true + } + if req.Closed != nil && next.Closed != *req.Closed { + next.Closed = *req.Closed + action.Closed = boolPtr(*req.Closed) + changed = true + } + if req.Hidden != nil && next.Hidden != *req.Hidden { + next.Hidden = *req.Hidden + action.Hidden = boolPtr(*req.Hidden) + changed = true + } + if !changed { + return domain.EditChannelForumTopicResult{}, domain.ErrChannelNotModified + } + res, err := s.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: req.UserID, + ChannelID: req.ChannelID, + ReplyTo: &domain.MessageReply{ + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, + MessageID: req.TopicID, + TopMessageID: req.TopicID, + }, + Action: &action, + Date: req.Date, + }) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + if _, err := s.db.Exec(ctx, ` +UPDATE channel_forum_topics +SET title = $3, + icon_emoji_id = $4, + closed = $5, + hidden = $6, + top_message_id = GREATEST(top_message_id, $7), + updated_at = now() +WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, + req.ChannelID, req.TopicID, next.Title, next.IconEmojiID, next.Closed, next.Hidden, res.Message.ID); err != nil { + return domain.EditChannelForumTopicResult{}, fmt.Errorf("update forum topic: %w", err) + } + topic, err = s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) + if err != nil { + return domain.EditChannelForumTopicResult{}, err + } + return domain.EditChannelForumTopicResult{ + Channel: res.Channel, + Topic: topic, + Message: res.Message, + Event: res.Event, + Recipients: res.Recipients, + }, nil +} + +func (s *ChannelStore) UpdatePinnedForumTopic(ctx context.Context, req domain.UpdateChannelForumTopicPinnedRequest) (domain.UpdateChannelForumTopicPinnedResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, err + } + if !channel.Forum { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelForumMissing + } + topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) + if err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, err + } + if !canPinChannelMessages(channel, member) { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelAdminRequired + } + if topic.Pinned == req.Pinned { + return domain.UpdateChannelForumTopicPinnedResult{}, domain.ErrChannelNotModified + } + pinnedOrder := 0 + if req.Pinned { + pinnedOrder = topic.PinnedOrder + if pinnedOrder == 0 { + pinnedOrder, err = s.nextForumTopicPinnedOrder(ctx, req.ChannelID) + if err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, err + } + } + } + if _, err := s.db.Exec(ctx, ` +UPDATE channel_forum_topics +SET pinned = $3, pinned_order = $4, updated_at = now() +WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, + req.ChannelID, req.TopicID, req.Pinned, pinnedOrder); err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, fmt.Errorf("update pinned forum topic: %w", err) + } + topic, err = s.getForumTopic(ctx, s.db, req.ChannelID, req.TopicID) + if err != nil { + return domain.UpdateChannelForumTopicPinnedResult{}, err + } + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.UpdateChannelForumTopicPinnedResult{Channel: channel, Topic: topic, Recipients: recipients}, nil +} + +func (s *ChannelStore) ReorderPinnedForumTopics(ctx context.Context, req domain.ReorderChannelPinnedForumTopicsRequest) (domain.ReorderChannelPinnedForumTopicsResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || len(req.Order) > domain.MaxChannelForumTopicIDs { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelInvalid + } + channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ReorderChannelPinnedForumTopicsResult{}, err + } + if !channel.Forum { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelForumMissing + } + if !canPinChannelMessages(channel, member) { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrChannelAdminRequired + } + seen := make(map[int]struct{}, len(req.Order)) + order := make([]int, 0, len(req.Order)) + for _, id := range req.Order { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + topic, err := s.getForumTopic(ctx, s.db, req.ChannelID, id) + if err != nil || !topic.Pinned { + if req.Force { + continue + } + return domain.ReorderChannelPinnedForumTopicsResult{}, domain.ErrMessageIDInvalid + } + seen[id] = struct{}{} + order = append(order, id) + } + for i, id := range order { + if _, err := s.db.Exec(ctx, ` +UPDATE channel_forum_topics +SET pinned_order = $3, updated_at = now() +WHERE channel_id = $1 AND topic_id = $2 AND pinned AND NOT deleted`, req.ChannelID, id, len(order)-i); err != nil { + return domain.ReorderChannelPinnedForumTopicsResult{}, fmt.Errorf("reorder pinned forum topics: %w", err) + } + } + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.ReorderChannelPinnedForumTopicsResult{Channel: channel, Order: order, Recipients: recipients}, nil +} + +func (s *ChannelStore) DeleteForumTopicHistory(ctx context.Context, req domain.DeleteChannelForumTopicHistoryRequest) (domain.DeleteChannelHistoryResult, error) { + if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelInvalid + } + if req.Date == 0 { + req.Date = nowUnix() + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("delete forum topic history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("begin delete forum topic history: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if !channel.Forum { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelForumMissing + } + topic, err := s.getForumTopic(ctx, tx, req.ChannelID, req.TopicID) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + if !canManageForumTopic(channel, member, topic, req.UserID) && !canDeleteAnyChannelMessage(member) { + return domain.DeleteChannelHistoryResult{}, domain.ErrChannelAdminRequired + } + rows, err := tx.Query(ctx, ` +SELECT id +FROM channel_messages +WHERE channel_id = $1 AND NOT deleted AND (id = $2 OR reply_to_top_id = $2) +ORDER BY id DESC +LIMIT $3`, req.ChannelID, req.TopicID, domain.MaxDeleteHistoryBatch) + if err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("list forum topic delete ids: %w", err) + } + ids := make([]int, 0, domain.MaxDeleteHistoryBatch) + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return domain.DeleteChannelHistoryResult{}, err + } + rows.Close() + deleted, event, channel, err := s.deleteChannelMessagesTx(ctx, tx, channel, member, ids, req.UserID, req.Date) + if err != nil { + return domain.DeleteChannelHistoryResult{}, err + } + remaining := 0 + if err := tx.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM channel_messages +WHERE channel_id = $1 AND NOT deleted AND (id = $2 OR reply_to_top_id = $2)`, req.ChannelID, req.TopicID).Scan(&remaining); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("count remaining forum topic messages: %w", err) + } + offset := 0 + if remaining > 0 { + offset = 1 + } else if _, err := tx.Exec(ctx, ` +UPDATE channel_forum_topics +SET deleted = true, updated_at = now() +WHERE channel_id = $1 AND topic_id = $2`, req.ChannelID, req.TopicID); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("mark forum topic deleted: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.DeleteChannelHistoryResult{}, fmt.Errorf("commit delete forum topic history: %w", err) + } + committed = true + recipients, _ := s.ListActiveChannelMemberIDs(ctx, req.UserID, req.ChannelID, 0) + return domain.DeleteChannelHistoryResult{Channel: channel, Event: event, DeletedIDs: deleted, Recipients: recipients, Offset: offset}, nil +} + +func (s *ChannelStore) ListForumTopics(ctx context.Context, viewerUserID int64, filter domain.ChannelForumTopicFilter) (domain.ChannelForumTopicList, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + if !channel.Forum { + return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelForumTopicsLimit { + limit = domain.MaxChannelForumTopicsLimit + } + query := strings.TrimSpace(strings.ToLower(filter.Query)) + countArgs := []any{filter.ChannelID, member.AvailableMinID, query} + countSQL := ` +SELECT COUNT(*)::int +FROM channel_forum_topics +WHERE channel_id = $1 AND NOT deleted AND topic_id > $2 + AND ($3 = '' OR POSITION($3 IN LOWER(title)) > 0)` + var total int + if err := s.db.QueryRow(ctx, countSQL, countArgs...).Scan(&total); err != nil { + return domain.ChannelForumTopicList{}, fmt.Errorf("count forum topics: %w", err) + } + args := []any{filter.ChannelID, member.AvailableMinID, query} + where := `channel_id = $1 AND NOT deleted AND topic_id > $2 AND ($3 = '' OR POSITION($3 IN LOWER(title)) > 0)` + offsetID := filter.OffsetTopic + if offsetID == 0 { + offsetID = filter.OffsetID + } + if filter.OffsetDate != 0 { + args = append(args, filter.OffsetDate, offsetID) + where += fmt.Sprintf(" AND (date, topic_id) < ($%d, $%d)", len(args)-1, len(args)) + } else if offsetID != 0 { + args = append(args, offsetID) + where += fmt.Sprintf(" AND topic_id < $%d", len(args)) + } + args = append(args, limit) + rows, err := s.db.Query(ctx, ` +SELECT `+channelForumTopicColumns+` +FROM channel_forum_topics +WHERE `+where+` +ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelForumTopicList{}, fmt.Errorf("list forum topics: %w", err) + } + defer rows.Close() + topics := make([]domain.ChannelForumTopic, 0, limit) + for rows.Next() { + topic, err := scanChannelForumTopic(rows) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + topics = append(topics, topic) + } + if err := rows.Err(); err != nil { + return domain.ChannelForumTopicList{}, err + } + if err := s.populateForumTopicViewerCounters(ctx, viewerUserID, filter.ChannelID, topics, member.AvailableMinID); err != nil { + return domain.ChannelForumTopicList{}, err + } + messages, err := s.forumTopicRootMessages(ctx, filter.ChannelID, topics, member.AvailableMinID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) + return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog, Topics: topics, Messages: messages, Count: total}, nil +} + +func (s *ChannelStore) GetForumTopicsByID(ctx context.Context, viewerUserID, channelID int64, ids []int) (domain.ChannelForumTopicList, error) { + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + if !channel.Forum { + return domain.ChannelForumTopicList{}, domain.ErrChannelForumMissing + } + if len(ids) == 0 { + dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) + return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog}, nil + } + id32, _, err := validUniqueChannelMessageIDs(ids) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelForumTopicColumns+` +FROM channel_forum_topics +WHERE channel_id = $1 AND NOT deleted AND topic_id > $2 AND topic_id = ANY($3::int[]) +ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC`, channelID, member.AvailableMinID, id32) + if err != nil { + return domain.ChannelForumTopicList{}, fmt.Errorf("get forum topics by id: %w", err) + } + defer rows.Close() + topics := make([]domain.ChannelForumTopic, 0, len(id32)) + for rows.Next() { + topic, err := scanChannelForumTopic(rows) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + topics = append(topics, topic) + } + if err := rows.Err(); err != nil { + return domain.ChannelForumTopicList{}, err + } + if err := s.populateForumTopicViewerCounters(ctx, viewerUserID, channelID, topics, member.AvailableMinID); err != nil { + return domain.ChannelForumTopicList{}, err + } + messages, err := s.forumTopicRootMessages(ctx, channelID, topics, member.AvailableMinID) + if err != nil { + return domain.ChannelForumTopicList{}, err + } + dialog, _ := s.getChannelDialog(ctx, s.db, viewerUserID, channel) + return domain.ChannelForumTopicList{Channel: channel, Dialog: dialog, Topics: topics, Messages: messages, Count: len(topics)}, nil +} + +func (s *ChannelStore) ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) { + source, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID) + if err != nil { + return domain.ChannelHistory{}, err + } + root, err := s.getChannelMessage(ctx, s.db, filter.ChannelID, filter.RootMessageID) + if err != nil || root.Deleted || root.ID <= member.AvailableMinID { + return domain.ChannelHistory{}, domain.ErrMessageIDInvalid + } + target := source + availableMinID := member.AvailableMinID + extraChannels := []domain.Channel(nil) + rootID := root.ID + if source.Broadcast { + if root.Discussion == nil || root.Discussion.ChannelID == 0 || root.Discussion.MessageID == 0 { + return domain.ChannelHistory{Channel: source}, nil + } + linked, err := getChannelByID(ctx, s.db, root.Discussion.ChannelID) + if err != nil { + return domain.ChannelHistory{Channel: source}, nil + } + target = linked + rootID = root.Discussion.MessageID + availableMinID = 0 + if linkedMember, err := s.getChannelMember(ctx, s.db, linked.ID, viewerUserID); err == nil && validateChannelMemberVisible(linkedMember) == nil { + availableMinID = linkedMember.AvailableMinID + } + extraChannels = append(extraChannels, source) + } + targetRoot, err := s.getChannelMessage(ctx, s.db, target.ID, rootID) + if err != nil || targetRoot.Deleted || targetRoot.ID <= availableMinID { + return domain.ChannelHistory{Channel: target, Channels: extraChannels}, nil + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxChannelRepliesLimit { + limit = domain.MaxChannelRepliesLimit + } + filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset) + count, err := s.countChannelReplies(ctx, target.ID, rootID, availableMinID, filter) + if err != nil { + return domain.ChannelHistory{}, err + } + messages, err := s.queryChannelRepliesPage(ctx, target.ID, rootID, availableMinID, filter, limit) + if err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessageReplies(ctx, s.db, viewerUserID, target, messages); err != nil { + return domain.ChannelHistory{}, err + } + if err := s.populateChannelMessagesReactions(ctx, s.db, viewerUserID, []domain.Channel{target}, messages); err != nil { + return domain.ChannelHistory{}, err + } + topics := []domain.ChannelForumTopic(nil) + if target.Forum { + if topic, err := s.getForumTopic(ctx, s.db, target.ID, rootID); err == nil && !topic.Hidden { + topic = s.topicWithViewerCounters(ctx, viewerUserID, target.ID, topic, availableMinID, availableMinID) + topics = append(topics, topic) + } else if err != nil && !errors.Is(err, domain.ErrMessageIDInvalid) { + return domain.ChannelHistory{}, err + } + } + return domain.ChannelHistory{Channel: target, Channels: extraChannels, Topics: topics, Messages: messages, Count: count}, nil +} + +func (s *ChannelStore) getForumTopic(ctx context.Context, db sqlcgen.DBTX, channelID int64, topicID int) (domain.ChannelForumTopic, error) { + if channelID == 0 || topicID == 0 { + return domain.ChannelForumTopic{}, domain.ErrMessageIDInvalid + } + topic, err := scanChannelForumTopic(db.QueryRow(ctx, ` +SELECT `+channelForumTopicColumns+` +FROM channel_forum_topics +WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, channelID, topicID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.ChannelForumTopic{}, domain.ErrMessageIDInvalid + } + return topic, err +} + +func (s *ChannelStore) forumTopicRootMessages(ctx context.Context, channelID int64, topics []domain.ChannelForumTopic, availableMinID int) ([]domain.ChannelMessage, error) { + if len(topics) == 0 { + return nil, nil + } + ids := make([]int, 0, len(topics)) + seen := make(map[int]struct{}, len(topics)) + for _, topic := range topics { + if topic.TopMessageID <= 0 { + continue + } + if _, ok := seen[topic.TopMessageID]; ok { + continue + } + seen[topic.TopMessageID] = struct{}{} + ids = append(ids, topic.TopMessageID) + } + id32, _, err := validUniqueChannelMessageIDs(ids) + if err != nil { + return nil, err + } + if len(id32) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE channel_id = $1 AND id = ANY($2::int[]) AND id > $3 AND NOT deleted +ORDER BY id DESC`, channelID, id32, availableMinID) + if err != nil { + return nil, fmt.Errorf("list forum topic root messages: %w", err) + } + defer rows.Close() + messages := make([]domain.ChannelMessage, 0, len(id32)) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return nil, err + } + messages = append(messages, msg) + } + if err := rows.Err(); err != nil { + return nil, err + } + return messages, nil +} + +func (s *ChannelStore) nextForumTopicPinnedOrder(ctx context.Context, channelID int64) (int, error) { + var maxOrder int + if err := s.db.QueryRow(ctx, ` +SELECT COALESCE(MAX(pinned_order), 0)::int +FROM channel_forum_topics +WHERE channel_id = $1 AND pinned AND NOT deleted`, channelID).Scan(&maxOrder); err != nil { + return 0, fmt.Errorf("next forum topic pinned order: %w", err) + } + return maxOrder + 1, nil +} + +func (s *ChannelStore) populateChannelMessageReplies(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, channel domain.Channel, messages []domain.ChannelMessage) error { + if len(messages) == 0 || channel.ID == 0 { + return nil + } + indexes := make(map[channelReplyStatKey][]int) + rootsByChannel := make(map[int64][]int32) + readMaxByChannel := make(map[int64]int) + for i := range messages { + targetChannelID := channel.ID + rootID := messages[i].ID + replies := &domain.ChannelMessageReplies{} + if messages[i].Discussion != nil && messages[i].Discussion.ChannelID != 0 && messages[i].Discussion.MessageID != 0 { + targetChannelID = messages[i].Discussion.ChannelID + rootID = messages[i].Discussion.MessageID + replies.Comments = true + replies.ChannelID = messages[i].Discussion.ChannelID + } else if channel.Broadcast && channel.LinkedChatID != 0 && messages[i].Post { + replies.Comments = true + replies.ChannelID = channel.LinkedChatID + } + if _, ok := readMaxByChannel[targetChannelID]; !ok { + readInbox, _ := s.channelReadWatermarks(ctx, targetChannelID, viewerUserID) + readMaxByChannel[targetChannelID] = readInbox + } + replies.ReadMaxID = readMaxByChannel[targetChannelID] + key := channelReplyStatKey{channelID: targetChannelID, rootID: rootID} + if _, ok := indexes[key]; !ok { + rootsByChannel[targetChannelID] = append(rootsByChannel[targetChannelID], int32(rootID)) + } + indexes[key] = append(indexes[key], i) + if replies.Comments { + messages[i].Replies = replies + } + } + for channelID, roots := range rootsByChannel { + rows, err := db.Query(ctx, ` +SELECT reply_to_top_id, COUNT(*)::int, COALESCE(MAX(id), 0)::int, COALESCE((array_agg(pts ORDER BY id DESC))[1], 0)::int +FROM channel_messages +WHERE channel_id = $1 AND reply_to_top_id = ANY($2::int[]) AND NOT deleted +GROUP BY reply_to_top_id`, channelID, roots) + if err != nil { + return fmt.Errorf("load channel reply stats: %w", err) + } + // 只为「确有回复」的 root 再查最近回复者:普通频道/群历史页里绝大多数消息没有 thread + // 回复,rootsWithReplies 为空则跳过第二条查询,热读路径(getHistory/getDialogs)零额外往返。 + var rootsWithReplies []int32 + for rows.Next() { + var rootID, count, maxID, repliesPts int + if err := rows.Scan(&rootID, &count, &maxID, &repliesPts); err != nil { + rows.Close() + return err + } + if count > 0 { + rootsWithReplies = append(rootsWithReplies, int32(rootID)) + } + for _, idx := range indexes[channelReplyStatKey{channelID: channelID, rootID: rootID}] { + replies := messages[idx].Replies + if replies == nil { + replies = &domain.ChannelMessageReplies{ReadMaxID: readMaxByChannel[channelID]} + } + replies.Replies = count + replies.MaxID = maxID + replies.RepliesPts = repliesPts + messages[idx].Replies = replies + } + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + rows.Close() + + // 与 memory store 的 channelMessageRepliesLocked 对齐:补齐每个 root 的最近回复者。 + // 此前只算了 count/max/pts,RecentRepliers 恒为 nil,导致频道帖评论入口不显示最近回复者头像。 + repliers, err := s.channelRecentRepliers(ctx, db, channelID, rootsWithReplies) + if err != nil { + return err + } + for rootID, peers := range repliers { + for _, idx := range indexes[channelReplyStatKey{channelID: channelID, rootID: rootID}] { + replies := messages[idx].Replies + if replies == nil { + replies = &domain.ChannelMessageReplies{ReadMaxID: readMaxByChannel[channelID]} + } + replies.RecentRepliers = append([]domain.Peer(nil), peers...) + messages[idx].Replies = replies + } + } + } + return nil +} + +// channelRecentRepliers 返回每个 root 最近 3 个去重回复者(newest-first),与 memory store 的 +// channelMessageRepliesLocked 一致:发送者优先取 from_peer(send-as/匿名管理员),为空时回退 +// sender_user_id;按各回复者最新一条回复的 id 倒序取前 3 个。 +func (s *ChannelStore) channelRecentRepliers(ctx context.Context, db sqlcgen.DBTX, channelID int64, roots []int32) (map[int][]domain.Peer, error) { + if len(roots) == 0 { + return nil, nil + } + rows, err := db.Query(ctx, ` +SELECT reply_to_top_id, peer_type, peer_id +FROM ( + SELECT reply_to_top_id, peer_type, peer_id, + ROW_NUMBER() OVER (PARTITION BY reply_to_top_id ORDER BY last_id DESC) AS rn + FROM ( + SELECT reply_to_top_id, + CASE WHEN from_peer_id <> 0 THEN from_peer_type ELSE 'user' END AS peer_type, + CASE WHEN from_peer_id <> 0 THEN from_peer_id ELSE sender_user_id END AS peer_id, + MAX(id) AS last_id + FROM channel_messages + WHERE channel_id = $1 AND reply_to_top_id = ANY($2::int[]) AND NOT deleted + GROUP BY reply_to_top_id, peer_type, peer_id + ) grouped + WHERE peer_id <> 0 +) ranked +WHERE rn <= 3 +ORDER BY reply_to_top_id, rn`, channelID, roots) + if err != nil { + return nil, fmt.Errorf("load channel recent repliers: %w", err) + } + defer rows.Close() + out := make(map[int][]domain.Peer) + for rows.Next() { + var rootID int + var peerType string + var peerID int64 + if err := rows.Scan(&rootID, &peerType, &peerID); err != nil { + return nil, err + } + out[rootID] = append(out[rootID], domain.Peer{Type: domain.PeerType(peerType), ID: peerID}) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *ChannelStore) countChannelReplies(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter) (int, error) { + where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) + var count int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM channel_messages WHERE `+where, args...).Scan(&count); err != nil { + return 0, fmt.Errorf("count channel replies: %w", err) + } + return count, nil +} + +func (s *ChannelStore) queryChannelRepliesPage(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + switch messageHistoryLoadType(filter.AddOffset, limit) { + case messageHistoryLoadForward: + return s.queryChannelRepliesForward(ctx, channelID, rootID, availableMinID, filter, limit) + case messageHistoryLoadAround: + forwardLimit := -filter.AddOffset + if forwardLimit > limit { + forwardLimit = limit + } + backwardLimit := limit + filter.AddOffset + if backwardLimit < 0 { + backwardLimit = 0 + } + forward, err := s.queryChannelRepliesForward(ctx, channelID, rootID, availableMinID, filter, forwardLimit) + if err != nil { + return nil, err + } + backward, err := s.queryChannelRepliesBackward(ctx, channelID, rootID, availableMinID, filter, backwardLimit, true) + if err != nil { + return nil, err + } + out := append(forward, backward...) + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil + default: + start := filter.AddOffset + if start < 0 { + start = 0 + } + items, err := s.queryChannelRepliesBackward(ctx, channelID, rootID, availableMinID, filter, limit+start, false) + if err != nil || start >= len(items) { + return nil, err + } + return items[start:], nil + } +} + +func (s *ChannelStore) queryChannelRepliesBackward(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int, includeOffset bool) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) + where, args = appendChannelRepliesBackwardOffset(where, args, filter, includeOffset) + args = append(args, limit) + return s.queryChannelReplies(ctx, where, args, "DESC") +} + +func (s *ChannelStore) queryChannelRepliesForward(ctx context.Context, channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter, limit int) ([]domain.ChannelMessage, error) { + if limit <= 0 { + return nil, nil + } + where, args := channelRepliesBaseWhere(channelID, rootID, availableMinID, filter) + where, args = appendChannelRepliesForwardOffset(where, args, filter) + args = append(args, limit) + out, err := s.queryChannelReplies(ctx, where, args, "ASC") + if err != nil { + return nil, err + } + sort.SliceStable(out, func(i, j int) bool { return channelMessageLess(out[i], out[j]) }) + return out, nil +} + +func (s *ChannelStore) queryChannelReplies(ctx context.Context, where string, args []any, order string) ([]domain.ChannelMessage, error) { + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id `+order+` +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return nil, fmt.Errorf("list channel replies: %w", err) + } + defer rows.Close() + out := make([]domain.ChannelMessage, 0) + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return nil, err + } + out = append(out, msg) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func channelRepliesBaseWhere(channelID int64, rootID, availableMinID int, filter domain.ChannelRepliesFilter) (string, []any) { + args := []any{channelID, rootID} + where := "channel_id = $1 AND reply_to_top_id = $2 AND NOT deleted" + if availableMinID > 0 { + args = append(args, availableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + if filter.MaxID > 0 { + args = append(args, filter.MaxID) + where += fmt.Sprintf(" AND id < $%d", len(args)) + } + if filter.MinID > 0 { + args = append(args, filter.MinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + return where, args +} + +func appendChannelRepliesBackwardOffset(where string, args []any, filter domain.ChannelRepliesFilter, include bool) (string, []any) { + if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + if include { + return where + fmt.Sprintf(" AND message_date <= $%d", len(args)), args + } + return where + fmt.Sprintf(" AND message_date < $%d", len(args)), args + } + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + if include { + return where + fmt.Sprintf(" AND id <= $%d", len(args)), args + } + return where + fmt.Sprintf(" AND id < $%d", len(args)), args + } + return where, args +} + +func appendChannelRepliesForwardOffset(where string, args []any, filter domain.ChannelRepliesFilter) (string, []any) { + if filter.OffsetDate > 0 { + args = append(args, filter.OffsetDate) + return where + fmt.Sprintf(" AND message_date >= $%d", len(args)), args + } + if filter.OffsetID > 0 { + args = append(args, filter.OffsetID) + return where + fmt.Sprintf(" AND id > $%d", len(args)), args + } + return where + " AND false", args +} + +func updateForumTopicTopMessageTx(ctx context.Context, tx pgx.Tx, channelID int64, msg domain.ChannelMessage) error { + if msg.ReplyTo == nil || !msg.ReplyTo.ForumTopic || msg.ReplyTo.TopMessageID <= 0 { + return nil + } + if _, err := tx.Exec(ctx, ` +UPDATE channel_forum_topics +SET top_message_id = $3, + date = $4, + updated_at = now() +WHERE channel_id = $1 AND topic_id = $2 AND NOT deleted`, + channelID, msg.ReplyTo.TopMessageID, msg.ID, msg.Date); err != nil { + return fmt.Errorf("update forum topic top message: %w", err) + } + return nil +} + +func scanChannelForumTopic(row rowScanner) (domain.ChannelForumTopic, error) { + var topic domain.ChannelForumTopic + if err := row.Scan( + &topic.ChannelID, + &topic.TopicID, + &topic.CreatorUserID, + &topic.Title, + &topic.IconColor, + &topic.IconEmojiID, + &topic.TitleMissing, + &topic.Closed, + &topic.Hidden, + &topic.Pinned, + &topic.PinnedOrder, + &topic.Date, + &topic.TopMessageID, + &topic.ReadInboxMaxID, + &topic.ReadOutboxMaxID, + &topic.UnreadCount, + &topic.UnreadMentionsCount, + &topic.UnreadReactionsCount, + &topic.UnreadPollVotesCount, + ); err != nil { + return domain.ChannelForumTopic{}, err + } + return topic, nil +} + +func canManageForumTopic(channel domain.Channel, member domain.ChannelMember, topic domain.ChannelForumTopic, userID int64) bool { + if topic.CreatorUserID == userID { + return true + } + return canPinChannelMessages(channel, member) +} diff --git a/internal/store/postgres/channel_updates.go b/internal/store/postgres/channel_updates.go new file mode 100644 index 00000000..7b5071c8 --- /dev/null +++ b/internal/store/postgres/channel_updates.go @@ -0,0 +1,394 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/jackc/pgx/v5" + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *ChannelStore) ListChannelDifference(ctx context.Context, req domain.ChannelDifferenceRequest) (domain.ChannelDifference, error) { + channel, member, preview, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID) + if err != nil { + return domain.ChannelDifference{}, err + } + if req.Pts < 0 || req.Pts > channel.Pts { + return domain.ChannelDifference{}, domain.ErrPersistentTimestamp + } + if !preview && member.AvailableMinPts > req.Pts { + req.Pts = minInt(member.AvailableMinPts, channel.Pts) + } + limit := req.Limit + if limit <= 0 || limit > domain.MaxChannelDifferenceLimit { + limit = domain.MaxChannelDifferenceLimit + } + if channel.Pts-req.Pts > limit { + args := []any{req.ChannelID} + where := "channel_id = $1 AND NOT deleted" + if member.AvailableMinID > 0 { + args = append(args, member.AvailableMinID) + where += fmt.Sprintf(" AND id > $%d", len(args)) + } + args = append(args, domain.MaxChannelDifferenceTooLongMessages) + rows, err := s.db.Query(ctx, ` +SELECT `+channelMessageColumns+` +FROM channel_messages +WHERE `+where+` +ORDER BY id DESC +LIMIT $`+fmt.Sprint(len(args)), args...) + if err != nil { + return domain.ChannelDifference{}, fmt.Errorf("list channel too long messages: %w", err) + } + defer rows.Close() + diff := domain.ChannelDifference{ + Channel: channel, + Self: member, + Pts: channel.Pts, + Final: true, + TooLong: true, + Timeout: 30, + } + for rows.Next() { + msg, err := scanChannelMessage(rows) + if err != nil { + return domain.ChannelDifference{}, err + } + diff.NewMessages = append(diff.NewMessages, msg) + } + if err := rows.Err(); err != nil { + return domain.ChannelDifference{}, err + } + if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil { + return domain.ChannelDifference{}, err + } + if preview { + diff.Dialog = previewChannelDialog(req.UserID, channel, member) + } else { + dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) + if err != nil { + return domain.ChannelDifference{}, err + } + diff.Dialog = dialog + } + return diff, nil + } + rows, err := s.db.Query(ctx, ` +SELECT channel_id, pts, pts_count, date, event_type, message_id, message_ids::text, sender_user_id, user_ids::text, payload::text +FROM channel_update_events +WHERE channel_id = $1 AND pts > $2 +ORDER BY pts ASC +LIMIT $3`, req.ChannelID, req.Pts, limit) + if err != nil { + return domain.ChannelDifference{}, fmt.Errorf("list channel difference: %w", err) + } + defer rows.Close() + diff := domain.ChannelDifference{Channel: channel, Self: member, Pts: channel.Pts, Final: true, Timeout: 30} + userRefs := make(map[int64]struct{}) + channelRefs := make(map[int64]struct{}) + lastPts := req.Pts + for rows.Next() { + event, messageID, err := scanChannelEvent(rows) + if err != nil { + return domain.ChannelDifference{}, err + } + ptsCount := event.PtsCount + if ptsCount <= 0 { + ptsCount = 1 + } + if event.Pts != lastPts+ptsCount { + s.log.Warn("channel_difference_stopped_at_gap", + zap.String("scope", "channel"), + zap.Int64("user_id", req.UserID), + zap.Int64("channel_id", req.ChannelID), + zap.Int("request_pts", req.Pts), + zap.Int("channel_pts", channel.Pts), + zap.Int("returned_pts", lastPts), + zap.Int("expected_pts", lastPts+ptsCount), + zap.Int("got_pts", event.Pts), + zap.Int("got_pts_count", ptsCount), + zap.String("event_type", string(event.Type)), + zap.Int("limit", limit), + ) + break + } + lastPts = event.Pts + if messageID != 0 && event.Message.ID == 0 { + msg, err := s.getChannelMessage(ctx, s.db, req.ChannelID, messageID) + if err != nil { + return domain.ChannelDifference{}, err + } + event.Message = msg + } + visibleEvent, ok := domain.FilterChannelUpdateEventForAvailableMinID(event, member.AvailableMinID) + if !ok { + continue + } + event = visibleEvent + if preview && event.Type == domain.ChannelUpdateParticipant { + continue + } + collectChannelEventRefs(event, req.ChannelID, userRefs, channelRefs) + diff.Events = append(diff.Events, event) + diff.Pts = event.Pts + switch event.Type { + case domain.ChannelUpdateNewMessage: + diff.NewMessages = append(diff.NewMessages, event.Message) + default: + diff.OtherUpdates = append(diff.OtherUpdates, event) + } + } + if err := rows.Err(); err != nil { + return domain.ChannelDifference{}, err + } + if len(diff.Events) == 0 { + diff.Pts = lastPts + } else if lastPts > diff.Pts { + diff.Pts = lastPts + } + if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, diff.NewMessages); err != nil { + return domain.ChannelDifference{}, err + } + // OtherUpdates 里带消息的事件未读/提及标记一次批量回填(原来逐事件一条 SQL 的 N+1)。 + otherMsgs := make([]domain.ChannelMessage, 0, len(diff.OtherUpdates)) + otherIdx := make([]int, 0, len(diff.OtherUpdates)) + for i := range diff.OtherUpdates { + if diff.OtherUpdates[i].Message.ID == 0 { + continue + } + otherMsgs = append(otherMsgs, diff.OtherUpdates[i].Message) + otherIdx = append(otherIdx, i) + } + if len(otherMsgs) > 0 { + if err := populateChannelMessageUnreadFlags(ctx, s.db, req.UserID, otherMsgs); err != nil { + return domain.ChannelDifference{}, err + } + for j, i := range otherIdx { + diff.OtherUpdates[i].Message = otherMsgs[j] + } + } + users, err := listUsersByIDs(ctx, s.db, mapKeysInt64(userRefs)) + if err != nil { + return domain.ChannelDifference{}, err + } + channels, err := listChannelsByIDs(ctx, s.db, mapKeysInt64(channelRefs)) + if err != nil { + return domain.ChannelDifference{}, err + } + diff.Users = users + diff.Channels = channels + if preview { + diff.Dialog = previewChannelDialog(req.UserID, channel, member) + } else { + dialog, err := s.getChannelDialog(ctx, s.db, req.UserID, channel) + if err != nil { + return domain.ChannelDifference{}, err + } + diff.Dialog = dialog + } + diff.Final = lastPts >= channel.Pts + return diff, nil +} + +func (s *ChannelStore) MaxChannelPts(ctx context.Context, channelID int64) (int, error) { + var pts int + err := s.db.QueryRow(ctx, `SELECT pts FROM channels WHERE id = $1`, channelID).Scan(&pts) + return pts, err +} + +func transientChannelParticipantEvent(channelID, actorUserID int64, previous, participant domain.ChannelMember, date int) domain.ChannelUpdateEvent { + return domain.ChannelUpdateEvent{ + ChannelID: channelID, + Type: domain.ChannelUpdateParticipant, + Date: date, + SenderUserID: actorUserID, + UserIDs: uniqueNonZeroInt64s(actorUserID, previous.UserID, previous.InviterUserID, participant.UserID, participant.InviterUserID), + Previous: previous, + Participant: participant, + } +} + +func (s *ChannelStore) reserveChannelPts(ctx context.Context, db sqlcgen.DBTX, channelID int64) (int, error) { + return s.reserveChannelPtsN(ctx, db, channelID, 1) +} + +func (s *ChannelStore) reserveChannelPtsN(ctx context.Context, db sqlcgen.DBTX, channelID int64, count int) (int, error) { + count = normalizePtsCount(count) + caller := traceCaller(2) + pts, err := reserveChannelPts(ctx, db, channelID, count) + if err != nil { + s.log.Warn("pts_reserve_failed", + zap.String("scope", "channel"), + zap.Int64("channel_id", channelID), + zap.Int("pts_count", count), + zap.String("caller", traceCaller(2)), + zap.Error(err), + zap.Error(ctx.Err()), + ) + return 0, err + } + s.log.Debug("pts_reserve", + zap.String("scope", "channel"), + zap.Int64("channel_id", channelID), + zap.Int("pts", pts), + zap.Int("pts_count", maxInt(count, 1)), + zap.String("caller", caller), + ) + return pts, nil +} + +func reserveChannelPts(ctx context.Context, db sqlcgen.DBTX, channelID int64, count int) (int, error) { + count = normalizePtsCount(count) + if channelID == 0 { + return 0, fmt.Errorf("channel pts: missing channel id") + } + var pts int + if err := db.QueryRow(ctx, ` +UPDATE channels +SET pts = pts + $2, + updated_at = now() +WHERE id = $1 +RETURNING pts`, channelID, count).Scan(&pts); err != nil { + return 0, fmt.Errorf("reserve channel pts: %w", err) + } + return pts, nil +} + +func insertChannelEventTx(ctx context.Context, tx pgx.Tx, event domain.ChannelUpdateEvent) error { + ids, err := marshalJSON(event.MessageIDs, "[]") + if err != nil { + return err + } + userIDs, err := marshalJSON(event.UserIDs, "[]") + if err != nil { + return err + } + payloadData := map[string]any{ + "message_id": event.Message.ID, + "pinned": event.Pinned, + } + if event.Message.ID != 0 { + payloadData["message"] = event.Message + } + if event.Previous.UserID != 0 { + payloadData["previous_participant"] = event.Previous + } + if event.Participant.UserID != 0 { + payloadData["participant"] = event.Participant + } + payload, err := marshalJSON(payloadData, "{}") + if err != nil { + return err + } + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_update_events ( + channel_id, pts, pts_count, date, event_type, message_id, message_ids, sender_user_id, user_ids, payload +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + event.ChannelID, event.Pts, event.PtsCount, event.Date, string(event.Type), event.Message.ID, + ids, event.SenderUserID, userIDs, payload); err != nil { + return fmt.Errorf("insert channel event: %w", err) + } + return nil +} + +func scanChannelEvent(row rowScanner) (domain.ChannelUpdateEvent, int, error) { + var event domain.ChannelUpdateEvent + var typ string + var messageID int + var messageIDs, userIDs, payload string + if err := row.Scan( + &event.ChannelID, &event.Pts, &event.PtsCount, &event.Date, &typ, &messageID, + &messageIDs, &event.SenderUserID, &userIDs, &payload, + ); err != nil { + return domain.ChannelUpdateEvent{}, 0, err + } + event.Type = domain.ChannelUpdateEventType(typ) + _ = json.Unmarshal([]byte(messageIDs), &event.MessageIDs) + _ = json.Unmarshal([]byte(userIDs), &event.UserIDs) + var data struct { + Pinned bool `json:"pinned"` + Message domain.ChannelMessage `json:"message"` + PreviousParticipant domain.ChannelMember `json:"previous_participant"` + Participant domain.ChannelMember `json:"participant"` + } + _ = json.Unmarshal([]byte(payload), &data) + event.Pinned = data.Pinned + if data.Message.ID != 0 { + event.Message = data.Message + } + event.Previous = data.PreviousParticipant + event.Participant = data.Participant + return event, messageID, nil +} + +func channelInitialAvailableMinPts(channel domain.Channel) int { + return channel.Pts +} + +func adminLogEventTypesForFilter(filter domain.ChannelAdminLogFilter) []string { + if filter.Empty() { + return nil + } + types := make([]string, 0, 16) + add := func(enabled bool, typ domain.ChannelAdminLogEventType) { + if enabled { + types = append(types, string(typ)) + } + } + add(filter.Join, domain.ChannelAdminLogParticipantJoin) + add(filter.Leave, domain.ChannelAdminLogParticipantLeave) + add(filter.Invite || filter.Invites, domain.ChannelAdminLogParticipantInvite) + add(filter.Ban, domain.ChannelAdminLogParticipantBan) + add(filter.Unban, domain.ChannelAdminLogParticipantUnban) + add(filter.Kick, domain.ChannelAdminLogParticipantKick) + add(filter.Unkick, domain.ChannelAdminLogParticipantUnkick) + add(filter.Promote, domain.ChannelAdminLogParticipantPromote) + add(filter.Demote, domain.ChannelAdminLogParticipantDemote) + add(filter.EditRank, domain.ChannelAdminLogParticipantEditRank) + if filter.Info { + types = append(types, + string(domain.ChannelAdminLogChangeTitle), + string(domain.ChannelAdminLogChangeUsername), + string(domain.ChannelAdminLogChangeLinkedChat), + string(domain.ChannelAdminLogToggleSlowMode), + ) + } + if filter.Settings { + types = append(types, + string(domain.ChannelAdminLogToggleSignatures), + string(domain.ChannelAdminLogTogglePreHistoryHidden), + string(domain.ChannelAdminLogToggleAntiSpam), + string(domain.ChannelAdminLogToggleAutotranslation), + ) + } + add(filter.Forums || filter.Settings, domain.ChannelAdminLogToggleForum) + add(filter.Pinned, domain.ChannelAdminLogUpdatePinned) + add(filter.Edit, domain.ChannelAdminLogEditMessage) + add(filter.Delete, domain.ChannelAdminLogDeleteMessage) + add(filter.Send, domain.ChannelAdminLogSendMessage) + return types +} + +func collectChannelEventRefs(event domain.ChannelUpdateEvent, currentChannelID int64, userRefs, channelRefs map[int64]struct{}) { + if event.SenderUserID != 0 { + userRefs[event.SenderUserID] = struct{}{} + } + for _, id := range event.UserIDs { + if id != 0 { + userRefs[id] = struct{}{} + } + } + for _, member := range []domain.ChannelMember{event.Previous, event.Participant} { + if member.UserID != 0 { + userRefs[member.UserID] = struct{}{} + } + if member.InviterUserID != 0 { + userRefs[member.InviterUserID] = struct{}{} + } + } + collectChannelMessageRefs(event.Message, currentChannelID, userRefs, channelRefs) +} diff --git a/internal/store/postgres/channel_username_integration_test.go b/internal/store/postgres/channel_username_integration_test.go new file mode 100644 index 00000000..9e9c0f47 --- /dev/null +++ b/internal/store/postgres/channel_username_integration_test.go @@ -0,0 +1,179 @@ +package postgres + +import ( + "context" + "errors" + "strings" + "testing" + + appusers "telesrv/internal/app/users" + "telesrv/internal/domain" +) + +func TestChannelStoreResolvePublicUsernameRejectsStaleIndex(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1886"+suffix+"71", "ResolveOwner", "") + viewer := createTestUser(t, ctx, users, "+1886"+suffix+"72", "ResolveViewer", "") + var channelIDs []int64 + usernames := []string{"resolvepub" + suffix, "missing" + suffix} + t.Cleanup(func() { + lower := make([]string, 0, len(usernames)) + for _, username := range usernames { + lower = append(lower, strings.ToLower(username)) + } + _, _ = pool.Exec(ctx, "DELETE FROM peer_usernames WHERE (peer_type = 'channel' AND peer_id = ANY($1::bigint[])) OR username_lower = ANY($2::text[])", channelIDs, lower) + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, viewer.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "resolve public " + suffix, + Megagroup: true, + Date: 1700000910, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelIDs = append(channelIDs, created.Channel.ID) + publicUsername := usernames[0] + publicChannel, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: created.Channel.ID, + Username: publicUsername, + }) + if err != nil { + t.Fatalf("set username: %v", err) + } + + resolved, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, strings.ToUpper(publicUsername)) + if err != nil || !found || resolved.ID != publicChannel.ID { + t.Fatalf("resolve public username = %+v found %v err %v, want channel %d", resolved, found, err, publicChannel.ID) + } + if _, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, usernames[1]); err != nil || found { + t.Fatalf("resolve missing username found %v err %v, want not found", found, err) + } + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: publicChannel.ID, + Username: "", + }); err != nil { + t.Fatalf("clear username: %v", err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO peer_usernames (username_lower, peer_type, peer_id) +VALUES ($1,'channel',$2) +ON CONFLICT (username_lower) DO UPDATE SET peer_type = EXCLUDED.peer_type, peer_id = EXCLUDED.peer_id, updated_at = now() +`, strings.ToLower(publicUsername), publicChannel.ID); err != nil { + t.Fatalf("insert stale username index: %v", err) + } + + stale, found, err := channels.ResolvePublicChannelUsername(ctx, viewer.ID, publicUsername) + if err != nil || found { + t.Fatalf("resolve stale username = %+v found %v err %v, want not found", stale, found, err) + } +} + +func TestPeerUsernameNamespaceIsGlobal(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + channels := NewChannelStore(pool) + bots := NewBotStore(pool) + + owner := createTestUser(t, ctx, users, "+1886"+suffix+"81", "GlobalOwner", "") + viewer := createTestUser(t, ctx, users, "+1886"+suffix+"82", "GlobalViewer", "") + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, viewer.ID}) + }) + + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "global namespace " + suffix, + Broadcast: true, + Date: 1700000920, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelIDs = append(channelIDs, created.Channel.ID) + channelUsername := "globalchan_" + suffix + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: created.Channel.ID, + Username: channelUsername, + }); err != nil { + t.Fatalf("set channel username: %v", err) + } + + userService := appusers.NewService(users) + ok, err := userService.CheckUsername(ctx, viewer.ID, strings.ToUpper(channelUsername)) + if err != nil || ok { + t.Fatalf("account check channel username = ok %v err %v, want false/nil", ok, err) + } + if _, err := userService.UpdateUsername(ctx, viewer.ID, channelUsername); !errors.Is(err, domain.ErrUsernameOccupied) { + t.Fatalf("account update to channel username err = %v, want username occupied", err) + } + if _, err := users.Create(ctx, domain.User{ + AccessHash: 9001, + Phone: "+1886" + suffix + "83", + FirstName: "GlobalCreate", + Username: channelUsername, + }); !errors.Is(err, domain.ErrUsernameOccupied) { + t.Fatalf("create user with channel username err = %v, want username occupied", err) + } + if _, _, err := bots.CreateBotAccount(ctx, domain.User{ + AccessHash: 9002, + FirstName: "GlobalBot", + Username: channelUsername, + Bot: true, + BotInfoVersion: 1, + }, domain.BotProfile{OwnerUserID: owner.ID, TokenSecret: "secret"}); !errors.Is(err, domain.ErrUsernameOccupied) { + t.Fatalf("create bot with channel username err = %v, want username occupied", err) + } + + userUsername := "globaluser_" + suffix + if _, err := userService.UpdateUsername(ctx, viewer.ID, userUsername); err != nil { + t.Fatalf("set user username: %v", err) + } + second, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "global namespace second " + suffix, + Megagroup: true, + Date: 1700000921, + }) + if err != nil { + t.Fatalf("create second channel: %v", err) + } + channelIDs = append(channelIDs, second.Channel.ID) + ok, err = channels.CheckUsername(ctx, owner.ID, second.Channel.ID, strings.ToUpper(userUsername)) + if err != nil || ok { + t.Fatalf("channel check user username = ok %v err %v, want false/nil", ok, err) + } + if _, err := channels.UpdateUsername(ctx, domain.UpdateChannelUsernameRequest{ + UserID: owner.ID, + ChannelID: second.Channel.ID, + Username: userUsername, + }); !errors.Is(err, domain.ErrUsernameOccupied) { + t.Fatalf("channel update to user username err = %v, want username occupied", err) + } + + if _, err := userService.UpdateUsername(ctx, viewer.ID, ""); err != nil { + t.Fatalf("clear user username: %v", err) + } + ok, err = channels.CheckUsername(ctx, owner.ID, second.Channel.ID, userUsername) + if err != nil || !ok { + t.Fatalf("channel check released user username = ok %v err %v, want true/nil", ok, err) + } +} diff --git a/internal/store/postgres/channel_verified_integration_test.go b/internal/store/postgres/channel_verified_integration_test.go new file mode 100644 index 00000000..e4d3ce9e --- /dev/null +++ b/internal/store/postgres/channel_verified_integration_test.go @@ -0,0 +1,59 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestChannelStoreSetChannelVerifiedRefreshesRowCache(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1886"+suffix+"91", "VerifiedChannelOwner", "") + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + cache := NewChannelRowCache(8) + channels := NewChannelStore(pool, WithChannelRowCache(cache)) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "verified channel " + suffix, + Broadcast: true, + Date: 1700001100, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelIDs = append(channelIDs, created.Channel.ID) + + warm, err := channels.GetChannelByID(ctx, created.Channel.ID) + if err != nil { + t.Fatalf("warm channel cache: %v", err) + } + if warm.Verified { + t.Fatalf("created channel verified=true, want false") + } + + updated, err := channels.SetChannelVerified(ctx, created.Channel.ID, true) + if err != nil { + t.Fatalf("set verified: %v", err) + } + if !updated.Verified { + t.Fatalf("updated verified=false, want true") + } + got, err := channels.GetChannelByID(ctx, created.Channel.ID) + if err != nil { + t.Fatalf("get updated channel: %v", err) + } + if !got.Verified { + t.Fatalf("cached channel verified=false after update, want true") + } +} diff --git a/internal/store/postgres/channel_webpage_resolve_integration_test.go b/internal/store/postgres/channel_webpage_resolve_integration_test.go new file mode 100644 index 00000000..df1b058b --- /dev/null +++ b/internal/store/postgres/channel_webpage_resolve_integration_test.go @@ -0,0 +1,99 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +// TestChannelStoreResolveWebPageIntegration 对 live Postgres 验证频道 WebPageResolve 分支: +// media 就地换 done、body/edit_date 不变、durable channel_web_page 事件被白名单接受(迁移0016)、 +// 历史读回 done、幂等。需 TELESRV_TEST_POSTGRES_DSN。 +func TestChannelStoreResolveWebPageIntegration(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 51, Phone: "+1668" + suffix + "11", FirstName: "ChWPOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner.ID, Title: "ChWP " + suffix, Megagroup: true, Date: 1700000300}) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + const url = "https://example.com/c" + urlHash := domain.WebPageURLHash(url) + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, ChannelID: channelID, RandomID: 9911, Message: "see " + url, Date: 1700000301, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStatePending, ID: urlHash, URL: url}}, + }) + if err != nil { + t.Fatalf("send channel message: %v", err) + } + + done := &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: urlHash, URL: url, DisplayURL: "example.com", Title: "Example"}} + resolveReq := domain.EditChannelMessageRequest{UserID: owner.ID, ChannelID: channelID, ID: sent.Message.ID, Media: done, WebPageResolve: true, ExpectedWebPageID: urlHash} + res, err := channels.EditChannelMessage(ctx, resolveReq) + if err != nil { + t.Fatalf("EditChannelMessage(WebPageResolve): %v", err) + } + if res.Event.Type != domain.ChannelUpdateWebPage { + t.Fatalf("event type = %q, want channel_web_page", res.Event.Type) + } + if res.Message.Media == nil || res.Message.Media.WebPage == nil || res.Message.Media.WebPage.State != domain.MessageWebPageStateDone { + t.Fatalf("message media not resolved: %+v", res.Message.Media) + } + if res.Message.Body != "see "+url || res.Message.EditDate != 0 { + t.Fatalf("body/edit_date changed: body=%q edit_date=%d", res.Message.Body, res.Message.EditDate) + } + + // durable 事件被白名单接受(迁移0016)+ 类型正确。 + var eventType string + if err := pool.QueryRow(ctx, `SELECT event_type FROM channel_update_events WHERE channel_id = $1 ORDER BY pts DESC LIMIT 1`, channelID).Scan(&eventType); err != nil { + t.Fatalf("read latest channel event: %v", err) + } + if eventType != string(domain.ChannelUpdateWebPage) { + t.Fatalf("latest channel event type = %q, want channel_web_page", eventType) + } + + // 历史读回 media=done,body/edit_date 不变。 + hist, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ChannelID: channelID, Limit: 10}) + if err != nil { + t.Fatalf("list channel history: %v", err) + } + var found bool + for _, m := range hist.Messages { + if m.ID == sent.Message.ID { + found = true + if m.Media == nil || m.Media.WebPage == nil || m.Media.WebPage.State != domain.MessageWebPageStateDone { + t.Fatalf("history media not resolved: %+v", m.Media) + } + if m.Body != "see "+url || m.EditDate != 0 { + t.Fatalf("history body/edit_date changed: body=%q edit_date=%d", m.Body, m.EditDate) + } + } + } + if !found { + t.Fatalf("message %d not in history", sent.Message.ID) + } + + // 幂等。 + if _, err := channels.EditChannelMessage(ctx, resolveReq); !errors.Is(err, domain.ErrMessageNotModified) { + t.Fatalf("re-resolve err = %v, want ErrMessageNotModified", err) + } +} diff --git a/internal/store/postgres/contact.go b/internal/store/postgres/contact.go index 80b2fac4..fb34a2a8 100644 --- a/internal/store/postgres/contact.go +++ b/internal/store/postgres/contact.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "hash/fnv" + "sort" "github.com/jackc/pgx/v5" @@ -68,6 +69,7 @@ func (s *ContactStore) GetMany(ctx context.Context, userID int64, contactUserIDs SELECT c.contact_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -82,6 +84,9 @@ SELECT u.country_code, u.verified, u.support, + COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -114,6 +119,7 @@ func (s *ContactStore) GetReverseContacts(ctx context.Context, userID int64, own SELECT c.user_id AS owner_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -128,6 +134,9 @@ SELECT u.country_code, u.verified, u.support, + COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -251,6 +260,7 @@ reverse_updated AS ( SELECT c.contact_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -265,6 +275,9 @@ SELECT u.country_code, u.verified, u.support, + COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS premium_until, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at, EXISTS (SELECT 1 FROM reverse_updated ru WHERE ru.user_id = c.contact_user_id)::boolean AS reverse_mutual_changed FROM upserted c @@ -311,6 +324,7 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do var ( contactUserID int64 mutual bool + closeFriend bool contactPhone string contactFirstName string contactLastName string @@ -325,12 +339,16 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do countryCode string verified bool support bool + premiumUntil int64 + emojiStatusDocID int64 + emojiStatusUntil int64 lastSeenAt int64 reverseMutualChanged bool ) if err := rows.Scan( &contactUserID, &mutual, + &closeFriend, &contactPhone, &contactFirstName, &contactLastName, @@ -345,6 +363,9 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do &countryCode, &verified, &support, + &premiumUntil, + &emojiStatusDocID, + &emojiStatusUntil, &lastSeenAt, &reverseMutualChanged, ); err != nil { @@ -355,7 +376,7 @@ func (s *ContactStore) UpsertMany(ctx context.Context, userID int64, inputs []do if err != nil { return nil, fmt.Errorf("decode contact note entities: %w", err) } - out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual)) + out = append(out, contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend)) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate upsert contacts many: %w", err) @@ -387,6 +408,70 @@ func (s *ContactStore) UpdateNote(ctx context.Context, userID, contactUserID int return contact, true, nil } +func (s *ContactStore) SetCloseFriends(ctx context.Context, userID int64, contactUserIDs []int64) (domain.CloseFriendsEditResult, error) { + if userID == 0 { + return domain.CloseFriendsEditResult{}, nil + } + if contactUserIDs == nil { + contactUserIDs = []int64{} + } + beforeList, err := s.ListByUser(ctx, userID) + if err != nil { + return domain.CloseFriendsEditResult{}, err + } + contactSet := make(map[int64]struct{}, len(beforeList.Contacts)) + before := make(map[int64]struct{}) + for _, contact := range beforeList.Contacts { + id := contact.User.ID + if id == 0 { + continue + } + contactSet[id] = struct{}{} + if contact.CloseFriend || contact.User.CloseFriend { + before[id] = struct{}{} + } + } + after := make(map[int64]struct{}, len(contactUserIDs)) + for _, id := range contactUserIDs { + if id <= 0 || id == userID { + continue + } + if _, ok := contactSet[id]; ok { + after[id] = struct{}{} + } + } + _, err = s.db.Exec(ctx, ` +UPDATE contacts +SET close_friend = contact_user_id = ANY($2::bigint[]), + updated_at = now() +WHERE user_id = $1 + AND ( + close_friend + OR contact_user_id = ANY($2::bigint[]) + )`, userID, contactUserIDs) + if err != nil { + return domain.CloseFriendsEditResult{}, fmt.Errorf("set close friends: %w", err) + } + return closeFriendsEditDiff(before, after), nil +} + +func closeFriendsEditDiff(before, after map[int64]struct{}) domain.CloseFriendsEditResult { + var result domain.CloseFriendsEditResult + for id := range after { + if _, ok := before[id]; !ok { + result.AddedUserIDs = append(result.AddedUserIDs, id) + } + } + for id := range before { + if _, ok := after[id]; !ok { + result.RemovedUserIDs = append(result.RemovedUserIDs, id) + } + } + sort.Slice(result.AddedUserIDs, func(i, j int) bool { return result.AddedUserIDs[i] < result.AddedUserIDs[j] }) + sort.Slice(result.RemovedUserIDs, func(i, j int) bool { return result.RemovedUserIDs[i] < result.RemovedUserIDs[j] }) + return result +} + func (s *ContactStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) { tag, err := s.db.Exec(ctx, ` UPDATE contacts @@ -443,6 +528,7 @@ WHERE c.user_id = $1 DCID: int(dcID), Stripped: domain.StrippedFromSizes(sizes), Personal: true, + HasVideo: domain.PhotoHasVideo(sizes), } } if err := rows.Err(); err != nil { @@ -470,7 +556,7 @@ func contactFromListRow(row sqlcgen.ListContactsByUserRow) (domain.Contact, erro if err != nil { return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) } - return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) { @@ -478,7 +564,7 @@ func contactFromGetRow(row sqlcgen.GetContactRow) (domain.Contact, error) { if err != nil { return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) } - return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) { @@ -486,7 +572,7 @@ func contactFromUpsertRow(row sqlcgen.UpsertContactRow) (domain.Contact, error) if err != nil { return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) } - return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, error) { @@ -494,24 +580,35 @@ func contactFromUpdateNoteRow(row sqlcgen.UpdateContactNoteRow) (domain.Contact, if err != nil { return domain.Contact{}, fmt.Errorf("decode contact note entities: %w", err) } - return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual), nil + return contactFromFields(row.ID, row.AccessHash, row.Phone, row.FirstName, row.LastName, row.Username, row.CountryCode, row.Verified, row.Support, row.IsBot, int(row.BotInfoVersion), premiumUntilFromModel(row.PremiumExpiresAt), row.EmojiStatusDocumentID, int(row.EmojiStatusUntil), int(row.LastSeenAt), row.ContactFirstName, row.ContactLastName, row.ContactPhone, row.Note, entities, row.Mutual, row.CloseFriend), nil } -func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support bool, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual bool) domain.Contact { +// contactFromFields 组装 domain.Contact。getContacts 主路径(List/Get/Upsert/UpdateNote +// 四个 sqlc 查询)传真实 is_bot/bot_info_version;ImportContacts 等手机号批量导入的 +// raw-scan 调用传 false/0——bot 无 phone 不经手机号导入,bot 加联系人走 username +// 的单条 UpsertContact 路径(已带真实 bot 列)。premium/emoji status 列所有路径必须 +// 传真实值:TDesktop 对任何缺 emoji_status 字段的 user TL 一律清空本地状态。 +func contactFromFields(id, accessHash int64, phone, firstName, lastName, username, countryCode string, verified, support, isBot bool, botInfoVersion, premiumUntil int, emojiStatusDocumentID int64, emojiStatusUntil, lastSeenAt int, contactFirstName, contactLastName, contactPhone, note string, noteEntities []domain.MessageEntity, mutual, closeFriend bool) domain.Contact { return domain.Contact{ User: domain.User{ - ID: id, - AccessHash: accessHash, - Phone: phone, - FirstName: firstName, - LastName: lastName, - Username: username, - CountryCode: countryCode, - Verified: verified, - Support: support, - LastSeenAt: lastSeenAt, - Contact: true, - Mutual: mutual, + ID: id, + AccessHash: accessHash, + Phone: phone, + FirstName: firstName, + LastName: lastName, + Username: username, + CountryCode: countryCode, + Verified: verified, + Support: support, + Bot: isBot, + BotInfoVersion: botInfoVersion, + PremiumUntil: premiumUntil, + EmojiStatusDocumentID: emojiStatusDocumentID, + EmojiStatusUntil: emojiStatusUntil, + LastSeenAt: lastSeenAt, + Contact: true, + Mutual: mutual, + CloseFriend: closeFriend, }, FirstName: contactFirstName, LastName: contactLastName, @@ -519,6 +616,7 @@ func contactFromFields(id, accessHash int64, phone, firstName, lastName, usernam Note: note, NoteEntities: noteEntities, Mutual: mutual, + CloseFriend: closeFriend, } } @@ -530,6 +628,7 @@ func scanContactRows(row contactScanner) (domain.Contact, error) { var ( contactUserID int64 mutual bool + closeFriend bool contactPhone string contactFirstName string contactLastName string @@ -544,11 +643,15 @@ func scanContactRows(row contactScanner) (domain.Contact, error) { countryCode string verified bool support bool + premiumUntil int64 + emojiStatusDocID int64 + emojiStatusUntil int64 lastSeenAt int32 ) if err := row.Scan( &contactUserID, &mutual, + &closeFriend, &contactPhone, &contactFirstName, &contactLastName, @@ -563,6 +666,9 @@ func scanContactRows(row contactScanner) (domain.Contact, error) { &countryCode, &verified, &support, + &premiumUntil, + &emojiStatusDocID, + &emojiStatusUntil, &lastSeenAt, ); err != nil { return domain.Contact{}, err @@ -571,13 +677,14 @@ func scanContactRows(row contactScanner) (domain.Contact, error) { if err != nil { return domain.Contact{}, err } - return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual), nil + return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend), nil } func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { var ( ownerUserID int64 mutual bool + closeFriend bool contactPhone string contactFirstName string contactLastName string @@ -592,11 +699,15 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { countryCode string verified bool support bool + premiumUntil int64 + emojiStatusDocID int64 + emojiStatusUntil int64 lastSeenAt int32 ) if err := row.Scan( &ownerUserID, &mutual, + &closeFriend, &contactPhone, &contactFirstName, &contactLastName, @@ -611,6 +722,9 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { &countryCode, &verified, &support, + &premiumUntil, + &emojiStatusDocID, + &emojiStatusUntil, &lastSeenAt, ); err != nil { return 0, domain.Contact{}, err @@ -619,7 +733,7 @@ func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) { if err != nil { return 0, domain.Contact{}, err } - contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual) + contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, false, 0, int(premiumUntil), emojiStatusDocID, int(emojiStatusUntil), int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual, closeFriend) return ownerUserID, contact, nil } @@ -748,7 +862,12 @@ func contactListHash(contacts []domain.Contact) int64 { } else { buf[8] = 0 } - _, _ = h.Write(buf[:9]) + if c.CloseFriend || c.User.CloseFriend { + buf[9] = 1 + } else { + buf[9] = 0 + } + _, _ = h.Write(buf[:10]) _, _ = h.Write([]byte(c.FirstName)) _, _ = h.Write([]byte{0}) _, _ = h.Write([]byte(c.LastName)) diff --git a/internal/store/postgres/contact_dialog_integration_test.go b/internal/store/postgres/contact_dialog_integration_test.go index 9cd9838e..85b60a80 100644 --- a/internal/store/postgres/contact_dialog_integration_test.go +++ b/internal/store/postgres/contact_dialog_integration_test.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "reflect" "testing" contactsapp "telesrv/internal/app/contacts" @@ -85,6 +86,52 @@ func TestContactProfilesOwnerScopedRoundTrip(t *testing.T) { t.Fatalf("alt contact note = %q, want isolated alt note", altList.Contacts[0].Note) } + beforeCloseHash := ownerList.Hash + result, err := contacts.EditCloseFriends(ctx, owner.ID, []int64{friend.ID, friend.ID, owner.ID, 0, 999999}) + if err != nil { + t.Fatalf("edit close friends: %v", err) + } + if got, want := result.AddedUserIDs, []int64{friend.ID}; !reflect.DeepEqual(got, want) { + t.Fatalf("close friends added = %v, want %v", got, want) + } + if len(result.RemovedUserIDs) != 0 { + t.Fatalf("close friends removed = %v, want empty", result.RemovedUserIDs) + } + ownerList, _, err = contacts.GetContacts(ctx, owner.ID, 0) + if err != nil { + t.Fatalf("owner contacts after close friends: %v", err) + } + if !ownerList.Contacts[0].CloseFriend || !ownerList.Contacts[0].User.CloseFriend { + t.Fatalf("owner contact close friend = %+v, want true", ownerList.Contacts[0]) + } + if ownerList.Hash == beforeCloseHash { + t.Fatalf("owner contact hash did not change after close friend update: %d", ownerList.Hash) + } + altList, _, err = contacts.GetContacts(ctx, altOwner.ID, 0) + if err != nil { + t.Fatalf("alt contacts after close friends: %v", err) + } + if altList.Contacts[0].CloseFriend || altList.Contacts[0].User.CloseFriend { + t.Fatalf("alt contact close friend = %+v, want false", altList.Contacts[0]) + } + result, err = contacts.EditCloseFriends(ctx, owner.ID, nil) + if err != nil { + t.Fatalf("clear close friends: %v", err) + } + if len(result.AddedUserIDs) != 0 { + t.Fatalf("clear close friends added = %v, want empty", result.AddedUserIDs) + } + if got, want := result.RemovedUserIDs, []int64{friend.ID}; !reflect.DeepEqual(got, want) { + t.Fatalf("clear close friends removed = %v, want %v", got, want) + } + ownerList, _, err = contacts.GetContacts(ctx, owner.ID, 0) + if err != nil { + t.Fatalf("owner contacts after close friends clear: %v", err) + } + if ownerList.Contacts[0].CloseFriend || ownerList.Contacts[0].User.CloseFriend { + t.Fatalf("owner contact after clear = %+v, want false", ownerList.Contacts[0]) + } + if _, err := contacts.AddContact(ctx, friend.ID, domain.ContactInput{ ContactUserID: owner.ID, FirstName: "OwnerBack", @@ -196,21 +243,24 @@ func TestDialogUserViewUsesContactProfileAndDialogFlags(t *testing.T) { friendPeer := domain.Peer{Type: domain.PeerTypeUser, ID: friend.ID} otherPeer := domain.Peer{Type: domain.PeerTypeUser, ID: other.ID} - if changed, err := dialogs.SetPinned(ctx, ownerA.ID, friendPeer, true); err != nil || !changed { + if changed, _, err := dialogs.SetPinned(ctx, ownerA.ID, friendPeer, true); err != nil || !changed { t.Fatalf("pin friend changed=%v err=%v, want changed", changed, err) } - if changed, err := dialogs.SetPinned(ctx, ownerA.ID, otherPeer, true); err != nil || !changed { + if changed, _, err := dialogs.SetPinned(ctx, ownerA.ID, otherPeer, true); err != nil || !changed { t.Fatalf("pin other changed=%v err=%v, want changed", changed, err) } - if err := dialogs.ReorderPinned(ctx, ownerA.ID, []domain.Peer{otherPeer, friendPeer}, true); err != nil { - t.Fatalf("reorder pinned: %v", err) + if changed, err := dialogs.ReorderPinned(ctx, ownerA.ID, domain.DialogMainFolderID, []domain.Peer{otherPeer, friendPeer}, true); err != nil || changed { + t.Fatalf("reorder pinned = changed %v err %v, want no-op", changed, err) } pinned, err := dialogs.ListByUser(ctx, ownerA.ID, domain.DialogFilter{PinnedOnly: true, Limit: 10}) if err != nil { t.Fatalf("list pinned dialogs: %v", err) } - if len(pinned.Dialogs) != 2 || pinned.Dialogs[0].Peer != otherPeer || pinned.Dialogs[0].PinnedOrder != 1 || pinned.Dialogs[1].Peer != friendPeer || pinned.Dialogs[1].PinnedOrder != 2 { - t.Fatalf("pinned dialogs = %+v, want other then friend with stable order", pinned.Dialogs) + // 协议契约只看返回顺序(TL dialog 不暴露 pinned_order);内部约定为 + // 首位持最大 pinned_order(reorder 写 cardinality-pos+1,读取 DESC, + // 与 SetPinned 的 MAX+1"新 pin 置顶最前"一致)。 + if len(pinned.Dialogs) != 2 || pinned.Dialogs[0].Peer != otherPeer || pinned.Dialogs[0].PinnedOrder != 2 || pinned.Dialogs[1].Peer != friendPeer || pinned.Dialogs[1].PinnedOrder != 1 { + t.Fatalf("pinned dialogs = %+v, want other then friend with descending internal order", pinned.Dialogs) } if changed, err := dialogs.SetUnreadMark(ctx, ownerA.ID, friendPeer, true); err != nil || !changed { @@ -223,15 +273,16 @@ func TestDialogUserViewUsesContactProfileAndDialogFlags(t *testing.T) { if !containsPeer(marks, friendPeer) { t.Fatalf("unread marks = %+v, want friend peer", marks) } - if _, err := dialogs.MarkRead(ctx, ownerA.ID, friendPeer, 0); err != nil { + read, err := dialogs.MarkRead(ctx, ownerA.ID, friendPeer, domain.MaxMessageBoxID) + if err != nil { t.Fatalf("mark read: %v", err) } peerDialogs, err := dialogs.ListByPeers(ctx, ownerA.ID, []domain.Peer{friendPeer}) if err != nil { t.Fatalf("list peer dialogs after read: %v", err) } - if len(peerDialogs.Dialogs) != 1 || peerDialogs.Dialogs[0].UnreadMark { - t.Fatalf("peer dialog after read = %+v, want unread mark cleared", peerDialogs.Dialogs) + if len(peerDialogs.Dialogs) != 1 || peerDialogs.Dialogs[0].UnreadMark || peerDialogs.Dialogs[0].ReadInboxMaxID != peerDialogs.Dialogs[0].TopMessage || read.MaxID != peerDialogs.Dialogs[0].TopMessage { + t.Fatalf("peer dialog after read = %+v read=%+v, want unread mark cleared and read clamped to top", peerDialogs.Dialogs, read) } if changed, err := dialogs.SetPeerSettingsBarHidden(ctx, ownerA.ID, friendPeer); err != nil || !changed { @@ -246,6 +297,67 @@ func TestDialogUserViewUsesContactProfileAndDialogFlags(t *testing.T) { } } +func TestDialogStoreMarkReadClampsAndRecomputesUnread(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender := createTestUser(t, ctx, users, "+1911"+suffix+"01", "DialogReadSender", "") + recipient := createTestUser(t, ctx, users, "+1911"+suffix+"02", "DialogReadRecipient", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + sent := make([]domain.SendPrivateTextResult, 0, 3) + for i, body := range []string{"one", "two", "three"} { + got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 739100 + int64(i), + Message: body, + Date: 1700000390 + i, + }) + if err != nil { + t.Fatalf("SendPrivateText %q: %v", body, err) + } + sent = append(sent, got) + } + + dialogs := NewDialogStore(pool) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID} + partial, err := dialogs.MarkRead(ctx, recipient.ID, peer, sent[1].RecipientMessage.ID) + if err != nil { + t.Fatalf("partial MarkRead: %v", err) + } + if partial.MaxID != sent[1].RecipientMessage.ID || partial.StillUnreadCount != 1 { + t.Fatalf("partial read = %+v, want max second and one unread", partial) + } + list, err := dialogs.ListByPeers(ctx, recipient.ID, []domain.Peer{peer}) + if err != nil { + t.Fatalf("ListByPeers partial: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].ReadInboxMaxID != sent[1].RecipientMessage.ID || list.Dialogs[0].UnreadCount != 1 { + t.Fatalf("dialog after partial read = %+v, want read second and one unread", list.Dialogs) + } + + full, err := dialogs.MarkRead(ctx, recipient.ID, peer, domain.MaxMessageBoxID) + if err != nil { + t.Fatalf("future MarkRead: %v", err) + } + if full.MaxID != sent[2].RecipientMessage.ID || full.StillUnreadCount != 0 { + t.Fatalf("full read = %+v, want clamped to top and unread zero", full) + } + list, err = dialogs.ListByPeers(ctx, recipient.ID, []domain.Peer{peer}) + if err != nil { + t.Fatalf("ListByPeers full: %v", err) + } + if len(list.Dialogs) != 1 || list.Dialogs[0].ReadInboxMaxID != sent[2].RecipientMessage.ID || list.Dialogs[0].UnreadCount != 0 { + t.Fatalf("dialog after full read = %+v, want read top and unread zero", list.Dialogs) + } +} + func createTestUser(t *testing.T, ctx context.Context, users *UserStore, phone, firstName, lastName string) domain.User { t.Helper() user, err := users.Create(ctx, domain.User{ diff --git a/internal/store/postgres/contiguous_pts_integration_test.go b/internal/store/postgres/contiguous_pts_integration_test.go index f01a9061..f8580061 100644 --- a/internal/store/postgres/contiguous_pts_integration_test.go +++ b/internal/store/postgres/contiguous_pts_integration_test.go @@ -3,13 +3,13 @@ package postgres import ( "context" "testing" + "time" "telesrv/internal/domain" ) -// TestMaxContiguousPtsStopsAtHole 用真实 PG 验证 RecentUserPts 窗口查询 + 连续计算: -// 存在在途空洞时只报告最大连续 pts,补洞后回升。 -func TestMaxContiguousPtsStopsAtHole(t *testing.T) { +// TestAppendRejectsPtsHole 用真实 PG 验证显式 pts 写入不能制造空洞。 +func TestAppendRejectsPtsHole(t *testing.T) { pool := testPool(t) ctx := context.Background() @@ -39,27 +39,34 @@ func TestMaxContiguousPtsStopsAtHole(t *testing.T) { } } - for _, p := range []int{1, 2, 3, 5, 6} { // pts=4 为在途空洞 + for _, p := range []int{1, 2, 3} { appendPts(p) } - if _, err := pool.Exec(ctx, "DELETE FROM user_update_watermarks WHERE user_id = $1", owner.ID); err != nil { - t.Fatalf("delete watermark fallback row: %v", err) + err = events.Append(ctx, owner.ID, domain.UpdateEvent{ + Type: domain.UpdateEventNoop, + Pts: 5, + PtsCount: 1, + Date: 1700000005, + }) + if err == nil { + t.Fatal("append pts=5 succeeded, want gap rejection") } got, err := events.MaxContiguousPts(ctx, owner.ID) if err != nil { t.Fatalf("MaxContiguousPts: %v", err) } if got != 3 { - t.Fatalf("contiguous = %d, want 3(止于 pts=4 空洞,最大已提交为 6)", got) + t.Fatalf("contiguous = %d, want 3 after rejected gap", got) } - appendPts(4) // 在途事务提交/补洞 + appendPts(4) + appendPts(5) got, err = events.MaxContiguousPts(ctx, owner.ID) if err != nil { t.Fatalf("MaxContiguousPts after fill: %v", err) } - if got != 6 { - t.Fatalf("contiguous after fill = %d, want 6", got) + if got != 5 { + t.Fatalf("contiguous after ordered append = %d, want 5", got) } } @@ -96,8 +103,8 @@ func TestAppendWithDispatchWritesEventAndOutboxAtomically(t *testing.T) { } var excludeAuthKeyID [8]byte excludeAuthKeyID[0] = 9 - if err := NewUpdateEventStore(pool).AppendWithDispatch(ctx, owner.ID, event, excludeAuthKeyID, 77); err != nil { - t.Fatalf("AppendWithDispatch: %v", err) + if _, err := NewUpdateEventStore(pool).AppendAllocatedWithDispatch(ctx, owner.ID, event, excludeAuthKeyID, 77); err != nil { + t.Fatalf("AppendAllocatedWithDispatch: %v", err) } got, err := NewUpdateEventStore(pool).ListAfter(ctx, owner.ID, 0, 10) @@ -125,3 +132,182 @@ func TestAppendWithDispatchWritesEventAndOutboxAtomically(t *testing.T) { t.Fatalf("outbox = %+v, want event dispatch excluding current session", outbox) } } + +func TestDispatchOutboxLifecycleKeepsDurableEvents(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + + suffix := randomSuffix(t) + owner, err := NewUserStore(pool).Create(ctx, domain.User{ + AccessHash: 3, + Phone: "+1558" + suffix + "01", + FirstName: "Outbox", + }) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin tx: %v", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + events := NewUpdateEventStore(tx) + outbox := NewDispatchOutboxStore(tx, WithLeaseTimeout(time.Second)) + appendEvent := func(pts int, sessionID int64) { + t.Helper() + event := domain.UpdateEvent{ + UserID: owner.ID, + Type: domain.UpdateEventDialogPinned, + Pts: pts, + PtsCount: 1, + Date: 1700000100 + pts, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID + int64(pts)}, + Bool: pts%2 == 0, + } + if _, err := events.AppendAllocatedWithDispatch(ctx, owner.ID, event, [8]byte{}, sessionID); err != nil { + t.Fatalf("AppendAllocatedWithDispatch pts=%d: %v", pts, err) + } + if _, err := tx.Exec(ctx, ` + UPDATE dispatch_outbox + SET next_attempt_at = now() - interval '10 years', + updated_at = now() - interval '10 years' + WHERE target_user_id = $1 + AND pts = $2 + `, owner.ID, pts); err != nil { + t.Fatalf("make outbox ready pts=%d: %v", pts, err) + } + } + outboxRows := func(pts int) int { + t.Helper() + var count int + if err := tx.QueryRow(ctx, ` + SELECT count(*)::int + FROM dispatch_outbox + WHERE target_user_id = $1 + AND pts = $2 + `, owner.ID, pts).Scan(&count); err != nil { + t.Fatalf("count outbox pts=%d: %v", pts, err) + } + return count + } + eventRows := func(pts int) int { + t.Helper() + var count int + if err := tx.QueryRow(ctx, ` + SELECT count(*)::int + FROM user_update_events + WHERE user_id = $1 + AND pts = $2 + `, owner.ID, pts).Scan(&count); err != nil { + t.Fatalf("count event pts=%d: %v", pts, err) + } + return count + } + + appendEvent(1, 101) + claimed, err := outbox.ClaimPending(ctx, 1) + if err != nil { + t.Fatalf("ClaimPending first event: %v", err) + } + if len(claimed) != 1 || claimed[0].TargetUserID != owner.ID || claimed[0].Pts != 1 || claimed[0].Attempts != 1 || claimed[0].ExcludeSessionID != 101 { + t.Fatalf("claimed first = %+v, want owner pts=1 attempts=1", claimed) + } + if err := outbox.MarkDelivered(ctx, owner.ID, claimed[0].ID); err != nil { + t.Fatalf("MarkDelivered: %v", err) + } + if got := outboxRows(1); got != 0 { + t.Fatalf("outbox rows for delivered pts=1 = %d, want 0", got) + } + if got := eventRows(1); got != 1 { + t.Fatalf("durable event rows for delivered pts=1 = %d, want 1", got) + } + + appendEvent(2, 102) + if _, err := tx.Exec(ctx, ` + UPDATE dispatch_outbox + SET status = 'dispatching', + attempts = 1, + next_attempt_at = now() - interval '10 years', + updated_at = now() - interval '10 years' + WHERE target_user_id = $1 + AND pts = 2 + `, owner.ID); err != nil { + t.Fatalf("make stale dispatching: %v", err) + } + claimed, err = outbox.ClaimPending(ctx, 1) + if err != nil { + t.Fatalf("ClaimPending stale event: %v", err) + } + if len(claimed) != 1 || claimed[0].TargetUserID != owner.ID || claimed[0].Pts != 2 || claimed[0].Attempts != 2 { + t.Fatalf("claimed stale = %+v, want owner pts=2 attempts=2", claimed) + } + if err := outbox.MarkFailed(ctx, owner.ID, claimed[0].ID, "temporary"); err != nil { + t.Fatalf("MarkFailed temporary: %v", err) + } + var status string + var attempts int + var lastError string + var retryFuture bool + if err := tx.QueryRow(ctx, ` + SELECT status, attempts, last_error, next_attempt_at > now() + FROM dispatch_outbox + WHERE target_user_id = $1 + AND id = $2 + `, owner.ID, claimed[0].ID).Scan(&status, &attempts, &lastError, &retryFuture); err != nil { + t.Fatalf("query temporary failure: %v", err) + } + if status != "pending" || attempts != 2 || lastError != "temporary" || !retryFuture { + t.Fatalf("temporary failure status=%s attempts=%d err=%q retryFuture=%v, want pending attempts=2 future retry", status, attempts, lastError, retryFuture) + } + + if _, err := tx.Exec(ctx, ` + UPDATE dispatch_outbox + SET status = 'dispatching', + attempts = 5, + updated_at = now() - interval '10 years' + WHERE target_user_id = $1 + AND id = $2 + `, owner.ID, claimed[0].ID); err != nil { + t.Fatalf("prepare terminal failure: %v", err) + } + if err := outbox.MarkFailed(ctx, owner.ID, claimed[0].ID, "permanent"); err != nil { + t.Fatalf("MarkFailed permanent: %v", err) + } + if err := tx.QueryRow(ctx, ` + SELECT status, attempts, last_error + FROM dispatch_outbox + WHERE target_user_id = $1 + AND id = $2 + `, owner.ID, claimed[0].ID).Scan(&status, &attempts, &lastError); err != nil { + t.Fatalf("query terminal failure: %v", err) + } + if status != "failed" || attempts != 5 || lastError != "permanent" { + t.Fatalf("terminal failure status=%s attempts=%d err=%q, want failed attempts=5", status, attempts, lastError) + } + if _, err := tx.Exec(ctx, ` + UPDATE dispatch_outbox + SET updated_at = now() - interval '10 years' + WHERE target_user_id = $1 + AND id = $2 + `, owner.ID, claimed[0].ID); err != nil { + t.Fatalf("age failed row: %v", err) + } + deleted, err := outbox.DeleteFailed(ctx, 24*time.Hour, 1) + if err != nil { + t.Fatalf("DeleteFailed: %v", err) + } + if deleted != 1 { + t.Fatalf("DeleteFailed deleted=%d, want 1", deleted) + } + if got := outboxRows(2); got != 0 { + t.Fatalf("outbox rows for failed pts=2 = %d, want 0", got) + } + if got := eventRows(2); got != 1 { + t.Fatalf("durable event rows for failed pts=2 = %d, want 1", got) + } +} diff --git a/internal/store/postgres/counter_source.go b/internal/store/postgres/counter_source.go index b8345da1..a46ba918 100644 --- a/internal/store/postgres/counter_source.go +++ b/internal/store/postgres/counter_source.go @@ -43,21 +43,22 @@ func (s *ChannelIDCounterSource) Current(ctx context.Context, _ int64) (int, err return id, nil } -// ChannelPtsCounterSource 从 channel_update_events 恢复某 channel 的当前最大 pts。 -type ChannelPtsCounterSource struct { +// SecretChatIDCounterSource 从 secret_chats 表恢复全局 secret chat id(迁移 0137)。 +type SecretChatIDCounterSource struct { db sqlcgen.DBTX } -func NewChannelPtsCounterSource(db sqlcgen.DBTX) *ChannelPtsCounterSource { - return &ChannelPtsCounterSource{db: db} +// NewSecretChatIDCounterSource 创建 Redis SecretChatIDAllocator 的 PG 恢复源。 +func NewSecretChatIDCounterSource(db sqlcgen.DBTX) *SecretChatIDCounterSource { + return &SecretChatIDCounterSource{db: db} } -func (s *ChannelPtsCounterSource) Current(ctx context.Context, channelID int64) (int, error) { - var pts int - if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(pts), 0) FROM channel_update_events WHERE channel_id = $1`, channelID).Scan(&pts); err != nil { - return 0, fmt.Errorf("max channel pts: %w", err) +func (s *SecretChatIDCounterSource) Current(ctx context.Context, _ int64) (int, error) { + var id int + if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(chat_id), 0) FROM secret_chats`).Scan(&id); err != nil { + return 0, fmt.Errorf("max secret chat id: %w", err) } - return pts, nil + return id, nil } // ChannelMessageIDCounterSource 从 channel_messages 恢复某 channel 的当前最大 message id。 diff --git a/internal/store/postgres/dialog.go b/internal/store/postgres/dialog.go index fc6e0387..910053ec 100644 --- a/internal/store/postgres/dialog.go +++ b/internal/store/postgres/dialog.go @@ -16,12 +16,20 @@ import ( // DialogStore 用 PostgreSQL 实现 store.DialogStore。 type DialogStore struct { - q *sqlcgen.Queries + db sqlcgen.DBTX + q *sqlcgen.Queries } // NewDialogStore 基于 pgx 连接池(或事务)创建 DialogStore。 func NewDialogStore(db sqlcgen.DBTX) *DialogStore { - return &DialogStore{q: sqlcgen.New(db)} + return &DialogStore{db: db, q: sqlcgen.New(db)} +} + +func (s *DialogStore) enrichDialogTopMessages(ctx context.Context, userID int64, messages []domain.Message) error { + if len(messages) == 0 { + return nil + } + return NewMessageStore(s.db).enrichPrivateMessageReactions(ctx, s.db, userID, messages) } func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) { @@ -69,6 +77,9 @@ func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domai UnreadCount: int(row.UnreadCount), UnreadMentions: int(row.UnreadMentionsCount), UnreadReactions: int(row.UnreadReactionsCount), + TTLPeriod: int(row.TtlPeriod), + ThemeEmoticon: row.ThemeEmoticon, + HasScheduled: row.HasScheduled, Pinned: row.Pinned, PinnedOrder: int(row.PinnedOrder), UnreadMark: row.UnreadMark, @@ -125,6 +136,9 @@ func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domai UnreadCount: int(row.UnreadCount), UnreadMentions: int(row.UnreadMentionsCount), UnreadReactions: int(row.UnreadReactionsCount), + TTLPeriod: int(row.TtlPeriod), + ThemeEmoticon: row.ThemeEmoticon, + HasScheduled: row.HasScheduled, Pinned: row.Pinned, PinnedOrder: int(row.PinnedOrder), UnreadMark: row.UnreadMark, @@ -135,18 +149,23 @@ func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domai if _, ok := seenUsers[row.PeerUserID]; !ok { seenUsers[row.PeerUserID] = struct{}{} out.Users = append(out.Users, domain.User{ - ID: row.PeerUserID, - AccessHash: row.PeerAccessHash, - Phone: row.PeerPhone, - FirstName: row.PeerFirstName, - LastName: row.PeerLastName, - Username: row.PeerUsername, - CountryCode: row.PeerCountryCode, - Verified: row.PeerVerified, - Support: row.PeerSupport, - LastSeenAt: int(row.PeerLastSeenAt), - Contact: row.PeerContact, - Mutual: row.PeerMutual, + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + Bot: row.PeerIsBot, + BotInfoVersion: int(row.PeerBotInfoVersion), + PremiumUntil: int(row.PeerPremiumUntil), + EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID, + EmojiStatusUntil: int(row.PeerEmojiStatusUntil), + LastSeenAt: int(row.PeerLastSeenAt), + Contact: row.PeerContact, + Mutual: row.PeerMutual, }) } } @@ -159,19 +178,72 @@ func (s *DialogStore) ListByUser(ctx context.Context, userID int64, filter domai if err != nil { return domain.DialogList{}, fmt.Errorf("decode message media: %w", err) } + markup, err := decodeReplyMarkup(row.MessageReplyMarkupJson) + if err != nil { + return domain.DialogList{}, fmt.Errorf("decode message reply markup: %w", err) + } + rich, err := decodeRichMessage(row.MessageRichMessageJson) + if err != nil { + return domain.DialogList{}, fmt.Errorf("decode message rich message: %w", err) + } + // top message 必须带全量元数据:TDesktop 把 getDialogs 的消息 + // 先入缓存且不被后续 difference/getHistory 覆盖,缺 reply 等 + // 字段会让置顶/回复服务消息永久渲染成 "Deleted message"。 + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.MessageSilent, + row.MessageNoforwards, + row.MessageReplyToMsgID, + row.MessageReplyToPeerType, + row.MessageReplyToPeerID, + row.MessageReplyToTopID, + row.MessageReplyToStoryID, + row.MessageQuoteText, + row.MessageQuoteEntitiesJson, + row.MessageQuoteOffset, + row.MessageFwdFromPeerType, + row.MessageFwdFromPeerID, + row.MessageFwdFromName, + row.MessageFwdDate, + row.MessageFwdSavedFromPeerType, + row.MessageFwdSavedFromPeerID, + row.MessageFwdSavedFromMsgID, + ) + if err != nil { + return domain.DialogList{}, fmt.Errorf("decode message metadata: %w", err) + } out.Messages = append(out.Messages, domain.Message{ - ID: int(row.MessageID), - OwnerUserID: row.UserID, - Peer: dialog.Peer, - From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID}, - Date: int(row.MessageDate), - Out: row.MessageOutgoing, - Body: row.MessageBody, - Entities: entities, - Media: media, + ID: int(row.MessageID), + UID: row.MessagePrivateMessageID, + OwnerUserID: row.UserID, + Peer: dialog.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.MessageEditDate), + Out: row.MessageOutgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.MessageBody, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Media: media, + TTLPeriod: int(row.MessageTtlPeriod), + ExpiresAt: int(row.MessageExpiresAt), + MediaUnread: row.MessageMediaUnread, + ReactionUnread: row.MessageReactionUnread, + ViaBotID: row.MessageViaBotID, + GroupedID: row.MessageGroupedID, + Effect: row.MessageEffect, + ReplyMarkup: markup, + RichMessage: rich, + Pinned: row.MessagePinned, + SavedPeer: savedPeerFromFields(row.MessageSavedPeerType, row.MessageSavedPeerID), }) } } + if err := s.enrichDialogTopMessages(ctx, userID, out.Messages); err != nil { + return domain.DialogList{}, err + } return out, nil } @@ -216,6 +288,9 @@ func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []dom UnreadCount: int(row.UnreadCount), UnreadMentions: int(row.UnreadMentionsCount), UnreadReactions: int(row.UnreadReactionsCount), + TTLPeriod: int(row.TtlPeriod), + ThemeEmoticon: row.ThemeEmoticon, + HasScheduled: row.HasScheduled, Pinned: row.Pinned, PinnedOrder: int(row.PinnedOrder), UnreadMark: row.UnreadMark, @@ -226,18 +301,23 @@ func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []dom if _, ok := seenUsers[row.PeerUserID]; !ok { seenUsers[row.PeerUserID] = struct{}{} out.Users = append(out.Users, domain.User{ - ID: row.PeerUserID, - AccessHash: row.PeerAccessHash, - Phone: row.PeerPhone, - FirstName: row.PeerFirstName, - LastName: row.PeerLastName, - Username: row.PeerUsername, - CountryCode: row.PeerCountryCode, - Verified: row.PeerVerified, - Support: row.PeerSupport, - LastSeenAt: int(row.PeerLastSeenAt), - Contact: row.PeerContact, - Mutual: row.PeerMutual, + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + Bot: row.PeerIsBot, + BotInfoVersion: int(row.PeerBotInfoVersion), + PremiumUntil: int(row.PeerPremiumUntil), + EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID, + EmojiStatusUntil: int(row.PeerEmojiStatusUntil), + LastSeenAt: int(row.PeerLastSeenAt), + Contact: row.PeerContact, + Mutual: row.PeerMutual, }) } } @@ -250,19 +330,72 @@ func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []dom if err != nil { return domain.DialogList{}, fmt.Errorf("decode message media: %w", err) } + markup, err := decodeReplyMarkup(row.MessageReplyMarkupJson) + if err != nil { + return domain.DialogList{}, fmt.Errorf("decode message reply markup: %w", err) + } + rich, err := decodeRichMessage(row.MessageRichMessageJson) + if err != nil { + return domain.DialogList{}, fmt.Errorf("decode message rich message: %w", err) + } + // top message 必须带全量元数据:TDesktop 把 getDialogs 的消息 + // 先入缓存且不被后续 difference/getHistory 覆盖,缺 reply 等 + // 字段会让置顶/回复服务消息永久渲染成 "Deleted message"。 + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.MessageSilent, + row.MessageNoforwards, + row.MessageReplyToMsgID, + row.MessageReplyToPeerType, + row.MessageReplyToPeerID, + row.MessageReplyToTopID, + row.MessageReplyToStoryID, + row.MessageQuoteText, + row.MessageQuoteEntitiesJson, + row.MessageQuoteOffset, + row.MessageFwdFromPeerType, + row.MessageFwdFromPeerID, + row.MessageFwdFromName, + row.MessageFwdDate, + row.MessageFwdSavedFromPeerType, + row.MessageFwdSavedFromPeerID, + row.MessageFwdSavedFromMsgID, + ) + if err != nil { + return domain.DialogList{}, fmt.Errorf("decode message metadata: %w", err) + } out.Messages = append(out.Messages, domain.Message{ - ID: int(row.MessageID), - OwnerUserID: row.UserID, - Peer: dialog.Peer, - From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID}, - Date: int(row.MessageDate), - Out: row.MessageOutgoing, - Body: row.MessageBody, - Entities: entities, - Media: media, + ID: int(row.MessageID), + UID: row.MessagePrivateMessageID, + OwnerUserID: row.UserID, + Peer: dialog.Peer, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.MessageFromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.MessageEditDate), + Out: row.MessageOutgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.MessageBody, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Media: media, + TTLPeriod: int(row.MessageTtlPeriod), + ExpiresAt: int(row.MessageExpiresAt), + MediaUnread: row.MessageMediaUnread, + ReactionUnread: row.MessageReactionUnread, + ViaBotID: row.MessageViaBotID, + GroupedID: row.MessageGroupedID, + Effect: row.MessageEffect, + ReplyMarkup: markup, + RichMessage: rich, + Pinned: row.MessagePinned, + SavedPeer: savedPeerFromFields(row.MessageSavedPeerType, row.MessageSavedPeerID), }) } } + if err := s.enrichDialogTopMessages(ctx, userID, out.Messages); err != nil { + return domain.DialogList{}, err + } out.Count = len(out.Dialogs) out.Hash = dialogListHash(out.Dialogs) return out, nil @@ -288,6 +421,19 @@ func (s *DialogStore) Upsert(ctx context.Context, userID int64, dialog domain.Di return nil } +func (s *DialogStore) UpsertInbox(ctx context.Context, userID int64, dialog domain.Dialog) error { + if err := s.q.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{ + UserID: userID, + PeerType: string(dialog.Peer.Type), + PeerID: dialog.Peer.ID, + TopMessageID: int32(dialog.TopMessage), + TopMessageDate: int32(dialog.TopMessageDate), + }); err != nil { + return fmt.Errorf("upsert inbox dialog: %w", err) + } + return nil +} + func (s *DialogStore) SaveDraft(ctx context.Context, userID int64, draft domain.DialogDraft) error { data, err := json.Marshal(draft) if err != nil { @@ -306,6 +452,26 @@ func (s *DialogStore) SaveDraft(ctx context.Context, userID int64, draft domain. return nil } +func (s *DialogStore) GetDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (domain.DialogDraft, bool, error) { + var data []byte + err := s.db.QueryRow(ctx, ` +SELECT draft +FROM dialog_drafts +WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3 AND top_message_id = $4`, + userID, string(peer.Type), peer.ID, int32(topMessageID)).Scan(&data) + if errors.Is(err, pgx.ErrNoRows) { + return domain.DialogDraft{}, false, nil + } + if err != nil { + return domain.DialogDraft{}, false, fmt.Errorf("get dialog draft: %w", err) + } + var draft domain.DialogDraft + if err := json.Unmarshal(data, &draft); err != nil { + return domain.DialogDraft{}, false, fmt.Errorf("decode dialog draft: %w", err) + } + return draft, true, nil +} + func (s *DialogStore) DeleteDraft(ctx context.Context, userID int64, peer domain.Peer, topMessageID int) (bool, error) { changed, err := s.q.DeleteDialogDraft(ctx, sqlcgen.DeleteDialogDraftParams{ UserID: userID, @@ -366,41 +532,79 @@ func (s *DialogStore) MarkRead(ctx context.Context, userID int64, peer domain.Pe }, nil } -func (s *DialogStore) SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) { - changed, err := s.q.SetDialogPinned(ctx, sqlcgen.SetDialogPinnedParams{ +func (s *DialogStore) SetPinned(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, int, error) { + row, err := s.q.SetDialogPinned(ctx, sqlcgen.SetDialogPinnedParams{ UserID: userID, PeerType: string(peer.Type), PeerID: peer.ID, Pinned: pinned, }) if err != nil { - return false, fmt.Errorf("set dialog pinned: %w", err) + return false, 0, fmt.Errorf("set dialog pinned: %w", err) } - return changed, nil + return row.Changed, int(row.FolderID), nil } -func (s *DialogStore) ReorderPinned(ctx context.Context, userID int64, order []domain.Peer, force bool) error { +func (s *DialogStore) ReorderPinned(ctx context.Context, userID int64, folderID int, order []domain.Peer, force bool) (bool, error) { peerTypes, peerIDs := peerArrays(order) + changed := false if force { - if err := s.q.ClearPinnedDialogsNotInOrder(ctx, sqlcgen.ClearPinnedDialogsNotInOrderParams{ - UserID: userID, - PeerTypes: peerTypes, - PeerIds: peerIDs, - }); err != nil { - return fmt.Errorf("clear pinned dialogs not in order: %w", err) + tag, err := s.db.Exec(ctx, ` +WITH requested AS ( + SELECT ($3::text[])[i] AS peer_type, ($4::bigint[])[i] AS peer_id + FROM generate_subscripts($4::bigint[], 1) AS g(i) + WHERE i <= cardinality($3::text[]) +) +UPDATE dialogs d +SET pinned = false, pinned_order = 0, updated_at = now() +WHERE d.user_id = $1 + AND d.pinned + AND d.folder_id = $2::int + AND NOT EXISTS ( + SELECT 1 + FROM requested r + WHERE r.peer_type = d.peer_type + AND r.peer_id = d.peer_id + )`, userID, folderID, peerTypes, peerIDs) + if err != nil { + return false, fmt.Errorf("clear pinned dialogs not in order: %w", err) + } + if tag.RowsAffected() > 0 { + changed = true } } if len(peerTypes) == 0 { - return nil + return changed, nil } - if err := s.q.ReorderPinnedDialogs(ctx, sqlcgen.ReorderPinnedDialogsParams{ - UserID: userID, - PeerTypes: peerTypes, - PeerIds: peerIDs, - }); err != nil { - return fmt.Errorf("reorder pinned dialogs: %w", err) + tag, err := s.db.Exec(ctx, ` +WITH requested AS ( + SELECT ($3::text[])[i] AS peer_type, ($4::bigint[])[i] AS peer_id, i::int AS pos + FROM generate_subscripts($4::bigint[], 1) AS g(i) + WHERE i <= cardinality($3::text[]) +), +deduped AS ( + SELECT DISTINCT ON (peer_type, peer_id) + peer_type, + peer_id, + (cardinality($4::bigint[]) - pos + 1)::int AS ord + FROM requested + ORDER BY peer_type, peer_id, pos +) +UPDATE dialogs d +SET pinned = true, pinned_order = deduped.ord, updated_at = now() +FROM deduped +WHERE d.user_id = $1 + AND d.peer_type = deduped.peer_type + AND d.peer_id = deduped.peer_id + AND d.folder_id = $2::int + AND (NOT d.pinned OR d.pinned_order IS DISTINCT FROM deduped.ord)`, userID, folderID, peerTypes, peerIDs) + if err != nil { + return false, fmt.Errorf("reorder pinned dialogs: %w", err) } - return nil + if tag.RowsAffected() > 0 { + changed = true + } + return changed, nil } func (s *DialogStore) SetUnreadMark(ctx context.Context, userID int64, peer domain.Peer, unread bool) (bool, error) { @@ -428,6 +632,44 @@ func (s *DialogStore) ListUnreadMarked(ctx context.Context, userID int64) ([]dom return out, nil } +func (s *DialogStore) SetChatTheme(ctx context.Context, userID int64, peer domain.Peer, emoticon string) (bool, error) { + if userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, nil + } + var current string + err := s.db.QueryRow(ctx, ` +SELECT theme_emoticon +FROM dialogs +WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3`, + userID, string(peer.Type), peer.ID).Scan(¤t) + if errors.Is(err, pgx.ErrNoRows) { + if emoticon == "" { + return false, nil + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO dialogs (user_id, peer_type, peer_id, theme_emoticon) +VALUES ($1, $2, $3, $4)`, + userID, string(peer.Type), peer.ID, emoticon); err != nil { + return false, fmt.Errorf("insert dialog chat theme: %w", err) + } + return true, nil + } + if err != nil { + return false, fmt.Errorf("get dialog chat theme: %w", err) + } + if current == emoticon { + return false, nil + } + if _, err := s.db.Exec(ctx, ` +UPDATE dialogs +SET theme_emoticon = $4, updated_at = now() +WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3`, + userID, string(peer.Type), peer.ID, emoticon); err != nil { + return false, fmt.Errorf("update dialog chat theme: %w", err) + } + return true, nil +} + func (s *DialogStore) SetPeerSettingsBarHidden(ctx context.Context, userID int64, peer domain.Peer) (bool, error) { changed, err := s.q.SetPeerSettingsBarHidden(ctx, sqlcgen.SetPeerSettingsBarHiddenParams{ UserID: userID, @@ -579,6 +821,37 @@ func (s *DialogStore) EditPeerFolders(ctx context.Context, userID int64, peers [ return nil } +func (s *DialogStore) SetArchivePinned(ctx context.Context, userID int64, pinned bool) (bool, error) { + changed, err := s.q.SetDialogArchivePinned(ctx, sqlcgen.SetDialogArchivePinnedParams{ + UserID: userID, + Pinned: pinned, + }) + if err != nil { + return false, fmt.Errorf("set dialog archive pinned: %w", err) + } + return changed, nil +} + +func (s *DialogStore) ArchivePinned(ctx context.Context, userID int64) (bool, error) { + pinned, err := s.q.GetDialogArchivePinned(ctx, userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // 官方默认 archive folder 行在置顶区。 + return true, nil + } + return false, fmt.Errorf("get dialog archive pinned: %w", err) + } + return pinned, nil +} + +func (s *DialogStore) CountArchiveUnread(ctx context.Context, userID int64) (int, int, error) { + row, err := s.q.CountArchiveUnreadDialogs(ctx, userID) + if err != nil { + return 0, 0, fmt.Errorf("count archive unread dialogs: %w", err) + } + return int(row.UnreadPeers), int(row.UnreadMessages), nil +} + type dialogFolderParams struct { contacts bool nonContacts bool @@ -727,6 +1000,7 @@ func dialogListHash(dialogs []domain.Dialog) int64 { buf[46] = 0 } _, _ = h.Write(buf[:]) + _, _ = h.Write([]byte(d.ThemeEmoticon)) } return int64(h.Sum64()) } diff --git a/internal/store/postgres/dialog_draft_get_integration_test.go b/internal/store/postgres/dialog_draft_get_integration_test.go new file mode 100644 index 00000000..1cdce7ea --- /dev/null +++ b/internal/store/postgres/dialog_draft_get_integration_test.go @@ -0,0 +1,49 @@ +package postgres + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestDialogDraftGetRoundTrip 回归:GetDraft(draft_message 事件重放按 peer 重载权威态) +// 与 SaveDraft/DeleteDraft 的键语义一致(user, peer, top_message_id)。 +func TestDialogDraftGetRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + owner, err := NewUserStore(pool).Create(ctx, domain.User{AccessHash: 31, Phone: "+1777" + suffix + "01", FirstName: "DraftOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + userID := owner.ID + peer := domain.Peer{Type: domain.PeerTypeUser, ID: userID + 1} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dialog_drafts WHERE user_id = $1", userID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", userID) + }) + + dialogs := NewDialogStore(pool) + if _, found, err := dialogs.GetDraft(ctx, userID, peer, 0); err != nil || found { + t.Fatalf("get missing draft = found %v err %v, want absent", found, err) + } + saved := domain.DialogDraft{Peer: peer, Message: "pg roundtrip", Date: int(time.Now().Unix())} + if err := dialogs.SaveDraft(ctx, userID, saved); err != nil { + t.Fatalf("save draft: %v", err) + } + got, found, err := dialogs.GetDraft(ctx, userID, peer, 0) + if err != nil || !found { + t.Fatalf("get draft = found %v err %v, want present", found, err) + } + if got.Message != "pg roundtrip" || got.Peer != peer { + t.Fatalf("draft = %+v, want saved payload", got) + } + if _, err := dialogs.DeleteDraft(ctx, userID, peer, 0); err != nil { + t.Fatalf("delete draft: %v", err) + } + if _, found, err := dialogs.GetDraft(ctx, userID, peer, 0); err != nil || found { + t.Fatalf("get deleted draft = found %v err %v, want absent", found, err) + } +} diff --git a/internal/store/postgres/dialog_top_metadata_integration_test.go b/internal/store/postgres/dialog_top_metadata_integration_test.go new file mode 100644 index 00000000..f3ba1630 --- /dev/null +++ b/internal/store/postgres/dialog_top_metadata_integration_test.go @@ -0,0 +1,167 @@ +package postgres + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestDialogTopMessageCarriesFullMetadata 回归 getDialogs/getPeerDialogs 的 top-message +// 投影完整性:dialog 列表的 top message 是独立查询(dialog.sql,不复用 message.sql), +// 必须带 grouped_id / effect / reply_markup / rich_message —— TDesktop 把 getDialogs +// 返回的消息入缓存且不被后续 getHistory 覆盖,缺任一字段都让最新那条消息永久按缺失态渲染。 +func TestDialogTopMessageCarriesFullMetadata(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1997" + suffix + "01", FirstName: "MetaSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 72, Phone: "+1997" + suffix + "02", FirstName: "MetaRecipient"}) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + ids := []int64{sender.ID, recipient.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) + const groupedID = int64(990000000777) + const effect = int64(5104841169784993603) + markup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ + {Type: domain.MarkupButtonCallback, Text: "Press", Data: []byte{0x00, 0x01, 0xFF, 'x'}}, + }}} + rich := &domain.MessageRichMessage{Rtl: true, Blocks: []byte{0x10, 0x20, 0x30}} + + res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: time.Now().UnixNano(), + Message: "full metadata top", + GroupedID: groupedID, + Effect: effect, + ReplyMarkup: markup, + RichMessage: rich, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send message: %v", err) + } + + dialogs := NewDialogStore(pool) + check := func(label string, ownerID int64, boxID int) { + t.Helper() + list, err := dialogs.ListByUser(ctx, ownerID, domain.DialogFilter{Limit: 20}) + if err != nil { + t.Fatalf("%s getDialogs: %v", label, err) + } + var top *domain.Message + for i := range list.Messages { + if list.Messages[i].ID == boxID { + top = &list.Messages[i] + } + } + if top == nil { + t.Fatalf("%s getDialogs missing top message box %d", label, boxID) + } + if top.GroupedID != groupedID { + t.Fatalf("%s dialog top grouped_id = %d, want %d", label, top.GroupedID, groupedID) + } + if top.Effect != effect { + t.Fatalf("%s dialog top effect = %d, want %d", label, top.Effect, effect) + } + if top.ReplyMarkup == nil || len(top.ReplyMarkup.Inline) != 1 || len(top.ReplyMarkup.Inline[0]) != 1 || + top.ReplyMarkup.Inline[0][0].Text != "Press" { + t.Fatalf("%s dialog top reply_markup not preserved: %+v", label, top.ReplyMarkup) + } + if top.RichMessage == nil || top.RichMessage.IsZero() || !top.RichMessage.Rtl { + t.Fatalf("%s dialog top rich_message not preserved: %+v", label, top.RichMessage) + } + } + check("sender", sender.ID, res.SenderMessage.ID) + check("recipient", recipient.ID, res.RecipientMessage.ID) +} + +// TestSavedDialogTopMessageCarriesFullMetadata 回归 getSavedDialogs 的 top-message 投影: +// 收藏夹子会话(Saved Messages)的 top message 是另一组独立查询(saved_dialog.sql,3 条), +// 同样不复用 message.sql,必须带 grouped_id/effect/reply_markup/rich_message(转发相册/ +// bot 键盘/富文本进收藏夹后子会话最新一条会缺失)。 +func TestSavedDialogTopMessageCarriesFullMetadata(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + alice, err := users.Create(ctx, domain.User{AccessHash: 73, Phone: "+1998" + suffix + "01", FirstName: "SavedMeta"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = $1", alice.ID) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = $1", alice.ID) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = $1", alice.ID) + _, _ = pool.Exec(ctx, "DELETE FROM saved_dialog_pins WHERE user_id = $1", alice.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", alice.ID) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) + const groupedID = int64(990000000888) + const effect = int64(5104841169784993603) + markup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ + {Type: domain.MarkupButtonCallback, Text: "Saved", Data: []byte{0x09}}, + }}} + rich := &domain.MessageRichMessage{Part: true, Blocks: []byte{0x44, 0x55}} + + // 直发笔记 → saved_peer = self;子会话 top 即这条。 + note, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, + RecipientUserID: alice.ID, + RandomID: time.Now().UnixNano(), + Message: "self note full meta", + GroupedID: groupedID, + Effect: effect, + ReplyMarkup: markup, + RichMessage: rich, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send self note: %v", err) + } + + list, err := messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 20}) + if err != nil { + t.Fatalf("ListSavedDialogs: %v", err) + } + var top *domain.Message + for i := range list.Messages { + if list.Messages[i].ID == note.SenderMessage.ID { + top = &list.Messages[i] + } + } + if top == nil { + t.Fatalf("getSavedDialogs missing top message box %d (got %d messages)", note.SenderMessage.ID, len(list.Messages)) + } + if top.GroupedID != groupedID { + t.Fatalf("saved dialog top grouped_id = %d, want %d", top.GroupedID, groupedID) + } + if top.Effect != effect { + t.Fatalf("saved dialog top effect = %d, want %d", top.Effect, effect) + } + if top.ReplyMarkup == nil || len(top.ReplyMarkup.Inline) != 1 || top.ReplyMarkup.Inline[0][0].Text != "Saved" { + t.Fatalf("saved dialog top reply_markup not preserved: %+v", top.ReplyMarkup) + } + if top.RichMessage == nil || top.RichMessage.IsZero() || !top.RichMessage.Part { + t.Fatalf("saved dialog top rich_message not preserved: %+v", top.RichMessage) + } +} diff --git a/internal/store/postgres/encrypted_queue.go b/internal/store/postgres/encrypted_queue.go new file mode 100644 index 00000000..9d4c43ee --- /dev/null +++ b/internal/store/postgres/encrypted_queue.go @@ -0,0 +1,282 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// EncryptedQueueStore 是 store.EncryptedQueueStore 的 PostgreSQL 实现(迁移 0138)。 +// 盲中继 qts 投递队列:qts 分配(secret_qts_watermarks.reserved_qts 自增)+ 写队列行 +// 在单事务内完成,保证设备 qts 无空洞。bytes 原样 BYTEA 存储,永不解密。 +type EncryptedQueueStore struct { + db sqlcgen.DBTX +} + +// NewEncryptedQueueStore 基于 pgx 连接池创建 EncryptedQueueStore。 +func NewEncryptedQueueStore(db sqlcgen.DBTX) *EncryptedQueueStore { + return &EncryptedQueueStore{db: db} +} + +const encryptedMessageColumns = `receiver_auth_key_id, qts, receiver_user_id, chat_id, random_id, +date, is_service, bytes, file_id, file_access_hash, file_size, file_dc_id, file_key_fingerprint` + +func scanEncryptedMessage(row rowScanner) (domain.SecretChatMessage, error) { + var m domain.SecretChatMessage + var ( + fileID, fileAccessHash, fileSize *int64 + fileDC, fileKeyFP *int32 + ) + if err := row.Scan( + &m.ReceiverAuthKeyID, &m.Qts, &m.ReceiverUserID, &m.ChatID, &m.RandomID, + &m.Date, &m.IsService, &m.Bytes, &fileID, &fileAccessHash, &fileSize, &fileDC, &fileKeyFP, + ); err != nil { + return domain.SecretChatMessage{}, err + } + if fileID != nil { + ref := domain.EncryptedFileRef{ID: *fileID} + if fileAccessHash != nil { + ref.AccessHash = *fileAccessHash + } + if fileSize != nil { + ref.Size = *fileSize + } + if fileDC != nil { + ref.DCID = int(*fileDC) + } + if fileKeyFP != nil { + ref.KeyFingerprint = int(*fileKeyFP) + } + m.File = &ref + } + return m, nil +} + +func (s *EncryptedQueueStore) begin(ctx context.Context, op string) (pgx.Tx, error) { + beginner, ok := s.db.(txBeginner) + if !ok { + return nil, fmt.Errorf("%s: db does not support transactions", op) + } + tx, err := beginner.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin %s: %w", op, err) + } + return tx, nil +} + +func (s *EncryptedQueueStore) AppendEncryptedMessage(ctx context.Context, msg domain.SecretChatMessage) (domain.SecretChatMessage, bool, error) { + tx, err := s.begin(ctx, "append encrypted message") + if err != nil { + return domain.SecretChatMessage{}, false, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + + // 幂等:同接收设备同 chat 同 random_id 已存在 → 返回既有行(不重分配 qts)。 + existing, err := scanEncryptedMessage(tx.QueryRow(ctx, + `SELECT `+encryptedMessageColumns+` FROM encrypted_message_queue +WHERE receiver_auth_key_id = $1 AND chat_id = $2 AND random_id = $3`, + msg.ReceiverAuthKeyID, msg.ChatID, msg.RandomID)) + if err == nil { + if cerr := tx.Commit(ctx); cerr != nil { + return domain.SecretChatMessage{}, false, fmt.Errorf("commit append (dedup): %w", cerr) + } + committed = true + return existing, true, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return domain.SecretChatMessage{}, false, fmt.Errorf("dedup lookup: %w", err) + } + + // 分配下一个 qts(首值 1),与写队列同事务。 + var qts int + if err := tx.QueryRow(ctx, ` +INSERT INTO secret_qts_watermarks (auth_key_id, reserved_qts) +VALUES ($1, 1) +ON CONFLICT (auth_key_id) DO UPDATE SET reserved_qts = secret_qts_watermarks.reserved_qts + 1, updated_at = now() +RETURNING reserved_qts`, msg.ReceiverAuthKeyID).Scan(&qts); err != nil { + return domain.SecretChatMessage{}, false, fmt.Errorf("reserve device qts: %w", err) + } + msg.Qts = qts + + var ( + fileID, fileAccessHash, fileSize any + fileDC, fileKeyFP any + ) + if msg.File != nil { + fileID = msg.File.ID + fileAccessHash = msg.File.AccessHash + fileSize = msg.File.Size + fileDC = int32(msg.File.DCID) + fileKeyFP = int32(msg.File.KeyFingerprint) + } + if _, err := tx.Exec(ctx, ` +INSERT INTO encrypted_message_queue (receiver_auth_key_id, qts, receiver_user_id, chat_id, random_id, + date, is_service, bytes, file_id, file_access_hash, file_size, file_dc_id, file_key_fingerprint) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + msg.ReceiverAuthKeyID, msg.Qts, msg.ReceiverUserID, msg.ChatID, msg.RandomID, + msg.Date, msg.IsService, msg.Bytes, fileID, fileAccessHash, fileSize, fileDC, fileKeyFP); err != nil { + return domain.SecretChatMessage{}, false, fmt.Errorf("insert encrypted message: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.SecretChatMessage{}, false, fmt.Errorf("commit append encrypted message: %w", err) + } + committed = true + return msg, false, nil +} + +func (s *EncryptedQueueStore) ListEncryptedMessagesSince(ctx context.Context, receiverAuthKeyID int64, sinceQts, limit int) ([]domain.SecretChatMessage, error) { + if limit <= 0 || limit > 1000 { + limit = 1000 + } + rows, err := s.db.Query(ctx, + `SELECT `+encryptedMessageColumns+` FROM encrypted_message_queue +WHERE receiver_auth_key_id = $1 AND qts > $2 ORDER BY qts ASC LIMIT $3`, + receiverAuthKeyID, sinceQts, limit) + if err != nil { + return nil, fmt.Errorf("list encrypted messages: %w", err) + } + defer rows.Close() + var out []domain.SecretChatMessage + for rows.Next() { + m, err := scanEncryptedMessage(rows) + if err != nil { + return nil, err + } + out = append(out, m) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *EncryptedQueueStore) ReservedQts(ctx context.Context, receiverAuthKeyID int64) (int, error) { + var qts int + err := s.db.QueryRow(ctx, + `SELECT reserved_qts FROM secret_qts_watermarks WHERE auth_key_id = $1`, receiverAuthKeyID).Scan(&qts) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("reserved qts: %w", err) + } + return qts, nil +} + +func (s *EncryptedQueueStore) AckEncryptedMessages(ctx context.Context, receiverAuthKeyID int64, maxQts int) error { + if maxQts <= 0 { + return nil + } + // 推进 confirmed_qts(GREATEST 幂等,回退忽略)。 + if _, err := s.db.Exec(ctx, ` +INSERT INTO secret_qts_watermarks (auth_key_id, confirmed_qts) +VALUES ($1, $2) +ON CONFLICT (auth_key_id) DO UPDATE SET confirmed_qts = GREATEST(secret_qts_watermarks.confirmed_qts, EXCLUDED.confirmed_qts), updated_at = now()`, + receiverAuthKeyID, maxQts); err != nil { + return fmt.Errorf("advance confirmed qts: %w", err) + } + if _, err := s.db.Exec(ctx, ` +UPDATE encrypted_message_queue SET acked = true +WHERE receiver_auth_key_id = $1 AND qts <= $2 AND NOT acked`, receiverAuthKeyID, maxQts); err != nil { + return fmt.Errorf("ack encrypted messages: %w", err) + } + return nil +} + +func (s *EncryptedQueueStore) AppendStateEvent(ctx context.Context, ev domain.EncryptedStateEvent) (int64, error) { + var id int64 + if err := s.db.QueryRow(ctx, ` +INSERT INTO encrypted_state_events (target_user_id, target_auth_key_id, chat_id, event_type, max_date, date) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id`, + ev.TargetUserID, ev.TargetAuthKeyID, ev.ChatID, int16(ev.Type), ev.MaxDate, ev.Date).Scan(&id); err != nil { + return 0, fmt.Errorf("append state event: %w", err) + } + return id, nil +} + +func (s *EncryptedQueueStore) ListUndeliveredStateEvents(ctx context.Context, targetUserID, deviceAuthKeyID int64, limit int) ([]domain.EncryptedStateEvent, error) { + if limit <= 0 || limit > 1000 { + limit = 1000 + } + rows, err := s.db.Query(ctx, ` +SELECT e.id, e.target_user_id, e.target_auth_key_id, e.chat_id, e.event_type, e.max_date, e.date +FROM encrypted_state_events e +WHERE e.target_user_id = $1 + AND (e.target_auth_key_id = 0 OR e.target_auth_key_id = $2) + AND NOT EXISTS (SELECT 1 FROM encrypted_state_event_delivery d WHERE d.event_id = e.id AND d.auth_key_id = $2) +ORDER BY e.id ASC LIMIT $3`, targetUserID, deviceAuthKeyID, limit) + if err != nil { + return nil, fmt.Errorf("list undelivered state events: %w", err) + } + defer rows.Close() + var out []domain.EncryptedStateEvent + for rows.Next() { + var ev domain.EncryptedStateEvent + var typ int16 + if err := rows.Scan(&ev.ID, &ev.TargetUserID, &ev.TargetAuthKeyID, &ev.ChatID, &typ, &ev.MaxDate, &ev.Date); err != nil { + return nil, err + } + ev.Type = domain.EncryptedStateEventType(typ) + out = append(out, ev) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *EncryptedQueueStore) MarkStateEventsDelivered(ctx context.Context, deviceAuthKeyID int64, eventIDs []int64) error { + if len(eventIDs) == 0 { + return nil + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO encrypted_state_event_delivery (event_id, auth_key_id) +SELECT unnest($1::bigint[]), $2 +ON CONFLICT (event_id, auth_key_id) DO NOTHING`, eventIDs, deviceAuthKeyID); err != nil { + return fmt.Errorf("mark state events delivered: %w", err) + } + return nil +} + +func (s *EncryptedQueueStore) PutEncryptedFile(ctx context.Context, ownerUserID int64, ref domain.EncryptedFileRef) error { + if _, err := s.db.Exec(ctx, ` +INSERT INTO encrypted_files (id, access_hash, owner_user_id, size, dc_id, key_fingerprint) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (id) DO UPDATE SET access_hash = EXCLUDED.access_hash, size = EXCLUDED.size, + dc_id = EXCLUDED.dc_id, key_fingerprint = EXCLUDED.key_fingerprint`, + ref.ID, ref.AccessHash, ownerUserID, ref.Size, int32(ref.DCID), int32(ref.KeyFingerprint)); err != nil { + return fmt.Errorf("put encrypted file: %w", err) + } + return nil +} + +func (s *EncryptedQueueStore) GetEncryptedFile(ctx context.Context, id, accessHash int64) (domain.EncryptedFileRef, bool, error) { + var ( + ref domain.EncryptedFileRef + dc int32 + keyFP int32 + ) + err := s.db.QueryRow(ctx, + `SELECT id, access_hash, size, dc_id, key_fingerprint FROM encrypted_files WHERE id = $1 AND access_hash = $2`, + id, accessHash).Scan(&ref.ID, &ref.AccessHash, &ref.Size, &dc, &keyFP) + if errors.Is(err, pgx.ErrNoRows) { + return domain.EncryptedFileRef{}, false, nil + } + if err != nil { + return domain.EncryptedFileRef{}, false, fmt.Errorf("get encrypted file: %w", err) + } + ref.DCID = int(dc) + ref.KeyFingerprint = int(keyFP) + return ref, true, nil +} diff --git a/internal/store/postgres/encrypted_queue_integration_test.go b/internal/store/postgres/encrypted_queue_integration_test.go new file mode 100644 index 00000000..6baec68f --- /dev/null +++ b/internal/store/postgres/encrypted_queue_integration_test.go @@ -0,0 +1,187 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestEncryptedQueueStorePostgres 验证密聊 qts 投递队列 PG 实现:qts 单调分配(首值 1、 +// 无空洞)、幂等去重(同 random_id 返既有 qts/date)、按 qts seek 补差分、reserved/ +// confirmed 水位、ack 标记。门控于 TELESRV_TEST_POSTGRES_DSN。 +func TestEncryptedQueueStorePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + store := NewEncryptedQueueStore(pool) + + const device = int64(0x7700AA01) + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM encrypted_message_queue WHERE receiver_auth_key_id = $1`, device) + _, _ = pool.Exec(ctx, `DELETE FROM secret_qts_watermarks WHERE auth_key_id = $1`, device) + } + cleanup() + t.Cleanup(cleanup) + + mk := func(randomID int64, b byte) domain.SecretChatMessage { + return domain.SecretChatMessage{ + ReceiverAuthKeyID: device, + ReceiverUserID: 99, + ChatID: 7, + RandomID: randomID, + Date: int(1000 + randomID), + Bytes: []byte{b, b, b}, + } + } + + // qts 从 1 起单调分配。 + m1, existing, err := store.AppendEncryptedMessage(ctx, mk(101, 0x11)) + if err != nil || existing || m1.Qts != 1 { + t.Fatalf("append 1 = qts %d existing %v err %v, want qts=1 existing=false", m1.Qts, existing, err) + } + m2, _, err := store.AppendEncryptedMessage(ctx, mk(102, 0x22)) + if err != nil || m2.Qts != 2 { + t.Fatalf("append 2 = qts %d err %v, want 2", m2.Qts, err) + } + + // 幂等:同 random_id 返既有 qts/date,不重分配。 + dup, existing, err := store.AppendEncryptedMessage(ctx, mk(101, 0x99)) + if err != nil || !existing || dup.Qts != 1 || dup.Date != m1.Date { + t.Fatalf("dedup = qts %d date %d existing %v, want qts=1 date=%d existing=true", dup.Qts, dup.Date, existing, m1.Date) + } + if string(dup.Bytes) != string([]byte{0x11, 0x11, 0x11}) { + t.Fatalf("dedup must return first-stored bytes, got %x", dup.Bytes) + } + + // reserved qts = 2。 + if q, err := store.ReservedQts(ctx, device); err != nil || q != 2 { + t.Fatalf("reserved qts = %d err %v, want 2", q, err) + } + + // 补差分:since 0 → 2 条;since 1 → 1 条(qts=2)。 + msgs, err := store.ListEncryptedMessagesSince(ctx, device, 0, 0) + if err != nil || len(msgs) != 2 || msgs[0].Qts != 1 || msgs[1].Qts != 2 { + t.Fatalf("list since 0 = %+v err %v, want qts 1,2", msgs, err) + } + msgs, _ = store.ListEncryptedMessagesSince(ctx, device, 1, 0) + if len(msgs) != 1 || msgs[0].Qts != 2 { + t.Fatalf("list since 1 = %+v, want qts 2", msgs) + } + + // ack 到 1:confirmed 推进;回退 ack(0)幂等忽略。 + if err := store.AckEncryptedMessages(ctx, device, 1); err != nil { + t.Fatalf("ack: %v", err) + } + if err := store.AckEncryptedMessages(ctx, device, 0); err != nil { + t.Fatalf("ack rollback should be no-op: %v", err) + } + var confirmed int + if err := pool.QueryRow(ctx, `SELECT confirmed_qts FROM secret_qts_watermarks WHERE auth_key_id = $1`, device).Scan(&confirmed); err != nil { + t.Fatalf("read confirmed: %v", err) + } + if confirmed != 1 { + t.Fatalf("confirmed qts = %d, want 1", confirmed) + } + + // 未参与设备 reserved = 0。 + if q, err := store.ReservedQts(ctx, int64(0xDEADBEEF)); err != nil || q != 0 { + t.Fatalf("unrelated device reserved = %d err %v, want 0", q, err) + } +} + +// TestEncryptedStateEventsPostgres 验证密聊状态事件 PG 实现:账号级(target_auth_key_id=0) +// 对该 user 所有设备可见、设备级仅对绑定设备可见、未投递标记一次性投递。 +func TestEncryptedStateEventsPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + store := NewEncryptedQueueStore(pool) + + const ( + user = int64(0x7700BB01) + devA = int64(0x7700BB0A) + devB = int64(0x7700BB0B) + otherKy = int64(0x7700BB0C) + ) + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM encrypted_state_events WHERE target_user_id = $1`, user) + } + cleanup() + t.Cleanup(cleanup) + + // 账号级事件(target_auth_key_id=0)+ 设备级事件(绑定 devA)。 + accountEvID, err := store.AppendStateEvent(ctx, domain.EncryptedStateEvent{ + TargetUserID: user, TargetAuthKeyID: 0, ChatID: 11, Type: domain.EncryptedStateEventEncryption, Date: 1000, + }) + if err != nil { + t.Fatalf("append account event: %v", err) + } + devAEvID, err := store.AppendStateEvent(ctx, domain.EncryptedStateEvent{ + TargetUserID: user, TargetAuthKeyID: devA, ChatID: 12, Type: domain.EncryptedStateEventRead, MaxDate: 1500, Date: 1001, + }) + if err != nil { + t.Fatalf("append device event: %v", err) + } + + // devA 未投递:账号级 + 设备级 = 2 条。 + evs, err := store.ListUndeliveredStateEvents(ctx, user, devA, 0) + if err != nil || len(evs) != 2 { + t.Fatalf("devA undelivered = %d err %v, want 2", len(evs), err) + } + // devB 未投递:仅账号级 = 1 条(看不到 devA 的设备级事件)。 + evs, _ = store.ListUndeliveredStateEvents(ctx, user, devB, 0) + if len(evs) != 1 || evs[0].ID != accountEvID { + t.Fatalf("devB undelivered = %+v, want only account event %d", evs, accountEvID) + } + + // devA 标记两条已投递 → 再列为空;devB 仍能看到账号级(独立投递标记)。 + if err := store.MarkStateEventsDelivered(ctx, devA, []int64{accountEvID, devAEvID}); err != nil { + t.Fatalf("mark delivered: %v", err) + } + if evs, _ := store.ListUndeliveredStateEvents(ctx, user, devA, 0); len(evs) != 0 { + t.Fatalf("devA after deliver = %d, want 0", len(evs)) + } + if evs, _ := store.ListUndeliveredStateEvents(ctx, user, devB, 0); len(evs) != 1 { + t.Fatalf("devB after devA deliver = %d, want 1 (独立标记)", len(evs)) + } + // 幂等重复 mark 不报错。 + if err := store.MarkStateEventsDelivered(ctx, devA, []int64{accountEvID}); err != nil { + t.Fatalf("idempotent mark: %v", err) + } + // 无关设备(不同 user 维度):用同 user 但全新 key 仍看账号级。 + if evs, _ := store.ListUndeliveredStateEvents(ctx, user, otherKy, 0); len(evs) != 1 { + t.Fatalf("fresh device undelivered = %d, want 1 account-level", len(evs)) + } +} + +// TestEncryptedFilesPostgres 验证密聊文件元数据 PG 实现:put/get round-trip、access_hash +// 校验、不存在返回 found=false。门控于 TELESRV_TEST_POSTGRES_DSN。 +func TestEncryptedFilesPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + store := NewEncryptedQueueStore(pool) + + const fileID = int64(0x7700CC01) + cleanup := func() { _, _ = pool.Exec(ctx, `DELETE FROM encrypted_files WHERE id = $1`, fileID) } + cleanup() + t.Cleanup(cleanup) + + ref := domain.EncryptedFileRef{ID: fileID, AccessHash: 0xABCD, Size: 4096, DCID: 2, KeyFingerprint: 12345} + if err := store.PutEncryptedFile(ctx, 77, ref); err != nil { + t.Fatalf("put: %v", err) + } + got, found, err := store.GetEncryptedFile(ctx, fileID, 0xABCD) + if err != nil || !found { + t.Fatalf("get: found=%v err=%v", found, err) + } + if got != ref { + t.Fatalf("round-trip mismatch: %+v vs %+v", got, ref) + } + // 错 access_hash → 不命中。 + if _, found, _ := store.GetEncryptedFile(ctx, fileID, 0x9999); found { + t.Fatal("wrong access_hash must not match") + } + // 幂等覆盖。 + if err := store.PutEncryptedFile(ctx, 77, ref); err != nil { + t.Fatalf("idempotent put: %v", err) + } +} diff --git a/internal/store/postgres/groupcall.go b/internal/store/postgres/groupcall.go new file mode 100644 index 00000000..0f1f93e0 --- /dev/null +++ b/internal/store/postgres/groupcall.go @@ -0,0 +1,675 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// GroupCallStore 是 store.GroupCallStore 的 PostgreSQL 实现(迁移 0092)。 +// 所有参与者维度变更在事务内对 group_calls 行 FOR UPDATE 后 version+1, +// 行锁天然串行化并发变更,保证 version 单调无跳号。 +type GroupCallStore struct { + db sqlcgen.DBTX +} + +// NewGroupCallStore 基于 pgx 连接池创建 GroupCallStore。 +func NewGroupCallStore(db sqlcgen.DBTX) *GroupCallStore { + return &GroupCallStore{db: db} +} + +const groupCallColumns = `call_id, access_hash, channel_id, creator_user_id, state, title, join_muted, +version, participants_count, created_at, discarded_at, duration, started_msg_id` + +const groupCallParticipantColumns = `call_id, user_id, ssrc, join_date, active_date, muted, muted_by_admin, +volume_by_admin, raise_hand_rating, video_json, presentation_json, left_call, last_check_date` + +func scanGroupCall(row rowScanner) (domain.GroupCall, error) { + var c domain.GroupCall + var state string + if err := row.Scan( + &c.ID, &c.AccessHash, &c.ChannelID, &c.CreatorUserID, &state, &c.Title, &c.JoinMuted, + &c.Version, &c.ParticipantsCount, &c.CreatedAt, &c.DiscardedAt, &c.Duration, &c.StartedMsgID, + ); err != nil { + return domain.GroupCall{}, err + } + c.State = domain.GroupCallState(state) + return c, nil +} + +func scanGroupCallParticipant(row rowScanner) (domain.GroupCallParticipant, error) { + var p domain.GroupCallParticipant + if err := row.Scan( + &p.CallID, &p.UserID, &p.SSRC, &p.JoinDate, &p.ActiveDate, &p.Muted, &p.MutedByAdmin, + &p.VolumeByAdmin, &p.RaiseHandRating, &p.VideoJSON, &p.PresentationJSON, &p.Left, &p.LastCheckDate, + ); err != nil { + return domain.GroupCallParticipant{}, err + } + return p, nil +} + +func (s *GroupCallStore) begin(ctx context.Context, op string) (pgx.Tx, error) { + beginner, ok := s.db.(txBeginner) + if !ok { + return nil, fmt.Errorf("%s: db does not support transactions", op) + } + tx, err := beginner.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin %s: %w", op, err) + } + return tx, nil +} + +// lockGroupCallTx 锁定 call 行(串行化参与者变更的唯一入口)。 +func lockGroupCallTx(ctx context.Context, tx pgx.Tx, callID int64) (domain.GroupCall, error) { + call, err := scanGroupCall(tx.QueryRow(ctx, + `SELECT `+groupCallColumns+` FROM group_calls WHERE call_id = $1 FOR UPDATE`, callID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.GroupCall{}, domain.ErrGroupCallInvalid + } + if err != nil { + return domain.GroupCall{}, fmt.Errorf("lock group call: %w", err) + } + return call, nil +} + +func bumpGroupCallVersionTx(ctx context.Context, tx pgx.Tx, callID int64, countDelta int) (domain.GroupCall, error) { + call, err := scanGroupCall(tx.QueryRow(ctx, ` +UPDATE group_calls +SET version = version + 1, + participants_count = GREATEST(0, participants_count + $2) +WHERE call_id = $1 +RETURNING `+groupCallColumns, callID, countDelta)) + if err != nil { + return domain.GroupCall{}, fmt.Errorf("bump group call version: %w", err) + } + return call, nil +} + +func (s *GroupCallStore) CreateGroupCall(ctx context.Context, call domain.GroupCall) (domain.GroupCall, error) { + if call.ID == 0 || call.ChannelID == 0 || call.AccessHash == 0 { + return domain.GroupCall{}, domain.ErrGroupCallInvalid + } + if call.Version <= 0 { + call.Version = 1 + } + _, err := s.db.Exec(ctx, ` +INSERT INTO group_calls (call_id, access_hash, channel_id, creator_user_id, state, title, join_muted, version, participants_count, created_at) +VALUES ($1, $2, $3, $4, 'active', $5, $6, $7, 0, $8)`, + call.ID, call.AccessHash, call.ChannelID, call.CreatorUserID, call.Title, call.JoinMuted, call.Version, call.CreatedAt) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + if pgErr.ConstraintName == "group_calls_active_channel_uniq" { + return domain.GroupCall{}, domain.ErrGroupCallAlreadyStarted + } + return domain.GroupCall{}, domain.ErrGroupCallInvalid + } + return domain.GroupCall{}, fmt.Errorf("insert group call: %w", err) + } + call.State = domain.GroupCallStateActive + call.ParticipantsCount = 0 + return call, nil +} + +func (s *GroupCallStore) GetGroupCall(ctx context.Context, callID int64) (domain.GroupCall, bool, error) { + call, err := scanGroupCall(s.db.QueryRow(ctx, + `SELECT `+groupCallColumns+` FROM group_calls WHERE call_id = $1`, callID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.GroupCall{}, false, nil + } + if err != nil { + return domain.GroupCall{}, false, fmt.Errorf("get group call: %w", err) + } + return call, true, nil +} + +func (s *GroupCallStore) JoinGroupCall(ctx context.Context, req domain.JoinGroupCallRequest) (domain.GroupCallMutation, error) { + if req.SSRC == 0 { + return domain.GroupCallMutation{}, domain.ErrGroupCallInvalid + } + tx, err := s.begin(ctx, "join group call") + if err != nil { + return domain.GroupCallMutation{}, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + call, err := lockGroupCallTx(ctx, tx, req.CallID) + if err != nil { + return domain.GroupCallMutation{}, err + } + if !call.Active() { + return domain.GroupCallMutation{}, domain.ErrGroupCallDiscarded + } + existing, err := scanGroupCallParticipant(tx.QueryRow(ctx, + `SELECT `+groupCallParticipantColumns+` FROM group_call_participants WHERE call_id = $1 AND user_id = $2`, + req.CallID, req.UserID)) + wasActive := err == nil && !existing.Left + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return domain.GroupCallMutation{}, fmt.Errorf("load group call participant: %w", err) + } + p := domain.GroupCallParticipant{ + CallID: req.CallID, + UserID: req.UserID, + SSRC: req.SSRC, + JoinDate: req.Now, + ActiveDate: req.Now, + LastCheckDate: req.Now, + } + if wasActive { + // 同人换 ssrc 的 rejoin 保留原 join_date(列表排序稳定)。 + p.JoinDate = existing.JoinDate + } + if call.JoinMuted && !req.IsAdmin { + p.Muted = true + p.MutedByAdmin = true + } + p.VideoJSON = append([]byte(nil), req.VideoJSON...) + // video_json 整体替换、presentation_json 清空(rejoin 后客户端会重发 + // joinGroupCallPresentation,旧屏幕登记必须作废)。 + if _, err := tx.Exec(ctx, ` +INSERT INTO group_call_participants (call_id, user_id, ssrc, join_date, active_date, muted, muted_by_admin, volume_by_admin, raise_hand_rating, video_json, left_call, last_check_date) +VALUES ($1, $2, $3, $4, $5, $6, $7, 0, 0, $8, FALSE, $9) +ON CONFLICT (call_id, user_id) DO UPDATE SET + ssrc = EXCLUDED.ssrc, + join_date = EXCLUDED.join_date, + active_date = EXCLUDED.active_date, + muted = EXCLUDED.muted, + muted_by_admin = EXCLUDED.muted_by_admin, + volume_by_admin = 0, + raise_hand_rating = 0, + video_json = EXCLUDED.video_json, + presentation_json = NULL, + left_call = FALSE, + last_check_date = EXCLUDED.last_check_date`, + req.CallID, req.UserID, req.SSRC, p.JoinDate, p.ActiveDate, p.Muted, p.MutedByAdmin, nullableJSON(p.VideoJSON), p.LastCheckDate); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return domain.GroupCallMutation{}, domain.ErrGroupCallSSRCDuplicate + } + return domain.GroupCallMutation{}, fmt.Errorf("upsert group call participant: %w", err) + } + countDelta := 0 + if !wasActive { + countDelta = 1 + } + call, err = bumpGroupCallVersionTx(ctx, tx, req.CallID, countDelta) + if err != nil { + return domain.GroupCallMutation{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.GroupCallMutation{}, fmt.Errorf("commit join group call: %w", err) + } + committed = true + return domain.GroupCallMutation{Call: call, Participant: p}, nil +} + +func (s *GroupCallStore) LeaveGroupCall(ctx context.Context, callID, userID int64, now int) (domain.GroupCallMutation, error) { + tx, err := s.begin(ctx, "leave group call") + if err != nil { + return domain.GroupCallMutation{}, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if _, err := lockGroupCallTx(ctx, tx, callID); err != nil { + return domain.GroupCallMutation{}, err + } + p, err := scanGroupCallParticipant(tx.QueryRow(ctx, ` +UPDATE group_call_participants +SET left_call = TRUE, active_date = $3 +WHERE call_id = $1 AND user_id = $2 AND NOT left_call +RETURNING `+groupCallParticipantColumns, callID, userID, now)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.GroupCallMutation{}, domain.ErrGroupCallNotJoined + } + if err != nil { + return domain.GroupCallMutation{}, fmt.Errorf("leave group call participant: %w", err) + } + call, err := bumpGroupCallVersionTx(ctx, tx, callID, -1) + if err != nil { + return domain.GroupCallMutation{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.GroupCallMutation{}, fmt.Errorf("commit leave group call: %w", err) + } + committed = true + return domain.GroupCallMutation{Call: call, Participant: p}, nil +} + +func (s *GroupCallStore) DiscardGroupCall(ctx context.Context, callID int64, now int) (domain.GroupCall, []domain.GroupCallParticipant, error) { + tx, err := s.begin(ctx, "discard group call") + if err != nil { + return domain.GroupCall{}, nil, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + call, err := lockGroupCallTx(ctx, tx, callID) + if err != nil { + return domain.GroupCall{}, nil, err + } + if !call.Active() { + return domain.GroupCall{}, nil, domain.ErrGroupCallDiscarded + } + rows, err := tx.Query(ctx, ` +UPDATE group_call_participants +SET left_call = TRUE, active_date = $2 +WHERE call_id = $1 AND NOT left_call +RETURNING `+groupCallParticipantColumns, callID, now) + if err != nil { + return domain.GroupCall{}, nil, fmt.Errorf("clear group call participants: %w", err) + } + var active []domain.GroupCallParticipant + for rows.Next() { + p, err := scanGroupCallParticipant(rows) + if err != nil { + rows.Close() + return domain.GroupCall{}, nil, err + } + active = append(active, p) + } + rows.Close() + if err := rows.Err(); err != nil { + return domain.GroupCall{}, nil, err + } + call, err = scanGroupCall(tx.QueryRow(ctx, ` +UPDATE group_calls +SET state = 'discarded', discarded_at = $2, duration = GREATEST(0, $2 - created_at), + participants_count = 0, version = version + 1 +WHERE call_id = $1 +RETURNING `+groupCallColumns, callID, now)) + if err != nil { + return domain.GroupCall{}, nil, fmt.Errorf("discard group call: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.GroupCall{}, nil, fmt.Errorf("commit discard group call: %w", err) + } + committed = true + return call, active, nil +} + +func (s *GroupCallStore) TouchParticipant(ctx context.Context, callID, userID int64, now int) ([]int64, bool, error) { + call, found, err := s.GetGroupCall(ctx, callID) + if err != nil { + return nil, false, err + } + if !found { + return nil, false, domain.ErrGroupCallInvalid + } + if !call.Active() { + return nil, false, nil + } + var ssrc int64 + err = s.db.QueryRow(ctx, ` +UPDATE group_call_participants +SET last_check_date = $3, active_date = $3 +WHERE call_id = $1 AND user_id = $2 AND NOT left_call +RETURNING ssrc`, callID, userID, now).Scan(&ssrc) + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("touch group call participant: %w", err) + } + return []int64{ssrc}, true, nil +} + +func (s *GroupCallStore) GetParticipant(ctx context.Context, callID, userID int64) (domain.GroupCallParticipant, bool, error) { + p, err := scanGroupCallParticipant(s.db.QueryRow(ctx, + `SELECT `+groupCallParticipantColumns+` FROM group_call_participants WHERE call_id = $1 AND user_id = $2`, + callID, userID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.GroupCallParticipant{}, false, nil + } + if err != nil { + return domain.GroupCallParticipant{}, false, fmt.Errorf("get group call participant: %w", err) + } + return p, true, nil +} + +func (s *GroupCallStore) ListParticipants(ctx context.Context, callID int64, offset string, limit int) (domain.GroupCallParticipantPage, error) { + if limit <= 0 || limit > 200 { + limit = 200 + } + call, found, err := s.GetGroupCall(ctx, callID) + if err != nil { + return domain.GroupCallParticipantPage{}, err + } + if !found { + return domain.GroupCallParticipantPage{}, domain.ErrGroupCallInvalid + } + page := domain.GroupCallParticipantPage{Version: call.Version} + if err := s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM group_call_participants WHERE call_id = $1 AND NOT left_call`, callID, + ).Scan(&page.Count); err != nil { + return domain.GroupCallParticipantPage{}, fmt.Errorf("count group call participants: %w", err) + } + offDate, offUser, hasOffset := parseGroupCallOffset(offset) + query := `SELECT ` + groupCallParticipantColumns + ` FROM group_call_participants +WHERE call_id = $1 AND NOT left_call` + args := []any{callID} + if hasOffset { + query += ` AND (join_date, user_id) > ($2, $3)` + args = append(args, offDate, offUser) + } + query += fmt.Sprintf(` ORDER BY join_date ASC, user_id ASC LIMIT %d`, limit) + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return domain.GroupCallParticipantPage{}, fmt.Errorf("list group call participants: %w", err) + } + defer rows.Close() + for rows.Next() { + p, err := scanGroupCallParticipant(rows) + if err != nil { + return domain.GroupCallParticipantPage{}, err + } + page.Participants = append(page.Participants, p) + } + if err := rows.Err(); err != nil { + return domain.GroupCallParticipantPage{}, err + } + if n := len(page.Participants); n == limit && n < page.Count { + last := page.Participants[n-1] + page.NextOffset = formatGroupCallOffset(last.JoinDate, last.UserID) + } + return page, nil +} + +func (s *GroupCallStore) UpdateParticipant(ctx context.Context, callID, userID int64, update domain.GroupCallParticipantUpdate) (domain.GroupCallMutation, bool, error) { + tx, err := s.begin(ctx, "update group call participant") + if err != nil { + return domain.GroupCallMutation{}, false, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + call, err := lockGroupCallTx(ctx, tx, callID) + if err != nil { + return domain.GroupCallMutation{}, false, err + } + if !call.Active() { + return domain.GroupCallMutation{}, false, domain.ErrGroupCallDiscarded + } + p, err := scanGroupCallParticipant(tx.QueryRow(ctx, + `SELECT `+groupCallParticipantColumns+` FROM group_call_participants WHERE call_id = $1 AND user_id = $2`, + callID, userID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.GroupCallMutation{}, false, domain.ErrGroupCallNotJoined + } + if err != nil { + return domain.GroupCallMutation{}, false, fmt.Errorf("load group call participant: %w", err) + } + if p.Left { + return domain.GroupCallMutation{}, false, domain.ErrGroupCallNotJoined + } + changed := applyGroupCallParticipantUpdateRow(&p, update) + if !changed { + return domain.GroupCallMutation{Call: call, Participant: p}, false, nil + } + if update.Now > 0 { + p.ActiveDate = update.Now + } + if _, err := tx.Exec(ctx, ` +UPDATE group_call_participants +SET muted = $3, muted_by_admin = $4, volume_by_admin = $5, raise_hand_rating = $6, + video_json = $7, presentation_json = $8, active_date = $9 +WHERE call_id = $1 AND user_id = $2`, + callID, userID, p.Muted, p.MutedByAdmin, p.VolumeByAdmin, p.RaiseHandRating, + nullableJSON(p.VideoJSON), nullableJSON(p.PresentationJSON), p.ActiveDate); err != nil { + return domain.GroupCallMutation{}, false, fmt.Errorf("update group call participant: %w", err) + } + call, err = bumpGroupCallVersionTx(ctx, tx, callID, 0) + if err != nil { + return domain.GroupCallMutation{}, false, err + } + if err := tx.Commit(ctx); err != nil { + return domain.GroupCallMutation{}, false, fmt.Errorf("commit update group call participant: %w", err) + } + committed = true + return domain.GroupCallMutation{Call: call, Participant: p}, true, nil +} + +func (s *GroupCallStore) SetGroupCallTitle(ctx context.Context, callID int64, title string) (domain.GroupCall, bool, error) { + call, err := scanGroupCall(s.db.QueryRow(ctx, ` +UPDATE group_calls SET title = $2 WHERE call_id = $1 RETURNING `+groupCallColumns, callID, title)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.GroupCall{}, false, domain.ErrGroupCallInvalid + } + if err != nil { + return domain.GroupCall{}, false, fmt.Errorf("set group call title: %w", err) + } + return call, true, nil +} + +func (s *GroupCallStore) SetGroupCallJoinMuted(ctx context.Context, callID int64, joinMuted bool) (domain.GroupCall, bool, error) { + call, err := scanGroupCall(s.db.QueryRow(ctx, ` +UPDATE group_calls SET join_muted = $2 WHERE call_id = $1 RETURNING `+groupCallColumns, callID, joinMuted)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.GroupCall{}, false, domain.ErrGroupCallInvalid + } + if err != nil { + return domain.GroupCall{}, false, fmt.Errorf("set group call join muted: %w", err) + } + return call, true, nil +} + +func (s *GroupCallStore) SetStartedMessageID(ctx context.Context, callID int64, msgID int) error { + if _, err := s.db.Exec(ctx, + `UPDATE group_calls SET started_msg_id = $2 WHERE call_id = $1`, callID, msgID); err != nil { + return fmt.Errorf("set group call started msg: %w", err) + } + return nil +} + +func (s *GroupCallStore) SweepStaleParticipants(ctx context.Context, checkOlderThan, now, limit int) ([]domain.GroupCallMutation, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.db.Query(ctx, ` +SELECT p.call_id, p.user_id +FROM group_call_participants p +JOIN group_calls c ON c.call_id = p.call_id AND c.state = 'active' +WHERE NOT p.left_call AND p.last_check_date < $1 +ORDER BY p.call_id, p.user_id +LIMIT $2`, checkOlderThan, limit) + if err != nil { + return nil, fmt.Errorf("list stale group call participants: %w", err) + } + type key struct{ callID, userID int64 } + var stale []key + for rows.Next() { + var k key + if err := rows.Scan(&k.callID, &k.userID); err != nil { + rows.Close() + return nil, err + } + stale = append(stale, k) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + var out []domain.GroupCallMutation + for _, k := range stale { + mut, err := s.LeaveGroupCall(ctx, k.callID, k.userID, now) + if errors.Is(err, domain.ErrGroupCallNotJoined) || errors.Is(err, domain.ErrGroupCallInvalid) { + continue // 并发 leave/discard 竞态,无害 + } + if err != nil { + return out, err + } + out = append(out, mut) + } + return out, nil +} + +func (s *GroupCallStore) ResetAllParticipants(ctx context.Context, now int) ([]domain.GroupCall, error) { + rows, err := s.db.Query(ctx, ` +SELECT DISTINCT c.call_id +FROM group_calls c +JOIN group_call_participants p ON p.call_id = c.call_id AND NOT p.left_call +WHERE c.state = 'active'`) + if err != nil { + return nil, fmt.Errorf("list active group calls: %w", err) + } + var callIDs []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + callIDs = append(callIDs, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + var out []domain.GroupCall + for _, callID := range callIDs { + tx, err := s.begin(ctx, "reset group call participants") + if err != nil { + return out, err + } + if _, err := lockGroupCallTx(ctx, tx, callID); err != nil { + _ = tx.Rollback(ctx) + continue + } + if _, err := tx.Exec(ctx, ` +UPDATE group_call_participants SET left_call = TRUE, active_date = $2 +WHERE call_id = $1 AND NOT left_call`, callID, now); err != nil { + _ = tx.Rollback(ctx) + return out, fmt.Errorf("reset group call participants: %w", err) + } + call, err := scanGroupCall(tx.QueryRow(ctx, ` +UPDATE group_calls SET participants_count = 0, version = version + 1 +WHERE call_id = $1 RETURNING `+groupCallColumns, callID)) + if err != nil { + _ = tx.Rollback(ctx) + return out, fmt.Errorf("reset group call version: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return out, fmt.Errorf("commit reset group call: %w", err) + } + out = append(out, call) + } + return out, nil +} + +// applyGroupCallParticipantUpdateRow 与 memory 实现共享同一字段语义(contract test 钉死)。 +func applyGroupCallParticipantUpdateRow(p *domain.GroupCallParticipant, u domain.GroupCallParticipantUpdate) bool { + changed := false + if u.Muted != nil && p.Muted != *u.Muted { + p.Muted = *u.Muted + changed = true + } + if u.MutedByAdmin != nil && p.MutedByAdmin != *u.MutedByAdmin { + p.MutedByAdmin = *u.MutedByAdmin + changed = true + } + if u.VolumeByAdmin != nil && p.VolumeByAdmin != *u.VolumeByAdmin { + p.VolumeByAdmin = *u.VolumeByAdmin + changed = true + } + if u.RaiseHandRating != nil && p.RaiseHandRating != *u.RaiseHandRating { + p.RaiseHandRating = *u.RaiseHandRating + changed = true + } + if u.VideoJSON != nil && string(p.VideoJSON) != string(*u.VideoJSON) { + p.VideoJSON = append([]byte(nil), *u.VideoJSON...) + changed = true + } + if u.PresentationJSON != nil && string(p.PresentationJSON) != string(*u.PresentationJSON) { + p.PresentationJSON = append([]byte(nil), *u.PresentationJSON...) + changed = true + } + return changed +} + +func nullableJSON(b []byte) any { + if len(b) == 0 { + return nil + } + return b +} + +func parseGroupCallOffset(offset string) (joinDate int, userID int64, ok bool) { + if offset == "" { + return 0, 0, false + } + var d int + var u int64 + if _, err := fmt.Sscanf(offset, "j:%d:%d", &d, &u); err != nil { + return 0, 0, false + } + return d, u, true +} + +func formatGroupCallOffset(joinDate int, userID int64) string { + return fmt.Sprintf("j:%d:%d", joinDate, userID) +} + +func (s *GroupCallStore) NextRaiseHandRating(ctx context.Context, callID int64) (int64, error) { + // 全局单调序号用 sequence;group_calls.version 同步推进保证跨进程单调。 + var rating int64 + if err := s.db.QueryRow(ctx, ` +UPDATE group_calls SET version = version + 1 WHERE call_id = $1 RETURNING version`, callID).Scan(&rating); err != nil { + return 0, fmt.Errorf("next raise hand rating: %w", err) + } + return rating, nil +} + +func (s *GroupCallStore) SetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64, override domain.GroupCallParticipantOverride, clear bool) error { + if clear { + if _, err := s.db.Exec(ctx, + `DELETE FROM group_call_participant_overrides WHERE call_id = $1 AND setter_user_id = $2 AND target_user_id = $3`, + callID, setterUserID, targetUserID); err != nil { + return fmt.Errorf("clear participant override: %w", err) + } + return nil + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO group_call_participant_overrides (call_id, setter_user_id, target_user_id, muted_by_you, volume) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (call_id, setter_user_id, target_user_id) DO UPDATE SET + muted_by_you = EXCLUDED.muted_by_you, + volume = EXCLUDED.volume`, + callID, setterUserID, targetUserID, override.MutedByYou, override.Volume); err != nil { + return fmt.Errorf("upsert participant override: %w", err) + } + return nil +} + +func (s *GroupCallStore) GetParticipantOverride(ctx context.Context, callID, setterUserID, targetUserID int64) (domain.GroupCallParticipantOverride, bool, error) { + var ov domain.GroupCallParticipantOverride + err := s.db.QueryRow(ctx, + `SELECT muted_by_you, volume FROM group_call_participant_overrides WHERE call_id = $1 AND setter_user_id = $2 AND target_user_id = $3`, + callID, setterUserID, targetUserID).Scan(&ov.MutedByYou, &ov.Volume) + if errors.Is(err, pgx.ErrNoRows) { + return domain.GroupCallParticipantOverride{}, false, nil + } + if err != nil { + return domain.GroupCallParticipantOverride{}, false, fmt.Errorf("get participant override: %w", err) + } + return ov, true, nil +} diff --git a/internal/store/postgres/groupcall_integration_test.go b/internal/store/postgres/groupcall_integration_test.go new file mode 100644 index 00000000..113275da --- /dev/null +++ b/internal/store/postgres/groupcall_integration_test.go @@ -0,0 +1,88 @@ +package postgres + +import ( + "context" + "sync" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/storetest" +) + +// TestGroupCallStoreContractPostgres 与 memory 实现共跑同一契约(TELESRV_TEST_POSTGRES_DSN 门控)。 +func TestGroupCallStoreContractPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + var nextChannel int64 = 910_000_000 + storetest.RunGroupCallStoreContract(t, func(t *testing.T) (store.GroupCallStore, int64) { + nextChannel++ + channelID := nextChannel + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM group_calls WHERE channel_id = $1", channelID) + }) + return NewGroupCallStore(pool), channelID + }) +} + +// TestGroupCallStoreM2ContractPostgres 跑 overrides/raise-hand 契约(DSN 门控)。 +func TestGroupCallStoreM2ContractPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + var nextChannel int64 = 920_000_000 + storetest.RunGroupCallStoreM2Contract(t, func(t *testing.T) (store.GroupCallStore, int64) { + nextChannel++ + channelID := nextChannel + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM group_calls WHERE channel_id = $1", channelID) + }) + return NewGroupCallStore(pool), channelID + }) +} + +// TestGroupCallStoreConcurrentJoinVersions 验证行锁串行化下 version 无跳号。 +func TestGroupCallStoreConcurrentJoinVersions(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + const channelID = 910_900_001 + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM group_calls WHERE channel_id = $1", channelID) + }) + st := NewGroupCallStore(pool) + // 时间基准取运行时刻:测试库可能与运行中的开发服务器共库,远古 + // last_check_date 会被线上 sweeper 当幽灵清掉,污染 version/count 断言。 + now := int(time.Now().Unix()) + call, err := st.CreateGroupCall(ctx, domain.GroupCall{ID: 910_900_100, AccessHash: 5, ChannelID: channelID, CreatorUserID: 1, CreatedAt: now}) + if err != nil { + t.Fatalf("create: %v", err) + } + const joiners = 8 + var wg sync.WaitGroup + versions := make(chan int, joiners) + for i := int64(0); i < joiners; i++ { + wg.Add(1) + go func(userID int64) { + defer wg.Done() + mut, err := st.JoinGroupCall(ctx, domain.JoinGroupCallRequest{CallID: call.ID, UserID: userID, SSRC: 7000 + userID, Now: now}) + if err != nil { + t.Errorf("join %d: %v", userID, err) + return + } + versions <- mut.Call.Version + }(100 + i) + } + wg.Wait() + close(versions) + seen := map[int]bool{} + for v := range versions { + if seen[v] { + t.Fatalf("duplicate version %d under concurrency", v) + } + seen[v] = true + } + final, _, err := st.GetGroupCall(ctx, call.ID) + if err != nil || final.Version != 1+joiners || final.ParticipantsCount != joiners { + t.Fatalf("final call = %+v err=%v", final, err) + } +} diff --git a/internal/store/postgres/help.go b/internal/store/postgres/help.go index 6074676a..45842f8d 100644 --- a/internal/store/postgres/help.go +++ b/internal/store/postgres/help.go @@ -93,11 +93,21 @@ func (s *HelpStore) UpsertCountries(ctx context.Context, countries []domain.Coun return fmt.Errorf("upsert country %q: %w", country.ISO2, err) } for j, code := range country.CountryCodes { + // prefixes/patterns 列 NOT NULL:无前缀/格式的国家(如 AD)catalog 里为 nil, + // 须规整为空数组,否则 pgx 写 NULL 触发 23502。 + prefixes := code.Prefixes + if prefixes == nil { + prefixes = []string{} + } + patterns := code.Patterns + if patterns == nil { + patterns = []string{} + } if err := s.q.UpsertCountryCode(ctx, sqlcgen.UpsertCountryCodeParams{ Iso2: country.ISO2, CountryCode: code.CountryCode, - Prefixes: code.Prefixes, - Patterns: code.Patterns, + Prefixes: prefixes, + Patterns: patterns, OrderIndex: int32(j + 1), }); err != nil { return fmt.Errorf("upsert country code %q/%q: %w", country.ISO2, code.CountryCode, err) diff --git a/internal/store/postgres/history_ttl.go b/internal/store/postgres/history_ttl.go new file mode 100644 index 00000000..e2090164 --- /dev/null +++ b/internal/store/postgres/history_ttl.go @@ -0,0 +1,159 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "telesrv/internal/domain" +) + +func (s *MessageStore) GetPrivateHistoryTTL(ctx context.Context, ownerUserID int64, peer domain.Peer) (int, error) { + if ownerUserID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return 0, fmt.Errorf("private history ttl: invalid peer") + } + return privateHistoryTTLPeriod(ctx, s.db, ownerUserID, peer.ID) +} + +func (s *MessageStore) SetPrivateHistoryTTL(ctx context.Context, ownerUserID int64, peer domain.Peer, period int) error { + if ownerUserID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return fmt.Errorf("set private history ttl: invalid peer") + } + if period < 0 { + return fmt.Errorf("set private history ttl: invalid period") + } + return withTx(ctx, s.db, "set private history ttl", func(tx pgx.Tx) error { + if err := lockUsersForUpdate(ctx, tx, ownerUserID, peer.ID); err != nil { + return fmt.Errorf("lock private ttl users: %w", err) + } + if err := upsertPrivateDialogTTL(ctx, tx, ownerUserID, peer.ID, period); err != nil { + return err + } + if peer.ID != ownerUserID { + if err := upsertPrivateDialogTTL(ctx, tx, peer.ID, ownerUserID, period); err != nil { + return err + } + } + return nil + }) +} + +func (s *MessageStore) DefaultHistoryTTL(ctx context.Context, userID int64) (int, error) { + if userID == 0 { + return 0, nil + } + var period int + err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1`, userID).Scan(&period) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("get default history ttl: %w", err) + } + if period < 0 { + return 0, nil + } + return period, nil +} + +func (s *MessageStore) SetDefaultHistoryTTL(ctx context.Context, userID int64, period int) error { + if userID == 0 { + return nil + } + if period < 0 { + return fmt.Errorf("set default history ttl: invalid period") + } + tag, err := s.db.Exec(ctx, ` +UPDATE users +SET default_history_ttl_period = $2, + updated_at = now() +WHERE id = $1 +`, userID, period) + if err != nil { + return fmt.Errorf("set default history ttl: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("set default history ttl: user not found") + } + return nil +} + +func (s *MessageStore) ClaimExpiredPrivateMessages(ctx context.Context, now, limit int) ([]domain.DeleteMessagesRequest, error) { + if now <= 0 || limit <= 0 { + return nil, nil + } + if limit > domain.MaxDeleteHistoryBatch { + limit = domain.MaxDeleteHistoryBatch + } + rows, err := s.db.Query(ctx, ` +WITH due AS ( + SELECT DISTINCT ON (m.message_sender_id, m.private_message_id) + m.owner_user_id, + m.peer_id, + m.box_id, + m.expires_at + FROM message_boxes m + WHERE m.expires_at > 0 + AND m.expires_at <= $1 + AND NOT m.deleted + ORDER BY m.message_sender_id, m.private_message_id, m.expires_at ASC, m.owner_user_id ASC + LIMIT $2 +) +SELECT owner_user_id, peer_id, box_id +FROM due +ORDER BY owner_user_id ASC, peer_id ASC, box_id ASC +`, now, limit) + if err != nil { + return nil, fmt.Errorf("claim expired private messages: %w", err) + } + defer rows.Close() + type key struct { + owner int64 + peer int64 + } + index := make(map[key]int) + out := make([]domain.DeleteMessagesRequest, 0) + for rows.Next() { + var owner, peer int64 + var id int + if err := rows.Scan(&owner, &peer, &id); err != nil { + return nil, fmt.Errorf("scan expired private message: %w", err) + } + k := key{owner: owner, peer: peer} + pos, ok := index[k] + if !ok { + pos = len(out) + index[k] = pos + out = append(out, domain.DeleteMessagesRequest{ + OwnerUserID: owner, + IDs: make([]int, 0, 8), + Revoke: true, + Date: now, + }) + } + out[pos].IDs = append(out[pos].IDs, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate expired private messages: %w", err) + } + return out, nil +} + +func upsertPrivateDialogTTL(ctx context.Context, db interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) +}, ownerUserID, peerUserID int64, period int) error { + _, err := db.Exec(ctx, ` +INSERT INTO dialogs (user_id, peer_type, peer_id, ttl_period) +VALUES ($1, 'user', $2, $3) +ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET + ttl_period = EXCLUDED.ttl_period, + updated_at = now() +`, ownerUserID, peerUserID, period) + if err != nil { + return fmt.Errorf("upsert private dialog ttl: %w", err) + } + return nil +} diff --git a/internal/store/postgres/media.go b/internal/store/postgres/media.go index f9868cbf..e17355b3 100644 --- a/internal/store/postgres/media.go +++ b/internal/store/postgres/media.go @@ -1,10 +1,15 @@ package postgres import ( + "container/list" "context" "errors" + "fmt" + "sync" + "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "telesrv/internal/domain" "telesrv/internal/store" @@ -13,13 +18,18 @@ import ( // MediaStore 用 PostgreSQL 实现 store.MediaStore(媒体元数据 + blob 索引)。 type MediaStore struct { - db sqlcgen.DBTX - q *sqlcgen.Queries + db sqlcgen.DBTX + q *sqlcgen.Queries + documents *documentMetaCache } // NewMediaStore 基于 pgx 连接池(或事务)创建 MediaStore。 func NewMediaStore(db sqlcgen.DBTX) *MediaStore { - return &MediaStore{db: db, q: sqlcgen.New(db)} + return &MediaStore{ + db: db, + q: sqlcgen.New(db), + documents: newDocumentMetaCache(documentMetaCacheCapacity), + } } // bytesOrEmpty 把 nil []byte 归一为空切片,避免落入 NOT NULL bytea 列时被当作 NULL。 @@ -35,16 +45,61 @@ var _ store.MediaStore = (*MediaStore)(nil) // ---- 上传分片 ---- func (s *MediaStore) SaveFilePart(ctx context.Context, part domain.UploadPart) error { + backend := string(part.Backend) + if backend == "" { + backend = string(domain.MediaBackendLocalFS) + } + sha := part.SHA256 + if sha == nil { + sha = []byte{} + } return s.q.SaveUploadPart(ctx, sqlcgen.SaveUploadPartParams{ OwnerUserID: part.OwnerUserID, FileID: part.FileID, Part: int32(part.Part), TotalParts: int32(part.TotalParts), IsBig: part.Big, - Bytes: part.Bytes, + Backend: backend, + ObjectKey: part.ObjectKey, + Size: part.Size, + Sha256: sha, }) } +func (s *MediaStore) UploadPartUsage(ctx context.Context, ownerUserID int64) (domain.UploadPartUsage, error) { + row, err := s.q.GetUploadPartUsage(ctx, ownerUserID) + if err != nil { + return domain.UploadPartUsage{}, err + } + return domain.UploadPartUsage{ + Bytes: row.Bytes, + Parts: int(row.Parts), + Files: int(row.Files), + }, nil +} + +func (s *MediaStore) UploadPartSlot(ctx context.Context, ownerUserID, fileID int64, part int) (domain.UploadPartSlot, error) { + row, err := s.q.GetUploadPartSlot(ctx, sqlcgen.GetUploadPartSlotParams{ + OwnerUserID: ownerUserID, + FileID: fileID, + Part: int32(part), + }) + if err != nil { + return domain.UploadPartSlot{}, err + } + found := row.ExistingBytes >= 0 + existingBytes := row.ExistingBytes + if !found { + existingBytes = 0 + } + return domain.UploadPartSlot{ + ExistingBytes: existingBytes, + ObjectKey: row.ObjectKey, + FileParts: int(row.FileParts), + Found: found, + }, nil +} + func (s *MediaStore) LoadFileParts(ctx context.Context, ownerUserID, fileID int64) ([]domain.UploadPart, error) { rows, err := s.q.ListUploadParts(ctx, sqlcgen.ListUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID}) if err != nil { @@ -58,16 +113,29 @@ func (s *MediaStore) LoadFileParts(ctx context.Context, ownerUserID, fileID int6 Part: int(r.Part), TotalParts: int(r.TotalParts), Big: r.IsBig, - Bytes: r.Bytes, + Backend: domain.MediaBackend(r.Backend), + ObjectKey: r.ObjectKey, + Size: r.Size, + SHA256: r.Sha256, }) } return out, nil } -func (s *MediaStore) DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) error { +func (s *MediaStore) DeleteFileParts(ctx context.Context, ownerUserID, fileID int64) ([]string, error) { return s.q.DeleteUploadParts(ctx, sqlcgen.DeleteUploadPartsParams{OwnerUserID: ownerUserID, FileID: fileID}) } +func (s *MediaStore) DeleteExpiredUploadParts(ctx context.Context, before time.Time, limit int) ([]string, error) { + if limit <= 0 { + return nil, nil + } + return s.q.DeleteExpiredUploadParts(ctx, sqlcgen.DeleteExpiredUploadPartsParams{ + Before: pgtype.Timestamptz{Time: before, Valid: true}, + BatchLimit: int32(limit), + }) +} + // ---- blob 索引 ---- func (s *MediaStore) PutFileBlob(ctx context.Context, blob domain.FileBlob) error { @@ -107,6 +175,65 @@ func (s *MediaStore) GetFileBlob(ctx context.Context, locationKey string) (domai }, true, nil } +// GetFileBlobs 一发 ANY 查询批量取多个 location_key 的 blob 元数据,替代逐个 GetFileBlob 往返 +// (启动预热曾对 ~2400 个 blob 各打一次 PG,是启动期 N+1)。缺失的 key 不在返回 map 中。 +func (s *MediaStore) GetFileBlobs(ctx context.Context, locationKeys []string) (map[string]domain.FileBlob, error) { + if len(locationKeys) == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT location_key, backend, object_key, size, sha256, mime_type +FROM file_blobs +WHERE location_key = ANY($1::text[])`, locationKeys) + if err != nil { + return nil, fmt.Errorf("get file blobs: %w", err) + } + defer rows.Close() + out := make(map[string]domain.FileBlob, len(locationKeys)) + for rows.Next() { + var ( + blob domain.FileBlob + backend string + ) + if err := rows.Scan(&blob.LocationKey, &backend, &blob.ObjectKey, &blob.Size, &blob.SHA256, &blob.MimeType); err != nil { + return nil, fmt.Errorf("scan file blob: %w", err) + } + blob.Backend = domain.MediaBackend(backend) + out[blob.LocationKey] = blob + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (s *MediaStore) GetSeedState(ctx context.Context, key string) (string, bool, error) { + var hash string + if err := s.db.QueryRow(ctx, ` +SELECT content_hash +FROM seed_states +WHERE key = $1`, key).Scan(&hash); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + return "", false, fmt.Errorf("get seed state: %w", err) + } + return hash, true, nil +} + +func (s *MediaStore) PutSeedState(ctx context.Context, key, hash string) error { + _, err := s.db.Exec(ctx, ` +INSERT INTO seed_states (key, content_hash) +VALUES ($1, $2) +ON CONFLICT (key) DO UPDATE SET + content_hash = EXCLUDED.content_hash, + updated_at = now()`, key, hash) + if err != nil { + return fmt.Errorf("put seed state: %w", err) + } + return nil +} + // ---- 文档 ---- func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error { @@ -118,7 +245,7 @@ func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error if err != nil { return err } - return s.q.PutDocument(ctx, sqlcgen.PutDocumentParams{ + if err := s.q.PutDocument(ctx, sqlcgen.PutDocumentParams{ ID: doc.ID, AccessHash: doc.AccessHash, FileReference: bytesOrEmpty(doc.FileReference), @@ -128,10 +255,17 @@ func (s *MediaStore) PutDocument(ctx context.Context, doc domain.Document) error DcID: int32(doc.DCID), AttributesJson: attrs, ThumbsJson: thumbs, - }) + }); err != nil { + return err + } + s.documents.put(doc.ID, doc) + return nil } func (s *MediaStore) GetDocument(ctx context.Context, id int64) (domain.Document, bool, error) { + if doc, ok := s.documents.get(id); ok { + return doc, true, nil + } row, err := s.q.GetDocument(ctx, id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -143,6 +277,7 @@ func (s *MediaStore) GetDocument(ctx context.Context, id int64) (domain.Document if err != nil { return domain.Document{}, false, err } + s.documents.put(doc.ID, doc) return doc, true, nil } @@ -150,21 +285,143 @@ func (s *MediaStore) GetDocuments(ctx context.Context, ids []int64) ([]domain.Do if len(ids) == 0 { return nil, nil } - rows, err := s.q.GetDocuments(ctx, ids) - if err != nil { - return nil, err + + unique := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + found := make(map[int64]domain.Document, len(ids)) + var missing []int64 + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + if doc, ok := s.documents.get(id); ok { + found[id] = doc + continue + } + missing = append(missing, id) } - out := make([]domain.Document, 0, len(rows)) - for _, r := range rows { - doc, err := documentFromRow(sqlcgen.GetDocumentRow(r)) + if len(unique) == 0 { + return nil, nil + } + + if len(missing) > 0 { + rows, err := s.q.GetDocuments(ctx, missing) if err != nil { return nil, err } - out = append(out, doc) + for _, r := range rows { + doc, err := documentFromRow(sqlcgen.GetDocumentRow(r)) + if err != nil { + return nil, err + } + s.documents.put(doc.ID, doc) + found[doc.ID] = cloneDocument(doc) + } + } + + out := make([]domain.Document, 0, len(found)) + for _, id := range unique { + doc, ok := found[id] + if !ok { + continue + } + out = append(out, cloneDocument(doc)) } return out, nil } +const documentMetaCacheCapacity = 1 << 16 + +// documentMetaCache keeps immutable document metadata hot for message/media +// hydration. PutDocument refreshes entries synchronously, so same-process reads +// observe newly generated access_hash/file_reference without waiting for TTL. +type documentMetaCache struct { + mu sync.Mutex + cap int + ll *list.List + m map[int64]*list.Element +} + +type documentMetaEntry struct { + id int64 + doc domain.Document +} + +func newDocumentMetaCache(capacity int) *documentMetaCache { + if capacity <= 0 { + capacity = 1 + } + return &documentMetaCache{ + cap: capacity, + ll: list.New(), + m: make(map[int64]*list.Element, capacity), + } +} + +func (c *documentMetaCache) get(id int64) (domain.Document, bool) { + if c == nil || id == 0 { + return domain.Document{}, false + } + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.m[id] + if !ok { + return domain.Document{}, false + } + c.ll.MoveToFront(el) + return cloneDocument(el.Value.(*documentMetaEntry).doc), true +} + +func (c *documentMetaCache) put(id int64, doc domain.Document) { + if c == nil || id == 0 { + return + } + copied := cloneDocument(doc) + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.m[id]; ok { + el.Value.(*documentMetaEntry).doc = copied + c.ll.MoveToFront(el) + return + } + c.m[id] = c.ll.PushFront(&documentMetaEntry{id: id, doc: copied}) + if c.ll.Len() > c.cap { + oldest := c.ll.Back() + if oldest != nil { + c.ll.Remove(oldest) + delete(c.m, oldest.Value.(*documentMetaEntry).id) + } + } +} + +func cloneDocument(doc domain.Document) domain.Document { + doc.FileReference = append([]byte(nil), doc.FileReference...) + if len(doc.Attributes) > 0 { + attrs := make([]domain.DocumentAttribute, len(doc.Attributes)) + copy(attrs, doc.Attributes) + for i := range attrs { + attrs[i].Waveform = append([]byte(nil), attrs[i].Waveform...) + } + doc.Attributes = attrs + } + if len(doc.Thumbs) > 0 { + thumbs := make([]domain.PhotoSize, len(doc.Thumbs)) + copy(thumbs, doc.Thumbs) + for i := range thumbs { + thumbs[i].Bytes = append([]byte(nil), thumbs[i].Bytes...) + thumbs[i].Sizes = append([]int(nil), thumbs[i].Sizes...) + thumbs[i].BackgroundColors = append([]int(nil), thumbs[i].BackgroundColors...) + } + doc.Thumbs = thumbs + } + return doc +} + func documentFromRow(row sqlcgen.GetDocumentRow) (domain.Document, error) { attrs, err := decodeDocumentAttributes(row.AttributesJson) if err != nil { @@ -213,19 +470,47 @@ func (s *MediaStore) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool } return domain.Photo{}, false, err } - sizes, err := decodePhotoSizes(row.SizesJson) + photo, err := photoFromFields(row.ID, row.AccessHash, row.FileReference, int(row.Date), int(row.DcID), row.HasStickers, row.SizesJson) if err != nil { return domain.Photo{}, false, err } + return photo, true, nil +} + +type photoScanner interface { + Scan(dest ...any) error +} + +func scanPhotoRow(row photoScanner) (domain.Photo, error) { + var ( + id int64 + accessHash int64 + fileReference []byte + date int32 + dcID int32 + hasStickers bool + sizesJSON string + ) + if err := row.Scan(&id, &accessHash, &fileReference, &date, &dcID, &hasStickers, &sizesJSON); err != nil { + return domain.Photo{}, err + } + return photoFromFields(id, accessHash, fileReference, int(date), int(dcID), hasStickers, sizesJSON) +} + +func photoFromFields(id, accessHash int64, fileReference []byte, date, dcID int, hasStickers bool, sizesJSON string) (domain.Photo, error) { + sizes, err := decodePhotoSizes(sizesJSON) + if err != nil { + return domain.Photo{}, err + } return domain.Photo{ - ID: row.ID, - AccessHash: row.AccessHash, - FileReference: row.FileReference, - Date: int(row.Date), - DCID: int(row.DcID), - HasStickers: row.HasStickers, + ID: id, + AccessHash: accessHash, + FileReference: fileReference, + Date: date, + DCID: dcID, + HasStickers: hasStickers, Sizes: sizes, - }, true, nil + }, nil } // ---- 贴纸集 ---- @@ -419,10 +704,6 @@ func (s *MediaStore) CountAvailableReactions(ctx context.Context) (int, error) { // ---- 头像历史 ---- -func (s *MediaStore) AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error { - return s.AddProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoID, date) -} - func (s *MediaStore) AddProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error { kind = normalizeProfilePhotoKind(kind) next, err := s.nextProfilePhotoOrder(ctx, ownerType, ownerID, kind) @@ -440,10 +721,6 @@ ON CONFLICT (owner_peer_type, owner_peer_id, kind, photo_id) DO UPDATE SET return err } -func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) { - return s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile) -} - func (s *MediaStore) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) { kind = normalizeProfilePhotoKind(kind) row := s.db.QueryRow(ctx, ` @@ -510,6 +787,7 @@ ORDER BY pp.owner_peer_id, pp.sort_order DESC PhotoID: photoID, DCID: int(dcID), Stripped: domain.StrippedFromSizes(sizes), + HasVideo: domain.PhotoHasVideo(sizes), } } if err := rows.Err(); err != nil { @@ -518,10 +796,6 @@ ORDER BY pp.owner_peer_id, pp.sort_order DESC return out, nil } -func (s *MediaStore) ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]int64, int, error) { - return s.ListProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, offset, limit, maxID) -} - func (s *MediaStore) ListProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]int64, int, error) { kind = normalizeProfilePhotoKind(kind) rows, err := s.db.Query(ctx, ` @@ -566,6 +840,74 @@ WHERE owner_peer_type = $1 return ids, total, nil } +func (s *MediaStore) ListProfilePhotoDetailsKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) { + kind = normalizeProfilePhotoKind(kind) + if limit <= 0 { + return nil, 0, nil + } + var rows pgx.Rows + var err error + if offset < 0 && maxID > 0 { + rows, err = s.db.Query(ctx, ` +SELECT ph.id, ph.access_hash, ph.file_reference, ph.date, ph.dc_id, ph.has_stickers, ph.sizes::text AS sizes_json +FROM profile_photos pp +JOIN photos ph ON ph.id = pp.photo_id +WHERE pp.owner_peer_type = $1 + AND pp.owner_peer_id = $2 + AND pp.kind = $3 + AND pp.active + AND pp.photo_id = $4 +ORDER BY pp.sort_order DESC +LIMIT $5 +`, string(ownerType), ownerID, string(kind), maxID, limit) + } else { + if offset < 0 { + offset = 0 + } + rows, err = s.db.Query(ctx, ` +SELECT ph.id, ph.access_hash, ph.file_reference, ph.date, ph.dc_id, ph.has_stickers, ph.sizes::text AS sizes_json +FROM profile_photos pp +JOIN photos ph ON ph.id = pp.photo_id +WHERE pp.owner_peer_type = $1 + AND pp.owner_peer_id = $2 + AND pp.kind = $3 + AND pp.active + AND ($4::bigint <= 0 OR pp.photo_id < $4::bigint) +ORDER BY pp.sort_order DESC +OFFSET $5 +LIMIT $6 +`, string(ownerType), ownerID, string(kind), maxID, offset, limit) + } + if err != nil { + return nil, 0, err + } + defer rows.Close() + photos := make([]domain.Photo, 0, limit) + for rows.Next() { + photo, err := scanPhotoRow(rows) + if err != nil { + return nil, 0, err + } + photos = append(photos, photo) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + var total int + err = s.db.QueryRow(ctx, ` +SELECT count(*)::int +FROM profile_photos +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND kind = $3 + AND active +`, string(ownerType), ownerID, string(kind)).Scan(&total) + if err != nil { + return nil, 0, err + } + return photos, total, nil +} + func (s *MediaStore) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) { return s.DeleteProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoIDs) } diff --git a/internal/store/postgres/media_codec.go b/internal/store/postgres/media_codec.go index 28353c0c..27f0f7b4 100644 --- a/internal/store/postgres/media_codec.go +++ b/internal/store/postgres/media_codec.go @@ -43,6 +43,56 @@ func decodeMessageMedia(s string) (*domain.MessageMedia, error) { return &m, nil } +// encodeReplyMarkup 把 inline keyboard 快照序列化为 JSONB;空 markup 序列化为 "{}"。 +// callback data 是 []byte,json.Marshal 自动 base64(保证经 JSONB 字节级 round-trip)。 +func encodeReplyMarkup(m *domain.MessageReplyMarkup) ([]byte, error) { + if m.IsZero() { + return []byte("{}"), nil + } + return json.Marshal(m) +} + +// decodeReplyMarkup 把消息行的 reply_markup JSONB 文本还原为 *MessageReplyMarkup; +// 空载荷返回 nil。 +func decodeReplyMarkup(s string) (*domain.MessageReplyMarkup, error) { + if s == "" || s == "{}" || s == "null" { + return nil, nil + } + var m domain.MessageReplyMarkup + if err := json.Unmarshal([]byte(s), &m); err != nil { + return nil, err + } + if m.IsZero() { + return nil, nil + } + return &m, nil +} + +// encodeRichMessage 把富文本消息快照序列化为 JSONB;空载荷序列化为 "{}"。 +// Blocks 是 []byte(不透明 TL),json.Marshal 自动 base64,保证 JSONB 字节级 round-trip。 +func encodeRichMessage(m *domain.MessageRichMessage) ([]byte, error) { + if m.IsZero() { + return []byte("{}"), nil + } + return json.Marshal(m) +} + +// decodeRichMessage 把消息行的 rich_message JSONB 文本还原为 *MessageRichMessage; +// 空载荷返回 nil。 +func decodeRichMessage(s string) (*domain.MessageRichMessage, error) { + if s == "" || s == "{}" || s == "null" { + return nil, nil + } + var m domain.MessageRichMessage + if err := json.Unmarshal([]byte(s), &m); err != nil { + return nil, err + } + if m.IsZero() { + return nil, nil + } + return &m, nil +} + func decodePhotoSizes(s string) ([]domain.PhotoSize, error) { if s == "" || s == "[]" || s == "null" { return nil, nil diff --git a/internal/store/postgres/media_index.go b/internal/store/postgres/media_index.go new file mode 100644 index 00000000..8f3c0ea3 --- /dev/null +++ b/internal/store/postgres/media_index.go @@ -0,0 +1,71 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +// 共享媒体索引维护(迁移 0118)。分类真值唯一来自 domain.ClassifyMediaCategories; +// 写路径只在「创建」和「编辑改媒体」两处维护,删除靠读查询 JOIN 过滤 deleted、不在此维护。 + +// insertChannelMediaIndexTx 为一条频道消息按其媒体类别写索引行(无类别则 no-op)。 +func insertChannelMediaIndexTx(ctx context.Context, tx pgx.Tx, channelID int64, id, date int, media *domain.MessageMedia, entities []domain.MessageEntity) error { + for _, c := range domain.ClassifyMediaCategories(media, entities) { + if _, err := tx.Exec(ctx, ` +INSERT INTO channel_message_media (channel_id, id, category, message_date) +VALUES ($1,$2,$3,$4) +ON CONFLICT (channel_id, id, category) DO NOTHING`, channelID, id, int16(c), date); err != nil { + return fmt.Errorf("insert channel media index: %w", err) + } + } + return nil +} + +// deleteChannelMediaIndexTx 清掉一条频道消息的全部索引行(编辑改媒体前先清后插)。 +func deleteChannelMediaIndexTx(ctx context.Context, tx pgx.Tx, channelID int64, id int) error { + if _, err := tx.Exec(ctx, `DELETE FROM channel_message_media WHERE channel_id = $1 AND id = $2`, channelID, id); err != nil { + return fmt.Errorf("delete channel media index: %w", err) + } + return nil +} + +// replaceChannelMediaIndexTx 在编辑替换媒体后重建索引行(类别可能变化)。 +func replaceChannelMediaIndexTx(ctx context.Context, tx pgx.Tx, channelID int64, id, date int, media *domain.MessageMedia, entities []domain.MessageEntity) error { + if err := deleteChannelMediaIndexTx(ctx, tx, channelID, id); err != nil { + return err + } + return insertChannelMediaIndexTx(ctx, tx, channelID, id, date, media, entities) +} + +// insertMessageBoxMediaIndexTx 为一条私聊 owner box 按其媒体类别写索引行。 +func insertMessageBoxMediaIndexTx(ctx context.Context, tx pgx.Tx, ownerUserID, peerID int64, boxID, date int, media *domain.MessageMedia, entities []domain.MessageEntity) error { + for _, c := range domain.ClassifyMediaCategories(media, entities) { + if _, err := tx.Exec(ctx, ` +INSERT INTO message_box_media (owner_user_id, box_id, peer_id, category, message_date) +VALUES ($1,$2,$3,$4,$5) +ON CONFLICT (owner_user_id, box_id, category) DO NOTHING`, ownerUserID, boxID, peerID, int16(c), date); err != nil { + return fmt.Errorf("insert message box media index: %w", err) + } + } + return nil +} + +// deleteMessageBoxMediaIndexTx 清掉一条私聊 owner box 的全部索引行。 +func deleteMessageBoxMediaIndexTx(ctx context.Context, tx pgx.Tx, ownerUserID int64, boxID int) error { + if _, err := tx.Exec(ctx, `DELETE FROM message_box_media WHERE owner_user_id = $1 AND box_id = $2`, ownerUserID, boxID); err != nil { + return fmt.Errorf("delete message box media index: %w", err) + } + return nil +} + +// replaceMessageBoxMediaIndexTx 在编辑替换媒体后重建索引行。 +func replaceMessageBoxMediaIndexTx(ctx context.Context, tx pgx.Tx, ownerUserID, peerID int64, boxID, date int, media *domain.MessageMedia, entities []domain.MessageEntity) error { + if err := deleteMessageBoxMediaIndexTx(ctx, tx, ownerUserID, boxID); err != nil { + return err + } + return insertMessageBoxMediaIndexTx(ctx, tx, ownerUserID, peerID, boxID, date, media, entities) +} diff --git a/internal/store/postgres/media_index_integration_test.go b/internal/store/postgres/media_index_integration_test.go new file mode 100644 index 00000000..1d9834f6 --- /dev/null +++ b/internal/store/postgres/media_index_integration_test.go @@ -0,0 +1,212 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestChannelMediaIndexSearch 端到端验证频道共享媒体索引(迁移 0118):发送各类媒体消息 → +// 写路径建索引 → SearchChannelMedia 按标签页类别返回正确消息。门控于 TELESRV_TEST_POSTGRES_DSN。 +func TestChannelMediaIndexSearch(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 771, Phone: "+1890" + suffix + "01", FirstName: "MediaOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + channels := NewChannelStore(pool) + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, Title: "Media " + suffix, Megagroup: true, Date: 1700002000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + send := func(rnd int64, msg string, media *domain.MessageMedia, ents []domain.MessageEntity) int { + res, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, ChannelID: channelID, RandomID: rnd, Message: msg, Media: media, Entities: ents, Date: 1700002000 + int(rnd), + }) + if err != nil { + t.Fatalf("send channel message %d: %v", rnd, err) + } + return res.Message.ID + } + docMedia := func(attrs ...domain.DocumentAttribute) *domain.MessageMedia { + return &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &domain.Document{ID: 1, AccessHash: 1, Attributes: attrs}} + } + + photoID := send(1, "pic", &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 11, AccessHash: 1}}, nil) + fileID := send(2, "doc", docMedia(domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: "a.pdf"}), nil) + videoID := send(3, "vid", docMedia(domain.DocumentAttribute{Kind: domain.DocAttrVideo, W: 1, H: 1}), nil) + urlID := send(4, "see https://x.test", nil, []domain.MessageEntity{{Type: domain.MessageEntityURL, Offset: 4, Length: 14}}) + musicID := send(6, "song", docMedia(domain.DocumentAttribute{Kind: domain.DocAttrAudio, Title: "song"}), nil) + _ = send(5, "plain text only", nil, nil) // 不进任何标签页 + + search := func(cats ...domain.MediaCategory) domain.ChannelHistory { + h, err := channels.SearchChannelMedia(ctx, owner.ID, channelID, domain.MediaSearchRequest{Categories: cats, Limit: 50}) + if err != nil { + t.Fatalf("search media %v: %v", cats, err) + } + return h + } + wantIDs := func(name string, h domain.ChannelHistory, ids ...int) { + if h.Count != len(ids) { + t.Fatalf("%s: count = %d, want %d", name, h.Count, len(ids)) + } + if len(h.Messages) != len(ids) { + t.Fatalf("%s: got %d messages, want %d", name, len(h.Messages), len(ids)) + } + for i, id := range ids { // newest-first + if h.Messages[i].ID != id { + t.Fatalf("%s: message[%d].ID = %d, want %d (order %v)", name, i, h.Messages[i].ID, id, ids) + } + } + } + wantCount := func(name string, counts domain.MediaCategoryCounts, category domain.MediaCategory, want int) { + if got := counts[category]; got != want { + t.Fatalf("%s: count[%d] = %d, want %d", name, category, got, want) + } + } + + wantIDs("photos", search(domain.MediaCategoryPhoto), photoID) + wantIDs("files", search(domain.MediaCategoryFile), fileID) + wantIDs("video", search(domain.MediaCategoryVideo), videoID) + wantIDs("url", search(domain.MediaCategoryURL), urlID) + wantIDs("music", search(domain.MediaCategoryMusic), musicID) + wantIDs("photoVideo", search(domain.MediaCategoryPhoto, domain.MediaCategoryVideo), videoID, photoID) // newest-first + wantIDs("voice empty", search(domain.MediaCategoryVoice)) + countOnly, err := channels.SearchChannelMedia(ctx, owner.ID, channelID, domain.MediaSearchRequest{ + Categories: []domain.MediaCategory{domain.MediaCategoryPhoto}, + Limit: 0, + }) + if err != nil { + t.Fatalf("search count-only media: %v", err) + } + if countOnly.Count != 1 || len(countOnly.Messages) != 0 { + t.Fatalf("count-only media = count %d messages %d, want count 1 and no messages", countOnly.Count, len(countOnly.Messages)) + } + counts, err := channels.CountChannelMediaCategories(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("count channel media categories: %v", err) + } + wantCount("initial", counts, domain.MediaCategoryPhoto, 1) + wantCount("initial", counts, domain.MediaCategoryFile, 1) + wantCount("initial", counts, domain.MediaCategoryVideo, 1) + wantCount("initial", counts, domain.MediaCategoryMusic, 1) + wantCount("initial", counts, domain.MediaCategoryURL, 1) + wantCount("initial", counts, domain.MediaCategoryVoice, 0) + + // 编辑把视频消息换成语音 → 索引类别应随之迁移(video 标签页不再含它,voice 含它)。 + if _, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: owner.ID, ChannelID: channelID, ID: videoID, Message: "now voice", EditDate: 1700002999, + Media: docMedia(domain.DocumentAttribute{Kind: domain.DocAttrAudio, Voice: true}), + }); err != nil { + t.Fatalf("edit channel message media: %v", err) + } + wantIDs("video after edit empty", search(domain.MediaCategoryVideo)) + wantIDs("voice after edit", search(domain.MediaCategoryVoice), videoID) + counts, err = channels.CountChannelMediaCategories(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("count channel media categories after edit: %v", err) + } + wantCount("after edit", counts, domain.MediaCategoryVideo, 0) + wantCount("after edit", counts, domain.MediaCategoryVoice, 1) + wantCount("after edit", counts, domain.MediaCategoryMusic, 1) + + if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ + UserID: owner.ID, ChannelID: channelID, IDs: []int{videoID}, Date: 1700003001, + }); err != nil { + t.Fatalf("delete channel media message: %v", err) + } + counts, err = channels.CountChannelMediaCategories(ctx, owner.ID, channelID) + if err != nil { + t.Fatalf("count channel media categories after delete: %v", err) + } + wantCount("after delete", counts, domain.MediaCategoryVideo, 0) + wantCount("after delete", counts, domain.MediaCategoryVoice, 0) + wantCount("after delete", counts, domain.MediaCategoryMusic, 1) +} + +func TestPrivateMediaCategoryCountsMaterialized(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + alice, err := users.Create(ctx, domain.User{AccessHash: 772, Phone: "+1890" + suffix + "11", FirstName: "MediaAlice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := users.Create(ctx, domain.User{AccessHash: 773, Phone: "+1890" + suffix + "12", FirstName: "MediaBob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + ids := []int64{alice.ID, bob.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) + docMedia := func(attrs ...domain.DocumentAttribute) *domain.MessageMedia { + return &domain.MessageMedia{Kind: domain.MessageMediaKindDocument, Document: &domain.Document{ID: 2, AccessHash: 2, Attributes: attrs}} + } + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, RecipientUserID: bob.ID, RandomID: 991, + Message: "doc", Media: docMedia(domain.DocumentAttribute{Kind: domain.DocAttrFilename, FileName: "a.pdf"}), Date: 1700003100, + }) + if err != nil { + t.Fatalf("send private media: %v", err) + } + wantCount := func(name string, ownerID, peerID int64, category domain.MediaCategory, want int) { + counts, err := messages.CountPrivateMediaCategories(ctx, ownerID, peerID) + if err != nil { + t.Fatalf("%s count private media categories: %v", name, err) + } + if got := counts[category]; got != want { + t.Fatalf("%s count[%d] = %d, want %d", name, category, got, want) + } + } + wantCount("alice initial", alice.ID, bob.ID, domain.MediaCategoryFile, 1) + wantCount("bob initial", bob.ID, alice.ID, domain.MediaCategoryFile, 1) + + if _, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: alice.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + ID: sent.SenderMessage.ID, + Message: "voice", + Media: docMedia(domain.DocumentAttribute{Kind: domain.DocAttrAudio, Voice: true}), + EditDate: 1700003101, + }); err != nil { + t.Fatalf("edit private media: %v", err) + } + wantCount("alice file after edit", alice.ID, bob.ID, domain.MediaCategoryFile, 0) + wantCount("alice voice after edit", alice.ID, bob.ID, domain.MediaCategoryVoice, 1) + wantCount("bob file after edit", bob.ID, alice.ID, domain.MediaCategoryFile, 0) + wantCount("bob voice after edit", bob.ID, alice.ID, domain.MediaCategoryVoice, 1) + + if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ + OwnerUserID: alice.ID, IDs: []int{sent.SenderMessage.ID}, Revoke: true, Date: 1700003102, + }); err != nil { + t.Fatalf("delete private media: %v", err) + } + wantCount("alice voice after delete", alice.ID, bob.ID, domain.MediaCategoryVoice, 0) + wantCount("bob voice after delete", bob.ID, alice.ID, domain.MediaCategoryVoice, 0) +} diff --git a/internal/store/postgres/media_integration_test.go b/internal/store/postgres/media_integration_test.go index 3331062d..717fda41 100644 --- a/internal/store/postgres/media_integration_test.go +++ b/internal/store/postgres/media_integration_test.go @@ -1,8 +1,10 @@ package postgres import ( + "bytes" "context" "testing" + "time" "github.com/jackc/pgx/v5/pgxpool" @@ -142,10 +144,10 @@ func TestMediaStoreRoundTrip(t *testing.T) { } // ---- profile photo 历史 ---- - if err := s.AddProfilePhoto(ctx, domain.PeerTypeUser, ownerID, photoID, 1700000000); err != nil { + if err := s.AddProfilePhotoKind(ctx, domain.PeerTypeUser, ownerID, domain.ProfilePhotoKindProfile, photoID, 1700000000); err != nil { t.Fatalf("add profile photo: %v", err) } - cur, ok, err := s.CurrentProfilePhoto(ctx, domain.PeerTypeUser, ownerID) + cur, ok, err := s.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, ownerID, domain.ProfilePhotoKindProfile) if err != nil || !ok || cur != photoID { t.Fatalf("current profile photo = (%d,%v,%v)", cur, ok, err) } @@ -153,7 +155,7 @@ func TestMediaStoreRoundTrip(t *testing.T) { if err != nil || refs[ownerID].PhotoID != photoID || refs[ownerID].DCID != 2 { t.Fatalf("current profile photos batch = %+v err=%v", refs, err) } - ids, total, err := s.ListProfilePhotos(ctx, domain.PeerTypeUser, ownerID, 0, 10, 0) + ids, total, err := s.ListProfilePhotosKind(ctx, domain.PeerTypeUser, ownerID, domain.ProfilePhotoKindProfile, 0, 10, 0) if err != nil || total < 1 || len(ids) < 1 { t.Fatalf("list profile photos: ids=%v total=%d err=%v", ids, total, err) } @@ -161,26 +163,228 @@ func TestMediaStoreRoundTrip(t *testing.T) { if err != nil || len(deleted) != 1 { t.Fatalf("delete profile photos: deleted=%v err=%v", deleted, err) } - if _, ok, _ := s.CurrentProfilePhoto(ctx, domain.PeerTypeUser, ownerID); ok { + if _, ok, _ := s.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, ownerID, domain.ProfilePhotoKindProfile); ok { t.Fatal("profile photo still current after delete") } // ---- upload parts ---- - if err := s.SaveFilePart(ctx, domain.UploadPart{OwnerUserID: ownerID, FileID: 555, Part: 0, Bytes: []byte("hello")}); err != nil { + if err := s.SaveFilePart(ctx, domain.UploadPart{OwnerUserID: ownerID, FileID: 555, Part: 0, Backend: domain.MediaBackendLocalFS, ObjectKey: "upload_parts/test/555/0-a.part", Size: 5, SHA256: []byte("hello")}); err != nil { t.Fatalf("save file part: %v", err) } + if err := s.SaveFilePart(ctx, domain.UploadPart{OwnerUserID: ownerID, FileID: 555, Part: 0, Backend: domain.MediaBackendLocalFS, ObjectKey: "upload_parts/test/555/0-b.part", Size: 6, SHA256: []byte("hello!")}); err != nil { + t.Fatalf("retry file part: %v", err) + } parts, err := s.LoadFileParts(ctx, ownerID, 555) - if err != nil || len(parts) != 1 || string(parts[0].Bytes) != "hello" { + if err != nil || len(parts) != 1 || parts[0].ObjectKey != "upload_parts/test/555/0-b.part" || parts[0].Size != 6 { t.Fatalf("load file parts: parts=%+v err=%v", parts, err) } - if err := s.DeleteFileParts(ctx, ownerID, 555); err != nil { + usage, err := s.UploadPartUsage(ctx, ownerID) + if err != nil { + t.Fatalf("upload part usage: %v", err) + } + if usage.Bytes != 6 || usage.Parts != 1 || usage.Files != 1 { + t.Fatalf("upload part usage = %+v", usage) + } + slot, err := s.UploadPartSlot(ctx, ownerID, 555, 0) + if err != nil { + t.Fatalf("upload part slot: %v", err) + } + if !slot.Found || slot.ExistingBytes != 6 || slot.ObjectKey != "upload_parts/test/555/0-b.part" || slot.FileParts != 1 { + t.Fatalf("upload part slot = %+v", slot) + } + if err := s.SaveFilePart(ctx, domain.UploadPart{OwnerUserID: ownerID, FileID: 556, Part: 0, Backend: domain.MediaBackendLocalFS, ObjectKey: "upload_parts/test/556/0.part", Size: 3, SHA256: []byte("old")}); err != nil { + t.Fatalf("save old file part: %v", err) + } + if _, err := pool.Exec(ctx, "UPDATE upload_parts SET created_at = now() - interval '48 hours' WHERE owner_user_id = $1 AND file_id = 556", ownerID); err != nil { + t.Fatalf("age upload part: %v", err) + } + uploadDeleted, err := s.DeleteExpiredUploadParts(ctx, time.Now().Add(-24*time.Hour), 10) + if err != nil { + t.Fatalf("delete expired upload parts: %v", err) + } + if len(uploadDeleted) != 1 || uploadDeleted[0] != "upload_parts/test/556/0.part" { + t.Fatalf("delete expired upload parts deleted = %+v, want object key", uploadDeleted) + } + if stale, _ := s.LoadFileParts(ctx, ownerID, 556); len(stale) != 0 { + t.Fatalf("expired file parts still present: %+v", stale) + } + deletedPartKeys, err := s.DeleteFileParts(ctx, ownerID, 555) + if err != nil { t.Fatalf("delete file parts: %v", err) } + if len(deletedPartKeys) != 1 || deletedPartKeys[0] != "upload_parts/test/555/0-b.part" { + t.Fatalf("delete file parts keys = %+v", deletedPartKeys) + } if parts, _ := s.LoadFileParts(ctx, ownerID, 555); len(parts) != 0 { t.Fatal("file parts not cleared") } } +func TestMediaStoreDocumentCacheCopiesAndRefreshes(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + s := NewMediaStore(pool) + + const docID = int64(9100000000000000101) + if _, err := pool.Exec(ctx, `DELETE FROM documents WHERE id = $1`, docID); err != nil { + t.Fatalf("cleanup document: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM documents WHERE id = $1`, docID) + }) + + doc := domain.Document{ + ID: docID, + AccessHash: 101, + FileReference: []byte{1, 2, 3}, + Date: 123, + MimeType: "application/octet-stream", + Size: 10, + DCID: 2, + Attributes: []domain.DocumentAttribute{{ + Kind: domain.DocAttrAudio, + Waveform: []byte{4, 5, 6}, + }}, + Thumbs: []domain.PhotoSize{{ + Kind: domain.PhotoSizeKindCached, + Type: "m", + Bytes: []byte{7, 8, 9}, + Sizes: []int{1, 2, 3}, + }}, + } + if err := s.PutDocument(ctx, doc); err != nil { + t.Fatalf("put document: %v", err) + } + + doc.FileReference[0] = 99 + doc.Attributes[0].Waveform[0] = 99 + doc.Thumbs[0].Bytes[0] = 99 + doc.Thumbs[0].Sizes[0] = 99 + + got, ok, err := s.GetDocument(ctx, docID) + if err != nil || !ok { + t.Fatalf("get document: ok=%v err=%v", ok, err) + } + if !bytes.Equal(got.FileReference, []byte{1, 2, 3}) || + !bytes.Equal(got.Attributes[0].Waveform, []byte{4, 5, 6}) || + !bytes.Equal(got.Thumbs[0].Bytes, []byte{7, 8, 9}) || + got.Thumbs[0].Sizes[0] != 1 { + t.Fatalf("cached document was mutated: %+v", got) + } + + got.FileReference[0] = 42 + got.Attributes[0].Waveform[0] = 42 + got.Thumbs[0].Bytes[0] = 42 + got.Thumbs[0].Sizes[0] = 42 + again, ok, err := s.GetDocument(ctx, docID) + if err != nil || !ok { + t.Fatalf("get document again: ok=%v err=%v", ok, err) + } + if !bytes.Equal(again.FileReference, []byte{1, 2, 3}) || + !bytes.Equal(again.Attributes[0].Waveform, []byte{4, 5, 6}) || + !bytes.Equal(again.Thumbs[0].Bytes, []byte{7, 8, 9}) || + again.Thumbs[0].Sizes[0] != 1 { + t.Fatalf("cache returned shared slices: %+v", again) + } + + docs, err := s.GetDocuments(ctx, []int64{0, docID, docID, docID + 1}) + if err != nil { + t.Fatalf("get documents: %v", err) + } + if len(docs) != 1 || docs[0].ID != docID { + t.Fatalf("get documents = %+v, want one cached document", docs) + } + + updated := again + updated.AccessHash = 202 + updated.FileReference = []byte{10, 11, 12} + updated.Attributes[0].Waveform = []byte{13, 14, 15} + if err := s.PutDocument(ctx, updated); err != nil { + t.Fatalf("put updated document: %v", err) + } + refreshed, ok, err := s.GetDocument(ctx, docID) + if err != nil || !ok { + t.Fatalf("get refreshed document: ok=%v err=%v", ok, err) + } + if refreshed.AccessHash != 202 || + !bytes.Equal(refreshed.FileReference, []byte{10, 11, 12}) || + !bytes.Equal(refreshed.Attributes[0].Waveform, []byte{13, 14, 15}) { + t.Fatalf("document cache did not refresh after PutDocument: %+v", refreshed) + } +} + +func TestMediaStoreListProfilePhotoDetailsBatchesPhotosAndRefreshesMaxID(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + s := NewMediaStore(pool) + + const ownerID = int64(9100000000000000199) + photoIDs := []int64{9100000000000000201, 9100000000000000202, 9100000000000000203, 9100000000000000204} + cleanupProfilePhotoDetailsRows(t, ctx, pool, ownerID, photoIDs) + t.Cleanup(func() { + cleanupProfilePhotoDetailsRows(t, context.Background(), pool, ownerID, photoIDs) + }) + + for i, id := range photoIDs { + photo := domain.Photo{ + ID: id, + AccessHash: int64(700 + i), + FileReference: []byte{byte(i + 1)}, + Date: 1700000100 + i, + DCID: 2, + Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 100 + i, H: 100 + i, Size: 1000 + i}}, + } + if err := s.PutPhoto(ctx, photo); err != nil { + t.Fatalf("put photo %d: %v", id, err) + } + } + if err := s.AddProfilePhotoKind(ctx, domain.PeerTypeUser, ownerID, domain.ProfilePhotoKindProfile, photoIDs[0], 1700000101); err != nil { + t.Fatalf("add first profile photo: %v", err) + } + if err := s.AddProfilePhotoKind(ctx, domain.PeerTypeUser, ownerID, domain.ProfilePhotoKindProfile, photoIDs[1], 1700000102); err != nil { + t.Fatalf("add second profile photo: %v", err) + } + if err := s.AddProfilePhotoKind(ctx, domain.PeerTypeUser, ownerID, domain.ProfilePhotoKindProfile, photoIDs[2], 1700000103); err != nil { + t.Fatalf("add third profile photo: %v", err) + } + if err := s.AddProfilePhotoKind(ctx, domain.PeerTypeUser, ownerID, domain.ProfilePhotoKindFallback, photoIDs[3], 1700000104); err != nil { + t.Fatalf("add fallback photo: %v", err) + } + + photos, total, err := s.ListProfilePhotoDetailsKind(ctx, domain.PeerTypeUser, ownerID, domain.ProfilePhotoKindProfile, 0, 2, 0) + if err != nil { + t.Fatalf("list profile photo details: %v", err) + } + if total != 3 { + t.Fatalf("total = %d, want 3 profile photos only", total) + } + if gotIDs := photoIDsFromDomain(photos); len(gotIDs) != 2 || gotIDs[0] != photoIDs[2] || gotIDs[1] != photoIDs[1] { + t.Fatalf("first page ids = %v, want newest profile photos [%d %d]", gotIDs, photoIDs[2], photoIDs[1]) + } + if len(photos[0].Sizes) != 1 || photos[0].Sizes[0].W != 102 { + t.Fatalf("joined photo sizes not decoded: %+v", photos[0]) + } + + refreshed, total, err := s.ListProfilePhotoDetailsKind(ctx, domain.PeerTypeUser, ownerID, domain.ProfilePhotoKindProfile, -1, 1, photoIDs[1]) + if err != nil { + t.Fatalf("refresh profile photo by max_id: %v", err) + } + if total != 3 { + t.Fatalf("refresh total = %d, want 3", total) + } + if gotIDs := photoIDsFromDomain(refreshed); len(gotIDs) != 1 || gotIDs[0] != photoIDs[1] { + t.Fatalf("refresh ids = %v, want exact max_id %d", gotIDs, photoIDs[1]) + } +} + +func photoIDsFromDomain(photos []domain.Photo) []int64 { + ids := make([]int64, 0, len(photos)) + for _, photo := range photos { + ids = append(ids, photo.ID) + } + return ids +} + func cleanupMediaStoreRoundTripRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { t.Helper() @@ -194,7 +398,7 @@ func cleanupMediaStoreRoundTripRows(t *testing.T, ctx context.Context, pool *pgx sql string args []any }{ - {sql: "DELETE FROM upload_parts WHERE owner_user_id = $1 AND file_id = 555", args: []any{ownerID}}, + {sql: "DELETE FROM upload_parts WHERE owner_user_id = $1 AND file_id IN (555, 556)", args: []any{ownerID}}, {sql: "DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND photo_id = $2", args: []any{ownerID, photoID}}, {sql: "DELETE FROM available_reactions WHERE reaction IN ($1, 'telesrv-test-😀')", args: []any{reactionEmoji}}, {sql: "DELETE FROM sticker_sets WHERE id = $1 OR short_name = 'telesrv_test_set_9100000000000000003' OR system_key = 'test_system_9100000000000000003'", args: []any{setID}}, @@ -208,3 +412,13 @@ func cleanupMediaStoreRoundTripRows(t *testing.T, ctx context.Context, pool *pgx } } } + +func cleanupProfilePhotoDetailsRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ownerID int64, photoIDs []int64) { + t.Helper() + if _, err := pool.Exec(ctx, "DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND photo_id = ANY($2::bigint[])", ownerID, photoIDs); err != nil { + t.Fatalf("cleanup profile photo details profile_photos: %v", err) + } + if _, err := pool.Exec(ctx, "DELETE FROM photos WHERE id = ANY($1::bigint[])", photoIDs); err != nil { + t.Fatalf("cleanup profile photo details photos: %v", err) + } +} diff --git a/internal/store/postgres/media_search.go b/internal/store/postgres/media_search.go new file mode 100644 index 00000000..31201cdd --- /dev/null +++ b/internal/store/postgres/media_search.go @@ -0,0 +1,308 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +// 共享媒体标签页读路径(迁移 0118):按类别查媒体索引拿到消息 id(JOIN 基表过滤软删 deleted), +// 再复用既有「按 id 取消息」路径(GetByIDs / GetChannelMessages)做富化与打包,并按索引顺序(id 倒序)重排。 + +const mediaSearchPageLimit = 100 + +func mediaCategoriesToInt16(cats []domain.MediaCategory) []int16 { + out := make([]int16, 0, len(cats)) + for _, c := range cats { + if c != domain.MediaCategoryNone { + out = append(out, int16(c)) + } + } + return out +} + +func mediaSearchPaging(req domain.MediaSearchRequest) (limit, offset int) { + limit = req.Limit + if limit < 0 || limit > mediaSearchPageLimit { + limit = mediaSearchPageLimit + } + offset = req.AddOffset + if offset < 0 { + offset = 0 + } + return limit, offset +} + +type mediaSearchQueryer interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +func mediaSearchCount(ctx context.Context, db mediaSearchQueryer, fallbackSQL string, args ...any) (int, error) { + var count int + if err := db.QueryRow(ctx, fallbackSQL, args...).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func reorderMessagesByID(msgs []domain.Message, order []int) []domain.Message { + byID := make(map[int]domain.Message, len(msgs)) + for _, m := range msgs { + byID[m.ID] = m + } + out := make([]domain.Message, 0, len(order)) + for _, id := range order { + if m, ok := byID[id]; ok { + out = append(out, m) + } + } + return out +} + +func reorderChannelMessagesByID(msgs []domain.ChannelMessage, order []int) []domain.ChannelMessage { + byID := make(map[int]domain.ChannelMessage, len(msgs)) + for _, m := range msgs { + byID[m.ID] = m + } + out := make([]domain.ChannelMessage, 0, len(order)) + for _, id := range order { + if m, ok := byID[id]; ok { + out = append(out, m) + } + } + return out +} + +// SearchPrivateMedia 返回某私聊会话中属于给定类别的消息(newest-first 分页)。 +func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peerID int64, req domain.MediaSearchRequest) (domain.MessageList, error) { + cats := mediaCategoriesToInt16(req.Categories) + if ownerUserID == 0 || peerID == 0 || len(cats) == 0 { + return domain.MessageList{}, nil + } + limit, offset := mediaSearchPaging(req) + maxID, minID, offsetID := int32(req.MaxID), int32(req.MinID), int32(req.OffsetID) + + count := req.KnownCount + if !req.HasKnownCount { + var err error + count, err = mediaSearchCount(ctx, s.db, ` +SELECT count(DISTINCT mi.box_id)::int +FROM message_box_media mi +JOIN message_boxes mb ON mb.owner_user_id = mi.owner_user_id AND mb.box_id = mi.box_id +WHERE mi.owner_user_id = $1 AND mi.peer_id = $2 AND mi.category = ANY($3::smallint[]) + AND NOT mb.deleted + AND ($4 = 0 OR mi.box_id <= $4) + AND ($5 = 0 OR mi.box_id >= $5)`, ownerUserID, peerID, cats, maxID, minID) + if err != nil { + return domain.MessageList{}, fmt.Errorf("count private media: %w", err) + } + } + if limit == 0 { + return domain.MessageList{Count: count}, nil + } + rows, err := s.db.Query(ctx, ` +SELECT DISTINCT mi.box_id +FROM message_box_media mi +JOIN message_boxes mb ON mb.owner_user_id = mi.owner_user_id AND mb.box_id = mi.box_id +WHERE mi.owner_user_id = $1 AND mi.peer_id = $2 AND mi.category = ANY($3::smallint[]) + AND NOT mb.deleted + AND ($4 = 0 OR mi.box_id <= $4) + AND ($5 = 0 OR mi.box_id >= $5) + AND ($6 = 0 OR mi.box_id < $6) +ORDER BY mi.box_id DESC +OFFSET $7 LIMIT $8`, ownerUserID, peerID, cats, maxID, minID, offsetID, offset, limit) + if err != nil { + return domain.MessageList{}, fmt.Errorf("list private media ids: %w", err) + } + defer rows.Close() + ids := make([]int, 0, limit) + for rows.Next() { + var id int32 + if err := rows.Scan(&id); err != nil { + return domain.MessageList{}, fmt.Errorf("scan private media id: %w", err) + } + ids = append(ids, int(id)) + } + if err := rows.Err(); err != nil { + return domain.MessageList{}, fmt.Errorf("iterate private media ids: %w", err) + } + + list, err := s.GetByIDs(ctx, ownerUserID, ids) + if err != nil { + return domain.MessageList{}, err + } + list.Messages = reorderMessagesByID(list.Messages, ids) + list.Count = count + return list, nil +} + +// CountPrivateMediaCategories 返回某私聊会话按基础媒体类别聚合的精确计数。 +func (s *MessageStore) CountPrivateMediaCategories(ctx context.Context, ownerUserID, peerID int64) (domain.MediaCategoryCounts, error) { + if ownerUserID == 0 || peerID == 0 { + return domain.MediaCategoryCounts{}, nil + } + rows, err := s.db.Query(ctx, ` +SELECT category, media_count +FROM private_media_category_counts +WHERE owner_user_id = $1 AND peer_id = $2 AND media_count > 0`, ownerUserID, peerID) + if err != nil { + return nil, fmt.Errorf("count private media categories: %w", err) + } + defer rows.Close() + out := domain.MediaCategoryCounts{} + for rows.Next() { + var category int16 + var count int + if err := rows.Scan(&category, &count); err != nil { + return nil, fmt.Errorf("scan private media category count: %w", err) + } + out[domain.MediaCategory(category)] = count + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate private media category counts: %w", err) + } + return out, nil +} + +// SearchChannelMedia 返回某频道中属于给定类别的消息(newest-first 分页)。 +func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, channelID int64, req domain.MediaSearchRequest) (domain.ChannelHistory, error) { + cats := mediaCategoriesToInt16(req.Categories) + if viewerUserID == 0 || channelID == 0 || len(cats) == 0 { + return domain.ChannelHistory{}, nil + } + limit, offset := mediaSearchPaging(req) + maxID, minID, offsetID := int32(req.MaxID), int32(req.MinID), int32(req.OffsetID) + + channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID) + if err != nil { + return domain.ChannelHistory{}, err + } + count := req.KnownCount + if !req.HasKnownCount { + var err error + count, err = mediaSearchCount(ctx, s.db, ` +SELECT count(DISTINCT mi.id)::int +FROM channel_message_media mi +JOIN channel_messages m ON m.channel_id = mi.channel_id AND m.id = mi.id +WHERE mi.channel_id = $1 AND mi.category = ANY($2::smallint[]) + AND NOT m.deleted + AND ($3 <= 0 OR mi.id > $3) + AND ($4 = 0 OR mi.id <= $4) + AND ($5 = 0 OR mi.id >= $5)`, channelID, cats, int32(member.AvailableMinID), maxID, minID) + if err != nil { + return domain.ChannelHistory{}, fmt.Errorf("count channel media: %w", err) + } + } + if limit == 0 { + return domain.ChannelHistory{Channel: channel, Self: member, Count: count}, nil + } + rows, err := s.db.Query(ctx, ` +SELECT DISTINCT mi.id +FROM channel_message_media mi +JOIN channel_messages m ON m.channel_id = mi.channel_id AND m.id = mi.id +WHERE mi.channel_id = $1 AND mi.category = ANY($2::smallint[]) + AND NOT m.deleted + AND ($3 <= 0 OR mi.id > $3) + AND ($4 = 0 OR mi.id <= $4) + AND ($5 = 0 OR mi.id >= $5) + AND ($6 = 0 OR mi.id < $6) +ORDER BY mi.id DESC +OFFSET $7 LIMIT $8`, channelID, cats, int32(member.AvailableMinID), maxID, minID, offsetID, offset, limit) + if err != nil { + return domain.ChannelHistory{}, fmt.Errorf("list channel media ids: %w", err) + } + defer rows.Close() + ids := make([]int, 0, limit) + for rows.Next() { + var id int32 + if err := rows.Scan(&id); err != nil { + return domain.ChannelHistory{}, fmt.Errorf("scan channel media id: %w", err) + } + ids = append(ids, int(id)) + } + if err := rows.Err(); err != nil { + return domain.ChannelHistory{}, fmt.Errorf("iterate channel media ids: %w", err) + } + + hist, err := s.getChannelMessagesForMember(ctx, viewerUserID, channel, member, ids) + if err != nil { + return domain.ChannelHistory{}, err + } + hist.Messages = reorderChannelMessagesByID(hist.Messages, ids) + hist.Count = count + return hist, nil +} + +// CountChannelMediaCategories 返回某频道对当前 viewer 可见消息按基础媒体类别聚合的精确计数。 +func (s *ChannelStore) CountChannelMediaCategories(ctx context.Context, viewerUserID, channelID int64) (domain.MediaCategoryCounts, error) { + if viewerUserID == 0 || channelID == 0 { + return domain.MediaCategoryCounts{}, nil + } + var availableMinID int32 + if err := s.db.QueryRow(ctx, ` +SELECT available_min_id +FROM channel_members +WHERE channel_id = $1 AND user_id = $2 + AND status = 'active' + AND NOT COALESCE((banned_rights->>'ViewMessages')::boolean, false)`, channelID, viewerUserID).Scan(&availableMinID); err != nil { + if err == pgx.ErrNoRows { + return domain.MediaCategoryCounts{}, nil + } + return nil, fmt.Errorf("load channel media count member visibility: %w", err) + } + if availableMinID <= 0 { + return s.countFullChannelMediaCategories(ctx, channelID) + } + rows, err := s.db.Query(ctx, ` +SELECT mi.category, count(*)::int +FROM channel_message_media mi +JOIN channel_messages m ON m.channel_id = mi.channel_id AND m.id = mi.id +WHERE mi.channel_id = $1 + AND NOT m.deleted + AND mi.id > $2 +GROUP BY mi.category`, channelID, availableMinID) + if err != nil { + return nil, fmt.Errorf("count channel media categories: %w", err) + } + defer rows.Close() + out := domain.MediaCategoryCounts{} + for rows.Next() { + var category int16 + var count int + if err := rows.Scan(&category, &count); err != nil { + return nil, fmt.Errorf("scan channel media category count: %w", err) + } + out[domain.MediaCategory(category)] = count + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate channel media category counts: %w", err) + } + return out, nil +} + +func (s *ChannelStore) countFullChannelMediaCategories(ctx context.Context, channelID int64) (domain.MediaCategoryCounts, error) { + rows, err := s.db.Query(ctx, ` +SELECT category, media_count +FROM channel_media_category_counts +WHERE channel_id = $1 AND media_count > 0`, channelID) + if err != nil { + return nil, fmt.Errorf("count full channel media categories: %w", err) + } + defer rows.Close() + out := domain.MediaCategoryCounts{} + for rows.Next() { + var category int16 + var count int + if err := rows.Scan(&category, &count); err != nil { + return nil, fmt.Errorf("scan full channel media category count: %w", err) + } + out[domain.MediaCategory(category)] = count + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate full channel media category counts: %w", err) + } + return out, nil +} diff --git a/internal/store/postgres/message.go b/internal/store/postgres/message.go deleted file mode 100644 index ab109e92..00000000 --- a/internal/store/postgres/message.go +++ /dev/null @@ -1,2731 +0,0 @@ -package postgres - -import ( - "context" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "hash" - "hash/fnv" - "sort" - "strings" - "time" - - "github.com/jackc/pgx/v5" - - "telesrv/internal/domain" - "telesrv/internal/store" - "telesrv/internal/store/postgres/sqlcgen" -) - -// MessageStore 用 PostgreSQL 实现 store.MessageStore。 -type MessageStore struct { - db sqlcgen.DBTX - q *sqlcgen.Queries - boxIDs store.BoxIDAllocator - pts store.PtsAllocator -} - -type txBeginner interface { - Begin(ctx context.Context) (pgx.Tx, error) -} - -// MessageStoreOption 调整 PostgreSQL MessageStore 依赖。 -type MessageStoreOption func(*MessageStore) - -// WithMessageAllocators 注入 Redis-backed allocator;未注入时使用 PG max+1 兜底,仅用于测试。 -func WithMessageAllocators(boxIDs store.BoxIDAllocator, pts store.PtsAllocator) MessageStoreOption { - return func(s *MessageStore) { - s.boxIDs = boxIDs - s.pts = pts - } -} - -// NewMessageStore 基于 pgx 连接池(或事务)创建 MessageStore。 -func NewMessageStore(db sqlcgen.DBTX, opts ...MessageStoreOption) *MessageStore { - s := &MessageStore{db: db, q: sqlcgen.New(db)} - for _, opt := range opts { - opt(s) - } - if s.boxIDs == nil { - s.boxIDs = pgBoxIDAllocator{s: s} - } - if s.pts == nil { - s.pts = pgPtsAllocator{events: NewUpdateEventStore(db)} - } - return s -} - -func (s *MessageStore) Create(ctx context.Context, msg domain.Message) (domain.Message, error) { - if err := s.ensureOfficialSystemUser(ctx, msg); err != nil { - return domain.Message{}, err - } - entities, err := encodeMessageEntities(msg.Entities) - if err != nil { - return domain.Message{}, err - } - if msg.Date == 0 { - msg.Date = int(time.Now().Unix()) - } - if msg.ID == 0 { - msg.ID, err = s.boxIDs.NextBoxID(ctx, msg.OwnerUserID) - if err != nil { - return domain.Message{}, fmt.Errorf("allocate login message box id: %w", err) - } - } - row, err := s.q.CreateMessage(ctx, sqlcgen.CreateMessageParams{ - OwnerUserID: msg.OwnerUserID, - BoxID: int32(msg.ID), - PeerType: string(msg.Peer.Type), - PeerID: msg.Peer.ID, - FromUserID: msg.From.ID, - MessageDate: int32(msg.Date), - Outgoing: msg.Out, - Body: msg.Body, - EntitiesJson: entities, - Pts: int32(msg.Pts), - }) - if err != nil { - return domain.Message{}, fmt.Errorf("create message: %w", err) - } - return messageFromCreateRow(row) -} - -func (s *MessageStore) ensureOfficialSystemUser(ctx context.Context, msg domain.Message) error { - if msg.Peer.Type != domain.PeerTypeUser && msg.From.Type != domain.PeerTypeUser { - return nil - } - if msg.Peer.ID != domain.OfficialSystemUserID && msg.From.ID != domain.OfficialSystemUserID { - return nil - } - u := domain.OfficialSystemUser() - if _, err := s.db.Exec(ctx, ` -INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) -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, - country_code = EXCLUDED.country_code, - verified = EXCLUDED.verified, - support = EXCLUDED.support, - about = EXCLUDED.about, - updated_at = now() -`, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About); err != nil { - return fmt.Errorf("ensure official system user: %w", err) - } - return nil -} - -func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPrivateTextRequest) (res domain.SendPrivateTextResult, err error) { - if req.SenderUserID == 0 || req.RecipientUserID == 0 { - return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing user id") - } - if req.RandomID == 0 { - return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing random id") - } - if req.Message == "" && req.Media.IsZero() { - return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: empty message") - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - entities, err := encodeMessageEntities(req.Entities) - if err != nil { - return domain.SendPrivateTextResult{}, err - } - mediaJSON, err := encodeMessageMedia(req.Media) - if err != nil { - return domain.SendPrivateTextResult{}, err - } - senderReply, recipientReply, err := s.resolvePrivateSendReply(ctx, req) - if err != nil { - return domain.SendPrivateTextResult{}, err - } - senderMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, senderReply, req.Forward) - if err != nil { - return domain.SendPrivateTextResult{}, err - } - recipientMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, recipientReply, req.Forward) - if err != nil { - return domain.SendPrivateTextResult{}, err - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: db does not support transactions") - } - - // Redis pts/box_id 分配移到事务外:分配走 Redis(本就不属 PG 事务),放在 Begin 前可避免在 - // 持有 PG 连接(与行锁)期间空等 Redis 往返,显著降低高并发下的连接占用。代价是分配→提交窗口变长、 - // 瞬时 pts 空洞窗口变大,但已由 getState/getDifference 只暴露「连续 pts」兜底(见 internal/app/updates)。 - // box_id 空洞无害(消息 id 允许不连续);只有 pts 必须无洞,故仅把 pts 计入 reserved 做补洞。 - var reserved []reservedPts - senderBoxID, err := s.boxIDs.NextBoxID(ctx, req.SenderUserID) - if err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("allocate sender box id: %w", err) - } - senderPts, err := s.pts.NextPts(ctx, req.SenderUserID) - if err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("allocate sender pts: %w", err) - } - reserved = append(reserved, reservedPts{userID: req.SenderUserID, pts: senderPts}) - - var recipientBoxID, recipientPts int - selfMessage := req.RecipientUserID == req.SenderUserID - deliverRecipient := !selfMessage && !req.RecipientBlocked - if deliverRecipient { - recipientBoxID, err = s.boxIDs.NextBoxID(ctx, req.RecipientUserID) - if err != nil { - s.recordPtsGaps(ctx, reserved, req.Date) - return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient box id: %w", err) - } - recipientPts, err = s.pts.NextPts(ctx, req.RecipientUserID) - if err != nil { - s.recordPtsGaps(ctx, reserved, req.Date) - return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient pts: %w", err) - } - reserved = append(reserved, reservedPts{userID: req.RecipientUserID, pts: recipientPts}) - } - - tx, err := beginner.Begin(ctx) - if err != nil { - s.recordPtsGaps(ctx, reserved, req.Date) - return domain.SendPrivateTextResult{}, fmt.Errorf("begin send message tx: %w", err) - } - qtx := sqlcgen.New(tx) - committed := false - defer func() { - if committed { - return - } - _ = tx.Rollback(ctx) - // 未提交(出错或 random_id 重复):已分配的 pts 不会落进真实事件,补 noop 占位避免 pts 永久空洞。 - s.recordPtsGaps(ctx, reserved, req.Date) - }() - - // 事务级 advisory lock 串行化涉及收发双方的并发写,在任何行锁之前获取,消除 watermark/dialog - // 行锁的 AB-BA 死锁(A↔B 反向并发 send/read/edit)。 - if err := lockUsersForUpdate(ctx, tx, req.SenderUserID, req.RecipientUserID); err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("lock send users: %w", err) - } - - privateArg := sqlcgen.CreatePrivateMessageParams{ - SenderUserID: req.SenderUserID, - RecipientUserID: req.RecipientUserID, - RandomID: req.RandomID, - MessageDate: int32(req.Date), - Body: req.Message, - EntitiesJson: entities, - MediaJson: mediaJSON, - } - applyCreatePrivateMessageMetadata(&privateArg, senderMeta) - pm, err := qtx.CreatePrivateMessage(ctx, privateArg) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - // 幂等重复:返回原消息盒;defer 会把本次(白白分配的)pts 补成 noop。 - dup, dupErr := s.duplicateSendResult(ctx, req.SenderUserID, req.RecipientUserID, req.RandomID) - if dupErr != nil { - return domain.SendPrivateTextResult{}, dupErr - } - dup.Duplicate = true - return dup, nil - } - return domain.SendPrivateTextResult{}, fmt.Errorf("create private message: %w", err) - } - - senderArg := sqlcgen.CreateMessageBoxParams{ - OwnerUserID: req.SenderUserID, - BoxID: int32(senderBoxID), - PrivateMessageID: pm.ID, - MessageSenderID: req.SenderUserID, - PeerType: string(domain.PeerTypeUser), - PeerID: req.RecipientUserID, - FromUserID: req.SenderUserID, - MessageDate: int32(req.Date), - Outgoing: true, - Body: req.Message, - EntitiesJson: entities, - Pts: int32(senderPts), - MediaJson: mediaJSON, - MediaUnread: false, - ReactionUnread: false, - } - applyCreateMessageBoxMetadata(&senderArg, senderMeta) - senderRow, err := qtx.CreateMessageBox(ctx, senderArg) - if err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("create sender box: %w", err) - } - sender := messageFromBoxRow(senderRow) - if err := qtx.UpsertOutboxDialog(ctx, sqlcgen.UpsertOutboxDialogParams{ - UserID: req.SenderUserID, - PeerType: string(domain.PeerTypeUser), - PeerID: req.RecipientUserID, - TopMessageID: int32(sender.ID), - TopMessageDate: int32(sender.Date), - }); err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("upsert sender dialog: %w", err) - } - if err := appendNewMessageEvent(ctx, qtx, sender); err != nil { - return domain.SendPrivateTextResult{}, err - } - if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ - TargetUserID: req.SenderUserID, - Pts: int32(senderPts), - EventType: string(domain.UpdateEventNewMessage), - ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), - ExcludeSessionID: req.OriginSessionID, - }); err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue sender dispatch: %w", err) - } - - recipient := domain.Message{} - if selfMessage { - recipient = sender - } - if deliverRecipient { - recipientArg := sqlcgen.CreateMessageBoxParams{ - OwnerUserID: req.RecipientUserID, - BoxID: int32(recipientBoxID), - PrivateMessageID: pm.ID, - MessageSenderID: req.SenderUserID, - PeerType: string(domain.PeerTypeUser), - PeerID: req.SenderUserID, - FromUserID: req.SenderUserID, - MessageDate: int32(req.Date), - Outgoing: false, - Body: req.Message, - EntitiesJson: entities, - Pts: int32(recipientPts), - MediaJson: mediaJSON, - MediaUnread: !req.Media.IsZero(), - ReactionUnread: false, - } - applyCreateMessageBoxMetadata(&recipientArg, recipientMeta) - recipientRow, err := qtx.CreateMessageBox(ctx, recipientArg) - if err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("create recipient box: %w", err) - } - recipient = messageFromBoxRow(recipientRow) - if err := qtx.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{ - UserID: req.RecipientUserID, - PeerType: string(domain.PeerTypeUser), - PeerID: req.SenderUserID, - TopMessageID: int32(recipient.ID), - TopMessageDate: int32(recipient.Date), - }); err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("upsert recipient dialog: %w", err) - } - if err := appendNewMessageEvent(ctx, qtx, recipient); err != nil { - return domain.SendPrivateTextResult{}, err - } - if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ - TargetUserID: req.RecipientUserID, - Pts: int32(recipientPts), - EventType: string(domain.UpdateEventNewMessage), - ExcludeAuthKeyID: 0, - ExcludeSessionID: 0, - }); err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue recipient dispatch: %w", err) - } - } - - if err := tx.Commit(ctx); err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("commit send message tx: %w", err) - } - committed = true - return domain.SendPrivateTextResult{ - SenderMessage: sender, - RecipientMessage: recipient, - SenderEvent: eventFromMessage(sender), - RecipientEvent: eventFromMessage(recipient), - }, nil -} - -func (s *MessageStore) duplicateSendResult(ctx context.Context, senderUserID, recipientUserID, randomID int64) (domain.SendPrivateTextResult, error) { - pm, err := s.q.GetPrivateMessageByRandomID(ctx, sqlcgen.GetPrivateMessageByRandomIDParams{ - SenderUserID: senderUserID, - RandomID: randomID, - }) - if err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate private message: %w", err) - } - senderRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ - OwnerUserID: senderUserID, - PrivateMessageID: pm.ID, - }) - if err != nil { - return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate sender box: %w", err) - } - sender := messageFromGetBoxRow(senderRow) - recipient := domain.Message{} - if recipientUserID == senderUserID { - recipient = sender - } - if recipientUserID != senderUserID { - recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ - OwnerUserID: recipientUserID, - PrivateMessageID: pm.ID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.SendPrivateTextResult{ - SenderMessage: sender, - SenderEvent: eventFromMessage(sender), - RecipientEvent: domain.UpdateEvent{}, - }, nil - } - return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate recipient box: %w", err) - } - recipient = messageFromGetBoxRow(recipientRow) - } - return domain.SendPrivateTextResult{ - SenderMessage: sender, - RecipientMessage: recipient, - SenderEvent: eventFromMessage(sender), - RecipientEvent: eventFromMessage(recipient), - }, nil -} - -func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) { - if req.ReplyTo == nil { - return nil, nil, nil - } - if req.ReplyTo.MessageID <= 0 || req.ReplyTo.MessageID > domain.MaxMessageBoxID { - return nil, nil, domain.ErrReplyMessageIDInvalid - } - peer := req.ReplyTo.Peer - if peer.ID == 0 { - peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID} - } - if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID { - return nil, nil, domain.ErrReplyMessageIDInvalid - } - source, err := s.q.GetMessageBoxForReply(ctx, sqlcgen.GetMessageBoxForReplyParams{ - OwnerUserID: req.SenderUserID, - PeerType: string(peer.Type), - PeerID: peer.ID, - BoxID: int32(req.ReplyTo.MessageID), - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil, domain.ErrReplyMessageIDInvalid - } - return nil, nil, fmt.Errorf("get reply message: %w", err) - } - senderReply := cloneMessageReply(req.ReplyTo) - senderReply.MessageID = int(source.BoxID) - senderReply.Peer = peer - if req.SenderUserID == req.RecipientUserID { - return senderReply, cloneMessageReply(senderReply), nil - } - - recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ - OwnerUserID: req.RecipientUserID, - PrivateMessageID: source.PrivateMessageID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return senderReply, nil, nil - } - return nil, nil, fmt.Errorf("get recipient reply message: %w", err) - } - recipientReply := cloneMessageReply(senderReply) - recipientReply.MessageID = int(recipientRow.BoxID) - recipientReply.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} - return senderReply, recipientReply, nil -} - -func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { - if reply == nil { - return nil - } - clone := *reply - clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...) - return &clone -} - -func cloneMessageForward(forward *domain.MessageForward) *domain.MessageForward { - if forward == nil { - return nil - } - clone := *forward - return &clone -} - -func cloneChannelMessageAction(action *domain.ChannelMessageAction) *domain.ChannelMessageAction { - if action == nil { - return nil - } - clone := *action - clone.UserIDs = append([]int64(nil), action.UserIDs...) - if action.Closed != nil { - v := *action.Closed - clone.Closed = &v - } - if action.Hidden != nil { - v := *action.Hidden - clone.Hidden = &v - } - return &clone -} - -func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { - res := domain.ForwardPrivateMessagesResult{OwnerUserID: req.OwnerUserID} - if req.OwnerUserID == 0 || req.ToUserID == 0 { - return res, fmt.Errorf("forward private messages: missing user id") - } - if req.FromPeer.Type != domain.PeerTypeUser || req.FromPeer.ID == 0 { - return res, fmt.Errorf("forward private messages: invalid source peer") - } - if len(req.MessageIDs) == 0 || len(req.MessageIDs) != len(req.RandomIDs) { - return res, domain.ErrMessageIDInvalid - } - if len(req.MessageIDs) > domain.MaxForwardMessageIDs { - return res, fmt.Errorf("forward private messages: too many ids: %d > %d", len(req.MessageIDs), domain.MaxForwardMessageIDs) - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - boxIDs := make([]int32, 0, len(req.MessageIDs)) - for i, id := range req.MessageIDs { - if id <= 0 || id > domain.MaxMessageBoxID || req.RandomIDs[i] == 0 { - return res, domain.ErrMessageIDInvalid - } - boxIDs = append(boxIDs, int32(id)) - } - rows, err := s.q.GetMessageBoxesForForward(ctx, sqlcgen.GetMessageBoxesForForwardParams{ - OwnerUserID: req.OwnerUserID, - PeerType: string(req.FromPeer.Type), - PeerID: req.FromPeer.ID, - BoxIds: boxIDs, - }) - if err != nil { - return res, fmt.Errorf("get forward messages: %w", err) - } - if len(rows) != len(req.MessageIDs) { - return res, domain.ErrMessageIDInvalid - } - res.SenderMessages = make([]domain.Message, 0, len(rows)) - res.RecipientMessages = make([]domain.Message, 0, len(rows)) - res.SenderEvents = make([]domain.UpdateEvent, 0, len(rows)) - res.RecipientEvents = make([]domain.UpdateEvent, 0, len(rows)) - res.Duplicates = make([]bool, 0, len(rows)) - for i, row := range rows { - if int(row.BoxID) != req.MessageIDs[i] { - return res, domain.ErrMessageIDInvalid - } - source, err := messageFromForwardRow(row) - if err != nil { - return res, err - } - if source.NoForwards { - return res, domain.ErrChatForwardsRestricted - } - var forward *domain.MessageForward - if !req.DropAuthor { - forward = cloneMessageForward(source.Forward) - if forward == nil { - forward = &domain.MessageForward{From: source.From, Date: source.Date} - } - } - sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{ - SenderUserID: req.OwnerUserID, - RecipientUserID: req.ToUserID, - RandomID: req.RandomIDs[i], - Message: source.Body, - Entities: append([]domain.MessageEntity(nil), source.Entities...), - Media: source.Media, - Silent: req.Silent, - NoForwards: req.NoForwards, - ReplyTo: req.ReplyTo, - Forward: forward, - Date: req.Date, - OriginAuthKeyID: req.OriginAuthKeyID, - OriginSessionID: req.OriginSessionID, - RecipientBlocked: req.RecipientBlocked, - }) - if err != nil { - return res, err - } - res.SenderMessages = append(res.SenderMessages, sent.SenderMessage) - res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage) - res.SenderEvents = append(res.SenderEvents, sent.SenderEvent) - res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent) - res.Duplicates = append(res.Duplicates, sent.Duplicate) - } - return res, nil -} - -func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) { - if userID == 0 || len(ids) == 0 { - return domain.MessageList{}, nil - } - boxIDs := make([]int32, 0, len(ids)) - for _, id := range ids { - if id <= 0 || id > domain.MaxMessageBoxID { - continue - } - boxIDs = append(boxIDs, int32(id)) - } - if len(boxIDs) == 0 { - return domain.MessageList{}, nil - } - rows, err := s.q.GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{ - OwnerUserID: userID, - BoxIds: boxIDs, - }) - if err != nil { - return domain.MessageList{}, fmt.Errorf("get messages by ids: %w", err) - } - out := domain.MessageList{ - Messages: make([]domain.Message, 0, len(rows)), - Users: make([]domain.User, 0, len(rows)*2), - } - seenUsers := map[int64]struct{}{} - for _, row := range rows { - msg, err := messageFromIDRow(row) - if err != nil { - return domain.MessageList{}, err - } - out.Messages = append(out.Messages, msg) - appendUsersFromMessageIDRow(&out, seenUsers, row) - } - if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { - return domain.MessageList{}, err - } - out.Hash = messageListHash(out.Messages) - return out, nil -} - -func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { - limit := filter.Limit - if limit <= 0 { - limit = 100 - } - if limit > 500 { - limit = 500 - } - addOffset := domain.ClampMessageHistoryAddOffset(filter.AddOffset) - rows, err := s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{ - OwnerUserID: userID, - HasPeer: filter.HasPeer, - PeerType: string(filter.Peer.Type), - PeerID: filter.Peer.ID, - Query: filter.Query, - OffsetID: pgInt32NonNegative(filter.OffsetID), - OffsetDate: pgInt32NonNegative(filter.OffsetDate), - MaxID: pgInt32NonNegative(filter.MaxID), - MinID: pgInt32NonNegative(filter.MinID), - AddOffset: pgInt32Bounded(addOffset), - LimitCount: int32(limit), - NeedTotalCount: filter.NeedTotalCount, - }) - if err != nil { - return domain.MessageList{}, fmt.Errorf("list messages: %w", err) - } - out := domain.MessageList{ - Messages: make([]domain.Message, 0, len(rows)), - Users: make([]domain.User, 0, len(rows)*2), - } - seenUsers := map[int64]struct{}{} - for _, row := range rows { - entities, err := decodeMessageEntities(row.EntitiesJson) - if err != nil { - return domain.MessageList{}, fmt.Errorf("decode message entities: %w", err) - } - silent, noforwards, reply, forward, err := messageMetadataFromFields( - row.Silent, - row.Noforwards, - row.ReplyToMsgID, - row.ReplyToPeerType, - row.ReplyToPeerID, - row.ReplyToTopID, - row.QuoteText, - row.QuoteEntitiesJson, - row.QuoteOffset, - row.FwdFromPeerType, - row.FwdFromPeerID, - row.FwdFromName, - row.FwdDate, - ) - if err != nil { - return domain.MessageList{}, fmt.Errorf("decode message metadata: %w", err) - } - media, err := decodeMessageMedia(row.MediaJson) - if err != nil { - return domain.MessageList{}, fmt.Errorf("decode message media: %w", err) - } - out.Messages = append(out.Messages, domain.Message{ - ID: int(row.BoxID), - UID: row.PrivateMessageID, - OwnerUserID: row.OwnerUserID, - Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, - Date: int(row.MessageDate), - EditDate: int(row.EditDate), - Out: row.Outgoing, - Silent: silent, - NoForwards: noforwards, - Body: row.Body, - Entities: entities, - ReplyTo: reply, - Forward: forward, - Pts: int(row.Pts), - Media: media, - MediaUnread: row.MediaUnread, - ReactionUnread: row.ReactionUnread, - }) - if out.Count == 0 { - out.Count = int(row.TotalCount) - } - appendUserFromMessageRow(&out, seenUsers, row) - } - if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { - return domain.MessageList{}, err - } - out.Hash = messageListHash(out.Messages) - return out, nil -} - -func (s *MessageStore) ReadHistory(ctx context.Context, req domain.ReadHistoryRequest) (res domain.ReadHistoryResult, err error) { - res = domain.ReadHistoryResult{OwnerUserID: req.OwnerUserID, Peer: req.Peer, MaxID: req.MaxID} - if req.OwnerUserID == 0 { - return res, fmt.Errorf("read history: missing owner user id") - } - if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { - return res, fmt.Errorf("read history: invalid peer") - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - beginner, ok := s.db.(txBeginner) - if !ok { - return res, fmt.Errorf("read history: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return res, fmt.Errorf("begin read history tx: %w", err) - } - qtx := sqlcgen.New(tx) - committed := false - var reserved []reservedPts - defer func() { - if committed { - return - } - _ = tx.Rollback(ctx) - s.recordPtsGaps(ctx, reserved, req.Date) - }() - - // advisory lock 串行化与会话对端的并发写(peer 即私聊另一方 / 回执 sender),须在行锁前获取。 - if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { - return res, fmt.Errorf("lock read history users: %w", err) - } - - state, err := qtx.GetDialogReadStateForUpdate(ctx, sqlcgen.GetDialogReadStateForUpdateParams{ - UserID: req.OwnerUserID, - PeerType: string(req.Peer.Type), - PeerID: req.Peer.ID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return res, nil - } - return res, fmt.Errorf("get dialog read state: %w", err) - } - readMax := req.MaxID - if readMax <= 0 { - readMax = int(state.TopMessageID) - } - if readMax > domain.MaxMessageBoxID { - readMax = domain.MaxMessageBoxID - } - res.MaxID = readMax - oldRead := int(state.ReadInboxMaxID) - changed := int(state.UnreadCount) > 0 || readMax > oldRead - if !changed { - return res, nil - } - - candidate, candidateErr := qtx.LatestIncomingReadReceiptCandidate(ctx, sqlcgen.LatestIncomingReadReceiptCandidateParams{ - OwnerUserID: req.OwnerUserID, - PeerType: string(req.Peer.Type), - PeerID: req.Peer.ID, - OldReadInboxMaxID: int32(oldRead), - NewReadInboxMaxID: int32(readMax), - }) - if candidateErr != nil && !errors.Is(candidateErr, pgx.ErrNoRows) { - return res, fmt.Errorf("load read receipt candidate: %w", candidateErr) - } - - updated, err := qtx.UpdateDialogReadInbox(ctx, sqlcgen.UpdateDialogReadInboxParams{ - UserID: req.OwnerUserID, - PeerType: string(req.Peer.Type), - PeerID: req.Peer.ID, - ReadInboxMaxID: int32(readMax), - }) - if err != nil { - return res, fmt.Errorf("update dialog read inbox: %w", err) - } - readerPts, err := s.pts.NextPts(ctx, req.OwnerUserID) - if err != nil { - return res, fmt.Errorf("allocate read history pts: %w", err) - } - reserved = append(reserved, reservedPts{userID: req.OwnerUserID, pts: readerPts}) - res.Changed = true - res.MaxID = int(updated.ReadInboxMaxID) - res.StillUnreadCount = int(updated.UnreadCount) - res.InboxEvent = domain.UpdateEvent{ - UserID: req.OwnerUserID, - Type: domain.UpdateEventReadHistoryInbox, - Pts: readerPts, - PtsCount: 1, - Date: req.Date, - Peer: req.Peer, - MaxID: res.MaxID, - StillUnreadCount: res.StillUnreadCount, - } - if err := appendUserUpdateEvent(ctx, qtx, req.OwnerUserID, res.InboxEvent); err != nil { - return res, fmt.Errorf("append read inbox event: %w", err) - } - if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ - TargetUserID: req.OwnerUserID, - Pts: int32(readerPts), - EventType: string(domain.UpdateEventReadHistoryInbox), - ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), - ExcludeSessionID: req.OriginSessionID, - }); err != nil { - return res, fmt.Errorf("enqueue read inbox dispatch: %w", err) - } - - if candidateErr == nil && candidate.SenderOwnerUserID != 0 && int(candidate.SenderBoxID) > 0 { - if _, err := qtx.UpdateDialogReadOutbox(ctx, sqlcgen.UpdateDialogReadOutboxParams{ - UserID: candidate.SenderOwnerUserID, - PeerType: string(domain.PeerTypeUser), - PeerID: req.OwnerUserID, - ReadOutboxMaxID: candidate.SenderBoxID, - }); err == nil { - senderPts, err := s.pts.NextPts(ctx, candidate.SenderOwnerUserID) - if err != nil { - return res, fmt.Errorf("allocate read outbox pts: %w", err) - } - reserved = append(reserved, reservedPts{userID: candidate.SenderOwnerUserID, pts: senderPts}) - res.OutboxChanged = true - res.OutboxUserID = candidate.SenderOwnerUserID - res.OutboxEvent = domain.UpdateEvent{ - UserID: candidate.SenderOwnerUserID, - Type: domain.UpdateEventReadHistoryOutbox, - Pts: senderPts, - PtsCount: 1, - Date: req.Date, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, - MaxID: int(candidate.SenderBoxID), - } - if err := appendUserUpdateEvent(ctx, qtx, candidate.SenderOwnerUserID, res.OutboxEvent); err != nil { - return res, fmt.Errorf("append read outbox event: %w", err) - } - if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ - TargetUserID: candidate.SenderOwnerUserID, - Pts: int32(senderPts), - EventType: string(domain.UpdateEventReadHistoryOutbox), - ExcludeAuthKeyID: 0, - ExcludeSessionID: 0, - }); err != nil { - return res, fmt.Errorf("enqueue read outbox dispatch: %w", err) - } - } else if !errors.Is(err, pgx.ErrNoRows) { - return res, fmt.Errorf("update dialog read outbox: %w", err) - } - } - - if err := tx.Commit(ctx); err != nil { - return res, fmt.Errorf("commit read history tx: %w", err) - } - committed = true - return res, nil -} - -func (s *MessageStore) ReadMessageContents(ctx context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { - res := domain.ReadMessageContentsResult{OwnerUserID: req.OwnerUserID} - if req.OwnerUserID == 0 { - return res, fmt.Errorf("read message contents: missing owner user id") - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - if len(req.IDs) > domain.MaxGetMessageIDs { - return res, domain.ErrMessageIDInvalid - } - seen := make(map[int]struct{}, len(req.IDs)) - ids := make([]int32, 0, len(req.IDs)) - for _, id := range req.IDs { - if id <= 0 || id > domain.MaxMessageBoxID { - return res, domain.ErrMessageIDInvalid - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - ids = append(ids, int32(id)) - } - if len(ids) == 0 { - return res, nil - } - beginner, ok := s.db.(txBeginner) - if !ok { - return res, fmt.Errorf("read message contents: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return res, fmt.Errorf("begin read message contents tx: %w", err) - } - qtx := sqlcgen.New(tx) - committed := false - var reserved []reservedPts - defer func() { - if committed { - return - } - _ = tx.Rollback(ctx) - s.recordPtsGaps(ctx, reserved, req.Date) - }() - if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID); err != nil { - return res, fmt.Errorf("lock read message contents user: %w", err) - } - rows, err := tx.Query(ctx, ` -WITH target AS ( - SELECT owner_user_id, box_id, peer_type, peer_id, reaction_unread - FROM message_boxes - WHERE owner_user_id = $1 - AND box_id = ANY($2::int[]) - AND NOT deleted - AND (media_unread OR reaction_unread) - FOR UPDATE -), -updated AS ( - UPDATE message_boxes - SET media_unread = false, - reaction_unread = false - FROM target t - WHERE message_boxes.owner_user_id = t.owner_user_id - AND message_boxes.box_id = t.box_id - RETURNING message_boxes.box_id, t.peer_type, t.peer_id, t.reaction_unread -) -SELECT box_id, peer_type, peer_id, reaction_unread -FROM updated -ORDER BY box_id`, req.OwnerUserID, ids) - if err != nil { - return res, fmt.Errorf("read message contents: %w", err) - } - defer rows.Close() - affectedPeers := make(map[domain.Peer]struct{}) - for rows.Next() { - var id int32 - var peerType string - var peerID int64 - var reactionUnread bool - if err := rows.Scan(&id, &peerType, &peerID, &reactionUnread); err != nil { - return res, fmt.Errorf("scan read message contents: %w", err) - } - res.MessageIDs = append(res.MessageIDs, int(id)) - if reactionUnread && peerID != 0 { - affectedPeers[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = struct{}{} - } - } - if err := rows.Err(); err != nil { - return res, fmt.Errorf("read message contents rows: %w", err) - } - if len(res.MessageIDs) == 0 { - if err := tx.Commit(ctx); err != nil { - return res, fmt.Errorf("commit read message contents noop: %w", err) - } - committed = true - return res, nil - } - for peer := range affectedPeers { - if peer.Type != domain.PeerTypeUser || peer.ID == 0 { - continue - } - if _, err := tx.Exec(ctx, ` -UPDATE dialogs d -SET unread_reactions_count = ( - SELECT COUNT(*)::int - FROM message_boxes m - WHERE m.owner_user_id = d.user_id - AND m.peer_type = d.peer_type - AND m.peer_id = d.peer_id - AND NOT m.deleted - AND m.reaction_unread -), -updated_at = now() -WHERE d.user_id = $1 - AND d.peer_type = $2 - AND d.peer_id = $3`, req.OwnerUserID, string(peer.Type), peer.ID); err != nil { - return res, fmt.Errorf("refresh dialog unread reactions after content read: %w", err) - } - } - pts, err := s.nextPtsN(ctx, req.OwnerUserID, len(res.MessageIDs)) - if err != nil { - return res, fmt.Errorf("allocate read message contents pts: %w", err) - } - reserved = append(reserved, reservedPts{userID: req.OwnerUserID, pts: pts, count: len(res.MessageIDs)}) - res.Event = domain.UpdateEvent{ - UserID: req.OwnerUserID, - Type: domain.UpdateEventReadMessageContents, - Pts: pts, - PtsCount: len(res.MessageIDs), - Date: req.Date, - MessageIDs: append([]int(nil), res.MessageIDs...), - } - if err := appendUserUpdateEvent(ctx, qtx, req.OwnerUserID, res.Event); err != nil { - return res, fmt.Errorf("append read message contents event: %w", err) - } - if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ - TargetUserID: req.OwnerUserID, - Pts: int32(pts), - EventType: string(domain.UpdateEventReadMessageContents), - ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), - ExcludeSessionID: req.OriginSessionID, - }); err != nil { - return res, fmt.Errorf("enqueue read message contents dispatch: %w", err) - } - if err := tx.Commit(ctx); err != nil { - return res, fmt.Errorf("commit read message contents tx: %w", err) - } - committed = true - return res, nil -} - -func (s *MessageStore) GetOutboxReadDate(ctx context.Context, req domain.OutboxReadDateRequest) (int, error) { - if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return 0, domain.ErrMessageIDInvalid - } - if _, err := s.q.GetOutboxMessageForReadDate(ctx, sqlcgen.GetOutboxMessageForReadDateParams{ - OwnerUserID: req.OwnerUserID, - PeerType: string(req.Peer.Type), - PeerID: req.Peer.ID, - BoxID: int32(req.ID), - }); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return 0, domain.ErrMessageIDInvalid - } - return 0, fmt.Errorf("get outbox message for read date: %w", err) - } - date, err := s.q.GetOutboxReadDate(ctx, sqlcgen.GetOutboxReadDateParams{ - UserID: req.OwnerUserID, - PeerType: string(req.Peer.Type), - PeerID: req.Peer.ID, - MessageID: int32(req.ID), - }) - if err != nil { - return 0, fmt.Errorf("get outbox read date: %w", err) - } - if date == 0 { - return 0, domain.ErrMessageNotReadYet - } - return int(date), nil -} - -func (s *MessageStore) SetMessageReactions(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { - if req.UserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - for _, reaction := range req.Reactions { - if reaction.Type != domain.MessageReactionEmoji || strings.TrimSpace(reaction.Emoticon) == "" { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - } - beginner, ok := s.db.(txBeginner) - if !ok { - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("set message reactions: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("begin set message reactions tx: %w", err) - } - qtx := sqlcgen.New(tx) - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - - if err := lockUsersForUpdate(ctx, tx, req.UserID, req.Peer.ID); err != nil { - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("lock set message reactions users: %w", err) - } - - var target struct { - boxID int32 - privateMessageID int64 - messageSenderID int64 - } - if err := tx.QueryRow(ctx, ` -SELECT box_id, private_message_id, message_sender_id -FROM message_boxes -WHERE owner_user_id = $1 - AND box_id = $2 - AND peer_type = $3 - AND peer_id = $4 - AND NOT deleted -LIMIT 1 -FOR UPDATE`, req.UserID, int32(req.MessageID), string(req.Peer.Type), req.Peer.ID).Scan(&target.boxID, &target.privateMessageID, &target.messageSenderID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("get message for reactions: %w", err) - } - if _, err := tx.Exec(ctx, ` -DELETE FROM private_message_reactions -WHERE message_sender_id = $1 - AND private_message_id = $2 - AND user_id = $3`, target.messageSenderID, target.privateMessageID, req.UserID); err != nil { - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("delete old message reactions: %w", err) - } - for i, reaction := range req.Reactions { - if _, err := tx.Exec(ctx, ` -INSERT INTO private_message_reactions ( - message_sender_id, - private_message_id, - user_id, - reaction_type, - reaction_value, - big, - reaction_date, - chosen_order -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -ON CONFLICT (message_sender_id, private_message_id, user_id, reaction_type, reaction_value) -DO UPDATE SET - big = EXCLUDED.big, - reaction_date = EXCLUDED.reaction_date, - chosen_order = EXCLUDED.chosen_order, - updated_at = now()`, - target.messageSenderID, - target.privateMessageID, - req.UserID, - string(reaction.Type), - reaction.Emoticon, - req.Big, - int32(req.Date), - int32(i+1), - ); err != nil { - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("insert message reaction: %w", err) - } - } - if target.messageSenderID != 0 && target.messageSenderID != req.UserID { - if _, err := tx.Exec(ctx, ` -UPDATE message_boxes b -SET reaction_unread = EXISTS ( - SELECT 1 - FROM private_message_reactions r - WHERE r.message_sender_id = b.message_sender_id - AND r.private_message_id = b.private_message_id - AND r.user_id <> b.owner_user_id -) -WHERE b.owner_user_id = $1 - AND b.message_sender_id = $2 - AND b.private_message_id = $3`, target.messageSenderID, target.messageSenderID, target.privateMessageID); err != nil { - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("update private reaction unread: %w", err) - } - if _, err := tx.Exec(ctx, ` -UPDATE dialogs d -SET unread_reactions_count = ( - SELECT COUNT(*)::int - FROM message_boxes m - WHERE m.owner_user_id = d.user_id - AND m.peer_type = d.peer_type - AND m.peer_id = d.peer_id - AND NOT m.deleted - AND m.reaction_unread -), -updated_at = now() -WHERE d.user_id = $1 - AND d.peer_type = $2 - AND d.peer_id = $3`, target.messageSenderID, string(domain.PeerTypeUser), req.UserID); err != nil { - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("refresh private reaction unread dialog: %w", err) - } - } - - boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ - MessageSenderID: target.messageSenderID, - PrivateMessageID: target.privateMessageID, - }) - if err != nil { - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("list visible reaction boxes: %w", err) - } - res := domain.PrivateMessageReactionsResult{Messages: make([]domain.Message, 0, len(boxes))} - for _, box := range boxes { - msg, err := messageFromVisibleBoxRow(box) - if err != nil { - return domain.PrivateMessageReactionsResult{}, err - } - res.Messages = append(res.Messages, msg) - } - if err := s.enrichPrivateMessageReactions(ctx, tx, req.UserID, res.Messages); err != nil { - return domain.PrivateMessageReactionsResult{}, err - } - for _, msg := range res.Messages { - if msg.OwnerUserID == req.UserID && msg.Reactions != nil { - res.Reactions = *msg.Reactions - break - } - } - if err := tx.Commit(ctx); err != nil { - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("commit set message reactions tx: %w", err) - } - committed = true - return res, nil -} - -func (s *MessageStore) GetMessageReactions(ctx context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { - if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - if len(req.IDs) == 0 { - return domain.PrivateMessageReactionsResult{}, nil - } - boxIDs := make([]int32, 0, len(req.IDs)) - for _, id := range req.IDs { - if id <= 0 || id > domain.MaxMessageBoxID { - return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid - } - boxIDs = append(boxIDs, int32(id)) - } - rows, err := s.q.GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{ - OwnerUserID: req.OwnerUserID, - BoxIds: boxIDs, - }) - if err != nil { - return domain.PrivateMessageReactionsResult{}, fmt.Errorf("get message reactions boxes: %w", err) - } - res := domain.PrivateMessageReactionsResult{Messages: make([]domain.Message, 0, len(rows))} - for _, row := range rows { - if row.PeerType != string(req.Peer.Type) || row.PeerID != req.Peer.ID { - continue - } - msg, err := messageFromIDRow(row) - if err != nil { - return domain.PrivateMessageReactionsResult{}, err - } - res.Messages = append(res.Messages, msg) - } - if err := s.enrichPrivateMessageReactions(ctx, s.db, req.OwnerUserID, res.Messages); err != nil { - return domain.PrivateMessageReactionsResult{}, err - } - for _, msg := range res.Messages { - if msg.Reactions != nil { - res.Reactions = *msg.Reactions - break - } - } - return res, nil -} - -func (s *MessageStore) EditMessage(ctx context.Context, req domain.EditMessageRequest) (res domain.EditMessageResult, err error) { - res = domain.EditMessageResult{OwnerUserID: req.OwnerUserID} - if req.OwnerUserID == 0 { - return res, fmt.Errorf("edit message: missing owner user id") - } - if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { - return res, domain.ErrMessageIDInvalid - } - if req.Message == "" { - return res, fmt.Errorf("edit message: empty message") - } - if req.EditDate == 0 { - req.EditDate = int(time.Now().Unix()) - } - entities, err := encodeMessageEntities(req.Entities) - if err != nil { - return res, err - } - beginner, ok := s.db.(txBeginner) - if !ok { - return res, fmt.Errorf("edit message: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return res, fmt.Errorf("begin edit message tx: %w", err) - } - qtx := sqlcgen.New(tx) - committed := false - var reserved []reservedPts - defer func() { - if committed { - return - } - _ = tx.Rollback(ctx) - s.recordPtsGaps(ctx, reserved, req.EditDate) - }() - - // advisory lock 串行化与会话对端的并发写,须在行锁前获取,消除 AB-BA 死锁。 - if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { - return res, fmt.Errorf("lock edit message users: %w", err) - } - - target, err := qtx.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{ - OwnerUserID: req.OwnerUserID, - BoxID: int32(req.ID), - PeerType: string(req.Peer.Type), - PeerID: req.Peer.ID, - }) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return res, domain.ErrMessageIDInvalid - } - return res, fmt.Errorf("get message for edit: %w", err) - } - if !target.Outgoing || target.MessageSenderID != req.OwnerUserID || target.FromUserID != req.OwnerUserID { - return res, domain.ErrMessageAuthorRequired - } - oldEntities, err := decodeMessageEntities(target.EntitiesJson) - if err != nil { - return res, fmt.Errorf("decode target entities: %w", err) - } - if target.Body == req.Message && sameMessageEntities(oldEntities, req.Entities) { - return res, domain.ErrMessageNotModified - } - boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ - MessageSenderID: req.OwnerUserID, - PrivateMessageID: target.PrivateMessageID, - }) - if err != nil { - return res, fmt.Errorf("list visible edit boxes: %w", err) - } - if len(boxes) == 0 { - return res, domain.ErrMessageIDInvalid - } - if err := qtx.UpdatePrivateMessageEdit(ctx, sqlcgen.UpdatePrivateMessageEditParams{ - SenderUserID: req.OwnerUserID, - PrivateMessageID: target.PrivateMessageID, - Body: req.Message, - EntitiesJson: entities, - EditDate: int32(req.EditDate), - }); err != nil { - return res, fmt.Errorf("update private message edit: %w", err) - } - res.Edited = make([]domain.EditedMessageForUser, 0, len(boxes)) - for _, box := range boxes { - pts, err := s.pts.NextPts(ctx, box.OwnerUserID) - if err != nil { - return res, fmt.Errorf("allocate edit message pts: %w", err) - } - reserved = append(reserved, reservedPts{userID: box.OwnerUserID, pts: pts}) - updated, err := qtx.UpdateMessageBoxEdit(ctx, sqlcgen.UpdateMessageBoxEditParams{ - OwnerUserID: box.OwnerUserID, - BoxID: box.BoxID, - Body: req.Message, - EntitiesJson: entities, - EditDate: int32(req.EditDate), - Pts: int32(pts), - }) - if err != nil { - return res, fmt.Errorf("update message box edit: %w", err) - } - msg, err := messageFromUpdateEditRow(updated) - if err != nil { - return res, err - } - event := domain.UpdateEvent{ - UserID: msg.OwnerUserID, - Type: domain.UpdateEventEditMessage, - Pts: msg.Pts, - PtsCount: 1, - Date: req.EditDate, - Message: msg, - } - if err := appendUserUpdateEvent(ctx, qtx, msg.OwnerUserID, event); err != nil { - return res, fmt.Errorf("append edit message event: %w", err) - } - dispatchAuthKeyID := [8]byte{} - dispatchSessionID := int64(0) - if msg.OwnerUserID == req.OwnerUserID { - dispatchAuthKeyID = req.OriginAuthKeyID - dispatchSessionID = req.OriginSessionID - } - if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ - TargetUserID: msg.OwnerUserID, - Pts: int32(pts), - EventType: string(domain.UpdateEventEditMessage), - ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), - ExcludeSessionID: dispatchSessionID, - }); err != nil { - return res, fmt.Errorf("enqueue edit message dispatch: %w", err) - } - res.Edited = append(res.Edited, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) - } - if err := tx.Commit(ctx); err != nil { - return res, fmt.Errorf("commit edit message tx: %w", err) - } - committed = true - return res, nil -} - -func (s *MessageStore) DeleteMessages(ctx context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { - res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} - if req.OwnerUserID == 0 { - return res, fmt.Errorf("delete messages: missing owner user id") - } - ids := normalizeMessageIDs(req.IDs) - if len(ids) == 0 { - return res, nil - } - if len(ids) > domain.MaxDeleteMessageIDs { - return res, fmt.Errorf("delete messages: too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs) - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - beginner, ok := s.db.(txBeginner) - if !ok { - return res, fmt.Errorf("delete messages: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return res, fmt.Errorf("begin delete messages tx: %w", err) - } - qtx := sqlcgen.New(tx) - committed := false - var reserved []reservedPts - defer func() { - if committed { - return - } - _ = tx.Rollback(ctx) - s.recordPtsGaps(ctx, reserved, req.Date) - }() - - // advisory lock 串行化本 owner 的并发写(与 send/edit/read/其它 delete 共享 owner 时串行)。 - // 被删消息的对端是动态的(由删除结果推出),未在此锁定;但 finishDeleteMessagesTx 内 watermark - // 与 dialog rebuild 均按 user_id 升序执行,故两个反向 delete 也不会 AB-BA。 - if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID); err != nil { - return res, fmt.Errorf("lock delete messages user: %w", err) - } - - rows, err := qtx.DeleteMessageBoxesByIDs(ctx, sqlcgen.DeleteMessageBoxesByIDsParams{ - OwnerUserID: req.OwnerUserID, - BoxIds: int32s(ids), - }) - if err != nil { - return res, fmt.Errorf("delete message boxes by ids: %w", err) - } - deleted := deletedRowsFromIDRows(rows) - if req.Revoke && len(deleted) > 0 { - peerRows, err := qtx.DeleteMessageBoxesByPrivateMessages(ctx, privateMessageDeleteParams(deleted)) - if err != nil { - return res, fmt.Errorf("delete revoked private message boxes: %w", err) - } - deleted = append(deleted, deletedRowsFromPrivateRows(peerRows)...) - } - res, reserved, err = s.finishDeleteMessagesTx(ctx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, false) - if err != nil { - return res, err - } - if err := tx.Commit(ctx); err != nil { - return res, fmt.Errorf("commit delete messages tx: %w", err) - } - committed = true - return res, nil -} - -func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { - res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} - if req.OwnerUserID == 0 { - return res, fmt.Errorf("delete history: missing owner user id") - } - if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { - return res, fmt.Errorf("delete history: invalid peer") - } - if req.Date == 0 { - req.Date = int(time.Now().Unix()) - } - beginner, ok := s.db.(txBeginner) - if !ok { - return res, fmt.Errorf("delete history: db does not support transactions") - } - tx, err := beginner.Begin(ctx) - if err != nil { - return res, fmt.Errorf("begin delete history tx: %w", err) - } - qtx := sqlcgen.New(tx) - committed := false - var reserved []reservedPts - defer func() { - if committed { - return - } - _ = tx.Rollback(ctx) - s.recordPtsGaps(ctx, reserved, req.Date) - }() - - // advisory lock 串行化与会话对端的并发写,须在行锁前获取,消除 AB-BA 死锁。 - if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { - return res, fmt.Errorf("lock delete history users: %w", err) - } - - maxID := pgInt32NonNegative(req.MaxID) - rows, err := qtx.DeleteMessageBoxesByPeerBatch(ctx, sqlcgen.DeleteMessageBoxesByPeerBatchParams{ - OwnerUserID: req.OwnerUserID, - PeerType: string(req.Peer.Type), - PeerID: req.Peer.ID, - MaxID: maxID, - LimitCount: int32(domain.MaxDeleteHistoryBatch), - }) - if err != nil { - return res, fmt.Errorf("delete message boxes by peer: %w", err) - } - deleted := deletedRowsFromPeerBatchRows(rows) - if req.Revoke && len(deleted) > 0 { - peerRows, err := qtx.DeleteMessageBoxesByPrivateMessages(ctx, privateMessageDeleteParams(deleted)) - if err != nil { - return res, fmt.Errorf("delete revoked private history boxes: %w", err) - } - deleted = append(deleted, deletedRowsFromPrivateRows(peerRows)...) - } - res, reserved, err = s.finishDeleteMessagesTx(ctx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, req.JustClear) - if err != nil { - return res, err - } - more, err := qtx.HasDeletableMessageBoxByPeer(ctx, sqlcgen.HasDeletableMessageBoxByPeerParams{ - OwnerUserID: req.OwnerUserID, - PeerType: string(req.Peer.Type), - PeerID: req.Peer.ID, - MaxID: maxID, - }) - if err != nil { - return res, fmt.Errorf("check remaining history after delete: %w", err) - } - if more { - res.Offset = 1 - } - if err := tx.Commit(ctx); err != nil { - return res, fmt.Errorf("commit delete history tx: %w", err) - } - committed = true - return res, nil -} - -type deletedBox struct { - ownerUserID int64 - boxID int - privateMessageID int64 - messageSenderID int64 - peer domain.Peer -} - -func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, q *sqlcgen.Queries, ownerUserID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, date int, rows []deletedBox, preserveEmptyDialogs bool) (domain.DeleteMessagesResult, []reservedPts, error) { - res := domain.DeleteMessagesResult{OwnerUserID: ownerUserID} - if len(rows) == 0 { - return res, nil, nil - } - peersByOwner := make(map[int64]map[domain.Peer]struct{}) - idsByOwner := make(map[int64][]int) - for _, row := range rows { - if row.ownerUserID == 0 || row.boxID == 0 { - continue - } - idsByOwner[row.ownerUserID] = append(idsByOwner[row.ownerUserID], row.boxID) - if row.peer.ID != 0 { - if peersByOwner[row.ownerUserID] == nil { - peersByOwner[row.ownerUserID] = make(map[domain.Peer]struct{}) - } - peersByOwner[row.ownerUserID][row.peer] = struct{}{} - } - } - // 按 owner 升序重建 dialog,使两个反向 delete(X 删与 Y 的会话 / Y 删与 X 的会话)以一致顺序 - // 获取 dialog 行锁,配合下方 watermark 的升序推进,彻底避免 delete-delete 之间的 AB-BA 死锁。 - rebuildOwners := make([]int64, 0, len(peersByOwner)) - for userID := range peersByOwner { - rebuildOwners = append(rebuildOwners, userID) - } - sort.Slice(rebuildOwners, func(i, j int) bool { return rebuildOwners[i] < rebuildOwners[j] }) - for _, userID := range rebuildOwners { - for peer := range peersByOwner[userID] { - if err := rebuildDialogAfterMessageDelete(ctx, q, userID, peer, preserveEmptyDialogs); err != nil { - return res, nil, err - } - } - } - - ownerIDs := make([]int64, 0, len(idsByOwner)) - for userID := range idsByOwner { - ownerIDs = append(ownerIDs, userID) - } - sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] }) - - reserved := make([]reservedPts, 0, len(ownerIDs)) - res.Deleted = make([]domain.DeletedMessagesForUser, 0, len(ownerIDs)) - for _, userID := range ownerIDs { - ids := normalizeMessageIDs(idsByOwner[userID]) - if len(ids) == 0 { - continue - } - pts, err := s.nextPtsN(ctx, userID, len(ids)) - if err != nil { - return res, reserved, fmt.Errorf("allocate delete messages pts: %w", err) - } - reserved = append(reserved, reservedPts{userID: userID, pts: pts, count: len(ids)}) - event := domain.UpdateEvent{ - UserID: userID, - Type: domain.UpdateEventDeleteMessages, - Pts: pts, - PtsCount: len(ids), - Date: date, - MessageIDs: ids, - } - if err := appendDeleteMessagesEvent(ctx, q, event); err != nil { - return res, reserved, err - } - dispatchAuthKeyID := [8]byte{} - dispatchSessionID := int64(0) - if userID == ownerUserID { - dispatchAuthKeyID = excludeAuthKeyID - dispatchSessionID = excludeSessionID - } - if err := q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ - TargetUserID: userID, - Pts: int32(pts), - EventType: string(domain.UpdateEventDeleteMessages), - ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), - ExcludeSessionID: dispatchSessionID, - }); err != nil { - return res, reserved, fmt.Errorf("enqueue delete messages dispatch: %w", err) - } - res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{ - UserID: userID, - MessageIDs: ids, - Event: event, - }) - } - return res, reserved, nil -} - -func rebuildDialogAfterMessageDelete(ctx context.Context, q *sqlcgen.Queries, userID int64, peer domain.Peer, preserveEmpty bool) error { - top, err := q.TopVisibleMessageBoxByPeer(ctx, sqlcgen.TopVisibleMessageBoxByPeerParams{ - OwnerUserID: userID, - PeerType: string(peer.Type), - PeerID: peer.ID, - }) - if errors.Is(err, pgx.ErrNoRows) { - if preserveEmpty { - if err := q.ClearDialogAfterHistoryDelete(ctx, sqlcgen.ClearDialogAfterHistoryDeleteParams{ - UserID: userID, - PeerType: string(peer.Type), - PeerID: peer.ID, - }); err != nil { - return fmt.Errorf("clear empty dialog after history delete: %w", err) - } - return nil - } - if err := q.DeleteDialogByPeer(ctx, sqlcgen.DeleteDialogByPeerParams{ - UserID: userID, - PeerType: string(peer.Type), - PeerID: peer.ID, - }); err != nil { - return fmt.Errorf("delete empty dialog after message delete: %w", err) - } - return nil - } - if err != nil { - return fmt.Errorf("load top message after delete: %w", err) - } - if err := q.RefreshDialogAfterMessageDelete(ctx, sqlcgen.RefreshDialogAfterMessageDeleteParams{ - TopMessageID: top.BoxID, - TopMessageDate: top.MessageDate, - UserID: userID, - PeerType: string(peer.Type), - PeerID: peer.ID, - }); err != nil { - return fmt.Errorf("refresh dialog after message delete: %w", err) - } - return nil -} - -func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event domain.UpdateEvent) error { - messageIDs, err := encodeEventMessageIDs(event.MessageIDs) - if err != nil { - return err - } - if event.PtsCount == 0 { - event.PtsCount = len(event.MessageIDs) - } - if event.PtsCount == 0 { - event.PtsCount = 1 - } - if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ - UserID: event.UserID, - Pts: int32(event.Pts), - PtsCount: int32(event.PtsCount), - Date: int32(event.Date), - EventType: string(domain.UpdateEventDeleteMessages), - EventPeers: []byte("[]"), - PeerSettings: []byte("{}"), - MessageIds: messageIDs, - DialogFilter: []byte("{}"), - FilterOrder: []byte("[]"), - FolderPeers: []byte("[]"), - }); err != nil { - return fmt.Errorf("append delete messages event: %w", err) - } - if _, err := advanceContiguousPts(ctx, q, event.UserID); err != nil { - return fmt.Errorf("advance update watermark after delete messages: %w", err) - } - return nil -} - -func deletedRowsFromIDRows(rows []sqlcgen.DeleteMessageBoxesByIDsRow) []deletedBox { - out := make([]deletedBox, 0, len(rows)) - for _, row := range rows { - out = append(out, deletedBox{ - ownerUserID: row.OwnerUserID, - boxID: int(row.BoxID), - privateMessageID: row.PrivateMessageID, - messageSenderID: row.MessageSenderID, - peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - }) - } - return out -} - -func deletedRowsFromPeerRows(rows []sqlcgen.DeleteMessageBoxesByPeerRow) []deletedBox { - out := make([]deletedBox, 0, len(rows)) - for _, row := range rows { - out = append(out, deletedBox{ - ownerUserID: row.OwnerUserID, - boxID: int(row.BoxID), - privateMessageID: row.PrivateMessageID, - messageSenderID: row.MessageSenderID, - peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - }) - } - return out -} - -func deletedRowsFromPeerBatchRows(rows []sqlcgen.DeleteMessageBoxesByPeerBatchRow) []deletedBox { - out := make([]deletedBox, 0, len(rows)) - for _, row := range rows { - out = append(out, deletedBox{ - ownerUserID: row.OwnerUserID, - boxID: int(row.BoxID), - privateMessageID: row.PrivateMessageID, - messageSenderID: row.MessageSenderID, - peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - }) - } - return out -} - -func deletedRowsFromPrivateRows(rows []sqlcgen.DeleteMessageBoxesByPrivateMessagesRow) []deletedBox { - out := make([]deletedBox, 0, len(rows)) - for _, row := range rows { - out = append(out, deletedBox{ - ownerUserID: row.OwnerUserID, - boxID: int(row.BoxID), - privateMessageID: row.PrivateMessageID, - messageSenderID: row.MessageSenderID, - peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - }) - } - return out -} - -func privateMessageDeleteParams(rows []deletedBox) sqlcgen.DeleteMessageBoxesByPrivateMessagesParams { - senderIDs := make([]int64, 0, len(rows)) - privateIDs := make([]int64, 0, len(rows)) - seen := make(map[[2]int64]struct{}, len(rows)) - for _, row := range rows { - key := [2]int64{row.messageSenderID, row.privateMessageID} - if row.messageSenderID == 0 || row.privateMessageID == 0 { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - senderIDs = append(senderIDs, row.messageSenderID) - privateIDs = append(privateIDs, row.privateMessageID) - } - return sqlcgen.DeleteMessageBoxesByPrivateMessagesParams{ - MessageSenderIds: senderIDs, - PrivateMessageIds: privateIDs, - } -} - -func normalizeMessageIDs(ids []int) []int { - if len(ids) == 0 { - return nil - } - out := make([]int, 0, len(ids)) - seen := make(map[int]struct{}, len(ids)) - for _, id := range ids { - if id <= 0 || id > domain.MaxMessageBoxID { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - sort.Ints(out) - return out -} - -func int32s(ids []int) []int32 { - if len(ids) == 0 { - return nil - } - out := make([]int32, 0, len(ids)) - for _, id := range ids { - out = append(out, pgInt32NonNegative(id)) - } - return out -} - -func pgInt32NonNegative(v int) int32 { - if v <= 0 { - return 0 - } - if v > domain.MaxMessageBoxID { - return int32(domain.MaxMessageBoxID) - } - return int32(v) -} - -func pgInt32Bounded(v int) int32 { - if v > domain.MaxMessageBoxID { - return int32(domain.MaxMessageBoxID) - } - if v < -domain.MaxMessageBoxID { - return int32(-domain.MaxMessageBoxID) - } - return int32(v) -} - -func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.Message) error { - boxID := int32(msg.ID) - peerType := string(msg.Peer.Type) - peerID := msg.Peer.ID - if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ - UserID: msg.OwnerUserID, - Pts: int32(msg.Pts), - PtsCount: 1, - Date: int32(msg.Date), - EventType: string(domain.UpdateEventNewMessage), - EventPeers: []byte("[]"), - PeerSettings: []byte("{}"), - MessageIds: []byte("[]"), - DialogFilter: []byte("{}"), - FilterOrder: []byte("[]"), - FolderPeers: []byte("[]"), - MessageBoxID: &boxID, - PeerType: &peerType, - PeerID: &peerID, - }); err != nil { - return fmt.Errorf("append new message event: %w", err) - } - if _, err := advanceContiguousPts(ctx, q, msg.OwnerUserID); err != nil { - return fmt.Errorf("advance update watermark after new message: %w", err) - } - return nil -} - -// lockUsersForUpdate 在事务开始处用事务级 advisory lock 串行化所有涉及指定用户的并发写事务。 -// advisory lock 与行锁处于独立锁空间,且按 user_id 升序获取,因此:① 不会与后续 dialog / -// watermark / box 行锁交叉成跨类型死锁;② 任意两个共享某用户的写事务(send/read/edit/delete 对 -// 收发双方的并发操作)被完全串行化,从根上消除它们在 watermark 与 dialog 行上因加锁顺序相反 -// 导致的 AB-BA 死锁——既包含本次 watermark 优化新引入的(user_update_watermarks FOR UPDATE), -// 也包含 dialog upsert 既有的反向行锁。advisory xact lock 在事务结束自动释放;同对用户本就竞争 -// 这些行(天然串行),不额外降并发,不同用户集合的事务仍并行。**必须在任何行锁之前调用。** -func lockUsersForUpdate(ctx context.Context, tx pgx.Tx, userIDs ...int64) error { - if len(userIDs) == 0 { - return nil - } - unique := make([]int64, 0, len(userIDs)) - seen := make(map[int64]struct{}, len(userIDs)) - for _, id := range userIDs { - if id <= 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - unique = append(unique, id) - } - sort.Slice(unique, func(i, j int) bool { return unique[i] < unique[j] }) - for _, id := range unique { - if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", id); err != nil { - return fmt.Errorf("advisory lock user %d: %w", id, err) - } - } - return nil -} - -type reservedPts struct { - userID int64 - pts int - count int -} - -func (s *MessageStore) recordPtsGaps(ctx context.Context, items []reservedPts, date int) { - for _, item := range items { - count := item.count - if count <= 0 { - count = 1 - } - _ = s.q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ - UserID: item.userID, - Pts: int32(item.pts), - PtsCount: int32(count), - Date: int32(date), - EventType: string(domain.UpdateEventNoop), - EventPeers: []byte("[]"), - PeerSettings: []byte("{}"), - MessageIds: []byte("[]"), - DialogFilter: []byte("{}"), - FilterOrder: []byte("[]"), - FolderPeers: []byte("[]"), - }) - _, _ = advanceContiguousPts(ctx, s.q, item.userID) - } -} - -func (s *MessageStore) nextPtsN(ctx context.Context, userID int64, count int) (int, error) { - if count <= 0 { - count = 1 - } - if count == 1 { - return s.pts.NextPts(ctx, userID) - } - if ranges, ok := s.pts.(store.PtsRangeAllocator); ok { - return ranges.NextPtsN(ctx, userID, count) - } - var pts int - var err error - for i := 0; i < count; i++ { - pts, err = s.pts.NextPts(ctx, userID) - if err != nil { - return 0, err - } - } - return pts, nil -} - -type messageMetadataParams struct { - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJSON []byte - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 -} - -func messageMetadataParamsFrom(silent, noforwards bool, reply *domain.MessageReply, forward *domain.MessageForward) (messageMetadataParams, error) { - meta := messageMetadataParams{ - Silent: silent, - Noforwards: noforwards, - QuoteEntitiesJSON: []byte("[]"), - } - if reply != nil { - if err := domain.ValidateMessageReplyBounds(reply); err != nil { - return messageMetadataParams{}, err - } - quoteEntities, err := encodeMessageEntities(reply.QuoteEntities) - if err != nil { - return messageMetadataParams{}, err - } - meta.ReplyToMsgID = int32(reply.MessageID) - meta.ReplyToPeerType = string(reply.Peer.Type) - meta.ReplyToPeerID = reply.Peer.ID - meta.ReplyToTopID = int32(reply.TopMessageID) - meta.QuoteText = reply.QuoteText - meta.QuoteEntitiesJSON = quoteEntities - meta.QuoteOffset = int32(reply.QuoteOffset) - } - if forward != nil { - if forward.Date < 0 { - return messageMetadataParams{}, fmt.Errorf("forward metadata: invalid date") - } - meta.FwdFromPeerType = string(forward.From.Type) - meta.FwdFromPeerID = forward.From.ID - meta.FwdFromName = forward.FromName - meta.FwdDate = int32(forward.Date) - } - return meta, nil -} - -func applyCreatePrivateMessageMetadata(arg *sqlcgen.CreatePrivateMessageParams, meta messageMetadataParams) { - arg.Silent = meta.Silent - arg.Noforwards = meta.Noforwards - arg.ReplyToMsgID = meta.ReplyToMsgID - arg.ReplyToPeerType = meta.ReplyToPeerType - arg.ReplyToPeerID = meta.ReplyToPeerID - arg.ReplyToTopID = meta.ReplyToTopID - arg.QuoteText = meta.QuoteText - arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON - arg.QuoteOffset = meta.QuoteOffset - arg.FwdFromPeerType = meta.FwdFromPeerType - arg.FwdFromPeerID = meta.FwdFromPeerID - arg.FwdFromName = meta.FwdFromName - arg.FwdDate = meta.FwdDate -} - -func applyCreateMessageBoxMetadata(arg *sqlcgen.CreateMessageBoxParams, meta messageMetadataParams) { - arg.Silent = meta.Silent - arg.Noforwards = meta.Noforwards - arg.ReplyToMsgID = meta.ReplyToMsgID - arg.ReplyToPeerType = meta.ReplyToPeerType - arg.ReplyToPeerID = meta.ReplyToPeerID - arg.ReplyToTopID = meta.ReplyToTopID - arg.QuoteText = meta.QuoteText - arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON - arg.QuoteOffset = meta.QuoteOffset - arg.FwdFromPeerType = meta.FwdFromPeerType - arg.FwdFromPeerID = meta.FwdFromPeerID - arg.FwdFromName = meta.FwdFromName - arg.FwdDate = meta.FwdDate -} - -func messageMetadataFromFields(silent, noforwards bool, replyToMsgID int32, replyToPeerType string, replyToPeerID int64, replyToTopID int32, quoteText, quoteEntitiesJSON string, quoteOffset int32, fwdFromPeerType string, fwdFromPeerID int64, fwdFromName string, fwdDate int32) (bool, bool, *domain.MessageReply, *domain.MessageForward, error) { - var reply *domain.MessageReply - if replyToMsgID > 0 { - quoteEntities, err := decodeMessageEntities(quoteEntitiesJSON) - if err != nil { - return false, false, nil, nil, err - } - reply = &domain.MessageReply{ - MessageID: int(replyToMsgID), - Peer: domain.Peer{Type: domain.PeerType(replyToPeerType), ID: replyToPeerID}, - TopMessageID: int(replyToTopID), - QuoteText: quoteText, - QuoteEntities: quoteEntities, - QuoteOffset: int(quoteOffset), - } - } - var forward *domain.MessageForward - if fwdDate != 0 || fwdFromPeerID != 0 || fwdFromName != "" { - forward = &domain.MessageForward{ - From: domain.Peer{Type: domain.PeerType(fwdFromPeerType), ID: fwdFromPeerID}, - FromName: fwdFromName, - Date: int(fwdDate), - } - } - return silent, noforwards, reply, forward, nil -} - -func messageFromCreateRow(row sqlcgen.CreateMessageRow) (domain.Message, error) { - entities, err := decodeMessageEntities(row.EntitiesJson) - if err != nil { - return domain.Message{}, fmt.Errorf("decode message entities: %w", err) - } - return domain.Message{ - ID: int(row.BoxID), - UID: row.PrivateMessageID, - OwnerUserID: row.OwnerUserID, - Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, - Date: int(row.MessageDate), - EditDate: int(row.EditDate), - Out: row.Outgoing, - Body: row.Body, - Entities: entities, - Pts: int(row.Pts), - }, nil -} - -func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message { - entities, _ := decodeMessageEntities(row.EntitiesJson) - media, _ := decodeMessageMedia(row.MediaJson) - silent, noforwards, reply, forward, _ := messageMetadataFromFields( - row.Silent, - row.Noforwards, - row.ReplyToMsgID, - row.ReplyToPeerType, - row.ReplyToPeerID, - row.ReplyToTopID, - row.QuoteText, - row.QuoteEntitiesJson, - row.QuoteOffset, - row.FwdFromPeerType, - row.FwdFromPeerID, - row.FwdFromName, - row.FwdDate, - ) - return domain.Message{ - Media: media, - ID: int(row.BoxID), - UID: row.PrivateMessageID, - OwnerUserID: row.OwnerUserID, - Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, - Date: int(row.MessageDate), - EditDate: int(row.EditDate), - Out: row.Outgoing, - Silent: silent, - NoForwards: noforwards, - Body: row.Body, - Entities: entities, - ReplyTo: reply, - Forward: forward, - Pts: int(row.Pts), - MediaUnread: row.MediaUnread, - ReactionUnread: row.ReactionUnread, - } -} - -func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.Message { - entities, _ := decodeMessageEntities(row.EntitiesJson) - media, _ := decodeMessageMedia(row.MediaJson) - silent, noforwards, reply, forward, _ := messageMetadataFromFields( - row.Silent, - row.Noforwards, - row.ReplyToMsgID, - row.ReplyToPeerType, - row.ReplyToPeerID, - row.ReplyToTopID, - row.QuoteText, - row.QuoteEntitiesJson, - row.QuoteOffset, - row.FwdFromPeerType, - row.FwdFromPeerID, - row.FwdFromName, - row.FwdDate, - ) - return domain.Message{ - Media: media, - ID: int(row.BoxID), - UID: row.PrivateMessageID, - OwnerUserID: row.OwnerUserID, - Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, - Date: int(row.MessageDate), - EditDate: int(row.EditDate), - Out: row.Outgoing, - Silent: silent, - NoForwards: noforwards, - Body: row.Body, - Entities: entities, - ReplyTo: reply, - Forward: forward, - Pts: int(row.Pts), - MediaUnread: row.MediaUnread, - ReactionUnread: row.ReactionUnread, - } -} - -func messageFromVisibleBoxRow(row sqlcgen.ListVisibleMessageBoxesByPrivateMessageRow) (domain.Message, error) { - entities, err := decodeMessageEntities(row.EntitiesJson) - if err != nil { - return domain.Message{}, fmt.Errorf("decode visible message entities: %w", err) - } - silent, noforwards, reply, forward, err := messageMetadataFromFields( - row.Silent, - row.Noforwards, - row.ReplyToMsgID, - row.ReplyToPeerType, - row.ReplyToPeerID, - row.ReplyToTopID, - row.QuoteText, - row.QuoteEntitiesJson, - row.QuoteOffset, - row.FwdFromPeerType, - row.FwdFromPeerID, - row.FwdFromName, - row.FwdDate, - ) - if err != nil { - return domain.Message{}, fmt.Errorf("decode visible message metadata: %w", err) - } - media, err := decodeMessageMedia(row.MediaJson) - if err != nil { - return domain.Message{}, fmt.Errorf("decode visible message media: %w", err) - } - return domain.Message{ - Media: media, - ID: int(row.BoxID), - UID: row.PrivateMessageID, - OwnerUserID: row.OwnerUserID, - Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, - Date: int(row.MessageDate), - EditDate: int(row.EditDate), - Out: row.Outgoing, - Silent: silent, - NoForwards: noforwards, - Body: row.Body, - Entities: entities, - ReplyTo: reply, - Forward: forward, - Pts: int(row.Pts), - MediaUnread: row.MediaUnread, - ReactionUnread: row.ReactionUnread, - }, nil -} - -func messageFromUpdateEditRow(row sqlcgen.UpdateMessageBoxEditRow) (domain.Message, error) { - entities, err := decodeMessageEntities(row.EntitiesJson) - if err != nil { - return domain.Message{}, fmt.Errorf("decode edited message entities: %w", err) - } - silent, noforwards, reply, forward, err := messageMetadataFromFields( - row.Silent, - row.Noforwards, - row.ReplyToMsgID, - row.ReplyToPeerType, - row.ReplyToPeerID, - row.ReplyToTopID, - row.QuoteText, - row.QuoteEntitiesJson, - row.QuoteOffset, - row.FwdFromPeerType, - row.FwdFromPeerID, - row.FwdFromName, - row.FwdDate, - ) - if err != nil { - return domain.Message{}, fmt.Errorf("decode edited message metadata: %w", err) - } - media, err := decodeMessageMedia(row.MediaJson) - if err != nil { - return domain.Message{}, fmt.Errorf("decode edited message media: %w", err) - } - return domain.Message{ - Media: media, - ID: int(row.BoxID), - UID: row.PrivateMessageID, - OwnerUserID: row.OwnerUserID, - Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, - Date: int(row.MessageDate), - EditDate: int(row.EditDate), - Out: row.Outgoing, - Silent: silent, - NoForwards: noforwards, - Body: row.Body, - Entities: entities, - ReplyTo: reply, - Forward: forward, - Pts: int(row.Pts), - MediaUnread: row.MediaUnread, - ReactionUnread: row.ReactionUnread, - }, nil -} - -func messageFromForwardRow(row sqlcgen.GetMessageBoxesForForwardRow) (domain.Message, error) { - entities, err := decodeMessageEntities(row.EntitiesJson) - if err != nil { - return domain.Message{}, fmt.Errorf("decode forward message entities: %w", err) - } - silent, noforwards, reply, forward, err := messageMetadataFromFields( - row.Silent, - row.Noforwards, - row.ReplyToMsgID, - row.ReplyToPeerType, - row.ReplyToPeerID, - row.ReplyToTopID, - row.QuoteText, - row.QuoteEntitiesJson, - row.QuoteOffset, - row.FwdFromPeerType, - row.FwdFromPeerID, - row.FwdFromName, - row.FwdDate, - ) - if err != nil { - return domain.Message{}, fmt.Errorf("decode forward message metadata: %w", err) - } - media, err := decodeMessageMedia(row.MediaJson) - if err != nil { - return domain.Message{}, fmt.Errorf("decode forward message media: %w", err) - } - return domain.Message{ - Media: media, - ID: int(row.BoxID), - UID: row.PrivateMessageID, - OwnerUserID: row.OwnerUserID, - Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, - Date: int(row.MessageDate), - EditDate: int(row.EditDate), - Out: row.Outgoing, - Silent: silent, - NoForwards: noforwards, - Body: row.Body, - Entities: entities, - ReplyTo: reply, - Forward: forward, - Pts: int(row.Pts), - MediaUnread: row.MediaUnread, - ReactionUnread: row.ReactionUnread, - }, nil -} - -func messageFromIDRow(row sqlcgen.GetMessageBoxesByIDsRow) (domain.Message, error) { - entities, err := decodeMessageEntities(row.EntitiesJson) - if err != nil { - return domain.Message{}, fmt.Errorf("decode message entities: %w", err) - } - silent, noforwards, reply, forward, err := messageMetadataFromFields( - row.Silent, - row.Noforwards, - row.ReplyToMsgID, - row.ReplyToPeerType, - row.ReplyToPeerID, - row.ReplyToTopID, - row.QuoteText, - row.QuoteEntitiesJson, - row.QuoteOffset, - row.FwdFromPeerType, - row.FwdFromPeerID, - row.FwdFromName, - row.FwdDate, - ) - if err != nil { - return domain.Message{}, fmt.Errorf("decode message metadata: %w", err) - } - media, err := decodeMessageMedia(row.MediaJson) - if err != nil { - return domain.Message{}, fmt.Errorf("decode message media: %w", err) - } - return domain.Message{ - Media: media, - ID: int(row.BoxID), - UID: row.PrivateMessageID, - OwnerUserID: row.OwnerUserID, - Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, - From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, - Date: int(row.MessageDate), - EditDate: int(row.EditDate), - Out: row.Outgoing, - Silent: silent, - NoForwards: noforwards, - Body: row.Body, - Entities: entities, - ReplyTo: reply, - Forward: forward, - Pts: int(row.Pts), - MediaUnread: row.MediaUnread, - ReactionUnread: row.ReactionUnread, - }, nil -} - -func eventFromMessage(msg domain.Message) domain.UpdateEvent { - return domain.UpdateEvent{ - UserID: msg.OwnerUserID, - Type: domain.UpdateEventNewMessage, - Pts: msg.Pts, - PtsCount: 1, - Date: msg.Date, - Message: msg, - } -} - -func appendUserFromMessageRow(out *domain.MessageList, seen map[int64]struct{}, row sqlcgen.ListMessagesByUserRow) { - appendMessageUsers(out, seen, - domain.User{ - ID: row.PeerUserID, - AccessHash: row.PeerAccessHash, - Phone: row.PeerPhone, - FirstName: row.PeerFirstName, - LastName: row.PeerLastName, - Username: row.PeerUsername, - CountryCode: row.PeerCountryCode, - Verified: row.PeerVerified, - Support: row.PeerSupport, - LastSeenAt: int(row.PeerLastSeenAt), - }, - domain.User{ - ID: row.FromUserUserID, - AccessHash: row.FromUserAccessHash, - Phone: row.FromUserPhone, - FirstName: row.FromUserFirstName, - LastName: row.FromUserLastName, - Username: row.FromUserUsername, - CountryCode: row.FromUserCountryCode, - Verified: row.FromUserVerified, - Support: row.FromUserSupport, - LastSeenAt: int(row.FromUserLastSeenAt), - }, - ) -} - -func appendUsersFromMessageIDRow(out *domain.MessageList, seen map[int64]struct{}, row sqlcgen.GetMessageBoxesByIDsRow) { - appendMessageUsers(out, seen, - domain.User{ - ID: row.PeerUserID, - AccessHash: row.PeerAccessHash, - Phone: row.PeerPhone, - FirstName: row.PeerFirstName, - LastName: row.PeerLastName, - Username: row.PeerUsername, - CountryCode: row.PeerCountryCode, - Verified: row.PeerVerified, - Support: row.PeerSupport, - LastSeenAt: int(row.PeerLastSeenAt), - }, - domain.User{ - ID: row.FromUserUserID, - AccessHash: row.FromUserAccessHash, - Phone: row.FromUserPhone, - FirstName: row.FromUserFirstName, - LastName: row.FromUserLastName, - Username: row.FromUserUsername, - CountryCode: row.FromUserCountryCode, - Verified: row.FromUserVerified, - Support: row.FromUserSupport, - LastSeenAt: int(row.FromUserLastSeenAt), - }, - ) -} - -func appendMessageUsers(out *domain.MessageList, seen map[int64]struct{}, users ...domain.User) { - add := func(u domain.User) { - if u.ID == 0 { - return - } - if _, ok := seen[u.ID]; ok { - return - } - seen[u.ID] = struct{}{} - out.Users = append(out.Users, u) - } - for _, user := range users { - add(user) - } -} - -type privateMessageReactionRow struct { - messageSenderID int64 - privateMessageID int64 - userID int64 - reaction domain.MessageReaction - big bool - date int - chosenOrder int -} - -type privateMessageReactionKey struct { - messageSenderID int64 - privateMessageID int64 -} - -func (s *MessageStore) enrichPrivateMessageReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, messages []domain.Message) error { - if len(messages) == 0 { - return nil - } - keySet := make(map[privateMessageReactionKey]struct{}, len(messages)) - senderIDs := make([]int64, 0, len(messages)) - privateIDs := make([]int64, 0, len(messages)) - for _, msg := range messages { - if msg.UID == 0 || msg.From.ID == 0 { - continue - } - key := privateMessageReactionKey{messageSenderID: msg.From.ID, privateMessageID: msg.UID} - if _, ok := keySet[key]; ok { - continue - } - keySet[key] = struct{}{} - senderIDs = append(senderIDs, key.messageSenderID) - privateIDs = append(privateIDs, key.privateMessageID) - } - if len(senderIDs) == 0 { - return nil - } - rows, err := db.Query(ctx, ` -WITH wanted AS ( - SELECT message_sender_id, private_message_id - FROM unnest($1::bigint[], $2::bigint[]) AS w(message_sender_id, private_message_id) -) -SELECT r.message_sender_id, r.private_message_id, r.user_id, r.reaction_type, r.reaction_value, r.big, r.reaction_date, r.chosen_order -FROM private_message_reactions r -JOIN wanted w - ON w.message_sender_id = r.message_sender_id - AND w.private_message_id = r.private_message_id -ORDER BY r.message_sender_id ASC, r.private_message_id ASC, r.reaction_date DESC, r.user_id DESC, r.reaction_value ASC`, senderIDs, privateIDs) - if err != nil { - return fmt.Errorf("load private message reactions: %w", err) - } - defer rows.Close() - byMessage := make(map[privateMessageReactionKey][]privateMessageReactionRow) - for rows.Next() { - var ( - messageSenderID int64 - uid int64 - userID int64 - reactionType string - value string - big bool - date int32 - chosenOrder int32 - ) - if err := rows.Scan(&messageSenderID, &uid, &userID, &reactionType, &value, &big, &date, &chosenOrder); err != nil { - return fmt.Errorf("scan private message reactions: %w", err) - } - if reactionType != string(domain.MessageReactionEmoji) || strings.TrimSpace(value) == "" { - continue - } - key := privateMessageReactionKey{messageSenderID: messageSenderID, privateMessageID: uid} - byMessage[key] = append(byMessage[key], privateMessageReactionRow{ - messageSenderID: messageSenderID, - privateMessageID: uid, - userID: userID, - reaction: domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: value}, - big: big, - date: int(date), - chosenOrder: int(chosenOrder), - }) - } - if err := rows.Err(); err != nil { - return fmt.Errorf("private message reactions rows: %w", err) - } - for i := range messages { - key := privateMessageReactionKey{messageSenderID: messages[i].From.ID, privateMessageID: messages[i].UID} - reactions := privateMessageReactionsFromRows(byMessage[key], viewerUserID) - if len(reactions.Results) == 0 && len(reactions.Recent) == 0 { - continue - } - messages[i].Reactions = &reactions - } - return nil -} - -func privateMessageReactionsFromRows(rows []privateMessageReactionRow, viewerUserID int64) domain.ChannelMessageReactions { - out := domain.ChannelMessageReactions{ - CanSeeList: true, - Results: []domain.ChannelMessageReactionCount{}, - Recent: []domain.ChannelMessagePeerReaction{}, - } - if len(rows) == 0 { - return out - } - type aggregate struct { - reaction domain.MessageReaction - count int - chosenOrder int - latestDate int - } - aggregates := make(map[string]*aggregate) - recent := make([]domain.ChannelMessagePeerReaction, 0, len(rows)) - for _, row := range rows { - key := string(row.reaction.Type) + "\x00" + row.reaction.Emoticon - item := aggregates[key] - if item == nil { - item = &aggregate{reaction: row.reaction} - aggregates[key] = item - } - item.count++ - if row.userID == viewerUserID && row.chosenOrder > 0 && (item.chosenOrder == 0 || row.chosenOrder < item.chosenOrder) { - item.chosenOrder = row.chosenOrder - } - if row.date > item.latestDate { - item.latestDate = row.date - } - recent = append(recent, domain.ChannelMessagePeerReaction{ - UserID: row.userID, - Reaction: row.reaction, - Big: row.big, - My: row.userID == viewerUserID, - ChosenOrder: row.chosenOrder, - Date: row.date, - }) - } - items := make([]aggregate, 0, len(aggregates)) - for _, item := range aggregates { - items = append(items, *item) - } - sort.Slice(items, func(i, j int) bool { - if items[i].count != items[j].count { - return items[i].count > items[j].count - } - if items[i].latestDate != items[j].latestDate { - return items[i].latestDate > items[j].latestDate - } - return items[i].reaction.Emoticon < items[j].reaction.Emoticon - }) - for _, item := range items { - out.Results = append(out.Results, domain.ChannelMessageReactionCount{ - Reaction: item.reaction, - Count: item.count, - ChosenOrder: item.chosenOrder, - }) - } - sort.Slice(recent, func(i, j int) bool { - if recent[i].Date != recent[j].Date { - return recent[i].Date > recent[j].Date - } - if recent[i].UserID != recent[j].UserID { - return recent[i].UserID > recent[j].UserID - } - return recent[i].Reaction.Emoticon < recent[j].Reaction.Emoticon - }) - if len(recent) > domain.MaxChannelMessageReactionRecent { - recent = recent[:domain.MaxChannelMessageReactionRecent] - } - out.Recent = recent - return out -} - -type pgBoxIDAllocator struct { - s *MessageStore -} - -func (a pgBoxIDAllocator) NextBoxID(ctx context.Context, userID int64) (int, error) { - cur, err := a.CurrentBoxID(ctx, userID) - if err != nil { - return 0, err - } - return cur + 1, nil -} - -func (a pgBoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, error) { - v, err := a.s.q.MaxMessageBoxID(ctx, userID) - if err != nil { - return 0, err - } - return int(v), nil -} - -type pgPtsAllocator struct { - events *UpdateEventStore -} - -func (a pgPtsAllocator) NextPts(ctx context.Context, userID int64) (int, error) { - cur, err := a.CurrentPts(ctx, userID) - if err != nil { - return 0, err - } - return cur + 1, nil -} - -func (a pgPtsAllocator) NextPtsN(ctx context.Context, userID int64, count int) (int, error) { - if count <= 0 { - count = 1 - } - cur, err := a.CurrentPts(ctx, userID) - if err != nil { - return 0, err - } - return cur + count, nil -} - -func (a pgPtsAllocator) CurrentPts(ctx context.Context, userID int64) (int, error) { - return a.events.Current(ctx, userID) -} - -type messageEntityJSON struct { - Type string `json:"type"` - Offset int `json:"offset"` - Length int `json:"length"` -} - -func encodeMessageEntities(entities []domain.MessageEntity) ([]byte, error) { - if len(entities) == 0 { - return []byte("[]"), nil - } - wire := make([]messageEntityJSON, 0, len(entities)) - for _, entity := range entities { - wire = append(wire, messageEntityJSON{ - Type: string(entity.Type), - Offset: entity.Offset, - Length: entity.Length, - }) - } - raw, err := json.Marshal(wire) - if err != nil { - return nil, fmt.Errorf("marshal message entities: %w", err) - } - return raw, nil -} - -func decodeMessageEntities(raw string) ([]domain.MessageEntity, error) { - if raw == "" { - return nil, nil - } - var wire []messageEntityJSON - if err := json.Unmarshal([]byte(raw), &wire); err != nil { - return nil, err - } - out := make([]domain.MessageEntity, 0, len(wire)) - for _, entity := range wire { - out = append(out, domain.MessageEntity{ - Type: domain.MessageEntityType(entity.Type), - Offset: entity.Offset, - Length: entity.Length, - }) - } - return out, nil -} - -func sameMessageEntities(a, b []domain.MessageEntity) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func messageListHash(messages []domain.Message) int64 { - if len(messages) == 0 { - return 0 - } - h := fnv.New64a() - var buf [24]byte - for _, msg := range messages { - binary.LittleEndian.PutUint32(buf[:4], uint32(msg.ID)) - binary.LittleEndian.PutUint32(buf[4:8], uint32(msg.Date)) - binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.From.ID)) - binary.LittleEndian.PutUint64(buf[16:24], uint64(msg.UID)) - _, _ = h.Write(buf[:]) - writeMessageReactionsHash(h, msg.Reactions) - } - return int64(h.Sum64()) -} - -func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageReactions) { - if reactions == nil { - _, _ = h.Write([]byte{0}) - return - } - var buf [16]byte - for _, item := range reactions.Results { - _, _ = h.Write([]byte(item.Reaction.Type)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(item.Reaction.Emoticon)) - _, _ = h.Write([]byte{0}) - binary.LittleEndian.PutUint32(buf[:4], uint32(item.Count)) - binary.LittleEndian.PutUint32(buf[4:8], uint32(item.ChosenOrder)) - _, _ = h.Write(buf[:8]) - } - _, _ = h.Write([]byte{0xfe}) - for _, item := range reactions.Recent { - _, _ = h.Write([]byte(item.Reaction.Type)) - _, _ = h.Write([]byte{0}) - _, _ = h.Write([]byte(item.Reaction.Emoticon)) - _, _ = h.Write([]byte{0}) - binary.LittleEndian.PutUint64(buf[:8], uint64(item.UserID)) - binary.LittleEndian.PutUint32(buf[8:12], uint32(item.Date)) - binary.LittleEndian.PutUint32(buf[12:16], uint32(item.ChosenOrder)) - _, _ = h.Write(buf[:]) - } -} diff --git a/internal/store/postgres/message_allocators.go b/internal/store/postgres/message_allocators.go new file mode 100644 index 00000000..7d9e902c --- /dev/null +++ b/internal/store/postgres/message_allocators.go @@ -0,0 +1,42 @@ +package postgres + +import ( + "context" + + "go.uber.org/zap" + + "telesrv/internal/store/postgres/sqlcgen" +) + +func (s *MessageStore) reservePts(ctx context.Context, db sqlcgen.DBTX, userID int64) (int, error) { + return s.reservePtsN(ctx, db, userID, 1) +} + +func (s *MessageStore) reservePtsN(ctx context.Context, db sqlcgen.DBTX, userID int64, count int) (int, error) { + count = normalizePtsCount(count) + caller := traceCaller(2) + pts, err := reserveUserPts(ctx, db, userID, count) + if err != nil { + s.log.Warn("pts_reserve_failed", + zap.String("scope", "user"), + zap.Int64("user_id", userID), + zap.Int("pts_count", count), + zap.String("caller", caller), + zap.Error(err), + zap.Error(ctx.Err()), + ) + return 0, err + } + s.log.Debug("pts_reserve", + zap.String("scope", "user"), + zap.Int64("user_id", userID), + zap.Int("pts", pts), + zap.Int("pts_count", count), + zap.String("caller", caller), + ) + return pts, nil +} + +type pgBoxIDAllocator struct { + s *MessageStore +} diff --git a/internal/store/postgres/message_concurrent_integration_test.go b/internal/store/postgres/message_concurrent_integration_test.go index 26862339..fd20f983 100644 --- a/internal/store/postgres/message_concurrent_integration_test.go +++ b/internal/store/postgres/message_concurrent_integration_test.go @@ -9,11 +9,10 @@ import ( "telesrv/internal/domain" ) -// TestSendPrivateTextConcurrentNoPtsGap 是「Redis 分配移出事务」的正确性核心证明: +// TestSendPrivateTextConcurrentNoPtsGap 是「PG 事务内分配 pts」的正确性核心证明: // N 条消息并发 sender→recipient 发送,全部成功提交后,接收方账号事件 pts 必须严格连续 1..N // (无空洞、无重复、无丢失),且 MaxContiguousPts == N。 -// 用 perUserCounterAllocator(mutex 单调自增,忠实复刻 Redis INCR 原子性)驱动分配器, -// 验证事务外分配 + 连续 pts 兜底在高并发下不丢消息,不引入 Redis 依赖。 +// box id 仍可由外部计数器提供,pts 则由 user_update_watermarks 在同一 PG tx 内推进。 func TestSendPrivateTextConcurrentNoPtsGap(t *testing.T) { pool := testPool(t) ctx := context.Background() @@ -47,7 +46,7 @@ func TestSendPrivateTextConcurrentNoPtsGap(t *testing.T) { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) }) - messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{})) + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) const n = 200 base := time.Now().UnixNano() diff --git a/internal/store/postgres/message_deadlock_integration_test.go b/internal/store/postgres/message_deadlock_integration_test.go index 7ea30176..11923d7d 100644 --- a/internal/store/postgres/message_deadlock_integration_test.go +++ b/internal/store/postgres/message_deadlock_integration_test.go @@ -45,7 +45,7 @@ func TestMessageStoreBidirectionalConcurrencyNoDeadlock(t *testing.T) { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) }) - messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{})) + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) date := int(time.Now().Unix()) var ridMu sync.Mutex @@ -117,3 +117,103 @@ func TestMessageStoreBidirectionalConcurrencyNoDeadlock(t *testing.T) { t.Fatalf("%d/%d 反向并发操作失败", failed, rounds*4) } } + +func TestMessageStoreBidirectionalRevokeDeleteNoDeadlock(t *testing.T) { + pool := testPool(t) + baseCtx := context.Background() + ctx, cancel := context.WithTimeout(baseCtx, 20*time.Second) + defer cancel() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + a, err := users.Create(baseCtx, domain.User{AccessHash: 73, Phone: "+1997" + suffix + "11", FirstName: "DeleteA"}) + if err != nil { + t.Fatalf("create a: %v", err) + } + b, err := users.Create(baseCtx, domain.User{AccessHash: 74, Phone: "+1997" + suffix + "12", FirstName: "DeleteB"}) + if err != nil { + t.Fatalf("create b: %v", err) + } + ids := []int64{a.ID, b.ID} + t.Cleanup(func() { + _, _ = pool.Exec(baseCtx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(baseCtx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(baseCtx, "DELETE FROM user_update_watermarks WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(baseCtx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(baseCtx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(baseCtx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(baseCtx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) + date := int(time.Now().Unix()) + type pair struct { + senderBoxID int + recipientBoxID int + } + const count = 60 + pairs := make([]pair, 0, count) + for i := 0; i < count; i++ { + sent, err := messages.SendPrivateText(baseCtx, domain.SendPrivateTextRequest{ + SenderUserID: a.ID, + RecipientUserID: b.ID, + RandomID: time.Now().UnixNano() + int64(i), + Message: "revoke-delete", + Date: date, + }) + if err != nil { + t.Fatalf("seed message %d: %v", i, err) + } + pairs = append(pairs, pair{senderBoxID: sent.SenderMessage.ID, recipientBoxID: sent.RecipientMessage.ID}) + } + + var wg sync.WaitGroup + errCh := make(chan error, count*2) + sem := make(chan struct{}, 24) + submit := func(op func() error) { + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + if e := op(); e != nil { + errCh <- e + } + }() + } + + for _, p := range pairs { + p := p + submit(func() error { + _, e := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ + OwnerUserID: a.ID, + IDs: []int{p.senderBoxID}, + Revoke: true, + Date: date, + }) + return e + }) + submit(func() error { + _, e := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ + OwnerUserID: b.ID, + IDs: []int{p.recipientBoxID}, + Revoke: true, + Date: date, + }) + return e + }) + } + wg.Wait() + close(errCh) + + failed := 0 + for e := range errCh { + failed++ + if failed <= 5 { + t.Errorf("双端 revoke delete 失败(疑似死锁回归): %v", e) + } + } + if failed > 0 { + t.Fatalf("%d/%d 双端 revoke delete 操作失败", failed, count*2) + } +} diff --git a/internal/store/postgres/message_delete.go b/internal/store/postgres/message_delete.go new file mode 100644 index 00000000..fc20dfba --- /dev/null +++ b/internal/store/postgres/message_delete.go @@ -0,0 +1,431 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "sort" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" + "time" +) + +func (s *MessageStore) DeleteMessages(ctx context.Context, req domain.DeleteMessagesRequest) (domain.DeleteMessagesResult, error) { + res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("delete messages: missing owner user id") + } + ids := normalizeMessageIDs(req.IDs) + if len(ids) == 0 { + return res, nil + } + if len(ids) > domain.MaxDeleteMessageIDs { + return res, fmt.Errorf("delete messages: too many ids: %d > %d", len(ids), domain.MaxDeleteMessageIDs) + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + lockUserIDs := []int64{req.OwnerUserID} + if req.Revoke { + peers, err := s.revokeDeleteLockPeers(ctx, req.OwnerUserID, ids) + if err != nil { + return res, fmt.Errorf("load delete revoke peers: %w", err) + } + lockUserIDs = append(lockUserIDs, peers...) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("delete messages: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin delete messages tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + }() + + // advisory lock 串行化 owner 以及 revoke 会影响的私聊对端;必须在任何行锁前获取。 + if err := lockUsersForUpdate(ctx, tx, lockUserIDs...); err != nil { + return res, fmt.Errorf("lock delete messages user: %w", err) + } + + rows, err := qtx.DeleteMessageBoxesByIDs(ctx, sqlcgen.DeleteMessageBoxesByIDsParams{ + OwnerUserID: req.OwnerUserID, + BoxIds: int32s(ids), + }) + if err != nil { + return res, fmt.Errorf("delete message boxes by ids: %w", err) + } + deleted := deletedRowsFromIDRows(rows) + if req.Revoke && len(deleted) > 0 { + peerRows, err := qtx.DeleteMessageBoxesByPrivateMessages(ctx, privateMessageDeleteParams(deleted)) + if err != nil { + return res, fmt.Errorf("delete revoked private message boxes: %w", err) + } + deleted = append(deleted, deletedRowsFromPrivateRows(peerRows)...) + } + res, err = s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, false) + if err != nil { + return res, err + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit delete messages tx: %w", err) + } + committed = true + return res, nil +} + +func (s *MessageStore) revokeDeleteLockPeers(ctx context.Context, ownerUserID int64, ids []int) ([]int64, error) { + rows, err := s.q.GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{ + OwnerUserID: ownerUserID, + BoxIds: int32s(ids), + }) + if err != nil { + return nil, err + } + seen := make(map[int64]struct{}, len(rows)) + out := make([]int64, 0, len(rows)) + for _, row := range rows { + if row.PeerType != string(domain.PeerTypeUser) || row.PeerID == 0 || row.PeerID == ownerUserID { + continue + } + if _, ok := seen[row.PeerID]; ok { + continue + } + seen[row.PeerID] = struct{}{} + out = append(out, row.PeerID) + } + return out, nil +} + +type deletedBox struct { + ownerUserID int64 + boxID int + privateMessageID int64 + messageSenderID int64 + peer domain.Peer +} + +type deletedOwnerPeerKey struct { + userID int64 + peer domain.Peer +} + +func (s *MessageStore) finishDeleteMessagesTx(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, ownerUserID int64, excludeAuthKeyID [8]byte, excludeSessionID int64, date int, rows []deletedBox, preserveEmptyDialogs bool) (domain.DeleteMessagesResult, error) { + res := domain.DeleteMessagesResult{OwnerUserID: ownerUserID} + if len(rows) == 0 { + return res, nil + } + peersByOwner := make(map[int64]map[domain.Peer]struct{}) + idsByOwner := make(map[int64][]int) + incomingDeletedByPeer := make(deletedUnreadMessages) + for _, row := range rows { + if row.ownerUserID == 0 || row.boxID == 0 { + continue + } + idsByOwner[row.ownerUserID] = append(idsByOwner[row.ownerUserID], row.boxID) + if row.peer.ID != 0 { + if peersByOwner[row.ownerUserID] == nil { + peersByOwner[row.ownerUserID] = make(map[domain.Peer]struct{}) + } + peersByOwner[row.ownerUserID][row.peer] = struct{}{} + } + if row.peer.ID != 0 && row.messageSenderID != 0 && row.messageSenderID != row.ownerUserID { + key := deletedOwnerPeerKey{userID: row.ownerUserID, peer: row.peer} + if incomingDeletedByPeer[key] == nil { + incomingDeletedByPeer[key] = make(map[int]struct{}) + } + incomingDeletedByPeer[key][row.boxID] = struct{}{} + } + } + // 按 owner 升序重建 dialog,使两个反向 delete(X 删与 Y 的会话 / Y 删与 X 的会话)以一致顺序 + // 获取 dialog 行锁,配合下方 watermark 的升序推进,彻底避免 delete-delete 之间的 AB-BA 死锁。 + rebuildOwners := make([]int64, 0, len(peersByOwner)) + for userID := range peersByOwner { + rebuildOwners = append(rebuildOwners, userID) + } + sort.Slice(rebuildOwners, func(i, j int) bool { return rebuildOwners[i] < rebuildOwners[j] }) + for _, userID := range rebuildOwners { + for peer := range peersByOwner[userID] { + // just_clear(preserveEmptyDialogs)是请求者的本端语义"清空但保留我这侧 + // 空会话"。revoke 反查出的对端并未选择 just_clear,其空会话应按普通删除 + // 处理(无存活消息则移除 dialog),不能也被保留成空会话。 + preserve := preserveEmptyDialogs && userID == ownerUserID + if err := rebuildDialogAfterMessageDelete(ctx, q, userID, peer, preserve); err != nil { + return res, err + } + } + } + readCorrectionsByOwner, err := loadDeleteUnreadCorrections(ctx, q, incomingDeletedByPeer, date) + if err != nil { + return res, err + } + + ownerIDs := make([]int64, 0, len(idsByOwner)) + for userID := range idsByOwner { + ownerIDs = append(ownerIDs, userID) + } + sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] }) + + res.Deleted = make([]domain.DeletedMessagesForUser, 0, len(ownerIDs)) + for _, userID := range ownerIDs { + ids := normalizeMessageIDs(idsByOwner[userID]) + if len(ids) == 0 { + continue + } + corrections := readCorrectionsByOwner[userID] + totalPtsCount := len(ids) + len(corrections) + pts, err := s.reservePtsN(ctx, db, userID, totalPtsCount) + if err != nil { + return res, fmt.Errorf("allocate delete messages pts: %w", err) + } + deletePts := pts - len(corrections) + event := domain.UpdateEvent{ + UserID: userID, + Type: domain.UpdateEventDeleteMessages, + Pts: deletePts, + PtsCount: len(ids), + Date: date, + MessageIDs: ids, + } + if err := appendDeleteMessagesEvent(ctx, q, event); err != nil { + return res, err + } + dispatchAuthKeyID := [8]byte{} + dispatchSessionID := int64(0) + if userID == ownerUserID { + dispatchAuthKeyID = excludeAuthKeyID + dispatchSessionID = excludeSessionID + } + if err := q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: userID, + Pts: int32(deletePts), + EventType: string(domain.UpdateEventDeleteMessages), + ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), + ExcludeSessionID: dispatchSessionID, + }); err != nil { + return res, fmt.Errorf("enqueue delete messages dispatch: %w", err) + } + res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{ + UserID: userID, + MessageIDs: ids, + Event: event, + }) + for i, correction := range corrections { + correction.Pts = deletePts + i + 1 + if err := appendUserUpdateEvent(ctx, db, q, userID, correction); err != nil { + return res, fmt.Errorf("append delete unread correction event: %w", err) + } + // dialog 快照必须与 update 流宣布的水位一致,否则后续 + // readHistory 会以旧水位为基线重复发已读事件。 + if err := q.AdvanceDialogReadInboxFloor(ctx, sqlcgen.AdvanceDialogReadInboxFloorParams{ + UserID: userID, + PeerType: string(correction.Peer.Type), + PeerID: correction.Peer.ID, + ReadInboxMaxID: int32(correction.MaxID), + }); err != nil { + return res, fmt.Errorf("advance dialog read inbox after delete correction: %w", err) + } + if err := q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: userID, + Pts: int32(correction.Pts), + EventType: string(domain.UpdateEventReadHistoryInbox), + }); err != nil { + return res, fmt.Errorf("enqueue delete unread correction dispatch: %w", err) + } + } + } + return res, nil +} + +func maxDeletedMessageID(ids map[int]struct{}) int { + maxID := 0 + for id := range ids { + if id > maxID { + maxID = id + } + } + return maxID +} + +func rebuildDialogAfterMessageDelete(ctx context.Context, q *sqlcgen.Queries, userID int64, peer domain.Peer, preserveEmpty bool) error { + top, err := q.TopVisibleMessageBoxByPeer(ctx, sqlcgen.TopVisibleMessageBoxByPeerParams{ + OwnerUserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }) + if errors.Is(err, pgx.ErrNoRows) { + if preserveEmpty { + if err := q.ClearDialogAfterHistoryDelete(ctx, sqlcgen.ClearDialogAfterHistoryDeleteParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }); err != nil { + return fmt.Errorf("clear empty dialog after history delete: %w", err) + } + return nil + } + if err := q.DeleteDialogByPeer(ctx, sqlcgen.DeleteDialogByPeerParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }); err != nil { + return fmt.Errorf("delete empty dialog after message delete: %w", err) + } + return nil + } + if err != nil { + return fmt.Errorf("load top message after delete: %w", err) + } + if err := q.RefreshDialogAfterMessageDelete(ctx, sqlcgen.RefreshDialogAfterMessageDeleteParams{ + TopMessageID: top.BoxID, + TopMessageDate: top.MessageDate, + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }); err != nil { + return fmt.Errorf("refresh dialog after message delete: %w", err) + } + return nil +} + +func appendDeleteMessagesEvent(ctx context.Context, q *sqlcgen.Queries, event domain.UpdateEvent) error { + messageIDs, err := encodeEventMessageIDs(event.MessageIDs) + if err != nil { + return err + } + if event.PtsCount == 0 { + event.PtsCount = len(event.MessageIDs) + } + if event.PtsCount == 0 { + event.PtsCount = 1 + } + if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ + UserID: event.UserID, + Pts: int32(event.Pts), + PtsCount: int32(event.PtsCount), + Date: int32(event.Date), + EventType: string(domain.UpdateEventDeleteMessages), + EventPeers: []byte("[]"), + PeerSettings: []byte("{}"), + MessageIds: messageIDs, + DialogFilter: []byte("{}"), + FilterOrder: []byte("[]"), + FolderPeers: []byte("[]"), + StoryPayload: []byte("{}"), + ReactionPayload: []byte("{}"), + }); err != nil { + return fmt.Errorf("append delete messages event: %w", err) + } + return nil +} + +func deletedRowsFromIDRows(rows []sqlcgen.DeleteMessageBoxesByIDsRow) []deletedBox { + out := make([]deletedBox, 0, len(rows)) + for _, row := range rows { + out = append(out, deletedBox{ + ownerUserID: row.OwnerUserID, + boxID: int(row.BoxID), + privateMessageID: row.PrivateMessageID, + messageSenderID: row.MessageSenderID, + peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + }) + } + return out +} + +func deletedRowsFromPeerRows(rows []sqlcgen.DeleteMessageBoxesByPeerRow) []deletedBox { + out := make([]deletedBox, 0, len(rows)) + for _, row := range rows { + out = append(out, deletedBox{ + ownerUserID: row.OwnerUserID, + boxID: int(row.BoxID), + privateMessageID: row.PrivateMessageID, + messageSenderID: row.MessageSenderID, + peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + }) + } + return out +} + +func deletedRowsFromPeerBatchRows(rows []sqlcgen.DeleteMessageBoxesByPeerBatchRow) []deletedBox { + out := make([]deletedBox, 0, len(rows)) + for _, row := range rows { + out = append(out, deletedBox{ + ownerUserID: row.OwnerUserID, + boxID: int(row.BoxID), + privateMessageID: row.PrivateMessageID, + messageSenderID: row.MessageSenderID, + peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + }) + } + return out +} + +func deletedRowsFromPrivateRows(rows []sqlcgen.DeleteMessageBoxesByPrivateMessagesRow) []deletedBox { + out := make([]deletedBox, 0, len(rows)) + for _, row := range rows { + out = append(out, deletedBox{ + ownerUserID: row.OwnerUserID, + boxID: int(row.BoxID), + privateMessageID: row.PrivateMessageID, + messageSenderID: row.MessageSenderID, + peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + }) + } + return out +} + +func privateMessageDeleteParams(rows []deletedBox) sqlcgen.DeleteMessageBoxesByPrivateMessagesParams { + senderIDs := make([]int64, 0, len(rows)) + privateIDs := make([]int64, 0, len(rows)) + ownerIDs := make([]int64, 0, len(rows)*2) + seen := make(map[[3]int64]struct{}, len(rows)*2) + for _, row := range rows { + if row.messageSenderID == 0 || row.privateMessageID == 0 { + continue + } + owners := privateMessageOwnerIDs(row.ownerUserID, 0) + if row.peer.Type == domain.PeerTypeUser { + owners = privateMessageOwnerIDs(row.ownerUserID, row.peer.ID) + } + for _, ownerID := range owners { + key := [3]int64{row.messageSenderID, row.privateMessageID, ownerID} + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + senderIDs = append(senderIDs, row.messageSenderID) + privateIDs = append(privateIDs, row.privateMessageID) + ownerIDs = append(ownerIDs, ownerID) + } + } + return sqlcgen.DeleteMessageBoxesByPrivateMessagesParams{ + MessageSenderIds: senderIDs, + PrivateMessageIds: privateIDs, + OwnerUserIds: ownerIDs, + } +} + +func privateMessageOwnerIDs(first, second int64) []int64 { + switch { + case first == 0 && second == 0: + return nil + case first == 0 || first == second: + return []int64{second} + case second == 0: + return []int64{first} + default: + if first < second { + return []int64{first, second} + } + return []int64{second, first} + } +} diff --git a/internal/store/postgres/message_delete_integration_test.go b/internal/store/postgres/message_delete_integration_test.go new file mode 100644 index 00000000..ce506baf --- /dev/null +++ b/internal/store/postgres/message_delete_integration_test.go @@ -0,0 +1,587 @@ +package postgres + +import ( + "context" + "telesrv/internal/domain" + "testing" +) + +func TestMessageStoreDeleteMessagesRecomputesRecipientUnreadAndTop(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 61, + Phone: "+1668" + suffix + "01", + FirstName: "DeleteSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 62, + Phone: "+1668" + suffix + "02", + FirstName: "DeleteRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + bodies := []string{"one", "two", "three"} + sent := make([]domain.SendPrivateTextResult, 0, len(bodies)) + for i, body := range bodies { + got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 824000 + int64(i), + Message: body, + Date: 1700000500 + i, + }) + if err != nil { + t.Fatalf("SendPrivateText %q: %v", body, err) + } + sent = append(sent, got) + } + + deleted, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ + OwnerUserID: sender.ID, + IDs: []int{sent[2].SenderMessage.ID}, + Revoke: true, + Date: 1700000510, + }) + if err != nil { + t.Fatalf("DeleteMessages revoke latest: %v", err) + } + if len(deleted.Deleted) != 2 || !deleted.Changed() { + t.Fatalf("deleted = %+v, want both owner boxes revoked", deleted) + } + + var unreadCount, actualUnread, topMessageID int + var topBody string + if err := pool.QueryRow(ctx, ` + WITH target AS ( + SELECT d.unread_count, d.top_message_id, d.read_inbox_max_id, COALESCE(m.body, '') AS top_body + FROM dialogs d + LEFT JOIN message_boxes m + ON m.owner_user_id = d.user_id + AND m.box_id = d.top_message_id + AND NOT m.deleted + WHERE d.user_id = $1 + AND d.peer_type = $2 + AND d.peer_id = $3 + ), + actual AS ( + SELECT COUNT(*)::int AS unread + FROM message_boxes + WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND NOT deleted + AND NOT outgoing + AND box_id > (SELECT read_inbox_max_id FROM target) + ) + SELECT target.unread_count, target.top_message_id, target.top_body, actual.unread + FROM target CROSS JOIN actual + `, recipient.ID, string(domain.PeerTypeUser), sender.ID).Scan(&unreadCount, &topMessageID, &topBody, &actualUnread); err != nil { + t.Fatalf("load recipient dialog after revoke: %v", err) + } + if unreadCount != 2 || actualUnread != 2 || topMessageID != sent[1].RecipientMessage.ID || topBody != "two" { + t.Fatalf("recipient dialog after revoke = unread %d actual %d top %d body %q, want unread=2 top=%d body=two", + unreadCount, actualUnread, topMessageID, topBody, sent[1].RecipientMessage.ID) + } + events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 3, 10) + if err != nil { + t.Fatalf("list recipient events after revoke: %v", err) + } + if len(events) != 1 || + events[0].Type != domain.UpdateEventDeleteMessages || + events[0].Pts != 4 || + events[0].PtsCount != 1 || + !sameInts(events[0].MessageIDs, []int{sent[2].RecipientMessage.ID}) { + t.Fatalf("recipient events after revoke = %+v, want delete only; later unread messages are still live", events) + } +} + +func TestMessageStoreDeleteMiddleUnreadKeepsRecipientTop(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 63, + Phone: "+1669" + suffix + "01", + FirstName: "MiddleDeleteSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 64, + Phone: "+1669" + suffix + "02", + FirstName: "MiddleDeleteRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + recipientPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID} + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 824999, + Message: "already read", + Date: 1700000590, + }) + if err != nil { + t.Fatalf("SendPrivateText first: %v", err) + } + if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipient.ID, + Peer: recipientPeer, + MaxID: first.RecipientMessage.ID, + Date: 1700000595, + }); err != nil { + t.Fatalf("ReadHistory first: %v", err) + } + + bodies := []string{"one", "two", "three"} + sent := make([]domain.SendPrivateTextResult, 0, len(bodies)) + for i, body := range bodies { + got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 825000 + int64(i), + Message: body, + Date: 1700000600 + i, + }) + if err != nil { + t.Fatalf("SendPrivateText %q: %v", body, err) + } + sent = append(sent, got) + } + + if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ + OwnerUserID: sender.ID, + IDs: []int{sent[1].SenderMessage.ID}, + Revoke: true, + Date: 1700000610, + }); err != nil { + t.Fatalf("DeleteMessages revoke middle: %v", err) + } + + var unreadCount, actualUnread, topMessageID, readInboxMaxID int + var topBody string + if err := pool.QueryRow(ctx, ` + WITH target AS ( + SELECT d.unread_count, d.top_message_id, d.read_inbox_max_id, COALESCE(m.body, '') AS top_body + FROM dialogs d + LEFT JOIN message_boxes m + ON m.owner_user_id = d.user_id + AND m.box_id = d.top_message_id + AND NOT m.deleted + WHERE d.user_id = $1 + AND d.peer_type = $2 + AND d.peer_id = $3 + ), + actual AS ( + SELECT COUNT(*)::int AS unread + FROM message_boxes + WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND NOT deleted + AND NOT outgoing + AND box_id > (SELECT read_inbox_max_id FROM target) + ) + SELECT target.unread_count, target.top_message_id, target.read_inbox_max_id, target.top_body, actual.unread + FROM target CROSS JOIN actual + `, recipient.ID, string(domain.PeerTypeUser), sender.ID).Scan(&unreadCount, &topMessageID, &readInboxMaxID, &topBody, &actualUnread); err != nil { + t.Fatalf("load recipient dialog after middle revoke: %v", err) + } + if unreadCount != 2 || actualUnread != 2 || topMessageID != sent[2].RecipientMessage.ID || readInboxMaxID != first.RecipientMessage.ID || topBody != "three" { + t.Fatalf("recipient dialog after middle revoke = unread %d actual %d top %d read %d body %q, want unread=2 top=%d read=%d body=three", + unreadCount, actualUnread, topMessageID, readInboxMaxID, topBody, sent[2].RecipientMessage.ID, first.RecipientMessage.ID) + } + events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 5, 10) + if err != nil { + t.Fatalf("list recipient events after middle revoke: %v", err) + } + if len(events) != 1 || + events[0].Type != domain.UpdateEventDeleteMessages || + events[0].Pts != 6 || + events[0].PtsCount != 1 || + !sameInts(events[0].MessageIDs, []int{sent[1].RecipientMessage.ID}) { + t.Fatalf("recipient events after middle revoke = %+v, want delete only; first unread is still live", events) + } +} + +func TestMessageStoreDeleteFirstUnreadEmitsSafeUnreadCorrection(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 65, + Phone: "+1670" + suffix + "01", + FirstName: "FirstUnreadDeleteSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 66, + Phone: "+1670" + suffix + "02", + FirstName: "FirstUnreadDeleteRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + recipientPeer := domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID} + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 826000, + Message: "already read", + Date: 1700000640, + }) + if err != nil { + t.Fatalf("SendPrivateText first: %v", err) + } + if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipient.ID, + Peer: recipientPeer, + MaxID: first.RecipientMessage.ID, + Date: 1700000645, + }); err != nil { + t.Fatalf("ReadHistory first: %v", err) + } + + bodies := []string{"one", "two", "three"} + sent := make([]domain.SendPrivateTextResult, 0, len(bodies)) + for i, body := range bodies { + got, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 826001 + int64(i), + Message: body, + Date: 1700000650 + i, + }) + if err != nil { + t.Fatalf("SendPrivateText %q: %v", body, err) + } + sent = append(sent, got) + } + + if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ + OwnerUserID: sender.ID, + IDs: []int{sent[0].SenderMessage.ID}, + Revoke: true, + Date: 1700000660, + }); err != nil { + t.Fatalf("DeleteMessages revoke first unread: %v", err) + } + + events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 5, 10) + if err != nil { + t.Fatalf("list recipient events after first unread revoke: %v", err) + } + if len(events) != 2 || + events[0].Type != domain.UpdateEventDeleteMessages || + events[0].Pts != 6 || + events[0].PtsCount != 1 || + !sameInts(events[0].MessageIDs, []int{sent[0].RecipientMessage.ID}) || + events[1].Type != domain.UpdateEventReadHistoryInbox || + events[1].Pts != 7 || + events[1].PtsCount != 1 || + events[1].MaxID != sent[0].RecipientMessage.ID || + events[1].StillUnreadCount != 2 { + t.Fatalf("recipient events after first unread revoke = %+v, want delete then prefix unread correction", events) + } +} + +func TestMessageStoreDeleteHistoryRebuildsDialogAndEmitsDeleteUpdates(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender := createTestUser(t, ctx, users, "+1991"+suffix+"01", "DeleteSender", "") + recipient := createTestUser(t, ctx, users, "+1991"+suffix+"02", "DeleteRecipient", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + for i := 0; i < 2; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: int64(7000 + i), + Message: "history", + Date: 1700000700 + i, + }); err != nil { + t.Fatalf("seed send %d: %v", i, err) + } + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID} + deleted, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: sender.ID, + Peer: peer, + Date: 1700000800, + }) + if err != nil { + t.Fatalf("DeleteHistory: %v", err) + } + if self := deleted.Self(); self.Event.Pts != 4 || self.Event.PtsCount != 2 || len(self.MessageIDs) != 2 { + t.Fatalf("delete result = %+v, want sender delete range pts=4 count=2 ids", self) + } + senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10}) + if err != nil { + t.Fatalf("sender history: %v", err) + } + recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 { + t.Fatalf("history sizes sender=%d recipient=%d, want sender cleared only", len(senderHistory.Messages), len(recipientHistory.Messages)) + } + senderDialogs, err := NewDialogStore(pool).ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("sender dialogs after delete: %v", err) + } + if len(senderDialogs.Dialogs) != 0 { + t.Fatalf("sender dialogs = %+v, want empty after full history delete", senderDialogs.Dialogs) + } + events, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 2, 10) + if err != nil { + t.Fatalf("list sender events: %v", err) + } + if len(events) != 1 || events[0].Type != domain.UpdateEventDeleteMessages || events[0].Pts != 4 || events[0].PtsCount != 2 || len(events[0].MessageIDs) != 2 { + t.Fatalf("events = %+v, want delete messages event pts=4 pts_count=2", events) + } + + rebuilt, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 8000, + Message: "after clear", + Date: 1700000900, + }) + if err != nil { + t.Fatalf("send after delete: %v", err) + } + senderDialogs, err = NewDialogStore(pool).ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("sender dialogs after rebuild: %v", err) + } + if len(senderDialogs.Dialogs) != 1 || senderDialogs.Dialogs[0].Peer != peer || senderDialogs.Dialogs[0].TopMessage != rebuilt.SenderMessage.ID { + t.Fatalf("rebuilt dialogs = %+v, want new top message %d", senderDialogs.Dialogs, rebuilt.SenderMessage.ID) + } + + revoked, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ + OwnerUserID: sender.ID, + IDs: []int{rebuilt.SenderMessage.ID}, + Revoke: true, + Date: 1700001000, + }) + if err != nil { + t.Fatalf("DeleteMessages revoke: %v", err) + } + if len(revoked.Deleted) != 2 || !revoked.Changed() { + t.Fatalf("revoked = %+v, want delete events for both owners", revoked) + } + senderHistory, err = messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10}) + if err != nil { + t.Fatalf("sender history after revoke: %v", err) + } + recipientHistory, err = messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10}) + if err != nil { + t.Fatalf("recipient history after revoke: %v", err) + } + if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 { + t.Fatalf("history sizes after revoke sender=%d recipient=%d, want new message removed from both owners", len(senderHistory.Messages), len(recipientHistory.Messages)) + } +} + +func TestMessageStoreDeleteHistoryJustClearPreservesEmptyDialog(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1992"+suffix+"01", "ClearOwner", "") + peerUser := createTestUser(t, ctx, users, "+1992"+suffix+"02", "ClearPeer", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peerUser.ID}) + }) + + messages := NewMessageStore(pool) + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: owner.ID, + RecipientUserID: peerUser.ID, + RandomID: 9000, + Message: "clear but keep dialog", + Date: 1700001100, + }); err != nil { + t.Fatalf("seed send: %v", err) + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID} + if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: owner.ID, + Peer: peer, + JustClear: true, + Date: 1700001200, + }); err != nil { + t.Fatalf("DeleteHistory just_clear: %v", err) + } + dialogs, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("dialogs after just_clear: %v", err) + } + if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer != peer || dialogs.Dialogs[0].TopMessage != 0 || len(dialogs.Messages) != 0 { + t.Fatalf("dialogs = %+v messages=%+v, want empty dialog preserved after just_clear", dialogs.Dialogs, dialogs.Messages) + } + history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true}) + if err != nil { + t.Fatalf("history after just_clear: %v", err) + } + if len(history.Messages) != 0 { + t.Fatalf("history = %+v, want cleared", history.Messages) + } +} + +func TestMessageStoreDeleteHistoryBatchesHugeMaxID(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner := createTestUser(t, ctx, users, "+1993"+suffix+"01", "BulkOwner", "") + peerUser := createTestUser(t, ctx, users, "+1993"+suffix+"02", "BulkPeer", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peerUser.ID}) + }) + + total := domain.MaxDeleteHistoryBatch + 2 + if _, err := pool.Exec(ctx, ` + WITH src AS ( + SELECT generate_series(1, $3::int) AS g + ), + pm AS ( + INSERT INTO private_messages ( + sender_user_id, + recipient_user_id, + random_id, + message_date, + body, + entities + ) + SELECT + $1::bigint, + $2::bigint, + 910000000 + g, + 1700002000 + g, + 'bulk history', + '[]'::jsonb + FROM src + RETURNING id, random_id, message_date + ) + INSERT INTO message_boxes ( + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id, + from_user_id, + message_date, + outgoing, + body, + entities, + pts + ) + SELECT + $1::bigint, + (random_id - 910000000)::int, + id, + $1::bigint, + 'user', + $2::bigint, + $1::bigint, + message_date, + true, + 'bulk history', + '[]'::jsonb, + 0 + FROM pm + `, owner.ID, peerUser.ID, total); err != nil { + t.Fatalf("seed bulk history: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO dialogs ( + user_id, + peer_type, + peer_id, + top_message_id, + top_message_date, + read_outbox_max_id, + unread_count + ) VALUES ($1, 'user', $2, $3, $4, $3, 0) + `, owner.ID, peerUser.ID, total, 1700002000+total); err != nil { + t.Fatalf("seed dialog: %v", err) + } + + messages := NewMessageStore(pool) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID} + first, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: owner.ID, + Peer: peer, + MaxID: domain.MaxMessageBoxID, + Date: 1700003000, + }) + if err != nil { + t.Fatalf("DeleteHistory first batch: %v", err) + } + self := first.Self() + if first.Offset != 1 || self.Event.Pts != domain.MaxDeleteHistoryBatch || self.Event.PtsCount != domain.MaxDeleteHistoryBatch || len(self.MessageIDs) != domain.MaxDeleteHistoryBatch { + t.Fatalf("first batch = %+v self=%+v, want offset=1 and exactly %d deleted ids", first, self, domain.MaxDeleteHistoryBatch) + } + history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true}) + if err != nil { + t.Fatalf("history after first batch: %v", err) + } + if history.Count != 2 || len(history.Messages) != 2 || history.Messages[0].ID != 2 { + t.Fatalf("history after first batch = %+v, want only two oldest messages left", history) + } + + second, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ + OwnerUserID: owner.ID, + Peer: peer, + MaxID: domain.MaxMessageBoxID, + Date: 1700003001, + }) + if err != nil { + t.Fatalf("DeleteHistory second batch: %v", err) + } + if second.Offset != 0 || second.Self().Event.PtsCount != 2 { + t.Fatalf("second batch = %+v, want final offset=0 pts_count=2", second) + } +} diff --git a/internal/store/postgres/message_edit.go b/internal/store/postgres/message_edit.go new file mode 100644 index 00000000..2b366130 --- /dev/null +++ b/internal/store/postgres/message_edit.go @@ -0,0 +1,273 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" + "time" +) + +func (s *MessageStore) EditMessage(ctx context.Context, req domain.EditMessageRequest) (res domain.EditMessageResult, err error) { + res = domain.EditMessageResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("edit message: missing owner user id") + } + if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + + if req.EditDate == 0 { + req.EditDate = int(time.Now().Unix()) + } + entities, err := encodeMessageEntities(req.Entities) + if err != nil { + return res, err + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("edit message: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin edit message tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + }() + + // advisory lock 串行化与会话对端的并发写,须在行锁前获取,消除 AB-BA 死锁。 + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { + return res, fmt.Errorf("lock edit message users: %w", err) + } + + target, err := qtx.GetMessageBoxForEdit(ctx, sqlcgen.GetMessageBoxForEditParams{ + OwnerUserID: req.OwnerUserID, + BoxID: int32(req.ID), + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + }) + // 空文本只在目标消息携带媒体(或本次写入媒体)时合法(清空 caption); + // 纯文本消息清空会留下既无 body 也无 media 的空壳。 + if err == nil && req.Message == "" && req.Media == nil && (target.MediaJson == "" || target.MediaJson == "{}") { + return res, domain.ErrMessageEmpty + } + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return res, domain.ErrMessageIDInvalid + } + return res, fmt.Errorf("get message for edit: %w", err) + } + oldEntities, err := decodeMessageEntities(target.EntitiesJson) + if err != nil { + return res, fmt.Errorf("decode target entities: %w", err) + } + authorEdit := target.Outgoing && target.MessageSenderID == req.OwnerUserID && target.FromUserID == req.OwnerUserID + viaBotEdit := req.ViaBotEditBotID != 0 && target.ViaBotID == req.ViaBotEditBotID + if !authorEdit && !viaBotEdit && !req.WebPageResolve && !validTodoParticipantEdit(req, target, oldEntities) { + return res, domain.ErrMessageAuthorRequired + } + if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && sameMessageEntities(oldEntities, req.Entities) { + return res, domain.ErrMessageNotModified + } + replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup) + if err != nil { + return res, fmt.Errorf("encode edit reply markup: %w", err) + } + messageSenderID := target.MessageSenderID + boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: privateMessageOwnerIDs(req.OwnerUserID, req.Peer.ID), + MessageSenderID: messageSenderID, + PrivateMessageID: target.PrivateMessageID, + }) + if err != nil { + return res, fmt.Errorf("list visible edit boxes: %w", err) + } + if len(boxes) == 0 { + return res, domain.ErrMessageIDInvalid + } + if req.WebPageResolve { + // 链接预览就地替换:仅换 media(不碰 body/entities/edit_date,故不标记「已编辑」), + // 逐 box reserve 账号 pts + 追加 web_page 事件 + dispatch。幂等守卫见下。 + targetMedia, err := decodeMessageMedia(target.MediaJson) + if err != nil { + return res, fmt.Errorf("decode target media for web page resolve: %w", err) + } + if !domain.IsPendingWebPageMedia(targetMedia, req.ExpectedWebPageID) { + return res, domain.ErrMessageNotModified + } + mediaJSON, err := encodeMessageMedia(req.Media) + if err != nil { + return res, fmt.Errorf("encode resolved web page media: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE private_messages SET media = $3 +WHERE sender_user_id = $1 AND id = $2`, messageSenderID, target.PrivateMessageID, mediaJSON); err != nil { + return res, fmt.Errorf("update private message web page media: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE message_boxes SET media = $3 +WHERE message_sender_id = $1 AND private_message_id = $2`, messageSenderID, target.PrivateMessageID, mediaJSON); err != nil { + return res, fmt.Errorf("update message box web page media: %w", err) + } + res.Edited = make([]domain.EditedMessageForUser, 0, len(boxes)) + for _, box := range boxes { + pts, err := s.reservePts(ctx, tx, box.OwnerUserID) + if err != nil { + return res, fmt.Errorf("allocate web page resolve pts: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE message_boxes SET pts = $3 +WHERE owner_user_id = $1 AND box_id = $2`, box.OwnerUserID, box.BoxID, int32(pts)); err != nil { + return res, fmt.Errorf("bump message box pts for web page: %w", err) + } + msg, err := messageFromVisibleBoxRow(box) + if err != nil { + return res, err + } + // box 行取自 media 替换前:用解析结果与新 pts 覆盖(链接预览不改文本/实体, + // 故共享媒体索引无需重建)。 + msg.Media = req.Media + msg.Pts = pts + event := domain.UpdateEvent{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventWebPage, + Pts: pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + } + if err := appendUserUpdateEvent(ctx, tx, qtx, msg.OwnerUserID, event); err != nil { + return res, fmt.Errorf("append web page event: %w", err) + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: msg.OwnerUserID, + Pts: int32(pts), + EventType: string(domain.UpdateEventWebPage), + ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), + ExcludeSessionID: req.OriginSessionID, + }); err != nil { + return res, fmt.Errorf("enqueue web page dispatch: %w", err) + } + res.Edited = append(res.Edited, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit web page resolve tx: %w", err) + } + committed = true + return res, nil + } + if err := qtx.UpdatePrivateMessageEdit(ctx, sqlcgen.UpdatePrivateMessageEditParams{ + SenderUserID: messageSenderID, + PrivateMessageID: target.PrivateMessageID, + Body: req.Message, + EntitiesJson: entities, + EditDate: int32(req.EditDate), + SetReplyMarkup: req.SetReplyMarkup, + ReplyMarkupJson: replyMarkupJSON, + }); err != nil { + return res, fmt.Errorf("update private message edit: %w", err) + } + if req.Media != nil { + // 媒体快照整体替换(live location 续报/停止);先于 box 行编辑执行, + // 让 UpdateMessageBoxEdit RETURNING 直接带回新媒体。 + mediaJSON, err := encodeMessageMedia(req.Media) + if err != nil { + return res, fmt.Errorf("encode edit message media: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE private_messages SET media = $3 +WHERE sender_user_id = $1 AND id = $2`, messageSenderID, target.PrivateMessageID, mediaJSON); err != nil { + return res, fmt.Errorf("update private message media: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE message_boxes SET media = $3 +WHERE message_sender_id = $1 AND private_message_id = $2`, messageSenderID, target.PrivateMessageID, mediaJSON); err != nil { + return res, fmt.Errorf("update message box media: %w", err) + } + } + res.Edited = make([]domain.EditedMessageForUser, 0, len(boxes)) + for _, box := range boxes { + pts, err := s.reservePts(ctx, tx, box.OwnerUserID) + if err != nil { + return res, fmt.Errorf("allocate edit message pts: %w", err) + } + updated, err := qtx.UpdateMessageBoxEdit(ctx, sqlcgen.UpdateMessageBoxEditParams{ + OwnerUserID: box.OwnerUserID, + BoxID: box.BoxID, + Body: req.Message, + EntitiesJson: entities, + EditDate: int32(req.EditDate), + Pts: int32(pts), + SetReplyMarkup: req.SetReplyMarkup, + ReplyMarkupJson: replyMarkupJSON, + }) + if err != nil { + return res, fmt.Errorf("update message box edit: %w", err) + } + msg, err := messageFromUpdateEditRow(updated) + if err != nil { + return res, err + } + // 共享媒体索引(0118):编辑可换媒体、也可改文本链接实体 → 按编辑后有效媒体+实体逐 box 重建。 + if err := replaceMessageBoxMediaIndexTx(ctx, tx, msg.OwnerUserID, msg.Peer.ID, msg.ID, msg.Date, msg.Media, msg.Entities); err != nil { + return res, err + } + event := domain.UpdateEvent{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventEditMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: req.EditDate, + Message: msg, + } + if err := appendUserUpdateEvent(ctx, tx, qtx, msg.OwnerUserID, event); err != nil { + return res, fmt.Errorf("append edit message event: %w", err) + } + dispatchAuthKeyID := [8]byte{} + dispatchSessionID := int64(0) + if msg.OwnerUserID == req.OwnerUserID { + dispatchAuthKeyID = req.OriginAuthKeyID + dispatchSessionID = req.OriginSessionID + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: msg.OwnerUserID, + Pts: int32(pts), + EventType: string(domain.UpdateEventEditMessage), + ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), + ExcludeSessionID: dispatchSessionID, + }); err != nil { + return res, fmt.Errorf("enqueue edit message dispatch: %w", err) + } + res.Edited = append(res.Edited, domain.EditedMessageForUser{UserID: msg.OwnerUserID, Message: msg, Event: event}) + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit edit message tx: %w", err) + } + committed = true + return res, nil +} + +func validTodoParticipantEdit(req domain.EditMessageRequest, target sqlcgen.GetMessageBoxForEditRow, oldEntities []domain.MessageEntity) bool { + if !req.AllowTodoParticipantMutation || req.SetReplyMarkup || req.Media == nil || req.Media.Kind != domain.MessageMediaKindTodo || req.Media.Todo == nil { + return false + } + if target.MessageSenderID == req.OwnerUserID { + return false + } + if target.Body != req.Message || !sameMessageEntities(oldEntities, req.Entities) { + return false + } + targetMedia, err := decodeMessageMedia(target.MediaJson) + if err != nil || targetMedia == nil || targetMedia.Kind != domain.MessageMediaKindTodo || targetMedia.Todo == nil { + return false + } + return true +} diff --git a/internal/store/postgres/message_effect_integration_test.go b/internal/store/postgres/message_effect_integration_test.go new file mode 100644 index 00000000..05b03cd1 --- /dev/null +++ b/internal/store/postgres/message_effect_integration_test.go @@ -0,0 +1,135 @@ +package postgres + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestSendPrivateEffectSurvivesReadPaths 回归迁移 0023:消息特效 effect 经私聊全部读路径 +// 保真——发送结果双盒、history、GetByIDs、以及更新事件重建路径(离线 difference / 在线推送 +// 共用)。漏一处读站点 = 特效静默丢失(发送方播了、接收方/重载看不到)。 +func TestSendPrivateEffectSurvivesReadPaths(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1996" + suffix + "01", FirstName: "EffectSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1996" + suffix + "02", FirstName: "EffectRecipient"}) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + ids := []int64{sender.ID, recipient.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) + const effect = int64(5104841169784993603) + + res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: time.Now().UnixNano(), + Message: "🎉 with effect", + Effect: effect, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send effect message: %v", err) + } + // 发送结果双盒:发送方与接收方各自的副本都带特效。 + if res.SenderMessage.Effect != effect || res.RecipientMessage.Effect != effect { + t.Fatalf("send result effect = sender %d recipient %d, want %d", res.SenderMessage.Effect, res.RecipientMessage.Effect, effect) + } + + // history:接收方拉取携带 effect。 + hist, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + found := false + for _, m := range hist.Messages { + if m.ID == res.RecipientMessage.ID { + found = true + if m.Effect != effect { + t.Fatalf("history effect = %d, want %d", m.Effect, effect) + } + } + } + if !found { + t.Fatal("recipient history missing the effect message") + } + + // GetByIDs 带 effect。 + byID, err := messages.GetByIDs(ctx, sender.ID, []int{res.SenderMessage.ID}) + if err != nil { + t.Fatalf("sender get by ids: %v", err) + } + if len(byID.Messages) != 1 { + t.Fatalf("get by ids = %d, want 1", len(byID.Messages)) + } + if byID.Messages[0].Effect != effect { + t.Fatalf("get-by-id effect = %d, want %d", byID.Messages[0].Effect, effect) + } + + // 更新事件重建路径(关键:getDifference/推送共用)带 effect。 + events := NewUpdateEventStore(pool) + got, err := events.ListAfter(ctx, recipient.ID, 0, 20) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + rebuilt := false + for _, ev := range got { + if ev.Type == domain.UpdateEventNewMessage && ev.Message.ID == res.RecipientMessage.ID { + if ev.Message.Effect != effect { + t.Fatalf("update-event effect = %d, want %d(重建路径丢失特效)", ev.Message.Effect, effect) + } + rebuilt = true + } + } + if !rebuilt { + t.Fatal("recipient new_message event missing effect rebuild") + } + + // getDialogs 的 top message 必须带 effect:TDesktop 把 getDialogs 返回的消息入缓存 + // 且不被后续 getHistory 覆盖,dialog top message 漏 effect = 最新那条特效消息永久按 + // 「无特效」渲染(真机实测的 bug:getDialogs 返回的最新消息没有消息特效)。 + dialogs := NewDialogStore(pool) + for _, owner := range []struct { + id int64 + boxID int + label string + }{ + {recipient.ID, res.RecipientMessage.ID, "recipient"}, + {sender.ID, res.SenderMessage.ID, "sender"}, + } { + list, err := dialogs.ListByUser(ctx, owner.id, domain.DialogFilter{Limit: 20}) + if err != nil { + t.Fatalf("%s getDialogs: %v", owner.label, err) + } + top := false + for _, m := range list.Messages { + if m.ID == owner.boxID { + top = true + if m.Effect != effect { + t.Fatalf("%s dialog top message effect = %d, want %d(getDialogs 漏 effect)", owner.label, m.Effect, effect) + } + } + } + if !top { + t.Fatalf("%s getDialogs missing top message box %d", owner.label, owner.boxID) + } + } +} diff --git a/internal/store/postgres/message_forward.go b/internal/store/postgres/message_forward.go new file mode 100644 index 00000000..6dd299ce --- /dev/null +++ b/internal/store/postgres/message_forward.go @@ -0,0 +1,169 @@ +package postgres + +import ( + "context" + "fmt" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" + "time" +) + +func cloneMessageForward(forward *domain.MessageForward) *domain.MessageForward { + if forward == nil { + return nil + } + clone := *forward + return &clone +} + +func (s *MessageStore) ForwardPrivateMessages(ctx context.Context, req domain.ForwardPrivateMessagesRequest) (domain.ForwardPrivateMessagesResult, error) { + res := domain.ForwardPrivateMessagesResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.ToUserID == 0 { + return res, fmt.Errorf("forward private messages: missing user id") + } + if req.FromPeer.Type != domain.PeerTypeUser || req.FromPeer.ID == 0 { + return res, fmt.Errorf("forward private messages: invalid source peer") + } + if len(req.MessageIDs) == 0 || len(req.MessageIDs) != len(req.RandomIDs) { + return res, domain.ErrMessageIDInvalid + } + if len(req.MessageIDs) > domain.MaxForwardMessageIDs { + return res, fmt.Errorf("forward private messages: too many ids: %d > %d", len(req.MessageIDs), domain.MaxForwardMessageIDs) + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + boxIDs := make([]int32, 0, len(req.MessageIDs)) + for i, id := range req.MessageIDs { + if id <= 0 || id > domain.MaxMessageBoxID || req.RandomIDs[i] == 0 { + return res, domain.ErrMessageIDInvalid + } + boxIDs = append(boxIDs, int32(id)) + } + rows, err := s.q.GetMessageBoxesForForward(ctx, sqlcgen.GetMessageBoxesForForwardParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.FromPeer.Type), + PeerID: req.FromPeer.ID, + BoxIds: boxIDs, + }) + if err != nil { + return res, fmt.Errorf("get forward messages: %w", err) + } + if len(rows) != len(req.MessageIDs) { + return res, domain.ErrMessageIDInvalid + } + res.SenderMessages = make([]domain.Message, 0, len(rows)) + res.RecipientMessages = make([]domain.Message, 0, len(rows)) + res.SenderEvents = make([]domain.UpdateEvent, 0, len(rows)) + res.RecipientEvents = make([]domain.UpdateEvent, 0, len(rows)) + res.Duplicates = make([]bool, 0, len(rows)) + for i, row := range rows { + if int(row.BoxID) != req.MessageIDs[i] { + return res, domain.ErrMessageIDInvalid + } + source, err := messageFromForwardRow(row) + if err != nil { + return res, err + } + if source.NoForwards { + return res, domain.ErrChatForwardsRestricted + } + var forward *domain.MessageForward + if !req.DropAuthor { + forward = cloneMessageForward(source.Forward) + if forward == nil { + forward = &domain.MessageForward{From: source.From, Date: source.Date} + } + // 转发进 Saved Messages 必须带 saved_from_peer/msg_id 成对字段, + // 客户端靠它提供"跳回原会话"。 + if req.ToUserID == req.OwnerUserID { + forward.SavedFrom = req.FromPeer + forward.SavedFromMsgID = req.MessageIDs[i] + } + } + sent, err := s.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: req.OwnerUserID, + RecipientUserID: req.ToUserID, + RandomID: req.RandomIDs[i], + Message: source.Body, + Entities: append([]domain.MessageEntity(nil), source.Entities...), + Media: source.Media, + Silent: req.Silent, + NoForwards: req.NoForwards, + ReplyTo: req.ReplyTo, + Forward: forward, + Date: req.Date, + OriginAuthKeyID: req.OriginAuthKeyID, + OriginSessionID: req.OriginSessionID, + RecipientBlocked: req.RecipientBlocked, + }) + if err != nil { + return res, err + } + res.SenderMessages = append(res.SenderMessages, sent.SenderMessage) + res.RecipientMessages = append(res.RecipientMessages, sent.RecipientMessage) + res.SenderEvents = append(res.SenderEvents, sent.SenderEvent) + res.RecipientEvents = append(res.RecipientEvents, sent.RecipientEvent) + res.Duplicates = append(res.Duplicates, sent.Duplicate) + } + return res, nil +} + +func messageFromForwardRow(row sqlcgen.GetMessageBoxesForForwardRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode forward message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.ReplyToStoryID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + row.FwdSavedFromPeerType, + row.FwdSavedFromPeerID, + row.FwdSavedFromMsgID, + ) + if err != nil { + return domain.Message{}, fmt.Errorf("decode forward message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode forward message media: %w", err) + } + return domain.Message{ + Media: media, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + TTLPeriod: int(row.TtlPeriod), + ExpiresAt: int(row.ExpiresAt), + MediaUnread: row.MediaUnread, + ReactionUnread: row.ReactionUnread, + ViaBotID: row.ViaBotID, + GroupedID: row.GroupedID, + Pinned: row.Pinned, + SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), + }, nil +} diff --git a/internal/store/postgres/message_grouped_integration_test.go b/internal/store/postgres/message_grouped_integration_test.go new file mode 100644 index 00000000..51cb6586 --- /dev/null +++ b/internal/store/postgres/message_grouped_integration_test.go @@ -0,0 +1,188 @@ +package postgres + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestSendPrivateGroupedIDSurvivesReadPaths 回归迁移 0008:相册 grouped_id 经私聊 +// 全部读路径保真——发送结果双盒、history、GetByIDs、以及更新事件重建路径(离线 +// difference / 在线推送共用)。这是「漏一处读站点=分组静默丢失」最易踩的地方。 +func TestSendPrivateGroupedIDSurvivesReadPaths(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{AccessHash: 81, Phone: "+1995" + suffix + "01", FirstName: "AlbumSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 82, Phone: "+1995" + suffix + "02", FirstName: "AlbumRecipient"}) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + ids := []int64{sender.ID, recipient.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) + const groupedID = int64(880000000123) + + // 同一 album 的两条消息共享 grouped_id。 + var senderMsgIDs []int + for i := 0; i < 2; i++ { + res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: time.Now().UnixNano() + int64(i), + Message: "album item", + GroupedID: groupedID, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send album item %d: %v", i, err) + } + if res.SenderMessage.GroupedID != groupedID || res.RecipientMessage.GroupedID != groupedID { + t.Fatalf("send result grouped = sender %d recipient %d, want %d", res.SenderMessage.GroupedID, res.RecipientMessage.GroupedID, groupedID) + } + senderMsgIDs = append(senderMsgIDs, res.SenderMessage.ID) + } + + // history:两条都带同一 grouped_id(客户端据此分组)。 + hist, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + album := 0 + for _, m := range hist.Messages { + if m.GroupedID == groupedID { + album++ + } + } + if album != 2 { + t.Fatalf("recipient history grouped count = %d, want 2", album) + } + + // GetByIDs 带 grouped_id。 + byID, err := messages.GetByIDs(ctx, sender.ID, senderMsgIDs) + if err != nil { + t.Fatalf("sender get by ids: %v", err) + } + if len(byID.Messages) != 2 { + t.Fatalf("get by ids = %d, want 2", len(byID.Messages)) + } + for _, m := range byID.Messages { + if m.GroupedID != groupedID { + t.Fatalf("get-by-id grouped = %d, want %d", m.GroupedID, groupedID) + } + } + + // 更新事件重建路径(关键:getDifference/推送共用)带 grouped_id。 + events := NewUpdateEventStore(pool) + got, err := events.ListAfter(ctx, recipient.ID, 0, 20) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + rebuilt := 0 + for _, ev := range got { + if ev.Type == domain.UpdateEventNewMessage && ev.Message.ID != 0 { + if ev.Message.GroupedID != groupedID { + t.Fatalf("update-event grouped = %d, want %d(重建路径丢失分组)", ev.Message.GroupedID, groupedID) + } + rebuilt++ + } + } + if rebuilt != 2 { + t.Fatalf("rebuilt new_message events with grouped = %d, want 2", rebuilt) + } +} + +// TestSendChannelGroupedIDSurvivesReadPaths 回归迁移 0008:频道侧(手写 SQL)相册 +// grouped_id 经发送结果/事件/history/GetChannelMessages 保真。 +func TestSendChannelGroupedIDSurvivesReadPaths(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 3201, Phone: "+1887" + suffix + "01", FirstName: "AlbumChannelOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Album Channel " + suffix, + Megagroup: true, + Date: 1700002100, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + const groupedID = int64(990000000456) + + var msgIDs []int + for i := 0; i < 2; i++ { + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: int64(990401 + i), + Message: "channel album item", + GroupedID: groupedID, + Date: 1700002101 + i, + }) + if err != nil { + t.Fatalf("send channel album item %d: %v", i, err) + } + if sent.Message.GroupedID != groupedID || sent.Event.Message.GroupedID != groupedID { + t.Fatalf("sent grouped = msg %d event %d, want %d", sent.Message.GroupedID, sent.Event.Message.GroupedID, groupedID) + } + msgIDs = append(msgIDs, sent.Message.ID) + } + + hist, err := channels.ListChannelHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ChannelID: channelID, Limit: 10}) + if err != nil { + t.Fatalf("channel history: %v", err) + } + album := 0 + for _, m := range hist.Messages { + if m.GroupedID == groupedID { + album++ + } + } + if album != 2 { + t.Fatalf("channel history grouped count = %d, want 2", album) + } + + byID, err := channels.GetChannelMessages(ctx, owner.ID, channelID, msgIDs) + if err != nil { + t.Fatalf("get channel messages: %v", err) + } + if len(byID.Messages) != 2 { + t.Fatalf("get channel messages = %d, want 2", len(byID.Messages)) + } + for _, m := range byID.Messages { + if m.GroupedID != groupedID { + t.Fatalf("channel get-by-id grouped = %d, want %d", m.GroupedID, groupedID) + } + } +} diff --git a/internal/store/postgres/message_helpers.go b/internal/store/postgres/message_helpers.go new file mode 100644 index 00000000..d500844b --- /dev/null +++ b/internal/store/postgres/message_helpers.go @@ -0,0 +1,322 @@ +package postgres + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "github.com/jackc/pgx/v5" + "hash/fnv" + "sort" + "telesrv/internal/domain" +) + +func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { + if reply == nil { + return nil + } + clone := *reply + clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...) + return &clone +} + +func cloneChannelMessageAction(action *domain.ChannelMessageAction) *domain.ChannelMessageAction { + if action == nil { + return nil + } + clone := *action + clone.UserIDs = append([]int64(nil), action.UserIDs...) + clone.Completed = append([]int(nil), action.Completed...) + clone.Incompleted = append([]int(nil), action.Incompleted...) + clone.TodoItems = append([]domain.MessageTodoItem(nil), action.TodoItems...) + if action.Closed != nil { + v := *action.Closed + clone.Closed = &v + } + if action.Hidden != nil { + v := *action.Hidden + clone.Hidden = &v + } + if action.StarGift != nil { + g := *action.StarGift + if action.StarGift.Sticker != nil { + sticker := *action.StarGift.Sticker + g.Sticker = &sticker + } + clone.StarGift = &g + } + clone.Wallpaper = domain.CloneWallpaperPtr(action.Wallpaper) + return &clone +} + +func normalizeMessageIDs(ids []int) []int { + if len(ids) == 0 { + return nil + } + out := make([]int, 0, len(ids)) + seen := make(map[int]struct{}, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Ints(out) + return out +} + +func int32s(ids []int) []int32 { + if len(ids) == 0 { + return nil + } + out := make([]int32, 0, len(ids)) + for _, id := range ids { + out = append(out, pgInt32NonNegative(id)) + } + return out +} + +func pgInt32NonNegative(v int) int32 { + if v <= 0 { + return 0 + } + if v > domain.MaxMessageBoxID { + return int32(domain.MaxMessageBoxID) + } + return int32(v) +} + +func pgInt32Bounded(v int) int32 { + if v > domain.MaxMessageBoxID { + return int32(domain.MaxMessageBoxID) + } + if v < -domain.MaxMessageBoxID { + return int32(-domain.MaxMessageBoxID) + } + return int32(v) +} + +func maxMessageBoxIDForDialog(ctx context.Context, tx pgx.Tx, ownerUserID int64, peer domain.Peer) (int, error) { + var maxID int + if err := tx.QueryRow(ctx, ` +SELECT COALESCE(MAX(box_id), 0)::int +FROM message_boxes +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3`, ownerUserID, string(peer.Type), peer.ID).Scan(&maxID); err != nil { + return 0, err + } + return maxID, nil +} + +type messageMetadataParams struct { + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJSON []byte + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + // SavedPeerType/SavedPeerID 由发送路径按 self-chat 语义填充, + // 不属于 forward header 本身。 + SavedPeerType string + SavedPeerID int64 +} + +func messageMetadataParamsFrom(silent, noforwards bool, reply *domain.MessageReply, forward *domain.MessageForward) (messageMetadataParams, error) { + meta := messageMetadataParams{ + Silent: silent, + Noforwards: noforwards, + QuoteEntitiesJSON: []byte("[]"), + } + if reply != nil { + if err := domain.ValidateMessageReplyBounds(reply); err != nil { + return messageMetadataParams{}, err + } + quoteEntities, err := encodeMessageEntities(reply.QuoteEntities) + if err != nil { + return messageMetadataParams{}, err + } + meta.ReplyToMsgID = int32(reply.MessageID) + meta.ReplyToPeerType = string(reply.Peer.Type) + meta.ReplyToPeerID = reply.Peer.ID + meta.ReplyToTopID = int32(reply.TopMessageID) + meta.ReplyToStoryID = int32(reply.StoryID) + meta.QuoteText = reply.QuoteText + meta.QuoteEntitiesJSON = quoteEntities + meta.QuoteOffset = int32(reply.QuoteOffset) + } + if forward != nil { + if forward.Date < 0 { + return messageMetadataParams{}, fmt.Errorf("forward metadata: invalid date") + } + meta.FwdFromPeerType = string(forward.From.Type) + meta.FwdFromPeerID = forward.From.ID + meta.FwdFromName = forward.FromName + meta.FwdDate = int32(forward.Date) + if forward.SavedFrom.ID != 0 { + meta.FwdSavedFromPeerType = string(forward.SavedFrom.Type) + meta.FwdSavedFromPeerID = forward.SavedFrom.ID + meta.FwdSavedFromMsgID = int32(forward.SavedFromMsgID) + } + } + return meta, nil +} + +func messageMetadataFromFields(silent, noforwards bool, replyToMsgID int32, replyToPeerType string, replyToPeerID int64, replyToTopID int32, replyToStoryID int32, quoteText, quoteEntitiesJSON string, quoteOffset int32, fwdFromPeerType string, fwdFromPeerID int64, fwdFromName string, fwdDate int32, fwdSavedFromPeerType string, fwdSavedFromPeerID int64, fwdSavedFromMsgID int32) (bool, bool, *domain.MessageReply, *domain.MessageForward, error) { + var reply *domain.MessageReply + if replyToMsgID > 0 || replyToStoryID > 0 { + quoteEntities, err := decodeMessageEntities(quoteEntitiesJSON) + if err != nil { + return false, false, nil, nil, err + } + reply = &domain.MessageReply{ + MessageID: int(replyToMsgID), + Peer: domain.Peer{Type: domain.PeerType(replyToPeerType), ID: replyToPeerID}, + TopMessageID: int(replyToTopID), + StoryID: int(replyToStoryID), + QuoteText: quoteText, + QuoteEntities: quoteEntities, + QuoteOffset: int(quoteOffset), + } + } + var forward *domain.MessageForward + if fwdDate != 0 || fwdFromPeerID != 0 || fwdFromName != "" || fwdSavedFromPeerID != 0 { + forward = &domain.MessageForward{ + From: domain.Peer{Type: domain.PeerType(fwdFromPeerType), ID: fwdFromPeerID}, + FromName: fwdFromName, + Date: int(fwdDate), + } + if fwdSavedFromPeerID != 0 { + forward.SavedFrom = domain.Peer{Type: domain.PeerType(fwdSavedFromPeerType), ID: fwdSavedFromPeerID} + forward.SavedFromMsgID = int(fwdSavedFromMsgID) + } + } + return silent, noforwards, reply, forward, nil +} + +// savedPeerFromFields 还原 self-chat box 行的 saved 子会话分组键。 +func savedPeerFromFields(savedPeerType string, savedPeerID int64) domain.Peer { + if savedPeerType == "" || savedPeerID == 0 { + return domain.Peer{} + } + return domain.Peer{Type: domain.PeerType(savedPeerType), ID: savedPeerID} +} + +func (a pgBoxIDAllocator) NextBoxID(ctx context.Context, userID int64) (int, error) { + cur, err := a.CurrentBoxID(ctx, userID) + if err != nil { + return 0, err + } + return cur + 1, nil +} + +func (a pgBoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, error) { + v, err := a.s.q.MaxMessageBoxID(ctx, userID) + if err != nil { + return 0, err + } + return int(v), nil +} + +type messageEntityJSON struct { + Type string `json:"type"` + Offset int `json:"offset"` + Length int `json:"length"` + URL string `json:"url,omitempty"` + UserID int64 `json:"user_id,omitempty"` + Language string `json:"language,omitempty"` + DocumentID int64 `json:"document_id,omitempty"` + Collapsed bool `json:"collapsed,omitempty"` +} + +func encodeMessageEntities(entities []domain.MessageEntity) ([]byte, error) { + if len(entities) == 0 { + return []byte("[]"), nil + } + wire := make([]messageEntityJSON, 0, len(entities)) + for _, entity := range entities { + wire = append(wire, messageEntityJSON{ + Type: string(entity.Type), + Offset: entity.Offset, + Length: entity.Length, + URL: entity.URL, + UserID: entity.UserID, + Language: entity.Language, + DocumentID: entity.DocumentID, + Collapsed: entity.Collapsed, + }) + } + raw, err := json.Marshal(wire) + if err != nil { + return nil, fmt.Errorf("marshal message entities: %w", err) + } + return raw, nil +} + +func decodeMessageEntities(raw string) ([]domain.MessageEntity, error) { + if raw == "" { + return nil, nil + } + var wire []messageEntityJSON + if err := json.Unmarshal([]byte(raw), &wire); err != nil { + return nil, err + } + out := make([]domain.MessageEntity, 0, len(wire)) + for _, entity := range wire { + out = append(out, domain.MessageEntity{ + Type: domain.MessageEntityType(entity.Type), + Offset: entity.Offset, + Length: entity.Length, + URL: entity.URL, + UserID: entity.UserID, + Language: entity.Language, + DocumentID: entity.DocumentID, + Collapsed: entity.Collapsed, + }) + } + return out, nil +} + +func sameMessageEntities(a, b []domain.MessageEntity) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func messageListHash(messages []domain.Message) int64 { + if len(messages) == 0 { + return 0 + } + h := fnv.New64a() + var buf [24]byte + for _, msg := range messages { + binary.LittleEndian.PutUint32(buf[:4], uint32(msg.ID)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(msg.Date)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(msg.From.ID)) + binary.LittleEndian.PutUint64(buf[16:24], uint64(msg.UID)) + _, _ = h.Write(buf[:]) + writeMessageReactionsHash(h, msg.Reactions) + } + return int64(h.Sum64()) +} diff --git a/internal/store/postgres/message_history.go b/internal/store/postgres/message_history.go new file mode 100644 index 00000000..32704ae6 --- /dev/null +++ b/internal/store/postgres/message_history.go @@ -0,0 +1,1056 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" + "time" +) + +func (s *MessageStore) GetByIDs(ctx context.Context, userID int64, ids []int) (domain.MessageList, error) { + if userID == 0 || len(ids) == 0 { + return domain.MessageList{}, nil + } + boxIDs := make([]int32, 0, len(ids)) + for _, id := range ids { + if id <= 0 || id > domain.MaxMessageBoxID { + continue + } + boxIDs = append(boxIDs, int32(id)) + } + if len(boxIDs) == 0 { + return domain.MessageList{}, nil + } + rows, err := s.q.GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{ + OwnerUserID: userID, + BoxIds: boxIDs, + }) + if err != nil { + return domain.MessageList{}, fmt.Errorf("get messages by ids: %w", err) + } + out := domain.MessageList{ + Messages: make([]domain.Message, 0, len(rows)), + Users: make([]domain.User, 0, len(rows)*2), + } + seenUsers := map[int64]struct{}{} + for _, row := range rows { + msg, err := messageFromIDRow(row) + if err != nil { + return domain.MessageList{}, err + } + out.Messages = append(out.Messages, msg) + appendUsersFromMessageIDRow(&out, seenUsers, row) + } + if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { + return domain.MessageList{}, err + } + out.Hash = messageListHash(out.Messages) + return out, nil +} + +func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) { + limit := filter.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + addOffset := domain.ClampMessageHistoryAddOffset(filter.AddOffset) + queryLimit := limit + probeHasMore := !filter.NeedTotalCount && addOffset >= 0 + if probeHasMore { + queryLimit++ + } + savedPeerType := "" + var savedPeerID int64 + if filter.SavedPeer.ID != 0 { + savedPeerType = string(filter.SavedPeer.Type) + savedPeerID = filter.SavedPeer.ID + } + // add_offset>=0 是 backward 热路径(初始加载/上滑翻页,占 getHistory 绝大多数)。 + // 走扁平静态查询 ListMessagesBackward:规划仅单 index scan + 2 LEFT JOIN,避免 + // ListMessagesByUser 大 CTE 把 4 个分支+total 全树规划(6.7ms→~1ms)。与 CTE + // 的 backward 分支逐位等价。around/forward(add_offset<0,锚点跳转,较罕见)仍走 + // 原 CTE,逻辑零改动。total 在 backward 路径由 CountMessagesByUser 独立提供。 + var rows []sqlcgen.ListMessagesByUserRow + if addOffset >= 0 { + bw, err := s.q.ListMessagesBackward(ctx, sqlcgen.ListMessagesBackwardParams{ + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + Query: filter.Query, + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + PinnedOnly: filter.PinnedOnly, + MusicOnly: filter.MusicOnly, + SavedPeerType: savedPeerType, + SavedPeerID: savedPeerID, + OffsetDate: pgInt32NonNegative(filter.OffsetDate), + OffsetID: pgInt32NonNegative(filter.OffsetID), + RowOffset: pgInt32Bounded(addOffset), + LimitCount: int32(queryLimit), + }) + if err != nil { + return domain.MessageList{}, fmt.Errorf("list messages (backward): %w", err) + } + rows = make([]sqlcgen.ListMessagesByUserRow, len(bw)) + for i := range bw { + rows[i] = backwardRowToByUserRow(bw[i]) + } + if filter.NeedTotalCount { + total, err := s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{ + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + Query: filter.Query, + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + PinnedOnly: filter.PinnedOnly, + MusicOnly: filter.MusicOnly, + SavedPeerType: savedPeerType, + SavedPeerID: savedPeerID, + }) + if err != nil { + return domain.MessageList{}, fmt.Errorf("count messages: %w", err) + } + // 镜像原 CTE 的 CROSS JOIN total 语义:total_count 只随结果行下发, + // paged 为空时不产出(out.Count 保持 0),故仅在有行时附着。 + for i := range rows { + rows[i].TotalCount = total + } + } + } else { + var err error + rows, err = s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{ + OwnerUserID: userID, + HasPeer: filter.HasPeer, + PeerType: string(filter.Peer.Type), + PeerID: filter.Peer.ID, + Query: filter.Query, + OffsetID: pgInt32NonNegative(filter.OffsetID), + OffsetDate: pgInt32NonNegative(filter.OffsetDate), + MaxID: pgInt32NonNegative(filter.MaxID), + MinID: pgInt32NonNegative(filter.MinID), + AddOffset: pgInt32Bounded(addOffset), + LimitCount: int32(queryLimit), + PinnedOnly: filter.PinnedOnly, + MusicOnly: filter.MusicOnly, + NeedTotalCount: filter.NeedTotalCount, + SavedPeerType: savedPeerType, + SavedPeerID: savedPeerID, + }) + if err != nil { + return domain.MessageList{}, fmt.Errorf("list messages: %w", err) + } + } + hasMore := false + if probeHasMore && len(rows) > limit { + hasMore = true + rows = rows[:limit] + } + out := domain.MessageList{ + Messages: make([]domain.Message, 0, len(rows)), + Users: make([]domain.User, 0, len(rows)*2), + } + seenUsers := map[int64]struct{}{} + for _, row := range rows { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.MessageList{}, fmt.Errorf("decode message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.ReplyToStoryID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + row.FwdSavedFromPeerType, + row.FwdSavedFromPeerID, + row.FwdSavedFromMsgID, + ) + if err != nil { + return domain.MessageList{}, fmt.Errorf("decode message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.MessageList{}, fmt.Errorf("decode message media: %w", err) + } + markup, err := decodeReplyMarkup(row.ReplyMarkupJson) + if err != nil { + return domain.MessageList{}, fmt.Errorf("decode message reply markup: %w", err) + } + rich, err := decodeRichMessage(row.RichMessageJson) + if err != nil { + return domain.MessageList{}, fmt.Errorf("decode message rich message: %w", err) + } + out.Messages = append(out.Messages, domain.Message{ + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + TTLPeriod: int(row.TtlPeriod), + ExpiresAt: int(row.ExpiresAt), + Media: media, + ReplyMarkup: markup, + RichMessage: rich, + MediaUnread: row.MediaUnread, + ReactionUnread: row.ReactionUnread, + ViaBotID: row.ViaBotID, + GroupedID: row.GroupedID, + Effect: row.Effect, + Pinned: row.Pinned, + SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), + }) + if filter.NeedTotalCount && out.Count == 0 { + out.Count = int(row.TotalCount) + } + appendUserFromMessageRow(&out, seenUsers, row) + } + if !filter.NeedTotalCount { + out.Count = len(out.Messages) + if hasMore { + out.Count++ + } + } + if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { + return domain.MessageList{}, err + } + out.Hash = messageListHash(out.Messages) + return out, nil +} + +func (s *MessageStore) ReadHistory(ctx context.Context, req domain.ReadHistoryRequest) (res domain.ReadHistoryResult, err error) { + res = domain.ReadHistoryResult{OwnerUserID: req.OwnerUserID, Peer: req.Peer, MaxID: req.MaxID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("read history: missing owner user id") + } + if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { + return res, fmt.Errorf("read history: invalid peer") + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("read history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin read history tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + }() + + // advisory lock 串行化与会话对端的并发写(peer 即私聊另一方 / 回执 sender),须在行锁前获取。 + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { + return res, fmt.Errorf("lock read history users: %w", err) + } + + state, err := qtx.GetDialogReadStateForUpdate(ctx, sqlcgen.GetDialogReadStateForUpdateParams{ + UserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return res, nil + } + return res, fmt.Errorf("get dialog read state: %w", err) + } + oldRead := int(state.ReadInboxMaxID) + maxAllocated, err := maxMessageBoxIDForDialog(ctx, tx, req.OwnerUserID, req.Peer) + if err != nil { + return res, fmt.Errorf("load dialog max box id: %w", err) + } + clampedFutureRead := false + if oldRead > maxAllocated { + if err := clampDialogReadInboxToMaxBox(ctx, tx, req.OwnerUserID, req.Peer, maxAllocated); err != nil { + return res, fmt.Errorf("clamp dialog read inbox: %w", err) + } + oldRead = maxAllocated + clampedFutureRead = true + res.StillUnreadCount = 0 + } + readMax := req.MaxID + if readMax <= 0 { + readMax = int(state.TopMessageID) + } + if readMax > int(state.TopMessageID) { + readMax = int(state.TopMessageID) + } + res.MaxID = readMax + advancesRead := readMax > oldRead + if !advancesRead { + if int(state.UnreadCount) > 0 { + updated, err := qtx.UpdateDialogReadInbox(ctx, sqlcgen.UpdateDialogReadInboxParams{ + UserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + ReadInboxMaxID: int32(readMax), + }) + if err != nil { + return res, fmt.Errorf("repair stale dialog unread count: %w", err) + } + res.MaxID = int(updated.ReadInboxMaxID) + res.StillUnreadCount = int(updated.UnreadCount) + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit read history repair tx: %w", err) + } + committed = true + } else if clampedFutureRead { + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit read history clamp tx: %w", err) + } + committed = true + } + return res, nil + } + + candidate, candidateErr := qtx.LatestIncomingReadReceiptCandidate(ctx, sqlcgen.LatestIncomingReadReceiptCandidateParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + OldReadInboxMaxID: int32(oldRead), + NewReadInboxMaxID: int32(readMax), + }) + if candidateErr != nil && !errors.Is(candidateErr, pgx.ErrNoRows) { + return res, fmt.Errorf("load read receipt candidate: %w", candidateErr) + } + + updated, err := qtx.UpdateDialogReadInbox(ctx, sqlcgen.UpdateDialogReadInboxParams{ + UserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + ReadInboxMaxID: int32(readMax), + }) + if err != nil { + return res, fmt.Errorf("update dialog read inbox: %w", err) + } + readerPts, err := s.reservePts(ctx, tx, req.OwnerUserID) + if err != nil { + return res, fmt.Errorf("allocate read history pts: %w", err) + } + res.Changed = true + res.MaxID = int(updated.ReadInboxMaxID) + res.StillUnreadCount = int(updated.UnreadCount) + res.InboxEvent = domain.UpdateEvent{ + UserID: req.OwnerUserID, + Type: domain.UpdateEventReadHistoryInbox, + Pts: readerPts, + PtsCount: 1, + Date: req.Date, + Peer: req.Peer, + MaxID: res.MaxID, + StillUnreadCount: res.StillUnreadCount, + } + if err := appendUserUpdateEvent(ctx, tx, qtx, req.OwnerUserID, res.InboxEvent); err != nil { + return res, fmt.Errorf("append read inbox event: %w", err) + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: req.OwnerUserID, + Pts: int32(readerPts), + EventType: string(domain.UpdateEventReadHistoryInbox), + ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), + ExcludeSessionID: req.OriginSessionID, + }); err != nil { + return res, fmt.Errorf("enqueue read inbox dispatch: %w", err) + } + + if candidateErr == nil && candidate.SenderOwnerUserID != 0 && int(candidate.SenderBoxID) > 0 { + if _, err := qtx.UpdateDialogReadOutbox(ctx, sqlcgen.UpdateDialogReadOutboxParams{ + UserID: candidate.SenderOwnerUserID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.OwnerUserID, + ReadOutboxMaxID: candidate.SenderBoxID, + }); err == nil { + senderPts, err := s.reservePts(ctx, tx, candidate.SenderOwnerUserID) + if err != nil { + return res, fmt.Errorf("allocate read outbox pts: %w", err) + } + res.OutboxChanged = true + res.OutboxUserID = candidate.SenderOwnerUserID + res.OutboxEvent = domain.UpdateEvent{ + UserID: candidate.SenderOwnerUserID, + Type: domain.UpdateEventReadHistoryOutbox, + Pts: senderPts, + PtsCount: 1, + Date: req.Date, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + MaxID: int(candidate.SenderBoxID), + } + if err := appendUserUpdateEvent(ctx, tx, qtx, candidate.SenderOwnerUserID, res.OutboxEvent); err != nil { + return res, fmt.Errorf("append read outbox event: %w", err) + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: candidate.SenderOwnerUserID, + Pts: int32(senderPts), + EventType: string(domain.UpdateEventReadHistoryOutbox), + ExcludeAuthKeyID: 0, + ExcludeSessionID: 0, + }); err != nil { + return res, fmt.Errorf("enqueue read outbox dispatch: %w", err) + } + } else if !errors.Is(err, pgx.ErrNoRows) { + return res, fmt.Errorf("update dialog read outbox: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit read history tx: %w", err) + } + committed = true + return res, nil +} + +func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHistoryRequest) (domain.DeleteMessagesResult, error) { + res := domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("delete history: missing owner user id") + } + if req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { + return res, fmt.Errorf("delete history: invalid peer") + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("delete history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin delete history tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + }() + + // advisory lock 串行化与会话对端的并发写,须在行锁前获取,消除 AB-BA 死锁。 + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { + return res, fmt.Errorf("lock delete history users: %w", err) + } + + maxID := pgInt32NonNegative(req.MaxID) + minDate := pgInt32NonNegative(req.MinDate) + maxDate := pgInt32NonNegative(req.MaxDate) + rows, err := qtx.DeleteMessageBoxesByPeerBatch(ctx, sqlcgen.DeleteMessageBoxesByPeerBatchParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + MaxID: maxID, + MinDate: minDate, + MaxDate: maxDate, + LimitCount: int32(domain.MaxDeleteHistoryBatch), + }) + if err != nil { + return res, fmt.Errorf("delete message boxes by peer: %w", err) + } + deleted := deletedRowsFromPeerBatchRows(rows) + if req.Revoke { + if len(deleted) > 0 { + peerRows, err := qtx.DeleteMessageBoxesByPrivateMessages(ctx, privateMessageDeleteParams(deleted)) + if err != nil { + return res, fmt.Errorf("delete revoked private history boxes: %w", err) + } + deleted = append(deleted, deletedRowsFromPrivateRows(peerRows)...) + } + // 反查只覆盖"本批从我方实删的行":我方此前已单向删除/清空过的 + // 消息不在本批,对端会永久残留。全量清史(max_id 不限)与按日期 + // 清史的 revoke 直接对对端 box 再扫一批;date 是共享属性、对端 + // 适用同一区间,box_id 上限则是 owner 私有序无法映射,部分 + // max_id 清史保持反查模型(官方 UI 无此入口)。 + if req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID { + peerSideRows, err := qtx.DeleteMessageBoxesByPeerBatch(ctx, sqlcgen.DeleteMessageBoxesByPeerBatchParams{ + OwnerUserID: req.Peer.ID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.OwnerUserID, + MaxID: 0, + MinDate: minDate, + MaxDate: maxDate, + LimitCount: int32(domain.MaxDeleteHistoryBatch), + }) + if err != nil { + return res, fmt.Errorf("delete revoked peer-side history boxes: %w", err) + } + deleted = append(deleted, deletedRowsFromPeerBatchRows(peerSideRows)...) + } + } + res, err = s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, req.JustClear) + if err != nil { + return res, err + } + more, err := qtx.HasDeletableMessageBoxByPeer(ctx, sqlcgen.HasDeletableMessageBoxByPeerParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + MaxID: maxID, + MinDate: minDate, + MaxDate: maxDate, + }) + if err != nil { + return res, fmt.Errorf("check remaining history after delete: %w", err) + } + if !more && req.Revoke && req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID { + more, err = qtx.HasDeletableMessageBoxByPeer(ctx, sqlcgen.HasDeletableMessageBoxByPeerParams{ + OwnerUserID: req.Peer.ID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.OwnerUserID, + MaxID: 0, + MinDate: minDate, + MaxDate: maxDate, + }) + if err != nil { + return res, fmt.Errorf("check remaining peer history after revoke: %w", err) + } + } + if more { + res.Offset = 1 + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit delete history tx: %w", err) + } + committed = true + return res, nil +} + +func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message { + entities, _ := decodeMessageEntities(row.EntitiesJson) + media, _ := decodeMessageMedia(row.MediaJson) + markup, _ := decodeReplyMarkup(row.ReplyMarkupJson) + rich, _ := decodeRichMessage(row.RichMessageJson) + silent, noforwards, reply, forward, _ := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.ReplyToStoryID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + row.FwdSavedFromPeerType, + row.FwdSavedFromPeerID, + row.FwdSavedFromMsgID, + ) + return domain.Message{ + Media: media, + ReplyMarkup: markup, + RichMessage: rich, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + TTLPeriod: int(row.TtlPeriod), + ExpiresAt: int(row.ExpiresAt), + MediaUnread: row.MediaUnread, + ReactionUnread: row.ReactionUnread, + ViaBotID: row.ViaBotID, + GroupedID: row.GroupedID, + Effect: row.Effect, + Pinned: row.Pinned, + SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), + } +} + +func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.Message { + entities, _ := decodeMessageEntities(row.EntitiesJson) + media, _ := decodeMessageMedia(row.MediaJson) + markup, _ := decodeReplyMarkup(row.ReplyMarkupJson) + rich, _ := decodeRichMessage(row.RichMessageJson) + silent, noforwards, reply, forward, _ := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.ReplyToStoryID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + row.FwdSavedFromPeerType, + row.FwdSavedFromPeerID, + row.FwdSavedFromMsgID, + ) + return domain.Message{ + Media: media, + ReplyMarkup: markup, + RichMessage: rich, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + TTLPeriod: int(row.TtlPeriod), + ExpiresAt: int(row.ExpiresAt), + MediaUnread: row.MediaUnread, + ReactionUnread: row.ReactionUnread, + ViaBotID: row.ViaBotID, + GroupedID: row.GroupedID, + Effect: row.Effect, + Pinned: row.Pinned, + SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), + } +} + +func messageFromVisibleBoxRow(row sqlcgen.ListVisibleMessageBoxesByPrivateMessageRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode visible message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.ReplyToStoryID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + row.FwdSavedFromPeerType, + row.FwdSavedFromPeerID, + row.FwdSavedFromMsgID, + ) + if err != nil { + return domain.Message{}, fmt.Errorf("decode visible message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode visible message media: %w", err) + } + markup, err := decodeReplyMarkup(row.ReplyMarkupJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode visible message reply markup: %w", err) + } + rich, err := decodeRichMessage(row.RichMessageJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode visible message rich message: %w", err) + } + return domain.Message{ + Media: media, + ReplyMarkup: markup, + RichMessage: rich, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + TTLPeriod: int(row.TtlPeriod), + ExpiresAt: int(row.ExpiresAt), + MediaUnread: row.MediaUnread, + ReactionUnread: row.ReactionUnread, + ViaBotID: row.ViaBotID, + GroupedID: row.GroupedID, + Effect: row.Effect, + Pinned: row.Pinned, + SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), + }, nil +} + +func messageFromUpdateEditRow(row sqlcgen.UpdateMessageBoxEditRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode edited message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.ReplyToStoryID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + row.FwdSavedFromPeerType, + row.FwdSavedFromPeerID, + row.FwdSavedFromMsgID, + ) + if err != nil { + return domain.Message{}, fmt.Errorf("decode edited message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode edited message media: %w", err) + } + markup, err := decodeReplyMarkup(row.ReplyMarkupJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode edited message reply markup: %w", err) + } + rich, err := decodeRichMessage(row.RichMessageJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode edited message rich message: %w", err) + } + return domain.Message{ + Media: media, + ReplyMarkup: markup, + RichMessage: rich, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + TTLPeriod: int(row.TtlPeriod), + ExpiresAt: int(row.ExpiresAt), + MediaUnread: row.MediaUnread, + ReactionUnread: row.ReactionUnread, + ViaBotID: row.ViaBotID, + GroupedID: row.GroupedID, + Effect: row.Effect, + Pinned: row.Pinned, + SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), + }, nil +} + +func messageFromIDRow(row sqlcgen.GetMessageBoxesByIDsRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.ReplyToStoryID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + row.FwdSavedFromPeerType, + row.FwdSavedFromPeerID, + row.FwdSavedFromMsgID, + ) + if err != nil { + return domain.Message{}, fmt.Errorf("decode message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode message media: %w", err) + } + markup, err := decodeReplyMarkup(row.ReplyMarkupJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode message reply markup: %w", err) + } + rich, err := decodeRichMessage(row.RichMessageJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode message rich message: %w", err) + } + return domain.Message{ + Media: media, + ReplyMarkup: markup, + RichMessage: rich, + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + TTLPeriod: int(row.TtlPeriod), + ExpiresAt: int(row.ExpiresAt), + MediaUnread: row.MediaUnread, + ReactionUnread: row.ReactionUnread, + ViaBotID: row.ViaBotID, + GroupedID: row.GroupedID, + Effect: row.Effect, + Pinned: row.Pinned, + SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), + }, nil +} + +func eventFromMessage(msg domain.Message) domain.UpdateEvent { + return domain.UpdateEvent{ + UserID: msg.OwnerUserID, + Type: domain.UpdateEventNewMessage, + Pts: msg.Pts, + PtsCount: 1, + Date: msg.Date, + Message: msg, + } +} + +// backwardRowToByUserRow 把 ListMessagesBackward(扁平 backward 热路径)的行适配为 +// ListMessagesByUserRow,从而复用 ListByUser 既有的解码/用户收集下游逻辑。两者列与 +// base 完全一致,仅缺 TotalCount(由调用方按 NeedTotalCount 单独填,默认 0)。 +func backwardRowToByUserRow(r sqlcgen.ListMessagesBackwardRow) sqlcgen.ListMessagesByUserRow { + return sqlcgen.ListMessagesByUserRow{ + BoxID: r.BoxID, + PrivateMessageID: r.PrivateMessageID, + OwnerUserID: r.OwnerUserID, + PeerType: r.PeerType, + PeerID: r.PeerID, + FromUserID: r.FromUserID, + MessageDate: r.MessageDate, + TtlPeriod: r.TtlPeriod, + ExpiresAt: r.ExpiresAt, + EditDate: r.EditDate, + Outgoing: r.Outgoing, + Body: r.Body, + EntitiesJson: r.EntitiesJson, + Silent: r.Silent, + Noforwards: r.Noforwards, + ReplyToMsgID: r.ReplyToMsgID, + ReplyToPeerType: r.ReplyToPeerType, + ReplyToPeerID: r.ReplyToPeerID, + ReplyToTopID: r.ReplyToTopID, + ReplyToStoryID: r.ReplyToStoryID, + QuoteText: r.QuoteText, + QuoteEntitiesJson: r.QuoteEntitiesJson, + QuoteOffset: r.QuoteOffset, + FwdFromPeerType: r.FwdFromPeerType, + FwdFromPeerID: r.FwdFromPeerID, + FwdFromName: r.FwdFromName, + FwdDate: r.FwdDate, + FwdSavedFromPeerType: r.FwdSavedFromPeerType, + FwdSavedFromPeerID: r.FwdSavedFromPeerID, + FwdSavedFromMsgID: r.FwdSavedFromMsgID, + SavedPeerType: r.SavedPeerType, + SavedPeerID: r.SavedPeerID, + Pts: r.Pts, + MediaJson: r.MediaJson, + MediaUnread: r.MediaUnread, + ReactionUnread: r.ReactionUnread, + Pinned: r.Pinned, + ViaBotID: r.ViaBotID, + GroupedID: r.GroupedID, + Effect: r.Effect, + ReplyMarkupJson: r.ReplyMarkupJson, + RichMessageJson: r.RichMessageJson, + PeerUserID: r.PeerUserID, + PeerAccessHash: r.PeerAccessHash, + PeerPhone: r.PeerPhone, + PeerFirstName: r.PeerFirstName, + PeerLastName: r.PeerLastName, + PeerUsername: r.PeerUsername, + PeerCountryCode: r.PeerCountryCode, + PeerVerified: r.PeerVerified, + PeerSupport: r.PeerSupport, + PeerIsBot: r.PeerIsBot, + PeerBotInfoVersion: r.PeerBotInfoVersion, + PeerPremiumUntil: r.PeerPremiumUntil, + PeerEmojiStatusDocumentID: r.PeerEmojiStatusDocumentID, + PeerEmojiStatusUntil: r.PeerEmojiStatusUntil, + PeerLastSeenAt: r.PeerLastSeenAt, + FromUserUserID: r.FromUserUserID, + FromUserAccessHash: r.FromUserAccessHash, + FromUserPhone: r.FromUserPhone, + FromUserFirstName: r.FromUserFirstName, + FromUserLastName: r.FromUserLastName, + FromUserUsername: r.FromUserUsername, + FromUserCountryCode: r.FromUserCountryCode, + FromUserVerified: r.FromUserVerified, + FromUserSupport: r.FromUserSupport, + FromUserIsBot: r.FromUserIsBot, + FromUserBotInfoVersion: r.FromUserBotInfoVersion, + FromUserPremiumUntil: r.FromUserPremiumUntil, + FromUserEmojiStatusDocumentID: r.FromUserEmojiStatusDocumentID, + FromUserEmojiStatusUntil: r.FromUserEmojiStatusUntil, + FromUserLastSeenAt: r.FromUserLastSeenAt, + } +} + +func appendUserFromMessageRow(out *domain.MessageList, seen map[int64]struct{}, row sqlcgen.ListMessagesByUserRow) { + appendMessageUsers(out, seen, + domain.User{ + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + Bot: row.PeerIsBot, + BotInfoVersion: int(row.PeerBotInfoVersion), + PremiumUntil: int(row.PeerPremiumUntil), + EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID, + EmojiStatusUntil: int(row.PeerEmojiStatusUntil), + LastSeenAt: int(row.PeerLastSeenAt), + }, + domain.User{ + ID: row.FromUserUserID, + AccessHash: row.FromUserAccessHash, + Phone: row.FromUserPhone, + FirstName: row.FromUserFirstName, + LastName: row.FromUserLastName, + Username: row.FromUserUsername, + CountryCode: row.FromUserCountryCode, + Verified: row.FromUserVerified, + Support: row.FromUserSupport, + Bot: row.FromUserIsBot, + BotInfoVersion: int(row.FromUserBotInfoVersion), + PremiumUntil: int(row.FromUserPremiumUntil), + EmojiStatusDocumentID: row.FromUserEmojiStatusDocumentID, + EmojiStatusUntil: int(row.FromUserEmojiStatusUntil), + LastSeenAt: int(row.FromUserLastSeenAt), + }, + ) +} + +func appendUsersFromMessageIDRow(out *domain.MessageList, seen map[int64]struct{}, row sqlcgen.GetMessageBoxesByIDsRow) { + appendMessageUsers(out, seen, + domain.User{ + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + Bot: row.PeerIsBot, + BotInfoVersion: int(row.PeerBotInfoVersion), + PremiumUntil: int(row.PeerPremiumUntil), + EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID, + EmojiStatusUntil: int(row.PeerEmojiStatusUntil), + LastSeenAt: int(row.PeerLastSeenAt), + }, + domain.User{ + ID: row.FromUserUserID, + AccessHash: row.FromUserAccessHash, + Phone: row.FromUserPhone, + FirstName: row.FromUserFirstName, + LastName: row.FromUserLastName, + Username: row.FromUserUsername, + CountryCode: row.FromUserCountryCode, + Verified: row.FromUserVerified, + Support: row.FromUserSupport, + Bot: row.FromUserIsBot, + BotInfoVersion: int(row.FromUserBotInfoVersion), + PremiumUntil: int(row.FromUserPremiumUntil), + EmojiStatusDocumentID: row.FromUserEmojiStatusDocumentID, + EmojiStatusUntil: int(row.FromUserEmojiStatusUntil), + LastSeenAt: int(row.FromUserLastSeenAt), + }, + ) +} + +func appendMessageUsers(out *domain.MessageList, seen map[int64]struct{}, users ...domain.User) { + add := func(u domain.User) { + if u.ID == 0 { + return + } + if _, ok := seen[u.ID]; ok { + return + } + seen[u.ID] = struct{}{} + out.Users = append(out.Users, u) + } + for _, user := range users { + add(user) + } +} diff --git a/internal/store/postgres/message_history_integration_test.go b/internal/store/postgres/message_history_integration_test.go new file mode 100644 index 00000000..5386f26b --- /dev/null +++ b/internal/store/postgres/message_history_integration_test.go @@ -0,0 +1,178 @@ +package postgres + +import ( + "context" + "telesrv/internal/domain" + "testing" +) + +func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + alice, err := users.Create(ctx, domain.User{ + AccessHash: 91, + Phone: "+1667" + suffix + "01", + FirstName: "Alice", + }) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := users.Create(ctx, domain.User{ + AccessHash: 92, + Phone: "+1667" + suffix + "02", + FirstName: "Bob", + }) + if err != nil { + t.Fatalf("create bob: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID}) + }) + + messages := NewMessageStore(pool) + for i := 1; i <= 6; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, + RecipientUserID: bob.ID, + RandomID: int64(700 + i), + Message: "history", + Date: 1700000000 + i, + }); err != nil { + t.Fatalf("seed message %d: %v", i, err) + } + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID} + + around, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: 3, + AddOffset: -3, + Limit: 6, + NeedTotalCount: true, + }) + if err != nil { + t.Fatalf("around history: %v", err) + } + if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) { + t.Fatalf("around ids = %v, want unread/newer side plus older context", got) + } + if around.Count != 6 { + t.Fatalf("around count = %d, want full dialog count", around.Count) + } + + forward, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + OffsetID: 3, + AddOffset: -3, + Limit: 3, + NeedTotalCount: true, + }) + if err != nil { + t.Fatalf("forward history: %v", err) + } + if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) { + t.Fatalf("forward ids = %v, want messages newer than offset", got) + } + if forward.Count != 6 { + t.Fatalf("forward count = %d, want full dialog count", forward.Count) + } +} + +func TestMessageStoreSearchPagesUseHasMoreHintWithoutTotalCount(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + alice, err := users.Create(ctx, domain.User{ + AccessHash: 93, + Phone: "+1668" + suffix + "01", + FirstName: "Alice", + }) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := users.Create(ctx, domain.User{ + AccessHash: 94, + Phone: "+1668" + suffix + "02", + FirstName: "Bob", + }) + if err != nil { + t.Fatalf("create bob: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID}) + }) + + messages := NewMessageStore(pool) + for i := 1; i <= 5; i++ { + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, + RecipientUserID: bob.ID, + RandomID: int64(760 + i), + Message: "needle page", + Date: 1700000100 + i, + }); err != nil { + t.Fatalf("seed message %d: %v", i, err) + } + } + peer := domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID} + + first, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + Query: "needle", + Limit: 2, + NeedTotalCount: true, + }) + if err != nil { + t.Fatalf("first search page: %v", err) + } + if got := messageIDs(first.Messages); !sameInts(got, []int{5, 4}) { + t.Fatalf("first search ids = %v, want newest page", got) + } + if first.Count != 5 { + t.Fatalf("first search count = %d, want exact total", first.Count) + } + + second, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + Query: "needle", + OffsetID: 4, + Limit: 2, + NeedTotalCount: false, + }) + if err != nil { + t.Fatalf("second search page: %v", err) + } + if got := messageIDs(second.Messages); !sameInts(got, []int{3, 2}) { + t.Fatalf("second search ids = %v, want middle page", got) + } + if second.Count != 3 { + t.Fatalf("second search count hint = %d, want len(page)+hasMore", second.Count) + } + + last, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, + Peer: peer, + Query: "needle", + OffsetID: 2, + Limit: 2, + NeedTotalCount: false, + }) + if err != nil { + t.Fatalf("last search page: %v", err) + } + if got := messageIDs(last.Messages); !sameInts(got, []int{1}) { + t.Fatalf("last search ids = %v, want final message", got) + } + if last.Count != 1 { + t.Fatalf("last search count hint = %d, want page length without hasMore", last.Count) + } +} diff --git a/internal/store/postgres/message_integration_test.go b/internal/store/postgres/message_integration_test.go deleted file mode 100644 index 42391258..00000000 --- a/internal/store/postgres/message_integration_test.go +++ /dev/null @@ -1,813 +0,0 @@ -package postgres - -import ( - "context" - "sync" - "testing" - - "telesrv/internal/domain" -) - -func TestMessageStoreSendPrivateTextRoundTrip(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - sender, err := users.Create(ctx, domain.User{ - AccessHash: 11, - Phone: "+1666" + suffix + "01", - FirstName: "Sender", - }) - if err != nil { - t.Fatalf("create sender: %v", err) - } - recipient, err := users.Create(ctx, domain.User{ - AccessHash: 22, - Phone: "+1666" + suffix + "02", - FirstName: "Recipient", - }) - if err != nil { - t.Fatalf("create recipient: %v", err) - } - t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) - }) - - messages := NewMessageStore(pool) - var originAuthKeyID [8]byte - originAuthKeyID[0] = 5 - req := domain.SendPrivateTextRequest{ - SenderUserID: sender.ID, - RecipientUserID: recipient.ID, - RandomID: 123456, - Message: "hello from pg", - Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 5}}, - Date: 1700000200, - OriginAuthKeyID: originAuthKeyID, - OriginSessionID: 77, - } - got, err := messages.SendPrivateText(ctx, req) - if err != nil { - t.Fatalf("SendPrivateText: %v", err) - } - if got.SenderMessage.ID != 1 || got.SenderMessage.Pts != 1 || !got.SenderMessage.Out || got.SenderMessage.Peer.ID != recipient.ID { - t.Fatalf("sender message = %+v, want first outgoing box to recipient", got.SenderMessage) - } - if got.RecipientMessage.ID != 1 || got.RecipientMessage.Pts != 1 || got.RecipientMessage.Out || got.RecipientMessage.Peer.ID != sender.ID { - t.Fatalf("recipient message = %+v, want first incoming box from sender", got.RecipientMessage) - } - if got.SenderMessage.UID == 0 || got.SenderMessage.UID != got.RecipientMessage.UID { - t.Fatalf("uid = sender %d recipient %d, want shared private message uid", got.SenderMessage.UID, got.RecipientMessage.UID) - } - - senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: got.SenderMessage.Peer, Limit: 10}) - if err != nil { - t.Fatalf("sender history: %v", err) - } - recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: got.RecipientMessage.Peer, Limit: 10}) - if err != nil { - t.Fatalf("recipient history: %v", err) - } - if len(senderHistory.Messages) != 1 || len(recipientHistory.Messages) != 1 { - t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages)) - } - - events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) - if err != nil { - t.Fatalf("list recipient events: %v", err) - } - if len(events) != 1 || events[0].Message.ID != got.RecipientMessage.ID || len(events[0].Users) != 1 || events[0].Users[0].ID != sender.ID { - t.Fatalf("recipient events = %+v, want new message with sender user", events) - } - - var pendingOutbox int - if err := pool.QueryRow(ctx, ` - SELECT count(*) - FROM dispatch_outbox - WHERE target_user_id = ANY($1::bigint[]) - AND status = 'pending' - `, []int64{sender.ID, recipient.ID}).Scan(&pendingOutbox); err != nil { - t.Fatalf("count dispatch outbox: %v", err) - } - if pendingOutbox != 2 { - t.Fatalf("pending outbox = %d, want sender + recipient dispatch rows", pendingOutbox) - } - var excludeAuthKeyID, excludeSessionID int64 - if err := pool.QueryRow(ctx, ` - SELECT exclude_auth_key_id, exclude_session_id - FROM dispatch_outbox - WHERE target_user_id = $1 - `, sender.ID).Scan(&excludeAuthKeyID, &excludeSessionID); err != nil { - t.Fatalf("sender dispatch outbox: %v", err) - } - if excludeAuthKeyID != authKeyIDToInt64(originAuthKeyID) || excludeSessionID != 77 { - t.Fatalf("sender dispatch exclude = auth %d session %d, want origin auth/session", excludeAuthKeyID, excludeSessionID) - } - - dup, err := messages.SendPrivateText(ctx, req) - if err != nil { - t.Fatalf("SendPrivateText duplicate: %v", err) - } - if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID { - t.Fatalf("duplicate = %+v, want original message boxes", dup) - } -} - -func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - alice, err := users.Create(ctx, domain.User{ - AccessHash: 91, - Phone: "+1667" + suffix + "01", - FirstName: "Alice", - }) - if err != nil { - t.Fatalf("create alice: %v", err) - } - bob, err := users.Create(ctx, domain.User{ - AccessHash: 92, - Phone: "+1667" + suffix + "02", - FirstName: "Bob", - }) - if err != nil { - t.Fatalf("create bob: %v", err) - } - t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID}) - }) - - messages := NewMessageStore(pool) - for i := 1; i <= 6; i++ { - if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ - SenderUserID: alice.ID, - RecipientUserID: bob.ID, - RandomID: int64(700 + i), - Message: "history", - Date: 1700000000 + i, - }); err != nil { - t.Fatalf("seed message %d: %v", i, err) - } - } - peer := domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID} - - around, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ - HasPeer: true, - Peer: peer, - OffsetID: 3, - AddOffset: -3, - Limit: 6, - NeedTotalCount: true, - }) - if err != nil { - t.Fatalf("around history: %v", err) - } - if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) { - t.Fatalf("around ids = %v, want unread/newer side plus older context", got) - } - if around.Count != 6 { - t.Fatalf("around count = %d, want full dialog count", around.Count) - } - - forward, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ - HasPeer: true, - Peer: peer, - OffsetID: 3, - AddOffset: -3, - Limit: 3, - NeedTotalCount: true, - }) - if err != nil { - t.Fatalf("forward history: %v", err) - } - if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) { - t.Fatalf("forward ids = %v, want messages newer than offset", got) - } - if forward.Count != 6 { - t.Fatalf("forward count = %d, want full dialog count", forward.Count) - } -} - -func TestMessageStoreReadAndEditEmitDurableEvents(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - sender, err := users.Create(ctx, domain.User{ - AccessHash: 31, - Phone: "+1666" + suffix + "11", - FirstName: "ReadSender", - }) - if err != nil { - t.Fatalf("create sender: %v", err) - } - recipient, err := users.Create(ctx, domain.User{ - AccessHash: 32, - Phone: "+1666" + suffix + "12", - FirstName: "ReadRecipient", - }) - if err != nil { - t.Fatalf("create recipient: %v", err) - } - t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) - }) - - messages := NewMessageStore(pool) - sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ - SenderUserID: sender.ID, - RecipientUserID: recipient.ID, - RandomID: 223344, - Message: "before edit", - Date: 1700000300, - }) - if err != nil { - t.Fatalf("SendPrivateText: %v", err) - } - read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ - OwnerUserID: recipient.ID, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, - Date: 1700000310, - }) - if err != nil { - t.Fatalf("ReadHistory: %v", err) - } - if !read.Changed || read.InboxEvent.Pts != 2 || read.InboxEvent.Type != domain.UpdateEventReadHistoryInbox || read.InboxEvent.MaxID != sent.RecipientMessage.ID { - t.Fatalf("read inbox = %+v, want recipient pts=2 max recipient id", read) - } - if !read.OutboxChanged || read.OutboxEvent.Pts != 2 || read.OutboxEvent.Type != domain.UpdateEventReadHistoryOutbox || read.OutboxEvent.MaxID != sent.SenderMessage.ID { - t.Fatalf("read outbox = %+v, want sender pts=2 max sender id", read) - } - readDate, err := messages.GetOutboxReadDate(ctx, domain.OutboxReadDateRequest{ - OwnerUserID: sender.ID, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}, - ID: sent.SenderMessage.ID, - }) - if err != nil || readDate != 1700000310 { - t.Fatalf("outbox read date = %d err=%v, want read date", readDate, err) - } - - edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{ - OwnerUserID: sender.ID, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}, - ID: sent.SenderMessage.ID, - Message: "after edit", - EditDate: 1700000320, - }) - if err != nil { - t.Fatalf("EditMessage: %v", err) - } - if self := edited.Self(); self.Event.Pts != 3 || self.Event.Type != domain.UpdateEventEditMessage || self.Message.Body != "after edit" { - t.Fatalf("self edit = %+v, want sender edit event pts=3", self) - } - recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{ - HasPeer: true, - Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, - Limit: 10, - }) - if err != nil { - t.Fatalf("recipient history: %v", err) - } - if len(recipientHistory.Messages) != 1 || recipientHistory.Messages[0].Body != "after edit" || recipientHistory.Messages[0].EditDate != 1700000320 { - t.Fatalf("recipient history = %+v, want edited message visible", recipientHistory.Messages) - } - - senderEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 0, 10) - if err != nil { - t.Fatalf("sender events: %v", err) - } - if len(senderEvents) != 3 || senderEvents[1].Type != domain.UpdateEventReadHistoryOutbox || senderEvents[2].Type != domain.UpdateEventEditMessage { - t.Fatalf("sender events = %+v, want new/read_outbox/edit", senderEvents) - } - recipientEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) - if err != nil { - t.Fatalf("recipient events: %v", err) - } - if len(recipientEvents) != 3 || recipientEvents[1].Type != domain.UpdateEventReadHistoryInbox || recipientEvents[2].Type != domain.UpdateEventEditMessage || recipientEvents[2].Message.Body != "after edit" { - t.Fatalf("recipient events = %+v, want new/read_inbox/edit with edited body", recipientEvents) - } -} - -func TestMessageStoreSendPrivateTextRollbackRecordsPtsNoop(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - sender, err := users.Create(ctx, domain.User{ - AccessHash: 31, - Phone: "+1777" + suffix + "01", - FirstName: "GapSender", - }) - if err != nil { - t.Fatalf("create sender: %v", err) - } - recipient, err := users.Create(ctx, domain.User{ - AccessHash: 32, - Phone: "+1777" + suffix + "02", - FirstName: "GapRecipient", - }) - if err != nil { - t.Fatalf("create recipient: %v", err) - } - t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) - }) - - messages := NewMessageStore(pool) - if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ - SenderUserID: sender.ID, - RecipientUserID: recipient.ID, - RandomID: 223344, - Message: "seed box", - Date: 1700000210, - }); err != nil { - t.Fatalf("seed SendPrivateText: %v", err) - } - - failing := NewMessageStore(pool, WithMessageAllocators(fixedBoxIDAllocator{next: 1}, fixedPtsAllocator{next: 42})) - _, err = failing.SendPrivateText(ctx, domain.SendPrivateTextRequest{ - SenderUserID: sender.ID, - RecipientUserID: recipient.ID, - RandomID: 223345, - Message: "should roll back", - Date: 1700000211, - }) - if err == nil { - t.Fatal("SendPrivateText succeeded, want box id conflict") - } - - events, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 1, 10) - if err != nil { - t.Fatalf("list sender events: %v", err) - } - for _, event := range events { - if event.Pts == 42 && event.Type == domain.UpdateEventNoop { - return - } - } - t.Fatalf("events = %+v, want noop gap at pts=42", events) -} - -func TestMessageStoreConcurrentRandomIDIdempotent(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - sender, err := users.Create(ctx, domain.User{ - AccessHash: 41, - Phone: "+1888" + suffix + "01", - FirstName: "ConcurrentSender", - }) - if err != nil { - t.Fatalf("create sender: %v", err) - } - recipient, err := users.Create(ctx, domain.User{ - AccessHash: 42, - Phone: "+1888" + suffix + "02", - FirstName: "ConcurrentRecipient", - }) - if err != nil { - t.Fatalf("create recipient: %v", err) - } - t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) - }) - - boxCounters := &perUserCounterAllocator{} - ptsCounters := &perUserCounterAllocator{} - messages := NewMessageStore(pool, WithMessageAllocators(boxCounters, ptsCounters)) - req := domain.SendPrivateTextRequest{ - SenderUserID: sender.ID, - RecipientUserID: recipient.ID, - RandomID: 556677, - Message: "same random id", - Date: 1700000220, - } - - const workers = 8 - results := make(chan domain.SendPrivateTextResult, workers) - errs := make(chan error, workers) - var wg sync.WaitGroup - for range workers { - wg.Add(1) - go func() { - defer wg.Done() - res, err := messages.SendPrivateText(ctx, req) - if err != nil { - errs <- err - return - } - results <- res - }() - } - wg.Wait() - close(results) - close(errs) - - for err := range errs { - t.Fatalf("SendPrivateText: %v", err) - } - var uid int64 - duplicates := 0 - successes := 0 - for res := range results { - if res.SenderMessage.UID == 0 || res.RecipientMessage.UID == 0 { - t.Fatalf("result = %+v, want populated shared message uid", res) - } - if uid == 0 { - uid = res.SenderMessage.UID - } - if res.SenderMessage.UID != uid || res.RecipientMessage.UID != uid { - t.Fatalf("result = %+v, want same private message uid %d", res, uid) - } - if res.Duplicate { - duplicates++ - } else { - successes++ - } - } - if successes != 1 || duplicates != workers-1 { - t.Fatalf("successes=%d duplicates=%d, want one insert and duplicate rest", successes, duplicates) - } - - var privateCount int - if err := pool.QueryRow(ctx, ` - SELECT count(*) - FROM private_messages - WHERE sender_user_id = $1 - AND random_id = $2 - `, sender.ID, req.RandomID).Scan(&privateCount); err != nil { - t.Fatalf("count private_messages: %v", err) - } - if privateCount != 1 { - t.Fatalf("private message count = %d, want 1", privateCount) - } - var boxCount int - if err := pool.QueryRow(ctx, ` - SELECT count(*) - FROM message_boxes - WHERE private_message_id = $1 - `, uid).Scan(&boxCount); err != nil { - t.Fatalf("count message boxes: %v", err) - } - if boxCount != 2 { - t.Fatalf("message box count = %d, want sender + recipient boxes", boxCount) - } -} - -func TestMessageStoreDeleteHistoryRebuildsDialogAndEmitsDeleteUpdates(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - sender := createTestUser(t, ctx, users, "+1991"+suffix+"01", "DeleteSender", "") - recipient := createTestUser(t, ctx, users, "+1991"+suffix+"02", "DeleteRecipient", "") - t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) - }) - - messages := NewMessageStore(pool) - for i := 0; i < 2; i++ { - if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ - SenderUserID: sender.ID, - RecipientUserID: recipient.ID, - RandomID: int64(7000 + i), - Message: "history", - Date: 1700000700 + i, - }); err != nil { - t.Fatalf("seed send %d: %v", i, err) - } - } - peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID} - deleted, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ - OwnerUserID: sender.ID, - Peer: peer, - Date: 1700000800, - }) - if err != nil { - t.Fatalf("DeleteHistory: %v", err) - } - if self := deleted.Self(); self.Event.Pts != 4 || self.Event.PtsCount != 2 || len(self.MessageIDs) != 2 { - t.Fatalf("delete result = %+v, want sender delete range pts=4 count=2 ids", self) - } - senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10}) - if err != nil { - t.Fatalf("sender history: %v", err) - } - recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10}) - if err != nil { - t.Fatalf("recipient history: %v", err) - } - if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 { - t.Fatalf("history sizes sender=%d recipient=%d, want sender cleared only", len(senderHistory.Messages), len(recipientHistory.Messages)) - } - senderDialogs, err := NewDialogStore(pool).ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 10}) - if err != nil { - t.Fatalf("sender dialogs after delete: %v", err) - } - if len(senderDialogs.Dialogs) != 0 { - t.Fatalf("sender dialogs = %+v, want empty after full history delete", senderDialogs.Dialogs) - } - events, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 2, 10) - if err != nil { - t.Fatalf("list sender events: %v", err) - } - if len(events) != 1 || events[0].Type != domain.UpdateEventDeleteMessages || events[0].Pts != 4 || events[0].PtsCount != 2 || len(events[0].MessageIDs) != 2 { - t.Fatalf("events = %+v, want delete messages event pts=4 pts_count=2", events) - } - - rebuilt, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ - SenderUserID: sender.ID, - RecipientUserID: recipient.ID, - RandomID: 8000, - Message: "after clear", - Date: 1700000900, - }) - if err != nil { - t.Fatalf("send after delete: %v", err) - } - senderDialogs, err = NewDialogStore(pool).ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 10}) - if err != nil { - t.Fatalf("sender dialogs after rebuild: %v", err) - } - if len(senderDialogs.Dialogs) != 1 || senderDialogs.Dialogs[0].Peer != peer || senderDialogs.Dialogs[0].TopMessage != rebuilt.SenderMessage.ID { - t.Fatalf("rebuilt dialogs = %+v, want new top message %d", senderDialogs.Dialogs, rebuilt.SenderMessage.ID) - } - - revoked, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{ - OwnerUserID: sender.ID, - IDs: []int{rebuilt.SenderMessage.ID}, - Revoke: true, - Date: 1700001000, - }) - if err != nil { - t.Fatalf("DeleteMessages revoke: %v", err) - } - if len(revoked.Deleted) != 2 || !revoked.Changed() { - t.Fatalf("revoked = %+v, want delete events for both owners", revoked) - } - senderHistory, err = messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10}) - if err != nil { - t.Fatalf("sender history after revoke: %v", err) - } - recipientHistory, err = messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10}) - if err != nil { - t.Fatalf("recipient history after revoke: %v", err) - } - if len(senderHistory.Messages) != 0 || len(recipientHistory.Messages) != 2 { - t.Fatalf("history sizes after revoke sender=%d recipient=%d, want new message removed from both owners", len(senderHistory.Messages), len(recipientHistory.Messages)) - } -} - -func TestMessageStoreDeleteHistoryJustClearPreservesEmptyDialog(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner := createTestUser(t, ctx, users, "+1992"+suffix+"01", "ClearOwner", "") - peerUser := createTestUser(t, ctx, users, "+1992"+suffix+"02", "ClearPeer", "") - t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peerUser.ID}) - }) - - messages := NewMessageStore(pool) - if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ - SenderUserID: owner.ID, - RecipientUserID: peerUser.ID, - RandomID: 9000, - Message: "clear but keep dialog", - Date: 1700001100, - }); err != nil { - t.Fatalf("seed send: %v", err) - } - peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID} - if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ - OwnerUserID: owner.ID, - Peer: peer, - JustClear: true, - Date: 1700001200, - }); err != nil { - t.Fatalf("DeleteHistory just_clear: %v", err) - } - dialogs, err := NewDialogStore(pool).ListByUser(ctx, owner.ID, domain.DialogFilter{Limit: 10}) - if err != nil { - t.Fatalf("dialogs after just_clear: %v", err) - } - if len(dialogs.Dialogs) != 1 || dialogs.Dialogs[0].Peer != peer || dialogs.Dialogs[0].TopMessage != 0 || len(dialogs.Messages) != 0 { - t.Fatalf("dialogs = %+v messages=%+v, want empty dialog preserved after just_clear", dialogs.Dialogs, dialogs.Messages) - } - history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true}) - if err != nil { - t.Fatalf("history after just_clear: %v", err) - } - if len(history.Messages) != 0 { - t.Fatalf("history = %+v, want cleared", history.Messages) - } -} - -func TestMessageStoreDeleteHistoryBatchesHugeMaxID(t *testing.T) { - pool := testPool(t) - ctx := context.Background() - suffix := randomSuffix(t) - - users := NewUserStore(pool) - owner := createTestUser(t, ctx, users, "+1993"+suffix+"01", "BulkOwner", "") - peerUser := createTestUser(t, ctx, users, "+1993"+suffix+"02", "BulkPeer", "") - t.Cleanup(func() { - _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, peerUser.ID}) - }) - - total := domain.MaxDeleteHistoryBatch + 2 - if _, err := pool.Exec(ctx, ` - WITH src AS ( - SELECT generate_series(1, $3::int) AS g - ), - pm AS ( - INSERT INTO private_messages ( - sender_user_id, - recipient_user_id, - random_id, - message_date, - body, - entities - ) - SELECT - $1::bigint, - $2::bigint, - 910000000 + g, - 1700002000 + g, - 'bulk history', - '[]'::jsonb - FROM src - RETURNING id, random_id, message_date - ) - INSERT INTO message_boxes ( - owner_user_id, - box_id, - private_message_id, - message_sender_id, - peer_type, - peer_id, - from_user_id, - message_date, - outgoing, - body, - entities, - pts - ) - SELECT - $1::bigint, - (random_id - 910000000)::int, - id, - $1::bigint, - 'user', - $2::bigint, - $1::bigint, - message_date, - true, - 'bulk history', - '[]'::jsonb, - 0 - FROM pm - `, owner.ID, peerUser.ID, total); err != nil { - t.Fatalf("seed bulk history: %v", err) - } - if _, err := pool.Exec(ctx, ` - INSERT INTO dialogs ( - user_id, - peer_type, - peer_id, - top_message_id, - top_message_date, - read_outbox_max_id, - unread_count - ) VALUES ($1, 'user', $2, $3, $4, $3, 0) - `, owner.ID, peerUser.ID, total, 1700002000+total); err != nil { - t.Fatalf("seed dialog: %v", err) - } - - messages := NewMessageStore(pool) - peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID} - first, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ - OwnerUserID: owner.ID, - Peer: peer, - MaxID: domain.MaxMessageBoxID, - Date: 1700003000, - }) - if err != nil { - t.Fatalf("DeleteHistory first batch: %v", err) - } - self := first.Self() - if first.Offset != 1 || self.Event.Pts != domain.MaxDeleteHistoryBatch || self.Event.PtsCount != domain.MaxDeleteHistoryBatch || len(self.MessageIDs) != domain.MaxDeleteHistoryBatch { - t.Fatalf("first batch = %+v self=%+v, want offset=1 and exactly %d deleted ids", first, self, domain.MaxDeleteHistoryBatch) - } - history, err := messages.ListByUser(ctx, owner.ID, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10, NeedTotalCount: true}) - if err != nil { - t.Fatalf("history after first batch: %v", err) - } - if history.Count != 2 || len(history.Messages) != 2 || history.Messages[0].ID != 2 { - t.Fatalf("history after first batch = %+v, want only two oldest messages left", history) - } - - second, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{ - OwnerUserID: owner.ID, - Peer: peer, - MaxID: domain.MaxMessageBoxID, - Date: 1700003001, - }) - if err != nil { - t.Fatalf("DeleteHistory second batch: %v", err) - } - if second.Offset != 0 || second.Self().Event.PtsCount != 2 { - t.Fatalf("second batch = %+v, want final offset=0 pts_count=2", second) - } -} - -type fixedBoxIDAllocator struct { - next int -} - -func (a fixedBoxIDAllocator) NextBoxID(context.Context, int64) (int, error) { - return a.next, nil -} - -func (a fixedBoxIDAllocator) CurrentBoxID(context.Context, int64) (int, error) { - return a.next, nil -} - -type fixedPtsAllocator struct { - next int -} - -func (a fixedPtsAllocator) NextPts(context.Context, int64) (int, error) { - return a.next, nil -} - -func (a fixedPtsAllocator) CurrentPts(context.Context, int64) (int, error) { - return a.next, nil -} - -type perUserCounterAllocator struct { - mu sync.Mutex - values map[int64]int -} - -func (a *perUserCounterAllocator) NextBoxID(_ context.Context, userID int64) (int, error) { - return a.next(userID), nil -} - -func (a *perUserCounterAllocator) CurrentBoxID(_ context.Context, userID int64) (int, error) { - return a.current(userID), nil -} - -func (a *perUserCounterAllocator) NextPts(_ context.Context, userID int64) (int, error) { - return a.next(userID), nil -} - -func (a *perUserCounterAllocator) CurrentPts(_ context.Context, userID int64) (int, error) { - return a.current(userID), nil -} - -func (a *perUserCounterAllocator) next(userID int64) int { - a.mu.Lock() - defer a.mu.Unlock() - if a.values == nil { - a.values = map[int64]int{} - } - a.values[userID]++ - return a.values[userID] -} - -func (a *perUserCounterAllocator) current(userID int64) int { - a.mu.Lock() - defer a.mu.Unlock() - return a.values[userID] -} - -func messageIDs(messages []domain.Message) []int { - out := make([]int, 0, len(messages)) - for _, msg := range messages { - out = append(out, msg.ID) - } - return out -} - -func sameInts(got, want []int) bool { - if len(got) != len(want) { - return false - } - for i := range got { - if got[i] != want[i] { - return false - } - } - return true -} diff --git a/internal/store/postgres/message_markup_integration_test.go b/internal/store/postgres/message_markup_integration_test.go new file mode 100644 index 00000000..1f5abd0c --- /dev/null +++ b/internal/store/postgres/message_markup_integration_test.go @@ -0,0 +1,282 @@ +package postgres + +import ( + "bytes" + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestSendPrivateReplyMarkupSurvivesReadPaths 验证 bot inline keyboard(reply_markup) +// 经全部读路径字节级保真且双盒一致(P3 I2/I3): +// - 发送结果双端(sender/recipient)携带 markup; +// - history(ListByUser)读回 markup,callback data 含 0x00/0xFF/非 UTF-8 逐字节相等; +// - 接收方经 UpdateEventStore.ListAfter(在线/离线 difference 共用重建路径)带 markup; +// - 编辑替换/清空 markup 后双盒一致。 +func TestSendPrivateReplyMarkupSurvivesReadPaths(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1997" + suffix + "01", FirstName: "MarkupBot"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 72, Phone: "+1997" + suffix + "02", FirstName: "MarkupRecipient"}) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + ids := []int64{sender.ID, recipient.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) + + rawData := []byte{0x00, 0x01, 0xFF, 0x80, 'd', 'a', 't', 'a'} + markup := &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{ + {Type: domain.MarkupButtonCallback, Text: "Press", Data: rawData}, + {Type: domain.MarkupButtonURL, Text: "Open", URL: "https://example.com/p"}, + }}} + + res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: time.Now().UnixNano(), + Message: "tap", + ReplyMarkup: markup, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send private markup: %v", err) + } + + assertMarkup := func(name string, m *domain.MessageReplyMarkup) { + t.Helper() + if m == nil || len(m.Inline) != 1 || len(m.Inline[0]) != 2 { + t.Fatalf("%s: markup shape lost: %+v", name, m) + } + btn := m.Inline[0][0] + if btn.Type != domain.MarkupButtonCallback || !bytes.Equal(btn.Data, rawData) { + t.Fatalf("%s: callback data byte mismatch: %v want %v", name, btn.Data, rawData) + } + if m.Inline[0][1].Type != domain.MarkupButtonURL || m.Inline[0][1].URL != "https://example.com/p" { + t.Fatalf("%s: url button lost: %+v", name, m.Inline[0][1]) + } + } + + // 双端发送结果带 markup(I3)。 + assertMarkup("sender result", res.SenderMessage.ReplyMarkup) + assertMarkup("recipient result", res.RecipientMessage.ReplyMarkup) + + // history 读回(getHistory 主渲染路径)字节保真。 + list, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{Limit: 10}) + if err != nil { + t.Fatalf("list by user: %v", err) + } + if len(list.Messages) == 0 { + t.Fatal("recipient history empty") + } + assertMarkup("recipient history", list.Messages[0].ReplyMarkup) + + // 接收方经更新事件重建(离线 difference / 在线推送共用路径)带 markup。 + events := NewUpdateEventStore(pool) + got, err := events.ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + var sawEvent bool + for _, ev := range got { + if ev.Type == domain.UpdateEventNewMessage && ev.Message.ID != 0 { + sawEvent = true + assertMarkup("recipient update-event", ev.Message.ReplyMarkup) + } + } + if !sawEvent { + t.Fatal("no new_message event for recipient") + } + + // 编辑替换 markup(仅一个 callback 按钮)→ 双盒一致更新。 + editRes, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: sender.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}, + ID: res.SenderMessage.ID, + Message: "tap edited", + EditDate: int(time.Now().Unix()) + 1, + SetReplyMarkup: true, + ReplyMarkup: &domain.MessageReplyMarkup{Inline: [][]domain.MarkupButton{{{Type: domain.MarkupButtonCallback, Text: "Only", Data: []byte("x")}}}}, + }) + if err != nil { + t.Fatalf("edit markup: %v", err) + } + if len(editRes.Edited) == 0 { + t.Fatal("edit produced no boxes") + } + for _, e := range editRes.Edited { + m := e.Message.ReplyMarkup + if m == nil || len(m.Inline) != 1 || len(m.Inline[0]) != 1 || string(m.Inline[0][0].Data) != "x" { + t.Fatalf("edit box %d markup not replaced: %+v", e.UserID, m) + } + } + + // 编辑清空 markup(SetReplyMarkup + nil)→ 双盒 markup 清空。 + clearRes, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: sender.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}, + ID: res.SenderMessage.ID, + Message: "tap cleared", + EditDate: int(time.Now().Unix()) + 2, + SetReplyMarkup: true, + ReplyMarkup: nil, + }) + if err != nil { + t.Fatalf("clear markup: %v", err) + } + for _, e := range clearRes.Edited { + if e.Message.ReplyMarkup != nil { + t.Fatalf("clear box %d markup not cleared: %+v", e.UserID, e.Message.ReplyMarkup) + } + } +} + +func TestSendPrivateViaBotIDSurvivesReadPaths(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{AccessHash: 73, Phone: "+1997" + suffix + "03", FirstName: "InlineSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 74, Phone: "+1997" + suffix + "04", FirstName: "InlineRecipient"}) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + ids := []int64{sender.ID, recipient.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) + viaBotID := int64(770000000001) + randomID := time.Now().UnixNano() + res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: randomID, + Message: "inline via", + ViaBotID: viaBotID, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send private via bot: %v", err) + } + if res.SenderMessage.ViaBotID != viaBotID || res.RecipientMessage.ViaBotID != viaBotID { + t.Fatalf("send result via = sender %d recipient %d, want %d", res.SenderMessage.ViaBotID, res.RecipientMessage.ViaBotID, viaBotID) + } + + dup, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: randomID, + Message: "inline via duplicate", + ViaBotID: viaBotID, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("duplicate private via bot: %v", err) + } + if !dup.Duplicate || dup.SenderMessage.ViaBotID != viaBotID || dup.RecipientMessage.ViaBotID != viaBotID { + t.Fatalf("duplicate via = duplicate %v sender %d recipient %d, want duplicate true via %d", dup.Duplicate, dup.SenderMessage.ViaBotID, dup.RecipientMessage.ViaBotID, viaBotID) + } + + recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(recipientHistory.Messages) == 0 || recipientHistory.Messages[0].ViaBotID != viaBotID { + t.Fatalf("recipient history via lost: %+v", recipientHistory.Messages) + } + + byID, err := messages.GetByIDs(ctx, recipient.ID, []int{res.RecipientMessage.ID}) + if err != nil { + t.Fatalf("recipient get by ids: %v", err) + } + if len(byID.Messages) != 1 || byID.Messages[0].ViaBotID != viaBotID { + t.Fatalf("recipient get by ids via lost: %+v", byID.Messages) + } + + events := NewUpdateEventStore(pool) + got, err := events.ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + var sawEvent bool + for _, ev := range got { + if ev.Type == domain.UpdateEventNewMessage && ev.Message.ID != 0 { + sawEvent = true + if ev.Message.ViaBotID != viaBotID { + t.Fatalf("recipient update-event via = %d, want %d", ev.Message.ViaBotID, viaBotID) + } + } + } + if !sawEvent { + t.Fatal("no new_message event for recipient") + } + + dialogs := NewDialogStore(pool) + dialogList, err := dialogs.ListByUser(ctx, sender.ID, domain.DialogFilter{Limit: 20}) + if err != nil { + t.Fatalf("sender dialogs: %v", err) + } + if len(dialogList.Messages) == 0 || dialogList.Messages[0].ViaBotID != viaBotID { + t.Fatalf("sender dialog top via lost: %+v", dialogList.Messages) + } + + if _, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: recipient.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, + ID: res.RecipientMessage.ID, + Message: "wrong via bot edit", + EditDate: int(time.Now().Unix()) + 1, + ViaBotEditBotID: viaBotID + 1, + }); err != domain.ErrMessageAuthorRequired { + t.Fatalf("wrong via bot edit err = %v, want ErrMessageAuthorRequired", err) + } + + edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: recipient.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, + ID: res.RecipientMessage.ID, + Message: "inline via edited", + EditDate: int(time.Now().Unix()) + 2, + ViaBotEditBotID: viaBotID, + }) + if err != nil { + t.Fatalf("via bot edit: %v", err) + } + if len(edited.Edited) != 2 { + t.Fatalf("via bot edit boxes = %d, want 2", len(edited.Edited)) + } + senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}, Limit: 10}) + if err != nil { + t.Fatalf("sender history after via edit: %v", err) + } + if len(senderHistory.Messages) == 0 || senderHistory.Messages[0].Body != "inline via edited" || senderHistory.Messages[0].ViaBotID != viaBotID { + t.Fatalf("sender history after via edit = %+v, want edited body with via bot", senderHistory.Messages) + } +} diff --git a/internal/store/postgres/message_media_integration_test.go b/internal/store/postgres/message_media_integration_test.go index e377c9ff..d24918bf 100644 --- a/internal/store/postgres/message_media_integration_test.go +++ b/internal/store/postgres/message_media_integration_test.go @@ -37,7 +37,7 @@ func TestSendPrivateMediaSurvivesUpdateEvent(t *testing.T) { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) }) - messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{}, &perUserCounterAllocator{})) + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) media := &domain.MessageMedia{ Kind: domain.MessageMediaKindDocument, @@ -97,6 +97,225 @@ func TestSendPrivateMediaSurvivesUpdateEvent(t *testing.T) { } } +func TestEditPrivateTodoMediaByParticipantPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{AccessHash: 65, Phone: "+1998" + suffix + "05", FirstName: "TodoSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 66, Phone: "+1998" + suffix + "06", FirstName: "TodoRecipient"}) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + ids := []int64{sender.ID, recipient.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) + base := &domain.MessageMedia{ + Kind: domain.MessageMediaKindTodo, + Todo: &domain.MessageTodo{ + Title: "shared todo", + OthersCanComplete: true, + Items: []domain.MessageTodoItem{{ID: 1, Title: "one"}}, + }, + } + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: time.Now().UnixNano(), + Message: "", + Media: base, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send private todo: %v", err) + } + + updated := &domain.MessageMedia{ + Kind: domain.MessageMediaKindTodo, + Todo: &domain.MessageTodo{ + Title: base.Todo.Title, + OthersCanComplete: true, + Items: append([]domain.MessageTodoItem(nil), base.Todo.Items...), + Completions: []domain.MessageTodoCompletion{{ID: 1, CompletedBy: recipient.ID, Date: 1700000700}}, + }, + } + edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: recipient.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, + ID: sent.RecipientMessage.ID, + Message: "", + Media: updated, + EditDate: 1700000700, + AllowTodoParticipantMutation: true, + }) + if err != nil { + t.Fatalf("participant edit todo media: %v", err) + } + if len(edited.Edited) != 2 { + t.Fatalf("edited boxes = %d, want sender and recipient", len(edited.Edited)) + } + + senderView, err := messages.GetByIDs(ctx, sender.ID, []int{sent.SenderMessage.ID}) + if err != nil { + t.Fatalf("sender get by ids: %v", err) + } + if len(senderView.Messages) != 1 || senderView.Messages[0].Media == nil || senderView.Messages[0].Media.Todo == nil { + t.Fatalf("sender todo media = %+v, want todo", senderView.Messages) + } + got := senderView.Messages[0].Media.Todo.Completions + if len(got) != 1 || got[0].ID != 1 || got[0].CompletedBy != recipient.ID { + t.Fatalf("sender completions = %+v, want recipient completed item", got) + } +} + +func TestEditChannelTodoMediaByParticipantPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 67, Phone: "+1998" + suffix + "07", FirstName: "TodoChannelOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 68, Phone: "+1998" + suffix + "08", FirstName: "TodoChannelMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Todo Channel " + suffix, + Megagroup: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000710, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelID = created.Channel.ID + + base := &domain.MessageMedia{ + Kind: domain.MessageMediaKindTodo, + Todo: &domain.MessageTodo{ + Title: "group todo", + OthersCanAppend: true, + OthersCanComplete: true, + Items: []domain.MessageTodoItem{{ID: 1, Title: "one"}, {ID: 2, Title: "two"}}, + }, + } + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: time.Now().UnixNano(), + Media: base, + Date: 1700000711, + }) + if err != nil { + t.Fatalf("send channel todo: %v", err) + } + + updated := &domain.MessageMedia{ + Kind: domain.MessageMediaKindTodo, + Todo: &domain.MessageTodo{ + Title: base.Todo.Title, + OthersCanAppend: true, + OthersCanComplete: true, + Items: append([]domain.MessageTodoItem(nil), base.Todo.Items...), + Completions: []domain.MessageTodoCompletion{{ID: 1, CompletedBy: member.ID, Date: 1700000712}}, + }, + } + edited, err := channels.EditChannelMessage(ctx, domain.EditChannelMessageRequest{ + UserID: member.ID, + ChannelID: channelID, + ID: sent.Message.ID, + Media: updated, + AllowTodoParticipantMutation: true, + TodoServiceAction: &domain.ChannelMessageAction{ + Type: domain.ChannelActionTodoCompletions, + Completed: []int{1}, + }, + EditDate: 1700000712, + }) + if err != nil { + t.Fatalf("participant edit channel todo media: %v", err) + } + if edited.Event.Pts != sent.Event.Pts+1 || edited.ServiceEvent.Pts != edited.Event.Pts+1 { + t.Fatalf("todo channel pts edit=%d service=%d after send=%d, want two contiguous pts", edited.Event.Pts, edited.ServiceEvent.Pts, sent.Event.Pts) + } + if edited.ServiceMessage.Action == nil || edited.ServiceMessage.Action.Type != domain.ChannelActionTodoCompletions { + t.Fatalf("service action = %+v, want todo completions", edited.ServiceMessage.Action) + } + if edited.ServiceMessage.ReplyTo == nil || edited.ServiceMessage.ReplyTo.MessageID != sent.Message.ID { + t.Fatalf("service reply = %+v, want original message %d", edited.ServiceMessage.ReplyTo, sent.Message.ID) + } + + history, err := channels.GetChannelMessages(ctx, member.ID, channelID, []int{sent.Message.ID, edited.ServiceMessage.ID}) + if err != nil { + t.Fatalf("get channel todo messages: %v", err) + } + if len(history.Messages) != 2 { + t.Fatalf("history messages = %d, want original + service", len(history.Messages)) + } + var foundOriginal, foundService bool + for _, msg := range history.Messages { + switch msg.ID { + case sent.Message.ID: + foundOriginal = true + if msg.Media == nil || msg.Media.Todo == nil || len(msg.Media.Todo.Completions) != 1 || msg.Media.Todo.Completions[0].CompletedBy != member.ID { + t.Fatalf("stored todo media = %+v, want member completion", msg.Media) + } + case edited.ServiceMessage.ID: + foundService = true + if msg.Action == nil || msg.Action.Type != domain.ChannelActionTodoCompletions { + t.Fatalf("stored service action = %+v, want todo completions", msg.Action) + } + } + } + if !foundOriginal || !foundService { + t.Fatalf("history = %+v, missing original=%v service=%v", history.Messages, foundOriginal, foundService) + } + + diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{ + UserID: member.ID, + ChannelID: channelID, + Pts: sent.Event.Pts, + Limit: 10, + }) + if err != nil { + t.Fatalf("list channel difference: %v", err) + } + if diff.Pts != edited.ServiceEvent.Pts { + t.Fatalf("diff pts = %d, want service pts %d", diff.Pts, edited.ServiceEvent.Pts) + } + if len(diff.OtherUpdates) != 1 || diff.OtherUpdates[0].Type != domain.ChannelUpdateEditMessage || diff.OtherUpdates[0].Pts != edited.Event.Pts { + t.Fatalf("diff other updates = %+v, want edit event", diff.OtherUpdates) + } + if len(diff.NewMessages) != 1 || diff.NewMessages[0].ID != edited.ServiceMessage.ID || diff.NewMessages[0].Action == nil { + t.Fatalf("diff new messages = %+v, want todo service message", diff.NewMessages) + } +} + func TestSendChannelMediaSurvivesDifference(t *testing.T) { pool := testPool(t) ctx := context.Background() diff --git a/internal/store/postgres/message_pin.go b/internal/store/postgres/message_pin.go new file mode 100644 index 00000000..9e11d05b --- /dev/null +++ b/internal/store/postgres/message_pin.go @@ -0,0 +1,311 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// PinPrivateMessage 翻转私聊消息的置顶状态。官方语义: +// - pin 且非 pm_oneside:双方 box 行同步置位(对端 box 经 +// private_message_id 翻译),双方各收一条带账号 pts 的 +// updatePinnedMessages; +// - pin 且 pm_oneside / Saved Messages:仅本侧; +// - unpin:无 oneside 形态,双侧清除(对端行未置顶时自然跳过); +// - 状态已是目标值:幂等 no-op,不烧 pts、不发事件。 +// +// messageActionPinMessage 服务消息不在此生成(走 SendPrivateText 服务 +// 消息通道,置顶状态本身是真值源,服务消息仅为时间线装饰)。 +func (s *MessageStore) PinPrivateMessage(ctx context.Context, req domain.PinPrivateMessageRequest) (res domain.PinPrivateMessageResult, err error) { + res = domain.PinPrivateMessageResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { + return res, domain.ErrMessageIDInvalid + } + if req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("pin private message: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin pin message tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + }() + + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { + return res, fmt.Errorf("lock pin users: %w", err) + } + owned, err := qtx.GetMessageBoxForPin(ctx, sqlcgen.GetMessageBoxForPinParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + BoxID: int32(req.MessageID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return res, domain.ErrMessageIDInvalid + } + return res, fmt.Errorf("get message for pin: %w", err) + } + // 服务消息不可置顶(官方 canPin 排除 isService)。 + if media, mediaErr := decodeMessageMedia(owned.MediaJson); mediaErr == nil && + media != nil && media.Kind == domain.MessageMediaKindService { + return res, domain.ErrMessageIDInvalid + } + if owned.Pinned == req.Pinned && (!req.Pinned || req.PmOneside) { + // 幂等:本侧已是目标状态。unpin 与 oneside pin 到此即 no-op; + // 共享 pin 不在此短路——本侧已置顶(如此前 oneside)时仍需向 + // 对端传播补置顶与服务消息。 + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit pin message tx: %w", err) + } + committed = true + return res, nil + } + + type pinSide struct { + userID int64 + peer domain.Peer + boxID int + } + sides := []pinSide{{userID: req.OwnerUserID, peer: req.Peer, boxID: int(owned.BoxID)}} + // 对端侧翻转:pin 仅在非 oneside 时传播;unpin 恒尝试清除(oneside + // pin 的对端行本就未置顶,SetMessageBoxPinned 0 行自然跳过)。 + propagatePeer := req.Peer.ID != req.OwnerUserID && (!req.Pinned || !req.PmOneside) + if propagatePeer { + peerRow, peerErr := qtx.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + OwnerUserID: req.Peer.ID, + PrivateMessageID: owned.PrivateMessageID, + }) + if peerErr == nil { + sides = append(sides, pinSide{ + userID: req.Peer.ID, + peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + boxID: int(peerRow.BoxID), + }) + } else if !errors.Is(peerErr, pgx.ErrNoRows) { + return res, fmt.Errorf("get peer message for pin: %w", peerErr) + } + } + + for _, side := range sides { + affected, err := qtx.SetMessageBoxPinned(ctx, sqlcgen.SetMessageBoxPinnedParams{ + Pinned: req.Pinned, + OwnerUserID: side.userID, + BoxID: int32(side.boxID), + }) + if err != nil { + return res, fmt.Errorf("set message pinned: %w", err) + } + if affected == 0 { + continue + } + pts, err := s.reservePts(ctx, tx, side.userID) + if err != nil { + return res, fmt.Errorf("allocate pin pts: %w", err) + } + event := domain.UpdateEvent{ + UserID: side.userID, + Type: domain.UpdateEventPinnedMessages, + Pts: pts, + PtsCount: 1, + Date: req.Date, + Peer: side.peer, + Bool: req.Pinned, + MessageIDs: []int{side.boxID}, + } + if err := appendUserUpdateEvent(ctx, tx, qtx, side.userID, event); err != nil { + return res, fmt.Errorf("append pinned messages event: %w", err) + } + dispatchAuthKeyID := [8]byte{} + dispatchSessionID := int64(0) + if side.userID == req.OwnerUserID { + dispatchAuthKeyID = req.OriginAuthKeyID + dispatchSessionID = req.OriginSessionID + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: side.userID, + Pts: int32(pts), + EventType: string(domain.UpdateEventPinnedMessages), + ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), + ExcludeSessionID: dispatchSessionID, + }); err != nil { + return res, fmt.Errorf("enqueue pinned messages dispatch: %w", err) + } + res.Updated = append(res.Updated, domain.PinnedMessagesForUser{ + UserID: side.userID, + Peer: side.peer, + MessageIDs: []int{side.boxID}, + Pinned: req.Pinned, + Event: event, + }) + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit pin message tx: %w", err) + } + committed = true + return res, nil +} + +// UnpinAllPrivateMessages 清空与某私聊 peer 的全部置顶。本侧整批清除, +// 共享置顶经 private_message_id 同步清除对端行;双方各收一条带账号 pts +// 的 updatePinnedMessages{pinned:false},messages 为各自视角 box id。 +func (s *MessageStore) UnpinAllPrivateMessages(ctx context.Context, req domain.UnpinAllPrivateMessagesRequest) (res domain.PinPrivateMessageResult, err error) { + res = domain.PinPrivateMessageResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 { + return res, domain.ErrMessageIDInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("unpin all private messages: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin unpin all tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + }() + + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID, req.Peer.ID); err != nil { + return res, fmt.Errorf("lock unpin users: %w", err) + } + ownRows, err := qtx.UnpinAllMessageBoxesByPeer(ctx, sqlcgen.UnpinAllMessageBoxesByPeerParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + LimitCount: domain.MaxUnpinAllBatch, + }) + if err != nil { + return res, fmt.Errorf("unpin own messages: %w", err) + } + if len(ownRows) == 0 { + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit unpin all tx: %w", err) + } + committed = true + return res, nil + } + if len(ownRows) == domain.MaxUnpinAllBatch { + more, moreErr := qtx.HasPinnedMessageBoxByPeer(ctx, sqlcgen.HasPinnedMessageBoxByPeerParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + }) + if moreErr != nil { + return res, fmt.Errorf("check remaining pinned after unpin all: %w", moreErr) + } + if more { + res.Offset = 1 + } + } + + type unpinSide struct { + userID int64 + peer domain.Peer + ids []int + } + ownIDs := make([]int, 0, len(ownRows)) + senderIDs := make([]int64, 0, len(ownRows)) + pmIDs := make([]int64, 0, len(ownRows)) + for _, row := range ownRows { + ownIDs = append(ownIDs, int(row.BoxID)) + senderIDs = append(senderIDs, row.MessageSenderID) + pmIDs = append(pmIDs, row.PrivateMessageID) + } + sides := []unpinSide{{userID: req.OwnerUserID, peer: req.Peer, ids: ownIDs}} + if req.Peer.ID != req.OwnerUserID { + peerBoxIDs, err := qtx.UnpinMessageBoxesByPrivateMessages(ctx, sqlcgen.UnpinMessageBoxesByPrivateMessagesParams{ + MessageSenderIds: senderIDs, + PrivateMessageIds: pmIDs, + OwnerUserID: req.Peer.ID, + }) + if err != nil { + return res, fmt.Errorf("unpin peer messages: %w", err) + } + if len(peerBoxIDs) > 0 { + peerIDs := make([]int, 0, len(peerBoxIDs)) + for _, id := range peerBoxIDs { + peerIDs = append(peerIDs, int(id)) + } + sides = append(sides, unpinSide{ + userID: req.Peer.ID, + peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}, + ids: peerIDs, + }) + } + } + + for _, side := range sides { + pts, err := s.reservePts(ctx, tx, side.userID) + if err != nil { + return res, fmt.Errorf("allocate unpin all pts: %w", err) + } + event := domain.UpdateEvent{ + UserID: side.userID, + Type: domain.UpdateEventPinnedMessages, + Pts: pts, + PtsCount: 1, + Date: req.Date, + Peer: side.peer, + Bool: false, + MessageIDs: side.ids, + } + if err := appendUserUpdateEvent(ctx, tx, qtx, side.userID, event); err != nil { + return res, fmt.Errorf("append unpin all event: %w", err) + } + dispatchAuthKeyID := [8]byte{} + dispatchSessionID := int64(0) + if side.userID == req.OwnerUserID { + dispatchAuthKeyID = req.OriginAuthKeyID + dispatchSessionID = req.OriginSessionID + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: side.userID, + Pts: int32(pts), + EventType: string(domain.UpdateEventPinnedMessages), + ExcludeAuthKeyID: authKeyIDToInt64(dispatchAuthKeyID), + ExcludeSessionID: dispatchSessionID, + }); err != nil { + return res, fmt.Errorf("enqueue unpin all dispatch: %w", err) + } + res.Updated = append(res.Updated, domain.PinnedMessagesForUser{ + UserID: side.userID, + Peer: side.peer, + MessageIDs: side.ids, + Pinned: false, + Event: event, + }) + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit unpin all tx: %w", err) + } + committed = true + return res, nil +} diff --git a/internal/store/postgres/message_pin_integration_test.go b/internal/store/postgres/message_pin_integration_test.go new file mode 100644 index 00000000..149b4940 --- /dev/null +++ b/internal/store/postgres/message_pin_integration_test.go @@ -0,0 +1,236 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +func TestMessageStorePinPrivateMessageSharedAndOneside(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + alice, err := users.Create(ctx, domain.User{AccessHash: 81, Phone: "+1671" + suffix + "01", FirstName: "PinAlice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := users.Create(ctx, domain.User{AccessHash: 82, Phone: "+1671" + suffix + "02", FirstName: "PinBob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID}) + }) + + messages := NewMessageStore(pool) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, + RecipientUserID: bob.ID, + RandomID: 831001, + Message: "pin me", + Date: 1700000600, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + + // 共享置顶:双侧翻转,各产生一条 pinned_messages 事件(自带账号 pts)。 + res, err := messages.PinPrivateMessage(ctx, domain.PinPrivateMessageRequest{ + OwnerUserID: alice.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + MessageID: sent.SenderMessage.ID, + Pinned: true, + Date: 1700000601, + }) + if err != nil { + t.Fatalf("shared pin: %v", err) + } + if len(res.Updated) != 2 { + t.Fatalf("shared pin updated sides = %d, want 2", len(res.Updated)) + } + self := res.Self() + if !self.Pinned || len(self.MessageIDs) != 1 || self.MessageIDs[0] != sent.SenderMessage.ID || self.Event.Pts == 0 { + t.Fatalf("self side = %+v, want alice box pinned with pts", self) + } + var bobSide domain.PinnedMessagesForUser + for _, side := range res.Updated { + if side.UserID == bob.ID { + bobSide = side + } + } + if len(bobSide.MessageIDs) != 1 || bobSide.MessageIDs[0] != sent.RecipientMessage.ID { + t.Fatalf("bob side = %+v, want bob 视角 box id %d", bobSide, sent.RecipientMessage.ID) + } + events, err := NewUpdateEventStore(pool).ListAfter(ctx, bob.ID, bobSide.Event.Pts-1, 5) + if err != nil || len(events) == 0 { + t.Fatalf("bob events: %v %v", events, err) + } + if events[0].Type != domain.UpdateEventPinnedMessages || !events[0].Bool || + !sameInts(events[0].MessageIDs, []int{sent.RecipientMessage.ID}) || + events[0].Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}) { + t.Fatalf("bob pinned event = %+v, want pinned_messages with own box id and alice peer", events[0]) + } + + // message.pinned 回填到历史读取。 + bobList, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, Limit: 10, + }) + if err != nil || len(bobList.Messages) != 1 || !bobList.Messages[0].Pinned { + t.Fatalf("bob list = %+v err %v, want pinned message", bobList.Messages, err) + } + pinnedOnly, err := messages.ListByUser(ctx, alice.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + PinnedOnly: true, NeedTotalCount: true, Limit: 5, + }) + if err != nil || len(pinnedOnly.Messages) != 1 || pinnedOnly.Count != 1 { + t.Fatalf("pinned-only list = %+v count %d err %v, want exactly the pinned message", pinnedOnly.Messages, pinnedOnly.Count, err) + } + + // 幂等重复 pin:no-op,不烧 pts。 + repeat, err := messages.PinPrivateMessage(ctx, domain.PinPrivateMessageRequest{ + OwnerUserID: alice.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + MessageID: sent.SenderMessage.ID, + Pinned: true, + Date: 1700000602, + }) + if err != nil || repeat.Changed() { + t.Fatalf("repeat pin = %+v err %v, want no-op", repeat, err) + } + + // unpin 双侧传播。 + unpin, err := messages.PinPrivateMessage(ctx, domain.PinPrivateMessageRequest{ + OwnerUserID: bob.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, + MessageID: sent.RecipientMessage.ID, + Pinned: false, + Date: 1700000603, + }) + if err != nil || len(unpin.Updated) != 2 { + t.Fatalf("unpin = %+v err %v, want both sides cleared", unpin, err) + } + var pinnedCount int + if err := pool.QueryRow(ctx, ` +SELECT COUNT(*)::int FROM message_boxes +WHERE owner_user_id = ANY($1::bigint[]) AND pinned AND NOT deleted +`, []int64{alice.ID, bob.ID}).Scan(&pinnedCount); err != nil { + t.Fatalf("count pinned: %v", err) + } + if pinnedCount != 0 { + t.Fatalf("pinned rows after unpin = %d, want 0", pinnedCount) + } + + // pm_oneside:仅本侧置顶,对端不动。 + oneside, err := messages.PinPrivateMessage(ctx, domain.PinPrivateMessageRequest{ + OwnerUserID: alice.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + MessageID: sent.SenderMessage.ID, + Pinned: true, + PmOneside: true, + Date: 1700000604, + }) + if err != nil || len(oneside.Updated) != 1 || oneside.Updated[0].UserID != alice.ID { + t.Fatalf("oneside pin = %+v err %v, want alice side only", oneside, err) + } + bobPinned, err := messages.ListByUser(ctx, bob.ID, domain.MessageFilter{ + HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, + PinnedOnly: true, Limit: 5, + }) + if err != nil || len(bobPinned.Messages) != 0 { + t.Fatalf("bob pinned after oneside = %+v err %v, want empty", bobPinned.Messages, err) + } +} + +func TestMessageStoreUnpinAllPrivateMessagesSweepsBothSides(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + alice, err := users.Create(ctx, domain.User{AccessHash: 83, Phone: "+1672" + suffix + "01", FirstName: "UnpinAlice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := users.Create(ctx, domain.User{AccessHash: 84, Phone: "+1672" + suffix + "02", FirstName: "UnpinBob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID}) + }) + + messages := NewMessageStore(pool) + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, RecipientUserID: bob.ID, RandomID: 832001, Message: "shared", Date: 1700000610, + }) + if err != nil { + t.Fatalf("send shared: %v", err) + } + second, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, RecipientUserID: bob.ID, RandomID: 832002, Message: "oneside", Date: 1700000611, + }) + if err != nil { + t.Fatalf("send oneside: %v", err) + } + if _, err := messages.PinPrivateMessage(ctx, domain.PinPrivateMessageRequest{ + OwnerUserID: alice.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + MessageID: first.SenderMessage.ID, Pinned: true, Date: 1700000612, + }); err != nil { + t.Fatalf("shared pin: %v", err) + } + if _, err := messages.PinPrivateMessage(ctx, domain.PinPrivateMessageRequest{ + OwnerUserID: alice.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + MessageID: second.SenderMessage.ID, Pinned: true, PmOneside: true, Date: 1700000613, + }); err != nil { + t.Fatalf("oneside pin: %v", err) + } + + res, err := messages.UnpinAllPrivateMessages(ctx, domain.UnpinAllPrivateMessagesRequest{ + OwnerUserID: alice.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + Date: 1700000614, + }) + if err != nil { + t.Fatalf("unpinAll: %v", err) + } + if len(res.Updated) != 2 { + t.Fatalf("unpinAll sides = %d (%+v), want alice(2 ids) + bob(1 id)", len(res.Updated), res.Updated) + } + self := res.Self() + if len(self.MessageIDs) != 2 || self.Pinned { + t.Fatalf("alice unpinAll side = %+v, want both own pins cleared", self) + } + var bobSide domain.PinnedMessagesForUser + for _, side := range res.Updated { + if side.UserID == bob.ID { + bobSide = side + } + } + if len(bobSide.MessageIDs) != 1 || bobSide.MessageIDs[0] != first.RecipientMessage.ID { + t.Fatalf("bob unpinAll side = %+v, want only shared pin cleared with bob 视角 id %d", bobSide, first.RecipientMessage.ID) + } + + var pinnedCount int + if err := pool.QueryRow(ctx, ` +SELECT COUNT(*)::int FROM message_boxes +WHERE owner_user_id = ANY($1::bigint[]) AND pinned AND NOT deleted +`, []int64{alice.ID, bob.ID}).Scan(&pinnedCount); err != nil { + t.Fatalf("count pinned: %v", err) + } + if pinnedCount != 0 { + t.Fatalf("pinned rows after unpinAll = %d, want 0", pinnedCount) + } + + // 再次 unpinAll:no-op。 + empty, err := messages.UnpinAllPrivateMessages(ctx, domain.UnpinAllPrivateMessagesRequest{ + OwnerUserID: alice.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + Date: 1700000615, + }) + if err != nil || empty.Changed() { + t.Fatalf("repeat unpinAll = %+v err %v, want no-op", empty, err) + } +} diff --git a/internal/store/postgres/message_plan_integration_test.go b/internal/store/postgres/message_plan_integration_test.go index 1363bfd6..954570e6 100644 --- a/internal/store/postgres/message_plan_integration_test.go +++ b/internal/store/postgres/message_plan_integration_test.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "regexp" "strings" "testing" @@ -37,16 +38,21 @@ func TestMessagePartitionSeekPlansUseIndexes(t *testing.T) { }) messages := NewMessageStore(pool) + var first domain.SendPrivateTextResult for i := 0; i < 3; i++ { - if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: int64(9000 + i), Message: "plan check", Date: 1700000300 + i, - }); err != nil { + }) + if err != nil { t.Fatalf("seed message %d: %v", i, err) } + if i == 0 { + first = sent + } } if _, err := pool.Exec(ctx, ` UPDATE dispatch_outbox @@ -77,7 +83,7 @@ WHERE owner_user_id = $1 ORDER BY box_id DESC LIMIT 20 `, recipient.ID, sender.ID, 100000) - requirePlanUsesPartitionIndex(t, historyPlan, "message_boxes") + requirePlanUsesIndex(t, historyPlan, "message_boxes") requirePlanNotContains(t, historyPlan, "Append") updatesPlan := explainText(t, ctx, tx, ` @@ -88,12 +94,108 @@ WHERE user_id = $1 ORDER BY pts ASC LIMIT 100 `, recipient.ID, 0) - requirePlanUsesPartitionIndex(t, updatesPlan, "user_update_events") + requirePlanUsesIndex(t, updatesPlan, "user_update_events") requirePlanNotContains(t, updatesPlan, "Append") + updateDetailPlan := explainText(t, ctx, tx, ` +SELECT + e.pts, + COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type, + COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, + COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type, + COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(from_u.id, 0)::bigint AS from_user_id, + COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id, + COALESCE(reply_u.id, 0)::bigint AS reply_user_id +FROM user_update_events e +LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id +LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id +LEFT JOIN users from_u ON from_u.id = m.from_user_id +LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id +LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id +WHERE e.user_id = $1 + AND e.pts > $2 +ORDER BY e.pts ASC +LIMIT 100 +`, recipient.ID, 0) + requirePlanUsesIndex(t, updateDetailPlan, "user_update_events") + requireUniquePartitionCountAtMost(t, updateDetailPlan, `message_boxes_p\d+`, 1) + requirePlanNotContains(t, updateDetailPlan, "channels_p") + + batchDispatchDetailPlan := explainText(t, ctx, tx, ` +SELECT + e.pts, + COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type, + COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, + COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type, + COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(from_u.id, 0)::bigint AS from_user_id, + COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id, + COALESCE(reply_u.id, 0)::bigint AS reply_user_id +FROM unnest($1::bigint[]) WITH ORDINALITY AS u(user_id, ord) +JOIN unnest($2::int[]) WITH ORDINALITY AS p(pts, ord) USING (ord) +JOIN user_update_events e ON e.user_id = u.user_id AND e.pts = p.pts +LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id +LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id +LEFT JOIN users from_u ON from_u.id = m.from_user_id +LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id +LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id +`, []int64{recipient.ID}, []int32{int32(first.RecipientMessage.Pts)}) + requirePlanNotContains(t, batchDispatchDetailPlan, "channels_p") + + visibleByPrivatePlan := explainText(t, ctx, tx, ` +SELECT box_id +FROM message_boxes +WHERE owner_user_id = ANY($1::bigint[]) + AND message_sender_id = $2::bigint + AND private_message_id = $3::bigint + AND NOT deleted +ORDER BY owner_user_id ASC, box_id ASC +FOR UPDATE +`, []int64{sender.ID, recipient.ID}, sender.ID, first.SenderMessage.UID) + requirePlanUsesIndex(t, visibleByPrivatePlan, "message_boxes") + requireUniquePartitionCountAtMost(t, visibleByPrivatePlan, `message_boxes_p\d+`, 2) + + deleteByPrivatePlan := explainText(t, ctx, tx, ` +WITH requested AS ( + SELECT + ($1::bigint[])[i] AS message_sender_id, + ($2::bigint[])[i] AS private_message_id, + ($3::bigint[])[i] AS owner_user_id + FROM generate_subscripts($2::bigint[], 1) AS g(i) + WHERE i <= cardinality($1::bigint[]) + AND i <= cardinality($3::bigint[]) +), +deduped AS ( + SELECT DISTINCT message_sender_id, private_message_id, owner_user_id + FROM requested + WHERE owner_user_id <> 0 +), +updated AS ( + UPDATE message_boxes m + SET deleted = true + FROM deduped d + WHERE m.owner_user_id = ANY($3::bigint[]) + AND m.owner_user_id = d.owner_user_id + AND m.message_sender_id = d.message_sender_id + AND m.private_message_id = d.private_message_id + AND NOT m.deleted + RETURNING m.owner_user_id, m.box_id +) +SELECT owner_user_id, box_id +FROM updated +ORDER BY owner_user_id ASC, box_id ASC +`, []int64{sender.ID, sender.ID}, []int64{first.SenderMessage.UID, first.SenderMessage.UID}, []int64{sender.ID, recipient.ID}) + requirePlanUsesIndex(t, deleteByPrivatePlan, "message_boxes") + requireUniquePartitionCountAtMost(t, deleteByPrivatePlan, `message_boxes_p\d+`, 2) + dispatchPlan := explainText(t, ctx, tx, ` WITH picked AS ( - SELECT target_user_id, id + SELECT target_user_id, pts, id FROM dispatch_outbox WHERE ( status = 'pending' @@ -107,12 +209,30 @@ WITH picked AS ( LIMIT 100 FOR UPDATE SKIP LOCKED ) -SELECT target_user_id, id +SELECT target_user_id, pts, id FROM picked `) - requirePlanContains(t, dispatchPlan, "dispatch_outbox_p") + requirePlanContains(t, dispatchPlan, "dispatch_outbox") requirePlanContains(t, dispatchPlan, "Index") + requirePlanNotMatches(t, dispatchPlan, `dispatch_outbox_p\d+`) requirePlanNotContains(t, dispatchPlan, "Seq Scan") + + failedCleanupPlan := explainText(t, ctx, tx, ` +WITH doomed AS ( + SELECT target_user_id, id + FROM dispatch_outbox + WHERE status = 'failed' + AND updated_at < now() - interval '1 day' + ORDER BY updated_at ASC, target_user_id ASC, id ASC + LIMIT 100 +) +SELECT target_user_id, id +FROM doomed +`) + requirePlanContains(t, failedCleanupPlan, "dispatch_outbox") + requirePlanContains(t, failedCleanupPlan, "Index") + requirePlanNotMatches(t, failedCleanupPlan, `dispatch_outbox_p\d+`) + requirePlanNotContains(t, failedCleanupPlan, "Seq Scan") } func explainText(t *testing.T, ctx context.Context, tx pgx.Tx, query string, args ...any) string { @@ -138,9 +258,11 @@ func explainText(t *testing.T, ctx context.Context, tx pgx.Tx, query string, arg return b.String() } -func requirePlanUsesPartitionIndex(t *testing.T, plan, table string) { +// requirePlanUsesIndex 断言计划在指定表上走索引扫描、未退化为 Seq Scan。 +// 去分区后这些表都是普通表(不再有 *_pNN 子分区),故只校验「命中目标表 + 用了索引 + 无 Seq Scan」。 +func requirePlanUsesIndex(t *testing.T, plan, table string) { t.Helper() - requirePlanContains(t, plan, table+"_p") + requirePlanContains(t, plan, table) requirePlanContains(t, plan, "Index") requirePlanNotContains(t, plan, "Seq Scan") } @@ -158,3 +280,26 @@ func requirePlanNotContains(t *testing.T, plan string, needle string) { t.Fatalf("plan contains %q:\n%s", needle, plan) } } + +func requirePlanNotMatches(t *testing.T, plan string, pattern string) { + t.Helper() + re := regexp.MustCompile(pattern) + if re.MatchString(plan) { + t.Fatalf("plan matches %q:\n%s", pattern, plan) + } +} + +func requireUniquePartitionCountAtMost(t *testing.T, plan, pattern string, max int) { + t.Helper() + re := regexp.MustCompile(pattern) + matches := re.FindAllString(plan, -1) + seen := make(map[string]struct{}, len(matches)) + for _, match := range matches { + seen[match] = struct{}{} + } + // 去分区后这些表已是普通表:0 处 *_pNN 命中是预期(合格)。保留本断言作为回归护栏—— + // 若某天意外重新引入分区且单查询触及 >max 个分区,这里会炸出来。 + if len(seen) > max { + t.Fatalf("plan touches %d unique partitions, want <= %d:\n%s", len(seen), max, plan) + } +} diff --git a/internal/store/postgres/message_polls.go b/internal/store/postgres/message_polls.go new file mode 100644 index 00000000..93b18bf5 --- /dev/null +++ b/internal/store/postgres/message_polls.go @@ -0,0 +1,137 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// 私聊消息 poll 投票/关闭:消息可见性在 message_boxes 校验(与 reaction 同款 SELECT FOR +// UPDATE 定位),poll 级语义委托 poll.go 的共享 SQL(polls 行 FOR UPDATE 防 quiz 并发双投)。 + +func (s *MessageStore) VoteMessagePoll(ctx context.Context, req domain.VotePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) { + return s.mutateMessagePoll(ctx, req.UserID, req.Peer, req.MessageID, req.Date, func(ctx context.Context, tx pgx.Tx, def domain.PollDefinition, date int) error { + return applyPollVote(ctx, tx, def, req.UserID, req.Options, date) + }) +} + +func (s *MessageStore) CloseMessagePoll(ctx context.Context, req domain.ClosePrivateMessagePollRequest) (domain.PrivateMessagePollResult, error) { + return s.mutateMessagePoll(ctx, req.UserID, req.Peer, req.MessageID, req.Date, func(ctx context.Context, tx pgx.Tx, def domain.PollDefinition, _ int) error { + return closePollAsCreator(ctx, tx, def, req.UserID) + }) +} + +func (s *MessageStore) mutateMessagePoll( + ctx context.Context, + userID int64, + peer domain.Peer, + messageID int, + date int, + mutate func(ctx context.Context, tx pgx.Tx, def domain.PollDefinition, date int) error, +) (domain.PrivateMessagePollResult, error) { + if userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || messageID <= 0 || messageID > domain.MaxMessageBoxID { + return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid + } + if date == 0 { + date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.PrivateMessagePollResult{}, fmt.Errorf("mutate message poll: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.PrivateMessagePollResult{}, fmt.Errorf("begin message poll tx: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + + var target struct { + privateMessageID int64 + messageSenderID int64 + mediaJSON string + } + if err := tx.QueryRow(ctx, ` +SELECT private_message_id, message_sender_id, COALESCE(media::text, '{}') +FROM message_boxes +WHERE owner_user_id = $1 + AND box_id = $2 + AND peer_type = $3 + AND peer_id = $4 + AND NOT deleted +LIMIT 1`, userID, int32(messageID), string(peer.Type), peer.ID).Scan(&target.privateMessageID, &target.messageSenderID, &target.mediaJSON); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid + } + return domain.PrivateMessagePollResult{}, fmt.Errorf("get message for poll: %w", err) + } + media, err := decodeMessageMedia(target.mediaJSON) + if err != nil { + return domain.PrivateMessagePollResult{}, fmt.Errorf("decode poll message media: %w", err) + } + if media == nil || media.Kind != domain.MessageMediaKindPoll || media.Poll == nil || media.Poll.ID == 0 { + return domain.PrivateMessagePollResult{}, domain.ErrMessageIDInvalid + } + pollID := media.Poll.ID + defs, err := loadPollDefinitions(ctx, tx, []int64{pollID}, true) + if err != nil { + return domain.PrivateMessagePollResult{}, err + } + def, ok := defs[pollID] + if !ok { + return domain.PrivateMessagePollResult{}, domain.ErrPollNotFound + } + if err := mutate(ctx, tx, def, date); err != nil { + return domain.PrivateMessagePollResult{}, err + } + + boxes, err := sqlcgen.New(tx).ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: privateMessageOwnerIDs(userID, peer.ID), + MessageSenderID: target.messageSenderID, + PrivateMessageID: target.privateMessageID, + }) + if err != nil { + return domain.PrivateMessagePollResult{}, fmt.Errorf("list visible poll boxes: %w", err) + } + res := domain.PrivateMessagePollResult{PollID: pollID, Messages: make([]domain.Message, 0, len(boxes))} + for _, box := range boxes { + msg, err := messageFromVisibleBoxRow(box) + if err != nil { + return domain.PrivateMessagePollResult{}, err + } + res.Messages = append(res.Messages, msg) + } + // poll enrichment(按各 box owner 视角)由 enrichPrivateMessageReactions 统一挂载。 + if err := s.enrichPrivateMessageReactions(ctx, tx, userID, res.Messages); err != nil { + return domain.PrivateMessagePollResult{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.PrivateMessagePollResult{}, fmt.Errorf("commit message poll tx: %w", err) + } + committed = true + return res, nil +} + +// enrichPrivateMessagePolls 把页内全部 poll media 按各消息 owner 视角 enrich; +// 由 enrichPrivateMessageReactions 统一挂载(所有私聊读路径共用一个 choke point)。 +func (s *MessageStore) enrichPrivateMessagePolls(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, messages []domain.Message) error { + refs := make([]pollMediaRef, 0, 2) + for i := range messages { + viewer := messages[i].OwnerUserID + if viewer == 0 { + viewer = viewerUserID + } + refs = append(refs, pollMediaRef{media: messages[i].Media, viewer: viewer}) + } + return enrichPollMediaRefs(ctx, db, refs) +} diff --git a/internal/store/postgres/message_reactions.go b/internal/store/postgres/message_reactions.go new file mode 100644 index 00000000..47abf083 --- /dev/null +++ b/internal/store/postgres/message_reactions.go @@ -0,0 +1,443 @@ +package postgres + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "hash" + "sort" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" + "time" +) + +func (s *MessageStore) SetMessageReactions(ctx context.Context, req domain.SetPrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if req.UserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.MessageID <= 0 || req.MessageID > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if len(req.Reactions) > domain.MaxChannelMessageReactionsPerUser { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + req.Reactions = domain.TrimMessageReactionsToUserMax(req.Reactions, req.ReactionsPerUserMax) + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + for _, reaction := range req.Reactions { + if !reaction.Valid() { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("set message reactions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("begin set message reactions tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + + if err := lockUsersForUpdate(ctx, tx, req.UserID, req.Peer.ID); err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("lock set message reactions users: %w", err) + } + + var target struct { + boxID int32 + privateMessageID int64 + messageSenderID int64 + } + if err := tx.QueryRow(ctx, ` +SELECT box_id, private_message_id, message_sender_id +FROM message_boxes +WHERE owner_user_id = $1 + AND box_id = $2 + AND peer_type = $3 + AND peer_id = $4 + AND NOT deleted +LIMIT 1 +FOR UPDATE`, req.UserID, int32(req.MessageID), string(req.Peer.Type), req.Peer.ID).Scan(&target.boxID, &target.privateMessageID, &target.messageSenderID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("get message for reactions: %w", err) + } + if _, err := tx.Exec(ctx, ` +DELETE FROM private_message_reactions +WHERE message_sender_id = $1 + AND private_message_id = $2 + AND user_id = $3`, target.messageSenderID, target.privateMessageID, req.UserID); err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("delete old message reactions: %w", err) + } + for i, reaction := range req.Reactions { + if _, err := tx.Exec(ctx, ` +INSERT INTO private_message_reactions ( + message_sender_id, + private_message_id, + user_id, + reaction_type, + reaction_value, + big, + reaction_date, + chosen_order +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (message_sender_id, private_message_id, user_id, reaction_type, reaction_value) +DO UPDATE SET + big = EXCLUDED.big, + reaction_date = EXCLUDED.reaction_date, + chosen_order = EXCLUDED.chosen_order, + updated_at = now()`, + target.messageSenderID, + target.privateMessageID, + req.UserID, + string(reaction.Type), + reaction.Value(), + req.Big, + int32(req.Date), + int32(i+1), + ); err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("insert message reaction: %w", err) + } + } + if target.messageSenderID != 0 && target.messageSenderID != req.UserID { + if _, err := tx.Exec(ctx, ` +UPDATE message_boxes b +SET reaction_unread = EXISTS ( + SELECT 1 + FROM private_message_reactions r + WHERE r.message_sender_id = b.message_sender_id + AND r.private_message_id = b.private_message_id + AND r.user_id <> b.owner_user_id +) +WHERE b.owner_user_id = $1 + AND b.message_sender_id = $2 + AND b.private_message_id = $3`, target.messageSenderID, target.messageSenderID, target.privateMessageID); err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("update private reaction unread: %w", err) + } + if _, err := tx.Exec(ctx, ` +UPDATE dialogs d +SET unread_reactions_count = ( + SELECT COUNT(*)::int + FROM message_boxes m + WHERE m.owner_user_id = d.user_id + AND m.peer_type = d.peer_type + AND m.peer_id = d.peer_id + AND NOT m.deleted + AND m.reaction_unread +), +updated_at = now() +WHERE d.user_id = $1 + AND d.peer_type = $2 + AND d.peer_id = $3`, target.messageSenderID, string(domain.PeerTypeUser), req.UserID); err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("refresh private reaction unread dialog: %w", err) + } + } + + boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ + OwnerUserIds: privateMessageOwnerIDs(req.UserID, req.Peer.ID), + MessageSenderID: target.messageSenderID, + PrivateMessageID: target.privateMessageID, + }) + if err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("list visible reaction boxes: %w", err) + } + res := domain.PrivateMessageReactionsResult{Messages: make([]domain.Message, 0, len(boxes))} + for _, box := range boxes { + msg, err := messageFromVisibleBoxRow(box) + if err != nil { + return domain.PrivateMessageReactionsResult{}, err + } + res.Messages = append(res.Messages, msg) + } + if err := s.enrichPrivateMessageReactions(ctx, tx, req.UserID, res.Messages); err != nil { + return domain.PrivateMessageReactionsResult{}, err + } + for _, msg := range res.Messages { + if msg.OwnerUserID == req.UserID && msg.Reactions != nil { + res.Reactions = *msg.Reactions + break + } + } + if err := tx.Commit(ctx); err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("commit set message reactions tx: %w", err) + } + committed = true + return res, nil +} + +func (s *MessageStore) GetMessageReactions(ctx context.Context, req domain.PrivateMessageReactionsRequest) (domain.PrivateMessageReactionsResult, error) { + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || len(req.IDs) > domain.MaxGetMessageIDs { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + if len(req.IDs) == 0 { + return domain.PrivateMessageReactionsResult{}, nil + } + boxIDs := make([]int32, 0, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return domain.PrivateMessageReactionsResult{}, domain.ErrMessageIDInvalid + } + boxIDs = append(boxIDs, int32(id)) + } + rows, err := s.q.GetMessageBoxesByIDs(ctx, sqlcgen.GetMessageBoxesByIDsParams{ + OwnerUserID: req.OwnerUserID, + BoxIds: boxIDs, + }) + if err != nil { + return domain.PrivateMessageReactionsResult{}, fmt.Errorf("get message reactions boxes: %w", err) + } + res := domain.PrivateMessageReactionsResult{Messages: make([]domain.Message, 0, len(rows))} + for _, row := range rows { + if row.PeerType != string(req.Peer.Type) || row.PeerID != req.Peer.ID { + continue + } + msg, err := messageFromIDRow(row) + if err != nil { + return domain.PrivateMessageReactionsResult{}, err + } + res.Messages = append(res.Messages, msg) + } + if err := s.enrichPrivateMessageReactions(ctx, s.db, req.OwnerUserID, res.Messages); err != nil { + return domain.PrivateMessageReactionsResult{}, err + } + for _, msg := range res.Messages { + if msg.Reactions != nil { + res.Reactions = *msg.Reactions + break + } + } + return res, nil +} + +type privateMessageReactionRow struct { + messageSenderID int64 + privateMessageID int64 + userID int64 + reaction domain.MessageReaction + big bool + date int + chosenOrder int +} + +type privateMessageReactionKey struct { + messageSenderID int64 + privateMessageID int64 +} + +func (s *MessageStore) enrichPrivateMessageReactions(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, messages []domain.Message) error { + if len(messages) == 0 { + return nil + } + // poll enrichment 与 reactions 同点位挂载:所有私聊消息读路径都经过本函数, + // poll media 的权威 closed/聚合/viewer 门控在此一并填充(见 message_polls.go)。 + if err := s.enrichPrivateMessagePolls(ctx, db, viewerUserID, messages); err != nil { + return err + } + keySet := make(map[privateMessageReactionKey]struct{}, len(messages)) + senderIDs := make([]int64, 0, len(messages)) + privateIDs := make([]int64, 0, len(messages)) + for _, msg := range messages { + if msg.UID == 0 || msg.From.ID == 0 { + continue + } + key := privateMessageReactionKey{messageSenderID: msg.From.ID, privateMessageID: msg.UID} + if _, ok := keySet[key]; ok { + continue + } + keySet[key] = struct{}{} + senderIDs = append(senderIDs, key.messageSenderID) + privateIDs = append(privateIDs, key.privateMessageID) + } + if len(senderIDs) == 0 { + return nil + } + rows, err := db.Query(ctx, ` +WITH wanted AS ( + SELECT message_sender_id, private_message_id + FROM unnest($1::bigint[], $2::bigint[]) AS w(message_sender_id, private_message_id) +) +SELECT r.message_sender_id, r.private_message_id, r.user_id, r.reaction_type, r.reaction_value, r.big, r.reaction_date, r.chosen_order +FROM private_message_reactions r +JOIN wanted w + ON w.message_sender_id = r.message_sender_id + AND w.private_message_id = r.private_message_id +ORDER BY r.message_sender_id ASC, r.private_message_id ASC, r.reaction_date DESC, r.user_id DESC, r.reaction_type ASC, r.reaction_value ASC`, senderIDs, privateIDs) + if err != nil { + return fmt.Errorf("load private message reactions: %w", err) + } + defer rows.Close() + byMessage := make(map[privateMessageReactionKey][]privateMessageReactionRow) + for rows.Next() { + var ( + messageSenderID int64 + uid int64 + userID int64 + reactionType string + value string + big bool + date int32 + chosenOrder int32 + ) + if err := rows.Scan(&messageSenderID, &uid, &userID, &reactionType, &value, &big, &date, &chosenOrder); err != nil { + return fmt.Errorf("scan private message reactions: %w", err) + } + reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(reactionType), value) + if !ok { + continue + } + key := privateMessageReactionKey{messageSenderID: messageSenderID, privateMessageID: uid} + byMessage[key] = append(byMessage[key], privateMessageReactionRow{ + messageSenderID: messageSenderID, + privateMessageID: uid, + userID: userID, + reaction: reaction, + big: big, + date: int(date), + chosenOrder: int(chosenOrder), + }) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("private message reactions rows: %w", err) + } + for i := range messages { + key := privateMessageReactionKey{messageSenderID: messages[i].From.ID, privateMessageID: messages[i].UID} + // chosen/My 是 per-viewer 字段,必须按该副本的 box owner 视角解析: + // SetMessageReactions 会同时返回双方 owner 的副本,若统一用请求者视角, + // 对端收到的 updateMessageReactions 会把发起者的 reaction 标成"自己选的" + //(TDesktop 非 min 更新直接以 chosen_order 覆盖本地 my 状态)。 + viewpoint := messages[i].OwnerUserID + if viewpoint == 0 { + viewpoint = viewerUserID + } + reactions := privateMessageReactionsFromRows(byMessage[key], viewpoint) + applyPrivateMessageReactionUnread(&reactions, messages[i]) + if len(reactions.Results) == 0 && len(reactions.Recent) == 0 { + continue + } + messages[i].Reactions = &reactions + } + return nil +} + +func applyPrivateMessageReactionUnread(reactions *domain.ChannelMessageReactions, msg domain.Message) { + if reactions == nil || len(reactions.Recent) == 0 || msg.From.ID == 0 { + return + } + for i := range reactions.Recent { + reactions.Recent[i].SenderUserID = msg.From.ID + if msg.ReactionUnread && msg.From.ID == msg.OwnerUserID && reactions.Recent[i].UserID != msg.OwnerUserID { + reactions.Recent[i].Unread = true + } + } +} + +func privateMessageReactionsFromRows(rows []privateMessageReactionRow, viewerUserID int64) domain.ChannelMessageReactions { + out := domain.ChannelMessageReactions{ + CanSeeList: true, + Results: []domain.ChannelMessageReactionCount{}, + Recent: []domain.ChannelMessagePeerReaction{}, + } + if len(rows) == 0 { + return out + } + type aggregate struct { + reaction domain.MessageReaction + count int + chosenOrder int + latestDate int + } + aggregates := make(map[string]*aggregate) + recent := make([]domain.ChannelMessagePeerReaction, 0, len(rows)) + for _, row := range rows { + key := row.reaction.Key() + item := aggregates[key] + if item == nil { + item = &aggregate{reaction: row.reaction} + aggregates[key] = item + } + item.count++ + if row.userID == viewerUserID && row.chosenOrder > 0 && (item.chosenOrder == 0 || row.chosenOrder < item.chosenOrder) { + item.chosenOrder = row.chosenOrder + } + if row.date > item.latestDate { + item.latestDate = row.date + } + recent = append(recent, domain.ChannelMessagePeerReaction{ + UserID: row.userID, + Reaction: row.reaction, + Big: row.big, + My: row.userID == viewerUserID, + ChosenOrder: row.chosenOrder, + Date: row.date, + }) + } + items := make([]aggregate, 0, len(aggregates)) + for _, item := range aggregates { + items = append(items, *item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].count != items[j].count { + return items[i].count > items[j].count + } + if items[i].latestDate != items[j].latestDate { + return items[i].latestDate > items[j].latestDate + } + return items[i].reaction.Key() < items[j].reaction.Key() + }) + for _, item := range items { + out.Results = append(out.Results, domain.ChannelMessageReactionCount{ + Reaction: item.reaction, + Count: item.count, + ChosenOrder: item.chosenOrder, + }) + } + sort.Slice(recent, func(i, j int) bool { + if recent[i].Date != recent[j].Date { + return recent[i].Date > recent[j].Date + } + if recent[i].UserID != recent[j].UserID { + return recent[i].UserID > recent[j].UserID + } + return recent[i].Reaction.Key() < recent[j].Reaction.Key() + }) + if len(recent) > domain.MaxChannelMessageReactionRecent { + recent = recent[:domain.MaxChannelMessageReactionRecent] + } + out.Recent = recent + return out +} + +func writeMessageReactionsHash(h hash.Hash64, reactions *domain.ChannelMessageReactions) { + if reactions == nil { + _, _ = h.Write([]byte{0}) + return + } + var buf [16]byte + for _, item := range reactions.Results { + _, _ = h.Write([]byte(item.Reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(item.Reaction.Value())) + _, _ = h.Write([]byte{0}) + binary.LittleEndian.PutUint32(buf[:4], uint32(item.Count)) + binary.LittleEndian.PutUint32(buf[4:8], uint32(item.ChosenOrder)) + _, _ = h.Write(buf[:8]) + } + _, _ = h.Write([]byte{0xfe}) + for _, item := range reactions.Recent { + _, _ = h.Write([]byte(item.Reaction.Type)) + _, _ = h.Write([]byte{0}) + _, _ = h.Write([]byte(item.Reaction.Value())) + _, _ = h.Write([]byte{0}) + binary.LittleEndian.PutUint64(buf[:8], uint64(item.UserID)) + binary.LittleEndian.PutUint32(buf[8:12], uint32(item.Date)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(item.ChosenOrder)) + _, _ = h.Write(buf[:]) + } +} diff --git a/internal/store/postgres/message_reactions_integration_test.go b/internal/store/postgres/message_reactions_integration_test.go new file mode 100644 index 00000000..218e2b94 --- /dev/null +++ b/internal/store/postgres/message_reactions_integration_test.go @@ -0,0 +1,215 @@ +package postgres + +import ( + "context" + "reflect" + "testing" + + "telesrv/internal/domain" +) + +func TestMessageStorePrivateMessageReactionsUseOwnerVisibleBoxIDs(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender := createTestUser(t, ctx, users, "+1994"+suffix+"01", "ReactionSender", "") + recipient := createTestUser(t, ctx, users, "+1994"+suffix+"02", "ReactionRecipient", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 99401, + Message: "react to me", + Date: 1700000940, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + + reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"} + res, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{ + UserID: recipient.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, + MessageID: sent.RecipientMessage.ID, + Reactions: []domain.MessageReaction{ + reaction, + }, + Big: true, + Date: 1700000950, + }) + if err != nil { + t.Fatalf("SetMessageReactions: %v", err) + } + if len(res.Messages) != 2 { + t.Fatalf("reaction result messages = %d, want both owner boxes", len(res.Messages)) + } + byOwner := make(map[int64]domain.Message, len(res.Messages)) + for _, msg := range res.Messages { + byOwner[msg.OwnerUserID] = msg + } + senderMsg, ok := byOwner[sender.ID] + if !ok { + t.Fatalf("reaction result = %+v, want sender owner box", res.Messages) + } + if senderMsg.ID != sent.SenderMessage.ID || !senderMsg.ReactionUnread { + t.Fatalf("sender reaction box = %+v, want sender msg id %d with reaction_unread", senderMsg, sent.SenderMessage.ID) + } + recipientMsg, ok := byOwner[recipient.ID] + if !ok { + t.Fatalf("reaction result = %+v, want recipient owner box", res.Messages) + } + if recipientMsg.ID != sent.RecipientMessage.ID || recipientMsg.ReactionUnread { + t.Fatalf("recipient reaction box = %+v, want recipient msg id %d without reaction_unread", recipientMsg, sent.RecipientMessage.ID) + } + + senderReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{ + OwnerUserID: sender.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}, + IDs: []int{sent.SenderMessage.ID}, + }) + if err != nil { + t.Fatalf("sender GetMessageReactions: %v", err) + } + if len(senderReactions.Messages) != 1 || senderReactions.Messages[0].Reactions == nil { + t.Fatalf("sender reactions = %+v, want one enriched message", senderReactions) + } + if got := senderReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].Reaction != reaction || got[0].Count != 1 || got[0].ChosenOrder != 0 { + t.Fatalf("sender reaction counts = %+v, want one peer reaction without chosen order", got) + } + if got := senderReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != recipient.ID || got[0].SenderUserID != sender.ID || !got[0].Unread || !got[0].Big || got[0].My { + t.Fatalf("sender recent reactions = %+v, want recipient non-my big reaction", got) + } + senderBox, err := messages.GetByIDs(ctx, sender.ID, []int{sent.SenderMessage.ID}) + if err != nil { + t.Fatalf("sender GetByIDs after reaction: %v", err) + } + if len(senderBox.Messages) != 1 || !senderBox.Messages[0].ReactionUnread { + t.Fatalf("sender box after reaction = %+v, want reaction_unread", senderBox.Messages) + } + read, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{ + OwnerUserID: sender.ID, + IDs: []int{sent.SenderMessage.ID}, + Date: 1700000960, + }) + if err != nil { + t.Fatalf("ReadMessageContents reaction: %v", err) + } + if !reflect.DeepEqual(read.MessageIDs, []int{sent.SenderMessage.ID}) || read.Event.Type != domain.UpdateEventReadMessageContents || read.Event.Pts == 0 { + t.Fatalf("read reaction contents = %+v, want one read_message_contents event", read) + } + senderBox, err = messages.GetByIDs(ctx, sender.ID, []int{sent.SenderMessage.ID}) + if err != nil { + t.Fatalf("sender GetByIDs after read reaction: %v", err) + } + if len(senderBox.Messages) != 1 || senderBox.Messages[0].ReactionUnread { + t.Fatalf("sender box after read reaction = %+v, want reaction_unread cleared", senderBox.Messages) + } + if got := senderBox.Messages[0].Reactions.Recent; len(got) != 1 || got[0].Unread || got[0].SenderUserID != sender.ID { + t.Fatalf("sender recent reactions after read = %+v, want unread flag cleared", got) + } + + recipientReactions, err := messages.GetMessageReactions(ctx, domain.PrivateMessageReactionsRequest{ + OwnerUserID: recipient.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, + IDs: []int{sent.RecipientMessage.ID}, + }) + if err != nil { + t.Fatalf("recipient GetMessageReactions: %v", err) + } + if len(recipientReactions.Messages) != 1 || recipientReactions.Messages[0].Reactions == nil { + t.Fatalf("recipient reactions = %+v, want one enriched message", recipientReactions) + } + if got := recipientReactions.Messages[0].Reactions.Results; len(got) != 1 || got[0].Reaction != reaction || got[0].ChosenOrder != 1 { + t.Fatalf("recipient reaction counts = %+v, want own chosen order", got) + } + if got := recipientReactions.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != recipient.ID || got[0].SenderUserID != sender.ID || got[0].Unread || !got[0].My { + t.Fatalf("recipient recent reactions = %+v, want my reaction", got) + } +} + +func TestDialogTopMessagesCarryPrivateMessageReactions(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + alice := createTestUser(t, ctx, users, "+1995"+suffix+"01", "DialogReactionAlice", "") + bob := createTestUser(t, ctx, users, "+1995"+suffix+"02", "DialogReactionBob", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID}) + }) + + messages := NewMessageStore(pool) + dialogs := NewDialogStore(pool) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, + RecipientUserID: bob.ID, + RandomID: 99501, + Message: "latest from alice", + Date: 1700001950, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + reaction := domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "\u2764"} + if _, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{ + UserID: bob.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}, + MessageID: sent.RecipientMessage.ID, + Reactions: []domain.MessageReaction{ + reaction, + }, + Date: 1700001960, + }); err != nil { + t.Fatalf("SetMessageReactions: %v", err) + } + + aliceDialogs, err := dialogs.ListByUser(ctx, alice.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("alice ListByUser: %v", err) + } + if len(aliceDialogs.Dialogs) != 1 || aliceDialogs.Dialogs[0].TopMessage != sent.SenderMessage.ID || aliceDialogs.Dialogs[0].UnreadReactions != 1 { + t.Fatalf("alice dialog = %+v, want top message with one unread reaction", aliceDialogs.Dialogs) + } + if len(aliceDialogs.Messages) != 1 || aliceDialogs.Messages[0].ID != sent.SenderMessage.ID || aliceDialogs.Messages[0].Reactions == nil { + t.Fatalf("alice dialog messages = %+v, want enriched top message", aliceDialogs.Messages) + } + if got := aliceDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bob.ID || got[0].SenderUserID != alice.ID || !got[0].Unread || got[0].My { + t.Fatalf("alice dialog recent reactions = %+v, want bob unread non-my reaction", got) + } + + bobDialogs, err := dialogs.ListByPeers(ctx, bob.ID, []domain.Peer{{Type: domain.PeerTypeUser, ID: alice.ID}}) + if err != nil { + t.Fatalf("bob ListByPeers: %v", err) + } + if len(bobDialogs.Messages) != 1 || bobDialogs.Messages[0].ID != sent.RecipientMessage.ID || bobDialogs.Messages[0].Reactions == nil { + t.Fatalf("bob peer dialog messages = %+v, want enriched top message", bobDialogs.Messages) + } + if got := bobDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].UserID != bob.ID || got[0].SenderUserID != alice.ID || got[0].Unread || !got[0].My { + t.Fatalf("bob peer dialog recent reactions = %+v, want my read reaction", got) + } + + if _, err := messages.ReadMessageContents(ctx, domain.ReadMessageContentsRequest{ + OwnerUserID: alice.ID, + IDs: []int{sent.SenderMessage.ID}, + Date: 1700001970, + }); err != nil { + t.Fatalf("ReadMessageContents: %v", err) + } + aliceDialogs, err = dialogs.ListByUser(ctx, alice.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("alice ListByUser after read: %v", err) + } + if len(aliceDialogs.Dialogs) != 1 || aliceDialogs.Dialogs[0].UnreadReactions != 0 { + t.Fatalf("alice dialog after read = %+v, want no unread reactions", aliceDialogs.Dialogs) + } + if got := aliceDialogs.Messages[0].Reactions.Recent; len(got) != 1 || got[0].Unread { + t.Fatalf("alice dialog recent reactions after read = %+v, want unread cleared", got) + } +} diff --git a/internal/store/postgres/message_reactions_viewpoint_integration_test.go b/internal/store/postgres/message_reactions_viewpoint_integration_test.go new file mode 100644 index 00000000..2effcad0 --- /dev/null +++ b/internal/store/postgres/message_reactions_viewpoint_integration_test.go @@ -0,0 +1,94 @@ +package postgres + +import ( + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestSetMessageReactionsPeerViewpoint 回归:chosen/My 是 per-viewer 字段, +// SetMessageReactions 返回的双方 owner 副本必须各按自己的视角解析—— +// 修复前统一用发起者视角,对端推送会把发起者的 reaction 标成"对端自己选的" +// (TDesktop 非 min 更新用 chosen_order 直接覆盖本地 my 状态)。 +func TestSetMessageReactionsPeerViewpoint(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "+1666" + suffix + "01", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + friend, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "+1666" + suffix + "02", FirstName: "Friend"}) + if err != nil { + t.Fatalf("create friend: %v", err) + } + t.Cleanup(func() { + for _, id := range []int64{owner.ID, friend.ID} { + _, _ = pool.Exec(ctx, "DELETE FROM private_message_reactions WHERE user_id = $1 OR message_sender_id = $1", id) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = $1", id) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = $1", id) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = $1", id) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = $1", id) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", id) + } + }) + + messages := NewMessageStore(pool) + now := int(time.Now().Unix()) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: owner.ID, + RecipientUserID: friend.ID, + RandomID: time.Now().UnixNano(), + Message: "react to me", + Date: now, + }) + if err != nil { + t.Fatalf("send private text: %v", err) + } + if sent.RecipientMessage.ID == 0 { + t.Fatal("recipient box id missing") + } + + // friend 给 owner 的消息点 👍(用 friend 自己视角的 box id 与 peer)。 + res, err := messages.SetMessageReactions(ctx, domain.SetPrivateMessageReactionsRequest{ + UserID: friend.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + MessageID: sent.RecipientMessage.ID, + Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001F44D"}}, + Date: now + 1, + }) + if err != nil { + t.Fatalf("set message reactions: %v", err) + } + if len(res.Messages) != 2 { + t.Fatalf("reaction result messages = %d, want both owners' copies", len(res.Messages)) + } + for _, msg := range res.Messages { + if msg.Reactions == nil || len(msg.Reactions.Results) != 1 { + t.Fatalf("owner %d reactions = %+v, want one aggregate", msg.OwnerUserID, msg.Reactions) + } + result := msg.Reactions.Results[0] + switch msg.OwnerUserID { + case friend.ID: + if result.ChosenOrder == 0 { + t.Errorf("reactor's own copy should carry chosen_order, got %+v", result) + } + if len(msg.Reactions.Recent) != 1 || !msg.Reactions.Recent[0].My { + t.Errorf("reactor's recent entry should be My, got %+v", msg.Reactions.Recent) + } + case owner.ID: + if result.ChosenOrder != 0 { + t.Errorf("peer copy must NOT carry reactor's chosen_order (viewpoint bleed), got %+v", result) + } + if len(msg.Reactions.Recent) != 1 || msg.Reactions.Recent[0].My { + t.Errorf("peer copy recent entry must NOT be My, got %+v", msg.Reactions.Recent) + } + default: + t.Errorf("unexpected owner %d in reaction result", msg.OwnerUserID) + } + } +} diff --git a/internal/store/postgres/message_read.go b/internal/store/postgres/message_read.go new file mode 100644 index 00000000..6f89e6ee --- /dev/null +++ b/internal/store/postgres/message_read.go @@ -0,0 +1,469 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "github.com/jackc/pgx/v5" + "sort" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" + "time" +) + +func (s *MessageStore) ReadMessageContents(ctx context.Context, req domain.ReadMessageContentsRequest) (domain.ReadMessageContentsResult, error) { + res := domain.ReadMessageContentsResult{OwnerUserID: req.OwnerUserID} + if req.OwnerUserID == 0 { + return res, fmt.Errorf("read message contents: missing owner user id") + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + if len(req.IDs) > domain.MaxGetMessageIDs { + return res, domain.ErrMessageIDInvalid + } + seen := make(map[int]struct{}, len(req.IDs)) + ids := make([]int32, 0, len(req.IDs)) + for _, id := range req.IDs { + if id <= 0 || id > domain.MaxMessageBoxID { + return res, domain.ErrMessageIDInvalid + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, int32(id)) + } + if len(ids) == 0 { + return res, nil + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("read message contents: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin read message contents tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + }() + // 先做只读预查推导本次涉及的对端 sender,保证 advisory lock 仍按 + // user_id 升序统一获取,避免与 send/read 路径形成交叉锁序。 + senderRows, err := tx.Query(ctx, ` +SELECT DISTINCT message_sender_id +FROM message_boxes +WHERE owner_user_id = $1 + AND box_id = ANY($2::int[]) + AND NOT deleted + AND media_unread + AND NOT outgoing + AND peer_type = 'user' + AND message_sender_id <> $1`, req.OwnerUserID, ids) + if err != nil { + return res, fmt.Errorf("preview read message contents senders: %w", err) + } + lockIDs := []int64{req.OwnerUserID} + for senderRows.Next() { + var senderID int64 + if err := senderRows.Scan(&senderID); err != nil { + senderRows.Close() + return res, fmt.Errorf("scan read message contents sender: %w", err) + } + lockIDs = append(lockIDs, senderID) + } + if err := senderRows.Err(); err != nil { + senderRows.Close() + return res, fmt.Errorf("preview read message contents senders rows: %w", err) + } + senderRows.Close() + if err := lockUsersForUpdate(ctx, tx, lockIDs...); err != nil { + return res, fmt.Errorf("lock read message contents users: %w", err) + } + rows, err := tx.Query(ctx, ` +WITH target AS ( + SELECT owner_user_id, box_id, peer_type, peer_id, media_unread, reaction_unread, + private_message_id, message_sender_id, outgoing + FROM message_boxes + WHERE owner_user_id = $1 + AND box_id = ANY($2::int[]) + AND NOT deleted + AND (media_unread OR reaction_unread) + FOR UPDATE +), +updated AS ( + UPDATE message_boxes + SET media_unread = false, + reaction_unread = false + FROM target t + WHERE message_boxes.owner_user_id = t.owner_user_id + AND message_boxes.box_id = t.box_id + RETURNING message_boxes.box_id, t.peer_type, t.peer_id, t.media_unread, t.reaction_unread, + t.private_message_id, t.message_sender_id, t.outgoing +) +SELECT box_id, peer_type, peer_id, media_unread, reaction_unread, private_message_id, message_sender_id, outgoing +FROM updated +ORDER BY box_id`, req.OwnerUserID, ids) + if err != nil { + return res, fmt.Errorf("read message contents: %w", err) + } + defer rows.Close() + affectedPeers := make(map[domain.Peer]struct{}) + senderPrivateMessageIDs := make(map[int64][]int64) + for rows.Next() { + var id int32 + var peerType string + var peerID, privateMessageID, messageSenderID int64 + var mediaUnread, reactionUnread, outgoing bool + if err := rows.Scan(&id, &peerType, &peerID, &mediaUnread, &reactionUnread, &privateMessageID, &messageSenderID, &outgoing); err != nil { + return res, fmt.Errorf("scan read message contents: %w", err) + } + res.MessageIDs = append(res.MessageIDs, int(id)) + // reaction 与 media(语音/圆形视频)未读清除都要 UPDATE dialogs 行: + // dialog_light 触发器仅挂在 dialogs 表,message_boxes.media_unread 翻转不 bump 版本。 + // 不补这一下,「语音先历史已读、之后单独点听」会让 getPeerDialogs 的 per-peer 缓存 + // 顶层消息一直带 media_unread=true(蓝点不消),直到该会话因别的写被失效。 + if (reactionUnread || mediaUnread) && peerID != 0 { + affectedPeers[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = struct{}{} + } + if mediaUnread && !outgoing && peerType == string(domain.PeerTypeUser) && messageSenderID != 0 && messageSenderID != req.OwnerUserID { + senderPrivateMessageIDs[messageSenderID] = append(senderPrivateMessageIDs[messageSenderID], privateMessageID) + } + } + if err := rows.Err(); err != nil { + return res, fmt.Errorf("read message contents rows: %w", err) + } + if len(res.MessageIDs) == 0 { + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit read message contents noop: %w", err) + } + committed = true + return res, nil + } + for peer := range affectedPeers { + if peer.Type != domain.PeerTypeUser || peer.ID == 0 { + continue + } + if _, err := tx.Exec(ctx, ` +UPDATE dialogs d +SET unread_reactions_count = ( + SELECT COUNT(*)::int + FROM message_boxes m + WHERE m.owner_user_id = d.user_id + AND m.peer_type = d.peer_type + AND m.peer_id = d.peer_id + AND NOT m.deleted + AND m.reaction_unread +), +updated_at = now() +WHERE d.user_id = $1 + AND d.peer_type = $2 + AND d.peer_id = $3`, req.OwnerUserID, string(peer.Type), peer.ID); err != nil { + return res, fmt.Errorf("refresh dialog unread reactions after content read: %w", err) + } + } + pts, err := s.reservePtsN(ctx, tx, req.OwnerUserID, len(res.MessageIDs)) + if err != nil { + return res, fmt.Errorf("allocate read message contents pts: %w", err) + } + res.Event = domain.UpdateEvent{ + UserID: req.OwnerUserID, + Type: domain.UpdateEventReadMessageContents, + Pts: pts, + PtsCount: len(res.MessageIDs), + Date: req.Date, + MessageIDs: append([]int(nil), res.MessageIDs...), + } + if err := appendUserUpdateEvent(ctx, tx, qtx, req.OwnerUserID, res.Event); err != nil { + return res, fmt.Errorf("append read message contents event: %w", err) + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: req.OwnerUserID, + Pts: int32(pts), + EventType: string(domain.UpdateEventReadMessageContents), + ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), + ExcludeSessionID: req.OriginSessionID, + }); err != nil { + return res, fmt.Errorf("enqueue read message contents dispatch: %w", err) + } + senderIDs := make([]int64, 0, len(senderPrivateMessageIDs)) + for senderID := range senderPrivateMessageIDs { + senderIDs = append(senderIDs, senderID) + } + sort.Slice(senderIDs, func(i, j int) bool { return senderIDs[i] < senderIDs[j] }) + for _, senderID := range senderIDs { + senderBoxRows, err := tx.Query(ctx, ` +UPDATE message_boxes +SET media_unread = false +WHERE owner_user_id = $1 + AND private_message_id = ANY($2::bigint[]) + AND outgoing + AND NOT deleted + AND media_unread +RETURNING box_id`, senderID, senderPrivateMessageIDs[senderID]) + if err != nil { + return res, fmt.Errorf("clear sender media unread: %w", err) + } + senderBoxIDs := make([]int, 0, len(senderPrivateMessageIDs[senderID])) + for senderBoxRows.Next() { + var boxID int32 + if err := senderBoxRows.Scan(&boxID); err != nil { + senderBoxRows.Close() + return res, fmt.Errorf("scan sender media unread box: %w", err) + } + senderBoxIDs = append(senderBoxIDs, int(boxID)) + } + if err := senderBoxRows.Err(); err != nil { + senderBoxRows.Close() + return res, fmt.Errorf("sender media unread rows: %w", err) + } + senderBoxRows.Close() + if len(senderBoxIDs) == 0 { + continue + } + sort.Ints(senderBoxIDs) + senderPts, err := s.reservePtsN(ctx, tx, senderID, len(senderBoxIDs)) + if err != nil { + return res, fmt.Errorf("allocate sender content read pts: %w", err) + } + event := domain.UpdateEvent{ + UserID: senderID, + Type: domain.UpdateEventReadMessageContents, + Pts: senderPts, + PtsCount: len(senderBoxIDs), + Date: req.Date, + MessageIDs: senderBoxIDs, + } + if err := appendUserUpdateEvent(ctx, tx, qtx, senderID, event); err != nil { + return res, fmt.Errorf("append sender content read event: %w", err) + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: senderID, + Pts: int32(senderPts), + EventType: string(domain.UpdateEventReadMessageContents), + }); err != nil { + return res, fmt.Errorf("enqueue sender content read dispatch: %w", err) + } + res.SenderEvents = append(res.SenderEvents, event) + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit read message contents tx: %w", err) + } + committed = true + return res, nil +} + +func (s *MessageStore) GetOutboxReadDate(ctx context.Context, req domain.OutboxReadDateRequest) (int, error) { + if req.OwnerUserID == 0 || req.Peer.Type != domain.PeerTypeUser || req.Peer.ID == 0 || req.ID <= 0 || req.ID > domain.MaxMessageBoxID { + return 0, domain.ErrMessageIDInvalid + } + if _, err := s.q.GetOutboxMessageForReadDate(ctx, sqlcgen.GetOutboxMessageForReadDateParams{ + OwnerUserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + BoxID: int32(req.ID), + }); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, domain.ErrMessageIDInvalid + } + return 0, fmt.Errorf("get outbox message for read date: %w", err) + } + date, err := s.q.GetOutboxReadDate(ctx, sqlcgen.GetOutboxReadDateParams{ + UserID: req.OwnerUserID, + PeerType: string(req.Peer.Type), + PeerID: req.Peer.ID, + MessageID: int32(req.ID), + }) + if err != nil { + return 0, fmt.Errorf("get outbox read date: %w", err) + } + if date == 0 { + return 0, domain.ErrMessageNotReadYet + } + return int(date), nil +} + +type deletedUnreadMessages map[deletedOwnerPeerKey]map[int]struct{} + +func loadDeleteUnreadCorrections(ctx context.Context, q *sqlcgen.Queries, deleted deletedUnreadMessages, date int) (map[int64][]domain.UpdateEvent, error) { + if len(deleted) == 0 { + return nil, nil + } + keys := make([]deletedOwnerPeerKey, 0, len(deleted)) + for key := range deleted { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].userID != keys[j].userID { + return keys[i].userID < keys[j].userID + } + if keys[i].peer.Type != keys[j].peer.Type { + return keys[i].peer.Type < keys[j].peer.Type + } + return keys[i].peer.ID < keys[j].peer.ID + }) + out := make(map[int64][]domain.UpdateEvent) + for _, key := range keys { + deletedIDs := deleted[key] + if len(deletedIDs) == 0 { + continue + } + correctionMaxID := maxDeletedMessageID(deletedIDs) + stillUnread := 0 + state, err := q.GetDialogReadStateForUpdate(ctx, sqlcgen.GetDialogReadStateForUpdateParams{ + UserID: key.userID, + PeerType: string(key.peer.Type), + PeerID: key.peer.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // dialog 行已随本次删除被移除(peer 下已无存活消息),没有读边界 + // 可推进,不应再对一个正在丢弃的会话发悬空的 read 校正。 + continue + } + return nil, fmt.Errorf("load dialog read state after delete: %w", err) + } + readMax := int(state.ReadInboxMaxID) + stillUnread = int(state.UnreadCount) + correctionMaxID = readMax + for nextID := readMax + 1; ; nextID++ { + if _, ok := deletedIDs[nextID]; !ok { + break + } + correctionMaxID = nextID + } + // TDesktop stores unread as a boundary plus count. A read-history update + // must never advance the boundary across a still-live unread message, or the + // client will stop sending a real readHistory for that message while the + // server still considers it unread. Only deleted unread prefix items are safe + // to skip over here. + if correctionMaxID == readMax { + continue + } + out[key.userID] = append(out[key.userID], domain.UpdateEvent{ + UserID: key.userID, + Type: domain.UpdateEventReadHistoryInbox, + PtsCount: 1, + Date: date, + Peer: key.peer, + MaxID: correctionMaxID, + StillUnreadCount: stillUnread, + }) + } + return out, nil +} + +func clampDialogReadInboxToMaxBox(ctx context.Context, tx pgx.Tx, ownerUserID int64, peer domain.Peer, maxID int) error { + _, err := tx.Exec(ctx, ` +UPDATE dialogs +SET + read_inbox_max_id = $4, + unread_count = 0, + unread_mentions_count = 0, + unread_reactions_count = 0, + unread_mark = false, + updated_at = now() +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND read_inbox_max_id > $4`, ownerUserID, string(peer.Type), peer.ID, maxID) + return err +} + +// ListUnreadReactionMessages 返回当前 owner 在该 peer 下 reaction_unread 的 +// 消息(含最新 reactions 聚合),供 messages.getUnreadReactions 跳转。 +func (s *MessageStore) ListUnreadReactionMessages(ctx context.Context, ownerUserID int64, peer domain.Peer, limit int) ([]domain.Message, error) { + if ownerUserID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return nil, nil + } + if limit <= 0 || limit > domain.MaxChannelUnreadReactionsLimit { + limit = domain.MaxChannelUnreadReactionsLimit + } + rows, err := s.q.ListUnreadReactionMessageBoxes(ctx, sqlcgen.ListUnreadReactionMessageBoxesParams{ + OwnerUserID: ownerUserID, + PeerType: string(peer.Type), + PeerID: peer.ID, + PageLimit: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("list unread reaction messages: %w", err) + } + out := make([]domain.Message, 0, len(rows)) + for _, row := range rows { + out = append(out, messageFromBoxRow(sqlcgen.CreateMessageBoxRow(row))) + } + if err := s.enrichPrivateMessageReactions(ctx, s.db, ownerUserID, out); err != nil { + return nil, err + } + return out, nil +} + +// ReadPeerReactions 清理当前 owner 在该 peer 下的全部未读 reaction 状态, +// 对应 messages.readReactions 的私聊分支。 +// +// 必须在单事务内、且持与 SetMessageReactions 同键空间(owner, peer)的 advisory 锁: +// 否则「清 message_boxes.reaction_unread」与「重置 dialogs 计数」是两条独立 autocommit +// 语句,既有原子性缺口(stmt1 成功 stmt2 失败 → 底层已清但计数与缓存仍陈旧),又会与并发 +// 入站 SetMessageReactions 交错产生 lost-update(本侧硬归零 clobber 掉对端刚自增的正确计数, +// 角标永久偏低)。计数按存活 reaction_unread 重算而非硬置 0:既保留历史坏数据的 stale-counter +// 自愈(无存活行时 COUNT 自然为 0),又在并发交错时只得出与当前 box 状态一致的值。 +func (s *MessageStore) ReadPeerReactions(ctx context.Context, ownerUserID int64, peer domain.Peer) (int, error) { + if ownerUserID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 { + return 0, nil + } + beginner, ok := s.db.(txBeginner) + if !ok { + return 0, fmt.Errorf("read peer reactions: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin read peer reactions tx: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if err := lockUsersForUpdate(ctx, tx, ownerUserID, peer.ID); err != nil { + return 0, fmt.Errorf("lock read peer reactions users: %w", err) + } + tag, err := tx.Exec(ctx, ` +UPDATE message_boxes +SET reaction_unread = false +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND NOT deleted + AND reaction_unread`, ownerUserID, string(peer.Type), peer.ID) + if err != nil { + return 0, fmt.Errorf("read peer reactions: %w", err) + } + // 计数始终重算(且无条件 UPDATE dialogs 行)以 bump dialog_light 失效缓存; + // COUNT 在同事务内读到清除后的 box 状态,无存活行即为 0,保留 stale-counter 自愈。 + if _, err := tx.Exec(ctx, ` +UPDATE dialogs d +SET unread_reactions_count = ( + SELECT COUNT(*)::int + FROM message_boxes m + WHERE m.owner_user_id = d.user_id + AND m.peer_type = d.peer_type + AND m.peer_id = d.peer_id + AND NOT m.deleted + AND m.reaction_unread +), +updated_at = now() +WHERE d.user_id = $1 AND d.peer_type = $2 AND d.peer_id = $3`, ownerUserID, string(peer.Type), peer.ID); err != nil { + return 0, fmt.Errorf("reset dialog unread reactions: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit read peer reactions tx: %w", err) + } + committed = true + return int(tag.RowsAffected()), nil +} diff --git a/internal/store/postgres/message_read_edit_integration_test.go b/internal/store/postgres/message_read_edit_integration_test.go new file mode 100644 index 00000000..75d58816 --- /dev/null +++ b/internal/store/postgres/message_read_edit_integration_test.go @@ -0,0 +1,355 @@ +package postgres + +import ( + "context" + "telesrv/internal/domain" + "testing" +) + +func TestMessageStoreReadAndEditEmitDurableEvents(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 31, + Phone: "+1666" + suffix + "11", + FirstName: "ReadSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 32, + Phone: "+1666" + suffix + "12", + FirstName: "ReadRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 223344, + Message: "before edit", + Date: 1700000300, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipient.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, + Date: 1700000310, + }) + if err != nil { + t.Fatalf("ReadHistory: %v", err) + } + if !read.Changed || read.InboxEvent.Pts != 2 || read.InboxEvent.Type != domain.UpdateEventReadHistoryInbox || read.InboxEvent.MaxID != sent.RecipientMessage.ID { + t.Fatalf("read inbox = %+v, want recipient pts=2 max recipient id", read) + } + if !read.OutboxChanged || read.OutboxEvent.Pts != 2 || read.OutboxEvent.Type != domain.UpdateEventReadHistoryOutbox || read.OutboxEvent.MaxID != sent.SenderMessage.ID { + t.Fatalf("read outbox = %+v, want sender pts=2 max sender id", read) + } + readDate, err := messages.GetOutboxReadDate(ctx, domain.OutboxReadDateRequest{ + OwnerUserID: sender.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}, + ID: sent.SenderMessage.ID, + }) + if err != nil || readDate != 1700000310 { + t.Fatalf("outbox read date = %d err=%v, want read date", readDate, err) + } + + edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: sender.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID}, + ID: sent.SenderMessage.ID, + Message: "after edit", + EditDate: 1700000320, + }) + if err != nil { + t.Fatalf("EditMessage: %v", err) + } + if self := edited.Self(); self.Event.Pts != 3 || self.Event.Type != domain.UpdateEventEditMessage || self.Message.Body != "after edit" { + t.Fatalf("self edit = %+v, want sender edit event pts=3", self) + } + recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(recipientHistory.Messages) != 1 || recipientHistory.Messages[0].Body != "after edit" || recipientHistory.Messages[0].EditDate != 1700000320 { + t.Fatalf("recipient history = %+v, want edited message visible", recipientHistory.Messages) + } + + senderEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, sender.ID, 0, 10) + if err != nil { + t.Fatalf("sender events: %v", err) + } + if len(senderEvents) != 3 || senderEvents[1].Type != domain.UpdateEventReadHistoryOutbox || senderEvents[2].Type != domain.UpdateEventEditMessage { + t.Fatalf("sender events = %+v, want new/read_outbox/edit", senderEvents) + } + recipientEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("recipient events: %v", err) + } + if len(recipientEvents) != 3 || recipientEvents[1].Type != domain.UpdateEventReadHistoryInbox || recipientEvents[2].Type != domain.UpdateEventEditMessage || recipientEvents[2].Message.Body != "after edit" { + t.Fatalf("recipient events = %+v, want new/read_inbox/edit with edited body", recipientEvents) + } +} + +func TestMessageStoreReadHistoryStaleUnreadRepairDoesNotAppendPts(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1666" + suffix + "21", + FirstName: "StaleReadSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1666" + suffix + "22", + FirstName: "StaleReadRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID} + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 323344, + Message: "before stale repair", + Date: 1700000300, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipient.ID, + Peer: peer, + MaxID: sent.RecipientMessage.ID, + Date: 1700000310, + }); err != nil { + t.Fatalf("first ReadHistory: %v", err) + } + eventsBefore, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list events before repair: %v", err) + } + if _, err := pool.Exec(ctx, ` + UPDATE dialogs + SET unread_count = 1, + unread_mentions_count = 1, + unread_reactions_count = 1, + unread_mark = true + WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + `, recipient.ID, string(domain.PeerTypeUser), sender.ID); err != nil { + t.Fatalf("corrupt unread count: %v", err) + } + + read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipient.ID, + Peer: peer, + MaxID: sent.RecipientMessage.ID, + Date: 1700000320, + }) + if err != nil { + t.Fatalf("second ReadHistory: %v", err) + } + if read.Changed || read.InboxEvent.Pts != 0 || read.OutboxChanged { + t.Fatalf("stale unread repair = %+v, want no read pts/outbox event", read) + } + eventsAfter, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list events after repair: %v", err) + } + if len(eventsAfter) != len(eventsBefore) { + t.Fatalf("recipient events after repair = %d, want unchanged %d", len(eventsAfter), len(eventsBefore)) + } + var unreadCount, unreadMentions, unreadReactions int + var unreadMark bool + if err := pool.QueryRow(ctx, ` + SELECT unread_count, unread_mentions_count, unread_reactions_count, unread_mark + FROM dialogs + WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + `, recipient.ID, string(domain.PeerTypeUser), sender.ID).Scan(&unreadCount, &unreadMentions, &unreadReactions, &unreadMark); err != nil { + t.Fatalf("load repaired dialog: %v", err) + } + // readHistory 只清 message unread/mentions/manual mark;reaction 角标 + // 由 messages.readReactions 单独清除,与官方语义一致。 + if unreadCount != 0 || unreadMentions != 0 || unreadReactions != 1 || unreadMark { + t.Fatalf("dialog unread fields = count %d mentions %d reactions %d mark %v, want history cleared with reaction badge preserved", unreadCount, unreadMentions, unreadReactions, unreadMark) + } + // stale counter 场景没有存活的 reaction_unread 行,readReactions 清 0 + // 行但仍要把角标计数自愈归零。 + if cleared, err := messages.ReadPeerReactions(ctx, recipient.ID, domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}); err != nil || cleared != 0 { + t.Fatalf("ReadPeerReactions = %d err %v, want stale repair without live rows", cleared, err) + } + if err := pool.QueryRow(ctx, ` + SELECT unread_reactions_count + FROM dialogs + WHERE user_id = $1 AND peer_type = $2 AND peer_id = $3 + `, recipient.ID, string(domain.PeerTypeUser), sender.ID).Scan(&unreadReactions); err != nil { + t.Fatalf("load dialog after readReactions: %v", err) + } + if unreadReactions != 0 { + t.Fatalf("unread reactions after readReactions = %d, want 0", unreadReactions) + } + next, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 323345, + Message: "after stale repair", + Date: 1700000330, + }) + if err != nil { + t.Fatalf("next SendPrivateText: %v", err) + } + if next.RecipientMessage.Pts != 3 { + t.Fatalf("next recipient pts = %d, want 3 after no-op read repair", next.RecipientMessage.Pts) + } +} + +func TestMessageStoreReadHistoryClampsFutureMaxIDAndRepairsDialog(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 43, + Phone: "+1667" + suffix + "11", + FirstName: "FutureReadSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 44, + Phone: "+1667" + suffix + "12", + FirstName: "FutureReadRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID} + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 823101, + Message: "first", + Date: 1700000500, + }) + if err != nil { + t.Fatalf("SendPrivateText first: %v", err) + } + read, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipient.ID, + Peer: peer, + MaxID: domain.MaxMessageBoxID, + Date: 1700000510, + }) + if err != nil { + t.Fatalf("ReadHistory future max: %v", err) + } + if read.MaxID != first.RecipientMessage.ID || read.InboxEvent.MaxID != first.RecipientMessage.ID { + t.Fatalf("read = %+v, want max clamped to current top %d", read, first.RecipientMessage.ID) + } + + second, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 823102, + Message: "second", + Date: 1700000520, + }) + if err != nil { + t.Fatalf("SendPrivateText second: %v", err) + } + var unreadCount, readInboxMaxID, topMessageID int + if err := pool.QueryRow(ctx, ` + SELECT unread_count, read_inbox_max_id, top_message_id + FROM dialogs + WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + `, recipient.ID, string(domain.PeerTypeUser), sender.ID).Scan(&unreadCount, &readInboxMaxID, &topMessageID); err != nil { + t.Fatalf("load dialog after second send: %v", err) + } + if unreadCount != 1 || readInboxMaxID != first.RecipientMessage.ID || topMessageID != second.RecipientMessage.ID { + t.Fatalf("dialog after second = unread %d read %d top %d, want unread=1 read=%d top=%d", + unreadCount, readInboxMaxID, topMessageID, first.RecipientMessage.ID, second.RecipientMessage.ID) + } + + if _, err := pool.Exec(ctx, ` + UPDATE dialogs + SET read_inbox_max_id = $4, + unread_count = 0 + WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + `, recipient.ID, string(domain.PeerTypeUser), sender.ID, domain.MaxMessageBoxID); err != nil { + t.Fatalf("corrupt future read watermark: %v", err) + } + if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipient.ID, + Peer: peer, + MaxID: domain.MaxMessageBoxID, + Date: 1700000530, + }); err != nil { + t.Fatalf("ReadHistory repair future watermark: %v", err) + } + third, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 823103, + Message: "third", + Date: 1700000540, + }) + if err != nil { + t.Fatalf("SendPrivateText third: %v", err) + } + if err := pool.QueryRow(ctx, ` + SELECT unread_count, read_inbox_max_id, top_message_id + FROM dialogs + WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + `, recipient.ID, string(domain.PeerTypeUser), sender.ID).Scan(&unreadCount, &readInboxMaxID, &topMessageID); err != nil { + t.Fatalf("load dialog after third send: %v", err) + } + if unreadCount != 1 || readInboxMaxID != second.RecipientMessage.ID || topMessageID != third.RecipientMessage.ID { + t.Fatalf("dialog after repair+third = unread %d read %d top %d, want unread=1 read=%d top=%d", + unreadCount, readInboxMaxID, topMessageID, second.RecipientMessage.ID, third.RecipientMessage.ID) + } +} diff --git a/internal/store/postgres/message_rich_message_integration_test.go b/internal/store/postgres/message_rich_message_integration_test.go new file mode 100644 index 00000000..583be6b4 --- /dev/null +++ b/internal/store/postgres/message_rich_message_integration_test.go @@ -0,0 +1,124 @@ +package postgres + +import ( + "bytes" + "context" + "testing" + "time" + + "telesrv/internal/domain" +) + +// TestSendPrivateRichMessageSurvivesReadPaths 验证 Layer 227 富文本(rich_message JSONB) +// 经全部读路径字节级保真且双盒一致: +// - 发送结果双端(sender/recipient)携带 rich_message; +// - Blocks 是不透明 TL 字节,含 0x00/0xFF/非 UTF-8 经 JSONB base64 逐字节相等; +// - 内嵌 photo 快照(id/access_hash/sizes)存活; +// - history(ListByUser)/ GetByIDs / 更新事件重建路径均带 rich_message。 +func TestSendPrivateRichMessageSurvivesReadPaths(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{AccessHash: 81, Phone: "+1996" + suffix + "01", FirstName: "RichSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 82, Phone: "+1996" + suffix + "02", FirstName: "RichRecipient"}) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + ids := []int64{sender.ID, recipient.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM dispatch_outbox WHERE target_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM user_update_events WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM message_boxes WHERE owner_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM private_messages WHERE sender_user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool, WithMessageAllocators(&perUserCounterAllocator{})) + + // Blocks 在 store 层是不透明字节(rpc 层 TL 编解码),含 0x00/0xFF/非 UTF-8 以验证 + // JSONB base64 字节级 round-trip。 + rawBlocks := []byte{0x00, 0x01, 0xFF, 0x80, 'b', 'l', 'k'} + rich := &domain.MessageRichMessage{ + Rtl: true, + Blocks: rawBlocks, + Photos: []domain.Photo{{ID: 9911, AccessHash: 7, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 800, H: 600}}}}, + } + + res, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: time.Now().UnixNano(), + Message: "rich", + RichMessage: rich, + Date: int(time.Now().Unix()), + }) + if err != nil { + t.Fatalf("send private rich: %v", err) + } + + assertRich := func(name string, m *domain.MessageRichMessage) { + t.Helper() + if m == nil { + t.Fatalf("%s: rich message lost", name) + } + if !m.Rtl { + t.Errorf("%s: rtl = false, want true", name) + } + if !bytes.Equal(m.Blocks, rawBlocks) { + t.Fatalf("%s: blocks byte mismatch: %v want %v", name, m.Blocks, rawBlocks) + } + if len(m.Photos) != 1 || m.Photos[0].ID != 9911 || m.Photos[0].AccessHash != 7 { + t.Fatalf("%s: embedded photo lost: %+v", name, m.Photos) + } + if len(m.Photos[0].Sizes) != 1 || m.Photos[0].Sizes[0].Type != "x" { + t.Fatalf("%s: embedded photo sizes lost: %+v", name, m.Photos[0].Sizes) + } + } + + // 双端发送结果带 rich_message。 + assertRich("sender result", res.SenderMessage.RichMessage) + assertRich("recipient result", res.RecipientMessage.RichMessage) + + // history 读回(getHistory 主渲染路径,backward 扁平查询)。 + list, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{Limit: 10}) + if err != nil { + t.Fatalf("list by user: %v", err) + } + if len(list.Messages) == 0 { + t.Fatal("recipient history empty") + } + assertRich("recipient history", list.Messages[0].RichMessage) + + // GetByIDs(getMessages / getRichMessage 共用取数路径)。 + byID, err := messages.GetByIDs(ctx, recipient.ID, []int{res.RecipientMessage.ID}) + if err != nil { + t.Fatalf("recipient get by ids: %v", err) + } + if len(byID.Messages) != 1 { + t.Fatalf("get by ids = %d, want 1", len(byID.Messages)) + } + assertRich("recipient get by ids", byID.Messages[0].RichMessage) + + // 接收方经更新事件重建(离线 difference / 在线推送共用路径)。 + events := NewUpdateEventStore(pool) + got, err := events.ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + var sawEvent bool + for _, ev := range got { + if ev.Type == domain.UpdateEventNewMessage && ev.Message.ID != 0 { + sawEvent = true + assertRich("recipient update-event", ev.Message.RichMessage) + } + } + if !sawEvent { + t.Fatal("no new_message event for recipient") + } +} diff --git a/internal/store/postgres/message_send.go b/internal/store/postgres/message_send.go new file mode 100644 index 00000000..3e1a0cc0 --- /dev/null +++ b/internal/store/postgres/message_send.go @@ -0,0 +1,650 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "sort" + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" + "time" +) + +func (s *MessageStore) Create(ctx context.Context, msg domain.Message) (domain.Message, error) { + if err := s.ensureOfficialSystemUser(ctx, msg); err != nil { + return domain.Message{}, err + } + entities, err := encodeMessageEntities(msg.Entities) + if err != nil { + return domain.Message{}, err + } + if msg.Date == 0 { + msg.Date = int(time.Now().Unix()) + } + if msg.ID == 0 { + msg.ID, err = s.boxIDs.NextBoxID(ctx, msg.OwnerUserID) + if err != nil { + return domain.Message{}, fmt.Errorf("allocate login message box id: %w", err) + } + } + row, err := s.q.CreateMessage(ctx, sqlcgen.CreateMessageParams{ + OwnerUserID: msg.OwnerUserID, + BoxID: int32(msg.ID), + PeerType: string(msg.Peer.Type), + PeerID: msg.Peer.ID, + FromUserID: msg.From.ID, + MessageDate: int32(msg.Date), + Outgoing: msg.Out, + Body: msg.Body, + EntitiesJson: entities, + Pts: int32(msg.Pts), + }) + if err != nil { + return domain.Message{}, fmt.Errorf("create message: %w", err) + } + return messageFromCreateRow(row) +} + +func (s *MessageStore) ensureOfficialSystemUser(ctx context.Context, msg domain.Message) error { + if msg.Peer.Type != domain.PeerTypeUser && msg.From.Type != domain.PeerTypeUser { + return nil + } + u, ok := domain.SystemUserByID(msg.Peer.ID) + if !ok { + u, ok = domain.SystemUserByID(msg.From.ID) + } + if !ok { + return nil + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO users (id, access_hash, phone, first_name, last_name, username, country_code, verified, support, about, is_bot, bot_info_version) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +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, + country_code = EXCLUDED.country_code, + verified = EXCLUDED.verified, + support = EXCLUDED.support, + about = EXCLUDED.about, + is_bot = EXCLUDED.is_bot, + bot_info_version = EXCLUDED.bot_info_version, + updated_at = now() +`, u.ID, u.AccessHash, u.Phone, u.FirstName, u.LastName, u.Username, u.CountryCode, u.Verified, u.Support, u.About, u.Bot, u.BotInfoVersion); err != nil { + return fmt.Errorf("ensure official system user: %w", err) + } + return nil +} + +func (s *MessageStore) SendPrivateText(ctx context.Context, req domain.SendPrivateTextRequest) (res domain.SendPrivateTextResult, err error) { + for attempt := 0; attempt < 2; attempt++ { + res, err = s.sendPrivateTextOnce(ctx, req) + if err == nil { + return res, nil + } + if !isMessageBoxDuplicateKey(err) || attempt > 0 { + return domain.SendPrivateTextResult{}, err + } + if recoverErr := s.bumpBoxIDCountersAfterDuplicate(ctx, req); recoverErr != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("%w; recover box id counters: %v", err, recoverErr) + } + } + return domain.SendPrivateTextResult{}, err +} + +func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendPrivateTextRequest) (res domain.SendPrivateTextResult, err error) { + if req.SenderUserID == 0 || req.RecipientUserID == 0 { + return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing user id") + } + if req.RandomID == 0 { + return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: missing random id") + } + if req.Message == "" && req.Media.IsZero() { + return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: empty message") + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + entities, err := encodeMessageEntities(req.Entities) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + mediaJSON, err := encodeMessageMedia(req.Media) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + // reply_markup(bot inline keyboard)随消息一并入双盒;普通用户发送恒 nil → "{}"。 + replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + // rich_message(Layer 227 富文本)随消息一并入双盒;普通消息恒 nil → "{}"。 + richMessageJSON, err := encodeRichMessage(req.RichMessage) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + senderReply, recipientReply, err := s.resolvePrivateSendReply(ctx, req) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + senderMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, senderReply, req.Forward) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + recipientMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, recipientReply, req.Forward) + if err != nil { + return domain.SendPrivateTextResult{}, err + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: db does not support transactions") + } + + var recipientBoxID, recipientPts int + selfMessage := req.RecipientUserID == req.SenderUserID + deliverRecipient := !selfMessage && !req.RecipientBlocked + if selfMessage { + savedPeer := domain.SavedPeerForSelfChat(req.SenderUserID, req.Forward) + senderMeta.SavedPeerType = string(savedPeer.Type) + senderMeta.SavedPeerID = savedPeer.ID + } + + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("begin send message tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + }() + + // 事务级 advisory lock 串行化涉及收发双方的并发写,在任何行锁之前获取,消除 watermark/dialog + // 行锁的 AB-BA 死锁(A↔B 反向并发 send/read/edit)。 + if err := lockUsersForUpdate(ctx, tx, req.SenderUserID, req.RecipientUserID); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("lock send users: %w", err) + } + ttlPeriod := req.TTLPeriod + if ttlPeriod == 0 { + ttlPeriod, err = privateHistoryTTLPeriod(ctx, tx, req.SenderUserID, req.RecipientUserID) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("load private ttl: %w", err) + } + } + expiresAt := 0 + if ttlPeriod > 0 { + expiresAt = req.Date + ttlPeriod + } + + privateArg := sqlcgen.CreatePrivateMessageParams{ + SenderUserID: req.SenderUserID, + RecipientUserID: req.RecipientUserID, + RandomID: req.RandomID, + MessageDate: int32(req.Date), + Body: req.Message, + TtlPeriod: int32(ttlPeriod), + ExpiresAt: int32(expiresAt), + EntitiesJson: entities, + MediaJson: mediaJSON, + ReplyMarkupJson: replyMarkupJSON, + RichMessageJson: richMessageJSON, + ViaBotID: req.ViaBotID, + GroupedID: req.GroupedID, + Effect: req.Effect, + } + applyCreatePrivateMessageMetadata(&privateArg, senderMeta) + pm, err := qtx.CreatePrivateMessage(ctx, privateArg) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // 幂等重复:返回原消息盒;此时还没有分配 pts,重复发送不应制造额外事件。 + dup, dupErr := s.duplicateSendResult(ctx, req.SenderUserID, req.RecipientUserID, req.RandomID) + if dupErr != nil { + return domain.SendPrivateTextResult{}, dupErr + } + dup.Duplicate = true + return dup, nil + } + return domain.SendPrivateTextResult{}, fmt.Errorf("create private message: %w", err) + } + + senderBoxID, err := s.boxIDs.NextBoxID(ctx, req.SenderUserID) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("allocate sender box id: %w", err) + } + senderPts, err := s.reservePts(ctx, tx, req.SenderUserID) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("allocate sender pts: %w", err) + } + if deliverRecipient { + recipientBoxID, err = s.boxIDs.NextBoxID(ctx, req.RecipientUserID) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient box id: %w", err) + } + recipientPts, err = s.reservePts(ctx, tx, req.RecipientUserID) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("allocate recipient pts: %w", err) + } + } + + senderArg := sqlcgen.CreateMessageBoxParams{ + OwnerUserID: req.SenderUserID, + BoxID: int32(senderBoxID), + PrivateMessageID: pm.ID, + MessageSenderID: req.SenderUserID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.RecipientUserID, + FromUserID: req.SenderUserID, + MessageDate: int32(req.Date), + Outgoing: true, + Body: req.Message, + TtlPeriod: int32(ttlPeriod), + ExpiresAt: int32(expiresAt), + EntitiesJson: entities, + Pts: int32(senderPts), + MediaJson: mediaJSON, + ReplyMarkupJson: replyMarkupJSON, + RichMessageJson: richMessageJSON, + ViaBotID: req.ViaBotID, + GroupedID: req.GroupedID, + Effect: req.Effect, + // voice/round 在发送者自己的副本上也保持"未听",直到对端 + // readMessageContents 触发 sender 侧清除;发给自己无人可听,恒已读。 + MediaUnread: req.Media.HasUnreadPayload() && !selfMessage, + ReactionUnread: false, + } + applyCreateMessageBoxMetadata(&senderArg, senderMeta) + senderRow, err := qtx.CreateMessageBox(ctx, senderArg) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("create sender box: %w", err) + } + sender := messageFromBoxRow(senderRow) + // 共享媒体索引(0118):发送者侧 box 按媒体类别建索引(peer=收件人)。 + if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, req.Media, req.Entities); err != nil { + return domain.SendPrivateTextResult{}, err + } + if err := qtx.UpsertOutboxDialog(ctx, sqlcgen.UpsertOutboxDialogParams{ + UserID: req.SenderUserID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.RecipientUserID, + TopMessageID: int32(sender.ID), + TopMessageDate: int32(sender.Date), + }); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("upsert sender dialog: %w", err) + } + if err := appendNewMessageEvent(ctx, qtx, sender); err != nil { + return domain.SendPrivateTextResult{}, err + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: req.SenderUserID, + Pts: int32(senderPts), + EventType: string(domain.UpdateEventNewMessage), + ExcludeAuthKeyID: authKeyIDToInt64(req.OriginAuthKeyID), + ExcludeSessionID: req.OriginSessionID, + }); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue sender dispatch: %w", err) + } + + recipient := domain.Message{} + if selfMessage { + recipient = sender + } + if deliverRecipient { + recipientArg := sqlcgen.CreateMessageBoxParams{ + OwnerUserID: req.RecipientUserID, + BoxID: int32(recipientBoxID), + PrivateMessageID: pm.ID, + MessageSenderID: req.SenderUserID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.SenderUserID, + FromUserID: req.SenderUserID, + MessageDate: int32(req.Date), + Outgoing: false, + Body: req.Message, + TtlPeriod: int32(ttlPeriod), + ExpiresAt: int32(expiresAt), + EntitiesJson: entities, + Pts: int32(recipientPts), + MediaJson: mediaJSON, + ReplyMarkupJson: replyMarkupJSON, + RichMessageJson: richMessageJSON, + ViaBotID: req.ViaBotID, + GroupedID: req.GroupedID, + Effect: req.Effect, + MediaUnread: req.Media.HasUnreadPayload(), + ReactionUnread: false, + } + applyCreateMessageBoxMetadata(&recipientArg, recipientMeta) + recipientRow, err := qtx.CreateMessageBox(ctx, recipientArg) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("create recipient box: %w", err) + } + recipient = messageFromBoxRow(recipientRow) + // 共享媒体索引(0118):收件人侧 box 按媒体类别建索引(peer=发送者)。 + if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, req.Media, req.Entities); err != nil { + return domain.SendPrivateTextResult{}, err + } + if err := qtx.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{ + UserID: req.RecipientUserID, + PeerType: string(domain.PeerTypeUser), + PeerID: req.SenderUserID, + TopMessageID: int32(recipient.ID), + TopMessageDate: int32(recipient.Date), + }); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("upsert recipient dialog: %w", err) + } + if err := appendNewMessageEvent(ctx, qtx, recipient); err != nil { + return domain.SendPrivateTextResult{}, err + } + if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: req.RecipientUserID, + Pts: int32(recipientPts), + EventType: string(domain.UpdateEventNewMessage), + ExcludeAuthKeyID: 0, + ExcludeSessionID: 0, + }); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("enqueue recipient dispatch: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("commit send message tx: %w", err) + } + committed = true + return domain.SendPrivateTextResult{ + SenderMessage: sender, + RecipientMessage: recipient, + SenderEvent: eventFromMessage(sender), + RecipientEvent: eventFromMessage(recipient), + }, nil +} + +type boxIDCounterBumper interface { + BumpBoxIDAtLeast(ctx context.Context, userID int64, floor int) error +} + +func (s *MessageStore) bumpBoxIDCountersAfterDuplicate(ctx context.Context, req domain.SendPrivateTextRequest) error { + bumper, ok := s.boxIDs.(boxIDCounterBumper) + if !ok { + return nil + } + userIDs := []int64{req.SenderUserID} + if req.RecipientUserID != 0 && req.RecipientUserID != req.SenderUserID { + userIDs = append(userIDs, req.RecipientUserID) + } + for _, userID := range userIDs { + maxID, err := s.q.MaxMessageBoxID(ctx, userID) + if err != nil { + return fmt.Errorf("max message box id for %d: %w", userID, err) + } + if err := bumper.BumpBoxIDAtLeast(ctx, userID, int(maxID)); err != nil { + return fmt.Errorf("bump box id for %d: %w", userID, err) + } + } + return nil +} + +func isMessageBoxDuplicateKey(err error) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return false + } + return pgErr.Code == "23505" && strings.Contains(pgErr.ConstraintName, "message_boxes") +} + +func (s *MessageStore) duplicateSendResult(ctx context.Context, senderUserID, recipientUserID, randomID int64) (domain.SendPrivateTextResult, error) { + pm, err := s.q.GetPrivateMessageByRandomID(ctx, sqlcgen.GetPrivateMessageByRandomIDParams{ + SenderUserID: senderUserID, + RandomID: randomID, + }) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate private message: %w", err) + } + senderRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + OwnerUserID: senderUserID, + PrivateMessageID: pm.ID, + }) + if err != nil { + return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate sender box: %w", err) + } + sender := messageFromGetBoxRow(senderRow) + recipient := domain.Message{} + if recipientUserID == senderUserID { + recipient = sender + } + if recipientUserID != senderUserID { + recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + OwnerUserID: recipientUserID, + PrivateMessageID: pm.ID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.SendPrivateTextResult{ + SenderMessage: sender, + SenderEvent: eventFromMessage(sender), + RecipientEvent: domain.UpdateEvent{}, + }, nil + } + return domain.SendPrivateTextResult{}, fmt.Errorf("get duplicate recipient box: %w", err) + } + recipient = messageFromGetBoxRow(recipientRow) + } + return domain.SendPrivateTextResult{ + SenderMessage: sender, + RecipientMessage: recipient, + SenderEvent: eventFromMessage(sender), + RecipientEvent: eventFromMessage(recipient), + }, nil +} + +func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) { + if req.ReplyTo == nil { + return nil, nil, nil + } + if req.ReplyTo.StoryID > 0 { + // story 回复(评论):无源消息可查;story 作者就是会话对端(recipient),双盒同持。 + if req.ReplyTo.StoryID > domain.MaxStoryID { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + reply := &domain.MessageReply{ + StoryID: req.ReplyTo.StoryID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, + } + return cloneMessageReply(reply), cloneMessageReply(reply), nil + } + if req.ReplyTo.MessageID <= 0 || req.ReplyTo.MessageID > domain.MaxMessageBoxID { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + peer := req.ReplyTo.Peer + if peer.ID == 0 { + peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID} + } + if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + source, err := s.q.GetMessageBoxForReply(ctx, sqlcgen.GetMessageBoxForReplyParams{ + OwnerUserID: req.SenderUserID, + PeerType: string(peer.Type), + PeerID: peer.ID, + BoxID: int32(req.ReplyTo.MessageID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, domain.ErrReplyMessageIDInvalid + } + return nil, nil, fmt.Errorf("get reply message: %w", err) + } + senderReply := cloneMessageReply(req.ReplyTo) + senderReply.MessageID = int(source.BoxID) + senderReply.Peer = peer + if req.SenderUserID == req.RecipientUserID { + return senderReply, cloneMessageReply(senderReply), nil + } + + recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{ + OwnerUserID: req.RecipientUserID, + PrivateMessageID: source.PrivateMessageID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return senderReply, nil, nil + } + return nil, nil, fmt.Errorf("get recipient reply message: %w", err) + } + recipientReply := cloneMessageReply(senderReply) + recipientReply.MessageID = int(recipientRow.BoxID) + recipientReply.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} + return senderReply, recipientReply, nil +} + +func appendNewMessageEvent(ctx context.Context, q *sqlcgen.Queries, msg domain.Message) error { + boxID := int32(msg.ID) + peerType := string(msg.Peer.Type) + peerID := msg.Peer.ID + if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ + UserID: msg.OwnerUserID, + Pts: int32(msg.Pts), + PtsCount: 1, + Date: int32(msg.Date), + EventType: string(domain.UpdateEventNewMessage), + EventPeers: []byte("[]"), + PeerSettings: []byte("{}"), + MessageIds: []byte("[]"), + DialogFilter: []byte("{}"), + FilterOrder: []byte("[]"), + FolderPeers: []byte("[]"), + StoryPayload: []byte("{}"), + ReactionPayload: []byte("{}"), + MessageBoxID: &boxID, + PeerType: &peerType, + PeerID: &peerID, + }); err != nil { + return fmt.Errorf("append new message event: %w", err) + } + return nil +} + +// lockUsersForUpdate 在事务开始处用事务级 advisory lock 串行化所有涉及指定用户的并发写事务。 +// advisory lock 与行锁处于独立锁空间,且按 user_id 升序获取,因此:① 不会与后续 dialog / +// watermark / box 行锁交叉成跨类型死锁;② 任意两个共享某用户的写事务(send/read/edit/delete 对 +// 收发双方的并发操作)被完全串行化,从根上消除它们在 watermark 与 dialog 行上因加锁顺序相反 +// 导致的 AB-BA 死锁——既包含本次 watermark 优化新引入的(user_update_watermarks FOR UPDATE), +// 也包含 dialog upsert 既有的反向行锁。advisory xact lock 在事务结束自动释放;同对用户本就竞争 +// 这些行(天然串行),不额外降并发,不同用户集合的事务仍并行。**必须在任何行锁之前调用。** +func lockUsersForUpdate(ctx context.Context, tx pgx.Tx, userIDs ...int64) error { + if len(userIDs) == 0 { + return nil + } + unique := make([]int64, 0, len(userIDs)) + seen := make(map[int64]struct{}, len(userIDs)) + for _, id := range userIDs { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + sort.Slice(unique, func(i, j int) bool { return unique[i] < unique[j] }) + for _, id := range unique { + if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", id); err != nil { + return fmt.Errorf("advisory lock user %d: %w", id, err) + } + } + return nil +} + +func applyCreatePrivateMessageMetadata(arg *sqlcgen.CreatePrivateMessageParams, meta messageMetadataParams) { + arg.Silent = meta.Silent + arg.Noforwards = meta.Noforwards + arg.ReplyToMsgID = meta.ReplyToMsgID + arg.ReplyToPeerType = meta.ReplyToPeerType + arg.ReplyToPeerID = meta.ReplyToPeerID + arg.ReplyToTopID = meta.ReplyToTopID + arg.ReplyToStoryID = meta.ReplyToStoryID + arg.QuoteText = meta.QuoteText + arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON + arg.QuoteOffset = meta.QuoteOffset + arg.FwdFromPeerType = meta.FwdFromPeerType + arg.FwdFromPeerID = meta.FwdFromPeerID + arg.FwdFromName = meta.FwdFromName + arg.FwdDate = meta.FwdDate +} + +func applyCreateMessageBoxMetadata(arg *sqlcgen.CreateMessageBoxParams, meta messageMetadataParams) { + arg.Silent = meta.Silent + arg.Noforwards = meta.Noforwards + arg.ReplyToMsgID = meta.ReplyToMsgID + arg.ReplyToPeerType = meta.ReplyToPeerType + arg.ReplyToPeerID = meta.ReplyToPeerID + arg.ReplyToTopID = meta.ReplyToTopID + arg.ReplyToStoryID = meta.ReplyToStoryID + arg.QuoteText = meta.QuoteText + arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON + arg.QuoteOffset = meta.QuoteOffset + arg.FwdFromPeerType = meta.FwdFromPeerType + arg.FwdFromPeerID = meta.FwdFromPeerID + arg.FwdFromName = meta.FwdFromName + arg.FwdDate = meta.FwdDate + arg.FwdSavedFromPeerType = meta.FwdSavedFromPeerType + arg.FwdSavedFromPeerID = meta.FwdSavedFromPeerID + arg.FwdSavedFromMsgID = meta.FwdSavedFromMsgID + arg.SavedPeerType = meta.SavedPeerType + arg.SavedPeerID = meta.SavedPeerID +} + +func privateHistoryTTLPeriod(ctx context.Context, db sqlcgen.DBTX, ownerUserID, peerUserID int64) (int, error) { + if ownerUserID == 0 || peerUserID == 0 { + return 0, nil + } + var period int + err := db.QueryRow(ctx, ` +SELECT COALESCE(NULLIF(d.ttl_period, 0), u.default_history_ttl_period, 0)::int +FROM users u +LEFT JOIN dialogs d + ON d.user_id = u.id + AND d.peer_type = 'user' + AND d.peer_id = $2 +WHERE u.id = $1 +`, ownerUserID, peerUserID).Scan(&period) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, err + } + if period < 0 { + return 0, nil + } + return period, nil +} + +func messageFromCreateRow(row sqlcgen.CreateMessageRow) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode message entities: %w", err) + } + return domain.Message{ + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Body: row.Body, + Entities: entities, + Pts: int(row.Pts), + }, nil +} diff --git a/internal/store/postgres/message_send_integration_test.go b/internal/store/postgres/message_send_integration_test.go new file mode 100644 index 00000000..6ce71c83 --- /dev/null +++ b/internal/store/postgres/message_send_integration_test.go @@ -0,0 +1,649 @@ +package postgres + +import ( + "context" + "sync" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +func TestMessageStoreSendPrivateTextRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 11, + Phone: "+1666" + suffix + "01", + FirstName: "Sender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 22, + Phone: "+1666" + suffix + "02", + FirstName: "Recipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + var originAuthKeyID [8]byte + originAuthKeyID[0] = 5 + req := domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 123456, + Message: "hello from pg", + Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 5}}, + Date: 1700000200, + OriginAuthKeyID: originAuthKeyID, + OriginSessionID: 77, + } + got, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + if got.SenderMessage.ID != 1 || got.SenderMessage.Pts != 1 || !got.SenderMessage.Out || got.SenderMessage.Peer.ID != recipient.ID { + t.Fatalf("sender message = %+v, want first outgoing box to recipient", got.SenderMessage) + } + if got.RecipientMessage.ID != 1 || got.RecipientMessage.Pts != 1 || got.RecipientMessage.Out || got.RecipientMessage.Peer.ID != sender.ID { + t.Fatalf("recipient message = %+v, want first incoming box from sender", got.RecipientMessage) + } + if got.SenderMessage.UID == 0 || got.SenderMessage.UID != got.RecipientMessage.UID { + t.Fatalf("uid = sender %d recipient %d, want shared private message uid", got.SenderMessage.UID, got.RecipientMessage.UID) + } + + senderHistory, err := messages.ListByUser(ctx, sender.ID, domain.MessageFilter{HasPeer: true, Peer: got.SenderMessage.Peer, Limit: 10}) + if err != nil { + t.Fatalf("sender history: %v", err) + } + recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: got.RecipientMessage.Peer, Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(senderHistory.Messages) != 1 || len(recipientHistory.Messages) != 1 { + t.Fatalf("history sizes = sender %d recipient %d, want both owner partitions populated", len(senderHistory.Messages), len(recipientHistory.Messages)) + } + + events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + if len(events) != 1 || events[0].Message.ID != got.RecipientMessage.ID || len(events[0].Users) != 1 || events[0].Users[0].ID != sender.ID { + t.Fatalf("recipient events = %+v, want new message with sender user", events) + } + + var pendingOutbox int + if err := pool.QueryRow(ctx, ` + SELECT count(*) + FROM dispatch_outbox + WHERE target_user_id = ANY($1::bigint[]) + AND status = 'pending' + `, []int64{sender.ID, recipient.ID}).Scan(&pendingOutbox); err != nil { + t.Fatalf("count dispatch outbox: %v", err) + } + if pendingOutbox != 2 { + t.Fatalf("pending outbox = %d, want sender + recipient dispatch rows", pendingOutbox) + } + var excludeAuthKeyID, excludeSessionID int64 + if err := pool.QueryRow(ctx, ` + SELECT exclude_auth_key_id, exclude_session_id + FROM dispatch_outbox + WHERE target_user_id = $1 + `, sender.ID).Scan(&excludeAuthKeyID, &excludeSessionID); err != nil { + t.Fatalf("sender dispatch outbox: %v", err) + } + if excludeAuthKeyID != authKeyIDToInt64(originAuthKeyID) || excludeSessionID != 77 { + t.Fatalf("sender dispatch exclude = auth %d session %d, want origin auth/session", excludeAuthKeyID, excludeSessionID) + } + + dup, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText duplicate: %v", err) + } + if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID { + t.Fatalf("duplicate = %+v, want original message boxes", dup) + } +} + +func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender := createTestUser(t, ctx, users, "+1666"+suffix+"31", "WebViewSender", "") + recipient := createTestUser(t, ctx, users, "+1666"+suffix+"32", "WebViewRecipient", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + req := domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 9001, + Date: 1700000210, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionWebViewDataSent, + WebViewData: &domain.MessageWebViewDataAction{ + ButtonText: "Open", + Data: `{"ok":true}`, + }, + }, + }, + } + got, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + assertWebViewData := func(name string, msg domain.Message) { + t.Helper() + if msg.Media == nil || msg.Media.ServiceAction == nil || + msg.Media.ServiceAction.Kind != domain.MessageServiceActionWebViewDataSent || + msg.Media.ServiceAction.WebViewData == nil { + t.Fatalf("%s media = %+v, want webview data service action", name, msg.Media) + } + if data := msg.Media.ServiceAction.WebViewData; data.ButtonText != "Open" || data.Data != `{"ok":true}` { + t.Fatalf("%s webview data = %+v, want original payload", name, data) + } + } + assertWebViewData("sender", got.SenderMessage) + assertWebViewData("recipient", got.RecipientMessage) + + dupReq := req + dupReq.Date = 1700000211 + dupReq.Media = &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionWebViewDataSent, + WebViewData: &domain.MessageWebViewDataAction{ + ButtonText: "Changed", + Data: `{"ok":false}`, + }, + }, + } + dup, err := messages.SendPrivateText(ctx, dupReq) + if err != nil { + t.Fatalf("SendPrivateText duplicate: %v", err) + } + if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID { + t.Fatalf("duplicate = %+v, want original boxes", dup) + } + assertWebViewData("duplicate sender", dup.SenderMessage) + assertWebViewData("duplicate recipient", dup.RecipientMessage) + + recipientHistory, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, + Limit: 10, + }) + if err != nil || len(recipientHistory.Messages) != 1 { + t.Fatalf("recipient history = %+v err=%v, want one message", recipientHistory, err) + } + assertWebViewData("recipient history", recipientHistory.Messages[0]) + + events, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) + if err != nil || len(events) != 1 { + t.Fatalf("recipient events = %+v err=%v, want one event", events, err) + } + if events[0].Type != domain.UpdateEventNewMessage || events[0].Message.ID != got.RecipientMessage.ID { + t.Fatalf("recipient event = %+v, want original new message event", events[0]) + } + assertWebViewData("recipient event", events[0].Message) +} + +func TestUpdateEventStorePreservesChannelForwardRefsWithoutChannelSnapshot(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender := createTestUser(t, ctx, users, "+1666"+suffix+"21", "ForwardSender", "") + recipient := createTestUser(t, ctx, users, "+1666"+suffix+"22", "ForwardRecipient", "") + var channelIDs []int64 + t.Cleanup(func() { + if len(channelIDs) != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + created, err := NewChannelStore(pool).CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: sender.ID, + Title: "forward source " + suffix, + Broadcast: true, + Date: 1700000250, + }) + if err != nil { + t.Fatalf("create source channel: %v", err) + } + source := created.Channel + channelIDs = append(channelIDs, source.ID) + + sent, err := NewMessageStore(pool).SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 123466, + Message: "forwarded from channel", + Forward: &domain.MessageForward{ + From: domain.Peer{Type: domain.PeerTypeChannel, ID: source.ID}, + Date: 1700000249, + }, + Date: 1700000260, + }) + if err != nil { + t.Fatalf("SendPrivateText with channel forward: %v", err) + } + + updates := NewUpdateEventStore(pool) + events, err := updates.ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + if len(events) != 1 { + t.Fatalf("recipient events = %+v, want one new_message", events) + } + requireChannelForwardRefOnlyEvent(t, events[0], source.ID, sender.ID, sent.RecipientMessage.ID) + + batch, err := updates.BatchByCursor(ctx, []store.EventCursor{{UserID: recipient.ID, Pts: events[0].Pts}}) + if err != nil { + t.Fatalf("batch dispatch event: %v", err) + } + if len(batch) != 1 { + t.Fatalf("batch dispatch events = %+v, want one new_message", batch) + } + requireChannelForwardRefOnlyEvent(t, batch[0], source.ID, sender.ID, sent.RecipientMessage.ID) +} + +func TestMessageStoreSendPrivateTextDuplicateDoesNotAppendPts(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 51, + Phone: "+1666" + suffix + "11", + FirstName: "DuplicateSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 52, + Phone: "+1666" + suffix + "12", + FirstName: "DuplicateRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + req := domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 123456, + Message: "first", + Date: 1700000200, + } + got, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + if got.SenderMessage.Pts != 1 || got.RecipientMessage.Pts != 1 { + t.Fatalf("first send = %+v/%+v, want pts=1", got.SenderMessage, got.RecipientMessage) + } + dup, err := messages.SendPrivateText(ctx, req) + if err != nil { + t.Fatalf("SendPrivateText duplicate: %v", err) + } + if !dup.Duplicate || dup.SenderMessage.ID != got.SenderMessage.ID || dup.RecipientMessage.ID != got.RecipientMessage.ID { + t.Fatalf("duplicate = %+v, want original message boxes", dup) + } + + updateEvents := NewUpdateEventStore(pool) + senderEvents, err := updateEvents.ListAfter(ctx, sender.ID, 0, 10) + if err != nil { + t.Fatalf("list sender events after duplicate: %v", err) + } + recipientEvents, err := updateEvents.ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("list recipient events after duplicate: %v", err) + } + if len(senderEvents) != 1 || senderEvents[0].Type != domain.UpdateEventNewMessage || senderEvents[0].Pts != 1 { + t.Fatalf("sender events after duplicate = %+v, want only original new_message pts=1", senderEvents) + } + if len(recipientEvents) != 1 || recipientEvents[0].Type != domain.UpdateEventNewMessage || recipientEvents[0].Pts != 1 { + t.Fatalf("recipient events after duplicate = %+v, want only original new_message pts=1", recipientEvents) + } + + nextReq := req + nextReq.RandomID = 123457 + nextReq.Message = "after duplicate" + nextReq.Date = 1700000201 + next, err := messages.SendPrivateText(ctx, nextReq) + if err != nil { + t.Fatalf("SendPrivateText after duplicate: %v", err) + } + if next.SenderMessage.Pts != 2 || next.RecipientMessage.Pts != 2 { + t.Fatalf("next send = %+v/%+v, want pts=2 without duplicate event", next.SenderMessage, next.RecipientMessage) + } +} + +func requireChannelForwardRefOnlyEvent(t *testing.T, event domain.UpdateEvent, sourceChannelID, senderID int64, messageID int) { + t.Helper() + if event.Type != domain.UpdateEventNewMessage || event.Message.ID != messageID { + t.Fatalf("event = %+v, want new_message for message %d", event, messageID) + } + if event.Message.Forward == nil { + t.Fatalf("event message forward = nil, want channel %d", sourceChannelID) + } + if event.Message.Forward.From != (domain.Peer{Type: domain.PeerTypeChannel, ID: sourceChannelID}) { + t.Fatalf("event message forward from = %+v, want channel %d", event.Message.Forward.From, sourceChannelID) + } + if len(event.Channels) != 0 { + t.Fatalf("event channels = %+v, want no store-level channel snapshot", event.Channels) + } + if _, ok := findDialogUserByID(event.Users, senderID); !ok { + t.Fatalf("event users = %+v, want sender %d", event.Users, senderID) + } +} + +func TestMessageStoreSendPrivateTextRecomputesInboxUnreadFromReadMax(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1667" + suffix + "01", + FirstName: "Sender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1667" + suffix + "02", + FirstName: "Recipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID} + first, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 823001, + Message: "already read", + Date: 1700000400, + }) + if err != nil { + t.Fatalf("SendPrivateText first: %v", err) + } + if _, err := messages.ReadHistory(ctx, domain.ReadHistoryRequest{ + OwnerUserID: recipient.ID, + Peer: peer, + MaxID: first.RecipientMessage.ID, + Date: 1700000410, + }); err != nil { + t.Fatalf("ReadHistory: %v", err) + } + if _, err := pool.Exec(ctx, ` + UPDATE dialogs + SET unread_count = 2 + WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + `, recipient.ID, string(domain.PeerTypeUser), sender.ID); err != nil { + t.Fatalf("corrupt unread count: %v", err) + } + + bodies := []string{"one", "two", "three"} + var last domain.SendPrivateTextResult + for i, body := range bodies { + last, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 823010 + int64(i), + Message: body, + Date: 1700000420 + i, + }) + if err != nil { + t.Fatalf("SendPrivateText %q: %v", body, err) + } + } + + var unreadCount, topMessageID, readInboxMaxID int + var topBody string + if err := pool.QueryRow(ctx, ` + SELECT d.unread_count, d.top_message_id, d.read_inbox_max_id, COALESCE(m.body, '') + FROM dialogs d + LEFT JOIN message_boxes m + ON m.owner_user_id = d.user_id + AND m.box_id = d.top_message_id + AND NOT m.deleted + WHERE d.user_id = $1 + AND d.peer_type = $2 + AND d.peer_id = $3 + `, recipient.ID, string(domain.PeerTypeUser), sender.ID).Scan(&unreadCount, &topMessageID, &readInboxMaxID, &topBody); err != nil { + t.Fatalf("load dialog: %v", err) + } + if unreadCount != len(bodies) || topMessageID != last.RecipientMessage.ID || readInboxMaxID != first.RecipientMessage.ID || topBody != "three" { + t.Fatalf("dialog unread/top = count %d top %d read %d body %q, want count=3 top=%d read=%d body=three", + unreadCount, topMessageID, readInboxMaxID, topBody, last.RecipientMessage.ID, first.RecipientMessage.ID) + } +} + +func TestMessageStoreSendPrivateTextRollbackDoesNotAdvancePts(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 31, + Phone: "+1777" + suffix + "01", + FirstName: "GapSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 32, + Phone: "+1777" + suffix + "02", + FirstName: "GapRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + messages := NewMessageStore(pool) + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 223344, + Message: "seed box", + Date: 1700000210, + }); err != nil { + t.Fatalf("seed SendPrivateText: %v", err) + } + + failing := NewMessageStore(pool, WithMessageAllocators(fixedBoxIDAllocator{next: 1})) + _, err = failing.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 223345, + Message: "should roll back", + Date: 1700000211, + }) + if err == nil { + t.Fatal("SendPrivateText succeeded, want box id conflict") + } + + updateEvents := NewUpdateEventStore(pool) + events, err := updateEvents.ListAfter(ctx, sender.ID, 1, 10) + if err != nil { + t.Fatalf("list sender events: %v", err) + } + if len(events) != 0 { + t.Fatalf("sender events after rollback = %+v, want none", events) + } + senderPts, err := updateEvents.MaxContiguousPts(ctx, sender.ID) + if err != nil { + t.Fatalf("sender MaxContiguousPts: %v", err) + } + recipientPts, err := updateEvents.MaxContiguousPts(ctx, recipient.ID) + if err != nil { + t.Fatalf("recipient MaxContiguousPts: %v", err) + } + if senderPts != 1 || recipientPts != 1 { + t.Fatalf("pts after rollback sender=%d recipient=%d, want both unchanged at 1", senderPts, recipientPts) + } +} + +func TestMessageStoreConcurrentRandomIDIdempotent(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{ + AccessHash: 41, + Phone: "+1888" + suffix + "01", + FirstName: "ConcurrentSender", + }) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{ + AccessHash: 42, + Phone: "+1888" + suffix + "02", + FirstName: "ConcurrentRecipient", + }) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + boxCounters := &perUserCounterAllocator{} + messages := NewMessageStore(pool, WithMessageAllocators(boxCounters)) + req := domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, + RecipientUserID: recipient.ID, + RandomID: 556677, + Message: "same random id", + Date: 1700000220, + } + + const workers = 8 + results := make(chan domain.SendPrivateTextResult, workers) + errs := make(chan error, workers) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + res, err := messages.SendPrivateText(ctx, req) + if err != nil { + errs <- err + return + } + results <- res + }() + } + wg.Wait() + close(results) + close(errs) + + for err := range errs { + t.Fatalf("SendPrivateText: %v", err) + } + var uid int64 + duplicates := 0 + successes := 0 + for res := range results { + if res.SenderMessage.UID == 0 || res.RecipientMessage.UID == 0 { + t.Fatalf("result = %+v, want populated shared message uid", res) + } + if uid == 0 { + uid = res.SenderMessage.UID + } + if res.SenderMessage.UID != uid || res.RecipientMessage.UID != uid { + t.Fatalf("result = %+v, want same private message uid %d", res, uid) + } + if res.Duplicate { + duplicates++ + } else { + successes++ + } + } + if successes != 1 || duplicates != workers-1 { + t.Fatalf("successes=%d duplicates=%d, want one insert and duplicate rest", successes, duplicates) + } + + var privateCount int + if err := pool.QueryRow(ctx, ` + SELECT count(*) + FROM private_messages + WHERE sender_user_id = $1 + AND random_id = $2 + `, sender.ID, req.RandomID).Scan(&privateCount); err != nil { + t.Fatalf("count private_messages: %v", err) + } + if privateCount != 1 { + t.Fatalf("private message count = %d, want 1", privateCount) + } + var boxCount int + if err := pool.QueryRow(ctx, ` + SELECT count(*) + FROM message_boxes + WHERE private_message_id = $1 + `, uid).Scan(&boxCount); err != nil { + t.Fatalf("count message boxes: %v", err) + } + if boxCount != 2 { + t.Fatalf("message box count = %d, want sender + recipient boxes", boxCount) + } + updateEvents := NewUpdateEventStore(pool) + senderEvents, err := updateEvents.ListAfter(ctx, sender.ID, 0, workers+2) + if err != nil { + t.Fatalf("list sender events: %v", err) + } + recipientEvents, err := updateEvents.ListAfter(ctx, recipient.ID, 0, workers+2) + if err != nil { + t.Fatalf("list recipient events: %v", err) + } + if len(senderEvents) != 1 || senderEvents[0].Type != domain.UpdateEventNewMessage || senderEvents[0].Pts != 1 { + t.Fatalf("sender events = %+v, want one new_message and no extra noop", senderEvents) + } + if len(recipientEvents) != 1 || recipientEvents[0].Type != domain.UpdateEventNewMessage || recipientEvents[0].Pts != 1 { + t.Fatalf("recipient events = %+v, want one new_message and no extra noop", recipientEvents) + } +} diff --git a/internal/store/postgres/message_store.go b/internal/store/postgres/message_store.go new file mode 100644 index 00000000..e98f051f --- /dev/null +++ b/internal/store/postgres/message_store.go @@ -0,0 +1,55 @@ +package postgres + +import ( + "context" + + "github.com/jackc/pgx/v5" + "go.uber.org/zap" + + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// MessageStore 用 PostgreSQL 实现 store.MessageStore。 +type MessageStore struct { + db sqlcgen.DBTX + q *sqlcgen.Queries + boxIDs store.BoxIDAllocator + log *zap.Logger +} + +type txBeginner interface { + Begin(ctx context.Context) (pgx.Tx, error) +} + +// MessageStoreOption 调整 PostgreSQL MessageStore 依赖。 +type MessageStoreOption func(*MessageStore) + +// WithMessageAllocators 注入 Redis-backed box_id allocator。 +func WithMessageAllocators(boxIDs store.BoxIDAllocator) MessageStoreOption { + return func(s *MessageStore) { + s.boxIDs = boxIDs + } +} + +// WithMessageLogger 注入消息 store 日志器,用于追踪消息与 pts 原子写入。 +func WithMessageLogger(log *zap.Logger) MessageStoreOption { + return func(s *MessageStore) { + s.log = log + } +} + +// NewMessageStore 基于 pgx 连接池(或事务)创建 MessageStore。 +func NewMessageStore(db sqlcgen.DBTX, opts ...MessageStoreOption) *MessageStore { + s := &MessageStore{db: db, q: sqlcgen.New(db)} + for _, opt := range opts { + opt(s) + } + if s.log == nil { + s.log = zap.NewNop() + } + if s.boxIDs == nil { + s.boxIDs = pgBoxIDAllocator{s: s} + } + return s +} diff --git a/internal/store/postgres/message_store_testkit_test.go b/internal/store/postgres/message_store_testkit_test.go new file mode 100644 index 00000000..5bd75343 --- /dev/null +++ b/internal/store/postgres/message_store_testkit_test.go @@ -0,0 +1,68 @@ +package postgres + +import ( + "context" + "sync" + "telesrv/internal/domain" +) + +type fixedBoxIDAllocator struct { + next int +} + +func (a fixedBoxIDAllocator) NextBoxID(context.Context, int64) (int, error) { + return a.next, nil +} + +func (a fixedBoxIDAllocator) CurrentBoxID(context.Context, int64) (int, error) { + return a.next, nil +} + +type perUserCounterAllocator struct { + mu sync.Mutex + values map[int64]int +} + +func (a *perUserCounterAllocator) NextBoxID(_ context.Context, userID int64) (int, error) { + return a.next(userID), nil +} + +func (a *perUserCounterAllocator) CurrentBoxID(_ context.Context, userID int64) (int, error) { + return a.current(userID), nil +} + +func (a *perUserCounterAllocator) next(userID int64) int { + a.mu.Lock() + defer a.mu.Unlock() + if a.values == nil { + a.values = map[int64]int{} + } + a.values[userID]++ + return a.values[userID] +} + +func (a *perUserCounterAllocator) current(userID int64) int { + a.mu.Lock() + defer a.mu.Unlock() + return a.values[userID] +} + +func messageIDs(messages []domain.Message) []int { + out := make([]int, 0, len(messages)) + for _, msg := range messages { + out = append(out, msg.ID) + } + return out +} + +func sameInts(got, want []int) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/internal/store/postgres/message_webpage_resolve_integration_test.go b/internal/store/postgres/message_webpage_resolve_integration_test.go new file mode 100644 index 00000000..c9ee889b --- /dev/null +++ b/internal/store/postgres/message_webpage_resolve_integration_test.go @@ -0,0 +1,92 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +// TestMessageStoreResolveWebPageIntegration 对 live Postgres 验证 WebPageResolve 分支: +// 双盒 media 就地换 done、body/edit_date 不变(无「已编辑」标记)、durable web_page 事件 +// 经 box JOIN 重建出 done media(difference 复用)、幂等。需 TELESRV_TEST_POSTGRES_DSN。 +func TestMessageStoreResolveWebPageIntegration(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + sender, err := users.Create(ctx, domain.User{AccessHash: 41, Phone: "+1667" + suffix + "11", FirstName: "WPSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + recipient, err := users.Create(ctx, domain.User{AccessHash: 42, Phone: "+1667" + suffix + "12", FirstName: "WPRecipient"}) + if err != nil { + t.Fatalf("create recipient: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{sender.ID, recipient.ID}) + }) + + const url = "https://example.com/x" + urlHash := domain.WebPageURLHash(url) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: recipient.ID} + + messages := NewMessageStore(pool) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: sender.ID, RecipientUserID: recipient.ID, RandomID: 778811, + Message: "see " + url, Date: 1700000300, + Media: &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStatePending, ID: urlHash, URL: url}}, + }) + if err != nil { + t.Fatalf("SendPrivateText: %v", err) + } + + done := &domain.MessageMedia{Kind: domain.MessageMediaKindWebPage, WebPage: &domain.MessageWebPage{State: domain.MessageWebPageStateDone, ID: urlHash, URL: url, DisplayURL: "example.com", Title: "Example"}} + resolveReq := domain.EditMessageRequest{ + OwnerUserID: sender.ID, Peer: peer, ID: sent.SenderMessage.ID, + Media: done, WebPageResolve: true, ExpectedWebPageID: urlHash, + } + res, err := messages.EditMessage(ctx, resolveReq) + if err != nil { + t.Fatalf("EditMessage(WebPageResolve): %v", err) + } + self := res.Self() + if self.Event.Type != domain.UpdateEventWebPage { + t.Fatalf("self event type = %q, want web_page", self.Event.Type) + } + if self.Message.Media == nil || self.Message.Media.WebPage == nil || self.Message.Media.WebPage.State != domain.MessageWebPageStateDone { + t.Fatalf("self media not resolved: %+v", self.Message.Media) + } + if self.Message.Body != "see "+url || self.Message.EditDate != 0 { + t.Fatalf("body/edit_date changed: body=%q edit_date=%d", self.Message.Body, self.Message.EditDate) + } + + // 接收方盒子:media=done,body/edit_date 不变。 + rh, err := messages.ListByUser(ctx, recipient.ID, domain.MessageFilter{HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: sender.ID}, Limit: 10}) + if err != nil { + t.Fatalf("recipient history: %v", err) + } + if len(rh.Messages) != 1 || rh.Messages[0].Media == nil || rh.Messages[0].Media.WebPage == nil || rh.Messages[0].Media.WebPage.State != domain.MessageWebPageStateDone { + t.Fatalf("recipient media not resolved: %+v", rh.Messages) + } + if rh.Messages[0].Body != "see "+url || rh.Messages[0].EditDate != 0 { + t.Fatalf("recipient body/edit_date changed: %+v", rh.Messages[0]) + } + + // durable 事件:difference 复用经 box JOIN 重建出 done media。 + recipientEvents, err := NewUpdateEventStore(pool).ListAfter(ctx, recipient.ID, 0, 10) + if err != nil { + t.Fatalf("recipient events: %v", err) + } + last := recipientEvents[len(recipientEvents)-1] + if last.Type != domain.UpdateEventWebPage || last.Message.Media == nil || last.Message.Media.WebPage == nil || last.Message.Media.WebPage.State != domain.MessageWebPageStateDone { + t.Fatalf("recipient durable web_page event = %+v, want done media", last) + } + + // 幂等:再次解析 → ErrMessageNotModified。 + if _, err := messages.EditMessage(ctx, resolveReq); !errors.Is(err, domain.ErrMessageNotModified) { + t.Fatalf("re-resolve err = %v, want ErrMessageNotModified", err) + } +} diff --git a/internal/store/postgres/paid_reaction_integration_test.go b/internal/store/postgres/paid_reaction_integration_test.go new file mode 100644 index 00000000..4bab9e8d --- /dev/null +++ b/internal/store/postgres/paid_reaction_integration_test.go @@ -0,0 +1,129 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +// TestChannelPaidReactionPostgres 回归迁移 0010:广播频道付费 reaction 对真实 PG 的累计 + +// 聚合(总星数 / viewer 自身 / top reactors 降序 / 同 reactor 多次累加)。 +func TestChannelPaidReactionPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 521, Phone: "+1778" + suffix + "41", FirstName: "PaidRxOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 522, Phone: "+1778" + suffix + "42", FirstName: "PaidRxMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + var channelID int64 + t.Cleanup(func() { + if channelID != 0 { + _, _ = pool.Exec(ctx, "DELETE FROM channel_message_paid_reactions WHERE channel_id = $1", channelID) + _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID) + } + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, member.ID}) + }) + + channels := NewChannelStore(pool) + created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Paid Reaction " + suffix, + Broadcast: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000400, + }) + if err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + channelID = created.Channel.ID + sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{ + UserID: owner.ID, + ChannelID: channelID, + RandomID: 9401, + Message: "paid reaction target", + Date: 1700000401, + }) + if err != nil { + t.Fatalf("send message: %v", err) + } + msgID := sent.Message.ID + + // owner 投 100。 + res, err := channels.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{ + UserID: owner.ID, ChannelID: channelID, MessageID: msgID, Stars: 100, Date: 1700000402, + }) + if err != nil { + t.Fatalf("owner paid reaction: %v", err) + } + if res.Paid.TotalStars != 100 || res.Paid.MyStars != 100 { + t.Fatalf("after owner 100 = total %d my %d, want 100/100", res.Paid.TotalStars, res.Paid.MyStars) + } + + // member 投 250 → 总 350,member 视角 my=250,top reactors 降序 member(250)/owner(100)。 + res, err = channels.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{ + UserID: member.ID, ChannelID: channelID, MessageID: msgID, Stars: 250, Date: 1700000403, + }) + if err != nil { + t.Fatalf("member paid reaction: %v", err) + } + if res.Paid.TotalStars != 350 || res.Paid.MyStars != 250 { + t.Fatalf("after member 250 = total %d my %d, want 350/250", res.Paid.TotalStars, res.Paid.MyStars) + } + if len(res.Paid.TopReactors) != 2 || res.Paid.TopReactors[0].Stars != 250 || !res.Paid.TopReactors[0].My || res.Paid.TopReactors[1].Stars != 100 { + t.Fatalf("top reactors = %+v, want member(250,My)/owner(100)", res.Paid.TopReactors) + } + + // owner 再投 50 → 累加到 150,总 400。 + res, err = channels.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{ + UserID: owner.ID, ChannelID: channelID, MessageID: msgID, Stars: 50, Date: 1700000404, + }) + if err != nil { + t.Fatalf("owner re-invest: %v", err) + } + if res.Paid.TotalStars != 400 || res.Paid.MyStars != 150 { + t.Fatalf("after owner +50 = total %d my %d, want 400/150 (accumulated)", res.Paid.TotalStars, res.Paid.MyStars) + } + + // 关键回归(读路径修复):fresh 读回该消息必须携带付费 reaction(不止实时推送)。 + // owner 视角:Paid.TotalStars=400, MyStars=150。 + read, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ + UserID: owner.ID, ChannelID: channelID, IDs: []int{msgID}, + }) + if err != nil { + t.Fatalf("get message reactions: %v", err) + } + if len(read.Messages) != 1 || read.Messages[0].Reactions == nil || read.Messages[0].Reactions.Paid == nil { + t.Fatalf("fresh read messages=%d reactions/paid missing: %+v", len(read.Messages), read.Messages) + } + paid := read.Messages[0].Reactions.Paid + if paid.TotalStars != 400 || paid.MyStars != 150 { + t.Fatalf("fresh read paid = total %d my %d, want 400/150 (读路径须回显付费 reaction)", paid.TotalStars, paid.MyStars) + } + // member 视角读同一条:MyStars=250、TopReactors 含 member 自己带 My。 + readMember, err := channels.GetChannelMessageReactions(ctx, domain.ChannelMessageReactionsRequest{ + UserID: member.ID, ChannelID: channelID, IDs: []int{msgID}, + }) + if err != nil { + t.Fatalf("get message reactions (member): %v", err) + } + mp := readMember.Messages[0].Reactions.Paid + if mp == nil || mp.TotalStars != 400 || mp.MyStars != 250 { + t.Fatalf("member fresh read paid = %+v, want total 400 my 250", mp) + } + + // 非法星数被拒。 + if _, err := channels.AddChannelMessagePaidReaction(ctx, domain.SendChannelPaidReactionRequest{ + UserID: owner.ID, ChannelID: channelID, MessageID: msgID, Stars: 0, Date: 1700000405, + }); !errors.Is(err, domain.ErrChannelInvalid) { + t.Fatalf("zero-stars err = %v, want ErrChannelInvalid", err) + } +} diff --git a/internal/store/postgres/passkey.go b/internal/store/postgres/passkey.go new file mode 100644 index 00000000..0022a99e --- /dev/null +++ b/internal/store/postgres/passkey.go @@ -0,0 +1,129 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// PasskeyStore 用 PostgreSQL 实现 store.PasskeyStore。 +type PasskeyStore struct { + db sqlcgen.DBTX +} + +// NewPasskeyStore 基于 pgx 连接池(或事务)创建 PasskeyStore。 +func NewPasskeyStore(db sqlcgen.DBTX) *PasskeyStore { + return &PasskeyStore{db: db} +} + +func (s *PasskeyStore) InsertPasskey(ctx context.Context, cred domain.PasskeyCredential) error { + if len(cred.CredentialID) == 0 || cred.UserID == 0 { + return domain.ErrPasskeyInvalid + } + createdAt := time.Now() + if cred.CreatedAt > 0 { + createdAt = time.Unix(cred.CreatedAt, 0) + } + var lastUsed any + if cred.LastUsedAt > 0 { + lastUsed = time.Unix(cred.LastUsedAt, 0) + } + transports := cred.Transports + if transports == nil { + transports = []string{} + } + _, err := s.db.Exec(ctx, ` +INSERT INTO passkey_credentials ( + credential_id, user_id, public_key, sign_count, aaguid, name, transports, created_at, last_used_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + cred.CredentialID, cred.UserID, nonNilBytea(cred.PublicKey), int64(cred.SignCount), + nonNilBytea(cred.AAGUID), cred.Name, transports, createdAt, lastUsed, + ) + if err != nil { + return fmt.Errorf("insert passkey: %w", err) + } + return nil +} + +func scanPasskey(row pgx.Row) (domain.PasskeyCredential, error) { + var ( + cred domain.PasskeyCredential + signCount int64 + createdAt time.Time + lastUsedAt sql.NullTime + ) + if err := row.Scan( + &cred.CredentialID, &cred.UserID, &cred.PublicKey, &signCount, + &cred.AAGUID, &cred.Name, &cred.Transports, &createdAt, &lastUsedAt, + ); err != nil { + return domain.PasskeyCredential{}, err + } + cred.SignCount = uint32(signCount) + cred.CreatedAt = createdAt.Unix() + if lastUsedAt.Valid { + cred.LastUsedAt = lastUsedAt.Time.Unix() + } + return cred, nil +} + +const passkeyColumns = `credential_id, user_id, public_key, sign_count, aaguid, name, transports, created_at, last_used_at` + +func (s *PasskeyStore) GetPasskeyByCredentialID(ctx context.Context, credentialID []byte) (domain.PasskeyCredential, bool, error) { + row := s.db.QueryRow(ctx, `SELECT `+passkeyColumns+` FROM passkey_credentials WHERE credential_id = $1`, credentialID) + cred, err := scanPasskey(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.PasskeyCredential{}, false, nil + } + return domain.PasskeyCredential{}, false, fmt.Errorf("get passkey: %w", err) + } + return cred, true, nil +} + +func (s *PasskeyStore) ListPasskeysByUser(ctx context.Context, userID int64) ([]domain.PasskeyCredential, error) { + rows, err := s.db.Query(ctx, `SELECT `+passkeyColumns+` FROM passkey_credentials WHERE user_id = $1 ORDER BY created_at`, userID) + if err != nil { + return nil, fmt.Errorf("list passkeys: %w", err) + } + defer rows.Close() + out := make([]domain.PasskeyCredential, 0) + for rows.Next() { + cred, err := scanPasskey(rows) + if err != nil { + return nil, fmt.Errorf("scan passkey: %w", err) + } + out = append(out, cred) + } + return out, rows.Err() +} + +func (s *PasskeyStore) UpdatePasskeyUsage(ctx context.Context, credentialID []byte, signCount uint32, lastUsedAt int64) error { + var lastUsed any + if lastUsedAt > 0 { + lastUsed = time.Unix(lastUsedAt, 0) + } + tag, err := s.db.Exec(ctx, `UPDATE passkey_credentials SET sign_count = $2, last_used_at = $3 WHERE credential_id = $1`, + credentialID, int64(signCount), lastUsed) + if err != nil { + return fmt.Errorf("update passkey usage: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrPasskeyNotFound + } + return nil +} + +func (s *PasskeyStore) DeletePasskey(ctx context.Context, userID int64, credentialID []byte) (bool, error) { + tag, err := s.db.Exec(ctx, `DELETE FROM passkey_credentials WHERE credential_id = $1 AND user_id = $2`, credentialID, userID) + if err != nil { + return false, fmt.Errorf("delete passkey: %w", err) + } + return tag.RowsAffected() > 0, nil +} diff --git a/internal/store/postgres/peer_username.go b/internal/store/postgres/peer_username.go new file mode 100644 index 00000000..45071817 --- /dev/null +++ b/internal/store/postgres/peer_username.go @@ -0,0 +1,94 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +const ( + peerUsernameTypeUser = "user" + peerUsernameTypeChannel = "channel" +) + +type peerUsernameOwner struct { + peerType string + peerID int64 +} + +func (o peerUsernameOwner) matches(peerType string, peerID int64) bool { + return o.peerType == peerType && o.peerID == peerID +} + +func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower string, forUpdate bool) (peerUsernameOwner, bool, error) { + if usernameLower == "" { + return peerUsernameOwner{}, false, nil + } + query := `SELECT peer_type, peer_id FROM peer_usernames WHERE username_lower = $1` + if forUpdate { + query += ` FOR UPDATE` + } + var owner peerUsernameOwner + err := db.QueryRow(ctx, query, usernameLower).Scan(&owner.peerType, &owner.peerID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return peerUsernameOwner{}, false, nil + } + return peerUsernameOwner{}, false, fmt.Errorf("get peer username owner: %w", err) + } + return owner, true, nil +} + +func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower, peerType string, peerID int64) (bool, error) { + owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false) + if err != nil || !found { + return !found, err + } + return owner.matches(peerType, peerID), nil +} + +func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, usernameLower string) error { + if usernameLower != "" { + owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true) + if err != nil { + return err + } + if found && !owner.matches(peerType, peerID) { + return domain.ErrUsernameOccupied + } + } + if _, err := tx.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2`, peerType, peerID); err != nil { + return fmt.Errorf("delete peer username: %w", err) + } + if usernameLower == "" { + return nil + } + if _, err := tx.Exec(ctx, ` +INSERT INTO peer_usernames (username_lower, peer_type, peer_id) +VALUES ($1, $2, $3)`, usernameLower, peerType, peerID); err != nil { + if isUniqueViolation(err) { + return domain.ErrUsernameOccupied + } + return fmt.Errorf("insert peer username: %w", err) + } + return nil +} + +func deletePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64) error { + if _, err := tx.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2`, peerType, peerID); err != nil { + return fmt.Errorf("delete peer username: %w", err) + } + return nil +} + +func isUniqueConstraint(err error, constraintName string) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == constraintName +} diff --git a/internal/store/postgres/poll.go b/internal/store/postgres/poll.go new file mode 100644 index 00000000..979e1eb7 --- /dev/null +++ b/internal/store/postgres/poll.go @@ -0,0 +1,419 @@ +package postgres + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// 本文件实现 poll 权威态(polls / poll_votes 两张表,迁移 0089): +// - PollStore:发送时建 poll、getPollVotes 列表; +// - 私聊/频道消息 store 的投票、关闭与读路径 enrichment 共用本文件 SQL 辅助; +// - 校验与 viewer 门控复用 domain.ValidatePollVote / ResolvePollResults,与 memory 实现一致。 +// +// options/correct_options/votes.options JSONB 统一存 base64(option bytes) 数组 +//(即 json.Marshal([][]byte) 的天然形态)。 + +// PollStore 是 store.PollStore 的 PostgreSQL 实现。 +type PollStore struct { + db sqlcgen.DBTX +} + +// NewPollStore 基于 pgx 连接池(或事务)创建 PollStore。 +func NewPollStore(db sqlcgen.DBTX) *PollStore { + return &PollStore{db: db} +} + +func (s *PollStore) CreatePoll(ctx context.Context, def domain.PollDefinition) error { + if def.ID == 0 || len(def.Options) == 0 { + return domain.ErrPollInvalid + } + options, err := json.Marshal(def.Options) + if err != nil { + return fmt.Errorf("marshal poll options: %w", err) + } + correct, err := json.Marshal(def.CorrectOptions) + if err != nil { + return fmt.Errorf("marshal poll correct options: %w", err) + } + solutionEntities, err := encodeMessageEntities(def.SolutionEntities) + if err != nil { + return fmt.Errorf("marshal poll solution entities: %w", err) + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO polls ( + poll_id, creator_user_id, multiple_choice, quiz, public_voters, revoting_disabled, hide_results, + closed, close_period, close_date, options, correct_options, solution, solution_entities +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, + def.ID, def.CreatorUserID, def.MultipleChoice, def.Quiz, def.PublicVoters, def.RevotingDisabled, def.HideResultsUntilClose, + def.Closed, int32(def.ClosePeriod), int32(def.CloseDate), options, correct, def.Solution, solutionEntities, + ); err != nil { + return fmt.Errorf("insert poll: %w", err) + } + return nil +} + +func (s *PollStore) GetPollDefinition(ctx context.Context, pollID int64) (domain.PollDefinition, bool, error) { + defs, err := loadPollDefinitions(ctx, s.db, []int64{pollID}, false) + if err != nil { + return domain.PollDefinition{}, false, err + } + def, ok := defs[pollID] + return def, ok, nil +} + +func (s *PollStore) ListPollVotes(ctx context.Context, req domain.PollVotesListRequest) (domain.PollVotesList, error) { + if req.PollID == 0 || req.Limit <= 0 { + return domain.PollVotesList{}, domain.ErrPollInvalid + } + defs, err := loadPollDefinitions(ctx, s.db, []int64{req.PollID}, false) + if err != nil { + return domain.PollVotesList{}, err + } + if _, ok := defs[req.PollID]; !ok { + return domain.PollVotesList{}, domain.ErrPollNotFound + } + optionFilter := "" + args := []any{req.PollID} + if len(req.Option) > 0 { + args = append(args, base64.StdEncoding.EncodeToString(req.Option)) + optionFilter = fmt.Sprintf("AND options ? $%d", len(args)) + } + var count int + if err := s.db.QueryRow(ctx, fmt.Sprintf(` +SELECT COUNT(*)::int FROM poll_votes WHERE poll_id = $1 %s`, optionFilter), args...).Scan(&count); err != nil { + return domain.PollVotesList{}, fmt.Errorf("count poll votes: %w", err) + } + offsetFilter := "" + if req.OffsetDate > 0 || req.OffsetUserID > 0 { + args = append(args, int32(req.OffsetDate), req.OffsetUserID) + offsetFilter = fmt.Sprintf("AND (vote_date, user_id) < ($%d, $%d)", len(args)-1, len(args)) + } + args = append(args, int32(req.Limit+1)) + rows, err := s.db.Query(ctx, fmt.Sprintf(` +SELECT user_id, options::text, vote_date +FROM poll_votes +WHERE poll_id = $1 %s %s +ORDER BY vote_date DESC, user_id DESC +LIMIT $%d`, optionFilter, offsetFilter, len(args)), args...) + if err != nil { + return domain.PollVotesList{}, fmt.Errorf("list poll votes: %w", err) + } + defer rows.Close() + out := domain.PollVotesList{Count: count} + for rows.Next() { + var userID int64 + var optionsJSON string + var date int32 + if err := rows.Scan(&userID, &optionsJSON, &date); err != nil { + return domain.PollVotesList{}, fmt.Errorf("scan poll vote: %w", err) + } + options, err := decodePollOptions(optionsJSON) + if err != nil { + return domain.PollVotesList{}, err + } + out.Votes = append(out.Votes, domain.PollVote{PollID: req.PollID, UserID: userID, Options: options, Date: int(date)}) + } + if err := rows.Err(); err != nil { + return domain.PollVotesList{}, fmt.Errorf("iterate poll votes: %w", err) + } + if len(out.Votes) > req.Limit { + out.Votes = out.Votes[:req.Limit] + out.HasMore = true + } + return out, nil +} + +// loadPollDefinitions 批量加载权威定义;forUpdate 时锁行(投票/关闭事务用)。 +func loadPollDefinitions(ctx context.Context, db sqlcgen.DBTX, pollIDs []int64, forUpdate bool) (map[int64]domain.PollDefinition, error) { + if len(pollIDs) == 0 { + return map[int64]domain.PollDefinition{}, nil + } + suffix := "" + if forUpdate { + suffix = " FOR UPDATE" + } + rows, err := db.Query(ctx, ` +SELECT poll_id, creator_user_id, multiple_choice, quiz, public_voters, revoting_disabled, hide_results, + closed, close_period, close_date, options::text, correct_options::text, solution, solution_entities::text +FROM polls +WHERE poll_id = ANY($1)`+suffix, pollIDs) + if err != nil { + return nil, fmt.Errorf("load poll definitions: %w", err) + } + defer rows.Close() + out := make(map[int64]domain.PollDefinition, len(pollIDs)) + for rows.Next() { + var def domain.PollDefinition + var closePeriod, closeDate int32 + var optionsJSON, correctJSON, solutionEntitiesJSON string + if err := rows.Scan(&def.ID, &def.CreatorUserID, &def.MultipleChoice, &def.Quiz, &def.PublicVoters, &def.RevotingDisabled, &def.HideResultsUntilClose, + &def.Closed, &closePeriod, &closeDate, &optionsJSON, &correctJSON, &def.Solution, &solutionEntitiesJSON); err != nil { + return nil, fmt.Errorf("scan poll definition: %w", err) + } + def.ClosePeriod = int(closePeriod) + def.CloseDate = int(closeDate) + if def.Options, err = decodePollOptions(optionsJSON); err != nil { + return nil, err + } + if def.CorrectOptions, err = decodePollOptions(correctJSON); err != nil { + return nil, err + } + if def.SolutionEntities, err = decodeMessageEntities(solutionEntitiesJSON); err != nil { + return nil, fmt.Errorf("decode poll solution entities: %w", err) + } + out[def.ID] = def + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate poll definitions: %w", err) + } + return out, nil +} + +// applyPollVote 在调用方事务里执行一次投票(校验 + upsert/撤票删除)。 +// 必须传入已 FOR UPDATE 锁定的 def,避免 quiz 并发双投。 +func applyPollVote(ctx context.Context, db sqlcgen.DBTX, def domain.PollDefinition, userID int64, options [][]byte, date int) error { + var existingJSON *string + err := db.QueryRow(ctx, ` +SELECT options::text FROM poll_votes WHERE poll_id = $1 AND user_id = $2 FOR UPDATE`, def.ID, userID).Scan(&existingJSON) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("load existing poll vote: %w", err) + } + var existing [][]byte + if existingJSON != nil { + if existing, err = decodePollOptions(*existingJSON); err != nil { + return err + } + } + if err := domain.ValidatePollVote(def, existing, options, date); err != nil { + return err + } + if len(options) == 0 { + if _, err := db.Exec(ctx, `DELETE FROM poll_votes WHERE poll_id = $1 AND user_id = $2`, def.ID, userID); err != nil { + return fmt.Errorf("delete poll vote: %w", err) + } + return nil + } + encoded, err := json.Marshal(options) + if err != nil { + return fmt.Errorf("marshal poll vote options: %w", err) + } + if _, err := db.Exec(ctx, ` +INSERT INTO poll_votes (poll_id, user_id, options, vote_date) +VALUES ($1, $2, $3, $4) +ON CONFLICT (poll_id, user_id) +DO UPDATE SET options = EXCLUDED.options, vote_date = EXCLUDED.vote_date`, def.ID, userID, encoded, int32(date)); err != nil { + return fmt.Errorf("upsert poll vote: %w", err) + } + return nil +} + +// closePollAsCreator 关闭 poll(幂等);非创建者返回 ErrPollNotCreator。 +func closePollAsCreator(ctx context.Context, db sqlcgen.DBTX, def domain.PollDefinition, byUserID int64) error { + if def.CreatorUserID != byUserID { + return domain.ErrPollNotCreator + } + if _, err := db.Exec(ctx, `UPDATE polls SET closed = TRUE WHERE poll_id = $1`, def.ID); err != nil { + return fmt.Errorf("close poll: %w", err) + } + return nil +} + +// pollViewerAggregates 批量计算 viewer 视角的原始聚合(counts/total/recent/viewer options)。 +func pollViewerAggregates(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, pollIDs []int64) (map[int64]domain.PollAggregates, error) { + out := make(map[int64]domain.PollAggregates, len(pollIDs)) + if len(pollIDs) == 0 { + return out, nil + } + ensure := func(pollID int64) domain.PollAggregates { + agg, ok := out[pollID] + if !ok { + agg = domain.PollAggregates{Counts: make(map[string]int)} + } + return agg + } + counts, err := db.Query(ctx, ` +SELECT poll_id, opt, COUNT(*)::int +FROM poll_votes, LATERAL jsonb_array_elements_text(options) AS opt +WHERE poll_id = ANY($1) +GROUP BY poll_id, opt`, pollIDs) + if err != nil { + return nil, fmt.Errorf("aggregate poll counts: %w", err) + } + for counts.Next() { + var pollID int64 + var encoded string + var count int32 + if err := counts.Scan(&pollID, &encoded, &count); err != nil { + counts.Close() + return nil, fmt.Errorf("scan poll count: %w", err) + } + option, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + counts.Close() + return nil, fmt.Errorf("decode poll count option: %w", err) + } + agg := ensure(pollID) + agg.Counts[string(option)] = int(count) + out[pollID] = agg + } + if err := counts.Err(); err != nil { + counts.Close() + return nil, fmt.Errorf("iterate poll counts: %w", err) + } + counts.Close() + + totals, err := db.Query(ctx, ` +SELECT poll_id, COUNT(*)::int FROM poll_votes WHERE poll_id = ANY($1) GROUP BY poll_id`, pollIDs) + if err != nil { + return nil, fmt.Errorf("aggregate poll totals: %w", err) + } + for totals.Next() { + var pollID int64 + var total int32 + if err := totals.Scan(&pollID, &total); err != nil { + totals.Close() + return nil, fmt.Errorf("scan poll total: %w", err) + } + agg := ensure(pollID) + agg.TotalVoters = int(total) + out[pollID] = agg + } + if err := totals.Err(); err != nil { + totals.Close() + return nil, fmt.Errorf("iterate poll totals: %w", err) + } + totals.Close() + + recent, err := db.Query(ctx, ` +SELECT poll_id, user_id +FROM ( + SELECT poll_id, user_id, + row_number() OVER (PARTITION BY poll_id ORDER BY vote_date DESC, user_id DESC) AS rn + FROM poll_votes + WHERE poll_id = ANY($1) +) ranked +WHERE rn <= $2 +ORDER BY poll_id, rn`, pollIDs, int32(domain.MaxPollRecentVoters)) + if err != nil { + return nil, fmt.Errorf("aggregate poll recent voters: %w", err) + } + for recent.Next() { + var pollID, userID int64 + if err := recent.Scan(&pollID, &userID); err != nil { + recent.Close() + return nil, fmt.Errorf("scan poll recent voter: %w", err) + } + agg := ensure(pollID) + agg.RecentVoters = append(agg.RecentVoters, userID) + out[pollID] = agg + } + if err := recent.Err(); err != nil { + recent.Close() + return nil, fmt.Errorf("iterate poll recent voters: %w", err) + } + recent.Close() + + if viewerUserID != 0 { + viewer, err := db.Query(ctx, ` +SELECT poll_id, options::text FROM poll_votes WHERE poll_id = ANY($1) AND user_id = $2`, pollIDs, viewerUserID) + if err != nil { + return nil, fmt.Errorf("load viewer poll votes: %w", err) + } + for viewer.Next() { + var pollID int64 + var optionsJSON string + if err := viewer.Scan(&pollID, &optionsJSON); err != nil { + viewer.Close() + return nil, fmt.Errorf("scan viewer poll vote: %w", err) + } + options, err := decodePollOptions(optionsJSON) + if err != nil { + viewer.Close() + return nil, err + } + agg := ensure(pollID) + agg.ViewerOptions = options + out[pollID] = agg + } + if err := viewer.Err(); err != nil { + viewer.Close() + return nil, fmt.Errorf("iterate viewer poll votes: %w", err) + } + viewer.Close() + } + return out, nil +} + +// pollMediaRef 标记一条待 enrich 的 poll media 及其 viewer(私聊按 box owner,频道按请求 viewer)。 +type pollMediaRef struct { + media *domain.MessageMedia + viewer int64 +} + +// enrichPollMediaRefs 是 postgres 读路径的统一 poll enrichment: +// 按 (viewer) 分组加载 viewer options,counts/total/recent/定义只查一次。 +func enrichPollMediaRefs(ctx context.Context, db sqlcgen.DBTX, refs []pollMediaRef) error { + if len(refs) == 0 { + return nil + } + now := int(time.Now().Unix()) + pollIDSet := make(map[int64]struct{}, len(refs)) + viewers := make(map[int64][]pollMediaRef) + for _, ref := range refs { + if ref.media == nil || ref.media.Kind != domain.MessageMediaKindPoll || ref.media.Poll == nil || ref.media.Poll.ID == 0 { + continue + } + pollIDSet[ref.media.Poll.ID] = struct{}{} + viewers[ref.viewer] = append(viewers[ref.viewer], ref) + } + if len(pollIDSet) == 0 { + return nil + } + pollIDs := make([]int64, 0, len(pollIDSet)) + for id := range pollIDSet { + pollIDs = append(pollIDs, id) + } + defs, err := loadPollDefinitions(ctx, db, pollIDs, false) + if err != nil { + return err + } + for viewer, group := range viewers { + aggs, err := pollViewerAggregates(ctx, db, viewer, pollIDs) + if err != nil { + return err + } + for _, ref := range group { + def, ok := defs[ref.media.Poll.ID] + if !ok { + continue + } + agg, ok := aggs[ref.media.Poll.ID] + if !ok { + agg = domain.PollAggregates{Counts: map[string]int{}} + } + results := domain.ResolvePollResults(def, agg, viewer, now) + domain.ApplyPollState(ref.media.Poll, def, results, now) + } + } + return nil +} + +func decodePollOptions(raw string) ([][]byte, error) { + if raw == "" || raw == "[]" || raw == "null" { + return nil, nil + } + var out [][]byte + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil, fmt.Errorf("decode poll options: %w", err) + } + return out, nil +} diff --git a/internal/store/postgres/postgres.go b/internal/store/postgres/postgres.go index f258889d..04615bd5 100644 --- a/internal/store/postgres/postgres.go +++ b/internal/store/postgres/postgres.go @@ -12,13 +12,22 @@ import ( "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" // 注册 pgx5:// migrate driver "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "telesrv/deploy" + "telesrv/internal/store/postgres/sqlcgen" ) const defaultMinConns = 16 +// MigrationStatus 是启动迁移后的 schema 状态。 +type MigrationStatus struct { + Version uint + Dirty bool + Empty bool +} + // PoolOption 调整 pgxpool 连接池配置。 type PoolOption func(*pgxpool.Config) @@ -63,6 +72,7 @@ func Open(ctx context.Context, dsn string, opts ...PoolOption) (*pgxpool.Pool, e opt(cfg) } } + cfg.ConnConfig.Tracer = queryStatsTracer{} pool, err := pgxpool.NewWithConfig(ctx, cfg) if err != nil { return nil, fmt.Errorf("pgxpool new: %w", err) @@ -99,21 +109,59 @@ func warmMinConns(ctx context.Context, pool *pgxpool.Pool) error { return nil } +func withTx(ctx context.Context, db sqlcgen.DBTX, op string, fn func(pgx.Tx) error) error { + beginner, ok := db.(txBeginner) + if !ok { + return fmt.Errorf("%s: db does not support transactions", op) + } + tx, err := beginner.Begin(ctx) + if err != nil { + return fmt.Errorf("begin %s: %w", op, err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if err := fn(tx); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit %s: %w", op, err) + } + committed = true + return nil +} + // Migrate 用嵌入的迁移脚本将数据库迁移到最新版本。幂等:已最新时返回 nil。 func Migrate(dsn string) error { + _, err := MigrateAndStatus(dsn) + return err +} + +// MigrateAndStatus 用嵌入迁移脚本迁移数据库,并返回迁移后的 schema 版本。 +func MigrateAndStatus(dsn string) (MigrationStatus, error) { src, err := iofs.New(deploy.Migrations, "migrations") if err != nil { - return fmt.Errorf("iofs source: %w", err) + return MigrationStatus{}, fmt.Errorf("iofs source: %w", err) } m, err := migrate.NewWithSourceInstance("iofs", src, toPgx5DSN(dsn)) if err != nil { - return fmt.Errorf("migrate new: %w", err) + return MigrationStatus{}, fmt.Errorf("migrate new: %w", err) } defer m.Close() if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { - return fmt.Errorf("migrate up: %w", err) + return MigrationStatus{}, fmt.Errorf("migrate up: %w", err) } - return nil + version, dirty, err := m.Version() + if errors.Is(err, migrate.ErrNilVersion) { + return MigrationStatus{Empty: true}, nil + } + if err != nil { + return MigrationStatus{}, fmt.Errorf("migrate version: %w", err) + } + return MigrationStatus{Version: version, Dirty: dirty}, nil } // toPgx5DSN 把 pgxpool 用的 postgres:// DSN 转成 golang-migrate pgx5 driver 所需的 pgx5:// scheme。 diff --git a/internal/store/postgres/queries/bot.sql b/internal/store/postgres/queries/bot.sql new file mode 100644 index 00000000..84da0214 --- /dev/null +++ b/internal/store/postgres/queries/bot.sql @@ -0,0 +1,97 @@ +-- name: InsertBotUser :one +INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version) +VALUES ($1, '', $2, '', $3, '', TRUE, 1) +RETURNING *; + +-- name: InsertBot :exec +INSERT INTO bots (bot_user_id, owner_user_id, token_secret) +VALUES ($1, $2, $3); + +-- name: GetBot :one +SELECT * FROM bots WHERE bot_user_id = $1; + +-- name: ListBotsByOwner :many +-- 排除 self-owner 种子行(内置 BotFather 自有),与 memory 实现一致:/mybots 与 +-- 创建上限只统计用户经 /newbot 创建的 bot。 +SELECT * FROM bots WHERE owner_user_id = $1 AND bot_user_id <> owner_user_id ORDER BY bot_user_id; + +-- name: CountBotsByOwner :one +SELECT COUNT(*) FROM bots WHERE owner_user_id = $1 AND bot_user_id <> owner_user_id; + +-- name: UpdateBotTokenSecret :execrows +UPDATE bots SET token_secret = $2, updated_at = now() WHERE bot_user_id = $1; + +-- name: UpdateBotCommandsRow :execrows +UPDATE bots SET commands = $2, updated_at = now() WHERE bot_user_id = $1; + +-- name: UpdateBotDescriptionRow :execrows +UPDATE bots SET description = $2, updated_at = now() WHERE bot_user_id = $1; + +-- name: UpdateBotMenuButtonRow :execrows +UPDATE bots +SET menu_button_type = $2, menu_button_text = $3, menu_button_url = $4, updated_at = now() +WHERE bot_user_id = $1; + +-- name: SetBotInlinePlaceholderRow :execrows +UPDATE bots SET inline_placeholder = $2, updated_at = now() WHERE bot_user_id = $1; + +-- name: SetBotInlineGeoRow :execrows +UPDATE bots SET bot_inline_geo = $2, updated_at = now() WHERE bot_user_id = $1; + +-- name: SetBotNochatsRow :execrows +UPDATE bots SET bot_nochats = $2, updated_at = now() WHERE bot_user_id = $1; + +-- name: SetBotChatHistoryRow :execrows +UPDATE bots SET bot_chat_history = $2, updated_at = now() WHERE bot_user_id = $1; + +-- name: CanBotSendMessage :one +SELECT EXISTS ( + SELECT 1 + FROM bot_user_permissions + WHERE bot_user_id = $1 AND user_id = $2 +); + +-- name: AllowBotSendMessage :one +WITH inserted AS ( + INSERT INTO bot_user_permissions (bot_user_id, user_id, from_request) + VALUES ($1, $2, $3) + ON CONFLICT (bot_user_id, user_id) DO NOTHING + RETURNING 1 +), +updated AS ( + UPDATE bot_user_permissions + SET from_request = bot_user_permissions.from_request OR $3, + updated_at = now() + WHERE bot_user_id = $1 AND user_id = $2 + AND NOT EXISTS (SELECT 1 FROM inserted) + AND $3 + AND NOT from_request + RETURNING 1 +) +SELECT EXISTS (SELECT 1 FROM inserted) AS created; + +-- name: BumpBotInfoVersion :one +UPDATE users +SET bot_info_version = bot_info_version + 1, updated_at = now() +WHERE id = $1 AND is_bot +RETURNING bot_info_version; + +-- name: UpdateBotProfileFields :one +-- 部分更新 bot 的 users 行(setBotInfo 的 name/about);NULL 参数表示不动该字段。 +UPDATE users +SET first_name = COALESCE(sqlc.narg(first_name), first_name), + about = COALESCE(sqlc.narg(about), about), + updated_at = now() +WHERE id = $1 AND is_bot +RETURNING bot_info_version; + +-- name: GetBotChatState :one +SELECT * FROM bot_chat_states WHERE bot_user_id = $1 AND user_id = $2; + +-- name: UpsertBotChatState :exec +INSERT INTO bot_chat_states (bot_user_id, user_id, state, updated_at) +VALUES ($1, $2, $3, now()) +ON CONFLICT (bot_user_id, user_id) DO UPDATE SET state = EXCLUDED.state, updated_at = now(); + +-- name: DeleteBotChatState :exec +DELETE FROM bot_chat_states WHERE bot_user_id = $1 AND user_id = $2; diff --git a/internal/store/postgres/queries/contact.sql b/internal/store/postgres/queries/contact.sql index 137b4a51..7fa76e30 100644 --- a/internal/store/postgres/queries/contact.sql +++ b/internal/store/postgres/queries/contact.sql @@ -2,6 +2,7 @@ SELECT c.contact_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -16,6 +17,11 @@ SELECT u.country_code, u.verified, u.support, + u.is_bot, + u.bot_info_version, + u.premium_expires_at, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -26,6 +32,7 @@ ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u SELECT c.contact_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -40,6 +47,11 @@ SELECT u.country_code, u.verified, u.support, + u.is_bot, + u.bot_info_version, + u.premium_expires_at, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -98,6 +110,7 @@ reverse_updated AS ( SELECT c.contact_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -112,6 +125,11 @@ SELECT u.country_code, u.verified, u.support, + u.is_bot, + u.bot_info_version, + u.premium_expires_at, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at, EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed FROM upserted c @@ -130,6 +148,7 @@ WITH updated AS ( SELECT c.contact_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -144,6 +163,11 @@ SELECT u.country_code, u.verified, u.support, + u.is_bot, + u.bot_info_version, + u.premium_expires_at, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at FROM updated c JOIN users u ON u.id = c.contact_user_id; diff --git a/internal/store/postgres/queries/dialog.sql b/internal/store/postgres/queries/dialog.sql index 1af12f5a..b0df46ff 100644 --- a/internal/store/postgres/queries/dialog.sql +++ b/internal/store/postgres/queries/dialog.sql @@ -12,6 +12,9 @@ WITH base AS ( d.unread_count, d.unread_mentions_count, d.unread_reactions_count, + d.ttl_period, + d.theme_emoticon, + d.has_scheduled, d.pinned, d.pinned_order, d.unread_mark, @@ -25,25 +28,64 @@ WITH base AS ( COALESCE(u.country_code, '')::text AS peer_country_code, COALESCE(u.verified, false)::boolean AS peer_verified, COALESCE(u.support, false)::boolean AS peer_support, + COALESCE(u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at, (c.contact_user_id IS NOT NULL)::boolean AS peer_contact, COALESCE(c.mutual, false)::boolean AS peer_mutual, COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.private_message_id, 0)::bigint AS message_private_message_id, COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id, COALESCE(m.message_date, 0)::int AS message_date, COALESCE(m.outgoing, false)::boolean AS message_outgoing, COALESCE(m.body, '')::text AS message_body, COALESCE(m.entities::text, '[]')::text AS message_entities_json, - COALESCE(m.media::text, '{}')::text AS message_media_json + COALESCE(m.media::text, '{}')::text AS message_media_json, + COALESCE(m.ttl_period, 0)::int AS message_ttl_period, + COALESCE(m.expires_at, 0)::int AS message_expires_at, + COALESCE(m.edit_date, 0)::int AS message_edit_date, + COALESCE(m.silent, false)::boolean AS message_silent, + COALESCE(m.noforwards, false)::boolean AS message_noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS message_reply_to_msg_id, + COALESCE(m.reply_to_peer_type, '')::text AS message_reply_to_peer_type, + COALESCE(m.reply_to_peer_id, 0)::bigint AS message_reply_to_peer_id, + COALESCE(m.reply_to_top_id, 0)::int AS message_reply_to_top_id, + COALESCE(m.reply_to_story_id, 0)::int AS message_reply_to_story_id, + COALESCE(m.quote_text, '')::text AS message_quote_text, + COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json, + COALESCE(m.quote_offset, 0)::int AS message_quote_offset, + COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type, + COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id, + COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS message_fwd_date, + COALESCE(m.fwd_saved_from_peer_type, '')::text AS message_fwd_saved_from_peer_type, + COALESCE(m.fwd_saved_from_peer_id, 0)::bigint AS message_fwd_saved_from_peer_id, + COALESCE(m.fwd_saved_from_msg_id, 0)::int AS message_fwd_saved_from_msg_id, + COALESCE(m.saved_peer_type, '')::text AS message_saved_peer_type, + COALESCE(m.saved_peer_id, 0)::bigint AS message_saved_peer_id, + COALESCE(m.media_unread, false)::boolean AS message_media_unread, + COALESCE(m.reaction_unread, false)::boolean AS message_reaction_unread, + COALESCE(m.via_bot_id, 0)::bigint AS message_via_bot_id, + COALESCE(m.grouped_id, 0)::bigint AS message_grouped_id, + COALESCE(m.effect, 0)::bigint AS message_effect, + COALESCE(m.reply_markup::text, '{}')::text AS message_reply_markup_json, + COALESCE(m.rich_message::text, '{}')::text AS message_rich_message_json, + COALESCE(m.pinned, false)::boolean AS message_pinned FROM dialogs d LEFT JOIN users u ON d.peer_type = 'user' AND u.id = d.peer_id LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id LEFT JOIN message_boxes m ON m.owner_user_id = d.user_id AND m.box_id = d.top_message_id AND NOT m.deleted WHERE d.user_id = $1 AND ( - NOT sqlc.arg(has_folder_id)::boolean + -- 不带 folder_id flag 视为主列表(folder 0):官方语义下归档对话只以 + -- dialogFolder 聚合条目出现在主列表,DrKLO Android 主列表请求不设 flag。 + (NOT sqlc.arg(has_folder_id)::boolean AND d.folder_id = 0) OR ( - sqlc.arg(folder_id)::int < 2 + sqlc.arg(has_folder_id)::boolean + AND sqlc.arg(folder_id)::int < 2 AND d.folder_id = sqlc.arg(folder_id)::int ) OR ( @@ -135,6 +177,9 @@ SELECT unread_count, unread_mentions_count, unread_reactions_count, + ttl_period, + theme_emoticon, + has_scheduled, pinned, pinned_order, unread_mark, @@ -148,20 +193,56 @@ SELECT peer_country_code, peer_verified, peer_support, + peer_is_bot, + peer_bot_info_version, + peer_premium_until, + peer_emoji_status_document_id, + peer_emoji_status_until, peer_last_seen_at, peer_contact, peer_mutual, message_id, + message_private_message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json, - message_media_json + message_media_json, + message_ttl_period, + message_expires_at, + message_edit_date, + message_silent, + message_noforwards, + message_reply_to_msg_id, + message_reply_to_peer_type, + message_reply_to_peer_id, + message_reply_to_top_id, + message_reply_to_story_id, + message_quote_text, + message_quote_entities_json, + message_quote_offset, + message_fwd_from_peer_type, + message_fwd_from_peer_id, + message_fwd_from_name, + message_fwd_date, + message_fwd_saved_from_peer_type, + message_fwd_saved_from_peer_id, + message_fwd_saved_from_msg_id, + message_saved_peer_type, + message_saved_peer_id, + message_media_unread, + message_reaction_unread, + message_via_bot_id, + message_grouped_id, + message_effect, + message_reply_markup_json, + message_rich_message_json, + message_pinned FROM paged ORDER BY pinned DESC, - CASE WHEN pinned THEN COALESCE(NULLIF(pinned_order, 0), 2147483647) ELSE 2147483647 END ASC, + CASE WHEN pinned THEN COALESCE(pinned_order, 0) ELSE 0 END DESC, top_message_date DESC, top_message_id DESC, peer_id DESC @@ -179,6 +260,9 @@ SELECT d.unread_count, d.unread_mentions_count, d.unread_reactions_count, + d.ttl_period, + d.theme_emoticon, + d.has_scheduled, d.pinned, d.pinned_order, d.unread_mark, @@ -187,9 +271,10 @@ FROM dialogs d LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id WHERE d.user_id = $1 AND ( - NOT sqlc.arg(has_folder_id)::boolean + (NOT sqlc.arg(has_folder_id)::boolean AND d.folder_id = 0) OR ( - sqlc.arg(folder_id)::int < 2 + sqlc.arg(has_folder_id)::boolean + AND sqlc.arg(folder_id)::int < 2 AND d.folder_id = sqlc.arg(folder_id)::int ) OR ( @@ -233,7 +318,7 @@ WHERE d.user_id = $1 AND (NOT sqlc.arg(exclude_pinned)::boolean OR NOT d.pinned) ORDER BY d.pinned DESC, - CASE WHEN d.pinned THEN COALESCE(NULLIF(d.pinned_order, 0), 2147483647) ELSE 2147483647 END ASC, + CASE WHEN d.pinned THEN COALESCE(d.pinned_order, 0) ELSE 0 END DESC, d.top_message_date DESC, d.top_message_id DESC, d.peer_id DESC; @@ -268,6 +353,9 @@ base AS ( COALESCE(d.unread_count, 0)::int AS unread_count, COALESCE(d.unread_mentions_count, 0)::int AS unread_mentions_count, COALESCE(d.unread_reactions_count, 0)::int AS unread_reactions_count, + COALESCE(d.ttl_period, 0)::int AS ttl_period, + COALESCE(d.theme_emoticon, '')::text AS theme_emoticon, + COALESCE(d.has_scheduled, false)::boolean AS has_scheduled, COALESCE(d.pinned, false)::boolean AS pinned, COALESCE(d.pinned_order, 0)::int AS pinned_order, COALESCE(d.unread_mark, false)::boolean AS unread_mark, @@ -281,16 +369,52 @@ base AS ( COALESCE(u.country_code, '')::text AS peer_country_code, COALESCE(u.verified, false)::boolean AS peer_verified, COALESCE(u.support, false)::boolean AS peer_support, + COALESCE(u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at, (c.contact_user_id IS NOT NULL)::boolean AS peer_contact, COALESCE(c.mutual, false)::boolean AS peer_mutual, COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.private_message_id, 0)::bigint AS message_private_message_id, COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id, COALESCE(m.message_date, 0)::int AS message_date, COALESCE(m.outgoing, false)::boolean AS message_outgoing, COALESCE(m.body, '')::text AS message_body, COALESCE(m.entities::text, '[]')::text AS message_entities_json, COALESCE(m.media::text, '{}')::text AS message_media_json, + COALESCE(m.ttl_period, 0)::int AS message_ttl_period, + COALESCE(m.expires_at, 0)::int AS message_expires_at, + COALESCE(m.edit_date, 0)::int AS message_edit_date, + COALESCE(m.silent, false)::boolean AS message_silent, + COALESCE(m.noforwards, false)::boolean AS message_noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS message_reply_to_msg_id, + COALESCE(m.reply_to_peer_type, '')::text AS message_reply_to_peer_type, + COALESCE(m.reply_to_peer_id, 0)::bigint AS message_reply_to_peer_id, + COALESCE(m.reply_to_top_id, 0)::int AS message_reply_to_top_id, + COALESCE(m.reply_to_story_id, 0)::int AS message_reply_to_story_id, + COALESCE(m.quote_text, '')::text AS message_quote_text, + COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json, + COALESCE(m.quote_offset, 0)::int AS message_quote_offset, + COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type, + COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id, + COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS message_fwd_date, + COALESCE(m.fwd_saved_from_peer_type, '')::text AS message_fwd_saved_from_peer_type, + COALESCE(m.fwd_saved_from_peer_id, 0)::bigint AS message_fwd_saved_from_peer_id, + COALESCE(m.fwd_saved_from_msg_id, 0)::int AS message_fwd_saved_from_msg_id, + COALESCE(m.saved_peer_type, '')::text AS message_saved_peer_type, + COALESCE(m.saved_peer_id, 0)::bigint AS message_saved_peer_id, + COALESCE(m.media_unread, false)::boolean AS message_media_unread, + COALESCE(m.reaction_unread, false)::boolean AS message_reaction_unread, + COALESCE(m.via_bot_id, 0)::bigint AS message_via_bot_id, + COALESCE(m.grouped_id, 0)::bigint AS message_grouped_id, + COALESCE(m.effect, 0)::bigint AS message_effect, + COALESCE(m.reply_markup::text, '{}')::text AS message_reply_markup_json, + COALESCE(m.rich_message::text, '{}')::text AS message_rich_message_json, + COALESCE(m.pinned, false)::boolean AS message_pinned, r.ord FROM deduped r LEFT JOIN dialogs d @@ -313,6 +437,9 @@ SELECT unread_count, unread_mentions_count, unread_reactions_count, + ttl_period, + theme_emoticon, + has_scheduled, pinned, pinned_order, unread_mark, @@ -326,16 +453,52 @@ SELECT peer_country_code, peer_verified, peer_support, + peer_is_bot, + peer_bot_info_version, + peer_premium_until, + peer_emoji_status_document_id, + peer_emoji_status_until, peer_last_seen_at, peer_contact, peer_mutual, message_id, + message_private_message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json, - message_media_json + message_media_json, + message_ttl_period, + message_expires_at, + message_edit_date, + message_silent, + message_noforwards, + message_reply_to_msg_id, + message_reply_to_peer_type, + message_reply_to_peer_id, + message_reply_to_top_id, + message_reply_to_story_id, + message_quote_text, + message_quote_entities_json, + message_quote_offset, + message_fwd_from_peer_type, + message_fwd_from_peer_id, + message_fwd_from_name, + message_fwd_date, + message_fwd_saved_from_peer_type, + message_fwd_saved_from_peer_id, + message_fwd_saved_from_msg_id, + message_saved_peer_type, + message_saved_peer_id, + message_media_unread, + message_reaction_unread, + message_via_bot_id, + message_grouped_id, + message_effect, + message_reply_markup_json, + message_rich_message_json, + message_pinned FROM base ORDER BY ord; @@ -369,6 +532,8 @@ ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET updated_at = now(); -- name: UpsertOutboxDialog :exec +-- 发送方向该会话发出消息即视为已知晓内容:清除手动未读标记, +-- 与 channel 发送路径、readHistory 清除语义对齐。 INSERT INTO dialogs ( user_id, peer_type, @@ -382,6 +547,7 @@ INSERT INTO dialogs ( ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET top_message_id = EXCLUDED.top_message_id, top_message_date = EXCLUDED.top_message_date, + unread_mark = false, updated_at = now(); -- name: UpsertInboxDialog :exec @@ -396,9 +562,22 @@ INSERT INTO dialogs ( $1, $2, $3, $4, $5, 1 ) ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET - top_message_id = EXCLUDED.top_message_id, - top_message_date = EXCLUDED.top_message_date, - unread_count = dialogs.unread_count + 1, + top_message_id = GREATEST(dialogs.top_message_id, EXCLUDED.top_message_id), + top_message_date = CASE + WHEN EXCLUDED.top_message_id >= dialogs.top_message_id THEN EXCLUDED.top_message_date + ELSE dialogs.top_message_date + END, + unread_count = ( + SELECT COUNT(*)::int + FROM message_boxes m + WHERE m.owner_user_id = dialogs.user_id + AND m.peer_type = dialogs.peer_type + AND m.peer_id = dialogs.peer_id + AND NOT m.deleted + AND NOT m.outgoing + AND m.box_id > dialogs.read_inbox_max_id + AND m.box_id <= GREATEST(dialogs.top_message_id, EXCLUDED.top_message_id) + ), updated_at = now(); -- name: MarkDialogRead :one @@ -409,7 +588,11 @@ WITH target AS ( d.peer_id, d.top_message_id, d.read_inbox_max_id, - d.unread_count + d.unread_count, + LEAST( + d.top_message_id, + CASE WHEN sqlc.arg(max_id)::int > 0 THEN sqlc.arg(max_id)::int ELSE d.top_message_id END + )::int AS requested_read_max_id FROM dialogs d WHERE d.user_id = $1 AND d.peer_type = $2 @@ -418,14 +601,19 @@ WITH target AS ( updated AS ( UPDATE dialogs d SET - read_inbox_max_id = GREATEST( - d.read_inbox_max_id, - CASE WHEN sqlc.arg(max_id)::int > 0 THEN sqlc.arg(max_id)::int ELSE d.top_message_id END + read_inbox_max_id = GREATEST(d.read_inbox_max_id, target.requested_read_max_id), + unread_count = ( + SELECT count(*)::int + FROM message_boxes m + WHERE m.owner_user_id = d.user_id + AND m.peer_type = d.peer_type + AND m.peer_id = d.peer_id + AND NOT m.deleted + AND NOT m.outgoing + AND m.box_id > GREATEST(d.read_inbox_max_id, target.requested_read_max_id) ), - unread_count = 0, unread_mark = false, unread_mentions_count = 0, - unread_reactions_count = 0, updated_at = now() FROM target WHERE d.user_id = target.user_id @@ -439,9 +627,7 @@ RETURNING d.unread_count, ( target.unread_count > 0 - OR ( - CASE WHEN sqlc.arg(max_id)::int > 0 THEN sqlc.arg(max_id)::int ELSE target.top_message_id END - ) > target.read_inbox_max_id + OR target.requested_read_max_id > target.read_inbox_max_id )::boolean AS changed ) SELECT @@ -454,11 +640,34 @@ SELECT FROM updated; -- name: SetDialogPinned :one -WITH next_order AS ( - SELECT COALESCE(MAX(pinned_order), 0)::int + 1 AS value - FROM dialogs - WHERE user_id = sqlc.arg(user_id)::bigint - AND pinned +-- 置顶顺序在 dialog 当前 folder(0 主列表/1 归档)内独立分配, +-- 返回 folder_id 供 updateDialogPinned.folder_id 使用。 +WITH target AS ( + SELECT d.folder_id + FROM dialogs d + WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = sqlc.arg(peer_type)::text + AND d.peer_id = sqlc.arg(peer_id)::bigint +), +next_order AS ( + -- order 空间跨 dialogs/channel_dialogs 两表统一(合并层按 pinned_order + -- 全局排序),但仅在目标会话所在 folder 内取最大值。 + SELECT GREATEST( + COALESCE(( + SELECT MAX(d.pinned_order) + FROM dialogs d, target t + WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.pinned + AND d.folder_id = t.folder_id + ), 0), + COALESCE(( + SELECT MAX(cd.pinned_order) + FROM channel_dialogs cd, target t + WHERE cd.user_id = sqlc.arg(user_id)::bigint + AND cd.pinned + AND COALESCE(cd.folder_id, 0) = t.folder_id + ), 0) + )::int + 1 AS value ), updated AS ( UPDATE dialogs d @@ -473,11 +682,15 @@ updated AS ( WHERE d.user_id = sqlc.arg(user_id)::bigint AND d.peer_type = sqlc.arg(peer_type)::text AND d.peer_id = sqlc.arg(peer_id)::bigint - RETURNING d.user_id + RETURNING d.folder_id ) -SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed; +SELECT + EXISTS (SELECT 1 FROM updated)::boolean AS changed, + COALESCE((SELECT folder_id FROM updated), 0)::int AS folder_id; -- name: SetDialogUnreadMark :one +-- 值守卫 IS DISTINCT FROM:重复标记同一值不应算 changed,否则上层会记一条 +-- 幽灵 durable 未读标事件并多推一次 update(对齐频道侧 SetChannelDialogUnreadMark)。 WITH updated AS ( UPDATE dialogs d SET unread_mark = sqlc.arg(unread)::boolean, @@ -485,6 +698,7 @@ WITH updated AS ( WHERE d.user_id = sqlc.arg(user_id)::bigint AND d.peer_type = sqlc.arg(peer_type)::text AND d.peer_id = sqlc.arg(peer_id)::bigint + AND d.unread_mark IS DISTINCT FROM sqlc.arg(unread)::boolean RETURNING d.user_id ) SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed; @@ -527,10 +741,12 @@ WITH requested AS ( WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) ), deduped AS ( + -- 请求数组首位是置顶区最顶部;统一编码为"值越大越靠前",与频道 + -- reorder、toggle 的 MAX+1 分配以及合并层排序方向一致。 SELECT DISTINCT ON (peer_type, peer_id) peer_type, peer_id, - pos::int AS ord + (cardinality(sqlc.arg(peer_ids)::bigint[]) - pos + 1)::int AS ord FROM requested ORDER BY peer_type, peer_id, pos ) @@ -541,7 +757,8 @@ SET pinned = true, FROM deduped WHERE d.user_id = sqlc.arg(user_id)::bigint AND d.peer_type = deduped.peer_type - AND d.peer_id = deduped.peer_id; + AND d.peer_id = deduped.peer_id + AND d.folder_id = sqlc.arg(folder_id)::int; -- name: EditDialogPeerFolders :exec WITH requested AS ( @@ -563,13 +780,50 @@ deduped AS ( ORDER BY peer_type, peer_id ) UPDATE dialogs d +-- 换 folder 时清 pinned:TDesktop History::setFolderPointer 在归档/还原时 +-- 本地无条件 unpin,服务端保留旧 pin 会在下次 getDialogs 时把状态漂移回来。 SET folder_id = deduped.folder_id, + pinned = CASE WHEN d.folder_id <> deduped.folder_id THEN false ELSE d.pinned END, + pinned_order = CASE WHEN d.folder_id <> deduped.folder_id THEN 0 ELSE d.pinned_order END, updated_at = now() FROM deduped WHERE d.user_id = sqlc.arg(user_id)::bigint AND d.peer_type = deduped.peer_type AND d.peer_id = deduped.peer_id; +-- name: SetDialogArchivePinned :one +-- archive folder 行本身的置顶状态(toggleDialogPin(inputDialogPeerFolder))。 +-- 无行时官方默认视为 pinned=true,所以这里总是落一行显式值。 +WITH current AS ( + SELECT archive_pinned + FROM dialog_filter_settings + WHERE user_id = sqlc.arg(user_id)::bigint +), +upserted AS ( + INSERT INTO dialog_filter_settings (user_id, archive_pinned) + VALUES (sqlc.arg(user_id)::bigint, sqlc.arg(pinned)::boolean) + ON CONFLICT (user_id) DO UPDATE SET + archive_pinned = EXCLUDED.archive_pinned, + updated_at = now() + RETURNING archive_pinned +) +SELECT (COALESCE((SELECT archive_pinned FROM current), true) IS DISTINCT FROM sqlc.arg(pinned)::boolean)::boolean AS changed +FROM upserted; + +-- name: GetDialogArchivePinned :one +SELECT archive_pinned +FROM dialog_filter_settings +WHERE user_id = $1; + +-- name: CountArchiveUnreadDialogs :one +-- 归档(folder_id=1)未读聚合:有未读或手动标记未读的会话数 + 未读消息总数。 +SELECT + COUNT(*) FILTER (WHERE unread_count > 0 OR unread_mark)::int AS unread_peers, + COALESCE(SUM(unread_count), 0)::int AS unread_messages +FROM dialogs +WHERE user_id = $1 + AND folder_id = 1; + -- name: ClearPinnedDialogsNotInOrder :exec WITH requested AS ( SELECT @@ -584,6 +838,7 @@ SET pinned = false, updated_at = now() WHERE d.user_id = sqlc.arg(user_id)::bigint AND d.pinned + AND d.folder_id = sqlc.arg(folder_id)::int AND NOT EXISTS ( SELECT 1 FROM requested r @@ -607,7 +862,15 @@ SET AND m.box_id > d.read_inbox_max_id ), unread_mentions_count = 0, - unread_reactions_count = 0, + unread_reactions_count = ( + SELECT COUNT(*)::int + FROM message_boxes m2 + WHERE m2.owner_user_id = d.user_id + AND m2.peer_type = d.peer_type + AND m2.peer_id = d.peer_id + AND NOT m2.deleted + AND m2.reaction_unread + ), updated_at = now() WHERE d.user_id = sqlc.arg(user_id)::bigint AND d.peer_type = sqlc.arg(peer_type)::text @@ -623,17 +886,32 @@ SET unread_count = 0, unread_mark = false, unread_mentions_count = 0, - unread_reactions_count = 0, + unread_reactions_count = ( + SELECT COUNT(*)::int + FROM message_boxes m2 + WHERE m2.owner_user_id = d.user_id + AND m2.peer_type = d.peer_type + AND m2.peer_id = d.peer_id + AND NOT m2.deleted + AND m2.reaction_unread + ), updated_at = now() WHERE d.user_id = sqlc.arg(user_id)::bigint AND d.peer_type = sqlc.arg(peer_type)::text AND d.peer_id = sqlc.arg(peer_id)::bigint; -- name: DeleteDialogByPeer :exec -DELETE FROM dialogs -WHERE user_id = $1 - AND peer_type = $2 - AND peer_id = $3; +WITH dropped_drafts AS ( + -- 删除会话同时丢弃该 peer 的云草稿,避免对端重建会话后旧草稿复活。 + DELETE FROM dialog_drafts dd + WHERE dd.user_id = sqlc.arg(user_id)::bigint + AND dd.peer_type = sqlc.arg(peer_type)::text + AND dd.peer_id = sqlc.arg(peer_id)::bigint +) +DELETE FROM dialogs d +WHERE d.user_id = sqlc.arg(user_id)::bigint + AND d.peer_type = sqlc.arg(peer_type)::text + AND d.peer_id = sqlc.arg(peer_id)::bigint; -- name: ListDialogFolders :many SELECT @@ -775,3 +1053,11 @@ deleted AS ( ) SELECT draft_json FROM deleted; + +-- name: AdvanceDialogReadInboxFloor :exec +UPDATE dialogs +SET read_inbox_max_id = GREATEST(read_inbox_max_id, @read_inbox_max_id::int), + updated_at = now() +WHERE user_id = @user_id + AND peer_type = @peer_type + AND peer_id = @peer_id; diff --git a/internal/store/postgres/queries/media.sql b/internal/store/postgres/queries/media.sql index 85053f8a..a6a8381d 100644 --- a/internal/store/postgres/queries/media.sql +++ b/internal/store/postgres/queries/media.sql @@ -1,31 +1,85 @@ -- upload_parts ---------------------------------------------------------------- -- name: SaveUploadPart :exec -INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, bytes) +INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, backend, object_key, size, sha256) VALUES ( sqlc.arg(owner_user_id)::bigint, sqlc.arg(file_id)::bigint, sqlc.arg(part)::int, sqlc.arg(total_parts)::int, sqlc.arg(is_big)::boolean, - sqlc.arg(bytes)::bytea + sqlc.arg(backend)::text, + sqlc.arg(object_key)::text, + sqlc.arg(size)::bigint, + sqlc.arg(sha256)::bytea ) ON CONFLICT (owner_user_id, file_id, part) DO UPDATE SET total_parts = EXCLUDED.total_parts, is_big = EXCLUDED.is_big, - bytes = EXCLUDED.bytes; + backend = EXCLUDED.backend, + object_key = EXCLUDED.object_key, + size = EXCLUDED.size, + sha256 = EXCLUDED.sha256, + created_at = now(); + +-- name: GetUploadPartUsage :one +SELECT + COALESCE(SUM(size), 0)::bigint AS bytes, + COUNT(*)::int AS parts, + COUNT(DISTINCT file_id)::int AS files +FROM upload_parts +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint; + +-- name: GetUploadPartSlot :one +SELECT + COALESCE(( + SELECT size + FROM upload_parts + WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND file_id = sqlc.arg(file_id)::bigint + AND part = sqlc.arg(part)::int + ), -1)::bigint AS existing_bytes, + COALESCE(( + SELECT object_key + FROM upload_parts + WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND file_id = sqlc.arg(file_id)::bigint + AND part = sqlc.arg(part)::int + ), '')::text AS object_key, + ( + SELECT COUNT(*)::int + FROM upload_parts + WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND file_id = sqlc.arg(file_id)::bigint + )::int AS file_parts; -- name: ListUploadParts :many -SELECT part, total_parts, is_big, bytes +SELECT part, total_parts, is_big, backend, object_key, size, sha256 FROM upload_parts WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint AND file_id = sqlc.arg(file_id)::bigint ORDER BY part ASC; --- name: DeleteUploadParts :exec +-- name: DeleteUploadParts :many DELETE FROM upload_parts WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint - AND file_id = sqlc.arg(file_id)::bigint; + AND file_id = sqlc.arg(file_id)::bigint +RETURNING object_key; + +-- name: DeleteExpiredUploadParts :many +WITH doomed AS ( + SELECT owner_user_id, file_id, part + FROM upload_parts + WHERE created_at < sqlc.arg(before)::timestamptz + ORDER BY created_at ASC + LIMIT sqlc.arg(batch_limit)::int +) +DELETE FROM upload_parts p +USING doomed d +WHERE p.owner_user_id = d.owner_user_id + AND p.file_id = d.file_id + AND p.part = d.part +RETURNING p.object_key; -- file_blobs ------------------------------------------------------------------ diff --git a/internal/store/postgres/queries/message.sql b/internal/store/postgres/queries/message.sql index 6f4db751..3dc35480 100644 --- a/internal/store/postgres/queries/message.sql +++ b/internal/store/postgres/queries/message.sql @@ -81,6 +81,8 @@ INSERT INTO private_messages ( recipient_user_id, random_id, message_date, + ttl_period, + expires_at, body, entities, silent, @@ -89,6 +91,7 @@ INSERT INTO private_messages ( reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities, quote_offset, @@ -96,15 +99,21 @@ INSERT INTO private_messages ( fwd_from_peer_id, fwd_from_name, fwd_date, - media + media, + reply_markup, + rich_message, + via_bot_id, + grouped_id, + effect ) VALUES ( - $1, $2, $3, $4, $5, sqlc.arg(entities_json)::jsonb, + $1, $2, $3, $4, sqlc.arg(ttl_period)::int, sqlc.arg(expires_at)::int, $5, sqlc.arg(entities_json)::jsonb, sqlc.arg(silent)::boolean, sqlc.arg(noforwards)::boolean, sqlc.arg(reply_to_msg_id)::int, sqlc.arg(reply_to_peer_type)::text, sqlc.arg(reply_to_peer_id)::bigint, sqlc.arg(reply_to_top_id)::int, + sqlc.arg(reply_to_story_id)::int, sqlc.arg(quote_text)::text, sqlc.arg(quote_entities_json)::jsonb, sqlc.arg(quote_offset)::int, @@ -112,7 +121,12 @@ INSERT INTO private_messages ( sqlc.arg(fwd_from_peer_id)::bigint, sqlc.arg(fwd_from_name)::text, sqlc.arg(fwd_date)::int, - sqlc.arg(media_json)::jsonb + sqlc.arg(media_json)::jsonb, + sqlc.arg(reply_markup_json)::jsonb, + sqlc.arg(rich_message_json)::jsonb, + sqlc.arg(via_bot_id)::bigint, + sqlc.arg(grouped_id)::bigint, + sqlc.arg(effect)::bigint ) ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING RETURNING @@ -121,6 +135,8 @@ RETURNING recipient_user_id, random_id, message_date, + ttl_period, + expires_at, edit_date, body, entities::text AS entities_json; @@ -132,6 +148,8 @@ SELECT recipient_user_id, random_id, message_date, + ttl_period, + expires_at, edit_date, body, entities::text AS entities_json @@ -150,6 +168,8 @@ INSERT INTO message_boxes ( peer_id, from_user_id, message_date, + ttl_period, + expires_at, outgoing, body, entities, @@ -159,6 +179,7 @@ INSERT INTO message_boxes ( reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities, quote_offset, @@ -166,18 +187,32 @@ INSERT INTO message_boxes ( fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media, media_unread, - reaction_unread + reaction_unread, + reply_markup, + rich_message, + via_bot_id, + grouped_id, + effect ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, sqlc.arg(entities_json)::jsonb, + $1, $2, $3, $4, $5, $6, $7, $8, + sqlc.arg(ttl_period)::int, + sqlc.arg(expires_at)::int, + $9, $10, sqlc.arg(entities_json)::jsonb, sqlc.arg(silent)::boolean, sqlc.arg(noforwards)::boolean, sqlc.arg(reply_to_msg_id)::int, sqlc.arg(reply_to_peer_type)::text, sqlc.arg(reply_to_peer_id)::bigint, sqlc.arg(reply_to_top_id)::int, + sqlc.arg(reply_to_story_id)::int, sqlc.arg(quote_text)::text, sqlc.arg(quote_entities_json)::jsonb, sqlc.arg(quote_offset)::int, @@ -185,10 +220,20 @@ INSERT INTO message_boxes ( sqlc.arg(fwd_from_peer_id)::bigint, sqlc.arg(fwd_from_name)::text, sqlc.arg(fwd_date)::int, + sqlc.arg(fwd_saved_from_peer_type)::text, + sqlc.arg(fwd_saved_from_peer_id)::bigint, + sqlc.arg(fwd_saved_from_msg_id)::int, + sqlc.arg(saved_peer_type)::text, + sqlc.arg(saved_peer_id)::bigint, sqlc.arg(pts)::int, sqlc.arg(media_json)::jsonb, sqlc.arg(media_unread)::boolean, - sqlc.arg(reaction_unread)::boolean + sqlc.arg(reaction_unread)::boolean, + sqlc.arg(reply_markup_json)::jsonb, + sqlc.arg(rich_message_json)::jsonb, + sqlc.arg(via_bot_id)::bigint, + sqlc.arg(grouped_id)::bigint, + sqlc.arg(effect)::bigint ) RETURNING box_id, @@ -198,6 +243,8 @@ RETURNING peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -208,6 +255,7 @@ RETURNING reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities::text AS quote_entities_json, quote_offset, @@ -215,10 +263,21 @@ RETURNING fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media::text AS media_json, media_unread, - reaction_unread; + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json; -- name: GetMessageBoxByPrivateMessage :one SELECT @@ -229,6 +288,8 @@ SELECT peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -239,6 +300,7 @@ SELECT reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities::text AS quote_entities_json, quote_offset, @@ -246,10 +308,21 @@ SELECT fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media::text AS media_json, media_unread, - reaction_unread + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json FROM message_boxes WHERE owner_user_id = $1 AND private_message_id = $2 @@ -285,6 +358,8 @@ SELECT m.peer_id, m.from_user_id, m.message_date, + m.ttl_period, + m.expires_at, m.edit_date, m.outgoing, m.body, @@ -295,6 +370,7 @@ SELECT m.reply_to_peer_type, m.reply_to_peer_id, m.reply_to_top_id, + m.reply_to_story_id, m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, @@ -302,10 +378,18 @@ SELECT m.fwd_from_peer_id, m.fwd_from_name, m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, m.pts, m.media::text AS media_json, m.media_unread, - m.reaction_unread + m.reaction_unread, + m.pinned, + m.via_bot_id, + m.grouped_id FROM requested r JOIN message_boxes m ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint @@ -342,6 +426,8 @@ base AS NOT MATERIALIZED ( m.peer_id, m.from_user_id, m.message_date, + m.ttl_period, + m.expires_at, m.edit_date, m.outgoing, m.body, @@ -352,6 +438,7 @@ base AS NOT MATERIALIZED ( m.reply_to_peer_type, m.reply_to_peer_id, m.reply_to_top_id, + m.reply_to_story_id, m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, @@ -359,10 +446,21 @@ base AS NOT MATERIALIZED ( m.fwd_from_peer_id, m.fwd_from_name, m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, m.pts, m.media::text AS media_json, m.media_unread, m.reaction_unread, + m.pinned, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, COALESCE(peer_u.id, 0)::bigint AS peer_user_id, COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, COALESCE(peer_u.phone, '')::text AS peer_phone, @@ -372,6 +470,11 @@ base AS NOT MATERIALIZED ( COALESCE(peer_u.country_code, '')::text AS peer_country_code, COALESCE(peer_u.verified, false)::boolean AS peer_verified, COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(peer_u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM peer_u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(peer_u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(peer_u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at, COALESCE(from_u.id, 0)::bigint AS from_user_user_id, COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, @@ -382,6 +485,11 @@ base AS NOT MATERIALIZED ( COALESCE(from_u.country_code, '')::text AS from_user_country_code, COALESCE(from_u.verified, false)::boolean AS from_user_verified, COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.is_bot, false)::boolean AS from_user_is_bot, + COALESCE(from_u.bot_info_version, 0)::int AS from_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM from_u.premium_expires_at), 0)::bigint AS from_user_premium_until, + COALESCE(from_u.emoji_status_document_id, 0)::bigint AS from_user_emoji_status_document_id, + COALESCE(from_u.emoji_status_until, 0)::bigint AS from_user_emoji_status_until, COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at FROM message_boxes m LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id @@ -398,6 +506,23 @@ base AS NOT MATERIALIZED ( ) AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int) AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int) + AND (NOT sqlc.arg(pinned_only)::boolean OR m.pinned) + AND ( + NOT sqlc.arg(music_only)::boolean + OR ( + m.media->>'kind' = 'document' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(m.media #> '{document,attributes}', '[]'::jsonb)) AS attr + WHERE attr->>'kind' = 'audio' + AND COALESCE((attr->>'voice')::boolean, false) = false + ) + ) + ) + AND ( + sqlc.arg(saved_peer_type)::text = '' + OR (m.saved_peer_type = sqlc.arg(saved_peer_type)::text AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint) + ) ), total AS ( SELECT count(*)::int AS total_count @@ -476,6 +601,8 @@ SELECT peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -486,6 +613,7 @@ SELECT reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities_json, quote_offset, @@ -493,10 +621,21 @@ SELECT fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media_json, media_unread, reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup_json, + rich_message_json, peer_user_id, peer_access_hash, peer_phone, @@ -506,6 +645,11 @@ SELECT peer_country_code, peer_verified, peer_support, + peer_is_bot, + peer_bot_info_version, + peer_premium_until, + peer_emoji_status_document_id, + peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, @@ -516,12 +660,171 @@ SELECT from_user_country_code, from_user_verified, from_user_support, + from_user_is_bot, + from_user_bot_info_version, + from_user_premium_until, + from_user_emoji_status_document_id, + from_user_emoji_status_until, from_user_last_seen_at, COALESCE(total.total_count, 0)::int AS total_count FROM paged CROSS JOIN total ORDER BY box_id DESC; +-- name: ListMessagesBackward :many +-- backward 热路径(add_offset>=0:初始加载/上滑翻页)的扁平静态查询。 +-- 与 ListMessagesByUser 的 backward 分支逐位等价(相同 base 过滤 + 相同 anchor + +-- ORDER BY box_id DESC + OFFSET GREATEST(add_offset,0) + LIMIT),但只规划单次 +-- index scan + 2 LEFT JOIN,避免大 CTE 把 4 个分支+total 全树规划。total 走 +-- 独立 CountMessagesByUser,仅 NeedTotalCount 时发。 +SELECT + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.ttl_period, + m.expires_at, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.reply_to_story_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, + m.pts, + m.media::text AS media_json, + m.media_unread, + m.reaction_unread, + m.pinned, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(peer_u.phone, '')::text AS peer_phone, + COALESCE(peer_u.first_name, '')::text AS peer_first_name, + COALESCE(peer_u.last_name, '')::text AS peer_last_name, + COALESCE(peer_u.username, '')::text AS peer_username, + COALESCE(peer_u.country_code, '')::text AS peer_country_code, + COALESCE(peer_u.verified, false)::boolean AS peer_verified, + COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(peer_u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM peer_u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(peer_u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(peer_u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, + COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at, + COALESCE(from_u.id, 0)::bigint AS from_user_user_id, + COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, + COALESCE(from_u.phone, '')::text AS from_user_phone, + COALESCE(from_u.first_name, '')::text AS from_user_first_name, + COALESCE(from_u.last_name, '')::text AS from_user_last_name, + COALESCE(from_u.username, '')::text AS from_user_username, + COALESCE(from_u.country_code, '')::text AS from_user_country_code, + COALESCE(from_u.verified, false)::boolean AS from_user_verified, + COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.is_bot, false)::boolean AS from_user_is_bot, + COALESCE(from_u.bot_info_version, 0)::int AS from_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM from_u.premium_expires_at), 0)::bigint AS from_user_premium_until, + COALESCE(from_u.emoji_status_document_id, 0)::bigint AS from_user_emoji_status_document_id, + COALESCE(from_u.emoji_status_until, 0)::bigint AS from_user_emoji_status_until, + COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at +FROM message_boxes m +LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id +LEFT JOIN users from_u ON from_u.id = m.from_user_id +WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND NOT m.deleted + AND ( + NOT sqlc.arg(has_peer)::boolean + OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) + ) + AND ( + sqlc.arg(query)::text = '' + OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%') + ) + AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int) + AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int) + AND (NOT sqlc.arg(pinned_only)::boolean OR m.pinned) + AND ( + NOT sqlc.arg(music_only)::boolean + OR ( + m.media->>'kind' = 'document' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(m.media #> '{document,attributes}', '[]'::jsonb)) AS attr + WHERE attr->>'kind' = 'audio' + AND COALESCE((attr->>'voice')::boolean, false) = false + ) + ) + ) + AND ( + sqlc.arg(saved_peer_type)::text = '' + OR (m.saved_peer_type = sqlc.arg(saved_peer_type)::text AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint) + ) + AND ( + (sqlc.arg(offset_date)::int > 0 AND m.message_date < sqlc.arg(offset_date)::int) + OR (sqlc.arg(offset_date)::int <= 0 AND (sqlc.arg(offset_id)::int <= 0 OR m.box_id < sqlc.arg(offset_id)::int)) + ) +ORDER BY m.box_id DESC +OFFSET GREATEST(sqlc.arg(row_offset)::int, 0) +LIMIT sqlc.arg(limit_count)::int; + +-- name: CountMessagesByUser :one +-- ListMessagesByUser total CTE 的独立化:相同 base 过滤(不含分页 anchor), +-- 仅 NeedTotalCount 时发,避免热路径(getHistory 恒不需 total)次次规划 count 子树。 +SELECT count(*)::int AS total_count +FROM message_boxes m +WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND NOT m.deleted + AND ( + NOT sqlc.arg(has_peer)::boolean + OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint) + ) + AND ( + sqlc.arg(query)::text = '' + OR m.body ILIKE ('%' || sqlc.arg(query)::text || '%') + ) + AND (sqlc.arg(max_id)::int <= 0 OR m.box_id < sqlc.arg(max_id)::int) + AND (sqlc.arg(min_id)::int <= 0 OR m.box_id > sqlc.arg(min_id)::int) + AND (NOT sqlc.arg(pinned_only)::boolean OR m.pinned) + AND ( + NOT sqlc.arg(music_only)::boolean + OR ( + m.media->>'kind' = 'document' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(m.media #> '{document,attributes}', '[]'::jsonb)) AS attr + WHERE attr->>'kind' = 'audio' + AND COALESCE((attr->>'voice')::boolean, false) = false + ) + ) + ) + AND ( + sqlc.arg(saved_peer_type)::text = '' + OR (m.saved_peer_type = sqlc.arg(saved_peer_type)::text AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint) + ); + -- name: GetMessageBoxesByIDs :many SELECT wanted.box_id AS requested_box_id, @@ -532,6 +835,8 @@ SELECT m.peer_id, m.from_user_id, m.message_date, + m.ttl_period, + m.expires_at, m.edit_date, m.outgoing, m.body, @@ -542,6 +847,7 @@ SELECT m.reply_to_peer_type, m.reply_to_peer_id, m.reply_to_top_id, + m.reply_to_story_id, m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, @@ -549,10 +855,21 @@ SELECT m.fwd_from_peer_id, m.fwd_from_name, m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, m.pts, m.media::text AS media_json, m.media_unread, m.reaction_unread, + m.pinned, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, COALESCE(peer_u.id, 0)::bigint AS peer_user_id, COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, COALESCE(peer_u.phone, '')::text AS peer_phone, @@ -562,6 +879,11 @@ SELECT COALESCE(peer_u.country_code, '')::text AS peer_country_code, COALESCE(peer_u.verified, false)::boolean AS peer_verified, COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(peer_u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM peer_u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(peer_u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(peer_u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at, COALESCE(from_u.id, 0)::bigint AS from_user_user_id, COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, @@ -572,6 +894,11 @@ SELECT COALESCE(from_u.country_code, '')::text AS from_user_country_code, COALESCE(from_u.verified, false)::boolean AS from_user_verified, COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.is_bot, false)::boolean AS from_user_is_bot, + COALESCE(from_u.bot_info_version, 0)::int AS from_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM from_u.premium_expires_at), 0)::bigint AS from_user_premium_until, + COALESCE(from_u.emoji_status_document_id, 0)::bigint AS from_user_emoji_status_document_id, + COALESCE(from_u.emoji_status_until, 0)::bigint AS from_user_emoji_status_until, COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at FROM unnest(@box_ids::int[]) WITH ORDINALITY AS wanted(box_id, ord) JOIN message_boxes m @@ -592,6 +919,8 @@ SELECT peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -602,6 +931,7 @@ SELECT reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities::text AS quote_entities_json, quote_offset, @@ -609,10 +939,21 @@ SELECT fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media::text AS media_json, media_unread, - reaction_unread + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json FROM message_boxes WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint AND box_id = sqlc.arg(box_id)::int @@ -632,6 +973,8 @@ SELECT peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -642,6 +985,7 @@ SELECT reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities::text AS quote_entities_json, quote_offset, @@ -649,12 +993,24 @@ SELECT fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media::text AS media_json, media_unread, - reaction_unread + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json FROM message_boxes -WHERE message_sender_id = sqlc.arg(message_sender_id)::bigint +WHERE owner_user_id = ANY(sqlc.arg(owner_user_ids)::bigint[]) + AND message_sender_id = sqlc.arg(message_sender_id)::bigint AND private_message_id = sqlc.arg(private_message_id)::bigint AND NOT deleted ORDER BY owner_user_id ASC, box_id ASC @@ -664,7 +1020,11 @@ FOR UPDATE; UPDATE private_messages SET body = sqlc.arg(body)::text, entities = sqlc.arg(entities_json)::jsonb, - edit_date = sqlc.arg(edit_date)::int + edit_date = sqlc.arg(edit_date)::int, + reply_markup = CASE + WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb + ELSE reply_markup + END WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint AND id = sqlc.arg(private_message_id)::bigint; @@ -673,7 +1033,11 @@ UPDATE message_boxes SET body = sqlc.arg(body)::text, entities = sqlc.arg(entities_json)::jsonb, edit_date = sqlc.arg(edit_date)::int, - pts = sqlc.arg(pts)::int + pts = sqlc.arg(pts)::int, + reply_markup = CASE + WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb + ELSE reply_markup + END WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint AND box_id = sqlc.arg(box_id)::int AND NOT deleted @@ -686,6 +1050,8 @@ RETURNING peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -696,6 +1062,7 @@ RETURNING reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities::text AS quote_entities_json, quote_offset, @@ -703,10 +1070,21 @@ RETURNING fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media::text AS media_json, media_unread, - reaction_unread; + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json; -- name: GetDialogReadStateForUpdate :one SELECT @@ -761,7 +1139,6 @@ SET ), unread_mark = false, unread_mentions_count = 0, - unread_reactions_count = 0, updated_at = now() WHERE d.user_id = sqlc.arg(user_id)::bigint AND d.peer_type = sqlc.arg(peer_type)::text @@ -863,6 +1240,8 @@ WITH target AS ( AND m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int) + AND (sqlc.arg(min_date)::int <= 0 OR m.message_date >= sqlc.arg(min_date)::int) + AND (sqlc.arg(max_date)::int <= 0 OR m.message_date <= sqlc.arg(max_date)::int) AND NOT m.deleted ORDER BY m.box_id DESC LIMIT sqlc.arg(limit_count)::int @@ -900,6 +1279,8 @@ SELECT EXISTS ( AND m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int) + AND (sqlc.arg(min_date)::int <= 0 OR m.message_date >= sqlc.arg(min_date)::int) + AND (sqlc.arg(max_date)::int <= 0 OR m.message_date <= sqlc.arg(max_date)::int) AND NOT m.deleted LIMIT 1 )::boolean AS more; @@ -908,19 +1289,24 @@ SELECT EXISTS ( WITH requested AS ( SELECT (sqlc.arg(message_sender_ids)::bigint[])[i] AS message_sender_id, - (sqlc.arg(private_message_ids)::bigint[])[i] AS private_message_id + (sqlc.arg(private_message_ids)::bigint[])[i] AS private_message_id, + (sqlc.arg(owner_user_ids)::bigint[])[i] AS owner_user_id FROM generate_subscripts(sqlc.arg(private_message_ids)::bigint[], 1) AS g(i) WHERE i <= cardinality(sqlc.arg(message_sender_ids)::bigint[]) + AND i <= cardinality(sqlc.arg(owner_user_ids)::bigint[]) ), deduped AS ( - SELECT DISTINCT message_sender_id, private_message_id + SELECT DISTINCT message_sender_id, private_message_id, owner_user_id FROM requested + WHERE owner_user_id <> 0 ), updated AS ( UPDATE message_boxes m SET deleted = true FROM deduped d - WHERE m.message_sender_id = d.message_sender_id + WHERE m.owner_user_id = ANY(sqlc.arg(owner_user_ids)::bigint[]) + AND m.owner_user_id = d.owner_user_id + AND m.message_sender_id = d.message_sender_id AND m.private_message_id = d.private_message_id AND NOT m.deleted RETURNING @@ -952,3 +1338,132 @@ WHERE owner_user_id = $1 AND NOT deleted ORDER BY box_id DESC LIMIT 1; + +-- name: ListUnreadReactionMessageBoxes :many +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + ttl_period, + expires_at, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + reply_to_story_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, + pts, + media::text AS media_json, + media_unread, + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json +FROM message_boxes +WHERE owner_user_id = @owner_user_id + AND peer_type = @peer_type + AND peer_id = @peer_id + AND NOT deleted + AND reaction_unread +ORDER BY box_id DESC +LIMIT @page_limit; + +-- name: GetMessageBoxForPin :one +SELECT + box_id, + private_message_id, + message_sender_id, + pinned, + media::text AS media_json +FROM message_boxes +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND peer_type = sqlc.arg(peer_type)::text + AND peer_id = sqlc.arg(peer_id)::bigint + AND box_id = sqlc.arg(box_id)::int + AND NOT deleted +LIMIT 1 +FOR UPDATE; + +-- name: SetMessageBoxPinned :execrows +UPDATE message_boxes +SET pinned = sqlc.arg(pinned)::boolean +WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint + AND box_id = sqlc.arg(box_id)::int + AND NOT deleted + AND pinned <> sqlc.arg(pinned)::boolean; + +-- name: UnpinAllMessageBoxesByPeer :many +-- 按批清除(affectedHistory.offset 续清语义),单条 updatePinnedMessages +-- 的 messages 向量随之有界。 +WITH target AS ( + SELECT m.owner_user_id, m.box_id + FROM message_boxes m + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = sqlc.arg(peer_type)::text + AND m.peer_id = sqlc.arg(peer_id)::bigint + AND m.pinned + AND NOT m.deleted + ORDER BY m.box_id DESC + LIMIT sqlc.arg(limit_count)::int + FOR UPDATE +) +UPDATE message_boxes m +SET pinned = false +FROM target t +WHERE m.owner_user_id = t.owner_user_id + AND m.box_id = t.box_id +RETURNING m.box_id, m.private_message_id, m.message_sender_id; + +-- name: HasPinnedMessageBoxByPeer :one +SELECT EXISTS ( + SELECT 1 + FROM message_boxes m + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = sqlc.arg(peer_type)::text + AND m.peer_id = sqlc.arg(peer_id)::bigint + AND m.pinned + AND NOT m.deleted + LIMIT 1 +)::boolean AS more; + +-- name: UnpinMessageBoxesByPrivateMessages :many +WITH requested AS ( + SELECT + (sqlc.arg(message_sender_ids)::bigint[])[i] AS message_sender_id, + (sqlc.arg(private_message_ids)::bigint[])[i] AS private_message_id + FROM generate_subscripts(sqlc.arg(private_message_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(message_sender_ids)::bigint[]) +) +UPDATE message_boxes m +SET pinned = false +FROM requested r +WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.message_sender_id = r.message_sender_id + AND m.private_message_id = r.private_message_id + AND m.pinned + AND NOT m.deleted +RETURNING m.box_id; diff --git a/internal/store/postgres/queries/saved_dialog.sql b/internal/store/postgres/queries/saved_dialog.sql new file mode 100644 index 00000000..3d0d0657 --- /dev/null +++ b/internal/store/postgres/queries/saved_dialog.sql @@ -0,0 +1,337 @@ +-- Saved Messages 分会话(saved dialogs)查询组。 +-- 子会话 = self-chat box 行按 saved_peer 分组;top message 不物化,由 +-- MAX(box_id) 聚合现算(box_id 单调于时间,date 序与 id 序一致), +-- 删除/清史后的一致性由查询本身保证。 + +-- name: ListSavedDialogTops :many +WITH tops AS ( + SELECT m.saved_peer_type, m.saved_peer_id, MAX(m.box_id) AS top_box_id + FROM message_boxes m + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = 'user' + AND m.peer_id = sqlc.arg(owner_user_id)::bigint + AND NOT m.deleted + AND m.saved_peer_type <> '' + GROUP BY m.saved_peer_type, m.saved_peer_id +) +SELECT + t.saved_peer_type AS dialog_peer_type, + t.saved_peer_id AS dialog_peer_id, + (p.user_id IS NOT NULL)::boolean AS dialog_pinned, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.ttl_period, + m.expires_at, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.reply_to_story_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, + m.pts, + m.media::text AS media_json, + m.media_unread, + m.reaction_unread, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, + m.pinned +FROM tops t +JOIN message_boxes m + ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.box_id = t.top_box_id +LEFT JOIN saved_dialog_pins p + ON p.user_id = sqlc.arg(owner_user_id)::bigint + AND p.peer_type = t.saved_peer_type + AND p.peer_id = t.saved_peer_id +WHERE (sqlc.arg(offset_id)::int <= 0 OR t.top_box_id < sqlc.arg(offset_id)::int) + AND (NOT sqlc.arg(exclude_pinned)::boolean OR p.user_id IS NULL) +ORDER BY t.top_box_id DESC +LIMIT sqlc.arg(limit_count)::int; + +-- name: ListPinnedSavedDialogTops :many +WITH tops AS ( + SELECT m.saved_peer_type, m.saved_peer_id, MAX(m.box_id) AS top_box_id + FROM message_boxes m + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = 'user' + AND m.peer_id = sqlc.arg(owner_user_id)::bigint + AND NOT m.deleted + AND m.saved_peer_type <> '' + GROUP BY m.saved_peer_type, m.saved_peer_id +) +SELECT + t.saved_peer_type AS dialog_peer_type, + t.saved_peer_id AS dialog_peer_id, + true::boolean AS dialog_pinned, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.ttl_period, + m.expires_at, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.reply_to_story_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, + m.pts, + m.media::text AS media_json, + m.media_unread, + m.reaction_unread, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, + m.pinned +FROM saved_dialog_pins p +JOIN tops t + ON t.saved_peer_type = p.peer_type + AND t.saved_peer_id = p.peer_id +JOIN message_boxes m + ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.box_id = t.top_box_id +WHERE p.user_id = sqlc.arg(owner_user_id)::bigint +ORDER BY p.pinned_order ASC, p.peer_id ASC; + +-- name: ListSavedDialogTopsByPeers :many +WITH requested AS ( + SELECT + (sqlc.arg(peer_types)::text[])[i] AS peer_type, + (sqlc.arg(peer_ids)::bigint[])[i] AS peer_id + FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) +), +tops AS ( + SELECT m.saved_peer_type, m.saved_peer_id, MAX(m.box_id) AS top_box_id + FROM message_boxes m + JOIN requested r + ON r.peer_type = m.saved_peer_type + AND r.peer_id = m.saved_peer_id + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = 'user' + AND m.peer_id = sqlc.arg(owner_user_id)::bigint + AND NOT m.deleted + GROUP BY m.saved_peer_type, m.saved_peer_id +) +SELECT + t.saved_peer_type AS dialog_peer_type, + t.saved_peer_id AS dialog_peer_id, + (p.user_id IS NOT NULL)::boolean AS dialog_pinned, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.ttl_period, + m.expires_at, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.reply_to_story_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, + m.pts, + m.media::text AS media_json, + m.media_unread, + m.reaction_unread, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, + m.pinned +FROM tops t +JOIN message_boxes m + ON m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.box_id = t.top_box_id +LEFT JOIN saved_dialog_pins p + ON p.user_id = sqlc.arg(owner_user_id)::bigint + AND p.peer_type = t.saved_peer_type + AND p.peer_id = t.saved_peer_id +ORDER BY t.top_box_id DESC; + +-- name: CountSavedDialogs :one +SELECT count(*)::int AS dialog_count +FROM ( + SELECT m.saved_peer_type, m.saved_peer_id + FROM message_boxes m + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = 'user' + AND m.peer_id = sqlc.arg(owner_user_id)::bigint + AND NOT m.deleted + AND m.saved_peer_type <> '' + GROUP BY m.saved_peer_type, m.saved_peer_id +) d +LEFT JOIN saved_dialog_pins p + ON p.user_id = sqlc.arg(owner_user_id)::bigint + AND p.peer_type = d.saved_peer_type + AND p.peer_id = d.saved_peer_id +WHERE NOT sqlc.arg(exclude_pinned)::boolean OR p.user_id IS NULL; + +-- name: CountSavedDialogPins :one +SELECT count(*)::int AS pin_count +FROM saved_dialog_pins +WHERE user_id = $1; + +-- name: UpsertSavedDialogPinFront :execrows +INSERT INTO saved_dialog_pins (user_id, peer_type, peer_id, pinned_order) +VALUES ( + sqlc.arg(user_id)::bigint, + sqlc.arg(peer_type)::text, + sqlc.arg(peer_id)::bigint, + COALESCE((SELECT MIN(pinned_order) - 1 FROM saved_dialog_pins WHERE user_id = sqlc.arg(user_id)::bigint), 0) +) +ON CONFLICT (user_id, peer_type, peer_id) DO NOTHING; + +-- name: DeleteSavedDialogPin :execrows +DELETE FROM saved_dialog_pins +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3; + +-- name: ClearSavedDialogPinsNotInOrder :exec +DELETE FROM saved_dialog_pins p +WHERE p.user_id = sqlc.arg(user_id)::bigint + AND NOT EXISTS ( + SELECT 1 + FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i) + WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) + AND (sqlc.arg(peer_types)::text[])[i] = p.peer_type + AND (sqlc.arg(peer_ids)::bigint[])[i] = p.peer_id + ); + +-- name: ReorderSavedDialogPins :exec +INSERT INTO saved_dialog_pins (user_id, peer_type, peer_id, pinned_order) +SELECT + sqlc.arg(user_id)::bigint, + (sqlc.arg(peer_types)::text[])[i], + (sqlc.arg(peer_ids)::bigint[])[i], + (i - 1)::int +FROM generate_subscripts(sqlc.arg(peer_ids)::bigint[], 1) AS g(i) +WHERE i <= cardinality(sqlc.arg(peer_types)::text[]) +ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET pinned_order = EXCLUDED.pinned_order; + +-- name: DeleteMessageBoxesBySavedPeerBatch :many +WITH target AS ( + SELECT + m.owner_user_id, + m.box_id + FROM message_boxes m + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = 'user' + AND m.peer_id = sqlc.arg(owner_user_id)::bigint + AND m.saved_peer_type = sqlc.arg(saved_peer_type)::text + AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint + AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int) + AND (sqlc.arg(min_date)::int <= 0 OR m.message_date >= sqlc.arg(min_date)::int) + AND (sqlc.arg(max_date)::int <= 0 OR m.message_date <= sqlc.arg(max_date)::int) + AND NOT m.deleted + ORDER BY m.box_id DESC + LIMIT sqlc.arg(limit_count)::int + FOR UPDATE SKIP LOCKED +), +updated AS ( + UPDATE message_boxes m + SET deleted = true + FROM target t + WHERE m.owner_user_id = t.owner_user_id + AND m.box_id = t.box_id + RETURNING + m.owner_user_id, + m.box_id, + m.private_message_id, + m.message_sender_id, + m.peer_type, + m.peer_id +) +SELECT + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id +FROM updated +ORDER BY box_id ASC; + +-- name: HasDeletableMessageBoxBySavedPeer :one +SELECT EXISTS ( + SELECT 1 + FROM message_boxes m + WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint + AND m.peer_type = 'user' + AND m.peer_id = sqlc.arg(owner_user_id)::bigint + AND m.saved_peer_type = sqlc.arg(saved_peer_type)::text + AND m.saved_peer_id = sqlc.arg(saved_peer_id)::bigint + AND (sqlc.arg(max_id)::int <= 0 OR m.box_id <= sqlc.arg(max_id)::int) + AND (sqlc.arg(min_date)::int <= 0 OR m.message_date >= sqlc.arg(min_date)::int) + AND (sqlc.arg(max_date)::int <= 0 OR m.message_date <= sqlc.arg(max_date)::int) + AND NOT m.deleted + LIMIT 1 +)::boolean AS more; diff --git a/internal/store/postgres/queries/temp_auth_key.sql b/internal/store/postgres/queries/temp_auth_key.sql index c4e17928..8820c19a 100644 --- a/internal/store/postgres/queries/temp_auth_key.sql +++ b/internal/store/postgres/queries/temp_auth_key.sql @@ -21,3 +21,12 @@ SELECT encrypted_message FROM temp_auth_key_bindings WHERE temp_auth_key_id = $1; + +-- name: DeleteExpiredTempAuthKeys :execrows +DELETE FROM auth_keys +WHERE auth_key_id IN ( + SELECT temp_auth_key_id + FROM temp_auth_key_bindings + WHERE expires_at < $1 + LIMIT $2 +); diff --git a/internal/store/postgres/queries/update_state.sql b/internal/store/postgres/queries/update_state.sql index 3ed6b22e..480d9e05 100644 --- a/internal/store/postgres/queries/update_state.sql +++ b/internal/store/postgres/queries/update_state.sql @@ -8,10 +8,10 @@ WHERE auth_key_id = $1 INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (auth_key_id, user_id) DO UPDATE SET - pts = EXCLUDED.pts, - qts = EXCLUDED.qts, - date = EXCLUDED.date, - seq = EXCLUDED.seq, + pts = GREATEST(update_states.pts, EXCLUDED.pts), + qts = GREATEST(update_states.qts, EXCLUDED.qts), + date = GREATEST(update_states.date, EXCLUDED.date), + seq = GREATEST(update_states.seq, EXCLUDED.seq), updated_at = now(); -- name: DeleteUpdateState :exec diff --git a/internal/store/postgres/queries/user.sql b/internal/store/postgres/queries/user.sql index c7f68c41..ebc823bb 100644 --- a/internal/store/postgres/queries/user.sql +++ b/internal/store/postgres/queries/user.sql @@ -32,6 +32,17 @@ WITH matched AS ( u.country_code, u.verified, u.support, + u.is_bot, + u.bot_info_version, + u.premium_expires_at, + u.emoji_status_document_id, + u.emoji_status_until, + u.color_set, + u.color, + u.color_background_emoji_id, + u.profile_color_set, + u.profile_color, + u.profile_color_background_emoji_id, u.last_seen_at, (c.contact_user_id IS NOT NULL)::boolean AS contact, COALESCE(c.mutual, false)::boolean AS mutual, @@ -69,6 +80,17 @@ SELECT country_code, verified, support, + is_bot, + bot_info_version, + premium_expires_at, + emoji_status_document_id, + emoji_status_until, + color_set, + color, + color_background_emoji_id, + profile_color_set, + profile_color, + profile_color_background_emoji_id, last_seen_at, contact, mutual @@ -77,8 +99,8 @@ ORDER BY contact DESC, rank, id LIMIT sqlc.arg(limit_count); -- name: CreateUser :one -INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code) -VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) +VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *; -- name: UpdateUserUsername :one @@ -102,3 +124,72 @@ SET first_name = $2, updated_at = now() WHERE id = $1 RETURNING *; + +-- name: SetUserPremiumUntil :one +UPDATE users +SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz, + updated_at = now() +WHERE id = sqlc.arg(id)::bigint +RETURNING *; + +-- name: SetUserVerified :one +UPDATE users +SET verified = sqlc.arg(verified)::boolean, + updated_at = now() +WHERE id = sqlc.arg(id)::bigint +RETURNING *; + +-- name: SweepExpiredPremium :many +UPDATE users +SET premium_expires_at = NULL, + updated_at = now() +WHERE id IN ( + SELECT id FROM users + WHERE premium_expires_at IS NOT NULL + AND premium_expires_at <= sqlc.arg(now)::timestamptz + ORDER BY premium_expires_at + LIMIT sqlc.arg(limit_count)::int +) +RETURNING *; + +-- name: UpdateUserEmojiStatus :one +UPDATE users +SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint, + emoji_status_until = sqlc.arg(emoji_status_until)::bigint, + updated_at = now() +WHERE id = sqlc.arg(id)::bigint +RETURNING *; + +-- name: UpdateUserBirthday :one +UPDATE users +SET birthday_day = sqlc.arg(birthday_day)::int, + birthday_month = sqlc.arg(birthday_month)::int, + birthday_year = sqlc.arg(birthday_year)::int, + updated_at = now() +WHERE id = sqlc.arg(id)::bigint +RETURNING *; + +-- name: UpdateUserPersonalChannel :one +UPDATE users +SET personal_channel_id = sqlc.arg(personal_channel_id)::bigint, + updated_at = now() +WHERE id = sqlc.arg(id)::bigint +RETURNING *; + +-- name: UpdateUserColor :one +UPDATE users +SET color_set = sqlc.arg(color_set)::boolean, + color = sqlc.arg(color)::int, + color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint, + updated_at = now() +WHERE id = sqlc.arg(id)::bigint +RETURNING *; + +-- name: UpdateUserProfileColor :one +UPDATE users +SET profile_color_set = sqlc.arg(color_set)::boolean, + profile_color = sqlc.arg(color)::int, + profile_color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint, + updated_at = now() +WHERE id = sqlc.arg(id)::bigint +RETURNING *; diff --git a/internal/store/postgres/queries/user_update_event.sql b/internal/store/postgres/queries/user_update_event.sql index bbc6c2ac..9898a80f 100644 --- a/internal/store/postgres/queries/user_update_event.sql +++ b/internal/store/postgres/queries/user_update_event.sql @@ -12,13 +12,17 @@ INSERT INTO user_update_events ( dialog_filter, filter_order, folder_peers, + story_payload, + reaction_payload, message_box_id, peer_type, peer_id, filter_id, max_id, still_unread_count, - tags_enabled + channel_pts, + tags_enabled, + folder_id ) VALUES ( $1, $2, @@ -32,15 +36,18 @@ INSERT INTO user_update_events ( sqlc.arg(dialog_filter)::jsonb, sqlc.arg(filter_order)::jsonb, sqlc.arg(folder_peers)::jsonb, + sqlc.arg(story_payload)::jsonb, + sqlc.arg(reaction_payload)::jsonb, sqlc.narg(message_box_id), sqlc.narg(peer_type)::text, sqlc.narg(peer_id)::bigint, sqlc.arg(filter_id)::int, sqlc.arg(max_id)::int, sqlc.arg(still_unread_count)::int, - sqlc.arg(tags_enabled)::boolean -) -ON CONFLICT (user_id, pts) DO NOTHING; + sqlc.arg(channel_pts)::int, + sqlc.arg(tags_enabled)::boolean, + sqlc.arg(folder_id)::int +); -- name: ListUserUpdateEventsAfter :many SELECT @@ -56,12 +63,16 @@ SELECT COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json, COALESCE(e.filter_order::text, '[]')::text AS filter_order_json, COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, + COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, + COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, e.max_id, e.still_unread_count, + e.channel_pts, e.tags_enabled, + e.folder_id, COALESCE(m.box_id, 0)::int AS message_id, COALESCE(m.private_message_id, 0)::bigint AS private_message_id, COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id, @@ -69,6 +80,8 @@ SELECT COALESCE(m.peer_id, 0)::bigint AS peer_id, COALESCE(m.from_user_id, 0)::bigint AS from_user_id, COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.ttl_period, 0)::int AS ttl_period, + COALESCE(m.expires_at, 0)::int AS expires_at, COALESCE(m.edit_date, 0)::int AS edit_date, COALESCE(m.outgoing, false)::boolean AS outgoing, COALESCE(m.body, '')::text AS body, @@ -79,6 +92,7 @@ SELECT COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type, COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id, COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id, + COALESCE(m.reply_to_story_id, 0)::int AS reply_to_story_id, COALESCE(m.quote_text, '')::text AS quote_text, COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, COALESCE(m.quote_offset, 0)::int AS quote_offset, @@ -86,9 +100,20 @@ SELECT COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.fwd_saved_from_peer_type, '')::text AS fwd_saved_from_peer_type, + COALESCE(m.fwd_saved_from_peer_id, 0)::bigint AS fwd_saved_from_peer_id, + COALESCE(m.fwd_saved_from_msg_id, 0)::int AS fwd_saved_from_msg_id, + COALESCE(m.saved_peer_type, '')::text AS saved_peer_type, + COALESCE(m.saved_peer_id, 0)::bigint AS saved_peer_id, COALESCE(m.media::text, '{}')::text AS media_json, COALESCE(m.media_unread, false)::boolean AS media_unread, COALESCE(m.reaction_unread, false)::boolean AS reaction_unread, + COALESCE(m.pinned, false)::boolean AS pinned, + COALESCE(m.via_bot_id, 0)::bigint AS via_bot_id, + COALESCE(m.grouped_id, 0)::bigint AS grouped_id, + COALESCE(m.effect, 0)::bigint AS effect, + COALESCE(m.reply_markup::text, '{}')::text AS reply_markup_json, + COALESCE(m.rich_message::text, '{}')::text AS rich_message_json, COALESCE(peer_u.id, 0)::bigint AS peer_user_id, COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, COALESCE(peer_u.phone, '')::text AS peer_phone, @@ -98,6 +123,11 @@ SELECT COALESCE(peer_u.country_code, '')::text AS peer_country_code, COALESCE(peer_u.verified, false)::boolean AS peer_verified, COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(peer_u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM peer_u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(peer_u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(peer_u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(from_u.id, 0)::bigint AS from_user_user_id, COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, COALESCE(from_u.phone, '')::text AS from_user_phone, @@ -107,6 +137,11 @@ SELECT COALESCE(from_u.country_code, '')::text AS from_user_country_code, COALESCE(from_u.verified, false)::boolean AS from_user_verified, COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.is_bot, false)::boolean AS from_user_is_bot, + COALESCE(from_u.bot_info_version, 0)::int AS from_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM from_u.premium_expires_at), 0)::bigint AS from_user_premium_until, + COALESCE(from_u.emoji_status_document_id, 0)::bigint AS from_user_emoji_status_document_id, + COALESCE(from_u.emoji_status_until, 0)::bigint AS from_user_emoji_status_until, COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id, COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash, COALESCE(fwd_u.phone, '')::text AS fwd_user_phone, @@ -116,6 +151,11 @@ SELECT COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code, COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified, COALESCE(fwd_u.support, false)::boolean AS fwd_user_support, + COALESCE(fwd_u.is_bot, false)::boolean AS fwd_user_is_bot, + COALESCE(fwd_u.bot_info_version, 0)::int AS fwd_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM fwd_u.premium_expires_at), 0)::bigint AS fwd_user_premium_until, + COALESCE(fwd_u.emoji_status_document_id, 0)::bigint AS fwd_user_emoji_status_document_id, + COALESCE(fwd_u.emoji_status_until, 0)::bigint AS fwd_user_emoji_status_until, COALESCE(reply_u.id, 0)::bigint AS reply_user_id, COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash, COALESCE(reply_u.phone, '')::text AS reply_user_phone, @@ -125,62 +165,17 @@ SELECT COALESCE(reply_u.country_code, '')::text AS reply_user_country_code, COALESCE(reply_u.verified, false)::boolean AS reply_user_verified, COALESCE(reply_u.support, false)::boolean AS reply_user_support, - COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id, - COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash, - COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id, - COALESCE(fwd_ch.title, '')::text AS fwd_channel_title, - COALESCE(fwd_ch.about, '')::text AS fwd_channel_about, - COALESCE(fwd_ch.username, '')::text AS fwd_channel_username, - COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast, - COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup, - COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum, - COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards, - COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures, - COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden, - COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds, - COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights, - COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count, - COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count, - COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count, - COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count, - COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id, - COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id, - COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts, - COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period, - COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date, - COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted, - COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id, - COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash, - COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id, - COALESCE(reply_ch.title, '')::text AS reply_channel_title, - COALESCE(reply_ch.about, '')::text AS reply_channel_about, - COALESCE(reply_ch.username, '')::text AS reply_channel_username, - COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast, - COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup, - COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum, - COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards, - COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures, - COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden, - COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds, - COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights, - COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count, - COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count, - COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count, - COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count, - COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id, - COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id, - COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts, - COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period, - COALESCE(reply_ch.date, 0)::int AS reply_channel_date, - COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted + COALESCE(reply_u.is_bot, false)::boolean AS reply_user_is_bot, + COALESCE(reply_u.bot_info_version, 0)::int AS reply_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM reply_u.premium_expires_at), 0)::bigint AS reply_user_premium_until, + COALESCE(reply_u.emoji_status_document_id, 0)::bigint AS reply_user_emoji_status_document_id, + COALESCE(reply_u.emoji_status_until, 0)::bigint AS reply_user_emoji_status_until FROM user_update_events e LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id LEFT JOIN users from_u ON from_u.id = m.from_user_id LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id -LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id -LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id WHERE e.user_id = $1 AND e.pts > $2 ORDER BY e.pts ASC @@ -191,46 +186,11 @@ SELECT COALESCE(MAX(pts), 0)::int AS max_pts FROM user_update_events WHERE user_id = $1; --- name: RecentUserPts :many --- 取某 user 最近的一段 pts(降序),供计算「最大连续已提交 pts」用。 --- 只看顶部窗口:瞬时空洞只可能出现在最近在途事务区,窗口足够大即可覆盖其下方连续。 -SELECT pts, pts_count -FROM user_update_events -WHERE user_id = $1 -ORDER BY pts DESC -LIMIT sqlc.arg(window_size); - --- name: EnsureUserUpdateWatermark :exec -INSERT INTO user_update_watermarks (user_id, contiguous_pts) -VALUES ($1, 0) -ON CONFLICT (user_id) DO NOTHING; - -- name: GetUserUpdateWatermark :one SELECT contiguous_pts FROM user_update_watermarks WHERE user_id = $1; --- name: LockUserUpdateWatermark :one -SELECT contiguous_pts -FROM user_update_watermarks -WHERE user_id = $1 -FOR UPDATE; - --- name: NextUserPtsAfter :many -SELECT pts, pts_count -FROM user_update_events -WHERE user_id = $1 - AND pts > $2 -ORDER BY pts ASC -LIMIT sqlc.arg(limit_count); - --- name: SaveUserUpdateWatermark :exec -INSERT INTO user_update_watermarks (user_id, contiguous_pts, updated_at) -VALUES ($1, $2, now()) -ON CONFLICT (user_id) DO UPDATE SET - contiguous_pts = GREATEST(user_update_watermarks.contiguous_pts, EXCLUDED.contiguous_pts), - updated_at = now(); - -- name: EnqueueDispatch :exec INSERT INTO dispatch_outbox ( target_user_id, @@ -245,17 +205,17 @@ ON CONFLICT DO NOTHING; -- name: ClaimDispatchOutbox :many WITH picked AS ( - SELECT target_user_id, id - FROM dispatch_outbox + SELECT d.target_user_id, d.id + FROM dispatch_outbox d WHERE ( - status = 'pending' - AND next_attempt_at <= now() + d.status = 'pending' + AND d.next_attempt_at <= now() ) OR ( - status = 'dispatching' - AND updated_at < now() - make_interval(secs => sqlc.arg(lease_seconds)::int) + d.status = 'dispatching' + AND d.updated_at < now() - make_interval(secs => sqlc.arg(lease_seconds)::int) ) - ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC + ORDER BY d.next_attempt_at ASC, d.target_user_id ASC, d.pts ASC, d.id ASC LIMIT sqlc.arg(limit_count) FOR UPDATE SKIP LOCKED ) @@ -312,12 +272,16 @@ SELECT COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json, COALESCE(e.filter_order::text, '[]')::text AS filter_order_json, COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, + COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, + COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, e.max_id, e.still_unread_count, + e.channel_pts, e.tags_enabled, + e.folder_id, COALESCE(m.box_id, 0)::int AS message_id, COALESCE(m.private_message_id, 0)::bigint AS private_message_id, COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id, @@ -325,6 +289,8 @@ SELECT COALESCE(m.peer_id, 0)::bigint AS peer_id, COALESCE(m.from_user_id, 0)::bigint AS from_user_id, COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.ttl_period, 0)::int AS ttl_period, + COALESCE(m.expires_at, 0)::int AS expires_at, COALESCE(m.edit_date, 0)::int AS edit_date, COALESCE(m.outgoing, false)::boolean AS outgoing, COALESCE(m.body, '')::text AS body, @@ -335,6 +301,7 @@ SELECT COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type, COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id, COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id, + COALESCE(m.reply_to_story_id, 0)::int AS reply_to_story_id, COALESCE(m.quote_text, '')::text AS quote_text, COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, COALESCE(m.quote_offset, 0)::int AS quote_offset, @@ -342,9 +309,20 @@ SELECT COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.fwd_saved_from_peer_type, '')::text AS fwd_saved_from_peer_type, + COALESCE(m.fwd_saved_from_peer_id, 0)::bigint AS fwd_saved_from_peer_id, + COALESCE(m.fwd_saved_from_msg_id, 0)::int AS fwd_saved_from_msg_id, + COALESCE(m.saved_peer_type, '')::text AS saved_peer_type, + COALESCE(m.saved_peer_id, 0)::bigint AS saved_peer_id, COALESCE(m.media::text, '{}')::text AS media_json, COALESCE(m.media_unread, false)::boolean AS media_unread, COALESCE(m.reaction_unread, false)::boolean AS reaction_unread, + COALESCE(m.pinned, false)::boolean AS pinned, + COALESCE(m.via_bot_id, 0)::bigint AS via_bot_id, + COALESCE(m.grouped_id, 0)::bigint AS grouped_id, + COALESCE(m.effect, 0)::bigint AS effect, + COALESCE(m.reply_markup::text, '{}')::text AS reply_markup_json, + COALESCE(m.rich_message::text, '{}')::text AS rich_message_json, COALESCE(peer_u.id, 0)::bigint AS peer_user_id, COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, COALESCE(peer_u.phone, '')::text AS peer_phone, @@ -354,6 +332,11 @@ SELECT COALESCE(peer_u.country_code, '')::text AS peer_country_code, COALESCE(peer_u.verified, false)::boolean AS peer_verified, COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(peer_u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM peer_u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(peer_u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(peer_u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(from_u.id, 0)::bigint AS from_user_user_id, COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, COALESCE(from_u.phone, '')::text AS from_user_phone, @@ -363,6 +346,11 @@ SELECT COALESCE(from_u.country_code, '')::text AS from_user_country_code, COALESCE(from_u.verified, false)::boolean AS from_user_verified, COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.is_bot, false)::boolean AS from_user_is_bot, + COALESCE(from_u.bot_info_version, 0)::int AS from_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM from_u.premium_expires_at), 0)::bigint AS from_user_premium_until, + COALESCE(from_u.emoji_status_document_id, 0)::bigint AS from_user_emoji_status_document_id, + COALESCE(from_u.emoji_status_until, 0)::bigint AS from_user_emoji_status_until, COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id, COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash, COALESCE(fwd_u.phone, '')::text AS fwd_user_phone, @@ -372,6 +360,11 @@ SELECT COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code, COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified, COALESCE(fwd_u.support, false)::boolean AS fwd_user_support, + COALESCE(fwd_u.is_bot, false)::boolean AS fwd_user_is_bot, + COALESCE(fwd_u.bot_info_version, 0)::int AS fwd_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM fwd_u.premium_expires_at), 0)::bigint AS fwd_user_premium_until, + COALESCE(fwd_u.emoji_status_document_id, 0)::bigint AS fwd_user_emoji_status_document_id, + COALESCE(fwd_u.emoji_status_until, 0)::bigint AS fwd_user_emoji_status_until, COALESCE(reply_u.id, 0)::bigint AS reply_user_id, COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash, COALESCE(reply_u.phone, '')::text AS reply_user_phone, @@ -381,54 +374,11 @@ SELECT COALESCE(reply_u.country_code, '')::text AS reply_user_country_code, COALESCE(reply_u.verified, false)::boolean AS reply_user_verified, COALESCE(reply_u.support, false)::boolean AS reply_user_support, - COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id, - COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash, - COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id, - COALESCE(fwd_ch.title, '')::text AS fwd_channel_title, - COALESCE(fwd_ch.about, '')::text AS fwd_channel_about, - COALESCE(fwd_ch.username, '')::text AS fwd_channel_username, - COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast, - COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup, - COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum, - COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards, - COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures, - COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden, - COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds, - COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights, - COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count, - COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count, - COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count, - COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count, - COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id, - COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id, - COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts, - COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period, - COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date, - COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted, - COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id, - COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash, - COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id, - COALESCE(reply_ch.title, '')::text AS reply_channel_title, - COALESCE(reply_ch.about, '')::text AS reply_channel_about, - COALESCE(reply_ch.username, '')::text AS reply_channel_username, - COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast, - COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup, - COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum, - COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards, - COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures, - COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden, - COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds, - COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights, - COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count, - COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count, - COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count, - COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count, - COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id, - COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id, - COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts, - COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period, - COALESCE(reply_ch.date, 0)::int AS reply_channel_date, - COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted + COALESCE(reply_u.is_bot, false)::boolean AS reply_user_is_bot, + COALESCE(reply_u.bot_info_version, 0)::int AS reply_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM reply_u.premium_expires_at), 0)::bigint AS reply_user_premium_until, + COALESCE(reply_u.emoji_status_document_id, 0)::bigint AS reply_user_emoji_status_document_id, + COALESCE(reply_u.emoji_status_until, 0)::bigint AS reply_user_emoji_status_until FROM unnest(@user_ids::bigint[]) WITH ORDINALITY AS u(user_id, ord) JOIN unnest(@pts_list::int[]) WITH ORDINALITY AS p(pts, ord) USING (ord) JOIN user_update_events e ON e.user_id = u.user_id AND e.pts = p.pts @@ -436,12 +386,10 @@ LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.messag LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id LEFT JOIN users from_u ON from_u.id = m.from_user_id LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id -LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id -LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id -LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id; +LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id; -- name: MarkDispatchDeliveredBatch :exec --- 批量删除一批已投递的 (target_user_id, id);target_user_id 入 WHERE 保证分区裁剪。 +-- 批量删除一批已投递的 (target_user_id, id);target_user_id 入 WHERE 命中唯一索引并避免串删。 DELETE FROM dispatch_outbox d USING unnest(@target_user_ids::bigint[]) WITH ORDINALITY AS tu(target_user_id, ord) JOIN unnest(@ids::bigint[]) WITH ORDINALITY AS di(id, ord) USING (ord) diff --git a/internal/store/postgres/query_stats.go b/internal/store/postgres/query_stats.go new file mode 100644 index 00000000..bbf6e43c --- /dev/null +++ b/internal/store/postgres/query_stats.go @@ -0,0 +1,34 @@ +package postgres + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/observability/dbtrace" +) + +type queryStatsTracer struct{} + +type queryStatsStartKey struct{} + +func (queryStatsTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, _ pgx.TraceQueryStartData) context.Context { + if _, ok := dbtrace.FromContext(ctx); !ok { + return ctx + } + return context.WithValue(ctx, queryStatsStartKey{}, time.Now()) +} + +func (queryStatsTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryEndData) { + stats, ok := dbtrace.FromContext(ctx) + if !ok { + return + } + start, _ := ctx.Value(queryStatsStartKey{}).(time.Time) + if start.IsZero() { + stats.Add(0, data.Err) + return + } + stats.Add(time.Since(start), data.Err) +} diff --git a/internal/store/postgres/query_stats_test.go b/internal/store/postgres/query_stats_test.go new file mode 100644 index 00000000..8994c74f --- /dev/null +++ b/internal/store/postgres/query_stats_test.go @@ -0,0 +1,38 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/observability/dbtrace" +) + +func TestQueryStatsTracerRecordsContextStats(t *testing.T) { + ctx, stats := dbtrace.WithStats(context.Background()) + tracer := queryStatsTracer{} + + ctx = tracer.TraceQueryStart(ctx, nil, pgx.TraceQueryStartData{}) + tracer.TraceQueryEnd(ctx, nil, pgx.TraceQueryEndData{}) + + snap := stats.Snapshot() + if snap.Queries != 1 || snap.Errors != 0 { + t.Fatalf("Snapshot() = %+v, want 1 query without errors", snap) + } + + ctx = tracer.TraceQueryStart(ctx, nil, pgx.TraceQueryStartData{}) + tracer.TraceQueryEnd(ctx, nil, pgx.TraceQueryEndData{Err: errors.New("boom")}) + + snap = stats.Snapshot() + if snap.Queries != 2 || snap.Errors != 1 { + t.Fatalf("Snapshot() = %+v, want 2 queries with 1 error", snap) + } +} + +func TestQueryStatsTracerIgnoresContextWithoutStats(t *testing.T) { + tracer := queryStatsTracer{} + ctx := tracer.TraceQueryStart(context.Background(), nil, pgx.TraceQueryStartData{}) + tracer.TraceQueryEnd(ctx, nil, pgx.TraceQueryEndData{Err: errors.New("boom")}) +} diff --git a/internal/store/postgres/read_model_listener.go b/internal/store/postgres/read_model_listener.go new file mode 100644 index 00000000..52e5988c --- /dev/null +++ b/internal/store/postgres/read_model_listener.go @@ -0,0 +1,459 @@ +package postgres + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5" + "go.uber.org/zap" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +const readModelChangeNotifyChannel = "telesrv_read_model_changed" + +// ReadModelCacheSet 是 read_model_versions 通知可失效的进程内投影缓存集合。 +// 后续新增 read model 时,把缓存接到这里即可复用同一条 LISTEN 连接。 +type ReadModelCacheSet struct { + ReadModelVersions store.ReadModelVersionCache + ChannelRows *ChannelRowCache + ChannelMembers *ChannelMemberCache + ChannelDialogs *ChannelDialogCache + ChannelBoosts *ChannelBoostCache + Contacts ContactReadModelCache + Dialogs DialogReadModelCache + Privacy PrivacyReadModelCache + ProfilePhotos ProfilePhotoReadModelCache + Stories StoryReadModelCache + ChannelFullBots ChannelFullBotReadModelCache + ChannelMediaCounts ChannelMediaCountReadModelCache + PrivateMediaCounts PrivateMediaCountReadModelCache + RPCProjections RPCProjectionReadModelCache + BaseUsers BaseUserCache + BotProfiles BotProfileReadModelCache +} + +// BaseUserCache 是跨进程共享的 user:base 缓存(Redis)。user_base read-model 事件必须删除 +// 对应 user 键,否则 RPC 投影失效后会从陈旧的 user:base 重建(失效被未失效的源自我抵消)。 +// 不参与重连 flush:Redis 是跨实例共享的,整库清空是错的,漏掉的通知靠其自身 TTL 兜底。 +type BaseUserCache interface { + Delete(ctx context.Context, ids []int64) error +} + +// BotProfileReadModelCache 是 bots 服务的进程内 bot 资料缓存。每次 bot 写都会 bump +// bot_info_version 从而触发 user_base 事件,据此跨实例失效 bot 资料(命令/菜单/简介等 +// 仅存内存的字段,TTL 24h 太长不能只靠它)。 +type BotProfileReadModelCache interface { + InvalidateBotProfileReadModel(userID int64) + FlushBotProfileReadModel() +} + +type ContactReadModelCache interface { + InvalidateViewers(...int64) + FlushReadModelCache() +} + +type DialogReadModelCache interface { + InvalidateDialog(ownerUserID int64, peer domain.Peer) + FlushReadModelCache() +} + +// ContactReadModelCaches fans out invalidation to multiple contact read-model caches. +type ContactReadModelCaches []ContactReadModelCache + +func (c ContactReadModelCaches) InvalidateViewers(ids ...int64) { + for _, cache := range c { + if cache != nil { + cache.InvalidateViewers(ids...) + } + } +} + +func (c ContactReadModelCaches) FlushReadModelCache() { + for _, cache := range c { + if cache != nil { + cache.FlushReadModelCache() + } + } +} + +type PrivacyReadModelCache interface { + InvalidateOwners(...int64) + FlushReadModelCache() +} + +type ProfilePhotoReadModelCache interface { + InvalidateOwner(domain.PeerType, int64) + FlushReadModelCache() +} + +type StoryReadModelCache interface { + InvalidateStoryReadModelViewers(...int64) + InvalidateStoryReadModelPeer(domain.Peer) + FlushStoryReadModelCache() +} + +type ChannelFullBotReadModelCache interface { + InvalidateChannelFullBotInfoReadModel(channelID int64) + FlushChannelFullBotInfoReadModel() +} + +type ChannelMediaCountReadModelCache interface { + InvalidateChannelMediaCountReadModel(channelID int64) + InvalidateChannelMediaCountReadModelForViewer(userID, channelID int64) + FlushChannelMediaCountReadModel() +} + +type PrivateMediaCountReadModelCache interface { + InvalidatePrivateMediaCountReadModel(userID, peerID int64) + FlushPrivateMediaCountReadModel() +} + +type RPCProjectionReadModelCache interface { + InvalidateRPCProjectionReadModelForViewer(viewerUserID int64) + InvalidateRPCProjectionReadModelForUser(userID int64) + InvalidateRPCProjectionReadModelForPeer(ownerUserID int64, peer domain.Peer) + InvalidateRPCProjectionReadModelForChannel(channelID int64) + FlushRPCProjectionReadModel() +} + +type readModelChangePayload struct { + Model string `json:"model"` + OwnerUserID int64 `json:"owner_user_id"` + PeerType string `json:"peer_type"` + PeerID int64 `json:"peer_id"` + Version int64 `json:"version"` + Hash int64 `json:"hash"` +} + +// ReadModelChangeListener 消费统一 read model invalidation 通道,驱动进程内 snapshot 失效。 +type ReadModelChangeListener struct { + dsn string + caches ReadModelCacheSet + log *zap.Logger +} + +func NewReadModelChangeListener(dsn string, caches ReadModelCacheSet, log *zap.Logger) *ReadModelChangeListener { + if log == nil { + log = zap.NewNop() + } + return &ReadModelChangeListener{dsn: dsn, caches: caches, log: log} +} + +func (l *ReadModelChangeListener) Run(ctx context.Context) { + if l == nil || l.empty() { + return + } + backoff := channelListenerInitialBackoff + for { + if ctx.Err() != nil { + return + } + err := l.listenAndConsume(ctx) + if ctx.Err() != nil { + return + } + l.log.Warn("read model change listener disconnected; reconnecting", + zap.Error(err), zap.Duration("backoff", backoff)) + if !sleepCtx(ctx, backoff) { + return + } + backoff *= 2 + if backoff > channelListenerMaxBackoff { + backoff = channelListenerMaxBackoff + } + } +} + +func (l *ReadModelChangeListener) listenAndConsume(ctx context.Context) error { + conn, err := pgx.Connect(ctx, l.dsn) + if err != nil { + return err + } + defer conn.Close(context.Background()) + + if _, err := conn.Exec(ctx, "LISTEN "+readModelChangeNotifyChannel); err != nil { + return err + } + l.flush("listen_ready") + l.log.Info("read model change listener ready", zap.String("notify_channel", readModelChangeNotifyChannel)) + + for { + notification, err := conn.WaitForNotification(ctx) + if err != nil { + return err + } + if notification == nil { + continue + } + l.handlePayload(notification.Payload) + } +} + +func (l *ReadModelChangeListener) empty() bool { + return l.caches.ReadModelVersions == nil && + l.caches.ChannelRows == nil && + l.caches.ChannelMembers == nil && + l.caches.ChannelDialogs == nil && + l.caches.ChannelBoosts == nil && + l.caches.Contacts == nil && + l.caches.Dialogs == nil && + l.caches.Privacy == nil && + l.caches.ProfilePhotos == nil && + l.caches.Stories == nil && + l.caches.ChannelFullBots == nil && + l.caches.ChannelMediaCounts == nil && + l.caches.PrivateMediaCounts == nil && + l.caches.RPCProjections == nil && + l.caches.BaseUsers == nil && + l.caches.BotProfiles == nil +} + +func (l *ReadModelChangeListener) flush(reasons ...string) { + reason := "manual" + if len(reasons) > 0 && reasons[0] != "" { + reason = reasons[0] + } + flushed := make([]string, 0, 12) + if l.caches.ReadModelVersions != nil { + l.caches.ReadModelVersions.FlushReadModelCache() + flushed = append(flushed, "read_model_versions") + } + if l.caches.ChannelRows != nil { + l.caches.ChannelRows.flush() + flushed = append(flushed, "channel_rows") + } + if l.caches.ChannelMembers != nil { + l.caches.ChannelMembers.flush() + flushed = append(flushed, "channel_members") + } + if l.caches.ChannelDialogs != nil { + l.caches.ChannelDialogs.flush() + flushed = append(flushed, "channel_dialogs") + } + if l.caches.ChannelBoosts != nil { + l.caches.ChannelBoosts.flush() + flushed = append(flushed, "channel_boosts") + } + if l.caches.Contacts != nil { + l.caches.Contacts.FlushReadModelCache() + flushed = append(flushed, "contacts") + } + if l.caches.Dialogs != nil { + l.caches.Dialogs.FlushReadModelCache() + flushed = append(flushed, "dialogs") + } + if l.caches.Privacy != nil { + l.caches.Privacy.FlushReadModelCache() + flushed = append(flushed, "privacy") + } + if l.caches.ProfilePhotos != nil { + l.caches.ProfilePhotos.FlushReadModelCache() + flushed = append(flushed, "profile_photos") + } + if l.caches.Stories != nil { + l.caches.Stories.FlushStoryReadModelCache() + flushed = append(flushed, "stories") + } + if l.caches.ChannelFullBots != nil { + l.caches.ChannelFullBots.FlushChannelFullBotInfoReadModel() + flushed = append(flushed, "channel_full_bots") + } + if l.caches.ChannelMediaCounts != nil { + l.caches.ChannelMediaCounts.FlushChannelMediaCountReadModel() + flushed = append(flushed, "channel_media_counts") + } + if l.caches.PrivateMediaCounts != nil { + l.caches.PrivateMediaCounts.FlushPrivateMediaCountReadModel() + flushed = append(flushed, "private_media_counts") + } + if l.caches.RPCProjections != nil { + l.caches.RPCProjections.FlushRPCProjectionReadModel() + flushed = append(flushed, "rpc_projections") + } + if l.caches.BotProfiles != nil { + l.caches.BotProfiles.FlushBotProfileReadModel() + flushed = append(flushed, "bot_profiles") + } + // 注意:BaseUsers(Redis) 刻意不在重连时 flush——它是跨实例共享缓存,整库清空会误伤 + // 其它实例;漏掉的通知由其 5min TTL 兜底。 + l.log.Info("read model caches flushed", + zap.String("reason", reason), + zap.Int("cache_groups", len(flushed)), + zap.Strings("caches", flushed)) +} + +func (l *ReadModelChangeListener) handlePayload(payload string) { + var evt readModelChangePayload + if err := json.Unmarshal([]byte(payload), &evt); err != nil { + l.log.Debug("ignore malformed read model change payload", zap.String("payload", payload), zap.Error(err)) + return + } + if l.caches.ReadModelVersions != nil && evt.Model != "" { + key := store.ReadModelKey{ + Model: evt.Model, + OwnerUserID: evt.OwnerUserID, + PeerType: domain.PeerType(evt.PeerType), + PeerID: evt.PeerID, + } + if updater, ok := l.caches.ReadModelVersions.(store.ReadModelVersionCacheUpdater); ok && evt.Hash != 0 { + updater.UpdateReadModelHash(key, evt.Hash) + } else { + l.caches.ReadModelVersions.InvalidateReadModel(key) + } + } + switch evt.Model { + case "user_base": + if evt.PeerType == "user" && evt.PeerID != 0 { + if l.caches.RPCProjections != nil { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.PeerID) + } + // 跨实例失效 Redis user:base:否则本实例的 RPC 投影失效后会从其它实例仍陈旧的 + // (其实是本实例自己的) user:base 重建,等于没失效;这也覆盖了 system-user upsert。 + if l.caches.BaseUsers != nil { + if err := l.caches.BaseUsers.Delete(context.Background(), []int64{evt.PeerID}); err != nil { + l.log.Warn("invalidate base user cache on user_base event", + zap.Int64("user_id", evt.PeerID), zap.Error(err)) + } + } + // bot 写 bump bot_info_version 也走 user_base:跨实例失效 bot 资料缓存。 + if l.caches.BotProfiles != nil { + l.caches.BotProfiles.InvalidateBotProfileReadModel(evt.PeerID) + } + } + case "bot_full": + // bot 资料(name/about/description/commands/menu_button)变更经 bot_info_version + // bump 触发(迁移 0013)。channelFullBotInfoCache 按 (viewer,channel) 键、无法按 botID + // 精确定位某 bot 涉及的频道,故整体 flush(bot 改资料频率低,代价是偶发全量重建)。 + // 这覆盖了 user_base 分支漏失效的群信息页 bot_info 陈旧:本地 BotFather 改资料路径 + // 与跨实例 bots.* RPC 两条更新路径都经此失效。 + if l.caches.ChannelFullBots != nil { + l.caches.ChannelFullBots.FlushChannelFullBotInfoReadModel() + } + case "contact_account", "contact_blocklist": + if evt.OwnerUserID != 0 && l.caches.Contacts != nil { + l.caches.Contacts.InvalidateViewers(evt.OwnerUserID) + } + if evt.OwnerUserID != 0 && l.caches.Stories != nil { + l.caches.Stories.InvalidateStoryReadModelPeer(domain.Peer{Type: domain.PeerTypeUser, ID: evt.OwnerUserID}) + } + if evt.OwnerUserID != 0 && l.caches.RPCProjections != nil { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForViewer(evt.OwnerUserID) + } + case "story_peer": + // stories / story_hidden_peers 写(0135 触发器)→ 按 owner peer 失效该 peer 的 + // 故事投影(ring/hidden/置顶可用性/置顶分页),实现跨实例失效。 + if peerType, ok := readModelPeerType(evt.PeerType); ok && evt.PeerID != 0 && l.caches.Stories != nil { + l.caches.Stories.InvalidateStoryReadModelPeer(domain.Peer{Type: peerType, ID: evt.PeerID}) + } + case "privacy_rules": + if evt.OwnerUserID != 0 && l.caches.Privacy != nil { + l.caches.Privacy.InvalidateOwners(evt.OwnerUserID) + } + if evt.OwnerUserID != 0 && l.caches.Stories != nil { + l.caches.Stories.InvalidateStoryReadModelViewers(evt.OwnerUserID) + } + if evt.OwnerUserID != 0 && l.caches.RPCProjections != nil { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.OwnerUserID) + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForViewer(evt.OwnerUserID) + } + case "dialog_light": + if peerType, ok := readModelPeerType(evt.PeerType); ok && evt.OwnerUserID != 0 && evt.PeerID != 0 { + peer := domain.Peer{Type: peerType, ID: evt.PeerID} + if l.caches.Dialogs != nil { + l.caches.Dialogs.InvalidateDialog(evt.OwnerUserID, peer) + } + if peerType == domain.PeerTypeChannel && l.caches.ChannelDialogs != nil { + l.caches.ChannelDialogs.delete(evt.OwnerUserID, evt.PeerID) + } + if l.caches.RPCProjections != nil { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForPeer(evt.OwnerUserID, peer) + } + } + case "profile_photo": + if peerType, ok := readModelPeerType(evt.PeerType); ok && evt.PeerID != 0 && l.caches.ProfilePhotos != nil { + l.caches.ProfilePhotos.InvalidateOwner(peerType, evt.PeerID) + } + if peerType, ok := readModelPeerType(evt.PeerType); ok && evt.PeerID != 0 && l.caches.RPCProjections != nil { + if peerType == domain.PeerTypeUser { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.PeerID) + } else if peerType == domain.PeerTypeChannel { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForChannel(evt.PeerID) + } + } + case "channel_base": + if evt.PeerType == "channel" && evt.PeerID != 0 { + if l.caches.ChannelRows != nil { + l.caches.ChannelRows.delete(evt.PeerID) + } + if l.caches.ChannelMembers != nil { + l.caches.ChannelMembers.deleteChannel(evt.PeerID) + } + if l.caches.ChannelDialogs != nil { + l.caches.ChannelDialogs.deleteChannel(evt.PeerID) + } + if l.caches.ChannelFullBots != nil { + l.caches.ChannelFullBots.InvalidateChannelFullBotInfoReadModel(evt.PeerID) + } + if l.caches.RPCProjections != nil { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForChannel(evt.PeerID) + } + } + case "channel_media_counts": + if evt.PeerType == "channel" && evt.PeerID != 0 && l.caches.ChannelMediaCounts != nil { + l.caches.ChannelMediaCounts.InvalidateChannelMediaCountReadModel(evt.PeerID) + } + case "channel_member": + if evt.PeerType == "channel" && evt.PeerID != 0 && evt.OwnerUserID != 0 { + if l.caches.ChannelMembers != nil { + l.caches.ChannelMembers.delete(evt.PeerID, evt.OwnerUserID) + } + if l.caches.ChannelMediaCounts != nil { + l.caches.ChannelMediaCounts.InvalidateChannelMediaCountReadModelForViewer(evt.OwnerUserID, evt.PeerID) + } + if l.caches.ChannelDialogs != nil { + l.caches.ChannelDialogs.delete(evt.OwnerUserID, evt.PeerID) + } + if l.caches.Dialogs != nil { + l.caches.Dialogs.InvalidateDialog(evt.OwnerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: evt.PeerID}) + } + if l.caches.ChannelFullBots != nil { + l.caches.ChannelFullBots.InvalidateChannelFullBotInfoReadModel(evt.PeerID) + } + if l.caches.RPCProjections != nil { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForChannel(evt.PeerID) + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForPeer(evt.OwnerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: evt.PeerID}) + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.OwnerUserID) + } + } + case "channel_self_boosts": + if evt.PeerType == "channel" && evt.PeerID != 0 && l.caches.ChannelBoosts != nil { + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: evt.PeerID} + if evt.OwnerUserID != 0 { + l.caches.ChannelBoosts.delete(evt.OwnerUserID, peer) + } + l.caches.ChannelBoosts.deletePeerTotal(peer) + } + if evt.PeerType == "channel" && evt.PeerID != 0 && evt.OwnerUserID != 0 && l.caches.RPCProjections != nil { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForPeer(evt.OwnerUserID, domain.Peer{Type: domain.PeerTypeChannel, ID: evt.PeerID}) + } + case "channel_participants": + if evt.PeerType == "channel" && evt.PeerID != 0 && l.caches.RPCProjections != nil { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForChannel(evt.PeerID) + } + case "private_media_counts": + if evt.OwnerUserID != 0 && evt.PeerType == "user" && evt.PeerID != 0 && l.caches.PrivateMediaCounts != nil { + l.caches.PrivateMediaCounts.InvalidatePrivateMediaCountReadModel(evt.OwnerUserID, evt.PeerID) + } + } +} + +func readModelPeerType(value string) (domain.PeerType, bool) { + switch domain.PeerType(value) { + case domain.PeerTypeUser, domain.PeerTypeChannel: + return domain.PeerType(value), true + default: + return "", false + } +} diff --git a/internal/store/postgres/read_model_versions.go b/internal/store/postgres/read_model_versions.go new file mode 100644 index 00000000..671e5a7b --- /dev/null +++ b/internal/store/postgres/read_model_versions.go @@ -0,0 +1,104 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store" + "telesrv/internal/store/postgres/sqlcgen" +) + +// ReadModelVersionStore reads durable read-model version tokens. +type ReadModelVersionStore struct { + db sqlcgen.DBTX +} + +func NewReadModelVersionStore(db sqlcgen.DBTX) *ReadModelVersionStore { + return &ReadModelVersionStore{db: db} +} + +func (s *ReadModelVersionStore) ReadModelHash(ctx context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) { + if s == nil || s.db == nil || model == "" { + return 0, false, nil + } + var hash int64 + err := s.db.QueryRow(ctx, ` +SELECT hash +FROM read_model_versions +WHERE model = $1 + AND owner_user_id = $2 + AND peer_type = $3 + AND peer_id = $4 +`, model, ownerUserID, string(peerType), peerID).Scan(&hash) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return 0, false, nil + } + return 0, false, fmt.Errorf("read model hash: %w", err) + } + return hash, hash != 0, nil +} + +func (s *ReadModelVersionStore) ReadModelHashes(ctx context.Context, keys []store.ReadModelKey) (map[store.ReadModelKey]int64, error) { + out := make(map[store.ReadModelKey]int64, len(keys)) + if s == nil || s.db == nil || len(keys) == 0 { + return out, nil + } + models := make([]string, 0, len(keys)) + owners := make([]int64, 0, len(keys)) + peerTypes := make([]string, 0, len(keys)) + peerIDs := make([]int64, 0, len(keys)) + seen := make(map[store.ReadModelKey]struct{}, len(keys)) + for _, key := range keys { + if key.Model == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + models = append(models, key.Model) + owners = append(owners, key.OwnerUserID) + peerTypes = append(peerTypes, string(key.PeerType)) + peerIDs = append(peerIDs, key.PeerID) + } + if len(models) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, ` +WITH requested AS ( + SELECT * + FROM unnest($1::text[], $2::bigint[], $3::text[], $4::bigint[]) + AS r(model, owner_user_id, peer_type, peer_id) +) +SELECT v.model, v.owner_user_id, v.peer_type, v.peer_id, v.hash +FROM requested r +JOIN read_model_versions v + ON v.model = r.model + AND v.owner_user_id = r.owner_user_id + AND v.peer_type = r.peer_type + AND v.peer_id = r.peer_id +WHERE v.hash <> 0`, models, owners, peerTypes, peerIDs) + if err != nil { + return nil, fmt.Errorf("read model hashes: %w", err) + } + defer rows.Close() + for rows.Next() { + var key store.ReadModelKey + var peerType string + var hash int64 + if err := rows.Scan(&key.Model, &key.OwnerUserID, &peerType, &key.PeerID, &hash); err != nil { + return nil, fmt.Errorf("scan read model hash: %w", err) + } + key.PeerType = domain.PeerType(peerType) + out[key] = hash + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read model hashes: %w", err) + } + return out, nil +} diff --git a/internal/store/postgres/saved_dialog.go b/internal/store/postgres/saved_dialog.go new file mode 100644 index 00000000..32f343f6 --- /dev/null +++ b/internal/store/postgres/saved_dialog.go @@ -0,0 +1,480 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// ListSavedDialogs 返回收藏夹子会话分页:首页(offset 无效)且不排除置顶时 +// 置顶块按 pinned_order 在前,普通块按 top box id 严格降序;翻页恒排除置顶 +// (置顶只随首页返回,避免跨页重复)。Count 按过滤口径(含/不含置顶)。 +func (s *MessageStore) ListSavedDialogs(ctx context.Context, userID int64, filter domain.SavedDialogsFilter) (domain.SavedDialogList, error) { + out := domain.SavedDialogList{} + if userID == 0 { + return out, fmt.Errorf("list saved dialogs: missing user id") + } + limit := filter.Limit + if limit <= 0 || limit > domain.MaxSavedDialogsLimit { + limit = domain.MaxSavedDialogsLimit + } + // TDesktop 首页 offset_id=0,DrKLO Android 首页 offset_id=int32 max; + // 两者都视为"从最新开始"。 + offsetID := filter.OffsetID + // DrKLO Android 首页恰好发 offset_id = MaxMessageBoxID(int32 max),必须用 >= + // 才能命中首页分支;否则该值被误判为续页,置顶子会话块被整体跳过、首页丢置顶。 + firstPage := offsetID <= 0 || offsetID >= domain.MaxMessageBoxID + if firstPage { + offsetID = 0 + } + if firstPage && !filter.ExcludePinned { + rows, err := s.q.ListPinnedSavedDialogTops(ctx, userID) + if err != nil { + return out, fmt.Errorf("list pinned saved dialog tops: %w", err) + } + for _, row := range rows { + if len(out.Dialogs) >= limit { + break + } + msg, err := messageFromSavedDialogRow(savedDialogRowFields(row)) + if err != nil { + return out, err + } + out.Dialogs = append(out.Dialogs, domain.SavedDialog{ + Peer: domain.Peer{Type: domain.PeerType(row.DialogPeerType), ID: row.DialogPeerID}, + TopMessage: msg.ID, + Pinned: true, + }) + out.Messages = append(out.Messages, msg) + } + } + remaining := limit - len(out.Dialogs) + hasMore := false + if remaining > 0 { + rows, err := s.q.ListSavedDialogTops(ctx, sqlcgen.ListSavedDialogTopsParams{ + OwnerUserID: userID, + OffsetID: pgInt32NonNegative(offsetID), + // 普通块恒排除置顶:置顶要么已随首页置顶块返回,要么被 + // exclude_pinned 显式排除,任何一页都不允许再出现。 + ExcludePinned: true, + LimitCount: int32(remaining + 1), + }) + if err != nil { + return out, fmt.Errorf("list saved dialog tops: %w", err) + } + if len(rows) > remaining { + hasMore = true + rows = rows[:remaining] + } + for _, row := range rows { + msg, err := messageFromSavedDialogRow(savedDialogRowFields(row)) + if err != nil { + return out, err + } + out.Dialogs = append(out.Dialogs, domain.SavedDialog{ + Peer: domain.Peer{Type: domain.PeerType(row.DialogPeerType), ID: row.DialogPeerID}, + TopMessage: msg.ID, + Pinned: row.DialogPinned, + }) + out.Messages = append(out.Messages, msg) + } + } + total, err := s.q.CountSavedDialogs(ctx, sqlcgen.CountSavedDialogsParams{ + OwnerUserID: userID, + ExcludePinned: filter.ExcludePinned, + }) + if err != nil { + return out, fmt.Errorf("count saved dialogs: %w", err) + } + out.Count = int(total) + out.Full = !hasMore + if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { + return out, err + } + return out, nil +} + +// ListPinnedSavedDialogs 返回全部置顶子会话(messages.getPinnedSavedDialogs)。 +func (s *MessageStore) ListPinnedSavedDialogs(ctx context.Context, userID int64) (domain.SavedDialogList, error) { + out := domain.SavedDialogList{Full: true} + if userID == 0 { + return out, fmt.Errorf("list pinned saved dialogs: missing user id") + } + rows, err := s.q.ListPinnedSavedDialogTops(ctx, userID) + if err != nil { + return out, fmt.Errorf("list pinned saved dialog tops: %w", err) + } + for _, row := range rows { + msg, err := messageFromSavedDialogRow(savedDialogRowFields(row)) + if err != nil { + return out, err + } + out.Dialogs = append(out.Dialogs, domain.SavedDialog{ + Peer: domain.Peer{Type: domain.PeerType(row.DialogPeerType), ID: row.DialogPeerID}, + TopMessage: msg.ID, + Pinned: true, + }) + out.Messages = append(out.Messages, msg) + } + out.Count = len(out.Dialogs) + if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { + return out, err + } + return out, nil +} + +// ListSavedDialogsByPeers 返回指定子会话(messages.getSavedDialogsByID); +// 不存在的 peer 静默缺席。 +func (s *MessageStore) ListSavedDialogsByPeers(ctx context.Context, userID int64, peers []domain.Peer) (domain.SavedDialogList, error) { + out := domain.SavedDialogList{Full: true} + if userID == 0 { + return out, fmt.Errorf("list saved dialogs by peers: missing user id") + } + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + for _, peer := range peers { + if peer.Type == "" || peer.ID == 0 { + continue + } + peerTypes = append(peerTypes, string(peer.Type)) + peerIDs = append(peerIDs, peer.ID) + } + if len(peerTypes) == 0 { + return out, nil + } + rows, err := s.q.ListSavedDialogTopsByPeers(ctx, sqlcgen.ListSavedDialogTopsByPeersParams{ + OwnerUserID: userID, + PeerTypes: peerTypes, + PeerIds: peerIDs, + }) + if err != nil { + return out, fmt.Errorf("list saved dialog tops by peers: %w", err) + } + for _, row := range rows { + msg, err := messageFromSavedDialogRow(savedDialogRowFields(row)) + if err != nil { + return out, err + } + out.Dialogs = append(out.Dialogs, domain.SavedDialog{ + Peer: domain.Peer{Type: domain.PeerType(row.DialogPeerType), ID: row.DialogPeerID}, + TopMessage: msg.ID, + Pinned: row.DialogPinned, + }) + out.Messages = append(out.Messages, msg) + } + out.Count = len(out.Dialogs) + if err := s.enrichPrivateMessageReactions(ctx, s.db, userID, out.Messages); err != nil { + return out, err + } + return out, nil +} + +// ToggleSavedDialogPin 翻转一个子会话的置顶状态;新置顶插到最前。 +// 返回状态是否实际变化。 +func (s *MessageStore) ToggleSavedDialogPin(ctx context.Context, userID int64, peer domain.Peer, pinned bool) (bool, error) { + if userID == 0 || peer.Type == "" || peer.ID == 0 { + return false, fmt.Errorf("toggle saved dialog pin: invalid input") + } + if !pinned { + rows, err := s.q.DeleteSavedDialogPin(ctx, sqlcgen.DeleteSavedDialogPinParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }) + if err != nil { + return false, fmt.Errorf("delete saved dialog pin: %w", err) + } + return rows > 0, nil + } + count, err := s.q.CountSavedDialogPins(ctx, userID) + if err != nil { + return false, fmt.Errorf("count saved dialog pins: %w", err) + } + if int(count) >= domain.MaxPinnedSavedDialogs { + return false, domain.ErrPinnedSavedDialogsTooMuch + } + rows, err := s.q.UpsertSavedDialogPinFront(ctx, sqlcgen.UpsertSavedDialogPinFrontParams{ + UserID: userID, + PeerType: string(peer.Type), + PeerID: peer.ID, + }) + if err != nil { + return false, fmt.Errorf("upsert saved dialog pin: %w", err) + } + return rows > 0, nil +} + +// ReorderPinnedSavedDialogs 全量重排置顶顺序;force 时清掉不在 order 中的 +// 既有置顶(与 messages.reorderPinnedDialogs 的 force 语义一致)。 +func (s *MessageStore) ReorderPinnedSavedDialogs(ctx context.Context, userID int64, order []domain.Peer, force bool) error { + if userID == 0 { + return fmt.Errorf("reorder pinned saved dialogs: missing user id") + } + if len(order) > domain.MaxPinnedSavedDialogs { + return domain.ErrPinnedSavedDialogsTooMuch + } + peerTypes := make([]string, 0, len(order)) + peerIDs := make([]int64, 0, len(order)) + for _, peer := range order { + if peer.Type == "" || peer.ID == 0 { + continue + } + peerTypes = append(peerTypes, string(peer.Type)) + peerIDs = append(peerIDs, peer.ID) + } + if force { + if err := s.q.ClearSavedDialogPinsNotInOrder(ctx, sqlcgen.ClearSavedDialogPinsNotInOrderParams{ + UserID: userID, + PeerTypes: peerTypes, + PeerIds: peerIDs, + }); err != nil { + return fmt.Errorf("clear saved dialog pins not in order: %w", err) + } + } + if len(peerTypes) == 0 { + return nil + } + if err := s.q.ReorderSavedDialogPins(ctx, sqlcgen.ReorderSavedDialogPinsParams{ + UserID: userID, + PeerTypes: peerTypes, + PeerIds: peerIDs, + }); err != nil { + return fmt.Errorf("reorder saved dialog pins: %w", err) + } + return nil +} + +// DeleteSavedHistory 删除 self-chat 中一个 saved 子会话的消息(单批, +// 上限 MaxDeleteHistoryBatch),生成带 pts 的 delete_messages durable 事件, +// 修复 self dialog top;子会话删空时顺带清掉它的置顶行。 +func (s *MessageStore) DeleteSavedHistory(ctx context.Context, req domain.DeleteSavedHistoryRequest) (domain.DeleteSavedHistoryResult, error) { + res := domain.DeleteSavedHistoryResult{} + if req.OwnerUserID == 0 || req.SavedPeer.Type == "" || req.SavedPeer.ID == 0 { + return res, fmt.Errorf("delete saved history: invalid input") + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return res, fmt.Errorf("delete saved history: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return res, fmt.Errorf("begin delete saved history tx: %w", err) + } + qtx := sqlcgen.New(tx) + committed := false + defer func() { + if committed { + return + } + _ = tx.Rollback(ctx) + }() + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID); err != nil { + return res, fmt.Errorf("lock delete saved history user: %w", err) + } + rows, err := qtx.DeleteMessageBoxesBySavedPeerBatch(ctx, sqlcgen.DeleteMessageBoxesBySavedPeerBatchParams{ + OwnerUserID: req.OwnerUserID, + SavedPeerType: string(req.SavedPeer.Type), + SavedPeerID: req.SavedPeer.ID, + MaxID: pgInt32NonNegative(req.MaxID), + MinDate: pgInt32NonNegative(req.MinDate), + MaxDate: pgInt32NonNegative(req.MaxDate), + LimitCount: int32(domain.MaxDeleteHistoryBatch), + }) + if err != nil { + return res, fmt.Errorf("delete saved history batch: %w", err) + } + deleted := make([]deletedBox, 0, len(rows)) + for _, row := range rows { + deleted = append(deleted, deletedBox{ + ownerUserID: row.OwnerUserID, + boxID: int(row.BoxID), + privateMessageID: row.PrivateMessageID, + messageSenderID: row.MessageSenderID, + peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + }) + } + delRes, err := s.finishDeleteMessagesTx(ctx, tx, qtx, req.OwnerUserID, req.OriginAuthKeyID, req.OriginSessionID, req.Date, deleted, false) + if err != nil { + return res, err + } + if len(deleted) > 0 { + more, err := qtx.HasDeletableMessageBoxBySavedPeer(ctx, sqlcgen.HasDeletableMessageBoxBySavedPeerParams{ + OwnerUserID: req.OwnerUserID, + SavedPeerType: string(req.SavedPeer.Type), + SavedPeerID: req.SavedPeer.ID, + MaxID: pgInt32NonNegative(req.MaxID), + MinDate: pgInt32NonNegative(req.MinDate), + MaxDate: pgInt32NonNegative(req.MaxDate), + }) + if err != nil { + return res, fmt.Errorf("probe deletable saved history: %w", err) + } + res.More = more + if !more { + // 边界口径外可能还有残留(如 max_id 截断),子会话仍存活时 + // 不能清置顶;只有全量删空才清。 + alive, err := qtx.HasDeletableMessageBoxBySavedPeer(ctx, sqlcgen.HasDeletableMessageBoxBySavedPeerParams{ + OwnerUserID: req.OwnerUserID, + SavedPeerType: string(req.SavedPeer.Type), + SavedPeerID: req.SavedPeer.ID, + }) + if err != nil { + return res, fmt.Errorf("probe saved dialog alive: %w", err) + } + if !alive { + if _, err := qtx.DeleteSavedDialogPin(ctx, sqlcgen.DeleteSavedDialogPinParams{ + UserID: req.OwnerUserID, + PeerType: string(req.SavedPeer.Type), + PeerID: req.SavedPeer.ID, + }); err != nil { + return res, fmt.Errorf("clear pin after saved history delete: %w", err) + } + } + } + } + if err := tx.Commit(ctx); err != nil { + return res, fmt.Errorf("commit delete saved history tx: %w", err) + } + committed = true + for _, d := range delRes.Deleted { + if d.UserID == req.OwnerUserID { + res.MessageIDs = d.MessageIDs + res.Event = d.Event + } + } + return res, nil +} + +// savedDialogTopRowFields 是三个 saved dialog top 查询共有的行字段。 +type savedDialogTopRowFields struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string + Pinned bool +} + +func savedDialogRowFields[T sqlcgen.ListSavedDialogTopsRow | sqlcgen.ListPinnedSavedDialogTopsRow | sqlcgen.ListSavedDialogTopsByPeersRow](row T) savedDialogTopRowFields { + switch r := any(row).(type) { + case sqlcgen.ListSavedDialogTopsRow: + return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned} + case sqlcgen.ListPinnedSavedDialogTopsRow: + return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned} + case sqlcgen.ListSavedDialogTopsByPeersRow: + return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned} + } + return savedDialogTopRowFields{} +} + +func messageFromSavedDialogRow(row savedDialogTopRowFields) (domain.Message, error) { + entities, err := decodeMessageEntities(row.EntitiesJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode saved dialog message entities: %w", err) + } + silent, noforwards, reply, forward, err := messageMetadataFromFields( + row.Silent, + row.Noforwards, + row.ReplyToMsgID, + row.ReplyToPeerType, + row.ReplyToPeerID, + row.ReplyToTopID, + row.ReplyToStoryID, + row.QuoteText, + row.QuoteEntitiesJson, + row.QuoteOffset, + row.FwdFromPeerType, + row.FwdFromPeerID, + row.FwdFromName, + row.FwdDate, + row.FwdSavedFromPeerType, + row.FwdSavedFromPeerID, + row.FwdSavedFromMsgID, + ) + if err != nil { + return domain.Message{}, fmt.Errorf("decode saved dialog message metadata: %w", err) + } + media, err := decodeMessageMedia(row.MediaJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode saved dialog message media: %w", err) + } + markup, err := decodeReplyMarkup(row.ReplyMarkupJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode saved dialog message reply markup: %w", err) + } + rich, err := decodeRichMessage(row.RichMessageJson) + if err != nil { + return domain.Message{}, fmt.Errorf("decode saved dialog message rich message: %w", err) + } + return domain.Message{ + ID: int(row.BoxID), + UID: row.PrivateMessageID, + OwnerUserID: row.OwnerUserID, + Peer: domain.Peer{Type: domain.PeerType(row.PeerType), ID: row.PeerID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: row.FromUserID}, + Date: int(row.MessageDate), + EditDate: int(row.EditDate), + Out: row.Outgoing, + Silent: silent, + NoForwards: noforwards, + Body: row.Body, + Entities: entities, + ReplyTo: reply, + Forward: forward, + Pts: int(row.Pts), + TTLPeriod: int(row.TtlPeriod), + ExpiresAt: int(row.ExpiresAt), + Media: media, + MediaUnread: row.MediaUnread, + ReactionUnread: row.ReactionUnread, + ViaBotID: row.ViaBotID, + GroupedID: row.GroupedID, + Effect: row.Effect, + ReplyMarkup: markup, + RichMessage: rich, + Pinned: row.Pinned, + SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), + }, nil +} diff --git a/internal/store/postgres/saved_dialog_integration_test.go b/internal/store/postgres/saved_dialog_integration_test.go new file mode 100644 index 00000000..122ba099 --- /dev/null +++ b/internal/store/postgres/saved_dialog_integration_test.go @@ -0,0 +1,259 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestMessageStoreSavedDialogsLifecycle 覆盖收藏夹分会话 PG 主链路: +// 直发/转发的 saved_peer 写入、fwd saved_from 持久化(重读非内存 echo)、 +// 聚合分页、置顶、按子会话历史过滤与删除(含置顶清理)。 +func TestMessageStoreSavedDialogsLifecycle(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + alice, err := users.Create(ctx, domain.User{AccessHash: 91, Phone: "+1672" + suffix + "01", FirstName: "SavedAlice"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1672" + suffix + "02", FirstName: "SavedBob"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID}) + _, _ = pool.Exec(ctx, "DELETE FROM saved_dialog_pins WHERE user_id = ANY($1::bigint[])", []int64{alice.ID, bob.ID}) + }) + + messages := NewMessageStore(pool) + selfPeer := domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID} + bobPeer := domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID} + + // 直发笔记 → saved_peer = self。 + note, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, + RecipientUserID: alice.ID, + RandomID: 841001, + Message: "note to self", + Date: 1700000700, + }) + if err != nil { + t.Fatalf("send note: %v", err) + } + + // bob 私聊来信 → alice 转发进收藏夹 → saved_peer = bob 且 fwd saved_from 持久化。 + if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: bob.ID, + RecipientUserID: alice.ID, + RandomID: 841002, + Message: "from bob", + Date: 1700000701, + }); err != nil { + t.Fatalf("bob send: %v", err) + } + aliceInbox, err := messages.ListByUser(ctx, alice.ID, domain.MessageFilter{ + HasPeer: true, Peer: bobPeer, Limit: 1, + }) + if err != nil || len(aliceInbox.Messages) != 1 { + t.Fatalf("alice inbox = %+v err %v", aliceInbox.Messages, err) + } + fwdRes, err := messages.ForwardPrivateMessages(ctx, domain.ForwardPrivateMessagesRequest{ + OwnerUserID: alice.ID, + ToUserID: alice.ID, + FromPeer: bobPeer, + MessageIDs: []int{aliceInbox.Messages[0].ID}, + RandomIDs: []int64{841003}, + Date: 1700000702, + }) + if err != nil || len(fwdRes.SenderMessages) != 1 { + t.Fatalf("forward to saved: %+v err %v", fwdRes, err) + } + fwdID := fwdRes.SenderMessages[0].ID + + // 重读 self-chat(非发送 echo)验证持久化:saved_peer 与 fwd saved_from 都在。 + selfHistory, err := messages.ListByUser(ctx, alice.ID, domain.MessageFilter{ + HasPeer: true, Peer: selfPeer, Limit: 10, + }) + if err != nil || len(selfHistory.Messages) != 2 { + t.Fatalf("self history = %+v err %v, want note+forward", selfHistory.Messages, err) + } + for _, msg := range selfHistory.Messages { + switch msg.ID { + case note.SenderMessage.ID: + if msg.SavedPeer != selfPeer { + t.Fatalf("note saved_peer = %+v, want self", msg.SavedPeer) + } + case fwdID: + if msg.SavedPeer != bobPeer { + t.Fatalf("forward saved_peer = %+v, want bob", msg.SavedPeer) + } + if msg.Forward == nil || msg.Forward.SavedFrom != bobPeer || msg.Forward.SavedFromMsgID != aliceInbox.Messages[0].ID { + t.Fatalf("forward header = %+v, want persisted saved_from bob/%d", msg.Forward, aliceInbox.Messages[0].ID) + } + default: + t.Fatalf("unexpected self-chat message %d", msg.ID) + } + } + + // 聚合:转发更晚在前;分页 limit=1 → slice,offset 翻页到尾页。 + list, err := messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 20}) + if err != nil { + t.Fatalf("list saved dialogs: %v", err) + } + if !list.Full || list.Count != 2 || len(list.Dialogs) != 2 || + list.Dialogs[0].Peer != bobPeer || list.Dialogs[0].TopMessage != fwdID || + list.Dialogs[1].Peer != selfPeer || list.Dialogs[1].TopMessage != note.SenderMessage.ID { + t.Fatalf("saved dialogs = %+v count %d full %v, want bob then self", list.Dialogs, list.Count, list.Full) + } + page, err := messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 1}) + if err != nil || page.Full || len(page.Dialogs) != 1 || page.Dialogs[0].Peer != bobPeer || page.Count != 2 { + t.Fatalf("first page = %+v err %v, want slice bob count 2", page, err) + } + tail, err := messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 1, OffsetID: fwdID}) + if err != nil || !tail.Full || len(tail.Dialogs) != 1 || tail.Dialogs[0].Peer != selfPeer { + t.Fatalf("tail page = %+v err %v, want full self", tail, err) + } + + // 置顶:self 置顶后首页在前、exclude 口径、ByPeers/pinned 一致。 + changed, err := messages.ToggleSavedDialogPin(ctx, alice.ID, selfPeer, true) + if err != nil || !changed { + t.Fatalf("pin self = %v err %v", changed, err) + } + if changed, err = messages.ToggleSavedDialogPin(ctx, alice.ID, selfPeer, true); err != nil || changed { + t.Fatalf("re-pin self = %v err %v, want no-op", changed, err) + } + list, err = messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 20}) + if err != nil || len(list.Dialogs) != 2 || list.Dialogs[0].Peer != selfPeer || !list.Dialogs[0].Pinned { + t.Fatalf("after pin = %+v err %v, want pinned self first", list.Dialogs, err) + } + excluded, err := messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{ExcludePinned: true, Limit: 20}) + if err != nil || excluded.Count != 1 || len(excluded.Dialogs) != 1 || excluded.Dialogs[0].Peer != bobPeer { + t.Fatalf("exclude pinned = %+v err %v, want only bob count 1", excluded, err) + } + pinned, err := messages.ListPinnedSavedDialogs(ctx, alice.ID) + if err != nil || len(pinned.Dialogs) != 1 || pinned.Dialogs[0].Peer != selfPeer { + t.Fatalf("pinned list = %+v err %v, want self", pinned.Dialogs, err) + } + byPeers, err := messages.ListSavedDialogsByPeers(ctx, alice.ID, []domain.Peer{bobPeer, {Type: domain.PeerTypeUser, ID: 424242}}) + if err != nil || len(byPeers.Dialogs) != 1 || byPeers.Dialogs[0].Peer != bobPeer { + t.Fatalf("by peers = %+v err %v, want bob only", byPeers.Dialogs, err) + } + + // reorder:bob 也置顶后强排为 bob→self。 + if _, err := messages.ToggleSavedDialogPin(ctx, alice.ID, bobPeer, true); err != nil { + t.Fatalf("pin bob: %v", err) + } + if err := messages.ReorderPinnedSavedDialogs(ctx, alice.ID, []domain.Peer{bobPeer, selfPeer}, true); err != nil { + t.Fatalf("reorder: %v", err) + } + pinned, err = messages.ListPinnedSavedDialogs(ctx, alice.ID) + if err != nil || len(pinned.Dialogs) != 2 || pinned.Dialogs[0].Peer != bobPeer || pinned.Dialogs[1].Peer != selfPeer { + t.Fatalf("after reorder = %+v err %v, want bob then self", pinned.Dialogs, err) + } + + // 子会话历史过滤。 + bobHistory, err := messages.ListByUser(ctx, alice.ID, domain.MessageFilter{ + HasPeer: true, Peer: selfPeer, SavedPeer: bobPeer, Limit: 10, NeedTotalCount: true, + }) + if err != nil || len(bobHistory.Messages) != 1 || bobHistory.Messages[0].ID != fwdID || bobHistory.Count != 1 { + t.Fatalf("bob sublist history = %+v count %d err %v", bobHistory.Messages, bobHistory.Count, err) + } + + // 删除 bob 子会话:消息消失、置顶清理、带 pts 的 delete 事件。 + delRes, err := messages.DeleteSavedHistory(ctx, domain.DeleteSavedHistoryRequest{ + OwnerUserID: alice.ID, + SavedPeer: bobPeer, + Date: 1700000710, + }) + if err != nil { + t.Fatalf("delete saved history: %v", err) + } + if delRes.More || len(delRes.MessageIDs) != 1 || delRes.MessageIDs[0] != fwdID || delRes.Event.Pts == 0 || delRes.Event.PtsCount != 1 { + t.Fatalf("delete result = %+v, want fwd deleted with pts", delRes) + } + list, err = messages.ListSavedDialogs(ctx, alice.ID, domain.SavedDialogsFilter{Limit: 20}) + if err != nil || len(list.Dialogs) != 1 || list.Dialogs[0].Peer != selfPeer || list.Count != 1 { + t.Fatalf("after delete = %+v count %d err %v, want only self", list.Dialogs, list.Count, err) + } + pinned, err = messages.ListPinnedSavedDialogs(ctx, alice.ID) + if err != nil || len(pinned.Dialogs) != 1 || pinned.Dialogs[0].Peer != selfPeer { + t.Fatalf("pins after delete = %+v err %v, want bob pin cleared", pinned.Dialogs, err) + } +} + +// TestSavedDialogsBackfillRule 验证 0087 回填 CASE 规则与 TDLib 语义对齐: +// fwd 作者可见或无 fwd → self;仅 from_name → hidden author 2666000。 +// migration 已在建库时应用,这里手工插入老格式行(saved_peer_type=”)后 +// 重放回填语句验证规则本身。 +func TestSavedDialogsBackfillRule(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 93, Phone: "+1673" + suffix + "01", FirstName: "BackfillOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID) + }) + + insert := func(boxID int, fwdPeerID int64, fwdName string, fwdDate int) { + t.Helper() + var privateID int64 + if err := pool.QueryRow(ctx, ` +INSERT INTO private_messages (sender_user_id, recipient_user_id, random_id, message_date, body, entities) +VALUES ($1, $1, $2::bigint, 1700000800, 'legacy', '[]'::jsonb) +RETURNING id`, owner.ID, 841100+boxID).Scan(&privateID); err != nil { + t.Fatalf("insert legacy private message %d: %v", boxID, err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO message_boxes ( + owner_user_id, box_id, private_message_id, message_sender_id, + peer_type, peer_id, from_user_id, message_date, outgoing, body, entities, pts, + fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date +) VALUES ($1, $2::int, $3::bigint, $1, 'user', $1, $1, 1700000800, true, 'legacy', '[]'::jsonb, $2::int, + CASE WHEN $4::bigint <> 0 THEN 'user' ELSE '' END, $4::bigint, $5, $6::int)`, + owner.ID, boxID, privateID, fwdPeerID, fwdName, fwdDate); err != nil { + t.Fatalf("insert legacy row %d: %v", boxID, err) + } + } + insert(1, 0, "", 0) // 直发笔记 → self + insert(2, 4242, "", 1700000500) // fwd 作者可见、saved_from 丢失 → self + insert(3, 0, "Hidden", 1700000501) // 仅 from_name → 2666000 + + if _, err := pool.Exec(ctx, ` +UPDATE message_boxes +SET saved_peer_type = 'user', + saved_peer_id = CASE + WHEN fwd_date <> 0 AND fwd_from_peer_id = 0 AND fwd_from_name <> '' THEN 2666000 + ELSE owner_user_id + END +WHERE peer_type = 'user' + AND peer_id = owner_user_id + AND saved_peer_type = ''`); err != nil { + t.Fatalf("replay backfill: %v", err) + } + + messages := NewMessageStore(pool) + list, err := messages.ListSavedDialogs(ctx, owner.ID, domain.SavedDialogsFilter{Limit: 20}) + if err != nil { + t.Fatalf("list after backfill: %v", err) + } + if len(list.Dialogs) != 2 { + t.Fatalf("backfill dialogs = %+v, want self + hidden author", list.Dialogs) + } + hidden := domain.Peer{Type: domain.PeerTypeUser, ID: domain.SavedHiddenAuthorUserID} + self := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + if list.Dialogs[0].Peer != hidden || list.Dialogs[0].TopMessage != 3 { + t.Fatalf("hidden sublist = %+v, want top 3", list.Dialogs[0]) + } + if list.Dialogs[1].Peer != self || list.Dialogs[1].TopMessage != 2 { + t.Fatalf("self sublist = %+v, want top 2 (visible-author legacy forward)", list.Dialogs[1]) + } +} diff --git a/internal/store/postgres/scheduled_message.go b/internal/store/postgres/scheduled_message.go new file mode 100644 index 00000000..a50efc90 --- /dev/null +++ b/internal/store/postgres/scheduled_message.go @@ -0,0 +1,718 @@ +package postgres + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "hash/fnv" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +const maxScheduledMessagePage = 100 + +// scheduledRetryBackoffSeconds 是后台派发投递失败后的最小重试间隔:投递失败的 +// pending 行(last_error 非空)在该退避窗口内不再被 ClaimDueScheduledMessages +// 认领,避免对端注销等永久失败的到期消息每秒被反复 claim+send。 +const scheduledRetryBackoffSeconds = 30 + +func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.ScheduleMessageRequest) (domain.ScheduledMessage, error) { + if req.OwnerUserID == 0 || req.Peer.ID == 0 || req.ScheduleDate <= 0 { + return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: invalid request") + } + if req.Peer.Type != domain.PeerTypeUser && req.Peer.Type != domain.PeerTypeChannel { + return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: invalid peer") + } + if req.Message == "" && req.Media.IsZero() { + return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: empty message") + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + entities, err := encodeMessageEntities(req.Entities) + if err != nil { + return domain.ScheduledMessage{}, err + } + media, err := encodeMessageMedia(req.Media) + if err != nil { + return domain.ScheduledMessage{}, err + } + meta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, req.ReplyTo, req.Forward) + if err != nil { + return domain.ScheduledMessage{}, err + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("begin create scheduled message: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID); err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("lock scheduled owner: %w", err) + } + if req.RandomID != 0 { + existing, ok, err := getPendingScheduledByRandomID(ctx, tx, req.OwnerUserID, req.RandomID) + if err != nil { + return domain.ScheduledMessage{}, err + } + if ok { + if err := tx.Commit(ctx); err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("commit duplicate scheduled message: %w", err) + } + committed = true + return existing, nil + } + } + nextID, err := nextScheduledMessageID(ctx, tx, req.OwnerUserID) + if err != nil { + return domain.ScheduledMessage{}, err + } + sendAsType, sendAsID := "", int64(0) + if req.SendAs != nil { + sendAsType = string(req.SendAs.Type) + sendAsID = req.SendAs.ID + } + if _, err := tx.Exec(ctx, ` +INSERT INTO scheduled_messages ( + owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date, + body, entities, media, silent, noforwards, + reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + quote_text, quote_entities, quote_offset, + fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, + send_as_peer_type, send_as_peer_id, + schedule_date, schedule_repeat_period, state, created_at, updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8::jsonb, $9::jsonb, $10, $11, + $12, $13, $14, $15, + $16, $17::jsonb, $18, + $19, $20, $21, $22, + $23, $24, + $25, $26, 'pending', $27, $27 +)`, req.OwnerUserID, nextID, string(req.Peer.Type), req.Peer.ID, req.RandomID, req.Date, + req.Message, entities, media, req.Silent, req.NoForwards, + meta.ReplyToMsgID, meta.ReplyToPeerType, meta.ReplyToPeerID, meta.ReplyToTopID, + meta.QuoteText, meta.QuoteEntitiesJSON, meta.QuoteOffset, + meta.FwdFromPeerType, meta.FwdFromPeerID, meta.FwdFromName, meta.FwdDate, + sendAsType, sendAsID, + req.ScheduleDate, req.ScheduleRepeatPeriod, req.Date); err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("insert scheduled message: %w", err) + } + if err := setHasScheduledTx(ctx, tx, req.OwnerUserID, req.Peer, true); err != nil { + return domain.ScheduledMessage{}, err + } + msg, ok, err := getScheduledByID(ctx, tx, req.OwnerUserID, nextID) + if err != nil { + return domain.ScheduledMessage{}, err + } + if !ok { + return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: inserted row missing") + } + if err := tx.Commit(ctx); err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("commit create scheduled message: %w", err) + } + committed = true + return msg, nil +} + +func (s *MessageStore) ListScheduledMessages(ctx context.Context, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) { + return listScheduledMessages(ctx, s.db, filter, false) +} + +func (s *MessageStore) EditScheduledMessage(ctx context.Context, req domain.EditScheduledMessageRequest) (domain.ScheduledMessage, error) { + if req.OwnerUserID == 0 || req.Peer.ID == 0 || req.ID <= 0 || req.ScheduleDate <= 0 { + return domain.ScheduledMessage{}, domain.ErrMessageIDInvalid + } + if req.Peer.Type != domain.PeerTypeUser && req.Peer.Type != domain.PeerTypeChannel { + return domain.ScheduledMessage{}, domain.ErrMessageIDInvalid + } + if req.Date == 0 { + req.Date = int(time.Now().Unix()) + } + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.ScheduledMessage{}, fmt.Errorf("edit scheduled message: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("begin edit scheduled message: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if err := lockUsersForUpdate(ctx, tx, req.OwnerUserID); err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("lock scheduled edit owner: %w", err) + } + current, ok, err := getPendingScheduledForEdit(ctx, tx, req.OwnerUserID, req.Peer, req.ID) + if err != nil { + return domain.ScheduledMessage{}, err + } + if !ok { + return domain.ScheduledMessage{}, domain.ErrMessageIDInvalid + } + message := current.Message + entities := append([]domain.MessageEntity(nil), current.Entities...) + if req.SetMessage { + if req.Message == "" && current.Media.IsZero() { + return domain.ScheduledMessage{}, domain.ErrMessageEmpty + } + message = req.Message + entities = append([]domain.MessageEntity(nil), req.Entities...) + } + encodedEntities, err := encodeMessageEntities(entities) + if err != nil { + return domain.ScheduledMessage{}, err + } + row := tx.QueryRow(ctx, ` +UPDATE scheduled_messages +SET body = $5, + entities = $6::jsonb, + schedule_date = $7, + updated_at = $8 +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND scheduled_id = $4 + AND state = 'pending' +RETURNING `+scheduledMessageSelectColumns(), req.OwnerUserID, string(req.Peer.Type), req.Peer.ID, req.ID, message, encodedEntities, req.ScheduleDate, req.Date) + msg, err := scanScheduledMessage(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ScheduledMessage{}, domain.ErrMessageIDInvalid + } + return domain.ScheduledMessage{}, err + } + if err := tx.Commit(ctx); err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("commit edit scheduled message: %w", err) + } + committed = true + return msg, nil +} + +func (s *MessageStore) GetScheduledMessages(ctx context.Context, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) { + return listScheduledMessages(ctx, s.db, filter, true) +} + +func (s *MessageStore) DeleteScheduledMessages(ctx context.Context, filter domain.ScheduledMessageFilter, date int) ([]domain.ScheduledMessage, error) { + if filter.OwnerUserID == 0 || filter.Peer.ID == 0 || len(filter.IDs) == 0 { + return nil, nil + } + beginner, ok := s.db.(txBeginner) + if !ok { + return nil, fmt.Errorf("delete scheduled messages: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin delete scheduled messages: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if err := lockUsersForUpdate(ctx, tx, filter.OwnerUserID); err != nil { + return nil, fmt.Errorf("lock scheduled delete owner: %w", err) + } + rows, err := tx.Query(ctx, ` +UPDATE scheduled_messages +SET state = 'deleted', + updated_at = $5 +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND scheduled_id = ANY($4::int[]) + AND state IN ('pending', 'dispatching') +RETURNING `+scheduledMessageSelectColumns(), filter.OwnerUserID, string(filter.Peer.Type), filter.Peer.ID, intSliceToInt32(filter.IDs), date) + if err != nil { + return nil, fmt.Errorf("delete scheduled messages: %w", err) + } + deleted, err := scanScheduledMessages(rows) + if err != nil { + return nil, err + } + if err := refreshHasScheduledTx(ctx, tx, filter.OwnerUserID, filter.Peer); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit delete scheduled messages: %w", err) + } + committed = true + return deleted, nil +} + +func (s *MessageStore) ClaimScheduledMessages(ctx context.Context, claim domain.ScheduledMessageClaim) ([]domain.ScheduledMessage, error) { + if claim.OwnerUserID == 0 || claim.Peer.ID == 0 || len(claim.IDs) == 0 { + return nil, nil + } + if claim.Now == 0 { + claim.Now = int(time.Now().Unix()) + } + if claim.LeaseUntil == 0 { + claim.LeaseUntil = claim.Now + 60 + } + rows, err := s.db.Query(ctx, ` +UPDATE scheduled_messages +SET state = 'dispatching', + lease_until = $6, + updated_at = $5, + last_error = '' +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND scheduled_id = ANY($4::int[]) + AND (state = 'pending' OR (state = 'dispatching' AND lease_until <= $5)) +RETURNING `+scheduledMessageSelectColumns(), claim.OwnerUserID, string(claim.Peer.Type), claim.Peer.ID, intSliceToInt32(claim.IDs), claim.Now, claim.LeaseUntil) + if err != nil { + return nil, fmt.Errorf("claim scheduled messages: %w", err) + } + return scanScheduledMessages(rows) +} + +func (s *MessageStore) ClaimDueScheduledMessages(ctx context.Context, now, limit, leaseSeconds int) ([]domain.ScheduledMessage, error) { + if now <= 0 || limit <= 0 { + return nil, nil + } + if limit > maxScheduledMessagePage { + limit = maxScheduledMessagePage + } + if leaseSeconds <= 0 { + leaseSeconds = 60 + } + // retryFloor 让上一次投递失败(last_error 非空)的 pending 行至少退避 + // scheduledRetryBackoffSeconds 秒再被认领;同时回收 lease 已过期却仍卡在 + // 'dispatching' 的行——派发器在 claim 之后、MarkScheduledMessageSent 之前 + // 崩溃/部署/OOM 会留下这种行,旧实现只认领 'pending' 导致该定时消息永久不 + // 投递且 has_scheduled 永远为真。重投由 random_id 幂等兜底(命中 Duplicate + // 仍会 MarkScheduledMessageSent 清状态)。 + retryFloor := now - scheduledRetryBackoffSeconds + rows, err := s.db.Query(ctx, ` +WITH due AS ( + SELECT sm.owner_user_id, sm.scheduled_id + FROM scheduled_messages sm + WHERE sm.schedule_date <= $1 + AND ( + (sm.state = 'pending' AND (sm.last_error = '' OR sm.updated_at <= $4)) + OR (sm.state = 'dispatching' AND sm.lease_until <= $1) + ) + ORDER BY sm.schedule_date ASC, sm.owner_user_id ASC, sm.scheduled_id ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED +) +UPDATE scheduled_messages sm +SET state = 'dispatching', + lease_until = $1 + $3, + updated_at = $1, + last_error = '' +FROM due +WHERE sm.owner_user_id = due.owner_user_id + AND sm.scheduled_id = due.scheduled_id +RETURNING `+scheduledMessageSelectColumnsFor("sm"), now, limit, leaseSeconds, retryFloor) + if err != nil { + return nil, fmt.Errorf("claim due scheduled messages: %w", err) + } + return scanScheduledMessages(rows) +} + +func (s *MessageStore) MarkScheduledMessageSent(ctx context.Context, ownerUserID int64, id, sentMessageID, date int) error { + if ownerUserID == 0 || id <= 0 { + return nil + } + beginner, ok := s.db.(txBeginner) + if !ok { + return fmt.Errorf("mark scheduled sent: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return fmt.Errorf("begin mark scheduled sent: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + if err := lockUsersForUpdate(ctx, tx, ownerUserID); err != nil { + return fmt.Errorf("lock scheduled sent owner: %w", err) + } + row := tx.QueryRow(ctx, ` +UPDATE scheduled_messages +SET state = 'sent', + sent_message_id = $3, + lease_until = 0, + updated_at = $4 +WHERE owner_user_id = $1 + AND scheduled_id = $2 +RETURNING peer_type, peer_id`, ownerUserID, id, sentMessageID, date) + var peerType string + var peerID int64 + if err := row.Scan(&peerType, &peerID); err != nil { + if err == pgx.ErrNoRows { + return nil + } + return fmt.Errorf("mark scheduled sent: %w", err) + } + if err := refreshHasScheduledTx(ctx, tx, ownerUserID, domain.Peer{Type: domain.PeerType(peerType), ID: peerID}); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit mark scheduled sent: %w", err) + } + committed = true + return nil +} + +func (s *MessageStore) ReleaseScheduledMessage(ctx context.Context, ownerUserID int64, id int, errText string) error { + if ownerUserID == 0 || id <= 0 { + return nil + } + _, err := s.db.Exec(ctx, ` +UPDATE scheduled_messages +SET state = 'pending', + lease_until = 0, + last_error = $3, + updated_at = EXTRACT(EPOCH FROM now())::int +WHERE owner_user_id = $1 + AND scheduled_id = $2 + AND state = 'dispatching'`, ownerUserID, id, errText) + if err != nil { + return fmt.Errorf("release scheduled message: %w", err) + } + return nil +} + +func (s *MessageStore) HasScheduledMessages(ctx context.Context, ownerUserID int64, peer domain.Peer) (bool, error) { + if ownerUserID == 0 || peer.ID == 0 { + return false, nil + } + var exists bool + if err := s.db.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM scheduled_messages + WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND state IN ('pending', 'dispatching') + LIMIT 1 +)::boolean`, ownerUserID, string(peer.Type), peer.ID).Scan(&exists); err != nil { + return false, fmt.Errorf("has scheduled messages: %w", err) + } + return exists, nil +} + +func listScheduledMessages(ctx context.Context, db interface { + Query(context.Context, string, ...any) (pgx.Rows, error) +}, filter domain.ScheduledMessageFilter, exact bool) (domain.ScheduledMessageList, error) { + if filter.OwnerUserID == 0 || filter.Peer.ID == 0 { + return domain.ScheduledMessageList{}, nil + } + limit := filter.Limit + if limit <= 0 || limit > maxScheduledMessagePage { + limit = maxScheduledMessagePage + } + var ( + rows pgx.Rows + err error + ) + if exact { + if len(filter.IDs) == 0 { + return domain.ScheduledMessageList{}, nil + } + rows, err = db.Query(ctx, ` +SELECT `+scheduledMessageSelectColumns()+` +FROM scheduled_messages +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND scheduled_id = ANY($4::int[]) + AND state IN ('pending', 'dispatching') +ORDER BY schedule_date ASC, scheduled_id ASC +LIMIT $5`, filter.OwnerUserID, string(filter.Peer.Type), filter.Peer.ID, intSliceToInt32(filter.IDs), limit) + } else { + rows, err = db.Query(ctx, ` +SELECT `+scheduledMessageSelectColumns()+` +FROM scheduled_messages +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND state IN ('pending', 'dispatching') +ORDER BY schedule_date ASC, scheduled_id ASC +LIMIT $4`, filter.OwnerUserID, string(filter.Peer.Type), filter.Peer.ID, limit) + } + if err != nil { + return domain.ScheduledMessageList{}, fmt.Errorf("list scheduled messages: %w", err) + } + msgs, err := scanScheduledMessages(rows) + if err != nil { + return domain.ScheduledMessageList{}, err + } + out := domain.ScheduledMessageList{Messages: msgs, Count: len(msgs), Hash: scheduledMessageListHash(msgs)} + if filter.Hash != 0 && filter.Hash == out.Hash { + out.Messages = nil + out.Count = 0 + } + return out, nil +} + +func scheduledMessageSelectColumns() string { + return `owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date, +body, entities::text, media::text, silent, noforwards, +reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, +quote_text, quote_entities::text, quote_offset, +fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, +send_as_peer_type, send_as_peer_id, +schedule_date, schedule_repeat_period, state, sent_message_id, created_at, updated_at` +} + +func scheduledMessageSelectColumnsFor(alias string) string { + if alias == "" { + return scheduledMessageSelectColumns() + } + prefix := alias + "." + return prefix + `owner_user_id, ` + prefix + `scheduled_id, ` + prefix + `peer_type, ` + prefix + `peer_id, ` + prefix + `random_id, ` + prefix + `message_date, +` + prefix + `body, ` + prefix + `entities::text, ` + prefix + `media::text, ` + prefix + `silent, ` + prefix + `noforwards, +` + prefix + `reply_to_msg_id, ` + prefix + `reply_to_peer_type, ` + prefix + `reply_to_peer_id, ` + prefix + `reply_to_top_id, +` + prefix + `quote_text, ` + prefix + `quote_entities::text, ` + prefix + `quote_offset, +` + prefix + `fwd_from_peer_type, ` + prefix + `fwd_from_peer_id, ` + prefix + `fwd_from_name, ` + prefix + `fwd_date, +` + prefix + `send_as_peer_type, ` + prefix + `send_as_peer_id, +` + prefix + `schedule_date, ` + prefix + `schedule_repeat_period, ` + prefix + `state, ` + prefix + `sent_message_id, ` + prefix + `created_at, ` + prefix + `updated_at` +} + +func scanScheduledMessages(rows pgx.Rows) ([]domain.ScheduledMessage, error) { + defer rows.Close() + out := make([]domain.ScheduledMessage, 0) + for rows.Next() { + msg, err := scanScheduledMessage(rows) + if err != nil { + return nil, err + } + out = append(out, msg) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate scheduled messages: %w", err) + } + return out, nil +} + +func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.ScheduledMessage, error) { + var ( + msg domain.ScheduledMessage + peerType string + entitiesJSON string + mediaJSON string + replyToMsgID int32 + replyToPeerType string + replyToPeerID int64 + replyToTopID int32 + quoteText string + quoteEntitiesJSON string + quoteOffset int32 + fwdFromPeerType string + fwdFromPeerID int64 + fwdFromName string + fwdDate int32 + sendAsPeerType string + sendAsPeerID int64 + scheduleRepeatPeriod int32 + ) + if err := scanner.Scan( + &msg.OwnerUserID, &msg.ID, &peerType, &msg.Peer.ID, &msg.RandomID, &msg.CreatedAt, + &msg.Message, &entitiesJSON, &mediaJSON, &msg.Silent, &msg.NoForwards, + &replyToMsgID, &replyToPeerType, &replyToPeerID, &replyToTopID, + "eText, "eEntitiesJSON, "eOffset, + &fwdFromPeerType, &fwdFromPeerID, &fwdFromName, &fwdDate, + &sendAsPeerType, &sendAsPeerID, + &msg.ScheduleDate, &scheduleRepeatPeriod, &msg.State, &msg.SentMessageID, &msg.CreatedAt, &msg.UpdatedAt, + ); err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("scan scheduled message: %w", err) + } + msg.Peer.Type = domain.PeerType(peerType) + msg.ScheduleRepeatPeriod = int(scheduleRepeatPeriod) + entities, err := decodeMessageEntities(entitiesJSON) + if err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled entities: %w", err) + } + msg.Entities = entities + media, err := decodeMessageMedia(mediaJSON) + if err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled media: %w", err) + } + msg.Media = media + // scheduled_messages 不存 saved_from:到点投递经 SendPrivateText 实时 + // 重算 saved 语义(self-chat 直发归 self),fwd saved 维度恒空。 + _, _, reply, forward, err := messageMetadataFromFields( + msg.Silent, msg.NoForwards, replyToMsgID, replyToPeerType, replyToPeerID, replyToTopID, + 0, // scheduled_messages 不持久化 story 回复(定时回复 story 是边角,恒 0) + quoteText, quoteEntitiesJSON, quoteOffset, + fwdFromPeerType, fwdFromPeerID, fwdFromName, fwdDate, + "", 0, 0, + ) + if err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled metadata: %w", err) + } + msg.ReplyTo = reply + msg.Forward = forward + if sendAsPeerType != "" && sendAsPeerID != 0 { + msg.SendAs = &domain.Peer{Type: domain.PeerType(sendAsPeerType), ID: sendAsPeerID} + } + return msg, nil +} + +func getPendingScheduledByRandomID(ctx context.Context, db pgx.Tx, ownerUserID, randomID int64) (domain.ScheduledMessage, bool, error) { + row := db.QueryRow(ctx, ` +SELECT `+scheduledMessageSelectColumns()+` +FROM scheduled_messages +WHERE owner_user_id = $1 + AND random_id = $2 + AND random_id <> 0 + AND state IN ('pending', 'dispatching') +ORDER BY scheduled_id ASC +LIMIT 1`, ownerUserID, randomID) + msg, err := scanScheduledMessage(row) + if err == nil { + return msg, true, nil + } + if errors.Is(err, pgx.ErrNoRows) { + return domain.ScheduledMessage{}, false, nil + } + return domain.ScheduledMessage{}, false, err +} + +func getScheduledByID(ctx context.Context, db pgx.Tx, ownerUserID int64, id int) (domain.ScheduledMessage, bool, error) { + row := db.QueryRow(ctx, ` +SELECT `+scheduledMessageSelectColumns()+` +FROM scheduled_messages +WHERE owner_user_id = $1 + AND scheduled_id = $2 +LIMIT 1`, ownerUserID, id) + msg, err := scanScheduledMessage(row) + if err == nil { + return msg, true, nil + } + if errors.Is(err, pgx.ErrNoRows) { + return domain.ScheduledMessage{}, false, nil + } + return domain.ScheduledMessage{}, false, err +} + +func getPendingScheduledForEdit(ctx context.Context, db pgx.Tx, ownerUserID int64, peer domain.Peer, id int) (domain.ScheduledMessage, bool, error) { + row := db.QueryRow(ctx, ` +SELECT `+scheduledMessageSelectColumns()+` +FROM scheduled_messages +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND scheduled_id = $4 + AND state = 'pending' +FOR UPDATE`, ownerUserID, string(peer.Type), peer.ID, id) + msg, err := scanScheduledMessage(row) + if err == nil { + return msg, true, nil + } + if errors.Is(err, pgx.ErrNoRows) { + return domain.ScheduledMessage{}, false, nil + } + return domain.ScheduledMessage{}, false, err +} + +func nextScheduledMessageID(ctx context.Context, db pgx.Tx, ownerUserID int64) (int, error) { + var id int + if err := db.QueryRow(ctx, ` +SELECT COALESCE(MAX(scheduled_id), 0)::int + 1 +FROM scheduled_messages +WHERE owner_user_id = $1`, ownerUserID).Scan(&id); err != nil { + return 0, fmt.Errorf("next scheduled message id: %w", err) + } + if id <= 0 || id > domain.MaxMessageBoxID { + return 0, domain.ErrMessageIDInvalid + } + return id, nil +} + +func setHasScheduledTx(ctx context.Context, db pgx.Tx, ownerUserID int64, peer domain.Peer, has bool) error { + switch peer.Type { + case domain.PeerTypeUser: + _, err := db.Exec(ctx, ` +INSERT INTO dialogs (user_id, peer_type, peer_id, has_scheduled) +VALUES ($1, 'user', $2, $3) +ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET + has_scheduled = EXCLUDED.has_scheduled, + updated_at = now()`, ownerUserID, peer.ID, has) + if err != nil { + return fmt.Errorf("set private has_scheduled: %w", err) + } + return nil + case domain.PeerTypeChannel: + _, err := db.Exec(ctx, ` +UPDATE channel_dialogs +SET has_scheduled = $3, + updated_at = now() +WHERE user_id = $1 + AND channel_id = $2`, ownerUserID, peer.ID, has) + if err != nil { + return fmt.Errorf("set channel has_scheduled: %w", err) + } + return nil + default: + return nil + } +} + +func refreshHasScheduledTx(ctx context.Context, db pgx.Tx, ownerUserID int64, peer domain.Peer) error { + var exists bool + if err := db.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM scheduled_messages + WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND state IN ('pending', 'dispatching') + LIMIT 1 +)::boolean`, ownerUserID, string(peer.Type), peer.ID).Scan(&exists); err != nil { + return fmt.Errorf("refresh scheduled flag: %w", err) + } + return setHasScheduledTx(ctx, db, ownerUserID, peer, exists) +} + +func intSliceToInt32(ids []int) []int32 { + out := make([]int32, 0, len(ids)) + for _, id := range ids { + if id > 0 && id <= domain.MaxMessageBoxID { + out = append(out, int32(id)) + } + } + return out +} + +func scheduledMessageListHash(messages []domain.ScheduledMessage) int64 { + h := fnv.New64a() + var buf [8]byte + for _, msg := range messages { + binary.LittleEndian.PutUint64(buf[:], uint64(msg.ID)) + _, _ = h.Write(buf[:]) + binary.LittleEndian.PutUint64(buf[:], uint64(msg.Peer.ID)) + _, _ = h.Write(buf[:]) + binary.LittleEndian.PutUint64(buf[:], uint64(msg.ScheduleDate)) + _, _ = h.Write(buf[:]) + binary.LittleEndian.PutUint64(buf[:], uint64(msg.UpdatedAt)) + _, _ = h.Write(buf[:]) + } + return int64(h.Sum64() & 0x7fffffffffffffff) +} diff --git a/internal/store/postgres/scheduled_message_integration_test.go b/internal/store/postgres/scheduled_message_integration_test.go new file mode 100644 index 00000000..950ed0c2 --- /dev/null +++ b/internal/store/postgres/scheduled_message_integration_test.go @@ -0,0 +1,110 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +func TestScheduledMessageEditPreservesContentWhenMessageUnset(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1777" + suffix + "01", FirstName: "ScheduledOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + peerUser, err := users.Create(ctx, domain.User{AccessHash: 72, Phone: "+1777" + suffix + "02", FirstName: "ScheduledPeer"}) + if err != nil { + t.Fatalf("create peer: %v", err) + } + ids := []int64{owner.ID, peerUser.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM scheduled_messages WHERE owner_user_id = $1", owner.ID) + _, _ = pool.Exec(ctx, "DELETE FROM dialogs WHERE user_id = ANY($1::bigint[])", ids) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", ids) + }) + + messages := NewMessageStore(pool) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerUser.ID} + media := &domain.MessageMedia{ + Kind: domain.MessageMediaKindDocument, + Document: &domain.Document{ + ID: 910000000000000101, + AccessHash: 9101, + DCID: 2, + MimeType: "application/x-tgsticker", + Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrSticker, Alt: "wave"}}, + }, + } + scheduled, err := messages.CreateScheduledMessage(ctx, domain.ScheduleMessageRequest{ + OwnerUserID: owner.ID, + Peer: peer, + RandomID: 7001, + Message: "", + Media: media, + ScheduleDate: 1700003600, + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create media scheduled message: %v", err) + } + + dateOnly, err := messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{ + OwnerUserID: owner.ID, + Peer: peer, + ID: scheduled.ID, + ScheduleDate: 1700007200, + Date: 1700000100, + }) + if err != nil { + t.Fatalf("date-only edit scheduled message: %v", err) + } + if dateOnly.Message != "" || dateOnly.ScheduleDate != 1700007200 || dateOnly.Media == nil || dateOnly.Media.Document == nil || dateOnly.Media.Document.ID != media.Document.ID { + t.Fatalf("date-only scheduled edit = %+v, want original media/content and new date", dateOnly) + } + + emptyCaption, err := messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{ + OwnerUserID: owner.ID, + Peer: peer, + ID: scheduled.ID, + SetMessage: true, + Message: "", + ScheduleDate: 1700010800, + Date: 1700000200, + }) + if err != nil { + t.Fatalf("empty-caption edit scheduled media: %v", err) + } + if emptyCaption.Message != "" || emptyCaption.ScheduleDate != 1700010800 || emptyCaption.Media == nil || emptyCaption.Media.Document == nil || emptyCaption.Media.Document.ID != media.Document.ID { + t.Fatalf("empty-caption scheduled edit = %+v, want media kept and new date", emptyCaption) + } + + textOnly, err := messages.CreateScheduledMessage(ctx, domain.ScheduleMessageRequest{ + OwnerUserID: owner.ID, + Peer: peer, + RandomID: 7002, + Message: "text", + ScheduleDate: 1700014400, + Date: 1700000300, + }) + if err != nil { + t.Fatalf("create text scheduled message: %v", err) + } + _, err = messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{ + OwnerUserID: owner.ID, + Peer: peer, + ID: textOnly.ID, + SetMessage: true, + Message: "", + ScheduleDate: 1700018000, + Date: 1700000400, + }) + if !errors.Is(err, domain.ErrMessageEmpty) { + t.Fatalf("empty text scheduled edit err = %v, want ErrMessageEmpty", err) + } +} diff --git a/internal/store/postgres/secretchat.go b/internal/store/postgres/secretchat.go new file mode 100644 index 00000000..da957fcb --- /dev/null +++ b/internal/store/postgres/secretchat.go @@ -0,0 +1,193 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// SecretChatStore 是 store.SecretChatStore 的 PostgreSQL 实现(迁移 0137)。 +// 盲中继:g_a/g_b/key_fingerprint 原样 BYTEA/BIGINT 存储;握手态迁移用条件 +// UPDATE 做原子 CAS(accept 绑定接受设备、discard 幂等)。行为契约与 memory +// 实现由 storetest 钉死。 +type SecretChatStore struct { + db sqlcgen.DBTX +} + +// NewSecretChatStore 基于 pgx 连接池创建 SecretChatStore。 +func NewSecretChatStore(db sqlcgen.DBTX) *SecretChatStore { + return &SecretChatStore{db: db} +} + +const secretChatColumns = `chat_id, admin_access_hash, participant_access_hash, admin_user_id, +admin_auth_key_id, participant_user_id, participant_auth_key_id, state, g_a, g_b, +key_fingerprint, layer, folder_id, history_deleted, random_id, date` + +func scanSecretChat(row rowScanner) (domain.SecretChat, error) { + var c domain.SecretChat + var state string + if err := row.Scan( + &c.ID, &c.AdminAccessHash, &c.ParticipantAccessHash, &c.AdminUserID, + &c.AdminAuthKeyID, &c.ParticipantUserID, &c.ParticipantAuthKeyID, &state, &c.GA, &c.GB, + &c.KeyFingerprint, &c.Layer, &c.FolderID, &c.HistoryDeleted, &c.RandomID, &c.Date, + ); err != nil { + return domain.SecretChat{}, err + } + c.State = domain.SecretChatState(state) + return c, nil +} + +func (s *SecretChatStore) CreateSecretChat(ctx context.Context, chat domain.SecretChat) error { + if chat.ID == 0 { + return domain.ErrSecretChatNotFound + } + if chat.State == "" { + chat.State = domain.SecretChatStateRequested + } + _, err := s.db.Exec(ctx, ` +INSERT INTO secret_chats (chat_id, admin_access_hash, participant_access_hash, admin_user_id, + admin_auth_key_id, participant_user_id, participant_auth_key_id, state, g_a, g_b, + key_fingerprint, layer, folder_id, history_deleted, random_id, date) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + chat.ID, chat.AdminAccessHash, chat.ParticipantAccessHash, chat.AdminUserID, + chat.AdminAuthKeyID, chat.ParticipantUserID, chat.ParticipantAuthKeyID, string(chat.State), + nullableBytes(chat.GA), nullableBytes(chat.GB), chat.KeyFingerprint, chat.Layer, + chat.FolderID, chat.HistoryDeleted, chat.RandomID, chat.Date) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + if pgErr.ConstraintName == "secret_chats_pkey" { + return domain.ErrSecretChatIDConflict + } + // uq_secret_chats_admin_random:与并发同 random_id 请求竞态(罕见, + // 服务端 GetByAdminRandom 预检已收口大多数情况)。 + return fmt.Errorf("insert secret chat: duplicate admin random: %w", err) + } + return fmt.Errorf("insert secret chat: %w", err) + } + return nil +} + +func (s *SecretChatStore) GetSecretChat(ctx context.Context, chatID int) (domain.SecretChat, bool, error) { + chat, err := scanSecretChat(s.db.QueryRow(ctx, + `SELECT `+secretChatColumns+` FROM secret_chats WHERE chat_id = $1`, chatID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SecretChat{}, false, nil + } + if err != nil { + return domain.SecretChat{}, false, fmt.Errorf("get secret chat: %w", err) + } + return chat, true, nil +} + +func (s *SecretChatStore) GetByAdminRandom(ctx context.Context, adminAuthKeyID int64, randomID int32) (domain.SecretChat, bool, error) { + chat, err := scanSecretChat(s.db.QueryRow(ctx, + `SELECT `+secretChatColumns+` FROM secret_chats +WHERE admin_auth_key_id = $1 AND random_id = $2 AND state <> 'discarded' LIMIT 1`, + adminAuthKeyID, randomID)) + if errors.Is(err, pgx.ErrNoRows) { + return domain.SecretChat{}, false, nil + } + if err != nil { + return domain.SecretChat{}, false, fmt.Errorf("get secret chat by admin random: %w", err) + } + return chat, true, nil +} + +func (s *SecretChatStore) AcceptSecretChat(ctx context.Context, chatID int, participantAuthKeyID int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error) { + // 原子 CAS:仅 requested 且未绑定接受设备时迁移到 normal。 + chat, err := scanSecretChat(s.db.QueryRow(ctx, ` +UPDATE secret_chats +SET state = 'normal', participant_auth_key_id = $2, g_b = $3, key_fingerprint = $4 +WHERE chat_id = $1 AND state = 'requested' AND participant_auth_key_id = 0 +RETURNING `+secretChatColumns, chatID, participantAuthKeyID, nullableBytes(gb), keyFingerprint)) + if err == nil { + return chat, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return domain.SecretChat{}, fmt.Errorf("accept secret chat: %w", err) + } + // CAS 落空:按当前态决定错误码。 + current, ok, gerr := s.GetSecretChat(ctx, chatID) + if gerr != nil { + return domain.SecretChat{}, gerr + } + if !ok { + return domain.SecretChat{}, domain.ErrSecretChatNotFound + } + switch current.State { + case domain.SecretChatStateDiscarded: + return domain.SecretChat{}, domain.ErrSecretChatAlreadyDeclined + default: + return domain.SecretChat{}, domain.ErrSecretChatAlreadyAccepted + } +} + +func (s *SecretChatStore) DiscardSecretChat(ctx context.Context, chatID int, historyDeleted bool) (domain.SecretChat, bool, error) { + chat, err := scanSecretChat(s.db.QueryRow(ctx, ` +UPDATE secret_chats +SET state = 'discarded', history_deleted = $2 +WHERE chat_id = $1 AND state <> 'discarded' +RETURNING `+secretChatColumns, chatID, historyDeleted)) + if err == nil { + return chat, false, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return domain.SecretChat{}, false, fmt.Errorf("discard secret chat: %w", err) + } + // 未更新:要么已是终态(幂等成功),要么不存在。 + current, ok, gerr := s.GetSecretChat(ctx, chatID) + if gerr != nil { + return domain.SecretChat{}, false, gerr + } + if !ok { + return domain.SecretChat{}, false, domain.ErrSecretChatNotFound + } + return current, true, nil +} + +func (s *SecretChatStore) ListActiveSecretChatsByAuthKey(ctx context.Context, authKeyID int64) ([]domain.SecretChat, error) { + if authKeyID == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, `SELECT `+secretChatColumns+` FROM secret_chats +WHERE state <> 'discarded' AND (admin_auth_key_id = $1 OR participant_auth_key_id = $1) +ORDER BY chat_id`, authKeyID) + if err != nil { + return nil, fmt.Errorf("list secret chats by auth key: %w", err) + } + defer rows.Close() + var out []domain.SecretChat + for rows.Next() { + chat, serr := scanSecretChat(rows) + if serr != nil { + return nil, fmt.Errorf("scan secret chat by auth key: %w", serr) + } + out = append(out, chat) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list secret chats by auth key rows: %w", err) + } + return out, nil +} + +func (s *SecretChatStore) MaxSecretChatID(ctx context.Context) (int, error) { + var id int + if err := s.db.QueryRow(ctx, `SELECT COALESCE(MAX(chat_id), 0) FROM secret_chats`).Scan(&id); err != nil { + return 0, fmt.Errorf("max secret chat id: %w", err) + } + return id, nil +} + +func nullableBytes(b []byte) any { + if len(b) == 0 { + return nil + } + return b +} diff --git a/internal/store/postgres/secretchat_integration_test.go b/internal/store/postgres/secretchat_integration_test.go new file mode 100644 index 00000000..fd42e666 --- /dev/null +++ b/internal/store/postgres/secretchat_integration_test.go @@ -0,0 +1,210 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +// TestSecretChatStorePostgres 验证密聊握手状态机 PG 实现的行为契约(与 memory 实现 +// 同构):create/get、幂等去重、accept CAS、double-accept、discard 幂等、 +// accept-after-discard、部分唯一索引(discard 后同 random_id 可重建)。 +// 门控于 TELESRV_TEST_POSTGRES_DSN。 +func TestSecretChatStorePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + store := NewSecretChatStore(pool) + + const ( + adminUser = int64(770001) + partUser = int64(770002) + adminKey = int64(0xA1) + partKey = int64(0xB2) + base = 7700001 + ) + // 隔离:清理本测试用到的行(测试库持久累积)。 + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM secret_chats WHERE admin_user_id = $1`, adminUser) + } + cleanup() + t.Cleanup(cleanup) + + mk := func(id int, randomID int32) domain.SecretChat { + return domain.SecretChat{ + ID: id, + AdminAccessHash: 111, + ParticipantAccessHash: 222, + AdminUserID: adminUser, + AdminAuthKeyID: adminKey, + ParticipantUserID: partUser, + State: domain.SecretChatStateRequested, + GA: []byte{0x0a, 0x0b, 0x0c}, + RandomID: randomID, + Date: 1000, + } + } + + // create + get round-trip。 + chat := mk(base, 555) + if err := store.CreateSecretChat(ctx, chat); err != nil { + t.Fatalf("create: %v", err) + } + got, found, err := store.GetSecretChat(ctx, base) + if err != nil || !found { + t.Fatalf("get: found=%v err=%v", found, err) + } + if got.AdminUserID != adminUser || got.RandomID != 555 || string(got.GA) != string(chat.GA) { + t.Fatalf("round-trip mismatch: %+v", got) + } + + // 重复 chat_id → ID conflict。 + if err := store.CreateSecretChat(ctx, mk(base, 556)); !errors.Is(err, domain.ErrSecretChatIDConflict) { + t.Fatalf("duplicate chat_id err = %v, want ErrSecretChatIDConflict", err) + } + + // 幂等查询(非终态)。 + idem, found, err := store.GetByAdminRandom(ctx, adminKey, 555) + if err != nil || !found || idem.ID != base { + t.Fatalf("GetByAdminRandom: found=%v id=%d err=%v", found, idem.ID, err) + } + + // accept CAS。 + accepted, err := store.AcceptSecretChat(ctx, base, partKey, []byte{0x0d, 0x0e}, 0x99) + if err != nil { + t.Fatalf("accept: %v", err) + } + if accepted.State != domain.SecretChatStateNormal || accepted.ParticipantAuthKeyID != partKey || + accepted.KeyFingerprint != 0x99 || string(accepted.GB) != string([]byte{0x0d, 0x0e}) { + t.Fatalf("accepted = %+v", accepted) + } + + // double accept → already accepted。 + if _, err := store.AcceptSecretChat(ctx, base, int64(0xC3), []byte{0x01}, 1); !errors.Is(err, domain.ErrSecretChatAlreadyAccepted) { + t.Fatalf("double accept err = %v, want ErrSecretChatAlreadyAccepted", err) + } + + // discard。 + _, already, err := store.DiscardSecretChat(ctx, base, true) + if err != nil || already { + t.Fatalf("discard: already=%v err=%v", already, err) + } + // 幂等 discard。 + cur, already, err := store.DiscardSecretChat(ctx, base, false) + if err != nil || !already || cur.State != domain.SecretChatStateDiscarded { + t.Fatalf("idempotent discard: already=%v state=%v err=%v", already, cur.State, err) + } + + // accept after discard → already declined。 + if _, err := store.AcceptSecretChat(ctx, base, partKey, []byte{0x01}, 1); !errors.Is(err, domain.ErrSecretChatAlreadyDeclined) { + t.Fatalf("accept after discard err = %v, want ErrSecretChatAlreadyDeclined", err) + } + + // 部分唯一索引:discarded 旧 chat 不阻塞同 (admin_auth_key_id, random_id) 重建。 + if err := store.CreateSecretChat(ctx, mk(base+1, 555)); err != nil { + t.Fatalf("recreate after discard with same random_id: %v", err) + } + + // MaxSecretChatID 反映最大 chat_id(≥ base+1)。 + maxID, err := store.MaxSecretChatID(ctx) + if err != nil || maxID < base+1 { + t.Fatalf("max chat id = %d err = %v, want >= %d", maxID, err, base+1) + } + + // 不存在 chat:accept/discard 返回 not found。 + if _, err := store.AcceptSecretChat(ctx, base+999, partKey, []byte{0x01}, 1); !errors.Is(err, domain.ErrSecretChatNotFound) { + t.Fatalf("accept missing err = %v, want ErrSecretChatNotFound", err) + } + if _, _, err := store.DiscardSecretChat(ctx, base+999, false); !errors.Is(err, domain.ErrSecretChatNotFound) { + t.Fatalf("discard missing err = %v, want ErrSecretChatNotFound", err) + } +} + +// TestSecretChatListActiveByAuthKeyPostgres 验证 ListActiveSecretChatsByAuthKey(登出/撤销 +// 级联 discard 的查询基础,P1 修复):匹配 admin 或 participant auth_key、排除 discarded、 +// 按 chat_id 升序;与 memory 实现同构。 +func TestSecretChatListActiveByAuthKeyPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + store := NewSecretChatStore(pool) + + const ( + adminUser = int64(770101) + partUser = int64(770102) + keyA = int64(0xAA01) // admin 设备 key + keyB = int64(0xBB02) // participant 设备 key + keyOther = int64(0xCC03) + base = 7701001 + ) + cleanup := func() { _, _ = pool.Exec(ctx, `DELETE FROM secret_chats WHERE admin_user_id IN ($1, $2)`, adminUser, partUser) } + cleanup() + t.Cleanup(cleanup) + + mk := func(id int, randomID int32, adminUID, adminKey, partUID, partKeyV int64, state domain.SecretChatState) domain.SecretChat { + return domain.SecretChat{ + ID: id, AdminAccessHash: 1, ParticipantAccessHash: 2, + AdminUserID: adminUID, AdminAuthKeyID: adminKey, + ParticipantUserID: partUID, ParticipantAuthKeyID: partKeyV, + State: state, GA: []byte{0x01}, RandomID: randomID, Date: 1, + } + } + idsOf := func(chats []domain.SecretChat) []int { + out := make([]int, len(chats)) + for i, c := range chats { + out[i] = c.ID + } + return out + } + eq := func(got, want []int) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true + } + + // chat1 admin=keyA participant=keyB normal;chat2 admin=keyA 未绑定 requested; + // chat3 admin=keyOther participant=keyB normal;chat4 admin=keyA participant=keyB 已 discard。 + for _, c := range []domain.SecretChat{ + mk(base+1, 1, adminUser, keyA, partUser, keyB, domain.SecretChatStateNormal), + mk(base+2, 2, adminUser, keyA, partUser, 0, domain.SecretChatStateRequested), + mk(base+3, 3, partUser, keyOther, adminUser, keyB, domain.SecretChatStateNormal), + mk(base+4, 4, adminUser, keyA, partUser, keyB, domain.SecretChatStateNormal), + } { + if err := store.CreateSecretChat(ctx, c); err != nil { + t.Fatalf("create %d: %v", c.ID, err) + } + } + if _, _, err := store.DiscardSecretChat(ctx, base+4, false); err != nil { + t.Fatalf("discard chat4: %v", err) + } + + // keyA(作为 admin)→ chat1, chat2(chat4 已 discard 排除)。 + got, err := store.ListActiveSecretChatsByAuthKey(ctx, keyA) + if err != nil { + t.Fatalf("list keyA: %v", err) + } + if ids := idsOf(got); !eq(ids, []int{base + 1, base + 2}) { + t.Fatalf("keyA active = %v, want [%d %d]", ids, base+1, base+2) + } + // keyB(作为 participant)→ chat1, chat3(chat4 已 discard 排除)。 + got, err = store.ListActiveSecretChatsByAuthKey(ctx, keyB) + if err != nil { + t.Fatalf("list keyB: %v", err) + } + if ids := idsOf(got); !eq(ids, []int{base + 1, base + 3}) { + t.Fatalf("keyB active = %v, want [%d %d]", ids, base+1, base+3) + } + // 未知 key → 空;authKeyID 0 → nil。 + if got, err := store.ListActiveSecretChatsByAuthKey(ctx, 0x9999); err != nil || len(got) != 0 { + t.Fatalf("unknown key = %d (err=%v), want 0", len(got), err) + } + if got, err := store.ListActiveSecretChatsByAuthKey(ctx, 0); err != nil || got != nil { + t.Fatalf("zero key = %v (err=%v), want nil", got, err) + } +} diff --git a/internal/store/postgres/sqlcgen/authorization.sql.go b/internal/store/postgres/sqlcgen/authorization.sql.go index 9739ea5f..d5b6952b 100644 --- a/internal/store/postgres/sqlcgen/authorization.sql.go +++ b/internal/store/postgres/sqlcgen/authorization.sql.go @@ -19,7 +19,7 @@ func (q *Queries) DeleteAuthorization(ctx context.Context, authKeyID int64) erro } const getAuthorizationByAuthKey = `-- name: GetAuthorizationByAuthKey :one -SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at FROM authorizations WHERE auth_key_id = $1 +SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at, password_pending FROM authorizations WHERE auth_key_id = $1 ` func (q *Queries) GetAuthorizationByAuthKey(ctx context.Context, authKeyID int64) (Authorization, error) { @@ -38,12 +38,13 @@ func (q *Queries) GetAuthorizationByAuthKey(ctx context.Context, authKeyID int64 &i.Ip, &i.CreatedAt, &i.ActiveAt, + &i.PasswordPending, ) return i, err } const listAuthorizationsByUser = `-- name: ListAuthorizationsByUser :many -SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at FROM authorizations +SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at, password_pending FROM authorizations WHERE user_id = $1 ORDER BY active_at DESC, auth_key_id DESC ` @@ -70,6 +71,7 @@ func (q *Queries) ListAuthorizationsByUser(ctx context.Context, userID int64) ([ &i.Ip, &i.CreatedAt, &i.ActiveAt, + &i.PasswordPending, ); err != nil { return nil, err } diff --git a/internal/store/postgres/sqlcgen/bot.sql.go b/internal/store/postgres/sqlcgen/bot.sql.go new file mode 100644 index 00000000..d2be43e7 --- /dev/null +++ b/internal/store/postgres/sqlcgen/bot.sql.go @@ -0,0 +1,440 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: bot.sql + +package sqlcgen + +import ( + "context" +) + +const allowBotSendMessage = `-- name: AllowBotSendMessage :one +WITH inserted AS ( + INSERT INTO bot_user_permissions (bot_user_id, user_id, from_request) + VALUES ($1, $2, $3) + ON CONFLICT (bot_user_id, user_id) DO NOTHING + RETURNING 1 +), +updated AS ( + UPDATE bot_user_permissions + SET from_request = bot_user_permissions.from_request OR $3, + updated_at = now() + WHERE bot_user_id = $1 AND user_id = $2 + AND NOT EXISTS (SELECT 1 FROM inserted) + AND $3 + AND NOT from_request + RETURNING 1 +) +SELECT EXISTS (SELECT 1 FROM inserted) AS created +` + +type AllowBotSendMessageParams struct { + BotUserID int64 + UserID int64 + FromRequest bool +} + +func (q *Queries) AllowBotSendMessage(ctx context.Context, arg AllowBotSendMessageParams) (bool, error) { + row := q.db.QueryRow(ctx, allowBotSendMessage, arg.BotUserID, arg.UserID, arg.FromRequest) + var created bool + err := row.Scan(&created) + return created, err +} + +const bumpBotInfoVersion = `-- name: BumpBotInfoVersion :one +UPDATE users +SET bot_info_version = bot_info_version + 1, updated_at = now() +WHERE id = $1 AND is_bot +RETURNING bot_info_version +` + +func (q *Queries) BumpBotInfoVersion(ctx context.Context, id int64) (int32, error) { + row := q.db.QueryRow(ctx, bumpBotInfoVersion, id) + var bot_info_version int32 + err := row.Scan(&bot_info_version) + return bot_info_version, err +} + +const canBotSendMessage = `-- name: CanBotSendMessage :one +SELECT EXISTS ( + SELECT 1 + FROM bot_user_permissions + WHERE bot_user_id = $1 AND user_id = $2 +) +` + +type CanBotSendMessageParams struct { + BotUserID int64 + UserID int64 +} + +func (q *Queries) CanBotSendMessage(ctx context.Context, arg CanBotSendMessageParams) (bool, error) { + row := q.db.QueryRow(ctx, canBotSendMessage, arg.BotUserID, arg.UserID) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const countBotsByOwner = `-- name: CountBotsByOwner :one +SELECT COUNT(*) FROM bots WHERE owner_user_id = $1 AND bot_user_id <> owner_user_id +` + +func (q *Queries) CountBotsByOwner(ctx context.Context, ownerUserID int64) (int64, error) { + row := q.db.QueryRow(ctx, countBotsByOwner, ownerUserID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const deleteBotChatState = `-- name: DeleteBotChatState :exec +DELETE FROM bot_chat_states WHERE bot_user_id = $1 AND user_id = $2 +` + +type DeleteBotChatStateParams struct { + BotUserID int64 + UserID int64 +} + +func (q *Queries) DeleteBotChatState(ctx context.Context, arg DeleteBotChatStateParams) error { + _, err := q.db.Exec(ctx, deleteBotChatState, arg.BotUserID, arg.UserID) + return err +} + +const getBot = `-- name: GetBot :one +SELECT bot_user_id, owner_user_id, token_secret, description, commands, bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at, menu_button_type, menu_button_text, menu_button_url, bot_inline_geo FROM bots WHERE bot_user_id = $1 +` + +func (q *Queries) GetBot(ctx context.Context, botUserID int64) (Bot, error) { + row := q.db.QueryRow(ctx, getBot, botUserID) + var i Bot + err := row.Scan( + &i.BotUserID, + &i.OwnerUserID, + &i.TokenSecret, + &i.Description, + &i.Commands, + &i.BotChatHistory, + &i.BotNochats, + &i.InlinePlaceholder, + &i.CreatedAt, + &i.UpdatedAt, + &i.MenuButtonType, + &i.MenuButtonText, + &i.MenuButtonUrl, + &i.BotInlineGeo, + ) + return i, err +} + +const getBotChatState = `-- name: GetBotChatState :one +SELECT bot_user_id, user_id, state, updated_at FROM bot_chat_states WHERE bot_user_id = $1 AND user_id = $2 +` + +type GetBotChatStateParams struct { + BotUserID int64 + UserID int64 +} + +func (q *Queries) GetBotChatState(ctx context.Context, arg GetBotChatStateParams) (BotChatState, error) { + row := q.db.QueryRow(ctx, getBotChatState, arg.BotUserID, arg.UserID) + var i BotChatState + err := row.Scan( + &i.BotUserID, + &i.UserID, + &i.State, + &i.UpdatedAt, + ) + return i, err +} + +const insertBot = `-- name: InsertBot :exec +INSERT INTO bots (bot_user_id, owner_user_id, token_secret) +VALUES ($1, $2, $3) +` + +type InsertBotParams struct { + BotUserID int64 + OwnerUserID int64 + TokenSecret string +} + +func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error { + _, err := q.db.Exec(ctx, insertBot, arg.BotUserID, arg.OwnerUserID, arg.TokenSecret) + return err +} + +const insertBotUser = `-- name: InsertBotUser :one +INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version) +VALUES ($1, '', $2, '', $3, '', TRUE, 1) +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +` + +type InsertBotUserParams struct { + AccessHash int64 + FirstName string + Username string +} + +func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (User, error) { + row := q.db.QueryRow(ctx, insertBotUser, arg.AccessHash, arg.FirstName, arg.Username) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + ) + return i, err +} + +const listBotsByOwner = `-- name: ListBotsByOwner :many +SELECT bot_user_id, owner_user_id, token_secret, description, commands, bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at, menu_button_type, menu_button_text, menu_button_url, bot_inline_geo FROM bots WHERE owner_user_id = $1 AND bot_user_id <> owner_user_id ORDER BY bot_user_id +` + +// 排除 self-owner 种子行(内置 BotFather 自有),与 memory 实现一致:/mybots 与 +// 创建上限只统计用户经 /newbot 创建的 bot。 +func (q *Queries) ListBotsByOwner(ctx context.Context, ownerUserID int64) ([]Bot, error) { + rows, err := q.db.Query(ctx, listBotsByOwner, ownerUserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Bot + for rows.Next() { + var i Bot + if err := rows.Scan( + &i.BotUserID, + &i.OwnerUserID, + &i.TokenSecret, + &i.Description, + &i.Commands, + &i.BotChatHistory, + &i.BotNochats, + &i.InlinePlaceholder, + &i.CreatedAt, + &i.UpdatedAt, + &i.MenuButtonType, + &i.MenuButtonText, + &i.MenuButtonUrl, + &i.BotInlineGeo, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setBotChatHistoryRow = `-- name: SetBotChatHistoryRow :execrows +UPDATE bots SET bot_chat_history = $2, updated_at = now() WHERE bot_user_id = $1 +` + +type SetBotChatHistoryRowParams struct { + BotUserID int64 + BotChatHistory bool +} + +func (q *Queries) SetBotChatHistoryRow(ctx context.Context, arg SetBotChatHistoryRowParams) (int64, error) { + result, err := q.db.Exec(ctx, setBotChatHistoryRow, arg.BotUserID, arg.BotChatHistory) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setBotInlineGeoRow = `-- name: SetBotInlineGeoRow :execrows +UPDATE bots SET bot_inline_geo = $2, updated_at = now() WHERE bot_user_id = $1 +` + +type SetBotInlineGeoRowParams struct { + BotUserID int64 + BotInlineGeo bool +} + +func (q *Queries) SetBotInlineGeoRow(ctx context.Context, arg SetBotInlineGeoRowParams) (int64, error) { + result, err := q.db.Exec(ctx, setBotInlineGeoRow, arg.BotUserID, arg.BotInlineGeo) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setBotInlinePlaceholderRow = `-- name: SetBotInlinePlaceholderRow :execrows +UPDATE bots SET inline_placeholder = $2, updated_at = now() WHERE bot_user_id = $1 +` + +type SetBotInlinePlaceholderRowParams struct { + BotUserID int64 + InlinePlaceholder string +} + +func (q *Queries) SetBotInlinePlaceholderRow(ctx context.Context, arg SetBotInlinePlaceholderRowParams) (int64, error) { + result, err := q.db.Exec(ctx, setBotInlinePlaceholderRow, arg.BotUserID, arg.InlinePlaceholder) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setBotNochatsRow = `-- name: SetBotNochatsRow :execrows +UPDATE bots SET bot_nochats = $2, updated_at = now() WHERE bot_user_id = $1 +` + +type SetBotNochatsRowParams struct { + BotUserID int64 + BotNochats bool +} + +func (q *Queries) SetBotNochatsRow(ctx context.Context, arg SetBotNochatsRowParams) (int64, error) { + result, err := q.db.Exec(ctx, setBotNochatsRow, arg.BotUserID, arg.BotNochats) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const updateBotCommandsRow = `-- name: UpdateBotCommandsRow :execrows +UPDATE bots SET commands = $2, updated_at = now() WHERE bot_user_id = $1 +` + +type UpdateBotCommandsRowParams struct { + BotUserID int64 + Commands []byte +} + +func (q *Queries) UpdateBotCommandsRow(ctx context.Context, arg UpdateBotCommandsRowParams) (int64, error) { + result, err := q.db.Exec(ctx, updateBotCommandsRow, arg.BotUserID, arg.Commands) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const updateBotDescriptionRow = `-- name: UpdateBotDescriptionRow :execrows +UPDATE bots SET description = $2, updated_at = now() WHERE bot_user_id = $1 +` + +type UpdateBotDescriptionRowParams struct { + BotUserID int64 + Description string +} + +func (q *Queries) UpdateBotDescriptionRow(ctx context.Context, arg UpdateBotDescriptionRowParams) (int64, error) { + result, err := q.db.Exec(ctx, updateBotDescriptionRow, arg.BotUserID, arg.Description) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const updateBotMenuButtonRow = `-- name: UpdateBotMenuButtonRow :execrows +UPDATE bots +SET menu_button_type = $2, menu_button_text = $3, menu_button_url = $4, updated_at = now() +WHERE bot_user_id = $1 +` + +type UpdateBotMenuButtonRowParams struct { + BotUserID int64 + MenuButtonType int16 + MenuButtonText string + MenuButtonUrl string +} + +func (q *Queries) UpdateBotMenuButtonRow(ctx context.Context, arg UpdateBotMenuButtonRowParams) (int64, error) { + result, err := q.db.Exec(ctx, updateBotMenuButtonRow, + arg.BotUserID, + arg.MenuButtonType, + arg.MenuButtonText, + arg.MenuButtonUrl, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const updateBotProfileFields = `-- name: UpdateBotProfileFields :one +UPDATE users +SET first_name = COALESCE($2, first_name), + about = COALESCE($3, about), + updated_at = now() +WHERE id = $1 AND is_bot +RETURNING bot_info_version +` + +type UpdateBotProfileFieldsParams struct { + ID int64 + FirstName *string + About *string +} + +// 部分更新 bot 的 users 行(setBotInfo 的 name/about);NULL 参数表示不动该字段。 +func (q *Queries) UpdateBotProfileFields(ctx context.Context, arg UpdateBotProfileFieldsParams) (int32, error) { + row := q.db.QueryRow(ctx, updateBotProfileFields, arg.ID, arg.FirstName, arg.About) + var bot_info_version int32 + err := row.Scan(&bot_info_version) + return bot_info_version, err +} + +const updateBotTokenSecret = `-- name: UpdateBotTokenSecret :execrows +UPDATE bots SET token_secret = $2, updated_at = now() WHERE bot_user_id = $1 +` + +type UpdateBotTokenSecretParams struct { + BotUserID int64 + TokenSecret string +} + +func (q *Queries) UpdateBotTokenSecret(ctx context.Context, arg UpdateBotTokenSecretParams) (int64, error) { + result, err := q.db.Exec(ctx, updateBotTokenSecret, arg.BotUserID, arg.TokenSecret) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const upsertBotChatState = `-- name: UpsertBotChatState :exec +INSERT INTO bot_chat_states (bot_user_id, user_id, state, updated_at) +VALUES ($1, $2, $3, now()) +ON CONFLICT (bot_user_id, user_id) DO UPDATE SET state = EXCLUDED.state, updated_at = now() +` + +type UpsertBotChatStateParams struct { + BotUserID int64 + UserID int64 + State []byte +} + +func (q *Queries) UpsertBotChatState(ctx context.Context, arg UpsertBotChatStateParams) error { + _, err := q.db.Exec(ctx, upsertBotChatState, arg.BotUserID, arg.UserID, arg.State) + return err +} diff --git a/internal/store/postgres/sqlcgen/contact.sql.go b/internal/store/postgres/sqlcgen/contact.sql.go index ca02eadc..9142a2cf 100644 --- a/internal/store/postgres/sqlcgen/contact.sql.go +++ b/internal/store/postgres/sqlcgen/contact.sql.go @@ -7,6 +7,8 @@ package sqlcgen import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const deleteContacts = `-- name: DeleteContacts :one @@ -45,6 +47,7 @@ const getContact = `-- name: GetContact :one SELECT c.contact_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -59,6 +62,11 @@ SELECT u.country_code, u.verified, u.support, + u.is_bot, + u.bot_info_version, + u.premium_expires_at, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -72,23 +80,29 @@ type GetContactParams struct { } type GetContactRow struct { - ContactUserID int64 - Mutual bool - ContactPhone string - ContactFirstName string - ContactLastName string - Note string - NoteEntitiesJson string - ID int64 - AccessHash int64 - Phone string - FirstName string - LastName string - Username string - CountryCode string - Verified bool - Support bool - LastSeenAt int64 + ContactUserID int64 + Mutual bool + CloseFriend bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + LastSeenAt int64 } func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetContactRow, error) { @@ -97,6 +111,7 @@ func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetCont err := row.Scan( &i.ContactUserID, &i.Mutual, + &i.CloseFriend, &i.ContactPhone, &i.ContactFirstName, &i.ContactLastName, @@ -111,6 +126,11 @@ func (q *Queries) GetContact(ctx context.Context, arg GetContactParams) (GetCont &i.CountryCode, &i.Verified, &i.Support, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, &i.LastSeenAt, ) return i, err @@ -120,6 +140,7 @@ const listContactsByUser = `-- name: ListContactsByUser :many SELECT c.contact_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -134,6 +155,11 @@ SELECT u.country_code, u.verified, u.support, + u.is_bot, + u.bot_info_version, + u.premium_expires_at, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at FROM contacts c JOIN users u ON u.id = c.contact_user_id @@ -142,23 +168,29 @@ ORDER BY c.contact_first_name, c.contact_last_name, u.first_name, u.last_name, u ` type ListContactsByUserRow struct { - ContactUserID int64 - Mutual bool - ContactPhone string - ContactFirstName string - ContactLastName string - Note string - NoteEntitiesJson string - ID int64 - AccessHash int64 - Phone string - FirstName string - LastName string - Username string - CountryCode string - Verified bool - Support bool - LastSeenAt int64 + ContactUserID int64 + Mutual bool + CloseFriend bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + LastSeenAt int64 } func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListContactsByUserRow, error) { @@ -173,6 +205,7 @@ func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListC if err := rows.Scan( &i.ContactUserID, &i.Mutual, + &i.CloseFriend, &i.ContactPhone, &i.ContactFirstName, &i.ContactLastName, @@ -187,6 +220,11 @@ func (q *Queries) ListContactsByUser(ctx context.Context, userID int64) ([]ListC &i.CountryCode, &i.Verified, &i.Support, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, &i.LastSeenAt, ); err != nil { return nil, err @@ -207,11 +245,12 @@ WITH updated AS ( updated_at = now() WHERE c.user_id = $3::bigint AND c.contact_user_id = $4::bigint - RETURNING user_id, contact_user_id, mutual, created_at, updated_at, contact_phone, contact_first_name, contact_last_name, note, note_entities, close_friend, stories_hidden + RETURNING user_id, contact_user_id, mutual, created_at, updated_at, contact_phone, contact_first_name, contact_last_name, note, note_entities, close_friend, stories_hidden, personal_photo_id, personal_photo_date ) SELECT c.contact_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -226,6 +265,11 @@ SELECT u.country_code, u.verified, u.support, + u.is_bot, + u.bot_info_version, + u.premium_expires_at, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at FROM updated c JOIN users u ON u.id = c.contact_user_id @@ -239,23 +283,29 @@ type UpdateContactNoteParams struct { } type UpdateContactNoteRow struct { - ContactUserID int64 - Mutual bool - ContactPhone string - ContactFirstName string - ContactLastName string - Note string - NoteEntitiesJson string - ID int64 - AccessHash int64 - Phone string - FirstName string - LastName string - Username string - CountryCode string - Verified bool - Support bool - LastSeenAt int64 + ContactUserID int64 + Mutual bool + CloseFriend bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + LastSeenAt int64 } func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNoteParams) (UpdateContactNoteRow, error) { @@ -269,6 +319,7 @@ func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNotePa err := row.Scan( &i.ContactUserID, &i.Mutual, + &i.CloseFriend, &i.ContactPhone, &i.ContactFirstName, &i.ContactLastName, @@ -283,6 +334,11 @@ func (q *Queries) UpdateContactNote(ctx context.Context, arg UpdateContactNotePa &i.CountryCode, &i.Verified, &i.Support, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, &i.LastSeenAt, ) return i, err @@ -326,7 +382,7 @@ upserted AS ( note_entities = EXCLUDED.note_entities, mutual = contacts.mutual OR EXCLUDED.mutual, updated_at = now() - RETURNING user_id, contact_user_id, mutual, created_at, updated_at, contact_phone, contact_first_name, contact_last_name, note, note_entities, close_friend, stories_hidden + RETURNING user_id, contact_user_id, mutual, created_at, updated_at, contact_phone, contact_first_name, contact_last_name, note, note_entities, close_friend, stories_hidden, personal_photo_id, personal_photo_date ), reverse_updated AS ( UPDATE contacts c @@ -340,6 +396,7 @@ reverse_updated AS ( SELECT c.contact_user_id, c.mutual, + c.close_friend, c.contact_phone, c.contact_first_name, c.contact_last_name, @@ -354,6 +411,11 @@ SELECT u.country_code, u.verified, u.support, + u.is_bot, + u.bot_info_version, + u.premium_expires_at, + u.emoji_status_document_id, + u.emoji_status_until, u.last_seen_at, EXISTS (SELECT 1 FROM reverse_updated)::boolean AS reverse_mutual_changed FROM upserted c @@ -371,24 +433,30 @@ type UpsertContactParams struct { } type UpsertContactRow struct { - ContactUserID int64 - Mutual bool - ContactPhone string - ContactFirstName string - ContactLastName string - Note string - NoteEntitiesJson string - ID int64 - AccessHash int64 - Phone string - FirstName string - LastName string - Username string - CountryCode string - Verified bool - Support bool - LastSeenAt int64 - ReverseMutualChanged bool + ContactUserID int64 + Mutual bool + CloseFriend bool + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntitiesJson string + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + Verified bool + Support bool + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + LastSeenAt int64 + ReverseMutualChanged bool } func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (UpsertContactRow, error) { @@ -405,6 +473,7 @@ func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (U err := row.Scan( &i.ContactUserID, &i.Mutual, + &i.CloseFriend, &i.ContactPhone, &i.ContactFirstName, &i.ContactLastName, @@ -419,6 +488,11 @@ func (q *Queries) UpsertContact(ctx context.Context, arg UpsertContactParams) (U &i.CountryCode, &i.Verified, &i.Support, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, &i.LastSeenAt, &i.ReverseMutualChanged, ) diff --git a/internal/store/postgres/sqlcgen/dialog.sql.go b/internal/store/postgres/sqlcgen/dialog.sql.go index bdec78a9..76ce62da 100644 --- a/internal/store/postgres/sqlcgen/dialog.sql.go +++ b/internal/store/postgres/sqlcgen/dialog.sql.go @@ -9,6 +9,32 @@ import ( "context" ) +const advanceDialogReadInboxFloor = `-- name: AdvanceDialogReadInboxFloor :exec +UPDATE dialogs +SET read_inbox_max_id = GREATEST(read_inbox_max_id, $1::int), + updated_at = now() +WHERE user_id = $2 + AND peer_type = $3 + AND peer_id = $4 +` + +type AdvanceDialogReadInboxFloorParams struct { + ReadInboxMaxID int32 + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) AdvanceDialogReadInboxFloor(ctx context.Context, arg AdvanceDialogReadInboxFloorParams) error { + _, err := q.db.Exec(ctx, advanceDialogReadInboxFloor, + arg.ReadInboxMaxID, + arg.UserID, + arg.PeerType, + arg.PeerID, + ) + return err +} + const clearDialogAfterHistoryDelete = `-- name: ClearDialogAfterHistoryDelete :exec UPDATE dialogs d SET @@ -19,7 +45,15 @@ SET unread_count = 0, unread_mark = false, unread_mentions_count = 0, - unread_reactions_count = 0, + unread_reactions_count = ( + SELECT COUNT(*)::int + FROM message_boxes m2 + WHERE m2.owner_user_id = d.user_id + AND m2.peer_type = d.peer_type + AND m2.peer_id = d.peer_id + AND NOT m2.deleted + AND m2.reaction_unread + ), updated_at = now() WHERE d.user_id = $1::bigint AND d.peer_type = $2::text @@ -86,10 +120,10 @@ func (q *Queries) ClearDialogDrafts(ctx context.Context, arg ClearDialogDraftsPa const clearPinnedDialogsNotInOrder = `-- name: ClearPinnedDialogsNotInOrder :exec WITH requested AS ( SELECT - ($2::text[])[i] AS peer_type, - ($3::bigint[])[i] AS peer_id - FROM generate_subscripts($3::bigint[], 1) AS g(i) - WHERE i <= cardinality($2::text[]) + ($3::text[])[i] AS peer_type, + ($4::bigint[])[i] AS peer_id + FROM generate_subscripts($4::bigint[], 1) AS g(i) + WHERE i <= cardinality($3::text[]) ) UPDATE dialogs d SET pinned = false, @@ -97,6 +131,7 @@ SET pinned = false, updated_at = now() WHERE d.user_id = $1::bigint AND d.pinned + AND d.folder_id = $2::int AND NOT EXISTS ( SELECT 1 FROM requested r @@ -107,20 +142,55 @@ WHERE d.user_id = $1::bigint type ClearPinnedDialogsNotInOrderParams struct { UserID int64 + FolderID int32 PeerTypes []string PeerIds []int64 } func (q *Queries) ClearPinnedDialogsNotInOrder(ctx context.Context, arg ClearPinnedDialogsNotInOrderParams) error { - _, err := q.db.Exec(ctx, clearPinnedDialogsNotInOrder, arg.UserID, arg.PeerTypes, arg.PeerIds) + _, err := q.db.Exec(ctx, clearPinnedDialogsNotInOrder, + arg.UserID, + arg.FolderID, + arg.PeerTypes, + arg.PeerIds, + ) return err } -const deleteDialogByPeer = `-- name: DeleteDialogByPeer :exec -DELETE FROM dialogs +const countArchiveUnreadDialogs = `-- name: CountArchiveUnreadDialogs :one +SELECT + COUNT(*) FILTER (WHERE unread_count > 0 OR unread_mark)::int AS unread_peers, + COALESCE(SUM(unread_count), 0)::int AS unread_messages +FROM dialogs WHERE user_id = $1 - AND peer_type = $2 - AND peer_id = $3 + AND folder_id = 1 +` + +type CountArchiveUnreadDialogsRow struct { + UnreadPeers int32 + UnreadMessages int32 +} + +// 归档(folder_id=1)未读聚合:有未读或手动标记未读的会话数 + 未读消息总数。 +func (q *Queries) CountArchiveUnreadDialogs(ctx context.Context, userID int64) (CountArchiveUnreadDialogsRow, error) { + row := q.db.QueryRow(ctx, countArchiveUnreadDialogs, userID) + var i CountArchiveUnreadDialogsRow + err := row.Scan(&i.UnreadPeers, &i.UnreadMessages) + return i, err +} + +const deleteDialogByPeer = `-- name: DeleteDialogByPeer :exec +WITH dropped_drafts AS ( + -- 删除会话同时丢弃该 peer 的云草稿,避免对端重建会话后旧草稿复活。 + DELETE FROM dialog_drafts dd + WHERE dd.user_id = $1::bigint + AND dd.peer_type = $2::text + AND dd.peer_id = $3::bigint +) +DELETE FROM dialogs d +WHERE d.user_id = $1::bigint + AND d.peer_type = $2::text + AND d.peer_id = $3::bigint ` type DeleteDialogByPeerParams struct { @@ -202,6 +272,8 @@ deduped AS ( ) UPDATE dialogs d SET folder_id = deduped.folder_id, + pinned = CASE WHEN d.folder_id <> deduped.folder_id THEN false ELSE d.pinned END, + pinned_order = CASE WHEN d.folder_id <> deduped.folder_id THEN 0 ELSE d.pinned_order END, updated_at = now() FROM deduped WHERE d.user_id = $1::bigint @@ -216,6 +288,8 @@ type EditDialogPeerFoldersParams struct { FolderIds []int32 } +// 换 folder 时清 pinned:TDesktop History::setFolderPointer 在归档/还原时 +// 本地无条件 unpin,服务端保留旧 pin 会在下次 getDialogs 时把状态漂移回来。 func (q *Queries) EditDialogPeerFolders(ctx context.Context, arg EditDialogPeerFoldersParams) error { _, err := q.db.Exec(ctx, editDialogPeerFolders, arg.UserID, @@ -226,6 +300,19 @@ func (q *Queries) EditDialogPeerFolders(ctx context.Context, arg EditDialogPeerF return err } +const getDialogArchivePinned = `-- name: GetDialogArchivePinned :one +SELECT archive_pinned +FROM dialog_filter_settings +WHERE user_id = $1 +` + +func (q *Queries) GetDialogArchivePinned(ctx context.Context, userID int64) (bool, error) { + row := q.db.QueryRow(ctx, getDialogArchivePinned, userID) + var archive_pinned bool + err := row.Scan(&archive_pinned) + return archive_pinned, err +} + const getDialogFolder = `-- name: GetDialogFolder :one SELECT filter_id, @@ -369,6 +456,9 @@ SELECT d.unread_count, d.unread_mentions_count, d.unread_reactions_count, + d.ttl_period, + d.theme_emoticon, + d.has_scheduled, d.pinned, d.pinned_order, d.unread_mark, @@ -377,9 +467,10 @@ FROM dialogs d LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id WHERE d.user_id = $1 AND ( - NOT $2::boolean + (NOT $2::boolean AND d.folder_id = 0) OR ( - $3::int < 2 + $2::boolean + AND $3::int < 2 AND d.folder_id = $3::int ) OR ( @@ -423,7 +514,7 @@ WHERE d.user_id = $1 AND (NOT $15::boolean OR NOT d.pinned) ORDER BY d.pinned DESC, - CASE WHEN d.pinned THEN COALESCE(NULLIF(d.pinned_order, 0), 2147483647) ELSE 2147483647 END ASC, + CASE WHEN d.pinned THEN COALESCE(d.pinned_order, 0) ELSE 0 END DESC, d.top_message_date DESC, d.top_message_id DESC, d.peer_id DESC @@ -458,6 +549,9 @@ type ListDialogSummaryByUserRow struct { UnreadCount int32 UnreadMentionsCount int32 UnreadReactionsCount int32 + TtlPeriod int32 + ThemeEmoticon string + HasScheduled bool Pinned bool PinnedOrder int32 UnreadMark bool @@ -500,6 +594,9 @@ func (q *Queries) ListDialogSummaryByUser(ctx context.Context, arg ListDialogSum &i.UnreadCount, &i.UnreadMentionsCount, &i.UnreadReactionsCount, + &i.TtlPeriod, + &i.ThemeEmoticon, + &i.HasScheduled, &i.Pinned, &i.PinnedOrder, &i.UnreadMark, @@ -580,6 +677,9 @@ base AS ( COALESCE(d.unread_count, 0)::int AS unread_count, COALESCE(d.unread_mentions_count, 0)::int AS unread_mentions_count, COALESCE(d.unread_reactions_count, 0)::int AS unread_reactions_count, + COALESCE(d.ttl_period, 0)::int AS ttl_period, + COALESCE(d.theme_emoticon, '')::text AS theme_emoticon, + COALESCE(d.has_scheduled, false)::boolean AS has_scheduled, COALESCE(d.pinned, false)::boolean AS pinned, COALESCE(d.pinned_order, 0)::int AS pinned_order, COALESCE(d.unread_mark, false)::boolean AS unread_mark, @@ -593,16 +693,52 @@ base AS ( COALESCE(u.country_code, '')::text AS peer_country_code, COALESCE(u.verified, false)::boolean AS peer_verified, COALESCE(u.support, false)::boolean AS peer_support, + COALESCE(u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at, (c.contact_user_id IS NOT NULL)::boolean AS peer_contact, COALESCE(c.mutual, false)::boolean AS peer_mutual, COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.private_message_id, 0)::bigint AS message_private_message_id, COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id, COALESCE(m.message_date, 0)::int AS message_date, COALESCE(m.outgoing, false)::boolean AS message_outgoing, COALESCE(m.body, '')::text AS message_body, COALESCE(m.entities::text, '[]')::text AS message_entities_json, COALESCE(m.media::text, '{}')::text AS message_media_json, + COALESCE(m.ttl_period, 0)::int AS message_ttl_period, + COALESCE(m.expires_at, 0)::int AS message_expires_at, + COALESCE(m.edit_date, 0)::int AS message_edit_date, + COALESCE(m.silent, false)::boolean AS message_silent, + COALESCE(m.noforwards, false)::boolean AS message_noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS message_reply_to_msg_id, + COALESCE(m.reply_to_peer_type, '')::text AS message_reply_to_peer_type, + COALESCE(m.reply_to_peer_id, 0)::bigint AS message_reply_to_peer_id, + COALESCE(m.reply_to_top_id, 0)::int AS message_reply_to_top_id, + COALESCE(m.reply_to_story_id, 0)::int AS message_reply_to_story_id, + COALESCE(m.quote_text, '')::text AS message_quote_text, + COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json, + COALESCE(m.quote_offset, 0)::int AS message_quote_offset, + COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type, + COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id, + COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS message_fwd_date, + COALESCE(m.fwd_saved_from_peer_type, '')::text AS message_fwd_saved_from_peer_type, + COALESCE(m.fwd_saved_from_peer_id, 0)::bigint AS message_fwd_saved_from_peer_id, + COALESCE(m.fwd_saved_from_msg_id, 0)::int AS message_fwd_saved_from_msg_id, + COALESCE(m.saved_peer_type, '')::text AS message_saved_peer_type, + COALESCE(m.saved_peer_id, 0)::bigint AS message_saved_peer_id, + COALESCE(m.media_unread, false)::boolean AS message_media_unread, + COALESCE(m.reaction_unread, false)::boolean AS message_reaction_unread, + COALESCE(m.via_bot_id, 0)::bigint AS message_via_bot_id, + COALESCE(m.grouped_id, 0)::bigint AS message_grouped_id, + COALESCE(m.effect, 0)::bigint AS message_effect, + COALESCE(m.reply_markup::text, '{}')::text AS message_reply_markup_json, + COALESCE(m.rich_message::text, '{}')::text AS message_rich_message_json, + COALESCE(m.pinned, false)::boolean AS message_pinned, r.ord FROM deduped r LEFT JOIN dialogs d @@ -625,6 +761,9 @@ SELECT unread_count, unread_mentions_count, unread_reactions_count, + ttl_period, + theme_emoticon, + has_scheduled, pinned, pinned_order, unread_mark, @@ -638,16 +777,52 @@ SELECT peer_country_code, peer_verified, peer_support, + peer_is_bot, + peer_bot_info_version, + peer_premium_until, + peer_emoji_status_document_id, + peer_emoji_status_until, peer_last_seen_at, peer_contact, peer_mutual, message_id, + message_private_message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json, - message_media_json + message_media_json, + message_ttl_period, + message_expires_at, + message_edit_date, + message_silent, + message_noforwards, + message_reply_to_msg_id, + message_reply_to_peer_type, + message_reply_to_peer_id, + message_reply_to_top_id, + message_reply_to_story_id, + message_quote_text, + message_quote_entities_json, + message_quote_offset, + message_fwd_from_peer_type, + message_fwd_from_peer_id, + message_fwd_from_name, + message_fwd_date, + message_fwd_saved_from_peer_type, + message_fwd_saved_from_peer_id, + message_fwd_saved_from_msg_id, + message_saved_peer_type, + message_saved_peer_id, + message_media_unread, + message_reaction_unread, + message_via_bot_id, + message_grouped_id, + message_effect, + message_reply_markup_json, + message_rich_message_json, + message_pinned FROM base ORDER BY ord ` @@ -659,40 +834,79 @@ type ListDialogsByPeersParams struct { } type ListDialogsByPeersRow struct { - UserID int64 - PeerType string - PeerID int64 - FolderID int32 - TopMessageID int32 - TopMessageDate int32 - ReadInboxMaxID int32 - ReadOutboxMaxID int32 - UnreadCount int32 - UnreadMentionsCount int32 - UnreadReactionsCount int32 - Pinned bool - PinnedOrder int32 - UnreadMark bool - HiddenPeerSettingsBar bool - PeerUserID int64 - PeerAccessHash int64 - PeerPhone string - PeerFirstName string - PeerLastName string - PeerUsername string - PeerCountryCode string - PeerVerified bool - PeerSupport bool - PeerLastSeenAt int64 - PeerContact bool - PeerMutual bool - MessageID int32 - MessageFromUserID int64 - MessageDate int32 - MessageOutgoing bool - MessageBody string - MessageEntitiesJson string - MessageMediaJson string + UserID int64 + PeerType string + PeerID int64 + FolderID int32 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + TtlPeriod int32 + ThemeEmoticon string + HasScheduled bool + Pinned bool + PinnedOrder int32 + UnreadMark bool + HiddenPeerSettingsBar bool + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerIsBot bool + PeerBotInfoVersion int32 + PeerPremiumUntil int64 + PeerEmojiStatusDocumentID int64 + PeerEmojiStatusUntil int64 + PeerLastSeenAt int64 + PeerContact bool + PeerMutual bool + MessageID int32 + MessagePrivateMessageID int64 + MessageFromUserID int64 + MessageDate int32 + MessageOutgoing bool + MessageBody string + MessageEntitiesJson string + MessageMediaJson string + MessageTtlPeriod int32 + MessageExpiresAt int32 + MessageEditDate int32 + MessageSilent bool + MessageNoforwards bool + MessageReplyToMsgID int32 + MessageReplyToPeerType string + MessageReplyToPeerID int64 + MessageReplyToTopID int32 + MessageReplyToStoryID int32 + MessageQuoteText string + MessageQuoteEntitiesJson string + MessageQuoteOffset int32 + MessageFwdFromPeerType string + MessageFwdFromPeerID int64 + MessageFwdFromName string + MessageFwdDate int32 + MessageFwdSavedFromPeerType string + MessageFwdSavedFromPeerID int64 + MessageFwdSavedFromMsgID int32 + MessageSavedPeerType string + MessageSavedPeerID int64 + MessageMediaUnread bool + MessageReactionUnread bool + MessageViaBotID int64 + MessageGroupedID int64 + MessageEffect int64 + MessageReplyMarkupJson string + MessageRichMessageJson string + MessagePinned bool } func (q *Queries) ListDialogsByPeers(ctx context.Context, arg ListDialogsByPeersParams) ([]ListDialogsByPeersRow, error) { @@ -716,6 +930,9 @@ func (q *Queries) ListDialogsByPeers(ctx context.Context, arg ListDialogsByPeers &i.UnreadCount, &i.UnreadMentionsCount, &i.UnreadReactionsCount, + &i.TtlPeriod, + &i.ThemeEmoticon, + &i.HasScheduled, &i.Pinned, &i.PinnedOrder, &i.UnreadMark, @@ -729,16 +946,52 @@ func (q *Queries) ListDialogsByPeers(ctx context.Context, arg ListDialogsByPeers &i.PeerCountryCode, &i.PeerVerified, &i.PeerSupport, + &i.PeerIsBot, + &i.PeerBotInfoVersion, + &i.PeerPremiumUntil, + &i.PeerEmojiStatusDocumentID, + &i.PeerEmojiStatusUntil, &i.PeerLastSeenAt, &i.PeerContact, &i.PeerMutual, &i.MessageID, + &i.MessagePrivateMessageID, &i.MessageFromUserID, &i.MessageDate, &i.MessageOutgoing, &i.MessageBody, &i.MessageEntitiesJson, &i.MessageMediaJson, + &i.MessageTtlPeriod, + &i.MessageExpiresAt, + &i.MessageEditDate, + &i.MessageSilent, + &i.MessageNoforwards, + &i.MessageReplyToMsgID, + &i.MessageReplyToPeerType, + &i.MessageReplyToPeerID, + &i.MessageReplyToTopID, + &i.MessageReplyToStoryID, + &i.MessageQuoteText, + &i.MessageQuoteEntitiesJson, + &i.MessageQuoteOffset, + &i.MessageFwdFromPeerType, + &i.MessageFwdFromPeerID, + &i.MessageFwdFromName, + &i.MessageFwdDate, + &i.MessageFwdSavedFromPeerType, + &i.MessageFwdSavedFromPeerID, + &i.MessageFwdSavedFromMsgID, + &i.MessageSavedPeerType, + &i.MessageSavedPeerID, + &i.MessageMediaUnread, + &i.MessageReactionUnread, + &i.MessageViaBotID, + &i.MessageGroupedID, + &i.MessageEffect, + &i.MessageReplyMarkupJson, + &i.MessageRichMessageJson, + &i.MessagePinned, ); err != nil { return nil, err } @@ -764,6 +1017,9 @@ WITH base AS ( d.unread_count, d.unread_mentions_count, d.unread_reactions_count, + d.ttl_period, + d.theme_emoticon, + d.has_scheduled, d.pinned, d.pinned_order, d.unread_mark, @@ -777,25 +1033,64 @@ WITH base AS ( COALESCE(u.country_code, '')::text AS peer_country_code, COALESCE(u.verified, false)::boolean AS peer_verified, COALESCE(u.support, false)::boolean AS peer_support, + COALESCE(u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(u.last_seen_at, 0)::bigint AS peer_last_seen_at, (c.contact_user_id IS NOT NULL)::boolean AS peer_contact, COALESCE(c.mutual, false)::boolean AS peer_mutual, COALESCE(m.box_id, 0)::int AS message_id, + COALESCE(m.private_message_id, 0)::bigint AS message_private_message_id, COALESCE(m.from_user_id, 0)::bigint AS message_from_user_id, COALESCE(m.message_date, 0)::int AS message_date, COALESCE(m.outgoing, false)::boolean AS message_outgoing, COALESCE(m.body, '')::text AS message_body, COALESCE(m.entities::text, '[]')::text AS message_entities_json, - COALESCE(m.media::text, '{}')::text AS message_media_json + COALESCE(m.media::text, '{}')::text AS message_media_json, + COALESCE(m.ttl_period, 0)::int AS message_ttl_period, + COALESCE(m.expires_at, 0)::int AS message_expires_at, + COALESCE(m.edit_date, 0)::int AS message_edit_date, + COALESCE(m.silent, false)::boolean AS message_silent, + COALESCE(m.noforwards, false)::boolean AS message_noforwards, + COALESCE(m.reply_to_msg_id, 0)::int AS message_reply_to_msg_id, + COALESCE(m.reply_to_peer_type, '')::text AS message_reply_to_peer_type, + COALESCE(m.reply_to_peer_id, 0)::bigint AS message_reply_to_peer_id, + COALESCE(m.reply_to_top_id, 0)::int AS message_reply_to_top_id, + COALESCE(m.reply_to_story_id, 0)::int AS message_reply_to_story_id, + COALESCE(m.quote_text, '')::text AS message_quote_text, + COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json, + COALESCE(m.quote_offset, 0)::int AS message_quote_offset, + COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type, + COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id, + COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name, + COALESCE(m.fwd_date, 0)::int AS message_fwd_date, + COALESCE(m.fwd_saved_from_peer_type, '')::text AS message_fwd_saved_from_peer_type, + COALESCE(m.fwd_saved_from_peer_id, 0)::bigint AS message_fwd_saved_from_peer_id, + COALESCE(m.fwd_saved_from_msg_id, 0)::int AS message_fwd_saved_from_msg_id, + COALESCE(m.saved_peer_type, '')::text AS message_saved_peer_type, + COALESCE(m.saved_peer_id, 0)::bigint AS message_saved_peer_id, + COALESCE(m.media_unread, false)::boolean AS message_media_unread, + COALESCE(m.reaction_unread, false)::boolean AS message_reaction_unread, + COALESCE(m.via_bot_id, 0)::bigint AS message_via_bot_id, + COALESCE(m.grouped_id, 0)::bigint AS message_grouped_id, + COALESCE(m.effect, 0)::bigint AS message_effect, + COALESCE(m.reply_markup::text, '{}')::text AS message_reply_markup_json, + COALESCE(m.rich_message::text, '{}')::text AS message_rich_message_json, + COALESCE(m.pinned, false)::boolean AS message_pinned FROM dialogs d LEFT JOIN users u ON d.peer_type = 'user' AND u.id = d.peer_id LEFT JOIN contacts c ON d.peer_type = 'user' AND c.user_id = d.user_id AND c.contact_user_id = d.peer_id LEFT JOIN message_boxes m ON m.owner_user_id = d.user_id AND m.box_id = d.top_message_id AND NOT m.deleted WHERE d.user_id = $1 AND ( - NOT $3::boolean + -- 不带 folder_id flag 视为主列表(folder 0):官方语义下归档对话只以 + -- dialogFolder 聚合条目出现在主列表,DrKLO Android 主列表请求不设 flag。 + (NOT $3::boolean AND d.folder_id = 0) OR ( - $4::int < 2 + $3::boolean + AND $4::int < 2 AND d.folder_id = $4::int ) OR ( @@ -839,7 +1134,7 @@ WITH base AS ( AND (NOT $16::boolean OR NOT d.pinned) ), paged AS ( - SELECT user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, pinned, pinned_order, unread_mark, hidden_peer_settings_bar, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, peer_contact, peer_mutual, message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json, message_media_json + SELECT user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, ttl_period, theme_emoticon, has_scheduled, pinned, pinned_order, unread_mark, hidden_peer_settings_bar, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, peer_contact, peer_mutual, message_id, message_private_message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json, message_media_json, message_ttl_period, message_expires_at, message_edit_date, message_silent, message_noforwards, message_reply_to_msg_id, message_reply_to_peer_type, message_reply_to_peer_id, message_reply_to_top_id, message_reply_to_story_id, message_quote_text, message_quote_entities_json, message_quote_offset, message_fwd_from_peer_type, message_fwd_from_peer_id, message_fwd_from_name, message_fwd_date, message_fwd_saved_from_peer_type, message_fwd_saved_from_peer_id, message_fwd_saved_from_msg_id, message_saved_peer_type, message_saved_peer_id, message_media_unread, message_reaction_unread, message_via_bot_id, message_grouped_id, message_effect, message_reply_markup_json, message_rich_message_json, message_pinned FROM base WHERE ( ($17::int <= 0 AND $18::int <= 0) @@ -887,6 +1182,9 @@ SELECT unread_count, unread_mentions_count, unread_reactions_count, + ttl_period, + theme_emoticon, + has_scheduled, pinned, pinned_order, unread_mark, @@ -900,20 +1198,56 @@ SELECT peer_country_code, peer_verified, peer_support, + peer_is_bot, + peer_bot_info_version, + peer_premium_until, + peer_emoji_status_document_id, + peer_emoji_status_until, peer_last_seen_at, peer_contact, peer_mutual, message_id, + message_private_message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json, - message_media_json + message_media_json, + message_ttl_period, + message_expires_at, + message_edit_date, + message_silent, + message_noforwards, + message_reply_to_msg_id, + message_reply_to_peer_type, + message_reply_to_peer_id, + message_reply_to_top_id, + message_reply_to_story_id, + message_quote_text, + message_quote_entities_json, + message_quote_offset, + message_fwd_from_peer_type, + message_fwd_from_peer_id, + message_fwd_from_name, + message_fwd_date, + message_fwd_saved_from_peer_type, + message_fwd_saved_from_peer_id, + message_fwd_saved_from_msg_id, + message_saved_peer_type, + message_saved_peer_id, + message_media_unread, + message_reaction_unread, + message_via_bot_id, + message_grouped_id, + message_effect, + message_reply_markup_json, + message_rich_message_json, + message_pinned FROM paged ORDER BY pinned DESC, - CASE WHEN pinned THEN COALESCE(NULLIF(pinned_order, 0), 2147483647) ELSE 2147483647 END ASC, + CASE WHEN pinned THEN COALESCE(pinned_order, 0) ELSE 0 END DESC, top_message_date DESC, top_message_id DESC, peer_id DESC @@ -944,40 +1278,79 @@ type ListDialogsByUserParams struct { } type ListDialogsByUserRow struct { - UserID int64 - PeerType string - PeerID int64 - FolderID int32 - TopMessageID int32 - TopMessageDate int32 - ReadInboxMaxID int32 - ReadOutboxMaxID int32 - UnreadCount int32 - UnreadMentionsCount int32 - UnreadReactionsCount int32 - Pinned bool - PinnedOrder int32 - UnreadMark bool - HiddenPeerSettingsBar bool - PeerUserID int64 - PeerAccessHash int64 - PeerPhone string - PeerFirstName string - PeerLastName string - PeerUsername string - PeerCountryCode string - PeerVerified bool - PeerSupport bool - PeerLastSeenAt int64 - PeerContact bool - PeerMutual bool - MessageID int32 - MessageFromUserID int64 - MessageDate int32 - MessageOutgoing bool - MessageBody string - MessageEntitiesJson string - MessageMediaJson string + UserID int64 + PeerType string + PeerID int64 + FolderID int32 + TopMessageID int32 + TopMessageDate int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + UnreadCount int32 + UnreadMentionsCount int32 + UnreadReactionsCount int32 + TtlPeriod int32 + ThemeEmoticon string + HasScheduled bool + Pinned bool + PinnedOrder int32 + UnreadMark bool + HiddenPeerSettingsBar bool + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerIsBot bool + PeerBotInfoVersion int32 + PeerPremiumUntil int64 + PeerEmojiStatusDocumentID int64 + PeerEmojiStatusUntil int64 + PeerLastSeenAt int64 + PeerContact bool + PeerMutual bool + MessageID int32 + MessagePrivateMessageID int64 + MessageFromUserID int64 + MessageDate int32 + MessageOutgoing bool + MessageBody string + MessageEntitiesJson string + MessageMediaJson string + MessageTtlPeriod int32 + MessageExpiresAt int32 + MessageEditDate int32 + MessageSilent bool + MessageNoforwards bool + MessageReplyToMsgID int32 + MessageReplyToPeerType string + MessageReplyToPeerID int64 + MessageReplyToTopID int32 + MessageReplyToStoryID int32 + MessageQuoteText string + MessageQuoteEntitiesJson string + MessageQuoteOffset int32 + MessageFwdFromPeerType string + MessageFwdFromPeerID int64 + MessageFwdFromName string + MessageFwdDate int32 + MessageFwdSavedFromPeerType string + MessageFwdSavedFromPeerID int64 + MessageFwdSavedFromMsgID int32 + MessageSavedPeerType string + MessageSavedPeerID int64 + MessageMediaUnread bool + MessageReactionUnread bool + MessageViaBotID int64 + MessageGroupedID int64 + MessageEffect int64 + MessageReplyMarkupJson string + MessageRichMessageJson string + MessagePinned bool } func (q *Queries) ListDialogsByUser(ctx context.Context, arg ListDialogsByUserParams) ([]ListDialogsByUserRow, error) { @@ -1022,6 +1395,9 @@ func (q *Queries) ListDialogsByUser(ctx context.Context, arg ListDialogsByUserPa &i.UnreadCount, &i.UnreadMentionsCount, &i.UnreadReactionsCount, + &i.TtlPeriod, + &i.ThemeEmoticon, + &i.HasScheduled, &i.Pinned, &i.PinnedOrder, &i.UnreadMark, @@ -1035,16 +1411,52 @@ func (q *Queries) ListDialogsByUser(ctx context.Context, arg ListDialogsByUserPa &i.PeerCountryCode, &i.PeerVerified, &i.PeerSupport, + &i.PeerIsBot, + &i.PeerBotInfoVersion, + &i.PeerPremiumUntil, + &i.PeerEmojiStatusDocumentID, + &i.PeerEmojiStatusUntil, &i.PeerLastSeenAt, &i.PeerContact, &i.PeerMutual, &i.MessageID, + &i.MessagePrivateMessageID, &i.MessageFromUserID, &i.MessageDate, &i.MessageOutgoing, &i.MessageBody, &i.MessageEntitiesJson, &i.MessageMediaJson, + &i.MessageTtlPeriod, + &i.MessageExpiresAt, + &i.MessageEditDate, + &i.MessageSilent, + &i.MessageNoforwards, + &i.MessageReplyToMsgID, + &i.MessageReplyToPeerType, + &i.MessageReplyToPeerID, + &i.MessageReplyToTopID, + &i.MessageReplyToStoryID, + &i.MessageQuoteText, + &i.MessageQuoteEntitiesJson, + &i.MessageQuoteOffset, + &i.MessageFwdFromPeerType, + &i.MessageFwdFromPeerID, + &i.MessageFwdFromName, + &i.MessageFwdDate, + &i.MessageFwdSavedFromPeerType, + &i.MessageFwdSavedFromPeerID, + &i.MessageFwdSavedFromMsgID, + &i.MessageSavedPeerType, + &i.MessageSavedPeerID, + &i.MessageMediaUnread, + &i.MessageReactionUnread, + &i.MessageViaBotID, + &i.MessageGroupedID, + &i.MessageEffect, + &i.MessageReplyMarkupJson, + &i.MessageRichMessageJson, + &i.MessagePinned, ); err != nil { return nil, err } @@ -1064,7 +1476,11 @@ WITH target AS ( d.peer_id, d.top_message_id, d.read_inbox_max_id, - d.unread_count + d.unread_count, + LEAST( + d.top_message_id, + CASE WHEN $4::int > 0 THEN $4::int ELSE d.top_message_id END + )::int AS requested_read_max_id FROM dialogs d WHERE d.user_id = $1 AND d.peer_type = $2 @@ -1073,14 +1489,19 @@ WITH target AS ( updated AS ( UPDATE dialogs d SET - read_inbox_max_id = GREATEST( - d.read_inbox_max_id, - CASE WHEN $4::int > 0 THEN $4::int ELSE d.top_message_id END + read_inbox_max_id = GREATEST(d.read_inbox_max_id, target.requested_read_max_id), + unread_count = ( + SELECT count(*)::int + FROM message_boxes m + WHERE m.owner_user_id = d.user_id + AND m.peer_type = d.peer_type + AND m.peer_id = d.peer_id + AND NOT m.deleted + AND NOT m.outgoing + AND m.box_id > GREATEST(d.read_inbox_max_id, target.requested_read_max_id) ), - unread_count = 0, unread_mark = false, unread_mentions_count = 0, - unread_reactions_count = 0, updated_at = now() FROM target WHERE d.user_id = target.user_id @@ -1094,9 +1515,7 @@ RETURNING d.unread_count, ( target.unread_count > 0 - OR ( - CASE WHEN $4::int > 0 THEN $4::int ELSE target.top_message_id END - ) > target.read_inbox_max_id + OR target.requested_read_max_id > target.read_inbox_max_id )::boolean AS changed ) SELECT @@ -1160,7 +1579,15 @@ SET AND m.box_id > d.read_inbox_max_id ), unread_mentions_count = 0, - unread_reactions_count = 0, + unread_reactions_count = ( + SELECT COUNT(*)::int + FROM message_boxes m2 + WHERE m2.owner_user_id = d.user_id + AND m2.peer_type = d.peer_type + AND m2.peer_id = d.peer_id + AND NOT m2.deleted + AND m2.reaction_unread + ), updated_at = now() WHERE d.user_id = $3::bigint AND d.peer_type = $4::text @@ -1220,17 +1647,19 @@ func (q *Queries) ReorderDialogFolders(ctx context.Context, arg ReorderDialogFol const reorderPinnedDialogs = `-- name: ReorderPinnedDialogs :exec WITH requested AS ( SELECT - ($2::text[])[i] AS peer_type, - ($3::bigint[])[i] AS peer_id, + ($3::text[])[i] AS peer_type, + ($4::bigint[])[i] AS peer_id, i::int AS pos - FROM generate_subscripts($3::bigint[], 1) AS g(i) - WHERE i <= cardinality($2::text[]) + FROM generate_subscripts($4::bigint[], 1) AS g(i) + WHERE i <= cardinality($3::text[]) ), deduped AS ( + -- 请求数组首位是置顶区最顶部;统一编码为"值越大越靠前",与频道 + -- reorder、toggle 的 MAX+1 分配以及合并层排序方向一致。 SELECT DISTINCT ON (peer_type, peer_id) peer_type, peer_id, - pos::int AS ord + (cardinality($4::bigint[]) - pos + 1)::int AS ord FROM requested ORDER BY peer_type, peer_id, pos ) @@ -1242,19 +1671,58 @@ FROM deduped WHERE d.user_id = $1::bigint AND d.peer_type = deduped.peer_type AND d.peer_id = deduped.peer_id + AND d.folder_id = $2::int ` type ReorderPinnedDialogsParams struct { UserID int64 + FolderID int32 PeerTypes []string PeerIds []int64 } func (q *Queries) ReorderPinnedDialogs(ctx context.Context, arg ReorderPinnedDialogsParams) error { - _, err := q.db.Exec(ctx, reorderPinnedDialogs, arg.UserID, arg.PeerTypes, arg.PeerIds) + _, err := q.db.Exec(ctx, reorderPinnedDialogs, + arg.UserID, + arg.FolderID, + arg.PeerTypes, + arg.PeerIds, + ) return err } +const setDialogArchivePinned = `-- name: SetDialogArchivePinned :one +WITH current AS ( + SELECT archive_pinned + FROM dialog_filter_settings + WHERE user_id = $2::bigint +), +upserted AS ( + INSERT INTO dialog_filter_settings (user_id, archive_pinned) + VALUES ($2::bigint, $1::boolean) + ON CONFLICT (user_id) DO UPDATE SET + archive_pinned = EXCLUDED.archive_pinned, + updated_at = now() + RETURNING archive_pinned +) +SELECT (COALESCE((SELECT archive_pinned FROM current), true) IS DISTINCT FROM $1::boolean)::boolean AS changed +FROM upserted +` + +type SetDialogArchivePinnedParams struct { + Pinned bool + UserID int64 +} + +// archive folder 行本身的置顶状态(toggleDialogPin(inputDialogPeerFolder))。 +// 无行时官方默认视为 pinned=true,所以这里总是落一行显式值。 +func (q *Queries) SetDialogArchivePinned(ctx context.Context, arg SetDialogArchivePinnedParams) (bool, error) { + row := q.db.QueryRow(ctx, setDialogArchivePinned, arg.Pinned, arg.UserID) + var changed bool + err := row.Scan(&changed) + return changed, err +} + const setDialogFolderTags = `-- name: SetDialogFolderTags :exec INSERT INTO dialog_filter_settings ( user_id, @@ -1279,47 +1747,77 @@ func (q *Queries) SetDialogFolderTags(ctx context.Context, arg SetDialogFolderTa } const setDialogPinned = `-- name: SetDialogPinned :one -WITH next_order AS ( - SELECT COALESCE(MAX(pinned_order), 0)::int + 1 AS value - FROM dialogs - WHERE user_id = $1::bigint - AND pinned +WITH target AS ( + SELECT d.folder_id + FROM dialogs d + WHERE d.user_id = $1::bigint + AND d.peer_type = $2::text + AND d.peer_id = $3::bigint +), +next_order AS ( + -- order 空间跨 dialogs/channel_dialogs 两表统一(合并层按 pinned_order + -- 全局排序),但仅在目标会话所在 folder 内取最大值。 + SELECT GREATEST( + COALESCE(( + SELECT MAX(d.pinned_order) + FROM dialogs d, target t + WHERE d.user_id = $1::bigint + AND d.pinned + AND d.folder_id = t.folder_id + ), 0), + COALESCE(( + SELECT MAX(cd.pinned_order) + FROM channel_dialogs cd, target t + WHERE cd.user_id = $1::bigint + AND cd.pinned + AND COALESCE(cd.folder_id, 0) = t.folder_id + ), 0) + )::int + 1 AS value ), updated AS ( UPDATE dialogs d - SET pinned = $2::boolean, + SET pinned = $4::boolean, pinned_order = CASE - WHEN $2::boolean THEN + WHEN $4::boolean THEN CASE WHEN d.pinned_order > 0 THEN d.pinned_order ELSE next_order.value END ELSE 0 END, updated_at = now() FROM next_order WHERE d.user_id = $1::bigint - AND d.peer_type = $3::text - AND d.peer_id = $4::bigint - RETURNING d.user_id + AND d.peer_type = $2::text + AND d.peer_id = $3::bigint + RETURNING d.folder_id ) -SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed +SELECT + EXISTS (SELECT 1 FROM updated)::boolean AS changed, + COALESCE((SELECT folder_id FROM updated), 0)::int AS folder_id ` type SetDialogPinnedParams struct { UserID int64 - Pinned bool PeerType string PeerID int64 + Pinned bool } -func (q *Queries) SetDialogPinned(ctx context.Context, arg SetDialogPinnedParams) (bool, error) { +type SetDialogPinnedRow struct { + Changed bool + FolderID int32 +} + +// 置顶顺序在 dialog 当前 folder(0 主列表/1 归档)内独立分配, +// 返回 folder_id 供 updateDialogPinned.folder_id 使用。 +func (q *Queries) SetDialogPinned(ctx context.Context, arg SetDialogPinnedParams) (SetDialogPinnedRow, error) { row := q.db.QueryRow(ctx, setDialogPinned, arg.UserID, - arg.Pinned, arg.PeerType, arg.PeerID, + arg.Pinned, ) - var changed bool - err := row.Scan(&changed) - return changed, err + var i SetDialogPinnedRow + err := row.Scan(&i.Changed, &i.FolderID) + return i, err } const setDialogUnreadMark = `-- name: SetDialogUnreadMark :one @@ -1330,6 +1828,7 @@ WITH updated AS ( WHERE d.user_id = $2::bigint AND d.peer_type = $3::text AND d.peer_id = $4::bigint + AND d.unread_mark IS DISTINCT FROM $1::boolean RETURNING d.user_id ) SELECT EXISTS (SELECT 1 FROM updated)::boolean AS changed @@ -1342,6 +1841,8 @@ type SetDialogUnreadMarkParams struct { PeerID int64 } +// 值守卫 IS DISTINCT FROM:重复标记同一值不应算 changed,否则上层会记一条 +// 幽灵 durable 未读标事件并多推一次 update(对齐频道侧 SetChannelDialogUnreadMark)。 func (q *Queries) SetDialogUnreadMark(ctx context.Context, arg SetDialogUnreadMarkParams) (bool, error) { row := q.db.QueryRow(ctx, setDialogUnreadMark, arg.Unread, @@ -1538,9 +2039,22 @@ INSERT INTO dialogs ( $1, $2, $3, $4, $5, 1 ) ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET - top_message_id = EXCLUDED.top_message_id, - top_message_date = EXCLUDED.top_message_date, - unread_count = dialogs.unread_count + 1, + top_message_id = GREATEST(dialogs.top_message_id, EXCLUDED.top_message_id), + top_message_date = CASE + WHEN EXCLUDED.top_message_id >= dialogs.top_message_id THEN EXCLUDED.top_message_date + ELSE dialogs.top_message_date + END, + unread_count = ( + SELECT COUNT(*)::int + FROM message_boxes m + WHERE m.owner_user_id = dialogs.user_id + AND m.peer_type = dialogs.peer_type + AND m.peer_id = dialogs.peer_id + AND NOT m.deleted + AND NOT m.outgoing + AND m.box_id > dialogs.read_inbox_max_id + AND m.box_id <= GREATEST(dialogs.top_message_id, EXCLUDED.top_message_id) + ), updated_at = now() ` @@ -1577,6 +2091,7 @@ INSERT INTO dialogs ( ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET top_message_id = EXCLUDED.top_message_id, top_message_date = EXCLUDED.top_message_date, + unread_mark = false, updated_at = now() ` @@ -1588,6 +2103,8 @@ type UpsertOutboxDialogParams struct { TopMessageDate int32 } +// 发送方向该会话发出消息即视为已知晓内容:清除手动未读标记, +// 与 channel 发送路径、readHistory 清除语义对齐。 func (q *Queries) UpsertOutboxDialog(ctx context.Context, arg UpsertOutboxDialogParams) error { _, err := q.db.Exec(ctx, upsertOutboxDialog, arg.UserID, diff --git a/internal/store/postgres/sqlcgen/media.sql.go b/internal/store/postgres/sqlcgen/media.sql.go index 26393db0..cfb29253 100644 --- a/internal/store/postgres/sqlcgen/media.sql.go +++ b/internal/store/postgres/sqlcgen/media.sql.go @@ -7,6 +7,8 @@ package sqlcgen import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const addProfilePhoto = `-- name: AddProfilePhoto :exec @@ -197,10 +199,52 @@ func (q *Queries) DeactivateProfilePhotos(ctx context.Context, arg DeactivatePro return items, nil } -const deleteUploadParts = `-- name: DeleteUploadParts :exec +const deleteExpiredUploadParts = `-- name: DeleteExpiredUploadParts :many +WITH doomed AS ( + SELECT owner_user_id, file_id, part + FROM upload_parts + WHERE created_at < $1::timestamptz + ORDER BY created_at ASC + LIMIT $2::int +) +DELETE FROM upload_parts p +USING doomed d +WHERE p.owner_user_id = d.owner_user_id + AND p.file_id = d.file_id + AND p.part = d.part +RETURNING p.object_key +` + +type DeleteExpiredUploadPartsParams struct { + Before pgtype.Timestamptz + BatchLimit int32 +} + +func (q *Queries) DeleteExpiredUploadParts(ctx context.Context, arg DeleteExpiredUploadPartsParams) ([]string, error) { + rows, err := q.db.Query(ctx, deleteExpiredUploadParts, arg.Before, arg.BatchLimit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var object_key string + if err := rows.Scan(&object_key); err != nil { + return nil, err + } + items = append(items, object_key) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteUploadParts = `-- name: DeleteUploadParts :many DELETE FROM upload_parts WHERE owner_user_id = $1::bigint AND file_id = $2::bigint +RETURNING object_key ` type DeleteUploadPartsParams struct { @@ -208,9 +252,24 @@ type DeleteUploadPartsParams struct { FileID int64 } -func (q *Queries) DeleteUploadParts(ctx context.Context, arg DeleteUploadPartsParams) error { - _, err := q.db.Exec(ctx, deleteUploadParts, arg.OwnerUserID, arg.FileID) - return err +func (q *Queries) DeleteUploadParts(ctx context.Context, arg DeleteUploadPartsParams) ([]string, error) { + rows, err := q.db.Query(ctx, deleteUploadParts, arg.OwnerUserID, arg.FileID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var object_key string + if err := rows.Scan(&object_key); err != nil { + return nil, err + } + items = append(items, object_key) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const getDocument = `-- name: GetDocument :one @@ -562,6 +621,71 @@ func (q *Queries) GetStickerSetBySystemKey(ctx context.Context, systemKey string return i, err } +const getUploadPartSlot = `-- name: GetUploadPartSlot :one +SELECT + COALESCE(( + SELECT size + FROM upload_parts + WHERE owner_user_id = $1::bigint + AND file_id = $2::bigint + AND part = $3::int + ), -1)::bigint AS existing_bytes, + COALESCE(( + SELECT object_key + FROM upload_parts + WHERE owner_user_id = $1::bigint + AND file_id = $2::bigint + AND part = $3::int + ), '')::text AS object_key, + ( + SELECT COUNT(*)::int + FROM upload_parts + WHERE owner_user_id = $1::bigint + AND file_id = $2::bigint + )::int AS file_parts +` + +type GetUploadPartSlotParams struct { + OwnerUserID int64 + FileID int64 + Part int32 +} + +type GetUploadPartSlotRow struct { + ExistingBytes int64 + ObjectKey string + FileParts int32 +} + +func (q *Queries) GetUploadPartSlot(ctx context.Context, arg GetUploadPartSlotParams) (GetUploadPartSlotRow, error) { + row := q.db.QueryRow(ctx, getUploadPartSlot, arg.OwnerUserID, arg.FileID, arg.Part) + var i GetUploadPartSlotRow + err := row.Scan(&i.ExistingBytes, &i.ObjectKey, &i.FileParts) + return i, err +} + +const getUploadPartUsage = `-- name: GetUploadPartUsage :one +SELECT + COALESCE(SUM(size), 0)::bigint AS bytes, + COUNT(*)::int AS parts, + COUNT(DISTINCT file_id)::int AS files +FROM upload_parts +WHERE owner_user_id = $1::bigint +` + +type GetUploadPartUsageRow struct { + Bytes int64 + Parts int32 + Files int32 +} + +func (q *Queries) GetUploadPartUsage(ctx context.Context, ownerUserID int64) (GetUploadPartUsageRow, error) { + row := q.db.QueryRow(ctx, getUploadPartUsage, ownerUserID) + var i GetUploadPartUsageRow + err := row.Scan(&i.Bytes, &i.Parts, &i.Files) + return i, err +} + const listAvailableReactions = `-- name: ListAvailableReactions :many SELECT reaction, title, inactive, premium, @@ -732,7 +856,7 @@ func (q *Queries) ListStickerSetsByKind(ctx context.Context, setKind string) ([] } const listUploadParts = `-- name: ListUploadParts :many -SELECT part, total_parts, is_big, bytes +SELECT part, total_parts, is_big, backend, object_key, size, sha256 FROM upload_parts WHERE owner_user_id = $1::bigint AND file_id = $2::bigint @@ -748,7 +872,10 @@ type ListUploadPartsRow struct { Part int32 TotalParts int32 IsBig bool - Bytes []byte + Backend string + ObjectKey string + Size int64 + Sha256 []byte } func (q *Queries) ListUploadParts(ctx context.Context, arg ListUploadPartsParams) ([]ListUploadPartsRow, error) { @@ -764,7 +891,10 @@ func (q *Queries) ListUploadParts(ctx context.Context, arg ListUploadPartsParams &i.Part, &i.TotalParts, &i.IsBig, - &i.Bytes, + &i.Backend, + &i.ObjectKey, + &i.Size, + &i.Sha256, ); err != nil { return nil, err } @@ -1116,19 +1246,26 @@ func (q *Queries) PutStickerSet(ctx context.Context, arg PutStickerSetParams) er const saveUploadPart = `-- name: SaveUploadPart :exec -INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, bytes) +INSERT INTO upload_parts (owner_user_id, file_id, part, total_parts, is_big, backend, object_key, size, sha256) VALUES ( $1::bigint, $2::bigint, $3::int, $4::int, $5::boolean, - $6::bytea + $6::text, + $7::text, + $8::bigint, + $9::bytea ) ON CONFLICT (owner_user_id, file_id, part) DO UPDATE SET total_parts = EXCLUDED.total_parts, is_big = EXCLUDED.is_big, - bytes = EXCLUDED.bytes + backend = EXCLUDED.backend, + object_key = EXCLUDED.object_key, + size = EXCLUDED.size, + sha256 = EXCLUDED.sha256, + created_at = now() ` type SaveUploadPartParams struct { @@ -1137,7 +1274,10 @@ type SaveUploadPartParams struct { Part int32 TotalParts int32 IsBig bool - Bytes []byte + Backend string + ObjectKey string + Size int64 + Sha256 []byte } // upload_parts ---------------------------------------------------------------- @@ -1148,7 +1288,10 @@ func (q *Queries) SaveUploadPart(ctx context.Context, arg SaveUploadPartParams) arg.Part, arg.TotalParts, arg.IsBig, - arg.Bytes, + arg.Backend, + arg.ObjectKey, + arg.Size, + arg.Sha256, ) return err } diff --git a/internal/store/postgres/sqlcgen/message.sql.go b/internal/store/postgres/sqlcgen/message.sql.go index 90e89e21..727ea79c 100644 --- a/internal/store/postgres/sqlcgen/message.sql.go +++ b/internal/store/postgres/sqlcgen/message.sql.go @@ -9,6 +9,75 @@ import ( "context" ) +const countMessagesByUser = `-- name: CountMessagesByUser :one +SELECT count(*)::int AS total_count +FROM message_boxes m +WHERE m.owner_user_id = $1::bigint + AND NOT m.deleted + AND ( + NOT $2::boolean + OR (m.peer_type = $3::text AND m.peer_id = $4::bigint) + ) + AND ( + $5::text = '' + OR m.body ILIKE ('%' || $5::text || '%') + ) + AND ($6::int <= 0 OR m.box_id < $6::int) + AND ($7::int <= 0 OR m.box_id > $7::int) + AND (NOT $8::boolean OR m.pinned) + AND ( + NOT $9::boolean + OR ( + m.media->>'kind' = 'document' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(m.media #> '{document,attributes}', '[]'::jsonb)) AS attr + WHERE attr->>'kind' = 'audio' + AND COALESCE((attr->>'voice')::boolean, false) = false + ) + ) + ) + AND ( + $10::text = '' + OR (m.saved_peer_type = $10::text AND m.saved_peer_id = $11::bigint) + ) +` + +type CountMessagesByUserParams struct { + OwnerUserID int64 + HasPeer bool + PeerType string + PeerID int64 + Query string + MaxID int32 + MinID int32 + PinnedOnly bool + MusicOnly bool + SavedPeerType string + SavedPeerID int64 +} + +// ListMessagesByUser total CTE 的独立化:相同 base 过滤(不含分页 anchor), +// 仅 NeedTotalCount 时发,避免热路径(getHistory 恒不需 total)次次规划 count 子树。 +func (q *Queries) CountMessagesByUser(ctx context.Context, arg CountMessagesByUserParams) (int32, error) { + row := q.db.QueryRow(ctx, countMessagesByUser, + arg.OwnerUserID, + arg.HasPeer, + arg.PeerType, + arg.PeerID, + arg.Query, + arg.MaxID, + arg.MinID, + arg.PinnedOnly, + arg.MusicOnly, + arg.SavedPeerType, + arg.SavedPeerID, + ) + var total_count int32 + err := row.Scan(&total_count) + return total_count, err +} + const createMessage = `-- name: CreateMessage :one WITH pm AS ( INSERT INTO private_messages ( @@ -156,6 +225,8 @@ INSERT INTO message_boxes ( peer_id, from_user_id, message_date, + ttl_period, + expires_at, outgoing, body, entities, @@ -165,6 +236,7 @@ INSERT INTO message_boxes ( reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities, quote_offset, @@ -172,29 +244,53 @@ INSERT INTO message_boxes ( fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media, media_unread, - reaction_unread + reaction_unread, + reply_markup, + rich_message, + via_bot_id, + grouped_id, + effect ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, - $12::boolean, - $13::boolean, - $14::int, - $15::text, - $16::bigint, - $17::int, - $18::text, - $19::jsonb, + $1, $2, $3, $4, $5, $6, $7, $8, + $11::int, + $12::int, + $9, $10, $13::jsonb, + $14::boolean, + $15::boolean, + $16::int, + $17::text, + $18::bigint, + $19::int, $20::int, $21::text, - $22::bigint, - $23::text, - $24::int, - $25::int, - $26::jsonb, - $27::boolean, - $28::boolean + $22::jsonb, + $23::int, + $24::text, + $25::bigint, + $26::text, + $27::int, + $28::text, + $29::bigint, + $30::int, + $31::text, + $32::bigint, + $33::int, + $34::jsonb, + $35::boolean, + $36::boolean, + $37::jsonb, + $38::jsonb, + $39::bigint, + $40::bigint, + $41::bigint ) RETURNING box_id, @@ -204,6 +300,8 @@ RETURNING peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -214,6 +312,7 @@ RETURNING reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities::text AS quote_entities_json, quote_offset, @@ -221,72 +320,110 @@ RETURNING fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media::text AS media_json, media_unread, - reaction_unread + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json ` type CreateMessageBoxParams struct { - OwnerUserID int64 - BoxID int32 - PrivateMessageID int64 - MessageSenderID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - Outgoing bool - Body string - EntitiesJson []byte - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson []byte - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - Pts int32 - MediaJson []byte - MediaUnread bool - ReactionUnread bool + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + Outgoing bool + Body string + TtlPeriod int32 + ExpiresAt int32 + EntitiesJson []byte + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson []byte + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson []byte + MediaUnread bool + ReactionUnread bool + ReplyMarkupJson []byte + RichMessageJson []byte + ViaBotID int64 + GroupedID int64 + Effect int64 } type CreateMessageBoxRow struct { - BoxID int32 - PrivateMessageID int64 - OwnerUserID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - EditDate int32 - Outgoing bool - Body string - EntitiesJson string - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson string - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - Pts int32 - MediaJson string - MediaUnread bool - ReactionUnread bool + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string } func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxParams) (CreateMessageBoxRow, error) { @@ -301,6 +438,8 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara arg.MessageDate, arg.Outgoing, arg.Body, + arg.TtlPeriod, + arg.ExpiresAt, arg.EntitiesJson, arg.Silent, arg.Noforwards, @@ -308,6 +447,7 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara arg.ReplyToPeerType, arg.ReplyToPeerID, arg.ReplyToTopID, + arg.ReplyToStoryID, arg.QuoteText, arg.QuoteEntitiesJson, arg.QuoteOffset, @@ -315,10 +455,20 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara arg.FwdFromPeerID, arg.FwdFromName, arg.FwdDate, + arg.FwdSavedFromPeerType, + arg.FwdSavedFromPeerID, + arg.FwdSavedFromMsgID, + arg.SavedPeerType, + arg.SavedPeerID, arg.Pts, arg.MediaJson, arg.MediaUnread, arg.ReactionUnread, + arg.ReplyMarkupJson, + arg.RichMessageJson, + arg.ViaBotID, + arg.GroupedID, + arg.Effect, ) var i CreateMessageBoxRow err := row.Scan( @@ -329,6 +479,8 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara &i.PeerID, &i.FromUserID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Outgoing, &i.Body, @@ -339,6 +491,7 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara &i.ReplyToPeerType, &i.ReplyToPeerID, &i.ReplyToTopID, + &i.ReplyToStoryID, &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, @@ -346,10 +499,21 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara &i.FwdFromPeerID, &i.FwdFromName, &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, &i.Pts, &i.MediaJson, &i.MediaUnread, &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, ) return i, err } @@ -360,6 +524,8 @@ INSERT INTO private_messages ( recipient_user_id, random_id, message_date, + ttl_period, + expires_at, body, entities, silent, @@ -368,6 +534,7 @@ INSERT INTO private_messages ( reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities, quote_offset, @@ -375,23 +542,34 @@ INSERT INTO private_messages ( fwd_from_peer_id, fwd_from_name, fwd_date, - media + media, + reply_markup, + rich_message, + via_bot_id, + grouped_id, + effect ) VALUES ( - $1, $2, $3, $4, $5, $6::jsonb, - $7::boolean, - $8::boolean, - $9::int, - $10::text, - $11::bigint, - $12::int, - $13::text, - $14::jsonb, + $1, $2, $3, $4, $6::int, $7::int, $5, $8::jsonb, + $9::boolean, + $10::boolean, + $11::int, + $12::text, + $13::bigint, + $14::int, $15::int, $16::text, - $17::bigint, - $18::text, - $19::int, - $20::jsonb + $17::jsonb, + $18::int, + $19::text, + $20::bigint, + $21::text, + $22::int, + $23::jsonb, + $24::jsonb, + $25::jsonb, + $26::bigint, + $27::bigint, + $28::bigint ) ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING RETURNING @@ -400,6 +578,8 @@ RETURNING recipient_user_id, random_id, message_date, + ttl_period, + expires_at, edit_date, body, entities::text AS entities_json @@ -411,6 +591,8 @@ type CreatePrivateMessageParams struct { RandomID int64 MessageDate int32 Body string + TtlPeriod int32 + ExpiresAt int32 EntitiesJson []byte Silent bool Noforwards bool @@ -418,6 +600,7 @@ type CreatePrivateMessageParams struct { ReplyToPeerType string ReplyToPeerID int64 ReplyToTopID int32 + ReplyToStoryID int32 QuoteText string QuoteEntitiesJson []byte QuoteOffset int32 @@ -426,6 +609,11 @@ type CreatePrivateMessageParams struct { FwdFromName string FwdDate int32 MediaJson []byte + ReplyMarkupJson []byte + RichMessageJson []byte + ViaBotID int64 + GroupedID int64 + Effect int64 } type CreatePrivateMessageRow struct { @@ -434,6 +622,8 @@ type CreatePrivateMessageRow struct { RecipientUserID int64 RandomID int64 MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 EditDate int32 Body string EntitiesJson string @@ -446,6 +636,8 @@ func (q *Queries) CreatePrivateMessage(ctx context.Context, arg CreatePrivateMes arg.RandomID, arg.MessageDate, arg.Body, + arg.TtlPeriod, + arg.ExpiresAt, arg.EntitiesJson, arg.Silent, arg.Noforwards, @@ -453,6 +645,7 @@ func (q *Queries) CreatePrivateMessage(ctx context.Context, arg CreatePrivateMes arg.ReplyToPeerType, arg.ReplyToPeerID, arg.ReplyToTopID, + arg.ReplyToStoryID, arg.QuoteText, arg.QuoteEntitiesJson, arg.QuoteOffset, @@ -461,6 +654,11 @@ func (q *Queries) CreatePrivateMessage(ctx context.Context, arg CreatePrivateMes arg.FwdFromName, arg.FwdDate, arg.MediaJson, + arg.ReplyMarkupJson, + arg.RichMessageJson, + arg.ViaBotID, + arg.GroupedID, + arg.Effect, ) var i CreatePrivateMessageRow err := row.Scan( @@ -469,6 +667,8 @@ func (q *Queries) CreatePrivateMessage(ctx context.Context, arg CreatePrivateMes &i.RecipientUserID, &i.RandomID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Body, &i.EntitiesJson, @@ -629,9 +829,11 @@ WITH target AS ( AND m.peer_type = $2::text AND m.peer_id = $3::bigint AND ($4::int <= 0 OR m.box_id <= $4::int) + AND ($5::int <= 0 OR m.message_date >= $5::int) + AND ($6::int <= 0 OR m.message_date <= $6::int) AND NOT m.deleted ORDER BY m.box_id DESC - LIMIT $5::int + LIMIT $7::int FOR UPDATE SKIP LOCKED ), updated AS ( @@ -664,6 +866,8 @@ type DeleteMessageBoxesByPeerBatchParams struct { PeerType string PeerID int64 MaxID int32 + MinDate int32 + MaxDate int32 LimitCount int32 } @@ -682,6 +886,8 @@ func (q *Queries) DeleteMessageBoxesByPeerBatch(ctx context.Context, arg DeleteM arg.PeerType, arg.PeerID, arg.MaxID, + arg.MinDate, + arg.MaxDate, arg.LimitCount, ) if err != nil { @@ -713,19 +919,24 @@ const deleteMessageBoxesByPrivateMessages = `-- name: DeleteMessageBoxesByPrivat WITH requested AS ( SELECT ($1::bigint[])[i] AS message_sender_id, - ($2::bigint[])[i] AS private_message_id + ($2::bigint[])[i] AS private_message_id, + ($3::bigint[])[i] AS owner_user_id FROM generate_subscripts($2::bigint[], 1) AS g(i) WHERE i <= cardinality($1::bigint[]) + AND i <= cardinality($3::bigint[]) ), deduped AS ( - SELECT DISTINCT message_sender_id, private_message_id + SELECT DISTINCT message_sender_id, private_message_id, owner_user_id FROM requested + WHERE owner_user_id <> 0 ), updated AS ( UPDATE message_boxes m SET deleted = true FROM deduped d - WHERE m.message_sender_id = d.message_sender_id + WHERE m.owner_user_id = ANY($3::bigint[]) + AND m.owner_user_id = d.owner_user_id + AND m.message_sender_id = d.message_sender_id AND m.private_message_id = d.private_message_id AND NOT m.deleted RETURNING @@ -750,6 +961,7 @@ ORDER BY owner_user_id ASC, box_id ASC type DeleteMessageBoxesByPrivateMessagesParams struct { MessageSenderIds []int64 PrivateMessageIds []int64 + OwnerUserIds []int64 } type DeleteMessageBoxesByPrivateMessagesRow struct { @@ -762,7 +974,7 @@ type DeleteMessageBoxesByPrivateMessagesRow struct { } func (q *Queries) DeleteMessageBoxesByPrivateMessages(ctx context.Context, arg DeleteMessageBoxesByPrivateMessagesParams) ([]DeleteMessageBoxesByPrivateMessagesRow, error) { - rows, err := q.db.Query(ctx, deleteMessageBoxesByPrivateMessages, arg.MessageSenderIds, arg.PrivateMessageIds) + rows, err := q.db.Query(ctx, deleteMessageBoxesByPrivateMessages, arg.MessageSenderIds, arg.PrivateMessageIds, arg.OwnerUserIds) if err != nil { return nil, err } @@ -841,6 +1053,8 @@ SELECT peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -851,6 +1065,7 @@ SELECT reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities::text AS quote_entities_json, quote_offset, @@ -858,10 +1073,21 @@ SELECT fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media::text AS media_json, media_unread, - reaction_unread + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json FROM message_boxes WHERE owner_user_id = $1 AND private_message_id = $2 @@ -874,34 +1100,48 @@ type GetMessageBoxByPrivateMessageParams struct { } type GetMessageBoxByPrivateMessageRow struct { - BoxID int32 - PrivateMessageID int64 - OwnerUserID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - EditDate int32 - Outgoing bool - Body string - EntitiesJson string - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson string - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - Pts int32 - MediaJson string - MediaUnread bool - ReactionUnread bool + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string } func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMessageBoxByPrivateMessageParams) (GetMessageBoxByPrivateMessageRow, error) { @@ -915,6 +1155,8 @@ func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMess &i.PeerID, &i.FromUserID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Outgoing, &i.Body, @@ -925,6 +1167,7 @@ func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMess &i.ReplyToPeerType, &i.ReplyToPeerID, &i.ReplyToTopID, + &i.ReplyToStoryID, &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, @@ -932,10 +1175,21 @@ func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMess &i.FwdFromPeerID, &i.FwdFromName, &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, &i.Pts, &i.MediaJson, &i.MediaUnread, &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, ) return i, err } @@ -950,6 +1204,8 @@ SELECT peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -960,6 +1216,7 @@ SELECT reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities::text AS quote_entities_json, quote_offset, @@ -967,10 +1224,21 @@ SELECT fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media::text AS media_json, media_unread, - reaction_unread + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json FROM message_boxes WHERE owner_user_id = $1::bigint AND box_id = $2::int @@ -989,35 +1257,49 @@ type GetMessageBoxForEditParams struct { } type GetMessageBoxForEditRow struct { - BoxID int32 - PrivateMessageID int64 - OwnerUserID int64 - MessageSenderID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - EditDate int32 - Outgoing bool - Body string - EntitiesJson string - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson string - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - Pts int32 - MediaJson string - MediaUnread bool - ReactionUnread bool + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string } func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxForEditParams) (GetMessageBoxForEditRow, error) { @@ -1037,6 +1319,8 @@ func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxFor &i.PeerID, &i.FromUserID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Outgoing, &i.Body, @@ -1047,6 +1331,7 @@ func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxFor &i.ReplyToPeerType, &i.ReplyToPeerID, &i.ReplyToTopID, + &i.ReplyToStoryID, &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, @@ -1054,10 +1339,71 @@ func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxFor &i.FwdFromPeerID, &i.FwdFromName, &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, &i.Pts, &i.MediaJson, &i.MediaUnread, &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, + ) + return i, err +} + +const getMessageBoxForPin = `-- name: GetMessageBoxForPin :one +SELECT + box_id, + private_message_id, + message_sender_id, + pinned, + media::text AS media_json +FROM message_boxes +WHERE owner_user_id = $1::bigint + AND peer_type = $2::text + AND peer_id = $3::bigint + AND box_id = $4::int + AND NOT deleted +LIMIT 1 +FOR UPDATE +` + +type GetMessageBoxForPinParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + BoxID int32 +} + +type GetMessageBoxForPinRow struct { + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + Pinned bool + MediaJson string +} + +func (q *Queries) GetMessageBoxForPin(ctx context.Context, arg GetMessageBoxForPinParams) (GetMessageBoxForPinRow, error) { + row := q.db.QueryRow(ctx, getMessageBoxForPin, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.BoxID, + ) + var i GetMessageBoxForPinRow + err := row.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.MessageSenderID, + &i.Pinned, + &i.MediaJson, ) return i, err } @@ -1111,6 +1457,8 @@ SELECT m.peer_id, m.from_user_id, m.message_date, + m.ttl_period, + m.expires_at, m.edit_date, m.outgoing, m.body, @@ -1121,6 +1469,7 @@ SELECT m.reply_to_peer_type, m.reply_to_peer_id, m.reply_to_top_id, + m.reply_to_story_id, m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, @@ -1128,10 +1477,21 @@ SELECT m.fwd_from_peer_id, m.fwd_from_name, m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, m.pts, m.media::text AS media_json, m.media_unread, m.reaction_unread, + m.pinned, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, COALESCE(peer_u.id, 0)::bigint AS peer_user_id, COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, COALESCE(peer_u.phone, '')::text AS peer_phone, @@ -1141,6 +1501,11 @@ SELECT COALESCE(peer_u.country_code, '')::text AS peer_country_code, COALESCE(peer_u.verified, false)::boolean AS peer_verified, COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(peer_u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM peer_u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(peer_u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(peer_u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at, COALESCE(from_u.id, 0)::bigint AS from_user_user_id, COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, @@ -1151,6 +1516,11 @@ SELECT COALESCE(from_u.country_code, '')::text AS from_user_country_code, COALESCE(from_u.verified, false)::boolean AS from_user_verified, COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.is_bot, false)::boolean AS from_user_is_bot, + COALESCE(from_u.bot_info_version, 0)::int AS from_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM from_u.premium_expires_at), 0)::bigint AS from_user_premium_until, + COALESCE(from_u.emoji_status_document_id, 0)::bigint AS from_user_emoji_status_document_id, + COALESCE(from_u.emoji_status_until, 0)::bigint AS from_user_emoji_status_until, COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at FROM unnest($1::int[]) WITH ORDINALITY AS wanted(box_id, ord) JOIN message_boxes m @@ -1168,55 +1538,79 @@ type GetMessageBoxesByIDsParams struct { } type GetMessageBoxesByIDsRow struct { - RequestedBoxID interface{} - BoxID int32 - PrivateMessageID int64 - OwnerUserID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - EditDate int32 - Outgoing bool - Body string - EntitiesJson string - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson string - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - Pts int32 - MediaJson string - MediaUnread bool - ReactionUnread bool - PeerUserID int64 - PeerAccessHash int64 - PeerPhone string - PeerFirstName string - PeerLastName string - PeerUsername string - PeerCountryCode string - PeerVerified bool - PeerSupport bool - PeerLastSeenAt int64 - FromUserUserID int64 - FromUserAccessHash int64 - FromUserPhone string - FromUserFirstName string - FromUserLastName string - FromUserUsername string - FromUserCountryCode string - FromUserVerified bool - FromUserSupport bool - FromUserLastSeenAt int64 + RequestedBoxID interface{} + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerIsBot bool + PeerBotInfoVersion int32 + PeerPremiumUntil int64 + PeerEmojiStatusDocumentID int64 + PeerEmojiStatusUntil int64 + PeerLastSeenAt int64 + FromUserUserID int64 + FromUserAccessHash int64 + FromUserPhone string + FromUserFirstName string + FromUserLastName string + FromUserUsername string + FromUserCountryCode string + FromUserVerified bool + FromUserSupport bool + FromUserIsBot bool + FromUserBotInfoVersion int32 + FromUserPremiumUntil int64 + FromUserEmojiStatusDocumentID int64 + FromUserEmojiStatusUntil int64 + FromUserLastSeenAt int64 } func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesByIDsParams) ([]GetMessageBoxesByIDsRow, error) { @@ -1237,6 +1631,8 @@ func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesB &i.PeerID, &i.FromUserID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Outgoing, &i.Body, @@ -1247,6 +1643,7 @@ func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesB &i.ReplyToPeerType, &i.ReplyToPeerID, &i.ReplyToTopID, + &i.ReplyToStoryID, &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, @@ -1254,10 +1651,21 @@ func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesB &i.FwdFromPeerID, &i.FwdFromName, &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, &i.Pts, &i.MediaJson, &i.MediaUnread, &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, &i.PeerUserID, &i.PeerAccessHash, &i.PeerPhone, @@ -1267,6 +1675,11 @@ func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesB &i.PeerCountryCode, &i.PeerVerified, &i.PeerSupport, + &i.PeerIsBot, + &i.PeerBotInfoVersion, + &i.PeerPremiumUntil, + &i.PeerEmojiStatusDocumentID, + &i.PeerEmojiStatusUntil, &i.PeerLastSeenAt, &i.FromUserUserID, &i.FromUserAccessHash, @@ -1277,6 +1690,11 @@ func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesB &i.FromUserCountryCode, &i.FromUserVerified, &i.FromUserSupport, + &i.FromUserIsBot, + &i.FromUserBotInfoVersion, + &i.FromUserPremiumUntil, + &i.FromUserEmojiStatusDocumentID, + &i.FromUserEmojiStatusUntil, &i.FromUserLastSeenAt, ); err != nil { return nil, err @@ -1306,6 +1724,8 @@ SELECT m.peer_id, m.from_user_id, m.message_date, + m.ttl_period, + m.expires_at, m.edit_date, m.outgoing, m.body, @@ -1316,6 +1736,7 @@ SELECT m.reply_to_peer_type, m.reply_to_peer_id, m.reply_to_top_id, + m.reply_to_story_id, m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, @@ -1323,10 +1744,18 @@ SELECT m.fwd_from_peer_id, m.fwd_from_name, m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, m.pts, m.media::text AS media_json, m.media_unread, - m.reaction_unread + m.reaction_unread, + m.pinned, + m.via_bot_id, + m.grouped_id FROM requested r JOIN message_boxes m ON m.owner_user_id = $1::bigint @@ -1345,36 +1774,47 @@ type GetMessageBoxesForForwardParams struct { } type GetMessageBoxesForForwardRow struct { - Ord int32 - BoxID int32 - PrivateMessageID int64 - OwnerUserID int64 - MessageSenderID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - EditDate int32 - Outgoing bool - Body string - EntitiesJson string - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson string - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - Pts int32 - MediaJson string - MediaUnread bool - ReactionUnread bool + Ord int32 + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 } func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageBoxesForForwardParams) ([]GetMessageBoxesForForwardRow, error) { @@ -1401,6 +1841,8 @@ func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageB &i.PeerID, &i.FromUserID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Outgoing, &i.Body, @@ -1411,6 +1853,7 @@ func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageB &i.ReplyToPeerType, &i.ReplyToPeerID, &i.ReplyToTopID, + &i.ReplyToStoryID, &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, @@ -1418,10 +1861,18 @@ func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageB &i.FwdFromPeerID, &i.FwdFromName, &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, &i.Pts, &i.MediaJson, &i.MediaUnread, &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, ); err != nil { return nil, err } @@ -1500,6 +1951,8 @@ SELECT recipient_user_id, random_id, message_date, + ttl_period, + expires_at, edit_date, body, entities::text AS entities_json @@ -1520,6 +1973,8 @@ type GetPrivateMessageByRandomIDRow struct { RecipientUserID int64 RandomID int64 MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 EditDate int32 Body string EntitiesJson string @@ -1534,6 +1989,8 @@ func (q *Queries) GetPrivateMessageByRandomID(ctx context.Context, arg GetPrivat &i.RecipientUserID, &i.RandomID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Body, &i.EntitiesJson, @@ -1549,6 +2006,8 @@ SELECT EXISTS ( AND m.peer_type = $2::text AND m.peer_id = $3::bigint AND ($4::int <= 0 OR m.box_id <= $4::int) + AND ($5::int <= 0 OR m.message_date >= $5::int) + AND ($6::int <= 0 OR m.message_date <= $6::int) AND NOT m.deleted LIMIT 1 )::boolean AS more @@ -1559,6 +2018,8 @@ type HasDeletableMessageBoxByPeerParams struct { PeerType string PeerID int64 MaxID int32 + MinDate int32 + MaxDate int32 } func (q *Queries) HasDeletableMessageBoxByPeer(ctx context.Context, arg HasDeletableMessageBoxByPeerParams) (bool, error) { @@ -1567,12 +2028,40 @@ func (q *Queries) HasDeletableMessageBoxByPeer(ctx context.Context, arg HasDelet arg.PeerType, arg.PeerID, arg.MaxID, + arg.MinDate, + arg.MaxDate, ) var more bool err := row.Scan(&more) return more, err } +const hasPinnedMessageBoxByPeer = `-- name: HasPinnedMessageBoxByPeer :one +SELECT EXISTS ( + SELECT 1 + FROM message_boxes m + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = $2::text + AND m.peer_id = $3::bigint + AND m.pinned + AND NOT m.deleted + LIMIT 1 +)::boolean AS more +` + +type HasPinnedMessageBoxByPeerParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) HasPinnedMessageBoxByPeer(ctx context.Context, arg HasPinnedMessageBoxByPeerParams) (bool, error) { + row := q.db.QueryRow(ctx, hasPinnedMessageBoxByPeer, arg.OwnerUserID, arg.PeerType, arg.PeerID) + var more bool + err := row.Scan(&more) + return more, err +} + const latestIncomingReadReceiptCandidate = `-- name: LatestIncomingReadReceiptCandidate :one SELECT m.message_sender_id, @@ -1630,6 +2119,328 @@ func (q *Queries) LatestIncomingReadReceiptCandidate(ctx context.Context, arg La return i, err } +const listMessagesBackward = `-- name: ListMessagesBackward :many +SELECT + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.ttl_period, + m.expires_at, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.reply_to_story_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, + m.pts, + m.media::text AS media_json, + m.media_unread, + m.reaction_unread, + m.pinned, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, + COALESCE(peer_u.id, 0)::bigint AS peer_user_id, + COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, + COALESCE(peer_u.phone, '')::text AS peer_phone, + COALESCE(peer_u.first_name, '')::text AS peer_first_name, + COALESCE(peer_u.last_name, '')::text AS peer_last_name, + COALESCE(peer_u.username, '')::text AS peer_username, + COALESCE(peer_u.country_code, '')::text AS peer_country_code, + COALESCE(peer_u.verified, false)::boolean AS peer_verified, + COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(peer_u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM peer_u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(peer_u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(peer_u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, + COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at, + COALESCE(from_u.id, 0)::bigint AS from_user_user_id, + COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, + COALESCE(from_u.phone, '')::text AS from_user_phone, + COALESCE(from_u.first_name, '')::text AS from_user_first_name, + COALESCE(from_u.last_name, '')::text AS from_user_last_name, + COALESCE(from_u.username, '')::text AS from_user_username, + COALESCE(from_u.country_code, '')::text AS from_user_country_code, + COALESCE(from_u.verified, false)::boolean AS from_user_verified, + COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.is_bot, false)::boolean AS from_user_is_bot, + COALESCE(from_u.bot_info_version, 0)::int AS from_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM from_u.premium_expires_at), 0)::bigint AS from_user_premium_until, + COALESCE(from_u.emoji_status_document_id, 0)::bigint AS from_user_emoji_status_document_id, + COALESCE(from_u.emoji_status_until, 0)::bigint AS from_user_emoji_status_until, + COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at +FROM message_boxes m +LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id +LEFT JOIN users from_u ON from_u.id = m.from_user_id +WHERE m.owner_user_id = $1::bigint + AND NOT m.deleted + AND ( + NOT $2::boolean + OR (m.peer_type = $3::text AND m.peer_id = $4::bigint) + ) + AND ( + $5::text = '' + OR m.body ILIKE ('%' || $5::text || '%') + ) + AND ($6::int <= 0 OR m.box_id < $6::int) + AND ($7::int <= 0 OR m.box_id > $7::int) + AND (NOT $8::boolean OR m.pinned) + AND ( + NOT $9::boolean + OR ( + m.media->>'kind' = 'document' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(m.media #> '{document,attributes}', '[]'::jsonb)) AS attr + WHERE attr->>'kind' = 'audio' + AND COALESCE((attr->>'voice')::boolean, false) = false + ) + ) + ) + AND ( + $10::text = '' + OR (m.saved_peer_type = $10::text AND m.saved_peer_id = $11::bigint) + ) + AND ( + ($12::int > 0 AND m.message_date < $12::int) + OR ($12::int <= 0 AND ($13::int <= 0 OR m.box_id < $13::int)) + ) +ORDER BY m.box_id DESC +OFFSET GREATEST($14::int, 0) +LIMIT $15::int +` + +type ListMessagesBackwardParams struct { + OwnerUserID int64 + HasPeer bool + PeerType string + PeerID int64 + Query string + MaxID int32 + MinID int32 + PinnedOnly bool + MusicOnly bool + SavedPeerType string + SavedPeerID int64 + OffsetDate int32 + OffsetID int32 + RowOffset int32 + LimitCount int32 +} + +type ListMessagesBackwardRow struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerIsBot bool + PeerBotInfoVersion int32 + PeerPremiumUntil int64 + PeerEmojiStatusDocumentID int64 + PeerEmojiStatusUntil int64 + PeerLastSeenAt int64 + FromUserUserID int64 + FromUserAccessHash int64 + FromUserPhone string + FromUserFirstName string + FromUserLastName string + FromUserUsername string + FromUserCountryCode string + FromUserVerified bool + FromUserSupport bool + FromUserIsBot bool + FromUserBotInfoVersion int32 + FromUserPremiumUntil int64 + FromUserEmojiStatusDocumentID int64 + FromUserEmojiStatusUntil int64 + FromUserLastSeenAt int64 +} + +// backward 热路径(add_offset>=0:初始加载/上滑翻页)的扁平静态查询。 +// 与 ListMessagesByUser 的 backward 分支逐位等价(相同 base 过滤 + 相同 anchor + +// ORDER BY box_id DESC + OFFSET GREATEST(add_offset,0) + LIMIT),但只规划单次 +// index scan + 2 LEFT JOIN,避免大 CTE 把 4 个分支+total 全树规划。total 走 +// 独立 CountMessagesByUser,仅 NeedTotalCount 时发。 +func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBackwardParams) ([]ListMessagesBackwardRow, error) { + rows, err := q.db.Query(ctx, listMessagesBackward, + arg.OwnerUserID, + arg.HasPeer, + arg.PeerType, + arg.PeerID, + arg.Query, + arg.MaxID, + arg.MinID, + arg.PinnedOnly, + arg.MusicOnly, + arg.SavedPeerType, + arg.SavedPeerID, + arg.OffsetDate, + arg.OffsetID, + arg.RowOffset, + arg.LimitCount, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMessagesBackwardRow + for rows.Next() { + var i ListMessagesBackwardRow + if err := rows.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.ReplyToStoryID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, + &i.Pts, + &i.MediaJson, + &i.MediaUnread, + &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, + &i.PeerUserID, + &i.PeerAccessHash, + &i.PeerPhone, + &i.PeerFirstName, + &i.PeerLastName, + &i.PeerUsername, + &i.PeerCountryCode, + &i.PeerVerified, + &i.PeerSupport, + &i.PeerIsBot, + &i.PeerBotInfoVersion, + &i.PeerPremiumUntil, + &i.PeerEmojiStatusDocumentID, + &i.PeerEmojiStatusUntil, + &i.PeerLastSeenAt, + &i.FromUserUserID, + &i.FromUserAccessHash, + &i.FromUserPhone, + &i.FromUserFirstName, + &i.FromUserLastName, + &i.FromUserUsername, + &i.FromUserCountryCode, + &i.FromUserVerified, + &i.FromUserSupport, + &i.FromUserIsBot, + &i.FromUserBotInfoVersion, + &i.FromUserPremiumUntil, + &i.FromUserEmojiStatusDocumentID, + &i.FromUserEmojiStatusUntil, + &i.FromUserLastSeenAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listMessagesByUser = `-- name: ListMessagesByUser :many WITH load_params AS ( SELECT @@ -1652,6 +2463,8 @@ base AS NOT MATERIALIZED ( m.peer_id, m.from_user_id, m.message_date, + m.ttl_period, + m.expires_at, m.edit_date, m.outgoing, m.body, @@ -1662,6 +2475,7 @@ base AS NOT MATERIALIZED ( m.reply_to_peer_type, m.reply_to_peer_id, m.reply_to_top_id, + m.reply_to_story_id, m.quote_text, m.quote_entities::text AS quote_entities_json, m.quote_offset, @@ -1669,10 +2483,21 @@ base AS NOT MATERIALIZED ( m.fwd_from_peer_id, m.fwd_from_name, m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, m.pts, m.media::text AS media_json, m.media_unread, m.reaction_unread, + m.pinned, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, COALESCE(peer_u.id, 0)::bigint AS peer_user_id, COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, COALESCE(peer_u.phone, '')::text AS peer_phone, @@ -1682,6 +2507,11 @@ base AS NOT MATERIALIZED ( COALESCE(peer_u.country_code, '')::text AS peer_country_code, COALESCE(peer_u.verified, false)::boolean AS peer_verified, COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(peer_u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM peer_u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(peer_u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(peer_u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(peer_u.last_seen_at, 0)::bigint AS peer_last_seen_at, COALESCE(from_u.id, 0)::bigint AS from_user_user_id, COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, @@ -1692,6 +2522,11 @@ base AS NOT MATERIALIZED ( COALESCE(from_u.country_code, '')::text AS from_user_country_code, COALESCE(from_u.verified, false)::boolean AS from_user_verified, COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.is_bot, false)::boolean AS from_user_is_bot, + COALESCE(from_u.bot_info_version, 0)::int AS from_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM from_u.premium_expires_at), 0)::bigint AS from_user_premium_until, + COALESCE(from_u.emoji_status_document_id, 0)::bigint AS from_user_emoji_status_document_id, + COALESCE(from_u.emoji_status_until, 0)::bigint AS from_user_emoji_status_until, COALESCE(from_u.last_seen_at, 0)::bigint AS from_user_last_seen_at FROM message_boxes m LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id @@ -1708,14 +2543,31 @@ base AS NOT MATERIALIZED ( ) AND ($10::int <= 0 OR m.box_id < $10::int) AND ($11::int <= 0 OR m.box_id > $11::int) + AND (NOT $12::boolean OR m.pinned) + AND ( + NOT $13::boolean + OR ( + m.media->>'kind' = 'document' + AND EXISTS ( + SELECT 1 + FROM jsonb_array_elements(COALESCE(m.media #> '{document,attributes}', '[]'::jsonb)) AS attr + WHERE attr->>'kind' = 'audio' + AND COALESCE((attr->>'voice')::boolean, false) = false + ) + ) + ) + AND ( + $14::text = '' + OR (m.saved_peer_type = $14::text AND m.saved_peer_id = $15::bigint) + ) ), total AS ( SELECT count(*)::int AS total_count FROM base - WHERE $12::boolean + WHERE $16::boolean ), backward AS ( - SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at FROM base b CROSS JOIN load_params p WHERE p.load_type = 'backward' @@ -1728,9 +2580,9 @@ backward AS ( LIMIT (SELECT limit_count FROM load_params) ), around_forward AS ( - SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.edit_date, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_last_seen_at + SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.ttl_period, f.expires_at, f.edit_date, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.reply_to_story_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.fwd_saved_from_peer_type, f.fwd_saved_from_peer_id, f.fwd_saved_from_msg_id, f.saved_peer_type, f.saved_peer_id, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.pinned, f.via_bot_id, f.grouped_id, f.effect, f.reply_markup_json, f.rich_message_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_is_bot, f.peer_bot_info_version, f.peer_premium_until, f.peer_emoji_status_document_id, f.peer_emoji_status_until, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_is_bot, f.from_user_bot_info_version, f.from_user_premium_until, f.from_user_emoji_status_document_id, f.from_user_emoji_status_until, f.from_user_last_seen_at FROM ( - SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at FROM base b CROSS JOIN load_params p WHERE p.load_type = 'around' @@ -1743,7 +2595,7 @@ around_forward AS ( ) f ), around_backward AS ( - SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at FROM base b CROSS JOIN load_params p WHERE p.load_type = 'around' @@ -1755,9 +2607,9 @@ around_backward AS ( LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0) ), forward AS ( - SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.edit_date, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_last_seen_at + SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.ttl_period, f.expires_at, f.edit_date, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.reply_to_story_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.fwd_saved_from_peer_type, f.fwd_saved_from_peer_id, f.fwd_saved_from_msg_id, f.saved_peer_type, f.saved_peer_id, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.pinned, f.via_bot_id, f.grouped_id, f.effect, f.reply_markup_json, f.rich_message_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_is_bot, f.peer_bot_info_version, f.peer_premium_until, f.peer_emoji_status_document_id, f.peer_emoji_status_until, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_is_bot, f.from_user_bot_info_version, f.from_user_premium_until, f.from_user_emoji_status_document_id, f.from_user_emoji_status_until, f.from_user_last_seen_at FROM ( - SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_last_seen_at + SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at FROM base b CROSS JOIN load_params p WHERE p.load_type = 'forward' @@ -1770,13 +2622,13 @@ forward AS ( ) f ), paged AS ( - SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, media_unread, reaction_unread, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM backward + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM backward UNION ALL - SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, media_unread, reaction_unread, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM around_forward + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM around_forward UNION ALL - SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, media_unread, reaction_unread, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM around_backward + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM around_backward UNION ALL - SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, pts, media_json, media_unread, reaction_unread, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_last_seen_at FROM forward + SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM forward ) SELECT box_id, @@ -1786,6 +2638,8 @@ SELECT peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -1796,6 +2650,7 @@ SELECT reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities_json, quote_offset, @@ -1803,10 +2658,21 @@ SELECT fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media_json, media_unread, reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup_json, + rich_message_json, peer_user_id, peer_access_hash, peer_phone, @@ -1816,6 +2682,11 @@ SELECT peer_country_code, peer_verified, peer_support, + peer_is_bot, + peer_bot_info_version, + peer_premium_until, + peer_emoji_status_document_id, + peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, @@ -1826,6 +2697,11 @@ SELECT from_user_country_code, from_user_verified, from_user_support, + from_user_is_bot, + from_user_bot_info_version, + from_user_premium_until, + from_user_emoji_status_document_id, + from_user_emoji_status_until, from_user_last_seen_at, COALESCE(total.total_count, 0)::int AS total_count FROM paged @@ -1845,59 +2721,87 @@ type ListMessagesByUserParams struct { Query string MaxID int32 MinID int32 + PinnedOnly bool + MusicOnly bool + SavedPeerType string + SavedPeerID int64 NeedTotalCount bool } type ListMessagesByUserRow struct { - BoxID int32 - PrivateMessageID int64 - OwnerUserID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - EditDate int32 - Outgoing bool - Body string - EntitiesJson string - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson string - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - Pts int32 - MediaJson string - MediaUnread bool - ReactionUnread bool - PeerUserID int64 - PeerAccessHash int64 - PeerPhone string - PeerFirstName string - PeerLastName string - PeerUsername string - PeerCountryCode string - PeerVerified bool - PeerSupport bool - PeerLastSeenAt int64 - FromUserUserID int64 - FromUserAccessHash int64 - FromUserPhone string - FromUserFirstName string - FromUserLastName string - FromUserUsername string - FromUserCountryCode string - FromUserVerified bool - FromUserSupport bool - FromUserLastSeenAt int64 - TotalCount int32 + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerIsBot bool + PeerBotInfoVersion int32 + PeerPremiumUntil int64 + PeerEmojiStatusDocumentID int64 + PeerEmojiStatusUntil int64 + PeerLastSeenAt int64 + FromUserUserID int64 + FromUserAccessHash int64 + FromUserPhone string + FromUserFirstName string + FromUserLastName string + FromUserUsername string + FromUserCountryCode string + FromUserVerified bool + FromUserSupport bool + FromUserIsBot bool + FromUserBotInfoVersion int32 + FromUserPremiumUntil int64 + FromUserEmojiStatusDocumentID int64 + FromUserEmojiStatusUntil int64 + FromUserLastSeenAt int64 + TotalCount int32 } func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUserParams) ([]ListMessagesByUserRow, error) { @@ -1913,6 +2817,10 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser arg.Query, arg.MaxID, arg.MinID, + arg.PinnedOnly, + arg.MusicOnly, + arg.SavedPeerType, + arg.SavedPeerID, arg.NeedTotalCount, ) if err != nil { @@ -1930,6 +2838,8 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser &i.PeerID, &i.FromUserID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Outgoing, &i.Body, @@ -1940,6 +2850,7 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser &i.ReplyToPeerType, &i.ReplyToPeerID, &i.ReplyToTopID, + &i.ReplyToStoryID, &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, @@ -1947,10 +2858,21 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser &i.FwdFromPeerID, &i.FwdFromName, &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, &i.Pts, &i.MediaJson, &i.MediaUnread, &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, &i.PeerUserID, &i.PeerAccessHash, &i.PeerPhone, @@ -1960,6 +2882,11 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser &i.PeerCountryCode, &i.PeerVerified, &i.PeerSupport, + &i.PeerIsBot, + &i.PeerBotInfoVersion, + &i.PeerPremiumUntil, + &i.PeerEmojiStatusDocumentID, + &i.PeerEmojiStatusUntil, &i.PeerLastSeenAt, &i.FromUserUserID, &i.FromUserAccessHash, @@ -1970,6 +2897,11 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser &i.FromUserCountryCode, &i.FromUserVerified, &i.FromUserSupport, + &i.FromUserIsBot, + &i.FromUserBotInfoVersion, + &i.FromUserPremiumUntil, + &i.FromUserEmojiStatusDocumentID, + &i.FromUserEmojiStatusUntil, &i.FromUserLastSeenAt, &i.TotalCount, ); err != nil { @@ -1983,6 +2915,180 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser return items, nil } +const listUnreadReactionMessageBoxes = `-- name: ListUnreadReactionMessageBoxes :many +SELECT + box_id, + private_message_id, + owner_user_id, + peer_type, + peer_id, + from_user_id, + message_date, + ttl_period, + expires_at, + edit_date, + outgoing, + body, + entities::text AS entities_json, + silent, + noforwards, + reply_to_msg_id, + reply_to_peer_type, + reply_to_peer_id, + reply_to_top_id, + reply_to_story_id, + quote_text, + quote_entities::text AS quote_entities_json, + quote_offset, + fwd_from_peer_type, + fwd_from_peer_id, + fwd_from_name, + fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, + pts, + media::text AS media_json, + media_unread, + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json +FROM message_boxes +WHERE owner_user_id = $1 + AND peer_type = $2 + AND peer_id = $3 + AND NOT deleted + AND reaction_unread +ORDER BY box_id DESC +LIMIT $4 +` + +type ListUnreadReactionMessageBoxesParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + PageLimit int32 +} + +type ListUnreadReactionMessageBoxesRow struct { + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string +} + +func (q *Queries) ListUnreadReactionMessageBoxes(ctx context.Context, arg ListUnreadReactionMessageBoxesParams) ([]ListUnreadReactionMessageBoxesRow, error) { + rows, err := q.db.Query(ctx, listUnreadReactionMessageBoxes, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUnreadReactionMessageBoxesRow + for rows.Next() { + var i ListUnreadReactionMessageBoxesRow + if err := rows.Scan( + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.ReplyToStoryID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, + &i.Pts, + &i.MediaJson, + &i.MediaUnread, + &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listVisibleMessageBoxesByPrivateMessage = `-- name: ListVisibleMessageBoxesByPrivateMessage :many SELECT box_id, @@ -1993,6 +3099,8 @@ SELECT peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -2003,6 +3111,7 @@ SELECT reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities::text AS quote_entities_json, quote_offset, @@ -2010,57 +3119,84 @@ SELECT fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media::text AS media_json, media_unread, - reaction_unread + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json FROM message_boxes -WHERE message_sender_id = $1::bigint - AND private_message_id = $2::bigint +WHERE owner_user_id = ANY($1::bigint[]) + AND message_sender_id = $2::bigint + AND private_message_id = $3::bigint AND NOT deleted ORDER BY owner_user_id ASC, box_id ASC FOR UPDATE ` type ListVisibleMessageBoxesByPrivateMessageParams struct { + OwnerUserIds []int64 MessageSenderID int64 PrivateMessageID int64 } type ListVisibleMessageBoxesByPrivateMessageRow struct { - BoxID int32 - PrivateMessageID int64 - OwnerUserID int64 - MessageSenderID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - EditDate int32 - Outgoing bool - Body string - EntitiesJson string - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson string - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - Pts int32 - MediaJson string - MediaUnread bool - ReactionUnread bool + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string } func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, arg ListVisibleMessageBoxesByPrivateMessageParams) ([]ListVisibleMessageBoxesByPrivateMessageRow, error) { - rows, err := q.db.Query(ctx, listVisibleMessageBoxesByPrivateMessage, arg.MessageSenderID, arg.PrivateMessageID) + rows, err := q.db.Query(ctx, listVisibleMessageBoxesByPrivateMessage, arg.OwnerUserIds, arg.MessageSenderID, arg.PrivateMessageID) if err != nil { return nil, err } @@ -2077,6 +3213,8 @@ func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, a &i.PeerID, &i.FromUserID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Outgoing, &i.Body, @@ -2087,6 +3225,7 @@ func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, a &i.ReplyToPeerType, &i.ReplyToPeerID, &i.ReplyToTopID, + &i.ReplyToStoryID, &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, @@ -2094,10 +3233,21 @@ func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, a &i.FwdFromPeerID, &i.FwdFromName, &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, &i.Pts, &i.MediaJson, &i.MediaUnread, &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, ); err != nil { return nil, err } @@ -2122,6 +3272,29 @@ func (q *Queries) MaxMessageBoxID(ctx context.Context, ownerUserID int64) (int32 return max_box_id, err } +const setMessageBoxPinned = `-- name: SetMessageBoxPinned :execrows +UPDATE message_boxes +SET pinned = $1::boolean +WHERE owner_user_id = $2::bigint + AND box_id = $3::int + AND NOT deleted + AND pinned <> $1::boolean +` + +type SetMessageBoxPinnedParams struct { + Pinned bool + OwnerUserID int64 + BoxID int32 +} + +func (q *Queries) SetMessageBoxPinned(ctx context.Context, arg SetMessageBoxPinnedParams) (int64, error) { + result, err := q.db.Exec(ctx, setMessageBoxPinned, arg.Pinned, arg.OwnerUserID, arg.BoxID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const topVisibleMessageBoxByPeer = `-- name: TopVisibleMessageBoxByPeer :one SELECT box_id, @@ -2153,6 +3326,112 @@ func (q *Queries) TopVisibleMessageBoxByPeer(ctx context.Context, arg TopVisible return i, err } +const unpinAllMessageBoxesByPeer = `-- name: UnpinAllMessageBoxesByPeer :many +WITH target AS ( + SELECT m.owner_user_id, m.box_id + FROM message_boxes m + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = $2::text + AND m.peer_id = $3::bigint + AND m.pinned + AND NOT m.deleted + ORDER BY m.box_id DESC + LIMIT $4::int + FOR UPDATE +) +UPDATE message_boxes m +SET pinned = false +FROM target t +WHERE m.owner_user_id = t.owner_user_id + AND m.box_id = t.box_id +RETURNING m.box_id, m.private_message_id, m.message_sender_id +` + +type UnpinAllMessageBoxesByPeerParams struct { + OwnerUserID int64 + PeerType string + PeerID int64 + LimitCount int32 +} + +type UnpinAllMessageBoxesByPeerRow struct { + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 +} + +// 按批清除(affectedHistory.offset 续清语义),单条 updatePinnedMessages +// 的 messages 向量随之有界。 +func (q *Queries) UnpinAllMessageBoxesByPeer(ctx context.Context, arg UnpinAllMessageBoxesByPeerParams) ([]UnpinAllMessageBoxesByPeerRow, error) { + rows, err := q.db.Query(ctx, unpinAllMessageBoxesByPeer, + arg.OwnerUserID, + arg.PeerType, + arg.PeerID, + arg.LimitCount, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []UnpinAllMessageBoxesByPeerRow + for rows.Next() { + var i UnpinAllMessageBoxesByPeerRow + if err := rows.Scan(&i.BoxID, &i.PrivateMessageID, &i.MessageSenderID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const unpinMessageBoxesByPrivateMessages = `-- name: UnpinMessageBoxesByPrivateMessages :many +WITH requested AS ( + SELECT + ($2::bigint[])[i] AS message_sender_id, + ($3::bigint[])[i] AS private_message_id + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::bigint[]) +) +UPDATE message_boxes m +SET pinned = false +FROM requested r +WHERE m.owner_user_id = $1::bigint + AND m.message_sender_id = r.message_sender_id + AND m.private_message_id = r.private_message_id + AND m.pinned + AND NOT m.deleted +RETURNING m.box_id +` + +type UnpinMessageBoxesByPrivateMessagesParams struct { + OwnerUserID int64 + MessageSenderIds []int64 + PrivateMessageIds []int64 +} + +func (q *Queries) UnpinMessageBoxesByPrivateMessages(ctx context.Context, arg UnpinMessageBoxesByPrivateMessagesParams) ([]int32, error) { + rows, err := q.db.Query(ctx, unpinMessageBoxesByPrivateMessages, arg.OwnerUserID, arg.MessageSenderIds, arg.PrivateMessageIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []int32 + for rows.Next() { + var box_id int32 + if err := rows.Scan(&box_id); err != nil { + return nil, err + } + items = append(items, box_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateDialogReadInbox = `-- name: UpdateDialogReadInbox :one UPDATE dialogs d SET @@ -2169,7 +3448,6 @@ SET ), unread_mark = false, unread_mentions_count = 0, - unread_reactions_count = 0, updated_at = now() WHERE d.user_id = $2::bigint AND d.peer_type = $3::text @@ -2239,9 +3517,13 @@ UPDATE message_boxes SET body = $1::text, entities = $2::jsonb, edit_date = $3::int, - pts = $4::int -WHERE owner_user_id = $5::bigint - AND box_id = $6::int + pts = $4::int, + reply_markup = CASE + WHEN $5::boolean THEN $6::jsonb + ELSE reply_markup + END +WHERE owner_user_id = $7::bigint + AND box_id = $8::int AND NOT deleted RETURNING box_id, @@ -2252,6 +3534,8 @@ RETURNING peer_id, from_user_id, message_date, + ttl_period, + expires_at, edit_date, outgoing, body, @@ -2262,6 +3546,7 @@ RETURNING reply_to_peer_type, reply_to_peer_id, reply_to_top_id, + reply_to_story_id, quote_text, quote_entities::text AS quote_entities_json, quote_offset, @@ -2269,51 +3554,78 @@ RETURNING fwd_from_peer_id, fwd_from_name, fwd_date, + fwd_saved_from_peer_type, + fwd_saved_from_peer_id, + fwd_saved_from_msg_id, + saved_peer_type, + saved_peer_id, pts, media::text AS media_json, media_unread, - reaction_unread + reaction_unread, + pinned, + via_bot_id, + grouped_id, + effect, + reply_markup::text AS reply_markup_json, + rich_message::text AS rich_message_json ` type UpdateMessageBoxEditParams struct { - Body string - EntitiesJson []byte - EditDate int32 - Pts int32 - OwnerUserID int64 - BoxID int32 + Body string + EntitiesJson []byte + EditDate int32 + Pts int32 + SetReplyMarkup bool + ReplyMarkupJson []byte + OwnerUserID int64 + BoxID int32 } type UpdateMessageBoxEditRow struct { - BoxID int32 - PrivateMessageID int64 - OwnerUserID int64 - MessageSenderID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - EditDate int32 - Outgoing bool - Body string - EntitiesJson string - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson string - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - Pts int32 - MediaJson string - MediaUnread bool - ReactionUnread bool + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string } func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBoxEditParams) (UpdateMessageBoxEditRow, error) { @@ -2322,6 +3634,8 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox arg.EntitiesJson, arg.EditDate, arg.Pts, + arg.SetReplyMarkup, + arg.ReplyMarkupJson, arg.OwnerUserID, arg.BoxID, ) @@ -2335,6 +3649,8 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox &i.PeerID, &i.FromUserID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Outgoing, &i.Body, @@ -2345,6 +3661,7 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox &i.ReplyToPeerType, &i.ReplyToPeerID, &i.ReplyToTopID, + &i.ReplyToStoryID, &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, @@ -2352,10 +3669,21 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox &i.FwdFromPeerID, &i.FwdFromName, &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, &i.Pts, &i.MediaJson, &i.MediaUnread, &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, ) return i, err } @@ -2364,15 +3692,21 @@ const updatePrivateMessageEdit = `-- name: UpdatePrivateMessageEdit :exec UPDATE private_messages SET body = $1::text, entities = $2::jsonb, - edit_date = $3::int -WHERE sender_user_id = $4::bigint - AND id = $5::bigint + edit_date = $3::int, + reply_markup = CASE + WHEN $4::boolean THEN $5::jsonb + ELSE reply_markup + END +WHERE sender_user_id = $6::bigint + AND id = $7::bigint ` type UpdatePrivateMessageEditParams struct { Body string EntitiesJson []byte EditDate int32 + SetReplyMarkup bool + ReplyMarkupJson []byte SenderUserID int64 PrivateMessageID int64 } @@ -2382,6 +3716,8 @@ func (q *Queries) UpdatePrivateMessageEdit(ctx context.Context, arg UpdatePrivat arg.Body, arg.EntitiesJson, arg.EditDate, + arg.SetReplyMarkup, + arg.ReplyMarkupJson, arg.SenderUserID, arg.PrivateMessageID, ) diff --git a/internal/store/postgres/sqlcgen/models.go b/internal/store/postgres/sqlcgen/models.go index 3228346f..c3ee1ee8 100644 --- a/internal/store/postgres/sqlcgen/models.go +++ b/internal/store/postgres/sqlcgen/models.go @@ -18,6 +18,25 @@ type AccountPassword struct { LoginEmailPattern string SecureRandom []byte UpdatedAt pgtype.Timestamptz + CurrentAlgoSalt1 []byte + CurrentAlgoSalt2 []byte + CurrentAlgoG int32 + CurrentAlgoP []byte + SrpID int64 + SrpVerifier []byte + SrpBSecret []byte + SrpB []byte + RecoveryEmail string + RecoveryCode string + RecoveryCodeExpiresAt pgtype.Timestamptz + LoginEmail string +} + +type AccountPrivacyRule struct { + OwnerUserID int64 + PrivacyKey string + Rules []byte + UpdatedAt pgtype.Timestamptz } type AccountReactionSetting struct { @@ -34,6 +53,21 @@ type AccountReactionSetting struct { UpdatedAt pgtype.Timestamptz } +type AccountSetting struct { + UserID int64 + ArchiveAndMuteNewNoncontactPeers bool + KeepArchivedUnmuted bool + KeepArchivedFolders bool + HideReadMarks bool + NewNoncontactPeersRequirePremium bool + DisplayGiftsButton bool + NoncontactPeersPaidStars int64 + AccountTtlDays int32 + SensitiveContentEnabled bool + ContactSignupSilent bool + UpdatedAt pgtype.Timestamptz +} + type AppConfig struct { Client string Hash int32 @@ -49,18 +83,19 @@ type AuthKey struct { } type Authorization struct { - AuthKeyID int64 - UserID int64 - Hash int64 - Layer int32 - DeviceModel string - Platform string - SystemVersion string - ApiID int32 - AppVersion string - Ip string - CreatedAt pgtype.Timestamptz - ActiveAt pgtype.Timestamptz + AuthKeyID int64 + UserID int64 + Hash int64 + Layer int32 + DeviceModel string + Platform string + SystemVersion string + ApiID int32 + AppVersion string + Ip string + CreatedAt pgtype.Timestamptz + ActiveAt pgtype.Timestamptz + PasswordPending bool } type AvailableReaction struct { @@ -78,6 +113,78 @@ type AvailableReaction struct { SortOrder int32 } +type Bot struct { + BotUserID int64 + OwnerUserID int64 + TokenSecret string + Description string + Commands []byte + BotChatHistory bool + BotNochats bool + InlinePlaceholder string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + MenuButtonType int16 + MenuButtonText string + MenuButtonUrl string + BotInlineGeo bool +} + +type BotChatState struct { + BotUserID int64 + UserID int64 + State []byte + UpdatedAt pgtype.Timestamptz +} + +type BotUserPermission struct { + BotUserID int64 + UserID int64 + FromRequest bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type BusinessAutomationDelivery struct { + OwnerUserID int64 + PeerUserID int64 + Kind string + TriggerMessageID int32 + ShortcutID int32 + SentAt int32 + CreatedAt pgtype.Timestamptz +} + +type BusinessChatLink struct { + Slug string + OwnerUserID int64 + Link string + Message string + Entities []byte + Title string + Views int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type BusinessConnectedBot struct { + OwnerUserID int64 + BotUserID int64 + Recipients []byte + Rights []byte + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type BusinessConnectedBotPeerState struct { + OwnerUserID int64 + PeerUserID int64 + Paused bool + Disabled bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + type Channel struct { ID int64 AccessHash int64 @@ -128,6 +235,15 @@ type Channel struct { PhotoID int64 PhotoDcID int32 PhotoStripped []byte + ReadInboxTop1UserID int64 + ReadInboxTop1 int32 + ReadInboxTop2 int32 + ActiveCallID int64 + ActiveCallAccessHash int64 + ActiveCallNotEmpty bool + BoostsUnrestrict int32 + Monoforum bool + LinkedMonoforumID int64 } type ChannelAdminLogEvent struct { @@ -152,6 +268,25 @@ type ChannelAdminLogEvent struct { CreatedAt pgtype.Timestamptz } +type ChannelBoostSlot struct { + UserID int64 + Slot int32 + PeerType string + PeerID int64 + AssignedAt int32 + ExpiresAt int32 + CooldownUntil int32 + Multiplier int32 + Gift bool + Giveaway bool + Unclaimed bool + GiveawayMsgID int32 + UsedGiftSlug string + Stars int64 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + type ChannelDialog struct { UserID int64 ChannelID int64 @@ -172,6 +307,7 @@ type ChannelDialog struct { UpdatedAt pgtype.Timestamptz DefaultSendAsPeerType *string DefaultSendAsPeerID *int64 + HasScheduled bool } type ChannelForumTopic struct { @@ -236,6 +372,13 @@ type ChannelInviteImporter struct { UpdatedAt pgtype.Timestamptz } +type ChannelMediaCategoryCount struct { + ChannelID int64 + Category int16 + MediaCount int32 + UpdatedAt pgtype.Timestamptz +} + type ChannelMember struct { ChannelID int64 UserID int64 @@ -289,6 +432,33 @@ type ChannelMessage struct { UpdatedAt pgtype.Timestamptz ViewsCount int32 Media []byte + TtlPeriod int32 + ExpiresAt int32 + PostAuthor string + Pinned bool + ViaBotID int64 + ReplyMarkup []byte + FromBoostsApplied int32 + RichMessage []byte + GroupedID int64 + SavedPeerType string + SavedPeerID int64 +} + +type ChannelMessageMedium struct { + ChannelID int64 + ID int32 + Category int16 + MessageDate int32 +} + +type ChannelMessagePaidReaction struct { + ChannelID int64 + MessageID int32 + ReactorUserID int64 + Stars int64 + Anonymous bool + ReactionDate int32 } type ChannelMessageReaction struct { @@ -314,6 +484,16 @@ type ChannelMessageViewer struct { CreatedAt pgtype.Timestamptz } +type ChannelTopicRead struct { + ChannelID int64 + UserID int64 + TopicID int32 + ReadInboxMaxID int32 + ReadOutboxMaxID int32 + ReadInboxDate int32 + UpdatedAt pgtype.Timestamptz +} + type ChannelUnreadMention struct { UserID int64 ChannelID int64 @@ -321,6 +501,14 @@ type ChannelUnreadMention struct { TopMessageID int32 CreatedAt pgtype.Timestamptz MediaUnread bool + Unread bool +} + +type ChannelUnreadMentionIndex struct { + ChannelID int64 + MessageID int32 + UserID int64 + CreatedAt pgtype.Timestamptz } type ChannelUpdateEvent struct { @@ -344,18 +532,20 @@ type ChannelUsername struct { } type Contact struct { - UserID int64 - ContactUserID int64 - Mutual bool - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz - ContactPhone string - ContactFirstName string - ContactLastName string - Note string - NoteEntities []byte - CloseFriend bool - StoriesHidden bool + UserID int64 + ContactUserID int64 + Mutual bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + ContactPhone string + ContactFirstName string + ContactLastName string + Note string + NoteEntities []byte + CloseFriend bool + StoriesHidden bool + PersonalPhotoID int64 + PersonalPhotoDate int32 } type ContactBlock struct { @@ -400,6 +590,9 @@ type Dialog struct { UnreadMark bool HiddenPeerSettingsBar bool FolderID int32 + TtlPeriod int32 + HasScheduled bool + ThemeEmoticon string } type DialogDraft struct { @@ -424,9 +617,11 @@ type DialogFilter struct { } type DialogFilterSetting struct { - UserID int64 - TagsEnabled bool - UpdatedAt pgtype.Timestamptz + UserID int64 + TagsEnabled bool + UpdatedAt pgtype.Timestamptz + ArchivePinned bool + ArchivePinnedOrder int32 } type DispatchOutbox struct { @@ -435,13 +630,13 @@ type DispatchOutbox struct { Pts int32 EventType string ExcludeSessionID int64 + ExcludeAuthKeyID int64 Status string Attempts int32 NextAttemptAt pgtype.Timestamptz LastError string CreatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz - ExcludeAuthKeyID int64 } type Document struct { @@ -457,6 +652,50 @@ type Document struct { CreatedAt pgtype.Timestamptz } +type EncryptedFile struct { + ID int64 + AccessHash int64 + OwnerUserID int64 + Size int64 + DcID int32 + KeyFingerprint int32 + CreatedAt pgtype.Timestamptz +} + +type EncryptedMessageQueue struct { + ReceiverAuthKeyID int64 + Qts int32 + ReceiverUserID int64 + ChatID int32 + RandomID int64 + Date int32 + IsService bool + Bytes []byte + FileID *int64 + FileAccessHash *int64 + FileSize *int64 + FileDcID *int32 + FileKeyFingerprint *int32 + Acked bool + CreatedAt pgtype.Timestamptz +} + +type EncryptedStateEvent struct { + ID int64 + TargetUserID int64 + TargetAuthKeyID int64 + ChatID int32 + EventType int16 + MaxDate int32 + Date int32 + CreatedAt pgtype.Timestamptz +} + +type EncryptedStateEventDelivery struct { + EventID int64 + AuthKeyID int64 +} + type FileBlob struct { LocationKey string Backend string @@ -467,6 +706,46 @@ type FileBlob struct { CreatedAt pgtype.Timestamptz } +type GroupCall struct { + CallID int64 + AccessHash int64 + ChannelID int64 + CreatorUserID int64 + State string + Title string + JoinMuted bool + Version int32 + ParticipantsCount int32 + CreatedAt int32 + DiscardedAt int32 + Duration int32 + StartedMsgID int32 +} + +type GroupCallParticipant struct { + CallID int64 + UserID int64 + Ssrc int64 + JoinDate int32 + ActiveDate int32 + Muted bool + MutedByAdmin bool + VolumeByAdmin int32 + RaiseHandRating int64 + VideoJson []byte + PresentationJson []byte + LeftCall bool + LastCheckDate int32 +} + +type GroupCallParticipantOverride struct { + CallID int64 + SetterUserID int64 + TargetUserID int64 + MutedByYou bool + Volume int32 +} + type LangPack struct { LangPack string LangCode string @@ -493,37 +772,85 @@ type LangPackString struct { } type MessageBox struct { - OwnerUserID int64 - BoxID int32 - PrivateMessageID int64 - MessageSenderID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - Outgoing bool - Body string - Entities []byte - Pts int32 - Deleted bool - CreatedAt pgtype.Timestamptz - EditDate int32 - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntities []byte - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - Media []byte - MediaUnread bool - ReactionUnread bool + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + Outgoing bool + Body string + Entities []byte + Pts int32 + Deleted bool + CreatedAt pgtype.Timestamptz + EditDate int32 + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntities []byte + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + Media []byte + MediaUnread bool + ReactionUnread bool + TtlPeriod int32 + ExpiresAt int32 + Pinned bool + SavedPeerType string + SavedPeerID int64 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + ReplyMarkup []byte + ViaBotID int64 + RichMessage []byte + GroupedID int64 + ReplyToStoryID int32 + Effect int64 +} + +type MessageBoxMedium struct { + OwnerUserID int64 + BoxID int32 + PeerID int64 + Category int16 + MessageDate int32 +} + +type NotifySetting struct { + OwnerUserID int64 + ScopeKind string + PeerType string + PeerID int64 + TopicID int32 + ShowPreviews *bool + Silent *bool + MuteUntil *int32 + StoriesMuted *bool + StoriesHideSender *bool + UpdatedAt pgtype.Timestamptz +} + +type PasskeyCredential struct { + CredentialID []byte + UserID int64 + PublicKey []byte + SignCount int64 + Aaguid []byte + Name string + Transports []string + CreatedAt pgtype.Timestamptz + LastUsedAt pgtype.Timestamptz } type Photo struct { @@ -537,6 +864,40 @@ type Photo struct { CreatedAt pgtype.Timestamptz } +type Poll struct { + PollID int64 + CreatorUserID int64 + MultipleChoice bool + Quiz bool + PublicVoters bool + RevotingDisabled bool + HideResults bool + Closed bool + ClosePeriod int32 + CloseDate int32 + Options []byte + CorrectOptions []byte + Solution string + SolutionEntities []byte + CreatedAt pgtype.Timestamptz +} + +type PollVote struct { + PollID int64 + UserID int64 + Options []byte + VoteDate int32 + CreatedAt pgtype.Timestamptz +} + +type PrivateMediaCategoryCount struct { + OwnerUserID int64 + PeerID int64 + Category int16 + MediaCount int32 + UpdatedAt pgtype.Timestamptz +} + type PrivateMessage struct { ID int64 SenderUserID int64 @@ -561,6 +922,14 @@ type PrivateMessage struct { FwdFromName string FwdDate int32 Media []byte + TtlPeriod int32 + ExpiresAt int32 + ReplyMarkup []byte + ViaBotID int64 + RichMessage []byte + GroupedID int64 + ReplyToStoryID int32 + Effect int64 } type PrivateMessageReaction struct { @@ -584,6 +953,135 @@ type ProfilePhoto struct { Active bool SortOrder int64 CreatedAt pgtype.Timestamptz + Kind string +} + +type QuickReply struct { + OwnerUserID int64 + ShortcutID int32 + Shortcut string + SortOrder int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type QuickReplyMessage struct { + OwnerUserID int64 + ShortcutID int32 + MessageID int32 + RandomID int64 + MessageDate int32 + Body string + Entities []byte + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ReadModelVersion struct { + Model string + OwnerUserID int64 + PeerType string + PeerID int64 + Version int64 + UpdatedAt pgtype.Timestamptz + Hash int64 +} + +type SavedDialogPin struct { + UserID int64 + PeerType string + PeerID int64 + PinnedOrder int32 + CreatedAt pgtype.Timestamptz +} + +type SavedMusic struct { + UserID int64 + DocumentID int64 + SortOrder int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type ScheduledMessage struct { + OwnerUserID int64 + ScheduledID int32 + PeerType string + PeerID int64 + RandomID int64 + MessageDate int32 + Body string + Entities []byte + Media []byte + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + QuoteText string + QuoteEntities []byte + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + SendAsPeerType string + SendAsPeerID int64 + ScheduleDate int32 + ScheduleRepeatPeriod int32 + State string + LeaseUntil int32 + SentMessageID int32 + LastError string + CreatedAt int32 + UpdatedAt int32 +} + +type SecretChat struct { + ChatID int32 + AdminAccessHash int64 + ParticipantAccessHash int64 + AdminUserID int64 + AdminAuthKeyID int64 + ParticipantUserID int64 + ParticipantAuthKeyID int64 + State string + GA []byte + GB []byte + KeyFingerprint int64 + Layer int32 + FolderID int32 + HistoryDeleted bool + RandomID int32 + Date int32 + CreatedAt pgtype.Timestamptz +} + +type SecretQtsWatermark struct { + AuthKeyID int64 + ReservedQts int32 + ConfirmedQts int32 + UpdatedAt pgtype.Timestamptz +} + +type StarsBalance struct { + UserID int64 + Balance int64 + Granted bool + UpdatedAt pgtype.Timestamptz +} + +type StarsTransaction struct { + ID int64 + UserID int64 + PeerType string + PeerID int64 + Amount int64 + Reason string + Title string + Description string + Date int32 } type StickerSet struct { @@ -613,6 +1111,60 @@ type StickerSet struct { CreatedAt pgtype.Timestamptz } +type Story struct { + OwnerPeerType string + OwnerPeerID int64 + StoryID int32 + RandomID int64 + Date int32 + ExpireDate int32 + Deleted bool + Pinned bool + Public bool + CloseFriends bool + Contacts bool + SelectedContacts bool + Noforwards bool + Edited bool + PrivacyRules []byte + AllowUserIds []int64 + DisallowUserIds []int64 + Caption string + Entities []byte + Media []byte + MediaAreas []byte + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + PinnedToTopOrder int32 + FwdFrom []byte +} + +type StoryHiddenPeer struct { + ViewerUserID int64 + OwnerPeerType string + OwnerPeerID int64 + UpdatedAt pgtype.Timestamptz +} + +type StoryReadState struct { + ViewerUserID int64 + OwnerPeerType string + OwnerPeerID int64 + MaxReadID int32 + Date int32 + UpdatedAt pgtype.Timestamptz +} + +type StoryView struct { + OwnerPeerType string + OwnerPeerID int64 + StoryID int32 + ViewerUserID int64 + Date int32 + Reaction []byte + UpdatedAt pgtype.Timestamptz +} + type TempAuthKeyBinding struct { TempAuthKeyID int64 PermAuthKeyID int64 @@ -623,6 +1175,27 @@ type TempAuthKeyBinding struct { TempSessionID int64 } +type Theme struct { + ID int64 + AccessHash int64 + CreatorUserID int64 + Slug string + Title string + Emoticon string + ForChat bool + DocumentID int64 + Settings []byte + InstallsCount int32 + CreatedAt pgtype.Timestamptz +} + +type ThemeUserInstall struct { + UserID int64 + ThemeID int64 + Dark bool + InstalledAt pgtype.Timestamptz +} + type UpdateState struct { AuthKeyID int64 Pts int32 @@ -639,34 +1212,68 @@ type UploadPart struct { Part int32 TotalParts int32 IsBig bool - Bytes []byte CreatedAt pgtype.Timestamptz + Backend string + ObjectKey string + Size int64 + Sha256 []byte } type User struct { - ID int64 - AccessHash int64 - Phone string - FirstName string - LastName string - Username string - CountryCode string - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz - Verified bool - Support bool - About string - LastSeenAt int64 + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + Verified bool + Support bool + About string + LastSeenAt int64 + DefaultHistoryTtlPeriod int32 + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + ColorSet bool + Color int32 + ColorBackgroundEmojiID int64 + ProfileColorSet bool + ProfileColor int32 + ProfileColorBackgroundEmojiID int64 + BirthdayDay int32 + BirthdayMonth int32 + BirthdayYear int32 + PersonalChannelID int64 +} + +type UserBusinessProfile struct { + UserID int64 + WorkHours []byte + Location []byte + Intro []byte + GreetingMessage []byte + AwayMessage []byte + UpdatedAt pgtype.Timestamptz } type UserChannelMemberIndex struct { - UserID int64 - ChannelID int64 - Status string - Megagroup bool - Broadcast bool - Deleted bool - UpdatedAt pgtype.Timestamptz + UserID int64 + ChannelID int64 + Status string + Megagroup bool + Broadcast bool + Deleted bool + UpdatedAt pgtype.Timestamptz + Role string + LeftAt int32 + Forum bool + PublicUsername bool + CanPinMessages bool } type UserRecentReaction struct { @@ -688,6 +1295,27 @@ type UserSavedReactionTag struct { UpdatedAt pgtype.Timestamptz } +type UserStarGift struct { + ID int64 + OwnerUserID int64 + FromUserID int64 + GiftID int64 + MsgID int32 + GiftDate int32 + NameHidden bool + Unsaved bool + Converted bool + ConvertStars int64 + Message string +} + +type UserStickerCollection struct { + OwnerUserID int64 + Kind string + DocumentID int64 + UsedAt int32 +} + type UserTopReaction struct { UserID int64 ReactionType string @@ -699,26 +1327,32 @@ type UserTopReaction struct { } type UserUpdateEvent struct { - UserID int64 - Pts int32 - PtsCount int32 - Date int32 - EventType string - MessageBoxID *int32 - PeerType *string - PeerID *int64 - MaxID int32 - StillUnreadCount int32 - CreatedAt pgtype.Timestamptz - EventBool bool - EventPeers []byte - PeerSettings []byte - MessageIds []byte - DialogFilter []byte - FilterOrder []byte - FolderPeers []byte - FilterID int32 - TagsEnabled bool + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + MessageBoxID *int32 + PeerType *string + PeerID *int64 + MaxID int32 + StillUnreadCount int32 + CreatedAt pgtype.Timestamptz + EventBool bool + EventPeers []byte + PeerSettings []byte + MessageIds []byte + DialogFilter []byte + FilterOrder []byte + FolderPeers []byte + FilterID int32 + TagsEnabled bool + ChannelPts int32 + FolderID int32 + QuickReplies []byte + QuickReplyMessage []byte + StoryPayload []byte + ReactionPayload []byte } type UserUpdateWatermark struct { @@ -726,3 +1360,12 @@ type UserUpdateWatermark struct { ContiguousPts int32 UpdatedAt pgtype.Timestamptz } + +type WebPage struct { + UrlHash int64 + WebPageID int64 + State string + Snapshot []byte + CreatedAt int64 + RefreshedAt int64 +} diff --git a/internal/store/postgres/sqlcgen/saved_dialog.sql.go b/internal/store/postgres/sqlcgen/saved_dialog.sql.go new file mode 100644 index 00000000..2eb722b4 --- /dev/null +++ b/internal/store/postgres/sqlcgen/saved_dialog.sql.go @@ -0,0 +1,865 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: saved_dialog.sql + +package sqlcgen + +import ( + "context" +) + +const clearSavedDialogPinsNotInOrder = `-- name: ClearSavedDialogPinsNotInOrder :exec +DELETE FROM saved_dialog_pins p +WHERE p.user_id = $1::bigint + AND NOT EXISTS ( + SELECT 1 + FROM generate_subscripts($2::bigint[], 1) AS g(i) + WHERE i <= cardinality($3::text[]) + AND ($3::text[])[i] = p.peer_type + AND ($2::bigint[])[i] = p.peer_id + ) +` + +type ClearSavedDialogPinsNotInOrderParams struct { + UserID int64 + PeerIds []int64 + PeerTypes []string +} + +func (q *Queries) ClearSavedDialogPinsNotInOrder(ctx context.Context, arg ClearSavedDialogPinsNotInOrderParams) error { + _, err := q.db.Exec(ctx, clearSavedDialogPinsNotInOrder, arg.UserID, arg.PeerIds, arg.PeerTypes) + return err +} + +const countSavedDialogPins = `-- name: CountSavedDialogPins :one +SELECT count(*)::int AS pin_count +FROM saved_dialog_pins +WHERE user_id = $1 +` + +func (q *Queries) CountSavedDialogPins(ctx context.Context, userID int64) (int32, error) { + row := q.db.QueryRow(ctx, countSavedDialogPins, userID) + var pin_count int32 + err := row.Scan(&pin_count) + return pin_count, err +} + +const countSavedDialogs = `-- name: CountSavedDialogs :one +SELECT count(*)::int AS dialog_count +FROM ( + SELECT m.saved_peer_type, m.saved_peer_id + FROM message_boxes m + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = 'user' + AND m.peer_id = $1::bigint + AND NOT m.deleted + AND m.saved_peer_type <> '' + GROUP BY m.saved_peer_type, m.saved_peer_id +) d +LEFT JOIN saved_dialog_pins p + ON p.user_id = $1::bigint + AND p.peer_type = d.saved_peer_type + AND p.peer_id = d.saved_peer_id +WHERE NOT $2::boolean OR p.user_id IS NULL +` + +type CountSavedDialogsParams struct { + OwnerUserID int64 + ExcludePinned bool +} + +func (q *Queries) CountSavedDialogs(ctx context.Context, arg CountSavedDialogsParams) (int32, error) { + row := q.db.QueryRow(ctx, countSavedDialogs, arg.OwnerUserID, arg.ExcludePinned) + var dialog_count int32 + err := row.Scan(&dialog_count) + return dialog_count, err +} + +const deleteMessageBoxesBySavedPeerBatch = `-- name: DeleteMessageBoxesBySavedPeerBatch :many +WITH target AS ( + SELECT + m.owner_user_id, + m.box_id + FROM message_boxes m + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = 'user' + AND m.peer_id = $1::bigint + AND m.saved_peer_type = $2::text + AND m.saved_peer_id = $3::bigint + AND ($4::int <= 0 OR m.box_id <= $4::int) + AND ($5::int <= 0 OR m.message_date >= $5::int) + AND ($6::int <= 0 OR m.message_date <= $6::int) + AND NOT m.deleted + ORDER BY m.box_id DESC + LIMIT $7::int + FOR UPDATE SKIP LOCKED +), +updated AS ( + UPDATE message_boxes m + SET deleted = true + FROM target t + WHERE m.owner_user_id = t.owner_user_id + AND m.box_id = t.box_id + RETURNING + m.owner_user_id, + m.box_id, + m.private_message_id, + m.message_sender_id, + m.peer_type, + m.peer_id +) +SELECT + owner_user_id, + box_id, + private_message_id, + message_sender_id, + peer_type, + peer_id +FROM updated +ORDER BY box_id ASC +` + +type DeleteMessageBoxesBySavedPeerBatchParams struct { + OwnerUserID int64 + SavedPeerType string + SavedPeerID int64 + MaxID int32 + MinDate int32 + MaxDate int32 + LimitCount int32 +} + +type DeleteMessageBoxesBySavedPeerBatchRow struct { + OwnerUserID int64 + BoxID int32 + PrivateMessageID int64 + MessageSenderID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteMessageBoxesBySavedPeerBatch(ctx context.Context, arg DeleteMessageBoxesBySavedPeerBatchParams) ([]DeleteMessageBoxesBySavedPeerBatchRow, error) { + rows, err := q.db.Query(ctx, deleteMessageBoxesBySavedPeerBatch, + arg.OwnerUserID, + arg.SavedPeerType, + arg.SavedPeerID, + arg.MaxID, + arg.MinDate, + arg.MaxDate, + arg.LimitCount, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []DeleteMessageBoxesBySavedPeerBatchRow + for rows.Next() { + var i DeleteMessageBoxesBySavedPeerBatchRow + if err := rows.Scan( + &i.OwnerUserID, + &i.BoxID, + &i.PrivateMessageID, + &i.MessageSenderID, + &i.PeerType, + &i.PeerID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const deleteSavedDialogPin = `-- name: DeleteSavedDialogPin :execrows +DELETE FROM saved_dialog_pins +WHERE user_id = $1 + AND peer_type = $2 + AND peer_id = $3 +` + +type DeleteSavedDialogPinParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) DeleteSavedDialogPin(ctx context.Context, arg DeleteSavedDialogPinParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteSavedDialogPin, arg.UserID, arg.PeerType, arg.PeerID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const hasDeletableMessageBoxBySavedPeer = `-- name: HasDeletableMessageBoxBySavedPeer :one +SELECT EXISTS ( + SELECT 1 + FROM message_boxes m + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = 'user' + AND m.peer_id = $1::bigint + AND m.saved_peer_type = $2::text + AND m.saved_peer_id = $3::bigint + AND ($4::int <= 0 OR m.box_id <= $4::int) + AND ($5::int <= 0 OR m.message_date >= $5::int) + AND ($6::int <= 0 OR m.message_date <= $6::int) + AND NOT m.deleted + LIMIT 1 +)::boolean AS more +` + +type HasDeletableMessageBoxBySavedPeerParams struct { + OwnerUserID int64 + SavedPeerType string + SavedPeerID int64 + MaxID int32 + MinDate int32 + MaxDate int32 +} + +func (q *Queries) HasDeletableMessageBoxBySavedPeer(ctx context.Context, arg HasDeletableMessageBoxBySavedPeerParams) (bool, error) { + row := q.db.QueryRow(ctx, hasDeletableMessageBoxBySavedPeer, + arg.OwnerUserID, + arg.SavedPeerType, + arg.SavedPeerID, + arg.MaxID, + arg.MinDate, + arg.MaxDate, + ) + var more bool + err := row.Scan(&more) + return more, err +} + +const listPinnedSavedDialogTops = `-- name: ListPinnedSavedDialogTops :many +WITH tops AS ( + SELECT m.saved_peer_type, m.saved_peer_id, MAX(m.box_id) AS top_box_id + FROM message_boxes m + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = 'user' + AND m.peer_id = $1::bigint + AND NOT m.deleted + AND m.saved_peer_type <> '' + GROUP BY m.saved_peer_type, m.saved_peer_id +) +SELECT + t.saved_peer_type AS dialog_peer_type, + t.saved_peer_id AS dialog_peer_id, + true::boolean AS dialog_pinned, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.ttl_period, + m.expires_at, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.reply_to_story_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, + m.pts, + m.media::text AS media_json, + m.media_unread, + m.reaction_unread, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, + m.pinned +FROM saved_dialog_pins p +JOIN tops t + ON t.saved_peer_type = p.peer_type + AND t.saved_peer_id = p.peer_id +JOIN message_boxes m + ON m.owner_user_id = $1::bigint + AND m.box_id = t.top_box_id +WHERE p.user_id = $1::bigint +ORDER BY p.pinned_order ASC, p.peer_id ASC +` + +type ListPinnedSavedDialogTopsRow struct { + DialogPeerType string + DialogPeerID int64 + DialogPinned bool + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string + Pinned bool +} + +func (q *Queries) ListPinnedSavedDialogTops(ctx context.Context, ownerUserID int64) ([]ListPinnedSavedDialogTopsRow, error) { + rows, err := q.db.Query(ctx, listPinnedSavedDialogTops, ownerUserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPinnedSavedDialogTopsRow + for rows.Next() { + var i ListPinnedSavedDialogTopsRow + if err := rows.Scan( + &i.DialogPeerType, + &i.DialogPeerID, + &i.DialogPinned, + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.ReplyToStoryID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, + &i.Pts, + &i.MediaJson, + &i.MediaUnread, + &i.ReactionUnread, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, + &i.Pinned, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSavedDialogTops = `-- name: ListSavedDialogTops :many + +WITH tops AS ( + SELECT m.saved_peer_type, m.saved_peer_id, MAX(m.box_id) AS top_box_id + FROM message_boxes m + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = 'user' + AND m.peer_id = $1::bigint + AND NOT m.deleted + AND m.saved_peer_type <> '' + GROUP BY m.saved_peer_type, m.saved_peer_id +) +SELECT + t.saved_peer_type AS dialog_peer_type, + t.saved_peer_id AS dialog_peer_id, + (p.user_id IS NOT NULL)::boolean AS dialog_pinned, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.ttl_period, + m.expires_at, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.reply_to_story_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, + m.pts, + m.media::text AS media_json, + m.media_unread, + m.reaction_unread, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, + m.pinned +FROM tops t +JOIN message_boxes m + ON m.owner_user_id = $1::bigint + AND m.box_id = t.top_box_id +LEFT JOIN saved_dialog_pins p + ON p.user_id = $1::bigint + AND p.peer_type = t.saved_peer_type + AND p.peer_id = t.saved_peer_id +WHERE ($2::int <= 0 OR t.top_box_id < $2::int) + AND (NOT $3::boolean OR p.user_id IS NULL) +ORDER BY t.top_box_id DESC +LIMIT $4::int +` + +type ListSavedDialogTopsParams struct { + OwnerUserID int64 + OffsetID int32 + ExcludePinned bool + LimitCount int32 +} + +type ListSavedDialogTopsRow struct { + DialogPeerType string + DialogPeerID int64 + DialogPinned bool + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string + Pinned bool +} + +// Saved Messages 分会话(saved dialogs)查询组。 +// 子会话 = self-chat box 行按 saved_peer 分组;top message 不物化,由 +// MAX(box_id) 聚合现算(box_id 单调于时间,date 序与 id 序一致), +// 删除/清史后的一致性由查询本身保证。 +func (q *Queries) ListSavedDialogTops(ctx context.Context, arg ListSavedDialogTopsParams) ([]ListSavedDialogTopsRow, error) { + rows, err := q.db.Query(ctx, listSavedDialogTops, + arg.OwnerUserID, + arg.OffsetID, + arg.ExcludePinned, + arg.LimitCount, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListSavedDialogTopsRow + for rows.Next() { + var i ListSavedDialogTopsRow + if err := rows.Scan( + &i.DialogPeerType, + &i.DialogPeerID, + &i.DialogPinned, + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.ReplyToStoryID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, + &i.Pts, + &i.MediaJson, + &i.MediaUnread, + &i.ReactionUnread, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, + &i.Pinned, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSavedDialogTopsByPeers = `-- name: ListSavedDialogTopsByPeers :many +WITH requested AS ( + SELECT + ($2::text[])[i] AS peer_type, + ($3::bigint[])[i] AS peer_id + FROM generate_subscripts($3::bigint[], 1) AS g(i) + WHERE i <= cardinality($2::text[]) +), +tops AS ( + SELECT m.saved_peer_type, m.saved_peer_id, MAX(m.box_id) AS top_box_id + FROM message_boxes m + JOIN requested r + ON r.peer_type = m.saved_peer_type + AND r.peer_id = m.saved_peer_id + WHERE m.owner_user_id = $1::bigint + AND m.peer_type = 'user' + AND m.peer_id = $1::bigint + AND NOT m.deleted + GROUP BY m.saved_peer_type, m.saved_peer_id +) +SELECT + t.saved_peer_type AS dialog_peer_type, + t.saved_peer_id AS dialog_peer_id, + (p.user_id IS NOT NULL)::boolean AS dialog_pinned, + m.box_id, + m.private_message_id, + m.owner_user_id, + m.peer_type, + m.peer_id, + m.from_user_id, + m.message_date, + m.ttl_period, + m.expires_at, + m.edit_date, + m.outgoing, + m.body, + m.entities::text AS entities_json, + m.silent, + m.noforwards, + m.reply_to_msg_id, + m.reply_to_peer_type, + m.reply_to_peer_id, + m.reply_to_top_id, + m.reply_to_story_id, + m.quote_text, + m.quote_entities::text AS quote_entities_json, + m.quote_offset, + m.fwd_from_peer_type, + m.fwd_from_peer_id, + m.fwd_from_name, + m.fwd_date, + m.fwd_saved_from_peer_type, + m.fwd_saved_from_peer_id, + m.fwd_saved_from_msg_id, + m.saved_peer_type, + m.saved_peer_id, + m.pts, + m.media::text AS media_json, + m.media_unread, + m.reaction_unread, + m.via_bot_id, + m.grouped_id, + m.effect, + m.reply_markup::text AS reply_markup_json, + m.rich_message::text AS rich_message_json, + m.pinned +FROM tops t +JOIN message_boxes m + ON m.owner_user_id = $1::bigint + AND m.box_id = t.top_box_id +LEFT JOIN saved_dialog_pins p + ON p.user_id = $1::bigint + AND p.peer_type = t.saved_peer_type + AND p.peer_id = t.saved_peer_id +ORDER BY t.top_box_id DESC +` + +type ListSavedDialogTopsByPeersParams struct { + OwnerUserID int64 + PeerTypes []string + PeerIds []int64 +} + +type ListSavedDialogTopsByPeersRow struct { + DialogPeerType string + DialogPeerID int64 + DialogPinned bool + BoxID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + EntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + Pts int32 + MediaJson string + MediaUnread bool + ReactionUnread bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string + Pinned bool +} + +func (q *Queries) ListSavedDialogTopsByPeers(ctx context.Context, arg ListSavedDialogTopsByPeersParams) ([]ListSavedDialogTopsByPeersRow, error) { + rows, err := q.db.Query(ctx, listSavedDialogTopsByPeers, arg.OwnerUserID, arg.PeerTypes, arg.PeerIds) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListSavedDialogTopsByPeersRow + for rows.Next() { + var i ListSavedDialogTopsByPeersRow + if err := rows.Scan( + &i.DialogPeerType, + &i.DialogPeerID, + &i.DialogPinned, + &i.BoxID, + &i.PrivateMessageID, + &i.OwnerUserID, + &i.PeerType, + &i.PeerID, + &i.FromUserID, + &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, + &i.EditDate, + &i.Outgoing, + &i.Body, + &i.EntitiesJson, + &i.Silent, + &i.Noforwards, + &i.ReplyToMsgID, + &i.ReplyToPeerType, + &i.ReplyToPeerID, + &i.ReplyToTopID, + &i.ReplyToStoryID, + &i.QuoteText, + &i.QuoteEntitiesJson, + &i.QuoteOffset, + &i.FwdFromPeerType, + &i.FwdFromPeerID, + &i.FwdFromName, + &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, + &i.Pts, + &i.MediaJson, + &i.MediaUnread, + &i.ReactionUnread, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, + &i.Pinned, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const reorderSavedDialogPins = `-- name: ReorderSavedDialogPins :exec +INSERT INTO saved_dialog_pins (user_id, peer_type, peer_id, pinned_order) +SELECT + $1::bigint, + ($2::text[])[i], + ($3::bigint[])[i], + (i - 1)::int +FROM generate_subscripts($3::bigint[], 1) AS g(i) +WHERE i <= cardinality($2::text[]) +ON CONFLICT (user_id, peer_type, peer_id) DO UPDATE SET pinned_order = EXCLUDED.pinned_order +` + +type ReorderSavedDialogPinsParams struct { + UserID int64 + PeerTypes []string + PeerIds []int64 +} + +func (q *Queries) ReorderSavedDialogPins(ctx context.Context, arg ReorderSavedDialogPinsParams) error { + _, err := q.db.Exec(ctx, reorderSavedDialogPins, arg.UserID, arg.PeerTypes, arg.PeerIds) + return err +} + +const upsertSavedDialogPinFront = `-- name: UpsertSavedDialogPinFront :execrows +INSERT INTO saved_dialog_pins (user_id, peer_type, peer_id, pinned_order) +VALUES ( + $1::bigint, + $2::text, + $3::bigint, + COALESCE((SELECT MIN(pinned_order) - 1 FROM saved_dialog_pins WHERE user_id = $1::bigint), 0) +) +ON CONFLICT (user_id, peer_type, peer_id) DO NOTHING +` + +type UpsertSavedDialogPinFrontParams struct { + UserID int64 + PeerType string + PeerID int64 +} + +func (q *Queries) UpsertSavedDialogPinFront(ctx context.Context, arg UpsertSavedDialogPinFrontParams) (int64, error) { + result, err := q.db.Exec(ctx, upsertSavedDialogPinFront, arg.UserID, arg.PeerType, arg.PeerID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/internal/store/postgres/sqlcgen/temp_auth_key.sql.go b/internal/store/postgres/sqlcgen/temp_auth_key.sql.go index 4821e763..21c2d33c 100644 --- a/internal/store/postgres/sqlcgen/temp_auth_key.sql.go +++ b/internal/store/postgres/sqlcgen/temp_auth_key.sql.go @@ -9,6 +9,29 @@ import ( "context" ) +const deleteExpiredTempAuthKeys = `-- name: DeleteExpiredTempAuthKeys :execrows +DELETE FROM auth_keys +WHERE auth_key_id IN ( + SELECT temp_auth_key_id + FROM temp_auth_key_bindings + WHERE expires_at < $1 + LIMIT $2 +) +` + +type DeleteExpiredTempAuthKeysParams struct { + ExpiresAt int32 + Limit int32 +} + +func (q *Queries) DeleteExpiredTempAuthKeys(ctx context.Context, arg DeleteExpiredTempAuthKeysParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteExpiredTempAuthKeys, arg.ExpiresAt, arg.Limit) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const getTempAuthKeyBinding = `-- name: GetTempAuthKeyBinding :one SELECT temp_auth_key_id, diff --git a/internal/store/postgres/sqlcgen/update_state.sql.go b/internal/store/postgres/sqlcgen/update_state.sql.go index d2740b7f..89a96808 100644 --- a/internal/store/postgres/sqlcgen/update_state.sql.go +++ b/internal/store/postgres/sqlcgen/update_state.sql.go @@ -74,10 +74,10 @@ const upsertUpdateState = `-- name: UpsertUpdateState :exec INSERT INTO update_states (auth_key_id, user_id, pts, qts, date, seq) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (auth_key_id, user_id) DO UPDATE SET - pts = EXCLUDED.pts, - qts = EXCLUDED.qts, - date = EXCLUDED.date, - seq = EXCLUDED.seq, + pts = GREATEST(update_states.pts, EXCLUDED.pts), + qts = GREATEST(update_states.qts, EXCLUDED.qts), + date = GREATEST(update_states.date, EXCLUDED.date), + seq = GREATEST(update_states.seq, EXCLUDED.seq), updated_at = now() ` diff --git a/internal/store/postgres/sqlcgen/user.sql.go b/internal/store/postgres/sqlcgen/user.sql.go index 2d377141..1a4861f6 100644 --- a/internal/store/postgres/sqlcgen/user.sql.go +++ b/internal/store/postgres/sqlcgen/user.sql.go @@ -7,21 +7,24 @@ package sqlcgen import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const createUser = `-- name: CreateUser :one -INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at +INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id ` type CreateUserParams struct { - AccessHash int64 - Phone string - FirstName string - LastName string - Username string - CountryCode string + AccessHash int64 + Phone string + FirstName string + LastName string + Username string + CountryCode string + PremiumExpiresAt pgtype.Timestamptz } func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { @@ -32,6 +35,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e arg.LastName, arg.Username, arg.CountryCode, + arg.PremiumExpiresAt, ) var i User err := row.Scan( @@ -48,12 +52,28 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.Support, &i.About, &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at FROM users WHERE id = $1 +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { @@ -73,12 +93,28 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.Support, &i.About, &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, ) return i, err } const getUserByPhone = `-- name: GetUserByPhone :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at FROM users WHERE phone = $1 +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE phone = $1 ` func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) { @@ -98,12 +134,28 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error &i.Support, &i.About, &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at FROM users WHERE lower(username) = lower($1) AND username <> '' +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE lower(username) = lower($1) AND username <> '' ` func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) { @@ -123,12 +175,28 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er &i.Support, &i.About, &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, ) return i, err } const getUsersByIDs = `-- name: GetUsersByIDs :many -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE id = ANY($1::bigint[]) ORDER BY id @@ -157,6 +225,22 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error &i.Support, &i.About, &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, ); err != nil { return nil, err } @@ -169,7 +253,7 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error } const getUsersByPhones = `-- name: GetUsersByPhones :many -SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at +SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE phone = ANY($1::text[]) ORDER BY id @@ -198,6 +282,22 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User &i.Support, &i.About, &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, ); err != nil { return nil, err } @@ -222,6 +322,17 @@ WITH matched AS ( u.country_code, u.verified, u.support, + u.is_bot, + u.bot_info_version, + u.premium_expires_at, + u.emoji_status_document_id, + u.emoji_status_until, + u.color_set, + u.color, + u.color_background_emoji_id, + u.profile_color_set, + u.profile_color, + u.profile_color_background_emoji_id, u.last_seen_at, (c.contact_user_id IS NOT NULL)::boolean AS contact, COALESCE(c.mutual, false)::boolean AS mutual, @@ -259,6 +370,17 @@ SELECT country_code, verified, support, + is_bot, + bot_info_version, + premium_expires_at, + emoji_status_document_id, + emoji_status_until, + color_set, + color, + color_background_emoji_id, + profile_color_set, + profile_color, + profile_color_background_emoji_id, last_seen_at, contact, mutual @@ -276,19 +398,30 @@ type SearchUsersParams struct { } type SearchUsersRow struct { - ID int64 - AccessHash int64 - Phone string - FirstName string - LastName string - About string - Username string - CountryCode string - Verified bool - Support bool - LastSeenAt int64 - Contact bool - Mutual bool + ID int64 + AccessHash int64 + Phone string + FirstName string + LastName string + About string + Username string + CountryCode string + Verified bool + Support bool + IsBot bool + BotInfoVersion int32 + PremiumExpiresAt pgtype.Timestamptz + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + ColorSet bool + Color int32 + ColorBackgroundEmojiID int64 + ProfileColorSet bool + ProfileColor int32 + ProfileColorBackgroundEmojiID int64 + LastSeenAt int64 + Contact bool + Mutual bool } func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]SearchUsersRow, error) { @@ -317,6 +450,17 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Sea &i.CountryCode, &i.Verified, &i.Support, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, &i.LastSeenAt, &i.Contact, &i.Mutual, @@ -331,6 +475,345 @@ func (q *Queries) SearchUsers(ctx context.Context, arg SearchUsersParams) ([]Sea return items, nil } +const setUserPremiumUntil = `-- name: SetUserPremiumUntil :one +UPDATE users +SET premium_expires_at = $1::timestamptz, + updated_at = now() +WHERE id = $2::bigint +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +` + +type SetUserPremiumUntilParams struct { + PremiumExpiresAt pgtype.Timestamptz + ID int64 +} + +func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUntilParams) (User, error) { + row := q.db.QueryRow(ctx, setUserPremiumUntil, arg.PremiumExpiresAt, arg.ID) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + ) + return i, err +} + +const setUserVerified = `-- name: SetUserVerified :one +UPDATE users +SET verified = $1::boolean, + updated_at = now() +WHERE id = $2::bigint +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +` + +type SetUserVerifiedParams struct { + Verified bool + ID int64 +} + +func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams) (User, error) { + row := q.db.QueryRow(ctx, setUserVerified, arg.Verified, arg.ID) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + ) + return i, err +} + +const sweepExpiredPremium = `-- name: SweepExpiredPremium :many +UPDATE users +SET premium_expires_at = NULL, + updated_at = now() +WHERE id IN ( + SELECT id FROM users + WHERE premium_expires_at IS NOT NULL + AND premium_expires_at <= $1::timestamptz + ORDER BY premium_expires_at + LIMIT $2::int +) +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +` + +type SweepExpiredPremiumParams struct { + Now pgtype.Timestamptz + LimitCount int32 +} + +func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremiumParams) ([]User, error) { + rows, err := q.db.Query(ctx, sweepExpiredPremium, arg.Now, arg.LimitCount) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateUserBirthday = `-- name: UpdateUserBirthday :one +UPDATE users +SET birthday_day = $1::int, + birthday_month = $2::int, + birthday_year = $3::int, + updated_at = now() +WHERE id = $4::bigint +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +` + +type UpdateUserBirthdayParams struct { + BirthdayDay int32 + BirthdayMonth int32 + BirthdayYear int32 + ID int64 +} + +func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthdayParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserBirthday, + arg.BirthdayDay, + arg.BirthdayMonth, + arg.BirthdayYear, + arg.ID, + ) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + ) + return i, err +} + +const updateUserColor = `-- name: UpdateUserColor :one +UPDATE users +SET color_set = $1::boolean, + color = $2::int, + color_background_emoji_id = $3::bigint, + updated_at = now() +WHERE id = $4::bigint +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +` + +type UpdateUserColorParams struct { + ColorSet bool + Color int32 + BackgroundEmojiID int64 + ID int64 +} + +func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserColor, + arg.ColorSet, + arg.Color, + arg.BackgroundEmojiID, + arg.ID, + ) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + ) + return i, err +} + +const updateUserEmojiStatus = `-- name: UpdateUserEmojiStatus :one +UPDATE users +SET emoji_status_document_id = $1::bigint, + emoji_status_until = $2::bigint, + updated_at = now() +WHERE id = $3::bigint +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +` + +type UpdateUserEmojiStatusParams struct { + EmojiStatusDocumentID int64 + EmojiStatusUntil int64 + ID int64 +} + +func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmojiStatusParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserEmojiStatus, arg.EmojiStatusDocumentID, arg.EmojiStatusUntil, arg.ID) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + ) + return i, err +} + const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec UPDATE users SET last_seen_at = GREATEST(last_seen_at, $1::bigint), @@ -348,6 +831,56 @@ func (q *Queries) UpdateUserLastSeen(ctx context.Context, arg UpdateUserLastSeen return err } +const updateUserPersonalChannel = `-- name: UpdateUserPersonalChannel :one +UPDATE users +SET personal_channel_id = $1::bigint, + updated_at = now() +WHERE id = $2::bigint +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +` + +type UpdateUserPersonalChannelParams struct { + PersonalChannelID int64 + ID int64 +} + +func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserPersonalChannelParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserPersonalChannel, arg.PersonalChannelID, arg.ID) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + ) + return i, err +} + const updateUserProfile = `-- name: UpdateUserProfile :one UPDATE users SET first_name = $2, @@ -355,7 +888,7 @@ SET first_name = $2, about = $4, updated_at = now() WHERE id = $1 -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id ` type UpdateUserProfileParams struct { @@ -387,6 +920,81 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.Support, &i.About, &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, + ) + return i, err +} + +const updateUserProfileColor = `-- name: UpdateUserProfileColor :one +UPDATE users +SET profile_color_set = $1::boolean, + profile_color = $2::int, + profile_color_background_emoji_id = $3::bigint, + updated_at = now() +WHERE id = $4::bigint +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id +` + +type UpdateUserProfileColorParams struct { + ColorSet bool + Color int32 + BackgroundEmojiID int64 + ID int64 +} + +func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProfileColorParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserProfileColor, + arg.ColorSet, + arg.Color, + arg.BackgroundEmojiID, + arg.ID, + ) + var i User + err := row.Scan( + &i.ID, + &i.AccessHash, + &i.Phone, + &i.FirstName, + &i.LastName, + &i.Username, + &i.CountryCode, + &i.CreatedAt, + &i.UpdatedAt, + &i.Verified, + &i.Support, + &i.About, + &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, ) return i, err } @@ -396,7 +1004,7 @@ UPDATE users SET username = $2, updated_at = now() WHERE id = $1 -RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at +RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id ` type UpdateUserUsernameParams struct { @@ -421,6 +1029,22 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername &i.Support, &i.About, &i.LastSeenAt, + &i.DefaultHistoryTtlPeriod, + &i.IsBot, + &i.BotInfoVersion, + &i.PremiumExpiresAt, + &i.EmojiStatusDocumentID, + &i.EmojiStatusUntil, + &i.ColorSet, + &i.Color, + &i.ColorBackgroundEmojiID, + &i.ProfileColorSet, + &i.ProfileColor, + &i.ProfileColorBackgroundEmojiID, + &i.BirthdayDay, + &i.BirthdayMonth, + &i.BirthdayYear, + &i.PersonalChannelID, ) return i, err } diff --git a/internal/store/postgres/sqlcgen/user_update_event.sql.go b/internal/store/postgres/sqlcgen/user_update_event.sql.go index fc9b29ab..4c4ed9f4 100644 --- a/internal/store/postgres/sqlcgen/user_update_event.sql.go +++ b/internal/store/postgres/sqlcgen/user_update_event.sql.go @@ -23,13 +23,17 @@ INSERT INTO user_update_events ( dialog_filter, filter_order, folder_peers, + story_payload, + reaction_payload, message_box_id, peer_type, peer_id, filter_id, max_id, still_unread_count, - tags_enabled + channel_pts, + tags_enabled, + folder_id ) VALUES ( $1, $2, @@ -43,15 +47,18 @@ INSERT INTO user_update_events ( $10::jsonb, $11::jsonb, $12::jsonb, - $13, - $14::text, - $15::bigint, - $16::int, - $17::int, + $13::jsonb, + $14::jsonb, + $15, + $16::text, + $17::bigint, $18::int, - $19::boolean + $19::int, + $20::int, + $21::int, + $22::boolean, + $23::int ) -ON CONFLICT (user_id, pts) DO NOTHING ` type AppendUserUpdateEventParams struct { @@ -67,13 +74,17 @@ type AppendUserUpdateEventParams struct { DialogFilter []byte FilterOrder []byte FolderPeers []byte + StoryPayload []byte + ReactionPayload []byte MessageBoxID *int32 PeerType *string PeerID *int64 FilterID int32 MaxID int32 StillUnreadCount int32 + ChannelPts int32 TagsEnabled bool + FolderID int32 } func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdateEventParams) error { @@ -90,13 +101,17 @@ func (q *Queries) AppendUserUpdateEvent(ctx context.Context, arg AppendUserUpdat arg.DialogFilter, arg.FilterOrder, arg.FolderPeers, + arg.StoryPayload, + arg.ReactionPayload, arg.MessageBoxID, arg.PeerType, arg.PeerID, arg.FilterID, arg.MaxID, arg.StillUnreadCount, + arg.ChannelPts, arg.TagsEnabled, + arg.FolderID, ) return err } @@ -115,12 +130,16 @@ SELECT COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json, COALESCE(e.filter_order::text, '[]')::text AS filter_order_json, COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, + COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, + COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, e.max_id, e.still_unread_count, + e.channel_pts, e.tags_enabled, + e.folder_id, COALESCE(m.box_id, 0)::int AS message_id, COALESCE(m.private_message_id, 0)::bigint AS private_message_id, COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id, @@ -128,6 +147,8 @@ SELECT COALESCE(m.peer_id, 0)::bigint AS peer_id, COALESCE(m.from_user_id, 0)::bigint AS from_user_id, COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.ttl_period, 0)::int AS ttl_period, + COALESCE(m.expires_at, 0)::int AS expires_at, COALESCE(m.edit_date, 0)::int AS edit_date, COALESCE(m.outgoing, false)::boolean AS outgoing, COALESCE(m.body, '')::text AS body, @@ -138,6 +159,7 @@ SELECT COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type, COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id, COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id, + COALESCE(m.reply_to_story_id, 0)::int AS reply_to_story_id, COALESCE(m.quote_text, '')::text AS quote_text, COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, COALESCE(m.quote_offset, 0)::int AS quote_offset, @@ -145,9 +167,20 @@ SELECT COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.fwd_saved_from_peer_type, '')::text AS fwd_saved_from_peer_type, + COALESCE(m.fwd_saved_from_peer_id, 0)::bigint AS fwd_saved_from_peer_id, + COALESCE(m.fwd_saved_from_msg_id, 0)::int AS fwd_saved_from_msg_id, + COALESCE(m.saved_peer_type, '')::text AS saved_peer_type, + COALESCE(m.saved_peer_id, 0)::bigint AS saved_peer_id, COALESCE(m.media::text, '{}')::text AS media_json, COALESCE(m.media_unread, false)::boolean AS media_unread, COALESCE(m.reaction_unread, false)::boolean AS reaction_unread, + COALESCE(m.pinned, false)::boolean AS pinned, + COALESCE(m.via_bot_id, 0)::bigint AS via_bot_id, + COALESCE(m.grouped_id, 0)::bigint AS grouped_id, + COALESCE(m.effect, 0)::bigint AS effect, + COALESCE(m.reply_markup::text, '{}')::text AS reply_markup_json, + COALESCE(m.rich_message::text, '{}')::text AS rich_message_json, COALESCE(peer_u.id, 0)::bigint AS peer_user_id, COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, COALESCE(peer_u.phone, '')::text AS peer_phone, @@ -157,6 +190,11 @@ SELECT COALESCE(peer_u.country_code, '')::text AS peer_country_code, COALESCE(peer_u.verified, false)::boolean AS peer_verified, COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(peer_u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM peer_u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(peer_u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(peer_u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(from_u.id, 0)::bigint AS from_user_user_id, COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, COALESCE(from_u.phone, '')::text AS from_user_phone, @@ -166,6 +204,11 @@ SELECT COALESCE(from_u.country_code, '')::text AS from_user_country_code, COALESCE(from_u.verified, false)::boolean AS from_user_verified, COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.is_bot, false)::boolean AS from_user_is_bot, + COALESCE(from_u.bot_info_version, 0)::int AS from_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM from_u.premium_expires_at), 0)::bigint AS from_user_premium_until, + COALESCE(from_u.emoji_status_document_id, 0)::bigint AS from_user_emoji_status_document_id, + COALESCE(from_u.emoji_status_until, 0)::bigint AS from_user_emoji_status_until, COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id, COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash, COALESCE(fwd_u.phone, '')::text AS fwd_user_phone, @@ -175,6 +218,11 @@ SELECT COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code, COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified, COALESCE(fwd_u.support, false)::boolean AS fwd_user_support, + COALESCE(fwd_u.is_bot, false)::boolean AS fwd_user_is_bot, + COALESCE(fwd_u.bot_info_version, 0)::int AS fwd_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM fwd_u.premium_expires_at), 0)::bigint AS fwd_user_premium_until, + COALESCE(fwd_u.emoji_status_document_id, 0)::bigint AS fwd_user_emoji_status_document_id, + COALESCE(fwd_u.emoji_status_until, 0)::bigint AS fwd_user_emoji_status_until, COALESCE(reply_u.id, 0)::bigint AS reply_user_id, COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash, COALESCE(reply_u.phone, '')::text AS reply_user_phone, @@ -184,54 +232,11 @@ SELECT COALESCE(reply_u.country_code, '')::text AS reply_user_country_code, COALESCE(reply_u.verified, false)::boolean AS reply_user_verified, COALESCE(reply_u.support, false)::boolean AS reply_user_support, - COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id, - COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash, - COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id, - COALESCE(fwd_ch.title, '')::text AS fwd_channel_title, - COALESCE(fwd_ch.about, '')::text AS fwd_channel_about, - COALESCE(fwd_ch.username, '')::text AS fwd_channel_username, - COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast, - COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup, - COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum, - COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards, - COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures, - COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden, - COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds, - COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights, - COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count, - COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count, - COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count, - COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count, - COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id, - COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id, - COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts, - COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period, - COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date, - COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted, - COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id, - COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash, - COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id, - COALESCE(reply_ch.title, '')::text AS reply_channel_title, - COALESCE(reply_ch.about, '')::text AS reply_channel_about, - COALESCE(reply_ch.username, '')::text AS reply_channel_username, - COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast, - COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup, - COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum, - COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards, - COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures, - COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden, - COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds, - COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights, - COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count, - COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count, - COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count, - COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count, - COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id, - COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id, - COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts, - COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period, - COALESCE(reply_ch.date, 0)::int AS reply_channel_date, - COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted + COALESCE(reply_u.is_bot, false)::boolean AS reply_user_is_bot, + COALESCE(reply_u.bot_info_version, 0)::int AS reply_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM reply_u.premium_expires_at), 0)::bigint AS reply_user_premium_until, + COALESCE(reply_u.emoji_status_document_id, 0)::bigint AS reply_user_emoji_status_document_id, + COALESCE(reply_u.emoji_status_until, 0)::bigint AS reply_user_emoji_status_until FROM unnest($1::bigint[]) WITH ORDINALITY AS u(user_id, ord) JOIN unnest($2::int[]) WITH ORDINALITY AS p(pts, ord) USING (ord) JOIN user_update_events e ON e.user_id = u.user_id AND e.pts = p.pts @@ -240,8 +245,6 @@ LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id LEFT JOIN users from_u ON from_u.id = m.from_user_id LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id -LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id -LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id ` type BatchListDispatchEventsParams struct { @@ -250,135 +253,125 @@ type BatchListDispatchEventsParams struct { } type BatchListDispatchEventsRow struct { - UserID int64 - Pts int32 - PtsCount int32 - Date int32 - EventType string - EventBool bool - EventPeersJson string - PeerSettingsJson string - MessageIdsJson string - DialogFilterJson string - FilterOrderJson string - FolderPeersJson string - EventPeerType string - EventPeerID int64 - FilterID int32 - MaxID int32 - StillUnreadCount int32 - TagsEnabled bool - MessageID int32 - PrivateMessageID int64 - OwnerUserID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - EditDate int32 - Outgoing bool - Body string - MessageEntitiesJson string - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson string - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - MediaJson string - MediaUnread bool - ReactionUnread bool - PeerUserID int64 - PeerAccessHash int64 - PeerPhone string - PeerFirstName string - PeerLastName string - PeerUsername string - PeerCountryCode string - PeerVerified bool - PeerSupport bool - FromUserUserID int64 - FromUserAccessHash int64 - FromUserPhone string - FromUserFirstName string - FromUserLastName string - FromUserUsername string - FromUserCountryCode string - FromUserVerified bool - FromUserSupport bool - FwdUserID int64 - FwdUserAccessHash int64 - FwdUserPhone string - FwdUserFirstName string - FwdUserLastName string - FwdUserUsername string - FwdUserCountryCode string - FwdUserVerified bool - FwdUserSupport bool - ReplyUserID int64 - ReplyUserAccessHash int64 - ReplyUserPhone string - ReplyUserFirstName string - ReplyUserLastName string - ReplyUserUsername string - ReplyUserCountryCode string - ReplyUserVerified bool - ReplyUserSupport bool - FwdChannelID int64 - FwdChannelAccessHash int64 - FwdChannelCreatorUserID int64 - FwdChannelTitle string - FwdChannelAbout string - FwdChannelUsername string - FwdChannelBroadcast bool - FwdChannelMegagroup bool - FwdChannelForum bool - FwdChannelNoforwards bool - FwdChannelSignatures bool - FwdChannelPreHistoryHidden bool - FwdChannelSlowmodeSeconds int32 - FwdChannelDefaultBannedRights string - FwdChannelParticipantsCount int32 - FwdChannelAdminsCount int32 - FwdChannelKickedCount int32 - FwdChannelBannedCount int32 - FwdChannelTopMessageID int32 - FwdChannelPinnedMessageID int32 - FwdChannelPts int32 - FwdChannelTtlPeriod int32 - FwdChannelDate int32 - FwdChannelDeleted bool - ReplyChannelID int64 - ReplyChannelAccessHash int64 - ReplyChannelCreatorUserID int64 - ReplyChannelTitle string - ReplyChannelAbout string - ReplyChannelUsername string - ReplyChannelBroadcast bool - ReplyChannelMegagroup bool - ReplyChannelForum bool - ReplyChannelNoforwards bool - ReplyChannelSignatures bool - ReplyChannelPreHistoryHidden bool - ReplyChannelSlowmodeSeconds int32 - ReplyChannelDefaultBannedRights string - ReplyChannelParticipantsCount int32 - ReplyChannelAdminsCount int32 - ReplyChannelKickedCount int32 - ReplyChannelBannedCount int32 - ReplyChannelTopMessageID int32 - ReplyChannelPinnedMessageID int32 - ReplyChannelPts int32 - ReplyChannelTtlPeriod int32 - ReplyChannelDate int32 - ReplyChannelDeleted bool + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + EventBool bool + EventPeersJson string + PeerSettingsJson string + MessageIdsJson string + DialogFilterJson string + FilterOrderJson string + FolderPeersJson string + StoryPayloadJson string + ReactionPayloadJson string + EventPeerType string + EventPeerID int64 + FilterID int32 + MaxID int32 + StillUnreadCount int32 + ChannelPts int32 + TagsEnabled bool + FolderID int32 + MessageID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + MessageEntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerIsBot bool + PeerBotInfoVersion int32 + PeerPremiumUntil int64 + PeerEmojiStatusDocumentID int64 + PeerEmojiStatusUntil int64 + FromUserUserID int64 + FromUserAccessHash int64 + FromUserPhone string + FromUserFirstName string + FromUserLastName string + FromUserUsername string + FromUserCountryCode string + FromUserVerified bool + FromUserSupport bool + FromUserIsBot bool + FromUserBotInfoVersion int32 + FromUserPremiumUntil int64 + FromUserEmojiStatusDocumentID int64 + FromUserEmojiStatusUntil int64 + FwdUserID int64 + FwdUserAccessHash int64 + FwdUserPhone string + FwdUserFirstName string + FwdUserLastName string + FwdUserUsername string + FwdUserCountryCode string + FwdUserVerified bool + FwdUserSupport bool + FwdUserIsBot bool + FwdUserBotInfoVersion int32 + FwdUserPremiumUntil int64 + FwdUserEmojiStatusDocumentID int64 + FwdUserEmojiStatusUntil int64 + ReplyUserID int64 + ReplyUserAccessHash int64 + ReplyUserPhone string + ReplyUserFirstName string + ReplyUserLastName string + ReplyUserUsername string + ReplyUserCountryCode string + ReplyUserVerified bool + ReplyUserSupport bool + ReplyUserIsBot bool + ReplyUserBotInfoVersion int32 + ReplyUserPremiumUntil int64 + ReplyUserEmojiStatusDocumentID int64 + ReplyUserEmojiStatusUntil int64 } // 按 (user_id, pts) 精确批量取账号事件,供 outbox worker 一次性加载一批 claim 的事件详情, @@ -405,12 +398,16 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.DialogFilterJson, &i.FilterOrderJson, &i.FolderPeersJson, + &i.StoryPayloadJson, + &i.ReactionPayloadJson, &i.EventPeerType, &i.EventPeerID, &i.FilterID, &i.MaxID, &i.StillUnreadCount, + &i.ChannelPts, &i.TagsEnabled, + &i.FolderID, &i.MessageID, &i.PrivateMessageID, &i.OwnerUserID, @@ -418,6 +415,8 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.PeerID, &i.FromUserID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Outgoing, &i.Body, @@ -428,6 +427,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.ReplyToPeerType, &i.ReplyToPeerID, &i.ReplyToTopID, + &i.ReplyToStoryID, &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, @@ -435,9 +435,20 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.FwdFromPeerID, &i.FwdFromName, &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, &i.MediaJson, &i.MediaUnread, &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, &i.PeerUserID, &i.PeerAccessHash, &i.PeerPhone, @@ -447,6 +458,11 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.PeerCountryCode, &i.PeerVerified, &i.PeerSupport, + &i.PeerIsBot, + &i.PeerBotInfoVersion, + &i.PeerPremiumUntil, + &i.PeerEmojiStatusDocumentID, + &i.PeerEmojiStatusUntil, &i.FromUserUserID, &i.FromUserAccessHash, &i.FromUserPhone, @@ -456,6 +472,11 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.FromUserCountryCode, &i.FromUserVerified, &i.FromUserSupport, + &i.FromUserIsBot, + &i.FromUserBotInfoVersion, + &i.FromUserPremiumUntil, + &i.FromUserEmojiStatusDocumentID, + &i.FromUserEmojiStatusUntil, &i.FwdUserID, &i.FwdUserAccessHash, &i.FwdUserPhone, @@ -465,6 +486,11 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.FwdUserCountryCode, &i.FwdUserVerified, &i.FwdUserSupport, + &i.FwdUserIsBot, + &i.FwdUserBotInfoVersion, + &i.FwdUserPremiumUntil, + &i.FwdUserEmojiStatusDocumentID, + &i.FwdUserEmojiStatusUntil, &i.ReplyUserID, &i.ReplyUserAccessHash, &i.ReplyUserPhone, @@ -474,54 +500,11 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp &i.ReplyUserCountryCode, &i.ReplyUserVerified, &i.ReplyUserSupport, - &i.FwdChannelID, - &i.FwdChannelAccessHash, - &i.FwdChannelCreatorUserID, - &i.FwdChannelTitle, - &i.FwdChannelAbout, - &i.FwdChannelUsername, - &i.FwdChannelBroadcast, - &i.FwdChannelMegagroup, - &i.FwdChannelForum, - &i.FwdChannelNoforwards, - &i.FwdChannelSignatures, - &i.FwdChannelPreHistoryHidden, - &i.FwdChannelSlowmodeSeconds, - &i.FwdChannelDefaultBannedRights, - &i.FwdChannelParticipantsCount, - &i.FwdChannelAdminsCount, - &i.FwdChannelKickedCount, - &i.FwdChannelBannedCount, - &i.FwdChannelTopMessageID, - &i.FwdChannelPinnedMessageID, - &i.FwdChannelPts, - &i.FwdChannelTtlPeriod, - &i.FwdChannelDate, - &i.FwdChannelDeleted, - &i.ReplyChannelID, - &i.ReplyChannelAccessHash, - &i.ReplyChannelCreatorUserID, - &i.ReplyChannelTitle, - &i.ReplyChannelAbout, - &i.ReplyChannelUsername, - &i.ReplyChannelBroadcast, - &i.ReplyChannelMegagroup, - &i.ReplyChannelForum, - &i.ReplyChannelNoforwards, - &i.ReplyChannelSignatures, - &i.ReplyChannelPreHistoryHidden, - &i.ReplyChannelSlowmodeSeconds, - &i.ReplyChannelDefaultBannedRights, - &i.ReplyChannelParticipantsCount, - &i.ReplyChannelAdminsCount, - &i.ReplyChannelKickedCount, - &i.ReplyChannelBannedCount, - &i.ReplyChannelTopMessageID, - &i.ReplyChannelPinnedMessageID, - &i.ReplyChannelPts, - &i.ReplyChannelTtlPeriod, - &i.ReplyChannelDate, - &i.ReplyChannelDeleted, + &i.ReplyUserIsBot, + &i.ReplyUserBotInfoVersion, + &i.ReplyUserPremiumUntil, + &i.ReplyUserEmojiStatusDocumentID, + &i.ReplyUserEmojiStatusUntil, ); err != nil { return nil, err } @@ -535,17 +518,17 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp const claimDispatchOutbox = `-- name: ClaimDispatchOutbox :many WITH picked AS ( - SELECT target_user_id, id - FROM dispatch_outbox + SELECT d.target_user_id, d.id + FROM dispatch_outbox d WHERE ( - status = 'pending' - AND next_attempt_at <= now() + d.status = 'pending' + AND d.next_attempt_at <= now() ) OR ( - status = 'dispatching' - AND updated_at < now() - make_interval(secs => $1::int) + d.status = 'dispatching' + AND d.updated_at < now() - make_interval(secs => $1::int) ) - ORDER BY next_attempt_at ASC, target_user_id ASC, id ASC + ORDER BY d.next_attempt_at ASC, d.target_user_id ASC, d.pts ASC, d.id ASC LIMIT $2 FOR UPDATE SKIP LOCKED ) @@ -674,17 +657,6 @@ func (q *Queries) EnqueueDispatch(ctx context.Context, arg EnqueueDispatchParams return err } -const ensureUserUpdateWatermark = `-- name: EnsureUserUpdateWatermark :exec -INSERT INTO user_update_watermarks (user_id, contiguous_pts) -VALUES ($1, 0) -ON CONFLICT (user_id) DO NOTHING -` - -func (q *Queries) EnsureUserUpdateWatermark(ctx context.Context, userID int64) error { - _, err := q.db.Exec(ctx, ensureUserUpdateWatermark, userID) - return err -} - const getUserUpdateWatermark = `-- name: GetUserUpdateWatermark :one SELECT contiguous_pts FROM user_update_watermarks @@ -712,12 +684,16 @@ SELECT COALESCE(e.dialog_filter::text, '{}')::text AS dialog_filter_json, COALESCE(e.filter_order::text, '[]')::text AS filter_order_json, COALESCE(e.folder_peers::text, '[]')::text AS folder_peers_json, + COALESCE(e.story_payload::text, '{}')::text AS story_payload_json, + COALESCE(e.reaction_payload::text, '{}')::text AS reaction_payload_json, COALESCE(e.peer_type, '')::text AS event_peer_type, COALESCE(e.peer_id, 0)::bigint AS event_peer_id, e.filter_id, e.max_id, e.still_unread_count, + e.channel_pts, e.tags_enabled, + e.folder_id, COALESCE(m.box_id, 0)::int AS message_id, COALESCE(m.private_message_id, 0)::bigint AS private_message_id, COALESCE(m.owner_user_id, 0)::bigint AS owner_user_id, @@ -725,6 +701,8 @@ SELECT COALESCE(m.peer_id, 0)::bigint AS peer_id, COALESCE(m.from_user_id, 0)::bigint AS from_user_id, COALESCE(m.message_date, 0)::int AS message_date, + COALESCE(m.ttl_period, 0)::int AS ttl_period, + COALESCE(m.expires_at, 0)::int AS expires_at, COALESCE(m.edit_date, 0)::int AS edit_date, COALESCE(m.outgoing, false)::boolean AS outgoing, COALESCE(m.body, '')::text AS body, @@ -735,6 +713,7 @@ SELECT COALESCE(m.reply_to_peer_type, '')::text AS reply_to_peer_type, COALESCE(m.reply_to_peer_id, 0)::bigint AS reply_to_peer_id, COALESCE(m.reply_to_top_id, 0)::int AS reply_to_top_id, + COALESCE(m.reply_to_story_id, 0)::int AS reply_to_story_id, COALESCE(m.quote_text, '')::text AS quote_text, COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json, COALESCE(m.quote_offset, 0)::int AS quote_offset, @@ -742,9 +721,20 @@ SELECT COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id, COALESCE(m.fwd_from_name, '')::text AS fwd_from_name, COALESCE(m.fwd_date, 0)::int AS fwd_date, + COALESCE(m.fwd_saved_from_peer_type, '')::text AS fwd_saved_from_peer_type, + COALESCE(m.fwd_saved_from_peer_id, 0)::bigint AS fwd_saved_from_peer_id, + COALESCE(m.fwd_saved_from_msg_id, 0)::int AS fwd_saved_from_msg_id, + COALESCE(m.saved_peer_type, '')::text AS saved_peer_type, + COALESCE(m.saved_peer_id, 0)::bigint AS saved_peer_id, COALESCE(m.media::text, '{}')::text AS media_json, COALESCE(m.media_unread, false)::boolean AS media_unread, COALESCE(m.reaction_unread, false)::boolean AS reaction_unread, + COALESCE(m.pinned, false)::boolean AS pinned, + COALESCE(m.via_bot_id, 0)::bigint AS via_bot_id, + COALESCE(m.grouped_id, 0)::bigint AS grouped_id, + COALESCE(m.effect, 0)::bigint AS effect, + COALESCE(m.reply_markup::text, '{}')::text AS reply_markup_json, + COALESCE(m.rich_message::text, '{}')::text AS rich_message_json, COALESCE(peer_u.id, 0)::bigint AS peer_user_id, COALESCE(peer_u.access_hash, 0)::bigint AS peer_access_hash, COALESCE(peer_u.phone, '')::text AS peer_phone, @@ -754,6 +744,11 @@ SELECT COALESCE(peer_u.country_code, '')::text AS peer_country_code, COALESCE(peer_u.verified, false)::boolean AS peer_verified, COALESCE(peer_u.support, false)::boolean AS peer_support, + COALESCE(peer_u.is_bot, false)::boolean AS peer_is_bot, + COALESCE(peer_u.bot_info_version, 0)::int AS peer_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM peer_u.premium_expires_at), 0)::bigint AS peer_premium_until, + COALESCE(peer_u.emoji_status_document_id, 0)::bigint AS peer_emoji_status_document_id, + COALESCE(peer_u.emoji_status_until, 0)::bigint AS peer_emoji_status_until, COALESCE(from_u.id, 0)::bigint AS from_user_user_id, COALESCE(from_u.access_hash, 0)::bigint AS from_user_access_hash, COALESCE(from_u.phone, '')::text AS from_user_phone, @@ -763,6 +758,11 @@ SELECT COALESCE(from_u.country_code, '')::text AS from_user_country_code, COALESCE(from_u.verified, false)::boolean AS from_user_verified, COALESCE(from_u.support, false)::boolean AS from_user_support, + COALESCE(from_u.is_bot, false)::boolean AS from_user_is_bot, + COALESCE(from_u.bot_info_version, 0)::int AS from_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM from_u.premium_expires_at), 0)::bigint AS from_user_premium_until, + COALESCE(from_u.emoji_status_document_id, 0)::bigint AS from_user_emoji_status_document_id, + COALESCE(from_u.emoji_status_until, 0)::bigint AS from_user_emoji_status_until, COALESCE(fwd_u.id, 0)::bigint AS fwd_user_id, COALESCE(fwd_u.access_hash, 0)::bigint AS fwd_user_access_hash, COALESCE(fwd_u.phone, '')::text AS fwd_user_phone, @@ -772,6 +772,11 @@ SELECT COALESCE(fwd_u.country_code, '')::text AS fwd_user_country_code, COALESCE(fwd_u.verified, false)::boolean AS fwd_user_verified, COALESCE(fwd_u.support, false)::boolean AS fwd_user_support, + COALESCE(fwd_u.is_bot, false)::boolean AS fwd_user_is_bot, + COALESCE(fwd_u.bot_info_version, 0)::int AS fwd_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM fwd_u.premium_expires_at), 0)::bigint AS fwd_user_premium_until, + COALESCE(fwd_u.emoji_status_document_id, 0)::bigint AS fwd_user_emoji_status_document_id, + COALESCE(fwd_u.emoji_status_until, 0)::bigint AS fwd_user_emoji_status_until, COALESCE(reply_u.id, 0)::bigint AS reply_user_id, COALESCE(reply_u.access_hash, 0)::bigint AS reply_user_access_hash, COALESCE(reply_u.phone, '')::text AS reply_user_phone, @@ -781,62 +786,17 @@ SELECT COALESCE(reply_u.country_code, '')::text AS reply_user_country_code, COALESCE(reply_u.verified, false)::boolean AS reply_user_verified, COALESCE(reply_u.support, false)::boolean AS reply_user_support, - COALESCE(fwd_ch.id, 0)::bigint AS fwd_channel_id, - COALESCE(fwd_ch.access_hash, 0)::bigint AS fwd_channel_access_hash, - COALESCE(fwd_ch.creator_user_id, 0)::bigint AS fwd_channel_creator_user_id, - COALESCE(fwd_ch.title, '')::text AS fwd_channel_title, - COALESCE(fwd_ch.about, '')::text AS fwd_channel_about, - COALESCE(fwd_ch.username, '')::text AS fwd_channel_username, - COALESCE(fwd_ch.broadcast, false)::boolean AS fwd_channel_broadcast, - COALESCE(fwd_ch.megagroup, false)::boolean AS fwd_channel_megagroup, - COALESCE(fwd_ch.forum, false)::boolean AS fwd_channel_forum, - COALESCE(fwd_ch.noforwards, false)::boolean AS fwd_channel_noforwards, - COALESCE(fwd_ch.signatures, false)::boolean AS fwd_channel_signatures, - COALESCE(fwd_ch.pre_history_hidden, false)::boolean AS fwd_channel_pre_history_hidden, - COALESCE(fwd_ch.slowmode_seconds, 0)::int AS fwd_channel_slowmode_seconds, - COALESCE(fwd_ch.default_banned_rights::text, '{}')::text AS fwd_channel_default_banned_rights, - COALESCE(fwd_ch.participants_count, 0)::int AS fwd_channel_participants_count, - COALESCE(fwd_ch.admins_count, 0)::int AS fwd_channel_admins_count, - COALESCE(fwd_ch.kicked_count, 0)::int AS fwd_channel_kicked_count, - COALESCE(fwd_ch.banned_count, 0)::int AS fwd_channel_banned_count, - COALESCE(fwd_ch.top_message_id, 0)::int AS fwd_channel_top_message_id, - COALESCE(fwd_ch.pinned_message_id, 0)::int AS fwd_channel_pinned_message_id, - COALESCE(fwd_ch.pts, 0)::int AS fwd_channel_pts, - COALESCE(fwd_ch.ttl_period, 0)::int AS fwd_channel_ttl_period, - COALESCE(fwd_ch.date, 0)::int AS fwd_channel_date, - COALESCE(fwd_ch.deleted, false)::boolean AS fwd_channel_deleted, - COALESCE(reply_ch.id, 0)::bigint AS reply_channel_id, - COALESCE(reply_ch.access_hash, 0)::bigint AS reply_channel_access_hash, - COALESCE(reply_ch.creator_user_id, 0)::bigint AS reply_channel_creator_user_id, - COALESCE(reply_ch.title, '')::text AS reply_channel_title, - COALESCE(reply_ch.about, '')::text AS reply_channel_about, - COALESCE(reply_ch.username, '')::text AS reply_channel_username, - COALESCE(reply_ch.broadcast, false)::boolean AS reply_channel_broadcast, - COALESCE(reply_ch.megagroup, false)::boolean AS reply_channel_megagroup, - COALESCE(reply_ch.forum, false)::boolean AS reply_channel_forum, - COALESCE(reply_ch.noforwards, false)::boolean AS reply_channel_noforwards, - COALESCE(reply_ch.signatures, false)::boolean AS reply_channel_signatures, - COALESCE(reply_ch.pre_history_hidden, false)::boolean AS reply_channel_pre_history_hidden, - COALESCE(reply_ch.slowmode_seconds, 0)::int AS reply_channel_slowmode_seconds, - COALESCE(reply_ch.default_banned_rights::text, '{}')::text AS reply_channel_default_banned_rights, - COALESCE(reply_ch.participants_count, 0)::int AS reply_channel_participants_count, - COALESCE(reply_ch.admins_count, 0)::int AS reply_channel_admins_count, - COALESCE(reply_ch.kicked_count, 0)::int AS reply_channel_kicked_count, - COALESCE(reply_ch.banned_count, 0)::int AS reply_channel_banned_count, - COALESCE(reply_ch.top_message_id, 0)::int AS reply_channel_top_message_id, - COALESCE(reply_ch.pinned_message_id, 0)::int AS reply_channel_pinned_message_id, - COALESCE(reply_ch.pts, 0)::int AS reply_channel_pts, - COALESCE(reply_ch.ttl_period, 0)::int AS reply_channel_ttl_period, - COALESCE(reply_ch.date, 0)::int AS reply_channel_date, - COALESCE(reply_ch.deleted, false)::boolean AS reply_channel_deleted + COALESCE(reply_u.is_bot, false)::boolean AS reply_user_is_bot, + COALESCE(reply_u.bot_info_version, 0)::int AS reply_user_bot_info_version, + COALESCE(EXTRACT(EPOCH FROM reply_u.premium_expires_at), 0)::bigint AS reply_user_premium_until, + COALESCE(reply_u.emoji_status_document_id, 0)::bigint AS reply_user_emoji_status_document_id, + COALESCE(reply_u.emoji_status_until, 0)::bigint AS reply_user_emoji_status_until FROM user_update_events e LEFT JOIN message_boxes m ON m.owner_user_id = e.user_id AND m.box_id = e.message_box_id LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id LEFT JOIN users from_u ON from_u.id = m.from_user_id LEFT JOIN users fwd_u ON m.fwd_from_peer_type = 'user' AND fwd_u.id = m.fwd_from_peer_id LEFT JOIN users reply_u ON m.reply_to_peer_type = 'user' AND reply_u.id = m.reply_to_peer_id -LEFT JOIN channels fwd_ch ON m.fwd_from_peer_type = 'channel' AND fwd_ch.id = m.fwd_from_peer_id -LEFT JOIN channels reply_ch ON m.reply_to_peer_type = 'channel' AND reply_ch.id = m.reply_to_peer_id WHERE e.user_id = $1 AND e.pts > $2 ORDER BY e.pts ASC @@ -850,135 +810,125 @@ type ListUserUpdateEventsAfterParams struct { } type ListUserUpdateEventsAfterRow struct { - UserID int64 - Pts int32 - PtsCount int32 - Date int32 - EventType string - EventBool bool - EventPeersJson string - PeerSettingsJson string - MessageIdsJson string - DialogFilterJson string - FilterOrderJson string - FolderPeersJson string - EventPeerType string - EventPeerID int64 - FilterID int32 - MaxID int32 - StillUnreadCount int32 - TagsEnabled bool - MessageID int32 - PrivateMessageID int64 - OwnerUserID int64 - PeerType string - PeerID int64 - FromUserID int64 - MessageDate int32 - EditDate int32 - Outgoing bool - Body string - MessageEntitiesJson string - Silent bool - Noforwards bool - ReplyToMsgID int32 - ReplyToPeerType string - ReplyToPeerID int64 - ReplyToTopID int32 - QuoteText string - QuoteEntitiesJson string - QuoteOffset int32 - FwdFromPeerType string - FwdFromPeerID int64 - FwdFromName string - FwdDate int32 - MediaJson string - MediaUnread bool - ReactionUnread bool - PeerUserID int64 - PeerAccessHash int64 - PeerPhone string - PeerFirstName string - PeerLastName string - PeerUsername string - PeerCountryCode string - PeerVerified bool - PeerSupport bool - FromUserUserID int64 - FromUserAccessHash int64 - FromUserPhone string - FromUserFirstName string - FromUserLastName string - FromUserUsername string - FromUserCountryCode string - FromUserVerified bool - FromUserSupport bool - FwdUserID int64 - FwdUserAccessHash int64 - FwdUserPhone string - FwdUserFirstName string - FwdUserLastName string - FwdUserUsername string - FwdUserCountryCode string - FwdUserVerified bool - FwdUserSupport bool - ReplyUserID int64 - ReplyUserAccessHash int64 - ReplyUserPhone string - ReplyUserFirstName string - ReplyUserLastName string - ReplyUserUsername string - ReplyUserCountryCode string - ReplyUserVerified bool - ReplyUserSupport bool - FwdChannelID int64 - FwdChannelAccessHash int64 - FwdChannelCreatorUserID int64 - FwdChannelTitle string - FwdChannelAbout string - FwdChannelUsername string - FwdChannelBroadcast bool - FwdChannelMegagroup bool - FwdChannelForum bool - FwdChannelNoforwards bool - FwdChannelSignatures bool - FwdChannelPreHistoryHidden bool - FwdChannelSlowmodeSeconds int32 - FwdChannelDefaultBannedRights string - FwdChannelParticipantsCount int32 - FwdChannelAdminsCount int32 - FwdChannelKickedCount int32 - FwdChannelBannedCount int32 - FwdChannelTopMessageID int32 - FwdChannelPinnedMessageID int32 - FwdChannelPts int32 - FwdChannelTtlPeriod int32 - FwdChannelDate int32 - FwdChannelDeleted bool - ReplyChannelID int64 - ReplyChannelAccessHash int64 - ReplyChannelCreatorUserID int64 - ReplyChannelTitle string - ReplyChannelAbout string - ReplyChannelUsername string - ReplyChannelBroadcast bool - ReplyChannelMegagroup bool - ReplyChannelForum bool - ReplyChannelNoforwards bool - ReplyChannelSignatures bool - ReplyChannelPreHistoryHidden bool - ReplyChannelSlowmodeSeconds int32 - ReplyChannelDefaultBannedRights string - ReplyChannelParticipantsCount int32 - ReplyChannelAdminsCount int32 - ReplyChannelKickedCount int32 - ReplyChannelBannedCount int32 - ReplyChannelTopMessageID int32 - ReplyChannelPinnedMessageID int32 - ReplyChannelPts int32 - ReplyChannelTtlPeriod int32 - ReplyChannelDate int32 - ReplyChannelDeleted bool + UserID int64 + Pts int32 + PtsCount int32 + Date int32 + EventType string + EventBool bool + EventPeersJson string + PeerSettingsJson string + MessageIdsJson string + DialogFilterJson string + FilterOrderJson string + FolderPeersJson string + StoryPayloadJson string + ReactionPayloadJson string + EventPeerType string + EventPeerID int64 + FilterID int32 + MaxID int32 + StillUnreadCount int32 + ChannelPts int32 + TagsEnabled bool + FolderID int32 + MessageID int32 + PrivateMessageID int64 + OwnerUserID int64 + PeerType string + PeerID int64 + FromUserID int64 + MessageDate int32 + TtlPeriod int32 + ExpiresAt int32 + EditDate int32 + Outgoing bool + Body string + MessageEntitiesJson string + Silent bool + Noforwards bool + ReplyToMsgID int32 + ReplyToPeerType string + ReplyToPeerID int64 + ReplyToTopID int32 + ReplyToStoryID int32 + QuoteText string + QuoteEntitiesJson string + QuoteOffset int32 + FwdFromPeerType string + FwdFromPeerID int64 + FwdFromName string + FwdDate int32 + FwdSavedFromPeerType string + FwdSavedFromPeerID int64 + FwdSavedFromMsgID int32 + SavedPeerType string + SavedPeerID int64 + MediaJson string + MediaUnread bool + ReactionUnread bool + Pinned bool + ViaBotID int64 + GroupedID int64 + Effect int64 + ReplyMarkupJson string + RichMessageJson string + PeerUserID int64 + PeerAccessHash int64 + PeerPhone string + PeerFirstName string + PeerLastName string + PeerUsername string + PeerCountryCode string + PeerVerified bool + PeerSupport bool + PeerIsBot bool + PeerBotInfoVersion int32 + PeerPremiumUntil int64 + PeerEmojiStatusDocumentID int64 + PeerEmojiStatusUntil int64 + FromUserUserID int64 + FromUserAccessHash int64 + FromUserPhone string + FromUserFirstName string + FromUserLastName string + FromUserUsername string + FromUserCountryCode string + FromUserVerified bool + FromUserSupport bool + FromUserIsBot bool + FromUserBotInfoVersion int32 + FromUserPremiumUntil int64 + FromUserEmojiStatusDocumentID int64 + FromUserEmojiStatusUntil int64 + FwdUserID int64 + FwdUserAccessHash int64 + FwdUserPhone string + FwdUserFirstName string + FwdUserLastName string + FwdUserUsername string + FwdUserCountryCode string + FwdUserVerified bool + FwdUserSupport bool + FwdUserIsBot bool + FwdUserBotInfoVersion int32 + FwdUserPremiumUntil int64 + FwdUserEmojiStatusDocumentID int64 + FwdUserEmojiStatusUntil int64 + ReplyUserID int64 + ReplyUserAccessHash int64 + ReplyUserPhone string + ReplyUserFirstName string + ReplyUserLastName string + ReplyUserUsername string + ReplyUserCountryCode string + ReplyUserVerified bool + ReplyUserSupport bool + ReplyUserIsBot bool + ReplyUserBotInfoVersion int32 + ReplyUserPremiumUntil int64 + ReplyUserEmojiStatusDocumentID int64 + ReplyUserEmojiStatusUntil int64 } func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpdateEventsAfterParams) ([]ListUserUpdateEventsAfterRow, error) { @@ -1003,12 +953,16 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.DialogFilterJson, &i.FilterOrderJson, &i.FolderPeersJson, + &i.StoryPayloadJson, + &i.ReactionPayloadJson, &i.EventPeerType, &i.EventPeerID, &i.FilterID, &i.MaxID, &i.StillUnreadCount, + &i.ChannelPts, &i.TagsEnabled, + &i.FolderID, &i.MessageID, &i.PrivateMessageID, &i.OwnerUserID, @@ -1016,6 +970,8 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.PeerID, &i.FromUserID, &i.MessageDate, + &i.TtlPeriod, + &i.ExpiresAt, &i.EditDate, &i.Outgoing, &i.Body, @@ -1026,6 +982,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.ReplyToPeerType, &i.ReplyToPeerID, &i.ReplyToTopID, + &i.ReplyToStoryID, &i.QuoteText, &i.QuoteEntitiesJson, &i.QuoteOffset, @@ -1033,9 +990,20 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.FwdFromPeerID, &i.FwdFromName, &i.FwdDate, + &i.FwdSavedFromPeerType, + &i.FwdSavedFromPeerID, + &i.FwdSavedFromMsgID, + &i.SavedPeerType, + &i.SavedPeerID, &i.MediaJson, &i.MediaUnread, &i.ReactionUnread, + &i.Pinned, + &i.ViaBotID, + &i.GroupedID, + &i.Effect, + &i.ReplyMarkupJson, + &i.RichMessageJson, &i.PeerUserID, &i.PeerAccessHash, &i.PeerPhone, @@ -1045,6 +1013,11 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.PeerCountryCode, &i.PeerVerified, &i.PeerSupport, + &i.PeerIsBot, + &i.PeerBotInfoVersion, + &i.PeerPremiumUntil, + &i.PeerEmojiStatusDocumentID, + &i.PeerEmojiStatusUntil, &i.FromUserUserID, &i.FromUserAccessHash, &i.FromUserPhone, @@ -1054,6 +1027,11 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.FromUserCountryCode, &i.FromUserVerified, &i.FromUserSupport, + &i.FromUserIsBot, + &i.FromUserBotInfoVersion, + &i.FromUserPremiumUntil, + &i.FromUserEmojiStatusDocumentID, + &i.FromUserEmojiStatusUntil, &i.FwdUserID, &i.FwdUserAccessHash, &i.FwdUserPhone, @@ -1063,6 +1041,11 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.FwdUserCountryCode, &i.FwdUserVerified, &i.FwdUserSupport, + &i.FwdUserIsBot, + &i.FwdUserBotInfoVersion, + &i.FwdUserPremiumUntil, + &i.FwdUserEmojiStatusDocumentID, + &i.FwdUserEmojiStatusUntil, &i.ReplyUserID, &i.ReplyUserAccessHash, &i.ReplyUserPhone, @@ -1072,54 +1055,11 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd &i.ReplyUserCountryCode, &i.ReplyUserVerified, &i.ReplyUserSupport, - &i.FwdChannelID, - &i.FwdChannelAccessHash, - &i.FwdChannelCreatorUserID, - &i.FwdChannelTitle, - &i.FwdChannelAbout, - &i.FwdChannelUsername, - &i.FwdChannelBroadcast, - &i.FwdChannelMegagroup, - &i.FwdChannelForum, - &i.FwdChannelNoforwards, - &i.FwdChannelSignatures, - &i.FwdChannelPreHistoryHidden, - &i.FwdChannelSlowmodeSeconds, - &i.FwdChannelDefaultBannedRights, - &i.FwdChannelParticipantsCount, - &i.FwdChannelAdminsCount, - &i.FwdChannelKickedCount, - &i.FwdChannelBannedCount, - &i.FwdChannelTopMessageID, - &i.FwdChannelPinnedMessageID, - &i.FwdChannelPts, - &i.FwdChannelTtlPeriod, - &i.FwdChannelDate, - &i.FwdChannelDeleted, - &i.ReplyChannelID, - &i.ReplyChannelAccessHash, - &i.ReplyChannelCreatorUserID, - &i.ReplyChannelTitle, - &i.ReplyChannelAbout, - &i.ReplyChannelUsername, - &i.ReplyChannelBroadcast, - &i.ReplyChannelMegagroup, - &i.ReplyChannelForum, - &i.ReplyChannelNoforwards, - &i.ReplyChannelSignatures, - &i.ReplyChannelPreHistoryHidden, - &i.ReplyChannelSlowmodeSeconds, - &i.ReplyChannelDefaultBannedRights, - &i.ReplyChannelParticipantsCount, - &i.ReplyChannelAdminsCount, - &i.ReplyChannelKickedCount, - &i.ReplyChannelBannedCount, - &i.ReplyChannelTopMessageID, - &i.ReplyChannelPinnedMessageID, - &i.ReplyChannelPts, - &i.ReplyChannelTtlPeriod, - &i.ReplyChannelDate, - &i.ReplyChannelDeleted, + &i.ReplyUserIsBot, + &i.ReplyUserBotInfoVersion, + &i.ReplyUserPremiumUntil, + &i.ReplyUserEmojiStatusDocumentID, + &i.ReplyUserEmojiStatusUntil, ); err != nil { return nil, err } @@ -1131,20 +1071,6 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd return items, nil } -const lockUserUpdateWatermark = `-- name: LockUserUpdateWatermark :one -SELECT contiguous_pts -FROM user_update_watermarks -WHERE user_id = $1 -FOR UPDATE -` - -func (q *Queries) LockUserUpdateWatermark(ctx context.Context, userID int64) (int32, error) { - row := q.db.QueryRow(ctx, lockUserUpdateWatermark, userID) - var contiguous_pts int32 - err := row.Scan(&contiguous_pts) - return contiguous_pts, err -} - const markDispatchDelivered = `-- name: MarkDispatchDelivered :exec DELETE FROM dispatch_outbox WHERE target_user_id = $1 @@ -1176,7 +1102,7 @@ type MarkDispatchDeliveredBatchParams struct { Ids []int64 } -// 批量删除一批已投递的 (target_user_id, id);target_user_id 入 WHERE 保证分区裁剪。 +// 批量删除一批已投递的 (target_user_id, id);target_user_id 入 WHERE 命中唯一索引并避免串删。 func (q *Queries) MarkDispatchDeliveredBatch(ctx context.Context, arg MarkDispatchDeliveredBatchParams) error { _, err := q.db.Exec(ctx, markDispatchDeliveredBatch, arg.TargetUserIds, arg.Ids) return err @@ -1219,101 +1145,3 @@ func (q *Queries) MaxUserPts(ctx context.Context, userID int64) (int32, error) { err := row.Scan(&max_pts) return max_pts, err } - -const nextUserPtsAfter = `-- name: NextUserPtsAfter :many -SELECT pts, pts_count -FROM user_update_events -WHERE user_id = $1 - AND pts > $2 -ORDER BY pts ASC -LIMIT $3 -` - -type NextUserPtsAfterParams struct { - UserID int64 - Pts int32 - LimitCount int32 -} - -type NextUserPtsAfterRow struct { - Pts int32 - PtsCount int32 -} - -func (q *Queries) NextUserPtsAfter(ctx context.Context, arg NextUserPtsAfterParams) ([]NextUserPtsAfterRow, error) { - rows, err := q.db.Query(ctx, nextUserPtsAfter, arg.UserID, arg.Pts, arg.LimitCount) - if err != nil { - return nil, err - } - defer rows.Close() - var items []NextUserPtsAfterRow - for rows.Next() { - var i NextUserPtsAfterRow - if err := rows.Scan(&i.Pts, &i.PtsCount); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const recentUserPts = `-- name: RecentUserPts :many -SELECT pts, pts_count -FROM user_update_events -WHERE user_id = $1 -ORDER BY pts DESC -LIMIT $2 -` - -type RecentUserPtsParams struct { - UserID int64 - WindowSize int32 -} - -type RecentUserPtsRow struct { - Pts int32 - PtsCount int32 -} - -// 取某 user 最近的一段 pts(降序),供计算「最大连续已提交 pts」用。 -// 只看顶部窗口:瞬时空洞只可能出现在最近在途事务区,窗口足够大即可覆盖其下方连续。 -func (q *Queries) RecentUserPts(ctx context.Context, arg RecentUserPtsParams) ([]RecentUserPtsRow, error) { - rows, err := q.db.Query(ctx, recentUserPts, arg.UserID, arg.WindowSize) - if err != nil { - return nil, err - } - defer rows.Close() - var items []RecentUserPtsRow - for rows.Next() { - var i RecentUserPtsRow - if err := rows.Scan(&i.Pts, &i.PtsCount); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const saveUserUpdateWatermark = `-- name: SaveUserUpdateWatermark :exec -INSERT INTO user_update_watermarks (user_id, contiguous_pts, updated_at) -VALUES ($1, $2, now()) -ON CONFLICT (user_id) DO UPDATE SET - contiguous_pts = GREATEST(user_update_watermarks.contiguous_pts, EXCLUDED.contiguous_pts), - updated_at = now() -` - -type SaveUserUpdateWatermarkParams struct { - UserID int64 - ContiguousPts int32 -} - -func (q *Queries) SaveUserUpdateWatermark(ctx context.Context, arg SaveUserUpdateWatermarkParams) error { - _, err := q.db.Exec(ctx, saveUserUpdateWatermark, arg.UserID, arg.ContiguousPts) - return err -} diff --git a/internal/store/postgres/star_gift.go b/internal/store/postgres/star_gift.go new file mode 100644 index 00000000..44d142e6 --- /dev/null +++ b/internal/store/postgres/star_gift.go @@ -0,0 +1,222 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// StarGiftStore 用 PostgreSQL 实现 store.StarGiftStore(peer 收到的 Star 礼物实例)。 +type StarGiftStore struct { + db sqlcgen.DBTX +} + +// NewStarGiftStore 基于 pgx 连接池(或事务)创建 StarGiftStore。 +func NewStarGiftStore(db sqlcgen.DBTX) *StarGiftStore { + return &StarGiftStore{db: db} +} + +func (s *StarGiftStore) Create(ctx context.Context, gift domain.SavedStarGift) (int64, error) { + if !validSavedStarGift(gift) { + return 0, domain.ErrStarGiftInvalid + } + var id int64 + err := s.db.QueryRow(ctx, ` +WITH next_id AS ( + SELECT nextval(pg_get_serial_sequence('public.peer_star_gifts', 'id'))::bigint AS id +) +INSERT INTO peer_star_gifts (id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message) +SELECT next_id.id, $1,$2,$3,$4,$5, + CASE WHEN $1 = 'channel' AND $6::bigint = 0 THEN next_id.id ELSE $6::bigint END, + $7,$8,$9,false,$10,$11 +FROM next_id +RETURNING id`, + string(gift.Owner.Type), gift.Owner.ID, gift.FromUserID, gift.GiftID, gift.MsgID, gift.SavedID, gift.Date, + gift.NameHidden, gift.Unsaved, gift.ConvertStars, gift.Message).Scan(&id) + if err != nil { + return 0, fmt.Errorf("create star gift: %w", err) + } + return id, nil +} + +func (s *StarGiftStore) ListByOwner(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) { + if !validStarGiftOwner(owner) { + return domain.SavedStarGiftPage{}, nil + } + if limit <= 0 || limit > domain.MaxSavedStarGiftsLimit { + limit = domain.MaxSavedStarGiftsLimit + } + // 总数(未转换 + 可选 excludeUnsaved 过滤)。 + countQuery := `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted` + if excludeUnsaved { + countQuery += ` AND NOT unsaved` + } + var total int + if err := s.db.QueryRow(ctx, countQuery, string(owner.Type), owner.ID).Scan(&total); err != nil { + return domain.SavedStarGiftPage{}, fmt.Errorf("count star gifts: %w", err) + } + page := domain.SavedStarGiftPage{Count: total} + + where := "owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted" + if excludeUnsaved { + where += " AND NOT unsaved" + } + args := []any{string(owner.Type), owner.ID, limit + 1} + if cursor, ok := domain.DecodeStarGiftCursor(offset); ok { + where += " AND id < $4" + args = append(args, cursor) + } + rows, err := s.db.Query(ctx, ` +SELECT id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message +FROM peer_star_gifts +WHERE `+where+` +ORDER BY id DESC +LIMIT $3`, args...) + if err != nil { + return domain.SavedStarGiftPage{}, fmt.Errorf("list star gifts: %w", err) + } + defer rows.Close() + gifts := make([]domain.SavedStarGift, 0, limit) + for rows.Next() { + g, err := scanSavedStarGift(rows) + if err != nil { + return domain.SavedStarGiftPage{}, err + } + gifts = append(gifts, g) + } + if err := rows.Err(); err != nil { + return domain.SavedStarGiftPage{}, fmt.Errorf("iterate star gifts: %w", err) + } + if len(gifts) > limit { + gifts = gifts[:limit] + page.NextOffset = domain.EncodeStarGiftCursor(gifts[len(gifts)-1].ID) + } + page.Gifts = gifts + return page, nil +} + +func (s *StarGiftStore) GetByRef(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) { + if !ref.Valid() { + return domain.SavedStarGift{}, false, nil + } + where, args := savedStarGiftRefWhere(ref) + row := s.db.QueryRow(ctx, ` +SELECT id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message +FROM peer_star_gifts +WHERE `+where, args...) + g, err := scanSavedStarGift(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.SavedStarGift{}, false, nil + } + return domain.SavedStarGift{}, false, err + } + return g, true, nil +} + +func (s *StarGiftStore) CountByOwner(ctx context.Context, owner domain.Peer) (int, error) { + if !validStarGiftOwner(owner) { + return 0, nil + } + var n int + if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM peer_star_gifts WHERE owner_peer_type = $1 AND owner_peer_id = $2 AND NOT converted AND NOT unsaved`, string(owner.Type), owner.ID).Scan(&n); err != nil { + return 0, fmt.Errorf("count star gifts: %w", err) + } + return n, nil +} + +func (s *StarGiftStore) SetUnsaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) { + if !ref.Valid() { + return false, domain.ErrStarGiftNotFound + } + where, args := savedStarGiftRefWhere(ref) + args = append(args, unsaved) + tag, err := s.db.Exec(ctx, ` +UPDATE peer_star_gifts SET unsaved = $4 +WHERE `+where+` AND NOT converted`, args...) + if err != nil { + return false, fmt.Errorf("set star gift unsaved: %w", err) + } + return tag.RowsAffected() > 0, nil +} + +func (s *StarGiftStore) MarkConverted(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) { + if !ref.Valid() { + return domain.SavedStarGift{}, domain.ErrStarGiftNotFound + } + out := domain.SavedStarGift{} + err := withTx(ctx, s.db, "convert star gift", func(tx pgx.Tx) error { + where, args := savedStarGiftRefWhere(ref) + row := tx.QueryRow(ctx, ` +SELECT id, owner_peer_type, owner_peer_id, from_user_id, gift_id, msg_id, saved_id, gift_date, name_hidden, unsaved, converted, convert_stars, message +FROM peer_star_gifts +WHERE `+where+` FOR UPDATE`, args...) + g, err := scanSavedStarGift(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.ErrStarGiftNotFound + } + return err + } + if g.Converted { + return domain.ErrStarGiftAlreadyConverted + } + if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts SET converted = true, unsaved = true WHERE id = $1`, g.ID); err != nil { + return fmt.Errorf("mark star gift converted: %w", err) + } + g.Converted = true + g.Unsaved = true + out = g + return nil + }) + if err != nil { + return domain.SavedStarGift{}, err + } + return out, nil +} + +func scanSavedStarGift(row rowScanner) (domain.SavedStarGift, error) { + var g domain.SavedStarGift + var ownerType string + if err := row.Scan(&g.ID, &ownerType, &g.Owner.ID, &g.FromUserID, &g.GiftID, &g.MsgID, &g.SavedID, &g.Date, + &g.NameHidden, &g.Unsaved, &g.Converted, &g.ConvertStars, &g.Message); err != nil { + return domain.SavedStarGift{}, err + } + g.Owner.Type = domain.PeerType(ownerType) + return g, nil +} + +func savedStarGiftRefWhere(ref domain.SavedStarGiftRef) (string, []any) { + args := []any{string(ref.Owner.Type), ref.Owner.ID} + switch ref.Owner.Type { + case domain.PeerTypeChannel: + args = append(args, ref.SavedID) + return "owner_peer_type = $1 AND owner_peer_id = $2 AND saved_id = $3", args + default: + args = append(args, ref.MsgID) + return "owner_peer_type = $1 AND owner_peer_id = $2 AND msg_id = $3", args + } +} + +func validSavedStarGift(g domain.SavedStarGift) bool { + if g.GiftID == 0 || !validStarGiftOwner(g.Owner) { + return false + } + switch g.Owner.Type { + case domain.PeerTypeUser: + return g.MsgID > 0 && g.SavedID == 0 + case domain.PeerTypeChannel: + return g.MsgID == 0 && g.SavedID >= 0 + default: + return false + } +} + +func validStarGiftOwner(owner domain.Peer) bool { + return owner.ID != 0 && (owner.Type == domain.PeerTypeUser || owner.Type == domain.PeerTypeChannel) +} diff --git a/internal/store/postgres/star_gift_integration_test.go b/internal/store/postgres/star_gift_integration_test.go new file mode 100644 index 00000000..39c1864c --- /dev/null +++ b/internal/store/postgres/star_gift_integration_test.go @@ -0,0 +1,124 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +// TestStarGiftStorePostgres 回归迁移 0011:用户收到礼物实例对真实 PG 的 CRUD +// (创建 / keyset 分页 / excludeUnsaved / 隐藏切换 / 转换幂等)。 +func TestStarGiftStorePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + st := NewStarGiftStore(pool) + + users := NewUserStore(pool) + suffix := randomSuffix(t) + owner, err := users.Create(ctx, domain.User{AccessHash: 81, Phone: "+1779" + suffix + "51", FirstName: "GiftOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + from, err := users.Create(ctx, domain.User{AccessHash: 82, Phone: "+1779" + suffix + "52", FirstName: "GiftSender"}) + if err != nil { + t.Fatalf("create sender: %v", err) + } + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM peer_star_gifts WHERE owner_peer_id IN ($1, $2)", owner.ID, int64(987654321)) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, from.ID}) + }) + + // 创建三份礼物(msg_id 递增)。 + for i := 0; i < 3; i++ { + if _, err := st.Create(ctx, domain.SavedStarGift{ + Owner: ownerPeer, FromUserID: from.ID, GiftID: 8001, MsgID: 100 + i, + Date: 1700000000 + i, ConvertStars: 50, + }); err != nil { + t.Fatalf("create gift #%d: %v", i, err) + } + } + + // keyset 分页:每页 2,末页省略游标。 + page1, err := st.ListByOwner(ctx, ownerPeer, false, "", 2) + if err != nil { + t.Fatalf("list page1: %v", err) + } + if len(page1.Gifts) != 2 || page1.Count != 3 || page1.NextOffset == "" { + t.Fatalf("page1 = %d count %d next %q, want 2/3/nonempty", len(page1.Gifts), page1.Count, page1.NextOffset) + } + if page1.Gifts[0].MsgID != 102 { + t.Fatalf("page1[0] msg_id = %d, want 102 (newest first)", page1.Gifts[0].MsgID) + } + page2, err := st.ListByOwner(ctx, ownerPeer, false, page1.NextOffset, 2) + if err != nil { + t.Fatalf("list page2: %v", err) + } + if len(page2.Gifts) != 1 || page2.NextOffset != "" { + t.Fatalf("page2 = %d next %q, want 1 + empty (terminal)", len(page2.Gifts), page2.NextOffset) + } + + // 隐藏 msg_id=101 → excludeUnsaved 列表少一份。 + if ok, err := st.SetUnsaved(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 101}, true); err != nil || !ok { + t.Fatalf("set unsaved = %v err %v", ok, err) + } + shown, err := st.ListByOwner(ctx, ownerPeer, true, "", 100) + if err != nil || len(shown.Gifts) != 2 || shown.Count != 2 { + t.Fatalf("excludeUnsaved = %d count %d err %v, want 2/2", len(shown.Gifts), shown.Count, err) + } + + // GetByRef(user msg_id)。 + g, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}) + if err != nil || !found || g.GiftID != 8001 || g.ConvertStars != 50 { + t.Fatalf("get = %+v found %v err %v", g, found, err) + } + + // 转换 msg_id=100 → converted,从列表消失;重复转换被拒。 + conv, err := st.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}) + if err != nil || conv.ConvertStars != 50 || !conv.Converted { + t.Fatalf("convert = %+v err %v, want ConvertStars 50 converted", conv, err) + } + if _, err := st.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 100}); !errors.Is(err, domain.ErrStarGiftAlreadyConverted) { + t.Fatalf("double convert err = %v, want ErrStarGiftAlreadyConverted", err) + } + full, _ := st.ListByOwner(ctx, ownerPeer, false, "", 100) + if full.Count != 2 { + t.Fatalf("count after convert = %d, want 2", full.Count) + } + // 转换不存在的礼物。 + if _, err := st.MarkConverted(ctx, domain.SavedStarGiftRef{Owner: ownerPeer, MsgID: 999}); !errors.Is(err, domain.ErrStarGiftNotFound) { + t.Fatalf("convert missing err = %v, want ErrStarGiftNotFound", err) + } + + // CountByOwner(展示在资料 = 非转换、非隐藏):100 已转换、101 已隐藏、102 仍展示 → 1。 + n, err := st.CountByOwner(ctx, ownerPeer) + if err != nil || n != 1 { + t.Fatalf("CountByOwner = %d err %v, want 1 (100 converted, 101 hidden, 102 shown)", n, err) + } + + // 频道礼物用 saved_id 定位,和用户 msg_id 身份键隔离。 + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: 987654321} + if _, err := st.Create(ctx, domain.SavedStarGift{ + Owner: ownerPeer, FromUserID: from.ID, GiftID: 8001, MsgID: 700, + Date: 1700000100, ConvertStars: 50, + }); err != nil { + t.Fatalf("create user gift with same msg_id namespace: %v", err) + } + channelSavedID, err := st.Create(ctx, domain.SavedStarGift{ + Owner: channelPeer, FromUserID: from.ID, GiftID: 8001, MsgID: 0, SavedID: 0, + Date: 1700000101, ConvertStars: 50, + }) + if err != nil { + t.Fatalf("create channel gift: %v", err) + } + cg, found, err := st.GetByRef(ctx, domain.SavedStarGiftRef{Owner: channelPeer, SavedID: channelSavedID}) + if err != nil || !found || cg.Owner != channelPeer || cg.MsgID != 0 || cg.SavedID != channelSavedID { + t.Fatalf("get channel gift = %+v found %v err %v", cg, found, err) + } + cn, err := st.CountByOwner(ctx, channelPeer) + if err != nil || cn != 1 { + t.Fatalf("channel CountByOwner = %d err %v, want 1", cn, err) + } +} diff --git a/internal/store/postgres/stars.go b/internal/store/postgres/stars.go new file mode 100644 index 00000000..beee06d4 --- /dev/null +++ b/internal/store/postgres/stars.go @@ -0,0 +1,206 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// StarsStore 用 PostgreSQL 实现 store.StarsStore(Stars 本地账本)。 +// 借记/贷记/授予各自在单事务内完成:余额与流水永不漂移。 +type StarsStore struct { + db sqlcgen.DBTX +} + +// NewStarsStore 基于 pgx 连接池(或事务)创建 StarsStore。 +func NewStarsStore(db sqlcgen.DBTX) *StarsStore { + return &StarsStore{db: db} +} + +func (s *StarsStore) GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) { + if userID == 0 { + return domain.StarsBalance{}, nil + } + bal := domain.StarsBalance{UserID: userID} + err := s.db.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1`, userID). + Scan(&bal.Balance, &bal.Granted) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.StarsBalance{UserID: userID}, nil + } + return domain.StarsBalance{}, fmt.Errorf("get stars balance: %w", err) + } + return bal, nil +} + +func (s *StarsStore) EnsureGrant(ctx context.Context, userID, amount int64, date int) (domain.StarsBalance, bool, error) { + if userID == 0 { + return domain.StarsBalance{}, false, nil + } + if amount <= 0 { + // 授予额为 0 时只确保有一行且 granted=true(幂等关闭授予)。 + bal, err := s.GetBalance(ctx, userID) + return bal, false, err + } + out := domain.StarsBalance{UserID: userID} + applied := false + err := withTx(ctx, s.db, "ensure stars grant", func(tx pgx.Tx) error { + var balance int64 + var granted bool + err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID). + Scan(&balance, &granted) + switch { + case errors.Is(err, pgx.ErrNoRows): + if _, err := tx.Exec(ctx, `INSERT INTO stars_balances (user_id, balance, granted, updated_at) VALUES ($1, $2, true, now())`, + userID, amount); err != nil { + return fmt.Errorf("insert stars balance grant: %w", err) + } + out.Balance, out.Granted, applied = amount, true, true + case err != nil: + return fmt.Errorf("select stars balance for grant: %w", err) + case granted: + out.Balance, out.Granted = balance, true + return nil + default: + if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance = balance + $2, granted = true, updated_at = now() WHERE user_id = $1 RETURNING balance`, + userID, amount).Scan(&out.Balance); err != nil { + return fmt.Errorf("update stars balance grant: %w", err) + } + out.Granted, applied = true, true + } + return insertStarsTxn(ctx, tx, userID, amount, domain.StarsReasonGrant, domain.Peer{}, date, "", "") + }) + if err != nil { + return domain.StarsBalance{}, false, err + } + return out, applied, nil +} + +func (s *StarsStore) Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) { + if userID == 0 || amount <= 0 { + return domain.StarsBalance{}, domain.ErrStarsInvalidAmount + } + out := domain.StarsBalance{UserID: userID} + err := withTx(ctx, s.db, "credit stars", func(tx pgx.Tx) error { + if err := tx.QueryRow(ctx, ` +INSERT INTO stars_balances (user_id, balance, updated_at) VALUES ($1, $2, now()) +ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now() +RETURNING balance, granted`, userID, amount).Scan(&out.Balance, &out.Granted); err != nil { + return fmt.Errorf("credit stars balance: %w", err) + } + return insertStarsTxn(ctx, tx, userID, amount, reason, peer, date, title, desc) + }) + if err != nil { + return domain.StarsBalance{}, err + } + return out, nil +} + +func (s *StarsStore) Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) { + if userID == 0 || amount <= 0 { + return domain.StarsBalance{}, domain.ErrStarsInvalidAmount + } + out := domain.StarsBalance{UserID: userID, Granted: true} + err := withTx(ctx, s.db, "debit stars", func(tx pgx.Tx) error { + var balance int64 + var granted bool + err := tx.QueryRow(ctx, `SELECT balance, granted FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID). + Scan(&balance, &granted) + if errors.Is(err, pgx.ErrNoRows) || (err == nil && balance < amount) { + return domain.ErrStarsInsufficient + } + if err != nil { + return fmt.Errorf("select stars balance for debit: %w", err) + } + if err := tx.QueryRow(ctx, `UPDATE stars_balances SET balance = balance - $2, updated_at = now() WHERE user_id = $1 RETURNING balance`, + userID, amount).Scan(&out.Balance); err != nil { + return fmt.Errorf("update stars balance debit: %w", err) + } + out.Granted = granted + return insertStarsTxn(ctx, tx, userID, -amount, reason, peer, date, title, desc) + }) + if err != nil { + return domain.StarsBalance{}, err + } + return out, nil +} + +func (s *StarsStore) ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) { + if userID == 0 { + return domain.StarsTransactionPage{}, nil + } + if limit <= 0 || limit > domain.MaxStarsTransactionsLimit { + limit = domain.MaxStarsTransactionsLimit + } + bal, err := s.GetBalance(ctx, userID) + if err != nil { + return domain.StarsTransactionPage{}, err + } + page := domain.StarsTransactionPage{Balance: bal.Balance} + + // keyset:多取一条以探测是否还有下一页。 + args := []any{userID, limit + 1} + query := ` +SELECT id, peer_type, peer_id, amount, reason, title, description, date +FROM stars_transactions +WHERE user_id = $1 +ORDER BY id DESC +LIMIT $2` + if cursor, ok := domain.DecodeStarsCursor(offset); ok { + query = ` +SELECT id, peer_type, peer_id, amount, reason, title, description, date +FROM stars_transactions +WHERE user_id = $1 AND id < $3 +ORDER BY id DESC +LIMIT $2` + args = append(args, cursor) + } + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return domain.StarsTransactionPage{}, fmt.Errorf("list stars transactions: %w", err) + } + defer rows.Close() + txns := make([]domain.StarsTransaction, 0, limit) + for rows.Next() { + var ( + t domain.StarsTransaction + peerType string + peerID int64 + reason string + ) + if err := rows.Scan(&t.ID, &peerType, &peerID, &t.Amount, &reason, &t.Title, &t.Description, &t.Date); err != nil { + return domain.StarsTransactionPage{}, fmt.Errorf("scan stars transaction: %w", err) + } + t.UserID = userID + t.Reason = domain.StarsTransactionReason(reason) + if peerType != "" { + t.Peer = domain.Peer{Type: domain.PeerType(peerType), ID: peerID} + } + txns = append(txns, t) + } + if err := rows.Err(); err != nil { + return domain.StarsTransactionPage{}, fmt.Errorf("iterate stars transactions: %w", err) + } + if len(txns) > limit { + txns = txns[:limit] + page.NextOffset = domain.EncodeStarsCursor(txns[len(txns)-1].ID) + } + page.Transactions = txns + return page, nil +} + +// insertStarsTxn 在事务内写一条流水(amount 带符号)。 +func insertStarsTxn(ctx context.Context, tx pgx.Tx, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) error { + if _, err := tx.Exec(ctx, ` +INSERT INTO stars_transactions (user_id, peer_type, peer_id, amount, reason, title, description, date) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + userID, string(peer.Type), peer.ID, amount, string(reason), title, desc, date); err != nil { + return fmt.Errorf("insert stars transaction: %w", err) + } + return nil +} diff --git a/internal/store/postgres/stars_integration_test.go b/internal/store/postgres/stars_integration_test.go new file mode 100644 index 00000000..42a6f483 --- /dev/null +++ b/internal/store/postgres/stars_integration_test.go @@ -0,0 +1,91 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "telesrv/internal/domain" +) + +// TestStarsLedgerPostgres 回归迁移 0009:Stars 本地账本对真实 PG 的原子语义 +// (首读授予幂等 / 贷记 / 借记原子 / 余额不足拦截且不动账 / keyset 分页末页无游标)。 +func TestStarsLedgerPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + st := NewStarsStore(pool) + + users := NewUserStore(pool) + suffix := randomSuffix(t) + u, err := users.Create(ctx, domain.User{AccessHash: 92, Phone: "+1665" + suffix + "01", FirstName: "StarsLedger"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM stars_transactions WHERE user_id = $1", u.ID) + _, _ = pool.Exec(ctx, "DELETE FROM stars_balances WHERE user_id = $1", u.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID) + }) + + // 空账号:余额 0、未授予。 + if bal, err := st.GetBalance(ctx, u.ID); err != nil || bal.Balance != 0 || bal.Granted { + t.Fatalf("empty balance = %+v err %v, want 0 not granted", bal, err) + } + + // 首读授予一次。 + bal, applied, err := st.EnsureGrant(ctx, u.ID, 1000, 1700000000) + if err != nil || !applied || bal.Balance != 1000 || !bal.Granted { + t.Fatalf("first grant = %+v applied %v err %v, want 1000 granted applied", bal, applied, err) + } + // 再次授予幂等:不重复。 + bal, applied, err = st.EnsureGrant(ctx, u.ID, 1000, 1700000001) + if err != nil || applied || bal.Balance != 1000 { + t.Fatalf("second grant = %+v applied %v err %v, want 1000 not applied", bal, applied, err) + } + + // 借记原子扣减 + 写负流水。 + peer := domain.Peer{Type: domain.PeerTypeChannel, ID: 4242} + bal, err = st.Debit(ctx, u.ID, 300, domain.StarsReasonReaction, peer, 1700000002, "paid reaction", "") + if err != nil || bal.Balance != 700 { + t.Fatalf("debit = %+v err %v, want 700", bal, err) + } + + // 余额不足:拦截且不动账(CHECK + FOR UPDATE 双保险)。 + if _, err := st.Debit(ctx, u.ID, 100000, domain.StarsReasonReaction, peer, 1700000003, "", ""); !errors.Is(err, domain.ErrStarsInsufficient) { + t.Fatalf("over-debit err = %v, want ErrStarsInsufficient", err) + } + if bal, err := st.GetBalance(ctx, u.ID); err != nil || bal.Balance != 700 { + t.Fatalf("balance after failed debit = %+v err %v, want 700 unchanged", bal, err) + } + + // 贷记。 + bal, err = st.Credit(ctx, u.ID, 50, domain.StarsReasonGift, domain.Peer{Type: domain.PeerTypeUser, ID: 9}, 1700000004, "gift", "") + if err != nil || bal.Balance != 750 { + t.Fatalf("credit = %+v err %v, want 750", bal, err) + } + + // 流水:grant(+1000) / debit(-300) / credit(+50) 共 3 条,倒序最新在前。 + page, err := st.ListTransactions(ctx, u.ID, "", 2) + if err != nil { + t.Fatalf("list page1: %v", err) + } + if len(page.Transactions) != 2 || page.NextOffset == "" { + t.Fatalf("page1 = %d txns next=%q, want 2 + next", len(page.Transactions), page.NextOffset) + } + if page.Transactions[0].Amount != 50 || page.Transactions[0].Reason != domain.StarsReasonGift { + t.Fatalf("page1[0] = %+v, want +50 gift (newest)", page.Transactions[0]) + } + if page.Balance != 750 { + t.Fatalf("page balance = %d, want 750", page.Balance) + } + page2, err := st.ListTransactions(ctx, u.ID, page.NextOffset, 2) + if err != nil { + t.Fatalf("list page2: %v", err) + } + if len(page2.Transactions) != 1 || page2.NextOffset != "" { + t.Fatalf("page2 = %d txns next=%q, want 1 + empty (terminal)", len(page2.Transactions), page2.NextOffset) + } + if page2.Transactions[0].Reason != domain.StarsReasonGrant || page2.Transactions[0].Amount != 1000 { + t.Fatalf("page2[0] = %+v, want +1000 grant (oldest)", page2.Transactions[0]) + } +} diff --git a/internal/store/postgres/story.go b/internal/store/postgres/story.go new file mode 100644 index 00000000..08e2edbc --- /dev/null +++ b/internal/store/postgres/story.go @@ -0,0 +1,2502 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// StoryStore persists Telegram story snapshots and per-viewer state in PG. +type StoryStore struct { + db sqlcgen.DBTX +} + +// NewStoryStore creates a PostgreSQL-backed story store. +func NewStoryStore(db sqlcgen.DBTX) *StoryStore { + return &StoryStore{db: db} +} + +const storySelectColumns = ` + s.owner_peer_type, + s.owner_peer_id, + s.story_id, + s.random_id, + s.date, + s.expire_date, + s.deleted, + s.pinned, + s.pinned_to_top_order, + s.public, + s.close_friends, + s.contacts, + s.selected_contacts, + s.noforwards, + s.edited, + COALESCE(s.privacy_rules::text, '[]')::text, + s.allow_user_ids, + s.disallow_user_ids, + s.caption, + COALESCE(s.entities::text, '[]')::text, + COALESCE(s.media::text, '{}')::text, + COALESCE(s.media_areas::text, '[]')::text, + COALESCE(s.fwd_from::text, '{}')::text` + +const storyReturningColumns = ` + owner_peer_type, + owner_peer_id, + story_id, + random_id, + date, + expire_date, + deleted, + pinned, + pinned_to_top_order, + public, + close_friends, + contacts, + selected_contacts, + noforwards, + edited, + COALESCE(privacy_rules::text, '[]')::text, + allow_user_ids, + disallow_user_ids, + caption, + COALESCE(entities::text, '[]')::text, + COALESCE(media::text, '{}')::text, + COALESCE(media_areas::text, '[]')::text, + COALESCE(fwd_from::text, '{}')::text` + +func storyVisiblePredicate(viewerParam string) string { + return storyVisiblePredicateFor("s", viewerParam) +} + +func storyPublicRepostVisiblePredicateFor(alias, viewerParam string) string { + return storyBaseVisiblePredicateFor(alias, viewerParam) +} + +// storyForwardSourceTypeSQL / storyForwardSourceIDSQL 复刻 memory store 的 repost 源解析 +// (memory story.go effectiveForwardSource:forward.Source.ID != 0 用 Source,否则回退 From)。 +// 原 postgres 写法只在 Source 字段为空串时回退 From,但零值 Peer 的 Source.ID 会序列化成 "0" +// (非空串),使「只设了 From 的 repost」被计成 source_id=0 → forwards_count/列表错位为 0, +// 与 memory 行为漂移。两 store 必须一致:按 Source.ID 是否为 0 在 Source / From 间整体切换。 +func storyForwardSourceTypeSQL(alias string) string { + return `(CASE WHEN COALESCE(NULLIF(` + alias + `.fwd_from->'Source'->>'ID', ''), '0')::bigint <> 0 + THEN ` + alias + `.fwd_from->'Source'->>'Type' + ELSE ` + alias + `.fwd_from->'From'->>'Type' END)` +} + +func storyForwardSourceIDSQL(alias string) string { + return `(CASE WHEN COALESCE(NULLIF(` + alias + `.fwd_from->'Source'->>'ID', ''), '0')::bigint <> 0 + THEN (` + alias + `.fwd_from->'Source'->>'ID')::bigint + ELSE (` + alias + `.fwd_from->'From'->>'ID')::bigint END)` +} + +func storyVisiblePredicateFor(alias, viewerParam string) string { + return `( + (` + alias + `.owner_peer_type <> 'channel' + OR EXISTS ( + SELECT 1 + FROM channel_members cm + WHERE cm.channel_id = ` + alias + `.owner_peer_id + AND cm.user_id = ` + viewerParam + ` + AND cm.status = 'active' + AND NOT COALESCE((cm.banned_rights->>'ViewMessages')::boolean, false) + ) + ) + AND ` + storyBaseVisiblePredicateFor(alias, viewerParam) + ` +)` +} + +func storyBaseVisiblePredicateFor(alias, viewerParam string) string { + return `( + (` + alias + `.owner_peer_type = 'user' AND ` + alias + `.owner_peer_id = ` + viewerParam + `) + OR ( + NOT ( + ` + alias + `.owner_peer_type = 'user' + AND EXISTS ( + SELECT 1 + FROM contact_blocks b + WHERE b.owner_user_id = ` + alias + `.owner_peer_id + AND b.blocked_user_id = ` + viewerParam + ` + ) + ) + AND + NOT (` + viewerParam + ` = ANY(` + alias + `.disallow_user_ids)) + AND ( + ` + alias + `.public + OR ` + viewerParam + ` = ANY(` + alias + `.allow_user_ids) + OR ( + ` + alias + `.owner_peer_type = 'user' + AND ` + alias + `.contacts + AND EXISTS ( + SELECT 1 + FROM contacts c + WHERE c.user_id = ` + alias + `.owner_peer_id + AND c.contact_user_id = ` + viewerParam + ` + ) + ) + OR ( + ` + alias + `.owner_peer_type = 'user' + AND ` + alias + `.close_friends + AND EXISTS ( + SELECT 1 + FROM contacts c + WHERE c.user_id = ` + alias + `.owner_peer_id + AND c.contact_user_id = ` + viewerParam + ` + AND c.close_friend + ) + ) + ) + ) +)` +} + +func (s *StoryStore) CreateStory(ctx context.Context, req domain.StoryCreateRequest) (domain.StoryCreateResult, error) { + if err := validatePGStoryPeer(req.Owner); err != nil { + return domain.StoryCreateResult{}, err + } + if req.RandomID == 0 { + return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid + } + if existing, ok, err := s.storyByRandomID(ctx, req.Owner, req.RandomID); err != nil { + return domain.StoryCreateResult{}, err + } else if ok { + return domain.StoryCreateResult{Story: existing, Duplicate: true}, nil + } + entities, err := encodeMessageEntities(req.Entities) + if err != nil { + return domain.StoryCreateResult{}, fmt.Errorf("encode story entities: %w", err) + } + media, err := encodeMessageMedia(req.Media) + if err != nil { + return domain.StoryCreateResult{}, fmt.Errorf("encode story media: %w", err) + } + mediaAreas, err := encodeStoryMediaAreas(req.MediaAreas) + if err != nil { + return domain.StoryCreateResult{}, fmt.Errorf("encode story media areas: %w", err) + } + forward, err := encodeStoryForward(req.Forward) + if err != nil { + return domain.StoryCreateResult{}, fmt.Errorf("encode story forward: %w", err) + } + privacyRules, err := encodePrivacyRules(req.PrivacyRules) + if err != nil { + return domain.StoryCreateResult{}, fmt.Errorf("encode story privacy rules: %w", err) + } + for attempt := 0; attempt < 5; attempt++ { + row := s.db.QueryRow(ctx, ` +WITH next_id AS ( + SELECT COALESCE(MAX(story_id), 0) + 1 AS story_id + FROM stories + WHERE owner_peer_type = $1 + AND owner_peer_id = $2 +), +inserted AS ( + INSERT INTO stories ( + owner_peer_type, owner_peer_id, story_id, random_id, date, expire_date, + deleted, pinned, pinned_to_top_order, public, close_friends, contacts, selected_contacts, + noforwards, edited, privacy_rules, allow_user_ids, disallow_user_ids, caption, entities, media, media_areas, fwd_from + ) + SELECT + $1, $2, next_id.story_id, $3, $4, $5, + false, $6, 0, $7, $8, $9, $10, + $11, false, $12::jsonb, $13::bigint[], $14::bigint[], $15, $16::jsonb, $17::jsonb, $18::jsonb, $19::jsonb + FROM next_id + WHERE next_id.story_id <= $20 + RETURNING `+storyReturningColumns+` +), +self_read AS ( + INSERT INTO story_read_states (viewer_user_id, owner_peer_type, owner_peer_id, max_read_id, date) + SELECT owner_peer_id, owner_peer_type, owner_peer_id, story_id, date + FROM inserted + WHERE owner_peer_type = 'user' + ON CONFLICT (viewer_user_id, owner_peer_type, owner_peer_id) DO UPDATE SET + max_read_id = GREATEST(story_read_states.max_read_id, EXCLUDED.max_read_id), + date = CASE + WHEN EXCLUDED.max_read_id > story_read_states.max_read_id THEN EXCLUDED.date + ELSE story_read_states.date + END, + updated_at = CASE + WHEN EXCLUDED.max_read_id > story_read_states.max_read_id THEN now() + ELSE story_read_states.updated_at + END + RETURNING 1 +) +-- inserted 的列已是 storyReturningColumns(含 COALESCE 等表达式求值后的结果),外层不能再按 +-- 原列名(如 privacy_rules)重复套表达式——那些列在 CTE 里是表达式结果而非原始列,会报 +-- "column does not exist"。直接 SELECT * 取 CTE 行(顺序与 storyReturningColumns 一致,按位扫描)。 +SELECT * FROM inserted`, + string(req.Owner.Type), req.Owner.ID, req.RandomID, int32(req.Date), int32(req.Date+req.Period), + req.Pinned, req.Public, req.CloseFriends, req.Contacts, req.SelectedContacts, + req.NoForwards, privacyRules, nonNullInt64s(req.AllowUserIDs), nonNullInt64s(req.DisallowUserIDs), req.Caption, entities, media, mediaAreas, forward, int32(domain.MaxStoryID)) + story, err := scanPGStory(row, req.Owner.ID) + if err == nil { + return domain.StoryCreateResult{Story: story}, nil + } + if errors.Is(err, pgx.ErrNoRows) { + return domain.StoryCreateResult{}, domain.ErrStoryIDInvalid + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + if existing, ok, loadErr := s.storyByRandomID(ctx, req.Owner, req.RandomID); loadErr != nil { + return domain.StoryCreateResult{}, loadErr + } else if ok { + return domain.StoryCreateResult{Story: existing, Duplicate: true}, nil + } + continue + } + return domain.StoryCreateResult{}, fmt.Errorf("create story: %w", err) + } + return domain.StoryCreateResult{}, fmt.Errorf("create story: exhausted id allocation retries") +} + +func (s *StoryStore) UpsertStory(ctx context.Context, req domain.UpsertStoryRequest) (domain.Story, error) { + story := clonePGStory(req.Story) + if err := validatePGStoryIdentity(story.Owner, story.ID); err != nil { + return domain.Story{}, err + } + if story.Deleted || !story.Pinned { + story.PinnedToTopOrder = 0 + } + entities, err := encodeMessageEntities(story.Entities) + if err != nil { + return domain.Story{}, fmt.Errorf("encode story entities: %w", err) + } + media, err := encodeMessageMedia(story.Media) + if err != nil { + return domain.Story{}, fmt.Errorf("encode story media: %w", err) + } + mediaAreas, err := encodeStoryMediaAreas(story.MediaAreas) + if err != nil { + return domain.Story{}, fmt.Errorf("encode story media areas: %w", err) + } + forward, err := encodeStoryForward(story.Forward) + if err != nil { + return domain.Story{}, fmt.Errorf("encode story forward: %w", err) + } + privacyRules, err := encodePrivacyRules(story.PrivacyRules) + if err != nil { + return domain.Story{}, fmt.Errorf("encode story privacy rules: %w", err) + } + row := s.db.QueryRow(ctx, ` +INSERT INTO stories ( + owner_peer_type, owner_peer_id, story_id, random_id, date, expire_date, + deleted, pinned, pinned_to_top_order, public, close_friends, contacts, selected_contacts, + noforwards, edited, privacy_rules, allow_user_ids, disallow_user_ids, caption, entities, media, media_areas, fwd_from +) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, $12, $13, + $14, $15, $16::jsonb, $17::bigint[], $18::bigint[], $19, $20::jsonb, $21::jsonb, $22::jsonb, $23::jsonb +) +ON CONFLICT (owner_peer_type, owner_peer_id, story_id) DO UPDATE SET + random_id = EXCLUDED.random_id, + date = EXCLUDED.date, + expire_date = EXCLUDED.expire_date, + deleted = EXCLUDED.deleted, + pinned = EXCLUDED.pinned, + pinned_to_top_order = EXCLUDED.pinned_to_top_order, + public = EXCLUDED.public, + close_friends = EXCLUDED.close_friends, + contacts = EXCLUDED.contacts, + selected_contacts = EXCLUDED.selected_contacts, + noforwards = EXCLUDED.noforwards, + edited = EXCLUDED.edited, + privacy_rules = EXCLUDED.privacy_rules, + allow_user_ids = EXCLUDED.allow_user_ids, + disallow_user_ids = EXCLUDED.disallow_user_ids, + caption = EXCLUDED.caption, + entities = EXCLUDED.entities, + media = EXCLUDED.media, + media_areas = EXCLUDED.media_areas, + fwd_from = EXCLUDED.fwd_from, + updated_at = now() +RETURNING `+storyReturningColumns, + string(story.Owner.Type), story.Owner.ID, int32(story.ID), story.RandomID, int32(story.Date), int32(story.ExpireDate), + story.Deleted, story.Pinned, story.PinnedToTopOrder, story.Public, story.CloseFriends, story.Contacts, story.SelectedContacts, + story.NoForwards, story.Edited, privacyRules, nonNullInt64s(story.AllowUserIDs), nonNullInt64s(story.DisallowUserIDs), story.Caption, entities, media, mediaAreas, forward) + out, err := scanPGStory(row, 0) + if err != nil { + return domain.Story{}, fmt.Errorf("upsert story: %w", err) + } + return out, nil +} + +func (s *StoryStore) ListActiveStories(ctx context.Context, viewerUserID int64, hidden bool, now, limit int) (domain.StoryList, error) { + return s.ListActiveStoriesPage(ctx, viewerUserID, hidden, now, domain.StoryListCursor{}, limit) +} + +func (s *StoryStore) ListActiveStoriesPage(ctx context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) (domain.StoryList, error) { + if viewerUserID == 0 { + return domain.StoryList{}, nil + } + limit = clampPGStoryLimit(limit) + total, err := s.countActiveStoryPeers(ctx, viewerUserID, hidden, now) + if err != nil { + return domain.StoryList{}, err + } + owners, err := s.listActiveStoryPeerPage(ctx, viewerUserID, hidden, now, cursor, limit+1) + if err != nil { + return domain.StoryList{}, err + } + hasMore := len(owners) > limit + if hasMore { + owners = owners[:limit] + } + if len(owners) == 0 { + return domain.StoryList{Count: total}, nil + } + stories, err := s.listActiveStoriesForPeers(ctx, viewerUserID, now, owners) + if err != nil { + return domain.StoryList{}, err + } + if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil { + return domain.StoryList{}, err + } + if err := s.recordStoryExposures(ctx, viewerUserID, stories); err != nil { + return domain.StoryList{}, err + } + reads, err := s.ListReadStates(ctx, viewerUserID) + if err != nil { + return domain.StoryList{}, err + } + peers := groupPGPeerStories(stories, reads) + return domain.StoryList{ + Count: total, + HasMore: hasMore, + Stories: stories, + Peers: peers, + }, nil +} + +func (s *StoryStore) ActiveStoriesDigest(ctx context.Context, viewerUserID int64, hidden bool, now int) (domain.StoryListDigest, error) { + if viewerUserID == 0 { + return domain.StoryListDigest{}, nil + } + rows, err := s.db.Query(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +LEFT JOIN story_hidden_peers h + ON h.viewer_user_id = $1 + AND h.owner_peer_type = s.owner_peer_type + AND h.owner_peer_id = s.owner_peer_id +WHERE s.deleted = false + AND s.expire_date > $2 + AND `+storyVisiblePredicate("$1")+` + AND (($3::boolean AND h.viewer_user_id IS NOT NULL) OR (NOT $3::boolean AND h.viewer_user_id IS NULL)) +ORDER BY s.date DESC, s.owner_peer_type ASC, s.owner_peer_id ASC, s.story_id DESC`, viewerUserID, int32(now), hidden) + if err != nil { + return domain.StoryListDigest{}, fmt.Errorf("digest active stories: %w", err) + } + stories, err := scanPGStories(rows, viewerUserID) + if err != nil { + return domain.StoryListDigest{}, err + } + if len(stories) == 0 { + return domain.StoryListDigest{}, nil + } + if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil { + return domain.StoryListDigest{}, err + } + reads, err := s.ListReadStates(ctx, viewerUserID) + if err != nil { + return domain.StoryListDigest{}, err + } + return domain.DigestStoryPeerList(groupPGPeerStories(stories, reads)), nil +} + +type activeStoryPeerOwner struct { + peer domain.Peer + maxDate int +} + +func (s *StoryStore) countActiveStoryPeers(ctx context.Context, viewerUserID int64, hidden bool, now int) (int, error) { + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*) +FROM ( + SELECT s.owner_peer_type, s.owner_peer_id + FROM stories s + LEFT JOIN story_hidden_peers h + ON h.viewer_user_id = $1 + AND h.owner_peer_type = s.owner_peer_type + AND h.owner_peer_id = s.owner_peer_id + WHERE s.deleted = false + AND s.expire_date > $2 + AND `+storyVisiblePredicate("$1")+` + AND (($3::boolean AND h.viewer_user_id IS NOT NULL) OR (NOT $3::boolean AND h.viewer_user_id IS NULL)) + GROUP BY s.owner_peer_type, s.owner_peer_id +) peers`, viewerUserID, int32(now), hidden).Scan(&count); err != nil { + return 0, fmt.Errorf("count active story peers: %w", err) + } + return count, nil +} + +func (s *StoryStore) listActiveStoryPeerPage(ctx context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) ([]activeStoryPeerOwner, error) { + limit = clampPGStoryProbeLimit(limit) + args := []any{viewerUserID, int32(now), hidden} + having := "" + if cursor.Set { + args = append(args, int32(cursor.Date), string(cursor.Peer.Type), cursor.Peer.ID) + having = ` +HAVING MAX(s.date) < $4 + OR (MAX(s.date) = $4 AND (s.owner_peer_type > $5 OR (s.owner_peer_type = $5 AND s.owner_peer_id > $6)))` + } + args = append(args, int32(limit)) + limitParam := len(args) + rows, err := s.db.Query(ctx, ` +SELECT s.owner_peer_type, s.owner_peer_id, MAX(s.date)::int AS max_date +FROM stories s +LEFT JOIN story_hidden_peers h + ON h.viewer_user_id = $1 + AND h.owner_peer_type = s.owner_peer_type + AND h.owner_peer_id = s.owner_peer_id +WHERE s.deleted = false + AND s.expire_date > $2 + AND `+storyVisiblePredicate("$1")+` + AND (($3::boolean AND h.viewer_user_id IS NOT NULL) OR (NOT $3::boolean AND h.viewer_user_id IS NULL)) +GROUP BY s.owner_peer_type, s.owner_peer_id`+having+` +ORDER BY max_date DESC, s.owner_peer_type ASC, s.owner_peer_id ASC +LIMIT $`+fmt.Sprint(limitParam), args...) + if err != nil { + return nil, fmt.Errorf("list active story peer page: %w", err) + } + defer rows.Close() + owners := make([]activeStoryPeerOwner, 0, limit) + for rows.Next() { + var peerType string + var owner activeStoryPeerOwner + if err := rows.Scan(&peerType, &owner.peer.ID, &owner.maxDate); err != nil { + return nil, fmt.Errorf("scan active story peer page: %w", err) + } + owner.peer.Type = domain.PeerType(peerType) + owners = append(owners, owner) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate active story peer page: %w", err) + } + return owners, nil +} + +func (s *StoryStore) listActiveStoriesForPeers(ctx context.Context, viewerUserID int64, now int, owners []activeStoryPeerOwner) ([]domain.Story, error) { + args := []any{viewerUserID, int32(now)} + clauses := make([]string, 0, len(owners)) + for _, owner := range owners { + args = append(args, string(owner.peer.Type), owner.peer.ID) + clauses = append(clauses, fmt.Sprintf("(s.owner_peer_type = $%d AND s.owner_peer_id = $%d)", len(args)-1, len(args))) + } + rows, err := s.db.Query(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE s.deleted = false + AND s.expire_date > $2 + AND `+storyVisiblePredicate("$1")+` + AND (`+strings.Join(clauses, " OR ")+`) +ORDER BY s.date DESC, s.owner_peer_type ASC, s.owner_peer_id ASC, s.story_id DESC`, args...) + if err != nil { + return nil, fmt.Errorf("list active stories for peers: %w", err) + } + stories, err := scanPGStories(rows, viewerUserID) + if err != nil { + return nil, err + } + return stories, nil +} + +func (s *StoryStore) ListOwnerActiveStories(ctx context.Context, owner domain.Peer, now, limit int) (domain.StoryList, error) { + if err := validatePGStoryPeer(owner); err != nil { + return domain.StoryList{}, err + } + limit = clampPGStoryLimit(limit) + rows, err := s.db.Query(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.deleted = false + AND s.expire_date > $3 +ORDER BY s.story_id ASC +LIMIT $4`, string(owner.Type), owner.ID, int32(now), int32(limit)) + if err != nil { + return domain.StoryList{}, fmt.Errorf("list owner active stories: %w", err) + } + stories, err := scanPGStories(rows, 0) + if err != nil { + return domain.StoryList{}, err + } + for i := range stories { + stories[i] = fanoutPGStorySnapshot(stories[i]) + } + return domain.StoryList{Count: len(stories), Stories: stories}, nil +} + +func (s *StoryStore) GetPeerStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (domain.PeerStories, error) { + if err := validatePGStoryPeer(peer); err != nil { + return domain.PeerStories{}, err + } + rows, err := s.db.Query(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.deleted = false + AND s.expire_date > $3 + AND `+storyVisiblePredicate("$4")+` +ORDER BY s.story_id ASC`, string(peer.Type), peer.ID, int32(now), viewerUserID) + if err != nil { + return domain.PeerStories{}, fmt.Errorf("get peer stories: %w", err) + } + stories, err := scanPGStories(rows, viewerUserID) + if err != nil { + return domain.PeerStories{}, err + } + if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil { + return domain.PeerStories{}, err + } + if err := s.recordStoryExposures(ctx, viewerUserID, stories); err != nil { + return domain.PeerStories{}, err + } + read, err := s.getReadState(ctx, viewerUserID, peer) + if err != nil { + return domain.PeerStories{}, err + } + return domain.PeerStories{Peer: peer, MaxReadID: read.MaxReadID, Stories: stories}, nil +} + +func (s *StoryStore) GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error) { + _ = now + if err := validatePGStoryPeer(peer); err != nil { + return domain.StoryList{}, err + } + ids, err := normalizePGStoryIDsNonEmpty(ids) + if err != nil { + return domain.StoryList{}, err + } + rows, err := s.db.Query(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.story_id = ANY($3::int[]) + AND s.deleted = false + AND `+storyVisiblePredicate("$4")+` +ORDER BY array_position($3::int[], s.story_id)`, string(peer.Type), peer.ID, int32s(ids), viewerUserID) + if err != nil { + return domain.StoryList{}, fmt.Errorf("get stories by id: %w", err) + } + stories, err := scanPGStories(rows, viewerUserID) + if err != nil { + return domain.StoryList{}, err + } + if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil { + return domain.StoryList{}, err + } + if err := s.recordStoryExposures(ctx, viewerUserID, stories); err != nil { + return domain.StoryList{}, err + } + return domain.StoryList{Count: len(stories), Stories: stories}, nil +} + +func (s *StoryStore) ListPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) { + _ = now + if err := validatePGStoryPeer(peer); err != nil { + return domain.StoryList{}, err + } + if offsetID < 0 { + offsetID = 0 + } + limit = clampPGStoryLimit(limit) + var count int + var pinnedToTop32 []int32 + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int, + COALESCE( + array_agg(s.story_id ORDER BY s.pinned_to_top_order ASC, s.story_id DESC) + FILTER (WHERE s.pinned_to_top_order > 0), + '{}'::int[] + ) +FROM stories s +WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.deleted = false + AND s.pinned = true + AND `+storyVisiblePredicate("$3"), string(peer.Type), peer.ID, viewerUserID).Scan(&count, &pinnedToTop32); err != nil { + return domain.StoryList{}, fmt.Errorf("summarize pinned stories: %w", err) + } + pinnedToTop := make([]int, 0, len(pinnedToTop32)) + for _, id := range pinnedToTop32 { + pinnedToTop = append(pinnedToTop, int(id)) + } + if count == 0 { + return domain.StoryList{Count: 0, PinnedToTop: pinnedToTop}, nil + } + rows, err := s.db.Query(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.deleted = false + AND s.pinned = true + AND ($3::int = 0 OR s.story_id < $3) + AND `+storyVisiblePredicate("$5")+` +ORDER BY s.story_id DESC +LIMIT $4`, string(peer.Type), peer.ID, int32(offsetID), int32(limit), viewerUserID) + if err != nil { + return domain.StoryList{}, fmt.Errorf("list pinned stories: %w", err) + } + stories, err := scanPGStories(rows, viewerUserID) + if err != nil { + return domain.StoryList{}, err + } + if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil { + return domain.StoryList{}, err + } + if err := s.recordStoryExposures(ctx, viewerUserID, stories); err != nil { + return domain.StoryList{}, err + } + return domain.StoryList{Count: count, Stories: stories, PinnedToTop: pinnedToTop}, nil +} + +func (s *StoryStore) HasPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) { + _ = now + if err := validatePGStoryPeer(peer); err != nil { + return false, err + } + var exists bool + if err := s.db.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM stories s + WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.deleted = false + AND s.pinned = true + AND `+storyVisiblePredicate("$3")+` +)`, string(peer.Type), peer.ID, viewerUserID).Scan(&exists); err != nil { + return false, fmt.Errorf("has pinned stories: %w", err) + } + return exists, nil +} + +func (s *StoryStore) ListStoriesArchive(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) { + if err := validatePGStoryPeer(peer); err != nil { + return domain.StoryList{}, err + } + if offsetID < 0 { + offsetID = 0 + } + var count int + if err := s.db.QueryRow(ctx, ` +SELECT count(*)::int +FROM stories s +WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.deleted = false + AND s.expire_date <= $3`, string(peer.Type), peer.ID, int32(now)).Scan(&count); err != nil { + return domain.StoryList{}, fmt.Errorf("count story archive: %w", err) + } + if limit == 0 { + return domain.StoryList{Count: count}, nil + } + limit = clampPGStoryLimit(limit) + rows, err := s.db.Query(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.deleted = false + AND s.expire_date <= $3 + AND ($4::int = 0 OR s.story_id < $4) +ORDER BY s.story_id DESC +LIMIT $5`, string(peer.Type), peer.ID, int32(now), int32(offsetID), int32(limit)) + if err != nil { + return domain.StoryList{}, fmt.Errorf("list story archive: %w", err) + } + stories, err := scanPGStories(rows, viewerUserID) + if err != nil { + return domain.StoryList{}, err + } + if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil { + return domain.StoryList{}, err + } + if err := s.recordStoryExposures(ctx, viewerUserID, stories); err != nil { + return domain.StoryList{}, err + } + return domain.StoryList{Count: count, Stories: stories}, nil +} + +func (s *StoryStore) ListReadStates(ctx context.Context, viewerUserID int64) ([]domain.StoryReadState, error) { + if viewerUserID == 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +SELECT viewer_user_id, owner_peer_type, owner_peer_id, max_read_id, date +FROM story_read_states +WHERE viewer_user_id = $1 +ORDER BY owner_peer_type ASC, owner_peer_id ASC`, viewerUserID) + if err != nil { + return nil, fmt.Errorf("list story read states: %w", err) + } + defer rows.Close() + out := make([]domain.StoryReadState, 0) + for rows.Next() { + var state domain.StoryReadState + var peerType string + if err := rows.Scan(&state.ViewerID, &peerType, &state.Peer.ID, &state.MaxReadID, &state.Date); err != nil { + return nil, fmt.Errorf("scan story read state: %w", err) + } + state.Peer.Type = domain.PeerType(peerType) + out = append(out, state) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan story read states: %w", err) + } + return out, nil +} + +func (s *StoryStore) GetPeerMaxIDs(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) { + if len(peers) > domain.MaxStoryIDs { + return nil, domain.ErrStoryIDInvalid + } + if len(peers) == 0 { + return nil, nil + } + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + for _, peer := range peers { + if err := validatePGStoryPeer(peer); err != nil { + return nil, err + } + peerTypes = append(peerTypes, string(peer.Type)) + peerIDs = append(peerIDs, peer.ID) + } + rows, err := s.db.Query(ctx, ` +WITH input AS ( + SELECT p.peer_type, i.peer_id, p.ord + FROM unnest($1::text[]) WITH ORDINALITY AS p(peer_type, ord) + JOIN unnest($2::bigint[]) WITH ORDINALITY AS i(peer_id, ord) USING (ord) +) +SELECT input.peer_type, input.peer_id, COALESCE(MAX(s.story_id), 0)::int +FROM input +LEFT JOIN stories s + ON s.owner_peer_type = input.peer_type + AND s.owner_peer_id = input.peer_id + AND s.deleted = false + AND s.expire_date > $3 + AND `+storyVisiblePredicate("$4")+` +GROUP BY input.ord, input.peer_type, input.peer_id +ORDER BY input.ord ASC`, peerTypes, peerIDs, int32(now), viewerUserID) + if err != nil { + return nil, fmt.Errorf("get story peer max ids: %w", err) + } + defer rows.Close() + out := make([]domain.RecentStory, 0, len(peers)) + for rows.Next() { + var peerType string + var item domain.RecentStory + if err := rows.Scan(&peerType, &item.Peer.ID, &item.MaxID); err != nil { + return nil, fmt.Errorf("scan story peer max id: %w", err) + } + item.Peer.Type = domain.PeerType(peerType) + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan story peer max ids: %w", err) + } + return out, nil +} + +func (s *StoryStore) GetPeerHiddenStates(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) { + if len(peers) > domain.MaxStoryIDs { + return nil, domain.ErrStoryIDInvalid + } + if viewerUserID == 0 { + return nil, domain.ErrStoryPeerInvalid + } + if len(peers) == 0 { + return map[domain.Peer]bool{}, nil + } + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + for _, peer := range peers { + if err := validatePGStoryPeer(peer); err != nil { + return nil, err + } + peerTypes = append(peerTypes, string(peer.Type)) + peerIDs = append(peerIDs, peer.ID) + } + rows, err := s.db.Query(ctx, ` +WITH input AS ( + SELECT p.peer_type, i.peer_id, p.ord + FROM unnest($1::text[]) WITH ORDINALITY AS p(peer_type, ord) + JOIN unnest($2::bigint[]) WITH ORDINALITY AS i(peer_id, ord) USING (ord) +) +SELECT input.peer_type, input.peer_id, (h.viewer_user_id IS NOT NULL) AS hidden +FROM input +LEFT JOIN story_hidden_peers h + ON h.viewer_user_id = $3 + AND h.owner_peer_type = input.peer_type + AND h.owner_peer_id = input.peer_id +ORDER BY input.ord ASC`, peerTypes, peerIDs, viewerUserID) + if err != nil { + return nil, fmt.Errorf("get story peer hidden states: %w", err) + } + defer rows.Close() + out := make(map[domain.Peer]bool, len(peers)) + for rows.Next() { + var peerType string + var peer domain.Peer + var hidden bool + if err := rows.Scan(&peerType, &peer.ID, &hidden); err != nil { + return nil, fmt.Errorf("scan story peer hidden state: %w", err) + } + peer.Type = domain.PeerType(peerType) + out[peer] = hidden + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan story peer hidden states: %w", err) + } + return out, nil +} + +func (s *StoryStore) GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) { + if len(peers) > domain.MaxStoryIDs { + return nil, domain.ErrStoryIDInvalid + } + if viewerUserID == 0 { + return nil, domain.ErrStoryPeerInvalid + } + if len(peers) == 0 { + return nil, nil + } + peerTypes := make([]string, 0, len(peers)) + peerIDs := make([]int64, 0, len(peers)) + for _, peer := range peers { + if err := validatePGStoryPeer(peer); err != nil { + return nil, err + } + peerTypes = append(peerTypes, string(peer.Type)) + peerIDs = append(peerIDs, peer.ID) + } + rows, err := s.db.Query(ctx, ` +WITH input AS ( + SELECT p.peer_type, i.peer_id, p.ord + FROM unnest($1::text[]) WITH ORDINALITY AS p(peer_type, ord) + JOIN unnest($2::bigint[]) WITH ORDINALITY AS i(peer_id, ord) USING (ord) +), +recent AS ( + SELECT input.peer_type, input.peer_id, input.ord, COALESCE(MAX(s.story_id), 0)::int AS max_story_id + FROM input + LEFT JOIN stories s + ON s.owner_peer_type = input.peer_type + AND s.owner_peer_id = input.peer_id + AND s.deleted = false + AND s.expire_date > $3 + AND `+storyVisiblePredicate("$4")+` + GROUP BY input.ord, input.peer_type, input.peer_id +) +SELECT recent.peer_type, recent.peer_id, recent.max_story_id, (h.viewer_user_id IS NOT NULL) AS hidden +FROM recent +LEFT JOIN story_hidden_peers h + ON h.viewer_user_id = $4 + AND h.owner_peer_type = recent.peer_type + AND h.owner_peer_id = recent.peer_id +ORDER BY recent.ord ASC`, peerTypes, peerIDs, int32(now), viewerUserID) + if err != nil { + return nil, fmt.Errorf("get story peer projections: %w", err) + } + defer rows.Close() + out := make([]domain.PeerStoryProjection, 0, len(peers)) + for rows.Next() { + var peerType string + var item domain.PeerStoryProjection + if err := rows.Scan(&peerType, &item.Peer.ID, &item.Recent.MaxID, &item.Hidden); err != nil { + return nil, fmt.Errorf("scan story peer projection: %w", err) + } + item.Peer.Type = domain.PeerType(peerType) + item.Recent.Peer = item.Peer + out = append(out, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan story peer projections: %w", err) + } + return out, nil +} + +func (s *StoryStore) MarkRead(ctx context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) { + if viewerUserID == 0 { + return domain.StoryReadResult{}, domain.ErrStoryPeerInvalid + } + if err := validatePGStoryIdentity(peer, maxID); err != nil { + return domain.StoryReadResult{}, err + } + var gotMax, gotDate int + var advanced bool + err := s.db.QueryRow(ctx, ` +WITH existing AS ( + SELECT max_read_id + FROM story_read_states + WHERE viewer_user_id = $1 + AND owner_peer_type = $2 + AND owner_peer_id = $3 +), +upsert AS ( + INSERT INTO story_read_states (viewer_user_id, owner_peer_type, owner_peer_id, max_read_id, date) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (viewer_user_id, owner_peer_type, owner_peer_id) DO UPDATE SET + max_read_id = GREATEST(story_read_states.max_read_id, EXCLUDED.max_read_id), + date = CASE + WHEN EXCLUDED.max_read_id > story_read_states.max_read_id THEN EXCLUDED.date + ELSE story_read_states.date + END, + updated_at = CASE + WHEN EXCLUDED.max_read_id > story_read_states.max_read_id THEN now() + ELSE story_read_states.updated_at + END + RETURNING max_read_id, date +) +SELECT upsert.max_read_id, upsert.date, COALESCE((SELECT max_read_id FROM existing), 0) < $4 +FROM upsert`, viewerUserID, string(peer.Type), peer.ID, int32(maxID), int32(date)).Scan(&gotMax, &gotDate, &advanced) + if err != nil { + return domain.StoryReadResult{}, fmt.Errorf("mark story read: %w", err) + } + return domain.StoryReadResult{ViewerID: viewerUserID, Peer: peer, MaxReadID: gotMax, Advanced: advanced, Date: gotDate}, nil +} + +func (s *StoryStore) IncrementViews(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, date int) (int, error) { + if viewerUserID == 0 { + return 0, domain.ErrStoryPeerInvalid + } + if err := validatePGStoryPeer(peer); err != nil { + return 0, err + } + ids, err := normalizePGStoryIDsNonEmpty(ids) + if err != nil { + return 0, err + } + if peer.IsSelfUser(viewerUserID) { + return 0, nil + } + var created int + if err := s.db.QueryRow(ctx, ` +WITH input AS ( + SELECT DISTINCT unnest($5::int[]) AS story_id +), +visible AS ( + SELECT s.story_id + FROM stories s + JOIN input i ON i.story_id = s.story_id + WHERE s.owner_peer_type = $2 + AND s.owner_peer_id = $3 + AND s.deleted = false + AND (s.expire_date > $4 OR s.pinned) + AND `+storyVisiblePredicate("$1")+` +), +inserted AS ( + INSERT INTO story_views (owner_peer_type, owner_peer_id, story_id, viewer_user_id, date) + SELECT $2, $3, visible.story_id, $1, $4 + FROM visible + ON CONFLICT DO NOTHING + RETURNING story_id +) +SELECT count(*)::int FROM inserted`, viewerUserID, string(peer.Type), peer.ID, int32(date), int32s(ids)).Scan(&created); err != nil { + return 0, fmt.Errorf("increment story views: %w", err) + } + return created, nil +} + +func (s *StoryStore) SetReaction(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction *domain.MessageReaction, date int) (domain.StoryReactionResult, error) { + if viewerUserID == 0 { + return domain.StoryReactionResult{}, domain.ErrStoryPeerInvalid + } + if err := validatePGStoryIdentity(peer, storyID); err != nil { + return domain.StoryReactionResult{}, err + } + if peer.IsSelfUser(viewerUserID) { + return domain.StoryReactionResult{}, domain.ErrStoryPeerInvalid + } + encodedReaction, err := encodeStoryReaction(reaction) + if err != nil { + return domain.StoryReactionResult{}, err + } + story, err := s.getVisibleStory(ctx, viewerUserID, peer, storyID) + if err != nil { + return domain.StoryReactionResult{}, err + } + if !story.Interactable(date) { + return domain.StoryReactionResult{}, domain.ErrStoryNotFound + } + var priorRaw string + var priorDate int + priorErr := s.db.QueryRow(ctx, ` +SELECT COALESCE(reaction::text, '{}')::text, date +FROM story_views +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = $3 + AND viewer_user_id = $4`, string(peer.Type), peer.ID, int32(storyID), viewerUserID).Scan(&priorRaw, &priorDate) + if priorErr != nil && !errors.Is(priorErr, pgx.ErrNoRows) { + return domain.StoryReactionResult{}, fmt.Errorf("load prior story reaction: %w", priorErr) + } + priorExists := priorErr == nil + prior, err := decodeStoryReaction(priorRaw) + if err != nil { + return domain.StoryReactionResult{}, fmt.Errorf("decode prior story reaction: %w", err) + } + changed := !samePGReaction(prior, reaction) + resultDate := date + if priorExists && !changed { + resultDate = priorDate + } else if _, err := s.db.Exec(ctx, ` +INSERT INTO story_views (owner_peer_type, owner_peer_id, story_id, viewer_user_id, date, reaction) +VALUES ($1, $2, $3, $4, $5, $6::jsonb) +ON CONFLICT (owner_peer_type, owner_peer_id, story_id, viewer_user_id) DO UPDATE SET + date = EXCLUDED.date, + reaction = EXCLUDED.reaction, + updated_at = now()`, string(peer.Type), peer.ID, int32(storyID), viewerUserID, int32(date), encodedReaction); err != nil { + return domain.StoryReactionResult{}, fmt.Errorf("set story reaction: %w", err) + } + stories := []domain.Story{story} + if err := s.populateStoryViewState(ctx, viewerUserID, stories); err != nil { + return domain.StoryReactionResult{}, err + } + return domain.StoryReactionResult{ + ViewerID: viewerUserID, + Peer: peer, + StoryID: storyID, + Reaction: clonePGReactionPtr(reaction), + Story: stories[0], + Changed: changed, + Date: resultDate, + }, nil +} + +func (s *StoryStore) ListStoryViews(ctx context.Context, req domain.StoryViewListRequest) (domain.StoryViewList, error) { + if req.ViewerUserID == 0 { + return domain.StoryViewList{}, domain.ErrStoryPeerInvalid + } + if err := validatePGStoryIdentity(req.Owner, req.StoryID); err != nil { + return domain.StoryViewList{}, err + } + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return domain.StoryViewList{}, err + } + if _, err := s.getOwnerStory(ctx, req.Owner, req.StoryID); err != nil { + return domain.StoryViewList{}, err + } + viewsCount, reactionsCount, err := s.storyViewCounts(ctx, req.Owner, req.StoryID) + if err != nil { + return domain.StoryViewList{}, err + } + forwardsCount, err := s.storyForwardCount(ctx, req.Owner, req.StoryID, req.ViewerUserID) + if err != nil { + return domain.StoryViewList{}, err + } + limit := clampPGStoryInteractionLimit(req.Limit) + cursor := parsePGStoryInteractionCursor(req.Offset) + interactionsFirst := req.ReactionsFirst || req.ForwardsFirst + query := strings.ToLower(strings.TrimSpace(req.Query)) + querySet := query != "" + queryLike := "%" + escapeLike(query) + "%" + var count int + if err := s.db.QueryRow(ctx, ` +SELECT (count(*))::int +FROM story_views sv +JOIN users u ON u.id = sv.viewer_user_id +LEFT JOIN contacts c + ON c.user_id = $4 + AND c.contact_user_id = sv.viewer_user_id +WHERE sv.owner_peer_type = $1 + AND sv.owner_peer_id = $2 + AND sv.story_id = $3 + AND (NOT $5::boolean OR c.contact_user_id IS NOT NULL) + AND ( + NOT $6::boolean + OR lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) LIKE $7 ESCAPE '\' + OR lower(COALESCE(c.contact_last_name, u.last_name)) LIKE $7 ESCAPE '\' + OR lower(trim(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name) || ' ' || COALESCE(c.contact_last_name, u.last_name))) LIKE $7 ESCAPE '\' + OR lower(u.username) LIKE $7 ESCAPE '\' + OR lower(COALESCE(NULLIF(c.contact_phone, ''), u.phone)) LIKE $7 ESCAPE '\' + )`, string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), req.ViewerUserID, req.JustContacts, querySet, queryLike).Scan(&count); err != nil { + return domain.StoryViewList{}, fmt.Errorf("count story views: %w", err) + } + rows, err := s.db.Query(ctx, ` +SELECT + sv.viewer_user_id, + sv.date, + COALESCE(sv.reaction::text, '{}')::text, + false, + ( + $1 = 'user' + AND EXISTS ( + SELECT 1 + FROM contact_blocks b + WHERE b.owner_user_id = $2 + AND b.blocked_user_id = sv.viewer_user_id + ) + ) +FROM story_views sv +JOIN users u ON u.id = sv.viewer_user_id +LEFT JOIN contacts c + ON c.user_id = $4 + AND c.contact_user_id = sv.viewer_user_id +WHERE sv.owner_peer_type = $1 + AND sv.owner_peer_id = $2 + AND sv.story_id = $3 + AND (NOT $5::boolean OR c.contact_user_id IS NOT NULL) + AND ( + NOT $6::boolean + OR lower(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)) LIKE $7 ESCAPE '\' + OR lower(COALESCE(c.contact_last_name, u.last_name)) LIKE $7 ESCAPE '\' + OR lower(trim(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name) || ' ' || COALESCE(c.contact_last_name, u.last_name))) LIKE $7 ESCAPE '\' + OR lower(u.username) LIKE $7 ESCAPE '\' + OR lower(COALESCE(NULLIF(c.contact_phone, ''), u.phone)) LIKE $7 ESCAPE '\' + ) + AND ( + NOT $9::boolean + OR (CASE WHEN $8::boolean AND sv.reaction <> '{}'::jsonb AND sv.reaction <> 'null'::jsonb THEN 0 WHEN $8::boolean THEN 1 ELSE 0 END) > $10 + OR ( + (CASE WHEN $8::boolean AND sv.reaction <> '{}'::jsonb AND sv.reaction <> 'null'::jsonb THEN 0 WHEN $8::boolean THEN 1 ELSE 0 END) = $10 + AND (sv.date < $11 OR (sv.date = $11 AND sv.viewer_user_id < $12)) + ) + ) +ORDER BY + (CASE WHEN $8::boolean AND sv.reaction <> '{}'::jsonb AND sv.reaction <> 'null'::jsonb THEN 0 WHEN $8::boolean THEN 1 ELSE 0 END) ASC, + sv.date DESC, + sv.viewer_user_id DESC +LIMIT $13`, string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), req.ViewerUserID, req.JustContacts, querySet, queryLike, interactionsFirst, cursor.set, int32(cursor.group), int32(cursor.date), cursor.viewerID, int32(limit+1)) + if err != nil { + return domain.StoryViewList{}, fmt.Errorf("list story views: %w", err) + } + views, err := scanPGStoryViews(rows, req.Owner, req.StoryID) + if err != nil { + return domain.StoryViewList{}, err + } + if !querySet && !req.JustContacts { + reposts, err := s.listStoryRepostViews(ctx, req, limit+1, cursor) + if err != nil { + return domain.StoryViewList{}, err + } + views = append(views, reposts...) + } + sortPGStoryViewsForList(views, req.ReactionsFirst, req.ForwardsFirst) + nextOffset := "" + if len(views) > limit { + views = views[:limit] + nextOffset = formatPGStoryInteractionCursor(views[len(views)-1], req.ReactionsFirst, req.ForwardsFirst) + } + if !querySet && !req.JustContacts { + count += forwardsCount + } + return domain.StoryViewList{ + Count: count, + ViewsCount: viewsCount, + ForwardsCount: forwardsCount, + ReactionsCount: reactionsCount, + Views: views, + NextOffset: nextOffset, + }, nil +} + +func (s *StoryStore) ListStoryReactions(ctx context.Context, req domain.StoryReactionListRequest) (domain.StoryReactionList, error) { + if req.ViewerUserID == 0 { + return domain.StoryReactionList{}, domain.ErrStoryPeerInvalid + } + if err := validatePGStoryIdentity(req.Owner, req.StoryID); err != nil { + return domain.StoryReactionList{}, err + } + if err := domain.ValidateStoryReactionInteractionOffset(req.Offset, req.ForwardsFirst); err != nil { + return domain.StoryReactionList{}, err + } + if _, err := s.getOwnerStory(ctx, req.Owner, req.StoryID); err != nil { + return domain.StoryReactionList{}, err + } + filterSet := req.Reaction != nil + reactionFilter, err := encodeStoryReaction(req.Reaction) + if err != nil { + return domain.StoryReactionList{}, err + } + var count int + if err := s.db.QueryRow(ctx, ` +SELECT (count(*))::int +FROM story_views +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = $3 + AND reaction <> '{}'::jsonb + AND reaction <> 'null'::jsonb + AND (NOT $4::boolean OR reaction = $5::jsonb)`, + string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), filterSet, reactionFilter).Scan(&count); err != nil { + return domain.StoryReactionList{}, fmt.Errorf("count story reactions: %w", err) + } + if !filterSet { + forwardsCount, err := s.storyForwardCount(ctx, req.Owner, req.StoryID, req.ViewerUserID) + if err != nil { + return domain.StoryReactionList{}, err + } + count += forwardsCount + } + limit := clampPGStoryInteractionLimit(req.Limit) + cursor := parsePGStoryInteractionCursor(req.Offset) + reactionGroup := pgStoryViewSortGroup(domain.StoryView{Reaction: &domain.MessageReaction{}}, false, req.ForwardsFirst) + args := []any{string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), filterSet, reactionFilter, int32(limit + 1)} + cursorClause := "" + if cursor.set { + args = append(args, int32(reactionGroup), int32(cursor.group), int32(cursor.date), cursor.viewerID) + cursorClause = ` + AND ( + $7::int > $8::int + OR ( + $7::int = $8::int + AND ( + date < $9::int + OR (date = $9::int AND viewer_user_id < $10::bigint) + ) + ) + )` + } + rows, err := s.db.Query(ctx, ` +SELECT viewer_user_id, date, COALESCE(reaction::text, '{}')::text, false, false +FROM story_views +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = $3 + AND reaction <> '{}'::jsonb + AND reaction <> 'null'::jsonb + AND (NOT $4::boolean OR reaction = $5::jsonb) +`+cursorClause+` +ORDER BY date DESC, viewer_user_id DESC +LIMIT $6`, args...) + if err != nil { + return domain.StoryReactionList{}, fmt.Errorf("list story reactions: %w", err) + } + reactions, err := scanPGStoryViews(rows, req.Owner, req.StoryID) + if err != nil { + return domain.StoryReactionList{}, err + } + if !filterSet { + reposts, err := s.listStoryRepostViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: req.ViewerUserID, + Owner: req.Owner, + StoryID: req.StoryID, + ForwardsFirst: req.ForwardsFirst, + }, limit+1, cursor) + if err != nil { + return domain.StoryReactionList{}, err + } + reactions = append(reactions, reposts...) + } + sortPGStoryViewsForList(reactions, false, req.ForwardsFirst) + nextOffset := "" + if len(reactions) > limit { + reactions = reactions[:limit] + nextOffset = formatPGStoryInteractionCursor(reactions[len(reactions)-1], false, req.ForwardsFirst) + } + return domain.StoryReactionList{Count: count, Reactions: reactions, NextOffset: nextOffset}, nil +} + +func (s *StoryStore) ListStoryPublicForwards(ctx context.Context, req domain.StoryPublicForwardListRequest) (domain.StoryPublicForwardList, error) { + if req.ViewerUserID == 0 { + return domain.StoryPublicForwardList{}, domain.ErrStoryPeerInvalid + } + if err := validatePGStoryIdentity(req.Owner, req.StoryID); err != nil { + return domain.StoryPublicForwardList{}, err + } + if err := domain.ValidateStoryInteractionOffset(req.Offset, false); err != nil { + return domain.StoryPublicForwardList{}, err + } + if _, err := s.getOwnerStory(ctx, req.Owner, req.StoryID); err != nil { + return domain.StoryPublicForwardList{}, err + } + count, err := s.storyForwardCount(ctx, req.Owner, req.StoryID, req.ViewerUserID) + if err != nil { + return domain.StoryPublicForwardList{}, err + } + limit := clampPGStoryInteractionLimit(req.Limit) + cursor := parsePGStoryInteractionCursor(req.Offset) + reposts, err := s.listStoryRepostViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: req.ViewerUserID, + Owner: req.Owner, + StoryID: req.StoryID, + Offset: req.Offset, + Limit: req.Limit, + ForwardsFirst: true, + }, limit+1, cursor) + if err != nil { + return domain.StoryPublicForwardList{}, err + } + sortPGStoryViewsForList(reposts, false, true) + nextOffset := "" + if len(reposts) > limit { + reposts = reposts[:limit] + nextOffset = formatPGStoryInteractionCursor(reposts[len(reposts)-1], false, true) + } + return domain.StoryPublicForwardList{Count: count, Forwards: reposts, NextOffset: nextOffset}, nil +} + +func (s *StoryStore) ListStoryViewerIDs(ctx context.Context, owner domain.Peer, storyID, limit int) ([]int64, error) { + if err := validatePGStoryIdentity(owner, storyID); err != nil { + return nil, err + } + var exists bool + if err := s.db.QueryRow(ctx, ` +SELECT EXISTS ( + SELECT 1 + FROM stories + WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = $3 +)`, string(owner.Type), owner.ID, int32(storyID)).Scan(&exists); err != nil { + return nil, fmt.Errorf("check story for viewer ids: %w", err) + } + if !exists { + return nil, domain.ErrStoryNotFound + } + limit = clampPGStoryPrivacyFanoutLimit(limit) + rows, err := s.db.Query(ctx, ` +SELECT viewer_user_id +FROM ( + SELECT viewer_user_id + FROM story_views + WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = $3 + UNION + SELECT viewer_user_id + FROM story_exposures + WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = $3 +) viewers +WHERE viewer_user_id <> 0 +ORDER BY viewer_user_id ASC +LIMIT $4`, string(owner.Type), owner.ID, int32(storyID), int32(limit)) + if err != nil { + return nil, fmt.Errorf("list story viewer ids: %w", err) + } + defer rows.Close() + ids := make([]int64, 0) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan story viewer id: %w", err) + } + if id != 0 { + ids = append(ids, id) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate story viewer ids: %w", err) + } + return ids, nil +} + +func (s *StoryStore) recordStoryExposures(ctx context.Context, viewerUserID int64, stories []domain.Story) error { + if viewerUserID == 0 || len(stories) == 0 { + return nil + } + peerTypes := make([]string, 0, len(stories)) + peerIDs := make([]int64, 0, len(stories)) + storyIDs := make([]int32, 0, len(stories)) + dates := make([]int32, 0, len(stories)) + type exposureKey struct { + peerType domain.PeerType + peerID int64 + storyID int + } + seen := make(map[exposureKey]struct{}, len(stories)) + for _, story := range stories { + if story.ID <= 0 || story.Owner.ID == 0 { + continue + } + if story.Owner.Type == domain.PeerTypeUser && story.Owner.ID == viewerUserID { + continue + } + key := exposureKey{peerType: story.Owner.Type, peerID: story.Owner.ID, storyID: story.ID} + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + peerTypes = append(peerTypes, string(story.Owner.Type)) + peerIDs = append(peerIDs, story.Owner.ID) + storyIDs = append(storyIDs, int32(story.ID)) + dates = append(dates, int32(story.Date)) + } + if len(storyIDs) == 0 { + return nil + } + if _, err := s.db.Exec(ctx, ` +INSERT INTO story_exposures (owner_peer_type, owner_peer_id, story_id, viewer_user_id, date) +SELECT t.owner_peer_type, t.owner_peer_id, t.story_id, $1, t.date +FROM unnest($2::text[], $3::bigint[], $4::int[], $5::int[]) AS t(owner_peer_type, owner_peer_id, story_id, date) +ON CONFLICT (owner_peer_type, owner_peer_id, story_id, viewer_user_id) DO UPDATE SET + date = GREATEST(story_exposures.date, EXCLUDED.date), + updated_at = now()`, viewerUserID, peerTypes, peerIDs, storyIDs, dates); err != nil { + return fmt.Errorf("record story exposures: %w", err) + } + return nil +} + +func (s *StoryStore) EditStory(ctx context.Context, req domain.StoryEditRequest) (domain.StoryEditResult, error) { + if err := validatePGStoryIdentity(req.Owner, req.ID); err != nil { + return domain.StoryEditResult{}, err + } + current, err := s.getOwnerStory(ctx, req.Owner, req.ID) + if err != nil { + return domain.StoryEditResult{}, err + } + updated := clonePGStory(current) + if req.UpdateMedia { + updated.Media = req.Media + } + if req.UpdateCaption { + updated.Caption = req.Caption + updated.Entities = append([]domain.MessageEntity(nil), req.Entities...) + } + if req.UpdatePrivacy { + updated.Public = req.Public + updated.CloseFriends = req.CloseFriends + updated.Contacts = req.Contacts + updated.SelectedContacts = req.SelectedContacts + updated.PrivacyRules = clonePGPrivacyRules(req.PrivacyRules) + updated.AllowUserIDs = append([]int64(nil), req.AllowUserIDs...) + updated.DisallowUserIDs = append([]int64(nil), req.DisallowUserIDs...) + } + if req.UpdateMediaAreas { + updated.MediaAreas = clonePGStoryMediaAreas(req.MediaAreas) + } + if reflect.DeepEqual(current, updated) { + return domain.StoryEditResult{}, domain.ErrStoryNotModified + } + updated.Edited = true + entities, err := encodeMessageEntities(updated.Entities) + if err != nil { + return domain.StoryEditResult{}, fmt.Errorf("encode story entities: %w", err) + } + media, err := encodeMessageMedia(updated.Media) + if err != nil { + return domain.StoryEditResult{}, fmt.Errorf("encode story media: %w", err) + } + mediaAreas, err := encodeStoryMediaAreas(updated.MediaAreas) + if err != nil { + return domain.StoryEditResult{}, fmt.Errorf("encode story media areas: %w", err) + } + privacyRules, err := encodePrivacyRules(updated.PrivacyRules) + if err != nil { + return domain.StoryEditResult{}, fmt.Errorf("encode story privacy rules: %w", err) + } + row := s.db.QueryRow(ctx, ` +UPDATE stories +SET public = $4, + close_friends = $5, + contacts = $6, + selected_contacts = $7, + edited = true, + privacy_rules = $8::jsonb, + allow_user_ids = $9::bigint[], + disallow_user_ids = $10::bigint[], + caption = $11, + entities = $12::jsonb, + media = $13::jsonb, + media_areas = $14::jsonb, + updated_at = now() +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = $3 + AND deleted = false +RETURNING `+storyReturningColumns, + string(req.Owner.Type), req.Owner.ID, int32(req.ID), + updated.Public, updated.CloseFriends, updated.Contacts, updated.SelectedContacts, + privacyRules, nonNullInt64s(updated.AllowUserIDs), nonNullInt64s(updated.DisallowUserIDs), + updated.Caption, entities, media, mediaAreas) + out, err := scanPGStory(row, req.Owner.ID) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StoryEditResult{}, domain.ErrStoryNotFound + } + if err != nil { + return domain.StoryEditResult{}, fmt.Errorf("edit story: %w", err) + } + return domain.StoryEditResult{Story: out, Previous: clonePGStory(current)}, nil +} + +func (s *StoryStore) DeleteStories(ctx context.Context, peer domain.Peer, ids []int, date int) (domain.StoryMutationResult, error) { + _ = date + if err := validatePGStoryPeer(peer); err != nil { + return domain.StoryMutationResult{}, err + } + ids, err := normalizePGStoryIDsNonEmpty(ids) + if err != nil { + return domain.StoryMutationResult{}, err + } + beforeRows, err := s.db.Query(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = ANY($3::int[]) + AND deleted = false`, string(peer.Type), peer.ID, int32s(ids)) + if err != nil { + return domain.StoryMutationResult{}, fmt.Errorf("load stories before delete: %w", err) + } + before, err := scanPGStories(beforeRows, peer.ID) + if err != nil { + return domain.StoryMutationResult{}, err + } + rows, err := s.db.Query(ctx, ` +UPDATE stories +SET deleted = true, + pinned = false, + pinned_to_top_order = 0, + updated_at = now() +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = ANY($3::int[]) + AND deleted = false +RETURNING `+storyReturningColumns, string(peer.Type), peer.ID, int32s(ids)) + if err != nil { + return domain.StoryMutationResult{}, fmt.Errorf("delete stories: %w", err) + } + stories, err := scanPGStories(rows, peer.ID) + if err != nil { + return domain.StoryMutationResult{}, err + } + previousByID := make(map[int]domain.Story, len(before)) + for _, story := range before { + previousByID[story.ID] = story + } + previous := make([]domain.Story, 0, len(stories)) + for _, story := range stories { + if prev, ok := previousByID[story.ID]; ok { + previous = append(previous, prev) + } + } + return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...), Stories: stories, Previous: previous}, nil +} + +func (s *StoryStore) TogglePinned(ctx context.Context, peer domain.Peer, ids []int, pinned bool, date int) (domain.StoryMutationResult, error) { + _ = date + if err := validatePGStoryPeer(peer); err != nil { + return domain.StoryMutationResult{}, err + } + ids, err := normalizePGStoryIDs(ids) + if err != nil { + return domain.StoryMutationResult{}, err + } + beforeRows, err := s.db.Query(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = ANY($3::int[]) + AND deleted = false + AND pinned <> $4`, string(peer.Type), peer.ID, int32s(ids), pinned) + if err != nil { + return domain.StoryMutationResult{}, fmt.Errorf("load stories before toggle pinned: %w", err) + } + before, err := scanPGStories(beforeRows, peer.ID) + if err != nil { + return domain.StoryMutationResult{}, err + } + rows, err := s.db.Query(ctx, ` +UPDATE stories +SET pinned = $4, + pinned_to_top_order = CASE WHEN $4::boolean THEN pinned_to_top_order ELSE 0 END, + updated_at = now() +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = ANY($3::int[]) + AND deleted = false + AND pinned <> $4 +RETURNING `+storyReturningColumns, string(peer.Type), peer.ID, int32s(ids), pinned) + if err != nil { + return domain.StoryMutationResult{}, fmt.Errorf("toggle story pinned: %w", err) + } + stories, err := scanPGStories(rows, peer.ID) + if err != nil { + return domain.StoryMutationResult{}, err + } + previousByID := make(map[int]domain.Story, len(before)) + for _, story := range before { + previousByID[story.ID] = story + } + previous := make([]domain.Story, 0, len(stories)) + for _, story := range stories { + if prev, ok := previousByID[story.ID]; ok { + previous = append(previous, prev) + } + } + return domain.StoryMutationResult{Peer: peer, IDs: append([]int(nil), ids...), Stories: stories, Previous: previous}, nil +} + +func (s *StoryStore) TogglePinnedToTop(ctx context.Context, peer domain.Peer, ids []int) error { + if err := validatePGStoryPeer(peer); err != nil { + return err + } + ids, err := normalizePGStoryPinnedToTopIDs(ids) + if err != nil { + return err + } + return withTx(ctx, s.db, "toggle story pinned to top", func(tx pgx.Tx) error { + if len(ids) > 0 { + var found int + if err := tx.QueryRow(ctx, ` +SELECT count(*)::int +FROM stories +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = ANY($3::int[]) + AND deleted = false + AND pinned = true`, string(peer.Type), peer.ID, int32s(ids)).Scan(&found); err != nil { + return fmt.Errorf("count pinned-to-top candidates: %w", err) + } + if found != len(ids) { + return domain.ErrStoryIDInvalid + } + } + if _, err := tx.Exec(ctx, ` +UPDATE stories +SET pinned_to_top_order = 0, + updated_at = now() +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND pinned_to_top_order <> 0`, string(peer.Type), peer.ID); err != nil { + return fmt.Errorf("clear pinned-to-top order: %w", err) + } + for i, id := range ids { + tag, err := tx.Exec(ctx, ` +UPDATE stories +SET pinned_to_top_order = $4, + updated_at = now() +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = $3 + AND deleted = false + AND pinned = true`, string(peer.Type), peer.ID, int32(id), int32(i+1)) + if err != nil { + return fmt.Errorf("set pinned-to-top order: %w", err) + } + if tag.RowsAffected() != 1 { + return domain.ErrStoryIDInvalid + } + } + return nil + }) +} + +func (s *StoryStore) SetPeerHidden(ctx context.Context, viewerUserID int64, peer domain.Peer, hidden bool) error { + if viewerUserID == 0 { + return domain.ErrStoryPeerInvalid + } + if err := validatePGStoryPeer(peer); err != nil { + return err + } + if hidden { + _, err := s.db.Exec(ctx, ` +INSERT INTO story_hidden_peers (viewer_user_id, owner_peer_type, owner_peer_id) +VALUES ($1, $2, $3) +ON CONFLICT (viewer_user_id, owner_peer_type, owner_peer_id) DO UPDATE SET updated_at = now()`, + viewerUserID, string(peer.Type), peer.ID) + if err != nil { + return fmt.Errorf("set story peer hidden: %w", err) + } + return nil + } + if _, err := s.db.Exec(ctx, ` +DELETE FROM story_hidden_peers +WHERE viewer_user_id = $1 + AND owner_peer_type = $2 + AND owner_peer_id = $3`, viewerUserID, string(peer.Type), peer.ID); err != nil { + return fmt.Errorf("clear story peer hidden: %w", err) + } + return nil +} + +func (s *StoryStore) getVisibleStory(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int) (domain.Story, error) { + row := s.db.QueryRow(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.story_id = $3 + AND s.deleted = false + AND `+storyVisiblePredicate("$4"), string(peer.Type), peer.ID, int32(storyID), viewerUserID) + story, err := scanPGStory(row, viewerUserID) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Story{}, domain.ErrStoryNotFound + } + if err != nil { + return domain.Story{}, fmt.Errorf("get visible story: %w", err) + } + return story, nil +} + +func (s *StoryStore) storyByRandomID(ctx context.Context, peer domain.Peer, randomID int64) (domain.Story, bool, error) { + row := s.db.QueryRow(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.random_id = $3`, string(peer.Type), peer.ID, randomID) + story, err := scanPGStory(row, peer.ID) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Story{}, false, nil + } + if err != nil { + return domain.Story{}, false, fmt.Errorf("load story by random id: %w", err) + } + return story, true, nil +} + +func (s *StoryStore) getOwnerStory(ctx context.Context, peer domain.Peer, storyID int) (domain.Story, error) { + row := s.db.QueryRow(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE s.owner_peer_type = $1 + AND s.owner_peer_id = $2 + AND s.story_id = $3 + AND s.deleted = false`, string(peer.Type), peer.ID, int32(storyID)) + story, err := scanPGStory(row, peer.ID) + if errors.Is(err, pgx.ErrNoRows) { + return domain.Story{}, domain.ErrStoryNotFound + } + if err != nil { + return domain.Story{}, fmt.Errorf("get owner story: %w", err) + } + return story, nil +} + +func (s *StoryStore) getReadState(ctx context.Context, viewerUserID int64, peer domain.Peer) (domain.StoryReadState, error) { + if viewerUserID == 0 { + return domain.StoryReadState{ViewerID: viewerUserID, Peer: peer}, nil + } + var state domain.StoryReadState + var peerType string + err := s.db.QueryRow(ctx, ` +SELECT viewer_user_id, owner_peer_type, owner_peer_id, max_read_id, date +FROM story_read_states +WHERE viewer_user_id = $1 + AND owner_peer_type = $2 + AND owner_peer_id = $3`, viewerUserID, string(peer.Type), peer.ID).Scan( + &state.ViewerID, &peerType, &state.Peer.ID, &state.MaxReadID, &state.Date, + ) + if errors.Is(err, pgx.ErrNoRows) { + return domain.StoryReadState{ViewerID: viewerUserID, Peer: peer}, nil + } + if err != nil { + return domain.StoryReadState{}, fmt.Errorf("get story read state: %w", err) + } + state.Peer.Type = domain.PeerType(peerType) + return state, nil +} + +func (s *StoryStore) populateStoryViewState(ctx context.Context, viewerUserID int64, stories []domain.Story) error { + if len(stories) == 0 { + return nil + } + peerTypes := make([]string, 0, len(stories)) + peerIDs := make([]int64, 0, len(stories)) + storyIDs := make([]int32, 0, len(stories)) + for _, story := range stories { + peerTypes = append(peerTypes, string(story.Owner.Type)) + peerIDs = append(peerIDs, story.Owner.ID) + storyIDs = append(storyIDs, int32(story.ID)) + } + rows, err := s.db.Query(ctx, ` +WITH input AS ( + SELECT p.peer_type, i.peer_id, sid.story_id, p.ord + FROM unnest($1::text[]) WITH ORDINALITY AS p(peer_type, ord) + JOIN unnest($2::bigint[]) WITH ORDINALITY AS i(peer_id, ord) USING (ord) + JOIN unnest($3::int[]) WITH ORDINALITY AS sid(story_id, ord) USING (ord) +) +SELECT input.ord, v.viewer_user_id, v.date, COALESCE(v.reaction::text, '{}')::text +FROM input +JOIN story_views v + ON v.owner_peer_type = input.peer_type + AND v.owner_peer_id = input.peer_id + AND v.story_id = input.story_id +ORDER BY input.ord ASC, v.date DESC, v.viewer_user_id DESC`, peerTypes, peerIDs, storyIDs) + if err != nil { + return fmt.Errorf("load story view state: %w", err) + } + defer rows.Close() + for i := range stories { + stories[i].Views = domain.StoryViews{} + stories[i].SentReaction = nil + } + for rows.Next() { + var ord int + var viewerID int64 + var date int + var reactionRaw string + if err := rows.Scan(&ord, &viewerID, &date, &reactionRaw); err != nil { + return fmt.Errorf("scan story view state: %w", err) + } + _ = date + idx := ord - 1 + if idx < 0 || idx >= len(stories) { + continue + } + view := &stories[idx].Views + view.ViewsCount++ + view.HasViewers = true + if len(view.RecentViewers) < 3 { + view.RecentViewers = append(view.RecentViewers, viewerID) + } + reaction, err := decodeStoryReaction(reactionRaw) + if err != nil { + return fmt.Errorf("decode story view reaction: %w", err) + } + if reaction != nil { + view.ReactionsCount++ + addPGStoryReactionCount(view, *reaction) + } + if viewerID == viewerUserID { + stories[idx].SentReaction = reaction + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("scan story view states: %w", err) + } + repostRows, err := s.db.Query(ctx, ` +WITH input AS ( + SELECT p.peer_type, i.peer_id, sid.story_id, p.ord + FROM unnest($1::text[]) WITH ORDINALITY AS p(peer_type, ord) + JOIN unnest($2::bigint[]) WITH ORDINALITY AS i(peer_id, ord) USING (ord) + JOIN unnest($3::int[]) WITH ORDINALITY AS sid(story_id, ord) USING (ord) +) +SELECT input.ord, COUNT(r.story_id)::int +FROM input +LEFT JOIN stories r + ON r.deleted = false + AND r.public = true + AND `+storyForwardSourceTypeSQL("r")+` = input.peer_type + AND `+storyForwardSourceIDSQL("r")+` = input.peer_id + AND (r.fwd_from->>'StoryID')::int = input.story_id + AND `+storyPublicRepostVisiblePredicateFor("r", "$4")+` +GROUP BY input.ord +ORDER BY input.ord ASC`, peerTypes, peerIDs, storyIDs, viewerUserID) + if err != nil { + return fmt.Errorf("load story forward state: %w", err) + } + defer repostRows.Close() + for repostRows.Next() { + var ord, count int + if err := repostRows.Scan(&ord, &count); err != nil { + return fmt.Errorf("scan story forward state: %w", err) + } + idx := ord - 1 + if idx < 0 || idx >= len(stories) { + continue + } + stories[idx].Views.ForwardsCount = count + } + if err := repostRows.Err(); err != nil { + return fmt.Errorf("scan story forward states: %w", err) + } + return nil +} + +func (s *StoryStore) storyViewCounts(ctx context.Context, peer domain.Peer, storyID int) (viewsCount, reactionsCount int, err error) { + err = s.db.QueryRow(ctx, ` +SELECT + (count(*))::int, + (count(*) FILTER (WHERE reaction <> '{}'::jsonb AND reaction <> 'null'::jsonb))::int +FROM story_views +WHERE owner_peer_type = $1 + AND owner_peer_id = $2 + AND story_id = $3`, string(peer.Type), peer.ID, int32(storyID)).Scan(&viewsCount, &reactionsCount) + if err != nil { + return 0, 0, fmt.Errorf("count story views: %w", err) + } + return viewsCount, reactionsCount, nil +} + +func (s *StoryStore) storyForwardCount(ctx context.Context, peer domain.Peer, storyID int, viewerUserID int64) (int, error) { + var count int + if err := s.db.QueryRow(ctx, ` +SELECT COUNT(*)::int +FROM stories r +WHERE r.deleted = false + AND r.public = true + AND `+storyForwardSourceTypeSQL("r")+` = $1 + AND `+storyForwardSourceIDSQL("r")+` = $2 + AND (r.fwd_from->>'StoryID')::int = $3 + AND `+storyPublicRepostVisiblePredicateFor("r", "$4"), + string(peer.Type), peer.ID, int32(storyID), viewerUserID).Scan(&count); err != nil { + return 0, fmt.Errorf("count story forwards: %w", err) + } + return count, nil +} + +func (s *StoryStore) listStoryRepostViews(ctx context.Context, req domain.StoryViewListRequest, limit int, cursor storyInteractionCursor) ([]domain.StoryView, error) { + limit = clampPGStoryProbeLimit(limit) + group := pgStoryViewSortGroup(domain.StoryView{Repost: &domain.Story{}}, req.ReactionsFirst, req.ForwardsFirst) + args := []any{string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), req.ViewerUserID, int32(limit)} + cursorClause := "" + if cursor.set { + args = append(args, int32(group), int32(cursor.group), int32(cursor.date), cursor.viewerID, int32(cursor.messageID)) + cursorClause = fmt.Sprintf(` + AND ( + $6::int > $7::int + OR ( + $6::int = $7::int + AND ( + s.date < $8::int + OR ( + s.date = $8::int + AND ( + (CASE WHEN s.owner_peer_type = 'channel' THEN -s.owner_peer_id ELSE s.owner_peer_id END) < $9::bigint + OR ( + (CASE WHEN s.owner_peer_type = 'channel' THEN -s.owner_peer_id ELSE s.owner_peer_id END) = $9::bigint + AND 0 < $10::int + ) + ) + ) + ) + ) + )`) + } + rows, err := s.db.Query(ctx, ` +SELECT `+storySelectColumns+` +FROM stories s +WHERE s.deleted = false + AND s.public = true + AND `+storyForwardSourceTypeSQL("s")+` = $1 + AND `+storyForwardSourceIDSQL("s")+` = $2 + AND (s.fwd_from->>'StoryID')::int = $3 + AND `+storyPublicRepostVisiblePredicateFor("s", "$4")+cursorClause+` +ORDER BY s.date DESC, (CASE WHEN s.owner_peer_type = 'channel' THEN -s.owner_peer_id ELSE s.owner_peer_id END) DESC +LIMIT $5`, args...) + if err != nil { + return nil, fmt.Errorf("list story repost views: %w", err) + } + reposts, err := scanPGStories(rows, req.ViewerUserID) + if err != nil { + return nil, err + } + out := make([]domain.StoryView, 0, len(reposts)) + for _, repost := range reposts { + item := clonePGStory(repost) + out = append(out, domain.StoryView{ + Owner: req.Owner, + StoryID: req.StoryID, + ViewerID: pgStoryPeerCursorKey(item.Owner), + Date: item.Date, + Repost: &item, + }) + } + return out, nil +} + +func scanPGStoryViews(rows pgx.Rows, owner domain.Peer, storyID int) ([]domain.StoryView, error) { + defer rows.Close() + out := make([]domain.StoryView, 0) + for rows.Next() { + var view domain.StoryView + var reactionRaw string + if err := rows.Scan( + &view.ViewerID, + &view.Date, + &reactionRaw, + &view.Blocked, + &view.BlockedMyStoriesFrom, + ); err != nil { + return nil, fmt.Errorf("scan story view: %w", err) + } + reaction, err := decodeStoryReaction(reactionRaw) + if err != nil { + return nil, fmt.Errorf("decode story list reaction: %w", err) + } + view.Owner = owner + view.StoryID = storyID + view.Reaction = reaction + out = append(out, view) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan story views: %w", err) + } + return out, nil +} + +func scanPGStories(rows pgx.Rows, viewerUserID int64) ([]domain.Story, error) { + defer rows.Close() + stories := make([]domain.Story, 0) + for rows.Next() { + story, err := scanPGStory(rows, viewerUserID) + if err != nil { + return nil, fmt.Errorf("scan story: %w", err) + } + stories = append(stories, story) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("scan stories: %w", err) + } + return stories, nil +} + +func scanPGStory(row rowScanner, viewerUserID int64) (domain.Story, error) { + var story domain.Story + var peerType string + var privacyRulesJSON, entitiesJSON, mediaJSON, mediaAreasJSON, forwardJSON string + if err := row.Scan( + &peerType, + &story.Owner.ID, + &story.ID, + &story.RandomID, + &story.Date, + &story.ExpireDate, + &story.Deleted, + &story.Pinned, + &story.PinnedToTopOrder, + &story.Public, + &story.CloseFriends, + &story.Contacts, + &story.SelectedContacts, + &story.NoForwards, + &story.Edited, + &privacyRulesJSON, + &story.AllowUserIDs, + &story.DisallowUserIDs, + &story.Caption, + &entitiesJSON, + &mediaJSON, + &mediaAreasJSON, + &forwardJSON, + ); err != nil { + return domain.Story{}, err + } + story.Owner.Type = domain.PeerType(peerType) + privacyRules, err := decodePrivacyRulesJSON(privacyRulesJSON) + if err != nil { + return domain.Story{}, fmt.Errorf("decode story privacy rules: %w", err) + } + story.PrivacyRules = privacyRules + entities, err := decodeMessageEntities(entitiesJSON) + if err != nil { + return domain.Story{}, fmt.Errorf("decode story entities: %w", err) + } + story.Entities = entities + media, err := decodeMessageMedia(mediaJSON) + if err != nil { + return domain.Story{}, fmt.Errorf("decode story media: %w", err) + } + story.Media = media + mediaAreas, err := decodeStoryMediaAreas(mediaAreasJSON) + if err != nil { + return domain.Story{}, fmt.Errorf("decode story media areas: %w", err) + } + story.MediaAreas = mediaAreas + forward, err := decodeStoryForward(forwardJSON) + if err != nil { + return domain.Story{}, fmt.Errorf("decode story forward: %w", err) + } + story.Forward = forward + story.Out = story.Owner.Type == domain.PeerTypeUser && story.Owner.ID == viewerUserID + return story, nil +} + +func groupPGPeerStories(stories []domain.Story, reads []domain.StoryReadState) []domain.PeerStories { + readByPeer := make(map[domain.Peer]int, len(reads)) + for _, read := range reads { + readByPeer[read.Peer] = read.MaxReadID + } + index := make(map[domain.Peer]int) + out := make([]domain.PeerStories, 0) + for _, story := range stories { + i, ok := index[story.Owner] + if !ok { + i = len(out) + index[story.Owner] = i + out = append(out, domain.PeerStories{Peer: story.Owner, MaxReadID: readByPeer[story.Owner]}) + } + out[i].Stories = append(out[i].Stories, clonePGStory(story)) + } + for i := range out { + sort.Slice(out[i].Stories, func(a, b int) bool { + if out[i].Stories[a].ID != out[i].Stories[b].ID { + return out[i].Stories[a].ID < out[i].Stories[b].ID + } + return out[i].Stories[a].Date < out[i].Stories[b].Date + }) + } + return out +} + +func validatePGStoryIdentity(peer domain.Peer, id int) error { + if err := validatePGStoryPeer(peer); err != nil { + return err + } + if id <= 0 || id > domain.MaxStoryID { + return domain.ErrStoryIDInvalid + } + return nil +} + +func validatePGStoryPeer(peer domain.Peer) error { + switch peer.Type { + case domain.PeerTypeUser, domain.PeerTypeChannel: + if peer.ID > 0 { + return nil + } + } + return domain.ErrStoryPeerInvalid +} + +func validatePGStoryIDs(ids []int) error { + if len(ids) > domain.MaxStoryIDs { + return domain.ErrStoryIDInvalid + } + for _, id := range ids { + if id <= 0 || id > domain.MaxStoryID { + return domain.ErrStoryIDInvalid + } + } + return nil +} + +func validatePGStoryIDsNonEmpty(ids []int) error { + if len(ids) == 0 { + return domain.ErrStoryIDInvalid + } + return validatePGStoryIDs(ids) +} + +func normalizePGStoryIDsNonEmpty(ids []int) ([]int, error) { + if err := validatePGStoryIDsNonEmpty(ids); err != nil { + return nil, err + } + return normalizePGStoryIDsUnchecked(ids), nil +} + +func normalizePGStoryIDs(ids []int) ([]int, error) { + if err := validatePGStoryIDs(ids); err != nil { + return nil, err + } + return normalizePGStoryIDsUnchecked(ids), nil +} + +func normalizePGStoryPinnedToTopIDs(ids []int) ([]int, error) { + ids, err := normalizePGStoryIDs(ids) + if err != nil { + return nil, err + } + if len(ids) > domain.MaxStoryPinnedToTop { + return nil, domain.ErrStoryIDInvalid + } + return ids, nil +} + +func normalizePGStoryIDsUnchecked(ids []int) []int { + seen := make(map[int]struct{}, len(ids)) + out := make([]int, 0, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func clampPGStoryLimit(limit int) int { + if limit <= 0 || limit > domain.MaxStoryListLimit { + return domain.MaxStoryListLimit + } + return limit +} + +func clampPGStoryProbeLimit(limit int) int { + max := domain.MaxStoryListLimit + 1 + if limit <= 0 || limit > max { + return max + } + return limit +} + +func clampPGStoryInteractionLimit(limit int) int { + if limit <= 0 || limit > domain.MaxStoryInteractionListLimit { + return domain.MaxStoryInteractionListLimit + } + return limit +} + +func clampPGStoryPrivacyFanoutLimit(limit int) int { + if limit <= 0 || limit > domain.MaxStoryPrivacyFanoutTargets { + return domain.MaxStoryPrivacyFanoutTargets + } + return limit +} + +type storyInteractionCursor struct { + set bool + group int + date int + viewerID int64 + messageID int +} + +func parsePGStoryInteractionCursor(offset string) storyInteractionCursor { + if offset == "" { + return storyInteractionCursor{} + } + parts := strings.Split(offset, ":") + if len(parts) != 3 && len(parts) != 4 { + return storyInteractionCursor{} + } + group, err1 := strconv.Atoi(parts[0]) + date, err2 := strconv.Atoi(parts[1]) + viewerID, err3 := strconv.ParseInt(parts[2], 10, 64) + var messageID int + var err4 error + if len(parts) == 4 { + messageID, err4 = strconv.Atoi(parts[3]) + } + if err1 != nil || err2 != nil || err3 != nil || err4 != nil || group < 0 || viewerID == 0 || messageID < 0 { + return storyInteractionCursor{} + } + return storyInteractionCursor{set: true, group: group, date: date, viewerID: viewerID, messageID: messageID} +} + +func formatPGStoryInteractionCursor(view domain.StoryView, reactionsFirst, forwardsFirst bool) string { + group := pgStoryViewSortGroup(view, reactionsFirst, forwardsFirst) + out := strconv.Itoa(group) + ":" + strconv.Itoa(view.Date) + ":" + strconv.FormatInt(pgStoryViewCursorKey(view), 10) + if id := pgStoryViewCursorMessageID(view); id > 0 { + out += ":" + strconv.Itoa(id) + } + return out +} + +func pgStoryViewSortGroup(view domain.StoryView, reactionsFirst, forwardsFirst bool) int { + if forwardsFirst { + if view.Repost != nil || view.PublicForward != nil { + return 0 + } + return 1 + } + if reactionsFirst && view.Reaction == nil && view.Repost == nil && view.PublicForward == nil { + return 1 + } + return 0 +} + +func pgStoryViewCursorKey(view domain.StoryView) int64 { + if view.PublicForward != nil { + return pgStoryPeerCursorKey(domain.Peer{Type: domain.PeerTypeChannel, ID: view.PublicForward.Message.ChannelID}) + } + if view.Repost != nil { + return pgStoryPeerCursorKey(view.Repost.Owner) + } + return view.ViewerID +} + +func pgStoryViewCursorMessageID(view domain.StoryView) int { + if view.PublicForward != nil { + return view.PublicForward.Message.ID + } + return 0 +} + +func pgStoryPeerCursorKey(peer domain.Peer) int64 { + if peer.Type == domain.PeerTypeChannel { + return -peer.ID + } + return peer.ID +} + +func sortPGStoryViewsForList(views []domain.StoryView, reactionsFirst, forwardsFirst bool) { + sort.Slice(views, func(i, j int) bool { + gi := pgStoryViewSortGroup(views[i], reactionsFirst, forwardsFirst) + gj := pgStoryViewSortGroup(views[j], reactionsFirst, forwardsFirst) + if gi != gj { + return gi < gj + } + if views[i].Date != views[j].Date { + return views[i].Date > views[j].Date + } + if pgStoryViewCursorKey(views[i]) != pgStoryViewCursorKey(views[j]) { + return pgStoryViewCursorKey(views[i]) > pgStoryViewCursorKey(views[j]) + } + return pgStoryViewCursorMessageID(views[i]) > pgStoryViewCursorMessageID(views[j]) + }) +} + +func encodeStoryReaction(reaction *domain.MessageReaction) ([]byte, error) { + if reaction == nil { + return []byte("{}"), nil + } + return json.Marshal(reaction) +} + +func decodeStoryReaction(raw string) (*domain.MessageReaction, error) { + if raw == "" || raw == "{}" || raw == "null" { + return nil, nil + } + var reaction domain.MessageReaction + if err := json.Unmarshal([]byte(raw), &reaction); err != nil { + return nil, err + } + if reaction.Type == "" { + return nil, nil + } + return &reaction, nil +} + +func encodePrivacyRules(rules []domain.PrivacyRule) ([]byte, error) { + if len(rules) == 0 { + return []byte("[]"), nil + } + return json.Marshal(rules) +} + +func encodeStoryMediaAreas(areas []domain.StoryMediaArea) ([]byte, error) { + if len(areas) == 0 { + return []byte("[]"), nil + } + raw, err := json.Marshal(areas) + if err != nil { + return nil, err + } + return raw, nil +} + +func decodeStoryMediaAreas(raw string) ([]domain.StoryMediaArea, error) { + if raw == "" || raw == "[]" || raw == "null" { + return nil, nil + } + var areas []domain.StoryMediaArea + if err := json.Unmarshal([]byte(raw), &areas); err != nil { + return nil, err + } + return clonePGStoryMediaAreas(areas), nil +} + +func encodeStoryForward(forward *domain.StoryForward) ([]byte, error) { + if forward == nil { + return []byte("{}"), nil + } + raw, err := json.Marshal(forward) + if err != nil { + return nil, err + } + return raw, nil +} + +func decodeStoryForward(raw string) (*domain.StoryForward, error) { + if raw == "" || raw == "{}" || raw == "null" { + return nil, nil + } + var forward domain.StoryForward + if err := json.Unmarshal([]byte(raw), &forward); err != nil { + return nil, err + } + if forward.From.Type == "" && forward.FromName == "" && forward.StoryID == 0 { + return nil, nil + } + return clonePGStoryForward(&forward), nil +} + +func addPGStoryReactionCount(views *domain.StoryViews, reaction domain.MessageReaction) { + for i := range views.Reactions { + if views.Reactions[i].Reaction == reaction { + views.Reactions[i].Count++ + return + } + } + views.Reactions = append(views.Reactions, domain.ChannelMessageReactionCount{ + Reaction: reaction, + Count: 1, + ChosenOrder: len(views.Reactions), + }) +} + +func samePGReaction(a, b *domain.MessageReaction) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +func clonePGStory(story domain.Story) domain.Story { + story.PrivacyRules = clonePGPrivacyRules(story.PrivacyRules) + story.AllowUserIDs = append([]int64(nil), story.AllowUserIDs...) + story.DisallowUserIDs = append([]int64(nil), story.DisallowUserIDs...) + story.Entities = append([]domain.MessageEntity(nil), story.Entities...) + story.MediaAreas = clonePGStoryMediaAreas(story.MediaAreas) + story.Forward = clonePGStoryForward(story.Forward) + story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...) + story.Views.RecentViewers = append([]int64(nil), story.Views.RecentViewers...) + story.SentReaction = clonePGReactionPtr(story.SentReaction) + return story +} + +func clonePGStoryForward(in *domain.StoryForward) *domain.StoryForward { + if in == nil { + return nil + } + out := *in + return &out +} + +func clonePGStoryMediaAreas(in []domain.StoryMediaArea) []domain.StoryMediaArea { + if len(in) == 0 { + return nil + } + out := make([]domain.StoryMediaArea, len(in)) + for i, area := range in { + out[i] = area + out[i].Reaction = clonePGReactionPtr(area.Reaction) + if area.Geo != nil { + geo := *area.Geo + out[i].Geo = &geo + } + if area.GeoAddress != nil { + address := *area.GeoAddress + out[i].GeoAddress = &address + } + if area.Venue != nil { + venue := *area.Venue + out[i].Venue = &venue + } + } + return out +} + +func fanoutPGStorySnapshot(story domain.Story) domain.Story { + story = clonePGStory(story) + story.Out = false + story.Views = domain.StoryViews{} + story.SentReaction = nil + return story +} + +func clonePGPrivacyRules(in []domain.PrivacyRule) []domain.PrivacyRule { + if len(in) == 0 { + return nil + } + out := make([]domain.PrivacyRule, len(in)) + for i, rule := range in { + out[i] = rule + out[i].UserIDs = append([]int64(nil), rule.UserIDs...) + out[i].ChatIDs = append([]int64(nil), rule.ChatIDs...) + } + return out +} + +func clonePGReactionPtr(in *domain.MessageReaction) *domain.MessageReaction { + if in == nil { + return nil + } + out := *in + return &out +} diff --git a/internal/store/postgres/story_integration_test.go b/internal/store/postgres/story_integration_test.go new file mode 100644 index 00000000..47a4e852 --- /dev/null +++ b/internal/store/postgres/story_integration_test.go @@ -0,0 +1,2349 @@ +package postgres + +import ( + "context" + "errors" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "telesrv/internal/domain" + storepkg "telesrv/internal/store" +) + +func TestStoryStoreReadMaxHiddenAndPeerMaxIDsPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, viewer := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + + for _, id := range []int{1, 2} { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: id, + Date: 1700000100 + id, + ExpireDate: 1700001000, + Public: true, + Caption: "pg story", + }}); err != nil { + t.Fatalf("upsert story %d: %v", id, err) + } + } + + read, err := store.MarkRead(ctx, viewer.ID, ownerPeer, 2, 1700000200) + if err != nil { + t.Fatalf("mark read: %v", err) + } + if !read.Advanced || read.MaxReadID != 2 { + t.Fatalf("read = %+v, want advanced max 2", read) + } + read, err = store.MarkRead(ctx, viewer.ID, ownerPeer, 1, 1700000201) + if err != nil { + t.Fatalf("mark lower read: %v", err) + } + if read.Advanced || read.MaxReadID != 2 { + t.Fatalf("lower read = %+v, want unchanged max 2", read) + } + + peerStories, err := store.GetPeerStories(ctx, viewer.ID, ownerPeer, 1700000202) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + if peerStories.MaxReadID != 2 || len(peerStories.Stories) != 2 { + t.Fatalf("peer stories = %+v, want max read 2 and two stories", peerStories) + } + recent, err := store.GetPeerMaxIDs(ctx, viewer.ID, []domain.Peer{ownerPeer}, 1700000202) + if err != nil { + t.Fatalf("peer max ids: %v", err) + } + if len(recent) != 1 || recent[0].MaxID != 2 { + t.Fatalf("recent = %+v, want max id 2", recent) + } + + list, err := store.ListActiveStories(ctx, viewer.ID, false, 1700000202, 100) + if err != nil { + t.Fatalf("list active stories: %v", err) + } + if list.Count != 1 || len(list.Peers) != 1 || len(list.Stories) != 2 { + t.Fatalf("active list = %+v, want one peer with two stories", list) + } + ownerActive, err := store.ListOwnerActiveStories(ctx, ownerPeer, 1700000202, 100) + if err != nil { + t.Fatalf("list owner active stories: %v", err) + } + if len(ownerActive.Stories) != 2 { + t.Fatalf("owner active stories = %+v, want two stories", ownerActive.Stories) + } + for _, story := range ownerActive.Stories { + if story.Out || story.Views.HasViewers || story.SentReaction != nil { + t.Fatalf("owner active story fanout snapshot = %+v, want no out/views/reaction", story) + } + } + if err := store.SetPeerHidden(ctx, viewer.ID, ownerPeer, true); err != nil { + t.Fatalf("set hidden: %v", err) + } + hiddenStates, err := store.GetPeerHiddenStates(ctx, viewer.ID, []domain.Peer{ownerPeer}) + if err != nil { + t.Fatalf("get hidden states: %v", err) + } + if !hiddenStates[ownerPeer] { + t.Fatalf("hidden states = %+v, want owner hidden", hiddenStates) + } + projections, err := store.GetPeerStoryProjections(ctx, viewer.ID, []domain.Peer{ownerPeer}, 1700000202) + if err != nil { + t.Fatalf("get story peer projections: %v", err) + } + if len(projections) != 1 || projections[0].Peer != ownerPeer || projections[0].Recent.MaxID != 2 || !projections[0].Hidden { + t.Fatalf("story peer projections = %+v, want max id 2 hidden owner", projections) + } + list, err = store.ListActiveStories(ctx, viewer.ID, false, 1700000202, 100) + if err != nil { + t.Fatalf("list visible after hidden: %v", err) + } + if list.Count != 0 || len(list.Stories) != 0 { + t.Fatalf("visible list after hidden = %+v, want empty", list) + } + list, err = store.ListActiveStories(ctx, viewer.ID, true, 1700000202, 100) + if err != nil { + t.Fatalf("list hidden stories: %v", err) + } + if list.Count != 1 || len(list.Stories) != 2 { + t.Fatalf("hidden list = %+v, want hidden peer stories", list) + } + if err := store.SetPeerHidden(ctx, viewer.ID, ownerPeer, false); err != nil { + t.Fatalf("clear hidden: %v", err) + } + hiddenStates, err = store.GetPeerHiddenStates(ctx, viewer.ID, []domain.Peer{ownerPeer}) + if err != nil { + t.Fatalf("get hidden states after clear: %v", err) + } + if hiddenStates[ownerPeer] { + t.Fatalf("hidden states after clear = %+v, want owner visible", hiddenStates) + } +} + +func TestStoryStoreListActiveStoriesPaginatesByPeerPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + viewer, err := users.Create(ctx, domain.User{AccessHash: 9100, Phone: "+1881" + suffix + "00", FirstName: "StoryViewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + owners := make([]domain.User, 0, 3) + ownerIDs := make([]int64, 0, 3) + for i := 0; i < 3; i++ { + owner, err := users.Create(ctx, domain.User{AccessHash: int64(9101 + i), Phone: "+1881" + suffix + "0" + string(rune('1'+i)), FirstName: "StoryOwner"}) + if err != nil { + t.Fatalf("create owner %d: %v", i, err) + } + owners = append(owners, owner) + ownerIDs = append(ownerIDs, owner.ID) + } + t.Cleanup(func() { + cleanupStoryPagingTestRows(t, context.Background(), pool, viewer.ID, ownerIDs) + }) + store := NewStoryStore(pool) + for i, owner := range owners { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}, + ID: 1, + Date: 1700000300 - i*100, + ExpireDate: 1700001000, + Public: true, + }}); err != nil { + t.Fatalf("upsert owner %d story: %v", owner.ID, err) + } + } + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: domain.Peer{Type: domain.PeerTypeUser, ID: owners[0].ID}, + ID: 2, + Date: 1700000250, + ExpireDate: 1700001000, + Public: true, + }}); err != nil { + t.Fatalf("upsert second story for first owner: %v", err) + } + + first, err := store.ListActiveStoriesPage(ctx, viewer.ID, false, 1700000400, domain.StoryListCursor{}, 2) + if err != nil { + t.Fatalf("first page: %v", err) + } + if first.Count != 3 || !first.HasMore || len(first.Peers) != 2 { + t.Fatalf("first page = count %d more %v peers %d, want count 3 more true peers 2", first.Count, first.HasMore, len(first.Peers)) + } + if first.Peers[0].Peer.ID != owners[0].ID || len(first.Peers[0].Stories) != 2 { + t.Fatalf("first peer = %+v stories %d, want first owner with two stories", first.Peers[0].Peer, len(first.Peers[0].Stories)) + } + + next, err := store.ListActiveStoriesPage(ctx, viewer.ID, false, 1700000400, domain.StoryListCursor{ + Set: true, + Date: 1700000200, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: owners[1].ID}, + }, 2) + if err != nil { + t.Fatalf("next page: %v", err) + } + if next.Count != 3 || next.HasMore || len(next.Peers) != 1 || next.Peers[0].Peer.ID != owners[2].ID { + t.Fatalf("next page = %+v, want final page with third owner", next) + } + digest, err := store.ActiveStoriesDigest(ctx, viewer.ID, false, 1700000400) + if err != nil { + t.Fatalf("digest active stories: %v", err) + } + if digest.Count != 3 { + t.Fatalf("digest count = %d, want 3", digest.Count) + } + if _, err := store.MarkRead(ctx, viewer.ID, domain.Peer{Type: domain.PeerTypeUser, ID: owners[0].ID}, 2, 1700000401); err != nil { + t.Fatalf("mark read for digest: %v", err) + } + changed, err := store.ActiveStoriesDigest(ctx, viewer.ID, false, 1700000400) + if err != nil { + t.Fatalf("changed digest active stories: %v", err) + } + if changed.Hash == digest.Hash { + t.Fatalf("digest hash unchanged after read boundary: %#x", changed.Hash) + } +} + +func TestStoryStoreSelfUserViewAndReactionDoNotPolluteOwnerInteractionsPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, _ := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 1, + Date: 1700000100, + ExpireDate: 1700001000, + Public: true, + Caption: "pg self view", + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + + created, err := store.IncrementViews(ctx, owner.ID, ownerPeer, []int{1}, 1700000200) + if err != nil { + t.Fatalf("increment self view: %v", err) + } + if created != 0 { + t.Fatalf("created self views = %d, want 0", created) + } + if _, err := store.SetReaction(ctx, owner.ID, ownerPeer, 1, &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "🔥", + }, 1700000201); err != domain.ErrStoryPeerInvalid { + t.Fatalf("self reaction err = %v, want ErrStoryPeerInvalid", err) + } + list, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story views: %v", err) + } + if list.Count != 0 || list.ViewsCount != 0 || list.ReactionsCount != 0 || len(list.Views) != 0 { + t.Fatalf("self view list = %+v, want empty counters", list) + } +} + +func TestStoryStoreBlocklistHidesOwnerStoriesFromViewerPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, blockedViewer := createStoryTestUsers(t, ctx, pool) + users := NewUserStore(pool) + otherViewer, err := users.Create(ctx, domain.User{AccessHash: 8103, Phone: "+1771999903" + randomSuffix(t), FirstName: "StoryOtherViewer"}) + if err != nil { + t.Fatalf("create other viewer: %v", err) + } + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, otherViewer.ID); err != nil { + t.Fatalf("cleanup other viewer: %v", err) + } + }) + + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 1, + Date: 1700000100, + ExpireDate: 1700001000, + Public: true, + Caption: "pg blocked story", + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + if created, err := store.IncrementViews(ctx, blockedViewer.ID, ownerPeer, []int{1}, 1700000105); err != nil || created != 1 { + t.Fatalf("pre-block increment = %d, %v, want 1 nil", created, err) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO contact_blocks (owner_user_id, blocked_user_id, date) +VALUES ($1, $2, $3) +ON CONFLICT (owner_user_id, blocked_user_id) DO UPDATE SET date = EXCLUDED.date`, + owner.ID, blockedViewer.ID, int32(1700000200)); err != nil { + t.Fatalf("insert contact block: %v", err) + } + + peerStories, err := store.GetPeerStories(ctx, blockedViewer.ID, ownerPeer, 1700000201) + if err != nil { + t.Fatalf("get blocked peer stories: %v", err) + } + if len(peerStories.Stories) != 0 { + t.Fatalf("blocked peer stories = %+v, want empty", peerStories.Stories) + } + exact, err := store.GetStoriesByID(ctx, blockedViewer.ID, ownerPeer, []int{1}, 1700000201) + if err != nil { + t.Fatalf("get blocked stories by id: %v", err) + } + if len(exact.Stories) != 0 { + t.Fatalf("blocked exact stories = %+v, want empty", exact.Stories) + } + recent, err := store.GetPeerMaxIDs(ctx, blockedViewer.ID, []domain.Peer{ownerPeer}, 1700000201) + if err != nil { + t.Fatalf("get blocked peer max ids: %v", err) + } + if len(recent) != 1 || recent[0].MaxID != 0 { + t.Fatalf("blocked recent = %+v, want max id 0", recent) + } + active, err := store.ListActiveStories(ctx, blockedViewer.ID, false, 1700000201, 100) + if err != nil { + t.Fatalf("list blocked active stories: %v", err) + } + if active.Count != 0 || len(active.Stories) != 0 { + t.Fatalf("blocked active stories = %+v, want empty", active) + } + if created, err := store.IncrementViews(ctx, blockedViewer.ID, ownerPeer, []int{1}, 1700000210); err != nil || created != 0 { + t.Fatalf("blocked increment = %d, %v, want 0 nil", created, err) + } + if _, err := store.SetReaction(ctx, blockedViewer.ID, ownerPeer, 1, &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "🔥", + }, 1700000211); err != domain.ErrStoryNotFound { + t.Fatalf("blocked reaction err = %v, want ErrStoryNotFound", err) + } + + ownerStories, err := store.GetPeerStories(ctx, owner.ID, ownerPeer, 1700000201) + if err != nil { + t.Fatalf("owner get peer stories: %v", err) + } + if len(ownerStories.Stories) != 1 { + t.Fatalf("owner peer stories = %+v, want one story", ownerStories.Stories) + } + otherStories, err := store.GetPeerStories(ctx, otherViewer.ID, ownerPeer, 1700000201) + if err != nil { + t.Fatalf("other get peer stories: %v", err) + } + if len(otherStories.Stories) != 1 { + t.Fatalf("other peer stories = %+v, want one story", otherStories.Stories) + } + if created, err := store.IncrementViews(ctx, otherViewer.ID, ownerPeer, []int{1}, 1700000220); err != nil || created != 1 { + t.Fatalf("other increment = %d, %v, want 1 nil", created, err) + } + list, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story views: %v", err) + } + got := map[int64]domain.StoryView{} + for _, view := range list.Views { + got[view.ViewerID] = view + } + if list.Count != 2 || len(list.Views) != 2 { + t.Fatalf("view list = %+v, want historical blocked viewer and other viewer", list) + } + if !got[blockedViewer.ID].BlockedMyStoriesFrom { + t.Fatalf("blocked viewer row = %+v, want blocked_my_stories_from", got[blockedViewer.ID]) + } + if got[otherViewer.ID].BlockedMyStoriesFrom { + t.Fatalf("other viewer row = %+v, want not blocked", got[otherViewer.ID]) + } +} + +func TestStoryStoreChannelStoriesRequireActiveMemberPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + users := NewUserStore(pool) + creator, err := users.Create(ctx, domain.User{AccessHash: 8111, Phone: "+1773" + suffix + "01", FirstName: "StoryChannelCreator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + member, err := users.Create(ctx, domain.User{AccessHash: 8112, Phone: "+1773" + suffix + "02", FirstName: "StoryChannelMember"}) + if err != nil { + t.Fatalf("create member: %v", err) + } + outsider, err := users.Create(ctx, domain.User{AccessHash: 8113, Phone: "+1773" + suffix + "03", FirstName: "StoryChannelOutsider"}) + if err != nil { + t.Fatalf("create outsider: %v", err) + } + + channels := NewChannelStore(pool) + createdChannel, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: creator.ID, + Title: "Story Gate " + suffix, + Broadcast: true, + MemberUserIDs: []int64{member.ID}, + Date: 1700000300, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channelPeer := domain.Peer{Type: domain.PeerTypeChannel, ID: createdChannel.Channel.ID} + userIDs := []int64{creator.ID, member.ID, outsider.ID} + t.Cleanup(func() { + cleanupChannelStoryTestRows(t, context.Background(), pool, channelPeer.ID, userIDs) + }) + + store := NewStoryStore(pool) + createdStory, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: channelPeer, + RandomID: 1700000301, + Date: 1700000301, + Period: 86400, + Public: true, + Caption: "member-only channel story", + }) + if err != nil { + t.Fatalf("create channel story: %v", err) + } + + creatorStories, err := store.GetPeerStories(ctx, creator.ID, channelPeer, 1700000310) + if err != nil { + t.Fatalf("creator get channel peer stories: %v", err) + } + if len(creatorStories.Stories) != 1 { + t.Fatalf("creator channel stories = %+v, want one story", creatorStories.Stories) + } + memberStories, err := store.GetPeerStories(ctx, member.ID, channelPeer, 1700000310) + if err != nil { + t.Fatalf("member get channel peer stories: %v", err) + } + if len(memberStories.Stories) != 1 { + t.Fatalf("member channel stories = %+v, want one story", memberStories.Stories) + } + memberActive, err := store.ListActiveStories(ctx, member.ID, false, 1700000310, 100) + if err != nil { + t.Fatalf("member list active stories: %v", err) + } + if !storyListContains(memberActive, channelPeer, createdStory.Story.ID) { + t.Fatalf("member active stories = %+v, want channel story %d", memberActive.Stories, createdStory.Story.ID) + } + memberProjection, err := store.GetPeerStoryProjections(ctx, member.ID, []domain.Peer{channelPeer}, 1700000310) + if err != nil { + t.Fatalf("member get projections: %v", err) + } + if len(memberProjection) != 1 || memberProjection[0].Recent.MaxID != createdStory.Story.ID { + t.Fatalf("member projection = %+v, want story max id %d", memberProjection, createdStory.Story.ID) + } + + outsiderStories, err := store.GetPeerStories(ctx, outsider.ID, channelPeer, 1700000310) + if err != nil { + t.Fatalf("outsider get channel peer stories: %v", err) + } + if len(outsiderStories.Stories) != 0 { + t.Fatalf("outsider channel stories = %+v, want empty", outsiderStories.Stories) + } + exact, err := store.GetStoriesByID(ctx, outsider.ID, channelPeer, []int{createdStory.Story.ID}, 1700000310) + if err != nil { + t.Fatalf("outsider get stories by id: %v", err) + } + if len(exact.Stories) != 0 { + t.Fatalf("outsider exact channel stories = %+v, want empty", exact.Stories) + } + recent, err := store.GetPeerMaxIDs(ctx, outsider.ID, []domain.Peer{channelPeer}, 1700000310) + if err != nil { + t.Fatalf("outsider get peer max ids: %v", err) + } + if len(recent) != 1 || recent[0].MaxID != 0 { + t.Fatalf("outsider recent = %+v, want max id 0", recent) + } + projections, err := store.GetPeerStoryProjections(ctx, outsider.ID, []domain.Peer{channelPeer}, 1700000310) + if err != nil { + t.Fatalf("outsider get projections: %v", err) + } + if len(projections) != 1 || projections[0].Recent.MaxID != 0 { + t.Fatalf("outsider projection = %+v, want max id 0", projections) + } + active, err := store.ListActiveStories(ctx, outsider.ID, false, 1700000310, 100) + if err != nil { + t.Fatalf("outsider list active stories: %v", err) + } + if storyListContains(active, channelPeer, createdStory.Story.ID) { + t.Fatalf("outsider active stories = %+v, want no channel story %d", active.Stories, createdStory.Story.ID) + } + if created, err := store.IncrementViews(ctx, outsider.ID, channelPeer, []int{createdStory.Story.ID}, 1700000311); err != nil || created != 0 { + t.Fatalf("outsider increment = %d, %v, want 0 nil", created, err) + } + if _, err := store.SetReaction(ctx, outsider.ID, channelPeer, createdStory.Story.ID, &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "👍", + }, 1700000312); !errors.Is(err, domain.ErrStoryNotFound) { + t.Fatalf("outsider reaction err = %v, want ErrStoryNotFound", err) + } + + if _, err := pool.Exec(ctx, ` +UPDATE channel_members +SET banned_rights = '{"ViewMessages":true}'::jsonb +WHERE channel_id = $1 AND user_id = $2`, channelPeer.ID, member.ID); err != nil { + t.Fatalf("ban member view messages: %v", err) + } + bannedStories, err := store.GetPeerStories(ctx, member.ID, channelPeer, 1700000313) + if err != nil { + t.Fatalf("banned member get channel peer stories: %v", err) + } + if len(bannedStories.Stories) != 0 { + t.Fatalf("banned member stories = %+v, want empty", bannedStories.Stories) + } + if _, err := pool.Exec(ctx, ` +UPDATE channel_members +SET banned_rights = '{}'::jsonb, + status = 'left', + left_at = $3 +WHERE channel_id = $1 AND user_id = $2`, channelPeer.ID, member.ID, 1700000314); err != nil { + t.Fatalf("mark member left: %v", err) + } + leftStories, err := store.GetPeerStories(ctx, member.ID, channelPeer, 1700000315) + if err != nil { + t.Fatalf("left member get channel peer stories: %v", err) + } + if len(leftStories.Stories) != 0 { + t.Fatalf("left member stories = %+v, want empty", leftStories.Stories) + } +} + +func TestStoryStoreMediaAreasRoundTripEditClearPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, _ := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + + created, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: ownerPeer, + RandomID: 1700000251, + Date: 1700000250, + Period: 86400, + Public: true, + MediaAreas: []domain.StoryMediaArea{testPGStoryMediaArea("🔥", 10)}, + }) + if err != nil { + t.Fatalf("create story: %v", err) + } + assertPGStoryMediaArea(t, created.Story, "🔥", 10) + + afterRestart := NewStoryStore(pool) + list, err := afterRestart.GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID}, 1700000260) + if err != nil { + t.Fatalf("get story after restart: %v", err) + } + if len(list.Stories) != 1 { + t.Fatalf("story after restart = %+v, want one story", list.Stories) + } + assertPGStoryMediaArea(t, list.Stories[0], "🔥", 10) + + edited, err := afterRestart.EditStory(ctx, domain.StoryEditRequest{ + Owner: ownerPeer, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testPGStoryURLMediaArea("https://example.com/story/link", 25)}, + }) + if err != nil { + t.Fatalf("edit story media areas: %v", err) + } + assertPGStoryURLMediaArea(t, edited.Story, "https://example.com/story/link", 25) + + reloaded, err := NewStoryStore(pool).GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID}, 1700000261) + if err != nil { + t.Fatalf("reload edited story: %v", err) + } + assertPGStoryURLMediaArea(t, reloaded.Stories[0], "https://example.com/story/link", 25) + + geoEdited, err := NewStoryStore(pool).EditStory(ctx, domain.StoryEditRequest{ + Owner: ownerPeer, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testPGStoryGeoPointMediaArea(31.2304, 121.4737, 35)}, + }) + if err != nil { + t.Fatalf("edit story geo media area: %v", err) + } + assertPGStoryGeoPointMediaArea(t, geoEdited.Story, 31.2304, 121.4737, 35) + + reloaded, err = NewStoryStore(pool).GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID}, 1700000262) + if err != nil { + t.Fatalf("reload geo edited story: %v", err) + } + assertPGStoryGeoPointMediaArea(t, reloaded.Stories[0], 31.2304, 121.4737, 35) + + venueEdited, err := NewStoryStore(pool).EditStory(ctx, domain.StoryEditRequest{ + Owner: ownerPeer, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testPGStoryVenueMediaArea("Inline Cafe", 31.231, 121.474, 40)}, + }) + if err != nil { + t.Fatalf("edit story venue media area: %v", err) + } + assertPGStoryVenueMediaArea(t, venueEdited.Story, "Inline Cafe", 31.231, 121.474, 40) + + reloaded, err = NewStoryStore(pool).GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID}, 1700000263) + if err != nil { + t.Fatalf("reload venue edited story: %v", err) + } + assertPGStoryVenueMediaArea(t, reloaded.Stories[0], "Inline Cafe", 31.231, 121.474, 40) + + weatherEdited, err := NewStoryStore(pool).EditStory(ctx, domain.StoryEditRequest{ + Owner: ownerPeer, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testPGStoryWeatherMediaArea("☀️", 22.5, 0x00cc6600, 45)}, + }) + if err != nil { + t.Fatalf("edit story weather media area: %v", err) + } + assertPGStoryWeatherMediaArea(t, weatherEdited.Story, "☀️", 22.5, 0x00cc6600, 45) + + reloaded, err = NewStoryStore(pool).GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID}, 1700000264) + if err != nil { + t.Fatalf("reload weather edited story: %v", err) + } + assertPGStoryWeatherMediaArea(t, reloaded.Stories[0], "☀️", 22.5, 0x00cc6600, 45) + + channelPostEdited, err := NewStoryStore(pool).EditStory(ctx, domain.StoryEditRequest{ + Owner: ownerPeer, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testPGStoryChannelPostMediaArea(777001, 42, 50)}, + }) + if err != nil { + t.Fatalf("edit story channel post media area: %v", err) + } + assertPGStoryChannelPostMediaArea(t, channelPostEdited.Story, 777001, 42, 50) + + reloaded, err = NewStoryStore(pool).GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID}, 1700000265) + if err != nil { + t.Fatalf("reload channel post edited story: %v", err) + } + assertPGStoryChannelPostMediaArea(t, reloaded.Stories[0], 777001, 42, 50) + + starGiftEdited, err := NewStoryStore(pool).EditStory(ctx, domain.StoryEditRequest{ + Owner: ownerPeer, + ID: created.Story.ID, + UpdateMediaAreas: true, + MediaAreas: []domain.StoryMediaArea{testPGStoryStarGiftMediaArea("Gift.Series_01-42", 55)}, + }) + if err != nil { + t.Fatalf("edit story star gift media area: %v", err) + } + assertPGStoryStarGiftMediaArea(t, starGiftEdited.Story, "Gift.Series_01-42", 55) + + reloaded, err = NewStoryStore(pool).GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID}, 1700000266) + if err != nil { + t.Fatalf("reload star gift edited story: %v", err) + } + assertPGStoryStarGiftMediaArea(t, reloaded.Stories[0], "Gift.Series_01-42", 55) + + cleared, err := NewStoryStore(pool).EditStory(ctx, domain.StoryEditRequest{ + Owner: ownerPeer, + ID: created.Story.ID, + UpdateMediaAreas: true, + }) + if err != nil { + t.Fatalf("clear story media areas: %v", err) + } + if len(cleared.Story.MediaAreas) != 0 { + t.Fatalf("cleared story media areas = %+v, want none", cleared.Story.MediaAreas) + } + reloaded, err = NewStoryStore(pool).GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID}, 1700000267) + if err != nil { + t.Fatalf("reload cleared story: %v", err) + } + if len(reloaded.Stories) != 1 || len(reloaded.Stories[0].MediaAreas) != 0 { + t.Fatalf("reloaded cleared story = %+v, want no media areas", reloaded.Stories) + } +} + +func TestStoryStoreArchiveCountAndSeekPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, _ := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + fixtures := []domain.Story{ + {Owner: ownerPeer, ID: 1, Date: 1700000271, ExpireDate: 1700000300, Public: true}, + {Owner: ownerPeer, ID: 2, Date: 1700000272, ExpireDate: 1700000500, Public: true}, + {Owner: ownerPeer, ID: 3, Date: 1700000273, ExpireDate: 1700000300, Public: true, Pinned: true}, + {Owner: ownerPeer, ID: 4, Date: 1700000274, ExpireDate: 1700000300, Public: true, Deleted: true}, + {Owner: ownerPeer, ID: 5, Date: 1700000275, ExpireDate: 1700000300, Public: true}, + } + for _, story := range fixtures { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story %d: %v", story.ID, err) + } + } + + countOnly, err := store.ListStoriesArchive(ctx, owner.ID, ownerPeer, 0, 0, 1700000400) + if err != nil { + t.Fatalf("count archive stories: %v", err) + } + if countOnly.Count != 3 || len(countOnly.Stories) != 0 { + t.Fatalf("count-only archive = %+v, want count 3 and no page", countOnly) + } + first, err := store.ListStoriesArchive(ctx, owner.ID, ownerPeer, 0, 2, 1700000400) + if err != nil { + t.Fatalf("list archive first page: %v", err) + } + if first.Count != 3 || len(first.Stories) != 2 || first.Stories[0].ID != 5 || first.Stories[1].ID != 3 || !first.Stories[1].Pinned { + t.Fatalf("first archive page = %+v, want count 3 ids 5,3 with pinned story", first) + } + second, err := store.ListStoriesArchive(ctx, owner.ID, ownerPeer, 3, 2, 1700000400) + if err != nil { + t.Fatalf("list archive second page: %v", err) + } + if second.Count != 3 || len(second.Stories) != 1 || second.Stories[0].ID != 1 { + t.Fatalf("second archive page = %+v, want count 3 id 1", second) + } +} + +func TestStoryStoreViewsAndReactionPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, viewer := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 1, + Date: 1700000300, + ExpireDate: 1700001000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + + if created, err := store.IncrementViews(ctx, viewer.ID, ownerPeer, []int{1, 1}, 1700000301); err != nil || created != 1 { + t.Fatalf("increment views first = %d, %v; want 1 nil", created, err) + } + if created, err := store.IncrementViews(ctx, viewer.ID, ownerPeer, []int{1}, 1700000302); err != nil || created != 0 { + t.Fatalf("increment views duplicate = %d, %v; want 0 nil", created, err) + } + if _, err := store.IncrementViews(ctx, viewer.ID, ownerPeer, nil, 1700000302); !errors.Is(err, domain.ErrStoryIDInvalid) { + t.Fatalf("empty increment views err = %v, want ErrStoryIDInvalid", err) + } + like := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "like"} + fire := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "fire"} + res, err := store.SetReaction(ctx, viewer.ID, ownerPeer, 1, like, 1700000303) + if err != nil { + t.Fatalf("set reaction: %v", err) + } + if !res.Changed || res.Story.Views.ViewsCount != 1 || res.Story.Views.ReactionsCount != 1 { + t.Fatalf("reaction result = %+v, want one view and one reaction", res) + } + res, err = store.SetReaction(ctx, viewer.ID, ownerPeer, 1, fire, 1700000304) + if err != nil { + t.Fatalf("replace reaction: %v", err) + } + if !res.Changed || res.Story.Views.ReactionsCount != 1 || len(res.Story.Views.Reactions) != 1 || res.Story.Views.Reactions[0].Reaction.Emoticon != "fire" { + t.Fatalf("replace result = %+v, want only fire reaction", res) + } + res, err = store.SetReaction(ctx, viewer.ID, ownerPeer, 1, fire, 1700000305) + if err != nil { + t.Fatalf("retry same reaction: %v", err) + } + if res.Changed || res.Date != 1700000304 || res.Story.Views.ReactionsCount != 1 || len(res.Story.Views.Reactions) != 1 || res.Story.Views.Reactions[0].Reaction.Emoticon != "fire" { + t.Fatalf("retry same reaction result = %+v, want unchanged fire reaction at original date", res) + } + views, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story views: %v", err) + } + if views.Count != 1 || views.ViewsCount != 1 || views.ReactionsCount != 1 || len(views.Views) != 1 || views.Views[0].ViewerID != viewer.ID { + t.Fatalf("views list = %+v, want one viewer with reaction", views) + } + reactions, err := store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Reaction: fire, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story reactions: %v", err) + } + if reactions.Count != 1 || len(reactions.Reactions) != 1 || reactions.Reactions[0].ViewerID != viewer.ID { + t.Fatalf("reactions list = %+v, want one filtered reaction", reactions) + } + if reactions.Reactions[0].Date != 1700000304 { + t.Fatalf("reaction date after retry = %d, want original date", reactions.Reactions[0].Date) + } + custom := &domain.MessageReaction{Type: domain.MessageReactionCustomEmoji, DocumentID: 12345} + res, err = store.SetReaction(ctx, viewer.ID, ownerPeer, 1, custom, 1700000305) + if err != nil { + t.Fatalf("replace with custom reaction: %v", err) + } + if !res.Changed || res.Story.Views.ReactionsCount != 1 || len(res.Story.Views.Reactions) != 1 || res.Story.Views.Reactions[0].Reaction.DocumentID != 12345 { + t.Fatalf("custom replace result = %+v, want one custom reaction", res) + } + res, err = store.SetReaction(ctx, viewer.ID, ownerPeer, 1, custom, 1700000306) + if err != nil { + t.Fatalf("retry same custom reaction: %v", err) + } + if res.Changed || res.Date != 1700000305 || res.Story.Views.ReactionsCount != 1 || len(res.Story.Views.Reactions) != 1 || res.Story.Views.Reactions[0].Reaction.DocumentID != 12345 { + t.Fatalf("retry same custom reaction result = %+v, want unchanged custom reaction at original date", res) + } + reactions, err = store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Reaction: custom, + Limit: 10, + }) + if err != nil { + t.Fatalf("list custom story reactions: %v", err) + } + if reactions.Count != 1 || len(reactions.Reactions) != 1 || reactions.Reactions[0].ViewerID != viewer.ID || reactions.Reactions[0].Date != 1700000305 || reactions.Reactions[0].Reaction == nil || reactions.Reactions[0].Reaction.DocumentID != 12345 { + t.Fatalf("custom story reactions = %+v, want original custom reaction date", reactions) + } + if _, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Offset: "bad", + Limit: 10, + }); !errors.Is(err, domain.ErrStoryOffsetInvalid) { + t.Fatalf("bad story views offset err = %v, want ErrStoryOffsetInvalid", err) + } + if _, err := store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Offset: "1:1700000304:2001", + Limit: 10, + }); !errors.Is(err, domain.ErrStoryOffsetInvalid) { + t.Fatalf("bad story reactions offset err = %v, want ErrStoryOffsetInvalid", err) + } + res, err = store.SetReaction(ctx, viewer.ID, ownerPeer, 1, nil, 1700000307) + if err != nil { + t.Fatalf("clear reaction: %v", err) + } + if !res.Changed || res.Story.Views.ReactionsCount != 0 || len(res.Story.Views.Reactions) != 0 { + t.Fatalf("clear result = %+v, want no reactions", res) + } + + afterRestart := NewStoryStore(pool) + list, err := afterRestart.GetStoriesByID(ctx, viewer.ID, ownerPeer, []int{1}, 1700000307) + if err != nil { + t.Fatalf("get story after restart: %v", err) + } + if len(list.Stories) != 1 || list.Stories[0].Views.ViewsCount != 1 || list.Stories[0].Views.ReactionsCount != 0 || list.Stories[0].SentReaction != nil { + t.Fatalf("story after restart = %+v, want durable view with cleared reaction", list.Stories) + } +} + +func TestStoryStoreExpiredUnpinnedStoriesDoNotAcceptNewInteractionsPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, viewer := createStoryTestUsers(t, ctx, pool) + _, otherViewer := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + for _, story := range []domain.Story{ + {Owner: ownerPeer, ID: 1, Date: 1700000100, ExpireDate: 1700000150, Public: true}, + {Owner: ownerPeer, ID: 2, Date: 1700000101, ExpireDate: 1700000150, Public: true, Pinned: true}, + } { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story %d: %v", story.ID, err) + } + } + + if created, err := store.IncrementViews(ctx, viewer.ID, ownerPeer, []int{1}, 1700000200); err != nil || created != 0 { + t.Fatalf("expired unpinned increment = %d, %v; want 0 nil", created, err) + } + if _, err := store.SetReaction(ctx, viewer.ID, ownerPeer, 1, &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "like"}, 1700000200); !errors.Is(err, domain.ErrStoryNotFound) { + t.Fatalf("expired unpinned reaction err = %v, want ErrStoryNotFound", err) + } + if created, err := store.IncrementViews(ctx, viewer.ID, ownerPeer, []int{2}, 1700000200); err != nil || created != 1 { + t.Fatalf("expired pinned increment = %d, %v; want 1 nil", created, err) + } + res, err := store.SetReaction(ctx, otherViewer.ID, ownerPeer, 2, &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "fire"}, 1700000201) + if err != nil { + t.Fatalf("expired pinned reaction: %v", err) + } + if !res.Changed || res.Story.Views.ViewsCount != 2 || res.Story.Views.ReactionsCount != 1 { + t.Fatalf("expired pinned reaction result = %+v, want accepted profile interaction", res) + } +} + +func TestStoryStoreForwardRoundTripEditAndClonePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, sourceUser := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + source := domain.Peer{Type: domain.PeerTypeUser, ID: sourceUser.ID} + created, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: ownerPeer, + RandomID: 202, + Date: 1700000600, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + From: source, + StoryID: 7, + Modified: true, + }, + }) + if err != nil { + t.Fatalf("create story: %v", err) + } + assertPGStoryForward(t, created.Story, source, 7, true) + created.Story.Forward.From.ID = 9999 + created.Story.Forward.StoryID = 99 + + list, err := store.GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID}, 1700000601) + if err != nil { + t.Fatalf("get story by id: %v", err) + } + if len(list.Stories) != 1 { + t.Fatalf("stories by id = %+v, want one story", list.Stories) + } + assertPGStoryForward(t, list.Stories[0], source, 7, true) + + edited, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: ownerPeer, + ID: created.Story.ID, + UpdateCaption: true, + Caption: "edited repost caption", + }) + if err != nil { + t.Fatalf("edit story caption: %v", err) + } + assertPGStoryForward(t, edited.Story, source, 7, true) + edited.Story.Forward.From.ID = 9998 + + list, err = store.GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID}, 1700000602) + if err != nil { + t.Fatalf("get edited story by id: %v", err) + } + assertPGStoryForward(t, list.Stories[0], source, 7, true) + + hidden, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: ownerPeer, + RandomID: 203, + Date: 1700000603, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + FromName: "Alice Hidden", + StoryID: 8, + }, + }) + if err != nil { + t.Fatalf("create hidden-author story: %v", err) + } + assertPGStoryForwardName(t, hidden.Story, "Alice Hidden", 8, false) + hidden.Story.Forward.From = source + hidden.Story.Forward.FromName = "mutated" + hidden.Story.Forward.StoryID = 88 + + list, err = store.GetStoriesByID(ctx, owner.ID, ownerPeer, []int{hidden.Story.ID}, 1700000604) + if err != nil { + t.Fatalf("get hidden-author story by id: %v", err) + } + if len(list.Stories) != 1 { + t.Fatalf("hidden-author stories by id = %+v, want one story", list.Stories) + } + assertPGStoryForwardName(t, list.Stories[0], "Alice Hidden", 8, false) + + editedHidden, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: ownerPeer, + ID: hidden.Story.ID, + UpdateCaption: true, + Caption: "edited hidden repost caption", + }) + if err != nil { + t.Fatalf("edit hidden-author story caption: %v", err) + } + assertPGStoryForwardName(t, editedHidden.Story, "Alice Hidden", 8, false) +} + +func TestStoryStorePublicRepostForwardCountAndViewsListPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + sourceUser, repostUser := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + sourceOwner := domain.Peer{Type: domain.PeerTypeUser, ID: sourceUser.ID} + repostOwner := domain.Peer{Type: domain.PeerTypeUser, ID: repostUser.ID} + source, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: sourceOwner, + RandomID: 301, + Date: 1700000700, + Period: 86400, + Public: true, + }) + if err != nil { + t.Fatalf("create source story: %v", err) + } + repost, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: repostOwner, + RandomID: 302, + Date: 1700000701, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + From: sourceOwner, + StoryID: source.Story.ID, + }, + }) + if err != nil { + t.Fatalf("create repost story: %v", err) + } + sourceList, err := store.GetStoriesByID(ctx, sourceUser.ID, sourceOwner, []int{source.Story.ID}, 1700000702) + if err != nil { + t.Fatalf("get source story: %v", err) + } + if len(sourceList.Stories) != 1 || sourceList.Stories[0].Views.ForwardsCount != 1 { + t.Fatalf("source story = %+v, want forwards_count=1", sourceList.Stories) + } + views, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: sourceUser.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + ForwardsFirst: true, + }) + if err != nil { + t.Fatalf("list story views: %v", err) + } + if views.Count != 1 || views.ForwardsCount != 1 || len(views.Views) != 1 || views.Views[0].Repost == nil { + t.Fatalf("views list = %+v, want one repost interaction", views) + } + if views.Views[0].Repost.Owner != repostOwner || views.Views[0].Repost.ID != repost.Story.ID { + t.Fatalf("repost interaction = %+v, want owner %+v story %d", views.Views[0].Repost, repostOwner, repost.Story.ID) + } + if _, err := store.DeleteStories(ctx, repostOwner, []int{repost.Story.ID}, 1700000703); err != nil { + t.Fatalf("delete repost: %v", err) + } + sourceList, err = store.GetStoriesByID(ctx, sourceUser.ID, sourceOwner, []int{source.Story.ID}, 1700000703) + if err != nil { + t.Fatalf("get source story after delete: %v", err) + } + if len(sourceList.Stories) != 1 || sourceList.Stories[0].Views.ForwardsCount != 0 { + t.Fatalf("source story after delete = %+v, want forwards_count=0", sourceList.Stories) + } +} + +func TestStoryStorePublicForwardListReturnsRepostsOnlyPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + sourceUser, repostUser := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + sourceOwner := domain.Peer{Type: domain.PeerTypeChannel, ID: sourceUser.ID} + repostOwner := domain.Peer{Type: domain.PeerTypeChannel, ID: repostUser.ID} + privateRepostOwner := domain.Peer{Type: domain.PeerTypeChannel, ID: repostUser.ID + 1000} + source, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: sourceOwner, + RandomID: 311, + Date: 1700000710, + Period: 86400, + Public: true, + }) + if err != nil { + t.Fatalf("create source story: %v", err) + } + repost, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: repostOwner, + RandomID: 312, + Date: 1700000711, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + From: sourceOwner, + StoryID: source.Story.ID, + }, + }) + if err != nil { + t.Fatalf("create public repost: %v", err) + } + hiddenRepost, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: repostOwner, + RandomID: 314, + Date: 1700000713, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + Source: sourceOwner, + FromName: "Hidden Source", + StoryID: source.Story.ID, + }, + }) + if err != nil { + t.Fatalf("create hidden-source public repost: %v", err) + } + if _, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: privateRepostOwner, + RandomID: 313, + Date: 1700000712, + Period: 86400, + Public: false, + Forward: &domain.StoryForward{ + From: sourceOwner, + StoryID: source.Story.ID, + }, + }); err != nil { + t.Fatalf("create private repost: %v", err) + } + list, err := store.ListStoryPublicForwards(ctx, domain.StoryPublicForwardListRequest{ + ViewerUserID: sourceUser.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + }) + if err != nil { + t.Fatalf("list story public forwards: %v", err) + } + if list.Count != 2 || len(list.Forwards) != 2 || list.Forwards[0].Repost == nil || list.Forwards[1].Repost == nil { + t.Fatalf("public forwards = %+v, want two public reposts", list) + } + if list.Forwards[0].Repost.ID != hiddenRepost.Story.ID || + list.Forwards[0].Repost.Forward == nil || + list.Forwards[0].Repost.Forward.From != (domain.Peer{}) || + list.Forwards[0].Repost.Forward.Source != sourceOwner || + list.Forwards[0].Repost.Forward.FromName != "Hidden Source" { + t.Fatalf("hidden public forward repost = %+v, want from_name-only header with source %+v", list.Forwards[0].Repost, sourceOwner) + } + if list.Forwards[1].Repost.Owner != repostOwner || list.Forwards[1].Repost.ID != repost.Story.ID { + t.Fatalf("public forward repost = %+v, want owner %+v story %d", list.Forwards[1].Repost, repostOwner, repost.Story.ID) + } + if _, err := store.DeleteStories(ctx, repostOwner, []int{repost.Story.ID}, 1700000714); err != nil { + t.Fatalf("delete repost: %v", err) + } + afterVisibleDelete, err := store.ListStoryPublicForwards(ctx, domain.StoryPublicForwardListRequest{ + ViewerUserID: sourceUser.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + }) + if err != nil { + t.Fatalf("list story public forwards after delete: %v", err) + } + if afterVisibleDelete.Count != 1 || len(afterVisibleDelete.Forwards) != 1 || afterVisibleDelete.Forwards[0].Repost == nil || + afterVisibleDelete.Forwards[0].Repost.ID != hiddenRepost.Story.ID { + t.Fatalf("public forwards after visible delete = %+v, want hidden repost only", afterVisibleDelete) + } + if _, err := store.DeleteStories(ctx, repostOwner, []int{hiddenRepost.Story.ID}, 1700000715); err != nil { + t.Fatalf("delete hidden repost: %v", err) + } + empty, err := store.ListStoryPublicForwards(ctx, domain.StoryPublicForwardListRequest{ + ViewerUserID: sourceUser.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + }) + if err != nil { + t.Fatalf("list story public forwards after hidden delete: %v", err) + } + if empty.Count != 0 || len(empty.Forwards) != 0 { + t.Fatalf("public forwards after delete = %+v, want empty", empty) + } +} + +func TestStoryStorePublicRepostStoryReactionsListPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + sourceUser, repostUser := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + channels := NewChannelStore(pool) + createdChannel, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{ + CreatorUserID: sourceUser.ID, + Title: "Story Reactions " + randomSuffix(t), + Broadcast: true, + MemberUserIDs: []int64{repostUser.ID}, + Date: 1700000790, + }) + if err != nil { + t.Fatalf("create source channel: %v", err) + } + sourceOwner := domain.Peer{Type: domain.PeerTypeChannel, ID: createdChannel.Channel.ID} + t.Cleanup(func() { + cleanupChannelStoryTestRows(t, context.Background(), pool, sourceOwner.ID, []int64{sourceUser.ID, repostUser.ID}) + }) + repostOwner := domain.Peer{Type: domain.PeerTypeUser, ID: repostUser.ID} + source, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: sourceOwner, + RandomID: 401, + Date: 1700000800, + Period: 86400, + Public: true, + }) + if err != nil { + t.Fatalf("create source story: %v", err) + } + if _, err := store.SetReaction(ctx, repostUser.ID, sourceOwner, source.Story.ID, &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "🔥", + }, 1700000801); err != nil { + t.Fatalf("set reaction: %v", err) + } + repost, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: repostOwner, + RandomID: 402, + Date: 1700000802, + Period: 86400, + Public: true, + Forward: &domain.StoryForward{ + From: sourceOwner, + StoryID: source.Story.ID, + }, + }) + if err != nil { + t.Fatalf("create repost story: %v", err) + } + list, err := store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: sourceUser.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + ForwardsFirst: true, + }) + if err != nil { + t.Fatalf("list story reactions: %v", err) + } + if list.Count != 2 || len(list.Reactions) != 2 || list.Reactions[0].Repost == nil { + t.Fatalf("reaction list = %+v, want repost first plus reaction", list) + } + if list.Reactions[0].Repost.Owner != repostOwner || list.Reactions[0].Repost.ID != repost.Story.ID { + t.Fatalf("repost reaction = %+v, want owner %+v story %d", list.Reactions[0].Repost, repostOwner, repost.Story.ID) + } + filtered, err := store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: sourceUser.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + Reaction: &domain.MessageReaction{ + Type: domain.MessageReactionEmoji, + Emoticon: "🔥", + }, + }) + if err != nil { + t.Fatalf("list filtered story reactions: %v", err) + } + if filtered.Count != 1 || len(filtered.Reactions) != 1 || filtered.Reactions[0].Repost != nil || filtered.Reactions[0].ViewerID != repostUser.ID { + t.Fatalf("filtered reaction list = %+v, want only emoji reactor", filtered) + } + if _, err := store.DeleteStories(ctx, repostOwner, []int{repost.Story.ID}, 1700000803); err != nil { + t.Fatalf("delete repost: %v", err) + } + list, err = store.ListStoryReactions(ctx, domain.StoryReactionListRequest{ + ViewerUserID: sourceUser.ID, + Owner: sourceOwner, + StoryID: source.Story.ID, + Limit: 20, + ForwardsFirst: true, + }) + if err != nil { + t.Fatalf("list story reactions after delete: %v", err) + } + if list.Count != 1 || len(list.Reactions) != 1 || list.Reactions[0].Repost != nil { + t.Fatalf("reaction list after delete = %+v, want only durable reaction", list) + } +} + +func testPGStoryMediaArea(emoticon string, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaSuggestedReaction, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 10, + W: 10, + H: 10, + Rotation: 15, + Radius: 8, + HasRadius: true, + }, + Dark: true, + Flipped: true, + Reaction: &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon}, + } +} + +func assertPGStoryMediaArea(t *testing.T, story domain.Story, wantEmoticon string, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaSuggestedReaction || area.Reaction == nil { + t.Fatalf("story media area = %+v, want suggested reaction", area) + } + if area.Reaction.Type != domain.MessageReactionEmoji || area.Reaction.Emoticon != wantEmoticon { + t.Fatalf("story media area reaction = %+v, want emoji %q", area.Reaction, wantEmoticon) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 10 || area.Coordinates.W != 10 || area.Coordinates.H != 10 || + area.Coordinates.Rotation != 15 || !area.Coordinates.HasRadius || area.Coordinates.Radius != 8 { + t.Fatalf("story media area coordinates = %+v, want x %v y/w/h 10 rotation 15 radius 8", area.Coordinates, wantX) + } + if !area.Dark || !area.Flipped { + t.Fatalf("story media area flags = dark %v flipped %v, want true/true", area.Dark, area.Flipped) + } +} + +func testPGStoryURLMediaArea(url string, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaURL, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 10, + W: 20, + H: 10, + Rotation: 15, + }, + URL: url, + } +} + +func assertPGStoryURLMediaArea(t *testing.T, story domain.Story, wantURL string, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one url area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaURL || area.URL != wantURL { + t.Fatalf("story media area = %+v, want url %q", area, wantURL) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 10 || area.Coordinates.W != 20 || area.Coordinates.H != 10 || + area.Coordinates.Rotation != 15 { + t.Fatalf("story media area coordinates = %+v, want x %v y 10 w 20 h 10 rotation 15", area.Coordinates, wantX) + } +} + +func testPGStoryGeoPointMediaArea(lat, long, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaGeoPoint, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 12, + W: 22, + H: 11, + Rotation: 20, + }, + Geo: &domain.MessageGeoPoint{ + Lat: lat, + Long: long, + AccessHash: 123456, + }, + GeoAddress: &domain.StoryGeoPointAddress{ + CountryISO2: "CN", + State: "Shanghai", + City: "Shanghai", + Street: "People Square", + }, + } +} + +func assertPGStoryGeoPointMediaArea(t *testing.T, story domain.Story, wantLat, wantLong, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one geo area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaGeoPoint || area.Geo == nil { + t.Fatalf("story media area = %+v, want geo point", area) + } + if area.Geo.Lat != wantLat || area.Geo.Long != wantLong || area.Geo.AccessHash != 123456 { + t.Fatalf("story media area geo = %+v, want lat %v long %v access_hash 123456", area.Geo, wantLat, wantLong) + } + if area.GeoAddress == nil || area.GeoAddress.CountryISO2 != "CN" || area.GeoAddress.State != "Shanghai" || area.GeoAddress.City != "Shanghai" || area.GeoAddress.Street != "People Square" { + t.Fatalf("story media area address = %+v, want CN/Shanghai/Shanghai/People Square", area.GeoAddress) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 12 || area.Coordinates.W != 22 || area.Coordinates.H != 11 || + area.Coordinates.Rotation != 20 { + t.Fatalf("story media area coordinates = %+v, want x %v y 12 w 22 h 11 rotation 20", area.Coordinates, wantX) + } +} + +func testPGStoryVenueMediaArea(title string, lat, long, x float64) domain.StoryMediaArea { + venue := &domain.MessageVenue{ + Geo: domain.MessageGeoPoint{Lat: lat, Long: long, AccessHash: 123456}, + Title: title, + Address: "Inline Street", + Provider: "gplaces", + VenueID: "venue-id", + VenueType: "cafe", + } + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaVenue, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 13, + W: 23, + H: 12, + Rotation: 22, + }, + Geo: &venue.Geo, + Venue: venue, + } +} + +func assertPGStoryVenueMediaArea(t *testing.T, story domain.Story, wantTitle string, wantLat, wantLong, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one venue area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaVenue || area.Venue == nil || area.Geo == nil { + t.Fatalf("story media area = %+v, want venue", area) + } + if area.Venue.Title != wantTitle || area.Venue.Address != "Inline Street" || area.Venue.Provider != "gplaces" || area.Venue.VenueID != "venue-id" || area.Venue.VenueType != "cafe" { + t.Fatalf("story venue = %+v, want %q/Inline Street/gplaces/venue-id/cafe", area.Venue, wantTitle) + } + if area.Venue.Geo.Lat != wantLat || area.Venue.Geo.Long != wantLong || area.Venue.Geo.AccessHash != 123456 || + area.Geo.Lat != wantLat || area.Geo.Long != wantLong || area.Geo.AccessHash != 123456 { + t.Fatalf("story venue geo = venue %+v area %+v, want lat %v long %v access_hash 123456", area.Venue.Geo, area.Geo, wantLat, wantLong) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 13 || area.Coordinates.W != 23 || area.Coordinates.H != 12 || + area.Coordinates.Rotation != 22 { + t.Fatalf("story media area coordinates = %+v, want x %v y 13 w 23 h 12 rotation 22", area.Coordinates, wantX) + } +} + +func testPGStoryWeatherMediaArea(emoji string, temperatureC float64, color int, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaWeather, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 14, + W: 24, + H: 12, + Rotation: 25, + }, + WeatherEmoji: emoji, + TemperatureC: temperatureC, + Color: color, + } +} + +func assertPGStoryWeatherMediaArea(t *testing.T, story domain.Story, wantEmoji string, wantTemperatureC float64, wantColor int, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one weather area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaWeather { + t.Fatalf("story media area = %+v, want weather", area) + } + if area.WeatherEmoji != wantEmoji || area.TemperatureC != wantTemperatureC || area.Color != wantColor { + t.Fatalf("story weather area = emoji %q temp %v color %d, want %q/%v/%d", area.WeatherEmoji, area.TemperatureC, area.Color, wantEmoji, wantTemperatureC, wantColor) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 14 || area.Coordinates.W != 24 || area.Coordinates.H != 12 || + area.Coordinates.Rotation != 25 { + t.Fatalf("story media area coordinates = %+v, want x %v y 14 w 24 h 12 rotation 25", area.Coordinates, wantX) + } +} + +func testPGStoryChannelPostMediaArea(channelID int64, msgID int, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaChannelPost, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 15, + W: 25, + H: 12, + Rotation: 27, + }, + ChannelID: channelID, + MsgID: msgID, + } +} + +func assertPGStoryChannelPostMediaArea(t *testing.T, story domain.Story, wantChannelID int64, wantMsgID int, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one channel post area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaChannelPost || area.ChannelID != wantChannelID || area.MsgID != wantMsgID { + t.Fatalf("story channel post area = %+v, want channel %d msg %d", area, wantChannelID, wantMsgID) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 15 || area.Coordinates.W != 25 || area.Coordinates.H != 12 || + area.Coordinates.Rotation != 27 { + t.Fatalf("story media area coordinates = %+v, want x %v y 15 w 25 h 12 rotation 27", area.Coordinates, wantX) + } +} + +func testPGStoryStarGiftMediaArea(slug string, x float64) domain.StoryMediaArea { + return domain.StoryMediaArea{ + Kind: domain.StoryMediaAreaStarGift, + Coordinates: domain.StoryMediaAreaCoordinates{ + X: x, + Y: 16, + W: 26, + H: 12, + Rotation: 29, + }, + StarGiftSlug: slug, + } +} + +func assertPGStoryStarGiftMediaArea(t *testing.T, story domain.Story, wantSlug string, wantX float64) { + t.Helper() + if len(story.MediaAreas) != 1 { + t.Fatalf("story media areas = %+v, want one star gift area", story.MediaAreas) + } + area := story.MediaAreas[0] + if area.Kind != domain.StoryMediaAreaStarGift || area.StarGiftSlug != wantSlug { + t.Fatalf("story star gift area = %+v, want slug %q", area, wantSlug) + } + if area.Coordinates.X != wantX || area.Coordinates.Y != 16 || area.Coordinates.W != 26 || area.Coordinates.H != 12 || + area.Coordinates.Rotation != 29 { + t.Fatalf("story media area coordinates = %+v, want x %v y 16 w 26 h 12 rotation 29", area.Coordinates, wantX) + } +} + +func assertPGStoryForward(t *testing.T, story domain.Story, wantSource domain.Peer, wantStoryID int, wantModified bool) { + t.Helper() + if story.Forward == nil { + t.Fatalf("story forward is nil, want source %+v story %d", wantSource, wantStoryID) + } + if story.Forward.From != wantSource || story.Forward.StoryID != wantStoryID || story.Forward.Modified != wantModified { + t.Fatalf("story forward = %+v, want source %+v story %d modified %v", story.Forward, wantSource, wantStoryID, wantModified) + } +} + +func assertPGStoryForwardName(t *testing.T, story domain.Story, wantName string, wantStoryID int, wantModified bool) { + t.Helper() + if story.Forward == nil { + t.Fatalf("story forward is nil, want from_name %q story %d", wantName, wantStoryID) + } + if story.Forward.From != (domain.Peer{}) || story.Forward.FromName != wantName || + story.Forward.StoryID != wantStoryID || story.Forward.Modified != wantModified { + t.Fatalf("story forward = %+v, want from_name %q story %d modified %v", story.Forward, wantName, wantStoryID, wantModified) + } +} + +func TestStoryStoreViewListFiltersPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, contactViewer := createStoryTestUsers(t, ctx, pool) + users := NewUserStore(pool) + stranger, err := users.Create(ctx, domain.User{AccessHash: 8103, Phone: "+1771" + randomSuffix(t) + "03", FirstName: "Bob", LastName: "Stranger"}) + if err != nil { + t.Fatalf("create stranger: %v", err) + } + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, stranger.ID); err != nil { + t.Fatalf("cleanup stranger: %v", err) + } + }) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 1, + Date: 1700000400, + ExpireDate: 1700001000, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + if _, err := store.IncrementViews(ctx, contactViewer.ID, ownerPeer, []int{1}, 1700000401); err != nil { + t.Fatalf("increment contact view: %v", err) + } + if _, err := store.IncrementViews(ctx, stranger.ID, ownerPeer, []int{1}, 1700000402); err != nil { + t.Fatalf("increment stranger view: %v", err) + } + viewerIDs, err := store.ListStoryViewerIDs(ctx, ownerPeer, 1, 10) + if err != nil { + t.Fatalf("list story viewer ids: %v", err) + } + if len(viewerIDs) != 2 || viewerIDs[0] != contactViewer.ID || viewerIDs[1] != stranger.ID { + t.Fatalf("viewer ids = %v, want contact and stranger ascending", viewerIDs) + } + if _, err := pool.Exec(ctx, ` +INSERT INTO contacts (user_id, contact_user_id, mutual, contact_first_name, contact_last_name, contact_phone) +VALUES ($1, $2, false, 'Close', 'Friend', '7001') +ON CONFLICT (user_id, contact_user_id) DO UPDATE SET + contact_first_name = EXCLUDED.contact_first_name, + contact_last_name = EXCLUDED.contact_last_name, + contact_phone = EXCLUDED.contact_phone`, owner.ID, contactViewer.ID); err != nil { + t.Fatalf("insert contact: %v", err) + } + + contactsOnly, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Limit: 10, + JustContacts: true, + }) + if err != nil { + t.Fatalf("list contacts-only: %v", err) + } + if contactsOnly.Count != 1 || contactsOnly.ViewsCount != 2 || len(contactsOnly.Views) != 1 || contactsOnly.Views[0].ViewerID != contactViewer.ID { + t.Fatalf("contacts-only = %+v, want one contact viewer and total views 2", contactsOnly) + } + + remark, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Limit: 10, + Query: "close", + }) + if err != nil { + t.Fatalf("list remark query: %v", err) + } + if remark.Count != 1 || len(remark.Views) != 1 || remark.Views[0].ViewerID != contactViewer.ID { + t.Fatalf("remark query = %+v, want contact viewer", remark) + } + + strangerQuery, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Limit: 10, + Query: "bob", + }) + if err != nil { + t.Fatalf("list stranger query: %v", err) + } + if strangerQuery.Count != 1 || len(strangerQuery.Views) != 1 || strangerQuery.Views[0].ViewerID != stranger.ID { + t.Fatalf("stranger query = %+v, want stranger viewer", strangerQuery) + } + + intersection, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Limit: 10, + Query: "bob", + JustContacts: true, + }) + if err != nil { + t.Fatalf("list query contacts intersection: %v", err) + } + if intersection.Count != 0 || len(intersection.Views) != 0 { + t.Fatalf("intersection = %+v, want empty contact-filtered bob result", intersection) + } + if _, err := store.DeleteStories(ctx, ownerPeer, []int{1}, 1700000403); err != nil { + t.Fatalf("delete story: %v", err) + } + deletedViewerIDs, err := store.ListStoryViewerIDs(ctx, ownerPeer, 1, 10) + if err != nil { + t.Fatalf("list deleted story viewer ids: %v", err) + } + if len(deletedViewerIDs) != 2 || deletedViewerIDs[0] != contactViewer.ID || deletedViewerIDs[1] != stranger.ID { + t.Fatalf("deleted story viewer ids = %v, want contact and stranger ascending", deletedViewerIDs) + } +} + +func TestStoryStoreViewerIDsIncludeCachedStoryExposurePostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, _ := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 1, + Date: 1700000900, + ExpireDate: 1700001900, + Public: true, + }}); err != nil { + t.Fatalf("upsert story: %v", err) + } + + viewerOne := owner.ID + 100001 + viewerTwo := owner.ID + 100002 + viewerThree := owner.ID + 100003 + list, err := store.ListActiveStories(ctx, viewerOne, false, 1700000910, 10) + if err != nil { + t.Fatalf("list active stories: %v", err) + } + if len(list.Stories) != 1 { + t.Fatalf("active stories = %d, want 1", len(list.Stories)) + } + peerStories, err := store.GetPeerStories(ctx, viewerTwo, ownerPeer, 1700000911) + if err != nil { + t.Fatalf("get peer stories: %v", err) + } + if len(peerStories.Stories) != 1 { + t.Fatalf("peer stories = %d, want 1", len(peerStories.Stories)) + } + exact, err := store.GetStoriesByID(ctx, viewerThree, ownerPeer, []int{1}, 1700000912) + if err != nil { + t.Fatalf("get stories by id: %v", err) + } + if len(exact.Stories) != 1 { + t.Fatalf("exact stories = %d, want 1", len(exact.Stories)) + } + + viewerIDs, err := store.ListStoryViewerIDs(ctx, ownerPeer, 1, 10) + if err != nil { + t.Fatalf("list story viewer ids: %v", err) + } + want := []int64{viewerOne, viewerTwo, viewerThree} + if len(viewerIDs) != len(want) { + t.Fatalf("viewer ids = %v, want %v", viewerIDs, want) + } + for i := range want { + if viewerIDs[i] != want[i] { + t.Fatalf("viewer ids = %v, want %v", viewerIDs, want) + } + } + views, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: ownerPeer, + StoryID: 1, + Limit: 10, + }) + if err != nil { + t.Fatalf("list story views: %v", err) + } + if views.Count != 0 || views.ViewsCount != 0 || len(views.Views) != 0 { + t.Fatalf("story views = %+v, want exposure without view counters", views) + } +} + +func TestStoryStorePrivacyVisibilityPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, selectedViewer := createStoryTestUsers(t, ctx, pool) + users := NewUserStore(pool) + suffix := randomSuffix(t) + stranger, err := users.Create(ctx, domain.User{AccessHash: 8203, Phone: "+1772" + suffix + "03", FirstName: "Stranger"}) + if err != nil { + t.Fatalf("create stranger: %v", err) + } + contactViewer, err := users.Create(ctx, domain.User{AccessHash: 8204, Phone: "+1772" + suffix + "04", FirstName: "Contact"}) + if err != nil { + t.Fatalf("create contact viewer: %v", err) + } + excludedContact, err := users.Create(ctx, domain.User{AccessHash: 8205, Phone: "+1772" + suffix + "05", FirstName: "Excluded"}) + if err != nil { + t.Fatalf("create excluded contact: %v", err) + } + closeFriendViewer, err := users.Create(ctx, domain.User{AccessHash: 8206, Phone: "+1772" + suffix + "06", FirstName: "Close"}) + if err != nil { + t.Fatalf("create close friend viewer: %v", err) + } + t.Cleanup(func() { + if _, err := pool.Exec(context.Background(), ` +DELETE FROM contacts +WHERE user_id = $1 + OR contact_user_id = ANY($2::bigint[])`, owner.ID, []int64{stranger.ID, contactViewer.ID, excludedContact.ID, closeFriendViewer.ID}); err != nil { + t.Fatalf("cleanup contacts: %v", err) + } + if _, err := pool.Exec(context.Background(), ` +DELETE FROM users +WHERE id = ANY($1::bigint[])`, []int64{stranger.ID, contactViewer.ID, excludedContact.ID, closeFriendViewer.ID}); err != nil { + t.Fatalf("cleanup extra users: %v", err) + } + }) + if _, err := pool.Exec(ctx, ` +INSERT INTO contacts (user_id, contact_user_id, mutual, close_friend, contact_first_name, contact_last_name, contact_phone) +VALUES ($1, $2, false, false, 'Contact', 'Viewer', '7004'), + ($1, $3, false, false, 'Excluded', 'Contact', '7005'), + ($1, $4, false, true, 'Close', 'Friend', '7006') +ON CONFLICT (user_id, contact_user_id) DO UPDATE SET + close_friend = EXCLUDED.close_friend, + contact_first_name = EXCLUDED.contact_first_name, + contact_last_name = EXCLUDED.contact_last_name, + contact_phone = EXCLUDED.contact_phone`, owner.ID, contactViewer.ID, excludedContact.ID, closeFriendViewer.ID); err != nil { + t.Fatalf("insert contacts: %v", err) + } + + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + stories := []domain.Story{ + { + Owner: ownerPeer, + ID: 1, + Date: 1700000701, + ExpireDate: 1700009000, + Public: true, + DisallowUserIDs: []int64{stranger.ID}, + PrivacyRules: []domain.PrivacyRule{ + {Kind: domain.PrivacyRuleAllowAll}, + {Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{stranger.ID}}, + }, + }, + { + Owner: ownerPeer, + ID: 2, + Date: 1700000702, + ExpireDate: 1700009000, + SelectedContacts: true, + AllowUserIDs: []int64{selectedViewer.ID}, + PrivacyRules: []domain.PrivacyRule{ + {Kind: domain.PrivacyRuleAllowUsers, UserIDs: []int64{selectedViewer.ID}}, + }, + }, + { + Owner: ownerPeer, + ID: 3, + Date: 1700000703, + ExpireDate: 1700009000, + Contacts: true, + DisallowUserIDs: []int64{excludedContact.ID}, + PrivacyRules: []domain.PrivacyRule{ + {Kind: domain.PrivacyRuleAllowContacts}, + {Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{excludedContact.ID}}, + }, + }, + { + Owner: ownerPeer, + ID: 4, + Date: 1700000704, + ExpireDate: 1700009000, + CloseFriends: true, + PrivacyRules: []domain.PrivacyRule{ + {Kind: domain.PrivacyRuleAllowCloseFriends}, + }, + }, + } + for _, story := range stories { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: story}); err != nil { + t.Fatalf("upsert story %d: %v", story.ID, err) + } + } + + afterRestart := NewStoryStore(pool) + selected, err := afterRestart.GetStoriesByID(ctx, selectedViewer.ID, ownerPeer, []int{1, 2, 3, 4}, 1700000800) + if err != nil { + t.Fatalf("selected viewer get stories: %v", err) + } + if gotIDs := pgStoryIDs(selected.Stories); !samePGStoryIDs(gotIDs, []int{1, 2}) { + t.Fatalf("selected viewer story ids = %v, want [1 2]", gotIDs) + } + if len(selected.Stories[1].PrivacyRules) != 1 || selected.Stories[1].AllowUserIDs[0] != selectedViewer.ID { + t.Fatalf("selected story privacy = %+v allow=%v, want persisted allow user", selected.Stories[1].PrivacyRules, selected.Stories[1].AllowUserIDs) + } + hidden, err := afterRestart.GetStoriesByID(ctx, stranger.ID, ownerPeer, []int{1, 2, 3, 4}, 1700000800) + if err != nil { + t.Fatalf("stranger get stories: %v", err) + } + if len(hidden.Stories) != 0 { + t.Fatalf("stranger stories = %+v, want none", hidden.Stories) + } + contacts, err := afterRestart.GetStoriesByID(ctx, contactViewer.ID, ownerPeer, []int{1, 2, 3, 4}, 1700000800) + if err != nil { + t.Fatalf("contact get stories: %v", err) + } + if gotIDs := pgStoryIDs(contacts.Stories); !samePGStoryIDs(gotIDs, []int{1, 3}) { + t.Fatalf("contact story ids = %v, want [1 3]", gotIDs) + } + excluded, err := afterRestart.GetStoriesByID(ctx, excludedContact.ID, ownerPeer, []int{3}, 1700000800) + if err != nil { + t.Fatalf("excluded contact get stories: %v", err) + } + if len(excluded.Stories) != 0 { + t.Fatalf("excluded contact stories = %+v, want none", excluded.Stories) + } + closeFriend, err := afterRestart.GetStoriesByID(ctx, closeFriendViewer.ID, ownerPeer, []int{1, 2, 3, 4}, 1700000800) + if err != nil { + t.Fatalf("close friend get stories: %v", err) + } + if gotIDs := pgStoryIDs(closeFriend.Stories); !samePGStoryIDs(gotIDs, []int{1, 3, 4}) { + t.Fatalf("close friend story ids = %v, want [1 3 4]", gotIDs) + } + if created, err := afterRestart.IncrementViews(ctx, stranger.ID, ownerPeer, []int{2}, 1700000801); err != nil || created != 0 { + t.Fatalf("hidden increment views = %d, %v; want 0 nil", created, err) + } + if _, err := afterRestart.SetReaction(ctx, stranger.ID, ownerPeer, 2, &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "like"}, 1700000802); err != domain.ErrStoryNotFound { + t.Fatalf("hidden set reaction err = %v, want ErrStoryNotFound", err) + } + recent, err := afterRestart.GetPeerMaxIDs(ctx, contactViewer.ID, []domain.Peer{ownerPeer}, 1700000803) + if err != nil { + t.Fatalf("contact peer max ids: %v", err) + } + if len(recent) != 1 || recent[0].MaxID != 3 { + t.Fatalf("contact recent = %+v, want max id 3", recent) + } + closeFriendRecent, err := afterRestart.GetPeerMaxIDs(ctx, closeFriendViewer.ID, []domain.Peer{ownerPeer}, 1700000803) + if err != nil { + t.Fatalf("close friend peer max ids: %v", err) + } + if len(closeFriendRecent) != 1 || closeFriendRecent[0].MaxID != 4 { + t.Fatalf("close friend recent = %+v, want max id 4", closeFriendRecent) + } +} + +func TestStoryStoreCreateEditDeleteAndPinnedPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, _ := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + media := &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: 9701, AccessHash: 97, DCID: 2}} + + created, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: ownerPeer, + RandomID: 99001, + Date: 1700000600, + Period: 86400, + Public: true, + Pinned: true, + Caption: "pg first", + Media: media, + }) + if err != nil { + t.Fatalf("create story: %v", err) + } + if created.Duplicate || created.Story.ID != 1 || created.Story.ExpireDate != 1700087000 { + t.Fatalf("created = %+v, want new story id 1", created) + } + dup, err := store.CreateStory(ctx, domain.StoryCreateRequest{ + Owner: ownerPeer, + RandomID: 99001, + Date: 1700000601, + Period: 86400, + Public: true, + Caption: "duplicate", + Media: media, + }) + if err != nil { + t.Fatalf("create duplicate: %v", err) + } + if !dup.Duplicate || dup.Story.ID != created.Story.ID || dup.Story.Caption != "pg first" { + t.Fatalf("duplicate = %+v, want original story", dup) + } + selfStories, err := store.GetPeerStories(ctx, owner.ID, ownerPeer, 1700000602) + if err != nil { + t.Fatalf("get self stories: %v", err) + } + if selfStories.MaxReadID != created.Story.ID { + t.Fatalf("self max read = %d, want story id", selfStories.MaxReadID) + } + exact, err := store.GetStoriesByID(ctx, owner.ID, ownerPeer, []int{created.Story.ID, created.Story.ID}, 1700000602) + if err != nil { + t.Fatalf("get stories by duplicate ids: %v", err) + } + if len(exact.Stories) != 1 || exact.Stories[0].ID != created.Story.ID { + t.Fatalf("exact stories = %+v, want one deduped story", exact.Stories) + } + if _, err := store.GetStoriesByID(ctx, owner.ID, ownerPeer, nil, 1700000602); !errors.Is(err, domain.ErrStoryIDInvalid) { + t.Fatalf("empty get stories by id err = %v, want ErrStoryIDInvalid", err) + } + + edited, err := store.EditStory(ctx, domain.StoryEditRequest{ + Owner: ownerPeer, + ID: created.Story.ID, + Caption: "pg edited", + UpdateCaption: true, + }) + if err != nil { + t.Fatalf("edit story: %v", err) + } + if edited.Previous.Caption != "pg first" || edited.Previous.Edited { + t.Fatalf("previous = %+v, want pre-edit story", edited.Previous) + } + if !edited.Story.Edited || edited.Story.Caption != "pg edited" { + t.Fatalf("edited = %+v, want edited caption", edited) + } + pinned, err := store.ListPinnedStories(ctx, owner.ID, ownerPeer, 0, 10, 1700000603) + if err != nil { + t.Fatalf("list pinned: %v", err) + } + if len(pinned.Stories) != 1 || pinned.Stories[0].ID != created.Story.ID { + t.Fatalf("pinned = %+v, want created story", pinned) + } + toggled, err := store.TogglePinned(ctx, ownerPeer, []int{created.Story.ID, created.Story.ID}, false, 1700000604) + if err != nil { + t.Fatalf("toggle pinned: %v", err) + } + if len(toggled.IDs) != 1 || toggled.IDs[0] != created.Story.ID || len(toggled.Stories) != 1 || toggled.Stories[0].Pinned { + t.Fatalf("toggled = %+v, want unpinned story", toggled) + } + if len(toggled.Previous) != 1 || toggled.Previous[0].Deleted || !toggled.Previous[0].Pinned { + t.Fatalf("toggled previous = %+v, want pre-toggle pinned snapshot", toggled.Previous) + } + retryToggled, err := store.TogglePinned(ctx, ownerPeer, []int{created.Story.ID}, false, 1700000605) + if err != nil { + t.Fatalf("retry toggle pinned: %v", err) + } + if len(retryToggled.IDs) != 1 || retryToggled.IDs[0] != created.Story.ID || len(retryToggled.Stories) != 0 || len(retryToggled.Previous) != 0 { + t.Fatalf("retry toggled = %+v, want id echo without mutation snapshot", retryToggled) + } + emptyToggle, err := store.TogglePinned(ctx, ownerPeer, nil, true, 1700000604) + if err != nil { + t.Fatalf("empty toggle pinned: %v", err) + } + if len(emptyToggle.IDs) != 0 || len(emptyToggle.Stories) != 0 { + t.Fatalf("empty toggle = %+v, want no-op", emptyToggle) + } + deleted, err := store.DeleteStories(ctx, ownerPeer, []int{created.Story.ID, created.Story.ID}, 1700000605) + if err != nil { + t.Fatalf("delete story: %v", err) + } + if len(deleted.IDs) != 1 || deleted.IDs[0] != created.Story.ID || len(deleted.Stories) != 1 || !deleted.Stories[0].Deleted { + t.Fatalf("deleted = %+v, want deleted story", deleted) + } + retryDeleted, err := store.DeleteStories(ctx, ownerPeer, []int{created.Story.ID}, 1700000606) + if err != nil { + t.Fatalf("retry delete story: %v", err) + } + if len(retryDeleted.IDs) != 1 || retryDeleted.IDs[0] != created.Story.ID || len(retryDeleted.Stories) != 0 { + t.Fatalf("retry deleted = %+v, want id echo without mutation snapshot", retryDeleted) + } + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: 77, + Date: 1700000000, + ExpireDate: 1700000100, + Pinned: true, + Public: true, + Media: media, + }}); err != nil { + t.Fatalf("upsert expired pinned story: %v", err) + } + expiredPinnedDeleted, err := store.DeleteStories(ctx, ownerPeer, []int{77}, 1700000607) + if err != nil { + t.Fatalf("delete expired pinned story: %v", err) + } + if len(expiredPinnedDeleted.Stories) != 1 || !expiredPinnedDeleted.Stories[0].Deleted || expiredPinnedDeleted.Stories[0].Pinned { + t.Fatalf("expired pinned deleted = %+v, want deleted unpinned snapshot", expiredPinnedDeleted) + } + if len(expiredPinnedDeleted.Previous) != 1 || expiredPinnedDeleted.Previous[0].Deleted || !expiredPinnedDeleted.Previous[0].Pinned { + t.Fatalf("expired pinned previous = %+v, want pre-delete pinned snapshot", expiredPinnedDeleted.Previous) + } + active, err := store.GetPeerStories(ctx, owner.ID, ownerPeer, 1700000606) + if err != nil { + t.Fatalf("get active after delete: %v", err) + } + if len(active.Stories) != 0 { + t.Fatalf("active after delete = %+v, want empty", active) + } +} + +func TestStoryStorePinnedToTopOrderPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, _ := createStoryTestUsers(t, ctx, pool) + store := NewStoryStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + for _, id := range []int{1, 2, 3, 4} { + if _, err := store.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{ + Owner: ownerPeer, + ID: id, + Date: 1700000700 + id, + ExpireDate: 1700009000, + Pinned: true, + Public: true, + }}); err != nil { + t.Fatalf("upsert story %d: %v", id, err) + } + } + if err := store.TogglePinnedToTop(ctx, ownerPeer, []int{3, 1, 3}); err != nil { + t.Fatalf("toggle pinned to top: %v", err) + } + pinned, err := store.ListPinnedStories(ctx, owner.ID, ownerPeer, 0, 2, 1700000800) + if err != nil { + t.Fatalf("list pinned stories: %v", err) + } + if pinned.Count != 4 || !samePGStoryIDs(pgStoryIDs(pinned.Stories), []int{4, 3}) || !samePGStoryIDs(pinned.PinnedToTop, []int{3, 1}) { + t.Fatalf("pinned = count %d stories %v top %v, want count 4 stories 4,3 top 3,1", pinned.Count, pgStoryIDs(pinned.Stories), pinned.PinnedToTop) + } + androidInitial, err := store.ListPinnedStories(ctx, owner.ID, ownerPeer, -1, 2, 1700000800) + if err != nil { + t.Fatalf("list pinned stories with android sentinel: %v", err) + } + if androidInitial.Count != pinned.Count || !samePGStoryIDs(pgStoryIDs(androidInitial.Stories), []int{4, 3}) || !samePGStoryIDs(androidInitial.PinnedToTop, []int{3, 1}) { + t.Fatalf("android sentinel pinned = count %d stories %v top %v, want same first page 4,3 top 3,1", androidInitial.Count, pgStoryIDs(androidInitial.Stories), androidInitial.PinnedToTop) + } + if err := store.TogglePinnedToTop(ctx, ownerPeer, []int{2, 1, 4}); err != nil { + t.Fatalf("replace pinned to top: %v", err) + } + pinned, err = store.ListPinnedStories(ctx, owner.ID, ownerPeer, 0, 10, 1700000801) + if err != nil { + t.Fatalf("list replaced pinned stories: %v", err) + } + if !samePGStoryIDs(pinned.PinnedToTop, []int{2, 1, 4}) { + t.Fatalf("replaced top = %v, want 2,1,4", pinned.PinnedToTop) + } + pageAfterOffset, err := store.ListPinnedStories(ctx, owner.ID, ownerPeer, 3, 10, 1700000801) + if err != nil { + t.Fatalf("list pinned page after offset: %v", err) + } + if !samePGStoryIDs(pgStoryIDs(pageAfterOffset.Stories), []int{2, 1}) || !samePGStoryIDs(pageAfterOffset.PinnedToTop, []int{2, 1, 4}) { + t.Fatalf("offset page stories %v top %v, want stories 2,1 and full top 2,1,4", pgStoryIDs(pageAfterOffset.Stories), pageAfterOffset.PinnedToTop) + } + pageAfterTail, err := store.ListPinnedStories(ctx, owner.ID, ownerPeer, 1, 10, 1700000801) + if err != nil { + t.Fatalf("list pinned page after tail: %v", err) + } + if pageAfterTail.Count != 4 || len(pageAfterTail.Stories) != 0 || !samePGStoryIDs(pageAfterTail.PinnedToTop, []int{2, 1, 4}) { + t.Fatalf("tail page = count %d stories %v top %v, want count 4 empty stories top 2,1,4", pageAfterTail.Count, pgStoryIDs(pageAfterTail.Stories), pageAfterTail.PinnedToTop) + } + if _, err := store.TogglePinned(ctx, ownerPeer, []int{2}, false, 1700000802); err != nil { + t.Fatalf("unpin story: %v", err) + } + pinned, err = store.ListPinnedStories(ctx, owner.ID, ownerPeer, 0, 10, 1700000803) + if err != nil { + t.Fatalf("list after unpin: %v", err) + } + if !samePGStoryIDs(pinned.PinnedToTop, []int{1, 4}) { + t.Fatalf("top after unpin = %v, want 1,4", pinned.PinnedToTop) + } + if err := store.TogglePinnedToTop(ctx, ownerPeer, []int{2}); !errors.Is(err, domain.ErrStoryIDInvalid) { + t.Fatalf("unpinned top err = %v, want ErrStoryIDInvalid", err) + } + if _, err := store.DeleteStories(ctx, ownerPeer, []int{1}, 1700000804); err != nil { + t.Fatalf("delete story: %v", err) + } + pinned, err = store.ListPinnedStories(ctx, owner.ID, ownerPeer, 0, 10, 1700000805) + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if !samePGStoryIDs(pinned.PinnedToTop, []int{4}) { + t.Fatalf("top after delete = %v, want 4", pinned.PinnedToTop) + } + if err := store.TogglePinnedToTop(ctx, ownerPeer, nil); err != nil { + t.Fatalf("clear pinned to top: %v", err) + } + pinned, err = store.ListPinnedStories(ctx, owner.ID, ownerPeer, 0, 10, 1700000806) + if err != nil { + t.Fatalf("list after clear: %v", err) + } + if len(pinned.PinnedToTop) != 0 { + t.Fatalf("top after clear = %v, want empty", pinned.PinnedToTop) + } +} + +func pgStoryIDs(stories []domain.Story) []int { + out := make([]int, 0, len(stories)) + for _, story := range stories { + out = append(out, story.ID) + } + return out +} + +func samePGStoryIDs(got, want []int) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +func TestStoryUpdateEventPayloadPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + owner, _ := createStoryTestUsers(t, ctx, pool) + events := NewUpdateEventStore(pool) + ownerPeer := domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID} + story := domain.Story{ + Owner: ownerPeer, + ID: 7, + Date: 1700000400, + ExpireDate: 1700004000, + Public: true, + Caption: "durable payload", + } + reaction := &domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: "spark"} + + if err := events.Append(ctx, owner.ID, domain.UpdateEvent{ + Type: domain.UpdateEventStory, + Pts: 1, + PtsCount: 1, + Date: story.Date, + Peer: ownerPeer, + Story: story, + }); err != nil { + t.Fatalf("append story event: %v", err) + } + if err := events.Append(ctx, owner.ID, domain.UpdateEvent{ + Type: domain.UpdateEventSentStoryReaction, + Pts: 2, + PtsCount: 1, + Date: 1700000401, + Peer: ownerPeer, + MaxID: story.ID, + Story: story, + Reaction: reaction, + }); err != nil { + t.Fatalf("append reaction event: %v", err) + } + + listed, err := events.ListAfter(ctx, owner.ID, 0, 10) + if err != nil { + t.Fatalf("list events: %v", err) + } + if len(listed) != 2 || listed[0].Story.ID != story.ID || listed[0].Story.Caption != story.Caption { + t.Fatalf("listed story events = %+v, want story payload", listed) + } + if listed[1].Reaction == nil || *listed[1].Reaction != *reaction || listed[1].Story.ID != story.ID { + t.Fatalf("listed reaction event = %+v, want reaction and story payload", listed[1]) + } + + batched, err := events.BatchByCursor(ctx, []storepkg.EventCursor{{UserID: owner.ID, Pts: 2}}) + if err != nil { + t.Fatalf("batch events: %v", err) + } + if len(batched) != 1 || batched[0].Reaction == nil || *batched[0].Reaction != *reaction || batched[0].Story.ID != story.ID { + t.Fatalf("batched event = %+v, want durable reaction payload", batched) + } +} + +func createStoryTestUsers(t *testing.T, ctx context.Context, pool *pgxpool.Pool) (domain.User, domain.User) { + t.Helper() + suffix := randomSuffix(t) + users := NewUserStore(pool) + owner, err := users.Create(ctx, domain.User{AccessHash: 8101, Phone: "+1771" + suffix + "01", FirstName: "StoryOwner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + viewer, err := users.Create(ctx, domain.User{AccessHash: 8102, Phone: "+1771" + suffix + "02", FirstName: "StoryViewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + t.Cleanup(func() { + cleanupStoryTestRows(t, context.Background(), pool, owner.ID, viewer.ID) + }) + return owner, viewer +} + +func cleanupStoryTestRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ownerID, viewerID int64) { + t.Helper() + if _, err := pool.Exec(ctx, ` +DELETE FROM story_hidden_peers +WHERE viewer_user_id = $1 + OR owner_peer_id = $2`, viewerID, ownerID); err != nil { + t.Fatalf("cleanup story hidden peers: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM story_read_states +WHERE viewer_user_id = $1 + OR owner_peer_id = $2`, viewerID, ownerID); err != nil { + t.Fatalf("cleanup story read states: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM story_views +WHERE viewer_user_id = $1 + OR owner_peer_id = $2`, viewerID, ownerID); err != nil { + t.Fatalf("cleanup story views: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM story_exposures +WHERE viewer_user_id = $1 + OR owner_peer_id = $2`, viewerID, ownerID); err != nil { + t.Fatalf("cleanup story exposures: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM stories +WHERE owner_peer_id = ANY($1::bigint[])`, []int64{ownerID, viewerID}); err != nil { + t.Fatalf("cleanup stories: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM users +WHERE id = ANY($1::bigint[])`, []int64{ownerID, viewerID}); err != nil { + t.Fatalf("cleanup story users: %v", err) + } +} + +func cleanupChannelStoryTestRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool, channelID int64, userIDs []int64) { + t.Helper() + if _, err := pool.Exec(ctx, ` +DELETE FROM story_hidden_peers +WHERE (owner_peer_type = 'channel' AND owner_peer_id = $1) + OR viewer_user_id = ANY($2::bigint[])`, channelID, userIDs); err != nil { + t.Fatalf("cleanup channel story hidden peers: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM story_read_states +WHERE (owner_peer_type = 'channel' AND owner_peer_id = $1) + OR viewer_user_id = ANY($2::bigint[])`, channelID, userIDs); err != nil { + t.Fatalf("cleanup channel story read states: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM story_views +WHERE (owner_peer_type = 'channel' AND owner_peer_id = $1) + OR viewer_user_id = ANY($2::bigint[])`, channelID, userIDs); err != nil { + t.Fatalf("cleanup channel story views: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM story_exposures +WHERE (owner_peer_type = 'channel' AND owner_peer_id = $1) + OR viewer_user_id = ANY($2::bigint[])`, channelID, userIDs); err != nil { + t.Fatalf("cleanup channel story exposures: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM stories +WHERE owner_peer_type = 'channel' + AND owner_peer_id = $1`, channelID); err != nil { + t.Fatalf("cleanup channel stories: %v", err) + } + if _, err := pool.Exec(ctx, `DELETE FROM channels WHERE id = $1`, channelID); err != nil { + t.Fatalf("cleanup channel: %v", err) + } + if _, err := pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1::bigint[])`, userIDs); err != nil { + t.Fatalf("cleanup channel story users: %v", err) + } +} + +func storyListContains(list domain.StoryList, peer domain.Peer, storyID int) bool { + for _, story := range list.Stories { + if story.Owner == peer && story.ID == storyID { + return true + } + } + return false +} + +func cleanupStoryPagingTestRows(t *testing.T, ctx context.Context, pool *pgxpool.Pool, viewerID int64, ownerIDs []int64) { + t.Helper() + if _, err := pool.Exec(ctx, ` +DELETE FROM story_hidden_peers +WHERE viewer_user_id = $1 + OR owner_peer_id = ANY($2::bigint[])`, viewerID, ownerIDs); err != nil { + t.Fatalf("cleanup story paging hidden peers: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM story_read_states +WHERE viewer_user_id = $1 + OR owner_peer_id = ANY($2::bigint[])`, viewerID, ownerIDs); err != nil { + t.Fatalf("cleanup story paging read states: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM story_views +WHERE viewer_user_id = $1 + OR owner_peer_id = ANY($2::bigint[])`, viewerID, ownerIDs); err != nil { + t.Fatalf("cleanup story paging views: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM story_exposures +WHERE viewer_user_id = $1 + OR owner_peer_id = ANY($2::bigint[])`, viewerID, ownerIDs); err != nil { + t.Fatalf("cleanup story paging exposures: %v", err) + } + if _, err := pool.Exec(ctx, ` +DELETE FROM stories +WHERE owner_peer_id = ANY($1::bigint[])`, ownerIDs); err != nil { + t.Fatalf("cleanup story paging stories: %v", err) + } + userIDs := append([]int64{viewerID}, ownerIDs...) + if _, err := pool.Exec(ctx, ` +DELETE FROM users +WHERE id = ANY($1::bigint[])`, userIDs); err != nil { + t.Fatalf("cleanup story paging users: %v", err) + } +} diff --git a/internal/store/postgres/story_peer_read_model_test.go b/internal/store/postgres/story_peer_read_model_test.go new file mode 100644 index 00000000..de7af269 --- /dev/null +++ b/internal/store/postgres/story_peer_read_model_test.go @@ -0,0 +1,149 @@ +package postgres + +import ( + "context" + "os" + "sync" + "testing" + "time" + + "telesrv/internal/domain" +) + +type fakeStoryReadModelCache struct { + mu sync.Mutex + peers []domain.Peer + viewers []int64 + flushes int +} + +func (f *fakeStoryReadModelCache) InvalidateStoryReadModelViewers(ids ...int64) { + f.mu.Lock() + defer f.mu.Unlock() + f.viewers = append(f.viewers, ids...) +} + +func (f *fakeStoryReadModelCache) InvalidateStoryReadModelPeer(peer domain.Peer) { + f.mu.Lock() + defer f.mu.Unlock() + f.peers = append(f.peers, peer) +} + +func (f *fakeStoryReadModelCache) FlushStoryReadModelCache() { + f.mu.Lock() + defer f.mu.Unlock() + f.flushes++ +} + +func (f *fakeStoryReadModelCache) peersSnapshot() []domain.Peer { + f.mu.Lock() + defer f.mu.Unlock() + return append([]domain.Peer(nil), f.peers...) +} + +func (f *fakeStoryReadModelCache) flushCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.flushes +} + +func countPeer(peers []domain.Peer, want domain.Peer) int { + n := 0 + for _, p := range peers { + if p == want { + n++ + } + } + return n +} + +func TestReadModelChangeListenerRoutesStoryPeer(t *testing.T) { + stories := &fakeStoryReadModelCache{} + listener := NewReadModelChangeListener("", ReadModelCacheSet{Stories: stories}, nil) + + listener.handlePayload(`{"model":"story_peer","owner_user_id":0,"peer_type":"user","peer_id":777,"version":2}`) + listener.handlePayload(`{"model":"story_peer","owner_user_id":0,"peer_type":"channel","peer_id":888,"version":3}`) + peers := stories.peersSnapshot() + if len(peers) != 2 || + peers[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 777}) || + peers[1] != (domain.Peer{Type: domain.PeerTypeChannel, ID: 888}) { + t.Fatalf("story_peer routing = %+v, want user 777 + channel 888", peers) + } + + // peer_id==0 或 peer_type 非法都不应触发失效。 + listener.handlePayload(`{"model":"story_peer","owner_user_id":0,"peer_type":"user","peer_id":0,"version":4}`) + listener.handlePayload(`{"model":"story_peer","owner_user_id":0,"peer_type":"","peer_id":999,"version":5}`) + if got := len(stories.peersSnapshot()); got != 2 { + t.Fatalf("invalid story_peer events should be ignored: peers=%d, want 2", got) + } +} + +// TestStoryPeerReadModelNotifyInvalidatesOnStoryWrite 验证 0135 触发器:写 stories / +// story_hidden_peers → story_peer bump → 统一 read-model NOTIFY → 按 owner peer 失效故事投影。 +func TestStoryPeerReadModelNotifyInvalidatesOnStoryWrite(t *testing.T) { + pool := testPool(t) + dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN") + ctx := context.Background() + + const ownerID int64 = 913500777 + const viewerID int64 = 913500888 + cleanup := func() { + _, _ = pool.Exec(ctx, "DELETE FROM stories WHERE owner_peer_type='user' AND owner_peer_id=$1", ownerID) + _, _ = pool.Exec(ctx, "DELETE FROM story_hidden_peers WHERE owner_peer_type='user' AND owner_peer_id=$1", ownerID) + _, _ = pool.Exec(ctx, "DELETE FROM read_model_versions WHERE model='story_peer' AND peer_id=$1", ownerID) + } + cleanup() + t.Cleanup(cleanup) + + stories := &fakeStoryReadModelCache{} + lctx, cancel := context.WithCancel(ctx) + defer cancel() + listener := NewReadModelChangeListener(dsn, ReadModelCacheSet{Stories: stories}, nil) + go listener.Run(lctx) + if !waitUntil(2*time.Second, func() bool { return stories.flushCount() >= 1 }) { + t.Fatal("read model listener 未在预期内连接并 flush") + } + + wantPeer := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID} + + // INSERT story → story_peer bump → NOTIFY → 失效 owner peer。 + if _, err := pool.Exec(ctx, ` +INSERT INTO stories (owner_peer_type, owner_peer_id, story_id, date, expire_date) +VALUES ('user', $1, 1, 100, 200)`, ownerID); err != nil { + t.Fatalf("insert story: %v", err) + } + if !waitUntil(3*time.Second, func() bool { return countPeer(stories.peersSnapshot(), wantPeer) >= 1 }) { + t.Fatalf("stories INSERT 后 story_peer NOTIFY 未失效 owner peer; got %+v", stories.peersSnapshot()) + } + + // story_hidden_peers 写也走同一 owner peer 失效。 + before := countPeer(stories.peersSnapshot(), wantPeer) + if _, err := pool.Exec(ctx, ` +INSERT INTO story_hidden_peers (viewer_user_id, owner_peer_type, owner_peer_id) +VALUES ($1, 'user', $2)`, viewerID, ownerID); err != nil { + t.Fatalf("insert story_hidden_peers: %v", err) + } + if !waitUntil(3*time.Second, func() bool { return countPeer(stories.peersSnapshot(), wantPeer) > before }) { + t.Fatalf("story_hidden_peers INSERT 后未再失效 owner peer") + } + + // 持久版本脊确实 bump 了 story_peer(owner_user_id=0, peer=user/ownerID)。 + var version int64 + if err := pool.QueryRow(ctx, ` +SELECT version FROM read_model_versions +WHERE model='story_peer' AND owner_user_id=0 AND peer_type='user' AND peer_id=$1`, ownerID).Scan(&version); err != nil { + t.Fatalf("read story_peer version: %v", err) + } + if version < 2 { + t.Fatalf("story_peer version = %d, want >=2 (stories + hidden writes)", version) + } + + // DELETE story 也应失效(用 OLD.owner_peer_*)。 + before = countPeer(stories.peersSnapshot(), wantPeer) + if _, err := pool.Exec(ctx, `DELETE FROM stories WHERE owner_peer_type='user' AND owner_peer_id=$1`, ownerID); err != nil { + t.Fatalf("delete story: %v", err) + } + if !waitUntil(3*time.Second, func() bool { return countPeer(stories.peersSnapshot(), wantPeer) > before }) { + t.Fatalf("stories DELETE 后未失效 owner peer") + } +} diff --git a/internal/store/postgres/temp_auth_key.go b/internal/store/postgres/temp_auth_key.go index 88a3e5f1..ccd8c636 100644 --- a/internal/store/postgres/temp_auth_key.go +++ b/internal/store/postgres/temp_auth_key.go @@ -35,6 +35,22 @@ func (s *TempAuthKeyBindingStore) Save(ctx context.Context, b domain.TempAuthKey return nil } +// DeleteExpired 实现 store.TempAuthKeyBindingStore:删除 auth_keys 中过期的 temp key, +// temp_auth_key_bindings 经 ON DELETE CASCADE 一并清除,过期 key 的入站帧随之失效。 +func (s *TempAuthKeyBindingStore) DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error) { + if limit <= 0 { + return 0, nil + } + n, err := s.q.DeleteExpiredTempAuthKeys(ctx, sqlcgen.DeleteExpiredTempAuthKeysParams{ + ExpiresAt: int32(expiredBefore), + Limit: int32(limit), + }) + if err != nil { + return 0, fmt.Errorf("delete expired temp auth keys: %w", err) + } + return int(n), nil +} + func (s *TempAuthKeyBindingStore) GetByTemp(ctx context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) { row, err := s.q.GetTempAuthKeyBinding(ctx, authKeyIDToInt64(tempAuthKeyID)) if err != nil { diff --git a/internal/store/postgres/themes.go b/internal/store/postgres/themes.go new file mode 100644 index 00000000..d40885e7 --- /dev/null +++ b/internal/store/postgres/themes.go @@ -0,0 +1,248 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" + "telesrv/internal/store/postgres/sqlcgen" +) + +// ThemeStore 用 PostgreSQL 实现 store.ThemeStore(裸 pgx,与近期功能一致,不走 sqlc)。 +type ThemeStore struct { + db sqlcgen.DBTX +} + +// NewThemeStore 基于 pgx 连接池(或事务)创建 ThemeStore。 +func NewThemeStore(db sqlcgen.DBTX) *ThemeStore { + return &ThemeStore{db: db} +} + +const themeColumns = `id, access_hash, creator_user_id, slug, title, emoticon, for_chat, document_id, settings, installs_count, created_at` + +func (s *ThemeStore) CreateTheme(ctx context.Context, t domain.Theme) error { + if t.ID == 0 || t.CreatorUserID == 0 { + return domain.ErrThemeInvalid + } + settings, err := marshalThemeSettings(t.Settings) + if err != nil { + return err + } + createdAt := time.Now() + if t.CreatedAt > 0 { + createdAt = time.Unix(t.CreatedAt, 0) + } + _, err = s.db.Exec(ctx, ` +INSERT INTO themes ( + id, access_hash, creator_user_id, slug, title, emoticon, for_chat, document_id, settings, installs_count, created_at +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, + t.ID, t.AccessHash, t.CreatorUserID, t.Slug, t.Title, t.Emoticon, t.ForChat, + t.DocumentID, settings, t.InstallsCount, createdAt, + ) + if err != nil { + if isUniqueViolation(err) { + return domain.ErrThemeSlugTaken + } + return fmt.Errorf("insert theme: %w", err) + } + return nil +} + +func scanTheme(row pgx.Row) (domain.Theme, error) { + var ( + t domain.Theme + settings []byte + createdAt time.Time + ) + if err := row.Scan( + &t.ID, &t.AccessHash, &t.CreatorUserID, &t.Slug, &t.Title, &t.Emoticon, &t.ForChat, + &t.DocumentID, &settings, &t.InstallsCount, &createdAt, + ); err != nil { + return domain.Theme{}, err + } + t.CreatedAt = createdAt.Unix() + parsed, err := unmarshalThemeSettings(settings) + if err != nil { + return domain.Theme{}, err + } + t.Settings = parsed + return t, nil +} + +func (s *ThemeStore) GetThemeByID(ctx context.Context, id int64) (domain.Theme, bool, error) { + row := s.db.QueryRow(ctx, `SELECT `+themeColumns+` FROM themes WHERE id = $1`, id) + t, err := scanTheme(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Theme{}, false, nil + } + return domain.Theme{}, false, fmt.Errorf("get theme by id: %w", err) + } + return t, true, nil +} + +func (s *ThemeStore) GetThemeBySlug(ctx context.Context, slug string) (domain.Theme, bool, error) { + if slug == "" { + return domain.Theme{}, false, nil + } + row := s.db.QueryRow(ctx, `SELECT `+themeColumns+` FROM themes WHERE slug = $1`, slug) + t, err := scanTheme(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.Theme{}, false, nil + } + return domain.Theme{}, false, fmt.Errorf("get theme by slug: %w", err) + } + return t, true, nil +} + +func (s *ThemeStore) SlugExists(ctx context.Context, slug string) (bool, error) { + if slug == "" { + return false, nil + } + var exists bool + if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM themes WHERE slug = $1)`, slug).Scan(&exists); err != nil { + return false, fmt.Errorf("theme slug exists: %w", err) + } + return exists, nil +} + +func (s *ThemeStore) UpdateTheme(ctx context.Context, t domain.Theme) error { + settings, err := marshalThemeSettings(t.Settings) + if err != nil { + return err + } + tag, err := s.db.Exec(ctx, ` +UPDATE themes SET slug = $2, title = $3, emoticon = $4, for_chat = $5, document_id = $6, settings = $7 +WHERE id = $1`, + t.ID, t.Slug, t.Title, t.Emoticon, t.ForChat, t.DocumentID, settings, + ) + if err != nil { + if isUniqueViolation(err) { + return domain.ErrThemeSlugTaken + } + return fmt.Errorf("update theme: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrThemeNotFound + } + return nil +} + +func (s *ThemeStore) IncrementInstalls(ctx context.Context, id int64) error { + tag, err := s.db.Exec(ctx, `UPDATE themes SET installs_count = installs_count + 1 WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("increment theme installs: %w", err) + } + if tag.RowsAffected() == 0 { + return domain.ErrThemeNotFound + } + return nil +} + +func (s *ThemeStore) SetInstalled(ctx context.Context, userID, themeID int64, dark bool) error { + _, err := s.db.Exec(ctx, ` +INSERT INTO theme_user_installs (user_id, theme_id, dark, installed_at) +VALUES ($1,$2,$3, now()) +ON CONFLICT (user_id, theme_id) DO UPDATE SET dark = EXCLUDED.dark`, + userID, themeID, dark, + ) + if err != nil { + if isUniqueViolation(err) { + return domain.ErrThemeNotFound // FK 缺失等同主题不存在 + } + return fmt.Errorf("set theme installed: %w", err) + } + return nil +} + +func (s *ThemeStore) RemoveInstalled(ctx context.Context, userID, themeID int64) error { + _, err := s.db.Exec(ctx, `DELETE FROM theme_user_installs WHERE user_id = $1 AND theme_id = $2`, userID, themeID) + if err != nil { + return fmt.Errorf("remove theme installed: %w", err) + } + return nil +} + +func (s *ThemeStore) ListInstalledByUser(ctx context.Context, userID int64) ([]domain.Theme, error) { + rows, err := s.db.Query(ctx, ` +SELECT `+prefixThemeColumns()+` +FROM themes t +JOIN theme_user_installs i ON i.theme_id = t.id +WHERE i.user_id = $1 +ORDER BY i.installed_at, t.id`, userID) + if err != nil { + return nil, fmt.Errorf("list installed themes: %w", err) + } + defer rows.Close() + out := make([]domain.Theme, 0) + for rows.Next() { + t, err := scanTheme(rows) + if err != nil { + return nil, fmt.Errorf("scan installed theme: %w", err) + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *ThemeStore) ListThemesForUser(ctx context.Context, userID int64) ([]domain.Theme, error) { + rows, err := s.db.Query(ctx, ` +SELECT `+themeColumns+` +FROM themes +WHERE creator_user_id = $1 + OR id IN (SELECT theme_id FROM theme_user_installs WHERE user_id = $1) +ORDER BY created_at, id`, userID) + if err != nil { + return nil, fmt.Errorf("list themes for user: %w", err) + } + defer rows.Close() + out := make([]domain.Theme, 0) + for rows.Next() { + t, err := scanTheme(rows) + if err != nil { + return nil, fmt.Errorf("scan user theme: %w", err) + } + out = append(out, t) + } + return out, rows.Err() +} + +func prefixThemeColumns() string { + parts := strings.Split(themeColumns, ", ") + for i, p := range parts { + parts[i] = "t." + p + } + return strings.Join(parts, ", ") +} + +func marshalThemeSettings(settings []domain.ThemeSettingsSpec) ([]byte, error) { + if len(settings) == 0 { + return []byte("[]"), nil + } + b, err := json.Marshal(settings) + if err != nil { + return nil, fmt.Errorf("marshal theme settings: %w", err) + } + return b, nil +} + +func unmarshalThemeSettings(b []byte) ([]domain.ThemeSettingsSpec, error) { + if len(b) == 0 { + return nil, nil + } + var out []domain.ThemeSettingsSpec + if err := json.Unmarshal(b, &out); err != nil { + return nil, fmt.Errorf("unmarshal theme settings: %w", err) + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} diff --git a/internal/store/postgres/themes_integration_test.go b/internal/store/postgres/themes_integration_test.go new file mode 100644 index 00000000..667771c4 --- /dev/null +++ b/internal/store/postgres/themes_integration_test.go @@ -0,0 +1,141 @@ +package postgres + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "testing" + + "telesrv/internal/domain" +) + +// TestThemeStoreRoundTripPostgres 回归迁移 0019:自定义云主题 + 每用户安装列表持久化, +// 含 JSONB settings 往返、slug 唯一、update 不存在、安装排序——与内存实现行为对齐。 +func TestThemeStoreRoundTripPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + store := NewThemeStore(pool) + users := NewUserStore(pool) + suffix := randomSuffix(t) + + creator, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1665" + suffix + "01", FirstName: "ThemeCreator"}) + if err != nil { + t.Fatalf("create creator: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM theme_user_installs WHERE user_id = $1", creator.ID) + _, _ = pool.Exec(ctx, "DELETE FROM themes WHERE creator_user_id = $1", creator.ID) + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", creator.ID) + }) + + a := domain.Theme{ + ID: randomThemeID(), + AccessHash: 9988, + CreatorUserID: creator.ID, + Slug: "pg-alpha-" + suffix, + Title: "Alpha", + DocumentID: 7777, + Settings: []domain.ThemeSettingsSpec{{ + BaseTheme: domain.ThemeBaseNight, + AccentColor: 0x3997d3, + OutboxAccentColor: 0x56b7e8, + HasOutboxAccent: true, + MessageColors: []int{0xd4f1ff, 0xb9e4ff}, + Wallpaper: &domain.ThemeWallpaperSpec{BackgroundColors: []int{0x111, 0x222}, Dark: true}, + }}, + CreatedAt: 1700000000, + } + if err := store.CreateTheme(ctx, a); err != nil { + t.Fatalf("create theme: %v", err) + } + + // JSONB settings 往返。 + got, ok, err := store.GetThemeByID(ctx, a.ID) + if err != nil || !ok { + t.Fatalf("get by id = ok %v err %v", ok, err) + } + if len(got.Settings) != 1 || got.Settings[0].BaseTheme != domain.ThemeBaseNight || + got.Settings[0].AccentColor != 0x3997d3 || !got.Settings[0].HasOutboxAccent || + got.Settings[0].Wallpaper == nil || !got.Settings[0].Wallpaper.Dark { + t.Fatalf("settings round-trip mismatch: %+v", got.Settings) + } + + // slug 唯一冲突 → ErrThemeSlugTaken。 + dup := a + dup.ID = randomThemeID() + if err := store.CreateTheme(ctx, dup); !errors.Is(err, domain.ErrThemeSlugTaken) { + t.Fatalf("dup slug err = %v, want ErrThemeSlugTaken", err) + } + + // get by slug。 + if bySlug, ok, _ := store.GetThemeBySlug(ctx, a.Slug); !ok || bySlug.ID != a.ID { + t.Fatalf("get by slug = ok %v id %d", ok, bySlug.ID) + } + + // update 不存在 → ErrThemeNotFound。 + ghost := a + ghost.ID = randomThemeID() + ghost.Slug = "ghost-" + suffix + if err := store.UpdateTheme(ctx, ghost); !errors.Is(err, domain.ErrThemeNotFound) { + t.Fatalf("update missing err = %v, want ErrThemeNotFound", err) + } + + // increment installs。 + if err := store.IncrementInstalls(ctx, a.ID); err != nil { + t.Fatalf("increment: %v", err) + } + if got, _, _ := store.GetThemeByID(ctx, a.ID); got.InstallsCount != 1 { + t.Fatalf("installs = %d, want 1", got.InstallsCount) + } + + // 安装列表顺序。 + b := a + b.ID = randomThemeID() + b.Slug = "pg-beta-" + suffix + if err := store.CreateTheme(ctx, b); err != nil { + t.Fatalf("create b: %v", err) + } + if err := store.SetInstalled(ctx, creator.ID, b.ID, false); err != nil { + t.Fatalf("install b: %v", err) + } + if err := store.SetInstalled(ctx, creator.ID, a.ID, true); err != nil { + t.Fatalf("install a: %v", err) + } + list, err := store.ListInstalledByUser(ctx, creator.ID) + if err != nil || len(list) != 2 { + t.Fatalf("list installed = %d err %v, want 2", len(list), err) + } + if list[0].ID != b.ID || list[1].ID != a.ID { + t.Fatalf("install order = [%d,%d], want [%d,%d]", list[0].ID, list[1].ID, b.ID, a.ID) + } + + // 移除。 + if err := store.RemoveInstalled(ctx, creator.ID, b.ID); err != nil { + t.Fatalf("remove: %v", err) + } + if list, _ := store.ListInstalledByUser(ctx, creator.ID); len(list) != 1 || list[0].ID != a.ID { + t.Fatalf("after remove = %+v, want [a]", list) + } + + // ListThemesForUser:创建 ∪ 安装。a、b 均由 creator 创建,故即便 b 已从安装列表移除, + // 仍应通过 creator 分支返回(getThemes 跨设备同步靠它)。 + forUser, err := store.ListThemesForUser(ctx, creator.ID) + if err != nil { + t.Fatalf("list themes for user: %v", err) + } + gotIDs := map[int64]bool{} + for _, t := range forUser { + gotIDs[t.ID] = true + } + if !gotIDs[a.ID] || !gotIDs[b.ID] { + t.Fatalf("ListThemesForUser = %d themes (a=%v b=%v), want both creator themes", len(forUser), gotIDs[a.ID], gotIDs[b.ID]) + } +} + +// randomThemeID 生成正 63 位 id(避免与静态目录保留区间碰撞)。 +func randomThemeID() int64 { + var b [8]byte + _, _ = rand.Read(b[:]) + return int64(binary.BigEndian.Uint64(b[:]) & 0x7fffffffffffffff) +} diff --git a/internal/store/postgres/trace.go b/internal/store/postgres/trace.go new file mode 100644 index 00000000..8c937835 --- /dev/null +++ b/internal/store/postgres/trace.go @@ -0,0 +1,22 @@ +package postgres + +import ( + "fmt" + "path/filepath" + "runtime" +) + +func traceCaller(skip int) string { + pc, file, line, ok := runtime.Caller(skip) + if !ok { + return "" + } + name := "" + if fn := runtime.FuncForPC(pc); fn != nil { + name = fn.Name() + } + if name == "" { + return fmt.Sprintf("%s:%d", filepath.ToSlash(file), line) + } + return fmt.Sprintf("%s %s:%d", name, filepath.ToSlash(file), line) +} diff --git a/internal/store/postgres/update_event.go b/internal/store/postgres/update_event.go index f3db0d5b..8871ce21 100644 --- a/internal/store/postgres/update_event.go +++ b/internal/store/postgres/update_event.go @@ -3,10 +3,10 @@ package postgres import ( "context" "encoding/json" - "errors" "fmt" "github.com/jackc/pgx/v5" + "go.uber.org/zap" "telesrv/internal/domain" "telesrv/internal/store" @@ -15,70 +15,76 @@ import ( // UpdateEventStore 用 PostgreSQL 实现 store.UpdateEventStore。 type UpdateEventStore struct { - db sqlcgen.DBTX - q *sqlcgen.Queries + db sqlcgen.DBTX + q *sqlcgen.Queries + log *zap.Logger +} + +type UpdateEventStoreOption func(*UpdateEventStore) + +// WithUpdateEventLogger 注入 durable update log 的日志器。 +func WithUpdateEventLogger(log *zap.Logger) UpdateEventStoreOption { + return func(s *UpdateEventStore) { + s.log = log + } } // NewUpdateEventStore 基于 pgx 连接池(或事务)创建 UpdateEventStore。 -func NewUpdateEventStore(db sqlcgen.DBTX) *UpdateEventStore { - return &UpdateEventStore{db: db, q: sqlcgen.New(db)} +func NewUpdateEventStore(db sqlcgen.DBTX, opts ...UpdateEventStoreOption) *UpdateEventStore { + s := &UpdateEventStore{db: db, q: sqlcgen.New(db)} + for _, opt := range opts { + opt(s) + } + if s.log == nil { + s.log = zap.NewNop() + } + return s } func (s *UpdateEventStore) Append(ctx context.Context, userID int64, event domain.UpdateEvent) error { - beginner, ok := s.db.(interface { - Begin(context.Context) (pgx.Tx, error) - }) - if !ok { - if err := appendUserUpdateEvent(ctx, s.q, userID, event); err != nil { - return fmt.Errorf("append update event: %w", err) - } - return nil - } - tx, err := beginner.Begin(ctx) - if err != nil { - return fmt.Errorf("begin append update event: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - if err := appendUserUpdateEvent(ctx, sqlcgen.New(tx), userID, event); err != nil { - return fmt.Errorf("append update event: %w", err) - } - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("commit append update event: %w", err) - } - committed = true - return nil + _, err := s.append(ctx, userID, event, false, [8]byte{}, 0, false) + return err } -// AppendWithDispatch 将账号级 update 事件与在线投递 outbox 放入同一个 PG 事务。 -// 设置类 RPC 不像消息发送那样已有业务大事务;这里至少保证“事件已持久化”与 -// “可靠在线投递任务已入队”同生共死,避免进程在手动 push 前退出造成在线通知漏投。 -func (s *UpdateEventStore) AppendWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) error { +func (s *UpdateEventStore) AppendAllocated(ctx context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, error) { + return s.append(ctx, userID, event, false, [8]byte{}, 0, true) +} + +// AppendAllocatedWithDispatch 在同一个 PG 事务里完成 pts 分配、durable event 写入与 +// dispatch outbox 入队,返回带最终 pts 的事件。 +func (s *UpdateEventStore) AppendAllocatedWithDispatch(ctx context.Context, userID int64, event domain.UpdateEvent, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, error) { + return s.append(ctx, userID, event, true, excludeAuthKeyID, excludeSessionID, true) +} + +func (s *UpdateEventStore) append(ctx context.Context, userID int64, event domain.UpdateEvent, dispatch bool, excludeAuthKeyID [8]byte, excludeSessionID int64, allocate bool) (domain.UpdateEvent, error) { + if event.PtsCount <= 0 { + event.PtsCount = 1 + } beginner, ok := s.db.(interface { Begin(context.Context) (pgx.Tx, error) }) if !ok { - if err := s.Append(ctx, userID, event); err != nil { - return err + event, err := s.appendInTx(ctx, s.db, s.q, userID, event, dispatch, excludeAuthKeyID, excludeSessionID, allocate) + if err != nil { + s.logAppendFailure(ctx, userID, event, dispatch, "append", err) + return domain.UpdateEvent{}, err } - if err := s.q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ - TargetUserID: userID, - Pts: int32(event.Pts), - EventType: string(event.Type), - ExcludeAuthKeyID: authKeyIDToInt64(excludeAuthKeyID), - ExcludeSessionID: excludeSessionID, - }); err != nil { - return fmt.Errorf("enqueue dispatch: %w", err) - } - return nil + s.logAppendSuccess(userID, event, dispatch, excludeSessionID) + return event, nil } tx, err := beginner.Begin(ctx) if err != nil { - return fmt.Errorf("begin append update dispatch: %w", err) + s.log.Warn("update_event_append_failed", + zap.String("scope", "user"), + zap.Int64("user_id", userID), + zap.Int("pts", event.Pts), + zap.Int("pts_count", event.PtsCount), + zap.String("event_type", string(event.Type)), + zap.String("phase", "begin"), + zap.Error(err), + zap.Error(ctx.Err()), + ) + return domain.UpdateEvent{}, fmt.Errorf("begin append update event: %w", err) } committed := false defer func() { @@ -87,26 +93,75 @@ func (s *UpdateEventStore) AppendWithDispatch(ctx context.Context, userID int64, } }() qtx := sqlcgen.New(tx) - if err := appendUserUpdateEvent(ctx, qtx, userID, event); err != nil { - return fmt.Errorf("append update event: %w", err) - } - if err := qtx.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ - TargetUserID: userID, - Pts: int32(event.Pts), - EventType: string(event.Type), - ExcludeAuthKeyID: authKeyIDToInt64(excludeAuthKeyID), - ExcludeSessionID: excludeSessionID, - }); err != nil { - return fmt.Errorf("enqueue dispatch: %w", err) + event, err = s.appendInTx(ctx, tx, qtx, userID, event, dispatch, excludeAuthKeyID, excludeSessionID, allocate) + if err != nil { + s.logAppendFailure(ctx, userID, event, dispatch, "append", err) + return domain.UpdateEvent{}, err } if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("commit append update dispatch: %w", err) + s.logAppendFailure(ctx, userID, event, dispatch, "commit", err) + return domain.UpdateEvent{}, fmt.Errorf("commit append update event: %w", err) } committed = true - return nil + s.logAppendSuccess(userID, event, dispatch, excludeSessionID) + return event, nil } -func appendUserUpdateEvent(ctx context.Context, q *sqlcgen.Queries, userID int64, event domain.UpdateEvent) error { +func (s *UpdateEventStore) appendInTx(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, event domain.UpdateEvent, dispatch bool, excludeAuthKeyID [8]byte, excludeSessionID int64, allocate bool) (domain.UpdateEvent, error) { + var err error + if allocate || event.Pts == 0 { + event.Pts, err = reserveUserPts(ctx, db, userID, event.PtsCount) + if err != nil { + return domain.UpdateEvent{}, fmt.Errorf("reserve user pts: %w", err) + } + } else if err := advanceUserPtsTo(ctx, db, userID, event.Pts, event.PtsCount); err != nil { + return domain.UpdateEvent{}, err + } + event.UserID = userID + if err := appendUserUpdateEvent(ctx, db, q, userID, event); err != nil { + return domain.UpdateEvent{}, fmt.Errorf("append update event: %w", err) + } + if dispatch { + if err := q.EnqueueDispatch(ctx, sqlcgen.EnqueueDispatchParams{ + TargetUserID: userID, + Pts: int32(event.Pts), + EventType: string(event.Type), + ExcludeAuthKeyID: authKeyIDToInt64(excludeAuthKeyID), + ExcludeSessionID: excludeSessionID, + }); err != nil { + return domain.UpdateEvent{}, fmt.Errorf("enqueue dispatch: %w", err) + } + } + return event, nil +} + +func (s *UpdateEventStore) logAppendFailure(ctx context.Context, userID int64, event domain.UpdateEvent, dispatch bool, phase string, err error) { + s.log.Warn("update_event_append_failed", + zap.String("scope", "user"), + zap.Int64("user_id", userID), + zap.Int("pts", event.Pts), + zap.Int("pts_count", event.PtsCount), + zap.String("event_type", string(event.Type)), + zap.String("phase", phase), + zap.Bool("dispatch", dispatch), + zap.Error(err), + zap.Error(ctx.Err()), + ) +} + +func (s *UpdateEventStore) logAppendSuccess(userID int64, event domain.UpdateEvent, dispatch bool, excludeSessionID int64) { + s.log.Debug("update_event_appended", + zap.String("scope", "user"), + zap.Int64("user_id", userID), + zap.Int("pts", event.Pts), + zap.Int("pts_count", event.PtsCount), + zap.String("event_type", string(event.Type)), + zap.Bool("dispatch", dispatch), + zap.Int64("exclude_session_id", excludeSessionID), + ) +} + +func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, userID int64, event domain.UpdateEvent) error { var messageID *int32 if event.Message.ID != 0 { id := int32(event.Message.ID) @@ -148,6 +203,14 @@ func appendUserUpdateEvent(ctx context.Context, q *sqlcgen.Queries, userID int64 if err != nil { return err } + storyPayload, err := encodeEventStory(event.Story) + if err != nil { + return err + } + reactionPayload, err := encodeEventReaction(event.Reaction) + if err != nil { + return err + } if err := q.AppendUserUpdateEvent(ctx, sqlcgen.AppendUserUpdateEventParams{ UserID: userID, Pts: int32(event.Pts), @@ -161,18 +224,90 @@ func appendUserUpdateEvent(ctx context.Context, q *sqlcgen.Queries, userID int64 DialogFilter: dialogFilter, FilterOrder: filterOrder, FolderPeers: folderPeers, + StoryPayload: storyPayload, + ReactionPayload: reactionPayload, MaxID: pgInt32NonNegative(event.MaxID), StillUnreadCount: int32(event.StillUnreadCount), + ChannelPts: int32(event.ChannelPts), FilterID: pgInt32NonNegative(event.FilterID), TagsEnabled: event.TagsEnabled, + FolderID: pgInt32NonNegative(event.FolderID), MessageBoxID: messageID, PeerType: peerType, PeerID: peerID, }); err != nil { return err } - if _, err := advanceContiguousPts(ctx, q, userID); err != nil { - return fmt.Errorf("advance update watermark: %w", err) + if err := appendQuickReplyPayload(ctx, db, userID, event); err != nil { + return err + } + return nil +} + +func appendQuickReplyPayload(ctx context.Context, db sqlcgen.DBTX, userID int64, event domain.UpdateEvent) error { + switch event.Type { + case domain.UpdateEventQuickReplies, + domain.UpdateEventNewQuickReply, + domain.UpdateEventDeleteQuickReply, + domain.UpdateEventQuickReplyMessage, + domain.UpdateEventDeleteQuickReplyMessages: + default: + return nil + } + replies, err := json.Marshal(event.QuickReplies) + if err != nil { + return fmt.Errorf("encode quick replies: %w", err) + } + message, err := json.Marshal(event.QuickReplyMessage) + if err != nil { + return fmt.Errorf("encode quick reply message: %w", err) + } + if _, err := db.Exec(ctx, ` +UPDATE user_update_events +SET quick_replies = $3::jsonb, + quick_reply_message = $4::jsonb +WHERE user_id = $1 + AND pts = $2`, userID, event.Pts, string(replies), string(message)); err != nil { + return fmt.Errorf("save quick reply update payload: %w", err) + } + return nil +} + +func (s *UpdateEventStore) hydrateQuickReplyEvent(ctx context.Context, event *domain.UpdateEvent) error { + if event == nil { + return nil + } + switch event.Type { + case domain.UpdateEventQuickReplies, + domain.UpdateEventNewQuickReply, + domain.UpdateEventQuickReplyMessage, + domain.UpdateEventDeleteQuickReply: + default: + return nil + } + var repliesJSON, messageJSON string + if err := s.db.QueryRow(ctx, ` +SELECT + COALESCE(quick_replies::text, '[]')::text, + COALESCE(quick_reply_message::text, '{}')::text +FROM user_update_events +WHERE user_id = $1 + AND pts = $2`, event.UserID, event.Pts).Scan(&repliesJSON, &messageJSON); err != nil { + return fmt.Errorf("get quick reply update payload: %w", err) + } + if err := json.Unmarshal([]byte(repliesJSON), &event.QuickReplies); err != nil { + return fmt.Errorf("decode quick replies: %w", err) + } + if err := json.Unmarshal([]byte(messageJSON), &event.QuickReplyMessage); err != nil { + return fmt.Errorf("decode quick reply message: %w", err) + } + if event.Type == domain.UpdateEventNewQuickReply && event.QuickReply.ID == 0 { + for _, item := range event.QuickReplies { + if item.ID == event.MaxID { + event.QuickReply = item + break + } + } } return nil } @@ -202,6 +337,7 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim row.ReplyToPeerType, row.ReplyToPeerID, row.ReplyToTopID, + row.ReplyToStoryID, row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, @@ -209,6 +345,9 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim row.FwdFromPeerID, row.FwdFromName, row.FwdDate, + row.FwdSavedFromPeerType, + row.FwdSavedFromPeerID, + row.FwdSavedFromMsgID, ) if err != nil { return nil, fmt.Errorf("decode message metadata: %w", err) @@ -237,28 +376,48 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim if err != nil { return nil, fmt.Errorf("decode folder peers: %w", err) } + story, err := decodeEventStory(row.StoryPayloadJson) + if err != nil { + return nil, fmt.Errorf("decode story payload: %w", err) + } + reaction, err := decodeEventReaction(row.ReactionPayloadJson) + if err != nil { + return nil, fmt.Errorf("decode reaction payload: %w", err) + } media, err := decodeMessageMedia(row.MediaJson) if err != nil { return nil, fmt.Errorf("decode message media: %w", err) } - out = append(out, domain.UpdateEvent{ + markup, err := decodeReplyMarkup(row.ReplyMarkupJson) + if err != nil { + return nil, fmt.Errorf("decode message reply markup: %w", err) + } + rich, err := decodeRichMessage(row.RichMessageJson) + if err != nil { + return nil, fmt.Errorf("decode message rich message: %w", err) + } + event := domain.UpdateEvent{ UserID: row.UserID, Type: domain.UpdateEventType(row.EventType), Pts: int(row.Pts), PtsCount: int(row.PtsCount), Date: int(row.Date), Peer: domain.Peer{Type: domain.PeerType(row.EventPeerType), ID: row.EventPeerID}, + Story: story, Peers: peers, Bool: row.EventBool, Settings: settings, MessageIDs: messageIDs, MaxID: int(row.MaxID), StillUnreadCount: int(row.StillUnreadCount), + ChannelPts: int(row.ChannelPts), FilterID: int(row.FilterID), DialogFilter: dialogFilter, FilterOrder: filterOrder, FolderPeers: folderPeers, TagsEnabled: row.TagsEnabled, + FolderID: int(row.FolderID), + Reaction: reaction, Message: domain.Message{ ID: int(row.MessageID), UID: row.PrivateMessageID, @@ -275,160 +434,39 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim ReplyTo: reply, Forward: forward, Media: media, + ReplyMarkup: markup, + RichMessage: rich, MediaUnread: row.MediaUnread, ReactionUnread: row.ReactionUnread, + ViaBotID: row.ViaBotID, + GroupedID: row.GroupedID, + Effect: row.Effect, + Pinned: row.Pinned, + SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), + TTLPeriod: int(row.TtlPeriod), + ExpiresAt: int(row.ExpiresAt), }, - Users: usersFromUpdateEventRow(row), - Channels: channelsFromUpdateEventRow(row), - }) + Users: usersFromUpdateEventRow(row), + } + if err := s.hydrateQuickReplyEvent(ctx, &event); err != nil { + return nil, err + } + out = append(out, event) } return out, nil } -func (s *UpdateEventStore) Current(ctx context.Context, userID int64) (int, error) { - pts, err := s.q.MaxUserPts(ctx, userID) - if err != nil { - return 0, fmt.Errorf("max user pts: %w", err) - } - return int(pts), nil -} - -func (s *UpdateEventStore) AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) { - beginner, ok := s.db.(interface { - Begin(context.Context) (pgx.Tx, error) - }) - if !ok { - pts, err := advanceContiguousPts(ctx, s.q, userID) - if err != nil { - return 0, fmt.Errorf("advance update watermark: %w", err) - } - return pts, nil - } - tx, err := beginner.Begin(ctx) - if err != nil { - return 0, fmt.Errorf("begin advance update watermark: %w", err) - } - committed := false - defer func() { - if !committed { - _ = tx.Rollback(ctx) - } - }() - pts, err := advanceContiguousPts(ctx, sqlcgen.New(tx), userID) - if err != nil { - return 0, fmt.Errorf("advance update watermark: %w", err) - } - if err := tx.Commit(ctx); err != nil { - return 0, fmt.Errorf("commit advance update watermark: %w", err) - } - committed = true - return pts, nil -} - -// contiguousWindow 是计算最大连续 pts 时回看的顶部 pts 数量。 -// 瞬时空洞只来自最近在途的发送事务(提交即填实、回退即补 noop),单用户在途量远小于此, -// 故窗口内若无空洞即可认定窗口下方连续。生产极端高 fan-in 可调大。 -const contiguousWindow = 4096 - -// MaxContiguousPts 见 store.UpdateEventStore 接口说明。正常路径 O(1) 读账号水位; -// 缺行通常来自迁移前数据,允许一次性从 durable 事件计算并补写。 +// MaxContiguousPts 见 store.UpdateEventStore 接口说明。PG 写路径保证水位与 +// durable event 同事务提交;缺行代表该账号还没有 update。 func (s *UpdateEventStore) MaxContiguousPts(ctx context.Context, userID int64) (int, error) { pts, err := s.q.GetUserUpdateWatermark(ctx, userID) if err == nil { return int(pts), nil } - if !errors.Is(err, pgx.ErrNoRows) { + if err != pgx.ErrNoRows { return 0, fmt.Errorf("get update watermark: %w", err) } - return s.AdvanceContiguousPts(ctx, userID) -} - -func advanceContiguousPts(ctx context.Context, q *sqlcgen.Queries, userID int64) (int, error) { - if err := q.EnsureUserUpdateWatermark(ctx, userID); err != nil { - return 0, err - } - locked, err := q.LockUserUpdateWatermark(ctx, userID) - if err != nil { - return 0, err - } - contiguous := int(locked) - for { - rows, err := q.NextUserPtsAfter(ctx, sqlcgen.NextUserPtsAfterParams{ - UserID: userID, - Pts: int32(contiguous), - LimitCount: contiguousWindow, - }) - if err != nil { - return 0, err - } - if len(rows) == 0 { - break - } - advanced := false - for _, row := range rows { - count := maxInt(int(row.PtsCount), 1) - expected := contiguous + count - if int(row.Pts) != expected { - if contiguous > int(locked) { - if err := saveUserUpdateWatermark(ctx, q, userID, contiguous); err != nil { - return 0, err - } - } - return contiguous, nil - } - contiguous = int(row.Pts) - advanced = true - } - if len(rows) < contiguousWindow || !advanced { - break - } - } - if contiguous > int(locked) { - if err := saveUserUpdateWatermark(ctx, q, userID, contiguous); err != nil { - return 0, err - } - } - return contiguous, nil -} - -func saveUserUpdateWatermark(ctx context.Context, q *sqlcgen.Queries, userID int64, contiguous int) error { - return q.SaveUserUpdateWatermark(ctx, sqlcgen.SaveUserUpdateWatermarkParams{ - UserID: userID, - ContiguousPts: int32(contiguous), - }) -} - -func computeContiguousPtsFromRecent(ctx context.Context, q *sqlcgen.Queries, userID int64) (int, error) { - rows, err := q.RecentUserPts(ctx, sqlcgen.RecentUserPtsParams{ - UserID: userID, - WindowSize: contiguousWindow, - }) - if err != nil { - return 0, fmt.Errorf("recent user pts: %w", err) - } - if len(rows) == 0 { - return 0, nil - } - nextByStart := make(map[int]int, len(rows)) - floor := int(rows[0].Pts) - maxInt(int(rows[0].PtsCount), 1) - for _, p := range rows { - count := maxInt(int(p.PtsCount), 1) - v := int(p.Pts) - start := v - count - nextByStart[start] = v - if start < floor { - floor = start - } - } - contiguous := floor - for { - next, ok := nextByStart[contiguous] - if !ok { - break - } - contiguous = next - } - return contiguous, nil + return 0, nil } // BatchByCursor 按 (user_id, pts) 一次性批量取多条账号事件,供 outbox worker 取代逐条 ListAfter。 @@ -463,6 +501,7 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev row.ReplyToPeerType, row.ReplyToPeerID, row.ReplyToTopID, + row.ReplyToStoryID, row.QuoteText, row.QuoteEntitiesJson, row.QuoteOffset, @@ -470,6 +509,9 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev row.FwdFromPeerID, row.FwdFromName, row.FwdDate, + row.FwdSavedFromPeerType, + row.FwdSavedFromPeerID, + row.FwdSavedFromMsgID, ) if err != nil { return nil, fmt.Errorf("decode message metadata: %w", err) @@ -498,28 +540,48 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev if err != nil { return nil, fmt.Errorf("decode folder peers: %w", err) } + story, err := decodeEventStory(row.StoryPayloadJson) + if err != nil { + return nil, fmt.Errorf("decode story payload: %w", err) + } + reaction, err := decodeEventReaction(row.ReactionPayloadJson) + if err != nil { + return nil, fmt.Errorf("decode reaction payload: %w", err) + } media, err := decodeMessageMedia(row.MediaJson) if err != nil { return nil, fmt.Errorf("decode message media: %w", err) } - out = append(out, domain.UpdateEvent{ + markup, err := decodeReplyMarkup(row.ReplyMarkupJson) + if err != nil { + return nil, fmt.Errorf("decode message reply markup: %w", err) + } + rich, err := decodeRichMessage(row.RichMessageJson) + if err != nil { + return nil, fmt.Errorf("decode message rich message: %w", err) + } + event := domain.UpdateEvent{ UserID: row.UserID, Type: domain.UpdateEventType(row.EventType), Pts: int(row.Pts), PtsCount: int(row.PtsCount), Date: int(row.Date), Peer: domain.Peer{Type: domain.PeerType(row.EventPeerType), ID: row.EventPeerID}, + Story: story, Peers: peers, Bool: row.EventBool, Settings: settings, MessageIDs: messageIDs, MaxID: int(row.MaxID), StillUnreadCount: int(row.StillUnreadCount), + ChannelPts: int(row.ChannelPts), FilterID: int(row.FilterID), DialogFilter: dialogFilter, FilterOrder: filterOrder, FolderPeers: folderPeers, TagsEnabled: row.TagsEnabled, + FolderID: int(row.FolderID), + Reaction: reaction, Message: domain.Message{ ID: int(row.MessageID), UID: row.PrivateMessageID, @@ -536,12 +598,24 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev ReplyTo: reply, Forward: forward, Media: media, + ReplyMarkup: markup, + RichMessage: rich, MediaUnread: row.MediaUnread, ReactionUnread: row.ReactionUnread, + ViaBotID: row.ViaBotID, + GroupedID: row.GroupedID, + Effect: row.Effect, + Pinned: row.Pinned, + SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID), + TTLPeriod: int(row.TtlPeriod), + ExpiresAt: int(row.ExpiresAt), }, - Users: usersFromBatchDispatchRow(row), - Channels: channelsFromBatchDispatchRow(row), - }) + Users: usersFromBatchDispatchRow(row), + } + if err := s.hydrateQuickReplyEvent(ctx, &event); err != nil { + return nil, err + } + out = append(out, event) } return out, nil } @@ -549,48 +623,68 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev func usersFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.User { return mergeEventUsers( domain.User{ - ID: row.PeerUserID, - AccessHash: row.PeerAccessHash, - Phone: row.PeerPhone, - FirstName: row.PeerFirstName, - LastName: row.PeerLastName, - Username: row.PeerUsername, - CountryCode: row.PeerCountryCode, - Verified: row.PeerVerified, - Support: row.PeerSupport, + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + Bot: row.PeerIsBot, + BotInfoVersion: int(row.PeerBotInfoVersion), + PremiumUntil: int(row.PeerPremiumUntil), + EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID, + EmojiStatusUntil: int(row.PeerEmojiStatusUntil), }, domain.User{ - ID: row.FromUserUserID, - AccessHash: row.FromUserAccessHash, - Phone: row.FromUserPhone, - FirstName: row.FromUserFirstName, - LastName: row.FromUserLastName, - Username: row.FromUserUsername, - CountryCode: row.FromUserCountryCode, - Verified: row.FromUserVerified, - Support: row.FromUserSupport, + ID: row.FromUserUserID, + AccessHash: row.FromUserAccessHash, + Phone: row.FromUserPhone, + FirstName: row.FromUserFirstName, + LastName: row.FromUserLastName, + Username: row.FromUserUsername, + CountryCode: row.FromUserCountryCode, + Verified: row.FromUserVerified, + Support: row.FromUserSupport, + Bot: row.FromUserIsBot, + BotInfoVersion: int(row.FromUserBotInfoVersion), + PremiumUntil: int(row.FromUserPremiumUntil), + EmojiStatusDocumentID: row.FromUserEmojiStatusDocumentID, + EmojiStatusUntil: int(row.FromUserEmojiStatusUntil), }, domain.User{ - ID: row.FwdUserID, - AccessHash: row.FwdUserAccessHash, - Phone: row.FwdUserPhone, - FirstName: row.FwdUserFirstName, - LastName: row.FwdUserLastName, - Username: row.FwdUserUsername, - CountryCode: row.FwdUserCountryCode, - Verified: row.FwdUserVerified, - Support: row.FwdUserSupport, + ID: row.FwdUserID, + AccessHash: row.FwdUserAccessHash, + Phone: row.FwdUserPhone, + FirstName: row.FwdUserFirstName, + LastName: row.FwdUserLastName, + Username: row.FwdUserUsername, + CountryCode: row.FwdUserCountryCode, + Verified: row.FwdUserVerified, + Support: row.FwdUserSupport, + Bot: row.FwdUserIsBot, + BotInfoVersion: int(row.FwdUserBotInfoVersion), + PremiumUntil: int(row.FwdUserPremiumUntil), + EmojiStatusDocumentID: row.FwdUserEmojiStatusDocumentID, + EmojiStatusUntil: int(row.FwdUserEmojiStatusUntil), }, domain.User{ - ID: row.ReplyUserID, - AccessHash: row.ReplyUserAccessHash, - Phone: row.ReplyUserPhone, - FirstName: row.ReplyUserFirstName, - LastName: row.ReplyUserLastName, - Username: row.ReplyUserUsername, - CountryCode: row.ReplyUserCountryCode, - Verified: row.ReplyUserVerified, - Support: row.ReplyUserSupport, + ID: row.ReplyUserID, + AccessHash: row.ReplyUserAccessHash, + Phone: row.ReplyUserPhone, + FirstName: row.ReplyUserFirstName, + LastName: row.ReplyUserLastName, + Username: row.ReplyUserUsername, + CountryCode: row.ReplyUserCountryCode, + Verified: row.ReplyUserVerified, + Support: row.ReplyUserSupport, + Bot: row.ReplyUserIsBot, + BotInfoVersion: int(row.ReplyUserBotInfoVersion), + PremiumUntil: int(row.ReplyUserPremiumUntil), + EmojiStatusDocumentID: row.ReplyUserEmojiStatusDocumentID, + EmojiStatusUntil: int(row.ReplyUserEmojiStatusUntil), }, ) } @@ -600,90 +694,72 @@ func usersFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain. func usersFromBatchDispatchRow(row sqlcgen.BatchListDispatchEventsRow) []domain.User { return mergeEventUsers( domain.User{ - ID: row.PeerUserID, - AccessHash: row.PeerAccessHash, - Phone: row.PeerPhone, - FirstName: row.PeerFirstName, - LastName: row.PeerLastName, - Username: row.PeerUsername, - CountryCode: row.PeerCountryCode, - Verified: row.PeerVerified, - Support: row.PeerSupport, + ID: row.PeerUserID, + AccessHash: row.PeerAccessHash, + Phone: row.PeerPhone, + FirstName: row.PeerFirstName, + LastName: row.PeerLastName, + Username: row.PeerUsername, + CountryCode: row.PeerCountryCode, + Verified: row.PeerVerified, + Support: row.PeerSupport, + Bot: row.PeerIsBot, + BotInfoVersion: int(row.PeerBotInfoVersion), + PremiumUntil: int(row.PeerPremiumUntil), + EmojiStatusDocumentID: row.PeerEmojiStatusDocumentID, + EmojiStatusUntil: int(row.PeerEmojiStatusUntil), }, domain.User{ - ID: row.FromUserUserID, - AccessHash: row.FromUserAccessHash, - Phone: row.FromUserPhone, - FirstName: row.FromUserFirstName, - LastName: row.FromUserLastName, - Username: row.FromUserUsername, - CountryCode: row.FromUserCountryCode, - Verified: row.FromUserVerified, - Support: row.FromUserSupport, + ID: row.FromUserUserID, + AccessHash: row.FromUserAccessHash, + Phone: row.FromUserPhone, + FirstName: row.FromUserFirstName, + LastName: row.FromUserLastName, + Username: row.FromUserUsername, + CountryCode: row.FromUserCountryCode, + Verified: row.FromUserVerified, + Support: row.FromUserSupport, + Bot: row.FromUserIsBot, + BotInfoVersion: int(row.FromUserBotInfoVersion), + PremiumUntil: int(row.FromUserPremiumUntil), + EmojiStatusDocumentID: row.FromUserEmojiStatusDocumentID, + EmojiStatusUntil: int(row.FromUserEmojiStatusUntil), }, domain.User{ - ID: row.FwdUserID, - AccessHash: row.FwdUserAccessHash, - Phone: row.FwdUserPhone, - FirstName: row.FwdUserFirstName, - LastName: row.FwdUserLastName, - Username: row.FwdUserUsername, - CountryCode: row.FwdUserCountryCode, - Verified: row.FwdUserVerified, - Support: row.FwdUserSupport, + ID: row.FwdUserID, + AccessHash: row.FwdUserAccessHash, + Phone: row.FwdUserPhone, + FirstName: row.FwdUserFirstName, + LastName: row.FwdUserLastName, + Username: row.FwdUserUsername, + CountryCode: row.FwdUserCountryCode, + Verified: row.FwdUserVerified, + Support: row.FwdUserSupport, + Bot: row.FwdUserIsBot, + BotInfoVersion: int(row.FwdUserBotInfoVersion), + PremiumUntil: int(row.FwdUserPremiumUntil), + EmojiStatusDocumentID: row.FwdUserEmojiStatusDocumentID, + EmojiStatusUntil: int(row.FwdUserEmojiStatusUntil), }, domain.User{ - ID: row.ReplyUserID, - AccessHash: row.ReplyUserAccessHash, - Phone: row.ReplyUserPhone, - FirstName: row.ReplyUserFirstName, - LastName: row.ReplyUserLastName, - Username: row.ReplyUserUsername, - CountryCode: row.ReplyUserCountryCode, - Verified: row.ReplyUserVerified, - Support: row.ReplyUserSupport, + ID: row.ReplyUserID, + AccessHash: row.ReplyUserAccessHash, + Phone: row.ReplyUserPhone, + FirstName: row.ReplyUserFirstName, + LastName: row.ReplyUserLastName, + Username: row.ReplyUserUsername, + CountryCode: row.ReplyUserCountryCode, + Verified: row.ReplyUserVerified, + Support: row.ReplyUserSupport, + Bot: row.ReplyUserIsBot, + BotInfoVersion: int(row.ReplyUserBotInfoVersion), + PremiumUntil: int(row.ReplyUserPremiumUntil), + EmojiStatusDocumentID: row.ReplyUserEmojiStatusDocumentID, + EmojiStatusUntil: int(row.ReplyUserEmojiStatusUntil), }, ) } -func channelsFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.Channel { - return mergeEventChannels( - eventChannelFromFields( - row.FwdChannelID, row.FwdChannelAccessHash, row.FwdChannelCreatorUserID, row.FwdChannelTitle, row.FwdChannelAbout, row.FwdChannelUsername, - row.FwdChannelBroadcast, row.FwdChannelMegagroup, row.FwdChannelForum, row.FwdChannelNoforwards, row.FwdChannelSignatures, row.FwdChannelPreHistoryHidden, - int(row.FwdChannelSlowmodeSeconds), row.FwdChannelDefaultBannedRights, int(row.FwdChannelParticipantsCount), int(row.FwdChannelAdminsCount), - int(row.FwdChannelKickedCount), int(row.FwdChannelBannedCount), int(row.FwdChannelTopMessageID), int(row.FwdChannelPinnedMessageID), - int(row.FwdChannelPts), int(row.FwdChannelTtlPeriod), int(row.FwdChannelDate), row.FwdChannelDeleted, - ), - eventChannelFromFields( - row.ReplyChannelID, row.ReplyChannelAccessHash, row.ReplyChannelCreatorUserID, row.ReplyChannelTitle, row.ReplyChannelAbout, row.ReplyChannelUsername, - row.ReplyChannelBroadcast, row.ReplyChannelMegagroup, row.ReplyChannelForum, row.ReplyChannelNoforwards, row.ReplyChannelSignatures, row.ReplyChannelPreHistoryHidden, - int(row.ReplyChannelSlowmodeSeconds), row.ReplyChannelDefaultBannedRights, int(row.ReplyChannelParticipantsCount), int(row.ReplyChannelAdminsCount), - int(row.ReplyChannelKickedCount), int(row.ReplyChannelBannedCount), int(row.ReplyChannelTopMessageID), int(row.ReplyChannelPinnedMessageID), - int(row.ReplyChannelPts), int(row.ReplyChannelTtlPeriod), int(row.ReplyChannelDate), row.ReplyChannelDeleted, - ), - ) -} - -func channelsFromBatchDispatchRow(row sqlcgen.BatchListDispatchEventsRow) []domain.Channel { - return mergeEventChannels( - eventChannelFromFields( - row.FwdChannelID, row.FwdChannelAccessHash, row.FwdChannelCreatorUserID, row.FwdChannelTitle, row.FwdChannelAbout, row.FwdChannelUsername, - row.FwdChannelBroadcast, row.FwdChannelMegagroup, row.FwdChannelForum, row.FwdChannelNoforwards, row.FwdChannelSignatures, row.FwdChannelPreHistoryHidden, - int(row.FwdChannelSlowmodeSeconds), row.FwdChannelDefaultBannedRights, int(row.FwdChannelParticipantsCount), int(row.FwdChannelAdminsCount), - int(row.FwdChannelKickedCount), int(row.FwdChannelBannedCount), int(row.FwdChannelTopMessageID), int(row.FwdChannelPinnedMessageID), - int(row.FwdChannelPts), int(row.FwdChannelTtlPeriod), int(row.FwdChannelDate), row.FwdChannelDeleted, - ), - eventChannelFromFields( - row.ReplyChannelID, row.ReplyChannelAccessHash, row.ReplyChannelCreatorUserID, row.ReplyChannelTitle, row.ReplyChannelAbout, row.ReplyChannelUsername, - row.ReplyChannelBroadcast, row.ReplyChannelMegagroup, row.ReplyChannelForum, row.ReplyChannelNoforwards, row.ReplyChannelSignatures, row.ReplyChannelPreHistoryHidden, - int(row.ReplyChannelSlowmodeSeconds), row.ReplyChannelDefaultBannedRights, int(row.ReplyChannelParticipantsCount), int(row.ReplyChannelAdminsCount), - int(row.ReplyChannelKickedCount), int(row.ReplyChannelBannedCount), int(row.ReplyChannelTopMessageID), int(row.ReplyChannelPinnedMessageID), - int(row.ReplyChannelPts), int(row.ReplyChannelTtlPeriod), int(row.ReplyChannelDate), row.ReplyChannelDeleted, - ), - ) -} - // mergeEventUsers 合并事件依赖用户,跳过 ID=0 并按 ID 去重。 func mergeEventUsers(items ...domain.User) []domain.User { users := make([]domain.User, 0, len(items)) @@ -704,55 +780,6 @@ func mergeEventUsers(items ...domain.User) []domain.User { return users } -func eventChannelFromFields(id, accessHash, creatorUserID int64, title, about, username string, broadcast, megagroup, forum, noforwards, signatures, preHistoryHidden bool, slowmodeSeconds int, defaultRights string, participantsCount, adminsCount, kickedCount, bannedCount, topMessageID, pinnedMessageID, pts, ttlPeriod, date int, deleted bool) domain.Channel { - if id == 0 { - return domain.Channel{} - } - ch := domain.Channel{ - ID: id, - AccessHash: accessHash, - CreatorUserID: creatorUserID, - Title: title, - About: about, - Username: username, - Broadcast: broadcast, - Megagroup: megagroup, - Forum: forum, - NoForwards: noforwards, - Signatures: signatures, - PreHistoryHidden: preHistoryHidden, - SlowmodeSeconds: slowmodeSeconds, - ParticipantsCount: participantsCount, - AdminsCount: adminsCount, - KickedCount: kickedCount, - BannedCount: bannedCount, - TopMessageID: topMessageID, - PinnedMessageID: pinnedMessageID, - Pts: pts, - TTLPeriod: ttlPeriod, - Date: date, - Deleted: deleted, - } - _ = json.Unmarshal([]byte(defaultRights), &ch.DefaultBannedRights) - return ch -} - -func mergeEventChannels(items ...domain.Channel) []domain.Channel { - channels := make([]domain.Channel, 0, len(items)) - seen := make(map[int64]struct{}, len(items)) - for _, ch := range items { - if ch.ID == 0 { - continue - } - if _, ok := seen[ch.ID]; ok { - continue - } - seen[ch.ID] = struct{}{} - channels = append(channels, ch) - } - return channels -} - type eventPeerJSON struct { Type string `json:"type"` ID int64 `json:"id"` @@ -882,12 +909,53 @@ func decodeEventFolderPeers(raw string) ([]domain.FolderPeerUpdate, error) { return peers, nil } +func encodeEventStory(story domain.Story) ([]byte, error) { + if story.Owner.ID == 0 || story.ID == 0 { + return []byte("{}"), nil + } + raw, err := json.Marshal(story) + if err != nil { + return nil, fmt.Errorf("marshal event story: %w", err) + } + return raw, nil +} + +func decodeEventStory(raw string) (domain.Story, error) { + if raw == "" || raw == "{}" || raw == "null" { + return domain.Story{}, nil + } + var story domain.Story + if err := json.Unmarshal([]byte(raw), &story); err != nil { + return domain.Story{}, err + } + if story.Owner.ID == 0 || story.ID == 0 { + return domain.Story{}, nil + } + return story, nil +} + +func encodeEventReaction(reaction *domain.MessageReaction) ([]byte, error) { + raw, err := encodeStoryReaction(reaction) + if err != nil { + return nil, fmt.Errorf("marshal event reaction: %w", err) + } + return raw, nil +} + +func decodeEventReaction(raw string) (*domain.MessageReaction, error) { + return decodeStoryReaction(raw) +} + type peerSettingsJSON struct { - AddContact bool `json:"add_contact,omitempty"` - BlockContact bool `json:"block_contact,omitempty"` - ShareContact bool `json:"share_contact,omitempty"` - NeedContactsException bool `json:"need_contacts_exception,omitempty"` - HiddenPeerSettingsBar bool `json:"hidden_peer_settings_bar,omitempty"` + AddContact bool `json:"add_contact,omitempty"` + BlockContact bool `json:"block_contact,omitempty"` + ShareContact bool `json:"share_contact,omitempty"` + NeedContactsException bool `json:"need_contacts_exception,omitempty"` + HiddenPeerSettingsBar bool `json:"hidden_peer_settings_bar,omitempty"` + BusinessBotID int64 `json:"business_bot_id,omitempty"` + BusinessBotManageURL string `json:"business_bot_manage_url,omitempty"` + BusinessBotPaused bool `json:"business_bot_paused,omitempty"` + BusinessBotCanReply bool `json:"business_bot_can_reply,omitempty"` } func encodePeerSettings(settings domain.PeerSettings) ([]byte, error) { @@ -897,6 +965,10 @@ func encodePeerSettings(settings domain.PeerSettings) ([]byte, error) { ShareContact: settings.ShareContact, NeedContactsException: settings.NeedContactsException, HiddenPeerSettingsBar: settings.HiddenPeerSettingsBar, + BusinessBotID: settings.BusinessBotID, + BusinessBotManageURL: settings.BusinessBotManageURL, + BusinessBotPaused: settings.BusinessBotPaused, + BusinessBotCanReply: settings.BusinessBotCanReply, }) if err != nil { return nil, fmt.Errorf("marshal peer settings: %w", err) @@ -918,6 +990,10 @@ func decodePeerSettings(raw string) (domain.PeerSettings, error) { ShareContact: wire.ShareContact, NeedContactsException: wire.NeedContactsException, HiddenPeerSettingsBar: wire.HiddenPeerSettingsBar, + BusinessBotID: wire.BusinessBotID, + BusinessBotManageURL: wire.BusinessBotManageURL, + BusinessBotPaused: wire.BusinessBotPaused, + BusinessBotCanReply: wire.BusinessBotCanReply, }, nil } diff --git a/internal/store/postgres/update_event_discussion_types_integration_test.go b/internal/store/postgres/update_event_discussion_types_integration_test.go new file mode 100644 index 00000000..7e69c852 --- /dev/null +++ b/internal/store/postgres/update_event_discussion_types_integration_test.go @@ -0,0 +1,61 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestUpdateEventAppendDiscussionTypes 回归迁移 0003:forum 话题已读的 durable 事件类型 +// read_channel_discussion_inbox / read_channel_discussion_outbox 必须能写入 user_update_events。 +// 0001 的 user_update_events_type_check 白名单原本遗漏这两种类型(0002/a180fbc 加了话题已读 +// 功能却没补约束),导致真 PostgreSQL 上 messages.readDiscussion 首次标已读 append durable 事件 +// 被 CHECK 约束拒(SQLSTATE 23514)→ RPC 500,且话题已读不进 durable / getDifference。 +// memory store 无 CHECK 约束故单测未暴露,真双机 DrKLO 才发现。 +func TestUpdateEventAppendDiscussionTypes(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + u, err := users.Create(ctx, domain.User{AccessHash: 71, Phone: "+1675" + suffix + "01", FirstName: "DiscRead"}) + if err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", u.ID) }) + + store := NewUpdateEventStore(pool) + const channelID = int64(990077) + for _, et := range []domain.UpdateEventType{ + domain.UpdateEventReadChannelDiscussionInbox, + domain.UpdateEventReadChannelDiscussionOutbox, + } { + ev, aerr := store.AppendAllocated(ctx, u.ID, domain.UpdateEvent{ + Type: et, + Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, + TopMsgID: 51, + MaxID: 100, + PtsCount: 1, + Date: 1700000900, + }) + if aerr != nil { + t.Fatalf("append %s 失败(迁移 0003 约束白名单是否缺该类型?): %v", et, aerr) + } + if ev.Pts <= 0 { + t.Fatalf("append %s: pts=%d, want >0", et, ev.Pts) + } + } + + events, err := store.ListAfter(ctx, u.ID, 0, 50) + if err != nil { + t.Fatalf("list after: %v", err) + } + seen := map[domain.UpdateEventType]bool{} + for _, e := range events { + seen[e.Type] = true + } + if !seen[domain.UpdateEventReadChannelDiscussionInbox] || !seen[domain.UpdateEventReadChannelDiscussionOutbox] { + t.Fatalf("discussion 已读事件无法经 ListAfter 回放(多设备 getDifference 同步缺失): seen=%v", seen) + } +} diff --git a/internal/store/postgres/update_event_pin_reply_integration_test.go b/internal/store/postgres/update_event_pin_reply_integration_test.go new file mode 100644 index 00000000..26a093a8 --- /dev/null +++ b/internal/store/postgres/update_event_pin_reply_integration_test.go @@ -0,0 +1,142 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestUpdateEventListAfterCarriesPinServiceReply 复现离线差分场景: +// 接收方经 ListAfter 读到的 pin 服务消息事件必须携带 reply_to(指向 +// 接收方视角的被置顶消息),否则 TDesktop 渲染 "pinned Deleted message"。 +func TestUpdateEventListAfterCarriesPinServiceReply(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + alice, err := users.Create(ctx, domain.User{AccessHash: 95, Phone: "+1674" + suffix + "01", FirstName: "DiffPinA"}) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := users.Create(ctx, domain.User{AccessHash: 96, Phone: "+1674" + suffix + "02", FirstName: "DiffPinB"}) + if err != nil { + t.Fatalf("create bob: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{alice.ID, bob.ID}) + }) + + messages := NewMessageStore(pool) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, RecipientUserID: bob.ID, RandomID: 841001, + Message: "OfflinePinTarget", Date: 1700000800, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if _, err := messages.PinPrivateMessage(ctx, domain.PinPrivateMessageRequest{ + OwnerUserID: alice.ID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}, + MessageID: sent.SenderMessage.ID, + Pinned: true, + Date: 1700000801, + }); err != nil { + t.Fatalf("pin: %v", err) + } + svc, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: alice.ID, RecipientUserID: bob.ID, RandomID: 841002, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{Kind: domain.MessageServiceActionPinMessage}, + }, + ReplyTo: &domain.MessageReply{MessageID: sent.SenderMessage.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob.ID}}, + Date: 1700000802, + }) + if err != nil { + t.Fatalf("send pin service: %v", err) + } + if svc.RecipientMessage.ReplyTo == nil || svc.RecipientMessage.ReplyTo.MessageID != sent.RecipientMessage.ID { + t.Fatalf("recipient service reply = %+v, want bob 视角 id %d", svc.RecipientMessage.ReplyTo, sent.RecipientMessage.ID) + } + + events, err := NewUpdateEventStore(pool).ListAfter(ctx, bob.ID, 0, 50) + if err != nil { + t.Fatalf("list bob events: %v", err) + } + foundService := false + for _, event := range events { + if event.Type != domain.UpdateEventNewMessage || event.Message.Media == nil { + continue + } + if event.Message.Media.Kind != domain.MessageMediaKindService { + continue + } + foundService = true + if event.Message.Media.ServiceAction == nil || event.Message.Media.ServiceAction.Kind != domain.MessageServiceActionPinMessage { + t.Fatalf("service event action = %+v, want pin_message", event.Message.Media.ServiceAction) + } + if event.Message.ReplyTo == nil { + t.Fatalf("difference 路径服务消息事件丢失 reply_to(TDesktop 将渲染 pinned Deleted message):%+v", event.Message) + } + if event.Message.ReplyTo.MessageID != sent.RecipientMessage.ID { + t.Fatalf("service event reply id = %d, want bob 视角 %d", event.Message.ReplyTo.MessageID, sent.RecipientMessage.ID) + } + } + if !foundService { + t.Fatalf("bob events lack pin service message: %+v", events) + } + + // pinned_messages 事件也必须可经 difference 回放。 + foundPinned := false + for _, event := range events { + if event.Type == domain.UpdateEventPinnedMessages { + foundPinned = true + if !event.Bool || len(event.MessageIDs) != 1 || event.MessageIDs[0] != sent.RecipientMessage.ID { + t.Fatalf("pinned event = %+v, want pinned=true with bob 视角 id", event) + } + } + } + if !foundPinned { + t.Fatalf("bob events lack pinned_messages: %+v", events) + } + + // getDialogs 的 top message 必须带全量元数据:TDesktop 把它先入缓存 + // 且后续完整版不覆盖,缺 reply 头会让 pin 服务消息永久渲染成 + // "pinned Deleted message"(实测离线场景抓到的真 bug)。 + dialogs := NewDialogStore(pool) + list, err := dialogs.ListByPeers(ctx, bob.ID, []domain.Peer{{Type: domain.PeerTypeUser, ID: alice.ID}}) + if err != nil { + t.Fatalf("list dialogs by peers: %v", err) + } + if len(list.Messages) != 1 { + t.Fatalf("dialog top messages = %d, want 1", len(list.Messages)) + } + top := list.Messages[0] + if top.ID != svc.RecipientMessage.ID { + t.Fatalf("dialog top message id = %d, want pin service %d", top.ID, svc.RecipientMessage.ID) + } + if top.Media == nil || top.Media.ServiceAction == nil || top.Media.ServiceAction.Kind != domain.MessageServiceActionPinMessage { + t.Fatalf("dialog top message media = %+v, want pin service action", top.Media) + } + if top.ReplyTo == nil || top.ReplyTo.MessageID != sent.RecipientMessage.ID { + t.Fatalf("getDialogs top message 丢失 reply 头:%+v, want reply_to=%d", top.ReplyTo, sent.RecipientMessage.ID) + } + byUser, err := dialogs.ListByUser(ctx, bob.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list dialogs by user: %v", err) + } + foundTop := false + for _, msg := range byUser.Messages { + if msg.ID == svc.RecipientMessage.ID && msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: alice.ID}) { + foundTop = true + if msg.ReplyTo == nil || msg.ReplyTo.MessageID != sent.RecipientMessage.ID { + t.Fatalf("ListByUser top message 丢失 reply 头:%+v", msg.ReplyTo) + } + } + } + if !foundTop { + t.Fatalf("ListByUser lacks pin service top message: %+v", byUser.Messages) + } +} diff --git a/internal/store/postgres/user.go b/internal/store/postgres/user.go index e445bc47..b5b944cd 100644 --- a/internal/store/postgres/user.go +++ b/internal/store/postgres/user.go @@ -5,10 +5,10 @@ import ( "errors" "fmt" "strings" + "time" - "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "telesrv/internal/domain" "telesrv/internal/store/postgres/sqlcgen" @@ -16,12 +16,13 @@ import ( // UserStore 用 PostgreSQL 实现 store.UserStore。 type UserStore struct { - q *sqlcgen.Queries + db sqlcgen.DBTX + q *sqlcgen.Queries } // NewUserStore 基于 pgx 连接池(或事务)创建 UserStore。 func NewUserStore(db sqlcgen.DBTX) *UserStore { - return &UserStore{q: sqlcgen.New(db)} + return &UserStore{db: db, q: sqlcgen.New(db)} } func (s *UserStore) ByID(ctx context.Context, id int64) (domain.User, bool, error) { @@ -51,6 +52,10 @@ func (s *UserStore) ByIDs(ctx context.Context, ids []int64) ([]domain.User, erro } func (s *UserStore) ByPhone(ctx context.Context, phone string) (domain.User, bool, error) { + // bot 行 phone 为空串(0090 起 phone 唯一性只覆盖非空值),空查询必须判未找到。 + if phone == "" { + return domain.User{}, false, nil + } row, err := s.q.GetUserByPhone(ctx, phone) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -62,6 +67,13 @@ func (s *UserStore) ByPhone(ctx context.Context, phone string) (domain.User, boo } func (s *UserStore) ByPhones(ctx context.Context, phones []string) ([]domain.User, error) { + filtered := make([]string, 0, len(phones)) + for _, phone := range phones { + if phone != "" { + filtered = append(filtered, phone) + } + } + phones = filtered if len(phones) == 0 { return nil, nil } @@ -91,6 +103,14 @@ func (s *UserStore) ByUsername(ctx context.Context, username string) (domain.Use return userFromModel(row), true, nil } +func (s *UserStore) CheckUsername(ctx context.Context, userID int64, username string) (bool, error) { + usernameLower := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(username, "@"))) + if usernameLower == "" { + return true, nil + } + return peerUsernameAvailable(ctx, s.db, usernameLower, peerUsernameTypeUser, userID) +} + func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) { query = strings.ToLower(strings.TrimSpace(query)) if currentUserID == 0 || query == "" { @@ -115,19 +135,26 @@ func (s *UserStore) Search(ctx context.Context, currentUserID int64, query, phon } for _, row := range rows { u := domain.User{ - ID: row.ID, - AccessHash: row.AccessHash, - Phone: row.Phone, - FirstName: row.FirstName, - LastName: row.LastName, - About: row.About, - Username: row.Username, - CountryCode: row.CountryCode, - Verified: row.Verified, - Support: row.Support, - LastSeenAt: int(row.LastSeenAt), - Contact: row.Contact, - Mutual: row.Mutual, + ID: row.ID, + AccessHash: row.AccessHash, + Phone: row.Phone, + FirstName: row.FirstName, + LastName: row.LastName, + About: row.About, + Username: row.Username, + CountryCode: row.CountryCode, + Verified: row.Verified, + Support: row.Support, + Bot: row.IsBot, + BotInfoVersion: int(row.BotInfoVersion), + PremiumUntil: premiumUntilFromModel(row.PremiumExpiresAt), + EmojiStatusDocumentID: row.EmojiStatusDocumentID, + EmojiStatusUntil: int(row.EmojiStatusUntil), + Color: peerColorFromModel(row.ColorSet, row.Color, row.ColorBackgroundEmojiID), + ProfileColor: peerColorFromModel(row.ProfileColorSet, row.ProfileColor, row.ProfileColorBackgroundEmojiID), + LastSeenAt: int(row.LastSeenAt), + Contact: row.Contact, + Mutual: row.Mutual, } if row.Contact { out.MyResults = append(out.MyResults, u) @@ -156,13 +183,38 @@ func (s *UserStore) UpdateProfile(ctx context.Context, userID int64, firstName, func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username string) (domain.User, error) { username = strings.TrimSpace(strings.TrimPrefix(username, "@")) - row, err := s.q.UpdateUserUsername(ctx, sqlcgen.UpdateUserUsernameParams{ + usernameLower := strings.ToLower(username) + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.User{}, fmt.Errorf("update user username: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.User{}, fmt.Errorf("begin update user username: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + qtx := s.q.WithTx(tx) + var lockedUserID int64 + if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedUserID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUsernameNotOccupied + } + return domain.User{}, fmt.Errorf("lock user for username update: %w", err) + } + if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, usernameLower); err != nil { + return domain.User{}, err + } + row, err := qtx.UpdateUserUsername(ctx, sqlcgen.UpdateUserUsernameParams{ ID: userID, Username: username, }) if err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == "users_username_lower_unique_idx" { + if isUniqueConstraint(err, "users_username_lower_unique_idx") { return domain.User{}, domain.ErrUsernameOccupied } if errors.Is(err, pgx.ErrNoRows) { @@ -170,6 +222,10 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s } return domain.User{}, fmt.Errorf("update user username: %w", err) } + if err := tx.Commit(ctx); err != nil { + return domain.User{}, fmt.Errorf("commit update user username: %w", err) + } + committed = true return userFromModel(row), nil } @@ -187,24 +243,194 @@ func (s *UserStore) UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt } func (s *UserStore) Create(ctx context.Context, u domain.User) (domain.User, error) { - row, err := s.q.CreateUser(ctx, sqlcgen.CreateUserParams{ - AccessHash: u.AccessHash, - Phone: u.Phone, - FirstName: u.FirstName, - LastName: u.LastName, - Username: u.Username, - CountryCode: u.CountryCode, + u.Username = strings.TrimSpace(strings.TrimPrefix(u.Username, "@")) + beginner, ok := s.db.(txBeginner) + if !ok { + return domain.User{}, fmt.Errorf("create user: db does not support transactions") + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.User{}, fmt.Errorf("begin create user: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + qtx := s.q.WithTx(tx) + row, err := qtx.CreateUser(ctx, sqlcgen.CreateUserParams{ + AccessHash: u.AccessHash, + Phone: u.Phone, + FirstName: u.FirstName, + LastName: u.LastName, + Username: u.Username, + CountryCode: u.CountryCode, + PremiumExpiresAt: premiumUntilToModel(u.PremiumUntil), }) if err != nil { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == "users_username_lower_unique_idx" { + if isUniqueConstraint(err, "users_username_lower_unique_idx") { return domain.User{}, domain.ErrUsernameOccupied } return domain.User{}, fmt.Errorf("create user: %w", err) } + usernameLower := strings.ToLower(row.Username) + if usernameLower != "" { + if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, row.ID, usernameLower); err != nil { + return domain.User{}, err + } + } + if err := tx.Commit(ctx); err != nil { + return domain.User{}, fmt.Errorf("commit create user: %w", err) + } + committed = true return userFromModel(row), nil } +// SetPremiumUntil 把会员到期时间设为绝对 Unix 秒(0 = 清除会员)。 +func (s *UserStore) SetPremiumUntil(ctx context.Context, userID int64, until int) (domain.User, error) { + row, err := s.q.SetUserPremiumUntil(ctx, sqlcgen.SetUserPremiumUntilParams{ + ID: userID, + PremiumExpiresAt: premiumUntilToModel(until), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, fmt.Errorf("set user premium until: %w", err) + } + return userFromModel(row), nil +} + +// SetVerified 设置/取消用户认证标记。 +func (s *UserStore) SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error) { + row, err := s.q.SetUserVerified(ctx, sqlcgen.SetUserVerifiedParams{ + ID: userID, + Verified: verified, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, fmt.Errorf("set user verified: %w", err) + } + return userFromModel(row), nil +} + +// SweepExpiredPremium 清空到期会员行并返回清理后的用户。 +func (s *UserStore) SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) { + if limit <= 0 { + return nil, nil + } + rows, err := s.q.SweepExpiredPremium(ctx, sqlcgen.SweepExpiredPremiumParams{ + Now: pgtype.Timestamptz{Time: time.Unix(now, 0).UTC(), Valid: true}, + LimitCount: int32(limit), + }) + if err != nil { + return nil, fmt.Errorf("sweep expired premium: %w", err) + } + out := make([]domain.User, 0, len(rows)) + for _, row := range rows { + out = append(out, userFromModel(row)) + } + return out, nil +} + +// UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除)。 +func (s *UserStore) UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) { + row, err := s.q.UpdateUserEmojiStatus(ctx, sqlcgen.UpdateUserEmojiStatusParams{ + ID: userID, + EmojiStatusDocumentID: documentID, + EmojiStatusUntil: int64(until), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, fmt.Errorf("update user emoji status: %w", err) + } + return userFromModel(row), nil +} + +// UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。 +func (s *UserStore) UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) { + row, err := s.q.UpdateUserBirthday(ctx, sqlcgen.UpdateUserBirthdayParams{ + ID: userID, + BirthdayDay: int32(birthday.Day), + BirthdayMonth: int32(birthday.Month), + BirthdayYear: int32(birthday.Year), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, fmt.Errorf("update user birthday: %w", err) + } + return userFromModel(row), nil +} + +// UpdatePersonalChannel 设置/清除资料页个人频道(channelID=0 表示清除)。 +func (s *UserStore) UpdatePersonalChannel(ctx context.Context, userID int64, channelID int64) (domain.User, error) { + row, err := s.q.UpdateUserPersonalChannel(ctx, sqlcgen.UpdateUserPersonalChannelParams{ + ID: userID, + PersonalChannelID: channelID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, fmt.Errorf("update user personal channel: %w", err) + } + return userFromModel(row), nil +} + +func (s *UserStore) UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) { + if forProfile { + row, err := s.q.UpdateUserProfileColor(ctx, sqlcgen.UpdateUserProfileColorParams{ + ID: userID, + ColorSet: color.HasColor, + Color: int32(color.Color), + BackgroundEmojiID: color.BackgroundEmojiID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, fmt.Errorf("update user profile color: %w", err) + } + return userFromModel(row), nil + } + row, err := s.q.UpdateUserColor(ctx, sqlcgen.UpdateUserColorParams{ + ID: userID, + ColorSet: color.HasColor, + Color: int32(color.Color), + BackgroundEmojiID: color.BackgroundEmojiID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.User{}, domain.ErrUserNotFound + } + return domain.User{}, fmt.Errorf("update user color: %w", err) + } + return userFromModel(row), nil +} + +// premiumUntilFromModel 把可空 timestamptz 转为 Unix 秒(NULL → 0)。 +func premiumUntilFromModel(t pgtype.Timestamptz) int { + if !t.Valid { + return 0 + } + return int(t.Time.Unix()) +} + +// premiumUntilToModel 把 Unix 秒转为可空 timestamptz(<=0 → NULL)。 +func premiumUntilToModel(until int) pgtype.Timestamptz { + if until <= 0 { + return pgtype.Timestamptz{} + } + return pgtype.Timestamptz{Time: time.Unix(int64(until), 0).UTC(), Valid: true} +} + func escapeLike(s string) string { var b strings.Builder b.Grow(len(s)) @@ -219,16 +445,33 @@ func escapeLike(s string) string { func userFromModel(r sqlcgen.User) domain.User { return domain.User{ - ID: r.ID, - AccessHash: r.AccessHash, - Phone: r.Phone, - FirstName: r.FirstName, - LastName: r.LastName, - About: r.About, - Username: r.Username, - CountryCode: r.CountryCode, - Verified: r.Verified, - Support: r.Support, - LastSeenAt: int(r.LastSeenAt), + ID: r.ID, + AccessHash: r.AccessHash, + Phone: r.Phone, + FirstName: r.FirstName, + LastName: r.LastName, + About: r.About, + Username: r.Username, + CountryCode: r.CountryCode, + Verified: r.Verified, + Support: r.Support, + Bot: r.IsBot, + BotInfoVersion: int(r.BotInfoVersion), + PremiumUntil: premiumUntilFromModel(r.PremiumExpiresAt), + EmojiStatusDocumentID: r.EmojiStatusDocumentID, + EmojiStatusUntil: int(r.EmojiStatusUntil), + Birthday: domain.Birthday{Day: int(r.BirthdayDay), Month: int(r.BirthdayMonth), Year: int(r.BirthdayYear)}, + PersonalChannelID: r.PersonalChannelID, + Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID), + ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID), + LastSeenAt: int(r.LastSeenAt), + } +} + +func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor { + return domain.PeerColor{ + HasColor: hasColor, + Color: int(color), + BackgroundEmojiID: backgroundEmojiID, } } diff --git a/internal/store/postgres/user_color_integration_test.go b/internal/store/postgres/user_color_integration_test.go new file mode 100644 index 00000000..dfb8dd98 --- /dev/null +++ b/internal/store/postgres/user_color_integration_test.go @@ -0,0 +1,96 @@ +package postgres + +import ( + "context" + "fmt" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestUserStoreUpdateColorPostgres(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + users := NewUserStore(pool) + suffix := time.Now().UnixNano() % 1_000_000_000 + + viewer, err := users.Create(ctx, domain.User{ + AccessHash: 1, + Phone: fmt.Sprintf("1777%d", suffix), + FirstName: "ViewerColor", + }) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + target, err := users.Create(ctx, domain.User{ + AccessHash: 2, + Phone: fmt.Sprintf("1888%d", suffix), + FirstName: fmt.Sprintf("TargetColor%d", suffix), + }) + if err != nil { + t.Fatalf("create target: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{viewer.ID, target.ID}) + }) + + updated, err := users.UpdateColor(ctx, target.ID, false, domain.PeerColor{ + HasColor: true, + Color: 0, + BackgroundEmojiID: 100, + }) + if err != nil { + t.Fatalf("update message color: %v", err) + } + assertPostgresPeerColor(t, updated.Color, true, 0, 100) + + updated, err = users.UpdateColor(ctx, target.ID, true, domain.PeerColor{ + HasColor: true, + Color: 3, + BackgroundEmojiID: 200, + }) + if err != nil { + t.Fatalf("update profile color: %v", err) + } + assertPostgresPeerColor(t, updated.Color, true, 0, 100) + assertPostgresPeerColor(t, updated.ProfileColor, true, 3, 200) + + loaded, found, err := users.ByID(ctx, target.ID) + if err != nil || !found { + t.Fatalf("load target found=%v err=%v", found, err) + } + assertPostgresPeerColor(t, loaded.Color, true, 0, 100) + assertPostgresPeerColor(t, loaded.ProfileColor, true, 3, 200) + + foundInSearch := false + search, err := users.Search(ctx, viewer.ID, fmt.Sprintf("targetcolor%d", suffix), "", 10) + if err != nil { + t.Fatalf("search users: %v", err) + } + for _, u := range search.Results { + if u.ID == target.ID { + foundInSearch = true + assertPostgresPeerColor(t, u.Color, true, 0, 100) + assertPostgresPeerColor(t, u.ProfileColor, true, 3, 200) + } + } + if !foundInSearch { + t.Fatalf("target %d not found in search results %+v", target.ID, search.Results) + } + + cleared, err := users.UpdateColor(ctx, target.ID, true, domain.PeerColor{}) + if err != nil { + t.Fatalf("clear profile color: %v", err) + } + if !cleared.ProfileColor.Empty() { + t.Fatalf("profile color after clear = %+v, want empty", cleared.ProfileColor) + } +} + +func assertPostgresPeerColor(t *testing.T, got domain.PeerColor, hasColor bool, color int, backgroundEmojiID int64) { + t.Helper() + if got.HasColor != hasColor || got.Color != color || got.BackgroundEmojiID != backgroundEmojiID { + t.Fatalf("peer color = %+v, want has=%v color=%d bg=%d", got, hasColor, color, backgroundEmojiID) + } +} diff --git a/internal/store/postgres/user_pts.go b/internal/store/postgres/user_pts.go new file mode 100644 index 00000000..b1d41fb0 --- /dev/null +++ b/internal/store/postgres/user_pts.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "context" + "fmt" + + "telesrv/internal/store/postgres/sqlcgen" +) + +func normalizePtsCount(count int) int { + if count <= 0 { + return 1 + } + return count +} + +func ensureUserUpdateWatermark(ctx context.Context, db sqlcgen.DBTX, userID int64) error { + if userID == 0 { + return fmt.Errorf("user pts: missing user id") + } + _, err := db.Exec(ctx, ` +INSERT INTO user_update_watermarks (user_id, contiguous_pts) +VALUES ($1, 0) +ON CONFLICT (user_id) DO NOTHING`, userID) + if err != nil { + return fmt.Errorf("ensure user update watermark: %w", err) + } + return nil +} + +func reserveUserPts(ctx context.Context, db sqlcgen.DBTX, userID int64, count int) (int, error) { + count = normalizePtsCount(count) + if err := ensureUserUpdateWatermark(ctx, db, userID); err != nil { + return 0, err + } + var pts int + if err := db.QueryRow(ctx, ` +UPDATE user_update_watermarks +SET contiguous_pts = contiguous_pts + $2, + updated_at = now() +WHERE user_id = $1 +RETURNING contiguous_pts`, userID, count).Scan(&pts); err != nil { + return 0, fmt.Errorf("reserve user pts: %w", err) + } + return pts, nil +} + +func advanceUserPtsTo(ctx context.Context, db sqlcgen.DBTX, userID int64, pts, count int) error { + count = normalizePtsCount(count) + if pts <= 0 { + return fmt.Errorf("user pts: missing pts") + } + if err := ensureUserUpdateWatermark(ctx, db, userID); err != nil { + return err + } + var current int + if err := db.QueryRow(ctx, ` +SELECT contiguous_pts +FROM user_update_watermarks +WHERE user_id = $1 +FOR UPDATE`, userID).Scan(¤t); err != nil { + return fmt.Errorf("lock user update watermark: %w", err) + } + if current+count != pts { + return fmt.Errorf("user pts not contiguous: user_id=%d current=%d pts=%d pts_count=%d", userID, current, pts, count) + } + if _, err := db.Exec(ctx, ` +UPDATE user_update_watermarks +SET contiguous_pts = $2, + updated_at = now() +WHERE user_id = $1`, userID, pts); err != nil { + return fmt.Errorf("advance user pts watermark: %w", err) + } + return nil +} diff --git a/internal/store/postgres/web_page.go b/internal/store/postgres/web_page.go new file mode 100644 index 00000000..20d72eba --- /dev/null +++ b/internal/store/postgres/web_page.go @@ -0,0 +1,55 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + + "telesrv/internal/domain" +) + +// web_pages 是链接预览解析缓存:按规范化 URL 的 63-bit 哈希去重,snapshot 持久化完整的 +// domain.MessageWebPage 快照。用裸 pgx(非 sqlc),该表无需进入查询代码生成。 + +// PutWebPage upsert 一行解析结果;冲突按 url_hash 覆盖快照与 refreshed_at(created_at 保留首次)。 +func (s *MediaStore) PutWebPage(ctx context.Context, urlHash int64, page domain.MessageWebPage, now int) error { + snapshot, err := json.Marshal(page) + if err != nil { + return err + } + _, err = s.db.Exec(ctx, ` + INSERT INTO web_pages (url_hash, web_page_id, state, snapshot, created_at, refreshed_at) + VALUES ($1, $2, $3, $4, $5, $5) + ON CONFLICT (url_hash) DO UPDATE SET + web_page_id = EXCLUDED.web_page_id, + state = EXCLUDED.state, + snapshot = EXCLUDED.snapshot, + refreshed_at = EXCLUDED.refreshed_at`, + urlHash, page.ID, string(page.State), snapshot, int64(now)) + return err +} + +// GetWebPageByURLHash 读回已解析快照;未命中 found=false。 +func (s *MediaStore) GetWebPageByURLHash(ctx context.Context, urlHash int64) (domain.MessageWebPage, int, bool, error) { + var ( + snapshot []byte + refreshedAt int64 + ) + err := s.db.QueryRow(ctx, `SELECT snapshot, refreshed_at FROM web_pages WHERE url_hash = $1`, urlHash). + Scan(&snapshot, &refreshedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.MessageWebPage{}, 0, false, nil + } + return domain.MessageWebPage{}, 0, false, err + } + var page domain.MessageWebPage + if len(snapshot) > 0 { + if err := json.Unmarshal(snapshot, &page); err != nil { + return domain.MessageWebPage{}, 0, false, err + } + } + return page, int(refreshedAt), true, nil +} diff --git a/internal/store/postgres/web_page_integration_test.go b/internal/store/postgres/web_page_integration_test.go new file mode 100644 index 00000000..886eff47 --- /dev/null +++ b/internal/store/postgres/web_page_integration_test.go @@ -0,0 +1,69 @@ +package postgres + +import ( + "context" + "testing" + + "telesrv/internal/domain" +) + +// TestWebPageStoreRoundTrip 验证 web_pages 的 upsert/读回(snapshot JSONB 字节级往返)与 +// 按 url_hash 去重覆盖。需 TELESRV_TEST_POSTGRES_DSN。 +func TestWebPageStoreRoundTrip(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + s := NewMediaStore(pool) + + const urlHash = int64(9100000000000000777) + _, _ = pool.Exec(ctx, `DELETE FROM web_pages WHERE url_hash = $1`, urlHash) + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM web_pages WHERE url_hash = $1`, urlHash) + }) + + // miss + if _, _, found, err := s.GetWebPageByURLHash(ctx, urlHash); err != nil || found { + t.Fatalf("expected miss, got found=%v err=%v", found, err) + } + + page := domain.MessageWebPage{ + State: domain.MessageWebPageStateDone, + ID: urlHash, + URL: "https://example.com/a", + DisplayURL: "example.com", + Title: "Title", + Description: "Desc", + SiteName: "Site", + Type: "article", + Hash: 123, + Photo: &domain.Photo{ID: 5, AccessHash: 6, DCID: 2, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "x", W: 320, H: 200, Size: 1024}}}, + } + if err := s.PutWebPage(ctx, urlHash, page, 1700000000); err != nil { + t.Fatalf("PutWebPage: %v", err) + } + got, refreshedAt, found, err := s.GetWebPageByURLHash(ctx, urlHash) + if err != nil || !found { + t.Fatalf("GetWebPageByURLHash: found=%v err=%v", found, err) + } + if got.State != domain.MessageWebPageStateDone || got.Title != "Title" || got.Photo == nil || got.Photo.ID != 5 { + t.Fatalf("round-trip mismatch: %+v", got) + } + if refreshedAt != 1700000000 { + t.Fatalf("refreshedAt = %d, want 1700000000", refreshedAt) + } + + // upsert 覆盖为 empty 态。 + empty := domain.MessageWebPage{State: domain.MessageWebPageStateEmpty, ID: urlHash, URL: "https://example.com/a"} + if err := s.PutWebPage(ctx, urlHash, empty, 1700000500); err != nil { + t.Fatalf("PutWebPage(empty): %v", err) + } + got2, refreshed2, _, err := s.GetWebPageByURLHash(ctx, urlHash) + if err != nil { + t.Fatalf("GetWebPageByURLHash after upsert: %v", err) + } + if got2.State != domain.MessageWebPageStateEmpty || got2.Photo != nil { + t.Fatalf("upsert did not overwrite: %+v", got2) + } + if refreshed2 != 1700000500 { + t.Fatalf("refreshed_at not updated on upsert: %d", refreshed2) + } +} diff --git a/internal/store/ratelimit.go b/internal/store/ratelimit.go index 069facfb..0f91bf58 100644 --- a/internal/store/ratelimit.go +++ b/internal/store/ratelimit.go @@ -8,4 +8,5 @@ import ( // RateLimiter 提供轻量窗口限流。返回 retryAfterSeconds 供 RPC 映射 FLOOD_WAIT_X。 type RateLimiter interface { Allow(ctx context.Context, key string, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error) + AllowN(ctx context.Context, key string, cost, limit int, window time.Duration) (allowed bool, retryAfterSeconds int, err error) } diff --git a/internal/store/read_model.go b/internal/store/read_model.go new file mode 100644 index 00000000..b5dd8419 --- /dev/null +++ b/internal/store/read_model.go @@ -0,0 +1,29 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// ReadModelVersionStore exposes durable read-model version tokens to app services. +type ReadModelVersionStore interface { + ReadModelHash(ctx context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) + ReadModelHashes(ctx context.Context, keys []ReadModelKey) (map[ReadModelKey]int64, error) +} + +type ReadModelVersionCache interface { + InvalidateReadModel(ReadModelKey) + FlushReadModelCache() +} + +type ReadModelVersionCacheUpdater interface { + UpdateReadModelHash(ReadModelKey, int64) +} + +type ReadModelKey struct { + Model string + OwnerUserID int64 + PeerType domain.PeerType + PeerID int64 +} diff --git a/internal/store/read_model_cache.go b/internal/store/read_model_cache.go new file mode 100644 index 00000000..21f134ca --- /dev/null +++ b/internal/store/read_model_cache.go @@ -0,0 +1,258 @@ +package store + +import ( + "context" + "sort" + "sync" + "time" + + "telesrv/internal/domain" +) + +const ( + defaultReadModelHashCacheTTL = 30 * time.Minute + defaultReadModelHashCacheMax = 65536 +) + +type readModelHashCacheEntry struct { + hash int64 + expireAt time.Time +} + +type readModelHashInflight struct { + done chan struct{} + err error +} + +// CachedReadModelVersionStore caches read_model_versions hash tokens in-process. +// Correctness is driven by the same read-model NOTIFY stream that invalidates the +// heavier projection caches; TTL only bounds missed out-of-band writes. +type CachedReadModelVersionStore struct { + base ReadModelVersionStore + ttl time.Duration + max int + now func() time.Time + + mu sync.RWMutex + m map[ReadModelKey]readModelHashCacheEntry + inflight map[ReadModelKey]*readModelHashInflight + // epoch 在每次 invalidate/update/flush 时自增;一次锁外 DB load 若跨越了一次失效, + // finishReadModelHashInflight 会拒绝把 stale hash 写回,避免 NOTIFY 送来的新 hash 被覆盖。 + // 与 contacts/privacy 读模缓存的 epoch 守卫同构。 + epoch uint64 +} + +func NewCachedReadModelVersionStore(base ReadModelVersionStore, ttl time.Duration, max int) *CachedReadModelVersionStore { + if base == nil { + return nil + } + if ttl <= 0 { + ttl = defaultReadModelHashCacheTTL + } + if max <= 0 { + max = defaultReadModelHashCacheMax + } + return &CachedReadModelVersionStore{ + base: base, + ttl: ttl, + max: max, + now: time.Now, + m: make(map[ReadModelKey]readModelHashCacheEntry, 1024), + inflight: make(map[ReadModelKey]*readModelHashInflight), + } +} + +func (s *CachedReadModelVersionStore) ReadModelHash(ctx context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) { + if s == nil || s.base == nil || model == "" { + return 0, false, nil + } + key := ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID} + rows, err := s.ReadModelHashes(ctx, []ReadModelKey{key}) + if err != nil { + return 0, false, err + } + hash := rows[key] + return hash, hash != 0, nil +} + +func (s *CachedReadModelVersionStore) ReadModelHashes(ctx context.Context, keys []ReadModelKey) (map[ReadModelKey]int64, error) { + out := make(map[ReadModelKey]int64, len(keys)) + if s == nil || s.base == nil || len(keys) == 0 { + return out, nil + } + now := s.now() + misses := make([]ReadModelKey, 0, len(keys)) + done := make(map[ReadModelKey]struct{}, len(keys)) + seen := make(map[ReadModelKey]struct{}, len(keys)) + + s.mu.RLock() + for _, key := range keys { + if key.Model == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if entry, ok := s.m[key]; ok && entry.expireAt.After(now) { + out[key] = entry.hash + done[key] = struct{}{} + continue + } + misses = append(misses, key) + } + s.mu.RUnlock() + + if len(misses) == 0 { + return out, nil + } + sortReadModelKeys(misses) + + for len(done) < len(seen) { + owned := make([]ReadModelKey, 0, len(misses)) + waiting := make(map[ReadModelKey]*readModelHashInflight) + var loadEpoch uint64 + now = s.now() + s.mu.Lock() + loadEpoch = s.epoch + for _, key := range misses { + if _, ok := done[key]; ok { + continue + } + if entry, ok := s.m[key]; ok && entry.expireAt.After(now) { + out[key] = entry.hash + done[key] = struct{}{} + continue + } + if inflight := s.inflight[key]; inflight != nil { + waiting[key] = inflight + continue + } + inflight := &readModelHashInflight{done: make(chan struct{})} + s.inflight[key] = inflight + owned = append(owned, key) + } + s.mu.Unlock() + if len(owned) == 0 && len(waiting) == 0 { + break + } + if len(owned) > 0 { + loaded, err := s.base.ReadModelHashes(ctx, owned) + if err != nil { + s.finishReadModelHashInflight(owned, nil, err, time.Time{}, loadEpoch) + return nil, err + } + expireAt := s.now().Add(s.ttl) + s.finishReadModelHashInflight(owned, loaded, nil, expireAt, loadEpoch) + // 失效可能在 load 期间到达并写入更新的 hash;优先返回缓存里的当前值 + // (可能是 NOTIFY 刚写入的新 hash),而不是这次 load 读到的可能已过期的值。 + effNow := s.now() + s.mu.RLock() + for _, key := range owned { + if entry, ok := s.m[key]; ok && entry.expireAt.After(effNow) { + out[key] = entry.hash + } else { + out[key] = loaded[key] + } + done[key] = struct{}{} + } + s.mu.RUnlock() + } + for key, inflight := range waiting { + select { + case <-inflight.done: + case <-ctx.Done(): + return nil, ctx.Err() + } + if inflight.err != nil { + return nil, inflight.err + } + s.mu.RLock() + entry, ok := s.m[key] + s.mu.RUnlock() + if ok && entry.expireAt.After(s.now()) { + out[key] = entry.hash + } + done[key] = struct{}{} + } + } + return out, nil +} + +func (s *CachedReadModelVersionStore) finishReadModelHashInflight(keys []ReadModelKey, loaded map[ReadModelKey]int64, err error, expireAt time.Time, loadEpoch uint64) { + s.mu.Lock() + defer s.mu.Unlock() + if err == nil && s.epoch == loadEpoch { + if len(s.m)+len(keys) > s.max { + s.m = make(map[ReadModelKey]readModelHashCacheEntry, 1024) + } + if expireAt.IsZero() { + expireAt = s.now().Add(s.ttl) + } + for _, key := range keys { + hash := loaded[key] + s.m[key] = readModelHashCacheEntry{hash: hash, expireAt: expireAt} + } + } + for _, key := range keys { + if inflight := s.inflight[key]; inflight != nil { + inflight.err = err + delete(s.inflight, key) + close(inflight.done) + } + } +} + +func (s *CachedReadModelVersionStore) InvalidateReadModel(key ReadModelKey) { + if s == nil || key.Model == "" { + return + } + s.mu.Lock() + delete(s.m, key) + s.epoch++ + s.mu.Unlock() +} + +func (s *CachedReadModelVersionStore) UpdateReadModelHash(key ReadModelKey, hash int64) { + if s == nil || key.Model == "" { + return + } + if hash == 0 { + s.InvalidateReadModel(key) + return + } + s.mu.Lock() + if len(s.m)+1 > s.max { + s.m = make(map[ReadModelKey]readModelHashCacheEntry, 1024) + } + s.m[key] = readModelHashCacheEntry{hash: hash, expireAt: s.now().Add(s.ttl)} + // 写入权威新 hash 后自增 epoch:任何此刻在飞的 load 都不得再用旧值覆盖它。 + s.epoch++ + s.mu.Unlock() +} + +func (s *CachedReadModelVersionStore) FlushReadModelCache() { + if s == nil { + return + } + s.mu.Lock() + s.m = make(map[ReadModelKey]readModelHashCacheEntry, 1024) + s.epoch++ + s.mu.Unlock() +} + +func sortReadModelKeys(keys []ReadModelKey) { + sort.Slice(keys, func(i, j int) bool { + a, b := keys[i], keys[j] + if a.Model != b.Model { + return a.Model < b.Model + } + if a.OwnerUserID != b.OwnerUserID { + return a.OwnerUserID < b.OwnerUserID + } + if a.PeerType != b.PeerType { + return a.PeerType < b.PeerType + } + return a.PeerID < b.PeerID + }) +} diff --git a/internal/store/read_model_cache_test.go b/internal/store/read_model_cache_test.go new file mode 100644 index 00000000..d008ccd4 --- /dev/null +++ b/internal/store/read_model_cache_test.go @@ -0,0 +1,243 @@ +package store + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "telesrv/internal/domain" +) + +type blockingReadModelVersionStore struct { + calls atomic.Int32 + once sync.Once + started chan struct{} + release chan struct{} + hashes map[ReadModelKey]int64 + mu sync.Mutex + loaded map[ReadModelKey]int +} + +func (s *blockingReadModelVersionStore) ReadModelHash(ctx context.Context, model string, ownerUserID int64, peerType domain.PeerType, peerID int64) (int64, bool, error) { + rows, err := s.ReadModelHashes(ctx, []ReadModelKey{{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID}}) + if err != nil { + return 0, false, err + } + key := ReadModelKey{Model: model, OwnerUserID: ownerUserID, PeerType: peerType, PeerID: peerID} + hash := rows[key] + return hash, hash != 0, nil +} + +func (s *blockingReadModelVersionStore) ReadModelHashes(ctx context.Context, keys []ReadModelKey) (map[ReadModelKey]int64, error) { + s.calls.Add(1) + s.once.Do(func() { close(s.started) }) + select { + case <-s.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + out := make(map[ReadModelKey]int64, len(keys)) + s.mu.Lock() + if s.loaded == nil { + s.loaded = make(map[ReadModelKey]int) + } + for _, key := range keys { + s.loaded[key]++ + if hash := s.hashes[key]; hash != 0 { + out[key] = hash + } + } + s.mu.Unlock() + return out, nil +} + +func TestCachedReadModelVersionStoreSingleflightsBatchMiss(t *testing.T) { + ctx := context.Background() + keys := []ReadModelKey{ + {Model: "channel_base", OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: 10}, + {Model: "channel_member", OwnerUserID: 100, PeerType: domain.PeerTypeChannel, PeerID: 10}, + } + base := &blockingReadModelVersionStore{ + started: make(chan struct{}), + release: make(chan struct{}), + hashes: map[ReadModelKey]int64{ + keys[0]: 11, + keys[1]: 22, + }, + } + cache := NewCachedReadModelVersionStore(base, time.Hour, 100) + + const goroutines = 16 + errs := make(chan error, goroutines) + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + rows, err := cache.ReadModelHashes(ctx, keys) + if err != nil { + errs <- err + return + } + if rows[keys[0]] != 11 || rows[keys[1]] != 22 { + errs <- fmt.Errorf("hashes = %+v, want 11/22", rows) + } + }() + } + <-base.started + time.Sleep(20 * time.Millisecond) + close(base.release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if got := base.calls.Load(); got != 1 { + t.Fatalf("base ReadModelHashes calls = %d, want 1", got) + } + if _, err := cache.ReadModelHashes(ctx, keys); err != nil { + t.Fatalf("cached ReadModelHashes: %v", err) + } + if got := base.calls.Load(); got != 1 { + t.Fatalf("cache hit called base again: calls=%d", got) + } +} + +func TestCachedReadModelVersionStoreWaitsOverlappingBatchKeys(t *testing.T) { + ctx := context.Background() + a := ReadModelKey{Model: "channel_base", OwnerUserID: 0, PeerType: domain.PeerTypeChannel, PeerID: 10} + b := ReadModelKey{Model: "channel_member", OwnerUserID: 100, PeerType: domain.PeerTypeChannel, PeerID: 10} + c := ReadModelKey{Model: "dialog_light", OwnerUserID: 100, PeerType: domain.PeerTypeChannel, PeerID: 10} + base := &blockingReadModelVersionStore{ + started: make(chan struct{}), + release: make(chan struct{}), + hashes: map[ReadModelKey]int64{ + a: 11, + b: 22, + c: 33, + }, + } + cache := NewCachedReadModelVersionStore(base, time.Hour, 100) + + firstErr := make(chan error, 1) + go func() { + rows, err := cache.ReadModelHashes(ctx, []ReadModelKey{a, b}) + if err == nil && (rows[a] != 11 || rows[b] != 22) { + err = fmt.Errorf("first hashes = %+v, want A/B", rows) + } + firstErr <- err + }() + <-base.started + + secondErr := make(chan error, 1) + go func() { + rows, err := cache.ReadModelHashes(ctx, []ReadModelKey{b, c}) + if err == nil && (rows[b] != 22 || rows[c] != 33) { + err = fmt.Errorf("second hashes = %+v, want B/C", rows) + } + secondErr <- err + }() + time.Sleep(20 * time.Millisecond) + close(base.release) + + if err := <-firstErr; err != nil { + t.Fatal(err) + } + if err := <-secondErr; err != nil { + t.Fatal(err) + } + base.mu.Lock() + loadedB := base.loaded[b] + loadedC := base.loaded[c] + base.mu.Unlock() + if loadedB != 1 { + t.Fatalf("overlap key B loaded %d times, want 1", loadedB) + } + if loadedC != 1 { + t.Fatalf("owned key C loaded %d times, want 1", loadedC) + } +} + +// TestCachedReadModelVersionStoreEpochGuardRejectsStaleWriteback 证明 epoch 守卫堵住了 +// lost-update:一次锁外 DB load 期间到达的 NOTIFY(写入新 hash)不得被 load 返回的旧 hash 覆盖。 +// 这是 4 个 hash-only 消费缓存(private/channel media counts、participants、active ids)正确性的根。 +func TestCachedReadModelVersionStoreEpochGuardRejectsStaleWriteback(t *testing.T) { + ctx := context.Background() + key := ReadModelKey{Model: "private_media_counts", OwnerUserID: 100, PeerType: domain.PeerTypeUser, PeerID: 100} + base := &blockingReadModelVersionStore{ + started: make(chan struct{}), + release: make(chan struct{}), + hashes: map[ReadModelKey]int64{key: 11}, // DB 此刻仍是旧 hash 11 + } + cache := NewCachedReadModelVersionStore(base, time.Hour, 100) + + resultCh := make(chan map[ReadModelKey]int64, 1) + errCh := make(chan error, 1) + go func() { + rows, err := cache.ReadModelHashes(ctx, []ReadModelKey{key}) + if err != nil { + errCh <- err + return + } + resultCh <- rows + }() + + // 等到 load 已 claim inflight 并阻塞在 base.ReadModelHashes 内。 + <-base.started + // 模拟 NOTIFY 在 load 期间送来权威新 hash 99(写入并自增 epoch)。 + cache.UpdateReadModelHash(key, 99) + // 放行 load:它会返回旧 hash 11;epoch 守卫必须拒绝用 11 覆盖 99。 + close(base.release) + + select { + case err := <-errCh: + t.Fatalf("ReadModelHashes: %v", err) + case rows := <-resultCh: + if rows[key] != 99 { + t.Fatalf("in-flight reader got hash %d, want fresh 99 (stale 11 must not win)", rows[key]) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for in-flight reader") + } + + // 后续读仍须是 99,且不再打 base —— 证明缓存没被旧值污染、也没被错误驱逐。 + callsBefore := base.calls.Load() + rows, err := cache.ReadModelHashes(ctx, []ReadModelKey{key}) + if err != nil { + t.Fatalf("second ReadModelHashes: %v", err) + } + if rows[key] != 99 { + t.Fatalf("cached hash = %d, want 99 (cache poisoned by stale 11)", rows[key]) + } + if got := base.calls.Load(); got != callsBefore { + t.Fatalf("second read hit base (calls %d -> %d): cache was poisoned or evicted", callsBefore, got) + } +} + +func TestCachedReadModelVersionStoreUpdateReadModelHashWarmsCache(t *testing.T) { + ctx := context.Background() + key := ReadModelKey{Model: "channel_base", PeerType: domain.PeerTypeChannel, PeerID: 10} + base := &blockingReadModelVersionStore{ + started: make(chan struct{}), + release: make(chan struct{}), + hashes: map[ReadModelKey]int64{key: 11}, + } + cache := NewCachedReadModelVersionStore(base, time.Hour, 100) + + cache.UpdateReadModelHash(key, 99) + rows, err := cache.ReadModelHashes(ctx, []ReadModelKey{key}) + if err != nil { + t.Fatalf("ReadModelHashes: %v", err) + } + if rows[key] != 99 { + t.Fatalf("hash = %d, want notify-provided 99", rows[key]) + } + if got := base.calls.Load(); got != 0 { + t.Fatalf("base calls = %d, want cache warmed by notify", got) + } +} diff --git a/internal/store/redisstore/allocator.go b/internal/store/redisstore/allocator.go index ab051b01..3e50aca9 100644 --- a/internal/store/redisstore/allocator.go +++ b/internal/store/redisstore/allocator.go @@ -10,21 +10,11 @@ import ( "telesrv/internal/store" ) -// PtsAllocator 用 Redis INCR 分配账号级 pts,Redis 丢失时从 PG durable log 恢复当前最大 pts。 -type PtsAllocator struct { - counter counterAllocator -} - // BoxIDAllocator 用 Redis INCR 分配 owner 视角的 message box id。 type BoxIDAllocator struct { counter counterAllocator } -// ChannelPtsAllocator 用 Redis INCR 分配 channel 维度 pts。 -type ChannelPtsAllocator struct { - counter counterAllocator -} - // ChannelIDAllocator 用 Redis INCR 分配全局 channel/supergroup id。 type ChannelIDAllocator struct { counter counterAllocator @@ -35,6 +25,11 @@ type ChannelMessageIDAllocator struct { counter counterAllocator } +// SecretChatIDAllocator 用 Redis INCR 分配全局 secret chat id(int32 量级)。 +type SecretChatIDAllocator struct { + counter counterAllocator +} + type counterAllocator struct { c *redis.Client source store.CounterSource @@ -51,14 +46,6 @@ if current then return redis.call("INCR", KEYS[1]) end return -1 -`) - - counterNextByScript = redis.NewScript(` -local current = redis.call("GET", KEYS[1]) -if current then - return redis.call("INCRBY", KEYS[1], ARGV[1]) -end -return -1 `) counterRecoverCurrentScript = redis.NewScript(` @@ -78,25 +65,24 @@ end return redis.call("INCR", KEYS[1]) `) - counterRecoverNextByScript = redis.NewScript(` + counterNextAtLeastScript = redis.NewScript(` local current = redis.call("GET", KEYS[1]) -if not current then +if (not current) or tonumber(current) < tonumber(ARGV[1]) then redis.call("SET", KEYS[1], ARGV[1]) end -return redis.call("INCRBY", KEYS[1], ARGV[2]) +return redis.call("INCR", KEYS[1]) +`) + + counterSetAtLeastScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +if (not current) or tonumber(current) < tonumber(ARGV[1]) then + redis.call("SET", KEYS[1], ARGV[1]) + return tonumber(ARGV[1]) +end +return tonumber(current) `) ) -// NewPtsAllocator 创建 Redis-backed pts allocator。 -func NewPtsAllocator(c *redis.Client, source store.CounterSource) *PtsAllocator { - return &PtsAllocator{counter: counterAllocator{ - c: c, - source: source, - key: ptsKey, - name: "pts", - }} -} - // NewBoxIDAllocator 创建 Redis-backed message box id allocator。 func NewBoxIDAllocator(c *redis.Client, source store.CounterSource) *BoxIDAllocator { return &BoxIDAllocator{counter: counterAllocator{ @@ -107,16 +93,6 @@ func NewBoxIDAllocator(c *redis.Client, source store.CounterSource) *BoxIDAlloca }} } -// NewChannelPtsAllocator 创建 Redis-backed channel pts allocator。 -func NewChannelPtsAllocator(c *redis.Client, source store.CounterSource) *ChannelPtsAllocator { - return &ChannelPtsAllocator{counter: counterAllocator{ - c: c, - source: source, - key: channelPtsKey, - name: "channel_pts", - }} -} - // NewChannelIDAllocator 创建 Redis-backed channel id allocator。 func NewChannelIDAllocator(c *redis.Client, source store.CounterSource) *ChannelIDAllocator { return &ChannelIDAllocator{counter: counterAllocator{ @@ -137,41 +113,32 @@ func NewChannelMessageIDAllocator(c *redis.Client, source store.CounterSource) * }} } -func ptsKey(userID int64) string { - return fmt.Sprintf("counter:pts:{%d}", userID) +// NewSecretChatIDAllocator 创建 Redis-backed secret chat id allocator。 +func NewSecretChatIDAllocator(c *redis.Client, source store.CounterSource) *SecretChatIDAllocator { + return &SecretChatIDAllocator{counter: counterAllocator{ + c: c, + source: source, + key: secretChatIDKey, + name: "secret_chat_id", + }} } func boxIDKey(userID int64) string { return fmt.Sprintf("counter:box_id:{%d}", userID) } -func channelPtsKey(channelID int64) string { - return fmt.Sprintf("counter:channel_pts:{%d}", channelID) -} - func channelIDKey(_ int64) string { return "counter:channel_id" } +func secretChatIDKey(_ int64) string { + return "counter:secret_chat_id" +} + func channelMessageIDKey(channelID int64) string { return fmt.Sprintf("counter:channel_msg_id:{%d}", channelID) } -func (a *PtsAllocator) NextPts(ctx context.Context, userID int64) (int, error) { - v, err := a.counter.next(ctx, userID) - return int(v), err -} - -func (a *PtsAllocator) NextPtsN(ctx context.Context, userID int64, count int) (int, error) { - v, err := a.counter.nextBy(ctx, userID, count) - return int(v), err -} - -func (a *PtsAllocator) CurrentPts(ctx context.Context, userID int64) (int, error) { - v, err := a.counter.current(ctx, userID) - return int(v), err -} - func (a *BoxIDAllocator) NextBoxID(ctx context.Context, userID int64) (int, error) { v, err := a.counter.next(ctx, userID) return int(v), err @@ -182,29 +149,64 @@ func (a *BoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, e return int(v), err } -func (a *ChannelPtsAllocator) NextChannelPts(ctx context.Context, channelID int64) (int, error) { - v, err := a.counter.next(ctx, channelID) - return int(v), err -} - -func (a *ChannelPtsAllocator) NextChannelPtsN(ctx context.Context, channelID int64, count int) (int, error) { - v, err := a.counter.nextBy(ctx, channelID, count) - return int(v), err -} - -func (a *ChannelPtsAllocator) CurrentChannelPts(ctx context.Context, channelID int64) (int, error) { - v, err := a.counter.current(ctx, channelID) - return int(v), err +// BumpBoxIDAtLeast advances the Redis box id counter to at least floor without +// allocating a visible id. It is a cold-path self-heal for Redis counters that +// lag behind message_boxes after external/dev writes. +func (a *BoxIDAllocator) BumpBoxIDAtLeast(ctx context.Context, userID int64, floor int) error { + if a.counter.c == nil { + return fmt.Errorf("redis box_id counter: nil client") + } + if _, err := counterSetAtLeastScript.Run(ctx, a.counter.c, []string{boxIDKey(userID)}, floor).Int64(); err != nil { + return fmt.Errorf("redis set-at-least box_id counter: %w", err) + } + return nil } func (a *ChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) { return a.counter.next(ctx, 1) } +// NextChannelIDAtLeast 把计数器至少顶到 floor 后再分配下一个 id。 +// 用于撞主键自愈:Redis 快照回退或测试 fallback 分配器绕过 Redis 写库 +// 后,计数器可能落后于 channels 表真实最大 id。 +func (a *ChannelIDAllocator) NextChannelIDAtLeast(ctx context.Context, floor int64) (int64, error) { + if a.counter.c == nil { + return 0, fmt.Errorf("redis channel_id counter: nil client") + } + v, err := counterNextAtLeastScript.Run(ctx, a.counter.c, []string{channelIDKey(1)}, floor).Int64() + if err != nil { + return 0, fmt.Errorf("redis next-at-least channel_id counter: %w", err) + } + return v, nil +} + func (a *ChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) { return a.counter.current(ctx, 1) } +func (a *SecretChatIDAllocator) NextSecretChatID(ctx context.Context) (int, error) { + v, err := a.counter.next(ctx, 1) + return int(v), err +} + +// NextSecretChatIDAtLeast 把计数器至少顶到 floor 后再分配下一个 id(撞 chat_id +// 主键自愈:Redis 快照回退或外部写库后计数器落后于 secret_chats 表最大 id)。 +func (a *SecretChatIDAllocator) NextSecretChatIDAtLeast(ctx context.Context, floor int) (int, error) { + if a.counter.c == nil { + return 0, fmt.Errorf("redis secret_chat_id counter: nil client") + } + v, err := counterNextAtLeastScript.Run(ctx, a.counter.c, []string{secretChatIDKey(1)}, floor).Int64() + if err != nil { + return 0, fmt.Errorf("redis next-at-least secret_chat_id counter: %w", err) + } + return int(v), nil +} + +func (a *SecretChatIDAllocator) CurrentSecretChatID(ctx context.Context) (int, error) { + v, err := a.counter.current(ctx, 1) + return int(v), err +} + func (a *ChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) { v, err := a.counter.next(ctx, channelID) return int(v), err @@ -216,24 +218,11 @@ func (a *ChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, } func (a counterAllocator) next(ctx context.Context, userID int64) (int64, error) { - return a.nextBy(ctx, userID, 1) -} - -func (a counterAllocator) nextBy(ctx context.Context, userID int64, count int) (int64, error) { - if count <= 0 { - return 0, fmt.Errorf("redis next %s counter: invalid count %d", a.name, count) - } key, err := a.validatedKey(userID) if err != nil { return 0, err } - script := counterNextScript - args := []any{} - if count > 1 { - script = counterNextByScript - args = append(args, count) - } - v, err := script.Run(ctx, a.c, []string{key}, args...).Int64() + v, err := counterNextScript.Run(ctx, a.c, []string{key}).Int64() if err != nil { return 0, fmt.Errorf("redis next %s counter: %w", a.name, err) } @@ -244,13 +233,7 @@ func (a counterAllocator) nextBy(ctx context.Context, userID int64, count int) ( if err != nil { return 0, err } - recoverScript := counterRecoverNextScript - recoverArgs := []any{recovered} - if count > 1 { - recoverScript = counterRecoverNextByScript - recoverArgs = append(recoverArgs, count) - } - v, err = recoverScript.Run(ctx, a.c, []string{key}, recoverArgs...).Int64() + v, err = counterRecoverNextScript.Run(ctx, a.c, []string{key}, recovered).Int64() if err != nil { return 0, fmt.Errorf("redis recover-next %s counter: %w", a.name, err) } diff --git a/internal/store/redisstore/allocator_integration_test.go b/internal/store/redisstore/allocator_integration_test.go index bdcb5956..a5a82031 100644 --- a/internal/store/redisstore/allocator_integration_test.go +++ b/internal/store/redisstore/allocator_integration_test.go @@ -16,7 +16,7 @@ func (s staticCounterSource) Current(context.Context, int64) (int, error) { return s.value, nil } -func TestRedisAllocatorsRecoverFromCounterSource(t *testing.T) { +func TestRedisBoxAllocatorRecoverFromCounterSource(t *testing.T) { addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") if addr == "" { t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") @@ -29,23 +29,7 @@ func TestRedisAllocatorsRecoverFromCounterSource(t *testing.T) { t.Cleanup(func() { _ = c.Close() }) userID := time.Now().UnixNano() - t.Cleanup(func() { _ = c.Del(ctx, ptsKey(userID), boxIDKey(userID)).Err() }) - - pts := NewPtsAllocator(c, staticCounterSource{value: 41}) - currentPts, err := pts.CurrentPts(ctx, userID) - if err != nil { - t.Fatalf("CurrentPts: %v", err) - } - if currentPts != 41 { - t.Fatalf("current pts = %d, want recovered 41", currentPts) - } - nextPts, err := pts.NextPts(ctx, userID) - if err != nil { - t.Fatalf("NextPts: %v", err) - } - if nextPts != 42 { - t.Fatalf("next pts = %d, want 42", nextPts) - } + t.Cleanup(func() { _ = c.Del(ctx, boxIDKey(userID)).Err() }) boxes := NewBoxIDAllocator(c, staticCounterSource{value: 100}) currentBox, err := boxes.CurrentBoxID(ctx, userID) @@ -64,7 +48,7 @@ func TestRedisAllocatorsRecoverFromCounterSource(t *testing.T) { } } -func TestRedisAllocatorConcurrentFirstUse(t *testing.T) { +func TestRedisBoxAllocatorConcurrentFirstUse(t *testing.T) { addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") if addr == "" { t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") @@ -77,10 +61,10 @@ func TestRedisAllocatorConcurrentFirstUse(t *testing.T) { t.Cleanup(func() { _ = c.Close() }) userID := time.Now().UnixNano() - t.Cleanup(func() { _ = c.Del(ctx, ptsKey(userID)).Err() }) + t.Cleanup(func() { _ = c.Del(ctx, boxIDKey(userID)).Err() }) const workers = 32 - pts := NewPtsAllocator(c, staticCounterSource{value: 1000}) + boxes := NewBoxIDAllocator(c, staticCounterSource{value: 1000}) values := make(chan int, workers) errs := make(chan error, workers) var wg sync.WaitGroup @@ -88,7 +72,7 @@ func TestRedisAllocatorConcurrentFirstUse(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - v, err := pts.NextPts(ctx, userID) + v, err := boxes.NextBoxID(ctx, userID) if err != nil { errs <- err return @@ -101,29 +85,29 @@ func TestRedisAllocatorConcurrentFirstUse(t *testing.T) { close(errs) for err := range errs { - t.Fatalf("NextPts: %v", err) + t.Fatalf("NextBoxID: %v", err) } seen := make(map[int]bool, workers) for v := range values { if v < 1001 || v > 1000+workers { - t.Fatalf("pts = %d, want recovered contiguous range", v) + t.Fatalf("box id = %d, want recovered contiguous range", v) } if seen[v] { - t.Fatalf("duplicate pts %d", v) + t.Fatalf("duplicate box id %d", v) } seen[v] = true } for want := 1001; want <= 1000+workers; want++ { if !seen[want] { - t.Fatalf("missing pts %d", want) + t.Fatalf("missing box id %d", want) } } - current, err := pts.CurrentPts(ctx, userID) + current, err := boxes.CurrentBoxID(ctx, userID) if err != nil { - t.Fatalf("CurrentPts: %v", err) + t.Fatalf("CurrentBoxID: %v", err) } if current != 1000+workers { - t.Fatalf("current pts = %d, want %d", current, 1000+workers) + t.Fatalf("current box id = %d, want %d", current, 1000+workers) } } @@ -157,4 +141,21 @@ func TestRedisRateLimiterWindow(t *testing.T) { if allowed || retry <= 0 { t.Fatalf("second allowed=%v retry=%d, want limited with retry", allowed, retry) } + + batchKey := key + ":batch" + t.Cleanup(func() { _ = c.Del(ctx, rateLimitKey(batchKey)).Err() }) + allowed, retry, err = limiter.AllowN(ctx, batchKey, 2, 3, time.Minute) + if err != nil { + t.Fatalf("AllowN first: %v", err) + } + if !allowed || retry != 0 { + t.Fatalf("AllowN first allowed=%v retry=%d, want allowed", allowed, retry) + } + allowed, retry, err = limiter.AllowN(ctx, batchKey, 2, 3, time.Minute) + if err != nil { + t.Fatalf("AllowN second: %v", err) + } + if allowed || retry <= 0 { + t.Fatalf("AllowN second allowed=%v retry=%d, want limited with retry", allowed, retry) + } } diff --git a/internal/store/redisstore/inline_registry.go b/internal/store/redisstore/inline_registry.go new file mode 100644 index 00000000..7146cda5 --- /dev/null +++ b/internal/store/redisstore/inline_registry.go @@ -0,0 +1,363 @@ +package redisstore + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +type InlineRegistryStore struct { + c *redis.Client +} + +func NewInlineRegistryStore(c *redis.Client) *InlineRegistryStore { + return &InlineRegistryStore{c: c} +} + +const inlineBotQueryChannel = "inline:bot_query" + +func inlinePendingKey(queryID int64) string { + return fmt.Sprintf("inline:pending:%d", queryID) +} + +func inlineResultKey(queryID int64) string { + return fmt.Sprintf("inline:result:%d", queryID) +} + +func inlineCacheKey(key store.InlineCacheKey) (string, error) { + raw, err := json.Marshal(key) + if err != nil { + return "", fmt.Errorf("marshal inline cache key: %w", err) + } + sum := sha256.Sum256(raw) + return "inline:cache:" + hex.EncodeToString(sum[:]), nil +} + +func inlineWebDocumentKey(key store.InlineWebDocumentKey) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d", key.URL, key.AccessHash))) + return "inline:webdoc:" + hex.EncodeToString(sum[:]) +} + +func preparedInlineMessageKey(id string) string { + sum := sha256.Sum256([]byte(id)) + return "inline:prepared:" + hex.EncodeToString(sum[:]) +} + +func webViewSessionKey(queryID int64) string { + return fmt.Sprintf("webview:session:%d", queryID) +} + +func webViewBotQueryKey(botQueryID string) string { + sum := sha256.Sum256([]byte(botQueryID)) + return "webview:bot_query:" + hex.EncodeToString(sum[:]) +} + +func (s *InlineRegistryStore) PutInlinePending(ctx context.Context, pending store.InlinePending, ttl time.Duration) error { + raw, err := json.Marshal(pending) + if err != nil { + return fmt.Errorf("marshal inline pending: %w", err) + } + if err := s.c.Set(ctx, inlinePendingKey(pending.QueryID), raw, ttl).Err(); err != nil { + return fmt.Errorf("redis set inline pending: %w", err) + } + return nil +} + +func (s *InlineRegistryStore) GetInlinePending(ctx context.Context, queryID int64) (store.InlinePending, bool, error) { + key := inlinePendingKey(queryID) + var pending store.InlinePending + found, err := redisGetJSON(ctx, s.c, key, &pending) + if err != nil || !found { + return store.InlinePending{}, false, err + } + if pending.QueryID != queryID || pending.BotUserID == 0 || pending.UserID == 0 { + _ = s.c.Del(ctx, key).Err() + return store.InlinePending{}, false, nil + } + return pending, true, nil +} + +func (s *InlineRegistryStore) DeleteInlinePending(ctx context.Context, queryID int64) error { + if err := s.c.Del(ctx, inlinePendingKey(queryID)).Err(); err != nil { + return fmt.Errorf("redis delete inline pending: %w", err) + } + return nil +} + +func (s *InlineRegistryStore) PutInlineResult(ctx context.Context, results domain.BotInlineResults, ttl time.Duration) error { + raw, err := json.Marshal(results) + if err != nil { + return fmt.Errorf("marshal inline result: %w", err) + } + if err := s.c.Set(ctx, inlineResultKey(results.QueryID), raw, ttl).Err(); err != nil { + return fmt.Errorf("redis set inline result: %w", err) + } + return nil +} + +func (s *InlineRegistryStore) GetInlineResult(ctx context.Context, queryID int64) (domain.BotInlineResults, bool, error) { + key := inlineResultKey(queryID) + var results domain.BotInlineResults + found, err := redisGetJSON(ctx, s.c, key, &results) + if err != nil || !found { + return domain.BotInlineResults{}, false, err + } + if results.QueryID != queryID || results.UserID == 0 || results.BotUserID == 0 { + _ = s.c.Del(ctx, key).Err() + return domain.BotInlineResults{}, false, nil + } + return results, true, nil +} + +func (s *InlineRegistryStore) DeleteInlineResult(ctx context.Context, queryID int64) error { + if err := s.c.Del(ctx, inlineResultKey(queryID)).Err(); err != nil { + return fmt.Errorf("redis delete inline result: %w", err) + } + return nil +} + +func (s *InlineRegistryStore) PutInlineCache(ctx context.Context, key store.InlineCacheKey, results domain.BotInlineResults, ttl time.Duration) error { + redisKey, err := inlineCacheKey(key) + if err != nil { + return err + } + results.QueryID = 0 + raw, err := json.Marshal(results) + if err != nil { + return fmt.Errorf("marshal inline cache: %w", err) + } + if err := s.c.Set(ctx, redisKey, raw, ttl).Err(); err != nil { + return fmt.Errorf("redis set inline cache: %w", err) + } + return nil +} + +func (s *InlineRegistryStore) GetInlineCache(ctx context.Context, key store.InlineCacheKey) (domain.BotInlineResults, bool, time.Duration, error) { + redisKey, err := inlineCacheKey(key) + if err != nil { + return domain.BotInlineResults{}, false, 0, err + } + var results domain.BotInlineResults + found, err := redisGetJSON(ctx, s.c, redisKey, &results) + if err != nil || !found { + return domain.BotInlineResults{}, false, 0, err + } + ttl, err := s.c.TTL(ctx, redisKey).Result() + if err != nil { + return domain.BotInlineResults{}, false, 0, fmt.Errorf("redis ttl inline cache: %w", err) + } + if ttl <= 0 { + ttl = time.Second + } + return results, true, ttl, nil +} + +func (s *InlineRegistryStore) PutInlineWebDocument(ctx context.Context, document domain.BotInlineWebDocument, ttl time.Duration) error { + key := store.InlineWebDocumentKey{URL: document.URL, AccessHash: document.AccessHash} + redisKey := inlineWebDocumentKey(key) + entry := store.InlineWebDocumentEntry{Document: document} + if existing, found, err := s.GetInlineWebDocument(ctx, key); err != nil { + return err + } else if found { + entry.Bytes = append([]byte(nil), existing.Bytes...) + entry.MimeType = existing.MimeType + } + raw, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("marshal inline web document: %w", err) + } + if err := s.c.Set(ctx, redisKey, raw, ttl).Err(); err != nil { + return fmt.Errorf("redis set inline web document: %w", err) + } + return nil +} + +func (s *InlineRegistryStore) GetInlineWebDocument(ctx context.Context, key store.InlineWebDocumentKey) (store.InlineWebDocumentEntry, bool, error) { + redisKey := inlineWebDocumentKey(key) + var entry store.InlineWebDocumentEntry + found, err := redisGetJSON(ctx, s.c, redisKey, &entry) + if err != nil || !found { + return store.InlineWebDocumentEntry{}, false, err + } + if entry.Document.URL != key.URL || entry.Document.AccessHash != key.AccessHash || entry.Document.URL == "" || entry.Document.AccessHash == 0 { + _ = s.c.Del(ctx, redisKey).Err() + return store.InlineWebDocumentEntry{}, false, nil + } + if len(entry.Bytes) > domain.MaxBotInlineWebSize { + _ = s.c.Del(ctx, redisKey).Err() + return store.InlineWebDocumentEntry{}, false, nil + } + entry.Bytes = append([]byte(nil), entry.Bytes...) + return entry, true, nil +} + +func (s *InlineRegistryStore) PutInlineWebDocumentBytes(ctx context.Context, key store.InlineWebDocumentKey, data []byte, mimeType string, ttl time.Duration) error { + if len(data) == 0 || len(data) > domain.MaxBotInlineWebSize { + return fmt.Errorf("inline web document bytes size %d out of range", len(data)) + } + redisKey := inlineWebDocumentKey(key) + entry, found, err := s.GetInlineWebDocument(ctx, key) + if err != nil { + return err + } + if !found { + return fmt.Errorf("inline web document missing") + } + entry.Bytes = append([]byte(nil), data...) + entry.MimeType = mimeType + raw, err := json.Marshal(entry) + if err != nil { + return fmt.Errorf("marshal inline web document bytes: %w", err) + } + if currentTTL, err := s.c.TTL(ctx, redisKey).Result(); err == nil && currentTTL > 0 { + ttl = currentTTL + } + if err := s.c.Set(ctx, redisKey, raw, ttl).Err(); err != nil { + return fmt.Errorf("redis set inline web document bytes: %w", err) + } + return nil +} + +func (s *InlineRegistryStore) PutPreparedInlineMessage(ctx context.Context, msg store.PreparedInlineMessage, ttl time.Duration) error { + raw, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshal prepared inline message: %w", err) + } + if err := s.c.Set(ctx, preparedInlineMessageKey(msg.ID), raw, ttl).Err(); err != nil { + return fmt.Errorf("redis set prepared inline message: %w", err) + } + return nil +} + +func (s *InlineRegistryStore) GetPreparedInlineMessage(ctx context.Context, id string) (store.PreparedInlineMessage, bool, error) { + key := preparedInlineMessageKey(id) + var msg store.PreparedInlineMessage + found, err := redisGetJSON(ctx, s.c, key, &msg) + if err != nil || !found { + return store.PreparedInlineMessage{}, false, err + } + if msg.ID != id || msg.BotUserID == 0 || msg.UserID == 0 || len(msg.Results.Results) != 1 { + _ = s.c.Del(ctx, key).Err() + return store.PreparedInlineMessage{}, false, nil + } + msg.Results.Results = append([]domain.BotInlineResult(nil), msg.Results.Results...) + return msg, true, nil +} + +func (s *InlineRegistryStore) PutWebViewSession(ctx context.Context, session store.WebViewSession, ttl time.Duration) error { + raw, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("marshal webview session: %w", err) + } + if err := s.c.Set(ctx, webViewSessionKey(session.QueryID), raw, ttl).Err(); err != nil { + return fmt.Errorf("redis set webview session: %w", err) + } + if err := s.c.Set(ctx, webViewBotQueryKey(session.BotQueryID), raw, ttl).Err(); err != nil { + return fmt.Errorf("redis set webview bot query: %w", err) + } + return nil +} + +func (s *InlineRegistryStore) GetWebViewSession(ctx context.Context, queryID int64) (store.WebViewSession, bool, error) { + key := webViewSessionKey(queryID) + var session store.WebViewSession + found, err := redisGetJSON(ctx, s.c, key, &session) + if err != nil || !found { + return store.WebViewSession{}, false, err + } + if !validWebViewSession(session) || session.QueryID != queryID { + _ = s.c.Del(ctx, key).Err() + return store.WebViewSession{}, false, nil + } + return session, true, nil +} + +func (s *InlineRegistryStore) GetWebViewSessionByBotQuery(ctx context.Context, botQueryID string) (store.WebViewSession, bool, error) { + key := webViewBotQueryKey(botQueryID) + var session store.WebViewSession + found, err := redisGetJSON(ctx, s.c, key, &session) + if err != nil || !found { + return store.WebViewSession{}, false, err + } + if !validWebViewSession(session) || session.BotQueryID != botQueryID { + _ = s.c.Del(ctx, key).Err() + return store.WebViewSession{}, false, nil + } + return session, true, nil +} + +func (s *InlineRegistryStore) DeleteWebViewSession(ctx context.Context, queryID int64, botQueryID string) error { + if err := s.c.Del(ctx, webViewSessionKey(queryID), webViewBotQueryKey(botQueryID)).Err(); err != nil { + return fmt.Errorf("redis delete webview session: %w", err) + } + return nil +} + +func validWebViewSession(session store.WebViewSession) bool { + return session.QueryID != 0 && session.BotQueryID != "" && session.BotUserID != 0 && session.UserID != 0 && session.Peer.ID != 0 +} + +func (s *InlineRegistryStore) PublishBotInlineQuery(ctx context.Context, event store.BotInlineQueryPush) error { + if event.SourceID == "" || event.QueryID == 0 || event.BotUserID == 0 || event.UserID == 0 { + return fmt.Errorf("inline bot query push missing identity") + } + raw, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal inline bot query push: %w", err) + } + if err := s.c.Publish(ctx, inlineBotQueryChannel, raw).Err(); err != nil { + return fmt.Errorf("redis publish inline bot query: %w", err) + } + return nil +} + +func (s *InlineRegistryStore) SubscribeBotInlineQueries(ctx context.Context, handle func(context.Context, store.BotInlineQueryPush)) error { + if handle == nil { + return fmt.Errorf("inline bot query handler is nil") + } + pubsub := s.c.Subscribe(ctx, inlineBotQueryChannel) + defer func() { _ = pubsub.Close() }() + if _, err := pubsub.Receive(ctx); err != nil { + return fmt.Errorf("redis subscribe inline bot query: %w", err) + } + ch := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case msg, ok := <-ch: + if !ok { + return nil + } + var event store.BotInlineQueryPush + if err := json.Unmarshal([]byte(msg.Payload), &event); err != nil { + continue + } + handle(ctx, event) + } + } +} + +func redisGetJSON(ctx context.Context, c *redis.Client, key string, out any) (bool, error) { + raw, err := c.Get(ctx, key).Bytes() + if err != nil { + if errors.Is(err, redis.Nil) { + return false, nil + } + return false, fmt.Errorf("redis get %s: %w", key, err) + } + if err := json.Unmarshal(raw, out); err != nil { + _ = c.Del(ctx, key).Err() + return false, nil + } + return true, nil +} diff --git a/internal/store/redisstore/ratelimit.go b/internal/store/redisstore/ratelimit.go index 1b5a948d..6c916ba6 100644 --- a/internal/store/redisstore/ratelimit.go +++ b/internal/store/redisstore/ratelimit.go @@ -24,6 +24,13 @@ func rateLimitKey(key string) string { } func (l *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, int, error) { + return l.AllowN(ctx, key, 1, limit, window) +} + +func (l *RateLimiter) AllowN(ctx context.Context, key string, cost, limit int, window time.Duration) (bool, int, error) { + if cost <= 0 { + return true, 0, nil + } if limit <= 0 { return true, 0, nil } @@ -34,11 +41,11 @@ func (l *RateLimiter) Allow(ctx context.Context, key string, limit int, window t return false, 0, fmt.Errorf("redis rate limiter: nil client") } redisKey := rateLimitKey(key) - count, err := l.c.Incr(ctx, redisKey).Result() + count, err := l.c.IncrBy(ctx, redisKey, int64(cost)).Result() if err != nil { - return false, 0, fmt.Errorf("redis incr rate limit: %w", err) + return false, 0, fmt.Errorf("redis incrby rate limit: %w", err) } - if count == 1 { + if count == int64(cost) { if err := l.c.Expire(ctx, redisKey, window).Err(); err != nil { return false, 0, fmt.Errorf("redis expire rate limit: %w", err) } diff --git a/internal/store/redisstore/session.go b/internal/store/redisstore/session.go index 626c9533..2e5507ff 100644 --- a/internal/store/redisstore/session.go +++ b/internal/store/redisstore/session.go @@ -14,8 +14,9 @@ import ( // DefaultSessionTTL 是 session 记录的默认过期时间。 // session 是连接态:过期或丢失后,客户端重连会触发 new_session_created / bad_server_salt 重建, -// 因此 TTL 不必很长。 -const DefaultSessionTTL = 30 * 24 * time.Hour +// 因此 TTL 不必很长。每个随机 session_id 都落一条记录且断连不删,过长的 TTL +// 只会堆积死 session(移动端每次重连一条)。7 天足够覆盖常规离线窗口。 +const DefaultSessionTTL = 7 * 24 * time.Hour // SessionStore 用 Redis 实现 store.SessionStore。 type SessionStore struct { diff --git a/internal/store/redisstore/user_cache.go b/internal/store/redisstore/user_cache.go new file mode 100644 index 00000000..092ea10b --- /dev/null +++ b/internal/store/redisstore/user_cache.go @@ -0,0 +1,235 @@ +package redisstore + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + + "telesrv/internal/domain" +) + +const DefaultUserCacheTTL = 5 * time.Minute + +// UserCache stores viewer-independent base user rows in Redis. +type UserCache struct { + c *redis.Client + ttl time.Duration +} + +func NewUserCache(c *redis.Client, ttl time.Duration) *UserCache { + if ttl <= 0 { + ttl = DefaultUserCacheTTL + } + return &UserCache{c: c, ttl: ttl} +} + +func userBaseKey(id int64) string { + return fmt.Sprintf("user:base:%d", id) +} + +type userBaseValue struct { + ID int64 `json:"id"` + AccessHash int64 `json:"access_hash"` + Phone string `json:"phone"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + About string `json:"about"` + Username string `json:"username"` + CountryCode string `json:"country_code"` + Verified bool `json:"verified"` + Support bool `json:"support"` + // bot 字段必须随缓存往返:丢失会让缓存命中路径把 bot 输出成普通用户, + // 污染客户端本地缓存(TDesktop 的 bot 标记不可逆)。 + Bot bool `json:"bot,omitempty"` + BotInfoVersion int `json:"bot_info_version,omitempty"` + // premium / emoji status 同理必须随缓存往返:丢失会让缓存命中路径把 + // 会员输出成非会员,跨路径状态漂移(与 bot 列同一坑位)。 + PremiumUntil int `json:"premium_until,omitempty"` + EmojiStatusDocumentID int64 `json:"emoji_status_document_id,omitempty"` + EmojiStatusUntil int `json:"emoji_status_until,omitempty"` + // birthday / personal channel 同理必须随缓存往返:缓存命中路径丢失会让刚保存的 + // 生日 / 个人频道在重新打开资料时归零(与 bot/premium 列同一坑位)。 + BirthdayDay int `json:"birthday_day,omitempty"` + BirthdayMonth int `json:"birthday_month,omitempty"` + BirthdayYear int `json:"birthday_year,omitempty"` + PersonalChannelID int64 `json:"personal_channel_id,omitempty"` + ColorSet bool `json:"color_set,omitempty"` + Color int `json:"color,omitempty"` + ColorBackgroundEmojiID int64 `json:"color_background_emoji_id,omitempty"` + ProfileColorSet bool `json:"profile_color_set,omitempty"` + ProfileColor int `json:"profile_color,omitempty"` + ProfileColorBackgroundEmojiID int64 `json:"profile_color_background_emoji_id,omitempty"` + LastSeenAt int `json:"last_seen_at"` +} + +func baseValueFromUser(u domain.User) userBaseValue { + return userBaseValue{ + ID: u.ID, + AccessHash: u.AccessHash, + Phone: u.Phone, + FirstName: u.FirstName, + LastName: u.LastName, + About: u.About, + Username: u.Username, + CountryCode: u.CountryCode, + Verified: u.Verified, + Support: u.Support, + Bot: u.Bot, + BotInfoVersion: u.BotInfoVersion, + PremiumUntil: u.PremiumUntil, + EmojiStatusDocumentID: u.EmojiStatusDocumentID, + EmojiStatusUntil: u.EmojiStatusUntil, + BirthdayDay: u.Birthday.Day, + BirthdayMonth: u.Birthday.Month, + BirthdayYear: u.Birthday.Year, + PersonalChannelID: u.PersonalChannelID, + ColorSet: u.Color.HasColor, + Color: u.Color.Color, + ColorBackgroundEmojiID: u.Color.BackgroundEmojiID, + ProfileColorSet: u.ProfileColor.HasColor, + ProfileColor: u.ProfileColor.Color, + ProfileColorBackgroundEmojiID: u.ProfileColor.BackgroundEmojiID, + LastSeenAt: u.LastSeenAt, + } +} + +func (v userBaseValue) user() domain.User { + return domain.User{ + ID: v.ID, + AccessHash: v.AccessHash, + Phone: v.Phone, + FirstName: v.FirstName, + LastName: v.LastName, + About: v.About, + Username: v.Username, + CountryCode: v.CountryCode, + Verified: v.Verified, + Support: v.Support, + Bot: v.Bot, + BotInfoVersion: v.BotInfoVersion, + PremiumUntil: v.PremiumUntil, + EmojiStatusDocumentID: v.EmojiStatusDocumentID, + EmojiStatusUntil: v.EmojiStatusUntil, + Birthday: domain.Birthday{Day: v.BirthdayDay, Month: v.BirthdayMonth, Year: v.BirthdayYear}, + PersonalChannelID: v.PersonalChannelID, + Color: domain.PeerColor{ + HasColor: v.ColorSet, + Color: v.Color, + BackgroundEmojiID: v.ColorBackgroundEmojiID, + }, + ProfileColor: domain.PeerColor{ + HasColor: v.ProfileColorSet, + Color: v.ProfileColor, + BackgroundEmojiID: v.ProfileColorBackgroundEmojiID, + }, + LastSeenAt: v.LastSeenAt, + } +} + +func (s *UserCache) GetByIDs(ctx context.Context, ids []int64) (map[int64]domain.User, error) { + if s == nil || s.c == nil || len(ids) == 0 { + return map[int64]domain.User{}, nil + } + unique := uniqueCacheUserIDs(ids) + if len(unique) == 0 { + return map[int64]domain.User{}, nil + } + keys := make([]string, 0, len(unique)) + for _, id := range unique { + keys = append(keys, userBaseKey(id)) + } + values, err := s.c.MGet(ctx, keys...).Result() + if err != nil { + return nil, fmt.Errorf("redis mget user base: %w", err) + } + out := make(map[int64]domain.User, len(values)) + corruptKeys := make([]string, 0) + for i, value := range values { + if value == nil { + continue + } + raw, ok := value.(string) + if !ok { + corruptKeys = append(corruptKeys, keys[i]) + continue + } + var decoded userBaseValue + if err := json.Unmarshal([]byte(raw), &decoded); err != nil || decoded.ID == 0 || decoded.ID != unique[i] { + corruptKeys = append(corruptKeys, keys[i]) + continue + } + out[decoded.ID] = decoded.user() + } + if len(corruptKeys) > 0 { + _ = s.c.Del(ctx, corruptKeys...).Err() + } + return out, nil +} + +func (s *UserCache) PutMany(ctx context.Context, users []domain.User) error { + if s == nil || s.c == nil || len(users) == 0 { + return nil + } + pipe := s.c.Pipeline() + writes := 0 + seen := make(map[int64]struct{}, len(users)) + for _, u := range users { + if u.ID == 0 { + continue + } + if _, ok := seen[u.ID]; ok { + continue + } + seen[u.ID] = struct{}{} + raw, err := json.Marshal(baseValueFromUser(u)) + if err != nil { + return fmt.Errorf("marshal user base cache: %w", err) + } + pipe.Set(ctx, userBaseKey(u.ID), raw, s.ttl) + writes++ + } + if writes == 0 { + return nil + } + if _, err := pipe.Exec(ctx); err != nil { + return fmt.Errorf("redis set user base: %w", err) + } + return nil +} + +func (s *UserCache) Delete(ctx context.Context, ids []int64) error { + if s == nil || s.c == nil || len(ids) == 0 { + return nil + } + unique := uniqueCacheUserIDs(ids) + if len(unique) == 0 { + return nil + } + keys := make([]string, 0, len(unique)) + for _, id := range unique { + keys = append(keys, userBaseKey(id)) + } + if err := s.c.Del(ctx, keys...).Err(); err != nil { + return fmt.Errorf("redis delete user base: %w", err) + } + return nil +} + +func uniqueCacheUserIDs(ids []int64) []int64 { + out := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} diff --git a/internal/store/redisstore/user_cache_integration_test.go b/internal/store/redisstore/user_cache_integration_test.go new file mode 100644 index 00000000..bbd10a1f --- /dev/null +++ b/internal/store/redisstore/user_cache_integration_test.go @@ -0,0 +1,118 @@ +package redisstore + +import ( + "context" + "os" + "testing" + "time" + + "telesrv/internal/domain" +) + +func TestUserCacheRoundTrip(t *testing.T) { + addr := os.Getenv("TELESRV_TEST_REDIS_ADDR") + if addr == "" { + t.Skip("set TELESRV_TEST_REDIS_ADDR to run redis integration test") + } + ctx := context.Background() + c, err := Open(ctx, addr, "", 0) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + + const userID int64 = 77000001 + cache := NewUserCache(c, time.Minute) + t.Cleanup(func() { _ = c.Del(ctx, userBaseKey(userID), userBaseKey(userID+1)).Err() }) + + want := domain.User{ + ID: userID, + AccessHash: 12345, + Phone: "15550000001", + FirstName: "Alice", + LastName: "Base", + About: "about", + Username: "alice_base", + CountryCode: "US", + Verified: true, + Support: true, + LastSeenAt: 99, + Contact: true, + PhotoID: 42, + Birthday: domain.Birthday{Day: 14, Month: 2, Year: 1990}, + PersonalChannelID: 555, + } + if err := cache.PutMany(ctx, []domain.User{want, want}); err != nil { + t.Fatalf("put: %v", err) + } + got, err := cache.GetByIDs(ctx, []int64{userID, userID + 1, userID}) + if err != nil { + t.Fatalf("get: %v", err) + } + u, ok := got[userID] + if !ok { + t.Fatalf("cached user %d not found", userID) + } + if u.ID != want.ID || u.AccessHash != want.AccessHash || u.FirstName != want.FirstName || u.Username != want.Username || u.LastSeenAt != want.LastSeenAt { + t.Fatalf("cached base mismatch: got %+v want %+v", u, want) + } + if u.Contact || u.PhotoID != 0 { + t.Fatalf("viewer overlay leaked into base cache: %+v", u) + } + // birthday / personal channel 必须随缓存往返(缓存命中路径丢失会让刚保存的值归零)。 + if u.Birthday != want.Birthday || u.PersonalChannelID != want.PersonalChannelID { + t.Fatalf("birthday/personal channel lost in base cache round-trip: got birthday=%+v personal=%d", u.Birthday, u.PersonalChannelID) + } + if _, ok := got[userID+1]; ok { + t.Fatalf("unexpected missing user hit: %+v", got[userID+1]) + } + + if err := c.Set(ctx, userBaseKey(userID+1), "{bad json", time.Minute).Err(); err != nil { + t.Fatalf("set corrupt: %v", err) + } + got, err = cache.GetByIDs(ctx, []int64{userID + 1}) + if err != nil { + t.Fatalf("get corrupt: %v", err) + } + if len(got) != 0 { + t.Fatalf("corrupt cache returned users: %+v", got) + } + if n, err := c.Exists(ctx, userBaseKey(userID+1)).Result(); err != nil || n != 0 { + t.Fatalf("corrupt key exists=%d err=%v, want deleted", n, err) + } + + if err := cache.PutMany(ctx, []domain.User{{ID: userID + 1, AccessHash: 6789, FirstName: "WrongKey"}}); err != nil { + t.Fatalf("put mismatched payload source: %v", err) + } + raw, err := c.Get(ctx, userBaseKey(userID+1)).Result() + if err != nil { + t.Fatalf("get raw mismatched source: %v", err) + } + if err := c.Set(ctx, userBaseKey(userID), raw, time.Minute).Err(); err != nil { + t.Fatalf("set mismatched payload: %v", err) + } + got, err = cache.GetByIDs(ctx, []int64{userID}) + if err != nil { + t.Fatalf("get mismatched payload: %v", err) + } + if len(got) != 0 { + t.Fatalf("mismatched payload returned users: %+v", got) + } + if n, err := c.Exists(ctx, userBaseKey(userID)).Result(); err != nil || n != 0 { + t.Fatalf("mismatched key exists=%d err=%v, want deleted", n, err) + } + + if err := cache.PutMany(ctx, []domain.User{want}); err != nil { + t.Fatalf("restore user: %v", err) + } + if err := cache.Delete(ctx, []int64{userID}); err != nil { + t.Fatalf("delete: %v", err) + } + got, err = cache.GetByIDs(ctx, []int64{userID}) + if err != nil { + t.Fatalf("get after delete: %v", err) + } + if len(got) != 0 { + t.Fatalf("cache hit after delete: %+v", got) + } +} diff --git a/internal/store/redisstore/user_counter_allocator.go b/internal/store/redisstore/user_counter_allocator.go deleted file mode 100644 index b5f77c18..00000000 --- a/internal/store/redisstore/user_counter_allocator.go +++ /dev/null @@ -1,112 +0,0 @@ -package redisstore - -import ( - "context" - "fmt" - - "github.com/redis/go-redis/v9" - - "telesrv/internal/store" -) - -// UserCounterAllocator 在一次 Lua 往返内同时分配某 user 的下一个 pts 与 box_id。 -// -// counter:pts:{userID} 与 counter:box_id:{userID} 共享 hash-tag {userID},处于同一 -// Redis Cluster slot,可被单个 Lua 脚本原子操作——把发送热路径上「pts + box 两次往返」 -// 合并成一次。语义与 PtsAllocator/BoxIDAllocator 各自调用等价:pts 账号级无洞、 -// Redis 冷 miss 时从 PG durable log 恢复基线。 -type UserCounterAllocator struct { - c *redis.Client - ptsSource store.CounterSource - boxSource store.CounterSource -} - -// NewUserCounterAllocator 创建合并 allocator。ptsSource 恢复 pts(MAX(user_update_events.pts)), -// boxSource 恢复 box_id(MAX(message_boxes.box_id))。 -func NewUserCounterAllocator(c *redis.Client, ptsSource, boxSource store.CounterSource) *UserCounterAllocator { - return &UserCounterAllocator{c: c, ptsSource: ptsSource, boxSource: boxSource} -} - -// userCountersNextScript 热路径:两 key 都存在时各自 INCR 并返回;任一缺失返回 {-1,-1} 触发恢复。 -var userCountersNextScript = redis.NewScript(` -local pts = redis.call("GET", KEYS[1]) -local box = redis.call("GET", KEYS[2]) -if pts and box then - return {redis.call("INCR", KEYS[1]), redis.call("INCR", KEYS[2])} -end -return {-1, -1} -`) - -// userCountersRecoverScript 冷路径:每个 key「缺失才用 PG 基线 SET,再 INCR」。 -// 对已存在的 key 跳过 SET 只 INCR,故对「一存一缺」也安全;Redis 单线程串行化保证无重复无洞。 -var userCountersRecoverScript = redis.NewScript(` -local function recnext(key, base) - if not redis.call("GET", key) then - redis.call("SET", key, base) - end - return redis.call("INCR", key) -end -return {recnext(KEYS[1], ARGV[1]), recnext(KEYS[2], ARGV[2])} -`) - -// NextUserCounters 返回该 user 的下一个 (pts, boxID)。 -func (a *UserCounterAllocator) NextUserCounters(ctx context.Context, userID int64) (int, int, error) { - if userID == 0 { - return 0, 0, fmt.Errorf("redis user counters: missing user id") - } - if a.c == nil { - return 0, 0, fmt.Errorf("redis user counters: nil client") - } - keys := []string{ptsKey(userID), boxIDKey(userID)} - - pts, box, err := runTwoCounters(ctx, a.c, userCountersNextScript, keys) - if err != nil { - return 0, 0, fmt.Errorf("redis next user counters: %w", err) - } - if pts != missingCounterSentinel && box != missingCounterSentinel { - return int(pts), int(box), nil - } - - // 冷路径:至少一个 key 缺失,从 PG durable log 恢复两个基线,再 recover-next。 - ptsBase, err := recoverBase(ctx, a.ptsSource, userID, "pts") - if err != nil { - return 0, 0, err - } - boxBase, err := recoverBase(ctx, a.boxSource, userID, "box_id") - if err != nil { - return 0, 0, err - } - pts, box, err = runTwoCounters(ctx, a.c, userCountersRecoverScript, keys, ptsBase, boxBase) - if err != nil { - return 0, 0, fmt.Errorf("redis recover user counters: %w", err) - } - return int(pts), int(box), nil -} - -// runTwoCounters 执行返回二元整数表的 Lua 脚本,用 .Slice() 手动解析(不依赖 Int64Slice)。 -func runTwoCounters(ctx context.Context, c *redis.Client, script *redis.Script, keys []string, args ...any) (int64, int64, error) { - raw, err := script.Run(ctx, c, keys, args...).Slice() - if err != nil { - return 0, 0, err - } - if len(raw) != 2 { - return 0, 0, fmt.Errorf("unexpected reply len %d, want 2", len(raw)) - } - a, okA := raw[0].(int64) - b, okB := raw[1].(int64) - if !okA || !okB { - return 0, 0, fmt.Errorf("unexpected reply element types %T,%T, want int64", raw[0], raw[1]) - } - return a, b, nil -} - -func recoverBase(ctx context.Context, source store.CounterSource, userID int64, name string) (int, error) { - if source == nil { - return 0, nil - } - v, err := source.Current(ctx, userID) - if err != nil { - return 0, fmt.Errorf("recover %s counter: %w", name, err) - } - return v, nil -} diff --git a/internal/store/scheduled_message.go b/internal/store/scheduled_message.go new file mode 100644 index 00000000..d14cd77d --- /dev/null +++ b/internal/store/scheduled_message.go @@ -0,0 +1,37 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// ScheduledMessageStore persists account-owned scheduled messages. +type ScheduledMessageStore interface { + CreateScheduledMessage(ctx context.Context, req domain.ScheduleMessageRequest) (domain.ScheduledMessage, error) + EditScheduledMessage(ctx context.Context, req domain.EditScheduledMessageRequest) (domain.ScheduledMessage, error) + ListScheduledMessages(ctx context.Context, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) + GetScheduledMessages(ctx context.Context, filter domain.ScheduledMessageFilter) (domain.ScheduledMessageList, error) + DeleteScheduledMessages(ctx context.Context, filter domain.ScheduledMessageFilter, date int) ([]domain.ScheduledMessage, error) + ClaimScheduledMessages(ctx context.Context, claim domain.ScheduledMessageClaim) ([]domain.ScheduledMessage, error) + ClaimDueScheduledMessages(ctx context.Context, now, limit, leaseSeconds int) ([]domain.ScheduledMessage, error) + MarkScheduledMessageSent(ctx context.Context, ownerUserID int64, id, sentMessageID, date int) error + ReleaseScheduledMessage(ctx context.Context, ownerUserID int64, id int, errText string) error + HasScheduledMessages(ctx context.Context, ownerUserID int64, peer domain.Peer) (bool, error) +} + +// HistoryTTLStore persists peer-level TTL settings and finds expired messages. +type HistoryTTLStore interface { + GetPrivateHistoryTTL(ctx context.Context, ownerUserID int64, peer domain.Peer) (int, error) + SetPrivateHistoryTTL(ctx context.Context, ownerUserID int64, peer domain.Peer, period int) error + DefaultHistoryTTL(ctx context.Context, userID int64) (int, error) + SetDefaultHistoryTTL(ctx context.Context, userID int64, period int) error + ClaimExpiredPrivateMessages(ctx context.Context, now, limit int) ([]domain.DeleteMessagesRequest, error) +} + +// ChannelHistoryTTLStore persists channel-level TTL settings and finds expired +// channel messages. +type ChannelHistoryTTLStore interface { + SetChannelHistoryTTL(ctx context.Context, userID, channelID int64, period int, date int) (domain.Channel, []int64, error) + ClaimExpiredChannelMessages(ctx context.Context, now, limit int) ([]domain.DeleteChannelMessagesRequest, error) +} diff --git a/internal/store/secretchat.go b/internal/store/secretchat.go new file mode 100644 index 00000000..63efca6e --- /dev/null +++ b/internal/store/secretchat.go @@ -0,0 +1,74 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// SecretChatStore 持久化私聊密聊握手状态机真值(memory/postgres 双实现,行为契约由 +// storetest 钉死)。服务端是盲中继:g_a/g_b/key_fingerprint 原样不透明存储。 +// 设计见 docs/secret-chat-module.md。 +type SecretChatStore interface { + // CreateSecretChat 插入 requested 态密聊。chat_id 主键撞键返回 + // domain.ErrSecretChatIDConflict(调用方按 AtLeast 重分配重试)。 + CreateSecretChat(ctx context.Context, chat domain.SecretChat) error + // GetSecretChat 按 chat_id 取密聊。 + GetSecretChat(ctx context.Context, chatID int) (domain.SecretChat, bool, error) + // GetByAdminRandom 幂等查询:按发起设备 perm auth_key + random_id 找既有密聊 + //(同 random_id 重发 requestEncryption 返回同 chat)。 + GetByAdminRandom(ctx context.Context, adminAuthKeyID int64, randomID int32) (domain.SecretChat, bool, error) + // AcceptSecretChat 原子 CAS 绑定:仅当 requested 且 participant_auth_key_id 未绑定时 + // 迁移到 normal、落 g_b/key_fingerprint、绑定接受设备。并发第二个 accept 或已成型/ + // 已销毁分别返回 domain.ErrSecretChatAlreadyAccepted / ErrSecretChatAlreadyDeclined; + // chat_id 不存在返回 domain.ErrSecretChatNotFound。 + AcceptSecretChat(ctx context.Context, chatID int, participantAuthKeyID int64, gb []byte, keyFingerprint int64) (domain.SecretChat, error) + // DiscardSecretChat 迁移到 discarded(幂等);已是终态时 already=true 返回当前快照; + // chat_id 不存在返回 domain.ErrSecretChatNotFound。 + DiscardSecretChat(ctx context.Context, chatID int, historyDeleted bool) (chat domain.SecretChat, already bool, err error) + // ListActiveSecretChatsByAuthKey 返回绑定该设备 perm auth_key(作为发起方 admin 或 + // 接受方 participant)且未终态的密聊,按 chat_id 升序。设备登出 / 授权撤销时用于级联 + // discard 并通知对端(避免对端继续往死 auth_key 投递的静默死链)。authKeyID==0 返回 nil。 + ListActiveSecretChatsByAuthKey(ctx context.Context, authKeyID int64) ([]domain.SecretChat, error) + // MaxSecretChatID 返回当前最大 chat_id(id 计数器冷恢复 / 撞键自愈用,空表返回 0)。 + MaxSecretChatID(ctx context.Context) (int, error) +} + +// EncryptedQueueStore 持久化密聊 qts 投递队列(设备级,memory/postgres 双实现)。 +// updateNewEncryptedMessage 按接收设备 qts 序列投递;qts 分配 + 写队列 + 推进 +// reserved 水位在单事务内完成(无空洞)。盲中继:bytes 原样存储。 +// 设计见 docs/secret-chat-module.md §7。 +type EncryptedQueueStore interface { + // AppendEncryptedMessage 为接收设备分配下一个 qts(首值 1)并写入队列,与 reserved_qts + // 推进同事务。幂等:同 (receiver_auth_key_id, chat_id, random_id) 已存在时返回既有行 + + // existing=true(不重分配 qts,发送方丢响应重发拿同一 qts/date)。 + AppendEncryptedMessage(ctx context.Context, msg domain.SecretChatMessage) (stored domain.SecretChatMessage, existing bool, err error) + // ListEncryptedMessagesSince 返回接收设备 qts > sinceQts 的连续消息(qts 升序,最多 limit)。 + ListEncryptedMessagesSince(ctx context.Context, receiverAuthKeyID int64, sinceQts, limit int) ([]domain.SecretChatMessage, error) + // ReservedQts 返回接收设备当前已分配的最高 qts(getState 用,无行返 0)。 + ReservedQts(ctx context.Context, receiverAuthKeyID int64) (int, error) + // AckEncryptedMessages 推进设备 confirmed_qts 到 maxQts(GREATEST 幂等,回退忽略), + // 标记 qts<=maxQts 行为 acked(receivedQueue)。 + AckEncryptedMessages(ctx context.Context, receiverAuthKeyID int64, maxQts int) error + + // AppendStateEvent 写入无 qts 的 durable 密聊状态事件(握手/已读,离线补偿)。 + AppendStateEvent(ctx context.Context, ev domain.EncryptedStateEvent) (int64, error) + // ListUndeliveredStateEvents 返回某设备未投递的状态事件(账号级 target_auth_key_id=0 + // 或绑定本设备),按 id 升序,最多 limit。 + ListUndeliveredStateEvents(ctx context.Context, targetUserID, deviceAuthKeyID int64, limit int) ([]domain.EncryptedStateEvent, error) + // MarkStateEventsDelivered 登记某设备已投递这些事件(幂等)。 + MarkStateEventsDelivered(ctx context.Context, deviceAuthKeyID int64, eventIDs []int64) error + + // PutEncryptedFile 持久化密聊文件元数据快照(铸造后写一次,幂等覆盖)。 + PutEncryptedFile(ctx context.Context, ownerUserID int64, ref domain.EncryptedFileRef) error + // GetEncryptedFile 按 id + access_hash 回查文件快照(inputEncryptedFile 复用路径)。 + GetEncryptedFile(ctx context.Context, id, accessHash int64) (domain.EncryptedFileRef, bool, error) +} + +// SecretChatIDAllocator 分配全局单调 chat_id(int32 量级)。Redis INCR 实现 + PG +// CounterSource 冷恢复;撞键时 AtLeast 自愈。语义同 ChannelIDAllocator。 +type SecretChatIDAllocator interface { + NextSecretChatID(ctx context.Context) (int, error) + NextSecretChatIDAtLeast(ctx context.Context, floor int) (int, error) + CurrentSecretChatID(ctx context.Context) (int, error) +} diff --git a/internal/store/star_gift.go b/internal/store/star_gift.go new file mode 100644 index 00000000..37372003 --- /dev/null +++ b/internal/store/star_gift.go @@ -0,0 +1,25 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// StarGiftStore 持久化 peer 收到的 Star 礼物实例(peer_star_gifts)。礼物目录是合成的 +// 内存集合,不在此存储。 +type StarGiftStore interface { + // Create 写一条收到的礼物实例,返回行 id;频道礼物未显式给 saved_id 时以该行 id 作为 saved_id。 + Create(ctx context.Context, gift domain.SavedStarGift) (int64, error) + // ListByOwner 按 id DESC keyset 分页返回某 owner 未转换的礼物;excludeUnsaved 时只返展示在资料的。 + ListByOwner(ctx context.Context, owner domain.Peer, excludeUnsaved bool, offset string, limit int) (domain.SavedStarGiftPage, error) + // GetByRef 按协议引用取礼物实例:用户用 msg_id,频道用 saved_id。 + GetByRef(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, bool, error) + // CountByOwner 返回某 owner 展示在资料的礼物数(非转换、非隐藏),供 full.stargifts_count。 + CountByOwner(ctx context.Context, owner domain.Peer) (int, error) + // SetUnsaved 切换礼物在资料的展示(saveStarGift);返回是否命中一行。 + SetUnsaved(ctx context.Context, ref domain.SavedStarGiftRef, unsaved bool) (bool, error) + // MarkConverted 幂等地把礼物标记为已转换(convertStarGift),返回该行供调用方据 ConvertStars + // 入账;已转换返回 domain.ErrStarGiftAlreadyConverted,不存在返回 domain.ErrStarGiftNotFound。 + MarkConverted(ctx context.Context, ref domain.SavedStarGiftRef) (domain.SavedStarGift, error) +} diff --git a/internal/store/stars.go b/internal/store/stars.go new file mode 100644 index 00000000..48cb8669 --- /dev/null +++ b/internal/store/stars.go @@ -0,0 +1,24 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// StarsStore 持久化 Stars 本地账本:per-user 余额 + 交易流水。 +// 借记/贷记/授予必须各自在单个事务内原子完成(余额与流水不漂移)。 +type StarsStore interface { + // GetBalance 返回账号当前余额;无行时返回零值(Balance 0, Granted false)。 + GetBalance(ctx context.Context, userID int64) (domain.StarsBalance, error) + // EnsureGrant 幂等地应用一次起始授予:仅当从未授予时贷记 amount 并置 granted=true、 + // 写一条 grant 流水,全在单事务内完成。返回最新余额 + 本次是否实际授予。 + EnsureGrant(ctx context.Context, userID, amount int64, date int) (domain.StarsBalance, bool, error) + // Credit 在单事务内为账号入账(amount>0)并写流水(amount=+x);余额行不存在则创建。 + Credit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) + // Debit 在单事务内做 SELECT ... FOR UPDATE 充足性检查后扣款(amount>0),写流水(amount=-x)。 + // 余额不足返回 domain.ErrStarsInsufficient。 + Debit(ctx context.Context, userID, amount int64, reason domain.StarsTransactionReason, peer domain.Peer, date int, title, desc string) (domain.StarsBalance, error) + // ListTransactions 按 id DESC keyset 分页返回一页流水 + 当前余额。 + ListTransactions(ctx context.Context, userID int64, offset string, limit int) (domain.StarsTransactionPage, error) +} diff --git a/internal/store/storetest/groupcall.go b/internal/store/storetest/groupcall.go new file mode 100644 index 00000000..cd295dce --- /dev/null +++ b/internal/store/storetest/groupcall.go @@ -0,0 +1,393 @@ +// Package storetest 提供跨实现(memory/postgres)共享的 store 行为契约用例。 +// 群通话语义(version 单调、rejoin upsert、ssrc 唯一、join_muted 策略)属双 store +// 漂移高发区:凡动语义必须两实现同跑本契约 + PG 集成(TELESRV_TEST_POSTGRES_DSN)。 +package storetest + +import ( + "context" + "errors" + "testing" + "time" + + "telesrv/internal/domain" + "telesrv/internal/store" +) + +// baseNow 是用例时间基准。必须取运行时刻:集成测试可能与运行中的开发服务器 +// 共用同一库,远古 last_check_date 会被线上 sweeper(45s TTL)当幽灵参与者 +// 清掉(version++/count--),污染断言。 +func baseNow() int { + return int(time.Now().Unix()) +} + +// GroupCallStoreFactory 为每个用例提供干净的 store 与不冲突的 channel id。 +type GroupCallStoreFactory func(t *testing.T) (st store.GroupCallStore, channelID int64) + +// RunGroupCallStoreContract 跑全部群通话 store 契约用例。 +func RunGroupCallStoreContract(t *testing.T, factory GroupCallStoreFactory) { + t.Helper() + t.Run("CreateAndActiveUniq", func(t *testing.T) { contractCreateActiveUniq(t, factory) }) + t.Run("JoinVersionMonotonic", func(t *testing.T) { contractJoinVersion(t, factory) }) + t.Run("SSRCDuplicateAndRejoin", func(t *testing.T) { contractSSRC(t, factory) }) + t.Run("JoinMutedPolicy", func(t *testing.T) { contractJoinMuted(t, factory) }) + t.Run("TouchAndSweep", func(t *testing.T) { contractTouchSweep(t, factory) }) + t.Run("DiscardClearsParticipants", func(t *testing.T) { contractDiscard(t, factory) }) + t.Run("ListPagination", func(t *testing.T) { contractListPagination(t, factory) }) + t.Run("UpdateParticipant", func(t *testing.T) { contractUpdateParticipant(t, factory) }) + t.Run("ResetAllParticipants", func(t *testing.T) { contractReset(t, factory) }) + t.Run("JoinVideoStateLifecycle", func(t *testing.T) { contractJoinVideoState(t, factory) }) +} + +func newContractCall(t *testing.T, st store.GroupCallStore, channelID, id int64) domain.GroupCall { + t.Helper() + call, err := st.CreateGroupCall(context.Background(), domain.GroupCall{ + ID: id, AccessHash: id + 7, ChannelID: channelID, CreatorUserID: 1, CreatedAt: baseNow(), + }) + if err != nil { + t.Fatalf("create group call: %v", err) + } + return call +} + +func join(t *testing.T, st store.GroupCallStore, callID, userID, ssrc int64, now int) domain.GroupCallMutation { + t.Helper() + mut, err := st.JoinGroupCall(context.Background(), domain.JoinGroupCallRequest{ + CallID: callID, UserID: userID, SSRC: ssrc, Now: now, + }) + if err != nil { + t.Fatalf("join call %d user %d: %v", callID, userID, err) + } + return mut +} + +func contractCreateActiveUniq(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + if call.Version != 1 || !call.Active() { + t.Fatalf("created call = %+v", call) + } + // 同频道二次建会:唯一活跃约束。 + if _, err := st.CreateGroupCall(ctx, domain.GroupCall{ID: channelID*100 + 2, AccessHash: 9, ChannelID: channelID, CreatorUserID: 1, CreatedAt: baseNow()}); !errors.Is(err, domain.ErrGroupCallAlreadyStarted) { + t.Fatalf("second active call err = %v, want ErrGroupCallAlreadyStarted", err) + } + // discard 后可重新建会。 + if _, _, err := st.DiscardGroupCall(ctx, call.ID, baseNow()+500); err != nil { + t.Fatalf("discard: %v", err) + } + if _, err := st.CreateGroupCall(ctx, domain.GroupCall{ID: channelID*100 + 3, AccessHash: 9, ChannelID: channelID, CreatorUserID: 1, CreatedAt: baseNow() + 501}); err != nil { + t.Fatalf("create after discard: %v", err) + } +} + +func contractJoinVersion(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + call := newContractCall(t, st, channelID, channelID*100+1) + // 每次参与者变更 version 严格 +1。 + v := call.Version + for i := int64(1); i <= 3; i++ { + mut := join(t, st, call.ID, 100+i, 9000+i, baseNow()+int(i)) + if mut.Call.Version != v+int(i) { + t.Fatalf("join %d version = %d, want %d", i, mut.Call.Version, v+int(i)) + } + if mut.Call.ParticipantsCount != int(i) { + t.Fatalf("join %d count = %d, want %d", i, mut.Call.ParticipantsCount, i) + } + } + mut, err := st.LeaveGroupCall(context.Background(), call.ID, 101, baseNow()+100) + if err != nil || mut.Call.Version != v+4 || mut.Call.ParticipantsCount != 2 || !mut.Participant.Left { + t.Fatalf("leave = %+v err=%v", mut, err) + } + // 重复 leave。 + if _, err := st.LeaveGroupCall(context.Background(), call.ID, 101, baseNow()+101); !errors.Is(err, domain.ErrGroupCallNotJoined) { + t.Fatalf("double leave err = %v, want ErrGroupCallNotJoined", err) + } +} + +func contractSSRC(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + now := baseNow() + join(t, st, call.ID, 101, 7001, now) + // 他人撞 ssrc。 + if _, err := st.JoinGroupCall(ctx, domain.JoinGroupCallRequest{CallID: call.ID, UserID: 102, SSRC: 7001, Now: now + 1}); !errors.Is(err, domain.ErrGroupCallSSRCDuplicate) { + t.Fatalf("ssrc duplicate err = %v, want ErrGroupCallSSRCDuplicate", err) + } + // 本人 rejoin 换 ssrc:保留 join_date、count 不变。 + mut := join(t, st, call.ID, 101, 7002, now+500) + if mut.Participant.SSRC != 7002 || mut.Participant.JoinDate != now || mut.Call.ParticipantsCount != 1 { + t.Fatalf("rejoin = %+v", mut) + } + // 离开后 rejoin:join_date 重置、count 恢复。 + if _, err := st.LeaveGroupCall(ctx, call.ID, 101, now+600); err != nil { + t.Fatalf("leave: %v", err) + } + mut = join(t, st, call.ID, 101, 7003, now+700) + if mut.Participant.JoinDate != now+700 || mut.Call.ParticipantsCount != 1 || mut.Participant.Left { + t.Fatalf("rejoin after leave = %+v", mut) + } + // 旧 ssrc 释放后他人可用。 + if _, err := st.JoinGroupCall(ctx, domain.JoinGroupCallRequest{CallID: call.ID, UserID: 102, SSRC: 7001, Now: now + 800}); err != nil { + t.Fatalf("reuse released ssrc: %v", err) + } +} + +// contractJoinVideoState:join 携带 VideoJSON 整体替换、rejoin 清空 presentation +//(主连接 rejoin 后客户端会重发 joinGroupCallPresentation,旧屏幕登记必须作废)。 +func contractJoinVideoState(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + now := baseNow() + video1 := []byte(`{"endpoint":"audio-9001","active":false}`) + mut, err := st.JoinGroupCall(ctx, domain.JoinGroupCallRequest{ + CallID: call.ID, UserID: 101, SSRC: 9001, VideoJSON: video1, Now: now, + }) + if err != nil || string(mut.Participant.VideoJSON) != string(video1) { + t.Fatalf("join video_json = %s err=%v", mut.Participant.VideoJSON, err) + } + // 共享中:presentation_json 落库。 + pres := []byte(`{"endpoint":"presentation-9100","active":true,"audio_source":9100}`) + if _, changed, err := st.UpdateParticipant(ctx, call.ID, 101, domain.GroupCallParticipantUpdate{ + PresentationJSON: &pres, Now: now + 1, + }); err != nil || !changed { + t.Fatalf("set presentation changed=%v err=%v", changed, err) + } + // rejoin:video_json 整体替换、presentation_json 必须清空。 + video2 := []byte(`{"endpoint":"audio-9002","active":true}`) + mut, err = st.JoinGroupCall(ctx, domain.JoinGroupCallRequest{ + CallID: call.ID, UserID: 101, SSRC: 9002, VideoJSON: video2, Now: now + 2, + }) + if err != nil { + t.Fatalf("rejoin: %v", err) + } + if string(mut.Participant.VideoJSON) != string(video2) { + t.Fatalf("rejoin video_json = %s, want replaced", mut.Participant.VideoJSON) + } + if len(mut.Participant.PresentationJSON) != 0 { + t.Fatalf("rejoin presentation_json = %s, want cleared", mut.Participant.PresentationJSON) + } + // 清空 presentation 的字段级更新(leaveGroupCallPresentation 路径)。 + if _, changed, err := st.UpdateParticipant(ctx, call.ID, 101, domain.GroupCallParticipantUpdate{ + PresentationJSON: &pres, Now: now + 3, + }); err != nil || !changed { + t.Fatalf("re-set presentation changed=%v err=%v", changed, err) + } + empty := []byte(nil) + mut2, changed, err := st.UpdateParticipant(ctx, call.ID, 101, domain.GroupCallParticipantUpdate{ + PresentationJSON: &empty, Now: now + 4, + }) + if err != nil || !changed || len(mut2.Participant.PresentationJSON) != 0 { + t.Fatalf("clear presentation = %s changed=%v err=%v", mut2.Participant.PresentationJSON, changed, err) + } +} + +func contractJoinMuted(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + if _, _, err := st.SetGroupCallJoinMuted(ctx, call.ID, true); err != nil { + t.Fatalf("set join muted: %v", err) + } + // ⚠ P1-4:join_muted 时普通成员 muted=true 且 muted_by_admin=true(不可自行开麦)。 + mut, err := st.JoinGroupCall(ctx, domain.JoinGroupCallRequest{CallID: call.ID, UserID: 201, SSRC: 8001, Now: baseNow()}) + if err != nil || !mut.Participant.Muted || !mut.Participant.MutedByAdmin { + t.Fatalf("plain member under join_muted = %+v err=%v", mut.Participant, err) + } + // 管理员不受 join_muted 影响。 + mut, err = st.JoinGroupCall(ctx, domain.JoinGroupCallRequest{CallID: call.ID, UserID: 202, SSRC: 8002, IsAdmin: true, Now: baseNow()}) + if err != nil || mut.Participant.Muted || mut.Participant.MutedByAdmin { + t.Fatalf("admin under join_muted = %+v err=%v", mut.Participant, err) + } +} + +func contractTouchSweep(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + now := baseNow() + join(t, st, call.ID, 101, 7001, now) + join(t, st, call.ID, 102, 7002, now) + + ssrcs, joined, err := st.TouchParticipant(ctx, call.ID, 101, now+40) + if err != nil || !joined || len(ssrcs) != 1 || ssrcs[0] != 7001 { + t.Fatalf("touch = %v joined=%v err=%v", ssrcs, joined, err) + } + if _, joined, err := st.TouchParticipant(ctx, call.ID, 999, now+40); err != nil || joined { + t.Fatalf("touch non-member joined=%v err=%v, want false(客户端据空集 rejoin)", joined, err) + } + // 101 在 now+40 刷过水位、102 停在 now:cutoff=now+20 只清 102。 + muts, err := st.SweepStaleParticipants(ctx, now+20, now+50, 10) + if err != nil || len(muts) != 1 || muts[0].Participant.UserID != 102 || !muts[0].Participant.Left { + t.Fatalf("sweep = %+v err=%v", muts, err) + } + if muts[0].Call.ParticipantsCount != 1 { + t.Fatalf("sweep count = %d, want 1", muts[0].Call.ParticipantsCount) + } + // 幂等。 + if muts, err := st.SweepStaleParticipants(ctx, now+20, now+51, 10); err != nil || len(muts) != 0 { + t.Fatalf("second sweep = %+v err=%v", muts, err) + } +} + +func contractDiscard(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + now := baseNow() + join(t, st, call.ID, 101, 7001, now) + join(t, st, call.ID, 102, 7002, now+1) + discarded, active, err := st.DiscardGroupCall(ctx, call.ID, call.CreatedAt+500) + if err != nil || discarded.Active() || len(active) != 2 { + t.Fatalf("discard = %+v active=%d err=%v", discarded, len(active), err) + } + if discarded.Duration != 500 || discarded.ParticipantsCount != 0 { + t.Fatalf("discarded snapshot = %+v", discarded) + } + if _, _, err := st.DiscardGroupCall(ctx, call.ID, call.CreatedAt+501); !errors.Is(err, domain.ErrGroupCallDiscarded) { + t.Fatalf("double discard err = %v, want ErrGroupCallDiscarded", err) + } + // 终态拒绝 join。 + if _, err := st.JoinGroupCall(ctx, domain.JoinGroupCallRequest{CallID: call.ID, UserID: 103, SSRC: 7003, Now: now + 502}); !errors.Is(err, domain.ErrGroupCallDiscarded) { + t.Fatalf("join discarded err = %v, want ErrGroupCallDiscarded", err) + } +} + +func contractListPagination(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + now := baseNow() + for i := int64(1); i <= 5; i++ { + join(t, st, call.ID, 300+i, 9100+i, now+int(i)) + } + page, err := st.ListParticipants(ctx, call.ID, "", 2) + if err != nil || page.Count != 5 || len(page.Participants) != 2 || page.NextOffset == "" { + t.Fatalf("page1 = %+v err=%v", page, err) + } + if page.Version == 0 { + t.Fatalf("page must carry current version(客户端跳号 reload 依赖)") + } + seen := map[int64]bool{} + for _, p := range page.Participants { + seen[p.UserID] = true + } + page2, err := st.ListParticipants(ctx, call.ID, page.NextOffset, 10) + if err != nil || len(page2.Participants) != 3 { + t.Fatalf("page2 = %+v err=%v", page2, err) + } + for _, p := range page2.Participants { + if seen[p.UserID] { + t.Fatalf("pagination overlap on user %d", p.UserID) + } + } +} + +func contractUpdateParticipant(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + now := baseNow() + mut := join(t, st, call.ID, 101, 7001, now) + v := mut.Call.Version + muted := true + out, changed, err := st.UpdateParticipant(ctx, call.ID, 101, domain.GroupCallParticipantUpdate{Muted: &muted, Now: now + 100}) + if err != nil || !changed || !out.Participant.Muted || out.Call.Version != v+1 { + t.Fatalf("update = %+v changed=%v err=%v", out, changed, err) + } + // 无变化不动 version。 + out, changed, err = st.UpdateParticipant(ctx, call.ID, 101, domain.GroupCallParticipantUpdate{Muted: &muted, Now: now + 101}) + if err != nil || changed || out.Call.Version != v+1 { + t.Fatalf("noop update = %+v changed=%v err=%v", out, changed, err) + } + // 未在会成员。 + if _, _, err := st.UpdateParticipant(ctx, call.ID, 999, domain.GroupCallParticipantUpdate{Muted: &muted}); !errors.Is(err, domain.ErrGroupCallNotJoined) { + t.Fatalf("update non-member err = %v, want ErrGroupCallNotJoined", err) + } +} + +func contractReset(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + now := baseNow() + join(t, st, call.ID, 101, 7001, now) + mut := join(t, st, call.ID, 102, 7002, now+1) + v := mut.Call.Version + calls, err := st.ResetAllParticipants(ctx, now+100) + if err != nil || len(calls) != 1 { + t.Fatalf("reset = %d calls err=%v", len(calls), err) + } + if calls[0].ParticipantsCount != 0 || calls[0].Version != v+1 { + t.Fatalf("reset call = %+v, want count=0 version=%d", calls[0], v+1) + } + // 重启清理后客户端 touch 返回未在会 → 触发 rejoin。 + if _, joined, err := st.TouchParticipant(ctx, call.ID, 101, now+101); err != nil || joined { + t.Fatalf("touch after reset joined=%v err=%v, want false", joined, err) + } +} + +// 以下为 M2 契约:per-viewer overrides 与举手序号(追加进 RunGroupCallStoreContract +// 之外单独可调,避免改既有签名——两实现测试各自调用 RunGroupCallStoreM2Contract)。 + +// RunGroupCallStoreM2Contract 跑 overrides 与 raise-hand 契约。 +func RunGroupCallStoreM2Contract(t *testing.T, factory GroupCallStoreFactory) { + t.Helper() + t.Run("ParticipantOverrides", func(t *testing.T) { contractOverrides(t, factory) }) + t.Run("RaiseHandRatingMonotonic", func(t *testing.T) { contractRaiseHand(t, factory) }) +} + +func contractOverrides(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + now := baseNow() + join(t, st, call.ID, 101, 7001, now) + join(t, st, call.ID, 102, 7002, now+1) + + // 写覆盖:仅 setter 视图,不动 call version。 + before, _, err := st.GetGroupCall(ctx, call.ID) + if err != nil { + t.Fatalf("get call: %v", err) + } + if err := st.SetParticipantOverride(ctx, call.ID, 101, 102, domain.GroupCallParticipantOverride{MutedByYou: true, Volume: 5000}, false); err != nil { + t.Fatalf("set override: %v", err) + } + after, _, err := st.GetGroupCall(ctx, call.ID) + if err != nil || after.Version != before.Version { + t.Fatalf("override must not bump version: %d → %d err=%v", before.Version, after.Version, err) + } + ov, found, err := st.GetParticipantOverride(ctx, call.ID, 101, 102) + if err != nil || !found || !ov.MutedByYou || ov.Volume != 5000 { + t.Fatalf("override = %+v found=%v err=%v", ov, found, err) + } + // 方向性:setter↔target 不可互换。 + if _, found, _ := st.GetParticipantOverride(ctx, call.ID, 102, 101); found { + t.Fatalf("override must be directional") + } + // 清除。 + if err := st.SetParticipantOverride(ctx, call.ID, 101, 102, domain.GroupCallParticipantOverride{}, true); err != nil { + t.Fatalf("clear override: %v", err) + } + if _, found, _ := st.GetParticipantOverride(ctx, call.ID, 101, 102); found { + t.Fatalf("override must be cleared") + } +} + +func contractRaiseHand(t *testing.T, factory GroupCallStoreFactory) { + st, channelID := factory(t) + ctx := context.Background() + call := newContractCall(t, st, channelID, channelID*100+1) + prev := int64(0) + for i := 0; i < 5; i++ { + rating, err := st.NextRaiseHandRating(ctx, call.ID) + if err != nil { + t.Fatalf("next rating: %v", err) + } + if rating <= prev { + t.Fatalf("rating %d not monotonic after %d(举手排序依赖单调)", rating, prev) + } + prev = rating + } +} diff --git a/internal/store/story.go b/internal/store/story.go new file mode 100644 index 00000000..4312c509 --- /dev/null +++ b/internal/store/story.go @@ -0,0 +1,38 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// StoryStore persists story snapshots and per-viewer story state. +type StoryStore interface { + CreateStory(ctx context.Context, req domain.StoryCreateRequest) (domain.StoryCreateResult, error) + UpsertStory(ctx context.Context, req domain.UpsertStoryRequest) (domain.Story, error) + ListActiveStories(ctx context.Context, viewerUserID int64, hidden bool, now, limit int) (domain.StoryList, error) + ListActiveStoriesPage(ctx context.Context, viewerUserID int64, hidden bool, now int, cursor domain.StoryListCursor, limit int) (domain.StoryList, error) + ActiveStoriesDigest(ctx context.Context, viewerUserID int64, hidden bool, now int) (domain.StoryListDigest, error) + ListOwnerActiveStories(ctx context.Context, owner domain.Peer, now, limit int) (domain.StoryList, error) + GetPeerStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (domain.PeerStories, error) + GetStoriesByID(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, now int) (domain.StoryList, error) + ListPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) + HasPinnedStories(ctx context.Context, viewerUserID int64, peer domain.Peer, now int) (bool, error) + ListStoriesArchive(ctx context.Context, viewerUserID int64, peer domain.Peer, offsetID, limit, now int) (domain.StoryList, error) + ListReadStates(ctx context.Context, viewerUserID int64) ([]domain.StoryReadState, error) + GetPeerMaxIDs(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.RecentStory, error) + GetPeerHiddenStates(ctx context.Context, viewerUserID int64, peers []domain.Peer) (map[domain.Peer]bool, error) + GetPeerStoryProjections(ctx context.Context, viewerUserID int64, peers []domain.Peer, now int) ([]domain.PeerStoryProjection, error) + MarkRead(ctx context.Context, viewerUserID int64, peer domain.Peer, maxID, date int) (domain.StoryReadResult, error) + IncrementViews(ctx context.Context, viewerUserID int64, peer domain.Peer, ids []int, date int) (int, error) + SetReaction(ctx context.Context, viewerUserID int64, peer domain.Peer, storyID int, reaction *domain.MessageReaction, date int) (domain.StoryReactionResult, error) + ListStoryViews(ctx context.Context, req domain.StoryViewListRequest) (domain.StoryViewList, error) + ListStoryReactions(ctx context.Context, req domain.StoryReactionListRequest) (domain.StoryReactionList, error) + ListStoryPublicForwards(ctx context.Context, req domain.StoryPublicForwardListRequest) (domain.StoryPublicForwardList, error) + ListStoryViewerIDs(ctx context.Context, owner domain.Peer, storyID, limit int) ([]int64, error) + EditStory(ctx context.Context, req domain.StoryEditRequest) (domain.StoryEditResult, error) + DeleteStories(ctx context.Context, peer domain.Peer, ids []int, date int) (domain.StoryMutationResult, error) + TogglePinned(ctx context.Context, peer domain.Peer, ids []int, pinned bool, date int) (domain.StoryMutationResult, error) + TogglePinnedToTop(ctx context.Context, peer domain.Peer, ids []int) error + SetPeerHidden(ctx context.Context, viewerUserID int64, peer domain.Peer, hidden bool) error +} diff --git a/internal/store/temp_auth_key.go b/internal/store/temp_auth_key.go index ae195866..14fd14b0 100644 --- a/internal/store/temp_auth_key.go +++ b/internal/store/temp_auth_key.go @@ -10,4 +10,9 @@ import ( type TempAuthKeyBindingStore interface { Save(ctx context.Context, binding domain.TempAuthKeyBinding) error GetByTemp(ctx context.Context, tempAuthKeyID [8]byte) (domain.TempAuthKeyBinding, bool, error) + // DeleteExpired 回收过期早于 expiredBefore(unix 秒)的 temp 绑定,单次最多 limit 条, + // 返回回收数。PFS temp key 定期轮换,无回收时绑定表无界堆积。 + // postgres 实现删除 auth_keys 中的 temp key 行(绑定经 ON DELETE CASCADE 一并清除), + // 让过期 temp key 的入站帧立即失效;memory 替身仅删绑定。 + DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error) } diff --git a/internal/store/themes.go b/internal/store/themes.go new file mode 100644 index 00000000..38dc285c --- /dev/null +++ b/internal/store/themes.go @@ -0,0 +1,27 @@ +package store + +import ( + "context" + + "telesrv/internal/domain" +) + +// ThemeStore 持久化自定义云主题(account.createTheme 等)与每用户已安装/已存主题列表。 +// 读未命中返回 (zero,false,nil);更新/安装不存在的主题返回 domain.ErrThemeNotFound; +// slug 冲突返回 domain.ErrThemeSlugTaken。memory 与 postgres 实现必须行为一致。 +type ThemeStore interface { + CreateTheme(ctx context.Context, t domain.Theme) error + GetThemeByID(ctx context.Context, id int64) (domain.Theme, bool, error) + GetThemeBySlug(ctx context.Context, slug string) (domain.Theme, bool, error) + SlugExists(ctx context.Context, slug string) (bool, error) + UpdateTheme(ctx context.Context, t domain.Theme) error + IncrementInstalls(ctx context.Context, id int64) error + // SetInstalled 把 themeID 加入 userID 的已安装/已存列表(upsert,更新 dark)。 + SetInstalled(ctx context.Context, userID, themeID int64, dark bool) error + // RemoveInstalled 从列表移除(saveTheme unsave)。不存在不报错。 + RemoveInstalled(ctx context.Context, userID, themeID int64) error + ListInstalledByUser(ctx context.Context, userID int64) ([]domain.Theme, error) + // ListThemesForUser 返回用户「创建 ∪ 安装」的全部主题(去重),供 getThemes 跨设备同步。 + // 按 created_at,id 升序(memory 与 postgres 必须一致)。 + ListThemesForUser(ctx context.Context, userID int64) ([]domain.Theme, error) +} diff --git a/internal/store/update_event.go b/internal/store/update_event.go index 502584ad..1532343b 100644 --- a/internal/store/update_event.go +++ b/internal/store/update_event.go @@ -9,14 +9,11 @@ import ( // UpdateEventStore 持久化 user 维度的增量事件。 type UpdateEventStore interface { Append(ctx context.Context, userID int64, event domain.UpdateEvent) error + AppendAllocated(ctx context.Context, userID int64, event domain.UpdateEvent) (domain.UpdateEvent, error) ListAfter(ctx context.Context, userID int64, pts, limit int) ([]domain.UpdateEvent, error) - Current(ctx context.Context, userID int64) (int, error) - // MaxContiguousPts 返回从 1 起无空洞的最大已提交 pts。 - // 并发发送在途时(pts 已分配未提交)会暂时小于 Current(最大已提交),空洞提交/补洞后回升。 - // getState/getDifference 据此只暴露连续 pts,避免客户端跳过在途空洞而丢消息。 + // MaxContiguousPts 返回账号已提交 pts 水位。PG 实现把 pts 分配与事件写入放在 + // 同一事务边界内,因此该水位就是 durable log 的连续水位。 MaxContiguousPts(ctx context.Context, userID int64) (int, error) - // AdvanceContiguousPts 推进并返回账号级连续 pts 水位。实现应在写事件的同一事务内调用。 - AdvanceContiguousPts(ctx context.Context, userID int64) (int, error) } // EventCursor 精确定位单条账号事件,用于 outbox worker 批量加载已 claim 事件。 diff --git a/internal/store/user.go b/internal/store/user.go index fff88664..fe2eabfa 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -19,4 +19,28 @@ type UserStore interface { UpdateLastSeen(ctx context.Context, userID int64, lastSeenAt int) error // Create 创建用户并返回分配了 ID 的副本。 Create(ctx context.Context, u domain.User) (domain.User, error) + // SetPremiumUntil 把会员到期时间设为绝对 Unix 秒(0 = 清除会员)。 + // 续期累加语义由 app 层先读后算,这里只落绝对值。 + SetPremiumUntil(ctx context.Context, userID int64, until int) (domain.User, error) + // SetVerified 设置/取消用户认证标记。认证是用户基础事实,读取投影统一下发。 + SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error) + // SweepExpiredPremium 把到期(premium_expires_at <= now)的会员行清空并 + // 返回清理后的用户(供推送 updateUser);单次最多处理 limit 行。 + SweepExpiredPremium(ctx context.Context, now int64, limit int) ([]domain.User, error) + // UpdateEmojiStatus 更新用户自定义 emoji status(documentID=0 表示清除, + // until=0 表示永久)。 + UpdateEmojiStatus(ctx context.Context, userID int64, documentID int64, until int) (domain.User, error) + UpdateColor(ctx context.Context, userID int64, forProfile bool, color domain.PeerColor) (domain.User, error) + // UpdateBirthday 更新用户生日(零值 Birthday 表示清除)。 + UpdateBirthday(ctx context.Context, userID int64, birthday domain.Birthday) (domain.User, error) + // UpdatePersonalChannel 设置/清除资料页个人频道(channelID=0 表示清除)。 + UpdatePersonalChannel(ctx context.Context, userID int64, channelID int64) (domain.User, error) +} + +// UserCache 缓存 viewer 无关的 users 表基础资料。 +// 联系人备注、隐私裁剪、头像选择和 presence 不应写入该缓存。 +type UserCache interface { + GetByIDs(ctx context.Context, ids []int64) (map[int64]domain.User, error) + PutMany(ctx context.Context, users []domain.User) error + Delete(ctx context.Context, ids []int64) error } diff --git a/internal/testkit/doc.go b/internal/testkit/doc.go deleted file mode 100644 index abe31e86..00000000 --- a/internal/testkit/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package testkit 提供协议与业务测试工具:协议 spike 的集成测试辅助、 -// 驱动客户端调用首批 RPC 的脚手架等。 -package testkit diff --git a/internal/turnsrv/turnsrv.go b/internal/turnsrv/turnsrv.go new file mode 100644 index 00000000..956cf617 --- /dev/null +++ b/internal/turnsrv/turnsrv.go @@ -0,0 +1,163 @@ +// Package turnsrv 是私聊通话 P3 的内嵌 TURN/STUN 服务(pion/turn/v5)。 +// +// 角色:私聊媒体面是客户端间 P2P(tgcalls/WebRTC),服务端只在 NAT 穿透失败时 +// 充当中继。本包对外只暴露三件事:服务可达地址(写进 phoneCall.connections 的 +// phoneConnectionWebrtc 条目)、按通话签发的临时凭据、运行开关。 +// +// 凭据采用 TURN REST 规范(draft-uberti-behave-turn-rest-00,coturn 同款): +// username = ":<标识>",password = base64(HMAC-SHA1(secret, username))。 +// 这使得未来切换到外部 coturn 时只需共享 secret,信令侧零改动。 +package turnsrv + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "time" + + "github.com/pion/logging" + "github.com/pion/turn/v5" + "go.uber.org/zap" +) + +// Config 是内嵌 TURN 的运行参数。 +type Config struct { + // UDPPort 是 TURN/STUN 监听端口(独立于 SFU 端口:两者都要独占消费各自 + // socket 上的 STUN 流量,不能共用)。Windows 防火墙需放行。 + UDPPort int + // AdvertiseIP 是写进 phoneConnectionWebrtc 与 relay 分配地址的客户端可达 + // 地址。⚠ 127.0.0.1 时真机拿到的 relay candidate 不可达(媒体面静默失败)。 + AdvertiseIP string + // Realm 是 TURN long-term credential 的 realm(任意稳定串即可)。 + Realm string + // SharedSecret 是 REST 凭据的 HMAC 密钥;为空则进程级随机生成 + // (单实例自洽;多实例/外部 coturn 必须显式配置同一值)。 + SharedSecret string + // RelayMinPort/RelayMaxPort 限定 relay 分配的 UDP 端口段(防火墙放行范围)。 + RelayMinPort int + RelayMaxPort int + // CredentialTTL 是签发凭据的有效期。⚠ 必须 ≥ 单次通话时长上限:libwebrtc + // TurnPort 的 allocation refresh 复用初始凭据,凭据过期会让进行中的中继 + // 通话断流(TDesktop 读码结论),缺省 24h。 + CredentialTTL time.Duration + Logger *zap.Logger +} + +// Service 是信令层可见的 TURN 边界。 +type Service interface { + // Enabled 报告中继是否真实可用(false 时 connections 只能下发空列表, + // 行为退回 P1 的 LAN 直连)。 + Enabled() bool + // Credentials 为一通通话签发临时凭据(user 任意标识,惯例用 callID)。 + Credentials(user string) (username, password string, err error) + // IP/Port 返回客户端可达的服务地址。 + IP() string + Port() int + Close() error +} + +// Disabled 返回未启用实现。 +func Disabled() Service { return disabled{} } + +type disabled struct{} + +func (disabled) Enabled() bool { return false } +func (disabled) Credentials(string) (string, string, error) { + return "", "", fmt.Errorf("turnsrv: disabled") +} +func (disabled) IP() string { return "" } +func (disabled) Port() int { return 0 } +func (disabled) Close() error { return nil } + +type pionTURN struct { + cfg Config + server *turn.Server +} + +// New 启动内嵌 TURN:绑定 UDP 端口、装配 REST 凭据校验与 relay 端口段。 +func New(cfg Config) (Service, error) { + if cfg.UDPPort <= 0 { + return nil, fmt.Errorf("turnsrv: invalid udp port %d", cfg.UDPPort) + } + if cfg.AdvertiseIP == "" { + cfg.AdvertiseIP = "127.0.0.1" + } + if cfg.Realm == "" { + cfg.Realm = "telesrv" + } + if cfg.SharedSecret == "" { + secret, err := randomSecret() + if err != nil { + return nil, err + } + cfg.SharedSecret = secret + } + if cfg.RelayMinPort <= 0 || cfg.RelayMaxPort < cfg.RelayMinPort { + return nil, fmt.Errorf("turnsrv: invalid relay port range [%d,%d]", cfg.RelayMinPort, cfg.RelayMaxPort) + } + if cfg.CredentialTTL <= 0 { + cfg.CredentialTTL = 24 * time.Hour + } + if cfg.Logger == nil { + cfg.Logger = zap.NewNop() + } + relayIP := net.ParseIP(cfg.AdvertiseIP) + if relayIP == nil { + return nil, fmt.Errorf("turnsrv: invalid advertise ip %q", cfg.AdvertiseIP) + } + conn, err := net.ListenPacket("udp4", fmt.Sprintf("0.0.0.0:%d", cfg.UDPPort)) + if err != nil { + return nil, fmt.Errorf("turnsrv: listen udp %d: %w", cfg.UDPPort, err) + } + pionLog := logging.NewDefaultLoggerFactory() + server, err := turn.NewServer(turn.ServerConfig{ + Realm: cfg.Realm, + LoggerFactory: pionLog, + AuthHandler: turn.LongTermTURNRESTAuthHandler(cfg.SharedSecret, pionLog.NewLogger("turn-auth")), + PacketConnConfigs: []turn.PacketConnConfig{{ + PacketConn: conn, + RelayAddressGenerator: &turn.RelayAddressGeneratorPortRange{ + RelayAddress: relayIP, + MinPort: uint16(cfg.RelayMinPort), + MaxPort: uint16(cfg.RelayMaxPort), + Address: "0.0.0.0", + }, + }}, + }) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("turnsrv: new server: %w", err) + } + cfg.Logger.Info("turn listening", + zap.Int("udp_port", cfg.UDPPort), + zap.String("advertise_ip", cfg.AdvertiseIP), + zap.Int("relay_min_port", cfg.RelayMinPort), + zap.Int("relay_max_port", cfg.RelayMaxPort)) + if cfg.AdvertiseIP == "127.0.0.1" { + cfg.Logger.Warn("TELESRV_TURN_ADVERTISE_IP 为 127.0.0.1:真机拿到的 relay candidate 不可达(跨网媒体面静默失败),跨设备通话必须设为客户端可达 IP") + } + return &pionTURN{cfg: cfg, server: server}, nil +} + +func (t *pionTURN) Enabled() bool { return true } + +func (t *pionTURN) Credentials(user string) (string, string, error) { + username, password, err := turn.GenerateLongTermTURNRESTCredentials(t.cfg.SharedSecret, user, t.cfg.CredentialTTL) + if err != nil { + return "", "", fmt.Errorf("turnsrv: generate credentials: %w", err) + } + return username, password, nil +} + +func (t *pionTURN) IP() string { return t.cfg.AdvertiseIP } +func (t *pionTURN) Port() int { return t.cfg.UDPPort } +func (t *pionTURN) Close() error { return t.server.Close() } + +func randomSecret() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("turnsrv: random secret: %w", err) + } + return hex.EncodeToString(buf), nil +} diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go new file mode 100644 index 00000000..a219a4e3 --- /dev/null +++ b/internal/webauthn/webauthn.go @@ -0,0 +1,418 @@ +// Package webauthn 实现 telesrv passkey 登录所需的 WebAuthn/FIDO2 服务端原语: +// 生成注册/断言挑战选项、验证注册 attestation、验证登录 assertion。 +// +// 它只处理字节与 JSON,不依赖 tg/domain/store——便于独立单测(配套 +// internal/webauthn/webauthntest 的软件 authenticator)。MTProto 场景下,客户端把 +// WebAuthn 各字段 base64url 解码后塞进 TL,故这里直接收原始字节(clientDataJSON、 +// authenticatorData、signature、attestationObject、credentialID)。 +// +// 支持算法:ES256(ECDSA P-256,平台 passkey 主流)与 EdDSA(Ed25519)。 +package webauthn + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math/big" + + "github.com/fxamacker/cbor/v2" +) + +// 验证错误。调用方据此映射到 TL 错误码/拒绝登录。 +var ( + ErrClientDataInvalid = errors.New("webauthn: client data invalid") + ErrChallengeMismatch = errors.New("webauthn: challenge mismatch") + ErrOriginNotAllowed = errors.New("webauthn: origin not allowed") + ErrRPIDMismatch = errors.New("webauthn: rp id hash mismatch") + ErrUserNotPresent = errors.New("webauthn: user-present flag not set") + ErrAuthDataInvalid = errors.New("webauthn: authenticator data invalid") + ErrAttestationInvalid = errors.New("webauthn: attestation object invalid") + ErrPublicKeyUnsupported = errors.New("webauthn: unsupported public key algorithm") + ErrSignatureInvalid = errors.New("webauthn: signature invalid") + ErrCounterRegressed = errors.New("webauthn: sign counter regressed (possible cloned authenticator)") +) + +// authenticatorData 标志位(WebAuthn §6.1)。 +const ( + flagUserPresent = 0x01 + flagUserVerified = 0x04 + flagAttestedCredData = 0x40 + flagExtensionData = 0x80 +) + +// COSE algorithm identifiers。 +const ( + coseAlgES256 = -7 + coseAlgEdDSA = -8 +) + +// COSE key type / curve。 +const ( + coseKtyOKP = 1 + coseKtyEC2 = 2 + coseCrvP256 = 1 + coseCrvEd25519 = 6 +) + +// b64 是 WebAuthn 用的 base64url 无填充编码。 +var b64 = base64.RawURLEncoding + +// decodeB64URL 容忍有/无填充的 base64url。 +func decodeB64URL(s string) ([]byte, error) { + if out, err := base64.RawURLEncoding.DecodeString(s); err == nil { + return out, nil + } + return base64.URLEncoding.DecodeString(s) +} + +// RegistrationParams 是构造 publicKey creation options 所需的输入。 +type RegistrationParams struct { + RPID string + RPName string + UserID []byte // 通常是 "dcId:userId" 的字节 + UserName string + UserDisplay string + Challenge []byte + TimeoutMillis int + ExcludeIDs [][]byte // 已有凭据 id,避免同一 authenticator 重复注册 +} + +// BuildRegistrationOptions 返回 account.passkeyRegistrationOptions 的 DataJSON 内容 +// (顶层含 publicKey 字段,与 DrKLO PasskeysController 读取约定一致)。 +func BuildRegistrationOptions(p RegistrationParams) ([]byte, error) { + if p.RPID == "" || len(p.Challenge) == 0 || len(p.UserID) == 0 { + return nil, errors.New("webauthn: incomplete registration params") + } + timeout := p.TimeoutMillis + if timeout == 0 { + timeout = 120000 + } + exclude := make([]map[string]any, 0, len(p.ExcludeIDs)) + for _, id := range p.ExcludeIDs { + exclude = append(exclude, map[string]any{"type": "public-key", "id": b64.EncodeToString(id)}) + } + pub := map[string]any{ + "rp": map[string]any{"id": p.RPID, "name": orDefault(p.RPName, "Telegram")}, + "user": map[string]any{"id": b64.EncodeToString(p.UserID), "name": p.UserName, "displayName": orDefault(p.UserDisplay, p.UserName)}, + "challenge": b64.EncodeToString(p.Challenge), + "pubKeyCredParams": []map[string]any{ + {"type": "public-key", "alg": coseAlgES256}, + {"type": "public-key", "alg": coseAlgEdDSA}, + }, + "timeout": timeout, + "attestation": "none", + "authenticatorSelection": map[string]any{ + "residentKey": "required", + "userVerification": "preferred", + }, + "excludeCredentials": exclude, + } + return json.Marshal(map[string]any{"publicKey": pub}) +} + +// LoginParams 是构造 publicKey request options(断言挑战)的输入。 +type LoginParams struct { + RPID string + Challenge []byte + TimeoutMillis int +} + +// BuildLoginOptions 返回 auth.passkeyLoginOptions 的 DataJSON 内容。无 allowCredentials +// (discoverable/usernameless 登录,用户由 user_handle 反查)。 +func BuildLoginOptions(p LoginParams) ([]byte, error) { + if p.RPID == "" || len(p.Challenge) == 0 { + return nil, errors.New("webauthn: incomplete login params") + } + timeout := p.TimeoutMillis + if timeout == 0 { + timeout = 120000 + } + pub := map[string]any{ + "challenge": b64.EncodeToString(p.Challenge), + "timeout": timeout, + "rpId": p.RPID, + "userVerification": "preferred", + "allowCredentials": []any{}, + } + return json.Marshal(map[string]any{"publicKey": pub}) +} + +// clientData 是 clientDataJSON 的解析结构。 +type clientData struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` +} + +// verifyClientData 校验 type/challenge/origin。allowedOrigins 为空表示不强校验 origin +// (dev:服务端通常不预知 Android apk-key-hash origin)。 +func verifyClientData(clientDataJSON []byte, wantType string, challenge []byte, allowedOrigins []string) error { + var cd clientData + if err := json.Unmarshal(clientDataJSON, &cd); err != nil { + return ErrClientDataInvalid + } + if cd.Type != wantType { + return fmt.Errorf("%w: type=%q want %q", ErrClientDataInvalid, cd.Type, wantType) + } + got, err := decodeB64URL(cd.Challenge) + if err != nil { + return ErrClientDataInvalid + } + if subtle.ConstantTimeCompare(got, challenge) != 1 { + return ErrChallengeMismatch + } + if len(allowedOrigins) > 0 { + ok := false + for _, o := range allowedOrigins { + if o == cd.Origin { + ok = true + break + } + } + if !ok { + return fmt.Errorf("%w: %q", ErrOriginNotAllowed, cd.Origin) + } + } + return nil +} + +// ChallengeFromClientData 提取 clientDataJSON 里的 challenge 原始字节,供调用方按挑战 +// 反查/消费一次性挑战 store(此时尚未验证,仅做查表键)。 +func ChallengeFromClientData(clientDataJSON []byte) ([]byte, error) { + var cd clientData + if err := json.Unmarshal(clientDataJSON, &cd); err != nil { + return nil, ErrClientDataInvalid + } + b, err := decodeB64URL(cd.Challenge) + if err != nil || len(b) == 0 { + return nil, ErrClientDataInvalid + } + return b, nil +} + +// parsedAuthData 是 authenticatorData 的解析结果。 +type parsedAuthData struct { + rpIDHash []byte + flags byte + signCount uint32 + aaguid []byte + credID []byte + credPubKey []byte // COSE 公钥原始字节(仅注册时存在) +} + +func parseAuthData(authData []byte) (parsedAuthData, error) { + if len(authData) < 37 { + return parsedAuthData{}, ErrAuthDataInvalid + } + out := parsedAuthData{ + rpIDHash: authData[0:32], + flags: authData[32], + signCount: binary.BigEndian.Uint32(authData[33:37]), + } + rest := authData[37:] + if out.flags&flagAttestedCredData != 0 { + if len(rest) < 18 { + return parsedAuthData{}, ErrAuthDataInvalid + } + out.aaguid = rest[0:16] + credIDLen := int(binary.BigEndian.Uint16(rest[16:18])) + rest = rest[18:] + if len(rest) < credIDLen { + return parsedAuthData{}, ErrAuthDataInvalid + } + out.credID = rest[:credIDLen] + rest = rest[credIDLen:] + // 之后是 COSE 公钥(变长 CBOR);用 UnmarshalFirst 只吃一个 CBOR item。 + var raw cbor.RawMessage + remaining, err := cbor.UnmarshalFirst(rest, &raw) + if err != nil { + return parsedAuthData{}, ErrAuthDataInvalid + } + out.credPubKey = rest[:len(rest)-len(remaining)] + } + return out, nil +} + +func rpIDHashFor(rpID string) []byte { + h := sha256.Sum256([]byte(rpID)) + return h[:] +} + +// Credential 是注册验证产出的凭据材料,调用方据此持久化。 +type Credential struct { + ID []byte // credential id 原始字节 + COSEPublicKey []byte // COSE 公钥原始字节 + SignCount uint32 + AAGUID []byte +} + +// VerifyRegistrationInput 是注册验证输入。 +type VerifyRegistrationInput struct { + ClientDataJSON []byte + AttestationObject []byte + RPID string + Challenge []byte + AllowedOrigins []string +} + +type attestationObject struct { + Fmt string `cbor:"fmt"` + AuthData []byte `cbor:"authData"` + AttStmt cbor.RawMessage `cbor:"attStmt"` +} + +// VerifyRegistration 校验 attestation 并提取凭据公钥。attestation 格式仅要求能取出 +// 公钥(消费级 passkey 一般是 "none",不校验 attestation statement 签名)。 +func VerifyRegistration(in VerifyRegistrationInput) (*Credential, error) { + if err := verifyClientData(in.ClientDataJSON, "webauthn.create", in.Challenge, in.AllowedOrigins); err != nil { + return nil, err + } + var att attestationObject + if err := cbor.Unmarshal(in.AttestationObject, &att); err != nil { + return nil, ErrAttestationInvalid + } + ad, err := parseAuthData(att.AuthData) + if err != nil { + return nil, err + } + if subtle.ConstantTimeCompare(ad.rpIDHash, rpIDHashFor(in.RPID)) != 1 { + return nil, ErrRPIDMismatch + } + if ad.flags&flagUserPresent == 0 { + return nil, ErrUserNotPresent + } + if ad.flags&flagAttestedCredData == 0 || len(ad.credID) == 0 || len(ad.credPubKey) == 0 { + return nil, ErrAttestationInvalid + } + // 公钥能解析即认为有效(none attestation)。 + if _, err := parseCOSEKey(ad.credPubKey); err != nil { + return nil, err + } + return &Credential{ + ID: append([]byte(nil), ad.credID...), + COSEPublicKey: append([]byte(nil), ad.credPubKey...), + SignCount: ad.signCount, + AAGUID: append([]byte(nil), ad.aaguid...), + }, nil +} + +// VerifyAssertionInput 是登录断言验证输入。 +type VerifyAssertionInput struct { + COSEPublicKey []byte + ClientDataJSON []byte + AuthenticatorData []byte + Signature []byte + RPID string + Challenge []byte + AllowedOrigins []string + StoredSignCount uint32 +} + +// VerifyAssertion 校验登录断言,成功返回新的签名计数器(供持久化)。 +func VerifyAssertion(in VerifyAssertionInput) (uint32, error) { + if err := verifyClientData(in.ClientDataJSON, "webauthn.get", in.Challenge, in.AllowedOrigins); err != nil { + return 0, err + } + ad, err := parseAuthData(in.AuthenticatorData) + if err != nil { + return 0, err + } + if subtle.ConstantTimeCompare(ad.rpIDHash, rpIDHashFor(in.RPID)) != 1 { + return 0, ErrRPIDMismatch + } + if ad.flags&flagUserPresent == 0 { + return 0, ErrUserNotPresent + } + pub, err := parseCOSEKey(in.COSEPublicKey) + if err != nil { + return 0, err + } + // 签名覆盖 authenticatorData || sha256(clientDataJSON)。 + cdHash := sha256.Sum256(in.ClientDataJSON) + signed := make([]byte, 0, len(in.AuthenticatorData)+len(cdHash)) + signed = append(signed, in.AuthenticatorData...) + signed = append(signed, cdHash[:]...) + if err := pub.verify(signed, in.Signature); err != nil { + return 0, err + } + // 计数器回退检测:仅当双方都非 0 时严格(passkey 常报 0)。 + if ad.signCount != 0 && in.StoredSignCount != 0 && ad.signCount <= in.StoredSignCount { + return 0, ErrCounterRegressed + } + return ad.signCount, nil +} + +// publicKey 抽象 ES256/EdDSA 验签。 +type publicKey struct { + ec *ecdsa.PublicKey + ed ed25519.PublicKey +} + +func (p publicKey) verify(signed, sig []byte) error { + switch { + case p.ec != nil: + digest := sha256.Sum256(signed) + if !ecdsa.VerifyASN1(p.ec, digest[:], sig) { + return ErrSignatureInvalid + } + return nil + case p.ed != nil: + if !ed25519.Verify(p.ed, signed, sig) { + return ErrSignatureInvalid + } + return nil + default: + return ErrPublicKeyUnsupported + } +} + +type coseKey struct { + Kty int `cbor:"1,keyasint"` + Alg int `cbor:"3,keyasint"` + Crv int `cbor:"-1,keyasint"` + X []byte `cbor:"-2,keyasint"` + Y []byte `cbor:"-3,keyasint"` +} + +func parseCOSEKey(raw []byte) (publicKey, error) { + var k coseKey + if err := cbor.Unmarshal(raw, &k); err != nil { + return publicKey{}, ErrPublicKeyUnsupported + } + switch k.Kty { + case coseKtyEC2: + if k.Alg != coseAlgES256 || k.Crv != coseCrvP256 || len(k.X) != 32 || len(k.Y) != 32 { + return publicKey{}, ErrPublicKeyUnsupported + } + pub := &ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(k.X), + Y: new(big.Int).SetBytes(k.Y), + } + if !pub.Curve.IsOnCurve(pub.X, pub.Y) { + return publicKey{}, ErrPublicKeyUnsupported + } + return publicKey{ec: pub}, nil + case coseKtyOKP: + if k.Alg != coseAlgEdDSA || k.Crv != coseCrvEd25519 || len(k.X) != ed25519.PublicKeySize { + return publicKey{}, ErrPublicKeyUnsupported + } + return publicKey{ed: ed25519.PublicKey(append([]byte(nil), k.X...))}, nil + default: + return publicKey{}, ErrPublicKeyUnsupported + } +} + +func orDefault(s, def string) string { + if s == "" { + return def + } + return s +} diff --git a/internal/webauthn/webauthn_test.go b/internal/webauthn/webauthn_test.go new file mode 100644 index 00000000..3112620c --- /dev/null +++ b/internal/webauthn/webauthn_test.go @@ -0,0 +1,175 @@ +package webauthn_test + +import ( + "bytes" + "crypto/rand" + "errors" + "testing" + + "telesrv/internal/webauthn" + "telesrv/internal/webauthn/webauthntest" +) + +const ( + testRPID = "telesrv.test" + testOrigin = "android:apk-key-hash:Zm9vYmFy" +) + +func randChallenge(t *testing.T) []byte { + t.Helper() + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + t.Fatalf("rand: %v", err) + } + return b +} + +func registerAuth(t *testing.T) (*webauthntest.Authenticator, *webauthn.Credential) { + t.Helper() + auth, err := webauthntest.New() + if err != nil { + t.Fatalf("new authenticator: %v", err) + } + ch := randChallenge(t) + cd, att, err := auth.Register(testRPID, testOrigin, ch) + if err != nil { + t.Fatalf("register: %v", err) + } + cred, err := webauthn.VerifyRegistration(webauthn.VerifyRegistrationInput{ + ClientDataJSON: cd, AttestationObject: att, RPID: testRPID, Challenge: ch, + }) + if err != nil { + t.Fatalf("VerifyRegistration: %v", err) + } + if !bytes.Equal(cred.ID, auth.CredentialID()) { + t.Fatalf("credential id mismatch: got %x want %x", cred.ID, auth.CredentialID()) + } + if len(cred.COSEPublicKey) == 0 { + t.Fatal("empty COSE public key") + } + return auth, cred +} + +func TestRegisterThenAssert(t *testing.T) { + auth, cred := registerAuth(t) + ch := randChallenge(t) + cd, ad, sig, err := auth.Assert(testRPID, testOrigin, ch) + if err != nil { + t.Fatalf("assert: %v", err) + } + count, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{ + COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig, + RPID: testRPID, Challenge: ch, StoredSignCount: cred.SignCount, + }) + if err != nil { + t.Fatalf("VerifyAssertion: %v", err) + } + if count != 1 { + t.Fatalf("sign count = %d, want 1", count) + } +} + +func TestAssertCounterRegressionRejected(t *testing.T) { + auth, cred := registerAuth(t) + ch := randChallenge(t) + cd, ad, sig, _ := auth.Assert(testRPID, testOrigin, ch) // signCount -> 1 + if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{ + COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig, + RPID: testRPID, Challenge: ch, StoredSignCount: 5, // 已存计数器更大 => 克隆嫌疑 + }); !errors.Is(err, webauthn.ErrCounterRegressed) { + t.Fatalf("err = %v, want ErrCounterRegressed", err) + } +} + +func TestAssertChallengeMismatch(t *testing.T) { + auth, cred := registerAuth(t) + ch := randChallenge(t) + cd, ad, sig, _ := auth.Assert(testRPID, testOrigin, ch) + if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{ + COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig, + RPID: testRPID, Challenge: randChallenge(t), StoredSignCount: cred.SignCount, + }); !errors.Is(err, webauthn.ErrChallengeMismatch) { + t.Fatalf("err = %v, want ErrChallengeMismatch", err) + } +} + +func TestAssertSignatureTamperRejected(t *testing.T) { + auth, cred := registerAuth(t) + ch := randChallenge(t) + cd, ad, sig, _ := auth.Assert(testRPID, testOrigin, ch) + sig[len(sig)-1] ^= 0xFF + if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{ + COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig, + RPID: testRPID, Challenge: ch, StoredSignCount: cred.SignCount, + }); !errors.Is(err, webauthn.ErrSignatureInvalid) { + t.Fatalf("err = %v, want ErrSignatureInvalid", err) + } +} + +func TestAssertWrongRPIDRejected(t *testing.T) { + auth, cred := registerAuth(t) + ch := randChallenge(t) + cd, ad, sig, _ := auth.Assert("other.rp", testOrigin, ch) + if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{ + COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig, + RPID: testRPID, Challenge: ch, StoredSignCount: cred.SignCount, + }); !errors.Is(err, webauthn.ErrRPIDMismatch) { + t.Fatalf("err = %v, want ErrRPIDMismatch", err) + } +} + +func TestRegistrationWrongRPIDRejected(t *testing.T) { + auth, _ := func() (*webauthntest.Authenticator, *webauthn.Credential) { + a, err := webauthntest.New() + if err != nil { + t.Fatalf("new: %v", err) + } + return a, nil + }() + ch := randChallenge(t) + cd, att, _ := auth.Register("attacker.rp", testOrigin, ch) + if _, err := webauthn.VerifyRegistration(webauthn.VerifyRegistrationInput{ + ClientDataJSON: cd, AttestationObject: att, RPID: testRPID, Challenge: ch, + }); !errors.Is(err, webauthn.ErrRPIDMismatch) { + t.Fatalf("err = %v, want ErrRPIDMismatch", err) + } +} + +func TestOriginEnforcement(t *testing.T) { + auth, cred := registerAuth(t) + ch := randChallenge(t) + cd, ad, sig, _ := auth.Assert(testRPID, testOrigin, ch) + // 允许列表匹配 => 通过。 + if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{ + COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig, + RPID: testRPID, Challenge: ch, AllowedOrigins: []string{testOrigin}, StoredSignCount: cred.SignCount, + }); err != nil { + t.Fatalf("matching origin should pass: %v", err) + } + // 允许列表不含该 origin => 拒绝。 + if _, err := webauthn.VerifyAssertion(webauthn.VerifyAssertionInput{ + COSEPublicKey: cred.COSEPublicKey, ClientDataJSON: cd, AuthenticatorData: ad, Signature: sig, + RPID: testRPID, Challenge: ch, AllowedOrigins: []string{"android:apk-key-hash:other"}, StoredSignCount: cred.SignCount, + }); !errors.Is(err, webauthn.ErrOriginNotAllowed) { + t.Fatalf("err = %v, want ErrOriginNotAllowed", err) + } +} + +func TestBuildOptionsContainPublicKey(t *testing.T) { + reg, err := webauthn.BuildRegistrationOptions(webauthn.RegistrationParams{ + RPID: testRPID, UserID: []byte("2:123"), UserName: "user", Challenge: randChallenge(t), + }) + if err != nil { + t.Fatalf("BuildRegistrationOptions: %v", err) + } + if !bytes.Contains(reg, []byte(`"publicKey"`)) || !bytes.Contains(reg, []byte(`"challenge"`)) { + t.Fatalf("registration options missing publicKey/challenge: %s", reg) + } + login, err := webauthn.BuildLoginOptions(webauthn.LoginParams{RPID: testRPID, Challenge: randChallenge(t)}) + if err != nil { + t.Fatalf("BuildLoginOptions: %v", err) + } + if !bytes.Contains(login, []byte(`"publicKey"`)) || !bytes.Contains(login, []byte(`"rpId"`)) { + t.Fatalf("login options missing publicKey/rpId: %s", login) + } +} diff --git a/internal/webauthn/webauthntest/authenticator.go b/internal/webauthn/webauthntest/authenticator.go new file mode 100644 index 00000000..a511acb9 --- /dev/null +++ b/internal/webauthn/webauthntest/authenticator.go @@ -0,0 +1,135 @@ +// Package webauthntest 提供一个纯 Go 软件 authenticator,模拟平台 passkey 设备 +// 生成符合 WebAuthn 规范的 attestation(注册)与 assertion(登录)。 +// +// 它与被测的 internal/webauthn 是「同一规范的两份独立实现」:authenticator 按规范 +// 构造字节并用真私钥签名,服务端按规范验证。e2e/单测通过即说明双方对规范的理解一致。 +// 仅供测试使用(被 _test.go 导入)。 +package webauthntest + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + + "github.com/fxamacker/cbor/v2" +) + +const ( + flagUserPresent = 0x01 + flagUserVerified = 0x04 + flagAttestedCredData = 0x40 +) + +var b64 = base64.RawURLEncoding + +// Authenticator 是一个 ES256(ECDSA P-256)软件 authenticator,持有一对密钥与一个 +// 凭据 id,可重复用于注册与多次登录(签名计数器递增)。 +type Authenticator struct { + priv *ecdsa.PrivateKey + credID []byte + aaguid []byte + signCount uint32 +} + +// New 生成一个新的软件 authenticator(随机 ES256 密钥 + 随机 credential id)。 +func New() (*Authenticator, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + credID := make([]byte, 32) + if _, err := rand.Read(credID); err != nil { + return nil, err + } + aaguid := make([]byte, 16) // 全 0 aaguid(与多数 platform passkey 一致) + return &Authenticator{priv: priv, credID: credID, aaguid: aaguid}, nil +} + +// CredentialID 返回 credential id 原始字节。 +func (a *Authenticator) CredentialID() []byte { return append([]byte(nil), a.credID...) } + +// CredentialIDB64 返回 base64url(无填充) 的 credential id(对应 TL 的 ID/RawID string)。 +func (a *Authenticator) CredentialIDB64() string { return b64.EncodeToString(a.credID) } + +type clientData struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Origin string `json:"origin"` + CrossOrigin bool `json:"crossOrigin"` +} + +func (a *Authenticator) clientDataJSON(typ, origin string, challenge []byte) []byte { + out, _ := json.Marshal(clientData{Type: typ, Challenge: b64.EncodeToString(challenge), Origin: origin}) + return out +} + +func (a *Authenticator) cosePublicKey() []byte { + x := make([]byte, 32) + y := make([]byte, 32) + a.priv.PublicKey.X.FillBytes(x) + a.priv.PublicKey.Y.FillBytes(y) + key := struct { + Kty int `cbor:"1,keyasint"` + Alg int `cbor:"3,keyasint"` + Crv int `cbor:"-1,keyasint"` + X []byte `cbor:"-2,keyasint"` + Y []byte `cbor:"-3,keyasint"` + }{Kty: 2, Alg: -7, Crv: 1, X: x, Y: y} + out, _ := cbor.Marshal(key) + return out +} + +func (a *Authenticator) authData(rpID string, flags byte, includeCred bool) []byte { + h := sha256.Sum256([]byte(rpID)) + buf := make([]byte, 0, 128) + buf = append(buf, h[:]...) + buf = append(buf, flags) + var counter [4]byte + binary.BigEndian.PutUint32(counter[:], a.signCount) + buf = append(buf, counter[:]...) + if includeCred { + buf = append(buf, a.aaguid...) + var l [2]byte + binary.BigEndian.PutUint16(l[:], uint16(len(a.credID))) + buf = append(buf, l[:]...) + buf = append(buf, a.credID...) + buf = append(buf, a.cosePublicKey()...) + } + return buf +} + +// Register 模拟 createCredential,返回 clientDataJSON 与 attestationObject(fmt=none)。 +func (a *Authenticator) Register(rpID, origin string, challenge []byte) (clientDataJSON, attestationObject []byte, err error) { + cd := a.clientDataJSON("webauthn.create", origin, challenge) + ad := a.authData(rpID, flagUserPresent|flagUserVerified|flagAttestedCredData, true) + att := struct { + Fmt string `cbor:"fmt"` + AttStmt map[string]any `cbor:"attStmt"` + AuthData []byte `cbor:"authData"` + }{Fmt: "none", AttStmt: map[string]any{}, AuthData: ad} + obj, err := cbor.Marshal(att) + if err != nil { + return nil, nil, err + } + return cd, obj, nil +} + +// Assert 模拟 getCredential,返回 clientDataJSON、authenticatorData 与 signature。 +// 每次调用签名计数器 +1。 +func (a *Authenticator) Assert(rpID, origin string, challenge []byte) (clientDataJSON, authenticatorData, signature []byte, err error) { + a.signCount++ + cd := a.clientDataJSON("webauthn.get", origin, challenge) + ad := a.authData(rpID, flagUserPresent|flagUserVerified, false) + cdHash := sha256.Sum256(cd) + signed := append(append([]byte(nil), ad...), cdHash[:]...) + digest := sha256.Sum256(signed) + sig, err := ecdsa.SignASN1(rand.Reader, a.priv, digest[:]) + if err != nil { + return nil, nil, nil, err + } + return cd, ad, sig, nil +}